- ${Ya("topic-actions","主帖操作列","设置常用操作是否一直显示、是否锁定位置;未锁定时可长按收纳按钮拖动。",`${Xa("topic-actions")}
操作列默认位置恢复到正文左侧留白区域。
`)}
- ${Ya("replies","二级回复显示位置","设置二级回复在父回复下、楼层列表中和“完整讨论”视图中的显示方式。",`
- ${Xa("replies")}
+ ${Ja("topic-actions","主帖操作列","设置常用操作是否一直显示、是否锁定位置;未锁定时可长按收纳按钮拖动。",`${Za("topic-actions")}
操作列默认位置恢复到正文左侧留白区域。
`)}
+ ${Ja("replies","二级回复显示位置","设置二级回复在父回复下、楼层列表中和“完整讨论”视图中的显示方式。",`
+ ${Za("replies")}
+ ${f1()}
同时在父回复下和楼层列表中展开时,同一条二级回复可能出现两次。
`)}
- ${Ya("boost","复制 Boost 文本","复制结果 = 前置文字 + Boost 原文 + 末尾内容;最终结果最多 16 个字。",`
- ${JP()}
+ ${Ja("boost","复制 Boost 文本","复制结果 = 前置文字 + Boost 原文 + 末尾内容;最终结果最多 16 个字。",`
+ ${g1()}
结果预览
@@ -1039,7 +1079,7 @@ ${t.getMonth()+1} 月 ${t.getDate()} 日`:""}function yI(e,t){const n=e.querySel
- ${Ya("custom-sites","其他适用站点","添加其他 HTTPS Discourse 论坛;保存前会自动验证,不兼容的网站不会加入。",`
+ ${Ja("custom-sites","其他适用站点","添加其他 HTTPS Discourse 论坛;保存前会自动验证,不兼容的网站不会加入。",`
@@ -1051,7 +1091,7 @@ ${t.getMonth()+1} 月 ${t.getDate()} 日`:""}function yI(e,t){const n=e.querySel
- ${pn("config-management","导入与导出设置","配置文件包含当前版本的图片、字体、布局、浮窗、外观、动画、性能、阅读和交互设置,仅支持当前设置结构;不包含阅读队列、浏览历史、其他适用站点、帖子内容、缓存或账号数据。导入或恢复默认后会刷新页面。",`
+ ${fn("config-management","导入与导出设置","配置文件包含当前版本的图片、字体、布局、浮窗、外观、动画、性能、阅读和交互设置,仅支持当前设置结构;不包含阅读队列、浏览历史、其他适用站点、帖子内容、缓存或账号数据。导入或恢复默认后会刷新页面。",`
@@ -1059,12 +1099,12 @@ ${t.getMonth()+1} 月 ${t.getDate()} 日`:""}function yI(e,t){const n=e.querySel
`)}
- ${pn("local-cache","本地缓存","阅读器会在本机保存浏览历史、帖子与楼层、用户资料、通知、收藏和图片等可复用数据。清理后不会影响站点账号内容,需要时会重新联网获取。",`
+ ${fn("local-cache","本地缓存","阅读器会在本机保存浏览历史、帖子与楼层、用户资料、通知、收藏和图片等可复用数据。清理后不会影响站点账号内容,需要时会重新联网获取。",`
- ${mw.map(i=>`
@@ -1074,25 +1114,25 @@ ${t.getMonth()+1} 月 ${t.getDate()} 日`:""}function yI(e,t){const n=e.querySel
@@ -1183,7 +1223,7 @@ ${t.getMonth()+1} 月 ${t.getDate()} 日`:""}function yI(e,t){const n=e.querySel
${ue("externalLink")}Cloudflare 验证
-
${CM()}
+
${IM()}
${ue("loader","ldp-tip-icon")}正在加载楼层…
@@ -1218,10 +1258,10 @@ ${t.getMonth()+1} 月 ${t.getDate()} 日`:""}function yI(e,t){const n=e.querySel
-
`,jP(se),Tn(se),T0(),kP()),se.dataset.topicId=String(e),Ct=se,tm(),wP(se),Ja(),se._ldpReaderHistoryNavigation=te;const v=sP(se,e,te);se._ldpReaderSession=R,Lt.classList.add("ldp-reader-open"),se.dataset.targetPostNumber=a?String(a):"",se.classList.toggle("ldp-initial-target-layout",a>1);const S=Z(".ldp-modal"),I=Z(".ldp-body");Ae&&dI(se);const x=Ae?se._ldpReaderShell:{},me=x.lifecycleScope||Ho();x.lifecycleScope=me;const ie=x.surfaceManager||LP(se);x.surfaceManager=ie;const O=(i,p,h,y)=>me.listen(i,p,h,y),Xe=!!$&&!U&&fa(P).embedded;Xe&&Lt.classList.add("ldp-reader-host-anchor-restoring");const qe=x.readerWorkspace||jL(se,S,U);qe.sync(),x.syncReaderTitleActionAlignment=()=>{const i=Z(".ldp-header"),p=i?.querySelector(".ldp-title-jump"),h=i?.querySelector(":scope > .ldp-title-actions");if(!p||!h)return;h.style.setProperty("--ldp-title-actions-align-y","0px");const M=Array.from(h.children).find(L=>!L.hidden&&getComputedStyle(L).display!=="none")?.querySelector(".ldp-icon");if(!M)return;const T=p.getBoundingClientRect().top-M.getBoundingClientRect().top;h.style.setProperty("--ldp-title-actions-align-y",`${Math.round(T*2)/2}px`)},x.syncFullPageHeaderAlignment=()=>{const i=Z(".ldp-header");if(!i)return;const p=i.querySelector(":scope > .ldp-title-actions"),h=i.querySelector(":scope > .ldp-head-btns"),y=Z(".ldp-comments"),M=()=>{i.style.removeProperty("--ldp-header-logo-inset"),i.style.removeProperty("padding-left"),i.style.removeProperty("padding-right"),p?.style.removeProperty("right"),h?.classList.remove("is-content-aligned")};if(!qe.isFullPage()||!y){M(),x.syncReaderTitleActionAlignment();return}const T=S.getBoundingClientRect(),L=y.getBoundingClientRect();if(!(T.width>0)||!(L.width>0))return;const j=Math.max(0,Math.round(L.left-T.left)),q=Math.max(0,Math.round(T.right-L.right));i.style.setProperty("--ldp-header-logo-inset",`${j}px`),i.style.paddingLeft=`${j}px`,i.style.paddingRight=`${q}px`,p&&(p.style.right=`${q}px`),h?.classList.add("is-content-aligned"),x.syncReaderTitleActionAlignment()},Xe&&(qe.isEmbedded()&&NI($),me.raf(()=>Lt.classList.remove("ldp-reader-host-anchor-restoring"))),vf(qe.isFullPage()||window.innerWidth<=Ss);const ce=Z(".ldp-reader-window-capsule"),Ee=Z(".ldp-reader-placement-control"),We=Z(".ldp-reader-placement-strip"),Ie=Ge("[data-reader-placement]"),Je=Z(".ldp-comments"),st=Z(".ldp-comments-header"),pt=Z(".ldp-comments-count"),Pt=Z(".ldp-comments-empty"),Mt=Z(".ldp-loadmask"),jt=Mt?.querySelector(".ldp-loading-stage"),Nt=Mt?.querySelector(".ldp-loading-status span"),Er=Mt?.querySelector(".ldp-loading-target"),Ir=Mt?.querySelector(".ldp-loading-detail"),Pr=Z(".ldp-loading-tip"),xr=Pr?.querySelector("span"),Sr=(i,p={})=>{const h=p.target===!0&&a>1,y=Math.max(0,Number(p.cachedCount)||0),M=Math.max(0,Number(p.missingCount)||0),T=i==="cache"?{status:h?"正在读取目标楼层缓存":"正在读取帖子缓存",detail:y?`已读取 ${y} 条缓存,正在恢复楼层…`:"正在恢复已缓存楼层…"}:i==="network"?{status:h?"正在请求目标楼层":y?"正在补全帖子数据":"正在请求帖子数据",detail:y?`已读取 ${y} 条缓存,正在下载 ${M} 条缺失楼层…`:M?`正在下载 ${M} 条缺失楼层…`:"正在下载缺失楼层…"}:{status:"正在渲染帖子",detail:"正在生成页面…"};Nt&&(Nt.textContent=T.status),Ir&&(Ir.textContent=T.detail),xr&&(xr.textContent=T.detail),Er&&(Er.textContent=h?`#${a}`:""),jt&&rt(jt,`${T.status},${T.detail.replace("…","")}`)},Rr=a<=1||!!(H?.post_stream&&Array.isArray(H.post_stream.posts)&&H.post_stream.posts.some(i=>+i.post_number===a));Sr(H&&Rr?"cache":"network",{target:a>1});const wr=Z(".ldp-end-tip"),Mr=Z(".ldp-live-update"),qr=Mr.querySelector(".ldp-live-update-jump"),xt=Mr.querySelector(".ldp-live-update-dismiss"),Tr=Z(".ldp-topic-timeline"),Qr=Z(".ldp-layout-toggle"),mr=Z(".ldp-title-topic-row"),Y=Z(".ldp-title-topic-scroller");let He=0;const je=()=>{He=0;const i=Y.scrollWidth>mr.clientWidth+1;mr.classList.toggle("has-overflow",i);const p=Math.max(0,Y.scrollWidth-Y.clientWidth);mr.classList.toggle("can-scroll-left",i&&Y.scrollLeft>1),mr.classList.toggle("can-scroll-right",i&&Y.scrollLeft
{He||(He=requestAnimationFrame(je))},zt=Z(".ldp-only-op-toggle"),Ht=Z(".ldp-only-op-progress"),ir=Ht?.querySelector(".ldp-only-op-progress-value"),Zt=Z(".ldp-rate-limit-notice"),cr=Zt?.querySelector(".ldp-rate-limit-detail"),uo=Zt?.querySelector(".ldp-rate-limit-challenge"),Ce=Z(".ldp-notifications-toggle"),Le=Z(".ldp-notifications-popover"),ht=Ge(".ldp-notification-mode-tab"),St=Ge("[data-notification-mode-panel]"),lr=Ge(".ldp-notification-tab"),wt=(i,p)=>Object.fromEntries(p.map(h=>[h,Z(`.ldp-${i}-${h.replace(/[A-Z]/g,y=>`-${y.toLowerCase()}`)}`)])),de=wt("notification",["list","pagePrev","pageNext","pageInfo","search","searchClear","toolbar","unreadStatus","markAll","newMessage"]),ge=wt("history",["toggle","popover","list","pagePrev","pageNext","pageInfo","search","searchClear","defaultActions","bulkActions","sortToggle","multi","multiDone","selectScope","selectToggle","deleteSelected","deleteSelectedLabel","clear"]),Re=wt("bookmarks",["toggle","popover","list","pagePrev","pageNext","pageInfo","search","searchClear","defaultActions","bulkActions","multi","multiDone","selectScope","selectToggle","deleteSelected","deleteSelectedLabel"]),[mt,ct,Gt,Xt,k,fe,Se,Fe]=xe(".ldp-reaction-filters",".ldp-bookmark-tabs",".ldp-translate-toggle",".ldp-settings-toggle",".ldp-settings-popover",".ldp-settings-panel",".ldp-settings-close",".ldp-setting-help-tooltip"),[Be,Ze]=ze(".ldp-bookmark-tab",".ldp-settings-theme-button");let Ke=se.querySelector(":scope > .ldp-settings-layer");Ke||(Ke=ke("div"),Ke.className="ldp-settings-layer",se.append(Ke));const _t=()=>qe.isEmbedded()?Ke:S,dr=()=>{const i=_t(),p=i.getBoundingClientRect(),h=p.left+i.clientLeft,y=p.top+i.clientTop,M=i.clientWidth,T=i.clientHeight;return{left:h,top:y,width:M,height:T,right:h+M,bottom:y+T}},Kr=()=>dr().width<=Ss,ro=()=>{const i=S.clientHeight<=cw;if(S.classList.toggle("ldp-reader-surface-short",i),k.hidden)return;const p=_t(),h=p.clientHeight<=cw;S.classList.toggle("ldp-settings-surface-short",p===S&&h),Ke.classList.toggle("ldp-settings-surface-short",p===Ke&&h)};ie.mount(Le),ie.mount(ge.popover),ie.mount(Re.popover),ie.mount(k,_t()),ie.mount(Fe,_t()),ro(),kd();const[Cr,er,tr,Yr,Co,Dn,Ar,po,_,F,_e,ot,Et,Tt,rr,_r,hr,Dt,Ut,br,rn,wn,_a]=xe(".ldp-user-info-content",".ldp-user-info-last-refresh",".ldp-user-info-title-refresh",".ldp-settings-search-shell",".ldp-settings-search-input",".ldp-settings-search-clear",".ldp-settings-search-status",".ldp-settings-search-empty",".ldp-settings-draft-bar",".ldp-settings-draft-status",".ldp-settings-save-all",".ldp-image-scale-range",".ldp-image-scale-value",".ldp-image-scale-reset",".ldp-lightbox-original-by-default",".ldp-lightbox-comments-expanded",".ldp-lightbox-description-expanded",".ldp-font-family-menu",".ldp-font-family-search input",".ldp-font-family-options",".ldp-font-family-empty",".ldp-font-family-source-status",".ldp-font-family-manual button"),[Or,Io,Bs,as,kt,Kt,at]=ze(".ldp-settings-tab",".ldp-settings-section",".ldp-settings-nav-group",".ldp-settings-log-tab",".ldp-settings-log-panel",".ldp-font-family-trigger",".ldp-font-family-custom");Io.splice(0,Io.length,...nf.map(([i])=>Io.find(p=>p.dataset.settingsPanel===i))),fe.append(...Io.slice(1),_),ie.mount(Dt,_t());const[Jt,Vo,Jr,Go,Dr,Xr,on,Qo,On,Ec]=xe(".ldp-interface-font-scale-range",".ldp-interface-font-scale-value",".ldp-font-scale-range",".ldp-font-scale-value",".ldp-composer-font-scale-range",".ldp-composer-font-scale-value",".ldp-font-status",".ldp-font-reset",".ldp-font-rendering-enabled",".ldp-font-rendering-on-host"),[Ca,Hs,Sn,_n,Hr,Ea,Ma,is,di]=ze(".ldp-font-weight-select",".ldp-font-color",".ldp-font-color-value","[data-host-embed-size]","[data-host-embed-size-value]","[data-host-embed-size-reset]","[data-host-font-reset],[data-font-field-reset]","[data-font-scope-tab]","[data-font-scope-panel]"),[N,Q]=ze("[data-layout-region]","[data-layout-value]"),[le,z,ee,ve,Ne,Ve,Qe,it,Qt,Eo]=xe('[data-settings-panel="layout"] .ldp-layout-total','[data-settings-panel="layout"] .ldp-layout-reset',".ldp-reader-window-width",".ldp-reader-window-height",".ldp-reader-window-x",".ldp-reader-window-y",".ldp-reader-window-pin-input",".ldp-reader-window-lock-input",".ldp-reader-window-status",".ldp-reader-window-reset"),ll=tb.map(i=>{const p=IS(i.key);return Object.assign({},i,{input:Z(`[data-settings-panel="appearance"] .${p}${i.range?"-range":""}`),output:Z(`[data-settings-panel="appearance"] .${p}-value`)})}),[vm,wm,qI,LM]=xe(".ldp-appearance-theme-note",".ldp-appearance-status",".ldp-performance-status",".ldp-performance-reset"),[Hy,IM,Uy,Sm,xM,DI,OI,FI]=ze("[data-appearance-group-toggle]","[data-color-reset]",".ldp-performance-preset","[data-performance-key]","[data-resource-monitor-metric]","[data-resource-monitor-average]","[data-resource-monitor-peak]","[data-resource-monitor-trend]"),BI=[[xM,"resourceMonitorMetric",""],[DI,"resourceMonitorAverage","Average"],[OI,"resourceMonitorPeak","Peak"],[FI,"resourceMonitorTrend","Trend"]],[HI,UI,jI]=ze("[data-resource-monitor-chart]","[data-resource-monitor-scope]","[data-request-flow-metric]"),[zI,WI,VI,GI,QI,YI,qM,XI,KI,DM,JI,ZI,ex,tx,OM,FM,rx,ox,_m,nx]=xe(".ldp-resource-monitor-health",".ldp-resource-monitor-health-state",".ldp-resource-monitor-health-detail",".ldp-resource-monitor-trend-window",".ldp-resource-monitor-updated",".ldp-resource-monitor-evidence-window",".ldp-resource-monitor-event-log",'.ldp-settings-log-panel[data-settings-log-panel="performance"]',".ldp-request-flow-window",".ldp-request-flow-trace",".ldp-request-flow-legend",".ldp-request-flow-bottleneck",".ldp-request-flow-bottleneck-state",".ldp-request-flow-bottleneck-detail",".ldp-request-flow-anomaly-window",".ldp-request-flow-anomalies",".ldp-request-flow-types",".ldp-request-flow-log",".ldp-request-flow-observed",'.ldp-settings-log-panel[data-settings-log-panel="request"]'),Cm=Object.fromEntries(xa.map(i=>{const p=`.ldp-flash-${i.key}`;return[i.key,{input:Z(p),output:Z(`${p}-value`)}]})),sx=xa.map(i=>Cm[i.key].input),[BM,Em,HM,ax,UM,Mm,jy,jM,Mc,zM,zy,Wy,Vy,WM,VM,ix,$c,gu,$m,Gy,lx,Rm,Tm,Am,Pm,Nm,GM,cx,dx,ux,km,bu,Qy,Yy,Xy,yu]=xe(".ldp-flash-status",".ldp-loading-animation-select",".ldp-loading-preview-stage",".ldp-loading-settings-preview-label",".ldp-loading-preview-reroll",".ldp-history-sort-mode",".ldp-history-buttons-always-visible",".ldp-reader-queue-always-visible-empty",".ldp-history-edge-trigger-range",".ldp-history-edge-trigger-value",".ldp-open-topics-first-post",".ldp-double-escape-close-reader",".ldp-confirm-native-composer-close",".ldp-topic-action-rail-visible-setting",".ldp-topic-action-rail-fixed-setting",".ldp-topic-action-rail-reset",".ldp-expand-nested-replies-default",".ldp-expand-leaf-nested-replies",".ldp-aggregate-descendant-replies",".ldp-hide-nested-reply-floors",".ldp-nested-display-warning",".ldp-boost-copy-mode",".ldp-boost-copy-prefix",".ldp-boost-copy-counter-marker",".ldp-boost-copy-counter-step",".ldp-boost-copy-fixed-suffix",".ldp-boost-copy-preview",".ldp-config-export",".ldp-config-import",".ldp-config-reset",".ldp-config-file",".ldp-cache-clear",".ldp-custom-site-input",".ldp-custom-site-add",".ldp-custom-site-list",".ldp-custom-site-status"),[px,fx,Rc,mx,hx,gx]=ze(".ldp-boost-counter-row",".ldp-boost-text-row",".ldp-cache-select","[data-cache-size]","[data-cache-retention]",'.ldp-color-control input[type="color"]'),bx=["#F0FFF0","#FFFFFF","#E5E7EB","#94A3B8","#475569","#111827","#47855F","#22C55E","#2563EB","#7C3AED","#D97706","#DC2626"];let yr=Z(".ldp-color-picker-popover");yr||(yr=ke("div"),yr.className="ldp-color-picker-popover",yr.setAttribute("role","dialog"),yr.setAttribute("aria-modal","false"),rt(yr,"选择颜色"),yr.hidden=!0,yr.innerHTML=`
+ `,s1(ne),An(ne),F0(),QP()),ne.dataset.topicId=String(e),Ct=ne,cm(),xP(ne),ti(),ne._ldpReaderHistoryNavigation=oe;const v=wP(ne,e,oe);ne._ldpReaderSession=T,It.classList.add("ldp-reader-open"),ne.dataset.targetPostNumber=a?String(a):"",ne.classList.toggle("ldp-initial-target-layout",a>1);const S=X(".ldp-modal"),x=X(".ldp-body");Se&&AI(ne);const I=Se?ne._ldpReaderShell:{},he=I.lifecycleScope||Uo();I.lifecycleScope=he;const ie=I.surfaceManager||YP(ne);I.surfaceManager=ie;const O=(i,p,h,y)=>he.listen(i,p,h,y),Ze=!!M&&!z&&ba(P).embedded;Ze&&It.classList.add("ldp-reader-host-anchor-restoring");const xe=I.readerWorkspace||iI(ne,S,z);xe.sync(),I.syncReaderTitleActionAlignment=()=>{const i=X(".ldp-header"),p=i?.querySelector(".ldp-title-jump"),h=i?.querySelector(":scope > .ldp-title-actions");if(!p||!h)return;h.style.setProperty("--ldp-title-actions-align-y","0px");const C=Array.from(h.children).find(k=>!k.hidden&&getComputedStyle(k).display!=="none")?.querySelector(".ldp-icon");if(!C)return;const $=p.getBoundingClientRect().top-C.getBoundingClientRect().top;h.style.setProperty("--ldp-title-actions-align-y",`${Math.round($*2)/2}px`)},I.syncFullPageHeaderAlignment=()=>{const i=X(".ldp-header");if(!i)return;const p=i.querySelector(":scope > .ldp-title-actions"),h=i.querySelector(":scope > .ldp-head-btns"),y=X(".ldp-comments"),C=()=>{i.style.removeProperty("--ldp-header-logo-inset"),i.style.removeProperty("padding-left"),i.style.removeProperty("padding-right"),p?.style.removeProperty("right"),h?.classList.remove("is-content-aligned")};if(!xe.isFullPage()||!y){C(),I.syncReaderTitleActionAlignment();return}const $=S.getBoundingClientRect(),k=y.getBoundingClientRect();if(!($.width>0)||!(k.width>0))return;const j=Math.max(0,Math.round(k.left-$.left)),B=Math.max(0,Math.round($.right-k.right));i.style.setProperty("--ldp-header-logo-inset",`${j}px`),i.style.paddingLeft=`${j}px`,i.style.paddingRight=`${B}px`,p&&(p.style.right=`${B}px`),h?.classList.add("is-content-aligned"),I.syncReaderTitleActionAlignment()},Ze&&(xe.isEmbedded()&&XI(M),he.raf(()=>It.classList.remove("ldp-reader-host-anchor-restoring"))),$f(xe.isFullPage()||window.innerWidth<=Es);const ce=X(".ldp-reader-window-capsule"),Re=X(".ldp-reader-placement-control"),Qe=X(".ldp-reader-placement-strip"),Ie=Xe("[data-reader-placement]"),tt=X(".ldp-comments"),it=X(".ldp-comments-header"),ft=X(".ldp-comments-count"),Nt=X(".ldp-comments-empty"),Mt=X(".ldp-loadmask"),zt=Mt?.querySelector(".ldp-loading-stage"),kt=Mt?.querySelector(".ldp-loading-status span"),Mr=Mt?.querySelector(".ldp-loading-target"),xr=Mt?.querySelector(".ldp-loading-detail"),Nr=X(".ldp-loading-tip"),Dr=Nr?.querySelector("span"),Sr=(i,p={})=>{const h=p.target===!0&&a>1,y=Math.max(0,Number(p.cachedCount)||0),C=Math.max(0,Number(p.missingCount)||0),$=i==="cache"?{status:h?"正在读取目标楼层缓存":"正在读取帖子缓存",detail:y?`已读取 ${y} 条缓存,正在恢复楼层…`:"正在恢复已缓存楼层…"}:i==="network"?{status:h?"正在请求目标楼层":y?"正在补全帖子数据":"正在请求帖子数据",detail:y?`已读取 ${y} 条缓存,正在下载 ${C} 条缺失楼层…`:C?`正在下载 ${C} 条缺失楼层…`:"正在下载缺失楼层…"}:{status:"正在渲染帖子",detail:"正在生成页面…"};kt&&(kt.textContent=$.status),xr&&(xr.textContent=$.detail),Dr&&(Dr.textContent=$.detail),Mr&&(Mr.textContent=h?`#${a}`:""),zt&&nt(zt,`${$.status},${$.detail.replace("…","")}`)},Tr=a<=1||!!(U?.post_stream&&Array.isArray(U.post_stream.posts)&&U.post_stream.posts.some(i=>+i.post_number===a));Sr(U&&Tr?"cache":"network",{target:a>1});const wr=X(".ldp-end-tip"),Rr=X(".ldp-live-update"),qr=Rr.querySelector(".ldp-live-update-jump"),Dt=Rr.querySelector(".ldp-live-update-dismiss"),Ar=X(".ldp-topic-timeline"),Yr=X(".ldp-layout-toggle"),hr=X(".ldp-title-topic-row"),G=X(".ldp-title-topic-scroller");let ze=0;const We=()=>{ze=0;const i=G.scrollWidth>hr.clientWidth+1;hr.classList.toggle("has-overflow",i);const p=Math.max(0,G.scrollWidth-G.clientWidth);hr.classList.toggle("can-scroll-left",i&&G.scrollLeft>1),hr.classList.toggle("can-scroll-right",i&&G.scrollLeft
{ze||(ze=requestAnimationFrame(We))},Wt=X(".ldp-only-op-toggle"),Ut=X(".ldp-only-op-progress"),ir=Ut?.querySelector(".ldp-only-op-progress-value"),Zt=X(".ldp-rate-limit-notice"),dr=Zt?.querySelector(".ldp-rate-limit-detail"),po=Zt?.querySelector(".ldp-rate-limit-challenge"),Me=X(".ldp-notifications-toggle"),ke=X(".ldp-notifications-popover"),bt=Xe(".ldp-notification-mode-tab"),St=Xe("[data-notification-mode-panel]"),lr=Xe(".ldp-notification-tab"),wt=(i,p)=>Object.fromEntries(p.map(h=>[h,X(`.ldp-${i}-${h.replace(/[A-Z]/g,y=>`-${y.toLowerCase()}`)}`)])),de=wt("notification",["list","pagePrev","pageNext","pageInfo","search","searchClear","toolbar","unreadStatus","markAll","newMessage"]),ge=wt("history",["toggle","popover","list","pagePrev","pageNext","pageInfo","search","searchClear","defaultActions","bulkActions","sortToggle","multi","multiDone","selectScope","selectToggle","deleteSelected","deleteSelectedLabel","clear"]),Te=wt("bookmarks",["toggle","popover","list","pagePrev","pageNext","pageInfo","search","searchClear","defaultActions","bulkActions","multi","multiDone","selectScope","selectToggle","deleteSelected","deleteSelectedLabel"]),[ht,dt,Gt,Xt,L,me,Ce,Be]=Le(".ldp-reaction-filters",".ldp-bookmark-tabs",".ldp-translate-toggle",".ldp-settings-toggle",".ldp-settings-popover",".ldp-settings-panel",".ldp-settings-close",".ldp-setting-help-tooltip"),[Ue,ot]=He(".ldp-bookmark-tab",".ldp-settings-theme-button");let et=ne.querySelector(":scope > .ldp-settings-layer");et||(et=Ne("div"),et.className="ldp-settings-layer",ne.append(et));const _t=()=>xe.isEmbedded()?et:S,ur=()=>{const i=_t(),p=i.getBoundingClientRect(),h=p.left+i.clientLeft,y=p.top+i.clientTop,C=i.clientWidth,$=i.clientHeight;return{left:h,top:y,width:C,height:$,right:h+C,bottom:y+$}},Jr=()=>ur().width<=Es,oo=()=>{const i=S.clientHeight<=ww;if(S.classList.toggle("ldp-reader-surface-short",i),L.hidden)return;const p=_t(),h=p.clientHeight<=ww;S.classList.toggle("ldp-settings-surface-short",p===S&&h),et.classList.toggle("ldp-settings-surface-short",p===et&&h)};ie.mount(ke),ie.mount(ge.popover),ie.mount(Te.popover),ie.mount(L,_t()),ie.mount(Be,_t()),oo(),Ud();const[Cr,er,tr,Xr,Eo,Fn,Pr,fo,_,F,Ee,st,Et,At,rr,_r,gr,Ot,jt,br,on,Sn,Ra]=Le(".ldp-user-info-content",".ldp-user-info-last-refresh",".ldp-user-info-title-refresh",".ldp-settings-search-shell",".ldp-settings-search-input",".ldp-settings-search-clear",".ldp-settings-search-status",".ldp-settings-search-empty",".ldp-settings-draft-bar",".ldp-settings-draft-status",".ldp-settings-save-all",".ldp-image-scale-range",".ldp-image-scale-value",".ldp-image-scale-reset",".ldp-lightbox-original-by-default",".ldp-lightbox-comments-expanded",".ldp-lightbox-description-expanded",".ldp-font-family-menu",".ldp-font-family-search input",".ldp-font-family-options",".ldp-font-family-empty",".ldp-font-family-source-status",".ldp-font-family-manual button"),[Fr,Io,js,ls,Lt,Kt,ct]=He(".ldp-settings-tab",".ldp-settings-section",".ldp-settings-nav-group",".ldp-settings-log-tab",".ldp-settings-log-panel",".ldp-font-family-trigger",".ldp-font-family-custom");Io.splice(0,Io.length,...ff.map(([i])=>Io.find(p=>p.dataset.settingsPanel===i))),me.append(...Io.slice(1),_),ie.mount(Ot,_t());const[Jt,Go,Zr,Qo,Or,Kr,nn,Yo,Bn,Lc]=Le(".ldp-interface-font-scale-range",".ldp-interface-font-scale-value",".ldp-font-scale-range",".ldp-font-scale-value",".ldp-composer-font-scale-range",".ldp-composer-font-scale-value",".ldp-font-status",".ldp-font-reset",".ldp-font-rendering-enabled",".ldp-font-rendering-on-host"),[$a,zs,_n,Cn,Ur,Ta,Aa,cs,pi]=He(".ldp-font-weight-select",".ldp-font-color",".ldp-font-color-value","[data-host-embed-size]","[data-host-embed-size-value]","[data-host-embed-size-reset]","[data-host-font-reset],[data-font-field-reset]","[data-font-scope-tab]","[data-font-scope-panel]"),[N,V]=He("[data-layout-region]","[data-layout-value]"),[le,H,te,we,Ae,Ge,Ye,lt,Qt,Mo]=Le('[data-settings-panel="layout"] .ldp-layout-total','[data-settings-panel="layout"] .ldp-layout-reset',".ldp-reader-window-width",".ldp-reader-window-height",".ldp-reader-window-x",".ldp-reader-window-y",".ldp-reader-window-pin-input",".ldp-reader-window-lock-input",".ldp-reader-window-status",".ldp-reader-window-reset"),fl=lb.map(i=>{const p=WS(i.key);return Object.assign({},i,{input:X(`[data-settings-panel="appearance"] .${p}${i.range?"-range":""}`),output:X(`[data-settings-panel="appearance"] .${p}-value`)})}),[$m,Tm,tx,zM]=Le(".ldp-appearance-theme-note",".ldp-appearance-status",".ldp-performance-status",".ldp-performance-reset"),[Jy,WM,Zy,Am,VM,rx,ox,nx]=He("[data-appearance-group-toggle]","[data-color-reset]",".ldp-performance-preset","[data-performance-key]","[data-resource-monitor-metric]","[data-resource-monitor-average]","[data-resource-monitor-peak]","[data-resource-monitor-trend]"),sx=[[VM,"resourceMonitorMetric",""],[rx,"resourceMonitorAverage","Average"],[ox,"resourceMonitorPeak","Peak"],[nx,"resourceMonitorTrend","Trend"]],[ax,ix,lx]=He("[data-resource-monitor-chart]","[data-resource-monitor-scope]","[data-request-flow-metric]"),[cx,dx,ux,px,fx,mx,GM,hx,gx,QM,bx,yx,vx,wx,YM,XM,Sx,_x,Pm,Cx]=Le(".ldp-resource-monitor-health",".ldp-resource-monitor-health-state",".ldp-resource-monitor-health-detail",".ldp-resource-monitor-trend-window",".ldp-resource-monitor-updated",".ldp-resource-monitor-evidence-window",".ldp-resource-monitor-event-log",'.ldp-settings-log-panel[data-settings-log-panel="performance"]',".ldp-request-flow-window",".ldp-request-flow-trace",".ldp-request-flow-legend",".ldp-request-flow-bottleneck",".ldp-request-flow-bottleneck-state",".ldp-request-flow-bottleneck-detail",".ldp-request-flow-anomaly-window",".ldp-request-flow-anomalies",".ldp-request-flow-types",".ldp-request-flow-log",".ldp-request-flow-observed",'.ldp-settings-log-panel[data-settings-log-panel="request"]'),Nm=Object.fromEntries(Oa.map(i=>{const p=`.ldp-flash-${i.key}`;return[i.key,{input:X(p),output:X(`${p}-value`)}]})),Ex=Oa.map(i=>Nm[i.key].input),[KM,km,JM,Mx,ZM,Lm,ev,eR,Ic,tR,tv,rv,ov,rR,oR,Rx,xc,Eu,Im,ds,Ws,nv,$x,xm,Dm,qm,Om,Fm,nR,Tx,Ax,Px,Bm,Mu,sv,av,iv,Ru]=Le(".ldp-flash-status",".ldp-loading-animation-select",".ldp-loading-preview-stage",".ldp-loading-settings-preview-label",".ldp-loading-preview-reroll",".ldp-history-sort-mode",".ldp-history-buttons-always-visible",".ldp-reader-queue-always-visible-empty",".ldp-history-edge-trigger-range",".ldp-history-edge-trigger-value",".ldp-open-topics-first-post",".ldp-double-escape-close-reader",".ldp-confirm-native-composer-close",".ldp-topic-action-rail-visible-setting",".ldp-topic-action-rail-fixed-setting",".ldp-topic-action-rail-reset",".ldp-expand-nested-replies-default",".ldp-expand-leaf-nested-replies",".ldp-aggregate-descendant-replies",".ldp-inline-reply-tree-enable",".ldp-inline-reply-tree-depth",".ldp-hide-nested-reply-floors",".ldp-nested-display-warning",".ldp-boost-copy-mode",".ldp-boost-copy-prefix",".ldp-boost-copy-counter-marker",".ldp-boost-copy-counter-step",".ldp-boost-copy-fixed-suffix",".ldp-boost-copy-preview",".ldp-config-export",".ldp-config-import",".ldp-config-reset",".ldp-config-file",".ldp-cache-clear",".ldp-custom-site-input",".ldp-custom-site-add",".ldp-custom-site-list",".ldp-custom-site-status"),sR=Ws?.closest(".ldp-inline-reply-tree-depth-row")||null,[Nx,kx,Dc,Lx,Ix,xx]=He(".ldp-boost-counter-row",".ldp-boost-text-row",".ldp-cache-select","[data-cache-size]","[data-cache-retention]",'.ldp-color-control input[type="color"]'),Dx=["#F0FFF0","#FFFFFF","#E5E7EB","#94A3B8","#475569","#111827","#47855F","#22C55E","#2563EB","#7C3AED","#D97706","#DC2626"];let yr=X(".ldp-color-picker-popover");yr||(yr=Ne("div"),yr.className="ldp-color-picker-popover",yr.setAttribute("role","dialog"),yr.setAttribute("aria-modal","false"),nt(yr,"选择颜色"),yr.hidden=!0,yr.innerHTML=`
选择颜色
- ${bx.map(i=>``).join("")}
+ ${Dx.map(i=>``).join("")}
@@ -1231,56 +1271,56 @@ ${t.getMonth()+1} 月 ${t.getDate()} 日`:""}function yI(e,t){const n=e.querySel
色相
饱和度
明度
-
`,ie.mount(yr,_t()));let QM=_t();const Lm=(i=!0)=>{const p=_t(),h=[k,Fe,Dt,yr].filter(Boolean);if(i&&k.hidden){h.forEach(M=>ie.park(M)),ur(Ke,!0);return}const y=QM!==p;h.forEach(M=>ie.mount(M,p)),QM=p,ur(Ke,p!==Ke),y&&(k.style.removeProperty("left"),k.style.removeProperty("top"),k.style.removeProperty("transform")),ro()};Lm(!1),queueMicrotask(()=>{k.hidden&&Lm()});const yx=yr.querySelector(".ldp-color-picker-title"),Yo=yr.querySelector(".ldp-color-picker-hex"),Ky=yr.querySelectorAll(".ldp-color-picker-preset"),Jy=yr.querySelector(".ldp-color-picker-more"),YM=yr.querySelector(".ldp-color-picker-advanced"),vu=yr.querySelector(".ldp-color-picker-hue"),cl=yr.querySelector(".ldp-color-picker-saturation"),Tc=yr.querySelector(".ldp-color-picker-brightness"),vx=yr.querySelector(".ldp-color-picker-hue-value"),wx=yr.querySelector(".ldp-color-picker-saturation-value"),Sx=yr.querySelector(".ldp-color-picker-brightness-value");let xo=null,Im={h:0,s:0,v:100},ui=0,wu=null;const Zy=i=>{const p=/^#?([\da-f]{3}|[\da-f]{6})$/i.exec(String(i||"").trim());return p?`#${(p[1].length===3?Array.from(p[1],y=>`${y}${y}`).join(""):p[1]).toUpperCase()}`:""},_x=i=>{const p=Zy(i).slice(1),h=parseInt(p.slice(0,2),16)/255,y=parseInt(p.slice(2,4),16)/255,M=parseInt(p.slice(4,6),16)/255,T=Math.max(h,y,M),L=Math.min(h,y,M),j=T-L;let q=0;return j&&(T===h?q=60*((y-M)/j%6):T===y?q=60*((M-h)/j+2):q=60*((h-y)/j+4)),q<0&&(q+=360),{h:Math.round(q)%360,s:T?Math.round(j/T*100):0,v:Math.round(T*100)}},dl=({h:i,s:p,v:h})=>{const y=p/100,M=h/100,T=M*y,L=i/60,j=T*(1-Math.abs(L%2-1)),q=M-T;return`#${(L<1?[T,j,0]:L<2?[j,T,0]:L<3?[0,T,j]:L<4?[0,j,T]:L<5?[j,0,T]:[T,0,j]).map(J=>Math.round((J+q)*255).toString(16).padStart(2,"0")).join("").toUpperCase()}`},XM=()=>{const{h:i,s:p,v:h}=Im;vu.value=String(i),cl.value=String(p),Tc.value=String(h),vx.textContent=`${i}°`,wx.textContent=`${p}%`,Sx.textContent=`${h}%`,vu.style.setProperty("--ldp-color-slider-thumb",dl({h:i,s:100,v:100})),cl.style.setProperty("--ldp-color-slider-start",dl({h:i,s:0,v:h})),cl.style.setProperty("--ldp-color-slider-end",dl({h:i,s:100,v:h})),cl.style.setProperty("--ldp-color-slider-thumb",dl({h:i,s:p,v:h})),Tc.style.setProperty("--ldp-color-slider-end",dl({h:i,s:p,v:100})),Tc.style.setProperty("--ldp-color-slider-thumb",dl({h:i,s:p,v:h}))},KM=()=>{if(!xo)return;const i=xo.value.toUpperCase();yx.textContent=xo.getAttribute("aria-label")||"选择颜色",Yo.value=i,Im=_x(i),XM(),Yo.setAttribute("aria-invalid","false"),Ky.forEach(p=>go(p,p.dataset.color===i))},JM=()=>{ui=0;const i=wu;wu=null,!(!i||!i.input.isConnected)&&(i.input.value=i.value,i.input.dispatchEvent(new window.Event("input",{bubbles:!0})))},Cx=i=>{xo&&(wu={input:xo,value:i},ui||(ui=window.requestAnimationFrame(JM)))},ev=()=>{wu&&(ui&&window.cancelAnimationFrame(ui),JM())},Ac=({restoreFocus:i=!1}={})=>{if(yr.hidden)return;ev();const p=xo;yr.hidden=!0,xo=null,i&&p&&p.focus()},ZM=i=>{const y=dr(),M=i.getBoundingClientRect(),T=yr.getBoundingClientRect(),L=y.left+12,j=y.top+12,q=Math.min(Math.max(L,M.left),Math.max(L,y.right-T.width-12)),re=M.bottom+8,J=re+T.height<=y.bottom-12?re:Math.max(j,M.top-T.height-8);yr.style.left=`${Math.round(q-y.left)}px`,yr.style.top=`${Math.round(J-y.top)}px`},e$=i=>{!i||i.disabled||(ps(),Mo(),jc(),Sl(),xo=i,KM(),yr.hidden=!1,ZM(i),Yo.focus(),Yo.select())},tv=(i,p=!1)=>{xo&&(ev(),xo.value=i,xo.dispatchEvent(new window.Event("input",{bubbles:!0})),KM(),p&&Ac({restoreFocus:!0}))};Ae||(gx.forEach(i=>{i.setAttribute("aria-haspopup","dialog"),O(i,"click",p=>{p.preventDefault(),e$(i)}),O(i,"keydown",p=>{p.key!=="Enter"&&p.key!==" "||(p.preventDefault(),e$(i))})}),Ky.forEach(i=>O(i,"click",()=>{tv(i.dataset.color,!0)})),O(Yo,"input",()=>{const i=Yo.value.trim(),p=/^#?[\da-f]{6}$/i.test(i)?Zy(i):"";Yo.setAttribute("aria-invalid",String(i.length>=7&&!p)),p&&tv(p)}),O(Yo,"keydown",i=>{if(i.key==="Escape"){i.preventDefault(),Ac({restoreFocus:!0});return}if(i.key!=="Enter")return;const p=Zy(Yo.value);if(!p){Yo.setAttribute("aria-invalid","true");return}i.preventDefault(),tv(p,!0)}),O(Yo,"blur",()=>{!xo||yr.contains(document.activeElement)||(Yo.value=xo.value.toUpperCase(),Yo.setAttribute("aria-invalid","false"))}),O(Jy,"click",()=>{const i=YM.hidden;YM.hidden=!i,ar(Jy,i),Jy.textContent=i?"收起调色":"更多颜色",xo&&ZM(xo)}),[vu,cl,Tc].forEach(i=>O(i,"input",()=>{Im={h:Number(vu.value),s:Number(cl.value),v:Number(Tc.value)};const p=dl(Im);Yo.value=p,Yo.setAttribute("aria-invalid","false"),XM(),Ky.forEach(h=>go(h,h.dataset.color===p)),Cx(p)})),[vu,cl,Tc].forEach(i=>O(i,"change",ev)),O(yr,"keydown",i=>{i.key==="Escape"&&(i.preventDefault(),Ac({restoreFocus:!0}))}),O(yr,"pointerdown",i=>i.stopPropagation()),O(yr,"click",i=>i.stopPropagation()),O(se,"pointerdown",i=>{yr.hidden||yr.contains(i.target)||i.target===xo||Ac()},!0));const ls=(i,p)=>{i&&p&&(i.dataset.settingHelp=p)},gr=(i,p)=>{ls(i?.closest(".ldp-setting-row"),p)};gr(ot,"控制所有显示形态中帖子正文图片的显示大小。100% 是正常尺寸;调小能少占屏幕,调大方便看细节。修改后立即保存。"),gr(rr,"开启后,大图查看器首次打开和切换图片时会自动请求原图;关闭后不会新请求原图,但本地已有原图缓存时仍会优先展示,否则继续使用预览图。修改后立即保存。"),gr(_r,"控制大图查看器首次打开时是否展开评论面板;打开后仍可随时手动收起或展开。修改后立即保存。"),gr(hr,"控制大图查看器首次打开时是否展开图片说明;手动收起、展开或拖动说明高度后也会自动记住。修改后立即保存。");const Ex={left:"控制阅读内容左边保留多少空白。数值越大,正文会整体向右并变窄;它需要和另外四个区域合计正好 100%。",main:"控制正文区域实际占用的宽度,包括主帖和楼层内容。太小会增加换行,太大会挤压时间轴和两侧留白;五个区域必须合计 100%。",gap:"控制正文与右侧时间轴之间的空隙。调大后两块内容更分开,但正文可用宽度会减少;五个区域必须合计 100%。",timeline:"控制右侧楼层时间轴、日期和楼层计数占用的宽度。太小可能显得拥挤;五个区域必须合计 100%。",right:"控制时间轴右边保留多少空白。调大能让右侧不贴边,但会减少正文或时间轴的空间;五个区域必须合计 100%。"};N.forEach(i=>gr(i,Ex[i.dataset.layoutRegion])),ls(ee.closest(".ldp-reader-window-field"),"设置桌面浮窗的宽度。数值越大,一行能显示的正文越多;只影响浮窗模式,修改后立即保存。"),ls(ve.closest(".ldp-reader-window-field"),"设置桌面浮窗的高度。数值越大,一屏能看到的楼层越多;只影响浮窗模式,修改后立即保存。"),ls(Ne.closest(".ldp-reader-window-field"),"设置浮窗左边缘距离浏览器左侧的像素数。数值越大,浮窗越靠右;只影响桌面浮窗并立即保存。"),ls(Ve.closest(".ldp-reader-window-field"),"设置浮窗上边缘距离浏览器顶部的像素数。数值越大,浮窗越靠下;只影响桌面浮窗并立即保存。"),ls(Qe.closest(".ldp-reader-window-option"),"开启后,点击阅读器外面的网页区域也不会自动收起浮窗,适合边看帖子边操作原页面。"),ls(it.closest(".ldp-reader-window-option"),"开启后禁止拖动和缩放浮窗,避免误操作改变位置或大小;仍可通过小锁或这里重新解锁。"),ll.forEach(({input:i,help:p,range:h})=>gr(i,h?p:`${p}确定颜色后会保留色相,并按用途自动生成明亮与暗色主题的安全亮度;切换主题时自动应用。`)),Hy.forEach(i=>ls(i.closest(".ldp-setting-switch"),"关闭后隐藏回复连接线、引用线和界面分隔线;已经设置的颜色、粗细和圆角不会丢失,重新开启即可恢复。"));const Mx={low:"减少每次加载的内容、页面保留的楼层和提前请求量,优先降低网络、内存与主线程占用;下次打开阅读器生效。",balanced:"在加载速度、滚动流畅度、内存占用和请求稳定性之间取默认平衡;下次打开阅读器生效。",high:"增加每次加载的内容、页面保留的楼层和提前请求距离,优先减少滚动等待,但会增加内存、网络和限流压力;下次打开阅读器生效。",custom:"表示下面的性能参数已经手动调整。点击它不会自动改值,可直接修改下方各项;下次打开阅读器生效。"};Uy.forEach(i=>ls(i,Mx[i.dataset.performancePreset])),Sm.forEach(i=>gr(i,hp.find(p=>p.key===i.dataset.performanceKey).help)),xa.forEach(i=>gr(Cm[i.key].input,i.help)),gr(jy,"开启后,可用的历史前进和后退按钮会一直显示,并禁用“边缘唤出按钮范围”滑块;关闭后,按钮仅在鼠标进入对应边缘或键盘聚焦时显示。修改后立即保存。"),gr(Mc,`设置阅读器左右两侧用于唤出历史前进和后退按钮的范围,各占阅读器宽度的 ${ng}%–${sg}%;范围透明且不会遮挡正文操作,0% 表示关闭鼠标唤出。修改后立即保存。`),gr(Mm,"“最近打开优先”按每次打开的最新时间倒序排列,重开旧帖后它会回到列表顶部;“首次打开顺序”按每条记录第一次进入历史的时间排列,重开不会改变位置。旧记录无法还原更早的首次打开时间,会从当前保存时间开始计算。修改后立即保存。"),gr(zy,"开启后,普通帖子链接会从 #1 主楼开始;消息、历史和收藏面板中的链接仍优先打开各自目标楼层。关闭后,所有链接都会尊重其中指定的楼层号。修改后立即保存。"),gr(Wy,"开启后,需要在 1.5 秒内连续按两次 Esc 才会关闭阅读器;关闭后,按一次 Esc 即可关闭。修改后立即保存。"),gr(Vy,"开启后,在阅读器内按 Esc、关闭或舍弃 LINUX DO 原生回复窗口时,需要在 1.5 秒内重复同一操作;关闭后,一次操作即可关闭。修改后立即保存。"),gr($c,"开启后,在每条父回复下默认展开它的直接二级回复;关闭时会同时关闭“完整讨论”视图。修改后立即保存并应用到当前帖子。"),gr(gu,"开启后,二级回复在楼层列表中的对应位置默认完整展开;可与父回复下的二级回复同时显示,但至少要保留一种显示位置。修改后立即保存并应用到当前帖子。"),gr($m,"建立在“在父回复下展开二级回复”之上;开启时会按滚动位置预加载更深层回复,并提供“查看完整讨论”浮窗。修改后立即保存并应用到当前帖子。"),gr(Gy,"未启用“完整讨论”时,从楼层列表隐藏全部二级回复;启用后先保留未读二级回复,读过后再隐藏。时间轴、跳转和已读记录不受影响;跳转时会临时显示或打开对应讨论。修改后立即保存并应用到当前帖子。"),gr(Rm,"选择复制 Boost 时如何生成末尾内容:“递增数字”每次按设定步长增加,“固定文字”每次追加同一段文字。修改后立即保存。"),gr(Tm,"填写复制结果开头的前置文字,例如“赞同:”。留空就直接从原 Boost 内容开始,最多 16 个字。修改后立即保存。"),gr(Am,"使用递增数字时,填写数字前缀,例如“+”会得到“原 Boost +1”;留空时数字会直接接在原文后面。修改后立即保存。"),gr(Pm,"使用递增数字时,每复制一次增加多少。设为 1 会依次得到 1、2、3;设为 5 会得到 5、10、15。修改后立即保存。"),gr(Nm,"使用固定文字时,每次复制都会把这里的内容追加到原 Boost 后面,例如“俺也一样”。最多 16 个字。修改后立即保存。"),gr(GM,"展示当前规则实际会复制出的结果;使用递增数字时会同时展示连续两次复制,方便确认步长。"),Rc.forEach((i,p)=>ls(i.closest(".ldp-cache-row"),mw[p].help)),ls(bu,"只清理上面已经勾选的缓存类型。不会退出登录,也不会删除站点上的帖子、消息或图片;清理后需要时会重新联网获取。");const qo=x.readerWindow||DL(se,S);x.readerWorkspace=qe,x.readerWindow=qo,x.topicId=Number(e),x.targetPostNumber=a,x.topicNavMetadata=V,x.commentsHeader=st,se._ldpReaderShell=x;const rv=()=>{const i=U?vp:yp,p=i.length;Ee.hidden=p<2,We.hidden=p<2,ce.classList.toggle("ldp-reader-placement-available",p>1);const h=qe.mode();Ie.forEach(y=>{const M=y.dataset.readerPlacement;y.hidden=!i.includes(M),y.classList.toggle("active",y.dataset.readerPlacement===h)})},$x=i=>{(U?vp:yp).includes(i)&&(qe.setMode(i),qo.syncMode(),Hm(),gv(),Tu(),i!==qe.mode()&&ft("页面空间不足,暂时保持浮窗"),rv())};Ae||Ie.forEach(i=>{O(i,"click",()=>$x(i.dataset.readerPlacement))}),rv();let t$=H,Su=null,Pc=0,xm=0,qm=null,Do=null;const Dm=()=>{Su&&Su.disconnect(),Pc&&cancelAnimationFrame(Pc),xm&&clearTimeout(xm),Su=null,Pc=0,xm=0},r$=()=>{const i=Z(".ldp-header"),p=i?.querySelector(".ldp-title-jump");if(!i||!p)return;const h=document.createRange();h.selectNodeContents(p);const y=Array.from(h.getClientRects()).filter(T=>T.width>0&&T.height>0),M=y[0]?.top;i.classList.toggle("ldp-title-single-line",y.length>0&&y.every(T=>Math.abs(T.top-M)<1))};x.syncReaderTitleDensity=r$;const Nc=i=>{if(!i)return;kE({topic:i}),t$=i;const p=ma(null,e);V=Ts(qm||p,Ts(V,Od(i))),G&&ef(G,i),x.topicNavMetadata=V;const h=S1(i,V),y=w1(e,i,V);h&&!y&&so(e,i);const M=Array.isArray(i.post_stream?.posts)?i.post_stream.posts:[],T=i._opUsername||i.details?.created_by&&i.details.created_by.username||(M.find(yt=>+yt.post_number==1)||{}).username||"",L=String(i.title||"").trim();L&&(Z(".ldp-title-jump").textContent=L,pI(L));const j=Z(".ldp-topic-edit-trigger"),q=i.details&&i.details.can_edit===!0;j.hidden=!q,!q&&Do&&Do.close(!0);const re=T?`@${$e(T)}`:"@?",J=[`${$e(String(i.posts_count||0))} 帖`,`${$e(String(i.views||0))} 浏览`];i.like_count!=null&&J.push(`${$e(String(Math.max(0,+i.like_count||0)))} 赞`),i.participant_count!=null&&J.push(`${$e(String(Math.max(0,+i.participant_count||0)))} 用户`),Z(".ldp-meta-stats").innerHTML=J.join(" · "),Z(".ldp-meta-owner-value").innerHTML=re,Z(".ldp-meta-owner").hidden=!1;const be=Z(".ldp-topic-tags");be.innerHTML=_1(i,V),be.hidden=!be.innerHTML;const X=Z(".ldp-topic-vote-slot"),Ue=Gr("topicVoting",{topic:i})&&(i.can_vote===!0||i.user_voted===!0||+i.vote_count>0);X.hidden=!Ue,X.innerHTML=Ue?``:"",r$(),x.syncReaderTitleActionAlignment(),Rt()},Rx=()=>{if(qm)return!1;if(!se.isConnected)return Dm(),!1;const i=ma(null,e);return Sf(i)?(Dm(),V=Ts(i,V),x.topicNavMetadata=V,Nc(t$),!0):!1},o$=()=>{Pc||(Pc=requestAnimationFrame(()=>{Pc=0,Rx()}))},Tx=()=>{if(!U||Sf(V)||!document.body)return;const i=document.querySelector("#main-outlet")||document.querySelector("#ember-app")||document.body;Su=new MutationObserver(o$),Su.observe(i,{childList:!0,subtree:!0}),xm=setTimeout(Dm,5e3),o$()};Nc(H),Tx();const $a=uN(n);Ao=$a,Bi();let ul=0,ov=!1;const n$=()=>{try{const i=$a.snapshot();return Math.max(0,Number(i?.cooldownRemaining)||0)}catch{}return 0},s$=()=>{if(!Zt||!cr)return;const i=n$();if(i<=0){ur(Zt,!0),"cooldownSeconds"in Zt.dataset&&delete Zt.dataset.cooldownSeconds,ov&&uo&&(ov=!1,Oy(uo.href));return}ov=!0;const p=Math.max(1,Math.ceil(i/1e3));Zt.dataset.cooldownSeconds!==String(p)&&(Zt.dataset.cooldownSeconds=String(p),ca(cr,`触发 429,正在等待 ${p} 秒;倒计时结束后将自动打开过盾浮窗。`)),ur(Zt,!1)},a$=()=>{ul&&clearInterval(ul),ul=0,Zt&&(ur(Zt,!0),"cooldownSeconds"in Zt.dataset&&delete Zt.dataset.cooldownSeconds)},i$=()=>{ul&&clearInterval(ul),ul=0,document.visibilityState==="visible"&&(s$(),ul=setInterval(s$,1e3))};i$();const Ur=Fk(e,$a,n),Ax=Ur.init(),Ot={group:"all",page:0,token:0,total:0,hasNext:!1,query:"",groups:{notifications:"all",messages:"inbox"}};let Us="";const _u=()=>x.activeContext?.postRequestScheduler||Ao||$a,fo={page:0,query:"",multi:!1,scope:"page"},Xo=new Set;let nv=[];const De={page:0,query:"",token:0,hasNext:!1,entries:[],allEntries:[],reactionEntries:[],reactionLoadedAt:0,reactionUsername:"",reactionFilter:"",type:ye[0],multi:!1,scope:"page",allIds:null},Cu=x.givenReactionOverrides instanceof Map?x.givenReactionOverrides:new Map;x.givenReactionOverrides=Cu;const nn=new Set,kc=()=>{De.allIds=null,nn.clear()},sv=Pb(Ud("all",0));sv&&(Ot.total=sv.total,Ot.hasNext=sv.hasNext),kf(To?Cs:Nf());const Px=(i,p)=>{if(!i||!p||p.hidden||!p.isConnected)return;const h=i.getBoundingClientRect(),y=p.getBoundingClientRect(),M=8,T=Math.max(12,Math.min(window.innerWidth-y.width-12,h.right-y.width)),L=h.bottom+M,j=h.top-y.height-M,q=`${Math.round(T)}px`,re=`${Math.round(L+y.height<=window.innerHeight-12||j<12?L:j)}px`;p.style.left!==q&&(p.style.left=q),p.style.top!==re&&(p.style.top=re)},av=({toggle:i,popover:p,panel:h,state:y,close:M})=>{const T=()=>ie.mount(p);return{toggle:i,popover:p,position:()=>Px(i,p),open(){T(),ur(p,!1),ar(i,!0)},close(){const j=!p.hidden;ur(p,!0),ar(i,!1),ie.park(p),M?.(),j&&(Vr(h.list),h.list.replaceChildren())},park:()=>{ur(p,!0),ar(i,!1),ie.park(p)},message(j,q=!1){Vr(h.list),h.list.innerHTML=`${j}
`,h.pagePrev.disabled=!0,h.pageNext.disabled=!0},paging(j,q=!0,re=y.hasNext){h.pagePrev.disabled=y.page<=0,h.pageNext.disabled=!re,ca(h.pageInfo,q?`${y.page+1} / ${Math.max(1,j)}`:"暂无记录")}}},Ra=av({toggle:Ce,popover:Le,panel:de,state:Ot}),pl=av({toggle:ge.toggle,popover:ge.popover,panel:ge,state:fo,close:()=>{fo.multi=!1,Xo.clear()}}),Ta=av({toggle:Re.toggle,popover:Re.popover,panel:Re,state:De,close:()=>{x.cancelBookmarksSearch?.(),De.token++,De.multi=!1,kc()}}),l$=Ra.position,Om=Ra.close;x.closeNotifications=Om;const Nx=pl.position,iv=pl.close,c$=Ta.position,Eu=Ta.close,d$=[Ra,pl,Ta];d$.forEach(i=>i.park());const lv=()=>d$.forEach(i=>i.position()),u$=()=>{const i=Oe.historySortMode==="recent-viewed",p=i?"最近打开优先;点击切换为首次打开顺序":"首次打开顺序固定;点击切换为最近打开优先";ge.sortToggle.innerHTML=ue(i?"history":"pin"),rt(ge.sortToggle,p),go(ge.sortToggle,!i),ge.sortToggle.dataset.ldpTooltipLabel=p},p$=(i,p,h,y,M,T,L,j=!1)=>{const q=Array.isArray(y)&&y.length>0&&y.every(re=>h.has(re));i.defaultActions.hidden=j||p.multi,i.bulkActions.hidden=j||!p.multi,i.multi.disabled=T,i.selectScope.value=p.scope,i.selectToggle.disabled=L,i.selectToggle.innerHTML=ue(q?"squareCheck":"square"),go(i.selectToggle,q),rt(i.selectToggle,`${q?"全不选":"全选"}${p.scope==="all"?"全部页面":"本页"}${M}`),i.deleteSelected.disabled=h.size===0,i.deleteSelectedLabel.textContent=String(h.size),i.deleteSelectedLabel.hidden=h.size===0},cv=(i,p,h)=>{const y=Math.max(1,Math.ceil(i.length/h));p.page=Math.max(0,Math.min(y-1,p.page));const M=p.page*h;return{entries:i.slice(M,M+h),totalPages:y}};function cs(){u$();const i=cn(),p=Og(i),h=new Set(p.map(j=>Number(j.topicId)));Xo.forEach(j=>{h.has(j)||Xo.delete(j)});const y=fo.query?p.filter(j=>W1(j,fo.query)):p,{entries:M,totalPages:T}=cv(y,fo,JT);Vr(ge.list),ge.list.innerHTML=M.length?M.map(j=>{const q=j.avatarTemplate&&Gi(Ba,Vi(j.avatarTemplate,64)),re=q?`
`:j.avatarTemplate?`${$e(String(j.ownerUsername||"?").slice(0,1).toUpperCase())}`:`${ue("history")}`,J=Fd(re,j.ownerUsername),be=Xo.has(Number(j.topicId)),X=Math.max(1,Number(j.postNumber)||1);return`
- ${fo.multi?`
-
+
`,ie.mount(yr,_t()));let aR=_t();const Hm=(i=!0)=>{const p=_t(),h=[L,Be,Ot,yr].filter(Boolean);if(i&&L.hidden){h.forEach(C=>ie.park(C)),fr(et,!0);return}const y=aR!==p;h.forEach(C=>ie.mount(C,p)),aR=p,fr(et,p!==et),y&&(L.style.removeProperty("left"),L.style.removeProperty("top"),L.style.removeProperty("transform")),oo()};Hm(!1),queueMicrotask(()=>{L.hidden&&Hm()});const qx=yr.querySelector(".ldp-color-picker-title"),Xo=yr.querySelector(".ldp-color-picker-hex"),lv=yr.querySelectorAll(".ldp-color-picker-preset"),cv=yr.querySelector(".ldp-color-picker-more"),iR=yr.querySelector(".ldp-color-picker-advanced"),$u=yr.querySelector(".ldp-color-picker-hue"),ml=yr.querySelector(".ldp-color-picker-saturation"),qc=yr.querySelector(".ldp-color-picker-brightness"),Ox=yr.querySelector(".ldp-color-picker-hue-value"),Fx=yr.querySelector(".ldp-color-picker-saturation-value"),Bx=yr.querySelector(".ldp-color-picker-brightness-value");let xo=null,Um={h:0,s:0,v:100},fi=0,Tu=null;const dv=i=>{const p=/^#?([\da-f]{3}|[\da-f]{6})$/i.exec(String(i||"").trim());return p?`#${(p[1].length===3?Array.from(p[1],y=>`${y}${y}`).join(""):p[1]).toUpperCase()}`:""},Hx=i=>{const p=dv(i).slice(1),h=parseInt(p.slice(0,2),16)/255,y=parseInt(p.slice(2,4),16)/255,C=parseInt(p.slice(4,6),16)/255,$=Math.max(h,y,C),k=Math.min(h,y,C),j=$-k;let B=0;return j&&($===h?B=60*((y-C)/j%6):$===y?B=60*((C-h)/j+2):B=60*((h-y)/j+4)),B<0&&(B+=360),{h:Math.round(B)%360,s:$?Math.round(j/$*100):0,v:Math.round($*100)}},hl=({h:i,s:p,v:h})=>{const y=p/100,C=h/100,$=C*y,k=i/60,j=$*(1-Math.abs(k%2-1)),B=C-$;return`#${(k<1?[$,j,0]:k<2?[j,$,0]:k<3?[0,$,j]:k<4?[0,j,$]:k<5?[j,0,$]:[$,0,j]).map(J=>Math.round((J+B)*255).toString(16).padStart(2,"0")).join("").toUpperCase()}`},lR=()=>{const{h:i,s:p,v:h}=Um;$u.value=String(i),ml.value=String(p),qc.value=String(h),Ox.textContent=`${i}°`,Fx.textContent=`${p}%`,Bx.textContent=`${h}%`,$u.style.setProperty("--ldp-color-slider-thumb",hl({h:i,s:100,v:100})),ml.style.setProperty("--ldp-color-slider-start",hl({h:i,s:0,v:h})),ml.style.setProperty("--ldp-color-slider-end",hl({h:i,s:100,v:h})),ml.style.setProperty("--ldp-color-slider-thumb",hl({h:i,s:p,v:h})),qc.style.setProperty("--ldp-color-slider-end",hl({h:i,s:p,v:100})),qc.style.setProperty("--ldp-color-slider-thumb",hl({h:i,s:p,v:h}))},cR=()=>{if(!xo)return;const i=xo.value.toUpperCase();qx.textContent=xo.getAttribute("aria-label")||"选择颜色",Xo.value=i,Um=Hx(i),lR(),Xo.setAttribute("aria-invalid","false"),lv.forEach(p=>bo(p,p.dataset.color===i))},dR=()=>{fi=0;const i=Tu;Tu=null,!(!i||!i.input.isConnected)&&(i.input.value=i.value,i.input.dispatchEvent(new window.Event("input",{bubbles:!0})))},Ux=i=>{xo&&(Tu={input:xo,value:i},fi||(fi=window.requestAnimationFrame(dR)))},uv=()=>{Tu&&(fi&&window.cancelAnimationFrame(fi),dR())},Oc=({restoreFocus:i=!1}={})=>{if(yr.hidden)return;uv();const p=xo;yr.hidden=!0,xo=null,i&&p&&p.focus()},uR=i=>{const y=ur(),C=i.getBoundingClientRect(),$=yr.getBoundingClientRect(),k=y.left+12,j=y.top+12,B=Math.min(Math.max(k,C.left),Math.max(k,y.right-$.width-12)),Z=C.bottom+8,J=Z+$.height<=y.bottom-12?Z:Math.max(j,C.top-$.height-8);yr.style.left=`${Math.round(B-y.left)}px`,yr.style.top=`${Math.round(J-y.top)}px`},pR=i=>{!i||i.disabled||(hs(),Ro(),Jc(),Rl(),xo=i,cR(),yr.hidden=!1,uR(i),Xo.focus(),Xo.select())},pv=(i,p=!1)=>{xo&&(uv(),xo.value=i,xo.dispatchEvent(new window.Event("input",{bubbles:!0})),cR(),p&&Oc({restoreFocus:!0}))};Se||(xx.forEach(i=>{i.setAttribute("aria-haspopup","dialog"),O(i,"click",p=>{p.preventDefault(),pR(i)}),O(i,"keydown",p=>{p.key!=="Enter"&&p.key!==" "||(p.preventDefault(),pR(i))})}),lv.forEach(i=>O(i,"click",()=>{pv(i.dataset.color,!0)})),O(Xo,"input",()=>{const i=Xo.value.trim(),p=/^#?[\da-f]{6}$/i.test(i)?dv(i):"";Xo.setAttribute("aria-invalid",String(i.length>=7&&!p)),p&&pv(p)}),O(Xo,"keydown",i=>{if(i.key==="Escape"){i.preventDefault(),Oc({restoreFocus:!0});return}if(i.key!=="Enter")return;const p=dv(Xo.value);if(!p){Xo.setAttribute("aria-invalid","true");return}i.preventDefault(),pv(p,!0)}),O(Xo,"blur",()=>{!xo||yr.contains(document.activeElement)||(Xo.value=xo.value.toUpperCase(),Xo.setAttribute("aria-invalid","false"))}),O(cv,"click",()=>{const i=iR.hidden;iR.hidden=!i,sr(cv,i),cv.textContent=i?"收起调色":"更多颜色",xo&&uR(xo)}),[$u,ml,qc].forEach(i=>O(i,"input",()=>{Um={h:Number($u.value),s:Number(ml.value),v:Number(qc.value)};const p=hl(Um);Xo.value=p,Xo.setAttribute("aria-invalid","false"),lR(),lv.forEach(h=>bo(h,h.dataset.color===p)),Ux(p)})),[$u,ml,qc].forEach(i=>O(i,"change",uv)),O(yr,"keydown",i=>{i.key==="Escape"&&(i.preventDefault(),Oc({restoreFocus:!0}))}),O(yr,"pointerdown",i=>i.stopPropagation()),O(yr,"click",i=>i.stopPropagation()),O(ne,"pointerdown",i=>{yr.hidden||yr.contains(i.target)||i.target===xo||Oc()},!0));const us=(i,p)=>{i&&p&&(i.dataset.settingHelp=p)},pr=(i,p)=>{us(i?.closest(".ldp-setting-row"),p)};pr(st,"控制所有显示形态中帖子正文图片的显示大小。100% 是正常尺寸;调小能少占屏幕,调大方便看细节。修改后立即保存。"),pr(rr,"开启后,大图查看器首次打开和切换图片时会自动请求原图;关闭后不会新请求原图,但本地已有原图缓存时仍会优先展示,否则继续使用预览图。修改后立即保存。"),pr(_r,"控制大图查看器首次打开时是否展开评论面板;打开后仍可随时手动收起或展开。修改后立即保存。"),pr(gr,"控制大图查看器首次打开时是否展开图片说明;手动收起、展开或拖动说明高度后也会自动记住。修改后立即保存。");const jx={left:"控制阅读内容左边保留多少空白。数值越大,正文会整体向右并变窄;它需要和另外四个区域合计正好 100%。",main:"控制正文区域实际占用的宽度,包括主帖和楼层内容。太小会增加换行,太大会挤压时间轴和两侧留白;五个区域必须合计 100%。",gap:"控制正文与右侧时间轴之间的空隙。调大后两块内容更分开,但正文可用宽度会减少;五个区域必须合计 100%。",timeline:"控制右侧楼层时间轴、日期和楼层计数占用的宽度。太小可能显得拥挤;五个区域必须合计 100%。",right:"控制时间轴右边保留多少空白。调大能让右侧不贴边,但会减少正文或时间轴的空间;五个区域必须合计 100%。"};N.forEach(i=>pr(i,jx[i.dataset.layoutRegion])),us(te.closest(".ldp-reader-window-field"),"设置桌面浮窗的宽度。数值越大,一行能显示的正文越多;只影响浮窗模式,修改后立即保存。"),us(we.closest(".ldp-reader-window-field"),"设置桌面浮窗的高度。数值越大,一屏能看到的楼层越多;只影响浮窗模式,修改后立即保存。"),us(Ae.closest(".ldp-reader-window-field"),"设置浮窗左边缘距离浏览器左侧的像素数。数值越大,浮窗越靠右;只影响桌面浮窗并立即保存。"),us(Ge.closest(".ldp-reader-window-field"),"设置浮窗上边缘距离浏览器顶部的像素数。数值越大,浮窗越靠下;只影响桌面浮窗并立即保存。"),us(Ye.closest(".ldp-reader-window-option"),"开启后,点击阅读器外面的网页区域也不会自动收起浮窗,适合边看帖子边操作原页面。"),us(lt.closest(".ldp-reader-window-option"),"开启后禁止拖动和缩放浮窗,避免误操作改变位置或大小;仍可通过小锁或这里重新解锁。"),fl.forEach(({input:i,help:p,range:h})=>pr(i,h?p:`${p}确定颜色后会保留色相,并按用途自动生成明亮与暗色主题的安全亮度;切换主题时自动应用。`)),Jy.forEach(i=>us(i.closest(".ldp-setting-switch"),"关闭后隐藏回复连接线、引用线和界面分隔线;已经设置的颜色、粗细和圆角不会丢失,重新开启即可恢复。"));const zx={low:"减少每次加载的内容、页面保留的楼层和提前请求量,优先降低网络、内存与主线程占用;下次打开阅读器生效。",balanced:"在加载速度、滚动流畅度、内存占用和请求稳定性之间取默认平衡;下次打开阅读器生效。",high:"增加每次加载的内容、页面保留的楼层和提前请求距离,优先减少滚动等待,但会增加内存、网络和限流压力;下次打开阅读器生效。",custom:"表示下面的性能参数已经手动调整。点击它不会自动改值,可直接修改下方各项;下次打开阅读器生效。"};Zy.forEach(i=>us(i,zx[i.dataset.performancePreset])),Am.forEach(i=>pr(i,Cp.find(p=>p.key===i.dataset.performanceKey).help)),Oa.forEach(i=>pr(Nm[i.key].input,i.help)),pr(ev,"开启后,可用的历史前进和后退按钮会一直显示,并禁用“边缘唤出按钮范围”滑块;关闭后,按钮仅在鼠标进入对应边缘或键盘聚焦时显示。修改后立即保存。"),pr(Ic,`设置阅读器左右两侧用于唤出历史前进和后退按钮的范围,各占阅读器宽度的 ${pg}%–${fg}%;范围透明且不会遮挡正文操作,0% 表示关闭鼠标唤出。修改后立即保存。`),pr(Lm,"“最近打开优先”按每次打开的最新时间倒序排列,重开旧帖后它会回到列表顶部;“首次打开顺序”按每条记录第一次进入历史的时间排列,重开不会改变位置。旧记录无法还原更早的首次打开时间,会从当前保存时间开始计算。修改后立即保存。"),pr(tv,"开启后,普通帖子链接会从 #1 主楼开始;消息、历史和收藏面板中的链接仍优先打开各自目标楼层。关闭后,所有链接都会尊重其中指定的楼层号。修改后立即保存。"),pr(rv,"开启后,需要在 1.5 秒内连续按两次 Esc 才会关闭阅读器;关闭后,按一次 Esc 即可关闭。修改后立即保存。"),pr(ov,"开启后,在阅读器内按 Esc、关闭或舍弃 LINUX DO 原生回复窗口时,需要在 1.5 秒内重复同一操作;关闭后,一次操作即可关闭。修改后立即保存。"),pr(xc,"开启后,在每条父回复下默认展开它的直接二级回复;关闭时会同时关闭“完整讨论”视图与树状评论。修改后立即保存并应用到当前帖子。"),pr(Eu,"开启后,二级回复在楼层列表中的对应位置默认完整展开;可与父回复下的二级回复同时显示,但至少要保留一种显示位置。修改后立即保存并应用到当前帖子。"),pr(Im,"建立在“在父回复下展开二级回复”之上;开启时会按滚动位置预加载更深层回复,并提供“查看完整讨论”浮窗。修改后立即保存并应用到当前帖子。"),ds&&pr(ds,"需先开启「在父回复下展开二级回复」。关闭时仅显示一层直属二级回复;开启后可在 2–5 层之间选择主信息流嵌套深度。修改后立即保存并应用到当前帖子。"),Ws&&pr(Ws,"仅在启用树状评论后可选。层数从主楼下的直属回复算起;超出部分仍可通过「完整讨论」查看。修改后立即保存并应用到当前帖子。"),pr(nv,"未启用“完整讨论”时,从楼层列表隐藏全部二级回复;启用后先保留未读二级回复,读过后再隐藏。时间轴、跳转和已读记录不受影响;跳转时会临时显示或打开对应讨论。修改后立即保存并应用到当前帖子。"),pr(xm,"选择复制 Boost 时如何生成末尾内容:“递增数字”每次按设定步长增加,“固定文字”每次追加同一段文字。修改后立即保存。"),pr(Dm,"填写复制结果开头的前置文字,例如“赞同:”。留空就直接从原 Boost 内容开始,最多 16 个字。修改后立即保存。"),pr(qm,"使用递增数字时,填写数字前缀,例如“+”会得到“原 Boost +1”;留空时数字会直接接在原文后面。修改后立即保存。"),pr(Om,"使用递增数字时,每复制一次增加多少。设为 1 会依次得到 1、2、3;设为 5 会得到 5、10、15。修改后立即保存。"),pr(Fm,"使用固定文字时,每次复制都会把这里的内容追加到原 Boost 后面,例如“俺也一样”。最多 16 个字。修改后立即保存。"),pr(nR,"展示当前规则实际会复制出的结果;使用递增数字时会同时展示连续两次复制,方便确认步长。"),Dc.forEach((i,p)=>us(i.closest(".ldp-cache-row"),Mw[p].help)),us(Mu,"只清理上面已经勾选的缓存类型。不会退出登录,也不会删除站点上的帖子、消息或图片;清理后需要时会重新联网获取。");const Do=I.readerWindow||tI(ne,S);I.readerWorkspace=xe,I.readerWindow=Do,I.topicId=Number(e),I.targetPostNumber=a,I.topicNavMetadata=K,I.commentsHeader=it,ne._ldpReaderShell=I;const fv=()=>{const i=z?$p:Rp,p=i.length;Re.hidden=p<2,Qe.hidden=p<2,ce.classList.toggle("ldp-reader-placement-available",p>1);const h=xe.mode();Ie.forEach(y=>{const C=y.dataset.readerPlacement;y.hidden=!i.includes(C),y.classList.toggle("active",y.dataset.readerPlacement===h)})},Wx=i=>{(z?$p:Rp).includes(i)&&(xe.setMode(i),Do.syncMode(),Ym(),$v(),Du(),i!==xe.mode()&&mt("页面空间不足,暂时保持浮窗"),fv())};Se||Ie.forEach(i=>{O(i,"click",()=>Wx(i.dataset.readerPlacement))}),fv();let fR=U,Au=null,Fc=0,jm=0,zm=null,qo=null;const Wm=()=>{Au&&Au.disconnect(),Fc&&cancelAnimationFrame(Fc),jm&&clearTimeout(jm),Au=null,Fc=0,jm=0},mR=()=>{const i=X(".ldp-header"),p=i?.querySelector(".ldp-title-jump");if(!i||!p)return;const h=document.createRange();h.selectNodeContents(p);const y=Array.from(h.getClientRects()).filter($=>$.width>0&&$.height>0),C=y[0]?.top;i.classList.toggle("ldp-title-single-line",y.length>0&&y.every($=>Math.abs($.top-C)<1))};I.syncReaderTitleDensity=mR;const Bc=i=>{if(!i)return;zE({topic:i}),fR=i;const p=ya(null,e);K=Ns(zm||p,Ns(K,Qd(i))),Y&&cf(Y,i),I.topicNavMetadata=K;const h=F1(i,K),y=O1(e,i,K);h&&!y&&ao(e,i);const C=Array.isArray(i.post_stream?.posts)?i.post_stream.posts:[],$=i._opUsername||i.details?.created_by&&i.details.created_by.username||(C.find(gt=>+gt.post_number==1)||{}).username||"",k=String(i.title||"").trim();k&&(X(".ldp-title-jump").textContent=k,NI(k));const j=X(".ldp-topic-edit-trigger"),B=i.details&&i.details.can_edit===!0;j.hidden=!B,!B&&qo&&qo.close(!0);const Z=$?`@${$e($)}`:"@?",J=[`${$e(String(i.posts_count||0))} 帖`,`${$e(String(i.views||0))} 浏览`];i.like_count!=null&&J.push(`${$e(String(Math.max(0,+i.like_count||0)))} 赞`),i.participant_count!=null&&J.push(`${$e(String(Math.max(0,+i.participant_count||0)))} 用户`),X(".ldp-meta-stats").innerHTML=J.join(" · "),X(".ldp-meta-owner-value").innerHTML=Z,X(".ldp-meta-owner").hidden=!1;const ye=X(".ldp-topic-tags");ye.innerHTML=B1(i,K),ye.hidden=!ye.innerHTML;const Q=X(".ldp-topic-vote-slot"),Ve=Qr("topicVoting",{topic:i})&&(i.can_vote===!0||i.user_voted===!0||+i.vote_count>0);Q.hidden=!Ve,Q.innerHTML=Ve?``:"",mR(),I.syncReaderTitleActionAlignment(),$t()},Vx=()=>{if(zm)return!1;if(!ne.isConnected)return Wm(),!1;const i=ya(null,e);return Af(i)?(Wm(),K=Ns(i,K),I.topicNavMetadata=K,Bc(fR),!0):!1},hR=()=>{Fc||(Fc=requestAnimationFrame(()=>{Fc=0,Vx()}))},Gx=()=>{if(!z||Af(K)||!document.body)return;const i=document.querySelector("#main-outlet")||document.querySelector("#ember-app")||document.body;Au=new MutationObserver(hR),Au.observe(i,{childList:!0,subtree:!0}),jm=setTimeout(Wm,5e3),hR()};Bc(U),Gx();const Pa=AN(n);Ao=Pa,Ui();let gl=0,mv=!1;const gR=()=>{try{const i=Pa.snapshot();return Math.max(0,Number(i?.cooldownRemaining)||0)}catch{}return 0},bR=()=>{if(!Zt||!dr)return;const i=gR();if(i<=0){fr(Zt,!0),"cooldownSeconds"in Zt.dataset&&delete Zt.dataset.cooldownSeconds,mv&&po&&(mv=!1,Yy(po.href));return}mv=!0;const p=Math.max(1,Math.ceil(i/1e3));Zt.dataset.cooldownSeconds!==String(p)&&(Zt.dataset.cooldownSeconds=String(p),fa(dr,`触发 429,正在等待 ${p} 秒;倒计时结束后将自动打开过盾浮窗。`)),fr(Zt,!1)},yR=()=>{gl&&clearInterval(gl),gl=0,Zt&&(fr(Zt,!0),"cooldownSeconds"in Zt.dataset&&delete Zt.dataset.cooldownSeconds)},vR=()=>{gl&&clearInterval(gl),gl=0,document.visibilityState==="visible"&&(bR(),gl=setInterval(bR,1e3))};vR();const jr=oL(e,Pa,n),Qx=jr.init(),Ft={group:"all",page:0,token:0,total:0,hasNext:!1,query:"",groups:{notifications:"all",messages:"inbox"}};let Vs="";const Pu=()=>I.activeContext?.postRequestScheduler||Ao||Pa,mo={page:0,query:"",multi:!1,scope:"page"},Ko=new Set;let hv=[];const Fe={page:0,query:"",token:0,hasNext:!1,entries:[],allEntries:[],reactionEntries:[],reactionLoadedAt:0,reactionUsername:"",reactionFilter:"",type:be[0],multi:!1,scope:"page",allIds:null},Nu=I.givenReactionOverrides instanceof Map?I.givenReactionOverrides:new Map;I.givenReactionOverrides=Nu;const sn=new Set,Hc=()=>{Fe.allIds=null,sn.clear()},gv=Ob(Jd("all",0));gv&&(Ft.total=gv.total,Ft.hasNext=gv.hasNext),Bf(To?Rs:Ff());const Yx=(i,p)=>{if(!i||!p||p.hidden||!p.isConnected)return;const h=i.getBoundingClientRect(),y=p.getBoundingClientRect(),C=8,$=Math.max(12,Math.min(window.innerWidth-y.width-12,h.right-y.width)),k=h.bottom+C,j=h.top-y.height-C,B=`${Math.round($)}px`,Z=`${Math.round(k+y.height<=window.innerHeight-12||j<12?k:j)}px`;p.style.left!==B&&(p.style.left=B),p.style.top!==Z&&(p.style.top=Z)},bv=({toggle:i,popover:p,panel:h,state:y,close:C})=>{const $=()=>ie.mount(p);return{toggle:i,popover:p,position:()=>Yx(i,p),open(){$(),fr(p,!1),sr(i,!0)},close(){const j=!p.hidden;fr(p,!0),sr(i,!1),ie.park(p),C?.(),j&&(Gr(h.list),h.list.replaceChildren())},park:()=>{fr(p,!0),sr(i,!1),ie.park(p)},message(j,B=!1){Gr(h.list),h.list.innerHTML=`${j}
`,h.pagePrev.disabled=!0,h.pageNext.disabled=!0},paging(j,B=!0,Z=y.hasNext){h.pagePrev.disabled=y.page<=0,h.pageNext.disabled=!Z,fa(h.pageInfo,B?`${y.page+1} / ${Math.max(1,j)}`:"暂无记录")}}},Na=bv({toggle:Me,popover:ke,panel:de,state:Ft}),bl=bv({toggle:ge.toggle,popover:ge.popover,panel:ge,state:mo,close:()=>{mo.multi=!1,Ko.clear()}}),ka=bv({toggle:Te.toggle,popover:Te.popover,panel:Te,state:Fe,close:()=>{I.cancelBookmarksSearch?.(),Fe.token++,Fe.multi=!1,Hc()}}),wR=Na.position,Vm=Na.close;I.closeNotifications=Vm;const Xx=bl.position,yv=bl.close,SR=ka.position,ku=ka.close,_R=[Na,bl,ka];_R.forEach(i=>i.park());const vv=()=>_R.forEach(i=>i.position()),CR=()=>{const i=qe.historySortMode==="recent-viewed",p=i?"最近打开优先;点击切换为首次打开顺序":"首次打开顺序固定;点击切换为最近打开优先";ge.sortToggle.innerHTML=ue(i?"history":"pin"),nt(ge.sortToggle,p),bo(ge.sortToggle,!i),ge.sortToggle.dataset.ldpTooltipLabel=p},ER=(i,p,h,y,C,$,k,j=!1)=>{const B=Array.isArray(y)&&y.length>0&&y.every(Z=>h.has(Z));i.defaultActions.hidden=j||p.multi,i.bulkActions.hidden=j||!p.multi,i.multi.disabled=$,i.selectScope.value=p.scope,i.selectToggle.disabled=k,i.selectToggle.innerHTML=ue(B?"squareCheck":"square"),bo(i.selectToggle,B),nt(i.selectToggle,`${B?"全不选":"全选"}${p.scope==="all"?"全部页面":"本页"}${C}`),i.deleteSelected.disabled=h.size===0,i.deleteSelectedLabel.textContent=String(h.size),i.deleteSelectedLabel.hidden=h.size===0},wv=(i,p,h)=>{const y=Math.max(1,Math.ceil(i.length/h));p.page=Math.max(0,Math.min(y-1,p.page));const C=p.page*h;return{entries:i.slice(C,C+h),totalPages:y}};function ps(){CR();const i=dn(),p=Wg(i),h=new Set(p.map(j=>Number(j.topicId)));Ko.forEach(j=>{h.has(j)||Ko.delete(j)});const y=mo.query?p.filter(j=>cN(j,mo.query)):p,{entries:C,totalPages:$}=wv(y,mo,dA);Gr(ge.list),ge.list.innerHTML=C.length?C.map(j=>{const B=j.avatarTemplate&&Yi(ja,Qi(j.avatarTemplate,64)),Z=B?`
`:j.avatarTemplate?`${$e(String(j.ownerUsername||"?").slice(0,1).toUpperCase())}`:`${ue("history")}`,J=Yd(Z,j.ownerUsername),ye=Ko.has(Number(j.topicId)),Q=Math.max(1,Number(j.postNumber)||1);return`
+ ${mo.multi?`
+
`:""}
- ${Ab("ldp-history-link",qd(j.topicId,X,!0),J,j.title,`${j.postsCount||0} 帖 · ${ha(Dg(j))}`)}
- ${fo.multi?"":``}
-
`}).join(""):`${fo.query?"没有匹配的浏览历史":"暂无浏览历史"}
`,lN(ge.list,M,i),fo.hasNext=fo.pageNumber(j.topicId)),p$(ge,fo,Xo,nv,"浏览历史",p.length===0,L.length===0),ge.clear.disabled=p.length===0,pl.paging(T,y.length>0),Nx()}x.refreshHistoryPanel=()=>{ge.popover.hidden||(Oe.historySortMode==="recent-viewed"&&(fo.page=0),cs())};const kx=i=>{const p=i?.user||{},h=Kn(p.avatar_template,64,"ldp-notification-avatar",p.name||p.username||"?");return h?Fd(h,p.username):`${ue("bookmark")}`},dv=i=>({topicId:Number(i?.topic_id)||0,postNumber:Math.max(1,Number(i?.linked_post_number)||1)}),Lx=i=>Array.isArray(i)?i:Array.isArray(i?.user_reactions)?i.user_reactions:Array.isArray(i?.reaction_users)?i.reaction_users:Array.isArray(i?.reactions)?i.reactions:[],uv=(i,p,h,y=0,M="",T="")=>{const L=Number(i?.id)||0,j=Number(i?.topic_id||p?.id)||0;return{id:Number(y)||L,topicId:j,postId:L,postNumber:Math.max(1,Number(i?.post_number)||1),reaction:h,title:String(i?.topic_title||T||p?.title||`帖子 #${j}`),authorUsername:String(i?.username||""),avatarTemplate:String(i?.avatar_template||""),reactedAt:String(M||"")}},Ix=i=>{const p=i?.post||{},h=p.topic||{},y=p.user||{},M=i?.reaction||{},T=Number(i?.id)||0,L=String(M.reaction_value||i?.reaction_value||""),j={id:i?.post_id||p.id,topic_id:p.topic_id||h.id||i?.topic_id,post_number:p.post_number||i?.post_number,topic_title:p.topic_title||h.title||i?.topic_title,username:p.username||y.username,avatar_template:p.avatar_template||y.avatar_template};return!T||!Number(j.topic_id)||!Number(j.id)||!L?null:uv(j,null,L,T,i?.created_at||M.created_at)},xx=i=>{const p=Number(i?.topic_id)||0,h=Number(i?.post_id)||0;return Number(i?.action_type)!==1||!p||!h?null:uv({...i,id:h},null,"heart",i.id,i.created_at,i.title)},f$=i=>new Map((Array.isArray(i)?i:[]).map(p=>[Number(p?.postId),p]).filter(([p])=>p>0)),pv=i=>{const p=f$(i);return Cu.forEach((h,y)=>{const M=p.get(y);if(h&&Number(M?.id)>0&&M.reaction===h.reaction){Cu.delete(y);return}if(!h&&!M){Cu.delete(y);return}p.delete(y),h&&p.set(y,h)}),[...p.values()].sort(I_)},m$=(i,p,h)=>{const y=Number(i?.id)||0;return uv(i,p,h,-y,new Date().toISOString())},qx=(i,p)=>{const h=Number(i?.id)||0;if(!h)return!1;const y=i?.current_user_reaction,M=String(y?.id||"");return Cu.set(h,M?m$(i,p,M):null),!0},Dx=(i,p,h,y)=>{const M=Number(p?.id)||0;if(!M)return Array.isArray(i)?i:[];const T=f$(i),L=T.get(M);return y&&(!L||L.reaction==="heart")?T.set(M,m$(p,h,"heart")):!y&&L&&L.reaction==="heart"&&T.delete(M),[...T.values()].sort(I_)},Ox=(i,p=0)=>{const y=Ft("discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction")?.default;return!y||!D(y.findReactions)?Promise.reject(new Error("Discourse 回应记录接口尚未就绪")):y.findReactions("reactions",i,{beforeReactionUserId:p>0?p:void 0})},fv=async(i,p,h,y,M)=>{const T=new Map,L=new Set;let j=0;for(;;){const q=await i(j);if(!M())return null;const[re,J]=p(q,j);if(re.forEach(be=>{const X=h(be);X&&T.set(y(X),X)}),J==null||L.has(J))break;L.add(J),j=J}return[...T.values()]},h$=i=>i===De.token&&!Re.popover.hidden&&De.type==="Reaction",Fx=(i,p=De.token)=>fv(h=>Ox(i,h),h=>{const y=Lx(h),M=y.length>=eA&&y.reduce((T,L)=>{const j=Number(L?.id)||0;return j>0&&(!T||jh.id,()=>h$(p)),Bx=(i,p=0)=>{const h=new URLSearchParams({username:i,filter:"1",offset:String(Math.max(0,Number(p)||0)),limit:String(fw)});return ao(`${vt}/user_actions.json?${h}`,{key:`likes-given:${i}:${p}`,priority:tt.auxiliary,cacheMode:"default"})},Hx=(i,p=De.token)=>fv(h=>Bx(i,h),(h,y)=>{const M=Array.isArray(h?.user_actions)?h.user_actions:[];return[M,M.lengthh.postId,()=>h$(p)),Ux=()=>{const i=ko(De.query);return De.reactionEntries.filter(p=>(!De.reactionFilter||p.reaction===De.reactionFilter)&&V1(p,i))},jx=i=>Fd(Kn(i?.avatarTemplate,64,"ldp-notification-avatar",i?.authorUsername||"?")||`${$e(String(i?.authorUsername||"?").slice(0,1).toUpperCase())}`,i?.authorUsername),g$=()=>De.type==="Reaction"?Ux():De.allEntries.filter(i=>i&&i.bookmarkable_type===De.type),Ko=()=>{const i=De.type==="Reaction",p=new Map;i&&De.reactionEntries.forEach(q=>{const re=String(q?.reaction||"");re&&p.set(re,(p.get(re)||0)+1)});const h=[...p.keys()];De.reactionFilter&&!h.includes(De.reactionFilter)&&(De.reactionFilter=""),mt.hidden=!i||h.length===0,mt.innerHTML=i&&h.length?[``,...h.map(q=>{const re=q===De.reactionFilter,J=zd(q)||$e(`:${q}:`),be=p.get(q)||0;return``})].join(""):"";const y=g$(),{entries:M,totalPages:T}=cv(y,De,ZT);if(De.entries=M,De.hasNext=De.pageNumber(re.id)));nn.forEach(re=>{q.has(re)||nn.delete(re)})}Vr(Re.list),i?Re.list.innerHTML=M.length?M.map(q=>{const re=String(q.reaction||""),J=zd(re)||$e(`:${re}:`),be=q.authorUsername?` · @${q.authorUsername}`:"",X=Number(q.topicId),Ue=Math.max(1,Number(q.postNumber)||1);return`
- ${Ab("ldp-bookmark-link",qd(X,Ue,!0),jx(q),q.title||`帖子 #${q.topicId}`,`${J}${$e(`回应 · 楼层 #${Ue}${be} · ${ha(q.reactedAt)}`)}`,!0)}
-
`}).join(""):`${De.query?"没有匹配的回应记录":"暂无回应记录;在楼层下方点回应后会出现在这里。"}
`:Re.list.innerHTML=M.length?M.map(q=>{const re=Number(q.id),J=dv(q),be=nn.has(re),X=q.bookmarkable_type==="Post"?`楼层 #${J.postNumber}`:"帖子",Ue=Number(q.highest_post_number)>0?` · ${Number(q.highest_post_number)} 帖`:"",yt=String(q.name||"").trim()?` · ${String(q.name).trim()}`:"";return`
- ${De.multi?`
-
+ ${qb("ldp-history-link",Vd(j.topicId,Q,!0),J,j.title,`${j.postsCount||0} 帖 · ${va(zg(j))}`)}
+ ${mo.multi?"":``}
+
`}).join(""):`${mo.query?"没有匹配的浏览历史":"暂无浏览历史"}
`,RN(ge.list,C,i),mo.hasNext=mo.page<$-1;const k=mo.scope==="all"?y:C;hv=k.map(j=>Number(j.topicId)),ER(ge,mo,Ko,hv,"浏览历史",p.length===0,k.length===0),ge.clear.disabled=p.length===0,bl.paging($,y.length>0),Xx()}I.refreshHistoryPanel=()=>{ge.popover.hidden||(qe.historySortMode==="recent-viewed"&&(mo.page=0),ps())};const Kx=i=>{const p=i?.user||{},h=Zn(p.avatar_template,64,"ldp-notification-avatar",p.name||p.username||"?");return h?Yd(h,p.username):`${ue("bookmark")}`},Sv=i=>({topicId:Number(i?.topic_id)||0,postNumber:Math.max(1,Number(i?.linked_post_number)||1)}),Jx=i=>Array.isArray(i)?i:Array.isArray(i?.user_reactions)?i.user_reactions:Array.isArray(i?.reaction_users)?i.reaction_users:Array.isArray(i?.reactions)?i.reactions:[],_v=(i,p,h,y=0,C="",$="")=>{const k=Number(i?.id)||0,j=Number(i?.topic_id||p?.id)||0;return{id:Number(y)||k,topicId:j,postId:k,postNumber:Math.max(1,Number(i?.post_number)||1),reaction:h,title:String(i?.topic_title||$||p?.title||`帖子 #${j}`),authorUsername:String(i?.username||""),avatarTemplate:String(i?.avatar_template||""),reactedAt:String(C||"")}},Zx=i=>{const p=i?.post||{},h=p.topic||{},y=p.user||{},C=i?.reaction||{},$=Number(i?.id)||0,k=String(C.reaction_value||i?.reaction_value||""),j={id:i?.post_id||p.id,topic_id:p.topic_id||h.id||i?.topic_id,post_number:p.post_number||i?.post_number,topic_title:p.topic_title||h.title||i?.topic_title,username:p.username||y.username,avatar_template:p.avatar_template||y.avatar_template};return!$||!Number(j.topic_id)||!Number(j.id)||!k?null:_v(j,null,k,$,i?.created_at||C.created_at)},eD=i=>{const p=Number(i?.topic_id)||0,h=Number(i?.post_id)||0;return Number(i?.action_type)!==1||!p||!h?null:_v({...i,id:h},null,"heart",i.id,i.created_at,i.title)},MR=i=>new Map((Array.isArray(i)?i:[]).map(p=>[Number(p?.postId),p]).filter(([p])=>p>0)),Cv=i=>{const p=MR(i);return Nu.forEach((h,y)=>{const C=p.get(y);if(h&&Number(C?.id)>0&&C.reaction===h.reaction){Nu.delete(y);return}if(!h&&!C){Nu.delete(y);return}p.delete(y),h&&p.set(y,h)}),[...p.values()].sort(V_)},RR=(i,p,h)=>{const y=Number(i?.id)||0;return _v(i,p,h,-y,new Date().toISOString())},tD=(i,p)=>{const h=Number(i?.id)||0;if(!h)return!1;const y=i?.current_user_reaction,C=String(y?.id||"");return Nu.set(h,C?RR(i,p,C):null),!0},rD=(i,p,h,y)=>{const C=Number(p?.id)||0;if(!C)return Array.isArray(i)?i:[];const $=MR(i),k=$.get(C);return y&&(!k||k.reaction==="heart")?$.set(C,RR(p,h,"heart")):!y&&k&&k.reaction==="heart"&&$.delete(C),[...$.values()].sort(V_)},oD=(i,p=0)=>{const y=Bt("discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction")?.default;return!y||!D(y.findReactions)?Promise.reject(new Error("Discourse 回应记录接口尚未就绪")):y.findReactions("reactions",i,{beforeReactionUserId:p>0?p:void 0})},Ev=async(i,p,h,y,C)=>{const $=new Map,k=new Set;let j=0;for(;;){const B=await i(j);if(!C())return null;const[Z,J]=p(B,j);if(Z.forEach(ye=>{const Q=h(ye);Q&&$.set(y(Q),Q)}),J==null||k.has(J))break;k.add(J),j=J}return[...$.values()]},$R=i=>i===Fe.token&&!Te.popover.hidden&&Fe.type==="Reaction",nD=(i,p=Fe.token)=>Ev(h=>oD(i,h),h=>{const y=Jx(h),C=y.length>=pA&&y.reduce(($,k)=>{const j=Number(k?.id)||0;return j>0&&(!$||j<$)?j:$},0);return[y,C||null]},Zx,h=>h.id,()=>$R(p)),sD=(i,p=0)=>{const h=new URLSearchParams({username:i,filter:"1",offset:String(Math.max(0,Number(p)||0)),limit:String(Ew)});return io(`${vt}/user_actions.json?${h}`,{key:`likes-given:${i}:${p}`,priority:rt.auxiliary,cacheMode:"default"})},aD=(i,p=Fe.token)=>Ev(h=>sD(i,h),(h,y)=>{const C=Array.isArray(h?.user_actions)?h.user_actions:[];return[C,C.lengthh.postId,()=>$R(p)),iD=()=>{const i=ko(Fe.query);return Fe.reactionEntries.filter(p=>(!Fe.reactionFilter||p.reaction===Fe.reactionFilter)&&dN(p,i))},lD=i=>Yd(Zn(i?.avatarTemplate,64,"ldp-notification-avatar",i?.authorUsername||"?")||`${$e(String(i?.authorUsername||"?").slice(0,1).toUpperCase())}`,i?.authorUsername),TR=()=>Fe.type==="Reaction"?iD():Fe.allEntries.filter(i=>i&&i.bookmarkable_type===Fe.type),Jo=()=>{const i=Fe.type==="Reaction",p=new Map;i&&Fe.reactionEntries.forEach(B=>{const Z=String(B?.reaction||"");Z&&p.set(Z,(p.get(Z)||0)+1)});const h=[...p.keys()];Fe.reactionFilter&&!h.includes(Fe.reactionFilter)&&(Fe.reactionFilter=""),ht.hidden=!i||h.length===0,ht.innerHTML=i&&h.length?[``,...h.map(B=>{const Z=B===Fe.reactionFilter,J=eu(B)||$e(`:${B}:`),ye=p.get(B)||0;return``})].join(""):"";const y=TR(),{entries:C,totalPages:$}=wv(y,Fe,uA);if(Fe.entries=C,Fe.hasNext=Fe.page<$-1,sn.size){const B=new Set(i?[]:y.map(Z=>Number(Z.id)));sn.forEach(Z=>{B.has(Z)||sn.delete(Z)})}Gr(Te.list),i?Te.list.innerHTML=C.length?C.map(B=>{const Z=String(B.reaction||""),J=eu(Z)||$e(`:${Z}:`),ye=B.authorUsername?` · @${B.authorUsername}`:"",Q=Number(B.topicId),Ve=Math.max(1,Number(B.postNumber)||1);return`
+ ${qb("ldp-bookmark-link",Vd(Q,Ve,!0),lD(B),B.title||`帖子 #${B.topicId}`,`${J}${$e(`回应 · 楼层 #${Ve}${ye} · ${va(B.reactedAt)}`)}`,!0)}
+
`}).join(""):`${Fe.query?"没有匹配的回应记录":"暂无回应记录;在楼层下方点回应后会出现在这里。"}
`:Te.list.innerHTML=C.length?C.map(B=>{const Z=Number(B.id),J=Sv(B),ye=sn.has(Z),Q=B.bookmarkable_type==="Post"?`楼层 #${J.postNumber}`:"帖子",Ve=Number(B.highest_post_number)>0?` · ${Number(B.highest_post_number)} 帖`:"",gt=String(B.name||"").trim()?` · ${String(B.name).trim()}`:"";return`
+ ${Fe.multi?`
+
`:""}
- ${Ab("ldp-bookmark-link",qd(J.topicId,J.postNumber,!0),kx(q),q.title||`帖子 #${J.topicId}`,`${X}${Ue}${yt} · ${ha(q.created_at)}`)}
- ${De.multi?"":``}
-
`}).join(""):`${De.query?`没有匹配的${De.type==="Post"?"楼层":"帖子"}收藏`:`暂无${De.type==="Post"?"楼层":"帖子"}收藏`}
`,wo(Re.list,tt.auxiliary),Ql(Be,"bookmarkType",De.type),Re.search.placeholder=i?"搜索回应、帖子或用户":"搜索收藏标题或内容",rt(Re.search,i?"搜索回应记录":"搜索收藏"),rt(Re.searchClear,i?"清空回应搜索":"清空收藏搜索");const L=i?[]:M.map(q=>Number(q.id)).filter(q=>q>0),j=De.scope==="all"?De.allIds:L;p$(Re,De,nn,j,"收藏",y.length===0,De.scope==="page"&&L.length===0,i),Ta.paging(T,y.length>0),c$()},zx=(i=De.query,p=De.token)=>fv(async h=>{const y=Ps();if(!y)throw new Error("登录后才能查看收藏");const M=new URLSearchParams({page:String(Math.max(0,Number(h)||0))});return i&&M.set("q",i),ao(`${vt}/u/${encodeURIComponent(y)}/bookmarks.json?${M}`,{key:`bookmarks:${h}:${i}`,priority:tt.auxiliary,cacheMode:"default"})},(h,y)=>{const M=h?.user_bookmark_list||h||{};return[Array.isArray(M.bookmarks)?M.bookmarks:[],M.more_bookmarks_url?y+1:null]},h=>Number(h?.id)>0?h:null,h=>Number(h.id),()=>p===De.token&&!Re.popover.hidden),fl=async()=>{const i=++De.token,p=De.type==="Reaction";let h=p&&De.reactionLoadedAt>0&&De.reactionUsername===Yt,y=null;const M=()=>{Lo||y||(y=nc().then(()=>{h&&i===De.token&&!Re.popover.hidden&&De.type==="Reaction"&&Ko()}).finally(()=>{y=null}))};h?Ko():Ta.message(`正在加载${p?"回应":"收藏"}…`);try{if(p){const L=Ps();if(!L)throw new Error("登录后才能查看回应记录");if(De.reactionUsername&&De.reactionUsername!==L&&(De.reactionEntries=[],De.reactionLoadedAt=0,h=!1),!h&&De.reactionLoadedAt>0&&De.reactionUsername===L&&(h=!0,Ko()),h){if(M(),Date.now()-De.reactionLoadedAt<=oo)return}else{const re=await QA(L);if(i!==De.token||Re.popover.hidden||De.type!=="Reaction"||re&&(De.reactionEntries=pv(re.entries),De.reactionLoadedAt=re.storedAt,De.reactionUsername=L,h=!0,Ko(),M(),re.fresh))return}const[j,q]=await Promise.all([Fx(L,i),Hx(L,i)]);if(!j||!q||i!==De.token||Re.popover.hidden||De.type!=="Reaction")return;De.reactionEntries=pv([...q,...j]),De.reactionLoadedAt=Date.now(),De.reactionUsername=L,h=!0,Xw(L,De.reactionEntries),De.multi=!1,kc(),Ko(),M();return}const T=await zx(De.query,i);if(!T||i!==De.token||Re.popover.hidden)return;De.allEntries=T,De.allIds=null,Ko()}catch(T){if(i!==De.token||Re.popover.hidden||T&&T.name==="AbortError"||p&&h)return;De.entries=[],p?De.reactionEntries=[]:De.allEntries=[],De.hasNext=!1,Ta.message(`${p?"回应记录":"收藏"}加载失败:${$e(T.message)}`,!0),Re.pageInfo.textContent=`第 ${De.page+1} 页`}},b$=i=>{const p=x.activeContext,h=dv(i);if(!p||Number(p.topicId)!==h.topicId)return;const y=i&&i.bookmarkable_type==="Post"?"Post":"Topic";mM(p,y,i?.bookmarkable_id,!1,null,{persist:!0})},Wx=()=>{const i=eo[Ot.group]||eo.all,p=P_(Ot.group);Ql(ht,"notificationMode",p),St.forEach(h=>ur(h,h.dataset.notificationModePanel!==p)),ur(de.toolbar,i.source!=="notifications"&&p!=="messages"),ur(de.unreadStatus,i.source!=="notifications"),ur(de.markAll,i.source!=="notifications"),ur(de.newMessage,p!=="messages")},mv=(i,p={})=>{const h=i.notifications;Ot.total=i.total,Ot.hasNext=i.hasNext,Vr(de.list),de.list.innerHTML=h.length?cN(h):`${p.search?"本地缓存中没有匹配消息":"暂无消息"}
`,iN(de.list,h),eo[Ot.group]?.source==="notifications"&&de.markAll.dataset.ldpRequestBusy!=="1"&&(de.markAll.disabled=Nf(Ot.group)<=0);const y=Math.max(1,Math.ceil(Ot.total/Mb(Ot.group)));Ra.paging(y,Ot.total>0),!Ot.total&&p.search&&(de.pageInfo.textContent="本地缓存"),l$()},Vx=()=>{const i=ko(Ot.query),p=Rb(Ot.group).filter(T=>z1(T,i)),h=Mb(Ot.group),{entries:y,totalPages:M}=cv(p,Ot,h);mv({notifications:y,total:p.length,hasNext:Ot.page{const y=++Ot.token,M=h.force===!0;if(Ot.group=Object.hasOwn(eo,i)?i:"all",Ot.groups[P_(Ot.group)]=Ot.group,Ot.page=Math.max(0,Number(p)||0),de.pageInfo.removeAttribute("title"),Wx(),Ql(lr,"notificationGroup",Ot.group),Ot.query){Us&&_u().cancelQueued(Us),Us="",Vx();return}const T=Ud(Ot.group,Ot.page),L=`notifications:${T}`;Us&&Us!==L&&_u().cancelQueued(Us);const j=Pb(T),q=j&&Rs.get(T),re=q&&Date.now()-Number(q.at||0)<=pw;if(j&&(mv(j),re&&!M)){Us="";return}Us=L,j||Ra.message("正在加载消息…");try{const J=await Lb(Ot.group,Ot.page,{force:M||!!j});if(y!==Ot.token||Le.hidden)return;mv(J)}catch(J){if(J&&J.name==="AbortError"||y!==Ot.token||Le.hidden)return;if(j){de.pageInfo.textContent=`${Ot.page+1} 页 · 缓存更新失败`,de.pageInfo.title=String(J?.message||"无法更新缓存");return}Ra.message(`消息加载失败:${$e(J.message)}`,!0),de.pageInfo.textContent=`第 ${Ot.page+1} 页`}finally{Us===L&&(Us="")}};let Lc=null,Mu=null,Ic=0;const hv=()=>{if(Ic&&clearTimeout(Ic),Ic=0,Lc&&Mu&&D(Lc.off))try{Lc.off("notifications:changed",x,Mu)}catch{}Lc=null,Mu=null},Gx=()=>{Le.hidden||Ic||(Ic=setTimeout(()=>{Ic=0,!Le.hidden&&ml(Ot.group,Ot.page,{force:!0})},XT))},Qx=()=>{Ps(),Nb(),Gx()},Yx=()=>{if(Lc&&Mu)return!0;hv();const i=It("service:app-events");if(!i||!D(i.on))return!1;const p=()=>Qx();try{return i.on("notifications:changed",x,p),Lc=i,Mu=p,!0}catch{return hv(),!1}};Ae||(x.startNotificationLiveEvents=Yx,x.stopNotificationLiveEvents=hv,O(document,"visibilitychange",()=>{document.visibilityState==="visible"&&(Ps(),x.startNotificationLiveEvents?.(),Nb(),Le.hidden||ml(Ot.group,Ot.page,{force:!0}))}));const gv=()=>{vf(qe.isFullPage()||window.innerWidth<=Ss)};let pi=qe.isFullPage(),Fm=uf(pi),Bm="interface",bv=Wi(),yv=ti(),$u=Ka(Oe);const fi=()=>bv,hl=i=>{bv=Id(i,Ei)},vv=()=>Id(Ei),Xx=()=>pi?"fullpageLayoutProfile":"layoutProfile",y$=()=>pi?Ll:cd,wv=()=>uf(pi),v$=(i=!1)=>{const p=qe.isFullPage();!i&&pi===p||(pi=p,Fm=uf(p))},xc=()=>Fm,w$=i=>{Fm=Td(i,y$())},S$=()=>Td(y$()),Aa=()=>yv,_$=i=>{yv=hf(i,Jo)},Sv=()=>hf(Jo),_v=()=>{Ev(),Tu(),js(),Au()},Cv=(i=!1)=>{k.hidden||(Um(),Pu(),En(),yl(),i&&Dc())},Hm=()=>{const i=qe.isFullPage();Qr.hidden=!U&&!i,Qr.innerHTML=`${ue(i?"minimize2":"maximize2")}${i?"浮窗":"全页"}`,rt(Qr,i?"切换到浮窗":"切换到全页"),qo.syncMode(),gv()},Kx=()=>{Hm(),rv(),v$(),x.syncSettingsSurfaceGeometry?x.syncSettingsSurfaceGeometry():ro(),kd(),_v();const i=x.activeContext;i?.nativeComposerWindow&&i.nativeComposerWindow.syncLayer(),lv(),Cv(!0),requestAnimationFrame(()=>{x.syncFullPageHeaderAlignment?.(),se._ldpSyncTopicActionRailPosition?.()})};Ae||(Hm(),O(se,"ldp-reader-workspace-change",Kx),O(Qr,"click",()=>{if(!U&&!qe.isFullPage())return;qe.setMode(qe.isFullPage()?"floating":"fullpage"),Hm(),Cv(),_v(),lv();const i=x.activeContext;i&&(i.streamListOffset=NaN,i.streamNeedsRepair=!0,bo(i))}));const Ev=()=>{se.style.setProperty("--ldp-image-zoom",String(ub()/100))},Jx=Z('[data-settings-panel="font"]'),Mv=Z('[data-settings-panel="layout"]'),Zx=Mv.querySelector(".ldp-settings-title"),eq=Mv.querySelector(".ldp-settings-description"),C$=Z('[data-settings-panel="appearance"]'),tq=()=>{const i=Aa(),p=ti();return aw.some(h=>i[h]!==p[h])},Ru=()=>!k.hidden&&!C$.hidden?Aa():ti(),rq=()=>!k.hidden&&!Jx.hidden?fi():Wi(),js=()=>{const i=rq(),p=Math.min(1.1,Math.max(1,.73+S.clientWidth/4e3)),h=Math.min(1,Math.max(0,(S.clientWidth-na)/(iw-na))),y=ua(i.interface)/100,M=(q,re)=>`${Math.round((q+(re-q)*h)*y*10)/10}px`,T={...i,interface:i.interface*p,post:i.post*p,composer:i.composer*p};se.style.setProperty("--ldp-reader-display-scale",String(p)),se.style.setProperty("--ldp-reader-title-font-size",M(zT,ud["--ldp-font-xl"])),se.style.setProperty("--ldp-reader-meta-font-size",M(WT,ud["--ldp-font-sm"])),se.style.setProperty("--ldp-reader-topic-tag-font-size",M(VT,ud["--ldp-font-sm"])),pb(se.style,T.interface),pb(ln?.style,T.interface),ff(se.style,T),ff(ln?.style,T),ff(Lt.style,T);const L=s_(HT,T.post);se.style.setProperty("--ldp-post-font-size",L),ln?.style.setProperty("--ldp-post-font-size",L),yf(T.composer);const j=x.activeContext;j?.nativeComposerWindow&&j.nativeComposerWindow.syncFont(T),x.syncReaderTitleDensity?.(),x.syncReaderTitleActionAlignment?.()},Tu=()=>{const i=qe.isFullPage(),h=!k.hidden&&!Mv.hidden&&pi===i?xc():uf(i);Ro.forEach(y=>{se.style.setProperty(`--ldp-layout-${y}`,`${h[y]}%`)}),requestAnimationFrame(()=>x.syncFullPageHeaderAlignment?.())},E$=()=>{const i=new Set([ln,se]);[...qa?.children||[]].forEach(p=>i.add(p)),document.querySelectorAll("[data-ldp-reader-composer-root]").forEach(p=>i.add(p)),i.forEach(p=>l_(p,Ru()))},M$=()=>{const i=Ru();se.style.setProperty("--ldp-zebra-color",pa(i,"zebraColor")),se.style.setProperty("--ldp-zebra-radius",`${i.zebraRadius}px`)},$$=()=>{const i=pa(Ru(),"listZebraColor");if(!qe.isEmbedded()){Lt.style.removeProperty("--ldp-reader-list-zebra-color");return}Lt.style.setProperty("--ldp-reader-list-zebra-color",i)},$v=(i,p)=>{const h=Ru(),y=`${i}Line`,M=`--ldp-${i}-line`,T=`${y}Color`,L=pa(h,T),j=Jo[fb(T)],q=h[`${y}Width`],re=h.structureColorsEnabled!==!1;if(se.style.setProperty(`${M}-color`,re?L:"transparent"),se.style.setProperty(`${M}-width`,`${q}px`),se.style.setProperty(`${M}-emphasis-width`,`${q*p}px`),i==="reply"){se.style.setProperty(`${M}-secondary-width`,`${q+.5}px`),se.style.setProperty(`${M}-radius`,`${h.replyLineRadius}px`),Ge(".ldp-children").forEach($E);const be=x.activeContext;be?.floorPreview&&be.floorPreview.isOpen()&&fy(be);return}if(i!=="divider")return;const J=qe.isEmbedded()&&!re;Lt.classList.toggle("ldp-structure-colors-disabled",J),!qe.isEmbedded()||!re||L===j?Lt.style.removeProperty("--ldp-divider-line-color"):Lt.style.setProperty("--ldp-divider-line-color",L),!qe.isEmbedded()||q===mp?Lt.style.removeProperty("--ldp-divider-line-width"):Lt.style.setProperty("--ldp-divider-line-width",`${q}px`)},R$=()=>$v("reply",2),T$=()=>$v("quote",5),A$=()=>$v("divider",2),P$=()=>{const i=Ru();se.style.setProperty("--ldp-floor-preview-color",pa(i,"floorPreviewColor")),se.style.setProperty("--ldp-floor-preview-height",`${i.floorPreviewHeight}px`);const p=x.activeContext;p?.floorPreview&&p.floorPreview.isOpen()&&fy(p)},Au=()=>{E$(),M$(),$$(),R$(),T$(),A$(),P$()},Rv=(i=Ka(Oe))=>{se.style.setProperty("--ldp-jump-highlight-color",i.color),se.style.setProperty("--ldp-jump-highlight-radius",`${i.radius}px`),se.style.setProperty("--ldp-jump-highlight-border-width",`${i.borderWidth}px`),se.style.setProperty("--ldp-jump-highlight-duration",`${US(i)}ms`),se.style.setProperty("--ldp-jump-highlight-count",String(i.count))};se._ldpApplyThemeDependentColors=()=>{Au(),!k.hidden&&!C$.hidden&&yl()};const gl=i=>{const p=Number(i.min)||0,h=Number(i.max)||100,y=Number(i.value),M=h>p&&Number.isFinite(y)?Math.min(100,Math.max(0,(y-p)/(h-p)*100)):0;i.style.setProperty("--ldp-range-progress",`${M}%`)},Um=()=>{const i=ub();ot.value=String(i),Et.textContent=`${i}%`,gl(ot);const p=Il,h=Ld(p.preset==="custom"?p.custom:p.preset,100);Tt.disabled=i===h,rr.checked=Oe.lightboxOriginalByDefault===!0,_r.checked=Oe.lightboxCommentsExpandedByDefault===!0,hr.checked=Oe.lightboxDescriptionExpanded===!0};let jm=[],zm=[],zs="idle",Wm=null,ds="interface";const mi=new Set,Cn=(i=ds)=>i==="host"?Jh:ra[i]||ra.interface,bl=(i=ds,p=fi())=>i==="host"?Oe:p,N$=()=>[...Kt].find(i=>i.dataset.fontScope===ds),k$=(i,p,h="")=>{const y=new Map;return[...i||[]].forEach(M=>{const T=ei(M&&(M.family||M[h]));if(!T)return;const L=T.toLocaleLowerCase(),j=y.get(L)||{name:T,search:new Set};p.forEach(q=>{M?.[q]&&j.search.add(String(M[q]))}),y.set(L,j)}),[...y.values()].map(M=>({name:M.name,search:[...M.search]})).sort((M,T)=>String(M.name||"").localeCompare(String(T.name||""),"zh-CN",{sensitivity:"base",numeric:!0}))},L$=()=>{try{return k$(document.fonts,["family","style","weight","stretch"])}catch{return[]}},oq=(i=fi())=>{Kt.forEach(T=>{const L=T.dataset.fontScope,j=T.querySelector(".ldp-font-family-value"),q=Cn(L),re=bl(L,i),J=Nn(re[q.family]);j.textContent=J!=="custom"?Wn[J].label:ei(re[q.customFamily])||"手动输入字体名",T.dataset.ldpTooltipLabel=j.textContent});const p=Cn(),h=bl(ds,i),y=Nn(h[p.family]),M=ko(h[p.customFamily]);br.querySelectorAll(".ldp-font-family-option").forEach(T=>{const L=T.dataset.fontKind==="preset"?y===T.dataset.fontValue:y==="custom"&&ko(T.dataset.fontValue)===M;T.setAttribute("aria-selected",String(L))})},nq=({kind:i,value:p,label:h,source:y,stack:M,search:T=[]},L)=>{const j=ke("button"),q=Cn(),re=i==="preset"?Nn(L[q.family])===p:Nn(L[q.family])==="custom"&&ko(L[q.customFamily])===ko(p);return j.className="ldp-font-family-option ldp-picker-option",j.type="button",j.dataset.fontKind=i,j.dataset.fontValue=p,j.dataset.searchText=[h,y,...T].flatMap(J=>Tf(J)).join(" "),j.setAttribute("role","option"),j.setAttribute("aria-selected",String(re)),j.innerHTML=`${$e(h)}${$e(y)}Aa 字`,j.querySelector(".ldp-font-family-preview").style.fontFamily=M,j},Vm=(i,p,h,y)=>{if(!h.length)return;const M=ke("div");M.className="ldp-font-family-group",M.setAttribute("role","group"),rt(M,p);const T=ke("div");T.className="ldp-font-family-group-title",T.textContent=`${p} · ${h.length}`,M.append(T),h.forEach(L=>M.append(nq(L,y))),i.append(M)},I$=()=>{const i=ko(Ut.value);let p=0;br.querySelectorAll(".ldp-font-family-group").forEach(h=>{let y=0;h.querySelectorAll(".ldp-font-family-option").forEach(M=>{const T=!i||String(M.dataset.searchText||"").includes(i);M.hidden=!T,T&&(y+=1)}),h.hidden=y===0,p+=y}),rn.hidden=p!==0},qc=()=>{const i=bl(),p=Cn(),h=(be,X)=>({kind:"named",value:be.name,label:be.name,source:X,stack:`${JSON.stringify(be.name)},${Wn.system.stack}`,search:be.search}),y=Object.entries(Wn).filter(([be])=>be!=="custom").map(([be,X])=>({kind:"preset",value:be,label:X.label,source:be==="site"?"站点样式":be===Ci?"默认字体":"阅读器预设",stack:be==="site"?r_():X.stack,search:[X.stack]})),M=jm.map(be=>h(be,"页面已加载")),T=zm.map(be=>h(be,"本机已安装")),L=ei(i[p.customFamily]),j=new Set([...jm,...zm].map(be=>ko(be.name))),q=Nn(i[p.family])==="custom"&&L&&!j.has(ko(L))?[h({name:L,search:[L]},"当前手动输入")]:[],re=document.createDocumentFragment();Vm(re,"预设",y,i),Vm(re,"页面已加载",M,i),Vm(re,"本机已安装",T,i),Vm(re,"当前选择",q,i),br.replaceChildren(re);let J="";zs==="loading"?J="正在读取浏览器允许访问的本机字体…":zs==="unsupported"?J="当前浏览器未开放本机字体列表;仍可选择页面字体或手动输入。":zs==="denied"?J="未获得本机字体读取权限;仍可选择页面字体或手动输入。":zs==="error"?J="本机字体读取失败;仍可选择页面字体或手动输入。":zs==="ready"&&!zm.length&&(J="浏览器没有返回可用的本机字体;仍可手动输入字体名。"),wn.textContent=J,wn.hidden=!J,I$()},Tv=()=>{const i=N$();if(Dt.hidden||!i||!i.isConnected)return;const p=12,h=6,y=dr(),M=i.getBoundingClientRect(),T=Math.min(360,Math.max(260,y.width-p*2)),L=y.left+p,j=y.top+p,q=Math.min(Math.max(L,M.left),Math.max(L,y.right-T-p));Dt.style.width=`${Math.round(T)}px`,Dt.style.top=`${p}px`;const re=Dt.getBoundingClientRect().height,J=M.bottom+h,be=M.top-re-h,X=J+re<=y.bottom-p?J:be>=j?be:Math.max(j,y.bottom-re-p),yt=X<=M.top&&X+re>=M.bottom?Math.max(L,q-2):q;Dt.style.left=`${Math.round(yt-y.left)}px`,Dt.style.top=`${Math.round(X-y.top)}px`},Mo=(i=!1)=>{if(Dt.hidden)return;const p=N$();Dt.hidden=!0,Kt.forEach(h=>ar(h,!1)),i&&p&&p.isConnected&&p.focus()},sq=()=>{if(zs!=="idle")return Wm;const i=D(bs?.queryLocalFonts)?bs:globalThis;if(!D(i.queryLocalFonts))return zs="unsupported",qc(),null;const p=h=>{const y=h&&(h.name==="NotAllowedError"||h.name==="SecurityError");zs=y?"denied":"error",y||console.warn("[LDP] local font access failed",{name:String(h?.name||"Error"),message:String(h?.message||"")})};zs="loading",qc();try{Wm=Promise.resolve(i.queryLocalFonts()).then(h=>{zm=k$(h,["family","fullName","postscriptName","style"],"fullName"),zs="ready"}).catch(h=>{p(h)}).finally(()=>{Wm=null,qc(),Tv()})}catch(h){p(h),qc()}return Wm},x$=i=>{nw.includes(i)&&(ds=i),Kt.forEach(p=>ar(p,p.dataset.fontScope===ds)),jm=L$(),Ut.value="",qc(),Dt.hidden=!1,Tv(),sq(),Ut.focus({preventScroll:!0}),document.fonts?.ready&&Promise.resolve(document.fonts.ready).then(()=>{jm=L$(),qc(),Tv()})},q$=[{input:Jt,output:Vo,key:"interface",normalize:ua},{input:Jr,output:Go,key:"post",normalize:ua},{input:Dr,output:Xr,key:"composer",normalize:ua}],En=()=>{const i=fi(),p=Wi(),h=vv(),y="阅读器";_n.forEach(L=>{const j=ws[L.dataset.hostEmbedSize];if(!j)return;const q=pf(Oe[j.pref]);L.value=String(q),gl(L);const re=[...Hr].find(be=>be.dataset.hostEmbedSizeValue===L.dataset.hostEmbedSize);re&&(re.value=`${q}%`,re.textContent=`${q}%`);const J=L.dataset.hostEmbedSize==="labelCard"?"卡片高度、文字、SVG/图片图标、间距和横向内边距会按同一比例变化。":L.dataset.hostEmbedSize==="stats"?"右下角的回复、最近回复、活跃和回应四组信息会一起变化。":"";gr(L,`使用左右嵌入阅读时,调整原站主题列表中${j.label}的大小,默认 ${j.defaultScale}%。${J}修改后立即保存。`)}),Ql(is,"fontScopeTab",Bm,!0),di.forEach(L=>{L.hidden=L.dataset.fontScopePanel!==Bm}),oq(i),nw.forEach(L=>{const j=L==="host",q=j?Oe:i,re=j?Fo:h,J=Cn(L),be=[...at].find(oe=>oe.dataset.fontScope===L),X=[...Ca].find(oe=>oe.dataset.fontScope===L),Ue=[...Hs].find(oe=>oe.dataset.fontScope===L),yt=[...Sn].find(oe=>oe.dataset.fontScope===L),Bt=kn(q[J.color],re[J.color]);Nn(q[J.family])!=="custom"&&mi.delete(L),be.value=q[J.customFamily],be.hidden=Nn(q[J.family])!=="custom"||!mi.has(L),X.value=String(q[J.weight]),Ue.value=Bt||an[zi()]["--primary"],yt.textContent=Bt?Bt.toUpperCase():"跟随主题";const C=[...Kt].find(oe=>oe.dataset.fontScope===L);j?(gr(C,`选择左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的字体。默认“${Wn[re[J.family]].label}”;支持页面字体、本机字体和手动输入。修改后立即保存。`),gr(X,`调整左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的字重。默认 ${re[J.weight]} · ${Kh[re[J.weight]]}。修改后立即保存。`),gr(Ue,"调整左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的颜色。默认跟随当前明暗主题。修改后立即保存。")):(gr(C,`选择${y}${J.label}文字使用的字体。下拉按预设、页面已加载和本机已安装分组并支持搜索;首次读取本机字体时浏览器可能询问权限。默认“${Wn[re[J.family]].label}”。`),gr(X,`调整${y}${J.label}文字的粗细。默认 ${re[J.weight]} · ${Kh[re[J.weight]]}。`),gr(Ue,`调整${y}${J.label}文字的基础颜色。默认跟随当前明暗主题;自定义颜色只作用于${J.label}文字,不改变链接、错误和状态等语义颜色。`))}),q$.forEach(({input:L,output:j,key:q})=>{L.value=String(i[q]),j.textContent=`${i[q]}%`,gl(L)}),gr(Jt,`调整${y}界面文字字号,包括标题、元信息、楼层头部、操作区、时间轴、设置面板和提示。默认 ${h.interface}%;不会改变帖子正文和回复输入文字。`),gr(Jr,`调整${y}中的帖子正文和各楼层内容,不改变标题、按钮等界面文字。默认 ${h.post}%;拖动时实时预览,统一保存。`),gr(Dr,`调整${y}回复弹窗里的输入文字和编辑区域字号,不改变已发布正文。默认 ${h.composer}%;拖动时实时预览,统一保存。`),Ma.forEach(L=>{const j=L.dataset.hostFontReset!=null,q=L.dataset.fontFieldReset,re=j?"host":ta.find(gt=>Object.values(Cn(gt)).includes(q)),J=Cn(re),be=j?J[L.dataset.hostFontReset]:q,X=j?Oe:i,Ue=j?Fo:h,yt=Ue[be],Bt=be===J.family,C=be===J.weight,oe=be===J.color,Te=`${j?"":y}${J.label}${Bt?"字体":C?"字重":oe?"颜色":"字号"}`,et=Bt?Wn[yt].label:C?String(yt):oe?"跟随主题":`${yt}%`;L.disabled=Bt?X[J.family]===Ue[J.family]&&X[J.customFamily]===Ue[J.customFamily]:X[be]===yt,L.querySelector("span").textContent=`默认 ${et}`,rt(L,`恢复${Te}默认值 ${et}`)});const M=Zh.some(L=>i[L]!==p[L]);on.textContent=M?`${y}字体正在实时预览,等待统一保存。`:`${y}字体当前配置已应用。`,on.classList.toggle("balanced",!M);const T=Object.keys(Fo).every(L=>Oe[L]===Fo[L]);Qo.disabled=T&&Zh.every(L=>i[L]===h[L]),Uc()},Pu=()=>{const i=xc(),p=wv(),h=S$(),y=pi?"全屏":"普通(嵌入/浮窗)";ca(Zx,`布局设置 · ${y}`),ca(eq,`当前检测为${y}形态;实时预览、保存和恢复默认只作用于该形态。`);const M=Math.max(0,fn(100-Xl(i)));N.forEach(L=>{const j=L.dataset.layoutRegion;Ro.includes(j)&&(L.max="100",L.value=String(i[j]),gl(L))}),Q.forEach(L=>{const j=L.dataset.layoutValue;Ro.includes(j)&&(L.textContent=`${Number(i[j].toFixed(1))}%`)}),le.classList.toggle("warning",M>0),le.classList.toggle("balanced",M<=0);const T=Ro.some(L=>Math.abs(i[L]-p[L])>=.01);le.textContent=M>0?`当前形态:${y}。还剩 ${Number(M.toFixed(1))}% 未分配,必须分配完才能保存。`:T?`当前形态:${y}。正在实时预览,只会保存该形态的布局。`:`当前形态:${y}。当前配置已应用,只会保存该形态的布局。`,z.disabled=Ro.every(L=>Math.abs(i[L]-h[L])<.01),Uc()},Dc=()=>{const i=qo.getState(),p=window.innerWidth<=Ss,h=qe.isFloating(),y=`${Math.round(i.width)} × ${Math.round(i.height)} · (${Math.round(i.left)}, ${Math.round(i.top)})${qo.isPinned()?" · 保持显示":""}${qo.isLocked()?" · 已锁定":""}`;ee.max=String(Math.max(na,window.innerWidth-no*2)),ve.max=String(Math.max(Mi,window.innerHeight-no*2)),Ne.max=String(Math.max(no,window.innerWidth-no-i.width)),Ve.min=String(no),Ve.max=String(Math.max(no,window.innerHeight-no-i.height)),ee.value=String(Math.round(i.width)),ve.value=String(Math.round(i.height)),Ne.value=String(Math.round(i.left)),Ve.value=String(Math.round(i.top)),Qe.checked=qo.isPinned(),it.checked=qo.isLocked(),[ee,ve,Ne,Ve,Qe,it].forEach(M=>{M.disabled=p}),Qt.textContent=p?"当前视口较窄,阅读器使用同一套窄屏响应式布局。":h?`${y}${qo.isLocked()?"":" · 可拖动缩放"}`:`${qe.isEmbedded()?"当前为嵌入阅读":"当前为全屏阅读"};以下配置将在切换到浮窗后生效。浮窗:${y}`,Eo.disabled=p||qo.isDefault()},aq={interaction:E$,zebra:M$,"list-zebra":$$,"reply-line":R$,"quote-line":T$,"divider-line":A$,"floor-preview":P$},D$=ll.map(i=>Object.assign({},i,{apply:aq[i.section]})),iq=()=>{const i=Aa();D$.forEach(({input:p,output:h,key:y})=>{const M=p.type==="range",T=i[y];p.value=M?String(T):T,h.textContent=M?`${T}px`:T,M||(h.title=`当前主题实际应用 ${pa(i,y).toUpperCase()}`),M&&gl(p)})},lq=()=>{const i=Aa(),p=Sv();IM.forEach(h=>{const y=h.dataset.colorReset,M=i_(y),T=h.closest('[data-appearance-group="structure"]')&&i.structureColorsEnabled===!1;h.disabled=T||M.every(L=>p[L]==null||i[L]===p[L])})},cq=()=>{const i=Aa();Hy.forEach(p=>{const h=p.dataset.appearanceGroupToggle,y=i[h]!==!1,M=p.closest(".ldp-color-group");p.checked=y,M.classList.toggle("is-disabled",!y),M.querySelector(".ldp-settings-fields").setAttribute("aria-disabled",String(!y)),M.querySelectorAll(".ldp-settings-fields input").forEach(T=>{T.disabled=!y})})},yl=()=>{const p=zi(),h=Pn(Za()),y=p==="dark"?"暗色主题":"明亮主题";vm.innerHTML=`当前预览 ${y}适应色${h==="system"?"(跟随系统)":""};HEX 保留所选原色,实际呈现自动限制亮度与对比。`,iq(),cq(),lq();const M=tq();wm.textContent=M?"阅读器外观正在实时预览,等待统一保存。":"阅读器外观当前配置已应用。",wm.classList.toggle("balanced",!M),Uc()};let Gm=!1;const Qm=()=>{const i=cb(),p=FS(i);Uy.forEach(q=>{const re=q.dataset.performancePreset===p;q.classList.toggle("active",re),go(q,re)}),Sm.forEach(q=>{const re=q.dataset.performanceKey,J=i[re];J!=null&&(q.value=kl[re]?.integer?String(J):String(Number(J.toFixed(2))))}),LM.disabled=p==="balanced";const h={low:"省资源",balanced:"均衡",high:"减少等待",custom:"自定义"},y=`每次加载 ${i.pageSize} 个楼层 · 页面保留不超过 ${i.streamMaxItems} 个楼层 · 二级回复提前 ${i.nestedPrefetchViewports} 屏 · 限流额度 ${i.requestRateTarget}%`,M=_u().snapshot(),T=`请求额度 ${M.shortWindowBudget} 次/10 秒、${M.longWindowBudget} 次/分钟`,L=M.endpointCoolingDown&&!M.coolingDown?` 当前隔离 ${M.endpointCooldownCount} 个异常端点,最长约 ${Math.max(1,Math.ceil(M.endpointCooldownRemaining/1e3))} 秒后允许再次尝试;其他请求继续按${T}运行。`:M.coolingDown?` 当前会话命中${M.lastRateLimitWindow||"动态"}限流,正在退避,约 ${Math.max(1,Math.ceil(M.cooldownRemaining/1e3))} 秒后继续;${T}。`:M.learnedRateLimit?` 当前会话已按最近边界调节;${T}${M.minInterval>M.configuredMinInterval?`,请求最短间隔 ${M.minInterval}ms`:""},稳定承压后继续小步提速,并发按需最高 ${M.maxConcurrent} 路。`:` 当前会话正常自适应;${T},稳定承压后小步上探,并发按需最高 ${M.maxConcurrent} 路。`,j=Gm?`已保存为${h[p]||"自定义"}:${y};下次打开阅读器生效。`:`下次打开采用${h[p]||"自定义"}:${y}。`;qI.textContent=j+L},Fn=i=>{const p=Math.max(0,Number(i)||0);return p<1024?`${Math.round(p)} B`:p<1024*1024?`${(p/1024).toFixed(p<10*1024?1:0)} KB`:`${(p/1024/1024).toFixed(p<10*1024*1024?1:0)} MB`},Bn=i=>{const p=Math.max(0,Number(i)||0);return p<10?`${p.toFixed(1)}ms`:p<1e3?`${Math.round(p)}ms`:p<6e4?`${(p/1e3).toFixed(p<1e4?1:0)}s`:`${(p/6e4).toFixed(1)}min`},vl=i=>{const p=new Date(Number(i)||0);return`${p.toLocaleTimeString("zh-CN",{hour12:!1,hour:"2-digit",minute:"2-digit",second:"2-digit"})}.${String(p.getMilliseconds()).padStart(3,"0")}`},Av=i=>$A[i]||"其他",O$=i=>Number.isFinite(i)?i<=tt.topic?"核心":i===tt.scroll?"滚动":i===tt.visible?"可见":i===tt.nested?"预取":i===tt.auxiliary?"辅助":"后台":"",Pv=i=>RA[i]||"调度",Ym=(i,p=vo())=>{if(!i)return null;const h=i.pending?Math.max(0,p-i.startedAt):Math.max(0,Number(i.duration)||0),y=Math.max(0,Number(i.permitWait)||0);if(i.controlOnly){const M=String(i.controlReason||i.waitReason||"cancelled");return M==="queue-limit"?{level:"warning",kind:"队列已满",detail:"辅助请求在发出前被队列上限丢弃"}:M==="endpoint-cooldown"?{level:"warning",kind:"端点熔断",detail:"同一异常 URL 在冷却期内未再次发出,不影响其他请求"}:M==="rate-limit-priority"||M==="cooldown"?{level:"warning",kind:"限流让位",detail:"低优先级请求在发出前为限流恢复让位"}:M==="context-close"?{level:"warning",kind:"页面取消",detail:"阅读器上下文结束,请求未发出"}:{level:"warning",kind:"调度取消",detail:`请求未发出(${Pv(i.waitReason)})`}}if(i.status===429)return{level:"danger",kind:"429 限流",detail:i.rateLimitCode||"服务器拒绝了当前请求速率"};if(i.status>=500)return{level:"danger",kind:`HTTP ${i.status}`,detail:"服务器或上游服务返回错误"};if(i.status>=400)return{level:"danger",kind:`HTTP ${i.status}`,detail:"请求参数、权限或目标状态异常"};if(i.error){const M=/abort/i.test(i.error);return{level:M?"warning":"danger",kind:M?"中止/超时":"网络错误",detail:i.error}}return i.pending&&i.type!=="realtime"&&h>=Da?{level:"danger",kind:"请求卡住",detail:`网络阶段已持续 ${Bn(h)}`}:y>=1e3?{level:"warning",kind:"排队过久",detail:`${Pv(i.waitReason)}等待 ${Bn(y)}`}:!i.pending&&i.type!=="realtime"&&h>=MA?{level:"warning",kind:"响应偏慢",detail:`网络阶段耗时 ${Bn(h)}`}:null},Xm=(i,p=vo())=>{const h=Math.max(0,Number(i.permitWait)||0),y=Math.max(0,Number(i.dispatchDuration)||0),M=i.pending?Math.max(0,p-i.startedAt):Math.max(0,Number(i.duration)||0);return i.controlOnly?`${h>0?`排 ${Bn(h)} · `:""}未发出`:`${h>0?`排 ${Bn(h)} · `:""}${y>=1?`放行 ${Bn(y)} · `:""}网 ${Bn(M)}`},dq=(i=vo())=>{Ap(i);const p=No.slice(),h=p.filter(C=>!C.controlOnly&&C.startedAt>=i-6e4),y=h.filter(C=>C.startedAt>=i-Ai),T=Math.ceil(i/100)*100-Ai,L=Array.from({length:Ai/100},()=>0),j=new Map,q={reader:0,host:0,browser:0};let re=0;const J=[];h.forEach(C=>{const oe=Math.floor((C.startedAt-T)/100);oe>=0&&oe0&&C.type!=="realtime"&&Te.durations.push(C.duration),C.duration>0&&C.type!=="realtime"&&J.push(C.duration)),Ym(C,i)&&Te.issues++,j.set(C.type,Te),Object.hasOwn(q,C.source)&&q[C.source]++,re+=C.size}),J.sort((C,oe)=>C-oe);const be=J.length?Math.min(J.length-1,Math.ceil(J.length*.95)-1):-1,X=p.slice().reverse().find(C=>C.status===429||C.retryAfter||C.rateLimitCode||C.serverLimit),Ue=p.filter(C=>(Number(C.endedAt)||C.startedAt)>=i-6e4).map(C=>({event:C,issue:Ym(C,i)})).filter(C=>C.issue),yt=p.filter(C=>{const oe=Number(C.queuedAt)||C.startedAt,Te=C.pending?i:Number(C.endedAt)||C.startedAt;return oe<=i&&Te>=i-Ai}),Bt=[...j.values()].map(C=>{C.durations.sort((Te,et)=>Te-et);const oe=C.durations.length?Math.min(C.durations.length-1,Math.ceil(C.durations.length*.95)-1):-1;return{...C,p95:oe>=0?C.durations[oe]:0}}).sort((C,oe)=>oe.count-C.count);return{at:i,session:p,recent60:h,recent10:y,traceEvents:yt,typeStats:Bt,recentIssues:Ue,sourceCounts:q,transfer:re,peak100:Math.max(0,...L),p95:be>=0?J[be]:0,completed:J.length,active:p.filter(C=>C.pending).length,latestLimit:X}},Ws=(i,p,h)=>({level:i,state:p,detail:h}),uq=(i,p)=>{const h=i.recent60.slice().reverse().find(T=>T.status===429);if(h||p?.coolingDown){const T=h||i.latestLimit,L=Ms[T?.type]||"请求",j=T?.retryAfter?`,服务器要求等待 ${T.retryAfter} 秒`:p?.cooldownRemaining?`,约 ${Math.max(1,Math.ceil(p.cooldownRemaining/1e3))} 秒后继续`:"";return Ws("danger","服务器限流",`${L}在最近一分钟收到 429${j};未启动的辅助和后台请求会让位。`)}const y=i.recentIssues.slice().reverse().find(T=>T.issue.level==="danger");if(y){const{event:T,issue:L}=y,j=Ms[T.type]||T.type,q=T.callSite?`,发起点 ${T.callSite}`:"";return Ws("danger",L.kind,`${j} ${T.method} ${T.path}:${L.detail}${q}。`)}if(p&&Math.max(p.queued,p.globalQueued||0)>=3){const T=Math.max(0,Number(p.nextPermitDelay)||0),L=p.blockingReason==="10s"?"10 秒请求额度":p.blockingReason==="60s"?"60 秒请求额度":p.blockingReason==="interval"?"请求最短间隔":"",j=L?`;正在等待${L}${T?`,约 ${Math.max(1,Math.ceil(T/1e3))} 秒后放行`:""}`:"";return Ws("warning","阅读器排队",`全局 ${p.globalActive||p.active} 路执行、${Math.max(p.queued,p.globalQueued||0)} 条排队${j};原站操作、目标楼层、正文和当前可见的二级回复会优先启动。`)}if(i.completed>=5&&i.p95>=1800)return Ws("warning","响应偏慢",`最近一分钟 95% 的已完成请求不超过 ${Bn(i.p95)},瓶颈更可能在网络或服务器响应,而不是请求启动间隔。`);const M=i.typeStats.filter(T=>["avatar","media","asset"].includes(T.type)).reduce((T,L)=>T+L.count,0);return i.recent60.length>=12&&M/i.recent60.length>=.7&&i.transfer>=4*1024*1024?Ws("warning","资源占用",`头像、图片和静态资源占最近一分钟请求的 ${Math.round(M/i.recent60.length*100)}%,已传输 ${Fn(i.transfer)}。`):i.recent60.length?Ws("normal","节奏正常",`最近一分钟 ${i.recent60.length} 次请求,${i.active} 次进行中,95% 请求耗时 ${i.p95?Bn(i.p95):"暂无"};当前未发现明显限流或排队瓶颈。`):Ws("normal","等待采样","打开帖子并滚动后,这里会根据排队、响应耗时、传输量和 429 判断主要瓶颈。")},pq=i=>{const p=i.at-Ai,h=T=>Math.max(0,Math.min(100,(Number(T)-p)/Ai*100)),y=(T,L)=>Math.max(0,h(L)-h(T));return[{source:"reader",label:"阅读器"},{source:"host",label:"原站"},{source:"browser",label:"资源"}].map(({source:T,label:L})=>{const j=[Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY],re=i.traceEvents.filter(J=>J.source===T).sort((J,be)=>(Number(J.queuedAt)||J.startedAt)-(Number(be.queuedAt)||be.startedAt)).slice(-80).map(J=>{const be=Number(J.queuedAt)||J.startedAt,X=J.pending?i.at:Number(J.endedAt)||J.startedAt;let Ue=j.findIndex(Mh=>Mh<=be);Ue<0&&(Ue=j.indexOf(Math.min(...j))),j[Ue]=X;const yt=3+Ue*10,Bt=Ym(J,i.at),C=Ms[J.type]||J.type,oe=O$(J.priority),Te=J.controlOnly?"未发出":J.pending?"进行中":J.status>0?String(J.status):J.error?"ERR":"完成",et=[`${vl(J.startedAt)} · ${J.method} ${J.path}`,`${Av(J.source)} / ${C} / ${Te}`,Xm(J,i.at),oe&&`${oe}优先级`,J.attempt>1&&`第 ${J.attempt} 次尝试`,J.callSite&&`发起点 ${J.callSite}`].filter(Boolean).join(" · "),gt=Math.max(p,J.startedAt),lt=Math.min(i.at,Math.max(J.startedAt,X)),At=J.controlOnly?"":``,Vt=Math.min(i.at,Number(J.permittedAt)||J.startedAt),Zr=be=p?``:"",La=Zr&&!J.controlOnly?``:"",Zs=Math.min(i.at,Math.max(p,X)),rd=Math.max(.6,Math.min(99.4,h(Zs))),od=Bt?``:"";return Zr+La+At+od}).join("");return`
- ${L}
- ${re}
-
`}).join("")};let Km=0;const Nu=(i,p)=>{if(!i||i.matches(":hover")||i.innerHTML===p)return;const h=i.scrollTop;i.innerHTML=p,h&&(i.scrollTop=h)},F$=()=>{if(!DM||!nx)return;const i=dq();let p=null;try{p=_u().snapshot()}catch{}const h=i.recent60.filter(X=>X.status===429).length,y={rate10:`${i.recent10.length}次 · ${(i.recent10.length/10).toFixed(1)}/秒`,rate60:`${i.recent60.length}次 · ${(i.recent60.length/60).toFixed(1)}/秒`,peak:`${i.peak100}次/100ms`,transfer:Fn(i.transfer),issues:`${i.recentIssues.length}次${h?` · 429 ${h}`:""}`};jI.forEach(X=>{X.textContent=y[X.dataset.requestFlowMetric]||"—"});const M=i.recent10.filter(X=>X.permitWait>.5).length,T=p?`全局 ${p.globalInstances} 实例 · 执行 ${p.globalActive} · 排队 ${p.globalQueued} · 共享上限 ${p.globalMaxConcurrent} 路 · 间隔 ${p.globalMinInterval}ms${p.coordinationMode==="best-effort"?" · 协调降级":""}`:`本页阅读器 ${i.sourceCounts.reader} · 原站 ${i.sourceCounts.host}`,L=p?` · 请求额度 ${p.shortWindowCount}/${p.shortWindowBudget}(10 秒)· ${p.longWindowCount}/${p.longWindowBudget}(60 秒)`:"";KI.textContent=`近 10 秒 ${i.recent10.length} 请求 · 排队 ${M} · 异常 ${i.recentIssues.filter(X=>X.event.startedAt>=i.at-Ai).length} · ${T}${L}`,Nu(DM,pq(i));const j=i.typeStats.slice(0,8),q=j.length?j.map(X=>`${$e(Ms[X.type]||X.type)}`).join(""):'等待请求';Nu(JI,`排队慢/久候错误/限流${q}`);const re=uq(i,p);if(ZI.dataset.level=re.level,ex.textContent=re.state,tx.textContent=re.detail,OM&&(OM.textContent=i.recentIssues.length?`最近 60 秒 ${i.recentIssues.length} 个 · 红色为失败/限流`:"最近 60 秒未发现异常"),FM){const X=i.recentIssues.slice(-8).reverse();Nu(FM,X.length?X.map(({event:Ue,issue:yt})=>{const Bt=Ms[Ue.type]||Ue.type,C=Ue.callSite||`${Ue.transport} 发起`,oe=`${Av(Ue.source)} / ${Bt} · ${Xm(Ue,i.at)} · ${yt.detail} · 发起点 ${C}`;return`
- ${$e(vl(Ue.endedAt||Ue.startedAt))}
- ${$e(yt.kind)}
- ${$e(`${Ue.method} ${Ue.path}`)}${$e(oe)}
-
`}).join(""):'最近 60 秒没有 HTTP 错误、网络中止、超时、慢响应或超过 1 秒的排队。
')}Nu(rx,i.typeStats.length?i.typeStats.map(X=>{const Ue=X.p95?Bn(X.p95):"—";return`
- ${$e(Ms[X.type]||X.type)}
- ${X.count} 次${Ue}${X.issues||"—"}
-
`}).join(""):'还没有可统计的请求。
');const J=i.session.slice(-60).reverse();Nu(ox,J.length?J.map(X=>{const Ue=X.controlOnly?"未发出":X.pending?"进行中":X.status>0?String(X.status):X.error?"ERR":"—",yt=Ms[X.type]||X.type,Bt=O$(X.priority),C=Ym(X,i.at),oe=X.callSite||`${X.transport} 发起`,Te=`${X.method} ${X.path}${Bt?` · ${Bt}优先级`:""}${X.attempt>1?` · 第 ${X.attempt} 次尝试`:""} · ${Xm(X,i.at)} · 发起点 ${oe}`;return`
- ${$e(vl(X.startedAt))}
- ${Av(X.source)}
- ${$e(yt)}
- ${Ue}
- ${$e(Xm(X,i.at))}
- ${$e(`${X.path} ← ${oe}`)}
-
`}).join(""):'打开帖子或操作原站页面后,请求会按时间出现在这里。
');const be=i.latestLimit;if(be&&be.status===429){const X=Ms[be.type]||"请求",Ue=[be.rateLimitCode&&`错误码 ${be.rateLimitCode}`,be.retryAfter&&`Retry-After ${be.retryAfter} 秒`].filter(Boolean).join(",");_m.textContent=`本页最近观测:${X}收到 429${Ue?`(${Ue})`:""}。`}else be?.serverLimit?_m.textContent=`服务器最近返回:上限 ${be.serverLimit}${be.serverRemaining?`,剩余 ${be.serverRemaining}`:""}${be.serverReset?`,重置 ${be.serverReset}`:""}。`:p?.learnedRateLimit?_m.textContent=`所有实例已共享历史限流与有效策略:${p.shortWindowBudget} 次/10 秒、${p.longWindowBudget} 次/分钟、并发 ${p.globalMaxConcurrent} 路。`:p&&(_m.textContent=`当前未收到 429 或服务器上限头;所有实例共同使用 ${p.shortWindowBudget} 次/10 秒、${p.longWindowBudget} 次/分钟、并发 ${p.globalMaxConcurrent} 路。`)},ku=()=>{Km&&clearInterval(Km),Km=0,Dw(!1)},fq=()=>{ku(),document.visibilityState==="visible"&&(Dw(!0),F$(),Km=setInterval(F$,1e3))},Jm=600*1e3,Nv=1200,nt=x.resourceMonitorState||{timer:0,longTaskObserver:null,longTasksAvailable:!1,samples:[]},mq=["longTaskObserver","longFrameObserver","resourceObserver","mutationObserver","readerMutationObserver","hostMutationObserver"],kv=(i=mq)=>i.forEach(p=>{D(nt[p]?.disconnect)&&nt[p].disconnect(),nt[p]=null});Array.isArray(nt.samples)||(nt.samples=[]),Array.isArray(nt.events)||(nt.events=[]),Array.isArray(nt.visibilityTimeline)||(nt.visibilityTimeline=[]),Array.isArray(nt.memorySamples)||(nt.memorySamples=[]),(!nt.memory||typeof nt.memory!="object")&&(nt.memory={bytes:null,status:"waiting",nextAt:0,pending:!1}),nt.timer&&clearInterval(nt.timer),kv(),nt.timer=0;const B$=nt.visibilityTimeline[nt.visibilityTimeline.length-1];B$&&B$.state!=="stopped"&&nt.visibilityTimeline.push({at:vo(),state:"stopped"}),nt.collecting=!1,nt.eventSequence=Number(nt.eventSequence)||0,nt.lastPruneAt=Number(nt.lastPruneAt)||0,x.resourceMonitorState=nt;const hq=D(window.PerformanceObserver)&&(!Array.isArray(window.PerformanceObserver.supportedEntryTypes)||window.PerformanceObserver.supportedEntryTypes.includes("longtask")),Zm=D(window.PerformanceObserver)&&[...window.PerformanceObserver.supportedEntryTypes||[]].includes("long-animation-frame"),Vs=()=>vo(),H$=i=>{const p=new Date(Number(i)||0);return`${p.toLocaleTimeString("zh-CN",{hour12:!1})}.${String(p.getMilliseconds()).padStart(3,"0")}`},us=i=>{const p=Math.max(0,Number(i)||0);return`${p<100?p.toFixed(1):Math.round(p)}ms`},Lv=(i=Vs())=>{const p=i-Jm;nt.samples=nt.samples.filter(h=>Number(h.at)>=p).slice(-600),nt.memorySamples=nt.memorySamples.filter(h=>Number(h.at)>=p).slice(-60),nt.events=nt.events.filter(h=>Number(h.at)>=p).slice(-Nv),nt.visibilityTimeline=nt.visibilityTimeline.filter((h,y,M)=>Number(h.at)>=p||y===M.length-1).slice(-240)},Iv=i=>{const p=Number(i?.at);if(!Number.isFinite(p))return;nt.events.push(Object.assign({id:++nt.eventSequence,visibility:"unknown",scope:"shared",kind:"event",duration:0,detail:"",basis:""},i,{at:p})),nt.events.length>Nv&&nt.events.splice(0,nt.events.length-Nv);const h=Vs();h-nt.lastPruneAt>=1e3&&(nt.lastPruneAt=h,Lv(h))},eh=i=>{for(let p=nt.visibilityTimeline.length-1;p>=0;p--){const h=nt.visibilityTimeline[p];if(!(Number(h.at)>i))return["visible","hidden"].includes(h.state)?h.state:"unknown"}return"unknown"},wl=(i,p)=>Iv(Object.assign({at:i,visibility:eh(i)},p)),U$=(i="visibilitychange",p=Vs())=>{if(!nt.collecting)return;const h=document.visibilityState==="visible"?"visible":"hidden",y=nt.visibilityTimeline[nt.visibilityTimeline.length-1];y&&y.state===h||(nt.visibilityTimeline.push({at:p,state:h}),Iv({at:p,visibility:h,scope:"shared",kind:"visibility",detail:i==="start"?`开始取证 · 当前${h==="visible"?"前台":"后台"}`:`页面切换为${h==="visible"?"前台":"后台"}`,basis:"Page Visibility"}))},gq=(i=Vs())=>{const p=i-Jm;return Ap(i),No.filter(h=>!h.controlOnly&&Number(h.startedAt)>=p&&Number(h.startedAt)<=i).map(h=>{const y=eh(Number(h.startedAt)),M=h.source==="reader"?"reader":h.source==="host"?"host":"shared",T=Math.max(0,h.pending?i-h.startedAt:Number(h.duration)||0),L=Math.max(0,Number(h.size)||0),j=h.pending?"进行中":h.status>0?String(h.status):h.error?"ERR":"完成",q=h.resourceTimed?String(h.transport||"resource"):Ms[h.type]||h.type,re=h.resourceTimed?h.path:`${h.method} ${h.path}`,J=h.resourceTimed?`PerformanceResourceTiming${h.source==="reader"?" + reader 元数据":h.source==="host"?" + 未标记请求":""}`:h.source==="reader"?"显式 reader 元数据":h.source==="host"?"未标记 fetch/XHR":"Resource Timing";return{id:`request-${h.id}`,at:Number(h.startedAt),visibility:y,scope:M,kind:"resource",duration:T,bytes:L,detail:`${q} · ${re} · ${j} · ${us(T)}${L?` · ${Fn(L)}`:h.resourceTimed?" · 传输量受限或缓存命中":""}`,basis:J}})},bq=(i=Vs())=>md.filter(p=>p.at>=i-Jm&&p.at<=i).map((p,h)=>({id:`resource-drop-${p.at}-${h}`,at:p.at,visibility:eh(p.at),scope:"shared",kind:"dropped",detail:`Resource Timing 有 ${p.count} 条浏览器记录被丢弃;该区间统计不完整`,basis:"PerformanceObserver droppedEntriesCount"})),yq=(i,p)=>nt.events.concat(i.filter(h=>["visible","hidden"].includes(h.visibility)),p.filter(h=>["visible","hidden"].includes(h.visibility))),vq=i=>!i||i.nodeType!==1?!1:i===ln?!0:!!i.closest(".ldp-reader-portal-host,.ldp-overlay,.ldp-native-boost-menu,.ldp-lightbox,.ldp-user-card-fallback"),xv=(i,p=!1)=>[...i||[]].reduce((h,y)=>!y||y.nodeType!==1||p&&vq(y)?h:h+1+(D(y.getElementsByTagName)?y.getElementsByTagName("*").length:0),0),wq=()=>{const i=document.getElementsByTagName("*").length,p=document.querySelectorAll("html > .ldp-reader-portal-host,body > .ldp-native-boost-menu");return Math.max(0,i-xv(p))},j$=(i,p)=>{let h=0,y=0;if(p.forEach(T=>{h+=xv(T.addedNodes,i==="host"),y+=xv(T.removedNodes,i==="host")}),!h&&!y)return;const M=Vs();wl(M,{scope:i,kind:"dom",added:h,removed:y,detail:`页面元素 +${h} / −${y} · ${p.length} 个变更记录`,basis:"MutationObserver 回调"})},z$=(i,p)=>{const h=Math.max(0,Number(p?.droppedEntriesCount)||0);if(!h)return;const y=Vs();wl(y,{scope:"shared",kind:"dropped",detail:`${i} 有 ${h} 条浏览器记录被丢弃;该区间统计不完整`,basis:"PerformanceObserver droppedEntriesCount"})},Sq=i=>{const p=String(i?.sourceURL||"");let h=p;try{h=decodeURIComponent(p)}catch{}if(/Awesome LinuxDo Reader|更流畅的 LinuxDo 阅读器|katex@0\.16\.22|pinyin-pro@3\.18\.2|hls\.js@1\.6\.16/i.test(h))return"reader";try{const y=new URL(p,location.href);if((y.origin===vt||/(?:^|\.)linux\.do$/i.test(y.hostname)||/(?:^|\.)ldstatic\.com$/i.test(y.hostname))&&/\/(?:assets|plugins|theme-javascripts)\/|\.(?:m?js)(?:$|[?#])/i.test(y.pathname))return"host"}catch{}return"shared"},_q=i=>{const p=String(i?.sourceFunctionName||"").trim();if(p)return p.slice(0,80);const h=String(i?.sourceURL||"");if(!h)return"匿名脚本";try{const y=new URL(h,location.href);return`${y.host}${y.pathname}`.slice(0,100)}catch{return h.split(/[?#]/,1)[0].slice(0,100)}},Cq=i=>{const p=nt.memory,h=window.performance?.measureUserAgentSpecificMemory;if(!D(h)){p.status="unsupported";return}if(window.crossOriginIsolated!==!0){p.status="not-isolated";return}if(p.pending||i{const T=Number(M?.bytes),L=Vs();if(p.pending=!1,!Number.isFinite(T)){p.status="error";return}p.bytes=T,p.status="measured",nt.memorySamples.push({at:L,heapUsed:T}),wl(L,{scope:"shared",kind:"memory",detail:`页面内存估计 ${Fn(T)}`,basis:"measureUserAgentSpecificMemory"})}).catch(()=>{p.pending=!1,p.status="error"})},Eq=(i,p)=>{const h=i.map(y=>y[p]).filter(Number.isFinite);return h.length?{count:h.length,average:h.reduce((y,M)=>y+M,0)/h.length,peak:Math.max(...h),delta:h[h.length-1]-h[0]}:{count:0,average:0,peak:0,delta:0}},W$=i=>{const p=Number(i)||0;return p?`${p>0?"+":"-"}${Fn(Math.abs(p))}`:"0 B"},Oc=i=>{const p=Math.round(Number(i)||0);return p>0?`+${p}`:String(p)},hi=(i,p)=>i.count<2?"→ —":`${i.delta>0?"↑":i.delta<0?"↓":"→"} ${p(i.delta)}`,Mq=(i,p)=>{const h=i.filter(re=>Number.isFinite(re[p]));if(!h.length)return"";if(h.length===1)return"0,17 240,17";const y=Math.max(1,Math.ceil(h.length/120)),M=h.filter((re,J)=>J%y===0||J===h.length-1),T=M.map(re=>re[p]),L=Math.min(...T),q=Math.max(...T)-L||1;return M.map((re,J)=>{const be=J/Math.max(1,M.length-1)*240,X=31-(re[p]-L)/q*28;return`${be.toFixed(1)},${X.toFixed(1)}`}).join(" ")},V$=()=>{const i=nt.memory;return i.status==="measured"&&Number.isFinite(i.bytes)?Fn(i.bytes):i.status==="measuring"?"正在测量":i.status==="not-isolated"?"不可用(未隔离)":i.status==="unsupported"?"浏览器未提供":i.status==="error"?"测量失败":"等待测量"},$q=(i,p)=>{const h=()=>({requests:0,requestDuration:0,bytes:0,scripts:0,longTasks:0,longTaskDuration:0,domChanges:0}),y={reader:{visible:h(),hidden:h()},host:{visible:h(),hidden:h()},shared:{visible:h(),hidden:h()}},M=i-6e4;return p.forEach(T=>{if(T.at{if(T.ati||!["visible","hidden"].includes(T.visibility))return;const L=Object.hasOwn(y,T.scope)?T.scope:"shared",j=y[L][T.visibility];T.kind==="script"&&(j.scripts+=Math.max(0,Number(T.duration)||0)),T.kind==="longtask"&&(j.longTasks++,j.longTaskDuration+=Math.max(0,Number(T.duration)||0)),T.kind==="dom"&&(j.domChanges+=Math.max(0,Number(T.added)||0)+Math.max(0,Number(T.removed)||0))}),y},G$=(i,p)=>{const h=[];if(i.requests){const y=`请求 ${i.requests} / ${us(i.requestDuration)}`;h.push(i.bytes?`${y} / ${Fn(i.bytes)}`:y)}return i.scripts&&h.push(`已归因脚本 ${us(i.scripts)}`),p==="shared"&&i.longTasks&&h.push(`长任务 ${i.longTasks} / ${us(i.longTaskDuration)}`),i.domChanges&&h.push(`页面元素变更 ${i.domChanges}`),h.length?h.join(" · "):"—"},Rq=(i,p,h,y)=>{const M=$q(p,h),T={reader:`${i.dom} 个页面元素 · ${i.retainedFloors} 个楼层`,host:`${i.hostDom} 个页面元素`,shared:V$(),readerBasis:`reader 元数据 · Resource Timing · Reader DOM${Zm?" · LoAF 已确认脚本":""}`,hostBasis:`未标记 fetch/XHR · Resource Timing · Document DOM${Zm?" · 同源 LoAF 脚本":""}`,sharedBasis:`Long Tasks · Resource Timing · Page Visibility${Zm?" · 未归因 LoAF":""}`};UI.forEach(J=>{const be=J.dataset.resourceMonitorScope;J.querySelector("[data-resource-monitor-scope-current]").textContent=T[be]||"—",J.querySelector("[data-resource-monitor-scope-visible]").textContent=G$(M[be].visible,be),J.querySelector("[data-resource-monitor-scope-hidden]").textContent=G$(M[be].hidden,be),J.querySelector("[data-resource-monitor-scope-basis]").textContent=T[`${be}Basis`]||"—"});const L=y.filter(J=>J.at>=p-6e4),j=L.filter(J=>J.visibility==="visible").length,q=L.filter(J=>J.visibility==="hidden").length,re=L.length-j-q;YI.textContent=`${document.visibilityState==="visible"?"当前前台":"当前后台"} · 前 ${j} / 后 ${q}${re?` / 未归档 ${re}`:""} 个原始事件`},Tq=i=>TA[i]||"未归因",Aq=(i,p)=>{if(!qM)return;const h=p.filter(y=>y.at>=i-Jm&&y.at<=i).sort((y,M)=>M.at-y.at||String(M.id).localeCompare(String(y.id))).slice(0,48);qM.innerHTML=h.length?h.map(y=>{const M=y.visibility==="visible"?"前台":y.visibility==="hidden"?"后台":"未知";return`
-
- ${M}
- ${$e(Tq(y.scope))}
+ ${qb("ldp-bookmark-link",Vd(J.topicId,J.postNumber,!0),Kx(B),B.title||`帖子 #${J.topicId}`,`${Q}${Ve}${gt} · ${va(B.created_at)}`)}
+ ${Fe.multi?"":``}
+
`}).join(""):`${Fe.query?`没有匹配的${Fe.type==="Post"?"楼层":"帖子"}收藏`:`暂无${Fe.type==="Post"?"楼层":"帖子"}收藏`}
`,_o(Te.list,rt.auxiliary),ec(Ue,"bookmarkType",Fe.type),Te.search.placeholder=i?"搜索回应、帖子或用户":"搜索收藏标题或内容",nt(Te.search,i?"搜索回应记录":"搜索收藏"),nt(Te.searchClear,i?"清空回应搜索":"清空收藏搜索");const k=i?[]:C.map(B=>Number(B.id)).filter(B=>B>0),j=Fe.scope==="all"?Fe.allIds:k;ER(Te,Fe,sn,j,"收藏",y.length===0,Fe.scope==="page"&&k.length===0,i),ka.paging($,y.length>0),SR()},cD=(i=Fe.query,p=Fe.token)=>Ev(async h=>{const y=Ls();if(!y)throw new Error("登录后才能查看收藏");const C=new URLSearchParams({page:String(Math.max(0,Number(h)||0))});return i&&C.set("q",i),io(`${vt}/u/${encodeURIComponent(y)}/bookmarks.json?${C}`,{key:`bookmarks:${h}:${i}`,priority:rt.auxiliary,cacheMode:"default"})},(h,y)=>{const C=h?.user_bookmark_list||h||{};return[Array.isArray(C.bookmarks)?C.bookmarks:[],C.more_bookmarks_url?y+1:null]},h=>Number(h?.id)>0?h:null,h=>Number(h.id),()=>p===Fe.token&&!Te.popover.hidden),yl=async()=>{const i=++Fe.token,p=Fe.type==="Reaction";let h=p&&Fe.reactionLoadedAt>0&&Fe.reactionUsername===Yt,y=null;const C=()=>{Lo||y||(y=pc().then(()=>{h&&i===Fe.token&&!Te.popover.hidden&&Fe.type==="Reaction"&&Jo()}).finally(()=>{y=null}))};h?Jo():ka.message(`正在加载${p?"回应":"收藏"}…`);try{if(p){const k=Ls();if(!k)throw new Error("登录后才能查看回应记录");if(Fe.reactionUsername&&Fe.reactionUsername!==k&&(Fe.reactionEntries=[],Fe.reactionLoadedAt=0,h=!1),!h&&Fe.reactionLoadedAt>0&&Fe.reactionUsername===k&&(h=!0,Jo()),h){if(C(),Date.now()-Fe.reactionLoadedAt<=no)return}else{const Z=await cP(k);if(i!==Fe.token||Te.popover.hidden||Fe.type!=="Reaction"||Z&&(Fe.reactionEntries=Cv(Z.entries),Fe.reactionLoadedAt=Z.storedAt,Fe.reactionUsername=k,h=!0,Jo(),C(),Z.fresh))return}const[j,B]=await Promise.all([nD(k,i),aD(k,i)]);if(!j||!B||i!==Fe.token||Te.popover.hidden||Fe.type!=="Reaction")return;Fe.reactionEntries=Cv([...B,...j]),Fe.reactionLoadedAt=Date.now(),Fe.reactionUsername=k,h=!0,iS(k,Fe.reactionEntries),Fe.multi=!1,Hc(),Jo(),C();return}const $=await cD(Fe.query,i);if(!$||i!==Fe.token||Te.popover.hidden)return;Fe.allEntries=$,Fe.allIds=null,Jo()}catch($){if(i!==Fe.token||Te.popover.hidden||$&&$.name==="AbortError"||p&&h)return;Fe.entries=[],p?Fe.reactionEntries=[]:Fe.allEntries=[],Fe.hasNext=!1,ka.message(`${p?"回应记录":"收藏"}加载失败:${$e($.message)}`,!0),Te.pageInfo.textContent=`第 ${Fe.page+1} 页`}},AR=i=>{const p=I.activeContext,h=Sv(i);if(!p||Number(p.topicId)!==h.topicId)return;const y=i&&i.bookmarkable_type==="Post"?"Post":"Topic";MM(p,y,i?.bookmarkable_id,!1,null,{persist:!0})},dD=()=>{const i=to[Ft.group]||to.all,p=U_(Ft.group);ec(bt,"notificationMode",p),St.forEach(h=>fr(h,h.dataset.notificationModePanel!==p)),fr(de.toolbar,i.source!=="notifications"&&p!=="messages"),fr(de.unreadStatus,i.source!=="notifications"),fr(de.markAll,i.source!=="notifications"),fr(de.newMessage,p!=="messages")},Mv=(i,p={})=>{const h=i.notifications;Ft.total=i.total,Ft.hasNext=i.hasNext,Gr(de.list),de.list.innerHTML=h.length?$N(h):`${p.search?"本地缓存中没有匹配消息":"暂无消息"}
`,MN(de.list,h),to[Ft.group]?.source==="notifications"&&de.markAll.dataset.ldpRequestBusy!=="1"&&(de.markAll.disabled=Ff(Ft.group)<=0);const y=Math.max(1,Math.ceil(Ft.total/Lb(Ft.group)));Na.paging(y,Ft.total>0),!Ft.total&&p.search&&(de.pageInfo.textContent="本地缓存"),wR()},uD=()=>{const i=ko(Ft.query),p=xb(Ft.group).filter($=>lN($,i)),h=Lb(Ft.group),{entries:y,totalPages:C}=wv(p,Ft,h);Mv({notifications:y,total:p.length,hasNext:Ft.page{const y=++Ft.token,C=h.force===!0;if(Ft.group=Object.hasOwn(to,i)?i:"all",Ft.groups[U_(Ft.group)]=Ft.group,Ft.page=Math.max(0,Number(p)||0),de.pageInfo.removeAttribute("title"),dD(),ec(lr,"notificationGroup",Ft.group),Ft.query){Vs&&Pu().cancelQueued(Vs),Vs="",uD();return}const $=Jd(Ft.group,Ft.page),k=`notifications:${$}`;Vs&&Vs!==k&&Pu().cancelQueued(Vs);const j=Ob($),B=j&&Ps.get($),Z=B&&Date.now()-Number(B.at||0)<=Cw;if(j&&(Mv(j),Z&&!C)){Vs="";return}Vs=k,j||Na.message("正在加载消息…");try{const J=await Hb(Ft.group,Ft.page,{force:C||!!j});if(y!==Ft.token||ke.hidden)return;Mv(J)}catch(J){if(J&&J.name==="AbortError"||y!==Ft.token||ke.hidden)return;if(j){de.pageInfo.textContent=`${Ft.page+1} 页 · 缓存更新失败`,de.pageInfo.title=String(J?.message||"无法更新缓存");return}Na.message(`消息加载失败:${$e(J.message)}`,!0),de.pageInfo.textContent=`第 ${Ft.page+1} 页`}finally{Vs===k&&(Vs="")}};let Uc=null,Lu=null,jc=0;const Rv=()=>{if(jc&&clearTimeout(jc),jc=0,Uc&&Lu&&D(Uc.off))try{Uc.off("notifications:changed",I,Lu)}catch{}Uc=null,Lu=null},pD=()=>{ke.hidden||jc||(jc=setTimeout(()=>{jc=0,!ke.hidden&&vl(Ft.group,Ft.page,{force:!0})},lA))},fD=()=>{Ls(),Fb(),pD()},mD=()=>{if(Uc&&Lu)return!0;Rv();const i=xt("service:app-events");if(!i||!D(i.on))return!1;const p=()=>fD();try{return i.on("notifications:changed",I,p),Uc=i,Lu=p,!0}catch{return Rv(),!1}};Se||(I.startNotificationLiveEvents=mD,I.stopNotificationLiveEvents=Rv,O(document,"visibilitychange",()=>{document.visibilityState==="visible"&&(Ls(),I.startNotificationLiveEvents?.(),Fb(),ke.hidden||vl(Ft.group,Ft.page,{force:!0}))}));const $v=()=>{$f(xe.isFullPage()||window.innerWidth<=Es)};let mi=xe.isFullPage(),Gm=vf(mi),Qm="interface",Tv=Gi(),Av=ni(),Iu=ei(qe);const hi=()=>Tv,wl=i=>{Tv=zd(i,Ri)},Pv=()=>zd(Ri),hD=()=>mi?"fullpageLayoutProfile":"layoutProfile",PR=()=>mi?Ol:yd,Nv=()=>vf(mi),NR=(i=!1)=>{const p=xe.isFullPage();!i&&mi===p||(mi=p,Gm=vf(p))},zc=()=>Gm,kR=i=>{Gm=qd(i,PR())},LR=()=>qd(PR()),La=()=>Av,IR=i=>{Av=Cf(i,Zo)},kv=()=>Cf(Zo),Lv=()=>{xv(),Du(),Gs(),qu()},Iv=(i=!1)=>{L.hidden||(Xm(),Ou(),Mn(),Cl(),i&&Vc())},Ym=()=>{const i=xe.isFullPage();Yr.hidden=!z&&!i,Yr.innerHTML=`${ue(i?"minimize2":"maximize2")}${i?"浮窗":"全页"}`,nt(Yr,i?"切换到浮窗":"切换到全页"),Do.syncMode(),$v()},gD=()=>{Ym(),fv(),NR(),I.syncSettingsSurfaceGeometry?I.syncSettingsSurfaceGeometry():oo(),Ud(),Lv();const i=I.activeContext;i?.nativeComposerWindow&&i.nativeComposerWindow.syncLayer(),vv(),Iv(!0),requestAnimationFrame(()=>{I.syncFullPageHeaderAlignment?.(),ne._ldpSyncTopicActionRailPosition?.()})};Se||(Ym(),O(ne,"ldp-reader-workspace-change",gD),O(Yr,"click",()=>{if(!z&&!xe.isFullPage())return;xe.setMode(xe.isFullPage()?"floating":"fullpage"),Ym(),Iv(),Lv(),vv();const i=I.activeContext;i&&(i.streamListOffset=NaN,i.streamNeedsRepair=!0,vo(i))}));const xv=()=>{ne.style.setProperty("--ldp-image-zoom",String(vb()/100))},bD=X('[data-settings-panel="font"]'),Dv=X('[data-settings-panel="layout"]'),yD=Dv.querySelector(".ldp-settings-title"),vD=Dv.querySelector(".ldp-settings-description"),xR=X('[data-settings-panel="appearance"]'),wD=()=>{const i=La(),p=ni();return bw.some(h=>i[h]!==p[h])},xu=()=>!L.hidden&&!xR.hidden?La():ni(),SD=()=>!L.hidden&&!bD.hidden?hi():Gi(),Gs=()=>{const i=SD(),p=Math.min(1.1,Math.max(1,.73+S.clientWidth/4e3)),h=Math.min(1,Math.max(0,(S.clientWidth-la)/(yw-la))),y=ha(i.interface)/100,C=(B,Z)=>`${Math.round((B+(Z-B)*h)*y*10)/10}px`,$={...i,interface:i.interface*p,post:i.post*p,composer:i.composer*p};ne.style.setProperty("--ldp-reader-display-scale",String(p)),ne.style.setProperty("--ldp-reader-title-font-size",C(rA,wd["--ldp-font-xl"])),ne.style.setProperty("--ldp-reader-meta-font-size",C(oA,wd["--ldp-font-sm"])),ne.style.setProperty("--ldp-reader-topic-tag-font-size",C(nA,wd["--ldp-font-sm"])),wb(ne.style,$.interface),wb(cn?.style,$.interface),Sf(ne.style,$),Sf(cn?.style,$),Sf(It.style,$);const k=b_(ZT,$.post);ne.style.setProperty("--ldp-post-font-size",k),cn?.style.setProperty("--ldp-post-font-size",k),Rf($.composer);const j=I.activeContext;j?.nativeComposerWindow&&j.nativeComposerWindow.syncFont($),I.syncReaderTitleDensity?.(),I.syncReaderTitleActionAlignment?.()},Du=()=>{const i=xe.isFullPage(),h=!L.hidden&&!Dv.hidden&&mi===i?zc():vf(i);$o.forEach(y=>{ne.style.setProperty(`--ldp-layout-${y}`,`${h[y]}%`)}),requestAnimationFrame(()=>I.syncFullPageHeaderAlignment?.())},DR=()=>{const i=new Set([cn,ne]);[...Fa?.children||[]].forEach(p=>i.add(p)),document.querySelectorAll("[data-ldp-reader-composer-root]").forEach(p=>i.add(p)),i.forEach(p=>w_(p,xu()))},qR=()=>{const i=xu();ne.style.setProperty("--ldp-zebra-color",ga(i,"zebraColor")),ne.style.setProperty("--ldp-zebra-radius",`${i.zebraRadius}px`)},OR=()=>{const i=ga(xu(),"listZebraColor");if(!xe.isEmbedded()){It.style.removeProperty("--ldp-reader-list-zebra-color");return}It.style.setProperty("--ldp-reader-list-zebra-color",i)},qv=(i,p)=>{const h=xu(),y=`${i}Line`,C=`--ldp-${i}-line`,$=`${y}Color`,k=ga(h,$),j=Zo[Sb($)],B=h[`${y}Width`],Z=h.structureColorsEnabled!==!1;if(ne.style.setProperty(`${C}-color`,Z?k:"transparent"),ne.style.setProperty(`${C}-width`,`${B}px`),ne.style.setProperty(`${C}-emphasis-width`,`${B*p}px`),i==="reply"){ne.style.setProperty(`${C}-secondary-width`,`${B+.5}px`),ne.style.setProperty(`${C}-radius`,`${h.replyLineRadius}px`),Xe(".ldp-children").forEach(qE);const ye=I.activeContext;ye?.floorPreview&&ye.floorPreview.isOpen()&&Sy(ye);return}if(i!=="divider")return;const J=xe.isEmbedded()&&!Z;It.classList.toggle("ldp-structure-colors-disabled",J),!xe.isEmbedded()||!Z||k===j?It.style.removeProperty("--ldp-divider-line-color"):It.style.setProperty("--ldp-divider-line-color",k),!xe.isEmbedded()||B===_p?It.style.removeProperty("--ldp-divider-line-width"):It.style.setProperty("--ldp-divider-line-width",`${B}px`)},FR=()=>qv("reply",2),BR=()=>qv("quote",5),HR=()=>qv("divider",2),UR=()=>{const i=xu();ne.style.setProperty("--ldp-floor-preview-color",ga(i,"floorPreviewColor")),ne.style.setProperty("--ldp-floor-preview-height",`${i.floorPreviewHeight}px`);const p=I.activeContext;p?.floorPreview&&p.floorPreview.isOpen()&&Sy(p)},qu=()=>{DR(),qR(),OR(),FR(),BR(),HR(),UR()},Ov=(i=ei(qe))=>{ne.style.setProperty("--ldp-jump-highlight-color",i.color),ne.style.setProperty("--ldp-jump-highlight-radius",`${i.radius}px`),ne.style.setProperty("--ldp-jump-highlight-border-width",`${i.borderWidth}px`),ne.style.setProperty("--ldp-jump-highlight-duration",`${ZS(i)}ms`),ne.style.setProperty("--ldp-jump-highlight-count",String(i.count))};ne._ldpApplyThemeDependentColors=()=>{qu(),!L.hidden&&!xR.hidden&&Cl()};const Sl=i=>{const p=Number(i.min)||0,h=Number(i.max)||100,y=Number(i.value),C=h>p&&Number.isFinite(y)?Math.min(100,Math.max(0,(y-p)/(h-p)*100)):0;i.style.setProperty("--ldp-range-progress",`${C}%`)},Xm=()=>{const i=vb();st.value=String(i),Et.textContent=`${i}%`,Sl(st);const p=Fl,h=jd(p.preset==="custom"?p.custom:p.preset,100);At.disabled=i===h,rr.checked=qe.lightboxOriginalByDefault===!0,_r.checked=qe.lightboxCommentsExpandedByDefault===!0,gr.checked=qe.lightboxDescriptionExpanded===!0};let Km=[],Jm=[],Qs="idle",Zm=null,fs="interface";const gi=new Set,En=(i=fs)=>i==="host"?ag:aa[i]||aa.interface,_l=(i=fs,p=hi())=>i==="host"?qe:p,jR=()=>[...Kt].find(i=>i.dataset.fontScope===fs),zR=(i,p,h="")=>{const y=new Map;return[...i||[]].forEach(C=>{const $=oi(C&&(C.family||C[h]));if(!$)return;const k=$.toLocaleLowerCase(),j=y.get(k)||{name:$,search:new Set};p.forEach(B=>{C?.[B]&&j.search.add(String(C[B]))}),y.set(k,j)}),[...y.values()].map(C=>({name:C.name,search:[...C.search]})).sort((C,$)=>String(C.name||"").localeCompare(String($.name||""),"zh-CN",{sensitivity:"base",numeric:!0}))},WR=()=>{try{return zR(document.fonts,["family","style","weight","stretch"])}catch{return[]}},_D=(i=hi())=>{Kt.forEach($=>{const k=$.dataset.fontScope,j=$.querySelector(".ldp-font-family-value"),B=En(k),Z=_l(k,i),J=kn(Z[B.family]);j.textContent=J!=="custom"?Gn[J].label:oi(Z[B.customFamily])||"手动输入字体名",$.dataset.ldpTooltipLabel=j.textContent});const p=En(),h=_l(fs,i),y=kn(h[p.family]),C=ko(h[p.customFamily]);br.querySelectorAll(".ldp-font-family-option").forEach($=>{const k=$.dataset.fontKind==="preset"?y===$.dataset.fontValue:y==="custom"&&ko($.dataset.fontValue)===C;$.setAttribute("aria-selected",String(k))})},CD=({kind:i,value:p,label:h,source:y,stack:C,search:$=[]},k)=>{const j=Ne("button"),B=En(),Z=i==="preset"?kn(k[B.family])===p:kn(k[B.family])==="custom"&&ko(k[B.customFamily])===ko(p);return j.className="ldp-font-family-option ldp-picker-option",j.type="button",j.dataset.fontKind=i,j.dataset.fontValue=p,j.dataset.searchText=[h,y,...$].flatMap(J=>Df(J)).join(" "),j.setAttribute("role","option"),j.setAttribute("aria-selected",String(Z)),j.innerHTML=`${$e(h)}${$e(y)}Aa 字`,j.querySelector(".ldp-font-family-preview").style.fontFamily=C,j},eh=(i,p,h,y)=>{if(!h.length)return;const C=Ne("div");C.className="ldp-font-family-group",C.setAttribute("role","group"),nt(C,p);const $=Ne("div");$.className="ldp-font-family-group-title",$.textContent=`${p} · ${h.length}`,C.append($),h.forEach(k=>C.append(CD(k,y))),i.append(C)},VR=()=>{const i=ko(jt.value);let p=0;br.querySelectorAll(".ldp-font-family-group").forEach(h=>{let y=0;h.querySelectorAll(".ldp-font-family-option").forEach(C=>{const $=!i||String(C.dataset.searchText||"").includes(i);C.hidden=!$,$&&(y+=1)}),h.hidden=y===0,p+=y}),on.hidden=p!==0},Wc=()=>{const i=_l(),p=En(),h=(ye,Q)=>({kind:"named",value:ye.name,label:ye.name,source:Q,stack:`${JSON.stringify(ye.name)},${Gn.system.stack}`,search:ye.search}),y=Object.entries(Gn).filter(([ye])=>ye!=="custom").map(([ye,Q])=>({kind:"preset",value:ye,label:Q.label,source:ye==="site"?"站点样式":ye===Mi?"默认字体":"阅读器预设",stack:ye==="site"?m_():Q.stack,search:[Q.stack]})),C=Km.map(ye=>h(ye,"页面已加载")),$=Jm.map(ye=>h(ye,"本机已安装")),k=oi(i[p.customFamily]),j=new Set([...Km,...Jm].map(ye=>ko(ye.name))),B=kn(i[p.family])==="custom"&&k&&!j.has(ko(k))?[h({name:k,search:[k]},"当前手动输入")]:[],Z=document.createDocumentFragment();eh(Z,"预设",y,i),eh(Z,"页面已加载",C,i),eh(Z,"本机已安装",$,i),eh(Z,"当前选择",B,i),br.replaceChildren(Z);let J="";Qs==="loading"?J="正在读取浏览器允许访问的本机字体…":Qs==="unsupported"?J="当前浏览器未开放本机字体列表;仍可选择页面字体或手动输入。":Qs==="denied"?J="未获得本机字体读取权限;仍可选择页面字体或手动输入。":Qs==="error"?J="本机字体读取失败;仍可选择页面字体或手动输入。":Qs==="ready"&&!Jm.length&&(J="浏览器没有返回可用的本机字体;仍可手动输入字体名。"),Sn.textContent=J,Sn.hidden=!J,VR()},Fv=()=>{const i=jR();if(Ot.hidden||!i||!i.isConnected)return;const p=12,h=6,y=ur(),C=i.getBoundingClientRect(),$=Math.min(360,Math.max(260,y.width-p*2)),k=y.left+p,j=y.top+p,B=Math.min(Math.max(k,C.left),Math.max(k,y.right-$-p));Ot.style.width=`${Math.round($)}px`,Ot.style.top=`${p}px`;const Z=Ot.getBoundingClientRect().height,J=C.bottom+h,ye=C.top-Z-h,Q=J+Z<=y.bottom-p?J:ye>=j?ye:Math.max(j,y.bottom-Z-p),gt=Q<=C.top&&Q+Z>=C.bottom?Math.max(k,B-2):B;Ot.style.left=`${Math.round(gt-y.left)}px`,Ot.style.top=`${Math.round(Q-y.top)}px`},Ro=(i=!1)=>{if(Ot.hidden)return;const p=jR();Ot.hidden=!0,Kt.forEach(h=>sr(h,!1)),i&&p&&p.isConnected&&p.focus()},ED=()=>{if(Qs!=="idle")return Zm;const i=D(ws?.queryLocalFonts)?ws:globalThis;if(!D(i.queryLocalFonts))return Qs="unsupported",Wc(),null;const p=h=>{const y=h&&(h.name==="NotAllowedError"||h.name==="SecurityError");Qs=y?"denied":"error",y||console.warn("[LDP] local font access failed",{name:String(h?.name||"Error"),message:String(h?.message||"")})};Qs="loading",Wc();try{Zm=Promise.resolve(i.queryLocalFonts()).then(h=>{Jm=zR(h,["family","fullName","postscriptName","style"],"fullName"),Qs="ready"}).catch(h=>{p(h)}).finally(()=>{Zm=null,Wc(),Fv()})}catch(h){p(h),Wc()}return Zm},GR=i=>{hw.includes(i)&&(fs=i),Kt.forEach(p=>sr(p,p.dataset.fontScope===fs)),Km=WR(),jt.value="",Wc(),Ot.hidden=!1,Fv(),ED(),jt.focus({preventScroll:!0}),document.fonts?.ready&&Promise.resolve(document.fonts.ready).then(()=>{Km=WR(),Wc(),Fv()})},QR=[{input:Jt,output:Go,key:"interface",normalize:ha},{input:Zr,output:Qo,key:"post",normalize:ha},{input:Or,output:Kr,key:"composer",normalize:ha}],Mn=()=>{const i=hi(),p=Gi(),h=Pv(),y="阅读器";Cn.forEach(k=>{const j=Cs[k.dataset.hostEmbedSize];if(!j)return;const B=wf(qe[j.pref]);k.value=String(B),Sl(k);const Z=[...Ur].find(ye=>ye.dataset.hostEmbedSizeValue===k.dataset.hostEmbedSize);Z&&(Z.value=`${B}%`,Z.textContent=`${B}%`);const J=k.dataset.hostEmbedSize==="labelCard"?"卡片高度、文字、SVG/图片图标、间距和横向内边距会按同一比例变化。":k.dataset.hostEmbedSize==="stats"?"右下角的回复、最近回复、活跃和回应四组信息会一起变化。":"";pr(k,`使用左右嵌入阅读时,调整原站主题列表中${j.label}的大小,默认 ${j.defaultScale}%。${J}修改后立即保存。`)}),ec(cs,"fontScopeTab",Qm,!0),pi.forEach(k=>{k.hidden=k.dataset.fontScopePanel!==Qm}),_D(i),hw.forEach(k=>{const j=k==="host",B=j?qe:i,Z=j?Bo:h,J=En(k),ye=[...ct].find(A=>A.dataset.fontScope===k),Q=[...$a].find(A=>A.dataset.fontScope===k),Ve=[...zs].find(A=>A.dataset.fontScope===k),gt=[..._n].find(A=>A.dataset.fontScope===k),Ht=Ln(B[J.color],Z[J.color]);kn(B[J.family])!=="custom"&&gi.delete(k),ye.value=B[J.customFamily],ye.hidden=kn(B[J.family])!=="custom"||!gi.has(k),Q.value=String(B[J.weight]),Ve.value=Ht||ln[Vi()]["--primary"],gt.textContent=Ht?Ht.toUpperCase():"跟随主题";const je=[...Kt].find(A=>A.dataset.fontScope===k);j?(pr(je,`选择左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的字体。默认“${Gn[Z[J.family]].label}”;支持页面字体、本机字体和手动输入。修改后立即保存。`),pr(Q,`调整左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的字重。默认 ${Z[J.weight]} · ${sg[Z[J.weight]]}。修改后立即保存。`),pr(Ve,"调整左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的颜色。默认跟随当前明暗主题。修改后立即保存。")):(pr(je,`选择${y}${J.label}文字使用的字体。下拉按预设、页面已加载和本机已安装分组并支持搜索;首次读取本机字体时浏览器可能询问权限。默认“${Gn[Z[J.family]].label}”。`),pr(Q,`调整${y}${J.label}文字的粗细。默认 ${Z[J.weight]} · ${sg[Z[J.weight]]}。`),pr(Ve,`调整${y}${J.label}文字的基础颜色。默认跟随当前明暗主题;自定义颜色只作用于${J.label}文字,不改变链接、错误和状态等语义颜色。`))}),QR.forEach(({input:k,output:j,key:B})=>{k.value=String(i[B]),j.textContent=`${i[B]}%`,Sl(k)}),pr(Jt,`调整${y}界面文字字号,包括标题、元信息、楼层头部、操作区、时间轴、设置面板和提示。默认 ${h.interface}%;不会改变帖子正文和回复输入文字。`),pr(Zr,`调整${y}中的帖子正文和各楼层内容,不改变标题、按钮等界面文字。默认 ${h.post}%;拖动时实时预览,统一保存。`),pr(Or,`调整${y}回复弹窗里的输入文字和编辑区域字号,不改变已发布正文。默认 ${h.composer}%;拖动时实时预览,统一保存。`),Aa.forEach(k=>{const j=k.dataset.hostFontReset!=null,B=k.dataset.fontFieldReset,Z=j?"host":sa.find(Ke=>Object.values(En(Ke)).includes(B)),J=En(Z),ye=j?J[k.dataset.hostFontReset]:B,Q=j?qe:i,Ve=j?Bo:h,gt=Ve[ye],Ht=ye===J.family,je=ye===J.weight,A=ye===J.color,se=`${j?"":y}${J.label}${Ht?"字体":je?"字重":A?"颜色":"字号"}`,De=Ht?Gn[gt].label:je?String(gt):A?"跟随主题":`${gt}%`;k.disabled=Ht?Q[J.family]===Ve[J.family]&&Q[J.customFamily]===Ve[J.customFamily]:Q[ye]===gt,k.querySelector("span").textContent=`默认 ${De}`,nt(k,`恢复${se}默认值 ${De}`)});const C=ig.some(k=>i[k]!==p[k]);nn.textContent=C?`${y}字体正在实时预览,等待统一保存。`:`${y}字体当前配置已应用。`,nn.classList.toggle("balanced",!C);const $=Object.keys(Bo).every(k=>qe[k]===Bo[k]);Yo.disabled=$&&ig.every(k=>i[k]===h[k]),Kc()},Ou=()=>{const i=zc(),p=Nv(),h=LR(),y=mi?"全屏":"普通(嵌入/浮窗)";fa(yD,`布局设置 · ${y}`),fa(vD,`当前检测为${y}形态;实时预览、保存和恢复默认只作用于该形态。`);const C=Math.max(0,mn(100-rc(i)));N.forEach(k=>{const j=k.dataset.layoutRegion;$o.includes(j)&&(k.max="100",k.value=String(i[j]),Sl(k))}),V.forEach(k=>{const j=k.dataset.layoutValue;$o.includes(j)&&(k.textContent=`${Number(i[j].toFixed(1))}%`)}),le.classList.toggle("warning",C>0),le.classList.toggle("balanced",C<=0);const $=$o.some(k=>Math.abs(i[k]-p[k])>=.01);le.textContent=C>0?`当前形态:${y}。还剩 ${Number(C.toFixed(1))}% 未分配,必须分配完才能保存。`:$?`当前形态:${y}。正在实时预览,只会保存该形态的布局。`:`当前形态:${y}。当前配置已应用,只会保存该形态的布局。`,H.disabled=$o.every(k=>Math.abs(i[k]-h[k])<.01),Kc()},Vc=()=>{const i=Do.getState(),p=window.innerWidth<=Es,h=xe.isFloating(),y=`${Math.round(i.width)} × ${Math.round(i.height)} · (${Math.round(i.left)}, ${Math.round(i.top)})${Do.isPinned()?" · 保持显示":""}${Do.isLocked()?" · 已锁定":""}`;te.max=String(Math.max(la,window.innerWidth-so*2)),we.max=String(Math.max($i,window.innerHeight-so*2)),Ae.max=String(Math.max(so,window.innerWidth-so-i.width)),Ge.min=String(so),Ge.max=String(Math.max(so,window.innerHeight-so-i.height)),te.value=String(Math.round(i.width)),we.value=String(Math.round(i.height)),Ae.value=String(Math.round(i.left)),Ge.value=String(Math.round(i.top)),Ye.checked=Do.isPinned(),lt.checked=Do.isLocked(),[te,we,Ae,Ge,Ye,lt].forEach(C=>{C.disabled=p}),Qt.textContent=p?"当前视口较窄,阅读器使用同一套窄屏响应式布局。":h?`${y}${Do.isLocked()?"":" · 可拖动缩放"}`:`${xe.isEmbedded()?"当前为嵌入阅读":"当前为全屏阅读"};以下配置将在切换到浮窗后生效。浮窗:${y}`,Mo.disabled=p||Do.isDefault()},MD={interaction:DR,zebra:qR,"list-zebra":OR,"reply-line":FR,"quote-line":BR,"divider-line":HR,"floor-preview":UR},YR=fl.map(i=>Object.assign({},i,{apply:MD[i.section]})),RD=()=>{const i=La();YR.forEach(({input:p,output:h,key:y})=>{const C=p.type==="range",$=i[y];p.value=C?String($):$,h.textContent=C?`${$}px`:$,C||(h.title=`当前主题实际应用 ${ga(i,y).toUpperCase()}`),C&&Sl(p)})},$D=()=>{const i=La(),p=kv();WM.forEach(h=>{const y=h.dataset.colorReset,C=v_(y),$=h.closest('[data-appearance-group="structure"]')&&i.structureColorsEnabled===!1;h.disabled=$||C.every(k=>p[k]==null||i[k]===p[k])})},TD=()=>{const i=La();Jy.forEach(p=>{const h=p.dataset.appearanceGroupToggle,y=i[h]!==!1,C=p.closest(".ldp-color-group");p.checked=y,C.classList.toggle("is-disabled",!y),C.querySelector(".ldp-settings-fields").setAttribute("aria-disabled",String(!y)),C.querySelectorAll(".ldp-settings-fields input").forEach($=>{$.disabled=!y})})},Cl=()=>{const p=Vi(),h=Nn(ri()),y=p==="dark"?"暗色主题":"明亮主题";$m.innerHTML=`当前预览 ${y}适应色${h==="system"?"(跟随系统)":""};HEX 保留所选原色,实际呈现自动限制亮度与对比。`,RD(),TD(),$D();const C=wD();Tm.textContent=C?"阅读器外观正在实时预览,等待统一保存。":"阅读器外观当前配置已应用。",Tm.classList.toggle("balanced",!C),Kc()};let th=!1;const rh=()=>{const i=bb(),p=XS(i);Zy.forEach(B=>{const Z=B.dataset.performancePreset===p;B.classList.toggle("active",Z),bo(B,Z)}),Am.forEach(B=>{const Z=B.dataset.performanceKey,J=i[Z];J!=null&&(B.value=ql[Z]?.integer?String(J):String(Number(J.toFixed(2))))}),zM.disabled=p==="balanced";const h={low:"省资源",balanced:"均衡",high:"减少等待",custom:"自定义"},y=`每次加载 ${i.pageSize} 个楼层 · 页面保留不超过 ${i.streamMaxItems} 个楼层 · 二级回复提前 ${i.nestedPrefetchViewports} 屏 · 限流额度 ${i.requestRateTarget}%`,C=Pu().snapshot(),$=`请求额度 ${C.shortWindowBudget} 次/10 秒、${C.longWindowBudget} 次/分钟`,k=C.endpointCoolingDown&&!C.coolingDown?` 当前隔离 ${C.endpointCooldownCount} 个异常端点,最长约 ${Math.max(1,Math.ceil(C.endpointCooldownRemaining/1e3))} 秒后允许再次尝试;其他请求继续按${$}运行。`:C.coolingDown?` 当前会话命中${C.lastRateLimitWindow||"动态"}限流,正在退避,约 ${Math.max(1,Math.ceil(C.cooldownRemaining/1e3))} 秒后继续;${$}。`:C.learnedRateLimit?` 当前会话已按最近边界调节;${$}${C.minInterval>C.configuredMinInterval?`,请求最短间隔 ${C.minInterval}ms`:""},稳定承压后继续小步提速,并发按需最高 ${C.maxConcurrent} 路。`:` 当前会话正常自适应;${$},稳定承压后小步上探,并发按需最高 ${C.maxConcurrent} 路。`,j=th?`已保存为${h[p]||"自定义"}:${y};下次打开阅读器生效。`:`下次打开采用${h[p]||"自定义"}:${y}。`;tx.textContent=j+k},Hn=i=>{const p=Math.max(0,Number(i)||0);return p<1024?`${Math.round(p)} B`:p<1024*1024?`${(p/1024).toFixed(p<10*1024?1:0)} KB`:`${(p/1024/1024).toFixed(p<10*1024*1024?1:0)} MB`},Un=i=>{const p=Math.max(0,Number(i)||0);return p<10?`${p.toFixed(1)}ms`:p<1e3?`${Math.round(p)}ms`:p<6e4?`${(p/1e3).toFixed(p<1e4?1:0)}s`:`${(p/6e4).toFixed(1)}min`},El=i=>{const p=new Date(Number(i)||0);return`${p.toLocaleTimeString("zh-CN",{hour12:!1,hour:"2-digit",minute:"2-digit",second:"2-digit"})}.${String(p.getMilliseconds()).padStart(3,"0")}`},Bv=i=>HA[i]||"其他",XR=i=>Number.isFinite(i)?i<=rt.topic?"核心":i===rt.scroll?"滚动":i===rt.visible?"可见":i===rt.nested?"预取":i===rt.auxiliary?"辅助":"后台":"",Hv=i=>UA[i]||"调度",oh=(i,p=So())=>{if(!i)return null;const h=i.pending?Math.max(0,p-i.startedAt):Math.max(0,Number(i.duration)||0),y=Math.max(0,Number(i.permitWait)||0);if(i.controlOnly){const C=String(i.controlReason||i.waitReason||"cancelled");return C==="queue-limit"?{level:"warning",kind:"队列已满",detail:"辅助请求在发出前被队列上限丢弃"}:C==="endpoint-cooldown"?{level:"warning",kind:"端点熔断",detail:"同一异常 URL 在冷却期内未再次发出,不影响其他请求"}:C==="rate-limit-priority"||C==="cooldown"?{level:"warning",kind:"限流让位",detail:"低优先级请求在发出前为限流恢复让位"}:C==="context-close"?{level:"warning",kind:"页面取消",detail:"阅读器上下文结束,请求未发出"}:{level:"warning",kind:"调度取消",detail:`请求未发出(${Hv(i.waitReason)})`}}if(i.status===429)return{level:"danger",kind:"429 限流",detail:i.rateLimitCode||"服务器拒绝了当前请求速率"};if(i.status>=500)return{level:"danger",kind:`HTTP ${i.status}`,detail:"服务器或上游服务返回错误"};if(i.status>=400)return{level:"danger",kind:`HTTP ${i.status}`,detail:"请求参数、权限或目标状态异常"};if(i.error){const C=/abort/i.test(i.error);return{level:C?"warning":"danger",kind:C?"中止/超时":"网络错误",detail:i.error}}return i.pending&&i.type!=="realtime"&&h>=Ba?{level:"danger",kind:"请求卡住",detail:`网络阶段已持续 ${Un(h)}`}:y>=1e3?{level:"warning",kind:"排队过久",detail:`${Hv(i.waitReason)}等待 ${Un(y)}`}:!i.pending&&i.type!=="realtime"&&h>=BA?{level:"warning",kind:"响应偏慢",detail:`网络阶段耗时 ${Un(h)}`}:null},nh=(i,p=So())=>{const h=Math.max(0,Number(i.permitWait)||0),y=Math.max(0,Number(i.dispatchDuration)||0),C=i.pending?Math.max(0,p-i.startedAt):Math.max(0,Number(i.duration)||0);return i.controlOnly?`${h>0?`排 ${Un(h)} · `:""}未发出`:`${h>0?`排 ${Un(h)} · `:""}${y>=1?`放行 ${Un(y)} · `:""}网 ${Un(C)}`},AD=(i=So())=>{qp(i);const p=No.slice(),h=p.filter(je=>!je.controlOnly&&je.startedAt>=i-6e4),y=h.filter(je=>je.startedAt>=i-Ni),$=Math.ceil(i/100)*100-Ni,k=Array.from({length:Ni/100},()=>0),j=new Map,B={reader:0,host:0,browser:0};let Z=0;const J=[];h.forEach(je=>{const A=Math.floor((je.startedAt-$)/100);A>=0&&A0&&je.type!=="realtime"&&se.durations.push(je.duration),je.duration>0&&je.type!=="realtime"&&J.push(je.duration)),oh(je,i)&&se.issues++,j.set(je.type,se),Object.hasOwn(B,je.source)&&B[je.source]++,Z+=je.size}),J.sort((je,A)=>je-A);const ye=J.length?Math.min(J.length-1,Math.ceil(J.length*.95)-1):-1,Q=p.slice().reverse().find(je=>je.status===429||je.retryAfter||je.rateLimitCode||je.serverLimit),Ve=p.filter(je=>(Number(je.endedAt)||je.startedAt)>=i-6e4).map(je=>({event:je,issue:oh(je,i)})).filter(je=>je.issue),gt=p.filter(je=>{const A=Number(je.queuedAt)||je.startedAt,se=je.pending?i:Number(je.endedAt)||je.startedAt;return A<=i&&se>=i-Ni}),Ht=[...j.values()].map(je=>{je.durations.sort((se,De)=>se-De);const A=je.durations.length?Math.min(je.durations.length-1,Math.ceil(je.durations.length*.95)-1):-1;return{...je,p95:A>=0?je.durations[A]:0}}).sort((je,A)=>A.count-je.count);return{at:i,session:p,recent60:h,recent10:y,traceEvents:gt,typeStats:Ht,recentIssues:Ve,sourceCounts:B,transfer:Z,peak100:Math.max(0,...k),p95:ye>=0?J[ye]:0,completed:J.length,active:p.filter(je=>je.pending).length,latestLimit:Q}},Ys=(i,p,h)=>({level:i,state:p,detail:h}),PD=(i,p)=>{const h=i.recent60.slice().reverse().find($=>$.status===429);if(h||p?.coolingDown){const $=h||i.latestLimit,k=Ts[$?.type]||"请求",j=$?.retryAfter?`,服务器要求等待 ${$.retryAfter} 秒`:p?.cooldownRemaining?`,约 ${Math.max(1,Math.ceil(p.cooldownRemaining/1e3))} 秒后继续`:"";return Ys("danger","服务器限流",`${k}在最近一分钟收到 429${j};未启动的辅助和后台请求会让位。`)}const y=i.recentIssues.slice().reverse().find($=>$.issue.level==="danger");if(y){const{event:$,issue:k}=y,j=Ts[$.type]||$.type,B=$.callSite?`,发起点 ${$.callSite}`:"";return Ys("danger",k.kind,`${j} ${$.method} ${$.path}:${k.detail}${B}。`)}if(p&&Math.max(p.queued,p.globalQueued||0)>=3){const $=Math.max(0,Number(p.nextPermitDelay)||0),k=p.blockingReason==="10s"?"10 秒请求额度":p.blockingReason==="60s"?"60 秒请求额度":p.blockingReason==="interval"?"请求最短间隔":"",j=k?`;正在等待${k}${$?`,约 ${Math.max(1,Math.ceil($/1e3))} 秒后放行`:""}`:"";return Ys("warning","阅读器排队",`全局 ${p.globalActive||p.active} 路执行、${Math.max(p.queued,p.globalQueued||0)} 条排队${j};原站操作、目标楼层、正文和当前可见的二级回复会优先启动。`)}if(i.completed>=5&&i.p95>=1800)return Ys("warning","响应偏慢",`最近一分钟 95% 的已完成请求不超过 ${Un(i.p95)},瓶颈更可能在网络或服务器响应,而不是请求启动间隔。`);const C=i.typeStats.filter($=>["avatar","media","asset"].includes($.type)).reduce(($,k)=>$+k.count,0);return i.recent60.length>=12&&C/i.recent60.length>=.7&&i.transfer>=4*1024*1024?Ys("warning","资源占用",`头像、图片和静态资源占最近一分钟请求的 ${Math.round(C/i.recent60.length*100)}%,已传输 ${Hn(i.transfer)}。`):i.recent60.length?Ys("normal","节奏正常",`最近一分钟 ${i.recent60.length} 次请求,${i.active} 次进行中,95% 请求耗时 ${i.p95?Un(i.p95):"暂无"};当前未发现明显限流或排队瓶颈。`):Ys("normal","等待采样","打开帖子并滚动后,这里会根据排队、响应耗时、传输量和 429 判断主要瓶颈。")},ND=i=>{const p=i.at-Ni,h=$=>Math.max(0,Math.min(100,(Number($)-p)/Ni*100)),y=($,k)=>Math.max(0,h(k)-h($));return[{source:"reader",label:"阅读器"},{source:"host",label:"原站"},{source:"browser",label:"资源"}].map(({source:$,label:k})=>{const j=[Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY],Z=i.traceEvents.filter(J=>J.source===$).sort((J,ye)=>(Number(J.queuedAt)||J.startedAt)-(Number(ye.queuedAt)||ye.startedAt)).slice(-80).map(J=>{const ye=Number(J.queuedAt)||J.startedAt,Q=J.pending?i.at:Number(J.endedAt)||J.startedAt;let Ve=j.findIndex(Lh=>Lh<=ye);Ve<0&&(Ve=j.indexOf(Math.min(...j))),j[Ve]=Q;const gt=3+Ve*10,Ht=oh(J,i.at),je=Ts[J.type]||J.type,A=XR(J.priority),se=J.controlOnly?"未发出":J.pending?"进行中":J.status>0?String(J.status):J.error?"ERR":"完成",De=[`${El(J.startedAt)} · ${J.method} ${J.path}`,`${Bv(J.source)} / ${je} / ${se}`,nh(J,i.at),A&&`${A}优先级`,J.attempt>1&&`第 ${J.attempt} 次尝试`,J.callSite&&`发起点 ${J.callSite}`].filter(Boolean).join(" · "),Ke=Math.max(p,J.startedAt),Je=Math.min(i.at,Math.max(J.startedAt,Q)),Tt=J.controlOnly?"":``,Pt=Math.min(i.at,Number(J.permittedAt)||J.startedAt),Er=ye=p?``:"",Oo=Er&&!J.controlOnly?``:"",oa=Math.min(i.at,Math.max(p,Q)),ud=Math.max(.6,Math.min(99.4,h(oa))),pd=Ht?``:"";return Er+Oo+Tt+pd}).join("");return`
+ ${k}
+ ${Z}
+
`}).join("")};let sh=0;const Fu=(i,p)=>{if(!i||i.matches(":hover")||i.innerHTML===p)return;const h=i.scrollTop;i.innerHTML=p,h&&(i.scrollTop=h)},KR=()=>{if(!QM||!Cx)return;const i=AD();let p=null;try{p=Pu().snapshot()}catch{}const h=i.recent60.filter(Q=>Q.status===429).length,y={rate10:`${i.recent10.length}次 · ${(i.recent10.length/10).toFixed(1)}/秒`,rate60:`${i.recent60.length}次 · ${(i.recent60.length/60).toFixed(1)}/秒`,peak:`${i.peak100}次/100ms`,transfer:Hn(i.transfer),issues:`${i.recentIssues.length}次${h?` · 429 ${h}`:""}`};lx.forEach(Q=>{Q.textContent=y[Q.dataset.requestFlowMetric]||"—"});const C=i.recent10.filter(Q=>Q.permitWait>.5).length,$=p?`全局 ${p.globalInstances} 实例 · 执行 ${p.globalActive} · 排队 ${p.globalQueued} · 共享上限 ${p.globalMaxConcurrent} 路 · 间隔 ${p.globalMinInterval}ms${p.coordinationMode==="best-effort"?" · 协调降级":""}`:`本页阅读器 ${i.sourceCounts.reader} · 原站 ${i.sourceCounts.host}`,k=p?` · 请求额度 ${p.shortWindowCount}/${p.shortWindowBudget}(10 秒)· ${p.longWindowCount}/${p.longWindowBudget}(60 秒)`:"";gx.textContent=`近 10 秒 ${i.recent10.length} 请求 · 排队 ${C} · 异常 ${i.recentIssues.filter(Q=>Q.event.startedAt>=i.at-Ni).length} · ${$}${k}`,Fu(QM,ND(i));const j=i.typeStats.slice(0,8),B=j.length?j.map(Q=>`${$e(Ts[Q.type]||Q.type)}`).join(""):'等待请求';Fu(bx,`排队慢/久候错误/限流${B}`);const Z=PD(i,p);if(yx.dataset.level=Z.level,vx.textContent=Z.state,wx.textContent=Z.detail,YM&&(YM.textContent=i.recentIssues.length?`最近 60 秒 ${i.recentIssues.length} 个 · 红色为失败/限流`:"最近 60 秒未发现异常"),XM){const Q=i.recentIssues.slice(-8).reverse();Fu(XM,Q.length?Q.map(({event:Ve,issue:gt})=>{const Ht=Ts[Ve.type]||Ve.type,je=Ve.callSite||`${Ve.transport} 发起`,A=`${Bv(Ve.source)} / ${Ht} · ${nh(Ve,i.at)} · ${gt.detail} · 发起点 ${je}`;return`
+ ${$e(El(Ve.endedAt||Ve.startedAt))}
+ ${$e(gt.kind)}
+ ${$e(`${Ve.method} ${Ve.path}`)}${$e(A)}
+
`}).join(""):'最近 60 秒没有 HTTP 错误、网络中止、超时、慢响应或超过 1 秒的排队。
')}Fu(Sx,i.typeStats.length?i.typeStats.map(Q=>{const Ve=Q.p95?Un(Q.p95):"—";return`
+ ${$e(Ts[Q.type]||Q.type)}
+ ${Q.count} 次${Ve}${Q.issues||"—"}
+
`}).join(""):'还没有可统计的请求。
');const J=i.session.slice(-60).reverse();Fu(_x,J.length?J.map(Q=>{const Ve=Q.controlOnly?"未发出":Q.pending?"进行中":Q.status>0?String(Q.status):Q.error?"ERR":"—",gt=Ts[Q.type]||Q.type,Ht=XR(Q.priority),je=oh(Q,i.at),A=Q.callSite||`${Q.transport} 发起`,se=`${Q.method} ${Q.path}${Ht?` · ${Ht}优先级`:""}${Q.attempt>1?` · 第 ${Q.attempt} 次尝试`:""} · ${nh(Q,i.at)} · 发起点 ${A}`;return`
+ ${$e(El(Q.startedAt))}
+ ${Bv(Q.source)}
+ ${$e(gt)}
+ ${Ve}
+ ${$e(nh(Q,i.at))}
+ ${$e(`${Q.path} ← ${A}`)}
+
`}).join(""):'打开帖子或操作原站页面后,请求会按时间出现在这里。
');const ye=i.latestLimit;if(ye&&ye.status===429){const Q=Ts[ye.type]||"请求",Ve=[ye.rateLimitCode&&`错误码 ${ye.rateLimitCode}`,ye.retryAfter&&`Retry-After ${ye.retryAfter} 秒`].filter(Boolean).join(",");Pm.textContent=`本页最近观测:${Q}收到 429${Ve?`(${Ve})`:""}。`}else ye?.serverLimit?Pm.textContent=`服务器最近返回:上限 ${ye.serverLimit}${ye.serverRemaining?`,剩余 ${ye.serverRemaining}`:""}${ye.serverReset?`,重置 ${ye.serverReset}`:""}。`:p?.learnedRateLimit?Pm.textContent=`所有实例已共享历史限流与有效策略:${p.shortWindowBudget} 次/10 秒、${p.longWindowBudget} 次/分钟、并发 ${p.globalMaxConcurrent} 路。`:p&&(Pm.textContent=`当前未收到 429 或服务器上限头;所有实例共同使用 ${p.shortWindowBudget} 次/10 秒、${p.longWindowBudget} 次/分钟、并发 ${p.globalMaxConcurrent} 路。`)},Bu=()=>{sh&&clearInterval(sh),sh=0,Qw(!1)},kD=()=>{Bu(),document.visibilityState==="visible"&&(Qw(!0),KR(),sh=setInterval(KR,1e3))},ah=600*1e3,Uv=1200,at=I.resourceMonitorState||{timer:0,longTaskObserver:null,longTasksAvailable:!1,samples:[]},LD=["longTaskObserver","longFrameObserver","resourceObserver","mutationObserver","readerMutationObserver","hostMutationObserver"],jv=(i=LD)=>i.forEach(p=>{D(at[p]?.disconnect)&&at[p].disconnect(),at[p]=null});Array.isArray(at.samples)||(at.samples=[]),Array.isArray(at.events)||(at.events=[]),Array.isArray(at.visibilityTimeline)||(at.visibilityTimeline=[]),Array.isArray(at.memorySamples)||(at.memorySamples=[]),(!at.memory||typeof at.memory!="object")&&(at.memory={bytes:null,status:"waiting",nextAt:0,pending:!1}),at.timer&&clearInterval(at.timer),jv(),at.timer=0;const JR=at.visibilityTimeline[at.visibilityTimeline.length-1];JR&&JR.state!=="stopped"&&at.visibilityTimeline.push({at:So(),state:"stopped"}),at.collecting=!1,at.eventSequence=Number(at.eventSequence)||0,at.lastPruneAt=Number(at.lastPruneAt)||0,I.resourceMonitorState=at;const ID=D(window.PerformanceObserver)&&(!Array.isArray(window.PerformanceObserver.supportedEntryTypes)||window.PerformanceObserver.supportedEntryTypes.includes("longtask")),ih=D(window.PerformanceObserver)&&[...window.PerformanceObserver.supportedEntryTypes||[]].includes("long-animation-frame"),Xs=()=>So(),ZR=i=>{const p=new Date(Number(i)||0);return`${p.toLocaleTimeString("zh-CN",{hour12:!1})}.${String(p.getMilliseconds()).padStart(3,"0")}`},ms=i=>{const p=Math.max(0,Number(i)||0);return`${p<100?p.toFixed(1):Math.round(p)}ms`},zv=(i=Xs())=>{const p=i-ah;at.samples=at.samples.filter(h=>Number(h.at)>=p).slice(-600),at.memorySamples=at.memorySamples.filter(h=>Number(h.at)>=p).slice(-60),at.events=at.events.filter(h=>Number(h.at)>=p).slice(-Uv),at.visibilityTimeline=at.visibilityTimeline.filter((h,y,C)=>Number(h.at)>=p||y===C.length-1).slice(-240)},Wv=i=>{const p=Number(i?.at);if(!Number.isFinite(p))return;at.events.push(Object.assign({id:++at.eventSequence,visibility:"unknown",scope:"shared",kind:"event",duration:0,detail:"",basis:""},i,{at:p})),at.events.length>Uv&&at.events.splice(0,at.events.length-Uv);const h=Xs();h-at.lastPruneAt>=1e3&&(at.lastPruneAt=h,zv(h))},lh=i=>{for(let p=at.visibilityTimeline.length-1;p>=0;p--){const h=at.visibilityTimeline[p];if(!(Number(h.at)>i))return["visible","hidden"].includes(h.state)?h.state:"unknown"}return"unknown"},Ml=(i,p)=>Wv(Object.assign({at:i,visibility:lh(i)},p)),e$=(i="visibilitychange",p=Xs())=>{if(!at.collecting)return;const h=document.visibilityState==="visible"?"visible":"hidden",y=at.visibilityTimeline[at.visibilityTimeline.length-1];y&&y.state===h||(at.visibilityTimeline.push({at:p,state:h}),Wv({at:p,visibility:h,scope:"shared",kind:"visibility",detail:i==="start"?`开始取证 · 当前${h==="visible"?"前台":"后台"}`:`页面切换为${h==="visible"?"前台":"后台"}`,basis:"Page Visibility"}))},xD=(i=Xs())=>{const p=i-ah;return qp(i),No.filter(h=>!h.controlOnly&&Number(h.startedAt)>=p&&Number(h.startedAt)<=i).map(h=>{const y=lh(Number(h.startedAt)),C=h.source==="reader"?"reader":h.source==="host"?"host":"shared",$=Math.max(0,h.pending?i-h.startedAt:Number(h.duration)||0),k=Math.max(0,Number(h.size)||0),j=h.pending?"进行中":h.status>0?String(h.status):h.error?"ERR":"完成",B=h.resourceTimed?String(h.transport||"resource"):Ts[h.type]||h.type,Z=h.resourceTimed?h.path:`${h.method} ${h.path}`,J=h.resourceTimed?`PerformanceResourceTiming${h.source==="reader"?" + reader 元数据":h.source==="host"?" + 未标记请求":""}`:h.source==="reader"?"显式 reader 元数据":h.source==="host"?"未标记 fetch/XHR":"Resource Timing";return{id:`request-${h.id}`,at:Number(h.startedAt),visibility:y,scope:C,kind:"resource",duration:$,bytes:k,detail:`${B} · ${Z} · ${j} · ${ms($)}${k?` · ${Hn(k)}`:h.resourceTimed?" · 传输量受限或缓存命中":""}`,basis:J}})},DD=(i=Xs())=>Cd.filter(p=>p.at>=i-ah&&p.at<=i).map((p,h)=>({id:`resource-drop-${p.at}-${h}`,at:p.at,visibility:lh(p.at),scope:"shared",kind:"dropped",detail:`Resource Timing 有 ${p.count} 条浏览器记录被丢弃;该区间统计不完整`,basis:"PerformanceObserver droppedEntriesCount"})),qD=(i,p)=>at.events.concat(i.filter(h=>["visible","hidden"].includes(h.visibility)),p.filter(h=>["visible","hidden"].includes(h.visibility))),OD=i=>!i||i.nodeType!==1?!1:i===cn?!0:!!i.closest(".ldp-reader-portal-host,.ldp-overlay,.ldp-native-boost-menu,.ldp-lightbox,.ldp-user-card-fallback"),Vv=(i,p=!1)=>[...i||[]].reduce((h,y)=>!y||y.nodeType!==1||p&&OD(y)?h:h+1+(D(y.getElementsByTagName)?y.getElementsByTagName("*").length:0),0),FD=()=>{const i=document.getElementsByTagName("*").length,p=document.querySelectorAll("html > .ldp-reader-portal-host,body > .ldp-native-boost-menu");return Math.max(0,i-Vv(p))},t$=(i,p)=>{let h=0,y=0;if(p.forEach($=>{h+=Vv($.addedNodes,i==="host"),y+=Vv($.removedNodes,i==="host")}),!h&&!y)return;const C=Xs();Ml(C,{scope:i,kind:"dom",added:h,removed:y,detail:`页面元素 +${h} / −${y} · ${p.length} 个变更记录`,basis:"MutationObserver 回调"})},r$=(i,p)=>{const h=Math.max(0,Number(p?.droppedEntriesCount)||0);if(!h)return;const y=Xs();Ml(y,{scope:"shared",kind:"dropped",detail:`${i} 有 ${h} 条浏览器记录被丢弃;该区间统计不完整`,basis:"PerformanceObserver droppedEntriesCount"})},BD=i=>{const p=String(i?.sourceURL||"");let h=p;try{h=decodeURIComponent(p)}catch{}if(/Awesome LinuxDo Reader|更流畅的 LinuxDo 阅读器|katex@0\.16\.22|pinyin-pro@3\.18\.2|hls\.js@1\.6\.16/i.test(h))return"reader";try{const y=new URL(p,location.href);if((y.origin===vt||/(?:^|\.)linux\.do$/i.test(y.hostname)||/(?:^|\.)ldstatic\.com$/i.test(y.hostname))&&/\/(?:assets|plugins|theme-javascripts)\/|\.(?:m?js)(?:$|[?#])/i.test(y.pathname))return"host"}catch{}return"shared"},HD=i=>{const p=String(i?.sourceFunctionName||"").trim();if(p)return p.slice(0,80);const h=String(i?.sourceURL||"");if(!h)return"匿名脚本";try{const y=new URL(h,location.href);return`${y.host}${y.pathname}`.slice(0,100)}catch{return h.split(/[?#]/,1)[0].slice(0,100)}},UD=i=>{const p=at.memory,h=window.performance?.measureUserAgentSpecificMemory;if(!D(h)){p.status="unsupported";return}if(window.crossOriginIsolated!==!0){p.status="not-isolated";return}if(p.pending||i{const $=Number(C?.bytes),k=Xs();if(p.pending=!1,!Number.isFinite($)){p.status="error";return}p.bytes=$,p.status="measured",at.memorySamples.push({at:k,heapUsed:$}),Ml(k,{scope:"shared",kind:"memory",detail:`页面内存估计 ${Hn($)}`,basis:"measureUserAgentSpecificMemory"})}).catch(()=>{p.pending=!1,p.status="error"})},jD=(i,p)=>{const h=i.map(y=>y[p]).filter(Number.isFinite);return h.length?{count:h.length,average:h.reduce((y,C)=>y+C,0)/h.length,peak:Math.max(...h),delta:h[h.length-1]-h[0]}:{count:0,average:0,peak:0,delta:0}},o$=i=>{const p=Number(i)||0;return p?`${p>0?"+":"-"}${Hn(Math.abs(p))}`:"0 B"},Gc=i=>{const p=Math.round(Number(i)||0);return p>0?`+${p}`:String(p)},bi=(i,p)=>i.count<2?"→ —":`${i.delta>0?"↑":i.delta<0?"↓":"→"} ${p(i.delta)}`,zD=(i,p)=>{const h=i.filter(Z=>Number.isFinite(Z[p]));if(!h.length)return"";if(h.length===1)return"0,17 240,17";const y=Math.max(1,Math.ceil(h.length/120)),C=h.filter((Z,J)=>J%y===0||J===h.length-1),$=C.map(Z=>Z[p]),k=Math.min(...$),B=Math.max(...$)-k||1;return C.map((Z,J)=>{const ye=J/Math.max(1,C.length-1)*240,Q=31-(Z[p]-k)/B*28;return`${ye.toFixed(1)},${Q.toFixed(1)}`}).join(" ")},n$=()=>{const i=at.memory;return i.status==="measured"&&Number.isFinite(i.bytes)?Hn(i.bytes):i.status==="measuring"?"正在测量":i.status==="not-isolated"?"不可用(未隔离)":i.status==="unsupported"?"浏览器未提供":i.status==="error"?"测量失败":"等待测量"},WD=(i,p)=>{const h=()=>({requests:0,requestDuration:0,bytes:0,scripts:0,longTasks:0,longTaskDuration:0,domChanges:0}),y={reader:{visible:h(),hidden:h()},host:{visible:h(),hidden:h()},shared:{visible:h(),hidden:h()}},C=i-6e4;return p.forEach($=>{if($.at{if($.ati||!["visible","hidden"].includes($.visibility))return;const k=Object.hasOwn(y,$.scope)?$.scope:"shared",j=y[k][$.visibility];$.kind==="script"&&(j.scripts+=Math.max(0,Number($.duration)||0)),$.kind==="longtask"&&(j.longTasks++,j.longTaskDuration+=Math.max(0,Number($.duration)||0)),$.kind==="dom"&&(j.domChanges+=Math.max(0,Number($.added)||0)+Math.max(0,Number($.removed)||0))}),y},s$=(i,p)=>{const h=[];if(i.requests){const y=`请求 ${i.requests} / ${ms(i.requestDuration)}`;h.push(i.bytes?`${y} / ${Hn(i.bytes)}`:y)}return i.scripts&&h.push(`已归因脚本 ${ms(i.scripts)}`),p==="shared"&&i.longTasks&&h.push(`长任务 ${i.longTasks} / ${ms(i.longTaskDuration)}`),i.domChanges&&h.push(`页面元素变更 ${i.domChanges}`),h.length?h.join(" · "):"—"},VD=(i,p,h,y)=>{const C=WD(p,h),$={reader:`${i.dom} 个页面元素 · ${i.retainedFloors} 个楼层`,host:`${i.hostDom} 个页面元素`,shared:n$(),readerBasis:`reader 元数据 · Resource Timing · Reader DOM${ih?" · LoAF 已确认脚本":""}`,hostBasis:`未标记 fetch/XHR · Resource Timing · Document DOM${ih?" · 同源 LoAF 脚本":""}`,sharedBasis:`Long Tasks · Resource Timing · Page Visibility${ih?" · 未归因 LoAF":""}`};ix.forEach(J=>{const ye=J.dataset.resourceMonitorScope;J.querySelector("[data-resource-monitor-scope-current]").textContent=$[ye]||"—",J.querySelector("[data-resource-monitor-scope-visible]").textContent=s$(C[ye].visible,ye),J.querySelector("[data-resource-monitor-scope-hidden]").textContent=s$(C[ye].hidden,ye),J.querySelector("[data-resource-monitor-scope-basis]").textContent=$[`${ye}Basis`]||"—"});const k=y.filter(J=>J.at>=p-6e4),j=k.filter(J=>J.visibility==="visible").length,B=k.filter(J=>J.visibility==="hidden").length,Z=k.length-j-B;mx.textContent=`${document.visibilityState==="visible"?"当前前台":"当前后台"} · 前 ${j} / 后 ${B}${Z?` / 未归档 ${Z}`:""} 个原始事件`},GD=i=>jA[i]||"未归因",QD=(i,p)=>{if(!GM)return;const h=p.filter(y=>y.at>=i-ah&&y.at<=i).sort((y,C)=>C.at-y.at||String(C.id).localeCompare(String(y.id))).slice(0,48);GM.innerHTML=h.length?h.map(y=>{const C=y.visibility==="visible"?"前台":y.visibility==="hidden"?"后台":"未知";return`
+
+ ${C}
+ ${$e(GD(y.scope))}
${$e(y.detail)}
${$e(y.basis)}
-
`}).join(""):'等待浏览器性能、网络资源、页面元素或前后台切换事件。
'},Pq=(i,p,h)=>{const y=i.filter(J=>J.at>=h-12e3);if(y.length<10)return Ws("normal","建立基线",`最近 12 秒取得 ${y.length} 个真实快照;满 10 个后开始判断页面元素增长、主线程卡顿和请求排队。`);const T=i.filter(J=>J.at>=h-6e4)[0]||p,L=p.dom-T.dom,j=p.retainedFloors-T.retainedFloors,q=[];let re="normal";return p.longTaskDuration>=2e3?(re="danger",q.push(`最近 10 秒长任务累计 ${Math.round(p.longTaskDuration)}ms`)):p.longTaskDuration>=500&&(re="warning",q.push(`最近 10 秒长任务累计 ${Math.round(p.longTaskDuration)}ms`)),p.queuedRequests>=10?(re="danger",q.push(`当前有 ${p.queuedRequests} 条阅读器请求排队`)):p.queuedRequests>=3&&(re==="normal"&&(re="warning"),q.push(`当前有 ${p.queuedRequests} 条阅读器请求排队`)),L>=1e3&&j>=50&&(re==="normal"&&(re="warning"),q.push(`近一分钟页面元素增加 ${L} 个,保留楼层增加 ${j} 个`)),q.length?Ws(re,re==="danger"?"资源压力高":"需要关注",`${q.join(";")}。继续观察趋势,回落后状态会自动恢复。`):Ws("normal","采样正常","最近一分钟未发现阅读器页面元素快速膨胀、页面共享主线程卡顿或阅读器请求积压;内存估计不参与自动判定。")},qv=()=>{if(!XI||!xM.length)return;const i=Vs(),p=x.activeContext;Cq(i),Lv(i);const h=gq(i),y=nt.events.filter(lt=>lt.kind==="longtask"&<.at>=i-1e4),M=y.reduce((lt,At)=>lt+At.duration,0);let T=null;try{T=_u().snapshot()}catch{}const L=h.filter(lt=>lt.scope==="reader"&<.at>=i-6e4),j=L.reduce((lt,At)=>lt+At.bytes,0),q={at:i,visibility:document.visibilityState==="visible"?"visible":"hidden",longTaskCount:nt.longTasksAvailable?y.length:null,longTaskDuration:nt.longTasksAvailable?M:null,dom:se.getElementsByTagName("*").length+1,hostDom:wq(),mountedFloors:p?.streamMountedNodes?.size||0,retainedFloors:p?.streamNodeMap?.size||0,nested:p?.subReplyState?.size||0,media:I.querySelectorAll("img,video,audio,iframe").length,activeRequests:T?.active||0,queuedRequests:T?.queued||0,readerRequestCount:L.length,readerTransfer:j},re=nt.samples[nt.samples.length-1];if(!re||i-re.at>=800){if(re&&i-re.at>1800){const lt=re.visibility==="hidden"||nt.visibilityTimeline.some(Vt=>Vt.at>re.at&&Vt.at<=i&&Vt.state==="hidden"),At=nt.visibilityTimeline.some(Vt=>Vt.at>re.at&&Vt.at<=i&&Vt.state==="stopped");Iv({at:i,visibility:At?"unknown":lt?"hidden":eh(i),scope:"shared",kind:"gap",duration:i-re.at,detail:`快照空档 ${us(i-re.at)}${At?"(包含取证暂停)":lt?"(包含后台节流或冻结)":""};未补造中间样本`,basis:"High Resolution Time"})}nt.samples.push(q)}if(Lv(i),document.visibilityState!=="visible")return;const J=yq(h,bq(i)),be=nt.samples,X=GP.reduce((lt,At)=>(lt[At]=Eq(At==="heapUsed"?nt.memorySamples:be,At),lt),{}),Ue={heap:V$(),longTasks:nt.longTasksAvailable?`${y.length} 次 · ${us(M)}`:"浏览器未提供",dom:`${q.dom} 个`,floors:`${q.mountedFloors} / ${q.retainedFloors} 个`,nested:`${q.nested} 组`,media:`${q.media} 个`,requests:T?`${T.active} / ${T.queued} 条`:"—",network:`${L.length} 次 · ${Fn(j)}`,heapAverage:X.heapUsed.count?Fn(X.heapUsed.average):"—",longTasksAverage:X.longTaskDuration.count?`${X.longTaskCount.average.toFixed(1)} / ${Math.round(X.longTaskDuration.average)}ms`:"—",domAverage:`${Math.round(X.dom.average)} 个`,floorsAverage:`${Math.round(X.mountedFloors.average)} / ${Math.round(X.retainedFloors.average)}`,nestedAverage:`${Math.round(X.nested.average)} 组`,mediaAverage:`${Math.round(X.media.average)} 个`,requestsAverage:`${X.activeRequests.average.toFixed(1)} / ${X.queuedRequests.average.toFixed(1)}`,networkAverage:`${Math.round(X.readerRequestCount.average)} / ${Fn(X.readerTransfer.average)}`,heapPeak:X.heapUsed.count?Fn(X.heapUsed.peak):"—",longTasksPeak:X.longTaskDuration.count?`${Math.round(X.longTaskCount.peak)} / ${Math.round(X.longTaskDuration.peak)}ms`:"—",domPeak:`${Math.round(X.dom.peak)} 个`,floorsPeak:`${Math.round(X.mountedFloors.peak)} / ${Math.round(X.retainedFloors.peak)}`,nestedPeak:`${Math.round(X.nested.peak)} 组`,mediaPeak:`${Math.round(X.media.peak)} 个`,requestsPeak:`${Math.round(X.activeRequests.peak)} / ${Math.round(X.queuedRequests.peak)}`,networkPeak:`${Math.round(X.readerRequestCount.peak)} / ${Fn(X.readerTransfer.peak)}`,heapTrend:hi(X.heapUsed,W$),longTasksTrend:hi(X.longTaskDuration,lt=>`${Oc(lt)}ms`),domTrend:hi(X.dom,Oc),floorsTrend:hi(X.retainedFloors,Oc),nestedTrend:hi(X.nested,Oc),mediaTrend:hi(X.media,Oc),requestsTrend:hi(X.queuedRequests,Oc),networkTrend:hi(X.readerTransfer,W$)};BI.forEach(([lt,At,Vt])=>lt.forEach(Zr=>{Zr.textContent=Ue[`${Zr.dataset[At]}${Vt}`]||"—"}));const yt={heapUsed:Ue.heap,dom:Ue.dom,retainedFloors:`${q.retainedFloors} 个`};HI.forEach(lt=>{const At=lt.dataset.resourceMonitorChart,Vt=At==="heapUsed"?nt.memorySamples:be;lt.querySelector("polyline").setAttribute("points",Mq(Vt,At)),lt.querySelector("[data-resource-monitor-chart-current]").textContent=yt[At]||"—"});const Bt=be.length>1?be[be.length-1].at-be[0].at:0,C=be.slice(1).reduce((lt,At,Vt)=>Math.max(lt,At.at-be[Vt].at),0),oe=be.filter(lt=>lt.visibility==="visible").length,Te=be.filter(lt=>lt.visibility==="hidden").length,et=Bt>=6e4?`${(Bt/6e4).toFixed(1)} 分钟`:`${Math.round(Bt/1e3)} 秒`;GI.textContent=`${et} · 前 ${oe} / 后 ${Te}${C>1800?` · 最大空档 ${Bn(C)}`:""}`,Rq(q,i,h,J),Aq(i,J);const gt=Pq(be,q,i);zI.dataset.level=gt.level,WI.textContent=gt.state,VI.textContent=gt.detail,QI.textContent=`最近快照 ${H$(i)} · ${q.visibility==="visible"?"前台":"后台"} · 仅内存保留`},Dv=()=>{if(nt.timer&&clearInterval(nt.timer),nt.timer=0,kv(),nt.collecting){const i=Vs();wl(i,{scope:"shared",kind:"capture",detail:"资源取证暂停",basis:"监控面板生命周期"}),nt.visibilityTimeline.push({at:i,state:"stopped"})}nt.collecting=!1},Q$=(i,p,h,y)=>{if(!i)return!1;try{return nt[p]=new window.PerformanceObserver(y),nt[p].observe({type:h}),!0}catch{return nt[p]=null,!1}},Nq=()=>{if(nt.collecting){qv();return}if(nt.timer&&clearInterval(nt.timer),nt.timer=0,nt.collecting=!0,nt.longTasksAvailable=!1,U$("start"),nt.longTasksAvailable=Q$(hq,"longTaskObserver","longtask",(i,...p)=>{z$("Long Tasks",p[1]),i.getEntries().forEach(h=>{const y=window.performance.timeOrigin+Number(h.startTime||0),M=Math.max(0,Number(h.duration)||0);wl(y,{scope:"shared",kind:"longtask",duration:M,detail:`页面共享主线程长任务 ${us(M)}`,basis:"PerformanceLongTaskTiming · 1ms"})})}),Q$(Zm,"longFrameObserver","long-animation-frame",(i,...p)=>{z$("Long Animation Frames",p[1]),i.getEntries().forEach(h=>{const y=window.performance.timeOrigin+Number(h.startTime||0),M=Math.max(0,Number(h.duration)||0),T=Math.max(0,Number(h.blockingDuration)||0);wl(y,{scope:"shared",kind:"frame",duration:M,detail:`长动画帧 ${us(M)} · 阻塞 ${us(T)}`,basis:"Long Animation Frames"});const L=new Map;[...h.scripts||[]].forEach(j=>{const q=Math.max(0,Number(j.duration)||0);if(!q)return;const re=Sq(j),J=L.get(re)||{duration:0,forced:0,count:0,labels:[]};J.duration+=q,J.forced+=Math.max(0,Number(j.forcedStyleAndLayoutDuration)||0),J.count++,J.labels.length<2&&J.labels.push(_q(j)),L.set(re,J)}),L.forEach((j,q)=>{wl(y,{scope:q,kind:"script",duration:j.duration,detail:`长帧内已归因脚本 ${us(j.duration)}${j.forced?` · 强制布局 ${us(j.forced)}`:""} · ${j.labels.join(" / ")}`,basis:`PerformanceScriptTiming · ${j.count} 段`})})})}),D(window.MutationObserver))try{nt.readerMutationObserver=new window.MutationObserver(i=>{j$("reader",i)}),nt.readerMutationObserver.observe(Bo,{childList:!0,subtree:!0}),nt.hostMutationObserver=new window.MutationObserver(i=>{j$("host",i)}),nt.hostMutationObserver.observe(Lt,{childList:!0,subtree:!0})}catch{kv(["readerMutationObserver","hostMutationObserver"])}qv(),nt.timer=setInterval(qv,1e3)},Ov=()=>{const i=$u;xa.forEach(h=>{const y=Cm[h.key];y.input.value=String(i[h.key]),y.output.textContent=h.format(i[h.key]),h.type!=="color"&&gl(y.input)});const p=oh("flash")>0;BM.textContent=p?"跳转楼层提示正在实时预览,等待统一保存。":"跳转楼层提示当前配置已应用。",BM.classList.toggle("balanced",!p),Rv(i),Uc()};let Fc="";const Fv=(i=!1)=>{const p=Ad(Oe.loadingAnimation);if(p==="random"){if(i||!Fc){const M=Ds.filter(L=>L.key!==Fc);Fc=(M[Math.floor(Math.random()*M.length)]||Ds[0]).key}}else Fc="";const h=wM(p,Fc);Em.value=p,HM.replaceChildren(),HM.insertAdjacentHTML("afterbegin",SM(h));const y=Ds.findIndex(M=>M.key===h.key)+1;ax.textContent=`${String(y).padStart(2,"0")} / ${Ds.length} · ${h.label}${p==="random"?" · 随机预览":" · 固定使用"}`,UM.hidden=p!=="random"},Y$=()=>({boostCopyMode:Rm.value,boostCopyPrefix:Tm.value,boostCopyCounterMarker:Am.value,boostCopyCounterStep:Pm.value,boostCopyFixedSuffix:Nm.value}),X$=(i=Y$())=>{const p=Zb("原 Boost",i),h=lf(i);GM.textContent=h.mode==="counter"?`${p} → ${Zb(p,i)}`:p},Bv=()=>{const i=rp();Xy.replaceChildren(...i.map(p=>{const h=ke("span");return h.className="ldp-custom-site-item",h.innerHTML=`${$e(p)}`,h})),Xy.hidden=!i.length},$o=()=>{Bv();const i=Oe.historyButtonsAlwaysVisible===!0,p=Xp(Oe.historyEdgeTriggerPercent);jy.checked=i,jM.checked=Oe.readerQueueAlwaysVisibleWhenEmpty,se._ldpSyncReaderQueue?.(),Mc.value=String(p),Mc.disabled=i,zM.value=`${p}%`,zM.textContent=`${p}%`,gl(Mc),se._ldpSyncHistoryButtonVisibilityPreference?.(),Mm.value=Oe.historySortMode,zy.checked=Oe.openTopicsAtFirstPost===!0,Wy.checked=Oe.doubleEscapeToCloseReader,Vy.checked=Oe.confirmNativeComposerClose!==!1,WM.checked=Oe.topicActionRailVisible,VM.checked=Oe.topicActionRailFixed,x.activeContext?.syncTopicActionRail?.(),$c.checked=Oe.expandNestedRepliesByDefault===!0,$c.disabled=!1,gu.checked=Oe.expandLeafNestedReplies===!0,$m.checked=Oe.aggregateDescendantReplies===!0,$m.disabled=!1,Gy.checked=Oe.hideNestedReplyFloors===!0,lx.hidden=!(Oe.expandNestedRepliesByDefault&&Oe.expandLeafNestedReplies);const h=lf(Oe);Rm.value=h.mode,Tm.value=h.prefix,Am.value=h.counterMarker,Pm.value=String(h.counterStep),Nm.value=h.fixedSuffix;const y=h.mode==="counter";px.forEach(M=>{M.hidden=!y}),fx.forEach(M=>{M.hidden=y}),X$(Oe)};let K$=0;const J$=async()=>{const i=++K$;xg(),await Gw();const p=ZA(),h=await Promise.all([...mx].map(async y=>({el:y,stat:await CP(y.dataset.cacheSize,p)})));i===K$&&(h.forEach(({el:y,stat:M})=>{const T=y.dataset.cacheSize;y.textContent=EP(T,M)}),hx.forEach(y=>{y.textContent=vS(y.dataset.cacheRetention)}),bu.disabled=![...Rc].some(y=>y.checked))};Fe.id="ldp-setting-help-tooltip";let Hn=null;const ps=()=>{Hn&&Hn.getAttribute("aria-describedby")===Fe.id&&Hn.removeAttribute("aria-describedby"),Hn=null,Fe.classList.remove("is-visible"),Fe.hidden=!0},Z$=i=>{const p=i&&i.dataset.settingHelp;if(!p)return;Hn&&Hn!==i&&ps(),Hn=i,i.setAttribute("aria-describedby",Fe.id),Fe.textContent=p,Fe.hidden=!1,Fe.classList.remove("is-visible");const h=i.getBoundingClientRect(),y=Fe.getBoundingClientRect(),M=12,T=8,L=dr(),j=L.left+M,q=L.top+M,re=Math.max(j,L.right-y.width-M),J=Math.min(re,Math.max(j,h.left+(h.width-y.width)/2));let be=h.top-y.height-T;be{th&&clearInterval(th),th=0},eR=()=>{if(!er)return;if(!Lu){er.textContent="最后刷新 · 暂无",delete er.dataset.ldpTooltipLabel,la(er);return}const i=Math.max(0,Date.now()-Lu),p=Math.floor(i/1e3),h=p<5?"刚刚":p<60?`${p} 秒前`:p<3600?`${Math.floor(p/60)} 分钟前`:p<86400?`${Math.floor(p/3600)} 小时前`:`${Math.floor(p/86400)} 天前`,y=new Date(Lu).toLocaleString("zh-CN",{timeZone:"Asia/Shanghai",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}).replaceAll("/","-").replace(",","");er.textContent=`最后刷新 · ${h}`,er.dataset.ldpTooltipLabel=`北京时间 ${y}`,la(er)},tR=i=>{Lu=Number(i||0),eR(),Uv(),Lu&&Cr?.closest(".ldp-settings-section")?.hidden===!1&&(th=setInterval(eR,1e3))},jv=i=>{tr&&(tr.disabled=i,tr.classList.toggle("is-refreshing",i),rt(tr,i?"正在刷新用户信息":"刷新用户信息"))},rR=async(i=!1)=>{if(!Cr)return;const p=++Hv;if(i&&jv(!0),Ps(),p!==Hv)return;const h=String(Yt||To?.username||"").trim();if(!h){Cr.innerHTML=`当前未登录 ${$e(zn)},无法读取用户资料。
`,jv(!1);return}const y=Gp(h),M=y?._ldp_connect_trust||null;tR(Math.max(Number(y&&(y._ldp_user_info_checked_at||y._ldp_summary_checked_at)||0),Number(M?.cachedAt||0))),y?QE(Cr,Object.assign({},y,To||{},{username:h}),M?.data,null,null,null):Cr.innerHTML='正在加载用户信息…
';const T=Gr("connect"),L=Ia.id==="linux-do",[j,q,re]=await Promise.allSettled([dL(h,i),T?vL(i,M):Promise.resolve(null),L?wL(h,i):Promise.resolve(null)]);if(p!==Hv)return;const J=j.status==="fulfilled"?j.value:y,be=Object.assign({},y||{},To||{},J||{},{username:h}),X=q.status==="fulfilled"?q.value:M?.data||null,Ue=q.status==="rejected"?q.reason:null,yt=re.status==="fulfilled"?re.value:null,Bt=re.status==="rejected"?re.reason:null;T&&q.status==="fulfilled"&&(be._ldp_connect_trust={data:X,cachedAt:Number($i?.cachedAt||Date.now())}),(j.status==="fulfilled"||q.status==="fulfilled")&&(qi(h,be,0),tR(Math.max(Number(be._ldp_user_info_checked_at||be._ldp_summary_checked_at||0),Number(be._ldp_connect_trust?.cachedAt||0),Date.now()))),QE(Cr,be,X,Ue,yt,Bt),jv(!1)},rh={font:{label:"字体设置",fields:Zh,draft:fi,saved:Wi,reset:()=>{bv=Wi()},patch:()=>({fontProfile:{...fi()}}),apply:js,sync:En,beforeSave:Mo},layout:{label:"布局设置",fields:Ro,draft:xc,saved:wv,reset:()=>{Fm=wv()},patch:()=>({[Xx()]:{...xc()}}),apply:Tu,sync:Pu,changed:(i,p,h)=>Math.abs(i[h]-p[h])>=.01,valid:()=>Xl(xc())===100},appearance:{label:"外观设置",fields:aw,draft:Aa,saved:ti,reset:()=>{yv=ti()},patch:()=>({appearanceProfile:{...Aa()}}),apply:Au,sync:yl},flash:{label:"动画与提示",fields:xa.map(i=>i.key),draft:()=>$u,saved:()=>Ka(Oe),reset:()=>{$u=Ka(Oe)},patch:()=>df($u),apply:Rv,sync:Ov}},oh=i=>{const p=rh[i];if(!p)return 0;const h=p.draft(),y=p.saved();return p.fields.reduce((M,T)=>M+(p.changed?p.changed(h,y,T):h[T]!==y[T]),0)},kq=(i="")=>{Object.entries(rh).forEach(([p,h])=>{p!==i&&(h.reset(),h.apply())}),Uc()};let nh="user",Iu="request";const oR=i=>String(i||"").trim().toLocaleLowerCase(),zv=i=>nf.find(([p])=>p===i),sh=()=>{const i=nh==="logs"&&!k.hidden;(i&&Iu==="performance"?Nq:Dv)(),(i&&Iu==="request"&&document.visibilityState==="visible"?fq:ku)()},Wv=i=>{Iu=i==="performance"?"performance":"request",Ql(as,"settingsLogTab",Iu,!0),kt.forEach(p=>ur(p,p.dataset.settingsLogPanel!==Iu)),sh()},nR=i=>{nh!==i&&(nh=i,Or.forEach(p=>{const h=p.dataset.settingsPanel===i;p.classList.toggle("active",h),p.setAttribute("aria-current",h?"page":"false")}),Or.find(p=>p.dataset.settingsPanel===i)?.scrollIntoView({block:"nearest",inline:"nearest"}),i!=="user"&&Uv(),sh())},Lq={image:"灯箱",font:"宿主 嵌入态",window:"置顶",appearance:"斑马线",flash:"动效",reading:"其他设置 队列 历史 导航 打开 退出",interaction:"其他设置 主贴 操作 回复 楼中楼 boost 复制",sites:"其他设置 自定义 站点 论坛 discourse",performance:"dom 窗口预算 并发",logs:"日志 请求 网络 流量 限流 性能 资源 监控 内存 cpu dom"},sR=(i=nh)=>{const p=oR(Co?.value);let h=0,y="",M="";return ur(Dn,!p),Io.forEach(T=>{const L=T.dataset.settingsPanel||"";if(L==="user")return;const j=zv(L),q=!p||oR(`${j?.slice(2).join(" ")||""} ${Lq[L]||""} ${T.textContent}`).includes(p),re=Or.find(J=>J.dataset.settingsPanel===L);re&&ur(re,!!p&&!q),q&&(h++,y||=L,L===i&&(M=L))}),M||=y,Io.forEach(T=>ur(T,T.dataset.settingsPanel!==M)),Bs.forEach(T=>ur(T,!!p&&![...T.querySelectorAll(".ldp-settings-tab")].some(L=>!L.hidden))),/性能|资源|监控|内存|cpu|dom/.test(p)?Wv("performance"):/请求|网络|流量|限流/.test(p)&&Wv("request"),Ar.textContent=p?h?`找到 ${h} 个设置分区`:"没有匹配结果":"输入名称或功能即可筛选",ur(po,!p||h>0),nR(M),M},Iq=()=>[Um,En,Pu,Dc,yl,Ov,Fv,Qm,$o,J$].forEach(i=>i()),ah=i=>{Mo(),ps(),gi="";const p=zv(i)?i:"image",h=p==="user",y=fe.classList.contains("is-settings-pages");fe.classList.toggle("is-settings-pages",!h),ur(Yr,h),h?(Co.value="",Io.forEach(M=>ur(M,M.dataset.settingsPanel!=="user")),Or.forEach(M=>ur(M,!1)),Bs.forEach(M=>ur(M,!1)),ur(po,!0),nR("user"),fe.scrollTop=0,rR(!1)):(y||Iq(),Or.find(M=>M.dataset.settingsPanel===p)?.hidden&&(Co.value=""),sR(p),fe.scrollTop=0),Uc(),sh()};let Bc=null,Hc=null,Un=null,Pa=0,gi="";const Vv=()=>Object.entries(rh).map(([i,p])=>({panel:i,label:p.label,count:oh(i)})).filter(i=>i.count>0);function Uc(){const i=Vv(),p=Object.fromEntries(i.map(y=>[y.panel,y.count])),h=i.reduce((y,M)=>y+M.count,0);ur(_,!fe.classList.contains("is-settings-pages")||!h),ca(F,`${h} 项更改未保存 · ${i.map(y=>y.label).join("、")}`),_e.disabled=!h,Or.forEach(y=>{const M=p[y.dataset.settingsPanel]||0,T=y.querySelector(".ldp-settings-tab-draft-count"),L=zv(y.dataset.settingsPanel)?.[2]||"设置";y.classList.toggle("has-draft",M>0),rt(y,M>0?`${L},${M} 项未保存`:L),T&&(ur(T,M<=0),ca(T,M>99?"99+":String(M)))})}const xq=i=>{const p=i.closest(".ldp-settings-section")?.dataset.settingsPanel||"";if(!oh(p)){gi===p&&(gi="");return}gi!==p&&(gi=p,ft("修改已实时预览,可在面板底部统一保存全部更改。"))},jc=()=>{k.classList.remove("ldp-range-dragging"),Bc&&Bc.classList.remove("ldp-range-drag-active"),Bc=null},Sl=()=>{k.classList.remove("ldp-color-picking"),Hc&&Hc.classList.remove("ldp-color-pick-active"),Hc=null},Gv=(i,p,h,y)=>{const M=lw,T=dr(),L=T.left+M,j=T.top+M,q=Math.max(L,T.right-h-M),re=Math.max(j,T.bottom-y-M),J={left:Math.round(Math.min(q,Math.max(L,i))),top:Math.round(Math.min(re,Math.max(j,p)))};return da(k,"left",`${J.left-T.left}px`),da(k,"top",`${J.top-T.top}px`),da(k,"transform","none"),J},qq=()=>{if(k.style.transform!=="none")return;const i=k.getBoundingClientRect();Gv(i.left,i.top,i.width,i.height)},aR=({closeTransients:i=!0}={})=>{Lm(),i&&(Mo(),Ac(),ps()),ro(),k.hidden||qq()};x.syncSettingsSurfaceGeometry||(x.syncSettingsSurfaceGeometry=aR);const iR=()=>{if(Pa=0,!Un)return;const i=Un,p=lw,h=i.bounds,y=h.left+p,M=h.top+p,T=Math.max(y,h.right-i.width-p),L=Math.max(M,h.bottom-i.height-p);i.previewLeft=Math.min(T,Math.max(y,i.startLeft+i.clientX-i.startX)),i.previewTop=Math.min(L,Math.max(M,i.startTop+i.clientY-i.startY)),k.style.transform=`translate3d(${i.previewLeft-i.startLeft}px,${i.previewTop-i.startTop}px,0)`},lR=i=>{if(!Un||i&&i.pointerId!==Un.pointerId)return;Pa&&(cancelAnimationFrame(Pa),Pa=0),iR();const p=Un;Un=null,Gv(p.previewLeft??p.startLeft,p.previewTop??p.startTop,p.width,p.height),k.classList.remove("ldp-settings-window-dragging");try{p.handle.hasPointerCapture(p.pointerId)&&p.handle.releasePointerCapture(p.pointerId)}catch{}},Qv=()=>{Mo(),jc(),Sl(),lR(),Dv(),ku(),Uv(),ps(),gi="",mi.clear(),ur(k,!0),Lm(),ar(Xt,!1),kq()},Dq=i=>{const p=rh[i];return!p||!oh(i)?!0:p.valid&&!p.valid()?!1:(p.beforeSave?.(),Uo(p.patch()),gi="",p.reset(),p.apply(),p.sync(),!0)},cR=()=>["layout","font","appearance","flash"].every(Dq),xu=()=>{const i=Vv();if(i.length){const p=i.reduce((h,y)=>h+y.count,0);return Cc(x.activeContext,{title:`${p} 项设置尚未保存`,message:"草稿已保留在各设置页。可以保存全部修改后关闭,也可以明确放弃这些草稿。",details:i.map(h=>({label:h.label,value:`${h.count} 项`})),note:"选择“继续编辑”会回到当前设置页,草稿不会丢失。",icon:"check",tone:"primary",cancelLabel:"继续编辑",secondaryLabel:"放弃并关闭",confirmLabel:"保存全部并关闭",onSecondary:Qv,onConfirm:()=>{if(!cR()){ah("layout"),ft("布局比例还没有分配到 100%,请完成后再保存。");return}Qv(),ft(`已保存 ${p} 项设置`)}}),!1}return Qv(),!0},dR=()=>{Mo(),k.hidden||xu(),Om(),iv(),Eu()},Gs=x.surfaceController||{closeFloatingPanels:dR,closeNotifications:Om,closeHistory:iv,closeBookmarks:Eu,closeFontFamilyMenu:Mo,requestCloseSettings:xu,syncOpenSettingsControls:Cv};if(x.surfaceController=Gs,!Ae){_v(),Rv(),$o(),Or.forEach(C=>{O(C,"click",()=>ah(C.dataset.settingsPanel||"image"))}),as.forEach(C=>O(C,"click",()=>Wv(C.dataset.settingsLogTab))),TS(Co,Dn,()=>{sR(),fe.scrollTop=0}),O(_e,"click",()=>{const C=Vv().reduce((oe,Te)=>oe+Te.count,0);if(!cR())return ah("layout"),ft("布局比例还没有分配到 100%,请完成后再保存。");ft(`已保存 ${C} 项设置`)}),O(Em,"change",()=>{pr("loadingAnimation",Ad(Em.value)),Fc="",Fv()}),O(Em,"wheel",C=>C.stopPropagation(),{passive:!0}),O(UM,"click",()=>Fv(!0)),Cr&&O(Cr,"click",C=>{const oe=C.target.closest("[data-user-info-view]");if(oe){const Te=["profile","credit"].includes(oe.dataset.userInfoView)?oe.dataset.userInfoView:"connect";Cr.dataset.userInfoView=Te,Ql(Cr.querySelectorAll("[data-user-info-view]"),"userInfoView",Te),Cr.querySelectorAll("[data-user-info-panel]").forEach(et=>{et.hidden=et.dataset.userInfoPanel!==Te})}}),tr&&O(tr,"click",()=>void rR(!0)),O(k,"pointerdown",C=>{const oe=C.target.closest(".ldp-settings-intro,.ldp-settings-search-shell");if(!oe||oe.classList.contains("ldp-settings-intro")&&fe.classList.contains("is-settings-pages")||C.button!==0||C.target.closest(".ldp-user-info-title-refresh,.ldp-settings-search")||Kr())return;const Te=k.getBoundingClientRect();Mo(),jc(),Sl(),ps();const et=Gv(Te.left,Te.top,Te.width,Te.height);Un={pointerId:C.pointerId,handle:oe,startX:C.clientX,startY:C.clientY,clientX:C.clientX,clientY:C.clientY,startLeft:et.left,startTop:et.top,width:Te.width,height:Te.height,bounds:dr()},k.classList.add("ldp-settings-window-dragging");try{oe.setPointerCapture(C.pointerId)}catch{}C.preventDefault()}),O(k,"pointermove",C=>{if(!Un||C.pointerId!==Un.pointerId)return;const oe=D(C.getCoalescedEvents)?C.getCoalescedEvents():[],Te=oe[oe.length-1]||C;Un.clientX=Te.clientX,Un.clientY=Te.clientY,Pa||(Pa=requestAnimationFrame(iR)),C.preventDefault()});const i=C=>{lR(C),jc()};["pointerup","pointercancel"].forEach(C=>O(k,C,i)),O(k,"lostpointercapture",i,!0),O(cx,"click",()=>{try{n1(),ft("设置配置已导出")}catch{ft("导出失败,请重试")}}),O(dx,"click",()=>{km.value="",km.click()}),O(km,"change",async()=>{const C=km.files?.[0];if(!C)return;let oe;try{oe=o1(JSON.parse(await C.text()))}catch{ft("配置文件无效或版本不匹配,请选择当前版本导出的 JSON 文件");return}Cc(x.activeContext,{title:"导入这份设置配置?",message:`将使用“${C.name}”覆盖当前阅读器设置,并刷新页面。`,note:"浏览历史和缓存不会改变;浮窗尺寸与位置会自动限制在当前屏幕范围内。",confirmLabel:"导入并刷新",onConfirm:()=>VS(oe,"导入失败,浏览器未能保存设置")})}),O(ux,"click",()=>{Cc(x.activeContext,{title:"恢复全部默认设置?",message:"图片、字体、布局、浮窗、外观、动效、性能、阅读、交互与站点设置都会恢复默认,并刷新页面。",note:"浏览历史和缓存不会被删除。",confirmLabel:"恢复并刷新",onConfirm:()=>VS(sa,"恢复失败,浏览器未能保存设置")})}),Rc.forEach(C=>O(C,"change",()=>{bu.disabled=![...Rc].some(oe=>oe.checked)})),O(bu,"click",async()=>{const C=[...Rc].filter(oe=>oe.checked).map(oe=>oe.value);C.length&&(bu.disabled=!0,await MP(C),Rc.forEach(oe=>{oe.checked=!1}),await J$())}),Uy.forEach(C=>{O(C,"click",()=>{const oe=C.dataset.performancePreset;if(oe==="custom"){const Te=Sm[0];Te&&Te.focus();return}JS(oe),Gm=!0,Qm()})}),Sm.forEach(C=>{O(C,"change",()=>{d1(C.dataset.performanceKey,C.value),Gm=!0,Qm()})}),O(LM,"click",()=>{JS("balanced"),Gm=!0,Qm()});const p=()=>{const C=Object.fromEntries(xa.map(oe=>[oe.key,Cm[oe.key].input.value]));$u=Ka(Object.assign({},Oe,df(C))),Ov()};sx.forEach(C=>O(C,"input",p)),[[jy,C=>pr("historyButtonsAlwaysVisible",C),$o],[jM,C=>pr("readerQueueAlwaysVisibleWhenEmpty",C),$o],[zy,C=>pr("openTopicsAtFirstPost",C),$o],[Wy,C=>pr("doubleEscapeToCloseReader",C),$o],[Vy,C=>pr("confirmNativeComposerClose",C),$o],[WM,C=>pr("topicActionRailVisible",C),$o],[VM,C=>pr("topicActionRailFixed",C),$o],[$m,C=>{Uo({aggregateDescendantReplies:C,expandNestedRepliesByDefault:C?!0:Oe.expandNestedRepliesByDefault}),x.activeContext?.subReplyState.forEach(oe=>{oe.pageIndex=0,oe.renderedKey=""}),x.activeContext?.subReplyState.forEach((oe,Te)=>qs(Te,x.activeContext)),Zd(x.activeContext)},$o],[Gy,C=>{pr("hideNestedReplyFloors",C),Zd(x.activeContext,!0)},$o],[rr,C=>pr("lightboxOriginalByDefault",C)],[_r,C=>pr("lightboxCommentsExpandedByDefault",C)],[hr,C=>pr("lightboxDescriptionExpanded",C)],[On,C=>pr("fontRenderingEnabled",C)],[Ec,C=>pr("fontRenderingOnHost",C)],[Qe,C=>qo.setPinned(C),Dc],[it,C=>qo.setLocked(C),Dc]].forEach(([C,oe,Te])=>O(C,"change",()=>{oe(C.checked),Te&&Te()})),O(ix,"click",()=>{pr("topicActionRailPosition",{x:"left",y:.95}),$o()}),O(Mc,"input",()=>{pr("historyEdgeTriggerPercent",Xp(Mc.value)),$o()}),O(Mm,"change",()=>{pr("historySortMode",Mm.value),$o(),u$(),se._ldpRefreshReaderHistoryNavigation?.()});const h=async()=>{const C=v0(Qy.value);if(!C||y0.has(C)){yu.textContent=C?`${C} 已内置支持,无需重复添加。`:"请输入有效的 HTTPS 域名或网址。";return}Yy.disabled=!0,yu.textContent="正在检测 Discourse…";try{const oe=await rT(C);await _0([...rp(),C]),Qy.value="",Bv(),yu.textContent=`已添加 ${oe.title||C},访问该站即可使用。`}catch(oe){yu.textContent=`${oe.message||"检测失败"};仅支持 Discourse 论坛。`}finally{Yy.disabled=!1}};O(Yy,"click",h),O(Qy,"keydown",C=>{C.key==="Enter"&&h()}),O(Xy,"click",async C=>{const oe=C.target.closest("[data-custom-site-remove]")?.dataset.customSiteRemove;oe&&(await _0(rp().filter(Te=>Te!==oe)),Bv(),yu.textContent=`已移除 ${oe}。`)});const y=C=>{const oe=Oe.aggregateDescendantReplies===!0;let Te=$c.checked,et=gu.checked;C===$c&&!Te?et=!0:!oe&&C===gu&&!et&&(Te=!0),Uo({expandNestedRepliesByDefault:Te,expandLeafNestedReplies:et,aggregateDescendantReplies:Te&&oe}),Zd(x.activeContext),$o()};[$c,gu].forEach(C=>O(C,"change",()=>y(C)));const M=()=>{Uo(Y$()),$o()};O(Rm,"change",M),[Tm,Am,Pm,Nm].forEach(C=>{O(C,"input",()=>X$()),O(C,"change",M)}),O(de.markAll,"click",async()=>{if(eo[Ot.group]?.source!=="notifications"||de.markAll.disabled||de.markAll.dataset.ldpRequestBusy==="1")return;ga(de.markAll,!0);const C=de.markAll.innerHTML;de.markAll.innerHTML=`${ue("loader")}处理中`;try{await mn(`${vt}/notifications/mark-read`,"PUT"),Y1(),X1(),de.list.querySelectorAll(".ldp-notification-item.unread").forEach(oe=>{oe.classList.remove("unread");const Te=oe.querySelector(".ldp-notification-read-state");Te&&(Te.textContent="已读")}),ft("消息已全部标为已读")}catch(oe){alert(`标记已读失败:${oe.message}`)}finally{ga(de.markAll,!1),de.markAll.innerHTML=C,de.markAll.disabled=Nf(Ot.group)<=0}});const T=(C,oe,Te)=>{O(C.toggle,"click",et=>{bt(et);const gt=C.popover.hidden;if(oe.forEach(lt=>lt.close()),!!xu()){if(!gt)return C.close();C.open(),Te()}})};T(Ra,[pl,Ta],()=>{l$(),Ps(),x.startNotificationLiveEvents?.(),Ot.page=0,ml(Ot.group,0,{force:!0})}),T(pl,[Ra,Ta],cs),T(Ta,[Ra,pl],()=>{c$(),fl()}),lr.forEach(C=>{O(C,"click",()=>{Ot.group=C.dataset.notificationGroup||"all",Ot.page=0,ml(Ot.group,Ot.page)})}),ht.forEach(C=>{O(C,"click",()=>{const oe=C.dataset.notificationMode||"notifications",Te=lr.find(et=>et.dataset.notificationGroup===Ot.groups[oe])||lr.find(et=>eo[et.dataset.notificationGroup]?.mode===oe);Te&&ml(Te.dataset.notificationGroup,0)})});const L={},j=(C,oe,Te,et,gt,lt=0)=>{L[C]=TS(oe.search,oe.searchClear,At=>{Te.query=At,Te.page=0,et(At)},lt),IP(oe.pagePrev,oe.pageNext,Te,gt)};j("notification",de,Ot,()=>ml(Ot.group,0),()=>ml(Ot.group,Ot.page)),O(ge.sortToggle,"click",()=>{pr("historySortMode",Oe.historySortMode==="recent-viewed"?"first-viewed":"recent-viewed"),fo.page=0,Xo.clear(),$o(),cs(),se._ldpRefreshReaderHistoryNavigation?.(),la(ge.sortToggle)}),NS(ge.popover,fo,Xo,ge.multi,ge.multiDone,ge.selectScope,cs),O(ge.selectToggle,"click",()=>{AS(Xo,nv),cs()}),j("history",ge,fo,()=>{Xo.clear(),cs()},cs),O(ge.deleteSelected,"click",()=>{const C=Xo.size;C&&Cc(x.activeContext,{title:"删除所选浏览历史?",message:`将删除选中的 ${C} 条阅读器浏览历史。`,note:"不会清除浏览器自身的访问历史。",icon:"trash",confirmLabel:`删除 ${C} 条`,onConfirm:()=>{Wa(cn().filter(oe=>!Xo.has(Number(oe.topicId)))),Xo.clear(),cs(),ft(`已删除 ${C} 条浏览历史`)}})}),O(ge.clear,"click",()=>{const C=cn().length;C&&Cc(x.activeContext,{title:"清空浏览历史?",message:`将删除全部 ${C} 条阅读器浏览历史。`,note:"此操作无法撤销,但不会清除浏览器自身的访问历史。",icon:"trash",confirmLabel:"清空全部",onConfirm:()=>{Wa([]),Xo.clear(),ge.search.value="",ge.searchClear.hidden=!0,fo.query="",fo.page=0,cs(),ft("浏览历史已清空")}})}),Be.forEach(C=>{O(C,"click",()=>{const oe=C.dataset.bookmarkType,Te=["Topic","Post","Reaction"].includes(oe)?oe:"Topic";Te!==De.type&&(De.type=Te,De.page=0,De.multi=!1,kc(),(Te!=="Reaction"||De.reactionLoadedAt<=0)&&Ko(),fl())})});let q=null,re=!1;const J=C=>{if(!q||C.pointerId!==q.pointerId)return;const{tab:oe,moved:Te}=q;q=null,oe.classList.remove("ldp-bookmark-tab-dragging"),Te&&(re=!0,pr("bookmarkTabOrder",Array.from(ct.children,et=>et.dataset.bookmarkType)))};O(ct,"click",C=>{re&&(re=!1,C.preventDefault(),C.stopImmediatePropagation())},!0),O(ct,"pointerdown",C=>{if(C.pointerType!=="mouse"||C.button!==0)return;const oe=C.target.closest(".ldp-bookmark-tab");if(oe){q={tab:oe,pointerId:C.pointerId,startX:C.clientX,startY:C.clientY,moved:!1};try{oe.setPointerCapture(C.pointerId)}catch{}}}),O(ct,"pointermove",C=>{if(!q||C.pointerId!==q.pointerId||!q.moved&&Math.hypot(C.clientX-q.startX,C.clientY-q.startY)<5)return;q.moved=!0,q.tab.classList.add("ldp-bookmark-tab-dragging"),C.preventDefault();const Te=[...ct.children].filter(et=>et!==q.tab).find(et=>{const gt=et.getBoundingClientRect();return C.clientX{const oe=C.target.closest("[data-reaction-filter]");oe&&(De.reactionFilter=String(oe.dataset.reactionFilter||""),De.page=0,Ko())}),NS(Re.popover,De,nn,Re.multi,Re.multiDone,Re.selectScope,Ko,()=>{De.allIds=null}),O(Re.selectToggle,"click",()=>{let C=De.entries.map(oe=>Number(oe.id)).filter(oe=>oe>0);De.scope==="all"&&(De.allIds||(De.allIds=g$().map(oe=>Number(oe.id)).filter(oe=>oe>0)),C=De.allIds||[]),AS(nn,C),Ko()}),j("bookmarks",Re,De,()=>{kc(),De.type==="Reaction"&&De.reactionLoadedAt>0?Ko():fl()},Ko,250),x.cancelBookmarksSearch=L.bookmarks,O(Re.deleteSelected,"click",async()=>{const C=[...nn];if(!C.length||Re.deleteSelected.dataset.ldpRequestBusy==="1"||!window.confirm(`确定取消选中的 ${C.length} 条收藏吗?`))return;ga(Re.deleteSelected,!0);const oe=De.entries.filter(gt=>nn.has(Number(gt.id))),Te=De.allEntries.filter(gt=>nn.has(Number(gt.id))),et=Array.isArray(De.allIds)&&De.allIds.length>0&&De.allIds.every(gt=>nn.has(gt));try{const lt=Ft("discourse/models/bookmark")?.default;if(!lt||!D(lt.bulkOperation))throw new Error("Discourse 收藏批处理接口尚未就绪");await lt.bulkOperation(C.map(Vt=>({id:Vt})),{type:"delete"}),await ho({tags:["bookmarks"]}),Te.forEach(b$);const At=De.entries.length>0&&oe.length===De.entries.length;et?De.page=0:At&&De.page>0&&(De.page-=1),kc(),await fl(),ft(`已取消 ${C.length} 条收藏`)}catch(gt){alert(`批量取消收藏失败:${gt.message}`)}finally{ga(Re.deleteSelected,!1),Re.popover.hidden||Ko()}}),O(Le,"click",C=>C.stopPropagation()),un(Le),PS(ge.list,".ldp-history-select-input","[data-history-topic-id]","historyTopicId",Xo,cs),O(ge.popover,"click",C=>{C.stopPropagation();const oe=C.target.closest("[data-history-topic-id]");if(!oe||C.target.closest(".ldp-history-select"))return;C.preventDefault();const Te=Number(oe.dataset.historyTopicId),et=Math.max(1,Number(oe.dataset.historyPostNumber)||1);if(C.target.closest(".ldp-history-delete")){Wa(cn().filter(gt=>Number(gt.topicId)!==Te)),Xo.delete(Te),cs(),ft("已删除这条浏览历史");return}Te>0&&lo(Te,{targetPostNumber:et})}),un(ge.popover),PS(Re.list,".ldp-bookmark-select-input","[data-bookmark-id]","bookmarkId",nn,Ko),O(Re.popover,"click",async C=>{C.stopPropagation();const oe=C.target.closest("[data-reaction-record]");if(oe){C.preventDefault();const At=Number(oe.dataset.reactionTopicId),Vt=Math.max(1,Number(oe.dataset.reactionPostNumber)||1);At>0&&(Eu(),lo(At,{targetPostNumber:Vt}));return}const Te=C.target.closest("[data-bookmark-id]");if(!Te||C.target.closest(".ldp-bookmark-select"))return;C.preventDefault();const et=Number(Te.dataset.bookmarkId),gt=De.entries.find(At=>Number(At.id)===et);if(!gt)return;if(C.target.closest(".ldp-bookmark-delete")){const At=C.target.closest(".ldp-bookmark-delete");if(At.dataset.ldpRequestBusy==="1")return;ga(At,!0);try{await gM(et),await ho({tags:["bookmarks"]}),b$(gt),nn.delete(et),De.allIds=null,De.entries.length===1&&De.page>0&&(De.page-=1),await fl(),ft("已取消这条收藏")}catch(Vt){alert(`取消收藏失败:${Vt.message}`),ga(At,!1)}return}const lt=dv(gt);lt.topicId>0&&(Eu(),lo(lt.topicId,{targetPostNumber:lt.postNumber}))}),un(Re.popover),Ze.forEach(C=>O(C,"click",oe=>{bt(oe),KS(C.dataset.readerThemeMode)})),O(Gt,"click",C=>{bt(C),x.activeContext?.translation?.cycleMode()}),O(Xt,"click",C=>{bt(C);const oe=!k.hidden;if(Om(),iv(),Eu(),oe){Mo(),xu();return}v$(!0),ur(k,!1),mi.clear(),aR({closeTransients:!1}),kd(),ar(Xt,!0),gi="",ah("user")}),O(Se,"click",C=>{C.preventDefault(),xu()}),O(k,"click",C=>C.stopPropagation()),un(k),O(k,"pointerover",C=>{const oe=C.target.closest("[data-setting-help]");!oe||C.relatedTarget&&oe.contains(C.relatedTarget)||Z$(oe)});const be=()=>{requestAnimationFrame(()=>{Hn&&!Hn.matches(":hover")&&!Hn.matches(":focus-within")&&ps()})};O(k,"pointerout",C=>{Hn&&(C.relatedTarget&&Hn.contains(C.relatedTarget)||be())}),O(k,"focusin",C=>{const oe=C.target.closest("[data-setting-help]");oe&&Z$(oe)}),O(k,"focusout",be),O(fe,"scroll",()=>{ps(),Mo(),Ac()},{passive:!0}),O(k,"pointerdown",C=>{!Dt.hidden&&!C.target.closest(".ldp-font-family-picker")&&Mo();const oe=C.target.closest('input[type="color"]');if(oe&&!oe.disabled&&C.button===0){ps(),jc(),Sl(),Hc=oe.closest(".ldp-setting-row"),Hc&&Hc.classList.add("ldp-color-pick-active"),k.classList.add("ldp-color-picking");return}Sl();const Te=C.target.closest('input[type="range"]');if(!(!Te||Te.disabled||C.button!==0)){ps(),jc(),Bc=Te.closest(".ldp-setting-row"),Bc&&Bc.classList.add("ldp-range-drag-active"),k.classList.add("ldp-range-dragging");try{Te.setPointerCapture(C.pointerId)}catch{}}}),O(k,"change",C=>{C.target.matches('input[type="color"]')&&Sl(),C.target.matches('input[type="range"]')&&xq(C.target)}),O(k,"focusout",C=>{C.target.matches('input[type="color"]')&&Sl()}),O(S,"click",dR);const X=C=>{Uo({imageProfile:C})};O(ot,"input",()=>{const C=Ld(ot.value,ub());X({...t_(),preset:"custom",custom:C}),Um(),Ev()}),O(Tt,"click",()=>{X({...Il}),Um(),Ev()}),is.forEach(C=>{O(C,"click",()=>{const oe=C.dataset.fontScopeTab;!ta.includes(oe)||oe===Bm||(Mo(),Bm=oe,En())})}),Kt.forEach(C=>{O(C,"click",oe=>{bt(oe),ps();const Te=C.dataset.fontScope;!Dt.hidden&&ds===Te?Mo(!0):x$(Te)}),O(C,"keydown",oe=>{oe.key==="ArrowDown"&&(oe.preventDefault(),x$(C.dataset.fontScope))})}),O(Ut,"input",I$),O(br,"click",C=>{const oe=C.target.closest(".ldp-font-family-option");if(!oe)return;const Te=Cn(),et={...bl()};oe.dataset.fontKind==="preset"?(et[Te.family]=Nn(oe.dataset.fontValue,et[Te.family]),et[Te.customFamily]=""):(et[Te.family]="custom",et[Te.customFamily]=ei(oe.dataset.fontValue,et[Te.customFamily])),mi.delete(ds),ds==="host"?Uo({hostFontFamily:et.hostFontFamily,hostFontCustomFamily:et.hostFontCustomFamily}):hl(et),Mo(!0),En(),ds!=="host"&&js()}),O(_a,"click",()=>{const C=ds,oe=Cn(C),Te={...bl(C),[oe.family]:"custom"};mi.add(C),C==="host"?pr(oe.family,"custom"):hl(Te),Mo(),En(),C!=="host"&&js();const et=[...at].find(gt=>gt.dataset.fontScope===C);et.focus(),et.select()}),O(Dt,"keydown",C=>{kS(C,Ut,br,".ldp-font-family-option:not([hidden])",Mo)}),O(Dt,"click",C=>C.stopPropagation()),O(Dt,"pointerdown",C=>C.stopPropagation()),un(Dt),at.forEach(C=>O(C,"input",()=>{const oe=C.dataset.fontScope,Te=Cn(oe),et={...bl(oe)};et[Te.family]="custom",et[Te.customFamily]=ei(C.value,et[Te.customFamily]),oe==="host"?Uo({hostFontFamily:et.hostFontFamily,hostFontCustomFamily:et.hostFontCustomFamily}):hl(et),En(),oe!=="host"&&js()})),[[Ca,"change","weight",Kl],[Hs,"input","color",kn]].forEach(([C,oe,Te,et])=>C.forEach(gt=>{O(gt,oe,()=>{const lt=gt.dataset.fontScope,At=Cn(lt),Vt={...bl(lt)},Zr=At[Te];Vt[Zr]=et(gt.value,Vt[Zr]),lt==="host"?pr(Zr,Vt[Zr]):hl(Vt),En(),lt!=="host"&&js()})})),q$.forEach(({input:C,key:oe,normalize:Te})=>O(C,"input",()=>{const et={...fi()};et[oe]=Te(C.value,et[oe]),hl(et),En(),js()})),_n.forEach(C=>O(C,"input",()=>{const oe=ws[C.dataset.hostEmbedSize];oe&&(pr(oe.pref,pf(C.value)),En())})),Ea.forEach(C=>O(C,"click",()=>{const oe=ws[C.dataset.hostEmbedSizeReset];oe&&(pr(oe.pref,oe.defaultScale),En())})),Ma.forEach(C=>O(C,"click",()=>{const oe=C.dataset.hostFontReset!=null,Te=C.dataset.fontFieldReset,et=oe?"host":ta.find(Zr=>Object.values(Cn(Zr)).includes(Te));if(!et)return;const gt=Cn(et),lt=oe?gt[C.dataset.hostFontReset]:Te,At=oe?Fo:vv();if(!Object.hasOwn(At,lt))return;const Vt={[lt]:At[lt]};lt===gt.family&&(Vt[gt.customFamily]=At[gt.customFamily],mi.delete(et)),oe?Uo(Vt):hl({...fi(),...Vt}),En(),oe||js()})),O(Qo,"click",()=>{Mo(),mi.clear(),Uo(Fo),hl({...vv()}),En(),js()}),N.forEach(C=>{O(C,"input",()=>{const oe=C.dataset.layoutRegion;if(!Ro.includes(oe))return;const Te={...xc()},et=Number(C.value),gt=100-Ro.reduce((lt,At)=>lt+(At===oe?0:oa[At]),0);Te[oe]=fn(Math.min(gt,Math.max(oa[oe],Number.isFinite(et)?et:Te[oe]))),w$(c1(Te,oe)),Tu(),Pu()})}),O(z,"click",()=>{w$({...S$()}),Tu(),Pu()});const Ue=()=>{qo.setGeometry(ee.value,ve.value,Ne.value,Ve.value),Dc()};O(ee,"change",Ue),O(ve,"change",Ue),O(Ne,"change",Ue),O(Ve,"change",Ue),O(Eo,"click",()=>{qo.reset(),Dc()});const yt=(C,oe)=>{_$({...Aa(),[C]:oe})},Bt=C=>{_$({...Aa(),...C})};Hy.forEach(C=>O(C,"change",()=>{const oe=C.dataset.appearanceGroupToggle;sw.includes(oe)&&(yt(oe,C.checked),Au(),yl())})),D$.forEach(({input:C,key:oe,normalize:Te,apply:et})=>{O(C,"input",()=>{if(C.type==="color"){const gt=kn(C.value,Sv()[oe]);Bt({[oe]:gt,[xd(oe)]:gt})}else yt(oe,Te(C.value));et(),yl()})}),IM.forEach(C=>O(C,"click",()=>{const oe=C.dataset.colorReset,Te=Sv(),et=i_(oe);et.some(gt=>Te[gt]==null)||(Bt(Object.fromEntries(et.map(gt=>[gt,Te[gt]]))),Au(),yl())})),x.cancelShellFrames=()=>{ui&&cancelAnimationFrame(ui),ui=0,wu=null,Pa&&cancelAnimationFrame(Pa),Pa=0,Un=null,Object.values(L).forEach(C=>C())}}const _l=Ho(),fr=(i,p,h,y)=>_l.listen(i,p,h,y);if(_l.add(()=>{He&&cancelAnimationFrame(He),He=0}),fr(Y,"scroll",Rt,{passive:!0}),typeof ResizeObserver=="function"){const i=new ResizeObserver(Rt);i.observe(mr),i.observe(Y),i.observe(Z(".ldp-topic-tags")),_l.add(()=>i.disconnect())}Rt(),fr(mr,"wheel",i=>{const p=Math.abs(i.deltaX)>Math.abs(i.deltaY)?i.deltaX:i.deltaY;if(!p)return;i.preventDefault(),i.stopPropagation();const h=Y.scrollWidth-Y.clientWidth;if(h<=1)return;const y=i.deltaMode===1?p*40:i.deltaMode===2?p*Y.clientWidth:p,M=Math.min(h,Math.max(0,Y.scrollLeft+y));Y.scrollLeft=M},{passive:!1});let E=null;const ih=Ok(e,I,i=>NE(i,E),i=>NE(i,E,{optimistic:!0}));E={topicId:e,op:null,topicData:null,loader:Ur,modal:S,commentsEl:Je,commentsHeader:st,countEl:pt,emptyEl:Pt,scrollRoot:I,performance:n,readerSession:R,isPostVoting:!!H?.is_post_voting,directRepliesByParent:new Map,replyParentByPost:new Map,tracker:ih,totalPosts:0,repliesIO:null,postRequestScheduler:$a,subReplyState:new Map,confirmedReadPostNumbers:new Set,optimisticReadPostNumbers:new Set,streamNodeMap:new Map,streamItems:[],streamItemMap:new Map,streamPrefix:[0],onlyOp:!1,streamLayoutItems:[],streamLayoutDirty:!0,streamPrefixDirty:!0,streamPrefixDirtyFrom:0,streamNeedsRepair:!0,streamPendingReindexFrom:null,streamWindowStart:0,streamWindowEnd:0,streamMountedItemCount:0,streamViewportTopIndex:0,streamViewportBottomIndex:0,viewportPersistenceEnabled:!1,streamScrollDirection:0,streamScrollVelocity:0,streamLastScrollAt:0,streamProgrammaticTarget:null,streamViewportAnchor:null,streamViewportAnchorRestoreDepth:0,streamViewportAnchorRestorePending:!1,streamWindowResetPending:!1,streamInternalScrollTop:NaN,streamInternalScrollDelta:0,readerNavigationSequence:0,streamListOffset:NaN,streamMountedNodes:new Set,streamHydratedNodes:new Set,pendingPostHydrations:new Set,pendingPostLatexRenders:new Set,streamTopSpacer:xC("top"),streamBottomSpacer:xC("bottom"),streamResizeObserver:null,queueViewportTracker:null,streamWindowFrame:0,floorPreviewSyncFrame:0,latestReplyAt:"",latestReplyTimestamp:0,topicBookmarked:!!H?.bookmarked,topicNotificationLevel:Xi(H),topicSharedIssueBusy:!1,topicSharedIssueHostVisible:!1,initialTargetPositionPending:a>1,initialTargetHydrationBoundary:a>1?a:0,initialTargetPrecedingContentDeferred:a>1,initialTargetHeadSpacerHeight:0,cancelInitialTargetPosition:null,releaseInitialPrecedingContent:null,readerActionDialog:null,nativeComposerSession:null,toggleTopicBookmark:null,readerWindow:qo,readerWorkspace:qe},E.queueViewportTracker=G?vP(E,G):{observe(){},unobserve(){},sync(){},stop(){}},x.activeContext=E,tm();const Wt=E.topicActionRail=Z(".ldp-topic-action-rail");Wt.hidden=!0,Wt.classList.remove("is-dragging"),Wt._ldpRelatedPost=null,se.classList.remove("ldp-topic-action-rail-visible");let Yv=0,Qs=null,uR=0;const zc=()=>{if(!Wt||Wt.hidden)return;const i=Oe.topicActionRailPosition||{},p=Wt.offsetWidth,h=Math.max(0,S.clientWidth-p),y=Math.max(0,S.clientHeight-Wt.offsetHeight),M=Z(".ldp-reader-main"),T=M?.getBoundingClientRect(),L=Number.parseFloat(getComputedStyle(M).gridTemplateColumns)||p,j=Wt.offsetParent||S,re=j.getBoundingClientRect().left+(j.clientLeft||0),J=T?T.left+Math.max(L,p)/2-re-p/2:(Math.max(L,p)-p)/2,be=Number.isFinite(i.x),X=be?h*Math.max(0,Math.min(1,i.x)):Math.max(0,Math.min(h,J)),Ue=Wt.querySelector(".ldp-topic-action-rail-toggle"),yt=Math.max(0,S.clientHeight-(Ue?.offsetHeight||0));Wt.style.left=`${Math.round(X)}px`,Wt.style.top=`${Math.round(Math.max(0,Math.min(y,yt*Math.max(0,Math.min(1,Number(i.y)||0))-(Ue?.offsetTop||0))))}px`;const Bt=[se.querySelector(".ldp-reader-queue:not([hidden])"),...se.querySelectorAll(".ldp-reader-history-nav:not([hidden])")],C=S.getBoundingClientRect();Bt.filter(et=>et&&(!Wt.classList.contains("is-expanded")||!et.matches(".ldp-reader-history-nav"))).forEach(et=>{const gt=Wt.getBoundingClientRect(),lt=et.getBoundingClientRect();gt.leftlt.left&>.toplt.top&&(Wt.style.left=`${Math.round(lt.left>C.left+C.width/2?Math.max(0,lt.left-re-p-8):Math.min(h,lt.right-re+8))}px`)});const oe=Number.parseFloat(Wt.style.left)||0,Te=S.clientWidth*gw;Wt.classList.toggle("is-docked-left",be&&oe<=Te),Wt.classList.toggle("is-docked-right",be&&h-oe<=Te),x.syncFullPageHeaderAlignment?.(),se._ldpSyncReaderQueuePosition?.()};E.syncTopicActionRail=(i=Wt?._ldpRelatedPost)=>{if(!i){const M=Lr(E.topicData).concat(Ur.cachedPosts).find(T=>+T?.post_number==1);if(M){vc(M,!1,E,{deferContent:!0});return}Oe.topicActionRailVisible&&!Wt._ldpStarterRequest&&(Wt._ldpStarterRequest=Ur.loadPost(1,{advanceCursor:!1,priority:tt.auxiliary}).then(T=>{const L=T.find(j=>+j?.post_number==1);L&&qt()&&vc(L,!1,E,{deferContent:!0})}).catch(()=>{}).finally(()=>{Wt._ldpStarterRequest=null}));return}const p=i?._ldpPostData;if(!Wt||!p)return;const h=jd(p,E),y=Wt.classList.contains("is-expanded");Wt._ldpRelatedPost=i,Wt._ldpPostData=p,Wt.hidden=Oe.topicActionRailVisible!==!0,se.classList.toggle("ldp-topic-action-rail-visible",!Wt.hidden),Wt.innerHTML=`${si(p)?``:""}
${ry(p,E)}${MC(p,E)}
`,As(Wt,E,{collectLike:!1,railPicker:!0}),Wt.querySelector(":scope > .ldp-reactions > .ldp-reaction-summary")?.remove(),ts(Wt,E),gn(E),requestAnimationFrame(()=>{zc(),requestAnimationFrame(zc)})},fr(Wt,"click",i=>{if(Date.now(){i.button||Oe.topicActionRailFixed||!i.target.closest(".ldp-topic-action-rail-toggle")||(clearTimeout(Yv),Yv=_l.timer(()=>{Qs={id:i.pointerId,x:i.clientX,y:i.clientY,left:Number.parseFloat(Wt.style.left)||0,top:Number.parseFloat(Wt.style.top)||0},Wt.classList.add("is-dragging")},420))}),fr(document,"pointermove",i=>{if(!Qs||i.pointerId!==Qs.id)return;const p=Math.max(0,S.clientWidth-Wt.offsetWidth),h=Math.max(0,S.clientHeight-Wt.offsetHeight);Wt.style.left=`${Math.round(Math.max(0,Math.min(p,Qs.left+i.clientX-Qs.x)))}px`,Wt.style.top=`${Math.round(Math.max(0,Math.min(h,Qs.top+i.clientY-Qs.y)))}px`,i.preventDefault()},!0);const pR=i=>{if(clearTimeout(Yv),!Qs||i.pointerId!==Qs.id)return;const p=Math.max(1,S.clientWidth-Wt.offsetWidth),h=Wt.querySelector(".ldp-topic-action-rail-toggle"),y=Math.max(1,S.clientHeight-(h?.offsetHeight||0));pr("topicActionRailPosition",{x:(Number.parseFloat(Wt.style.left)||0)/p,y:((Number.parseFloat(Wt.style.top)||0)+(h?.offsetTop||0))/y}),Qs=null,Wt.classList.remove("is-dragging"),uR=Date.now()+300,zc()};se._ldpSyncTopicActionRailPosition=zc,fr(document,"pointerup",pR,!0),fr(document,"pointercancel",pR,!0),fr(window,"resize",zc),E.translation=GN(E,Gt),D(x.refreshBookmarksPanel)||(x.refreshBookmarksPanel=(i=null)=>{kc();const p=De.reactionLoadedAt>0,h=i&&typeof i.likeActed=="boolean",y=i&&!h&&qx(i.post,i.topic);if(h||y){y&&(De.reactionEntries=pv(De.reactionEntries)),p&&(h&&(De.reactionEntries=Dx(De.reactionEntries,i.post,i.topic,i.likeActed)),De.reactionLoadedAt=Date.now(),Xw(De.reactionUsername,De.reactionEntries)),!Re.popover.hidden&&De.type==="Reaction"&&(p?(De.page=0,Ko()):fl());return}Re.popover.hidden||fl()}),E.refreshBookmarksPanel=i=>x.refreshBookmarksPanel(i),E.nativeComposerWindow=zL(R),E.renderTopicShell=Nc,E.repliesIO=lI(E),E.streamResizeObserver=hk(E);const Oq=E.timeline=yI(Tr,E),Fq=()=>{const i=It("service:site"),p=[...ut(i,"categories")||[]].filter(h=>h&&+ut(h,"id")>0&&ut(h,"name")).map(h=>({id:+ut(h,"id"),name:String(ut(h,"name")||""),slug:String(ut(h,"slug")||""),color:String(ut(h,"color")||""),parent_category_id:+ut(h,"parent_category_id")||null}));return p.length?Promise.resolve({categories:p}):Promise.reject(new Error("原站分类数据尚未就绪,请稍后重试"))},Bq=()=>{const i=E.topicData;if(!i||!i.details||i.details.can_edit!==!0)return;Do&&Do.close(!0);const p=Z(".ldp-topic-edit-trigger"),h=Z(".ldp-topic-edit-layer"),y=Z(".ldp-topic-edit-form"),M=y.querySelector('input[name="title"]'),T=y.querySelector(".ldp-topic-edit-category-trigger"),L=y.querySelector(".ldp-topic-edit-category-value"),j=y.querySelector(".ldp-topic-edit-category-menu"),q=y.querySelector(".ldp-topic-edit-category-search input"),re=y.querySelector(".ldp-topic-edit-category-options"),J=y.querySelector(".ldp-topic-edit-label-input"),be=y.querySelector(".ldp-topic-edit-label-values"),X=y.querySelector(".ldp-topic-edit-label-options"),Ue=y.querySelector(".ldp-topic-edit-save"),yt=y.querySelector(".ldp-topic-edit-cancel"),Bt=h.querySelector(".ldp-topic-edit-close"),C=y.querySelector(".ldp-topic-edit-status"),oe=String(i.title||"").trim(),Te=Number(i.category_id||i.categoryId)||0,et=[...new Set((Array.isArray(i.tags)?i.tags:[]).map(Br).map(dt=>dt.trim()).filter(Boolean))],gt=new Map,lt=new Set,At=new Map;let Vt=Te,Zr=!1,La=!1,Zs=!1,rd=0,od=null,Mh={query:"",sequence:0};const sn=Ho(),$h=dt=>String(dt||"").trim().toLocaleLowerCase();et.forEach(dt=>{const or=$h(dt);gt.set(or,{id:null,name:dt}),lt.add(or)});const m0=()=>[...lt].map(dt=>gt.get(dt)).filter(Boolean),Rh=()=>{be.innerHTML=m0().map(dt=>`
+
`}).join(""):'
等待浏览器性能、网络资源、页面元素或前后台切换事件。
'},YD=(i,p,h)=>{const y=i.filter(J=>J.at>=h-12e3);if(y.length<10)return Ys("normal","建立基线",`最近 12 秒取得 ${y.length} 个真实快照;满 10 个后开始判断页面元素增长、主线程卡顿和请求排队。`);const $=i.filter(J=>J.at>=h-6e4)[0]||p,k=p.dom-$.dom,j=p.retainedFloors-$.retainedFloors,B=[];let Z="normal";return p.longTaskDuration>=2e3?(Z="danger",B.push(`最近 10 秒长任务累计 ${Math.round(p.longTaskDuration)}ms`)):p.longTaskDuration>=500&&(Z="warning",B.push(`最近 10 秒长任务累计 ${Math.round(p.longTaskDuration)}ms`)),p.queuedRequests>=10?(Z="danger",B.push(`当前有 ${p.queuedRequests} 条阅读器请求排队`)):p.queuedRequests>=3&&(Z==="normal"&&(Z="warning"),B.push(`当前有 ${p.queuedRequests} 条阅读器请求排队`)),k>=1e3&&j>=50&&(Z==="normal"&&(Z="warning"),B.push(`近一分钟页面元素增加 ${k} 个,保留楼层增加 ${j} 个`)),B.length?Ys(Z,Z==="danger"?"资源压力高":"需要关注",`${B.join(";")}。继续观察趋势,回落后状态会自动恢复。`):Ys("normal","采样正常","最近一分钟未发现阅读器页面元素快速膨胀、页面共享主线程卡顿或阅读器请求积压;内存估计不参与自动判定。")},Gv=()=>{if(!hx||!VM.length)return;const i=Xs(),p=I.activeContext;UD(i),zv(i);const h=xD(i),y=at.events.filter(Je=>Je.kind==="longtask"&&Je.at>=i-1e4),C=y.reduce((Je,Tt)=>Je+Tt.duration,0);let $=null;try{$=Pu().snapshot()}catch{}const k=h.filter(Je=>Je.scope==="reader"&&Je.at>=i-6e4),j=k.reduce((Je,Tt)=>Je+Tt.bytes,0),B={at:i,visibility:document.visibilityState==="visible"?"visible":"hidden",longTaskCount:at.longTasksAvailable?y.length:null,longTaskDuration:at.longTasksAvailable?C:null,dom:ne.getElementsByTagName("*").length+1,hostDom:FD(),mountedFloors:p?.streamMountedNodes?.size||0,retainedFloors:p?.streamNodeMap?.size||0,nested:p?.subReplyState?.size||0,media:x.querySelectorAll("img,video,audio,iframe").length,activeRequests:$?.active||0,queuedRequests:$?.queued||0,readerRequestCount:k.length,readerTransfer:j},Z=at.samples[at.samples.length-1];if(!Z||i-Z.at>=800){if(Z&&i-Z.at>1800){const Je=Z.visibility==="hidden"||at.visibilityTimeline.some(Pt=>Pt.at>Z.at&&Pt.at<=i&&Pt.state==="hidden"),Tt=at.visibilityTimeline.some(Pt=>Pt.at>Z.at&&Pt.at<=i&&Pt.state==="stopped");Wv({at:i,visibility:Tt?"unknown":Je?"hidden":lh(i),scope:"shared",kind:"gap",duration:i-Z.at,detail:`快照空档 ${ms(i-Z.at)}${Tt?"(包含取证暂停)":Je?"(包含后台节流或冻结)":""};未补造中间样本`,basis:"High Resolution Time"})}at.samples.push(B)}if(zv(i),document.visibilityState!=="visible")return;const J=qD(h,DD(i)),ye=at.samples,Q=c1.reduce((Je,Tt)=>(Je[Tt]=jD(Tt==="heapUsed"?at.memorySamples:ye,Tt),Je),{}),Ve={heap:n$(),longTasks:at.longTasksAvailable?`${y.length} 次 · ${ms(C)}`:"浏览器未提供",dom:`${B.dom} 个`,floors:`${B.mountedFloors} / ${B.retainedFloors} 个`,nested:`${B.nested} 组`,media:`${B.media} 个`,requests:$?`${$.active} / ${$.queued} 条`:"—",network:`${k.length} 次 · ${Hn(j)}`,heapAverage:Q.heapUsed.count?Hn(Q.heapUsed.average):"—",longTasksAverage:Q.longTaskDuration.count?`${Q.longTaskCount.average.toFixed(1)} / ${Math.round(Q.longTaskDuration.average)}ms`:"—",domAverage:`${Math.round(Q.dom.average)} 个`,floorsAverage:`${Math.round(Q.mountedFloors.average)} / ${Math.round(Q.retainedFloors.average)}`,nestedAverage:`${Math.round(Q.nested.average)} 组`,mediaAverage:`${Math.round(Q.media.average)} 个`,requestsAverage:`${Q.activeRequests.average.toFixed(1)} / ${Q.queuedRequests.average.toFixed(1)}`,networkAverage:`${Math.round(Q.readerRequestCount.average)} / ${Hn(Q.readerTransfer.average)}`,heapPeak:Q.heapUsed.count?Hn(Q.heapUsed.peak):"—",longTasksPeak:Q.longTaskDuration.count?`${Math.round(Q.longTaskCount.peak)} / ${Math.round(Q.longTaskDuration.peak)}ms`:"—",domPeak:`${Math.round(Q.dom.peak)} 个`,floorsPeak:`${Math.round(Q.mountedFloors.peak)} / ${Math.round(Q.retainedFloors.peak)}`,nestedPeak:`${Math.round(Q.nested.peak)} 组`,mediaPeak:`${Math.round(Q.media.peak)} 个`,requestsPeak:`${Math.round(Q.activeRequests.peak)} / ${Math.round(Q.queuedRequests.peak)}`,networkPeak:`${Math.round(Q.readerRequestCount.peak)} / ${Hn(Q.readerTransfer.peak)}`,heapTrend:bi(Q.heapUsed,o$),longTasksTrend:bi(Q.longTaskDuration,Je=>`${Gc(Je)}ms`),domTrend:bi(Q.dom,Gc),floorsTrend:bi(Q.retainedFloors,Gc),nestedTrend:bi(Q.nested,Gc),mediaTrend:bi(Q.media,Gc),requestsTrend:bi(Q.queuedRequests,Gc),networkTrend:bi(Q.readerTransfer,o$)};sx.forEach(([Je,Tt,Pt])=>Je.forEach(Er=>{Er.textContent=Ve[`${Er.dataset[Tt]}${Pt}`]||"—"}));const gt={heapUsed:Ve.heap,dom:Ve.dom,retainedFloors:`${B.retainedFloors} 个`};ax.forEach(Je=>{const Tt=Je.dataset.resourceMonitorChart,Pt=Tt==="heapUsed"?at.memorySamples:ye;Je.querySelector("polyline").setAttribute("points",zD(Pt,Tt)),Je.querySelector("[data-resource-monitor-chart-current]").textContent=gt[Tt]||"—"});const Ht=ye.length>1?ye[ye.length-1].at-ye[0].at:0,je=ye.slice(1).reduce((Je,Tt,Pt)=>Math.max(Je,Tt.at-ye[Pt].at),0),A=ye.filter(Je=>Je.visibility==="visible").length,se=ye.filter(Je=>Je.visibility==="hidden").length,De=Ht>=6e4?`${(Ht/6e4).toFixed(1)} 分钟`:`${Math.round(Ht/1e3)} 秒`;px.textContent=`${De} · 前 ${A} / 后 ${se}${je>1800?` · 最大空档 ${Un(je)}`:""}`,VD(B,i,h,J),QD(i,J);const Ke=YD(ye,B,i);cx.dataset.level=Ke.level,dx.textContent=Ke.state,ux.textContent=Ke.detail,fx.textContent=`最近快照 ${ZR(i)} · ${B.visibility==="visible"?"前台":"后台"} · 仅内存保留`},Qv=()=>{if(at.timer&&clearInterval(at.timer),at.timer=0,jv(),at.collecting){const i=Xs();Ml(i,{scope:"shared",kind:"capture",detail:"资源取证暂停",basis:"监控面板生命周期"}),at.visibilityTimeline.push({at:i,state:"stopped"})}at.collecting=!1},a$=(i,p,h,y)=>{if(!i)return!1;try{return at[p]=new window.PerformanceObserver(y),at[p].observe({type:h}),!0}catch{return at[p]=null,!1}},XD=()=>{if(at.collecting){Gv();return}if(at.timer&&clearInterval(at.timer),at.timer=0,at.collecting=!0,at.longTasksAvailable=!1,e$("start"),at.longTasksAvailable=a$(ID,"longTaskObserver","longtask",(i,...p)=>{r$("Long Tasks",p[1]),i.getEntries().forEach(h=>{const y=window.performance.timeOrigin+Number(h.startTime||0),C=Math.max(0,Number(h.duration)||0);Ml(y,{scope:"shared",kind:"longtask",duration:C,detail:`页面共享主线程长任务 ${ms(C)}`,basis:"PerformanceLongTaskTiming · 1ms"})})}),a$(ih,"longFrameObserver","long-animation-frame",(i,...p)=>{r$("Long Animation Frames",p[1]),i.getEntries().forEach(h=>{const y=window.performance.timeOrigin+Number(h.startTime||0),C=Math.max(0,Number(h.duration)||0),$=Math.max(0,Number(h.blockingDuration)||0);Ml(y,{scope:"shared",kind:"frame",duration:C,detail:`长动画帧 ${ms(C)} · 阻塞 ${ms($)}`,basis:"Long Animation Frames"});const k=new Map;[...h.scripts||[]].forEach(j=>{const B=Math.max(0,Number(j.duration)||0);if(!B)return;const Z=BD(j),J=k.get(Z)||{duration:0,forced:0,count:0,labels:[]};J.duration+=B,J.forced+=Math.max(0,Number(j.forcedStyleAndLayoutDuration)||0),J.count++,J.labels.length<2&&J.labels.push(HD(j)),k.set(Z,J)}),k.forEach((j,B)=>{Ml(y,{scope:B,kind:"script",duration:j.duration,detail:`长帧内已归因脚本 ${ms(j.duration)}${j.forced?` · 强制布局 ${ms(j.forced)}`:""} · ${j.labels.join(" / ")}`,basis:`PerformanceScriptTiming · ${j.count} 段`})})})}),D(window.MutationObserver))try{at.readerMutationObserver=new window.MutationObserver(i=>{t$("reader",i)}),at.readerMutationObserver.observe(Ho,{childList:!0,subtree:!0}),at.hostMutationObserver=new window.MutationObserver(i=>{t$("host",i)}),at.hostMutationObserver.observe(It,{childList:!0,subtree:!0})}catch{jv(["readerMutationObserver","hostMutationObserver"])}Gv(),at.timer=setInterval(Gv,1e3)},Yv=()=>{const i=Iu;Oa.forEach(h=>{const y=Nm[h.key];y.input.value=String(i[h.key]),y.output.textContent=h.format(i[h.key]),h.type!=="color"&&Sl(y.input)});const p=uh("flash")>0;KM.textContent=p?"跳转楼层提示正在实时预览,等待统一保存。":"跳转楼层提示当前配置已应用。",KM.classList.toggle("balanced",!p),Ov(i),Kc()};let Qc="";const Xv=(i=!1)=>{const p=Od(qe.loadingAnimation);if(p==="random"){if(i||!Qc){const C=Bs.filter(k=>k.key!==Qc);Qc=(C[Math.floor(Math.random()*C.length)]||Bs[0]).key}}else Qc="";const h=NM(p,Qc);km.value=p,JM.replaceChildren(),JM.insertAdjacentHTML("afterbegin",kM(h));const y=Bs.findIndex(C=>C.key===h.key)+1;Mx.textContent=`${String(y).padStart(2,"0")} / ${Bs.length} · ${h.label}${p==="random"?" · 随机预览":" · 固定使用"}`,ZM.hidden=p!=="random"},i$=()=>({boostCopyMode:xm.value,boostCopyPrefix:Dm.value,boostCopyCounterMarker:qm.value,boostCopyCounterStep:Om.value,boostCopyFixedSuffix:Fm.value}),l$=(i=i$())=>{const p=iy("原 Boost",i),h=gf(i);nR.textContent=h.mode==="counter"?`${p} → ${iy(p,i)}`:p},Kv=()=>{const i=dp();iv.replaceChildren(...i.map(p=>{const h=Ne("span");return h.className="ldp-custom-site-item",h.innerHTML=`
${$e(p)}`,h})),iv.hidden=!i.length},eo=()=>{Kv();const i=qe.historyButtonsAlwaysVisible===!0,p=nf(qe.historyEdgeTriggerPercent);ev.checked=i,eR.checked=qe.readerQueueAlwaysVisibleWhenEmpty,ne._ldpSyncReaderQueue?.(),Ic.value=String(p),Ic.disabled=i,tR.value=`${p}%`,tR.textContent=`${p}%`,Sl(Ic),ne._ldpSyncHistoryButtonVisibilityPreference?.(),Lm.value=qe.historySortMode,tv.checked=qe.openTopicsAtFirstPost===!0,rv.checked=qe.doubleEscapeToCloseReader,ov.checked=qe.confirmNativeComposerClose!==!1,rR.checked=qe.topicActionRailVisible,oR.checked=qe.topicActionRailFixed,I.activeContext?.syncTopicActionRail?.(),xc.checked=qe.expandNestedRepliesByDefault===!0,xc.disabled=!1,Eu.checked=qe.expandLeafNestedReplies===!0,Im.checked=qe.aggregateDescendantReplies===!0,Im.disabled=!1;{const C=qe.expandNestedRepliesByDefault===!0,$=oc(qe.inlineReplyTreeMaxDepth),k=C&&$>=2;if(ds){ds.checked=k,ds.disabled=!C;const j=ds.closest(".ldp-inline-reply-tree-enable-row");j&&j.classList.toggle("is-disabled",!C)}if(Ws){const j=k?Math.max(2,$):Math.max(2,$>=2?$:ub);Ws.value=String(oc(j)),Ws.disabled=!k}sR&&(sR.hidden=!k)}nv.checked=qe.hideNestedReplyFloors===!0,$x.hidden=!(qe.expandNestedRepliesByDefault&&qe.expandLeafNestedReplies);const h=gf(qe);xm.value=h.mode,Dm.value=h.prefix,qm.value=h.counterMarker,Om.value=String(h.counterStep),Fm.value=h.fixedSuffix;const y=h.mode==="counter";Nx.forEach(C=>{C.hidden=!y}),kx.forEach(C=>{C.hidden=y}),l$(qe)};let c$=0;const d$=async()=>{const i=++c$;Ug(),await nS();const p=mP(),h=await Promise.all([...Lx].map(async y=>({el:y,stat:await OP(y.dataset.cacheSize,p)})));i===c$&&(h.forEach(({el:y,stat:C})=>{const $=y.dataset.cacheSize;y.textContent=FP($,C)}),Ix.forEach(y=>{y.textContent=PS(y.dataset.cacheRetention)}),Mu.disabled=![...Dc].some(y=>y.checked))};Be.id="ldp-setting-help-tooltip";let jn=null;const hs=()=>{jn&&jn.getAttribute("aria-describedby")===Be.id&&jn.removeAttribute("aria-describedby"),jn=null,Be.classList.remove("is-visible"),Be.hidden=!0},u$=i=>{const p=i&&i.dataset.settingHelp;if(!p)return;jn&&jn!==i&&hs(),jn=i,i.setAttribute("aria-describedby",Be.id),Be.textContent=p,Be.hidden=!1,Be.classList.remove("is-visible");const h=i.getBoundingClientRect(),y=Be.getBoundingClientRect(),C=12,$=8,k=ur(),j=k.left+C,B=k.top+C,Z=Math.max(j,k.right-y.width-C),J=Math.min(Z,Math.max(j,h.left+(h.width-y.width)/2));let ye=h.top-y.height-$;ye
{ch&&clearInterval(ch),ch=0},p$=()=>{if(!er)return;if(!Hu){er.textContent="最后刷新 · 暂无",delete er.dataset.ldpTooltipLabel,pa(er);return}const i=Math.max(0,Date.now()-Hu),p=Math.floor(i/1e3),h=p<5?"刚刚":p<60?`${p} 秒前`:p<3600?`${Math.floor(p/60)} 分钟前`:p<86400?`${Math.floor(p/3600)} 小时前`:`${Math.floor(p/86400)} 天前`,y=new Date(Hu).toLocaleString("zh-CN",{timeZone:"Asia/Shanghai",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}).replaceAll("/","-").replace(",","");er.textContent=`最后刷新 · ${h}`,er.dataset.ldpTooltipLabel=`北京时间 ${y}`,pa(er)},f$=i=>{Hu=Number(i||0),p$(),Zv(),Hu&&Cr?.closest(".ldp-settings-section")?.hidden===!1&&(ch=setInterval(p$,1e3))},e0=i=>{tr&&(tr.disabled=i,tr.classList.toggle("is-refreshing",i),nt(tr,i?"正在刷新用户信息":"刷新用户信息"))},m$=async(i=!1)=>{if(!Cr)return;const p=++Jv;if(i&&e0(!0),Ls(),p!==Jv)return;const h=String(Yt||To?.username||"").trim();if(!h){Cr.innerHTML=`当前未登录 ${$e(Vn)},无法读取用户资料。
`,e0(!1);return}const y=tf(h),C=y?._ldp_connect_trust||null;f$(Math.max(Number(y&&(y._ldp_user_info_checked_at||y._ldp_summary_checked_at)||0),Number(C?.cachedAt||0))),y?aM(Cr,Object.assign({},y,To||{},{username:h}),C?.data,null,null,null):Cr.innerHTML='正在加载用户信息…
';const $=Qr("connect"),k=qa.id==="linux-do",[j,B,Z]=await Promise.allSettled([TL(h,i),$?qL(i,C):Promise.resolve(null),k?OL(h,i):Promise.resolve(null)]);if(p!==Jv)return;const J=j.status==="fulfilled"?j.value:y,ye=Object.assign({},y||{},To||{},J||{},{username:h}),Q=B.status==="fulfilled"?B.value:C?.data||null,Ve=B.status==="rejected"?B.reason:null,gt=Z.status==="fulfilled"?Z.value:null,Ht=Z.status==="rejected"?Z.reason:null;$&&B.status==="fulfilled"&&(ye._ldp_connect_trust={data:Q,cachedAt:Number(Ti?.cachedAt||Date.now())}),(j.status==="fulfilled"||B.status==="fulfilled")&&(Oi(h,ye,0),f$(Math.max(Number(ye._ldp_user_info_checked_at||ye._ldp_summary_checked_at||0),Number(ye._ldp_connect_trust?.cachedAt||0),Date.now()))),aM(Cr,ye,Q,Ve,gt,Ht),e0(!1)},dh={font:{label:"字体设置",fields:ig,draft:hi,saved:Gi,reset:()=>{Tv=Gi()},patch:()=>({fontProfile:{...hi()}}),apply:Gs,sync:Mn,beforeSave:Ro},layout:{label:"布局设置",fields:$o,draft:zc,saved:Nv,reset:()=>{Gm=Nv()},patch:()=>({[hD()]:{...zc()}}),apply:Du,sync:Ou,changed:(i,p,h)=>Math.abs(i[h]-p[h])>=.01,valid:()=>rc(zc())===100},appearance:{label:"外观设置",fields:bw,draft:La,saved:ni,reset:()=>{Av=ni()},patch:()=>({appearanceProfile:{...La()}}),apply:qu,sync:Cl},flash:{label:"动画与提示",fields:Oa.map(i=>i.key),draft:()=>Iu,saved:()=>ei(qe),reset:()=>{Iu=ei(qe)},patch:()=>yf(Iu),apply:Ov,sync:Yv}},uh=i=>{const p=dh[i];if(!p)return 0;const h=p.draft(),y=p.saved();return p.fields.reduce((C,$)=>C+(p.changed?p.changed(h,y,$):h[$]!==y[$]),0)},KD=(i="")=>{Object.entries(dh).forEach(([p,h])=>{p!==i&&(h.reset(),h.apply())}),Kc()};let ph="user",Uu="request";const h$=i=>String(i||"").trim().toLocaleLowerCase(),t0=i=>ff.find(([p])=>p===i),fh=()=>{const i=ph==="logs"&&!L.hidden;(i&&Uu==="performance"?XD:Qv)(),(i&&Uu==="request"&&document.visibilityState==="visible"?kD:Bu)()},r0=i=>{Uu=i==="performance"?"performance":"request",ec(ls,"settingsLogTab",Uu,!0),Lt.forEach(p=>fr(p,p.dataset.settingsLogPanel!==Uu)),fh()},g$=i=>{ph!==i&&(ph=i,Fr.forEach(p=>{const h=p.dataset.settingsPanel===i;p.classList.toggle("active",h),p.setAttribute("aria-current",h?"page":"false")}),Fr.find(p=>p.dataset.settingsPanel===i)?.scrollIntoView({block:"nearest",inline:"nearest"}),i!=="user"&&Zv(),fh())},JD={image:"灯箱",font:"宿主 嵌入态",window:"置顶",appearance:"斑马线",flash:"动效",reading:"其他设置 队列 历史 导航 打开 退出",interaction:"其他设置 主贴 操作 回复 楼中楼 boost 复制",sites:"其他设置 自定义 站点 论坛 discourse",performance:"dom 窗口预算 并发",logs:"日志 请求 网络 流量 限流 性能 资源 监控 内存 cpu dom"},b$=(i=ph)=>{const p=h$(Eo?.value);let h=0,y="",C="";return fr(Fn,!p),Io.forEach($=>{const k=$.dataset.settingsPanel||"";if(k==="user")return;const j=t0(k),B=!p||h$(`${j?.slice(2).join(" ")||""} ${JD[k]||""} ${$.textContent}`).includes(p),Z=Fr.find(J=>J.dataset.settingsPanel===k);Z&&fr(Z,!!p&&!B),B&&(h++,y||=k,k===i&&(C=k))}),C||=y,Io.forEach($=>fr($,$.dataset.settingsPanel!==C)),js.forEach($=>fr($,!!p&&![...$.querySelectorAll(".ldp-settings-tab")].some(k=>!k.hidden))),/性能|资源|监控|内存|cpu|dom/.test(p)?r0("performance"):/请求|网络|流量|限流/.test(p)&&r0("request"),Pr.textContent=p?h?`找到 ${h} 个设置分区`:"没有匹配结果":"输入名称或功能即可筛选",fr(fo,!p||h>0),g$(C),C},ZD=()=>[Xm,Mn,Ou,Vc,Cl,Yv,Xv,rh,eo,d$].forEach(i=>i()),mh=i=>{Ro(),hs(),yi="";const p=t0(i)?i:"image",h=p==="user",y=me.classList.contains("is-settings-pages");me.classList.toggle("is-settings-pages",!h),fr(Xr,h),h?(Eo.value="",Io.forEach(C=>fr(C,C.dataset.settingsPanel!=="user")),Fr.forEach(C=>fr(C,!1)),js.forEach(C=>fr(C,!1)),fr(fo,!0),g$("user"),me.scrollTop=0,m$(!1)):(y||ZD(),Fr.find(C=>C.dataset.settingsPanel===p)?.hidden&&(Eo.value=""),b$(p),me.scrollTop=0),Kc(),fh()};let Yc=null,Xc=null,zn=null,Ia=0,yi="";const o0=()=>Object.entries(dh).map(([i,p])=>({panel:i,label:p.label,count:uh(i)})).filter(i=>i.count>0);function Kc(){const i=o0(),p=Object.fromEntries(i.map(y=>[y.panel,y.count])),h=i.reduce((y,C)=>y+C.count,0);fr(_,!me.classList.contains("is-settings-pages")||!h),fa(F,`${h} 项更改未保存 · ${i.map(y=>y.label).join("、")}`),Ee.disabled=!h,Fr.forEach(y=>{const C=p[y.dataset.settingsPanel]||0,$=y.querySelector(".ldp-settings-tab-draft-count"),k=t0(y.dataset.settingsPanel)?.[2]||"设置";y.classList.toggle("has-draft",C>0),nt(y,C>0?`${k},${C} 项未保存`:k),$&&(fr($,C<=0),fa($,C>99?"99+":String(C)))})}const eq=i=>{const p=i.closest(".ldp-settings-section")?.dataset.settingsPanel||"";if(!uh(p)){yi===p&&(yi="");return}yi!==p&&(yi=p,mt("修改已实时预览,可在面板底部统一保存全部更改。"))},Jc=()=>{L.classList.remove("ldp-range-dragging"),Yc&&Yc.classList.remove("ldp-range-drag-active"),Yc=null},Rl=()=>{L.classList.remove("ldp-color-picking"),Xc&&Xc.classList.remove("ldp-color-pick-active"),Xc=null},n0=(i,p,h,y)=>{const C=vw,$=ur(),k=$.left+C,j=$.top+C,B=Math.max(k,$.right-h-C),Z=Math.max(j,$.bottom-y-C),J={left:Math.round(Math.min(B,Math.max(k,i))),top:Math.round(Math.min(Z,Math.max(j,p)))};return ma(L,"left",`${J.left-$.left}px`),ma(L,"top",`${J.top-$.top}px`),ma(L,"transform","none"),J},tq=()=>{if(L.style.transform!=="none")return;const i=L.getBoundingClientRect();n0(i.left,i.top,i.width,i.height)},y$=({closeTransients:i=!0}={})=>{Hm(),i&&(Ro(),Oc(),hs()),oo(),L.hidden||tq()};I.syncSettingsSurfaceGeometry||(I.syncSettingsSurfaceGeometry=y$);const v$=()=>{if(Ia=0,!zn)return;const i=zn,p=vw,h=i.bounds,y=h.left+p,C=h.top+p,$=Math.max(y,h.right-i.width-p),k=Math.max(C,h.bottom-i.height-p);i.previewLeft=Math.min($,Math.max(y,i.startLeft+i.clientX-i.startX)),i.previewTop=Math.min(k,Math.max(C,i.startTop+i.clientY-i.startY)),L.style.transform=`translate3d(${i.previewLeft-i.startLeft}px,${i.previewTop-i.startTop}px,0)`},w$=i=>{if(!zn||i&&i.pointerId!==zn.pointerId)return;Ia&&(cancelAnimationFrame(Ia),Ia=0),v$();const p=zn;zn=null,n0(p.previewLeft??p.startLeft,p.previewTop??p.startTop,p.width,p.height),L.classList.remove("ldp-settings-window-dragging");try{p.handle.hasPointerCapture(p.pointerId)&&p.handle.releasePointerCapture(p.pointerId)}catch{}},s0=()=>{Ro(),Jc(),Rl(),w$(),Qv(),Bu(),Zv(),hs(),yi="",gi.clear(),fr(L,!0),Hm(),sr(Xt,!1),KD()},rq=i=>{const p=dh[i];return!p||!uh(i)?!0:p.valid&&!p.valid()?!1:(p.beforeSave?.(),jo(p.patch()),yi="",p.reset(),p.apply(),p.sync(),!0)},S$=()=>["layout","font","appearance","flash"].every(rq),ju=()=>{const i=o0();if(i.length){const p=i.reduce((h,y)=>h+y.count,0);return Nc(I.activeContext,{title:`${p} 项设置尚未保存`,message:"草稿已保留在各设置页。可以保存全部修改后关闭,也可以明确放弃这些草稿。",details:i.map(h=>({label:h.label,value:`${h.count} 项`})),note:"选择“继续编辑”会回到当前设置页,草稿不会丢失。",icon:"check",tone:"primary",cancelLabel:"继续编辑",secondaryLabel:"放弃并关闭",confirmLabel:"保存全部并关闭",onSecondary:s0,onConfirm:()=>{if(!S$()){mh("layout"),mt("布局比例还没有分配到 100%,请完成后再保存。");return}s0(),mt(`已保存 ${p} 项设置`)}}),!1}return s0(),!0},_$=()=>{Ro(),L.hidden||ju(),Vm(),yv(),ku()},Ks=I.surfaceController||{closeFloatingPanels:_$,closeNotifications:Vm,closeHistory:yv,closeBookmarks:ku,closeFontFamilyMenu:Ro,requestCloseSettings:ju,syncOpenSettingsControls:Iv};if(I.surfaceController=Ks,!Se){Lv(),Ov(),eo(),Fr.forEach(A=>{O(A,"click",()=>mh(A.dataset.settingsPanel||"image"))}),ls.forEach(A=>O(A,"click",()=>r0(A.dataset.settingsLogTab))),FS(Eo,Fn,()=>{b$(),me.scrollTop=0}),O(Ee,"click",()=>{const A=o0().reduce((se,De)=>se+De.count,0);if(!S$())return mh("layout"),mt("布局比例还没有分配到 100%,请完成后再保存。");mt(`已保存 ${A} 项设置`)}),O(km,"change",()=>{cr("loadingAnimation",Od(km.value)),Qc="",Xv()}),O(km,"wheel",A=>A.stopPropagation(),{passive:!0}),O(ZM,"click",()=>Xv(!0)),Cr&&O(Cr,"click",A=>{const se=A.target.closest("[data-user-info-view]");if(se){const De=["profile","credit"].includes(se.dataset.userInfoView)?se.dataset.userInfoView:"connect";Cr.dataset.userInfoView=De,ec(Cr.querySelectorAll("[data-user-info-view]"),"userInfoView",De),Cr.querySelectorAll("[data-user-info-panel]").forEach(Ke=>{Ke.hidden=Ke.dataset.userInfoPanel!==De})}}),tr&&O(tr,"click",()=>void m$(!0)),O(L,"pointerdown",A=>{const se=A.target.closest(".ldp-settings-intro,.ldp-settings-search-shell");if(!se||se.classList.contains("ldp-settings-intro")&&me.classList.contains("is-settings-pages")||A.button!==0||A.target.closest(".ldp-user-info-title-refresh,.ldp-settings-search")||Jr())return;const De=L.getBoundingClientRect();Ro(),Jc(),Rl(),hs();const Ke=n0(De.left,De.top,De.width,De.height);zn={pointerId:A.pointerId,handle:se,startX:A.clientX,startY:A.clientY,clientX:A.clientX,clientY:A.clientY,startLeft:Ke.left,startTop:Ke.top,width:De.width,height:De.height,bounds:ur()},L.classList.add("ldp-settings-window-dragging");try{se.setPointerCapture(A.pointerId)}catch{}A.preventDefault()}),O(L,"pointermove",A=>{if(!zn||A.pointerId!==zn.pointerId)return;const se=D(A.getCoalescedEvents)?A.getCoalescedEvents():[],De=se[se.length-1]||A;zn.clientX=De.clientX,zn.clientY=De.clientY,Ia||(Ia=requestAnimationFrame(v$)),A.preventDefault()});const i=A=>{w$(A),Jc()};["pointerup","pointercancel"].forEach(A=>O(L,A,i)),O(L,"lostpointercapture",i,!0),O(Tx,"click",()=>{try{_1(),mt("设置配置已导出")}catch{mt("导出失败,请重试")}}),O(Ax,"click",()=>{Bm.value="",Bm.click()}),O(Bm,"change",async()=>{const A=Bm.files?.[0];if(!A)return;let se;try{se=S1(JSON.parse(await A.text()))}catch{mt("配置文件无效或版本不匹配,请选择当前版本导出的 JSON 文件");return}Nc(I.activeContext,{title:"导入这份设置配置?",message:`将使用“${A.name}”覆盖当前阅读器设置,并刷新页面。`,note:"浏览历史和缓存不会改变;浮窗尺寸与位置会自动限制在当前屏幕范围内。",confirmLabel:"导入并刷新",onConfirm:()=>n_(se,"导入失败,浏览器未能保存设置")})}),O(Px,"click",()=>{Nc(I.activeContext,{title:"恢复全部默认设置?",message:"图片、字体、布局、浮窗、外观、动效、性能、阅读、交互与站点设置都会恢复默认,并刷新页面。",note:"浏览历史和缓存不会被删除。",confirmLabel:"恢复并刷新",onConfirm:()=>n_(ca,"恢复失败,浏览器未能保存设置")})}),Dc.forEach(A=>O(A,"change",()=>{Mu.disabled=![...Dc].some(se=>se.checked)})),O(Mu,"click",async()=>{const A=[...Dc].filter(se=>se.checked).map(se=>se.value);A.length&&(Mu.disabled=!0,await BP(A),Dc.forEach(se=>{se.checked=!1}),await d$())}),Zy.forEach(A=>{O(A,"click",()=>{const se=A.dataset.performancePreset;if(se==="custom"){const De=Am[0];De&&De.focus();return}d_(se),th=!0,rh()})}),Am.forEach(A=>{O(A,"change",()=>{T1(A.dataset.performanceKey,A.value),th=!0,rh()})}),O(zM,"click",()=>{d_("balanced"),th=!0,rh()});const p=()=>{const A=Object.fromEntries(Oa.map(se=>[se.key,Nm[se.key].input.value]));Iu=ei(Object.assign({},qe,yf(A))),Yv()};Ex.forEach(A=>O(A,"input",p)),[[ev,A=>cr("historyButtonsAlwaysVisible",A),eo],[eR,A=>cr("readerQueueAlwaysVisibleWhenEmpty",A),eo],[tv,A=>cr("openTopicsAtFirstPost",A),eo],[rv,A=>cr("doubleEscapeToCloseReader",A),eo],[ov,A=>cr("confirmNativeComposerClose",A),eo],[rR,A=>cr("topicActionRailVisible",A),eo],[oR,A=>cr("topicActionRailFixed",A),eo],[Im,A=>{jo({aggregateDescendantReplies:A,expandNestedRepliesByDefault:A?!0:qe.expandNestedRepliesByDefault}),I.activeContext?.subReplyState.forEach(se=>{se.pageIndex=0,se.renderedKey=""}),I.activeContext?.subReplyState.forEach((se,De)=>On(De,I.activeContext)),Ec(I.activeContext)},eo],[nv,A=>{cr("hideNestedReplyFloors",A),Ec(I.activeContext,!0)},eo],[rr,A=>cr("lightboxOriginalByDefault",A)],[_r,A=>cr("lightboxCommentsExpandedByDefault",A)],[gr,A=>cr("lightboxDescriptionExpanded",A)],[Bn,A=>cr("fontRenderingEnabled",A)],[Lc,A=>cr("fontRenderingOnHost",A)],[Ye,A=>Do.setPinned(A),Vc],[lt,A=>Do.setLocked(A),Vc]].forEach(([A,se,De])=>O(A,"change",()=>{se(A.checked),De&&De()})),O(Rx,"click",()=>{cr("topicActionRailPosition",{x:"left",y:.95}),eo()}),O(Ic,"input",()=>{cr("historyEdgeTriggerPercent",nf(Ic.value)),eo()}),O(Lm,"change",()=>{cr("historySortMode",Lm.value),eo(),CR(),ne._ldpRefreshReaderHistoryNavigation?.()});const h=async()=>{const A=P0(sv.value);if(!A||A0.has(A)){Ru.textContent=A?`${A} 已内置支持,无需重复添加。`:"请输入有效的 HTTPS 域名或网址。";return}av.disabled=!0,Ru.textContent="正在检测 Discourse…";try{const se=await mT(A);await L0([...dp(),A]),sv.value="",Kv(),Ru.textContent=`已添加 ${se.title||A},访问该站即可使用。`}catch(se){Ru.textContent=`${se.message||"检测失败"};仅支持 Discourse 论坛。`}finally{av.disabled=!1}};O(av,"click",h),O(sv,"keydown",A=>{A.key==="Enter"&&h()}),O(iv,"click",async A=>{const se=A.target.closest("[data-custom-site-remove]")?.dataset.customSiteRemove;se&&(await L0(dp().filter(De=>De!==se)),Kv(),Ru.textContent=`已移除 ${se}。`)});const y=()=>{const A=I.activeContext;A?.subReplyState&&(A.subReplyState.forEach(se=>{se.pageIndex=0,se.renderedKey=""}),A.subReplyState.forEach((se,De)=>On(De,A))),Ec(A)},C=A=>{const se=qe.aggregateDescendantReplies===!0;let De=xc.checked,Ke=Eu.checked;A===xc&&!De?Ke=!0:!se&&A===Eu&&!Ke&&(De=!0);const Je=o_();jo({expandNestedRepliesByDefault:De,expandLeafNestedReplies:Ke,aggregateDescendantReplies:De&&se}),Ec(I.activeContext),eo(),Je!==o_()&&y()};[xc,Eu].forEach(A=>O(A,"change",()=>C(A))),ds&&O(ds,"change",()=>{if(!qe.expandNestedRepliesByDefault){ds.checked=!1,eo();return}const A=ds.checked,se=oc(Ws?.value||ub),De=A?Math.max(2,se>=2?se:ub):1;cr("inlineReplyTreeMaxDepth",De),y(),eo()}),Ws&&O(Ws,"change",()=>{if(!qe.expandNestedRepliesByDefault||!ds?.checked){eo();return}const A=Math.max(2,oc(Ws.value));cr("inlineReplyTreeMaxDepth",A),y(),eo()});const $=()=>{jo(i$()),eo()};O(xm,"change",$),[Dm,qm,Om,Fm].forEach(A=>{O(A,"input",()=>l$()),O(A,"change",$)}),O(de.markAll,"click",async()=>{if(to[Ft.group]?.source!=="notifications"||de.markAll.disabled||de.markAll.dataset.ldpRequestBusy==="1")return;wa(de.markAll,!0);const A=de.markAll.innerHTML;de.markAll.innerHTML=`${ue("loader")}处理中`;try{await hn(`${vt}/notifications/mark-read`,"PUT"),fN(),mN(),de.list.querySelectorAll(".ldp-notification-item.unread").forEach(se=>{se.classList.remove("unread");const De=se.querySelector(".ldp-notification-read-state");De&&(De.textContent="已读")}),mt("消息已全部标为已读")}catch(se){alert(`标记已读失败:${se.message}`)}finally{wa(de.markAll,!1),de.markAll.innerHTML=A,de.markAll.disabled=Ff(Ft.group)<=0}});const k=(A,se,De)=>{O(A.toggle,"click",Ke=>{yt(Ke);const Je=A.popover.hidden;if(se.forEach(Tt=>Tt.close()),!!ju()){if(!Je)return A.close();A.open(),De()}})};k(Na,[bl,ka],()=>{wR(),Ls(),I.startNotificationLiveEvents?.(),Ft.page=0,vl(Ft.group,0,{force:!0})}),k(bl,[Na,ka],ps),k(ka,[Na,bl],()=>{SR(),yl()}),lr.forEach(A=>{O(A,"click",()=>{Ft.group=A.dataset.notificationGroup||"all",Ft.page=0,vl(Ft.group,Ft.page)})}),bt.forEach(A=>{O(A,"click",()=>{const se=A.dataset.notificationMode||"notifications",De=lr.find(Ke=>Ke.dataset.notificationGroup===Ft.groups[se])||lr.find(Ke=>to[Ke.dataset.notificationGroup]?.mode===se);De&&vl(De.dataset.notificationGroup,0)})});const j={},B=(A,se,De,Ke,Je,Tt=0)=>{j[A]=FS(se.search,se.searchClear,Pt=>{De.query=Pt,De.page=0,Ke(Pt)},Tt),XP(se.pagePrev,se.pageNext,De,Je)};B("notification",de,Ft,()=>vl(Ft.group,0),()=>vl(Ft.group,Ft.page)),O(ge.sortToggle,"click",()=>{cr("historySortMode",qe.historySortMode==="recent-viewed"?"first-viewed":"recent-viewed"),mo.page=0,Ko.clear(),eo(),ps(),ne._ldpRefreshReaderHistoryNavigation?.(),pa(ge.sortToggle)}),US(ge.popover,mo,Ko,ge.multi,ge.multiDone,ge.selectScope,ps),O(ge.selectToggle,"click",()=>{BS(Ko,hv),ps()}),B("history",ge,mo,()=>{Ko.clear(),ps()},ps),O(ge.deleteSelected,"click",()=>{const A=Ko.size;A&&Nc(I.activeContext,{title:"删除所选浏览历史?",message:`将删除选中的 ${A} 条阅读器浏览历史。`,note:"不会清除浏览器自身的访问历史。",icon:"trash",confirmLabel:`删除 ${A} 条`,onConfirm:()=>{Qa(dn().filter(se=>!Ko.has(Number(se.topicId)))),Ko.clear(),ps(),mt(`已删除 ${A} 条浏览历史`)}})}),O(ge.clear,"click",()=>{const A=dn().length;A&&Nc(I.activeContext,{title:"清空浏览历史?",message:`将删除全部 ${A} 条阅读器浏览历史。`,note:"此操作无法撤销,但不会清除浏览器自身的访问历史。",icon:"trash",confirmLabel:"清空全部",onConfirm:()=>{Qa([]),Ko.clear(),ge.search.value="",ge.searchClear.hidden=!0,mo.query="",mo.page=0,ps(),mt("浏览历史已清空")}})}),Ue.forEach(A=>{O(A,"click",()=>{const se=A.dataset.bookmarkType,De=["Topic","Post","Reaction"].includes(se)?se:"Topic";De!==Fe.type&&(Fe.type=De,Fe.page=0,Fe.multi=!1,Hc(),(De!=="Reaction"||Fe.reactionLoadedAt<=0)&&Jo(),yl())})});let Z=null,J=!1;const ye=A=>{if(!Z||A.pointerId!==Z.pointerId)return;const{tab:se,moved:De}=Z;Z=null,se.classList.remove("ldp-bookmark-tab-dragging"),De&&(J=!0,cr("bookmarkTabOrder",Array.from(dt.children,Ke=>Ke.dataset.bookmarkType)))};O(dt,"click",A=>{J&&(J=!1,A.preventDefault(),A.stopImmediatePropagation())},!0),O(dt,"pointerdown",A=>{if(A.pointerType!=="mouse"||A.button!==0)return;const se=A.target.closest(".ldp-bookmark-tab");if(se){Z={tab:se,pointerId:A.pointerId,startX:A.clientX,startY:A.clientY,moved:!1};try{se.setPointerCapture(A.pointerId)}catch{}}}),O(dt,"pointermove",A=>{if(!Z||A.pointerId!==Z.pointerId||!Z.moved&&Math.hypot(A.clientX-Z.startX,A.clientY-Z.startY)<5)return;Z.moved=!0,Z.tab.classList.add("ldp-bookmark-tab-dragging"),A.preventDefault();const De=[...dt.children].filter(Ke=>Ke!==Z.tab).find(Ke=>{const Je=Ke.getBoundingClientRect();return A.clientX{const se=A.target.closest("[data-reaction-filter]");se&&(Fe.reactionFilter=String(se.dataset.reactionFilter||""),Fe.page=0,Jo())}),US(Te.popover,Fe,sn,Te.multi,Te.multiDone,Te.selectScope,Jo,()=>{Fe.allIds=null}),O(Te.selectToggle,"click",()=>{let A=Fe.entries.map(se=>Number(se.id)).filter(se=>se>0);Fe.scope==="all"&&(Fe.allIds||(Fe.allIds=TR().map(se=>Number(se.id)).filter(se=>se>0)),A=Fe.allIds||[]),BS(sn,A),Jo()}),B("bookmarks",Te,Fe,()=>{Hc(),Fe.type==="Reaction"&&Fe.reactionLoadedAt>0?Jo():yl()},Jo,250),I.cancelBookmarksSearch=j.bookmarks,O(Te.deleteSelected,"click",async()=>{const A=[...sn];if(!A.length||Te.deleteSelected.dataset.ldpRequestBusy==="1"||!window.confirm(`确定取消选中的 ${A.length} 条收藏吗?`))return;wa(Te.deleteSelected,!0);const se=Fe.entries.filter(Je=>sn.has(Number(Je.id))),De=Fe.allEntries.filter(Je=>sn.has(Number(Je.id))),Ke=Array.isArray(Fe.allIds)&&Fe.allIds.length>0&&Fe.allIds.every(Je=>sn.has(Je));try{const Tt=Bt("discourse/models/bookmark")?.default;if(!Tt||!D(Tt.bulkOperation))throw new Error("Discourse 收藏批处理接口尚未就绪");await Tt.bulkOperation(A.map(Er=>({id:Er})),{type:"delete"}),await go({tags:["bookmarks"]}),De.forEach(AR);const Pt=Fe.entries.length>0&&se.length===Fe.entries.length;Ke?Fe.page=0:Pt&&Fe.page>0&&(Fe.page-=1),Hc(),await yl(),mt(`已取消 ${A.length} 条收藏`)}catch(Je){alert(`批量取消收藏失败:${Je.message}`)}finally{wa(Te.deleteSelected,!1),Te.popover.hidden||Jo()}}),O(ke,"click",A=>A.stopPropagation()),pn(ke),HS(ge.list,".ldp-history-select-input","[data-history-topic-id]","historyTopicId",Ko,ps),O(ge.popover,"click",A=>{A.stopPropagation();const se=A.target.closest("[data-history-topic-id]");if(!se||A.target.closest(".ldp-history-select"))return;A.preventDefault();const De=Number(se.dataset.historyTopicId),Ke=Math.max(1,Number(se.dataset.historyPostNumber)||1);if(A.target.closest(".ldp-history-delete")){Qa(dn().filter(Je=>Number(Je.topicId)!==De)),Ko.delete(De),ps(),mt("已删除这条浏览历史");return}De>0&&co(De,{targetPostNumber:Ke})}),pn(ge.popover),HS(Te.list,".ldp-bookmark-select-input","[data-bookmark-id]","bookmarkId",sn,Jo),O(Te.popover,"click",async A=>{A.stopPropagation();const se=A.target.closest("[data-reaction-record]");if(se){A.preventDefault();const Pt=Number(se.dataset.reactionTopicId),Er=Math.max(1,Number(se.dataset.reactionPostNumber)||1);Pt>0&&(ku(),co(Pt,{targetPostNumber:Er}));return}const De=A.target.closest("[data-bookmark-id]");if(!De||A.target.closest(".ldp-bookmark-select"))return;A.preventDefault();const Ke=Number(De.dataset.bookmarkId),Je=Fe.entries.find(Pt=>Number(Pt.id)===Ke);if(!Je)return;if(A.target.closest(".ldp-bookmark-delete")){const Pt=A.target.closest(".ldp-bookmark-delete");if(Pt.dataset.ldpRequestBusy==="1")return;wa(Pt,!0);try{await $M(Ke),await go({tags:["bookmarks"]}),AR(Je),sn.delete(Ke),Fe.allIds=null,Fe.entries.length===1&&Fe.page>0&&(Fe.page-=1),await yl(),mt("已取消这条收藏")}catch(Er){alert(`取消收藏失败:${Er.message}`),wa(Pt,!1)}return}const Tt=Sv(Je);Tt.topicId>0&&(ku(),co(Tt.topicId,{targetPostNumber:Tt.postNumber}))}),pn(Te.popover),ot.forEach(A=>O(A,"click",se=>{yt(se),c_(A.dataset.readerThemeMode)})),O(Gt,"click",A=>{yt(A),I.activeContext?.translation?.cycleMode()}),O(Xt,"click",A=>{yt(A);const se=!L.hidden;if(Vm(),yv(),ku(),se){Ro(),ju();return}NR(!0),fr(L,!1),gi.clear(),y$({closeTransients:!1}),Ud(),sr(Xt,!0),yi="",mh("user")}),O(Ce,"click",A=>{A.preventDefault(),ju()}),O(L,"click",A=>A.stopPropagation()),pn(L),O(L,"pointerover",A=>{const se=A.target.closest("[data-setting-help]");!se||A.relatedTarget&&se.contains(A.relatedTarget)||u$(se)});const Q=()=>{requestAnimationFrame(()=>{jn&&!jn.matches(":hover")&&!jn.matches(":focus-within")&&hs()})};O(L,"pointerout",A=>{jn&&(A.relatedTarget&&jn.contains(A.relatedTarget)||Q())}),O(L,"focusin",A=>{const se=A.target.closest("[data-setting-help]");se&&u$(se)}),O(L,"focusout",Q),O(me,"scroll",()=>{hs(),Ro(),Oc()},{passive:!0}),O(L,"pointerdown",A=>{!Ot.hidden&&!A.target.closest(".ldp-font-family-picker")&&Ro();const se=A.target.closest('input[type="color"]');if(se&&!se.disabled&&A.button===0){hs(),Jc(),Rl(),Xc=se.closest(".ldp-setting-row"),Xc&&Xc.classList.add("ldp-color-pick-active"),L.classList.add("ldp-color-picking");return}Rl();const De=A.target.closest('input[type="range"]');if(!(!De||De.disabled||A.button!==0)){hs(),Jc(),Yc=De.closest(".ldp-setting-row"),Yc&&Yc.classList.add("ldp-range-drag-active"),L.classList.add("ldp-range-dragging");try{De.setPointerCapture(A.pointerId)}catch{}}}),O(L,"change",A=>{A.target.matches('input[type="color"]')&&Rl(),A.target.matches('input[type="range"]')&&eq(A.target)}),O(L,"focusout",A=>{A.target.matches('input[type="color"]')&&Rl()}),O(S,"click",_$);const Ve=A=>{jo({imageProfile:A})};O(st,"input",()=>{const A=jd(st.value,vb());Ve({...f_(),preset:"custom",custom:A}),Xm(),xv()}),O(At,"click",()=>{Ve({...Fl}),Xm(),xv()}),cs.forEach(A=>{O(A,"click",()=>{const se=A.dataset.fontScopeTab;!sa.includes(se)||se===Qm||(Ro(),Qm=se,Mn())})}),Kt.forEach(A=>{O(A,"click",se=>{yt(se),hs();const De=A.dataset.fontScope;!Ot.hidden&&fs===De?Ro(!0):GR(De)}),O(A,"keydown",se=>{se.key==="ArrowDown"&&(se.preventDefault(),GR(A.dataset.fontScope))})}),O(jt,"input",VR),O(br,"click",A=>{const se=A.target.closest(".ldp-font-family-option");if(!se)return;const De=En(),Ke={..._l()};se.dataset.fontKind==="preset"?(Ke[De.family]=kn(se.dataset.fontValue,Ke[De.family]),Ke[De.customFamily]=""):(Ke[De.family]="custom",Ke[De.customFamily]=oi(se.dataset.fontValue,Ke[De.customFamily])),gi.delete(fs),fs==="host"?jo({hostFontFamily:Ke.hostFontFamily,hostFontCustomFamily:Ke.hostFontCustomFamily}):wl(Ke),Ro(!0),Mn(),fs!=="host"&&Gs()}),O(Ra,"click",()=>{const A=fs,se=En(A),De={..._l(A),[se.family]:"custom"};gi.add(A),A==="host"?cr(se.family,"custom"):wl(De),Ro(),Mn(),A!=="host"&&Gs();const Ke=[...ct].find(Je=>Je.dataset.fontScope===A);Ke.focus(),Ke.select()}),O(Ot,"keydown",A=>{jS(A,jt,br,".ldp-font-family-option:not([hidden])",Ro)}),O(Ot,"click",A=>A.stopPropagation()),O(Ot,"pointerdown",A=>A.stopPropagation()),pn(Ot),ct.forEach(A=>O(A,"input",()=>{const se=A.dataset.fontScope,De=En(se),Ke={..._l(se)};Ke[De.family]="custom",Ke[De.customFamily]=oi(A.value,Ke[De.customFamily]),se==="host"?jo({hostFontFamily:Ke.hostFontFamily,hostFontCustomFamily:Ke.hostFontCustomFamily}):wl(Ke),Mn(),se!=="host"&&Gs()})),[[$a,"change","weight",sc],[zs,"input","color",Ln]].forEach(([A,se,De,Ke])=>A.forEach(Je=>{O(Je,se,()=>{const Tt=Je.dataset.fontScope,Pt=En(Tt),Er={..._l(Tt)},Oo=Pt[De];Er[Oo]=Ke(Je.value,Er[Oo]),Tt==="host"?cr(Oo,Er[Oo]):wl(Er),Mn(),Tt!=="host"&&Gs()})})),QR.forEach(({input:A,key:se,normalize:De})=>O(A,"input",()=>{const Ke={...hi()};Ke[se]=De(A.value,Ke[se]),wl(Ke),Mn(),Gs()})),Cn.forEach(A=>O(A,"input",()=>{const se=Cs[A.dataset.hostEmbedSize];se&&(cr(se.pref,wf(A.value)),Mn())})),Ta.forEach(A=>O(A,"click",()=>{const se=Cs[A.dataset.hostEmbedSizeReset];se&&(cr(se.pref,se.defaultScale),Mn())})),Aa.forEach(A=>O(A,"click",()=>{const se=A.dataset.hostFontReset!=null,De=A.dataset.fontFieldReset,Ke=se?"host":sa.find(Oo=>Object.values(En(Oo)).includes(De));if(!Ke)return;const Je=En(Ke),Tt=se?Je[A.dataset.hostFontReset]:De,Pt=se?Bo:Pv();if(!Object.hasOwn(Pt,Tt))return;const Er={[Tt]:Pt[Tt]};Tt===Je.family&&(Er[Je.customFamily]=Pt[Je.customFamily],gi.delete(Ke)),se?jo(Er):wl({...hi(),...Er}),Mn(),se||Gs()})),O(Yo,"click",()=>{Ro(),gi.clear(),jo(Bo),wl({...Pv()}),Mn(),Gs()}),N.forEach(A=>{O(A,"input",()=>{const se=A.dataset.layoutRegion;if(!$o.includes(se))return;const De={...zc()},Ke=Number(A.value),Je=100-$o.reduce((Tt,Pt)=>Tt+(Pt===se?0:ia[Pt]),0);De[se]=mn(Math.min(Je,Math.max(ia[se],Number.isFinite(Ke)?Ke:De[se]))),kR($1(De,se)),Du(),Ou()})}),O(H,"click",()=>{kR({...LR()}),Du(),Ou()});const gt=()=>{Do.setGeometry(te.value,we.value,Ae.value,Ge.value),Vc()};O(te,"change",gt),O(we,"change",gt),O(Ae,"change",gt),O(Ge,"change",gt),O(Mo,"click",()=>{Do.reset(),Vc()});const Ht=(A,se)=>{IR({...La(),[A]:se})},je=A=>{IR({...La(),...A})};Jy.forEach(A=>O(A,"change",()=>{const se=A.dataset.appearanceGroupToggle;gw.includes(se)&&(Ht(se,A.checked),qu(),Cl())})),YR.forEach(({input:A,key:se,normalize:De,apply:Ke})=>{O(A,"input",()=>{if(A.type==="color"){const Je=Ln(A.value,kv()[se]);je({[se]:Je,[Wd(se)]:Je})}else Ht(se,De(A.value));Ke(),Cl()})}),WM.forEach(A=>O(A,"click",()=>{const se=A.dataset.colorReset,De=kv(),Ke=v_(se);Ke.some(Je=>De[Je]==null)||(je(Object.fromEntries(Ke.map(Je=>[Je,De[Je]]))),qu(),Cl())})),I.cancelShellFrames=()=>{fi&&cancelAnimationFrame(fi),fi=0,Tu=null,Ia&&cancelAnimationFrame(Ia),Ia=0,zn=null,Object.values(j).forEach(A=>A())}}const $l=Uo(),mr=(i,p,h,y)=>$l.listen(i,p,h,y);if($l.add(()=>{ze&&cancelAnimationFrame(ze),ze=0}),mr(G,"scroll",$t,{passive:!0}),typeof ResizeObserver=="function"){const i=new ResizeObserver($t);i.observe(hr),i.observe(G),i.observe(X(".ldp-topic-tags")),$l.add(()=>i.disconnect())}$t(),mr(hr,"wheel",i=>{const p=Math.abs(i.deltaX)>Math.abs(i.deltaY)?i.deltaX:i.deltaY;if(!p)return;i.preventDefault(),i.stopPropagation();const h=G.scrollWidth-G.clientWidth;if(h<=1)return;const y=i.deltaMode===1?p*40:i.deltaMode===2?p*G.clientWidth:p,C=Math.min(h,Math.max(0,G.scrollLeft+y));G.scrollLeft=C},{passive:!1});let E=null;const hh=rL(e,x,i=>jE(i,E),i=>jE(i,E,{optimistic:!0}));E={topicId:e,op:null,topicData:null,loader:jr,modal:S,commentsEl:tt,commentsHeader:it,countEl:ft,emptyEl:Nt,scrollRoot:x,performance:n,readerSession:T,isPostVoting:!!U?.is_post_voting,directRepliesByParent:new Map,replyParentByPost:new Map,tracker:hh,totalPosts:0,repliesIO:null,postRequestScheduler:Pa,subReplyState:new Map,confirmedReadPostNumbers:new Set,optimisticReadPostNumbers:new Set,streamNodeMap:new Map,streamItems:[],streamItemMap:new Map,streamPrefix:[0],onlyOp:!1,streamLayoutItems:[],streamLayoutDirty:!0,streamPrefixDirty:!0,streamPrefixDirtyFrom:0,streamNeedsRepair:!0,streamPendingReindexFrom:null,streamWindowStart:0,streamWindowEnd:0,streamMountedItemCount:0,streamViewportTopIndex:0,streamViewportBottomIndex:0,viewportPersistenceEnabled:!1,streamScrollDirection:0,streamScrollVelocity:0,streamLastScrollAt:0,streamProgrammaticTarget:null,streamViewportAnchor:null,streamViewportAnchorRestoreDepth:0,streamViewportAnchorRestorePending:!1,streamWindowResetPending:!1,streamInternalScrollTop:NaN,streamInternalScrollDelta:0,readerNavigationSequence:0,streamListOffset:NaN,streamMountedNodes:new Set,streamHydratedNodes:new Set,pendingPostHydrations:new Set,pendingPostLatexRenders:new Set,streamTopSpacer:GC("top"),streamBottomSpacer:GC("bottom"),streamResizeObserver:null,queueViewportTracker:null,streamWindowFrame:0,floorPreviewSyncFrame:0,latestReplyAt:"",latestReplyTimestamp:0,topicBookmarked:!!U?.bookmarked,topicNotificationLevel:Ji(U),topicSharedIssueBusy:!1,topicSharedIssueHostVisible:!1,initialTargetPositionPending:a>1,initialTargetHydrationBoundary:a>1?a:0,initialTargetPrecedingContentDeferred:a>1,initialTargetHeadSpacerHeight:0,cancelInitialTargetPosition:null,releaseInitialPrecedingContent:null,readerActionDialog:null,nativeComposerSession:null,toggleTopicBookmark:null,readerWindow:Do,readerWorkspace:xe},E.queueViewportTracker=Y?IP(E,Y):{observe(){},unobserve(){},sync(){},stop(){}},I.activeContext=E,cm();const Vt=E.topicActionRail=X(".ldp-topic-action-rail");Vt.hidden=!0,Vt.classList.remove("is-dragging"),Vt._ldpRelatedPost=null,ne.classList.remove("ldp-topic-action-rail-visible");let a0=0,Js=null,C$=0;const Zc=()=>{if(!Vt||Vt.hidden)return;const i=qe.topicActionRailPosition||{},p=Vt.offsetWidth,h=Math.max(0,S.clientWidth-p),y=Math.max(0,S.clientHeight-Vt.offsetHeight),C=X(".ldp-reader-main"),$=C?.getBoundingClientRect(),k=Number.parseFloat(getComputedStyle(C).gridTemplateColumns)||p,j=Vt.offsetParent||S,Z=j.getBoundingClientRect().left+(j.clientLeft||0),J=$?$.left+Math.max(k,p)/2-Z-p/2:(Math.max(k,p)-p)/2,ye=Number.isFinite(i.x),Q=ye?h*Math.max(0,Math.min(1,i.x)):Math.max(0,Math.min(h,J)),Ve=Vt.querySelector(".ldp-topic-action-rail-toggle"),gt=Math.max(0,S.clientHeight-(Ve?.offsetHeight||0));Vt.style.left=`${Math.round(Q)}px`,Vt.style.top=`${Math.round(Math.max(0,Math.min(y,gt*Math.max(0,Math.min(1,Number(i.y)||0))-(Ve?.offsetTop||0))))}px`;const Ht=[ne.querySelector(".ldp-reader-queue:not([hidden])"),...ne.querySelectorAll(".ldp-reader-history-nav:not([hidden])")],je=S.getBoundingClientRect();Ht.filter(De=>De&&(!Vt.classList.contains("is-expanded")||!De.matches(".ldp-reader-history-nav"))).forEach(De=>{const Ke=Vt.getBoundingClientRect(),Je=De.getBoundingClientRect();Ke.leftJe.left&&Ke.topJe.top&&(Vt.style.left=`${Math.round(Je.left>je.left+je.width/2?Math.max(0,Je.left-Z-p-8):Math.min(h,Je.right-Z+8))}px`)});const A=Number.parseFloat(Vt.style.left)||0,se=S.clientWidth*$w;Vt.classList.toggle("is-docked-left",ye&&A<=se),Vt.classList.toggle("is-docked-right",ye&&h-A<=se),I.syncFullPageHeaderAlignment?.(),ne._ldpSyncReaderQueuePosition?.()};E.syncTopicActionRail=(i=Vt?._ldpRelatedPost)=>{if(!i){const C=Ir(E.topicData).concat(jr.cachedPosts).find($=>+$?.post_number==1);if(C){$c(C,!1,E,{deferContent:!0});return}qe.topicActionRailVisible&&!Vt._ldpStarterRequest&&(Vt._ldpStarterRequest=jr.loadPost(1,{advanceCursor:!1,priority:rt.auxiliary}).then($=>{const k=$.find(j=>+j?.post_number==1);k&&qt()&&$c(k,!1,E,{deferContent:!0})}).catch(()=>{}).finally(()=>{Vt._ldpStarterRequest=null}));return}const p=i?._ldpPostData;if(!Vt||!p)return;const h=Zd(p,E),y=Vt.classList.contains("is-expanded");Vt._ldpRelatedPost=i,Vt._ldpPostData=p,Vt.hidden=qe.topicActionRailVisible!==!0,ne.classList.toggle("ldp-topic-action-rail-visible",!Vt.hidden),Vt.innerHTML=`${li(p)?``:""}
${dy(p,E)}${qC(p,E)}
`,ks(Vt,E,{collectLike:!1,railPicker:!0}),Vt.querySelector(":scope > .ldp-reactions > .ldp-reaction-summary")?.remove(),os(Vt,E),bn(E),requestAnimationFrame(()=>{Zc(),requestAnimationFrame(Zc)})},mr(Vt,"click",i=>{if(Date.now(){i.button||qe.topicActionRailFixed||!i.target.closest(".ldp-topic-action-rail-toggle")||(clearTimeout(a0),a0=$l.timer(()=>{Js={id:i.pointerId,x:i.clientX,y:i.clientY,left:Number.parseFloat(Vt.style.left)||0,top:Number.parseFloat(Vt.style.top)||0},Vt.classList.add("is-dragging")},420))}),mr(document,"pointermove",i=>{if(!Js||i.pointerId!==Js.id)return;const p=Math.max(0,S.clientWidth-Vt.offsetWidth),h=Math.max(0,S.clientHeight-Vt.offsetHeight);Vt.style.left=`${Math.round(Math.max(0,Math.min(p,Js.left+i.clientX-Js.x)))}px`,Vt.style.top=`${Math.round(Math.max(0,Math.min(h,Js.top+i.clientY-Js.y)))}px`,i.preventDefault()},!0);const E$=i=>{if(clearTimeout(a0),!Js||i.pointerId!==Js.id)return;const p=Math.max(1,S.clientWidth-Vt.offsetWidth),h=Vt.querySelector(".ldp-topic-action-rail-toggle"),y=Math.max(1,S.clientHeight-(h?.offsetHeight||0));cr("topicActionRailPosition",{x:(Number.parseFloat(Vt.style.left)||0)/p,y:((Number.parseFloat(Vt.style.top)||0)+(h?.offsetTop||0))/y}),Js=null,Vt.classList.remove("is-dragging"),C$=Date.now()+300,Zc()};ne._ldpSyncTopicActionRailPosition=Zc,mr(document,"pointerup",E$,!0),mr(document,"pointercancel",E$,!0),mr(window,"resize",Zc),E.translation=uk(E,Gt),D(I.refreshBookmarksPanel)||(I.refreshBookmarksPanel=(i=null)=>{Hc();const p=Fe.reactionLoadedAt>0,h=i&&typeof i.likeActed=="boolean",y=i&&!h&&tD(i.post,i.topic);if(h||y){y&&(Fe.reactionEntries=Cv(Fe.reactionEntries)),p&&(h&&(Fe.reactionEntries=rD(Fe.reactionEntries,i.post,i.topic,i.likeActed)),Fe.reactionLoadedAt=Date.now(),iS(Fe.reactionUsername,Fe.reactionEntries)),!Te.popover.hidden&&Fe.type==="Reaction"&&(p?(Fe.page=0,Jo()):yl());return}Te.popover.hidden||yl()}),E.refreshBookmarksPanel=i=>I.refreshBookmarksPanel(i),E.nativeComposerWindow=lI(T),E.renderTopicShell=Bc,E.repliesIO=$I(E),E.streamResizeObserver=Lk(E);const oq=E.timeline=qI(Ar,E),nq=()=>{const i=xt("service:site"),p=[...pt(i,"categories")||[]].filter(h=>h&&+pt(h,"id")>0&&pt(h,"name")).map(h=>({id:+pt(h,"id"),name:String(pt(h,"name")||""),slug:String(pt(h,"slug")||""),color:String(pt(h,"color")||""),parent_category_id:+pt(h,"parent_category_id")||null}));return p.length?Promise.resolve({categories:p}):Promise.reject(new Error("原站分类数据尚未就绪,请稍后重试"))},sq=()=>{const i=E.topicData;if(!i||!i.details||i.details.can_edit!==!0)return;qo&&qo.close(!0);const p=X(".ldp-topic-edit-trigger"),h=X(".ldp-topic-edit-layer"),y=X(".ldp-topic-edit-form"),C=y.querySelector('input[name="title"]'),$=y.querySelector(".ldp-topic-edit-category-trigger"),k=y.querySelector(".ldp-topic-edit-category-value"),j=y.querySelector(".ldp-topic-edit-category-menu"),B=y.querySelector(".ldp-topic-edit-category-search input"),Z=y.querySelector(".ldp-topic-edit-category-options"),J=y.querySelector(".ldp-topic-edit-label-input"),ye=y.querySelector(".ldp-topic-edit-label-values"),Q=y.querySelector(".ldp-topic-edit-label-options"),Ve=y.querySelector(".ldp-topic-edit-save"),gt=y.querySelector(".ldp-topic-edit-cancel"),Ht=h.querySelector(".ldp-topic-edit-close"),je=y.querySelector(".ldp-topic-edit-status"),A=String(i.title||"").trim(),se=Number(i.category_id||i.categoryId)||0,De=[...new Set((Array.isArray(i.tags)?i.tags:[]).map(Hr).map(ut=>ut.trim()).filter(Boolean))],Ke=new Map,Je=new Set,Tt=new Map;let Pt=se,Er=!1,Oo=!1,oa=!1,ud=0,pd=null,Lh={query:"",sequence:0};const an=Uo(),Ih=ut=>String(ut||"").trim().toLocaleLowerCase();De.forEach(ut=>{const or=Ih(ut);Ke.set(or,{id:null,name:ut}),Je.add(or)});const M0=()=>[...Je].map(ut=>Ke.get(ut)).filter(Boolean),xh=()=>{ye.innerHTML=M0().map(ut=>`
- ${$e(dt.name)}
-
- `).join("")},oD=dt=>{X.innerHTML=(dt||[]).map(or=>``).join("")},nD=async(dt,or)=>{const vr=It("service:tag-utils"),Nr=It("service:site-settings");if(!vr||!D(vr.searchTags)||!Nr)throw new Error("Discourse 标签搜索服务尚未就绪");const vi=m0(),tp={q:dt,limit:Math.max(1,Number(ut(Nr,"max_tag_search_results"))||20),categoryId:Vt||void 0,filterForInput:!0},ea=vi.map(nr=>Number(nr.id)).filter(nr=>nr>0),wi=vi.filter(nr=>!(Number(nr.id)>0)).map(nr=>nr.name);ea.length&&(tp.selected_tag_ids=ea.slice(0,100)),wi.length&&(tp.selected_tags=wi.slice(0,100));const Rl=await vr.searchTags("/tags/filter/search",tp,nr=>{const sd=Array.isArray(nr?.results)?nr.results:[];return D(vr.sortSearchResults)?vr.sortSearchResults(sd):sd});if(Zs||or!==rd)return;const nd=(Array.isArray(Rl)?Rl:[]).map(nr=>({id:Number(nr?.id)||null,name:String(nr&&(nr.name||nr.text)||"").trim()})).filter(nr=>nr.name);nd.forEach(nr=>gt.set($h(nr.name),nr)),oD(nd)},sD=()=>{const{query:dt,sequence:or}=Mh;nD(dt,or).catch(vr=>{!Zs&&or===rd&&(C.textContent=vr?.message?vr.message:"标签搜索失败,请重试")})},ep=()=>{if(Zs||!Zr)return;const dt=String(J.value||"").trim(),or=++rd;Mh={query:dt,sequence:or};const Nr=Ft("@ember/runloop")?.debounce;if(!D(Nr)){C.textContent="Discourse 标签搜索服务尚未就绪";return}od=Nr(J,sD,300)},h0=()=>{const dt=At.get(Vt),or=dt&&At.get(Number(dt.parent_category_id));L.textContent=dt?or?`${or.name} / ${dt.name}`:String(dt.name||"请选择类别"):"请选择类别",re.querySelectorAll(".ldp-topic-edit-category-option").forEach(vr=>{vr.setAttribute("aria-selected",+vr.dataset.categoryId===Vt?"true":"false")})},$l=(dt=!1)=>{j.hidden=!0,ar(T,!1),dt&&!T.disabled&&T.focus()},eT=()=>{La||!Zr||T.disabled||(j.hidden=!1,ar(T,!0),q.value="",re.querySelectorAll(".ldp-topic-edit-category-option").forEach(dt=>{dt.hidden=!1}),q.focus(),re.querySelector('[aria-selected="true"]')?.scrollIntoView({block:"nearest"}))},aD=()=>{const dt=document.createDocumentFragment();At.forEach(or=>{const vr=At.get(Number(or.parent_category_id)),Nr=ke("button"),vi=String(or.color||"").replace(/^#/,"");Nr.className="ldp-topic-edit-category-option ldp-picker-option",Nr.type="button",Nr.dataset.categoryId=String(or.id),Nr.dataset.searchText=`${vr?`${vr.name} `:""}${or.name}`.toLocaleLowerCase(),Nr.setAttribute("role","option"),Nr.setAttribute("aria-selected",+or.id===Vt?"true":"false"),Nr.innerHTML=`
+ ${$e(ut.name)}
+
+ `).join("")},_q=ut=>{Q.innerHTML=(ut||[]).map(or=>``).join("")},Cq=async(ut,or)=>{const vr=xt("service:tag-utils"),kr=xt("service:site-settings");if(!vr||!D(vr.searchTags)||!kr)throw new Error("Discourse 标签搜索服务尚未就绪");const Si=M0(),cp={q:ut,limit:Math.max(1,Number(pt(kr,"max_tag_search_results"))||20),categoryId:Pt||void 0,filterForInput:!0},na=Si.map(nr=>Number(nr.id)).filter(nr=>nr>0),_i=Si.filter(nr=>!(Number(nr.id)>0)).map(nr=>nr.name);na.length&&(cp.selected_tag_ids=na.slice(0,100)),_i.length&&(cp.selected_tags=_i.slice(0,100));const kl=await vr.searchTags("/tags/filter/search",cp,nr=>{const md=Array.isArray(nr?.results)?nr.results:[];return D(vr.sortSearchResults)?vr.sortSearchResults(md):md});if(oa||or!==ud)return;const fd=(Array.isArray(kl)?kl:[]).map(nr=>({id:Number(nr?.id)||null,name:String(nr&&(nr.name||nr.text)||"").trim()})).filter(nr=>nr.name);fd.forEach(nr=>Ke.set(Ih(nr.name),nr)),_q(fd)},Eq=()=>{const{query:ut,sequence:or}=Lh;Cq(ut,or).catch(vr=>{!oa&&or===ud&&(je.textContent=vr?.message?vr.message:"标签搜索失败,请重试")})},lp=()=>{if(oa||!Er)return;const ut=String(J.value||"").trim(),or=++ud;Lh={query:ut,sequence:or};const kr=Bt("@ember/runloop")?.debounce;if(!D(kr)){je.textContent="Discourse 标签搜索服务尚未就绪";return}pd=kr(J,Eq,300)},R0=()=>{const ut=Tt.get(Pt),or=ut&&Tt.get(Number(ut.parent_category_id));k.textContent=ut?or?`${or.name} / ${ut.name}`:String(ut.name||"请选择类别"):"请选择类别",Z.querySelectorAll(".ldp-topic-edit-category-option").forEach(vr=>{vr.setAttribute("aria-selected",+vr.dataset.categoryId===Pt?"true":"false")})},Nl=(ut=!1)=>{j.hidden=!0,sr($,!1),ut&&!$.disabled&&$.focus()},pT=()=>{Oo||!Er||$.disabled||(j.hidden=!1,sr($,!0),B.value="",Z.querySelectorAll(".ldp-topic-edit-category-option").forEach(ut=>{ut.hidden=!1}),B.focus(),Z.querySelector('[aria-selected="true"]')?.scrollIntoView({block:"nearest"}))},Mq=()=>{const ut=document.createDocumentFragment();Tt.forEach(or=>{const vr=Tt.get(Number(or.parent_category_id)),kr=Ne("button"),Si=String(or.color||"").replace(/^#/,"");kr.className="ldp-topic-edit-category-option ldp-picker-option",kr.type="button",kr.dataset.categoryId=String(or.id),kr.dataset.searchText=`${vr?`${vr.name} `:""}${or.name}`.toLocaleLowerCase(),kr.setAttribute("role","option"),kr.setAttribute("aria-selected",+or.id===Pt?"true":"false"),kr.innerHTML=`
${$e(String(or.name||"未命名类别"))}
${vr?`${$e(String(vr.name))}`:"一级类别"}
- `,/^[0-9a-f]{6}$/i.test(vi)&&Nr.querySelector(".ldp-topic-edit-category-dot").style.setProperty("--ldp-category-color",`#${vi}`),dt.appendChild(Nr)}),re.replaceChildren(dt),h0()},Th=dt=>{La=dt,M.disabled=dt,T.disabled=dt||!Zr,q.disabled=dt||!Zr,J.disabled=dt||!Zr,Ue.disabled=dt||!Zr,yt.disabled=dt,Bt.disabled=dt,dt&&$l()},g0=(dt=!0)=>{const or=String(J.value||"").replace(/[,,]\s*$/,"").trim();if(!or)return J.value="",!0;const vr=$h(or);return gt.get(vr)?(lt.add(vr),J.value="",C.textContent="",Rh(),ep(),!0):(dt&&(C.textContent=`没有找到 label“${or}”`),!1)},iD=dt=>{dt.key!=="Enter"&&dt.key!==","&&dt.key!==","||(dt.preventDefault(),g0())},lD=()=>ep(),cD=()=>g0(!1),dD=dt=>{const or=dt.target.closest(".ldp-topic-edit-label-remove");!or||La||(lt.delete(or.dataset.labelKey||""),Rh(),ep(),J.focus())},uD=()=>{j.hidden?eT():$l(!0)},pD=dt=>{dt.key==="ArrowDown"&&(dt.preventDefault(),eT())},fD=dt=>{const or=dt.target.closest(".ldp-topic-edit-category-option");!or||La||(Vt=Number(or.dataset.categoryId)||0,h0(),$l(!0),ep())},mD=()=>{const dt=String(q.value||"").trim().toLocaleLowerCase();re.querySelectorAll(".ldp-topic-edit-category-option").forEach(or=>{or.hidden=!!dt&&!String(or.dataset.searchText||"").includes(dt)})},hD=dt=>{kS(dt,q,re,".ldp-topic-edit-category-option:not([hidden])",$l)},gD=dt=>{if(dt.target===h){Ah();return}dt.target.closest(".ldp-topic-edit-category")||$l()},Ah=(dt=!1)=>{if(Zs||La&&!dt)return;Zs=!0,rd++;const vr=Ft("@ember/runloop")?.cancel;od&&D(vr)&&vr(od),od=null,$l(),h.hidden=!0,ar(p,!1),sn.destroy(),Do===Ph&&(Do=null),!dt&&!p.hidden&&p.focus()},tT=()=>Ah(),bD=async dt=>{if(dt.preventDefault(),La||!Zr||!g0())return;const or=String(M.value||"").trim(),vr=Vt;if(!or){C.textContent="标题不能为空",M.focus();return}const Nr=At.get(vr);if(!Nr){C.textContent="请选择类别",T.focus();return}if(!E.topicData||!E.topicData.details||E.topicData.details.can_edit!==!0){C.textContent="当前账号没有编辑该帖子的权限";return}Th(!0),C.textContent="正在保存…";const vi=m0(),tp={title:or,category_id:vr,tags:vi.map(ea=>Object.assign({name:ea.name},+ea.id>0?{id:+ea.id}:{}))};try{const wi=Ft("discourse/models/topic")?.default;if(!wi||!D(wi.create)||!D(wi.update))throw new Error("Discourse 主题编辑接口尚未就绪");const Rl=wi.create({...E.topicData,id:Number(e),slug:String(E.topicData.slug||"")});await wi.update(Rl,tp,{fastEdit:!0}),await ho({tags:[`topic:${e}`]});let nd=null;try{nd=await Ur.refreshTopicData({priority:tt.visible})}catch{}const nr=Object.assign({},E.topicData||{},{title:ut(Rl,"title"),fancy_title:ut(Rl,"fancy_title"),tags:ut(Rl,"tags")},nd||{});nr.title=String(nr.title||or),nr.fancy_title=String(nd?.fancy_title||nr.fancy_title||nr.title),nr.category_id=vr,nr.category_name=Xn(Nr.name),nr.category_level=oi(Nr.name),nr.category_slug=String(Nr.slug||""),nr.tags=vi.map(sd=>sd.name),qm={categoryName:nr.category_name,categoryLevel:nr.category_level,categorySlug:nr.category_slug,categoryId:vr,categoryIconHtml:Number(V.categoryId)===vr?V.categoryIconHtml:"",tags:nr.tags.slice(),tagIcons:(Array.isArray(V.tagIcons)?V.tagIcons:[]).filter(sd=>nr.tags.includes(Br(sd?.name)))},V=Ts(qm,Od(nr)),E.topicData=nr,so(e,nr),Fg(nr,0,dn(e)?.seenPostNumbers),La=!1,Ah(!0),Nc(nr),gn(E),ft("帖子信息已更新")}catch(ea){C.textContent=ea?.message?ea.message:"保存失败,请重试",Th(!1)}},Ph={close:Ah,closeTransient:()=>j.hidden?!1:($l(!0),!0)};Do=Ph,M.value=oe,At.set(Te,{id:Te,name:Xn(i.category_name||V.categoryName||"当前类别"),slug:String(i.category_slug||V.categorySlug||"")}),re.replaceChildren(),h0(),X.innerHTML="",J.value="",C.textContent="正在加载类别和 label…",Rh(),Th(!1),h.hidden=!1,ar(p,!0),sn.listen(J,"keydown",iD),sn.listen(J,"input",lD),sn.listen(J,"change",cD),sn.listen(be,"click",dD),sn.listen(T,"click",uD),sn.listen(T,"keydown",pD),sn.listen(re,"click",fD),sn.listen(q,"input",mD),sn.listen(j,"keydown",hD),sn.listen(h,"click",gD),sn.listen(y,"submit",bD),sn.listen(yt,"click",tT),sn.listen(Bt,"click",tT),M.focus(),M.select(),Fq().then(({categories:dt})=>{if(Zs||Do!==Ph)return;const or=new Set(dt.map(Nr=>+Nr.id)),vr=dt.slice();Te&&!or.has(Te)&&vr.unshift({id:Te,name:Xn(i.category_name||V.categoryName||"当前类别"),slug:String(i.category_slug||V.categorySlug||"")}),At.clear(),vr.forEach(Nr=>At.set(+Nr.id,Nr)),aD(),Zr=!0,C.textContent="",Rh(),Th(!1),ep()}).catch(dt=>{Zs||Do!==Ph||(C.textContent=`类别和 label 加载失败:${dt?.message?dt.message:"请重试"}`)})};fr(Z(".ldp-topic-edit-trigger"),"click",Bq),se._ldpCaptureHistoryViewport=()=>{Oq.sync();const i=mm(E)||{},p=Number(Tr.querySelector(".ldp-topic-timeline-track")?.getAttribute("aria-valuenow")),h=RM(E,p);return Yp({postNumber:Number(h?.postNumber)||Number(i.postNumber)||1,postOffset:h?h.postOffset:i.postOffset,scrollTop:Number.isFinite(Number(i.scrollTop))?Number(i.scrollTop):I.scrollTop})},se._ldpCaptureHistoryPostNumber=()=>se._ldpCaptureHistoryViewport()?.postNumber||1;const fR=()=>{const i=E.topicData;if(!i)return;const p=Number(se._ldpCaptureHistoryPostNumber?.())||a||1;Fg(i,p,dn(e)?.seenPostNumbers)};let Wc=0;const Hq=()=>{Wc=0,gv(),js(),Gs.syncOpenSettingsControls(!0),x.syncSettingsSurfaceGeometry?.(),E.nativeComposerWindow&&E.nativeComposerWindow.syncLayer(),lv(),se._ldpSyncReaderQueuePosition?.(),se._ldpSyncTopicActionRailPosition?.(),E.streamListOffset=NaN,E.streamNeedsRepair=!0,bo(E)},Xv=()=>{Wc||(Wc=requestAnimationFrame(Hq))};_l.add(()=>{Wc&&cancelAnimationFrame(Wc),Wc=0}),fr(window,"resize",Xv),fr(se,"ldp-reader-window-change",Xv),typeof ResizeObserver=="function"&&_l.observe(new ResizeObserver(Xv),S);let Cl=!1,jr=!1,jn=!1,bi=!1,qu=null,Kv=!1,Jv=!1,lh=!1,mR=0,ch=null,Vc=0,dh=[];const Du=new Map;let yi=0,Ys=0,Gc=!1,Ou=!1,Xs=0,Fu=0,Na=0,Qc=1,uh=0,ph=0,Zv=0,e0=0,hR=null,gR=null,bR=null,yR=null,Yc=0,ka=0,Bu=0,vR=!1,Ks=0,fh=0,Hu=0,fs=0,El=0,Ml=0,t0=0,r0=null,o0=null,Uu=!1,mh=null,hh=null,ju=0,ms=null,hs=null,zu=0,gh=null,Xc=null,Wu=null,wR=null,SR=null,_R=null,n0=null,CR=null,s0=null,ER=null,MR=null,$R=null,RR=null,TR=null;const Vu=()=>{ju&&clearTimeout(ju),ju=0},bh=()=>{fh&&clearInterval(fh),fh=0},Gu=()=>{if(!Ht||!ir)return;if(!E.onlyOp){bh(),Ht.hidden=!0;return}const i=Math.max(1,+(Ur.streamLength||E.totalPosts||1)),p=Math.min(i,Math.max(0,+(Ur.scanCursor||0))),h=Math.min(p,Math.max(0,+(Ur.scanSkippedCount||0))),y=jo(E).length,M=Date.now();(!Ml||p!==t0)&&(Ml=M,t0=p,p>0&&(El=0));const T=jr?100:Math.min(99.5,p/i*100),L=jr?"":` · 批${Math.max(1,Number(Ur.scanBatchSize)||1)}`,j=h?` · 缺失${h}`:"";let q=null;try{q=$a.snapshot()}catch{}let re=jr?h?"partial":"done":"running",J="";if(!jr&&bi){const X=Number(qu?.status||0);re="error",J=` · 已暂停${X?` HTTP ${X}`:""}`}else if(!jr&&q&&q.coolingDown)re="cooldown",J=` · 429冷却 ${Math.max(1,Math.ceil(q.cooldownRemaining/1e3))}秒`;else if(!jr&&q&&["10s","60s"].includes(q.blockingReason))re="window",J=` · ${q.blockingReason==="10s"?"10秒窗":"60秒窗"} ${Math.max(1,Math.ceil(q.nextPermitDelay/1e3))}秒`;else if(!jr&&jn&&fs>M)re="retry",J=` · 重试${El||1} ${Math.max(1,Math.ceil((fs-M)/1e3))}秒`;else if(!jr&&Cl&&Hu){const X=Math.max(1,Math.ceil((M-Hu)/1e3)),Ue=q&&(Number(q.globalActive||0)>0||Number(q.globalQueued||0)>0);re=X*1e3>=Da?"waiting":"running",J=Ue?` · 请求处理中 ${X}秒`:` · 缓存协调中 ${X}秒`}else if(!jr&&M-Ml>=2e3)re="waiting",J=` · 调度恢复中 ${Math.max(2,Math.ceil((M-Ml)/1e3))}秒`;else if(!jr&&q){const X=Math.max(q.queued,Number(q.globalQueued)||0);J=` · 全局执行${q.globalActive} 排队${X}`}Ht.hidden=!1,Ht.dataset.state=re,Ht.style.setProperty("--ldp-only-op-progress",`${T.toFixed(1)}%`);const be=jr?h?"部分完成":"完成":re==="error"?"暂停":re==="retry"?"等待":"扫描";ir.textContent=`${be} ${p}/${i} · 楼主${y}${j}${L}${J}`,rt(Ht,`只看楼主${jr?h?"扫描结束但有缺失":"扫描完成":"压测中"},已处理 ${p}/${i} 层,找到 ${y} 条楼主内容${h?`,其中 ${h} 层未能取回`:""}${L}${J}`),Ht.setAttribute("data-ldp-tooltip-label",Ht.getAttribute("aria-label"))},AR=(i=!0)=>{bh(),(i||!Ml)&&(Ml=Date.now(),t0=Math.max(0,+(Ur.scanCursor||0))),Gu(),E.onlyOp&&!jr&&(fh=setInterval(()=>{Gu();const p=jn&&fs>0&&fs<=Date.now();E.onlyOp&&!jr&&!Cl&&!bi&&!Ks&&(!jn||p)&&Date.now()-Ml>=2e3&&td({maxBatches:1,forceOnlyOpScan:!0})},500))},PR=new Map,NR=new Map,Uq=(i,p)=>{const h=Number(i.get(+p)||0);return h?h>Date.now()?!0:(i.delete(+p),!1):!1},qt=()=>R.isCurrent("opening","active");E.releaseInitialPrecedingContent=()=>{E.initialTargetPrecedingContentDeferred&&(E.initialTargetPrecedingContentDeferred=!1,E.streamMountedNodes.forEach(i=>{+i.dataset.postNumber>=+(E.initialTargetHydrationBoundary||0)||i.dataset.childReplyTotal&&E.repliesIO&&E.repliesIO.observe&&E.repliesIO.observe(i)}),bo(E))};const a0=()=>{Vc&&clearTimeout(Vc),Du.forEach(i=>clearTimeout(i)),Du.clear(),Vc=0,ch&&dh.forEach(({channel:i,handler:p})=>{try{ch.unsubscribe(i,p)}catch{}}),ch=null,dh=[]},kR=()=>{Vc||!qt()||document.visibilityState!=="visible"||(Vc=setTimeout(()=>{Vc=0,i0({force:!0})},350))},jq=new Set(["acted","created","rebaked","recovered","revised"]),zq=i=>{const p=Number(i?.post_id||0)>0&&Array.isArray(i?.reactions),h=Number(p?i.post_id:i?.id||0);if(!h||Du.has(h)||!qt()||document.visibilityState!=="visible")return;const y=p?"acted":String(i.type||"");if(y==="read")return;if(!jq.has(y)){kR();return}const M=setTimeout(async()=>{if(Du.delete(h),!qt()||!D(E.refreshPostById))return;const T=io(h,E).length>0,L=I.scrollHeight-I.scrollTop-I.clientHeight<=120,j=Math.max(0,+(E.totalPosts||0));try{const q=await E.refreshPostById(h);if(!q||!qt())return;const re=Math.max(0,Number(q.post_number)||0),J=y==="created"&&!T&&re>j;if(re&&(E.totalPosts=Math.max(j,re),E.topicData&&(E.topicData.highest_post_number=Math.max(Number(E.topicData.highest_post_number)||0,re),q.created_at&&(E.topicData.last_posted_at=q.created_at)),wy(E,q),du(E),E.timeline.update()),!J)return;if(E.replyThreadWindow?._ldpIngestLivePost?.(q),L){Qu();const be=I.scrollTop;I.scrollTop=I.scrollHeight,xn(E,be)}else Gc=!1,yi++,(!Ys||re{if(dh.length||!E.topicData||!qt()||document.visibilityState!=="visible")return;const i=It("service:message-bus");if(D(i?.subscribe)&&D(i.unsubscribe)){const p=y=>{const M=y?.payload&&typeof y.payload=="object"?y.payload:y,T=Number(y?.topic_id||M?.topic_id||0);T&&T!==+e||zq(M)},h=[{channel:`/topic/${e}`,handler:p},{channel:`/topic/${e}/reactions`,handler:p}];try{h.forEach(({channel:y,handler:M})=>i.subscribe(y,M)),ch=i,dh=h;return}catch{h.forEach(({channel:y,handler:M})=>{try{i.unsubscribe(y,M)}catch{}})}}};se._ldpStopLatestReplyUpdates=a0;const Kc=()=>{Mr.hidden=yi<=0||Gc,qr.disabled=Ou,qr.setAttribute("aria-busy",String(Ou));const i=Mr.querySelector("span");i&&(i.textContent=`有 ${yi} 条新回复`)},Qu=()=>{yi=0,Ys=0,Gc=!1,Kc()},Wq=async()=>{if(!qt()||Ou)return;const i=Ys,p=yi;if(Qu(),!i)return;Ou=!0,Kc();let h=!1;try{if(h=await E.timeline.jumpTo(i),!h&&qt()){const y=E.streamItemMap.get(i);E.onlyOp&&!Zi(E,y)&&D(Wu)?await Wu():(E.streamWindowResetPending=!0,rl(E)),await ya(),qt()&&(h=await E.timeline.jumpTo(i))}}catch{h=!1}finally{Ou=!1,!h&&qt()&&(yi+=p,(!Ys||i{bt(i),Gc=!0,Kc()};fr(qr,"click",Wq),fr(xt,"click",Vq);const i0=async(i={})=>{const p=i.force===!0;if(lh||!qt()||document.visibilityState!=="visible")return null;const h=Date.now();if(!p&&h-mR<1e4)return null;mR=h,lh=!0;try{const y=Number.isFinite(i.priority)?i.priority:tt.background,M=Math.max(0,Number.isFinite(+i.afterPostNumber)?+i.afterPostNumber:+(E.totalPosts||0)),T=await Ur.refreshTopicData({priority:y});if(!T||!qt())return null;const L=Math.max(0,+(T.highest_post_number||0),+(T.posts_count||0)),j=new Map;Lr(T).forEach(Ue=>{const yt=+(Ue?.post_number||0);yt&&(E.streamNodeMap.has(yt)||yt>M)&&j.set(yt,Ue)});let q=M,re=0;for(;q>0&&q+(C?.post_number||0)>q);if(!yt.length)break;yt.forEach(C=>j.set(+C.post_number,C));const Bt=yt.reduce((C,oe)=>Math.max(C,+(oe.post_number||0)),q);if(Bt<=q)break;q=Bt}const be=[...j.keys()].some(Ue=>Ue>M)&&I.scrollHeight-I.scrollTop-I.clientHeight<=120,X=lu([...j.values()],E);if(X.forEach(Ue=>{E.replyThreadWindow?._ldpIngestLivePost?.(Ue)}),E.topicData=T,E.topicSharedIssueHostVisible=CC(T),E.isPostVoting=!!T.is_post_voting,E.topicBookmarked=!!T.bookmarked,E.topicNotificationLevel=Xi(T),E.totalPosts=L,wy(E,{created_at:T.last_posted_at},{force:!0}),du(E),gn(E),Nc(T),ny(E),E.timeline.update(),X.length)if(Ys||(Ys=+(X[0]?.post_number||0)),be){Qu();const Ue=I.scrollTop;I.scrollTop=I.scrollHeight,xn(E,Ue)}else Gc=!1,yi+=X.length,Kc();return{posts:[...j.values()],topic:T}}catch{return null}finally{lh=!1}};E.refreshPostByNumber=async i=>{const p=await Ur.loadPost(+i,{priority:tt.target,forceRefresh:!0});return qt()?(Ur.persistFetchedPosts(p),lu(p,E,{refreshContent:!0}),p.find(h=>+(h?.post_number||0)==+i)||p[0]||null):null},E.refreshPostById=async i=>{const p=Number(i)||0;if(!p)return null;const h=await ao(`${vt}/posts/${p}.json`,{scheduler:$a,priority:tt.visible,cacheMode:"refresh",allowStaleOnError:!1,fetchOptions:{cache:"no-store"},key:`post:${p}:live-update`});return!qt()||!h||+h.id!==p||!h.post_number?null:(Ur.persistFetchedPosts([h]),lu([h],E,{refreshContent:!0}),h)},E.refreshNativeComposerSession=async i=>{if(!i||!qt())return!1;if(i.action==="edit"){const M=await E.refreshPostByNumber(i.postNumber);return M?E.timeline.jumpTo(+M.post_number,{targetPost:M,forceNested:!0}):!1}if(i.action!=="reply")return!1;const p=+(i.parentPostNumber||0),h=+(i.highestPostNumber||0),y=[0,700,1400];for(let M=0;ML.set(+q.post_number,q)),E.streamItems.forEach(q=>{const re=q?.node&&q.node._ldpPostData;re&&+re.post_number>h&&L.set(+re.post_number,re)});const j=[...L.values()].filter(q=>+q.post_number>h).filter(q=>!Yt||q.username===Yt).filter(q=>p<=1?+(q.reply_to_post_number||0)<=1:+(q.reply_to_post_number||0)===p).sort((q,re)=>+re.post_number-+q.post_number)[0];if(j)return D(E.loader?.persistFetchedPosts)&&E.loader.persistFetchedPosts([j]),Qu(),E.timeline.jumpTo(+j.post_number,{targetPost:j,forceNested:!0})}return!1},E.bindNativeComposerRefresh=()=>hs?!0:(ms=It("service:app-events"),!ms||!D(ms.on)?!1:(hs=()=>{const i=E.nativeComposerSession;!i||i.refreshing||!qt()||(i.refreshing=!0,zu=setTimeout(async()=>{zu=0;try{await E.refreshNativeComposerSession(i)}finally{E.nativeComposerSession===i&&(E.nativeComposerSession=null)}},0))},ms.on("composer:created-post",hs),ms.on("composer:edited-post",hs),ms.on("post:created",hs),!0)),E.bindNativeComposerRefresh(),yR=()=>{if(!qt())return;const i=document.visibilityState==="visible";if(E.timeline.setActive(i),E.repliesIO?.setActive?.(i),U$(),!i){a0(),a$(),bh(),ku(),sc(se),E.streamHydratedNodes.forEach(p=>sc(p));return}E.streamMountedNodes.forEach(p=>qf(p)),LR(),i$(),E.onlyOp&&!jr&&AR(!1),jn&&!Ks&&!Cl&&!jr&&!bi&&(fs=0,td({maxBatches:1,forceOnlyOpScan:E.onlyOp})),Na&&Zu(Na<0),sh(),E.timeline.update(),E.timeline.refreshRelativeTimes(),i0({force:!0})},fr(document,"visibilitychange",yR);const IR=1500;let yh=0,Yu=0,vh=Number.NaN,wh=Number.NaN;const Jc=()=>{!yh&&!Yu||(yh=0,Yu&&clearTimeout(Yu),Yu=0,to(".ldp-selection-toast[data-ldp-reader-exit-hint]")?.remove())},Gq=()=>{Jc(),yh=Date.now()+IR,ft("再按一次 Esc 退出阅读器");const i=to(".ldp-selection-toast");i&&(i.dataset.ldpReaderExitHint="1"),Yu=setTimeout(Jc,IR)},xR=1500;let l0="",c0=0,Sh=0;const Zc=()=>{l0="",c0=0,Sh&&clearTimeout(Sh),Sh=0,to(".ldp-selection-toast[data-ldp-composer-close-hint]")?.remove()},qR=i=>l0===i&&c0>Date.now(),DR=(i,p)=>{Zc(),l0=i,c0=Date.now()+xR,ft(p);const h=to(".ldp-selection-toast");h&&(h.dataset.ldpComposerCloseHint="1"),Sh=setTimeout(Zc,xR)},Xu=(i=gh)=>{if(!(!i||i.released)){if(i.released=!0,i.appEvents&&i.appEvents.trigger===i.trigger)try{i.appEvents.trigger=i.originalTrigger}catch{}(i.routeGuards||[]).forEach(({owner:p,originalRouteTo:h,guardedRouteTo:y})=>{if(p.routeTo===y)try{p.routeTo=h}catch{}}),gh===i&&(gh=null)}},OR=()=>{const i=Xc;if(Xc=null,!i||!i.topicController||!D(i.topicController.subscribe))return;const p=ut(i.topicController,"model");if(+(ut(p,"id")||0)===i.hostTopicId)try{i.topicController.subscribe()}catch{}},Qq=()=>{const i=E.nativeComposerSession;if(!i||!["reply","edit"].includes(i.action))return null;Xu();const p=i.composer&&(i.composer.appEvents||It("service:app-events"));if(!p||!D(p.trigger))return null;const h=p.trigger,y=new Set(i.action==="edit"?["composer:edited-post"]:["composer:created-post","post:created"]),M=new Set([...y,"post:highlight"]),T={appEvents:p,originalTrigger:h,trigger:null,routeGuards:[],released:!1};T.trigger=function(X,...Ue){const yt=String(X||"");return y.has(yt)&&D(hs)&&hs(),M.has(yt)?this:h.call(this,X,...Ue)};try{p.trigger=T.trigger}catch{return null}const j=Ft("discourse/lib/url")?.default;if(D(j?.routeTo)){const X=j.routeTo,Ue=function(yt,...Bt){const C=co(String(yt||""));if(!(C&&+C.topicId==+E.topicId&&+C.postNumber>0))return X.call(this,yt,...Bt)};try{j.routeTo=Ue,j.routeTo===Ue&&T.routeGuards.push({owner:j,originalRouteTo:X,guardedRouteTo:Ue})}catch{}}const q=It("controller:topic"),re=ut(q,"model"),J=+(ut(re,"id")||0);if(!(Xc&&Xc.topicController===q&&Xc.hostTopicId===J)&&J===+E.topicId&&q&&D(q.unsubscribe)){OR();try{q.unsubscribe(),Xc={topicController:q,hostTopicId:J}}catch{}}return gh=T,T},FR=()=>{const i=E.nativeComposerSession;if(!i||!["reply","edit"].includes(i.action)||ut(i.composer,"model")!==i.model)return null;const p=document.querySelector("#reply-control");return!tn(p)||!p.matches(`.composer-action-${i.action}`)?null:p.querySelector(".save-or-cancel button.create")},BR=i=>{const h=E.nativeComposerSession?.composer;if(!i||i.disabled||!h||!D(h.save))return!1;const y=Qq();try{const M=h.save(!0,{jump:!1});Promise.resolve(M).then(()=>Xu(y),()=>Xu(y))}catch(M){Xu(y),console.warn("[LINUX DO reader] native composer submit failed",M)}return!0};RR=i=>{const p=i.target.closest?.("#reply-control .save-or-cancel button.create");!p||p!==FR()||(i.preventDefault(),i.stopImmediatePropagation(),BR(p))},TR=i=>{if(i.isComposing||!(i.ctrlKey||i.metaKey)||i.key!=="Enter"&&i.code!=="Enter")return;const p=FR();p&&(i.preventDefault(),i.stopImmediatePropagation(),BR(p))},$R=i=>{const p=i.target.closest?.("#reply-control .toggle-save-and-close, #reply-control .discard-button");if(!p||!tn(document.querySelector("#reply-control")))return;if(!Oe.confirmNativeComposerClose){Zc();return}const h=p.classList.contains("discard-button")?"discard":"save-close";if(qR(h)){Zc(),h==="discard"&&(i.preventDefault(),i.stopImmediatePropagation(),iM(E).catch(y=>{console.warn("[LINUX DO reader] native composer discard failed",y)}));return}i.preventDefault(),i.stopImmediatePropagation(),DR(h,h==="discard"?"再点一次舍弃回复":"再点一次关闭回复窗口")};const Yq=i=>{vh=i.clientX,wh=i.clientY},Xq=()=>{const i=E.scrollRoot.getBoundingClientRect();return[...E.commentsEl.querySelectorAll(":scope > .ldp-post.ldp-reply-collapsible:not(.ldp-nested-collapsed)")].map(p=>({post:p,rect:p.getBoundingClientRect()})).filter(({post:p})=>sy(p)).filter(({rect:p})=>p.bottom>i.top&&p.topi.left&&p.leftp.rect.top-h.rect.top)},d0=()=>{if(E.onlyOp)return null;const i=Xq();return(i.find(({rect:h})=>Number.isFinite(vh)&&Number.isFinite(wh)&&vh>=h.left&&vh<=h.right&&wh>=h.top&&wh<=h.bottom)||i[0]||{}).post||null},HR=i=>{Jc(),!(i.type!=="pointerdown"||i.button!==0||!qe.isEmbedded()||Hi(i,ln))&&Gs.closeFloatingPanels()};let UR=!1,jR=!1;const gs=(i,p)=>{try{p()}catch(h){console.warn(`[LDP] ${i} cleanup failed`,h)}},_h=(i={})=>{const p=i.preserveShell===!0;if(!UR){if(UR=!0,G&&Vg(G.topicId,Oi(se)),D(se._ldpCloseCodePreview)&&se._ldpCloseCodePreview(!1,!0),p){const h=EM(se,E.commentsHeader);h&&(x.commentsHeader=h)}a0(),bh(),a$(),lh=!1,gs("topic nav metadata",Dm),zu&&clearTimeout(zu),zu=0,Xu(),OR(),ms&&hs&&D(ms.off)&&(ms.off("composer:created-post",hs),ms.off("composer:edited-post",hs),ms.off("post:created",hs)),[["loader",()=>Ur.destroy()],["reader queue prefetch",bS],["request scheduler",()=>{Ao===$a&&(Ao=null),$a.destroy()}],["user card",Ry],["action dialog",()=>E.readerActionDialog?.close()],["topic editor",()=>Do?.close(!0)],["composer window",()=>E.nativeComposerWindow.destroy()],["post actions",()=>r0&&r0()],["read tracker",()=>ih.stop()],["queue viewport tracker",()=>E.queueViewportTracker.stop()],["nested replies",()=>E.repliesIO.disconnect()],["descendant replies window",()=>E.replyThreadWindow?._ldpClose?.()],["body translation",()=>E.translation.destroy()],["media players",()=>sc(se)],["detached media players",()=>E.streamHydratedNodes.forEach(h=>sc(h))],["pending content hydration",()=>[...E.pendingPostHydrations].forEach(h=>Yd(h,E))],["pending latex render",()=>[...E.pendingPostLatexRenders].forEach(h=>zf(h,E))],["jump highlight",PC],["stream viewport anchor",()=>rs(E)],["avatar placeholders",()=>Vr(se)],["resize observer",()=>E.streamResizeObserver.disconnect()],["timeline",()=>E.timeline.destroy()],["topic presence",()=>o0&&o0()]].forEach(([h,y])=>gs(h,y)),E.streamWindowFrame&&cancelAnimationFrame(E.streamWindowFrame),E.floorPreviewSyncFrame&&cancelAnimationFrame(E.floorPreviewSyncFrame),Yc&&cancelAnimationFrame(Yc),ka&&cancelAnimationFrame(ka),E.streamWindowFrame=0,E.floorPreviewSyncFrame=0,Yc=0,ka=0,Bu=0,hh&&hh(),[Xs,Ks].forEach(h=>{h&&clearTimeout(h)}),Xs=0,Ks=0,Fu=0,Na=0,Qc=1,_l.destroy(),Zc(),Jc(),x.activeContext===E&&(x.activeContext=null),[E.streamItems,E.streamLayoutItems].forEach(h=>{h.length=0}),[E.streamItemMap,E.streamMountedNodes,E.streamHydratedNodes,E.pendingPostHydrations,E.pendingPostLatexRenders,E.streamNodeMap,E.subReplyState,E.directRepliesByParent,E.confirmedReadPostNumbers,E.replyParentByPost].forEach(h=>h.clear())}!p&&!jR&&(jR=!0,se.dataset.readerShellDestroyed="1",gs("avatar observers",()=>{Vr(de.list),Vr(ge.list),Vr(Re.list)}),gs("resource monitor view",Dv),gs("request flow view",ku),x.cancelShellFrames&&x.cancelShellFrames(),gs("notification live events",()=>x.stopNotificationLiveEvents?.()),gs("shell events",()=>me.destroy()),gs("reader window",()=>x.readerWindow.destroy()),gs("reader workspace",()=>x.readerWorkspace.destroy()),gs("reader queue surface",()=>se._ldpDestroyReaderQueueSurface?.()),gs("surface manager",()=>x.surfaceManager.destroy()))};let Ku=null,ed=0,zR=0,WR=0;const u0=()=>!qo.isPinned()&&qe.isFloating()&&window.innerWidth>Ss,VR=(i,p)=>{let h;try{se.classList.add("ldp-reader-hit-test-hidden"),h=document.elementFromPoint(i,p)}finally{se.classList.remove("ldp-reader-hit-test-hidden")}const y=h?.closest("a[href]"),M=y&&!y.classList.contains("ldp-open")&&!hm(y.href)?co(y.getAttribute("href")||y.href):null;return M?{link:y,route:M}:null},GR=i=>{Ku!==i&&(Ku?.classList.remove("ldp-reader-switch-target"),Ku=i||null,Ku?.classList.add("ldp-reader-switch-target"),se.classList.toggle("ldp-reader-switch-ready",!!Ku))},Ch=()=>{ed&&cancelAnimationFrame(ed),ed=0,GR(null)},Kq=()=>{ed=0;const i=u0()?VR(zR,WR):null;GR(i?.link)},Jq=i=>{if(i.target!==se||i.pointerType==="touch"||!u0()){Ch();return}zR=i.clientX,WR=i.clientY,ed||(ed=requestAnimationFrame(Kq))};let Eh=null;const QR=()=>{let i;try{i=WL(E)}catch(p){console.warn("[LINUX DO reader] native composer draft save setup failed",p),i=Promise.resolve(!1)}return Promise.resolve(i).catch(p=>(console.warn("[LINUX DO reader] native composer draft save cleanup failed",p),!1))},Zq=()=>{if(Eh)return Eh;if(Uu)return Promise.resolve(!1);Vu(),Ch(),R.state="switching";const i=QR();return fR(),_h({preserveShell:!0}),v(),se._ldpCaptureHistoryViewport=()=>null,se._ldpCaptureHistoryPostNumber=()=>1,Eh=i,Eh},Ju=(i={})=>{if(mh)return mh;if(Uu)return Promise.resolve();D(se._ldpCancelAutoRetryWait)&&se._ldpCancelAutoRetryWait(),Vu(),Ch(),Uu=!0,R.state="closing";const p=QR();return fR(),_h(),v(),bn(se),Ct===se&&(Ct=null,tm()),i.suppressNavigation||mS(e),queueMicrotask(PP),pm===Me&&(pm=0),Lt.classList.remove("ldp-reader-open"),vf(!1),R.state="closed",i.preserveDocumentTitle||fI(),i.suppressNavigation||(vn=String(e),Lt.classList.remove("ldp-route-takeover"),Fs()),mh=p,mh};se._ldpDispose=()=>Ju({suppressNavigation:!0,preserveDocumentTitle:!0}),se._ldpPrepareTopicSwitch=Zq;function eD(i){if(i.key!=="Escape"){Jc();return}if(to(".ldp-lightbox"))return;if(i.repeat){i.preventDefault(),i.stopImmediatePropagation();return}const p=yh>Date.now();Jc();const h=document.body?.querySelector(":scope > .ldp-native-boost-menu"),y=[...document.querySelectorAll(".emoji-picker")].find(tn);if(h&&y&&D(h._ldpCloseEmojiPicker)){i.preventDefault(),i.stopImmediatePropagation(),h._ldpCloseEmojiPicker();return}if(eM())return;if(D(h?._ldpClose)){i.preventDefault(),i.stopImmediatePropagation(),h._ldpClose();return}const T=document.querySelector("#reply-control");if(tn(T)){i.preventDefault(),i.stopImmediatePropagation(),!Oe.confirmNativeComposerClose||qR("escape")?(Zc(),iM(E).catch(re=>{console.warn("[LINUX DO reader] native composer discard failed",re)})):DR("escape","再按一次 Esc 舍弃回复");return}if(!k.hidden&&E.replyThreadWindow?.isConnected){bt(i),Dt.hidden?Gs.requestCloseSettings():Gs.closeFontFamilyMenu(!0);return}const L=E.replyThreadWindow?.querySelector(".ldp-floor-preview.open");if(L){bt(i),E.floorPreview?.hide(L._ldpRelatedPost);return}if(E.replyThreadWindow?.isConnected){bt(i),E.replyThreadWindow._ldpBack?.()||(E.replyThreadWindow._ldpCloseFromEscape||E.replyThreadWindow._ldpClose)?.();return}if(D(se._ldpCloseCodePreview)){i.preventDefault(),i.stopImmediatePropagation(),se._ldpCloseCodePreview();return}if(Do){bt(i),(!Do.closeTransient||!Do.closeTransient())&&Do.close();return}if(E.readerActionDialog){bt(i),E.readerActionDialog.close();return}if(D(E.closeTransientActions)&&E.closeTransientActions()){bt(i);return}if(qn()){bt(i),D(E.closeUserCardLayer)?E.closeUserCardLayer():D(E.closeUserCard)?E.closeUserCard():Ry();return}if(E.floorPreview?.isOpen()&&E.floorPreview.isVisibleInViewport()){bt(i),E.floorPreview.hide(),d0()&&ft("再按 Esc 收起楼层:优先鼠标所在位置,否则从上往下");return}const j=d0();if(j){bt(i),_o(j,!0);const re=d0();ft(re?`已收起 #${j.dataset.postNumber||""} · Esc 继续收起 #${re.dataset.postNumber||""}`:`已收起楼层 #${j.dataset.postNumber||""}`);return}const q=[[Le,Gs.closeNotifications],[ge.popover,Gs.closeHistory],[Re.popover,Gs.closeBookmarks],[Dt,()=>Gs.closeFontFamilyMenu(!0)],[k,Gs.requestCloseSettings]].find(([re])=>!re.hidden);if(q){bt(i),q[1]();return}i.preventDefault(),i.stopImmediatePropagation(),!Oe.doubleEscapeToCloseReader||p?Ju():Gq()}const Js=Z(".ldp-reader-refresh");Js.disabled=R.state!=="active",CR=async()=>{if(!(Js.disabled||R.state!=="active")){Js.disabled=!0,Js.classList.add("is-refreshing");try{const i=Oi(se,Number(se._ldpCaptureHistoryPostNumber?.())||1),p=i?.viewport.postNumber||1,h=E.onlyOp===!0,y=E.topicData;D(se._ldpPrepareTopicSwitch)&&await se._ldpPrepareTopicSwitch(),await RP(e,y),await lo(e,{targetPostNumber:p,restoreOnlyOp:h,restoreState:i||void 0,topicNavMetadata:V,historyNavigation:te})}finally{Js.classList.remove("is-refreshing"),Js.isConnected&&R.state==="active"&&(Js.disabled=!1)}}},fr(Js,"click",CR),s0=()=>Ju(),fr(Z(".ldp-close"),"click",s0),fr(se,"pointermove",Jq);const p0=i=>{Bu=i,!ka&&(ka=requestAnimationFrame(()=>{ka=0,Bu=0}))};ER=i=>{if(i.target!==se)return;if(Do){Do.close();return}if(!u0())return;const p=VR(i.clientX,i.clientY);if(p){Ch(),vn="",ci(),lo(p.route.topicId,{targetPostNumber:mu(p.route),topicNavMetadata:ma(p.link)});return}Ju()},fr(se,"click",ER),MR=i=>{const p=i.target.closest?.(".ldp-topic-action-rail,.ldp-reader-action-layer:not(.ldp-topic-edit-layer)");if(p){Rd(p,i);return}if(Le.contains(i.target)||ge.popover.contains(i.target)||Re.popover.contains(i.target))return;if(i.target.closest?.(".ldp-topic-edit-layer")){i.preventDefault();const T=i.target.closest(".ldp-topic-edit-category-menu"),L=T?.querySelector(".ldp-topic-edit-category-options");if(L&&!T.hidden){const j=i.deltaMode===1?i.deltaY*40:i.deltaMode===2?i.deltaY*L.clientHeight:i.deltaY;L.scrollTop+=j}return}if(i.ctrlKey)return;E.timeline?.releaseJump&&E.timeline.releaseJump();const y=i.deltaMode===1?i.deltaY*40:i.deltaMode===2?i.deltaY*I.clientHeight:i.deltaY;let M=i.target instanceof Element?i.target:null;for(;M&&M!==I;){const T=M.scrollHeight-M.clientHeight;if(T>1&&/(auto|scroll|overlay)/.test(getComputedStyle(M).overflowY)&&(y<0?M.scrollTop>0:M.scrollTop0&&(E.streamScrollDirection=1,LC(E)),y&&p0(y<0?-1:1),!I.contains(i.target)&&(i.preventDefault(),I.scrollTop+=y)},fr(S,"wheel",MR,{passive:!1}),fr(document,"pointerdown",HR,!0),fr(document,"scroll",HR,{capture:!0,passive:!0}),fr(document,"click",RR,!0),fr(document,"keydown",TR,!0),fr(document,"click",$R,!0),fr(document,"keydown",eD,!0),fr(se,"pointermove",Yq);const f0=()=>I.scrollTop+I.clientHeight>=I.scrollHeight-300,td=async(i={})=>{if(Cl||bi||!qt())return;Cl=!0;const p=i.maxBatches??1,h=i.keepFilling===!0,y=E.onlyOp,M=i.forceOnlyOpScan===!0&&y;y&&(Hu=Date.now());let T=0,L=0,j=180;jr||(wr.classList.remove("show"),Pr.classList.add("show"));try{for(;!jr&&T0);){jn=!1;const{posts:q,done:re,retry:J,fatal:be,error:X}=await Ur.next({onlyOp:y,onlyOpUsername:y?E.op:"",persistFetchedPosts:i.persistFetchedPosts===!0,onSource:(Ue,yt)=>Sr(Ue,yt)});if(!qt()||(q.length&&Sr("render"),await jk(q,E,{acceptsWork:qt}),!qt()))return;if(E.onlyOp&&Gu(),be){bi=!0,qu=X||null,jn=!1;break}if(J){jn=!0,qu=X||null;const Ue=Number(X?.status||0),yt=Math.max(0,Number(X?.retryAt||0)-Date.now());if(y&&El++,j=Ue===429||X&&X.name==="AbortError"?Math.max(250,yt):Ue===408||Ue>=500?1e3:400,Zn(X)||L>=1)break;L++,await Ji(400);continue}qu=null,El=0,fs=0,j=180,T++,E.onlyOp===y&&(jr=re)}}catch(q){if(qt())if(qu=q||new Error("请求流异常停止"),!!q&&(q.name==="AbortError"||q.name==="TypeError"||Number(q.status||0)===408||Number(q.status||0)===429||Number(q.status||0)>=500)){jn=!0,y&&El++;const J=Math.max(0,Number(q?.retryAt||0)-Date.now());j=Math.min(J>0?Ew:15e3,Math.max(J,1e3*Math.pow(2,Math.min(4,Math.max(0,El-1)))))}else bi=y,jn=!y,j=1e3,console.warn(`[LINUX DO reader] request pump ${y?"stopped":"will retry"} after unexpected error`,q)}finally{Cl=!1,y&&(Hu=0),qt()&&(Pr.classList.remove("show"),wr.classList.toggle("show",jr),E.onlyOp&&Gu(),(E.onlyOp||jn)&&!jr&&!bi&&!Ks?(E.onlyOp&&(fs=Date.now()+j),Ks=setTimeout(()=>{if(Ks=0,document.visibilityState!=="visible"){fs=Date.now();return}fs=0,qt()&&td({maxBatches:1,forceOnlyOpScan:E.onlyOp})},j)):(jr||bi||!E.onlyOp)&&(fs=0))}},YR=(i,p)=>{if(E.isPostVoting)return!1;const h=+(i&&i.dataset.postNumber),y=p?PR:NR;if(!h||p&&h<=1||Uq(y,h))return!1;const M=E.streamItemMap.get(h),T=Math.max(1,E.totalPosts||1);if(E.onlyOp){const q=jo(E),re=q[p?0:q.length-1],J=E.streamItems[p?0:E.streamItems.length-1];return M&&M===re&&J&&(p?J.postNumber>1:J.postNumber=0&&L1:j.postNumber-h>1:p||h{const p=jo(E),h=uc(E).lookahead,y=Math.max(E.streamWindowStart,E.streamViewportTopIndex-(i?h:1)),M=Math.min(E.streamWindowEnd,E.streamViewportBottomIndex+(i?2:h+1));for(let T=y;T{const y=i?Kv:Jv,M=i?Zv:e0;if(y||!qt()||Date.now()YR(X,i),L=()=>tD(i);let j=p&&(!i||p.isConnected)&&T(p)?p:L();if(i&&!j&&E.streamScrollDirection<0&&!E.streamItemMap.has(1)&&(E.streamNeedsRepair=!0,rl(E),j=L()),!j)return!1;i&&os(E);const q=Math.max(1,Math.min(2,+(h.maxBatches||1)));let re=+j.dataset.postNumber,J=0,be=0;i?Kv=!0:Jv=!0;try{for(let X=0;X0:E.streamScrollDirection<0;if(X>0&&(Ue||uc(E).batches<2))break;const yt=await Ur[i?"loadBeforePost":"loadAfterPost"](re,{persistFetchedPosts:h.persistFetchedPosts===!0});if(i?Zv=0:e0=0,!qt()||i&&E.streamScrollDirection>=0)return!1;const Bt=yt.filter(lt=>lt&&!E.streamNodeMap.has(+lt.post_number));if(!Bt.length){(i?PR:NR).set(re,Date.now()+5e3),i&&(be=5100,ph=Date.now()+be);break}const C=Bt.slice().sort((lt,At)=>(lt.post_number||0)-(At.post_number||0));i?(E.initialTargetHeadSpacerHeight=0,gc(C,E,{syncStreamNow:!0,preserveViewportAnchor:!0}),uh=0,ph=0):gc(C,E),J+=C.length;const Te=+(C[i?0:C.length-1]?.post_number||0),et=E.streamNodeMap.get(Te),gt=i?Tere;if(!Te||!gt||!et||!T(et))break;re=Te}return J>0}catch{i?(Zv=Date.now()+3e3,be=3100,ph=Date.now()+be):e0=Date.now()+3e3}finally{i?Kv=!1:Jv=!1,i&&be&&E.streamScrollDirection<0&&!E.streamItemMap.has(1)&&uh<2&&(uh++,Zu(!0,be))}return!1},KR=(i=null,p={})=>XR(!0,i,p),JR=(i=null,p={})=>XR(!1,i,p),Zu=(i,p=0)=>{const h=i?-1:1,y=uc(E),M=!!Na&&Na!==h;Na=h,Qc=Math.max(M?1:Qc,y.batches);const T=h<0?Math.max(0,ph-Date.now()):0,L=Date.now()+Math.max(T,Math.max(0,+p||0));Xs&&!M&&L>=Fu||(Xs&&clearTimeout(Xs),Fu=L,Xs=setTimeout(()=>{if(Xs=0,Fu=0,!qt()||document.visibilityState!=="visible")return;const j=Na,q=Qc;if(Na=0,Qc=1,j<0)KR(null,{maxBatches:q});else if(j>0){if(Cl||f0())return;JR(null,{maxBatches:q})}},Math.max(0,L-Date.now())))};E.loadPost=async(i,p={})=>{if(!qt()||(i=+i,!i))return!1;const h=p.scope==="around",y=p.mount!==!1,M=p.ensureAround===!0&&i>1,T=E.streamNodeMap.has(i);if(T&&p.forceRefresh!==!0&&(!h||!M))return y?!!zo(E,i,{mount:!0}):!0;let L=[];try{L=await Ur.loadPost(i,{onSource:p.onSource,priority:Number.isFinite(p.priority)?p.priority:h?tt.target:void 0,scope:h?"around":"single",forceRefresh:p.forceRefresh===!0})}catch(q){if(!h||!T||p.forceRefresh===!0)throw q}if(!qt())return!1;const j=h?L.slice().sort((q,re)=>(q.post_number||0)-(re.post_number||0)):L.filter(q=>q&&!E.streamNodeMap.has(+q.post_number));return gc(j,E),y?!!zo(E,i,{mount:!0}):E.streamNodeMap.has(i)};const rD=i=>{const p=+i||0;return p?Ur.cachedPosts.reduce((h,y)=>{const M=+(y?.post_number||0);if(!M)return h;if(!h)return y;const T=+(h.post_number||0),L=Math.abs(M-p),j=Math.abs(T-p);return Lp&&T{zt.classList.toggle("active",E.onlyOp),go(zt,E.onlyOp),rt(zt,E.onlyOp?"显示全部楼层":"只看楼主")};Wu=async()=>{if(!qt()||zt.disabled||!E.op)return;rs(E),E.onlyOp=!E.onlyOp,jn=!1,jr=E.onlyOp?Ur.scanDone:Ur.loadDone,!E.onlyOp&&Ks&&(clearTimeout(Ks),Ks=0),E.onlyOp||(Hu=0,fs=0,El=0),ZR(),va(E),E.onlyOp?AR():Gu(),E.streamWindowResetPending=!0,E.streamMountedItemCount=-1;const i=I.scrollTop;if(I.scrollTop=0,xn(E,i),E.onlyOp&&!E.streamItemMap.has(1)){if(await E.loadPost(1,{priority:tt.target}),!qt())return;va(E)}pc(E),E.timeline.update(),E.onlyOp&&td({maxBatches:1,forceOnlyOpScan:!0})},fr(zt,"click",Wu);try{ne("load-topic");const i=await Ax;if(!qt())return;ne("initialize-topic"),E.topicData=i,ih.preload(Lr(i)),E.topicSharedIssueHostVisible=!Ur.initializedFromCache&&CC(i),Lr(i).forEach(X=>ii(E,X)),E.isPostVoting=!!i.is_post_voting,E.topicBookmarked=!!i.bookmarked,E.topicNotificationLevel=Xi(i),E.latestReplyAt=String(i.last_posted_at||""),E.latestReplyTimestamp=Date.parse(E.latestReplyAt)||0,E.op=i._opUsername,zt.disabled=!E.op,ZR(),E.totalPosts=Math.max(0,i.highest_post_number||0,i.posts_count||0),Nc(i),E.syncTopicActionRail(),du(E),gn(E),Ps(),qt()&&(x.startNotificationLiveEvents?.(),gn(E),ny(E),Ge(".ldp-post").forEach(X=>{Vd(X,E),X.querySelector(":scope > .ldp-boost-list:not(.ldp-boost-pending)")&&lm(X,X._ldpPostData||{},E)})),qt()&&LR();const p=Z(".ldp-title-jump");let h=!1;E.cancelInitialTargetPosition=()=>{h=!0,E.initialTargetPositionPending=!1,jf(E),E.releaseInitialPrecedingContent()},wR=async()=>{qt()&&(E.replyThreadWindow?._ldpClose?.(!1),await E.timeline.jumpToStart())},fr(p,"click",wR),SR=X=>{X.key!=="Enter"&&X.key!==" "||(X.preventDefault(),p.click())},fr(p,"keydown",SR),_R=async()=>{qt()&&await E.timeline.jumpToStart()},fr(Tr.querySelector(".ldp-topic-timeline-top"),"click",_R);const y=Z(".ldp-open");y.href=qd(i.id,0,!0)||`${vt}/t/${i.id}?${Nl}=1`,y.hidden=!1,rt(y,"打开原帖"),n0=X=>{X.type==="click"&&X.button&&X.button!==0||X.type==="auxclick"&&X.button!==1||wI(y.href)&&bt(X)},fr(y,"click",n0),fr(y,"auxclick",n0),E.topicBookmarked=!!i.bookmarked,gn(E),E.toggleTopicBookmark=()=>bM(i,E,"Topic"),o0=cI(E),r0=iI(S,E),tm(),ih.start();let M=I.scrollTop,T=Date.now();E.streamInternalScrollTop=NaN,E.streamInternalScrollDelta=0,hR=()=>{if(!qt())return;const X=Date.now(),Ue=I.scrollTop,yt=Ue-M,Bt=Math.max(16,X-T);M=Ue,T=X;const C=E.streamInternalScrollTop,oe=Number.isFinite(E.streamInternalScrollDelta)?E.streamInternalScrollDelta:0;E.streamInternalScrollTop=NaN,E.streamInternalScrollDelta=0;let Te=yt-oe;if(Math.abs(Te)<1&&(Te=0),(Math.abs(oe)>=.01||Number.isFinite(C)&&Math.abs(Ue-C)<1)&&!Te){bo(E);return}if(E.initialTargetPositionPending){bo(E);return}Te&&R.state==="active"&&(E.viewportPersistenceEnabled=!0);const gt=Te<0?-1:Te>0?1:0,lt=!!gt&&Bu===gt;lt&&(Bu=0,ka&&cancelAnimationFrame(ka),ka=0),gt&&(E.streamScrollDirection=gt,lt||LC(E),gt<0?lt||(jf(E),E.releaseInitialPrecedingContent()):uh=0);const At=!gt||gt===E.streamScrollDirection;if(vR=E.streamScrollDirection<0,At){const Vt=Math.abs(Te)/Bt;E.streamScrollVelocity=E.streamScrollVelocity*.55+Vt*.45}E.streamLastScrollAt=X,bo(E),!Yc&&(Yc=requestAnimationFrame(()=>{if(Yc=0,!!qt()&&(yi>0&&I.scrollHeight-I.scrollTop-I.clientHeight<=80&&Qu(),I.scrollTop<=10&&!E.streamItemMap.has(1)?(Na=0,Qc=1,Xs&&(clearTimeout(Xs),Xs=0,Fu=0),KR(null,{maxBatches:uc(E).batches})):Zu(vR),f0())){const Vt=uc(E);td({maxBatches:Vt.batches,keepFilling:Vt.batches>1})}}))},fr(I,"scroll",hR,{passive:!0}),gR=()=>{E.timeline?.releaseJump&&E.timeline.releaseJump(),E.streamScrollDirection=0,os(E),E.releaseInitialPrecedingContent()},fr(I,"touchstart",gR,{passive:!0}),bR=X=>{["ArrowUp","PageUp","Home"].includes(X.key)||X.key===" "&&X.shiftKey?(E.timeline?.releaseJump&&E.timeline.releaseJump(),E.streamScrollDirection=-1,os(E),p0(-1),jf(E),E.releaseInitialPrecedingContent(),Zu(!0)):(["ArrowDown","PageDown","End"].includes(X.key)||X.key===" "&&!X.shiftKey)&&(E.timeline?.releaseJump&&E.timeline.releaseJump(),E.streamScrollDirection=1,os(E),p0(1))},fr(se,"keydown",bR),ne("load-target-post");let L=!1;const j=X=>Sr(X,{target:!0});if(L=await E.loadPost(Math.max(1,a),{priority:tt.target,onSource:j}),a>1&&!L&&(L=await E.loadPost(a,{onSource:j,scope:"around"})),a>1&&!L&&!h&&(L=await E.loadPost(a,{priority:tt.target,forceRefresh:!0,onSource:j})),d&&a>0&&!h){if(ne("load-target-boosts"),L=await E.loadPost(a,{ensureAround:!0,priority:tt.target,forceRefresh:!0,onSource:j,scope:"around"})||L,!qt())return;const X=E.streamNodeMap.get(a);X&&lm(X,X._ldpPostData||{},E)}if(a>1&&!L&&!h){const X=rD(a),Ue=+(X?.post_number||0);if(Ue&&(c=a,gc([X],E),a=Ue,L=!!zo(E,a,{mount:!0}),se.dataset.targetPostNumber=String(a),x.targetPostNumber=a,E.initialTargetHydrationBoundary=a,b&&+b.postNumber===c&&(b={...b,postNumber:a,postOffset:0})),!L)throw new Error(`目标楼层 #${a} 加载失败,请重试`)}if(a>1&&L&&!h&&!d&&(ne("load-target-neighborhood"),await E.loadPost(a,{ensureAround:!0,priority:tt.visible,onSource:j,scope:"around"}),!qt()))return;const q=E.streamNodeMap.get(a);l=a>1&&+(q?._ldpPostData?.reply_to_post_number||0)>0?a:0,a<=1&&!L&&await td({maxBatches:1,persistFetchedPosts:!0}),ne("mount-initial-stream"),Sr("render"),a>1&&L&&!h&&(E.releaseInitialPrecedingContent(),UC(E,a)),pc(E);let re=null;const J=m&&a?()=>(re=Vb(a,E,m.text,{source:m.source,active:m.active}),re):null,be=Math.max(1,+a||1);if(a>1){if(L&&!h&&!l){const X=!m&&+a==+(E.totalPosts||0),Ue={...Ls,resolveVisualTarget:J,alignNearest:!!m,alignBottom:X,alignTop:!X};if(!Is(a,E,Ue))throw new Error(`目标楼层 #${a} 定位失败,请重试`);os(E)}se.classList.remove("ldp-initial-target-layout"),E.initialTargetPositionPending=!1}else if(E.initialTargetPositionPending=!1,!Is(be,E,{...Ls,resolveVisualTarget:J,alignNearest:!!m,alignTop:!0,highlight:!!m}))throw new Error(`目标楼层 #${be} 定位失败,请重试`);if(L&&(a>1||E.totalPosts>be)&&(hh=Wp(async()=>{if(hh=null,!qt())return;const X=E.streamNodeMap.get(be);!X||!X.isConnected||await JR(X)},1400)),!qt()||(w&&!E.onlyOp&&E.op&&await Wu(),!qt()))return;if(b&&!l){const X=Math.max(1,Number(b.postNumber)||a||1),Ue=zo(E,X,{mount:!0}),yt=ly(Ue,E,b.postOffset);if(!(!!Ue&&Is(X,E,{...Ls,visualNode:Ue,alignOffset:yt,highlight:!1})))throw new Error(`历史位置 #${X} 恢复失败,请重试`);pc(E),os(E)}if(!qt())return;if(l&&!await E.timeline.jumpTo(l,{targetPost:q?._ldpPostData}))throw new Error(`目标楼层 #${l} 完整讨论定位失败,请重试`);if(!qt())return;if(A&&!l){if(await ck(se),!qt())return;E.timeline.sync();const X=b?Math.max(1,Number(b.postNumber)||a||1):a;let Ue=!b&&re&&re.isConnected?re:zo(E,X);if(X>0&&!iy(Ue,E)){const yt=zo(E,X,{mount:!0}),Bt=!b&&re&&re.isConnected?re:yt,C=b?ly(Bt,E,b.postOffset):NaN,oe=!b&&!m&&+X==+(E.totalPosts||0);if(yt&&Is(X,E,{...Ls,visualNode:Bt,resolveVisualTarget:b?null:J,alignOffset:C,alignNearest:!b&&!!m,alignBottom:oe,alignTop:!b&&!m&&!oe,highlight:!1})){if(os(E),await ya(),!qt())return;E.timeline.sync(),Ue=!b&&re&&re.isConnected?re:zo(E,X)}}if(X>0&&!iy(Ue,E))throw new Error(`目标楼层 #${X} 布局稳定校验失败,请重试`)}if(ne("activate-reader"),R.state="active",o>0&&o!==Number(e)&&mS(o),Js.disabled=!1,g&&await pM(g,E),E.timeline.update(),bn(Mt),u?.replyWindow&&qt())try{E.replyThreadWindow?._ldpClose?.(!1),await Ny(u.replyWindow.rootPostNumber,E,{returnViewport:b,restorePoint:u.replyWindow.point})}catch(X){console.warn("[LDP] reader discussion restore failed",X)}c&&ft(`楼层 #${c} 已不可用,已跳到相邻楼层 #${a}`),qt()&&Fg(i,a||1,G?.seenPostNumbers)}catch(i){if(Uu||r!==uu||!R.isCurrent("opening")||!se.isConnected)return;const p=we;if(ik(i,p)){const be=s+1,X=lk(be,i);console.warn("[LDP] reader open interrupted; retrying automatically",{phase:p,topicId:String(e||""),targetPostNumber:a,sessionId:Me,attempt:be,retryDelay:X}),R.state="retrying",se.classList.remove("ldp-initial-target-layout"),E.initialTargetPositionPending=!1,Js.disabled=!0,_h({preserveShell:!0}),Nt&&(Nt.textContent="载入暂时中断,正在自动重试"),Ir&&(Ir.textContent=`第 ${be} 次恢复将在 ${Math.max(1,Math.ceil(X/1e3))} 秒内开始…`),jt&&rt(jt,`载入暂时中断,第 ${be} 次自动恢复即将开始`),Vu();const Ue=Z(".ldp-close"),yt=()=>{Vu(),Ue?.removeEventListener("click",Bt),se._ldpCancelAutoRetryWait===yt&&delete se._ldpCancelAutoRetryWait},Bt=()=>{yt(),Ju()};se._ldpCancelAutoRetryWait=yt,Ue?.addEventListener("click",Bt),ju=setTimeout(()=>{yt(),!(Uu||!R.isCurrent("retrying")||!se.isConnected)&&Promise.resolve(lo(e,{targetPostNumber:a,topicNavMetadata:V,restoreViewport:b,restoreState:u||void 0,restoreOnlyOp:w,historyNavigation:te,settleTargetJump:A,quoteHighlight:m,quoteSourceTarget:g,refreshTargetBoosts:d,autoRetryAttempt:be})).catch(C=>{console.warn("[LDP] reader automatic retry setup failed",C)})},X);return}console.error("[LDP] reader open failed",{phase:p,topicId:String(e||""),targetPostNumber:a,sessionId:Me},i),R.state="failed",se.classList.remove("ldp-initial-target-layout"),E.initialTargetPositionPending=!1;const y=Number(i?.status||0)===429&&i&&i.rateLimitScope==="endpoint",M=Number(i?.status||0)===429&&!y;_h({preserveShell:!0}),bn(Mt);const T=ke("div");T.className="ldp-error",T.setAttribute("role","alert"),T.innerHTML=`
+ `,/^[0-9a-f]{6}$/i.test(Si)&&kr.querySelector(".ldp-topic-edit-category-dot").style.setProperty("--ldp-category-color",`#${Si}`),ut.appendChild(kr)}),Z.replaceChildren(ut),R0()},Dh=ut=>{Oo=ut,C.disabled=ut,$.disabled=ut||!Er,B.disabled=ut||!Er,J.disabled=ut||!Er,Ve.disabled=ut||!Er,gt.disabled=ut,Ht.disabled=ut,ut&&Nl()},$0=(ut=!0)=>{const or=String(J.value||"").replace(/[,,]\s*$/,"").trim();if(!or)return J.value="",!0;const vr=Ih(or);return Ke.get(vr)?(Je.add(vr),J.value="",je.textContent="",xh(),lp(),!0):(ut&&(je.textContent=`没有找到 label“${or}”`),!1)},Rq=ut=>{ut.key!=="Enter"&&ut.key!==","&&ut.key!==","||(ut.preventDefault(),$0())},$q=()=>lp(),Tq=()=>$0(!1),Aq=ut=>{const or=ut.target.closest(".ldp-topic-edit-label-remove");!or||Oo||(Je.delete(or.dataset.labelKey||""),xh(),lp(),J.focus())},Pq=()=>{j.hidden?pT():Nl(!0)},Nq=ut=>{ut.key==="ArrowDown"&&(ut.preventDefault(),pT())},kq=ut=>{const or=ut.target.closest(".ldp-topic-edit-category-option");!or||Oo||(Pt=Number(or.dataset.categoryId)||0,R0(),Nl(!0),lp())},Lq=()=>{const ut=String(B.value||"").trim().toLocaleLowerCase();Z.querySelectorAll(".ldp-topic-edit-category-option").forEach(or=>{or.hidden=!!ut&&!String(or.dataset.searchText||"").includes(ut)})},Iq=ut=>{jS(ut,B,Z,".ldp-topic-edit-category-option:not([hidden])",Nl)},xq=ut=>{if(ut.target===h){qh();return}ut.target.closest(".ldp-topic-edit-category")||Nl()},qh=(ut=!1)=>{if(oa||Oo&&!ut)return;oa=!0,ud++;const vr=Bt("@ember/runloop")?.cancel;pd&&D(vr)&&vr(pd),pd=null,Nl(),h.hidden=!0,sr(p,!1),an.destroy(),qo===Oh&&(qo=null),!ut&&!p.hidden&&p.focus()},fT=()=>qh(),Dq=async ut=>{if(ut.preventDefault(),Oo||!Er||!$0())return;const or=String(C.value||"").trim(),vr=Pt;if(!or){je.textContent="标题不能为空",C.focus();return}const kr=Tt.get(vr);if(!kr){je.textContent="请选择类别",$.focus();return}if(!E.topicData||!E.topicData.details||E.topicData.details.can_edit!==!0){je.textContent="当前账号没有编辑该帖子的权限";return}Dh(!0),je.textContent="正在保存…";const Si=M0(),cp={title:or,category_id:vr,tags:Si.map(na=>Object.assign({name:na.name},+na.id>0?{id:+na.id}:{}))};try{const _i=Bt("discourse/models/topic")?.default;if(!_i||!D(_i.create)||!D(_i.update))throw new Error("Discourse 主题编辑接口尚未就绪");const kl=_i.create({...E.topicData,id:Number(e),slug:String(E.topicData.slug||"")});await _i.update(kl,cp,{fastEdit:!0}),await go({tags:[`topic:${e}`]});let fd=null;try{fd=await jr.refreshTopicData({priority:rt.visible})}catch{}const nr=Object.assign({},E.topicData||{},{title:pt(kl,"title"),fancy_title:pt(kl,"fancy_title"),tags:pt(kl,"tags")},fd||{});nr.title=String(nr.title||or),nr.fancy_title=String(fd?.fancy_title||nr.fancy_title||nr.title),nr.category_id=vr,nr.category_name=Jn(kr.name),nr.category_level=ai(kr.name),nr.category_slug=String(kr.slug||""),nr.tags=Si.map(md=>md.name),zm={categoryName:nr.category_name,categoryLevel:nr.category_level,categorySlug:nr.category_slug,categoryId:vr,categoryIconHtml:Number(K.categoryId)===vr?K.categoryIconHtml:"",tags:nr.tags.slice(),tagIcons:(Array.isArray(K.tagIcons)?K.tagIcons:[]).filter(md=>nr.tags.includes(Hr(md?.name)))},K=Ns(zm,Qd(nr)),E.topicData=nr,ao(e,nr),Vg(nr,0,un(e)?.seenPostNumbers),Oo=!1,qh(!0),Bc(nr),bn(E),mt("帖子信息已更新")}catch(na){je.textContent=na?.message?na.message:"保存失败,请重试",Dh(!1)}},Oh={close:qh,closeTransient:()=>j.hidden?!1:(Nl(!0),!0)};qo=Oh,C.value=A,Tt.set(se,{id:se,name:Jn(i.category_name||K.categoryName||"当前类别"),slug:String(i.category_slug||K.categorySlug||"")}),Z.replaceChildren(),R0(),Q.innerHTML="",J.value="",je.textContent="正在加载类别和 label…",xh(),Dh(!1),h.hidden=!1,sr(p,!0),an.listen(J,"keydown",Rq),an.listen(J,"input",$q),an.listen(J,"change",Tq),an.listen(ye,"click",Aq),an.listen($,"click",Pq),an.listen($,"keydown",Nq),an.listen(Z,"click",kq),an.listen(B,"input",Lq),an.listen(j,"keydown",Iq),an.listen(h,"click",xq),an.listen(y,"submit",Dq),an.listen(gt,"click",fT),an.listen(Ht,"click",fT),C.focus(),C.select(),nq().then(({categories:ut})=>{if(oa||qo!==Oh)return;const or=new Set(ut.map(kr=>+kr.id)),vr=ut.slice();se&&!or.has(se)&&vr.unshift({id:se,name:Jn(i.category_name||K.categoryName||"当前类别"),slug:String(i.category_slug||K.categorySlug||"")}),Tt.clear(),vr.forEach(kr=>Tt.set(+kr.id,kr)),Mq(),Er=!0,je.textContent="",xh(),Dh(!1),lp()}).catch(ut=>{oa||qo!==Oh||(je.textContent=`类别和 label 加载失败:${ut?.message?ut.message:"请重试"}`)})};mr(X(".ldp-topic-edit-trigger"),"click",sq),ne._ldpCaptureHistoryViewport=()=>{oq.sync();const i=_m(E)||{},p=Number(Ar.querySelector(".ldp-topic-timeline-track")?.getAttribute("aria-valuenow")),h=OM(E,p);return of({postNumber:Number(h?.postNumber)||Number(i.postNumber)||1,postOffset:h?h.postOffset:i.postOffset,scrollTop:Number.isFinite(Number(i.scrollTop))?Number(i.scrollTop):x.scrollTop})},ne._ldpCaptureHistoryPostNumber=()=>ne._ldpCaptureHistoryViewport()?.postNumber||1;const M$=()=>{const i=E.topicData;if(!i)return;const p=Number(ne._ldpCaptureHistoryPostNumber?.())||a||1;Vg(i,p,un(e)?.seenPostNumbers)};let ed=0;const aq=()=>{ed=0,$v(),Gs(),Ks.syncOpenSettingsControls(!0),I.syncSettingsSurfaceGeometry?.(),E.nativeComposerWindow&&E.nativeComposerWindow.syncLayer(),vv(),ne._ldpSyncReaderQueuePosition?.(),ne._ldpSyncTopicActionRailPosition?.(),E.streamListOffset=NaN,E.streamNeedsRepair=!0,vo(E)},i0=()=>{ed||(ed=requestAnimationFrame(aq))};$l.add(()=>{ed&&cancelAnimationFrame(ed),ed=0}),mr(window,"resize",i0),mr(ne,"ldp-reader-window-change",i0),typeof ResizeObserver=="function"&&$l.observe(new ResizeObserver(i0),S);let Tl=!1,zr=!1,Wn=!1,vi=!1,zu=null,l0=!1,c0=!1,gh=!1,R$=0,bh=null,td=0,yh=[];const Wu=new Map;let wi=0,Zs=0,rd=!1,Vu=!1,ea=0,Gu=0,xa=0,od=1,vh=0,wh=0,d0=0,u0=0,$$=null,T$=null,A$=null,P$=null,nd=0,Da=0,Qu=0,N$=!1,ta=0,Sh=0,Yu=0,gs=0,Al=0,Pl=0,p0=0,f0=null,m0=null,Xu=!1,_h=null,Ch=null,Ku=0,bs=null,ys=null,Ju=0,Eh=null,sd=null,Zu=null,k$=null,L$=null,I$=null,h0=null,x$=null,g0=null,D$=null,q$=null,O$=null,F$=null,B$=null;const ep=()=>{Ku&&clearTimeout(Ku),Ku=0},Mh=()=>{Sh&&clearInterval(Sh),Sh=0},tp=()=>{if(!Ut||!ir)return;if(!E.onlyOp){Mh(),Ut.hidden=!0;return}const i=Math.max(1,+(jr.streamLength||E.totalPosts||1)),p=Math.min(i,Math.max(0,+(jr.scanCursor||0))),h=Math.min(p,Math.max(0,+(jr.scanSkippedCount||0))),y=zo(E).length,C=Date.now();(!Pl||p!==p0)&&(Pl=C,p0=p,p>0&&(Al=0));const $=zr?100:Math.min(99.5,p/i*100),k=zr?"":` · 批${Math.max(1,Number(jr.scanBatchSize)||1)}`,j=h?` · 缺失${h}`:"";let B=null;try{B=Pa.snapshot()}catch{}let Z=zr?h?"partial":"done":"running",J="";if(!zr&&vi){const Q=Number(zu?.status||0);Z="error",J=` · 已暂停${Q?` HTTP ${Q}`:""}`}else if(!zr&&B&&B.coolingDown)Z="cooldown",J=` · 429冷却 ${Math.max(1,Math.ceil(B.cooldownRemaining/1e3))}秒`;else if(!zr&&B&&["10s","60s"].includes(B.blockingReason))Z="window",J=` · ${B.blockingReason==="10s"?"10秒窗":"60秒窗"} ${Math.max(1,Math.ceil(B.nextPermitDelay/1e3))}秒`;else if(!zr&&Wn&&gs>C)Z="retry",J=` · 重试${Al||1} ${Math.max(1,Math.ceil((gs-C)/1e3))}秒`;else if(!zr&&Tl&&Yu){const Q=Math.max(1,Math.ceil((C-Yu)/1e3)),Ve=B&&(Number(B.globalActive||0)>0||Number(B.globalQueued||0)>0);Z=Q*1e3>=Ba?"waiting":"running",J=Ve?` · 请求处理中 ${Q}秒`:` · 缓存协调中 ${Q}秒`}else if(!zr&&C-Pl>=2e3)Z="waiting",J=` · 调度恢复中 ${Math.max(2,Math.ceil((C-Pl)/1e3))}秒`;else if(!zr&&B){const Q=Math.max(B.queued,Number(B.globalQueued)||0);J=` · 全局执行${B.globalActive} 排队${Q}`}Ut.hidden=!1,Ut.dataset.state=Z,Ut.style.setProperty("--ldp-only-op-progress",`${$.toFixed(1)}%`);const ye=zr?h?"部分完成":"完成":Z==="error"?"暂停":Z==="retry"?"等待":"扫描";ir.textContent=`${ye} ${p}/${i} · 楼主${y}${j}${k}${J}`,nt(Ut,`只看楼主${zr?h?"扫描结束但有缺失":"扫描完成":"压测中"},已处理 ${p}/${i} 层,找到 ${y} 条楼主内容${h?`,其中 ${h} 层未能取回`:""}${k}${J}`),Ut.setAttribute("data-ldp-tooltip-label",Ut.getAttribute("aria-label"))},H$=(i=!0)=>{Mh(),(i||!Pl)&&(Pl=Date.now(),p0=Math.max(0,+(jr.scanCursor||0))),tp(),E.onlyOp&&!zr&&(Sh=setInterval(()=>{tp();const p=Wn&&gs>0&&gs<=Date.now();E.onlyOp&&!zr&&!Tl&&!vi&&!ta&&(!Wn||p)&&Date.now()-Pl>=2e3&&dd({maxBatches:1,forceOnlyOpScan:!0})},500))},U$=new Map,j$=new Map,iq=(i,p)=>{const h=Number(i.get(+p)||0);return h?h>Date.now()?!0:(i.delete(+p),!1):!1},qt=()=>T.isCurrent("opening","active");E.releaseInitialPrecedingContent=()=>{E.initialTargetPrecedingContentDeferred&&(E.initialTargetPrecedingContentDeferred=!1,E.streamMountedNodes.forEach(i=>{+i.dataset.postNumber>=+(E.initialTargetHydrationBoundary||0)||i.dataset.childReplyTotal&&E.repliesIO&&E.repliesIO.observe&&E.repliesIO.observe(i)}),vo(E))};const b0=()=>{td&&clearTimeout(td),Wu.forEach(i=>clearTimeout(i)),Wu.clear(),td=0,bh&&yh.forEach(({channel:i,handler:p})=>{try{bh.unsubscribe(i,p)}catch{}}),bh=null,yh=[]},z$=()=>{td||!qt()||document.visibilityState!=="visible"||(td=setTimeout(()=>{td=0,y0({force:!0})},350))},lq=new Set(["acted","created","rebaked","recovered","revised"]),cq=i=>{const p=Number(i?.post_id||0)>0&&Array.isArray(i?.reactions),h=Number(p?i.post_id:i?.id||0);if(!h||Wu.has(h)||!qt()||document.visibilityState!=="visible")return;const y=p?"acted":String(i.type||"");if(y==="read")return;if(!lq.has(y)){z$();return}const C=setTimeout(async()=>{if(Wu.delete(h),!qt()||!D(E.refreshPostById))return;const $=lo(h,E).length>0,k=x.scrollHeight-x.scrollTop-x.clientHeight<=120,j=Math.max(0,+(E.totalPosts||0));try{const B=await E.refreshPostById(h);if(!B||!qt())return;const Z=Math.max(0,Number(B.post_number)||0),J=y==="created"&&!$&&Z>j;if(Z&&(E.totalPosts=Math.max(j,Z),E.topicData&&(E.topicData.highest_post_number=Math.max(Number(E.topicData.highest_post_number)||0,Z),B.created_at&&(E.topicData.last_posted_at=B.created_at)),Ty(E,B),yu(E),E.timeline.update()),!J)return;if(E.replyThreadWindow?._ldpIngestLivePost?.(B),k){rp();const ye=x.scrollTop;x.scrollTop=x.scrollHeight,Dn(E,ye)}else rd=!1,wi++,(!Zs||Z{if(yh.length||!E.topicData||!qt()||document.visibilityState!=="visible")return;const i=xt("service:message-bus");if(D(i?.subscribe)&&D(i.unsubscribe)){const p=y=>{const C=y?.payload&&typeof y.payload=="object"?y.payload:y,$=Number(y?.topic_id||C?.topic_id||0);$&&$!==+e||cq(C)},h=[{channel:`/topic/${e}`,handler:p},{channel:`/topic/${e}/reactions`,handler:p}];try{h.forEach(({channel:y,handler:C})=>i.subscribe(y,C)),bh=i,yh=h;return}catch{h.forEach(({channel:y,handler:C})=>{try{i.unsubscribe(y,C)}catch{}})}}};ne._ldpStopLatestReplyUpdates=b0;const ad=()=>{Rr.hidden=wi<=0||rd,qr.disabled=Vu,qr.setAttribute("aria-busy",String(Vu));const i=Rr.querySelector("span");i&&(i.textContent=`有 ${wi} 条新回复`)},rp=()=>{wi=0,Zs=0,rd=!1,ad()},dq=async()=>{if(!qt()||Vu)return;const i=Zs,p=wi;if(rp(),!i)return;Vu=!0,ad();let h=!1;try{if(h=await E.timeline.jumpTo(i),!h&&qt()){const y=E.streamItemMap.get(i);E.onlyOp&&!tl(E,y)&&D(Zu)?await Zu():(E.streamWindowResetPending=!0,nl(E)),await _a(),qt()&&(h=await E.timeline.jumpTo(i))}}catch{h=!1}finally{Vu=!1,!h&&qt()&&(wi+=p,(!Zs||i{yt(i),rd=!0,ad()};mr(qr,"click",dq),mr(Dt,"click",uq);const y0=async(i={})=>{const p=i.force===!0;if(gh||!qt()||document.visibilityState!=="visible")return null;const h=Date.now();if(!p&&h-R$<1e4)return null;R$=h,gh=!0;try{const y=Number.isFinite(i.priority)?i.priority:rt.background,C=Math.max(0,Number.isFinite(+i.afterPostNumber)?+i.afterPostNumber:+(E.totalPosts||0)),$=await jr.refreshTopicData({priority:y});if(!$||!qt())return null;const k=Math.max(0,+($.highest_post_number||0),+($.posts_count||0)),j=new Map;Ir($).forEach(Ve=>{const gt=+(Ve?.post_number||0);gt&&(E.streamNodeMap.has(gt)||gt>C)&&j.set(gt,Ve)});let B=C,Z=0;for(;B>0&&B+(je?.post_number||0)>B);if(!gt.length)break;gt.forEach(je=>j.set(+je.post_number,je));const Ht=gt.reduce((je,A)=>Math.max(je,+(A.post_number||0)),B);if(Ht<=B)break;B=Ht}const ye=[...j.keys()].some(Ve=>Ve>C)&&x.scrollHeight-x.scrollTop-x.clientHeight<=120,Q=bu([...j.values()],E);if(Q.forEach(Ve=>{E.replyThreadWindow?._ldpIngestLivePost?.(Ve)}),E.topicData=$,E.topicSharedIssueHostVisible=xC($),E.isPostVoting=!!$.is_post_voting,E.topicBookmarked=!!$.bookmarked,E.topicNotificationLevel=Ji($),E.totalPosts=k,Ty(E,{created_at:$.last_posted_at},{force:!0}),yu(E),bn(E),Bc($),py(E),E.timeline.update(),Q.length)if(Zs||(Zs=+(Q[0]?.post_number||0)),ye){rp();const Ve=x.scrollTop;x.scrollTop=x.scrollHeight,Dn(E,Ve)}else rd=!1,wi+=Q.length,ad();return{posts:[...j.values()],topic:$}}catch{return null}finally{gh=!1}};E.refreshPostByNumber=async i=>{const p=await jr.loadPost(+i,{priority:rt.target,forceRefresh:!0});return qt()?(jr.persistFetchedPosts(p),bu(p,E,{refreshContent:!0}),p.find(h=>+(h?.post_number||0)==+i)||p[0]||null):null},E.refreshPostById=async i=>{const p=Number(i)||0;if(!p)return null;const h=await io(`${vt}/posts/${p}.json`,{scheduler:Pa,priority:rt.visible,cacheMode:"refresh",allowStaleOnError:!1,fetchOptions:{cache:"no-store"},key:`post:${p}:live-update`});return!qt()||!h||+h.id!==p||!h.post_number?null:(jr.persistFetchedPosts([h]),bu([h],E,{refreshContent:!0}),h)},E.refreshNativeComposerSession=async i=>{if(!i||!qt())return!1;if(i.action==="edit"){const C=await E.refreshPostByNumber(i.postNumber);return C?E.timeline.jumpTo(+C.post_number,{targetPost:C,forceNested:!0}):!1}if(i.action!=="reply")return!1;const p=+(i.parentPostNumber||0),h=+(i.highestPostNumber||0),y=[0,700,1400];for(let C=0;Ck.set(+B.post_number,B)),E.streamItems.forEach(B=>{const Z=B?.node&&B.node._ldpPostData;Z&&+Z.post_number>h&&k.set(+Z.post_number,Z)});const j=[...k.values()].filter(B=>+B.post_number>h).filter(B=>!Yt||B.username===Yt).filter(B=>p<=1?+(B.reply_to_post_number||0)<=1:+(B.reply_to_post_number||0)===p).sort((B,Z)=>+Z.post_number-+B.post_number)[0];if(j)return D(E.loader?.persistFetchedPosts)&&E.loader.persistFetchedPosts([j]),rp(),E.timeline.jumpTo(+j.post_number,{targetPost:j,forceNested:!0})}return!1},E.bindNativeComposerRefresh=()=>ys?!0:(bs=xt("service:app-events"),!bs||!D(bs.on)?!1:(ys=()=>{const i=E.nativeComposerSession;!i||i.refreshing||!qt()||(i.refreshing=!0,Ju=setTimeout(async()=>{Ju=0;try{await E.refreshNativeComposerSession(i)}finally{E.nativeComposerSession===i&&(E.nativeComposerSession=null)}},0))},bs.on("composer:created-post",ys),bs.on("composer:edited-post",ys),bs.on("post:created",ys),!0)),E.bindNativeComposerRefresh(),P$=()=>{if(!qt())return;const i=document.visibilityState==="visible";if(E.timeline.setActive(i),E.repliesIO?.setActive?.(i),e$(),!i){b0(),yR(),Mh(),Bu(),fc(ne),E.streamHydratedNodes.forEach(p=>fc(p));return}E.streamMountedNodes.forEach(p=>zf(p)),W$(),vR(),E.onlyOp&&!zr&&H$(!1),Wn&&!ta&&!Tl&&!zr&&!vi&&(gs=0,dd({maxBatches:1,forceOnlyOpScan:E.onlyOp})),xa&&ip(xa<0),fh(),E.timeline.update(),E.timeline.refreshRelativeTimes(),y0({force:!0})},mr(document,"visibilitychange",P$);const V$=1500;let Rh=0,op=0,$h=Number.NaN,Th=Number.NaN;const id=()=>{!Rh&&!op||(Rh=0,op&&clearTimeout(op),op=0,ro(".ldp-selection-toast[data-ldp-reader-exit-hint]")?.remove())},pq=()=>{id(),Rh=Date.now()+V$,mt("再按一次 Esc 退出阅读器");const i=ro(".ldp-selection-toast");i&&(i.dataset.ldpReaderExitHint="1"),op=setTimeout(id,V$)},G$=1500;let v0="",w0=0,Ah=0;const ld=()=>{v0="",w0=0,Ah&&clearTimeout(Ah),Ah=0,ro(".ldp-selection-toast[data-ldp-composer-close-hint]")?.remove()},Q$=i=>v0===i&&w0>Date.now(),Y$=(i,p)=>{ld(),v0=i,w0=Date.now()+G$,mt(p);const h=ro(".ldp-selection-toast");h&&(h.dataset.ldpComposerCloseHint="1"),Ah=setTimeout(ld,G$)},np=(i=Eh)=>{if(!(!i||i.released)){if(i.released=!0,i.appEvents&&i.appEvents.trigger===i.trigger)try{i.appEvents.trigger=i.originalTrigger}catch{}(i.routeGuards||[]).forEach(({owner:p,originalRouteTo:h,guardedRouteTo:y})=>{if(p.routeTo===y)try{p.routeTo=h}catch{}}),Eh===i&&(Eh=null)}},X$=()=>{const i=sd;if(sd=null,!i||!i.topicController||!D(i.topicController.subscribe))return;const p=pt(i.topicController,"model");if(+(pt(p,"id")||0)===i.hostTopicId)try{i.topicController.subscribe()}catch{}},fq=()=>{const i=E.nativeComposerSession;if(!i||!["reply","edit"].includes(i.action))return null;np();const p=i.composer&&(i.composer.appEvents||xt("service:app-events"));if(!p||!D(p.trigger))return null;const h=p.trigger,y=new Set(i.action==="edit"?["composer:edited-post"]:["composer:created-post","post:created"]),C=new Set([...y,"post:highlight"]),$={appEvents:p,originalTrigger:h,trigger:null,routeGuards:[],released:!1};$.trigger=function(Q,...Ve){const gt=String(Q||"");return y.has(gt)&&D(ys)&&ys(),C.has(gt)?this:h.call(this,Q,...Ve)};try{p.trigger=$.trigger}catch{return null}const j=Bt("discourse/lib/url")?.default;if(D(j?.routeTo)){const Q=j.routeTo,Ve=function(gt,...Ht){const je=uo(String(gt||""));if(!(je&&+je.topicId==+E.topicId&&+je.postNumber>0))return Q.call(this,gt,...Ht)};try{j.routeTo=Ve,j.routeTo===Ve&&$.routeGuards.push({owner:j,originalRouteTo:Q,guardedRouteTo:Ve})}catch{}}const B=xt("controller:topic"),Z=pt(B,"model"),J=+(pt(Z,"id")||0);if(!(sd&&sd.topicController===B&&sd.hostTopicId===J)&&J===+E.topicId&&B&&D(B.unsubscribe)){X$();try{B.unsubscribe(),sd={topicController:B,hostTopicId:J}}catch{}}return Eh=$,$},K$=()=>{const i=E.nativeComposerSession;if(!i||!["reply","edit"].includes(i.action)||pt(i.composer,"model")!==i.model)return null;const p=document.querySelector("#reply-control");return!rn(p)||!p.matches(`.composer-action-${i.action}`)?null:p.querySelector(".save-or-cancel button.create")},J$=i=>{const h=E.nativeComposerSession?.composer;if(!i||i.disabled||!h||!D(h.save))return!1;const y=fq();try{const C=h.save(!0,{jump:!1});Promise.resolve(C).then(()=>np(y),()=>np(y))}catch(C){np(y),console.warn("[LINUX DO reader] native composer submit failed",C)}return!0};F$=i=>{const p=i.target.closest?.("#reply-control .save-or-cancel button.create");!p||p!==K$()||(i.preventDefault(),i.stopImmediatePropagation(),J$(p))},B$=i=>{if(i.isComposing||!(i.ctrlKey||i.metaKey)||i.key!=="Enter"&&i.code!=="Enter")return;const p=K$();p&&(i.preventDefault(),i.stopImmediatePropagation(),J$(p))},O$=i=>{const p=i.target.closest?.("#reply-control .toggle-save-and-close, #reply-control .discard-button");if(!p||!rn(document.querySelector("#reply-control")))return;if(!qe.confirmNativeComposerClose){ld();return}const h=p.classList.contains("discard-button")?"discard":"save-close";if(Q$(h)){ld(),h==="discard"&&(i.preventDefault(),i.stopImmediatePropagation(),vM(E).catch(y=>{console.warn("[LINUX DO reader] native composer discard failed",y)}));return}i.preventDefault(),i.stopImmediatePropagation(),Y$(h,h==="discard"?"再点一次舍弃回复":"再点一次关闭回复窗口")};const mq=i=>{$h=i.clientX,Th=i.clientY},hq=()=>{const i=E.scrollRoot.getBoundingClientRect();return[...E.commentsEl.querySelectorAll(":scope > .ldp-post.ldp-reply-collapsible:not(.ldp-nested-collapsed)")].map(p=>({post:p,rect:p.getBoundingClientRect()})).filter(({post:p})=>fy(p)).filter(({rect:p})=>p.bottom>i.top&&p.topi.left&&p.leftp.rect.top-h.rect.top)},S0=()=>{if(E.onlyOp)return null;const i=hq();return(i.find(({rect:h})=>Number.isFinite($h)&&Number.isFinite(Th)&&$h>=h.left&&$h<=h.right&&Th>=h.top&&Th<=h.bottom)||i[0]||{}).post||null},Z$=i=>{id(),!(i.type!=="pointerdown"||i.button!==0||!xe.isEmbedded()||ji(i,cn))&&Ks.closeFloatingPanels()};let eT=!1,tT=!1;const vs=(i,p)=>{try{p()}catch(h){console.warn(`[LDP] ${i} cleanup failed`,h)}},Ph=(i={})=>{const p=i.preserveShell===!0;if(!eT){if(eT=!0,Y&&Zg(Y.topicId,Bi(ne)),D(ne._ldpCloseCodePreview)&&ne._ldpCloseCodePreview(!1,!0),p){const h=xM(ne,E.commentsHeader);h&&(I.commentsHeader=h)}b0(),Mh(),yR(),gh=!1,vs("topic nav metadata",Wm),Ju&&clearTimeout(Ju),Ju=0,np(),X$(),bs&&ys&&D(bs.off)&&(bs.off("composer:created-post",ys),bs.off("composer:edited-post",ys),bs.off("post:created",ys)),[["loader",()=>jr.destroy()],["reader queue prefetch",TS],["request scheduler",()=>{Ao===Pa&&(Ao=null),Pa.destroy()}],["user card",Dy],["action dialog",()=>E.readerActionDialog?.close()],["topic editor",()=>qo?.close(!0)],["composer window",()=>E.nativeComposerWindow.destroy()],["post actions",()=>f0&&f0()],["read tracker",()=>hh.stop()],["queue viewport tracker",()=>E.queueViewportTracker.stop()],["nested replies",()=>E.repliesIO.disconnect()],["descendant replies window",()=>E.replyThreadWindow?._ldpClose?.()],["body translation",()=>E.translation.destroy()],["media players",()=>fc(ne)],["detached media players",()=>E.streamHydratedNodes.forEach(h=>fc(h))],["pending content hydration",()=>[...E.pendingPostHydrations].forEach(h=>su(h,E))],["pending latex render",()=>[...E.pendingPostLatexRenders].forEach(h=>Jf(h,E))],["jump highlight",UC],["stream viewport anchor",()=>ns(E)],["avatar placeholders",()=>Gr(ne)],["resize observer",()=>E.streamResizeObserver.disconnect()],["timeline",()=>E.timeline.destroy()],["topic presence",()=>m0&&m0()]].forEach(([h,y])=>vs(h,y)),E.streamWindowFrame&&cancelAnimationFrame(E.streamWindowFrame),E.floorPreviewSyncFrame&&cancelAnimationFrame(E.floorPreviewSyncFrame),nd&&cancelAnimationFrame(nd),Da&&cancelAnimationFrame(Da),E.streamWindowFrame=0,E.floorPreviewSyncFrame=0,nd=0,Da=0,Qu=0,Ch&&Ch(),[ea,ta].forEach(h=>{h&&clearTimeout(h)}),ea=0,ta=0,Gu=0,xa=0,od=1,$l.destroy(),ld(),id(),I.activeContext===E&&(I.activeContext=null),[E.streamItems,E.streamLayoutItems].forEach(h=>{h.length=0}),[E.streamItemMap,E.streamMountedNodes,E.streamHydratedNodes,E.pendingPostHydrations,E.pendingPostLatexRenders,E.streamNodeMap,E.subReplyState,E.directRepliesByParent,E.confirmedReadPostNumbers,E.replyParentByPost].forEach(h=>h.clear())}!p&&!tT&&(tT=!0,ne.dataset.readerShellDestroyed="1",vs("avatar observers",()=>{Gr(de.list),Gr(ge.list),Gr(Te.list)}),vs("resource monitor view",Qv),vs("request flow view",Bu),I.cancelShellFrames&&I.cancelShellFrames(),vs("notification live events",()=>I.stopNotificationLiveEvents?.()),vs("shell events",()=>he.destroy()),vs("reader window",()=>I.readerWindow.destroy()),vs("reader workspace",()=>I.readerWorkspace.destroy()),vs("reader queue surface",()=>ne._ldpDestroyReaderQueueSurface?.()),vs("surface manager",()=>I.surfaceManager.destroy()))};let sp=null,cd=0,rT=0,oT=0;const _0=()=>!Do.isPinned()&&xe.isFloating()&&window.innerWidth>Es,nT=(i,p)=>{let h;try{ne.classList.add("ldp-reader-hit-test-hidden"),h=document.elementFromPoint(i,p)}finally{ne.classList.remove("ldp-reader-hit-test-hidden")}const y=h?.closest("a[href]"),C=y&&!y.classList.contains("ldp-open")&&!Cm(y.href)?uo(y.getAttribute("href")||y.href):null;return C?{link:y,route:C}:null},sT=i=>{sp!==i&&(sp?.classList.remove("ldp-reader-switch-target"),sp=i||null,sp?.classList.add("ldp-reader-switch-target"),ne.classList.toggle("ldp-reader-switch-ready",!!sp))},Nh=()=>{cd&&cancelAnimationFrame(cd),cd=0,sT(null)},gq=()=>{cd=0;const i=_0()?nT(rT,oT):null;sT(i?.link)},bq=i=>{if(i.target!==ne||i.pointerType==="touch"||!_0()){Nh();return}rT=i.clientX,oT=i.clientY,cd||(cd=requestAnimationFrame(gq))};let kh=null;const aT=()=>{let i;try{i=cI(E)}catch(p){console.warn("[LINUX DO reader] native composer draft save setup failed",p),i=Promise.resolve(!1)}return Promise.resolve(i).catch(p=>(console.warn("[LINUX DO reader] native composer draft save cleanup failed",p),!1))},yq=()=>{if(kh)return kh;if(Xu)return Promise.resolve(!1);ep(),Nh(),T.state="switching";const i=aT();return M$(),Ph({preserveShell:!0}),v(),ne._ldpCaptureHistoryViewport=()=>null,ne._ldpCaptureHistoryPostNumber=()=>1,kh=i,kh},ap=(i={})=>{if(_h)return _h;if(Xu)return Promise.resolve();D(ne._ldpCancelAutoRetryWait)&&ne._ldpCancelAutoRetryWait(),ep(),Nh(),Xu=!0,T.state="closing";const p=aT();return M$(),Ph(),v(),yn(ne),Ct===ne&&(Ct=null,cm()),i.suppressNavigation||MS(e),queueMicrotask(VP),wm===ve&&(wm=0),It.classList.remove("ldp-reader-open"),$f(!1),T.state="closed",i.preserveDocumentTitle||kI(),i.suppressNavigation||(wn=String(e),It.classList.remove("ldp-route-takeover"),Us()),_h=p,_h};ne._ldpDispose=()=>ap({suppressNavigation:!0,preserveDocumentTitle:!0}),ne._ldpPrepareTopicSwitch=yq;function vq(i){if(i.key!=="Escape"){id();return}if(ro(".ldp-lightbox"))return;if(i.repeat){i.preventDefault(),i.stopImmediatePropagation();return}const p=Rh>Date.now();id();const h=document.body?.querySelector(":scope > .ldp-native-boost-menu"),y=[...document.querySelectorAll(".emoji-picker")].find(rn);if(h&&y&&D(h._ldpCloseEmojiPicker)){i.preventDefault(),i.stopImmediatePropagation(),h._ldpCloseEmojiPicker();return}if(pM())return;if(D(h?._ldpClose)){i.preventDefault(),i.stopImmediatePropagation(),h._ldpClose();return}const $=document.querySelector("#reply-control");if(rn($)){i.preventDefault(),i.stopImmediatePropagation(),!qe.confirmNativeComposerClose||Q$("escape")?(ld(),vM(E).catch(Z=>{console.warn("[LINUX DO reader] native composer discard failed",Z)})):Y$("escape","再按一次 Esc 舍弃回复");return}if(!L.hidden&&E.replyThreadWindow?.isConnected){yt(i),Ot.hidden?Ks.requestCloseSettings():Ks.closeFontFamilyMenu(!0);return}const k=E.replyThreadWindow?.querySelector(".ldp-floor-preview.open");if(k){yt(i),E.floorPreview?.hide(k._ldpRelatedPost);return}if(E.replyThreadWindow?.isConnected){yt(i),E.replyThreadWindow._ldpBack?.()||(E.replyThreadWindow._ldpCloseFromEscape||E.replyThreadWindow._ldpClose)?.();return}if(D(ne._ldpCloseCodePreview)){i.preventDefault(),i.stopImmediatePropagation(),ne._ldpCloseCodePreview();return}if(qo){yt(i),(!qo.closeTransient||!qo.closeTransient())&&qo.close();return}if(E.readerActionDialog){yt(i),E.readerActionDialog.close();return}if(D(E.closeTransientActions)&&E.closeTransientActions()){yt(i);return}if(qn()){yt(i),D(E.closeUserCardLayer)?E.closeUserCardLayer():D(E.closeUserCard)?E.closeUserCard():Dy();return}if(E.floorPreview?.isOpen()&&E.floorPreview.isVisibleInViewport()){yt(i),E.floorPreview.hide(),S0()&&mt("再按 Esc 收起楼层:优先鼠标所在位置,否则从上往下");return}const j=S0();if(j){yt(i),yo(j,!0);const Z=S0();mt(Z?`已收起 #${j.dataset.postNumber||""} · Esc 继续收起 #${Z.dataset.postNumber||""}`:`已收起楼层 #${j.dataset.postNumber||""}`);return}const B=[[ke,Ks.closeNotifications],[ge.popover,Ks.closeHistory],[Te.popover,Ks.closeBookmarks],[Ot,()=>Ks.closeFontFamilyMenu(!0)],[L,Ks.requestCloseSettings]].find(([Z])=>!Z.hidden);if(B){yt(i),B[1]();return}i.preventDefault(),i.stopImmediatePropagation(),!qe.doubleEscapeToCloseReader||p?ap():pq()}const ra=X(".ldp-reader-refresh");ra.disabled=T.state!=="active",x$=async()=>{if(!(ra.disabled||T.state!=="active")){ra.disabled=!0,ra.classList.add("is-refreshing");try{const i=Bi(ne,Number(ne._ldpCaptureHistoryPostNumber?.())||1),p=i?.viewport.postNumber||1,h=E.onlyOp===!0,y=E.topicData;D(ne._ldpPrepareTopicSwitch)&&await ne._ldpPrepareTopicSwitch(),await UP(e,y),await co(e,{targetPostNumber:p,restoreOnlyOp:h,restoreState:i||void 0,topicNavMetadata:K,historyNavigation:oe})}finally{ra.classList.remove("is-refreshing"),ra.isConnected&&T.state==="active"&&(ra.disabled=!1)}}},mr(ra,"click",x$),g0=()=>ap(),mr(X(".ldp-close"),"click",g0),mr(ne,"pointermove",bq);const C0=i=>{Qu=i,!Da&&(Da=requestAnimationFrame(()=>{Da=0,Qu=0}))};D$=i=>{if(i.target!==ne)return;if(qo){qo.close();return}if(!_0())return;const p=nT(i.clientX,i.clientY);if(p){Nh(),wn="",ui(),co(p.route.topicId,{targetPostNumber:_u(p.route),topicNavMetadata:ya(p.link)});return}ap()},mr(ne,"click",D$),q$=i=>{const p=i.target.closest?.(".ldp-topic-action-rail,.ldp-reader-action-layer:not(.ldp-topic-edit-layer)");if(p){Dd(p,i);return}if(ke.contains(i.target)||ge.popover.contains(i.target)||Te.popover.contains(i.target))return;if(i.target.closest?.(".ldp-topic-edit-layer")){i.preventDefault();const $=i.target.closest(".ldp-topic-edit-category-menu"),k=$?.querySelector(".ldp-topic-edit-category-options");if(k&&!$.hidden){const j=i.deltaMode===1?i.deltaY*40:i.deltaMode===2?i.deltaY*k.clientHeight:i.deltaY;k.scrollTop+=j}return}if(i.ctrlKey)return;E.timeline?.releaseJump&&E.timeline.releaseJump();const y=i.deltaMode===1?i.deltaY*40:i.deltaMode===2?i.deltaY*x.clientHeight:i.deltaY;let C=i.target instanceof Element?i.target:null;for(;C&&C!==x;){const $=C.scrollHeight-C.clientHeight;if($>1&&/(auto|scroll|overlay)/.test(getComputedStyle(C).overflowY)&&(y<0?C.scrollTop>0:C.scrollTop<$-1))return;C=C.parentElement}x.contains(i.target)&&y<0&&(i.preventDefault(),x.scrollTop+=y),y<0?(E.streamScrollDirection=-1,E.streamLastScrollAt=Date.now(),E.streamScrollVelocity=Math.max(+(E.streamScrollVelocity||0),Math.abs(y)/16),ss(E),Kf(E),E.releaseInitialPrecedingContent(),vo(E),ip(!0)):y>0&&(E.streamScrollDirection=1,WC(E)),y&&C0(y<0?-1:1),!x.contains(i.target)&&(i.preventDefault(),x.scrollTop+=y)},mr(S,"wheel",q$,{passive:!1}),mr(document,"pointerdown",Z$,!0),mr(document,"scroll",Z$,{capture:!0,passive:!0}),mr(document,"click",F$,!0),mr(document,"keydown",B$,!0),mr(document,"click",O$,!0),mr(document,"keydown",vq,!0),mr(ne,"pointermove",mq);const E0=()=>x.scrollTop+x.clientHeight>=x.scrollHeight-300,dd=async(i={})=>{if(Tl||vi||!qt())return;Tl=!0;const p=i.maxBatches??1,h=i.keepFilling===!0,y=E.onlyOp,C=i.forceOnlyOpScan===!0&&y;y&&(Yu=Date.now());let $=0,k=0,j=180;zr||(wr.classList.remove("show"),Nr.classList.add("show"));try{for(;!zr&&$0);){Wn=!1;const{posts:B,done:Z,retry:J,fatal:ye,error:Q}=await jr.next({onlyOp:y,onlyOpUsername:y?E.op:"",persistFetchedPosts:i.persistFetchedPosts===!0,onSource:(Ve,gt)=>Sr(Ve,gt)});if(!qt()||(B.length&&Sr("render"),await iL(B,E,{acceptsWork:qt}),!qt()))return;if(E.onlyOp&&tp(),ye){vi=!0,zu=Q||null,Wn=!1;break}if(J){Wn=!0,zu=Q||null;const Ve=Number(Q?.status||0),gt=Math.max(0,Number(Q?.retryAt||0)-Date.now());if(y&&Al++,j=Ve===429||Q&&Q.name==="AbortError"?Math.max(250,gt):Ve===408||Ve>=500?1e3:400,ts(Q)||k>=1)break;k++,await el(400);continue}zu=null,Al=0,gs=0,j=180,$++,E.onlyOp===y&&(zr=Z)}}catch(B){if(qt())if(zu=B||new Error("请求流异常停止"),!!B&&(B.name==="AbortError"||B.name==="TypeError"||Number(B.status||0)===408||Number(B.status||0)===429||Number(B.status||0)>=500)){Wn=!0,y&&Al++;const J=Math.max(0,Number(B?.retryAt||0)-Date.now());j=Math.min(J>0?xw:15e3,Math.max(J,1e3*Math.pow(2,Math.min(4,Math.max(0,Al-1)))))}else vi=y,Wn=!y,j=1e3,console.warn(`[LINUX DO reader] request pump ${y?"stopped":"will retry"} after unexpected error`,B)}finally{Tl=!1,y&&(Yu=0),qt()&&(Nr.classList.remove("show"),wr.classList.toggle("show",zr),E.onlyOp&&tp(),(E.onlyOp||Wn)&&!zr&&!vi&&!ta?(E.onlyOp&&(gs=Date.now()+j),ta=setTimeout(()=>{if(ta=0,document.visibilityState!=="visible"){gs=Date.now();return}gs=0,qt()&&dd({maxBatches:1,forceOnlyOpScan:E.onlyOp})},j)):(zr||vi||!E.onlyOp)&&(gs=0))}},iT=(i,p)=>{if(E.isPostVoting)return!1;const h=+(i&&i.dataset.postNumber),y=p?U$:j$;if(!h||p&&h<=1||iq(y,h))return!1;const C=E.streamItemMap.get(h),$=Math.max(1,E.totalPosts||1);if(E.onlyOp){const B=zo(E),Z=B[p?0:B.length-1],J=E.streamItems[p?0:E.streamItems.length-1];return C&&C===Z&&J&&(p?J.postNumber>1:J.postNumber<$)}const k=C?C.index+(p?-1:1):-1,j=k>=0&&k1:j.postNumber-h>1:p||h<$},wq=i=>{const p=zo(E),h=vc(E).lookahead,y=Math.max(E.streamWindowStart,E.streamViewportTopIndex-(i?h:1)),C=Math.min(E.streamWindowEnd,E.streamViewportBottomIndex+(i?2:h+1));for(let $=y;${const y=i?l0:c0,C=i?d0:u0;if(y||!qt()||Date.now()iT(Q,i),k=()=>wq(i);let j=p&&(!i||p.isConnected)&&$(p)?p:k();if(i&&!j&&E.streamScrollDirection<0&&!E.streamItemMap.has(1)&&(E.streamNeedsRepair=!0,nl(E),j=k()),!j)return!1;i&&ss(E);const B=Math.max(1,Math.min(2,+(h.maxBatches||1)));let Z=+j.dataset.postNumber,J=0,ye=0;i?l0=!0:c0=!0;try{for(let Q=0;Q0:E.streamScrollDirection<0;if(Q>0&&(Ve||vc(E).batches<2))break;const gt=await jr[i?"loadBeforePost":"loadAfterPost"](Z,{persistFetchedPosts:h.persistFetchedPosts===!0});if(i?d0=0:u0=0,!qt()||i&&E.streamScrollDirection>=0)return!1;const Ht=gt.filter(Je=>Je&&!E.streamNodeMap.has(+Je.post_number));if(!Ht.length){(i?U$:j$).set(Z,Date.now()+5e3),i&&(ye=5100,wh=Date.now()+ye);break}const je=Ht.slice().sort((Je,Tt)=>(Je.post_number||0)-(Tt.post_number||0));i?(E.initialTargetHeadSpacerHeight=0,Mc(je,E,{syncStreamNow:!0,preserveViewportAnchor:!0}),vh=0,wh=0):Mc(je,E),J+=je.length;const se=+(je[i?0:je.length-1]?.post_number||0),De=E.streamNodeMap.get(se),Ke=i?seZ;if(!se||!Ke||!De||!$(De))break;Z=se}return J>0}catch{i?(d0=Date.now()+3e3,ye=3100,wh=Date.now()+ye):u0=Date.now()+3e3}finally{i?l0=!1:c0=!1,i&&ye&&E.streamScrollDirection<0&&!E.streamItemMap.has(1)&&vh<2&&(vh++,ip(!0,ye))}return!1},cT=(i=null,p={})=>lT(!0,i,p),dT=(i=null,p={})=>lT(!1,i,p),ip=(i,p=0)=>{const h=i?-1:1,y=vc(E),C=!!xa&&xa!==h;xa=h,od=Math.max(C?1:od,y.batches);const $=h<0?Math.max(0,wh-Date.now()):0,k=Date.now()+Math.max($,Math.max(0,+p||0));ea&&!C&&k>=Gu||(ea&&clearTimeout(ea),Gu=k,ea=setTimeout(()=>{if(ea=0,Gu=0,!qt()||document.visibilityState!=="visible")return;const j=xa,B=od;if(xa=0,od=1,j<0)cT(null,{maxBatches:B});else if(j>0){if(Tl||E0())return;dT(null,{maxBatches:B})}},Math.max(0,k-Date.now())))};E.loadPost=async(i,p={})=>{if(!qt()||(i=+i,!i))return!1;const h=p.scope==="around",y=p.mount!==!1,C=p.ensureAround===!0&&i>1,$=E.streamNodeMap.has(i);if($&&p.forceRefresh!==!0&&(!h||!C))return y?!!Wo(E,i,{mount:!0}):!0;let k=[];try{k=await jr.loadPost(i,{onSource:p.onSource,priority:Number.isFinite(p.priority)?p.priority:h?rt.target:void 0,scope:h?"around":"single",forceRefresh:p.forceRefresh===!0})}catch(B){if(!h||!$||p.forceRefresh===!0)throw B}if(!qt())return!1;const j=h?k.slice().sort((B,Z)=>(B.post_number||0)-(Z.post_number||0)):k.filter(B=>B&&!E.streamNodeMap.has(+B.post_number));return Mc(j,E),y?!!Wo(E,i,{mount:!0}):E.streamNodeMap.has(i)};const Sq=i=>{const p=+i||0;return p?jr.cachedPosts.reduce((h,y)=>{const C=+(y?.post_number||0);if(!C)return h;if(!h)return y;const $=+(h.post_number||0),k=Math.abs(C-p),j=Math.abs($-p);return kp&&${Wt.classList.toggle("active",E.onlyOp),bo(Wt,E.onlyOp),nt(Wt,E.onlyOp?"显示全部楼层":"只看楼主")};Zu=async()=>{if(!qt()||Wt.disabled||!E.op)return;ns(E),E.onlyOp=!E.onlyOp,Wn=!1,zr=E.onlyOp?jr.scanDone:jr.loadDone,!E.onlyOp&&ta&&(clearTimeout(ta),ta=0),E.onlyOp||(Yu=0,gs=0,Al=0),uT(),Ca(E),E.onlyOp?H$():tp(),E.streamWindowResetPending=!0,E.streamMountedItemCount=-1;const i=x.scrollTop;if(x.scrollTop=0,Dn(E,i),E.onlyOp&&!E.streamItemMap.has(1)){if(await E.loadPost(1,{priority:rt.target}),!qt())return;Ca(E)}wc(E),E.timeline.update(),E.onlyOp&&dd({maxBatches:1,forceOnlyOpScan:!0})},mr(Wt,"click",Zu);try{ee("load-topic");const i=await Qx;if(!qt())return;ee("initialize-topic"),E.topicData=i,hh.preload(Ir(i)),E.topicSharedIssueHostVisible=!jr.initializedFromCache&&xC(i),Ir(i).forEach(Q=>Os(E,Q)),E.isPostVoting=!!i.is_post_voting,E.topicBookmarked=!!i.bookmarked,E.topicNotificationLevel=Ji(i),E.latestReplyAt=String(i.last_posted_at||""),E.latestReplyTimestamp=Date.parse(E.latestReplyAt)||0,E.op=i._opUsername,Wt.disabled=!E.op,uT(),E.totalPosts=Math.max(0,i.highest_post_number||0,i.posts_count||0),Bc(i),E.syncTopicActionRail(),yu(E),bn(E),Ls(),qt()&&(I.startNotificationLiveEvents?.(),bn(E),py(E),Xe(".ldp-post").forEach(Q=>{ru(Q,E),Q.querySelector(":scope > .ldp-boost-list:not(.ldp-boost-pending)")&&gm(Q,Q._ldpPostData||{},E)})),qt()&&W$();const p=X(".ldp-title-jump");let h=!1;E.cancelInitialTargetPosition=()=>{h=!0,E.initialTargetPositionPending=!1,Kf(E),E.releaseInitialPrecedingContent()},k$=async()=>{qt()&&(E.replyThreadWindow?._ldpClose?.(!1),await E.timeline.jumpToStart())},mr(p,"click",k$),L$=Q=>{Q.key!=="Enter"&&Q.key!==" "||(Q.preventDefault(),p.click())},mr(p,"keydown",L$),I$=async()=>{qt()&&await E.timeline.jumpToStart()},mr(Ar.querySelector(".ldp-topic-timeline-top"),"click",I$);const y=X(".ldp-open");y.href=Vd(i.id,0,!0)||`${vt}/t/${i.id}?${Dl}=1`,y.hidden=!1,nt(y,"打开原帖"),h0=Q=>{Q.type==="click"&&Q.button&&Q.button!==0||Q.type==="auxclick"&&Q.button!==1||FI(y.href)&&yt(Q)},mr(y,"click",h0),mr(y,"auxclick",h0),E.topicBookmarked=!!i.bookmarked,bn(E),E.toggleTopicBookmark=()=>TM(i,E,"Topic"),m0=TI(E),f0=RI(S,E),cm(),hh.start();let C=x.scrollTop,$=Date.now();E.streamInternalScrollTop=NaN,E.streamInternalScrollDelta=0,$$=()=>{if(!qt())return;const Q=Date.now(),Ve=x.scrollTop,gt=Ve-C,Ht=Math.max(16,Q-$);C=Ve,$=Q;const je=E.streamInternalScrollTop,A=Number.isFinite(E.streamInternalScrollDelta)?E.streamInternalScrollDelta:0;E.streamInternalScrollTop=NaN,E.streamInternalScrollDelta=0;let se=gt-A;if(Math.abs(se)<1&&(se=0),(Math.abs(A)>=.01||Number.isFinite(je)&&Math.abs(Ve-je)<1)&&!se){vo(E);return}if(E.initialTargetPositionPending){vo(E);return}se&&T.state==="active"&&(E.viewportPersistenceEnabled=!0);const Ke=se<0?-1:se>0?1:0,Je=!!Ke&&Qu===Ke;Je&&(Qu=0,Da&&cancelAnimationFrame(Da),Da=0),Ke&&(E.streamScrollDirection=Ke,Je||WC(E),Ke<0?Je||(Kf(E),E.releaseInitialPrecedingContent()):vh=0);const Tt=!Ke||Ke===E.streamScrollDirection;if(N$=E.streamScrollDirection<0,Tt){const Pt=Math.abs(se)/Ht;E.streamScrollVelocity=E.streamScrollVelocity*.55+Pt*.45}E.streamLastScrollAt=Q,vo(E),!nd&&(nd=requestAnimationFrame(()=>{if(nd=0,!!qt()&&(wi>0&&x.scrollHeight-x.scrollTop-x.clientHeight<=80&&rp(),x.scrollTop<=10&&!E.streamItemMap.has(1)?(xa=0,od=1,ea&&(clearTimeout(ea),ea=0,Gu=0),cT(null,{maxBatches:vc(E).batches})):ip(N$),E0())){const Pt=vc(E);dd({maxBatches:Pt.batches,keepFilling:Pt.batches>1})}}))},mr(x,"scroll",$$,{passive:!0}),T$=()=>{E.timeline?.releaseJump&&E.timeline.releaseJump(),E.streamScrollDirection=0,ss(E),E.releaseInitialPrecedingContent()},mr(x,"touchstart",T$,{passive:!0}),A$=Q=>{["ArrowUp","PageUp","Home"].includes(Q.key)||Q.key===" "&&Q.shiftKey?(E.timeline?.releaseJump&&E.timeline.releaseJump(),E.streamScrollDirection=-1,ss(E),C0(-1),Kf(E),E.releaseInitialPrecedingContent(),ip(!0)):(["ArrowDown","PageDown","End"].includes(Q.key)||Q.key===" "&&!Q.shiftKey)&&(E.timeline?.releaseJump&&E.timeline.releaseJump(),E.streamScrollDirection=1,ss(E),C0(1))},mr(ne,"keydown",A$),ee("load-target-post");let k=!1;const j=Q=>Sr(Q,{target:!0});if(k=await E.loadPost(Math.max(1,a),{priority:rt.target,onSource:j}),a>1&&!k&&(k=await E.loadPost(a,{onSource:j,scope:"around"})),a>1&&!k&&!h&&(k=await E.loadPost(a,{priority:rt.target,forceRefresh:!0,onSource:j})),d&&a>0&&!h){if(ee("load-target-boosts"),k=await E.loadPost(a,{ensureAround:!0,priority:rt.target,forceRefresh:!0,onSource:j,scope:"around"})||k,!qt())return;const Q=E.streamNodeMap.get(a);Q&&gm(Q,Q._ldpPostData||{},E)}if(a>1&&!k&&!h){const Q=Sq(a),Ve=+(Q?.post_number||0);if(Ve&&(c=a,Mc([Q],E),a=Ve,k=!!Wo(E,a,{mount:!0}),ne.dataset.targetPostNumber=String(a),I.targetPostNumber=a,E.initialTargetHydrationBoundary=a,b&&+b.postNumber===c&&(b={...b,postNumber:a,postOffset:0})),!k)throw new Error(`目标楼层 #${a} 加载失败,请重试`)}if(a>1&&k&&!h&&!d&&(ee("load-target-neighborhood"),await E.loadPost(a,{ensureAround:!0,priority:rt.visible,onSource:j,scope:"around"}),!qt()))return;const B=E.streamNodeMap.get(a);l=a>1&&+(B?._ldpPostData?.reply_to_post_number||0)>0?a:0,a<=1&&!k&&await dd({maxBatches:1,persistFetchedPosts:!0}),ee("mount-initial-stream"),Sr("render"),a>1&&k&&!h&&(E.releaseInitialPrecedingContent(),eE(E,a)),wc(E);let Z=null;const J=m&&a?()=>(Z=ey(a,E,m.text,{source:m.source,active:m.active}),Z):null,ye=Math.max(1,+a||1);if(a>1){if(k&&!h&&!l){const Q=!m&&+a==+(E.totalPosts||0),Ve={...Ds,resolveVisualTarget:J,alignNearest:!!m,alignBottom:Q,alignTop:!Q};if(!qs(a,E,Ve))throw new Error(`目标楼层 #${a} 定位失败,请重试`);ss(E)}ne.classList.remove("ldp-initial-target-layout"),E.initialTargetPositionPending=!1}else if(E.initialTargetPositionPending=!1,!qs(ye,E,{...Ds,resolveVisualTarget:J,alignNearest:!!m,alignTop:!0,highlight:!!m}))throw new Error(`目标楼层 #${ye} 定位失败,请重试`);if(k&&(a>1||E.totalPosts>ye)&&(Ch=Zp(async()=>{if(Ch=null,!qt())return;const Q=E.streamNodeMap.get(ye);!Q||!Q.isConnected||await dT(Q)},1400)),!qt()||(w&&!E.onlyOp&&E.op&&await Zu(),!qt()))return;if(b&&!l){const Q=Math.max(1,Number(b.postNumber)||a||1),Ve=Wo(E,Q,{mount:!0}),gt=gy(Ve,E,b.postOffset);if(!(!!Ve&&qs(Q,E,{...Ds,visualNode:Ve,alignOffset:gt,highlight:!1})))throw new Error(`历史位置 #${Q} 恢复失败,请重试`);wc(E),ss(E)}if(!qt())return;if(l&&!await E.timeline.jumpTo(l,{targetPost:B?._ldpPostData}))throw new Error(`目标楼层 #${l} 完整讨论定位失败,请重试`);if(!qt())return;if(R&&!l){if(await $k(ne),!qt())return;E.timeline.sync();const Q=b?Math.max(1,Number(b.postNumber)||a||1):a;let Ve=!b&&Z&&Z.isConnected?Z:Wo(E,Q);if(Q>0&&!hy(Ve,E)){const gt=Wo(E,Q,{mount:!0}),Ht=!b&&Z&&Z.isConnected?Z:gt,je=b?gy(Ht,E,b.postOffset):NaN,A=!b&&!m&&+Q==+(E.totalPosts||0);if(gt&&qs(Q,E,{...Ds,visualNode:Ht,resolveVisualTarget:b?null:J,alignOffset:je,alignNearest:!b&&!!m,alignBottom:A,alignTop:!b&&!m&&!A,highlight:!1})){if(ss(E),await _a(),!qt())return;E.timeline.sync(),Ve=!b&&Z&&Z.isConnected?Z:Wo(E,Q)}}if(Q>0&&!hy(Ve,E))throw new Error(`目标楼层 #${Q} 布局稳定校验失败,请重试`)}if(ee("activate-reader"),T.state="active",o>0&&o!==Number(e)&&MS(o),ra.disabled=!1,g&&await CM(g,E),E.timeline.update(),yn(Mt),u?.replyWindow&&qt())try{E.replyThreadWindow?._ldpClose?.(!1),await By(u.replyWindow.rootPostNumber,E,{returnViewport:b,restorePoint:u.replyWindow.point})}catch(Q){console.warn("[LDP] reader discussion restore failed",Q)}c&&mt(`楼层 #${c} 已不可用,已跳到相邻楼层 #${a}`),qt()&&Vg(i,a||1,Y?.seenPostNumbers)}catch(i){if(Xu||r!==vu||!T.isCurrent("opening")||!ne.isConnected)return;const p=_e;if(Mk(i,p)){const ye=s+1,Q=Rk(ye,i);console.warn("[LDP] reader open interrupted; retrying automatically",{phase:p,topicId:String(e||""),targetPostNumber:a,sessionId:ve,attempt:ye,retryDelay:Q}),T.state="retrying",ne.classList.remove("ldp-initial-target-layout"),E.initialTargetPositionPending=!1,ra.disabled=!0,Ph({preserveShell:!0}),kt&&(kt.textContent="载入暂时中断,正在自动重试"),xr&&(xr.textContent=`第 ${ye} 次恢复将在 ${Math.max(1,Math.ceil(Q/1e3))} 秒内开始…`),zt&&nt(zt,`载入暂时中断,第 ${ye} 次自动恢复即将开始`),ep();const Ve=X(".ldp-close"),gt=()=>{ep(),Ve?.removeEventListener("click",Ht),ne._ldpCancelAutoRetryWait===gt&&delete ne._ldpCancelAutoRetryWait},Ht=()=>{gt(),ap()};ne._ldpCancelAutoRetryWait=gt,Ve?.addEventListener("click",Ht),Ku=setTimeout(()=>{gt(),!(Xu||!T.isCurrent("retrying")||!ne.isConnected)&&Promise.resolve(co(e,{targetPostNumber:a,topicNavMetadata:K,restoreViewport:b,restoreState:u||void 0,restoreOnlyOp:w,historyNavigation:oe,settleTargetJump:R,quoteHighlight:m,quoteSourceTarget:g,refreshTargetBoosts:d,autoRetryAttempt:ye})).catch(je=>{console.warn("[LDP] reader automatic retry setup failed",je)})},Q);return}console.error("[LDP] reader open failed",{phase:p,topicId:String(e||""),targetPostNumber:a,sessionId:ve},i),T.state="failed",ne.classList.remove("ldp-initial-target-layout"),E.initialTargetPositionPending=!1;const y=Number(i?.status||0)===429&&i&&i.rateLimitScope==="endpoint",C=Number(i?.status||0)===429&&!y;Ph({preserveShell:!0}),yn(Mt);const $=Ne("div");$.className="ldp-error",$.setAttribute("role","alert"),$.innerHTML=`
`,T.querySelector(".ldp-error-message").textContent=M?"请求过于频繁,阅读器已暂停加载。倒计时结束后将自动打开过盾浮窗,验证通过后浮窗会自动关闭。":y?"当前请求地址暂时返回 429,已只暂停该地址;其他阅读请求不受影响,请稍后重试。":`加载失败(${p}):${i?.message?i.message:"未知错误"}`,I.replaceChildren(T);const L=T.querySelector(".ldp-error-retry"),j=T.querySelector(".ldp-error-challenge");let q=!1,re=!1;const J=()=>{if(Vu(),!se.isConnected||R.state!=="failed")return;const be=n$();L.disabled=q||be>0,L.textContent=q?"正在重新加载…":be>0?`限流冷却中(${Math.ceil(be/1e3)} 秒)`:"重新加载",be>0?ju=setTimeout(J,Math.min(1e3,be+20)):M&&!re&&j&&(re=!0,Oy(j.href))};T.addEventListener("click",be=>{const X=be.target instanceof Element?be.target:null;if(X?.closest(".ldp-error-close")){s0();return}X?.closest(".ldp-error-retry")&&(L.disabled||q||(q=!0,J(),Promise.resolve(lo(e,{targetPostNumber:a,topicNavMetadata:V,restoreViewport:b,restoreState:u||void 0,historyNavigation:te,settleTargetJump:A,refreshTargetBoosts:d})).catch(Ue=>{console.error("[LDP] reader retry failed",Ue),q=!1,J()})))}),J()}}function hm(e){let t;try{t=new URL(e,location.href)}catch{return!1}return t.searchParams.has(Nl)}function vI(){try{const e=sessionStorage.getItem(Uh)==="1";return e&&sessionStorage.removeItem(Uh),e}catch{return!1}}function wI(e){let t=null;try{t=window.open("about:blank","_blank")}catch{}if(!t)return!1;try{t.sessionStorage.setItem(Uh,"1")}catch{}try{t.opener=null}catch{}try{t.location.replace(e)}catch{t.location.href=e}return!0}let Wo=null,gm=0,Os=null,TM=0;const fu=`${Date.now().toString(36)}-${Math.random().toString(36).slice(2,10)}`;function AM(){try{const e=JSON.parse(localStorage.getItem(ip)||"null");return e&&typeof e=="object"?e:null}catch{return null}}function xy(e,t=vT){const r=Date.now();e==="active"&&(TM=r);const o={owner:fu,state:e,updatedAt:r,expiresAt:r+t};try{localStorage.setItem(ip,JSON.stringify(o))}catch{}return o}function qy(){const e=AM();if(!(!e||e.owner!==fu))try{localStorage.removeItem(ip)}catch{}}function SI(e){if(e.key!==ip||!e.newValue)return;let t=null;try{t=JSON.parse(e.newValue)}catch{}if(!t||t.owner===fu||t.state!=="passed")return;Sg()&&ft("验证已在另一窗口通过,已重置请求流限速")}window.addEventListener("storage",SI);function bm(){gm&&clearInterval(gm),gm=0}function _I(e){try{const t=e?.document;if(!t)return{accessible:!1,challenge:!1,ready:!1,status:0};const r=String(e.location?.href||""),o=String(t.title||""),n=!!(e._cf_chl_opt||t.querySelector('script[src*="/cdn-cgi/challenge-platform/"],#challenge-running')||/^(?:Just a moment|请稍候)/i.test(o)),s=e.performance?.getEntriesByType?e.performance.getEntriesByType("navigation")[0]:null;return{accessible:!0,challenge:n,ready:t.readyState==="complete"&&r!=="about:blank",status:Math.max(0,Number(s?.responseStatus)||0)}}catch{return{accessible:!1,challenge:!1,ready:!1,status:0}}}function CI(e){try{const t=e?.document;return t?!!t.querySelector('meta[name="discourse-base-uri"],#main-outlet'):!1}catch{return!1}}function Dy(e){const t=Sg();xy("passed",10*1e3);try{Wo.close()}catch{}Wo=null,Os=null,bm(),e?ft(e):t&&ft("验证已通过,已重置请求流限速")}function ym(e){try{Wo?.close()}catch{}Wo=null,Os=null,bm(),qy(),e&&ft(e)}function EI(e,t={}){if(!e||e.verificationStarted||e.verificationFailed)return;e.verificationStarted=!0;const r=Math.max(0,Number(t.status)||0);if(r>=200&&r<300){e.onFormSuccess&&e.onFormSuccess(),Dy("已收到后端 2xx,楼层已标记为已读");return}if(!e.verify){ym("验证请求未完成;楼层仍保持未读");return}Promise.resolve(e.verify()).then(o=>{if(Os===e){if(o===!0){Dy("验证通过且后端已返回 2xx,楼层已标记为已读");return}ym("验证后接口仍未返回 2xx;楼层保持未读,本次不会重复触发")}}).catch(o=>{console.warn("[LDP] verify read tracking challenge failed",o),Os===e&&ym("验证后接口仍未返回 2xx;楼层保持未读,本次不会重复触发")})}async function MI(e,t,r,o){const n=await R_();if(!e||e.closed||Wo!==e||Os!==o)return!1;const s="ldp-cloudflare-challenge";try{e.name=s}catch{}const a=ke("form");a.method="POST",a.action=new URL(t,location.href).href,a.target=s,a.hidden=!0;const l=(c,d)=>{const u=ke("input");u.type="hidden",u.name=c,u.value=String(d),a.appendChild(u)};l("authenticity_token",n),Object.entries(r||{}).forEach(([c,d])=>{Array.isArray(d)?d.forEach(u=>l(`${c}[]`,u)):d!=null&&l(c,d)}),document.body.appendChild(a),o.formSubmitted=!0,o.submittedAt=Date.now();try{a.submit()}finally{a.remove()}return!0}function Oy(e,t={}){if(Os?.formParams&&!Os.verificationFailed){try{Wo.focus()}catch{}return!0}if(Wo&&!Wo.closed){try{Wo.focus()}catch{}return!0}const r=AM();if(r&&r.expiresAt>Date.now()&&r.owner!==fu)return r.state==="passed"?Sg()&&ft("验证已通过,已重置请求流限速"):ft("已有一个过盾窗口正在处理,请在该窗口完成验证"),!0;xy("active");const o=window.screen,n=Math.min(760,Math.max(420,o.availWidth||760)),s=Math.min(720,Math.max(520,o.availHeight||720)),a=Math.max(0,Math.round((o.availLeft||0)+((o.availWidth||n)-n)/2)),l=Math.max(0,Math.round((o.availTop||0)+((o.availHeight||s)-s)/2)),c=`popup=yes,width=${n},height=${s},left=${a},top=${l},resizable=yes,scrollbars=yes`;let d=null;try{d=window.open(t.formParams?"about:blank":e,"ldp-cloudflare-challenge",c)}catch{}if(!d)return qy(),ft("验证浮窗被浏览器拦截,请允许本站弹出式窗口"),!1;Wo=d;const u={owner:fu,formParams:t.formParams||null,formSubmitted:!1,submittedAt:0,sawChallenge:!1,verificationStarted:!1,verificationFailed:!1,verify:D(t.verify)?t.verify:null,onFormSuccess:D(t.onFormSuccess)?t.onFormSuccess:null};Os=u;try{d.focus()}catch{}return u.formParams&&(ft("请在浮窗完成验证;后端返回 2xx 前楼层仍保持未读"),MI(d,e,u.formParams,u).catch(f=>{console.warn("[LDP] open read tracking challenge failed",f),ym("无法提交验证请求,楼层仍保持未读")})),bm(),gm=setInterval(()=>{if(!Wo||Wo.closed){Wo=null,Os=null,bm(),qy();return}Date.now()-TM>=5e3&&xy("active");const f=Os,m=_I(Wo);if(m.challenge){f&&(f.sawChallenge=!0);return}if(!(!f||f.verificationFailed||!m.ready)){if(!f.formParams){if(!CI(Wo))return;Dy();return}!f.formSubmitted||Date.now()-f.submittedAt<500||f.verificationStarted||EI(f,{status:m.status,challenged:f.sawChallenge})}},250),!0}function $I(){try{const e=new URL(location.href);if(!e.searchParams.has(Nl))return;e.searchParams.delete(Nl),history.replaceState(history.state,"",e.pathname+e.search+e.hash)}catch{}}function co(e){let t;try{t=new URL(e,location.href)}catch{return null}if(/^https?:$/i.test(t.protocol)&&t.origin!==vt)return null;const r=t.pathname.split("/").filter(Boolean);if(r[0]!=="t")return null;const o=r.slice(1);if(/^\d+$/.test(o[0]||"")){const a=o[0],l=/^\d+$/.test(o[1]||"")?o[1]:"";return{topicId:a,postNumber:l?+l:0}}const n=o[1],s=/^\d+$/.test(o[2]||"")?o[2]:"";return/^\d+$/.test(n||"")?{topicId:n,postNumber:s?+s:0}:null}function mu(e){return e?Oe.openTopicsAtFirstPost?1:+e.postNumber||0:0}function RI(e){document.body?e():document.addEventListener("DOMContentLoaded",e,{once:!0})}function ci(){Lt.classList.remove("ldp-native-reader-trigger-visible"),An(".ldp-native-reader-trigger-item").forEach(t=>{t.hidden=!0});const e=to(".ldp-native-reader-trigger");e&&!e.closest(".ldp-native-reader-trigger-item")&&(e.hidden=!0)}function Fy(){return document.querySelector(".d-header-icons .current-user:has(img.avatar)")}function Fs(){if(!document.body)return;const e=co(location.href);let t=e?{...e,source:"route",title:""}:null;if(!t){const d=Og()[0],u=Number(d?.topicId);u&&(t={topicId:u,postNumber:Math.max(1,Number(d.postNumber)||1),source:"history",title:String(d.title||"")})}const r=t&&String(t.topicId);if(Ct){ci();return}const o=Fy();if(!o){ci();return}Lt.classList.add("ldp-native-reader-trigger-visible");let n=document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]');n||(n=ke("div"),n.className="ldp-native-reader-trigger-item",n.dataset.ldpPortal="1"),n.parentElement!==o&&o.appendChild(n),An(".ldp-native-reader-trigger-item").forEach(d=>{d!==n&&(d.hidden=!0)});let s=n.querySelector(".ldp-native-reader-trigger");s||(s=ke("button"),s.className="ldp-native-reader-trigger",s.type="button",s.innerHTML=ue("maximize2"),s.addEventListener("click",async d=>{bt(d);const u=Number(s.dataset.topicId),f=Number(s.dataset.postNumber)||0,m=s.dataset.triggerSource;if(!u)return;if(vn="",ci(),Lt.classList.contains("ldp-reader-sidepanel-active")&&!await gb())return il(Fs,String(u));const g=co(location.href);if(m==="route"){if(!g||Number(g.topicId)!==u){Fs();return}Lt.classList.add("ldp-route-takeover")}else if(g){Fs();return}lo(u,{targetPostNumber:m==="route"?mu(g):f,topicNavMetadata:ma(null,u)})}),n.appendChild(s)),n.style.removeProperty("left"),n.style.removeProperty("top"),delete n.dataset.ldpPlacement,n.hidden=!1,s.hidden=!1,s.dataset.topicId=r||"",s.dataset.triggerSource=t?t.source:"empty-history";const a=t?t.source==="route"?mu(t):t.postNumber:0;s.dataset.postNumber=String(a||"");const l=Lt.classList.contains("ldp-reader-sidepanel-active"),c=t?t.source==="history"?t.title?`打开历史首项:${t.title}`:"打开历史首项":l?"切换到浮窗阅读器":Oe.openTopicsAtFirstPost?"从 #1 打开浮窗阅读器":t.postNumber?`从 #${t.postNumber} 打开浮窗阅读器`:"打开浮窗阅读器":"暂无浏览历史";rt(s,c)}function TI(){if(!document.body||document.body._ldpNativeReaderTriggerObserver)return;const e='.d-header,.d-header-wrap,.d-header-icons,.current-user,.ldp-native-reader-trigger-item[data-ldp-portal="1"]',t=a=>!a||a.nodeType!==Node.ELEMENT_NODE?!1:a.matches(e)||!!a.querySelector(e);let r=null,o=0,n=null;const s=()=>{const l=Fy()?.closest(".d-header-wrap,.d-header,header")||document.querySelector(".d-header-wrap,.d-header");if(l===r&&l?.isConnected)return;if(n.disconnect(),r=l||null,!r){n.observe(document.body,{childList:!0,subtree:!0});return}n.observe(r,{childList:!0,subtree:!0});let c=r.parentElement;for(;c&&(n.observe(c,{childList:!0}),c!==document.body);)c=c.parentElement};n=new MutationObserver(a=>{const l=!r?.isConnected||a.some(d=>d.target!==r&&!r.contains(d.target)),c=a.some(d=>[...d.addedNodes,...d.removedNodes].some(t));!l&&!c||o||(o=requestAnimationFrame(()=>{o=0,s();const d=Fy(),u=document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]');d&&(!u||u.parentElement!==d)&&Fs()}))}),s(),document.body._ldpNativeReaderTriggerObserver=n}function il(e,t=vn){return Lt.classList.remove("ldp-route-takeover"),vn=t,e(),!1}async function PM(){if(hm(location.href))return il(ci);const e=co(location.href);if(!e)return Iy="",il(Fs,"");const t=String(e.topicId),r=`${t}:${Math.max(0,Number(e.postNumber)||0)}`,o=Iy!==r;Iy=r;const n=Ct&&Ct._ldpReaderShell?.readerWorkspace;if(vn===t&&n?.isEmbedded())return il(Fs);if(Lt.classList.contains("ldp-reader-sidepanel-active")&&!await gb())return il(Fs,t);const s=co(location.href);if(!s||String(s.topicId)!==t)return il(ci);if(vn&&vn!==t&&(vn=""),vn===t)return il(Fs);if(ci(),Ct&&Ct.dataset.topicId===t){const a=Math.max(0,Number(s.postNumber)||0);if(!o||!a)return!0;const l=Ct._ldpReaderShell?.activeContext;return l?.readerSession?.state==="active"&&l.timeline?(await l.timeline.jumpTo(a),!0):(lo(s.topicId,{targetPostNumber:a,topicNavMetadata:ma(null,s.topicId)}),!0)}return Lt.classList.add("ldp-route-takeover"),lo(s.topicId,{targetPostNumber:Ct&&s.postNumber?+s.postNumber:mu(s),topicNavMetadata:ma(null,s.topicId)}),!0}function NM(){if(MM)return!0;const e=Ft("discourse/lib/plugin-api");if(!e||!D(e.withPluginApi))return!1;let t=fm();try{e.withPluginApi(r=>{r.onPageChange(()=>{setTimeout(()=>{const o=fm();o!==t&&(t=o,PM().catch(n=>{console.warn("[LDP] route signal takeover failed",n)}))},0)})})}catch{return!1}return MM=!0,!0}function AI(){const e=Ct,t=e?._ldpReaderShell?.activeContext;if(!e||!t||!t.readerSession?.isCurrent("opening","active"))return;e.querySelectorAll(".ldp-post").forEach(n=>{const s=!!n.querySelector(":scope > .ldp-reactions .ldp-reaction-picker:not([hidden])");if(As(n,t),!s)return;const a=n.querySelector(":scope > .ldp-reactions"),l=a?.querySelector(".ldp-reaction-picker"),c=a?.querySelector("[data-reaction-picker]");l&&(l.hidden=!1),c&&ar(c,!0)});const r=t.topicActionRail;if(!r?._ldpPostData)return;const o=r.querySelector(".ldp-like");o&&Yi(o,jd(r._ldpPostData,t)),As(r,t,{collectLike:!1,railPicker:!0}),r.querySelector(":scope > .ldp-reactions > .ldp-reaction-summary")?.remove()}function hu(e){ho({tags:e}).catch(t=>{console.warn("[LDP] Discourse event cache invalidation failed",t)})}function kM(){if($M)return!0;const e=It("service:app-events"),t=It("service:message-bus");if(!e||!D(e.on)||!t||!D(t.subscribe))return!1;const r=document,o=[],n=(a,l)=>{!e||!D(e.on)||(e.on(a,r,l),o.push({type:"app",name:a,handler:l}))},s=a=>{const l=String(a?.name||"");if(!["discourse_reactions_enabled_reactions","discourse_reactions_reaction_for_like"].includes(l))return;const c=It("service:site-settings");c&&(c[l]=a.value),nc({refresh:!0}).then(AI)};try{t.subscribe("/client_settings",s),o.push({type:"message-bus",name:"/client_settings",handler:s})}catch{return!1}return n("notifications:changed",()=>{Nb(),hu(["notifications"])}),n("bookmarks:changed",()=>{hu(["bookmarks"]),Ct?._ldpReaderShell?.refreshBookmarksPanel?.()}),n("discourse-reactions:reaction-toggled",a=>{const l=a?.post,c=Number(l?.id||0),d=Number(l?.topic_id||l?.topic?.id||0),u=["reactions-given"];c&&u.push(`post:${c}`),d&&u.push(`topic:${d}`),hu(u);const f=Ct?._ldpReaderShell?.activeContext;if(!c||!f||d&&d!==Number(f.topicId)){d&&(Vl(d),$r.delete(String(d)),Zo.delete(String(d)));return}f.loader?.persistFetchedPosts?.([l]),lu([l],f,{refreshContent:!0}),f.refreshBookmarksPanel?.({post:l,topic:f.topicData})}),n("post:created",a=>{const l=Number(a?.id||0),c=Number(a?.topic_id||a?.topic?.id||0),d=[];l&&d.push(`post:${l}`),c&&d.push(`topic:${c}`),d.length&&hu(d);const u=Number(Ct?._ldpReaderShell?.activeContext?.topicId||0);c&&c!==u&&(Vl(c),$r.delete(String(c)),Zo.delete(String(c)))}),n("composer:edited-post",()=>{const a=Number(co(location.href)?.topicId||0);if(!a)return;hu([`topic:${a}`]);const l=Number(Ct?._ldpReaderShell?.activeContext?.topicId||0);a!==l&&(Vl(a),$r.delete(String(a)),Zo.delete(String(a)))}),o.length?($M={appEvents:e,messageBus:t,bindings:o},!0):!1}function PI(e,t){if(e.type!=="click"||e.detail<=0)return null;const r=Fr(e,"tr.topic-list-item,.topic-list-item,.latest-topic-list-item");if(!r||r.closest(".ldp-overlay"))return null;const o=r.getBoundingClientRect();return o.width<=0||o.height<=0?null:{card:r,topicId:String(t||""),clientY:e.clientY,offsetY:Math.max(0,Math.min(o.height,e.clientY-o.top))}}function NI(e){if(!e)return!1;let t=e.card?.isConnected?e.card:null;if(!t&&e.topicId&&(t=[...document.querySelectorAll("tr.topic-list-item,.topic-list-item,.latest-topic-list-item")].find(s=>{if(s.closest(".ldp-overlay"))return!1;const a=s.dataset.topicId||s.getAttribute("data-topic-id"),l=s.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]');return String(a||co(l?.href)?.topicId||"")===e.topicId})||null),!t)return!1;document.querySelectorAll("[data-ldp-reader-active-topic]").forEach(n=>{n!==t&&n.removeAttribute("data-ldp-reader-active-topic")}),t.dataset.ldpReaderActiveTopic="true";const o=t.getBoundingClientRect().top+Number(e.offsetY||0)-Number(e.clientY||0);return Math.abs(o)>=.5&&window.scrollBy({top:o,behavior:"auto"}),!0}function kI(e){const t=Fr(e,".fk-d-menu,.menu-panel,.search-menu,.user-menu,.hamburger-panel,.sidebar-hamburger-dropdown,.chat-drawer");if(!t||t.closest(".ldp-overlay,.ldp-reader-portal-host,.sciapp-ldp-owned")||t.dataset.ldpReaderTopLayer)return null;const r=t.matches(".fk-d-menu,.menu-panel,.chat-drawer")?t:t.closest(".fk-d-menu,.menu-panel,.chat-drawer")||t.querySelector(".fk-d-menu,.menu-panel,.chat-drawer")||t,o=d=>{let u;try{u=d()}catch{return null}return Promise.resolve(u).catch(()=>{}).then(()=>new Promise(f=>{const m=performance.now()+180,g=()=>{if(!r.isConnected||!tn(r)||performance.now()>=m){f();return}requestAnimationFrame(g)};requestAnimationFrame(g)}))},n=r.matches(".fk-d-menu")?String(r.dataset.identifier||""):"",s=n&&It("service:menu");if(n&&D(s?.close))return o(()=>s.close(n));const a=r.matches(".chat-drawer")?r.querySelector(".chat-drawer-header__close-btn"):null;if(a&&D(a.click))return o(()=>a.click());const l=[r.id,t.id].filter(Boolean);let c=[...document.querySelectorAll('[aria-expanded="true"]')].find(d=>l.some(u=>[d.getAttribute("aria-controls"),d.getAttribute("aria-owns")].some(f=>String(f||"").split(/\s+/).includes(u))));if(!c){const d=Fr(e,".search-menu,.user-menu,.hamburger-panel,.sidebar-hamburger-dropdown"),u=d?.classList.contains("search-menu")?".d-header-icons .search-dropdown":d?.classList.contains("user-menu")?".d-header-icons .current-user":d?".d-header-icons .hamburger-dropdown,.hamburger-dropdown":"";if(u){const f=[...document.querySelectorAll(u)];c=f.find(m=>m.getAttribute("aria-expanded")==="true")||f[0]}}return!c||!D(c.click)?null:o(()=>c.click())}function LI(e){if(e.type==="click"&&e.button&&e.button!==0)return!1;const t=Fr(e,".ldp-reader-queue-add[data-reader-queue-topic-id]");if(t){bt(e);const P=Number(t.dataset.readerQueueTopicId);if(dn(P))rf(P)&&ft("已从阅读队列移除");else{const H=tf(P,aS(t,P));H&&ft(`已加入阅读队列:${H.title}`)}return!0}let r=Fr(e,"a[href]");if(!r&&Lt.classList.contains("ldp-reader-workspace")){const P=Fr(e,"tr.topic-list-item,.topic-list-item,.latest-topic-list-item"),$=Fr(e,'button,input,select,textarea,[role="button"],[contenteditable="true"]');P&&!P.closest(".ldp-overlay")&&!$&&(r=P.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]'))}if(!r)return!1;const o=r.closest(".search-menu");if(o&&!o.closest(".ldp-overlay,.ldp-reader-portal-host,.sciapp-ldp-owned")&&!r.closest(".search-result-topic,.search-result-post"))return!1;if(r.classList.contains("ldp-rate-limit-challenge")||r.classList.contains("ldp-error-challenge"))return bt(e),Oy(r.href),!0;const n=r.closest(".ldp-notification-item"),s=n?.classList.contains("ldp-notification-message-item")===!0,a=s?Math.max(0,Number(n.dataset.notificationTopicId)||0):0,l=s?Math.max(0,Number(n.dataset.notificationPostNumber)||0):0,c=a?{topicId:String(a),postNumber:l}:null,d=K1(n);d>0&&mn(`${vt}/notifications/mark-read`,"PUT",{id:d},null,{surviveReaderSwitch:!0}).catch(P=>{console.warn("[LDP] mark notification read failed",P)});const u=Ct&&Ct._ldpReaderShell?.readerWorkspace;if(u?.isEmbedded()&&r.getRootNode()===Bo&&!r.classList.contains("ldp-history-link")&&!r.classList.contains("ldp-rate-limit-challenge")&&!r.classList.contains("ldp-error-challenge")&&Ey(e,r,u))return!0;if(r.classList.contains("ldp-open")||hm(r.href))return!1;const f=c||co(r.getAttribute("href")||r.href);if(!f)return!1;const m=Ct?._ldpReaderShell,g=m?.activeContext;if(c&&l>0&&String(g?.topicId||"")===String(a)&&g?.readerSession?.state==="active"&&g.timeline)return bt(e),m.closeNotifications?.(),g.timeline.jumpTo(l).then(P=>{P||lo(a,{targetPostNumber:l,refreshTargetBoosts:!0})}).catch(()=>lo(a,{targetPostNumber:l,refreshTargetBoosts:!0})),!0;const b=aS(r,f.topicId);b.urlPostNumber=Math.max(0,Number(f.postNumber)||0);const w=PI(e,f.topicId);w&&D(r.blur)&&r.blur();const A=kI(e);bt(e),vn="",ci();const U=()=>lo(f.topicId,{targetPostNumber:r.dataset.ldpPreserveTargetPost==="1"?+f.postNumber||0:mu(f),topicNavMetadata:ma(r),readerQueueMetadata:b,hostTopicPointerAnchor:w,refreshTargetBoosts:s});return A?A.then(()=>requestAnimationFrame(U)):U(),!0}function II(e){if(e.key!=="F5")return!1;const r=Ct?._ldpReaderShell?.readerWorkspace;return!r||!r.isEmbedded()?!1:(e.preventDefault(),e.stopImmediatePropagation(),Zk()||ft(`${Ia.name} 原站刷新暂不可用,请稍后重试`),!0)}function By(e){if(e.button!==3&&e.button!==4)return!1;const t=Ct;if(!t||!t.isConnected)return!1;if(e.preventDefault(),e.stopImmediatePropagation(),e.type!=="mousedown")return!0;const r=e.button===3,o=t.querySelector(r?".ldp-reader-history-back":".ldp-reader-history-forward");return o&&!o.hidden&&!o.disabled?o.click():ft(r?"没有更早的阅读记录":"没有更新的阅读记录"),!0}function xI(){document._ldpReaderHostEventHandlersInstalled||(document._ldpReaderHostEventHandlersInstalled=!0,document.addEventListener("keydown",II,!0),document.addEventListener("mousedown",By,!0),document.addEventListener("mouseup",By,!0),document.addEventListener("auxclick",By,!0),document.addEventListener("click",LI,!0))}RI(async()=>{if(!await Jk()){console.warn(`[LDP] ${location.hostname} 未检测到 Discourse,阅读器未启动`);return}mT(),kE(),xI(),wg(),cT(),kM()||window.addEventListener("load",()=>{kM()||console.warn("[LDP] Discourse update events unavailable")},{once:!0}),NM()||window.addEventListener("load",()=>{NM()||console.warn("[LDP] Discourse page change signal unavailable")},{once:!0}),Xg(),TI(),BL();let e=!1;try{e=await PM()}catch(t){console.warn("[LDP] direct topic takeover failed",t)}if(!e){try{await gI()}catch(t){console.warn("[LDP] embedded reader reload restore failed",t)}Fs()}})})();
+
`,$.querySelector(".ldp-error-message").textContent=C?"请求过于频繁,阅读器已暂停加载。倒计时结束后将自动打开过盾浮窗,验证通过后浮窗会自动关闭。":y?"当前请求地址暂时返回 429,已只暂停该地址;其他阅读请求不受影响,请稍后重试。":`加载失败(${p}):${i?.message?i.message:"未知错误"}`,x.replaceChildren($);const k=$.querySelector(".ldp-error-retry"),j=$.querySelector(".ldp-error-challenge");let B=!1,Z=!1;const J=()=>{if(ep(),!ne.isConnected||T.state!=="failed")return;const ye=gR();k.disabled=B||ye>0,k.textContent=B?"正在重新加载…":ye>0?`限流冷却中(${Math.ceil(ye/1e3)} 秒)`:"重新加载",ye>0?Ku=setTimeout(J,Math.min(1e3,ye+20)):C&&!Z&&j&&(Z=!0,Yy(j.href))};$.addEventListener("click",ye=>{const Q=ye.target instanceof Element?ye.target:null;if(Q?.closest(".ldp-error-close")){g0();return}Q?.closest(".ldp-error-retry")&&(k.disabled||B||(B=!0,J(),Promise.resolve(co(e,{targetPostNumber:a,topicNavMetadata:K,restoreViewport:b,restoreState:u||void 0,historyNavigation:oe,settleTargetJump:R,refreshTargetBoosts:d})).catch(Ve=>{console.error("[LDP] reader retry failed",Ve),B=!1,J()})))}),J()}}function Cm(e){let t;try{t=new URL(e,location.href)}catch{return!1}return t.searchParams.has(Dl)}function OI(){try{const e=sessionStorage.getItem(Xh)==="1";return e&&sessionStorage.removeItem(Xh),e}catch{return!1}}function FI(e){let t=null;try{t=window.open("about:blank","_blank")}catch{}if(!t)return!1;try{t.sessionStorage.setItem(Xh,"1")}catch{}try{t.opener=null}catch{}try{t.location.replace(e)}catch{t.location.href=e}return!0}let Vo=null,Em=0,Hs=null,FM=0;const Su=`${Date.now().toString(36)}-${Math.random().toString(36).slice(2,10)}`;function BM(){try{const e=JSON.parse(localStorage.getItem(hp)||"null");return e&&typeof e=="object"?e:null}catch{return null}}function Vy(e,t=NT){const r=Date.now();e==="active"&&(FM=r);const o={owner:Su,state:e,updatedAt:r,expiresAt:r+t};try{localStorage.setItem(hp,JSON.stringify(o))}catch{}return o}function Gy(){const e=BM();if(!(!e||e.owner!==Su))try{localStorage.removeItem(hp)}catch{}}function BI(e){if(e.key!==hp||!e.newValue)return;let t=null;try{t=JSON.parse(e.newValue)}catch{}if(!t||t.owner===Su||t.state!=="passed")return;Tg()&&mt("验证已在另一窗口通过,已重置请求流限速")}window.addEventListener("storage",BI);function Mm(){Em&&clearInterval(Em),Em=0}function HI(e){try{const t=e?.document;if(!t)return{accessible:!1,challenge:!1,ready:!1,status:0};const r=String(e.location?.href||""),o=String(t.title||""),n=!!(e._cf_chl_opt||t.querySelector('script[src*="/cdn-cgi/challenge-platform/"],#challenge-running')||/^(?:Just a moment|请稍候)/i.test(o)),s=e.performance?.getEntriesByType?e.performance.getEntriesByType("navigation")[0]:null;return{accessible:!0,challenge:n,ready:t.readyState==="complete"&&r!=="about:blank",status:Math.max(0,Number(s?.responseStatus)||0)}}catch{return{accessible:!1,challenge:!1,ready:!1,status:0}}}function UI(e){try{const t=e?.document;return t?!!t.querySelector('meta[name="discourse-base-uri"],#main-outlet'):!1}catch{return!1}}function Qy(e){const t=Tg();Vy("passed",10*1e3);try{Vo.close()}catch{}Vo=null,Hs=null,Mm(),e?mt(e):t&&mt("验证已通过,已重置请求流限速")}function Rm(e){try{Vo?.close()}catch{}Vo=null,Hs=null,Mm(),Gy(),e&&mt(e)}function jI(e,t={}){if(!e||e.verificationStarted||e.verificationFailed)return;e.verificationStarted=!0;const r=Math.max(0,Number(t.status)||0);if(r>=200&&r<300){e.onFormSuccess&&e.onFormSuccess(),Qy("已收到后端 2xx,楼层已标记为已读");return}if(!e.verify){Rm("验证请求未完成;楼层仍保持未读");return}Promise.resolve(e.verify()).then(o=>{if(Hs===e){if(o===!0){Qy("验证通过且后端已返回 2xx,楼层已标记为已读");return}Rm("验证后接口仍未返回 2xx;楼层保持未读,本次不会重复触发")}}).catch(o=>{console.warn("[LDP] verify read tracking challenge failed",o),Hs===e&&Rm("验证后接口仍未返回 2xx;楼层保持未读,本次不会重复触发")})}async function zI(e,t,r,o){const n=await F_();if(!e||e.closed||Vo!==e||Hs!==o)return!1;const s="ldp-cloudflare-challenge";try{e.name=s}catch{}const a=Ne("form");a.method="POST",a.action=new URL(t,location.href).href,a.target=s,a.hidden=!0;const l=(c,d)=>{const u=Ne("input");u.type="hidden",u.name=c,u.value=String(d),a.appendChild(u)};l("authenticity_token",n),Object.entries(r||{}).forEach(([c,d])=>{Array.isArray(d)?d.forEach(u=>l(`${c}[]`,u)):d!=null&&l(c,d)}),document.body.appendChild(a),o.formSubmitted=!0,o.submittedAt=Date.now();try{a.submit()}finally{a.remove()}return!0}function Yy(e,t={}){if(Hs?.formParams&&!Hs.verificationFailed){try{Vo.focus()}catch{}return!0}if(Vo&&!Vo.closed){try{Vo.focus()}catch{}return!0}const r=BM();if(r&&r.expiresAt>Date.now()&&r.owner!==Su)return r.state==="passed"?Tg()&&mt("验证已通过,已重置请求流限速"):mt("已有一个过盾窗口正在处理,请在该窗口完成验证"),!0;Vy("active");const o=window.screen,n=Math.min(760,Math.max(420,o.availWidth||760)),s=Math.min(720,Math.max(520,o.availHeight||720)),a=Math.max(0,Math.round((o.availLeft||0)+((o.availWidth||n)-n)/2)),l=Math.max(0,Math.round((o.availTop||0)+((o.availHeight||s)-s)/2)),c=`popup=yes,width=${n},height=${s},left=${a},top=${l},resizable=yes,scrollbars=yes`;let d=null;try{d=window.open(t.formParams?"about:blank":e,"ldp-cloudflare-challenge",c)}catch{}if(!d)return Gy(),mt("验证浮窗被浏览器拦截,请允许本站弹出式窗口"),!1;Vo=d;const u={owner:Su,formParams:t.formParams||null,formSubmitted:!1,submittedAt:0,sawChallenge:!1,verificationStarted:!1,verificationFailed:!1,verify:D(t.verify)?t.verify:null,onFormSuccess:D(t.onFormSuccess)?t.onFormSuccess:null};Hs=u;try{d.focus()}catch{}return u.formParams&&(mt("请在浮窗完成验证;后端返回 2xx 前楼层仍保持未读"),zI(d,e,u.formParams,u).catch(f=>{console.warn("[LDP] open read tracking challenge failed",f),Rm("无法提交验证请求,楼层仍保持未读")})),Mm(),Em=setInterval(()=>{if(!Vo||Vo.closed){Vo=null,Hs=null,Mm(),Gy();return}Date.now()-FM>=5e3&&Vy("active");const f=Hs,m=HI(Vo);if(m.challenge){f&&(f.sawChallenge=!0);return}if(!(!f||f.verificationFailed||!m.ready)){if(!f.formParams){if(!UI(Vo))return;Qy();return}!f.formSubmitted||Date.now()-f.submittedAt<500||f.verificationStarted||jI(f,{status:m.status,challenged:f.sawChallenge})}},250),!0}function WI(){try{const e=new URL(location.href);if(!e.searchParams.has(Dl))return;e.searchParams.delete(Dl),history.replaceState(history.state,"",e.pathname+e.search+e.hash)}catch{}}function uo(e){let t;try{t=new URL(e,location.href)}catch{return null}if(/^https?:$/i.test(t.protocol)&&t.origin!==vt)return null;const r=t.pathname.split("/").filter(Boolean);if(r[0]!=="t")return null;const o=r.slice(1);if(/^\d+$/.test(o[0]||"")){const a=o[0],l=/^\d+$/.test(o[1]||"")?o[1]:"";return{topicId:a,postNumber:l?+l:0}}const n=o[1],s=/^\d+$/.test(o[2]||"")?o[2]:"";return/^\d+$/.test(n||"")?{topicId:n,postNumber:s?+s:0}:null}function _u(e){return e?qe.openTopicsAtFirstPost?1:+e.postNumber||0:0}function VI(e){document.body?e():document.addEventListener("DOMContentLoaded",e,{once:!0})}function ui(){It.classList.remove("ldp-native-reader-trigger-visible"),Pn(".ldp-native-reader-trigger-item").forEach(t=>{t.hidden=!0});const e=ro(".ldp-native-reader-trigger");e&&!e.closest(".ldp-native-reader-trigger-item")&&(e.hidden=!0)}function Xy(){return document.querySelector(".d-header-icons .current-user:has(img.avatar)")}function Us(){if(!document.body)return;const e=uo(location.href);let t=e?{...e,source:"route",title:""}:null;if(!t){const d=Wg()[0],u=Number(d?.topicId);u&&(t={topicId:u,postNumber:Math.max(1,Number(d.postNumber)||1),source:"history",title:String(d.title||"")})}const r=t&&String(t.topicId);if(Ct){ui();return}const o=Xy();if(!o){ui();return}It.classList.add("ldp-native-reader-trigger-visible");let n=document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]');n||(n=Ne("div"),n.className="ldp-native-reader-trigger-item",n.dataset.ldpPortal="1"),n.parentElement!==o&&o.appendChild(n),Pn(".ldp-native-reader-trigger-item").forEach(d=>{d!==n&&(d.hidden=!0)});let s=n.querySelector(".ldp-native-reader-trigger");s||(s=Ne("button"),s.className="ldp-native-reader-trigger",s.type="button",s.innerHTML=ue("maximize2"),s.addEventListener("click",async d=>{yt(d);const u=Number(s.dataset.topicId),f=Number(s.dataset.postNumber)||0,m=s.dataset.triggerSource;if(!u)return;if(wn="",ui(),It.classList.contains("ldp-reader-sidepanel-active")&&!await Eb())return pl(Us,String(u));const g=uo(location.href);if(m==="route"){if(!g||Number(g.topicId)!==u){Us();return}It.classList.add("ldp-route-takeover")}else if(g){Us();return}co(u,{targetPostNumber:m==="route"?_u(g):f,topicNavMetadata:ya(null,u)})}),n.appendChild(s)),n.style.removeProperty("left"),n.style.removeProperty("top"),delete n.dataset.ldpPlacement,n.hidden=!1,s.hidden=!1,s.dataset.topicId=r||"",s.dataset.triggerSource=t?t.source:"empty-history";const a=t?t.source==="route"?_u(t):t.postNumber:0;s.dataset.postNumber=String(a||"");const l=It.classList.contains("ldp-reader-sidepanel-active"),c=t?t.source==="history"?t.title?`打开历史首项:${t.title}`:"打开历史首项":l?"切换到浮窗阅读器":qe.openTopicsAtFirstPost?"从 #1 打开浮窗阅读器":t.postNumber?`从 #${t.postNumber} 打开浮窗阅读器`:"打开浮窗阅读器":"暂无浏览历史";nt(s,c)}function GI(){if(!document.body||document.body._ldpNativeReaderTriggerObserver)return;const e='.d-header,.d-header-wrap,.d-header-icons,.current-user,.ldp-native-reader-trigger-item[data-ldp-portal="1"]',t=a=>!a||a.nodeType!==Node.ELEMENT_NODE?!1:a.matches(e)||!!a.querySelector(e);let r=null,o=0,n=null;const s=()=>{const l=Xy()?.closest(".d-header-wrap,.d-header,header")||document.querySelector(".d-header-wrap,.d-header");if(l===r&&l?.isConnected)return;if(n.disconnect(),r=l||null,!r){n.observe(document.body,{childList:!0,subtree:!0});return}n.observe(r,{childList:!0,subtree:!0});let c=r.parentElement;for(;c&&(n.observe(c,{childList:!0}),c!==document.body);)c=c.parentElement};n=new MutationObserver(a=>{const l=!r?.isConnected||a.some(d=>d.target!==r&&!r.contains(d.target)),c=a.some(d=>[...d.addedNodes,...d.removedNodes].some(t));!l&&!c||o||(o=requestAnimationFrame(()=>{o=0,s();const d=Xy(),u=document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]');d&&(!u||u.parentElement!==d)&&Us()}))}),s(),document.body._ldpNativeReaderTriggerObserver=n}function pl(e,t=wn){return It.classList.remove("ldp-route-takeover"),wn=t,e(),!1}async function HM(){if(Cm(location.href))return pl(ui);const e=uo(location.href);if(!e)return Wy="",pl(Us,"");const t=String(e.topicId),r=`${t}:${Math.max(0,Number(e.postNumber)||0)}`,o=Wy!==r;Wy=r;const n=Ct&&Ct._ldpReaderShell?.readerWorkspace;if(wn===t&&n?.isEmbedded())return pl(Us);if(It.classList.contains("ldp-reader-sidepanel-active")&&!await Eb())return pl(Us,t);const s=uo(location.href);if(!s||String(s.topicId)!==t)return pl(ui);if(wn&&wn!==t&&(wn=""),wn===t)return pl(Us);if(ui(),Ct&&Ct.dataset.topicId===t){const a=Math.max(0,Number(s.postNumber)||0);if(!o||!a)return!0;const l=Ct._ldpReaderShell?.activeContext;return l?.readerSession?.state==="active"&&l.timeline?(await l.timeline.jumpTo(a),!0):(co(s.topicId,{targetPostNumber:a,topicNavMetadata:ya(null,s.topicId)}),!0)}return It.classList.add("ldp-route-takeover"),co(s.topicId,{targetPostNumber:Ct&&s.postNumber?+s.postNumber:_u(s),topicNavMetadata:ya(null,s.topicId)}),!0}function UM(){if(DM)return!0;const e=Bt("discourse/lib/plugin-api");if(!e||!D(e.withPluginApi))return!1;let t=Sm();try{e.withPluginApi(r=>{r.onPageChange(()=>{setTimeout(()=>{const o=Sm();o!==t&&(t=o,HM().catch(n=>{console.warn("[LDP] route signal takeover failed",n)}))},0)})})}catch{return!1}return DM=!0,!0}function QI(){const e=Ct,t=e?._ldpReaderShell?.activeContext;if(!e||!t||!t.readerSession?.isCurrent("opening","active"))return;e.querySelectorAll(".ldp-post").forEach(n=>{const s=!!n.querySelector(":scope > .ldp-reactions .ldp-reaction-picker:not([hidden])");if(ks(n,t),!s)return;const a=n.querySelector(":scope > .ldp-reactions"),l=a?.querySelector(".ldp-reaction-picker"),c=a?.querySelector("[data-reaction-picker]");l&&(l.hidden=!1),c&&sr(c,!0)});const r=t.topicActionRail;if(!r?._ldpPostData)return;const o=r.querySelector(".ldp-like");o&&Ki(o,Zd(r._ldpPostData,t)),ks(r,t,{collectLike:!1,railPicker:!0}),r.querySelector(":scope > .ldp-reactions > .ldp-reaction-summary")?.remove()}function Cu(e){go({tags:e}).catch(t=>{console.warn("[LDP] Discourse event cache invalidation failed",t)})}function jM(){if(qM)return!0;const e=xt("service:app-events"),t=xt("service:message-bus");if(!e||!D(e.on)||!t||!D(t.subscribe))return!1;const r=document,o=[],n=(a,l)=>{!e||!D(e.on)||(e.on(a,r,l),o.push({type:"app",name:a,handler:l}))},s=a=>{const l=String(a?.name||"");if(!["discourse_reactions_enabled_reactions","discourse_reactions_reaction_for_like"].includes(l))return;const c=xt("service:site-settings");c&&(c[l]=a.value),pc({refresh:!0}).then(QI)};try{t.subscribe("/client_settings",s),o.push({type:"message-bus",name:"/client_settings",handler:s})}catch{return!1}return n("notifications:changed",()=>{Fb(),Cu(["notifications"])}),n("bookmarks:changed",()=>{Cu(["bookmarks"]),Ct?._ldpReaderShell?.refreshBookmarksPanel?.()}),n("discourse-reactions:reaction-toggled",a=>{const l=a?.post,c=Number(l?.id||0),d=Number(l?.topic_id||l?.topic?.id||0),u=["reactions-given"];c&&u.push(`post:${c}`),d&&u.push(`topic:${d}`),Cu(u);const f=Ct?._ldpReaderShell?.activeContext;if(!c||!f||d&&d!==Number(f.topicId)){d&&(Jl(d),$r.delete(String(d)),en.delete(String(d)));return}f.loader?.persistFetchedPosts?.([l]),bu([l],f,{refreshContent:!0}),f.refreshBookmarksPanel?.({post:l,topic:f.topicData})}),n("post:created",a=>{const l=Number(a?.id||0),c=Number(a?.topic_id||a?.topic?.id||0),d=[];l&&d.push(`post:${l}`),c&&d.push(`topic:${c}`),d.length&&Cu(d);const u=Number(Ct?._ldpReaderShell?.activeContext?.topicId||0);c&&c!==u&&(Jl(c),$r.delete(String(c)),en.delete(String(c)))}),n("composer:edited-post",()=>{const a=Number(uo(location.href)?.topicId||0);if(!a)return;Cu([`topic:${a}`]);const l=Number(Ct?._ldpReaderShell?.activeContext?.topicId||0);a!==l&&(Jl(a),$r.delete(String(a)),en.delete(String(a)))}),o.length?(qM={appEvents:e,messageBus:t,bindings:o},!0):!1}function YI(e,t){if(e.type!=="click"||e.detail<=0)return null;const r=Br(e,"tr.topic-list-item,.topic-list-item,.latest-topic-list-item");if(!r||r.closest(".ldp-overlay"))return null;const o=r.getBoundingClientRect();return o.width<=0||o.height<=0?null:{card:r,topicId:String(t||""),clientY:e.clientY,offsetY:Math.max(0,Math.min(o.height,e.clientY-o.top))}}function XI(e){if(!e)return!1;let t=e.card?.isConnected?e.card:null;if(!t&&e.topicId&&(t=[...document.querySelectorAll("tr.topic-list-item,.topic-list-item,.latest-topic-list-item")].find(s=>{if(s.closest(".ldp-overlay"))return!1;const a=s.dataset.topicId||s.getAttribute("data-topic-id"),l=s.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]');return String(a||uo(l?.href)?.topicId||"")===e.topicId})||null),!t)return!1;document.querySelectorAll("[data-ldp-reader-active-topic]").forEach(n=>{n!==t&&n.removeAttribute("data-ldp-reader-active-topic")}),t.dataset.ldpReaderActiveTopic="true";const o=t.getBoundingClientRect().top+Number(e.offsetY||0)-Number(e.clientY||0);return Math.abs(o)>=.5&&window.scrollBy({top:o,behavior:"auto"}),!0}function KI(e){const t=Br(e,".fk-d-menu,.menu-panel,.search-menu,.user-menu,.hamburger-panel,.sidebar-hamburger-dropdown,.chat-drawer");if(!t||t.closest(".ldp-overlay,.ldp-reader-portal-host,.sciapp-ldp-owned")||t.dataset.ldpReaderTopLayer)return null;const r=t.matches(".fk-d-menu,.menu-panel,.chat-drawer")?t:t.closest(".fk-d-menu,.menu-panel,.chat-drawer")||t.querySelector(".fk-d-menu,.menu-panel,.chat-drawer")||t,o=d=>{let u;try{u=d()}catch{return null}return Promise.resolve(u).catch(()=>{}).then(()=>new Promise(f=>{const m=performance.now()+180,g=()=>{if(!r.isConnected||!rn(r)||performance.now()>=m){f();return}requestAnimationFrame(g)};requestAnimationFrame(g)}))},n=r.matches(".fk-d-menu")?String(r.dataset.identifier||""):"",s=n&&xt("service:menu");if(n&&D(s?.close))return o(()=>s.close(n));const a=r.matches(".chat-drawer")?r.querySelector(".chat-drawer-header__close-btn"):null;if(a&&D(a.click))return o(()=>a.click());const l=[r.id,t.id].filter(Boolean);let c=[...document.querySelectorAll('[aria-expanded="true"]')].find(d=>l.some(u=>[d.getAttribute("aria-controls"),d.getAttribute("aria-owns")].some(f=>String(f||"").split(/\s+/).includes(u))));if(!c){const d=Br(e,".search-menu,.user-menu,.hamburger-panel,.sidebar-hamburger-dropdown"),u=d?.classList.contains("search-menu")?".d-header-icons .search-dropdown":d?.classList.contains("user-menu")?".d-header-icons .current-user":d?".d-header-icons .hamburger-dropdown,.hamburger-dropdown":"";if(u){const f=[...document.querySelectorAll(u)];c=f.find(m=>m.getAttribute("aria-expanded")==="true")||f[0]}}return!c||!D(c.click)?null:o(()=>c.click())}function JI(e){if(e.type==="click"&&e.button&&e.button!==0)return!1;const t=Br(e,".ldp-reader-queue-add[data-reader-queue-topic-id]");if(t){yt(e);const P=Number(t.dataset.readerQueueTopicId);if(un(P))uf(P)&&mt("已从阅读队列移除");else{const U=df(P,bS(t,P));U&&mt(`已加入阅读队列:${U.title}`)}return!0}let r=Br(e,"a[href]");if(!r&&It.classList.contains("ldp-reader-workspace")){const P=Br(e,"tr.topic-list-item,.topic-list-item,.latest-topic-list-item"),M=Br(e,'button,input,select,textarea,[role="button"],[contenteditable="true"]');P&&!P.closest(".ldp-overlay")&&!M&&(r=P.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]'))}if(!r)return!1;const o=r.closest(".search-menu");if(o&&!o.closest(".ldp-overlay,.ldp-reader-portal-host,.sciapp-ldp-owned")&&!r.closest(".search-result-topic,.search-result-post"))return!1;if(r.classList.contains("ldp-rate-limit-challenge")||r.classList.contains("ldp-error-challenge"))return yt(e),Yy(r.href),!0;const n=r.closest(".ldp-notification-item"),s=n?.classList.contains("ldp-notification-message-item")===!0,a=s?Math.max(0,Number(n.dataset.notificationTopicId)||0):0,l=s?Math.max(0,Number(n.dataset.notificationPostNumber)||0):0,c=a?{topicId:String(a),postNumber:l}:null,d=hN(n);d>0&&hn(`${vt}/notifications/mark-read`,"PUT",{id:d},null,{surviveReaderSwitch:!0}).catch(P=>{console.warn("[LDP] mark notification read failed",P)});const u=Ct&&Ct._ldpReaderShell?.readerWorkspace;if(u?.isEmbedded()&&r.getRootNode()===Ho&&!r.classList.contains("ldp-history-link")&&!r.classList.contains("ldp-rate-limit-challenge")&&!r.classList.contains("ldp-error-challenge")&&Ly(e,r,u))return!0;if(r.classList.contains("ldp-open")||Cm(r.href))return!1;const f=c||uo(r.getAttribute("href")||r.href);if(!f)return!1;const m=Ct?._ldpReaderShell,g=m?.activeContext;if(c&&l>0&&String(g?.topicId||"")===String(a)&&g?.readerSession?.state==="active"&&g.timeline)return yt(e),m.closeNotifications?.(),g.timeline.jumpTo(l).then(P=>{P||co(a,{targetPostNumber:l,refreshTargetBoosts:!0})}).catch(()=>co(a,{targetPostNumber:l,refreshTargetBoosts:!0})),!0;const b=bS(r,f.topicId);b.urlPostNumber=Math.max(0,Number(f.postNumber)||0);const w=YI(e,f.topicId);w&&D(r.blur)&&r.blur();const R=KI(e);yt(e),wn="",ui();const z=()=>co(f.topicId,{targetPostNumber:r.dataset.ldpPreserveTargetPost==="1"?+f.postNumber||0:_u(f),topicNavMetadata:ya(r),readerQueueMetadata:b,hostTopicPointerAnchor:w,refreshTargetBoosts:s});return R?R.then(()=>requestAnimationFrame(z)):z(),!0}function ZI(e){if(e.key!=="F5")return!1;const r=Ct?._ldpReaderShell?.readerWorkspace;return!r||!r.isEmbedded()?!1:(e.preventDefault(),e.stopImmediatePropagation(),bL()||mt(`${qa.name} 原站刷新暂不可用,请稍后重试`),!0)}function Ky(e){if(e.button!==3&&e.button!==4)return!1;const t=Ct;if(!t||!t.isConnected)return!1;if(e.preventDefault(),e.stopImmediatePropagation(),e.type!=="mousedown")return!0;const r=e.button===3,o=t.querySelector(r?".ldp-reader-history-back":".ldp-reader-history-forward");return o&&!o.hidden&&!o.disabled?o.click():mt(r?"没有更早的阅读记录":"没有更新的阅读记录"),!0}function ex(){document._ldpReaderHostEventHandlersInstalled||(document._ldpReaderHostEventHandlersInstalled=!0,document.addEventListener("keydown",ZI,!0),document.addEventListener("mousedown",Ky,!0),document.addEventListener("mouseup",Ky,!0),document.addEventListener("auxclick",Ky,!0),document.addEventListener("click",JI,!0))}VI(async()=>{if(!await gL()){console.warn(`[LDP] ${location.hostname} 未检测到 Discourse,阅读器未启动`);return}RT(),zE(),ex(),$g(),ST(),jM()||window.addEventListener("load",()=>{jM()||console.warn("[LDP] Discourse update events unavailable")},{once:!0}),UM()||window.addEventListener("load",()=>{UM()||console.warn("[LDP] Discourse page change signal unavailable")},{once:!0}),ob(),GI(),nI();let e=!1;try{e=await HM()}catch(t){console.warn("[LDP] direct topic takeover failed",t)}if(!e){try{await xI()}catch(t){console.warn("[LDP] embedded reader reload restore failed",t)}Us()}})})();
diff --git a/docs/guide/thread-context.md b/docs/guide/thread-context.md
index 3ecf38a..ed694e7 100644
--- a/docs/guide/thread-context.md
+++ b/docs/guide/thread-context.md
@@ -1,8 +1,8 @@
---
title: 二级回复、完整讨论与上下文
-description: 理解父回复预览、二级回复分页、完整讨论窗口、楼层收纳和已解决答案。
-feature_ids: ["READ-003", "READ-008", "READ-012", "READ-015"]
-source_anchors: ["SUB_REPLY_PAGE_SIZE", "loadReaderQuotedPost", "focusLiveNestedReply", "openDescendantRepliesWindow", "captureReaderAnchorState", "syncHiddenReplyRun", "renderSolvedAnswerCard"]
+description: 理解父回复预览、有界树状楼中楼、完整讨论窗口、楼层收纳和已解决答案。
+feature_ids: ["READ-003", "READ-008", "READ-012", "READ-015", "SET-018"]
+source_anchors: ["SUB_REPLY_PAGE_SIZE", "inlineReplyTreeMaxDepth", "loadReaderQuotedPost", "focusLiveNestedReply", "openDescendantRepliesWindow", "captureReaderAnchorState", "syncHiddenReplyRun", "renderSolvedAnswerCard"]
since: 0.1.2
version: 0.1.16
status: current
@@ -12,7 +12,7 @@ screenshots: ["/screenshots/guide-18-thread-context.png", "/screenshots/guide-20
# 二级回复、完整讨论与上下文
-LINUX DO 的回复既有正式楼层号,也可能属于某个父楼层的直属讨论。阅读器同时保留这两种关系。
+LINUX DO 的回复既有正式楼层号,也可能属于某个父楼层的直属或更深层讨论。阅读器同时保留这两种关系,并可在主信息流内按设置展示有界树状嵌套。
## 父回复预览
@@ -25,18 +25,24 @@ LINUX DO 的回复既有正式楼层号,也可能属于某个父楼层的直

-
父楼层下方的缩进区域表示直属讨论;点击引用或父回复提示查看来源,点击楼层号跳到它在主信息流中的正式位置。
+
父楼层下方的缩进区域表示直属讨论;开启树状评论后,更深回复可继续嵌套在对应父回复下。点击引用或父回复提示查看来源,点击楼层号跳到它在主信息流中的正式位置。
-## 父回复下的二级回复
+## 父回复下的楼中楼
-当前版本默认先在父回复下预览 2 条直接回复;不存在更深层分支时最多显示 5 条。父楼层接近屏幕后才加载首批内容,后续数据继续服从共享请求调度。
+父楼层接近屏幕后才加载首批直属回复,后续数据继续服从共享请求调度。
-“设置 → 帖子与回复 → 二级回复显示位置”控制三个关联行为:
+未启用树状评论时,行为与此前一致:默认先在父回复下预览少量直接回复;分支较短时最多显示约 5 条,较长或含后续分支时配合「完整讨论」入口。启用树状评论后,主信息流可在直属回复下继续嵌套展示更深层回复,深度由设置限制。
-- **在父回复下展开二级回复**:直接显示父回复收到的回复。
-- **启用“完整讨论”视图**:分支较长或包含后续回复时显示“查看完整讨论”。
+“设置 → 帖子与回复 → 二级回复显示位置”控制这些关联行为:
+
+- **在父回复下展开二级回复**:直接显示父回复收到的回复。关闭时树状评论不可用,主信息流按单层直属处理。
+- **启用树状评论**:在二级回复下继续嵌套展示更深层回复。仅当「在父回复下展开二级回复」开启时可用。
+- **树状嵌套层数**:2–5 层可选(默认 3)。从主楼下的直属回复起算;超出深度的分支仍可通过「完整讨论」阅读。
+- **启用“完整讨论”视图**:分支较长、含后续回复或超出内联深度时显示「查看完整讨论」。
- **从楼层列表隐藏二级回复**:把二级回复固定收纳到对应父楼层,避免正文列表重复。
+更深一层默认折叠,需要时再展开,以控制 DOM 体积。接口返回的直属回复为空时,阅读器会去掉误标的「展开 N 条回复」入口,而不是留下空壳。
+
楼层列表中的连续二级回复会折叠为一条细分隔标记。展开标记可查看参与者头像,点击头像会直接打开该回复所属的完整讨论。如果折叠段正好位于主题末尾,阅读器会同时移除其后的空高度占位,避免尾部出现无法解释的大块空白。
## 完整讨论窗口
@@ -46,7 +52,7 @@ LINUX DO 的回复既有正式楼层号,也可能属于某个父楼层的直
1. 按真实父子关系加载当前回复的全部后续分支,不只显示直属回复。
2. 点击分支按钮可把某条回复设为新的讨论根,并显示从根到目标的关系路径;标题栏左侧按钮和 `Esc` 会先逐级返回上一个分支,回到入口层后才关闭窗口。
3. 窗口记录大小、位置、各分支阅读位置和已经打开过的分支;再次进入时优先恢复。
-4. 从消息、历史、时间轴、队列或隐藏头像跳到二级回复时,阅读器会先打开对应讨论,再定位并高亮目标。
+4. 从消息、历史、时间轴、队列或隐藏头像跳到二级或更深层回复时,阅读器会优先进入对应讨论或沿主信息流树路径展开,再定位并高亮目标。
5. 实时收到的新回复会合并到已打开的分支;点赞、回应、Boost、回复和父回复预览仍可使用。
6. 按 `Esc` 先关闭预览或返回上一层分支,再关闭完整讨论,不会直接退出整个阅读器。
@@ -60,7 +66,7 @@ LINUX DO 的回复既有正式楼层号,也可能属于某个父楼层的直

-
截图展示二级回复中的原生互动入口;完整讨论窗口、精确位置恢复和隐藏回复标记以当前脚本实际界面为准。
+
截图展示楼中楼中的原生互动入口;树状嵌套、完整讨论窗口、精确位置恢复和隐藏回复标记以当前脚本实际界面为准。
## 引用内容
diff --git a/docs/public/feature-catalog.json b/docs/public/feature-catalog.json
index e16a004..a7d6ef7 100644
--- a/docs/public/feature-catalog.json
+++ b/docs/public/feature-catalog.json
@@ -168,12 +168,12 @@
"feature_id": "READ-003",
"title": "楼中楼分页",
"category": "阅读与导航",
- "description": "在父楼层下按需加载直属回复,提供分页与展开策略,并在回复或编辑后刷新和聚焦对应分支。",
- "source_anchor": "SUB_REPLY_PAGE_SIZE",
+ "description": "在父楼层下按需加载直属回复,支持有界树状嵌套、分页与展开策略,并在回复或编辑后刷新和聚焦对应分支。",
+ "source_anchor": "inlineReplyTreeMaxDepth",
"since": "0.1.2",
"version": "0.1.16",
"status": "current",
- "last_verified": "2026-07-25",
+ "last_verified": "2026-07-28",
"screenshots": [
"assets/screenshots/guide-18-thread-context.png"
],
@@ -1377,8 +1377,8 @@
"feature_id": "SET-018",
"title": "二级回复展示策略",
"category": "其他设置",
- "description": "控制父回复下展开、完整讨论窗口和主楼层列表中的二级回复收纳;主题末尾的连续收纳段会移除无内容的底部占位。",
- "source_anchor": "expandNestedRepliesByDefault",
+ "description": "控制父回复下展开、树状评论开关与 2–5 层嵌套深度、完整讨论窗口和主楼层列表中的二级回复收纳;主题末尾的连续收纳段会移除无内容的底部占位。",
+ "source_anchor": "inlineReplyTreeMaxDepth",
"since": "0.1.2",
"version": "0.1.16",
"status": "current",
diff --git a/docs/settings/motion-and-other.md b/docs/settings/motion-and-other.md
index a58a2c6..db369b7 100644
--- a/docs/settings/motion-and-other.md
+++ b/docs/settings/motion-and-other.md
@@ -2,7 +2,7 @@
title: 动画、阅读与互动
description: 配置跳转提示、加载动画、历史导航、退出方式、主帖操作列、完整讨论和 Boost 复制。
feature_ids: ["READ-007", "READ-014", "ACTION-006", "SET-010", "SET-011", "SET-016", "SET-017", "SET-018", "SET-019"]
-source_anchors: ["historyEdgeTriggerPercent", "JUMP_HIGHLIGHT_SETTING_FIELDS", "BOOST_COPY_SETTING_ROWS", "jumpHighlightPrefsPatch", "READER_LOADING_ANIMATION_KEYS", "ldp-history-buttons-always-visible", "openTopicsAtFirstPost", "confirmNativeComposerClose", "expandNestedRepliesByDefault", "boostCopyMode"]
+source_anchors: ["historyEdgeTriggerPercent", "JUMP_HIGHLIGHT_SETTING_FIELDS", "BOOST_COPY_SETTING_ROWS", "jumpHighlightPrefsPatch", "READER_LOADING_ANIMATION_KEYS", "ldp-history-buttons-always-visible", "openTopicsAtFirstPost", "confirmNativeComposerClose", "expandNestedRepliesByDefault", "inlineReplyTreeMaxDepth", "boostCopyMode"]
since: 0.1.2
version: 0.1.16
status: current
@@ -54,11 +54,13 @@ screenshots: ["/screenshots/guide-08-motion-settings.png", "/screenshots/guide-1

-- 在父回复下展开二级回复:展示直接回复。
+- 在父回复下展开二级回复:展示直接回复;关闭时树状评论不可用。
+- 启用树状评论:在二级下继续嵌套更深层回复(需先开启上一开关)。
+- 树状嵌套层数:2–5 层可选,默认 3;超出部分仍可走完整讨论。
- 启用“完整讨论”视图:递归阅读更深分支并记住窗口位置。
- 从楼层列表隐藏二级回复:收纳为参与者头像标记,跳转仍可定位。
-完整讨论建立在父回复下展开之上;关闭前者时设置中心会同步处理关联开关,避免保留不可达配置。
+完整讨论与树状评论都建立在父回复下展开之上;关闭前者时设置中心会同步处理关联开关,树状入口禁用,避免保留不可达配置。
## Boost 复制
diff --git a/docs/settings/other.md b/docs/settings/other.md
index deeb89f..727ed77 100644
--- a/docs/settings/other.md
+++ b/docs/settings/other.md
@@ -2,7 +2,7 @@
title: 阅读、帖子与适用站点
description: 配置队列入口、历史、退出、主帖操作列、完整讨论、Boost 复制和其他 Discourse 站点。
feature_ids: ["CORE-006", "CORE-007", "READ-007", "READ-015", "ACTION-006", "ACTION-014", "SET-016", "SET-017", "SET-018", "SET-019", "SET-021"]
-source_anchors: ["CUSTOM_DISCOURSE_SITES_KEY", "readerQueueAlwaysVisibleWhenEmpty", "historyEdgeTriggerPercent", "doubleEscapeToCloseReader", "confirmNativeComposerClose", "syncTopicActionRail", "aggregateDescendantReplies", "openDescendantRepliesWindow", "BOOST_COPY_SETTING_ROWS", "ldp-history-buttons-always-visible", "openTopicsAtFirstPost", "expandNestedRepliesByDefault", "boostCopyMode"]
+source_anchors: ["CUSTOM_DISCOURSE_SITES_KEY", "readerQueueAlwaysVisibleWhenEmpty", "historyEdgeTriggerPercent", "doubleEscapeToCloseReader", "confirmNativeComposerClose", "syncTopicActionRail", "aggregateDescendantReplies", "openDescendantRepliesWindow", "BOOST_COPY_SETTING_ROWS", "ldp-history-buttons-always-visible", "openTopicsAtFirstPost", "expandNestedRepliesByDefault", "inlineReplyTreeMaxDepth", "boostCopyMode"]
since: 0.1.2
version: 0.1.16
status: current
@@ -65,11 +65,13 @@ screenshots: ["/screenshots/guide-12-other-features.png", "/screenshots/guide-18
| 设置 | 结果 |
| --- | --- |
-| 在父回复下展开二级回复 | 直接显示父回复收到的回复 |
+| 在父回复下展开二级回复 | 直接显示父回复收到的回复;关闭时树状评论不可用 |
+| 启用树状评论 | 在二级回复下继续嵌套展示更深层回复;仅当上一开关开启时可用 |
+| 树状嵌套层数 | 2–5 层(默认 3);仅启用树状评论后显示,超出深度仍可走完整讨论 |
| 启用“完整讨论”视图 | 长分支提供独立的可拖动、可缩放讨论窗口 |
| 从楼层列表隐藏二级回复 | 将正式楼层收纳为参与者头像标记,跳转仍可定位 |
-完整讨论视图建立在父回复下展开之上。关闭父回复下展开时,设置中心会同步关闭完整讨论;关闭完整讨论时仍可保留普通直属回复分页。
+完整讨论与树状评论都建立在「在父回复下展开二级回复」之上。关闭父回复下展开时,设置中心会同步关闭完整讨论,树状开关禁用且主信息流按单层直属处理;关闭完整讨论时仍可保留普通直属回复与已开启的树状嵌套。
## Boost 复制
diff --git a/docs/settings/reference.md b/docs/settings/reference.md
index c0cbafa..c47d0b2 100644
--- a/docs/settings/reference.md
+++ b/docs/settings/reference.md
@@ -2,7 +2,7 @@
title: 完整设置参考
description: 汇总全部设置项、范围、默认值、生效时机和数据影响。
feature_ids: ["SET-012", "SET-013", "SET-014", "SET-015", "SET-016", "SET-017", "SET-018", "SET-019", "SET-020", "SET-021"]
-source_anchors: ["PERFORMANCE_SETTING_GROUPS", "streamOverscanViewports", "nestedPrefetchViewports", "requestMaxConcurrent", "SETTINGS_NAV_GROUPS", "saveAllSettingsDrafts", "readerQueueAlwaysVisibleWhenEmpty", "doubleEscapeToCloseReader", "confirmNativeComposerClose", "ldp-history-buttons-always-visible", "openTopicsAtFirstPost", "aggregateDescendantReplies", "boostCopyMode"]
+source_anchors: ["PERFORMANCE_SETTING_GROUPS", "streamOverscanViewports", "nestedPrefetchViewports", "requestMaxConcurrent", "SETTINGS_NAV_GROUPS", "saveAllSettingsDrafts", "readerQueueAlwaysVisibleWhenEmpty", "doubleEscapeToCloseReader", "confirmNativeComposerClose", "ldp-history-buttons-always-visible", "openTopicsAtFirstPost", "aggregateDescendantReplies", "inlineReplyTreeMaxDepth", "boostCopyMode"]
since: 0.1.2
version: 0.1.16
status: current
@@ -101,6 +101,8 @@ screenshots: ["/screenshots/guide-02-settings-overview.png", "/screenshots/guide
| 始终显示主帖操作列 | 开/关 | 开 |
| 锁定操作列位置 | 开/关 | 关 |
| 父楼层下展开二级回复 | 开/关 | 开 |
+| 启用树状评论 | 开/关(依赖上一开关) | 开 |
+| 树状嵌套层数 | 2–5 | 3 |
| 启用“完整讨论”视图 | 开/关 | 开 |
| 从楼层列表隐藏二级回复 | 开/关 | 开 |
| Boost 末尾内容 | 数字递增/固定文字 | 数字递增 |
diff --git a/work/main.css b/work/main.css
index 93c63eb..7af0a70 100644
--- a/work/main.css
+++ b/work/main.css
@@ -1,4340 +1,4356 @@
-/* ldp-runtime-shell: migrated from main.js; keep before reader component styles. */
-:where([class^="ldp-"],[class*=" ldp-"])[hidden]{display:none!important;}
- html.ldp-scroll-lock,html.ldp-scroll-lock body{overflow:hidden!important;}
- html[data-ldp-font-rendering="builtin"]{
- --ldp-font-rendering-stroke:var(--ldp-font-rendering-stroke-runtime,.015px currentcolor);
- --ldp-font-rendering-shadow:var(--ldp-font-rendering-shadow-runtime,0 0 .75px #7c7c7cdd);}
- html > .ldp-reader-portal-host[data-ldp-font-rendering="builtin"],
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox)[data-ldp-font-rendering="builtin"],
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox)[data-ldp-font-rendering="builtin"]
- :where(*):not(i[class],pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]),
- html.ldp-reader-open[data-ldp-font-rendering="builtin"]
- :where([data-ldp-reader-composer-root],[data-ldp-reader-composer-overflow],#reply-control),
- html.ldp-reader-open[data-ldp-font-rendering="builtin"]
- :where([data-ldp-reader-composer-root],[data-ldp-reader-composer-overflow],#reply-control)
- :where(*):not(i[class],pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]),
- html[data-ldp-font-rendering="builtin"][data-ldp-font-rendering-host="true"] body,
- html[data-ldp-font-rendering="builtin"][data-ldp-font-rendering-host="true"] body
- :where(*):not(i[class],pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]){
- font-optical-sizing:auto;font-kerning:auto;text-rendering:optimizeLegibility;
- -webkit-text-stroke:var(--ldp-font-rendering-stroke);text-shadow:var(--ldp-font-rendering-shadow);
- -webkit-font-smoothing:var(--ldp-font-webkit-smoothing);-moz-osx-font-smoothing:var(--ldp-font-moz-smoothing);}
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox)[data-ldp-font-rendering="builtin"]
- :is(pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]),
- html.ldp-reader-open[data-ldp-font-rendering="builtin"]
- :where([data-ldp-reader-composer-root],[data-ldp-reader-composer-overflow],#reply-control)
- :is(pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]),
- html[data-ldp-font-rendering="builtin"][data-ldp-font-rendering-host="true"] body
- :is(pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]){
- -webkit-text-stroke:0 transparent;text-shadow:none;}
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox),
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *,
- html.ldp-reader-open body > [data-ldp-reader-composer-root],
- html.ldp-reader-open [data-ldp-reader-composer-overflow],
- html.ldp-reader-open #reply-control,
- html.ldp-reader-open #reply-control *{
- scrollbar-width:thin;
- scrollbar-color:color-mix(in srgb,var(--primary-medium,#888) 34%,transparent) transparent;}
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar,
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar,
- html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar,
- html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar,
- html.ldp-reader-open #reply-control::-webkit-scrollbar,
- html.ldp-reader-open #reply-control *::-webkit-scrollbar{width:6px;height:6px;}
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar-track,
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar-track,
- html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar-track,
- html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar-track,
- html.ldp-reader-open #reply-control::-webkit-scrollbar-track,
- html.ldp-reader-open #reply-control *::-webkit-scrollbar-track{background:transparent;}
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar-thumb,
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar-thumb,
- html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar-thumb,
- html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar-thumb,
- html.ldp-reader-open #reply-control::-webkit-scrollbar-thumb,
- html.ldp-reader-open #reply-control *::-webkit-scrollbar-thumb{
- border-radius:999px;background:color-mix(in srgb,var(--primary-medium,#888) 34%,transparent);}
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar-thumb:hover,
- :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar-thumb:hover,
- html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar-thumb:hover,
- html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar-thumb:hover,
- html.ldp-reader-open #reply-control::-webkit-scrollbar-thumb:hover,
- html.ldp-reader-open #reply-control *::-webkit-scrollbar-thumb:hover{
- background:color-mix(in srgb,var(--primary-medium,#888) 48%,transparent);}
- html > .ldp-reader-portal-host{display:contents!important;color:var(--ldp-interface-font-color,inherit);
- font-family:var(--ldp-interface-font-family,inherit);font-weight:var(--ldp-interface-font-weight,400);}
- html.ldp-route-takeover body > :not(.ldp-native-boost-menu):not(#reply-control):not(.composer-popup):not(#d-menu-portals):not(.fk-d-menu):not(.d-modal):not(.dialog-holder):not(#user-card):not(#group-card):not(.user-card):not(.group-card):not(.user-card-container){visibility:hidden!important;}
- html.ldp-composer-host-isolated body > :not(.ldp-native-boost-menu):not(#reply-control):not(.composer-popup):not(#d-menu-portals):not(.fk-d-menu):not(.d-modal):not(.dialog-holder):not(#user-card):not(#group-card):not(.user-card):not(.group-card):not(.user-card-container){visibility:hidden!important;}
- html.ldp-native-reader-trigger-visible .d-header-icons{margin-inline-end:30px!important;}
- html.ldp-native-reader-trigger-visible .d-header-icons .current-user{position:relative!important;}
- .ldp-native-reader-trigger-item{position:absolute;left:100%;top:50%;z-index:2147482999;display:flex;align-items:center;
- width:20px;height:20px;margin-inline-start:10px;transform:translateY(-50%);background:transparent;}
- .ldp-native-reader-trigger{width:20px;height:20px;display:inline-flex;align-items:center;
- justify-content:center;box-sizing:border-box;padding:0;border:0;border-radius:0;
- background:transparent;color:var(--header_primary-medium,var(--primary-medium,#777));
- font:inherit;line-height:1;cursor:pointer;}
- .ldp-native-reader-trigger:hover{background:transparent;color:var(--primary,#222);}
- .ldp-overlay{position:fixed;inset:0;z-index:2147483000;display:flex;
- align-items:center;justify-content:center;background:transparent;pointer-events:none;}
- .ldp-settings-layer{position:absolute;inset:0;z-index:2147483605;
- container:ldp-reader / inline-size;pointer-events:none;}
- .ldp-overlay.ldp-fullpage{align-items:stretch;justify-content:stretch;
- background:var(--secondary,#fff);pointer-events:auto;}
- .ldp-native-boost-menu{position:fixed;z-index:2147483608;width:min(340px,calc(100vw - 16px));
- box-sizing:border-box;padding:6px;border:1px solid var(--primary-low,#d8d8d8);border-radius:8px;
- background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 10px 30px rgba(0,0,0,.22);pointer-events:auto;}
- .ldp-native-boost-menu .discourse-boosts__input-container{display:flex;align-items:center;gap:5px;}
- .ldp-native-boost-menu .discourse-boosts__input{position:relative;flex:1;min-width:0;box-sizing:border-box;
- min-height:30px;max-height:60px;overflow:auto;padding:4px 7px;border:1px solid var(--primary-low,#ccc);
- border-radius:6px;background:var(--secondary,#fff);color:inherit;font-size:var(--ldp-font-base,13px);outline:none;white-space:pre-wrap;overflow-wrap:anywhere;}
- .ldp-native-boost-menu .discourse-boosts__input:focus{border-color:var(--tertiary,#47855f);
- box-shadow:0 0 0 1px var(--tertiary,#47855f);}
- .ldp-native-boost-menu .discourse-boosts__input:empty::before{content:attr(data-placeholder);
- position:absolute;inset-inline:7px;top:50%;transform:translateY(-50%);
- color:var(--primary-medium,#888);pointer-events:none;}
- .ldp-native-boost-menu .discourse-boosts__input img.emoji{width:16px;height:16px;margin:0 .05em;vertical-align:-.15em;}
- .ldp-native-boost-count{min-width:28px;font-size:var(--ldp-font-xs,10px);text-align:right;color:var(--primary-medium,#777);}
- .ldp-native-boost-menu .discourse-boosts__emoji-btn,.ldp-native-boost-menu .discourse-boosts__submit,
- .ldp-native-boost-menu .discourse-boosts__cancel{
- width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;flex:none;
- border:0;border-radius:6px;cursor:pointer;}
- .ldp-native-boost-menu .discourse-boosts__emoji-btn{background:transparent;color:var(--primary-medium,#777);}
- .ldp-native-boost-menu .discourse-boosts__emoji-btn:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
- .ldp-native-boost-menu .discourse-boosts__submit{background:var(--success,#47855f);color:#fff;}
- .ldp-native-boost-menu .discourse-boosts__cancel{background:var(--danger-low,#f5d7d7);color:var(--danger,#c44);}
- .ldp-native-boost-menu button:disabled{cursor:default;opacity:.5;}
- .ldp-native-boost-error{display:block;margin-top:5px;font-size:var(--ldp-font-sm,11px);color:var(--danger,#c44);}
- .ldp-native-boost-error:empty{display:none;}
- .ldp-modal{position:relative;display:flex;flex-direction:column;
- width:min(1080px,94vw);height:86vh;
- border-radius:12px;overflow:hidden;font-family:var(--ldp-interface-font-family,inherit);
- font-size:var(--ldp-font-md,14px);font-weight:var(--ldp-interface-font-weight,400);
- line-height:1.65;background:var(--secondary,#fff);color:var(--ldp-interface-font-color,var(--primary,#222));
- box-shadow:0 8px 24px rgba(0,0,0,.18);container:ldp-reader / inline-size;pointer-events:auto;}
- .ldp-fullpage .ldp-modal{width:100vw;height:100vh;border-radius:0;box-shadow:none;}
- html.ldp-reader-workspace{--ldp-reader-workspace-width:520px;--ldp-reader-host-gutter:15px;}
- html.ldp-reader-embedded-left,html.ldp-reader-embedded-right{overflow-x:hidden;scrollbar-width:none;}
- html.ldp-reader-embedded-left::-webkit-scrollbar,html.ldp-reader-embedded-right::-webkit-scrollbar{display:none;}
- html.ldp-reader-embedded-left #d-splash,html.ldp-reader-embedded-right #d-splash{
- width:calc(100vw - var(--ldp-reader-workspace-width));}
- html.ldp-reader-embedded-left #d-splash{left:var(--ldp-reader-workspace-width);}
- html.ldp-reader-embedded-right #d-splash{left:0;}
- html.ldp-reader-embedded-right body{box-sizing:border-box!important;width:calc(100vw - var(--ldp-reader-workspace-width))!important;
- min-width:var(--ldp-reader-host-min-width,680px)!important;max-width:calc(100vw - var(--ldp-reader-workspace-width))!important;
- overflow-x:clip!important;}
- html.ldp-reader-embedded-left body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]),
- html.ldp-reader-embedded-right body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){
- box-sizing:border-box!important;min-width:var(--ldp-reader-host-min-width,680px)!important;
- width:calc(100vw - var(--ldp-reader-workspace-width))!important;max-width:calc(100vw - var(--ldp-reader-workspace-width))!important;
- margin-left:0!important;margin-right:0!important;transform-origin:top left;overflow-x:clip!important;
- transition:width .24s cubic-bezier(.22,1,.36,1),transform .24s cubic-bezier(.22,1,.36,1),padding .24s cubic-bezier(.22,1,.36,1);}
- html.ldp-reader-embedded-left body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){
- transform:translateX(var(--ldp-reader-workspace-width))!important;padding-left:var(--ldp-reader-host-gutter)!important;padding-right:0!important;}
- html.ldp-reader-embedded-right body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){
- width:100%!important;max-width:100%!important;transform:translateX(0)!important;
- padding-left:0!important;padding-right:var(--ldp-reader-host-gutter)!important;}
- html.ldp-reader-host-anchor-restoring body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){transition:none!important;}
- html.ldp-reader-embedded-left body > [data-ldp-reader-host-root="sidebar"]{
- transform:translateX(calc(var(--ldp-reader-workspace-width) + var(--ldp-reader-host-gutter)))!important;}
- html.ldp-reader-embedded-right body > [data-ldp-reader-host-root="sidebar"]{transform:translateX(0)!important;}
- html.ldp-reader-workspace body > [data-ldp-reader-host-root="shell"] :is(#ember-app,#main-outlet,#main-outlet-wrapper,.list-container,.topic-list,.latest-topic-list,.discovery-list-container,.topic-list-body){
- box-sizing:border-box!important;width:100%!important;min-width:0!important;max-width:100%!important;margin-left:0!important;margin-right:0!important;}
- html.ldp-reader-workspace body > [data-ldp-reader-host-root="shell"] :is(.d-header,header){
- box-sizing:border-box!important;left:0!important;right:auto!important;width:100%!important;max-width:100%!important;
- border:0!important;box-shadow:none!important;}
- html.ldp-reader-workspace body > [data-ldp-reader-host-root="shell"]{container:ldp-reader-host / inline-size;}
- html.ldp-reader-workspace :is(#main-outlet,#main-outlet-wrapper,.list-container,.topic-list,.latest-topic-list,.discovery-list-container){box-sizing:border-box!important;min-width:0!important;max-width:100%!important;}
- html.ldp-reader-workspace .topic-list{display:block!important;width:100%!important;}
- html.ldp-reader-workspace .topic-list thead{display:none!important;}
- html.ldp-reader-workspace .topic-list tbody,
- html.ldp-reader-workspace .latest-topic-list,
- html.ldp-reader-workspace .discovery-list-container .topic-list-body{
- display:grid!important;width:100%!important;min-width:0!important;max-width:100%!important;overflow-x:clip;
- grid-template-columns:minmax(0,1fr);grid-auto-rows:max-content;align-content:start;gap:6px;padding:8px;}
- html.ldp-reader-workspace .topic-list tbody > tr.topic-list-item-separator{display:none!important;}
- html.ldp-reader-workspace tr.topic-list-item,
- html.ldp-reader-workspace .topic-list tbody > tr,
- html.ldp-reader-workspace .topic-list-item,
- html.ldp-reader-workspace .latest-topic-list-item{
- display:grid!important;width:100%!important;box-sizing:border-box!important;min-width:0;min-height:var(--ldp-reader-native-row-height,0px)!important;height:auto!important;
- max-width:100%!important;overflow:hidden;grid-template-columns:minmax(0,1fr) minmax(8rem,10rem) minmax(4.5rem,5rem) minmax(6.75rem,7.5rem) minmax(5rem,6rem);
- align-items:center;align-content:center;column-gap:12px;row-gap:3px;padding:7px 12px!important;
- border:1px solid var(--primary-low,#ddd)!important;border-radius:10px;background:color-mix(in srgb,var(--secondary,#fff) 92%,var(--primary-very-low,#f4f4f4));
- transition:background-color .16s ease-out,color .16s ease-out,outline-color .16s ease-out,border-color .16s ease-out;}
- html.ldp-reader-workspace .topic-list tbody > tr:nth-child(even),
- html.ldp-reader-workspace .latest-topic-list > .latest-topic-list-item:nth-child(even),
- html.ldp-reader-workspace .discovery-list-container .topic-list-body > .topic-list-item:nth-child(even){
- background:color-mix(in srgb,var(--secondary,#fff) 55%,var(--ldp-reader-list-zebra-color,var(--tertiary,#47855f)) 45%)!important;}
- html.ldp-reader-workspace tr.topic-list-item:hover,
- html.ldp-reader-workspace .topic-list-item:hover,
- html.ldp-reader-workspace .latest-topic-list-item:hover{cursor:pointer!important;}
- .ldp-reader-queue-add{display:inline-flex;width:20px;height:20px;align-items:center;justify-content:center;
- box-sizing:border-box;margin-left:5px;padding:2px;border:0;border-radius:50%;vertical-align:middle;
- background:transparent;color:var(--primary-medium,#777);cursor:pointer;opacity:0;transition:opacity .12s ease-out,background .12s ease-out,color .12s ease-out;}
- .ldp-reader-queue-add .ldp-icon{width:13px;height:13px;}
- :is(.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result):hover .ldp-reader-queue-add,
- .ldp-reader-queue-add:is(:focus-visible,.is-added){opacity:1;}
- .ldp-reader-queue-add:hover{background:var(--primary-very-low,#f2f2f2);color:var(--primary,#222);}
- .ldp-reader-queue-add.is-added{color:var(--tertiary,#47855f);}
- @media (hover:none){.ldp-reader-queue-add{opacity:1;}}
- html.ldp-reader-workspace :is(tr.topic-list-item,.topic-list-item,.latest-topic-list-item)[data-ldp-reader-active-topic="true"]{
- border-color:var(--tertiary,#47855f)!important;box-shadow:none!important;}
- html.ldp-reader-workspace :is(tr.topic-list-item,.topic-list-item,.latest-topic-list-item)[data-ldp-reader-active-topic="true"]::before,
- html.ldp-reader-workspace :is(tr.topic-list-item,.topic-list-item,.latest-topic-list-item)[data-ldp-reader-active-topic="true"] > td.main-link::before{
- content:none!important;display:none!important;}
- html:where(.ldp-reader-open,.ldp-reader-workspace) tr.topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link),
- html:where(.ldp-reader-open,.ldp-reader-workspace) .topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link),
- html:where(.ldp-reader-open,.ldp-reader-workspace) .latest-topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link){font-weight:600!important;-webkit-text-stroke:.1px currentColor;}
- html.ldp-reader-workspace tr.topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link),
- html.ldp-reader-workspace .topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link),
- html.ldp-reader-workspace .latest-topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link){font-weight:550!important;-webkit-text-stroke:0;}
- html.ldp-reader-workspace .link-top-line [data-ldp-native-dnd]{display:inline-flex!important;position:relative!important;right:auto;top:auto;flex:0 0 20px;width:20px!important;min-width:20px!important;height:20px!important;margin:0 0 0 4px!important;padding:0!important;transform:none;vertical-align:middle;box-sizing:border-box;}
- html.ldp-reader-workspace tr.topic-list-item:hover .link-top-line [data-ldp-native-dnd],
- html.ldp-reader-workspace .topic-list-item:hover .link-top-line [data-ldp-native-dnd],
- html.ldp-reader-workspace .latest-topic-list-item:hover .link-top-line [data-ldp-native-dnd]{
- background:transparent!important;border-color:transparent!important;box-shadow:none!important;color:transparent!important;text-shadow:none!important;}
- html.ldp-reader-workspace tr.topic-list-item:hover .link-top-line [data-ldp-native-dnd]::before,
- html.ldp-reader-workspace .topic-list-item:hover .link-top-line [data-ldp-native-dnd]::before,
- html.ldp-reader-workspace .latest-topic-list-item:hover .link-top-line [data-ldp-native-dnd]::before{
- content:'';position:absolute;left:50%;top:50%;width:15px;height:15px;transform:translate(-50%,-50%);background:var(--primary-medium,#777);
- -webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.268 21a2 2 0 0 0 3.464 0'/%3E%3Cpath d='M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .08-.969'/%3E%3Cpath d='M9.5 2.5A6 6 0 0 1 18 8c0 4.499 1.411 5.956 2.738 7.326A1 1 0 0 1 20 17h-1.17'/%3E%3Cpath d='m2 2 20 20'/%3E%3C/svg%3E") center/contain no-repeat;
- mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.268 21a2 2 0 0 0 3.464 0'/%3E%3Cpath d='M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .08-.969'/%3E%3Cpath d='M9.5 2.5A6 6 0 0 1 18 8c0 4.499 1.411 5.956 2.738 7.326A1 1 0 0 1 20 17h-1.17'/%3E%3Cpath d='m2 2 20 20'/%3E%3C/svg%3E") center/contain no-repeat;}
- html.ldp-reader-workspace .topic-list-item > td,
- html.ldp-reader-workspace .topic-list tbody > tr > td{display:block!important;width:auto!important;min-width:0;min-height:0!important;height:auto!important;margin:0!important;padding:0!important;border:0!important;align-self:center;}
- html.ldp-reader-workspace .topic-list-item > td.main-link,
- html.ldp-reader-workspace .topic-list tbody > tr > td:first-child{grid-column:1;grid-row:1;align-self:center;}
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(2){grid-column:2;grid-row:1;}
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3){grid-column:3;grid-row:1;}
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4){grid-column:4;grid-row:1;}
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5){grid-column:5;grid-row:1;}
- html.ldp-reader-workspace .topic-list-item > td.posters{display:flex!important;grid-column:2;grid-row:1;min-width:0;max-width:100%;align-items:center;justify-content:flex-end;gap:2px;overflow:visible;white-space:nowrap;}
- html.ldp-reader-workspace .topic-list-item > td.posters > *{flex:0 0 auto;}
- html.ldp-reader-workspace .topic-list-item > td.posts,
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3){grid-column:3;grid-row:1;text-align:right;}
- html.ldp-reader-workspace .topic-list-item > td.views,
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4){grid-column:4;grid-row:1;text-align:right;}
- html.ldp-reader-workspace .topic-list-item > td.activity,
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5){grid-column:5;grid-row:1;text-align:right;}
- html.ldp-reader-workspace tr.topic-list-item:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .topic-list tbody > tr:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .topic-list-item:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .latest-topic-list-item:has(> td[data-ldp-native-topic-date]){
- grid-template-columns:minmax(0,1fr) minmax(8rem,10rem) minmax(4.5rem,5rem) minmax(10.5rem,12rem) minmax(5rem,6rem);}
- html.ldp-reader-workspace .topic-list-item .main-link,
- html.ldp-reader-workspace .topic-list-item .topic-list-data.main-link{
- color:var(--ldp-host-font-color,var(--primary,#222));font-family:var(--ldp-host-font-family,inherit);
- font-size:var(--ldp-host-topic-title-size,var(--ldp-font-lg,15px));font-weight:var(--ldp-host-font-weight,700);line-height:1.4;}
- html.ldp-reader-workspace .topic-list-item .link-top-line{display:block;min-width:0;max-width:100%;white-space:normal!important;}
- html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) .link-top-line > :is(a,.title,.raw-topic-link){
- max-width:100%;color:var(--ldp-host-font-color,var(--primary,#222))!important;
- font-family:var(--ldp-host-font-family,inherit)!important;font-weight:var(--ldp-host-font-weight,700)!important;
- white-space:normal!important;overflow-wrap:anywhere;}
- html.ldp-reader-workspace .topic-list-item .link-bottom-line{display:flex!important;align-items:center;flex-wrap:nowrap!important;width:max-content;max-width:none;margin-top:3px;overflow:visible;white-space:nowrap;}
- html.ldp-reader-workspace .topic-list-item .link-bottom-line > *{flex:0 0 auto;}
- html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.badge-wrapper,.badge-category,.badge-category__wrapper,.discourse-tag,.topic-tag),
- html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.discourse-tags,.topic-tags,.tags,.tag-list) :is(a,.discourse-tag,.topic-tag){
- display:inline-flex!important;box-sizing:border-box!important;min-height:var(--ldp-host-label-card-height,22px);align-items:center!important;
- gap:var(--ldp-host-label-card-gap,3px)!important;padding-block:0!important;padding-inline:var(--ldp-host-label-card-padding,7px)!important;
- color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,system-ui,sans-serif)!important;
- font-size:var(--ldp-host-label-card-font-size,11px)!important;font-weight:var(--ldp-host-font-weight,600)!important;line-height:1!important;}
- html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) :is(.badge-category__name,.category-name,[data-category-name]),
- html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row)
- :is(.badge-wrapper,.badge-category,.badge-category__wrapper,.discourse-tag,.topic-tag) :is(span,strong,small){
- color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,system-ui,sans-serif)!important;
- font-weight:var(--ldp-host-font-weight,600)!important;}
- html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.badge-wrapper,.badge-category,.badge-category__wrapper,.discourse-tag,.topic-tag) :is(svg,img,.emoji),
- html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.discourse-tags,.topic-tags,.tags,.tag-list) :is(a,.discourse-tag,.topic-tag) :is(svg,img,.emoji){
- width:var(--ldp-host-label-card-icon-size,14px)!important;height:var(--ldp-host-label-card-icon-size,14px)!important;flex:none!important;object-fit:contain;}
- html.ldp-reader-workspace .topic-list-item > td.posters .avatar{
- width:var(--ldp-host-topic-avatar-size,32px)!important;height:var(--ldp-host-topic-avatar-size,32px)!important;}
- html.ldp-reader-workspace .topic-list-item > td.posts,
- html.ldp-reader-workspace .topic-list-item > td.views,
- html.ldp-reader-workspace .topic-list-item > td.activity,
- html.ldp-reader-workspace .topic-list tbody > tr > td:not(:first-child){min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;white-space:nowrap;}
- html.ldp-reader-workspace .topic-list-item > td.posts,
- html.ldp-reader-workspace .topic-list-item > td.views,
- html.ldp-reader-workspace .topic-list-item > td.activity,
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3),
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4),
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5),
- html.ldp-reader-workspace .topic-list-item > td[data-ldp-native-topic-date],
- html.ldp-reader-workspace .topic-list tbody > tr > td[data-ldp-native-topic-date]{display:inline-flex!important;min-width:0;align-self:start!important;align-items:baseline;justify-content:flex-end;gap:4px;line-height:1.25;}
- html.ldp-reader-workspace .topic-list-item > td.posts::before,
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3)::before{content:'回复';}
- html.ldp-reader-workspace .topic-list-item > td.views::before,
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4)::before{content:'最近回复';}
- html.ldp-reader-workspace .topic-list-item > td.activity::before,
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5)::before{content:'活跃';}
- html.ldp-reader-workspace .topic-list-item > td[data-ldp-native-old-topic]::before,
- html.ldp-reader-workspace .topic-list tbody > tr > td[data-ldp-native-old-topic]::before{content:'旧帖';}
- html.ldp-reader-workspace .topic-list-item > td.posts::before,
- html.ldp-reader-workspace .topic-list-item > td.views::before,
- html.ldp-reader-workspace .topic-list-item > td.activity::before,
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3)::before,
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4)::before,
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5)::before,
- html.ldp-reader-workspace .topic-list-item > td[data-ldp-native-old-topic]::before,
- html.ldp-reader-workspace .topic-list tbody > tr > td[data-ldp-native-old-topic]::before{flex:none;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-weight:650;opacity:.72;}
- @container ldp-reader-host (max-width:1180px){
- html.ldp-reader-workspace tr.topic-list-item,
- html.ldp-reader-workspace .topic-list tbody > tr,
- html.ldp-reader-workspace .topic-list-item,
- html.ldp-reader-workspace .latest-topic-list-item{grid-template-columns:minmax(0,1fr) 4.5rem 7.25rem 5.5rem;grid-template-rows:auto auto;align-items:center;align-content:center;column-gap:12px;row-gap:2px;padding:7px 10px!important;}
- html.ldp-reader-workspace tr.topic-list-item:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .topic-list tbody > tr:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .topic-list-item:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .latest-topic-list-item:has(> td[data-ldp-native-topic-date]){grid-template-columns:minmax(0,1fr) 4.5rem 12rem 5.5rem;}
- html.ldp-reader-workspace .topic-list-item > td.main-link,
- html.ldp-reader-workspace .topic-list tbody > tr > td:first-child{grid-column:1!important;grid-row:1 / span 2!important;}
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(2){grid-column:2 / span 3!important;grid-row:1!important;justify-self:end!important;}
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3){grid-column:2!important;grid-row:2!important;justify-self:end!important;}
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4){grid-column:3!important;grid-row:2!important;justify-self:end!important;}
- html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5){grid-column:4!important;grid-row:2!important;justify-self:end!important;align-self:end!important;}
- html.ldp-reader-workspace .topic-list-item > td.posters{grid-column:2 / span 3!important;grid-row:1!important;justify-self:end!important;max-inline-size:none!important;overflow:visible!important;}
- html.ldp-reader-workspace .topic-list-item > td.posts{grid-column:2!important;grid-row:2!important;justify-self:end!important;align-self:start!important;}
- html.ldp-reader-workspace .topic-list-item > td.views{grid-column:3!important;grid-row:2!important;justify-self:end!important;align-self:start!important;}
- html.ldp-reader-workspace .topic-list-item > td.activity{grid-column:4!important;grid-row:2!important;justify-self:end!important;align-self:start!important;}
- html.ldp-reader-workspace .topic-list-item > td[data-ldp-native-topic-date],
- html.ldp-reader-workspace .topic-list tbody > tr > td[data-ldp-native-topic-date]{grid-column:3!important;grid-row:2!important;justify-self:end!important;align-self:start!important;}}
- @container ldp-reader-host (max-width:860px){
- html.ldp-reader-workspace tr.topic-list-item,
- html.ldp-reader-workspace .topic-list tbody > tr,
- html.ldp-reader-workspace .topic-list-item,
- html.ldp-reader-workspace .latest-topic-list-item{grid-template-columns:minmax(0,1fr) 4rem 6.5rem 5rem;column-gap:10px;padding:7px 10px!important;}
- html.ldp-reader-workspace tr.topic-list-item:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .topic-list tbody > tr:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .topic-list-item:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .latest-topic-list-item:has(> td[data-ldp-native-topic-date]){grid-template-columns:minmax(0,1fr) 4rem 11.5rem 5rem;}
- html.ldp-reader-workspace .topic-list-item > td.posters .avatar{width:var(--ldp-host-topic-avatar-size-medium,24px)!important;height:var(--ldp-host-topic-avatar-size-medium,24px)!important;}}
- @container ldp-reader-host (max-width:680px){
- html.ldp-reader-workspace tr.topic-list-item,
- html.ldp-reader-workspace .topic-list tbody > tr,
- html.ldp-reader-workspace .topic-list-item,
- html.ldp-reader-workspace .latest-topic-list-item{grid-template-columns:minmax(0,1fr) 3.75rem 6.25rem 4.75rem;column-gap:8px;padding:7px 8px!important;}
- html.ldp-reader-workspace tr.topic-list-item:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .topic-list tbody > tr:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .topic-list-item:has(> td[data-ldp-native-topic-date]),
- html.ldp-reader-workspace .latest-topic-list-item:has(> td[data-ldp-native-topic-date]){grid-template-columns:minmax(0,1fr) 3.75rem 10.5rem 4.75rem;}
- html.ldp-reader-workspace .topic-list-item > td.posters .avatar{width:var(--ldp-host-topic-avatar-size-small,20px)!important;height:var(--ldp-host-topic-avatar-size-small,20px)!important;}}
-
- .ldp-topic-stats-component{display:none!important;}
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component){grid-template-columns:minmax(0,1fr) minmax(12rem,max-content);grid-template-rows:max-content max-content;align-items:start;align-content:start;row-gap:0;min-height:0!important;padding-block:4px!important;}
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link{display:contents!important;}
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line{display:block!important;grid-column:1;grid-row:1;align-self:center;min-width:0;width:100%;max-width:100%;padding-right:0;overflow:visible;text-overflow:clip;white-space:normal!important;box-sizing:border-box;}
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line::after{content:'';display:inline-block;width:20px;height:20px;margin-left:4px;vertical-align:middle;}
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line:has([data-ldp-native-dnd])::after{display:none;}
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line > :is(a,.title,.raw-topic-link){display:inline!important;max-width:none;white-space:normal!important;overflow-wrap:anywhere;}
- html.ldp-reader-workspace .ldp-native-topic-title-tools{display:inline-flex!important;align-items:center;margin-inline-start:6px;vertical-align:middle;white-space:nowrap!important;word-break:keep-all;}
- html.ldp-reader-workspace .ldp-native-topic-title-tools > *{flex:none;align-self:center!important;margin-block:0!important;top:auto!important;bottom:auto!important;transform:none!important;vertical-align:middle!important;}
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-bottom-line{display:flex!important;grid-column:1;grid-row:2;align-self:center;align-items:center;align-content:center;flex-wrap:wrap!important;gap:2px 4px;min-width:0;width:100%;max-width:100%;margin-top:0;overflow:hidden;white-space:normal;}
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-bottom-line > :is(.discourse-tags,.topic-tags){display:contents!important;}
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.posters{grid-column:2!important;grid-row:1!important;align-self:center!important;justify-self:end!important;}
- html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component){display:block!important;grid-column:2!important;grid-row:2!important;align-self:center!important;justify-self:stretch!important;min-width:0;width:100%!important;overflow:visible!important;font-size:0!important;white-space:nowrap;transform:none;}
- html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component)::before{content:none!important;}
- html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component) > :not(.ldp-topic-stats-component),
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.views,
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.activity{display:none!important;}
- html.ldp-reader-workspace .ldp-topic-stats-component{display:flex!important;flex-direction:column;align-items:stretch;justify-content:center;row-gap:0;width:100%;min-width:0;color:var(--ldp-host-font-color,inherit);font-family:var(--ldp-host-font-family,inherit);font-size:var(--ldp-host-topic-stats-size,var(--ldp-font-xs,10px));font-weight:var(--ldp-host-font-weight,400);line-height:1;white-space:nowrap;}
- html.ldp-reader-workspace .ldp-topic-stats-row{display:flex;align-items:baseline;justify-content:flex-end;column-gap:0;width:100%;min-width:0;}
- html.ldp-reader-workspace .ldp-topic-stats-row + .ldp-topic-stats-row{margin-top:var(--ldp-host-topic-stats-row-offset,-4px);}
- html.ldp-reader-workspace .ldp-topic-stat{display:inline-flex;flex:none;align-items:baseline;justify-content:flex-start;gap:4px;min-width:0;white-space:nowrap;}
- html.ldp-reader-workspace :is(.ldp-topic-stats-row,.ldp-topic-stat,.ldp-topic-stat-value,.ldp-topic-stat-value *){line-height:1!important;}
- html.ldp-reader-workspace .ldp-topic-stat + .ldp-topic-stat{position:relative;margin-left:10px;padding-left:11px;}
- html.ldp-reader-workspace .ldp-topic-stat + .ldp-topic-stat::before{content:'';position:absolute;left:0;bottom:0;width:var(--ldp-divider-line-width,1px);height:1.1em;background:var(--ldp-divider-line-color,var(--primary-low,#e5e5e5));}
- html.ldp-reader-workspace .ldp-topic-stat-label{flex:none;color:var(--ldp-host-font-color,var(--primary-medium,#777))!important;font-family:var(--ldp-host-font-family,inherit)!important;font-size:var(--ldp-host-topic-stats-label-size,var(--ldp-font-micro,9px));font-weight:var(--ldp-host-font-weight,650)!important;opacity:.72;}
- html.ldp-reader-workspace .ldp-topic-stat-value{display:inline-flex;align-items:baseline;min-width:0;color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,inherit)!important;font-size:var(--ldp-host-topic-stats-size,var(--ldp-font-xs,10px))!important;font-weight:var(--ldp-host-font-weight,400)!important;white-space:nowrap;}
- html.ldp-reader-workspace .ldp-topic-stat-value *{color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,inherit)!important;font-size:inherit!important;font-weight:var(--ldp-host-font-weight,400)!important;}
- html.ldp-reader-workspace .ldp-topic-stat-value :is(svg,img){flex:none;align-self:baseline;}
- @container ldp-reader-host (max-width:1180px){
- html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.posters,
- html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component){grid-column:2!important;}
- }
- .ldp-overlay.ldp-reader-embedded{align-items:stretch;justify-content:stretch;pointer-events:none;}
- .ldp-reader-embedded .ldp-modal{position:fixed;top:0;bottom:0;width:var(--ldp-reader-workspace-width);height:100vh;
- border-radius:0;box-shadow:0 8px 24px rgba(0,0,0,.18);will-change:width,clip-path,opacity;
- transition:width .24s cubic-bezier(.22,1,.36,1),left .24s cubic-bezier(.22,1,.36,1),right .24s cubic-bezier(.22,1,.36,1);}
- .ldp-reader-embedded-left .ldp-modal{left:0;right:auto;border-right:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
- animation:ldp-reader-embed-in-left .24s cubic-bezier(.22,1,.36,1) both;}
- .ldp-reader-embedded-right .ldp-modal{left:auto;right:0;border-left:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
- animation:ldp-reader-embed-in-right .24s cubic-bezier(.22,1,.36,1) both;}
- @keyframes ldp-reader-embed-in-left{from{opacity:.48;clip-path:inset(0 100% 0 0);}to{opacity:1;clip-path:inset(0);}}
- @keyframes ldp-reader-embed-in-right{from{opacity:.48;clip-path:inset(0 0 0 100%);}to{opacity:1;clip-path:inset(0);}}
- .ldp-reader-embedded-left .ldp-reader-window-capsule{display:flex;top:5px;left:calc(var(--ldp-reader-workspace-width) / 2);right:auto;
- transform:translate(-50%,0);}
- .ldp-reader-embedded-right .ldp-reader-window-capsule{display:flex;top:5px;left:auto;right:calc(var(--ldp-reader-workspace-width) / 2);
- transform:translate(50%,0);}
- .ldp-reader-embedded .ldp-reader-pin-button,.ldp-reader-embedded .ldp-reader-lock-button,
- .ldp-reader-embedded .ldp-reader-resize-handle{display:none!important;}
- .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule{
- position:fixed;display:flex;top:5px;left:50%;right:auto;width:40px;transform:translate(-50%,0);}
- .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule:hover,
- .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule:focus-within,
- .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule.ldp-reader-placement-available:has(.ldp-reader-placement-control:focus-within){
- width:102px;padding-inline:0;transform:translate(-50%,0);}
- .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-pin-button,
- .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-lock-button,
- .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-resize-handle{display:none!important;}
- .ldp-reader-embed-resize{position:fixed;top:0;bottom:0;z-index:15;display:none;width:10px;cursor:col-resize;pointer-events:auto;touch-action:none;}
- .ldp-reader-embedded-left .ldp-reader-embed-resize{left:calc(var(--ldp-reader-workspace-width) - 5px);}
- .ldp-reader-embedded-right .ldp-reader-embed-resize{right:calc(var(--ldp-reader-workspace-width) - 5px);}
- .ldp-reader-embedded .ldp-reader-embed-resize{display:block;}
- .ldp-reader-host-scrollbar{position:fixed;top:0;bottom:0;right:calc(var(--ldp-reader-workspace-width) + 6px);z-index:14;
- display:none;width:6px;padding:0;border:0;outline:0;background:transparent;pointer-events:auto;touch-action:none;user-select:none;
- opacity:1;transition:opacity .12s ease-out;}
- .ldp-reader-embedded .ldp-reader-host-scrollbar{display:block;}
- .ldp-reader-embedded-left .ldp-reader-host-scrollbar{right:6px;}
- .ldp-reader-host-scrollbar.ldp-reader-host-scrollbar-inactive{opacity:0;pointer-events:none;}
- .ldp-reader-host-scrollbar-thumb{position:absolute;left:0;top:0;width:6px;min-height:24px;border-radius:999px;
- background:color-mix(in srgb,var(--primary-medium,#888) 34%,transparent);will-change:height,transform;}
- .ldp-reader-host-scrollbar:hover .ldp-reader-host-scrollbar-thumb,
- .ldp-reader-host-scrollbar:focus-visible .ldp-reader-host-scrollbar-thumb,
- .ldp-reader-host-scrollbar.ldp-reader-host-scrollbar-dragging .ldp-reader-host-scrollbar-thumb{
- background:color-mix(in srgb,var(--primary-medium,#888) 48%,transparent);}
- .ldp-reader-host-top{position:fixed;z-index:16;display:inline-flex;align-items:center;justify-content:center;
- width:30px;height:30px;padding:0;border:1px solid color-mix(in srgb,var(--primary-low,#ddd) 72%,transparent);border-radius:50%;
- background:color-mix(in srgb,var(--secondary,#fff) 68%,transparent);color:color-mix(in srgb,var(--primary-medium,#777) 70%,transparent);
- box-shadow:0 3px 10px rgba(0,0,0,.1);backdrop-filter:blur(3px);pointer-events:auto;cursor:pointer;}
- .ldp-reader-host-top:hover{border-color:color-mix(in srgb,var(--primary-medium,#999) 78%,transparent);
- background:color-mix(in srgb,var(--primary-very-low,#f0f0f0) 86%,transparent);color:var(--primary,#222);}
- .ldp-reader-host-top-countdown{position:absolute;right:2px;bottom:1px;font-size:7px;font-style:italic;
- font-weight:400;line-height:1;color:currentColor;opacity:.42;pointer-events:none;}
- html.ldp-reader-embed-resizing #ember-app{will-change:width,margin-left,margin-right;}
- html.ldp-reader-embed-resizing #ember-app,html.ldp-reader-embed-resizing .d-header,
- html.ldp-reader-embed-resizing body > header,html.ldp-reader-embed-resizing #ember-app > header,
- .ldp-overlay.ldp-reader-embed-resizing .ldp-modal{transition:none!important;}
- .ldp-overlay.ldp-window-managed:not(.ldp-fullpage){align-items:flex-start;justify-content:flex-start;}
- .ldp-overlay.ldp-window-managed:not(.ldp-fullpage):not(.ldp-window-pinned){pointer-events:auto;}
- .ldp-overlay.ldp-window-managed:not(.ldp-fullpage),
- .ldp-overlay.ldp-reader-embedded,
- .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage{
- --ldp-window-capsule-rail:14px;}
- .ldp-overlay.ldp-reader-switch-ready{cursor:pointer;}
- html .ldp-reader-switch-target{border-radius:4px;outline:1px solid color-mix(in srgb,var(--tertiary,#47855f) 58%,transparent);
- outline-offset:3px;background:color-mix(in srgb,var(--tertiary-low,#dceee2) 64%,transparent)!important;
- color:var(--tertiary,#47855f)!important;cursor:pointer!important;
- transition:background-color .16s ease-out,color .16s ease-out,outline-color .16s ease-out;}
- .ldp-overlay.ldp-window-managed:not(.ldp-fullpage) .ldp-modal{position:absolute;}
- .ldp-reader-window-capsule{position:absolute;z-index:14;left:var(--ldp-reader-window-center-x,50%);
- top:calc(var(--ldp-reader-window-top,50%) + 5px);transform:translate(-50%,0);display:none;
- width:64px;height:20px;box-sizing:border-box;align-items:center;justify-content:center;gap:2px;padding:0 4px;
- border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--secondary,#fff) 88%,var(--primary-low,#ddd));
- color:var(--primary-medium,#777);box-shadow:none;pointer-events:auto;
- transition:width .12s ease-out,height .12s ease-out,padding .12s ease-out,gap .12s ease-out,
- transform .12s ease-out,background .12s ease-out,box-shadow .12s ease-out,opacity .12s ease-out;}
- .ldp-reader-window-capsule:not(.ldp-reader-placement-available){width:40px;}
- .ldp-reader-window-capsule:hover,.ldp-reader-window-capsule:focus-within{width:116px;height:24px;overflow:visible;
- transform:translate(-50%,0);gap:2px;padding:1px 4px;background:var(--secondary,#fff);
- box-shadow:0 2px 8px rgba(0,0,0,.22);opacity:1;transition-delay:0s;}
- .ldp-reader-window-capsule:not(:hover):not(:focus-within){
- width:42px;height:10px;gap:0;padding:0;overflow:visible;background:transparent;
- box-shadow:none;opacity:1;outline:0;transition-delay:.12s;}
- .ldp-reader-window-capsule::before{content:"";position:absolute;left:50%;top:50%;
- z-index:2;
- width:30px;height:3px;border-radius:999px;background:rgba(255,255,255,.9);
- box-shadow:0 1px 3px rgba(0,0,0,.2),0 0 0 1px rgba(0,0,0,.08);
- opacity:0;pointer-events:none;transform:translate(-50%,-50%);transition:opacity .1s ease-out;}
- .ldp-reader-window-capsule:not(:hover):not(:focus-within)::before{opacity:.82;transition-delay:.12s;}
- .ldp-reader-window-capsule::after{content:"";position:absolute;z-index:0;inset:-4px -6px;
- border-radius:999px;background:transparent;pointer-events:auto;}
- .ldp-reader-window-capsule > *{position:relative;z-index:1;transition:opacity .1s ease-out;}
- .ldp-reader-window-capsule:not(:hover):not(:focus-within) > *{
- opacity:0;pointer-events:none;transition-delay:.12s;}
- .ldp-overlay:not(.ldp-reader-embedded):not(.ldp-fullpage)
- .ldp-reader-window-capsule.ldp-reader-placement-available:is(:hover,:focus-within){width:172px;}
- .ldp-reader-embedded .ldp-reader-window-capsule:hover,
- .ldp-reader-embedded .ldp-reader-window-capsule:focus-within{width:60px;}
- .ldp-reader-embedded
- .ldp-reader-window-capsule.ldp-reader-placement-available:is(:hover,:focus-within){width:102px;padding-inline:0;}
- .ldp-reader-embedded-left .ldp-reader-window-capsule:hover,
- .ldp-reader-embedded-left .ldp-reader-window-capsule:focus-within{transform:translate(-50%,0);}
- .ldp-reader-embedded-right .ldp-reader-window-capsule:hover,
- .ldp-reader-embedded-right .ldp-reader-window-capsule:focus-within{transform:translate(50%,0);}
- .ldp-window-handle-docked .ldp-reader-window-capsule:hover,
- .ldp-window-handle-docked .ldp-reader-window-capsule:focus-within{transform:translate(-50%,0);}
- .ldp-reader-lock-button,.ldp-reader-pin-button{display:flex;width:20px;height:20px;align-items:center;justify-content:center;
- padding:0;border:0;border-radius:4px;
- background:transparent;color:var(--primary-medium,#777);cursor:pointer;
- transition:width .16s ease-out,opacity .16s ease-out,transform .16s ease-out,visibility 0s linear .16s;}
- .ldp-reader-window-capsule:not(:hover):not(:focus-within) .ldp-reader-lock-button,
- .ldp-reader-window-capsule:not(:hover):not(:focus-within) .ldp-reader-pin-button{
- width:0;opacity:0;visibility:hidden;pointer-events:none;transform:scale(.75);}
- .ldp-reader-window-capsule:hover .ldp-reader-lock-button,
- .ldp-reader-window-capsule:hover .ldp-reader-pin-button,
- .ldp-reader-window-capsule:focus-within .ldp-reader-lock-button,
- .ldp-reader-window-capsule:focus-within .ldp-reader-pin-button{
- opacity:1;visibility:visible;pointer-events:auto;transform:scale(1);transition-delay:0s;}
- .ldp-reader-lock-button:hover,.ldp-reader-pin-button:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
- .ldp-reader-lock-button.active,.ldp-reader-pin-button.active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
- .ldp-reader-lock-button.ldp-lock-state-changing .ldp-icon{animation:ldp-reader-lock-state-change .18s cubic-bezier(.22,1,.36,1);}
- @keyframes ldp-reader-lock-state-change{0%{opacity:.55;transform:scale(.72) rotate(-12deg);}65%{opacity:1;transform:scale(1.12) rotate(4deg);}100%{opacity:1;transform:scale(1) rotate(0);}}
- .ldp-window-pinned .ldp-reader-window-capsule:not(:hover):not(:focus-within),
- .ldp-window-locked .ldp-reader-window-capsule:not(:hover):not(:focus-within){
- background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
- .ldp-reader-placement-control{display:flex;width:auto;height:22px;flex:0 0 auto;align-items:center;overflow:visible;}
- .ldp-reader-placement-divider{width:1px;height:14px;flex:0 0 1px;margin:0 6px;border-radius:999px;
- background:color-mix(in srgb,var(--primary-medium,#777) 34%,transparent);}
- :is(.ldp-reader-embedded,.ldp-fullpage) .ldp-reader-placement-divider{display:none;}
- .ldp-reader-placement-strip{display:flex;width:0;height:22px;flex:0 0 auto;align-items:center;gap:2px;overflow:hidden;
- opacity:0;pointer-events:none;transform:translateX(3px);
- transition:width .12s ease-out,opacity .12s ease-out,transform .12s ease-out;transition-delay:.12s;}
- .ldp-reader-window-capsule.ldp-reader-placement-available:hover .ldp-reader-placement-strip:not([hidden]),
- .ldp-reader-window-capsule.ldp-reader-placement-available:focus-within .ldp-reader-placement-strip:not([hidden]),
- .ldp-reader-placement-control:focus-within .ldp-reader-placement-strip:not([hidden]){
- width:102px;opacity:1;pointer-events:auto;transform:translateX(0);transition-delay:0s;}
- .ldp-reader-window-capsule:not(:hover):not(:focus-within)
- :is(.ldp-reader-pin-button,.ldp-reader-lock-button,.ldp-reader-placement-divider,.ldp-reader-placement-strip){
- transition-delay:.12s;}
- .ldp-reader-placement-option{position:relative;display:flex;width:24px;height:22px;flex:0 0 24px;align-items:center;justify-content:center;
- padding:0;border:0;border-radius:999px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;touch-action:manipulation;}
- .ldp-reader-placement-option:hover,.ldp-reader-placement-option:focus-visible{background:var(--primary-very-low,#f3f3f3);color:var(--primary,#222);outline:0;}
- .ldp-reader-placement-option::after{content:attr(data-reader-placement-label);position:absolute;top:calc(100% + 7px);right:50%;z-index:2;
- padding:4px 7px;
- opacity:0;pointer-events:none;transform:translate(50%,-2px);transition:opacity .08s linear,transform .08s ease-out;}
- .ldp-reader-placement-option:hover::after,.ldp-reader-placement-option:focus-visible::after{opacity:1;transform:translate(50%,0);}
- .ldp-reader-placement-option.active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
- .ldp-reader-resize-handle{position:absolute;z-index:13;display:none;touch-action:none;background:transparent;}
- .ldp-reader-resize-handle[data-reader-resize="n"]{left:20px;right:20px;top:0;height:10px;cursor:ns-resize;}
- .ldp-reader-resize-handle[data-reader-resize="s"]{left:20px;right:20px;bottom:0;height:10px;cursor:ns-resize;}
- .ldp-reader-resize-handle[data-reader-resize="e"]{top:20px;right:0;bottom:20px;width:10px;cursor:ew-resize;}
- .ldp-reader-resize-handle[data-reader-resize="w"]{top:20px;bottom:20px;left:0;width:10px;cursor:ew-resize;}
- .ldp-reader-resize-handle[data-reader-resize="nw"],
- .ldp-reader-resize-handle[data-reader-resize="ne"],
- .ldp-reader-resize-handle[data-reader-resize="sw"],
- .ldp-reader-resize-handle[data-reader-resize="se"]{width:20px;height:20px;}
- .ldp-reader-resize-handle[data-reader-resize="nw"]{left:0;top:0;cursor:nwse-resize;}
- .ldp-reader-resize-handle[data-reader-resize="ne"]{right:0;top:0;cursor:nesw-resize;}
- .ldp-reader-resize-handle[data-reader-resize="sw"]{left:0;bottom:0;cursor:nesw-resize;}
- .ldp-reader-resize-handle[data-reader-resize="se"]{right:0;bottom:0;cursor:nwse-resize;}
- .ldp-window-managed:not(.ldp-fullpage) .ldp-reader-window-capsule,
- .ldp-window-managed:not(.ldp-fullpage):not(.ldp-window-locked) .ldp-reader-resize-handle{display:flex;}
- @media (prefers-reduced-motion:reduce){
- .ldp-reader-lock-button.ldp-lock-state-changing .ldp-icon,.ldp-reader-embedded .ldp-modal{animation:none;}}
- .ldp-window-interacting,.ldp-window-interacting *{user-select:none!important;}
- .ldp-window-interacting .ldp-modal{will-change:left,top,width,height,transform;}
- .ldp-header{--ldp-header-logo-inset:14px;--ldp-header-reserved-right:156px;
- --ldp-home-logo-size:clamp(32px,3.5cqi,48px);--ldp-home-logo-box-size:calc(var(--ldp-home-logo-size) + 4px);
- position:relative;display:flex;align-items:center;gap:8px;box-sizing:border-box;
- min-height:calc(73px + var(--ldp-window-capsule-rail,0px));
- padding:calc(6px + var(--ldp-window-capsule-rail,0px)) var(--ldp-header-reserved-right) 6px var(--ldp-header-logo-inset);
- border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-home-logo{width:var(--ldp-home-logo-box-size);height:var(--ldp-home-logo-box-size);border-radius:6px;display:inline-flex;
- align-items:center;justify-content:center;flex:none;}
- .ldp-home-logo:hover{background:var(--primary-very-low,#f0f0f0);}
- .ldp-logo{width:var(--ldp-home-logo-size);height:var(--ldp-home-logo-size);border-radius:50%;display:block;}
- .ldp-title-wrap{flex:1;min-width:0;display:grid;grid-template-columns:minmax(0,1fr);gap:2px;
- line-height:1.2;text-align:left;}
- .ldp-title{margin:0;min-width:0;display:block;
- font-size:var(--ldp-reader-title-font-size,var(--ldp-font-xl,16px));
- font-weight:700;line-height:1.25;text-align:left;}
- .ldp-title-jump{padding:0;border:none;background:transparent;color:inherit;
- cursor:pointer;font:inherit;text-align:left;display:inline;white-space:normal;
- overflow:visible;text-overflow:clip;overflow-wrap:anywhere;word-break:break-all;}
- .ldp-title-jump:hover{text-decoration:underline;}
- .ldp-title-jump:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:2px;border-radius:2px;}
- .ldp-title-actions{--ldp-header-action-gap:8px;position:absolute;top:7px;right:12px;z-index:2;
- display:inline-flex;align-items:center;gap:var(--ldp-header-action-gap);margin:0;
- transform:translateY(var(--ldp-title-actions-align-y,0px));}
- .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open){width:28px;height:28px;flex:0 0 28px;align-items:center;
- justify-content:center;box-sizing:border-box;padding:4px;border:0;border-radius:6px;background:transparent;
- color:var(--primary-medium,#777);font:inherit;line-height:1;text-decoration:none;cursor:pointer;}
- .ldp-title-actions .ldp-topic-edit-trigger,
- .ldp-title-actions .ldp-open{display:inline-flex;}
- .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open):hover{
- background:var(--primary-very-low,#f0f0f0);color:var(--tertiary,#47855f);}
- .ldp-title-actions .ldp-close{position:relative;}
- .ldp-title-actions .ldp-close::before{content:"";position:absolute;top:6px;bottom:6px;
- left:calc(var(--ldp-header-action-gap) / -2);width:1px;background:var(--primary-low,#d7d7d7);
- opacity:.65;transform:translateX(-50%);pointer-events:none;}
- .ldp-reader-action-dialog.ldp-topic-edit-dialog{width:min(560px,100%);overflow:visible;}
- .ldp-reader-action-body.ldp-topic-edit-body{grid-template-columns:repeat(2,minmax(250px,1fr));
- gap:12px;overflow:visible;}
- .ldp-topic-edit-title{grid-column:1 / -1;}
- .ldp-topic-edit-field{display:grid;gap:4px;min-width:0;color:var(--primary-medium,#777);
- font-size:var(--ldp-font-xs,10px);font-weight:650;line-height:1.2;}
- .ldp-topic-edit-field > input,.ldp-topic-edit-label-control{width:100%;min-width:0;
- box-sizing:border-box;border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);
- color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-base,13px);line-height:1.4;outline:none;}
- .ldp-topic-edit-field > input{height:38px;padding:7px 10px;}
- .ldp-topic-edit-field > input:focus,.ldp-topic-edit-label-control:focus-within{
- border-color:var(--tertiary,#47855f);box-shadow:0 0 0 1px var(--tertiary,#47855f);}
- .ldp-topic-edit-category{position:relative;min-width:0;}
- .ldp-picker-trigger{display:flex;width:100%;align-items:center;gap:8px;
- box-sizing:border-box;border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);
- color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-base,13px);font-weight:600;line-height:1.35;cursor:pointer;}
- .ldp-topic-edit-category-trigger{height:38px;padding:7px 9px 7px 10px;}
- .ldp-font-family-trigger:hover,
- .ldp-topic-edit-category-trigger:hover:not(:disabled){border-color:var(--primary-medium,#999);}
- .ldp-picker-trigger[aria-expanded="true"],.ldp-picker-trigger:focus-visible{
- border-color:var(--tertiary,#47855f);outline:none;}
- .ldp-picker-trigger:focus-visible,.ldp-topic-edit-category-trigger[aria-expanded="true"]{
- box-shadow:0 0 0 1px var(--tertiary,#47855f);}
- .ldp-topic-edit-category-trigger:disabled{cursor:default;opacity:.5;}
- .ldp-topic-edit-category-value{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;}
- .ldp-picker-trigger .ldp-icon{width:16px;height:16px;flex:none;color:var(--primary-medium,#777);}
- .ldp-topic-edit-category-trigger .ldp-icon{transition:transform 150ms ease;}
- .ldp-topic-edit-category-menu{position:absolute;z-index:3;top:calc(100% + 6px);left:0;width:100%;min-width:260px;
- box-sizing:border-box;padding:5px;border:1px solid var(--primary-low,#d7d7d7);border-radius:8px;
- background:var(--secondary,#fff);box-shadow:0 4px 8px rgba(0,0,0,.18);}
- .ldp-picker-search{display:flex;align-items:center;gap:7px;height:34px;padding:0 8px;
- box-sizing:border-box;border:1px solid var(--primary-low,#ddd);border-radius:6px;color:var(--primary-medium,#777);}
- .ldp-topic-edit-category-search{margin:1px 1px 5px;}
- .ldp-picker-search:focus-within{border-color:var(--tertiary,#47855f);}
- .ldp-picker-search .ldp-icon{width:14px;height:14px;flex:none;}
- .ldp-picker-search input{min-width:0;flex:1;padding:0;border:0;outline:0;box-shadow:none;
- background:transparent;color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-ui,12px);}
- .ldp-topic-edit-category-search input{height:30px;}
- .ldp-picker-options{display:grid;overflow-y:auto;overscroll-behavior:contain;
- padding-right:2px;scrollbar-width:thin;
- scrollbar-color:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff)) transparent;}
- .ldp-topic-edit-category-options{gap:2px;max-height:min(300px,40vh);}
- .ldp-picker-option{display:grid;align-items:center;gap:9px;box-sizing:border-box;width:100%;min-height:36px;
- padding:4px 8px;border:0;border-radius:6px;background:transparent;font:inherit;text-align:left;cursor:pointer;}
- .ldp-topic-edit-category-option{grid-template-columns:10px minmax(0,1fr);color:var(--primary,#222);}
- .ldp-picker-option:hover,.ldp-picker-option:focus-visible{
- background:var(--primary-very-low,#f2f2f2);outline:none;}
- .ldp-picker-option[aria-selected="true"]{
- background:color-mix(in srgb,var(--tertiary-low,#dceee2) 68%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
- .ldp-topic-edit-category-dot{width:8px;height:8px;border-radius:50%;background:var(--ldp-category-color,var(--primary-low,#ccc));}
- .ldp-picker-option-copy{display:grid;gap:1px;min-width:0;}
- .ldp-picker-option-copy strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--ldp-font-ui,12px);line-height:1.3;}
- .ldp-picker-option-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--primary-medium,#777);
- font-size:var(--ldp-font-micro,9px);font-weight:500;line-height:1.25;}
- @supports (appearance:base-select){
- .ldp-reader-select,.ldp-reader-select::picker(select){appearance:base-select;}
- .ldp-reader-select{display:flex;box-sizing:border-box;align-items:center;gap:8px;padding-inline:10px 8px;
- border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);color:var(--primary,#222);
- font:inherit;font-size:var(--ldp-font-base,13px);font-weight:600;line-height:1.35;text-align:left;text-align-last:left;cursor:pointer;}
- .ldp-reader-select:hover:not(:disabled){border-color:var(--primary-medium,#999);}
- .ldp-reader-select:focus-visible,.ldp-reader-select:open{border-color:var(--tertiary,#47855f);
- box-shadow:0 0 0 1px var(--tertiary,#47855f);outline:none;}
- .ldp-reader-select:disabled{cursor:default;opacity:.5;}
- .ldp-reader-select::picker-icon{width:16px;height:16px;flex:none;margin-inline-start:auto;color:var(--primary-medium,#777);
- transition:transform 150ms ease-out;}
- .ldp-reader-select:open::picker-icon{transform:rotate(180deg);}
- .ldp-reader-select::picker(select){inline-size:var(--ldp-select-picker-width,anchor-size(width));min-inline-size:0;
- max-inline-size:var(--ldp-select-picker-max-width,min(300px,calc(100vw - 24px)));
- max-block-size:var(--ldp-select-picker-max-height,min(300px,40vh));box-sizing:border-box;margin-block:6px;padding:5px;overflow-y:auto;
- overscroll-behavior:contain;scrollbar-width:thin;
- scrollbar-color:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff)) transparent;
- border:1px solid var(--primary-low,#d7d7d7);border-radius:8px;
- background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 4px 8px rgba(0,0,0,.18);
- opacity:1;visibility:visible;}
- .ldp-reader-select option{display:flex;box-sizing:border-box;min-height:34px;align-items:center;gap:9px;padding:4px 8px;
- border:0;border-radius:6px;background:transparent;color:var(--primary,#222);font:inherit;font-weight:600;
- line-height:1.35;white-space:nowrap;cursor:pointer;}
- .ldp-reader-select option:hover,.ldp-reader-select option:focus-visible{
- background:var(--primary-very-low,#f2f2f2);outline:none;}
- .ldp-reader-select option:checked{
- background:color-mix(in srgb,var(--tertiary-low,#dceee2) 68%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
- .ldp-reader-select option::checkmark{display:none;}
- .ldp-collection-scope{--ldp-select-picker-max-height:100px;justify-content:center;gap:6px;
- padding-inline:6px;text-align:center;text-align-last:center;}
- .ldp-collection-scope::picker-icon{width:14px;height:14px;margin-inline-start:0;}
- .ldp-collection-scope option{min-height:30px;gap:6px;padding:3px 6px;font-size:var(--ldp-font-ui,12px);}
- @media (prefers-reduced-motion:reduce){.ldp-reader-select::picker-icon{transition:none;}}
- }
- .ldp-topic-edit-label-control{display:flex;align-items:center;flex-wrap:wrap;gap:4px;min-height:38px;padding:4px 6px;}
- .ldp-topic-edit-label-values{display:contents;}
- .ldp-topic-edit-label-chip{display:inline-flex;align-items:center;gap:3px;max-width:150px;padding:2px 5px;
- border-radius:4px;background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);font-size:var(--ldp-font-ui,12px);font-weight:550;}
- .ldp-topic-edit-label-chip > span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-topic-edit-label-remove{display:inline-flex;width:18px;height:18px;flex:none;align-items:center;justify-content:center;
- padding:2px;border:0;border-radius:4px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-topic-edit-label-remove:hover{background:var(--primary-low,#ddd);color:var(--primary,#222);}
- .ldp-topic-edit-label-input{flex:1 1 120px;min-width:90px;height:26px;padding:2px;border:0!important;
- background:transparent!important;color:inherit!important;font:inherit!important;font-size:var(--ldp-font-base,13px)!important;
- outline:none!important;box-shadow:none!important;}
- .ldp-reader-action-footer.ldp-topic-edit-actions{justify-content:flex-start;}
- .ldp-topic-edit-save,.ldp-topic-edit-cancel{display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;
- padding:7px;border-radius:6px;font:inherit;cursor:pointer;}
- .ldp-topic-edit-cancel{border:1px solid var(--primary-low,#ccc);background:transparent;color:var(--primary,#222);}
- .ldp-topic-edit-save:hover{background:color-mix(in srgb,var(--tertiary,#47855f) 88%,#000);}
- .ldp-topic-edit-cancel:hover{background:var(--primary-very-low,#f0f0f0);}
- .ldp-topic-edit-save:disabled,.ldp-topic-edit-cancel:disabled{cursor:default;opacity:.5;}
- .ldp-topic-edit-status{grid-column:1 / -1;min-width:0;min-height:17px;margin:0;color:var(--danger,#c44);
- font-size:var(--ldp-font-ui,12px);font-weight:500;line-height:1.35;}
- .ldp-only-op-toggle{box-sizing:border-box;width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;
- flex:none;padding:3px;border:0;border-radius:5px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-only-op-toggle:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
- .ldp-only-op-toggle.active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
- .ldp-only-op-toggle:disabled{opacity:.45;cursor:default;}
- .ldp-title-subline{display:grid;grid-template-columns:minmax(0,1fr);align-items:start;justify-items:start;
- gap:2px;min-width:0;text-align:left;}
- .ldp-meta-row{display:flex;align-items:center;min-width:0;width:100%;}
- .ldp-meta{display:flex;flex-wrap:wrap;align-items:center;min-width:0;
- font-size:var(--ldp-reader-meta-font-size,var(--ldp-font-sm,11px));
- margin-top:0;line-height:1.2;white-space:nowrap;text-align:left;}
- .ldp-meta-stats,.ldp-meta-owner-copy,.ldp-meta-owner::before{opacity:.7;}
- .ldp-meta-owner{display:inline-flex;align-items:center;white-space:nowrap;}
- .ldp-meta-owner::before{content:' · ';white-space:pre;}
- .ldp-meta-owner .ldp-only-op-toggle{margin-left:3px;}
- .ldp-only-op-progress{display:inline-flex;min-width:0;align-items:center;gap:4px;margin-left:5px;
- color:var(--primary-medium,#68737e);font-size:var(--ldp-reader-meta-font-size,var(--ldp-font-sm,11px));
- font-variant-numeric:tabular-nums;line-height:1;}
- .ldp-only-op-progress-track{width:42px;height:4px;flex:none;overflow:hidden;border-radius:999px;
- background:color-mix(in srgb,var(--primary-low,#d7dde3) 82%,var(--secondary,#fff));}
- .ldp-only-op-progress-fill{display:block;width:var(--ldp-only-op-progress,0%);height:100%;border-radius:inherit;
- background:var(--tertiary,#47855f);transition:width .16s ease-out;}
- .ldp-only-op-progress[data-state="cooldown"] .ldp-only-op-progress-fill,
- .ldp-only-op-progress[data-state="window"] .ldp-only-op-progress-fill,
- .ldp-only-op-progress[data-state="waiting"] .ldp-only-op-progress-fill,
- .ldp-only-op-progress[data-state="retry"] .ldp-only-op-progress-fill,
- .ldp-only-op-progress[data-state="partial"] .ldp-only-op-progress-fill{background:var(--highlight,#e7a400);}
- .ldp-only-op-progress[data-state="error"] .ldp-only-op-progress-fill{background:var(--danger,#c84545);}
- .ldp-only-op-progress[data-state="done"] .ldp-only-op-progress-fill{background:var(--success,#3f8f5f);}
- .ldp-only-op-progress-value{max-width:min(255px,32vw);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-title-topic-row{display:grid;grid-template-columns:14px minmax(0,1fr) 14px;
- align-items:center;min-width:0;width:100%;overflow:hidden;text-align:left;}
- .ldp-title-topic-row:not(.has-overflow){grid-template-columns:minmax(0,1fr);}
- .ldp-title-topic-row:not(.has-overflow) .ldp-title-topic-scroll-hint{display:none;}
- .ldp-title-topic-scroller{display:flex;align-items:center;justify-content:flex-start;flex-wrap:nowrap;
- gap:4px 8px;min-width:0;width:100%;overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;
- scrollbar-width:none;}
- .ldp-title-topic-scroller::-webkit-scrollbar{display:none;}
- .ldp-title-topic-scroll-hint{display:flex;width:14px;height:18px;align-items:center;justify-content:center;
- color:var(--primary-medium,#777);font-size:0;line-height:0;
- opacity:0;pointer-events:none;transition:opacity 120ms ease;}
- .ldp-title-topic-scroll-hint::before{content:"";box-sizing:border-box;width:6px;height:6px;
- border:solid currentColor;border-width:0 1.5px 1.5px 0;}
- .ldp-title-topic-scroll-hint-left::before{transform:rotate(135deg);}
- .ldp-title-topic-scroll-hint-right::before{transform:rotate(-45deg);}
- .ldp-title-topic-row.can-scroll-left .ldp-title-topic-scroll-hint-left,
- .ldp-title-topic-row.can-scroll-right .ldp-title-topic-scroll-hint-right{opacity:.78;}
- .ldp-topic-tags{display:flex;min-width:max-content;flex-wrap:nowrap;gap:4px;margin-top:0;
- font-size:var(--ldp-reader-topic-tag-font-size,var(--ldp-font-sm,11px));line-height:1.2;}
- .ldp-topic-tag{display:inline-flex;align-items:center;gap:3px;max-width:none;flex:none;line-height:1.25;
- padding:1px 5px;border-radius:4px;background:var(--primary-very-low,#f0f0f0);
- color:inherit;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-topic-tag :is(svg,img){width:1em;height:1em;flex:none;object-fit:contain;}
- .ldp-topic-tag:hover{background:var(--primary-low,#e0e0e0);color:var(--tertiary,#08c);}
- .ldp-topic-category{font-weight:600;max-width:none;}
- .ldp-topic-category-text{display:inline-flex;min-width:0;}
- .ldp-topic-category-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-topic-category-level{flex:none;}
- .ldp-header :is(.ldp-title,.ldp-meta-row,.ldp-topic-tags){
- box-sizing:border-box;padding:1px 4px;}
- .ldp-head-btns{display:flex;gap:6px;align-items:center;flex:none;justify-content:flex-end;position:relative;margin-top:36px;
- right:calc(var(--ldp-header-logo-inset) - var(--ldp-header-reserved-right));}
- .ldp-layout-toggle,.ldp-notifications-toggle,.ldp-history-toggle,.ldp-bookmarks-toggle,.ldp-translate-toggle,.ldp-settings-toggle{cursor:pointer;border:1px solid var(--primary-low,#ccc);
- background:transparent;color:inherit;border-radius:6px;padding:5px;box-sizing:border-box;
- width:30px;height:30px;font-size:var(--ldp-font-ui,12px);white-space:nowrap;text-decoration:none;display:inline-flex;
- align-items:center;justify-content:center;}
- .ldp-head-btns :is(.ldp-notifications-toggle,.ldp-history-toggle,.ldp-bookmarks-toggle,.ldp-translate-toggle,.ldp-settings-toggle) .ldp-icon{
- width:15px;height:15px;stroke-width:1.9;}
- .ldp-notifications-toggle{position:relative;overflow:visible;}
- .ldp-notification-unread-badge{position:absolute;right:-5px;top:-5px;z-index:1;min-width:15px;height:15px;
- box-sizing:border-box;padding:0 4px;border:2px solid var(--secondary,#fff);border-radius:999px;
- background:var(--danger,#d94747);color:#fff;font-size:var(--ldp-font-micro,9px);font-weight:750;line-height:11px;text-align:center;
- font-variant-numeric:tabular-nums;pointer-events:none;}
- .ldp-layout-toggle span,.ldp-notifications-toggle > span,.ldp-history-toggle > span,.ldp-bookmarks-toggle > span,.ldp-translate-toggle span,.ldp-settings-toggle span{display:none;}
- @container ldp-reader (min-width:701px){
- .ldp-header{--ldp-header-reserved-right:var(--ldp-header-logo-inset);
- display:grid;grid-template-columns:var(--ldp-home-logo-box-size) minmax(0,1fr) auto;
- grid-template-rows:auto auto auto;
- row-gap:1px;column-gap:8px;min-height:0;
- padding-top:calc(4px + var(--ldp-window-capsule-rail,0px));padding-bottom:4px;}
- .ldp-home-logo{grid-column:1;grid-row:1 / 4;align-self:center;}
- .ldp-title-wrap{display:contents;}
- .ldp-title{grid-column:2 / -1;grid-row:1;min-height:30px;line-height:1.18;}
- .ldp-header.ldp-title-single-line .ldp-title{min-height:0;}
- .ldp-title::before{content:"";float:right;
- width:calc(112px * var(--ldp-reader-display-scale,1) + 32px);
- height:calc(28px * var(--ldp-reader-display-scale,1));}
- .ldp-header.ldp-title-single-line .ldp-title::before{height:1.18em;}
- .ldp-title-subline{display:contents;}
- .ldp-meta-row{grid-column:2;grid-row:2;}
- .ldp-title-topic-row{grid-column:2;grid-row:3;align-self:end;}
- .ldp-head-btns{grid-column:3;grid-row:3;position:static;right:auto;
- height:0;min-height:0;margin-top:0;align-items:flex-end;align-self:end;overflow:visible;}
- .ldp-title-actions{top:calc(4px + var(--ldp-window-capsule-rail,0px));}
- .ldp-overlay.ldp-fullpage .ldp-title{grid-column:2;}
- .ldp-overlay.ldp-fullpage .ldp-title::before{display:none;}
- }
- .ldp-layout-toggle:hover,.ldp-notifications-toggle:hover,.ldp-history-toggle:hover,.ldp-bookmarks-toggle:hover,.ldp-translate-toggle:hover,.ldp-settings-toggle:hover{background:var(--primary-very-low,#f0f0f0);}
- .ldp-translate-toggle.is-active{color:var(--tertiary,#08c);background:var(--tertiary-very-low,var(--primary-very-low,#f0f0f0));}
- .ldp-translate-toggle.is-busy{opacity:.7;}
- .ldp-translation-original{display:contents;}
- .ldp-translation-text{display:none;}
- .ldp-translation-active .ldp-translation-text{display:block;margin-top:.35em;padding-left:.65em;border-left:2px solid var(--primary-low,#ddd);color:var(--primary-medium,#666);}
- .ldp-translation-only .ldp-translation-original{display:none;}
- .ldp-translation-only .ldp-translation-text{margin-top:0;padding-left:0;border-left:0;color:inherit;}
- .ldp-notifications-popover,.ldp-history-popover,.ldp-bookmarks-popover{position:fixed;z-index:2147483606;box-sizing:border-box;
- width:min(420px,calc(100vw - 24px));height:min(68vh,560px);display:flex;flex-direction:column;
- border:1px solid var(--primary-low,#ddd);border-radius:8px;background:var(--secondary,#fff);
- color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit);
- font-weight:var(--ldp-interface-font-weight,400);box-shadow:0 16px 46px rgba(0,0,0,.22);overflow:hidden;pointer-events:auto;}
- .ldp-notification-mode-tabs,.ldp-notification-tabs{display:grid;gap:2px;
- margin:8px 10px 0;padding:2px;border-radius:6px;background:var(--primary-very-low,#f3f3f3);user-select:none;}
- .ldp-notification-mode-tabs{grid-template-columns:repeat(2,minmax(0,1fr));}
- .ldp-notification-tabs{grid-template-columns:repeat(var(--ldp-notification-tab-count,8),minmax(0,1fr));}
- .ldp-notification-mode-tab,.ldp-notification-tab{display:inline-flex;width:100%;min-width:0;align-items:center;justify-content:center;gap:3px;
- border:0;border-radius:4px;padding:6px 2px;background:transparent;color:var(--primary-medium,#777);
- font:inherit;font-size:var(--ldp-font-xs,10px);font-weight:650;line-height:1;white-space:nowrap;cursor:pointer;}
- .ldp-notification-mode-tab .ldp-icon,.ldp-notification-tab .ldp-icon{width:12px;height:12px;stroke-width:2;}
- .ldp-notification-mode-tab:hover,.ldp-notification-tab:hover{color:var(--primary,#222);}
- .ldp-notification-mode-tab:focus-visible,.ldp-notification-tab:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:-2px;}
- .ldp-notification-mode-tab.active,.ldp-notification-tab.active{background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 1px 2px rgba(0,0,0,.1);}
- .ldp-notification-toolbar{display:flex;align-items:center;justify-content:space-between;gap:10px;
- min-height:30px;padding:5px 10px 0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
- .ldp-notification-mark-all,.ldp-notification-new-message{display:inline-flex;align-items:center;gap:5px;min-height:26px;padding:4px 7px;
- border:1px solid var(--primary-low,#ddd);border-radius:6px;background:transparent;color:inherit;
- font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;text-decoration:none;cursor:pointer;}
- .ldp-notification-mark-all:hover:not(:disabled),.ldp-notification-new-message:hover{background:var(--primary-very-low,#f3f3f3);color:var(--primary,#222);}
- .ldp-notification-mark-all:disabled{opacity:.42;cursor:default;}
- .ldp-popover-search{display:flex;align-items:center;gap:7px;margin:8px 10px 6px;padding:0 9px;
- min-height:36px;box-sizing:border-box;border:1px solid var(--primary-low,#ddd);border-radius:6px;background:var(--secondary,#fff);}
- .ldp-popover-search:focus-within{border-color:var(--tertiary,#47855f);}
- .ldp-popover-search .ldp-icon{width:15px;height:15px;flex:none;color:var(--primary-medium,#777);}
- .ldp-popover-search-input{min-width:0;flex:1;align-self:center;height:32px;margin:0!important;
- border:0!important;outline:0!important;box-shadow:none!important;padding:0!important;appearance:none;
- background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);line-height:32px;}
- .ldp-popover-search-clear{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;
- flex:none;border:0;border-radius:4px;padding:0;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-popover-search-clear:hover{background:var(--primary-very-low,#f3f3f3);color:inherit;}
- .ldp-notification-list{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:2px 0;}
- .ldp-collection-title{display:flex;height:47px;box-sizing:border-box;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px 8px 12px;
- border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-base,13px);font-weight:700;}
- .ldp-collection-title-actions{display:flex;align-items:center;gap:6px;}
- .ldp-collection-action{position:relative;display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;padding:0;
- border:1px solid var(--primary-low,#ddd);border-radius:6px;background:transparent;color:var(--primary-medium,#777);
- font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;}
- .ldp-collection-action:hover:not(:disabled){
- border-color:var(--tertiary,#47855f);color:var(--tertiary,#47855f);background:var(--primary-very-low,#f3f3f3);}
- .ldp-history-sort-toggle[aria-pressed="true"]{border-color:var(--tertiary,#47855f);
- background:var(--tertiary-low,#dbeeff);color:var(--tertiary,#47855f);}
- .ldp-collection-action.danger:hover:not(:disabled){border-color:var(--danger,#c94747);color:var(--danger,#c94747);}
- .ldp-collection-action:disabled{opacity:.42;cursor:default;}
- .ldp-collection-count{position:absolute;right:-5px;top:-6px;display:flex;min-width:15px;height:15px;box-sizing:border-box;
- align-items:center;justify-content:center;padding:0 3px;border-radius:999px;background:var(--danger,#c94747);color:#fff;
- font-size:9px;font-weight:750;line-height:1;}
- .ldp-collection-scope{width:74px;height:28px;min-width:74px;flex:none;padding:3px 6px;border:1px solid var(--primary-low,#ddd);
- border-radius:6px;background:var(--secondary,#fff);color:inherit;font:inherit;font-size:var(--ldp-font-base,13px);
- font-weight:700;line-height:1;cursor:pointer;}
- .ldp-bookmark-tabs{display:flex;gap:2px;margin:8px 10px 0;padding:2px;border-radius:6px;background:var(--primary-very-low,#f3f3f3);user-select:none;}
- .ldp-bookmark-tab{flex:1;min-width:0;border:0;border-radius:4px;padding:6px 8px;background:transparent;color:var(--primary-medium,#777);
- font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:default;}
- .ldp-bookmark-tab.ldp-bookmark-tab-dragging{cursor:grabbing;opacity:.65;}
- .ldp-bookmark-tab:hover{color:var(--primary,#222);}
- .ldp-bookmark-tab.active{background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 1px 2px rgba(0,0,0,.1);}
- .ldp-reaction-filters{display:flex;gap:5px;margin:0 10px 6px;overflow-x:auto;overscroll-behavior-x:contain;}
- .ldp-reaction-filter{display:inline-flex;min-width:30px;height:28px;flex:none;align-items:center;justify-content:center;gap:4px;padding:0 7px;
- border:1px solid var(--primary-low,#ddd);border-radius:6px;background:transparent;color:var(--primary-medium,#777);
- font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;line-height:1;cursor:pointer;}
- .ldp-reaction-filter:hover{border-color:var(--tertiary,#47855f);color:var(--primary,#222);background:var(--primary-very-low,#f3f3f3);}
- .ldp-reaction-filter.active{border-color:var(--tertiary,#47855f);background:var(--tertiary-low,#dbeeff);color:var(--primary,#222);}
- .ldp-reaction-filter-count{font-variant-numeric:tabular-nums;}
- .ldp-collection-item{position:relative;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-collection-item:first-child{border-top:none;}
- .ldp-collection-item .ldp-notification-item{padding-right:56px;border-top:none;}
- .ldp-collection-item.multi .ldp-notification-item{padding-left:42px;}
- .ldp-collection-item.selected .ldp-notification-item{
- background:color-mix(in srgb,var(--tertiary-low,#dbeeff) 32%,var(--secondary,#fff));}
- .ldp-reaction-record .ldp-notification-item{padding-right:12px;}
- .ldp-reaction-record-icon{display:inline-flex;width:16px;height:16px;align-items:center;justify-content:center;
- margin-right:3px;font-size:14px;line-height:1;vertical-align:-3px;}
- .ldp-reaction-filter img.emoji,.ldp-reaction-filter img.emoji.only-emoji,
- .ldp-reaction-record-icon img.emoji,.ldp-reaction-record-icon img.emoji.only-emoji{width:16px;height:16px;margin:0;object-fit:contain;}
- .ldp-collection-select{position:absolute;z-index:1;left:12px;top:50%;transform:translateY(-50%);display:flex;
- width:18px;height:28px;align-items:center;justify-content:center;cursor:pointer;}
- .ldp-collection-select-input{width:16px;height:16px;margin:0;accent-color:var(--tertiary,#47855f);cursor:pointer;}
- .ldp-collection-delete{position:absolute;right:10px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;
- width:32px;height:32px;padding:0;border:1px solid var(--primary-low,#ddd);border-radius:6px;background:var(--secondary,#fff);
- color:var(--primary-medium,#777);font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;
- opacity:0;pointer-events:none;}
- .ldp-collection-item:hover .ldp-collection-delete,.ldp-collection-item:focus-within .ldp-collection-delete{
- opacity:1;pointer-events:auto;}
- .ldp-collection-delete:hover,.ldp-collection-delete:focus-visible{border-color:var(--danger,#c94747);color:var(--danger,#c94747);}
- @media (hover:none){.ldp-collection-delete{opacity:1;pointer-events:auto;}}
- .ldp-notification-date-group{padding:0 8px 4px;}
- .ldp-notification-date-label{padding:8px 4px 5px;color:var(--primary-medium,#777);
- font-size:var(--ldp-font-sm,11px);font-weight:700;}
- .ldp-notification-item{display:grid;grid-template-columns:34px minmax(0,1fr);gap:9px;
- align-items:start;padding:9px 12px;color:inherit;text-decoration:none;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-notification-date-group .ldp-notification-item:not(.ldp-notification-message-item):first-of-type{border-top:none;}
- .ldp-notification-item:hover{background:var(--primary-very-low,#f5f5f5);color:inherit;}
- .ldp-notification-item.unread{background:color-mix(in srgb,var(--tertiary-low,#dbeeff) 36%,var(--secondary,#fff));}
- .ldp-notification-message-item{grid-template-columns:36px 24px minmax(0,1fr) auto;gap:8px;align-items:start;
- margin:0 0 5px;padding:8px;border:1px solid var(--ldp-divider-line-color,#e5e5e5);border-radius:8px;
- background:var(--secondary,#fff);}
- .ldp-notification-date-label + .ldp-notification-message-item{margin-top:1px;}
- .ldp-notification-message-item:hover{border-color:color-mix(in srgb,var(--primary-medium,#888) 28%,transparent);}
- .ldp-notification-avatar{width:32px;height:32px;border-radius:50%;object-fit:cover;background:var(--primary-low,#ddd);}
- .ldp-notification-message-item .ldp-notification-avatar{width:34px;height:34px;}
- .ldp-notification-avatar-fallback{display:flex;align-items:center;justify-content:center;
- color:var(--primary-medium,#666);font-size:var(--ldp-font-base,13px);font-weight:700;}
- .ldp-notification-copy{min-width:0;display:grid;gap:2px;}
- .ldp-notification-title{font-size:var(--ldp-font-base,13px);font-weight:650;line-height:1.35;overflow-wrap:anywhere;}
- .ldp-notification-message-item .ldp-notification-title{display:block;
- font-size:var(--ldp-font-ui,12px);font-weight:600;line-height:1.35;overflow-wrap:anywhere;}
- .ldp-notification-type-icon{--ldp-notification-type-color:var(--primary-medium,#777);
- display:inline-flex;align-items:center;justify-content:center;flex:none;width:24px;height:24px;margin-top:1px;
- border-radius:50%;color:var(--ldp-notification-type-color);
- background:color-mix(in srgb,var(--ldp-notification-type-color) 12%,transparent);}
- .ldp-notification-type-icon[data-notification-group="replies"]{--ldp-notification-type-color:#3b82f6;}
- .ldp-notification-type-icon[data-notification-group="likes"]{--ldp-notification-type-color:#e0527d;}
- .ldp-notification-type-icon[data-notification-group="boosts"]{--ldp-notification-type-color:#f59e0b;}
- .ldp-notification-type-icon[data-notification-group="mentions"],
- .ldp-notification-type-icon[data-notification-group="reactions"]{--ldp-notification-type-color:#8b5cf6;}
- .ldp-notification-type-icon[data-notification-group="edits"],
- .ldp-notification-type-icon[data-notification-group="links"]{--ldp-notification-type-color:#2f9e72;}
- .ldp-notification-type-icon[data-notification-group="inbox"],
- .ldp-notification-type-icon[data-notification-group="sent"],
- .ldp-notification-type-icon[data-notification-group="newMessages"],
- .ldp-notification-type-icon[data-notification-group="unreadMessages"],
- .ldp-notification-type-icon[data-notification-group="archive"],
- .ldp-notification-type-icon[data-notification-group="botMessages"]{--ldp-notification-type-color:#64748b;}
- .ldp-notification-type-icon .ldp-icon{width:14px;height:14px;stroke-width:2;}
- .ldp-notification-title-text{min-width:0;}
- .ldp-notification-excerpt{display:-webkit-box;overflow:hidden;color:var(--primary-medium,#777);
- font-size:var(--ldp-font-xs,10px);line-height:1.35;-webkit-box-orient:vertical;-webkit-line-clamp:2;}
- .ldp-notification-meta{font-size:var(--ldp-font-xs,10px);line-height:1.3;color:var(--primary-medium,#777);}
- .ldp-notification-read-state{display:inline-flex;align-self:center;justify-content:center;min-width:28px;padding:3px 5px;
- border-radius:999px;background:var(--primary-very-low,#f3f3f3);color:var(--primary-medium,#777);
- font-size:var(--ldp-font-micro,9px);font-weight:650;line-height:1.2;white-space:nowrap;}
- .ldp-notification-item.unread .ldp-notification-read-state{
- background:color-mix(in srgb,var(--tertiary-low,#dbeeff) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
- .ldp-notification-empty,.ldp-notification-error{display:flex;min-height:148px;align-items:center;
- justify-content:center;padding:18px;text-align:center;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);}
- .ldp-notification-error{color:var(--danger,#c44);}
- .ldp-notification-pager{display:grid;grid-template-columns:34px minmax(0,1fr) 34px;align-items:center;
- gap:8px;padding:7px 9px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-notification-page-prev,.ldp-notification-page-next{width:30px;height:30px;display:inline-flex;
- align-items:center;justify-content:center;border:1px solid var(--primary-low,#ddd);border-radius:6px;
- background:transparent;color:inherit;cursor:pointer;}
- .ldp-notification-page-prev:disabled,.ldp-notification-page-next:disabled{opacity:.38;cursor:default;}
- .ldp-notification-page-info{text-align:center;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
- .ldp-settings-popover{--ldp-settings-frozen-height:75px;--ldp-settings-sidebar-width:160px;
- --ldp-font-micro:9px;--ldp-font-xs:10px;--ldp-font-sm:11px;--ldp-font-ui:12px;--ldp-font-base:13px;
- --ldp-font-md:14px;--ldp-font-lg:15px;--ldp-font-xl:16px;--ldp-font-2xl:17px;--ldp-font-3xl:18px;
- position:absolute;left:50%;top:50%;right:auto;z-index:2147483606;
- transform:translate(-50%,-50%);display:grid;grid-template-columns:var(--ldp-settings-sidebar-width) minmax(0,1fr);
- width:672px;height:520px;max-width:calc(100% - 32px);max-height:calc(100% - 32px);
- border:1px solid var(--primary-low,#ddd);border-radius:12px;background:var(--secondary,#fff);
- color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit);
- font-weight:var(--ldp-interface-font-weight,400);box-shadow:0 24px 70px rgba(0,0,0,.24);overflow:hidden;pointer-events:auto;}
- .ldp-settings-popover:not([hidden]){will-change:transform;}
- .ldp-settings-popover.ldp-settings-window-dragging{cursor:grabbing;user-select:none;}
- .ldp-settings-close{position:absolute;top:12px;right:12px;z-index:2;display:inline-flex;
- width:32px;height:32px;align-items:center;justify-content:center;padding:0;border:0;border-radius:7px;
- background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-settings-close:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
- .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking){
- border-color:color-mix(in srgb,var(--primary-low,#ddd) 10%,transparent);
- background:color-mix(in srgb,var(--secondary,#fff) 75%,transparent);box-shadow:0 24px 70px rgba(0,0,0,.024);}
- .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-tabs,
- .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-section > :not(.ldp-settings-fields):not(.ldp-color-groups):not(.ldp-settings-category-groups),
- .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-color-group-head,
- .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-category-head,
- .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-fields > .ldp-setting-row:not(.ldp-range-drag-active):not(.ldp-color-pick-active),
- .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-fields > .ldp-setting-group:not(:has(.ldp-range-drag-active,.ldp-color-pick-active)),
- .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-setting-group:has(.ldp-range-drag-active,.ldp-color-pick-active) > :not(.ldp-range-drag-active):not(.ldp-color-pick-active){opacity:.75;}
- .ldp-settings-tabs{display:flex;min-height:220px;height:100%;flex-direction:column;padding:0 12px 12px;background:var(--primary-very-low,#f7f7f7);
- border-right:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);box-sizing:border-box;}
- .ldp-settings-brand{display:flex;height:var(--ldp-settings-frozen-height);flex:none;box-sizing:border-box;
- align-items:center;justify-content:center;gap:6px;margin:0 -12px;padding:0 4px;
- border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);color:var(--primary-high,#333);}
- .ldp-settings-brand-logo{display:block;width:44px;height:44px;flex:none;border-radius:50%;}
- .ldp-settings-brand-name{display:grid;justify-items:start;color:color-mix(in srgb,var(--tertiary,#47855f) 68%,var(--primary,#222));
- font-family:inherit;font-style:normal;font-weight:750;letter-spacing:-.025em;font-kerning:normal;text-align:left;white-space:nowrap;}
- .ldp-settings-brand-name > span:first-child{font-size:var(--ldp-font-lg,15px);line-height:1.05;}
- .ldp-settings-brand-name > span:not(:first-child){font-size:var(--ldp-font-ui,12px);line-height:1.05;}
- .ldp-settings-nav-shell{position:relative;display:flex;min-height:0;flex:1;}
- .ldp-settings-nav{display:flex;width:100%;min-height:0;flex:1;flex-direction:column;padding:5px 3px 5px 0;
- overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--primary-low,#ddd) transparent;}
- .ldp-settings-nav-group{display:grid;gap:1px;padding:8px 0 5px;border-top:1px solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-settings-nav-group-label{padding:0 10px 4px;color:var(--primary-medium,#777);font-size:var(--ldp-font-micro,9px);
- font-weight:750;letter-spacing:.08em;line-height:1.2;}
- .ldp-settings-tab{display:flex;width:100%;align-items:center;gap:9px;border:none;background:transparent;color:inherit;cursor:default;
- border-radius:8px;padding:9px 10px;text-align:left;font-family:inherit;
- font-size:var(--ldp-font-base,13px);font-weight:650;line-height:1.25;}
- .ldp-settings-tab-draft-count{display:inline-flex;min-width:18px;height:18px;align-items:center;justify-content:center;
- box-sizing:border-box;margin-left:auto;padding:0 5px;border-radius:9px;background:var(--tertiary,#47855f);color:var(--secondary,#fff);
- font-size:var(--ldp-font-micro,9px);font-weight:750;line-height:1;}
- .ldp-settings-tab-draft-count[hidden]{display:none;}
- .ldp-settings-tab .ldp-icon{width:16px;height:16px;flex:none;color:var(--primary-medium,#777);}
- .ldp-settings-tab.active .ldp-icon{color:var(--tertiary,#47855f);}
- .ldp-settings-tab.active{background:var(--tertiary-low,#dceee2);color:var(--tertiary,#47855f);
- box-shadow:0 1px 4px rgba(0,0,0,.08);}
- .ldp-settings-tab[data-settings-panel="user"]{position:relative;margin-bottom:10px;}
- .ldp-settings-footer{margin-top:auto;padding-top:10px;
- border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-settings-theme{display:grid;grid-template-columns:repeat(3,30px);justify-content:center;gap:4px;}
- .ldp-settings-theme-button{display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;
- box-sizing:border-box;padding:6px;border:1px solid transparent;border-radius:6px;background:transparent;
- color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-settings-theme-button:hover{background:var(--secondary,#fff);color:var(--primary,#222);}
- .ldp-settings-theme-button.active{border-color:color-mix(in srgb,var(--tertiary,#47855f) 45%,transparent);
- background:var(--secondary,#fff);color:var(--tertiary,#47855f);}
- .ldp-settings-panel{min-width:0;box-sizing:border-box;padding:0 20px 18px;
- height:100%;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:none;-ms-overflow-style:none;}
- .ldp-settings-panel:has(> .ldp-settings-section[data-settings-panel="user"]:not([hidden])){overflow-y:hidden;}
- .ldp-settings-panel::-webkit-scrollbar{display:none;}
- .ldp-settings-search-shell{position:sticky;top:0;z-index:3;display:grid;grid-template-columns:minmax(0,1fr) auto;
- align-items:center;gap:5px 12px;min-height:58px;box-sizing:border-box;margin:0 -20px;padding:10px 58px 9px 20px;
- background:var(--secondary,#fff);cursor:grab;touch-action:none;user-select:none;}
- .ldp-settings-search-shell[hidden],.ldp-settings-search-empty[hidden],.ldp-settings-log-panel[hidden],.ldp-settings-draft-bar[hidden]{display:none;}
- .ldp-settings-search{display:grid;grid-template-columns:18px minmax(0,1fr) 28px;align-items:center;min-width:0;height:36px;
- box-sizing:border-box;padding:0 4px 0 10px;border:1px solid var(--primary-low,#ddd);border-radius:9px;
- background:var(--primary-very-low,#f7f7f7);color:var(--primary-medium,#777);cursor:text;touch-action:auto;user-select:auto;}
- .ldp-settings-search:focus-within{border-color:var(--tertiary,#47855f);box-shadow:0 0 0 3px color-mix(in srgb,var(--tertiary,#47855f) 14%,transparent);}
- .ldp-settings-search-input{min-width:0;height:32px!important;padding-inline:7px!important;border:0!important;outline:0;background:transparent!important;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);}
- .ldp-settings-search-clear{display:inline-flex;width:28px;height:28px;align-items:center;justify-content:center;padding:0;border:0;border-radius:6px;
- background:transparent;color:inherit;cursor:pointer;}
- .ldp-settings-search-status{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);white-space:nowrap;}
- .ldp-settings-search-empty{display:grid;justify-items:center;gap:5px;padding:42px 20px;color:var(--primary-medium,#777);text-align:center;}
- .ldp-settings-search-empty .ldp-icon{width:24px;height:24px;}
- .ldp-settings-search-empty strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);}
- .ldp-settings-search-empty span{font-size:var(--ldp-font-sm,11px);}
- .ldp-settings-intro{position:sticky;top:0;z-index:1;align-self:start;display:grid;gap:4px;
- min-height:var(--ldp-settings-frozen-height);box-sizing:border-box;
- margin:0 -20px;padding:18px 58px 14px 20px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
- background:var(--secondary,#fff);cursor:grab;touch-action:none;user-select:none;}
- .ldp-settings-panel.is-settings-pages .ldp-settings-intro{position:relative;top:auto;min-height:0;margin:0;padding:26px 0 12px;
- border:0;background:transparent;cursor:default;touch-action:auto;user-select:text;}
- .ldp-settings-panel.is-settings-pages .ldp-settings-intro::before{display:none;}
- .ldp-settings-draft-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;justify-content:space-between;gap:12px;
- margin:0 -20px;padding:11px 20px;background:color-mix(in srgb,var(--secondary,#fff) 94%,transparent);box-shadow:0 -10px 24px rgba(0,0,0,.08);backdrop-filter:blur(12px);}
- .ldp-settings-draft-status{min-width:0;overflow:hidden;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);text-overflow:ellipsis;white-space:nowrap;}
- .ldp-settings-save-all{display:inline-flex;align-items:center;gap:6px;flex:none;padding:8px 11px;border:0;border-radius:7px;
- background:var(--tertiary,#47855f);color:#fff;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;cursor:pointer;}
- .ldp-settings-save-all:disabled{opacity:.38;cursor:default;}
- .ldp-settings-intro::before{content:"";position:absolute;top:7px;left:calc(50% - var(--ldp-settings-sidebar-width) / 2);width:28px;height:3px;
- border-radius:999px;background:var(--primary-low,#ddd);transform:translateX(-50%);}
- .ldp-settings-popover.ldp-settings-window-dragging :is(.ldp-settings-intro,.ldp-settings-search-shell){cursor:grabbing;}
- .ldp-settings-popover.ldp-settings-window-dragging .ldp-settings-intro::before{background:var(--tertiary,#47855f);}
- .ldp-settings-title{margin:0;font-size:var(--ldp-font-xl,16px);font-weight:700;line-height:1.3;color:var(--primary,#222);}
- .ldp-settings-description,.ldp-reader-action-intro{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);line-height:1.5;}
- .ldp-settings-panel .ldp-settings-section[data-settings-panel="user"]{gap:0;}
- .ldp-settings-section[data-settings-panel="user"] .ldp-settings-intro{padding-right:100px;}
- .ldp-user-info-last-refresh{position:absolute;right:20px;bottom:8px;color:var(--primary-medium,#777);
- font-size:var(--ldp-font-micro,9px);font-variant-numeric:tabular-nums;line-height:1;white-space:nowrap;}
- .ldp-user-info-title-refresh{position:absolute;right:52px;top:12px;display:inline-flex;width:32px;height:32px;
- align-items:center;justify-content:center;padding:0;border:0;border-radius:7px;
- background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-user-info-title-refresh:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
- .ldp-user-info-title-refresh:disabled{opacity:.62;cursor:wait;}
- .ldp-user-info-title-refresh.is-refreshing .ldp-icon{animation:ldp-spin .9s linear infinite;}
- .ldp-user-info-content{display:grid;gap:4px;padding:0 0 2px;}
- .ldp-user-info-tabs{display:flex;align-items:center;gap:18px;padding:0 2px;border-bottom:0;background:transparent;}
- .ldp-user-info-tab{position:relative;display:inline-flex;min-width:0;height:25px;align-items:center;justify-content:center;gap:5px;
- box-sizing:border-box;padding:0 3px;border:0;border-radius:0;background:transparent;color:var(--primary-medium,#777);
- font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;}
- .ldp-user-info-tab .ldp-icon{width:14px;height:14px;flex:none;}
- .ldp-user-info-tab:hover{color:var(--primary,#222);}
- .ldp-user-info-tab.active{color:var(--tertiary,#47855f);}
- .ldp-user-info-tab.active::after{content:"";position:absolute;left:2px;right:2px;bottom:-1px;height:2px;
- border-radius:2px 2px 0 0;background:var(--tertiary,#47855f);}
- .ldp-user-info-view{padding-top:14px;}
- .ldp-user-info-loading,.ldp-user-info-error{display:grid;min-height:150px;place-items:center;padding:14px;
- border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--primary-very-low,#f7f7f7);
- color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);text-align:center;}
- .ldp-user-info-profile{position:relative;overflow:hidden;border:1px solid var(--primary-low,#ddd);border-radius:11px;
- background:var(--secondary,#fff);}
- .ldp-user-info-cover{height:78px;background:linear-gradient(135deg,
- color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff)),var(--primary-very-low,#f4f4f4));}
- .ldp-user-info-profile-body{position:relative;display:grid;grid-template-columns:60px minmax(0,1fr);gap:9px;
- padding:0 13px 10px;}
- .ldp-user-info-avatar{display:grid;width:54px;height:54px;place-items:center;margin-top:-22px;border:3px solid var(--secondary,#fff);
- border-radius:50%;background:var(--primary-low,#ddd);box-shadow:0 2px 8px rgba(0,0,0,.14);font-size:var(--ldp-font-xl,16px);font-weight:750;}
- .ldp-user-info-avatar :is(img,.ldp-persistent-avatar-fallback){width:100%;height:100%;border-radius:50%;object-fit:cover;}
- .ldp-user-info-avatar .ldp-avatar-with-flair{width:100%;height:100%;}
- .ldp-user-info-avatar .ldp-avatar-flair{right:-3px;bottom:-2px;width:18px;height:18px;border-width:2px;}
- .ldp-user-info-identity{min-width:0;padding-top:6px;}
- .ldp-user-info-name-row{display:flex;min-width:0;align-items:center;gap:6px;}
- .ldp-user-info-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--primary,#222);
- font-size:var(--ldp-font-2xl,17px);font-weight:750;line-height:1.25;}
- .ldp-user-info-level{flex:none;padding:2px 7px;border-radius:999px;background:var(--tertiary-low,#dceee2);
- color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;}
- .ldp-user-info-username{margin-top:1px;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);}
- .ldp-user-info-title{display:flex;align-items:center;gap:5px;margin-top:2px;color:var(--primary-high,#333);
- font-size:var(--ldp-font-ui,12px);}
- .ldp-user-info-title > .ldp-avatar-flair{position:static;width:14px;height:14px;flex:none;border-width:1px;}
- .ldp-user-info-site{display:inline-flex;max-width:100%;align-items:center;gap:5px;margin-top:3px;color:var(--tertiary,#47855f);
- font-size:var(--ldp-font-ui,12px);font-weight:650;text-decoration:none;}
- .ldp-user-info-site .ldp-icon{width:13px;height:13px;flex:none;}
- .ldp-user-info-site span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-user-info-bio{grid-column:1/-1;margin-top:0;color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);line-height:1.4;}
- .ldp-user-profile-facts{display:flex;align-items:center;flex-wrap:wrap;}
- .ldp-user-profile-fact{display:inline-flex;min-width:0;align-items:baseline;gap:4px;}
- .ldp-user-profile-fact-label{flex:none;color:var(--primary-medium,#777);}
- .ldp-user-profile-fact-value{min-width:0;color:var(--primary,#222);overflow-wrap:anywhere;}
- .ldp-user-profile-fact.is-accent .ldp-user-profile-fact-value{color:var(--tertiary,#47855f);}
- .ldp-user-profile-facts.is-settings{grid-column:1/-1;gap:4px 12px;padding-top:7px;
- border-top:1px solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-sm,11px);line-height:1.35;}
- .ldp-user-profile-facts.is-settings .is-wide{flex-basis:100%;}
- .ldp-connect-card{display:grid;gap:6px;padding:8px;border:1px solid var(--primary-low,#ddd);border-radius:11px;
- background:var(--secondary,#fff);}
- .ldp-connect-head{display:flex;align-items:flex-start;justify-content:space-between;gap:9px;}
- .ldp-connect-heading{display:grid;gap:2px;}
- .ldp-connect-heading strong{color:var(--primary,#222);font-size:var(--ldp-font-base,13px);line-height:1.35;}
- .ldp-connect-heading small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.4;}
- .ldp-connect-status{flex:none;padding:3px 7px;border-radius:999px;background:var(--tertiary-low,#dceee2);
- color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;}
- .ldp-connect-status.is-unmet{background:var(--danger-low,#f7dddd);color:var(--danger,#c84545);}
- .ldp-connect-rings{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px;}
- .ldp-connect-ring{display:grid;place-items:center;gap:2px;min-width:0;text-align:center;}
- .ldp-connect-ring-visual{display:grid;width:50px;height:50px;place-items:center;border-radius:50%;
- background:conic-gradient(var(--ldp-connect-color,var(--tertiary,#47855f)) var(--ldp-connect-progress,0%),var(--primary-low,#ddd) 0);}
- .ldp-connect-ring-visual::before{content:"";grid-area:1/1;width:40px;height:40px;border-radius:50%;background:var(--secondary,#fff);}
- .ldp-connect-ring-value{z-index:1;grid-area:1/1;display:grid;color:var(--primary,#222);font-size:var(--ldp-font-sm,11px);
- font-weight:400;font-variant-numeric:tabular-nums;line-height:1.2;}
- .ldp-connect-ring-value small{color:var(--primary-medium,#777);font-size:var(--ldp-font-micro,9px);font-weight:400;}
- .ldp-connect-ring-label{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
- color:var(--primary-high,#333);font-size:var(--ldp-font-sm,11px);font-weight:400;}
- .ldp-connect-metric{cursor:help;}
- .ldp-connect-detail-groups{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;align-items:start;}
- .ldp-connect-group{display:grid;gap:4px;}
- .ldp-connect-group-title{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-weight:400;}
- .ldp-connect-bars,.ldp-connect-quotas{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px;}
- .ldp-connect-detail-groups .ldp-connect-bars,
- .ldp-connect-detail-groups .ldp-connect-quotas{grid-template-columns:minmax(0,1fr);}
- .ldp-connect-bar,.ldp-connect-quota{display:grid;gap:2px;padding:5px;border:1px solid var(--primary-low,#ddd);border-radius:8px;}
- .ldp-connect-bar.is-short{border-color:color-mix(in srgb,var(--danger,#c84545) 34%,var(--primary-low,#ddd));
- background:color-mix(in srgb,var(--danger-low,#f7dddd) 24%,var(--secondary,#fff));}
- .ldp-connect-bar-copy{display:flex;align-items:baseline;justify-content:space-between;gap:10px;
- font-size:var(--ldp-font-sm,11px);line-height:1.35;}
- .ldp-connect-bar-copy span{color:var(--primary-high,#333);font-weight:400;}
- .ldp-connect-bar-copy strong,.ldp-connect-quota-copy strong{color:var(--ldp-connect-color,var(--tertiary,#47855f));font-weight:400;font-variant-numeric:tabular-nums;}
- .ldp-connect-bar-track{height:4px;overflow:hidden;border-radius:999px;background:var(--primary-low,#ddd);}
- .ldp-connect-bar-fill{position:relative;display:block;width:var(--ldp-connect-progress,0%);max-width:100%;height:100%;border-radius:inherit;
- background:var(--ldp-connect-color,var(--tertiary,#47855f));}
- .ldp-connect-bar.is-goal{--ldp-connect-sheen-opacity:.24;--ldp-connect-sheen-duration:5.2s;}
- .ldp-connect-bar.is-goal .ldp-connect-bar-fill{overflow:hidden;}
- .ldp-connect-bar.is-goal .ldp-connect-bar-fill::after{content:"";position:absolute;inset:-1px auto -1px -45%;width:38%;
- border-radius:inherit;background:linear-gradient(90deg,transparent,rgba(255,255,255,var(--ldp-connect-sheen-opacity)),transparent);
- transform:skewX(-18deg);animation:ldp-connect-goal-sheen var(--ldp-connect-sheen-duration) cubic-bezier(.22,.61,.36,1) infinite;}
- .ldp-connect-bar.is-over{--ldp-connect-sheen-opacity:.3;--ldp-connect-sheen-duration:4.6s;}
- .ldp-connect-bar.is-over-high{--ldp-connect-sheen-opacity:.36;--ldp-connect-sheen-duration:4s;}
- .ldp-connect-bar.is-over-ultra{--ldp-connect-sheen-opacity:.42;--ldp-connect-sheen-duration:3.5s;}
- .ldp-connect-bar.is-over-epic{--ldp-connect-sheen-opacity:.48;--ldp-connect-sheen-duration:3s;}
- .ldp-connect-bar.is-over-high .ldp-connect-bar-track{box-shadow:0 0 0 1px color-mix(in srgb,var(--ldp-connect-color) 14%,transparent);}
- .ldp-connect-bar.is-over-ultra .ldp-connect-bar-track{box-shadow:0 0 0 1px color-mix(in srgb,var(--ldp-connect-color) 20%,transparent),0 1px 4px color-mix(in srgb,var(--ldp-connect-color) 14%,transparent);}
- .ldp-connect-bar.is-over-epic .ldp-connect-bar-track{box-shadow:0 0 0 1px color-mix(in srgb,var(--ldp-connect-color) 26%,transparent),0 1px 5px color-mix(in srgb,var(--ldp-connect-color) 18%,transparent);}
- @keyframes ldp-connect-goal-sheen{
- 0%,18%{left:-45%;opacity:0;}
- 24%{opacity:1;}
- 48%{left:112%;opacity:1;}
- 54%,100%{left:112%;opacity:0;}}
- .ldp-connect-quota-copy{display:flex;align-items:baseline;justify-content:space-between;gap:8px;
- color:var(--primary-high,#333);font-size:var(--ldp-font-sm,11px);font-weight:400;}
- .ldp-connect-error{display:flex;align-items:center;justify-content:space-between;gap:9px;padding:8px;border-radius:9px;
- background:var(--primary-very-low,#f7f7f7);color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
- @container ldp-reader (max-width:480px){
- .ldp-connect-rings{grid-template-columns:repeat(2,minmax(0,1fr));}
- .ldp-connect-detail-groups,
- .ldp-connect-bars,.ldp-connect-quotas{grid-template-columns:minmax(0,1fr);}
- .ldp-user-info-profile-body{grid-template-columns:56px minmax(0,1fr);padding-inline:12px;}
- .ldp-user-info-avatar{width:50px;height:50px;}
- }
- @media (prefers-reduced-motion:reduce){
- .ldp-connect-bar.is-goal .ldp-connect-bar-fill::after{animation:none;display:none;}}
- .ldp-about-content{display:grid;gap:18px;padding:20px 0 4px;}
- .ldp-about-hero{display:flex;align-items:center;gap:12px;padding-bottom:18px;
- border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-about-logo{display:block;width:50px;height:50px;flex:none;border-radius:50%;}
- .ldp-about-identity{display:grid;min-width:0;gap:3px;}
- .ldp-about-name{margin:0;color:var(--primary,#222);font-size:var(--ldp-font-xl,16px);font-weight:750;line-height:1.2;}
- .ldp-about-tagline{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);line-height:1.45;}
- .ldp-about-version{display:inline-flex;flex:none;align-self:flex-start;padding:4px 7px;border-radius:999px;
- background:var(--primary-very-low,#f2f4f6);color:var(--primary-high,#333);
- font-size:var(--ldp-font-sm,11px);font-weight:650;font-variant-numeric:tabular-nums;line-height:1;}
- .ldp-about-links{display:grid;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-about-link{display:grid;grid-template-columns:32px minmax(0,1fr) 16px;gap:10px;align-items:center;padding:11px 0;
- color:inherit;text-decoration:none;}
- .ldp-about-link:hover .ldp-about-link-copy strong,.ldp-about-link:focus-visible .ldp-about-link-copy strong{
- color:var(--tertiary,#47855f);text-decoration:underline;}
- .ldp-about-link:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:3px;border-radius:4px;}
- .ldp-about-link-icon{display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center;
- border-radius:9px;background:var(--tertiary-low,#dceee2);color:var(--tertiary,#47855f);}
- .ldp-about-link-copy{display:grid;min-width:0;gap:2px;}
- .ldp-about-link-copy strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);line-height:1.35;}
- .ldp-about-link-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
- .ldp-about-link > .ldp-icon{width:16px;height:16px;color:var(--primary-medium,#777);}
- .ldp-about-link-icon .ldp-icon{width:16px;height:16px;}
- .ldp-about-features{display:grid;}
- .ldp-about-feature{display:grid;grid-template-columns:32px minmax(0,1fr);gap:10px;align-items:start;padding:11px 0;
- border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-about-feature-icon{display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center;
- border-radius:9px;background:var(--tertiary-low,#dceee2);color:var(--tertiary,#47855f);}
- .ldp-about-feature-copy{display:grid;gap:2px;}
- .ldp-about-credits{display:grid;gap:4px;padding:12px;border:1px solid var(--primary-low,#ddd);border-radius:9px;
- background:var(--primary-very-low,#f7f7f7);}
- .ldp-about-credits a{color:var(--tertiary,#47855f);font-weight:650;text-decoration:none;}
- .ldp-about-credits a:hover{text-decoration:underline;}
- .ldp-settings-category-groups{display:grid;gap:14px;}
- .ldp-settings-category-list{display:grid;border-top:0;}
- .ldp-settings-category-list > .ldp-setting-row{padding:11px 0;border-bottom:0;}
- .ldp-settings-category-content{display:grid;gap:10px;padding:10px 0;}
- .ldp-color-groups{display:grid;gap:14px;}
- .ldp-settings-category-group,.ldp-color-group{display:grid;min-width:0;gap:0;padding:0 14px;border:1px solid var(--primary-low,#ddd);
- border-radius:10px;background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 34%,var(--secondary,#fff));}
- .ldp-color-group-head{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:2px 12px;padding:12px 0 10px;
- border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
- transition:opacity .12s ease-out;}
- .ldp-color-group-head-copy{display:grid;gap:2px;min-width:0;}
- .ldp-color-group-head-actions{display:flex;align-items:center;gap:4px;}
- .ldp-color-group.is-disabled > .ldp-settings-fields{opacity:.42;}
- .ldp-color-group-title{margin:0;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);font-weight:700;line-height:1.35;}
- .ldp-color-group-description{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
- .ldp-settings-fields{display:grid;gap:0;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-setting-row{display:grid;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:16px;
- margin:0;padding:13px 0;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-base,13px);}
- .ldp-color-group > .ldp-settings-fields > .ldp-setting-row{grid-template-columns:136px minmax(0,1fr);}
- .ldp-setting-label{font-weight:650;white-space:nowrap;color:var(--primary-high,#333);}
- .ldp-setting-group{min-width:0;padding:10px 0 4px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-setting-group-title{padding:0 0 2px;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);font-weight:700;line-height:1.35;}
- .ldp-setting-group > .ldp-setting-row{padding:9px 0 9px 12px;border-bottom:0;}
- .ldp-setting-group > .ldp-setting-row + .ldp-setting-row{border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 62%,transparent);}
- .ldp-setting-group .ldp-setting-label{color:var(--primary-medium,#777);font-weight:600;}
- .ldp-font-scope-group{padding-top:0;}
- .ldp-settings-category-content > .ldp-settings-fields,.ldp-color-group > .ldp-settings-fields,.ldp-font-settings-fields{border-top:0;}
- .ldp-about-feature:last-child,.ldp-settings-category-content > .ldp-settings-fields > :last-child,
- .ldp-color-group > .ldp-settings-fields > :is(.ldp-setting-row,.ldp-setting-group):last-child,
- .ldp-settings-category-content .ldp-font-scope-group:not([hidden]){border-bottom:0;}
- .ldp-setting-help-tooltip{position:absolute;z-index:2147483646;box-sizing:border-box;width:max-content;max-width:min(380px,calc(100% - 24px));
- pointer-events:none;opacity:0;transform:translateY(3px);
- transition:opacity .12s ease,transform .12s ease;}
- .ldp-setting-help-tooltip.is-visible{opacity:1;transform:translateY(0);}
- .ldp-settings-section:not([hidden]){display:grid;gap:18px;align-content:start;}
- .ldp-motion-choice-row{grid-template-columns:88px minmax(0,240px);justify-content:space-between;border-bottom:0;}
- .ldp-loading-animation-select{width:100%;}
- .ldp-loading-settings-preview{display:grid;gap:10px;padding-top:2px;}
- .ldp-loading-settings-preview-head{display:flex;align-items:center;justify-content:space-between;gap:12px;}
- .ldp-loading-settings-preview-head > span{display:flex;min-width:0;align-items:baseline;gap:8px;}
- .ldp-loading-settings-preview-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);}
- .ldp-loading-settings-preview-head small{overflow:hidden;color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);text-overflow:ellipsis;white-space:nowrap;}
- .ldp-loading-preview-reroll{flex:none;border:1px solid var(--primary-low,#ddd);border-radius:7px;padding:6px 9px;background:var(--secondary,#fff);
- color:var(--primary-high,#333);font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;}
- .ldp-loading-preview-reroll:hover{border-color:color-mix(in srgb,var(--tertiary,#47855f) 52%,var(--primary-low,#ddd));background:var(--primary-very-low,#f4f4f4);}
- .ldp-loading-preview-stage{display:grid;min-height:184px;place-items:center;overflow:hidden;}
- .ldp-loading-preview-stage .ldp-loading-visual{width:min(304px,100%);height:184px;max-width:100%;}
- .ldp-loading-preview-stage .ldp-loading-visual-inner{transform:scale(.84);}
- .ldp-loading-settings-note{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.5;}
- .ldp-performance-presets{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;}
- .ldp-performance-preset{min-width:0;border:1px solid var(--primary-low,#ddd);border-radius:7px;
- padding:8px 5px;background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;}
- .ldp-performance-preset:hover{background:var(--primary-very-low,#f4f4f4);}
- .ldp-performance-fields .ldp-setting-row{grid-template-columns:minmax(0,1fr) 116px;gap:14px;padding:10px 0;}
- .ldp-performance-copy{min-width:0;display:grid;gap:2px;}
- .ldp-performance-copy strong{font-size:var(--ldp-font-base,13px);line-height:1.3;}
- .ldp-performance-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.35;}
- .ldp-performance-control,.ldp-reader-window-input-wrap{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:6px;}
- .ldp-performance-control input{box-sizing:border-box;width:100%;height:34px;border:1px solid var(--primary-low,#ccc);
- border-radius:7px;background:var(--secondary,#fff);color:inherit;padding:5px 8px;text-align:right;font:inherit;
- font-variant-numeric:tabular-nums;}
- .ldp-performance-control em{min-width:24px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-style:normal;white-space:nowrap;}
- .ldp-performance-footer,.ldp-layout-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;}
- :is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset){display:inline-flex;align-items:center;gap:6px;flex:none;border:1px solid var(--primary-low,#ddd);
- border-radius:7px;padding:7px 9px;background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;}
- :is(.ldp-performance-reset,.ldp-layout-reset):hover:not(:disabled){background:var(--primary-very-low,#f4f4f4);}
- .ldp-reader-window-reset:hover:not(:disabled){background:var(--secondary,#fff);}
- :is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset):disabled{opacity:.38;cursor:default;}
- .ldp-settings-log-tabs{display:inline-flex;justify-self:start;gap:4px;padding:4px;border-radius:10px;
- background:var(--primary-very-low,#f4f4f4);}
- .ldp-settings-log-tab{display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:6px 12px;border:0;border-radius:7px;
- background:transparent;color:var(--primary-medium,#777);font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;}
- .ldp-settings-log-tab.active{background:var(--secondary,#fff);color:var(--tertiary,#47855f);box-shadow:0 1px 4px rgba(0,0,0,.1);}
- .ldp-settings-log-panel:not([hidden]){display:grid;gap:16px;}
- :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck){display:grid;grid-template-columns:auto minmax(0,1fr);gap:5px 10px;align-items:start;
- padding:10px;border:1px solid var(--primary-low,#ddd);border-radius:8px;background:var(--primary-very-low,#f7f7f7);}
- .ldp-request-flow-bottleneck{gap:8px 10px;}
- :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck) strong{padding:2px 7px;border-radius:999px;background:var(--success-low,#dceee2);
- color:var(--success,#47855f);font-size:var(--ldp-font-sm,11px);line-height:1.35;white-space:nowrap;}
- :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck)[data-level="warning"] strong{background:var(--highlight-low,#fff8d8);color:var(--highlight-high,#806200);}
- :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck)[data-level="danger"] strong{background:var(--danger-low,#f7dddd);color:var(--danger,#c84545);}
- :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck) p{margin:0;color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);line-height:1.45;}
- .ldp-resource-monitor-updated{grid-column:2;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);
- font-variant-numeric:tabular-nums;line-height:1.35;}
- .ldp-resource-monitor-table{--ldp-resource-monitor-columns:minmax(116px,1fr) minmax(108px,1.05fr) repeat(3,minmax(52px,.64fr));
- min-width:0;overflow:hidden;border:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
- border-radius:10px;background:var(--secondary,#fff);}
- .ldp-resource-monitor-table-head,.ldp-resource-monitor-row{display:grid;
- grid-template-columns:var(--ldp-resource-monitor-columns);column-gap:8px;align-items:center;box-sizing:border-box;}
- .ldp-resource-monitor-table-head{min-height:34px;padding:8px 12px;
- border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);
- background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff));
- color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:650;line-height:1.2;text-align:right;}
- .ldp-resource-monitor-table-head span:first-child{text-align:left;}
- .ldp-resource-monitor-table-head span:nth-child(2){color:var(--primary-high,#333);font-weight:700;}
- .ldp-resource-monitor-row{min-height:54px;padding:10px 12px;}
- .ldp-resource-monitor-row + .ldp-resource-monitor-row{border-top:var(--ldp-divider-line-width,1px) solid
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);}
- .ldp-resource-monitor-copy{display:grid;min-width:0;gap:2px;}
- .ldp-resource-monitor-copy strong{color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);font-weight:650;line-height:1.3;}
- .ldp-resource-monitor-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.3;}
- .ldp-resource-monitor-current{color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);font-weight:750;
- font-variant-numeric:tabular-nums;line-height:1.25;text-align:right;white-space:nowrap;}
- .ldp-resource-monitor-summary{display:contents;}
- .ldp-resource-monitor-summary span{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);
- font-variant-numeric:tabular-nums;line-height:1.25;text-align:right;white-space:nowrap;}
- .ldp-resource-monitor-summary span:last-child{color:var(--primary-high,#333);font-weight:650;}
- .ldp-resource-monitor-trend-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;}
- .ldp-resource-monitor-trend-list{border-block:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-resource-monitor-trend-row{display:grid;grid-template-columns:92px 76px minmax(120px,1fr);gap:8px;align-items:center;
- min-height:48px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-resource-monitor-trend-row:first-child{border-top:0;}
- .ldp-resource-monitor-trend-row > strong{color:var(--primary-high,#333);font-size:var(--ldp-font-xs,10px);
- font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap;}
- .ldp-resource-monitor-trend-row svg{display:block;width:100%;height:34px;overflow:visible;}
- .ldp-resource-monitor-trend-row polyline{fill:none;stroke:var(--tertiary,#47855f);stroke-width:2;stroke-linecap:round;
- stroke-linejoin:round;vector-effect:non-scaling-stroke;}
- .ldp-resource-monitor-trend-row[data-resource-monitor-chart="dom"] polyline{stroke:var(--ldp-request-metric-navy,#365673);}
- .ldp-resource-monitor-trend-row[data-resource-monitor-chart="retainedFloors"] polyline{stroke:var(--ldp-request-metric-bronze,#72551f);}
- .ldp-resource-monitor-evidence-head span,.ldp-resource-monitor-events-head span{color:var(--primary-medium,#777);
- font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;text-align:right;}
- .ldp-resource-monitor-scope-table{width:100%;max-width:100%;min-width:0;overflow:hidden;border:var(--ldp-divider-line-width,1px) solid
- var(--ldp-divider-line-color,#e5e5e5);border-radius:10px;background:var(--secondary,#fff);}
- .ldp-resource-monitor-scope-head,.ldp-resource-monitor-scope-row{display:grid;
- grid-template-columns:minmax(110px,.7fr) minmax(120px,.8fr) repeat(2,minmax(0,1fr));
- column-gap:8px;box-sizing:border-box;padding:8px 10px;}
- .ldp-resource-monitor-scope-head{background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff));
- color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:650;align-items:center;}
- .ldp-resource-monitor-scope-row{min-height:68px;grid-template-rows:auto auto;row-gap:6px;align-items:start;
- border-top:var(--ldp-divider-line-width,1px) solid
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);}
- .ldp-resource-monitor-scope-row > *{min-width:0;}
- .ldp-resource-monitor-scope-row strong{grid-row:1 / 3;align-self:center;color:var(--primary-high,#333);
- font-size:var(--ldp-font-sm,11px);line-height:1.3;}
- .ldp-resource-monitor-scope-row span,.ldp-resource-monitor-scope-row small{color:var(--primary-medium,#777);
- font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;line-height:1.35;overflow-wrap:anywhere;}
- .ldp-resource-monitor-scope-row [data-resource-monitor-scope-basis]{grid-column:2 / -1;grid-row:2;
- padding-top:5px;border-top:var(--ldp-divider-line-width,1px) solid
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 62%,transparent);}
- .ldp-resource-monitor-scope-row [data-resource-monitor-scope-basis]::before{content:"依据 · ";
- color:var(--primary-high,#333);font-weight:650;}
- .ldp-resource-monitor-event-log{max-height:280px;overflow:auto;border-block:var(--ldp-divider-line-width,1px) solid
- var(--ldp-divider-line-color,#e5e5e5);scrollbar-gutter:stable;}
- .ldp-resource-monitor-event-row{display:grid;grid-template-columns:82px 34px 76px minmax(0,1fr) 104px;gap:8px;
- align-items:start;min-height:34px;box-sizing:border-box;padding:7px 0;border-top:var(--ldp-divider-line-width,1px) solid
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 68%,transparent);font-size:var(--ldp-font-xs,10px);line-height:1.35;}
- .ldp-resource-monitor-event-row:first-child{border-top:0;}
- .ldp-resource-monitor-event-row time{color:var(--primary-medium,#777);font-variant-numeric:tabular-nums;white-space:nowrap;}
- .ldp-resource-monitor-event-state{font-weight:700;white-space:nowrap;}
- .ldp-resource-monitor-scope-row [data-resource-monitor-scope-visible],
- .ldp-resource-monitor-event-state[data-visibility="visible"]{color:var(--tertiary,#47855f);}
- .ldp-resource-monitor-scope-row [data-resource-monitor-scope-hidden],
- .ldp-resource-monitor-event-state[data-visibility="hidden"]{color:var(--ldp-request-metric-navy,#365673);}
- .ldp-resource-monitor-event-scope{color:var(--primary-high,#333);font-weight:650;}
- .ldp-resource-monitor-event-detail{min-width:0;color:var(--primary-high,#333);overflow-wrap:anywhere;}
- .ldp-resource-monitor-event-basis{color:var(--primary-medium,#777);overflow-wrap:anywhere;}
- .ldp-resource-monitor-event-empty{padding:14px 0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
- .ldp-resource-monitor-boundary{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);line-height:1.5;}
- @container ldp-reader (max-width:700px){
- .ldp-resource-monitor-table{border-radius:9px;}
- .ldp-resource-monitor-table-head{display:none;}
- .ldp-resource-monitor-row{grid-template-columns:minmax(0,1fr) auto;gap:7px 12px;min-height:0;padding:12px;}
- .ldp-resource-monitor-summary{grid-column:1 / -1;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0;
- margin-top:2px;padding-top:8px;border-top:var(--ldp-divider-line-width,1px) solid
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 68%,transparent);}
- .ldp-resource-monitor-summary span{padding-right:7px;text-align:left;}
- .ldp-resource-monitor-summary span + span{padding-left:8px;border-left:var(--ldp-divider-line-width,1px) solid
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 68%,transparent);}
- .ldp-resource-monitor-summary span::before{color:var(--primary-medium,#777);font-weight:400;}
- .ldp-resource-monitor-summary span:nth-child(1)::before{content:"均 ";}
- .ldp-resource-monitor-summary span:nth-child(2)::before{content:"峰 ";}
- .ldp-resource-monitor-summary span:nth-child(3)::before{content:"变 ";}
- .ldp-resource-monitor-trend-row{grid-template-columns:72px 64px minmax(84px,1fr);}
- .ldp-resource-monitor-scope-head{display:none;}
- .ldp-resource-monitor-scope-row{grid-template-columns:84px repeat(2,minmax(0,1fr));gap:6px 9px;padding:10px;}
- .ldp-resource-monitor-scope-row strong{grid-row:1 / 3;}
- .ldp-resource-monitor-scope-row [data-resource-monitor-scope-current]{grid-column:2 / -1;}
- .ldp-resource-monitor-scope-row [data-resource-monitor-scope-basis]{grid-column:1 / -1;grid-row:auto;}
- .ldp-resource-monitor-event-row{grid-template-columns:76px 30px 64px minmax(0,1fr);}
- .ldp-resource-monitor-event-basis{grid-column:4;}}
- .ldp-request-flow-summary{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));overflow:hidden;
- border:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);border-radius:10px;background:var(--secondary,#fff);}
- .ldp-request-flow-metric{display:grid;min-width:0;min-height:52px;box-sizing:border-box;align-content:center;justify-items:center;
- gap:2px;padding:7px 8px;text-align:center;}
- .ldp-request-flow-metric + .ldp-request-flow-metric{border-left:var(--ldp-divider-line-width,1px) solid
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);}
- .ldp-request-flow-metric span{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:550;line-height:1.25;}
- .ldp-request-flow-metric strong{color:var(--primary-high,#333);font-size:var(--ldp-font-sm,11px);
- font-weight:750;font-variant-numeric:tabular-nums;line-height:1.25;overflow-wrap:anywhere;white-space:normal;}
- .ldp-request-flow-metric:nth-child(1) strong{color:var(--ldp-request-metric-pine,#315f47);}
- .ldp-request-flow-metric:nth-child(2) strong{color:var(--ldp-request-metric-navy,#365673);}
- .ldp-request-flow-metric:nth-child(3) strong{color:var(--ldp-request-metric-plum,#5e4b72);}
- .ldp-request-flow-metric:nth-child(4) strong{color:var(--ldp-request-metric-bronze,#72551f);}
- .ldp-request-flow-metric:nth-child(5) strong{color:var(--ldp-request-metric-wine,#743f47);}
- .ldp-resource-monitor-trends,.ldp-resource-monitor-evidence,.ldp-resource-monitor-events,.ldp-request-flow-block{display:grid;gap:8px;}
- .ldp-resource-monitor-trend-head,.ldp-resource-monitor-evidence-head,.ldp-resource-monitor-events-head,
- .ldp-request-flow-block-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;}
- .ldp-resource-monitor-trend-head strong,.ldp-resource-monitor-evidence-head strong,
- .ldp-resource-monitor-events-head strong,.ldp-request-flow-block-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);}
- .ldp-resource-monitor-trend-row > span,.ldp-request-flow-legend span,
- .ldp-request-flow-anomaly-detail small{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);}
- .ldp-request-flow-block-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-variant-numeric:tabular-nums;}
- .ldp-request-flow-chart-block,.ldp-request-flow-types-block,.ldp-request-flow-anomaly-block,.ldp-request-flow-log-block{gap:0;overflow:hidden;
- border:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);border-radius:10px;background:var(--secondary,#fff);}
- .ldp-request-flow-chart-block .ldp-request-flow-block-head,.ldp-request-flow-anomaly-block .ldp-request-flow-block-head,
- .ldp-request-flow-log-block .ldp-request-flow-block-head{
- min-height:34px;box-sizing:border-box;padding:8px 12px;
- border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);
- background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff));}
- .ldp-request-flow-trace{display:grid;gap:5px;margin:8px 12px 0;}
- .ldp-request-flow-trace-lane{display:grid;grid-template-columns:42px minmax(0,1fr);gap:7px;align-items:center;}
- .ldp-request-flow-trace-label{overflow:hidden;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);
- font-weight:650;text-align:right;white-space:nowrap;}
- .ldp-request-flow-trace-track{position:relative;isolation:isolate;height:34px;overflow:hidden;border:1px solid
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 72%,transparent);border-radius:5px;
- background:repeating-linear-gradient(to right,transparent 0,transparent calc(10% - 1px),
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 48%,transparent) calc(10% - 1px),
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 48%,transparent) 10%),
- color-mix(in srgb,var(--primary-very-low,#f7f7f7) 56%,var(--secondary,#fff));}
- .ldp-request-flow-trace-queue,.ldp-request-flow-trace-wire{position:absolute;z-index:1;top:var(--ldp-request-flow-top,3px);
- left:var(--ldp-request-flow-left,0);width:max(2px,var(--ldp-request-flow-width,2px));height:4px;box-sizing:border-box;border-radius:999px;}
- .ldp-request-flow-trace-queue{height:3px;margin-top:1px;border:1px dashed var(--primary-medium,#777);opacity:.68;}
- .ldp-request-flow-trace-wire{background:var(--ldp-request-flow-color,var(--primary-medium,#777));box-shadow:0 0 0 1px
- color-mix(in srgb,var(--ldp-request-flow-color,var(--primary-medium,#777)) 72%,transparent);}
- .ldp-request-flow-trace-wire[data-pending="true"]{background:color-mix(in srgb,var(--ldp-request-flow-color,var(--primary-medium,#777)) 62%,transparent);}
- .ldp-request-flow-trace-permit{position:absolute;z-index:2;top:calc(var(--ldp-request-flow-top,3px) - 1px);
- left:var(--ldp-request-flow-left,0);width:2px;height:7px;transform:translateX(-1px);border-radius:2px;background:var(--primary-high,#333);}
- .ldp-request-flow-trace-anomaly{position:absolute;z-index:3;top:calc(var(--ldp-request-flow-top,3px) - 2px);
- left:var(--ldp-request-flow-left,0);width:10px;height:10px;box-sizing:border-box;transform:translateX(-50%);
- border:2px solid var(--secondary,#fff);border-radius:50%;background:var(--highlight-high,#b38300);
- box-shadow:0 0 0 1px var(--highlight-high,#b38300);}
- .ldp-request-flow-trace-anomaly[data-level="danger"]{background:var(--danger,#c84545);box-shadow:0 0 0 1px var(--danger,#c84545);}
- .ldp-request-flow-axis{display:flex;justify-content:space-between;padding:4px 12px 0;color:var(--primary-medium,#777);
- font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;}
- .ldp-request-flow-axis span:nth-child(2){flex:1;text-align:center;}
- .ldp-request-flow-legend{display:flex;flex-wrap:wrap;gap:5px 10px;padding:7px 12px 10px;}
- .ldp-request-flow-legend span,.ldp-request-flow-type-name{display:inline-flex;align-items:center;gap:5px;min-width:0;}
- .ldp-request-flow-dot{width:7px;height:7px;flex:none;border-radius:50%;background:var(--ldp-request-flow-color,var(--primary-low,#ddd));}
- .ldp-request-flow-queue-key{width:11px;height:5px;box-sizing:border-box;border:1px dashed var(--primary-medium,#777);border-radius:999px;}
- .ldp-request-flow-warning-key,.ldp-request-flow-danger-key{width:8px;height:8px;box-sizing:border-box;border:2px solid var(--secondary,#fff);
- border-radius:50%;background:var(--highlight-high,#b38300);box-shadow:0 0 0 1px var(--highlight-high,#b38300);}
- .ldp-request-flow-danger-key{background:var(--danger,#c84545);box-shadow:0 0 0 1px var(--danger,#c84545);}
- [data-request-flow-type="topic"]{--ldp-request-flow-color:#47855f;}
- [data-request-flow-type="nested"]{--ldp-request-flow-color:#2870b8;}
- [data-request-flow-type="avatar"]{--ldp-request-flow-color:#7a5bb5;}
- [data-request-flow-type="media"]{--ldp-request-flow-color:#b66a26;}
- [data-request-flow-type="user"]{--ldp-request-flow-color:#2d7d78;}
- [data-request-flow-type="bookmark"]{--ldp-request-flow-color:#9a7600;}
- [data-request-flow-type="notification"]{--ldp-request-flow-color:#b94f4f;}
- [data-request-flow-type="realtime"]{--ldp-request-flow-color:#4f708f;}
- [data-request-flow-type="presence"]{--ldp-request-flow-color:#3d7f9d;}
- [data-request-flow-type="reaction"]{--ldp-request-flow-color:#a44f79;}
- [data-request-flow-type="search"]{--ldp-request-flow-color:#66717c;}
- [data-request-flow-type="read"]{--ldp-request-flow-color:#7a6c5d;}
- [data-request-flow-type="asset"]{--ldp-request-flow-color:#596979;}
- [data-request-flow-type="other"]{--ldp-request-flow-color:#8a8a8a;}
- .ldp-request-flow-anomalies{display:grid;max-height:174px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;}
- .ldp-request-flow-anomaly-row{display:grid;grid-template-columns:86px 68px minmax(0,1fr);gap:8px;align-items:center;
- min-height:42px;padding:6px 12px;border-left:3px solid var(--highlight-high,#b38300);border-bottom:var(--ldp-divider-line-width,1px) solid
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 65%,transparent);font-size:var(--ldp-font-xs,10px);}
- .ldp-request-flow-anomaly-row[data-level="danger"]{border-left-color:var(--danger,#c84545);}
- .ldp-request-flow-anomaly-row:last-child{border-bottom:0;}
- .ldp-request-flow-anomaly-time{color:var(--primary-medium,#777);font-variant-numeric:tabular-nums;}
- .ldp-request-flow-anomaly-kind{display:inline-flex;justify-content:center;padding:2px 5px;border-radius:999px;
- background:var(--highlight-low,#fff8d8);color:var(--highlight-high,#806200);font-weight:700;white-space:nowrap;}
- .ldp-request-flow-anomaly-row[data-level="danger"] .ldp-request-flow-anomaly-kind{background:var(--danger-low,#f7dddd);color:var(--danger,#c84545);}
- .ldp-request-flow-anomaly-detail{display:grid;min-width:0;gap:2px;}
- .ldp-request-flow-anomaly-detail strong,.ldp-request-flow-anomaly-detail small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-request-flow-anomaly-detail strong{color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);}
- .ldp-request-flow-type-head,.ldp-request-flow-type-row{display:grid;grid-template-columns:minmax(0,1fr) 62px 72px 44px;gap:8px;}
- .ldp-request-flow-type-head{min-height:34px;box-sizing:border-box;align-items:center;padding:4px 12px;
- border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);
- background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff));}
- .ldp-request-flow-type-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-md,14px);}
- .ldp-request-flow-type-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);
- text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap;}
- .ldp-request-flow-types{display:grid;}
- .ldp-request-flow-type-row{min-height:34px;box-sizing:border-box;align-items:center;padding:4px 12px;font-size:var(--ldp-font-ui,12px);}
- .ldp-request-flow-type-row + .ldp-request-flow-type-row{border-top:var(--ldp-divider-line-width,1px) solid
- color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);}
- .ldp-request-flow-type-row > span:not(.ldp-request-flow-type-name){color:var(--primary-medium,#777);text-align:right;font-variant-numeric:tabular-nums;}
- .ldp-request-flow-type-name strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--ldp-font-ui,12px);}
- .ldp-request-flow-log{max-height:224px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;}
- .ldp-request-flow-log-row{display:grid;grid-template-columns:88px 44px 58px 54px 88px minmax(140px,1fr);gap:7px;align-items:center;
- min-height:34px;padding:5px 12px;border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 65%,transparent);
- color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;}
- .ldp-request-flow-log-row[data-level="warning"]{box-shadow:inset 3px 0 var(--highlight-high,#b38300);}
- .ldp-request-flow-log-row[data-level="danger"]{box-shadow:inset 3px 0 var(--danger,#c84545);}
- .ldp-request-flow-log-row:last-child{border-bottom:0;}
- .ldp-request-flow-source{display:inline-flex;justify-content:center;padding:2px 4px;border-radius:4px;
- background:var(--primary-very-low,#f4f4f4);color:var(--primary-high,#333);}
- .ldp-request-flow-status{font-weight:700;text-align:right;}
- .ldp-request-flow-status.is-error{color:var(--danger,#c84545);}
- .ldp-request-flow-timing{overflow:hidden;text-align:right;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-request-flow-path{overflow:hidden;color:var(--primary-high,#333);text-overflow:ellipsis;white-space:nowrap;}
- .ldp-request-flow-empty,.ldp-font-family-empty{padding:18px 8px;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);text-align:center;}
- .ldp-request-flow-limit{display:grid;gap:7px;padding:11px;border:1px solid var(--primary-low,#ddd);border-radius:8px;}
- .ldp-request-flow-limit h4{margin:0;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);}
- .ldp-request-flow-limit strong{color:var(--primary-high,#333);}
- .ldp-request-flow-limit a{color:var(--d-link-color,#2870b8);text-decoration:none;}
- .ldp-request-flow-limit a:hover{text-decoration:underline;}
- @container ldp-reader (max-width:700px){
- .ldp-request-flow-summary{grid-template-columns:repeat(2,minmax(0,1fr));}
- .ldp-request-flow-metric + .ldp-request-flow-metric{border-left:0;}
- .ldp-request-flow-metric:nth-child(even){border-left:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-request-flow-metric:nth-child(n+3){border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-request-flow-metric:last-child{grid-column:1 / -1;}
- .ldp-request-flow-type-head,.ldp-request-flow-type-row{grid-template-columns:minmax(0,1fr) 52px 64px 36px;gap:5px;}
- .ldp-request-flow-trace-lane{grid-template-columns:34px minmax(0,1fr);gap:5px;}
- .ldp-request-flow-axis span:nth-child(2){display:none;}
- .ldp-request-flow-anomaly-row{grid-template-columns:82px 62px minmax(0,1fr);padding-inline:8px;}
- .ldp-request-flow-log-row{grid-template-columns:86px 50px 50px minmax(100px,1fr);padding-inline:8px;}
- .ldp-request-flow-log-row > :nth-child(2),.ldp-request-flow-log-row > :nth-child(5){display:none;}}
- .ldp-about-feature-copy p,.ldp-about-credits p,.ldp-request-flow-limit p,.ldp-cache-note{
- margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.5;}
- .ldp-config-actions{display:flex;flex-wrap:wrap;gap:7px;}
- .ldp-config-action{display:inline-flex;align-items:center;gap:6px;min-height:34px;padding:7px 10px;
- border:1px solid var(--primary-low,#ddd);border-radius:6px;background:var(--secondary,#fff);color:inherit;
- font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;}
- .ldp-config-action:hover{background:var(--primary-very-low,#f4f4f4);}
- .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open):focus-visible,
- .ldp-only-op-toggle:focus-visible,.ldp-reaction-filter:focus-visible,.ldp-settings-close:focus-visible,
- .ldp-settings-theme-button:focus-visible,.ldp-user-info-title-refresh:focus-visible,.ldp-config-action:focus-visible{
- outline:2px solid var(--tertiary,#47855f);outline-offset:1px;}
- .ldp-config-action.danger{color:var(--danger,#c44);}
- .ldp-cache-list{display:grid;gap:7px;}
- .ldp-cache-row{display:grid;grid-template-columns:18px minmax(0,1fr) auto;gap:8px;align-items:center;
- padding:8px;border:1px solid var(--primary-low,#e2e2e2);border-radius:6px;font-size:var(--ldp-font-ui,12px);cursor:pointer;}
- .ldp-cache-row input{margin:0;}
- .ldp-cache-row strong,.ldp-cache-row small{display:block;}
- .ldp-cache-row small{margin-top:2px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
- .ldp-cache-row em{font-style:normal;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);text-align:right;white-space:nowrap;}
- .ldp-cache-clear{justify-self:start;border:0;border-radius:6px;padding:8px 11px;background:var(--danger,#c44);color:#fff;
- font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;}
- .ldp-cache-clear:disabled{cursor:default;opacity:.42;}
- .ldp-image-scale-control{display:grid;grid-template-columns:minmax(0,1fr) 52px auto;align-items:center;gap:10px;min-width:0;}
- .ldp-image-scale-control input{width:100%;min-width:150px;}
- .ldp-font-scale-control{display:flex;align-items:center;gap:10px;min-width:0;}
- .ldp-host-embed-size-control{display:grid;grid-template-columns:minmax(0,1fr) 46px auto;align-items:center;gap:10px;min-width:0;}
- .ldp-layout-ratio-control{display:grid;grid-template-columns:minmax(0,1fr) 52px;align-items:center;gap:10px;min-width:0;}
- .ldp-layout-ratio-control input{width:100%;min-width:150px;}
- .ldp-setting-option-row.ldp-history-edge-trigger-row{grid-template-columns:minmax(0,1fr) minmax(190px,272px);align-items:center;gap:24px;}
- .ldp-history-edge-trigger-control{display:grid;grid-template-columns:minmax(0,1fr) 42px;align-items:center;gap:10px;min-width:0;width:100%;}
- @container ldp-reader (max-width:560px){
- .ldp-setting-option-row.ldp-history-edge-trigger-row{grid-template-columns:minmax(0,1fr);align-items:start;gap:8px;}
- .ldp-history-edge-trigger-control{padding-left:0;}
- }
- .ldp-settings-popover input[type="range"]{
- --ldp-range-progress:50%;appearance:none;-webkit-appearance:none;box-sizing:border-box;
- width:100%;min-width:0;height:24px;margin:0;padding:0;border:0;outline:0;background:transparent;cursor:pointer;}
- .ldp-settings-popover input[type="range"]::-webkit-slider-runnable-track{
- height:5px;border-radius:999px;background:linear-gradient(90deg,
- var(--tertiary,#47855f) 0 var(--ldp-range-progress),
- color-mix(in srgb,var(--primary-low,#d8d8d8) 72%,var(--secondary,#fff)) var(--ldp-range-progress) 100%);}
- .ldp-settings-popover input[type="range"]::-webkit-slider-thumb{
- appearance:none;-webkit-appearance:none;box-sizing:border-box;width:15px;height:15px;margin-top:-5px;border:3px solid var(--secondary,#fff);
- border-radius:50%;background:var(--tertiary,#47855f);
- box-shadow:0 0 0 1px color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#d8d8d8));
- transition:transform 160ms cubic-bezier(.22,1,.36,1),box-shadow 160ms cubic-bezier(.22,1,.36,1);}
- .ldp-settings-popover input[type="range"]:hover::-webkit-slider-thumb{transform:scale(1.08);}
- .ldp-settings-popover input[type="range"]:active::-webkit-slider-thumb{transform:scale(.96);}
- .ldp-settings-popover input[type="range"]:focus-visible::-webkit-slider-thumb{
- box-shadow:0 0 0 1px var(--tertiary,#47855f),0 0 0 4px color-mix(in srgb,var(--tertiary,#47855f) 22%,transparent);}
- .ldp-settings-popover input[type="range"]::-moz-range-track{
- height:5px;border:0;border-radius:999px;background:color-mix(in srgb,var(--primary-low,#d8d8d8) 72%,var(--secondary,#fff));}
- .ldp-settings-popover input[type="range"]::-moz-range-progress{
- height:5px;border:0;border-radius:999px;background:var(--tertiary,#47855f);}
- .ldp-settings-popover input[type="range"]::-moz-range-thumb{
- box-sizing:border-box;width:15px;height:15px;border:3px solid var(--secondary,#fff);border-radius:50%;background:var(--tertiary,#47855f);
- box-shadow:0 0 0 1px color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#d8d8d8));
- transition:transform 160ms cubic-bezier(.22,1,.36,1),box-shadow 160ms cubic-bezier(.22,1,.36,1);}
- .ldp-settings-popover input[type="range"]:hover::-moz-range-thumb{transform:scale(1.08);}
- .ldp-settings-popover input[type="range"]:active::-moz-range-thumb{transform:scale(.96);}
- .ldp-settings-popover input[type="range"]:focus-visible::-moz-range-thumb{
- box-shadow:0 0 0 1px var(--tertiary,#47855f),0 0 0 4px color-mix(in srgb,var(--tertiary,#47855f) 22%,transparent);}
- .ldp-settings-popover input[type="range"]:disabled{opacity:.4;cursor:default;}
- .ldp-image-scale-value,.ldp-host-embed-size-value,.ldp-history-edge-trigger-value,.ldp-layout-ratio-value{
- text-align:right;font-variant-numeric:tabular-nums;color:var(--primary-medium,#777);}
- .ldp-performance-preset.active{border-color:color-mix(in srgb,var(--tertiary,#47855f) 45%,var(--primary-low,#ddd));
- background:color-mix(in srgb,var(--tertiary-low,#dcefe3) 46%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
- .ldp-font-scope-tabs{display:flex;gap:18px;padding:0 4px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-font-scope-tab{flex:0 0 auto;min-width:64px;padding:5px 4px 8px;border:0;border-radius:0;
- background:transparent;color:var(--primary-medium,#777);font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:600;cursor:pointer;}
- .ldp-font-scope-tab:hover{background:transparent;color:var(--primary-high,#333);}
- .ldp-font-scope-tab.active{border:0;background:transparent;color:var(--tertiary,#47855f);
- box-shadow:inset 0 -2px 0 var(--tertiary,#47855f);}
- .ldp-setting-switch input{position:absolute;inset:0;z-index:1;width:100%;height:100%;margin:0;opacity:0;cursor:pointer;}
- .ldp-setting-switch input:focus-visible + .ldp-setting-switch-track{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 48%,transparent);outline-offset:2px;}
- .ldp-appearance-theme-note{display:flex;align-items:center;gap:7px;margin:0;padding:8px 10px;border-radius:8px;
- background:color-mix(in srgb,var(--tertiary-low,#dcefe3) 42%,var(--secondary,#fff));
- color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.4;}
- .ldp-appearance-theme-note strong{color:var(--tertiary,#47855f);font-weight:700;}
- .ldp-reader-window-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;}
- .ldp-reader-window-field{display:grid;gap:5px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
- .ldp-reader-window-input{box-sizing:border-box;width:100%;height:34px;padding:5px 8px;border:1px solid var(--primary-low,#ccc);
- border-radius:7px;background:var(--secondary,#fff);color:inherit;font:inherit;text-align:right;font-variant-numeric:tabular-nums;}
- .ldp-reader-window-option{display:flex;align-items:center;gap:7px;font-size:var(--ldp-font-ui,12px);cursor:pointer;}
- .ldp-reader-window-option input{margin:0;}
- .ldp-setting-option-row{grid-template-columns:minmax(0,1fr) auto;cursor:pointer;}
- .ldp-setting-option-copy{display:grid;gap:3px;min-width:0;}
- .ldp-setting-option-copy strong,.ldp-reader-action-option-copy strong{font-size:var(--ldp-font-base,13px);line-height:1.35;}
- .ldp-other-settings-fields{gap:14px;border-top:0;}
- .ldp-other-setting-group{min-width:0;padding:0 14px;border:1px solid var(--primary-low,#ddd);border-radius:10px;
- background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 34%,var(--secondary,#fff));}
- .ldp-settings-category-head,.ldp-other-setting-group-head{display:grid;gap:2px;padding:12px 0 10px;
- border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-about-feature-copy strong,.ldp-about-credits strong,.ldp-settings-category-head strong,
- .ldp-other-setting-group-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);line-height:1.35;}
- .ldp-settings-category-head small,.ldp-setting-option-copy small,.ldp-other-setting-group-head small,.ldp-reader-confirm-copy small{
- color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
- .ldp-other-setting-list{display:grid;}
- .ldp-other-setting-list > .ldp-setting-row{padding:11px 0;border-bottom:0;}
- .ldp-custom-site-form{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding-top:10px;}
- .ldp-custom-site-form .ldp-custom-site-input{width:100%;min-width:0;}
- .ldp-custom-site-form .ldp-custom-site-input::placeholder{font-size:var(--ldp-font-base,12px);}
- .ldp-custom-site-form .ldp-custom-site-add{justify-content:center;border-color:var(--tertiary,#47855f);
- background:var(--tertiary,#47855f);color:var(--secondary,#fff);white-space:nowrap;}
- .ldp-custom-site-form .ldp-custom-site-add:hover{background:color-mix(in srgb,var(--tertiary,#47855f) 88%,#000);}
- .ldp-custom-site-list{display:flex;flex-wrap:wrap;gap:6px;padding-top:8px;}
- .ldp-custom-site-list[hidden]{display:none;}
- .ldp-custom-site-item{display:inline-flex;max-width:100%;align-items:center;gap:5px;padding:4px 5px 4px 9px;
- border:1px solid var(--primary-low,#ddd);border-radius:999px;background:var(--secondary,#fff);
- color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);line-height:1.35;}
- .ldp-custom-site-item > span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-custom-site-item > button{display:inline-flex;width:22px;height:22px;flex:none;align-items:center;justify-content:center;
- padding:0;border:0;border-radius:50%;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-custom-site-item > button:hover{background:var(--danger-low,#fee);color:var(--danger,#c44);}
- .ldp-custom-site-item .ldp-icon{width:12px;height:12px;}
- .ldp-custom-site-status{display:block;margin:8px 0 10px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
- .ldp-settings-category-list > .ldp-setting-row + .ldp-setting-row,
- .ldp-other-setting-list > .ldp-setting-row + .ldp-setting-row{
- border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 72%,transparent);}
- .ldp-other-setting-list > .ldp-nested-display-warning{margin:0 0 11px;}
- .ldp-settings-panel .ldp-history-sort-mode{min-height:38px;padding-block:6px;}
- .ldp-nested-display-warning{margin:-2px 2px 2px;padding:8px 10px;border-radius:8px;background:color-mix(in srgb,var(--highlight,#ffb300) 12%,transparent);color:var(--primary-high,#444);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
- .ldp-font-rendering-status[data-mode="external"]{color:var(--tertiary,#47855f);font-weight:650;}
- .ldp-setting-switch{position:relative;display:inline-flex;width:40px;height:22px;flex:none;}
- .ldp-setting-switch-track{position:relative;box-sizing:border-box;display:block;width:100%;height:100%;
- border:1px solid color-mix(in srgb,var(--primary,#222) 24%,transparent);border-radius:999px;
- background:color-mix(in srgb,var(--primary,#222) 14%,var(--secondary,#fff));
- transition:background-color 180ms cubic-bezier(.22,1,.36,1),border-color 180ms cubic-bezier(.22,1,.36,1),transform 180ms cubic-bezier(.22,1,.36,1);}
- .ldp-setting-switch-track::after{content:"";position:absolute;left:2px;top:50%;width:16px;height:16px;
- border-radius:50%;background:var(--secondary,#fff);box-shadow:0 1px 2px rgba(0,0,0,.24);
- transform:translateY(-50%);transition:transform 180ms cubic-bezier(.22,1,.36,1),background-color 180ms cubic-bezier(.22,1,.36,1);}
- .ldp-setting-switch input:hover + .ldp-setting-switch-track{
- border-color:color-mix(in srgb,var(--primary,#222) 36%,transparent);}
- .ldp-setting-switch input:checked + .ldp-setting-switch-track{
- border-color:var(--tertiary,#47855f);background:var(--tertiary,#47855f);}
- .ldp-setting-switch input:checked + .ldp-setting-switch-track::after{
- background:#fff;transform:translate(20px,-50%);}
- .ldp-setting-switch input:active + .ldp-setting-switch-track{transform:scale(.97);}
- .ldp-setting-switch input:disabled{cursor:default;}
- .ldp-setting-switch input:disabled + .ldp-setting-switch-track{opacity:.45;}
- .ldp-boost-rule-row{grid-template-columns:110px minmax(0,1fr);gap:14px;padding:0;border:0;}
- .ldp-boost-rule-control{box-sizing:border-box;width:100%;height:34px;padding:5px 8px;
- border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);
- color:inherit;font:inherit;}
- .ldp-boost-rule-preview{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;}
- .ldp-boost-rule-preview code{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
- color:var(--tertiary,#47855f);font-family:inherit;font-size:var(--ldp-font-ui,12px);}
- .ldp-reader-window-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;}
- .ldp-performance-status,.ldp-reader-window-status,.ldp-layout-total{
- color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.4;}
- .ldp-layout-total.warning{color:var(--danger,#c44);font-weight:650;}
- .ldp-layout-total.balanced{color:var(--success,#47855f);font-weight:650;}
- .ldp-flash-range-control{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:10px;min-width:0;}
- .ldp-flash-value{text-align:right;font-variant-numeric:tabular-nums;color:var(--primary-medium,#777);white-space:nowrap;}
- .ldp-color-control{display:flex;align-items:center;gap:8px;width:100%;min-width:0;}
- .ldp-color-control input[type="color"]{
- -webkit-appearance:none;appearance:none;box-sizing:border-box;width:34px;height:34px;flex:0 0 34px;padding:0;
- overflow:hidden;border:2px solid var(--secondary,#fff);border-radius:50%;background:transparent;
- box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 24%,transparent);cursor:pointer;
- transition:box-shadow .15s ease-out,transform .15s ease-out;}
- .ldp-color-control input[type="color"]::-webkit-color-swatch-wrapper{padding:0;}
- .ldp-color-control input[type="color"]::-webkit-color-swatch{border:0;border-radius:50%;}
- .ldp-color-control input[type="color"]::-moz-color-swatch{border:0;border-radius:50%;}
- .ldp-color-control input[type="color"]:hover:not(:disabled){
- box-shadow:0 0 0 2px var(--secondary,#fff),0 0 0 4px color-mix(in srgb,var(--tertiary,#47855f) 38%,transparent);transform:scale(1.04);}
- .ldp-color-control input[type="color"]:focus-visible{outline:0;
- box-shadow:0 0 0 2px var(--secondary,#fff),0 0 0 4px var(--tertiary,#47855f);}
- .ldp-color-control input[type="color"]:active:not(:disabled){transform:scale(.94);}
- .ldp-color-control input[type="color"]:disabled{opacity:.42;cursor:default;}
- .ldp-flash-color-value,
- .ldp-accent-color-value,.ldp-link-color-value,.ldp-zebra-color-value,.ldp-list-zebra-color-value,.ldp-reply-line-color-value,.ldp-quote-line-color-value,.ldp-divider-line-color-value,.ldp-floor-preview-color-value{
- display:inline-flex;align-items:center;box-sizing:border-box;min-width:76px;min-height:28px;padding:0 8px;border-radius:7px;
- background:color-mix(in srgb,var(--primary,#222) 5%,transparent);color:var(--primary-high,#333);
- font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:var(--ldp-font-ui,12px);font-weight:650;letter-spacing:.02em;text-transform:uppercase;}
- .ldp-font-color-value{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI","Microsoft YaHei","PingFang SC","Noto Sans CJK SC",sans-serif;
- letter-spacing:0;text-transform:none;}
- .ldp-color-reset{display:inline-flex;min-height:28px;margin-left:auto;align-items:center;gap:4px;padding:4px 7px;border:0;
- border-radius:7px;background:transparent;color:var(--primary-medium,#777);font:inherit;font-size:var(--ldp-font-sm,11px);cursor:pointer;white-space:nowrap;}
- .ldp-color-reset:hover:not(:disabled){background:color-mix(in srgb,var(--primary,#222) 6%,transparent);color:var(--primary,#222);}
- .ldp-color-reset:focus-visible{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);outline-offset:1px;}
- .ldp-color-reset:disabled{opacity:.32;cursor:default;}
- .ldp-color-picker-popover{position:absolute;z-index:2147483610;box-sizing:border-box;width:min(268px,calc(100% - 24px));
- max-height:calc(100% - 24px);overflow:auto;overscroll-behavior:contain;padding:12px;
- border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff);color:var(--primary,#222);
- box-shadow:0 12px 30px rgba(0,0,0,.2);pointer-events:auto;}
- .ldp-color-picker-title{margin:0 0 9px;font-size:var(--ldp-font-base,13px);font-weight:700;}
- .ldp-color-picker-presets{display:grid;grid-template-columns:repeat(6,28px);justify-content:space-between;gap:8px;margin-bottom:11px;}
- .ldp-color-picker-preset{box-sizing:border-box;width:28px;height:28px;padding:0;border:2px solid var(--secondary,#fff);border-radius:50%;
- background:var(--ldp-color-picker-preset);box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 25%,transparent);cursor:pointer;}
- .ldp-color-picker-preset:hover,.ldp-color-picker-preset:focus-visible{outline:0;box-shadow:0 0 0 2px var(--secondary,#fff),0 0 0 4px var(--tertiary,#47855f);}
- .ldp-color-picker-preset[aria-pressed="true"]::after{content:"";display:block;width:8px;height:4px;margin:6px auto 0;border:solid currentColor;border-width:0 0 2px 2px;transform:rotate(-45deg);filter:drop-shadow(0 0 1px var(--secondary,#fff));}
- .ldp-color-picker-fields{display:flex;align-items:center;gap:8px;}
- .ldp-color-picker-hex{box-sizing:border-box;min-width:0;height:34px;flex:1;padding:5px 9px;border:1px solid var(--primary-low,#ccc);border-radius:7px;
- background:var(--secondary,#fff);color:inherit;font:650 var(--ldp-font-ui,12px)/1 ui-monospace,SFMono-Regular,Consolas,monospace;text-transform:uppercase;}
- .ldp-color-picker-hex:focus{border-color:var(--tertiary,#47855f);outline:0;box-shadow:0 0 0 1px var(--tertiary,#47855f);}
- .ldp-color-picker-hex[aria-invalid="true"]{border-color:var(--danger,#c44);box-shadow:0 0 0 1px var(--danger,#c44);}
- .ldp-color-picker-more{display:inline-flex;box-sizing:border-box;height:34px;flex:none;align-items:center;padding:5px 9px;
- border:1px solid var(--primary-low,#ccc);border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;white-space:nowrap;}
- .ldp-color-picker-more:hover,.ldp-color-picker-more:focus-visible{border-color:var(--tertiary,#47855f);outline:0;background:color-mix(in srgb,var(--primary,#222) 5%,transparent);}
- .ldp-color-picker-advanced{display:grid;gap:9px;margin-top:11px;padding-top:11px;border-top:1px solid var(--primary-low,#ddd);}
- .ldp-color-picker-slider{display:grid;grid-template-columns:42px minmax(0,1fr) 38px;align-items:center;gap:7px;
- color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
- .ldp-color-picker-slider output{text-align:right;font-variant-numeric:tabular-nums;}
- .ldp-color-picker-slider input{width:100%;height:18px;margin:0;padding:0;border:0;outline:0;background:transparent;cursor:pointer;appearance:none;-webkit-appearance:none;}
- .ldp-color-picker-slider input::-webkit-slider-runnable-track{height:8px;border:1px solid color-mix(in srgb,var(--primary,#222) 18%,transparent);border-radius:999px;background:var(--ldp-color-slider);}
- .ldp-color-picker-slider input::-webkit-slider-thumb{width:16px;height:16px;margin-top:-5px;border:2px solid var(--secondary,#fff);border-radius:50%;
- background:var(--ldp-color-slider-thumb,#fff);box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 42%,transparent);appearance:none;-webkit-appearance:none;}
- .ldp-color-picker-slider input::-moz-range-track{height:8px;border:1px solid color-mix(in srgb,var(--primary,#222) 18%,transparent);border-radius:999px;background:var(--ldp-color-slider);}
- .ldp-color-picker-slider input::-moz-range-thumb{width:14px;height:14px;border:2px solid var(--secondary,#fff);border-radius:50%;
- background:var(--ldp-color-slider-thumb,#fff);box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 42%,transparent);}
- .ldp-color-picker-slider input:focus-visible{filter:drop-shadow(0 0 2px var(--tertiary,#47855f));}
- .ldp-interface-font-scale-range,.ldp-font-scale-range,.ldp-composer-font-scale-range,.ldp-zebra-radius-range,.ldp-floor-preview-height-range,.ldp-flash-range{flex:1;min-width:120px;}
- .ldp-interface-font-scale-value,.ldp-font-scale-value,.ldp-composer-font-scale-value,.ldp-zebra-radius-value,.ldp-floor-preview-height-value{
- width:46px;text-align:right;font-variant-numeric:tabular-nums;color:var(--primary-medium,#777);}
- .ldp-font-field-reset{min-width:104px;justify-content:flex-end;}
- .ldp-font-option-control{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0;}
- .ldp-font-family-picker{position:relative;min-width:0;flex:1;}
- .ldp-font-family-trigger{height:36px;padding:5px 9px 5px 10px;text-align:left;}
- .ldp-font-family-trigger[aria-expanded="true"]:not(:focus-visible){box-shadow:none;}
- .ldp-font-family-trigger .ldp-icon{transition:transform 150ms ease-out;}
- .ldp-font-family-value{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-font-family-menu{position:absolute;z-index:2147483608;display:grid;grid-template-rows:auto auto minmax(0,1fr) auto auto;
- box-sizing:border-box;width:min(360px,calc(100% - 24px));max-height:calc(100% - 24px);overflow:hidden;
- padding:5px;border:1px solid var(--primary-low,#d7d7d7);border-radius:8px;background:var(--secondary,#fff);
- color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit);
- font-weight:var(--ldp-interface-font-weight,400);box-shadow:0 4px 8px rgba(0,0,0,.18);pointer-events:auto;}
- .ldp-font-family-search:focus-within{box-shadow:0 0 0 1px var(--tertiary,#47855f);}
- .ldp-font-family-search input{height:32px;}
- .ldp-font-family-source-status{margin:5px 2px 0;padding:5px 7px;border-radius:6px;
- background:var(--primary-very-low,#f3f3f3);color:var(--primary-medium,#666);font-size:var(--ldp-font-sm,11px);line-height:1.4;}
- .ldp-font-family-options{min-height:0;gap:7px;max-height:min(320px,42vh);margin-top:6px;}
- .ldp-font-family-options::-webkit-scrollbar,.ldp-topic-edit-category-options::-webkit-scrollbar{width:9px;}
- .ldp-font-family-options::-webkit-scrollbar-button,.ldp-topic-edit-category-options::-webkit-scrollbar-button{
- display:none;width:0;height:0;}
- .ldp-font-family-options::-webkit-scrollbar-track,.ldp-topic-edit-category-options::-webkit-scrollbar-track{
- border-radius:999px;background:linear-gradient(90deg,transparent 0 calc(50% - .75px),
- color-mix(in srgb,var(--ldp-reply-line-color,#47855f) 28%,transparent) calc(50% - .75px) calc(50% + .75px),
- transparent calc(50% + .75px) 100%);}
- .ldp-font-family-options::-webkit-scrollbar-thumb,.ldp-topic-edit-category-options::-webkit-scrollbar-thumb{
- border-radius:5px;background:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff));}
- .ldp-font-family-group{display:grid;gap:2px;}
- .ldp-font-family-group-title{padding:4px 7px 2px;color:var(--primary-medium,#777);
- font-size:var(--ldp-font-xs,10px);font-weight:700;line-height:1.25;}
- .ldp-font-family-option{grid-template-columns:minmax(0,1fr) auto;color:inherit;}
- .ldp-font-family-option-copy strong{font-weight:650;}
- .ldp-font-family-preview{min-width:44px;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);text-align:right;white-space:nowrap;}
- .ldp-font-family-manual{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:6px;padding:6px 7px 1px;
- border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
- .ldp-font-family-manual button{padding:5px 7px;border:0;border-radius:6px;background:transparent;color:var(--tertiary,#47855f);
- font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;}
- .ldp-font-family-manual button:hover,.ldp-font-family-manual button:focus-visible{background:var(--primary-very-low,#f2f2f2);outline:0;}
- .ldp-font-weight-select{width:132px;flex:none;}
- .ldp-font-family-custom{width:100%;min-width:0;flex:1 0 100%;order:3;}
- .ldp-font-weight-select,.ldp-font-family-custom{
- box-sizing:border-box;border:1px solid var(--primary-low,#ccc);
- border-radius:8px;background:var(--secondary,#fff);color:inherit;height:36px;padding:5px 10px;font:inherit;}
- .ldp-settings-panel,.ldp-setting-help-tooltip{
- --ldp-font-micro:8px;--ldp-font-xs:9px;--ldp-font-sm:10px;--ldp-font-ui:11px;--ldp-font-base:12px;
- --ldp-font-md:13px;--ldp-font-lg:14px;--ldp-font-xl:15px;--ldp-font-2xl:16px;--ldp-font-3xl:17px;}
- .ldp-settings-panel .ldp-settings-section:not([hidden]){gap:16px;}
- .ldp-settings-panel .ldp-color-groups{gap:14px;}
- .ldp-settings-panel .ldp-setting-row{padding-block:11px;}
- .ldp-settings-panel .ldp-setting-group{padding:8px 0 3px;}
- .ldp-settings-panel .ldp-setting-group > .ldp-setting-row{padding:7px 0 7px 12px;}
- .ldp-settings-panel .ldp-performance-fields .ldp-setting-row{padding-block:11px;}
- .ldp-settings-panel :is(.ldp-performance-control input,.ldp-reader-window-input,.ldp-boost-rule-control){height:32px;padding-block:4px;}
- .ldp-settings-panel :is(.ldp-font-family-trigger,.ldp-font-weight-select,.ldp-font-family-custom){height:34px;padding-block:4px;}
- .ldp-settings-panel .ldp-font-family-search{height:32px;}
- .ldp-settings-panel .ldp-font-family-search input{height:30px;}
- .ldp-settings-panel .ldp-font-family-option{min-height:34px;}
- .ldp-settings-panel .ldp-performance-preset{padding-block:6px;}
- .ldp-settings-panel .ldp-font-scope-tab{padding-block:4px 7px;}
- .ldp-settings-panel :is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset){padding:6px 8px;}
- .ldp-settings-panel .ldp-config-action{min-height:32px;padding:6px 9px;}
- .ldp-settings-panel .ldp-custom-site-form :is(.ldp-custom-site-input,.ldp-custom-site-add){height:34px;}
- .ldp-settings-panel .ldp-cache-row{padding:7px;}
- .ldp-settings-panel .ldp-cache-clear{padding:7px 10px;}
- .ldp-settings-panel .ldp-boost-rule-row{padding:11px 0;}
- .ldp-settings-panel .ldp-setting-switch{width:36px;height:20px;}
- .ldp-settings-panel .ldp-setting-switch-track::after{width:14px;height:14px;}
- .ldp-settings-panel .ldp-setting-switch input:checked + .ldp-setting-switch-track::after{transform:translate(18px,-50%);}
- .ldp-settings-panel .ldp-color-control input[type="color"]{width:32px;height:32px;flex-basis:32px;}
- .ldp-settings-panel :is(.ldp-flash-color-value,.ldp-accent-color-value,.ldp-link-color-value,.ldp-zebra-color-value,.ldp-list-zebra-color-value,.ldp-reply-line-color-value,.ldp-quote-line-color-value,.ldp-divider-line-color-value,.ldp-floor-preview-color-value,.ldp-color-reset){min-height:26px;}
- .ldp-settings-panel .ldp-resource-monitor-table-head{min-height:32px;padding-block:6px;}
- .ldp-settings-panel .ldp-resource-monitor-row{min-height:50px;padding-block:8px;}
- .ldp-settings-panel .ldp-resource-monitor-trend-row{min-height:44px;}
- .ldp-settings-panel .ldp-request-flow-metric{min-height:48px;padding-block:6px;}
- .ldp-settings-panel :is(.ldp-request-flow-chart-block,.ldp-request-flow-anomaly-block,.ldp-request-flow-log-block) .ldp-request-flow-block-head{min-height:32px;padding-block:6px;}
- .ldp-settings-panel :is(.ldp-request-flow-type-head,.ldp-request-flow-type-row,.ldp-request-flow-log-row){min-height:32px;}
- .ldp-reader-refresh,.ldp-close{cursor:pointer;border:none;background:transparent;color:inherit;padding:4px;}
- .ldp-reader-refresh:disabled{cursor:wait;opacity:.45;}
- .ldp-reader-refresh.is-refreshing .ldp-icon{animation:ldp-spin .9s linear infinite;}
- @media (prefers-reduced-motion:reduce){.ldp-reader-refresh.is-refreshing .ldp-icon{animation:none;}}
- .ldp-icon-btn{width:28px;height:28px;border-radius:6px;display:inline-flex;
- align-items:center;justify-content:center;}
- .ldp-icon-btn:hover{background:var(--primary-very-low,#f0f0f0);}
- .ldp-icon{width:14px;height:14px;stroke:currentColor;stroke-width:2;
- stroke-linecap:round;stroke-linejoin:round;fill:none;flex:none;}
- .ldp-reader-main{position:relative;display:grid;flex:1;min-height:0;grid-template-columns:
- minmax(0,var(--ldp-layout-left,10%)) minmax(0,var(--ldp-layout-main,72%))
- minmax(0,var(--ldp-layout-gap,3%)) minmax(0,var(--ldp-layout-timeline,10%))
- minmax(0,var(--ldp-layout-right,5%));}
- .ldp-rate-limit-notice{position:absolute;z-index:9;top:10px;left:50%;transform:translateX(-50%);
- display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;
- width:min(680px,calc(100% - 24px));box-sizing:border-box;padding:9px 10px;
- border:1px solid color-mix(in srgb,var(--highlight,#e7a400) 54%,var(--primary-low,#ddd));border-radius:9px;
- background:color-mix(in srgb,var(--highlight-low,#fff2c2) 82%,var(--secondary,#fff));color:var(--primary,#222);
- box-shadow:0 6px 20px rgba(0,0,0,.16);font-size:var(--ldp-font-ui,12px);line-height:1.4;pointer-events:auto;}
- .ldp-rate-limit-icon{display:inline-flex;width:26px;height:26px;align-items:center;justify-content:center;
- border-radius:7px;background:color-mix(in srgb,var(--highlight,#e7a400) 18%,transparent);color:var(--highlight,#b77900);}
- .ldp-rate-limit-copy{display:grid;gap:1px;min-width:0;}
- .ldp-rate-limit-copy strong{font-size:var(--ldp-font-base,13px);line-height:1.3;}
- .ldp-rate-limit-detail{color:var(--primary-high,#4d5963);overflow-wrap:anywhere;}
- .ldp-rate-limit-challenge{display:inline-flex;min-height:30px;align-items:center;justify-content:center;gap:5px;
- padding:5px 9px;border:1px solid color-mix(in srgb,var(--highlight,#e7a400) 46%,var(--primary-low,#ddd));border-radius:7px;
- background:var(--secondary,#fff);color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;
- white-space:nowrap;text-decoration:none;cursor:pointer;}
- .ldp-rate-limit-challenge:hover{background:color-mix(in srgb,var(--highlight-low,#fff2c2) 54%,var(--secondary,#fff));}
- .ldp-reader-lock-button .ldp-icon,.ldp-reader-pin-button .ldp-icon,.ldp-reader-placement-option .ldp-icon,
- .ldp-notification-mark-all .ldp-icon,:is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset) .ldp-icon,
- .ldp-settings-save-all .ldp-icon,.ldp-rate-limit-challenge .ldp-icon{width:13px;height:13px;}
- @container ldp-reader (max-width:600px){
- .ldp-rate-limit-notice{grid-template-columns:auto minmax(0,1fr);align-items:start;}
- .ldp-rate-limit-challenge{grid-column:2;justify-self:start;}
- }
- .ldp-reader-history-nav{position:absolute;top:50%;z-index:8;width:34px;height:34px;
- transform:translateY(-50%);
- display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;padding:0;
- border:1px solid var(--primary-low,#ccc);border-radius:50%;background:var(--secondary,#fff);
- color:var(--primary-medium,#777);cursor:pointer;pointer-events:none;opacity:0;
- transition:opacity 160ms ease-out,transform 160ms ease-out;}
- .ldp-fullpage .ldp-reader-history-nav{position:fixed;}
- .ldp-reader-history-edge{display:contents;}
- .ldp-reader-history-back{left:25px;}
- .ldp-reader-history-forward{left:auto;right:25px;}
- .ldp-reader-history-edge.is-active .ldp-reader-history-nav,
- .ldp-history-buttons-always-visible .ldp-reader-history-edge .ldp-reader-history-nav,
- .ldp-reader-history-nav:focus-visible{pointer-events:auto;opacity:.35;}
- .ldp-reader-history-nav:hover{border-color:var(--primary-medium,#999);background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);opacity:1;}
- .ldp-reader-history-nav:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:2px;opacity:1;}
- .ldp-reader-history-nav:disabled{cursor:wait;}
- .ldp-reader-history-edge.is-active .ldp-reader-history-nav:disabled,
- .ldp-history-buttons-always-visible .ldp-reader-history-edge .ldp-reader-history-nav:disabled{opacity:.45;}
- .ldp-reader-queue{--ldp-reader-queue-rail-size:44px;--ldp-reader-queue-toggle-size:44px;
- --ldp-reader-queue-toggle-radius:13px;--ldp-reader-queue-toggle-icon-size:20px;
- --ldp-reader-queue-avatar-size:38px;--ldp-reader-queue-progress-padding:4px;
- --ldp-reader-queue-gap:8px;--ldp-reader-queue-bubble-gap:8px;--ldp-reader-queue-bubbles-max-height:230px;
- --ldp-reader-queue-scroll-size:36px;--ldp-reader-queue-scroll-icon-size:24px;
- --ldp-reader-queue-badge-size:18px;--ldp-reader-queue-status-size:8px;--ldp-reader-queue-loading-size:10px;
- --ldp-reader-queue-affordance-size:14px;--ldp-reader-queue-pin-size:13px;--ldp-reader-queue-pin-icon-size:8px;
- position:absolute;z-index:11;left:14px;top:96px;display:flex;width:var(--ldp-reader-queue-rail-size);
- max-height:calc(100% - 120px);flex-direction:column;align-items:center;gap:var(--ldp-reader-queue-gap);
- color:var(--ldp-interface-font-color,var(--primary,#222));
- font-family:var(--ldp-interface-font-family,inherit);font-weight:var(--ldp-interface-font-weight,400);pointer-events:auto;
- transition:gap .2s ease-out,transform .2s cubic-bezier(.22,1,.36,1);}
- .ldp-reader-queue.is-docked-left:not(.is-dragging):not(.is-dock-revealed):not(:focus-within){
- transform:translateX(calc(-100% + 12px));}
- .ldp-reader-queue.is-docked-right:not(.is-dragging):not(.is-dock-revealed):not(:focus-within){
- transform:translateX(calc(100% - 12px));}
- .ldp-reader-queue.is-dragging{transition:none;user-select:none;}
- .ldp-reader-queue.is-preview-collapsed,
- .ldp-reader-queue:is(.is-docked-left,.is-docked-right):not(.is-dock-revealed):not(:focus-within){gap:0;}
- .ldp-reader-queue[hidden],.ldp-reader-queue-panel[hidden]{display:none!important;}
- .ldp-reader-queue-toggle,.ldp-reader-queue-bubble{position:relative;display:inline-flex;flex:none;
- align-items:center;justify-content:center;box-sizing:border-box;border:0;cursor:pointer;}
- .ldp-reader-queue-toggle{width:var(--ldp-reader-queue-toggle-size);height:var(--ldp-reader-queue-toggle-size);
- border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 28%,var(--primary-low,#ddd));
- border-radius:var(--ldp-reader-queue-toggle-radius);background:color-mix(in srgb,var(--secondary,#fff) 92%,var(--tertiary-low,#dceee2));
- color:var(--tertiary,#47855f);box-shadow:0 2px 8px rgba(0,0,0,.11);cursor:grab;touch-action:none;}
- .ldp-reader-queue.is-dragging .ldp-reader-queue-toggle{cursor:grabbing;}
- .ldp-reader-queue-toggle:hover{background:color-mix(in srgb,var(--secondary,#fff) 78%,var(--tertiary-low,#dceee2));}
- .ldp-reader-queue-toggle .ldp-icon{width:var(--ldp-reader-queue-toggle-icon-size);height:var(--ldp-reader-queue-toggle-icon-size);}
- .ldp-reader-queue:is(.is-docked-left,.is-docked-right):not(.is-dock-revealed):not(:focus-within)
- .ldp-reader-queue-toggle{background:var(--secondary,#fff);}
- .ldp-reader-queue:is(.is-docked-left,.is-docked-right):not(.is-dock-revealed):not(:focus-within)
- .ldp-reader-queue-toggle .ldp-icon{visibility:hidden;}
- .ldp-reader-queue-toggle b{position:absolute;right:-4px;top:-5px;display:inline-flex;
- min-width:var(--ldp-reader-queue-badge-size);height:var(--ldp-reader-queue-badge-size);
- align-items:center;justify-content:center;box-sizing:border-box;padding:0 3px;border:2px solid var(--secondary,#fff);border-radius:999px;
- background:var(--tertiary,#47855f);color:#fff;font-size:var(--ldp-font-micro,9px);font-variant-numeric:tabular-nums;line-height:1;}
- .ldp-reader-queue.is-docked-right:not(.is-dock-revealed):not(:focus-within)
- .ldp-reader-queue-toggle b{right:auto;left:-4px;}
- .ldp-reader-queue-bubbles{display:flex;min-height:0;max-height:var(--ldp-reader-queue-bubbles-max-height);
- box-sizing:border-box;flex-direction:column;align-items:center;gap:var(--ldp-reader-queue-bubble-gap);
- overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;padding:4px 6px;scrollbar-width:none;
- opacity:1;transform:none;transform-origin:top center;
- transition:max-height .22s cubic-bezier(.22,1,.36,1),padding-block .22s cubic-bezier(.22,1,.36,1),
- opacity .14s ease-out,transform .22s cubic-bezier(.22,1,.36,1);}
- .ldp-reader-queue.is-preview-collapsed .ldp-reader-queue-bubbles,
- .ldp-reader-queue:is(.is-docked-left,.is-docked-right):not(.is-dock-revealed):not(:focus-within)
- .ldp-reader-queue-bubbles{max-height:0;padding-block:0;
- overflow:hidden;opacity:0;pointer-events:none;transform:translateY(-5px) scale(.94);}
- .ldp-reader-queue.is-preview-collapsed .ldp-reader-queue-scroll-hint,
- .ldp-reader-queue:is(.is-docked-left,.is-docked-right):not(.is-dock-revealed):not(:focus-within)
- .ldp-reader-queue-scroll-hint{display:none!important;}
- .ldp-reader-queue-bubbles::-webkit-scrollbar{display:none;}
- .ldp-reader-queue-scroll-hint{display:flex;width:var(--ldp-reader-queue-scroll-size);height:var(--ldp-reader-queue-scroll-size);
- flex:none;align-items:center;justify-content:center;
- box-sizing:border-box;padding:0;border:0;background:none;color:#f2aa2e;cursor:pointer;}
- .ldp-reader-queue-scroll-hint[hidden]{display:none!important;}
- .ldp-reader-queue-scroll-hint:hover{color:#d98600;}
- .ldp-reader-queue-scroll-hint:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:-2px;}
- .ldp-reader-queue-scroll-hint .ldp-icon{width:var(--ldp-reader-queue-scroll-icon-size);height:var(--ldp-reader-queue-scroll-icon-size);
- filter:drop-shadow(0 1px 0 var(--secondary,#fff));
- transform:rotate(90deg);transition:transform .16s ease-out;}
- .ldp-reader-queue-scroll-hint.is-up .ldp-icon{transform:rotate(-90deg);}
- @media (prefers-reduced-motion:reduce){
- .ldp-reader-queue,.ldp-reader-queue-bubbles{transition:none;}
- }
- .ldp-reader-queue-bubble-shell{position:relative;display:flex;width:var(--ldp-reader-queue-avatar-size);
- height:var(--ldp-reader-queue-avatar-size);flex:none;align-items:center;justify-content:center;}
- .ldp-reader-queue-bubble,.ldp-reader-queue-row-progress{background:
- conic-gradient(from 0deg,transparent 0 var(--ldp-reader-queue-progress,0deg),
- color-mix(in srgb,var(--primary,#222) 32%,var(--secondary,#fff)) var(--ldp-reader-queue-progress,0deg) 1turn),
- conic-gradient(from 0deg,#a9e8b8 0deg,#dca64b 210deg,#8b1f2d 1turn);}
- .ldp-reader-queue-bubble.is-progress-complete,
- .ldp-reader-queue-row-progress.is-progress-complete{
- background:conic-gradient(from 0deg,#a9e8b8 0deg,#dca64b 210deg,#8b1f2d 1turn);}
- .ldp-reader-queue-bubble{width:var(--ldp-reader-queue-avatar-size);height:var(--ldp-reader-queue-avatar-size);
- padding:var(--ldp-reader-queue-progress-padding);border-radius:50%;color:var(--primary-medium,#777);
- box-shadow:0 1px 4px rgba(0,0,0,.1);transition:box-shadow .12s ease-out;}
- .ldp-reader-queue-bubble:hover{box-shadow:0 0 0 1px var(--secondary,#fff),0 2px 7px rgba(0,0,0,.15);}
- .ldp-reader-queue-bubble.is-active{box-shadow:0 0 0 1px var(--secondary,#fff),0 1px 5px rgba(0,0,0,.12);}
- .ldp-reader-queue-avatar{display:flex;width:100%;height:100%;align-items:center;justify-content:center;overflow:hidden;
- border-radius:50%;background:var(--secondary,#fff);color:var(--primary-high,#333);font-size:var(--ldp-font-sm,11px);font-weight:700;}
- .ldp-reader-queue-avatar img{width:100%;height:100%;object-fit:cover;}
- .ldp-reader-queue-bubble > i{position:absolute;right:-3px;bottom:-3px;width:var(--ldp-reader-queue-status-size);
- height:var(--ldp-reader-queue-status-size);box-sizing:border-box;
- border:2px solid var(--secondary,#fff);border-radius:50%;background:var(--primary-low,#ccc);}
- .ldp-reader-queue-bubble.is-ready > i{background:var(--success,#3f8f5b);}
- .ldp-reader-queue-bubble.is-partial > i{background:#d98600;}
- .ldp-reader-queue-bubble.is-error > i{background:var(--danger,#c94747);}
- .ldp-reader-queue-bubble.is-loading > i{width:var(--ldp-reader-queue-loading-size);height:var(--ldp-reader-queue-loading-size);
- border:2px solid color-mix(in srgb,var(--tertiary,#47855f) 25%,var(--secondary,#fff));
- border-top-color:var(--tertiary,#47855f);background:var(--secondary,#fff);animation:ldp-spin .8s linear infinite;}
- .ldp-reader-queue-bubble-remove{position:absolute;z-index:2;right:-3px;top:-3px;display:inline-flex;
- width:var(--ldp-reader-queue-affordance-size);height:var(--ldp-reader-queue-affordance-size);
- align-items:center;justify-content:center;box-sizing:border-box;padding:2px;border:1px solid var(--secondary,#fff);border-radius:50%;
- background:var(--danger,#c94747);color:#fff;cursor:pointer;opacity:0;pointer-events:none;transform:scale(.82);
- transition:opacity .12s ease-out,transform .12s ease-out;}
- .ldp-reader-queue-bubble-shell:hover .ldp-reader-queue-bubble-remove,
- .ldp-reader-queue-bubble-remove:focus-visible{opacity:1;pointer-events:auto;transform:scale(1);}
- .ldp-reader-queue-bubble-remove:disabled{display:none;}
- .ldp-reader-queue-bubble-remove .ldp-icon{width:8px;height:8px;stroke-width:2.5;}
- .ldp-reader-queue-bubble-pin{position:absolute;z-index:1;left:-3px;top:-3px;display:flex;
- width:var(--ldp-reader-queue-pin-size);height:var(--ldp-reader-queue-pin-size);
- align-items:center;justify-content:center;box-sizing:border-box;border:1px solid var(--secondary,#fff);border-radius:50%;
- background:var(--tertiary,#47855f);color:#fff;pointer-events:none;}
- .ldp-reader-queue-bubble-pin .ldp-icon{width:var(--ldp-reader-queue-pin-icon-size);height:var(--ldp-reader-queue-pin-icon-size);}
- .ldp-reader-queue-panel{position:absolute;left:calc(var(--ldp-reader-queue-rail-size) + 10px);top:0;
- display:grid;width:min(330px,calc(100vw - 78px));
- max-height:min(62vh,560px);grid-template-rows:auto minmax(0,1fr);box-sizing:border-box;border:1px solid var(--primary-low,#ddd);
- border-radius:11px;background:var(--secondary,#fff);box-shadow:0 16px 46px rgba(0,0,0,.22);overflow:hidden;}
- .ldp-reader-queue.is-docked-right .ldp-reader-queue-panel{
- left:auto;right:calc(var(--ldp-reader-queue-rail-size) + 10px);}
- .ldp-reader-queue-panel-head{display:flex;min-height:42px;align-items:center;gap:8px;padding:7px 9px 7px 12px;
- border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-reader-queue-panel-head strong{font-size:var(--ldp-font-base,13px);}
- .ldp-reader-queue-panel-count{margin-right:auto;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
- .ldp-reader-queue-close,.ldp-reader-queue-clear,.ldp-reader-queue-pin,.ldp-reader-queue-remove,.ldp-reader-queue-retry{display:inline-flex;flex:none;align-items:center;justify-content:center;
- box-sizing:border-box;border:0;border-radius:6px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-reader-queue-close,.ldp-reader-queue-clear{width:28px;height:28px;}
- .ldp-reader-queue-close:hover,.ldp-reader-queue-clear:hover,.ldp-reader-queue-pin:hover,.ldp-reader-queue-remove:hover,.ldp-reader-queue-retry:hover{
- background:var(--primary-very-low,#f3f3f3);color:var(--primary,#222);}
- .ldp-reader-queue-clear:hover{color:var(--danger,#c94747);}
- .ldp-reader-queue-clear:disabled{cursor:default;opacity:.35;}
- .ldp-reader-queue-pin.active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));
- color:var(--tertiary,#47855f);}
- .ldp-reader-queue-list{min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:5px;}
- .ldp-reader-queue-row{display:grid;grid-template-columns:38px minmax(0,1fr) auto;align-items:center;gap:9px;
- min-height:54px;padding:6px 7px;border-radius:8px;cursor:pointer;outline:none;}
- .ldp-reader-queue-row:hover{background:var(--primary-very-low,#f5f5f5);}
- .ldp-reader-queue-row.is-active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));}
- .ldp-reader-queue-row:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:-2px;}
- .ldp-reader-queue-row-progress{display:flex;width:38px;height:38px;box-sizing:border-box;padding:3px;border-radius:50%;}
- .ldp-reader-queue-row-copy{display:grid;min-width:0;gap:3px;}
- .ldp-reader-queue-row-copy strong{overflow:hidden;color:var(--primary,#222);font-size:var(--ldp-font-base,13px);
- font-weight:650;line-height:1.3;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-reader-queue-row-copy small{overflow:hidden;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);
- line-height:1.25;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-reader-queue-row-actions{display:flex;align-items:center;gap:2px;}
- .ldp-reader-queue-pin,.ldp-reader-queue-remove,.ldp-reader-queue-retry{width:26px;height:26px;}
- .ldp-reader-queue-remove:disabled{visibility:hidden;}
- .ldp-reader-queue-panel :is(.ldp-reader-queue-close,.ldp-reader-queue-clear,.ldp-reader-queue-pin,.ldp-reader-queue-remove,.ldp-reader-queue-retry) .ldp-icon{width:14px;height:14px;}
- .ldp-body{grid-column:1 / -1;grid-row:1;display:grid;grid-template-columns:subgrid;min-height:0;position:relative;
- grid-auto-rows:max-content;align-content:start;box-sizing:border-box;width:100%;max-width:none;
- margin:0;padding:0;overflow-y:auto;overscroll-behavior:contain;overflow-anchor:auto;}
- .ldp-body:is(.ldp-stream-viewport-anchor,.ldp-floor-preview-positioning,.ldp-quote-positioning){overflow-anchor:none;}
- .ldp-body > *{grid-column:2;box-sizing:border-box;width:100%;
- margin-left:0;margin-right:0;padding-left:20px;padding-right:20px;}
- .ldp-body > .ldp-sentinel{padding-left:0;padding-right:0;}
- .ldp-topic-timeline{position:relative;grid-column:4;grid-row:1;left:auto;right:auto;top:auto;bottom:auto;z-index:4;
- box-sizing:border-box;width:100%;max-width:100%;margin:24px 0;padding:0 4px;align-self:stretch;justify-self:stretch;
- display:grid;grid-template-rows:auto minmax(150px,1fr) auto;gap:10px;
- color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);line-height:1.25;pointer-events:none;}
- .ldp-topic-timeline-date,.ldp-topic-timeline-relative{min-width:0;padding:0 0 0 2px;
- overflow:hidden;border:0;background:transparent;color:inherit;font:inherit;cursor:pointer;pointer-events:auto;
- text-align:left;text-overflow:ellipsis;white-space:pre-line;}
- .ldp-topic-timeline-date:hover:not(:disabled),.ldp-topic-timeline-relative:hover:not(:disabled){color:var(--primary,#222);text-decoration:underline;}
- .ldp-topic-timeline-date:focus-visible,.ldp-topic-timeline-relative:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:2px;border-radius:3px;}
- .ldp-topic-timeline-date:disabled,.ldp-topic-timeline-relative:disabled{cursor:default;}
- .ldp-topic-timeline-footer{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0;}
- .ldp-topic-timeline-relative{flex:0 0 auto;overflow:visible;white-space:nowrap;}
- .ldp-topic-timeline-top{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;
- flex:none;box-sizing:border-box;padding:0;border:1px solid var(--primary-low,#ccc);border-radius:50%;
- background:var(--secondary,#fff);color:inherit;cursor:pointer;pointer-events:auto;}
- .ldp-topic-timeline-top:hover{border-color:var(--primary-medium,#999);background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
- .ldp-topic-timeline-track{position:relative;width:100%;min-height:150px;padding:0;border:0;
- background:transparent;color:inherit;cursor:ns-resize;pointer-events:auto;touch-action:none;}
- .ldp-topic-timeline-track::before{content:"";position:absolute;left:15px;top:10px;bottom:10px;width:0;
- border-left:var(--ldp-reply-line-width,1px) solid var(--ldp-reply-line-color,#47855f);}
- .ldp-topic-timeline-cursor{position:absolute;z-index:2;left:30px;
- top:var(--ldp-timeline-lens-top,calc(20px + var(--ldp-timeline-preview-progress,0) * (100% - 40px)));width:max-content;min-width:44px;height:82px;
- transform:translate3d(0,-50%,0) scale(.94);transform-origin:left center;will-change:top,transform,opacity;contain:layout style;
- color:var(--primary-medium,#777);font-family:var(--ldp-interface-font-family,inherit);font-weight:var(--ldp-interface-font-weight,400);
- font-variant-numeric:tabular-nums;text-align:left;white-space:nowrap;pointer-events:none;opacity:0;
- transition:opacity .1s ease-out,transform .1s cubic-bezier(.22,1,.36,1);}
- .ldp-topic-timeline-cursor > span{position:absolute;left:0;top:50%;display:block;min-width:0;line-height:1;transform-origin:left center;
- transform:translate3d(var(--ldp-lens-shift-x,0),calc(-50% + var(--ldp-lens-offset,0) * 16px),0) scale(var(--ldp-lens-scale,1));
- backface-visibility:hidden;will-change:transform,opacity;font-size:var(--ldp-font-md,14px);font-weight:inherit;opacity:var(--ldp-lens-opacity,0);}
- .ldp-topic-timeline-cursor > span::before{content:"";position:absolute;left:-11px;top:50%;width:7px;height:2px;
- transform:translateY(-50%) scaleX(.35);transform-origin:right center;border-radius:999px;background:var(--tertiary,#47855f);
- opacity:0;transition:opacity .08s ease-out,transform .08s ease-out;}
- .ldp-topic-timeline-cursor > .ldp-timeline-lens-selected{font-weight:inherit;}
- .ldp-topic-timeline-cursor > .ldp-timeline-lens-selected::before{transform:translateY(-50%) scaleX(1);opacity:.92;}
- .ldp-topic-timeline-track.ldp-timeline-hovering .ldp-topic-timeline-cursor{opacity:1;transform:translate3d(0,-50%,0) scale(1);}
- .ldp-topic-timeline-track.ldp-timeline-jumping .ldp-topic-timeline-cursor{opacity:1;transform:translate3d(0,-50%,0) scale(1);
- transition:opacity .1s ease-out,transform .1s cubic-bezier(.22,1,.36,1);}
- .ldp-topic-timeline-cursor.ldp-timeline-lens-composited{top:0;will-change:transform,opacity;transition:opacity .1s ease-out;
- transform:translate3d(0,var(--ldp-timeline-lens-top,0px),0) translateY(-50%) scale(.94);}
- .ldp-topic-timeline-track.ldp-timeline-hovering .ldp-topic-timeline-cursor.ldp-timeline-lens-composited,
- .ldp-topic-timeline-track.ldp-timeline-jumping .ldp-topic-timeline-cursor.ldp-timeline-lens-composited{
- transform:translate3d(0,var(--ldp-timeline-lens-top,0px),0) translateY(-50%) scale(1);transition:opacity .1s ease-out;}
- .ldp-topic-timeline-track.ldp-timeline-jumping .ldp-topic-timeline-cursor > .ldp-timeline-lens-focus{animation:ldp-topic-timeline-lens-focus .46s ease-out both;}
- @keyframes ldp-topic-timeline-lens-focus{
- 0%{filter:blur(.35px);opacity:.52;}
- 58%{filter:blur(0);opacity:1;}
- 100%{filter:none;opacity:1;}
- }
- .ldp-topic-timeline-thumb{position:absolute;left:11px;top:calc(10px + var(--ldp-timeline-progress,0) * (100% - 40px));
- width:9px;height:20px;border-radius:5px;
- background:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff));}
- .ldp-topic-timeline-count{position:absolute;left:30px;top:calc(20px + var(--ldp-timeline-progress,0) * (100% - 40px));
- transform:translateY(-50%);display:inline-flex;align-items:center;gap:1px;max-width:none;
- overflow:visible;color:var(--primary,#222);font-size:clamp(var(--ldp-font-xs,10px),.8vw,var(--ldp-font-sm,11px));font-weight:var(--ldp-reader-font-weight-base,400);
- font-variant-numeric:tabular-nums;text-align:left;white-space:nowrap;transition:opacity .12s ease-out;}
- .ldp-topic-timeline-preview{position:absolute;z-index:1;left:30px;
- top:calc(20px + var(--ldp-timeline-preview-progress,0) * (100% - 40px));
- transform:translate(var(--ldp-timeline-preview-shift-x,0px),calc(-50% - 14px));display:inline-flex;align-items:center;gap:1px;max-width:calc(100% - 30px);
- overflow:hidden;text-overflow:ellipsis;color:var(--primary,#222);font-size:clamp(var(--ldp-font-xs,10px),.8vw,var(--ldp-font-sm,11px));font-weight:var(--ldp-reader-font-weight-base,400);
- font-variant-numeric:tabular-nums;text-align:left;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .12s ease-out;}
- .ldp-topic-timeline-track.ldp-timeline-previewing .ldp-topic-timeline-preview{opacity:.45;}
- .ldp-topic-timeline-track:is(.ldp-timeline-hovering,.ldp-timeline-jumping) :is(.ldp-topic-timeline-count,.ldp-topic-timeline-preview){opacity:0;}
- .ldp-topic-timeline-track:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:3px;border-radius:4px;}
- .ldp-live-update{position:absolute;left:50%;bottom:18px;z-index:6;transform:translateX(-50%);opacity:.65;
- display:inline-flex;align-items:center;border:0;border-radius:999px;padding:8px 13px;
- background:var(--tertiary,#47855f);color:#fff;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;
- box-shadow:0 4px 8px rgba(0,0,0,.2);white-space:nowrap;}
- .ldp-live-update-jump{display:inline-flex;align-items:center;gap:6px;min-width:0;border:0;padding:0;
- background:transparent;color:inherit;font:inherit;cursor:pointer;white-space:nowrap;}
- .ldp-live-update-jump:hover{filter:brightness(1.08);}
- .ldp-live-update-dismiss{position:absolute;top:-8px;right:-8px;width:20px;height:20px;display:inline-flex;
- align-items:center;justify-content:center;box-sizing:border-box;border:0;border-radius:50%;padding:0;
- background:var(--tertiary,#47855f);color:#fff;cursor:pointer;box-shadow:0 2px 5px rgba(0,0,0,.22);}
- .ldp-live-update-dismiss:hover{background:rgba(255,255,255,.16);color:#fff;}
- .ldp-live-update-dismiss .ldp-icon{width:11px;height:11px;}
- .ldp-live-update-jump:focus-visible,.ldp-live-update-dismiss:focus-visible{outline:2px solid var(--secondary,#fff);outline-offset:2px;}
-
- /* 阅读器沉浸式载入场景 */
- .ldp-body > .ldp-loadmask{position:absolute;inset:0;z-index:7;grid-column:1 / -1;width:100%;height:100%;margin:0;
- display:grid;place-items:center;box-sizing:border-box;padding:0;overflow:hidden;
- background:radial-gradient(ellipse at 50% 30%,color-mix(in srgb,var(--tertiary,#47855f) 8%,transparent),transparent min(46vw,330px)),var(--secondary,#fff);color:inherit;}
- .ldp-loading-stage{position:absolute;inset:0;z-index:1;display:grid;grid-template-rows:60% 40%;place-items:center;width:100%;height:100%;box-sizing:border-box;text-align:center;}
- .ldp-loading-stage > .ldp-loading-visual{grid-area:1 / 1;}
- .ldp-loading-copy{position:absolute;top:calc(30% + 120px);left:50%;display:flex;width:min(520px,calc(100% - 48px));
- flex-direction:column;align-items:center;transform:translateX(-50%);}
- .ldp-loading-visual{--ldp-loader-accent:var(--tertiary,#47855f);--ldp-loader-brand:#f5b72e;position:relative;display:grid;place-items:center;
- width:min(360px,82vw);height:230px;overflow:visible;background:transparent;isolation:isolate;pointer-events:none;user-select:none;}
- .ldp-loading-visual-inner{position:relative;width:280px;height:170px;color:var(--ldp-loader-accent);transform:scale(1.08);transform-origin:center;}
- .ldp-loading-visual-inner span{position:absolute;display:block;box-sizing:border-box;will-change:transform,opacity;}
- .ldp-loading-mode{margin-top:2px;color:var(--tertiary,#47855f);font-size:10px;font-weight:750;letter-spacing:.18em;text-transform:uppercase;opacity:.7;}
- .ldp-loading-status{display:flex;align-items:center;justify-content:center;gap:9px;min-height:30px;margin-top:10px;color:var(--primary,#222);font-size:clamp(17px,2.2vw,21px);font-weight:720;letter-spacing:.02em;}
- .ldp-loading-status strong{display:inline-flex;align-items:center;min-height:22px;padding:0 8px;border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 22%,transparent);border-radius:999px;
- background:color-mix(in srgb,var(--tertiary,#47855f) 8%,var(--secondary,#fff));color:var(--tertiary,#47855f);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;}
- .ldp-loading-status strong:empty{display:none;}
- .ldp-loading-detail{min-height:20px;margin-top:5px;color:var(--primary-medium,#777);font-size:var(--ldp-font-base,13px);line-height:1.55;}
-
- /* LINUX DO Reader 十个真实等待场景:统一黑白黄识别,动作分别对应阅读器能力。 */
- .ldp-loading-visual[data-animation="portal"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 88%,var(--secondary,#fff));}
- .ldp-loader-portal::before{content:"LINUX DO READER";position:absolute;left:84px;top:68px;width:112px;height:32px;display:grid;place-items:center;z-index:1;white-space:nowrap;
- color:var(--ldp-loader-accent);font:820 10px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.13em;text-align:center;
- border-top:3px solid var(--ldp-loader-brand);border-bottom:1px solid color-mix(in srgb,var(--primary,#222) 30%,transparent);
- animation:ldp-loader-topic-mark 3.8s ease-in-out infinite;}
- .ldp-loader-portal::after{content:"";position:absolute;left:139px;top:25px;width:2px;height:121px;opacity:.55;
- background:linear-gradient(180deg,transparent,var(--ldp-loader-brand) 8% 34%,transparent 34% 66%,var(--ldp-loader-brand) 66% 92%,transparent);animation:ldp-loader-topic-spine 3.8s ease-in-out infinite;}
- .ldp-loader-portal span{--side:-1;left:22px;top:calc(25px + var(--i) * 23px);width:91px;height:18px;transform-origin:right center;
- border-bottom:1px solid color-mix(in srgb,var(--primary,#222) 27%,transparent);
- animation:ldp-loader-topic-open 3.8s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--i) * -.16s);}
- .ldp-loader-portal span:nth-child(even){--side:1;left:167px;transform-origin:left center;}
- .ldp-loader-portal span::before{content:attr(data-copy);position:absolute;inset:auto 0 4px;color:color-mix(in srgb,var(--primary,#222) 56%,transparent);
- font:750 8px/1 ui-monospace,monospace;letter-spacing:.12em;text-align:right;}
- .ldp-loader-portal span:nth-child(even)::before{text-align:left;}
- .ldp-loader-portal span:nth-child(n+3)::after{content:"";position:absolute;right:0;bottom:5px;width:calc(38px + var(--i) * 5px);height:1px;background:color-mix(in srgb,var(--primary,#222) 18%,transparent);}
- .ldp-loader-portal span:nth-child(even)::after{left:0;right:auto;}
- @keyframes ldp-loader-topic-mark{0%,100%{transform:scale(.94);opacity:.55}45%,70%{transform:scale(1);opacity:1}}
- @keyframes ldp-loader-topic-spine{0%,12%{transform:scaleY(.08);opacity:0}38%,78%{transform:scaleY(1);opacity:1}100%{transform:scaleY(.32);opacity:0}}
- @keyframes ldp-loader-topic-open{0%,12%{transform:translate3d(calc(var(--side) * 24px),0,0) scaleX(.12);opacity:0}40%,72%{transform:translate3d(0,0,0) scaleX(1);opacity:.94}100%{transform:translate3d(calc(var(--side) * -8px),0,0) scaleX(.72);opacity:0}}
-
- .ldp-loading-visual[data-animation="constellation"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 78%,var(--tertiary,#47855f));}
- .ldp-loader-thread-index::before{content:"REPLY / THREAD";position:absolute;left:32px;top:16px;color:color-mix(in srgb,var(--primary,#222) 45%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.17em;}
- .ldp-loader-thread-index::after{content:"";position:absolute;left:27px;top:38px;width:2px;height:108px;transform-origin:center top;border-radius:99px;
- background:linear-gradient(180deg,transparent,var(--ldp-loader-brand) 12% 70%,transparent);box-shadow:0 0 10px color-mix(in srgb,var(--ldp-loader-brand) 20%,transparent);
- animation:ldp-loader-thread-caret 5.2s cubic-bezier(.65,0,.35,1) infinite;}
- .ldp-loader-thread-index span{left:calc(38px + var(--depth) * 26px);top:calc(38px + var(--i) * 21px);width:calc(207px - var(--depth) * 25px);height:17px;
- border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 17%,transparent);color:color-mix(in srgb,var(--primary,#222) 57%,var(--ldp-loader-accent));
- animation:ldp-loader-thread-row 5.2s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .08s);}
- .ldp-loader-thread-index span::before{content:attr(data-user);position:absolute;left:0;bottom:4px;font:650 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.055em;}
- .ldp-loader-thread-index span::after{content:attr(data-floor);position:absolute;right:0;bottom:4px;font:720 8px/1 ui-monospace,monospace;letter-spacing:.07em;}
- .ldp-loader-thread-index span:first-child{border-color:color-mix(in srgb,var(--ldp-loader-brand) 72%,transparent);color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-brand));}
- .ldp-loader-thread-index span:first-child::before{padding:2px 6px;background:var(--ldp-loader-brand);color:#202124;clip-path:polygon(3% 12%,97% 0,100% 87%,0 100%);}
- @keyframes ldp-loader-thread-caret{0%,9%{transform:scaleY(.24);opacity:.18}24%{opacity:.92}64%{transform:scaleY(1);opacity:.86}76%,100%{transform:scaleY(.46);opacity:.18}}
- @keyframes ldp-loader-thread-row{0%,8%{transform:translate3d(-9px,0,0);opacity:.24}24%,72%{transform:translate3d(0,0,0);opacity:.96}88%,100%{transform:translate3d(4px,0,0);opacity:.38}}
-
- .ldp-loading-visual[data-animation="corridor"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 64%,var(--tertiary,#47855f));}
- .ldp-loader-floor-reel{background:linear-gradient(90deg,transparent 0 65px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 66px,transparent 67px);}
- .ldp-loader-floor-reel::before{content:"FLOOR / TIME";position:absolute;left:28px;top:16px;color:color-mix(in srgb,var(--primary,#222) 44%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.17em;}
- .ldp-loader-floor-reel::after{content:"";position:absolute;left:57px;top:40px;width:172px;height:2px;transform-origin:left center;
- background:linear-gradient(90deg,var(--ldp-loader-brand),color-mix(in srgb,var(--ldp-loader-brand) 36%,transparent) 58%,transparent);
- box-shadow:0 0 9px color-mix(in srgb,var(--ldp-loader-brand) 18%,transparent);animation:ldp-loader-reel-window 4.9s cubic-bezier(.65,0,.35,1) infinite;}
- .ldp-loader-floor-reel span{left:68px;top:calc(33px + var(--i) * 25px);width:162px;height:22px;border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 15%,transparent);
- color:color-mix(in srgb,var(--primary,#222) 52%,var(--ldp-loader-accent));animation:ldp-loader-reel-row 4.9s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .06s);}
- .ldp-loader-floor-reel span::before{content:attr(data-floor);position:absolute;left:9px;bottom:4px;font:740 11px/1 ui-monospace,monospace;letter-spacing:.09em;}
- .ldp-loader-floor-reel span::after{content:attr(data-time);position:absolute;right:5px;bottom:4px;font:620 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.11em;}
- .ldp-loader-floor-reel span:nth-child(3){color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-brand));border-color:color-mix(in srgb,var(--ldp-loader-brand) 64%,transparent);}
- .ldp-loader-floor-reel span:nth-child(3)::before{font-size:15px;}
- @keyframes ldp-loader-reel-window{0%,8%{transform:translate3d(0,0,0) scaleX(.18);opacity:.16}22%{opacity:.95}66%{transform:translate3d(0,100px,0) scaleX(1);opacity:.86}78%,100%{transform:translate3d(0,100px,0) scaleX(.48);opacity:.16}}
- @keyframes ldp-loader-reel-row{0%,8%{transform:translate3d(0,5px,0);opacity:.34}24%,74%{transform:translate3d(0,0,0);opacity:.92}88%,100%{transform:translate3d(0,-3px,0);opacity:.42}}
-
- .ldp-loading-visual[data-animation="typewave"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 70%,var(--tertiary,#47855f));}
- .ldp-loader-typewave{overflow:hidden;background:linear-gradient(90deg,transparent 49.7%,color-mix(in srgb,var(--ldp-loader-accent) 10%,transparent) 49.8% 50.2%,transparent 50.3%);}
- .ldp-loader-typewave::before{content:"MARKDOWN → RENDERED";position:absolute;left:34px;top:20px;color:color-mix(in srgb,var(--primary,#222) 44%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.13em;}
- .ldp-loader-typewave::after{content:"";position:absolute;left:72px;top:38px;width:2px;height:103px;border-radius:99px;background:linear-gradient(180deg,transparent,var(--ldp-loader-brand),transparent);
- box-shadow:0 0 12px color-mix(in srgb,var(--ldp-loader-brand) 34%,transparent);animation:ldp-loader-markdown-scan 4.8s cubic-bezier(.65,0,.35,1) infinite;}
- .ldp-loader-typewave span{height:25px;box-sizing:border-box;will-change:transform,opacity;}
- .ldp-loader-typewave span:nth-child(-n+3){left:31px;top:calc(49px + var(--row) * 34px);width:96px;color:color-mix(in srgb,var(--primary,#222) 68%,var(--ldp-loader-accent));
- font:700 10px/25px ui-monospace,monospace;border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 18%,transparent);
- animation:ldp-loader-markdown-source 4.8s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--row) * .12s);}
- .ldp-loader-typewave span:nth-child(-n+3)::before{content:attr(data-source);}
- .ldp-loader-typewave span:nth-child(n+4){left:157px;top:calc(47px + var(--row) * 34px);width:92px;color:color-mix(in srgb,var(--primary,#222) 78%,var(--ldp-loader-accent));
- font:650 11px/25px ui-sans-serif,system-ui,sans-serif;animation:ldp-loader-markdown-render 4.8s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--row) * .12s);}
- .ldp-loader-typewave span:nth-child(n+4)::before{content:attr(data-render);}
- .ldp-loader-typewave span:nth-child(4){height:28px;font-size:17px;font-weight:790;line-height:28px;border-bottom:2px solid color-mix(in srgb,var(--ldp-loader-brand) 78%,transparent);}
- .ldp-loader-typewave span:nth-child(5){font-weight:820;background:linear-gradient(transparent 59%,color-mix(in srgb,var(--ldp-loader-brand) 45%,transparent) 59% 91%,transparent 91%);}
- .ldp-loader-typewave span:nth-child(6){border-left:3px solid var(--ldp-loader-brand);padding-left:9px;color:color-mix(in srgb,var(--primary,#222) 61%,var(--ldp-loader-accent));font-style:italic;}
- @keyframes ldp-loader-markdown-scan{0%,10%{transform:translateX(0) scaleY(.5);opacity:0}24%{opacity:1}64%{transform:translateX(135px) scaleY(1);opacity:1}82%,100%{transform:translateX(135px) scaleY(.72);opacity:0}}
- @keyframes ldp-loader-markdown-source{0%,8%{transform:translate3d(-15px,0,0);opacity:0}22%,40%{transform:translate3d(0,0,0);opacity:1}58%,100%{transform:translate3d(26px,0,0);opacity:0}}
- @keyframes ldp-loader-markdown-render{0%,45%{transform:translate3d(-24px,4px,0) scale(.94);opacity:0}62%,84%{transform:translate3d(0,0,0) scale(1);opacity:1}100%{transform:translate3d(7px,-2px,0) scale(.98);opacity:0}}
-
- .ldp-loading-visual[data-animation="crystal"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 58%,var(--tertiary,#47855f));}
- .ldp-loader-cache-lanes{background:linear-gradient(180deg,transparent 50px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 51px,transparent 52px 83px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 84px,transparent 85px 116px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 117px,transparent 118px);}
- .ldp-loader-cache-lanes::before{content:"CACHE\\A REUSE";position:absolute;left:111px;top:45px;width:58px;height:79px;display:grid;place-items:center;z-index:2;white-space:pre;
- border-left:1px solid color-mix(in srgb,var(--ldp-loader-accent) 30%,transparent);border-right:1px solid color-mix(in srgb,var(--ldp-loader-accent) 30%,transparent);
- background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--secondary,#fff) 94%,var(--ldp-loader-brand)) 28% 72%,transparent);
- color:color-mix(in srgb,var(--primary,#222) 72%,var(--ldp-loader-accent));font:790 8px/1.65 ui-monospace,monospace;letter-spacing:.12em;text-align:center;}
- .ldp-loader-cache-lanes::after{content:"MISS HIT";position:absolute;left:31px;top:23px;width:218px;color:color-mix(in srgb,var(--primary,#222) 43%,transparent);
- font:740 7px/1 ui-monospace,monospace;letter-spacing:.12em;white-space:pre;}
- .ldp-loader-cache-lanes span{left:34px;top:calc(49px + var(--lane) * 33px);z-index:1;width:13px;height:3px;border-radius:99px;background:color-mix(in srgb,var(--ldp-loader-accent) 74%,transparent);
- box-shadow:0 0 0 3px color-mix(in srgb,var(--ldp-loader-accent) 5%,transparent);animation:ldp-loader-cache-packet 4.8s cubic-bezier(.65,0,.35,1) infinite both;animation-delay:calc(var(--i) * -.4s);}
- .ldp-loader-cache-lanes span:nth-child(3n+2){animation-direction:reverse;}
- .ldp-loader-cache-lanes span:nth-child(4n+1){width:16px;background:var(--ldp-loader-brand);box-shadow:0 0 0 4px color-mix(in srgb,var(--ldp-loader-brand) 10%,transparent);}
- .ldp-loader-cache-lanes span:nth-child(4n+3){width:7px;opacity:.72;}
- @keyframes ldp-loader-cache-packet{0%,5%{transform:translate3d(0,0,0) scaleX(.72);opacity:.16}14%{opacity:.9}43%{transform:translate3d(96px,0,0) scaleX(1);opacity:1}54%{transform:translate3d(110px,0,0) scaleX(.82);opacity:.72}88%{transform:translate3d(196px,0,0) scaleX(1);opacity:.74}100%{transform:translate3d(196px,0,0) scaleX(.72);opacity:.16}}
-
- .ldp-loading-visual[data-animation="marginalia"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 72%,var(--tertiary,#47855f));}
- .ldp-loader-marginalia::before{content:"只看楼主";position:absolute;left:27px;top:18px;color:color-mix(in srgb,var(--primary,#222) 66%,var(--ldp-loader-accent));font:800 9px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.18em;}
- .ldp-loader-marginalia::after{content:"";position:absolute;left:23px;top:39px;width:3px;height:106px;border-radius:99px;background:linear-gradient(180deg,var(--ldp-loader-brand),transparent);animation:ldp-loader-op-scan 4.2s cubic-bezier(.65,0,.35,1) infinite;}
- .ldp-loader-marginalia span{left:41px;top:calc(36px + var(--i) * 24px);width:203px;height:18px;border-bottom:1px solid color-mix(in srgb,var(--primary,#222) 19%,transparent);
- color:color-mix(in srgb,var(--primary,#222) 56%,transparent);animation:ldp-loader-op-muted 4.2s ease-in-out infinite;animation-delay:calc(var(--i) * -.13s);}
- .ldp-loader-marginalia span::before{content:attr(data-user);position:absolute;left:0;bottom:4px;font:750 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.08em;}
- .ldp-loader-marginalia span::after{content:attr(data-floor);position:absolute;right:0;bottom:4px;font:700 8px/1 ui-monospace,monospace;}
- .ldp-loader-marginalia span[data-user="OP"]{border-color:color-mix(in srgb,var(--ldp-loader-brand) 68%,transparent);color:color-mix(in srgb,var(--primary,#222) 88%,var(--ldp-loader-brand));animation-name:ldp-loader-op-focus;}
- .ldp-loader-marginalia span[data-user="OP"]::before{color:#202124;background:var(--ldp-loader-brand);padding:2px 5px;clip-path:polygon(4% 9%,96% 0,100% 87%,0 100%);}
- @keyframes ldp-loader-op-scan{0%,8%{transform:translateY(0) scaleY(.3);opacity:0}35%{opacity:1}84%,100%{transform:translateY(94px) scaleY(1);opacity:0}}
- @keyframes ldp-loader-op-muted{0%,20%{transform:translateX(0);opacity:.62}48%,76%{transform:translateX(18px);opacity:.12}100%{transform:translateX(25px);opacity:0}}
- @keyframes ldp-loader-op-focus{0%,16%{transform:translateX(-12px);opacity:0}40%,78%{transform:translateX(0);opacity:1}100%{transform:translateX(7px);opacity:0}}
-
- .ldp-loading-visual[data-animation="chapters"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 66%,var(--tertiary,#47855f));}
- .ldp-loader-index-fan::before{content:"TOPIC / INDEX";position:absolute;left:28px;top:18px;color:color-mix(in srgb,var(--primary,#222) 45%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.17em;}
- .ldp-loader-index-fan::after{content:"";position:absolute;left:30px;top:50px;width:217px;height:2px;transform-origin:left center;
- background:linear-gradient(90deg,var(--ldp-loader-brand),color-mix(in srgb,var(--ldp-loader-brand) 35%,transparent),transparent);animation:ldp-loader-index-rule 5s cubic-bezier(.16,1,.3,1) infinite;}
- .ldp-loader-index-fan span{left:calc(29px + var(--i) * 45px);top:calc(40px + var(--i) * 15px);width:38px;height:67px;color:color-mix(in srgb,var(--primary,#222) 62%,var(--ldp-loader-accent));
- border-left:1px solid color-mix(in srgb,var(--ldp-loader-accent) 19%,transparent);transform-origin:left bottom;animation:ldp-loader-index-leaf 5s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .075s);}
- .ldp-loader-index-fan span::before{content:attr(data-index);position:absolute;left:7px;top:1px;font:700 14px/1 ui-serif,"Songti SC","Noto Serif CJK SC",serif;letter-spacing:.03em;}
- .ldp-loader-index-fan span::after{content:attr(data-tag);position:absolute;left:7px;bottom:5px;white-space:nowrap;font:650 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.1em;}
- .ldp-loader-index-fan span:first-child{border-color:var(--ldp-loader-brand);color:color-mix(in srgb,var(--primary,#222) 78%,var(--ldp-loader-brand));}
- .ldp-loader-index-fan span:first-child::before{font-size:18px;font-weight:760;}
- @keyframes ldp-loader-index-rule{0%,8%{transform:rotate(14deg) scaleX(.08);opacity:.18}30%,74%{transform:rotate(14deg) scaleX(1);opacity:.9}88%,100%{transform:rotate(14deg) scaleX(.58);opacity:.2}}
- @keyframes ldp-loader-index-leaf{0%,8%{transform:translate3d(-7px,14px,0) rotate(-3deg);opacity:.2}26%,74%{transform:translate3d(0,0,0) rotate(0);opacity:.96}88%,100%{transform:translate3d(2px,-3px,0) rotate(.6deg);opacity:.38}}
-
- .ldp-loading-visual[data-animation="quoteecho"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 82%,var(--secondary,#fff));}
- .ldp-loader-quoteecho::before{content:"LINUX DO";position:absolute;left:87px;top:27px;width:106px;color:color-mix(in srgb,var(--primary,#222) 46%,transparent);font:850 10px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.26em;text-align:center;}
- .ldp-loader-quoteecho::after{content:"";position:absolute;left:54px;right:54px;top:117px;height:3px;transform-origin:center;background:linear-gradient(90deg,transparent 0,var(--ldp-loader-brand) 18% 82%,transparent 100%);animation:ldp-loader-creed-line 5.6s cubic-bezier(.65,0,.35,1) infinite;}
- .ldp-loader-quoteecho span{left:calc(35px + var(--i) * 57px);top:62px;width:50px;height:32px;display:grid;place-items:center;color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-accent));
- font:750 17px/1 ui-serif,"Songti SC","Noto Serif CJK SC",serif;letter-spacing:.12em;animation:ldp-loader-creed-word 5.6s cubic-bezier(.16,1,.3,1) infinite both;}
- .ldp-loader-quoteecho span:nth-child(2){animation-name:ldp-loader-creed-word-2;}
- .ldp-loader-quoteecho span:nth-child(3){animation-name:ldp-loader-creed-word-3;}
- .ldp-loader-quoteecho span:nth-child(4){animation-name:ldp-loader-creed-word-4;}
- .ldp-loader-quoteecho span::before{content:attr(data-word);}
- .ldp-loader-quoteecho span::after{content:"";position:absolute;left:23px;bottom:-15px;width:4px;height:4px;border-radius:50%;background:var(--ldp-loader-brand);}
- @keyframes ldp-loader-creed-line{0%,26%{transform:scaleX(.02);opacity:0}40%,78%{transform:scaleX(1);opacity:1}86%,100%{transform:scaleX(.02);opacity:0}}
- @keyframes ldp-loader-creed-word{0%,8%{transform:translateY(11px) scale(.95);opacity:0}28%,72%{transform:translateY(0) scale(1);opacity:1}80%,100%{transform:translateY(-5px) scale(.98);opacity:0}}
- @keyframes ldp-loader-creed-word-2{0%,14%{transform:translateY(11px) scale(.95);opacity:0}34%,74%{transform:translateY(0) scale(1);opacity:1}82%,100%{transform:translateY(-5px) scale(.98);opacity:0}}
- @keyframes ldp-loader-creed-word-3{0%,20%{transform:translateY(11px) scale(.95);opacity:0}40%,76%{transform:translateY(0) scale(1);opacity:1}84%,100%{transform:translateY(-5px) scale(.98);opacity:0}}
- @keyframes ldp-loader-creed-word-4{0%,26%{transform:translateY(11px) scale(.95);opacity:0}46%,78%{transform:translateY(0) scale(1);opacity:1}86%,100%{transform:translateY(-5px) scale(.98);opacity:0}}
-
- .ldp-loading-visual[data-animation="footnotes"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 62%,var(--tertiary,#47855f));}
- .ldp-loader-inbox-rain::before{content:"NEW / REPLIES";position:absolute;left:28px;top:18px;color:color-mix(in srgb,var(--primary,#222) 48%,var(--ldp-loader-accent));
- font:790 8px/1 ui-monospace,monospace;letter-spacing:.16em;border-left:2px solid var(--ldp-loader-brand);padding-left:9px;}
- .ldp-loader-inbox-rain::after{content:"";position:absolute;left:30px;top:124px;width:220px;height:1px;transform-origin:left center;
- background:linear-gradient(90deg,color-mix(in srgb,var(--ldp-loader-accent) 35%,transparent),color-mix(in srgb,var(--ldp-loader-brand) 68%,transparent) 74%,transparent);
- animation:ldp-loader-inbox-rule 5.2s cubic-bezier(.16,1,.3,1) infinite;}
- .ldp-loader-inbox-rain span{left:var(--x);top:var(--y);width:37px;height:27px;display:grid;place-items:center;color:color-mix(in srgb,var(--primary,#222) 74%,var(--ldp-loader-accent));
- border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 29%,transparent);font:740 9px/1 ui-monospace,monospace;animation:ldp-loader-inbox-note 5.2s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .1s);}
- .ldp-loader-inbox-rain span::before{content:attr(data-floor);}
- .ldp-loader-inbox-rain span::after{content:"";position:absolute;right:0;bottom:-2px;width:4px;height:4px;border-radius:50%;background:var(--ldp-loader-brand);box-shadow:0 0 0 4px color-mix(in srgb,var(--ldp-loader-brand) 9%,transparent);}
- .ldp-loader-inbox-rain span:last-child{color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-brand));border-color:var(--ldp-loader-brand);}
- @keyframes ldp-loader-inbox-rule{0%,8%{transform:rotate(-18deg) scaleX(.08);opacity:.16}28%,76%{transform:rotate(-18deg) scaleX(1);opacity:.9}90%,100%{transform:rotate(-18deg) scaleX(.62);opacity:.22}}
- @keyframes ldp-loader-inbox-note{0%,8%{transform:translate3d(0,var(--drop),0) scale(.96);opacity:.18}28%,76%{transform:translate3d(0,0,0) scale(1);opacity:1}90%,100%{transform:translate3d(3px,5px,0) scale(.98);opacity:.34}}
-
- .ldp-loading-visual[data-animation="inkverse"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 74%,var(--tertiary,#47855f));}
- .ldp-loader-inkverse::before{content:"DO";position:absolute;left:117px;top:57px;width:46px;height:46px;display:grid;place-items:center;z-index:1;
- border-radius:43% 57% 61% 39% / 39% 44% 56% 61%;background:radial-gradient(circle at 72% 27%,#202124 0 3px,transparent 3.5px),var(--ldp-loader-brand);
- color:#202124;font:900 12px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.12em;text-indent:.12em;
- box-shadow:-8px 9px 0 color-mix(in srgb,var(--ldp-loader-brand) 13%,transparent),0 0 0 1px color-mix(in srgb,#202124 12%,transparent);animation:ldp-loader-action-core 4.4s ease-in-out infinite;}
- .ldp-loader-inkverse::after{content:"";position:absolute;left:48px;top:25px;width:184px;height:120px;border-radius:50%;
- background:conic-gradient(from 18deg,transparent 0 11%,color-mix(in srgb,var(--ldp-loader-accent) 29%,transparent) 11% 27%,transparent 27% 41%,color-mix(in srgb,var(--ldp-loader-accent) 22%,transparent) 41% 67%,transparent 67% 80%,var(--ldp-loader-brand) 80% 83%,transparent 83% 100%);
- -webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 1px),#000 calc(100% - 1px));mask:radial-gradient(farthest-side,transparent calc(100% - 1px),#000 calc(100% - 1px));
- animation:ldp-loader-action-orbit 16s linear infinite;}
- .ldp-loader-inkverse span{left:var(--x);top:var(--y);width:56px;height:21px;display:grid;place-items:center;color:color-mix(in srgb,var(--primary,#222) 72%,var(--ldp-loader-accent));
- border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 18%,transparent);font:760 9px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.06em;
- animation:ldp-loader-action-label 4.4s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--i) * -.31s);}
- .ldp-loader-inkverse span::before{content:attr(data-action);}
- .ldp-loader-inkverse span::after{content:"";position:absolute;left:25px;top:25px;width:3px;height:3px;border-radius:50%;background:var(--ldp-loader-brand);
- animation:ldp-loader-action-pulse 4.4s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--i) * -.31s);}
- .ldp-loader-inkverse span:nth-child(1){--x:91px;--y:12px;--mx:29px;--my:25px;--dx:21px;--dy:58px}
- .ldp-loader-inkverse span:nth-child(2){--x:207px;--y:50px;--mx:-43px;--my:24px;--dx:-95px;--dy:20px}
- .ldp-loader-inkverse span:nth-child(3){--x:139px;--y:137px;--mx:-24px;--my:-36px;--dx:-27px;--dy:-67px}
- .ldp-loader-inkverse span:nth-child(4){--x:17px;--y:94px;--mx:47px;--my:-25px;--dx:95px;--dy:-24px}
- @keyframes ldp-loader-action-core{0%,100%{transform:scale(.9);opacity:.62}48%{transform:scale(1.08);opacity:1}}
- @keyframes ldp-loader-action-orbit{0%{transform:rotate(-8deg) scale(.98)}50%{transform:rotate(172deg) scale(1.02)}100%{transform:rotate(352deg) scale(.98)}}
- @keyframes ldp-loader-action-label{0%,16%{transform:translateY(5px) scale(.92);opacity:.22}38%,80%{transform:translateY(0) scale(1);opacity:.9}100%{transform:translateY(-3px) scale(.96);opacity:.22}}
- @keyframes ldp-loader-action-pulse{0%,18%{transform:translate3d(0,0,0) scale(.65);opacity:0}38%{opacity:1}62%{transform:translate3d(var(--mx),var(--my),0) scale(1);opacity:.9}82%{transform:translate3d(var(--dx),var(--dy),0) scale(1.45);opacity:1}100%{transform:translate3d(var(--dx),var(--dy),0) scale(.35);opacity:0}}
-
- @keyframes ldp-loader-reduced-breathe{0%,100%{opacity:.76}50%{opacity:1}}
- @container ldp-reader (max-width:380px){
- .ldp-loading-visual{transform:scale(.88);transform-origin:center center;}
- }
- @media (prefers-reduced-motion: reduce){
- :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner,
- :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner::before,
- :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner::after,
- :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner span,
- :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner span::before,
- :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner span::after{animation:none!important;will-change:auto!important;}
- :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner{animation:ldp-loader-reduced-breathe 2.5s ease-in-out infinite!important;}
- }
-
- /* 楼层区分隔 + 左上角标题 */
- .ldp-comments-header{display:flex;align-items:center;gap:8px;
- margin:6px 0 2px;padding-top:14px;border-top:var(--ldp-divider-line-emphasis-width,2px) solid var(--ldp-divider-line-color,#e5e5e5);
- font-size:var(--ldp-font-lg,15px);font-weight:700;letter-spacing:.5px;}
- .ldp-comments-count{font-size:var(--ldp-font-ui,12px);font-weight:500;opacity:.6;}
- .ldp-topic-presence{display:inline-flex;align-items:center;gap:7px;min-width:0;max-width:min(55%,18rem);
- margin-left:auto;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);font-weight:500;letter-spacing:0;}
- .ldp-topic-presence-avatars{display:flex;flex:none;align-items:center;padding-left:4px;}
- .ldp-topic-presence-user{display:inline-flex;margin-left:-4px;line-height:0;}
- .ldp-topic-presence-user:first-child{margin-left:0;}
- .ldp-topic-presence-avatar{box-sizing:border-box;width:22px;height:22px;border:2px solid var(--secondary,#fff);
- border-radius:50%;object-fit:cover;background:var(--primary-low,#ddd);}
- .ldp-topic-presence-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-comments{--ldp-nested-content-scale:.97;--ldp-nested-component-scale:.95;--ldp-nested-component-gap:7.6px;padding-top:4px;}
+/* ldp-runtime-shell: migrated from main.js; keep before reader component styles. */
+:where([class^="ldp-"],[class*=" ldp-"])[hidden]{display:none!important;}
+ html.ldp-scroll-lock,html.ldp-scroll-lock body{overflow:hidden!important;}
+ html[data-ldp-font-rendering="builtin"]{
+ --ldp-font-rendering-stroke:var(--ldp-font-rendering-stroke-runtime,.015px currentcolor);
+ --ldp-font-rendering-shadow:var(--ldp-font-rendering-shadow-runtime,0 0 .75px #7c7c7cdd);}
+ html > .ldp-reader-portal-host[data-ldp-font-rendering="builtin"],
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox)[data-ldp-font-rendering="builtin"],
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox)[data-ldp-font-rendering="builtin"]
+ :where(*):not(i[class],pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]),
+ html.ldp-reader-open[data-ldp-font-rendering="builtin"]
+ :where([data-ldp-reader-composer-root],[data-ldp-reader-composer-overflow],#reply-control),
+ html.ldp-reader-open[data-ldp-font-rendering="builtin"]
+ :where([data-ldp-reader-composer-root],[data-ldp-reader-composer-overflow],#reply-control)
+ :where(*):not(i[class],pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]),
+ html[data-ldp-font-rendering="builtin"][data-ldp-font-rendering-host="true"] body,
+ html[data-ldp-font-rendering="builtin"][data-ldp-font-rendering-host="true"] body
+ :where(*):not(i[class],pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]){
+ font-optical-sizing:auto;font-kerning:auto;text-rendering:optimizeLegibility;
+ -webkit-text-stroke:var(--ldp-font-rendering-stroke);text-shadow:var(--ldp-font-rendering-shadow);
+ -webkit-font-smoothing:var(--ldp-font-webkit-smoothing);-moz-osx-font-smoothing:var(--ldp-font-moz-smoothing);}
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox)[data-ldp-font-rendering="builtin"]
+ :is(pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]),
+ html.ldp-reader-open[data-ldp-font-rendering="builtin"]
+ :where([data-ldp-reader-composer-root],[data-ldp-reader-composer-overflow],#reply-control)
+ :is(pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]),
+ html[data-ldp-font-rendering="builtin"][data-ldp-font-rendering-host="true"] body
+ :is(pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]){
+ -webkit-text-stroke:0 transparent;text-shadow:none;}
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox),
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *,
+ html.ldp-reader-open body > [data-ldp-reader-composer-root],
+ html.ldp-reader-open [data-ldp-reader-composer-overflow],
+ html.ldp-reader-open #reply-control,
+ html.ldp-reader-open #reply-control *{
+ scrollbar-width:thin;
+ scrollbar-color:color-mix(in srgb,var(--primary-medium,#888) 34%,transparent) transparent;}
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar,
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar,
+ html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar,
+ html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar,
+ html.ldp-reader-open #reply-control::-webkit-scrollbar,
+ html.ldp-reader-open #reply-control *::-webkit-scrollbar{width:6px;height:6px;}
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar-track,
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar-track,
+ html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar-track,
+ html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar-track,
+ html.ldp-reader-open #reply-control::-webkit-scrollbar-track,
+ html.ldp-reader-open #reply-control *::-webkit-scrollbar-track{background:transparent;}
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar-thumb,
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar-thumb,
+ html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar-thumb,
+ html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar-thumb,
+ html.ldp-reader-open #reply-control::-webkit-scrollbar-thumb,
+ html.ldp-reader-open #reply-control *::-webkit-scrollbar-thumb{
+ border-radius:999px;background:color-mix(in srgb,var(--primary-medium,#888) 34%,transparent);}
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar-thumb:hover,
+ :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar-thumb:hover,
+ html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar-thumb:hover,
+ html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar-thumb:hover,
+ html.ldp-reader-open #reply-control::-webkit-scrollbar-thumb:hover,
+ html.ldp-reader-open #reply-control *::-webkit-scrollbar-thumb:hover{
+ background:color-mix(in srgb,var(--primary-medium,#888) 48%,transparent);}
+ html > .ldp-reader-portal-host{display:contents!important;color:var(--ldp-interface-font-color,inherit);
+ font-family:var(--ldp-interface-font-family,inherit);font-weight:var(--ldp-interface-font-weight,400);}
+ html.ldp-route-takeover body > :not(.ldp-native-boost-menu):not(#reply-control):not(.composer-popup):not(#d-menu-portals):not(.fk-d-menu):not(.d-modal):not(.dialog-holder):not(#user-card):not(#group-card):not(.user-card):not(.group-card):not(.user-card-container){visibility:hidden!important;}
+ html.ldp-composer-host-isolated body > :not(.ldp-native-boost-menu):not(#reply-control):not(.composer-popup):not(#d-menu-portals):not(.fk-d-menu):not(.d-modal):not(.dialog-holder):not(#user-card):not(#group-card):not(.user-card):not(.group-card):not(.user-card-container){visibility:hidden!important;}
+ html.ldp-native-reader-trigger-visible .d-header-icons{margin-inline-end:30px!important;}
+ html.ldp-native-reader-trigger-visible .d-header-icons .current-user{position:relative!important;}
+ .ldp-native-reader-trigger-item{position:absolute;left:100%;top:50%;z-index:2147482999;display:flex;align-items:center;
+ width:20px;height:20px;margin-inline-start:10px;transform:translateY(-50%);background:transparent;}
+ .ldp-native-reader-trigger{width:20px;height:20px;display:inline-flex;align-items:center;
+ justify-content:center;box-sizing:border-box;padding:0;border:0;border-radius:0;
+ background:transparent;color:var(--header_primary-medium,var(--primary-medium,#777));
+ font:inherit;line-height:1;cursor:pointer;}
+ .ldp-native-reader-trigger:hover{background:transparent;color:var(--primary,#222);}
+ .ldp-overlay{position:fixed;inset:0;z-index:2147483000;display:flex;
+ align-items:center;justify-content:center;background:transparent;pointer-events:none;}
+ .ldp-settings-layer{position:absolute;inset:0;z-index:2147483605;
+ container:ldp-reader / inline-size;pointer-events:none;}
+ .ldp-overlay.ldp-fullpage{align-items:stretch;justify-content:stretch;
+ background:var(--secondary,#fff);pointer-events:auto;}
+ .ldp-native-boost-menu{position:fixed;z-index:2147483608;width:min(340px,calc(100vw - 16px));
+ box-sizing:border-box;padding:6px;border:1px solid var(--primary-low,#d8d8d8);border-radius:8px;
+ background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 10px 30px rgba(0,0,0,.22);pointer-events:auto;}
+ .ldp-native-boost-menu .discourse-boosts__input-container{display:flex;align-items:center;gap:5px;}
+ .ldp-native-boost-menu .discourse-boosts__input{position:relative;flex:1;min-width:0;box-sizing:border-box;
+ min-height:30px;max-height:60px;overflow:auto;padding:4px 7px;border:1px solid var(--primary-low,#ccc);
+ border-radius:6px;background:var(--secondary,#fff);color:inherit;font-size:var(--ldp-font-base,13px);outline:none;white-space:pre-wrap;overflow-wrap:anywhere;}
+ .ldp-native-boost-menu .discourse-boosts__input:focus{border-color:var(--tertiary,#47855f);
+ box-shadow:0 0 0 1px var(--tertiary,#47855f);}
+ .ldp-native-boost-menu .discourse-boosts__input:empty::before{content:attr(data-placeholder);
+ position:absolute;inset-inline:7px;top:50%;transform:translateY(-50%);
+ color:var(--primary-medium,#888);pointer-events:none;}
+ .ldp-native-boost-menu .discourse-boosts__input img.emoji{width:16px;height:16px;margin:0 .05em;vertical-align:-.15em;}
+ .ldp-native-boost-count{min-width:28px;font-size:var(--ldp-font-xs,10px);text-align:right;color:var(--primary-medium,#777);}
+ .ldp-native-boost-menu .discourse-boosts__emoji-btn,.ldp-native-boost-menu .discourse-boosts__submit,
+ .ldp-native-boost-menu .discourse-boosts__cancel{
+ width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;flex:none;
+ border:0;border-radius:6px;cursor:pointer;}
+ .ldp-native-boost-menu .discourse-boosts__emoji-btn{background:transparent;color:var(--primary-medium,#777);}
+ .ldp-native-boost-menu .discourse-boosts__emoji-btn:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
+ .ldp-native-boost-menu .discourse-boosts__submit{background:var(--success,#47855f);color:#fff;}
+ .ldp-native-boost-menu .discourse-boosts__cancel{background:var(--danger-low,#f5d7d7);color:var(--danger,#c44);}
+ .ldp-native-boost-menu button:disabled{cursor:default;opacity:.5;}
+ .ldp-native-boost-error{display:block;margin-top:5px;font-size:var(--ldp-font-sm,11px);color:var(--danger,#c44);}
+ .ldp-native-boost-error:empty{display:none;}
+ .ldp-modal{position:relative;display:flex;flex-direction:column;
+ width:min(1080px,94vw);height:86vh;
+ border-radius:12px;overflow:hidden;font-family:var(--ldp-interface-font-family,inherit);
+ font-size:var(--ldp-font-md,14px);font-weight:var(--ldp-interface-font-weight,400);
+ line-height:1.65;background:var(--secondary,#fff);color:var(--ldp-interface-font-color,var(--primary,#222));
+ box-shadow:0 8px 24px rgba(0,0,0,.18);container:ldp-reader / inline-size;pointer-events:auto;}
+ .ldp-fullpage .ldp-modal{width:100vw;height:100vh;border-radius:0;box-shadow:none;}
+ html.ldp-reader-workspace{--ldp-reader-workspace-width:520px;--ldp-reader-host-gutter:15px;}
+ html.ldp-reader-embedded-left,html.ldp-reader-embedded-right{overflow-x:hidden;scrollbar-width:none;}
+ html.ldp-reader-embedded-left::-webkit-scrollbar,html.ldp-reader-embedded-right::-webkit-scrollbar{display:none;}
+ html.ldp-reader-embedded-left #d-splash,html.ldp-reader-embedded-right #d-splash{
+ width:calc(100vw - var(--ldp-reader-workspace-width));}
+ html.ldp-reader-embedded-left #d-splash{left:var(--ldp-reader-workspace-width);}
+ html.ldp-reader-embedded-right #d-splash{left:0;}
+ html.ldp-reader-embedded-right body{box-sizing:border-box!important;width:calc(100vw - var(--ldp-reader-workspace-width))!important;
+ min-width:var(--ldp-reader-host-min-width,680px)!important;max-width:calc(100vw - var(--ldp-reader-workspace-width))!important;
+ overflow-x:clip!important;}
+ html.ldp-reader-embedded-left body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]),
+ html.ldp-reader-embedded-right body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){
+ box-sizing:border-box!important;min-width:var(--ldp-reader-host-min-width,680px)!important;
+ width:calc(100vw - var(--ldp-reader-workspace-width))!important;max-width:calc(100vw - var(--ldp-reader-workspace-width))!important;
+ margin-left:0!important;margin-right:0!important;transform-origin:top left;overflow-x:clip!important;
+ transition:width .24s cubic-bezier(.22,1,.36,1),transform .24s cubic-bezier(.22,1,.36,1),padding .24s cubic-bezier(.22,1,.36,1);}
+ html.ldp-reader-embedded-left body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){
+ transform:translateX(var(--ldp-reader-workspace-width))!important;padding-left:var(--ldp-reader-host-gutter)!important;padding-right:0!important;}
+ html.ldp-reader-embedded-right body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){
+ width:100%!important;max-width:100%!important;transform:translateX(0)!important;
+ padding-left:0!important;padding-right:var(--ldp-reader-host-gutter)!important;}
+ html.ldp-reader-host-anchor-restoring body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){transition:none!important;}
+ html.ldp-reader-embedded-left body > [data-ldp-reader-host-root="sidebar"]{
+ transform:translateX(calc(var(--ldp-reader-workspace-width) + var(--ldp-reader-host-gutter)))!important;}
+ html.ldp-reader-embedded-right body > [data-ldp-reader-host-root="sidebar"]{transform:translateX(0)!important;}
+ html.ldp-reader-workspace body > [data-ldp-reader-host-root="shell"] :is(#ember-app,#main-outlet,#main-outlet-wrapper,.list-container,.topic-list,.latest-topic-list,.discovery-list-container,.topic-list-body){
+ box-sizing:border-box!important;width:100%!important;min-width:0!important;max-width:100%!important;margin-left:0!important;margin-right:0!important;}
+ html.ldp-reader-workspace body > [data-ldp-reader-host-root="shell"] :is(.d-header,header){
+ box-sizing:border-box!important;left:0!important;right:auto!important;width:100%!important;max-width:100%!important;
+ border:0!important;box-shadow:none!important;}
+ html.ldp-reader-workspace body > [data-ldp-reader-host-root="shell"]{container:ldp-reader-host / inline-size;}
+ html.ldp-reader-workspace :is(#main-outlet,#main-outlet-wrapper,.list-container,.topic-list,.latest-topic-list,.discovery-list-container){box-sizing:border-box!important;min-width:0!important;max-width:100%!important;}
+ html.ldp-reader-workspace .topic-list{display:block!important;width:100%!important;}
+ html.ldp-reader-workspace .topic-list thead{display:none!important;}
+ html.ldp-reader-workspace .topic-list tbody,
+ html.ldp-reader-workspace .latest-topic-list,
+ html.ldp-reader-workspace .discovery-list-container .topic-list-body{
+ display:grid!important;width:100%!important;min-width:0!important;max-width:100%!important;overflow-x:clip;
+ grid-template-columns:minmax(0,1fr);grid-auto-rows:max-content;align-content:start;gap:6px;padding:8px;}
+ html.ldp-reader-workspace .topic-list tbody > tr.topic-list-item-separator{display:none!important;}
+ html.ldp-reader-workspace tr.topic-list-item,
+ html.ldp-reader-workspace .topic-list tbody > tr,
+ html.ldp-reader-workspace .topic-list-item,
+ html.ldp-reader-workspace .latest-topic-list-item{
+ display:grid!important;width:100%!important;box-sizing:border-box!important;min-width:0;min-height:var(--ldp-reader-native-row-height,0px)!important;height:auto!important;
+ max-width:100%!important;overflow:hidden;grid-template-columns:minmax(0,1fr) minmax(8rem,10rem) minmax(4.5rem,5rem) minmax(6.75rem,7.5rem) minmax(5rem,6rem);
+ align-items:center;align-content:center;column-gap:12px;row-gap:3px;padding:7px 12px!important;
+ border:1px solid var(--primary-low,#ddd)!important;border-radius:10px;background:color-mix(in srgb,var(--secondary,#fff) 92%,var(--primary-very-low,#f4f4f4));
+ transition:background-color .16s ease-out,color .16s ease-out,outline-color .16s ease-out,border-color .16s ease-out;}
+ html.ldp-reader-workspace .topic-list tbody > tr:nth-child(even),
+ html.ldp-reader-workspace .latest-topic-list > .latest-topic-list-item:nth-child(even),
+ html.ldp-reader-workspace .discovery-list-container .topic-list-body > .topic-list-item:nth-child(even){
+ background:color-mix(in srgb,var(--secondary,#fff) 55%,var(--ldp-reader-list-zebra-color,var(--tertiary,#47855f)) 45%)!important;}
+ html.ldp-reader-workspace tr.topic-list-item:hover,
+ html.ldp-reader-workspace .topic-list-item:hover,
+ html.ldp-reader-workspace .latest-topic-list-item:hover{cursor:pointer!important;}
+ .ldp-reader-queue-add{display:inline-flex;width:20px;height:20px;align-items:center;justify-content:center;
+ box-sizing:border-box;margin-left:5px;padding:2px;border:0;border-radius:50%;vertical-align:middle;
+ background:transparent;color:var(--primary-medium,#777);cursor:pointer;opacity:0;transition:opacity .12s ease-out,background .12s ease-out,color .12s ease-out;}
+ .ldp-reader-queue-add .ldp-icon{width:13px;height:13px;}
+ :is(.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result):hover .ldp-reader-queue-add,
+ .ldp-reader-queue-add:is(:focus-visible,.is-added){opacity:1;}
+ .ldp-reader-queue-add:hover{background:var(--primary-very-low,#f2f2f2);color:var(--primary,#222);}
+ .ldp-reader-queue-add.is-added{color:var(--tertiary,#47855f);}
+ @media (hover:none){.ldp-reader-queue-add{opacity:1;}}
+ html.ldp-reader-workspace :is(tr.topic-list-item,.topic-list-item,.latest-topic-list-item)[data-ldp-reader-active-topic="true"]{
+ border-color:var(--tertiary,#47855f)!important;box-shadow:none!important;}
+ html.ldp-reader-workspace :is(tr.topic-list-item,.topic-list-item,.latest-topic-list-item)[data-ldp-reader-active-topic="true"]::before,
+ html.ldp-reader-workspace :is(tr.topic-list-item,.topic-list-item,.latest-topic-list-item)[data-ldp-reader-active-topic="true"] > td.main-link::before{
+ content:none!important;display:none!important;}
+ html:where(.ldp-reader-open,.ldp-reader-workspace) tr.topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link),
+ html:where(.ldp-reader-open,.ldp-reader-workspace) .topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link),
+ html:where(.ldp-reader-open,.ldp-reader-workspace) .latest-topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link){font-weight:600!important;-webkit-text-stroke:.1px currentColor;}
+ html.ldp-reader-workspace tr.topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link),
+ html.ldp-reader-workspace .topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link),
+ html.ldp-reader-workspace .latest-topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link){font-weight:550!important;-webkit-text-stroke:0;}
+ html.ldp-reader-workspace .link-top-line [data-ldp-native-dnd]{display:inline-flex!important;position:relative!important;right:auto;top:auto;flex:0 0 20px;width:20px!important;min-width:20px!important;height:20px!important;margin:0 0 0 4px!important;padding:0!important;transform:none;vertical-align:middle;box-sizing:border-box;}
+ html.ldp-reader-workspace tr.topic-list-item:hover .link-top-line [data-ldp-native-dnd],
+ html.ldp-reader-workspace .topic-list-item:hover .link-top-line [data-ldp-native-dnd],
+ html.ldp-reader-workspace .latest-topic-list-item:hover .link-top-line [data-ldp-native-dnd]{
+ background:transparent!important;border-color:transparent!important;box-shadow:none!important;color:transparent!important;text-shadow:none!important;}
+ html.ldp-reader-workspace tr.topic-list-item:hover .link-top-line [data-ldp-native-dnd]::before,
+ html.ldp-reader-workspace .topic-list-item:hover .link-top-line [data-ldp-native-dnd]::before,
+ html.ldp-reader-workspace .latest-topic-list-item:hover .link-top-line [data-ldp-native-dnd]::before{
+ content:'';position:absolute;left:50%;top:50%;width:15px;height:15px;transform:translate(-50%,-50%);background:var(--primary-medium,#777);
+ -webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.268 21a2 2 0 0 0 3.464 0'/%3E%3Cpath d='M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .08-.969'/%3E%3Cpath d='M9.5 2.5A6 6 0 0 1 18 8c0 4.499 1.411 5.956 2.738 7.326A1 1 0 0 1 20 17h-1.17'/%3E%3Cpath d='m2 2 20 20'/%3E%3C/svg%3E") center/contain no-repeat;
+ mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.268 21a2 2 0 0 0 3.464 0'/%3E%3Cpath d='M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .08-.969'/%3E%3Cpath d='M9.5 2.5A6 6 0 0 1 18 8c0 4.499 1.411 5.956 2.738 7.326A1 1 0 0 1 20 17h-1.17'/%3E%3Cpath d='m2 2 20 20'/%3E%3C/svg%3E") center/contain no-repeat;}
+ html.ldp-reader-workspace .topic-list-item > td,
+ html.ldp-reader-workspace .topic-list tbody > tr > td{display:block!important;width:auto!important;min-width:0;min-height:0!important;height:auto!important;margin:0!important;padding:0!important;border:0!important;align-self:center;}
+ html.ldp-reader-workspace .topic-list-item > td.main-link,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:first-child{grid-column:1;grid-row:1;align-self:center;}
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(2){grid-column:2;grid-row:1;}
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3){grid-column:3;grid-row:1;}
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4){grid-column:4;grid-row:1;}
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5){grid-column:5;grid-row:1;}
+ html.ldp-reader-workspace .topic-list-item > td.posters{display:flex!important;grid-column:2;grid-row:1;min-width:0;max-width:100%;align-items:center;justify-content:flex-end;gap:2px;overflow:visible;white-space:nowrap;}
+ html.ldp-reader-workspace .topic-list-item > td.posters > *{flex:0 0 auto;}
+ html.ldp-reader-workspace .topic-list-item > td.posts,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3){grid-column:3;grid-row:1;text-align:right;}
+ html.ldp-reader-workspace .topic-list-item > td.views,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4){grid-column:4;grid-row:1;text-align:right;}
+ html.ldp-reader-workspace .topic-list-item > td.activity,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5){grid-column:5;grid-row:1;text-align:right;}
+ html.ldp-reader-workspace tr.topic-list-item:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .topic-list tbody > tr:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .topic-list-item:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .latest-topic-list-item:has(> td[data-ldp-native-topic-date]){
+ grid-template-columns:minmax(0,1fr) minmax(8rem,10rem) minmax(4.5rem,5rem) minmax(10.5rem,12rem) minmax(5rem,6rem);}
+ html.ldp-reader-workspace .topic-list-item .main-link,
+ html.ldp-reader-workspace .topic-list-item .topic-list-data.main-link{
+ color:var(--ldp-host-font-color,var(--primary,#222));font-family:var(--ldp-host-font-family,inherit);
+ font-size:var(--ldp-host-topic-title-size,var(--ldp-font-lg,15px));font-weight:var(--ldp-host-font-weight,700);line-height:1.4;}
+ html.ldp-reader-workspace .topic-list-item .link-top-line{display:block;min-width:0;max-width:100%;white-space:normal!important;}
+ html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) .link-top-line > :is(a,.title,.raw-topic-link){
+ max-width:100%;color:var(--ldp-host-font-color,var(--primary,#222))!important;
+ font-family:var(--ldp-host-font-family,inherit)!important;font-weight:var(--ldp-host-font-weight,700)!important;
+ white-space:normal!important;overflow-wrap:anywhere;}
+ html.ldp-reader-workspace .topic-list-item .link-bottom-line{display:flex!important;align-items:center;flex-wrap:nowrap!important;width:max-content;max-width:none;margin-top:3px;overflow:visible;white-space:nowrap;}
+ html.ldp-reader-workspace .topic-list-item .link-bottom-line > *{flex:0 0 auto;}
+ html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.badge-wrapper,.badge-category,.badge-category__wrapper,.discourse-tag,.topic-tag),
+ html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.discourse-tags,.topic-tags,.tags,.tag-list) :is(a,.discourse-tag,.topic-tag){
+ display:inline-flex!important;box-sizing:border-box!important;min-height:var(--ldp-host-label-card-height,22px);align-items:center!important;
+ gap:var(--ldp-host-label-card-gap,3px)!important;padding-block:0!important;padding-inline:var(--ldp-host-label-card-padding,7px)!important;
+ color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,system-ui,sans-serif)!important;
+ font-size:var(--ldp-host-label-card-font-size,11px)!important;font-weight:var(--ldp-host-font-weight,600)!important;line-height:1!important;}
+ html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) :is(.badge-category__name,.category-name,[data-category-name]),
+ html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row)
+ :is(.badge-wrapper,.badge-category,.badge-category__wrapper,.discourse-tag,.topic-tag) :is(span,strong,small){
+ color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,system-ui,sans-serif)!important;
+ font-weight:var(--ldp-host-font-weight,600)!important;}
+ html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.badge-wrapper,.badge-category,.badge-category__wrapper,.discourse-tag,.topic-tag) :is(svg,img,.emoji),
+ html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.discourse-tags,.topic-tags,.tags,.tag-list) :is(a,.discourse-tag,.topic-tag) :is(svg,img,.emoji){
+ width:var(--ldp-host-label-card-icon-size,14px)!important;height:var(--ldp-host-label-card-icon-size,14px)!important;flex:none!important;object-fit:contain;}
+ html.ldp-reader-workspace .topic-list-item > td.posters .avatar{
+ width:var(--ldp-host-topic-avatar-size,32px)!important;height:var(--ldp-host-topic-avatar-size,32px)!important;}
+ html.ldp-reader-workspace .topic-list-item > td.posts,
+ html.ldp-reader-workspace .topic-list-item > td.views,
+ html.ldp-reader-workspace .topic-list-item > td.activity,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:not(:first-child){min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;white-space:nowrap;}
+ html.ldp-reader-workspace .topic-list-item > td.posts,
+ html.ldp-reader-workspace .topic-list-item > td.views,
+ html.ldp-reader-workspace .topic-list-item > td.activity,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3),
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4),
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5),
+ html.ldp-reader-workspace .topic-list-item > td[data-ldp-native-topic-date],
+ html.ldp-reader-workspace .topic-list tbody > tr > td[data-ldp-native-topic-date]{display:inline-flex!important;min-width:0;align-self:start!important;align-items:baseline;justify-content:flex-end;gap:4px;line-height:1.25;}
+ html.ldp-reader-workspace .topic-list-item > td.posts::before,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3)::before{content:'回复';}
+ html.ldp-reader-workspace .topic-list-item > td.views::before,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4)::before{content:'最近回复';}
+ html.ldp-reader-workspace .topic-list-item > td.activity::before,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5)::before{content:'活跃';}
+ html.ldp-reader-workspace .topic-list-item > td[data-ldp-native-old-topic]::before,
+ html.ldp-reader-workspace .topic-list tbody > tr > td[data-ldp-native-old-topic]::before{content:'旧帖';}
+ html.ldp-reader-workspace .topic-list-item > td.posts::before,
+ html.ldp-reader-workspace .topic-list-item > td.views::before,
+ html.ldp-reader-workspace .topic-list-item > td.activity::before,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3)::before,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4)::before,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5)::before,
+ html.ldp-reader-workspace .topic-list-item > td[data-ldp-native-old-topic]::before,
+ html.ldp-reader-workspace .topic-list tbody > tr > td[data-ldp-native-old-topic]::before{flex:none;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-weight:650;opacity:.72;}
+ @container ldp-reader-host (max-width:1180px){
+ html.ldp-reader-workspace tr.topic-list-item,
+ html.ldp-reader-workspace .topic-list tbody > tr,
+ html.ldp-reader-workspace .topic-list-item,
+ html.ldp-reader-workspace .latest-topic-list-item{grid-template-columns:minmax(0,1fr) 4.5rem 7.25rem 5.5rem;grid-template-rows:auto auto;align-items:center;align-content:center;column-gap:12px;row-gap:2px;padding:7px 10px!important;}
+ html.ldp-reader-workspace tr.topic-list-item:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .topic-list tbody > tr:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .topic-list-item:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .latest-topic-list-item:has(> td[data-ldp-native-topic-date]){grid-template-columns:minmax(0,1fr) 4.5rem 12rem 5.5rem;}
+ html.ldp-reader-workspace .topic-list-item > td.main-link,
+ html.ldp-reader-workspace .topic-list tbody > tr > td:first-child{grid-column:1!important;grid-row:1 / span 2!important;}
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(2){grid-column:2 / span 3!important;grid-row:1!important;justify-self:end!important;}
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3){grid-column:2!important;grid-row:2!important;justify-self:end!important;}
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4){grid-column:3!important;grid-row:2!important;justify-self:end!important;}
+ html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5){grid-column:4!important;grid-row:2!important;justify-self:end!important;align-self:end!important;}
+ html.ldp-reader-workspace .topic-list-item > td.posters{grid-column:2 / span 3!important;grid-row:1!important;justify-self:end!important;max-inline-size:none!important;overflow:visible!important;}
+ html.ldp-reader-workspace .topic-list-item > td.posts{grid-column:2!important;grid-row:2!important;justify-self:end!important;align-self:start!important;}
+ html.ldp-reader-workspace .topic-list-item > td.views{grid-column:3!important;grid-row:2!important;justify-self:end!important;align-self:start!important;}
+ html.ldp-reader-workspace .topic-list-item > td.activity{grid-column:4!important;grid-row:2!important;justify-self:end!important;align-self:start!important;}
+ html.ldp-reader-workspace .topic-list-item > td[data-ldp-native-topic-date],
+ html.ldp-reader-workspace .topic-list tbody > tr > td[data-ldp-native-topic-date]{grid-column:3!important;grid-row:2!important;justify-self:end!important;align-self:start!important;}}
+ @container ldp-reader-host (max-width:860px){
+ html.ldp-reader-workspace tr.topic-list-item,
+ html.ldp-reader-workspace .topic-list tbody > tr,
+ html.ldp-reader-workspace .topic-list-item,
+ html.ldp-reader-workspace .latest-topic-list-item{grid-template-columns:minmax(0,1fr) 4rem 6.5rem 5rem;column-gap:10px;padding:7px 10px!important;}
+ html.ldp-reader-workspace tr.topic-list-item:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .topic-list tbody > tr:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .topic-list-item:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .latest-topic-list-item:has(> td[data-ldp-native-topic-date]){grid-template-columns:minmax(0,1fr) 4rem 11.5rem 5rem;}
+ html.ldp-reader-workspace .topic-list-item > td.posters .avatar{width:var(--ldp-host-topic-avatar-size-medium,24px)!important;height:var(--ldp-host-topic-avatar-size-medium,24px)!important;}}
+ @container ldp-reader-host (max-width:680px){
+ html.ldp-reader-workspace tr.topic-list-item,
+ html.ldp-reader-workspace .topic-list tbody > tr,
+ html.ldp-reader-workspace .topic-list-item,
+ html.ldp-reader-workspace .latest-topic-list-item{grid-template-columns:minmax(0,1fr) 3.75rem 6.25rem 4.75rem;column-gap:8px;padding:7px 8px!important;}
+ html.ldp-reader-workspace tr.topic-list-item:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .topic-list tbody > tr:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .topic-list-item:has(> td[data-ldp-native-topic-date]),
+ html.ldp-reader-workspace .latest-topic-list-item:has(> td[data-ldp-native-topic-date]){grid-template-columns:minmax(0,1fr) 3.75rem 10.5rem 4.75rem;}
+ html.ldp-reader-workspace .topic-list-item > td.posters .avatar{width:var(--ldp-host-topic-avatar-size-small,20px)!important;height:var(--ldp-host-topic-avatar-size-small,20px)!important;}}
+
+ .ldp-topic-stats-component{display:none!important;}
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component){grid-template-columns:minmax(0,1fr) minmax(12rem,max-content);grid-template-rows:max-content max-content;align-items:start;align-content:start;row-gap:0;min-height:0!important;padding-block:4px!important;}
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link{display:contents!important;}
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line{display:block!important;grid-column:1;grid-row:1;align-self:center;min-width:0;width:100%;max-width:100%;padding-right:0;overflow:visible;text-overflow:clip;white-space:normal!important;box-sizing:border-box;}
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line::after{content:'';display:inline-block;width:20px;height:20px;margin-left:4px;vertical-align:middle;}
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line:has([data-ldp-native-dnd])::after{display:none;}
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line > :is(a,.title,.raw-topic-link){display:inline!important;max-width:none;white-space:normal!important;overflow-wrap:anywhere;}
+ html.ldp-reader-workspace .ldp-native-topic-title-tools{display:inline-flex!important;align-items:center;margin-inline-start:6px;vertical-align:middle;white-space:nowrap!important;word-break:keep-all;}
+ html.ldp-reader-workspace .ldp-native-topic-title-tools > *{flex:none;align-self:center!important;margin-block:0!important;top:auto!important;bottom:auto!important;transform:none!important;vertical-align:middle!important;}
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-bottom-line{display:flex!important;grid-column:1;grid-row:2;align-self:center;align-items:center;align-content:center;flex-wrap:wrap!important;gap:2px 4px;min-width:0;width:100%;max-width:100%;margin-top:0;overflow:hidden;white-space:normal;}
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-bottom-line > :is(.discourse-tags,.topic-tags){display:contents!important;}
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.posters{grid-column:2!important;grid-row:1!important;align-self:center!important;justify-self:end!important;}
+ html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component){display:block!important;grid-column:2!important;grid-row:2!important;align-self:center!important;justify-self:stretch!important;min-width:0;width:100%!important;overflow:visible!important;font-size:0!important;white-space:nowrap;transform:none;}
+ html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component)::before{content:none!important;}
+ html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component) > :not(.ldp-topic-stats-component),
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.views,
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.activity{display:none!important;}
+ html.ldp-reader-workspace .ldp-topic-stats-component{display:flex!important;flex-direction:column;align-items:stretch;justify-content:center;row-gap:0;width:100%;min-width:0;color:var(--ldp-host-font-color,inherit);font-family:var(--ldp-host-font-family,inherit);font-size:var(--ldp-host-topic-stats-size,var(--ldp-font-xs,10px));font-weight:var(--ldp-host-font-weight,400);line-height:1;white-space:nowrap;}
+ html.ldp-reader-workspace .ldp-topic-stats-row{display:flex;align-items:baseline;justify-content:flex-end;column-gap:0;width:100%;min-width:0;}
+ html.ldp-reader-workspace .ldp-topic-stats-row + .ldp-topic-stats-row{margin-top:var(--ldp-host-topic-stats-row-offset,-4px);}
+ html.ldp-reader-workspace .ldp-topic-stat{display:inline-flex;flex:none;align-items:baseline;justify-content:flex-start;gap:4px;min-width:0;white-space:nowrap;}
+ html.ldp-reader-workspace :is(.ldp-topic-stats-row,.ldp-topic-stat,.ldp-topic-stat-value,.ldp-topic-stat-value *){line-height:1!important;}
+ html.ldp-reader-workspace .ldp-topic-stat + .ldp-topic-stat{position:relative;margin-left:10px;padding-left:11px;}
+ html.ldp-reader-workspace .ldp-topic-stat + .ldp-topic-stat::before{content:'';position:absolute;left:0;bottom:0;width:var(--ldp-divider-line-width,1px);height:1.1em;background:var(--ldp-divider-line-color,var(--primary-low,#e5e5e5));}
+ html.ldp-reader-workspace .ldp-topic-stat-label{flex:none;color:var(--ldp-host-font-color,var(--primary-medium,#777))!important;font-family:var(--ldp-host-font-family,inherit)!important;font-size:var(--ldp-host-topic-stats-label-size,var(--ldp-font-micro,9px));font-weight:var(--ldp-host-font-weight,650)!important;opacity:.72;}
+ html.ldp-reader-workspace .ldp-topic-stat-value{display:inline-flex;align-items:baseline;min-width:0;color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,inherit)!important;font-size:var(--ldp-host-topic-stats-size,var(--ldp-font-xs,10px))!important;font-weight:var(--ldp-host-font-weight,400)!important;white-space:nowrap;}
+ html.ldp-reader-workspace .ldp-topic-stat-value *{color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,inherit)!important;font-size:inherit!important;font-weight:var(--ldp-host-font-weight,400)!important;}
+ html.ldp-reader-workspace .ldp-topic-stat-value :is(svg,img){flex:none;align-self:baseline;}
+ @container ldp-reader-host (max-width:1180px){
+ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.posters,
+ html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component){grid-column:2!important;}
+ }
+ .ldp-overlay.ldp-reader-embedded{align-items:stretch;justify-content:stretch;pointer-events:none;}
+ .ldp-reader-embedded .ldp-modal{position:fixed;top:0;bottom:0;width:var(--ldp-reader-workspace-width);height:100vh;
+ border-radius:0;box-shadow:0 8px 24px rgba(0,0,0,.18);will-change:width,clip-path,opacity;
+ transition:width .24s cubic-bezier(.22,1,.36,1),left .24s cubic-bezier(.22,1,.36,1),right .24s cubic-bezier(.22,1,.36,1);}
+ .ldp-reader-embedded-left .ldp-modal{left:0;right:auto;border-right:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
+ animation:ldp-reader-embed-in-left .24s cubic-bezier(.22,1,.36,1) both;}
+ .ldp-reader-embedded-right .ldp-modal{left:auto;right:0;border-left:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
+ animation:ldp-reader-embed-in-right .24s cubic-bezier(.22,1,.36,1) both;}
+ @keyframes ldp-reader-embed-in-left{from{opacity:.48;clip-path:inset(0 100% 0 0);}to{opacity:1;clip-path:inset(0);}}
+ @keyframes ldp-reader-embed-in-right{from{opacity:.48;clip-path:inset(0 0 0 100%);}to{opacity:1;clip-path:inset(0);}}
+ .ldp-reader-embedded-left .ldp-reader-window-capsule{display:flex;top:5px;left:calc(var(--ldp-reader-workspace-width) / 2);right:auto;
+ transform:translate(-50%,0);}
+ .ldp-reader-embedded-right .ldp-reader-window-capsule{display:flex;top:5px;left:auto;right:calc(var(--ldp-reader-workspace-width) / 2);
+ transform:translate(50%,0);}
+ .ldp-reader-embedded .ldp-reader-pin-button,.ldp-reader-embedded .ldp-reader-lock-button,
+ .ldp-reader-embedded .ldp-reader-resize-handle{display:none!important;}
+ .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule{
+ position:fixed;display:flex;top:5px;left:50%;right:auto;width:40px;transform:translate(-50%,0);}
+ .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule:hover,
+ .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule:focus-within,
+ .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule.ldp-reader-placement-available:has(.ldp-reader-placement-control:focus-within){
+ width:102px;padding-inline:0;transform:translate(-50%,0);}
+ .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-pin-button,
+ .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-lock-button,
+ .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-resize-handle{display:none!important;}
+ .ldp-reader-embed-resize{position:fixed;top:0;bottom:0;z-index:15;display:none;width:10px;cursor:col-resize;pointer-events:auto;touch-action:none;}
+ .ldp-reader-embedded-left .ldp-reader-embed-resize{left:calc(var(--ldp-reader-workspace-width) - 5px);}
+ .ldp-reader-embedded-right .ldp-reader-embed-resize{right:calc(var(--ldp-reader-workspace-width) - 5px);}
+ .ldp-reader-embedded .ldp-reader-embed-resize{display:block;}
+ .ldp-reader-host-scrollbar{position:fixed;top:0;bottom:0;right:calc(var(--ldp-reader-workspace-width) + 6px);z-index:14;
+ display:none;width:6px;padding:0;border:0;outline:0;background:transparent;pointer-events:auto;touch-action:none;user-select:none;
+ opacity:1;transition:opacity .12s ease-out;}
+ .ldp-reader-embedded .ldp-reader-host-scrollbar{display:block;}
+ .ldp-reader-embedded-left .ldp-reader-host-scrollbar{right:6px;}
+ .ldp-reader-host-scrollbar.ldp-reader-host-scrollbar-inactive{opacity:0;pointer-events:none;}
+ .ldp-reader-host-scrollbar-thumb{position:absolute;left:0;top:0;width:6px;min-height:24px;border-radius:999px;
+ background:color-mix(in srgb,var(--primary-medium,#888) 34%,transparent);will-change:height,transform;}
+ .ldp-reader-host-scrollbar:hover .ldp-reader-host-scrollbar-thumb,
+ .ldp-reader-host-scrollbar:focus-visible .ldp-reader-host-scrollbar-thumb,
+ .ldp-reader-host-scrollbar.ldp-reader-host-scrollbar-dragging .ldp-reader-host-scrollbar-thumb{
+ background:color-mix(in srgb,var(--primary-medium,#888) 48%,transparent);}
+ .ldp-reader-host-top{position:fixed;z-index:16;display:inline-flex;align-items:center;justify-content:center;
+ width:30px;height:30px;padding:0;border:1px solid color-mix(in srgb,var(--primary-low,#ddd) 72%,transparent);border-radius:50%;
+ background:color-mix(in srgb,var(--secondary,#fff) 68%,transparent);color:color-mix(in srgb,var(--primary-medium,#777) 70%,transparent);
+ box-shadow:0 3px 10px rgba(0,0,0,.1);backdrop-filter:blur(3px);pointer-events:auto;cursor:pointer;}
+ .ldp-reader-host-top:hover{border-color:color-mix(in srgb,var(--primary-medium,#999) 78%,transparent);
+ background:color-mix(in srgb,var(--primary-very-low,#f0f0f0) 86%,transparent);color:var(--primary,#222);}
+ .ldp-reader-host-top-countdown{position:absolute;right:2px;bottom:1px;font-size:7px;font-style:italic;
+ font-weight:400;line-height:1;color:currentColor;opacity:.42;pointer-events:none;}
+ html.ldp-reader-embed-resizing #ember-app{will-change:width,margin-left,margin-right;}
+ html.ldp-reader-embed-resizing #ember-app,html.ldp-reader-embed-resizing .d-header,
+ html.ldp-reader-embed-resizing body > header,html.ldp-reader-embed-resizing #ember-app > header,
+ .ldp-overlay.ldp-reader-embed-resizing .ldp-modal{transition:none!important;}
+ .ldp-overlay.ldp-window-managed:not(.ldp-fullpage){align-items:flex-start;justify-content:flex-start;}
+ .ldp-overlay.ldp-window-managed:not(.ldp-fullpage):not(.ldp-window-pinned){pointer-events:auto;}
+ .ldp-overlay.ldp-window-managed:not(.ldp-fullpage),
+ .ldp-overlay.ldp-reader-embedded,
+ .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage{
+ --ldp-window-capsule-rail:14px;}
+ .ldp-overlay.ldp-reader-switch-ready{cursor:pointer;}
+ html .ldp-reader-switch-target{border-radius:4px;outline:1px solid color-mix(in srgb,var(--tertiary,#47855f) 58%,transparent);
+ outline-offset:3px;background:color-mix(in srgb,var(--tertiary-low,#dceee2) 64%,transparent)!important;
+ color:var(--tertiary,#47855f)!important;cursor:pointer!important;
+ transition:background-color .16s ease-out,color .16s ease-out,outline-color .16s ease-out;}
+ .ldp-overlay.ldp-window-managed:not(.ldp-fullpage) .ldp-modal{position:absolute;}
+ .ldp-reader-window-capsule{position:absolute;z-index:14;left:var(--ldp-reader-window-center-x,50%);
+ top:calc(var(--ldp-reader-window-top,50%) + 5px);transform:translate(-50%,0);display:none;
+ width:64px;height:20px;box-sizing:border-box;align-items:center;justify-content:center;gap:2px;padding:0 4px;
+ border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--secondary,#fff) 88%,var(--primary-low,#ddd));
+ color:var(--primary-medium,#777);box-shadow:none;pointer-events:auto;
+ transition:width .12s ease-out,height .12s ease-out,padding .12s ease-out,gap .12s ease-out,
+ transform .12s ease-out,background .12s ease-out,box-shadow .12s ease-out,opacity .12s ease-out;}
+ .ldp-reader-window-capsule:not(.ldp-reader-placement-available){width:40px;}
+ .ldp-reader-window-capsule:hover,.ldp-reader-window-capsule:focus-within{width:116px;height:24px;overflow:visible;
+ transform:translate(-50%,0);gap:2px;padding:1px 4px;background:var(--secondary,#fff);
+ box-shadow:0 2px 8px rgba(0,0,0,.22);opacity:1;transition-delay:0s;}
+ .ldp-reader-window-capsule:not(:hover):not(:focus-within){
+ width:42px;height:10px;gap:0;padding:0;overflow:visible;background:transparent;
+ box-shadow:none;opacity:1;outline:0;transition-delay:.12s;}
+ .ldp-reader-window-capsule::before{content:"";position:absolute;left:50%;top:50%;
+ z-index:2;
+ width:30px;height:3px;border-radius:999px;background:rgba(255,255,255,.9);
+ box-shadow:0 1px 3px rgba(0,0,0,.2),0 0 0 1px rgba(0,0,0,.08);
+ opacity:0;pointer-events:none;transform:translate(-50%,-50%);transition:opacity .1s ease-out;}
+ .ldp-reader-window-capsule:not(:hover):not(:focus-within)::before{opacity:.82;transition-delay:.12s;}
+ .ldp-reader-window-capsule::after{content:"";position:absolute;z-index:0;inset:-4px -6px;
+ border-radius:999px;background:transparent;pointer-events:auto;}
+ .ldp-reader-window-capsule > *{position:relative;z-index:1;transition:opacity .1s ease-out;}
+ .ldp-reader-window-capsule:not(:hover):not(:focus-within) > *{
+ opacity:0;pointer-events:none;transition-delay:.12s;}
+ .ldp-overlay:not(.ldp-reader-embedded):not(.ldp-fullpage)
+ .ldp-reader-window-capsule.ldp-reader-placement-available:is(:hover,:focus-within){width:172px;}
+ .ldp-reader-embedded .ldp-reader-window-capsule:hover,
+ .ldp-reader-embedded .ldp-reader-window-capsule:focus-within{width:60px;}
+ .ldp-reader-embedded
+ .ldp-reader-window-capsule.ldp-reader-placement-available:is(:hover,:focus-within){width:102px;padding-inline:0;}
+ .ldp-reader-embedded-left .ldp-reader-window-capsule:hover,
+ .ldp-reader-embedded-left .ldp-reader-window-capsule:focus-within{transform:translate(-50%,0);}
+ .ldp-reader-embedded-right .ldp-reader-window-capsule:hover,
+ .ldp-reader-embedded-right .ldp-reader-window-capsule:focus-within{transform:translate(50%,0);}
+ .ldp-window-handle-docked .ldp-reader-window-capsule:hover,
+ .ldp-window-handle-docked .ldp-reader-window-capsule:focus-within{transform:translate(-50%,0);}
+ .ldp-reader-lock-button,.ldp-reader-pin-button{display:flex;width:20px;height:20px;align-items:center;justify-content:center;
+ padding:0;border:0;border-radius:4px;
+ background:transparent;color:var(--primary-medium,#777);cursor:pointer;
+ transition:width .16s ease-out,opacity .16s ease-out,transform .16s ease-out,visibility 0s linear .16s;}
+ .ldp-reader-window-capsule:not(:hover):not(:focus-within) .ldp-reader-lock-button,
+ .ldp-reader-window-capsule:not(:hover):not(:focus-within) .ldp-reader-pin-button{
+ width:0;opacity:0;visibility:hidden;pointer-events:none;transform:scale(.75);}
+ .ldp-reader-window-capsule:hover .ldp-reader-lock-button,
+ .ldp-reader-window-capsule:hover .ldp-reader-pin-button,
+ .ldp-reader-window-capsule:focus-within .ldp-reader-lock-button,
+ .ldp-reader-window-capsule:focus-within .ldp-reader-pin-button{
+ opacity:1;visibility:visible;pointer-events:auto;transform:scale(1);transition-delay:0s;}
+ .ldp-reader-lock-button:hover,.ldp-reader-pin-button:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
+ .ldp-reader-lock-button.active,.ldp-reader-pin-button.active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
+ .ldp-reader-lock-button.ldp-lock-state-changing .ldp-icon{animation:ldp-reader-lock-state-change .18s cubic-bezier(.22,1,.36,1);}
+ @keyframes ldp-reader-lock-state-change{0%{opacity:.55;transform:scale(.72) rotate(-12deg);}65%{opacity:1;transform:scale(1.12) rotate(4deg);}100%{opacity:1;transform:scale(1) rotate(0);}}
+ .ldp-window-pinned .ldp-reader-window-capsule:not(:hover):not(:focus-within),
+ .ldp-window-locked .ldp-reader-window-capsule:not(:hover):not(:focus-within){
+ background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
+ .ldp-reader-placement-control{display:flex;width:auto;height:22px;flex:0 0 auto;align-items:center;overflow:visible;}
+ .ldp-reader-placement-divider{width:1px;height:14px;flex:0 0 1px;margin:0 6px;border-radius:999px;
+ background:color-mix(in srgb,var(--primary-medium,#777) 34%,transparent);}
+ :is(.ldp-reader-embedded,.ldp-fullpage) .ldp-reader-placement-divider{display:none;}
+ .ldp-reader-placement-strip{display:flex;width:0;height:22px;flex:0 0 auto;align-items:center;gap:2px;overflow:hidden;
+ opacity:0;pointer-events:none;transform:translateX(3px);
+ transition:width .12s ease-out,opacity .12s ease-out,transform .12s ease-out;transition-delay:.12s;}
+ .ldp-reader-window-capsule.ldp-reader-placement-available:hover .ldp-reader-placement-strip:not([hidden]),
+ .ldp-reader-window-capsule.ldp-reader-placement-available:focus-within .ldp-reader-placement-strip:not([hidden]),
+ .ldp-reader-placement-control:focus-within .ldp-reader-placement-strip:not([hidden]){
+ width:102px;opacity:1;pointer-events:auto;transform:translateX(0);transition-delay:0s;}
+ .ldp-reader-window-capsule:not(:hover):not(:focus-within)
+ :is(.ldp-reader-pin-button,.ldp-reader-lock-button,.ldp-reader-placement-divider,.ldp-reader-placement-strip){
+ transition-delay:.12s;}
+ .ldp-reader-placement-option{position:relative;display:flex;width:24px;height:22px;flex:0 0 24px;align-items:center;justify-content:center;
+ padding:0;border:0;border-radius:999px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;touch-action:manipulation;}
+ .ldp-reader-placement-option:hover,.ldp-reader-placement-option:focus-visible{background:var(--primary-very-low,#f3f3f3);color:var(--primary,#222);outline:0;}
+ .ldp-reader-placement-option::after{content:attr(data-reader-placement-label);position:absolute;top:calc(100% + 7px);right:50%;z-index:2;
+ padding:4px 7px;
+ opacity:0;pointer-events:none;transform:translate(50%,-2px);transition:opacity .08s linear,transform .08s ease-out;}
+ .ldp-reader-placement-option:hover::after,.ldp-reader-placement-option:focus-visible::after{opacity:1;transform:translate(50%,0);}
+ .ldp-reader-placement-option.active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
+ .ldp-reader-resize-handle{position:absolute;z-index:13;display:none;touch-action:none;background:transparent;}
+ .ldp-reader-resize-handle[data-reader-resize="n"]{left:20px;right:20px;top:0;height:10px;cursor:ns-resize;}
+ .ldp-reader-resize-handle[data-reader-resize="s"]{left:20px;right:20px;bottom:0;height:10px;cursor:ns-resize;}
+ .ldp-reader-resize-handle[data-reader-resize="e"]{top:20px;right:0;bottom:20px;width:10px;cursor:ew-resize;}
+ .ldp-reader-resize-handle[data-reader-resize="w"]{top:20px;bottom:20px;left:0;width:10px;cursor:ew-resize;}
+ .ldp-reader-resize-handle[data-reader-resize="nw"],
+ .ldp-reader-resize-handle[data-reader-resize="ne"],
+ .ldp-reader-resize-handle[data-reader-resize="sw"],
+ .ldp-reader-resize-handle[data-reader-resize="se"]{width:20px;height:20px;}
+ .ldp-reader-resize-handle[data-reader-resize="nw"]{left:0;top:0;cursor:nwse-resize;}
+ .ldp-reader-resize-handle[data-reader-resize="ne"]{right:0;top:0;cursor:nesw-resize;}
+ .ldp-reader-resize-handle[data-reader-resize="sw"]{left:0;bottom:0;cursor:nesw-resize;}
+ .ldp-reader-resize-handle[data-reader-resize="se"]{right:0;bottom:0;cursor:nwse-resize;}
+ .ldp-window-managed:not(.ldp-fullpage) .ldp-reader-window-capsule,
+ .ldp-window-managed:not(.ldp-fullpage):not(.ldp-window-locked) .ldp-reader-resize-handle{display:flex;}
+ @media (prefers-reduced-motion:reduce){
+ .ldp-reader-lock-button.ldp-lock-state-changing .ldp-icon,.ldp-reader-embedded .ldp-modal{animation:none;}}
+ .ldp-window-interacting,.ldp-window-interacting *{user-select:none!important;}
+ .ldp-window-interacting .ldp-modal{will-change:left,top,width,height,transform;}
+ .ldp-header{--ldp-header-logo-inset:14px;--ldp-header-reserved-right:156px;
+ --ldp-home-logo-size:clamp(32px,3.5cqi,48px);--ldp-home-logo-box-size:calc(var(--ldp-home-logo-size) + 4px);
+ position:relative;display:flex;align-items:center;gap:8px;box-sizing:border-box;
+ min-height:calc(73px + var(--ldp-window-capsule-rail,0px));
+ padding:calc(6px + var(--ldp-window-capsule-rail,0px)) var(--ldp-header-reserved-right) 6px var(--ldp-header-logo-inset);
+ border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-home-logo{width:var(--ldp-home-logo-box-size);height:var(--ldp-home-logo-box-size);border-radius:6px;display:inline-flex;
+ align-items:center;justify-content:center;flex:none;}
+ .ldp-home-logo:hover{background:var(--primary-very-low,#f0f0f0);}
+ .ldp-logo{width:var(--ldp-home-logo-size);height:var(--ldp-home-logo-size);border-radius:50%;display:block;}
+ .ldp-title-wrap{flex:1;min-width:0;display:grid;grid-template-columns:minmax(0,1fr);gap:2px;
+ line-height:1.2;text-align:left;}
+ .ldp-title{margin:0;min-width:0;display:block;
+ font-size:var(--ldp-reader-title-font-size,var(--ldp-font-xl,16px));
+ font-weight:700;line-height:1.25;text-align:left;}
+ .ldp-title-jump{padding:0;border:none;background:transparent;color:inherit;
+ cursor:pointer;font:inherit;text-align:left;display:inline;white-space:normal;
+ overflow:visible;text-overflow:clip;overflow-wrap:anywhere;word-break:break-all;}
+ .ldp-title-jump:hover{text-decoration:underline;}
+ .ldp-title-jump:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:2px;border-radius:2px;}
+ .ldp-title-actions{--ldp-header-action-gap:8px;position:absolute;top:7px;right:12px;z-index:2;
+ display:inline-flex;align-items:center;gap:var(--ldp-header-action-gap);margin:0;
+ transform:translateY(var(--ldp-title-actions-align-y,0px));}
+ .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open){width:28px;height:28px;flex:0 0 28px;align-items:center;
+ justify-content:center;box-sizing:border-box;padding:4px;border:0;border-radius:6px;background:transparent;
+ color:var(--primary-medium,#777);font:inherit;line-height:1;text-decoration:none;cursor:pointer;}
+ .ldp-title-actions .ldp-topic-edit-trigger,
+ .ldp-title-actions .ldp-open{display:inline-flex;}
+ .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open):hover{
+ background:var(--primary-very-low,#f0f0f0);color:var(--tertiary,#47855f);}
+ .ldp-title-actions .ldp-close{position:relative;}
+ .ldp-title-actions .ldp-close::before{content:"";position:absolute;top:6px;bottom:6px;
+ left:calc(var(--ldp-header-action-gap) / -2);width:1px;background:var(--primary-low,#d7d7d7);
+ opacity:.65;transform:translateX(-50%);pointer-events:none;}
+ .ldp-reader-action-dialog.ldp-topic-edit-dialog{width:min(560px,100%);overflow:visible;}
+ .ldp-reader-action-body.ldp-topic-edit-body{grid-template-columns:repeat(2,minmax(250px,1fr));
+ gap:12px;overflow:visible;}
+ .ldp-topic-edit-title{grid-column:1 / -1;}
+ .ldp-topic-edit-field{display:grid;gap:4px;min-width:0;color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-xs,10px);font-weight:650;line-height:1.2;}
+ .ldp-topic-edit-field > input,.ldp-topic-edit-label-control{width:100%;min-width:0;
+ box-sizing:border-box;border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);
+ color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-base,13px);line-height:1.4;outline:none;}
+ .ldp-topic-edit-field > input{height:38px;padding:7px 10px;}
+ .ldp-topic-edit-field > input:focus,.ldp-topic-edit-label-control:focus-within{
+ border-color:var(--tertiary,#47855f);box-shadow:0 0 0 1px var(--tertiary,#47855f);}
+ .ldp-topic-edit-category{position:relative;min-width:0;}
+ .ldp-picker-trigger{display:flex;width:100%;align-items:center;gap:8px;
+ box-sizing:border-box;border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);
+ color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-base,13px);font-weight:600;line-height:1.35;cursor:pointer;}
+ .ldp-topic-edit-category-trigger{height:38px;padding:7px 9px 7px 10px;}
+ .ldp-font-family-trigger:hover,
+ .ldp-topic-edit-category-trigger:hover:not(:disabled){border-color:var(--primary-medium,#999);}
+ .ldp-picker-trigger[aria-expanded="true"],.ldp-picker-trigger:focus-visible{
+ border-color:var(--tertiary,#47855f);outline:none;}
+ .ldp-picker-trigger:focus-visible,.ldp-topic-edit-category-trigger[aria-expanded="true"]{
+ box-shadow:0 0 0 1px var(--tertiary,#47855f);}
+ .ldp-topic-edit-category-trigger:disabled{cursor:default;opacity:.5;}
+ .ldp-topic-edit-category-value{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;}
+ .ldp-picker-trigger .ldp-icon{width:16px;height:16px;flex:none;color:var(--primary-medium,#777);}
+ .ldp-topic-edit-category-trigger .ldp-icon{transition:transform 150ms ease;}
+ .ldp-topic-edit-category-menu{position:absolute;z-index:3;top:calc(100% + 6px);left:0;width:100%;min-width:260px;
+ box-sizing:border-box;padding:5px;border:1px solid var(--primary-low,#d7d7d7);border-radius:8px;
+ background:var(--secondary,#fff);box-shadow:0 4px 8px rgba(0,0,0,.18);}
+ .ldp-picker-search{display:flex;align-items:center;gap:7px;height:34px;padding:0 8px;
+ box-sizing:border-box;border:1px solid var(--primary-low,#ddd);border-radius:6px;color:var(--primary-medium,#777);}
+ .ldp-topic-edit-category-search{margin:1px 1px 5px;}
+ .ldp-picker-search:focus-within{border-color:var(--tertiary,#47855f);}
+ .ldp-picker-search .ldp-icon{width:14px;height:14px;flex:none;}
+ .ldp-picker-search input{min-width:0;flex:1;padding:0;border:0;outline:0;box-shadow:none;
+ background:transparent;color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-ui,12px);}
+ .ldp-topic-edit-category-search input{height:30px;}
+ .ldp-picker-options{display:grid;overflow-y:auto;overscroll-behavior:contain;
+ padding-right:2px;scrollbar-width:thin;
+ scrollbar-color:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff)) transparent;}
+ .ldp-topic-edit-category-options{gap:2px;max-height:min(300px,40vh);}
+ .ldp-picker-option{display:grid;align-items:center;gap:9px;box-sizing:border-box;width:100%;min-height:36px;
+ padding:4px 8px;border:0;border-radius:6px;background:transparent;font:inherit;text-align:left;cursor:pointer;}
+ .ldp-topic-edit-category-option{grid-template-columns:10px minmax(0,1fr);color:var(--primary,#222);}
+ .ldp-picker-option:hover,.ldp-picker-option:focus-visible{
+ background:var(--primary-very-low,#f2f2f2);outline:none;}
+ .ldp-picker-option[aria-selected="true"]{
+ background:color-mix(in srgb,var(--tertiary-low,#dceee2) 68%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
+ .ldp-topic-edit-category-dot{width:8px;height:8px;border-radius:50%;background:var(--ldp-category-color,var(--primary-low,#ccc));}
+ .ldp-picker-option-copy{display:grid;gap:1px;min-width:0;}
+ .ldp-picker-option-copy strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--ldp-font-ui,12px);line-height:1.3;}
+ .ldp-picker-option-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-micro,9px);font-weight:500;line-height:1.25;}
+ @supports (appearance:base-select){
+ .ldp-reader-select,.ldp-reader-select::picker(select){appearance:base-select;}
+ .ldp-reader-select{display:flex;box-sizing:border-box;align-items:center;gap:8px;padding-inline:10px 8px;
+ border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);color:var(--primary,#222);
+ font:inherit;font-size:var(--ldp-font-base,13px);font-weight:600;line-height:1.35;text-align:left;text-align-last:left;cursor:pointer;}
+ .ldp-reader-select:hover:not(:disabled){border-color:var(--primary-medium,#999);}
+ .ldp-reader-select:focus-visible,.ldp-reader-select:open{border-color:var(--tertiary,#47855f);
+ box-shadow:0 0 0 1px var(--tertiary,#47855f);outline:none;}
+ .ldp-reader-select:disabled{cursor:default;opacity:.5;}
+ .ldp-reader-select::picker-icon{width:16px;height:16px;flex:none;margin-inline-start:auto;color:var(--primary-medium,#777);
+ transition:transform 150ms ease-out;}
+ .ldp-reader-select:open::picker-icon{transform:rotate(180deg);}
+ .ldp-reader-select::picker(select){inline-size:var(--ldp-select-picker-width,anchor-size(width));min-inline-size:0;
+ max-inline-size:var(--ldp-select-picker-max-width,min(300px,calc(100vw - 24px)));
+ max-block-size:var(--ldp-select-picker-max-height,min(300px,40vh));box-sizing:border-box;margin-block:6px;padding:5px;overflow-y:auto;
+ overscroll-behavior:contain;scrollbar-width:thin;
+ scrollbar-color:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff)) transparent;
+ border:1px solid var(--primary-low,#d7d7d7);border-radius:8px;
+ background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 4px 8px rgba(0,0,0,.18);
+ opacity:1;visibility:visible;}
+ .ldp-reader-select option{display:flex;box-sizing:border-box;min-height:34px;align-items:center;gap:9px;padding:4px 8px;
+ border:0;border-radius:6px;background:transparent;color:var(--primary,#222);font:inherit;font-weight:600;
+ line-height:1.35;white-space:nowrap;cursor:pointer;}
+ .ldp-reader-select option:hover,.ldp-reader-select option:focus-visible{
+ background:var(--primary-very-low,#f2f2f2);outline:none;}
+ .ldp-reader-select option:checked{
+ background:color-mix(in srgb,var(--tertiary-low,#dceee2) 68%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
+ .ldp-reader-select option::checkmark{display:none;}
+ .ldp-collection-scope{--ldp-select-picker-max-height:100px;justify-content:center;gap:6px;
+ padding-inline:6px;text-align:center;text-align-last:center;}
+ .ldp-collection-scope::picker-icon{width:14px;height:14px;margin-inline-start:0;}
+ .ldp-collection-scope option{min-height:30px;gap:6px;padding:3px 6px;font-size:var(--ldp-font-ui,12px);}
+ @media (prefers-reduced-motion:reduce){.ldp-reader-select::picker-icon{transition:none;}}
+ }
+ .ldp-topic-edit-label-control{display:flex;align-items:center;flex-wrap:wrap;gap:4px;min-height:38px;padding:4px 6px;}
+ .ldp-topic-edit-label-values{display:contents;}
+ .ldp-topic-edit-label-chip{display:inline-flex;align-items:center;gap:3px;max-width:150px;padding:2px 5px;
+ border-radius:4px;background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);font-size:var(--ldp-font-ui,12px);font-weight:550;}
+ .ldp-topic-edit-label-chip > span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-topic-edit-label-remove{display:inline-flex;width:18px;height:18px;flex:none;align-items:center;justify-content:center;
+ padding:2px;border:0;border-radius:4px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-topic-edit-label-remove:hover{background:var(--primary-low,#ddd);color:var(--primary,#222);}
+ .ldp-topic-edit-label-input{flex:1 1 120px;min-width:90px;height:26px;padding:2px;border:0!important;
+ background:transparent!important;color:inherit!important;font:inherit!important;font-size:var(--ldp-font-base,13px)!important;
+ outline:none!important;box-shadow:none!important;}
+ .ldp-reader-action-footer.ldp-topic-edit-actions{justify-content:flex-start;}
+ .ldp-topic-edit-save,.ldp-topic-edit-cancel{display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;
+ padding:7px;border-radius:6px;font:inherit;cursor:pointer;}
+ .ldp-topic-edit-cancel{border:1px solid var(--primary-low,#ccc);background:transparent;color:var(--primary,#222);}
+ .ldp-topic-edit-save:hover{background:color-mix(in srgb,var(--tertiary,#47855f) 88%,#000);}
+ .ldp-topic-edit-cancel:hover{background:var(--primary-very-low,#f0f0f0);}
+ .ldp-topic-edit-save:disabled,.ldp-topic-edit-cancel:disabled{cursor:default;opacity:.5;}
+ .ldp-topic-edit-status{grid-column:1 / -1;min-width:0;min-height:17px;margin:0;color:var(--danger,#c44);
+ font-size:var(--ldp-font-ui,12px);font-weight:500;line-height:1.35;}
+ .ldp-only-op-toggle{box-sizing:border-box;width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;
+ flex:none;padding:3px;border:0;border-radius:5px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-only-op-toggle:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
+ .ldp-only-op-toggle.active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
+ .ldp-only-op-toggle:disabled{opacity:.45;cursor:default;}
+ .ldp-title-subline{display:grid;grid-template-columns:minmax(0,1fr);align-items:start;justify-items:start;
+ gap:2px;min-width:0;text-align:left;}
+ .ldp-meta-row{display:flex;align-items:center;min-width:0;width:100%;}
+ .ldp-meta{display:flex;flex-wrap:wrap;align-items:center;min-width:0;
+ font-size:var(--ldp-reader-meta-font-size,var(--ldp-font-sm,11px));
+ margin-top:0;line-height:1.2;white-space:nowrap;text-align:left;}
+ .ldp-meta-stats,.ldp-meta-owner-copy,.ldp-meta-owner::before{opacity:.7;}
+ .ldp-meta-owner{display:inline-flex;align-items:center;white-space:nowrap;}
+ .ldp-meta-owner::before{content:' · ';white-space:pre;}
+ .ldp-meta-owner .ldp-only-op-toggle{margin-left:3px;}
+ .ldp-only-op-progress{display:inline-flex;min-width:0;align-items:center;gap:4px;margin-left:5px;
+ color:var(--primary-medium,#68737e);font-size:var(--ldp-reader-meta-font-size,var(--ldp-font-sm,11px));
+ font-variant-numeric:tabular-nums;line-height:1;}
+ .ldp-only-op-progress-track{width:42px;height:4px;flex:none;overflow:hidden;border-radius:999px;
+ background:color-mix(in srgb,var(--primary-low,#d7dde3) 82%,var(--secondary,#fff));}
+ .ldp-only-op-progress-fill{display:block;width:var(--ldp-only-op-progress,0%);height:100%;border-radius:inherit;
+ background:var(--tertiary,#47855f);transition:width .16s ease-out;}
+ .ldp-only-op-progress[data-state="cooldown"] .ldp-only-op-progress-fill,
+ .ldp-only-op-progress[data-state="window"] .ldp-only-op-progress-fill,
+ .ldp-only-op-progress[data-state="waiting"] .ldp-only-op-progress-fill,
+ .ldp-only-op-progress[data-state="retry"] .ldp-only-op-progress-fill,
+ .ldp-only-op-progress[data-state="partial"] .ldp-only-op-progress-fill{background:var(--highlight,#e7a400);}
+ .ldp-only-op-progress[data-state="error"] .ldp-only-op-progress-fill{background:var(--danger,#c84545);}
+ .ldp-only-op-progress[data-state="done"] .ldp-only-op-progress-fill{background:var(--success,#3f8f5f);}
+ .ldp-only-op-progress-value{max-width:min(255px,32vw);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-title-topic-row{display:grid;grid-template-columns:14px minmax(0,1fr) 14px;
+ align-items:center;min-width:0;width:100%;overflow:hidden;text-align:left;}
+ .ldp-title-topic-row:not(.has-overflow){grid-template-columns:minmax(0,1fr);}
+ .ldp-title-topic-row:not(.has-overflow) .ldp-title-topic-scroll-hint{display:none;}
+ .ldp-title-topic-scroller{display:flex;align-items:center;justify-content:flex-start;flex-wrap:nowrap;
+ gap:4px 8px;min-width:0;width:100%;overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;
+ scrollbar-width:none;}
+ .ldp-title-topic-scroller::-webkit-scrollbar{display:none;}
+ .ldp-title-topic-scroll-hint{display:flex;width:14px;height:18px;align-items:center;justify-content:center;
+ color:var(--primary-medium,#777);font-size:0;line-height:0;
+ opacity:0;pointer-events:none;transition:opacity 120ms ease;}
+ .ldp-title-topic-scroll-hint::before{content:"";box-sizing:border-box;width:6px;height:6px;
+ border:solid currentColor;border-width:0 1.5px 1.5px 0;}
+ .ldp-title-topic-scroll-hint-left::before{transform:rotate(135deg);}
+ .ldp-title-topic-scroll-hint-right::before{transform:rotate(-45deg);}
+ .ldp-title-topic-row.can-scroll-left .ldp-title-topic-scroll-hint-left,
+ .ldp-title-topic-row.can-scroll-right .ldp-title-topic-scroll-hint-right{opacity:.78;}
+ .ldp-topic-tags{display:flex;min-width:max-content;flex-wrap:nowrap;gap:4px;margin-top:0;
+ font-size:var(--ldp-reader-topic-tag-font-size,var(--ldp-font-sm,11px));line-height:1.2;}
+ .ldp-topic-tag{display:inline-flex;align-items:center;gap:3px;max-width:none;flex:none;line-height:1.25;
+ padding:1px 5px;border-radius:4px;background:var(--primary-very-low,#f0f0f0);
+ color:inherit;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-topic-tag :is(svg,img){width:1em;height:1em;flex:none;object-fit:contain;}
+ .ldp-topic-tag:hover{background:var(--primary-low,#e0e0e0);color:var(--tertiary,#08c);}
+ .ldp-topic-category{font-weight:600;max-width:none;}
+ .ldp-topic-category-text{display:inline-flex;min-width:0;}
+ .ldp-topic-category-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-topic-category-level{flex:none;}
+ .ldp-header :is(.ldp-title,.ldp-meta-row,.ldp-topic-tags){
+ box-sizing:border-box;padding:1px 4px;}
+ .ldp-head-btns{display:flex;gap:6px;align-items:center;flex:none;justify-content:flex-end;position:relative;margin-top:36px;
+ right:calc(var(--ldp-header-logo-inset) - var(--ldp-header-reserved-right));}
+ .ldp-layout-toggle,.ldp-notifications-toggle,.ldp-history-toggle,.ldp-bookmarks-toggle,.ldp-translate-toggle,.ldp-settings-toggle{cursor:pointer;border:1px solid var(--primary-low,#ccc);
+ background:transparent;color:inherit;border-radius:6px;padding:5px;box-sizing:border-box;
+ width:30px;height:30px;font-size:var(--ldp-font-ui,12px);white-space:nowrap;text-decoration:none;display:inline-flex;
+ align-items:center;justify-content:center;}
+ .ldp-head-btns :is(.ldp-notifications-toggle,.ldp-history-toggle,.ldp-bookmarks-toggle,.ldp-translate-toggle,.ldp-settings-toggle) .ldp-icon{
+ width:15px;height:15px;stroke-width:1.9;}
+ .ldp-notifications-toggle{position:relative;overflow:visible;}
+ .ldp-notification-unread-badge{position:absolute;right:-5px;top:-5px;z-index:1;min-width:15px;height:15px;
+ box-sizing:border-box;padding:0 4px;border:2px solid var(--secondary,#fff);border-radius:999px;
+ background:var(--danger,#d94747);color:#fff;font-size:var(--ldp-font-micro,9px);font-weight:750;line-height:11px;text-align:center;
+ font-variant-numeric:tabular-nums;pointer-events:none;}
+ .ldp-layout-toggle span,.ldp-notifications-toggle > span,.ldp-history-toggle > span,.ldp-bookmarks-toggle > span,.ldp-translate-toggle span,.ldp-settings-toggle span{display:none;}
+ @container ldp-reader (min-width:701px){
+ .ldp-header{--ldp-header-reserved-right:var(--ldp-header-logo-inset);
+ display:grid;grid-template-columns:var(--ldp-home-logo-box-size) minmax(0,1fr) auto;
+ grid-template-rows:auto auto auto;
+ row-gap:1px;column-gap:8px;min-height:0;
+ padding-top:calc(4px + var(--ldp-window-capsule-rail,0px));padding-bottom:4px;}
+ .ldp-home-logo{grid-column:1;grid-row:1 / 4;align-self:center;}
+ .ldp-title-wrap{display:contents;}
+ .ldp-title{grid-column:2 / -1;grid-row:1;min-height:30px;line-height:1.18;}
+ .ldp-header.ldp-title-single-line .ldp-title{min-height:0;}
+ .ldp-title::before{content:"";float:right;
+ width:calc(112px * var(--ldp-reader-display-scale,1) + 32px);
+ height:calc(28px * var(--ldp-reader-display-scale,1));}
+ .ldp-header.ldp-title-single-line .ldp-title::before{height:1.18em;}
+ .ldp-title-subline{display:contents;}
+ .ldp-meta-row{grid-column:2;grid-row:2;}
+ .ldp-title-topic-row{grid-column:2;grid-row:3;align-self:end;}
+ .ldp-head-btns{grid-column:3;grid-row:3;position:static;right:auto;
+ height:0;min-height:0;margin-top:0;align-items:flex-end;align-self:end;overflow:visible;}
+ .ldp-title-actions{top:calc(4px + var(--ldp-window-capsule-rail,0px));}
+ .ldp-overlay.ldp-fullpage .ldp-title{grid-column:2;}
+ .ldp-overlay.ldp-fullpage .ldp-title::before{display:none;}
+ }
+ .ldp-layout-toggle:hover,.ldp-notifications-toggle:hover,.ldp-history-toggle:hover,.ldp-bookmarks-toggle:hover,.ldp-translate-toggle:hover,.ldp-settings-toggle:hover{background:var(--primary-very-low,#f0f0f0);}
+ .ldp-translate-toggle.is-active{color:var(--tertiary,#08c);background:var(--tertiary-very-low,var(--primary-very-low,#f0f0f0));}
+ .ldp-translate-toggle.is-busy{opacity:.7;}
+ .ldp-translation-original{display:contents;}
+ .ldp-translation-text{display:none;}
+ .ldp-translation-active .ldp-translation-text{display:block;margin-top:.35em;padding-left:.65em;border-left:2px solid var(--primary-low,#ddd);color:var(--primary-medium,#666);}
+ .ldp-translation-only .ldp-translation-original{display:none;}
+ .ldp-translation-only .ldp-translation-text{margin-top:0;padding-left:0;border-left:0;color:inherit;}
+ .ldp-notifications-popover,.ldp-history-popover,.ldp-bookmarks-popover{position:fixed;z-index:2147483606;box-sizing:border-box;
+ width:min(420px,calc(100vw - 24px));height:min(68vh,560px);display:flex;flex-direction:column;
+ border:1px solid var(--primary-low,#ddd);border-radius:8px;background:var(--secondary,#fff);
+ color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit);
+ font-weight:var(--ldp-interface-font-weight,400);box-shadow:0 16px 46px rgba(0,0,0,.22);overflow:hidden;pointer-events:auto;}
+ .ldp-notification-mode-tabs,.ldp-notification-tabs{display:grid;gap:2px;
+ margin:8px 10px 0;padding:2px;border-radius:6px;background:var(--primary-very-low,#f3f3f3);user-select:none;}
+ .ldp-notification-mode-tabs{grid-template-columns:repeat(2,minmax(0,1fr));}
+ .ldp-notification-tabs{grid-template-columns:repeat(var(--ldp-notification-tab-count,8),minmax(0,1fr));}
+ .ldp-notification-mode-tab,.ldp-notification-tab{display:inline-flex;width:100%;min-width:0;align-items:center;justify-content:center;gap:3px;
+ border:0;border-radius:4px;padding:6px 2px;background:transparent;color:var(--primary-medium,#777);
+ font:inherit;font-size:var(--ldp-font-xs,10px);font-weight:650;line-height:1;white-space:nowrap;cursor:pointer;}
+ .ldp-notification-mode-tab .ldp-icon,.ldp-notification-tab .ldp-icon{width:12px;height:12px;stroke-width:2;}
+ .ldp-notification-mode-tab:hover,.ldp-notification-tab:hover{color:var(--primary,#222);}
+ .ldp-notification-mode-tab:focus-visible,.ldp-notification-tab:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:-2px;}
+ .ldp-notification-mode-tab.active,.ldp-notification-tab.active{background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 1px 2px rgba(0,0,0,.1);}
+ .ldp-notification-toolbar{display:flex;align-items:center;justify-content:space-between;gap:10px;
+ min-height:30px;padding:5px 10px 0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
+ .ldp-notification-mark-all,.ldp-notification-new-message{display:inline-flex;align-items:center;gap:5px;min-height:26px;padding:4px 7px;
+ border:1px solid var(--primary-low,#ddd);border-radius:6px;background:transparent;color:inherit;
+ font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;text-decoration:none;cursor:pointer;}
+ .ldp-notification-mark-all:hover:not(:disabled),.ldp-notification-new-message:hover{background:var(--primary-very-low,#f3f3f3);color:var(--primary,#222);}
+ .ldp-notification-mark-all:disabled{opacity:.42;cursor:default;}
+ .ldp-popover-search{display:flex;align-items:center;gap:7px;margin:8px 10px 6px;padding:0 9px;
+ min-height:36px;box-sizing:border-box;border:1px solid var(--primary-low,#ddd);border-radius:6px;background:var(--secondary,#fff);}
+ .ldp-popover-search:focus-within{border-color:var(--tertiary,#47855f);}
+ .ldp-popover-search .ldp-icon{width:15px;height:15px;flex:none;color:var(--primary-medium,#777);}
+ .ldp-popover-search-input{min-width:0;flex:1;align-self:center;height:32px;margin:0!important;
+ border:0!important;outline:0!important;box-shadow:none!important;padding:0!important;appearance:none;
+ background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);line-height:32px;}
+ .ldp-popover-search-clear{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;
+ flex:none;border:0;border-radius:4px;padding:0;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-popover-search-clear:hover{background:var(--primary-very-low,#f3f3f3);color:inherit;}
+ .ldp-notification-list{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:2px 0;}
+ .ldp-collection-title{display:flex;height:47px;box-sizing:border-box;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px 8px 12px;
+ border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-base,13px);font-weight:700;}
+ .ldp-collection-title-actions{display:flex;align-items:center;gap:6px;}
+ .ldp-collection-action{position:relative;display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;padding:0;
+ border:1px solid var(--primary-low,#ddd);border-radius:6px;background:transparent;color:var(--primary-medium,#777);
+ font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;}
+ .ldp-collection-action:hover:not(:disabled){
+ border-color:var(--tertiary,#47855f);color:var(--tertiary,#47855f);background:var(--primary-very-low,#f3f3f3);}
+ .ldp-history-sort-toggle[aria-pressed="true"]{border-color:var(--tertiary,#47855f);
+ background:var(--tertiary-low,#dbeeff);color:var(--tertiary,#47855f);}
+ .ldp-collection-action.danger:hover:not(:disabled){border-color:var(--danger,#c94747);color:var(--danger,#c94747);}
+ .ldp-collection-action:disabled{opacity:.42;cursor:default;}
+ .ldp-collection-count{position:absolute;right:-5px;top:-6px;display:flex;min-width:15px;height:15px;box-sizing:border-box;
+ align-items:center;justify-content:center;padding:0 3px;border-radius:999px;background:var(--danger,#c94747);color:#fff;
+ font-size:9px;font-weight:750;line-height:1;}
+ .ldp-collection-scope{width:74px;height:28px;min-width:74px;flex:none;padding:3px 6px;border:1px solid var(--primary-low,#ddd);
+ border-radius:6px;background:var(--secondary,#fff);color:inherit;font:inherit;font-size:var(--ldp-font-base,13px);
+ font-weight:700;line-height:1;cursor:pointer;}
+ .ldp-bookmark-tabs{display:flex;gap:2px;margin:8px 10px 0;padding:2px;border-radius:6px;background:var(--primary-very-low,#f3f3f3);user-select:none;}
+ .ldp-bookmark-tab{flex:1;min-width:0;border:0;border-radius:4px;padding:6px 8px;background:transparent;color:var(--primary-medium,#777);
+ font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:default;}
+ .ldp-bookmark-tab.ldp-bookmark-tab-dragging{cursor:grabbing;opacity:.65;}
+ .ldp-bookmark-tab:hover{color:var(--primary,#222);}
+ .ldp-bookmark-tab.active{background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 1px 2px rgba(0,0,0,.1);}
+ .ldp-reaction-filters{display:flex;gap:5px;margin:0 10px 6px;overflow-x:auto;overscroll-behavior-x:contain;}
+ .ldp-reaction-filter{display:inline-flex;min-width:30px;height:28px;flex:none;align-items:center;justify-content:center;gap:4px;padding:0 7px;
+ border:1px solid var(--primary-low,#ddd);border-radius:6px;background:transparent;color:var(--primary-medium,#777);
+ font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;line-height:1;cursor:pointer;}
+ .ldp-reaction-filter:hover{border-color:var(--tertiary,#47855f);color:var(--primary,#222);background:var(--primary-very-low,#f3f3f3);}
+ .ldp-reaction-filter.active{border-color:var(--tertiary,#47855f);background:var(--tertiary-low,#dbeeff);color:var(--primary,#222);}
+ .ldp-reaction-filter-count{font-variant-numeric:tabular-nums;}
+ .ldp-collection-item{position:relative;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-collection-item:first-child{border-top:none;}
+ .ldp-collection-item .ldp-notification-item{padding-right:56px;border-top:none;}
+ .ldp-collection-item.multi .ldp-notification-item{padding-left:42px;}
+ .ldp-collection-item.selected .ldp-notification-item{
+ background:color-mix(in srgb,var(--tertiary-low,#dbeeff) 32%,var(--secondary,#fff));}
+ .ldp-reaction-record .ldp-notification-item{padding-right:12px;}
+ .ldp-reaction-record-icon{display:inline-flex;width:16px;height:16px;align-items:center;justify-content:center;
+ margin-right:3px;font-size:14px;line-height:1;vertical-align:-3px;}
+ .ldp-reaction-filter img.emoji,.ldp-reaction-filter img.emoji.only-emoji,
+ .ldp-reaction-record-icon img.emoji,.ldp-reaction-record-icon img.emoji.only-emoji{width:16px;height:16px;margin:0;object-fit:contain;}
+ .ldp-collection-select{position:absolute;z-index:1;left:12px;top:50%;transform:translateY(-50%);display:flex;
+ width:18px;height:28px;align-items:center;justify-content:center;cursor:pointer;}
+ .ldp-collection-select-input{width:16px;height:16px;margin:0;accent-color:var(--tertiary,#47855f);cursor:pointer;}
+ .ldp-collection-delete{position:absolute;right:10px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;
+ width:32px;height:32px;padding:0;border:1px solid var(--primary-low,#ddd);border-radius:6px;background:var(--secondary,#fff);
+ color:var(--primary-medium,#777);font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;
+ opacity:0;pointer-events:none;}
+ .ldp-collection-item:hover .ldp-collection-delete,.ldp-collection-item:focus-within .ldp-collection-delete{
+ opacity:1;pointer-events:auto;}
+ .ldp-collection-delete:hover,.ldp-collection-delete:focus-visible{border-color:var(--danger,#c94747);color:var(--danger,#c94747);}
+ @media (hover:none){.ldp-collection-delete{opacity:1;pointer-events:auto;}}
+ .ldp-notification-date-group{padding:0 8px 4px;}
+ .ldp-notification-date-label{padding:8px 4px 5px;color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-sm,11px);font-weight:700;}
+ .ldp-notification-item{display:grid;grid-template-columns:34px minmax(0,1fr);gap:9px;
+ align-items:start;padding:9px 12px;color:inherit;text-decoration:none;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-notification-date-group .ldp-notification-item:not(.ldp-notification-message-item):first-of-type{border-top:none;}
+ .ldp-notification-item:hover{background:var(--primary-very-low,#f5f5f5);color:inherit;}
+ .ldp-notification-item.unread{background:color-mix(in srgb,var(--tertiary-low,#dbeeff) 36%,var(--secondary,#fff));}
+ .ldp-notification-message-item{grid-template-columns:36px 24px minmax(0,1fr) auto;gap:8px;align-items:start;
+ margin:0 0 5px;padding:8px;border:1px solid var(--ldp-divider-line-color,#e5e5e5);border-radius:8px;
+ background:var(--secondary,#fff);}
+ .ldp-notification-date-label + .ldp-notification-message-item{margin-top:1px;}
+ .ldp-notification-message-item:hover{border-color:color-mix(in srgb,var(--primary-medium,#888) 28%,transparent);}
+ .ldp-notification-avatar{width:32px;height:32px;border-radius:50%;object-fit:cover;background:var(--primary-low,#ddd);}
+ .ldp-notification-message-item .ldp-notification-avatar{width:34px;height:34px;}
+ .ldp-notification-avatar-fallback{display:flex;align-items:center;justify-content:center;
+ color:var(--primary-medium,#666);font-size:var(--ldp-font-base,13px);font-weight:700;}
+ .ldp-notification-copy{min-width:0;display:grid;gap:2px;}
+ .ldp-notification-title{font-size:var(--ldp-font-base,13px);font-weight:650;line-height:1.35;overflow-wrap:anywhere;}
+ .ldp-notification-message-item .ldp-notification-title{display:block;
+ font-size:var(--ldp-font-ui,12px);font-weight:600;line-height:1.35;overflow-wrap:anywhere;}
+ .ldp-notification-type-icon{--ldp-notification-type-color:var(--primary-medium,#777);
+ display:inline-flex;align-items:center;justify-content:center;flex:none;width:24px;height:24px;margin-top:1px;
+ border-radius:50%;color:var(--ldp-notification-type-color);
+ background:color-mix(in srgb,var(--ldp-notification-type-color) 12%,transparent);}
+ .ldp-notification-type-icon[data-notification-group="replies"]{--ldp-notification-type-color:#3b82f6;}
+ .ldp-notification-type-icon[data-notification-group="likes"]{--ldp-notification-type-color:#e0527d;}
+ .ldp-notification-type-icon[data-notification-group="boosts"]{--ldp-notification-type-color:#f59e0b;}
+ .ldp-notification-type-icon[data-notification-group="mentions"],
+ .ldp-notification-type-icon[data-notification-group="reactions"]{--ldp-notification-type-color:#8b5cf6;}
+ .ldp-notification-type-icon[data-notification-group="edits"],
+ .ldp-notification-type-icon[data-notification-group="links"]{--ldp-notification-type-color:#2f9e72;}
+ .ldp-notification-type-icon[data-notification-group="inbox"],
+ .ldp-notification-type-icon[data-notification-group="sent"],
+ .ldp-notification-type-icon[data-notification-group="newMessages"],
+ .ldp-notification-type-icon[data-notification-group="unreadMessages"],
+ .ldp-notification-type-icon[data-notification-group="archive"],
+ .ldp-notification-type-icon[data-notification-group="botMessages"]{--ldp-notification-type-color:#64748b;}
+ .ldp-notification-type-icon .ldp-icon{width:14px;height:14px;stroke-width:2;}
+ .ldp-notification-title-text{min-width:0;}
+ .ldp-notification-excerpt{display:-webkit-box;overflow:hidden;color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-xs,10px);line-height:1.35;-webkit-box-orient:vertical;-webkit-line-clamp:2;}
+ .ldp-notification-meta{font-size:var(--ldp-font-xs,10px);line-height:1.3;color:var(--primary-medium,#777);}
+ .ldp-notification-read-state{display:inline-flex;align-self:center;justify-content:center;min-width:28px;padding:3px 5px;
+ border-radius:999px;background:var(--primary-very-low,#f3f3f3);color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-micro,9px);font-weight:650;line-height:1.2;white-space:nowrap;}
+ .ldp-notification-item.unread .ldp-notification-read-state{
+ background:color-mix(in srgb,var(--tertiary-low,#dbeeff) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
+ .ldp-notification-empty,.ldp-notification-error{display:flex;min-height:148px;align-items:center;
+ justify-content:center;padding:18px;text-align:center;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);}
+ .ldp-notification-error{color:var(--danger,#c44);}
+ .ldp-notification-pager{display:grid;grid-template-columns:34px minmax(0,1fr) 34px;align-items:center;
+ gap:8px;padding:7px 9px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-notification-page-prev,.ldp-notification-page-next{width:30px;height:30px;display:inline-flex;
+ align-items:center;justify-content:center;border:1px solid var(--primary-low,#ddd);border-radius:6px;
+ background:transparent;color:inherit;cursor:pointer;}
+ .ldp-notification-page-prev:disabled,.ldp-notification-page-next:disabled{opacity:.38;cursor:default;}
+ .ldp-notification-page-info{text-align:center;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
+ .ldp-settings-popover{--ldp-settings-frozen-height:75px;--ldp-settings-sidebar-width:160px;
+ --ldp-font-micro:9px;--ldp-font-xs:10px;--ldp-font-sm:11px;--ldp-font-ui:12px;--ldp-font-base:13px;
+ --ldp-font-md:14px;--ldp-font-lg:15px;--ldp-font-xl:16px;--ldp-font-2xl:17px;--ldp-font-3xl:18px;
+ position:absolute;left:50%;top:50%;right:auto;z-index:2147483606;
+ transform:translate(-50%,-50%);display:grid;grid-template-columns:var(--ldp-settings-sidebar-width) minmax(0,1fr);
+ width:672px;height:520px;max-width:calc(100% - 32px);max-height:calc(100% - 32px);
+ border:1px solid var(--primary-low,#ddd);border-radius:12px;background:var(--secondary,#fff);
+ color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit);
+ font-weight:var(--ldp-interface-font-weight,400);box-shadow:0 24px 70px rgba(0,0,0,.24);overflow:hidden;pointer-events:auto;}
+ .ldp-settings-popover:not([hidden]){will-change:transform;}
+ .ldp-settings-popover.ldp-settings-window-dragging{cursor:grabbing;user-select:none;}
+ .ldp-settings-close{position:absolute;top:12px;right:12px;z-index:2;display:inline-flex;
+ width:32px;height:32px;align-items:center;justify-content:center;padding:0;border:0;border-radius:7px;
+ background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-settings-close:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
+ .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking){
+ border-color:color-mix(in srgb,var(--primary-low,#ddd) 10%,transparent);
+ background:color-mix(in srgb,var(--secondary,#fff) 75%,transparent);box-shadow:0 24px 70px rgba(0,0,0,.024);}
+ .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-tabs,
+ .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-section > :not(.ldp-settings-fields):not(.ldp-color-groups):not(.ldp-settings-category-groups),
+ .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-color-group-head,
+ .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-category-head,
+ .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-fields > .ldp-setting-row:not(.ldp-range-drag-active):not(.ldp-color-pick-active),
+ .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-fields > .ldp-setting-group:not(:has(.ldp-range-drag-active,.ldp-color-pick-active)),
+ .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-setting-group:has(.ldp-range-drag-active,.ldp-color-pick-active) > :not(.ldp-range-drag-active):not(.ldp-color-pick-active){opacity:.75;}
+ .ldp-settings-tabs{display:flex;min-height:220px;height:100%;flex-direction:column;padding:0 12px 12px;background:var(--primary-very-low,#f7f7f7);
+ border-right:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);box-sizing:border-box;}
+ .ldp-settings-brand{display:flex;height:var(--ldp-settings-frozen-height);flex:none;box-sizing:border-box;
+ align-items:center;justify-content:center;gap:6px;margin:0 -12px;padding:0 4px;
+ border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);color:var(--primary-high,#333);}
+ .ldp-settings-brand-logo{display:block;width:44px;height:44px;flex:none;border-radius:50%;}
+ .ldp-settings-brand-name{display:grid;justify-items:start;color:color-mix(in srgb,var(--tertiary,#47855f) 68%,var(--primary,#222));
+ font-family:inherit;font-style:normal;font-weight:750;letter-spacing:-.025em;font-kerning:normal;text-align:left;white-space:nowrap;}
+ .ldp-settings-brand-name > span:first-child{font-size:var(--ldp-font-lg,15px);line-height:1.05;}
+ .ldp-settings-brand-name > span:not(:first-child){font-size:var(--ldp-font-ui,12px);line-height:1.05;}
+ .ldp-settings-nav-shell{position:relative;display:flex;min-height:0;flex:1;}
+ .ldp-settings-nav{display:flex;width:100%;min-height:0;flex:1;flex-direction:column;padding:5px 3px 5px 0;
+ overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--primary-low,#ddd) transparent;}
+ .ldp-settings-nav-group{display:grid;gap:1px;padding:8px 0 5px;border-top:1px solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-settings-nav-group-label{padding:0 10px 4px;color:var(--primary-medium,#777);font-size:var(--ldp-font-micro,9px);
+ font-weight:750;letter-spacing:.08em;line-height:1.2;}
+ .ldp-settings-tab{display:flex;width:100%;align-items:center;gap:9px;border:none;background:transparent;color:inherit;cursor:default;
+ border-radius:8px;padding:9px 10px;text-align:left;font-family:inherit;
+ font-size:var(--ldp-font-base,13px);font-weight:650;line-height:1.25;}
+ .ldp-settings-tab-draft-count{display:inline-flex;min-width:18px;height:18px;align-items:center;justify-content:center;
+ box-sizing:border-box;margin-left:auto;padding:0 5px;border-radius:9px;background:var(--tertiary,#47855f);color:var(--secondary,#fff);
+ font-size:var(--ldp-font-micro,9px);font-weight:750;line-height:1;}
+ .ldp-settings-tab-draft-count[hidden]{display:none;}
+ .ldp-settings-tab .ldp-icon{width:16px;height:16px;flex:none;color:var(--primary-medium,#777);}
+ .ldp-settings-tab.active .ldp-icon{color:var(--tertiary,#47855f);}
+ .ldp-settings-tab.active{background:var(--tertiary-low,#dceee2);color:var(--tertiary,#47855f);
+ box-shadow:0 1px 4px rgba(0,0,0,.08);}
+ .ldp-settings-tab[data-settings-panel="user"]{position:relative;margin-bottom:10px;}
+ .ldp-settings-footer{margin-top:auto;padding-top:10px;
+ border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-settings-theme{display:grid;grid-template-columns:repeat(3,30px);justify-content:center;gap:4px;}
+ .ldp-settings-theme-button{display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;
+ box-sizing:border-box;padding:6px;border:1px solid transparent;border-radius:6px;background:transparent;
+ color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-settings-theme-button:hover{background:var(--secondary,#fff);color:var(--primary,#222);}
+ .ldp-settings-theme-button.active{border-color:color-mix(in srgb,var(--tertiary,#47855f) 45%,transparent);
+ background:var(--secondary,#fff);color:var(--tertiary,#47855f);}
+ .ldp-settings-panel{min-width:0;box-sizing:border-box;padding:0 20px 18px;
+ height:100%;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:none;-ms-overflow-style:none;}
+ .ldp-settings-panel:has(> .ldp-settings-section[data-settings-panel="user"]:not([hidden])){overflow-y:hidden;}
+ .ldp-settings-panel::-webkit-scrollbar{display:none;}
+ .ldp-settings-search-shell{position:sticky;top:0;z-index:3;display:grid;grid-template-columns:minmax(0,1fr) auto;
+ align-items:center;gap:5px 12px;min-height:58px;box-sizing:border-box;margin:0 -20px;padding:10px 58px 9px 20px;
+ background:var(--secondary,#fff);cursor:grab;touch-action:none;user-select:none;}
+ .ldp-settings-search-shell[hidden],.ldp-settings-search-empty[hidden],.ldp-settings-log-panel[hidden],.ldp-settings-draft-bar[hidden]{display:none;}
+ .ldp-settings-search{display:grid;grid-template-columns:18px minmax(0,1fr) 28px;align-items:center;min-width:0;height:36px;
+ box-sizing:border-box;padding:0 4px 0 10px;border:1px solid var(--primary-low,#ddd);border-radius:9px;
+ background:var(--primary-very-low,#f7f7f7);color:var(--primary-medium,#777);cursor:text;touch-action:auto;user-select:auto;}
+ .ldp-settings-search:focus-within{border-color:var(--tertiary,#47855f);box-shadow:0 0 0 3px color-mix(in srgb,var(--tertiary,#47855f) 14%,transparent);}
+ .ldp-settings-search-input{min-width:0;height:32px!important;padding-inline:7px!important;border:0!important;outline:0;background:transparent!important;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);}
+ .ldp-settings-search-clear{display:inline-flex;width:28px;height:28px;align-items:center;justify-content:center;padding:0;border:0;border-radius:6px;
+ background:transparent;color:inherit;cursor:pointer;}
+ .ldp-settings-search-status{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);white-space:nowrap;}
+ .ldp-settings-search-empty{display:grid;justify-items:center;gap:5px;padding:42px 20px;color:var(--primary-medium,#777);text-align:center;}
+ .ldp-settings-search-empty .ldp-icon{width:24px;height:24px;}
+ .ldp-settings-search-empty strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);}
+ .ldp-settings-search-empty span{font-size:var(--ldp-font-sm,11px);}
+ .ldp-settings-intro{position:sticky;top:0;z-index:1;align-self:start;display:grid;gap:4px;
+ min-height:var(--ldp-settings-frozen-height);box-sizing:border-box;
+ margin:0 -20px;padding:18px 58px 14px 20px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
+ background:var(--secondary,#fff);cursor:grab;touch-action:none;user-select:none;}
+ .ldp-settings-panel.is-settings-pages .ldp-settings-intro{position:relative;top:auto;min-height:0;margin:0;padding:26px 0 12px;
+ border:0;background:transparent;cursor:default;touch-action:auto;user-select:text;}
+ .ldp-settings-panel.is-settings-pages .ldp-settings-intro::before{display:none;}
+ .ldp-settings-draft-bar{position:sticky;bottom:0;z-index:4;display:flex;align-items:center;justify-content:space-between;gap:12px;
+ margin:0 -20px;padding:11px 20px;background:color-mix(in srgb,var(--secondary,#fff) 94%,transparent);box-shadow:0 -10px 24px rgba(0,0,0,.08);backdrop-filter:blur(12px);}
+ .ldp-settings-draft-status{min-width:0;overflow:hidden;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-settings-save-all{display:inline-flex;align-items:center;gap:6px;flex:none;padding:8px 11px;border:0;border-radius:7px;
+ background:var(--tertiary,#47855f);color:#fff;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;cursor:pointer;}
+ .ldp-settings-save-all:disabled{opacity:.38;cursor:default;}
+ .ldp-settings-intro::before{content:"";position:absolute;top:7px;left:calc(50% - var(--ldp-settings-sidebar-width) / 2);width:28px;height:3px;
+ border-radius:999px;background:var(--primary-low,#ddd);transform:translateX(-50%);}
+ .ldp-settings-popover.ldp-settings-window-dragging :is(.ldp-settings-intro,.ldp-settings-search-shell){cursor:grabbing;}
+ .ldp-settings-popover.ldp-settings-window-dragging .ldp-settings-intro::before{background:var(--tertiary,#47855f);}
+ .ldp-settings-title{margin:0;font-size:var(--ldp-font-xl,16px);font-weight:700;line-height:1.3;color:var(--primary,#222);}
+ .ldp-settings-description,.ldp-reader-action-intro{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);line-height:1.5;}
+ .ldp-settings-panel .ldp-settings-section[data-settings-panel="user"]{gap:0;}
+ .ldp-settings-section[data-settings-panel="user"] .ldp-settings-intro{padding-right:100px;}
+ .ldp-user-info-last-refresh{position:absolute;right:20px;bottom:8px;color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-micro,9px);font-variant-numeric:tabular-nums;line-height:1;white-space:nowrap;}
+ .ldp-user-info-title-refresh{position:absolute;right:52px;top:12px;display:inline-flex;width:32px;height:32px;
+ align-items:center;justify-content:center;padding:0;border:0;border-radius:7px;
+ background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-user-info-title-refresh:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
+ .ldp-user-info-title-refresh:disabled{opacity:.62;cursor:wait;}
+ .ldp-user-info-title-refresh.is-refreshing .ldp-icon{animation:ldp-spin .9s linear infinite;}
+ .ldp-user-info-content{display:grid;gap:4px;padding:0 0 2px;}
+ .ldp-user-info-tabs{display:flex;align-items:center;gap:18px;padding:0 2px;border-bottom:0;background:transparent;}
+ .ldp-user-info-tab{position:relative;display:inline-flex;min-width:0;height:25px;align-items:center;justify-content:center;gap:5px;
+ box-sizing:border-box;padding:0 3px;border:0;border-radius:0;background:transparent;color:var(--primary-medium,#777);
+ font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;}
+ .ldp-user-info-tab .ldp-icon{width:14px;height:14px;flex:none;}
+ .ldp-user-info-tab:hover{color:var(--primary,#222);}
+ .ldp-user-info-tab.active{color:var(--tertiary,#47855f);}
+ .ldp-user-info-tab.active::after{content:"";position:absolute;left:2px;right:2px;bottom:-1px;height:2px;
+ border-radius:2px 2px 0 0;background:var(--tertiary,#47855f);}
+ .ldp-user-info-view{padding-top:14px;}
+ .ldp-user-info-loading,.ldp-user-info-error{display:grid;min-height:150px;place-items:center;padding:14px;
+ border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--primary-very-low,#f7f7f7);
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);text-align:center;}
+ .ldp-user-info-profile{position:relative;overflow:hidden;border:1px solid var(--primary-low,#ddd);border-radius:11px;
+ background:var(--secondary,#fff);}
+ .ldp-user-info-cover{height:78px;background:linear-gradient(135deg,
+ color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff)),var(--primary-very-low,#f4f4f4));}
+ .ldp-user-info-profile-body{position:relative;display:grid;grid-template-columns:60px minmax(0,1fr);gap:9px;
+ padding:0 13px 10px;}
+ .ldp-user-info-avatar{display:grid;width:54px;height:54px;place-items:center;margin-top:-22px;border:3px solid var(--secondary,#fff);
+ border-radius:50%;background:var(--primary-low,#ddd);box-shadow:0 2px 8px rgba(0,0,0,.14);font-size:var(--ldp-font-xl,16px);font-weight:750;}
+ .ldp-user-info-avatar :is(img,.ldp-persistent-avatar-fallback){width:100%;height:100%;border-radius:50%;object-fit:cover;}
+ .ldp-user-info-avatar .ldp-avatar-with-flair{width:100%;height:100%;}
+ .ldp-user-info-avatar .ldp-avatar-flair{right:-3px;bottom:-2px;width:18px;height:18px;border-width:2px;}
+ .ldp-user-info-identity{min-width:0;padding-top:6px;}
+ .ldp-user-info-name-row{display:flex;min-width:0;align-items:center;gap:6px;}
+ .ldp-user-info-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--primary,#222);
+ font-size:var(--ldp-font-2xl,17px);font-weight:750;line-height:1.25;}
+ .ldp-user-info-level{flex:none;padding:2px 7px;border-radius:999px;background:var(--tertiary-low,#dceee2);
+ color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;}
+ .ldp-user-info-username{margin-top:1px;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);}
+ .ldp-user-info-title{display:flex;align-items:center;gap:5px;margin-top:2px;color:var(--primary-high,#333);
+ font-size:var(--ldp-font-ui,12px);}
+ .ldp-user-info-title > .ldp-avatar-flair{position:static;width:14px;height:14px;flex:none;border-width:1px;}
+ .ldp-user-info-site{display:inline-flex;max-width:100%;align-items:center;gap:5px;margin-top:3px;color:var(--tertiary,#47855f);
+ font-size:var(--ldp-font-ui,12px);font-weight:650;text-decoration:none;}
+ .ldp-user-info-site .ldp-icon{width:13px;height:13px;flex:none;}
+ .ldp-user-info-site span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-user-info-bio{grid-column:1/-1;margin-top:0;color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);line-height:1.4;}
+ .ldp-user-profile-facts{display:flex;align-items:center;flex-wrap:wrap;}
+ .ldp-user-profile-fact{display:inline-flex;min-width:0;align-items:baseline;gap:4px;}
+ .ldp-user-profile-fact-label{flex:none;color:var(--primary-medium,#777);}
+ .ldp-user-profile-fact-value{min-width:0;color:var(--primary,#222);overflow-wrap:anywhere;}
+ .ldp-user-profile-fact.is-accent .ldp-user-profile-fact-value{color:var(--tertiary,#47855f);}
+ .ldp-user-profile-facts.is-settings{grid-column:1/-1;gap:4px 12px;padding-top:7px;
+ border-top:1px solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-sm,11px);line-height:1.35;}
+ .ldp-user-profile-facts.is-settings .is-wide{flex-basis:100%;}
+ .ldp-connect-card{display:grid;gap:6px;padding:8px;border:1px solid var(--primary-low,#ddd);border-radius:11px;
+ background:var(--secondary,#fff);}
+ .ldp-connect-head{display:flex;align-items:flex-start;justify-content:space-between;gap:9px;}
+ .ldp-connect-heading{display:grid;gap:2px;}
+ .ldp-connect-heading strong{color:var(--primary,#222);font-size:var(--ldp-font-base,13px);line-height:1.35;}
+ .ldp-connect-heading small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.4;}
+ .ldp-connect-status{flex:none;padding:3px 7px;border-radius:999px;background:var(--tertiary-low,#dceee2);
+ color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;}
+ .ldp-connect-status.is-unmet{background:var(--danger-low,#f7dddd);color:var(--danger,#c84545);}
+ .ldp-connect-rings{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px;}
+ .ldp-connect-ring{display:grid;place-items:center;gap:2px;min-width:0;text-align:center;}
+ .ldp-connect-ring-visual{display:grid;width:50px;height:50px;place-items:center;border-radius:50%;
+ background:conic-gradient(var(--ldp-connect-color,var(--tertiary,#47855f)) var(--ldp-connect-progress,0%),var(--primary-low,#ddd) 0);}
+ .ldp-connect-ring-visual::before{content:"";grid-area:1/1;width:40px;height:40px;border-radius:50%;background:var(--secondary,#fff);}
+ .ldp-connect-ring-value{z-index:1;grid-area:1/1;display:grid;color:var(--primary,#222);font-size:var(--ldp-font-sm,11px);
+ font-weight:400;font-variant-numeric:tabular-nums;line-height:1.2;}
+ .ldp-connect-ring-value small{color:var(--primary-medium,#777);font-size:var(--ldp-font-micro,9px);font-weight:400;}
+ .ldp-connect-ring-label{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
+ color:var(--primary-high,#333);font-size:var(--ldp-font-sm,11px);font-weight:400;}
+ .ldp-connect-metric{cursor:help;}
+ .ldp-connect-detail-groups{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;align-items:start;}
+ .ldp-connect-group{display:grid;gap:4px;}
+ .ldp-connect-group-title{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-weight:400;}
+ .ldp-connect-bars,.ldp-connect-quotas{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px;}
+ .ldp-connect-detail-groups .ldp-connect-bars,
+ .ldp-connect-detail-groups .ldp-connect-quotas{grid-template-columns:minmax(0,1fr);}
+ .ldp-connect-bar,.ldp-connect-quota{display:grid;gap:2px;padding:5px;border:1px solid var(--primary-low,#ddd);border-radius:8px;}
+ .ldp-connect-bar.is-short{border-color:color-mix(in srgb,var(--danger,#c84545) 34%,var(--primary-low,#ddd));
+ background:color-mix(in srgb,var(--danger-low,#f7dddd) 24%,var(--secondary,#fff));}
+ .ldp-connect-bar-copy{display:flex;align-items:baseline;justify-content:space-between;gap:10px;
+ font-size:var(--ldp-font-sm,11px);line-height:1.35;}
+ .ldp-connect-bar-copy span{color:var(--primary-high,#333);font-weight:400;}
+ .ldp-connect-bar-copy strong,.ldp-connect-quota-copy strong{color:var(--ldp-connect-color,var(--tertiary,#47855f));font-weight:400;font-variant-numeric:tabular-nums;}
+ .ldp-connect-bar-track{height:4px;overflow:hidden;border-radius:999px;background:var(--primary-low,#ddd);}
+ .ldp-connect-bar-fill{position:relative;display:block;width:var(--ldp-connect-progress,0%);max-width:100%;height:100%;border-radius:inherit;
+ background:var(--ldp-connect-color,var(--tertiary,#47855f));}
+ .ldp-connect-bar.is-goal{--ldp-connect-sheen-opacity:.24;--ldp-connect-sheen-duration:5.2s;}
+ .ldp-connect-bar.is-goal .ldp-connect-bar-fill{overflow:hidden;}
+ .ldp-connect-bar.is-goal .ldp-connect-bar-fill::after{content:"";position:absolute;inset:-1px auto -1px -45%;width:38%;
+ border-radius:inherit;background:linear-gradient(90deg,transparent,rgba(255,255,255,var(--ldp-connect-sheen-opacity)),transparent);
+ transform:skewX(-18deg);animation:ldp-connect-goal-sheen var(--ldp-connect-sheen-duration) cubic-bezier(.22,.61,.36,1) infinite;}
+ .ldp-connect-bar.is-over{--ldp-connect-sheen-opacity:.3;--ldp-connect-sheen-duration:4.6s;}
+ .ldp-connect-bar.is-over-high{--ldp-connect-sheen-opacity:.36;--ldp-connect-sheen-duration:4s;}
+ .ldp-connect-bar.is-over-ultra{--ldp-connect-sheen-opacity:.42;--ldp-connect-sheen-duration:3.5s;}
+ .ldp-connect-bar.is-over-epic{--ldp-connect-sheen-opacity:.48;--ldp-connect-sheen-duration:3s;}
+ .ldp-connect-bar.is-over-high .ldp-connect-bar-track{box-shadow:0 0 0 1px color-mix(in srgb,var(--ldp-connect-color) 14%,transparent);}
+ .ldp-connect-bar.is-over-ultra .ldp-connect-bar-track{box-shadow:0 0 0 1px color-mix(in srgb,var(--ldp-connect-color) 20%,transparent),0 1px 4px color-mix(in srgb,var(--ldp-connect-color) 14%,transparent);}
+ .ldp-connect-bar.is-over-epic .ldp-connect-bar-track{box-shadow:0 0 0 1px color-mix(in srgb,var(--ldp-connect-color) 26%,transparent),0 1px 5px color-mix(in srgb,var(--ldp-connect-color) 18%,transparent);}
+ @keyframes ldp-connect-goal-sheen{
+ 0%,18%{left:-45%;opacity:0;}
+ 24%{opacity:1;}
+ 48%{left:112%;opacity:1;}
+ 54%,100%{left:112%;opacity:0;}}
+ .ldp-connect-quota-copy{display:flex;align-items:baseline;justify-content:space-between;gap:8px;
+ color:var(--primary-high,#333);font-size:var(--ldp-font-sm,11px);font-weight:400;}
+ .ldp-connect-error{display:flex;align-items:center;justify-content:space-between;gap:9px;padding:8px;border-radius:9px;
+ background:var(--primary-very-low,#f7f7f7);color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
+ @container ldp-reader (max-width:480px){
+ .ldp-connect-rings{grid-template-columns:repeat(2,minmax(0,1fr));}
+ .ldp-connect-detail-groups,
+ .ldp-connect-bars,.ldp-connect-quotas{grid-template-columns:minmax(0,1fr);}
+ .ldp-user-info-profile-body{grid-template-columns:56px minmax(0,1fr);padding-inline:12px;}
+ .ldp-user-info-avatar{width:50px;height:50px;}
+ }
+ @media (prefers-reduced-motion:reduce){
+ .ldp-connect-bar.is-goal .ldp-connect-bar-fill::after{animation:none;display:none;}}
+ .ldp-about-content{display:grid;gap:18px;padding:20px 0 4px;}
+ .ldp-about-hero{display:flex;align-items:center;gap:12px;padding-bottom:18px;
+ border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-about-logo{display:block;width:50px;height:50px;flex:none;border-radius:50%;}
+ .ldp-about-identity{display:grid;min-width:0;gap:3px;}
+ .ldp-about-name{margin:0;color:var(--primary,#222);font-size:var(--ldp-font-xl,16px);font-weight:750;line-height:1.2;}
+ .ldp-about-tagline{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);line-height:1.45;}
+ .ldp-about-version{display:inline-flex;flex:none;align-self:flex-start;padding:4px 7px;border-radius:999px;
+ background:var(--primary-very-low,#f2f4f6);color:var(--primary-high,#333);
+ font-size:var(--ldp-font-sm,11px);font-weight:650;font-variant-numeric:tabular-nums;line-height:1;}
+ .ldp-about-links{display:grid;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-about-link{display:grid;grid-template-columns:32px minmax(0,1fr) 16px;gap:10px;align-items:center;padding:11px 0;
+ color:inherit;text-decoration:none;}
+ .ldp-about-link:hover .ldp-about-link-copy strong,.ldp-about-link:focus-visible .ldp-about-link-copy strong{
+ color:var(--tertiary,#47855f);text-decoration:underline;}
+ .ldp-about-link:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:3px;border-radius:4px;}
+ .ldp-about-link-icon{display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center;
+ border-radius:9px;background:var(--tertiary-low,#dceee2);color:var(--tertiary,#47855f);}
+ .ldp-about-link-copy{display:grid;min-width:0;gap:2px;}
+ .ldp-about-link-copy strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);line-height:1.35;}
+ .ldp-about-link-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
+ .ldp-about-link > .ldp-icon{width:16px;height:16px;color:var(--primary-medium,#777);}
+ .ldp-about-link-icon .ldp-icon{width:16px;height:16px;}
+ .ldp-about-features{display:grid;}
+ .ldp-about-feature{display:grid;grid-template-columns:32px minmax(0,1fr);gap:10px;align-items:start;padding:11px 0;
+ border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-about-feature-icon{display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center;
+ border-radius:9px;background:var(--tertiary-low,#dceee2);color:var(--tertiary,#47855f);}
+ .ldp-about-feature-copy{display:grid;gap:2px;}
+ .ldp-about-credits{display:grid;gap:4px;padding:12px;border:1px solid var(--primary-low,#ddd);border-radius:9px;
+ background:var(--primary-very-low,#f7f7f7);}
+ .ldp-about-credits a{color:var(--tertiary,#47855f);font-weight:650;text-decoration:none;}
+ .ldp-about-credits a:hover{text-decoration:underline;}
+ .ldp-settings-category-groups{display:grid;gap:14px;}
+ .ldp-settings-category-list{display:grid;border-top:0;}
+ .ldp-settings-category-list > .ldp-setting-row{padding:11px 0;border-bottom:0;}
+ .ldp-settings-category-content{display:grid;gap:10px;padding:10px 0;}
+ .ldp-color-groups{display:grid;gap:14px;}
+ .ldp-settings-category-group,.ldp-color-group{display:grid;min-width:0;gap:0;padding:0 14px;border:1px solid var(--primary-low,#ddd);
+ border-radius:10px;background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 34%,var(--secondary,#fff));}
+ .ldp-color-group-head{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:2px 12px;padding:12px 0 10px;
+ border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
+ transition:opacity .12s ease-out;}
+ .ldp-color-group-head-copy{display:grid;gap:2px;min-width:0;}
+ .ldp-color-group-head-actions{display:flex;align-items:center;gap:4px;}
+ .ldp-color-group.is-disabled > .ldp-settings-fields{opacity:.42;}
+ .ldp-color-group-title{margin:0;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);font-weight:700;line-height:1.35;}
+ .ldp-color-group-description{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
+ .ldp-settings-fields{display:grid;gap:0;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-setting-row{display:grid;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:16px;
+ margin:0;padding:13px 0;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-base,13px);}
+ .ldp-color-group > .ldp-settings-fields > .ldp-setting-row{grid-template-columns:136px minmax(0,1fr);}
+ .ldp-setting-label{font-weight:650;white-space:nowrap;color:var(--primary-high,#333);}
+ .ldp-setting-group{min-width:0;padding:10px 0 4px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-setting-group-title{padding:0 0 2px;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);font-weight:700;line-height:1.35;}
+ .ldp-setting-group > .ldp-setting-row{padding:9px 0 9px 12px;border-bottom:0;}
+ .ldp-setting-group > .ldp-setting-row + .ldp-setting-row{border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 62%,transparent);}
+ .ldp-setting-group .ldp-setting-label{color:var(--primary-medium,#777);font-weight:600;}
+ .ldp-font-scope-group{padding-top:0;}
+ .ldp-settings-category-content > .ldp-settings-fields,.ldp-color-group > .ldp-settings-fields,.ldp-font-settings-fields{border-top:0;}
+ .ldp-about-feature:last-child,.ldp-settings-category-content > .ldp-settings-fields > :last-child,
+ .ldp-color-group > .ldp-settings-fields > :is(.ldp-setting-row,.ldp-setting-group):last-child,
+ .ldp-settings-category-content .ldp-font-scope-group:not([hidden]){border-bottom:0;}
+ .ldp-setting-help-tooltip{position:absolute;z-index:2147483646;box-sizing:border-box;width:max-content;max-width:min(380px,calc(100% - 24px));
+ pointer-events:none;opacity:0;transform:translateY(3px);
+ transition:opacity .12s ease,transform .12s ease;}
+ .ldp-setting-help-tooltip.is-visible{opacity:1;transform:translateY(0);}
+ .ldp-settings-section:not([hidden]){display:grid;gap:18px;align-content:start;}
+ .ldp-motion-choice-row{grid-template-columns:88px minmax(0,240px);justify-content:space-between;border-bottom:0;}
+ .ldp-loading-animation-select{width:100%;}
+ .ldp-loading-settings-preview{display:grid;gap:10px;padding-top:2px;}
+ .ldp-loading-settings-preview-head{display:flex;align-items:center;justify-content:space-between;gap:12px;}
+ .ldp-loading-settings-preview-head > span{display:flex;min-width:0;align-items:baseline;gap:8px;}
+ .ldp-loading-settings-preview-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);}
+ .ldp-loading-settings-preview-head small{overflow:hidden;color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-loading-preview-reroll{flex:none;border:1px solid var(--primary-low,#ddd);border-radius:7px;padding:6px 9px;background:var(--secondary,#fff);
+ color:var(--primary-high,#333);font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;}
+ .ldp-loading-preview-reroll:hover{border-color:color-mix(in srgb,var(--tertiary,#47855f) 52%,var(--primary-low,#ddd));background:var(--primary-very-low,#f4f4f4);}
+ .ldp-loading-preview-stage{display:grid;min-height:184px;place-items:center;overflow:hidden;}
+ .ldp-loading-preview-stage .ldp-loading-visual{width:min(304px,100%);height:184px;max-width:100%;}
+ .ldp-loading-preview-stage .ldp-loading-visual-inner{transform:scale(.84);}
+ .ldp-loading-settings-note{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.5;}
+ .ldp-performance-presets{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;}
+ .ldp-performance-preset{min-width:0;border:1px solid var(--primary-low,#ddd);border-radius:7px;
+ padding:8px 5px;background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;}
+ .ldp-performance-preset:hover{background:var(--primary-very-low,#f4f4f4);}
+ .ldp-performance-fields .ldp-setting-row{grid-template-columns:minmax(0,1fr) 116px;gap:14px;padding:10px 0;}
+ .ldp-performance-copy{min-width:0;display:grid;gap:2px;}
+ .ldp-performance-copy strong{font-size:var(--ldp-font-base,13px);line-height:1.3;}
+ .ldp-performance-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.35;}
+ .ldp-performance-control,.ldp-reader-window-input-wrap{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:6px;}
+ .ldp-performance-control input{box-sizing:border-box;width:100%;height:34px;border:1px solid var(--primary-low,#ccc);
+ border-radius:7px;background:var(--secondary,#fff);color:inherit;padding:5px 8px;text-align:right;font:inherit;
+ font-variant-numeric:tabular-nums;}
+ .ldp-performance-control em{min-width:24px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-style:normal;white-space:nowrap;}
+ .ldp-performance-footer,.ldp-layout-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;}
+ :is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset){display:inline-flex;align-items:center;gap:6px;flex:none;border:1px solid var(--primary-low,#ddd);
+ border-radius:7px;padding:7px 9px;background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;}
+ :is(.ldp-performance-reset,.ldp-layout-reset):hover:not(:disabled){background:var(--primary-very-low,#f4f4f4);}
+ .ldp-reader-window-reset:hover:not(:disabled){background:var(--secondary,#fff);}
+ :is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset):disabled{opacity:.38;cursor:default;}
+ .ldp-settings-log-tabs{display:inline-flex;justify-self:start;gap:4px;padding:4px;border-radius:10px;
+ background:var(--primary-very-low,#f4f4f4);}
+ .ldp-settings-log-tab{display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:6px 12px;border:0;border-radius:7px;
+ background:transparent;color:var(--primary-medium,#777);font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;}
+ .ldp-settings-log-tab.active{background:var(--secondary,#fff);color:var(--tertiary,#47855f);box-shadow:0 1px 4px rgba(0,0,0,.1);}
+ .ldp-settings-log-panel:not([hidden]){display:grid;gap:16px;}
+ :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck){display:grid;grid-template-columns:auto minmax(0,1fr);gap:5px 10px;align-items:start;
+ padding:10px;border:1px solid var(--primary-low,#ddd);border-radius:8px;background:var(--primary-very-low,#f7f7f7);}
+ .ldp-request-flow-bottleneck{gap:8px 10px;}
+ :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck) strong{padding:2px 7px;border-radius:999px;background:var(--success-low,#dceee2);
+ color:var(--success,#47855f);font-size:var(--ldp-font-sm,11px);line-height:1.35;white-space:nowrap;}
+ :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck)[data-level="warning"] strong{background:var(--highlight-low,#fff8d8);color:var(--highlight-high,#806200);}
+ :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck)[data-level="danger"] strong{background:var(--danger-low,#f7dddd);color:var(--danger,#c84545);}
+ :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck) p{margin:0;color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);line-height:1.45;}
+ .ldp-resource-monitor-updated{grid-column:2;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);
+ font-variant-numeric:tabular-nums;line-height:1.35;}
+ .ldp-resource-monitor-table{--ldp-resource-monitor-columns:minmax(116px,1fr) minmax(108px,1.05fr) repeat(3,minmax(52px,.64fr));
+ min-width:0;overflow:hidden;border:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
+ border-radius:10px;background:var(--secondary,#fff);}
+ .ldp-resource-monitor-table-head,.ldp-resource-monitor-row{display:grid;
+ grid-template-columns:var(--ldp-resource-monitor-columns);column-gap:8px;align-items:center;box-sizing:border-box;}
+ .ldp-resource-monitor-table-head{min-height:34px;padding:8px 12px;
+ border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);
+ background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff));
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:650;line-height:1.2;text-align:right;}
+ .ldp-resource-monitor-table-head span:first-child{text-align:left;}
+ .ldp-resource-monitor-table-head span:nth-child(2){color:var(--primary-high,#333);font-weight:700;}
+ .ldp-resource-monitor-row{min-height:54px;padding:10px 12px;}
+ .ldp-resource-monitor-row + .ldp-resource-monitor-row{border-top:var(--ldp-divider-line-width,1px) solid
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);}
+ .ldp-resource-monitor-copy{display:grid;min-width:0;gap:2px;}
+ .ldp-resource-monitor-copy strong{color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);font-weight:650;line-height:1.3;}
+ .ldp-resource-monitor-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.3;}
+ .ldp-resource-monitor-current{color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);font-weight:750;
+ font-variant-numeric:tabular-nums;line-height:1.25;text-align:right;white-space:nowrap;}
+ .ldp-resource-monitor-summary{display:contents;}
+ .ldp-resource-monitor-summary span{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);
+ font-variant-numeric:tabular-nums;line-height:1.25;text-align:right;white-space:nowrap;}
+ .ldp-resource-monitor-summary span:last-child{color:var(--primary-high,#333);font-weight:650;}
+ .ldp-resource-monitor-trend-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;}
+ .ldp-resource-monitor-trend-list{border-block:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-resource-monitor-trend-row{display:grid;grid-template-columns:92px 76px minmax(120px,1fr);gap:8px;align-items:center;
+ min-height:48px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-resource-monitor-trend-row:first-child{border-top:0;}
+ .ldp-resource-monitor-trend-row > strong{color:var(--primary-high,#333);font-size:var(--ldp-font-xs,10px);
+ font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap;}
+ .ldp-resource-monitor-trend-row svg{display:block;width:100%;height:34px;overflow:visible;}
+ .ldp-resource-monitor-trend-row polyline{fill:none;stroke:var(--tertiary,#47855f);stroke-width:2;stroke-linecap:round;
+ stroke-linejoin:round;vector-effect:non-scaling-stroke;}
+ .ldp-resource-monitor-trend-row[data-resource-monitor-chart="dom"] polyline{stroke:var(--ldp-request-metric-navy,#365673);}
+ .ldp-resource-monitor-trend-row[data-resource-monitor-chart="retainedFloors"] polyline{stroke:var(--ldp-request-metric-bronze,#72551f);}
+ .ldp-resource-monitor-evidence-head span,.ldp-resource-monitor-events-head span{color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;text-align:right;}
+ .ldp-resource-monitor-scope-table{width:100%;max-width:100%;min-width:0;overflow:hidden;border:var(--ldp-divider-line-width,1px) solid
+ var(--ldp-divider-line-color,#e5e5e5);border-radius:10px;background:var(--secondary,#fff);}
+ .ldp-resource-monitor-scope-head,.ldp-resource-monitor-scope-row{display:grid;
+ grid-template-columns:minmax(110px,.7fr) minmax(120px,.8fr) repeat(2,minmax(0,1fr));
+ column-gap:8px;box-sizing:border-box;padding:8px 10px;}
+ .ldp-resource-monitor-scope-head{background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff));
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:650;align-items:center;}
+ .ldp-resource-monitor-scope-row{min-height:68px;grid-template-rows:auto auto;row-gap:6px;align-items:start;
+ border-top:var(--ldp-divider-line-width,1px) solid
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);}
+ .ldp-resource-monitor-scope-row > *{min-width:0;}
+ .ldp-resource-monitor-scope-row strong{grid-row:1 / 3;align-self:center;color:var(--primary-high,#333);
+ font-size:var(--ldp-font-sm,11px);line-height:1.3;}
+ .ldp-resource-monitor-scope-row span,.ldp-resource-monitor-scope-row small{color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;line-height:1.35;overflow-wrap:anywhere;}
+ .ldp-resource-monitor-scope-row [data-resource-monitor-scope-basis]{grid-column:2 / -1;grid-row:2;
+ padding-top:5px;border-top:var(--ldp-divider-line-width,1px) solid
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 62%,transparent);}
+ .ldp-resource-monitor-scope-row [data-resource-monitor-scope-basis]::before{content:"依据 · ";
+ color:var(--primary-high,#333);font-weight:650;}
+ .ldp-resource-monitor-event-log{max-height:280px;overflow:auto;border-block:var(--ldp-divider-line-width,1px) solid
+ var(--ldp-divider-line-color,#e5e5e5);scrollbar-gutter:stable;}
+ .ldp-resource-monitor-event-row{display:grid;grid-template-columns:82px 34px 76px minmax(0,1fr) 104px;gap:8px;
+ align-items:start;min-height:34px;box-sizing:border-box;padding:7px 0;border-top:var(--ldp-divider-line-width,1px) solid
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 68%,transparent);font-size:var(--ldp-font-xs,10px);line-height:1.35;}
+ .ldp-resource-monitor-event-row:first-child{border-top:0;}
+ .ldp-resource-monitor-event-row time{color:var(--primary-medium,#777);font-variant-numeric:tabular-nums;white-space:nowrap;}
+ .ldp-resource-monitor-event-state{font-weight:700;white-space:nowrap;}
+ .ldp-resource-monitor-scope-row [data-resource-monitor-scope-visible],
+ .ldp-resource-monitor-event-state[data-visibility="visible"]{color:var(--tertiary,#47855f);}
+ .ldp-resource-monitor-scope-row [data-resource-monitor-scope-hidden],
+ .ldp-resource-monitor-event-state[data-visibility="hidden"]{color:var(--ldp-request-metric-navy,#365673);}
+ .ldp-resource-monitor-event-scope{color:var(--primary-high,#333);font-weight:650;}
+ .ldp-resource-monitor-event-detail{min-width:0;color:var(--primary-high,#333);overflow-wrap:anywhere;}
+ .ldp-resource-monitor-event-basis{color:var(--primary-medium,#777);overflow-wrap:anywhere;}
+ .ldp-resource-monitor-event-empty{padding:14px 0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
+ .ldp-resource-monitor-boundary{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);line-height:1.5;}
+ @container ldp-reader (max-width:700px){
+ .ldp-resource-monitor-table{border-radius:9px;}
+ .ldp-resource-monitor-table-head{display:none;}
+ .ldp-resource-monitor-row{grid-template-columns:minmax(0,1fr) auto;gap:7px 12px;min-height:0;padding:12px;}
+ .ldp-resource-monitor-summary{grid-column:1 / -1;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0;
+ margin-top:2px;padding-top:8px;border-top:var(--ldp-divider-line-width,1px) solid
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 68%,transparent);}
+ .ldp-resource-monitor-summary span{padding-right:7px;text-align:left;}
+ .ldp-resource-monitor-summary span + span{padding-left:8px;border-left:var(--ldp-divider-line-width,1px) solid
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 68%,transparent);}
+ .ldp-resource-monitor-summary span::before{color:var(--primary-medium,#777);font-weight:400;}
+ .ldp-resource-monitor-summary span:nth-child(1)::before{content:"均 ";}
+ .ldp-resource-monitor-summary span:nth-child(2)::before{content:"峰 ";}
+ .ldp-resource-monitor-summary span:nth-child(3)::before{content:"变 ";}
+ .ldp-resource-monitor-trend-row{grid-template-columns:72px 64px minmax(84px,1fr);}
+ .ldp-resource-monitor-scope-head{display:none;}
+ .ldp-resource-monitor-scope-row{grid-template-columns:84px repeat(2,minmax(0,1fr));gap:6px 9px;padding:10px;}
+ .ldp-resource-monitor-scope-row strong{grid-row:1 / 3;}
+ .ldp-resource-monitor-scope-row [data-resource-monitor-scope-current]{grid-column:2 / -1;}
+ .ldp-resource-monitor-scope-row [data-resource-monitor-scope-basis]{grid-column:1 / -1;grid-row:auto;}
+ .ldp-resource-monitor-event-row{grid-template-columns:76px 30px 64px minmax(0,1fr);}
+ .ldp-resource-monitor-event-basis{grid-column:4;}}
+ .ldp-request-flow-summary{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));overflow:hidden;
+ border:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);border-radius:10px;background:var(--secondary,#fff);}
+ .ldp-request-flow-metric{display:grid;min-width:0;min-height:52px;box-sizing:border-box;align-content:center;justify-items:center;
+ gap:2px;padding:7px 8px;text-align:center;}
+ .ldp-request-flow-metric + .ldp-request-flow-metric{border-left:var(--ldp-divider-line-width,1px) solid
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);}
+ .ldp-request-flow-metric span{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:550;line-height:1.25;}
+ .ldp-request-flow-metric strong{color:var(--primary-high,#333);font-size:var(--ldp-font-sm,11px);
+ font-weight:750;font-variant-numeric:tabular-nums;line-height:1.25;overflow-wrap:anywhere;white-space:normal;}
+ .ldp-request-flow-metric:nth-child(1) strong{color:var(--ldp-request-metric-pine,#315f47);}
+ .ldp-request-flow-metric:nth-child(2) strong{color:var(--ldp-request-metric-navy,#365673);}
+ .ldp-request-flow-metric:nth-child(3) strong{color:var(--ldp-request-metric-plum,#5e4b72);}
+ .ldp-request-flow-metric:nth-child(4) strong{color:var(--ldp-request-metric-bronze,#72551f);}
+ .ldp-request-flow-metric:nth-child(5) strong{color:var(--ldp-request-metric-wine,#743f47);}
+ .ldp-resource-monitor-trends,.ldp-resource-monitor-evidence,.ldp-resource-monitor-events,.ldp-request-flow-block{display:grid;gap:8px;}
+ .ldp-resource-monitor-trend-head,.ldp-resource-monitor-evidence-head,.ldp-resource-monitor-events-head,
+ .ldp-request-flow-block-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;}
+ .ldp-resource-monitor-trend-head strong,.ldp-resource-monitor-evidence-head strong,
+ .ldp-resource-monitor-events-head strong,.ldp-request-flow-block-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);}
+ .ldp-resource-monitor-trend-row > span,.ldp-request-flow-legend span,
+ .ldp-request-flow-anomaly-detail small{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);}
+ .ldp-request-flow-block-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-variant-numeric:tabular-nums;}
+ .ldp-request-flow-chart-block,.ldp-request-flow-types-block,.ldp-request-flow-anomaly-block,.ldp-request-flow-log-block{gap:0;overflow:hidden;
+ border:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);border-radius:10px;background:var(--secondary,#fff);}
+ .ldp-request-flow-chart-block .ldp-request-flow-block-head,.ldp-request-flow-anomaly-block .ldp-request-flow-block-head,
+ .ldp-request-flow-log-block .ldp-request-flow-block-head{
+ min-height:34px;box-sizing:border-box;padding:8px 12px;
+ border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);
+ background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff));}
+ .ldp-request-flow-trace{display:grid;gap:5px;margin:8px 12px 0;}
+ .ldp-request-flow-trace-lane{display:grid;grid-template-columns:42px minmax(0,1fr);gap:7px;align-items:center;}
+ .ldp-request-flow-trace-label{overflow:hidden;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);
+ font-weight:650;text-align:right;white-space:nowrap;}
+ .ldp-request-flow-trace-track{position:relative;isolation:isolate;height:34px;overflow:hidden;border:1px solid
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 72%,transparent);border-radius:5px;
+ background:repeating-linear-gradient(to right,transparent 0,transparent calc(10% - 1px),
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 48%,transparent) calc(10% - 1px),
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 48%,transparent) 10%),
+ color-mix(in srgb,var(--primary-very-low,#f7f7f7) 56%,var(--secondary,#fff));}
+ .ldp-request-flow-trace-queue,.ldp-request-flow-trace-wire{position:absolute;z-index:1;top:var(--ldp-request-flow-top,3px);
+ left:var(--ldp-request-flow-left,0);width:max(2px,var(--ldp-request-flow-width,2px));height:4px;box-sizing:border-box;border-radius:999px;}
+ .ldp-request-flow-trace-queue{height:3px;margin-top:1px;border:1px dashed var(--primary-medium,#777);opacity:.68;}
+ .ldp-request-flow-trace-wire{background:var(--ldp-request-flow-color,var(--primary-medium,#777));box-shadow:0 0 0 1px
+ color-mix(in srgb,var(--ldp-request-flow-color,var(--primary-medium,#777)) 72%,transparent);}
+ .ldp-request-flow-trace-wire[data-pending="true"]{background:color-mix(in srgb,var(--ldp-request-flow-color,var(--primary-medium,#777)) 62%,transparent);}
+ .ldp-request-flow-trace-permit{position:absolute;z-index:2;top:calc(var(--ldp-request-flow-top,3px) - 1px);
+ left:var(--ldp-request-flow-left,0);width:2px;height:7px;transform:translateX(-1px);border-radius:2px;background:var(--primary-high,#333);}
+ .ldp-request-flow-trace-anomaly{position:absolute;z-index:3;top:calc(var(--ldp-request-flow-top,3px) - 2px);
+ left:var(--ldp-request-flow-left,0);width:10px;height:10px;box-sizing:border-box;transform:translateX(-50%);
+ border:2px solid var(--secondary,#fff);border-radius:50%;background:var(--highlight-high,#b38300);
+ box-shadow:0 0 0 1px var(--highlight-high,#b38300);}
+ .ldp-request-flow-trace-anomaly[data-level="danger"]{background:var(--danger,#c84545);box-shadow:0 0 0 1px var(--danger,#c84545);}
+ .ldp-request-flow-axis{display:flex;justify-content:space-between;padding:4px 12px 0;color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;}
+ .ldp-request-flow-axis span:nth-child(2){flex:1;text-align:center;}
+ .ldp-request-flow-legend{display:flex;flex-wrap:wrap;gap:5px 10px;padding:7px 12px 10px;}
+ .ldp-request-flow-legend span,.ldp-request-flow-type-name{display:inline-flex;align-items:center;gap:5px;min-width:0;}
+ .ldp-request-flow-dot{width:7px;height:7px;flex:none;border-radius:50%;background:var(--ldp-request-flow-color,var(--primary-low,#ddd));}
+ .ldp-request-flow-queue-key{width:11px;height:5px;box-sizing:border-box;border:1px dashed var(--primary-medium,#777);border-radius:999px;}
+ .ldp-request-flow-warning-key,.ldp-request-flow-danger-key{width:8px;height:8px;box-sizing:border-box;border:2px solid var(--secondary,#fff);
+ border-radius:50%;background:var(--highlight-high,#b38300);box-shadow:0 0 0 1px var(--highlight-high,#b38300);}
+ .ldp-request-flow-danger-key{background:var(--danger,#c84545);box-shadow:0 0 0 1px var(--danger,#c84545);}
+ [data-request-flow-type="topic"]{--ldp-request-flow-color:#47855f;}
+ [data-request-flow-type="nested"]{--ldp-request-flow-color:#2870b8;}
+ [data-request-flow-type="avatar"]{--ldp-request-flow-color:#7a5bb5;}
+ [data-request-flow-type="media"]{--ldp-request-flow-color:#b66a26;}
+ [data-request-flow-type="user"]{--ldp-request-flow-color:#2d7d78;}
+ [data-request-flow-type="bookmark"]{--ldp-request-flow-color:#9a7600;}
+ [data-request-flow-type="notification"]{--ldp-request-flow-color:#b94f4f;}
+ [data-request-flow-type="realtime"]{--ldp-request-flow-color:#4f708f;}
+ [data-request-flow-type="presence"]{--ldp-request-flow-color:#3d7f9d;}
+ [data-request-flow-type="reaction"]{--ldp-request-flow-color:#a44f79;}
+ [data-request-flow-type="search"]{--ldp-request-flow-color:#66717c;}
+ [data-request-flow-type="read"]{--ldp-request-flow-color:#7a6c5d;}
+ [data-request-flow-type="asset"]{--ldp-request-flow-color:#596979;}
+ [data-request-flow-type="other"]{--ldp-request-flow-color:#8a8a8a;}
+ .ldp-request-flow-anomalies{display:grid;max-height:174px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;}
+ .ldp-request-flow-anomaly-row{display:grid;grid-template-columns:86px 68px minmax(0,1fr);gap:8px;align-items:center;
+ min-height:42px;padding:6px 12px;border-left:3px solid var(--highlight-high,#b38300);border-bottom:var(--ldp-divider-line-width,1px) solid
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 65%,transparent);font-size:var(--ldp-font-xs,10px);}
+ .ldp-request-flow-anomaly-row[data-level="danger"]{border-left-color:var(--danger,#c84545);}
+ .ldp-request-flow-anomaly-row:last-child{border-bottom:0;}
+ .ldp-request-flow-anomaly-time{color:var(--primary-medium,#777);font-variant-numeric:tabular-nums;}
+ .ldp-request-flow-anomaly-kind{display:inline-flex;justify-content:center;padding:2px 5px;border-radius:999px;
+ background:var(--highlight-low,#fff8d8);color:var(--highlight-high,#806200);font-weight:700;white-space:nowrap;}
+ .ldp-request-flow-anomaly-row[data-level="danger"] .ldp-request-flow-anomaly-kind{background:var(--danger-low,#f7dddd);color:var(--danger,#c84545);}
+ .ldp-request-flow-anomaly-detail{display:grid;min-width:0;gap:2px;}
+ .ldp-request-flow-anomaly-detail strong,.ldp-request-flow-anomaly-detail small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-request-flow-anomaly-detail strong{color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);}
+ .ldp-request-flow-type-head,.ldp-request-flow-type-row{display:grid;grid-template-columns:minmax(0,1fr) 62px 72px 44px;gap:8px;}
+ .ldp-request-flow-type-head{min-height:34px;box-sizing:border-box;align-items:center;padding:4px 12px;
+ border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);
+ background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff));}
+ .ldp-request-flow-type-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-md,14px);}
+ .ldp-request-flow-type-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);
+ text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap;}
+ .ldp-request-flow-types{display:grid;}
+ .ldp-request-flow-type-row{min-height:34px;box-sizing:border-box;align-items:center;padding:4px 12px;font-size:var(--ldp-font-ui,12px);}
+ .ldp-request-flow-type-row + .ldp-request-flow-type-row{border-top:var(--ldp-divider-line-width,1px) solid
+ color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);}
+ .ldp-request-flow-type-row > span:not(.ldp-request-flow-type-name){color:var(--primary-medium,#777);text-align:right;font-variant-numeric:tabular-nums;}
+ .ldp-request-flow-type-name strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--ldp-font-ui,12px);}
+ .ldp-request-flow-log{max-height:224px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;}
+ .ldp-request-flow-log-row{display:grid;grid-template-columns:88px 44px 58px 54px 88px minmax(140px,1fr);gap:7px;align-items:center;
+ min-height:34px;padding:5px 12px;border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 65%,transparent);
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;}
+ .ldp-request-flow-log-row[data-level="warning"]{box-shadow:inset 3px 0 var(--highlight-high,#b38300);}
+ .ldp-request-flow-log-row[data-level="danger"]{box-shadow:inset 3px 0 var(--danger,#c84545);}
+ .ldp-request-flow-log-row:last-child{border-bottom:0;}
+ .ldp-request-flow-source{display:inline-flex;justify-content:center;padding:2px 4px;border-radius:4px;
+ background:var(--primary-very-low,#f4f4f4);color:var(--primary-high,#333);}
+ .ldp-request-flow-status{font-weight:700;text-align:right;}
+ .ldp-request-flow-status.is-error{color:var(--danger,#c84545);}
+ .ldp-request-flow-timing{overflow:hidden;text-align:right;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-request-flow-path{overflow:hidden;color:var(--primary-high,#333);text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-request-flow-empty,.ldp-font-family-empty{padding:18px 8px;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);text-align:center;}
+ .ldp-request-flow-limit{display:grid;gap:7px;padding:11px;border:1px solid var(--primary-low,#ddd);border-radius:8px;}
+ .ldp-request-flow-limit h4{margin:0;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);}
+ .ldp-request-flow-limit strong{color:var(--primary-high,#333);}
+ .ldp-request-flow-limit a{color:var(--d-link-color,#2870b8);text-decoration:none;}
+ .ldp-request-flow-limit a:hover{text-decoration:underline;}
+ @container ldp-reader (max-width:700px){
+ .ldp-request-flow-summary{grid-template-columns:repeat(2,minmax(0,1fr));}
+ .ldp-request-flow-metric + .ldp-request-flow-metric{border-left:0;}
+ .ldp-request-flow-metric:nth-child(even){border-left:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-request-flow-metric:nth-child(n+3){border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-request-flow-metric:last-child{grid-column:1 / -1;}
+ .ldp-request-flow-type-head,.ldp-request-flow-type-row{grid-template-columns:minmax(0,1fr) 52px 64px 36px;gap:5px;}
+ .ldp-request-flow-trace-lane{grid-template-columns:34px minmax(0,1fr);gap:5px;}
+ .ldp-request-flow-axis span:nth-child(2){display:none;}
+ .ldp-request-flow-anomaly-row{grid-template-columns:82px 62px minmax(0,1fr);padding-inline:8px;}
+ .ldp-request-flow-log-row{grid-template-columns:86px 50px 50px minmax(100px,1fr);padding-inline:8px;}
+ .ldp-request-flow-log-row > :nth-child(2),.ldp-request-flow-log-row > :nth-child(5){display:none;}}
+ .ldp-about-feature-copy p,.ldp-about-credits p,.ldp-request-flow-limit p,.ldp-cache-note{
+ margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.5;}
+ .ldp-config-actions{display:flex;flex-wrap:wrap;gap:7px;}
+ .ldp-config-action{display:inline-flex;align-items:center;gap:6px;min-height:34px;padding:7px 10px;
+ border:1px solid var(--primary-low,#ddd);border-radius:6px;background:var(--secondary,#fff);color:inherit;
+ font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;}
+ .ldp-config-action:hover{background:var(--primary-very-low,#f4f4f4);}
+ .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open):focus-visible,
+ .ldp-only-op-toggle:focus-visible,.ldp-reaction-filter:focus-visible,.ldp-settings-close:focus-visible,
+ .ldp-settings-theme-button:focus-visible,.ldp-user-info-title-refresh:focus-visible,.ldp-config-action:focus-visible{
+ outline:2px solid var(--tertiary,#47855f);outline-offset:1px;}
+ .ldp-config-action.danger{color:var(--danger,#c44);}
+ .ldp-cache-list{display:grid;gap:7px;}
+ .ldp-cache-row{display:grid;grid-template-columns:18px minmax(0,1fr) auto;gap:8px;align-items:center;
+ padding:8px;border:1px solid var(--primary-low,#e2e2e2);border-radius:6px;font-size:var(--ldp-font-ui,12px);cursor:pointer;}
+ .ldp-cache-row input{margin:0;}
+ .ldp-cache-row strong,.ldp-cache-row small{display:block;}
+ .ldp-cache-row small{margin-top:2px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
+ .ldp-cache-row em{font-style:normal;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);text-align:right;white-space:nowrap;}
+ .ldp-cache-clear{justify-self:start;border:0;border-radius:6px;padding:8px 11px;background:var(--danger,#c44);color:#fff;
+ font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;}
+ .ldp-cache-clear:disabled{cursor:default;opacity:.42;}
+ .ldp-image-scale-control{display:grid;grid-template-columns:minmax(0,1fr) 52px auto;align-items:center;gap:10px;min-width:0;}
+ .ldp-image-scale-control input{width:100%;min-width:150px;}
+ .ldp-font-scale-control{display:flex;align-items:center;gap:10px;min-width:0;}
+ .ldp-host-embed-size-control{display:grid;grid-template-columns:minmax(0,1fr) 46px auto;align-items:center;gap:10px;min-width:0;}
+ .ldp-layout-ratio-control{display:grid;grid-template-columns:minmax(0,1fr) 52px;align-items:center;gap:10px;min-width:0;}
+ .ldp-layout-ratio-control input{width:100%;min-width:150px;}
+ .ldp-setting-option-row.ldp-history-edge-trigger-row{grid-template-columns:minmax(0,1fr) minmax(190px,272px);align-items:center;gap:24px;}
+ .ldp-history-edge-trigger-control{display:grid;grid-template-columns:minmax(0,1fr) 42px;align-items:center;gap:10px;min-width:0;width:100%;}
+ @container ldp-reader (max-width:560px){
+ .ldp-setting-option-row.ldp-history-edge-trigger-row{grid-template-columns:minmax(0,1fr);align-items:start;gap:8px;}
+ .ldp-history-edge-trigger-control{padding-left:0;}
+ }
+ .ldp-settings-popover input[type="range"]{
+ --ldp-range-progress:50%;appearance:none;-webkit-appearance:none;box-sizing:border-box;
+ width:100%;min-width:0;height:24px;margin:0;padding:0;border:0;outline:0;background:transparent;cursor:pointer;}
+ .ldp-settings-popover input[type="range"]::-webkit-slider-runnable-track{
+ height:5px;border-radius:999px;background:linear-gradient(90deg,
+ var(--tertiary,#47855f) 0 var(--ldp-range-progress),
+ color-mix(in srgb,var(--primary-low,#d8d8d8) 72%,var(--secondary,#fff)) var(--ldp-range-progress) 100%);}
+ .ldp-settings-popover input[type="range"]::-webkit-slider-thumb{
+ appearance:none;-webkit-appearance:none;box-sizing:border-box;width:15px;height:15px;margin-top:-5px;border:3px solid var(--secondary,#fff);
+ border-radius:50%;background:var(--tertiary,#47855f);
+ box-shadow:0 0 0 1px color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#d8d8d8));
+ transition:transform 160ms cubic-bezier(.22,1,.36,1),box-shadow 160ms cubic-bezier(.22,1,.36,1);}
+ .ldp-settings-popover input[type="range"]:hover::-webkit-slider-thumb{transform:scale(1.08);}
+ .ldp-settings-popover input[type="range"]:active::-webkit-slider-thumb{transform:scale(.96);}
+ .ldp-settings-popover input[type="range"]:focus-visible::-webkit-slider-thumb{
+ box-shadow:0 0 0 1px var(--tertiary,#47855f),0 0 0 4px color-mix(in srgb,var(--tertiary,#47855f) 22%,transparent);}
+ .ldp-settings-popover input[type="range"]::-moz-range-track{
+ height:5px;border:0;border-radius:999px;background:color-mix(in srgb,var(--primary-low,#d8d8d8) 72%,var(--secondary,#fff));}
+ .ldp-settings-popover input[type="range"]::-moz-range-progress{
+ height:5px;border:0;border-radius:999px;background:var(--tertiary,#47855f);}
+ .ldp-settings-popover input[type="range"]::-moz-range-thumb{
+ box-sizing:border-box;width:15px;height:15px;border:3px solid var(--secondary,#fff);border-radius:50%;background:var(--tertiary,#47855f);
+ box-shadow:0 0 0 1px color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#d8d8d8));
+ transition:transform 160ms cubic-bezier(.22,1,.36,1),box-shadow 160ms cubic-bezier(.22,1,.36,1);}
+ .ldp-settings-popover input[type="range"]:hover::-moz-range-thumb{transform:scale(1.08);}
+ .ldp-settings-popover input[type="range"]:active::-moz-range-thumb{transform:scale(.96);}
+ .ldp-settings-popover input[type="range"]:focus-visible::-moz-range-thumb{
+ box-shadow:0 0 0 1px var(--tertiary,#47855f),0 0 0 4px color-mix(in srgb,var(--tertiary,#47855f) 22%,transparent);}
+ .ldp-settings-popover input[type="range"]:disabled{opacity:.4;cursor:default;}
+ .ldp-image-scale-value,.ldp-host-embed-size-value,.ldp-history-edge-trigger-value,.ldp-layout-ratio-value{
+ text-align:right;font-variant-numeric:tabular-nums;color:var(--primary-medium,#777);}
+ .ldp-performance-preset.active{border-color:color-mix(in srgb,var(--tertiary,#47855f) 45%,var(--primary-low,#ddd));
+ background:color-mix(in srgb,var(--tertiary-low,#dcefe3) 46%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
+ .ldp-font-scope-tabs{display:flex;gap:18px;padding:0 4px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-font-scope-tab{flex:0 0 auto;min-width:64px;padding:5px 4px 8px;border:0;border-radius:0;
+ background:transparent;color:var(--primary-medium,#777);font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:600;cursor:pointer;}
+ .ldp-font-scope-tab:hover{background:transparent;color:var(--primary-high,#333);}
+ .ldp-font-scope-tab.active{border:0;background:transparent;color:var(--tertiary,#47855f);
+ box-shadow:inset 0 -2px 0 var(--tertiary,#47855f);}
+ .ldp-setting-switch input{position:absolute;inset:0;z-index:1;width:100%;height:100%;margin:0;opacity:0;cursor:pointer;}
+ .ldp-setting-switch input:focus-visible + .ldp-setting-switch-track{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 48%,transparent);outline-offset:2px;}
+ .ldp-appearance-theme-note{display:flex;align-items:center;gap:7px;margin:0;padding:8px 10px;border-radius:8px;
+ background:color-mix(in srgb,var(--tertiary-low,#dcefe3) 42%,var(--secondary,#fff));
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.4;}
+ .ldp-appearance-theme-note strong{color:var(--tertiary,#47855f);font-weight:700;}
+ .ldp-reader-window-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;}
+ .ldp-reader-window-field{display:grid;gap:5px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
+ .ldp-reader-window-input{box-sizing:border-box;width:100%;height:34px;padding:5px 8px;border:1px solid var(--primary-low,#ccc);
+ border-radius:7px;background:var(--secondary,#fff);color:inherit;font:inherit;text-align:right;font-variant-numeric:tabular-nums;}
+ .ldp-reader-window-option{display:flex;align-items:center;gap:7px;font-size:var(--ldp-font-ui,12px);cursor:pointer;}
+ .ldp-reader-window-option input{margin:0;}
+ .ldp-setting-option-row{grid-template-columns:minmax(0,1fr) auto;cursor:pointer;}
+ .ldp-inline-reply-tree-enable-row.is-disabled{opacity:.55;cursor:not-allowed;}
+ .ldp-inline-reply-tree-enable-row.is-disabled .ldp-setting-switch input{cursor:not-allowed;}
+ .ldp-inline-reply-tree-depth-row{grid-template-columns:minmax(0,1fr) minmax(96px,132px);align-items:center;}
+ .ldp-inline-reply-tree-depth-row[hidden]{display:none;}
+ .ldp-inline-reply-tree-depth-row .ldp-inline-reply-tree-depth{width:100%;}
+ .ldp-setting-option-copy{display:grid;gap:3px;min-width:0;}
+ .ldp-setting-option-copy strong,.ldp-reader-action-option-copy strong{font-size:var(--ldp-font-base,13px);line-height:1.35;}
+ .ldp-other-settings-fields{gap:14px;border-top:0;}
+ .ldp-other-setting-group{min-width:0;padding:0 14px;border:1px solid var(--primary-low,#ddd);border-radius:10px;
+ background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 34%,var(--secondary,#fff));}
+ .ldp-settings-category-head,.ldp-other-setting-group-head{display:grid;gap:2px;padding:12px 0 10px;
+ border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-about-feature-copy strong,.ldp-about-credits strong,.ldp-settings-category-head strong,
+ .ldp-other-setting-group-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);line-height:1.35;}
+ .ldp-settings-category-head small,.ldp-setting-option-copy small,.ldp-other-setting-group-head small,.ldp-reader-confirm-copy small{
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
+ .ldp-other-setting-list{display:grid;}
+ .ldp-other-setting-list > .ldp-setting-row{padding:11px 0;border-bottom:0;}
+ .ldp-custom-site-form{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding-top:10px;}
+ .ldp-custom-site-form .ldp-custom-site-input{width:100%;min-width:0;}
+ .ldp-custom-site-form .ldp-custom-site-input::placeholder{font-size:var(--ldp-font-base,12px);}
+ .ldp-custom-site-form .ldp-custom-site-add{justify-content:center;border-color:var(--tertiary,#47855f);
+ background:var(--tertiary,#47855f);color:var(--secondary,#fff);white-space:nowrap;}
+ .ldp-custom-site-form .ldp-custom-site-add:hover{background:color-mix(in srgb,var(--tertiary,#47855f) 88%,#000);}
+ .ldp-custom-site-list{display:flex;flex-wrap:wrap;gap:6px;padding-top:8px;}
+ .ldp-custom-site-list[hidden]{display:none;}
+ .ldp-custom-site-item{display:inline-flex;max-width:100%;align-items:center;gap:5px;padding:4px 5px 4px 9px;
+ border:1px solid var(--primary-low,#ddd);border-radius:999px;background:var(--secondary,#fff);
+ color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);line-height:1.35;}
+ .ldp-custom-site-item > span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-custom-site-item > button{display:inline-flex;width:22px;height:22px;flex:none;align-items:center;justify-content:center;
+ padding:0;border:0;border-radius:50%;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-custom-site-item > button:hover{background:var(--danger-low,#fee);color:var(--danger,#c44);}
+ .ldp-custom-site-item .ldp-icon{width:12px;height:12px;}
+ .ldp-custom-site-status{display:block;margin:8px 0 10px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
+ .ldp-settings-category-list > .ldp-setting-row + .ldp-setting-row,
+ .ldp-other-setting-list > .ldp-setting-row + .ldp-setting-row{
+ border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 72%,transparent);}
+ .ldp-other-setting-list > .ldp-nested-display-warning{margin:0 0 11px;}
+ .ldp-settings-panel .ldp-history-sort-mode{min-height:38px;padding-block:6px;}
+ .ldp-nested-display-warning{margin:-2px 2px 2px;padding:8px 10px;border-radius:8px;background:color-mix(in srgb,var(--highlight,#ffb300) 12%,transparent);color:var(--primary-high,#444);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
+ .ldp-font-rendering-status[data-mode="external"]{color:var(--tertiary,#47855f);font-weight:650;}
+ .ldp-setting-switch{position:relative;display:inline-flex;width:40px;height:22px;flex:none;}
+ .ldp-setting-switch-track{position:relative;box-sizing:border-box;display:block;width:100%;height:100%;
+ border:1px solid color-mix(in srgb,var(--primary,#222) 24%,transparent);border-radius:999px;
+ background:color-mix(in srgb,var(--primary,#222) 14%,var(--secondary,#fff));
+ transition:background-color 180ms cubic-bezier(.22,1,.36,1),border-color 180ms cubic-bezier(.22,1,.36,1),transform 180ms cubic-bezier(.22,1,.36,1);}
+ .ldp-setting-switch-track::after{content:"";position:absolute;left:2px;top:50%;width:16px;height:16px;
+ border-radius:50%;background:var(--secondary,#fff);box-shadow:0 1px 2px rgba(0,0,0,.24);
+ transform:translateY(-50%);transition:transform 180ms cubic-bezier(.22,1,.36,1),background-color 180ms cubic-bezier(.22,1,.36,1);}
+ .ldp-setting-switch input:hover + .ldp-setting-switch-track{
+ border-color:color-mix(in srgb,var(--primary,#222) 36%,transparent);}
+ .ldp-setting-switch input:checked + .ldp-setting-switch-track{
+ border-color:var(--tertiary,#47855f);background:var(--tertiary,#47855f);}
+ .ldp-setting-switch input:checked + .ldp-setting-switch-track::after{
+ background:#fff;transform:translate(20px,-50%);}
+ .ldp-setting-switch input:active + .ldp-setting-switch-track{transform:scale(.97);}
+ .ldp-setting-switch input:disabled{cursor:default;}
+ .ldp-setting-switch input:disabled + .ldp-setting-switch-track{opacity:.45;}
+ .ldp-boost-rule-row{grid-template-columns:110px minmax(0,1fr);gap:14px;padding:0;border:0;}
+ .ldp-boost-rule-control{box-sizing:border-box;width:100%;height:34px;padding:5px 8px;
+ border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);
+ color:inherit;font:inherit;}
+ .ldp-boost-rule-preview{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;}
+ .ldp-boost-rule-preview code{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
+ color:var(--tertiary,#47855f);font-family:inherit;font-size:var(--ldp-font-ui,12px);}
+ .ldp-reader-window-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;}
+ .ldp-performance-status,.ldp-reader-window-status,.ldp-layout-total{
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.4;}
+ .ldp-layout-total.warning{color:var(--danger,#c44);font-weight:650;}
+ .ldp-layout-total.balanced{color:var(--success,#47855f);font-weight:650;}
+ .ldp-flash-range-control{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:10px;min-width:0;}
+ .ldp-flash-value{text-align:right;font-variant-numeric:tabular-nums;color:var(--primary-medium,#777);white-space:nowrap;}
+ .ldp-color-control{display:flex;align-items:center;gap:8px;width:100%;min-width:0;}
+ .ldp-color-control input[type="color"]{
+ -webkit-appearance:none;appearance:none;box-sizing:border-box;width:34px;height:34px;flex:0 0 34px;padding:0;
+ overflow:hidden;border:2px solid var(--secondary,#fff);border-radius:50%;background:transparent;
+ box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 24%,transparent);cursor:pointer;
+ transition:box-shadow .15s ease-out,transform .15s ease-out;}
+ .ldp-color-control input[type="color"]::-webkit-color-swatch-wrapper{padding:0;}
+ .ldp-color-control input[type="color"]::-webkit-color-swatch{border:0;border-radius:50%;}
+ .ldp-color-control input[type="color"]::-moz-color-swatch{border:0;border-radius:50%;}
+ .ldp-color-control input[type="color"]:hover:not(:disabled){
+ box-shadow:0 0 0 2px var(--secondary,#fff),0 0 0 4px color-mix(in srgb,var(--tertiary,#47855f) 38%,transparent);transform:scale(1.04);}
+ .ldp-color-control input[type="color"]:focus-visible{outline:0;
+ box-shadow:0 0 0 2px var(--secondary,#fff),0 0 0 4px var(--tertiary,#47855f);}
+ .ldp-color-control input[type="color"]:active:not(:disabled){transform:scale(.94);}
+ .ldp-color-control input[type="color"]:disabled{opacity:.42;cursor:default;}
+ .ldp-flash-color-value,
+ .ldp-accent-color-value,.ldp-link-color-value,.ldp-zebra-color-value,.ldp-list-zebra-color-value,.ldp-reply-line-color-value,.ldp-quote-line-color-value,.ldp-divider-line-color-value,.ldp-floor-preview-color-value{
+ display:inline-flex;align-items:center;box-sizing:border-box;min-width:76px;min-height:28px;padding:0 8px;border-radius:7px;
+ background:color-mix(in srgb,var(--primary,#222) 5%,transparent);color:var(--primary-high,#333);
+ font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:var(--ldp-font-ui,12px);font-weight:650;letter-spacing:.02em;text-transform:uppercase;}
+ .ldp-font-color-value{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI","Microsoft YaHei","PingFang SC","Noto Sans CJK SC",sans-serif;
+ letter-spacing:0;text-transform:none;}
+ .ldp-color-reset{display:inline-flex;min-height:28px;margin-left:auto;align-items:center;gap:4px;padding:4px 7px;border:0;
+ border-radius:7px;background:transparent;color:var(--primary-medium,#777);font:inherit;font-size:var(--ldp-font-sm,11px);cursor:pointer;white-space:nowrap;}
+ .ldp-color-reset:hover:not(:disabled){background:color-mix(in srgb,var(--primary,#222) 6%,transparent);color:var(--primary,#222);}
+ .ldp-color-reset:focus-visible{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);outline-offset:1px;}
+ .ldp-color-reset:disabled{opacity:.32;cursor:default;}
+ .ldp-color-picker-popover{position:absolute;z-index:2147483610;box-sizing:border-box;width:min(268px,calc(100% - 24px));
+ max-height:calc(100% - 24px);overflow:auto;overscroll-behavior:contain;padding:12px;
+ border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff);color:var(--primary,#222);
+ box-shadow:0 12px 30px rgba(0,0,0,.2);pointer-events:auto;}
+ .ldp-color-picker-title{margin:0 0 9px;font-size:var(--ldp-font-base,13px);font-weight:700;}
+ .ldp-color-picker-presets{display:grid;grid-template-columns:repeat(6,28px);justify-content:space-between;gap:8px;margin-bottom:11px;}
+ .ldp-color-picker-preset{box-sizing:border-box;width:28px;height:28px;padding:0;border:2px solid var(--secondary,#fff);border-radius:50%;
+ background:var(--ldp-color-picker-preset);box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 25%,transparent);cursor:pointer;}
+ .ldp-color-picker-preset:hover,.ldp-color-picker-preset:focus-visible{outline:0;box-shadow:0 0 0 2px var(--secondary,#fff),0 0 0 4px var(--tertiary,#47855f);}
+ .ldp-color-picker-preset[aria-pressed="true"]::after{content:"";display:block;width:8px;height:4px;margin:6px auto 0;border:solid currentColor;border-width:0 0 2px 2px;transform:rotate(-45deg);filter:drop-shadow(0 0 1px var(--secondary,#fff));}
+ .ldp-color-picker-fields{display:flex;align-items:center;gap:8px;}
+ .ldp-color-picker-hex{box-sizing:border-box;min-width:0;height:34px;flex:1;padding:5px 9px;border:1px solid var(--primary-low,#ccc);border-radius:7px;
+ background:var(--secondary,#fff);color:inherit;font:650 var(--ldp-font-ui,12px)/1 ui-monospace,SFMono-Regular,Consolas,monospace;text-transform:uppercase;}
+ .ldp-color-picker-hex:focus{border-color:var(--tertiary,#47855f);outline:0;box-shadow:0 0 0 1px var(--tertiary,#47855f);}
+ .ldp-color-picker-hex[aria-invalid="true"]{border-color:var(--danger,#c44);box-shadow:0 0 0 1px var(--danger,#c44);}
+ .ldp-color-picker-more{display:inline-flex;box-sizing:border-box;height:34px;flex:none;align-items:center;padding:5px 9px;
+ border:1px solid var(--primary-low,#ccc);border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;white-space:nowrap;}
+ .ldp-color-picker-more:hover,.ldp-color-picker-more:focus-visible{border-color:var(--tertiary,#47855f);outline:0;background:color-mix(in srgb,var(--primary,#222) 5%,transparent);}
+ .ldp-color-picker-advanced{display:grid;gap:9px;margin-top:11px;padding-top:11px;border-top:1px solid var(--primary-low,#ddd);}
+ .ldp-color-picker-slider{display:grid;grid-template-columns:42px minmax(0,1fr) 38px;align-items:center;gap:7px;
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
+ .ldp-color-picker-slider output{text-align:right;font-variant-numeric:tabular-nums;}
+ .ldp-color-picker-slider input{width:100%;height:18px;margin:0;padding:0;border:0;outline:0;background:transparent;cursor:pointer;appearance:none;-webkit-appearance:none;}
+ .ldp-color-picker-slider input::-webkit-slider-runnable-track{height:8px;border:1px solid color-mix(in srgb,var(--primary,#222) 18%,transparent);border-radius:999px;background:var(--ldp-color-slider);}
+ .ldp-color-picker-slider input::-webkit-slider-thumb{width:16px;height:16px;margin-top:-5px;border:2px solid var(--secondary,#fff);border-radius:50%;
+ background:var(--ldp-color-slider-thumb,#fff);box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 42%,transparent);appearance:none;-webkit-appearance:none;}
+ .ldp-color-picker-slider input::-moz-range-track{height:8px;border:1px solid color-mix(in srgb,var(--primary,#222) 18%,transparent);border-radius:999px;background:var(--ldp-color-slider);}
+ .ldp-color-picker-slider input::-moz-range-thumb{width:14px;height:14px;border:2px solid var(--secondary,#fff);border-radius:50%;
+ background:var(--ldp-color-slider-thumb,#fff);box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 42%,transparent);}
+ .ldp-color-picker-slider input:focus-visible{filter:drop-shadow(0 0 2px var(--tertiary,#47855f));}
+ .ldp-interface-font-scale-range,.ldp-font-scale-range,.ldp-composer-font-scale-range,.ldp-zebra-radius-range,.ldp-floor-preview-height-range,.ldp-flash-range{flex:1;min-width:120px;}
+ .ldp-interface-font-scale-value,.ldp-font-scale-value,.ldp-composer-font-scale-value,.ldp-zebra-radius-value,.ldp-floor-preview-height-value{
+ width:46px;text-align:right;font-variant-numeric:tabular-nums;color:var(--primary-medium,#777);}
+ .ldp-font-field-reset{min-width:104px;justify-content:flex-end;}
+ .ldp-font-option-control{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0;}
+ .ldp-font-family-picker{position:relative;min-width:0;flex:1;}
+ .ldp-font-family-trigger{height:36px;padding:5px 9px 5px 10px;text-align:left;}
+ .ldp-font-family-trigger[aria-expanded="true"]:not(:focus-visible){box-shadow:none;}
+ .ldp-font-family-trigger .ldp-icon{transition:transform 150ms ease-out;}
+ .ldp-font-family-value{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-font-family-menu{position:absolute;z-index:2147483608;display:grid;grid-template-rows:auto auto minmax(0,1fr) auto auto;
+ box-sizing:border-box;width:min(360px,calc(100% - 24px));max-height:calc(100% - 24px);overflow:hidden;
+ padding:5px;border:1px solid var(--primary-low,#d7d7d7);border-radius:8px;background:var(--secondary,#fff);
+ color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit);
+ font-weight:var(--ldp-interface-font-weight,400);box-shadow:0 4px 8px rgba(0,0,0,.18);pointer-events:auto;}
+ .ldp-font-family-search:focus-within{box-shadow:0 0 0 1px var(--tertiary,#47855f);}
+ .ldp-font-family-search input{height:32px;}
+ .ldp-font-family-source-status{margin:5px 2px 0;padding:5px 7px;border-radius:6px;
+ background:var(--primary-very-low,#f3f3f3);color:var(--primary-medium,#666);font-size:var(--ldp-font-sm,11px);line-height:1.4;}
+ .ldp-font-family-options{min-height:0;gap:7px;max-height:min(320px,42vh);margin-top:6px;}
+ .ldp-font-family-options::-webkit-scrollbar,.ldp-topic-edit-category-options::-webkit-scrollbar{width:9px;}
+ .ldp-font-family-options::-webkit-scrollbar-button,.ldp-topic-edit-category-options::-webkit-scrollbar-button{
+ display:none;width:0;height:0;}
+ .ldp-font-family-options::-webkit-scrollbar-track,.ldp-topic-edit-category-options::-webkit-scrollbar-track{
+ border-radius:999px;background:linear-gradient(90deg,transparent 0 calc(50% - .75px),
+ color-mix(in srgb,var(--ldp-reply-line-color,#47855f) 28%,transparent) calc(50% - .75px) calc(50% + .75px),
+ transparent calc(50% + .75px) 100%);}
+ .ldp-font-family-options::-webkit-scrollbar-thumb,.ldp-topic-edit-category-options::-webkit-scrollbar-thumb{
+ border-radius:5px;background:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff));}
+ .ldp-font-family-group{display:grid;gap:2px;}
+ .ldp-font-family-group-title{padding:4px 7px 2px;color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-xs,10px);font-weight:700;line-height:1.25;}
+ .ldp-font-family-option{grid-template-columns:minmax(0,1fr) auto;color:inherit;}
+ .ldp-font-family-option-copy strong{font-weight:650;}
+ .ldp-font-family-preview{min-width:44px;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);text-align:right;white-space:nowrap;}
+ .ldp-font-family-manual{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:6px;padding:6px 7px 1px;
+ border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
+ .ldp-font-family-manual button{padding:5px 7px;border:0;border-radius:6px;background:transparent;color:var(--tertiary,#47855f);
+ font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;}
+ .ldp-font-family-manual button:hover,.ldp-font-family-manual button:focus-visible{background:var(--primary-very-low,#f2f2f2);outline:0;}
+ .ldp-font-weight-select{width:132px;flex:none;}
+ .ldp-font-family-custom{width:100%;min-width:0;flex:1 0 100%;order:3;}
+ .ldp-font-weight-select,.ldp-font-family-custom{
+ box-sizing:border-box;border:1px solid var(--primary-low,#ccc);
+ border-radius:8px;background:var(--secondary,#fff);color:inherit;height:36px;padding:5px 10px;font:inherit;}
+ .ldp-settings-panel,.ldp-setting-help-tooltip{
+ --ldp-font-micro:8px;--ldp-font-xs:9px;--ldp-font-sm:10px;--ldp-font-ui:11px;--ldp-font-base:12px;
+ --ldp-font-md:13px;--ldp-font-lg:14px;--ldp-font-xl:15px;--ldp-font-2xl:16px;--ldp-font-3xl:17px;}
+ .ldp-settings-panel .ldp-settings-section:not([hidden]){gap:16px;}
+ .ldp-settings-panel .ldp-color-groups{gap:14px;}
+ .ldp-settings-panel .ldp-setting-row{padding-block:11px;}
+ .ldp-settings-panel .ldp-setting-group{padding:8px 0 3px;}
+ .ldp-settings-panel .ldp-setting-group > .ldp-setting-row{padding:7px 0 7px 12px;}
+ .ldp-settings-panel .ldp-performance-fields .ldp-setting-row{padding-block:11px;}
+ .ldp-settings-panel :is(.ldp-performance-control input,.ldp-reader-window-input,.ldp-boost-rule-control){height:32px;padding-block:4px;}
+ .ldp-settings-panel :is(.ldp-font-family-trigger,.ldp-font-weight-select,.ldp-font-family-custom){height:34px;padding-block:4px;}
+ .ldp-settings-panel .ldp-font-family-search{height:32px;}
+ .ldp-settings-panel .ldp-font-family-search input{height:30px;}
+ .ldp-settings-panel .ldp-font-family-option{min-height:34px;}
+ .ldp-settings-panel .ldp-performance-preset{padding-block:6px;}
+ .ldp-settings-panel .ldp-font-scope-tab{padding-block:4px 7px;}
+ .ldp-settings-panel :is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset){padding:6px 8px;}
+ .ldp-settings-panel .ldp-config-action{min-height:32px;padding:6px 9px;}
+ .ldp-settings-panel .ldp-custom-site-form :is(.ldp-custom-site-input,.ldp-custom-site-add){height:34px;}
+ .ldp-settings-panel .ldp-cache-row{padding:7px;}
+ .ldp-settings-panel .ldp-cache-clear{padding:7px 10px;}
+ .ldp-settings-panel .ldp-boost-rule-row{padding:11px 0;}
+ .ldp-settings-panel .ldp-setting-switch{width:36px;height:20px;}
+ .ldp-settings-panel .ldp-setting-switch-track::after{width:14px;height:14px;}
+ .ldp-settings-panel .ldp-setting-switch input:checked + .ldp-setting-switch-track::after{transform:translate(18px,-50%);}
+ .ldp-settings-panel .ldp-color-control input[type="color"]{width:32px;height:32px;flex-basis:32px;}
+ .ldp-settings-panel :is(.ldp-flash-color-value,.ldp-accent-color-value,.ldp-link-color-value,.ldp-zebra-color-value,.ldp-list-zebra-color-value,.ldp-reply-line-color-value,.ldp-quote-line-color-value,.ldp-divider-line-color-value,.ldp-floor-preview-color-value,.ldp-color-reset){min-height:26px;}
+ .ldp-settings-panel .ldp-resource-monitor-table-head{min-height:32px;padding-block:6px;}
+ .ldp-settings-panel .ldp-resource-monitor-row{min-height:50px;padding-block:8px;}
+ .ldp-settings-panel .ldp-resource-monitor-trend-row{min-height:44px;}
+ .ldp-settings-panel .ldp-request-flow-metric{min-height:48px;padding-block:6px;}
+ .ldp-settings-panel :is(.ldp-request-flow-chart-block,.ldp-request-flow-anomaly-block,.ldp-request-flow-log-block) .ldp-request-flow-block-head{min-height:32px;padding-block:6px;}
+ .ldp-settings-panel :is(.ldp-request-flow-type-head,.ldp-request-flow-type-row,.ldp-request-flow-log-row){min-height:32px;}
+ .ldp-reader-refresh,.ldp-close{cursor:pointer;border:none;background:transparent;color:inherit;padding:4px;}
+ .ldp-reader-refresh:disabled{cursor:wait;opacity:.45;}
+ .ldp-reader-refresh.is-refreshing .ldp-icon{animation:ldp-spin .9s linear infinite;}
+ @media (prefers-reduced-motion:reduce){.ldp-reader-refresh.is-refreshing .ldp-icon{animation:none;}}
+ .ldp-icon-btn{width:28px;height:28px;border-radius:6px;display:inline-flex;
+ align-items:center;justify-content:center;}
+ .ldp-icon-btn:hover{background:var(--primary-very-low,#f0f0f0);}
+ .ldp-icon{width:14px;height:14px;stroke:currentColor;stroke-width:2;
+ stroke-linecap:round;stroke-linejoin:round;fill:none;flex:none;}
+ .ldp-reader-main{position:relative;display:grid;flex:1;min-height:0;grid-template-columns:
+ minmax(0,var(--ldp-layout-left,10%)) minmax(0,var(--ldp-layout-main,72%))
+ minmax(0,var(--ldp-layout-gap,3%)) minmax(0,var(--ldp-layout-timeline,10%))
+ minmax(0,var(--ldp-layout-right,5%));}
+ .ldp-rate-limit-notice{position:absolute;z-index:9;top:10px;left:50%;transform:translateX(-50%);
+ display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;
+ width:min(680px,calc(100% - 24px));box-sizing:border-box;padding:9px 10px;
+ border:1px solid color-mix(in srgb,var(--highlight,#e7a400) 54%,var(--primary-low,#ddd));border-radius:9px;
+ background:color-mix(in srgb,var(--highlight-low,#fff2c2) 82%,var(--secondary,#fff));color:var(--primary,#222);
+ box-shadow:0 6px 20px rgba(0,0,0,.16);font-size:var(--ldp-font-ui,12px);line-height:1.4;pointer-events:auto;}
+ .ldp-rate-limit-icon{display:inline-flex;width:26px;height:26px;align-items:center;justify-content:center;
+ border-radius:7px;background:color-mix(in srgb,var(--highlight,#e7a400) 18%,transparent);color:var(--highlight,#b77900);}
+ .ldp-rate-limit-copy{display:grid;gap:1px;min-width:0;}
+ .ldp-rate-limit-copy strong{font-size:var(--ldp-font-base,13px);line-height:1.3;}
+ .ldp-rate-limit-detail{color:var(--primary-high,#4d5963);overflow-wrap:anywhere;}
+ .ldp-rate-limit-challenge{display:inline-flex;min-height:30px;align-items:center;justify-content:center;gap:5px;
+ padding:5px 9px;border:1px solid color-mix(in srgb,var(--highlight,#e7a400) 46%,var(--primary-low,#ddd));border-radius:7px;
+ background:var(--secondary,#fff);color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;
+ white-space:nowrap;text-decoration:none;cursor:pointer;}
+ .ldp-rate-limit-challenge:hover{background:color-mix(in srgb,var(--highlight-low,#fff2c2) 54%,var(--secondary,#fff));}
+ .ldp-reader-lock-button .ldp-icon,.ldp-reader-pin-button .ldp-icon,.ldp-reader-placement-option .ldp-icon,
+ .ldp-notification-mark-all .ldp-icon,:is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset) .ldp-icon,
+ .ldp-settings-save-all .ldp-icon,.ldp-rate-limit-challenge .ldp-icon{width:13px;height:13px;}
+ @container ldp-reader (max-width:600px){
+ .ldp-rate-limit-notice{grid-template-columns:auto minmax(0,1fr);align-items:start;}
+ .ldp-rate-limit-challenge{grid-column:2;justify-self:start;}
+ }
+ .ldp-reader-history-nav{position:absolute;top:50%;z-index:8;width:34px;height:34px;
+ transform:translateY(-50%);
+ display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;padding:0;
+ border:1px solid var(--primary-low,#ccc);border-radius:50%;background:var(--secondary,#fff);
+ color:var(--primary-medium,#777);cursor:pointer;pointer-events:none;opacity:0;
+ transition:opacity 160ms ease-out,transform 160ms ease-out;}
+ .ldp-fullpage .ldp-reader-history-nav{position:fixed;}
+ .ldp-reader-history-edge{display:contents;}
+ .ldp-reader-history-back{left:25px;}
+ .ldp-reader-history-forward{left:auto;right:25px;}
+ .ldp-reader-history-edge.is-active .ldp-reader-history-nav,
+ .ldp-history-buttons-always-visible .ldp-reader-history-edge .ldp-reader-history-nav,
+ .ldp-reader-history-nav:focus-visible{pointer-events:auto;opacity:.35;}
+ .ldp-reader-history-nav:hover{border-color:var(--primary-medium,#999);background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);opacity:1;}
+ .ldp-reader-history-nav:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:2px;opacity:1;}
+ .ldp-reader-history-nav:disabled{cursor:wait;}
+ .ldp-reader-history-edge.is-active .ldp-reader-history-nav:disabled,
+ .ldp-history-buttons-always-visible .ldp-reader-history-edge .ldp-reader-history-nav:disabled{opacity:.45;}
+ .ldp-reader-queue{--ldp-reader-queue-rail-size:44px;--ldp-reader-queue-toggle-size:44px;
+ --ldp-reader-queue-toggle-radius:13px;--ldp-reader-queue-toggle-icon-size:20px;
+ --ldp-reader-queue-avatar-size:38px;--ldp-reader-queue-progress-padding:4px;
+ --ldp-reader-queue-gap:8px;--ldp-reader-queue-bubble-gap:8px;--ldp-reader-queue-bubbles-max-height:230px;
+ --ldp-reader-queue-scroll-size:36px;--ldp-reader-queue-scroll-icon-size:24px;
+ --ldp-reader-queue-badge-size:18px;--ldp-reader-queue-status-size:8px;--ldp-reader-queue-loading-size:10px;
+ --ldp-reader-queue-affordance-size:14px;--ldp-reader-queue-pin-size:13px;--ldp-reader-queue-pin-icon-size:8px;
+ position:absolute;z-index:11;left:14px;top:96px;display:flex;width:var(--ldp-reader-queue-rail-size);
+ max-height:calc(100% - 120px);flex-direction:column;align-items:center;gap:var(--ldp-reader-queue-gap);
+ color:var(--ldp-interface-font-color,var(--primary,#222));
+ font-family:var(--ldp-interface-font-family,inherit);font-weight:var(--ldp-interface-font-weight,400);pointer-events:auto;
+ transition:gap .2s ease-out,transform .2s cubic-bezier(.22,1,.36,1);}
+ .ldp-reader-queue.is-docked-left:not(.is-dragging):not(.is-dock-revealed):not(:focus-within){
+ transform:translateX(calc(-100% + 12px));}
+ .ldp-reader-queue.is-docked-right:not(.is-dragging):not(.is-dock-revealed):not(:focus-within){
+ transform:translateX(calc(100% - 12px));}
+ .ldp-reader-queue.is-dragging{transition:none;user-select:none;}
+ .ldp-reader-queue.is-preview-collapsed,
+ .ldp-reader-queue:is(.is-docked-left,.is-docked-right):not(.is-dock-revealed):not(:focus-within){gap:0;}
+ .ldp-reader-queue[hidden],.ldp-reader-queue-panel[hidden]{display:none!important;}
+ .ldp-reader-queue-toggle,.ldp-reader-queue-bubble{position:relative;display:inline-flex;flex:none;
+ align-items:center;justify-content:center;box-sizing:border-box;border:0;cursor:pointer;}
+ .ldp-reader-queue-toggle{width:var(--ldp-reader-queue-toggle-size);height:var(--ldp-reader-queue-toggle-size);
+ border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 28%,var(--primary-low,#ddd));
+ border-radius:var(--ldp-reader-queue-toggle-radius);background:color-mix(in srgb,var(--secondary,#fff) 92%,var(--tertiary-low,#dceee2));
+ color:var(--tertiary,#47855f);box-shadow:0 2px 8px rgba(0,0,0,.11);cursor:grab;touch-action:none;}
+ .ldp-reader-queue.is-dragging .ldp-reader-queue-toggle{cursor:grabbing;}
+ .ldp-reader-queue-toggle:hover{background:color-mix(in srgb,var(--secondary,#fff) 78%,var(--tertiary-low,#dceee2));}
+ .ldp-reader-queue-toggle .ldp-icon{width:var(--ldp-reader-queue-toggle-icon-size);height:var(--ldp-reader-queue-toggle-icon-size);}
+ .ldp-reader-queue:is(.is-docked-left,.is-docked-right):not(.is-dock-revealed):not(:focus-within)
+ .ldp-reader-queue-toggle{background:var(--secondary,#fff);}
+ .ldp-reader-queue:is(.is-docked-left,.is-docked-right):not(.is-dock-revealed):not(:focus-within)
+ .ldp-reader-queue-toggle .ldp-icon{visibility:hidden;}
+ .ldp-reader-queue-toggle b{position:absolute;right:-4px;top:-5px;display:inline-flex;
+ min-width:var(--ldp-reader-queue-badge-size);height:var(--ldp-reader-queue-badge-size);
+ align-items:center;justify-content:center;box-sizing:border-box;padding:0 3px;border:2px solid var(--secondary,#fff);border-radius:999px;
+ background:var(--tertiary,#47855f);color:#fff;font-size:var(--ldp-font-micro,9px);font-variant-numeric:tabular-nums;line-height:1;}
+ .ldp-reader-queue.is-docked-right:not(.is-dock-revealed):not(:focus-within)
+ .ldp-reader-queue-toggle b{right:auto;left:-4px;}
+ .ldp-reader-queue-bubbles{display:flex;min-height:0;max-height:var(--ldp-reader-queue-bubbles-max-height);
+ box-sizing:border-box;flex-direction:column;align-items:center;gap:var(--ldp-reader-queue-bubble-gap);
+ overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;padding:4px 6px;scrollbar-width:none;
+ opacity:1;transform:none;transform-origin:top center;
+ transition:max-height .22s cubic-bezier(.22,1,.36,1),padding-block .22s cubic-bezier(.22,1,.36,1),
+ opacity .14s ease-out,transform .22s cubic-bezier(.22,1,.36,1);}
+ .ldp-reader-queue.is-preview-collapsed .ldp-reader-queue-bubbles,
+ .ldp-reader-queue:is(.is-docked-left,.is-docked-right):not(.is-dock-revealed):not(:focus-within)
+ .ldp-reader-queue-bubbles{max-height:0;padding-block:0;
+ overflow:hidden;opacity:0;pointer-events:none;transform:translateY(-5px) scale(.94);}
+ .ldp-reader-queue.is-preview-collapsed .ldp-reader-queue-scroll-hint,
+ .ldp-reader-queue:is(.is-docked-left,.is-docked-right):not(.is-dock-revealed):not(:focus-within)
+ .ldp-reader-queue-scroll-hint{display:none!important;}
+ .ldp-reader-queue-bubbles::-webkit-scrollbar{display:none;}
+ .ldp-reader-queue-scroll-hint{display:flex;width:var(--ldp-reader-queue-scroll-size);height:var(--ldp-reader-queue-scroll-size);
+ flex:none;align-items:center;justify-content:center;
+ box-sizing:border-box;padding:0;border:0;background:none;color:#f2aa2e;cursor:pointer;}
+ .ldp-reader-queue-scroll-hint[hidden]{display:none!important;}
+ .ldp-reader-queue-scroll-hint:hover{color:#d98600;}
+ .ldp-reader-queue-scroll-hint:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:-2px;}
+ .ldp-reader-queue-scroll-hint .ldp-icon{width:var(--ldp-reader-queue-scroll-icon-size);height:var(--ldp-reader-queue-scroll-icon-size);
+ filter:drop-shadow(0 1px 0 var(--secondary,#fff));
+ transform:rotate(90deg);transition:transform .16s ease-out;}
+ .ldp-reader-queue-scroll-hint.is-up .ldp-icon{transform:rotate(-90deg);}
+ @media (prefers-reduced-motion:reduce){
+ .ldp-reader-queue,.ldp-reader-queue-bubbles{transition:none;}
+ }
+ .ldp-reader-queue-bubble-shell{position:relative;display:flex;width:var(--ldp-reader-queue-avatar-size);
+ height:var(--ldp-reader-queue-avatar-size);flex:none;align-items:center;justify-content:center;}
+ .ldp-reader-queue-bubble,.ldp-reader-queue-row-progress{background:
+ conic-gradient(from 0deg,transparent 0 var(--ldp-reader-queue-progress,0deg),
+ color-mix(in srgb,var(--primary,#222) 32%,var(--secondary,#fff)) var(--ldp-reader-queue-progress,0deg) 1turn),
+ conic-gradient(from 0deg,#a9e8b8 0deg,#dca64b 210deg,#8b1f2d 1turn);}
+ .ldp-reader-queue-bubble.is-progress-complete,
+ .ldp-reader-queue-row-progress.is-progress-complete{
+ background:conic-gradient(from 0deg,#a9e8b8 0deg,#dca64b 210deg,#8b1f2d 1turn);}
+ .ldp-reader-queue-bubble{width:var(--ldp-reader-queue-avatar-size);height:var(--ldp-reader-queue-avatar-size);
+ padding:var(--ldp-reader-queue-progress-padding);border-radius:50%;color:var(--primary-medium,#777);
+ box-shadow:0 1px 4px rgba(0,0,0,.1);transition:box-shadow .12s ease-out;}
+ .ldp-reader-queue-bubble:hover{box-shadow:0 0 0 1px var(--secondary,#fff),0 2px 7px rgba(0,0,0,.15);}
+ .ldp-reader-queue-bubble.is-active{box-shadow:0 0 0 1px var(--secondary,#fff),0 1px 5px rgba(0,0,0,.12);}
+ .ldp-reader-queue-avatar{display:flex;width:100%;height:100%;align-items:center;justify-content:center;overflow:hidden;
+ border-radius:50%;background:var(--secondary,#fff);color:var(--primary-high,#333);font-size:var(--ldp-font-sm,11px);font-weight:700;}
+ .ldp-reader-queue-avatar img{width:100%;height:100%;object-fit:cover;}
+ .ldp-reader-queue-bubble > i{position:absolute;right:-3px;bottom:-3px;width:var(--ldp-reader-queue-status-size);
+ height:var(--ldp-reader-queue-status-size);box-sizing:border-box;
+ border:2px solid var(--secondary,#fff);border-radius:50%;background:var(--primary-low,#ccc);}
+ .ldp-reader-queue-bubble.is-ready > i{background:var(--success,#3f8f5b);}
+ .ldp-reader-queue-bubble.is-partial > i{background:#d98600;}
+ .ldp-reader-queue-bubble.is-error > i{background:var(--danger,#c94747);}
+ .ldp-reader-queue-bubble.is-loading > i{width:var(--ldp-reader-queue-loading-size);height:var(--ldp-reader-queue-loading-size);
+ border:2px solid color-mix(in srgb,var(--tertiary,#47855f) 25%,var(--secondary,#fff));
+ border-top-color:var(--tertiary,#47855f);background:var(--secondary,#fff);animation:ldp-spin .8s linear infinite;}
+ .ldp-reader-queue-bubble-remove{position:absolute;z-index:2;right:-3px;top:-3px;display:inline-flex;
+ width:var(--ldp-reader-queue-affordance-size);height:var(--ldp-reader-queue-affordance-size);
+ align-items:center;justify-content:center;box-sizing:border-box;padding:2px;border:1px solid var(--secondary,#fff);border-radius:50%;
+ background:var(--danger,#c94747);color:#fff;cursor:pointer;opacity:0;pointer-events:none;transform:scale(.82);
+ transition:opacity .12s ease-out,transform .12s ease-out;}
+ .ldp-reader-queue-bubble-shell:hover .ldp-reader-queue-bubble-remove,
+ .ldp-reader-queue-bubble-remove:focus-visible{opacity:1;pointer-events:auto;transform:scale(1);}
+ .ldp-reader-queue-bubble-remove:disabled{display:none;}
+ .ldp-reader-queue-bubble-remove .ldp-icon{width:8px;height:8px;stroke-width:2.5;}
+ .ldp-reader-queue-bubble-pin{position:absolute;z-index:1;left:-3px;top:-3px;display:flex;
+ width:var(--ldp-reader-queue-pin-size);height:var(--ldp-reader-queue-pin-size);
+ align-items:center;justify-content:center;box-sizing:border-box;border:1px solid var(--secondary,#fff);border-radius:50%;
+ background:var(--tertiary,#47855f);color:#fff;pointer-events:none;}
+ .ldp-reader-queue-bubble-pin .ldp-icon{width:var(--ldp-reader-queue-pin-icon-size);height:var(--ldp-reader-queue-pin-icon-size);}
+ .ldp-reader-queue-panel{position:absolute;left:calc(var(--ldp-reader-queue-rail-size) + 10px);top:0;
+ display:grid;width:min(330px,calc(100vw - 78px));
+ max-height:min(62vh,560px);grid-template-rows:auto minmax(0,1fr);box-sizing:border-box;border:1px solid var(--primary-low,#ddd);
+ border-radius:11px;background:var(--secondary,#fff);box-shadow:0 16px 46px rgba(0,0,0,.22);overflow:hidden;}
+ .ldp-reader-queue.is-docked-right .ldp-reader-queue-panel{
+ left:auto;right:calc(var(--ldp-reader-queue-rail-size) + 10px);}
+ .ldp-reader-queue-panel-head{display:flex;min-height:42px;align-items:center;gap:8px;padding:7px 9px 7px 12px;
+ border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-reader-queue-panel-head strong{font-size:var(--ldp-font-base,13px);}
+ .ldp-reader-queue-panel-count{margin-right:auto;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
+ .ldp-reader-queue-close,.ldp-reader-queue-clear,.ldp-reader-queue-pin,.ldp-reader-queue-remove,.ldp-reader-queue-retry{display:inline-flex;flex:none;align-items:center;justify-content:center;
+ box-sizing:border-box;border:0;border-radius:6px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-reader-queue-close,.ldp-reader-queue-clear{width:28px;height:28px;}
+ .ldp-reader-queue-close:hover,.ldp-reader-queue-clear:hover,.ldp-reader-queue-pin:hover,.ldp-reader-queue-remove:hover,.ldp-reader-queue-retry:hover{
+ background:var(--primary-very-low,#f3f3f3);color:var(--primary,#222);}
+ .ldp-reader-queue-clear:hover{color:var(--danger,#c94747);}
+ .ldp-reader-queue-clear:disabled{cursor:default;opacity:.35;}
+ .ldp-reader-queue-pin.active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));
+ color:var(--tertiary,#47855f);}
+ .ldp-reader-queue-list{min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:5px;}
+ .ldp-reader-queue-row{display:grid;grid-template-columns:38px minmax(0,1fr) auto;align-items:center;gap:9px;
+ min-height:54px;padding:6px 7px;border-radius:8px;cursor:pointer;outline:none;}
+ .ldp-reader-queue-row:hover{background:var(--primary-very-low,#f5f5f5);}
+ .ldp-reader-queue-row.is-active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));}
+ .ldp-reader-queue-row:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:-2px;}
+ .ldp-reader-queue-row-progress{display:flex;width:38px;height:38px;box-sizing:border-box;padding:3px;border-radius:50%;}
+ .ldp-reader-queue-row-copy{display:grid;min-width:0;gap:3px;}
+ .ldp-reader-queue-row-copy strong{overflow:hidden;color:var(--primary,#222);font-size:var(--ldp-font-base,13px);
+ font-weight:650;line-height:1.3;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-reader-queue-row-copy small{overflow:hidden;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);
+ line-height:1.25;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-reader-queue-row-actions{display:flex;align-items:center;gap:2px;}
+ .ldp-reader-queue-pin,.ldp-reader-queue-remove,.ldp-reader-queue-retry{width:26px;height:26px;}
+ .ldp-reader-queue-remove:disabled{visibility:hidden;}
+ .ldp-reader-queue-panel :is(.ldp-reader-queue-close,.ldp-reader-queue-clear,.ldp-reader-queue-pin,.ldp-reader-queue-remove,.ldp-reader-queue-retry) .ldp-icon{width:14px;height:14px;}
+ .ldp-body{grid-column:1 / -1;grid-row:1;display:grid;grid-template-columns:subgrid;min-height:0;position:relative;
+ grid-auto-rows:max-content;align-content:start;box-sizing:border-box;width:100%;max-width:none;
+ margin:0;padding:0;overflow-y:auto;overscroll-behavior:contain;overflow-anchor:auto;}
+ .ldp-body:is(.ldp-stream-viewport-anchor,.ldp-floor-preview-positioning,.ldp-quote-positioning){overflow-anchor:none;}
+ .ldp-body > *{grid-column:2;box-sizing:border-box;width:100%;
+ margin-left:0;margin-right:0;padding-left:20px;padding-right:20px;}
+ .ldp-body > .ldp-sentinel{padding-left:0;padding-right:0;}
+ .ldp-topic-timeline{position:relative;grid-column:4;grid-row:1;left:auto;right:auto;top:auto;bottom:auto;z-index:4;
+ box-sizing:border-box;width:100%;max-width:100%;margin:24px 0;padding:0 4px;align-self:stretch;justify-self:stretch;
+ display:grid;grid-template-rows:auto minmax(150px,1fr) auto;gap:10px;
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);line-height:1.25;pointer-events:none;}
+ .ldp-topic-timeline-date,.ldp-topic-timeline-relative{min-width:0;padding:0 0 0 2px;
+ overflow:hidden;border:0;background:transparent;color:inherit;font:inherit;cursor:pointer;pointer-events:auto;
+ text-align:left;text-overflow:ellipsis;white-space:pre-line;}
+ .ldp-topic-timeline-date:hover:not(:disabled),.ldp-topic-timeline-relative:hover:not(:disabled){color:var(--primary,#222);text-decoration:underline;}
+ .ldp-topic-timeline-date:focus-visible,.ldp-topic-timeline-relative:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:2px;border-radius:3px;}
+ .ldp-topic-timeline-date:disabled,.ldp-topic-timeline-relative:disabled{cursor:default;}
+ .ldp-topic-timeline-footer{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0;}
+ .ldp-topic-timeline-relative{flex:0 0 auto;overflow:visible;white-space:nowrap;}
+ .ldp-topic-timeline-top{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;
+ flex:none;box-sizing:border-box;padding:0;border:1px solid var(--primary-low,#ccc);border-radius:50%;
+ background:var(--secondary,#fff);color:inherit;cursor:pointer;pointer-events:auto;}
+ .ldp-topic-timeline-top:hover{border-color:var(--primary-medium,#999);background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);}
+ .ldp-topic-timeline-track{position:relative;width:100%;min-height:150px;padding:0;border:0;
+ background:transparent;color:inherit;cursor:ns-resize;pointer-events:auto;touch-action:none;}
+ .ldp-topic-timeline-track::before{content:"";position:absolute;left:15px;top:10px;bottom:10px;width:0;
+ border-left:var(--ldp-reply-line-width,1px) solid var(--ldp-reply-line-color,#47855f);}
+ .ldp-topic-timeline-cursor{position:absolute;z-index:2;left:30px;
+ top:var(--ldp-timeline-lens-top,calc(20px + var(--ldp-timeline-preview-progress,0) * (100% - 40px)));width:max-content;min-width:44px;height:82px;
+ transform:translate3d(0,-50%,0) scale(.94);transform-origin:left center;will-change:top,transform,opacity;contain:layout style;
+ color:var(--primary-medium,#777);font-family:var(--ldp-interface-font-family,inherit);font-weight:var(--ldp-interface-font-weight,400);
+ font-variant-numeric:tabular-nums;text-align:left;white-space:nowrap;pointer-events:none;opacity:0;
+ transition:opacity .1s ease-out,transform .1s cubic-bezier(.22,1,.36,1);}
+ .ldp-topic-timeline-cursor > span{position:absolute;left:0;top:50%;display:block;min-width:0;line-height:1;transform-origin:left center;
+ transform:translate3d(var(--ldp-lens-shift-x,0),calc(-50% + var(--ldp-lens-offset,0) * 16px),0) scale(var(--ldp-lens-scale,1));
+ backface-visibility:hidden;will-change:transform,opacity;font-size:var(--ldp-font-md,14px);font-weight:inherit;opacity:var(--ldp-lens-opacity,0);}
+ .ldp-topic-timeline-cursor > span::before{content:"";position:absolute;left:-11px;top:50%;width:7px;height:2px;
+ transform:translateY(-50%) scaleX(.35);transform-origin:right center;border-radius:999px;background:var(--tertiary,#47855f);
+ opacity:0;transition:opacity .08s ease-out,transform .08s ease-out;}
+ .ldp-topic-timeline-cursor > .ldp-timeline-lens-selected{font-weight:inherit;}
+ .ldp-topic-timeline-cursor > .ldp-timeline-lens-selected::before{transform:translateY(-50%) scaleX(1);opacity:.92;}
+ .ldp-topic-timeline-track.ldp-timeline-hovering .ldp-topic-timeline-cursor{opacity:1;transform:translate3d(0,-50%,0) scale(1);}
+ .ldp-topic-timeline-track.ldp-timeline-jumping .ldp-topic-timeline-cursor{opacity:1;transform:translate3d(0,-50%,0) scale(1);
+ transition:opacity .1s ease-out,transform .1s cubic-bezier(.22,1,.36,1);}
+ .ldp-topic-timeline-cursor.ldp-timeline-lens-composited{top:0;will-change:transform,opacity;transition:opacity .1s ease-out;
+ transform:translate3d(0,var(--ldp-timeline-lens-top,0px),0) translateY(-50%) scale(.94);}
+ .ldp-topic-timeline-track.ldp-timeline-hovering .ldp-topic-timeline-cursor.ldp-timeline-lens-composited,
+ .ldp-topic-timeline-track.ldp-timeline-jumping .ldp-topic-timeline-cursor.ldp-timeline-lens-composited{
+ transform:translate3d(0,var(--ldp-timeline-lens-top,0px),0) translateY(-50%) scale(1);transition:opacity .1s ease-out;}
+ .ldp-topic-timeline-track.ldp-timeline-jumping .ldp-topic-timeline-cursor > .ldp-timeline-lens-focus{animation:ldp-topic-timeline-lens-focus .46s ease-out both;}
+ @keyframes ldp-topic-timeline-lens-focus{
+ 0%{filter:blur(.35px);opacity:.52;}
+ 58%{filter:blur(0);opacity:1;}
+ 100%{filter:none;opacity:1;}
+ }
+ .ldp-topic-timeline-thumb{position:absolute;left:11px;top:calc(10px + var(--ldp-timeline-progress,0) * (100% - 40px));
+ width:9px;height:20px;border-radius:5px;
+ background:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff));}
+ .ldp-topic-timeline-count{position:absolute;left:30px;top:calc(20px + var(--ldp-timeline-progress,0) * (100% - 40px));
+ transform:translateY(-50%);display:inline-flex;align-items:center;gap:1px;max-width:none;
+ overflow:visible;color:var(--primary,#222);font-size:clamp(var(--ldp-font-xs,10px),.8vw,var(--ldp-font-sm,11px));font-weight:var(--ldp-reader-font-weight-base,400);
+ font-variant-numeric:tabular-nums;text-align:left;white-space:nowrap;transition:opacity .12s ease-out;}
+ .ldp-topic-timeline-preview{position:absolute;z-index:1;left:30px;
+ top:calc(20px + var(--ldp-timeline-preview-progress,0) * (100% - 40px));
+ transform:translate(var(--ldp-timeline-preview-shift-x,0px),calc(-50% - 14px));display:inline-flex;align-items:center;gap:1px;max-width:calc(100% - 30px);
+ overflow:hidden;text-overflow:ellipsis;color:var(--primary,#222);font-size:clamp(var(--ldp-font-xs,10px),.8vw,var(--ldp-font-sm,11px));font-weight:var(--ldp-reader-font-weight-base,400);
+ font-variant-numeric:tabular-nums;text-align:left;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .12s ease-out;}
+ .ldp-topic-timeline-track.ldp-timeline-previewing .ldp-topic-timeline-preview{opacity:.45;}
+ .ldp-topic-timeline-track:is(.ldp-timeline-hovering,.ldp-timeline-jumping) :is(.ldp-topic-timeline-count,.ldp-topic-timeline-preview){opacity:0;}
+ .ldp-topic-timeline-track:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:3px;border-radius:4px;}
+ .ldp-live-update{position:absolute;left:50%;bottom:18px;z-index:6;transform:translateX(-50%);opacity:.65;
+ display:inline-flex;align-items:center;border:0;border-radius:999px;padding:8px 13px;
+ background:var(--tertiary,#47855f);color:#fff;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;
+ box-shadow:0 4px 8px rgba(0,0,0,.2);white-space:nowrap;}
+ .ldp-live-update-jump{display:inline-flex;align-items:center;gap:6px;min-width:0;border:0;padding:0;
+ background:transparent;color:inherit;font:inherit;cursor:pointer;white-space:nowrap;}
+ .ldp-live-update-jump:hover{filter:brightness(1.08);}
+ .ldp-live-update-dismiss{position:absolute;top:-8px;right:-8px;width:20px;height:20px;display:inline-flex;
+ align-items:center;justify-content:center;box-sizing:border-box;border:0;border-radius:50%;padding:0;
+ background:var(--tertiary,#47855f);color:#fff;cursor:pointer;box-shadow:0 2px 5px rgba(0,0,0,.22);}
+ .ldp-live-update-dismiss:hover{background:rgba(255,255,255,.16);color:#fff;}
+ .ldp-live-update-dismiss .ldp-icon{width:11px;height:11px;}
+ .ldp-live-update-jump:focus-visible,.ldp-live-update-dismiss:focus-visible{outline:2px solid var(--secondary,#fff);outline-offset:2px;}
+
+ /* 阅读器沉浸式载入场景 */
+ .ldp-body > .ldp-loadmask{position:absolute;inset:0;z-index:7;grid-column:1 / -1;width:100%;height:100%;margin:0;
+ display:grid;place-items:center;box-sizing:border-box;padding:0;overflow:hidden;
+ background:radial-gradient(ellipse at 50% 30%,color-mix(in srgb,var(--tertiary,#47855f) 8%,transparent),transparent min(46vw,330px)),var(--secondary,#fff);color:inherit;}
+ .ldp-loading-stage{position:absolute;inset:0;z-index:1;display:grid;grid-template-rows:60% 40%;place-items:center;width:100%;height:100%;box-sizing:border-box;text-align:center;}
+ .ldp-loading-stage > .ldp-loading-visual{grid-area:1 / 1;}
+ .ldp-loading-copy{position:absolute;top:calc(30% + 120px);left:50%;display:flex;width:min(520px,calc(100% - 48px));
+ flex-direction:column;align-items:center;transform:translateX(-50%);}
+ .ldp-loading-visual{--ldp-loader-accent:var(--tertiary,#47855f);--ldp-loader-brand:#f5b72e;position:relative;display:grid;place-items:center;
+ width:min(360px,82vw);height:230px;overflow:visible;background:transparent;isolation:isolate;pointer-events:none;user-select:none;}
+ .ldp-loading-visual-inner{position:relative;width:280px;height:170px;color:var(--ldp-loader-accent);transform:scale(1.08);transform-origin:center;}
+ .ldp-loading-visual-inner span{position:absolute;display:block;box-sizing:border-box;will-change:transform,opacity;}
+ .ldp-loading-mode{margin-top:2px;color:var(--tertiary,#47855f);font-size:10px;font-weight:750;letter-spacing:.18em;text-transform:uppercase;opacity:.7;}
+ .ldp-loading-status{display:flex;align-items:center;justify-content:center;gap:9px;min-height:30px;margin-top:10px;color:var(--primary,#222);font-size:clamp(17px,2.2vw,21px);font-weight:720;letter-spacing:.02em;}
+ .ldp-loading-status strong{display:inline-flex;align-items:center;min-height:22px;padding:0 8px;border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 22%,transparent);border-radius:999px;
+ background:color-mix(in srgb,var(--tertiary,#47855f) 8%,var(--secondary,#fff));color:var(--tertiary,#47855f);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;}
+ .ldp-loading-status strong:empty{display:none;}
+ .ldp-loading-detail{min-height:20px;margin-top:5px;color:var(--primary-medium,#777);font-size:var(--ldp-font-base,13px);line-height:1.55;}
+
+ /* LINUX DO Reader 十个真实等待场景:统一黑白黄识别,动作分别对应阅读器能力。 */
+ .ldp-loading-visual[data-animation="portal"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 88%,var(--secondary,#fff));}
+ .ldp-loader-portal::before{content:"LINUX DO READER";position:absolute;left:84px;top:68px;width:112px;height:32px;display:grid;place-items:center;z-index:1;white-space:nowrap;
+ color:var(--ldp-loader-accent);font:820 10px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.13em;text-align:center;
+ border-top:3px solid var(--ldp-loader-brand);border-bottom:1px solid color-mix(in srgb,var(--primary,#222) 30%,transparent);
+ animation:ldp-loader-topic-mark 3.8s ease-in-out infinite;}
+ .ldp-loader-portal::after{content:"";position:absolute;left:139px;top:25px;width:2px;height:121px;opacity:.55;
+ background:linear-gradient(180deg,transparent,var(--ldp-loader-brand) 8% 34%,transparent 34% 66%,var(--ldp-loader-brand) 66% 92%,transparent);animation:ldp-loader-topic-spine 3.8s ease-in-out infinite;}
+ .ldp-loader-portal span{--side:-1;left:22px;top:calc(25px + var(--i) * 23px);width:91px;height:18px;transform-origin:right center;
+ border-bottom:1px solid color-mix(in srgb,var(--primary,#222) 27%,transparent);
+ animation:ldp-loader-topic-open 3.8s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--i) * -.16s);}
+ .ldp-loader-portal span:nth-child(even){--side:1;left:167px;transform-origin:left center;}
+ .ldp-loader-portal span::before{content:attr(data-copy);position:absolute;inset:auto 0 4px;color:color-mix(in srgb,var(--primary,#222) 56%,transparent);
+ font:750 8px/1 ui-monospace,monospace;letter-spacing:.12em;text-align:right;}
+ .ldp-loader-portal span:nth-child(even)::before{text-align:left;}
+ .ldp-loader-portal span:nth-child(n+3)::after{content:"";position:absolute;right:0;bottom:5px;width:calc(38px + var(--i) * 5px);height:1px;background:color-mix(in srgb,var(--primary,#222) 18%,transparent);}
+ .ldp-loader-portal span:nth-child(even)::after{left:0;right:auto;}
+ @keyframes ldp-loader-topic-mark{0%,100%{transform:scale(.94);opacity:.55}45%,70%{transform:scale(1);opacity:1}}
+ @keyframes ldp-loader-topic-spine{0%,12%{transform:scaleY(.08);opacity:0}38%,78%{transform:scaleY(1);opacity:1}100%{transform:scaleY(.32);opacity:0}}
+ @keyframes ldp-loader-topic-open{0%,12%{transform:translate3d(calc(var(--side) * 24px),0,0) scaleX(.12);opacity:0}40%,72%{transform:translate3d(0,0,0) scaleX(1);opacity:.94}100%{transform:translate3d(calc(var(--side) * -8px),0,0) scaleX(.72);opacity:0}}
+
+ .ldp-loading-visual[data-animation="constellation"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 78%,var(--tertiary,#47855f));}
+ .ldp-loader-thread-index::before{content:"REPLY / THREAD";position:absolute;left:32px;top:16px;color:color-mix(in srgb,var(--primary,#222) 45%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.17em;}
+ .ldp-loader-thread-index::after{content:"";position:absolute;left:27px;top:38px;width:2px;height:108px;transform-origin:center top;border-radius:99px;
+ background:linear-gradient(180deg,transparent,var(--ldp-loader-brand) 12% 70%,transparent);box-shadow:0 0 10px color-mix(in srgb,var(--ldp-loader-brand) 20%,transparent);
+ animation:ldp-loader-thread-caret 5.2s cubic-bezier(.65,0,.35,1) infinite;}
+ .ldp-loader-thread-index span{left:calc(38px + var(--depth) * 26px);top:calc(38px + var(--i) * 21px);width:calc(207px - var(--depth) * 25px);height:17px;
+ border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 17%,transparent);color:color-mix(in srgb,var(--primary,#222) 57%,var(--ldp-loader-accent));
+ animation:ldp-loader-thread-row 5.2s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .08s);}
+ .ldp-loader-thread-index span::before{content:attr(data-user);position:absolute;left:0;bottom:4px;font:650 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.055em;}
+ .ldp-loader-thread-index span::after{content:attr(data-floor);position:absolute;right:0;bottom:4px;font:720 8px/1 ui-monospace,monospace;letter-spacing:.07em;}
+ .ldp-loader-thread-index span:first-child{border-color:color-mix(in srgb,var(--ldp-loader-brand) 72%,transparent);color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-brand));}
+ .ldp-loader-thread-index span:first-child::before{padding:2px 6px;background:var(--ldp-loader-brand);color:#202124;clip-path:polygon(3% 12%,97% 0,100% 87%,0 100%);}
+ @keyframes ldp-loader-thread-caret{0%,9%{transform:scaleY(.24);opacity:.18}24%{opacity:.92}64%{transform:scaleY(1);opacity:.86}76%,100%{transform:scaleY(.46);opacity:.18}}
+ @keyframes ldp-loader-thread-row{0%,8%{transform:translate3d(-9px,0,0);opacity:.24}24%,72%{transform:translate3d(0,0,0);opacity:.96}88%,100%{transform:translate3d(4px,0,0);opacity:.38}}
+
+ .ldp-loading-visual[data-animation="corridor"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 64%,var(--tertiary,#47855f));}
+ .ldp-loader-floor-reel{background:linear-gradient(90deg,transparent 0 65px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 66px,transparent 67px);}
+ .ldp-loader-floor-reel::before{content:"FLOOR / TIME";position:absolute;left:28px;top:16px;color:color-mix(in srgb,var(--primary,#222) 44%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.17em;}
+ .ldp-loader-floor-reel::after{content:"";position:absolute;left:57px;top:40px;width:172px;height:2px;transform-origin:left center;
+ background:linear-gradient(90deg,var(--ldp-loader-brand),color-mix(in srgb,var(--ldp-loader-brand) 36%,transparent) 58%,transparent);
+ box-shadow:0 0 9px color-mix(in srgb,var(--ldp-loader-brand) 18%,transparent);animation:ldp-loader-reel-window 4.9s cubic-bezier(.65,0,.35,1) infinite;}
+ .ldp-loader-floor-reel span{left:68px;top:calc(33px + var(--i) * 25px);width:162px;height:22px;border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 15%,transparent);
+ color:color-mix(in srgb,var(--primary,#222) 52%,var(--ldp-loader-accent));animation:ldp-loader-reel-row 4.9s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .06s);}
+ .ldp-loader-floor-reel span::before{content:attr(data-floor);position:absolute;left:9px;bottom:4px;font:740 11px/1 ui-monospace,monospace;letter-spacing:.09em;}
+ .ldp-loader-floor-reel span::after{content:attr(data-time);position:absolute;right:5px;bottom:4px;font:620 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.11em;}
+ .ldp-loader-floor-reel span:nth-child(3){color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-brand));border-color:color-mix(in srgb,var(--ldp-loader-brand) 64%,transparent);}
+ .ldp-loader-floor-reel span:nth-child(3)::before{font-size:15px;}
+ @keyframes ldp-loader-reel-window{0%,8%{transform:translate3d(0,0,0) scaleX(.18);opacity:.16}22%{opacity:.95}66%{transform:translate3d(0,100px,0) scaleX(1);opacity:.86}78%,100%{transform:translate3d(0,100px,0) scaleX(.48);opacity:.16}}
+ @keyframes ldp-loader-reel-row{0%,8%{transform:translate3d(0,5px,0);opacity:.34}24%,74%{transform:translate3d(0,0,0);opacity:.92}88%,100%{transform:translate3d(0,-3px,0);opacity:.42}}
+
+ .ldp-loading-visual[data-animation="typewave"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 70%,var(--tertiary,#47855f));}
+ .ldp-loader-typewave{overflow:hidden;background:linear-gradient(90deg,transparent 49.7%,color-mix(in srgb,var(--ldp-loader-accent) 10%,transparent) 49.8% 50.2%,transparent 50.3%);}
+ .ldp-loader-typewave::before{content:"MARKDOWN → RENDERED";position:absolute;left:34px;top:20px;color:color-mix(in srgb,var(--primary,#222) 44%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.13em;}
+ .ldp-loader-typewave::after{content:"";position:absolute;left:72px;top:38px;width:2px;height:103px;border-radius:99px;background:linear-gradient(180deg,transparent,var(--ldp-loader-brand),transparent);
+ box-shadow:0 0 12px color-mix(in srgb,var(--ldp-loader-brand) 34%,transparent);animation:ldp-loader-markdown-scan 4.8s cubic-bezier(.65,0,.35,1) infinite;}
+ .ldp-loader-typewave span{height:25px;box-sizing:border-box;will-change:transform,opacity;}
+ .ldp-loader-typewave span:nth-child(-n+3){left:31px;top:calc(49px + var(--row) * 34px);width:96px;color:color-mix(in srgb,var(--primary,#222) 68%,var(--ldp-loader-accent));
+ font:700 10px/25px ui-monospace,monospace;border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 18%,transparent);
+ animation:ldp-loader-markdown-source 4.8s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--row) * .12s);}
+ .ldp-loader-typewave span:nth-child(-n+3)::before{content:attr(data-source);}
+ .ldp-loader-typewave span:nth-child(n+4){left:157px;top:calc(47px + var(--row) * 34px);width:92px;color:color-mix(in srgb,var(--primary,#222) 78%,var(--ldp-loader-accent));
+ font:650 11px/25px ui-sans-serif,system-ui,sans-serif;animation:ldp-loader-markdown-render 4.8s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--row) * .12s);}
+ .ldp-loader-typewave span:nth-child(n+4)::before{content:attr(data-render);}
+ .ldp-loader-typewave span:nth-child(4){height:28px;font-size:17px;font-weight:790;line-height:28px;border-bottom:2px solid color-mix(in srgb,var(--ldp-loader-brand) 78%,transparent);}
+ .ldp-loader-typewave span:nth-child(5){font-weight:820;background:linear-gradient(transparent 59%,color-mix(in srgb,var(--ldp-loader-brand) 45%,transparent) 59% 91%,transparent 91%);}
+ .ldp-loader-typewave span:nth-child(6){border-left:3px solid var(--ldp-loader-brand);padding-left:9px;color:color-mix(in srgb,var(--primary,#222) 61%,var(--ldp-loader-accent));font-style:italic;}
+ @keyframes ldp-loader-markdown-scan{0%,10%{transform:translateX(0) scaleY(.5);opacity:0}24%{opacity:1}64%{transform:translateX(135px) scaleY(1);opacity:1}82%,100%{transform:translateX(135px) scaleY(.72);opacity:0}}
+ @keyframes ldp-loader-markdown-source{0%,8%{transform:translate3d(-15px,0,0);opacity:0}22%,40%{transform:translate3d(0,0,0);opacity:1}58%,100%{transform:translate3d(26px,0,0);opacity:0}}
+ @keyframes ldp-loader-markdown-render{0%,45%{transform:translate3d(-24px,4px,0) scale(.94);opacity:0}62%,84%{transform:translate3d(0,0,0) scale(1);opacity:1}100%{transform:translate3d(7px,-2px,0) scale(.98);opacity:0}}
+
+ .ldp-loading-visual[data-animation="crystal"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 58%,var(--tertiary,#47855f));}
+ .ldp-loader-cache-lanes{background:linear-gradient(180deg,transparent 50px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 51px,transparent 52px 83px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 84px,transparent 85px 116px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 117px,transparent 118px);}
+ .ldp-loader-cache-lanes::before{content:"CACHE\\A REUSE";position:absolute;left:111px;top:45px;width:58px;height:79px;display:grid;place-items:center;z-index:2;white-space:pre;
+ border-left:1px solid color-mix(in srgb,var(--ldp-loader-accent) 30%,transparent);border-right:1px solid color-mix(in srgb,var(--ldp-loader-accent) 30%,transparent);
+ background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--secondary,#fff) 94%,var(--ldp-loader-brand)) 28% 72%,transparent);
+ color:color-mix(in srgb,var(--primary,#222) 72%,var(--ldp-loader-accent));font:790 8px/1.65 ui-monospace,monospace;letter-spacing:.12em;text-align:center;}
+ .ldp-loader-cache-lanes::after{content:"MISS HIT";position:absolute;left:31px;top:23px;width:218px;color:color-mix(in srgb,var(--primary,#222) 43%,transparent);
+ font:740 7px/1 ui-monospace,monospace;letter-spacing:.12em;white-space:pre;}
+ .ldp-loader-cache-lanes span{left:34px;top:calc(49px + var(--lane) * 33px);z-index:1;width:13px;height:3px;border-radius:99px;background:color-mix(in srgb,var(--ldp-loader-accent) 74%,transparent);
+ box-shadow:0 0 0 3px color-mix(in srgb,var(--ldp-loader-accent) 5%,transparent);animation:ldp-loader-cache-packet 4.8s cubic-bezier(.65,0,.35,1) infinite both;animation-delay:calc(var(--i) * -.4s);}
+ .ldp-loader-cache-lanes span:nth-child(3n+2){animation-direction:reverse;}
+ .ldp-loader-cache-lanes span:nth-child(4n+1){width:16px;background:var(--ldp-loader-brand);box-shadow:0 0 0 4px color-mix(in srgb,var(--ldp-loader-brand) 10%,transparent);}
+ .ldp-loader-cache-lanes span:nth-child(4n+3){width:7px;opacity:.72;}
+ @keyframes ldp-loader-cache-packet{0%,5%{transform:translate3d(0,0,0) scaleX(.72);opacity:.16}14%{opacity:.9}43%{transform:translate3d(96px,0,0) scaleX(1);opacity:1}54%{transform:translate3d(110px,0,0) scaleX(.82);opacity:.72}88%{transform:translate3d(196px,0,0) scaleX(1);opacity:.74}100%{transform:translate3d(196px,0,0) scaleX(.72);opacity:.16}}
+
+ .ldp-loading-visual[data-animation="marginalia"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 72%,var(--tertiary,#47855f));}
+ .ldp-loader-marginalia::before{content:"只看楼主";position:absolute;left:27px;top:18px;color:color-mix(in srgb,var(--primary,#222) 66%,var(--ldp-loader-accent));font:800 9px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.18em;}
+ .ldp-loader-marginalia::after{content:"";position:absolute;left:23px;top:39px;width:3px;height:106px;border-radius:99px;background:linear-gradient(180deg,var(--ldp-loader-brand),transparent);animation:ldp-loader-op-scan 4.2s cubic-bezier(.65,0,.35,1) infinite;}
+ .ldp-loader-marginalia span{left:41px;top:calc(36px + var(--i) * 24px);width:203px;height:18px;border-bottom:1px solid color-mix(in srgb,var(--primary,#222) 19%,transparent);
+ color:color-mix(in srgb,var(--primary,#222) 56%,transparent);animation:ldp-loader-op-muted 4.2s ease-in-out infinite;animation-delay:calc(var(--i) * -.13s);}
+ .ldp-loader-marginalia span::before{content:attr(data-user);position:absolute;left:0;bottom:4px;font:750 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.08em;}
+ .ldp-loader-marginalia span::after{content:attr(data-floor);position:absolute;right:0;bottom:4px;font:700 8px/1 ui-monospace,monospace;}
+ .ldp-loader-marginalia span[data-user="OP"]{border-color:color-mix(in srgb,var(--ldp-loader-brand) 68%,transparent);color:color-mix(in srgb,var(--primary,#222) 88%,var(--ldp-loader-brand));animation-name:ldp-loader-op-focus;}
+ .ldp-loader-marginalia span[data-user="OP"]::before{color:#202124;background:var(--ldp-loader-brand);padding:2px 5px;clip-path:polygon(4% 9%,96% 0,100% 87%,0 100%);}
+ @keyframes ldp-loader-op-scan{0%,8%{transform:translateY(0) scaleY(.3);opacity:0}35%{opacity:1}84%,100%{transform:translateY(94px) scaleY(1);opacity:0}}
+ @keyframes ldp-loader-op-muted{0%,20%{transform:translateX(0);opacity:.62}48%,76%{transform:translateX(18px);opacity:.12}100%{transform:translateX(25px);opacity:0}}
+ @keyframes ldp-loader-op-focus{0%,16%{transform:translateX(-12px);opacity:0}40%,78%{transform:translateX(0);opacity:1}100%{transform:translateX(7px);opacity:0}}
+
+ .ldp-loading-visual[data-animation="chapters"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 66%,var(--tertiary,#47855f));}
+ .ldp-loader-index-fan::before{content:"TOPIC / INDEX";position:absolute;left:28px;top:18px;color:color-mix(in srgb,var(--primary,#222) 45%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.17em;}
+ .ldp-loader-index-fan::after{content:"";position:absolute;left:30px;top:50px;width:217px;height:2px;transform-origin:left center;
+ background:linear-gradient(90deg,var(--ldp-loader-brand),color-mix(in srgb,var(--ldp-loader-brand) 35%,transparent),transparent);animation:ldp-loader-index-rule 5s cubic-bezier(.16,1,.3,1) infinite;}
+ .ldp-loader-index-fan span{left:calc(29px + var(--i) * 45px);top:calc(40px + var(--i) * 15px);width:38px;height:67px;color:color-mix(in srgb,var(--primary,#222) 62%,var(--ldp-loader-accent));
+ border-left:1px solid color-mix(in srgb,var(--ldp-loader-accent) 19%,transparent);transform-origin:left bottom;animation:ldp-loader-index-leaf 5s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .075s);}
+ .ldp-loader-index-fan span::before{content:attr(data-index);position:absolute;left:7px;top:1px;font:700 14px/1 ui-serif,"Songti SC","Noto Serif CJK SC",serif;letter-spacing:.03em;}
+ .ldp-loader-index-fan span::after{content:attr(data-tag);position:absolute;left:7px;bottom:5px;white-space:nowrap;font:650 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.1em;}
+ .ldp-loader-index-fan span:first-child{border-color:var(--ldp-loader-brand);color:color-mix(in srgb,var(--primary,#222) 78%,var(--ldp-loader-brand));}
+ .ldp-loader-index-fan span:first-child::before{font-size:18px;font-weight:760;}
+ @keyframes ldp-loader-index-rule{0%,8%{transform:rotate(14deg) scaleX(.08);opacity:.18}30%,74%{transform:rotate(14deg) scaleX(1);opacity:.9}88%,100%{transform:rotate(14deg) scaleX(.58);opacity:.2}}
+ @keyframes ldp-loader-index-leaf{0%,8%{transform:translate3d(-7px,14px,0) rotate(-3deg);opacity:.2}26%,74%{transform:translate3d(0,0,0) rotate(0);opacity:.96}88%,100%{transform:translate3d(2px,-3px,0) rotate(.6deg);opacity:.38}}
+
+ .ldp-loading-visual[data-animation="quoteecho"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 82%,var(--secondary,#fff));}
+ .ldp-loader-quoteecho::before{content:"LINUX DO";position:absolute;left:87px;top:27px;width:106px;color:color-mix(in srgb,var(--primary,#222) 46%,transparent);font:850 10px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.26em;text-align:center;}
+ .ldp-loader-quoteecho::after{content:"";position:absolute;left:54px;right:54px;top:117px;height:3px;transform-origin:center;background:linear-gradient(90deg,transparent 0,var(--ldp-loader-brand) 18% 82%,transparent 100%);animation:ldp-loader-creed-line 5.6s cubic-bezier(.65,0,.35,1) infinite;}
+ .ldp-loader-quoteecho span{left:calc(35px + var(--i) * 57px);top:62px;width:50px;height:32px;display:grid;place-items:center;color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-accent));
+ font:750 17px/1 ui-serif,"Songti SC","Noto Serif CJK SC",serif;letter-spacing:.12em;animation:ldp-loader-creed-word 5.6s cubic-bezier(.16,1,.3,1) infinite both;}
+ .ldp-loader-quoteecho span:nth-child(2){animation-name:ldp-loader-creed-word-2;}
+ .ldp-loader-quoteecho span:nth-child(3){animation-name:ldp-loader-creed-word-3;}
+ .ldp-loader-quoteecho span:nth-child(4){animation-name:ldp-loader-creed-word-4;}
+ .ldp-loader-quoteecho span::before{content:attr(data-word);}
+ .ldp-loader-quoteecho span::after{content:"";position:absolute;left:23px;bottom:-15px;width:4px;height:4px;border-radius:50%;background:var(--ldp-loader-brand);}
+ @keyframes ldp-loader-creed-line{0%,26%{transform:scaleX(.02);opacity:0}40%,78%{transform:scaleX(1);opacity:1}86%,100%{transform:scaleX(.02);opacity:0}}
+ @keyframes ldp-loader-creed-word{0%,8%{transform:translateY(11px) scale(.95);opacity:0}28%,72%{transform:translateY(0) scale(1);opacity:1}80%,100%{transform:translateY(-5px) scale(.98);opacity:0}}
+ @keyframes ldp-loader-creed-word-2{0%,14%{transform:translateY(11px) scale(.95);opacity:0}34%,74%{transform:translateY(0) scale(1);opacity:1}82%,100%{transform:translateY(-5px) scale(.98);opacity:0}}
+ @keyframes ldp-loader-creed-word-3{0%,20%{transform:translateY(11px) scale(.95);opacity:0}40%,76%{transform:translateY(0) scale(1);opacity:1}84%,100%{transform:translateY(-5px) scale(.98);opacity:0}}
+ @keyframes ldp-loader-creed-word-4{0%,26%{transform:translateY(11px) scale(.95);opacity:0}46%,78%{transform:translateY(0) scale(1);opacity:1}86%,100%{transform:translateY(-5px) scale(.98);opacity:0}}
+
+ .ldp-loading-visual[data-animation="footnotes"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 62%,var(--tertiary,#47855f));}
+ .ldp-loader-inbox-rain::before{content:"NEW / REPLIES";position:absolute;left:28px;top:18px;color:color-mix(in srgb,var(--primary,#222) 48%,var(--ldp-loader-accent));
+ font:790 8px/1 ui-monospace,monospace;letter-spacing:.16em;border-left:2px solid var(--ldp-loader-brand);padding-left:9px;}
+ .ldp-loader-inbox-rain::after{content:"";position:absolute;left:30px;top:124px;width:220px;height:1px;transform-origin:left center;
+ background:linear-gradient(90deg,color-mix(in srgb,var(--ldp-loader-accent) 35%,transparent),color-mix(in srgb,var(--ldp-loader-brand) 68%,transparent) 74%,transparent);
+ animation:ldp-loader-inbox-rule 5.2s cubic-bezier(.16,1,.3,1) infinite;}
+ .ldp-loader-inbox-rain span{left:var(--x);top:var(--y);width:37px;height:27px;display:grid;place-items:center;color:color-mix(in srgb,var(--primary,#222) 74%,var(--ldp-loader-accent));
+ border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 29%,transparent);font:740 9px/1 ui-monospace,monospace;animation:ldp-loader-inbox-note 5.2s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .1s);}
+ .ldp-loader-inbox-rain span::before{content:attr(data-floor);}
+ .ldp-loader-inbox-rain span::after{content:"";position:absolute;right:0;bottom:-2px;width:4px;height:4px;border-radius:50%;background:var(--ldp-loader-brand);box-shadow:0 0 0 4px color-mix(in srgb,var(--ldp-loader-brand) 9%,transparent);}
+ .ldp-loader-inbox-rain span:last-child{color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-brand));border-color:var(--ldp-loader-brand);}
+ @keyframes ldp-loader-inbox-rule{0%,8%{transform:rotate(-18deg) scaleX(.08);opacity:.16}28%,76%{transform:rotate(-18deg) scaleX(1);opacity:.9}90%,100%{transform:rotate(-18deg) scaleX(.62);opacity:.22}}
+ @keyframes ldp-loader-inbox-note{0%,8%{transform:translate3d(0,var(--drop),0) scale(.96);opacity:.18}28%,76%{transform:translate3d(0,0,0) scale(1);opacity:1}90%,100%{transform:translate3d(3px,5px,0) scale(.98);opacity:.34}}
+
+ .ldp-loading-visual[data-animation="inkverse"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 74%,var(--tertiary,#47855f));}
+ .ldp-loader-inkverse::before{content:"DO";position:absolute;left:117px;top:57px;width:46px;height:46px;display:grid;place-items:center;z-index:1;
+ border-radius:43% 57% 61% 39% / 39% 44% 56% 61%;background:radial-gradient(circle at 72% 27%,#202124 0 3px,transparent 3.5px),var(--ldp-loader-brand);
+ color:#202124;font:900 12px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.12em;text-indent:.12em;
+ box-shadow:-8px 9px 0 color-mix(in srgb,var(--ldp-loader-brand) 13%,transparent),0 0 0 1px color-mix(in srgb,#202124 12%,transparent);animation:ldp-loader-action-core 4.4s ease-in-out infinite;}
+ .ldp-loader-inkverse::after{content:"";position:absolute;left:48px;top:25px;width:184px;height:120px;border-radius:50%;
+ background:conic-gradient(from 18deg,transparent 0 11%,color-mix(in srgb,var(--ldp-loader-accent) 29%,transparent) 11% 27%,transparent 27% 41%,color-mix(in srgb,var(--ldp-loader-accent) 22%,transparent) 41% 67%,transparent 67% 80%,var(--ldp-loader-brand) 80% 83%,transparent 83% 100%);
+ -webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 1px),#000 calc(100% - 1px));mask:radial-gradient(farthest-side,transparent calc(100% - 1px),#000 calc(100% - 1px));
+ animation:ldp-loader-action-orbit 16s linear infinite;}
+ .ldp-loader-inkverse span{left:var(--x);top:var(--y);width:56px;height:21px;display:grid;place-items:center;color:color-mix(in srgb,var(--primary,#222) 72%,var(--ldp-loader-accent));
+ border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 18%,transparent);font:760 9px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.06em;
+ animation:ldp-loader-action-label 4.4s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--i) * -.31s);}
+ .ldp-loader-inkverse span::before{content:attr(data-action);}
+ .ldp-loader-inkverse span::after{content:"";position:absolute;left:25px;top:25px;width:3px;height:3px;border-radius:50%;background:var(--ldp-loader-brand);
+ animation:ldp-loader-action-pulse 4.4s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--i) * -.31s);}
+ .ldp-loader-inkverse span:nth-child(1){--x:91px;--y:12px;--mx:29px;--my:25px;--dx:21px;--dy:58px}
+ .ldp-loader-inkverse span:nth-child(2){--x:207px;--y:50px;--mx:-43px;--my:24px;--dx:-95px;--dy:20px}
+ .ldp-loader-inkverse span:nth-child(3){--x:139px;--y:137px;--mx:-24px;--my:-36px;--dx:-27px;--dy:-67px}
+ .ldp-loader-inkverse span:nth-child(4){--x:17px;--y:94px;--mx:47px;--my:-25px;--dx:95px;--dy:-24px}
+ @keyframes ldp-loader-action-core{0%,100%{transform:scale(.9);opacity:.62}48%{transform:scale(1.08);opacity:1}}
+ @keyframes ldp-loader-action-orbit{0%{transform:rotate(-8deg) scale(.98)}50%{transform:rotate(172deg) scale(1.02)}100%{transform:rotate(352deg) scale(.98)}}
+ @keyframes ldp-loader-action-label{0%,16%{transform:translateY(5px) scale(.92);opacity:.22}38%,80%{transform:translateY(0) scale(1);opacity:.9}100%{transform:translateY(-3px) scale(.96);opacity:.22}}
+ @keyframes ldp-loader-action-pulse{0%,18%{transform:translate3d(0,0,0) scale(.65);opacity:0}38%{opacity:1}62%{transform:translate3d(var(--mx),var(--my),0) scale(1);opacity:.9}82%{transform:translate3d(var(--dx),var(--dy),0) scale(1.45);opacity:1}100%{transform:translate3d(var(--dx),var(--dy),0) scale(.35);opacity:0}}
+
+ @keyframes ldp-loader-reduced-breathe{0%,100%{opacity:.76}50%{opacity:1}}
+ @container ldp-reader (max-width:380px){
+ .ldp-loading-visual{transform:scale(.88);transform-origin:center center;}
+ }
+ @media (prefers-reduced-motion: reduce){
+ :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner,
+ :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner::before,
+ :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner::after,
+ :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner span,
+ :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner span::before,
+ :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner span::after{animation:none!important;will-change:auto!important;}
+ :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner{animation:ldp-loader-reduced-breathe 2.5s ease-in-out infinite!important;}
+ }
+
+ /* 楼层区分隔 + 左上角标题 */
+ .ldp-comments-header{display:flex;align-items:center;gap:8px;
+ margin:6px 0 2px;padding-top:14px;border-top:var(--ldp-divider-line-emphasis-width,2px) solid var(--ldp-divider-line-color,#e5e5e5);
+ font-size:var(--ldp-font-lg,15px);font-weight:700;letter-spacing:.5px;}
+ .ldp-comments-count{font-size:var(--ldp-font-ui,12px);font-weight:500;opacity:.6;}
+ .ldp-topic-presence{display:inline-flex;align-items:center;gap:7px;min-width:0;max-width:min(55%,18rem);
+ margin-left:auto;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);font-weight:500;letter-spacing:0;}
+ .ldp-topic-presence-avatars{display:flex;flex:none;align-items:center;padding-left:4px;}
+ .ldp-topic-presence-user{display:inline-flex;margin-left:-4px;line-height:0;}
+ .ldp-topic-presence-user:first-child{margin-left:0;}
+ .ldp-topic-presence-avatar{box-sizing:border-box;width:22px;height:22px;border:2px solid var(--secondary,#fff);
+ border-radius:50%;object-fit:cover;background:var(--primary-low,#ddd);}
+ .ldp-topic-presence-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-comments{--ldp-nested-content-scale:.97;--ldp-nested-component-scale:.95;--ldp-nested-component-gap:7.6px;padding-top:4px;}
.ldp-comments-empty{padding:18px 0;text-align:center;opacity:.5;font-size:var(--ldp-font-base,13px);}
- .ldp-post{padding:12px 16px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
- content-visibility:auto;contain-intrinsic-size:auto 140px;}
- .ldp-overlay.ldp-initial-target-layout .ldp-post{content-visibility:visible;}
- .ldp-comments > .ldp-post.ldp-zebra-alt:not(.ldp-nested-preview):not(.ldp-nested-collapsed){
- position:relative;isolation:isolate;border-radius:var(--ldp-zebra-radius,10px);border-bottom-color:transparent;background:transparent;}
- .ldp-comments > .ldp-post.ldp-zebra-alt:not(.ldp-nested-preview):not(.ldp-nested-collapsed)::after{
- content:"";position:absolute;z-index:-2;inset:6px 0;border-radius:inherit;
- background:var(--ldp-zebra-color,honeydew);pointer-events:none;}
- .ldp-comments > .ldp-post:has(+ .ldp-post.ldp-zebra-alt:not(.ldp-nested-preview):not(.ldp-nested-collapsed)){
- border-bottom-color:transparent;}
- .ldp-comments > .ldp-post:has(+ .ldp-post > .ldp-hidden-reply-marker){border-bottom-color:transparent;}
- .ldp-comments > .ldp-post.ldp-nested-preview{background:transparent;}
- .ldp-stream-spacer{height:var(--ldp-stream-spacer-height,64px);
- border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);box-sizing:border-box;pointer-events:none;overflow-anchor:none;}
- .ldp-stream-spacer-top:has(+ .ldp-post[data-post-number="1"]){border-bottom-color:transparent;}
- .ldp-body:has(> .ldp-end-tip.show) > .ldp-comments > .ldp-post.ldp-hidden-replies-at-end ~ .ldp-stream-spacer-bottom{
- height:0;border-bottom-color:transparent;}
- .ldp-content-pending{overflow-anchor:none;}
- .ldp-post-head{display:flex;align-items:center;gap:8px;min-width:0;margin-bottom:5px;}
- .ldp-avatar{width:28px;height:28px;border-radius:50%;}
- .ldp-avatar-with-flair{position:relative;display:inline-flex;flex:none;line-height:0;}
- .ldp-avatar-flair{position:absolute;right:-3px;bottom:-2px;display:grid;box-sizing:border-box;
- width:13px;height:13px;place-items:center;overflow:hidden;pointer-events:auto;cursor:copy;border:1.5px solid var(--secondary,#fff);
- border-radius:50%;background:var(--ldp-flair-bg,var(--primary-low,#d9e1ec));color:var(--ldp-flair-color,var(--primary,#334155));}
- .ldp-avatar-flair-icon{display:block;width:70%;height:70%;fill:currentColor;stroke:none;}
- .ldp-avatar-flair-image{display:block;width:100%;height:100%;object-fit:contain;}
- .ldp-reply.ldp-nested-collapsed .ldp-avatar-flair{right:-2px;bottom:-1px;width:10px;height:10px;border-width:1px;}
- .ldp-persistent-avatar-fallback{display:inline-flex;align-items:center;justify-content:center;
- overflow:hidden;flex:none;background:var(--primary-low,#ddd);color:var(--primary-medium,#666);
- font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1;}
- .ldp-user-link{color:inherit;text-decoration:none;cursor:pointer;}
- .ldp-user-link:hover{color:var(--tertiary,#08c);text-decoration:underline;}
- .ldp-user-avatar-card{cursor:pointer;}
- span.ldp-user-avatar-card{display:inline-flex;line-height:0;flex:none;}
- .ldp-avatar-link{display:inline-flex;padding:0;border:0;background:transparent;color:inherit;line-height:0;cursor:zoom-in;}
- .ldp-author{font-weight:600;min-width:0;max-width:min(20rem,40%);
- overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-op{display:inline-flex;align-items:center;flex:none;font-size:var(--ldp-font-sm,11px);font-weight:700;color:#fff;background:var(--tertiary,#08c);
- border-radius:4px;padding:1px 6px;letter-spacing:.5px;}
+ .ldp-post{padding:12px 16px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);
+ content-visibility:auto;contain-intrinsic-size:auto 140px;}
+ .ldp-overlay.ldp-initial-target-layout .ldp-post{content-visibility:visible;}
+ .ldp-comments > .ldp-post.ldp-zebra-alt:not(.ldp-nested-preview):not(.ldp-nested-collapsed){
+ position:relative;isolation:isolate;border-radius:var(--ldp-zebra-radius,10px);border-bottom-color:transparent;background:transparent;}
+ .ldp-comments > .ldp-post.ldp-zebra-alt:not(.ldp-nested-preview):not(.ldp-nested-collapsed)::after{
+ content:"";position:absolute;z-index:-2;inset:6px 0;border-radius:inherit;
+ background:var(--ldp-zebra-color,honeydew);pointer-events:none;}
+ .ldp-comments > .ldp-post:has(+ .ldp-post.ldp-zebra-alt:not(.ldp-nested-preview):not(.ldp-nested-collapsed)){
+ border-bottom-color:transparent;}
+ .ldp-comments > .ldp-post:has(+ .ldp-post > .ldp-hidden-reply-marker){border-bottom-color:transparent;}
+ .ldp-comments > .ldp-post.ldp-nested-preview{background:transparent;}
+ .ldp-stream-spacer{height:var(--ldp-stream-spacer-height,64px);
+ border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);box-sizing:border-box;pointer-events:none;overflow-anchor:none;}
+ .ldp-stream-spacer-top:has(+ .ldp-post[data-post-number="1"]){border-bottom-color:transparent;}
+ .ldp-body:has(> .ldp-end-tip.show) > .ldp-comments > .ldp-post.ldp-hidden-replies-at-end ~ .ldp-stream-spacer-bottom{
+ height:0;border-bottom-color:transparent;}
+ .ldp-content-pending{overflow-anchor:none;}
+ .ldp-post-head{display:flex;align-items:center;gap:8px;min-width:0;margin-bottom:5px;}
+ .ldp-avatar{width:28px;height:28px;border-radius:50%;}
+ .ldp-avatar-with-flair{position:relative;display:inline-flex;flex:none;line-height:0;}
+ .ldp-avatar-flair{position:absolute;right:-3px;bottom:-2px;display:grid;box-sizing:border-box;
+ width:13px;height:13px;place-items:center;overflow:hidden;pointer-events:auto;cursor:copy;border:1.5px solid var(--secondary,#fff);
+ border-radius:50%;background:var(--ldp-flair-bg,var(--primary-low,#d9e1ec));color:var(--ldp-flair-color,var(--primary,#334155));}
+ .ldp-avatar-flair-icon{display:block;width:70%;height:70%;fill:currentColor;stroke:none;}
+ .ldp-avatar-flair-image{display:block;width:100%;height:100%;object-fit:contain;}
+ .ldp-reply.ldp-nested-collapsed .ldp-avatar-flair{right:-2px;bottom:-1px;width:10px;height:10px;border-width:1px;}
+ .ldp-persistent-avatar-fallback{display:inline-flex;align-items:center;justify-content:center;
+ overflow:hidden;flex:none;background:var(--primary-low,#ddd);color:var(--primary-medium,#666);
+ font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1;}
+ .ldp-user-link{color:inherit;text-decoration:none;cursor:pointer;}
+ .ldp-user-link:hover{color:var(--tertiary,#08c);text-decoration:underline;}
+ .ldp-user-avatar-card{cursor:pointer;}
+ span.ldp-user-avatar-card{display:inline-flex;line-height:0;flex:none;}
+ .ldp-avatar-link{display:inline-flex;padding:0;border:0;background:transparent;color:inherit;line-height:0;cursor:zoom-in;}
+ .ldp-author{font-weight:600;min-width:0;max-width:min(20rem,40%);
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-op{display:inline-flex;align-items:center;flex:none;font-size:var(--ldp-font-sm,11px);font-weight:700;color:#fff;background:var(--tertiary,#08c);
+ border-radius:4px;padding:1px 6px;letter-spacing:.5px;}
.ldp-me{font-size:var(--ldp-font-sm,11px);font-weight:700;color:#fff;background:#3ea66b;
border-radius:4px;padding:1px 6px;letter-spacing:.5px;}
- .ldp-user{font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);opacity:.6;min-width:0;max-width:min(14rem,25%);
- overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-new-user-badge{display:inline-flex;align-items:center;justify-content:center;gap:4px;flex:none;
- min-height:21px;padding:2px 7px 2px 5px;box-sizing:border-box;
- border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 42%,transparent);
- border-radius:999px;background:color-mix(in srgb,var(--tertiary,#47855f) 18%,var(--secondary,#fff));
- color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1;white-space:nowrap;}
- .ldp-new-user-badge .ldp-icon{width:12px;height:12px;stroke-width:2.35;}
- .ldp-new-user-badge:hover{background:color-mix(in srgb,var(--tertiary,#47855f) 27%,var(--secondary,#fff));}
- .ldp-time{position:relative;font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);white-space:nowrap;flex:none;}
- .ldp-time-relative{opacity:.55;}
- .ldp-time[data-exact-time]::after{content:attr(data-exact-time);position:absolute;left:calc(100% + 6px);top:50%;z-index:5;
- transform:translateY(-50%);display:none;color:inherit;font:inherit;line-height:inherit;opacity:.55;
- white-space:nowrap;pointer-events:none;}
- .ldp-time[data-exact-time]:hover::after{display:block;}
- .ldp-floor{font-size:var(--ldp-font-sm,11px);font-weight:var(--ldp-reader-font-weight-base,400);line-height:1.35;color:var(--primary-medium,#777);
- opacity:1;margin-left:auto;padding-left:8px;white-space:nowrap;}
- .ldp-post-read-state{display:inline-flex;flex:none;width:12px;height:12px;align-items:center;justify-content:center;
- color:var(--tertiary,#47855f);line-height:1;}
- .ldp-post-read-state .ldp-icon{width:9px;height:9px;stroke-width:2.6;}
- .ldp-post-read-state.is-read{opacity:.72;}
- .ldp-post-read-state.is-unread .ldp-icon{stroke:none;fill:currentColor;}
- .ldp-post-read-state.is-confirming{animation:ldp-post-read-confirm 1.25s ease-in-out both;}
- @keyframes ldp-post-read-confirm{
- 0%,100%{opacity:1;transform:scale(1);}
- 50%{opacity:.12;transform:scale(.72);}
- }
- @media (prefers-reduced-motion:reduce){
- .ldp-post-read-state.is-confirming{animation:none;}
- }
- .ldp-content{overflow-x:auto;color:var(--ldp-post-font-color,var(--primary,#222));font-family:var(--ldp-post-font-family,inherit)!important;
- font-size:var(--ldp-post-font-size,14px);font-weight:var(--ldp-post-font-weight,400);padding-inline:8px;box-sizing:border-box;}
- .ldp-comments .ldp-post > .ldp-post-head{margin-bottom:0;}
- .ldp-comments .ldp-post > .ldp-content:not(.ldp-content-pending){padding-block:12px;}
- .ldp-comments .ldp-post > .ldp-content > :first-child{margin-top:0;}
- .ldp-comments .ldp-post > .ldp-content > :last-child{margin-bottom:0;}
- .ldp-content a,.ldp-content a:visited,.ldp-content a:hover,.ldp-content a:active{
- color:var(--d-link-color,var(--tertiary,#08c));text-decoration:var(--d-link-text-decoration,none);cursor:pointer;}
- .ldp-content a.inline-onebox{display:inline-flex;max-width:min(100%,72ch);align-items:flex-start;gap:.28em;
- line-height:1.5;vertical-align:top;}
- .ldp-content a.inline-onebox > :is(svg,.svg-icon){width:.84em;height:.84em;flex:none;margin-top:.3em;opacity:.8;}
- .ldp-content a.inline-onebox > .ldp-inline-onebox-label{min-width:0;overflow:hidden;
- text-overflow:ellipsis;white-space:nowrap;}
- .ldp-content a.inline-onebox > .ldp-link-click-count{flex:none;margin-inline-start:0;margin-top:.12em;}
- .ldp-content .ldp-link-click-count{display:inline-flex;min-width:1.55em;height:1.55em;align-items:center;justify-content:center;
- margin-inline-start:.32em;padding:0 .42em;box-sizing:border-box;border-radius:999px;
- background:color-mix(in srgb,var(--primary,#222) 10%,var(--secondary,#fff));color:var(--primary-high,#444);
- font-size:.78em;font-weight:650;line-height:1;font-variant-numeric:tabular-nums;vertical-align:.08em;white-space:nowrap;}
- .ldp-content h1,.ldp-content h2,.ldp-content h3,.ldp-content h4,.ldp-content h5,.ldp-content h6{
- margin-top:0;margin-bottom:.5rem;font-family:inherit!important;font-weight:var(--heading-font-weight,bold);}
- .ldp-content aside.onebox{display:block;width:min(100%,760px);max-width:100%;margin:.85em 0;padding:14px 16px;
- overflow:hidden;box-sizing:border-box;border:1px solid var(--primary-low,#e5e5e5);border-radius:7px;
- background:var(--secondary,#fff);color:var(--primary,#222);
- box-shadow:0 1px 2px rgba(0,0,0,.12),0 4px 8px rgba(0,0,0,.08);}
- .ldp-content aside.onebox > header.source{display:flex;align-items:center;gap:6px;min-width:0;
- margin:0 0 11px;color:var(--primary-medium,#777);font-size:.9em;font-weight:600;line-height:1.35;}
- .ldp-content aside.onebox > header.source :is(img,.site-icon){width:18px!important;height:18px!important;
- max-width:18px!important;margin:0!important;border-radius:4px;object-fit:cover;cursor:default;zoom:1;flex:none;}
- .ldp-content aside.onebox > header.source a{min-width:0;overflow:hidden;color:inherit;
- text-overflow:ellipsis;white-space:nowrap;text-decoration:none;}
- .ldp-content aside.onebox > article.onebox-body{min-width:0;margin:0;}
- .ldp-content aside.onebox > article.onebox-body > :is(.aspect-image,.thumbnail),
- .ldp-content aside.onebox > article.onebox-body > :is(.aspect-image,.thumbnail) img,
- .ldp-content aside.onebox > article.onebox-body > img.thumbnail{display:none!important;}
- .ldp-content aside.onebox[data-ldp-github-onebox-normalized="1"] .onebox-body :is(h3,p){
- overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-content aside.onebox .onebox-body h3{margin:0;font-size:1em;
- line-height:1.38;text-wrap:pretty;}
- .ldp-content aside.onebox .onebox-body h3 a{font-weight:650;text-decoration:none;}
- .ldp-content aside.onebox :is(header.source,.onebox-body h3) :is(svg,.svg-icon){width:.9em;height:.9em;flex:none;}
- .ldp-content aside.onebox .onebox-body p{margin-top:6px;margin-bottom:0;color:var(--primary-medium,#666);
- font-size:.92em;line-height:1.5;}
- .ldp-content aside.onebox .onebox-metadata{margin:6px 0 0;color:var(--primary-medium,#777);font-size:.8em;}
- .ldp-content aside.onebox .ldp-link-click-count{min-width:1.4em;height:1.4em;padding:0 .36em;font-size:.72em;}
- .ldp-content hr{display:block;height:1px;margin:1em 0;padding:0;border:0;
- border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-content blockquote{box-sizing:border-box;margin:1em 0;padding:.75em 1em;
- border:0;border-inline-start:var(--ldp-quote-line-width,1px) solid var(--ldp-quote-line-color,#d7d7d7);
- border-radius:0 6px 6px 0;
- background:color-mix(in srgb,var(--primary,#222) 3%,var(--secondary,#fff));
- color:var(--primary,#222);line-height:1.65;}
- .ldp-content blockquote > :first-child{margin-top:0;}
- .ldp-content blockquote > :last-child{margin-bottom:0;}
- .ldp-content blockquote.ldp-callout{--ldp-callout-color:#2f78ef;position:relative;margin:1em 0;
- padding:.8em 1em .8em 2.8em;border:0;border-radius:6px;
- background:color-mix(in srgb,var(--ldp-callout-color) 12%,var(--secondary,#fff));color:var(--primary,#222);}
- .ldp-content blockquote:is(.ldp-callout--success,.ldp-callout--check,.ldp-callout--done){--ldp-callout-color:#2f9e64;}
- .ldp-content blockquote:is(.ldp-callout--question,.ldp-callout--help,.ldp-callout--faq){--ldp-callout-color:#d99c00;}
- .ldp-content blockquote:is(.ldp-callout--warning,.ldp-callout--caution,.ldp-callout--attention){--ldp-callout-color:#e87524;}
- .ldp-content blockquote.ldp-callout--example{--ldp-callout-color:#7658d6;}
- .ldp-content blockquote:is(.ldp-callout--failure,.ldp-callout--fail,.ldp-callout--missing,
- .ldp-callout--danger,.ldp-callout--error,.ldp-callout--bug){--ldp-callout-color:#d64545;}
- .ldp-content blockquote:is(.ldp-callout--quote,.ldp-callout--cite){--ldp-callout-color:#6f7782;}
- .ldp-content blockquote.ldp-callout > .ldp-callout-icon{position:absolute;left:1em;top:.9em;
- display:inline-flex;width:1.1em;height:1.1em;align-items:center;justify-content:center;color:var(--ldp-callout-color);}
- .ldp-content blockquote.ldp-callout > .ldp-callout-icon .ldp-icon{width:100%;height:100%;stroke:currentColor;fill:none;stroke-width:2;}
- .ldp-content blockquote.ldp-callout > .ldp-callout-icon + *{margin-top:0;color:var(--ldp-callout-color);font-weight:700;}
- .ldp-content blockquote.ldp-callout > :last-child{margin-bottom:0;}
- .ldp-content blockquote.ldp-callout--foldable{padding-right:3em;}
- .ldp-content .ldp-callout-toggle{position:absolute;right:.65em;top:.55em;display:inline-flex;width:1.8em;height:1.8em;
- align-items:center;justify-content:center;padding:0;border:0;border-radius:5px;background:transparent;
- color:var(--ldp-callout-color);cursor:pointer;}
- .ldp-content .ldp-callout-toggle:hover{background:color-mix(in srgb,var(--ldp-callout-color) 12%,transparent);}
- .ldp-content .ldp-callout-body{margin-top:.6em;}
- .ldp-content aside.ldp-post-quote{position:relative;box-sizing:border-box;width:100%;height:auto!important;max-height:none!important;
- margin:1em 0;padding:12px 14px;overflow:visible;
- border-left:var(--ldp-quote-line-emphasis-width,5px) solid var(--ldp-quote-line-color,#d7d7d7);background:var(--d-post-aside-background,var(--primary-very-low,#f6f6f6));}
- .ldp-content aside.ldp-post-quote > .ldp-quote-title{display:flex;align-items:center;gap:8px;min-height:28px;
- margin:0 0 10px;padding-right:70px;color:var(--primary-medium,#666);font-size:1em;line-height:1.35;}
- .ldp-content aside.ldp-post-quote > .ldp-quote-title :is(img,.avatar){width:24px!important;height:24px!important;
- margin:0!important;border-radius:50%;object-fit:cover;}
- .ldp-content aside.ldp-post-quote > .ldp-quote-title a{color:inherit;font-weight:650;text-decoration:none;}
- .ldp-content aside.ldp-post-quote > blockquote{height:auto!important;max-height:none!important;margin:0;padding:0;
- overflow:visible!important;border:0;background:transparent;}
- .ldp-content aside.ldp-post-quote.ldp-quote-expanded > blockquote aside.ldp-post-quote{display:block!important;}
- .ldp-content aside.ldp-post-quote.ldp-quote-expanded{padding-bottom:52px;}
- .ldp-content .ldp-quote-collapse-bottom{position:absolute;right:14px;bottom:12px;display:inline-flex;width:30px;height:30px;
- align-items:center;justify-content:center;margin:0;padding:0;border:0;border-radius:6px;
- background:color-mix(in srgb,var(--primary,#222) 5%,transparent);color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-content .ldp-quote-collapse-bottom:hover{background:color-mix(in srgb,var(--primary,#222) 9%,transparent);color:var(--primary,#222);}
- .ldp-content aside.ldp-post-quote > .ldp-quote-title > .ldp-quote-controls{position:absolute;top:10px;right:12px;
- display:flex;align-items:center;gap:4px;margin:0;}
- .ldp-content .ldp-quote-controls button{display:inline-flex;width:28px;height:28px;align-items:center;justify-content:center;
- margin:0;padding:0;border:0;border-radius:6px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-content .ldp-quote-controls button:hover{background:color-mix(in srgb,var(--primary,#222) 6%,transparent);color:var(--primary,#222);}
- .ldp-content .ldp-quote-controls button:disabled{cursor:wait;opacity:.45;}
- .ldp-content .ldp-quote-controls .ldp-quote-jump{background:color-mix(in srgb,var(--tertiary,#47855f) 13%,transparent);color:var(--tertiary,#47855f);}
- .ldp-content .ldp-quote-collapse-bottom .ldp-icon,
- .ldp-content .ldp-quote-controls .ldp-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2.2;}
- .ldp-content mark.ldp-quote-match{padding:0 .08em;border-radius:3px;background:color-mix(in srgb,#ffd85a 68%,transparent);
- color:inherit;box-decoration-break:clone;-webkit-box-decoration-break:clone;box-shadow:0 0 0 2px color-mix(in srgb,#d99c00 24%,transparent);cursor:pointer;}
- .ldp-content mark.ldp-expanded-quote-match{padding:0 .08em;border-radius:3px;
- background:color-mix(in srgb,#ffd85a 68%,transparent);color:inherit;box-decoration-break:clone;
- -webkit-box-decoration-break:clone;box-shadow:0 0 0 2px color-mix(in srgb,#d99c00 24%,transparent);}
- .ldp-content mark.ldp-quote-match.ldp-quote-match-muted{background:transparent;box-shadow:none;
- text-decoration-line:underline;text-decoration-style:dotted;text-decoration-thickness:1.5px;
- text-decoration-color:color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);text-underline-offset:.18em;}
- .ldp-content mark.ldp-quote-match-end{position:relative;}
- .ldp-content mark.ldp-quote-match-end:focus{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);outline-offset:3px;}
- .ldp-quote-highlight-hint{position:fixed;z-index:2147483646;display:none;
- align-items:center;gap:2px;padding:3px;pointer-events:auto;}
- .ldp-quote-highlight-hint.ldp-quote-hint-visible{display:flex;}
- .ldp-quote-highlight-hint button{margin:0;padding:3px 6px;border:0;border-radius:5px;background:transparent;
- color:inherit;font:inherit;line-height:1.35;white-space:nowrap;cursor:pointer;}
- .ldp-quote-highlight-hint button:hover,
- .ldp-quote-highlight-hint button:focus-visible{
- background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff));
- color:var(--tertiary,#47855f);outline:none;}
- .ldp-quote-highlight-return{border-left:1px solid var(--primary-low,#ddd)!important;border-radius:0 5px 5px 0!important;}
- .ldp-content ul,.ldp-content ol,.ldp-content dd{clear:both;margin:1em 0 1em 1.25em;padding-inline-start:1.25em;}
- .ldp-content li > ul,.ldp-content li > ol{margin:0;}
- .ldp-content code,.ldp-content pre,.ldp-content kbd{font-family:var(--d-font-family--monospace,monospace);}
- .ldp-content table{max-width:100%;border-collapse:collapse;}
- .ldp-content.ldp-content-pending{position:relative;min-height:var(--ldp-content-placeholder-height,72px);
- overflow:hidden;contain:layout paint;pointer-events:none;}
- .ldp-content.ldp-content-pending::before{content:"";display:block;width:min(78%,620px);height:52px;
- margin:7px 0;border-radius:4px;opacity:.52;background:
- linear-gradient(var(--primary-low,#e5e5e5) 0 0) 0 0/72% 8px no-repeat,
- linear-gradient(var(--primary-low,#e5e5e5) 0 0) 0 21px/100% 8px no-repeat,
- linear-gradient(var(--primary-low,#e5e5e5) 0 0) 0 42px/56% 8px no-repeat;}
- :is(.ldp-content,.ldp-solved-excerpt) img:not(.emoji){max-width:100%;height:auto;cursor:zoom-in;border-radius:4px;
- zoom:var(--ldp-image-zoom,1);}
- .ldp-content img.emoji{max-height:1lh;width:auto;object-fit:contain;}
- .ldp-content svg.svg-icon{display:inline-block;width:1em;height:1em;vertical-align:-.125em;fill:currentColor;}
- .ldp-content .hashtag-cooked{display:inline-flex;align-items:center;max-width:100%;padding:.06em .45em .08em;
- border-radius:999px;background:color-mix(in srgb,var(--primary,#222) 9%,transparent);color:var(--primary,#222)!important;
- line-height:1.35;vertical-align:-.12em;text-decoration:none!important;}
- .ldp-content .hashtag-cooked > a{display:inline-flex;align-items:center;max-width:100%;color:inherit!important;text-decoration:none!important;}
- .ldp-content .hashtag-cooked:hover{background:color-mix(in srgb,var(--primary,#222) 14%,transparent);}
- .ldp-content .hashtag-cooked:focus-visible{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);outline-offset:2px;}
- .ldp-content .hashtag-cooked .hashtag-icon-placeholder{display:none;}
- .ldp-content .hashtag-cooked img.emoji,.ldp-content .hashtag-cooked svg{width:.93em;height:.93em;
- margin-right:var(--space-1,.25em);vertical-align:-.125em;}
- :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper{display:block;max-width:100%;line-height:0;}
- :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper > a.lightbox{display:inline-block;max-width:100%;line-height:0;}
- :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper img:not(.emoji){display:block;}
- :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper .meta{display:none;}
- .ldp-content .ldp-media-carousel{max-width:100%;margin:1em 0;}
- .ldp-content .ldp-media-carousel-track{display:grid;grid-auto-flow:column;grid-auto-columns:100%;gap:10px;
- max-width:100%;overflow-x:auto;overscroll-behavior-inline:contain;scroll-snap-type:x mandatory;scrollbar-width:thin;}
- .ldp-content .ldp-media-carousel-track > .lightbox-wrapper{display:grid;min-width:0;place-items:center;
- overflow:hidden;border-radius:10px;background:var(--primary-very-low,#f5f5f5);scroll-snap-align:start;scroll-snap-stop:always;}
- .ldp-content .ldp-media-carousel-track > .lightbox-wrapper > a.lightbox{display:grid;width:100%;place-items:center;}
- .ldp-content .ldp-media-carousel-track img:not(.emoji){width:100%;max-height:min(72vh,760px);object-fit:contain;border-radius:10px;}
- .ldp-content .ldp-media-carousel-controls{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:8px;}
- .ldp-content .ldp-media-carousel-controls button{display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;
- padding:0;border:1px solid var(--primary-low,#ddd);border-radius:999px;background:var(--secondary,#fff);color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-content .ldp-media-carousel-controls button:hover:not(:disabled){border-color:var(--tertiary,#47855f);color:var(--tertiary,#47855f);}
- .ldp-content .ldp-media-carousel-controls button:disabled{opacity:.32;cursor:default;}
- .ldp-native-reader-trigger .ldp-icon,.ldp-native-boost-menu .discourse-boosts__emoji-btn .ldp-icon,
- .ldp-collection-delete .ldp-icon,.ldp-reader-history-nav .ldp-icon,
- .ldp-content .ldp-media-carousel-controls .ldp-icon{width:17px;height:17px;}
- .ldp-content .ldp-media-carousel-status{min-width:3.5em;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);text-align:center;font-variant-numeric:tabular-nums;}
- .ldp-content video{display:block;width:min(100%,960px);height:auto;max-height:min(72vh,760px);margin:1em auto;
- border-radius:10px;background:#000;box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 12%,transparent);}
- .ldp-content audio{display:block;width:min(100%,760px);height:42px;margin:.9em 0;}
- .ldp-content iframe{max-width:100%;border:0;}
- .ldp-content iframe.ldp-bilibili-player{display:block;width:min(100%,960px);height:auto;aspect-ratio:16/9;margin:1em auto;
- border-radius:10px;background:#000;box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 12%,transparent);}
- .ldp-content .ldp-image-retry{display:inline-flex;align-items:center;gap:6px;margin:8px 0;padding:7px 11px;
- border:1px solid var(--primary-low,#d9d9d9);border-radius:7px;background:var(--secondary,#fff);
- color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-base,13px);line-height:1.2;cursor:pointer;vertical-align:middle;}
- .ldp-content .ldp-image-retry:hover{border-color:var(--tertiary,#47855f);color:var(--tertiary,#47855f);}
- .ldp-content .ldp-image-retry:disabled{opacity:.55;cursor:wait;}
- .ldp-collection-action .ldp-icon,.ldp-settings-close .ldp-icon,.ldp-user-info-title-refresh .ldp-icon,
- .ldp-about-feature-icon .ldp-icon,.ldp-content .ldp-image-retry .ldp-icon{width:16px;height:16px;}
- .ldp-content .discourse-post-event{display:none;}
- .ldp-code-block{position:relative;max-width:100%;margin:1em 0;}
- .ldp-code-block > pre{margin:0;}
- .ldp-code-block-collapsible:not(.ldp-code-block-expanded) > pre{max-height:10lh;overflow-x:auto;overflow-y:hidden;}
- .ldp-code-block-actions{position:absolute;z-index:2;top:7px;right:7px;display:flex;align-items:center;gap:4px;}
- .ldp-code-block-action{display:inline-flex;width:28px;height:28px;align-items:center;justify-content:center;
- margin:0;padding:0;border:1px solid color-mix(in srgb,var(--primary,#222) 8%,transparent);border-radius:6px;
- background:color-mix(in srgb,var(--tertiary,#47855f) 11%,var(--secondary,#fff));color:var(--primary-high,#444);cursor:pointer;}
- .ldp-code-block-action:hover,.ldp-code-block-action:focus-visible{border-color:color-mix(in srgb,var(--tertiary,#47855f) 35%,transparent);
- background:color-mix(in srgb,var(--tertiary,#47855f) 18%,var(--secondary,#fff));color:var(--tertiary,#47855f);outline:none;}
- .ldp-code-block-action .ldp-icon{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;}
- .ldp-content pre{overflow:auto;background:var(--primary-very-low,#f6f6f6);
- padding:10px;border-radius:6px;font-size:inherit;}
- .ldp-code-preview-layer{position:absolute;z-index:30;inset:0;display:flex;flex-direction:column;min-width:0;min-height:0;
- background:var(--secondary,#fff);color:var(--primary,#222);}
- .ldp-code-preview-head{display:flex;flex:none;align-items:center;justify-content:space-between;gap:12px;min-height:50px;
- padding:8px 12px 8px 16px;border-bottom:1px solid var(--primary-low,#ddd);background:var(--header_background,var(--secondary,#fff));}
- .ldp-code-preview-head strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--ldp-font-md,14px);}
- .ldp-code-preview-actions{display:flex;align-items:center;gap:5px;}
- .ldp-code-preview-body{flex:1;min-width:0;min-height:0;padding:12px;overflow:auto;background:var(--primary-very-low,#f6f6f6);}
- .ldp-code-preview-body:has(.ldp-code-preview-editor){padding:0;overflow:hidden;}
- .ldp-code-preview-body pre,.ldp-code-preview-editor{box-sizing:border-box;margin:0;border:0;background:transparent;
- color:inherit;font-family:var(--ldp-code-preview-font-family,"Cascadia Code","JetBrains Mono",Consolas,"Liberation Mono",monospace);
- font-size:var(--ldp-code-preview-font-size,var(--ldp-font-base,13px));font-weight:var(--ldp-code-preview-font-weight,400);
- font-variant-ligatures:none;line-height:var(--ldp-code-preview-line-height,1.65);tab-size:var(--ldp-code-preview-tab-size,4);white-space:pre;}
- .ldp-code-preview-body pre *{font-family:inherit!important;font-size:inherit!important;line-height:inherit!important;}
- .ldp-code-preview-body pre{min-width:max-content;}
- .ldp-code-preview-editor{display:block;width:100%;height:100%;padding:12px;overflow:auto;overscroll-behavior:contain;
- resize:none;outline:none;touch-action:auto;}
- .ldp-content .ldp-reader-poll{margin:14px 0;padding:14px 16px;border:1px solid var(--primary-low,#ddd);
- border-radius:8px;background:var(--secondary,#fff);color:var(--primary,#222);}
- .ldp-poll-title{margin:0 0 12px;font-size:var(--ldp-font-xl,16px);font-weight:700;line-height:1.45;}
- .ldp-poll-options,.ldp-poll-results{display:grid;gap:8px;margin:0;padding:0;list-style:none;}
- .ldp-poll-option{display:flex;align-items:flex-start;gap:9px;padding:9px 10px;border:1px solid var(--primary-low,#ddd);
- border-radius:7px;cursor:pointer;line-height:1.45;background:var(--secondary,#fff);}
- .ldp-poll-option:hover{border-color:color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#ddd));
- background:var(--primary-very-low,#f7f7f7);}
- .ldp-poll-option:has(input:checked){border-color:var(--tertiary,#47855f);
- background:color-mix(in srgb,var(--tertiary,#47855f) 8%,var(--secondary,#fff));}
- .ldp-poll-option input{flex:none;margin:3px 0 0;accent-color:var(--tertiary,#47855f);}
- .ldp-poll-option-text{min-width:0;overflow-wrap:anywhere;}
- .ldp-poll-option-text p{display:inline;margin:0;}
- .ldp-poll-result{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:5px 12px;align-items:center;}
- .ldp-poll-result-label{min-width:0;overflow-wrap:anywhere;line-height:1.4;}
- .ldp-poll-result-label p{display:inline;margin:0;}
- .ldp-poll-result-value{font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);white-space:nowrap;}
- .ldp-poll-result-track{grid-column:1/-1;height:8px;border-radius:999px;overflow:hidden;
- background:var(--primary-low,#e5e5e5);}
- .ldp-poll-result-bar{display:block;height:100%;min-width:0;border-radius:inherit;background:var(--tertiary,#47855f);}
- .ldp-poll-footer{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:13px;}
- .ldp-poll-meta{margin-right:auto;font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);}
- .ldp-poll-button{min-height:32px;padding:5px 11px;border:1px solid var(--primary-low,#d5d5d5);border-radius:6px;
- background:var(--secondary,#fff);color:inherit;cursor:pointer;font:inherit;font-size:var(--ldp-font-base,13px);}
- .ldp-poll-button:hover:not(:disabled){background:var(--primary-very-low,#f2f2f2);}
- .ldp-poll-button-primary{border-color:var(--tertiary,#47855f);background:var(--tertiary,#47855f);color:#fff;}
- .ldp-poll-button-primary:hover:not(:disabled){filter:brightness(.95);background:var(--tertiary,#47855f);}
- .ldp-poll-button:disabled,.ldp-reader-poll[aria-busy="true"] .ldp-poll-button,
- .ldp-reader-poll[aria-busy="true"] .ldp-poll-option{cursor:wait;opacity:.55;pointer-events:none;}
- .ldp-poll-note{margin:10px 0 0;font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);}
- .ldp-floor-preview-post .ldp-reader-poll input,.ldp-floor-preview-post .ldp-reader-poll button{pointer-events:none;}
- .ldp-topic-vote{display:inline-flex;align-items:center;gap:6px;margin-left:6px;padding:3px 9px;
- border:1px solid var(--primary-low,#ddd);border-radius:999px;background:var(--secondary,#fff);color:inherit;
- cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;white-space:nowrap;}
- .ldp-topic-vote.on{border-color:var(--tertiary,#47855f);background:color-mix(in srgb,var(--tertiary,#47855f) 11%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
- .ldp-topic-vote:disabled{cursor:wait;opacity:.55;}
- .ldp-special-badges{display:flex;flex-wrap:wrap;gap:5px;margin:0 8px 7px;}
- .ldp-special-badge{padding:2px 7px;border-radius:999px;background:var(--primary-very-low,#f3f3f3);
- color:var(--primary-medium,#666);font-size:var(--ldp-font-sm,11px);font-weight:700;}
- .ldp-post-head > .ldp-hidden-badge{flex:none;white-space:nowrap;}
- .ldp-special-badge.warn{background:#fff1d6;color:#8a5500}.ldp-special-badge.danger{background:#ffe5e5;color:#a12b2b}
- .ldp-special-notice{margin:8px;padding:9px 11px;border-left:var(--ldp-reply-line-emphasis-width,3px) solid var(--ldp-reply-line-color,#47855f);
- border-radius:4px;background:var(--primary-very-low,#f5f5f5);font-size:var(--ldp-font-base,13px);line-height:1.5;}
- .ldp-system-action{display:flex;align-items:center;gap:8px;margin:8px;padding:9px 11px;
- border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 28%,var(--primary-low,#ddd));border-radius:6px;
- background:color-mix(in srgb,var(--tertiary,#47855f) 7%,var(--secondary,#fff));color:var(--primary-high,#444);
- font-size:var(--ldp-font-base,13px);font-weight:650;line-height:1.5;}
- .ldp-system-action .ldp-icon{width:17px;height:17px;flex:none;color:var(--tertiary,#47855f);stroke-width:2;}
- .ldp-system-action-user{color:var(--tertiary,#47855f);font-weight:750;text-decoration:none;}
- .ldp-system-action-user:hover,.ldp-system-action-user:focus-visible{text-decoration:underline;}
- .ldp-post.ldp-system-action-compact{display:flex;align-items:center;gap:8px;min-width:0;padding-block:6px;
- overflow-x:auto;overflow-y:hidden;white-space:nowrap;}
- .ldp-post.ldp-system-action-compact > .ldp-post-head{display:contents;}
- .ldp-post.ldp-system-action-compact :is(.ldp-author,.ldp-user){max-width:none;flex:none;overflow:visible;
- text-overflow:clip;white-space:nowrap;}
- .ldp-post.ldp-system-action-compact > .ldp-post-head > :is(.ldp-user,.ldp-new-user-badge,.ldp-time,.ldp-floor),
- .ldp-post.ldp-system-action-compact > .ldp-reactions{color:var(--primary-medium,#777);opacity:.62;}
- .ldp-post.ldp-system-action-compact > .ldp-post-head > .ldp-new-user-badge{
- border-color:color-mix(in srgb,var(--primary,#222) 14%,transparent);
- background:color-mix(in srgb,var(--primary,#222) 4%,var(--secondary,#fff));font-weight:600;}
- .ldp-post.ldp-system-action-compact > .ldp-reactions:hover,
- .ldp-post.ldp-system-action-compact > .ldp-reactions:focus-within{opacity:.86;}
- .ldp-post.ldp-system-action-compact > .ldp-system-action{order:1;flex:none;margin:0;padding:0;
- border:0;border-radius:0;background:transparent;color:var(--primary-medium,#777);
- font-size:var(--ldp-font-ui,12px);font-weight:500;white-space:nowrap;}
- .ldp-post.ldp-system-action-compact > .ldp-system-action .ldp-icon{color:inherit;opacity:.78;}
- .ldp-post.ldp-system-action-compact > .ldp-system-action .ldp-system-action-user{color:inherit;font-weight:600;}
- .ldp-post.ldp-system-action-compact > .ldp-post-head > .ldp-floor{order:2;flex:none;margin-left:auto;}
- .ldp-post.ldp-system-action-compact > .ldp-post-head > .ldp-post-read-state{order:2;}
- .ldp-post.ldp-system-action-compact > .ldp-content{display:none;}
- .ldp-solved-card{margin:14px 8px 4px;border:1px solid color-mix(in srgb,var(--success,#3f9625) 42%,var(--primary-low,#ddd));
- border-radius:8px;overflow:hidden;background:var(--secondary,#fff);}
- .ldp-solved-head{display:flex;align-items:center;gap:7px;padding:8px 12px;background:var(--success,#3f9625);
- color:#fff;font-size:var(--ldp-font-md,14px);font-weight:750;line-height:1.3;}
- .ldp-solved-head .ldp-icon{width:16px;height:16px;flex:none;}
- .ldp-solved-body{padding:10px 12px 11px;}
- .ldp-solved-body + .ldp-solved-body{border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-solved-author-row{display:flex;align-items:center;flex-wrap:wrap;gap:7px;min-width:0;margin-bottom:7px;font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#666);}
- .ldp-solved-avatar{width:26px;height:26px;border-radius:50%;flex:none;}
- .ldp-solved-author{min-width:0;font-weight:700;color:var(--primary,#222);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-solved-username{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-solved-floor{margin-left:auto;padding:0;border:0;background:transparent;flex:none;color:var(--success,#3f9625);
- cursor:pointer;font:inherit;font-weight:var(--ldp-reader-font-weight-base,400);}
- .ldp-solved-floor:hover{text-decoration:underline;}
- .ldp-solved-excerpt{max-width:75ch;font-size:var(--ldp-font-base,13px);line-height:1.55;overflow-wrap:anywhere;}
- .ldp-solved-excerpt.ldp-content{padding-inline:0;overflow-x:visible;}
- .ldp-solved-excerpt > :first-child{margin-top:0}.ldp-solved-excerpt > :last-child{margin-bottom:0}
- .ldp-solved-jump{margin-top:8px;padding:0;border:0;background:transparent;color:var(--success,#3f9625);
- cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;}
- .ldp-solved-jump:hover{text-decoration:underline;}
- .ldp-post.ldp-whisper{background:color-mix(in srgb,#7b61a8 7%,var(--secondary,#fff));}
- .ldp-post.ldp-post-voting-answer{padding-left:12px;}
- .ldp-post.ldp-system-post:not(.ldp-has-reactions) > .ldp-reactions,
- .ldp-post.ldp-system-post > .ldp-reactions .ldp-actions,.ldp-post-voting-answer > .ldp-reactions .ldp-like,
- .ldp-post-voting-answer > .ldp-reactions .ldp-replybtn{display:none;}
- .ldp-pv-layout{display:grid;grid-template-columns:46px minmax(0,1fr);gap:8px;align-items:start;}
- .ldp-pv-votes{display:flex;flex-direction:column;align-items:center;gap:3px;padding-top:4px;}
- .ldp-pv-vote{width:32px;height:28px;border:0;border-radius:6px;background:transparent;color:var(--primary-medium,#777);
- cursor:pointer;line-height:1}.ldp-pv-vote .ldp-icon{width:20px;height:20px;}
- .ldp-pv-vote:hover,.ldp-pv-vote.on{background:var(--primary-very-low,#eee);color:var(--tertiary,#47855f)}
- .ldp-pv-vote:disabled{cursor:wait;opacity:.5}.ldp-pv-score{font-weight:750;font-variant-numeric:tabular-nums;}
- .ldp-pv-main{min-width:0}.ldp-pv-comments{margin:10px 8px 2px 54px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-pv-comment{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:7px;padding:9px 0;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-base,13px);}
- .ldp-pv-comment-avatar{width:26px;height:26px;border-radius:50%}.ldp-pv-comment-body{min-width:0}.ldp-pv-comment-meta{font-size:var(--ldp-font-sm,11px);opacity:.62;margin-bottom:2px}
- .ldp-pv-comment-vote,.ldp-pv-comments-toggle,.ldp-pv-comments-more,.ldp-pv-comment-submit{border:0;background:transparent;color:var(--tertiary,#47855f);cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;}
- .ldp-pv-comment-vote,.ldp-pv-comments-toggle{display:inline-flex;align-items:center;gap:4px;}
- .ldp-pv-comment-vote .ldp-icon,.ldp-pv-comments-toggle .ldp-icon{width:15px;height:15px;}
- .ldp-pv-comment-vote.on{color:#c65b24}.ldp-pv-comment-form{display:flex;gap:7px;padding:9px 0}.ldp-pv-comment-input{flex:1;min-width:0;padding:7px 9px;border:1px solid var(--primary-low,#ccc);border-radius:6px;background:var(--secondary,#fff);color:inherit;font:inherit;}
- .ldp-post.ldp-has-reactions > .ldp-reactions .ldp-like{display:none}.ldp-reactions{display:flex;align-items:center;flex-wrap:wrap;gap:5px;width:fit-content;max-width:calc(100% - 16px);min-height:30px;height:auto;margin:12px 8px 0;overflow:visible;box-sizing:border-box;}
- .ldp-comments .ldp-post > .ldp-reactions{margin-top:0;}
- .ldp-reaction-summary{display:flex;flex:none;align-items:center;flex-wrap:wrap;gap:6px;width:fit-content;max-width:100%;min-height:30px;height:auto;padding:4px 9px;border-radius:999px;background:color-mix(in srgb,var(--primary,#222) 2%,transparent);box-sizing:border-box;}
- .ldp-reaction-picker-anchor{position:relative;display:inline-flex;flex:none;align-items:center;}
- .ldp-reaction-summary .ldp-like{display:inline-flex;min-width:20px;height:20px;align-items:center;justify-content:center;gap:3px;padding:1px 2px;border:0;background:transparent;}
- .ldp-reaction-summary .ldp-like .ldp-icon{width:16px;height:16px;}
- .ldp-reaction-chip,.ldp-reaction-add{display:inline-flex;align-items:center;gap:3px;min-height:20px;padding:1px 2px;border:0;background:transparent;color:inherit;cursor:pointer;font:inherit;font-size:var(--ldp-font-sm,11px);}
- .ldp-reaction-chip:hover,.ldp-reaction-add:hover{opacity:.72}.ldp-reaction-chip.on{color:var(--tertiary,#47855f);font-weight:700;}
- .ldp-reaction-chip > span{display:inline-flex;align-items:center;font-size:14px;line-height:1;}
- .ldp-reaction-chip img.emoji,.ldp-reaction-chip img.emoji.only-emoji{width:14px;height:14px;margin:0;object-fit:contain;}
- .ldp-reaction-picker{position:absolute;left:0;bottom:calc(100% + 5px);z-index:3;display:flex;align-items:center;justify-content:flex-start;flex-wrap:wrap;gap:6px;width:max-content;max-width:calc(100vw - 24px);min-height:30px;height:auto;padding:4px 9px;border:0;border-radius:999px;background:color-mix(in srgb,var(--secondary,#fff) 90%,transparent);white-space:normal;box-sizing:border-box}
- .ldp-event-card{margin:12px 8px;padding:14px;border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff);box-shadow:0 2px 10px rgba(0,0,0,.04)}
- .ldp-event-title{margin:0 0 9px;font-size:var(--ldp-font-2xl,17px)}.ldp-event-grid{display:grid;gap:6px;font-size:var(--ldp-font-base,13px)}.ldp-event-meta{color:var(--primary-medium,#666)}
- .ldp-event-actions{display:flex;flex-wrap:wrap;gap:7px;margin-top:12px}.ldp-event-action,.ldp-event-ics{padding:6px 10px;border:1px solid var(--primary-low,#d5d5d5);border-radius:6px;background:var(--secondary,#fff);color:inherit;text-decoration:none;cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px)}
- .ldp-event-action.on{border-color:var(--tertiary,#47855f);background:var(--tertiary,#47855f);color:#fff}.ldp-event-action:disabled{cursor:wait;opacity:.55}
- .ldp-children{margin-left:22px;padding-left:18px;position:relative;
- --ldp-nested-line-color:var(--ldp-reply-line-color,#47855f);--ldp-nested-line-width:var(--ldp-reply-line-width,1px);
- --ldp-nested-turn-rise:15px;--ldp-nested-corner-radius:var(--ldp-reply-line-radius,15px);
- --ldp-nested-branch-y:17px;--ldp-nested-parent-drop:34px;
- --ldp-nested-axis-left:3px;--ldp-nested-branch-width:15px;
- --ldp-nested-parent-head-width:15px;}
- .ldp-children-collapsed > .ldp-children{display:none;}
- .ldp-post.ldp-has-child-branches{overflow:visible;content-visibility:visible;}
- .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready > .ldp-children::before{content:"";position:absolute;left:var(--ldp-nested-axis-left);
- top:calc(0px - var(--ldp-nested-parent-drop) + var(--ldp-nested-turn-rise));
- height:calc(var(--ldp-nested-parent-drop) - var(--ldp-nested-turn-rise));border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);
- pointer-events:none;}
- .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready > .ldp-children::after{content:"";position:absolute;left:var(--ldp-nested-axis-left);
- top:calc(0px - var(--ldp-nested-parent-drop));width:calc(var(--ldp-nested-parent-head-width) - var(--ldp-nested-line-width));
- height:var(--ldp-nested-turn-rise);border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);
- border-top:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);border-top-left-radius:var(--ldp-nested-corner-radius);
- -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%);
- mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%);
- pointer-events:none;}
- .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children::before,
- .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children::after,
- .ldp-post.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children > .ldp-post::before,
- .ldp-post.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children > .ldp-post::after{display:none;}
- .ldp-children > .ldp-post{position:relative;overflow:visible;content-visibility:visible;}
- .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post::before{content:"";position:absolute;left:calc(var(--ldp-nested-axis-left) - 18px);
- top:0;bottom:0;border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);
- pointer-events:none;}
- .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post::after{content:"";position:absolute;left:calc(var(--ldp-nested-axis-left) - 18px);
- top:calc(var(--ldp-nested-branch-y) - var(--ldp-nested-turn-rise));
- width:calc(var(--ldp-nested-branch-width) - var(--ldp-nested-line-width));height:var(--ldp-nested-turn-rise);
- border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);
- border-bottom:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);border-bottom-left-radius:var(--ldp-nested-corner-radius);
- -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%);
- mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%);
- pointer-events:none;}
- .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post.ldp-nested-branch-last::before,
- .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post.ldp-nested-branch-single::before{bottom:auto;height:calc(var(--ldp-nested-branch-y) - var(--ldp-nested-turn-rise));}
- .ldp-nested-rail-toggle{display:none;position:absolute;z-index:4;width:20px;min-height:24px;padding:0;border:0;
- border-radius:999px;background:transparent;color:inherit;cursor:pointer;touch-action:manipulation;}
- .ldp-post.ldp-nested-branches-ready:not(.ldp-parent-preview-linked) > .ldp-children > .ldp-nested-rail-toggle{display:block;}
- .ldp-nested-rail-toggle::before{content:"";position:absolute;inset:0 6px;border-radius:999px;background:transparent;
- transition:background-color .15s ease;}
- .ldp-nested-rail-toggle:focus-visible::before{
- background:color-mix(in srgb,var(--ldp-reply-line-color,#47855f) 18%,transparent);}
- .ldp-nested-rail-toggle:focus-visible{outline:none;}
- .ldp-nested-branch-toggle{display:none;position:absolute;z-index:4;overflow:visible;pointer-events:none;}
- .ldp-post.ldp-nested-branches-ready:not(.ldp-parent-preview-linked) > .ldp-children > .ldp-nested-branch-toggle{display:block;}
- .ldp-nested-branch-hit-path{fill:none;stroke:transparent;stroke-width:20px;stroke-linecap:round;
- stroke-linejoin:round;pointer-events:stroke;cursor:pointer;}
- .ldp-children > .ldp-nested-preview{padding:5px 0 7px;line-height:1.5;border-bottom:none;
- contain-intrinsic-size:auto 96px;}
- .ldp-children > .ldp-nested-preview > .ldp-content{font-size:calc(var(--ldp-post-font-size,14px) * var(--ldp-nested-content-scale));}
- .ldp-children > .ldp-nested-preview > :not(.ldp-content){zoom:var(--ldp-nested-component-scale);}
- .ldp-children > .ldp-nested-preview > .ldp-post-head{margin-bottom:0;flex-wrap:wrap;}
- .ldp-children > .ldp-nested-preview .ldp-user,
- .ldp-children > .ldp-nested-preview .ldp-time{opacity:.5;}
- .ldp-floor.ldp-jump-self,.ldp-floor.ldp-jump-parent,.ldp-floor.ldp-jump-preview-self{border:none;cursor:pointer;margin-left:auto;
- padding:1px 5px;border-radius:5px;white-space:nowrap;line-height:1.35;}
- .ldp-floor.ldp-jump-parent{font-size:var(--ldp-font-sm,11px);font-weight:var(--ldp-reader-font-weight-base,400);color:var(--ldp-floor-preview-color,#9a5b13);background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 8%,transparent);}
- .ldp-floor.ldp-jump-parent[aria-busy="true"]{position:relative;color:transparent;cursor:wait;pointer-events:none;text-decoration:none;}
- .ldp-floor.ldp-jump-parent[aria-busy="true"]::after{content:"";position:absolute;left:50%;top:50%;box-sizing:border-box;
- width:11px;height:11px;margin:-5.5px 0 0 -5.5px;border:2px solid color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 28%,transparent);
- border-top-color:var(--ldp-floor-preview-color,#9a5b13);border-radius:50%;animation:ldp-spin .7s linear infinite;}
- .ldp-floor.ldp-jump-self{font-size:var(--ldp-font-sm,11px);font-weight:var(--ldp-reader-font-weight-base,400);color:#2563b8;background:rgba(37,99,184,.04);}
- .ldp-floor.ldp-jump-preview-self{height:24px;display:inline-flex;align-items:center;line-height:1;font-size:var(--ldp-font-sm,11px);font-weight:var(--ldp-reader-font-weight-base,400);color:var(--ldp-floor-preview-color,#9a5b13);background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 8%,transparent);}
- .ldp-floor.ldp-body-floor{font-size:var(--ldp-font-sm,11px);padding:1px 5px;border-radius:5px;
- background:color-mix(in srgb,var(--primary,#222) 3%,transparent);}
- .ldp-floor.ldp-current-floor{margin-left:4px;color:var(--primary-medium,#777);
- font-weight:var(--ldp-reader-font-weight-base,400);opacity:.72;}
-.ldp-descendant-replies-window .ldp-post-head:has(> .ldp-jump-parent){flex-wrap:wrap;row-gap:0;}
- .ldp-descendant-replies-window .ldp-post-head:has(> .ldp-jump-parent)::after{content:"";flex:0 0 100%;height:0;order:1;}
- .ldp-descendant-replies-window .ldp-floor.ldp-jump-parent{--ldp-callout-color:#2f78ef;box-sizing:border-box;display:inline-flex;
- align-items:center;gap:.35em;margin:8px 0 0;padding:.28em .55em;order:2;border:0;border-radius:5px;
- font:inherit;font-size:.86em;font-weight:600;color:var(--ldp-callout-color);
- background:color-mix(in srgb,var(--ldp-callout-color) 9%,var(--secondary,#fff));}
- .ldp-descendant-replies-window .ldp-floor.ldp-jump-parent > .ldp-icon{width:.9em;height:.9em;flex:none;
- stroke:currentColor;fill:none;stroke-width:2;}
- .ldp-descendant-replies-window .ldp-floor.ldp-jump-parent:hover{color:var(--ldp-callout-color);
- background:color-mix(in srgb,var(--ldp-callout-color) 14%,var(--secondary,#fff));text-decoration:none;}
- .ldp-descendant-replies-window .ldp-floor.ldp-body-floor{margin-left:auto;font:inherit;font-size:.8em;font-weight:600;color:#000;opacity:1;}
- .ldp-descendant-replies-window .ldp-time[data-exact-time]::after{display:none;}
- .ldp-descendant-replies-window .ldp-reply-exact-time{display:none;flex:none;white-space:nowrap;
- color:inherit;font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);opacity:.55;}
- .ldp-descendant-replies-window .ldp-time[data-exact-time]:hover ~ .ldp-reply-exact-time{display:inline;}
- .ldp-children > .ldp-nested-preview .ldp-floor.ldp-jump-self{
- margin-left:0;padding-inline:2px;color:#2f7a68;background:transparent;opacity:.72;}
- .ldp-children > .ldp-nested-preview .ldp-post-read-state{margin-left:auto;}
- .ldp-children > .ldp-nested-preview .ldp-time[data-exact-time]::after{
- position:static;margin-left:6px;transform:none;}
- .ldp-children > .ldp-nested-preview .ldp-time[data-exact-time]:hover::after{display:inline;}
- .ldp-floor.ldp-jump-self:hover,.ldp-floor.ldp-jump-parent:hover,
- .ldp-floor.ldp-jump-preview-self:hover{filter:saturate(1.25);text-decoration:underline;}
- .ldp-floor-preview{position:relative;z-index:1;box-sizing:border-box;width:auto;max-width:none;
- max-height:min(var(--ldp-floor-preview-height,300px),calc(100vh - 24px));margin:8px 0 10px;
- border:1px solid var(--ldp-floor-preview-color,#9a5b13);
- border-radius:8px;background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 12%,var(--secondary,#fff));
- color:var(--primary,#222);box-shadow:0 3px 8px rgba(0,0,0,.1);padding:0;overflow:hidden;
- display:none;flex-direction:column;}
- .ldp-floor-preview.open{display:flex;}
- .ldp-floor-preview.open:not(:has(.ldp-floor-preview-post,.ldp-floor-preview-tip)),
- .ldp-floor-preview.open:not(:has(.ldp-floor-preview-post,.ldp-floor-preview-tip)) + .ldp-floor-preview-link{display:none;}
- .ldp-floor-preview-close{position:absolute;right:8px;top:9px;z-index:3;border:none;background:transparent;color:inherit;cursor:pointer;
- width:auto;height:28px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:0 5px;}
- .ldp-floor-preview-close-hint{display:inline-flex;align-items:center;height:24px;line-height:1;font-size:var(--ldp-font-sm,11px);font-weight:500;color:var(--primary-medium,#777);white-space:nowrap;}
- .ldp-floor-preview-close:hover{background:transparent;color:var(--primary,#222);}
- .ldp-floor-preview-body{box-sizing:border-box;padding:10px 12px 12px;min-height:0;
- max-height:calc(var(--ldp-floor-preview-height,300px) - 52px);overflow-y:auto;}
- .ldp-floor-preview-post{font-size:var(--ldp-font-base,13px);line-height:1.55;max-width:100%;}
- .ldp-floor-preview-post .ldp-content{overflow-wrap:anywhere;}
- .ldp-floor-preview > .ldp-post-head{position:relative;z-index:2;isolation:isolate;flex:none;
- min-height:40px;box-sizing:border-box;margin:0;padding:5px 92px 5px 10px;align-items:center;
- gap:var(--ldp-nested-component-gap);
- background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 12%,var(--secondary,#fff));
- box-shadow:inset 0 calc(0px - var(--ldp-divider-line-width,1px)) 0 var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-floor-preview > .ldp-post-head > :not(.ldp-floor-preview-close){zoom:var(--ldp-nested-component-scale);}
- .ldp-floor-preview > .ldp-post-head > .ldp-user + .ldp-op{margin-left:4px;}
- .ldp-floor-preview > .ldp-post-head > .ldp-floor-preview-close{top:50%;transform:translateY(-50%);}
- .ldp-floor-preview-link{display:none;position:relative;height:0;margin:0;overflow:visible;
- pointer-events:none;z-index:2;
- --ldp-parent-link-color:var(--ldp-reply-line-color,#47855f);--ldp-parent-link-width:var(--ldp-reply-line-width,1px);
- --ldp-parent-turn-rise:15px;--ldp-parent-corner-radius:var(--ldp-reply-line-radius,15px);
- --ldp-parent-axis-left:-15px;--ldp-parent-preview-rail-width:15px;
- --ldp-parent-child-y:60px;--ldp-parent-child-branch-width:15px;
- --ldp-parent-axis-top:12px;--ldp-parent-preview-y:12px;}
- .ldp-floor-preview.open + .ldp-floor-preview-link,
- .ldp-floor-preview-link.open{display:block;}
- .ldp-floor-preview-link::before{content:"";position:absolute;left:var(--ldp-parent-axis-left);
- top:calc(var(--ldp-parent-axis-top) + var(--ldp-parent-turn-rise));
- width:calc(var(--ldp-parent-child-branch-width) - var(--ldp-parent-link-width));
- height:calc(var(--ldp-parent-child-y) - var(--ldp-parent-axis-top) - var(--ldp-parent-turn-rise));
- border-left:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color);border-bottom:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color);
- border-bottom-left-radius:var(--ldp-parent-corner-radius);
- -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%);
- mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%);}
- .ldp-floor-preview-link::after{content:"";position:absolute;left:var(--ldp-parent-axis-left);
- top:var(--ldp-parent-preview-y);width:calc(var(--ldp-parent-preview-rail-width) - var(--ldp-parent-link-width));
- height:var(--ldp-parent-turn-rise);border-left:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color);
- border-top:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color);border-top-left-radius:var(--ldp-parent-corner-radius);
- -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%);
- mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%);}
- .ldp-floor-preview-link.ldp-floor-preview-link-grouped::before,
- .ldp-floor-preview-link.ldp-floor-preview-link-grouped::after{display:none;}
- .ldp-post.ldp-parent-preview-linked{position:relative;}
- .ldp-thread-group-svg{position:absolute;z-index:1;overflow:visible;pointer-events:none;
- color:var(--ldp-parent-link-color);
- --ldp-parent-link-color:var(--ldp-reply-line-color,#47855f);
- --ldp-parent-link-width:var(--ldp-reply-line-width,1px);}
- .ldp-thread-group-path{fill:none;stroke:var(--ldp-parent-link-color);stroke-width:var(--ldp-parent-link-width);
- stroke-linecap:butt;stroke-linejoin:round;vector-effect:non-scaling-stroke;}
- .ldp-floor-preview-post .ldp-actions,
- .ldp-floor-preview-post .ldp-collapse-replies,
- .ldp-floor-preview-post .ldp-children,
- .ldp-floor-preview-post .ldp-nested-toggle{display:none!important;}
- .ldp-floor-preview-tip{height:100%;display:flex;align-items:center;justify-content:center;
- color:var(--primary-medium,#777);font-size:var(--ldp-font-base,13px);}
- .ldp-actions{display:inline-flex;gap:2px;margin:0 0 0 2px;font-size:var(--ldp-font-ui,12px);align-items:center;
- flex:none;opacity:1;pointer-events:auto;}
- .ldp-context-actions-slot{display:inline-flex;align-items:center;gap:2px;flex:none;height:20px;
- width:0;min-width:0;overflow:hidden;}
- .ldp-reactions:hover .ldp-context-actions-slot,
- .ldp-reactions:focus-within .ldp-context-actions-slot,
- .ldp-post.ldp-actions-active > .ldp-reactions .ldp-context-actions-slot{
- width:calc(var(--ldp-context-action-count,1) * 22px - 2px);overflow:visible;}
- .ldp-context-actions-slot:empty{pointer-events:none;}
- .ldp-actions .ldp-btn,.ldp-reaction-add{min-width:20px;height:20px;justify-content:center;padding:2px;gap:2px;}
- .ldp-actions .ldp-btn:not(.ldp-like),.ldp-reaction-add{width:20px;}
- .ldp-actions .ldp-replybtn span{display:none;}
- .ldp-topic-bookmark.on,.ldp-post-bookmark.on{color:var(--tertiary,#47855f);opacity:1;}
- .ldp-topic-bookmark.on .ldp-icon-bookmark,
- .ldp-post-bookmark.on .ldp-icon-bookmark{fill:currentColor;}
- .ldp-like-count{display:inline;font-size:var(--ldp-font-sm,11px);line-height:1;}
- .ldp-btn{cursor:pointer;border:none;background:transparent;color:inherit;
- opacity:.7;display:inline-flex;align-items:center;gap:4px;padding:2px 4px;}
- .ldp-btn:hover{opacity:1;}
- .ldp-btn:disabled{cursor:default;opacity:.4;}
- .ldp-like.liked{color:var(--love,#e25822);opacity:1;font-weight:600;}
- .ldp-like.liked .ldp-icon-heart{fill:currentColor;}
- .ldp-actions .ldp-context-action{opacity:0;visibility:hidden;pointer-events:none;
- transition:opacity .12s ease,visibility 0s linear .12s;}
- .ldp-reactions:hover .ldp-actions .ldp-context-action,
- .ldp-reactions:focus-within .ldp-actions .ldp-context-action,
- .ldp-post.ldp-actions-active > .ldp-reactions .ldp-actions .ldp-context-action{
- opacity:.7;visibility:visible;pointer-events:auto;transition-delay:0s;}
- .ldp-actions .ldp-context-action:hover{opacity:1;}
- @media (hover:none){
- .ldp-context-actions-slot{width:calc(var(--ldp-context-action-count,1) * 22px - 2px);overflow:visible;}
- .ldp-actions .ldp-context-action{opacity:.7;visibility:visible;pointer-events:auto;transition:none;}
- }
- .ldp-topic-footer-actions{display:flex;align-items:center;flex-wrap:nowrap;width:max-content;max-width:100%;
- gap:2px;margin:10px 0 2px;
- padding:8px 6px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-ui,12px);}
- .ldp-topic-footer-button,.ldp-topic-footer-link,.ldp-topic-notification{box-sizing:border-box;width:34px;height:34px;
- min-height:0;margin:0;display:inline-flex;align-items:center;justify-content:center;padding:6px;
- border:0;border-radius:6px;background:transparent;color:inherit;
- font:inherit;line-height:1;vertical-align:top;text-decoration:none;white-space:nowrap;}
- .ldp-topic-footer-actions > * > .ldp-icon{display:block;}
- .ldp-topic-footer-button > span,.ldp-topic-footer-link > span{display:none;}
- .ldp-topic-footer-button,.ldp-topic-footer-link{cursor:pointer;}
- .ldp-topic-footer-button:hover,.ldp-topic-footer-link:hover,.ldp-topic-notification:hover{
- background:var(--primary-very-low,#f4f4f4);color:inherit;}
- .ldp-topic-footer-button:disabled{cursor:default;opacity:.45;}
- .ldp-topic-footer-button.ldp-topic-reply{background:transparent;color:inherit;}
- .ldp-topic-footer-button.ldp-topic-reply:hover{background:var(--primary-very-low,#f4f4f4);}
- .ldp-topic-footer-button.ldp-topic-bookmark.on{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));}
- .ldp-topic-footer-button.ldp-topic-shared-issue{width:auto;min-width:34px;gap:4px;padding-inline:8px;}
- .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-label{
- display:block;font-weight:500;letter-spacing:0;}
- .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-count{
- display:block;margin-left:-1px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);
- font-variant-numeric:tabular-nums;}
- .ldp-topic-footer-button.ldp-topic-shared-issue.on{opacity:1;}
- .ldp-topic-footer-button.ldp-topic-shared-issue.on > .ldp-icon,
- .ldp-topic-footer-button.ldp-topic-shared-issue.on > .ldp-topic-shared-issue-label{
- color:var(--tertiary,#47855f);}
- .ldp-topic-footer-separator{flex:none;width:1px;height:14px;padding:0 3px;
- background:linear-gradient(var(--ldp-divider-line-color,#e5e5e5),var(--ldp-divider-line-color,#e5e5e5)) center/var(--ldp-divider-line-width,1px) 14px no-repeat;
- transform:translateY(1px);}
- .ldp-topic-notification{position:relative;padding:6px;cursor:pointer;overflow:hidden;}
- .ldp-topic-notification select{position:absolute;inset:0;width:100%;height:100%;min-width:0;
- border:0;opacity:0;cursor:pointer;outline:none;}
- @supports (appearance:base-select){
- .ldp-topic-notification .ldp-topic-notification-select{--ldp-select-picker-width:132px;
- --ldp-select-picker-max-width:min(132px,calc(100vw - 24px));background:transparent;color:transparent;
- box-shadow:none;opacity:1;}
- .ldp-topic-notification-select::picker-icon{display:none;}
- .ldp-topic-notification-select::picker(select){color:var(--primary,#222);}
- }
- .ldp-topic-notification.busy{opacity:.55;}
- .ldp-floor-preview .ldp-topic-footer-actions{display:none!important;}
- .ldp-boost-list{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:8px;}
- .ldp-boost-list.ldp-boost-pending{min-height:26px;contain:layout paint;pointer-events:none;}
- .ldp-boost-list.ldp-boost-pending::before{content:"";width:64px;height:22px;border-radius:999px;
- background:var(--primary-very-low,#f2f4f6);}
- .ldp-boost-bubble{position:relative;display:inline-flex;align-items:center;gap:5px;max-width:100%;
- padding:3px 8px 3px 3px;border-radius:999px;background:var(--primary-very-low,#f2f4f6);
- color:var(--primary,#222);font-size:var(--ldp-font-ui,12px);line-height:1.35;}
- .ldp-floor-preview .ldp-boost-bubble{opacity:.82;}
- .ldp-boost-avatar-link{display:inline-flex;line-height:0;flex:none;}
- .ldp-boost-avatar{width:20px;height:20px;border-radius:50%;}
- .ldp-boost-fallback-icon{width:14px;height:14px;margin-left:3px;opacity:.65;}
- .ldp-boost-identities{display:inline-flex;align-items:center;gap:3px;flex:none;}
- .ldp-boost-identity{display:inline-flex;align-items:center;gap:2px;min-height:16px;box-sizing:border-box;
- padding:1px 5px 1px 3px;border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 34%,transparent);
- border-radius:999px;background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff));
- color:var(--tertiary,#47855f);font-size:var(--ldp-font-xs,10px);font-weight:700;line-height:1;white-space:nowrap;}
- .ldp-boost-identity .ldp-icon{width:10px;height:10px;stroke-width:2.35;}
- .ldp-boost-identity-me{border-color:color-mix(in srgb,var(--success,#3ea66b) 40%,transparent);
- background:color-mix(in srgb,var(--success,#3ea66b) 12%,var(--secondary,#fff));color:var(--success,#3ea66b);}
- .ldp-boost-identity-admin,.ldp-boost-identity-moderator{
- border-color:color-mix(in srgb,var(--primary,#222) 28%,transparent);
- background:color-mix(in srgb,var(--primary,#222) 7%,var(--secondary,#fff));color:var(--primary-high,#333);}
- .ldp-boost-cooked{display:inline-flex;align-items:center;min-width:0;overflow:hidden;}
- .ldp-boost-cooked p{margin:0;display:inline-flex;align-items:center;gap:4px;min-width:0;}
- .ldp-boost-cooked img.emoji,.ldp-boost-cooked img.emoji.only-emoji{width:16px;height:16px;margin:0;}
- .ldp-boost-item-action{display:none;align-items:center;justify-content:center;flex:none;
- width:18px;height:18px;margin-left:1px;padding:2px;border:0;border-radius:50%;cursor:pointer;
- background:transparent;color:var(--primary-medium,#777);opacity:0;visibility:hidden;pointer-events:none;
- transition:opacity .16s ease,color .16s ease,background-color .16s ease;}
- .ldp-boost-delete-action{color:var(--danger,#c94747);}
- .ldp-boost-delete-action .ldp-icon,.ldp-boost-report-action .ldp-icon{
- width:15px;height:15px;stroke-width:2.25;}
- @keyframes ldp-boost-item-action-hover-ready{
- to{width:18px;height:18px;margin-left:1px;padding:2px;visibility:visible;pointer-events:auto;}}
- .ldp-boost-bubble:hover > .ldp-boost-item-action{
- display:inline-flex;width:0;height:0;margin-left:-5px;padding:0;overflow:hidden;opacity:.72;
- animation:ldp-boost-item-action-hover-ready 1ms linear .5s forwards;}
- .ldp-boost-bubble:focus-within > .ldp-boost-item-action{
- display:inline-flex;width:18px;height:18px;margin-left:1px;padding:2px;overflow:visible;
- opacity:.72;visibility:visible;pointer-events:auto;animation:none;}
- .ldp-boost-item-action:hover,.ldp-boost-item-action:focus-visible{
- opacity:1;background:var(--primary-low,#d7dde3);color:var(--primary,#222);outline:none;}
- .ldp-boost-delete-action:hover,.ldp-boost-delete-action:focus-visible{
- background:color-mix(in srgb,var(--danger,#c94747) 14%,transparent);color:var(--danger,#c94747);}
- .ldp-boost-item-action:disabled{cursor:default;opacity:.35;}
- .ldp-boost-copy-action{position:absolute;left:50%;bottom:calc(100% - 2px);z-index:2;
- margin:0;padding:6px 9px;border:0;cursor:pointer;font-family:inherit;
- transform:translateX(-50%);opacity:0;visibility:hidden;pointer-events:none;}
- .ldp-boost-copy-action.ldp-action-surface{
- border-color:color-mix(in srgb,var(--tertiary,#47855f) 38%,var(--primary-low,#ddd));
- color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1.25;}
- .ldp-boost-bubble:hover > .ldp-boost-copy-action,
- .ldp-boost-bubble:focus-within > .ldp-boost-copy-action{
- opacity:1;visibility:visible;pointer-events:auto;}
- .ldp-boost-copy-action:hover,.ldp-boost-copy-action:focus-visible{
- border-color:var(--tertiary,#47855f);
- background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff));
- color:var(--tertiary,#47855f);outline:none;}
- .ldp-boost-bubble:has(> .ldp-boost-item-action:hover) > .ldp-boost-copy-action,
- .ldp-boost-bubble:has(> .ldp-boost-item-action:focus-visible) > .ldp-boost-copy-action{
- opacity:0;visibility:hidden;pointer-events:none;}
- @media (hover:none){
- .ldp-boost-item-action,.ldp-boost-bubble:hover > .ldp-boost-item-action{
- display:inline-flex;width:18px;height:18px;margin-left:1px;padding:2px;overflow:visible;
- opacity:.72;visibility:visible;pointer-events:auto;animation:none;transition:none;}
- }
- html.ldp-reader-open body > [data-ldp-reader-composer-root]{position:relative!important;
- z-index:2147483604!important;overflow:visible!important;}
- html.ldp-reader-open [data-ldp-reader-composer-overflow]{overflow-x:clip!important;}
- html.ldp-reader-open #reply-control:not(.closed),
- html.ldp-reader-open .composer-popup:not(.closed){visibility:visible!important;
- z-index:2147483605!important;}
- html.ldp-reader-open #reply-control:not(.closed):not([data-ldp-reader-composer-positioned="1"]){visibility:hidden!important;}
- html.ldp-reader-open :is(#d-menu-portals,.fk-d-menu,.emoji-picker,.composer-popup,.dialog-holder,.d-modal){visibility:visible!important;
- z-index:2147483610!important;}
- html.ldp-reader-open #reply-control[data-ldp-reader-top-layer="composer"][popover]{margin:0!important;}
- html.ldp-reader-open #reply-control:not(.closed){position:fixed!important;
- left:var(--ldp-composer-left,50%)!important;top:var(--ldp-composer-top,25vh)!important;
- right:auto!important;bottom:auto!important;
- transform:var(--ldp-composer-transform,translateX(-50%))!important;
- width:var(--ldp-composer-width,min(1200px,calc(100vw - 32px)))!important;
- height:var(--ldp-composer-height,70vh)!important;
- min-width:min(520px,calc(100vw - 32px))!important;min-height:min(300px,calc(100vh - 32px))!important;
- max-width:calc(100vw - 32px)!important;max-height:calc(100vh - 32px)!important;
- box-sizing:border-box!important;border:0!important;outline:0!important;border-radius:10px!important;
- color:var(--ldp-interface-font-color,var(--primary,#222))!important;
- font-family:var(--ldp-interface-font-family,inherit)!important;
- font-size:var(--ldp-font-md,14px)!important;font-weight:var(--ldp-interface-font-weight,400);will-change:translate;
- box-shadow:0 18px 54px rgba(0,0,0,.28)!important;overflow:hidden!important;
- animation:none!important;transition:none!important;}
- html.ldp-reader-open #reply-control:not(.closed).fullscreen{inset:16px!important;
- width:auto!important;height:auto!important;min-width:0!important;min-height:0!important;
- max-width:calc(100vw - 32px)!important;max-height:calc(100vh - 32px)!important;
- transform:none!important;border-radius:10px!important;}
- html.ldp-reader-open #reply-control .reply-area{min-width:0!important;overflow:hidden!important;}
- html.ldp-reader-open #reply-control .toggle-minimize{display:none!important;}
- html.ldp-reader-open #reply-control .reply-to .presence-users{align-items:center!important;
- flex:0 0 auto!important;min-width:max-content!important;overflow:visible!important;white-space:nowrap!important;}
- html.ldp-reader-open #reply-control .reply-to .presence-avatars{align-items:center!important;
- flex:0 0 auto!important;flex-wrap:nowrap!important;overflow:visible!important;}
- html.ldp-reader-open #reply-control .reply-to .presence-avatars > *{display:inline-flex!important;
- flex:0 0 auto!important;line-height:0!important;}
- html.ldp-reader-open #reply-control .reply-to .presence-avatars img.avatar{display:block!important;flex:none!important;}
- html.ldp-reader-open #reply-control .reply-to span.presence-text{flex:0 0 auto!important;
- min-width:max-content!important;overflow:visible!important;}
- html.ldp-reader-open #reply-control .submit-panel{padding-bottom:18px!important;
- box-sizing:border-box!important;}
- html.ldp-reader-open #reply-control .d-editor-container{min-width:0!important;}
- html.ldp-reader-open #reply-control .d-editor-textarea-wrapper,
- html.ldp-reader-open #reply-control .d-editor-preview-wrapper,
- html.ldp-reader-open #reply-control .composer-preview,
- html.ldp-reader-open #reply-control .preview-area{min-width:0!important;
- width:auto!important;max-width:none!important;overflow:auto!important;}
- html.ldp-reader-open #reply-control .d-editor-input,
- html.ldp-reader-open #reply-control textarea,
- html.ldp-reader-open #reply-control .d-editor-preview,
- html.ldp-reader-open #reply-control .ProseMirror{color:var(--ldp-composer-font-color,var(--primary,#222))!important;
- font-family:var(--ldp-composer-font-family,inherit)!important;
- font-size:var(--ldp-composer-font-size,80%)!important;font-weight:var(--ldp-composer-font-weight,400)!important;
- line-height:1.5!important;}
- html.ldp-reader-open #reply-control :is(.d-editor-preview,.composer-preview,.preview-area)
- aside.quote blockquote img:not(.emoji){display:block!important;width:auto!important;height:auto!important;
- max-width:70%!important;max-height:360px!important;margin:8px auto!important;object-fit:contain;}
- html.ldp-reader-open #user-card,
- html.ldp-reader-open #group-card,
- html.ldp-reader-open .user-card,
- html.ldp-reader-open .group-card,
- html.ldp-reader-open .user-card-container{visibility:visible!important;
- z-index:2147483607!important;--ldp-native-user-card-scale:.8;color:var(--ldp-interface-font-color,inherit);
- font-family:var(--ldp-interface-font-family,inherit);font-weight:var(--ldp-interface-font-weight,400);}
- html.ldp-reader-open #user-card,
- html.ldp-reader-open #group-card,
- html.ldp-reader-open .user-card{zoom:var(--ldp-native-user-card-scale)!important;
- max-width:none!important;}
- .ldp-composer-window-chrome{position:fixed;z-index:2147483606;left:0;top:0;
- width:var(--ldp-composer-chrome-width,0);height:var(--ldp-composer-chrome-height,0);
- transform:none;box-sizing:border-box;pointer-events:none;will-change:transform;}
- .ldp-composer-window-chrome[data-ldp-reader-top-layer="chrome"][popover]{right:auto!important;bottom:auto!important;
- margin:0!important;padding:0!important;border:0!important;background:transparent!important;overflow:visible!important;}
- html.ldp-reader-open [data-ldp-reader-top-layer="portal"][popover]{
- margin:0!important;padding:0!important;border:0!important;background:transparent!important;overflow:visible!important;}
- html.ldp-reader-open [data-ldp-reader-top-layer][popover]::backdrop{background:transparent!important;pointer-events:none!important;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"]{
- color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit);
- font-size:var(--ldp-font-base,13px);font-weight:var(--ldp-interface-font-weight,400);}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .fk-d-menu__inner-content{
- box-sizing:border-box!important;border:1px solid var(--primary-low,#d7d7d7)!important;border-radius:8px!important;
- background:var(--secondary,#fff)!important;color:inherit!important;box-shadow:0 8px 24px rgba(0,0,0,.18)!important;
- overscroll-behavior:contain;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu{
- min-width:200px;gap:2px!important;box-sizing:border-box;margin:0!important;padding:5px!important;list-style:none;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a),
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn{
- display:flex;box-sizing:border-box;width:100%;min-height:36px;align-items:center;gap:10px;padding:6px 8px!important;
- border:0!important;border-radius:6px!important;background:transparent!important;color:var(--primary,#222)!important;
- box-shadow:none!important;font:inherit!important;font-size:var(--ldp-font-base,13px)!important;font-weight:550!important;
- line-height:1.35;text-align:left;text-decoration:none!important;cursor:pointer;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(:hover,:focus-visible),
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(:hover,:focus-visible){
- background:var(--primary-very-low,#f2f2f2)!important;color:var(--primary,#222)!important;outline:none!important;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(.active,[aria-current="true"],[aria-checked="true"]),
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(.active,[aria-current="true"],[aria-checked="true"]){
- background:color-mix(in srgb,var(--tertiary-low,#dceee2) 68%,var(--secondary,#fff))!important;
- color:var(--tertiary,#47855f)!important;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(:disabled,[aria-disabled="true"]),
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(:disabled,[aria-disabled="true"]){
- background:transparent!important;color:var(--primary-medium,#777)!important;opacity:.48;cursor:default;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .d-icon{
- width:18px;height:18px;flex:none;color:var(--primary-medium,#777);}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(:hover,:focus-visible,.active,[aria-current="true"],[aria-checked="true"]) .d-icon,
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(:hover,:focus-visible,.active,[aria-current="true"],[aria-checked="true"]) .d-icon{
- color:inherit;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__divider{
- margin:3px 4px!important;border-color:var(--ldp-divider-line-color,var(--primary-low,#ddd))!important;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__subheader{
- padding:7px 8px 3px!important;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:700;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *){
- scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff)) transparent;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar{
- width:9px;height:9px;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar-button{
- display:none;width:0;height:0;}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar-track{
- border-radius:999px;background:linear-gradient(90deg,transparent 0 calc(50% - .75px),
- color-mix(in srgb,var(--ldp-reply-line-color,#47855f) 28%,transparent) calc(50% - .75px) calc(50% + .75px),
- transparent calc(50% + .75px) 100%);}
- html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar-thumb{
- border-radius:5px;background:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff));}
- .ldp-composer-drag-handle{position:absolute;left:50%;top:-32px;z-index:2;
- width:140px;height:48px;transform:translateX(-50%);box-sizing:border-box;padding:0;
- border:0;background:transparent;box-shadow:none;color:transparent;
- cursor:grab;pointer-events:auto;touch-action:none;}
- .ldp-composer-drag-handle::before{display:none;}
- .ldp-composer-window-chrome.is-interacting .ldp-composer-drag-handle{cursor:grabbing;}
- .ldp-composer-resize-handle{position:absolute;z-index:1;display:block;pointer-events:auto;touch-action:none;}
- .ldp-composer-resize-handle[data-resize="n"]{left:16px;right:16px;top:-6px;height:12px;cursor:n-resize;}
- .ldp-composer-resize-handle[data-resize="s"]{left:16px;right:16px;bottom:-6px;height:12px;cursor:s-resize;}
- .ldp-composer-resize-handle[data-resize="e"]{top:16px;right:-6px;bottom:16px;width:12px;cursor:e-resize;}
- .ldp-composer-resize-handle[data-resize="w"]{top:16px;left:-6px;bottom:16px;width:12px;cursor:w-resize;}
- .ldp-composer-resize-handle[data-resize="ne"]{top:-7px;right:-7px;width:20px;height:20px;cursor:ne-resize;}
- .ldp-composer-resize-handle[data-resize="nw"]{top:-7px;left:-7px;width:20px;height:20px;cursor:nw-resize;}
- .ldp-composer-resize-handle[data-resize="se"]{right:-8px;bottom:-8px;width:28px;height:28px;padding:0;
- border:0;border-radius:0 0 8px 0;background:transparent;color:var(--primary-medium,#777);cursor:se-resize;}
- .ldp-composer-resize-handle[data-resize="se"]::before,
- .ldp-composer-resize-handle[data-resize="se"]::after{content:"";position:absolute;border-right:2px solid currentColor;
- border-bottom:2px solid currentColor;border-radius:0 0 2px 0;opacity:.58;transition:opacity .15s ease,color .15s ease;}
- .ldp-composer-resize-handle[data-resize="se"]::before{right:5px;bottom:5px;width:11px;height:11px;}
- .ldp-composer-resize-handle[data-resize="se"]::after{right:9px;bottom:9px;width:5px;height:5px;}
- .ldp-composer-resize-handle[data-resize="se"]:hover,
- .ldp-composer-resize-handle[data-resize="se"]:focus-visible{color:var(--tertiary,#47855f);}
- .ldp-composer-resize-handle[data-resize="se"]:focus-visible{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 62%,transparent);
- outline-offset:-3px;}
- .ldp-composer-resize-handle[data-resize="se"]:hover::before,
- .ldp-composer-resize-handle[data-resize="se"]:hover::after,
- .ldp-composer-resize-handle[data-resize="se"]:focus-visible::before,
- .ldp-composer-resize-handle[data-resize="se"]:focus-visible::after{opacity:1;}
- .ldp-composer-resize-handle[data-resize="sw"]{left:-7px;bottom:-7px;width:20px;height:20px;cursor:sw-resize;}
- html.ldp-composer-window-interacting,html.ldp-composer-window-interacting *{user-select:none!important;}
- @media (max-width:760px){
- html.ldp-reader-open #reply-control:not(.closed){left:6px!important;right:6px!important;
- top:auto!important;bottom:0!important;width:auto!important;height:50vh!important;
- min-width:0!important;min-height:0!important;max-width:none!important;max-height:calc(100vh - 12px)!important;
- transform:none!important;border-radius:10px 10px 0 0!important;}
- .ldp-composer-window-chrome{display:none!important;}
- html.ldp-reader-open #reply-control .d-editor-preview-wrapper,
- html.ldp-reader-open #reply-control .composer-preview,
- html.ldp-reader-open #reply-control .preview-area{min-height:140px!important;}
- }
- .ldp-user-card-fallback{position:fixed;z-index:2147483608;display:none;width:min(360px,calc(100vw - 24px));
- max-height:min(480px,calc(100vh - 24px));overflow:auto;overscroll-behavior:contain;box-sizing:border-box;
- border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff);
- color:var(--primary,#222);box-shadow:0 18px 44px rgba(0,0,0,.22);padding:12px;}
- .ldp-user-card-fallback.open{display:block;}
- .ldp-user-card-fallback:has(.ldp-user-card-badges){max-height:min(520px,calc(100vh - 24px));}
- .ldp-user-card-background{display:block;width:calc(100% + 24px);height:120px;margin:-12px -12px 12px;
- overflow:hidden;padding:0;border:0;border-radius:9px 9px 0 0;background:var(--primary-very-low,#f7f7f7);
- cursor:zoom-in;}
- .ldp-user-info-cover img,.ldp-user-card-background img{display:block;width:100%;height:100%;object-fit:cover;}
- .ldp-user-card-background:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:-3px;}
- .ldp-user-card-head{display:flex;gap:10px;align-items:center;margin-bottom:8px;padding-right:44px;}
- .ldp-user-card-identity{min-width:0;}
- .ldp-user-card-name-row{display:flex;align-items:center;gap:6px;min-width:0;}
- .ldp-user-card-level{flex:none;padding:2px 7px;border-radius:999px;
- background:color-mix(in srgb,var(--tertiary,#08c) 12%,var(--secondary,#fff));
- color:var(--tertiary,#08c);font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1.45;}
- .ldp-user-card-avatar-trigger{display:inline-flex;width:48px;height:48px;flex:none;align-items:center;justify-content:center;
- padding:0;border:0;border-radius:50%;background:transparent;box-shadow:0 0 0 0 transparent;cursor:zoom-in;}
- .ldp-user-card-avatar-trigger .ldp-avatar-flair{right:-4px;bottom:-3px;width:19px;height:19px;border-width:2px;}
- .ldp-user-card-avatar-trigger:hover{box-shadow:0 0 0 2px color-mix(in srgb,var(--tertiary,#08c) 38%,transparent);}
- .ldp-user-card-avatar-trigger:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:3px;}
- .ldp-user-card-avatar{width:48px;height:48px;border-radius:50%;flex:none;object-fit:cover;}
- .ldp-avatar-viewer{position:fixed;z-index:2147483609;display:flex;flex-direction:column;
- width:min(320px,calc(100vw - 20px));height:min(358px,calc(100vh - 20px));max-height:calc(100vh - 20px);
- box-sizing:border-box;overflow:hidden;padding:8px;border-radius:12px;background:var(--secondary,#fff);
- color:var(--primary,#222);box-shadow:0 8px 24px rgba(0,0,0,.28);}
- .ldp-avatar-viewer.is-background{width:min(560px,calc(100vw - 20px));height:min(360px,calc(100vh - 20px));}
- .ldp-avatar-viewer.is-image{width:min(480px,calc(100vw - 20px));height:min(480px,calc(100vh - 20px));
- border:1px solid rgba(255,255,255,.14);background:rgba(7,9,12,.92);color:#fff;box-shadow:0 18px 54px rgba(0,0,0,.48);}
- .ldp-avatar-viewer-toolbar{display:flex;min-width:0;flex:none;align-items:center;justify-content:flex-end;gap:4px;margin-bottom:6px;}
- .ldp-avatar-viewer-progress{display:flex;min-width:0;flex:1;align-items:center;gap:7px;margin-right:4px;
- color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-variant-numeric:tabular-nums;}
- .ldp-avatar-viewer-progress-track{height:4px;min-width:36px;flex:1;overflow:hidden;border-radius:999px;
- background:color-mix(in srgb,var(--primary-low,#ddd) 82%,var(--secondary,#fff));}
- .ldp-avatar-viewer-progress-fill{display:block;width:var(--ldp-avatar-progress,0%);height:100%;border-radius:inherit;
- background:var(--tertiary,#47855f);transition:width .16s ease-out;}
- .ldp-avatar-viewer-progress-value{min-width:6.5em;flex:none;text-align:right;white-space:nowrap;}
- .ldp-avatar-viewer-progress.is-indeterminate .ldp-avatar-viewer-progress-fill{width:34%;
- animation:ldp-avatar-viewer-progress 1.1s ease-in-out infinite alternate;}
- @keyframes ldp-avatar-viewer-progress{from{transform:translateX(-15%);}to{transform:translateX(210%);}}
- .ldp-avatar-viewer-stage{position:relative;display:grid;width:100%;min-width:0;min-height:0;flex:1;
- place-items:center;overflow:hidden;border-radius:8px;background:var(--secondary,#fff);}
- .ldp-avatar-viewer-image{display:block;width:100%;height:100%;max-width:100%;max-height:100%;
- min-width:0;min-height:0;border-radius:6px;object-fit:contain;}
- .ldp-avatar-viewer-stage > .ldp-avatar-flair{right:12px;bottom:12px;z-index:2;width:56px;height:56px;
- border-width:3px;box-shadow:0 3px 12px rgba(0,0,0,.18);}
- .ldp-avatar-viewer .ldp-lb-btn{border:1px solid var(--primary-low,#ddd);background:var(--secondary,#fff);
- color:var(--primary-high,#444);}
- .ldp-avatar-viewer .ldp-lb-btn:hover{background:var(--primary-very-low,#f7f7f7);}
- .ldp-avatar-viewer .ldp-lb-btn:focus-visible{outline-color:var(--tertiary,#08c);}
- .ldp-avatar-viewer.is-image .ldp-avatar-viewer-toolbar{margin-bottom:8px;}
- .ldp-avatar-viewer.is-image .ldp-avatar-viewer-stage{overflow:hidden;background:rgba(10,12,16,.5);}
- .ldp-avatar-viewer.is-image .ldp-avatar-viewer-image{transform:translate(var(--ldp-avatar-pan-x,0),var(--ldp-avatar-pan-y,0)) scale(var(--ldp-avatar-scale,1));
- transform-origin:center;will-change:transform;}.ldp-avatar-viewer.is-image .ldp-avatar-viewer-image.is-zoomed{cursor:grab;}
- .ldp-avatar-viewer.is-image .ldp-avatar-viewer-image.is-dragging{cursor:grabbing;}
- .ldp-avatar-viewer.is-image .ldp-avatar-viewer-progress,.ldp-avatar-viewer.is-image .ldp-avatar-viewer-status{color:rgba(255,255,255,.72);}
- .ldp-avatar-viewer.is-image .ldp-avatar-viewer-progress-track{background:rgba(255,255,255,.12);}
- .ldp-avatar-viewer.is-image .ldp-avatar-viewer-progress-fill{background:#8cc7ff;}
- .ldp-avatar-viewer.is-image .ldp-lb-btn{border:0;background:rgba(255,255,255,.1);color:#fff;}
- .ldp-avatar-viewer.is-image .ldp-lb-btn:hover:not(:disabled){background:rgba(255,255,255,.2);}
- .ldp-avatar-viewer.is-image .ldp-lb-btn:focus-visible{outline-color:#8cc7ff;}
- .ldp-avatar-viewer-selection{display:inline-flex;min-width:0;align-items:center;gap:7px;margin-right:auto;padding:5px 8px;
- border-radius:7px;background:rgba(255,255,255,.1);color:#fff;font-size:var(--ldp-font-ui,12px);cursor:pointer;}
- .ldp-avatar-viewer-selection input{width:17px;height:17px;margin:0;accent-color:#47855f;cursor:pointer;}
- .ldp-avatar-viewer-selection span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-avatar-viewer-nav{position:absolute;z-index:3;top:50%;display:grid;width:42px;height:54px;place-items:center;
- transform:translateY(-50%);border:0;border-radius:8px;background:rgba(0,0,0,.48);color:#fff;cursor:pointer;}
- .ldp-avatar-viewer-nav:hover{background:rgba(0,0,0,.68);}.ldp-avatar-viewer-nav:disabled{opacity:.3;cursor:default;}
- .ldp-avatar-viewer-nav .ldp-icon{width:22px;height:22px;}.ldp-avatar-viewer-prev{left:8px;}
- .ldp-avatar-viewer-next{right:8px;}.ldp-avatar-viewer-prev .ldp-icon{transform:scaleX(-1);}
- .ldp-avatar-viewer-status{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
- color:var(--primary-medium,#777);font-size:var(--ldp-font-base,13px);}
- .ldp-user-card-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
- font-size:var(--ldp-font-lg,15px);font-weight:700;line-height:1.25;}
- .ldp-user-card-username{font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);line-height:1.3;}
- .ldp-user-card-title{font-size:var(--ldp-font-ui,12px);color:var(--tertiary,#08c);margin-top:2px;}
- .ldp-user-profile-facts.is-card{gap:5px 12px;margin:-2px 0 8px;
- font-size:var(--ldp-font-sm,11px);line-height:1.45;}
- .ldp-user-profile-facts.is-card .is-wide{max-width:100%;}
- .ldp-user-profile-facts.is-card .is-wide .ldp-user-profile-fact-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-user-card-groups{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:7px;margin:6px 0;
- color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
- .ldp-user-card-groups > span{padding-top:3px;white-space:nowrap;}
- .ldp-user-card-groups > div{display:flex;min-width:0;flex-wrap:wrap;gap:4px;}
- .ldp-user-card-groups a{display:inline-flex;max-width:100%;padding:2px 7px;border:1px solid var(--primary-low,#ddd);
- border-radius:999px;background:color-mix(in srgb,var(--tertiary,#08c) 7%,var(--secondary,#fff));
- color:var(--tertiary,#08c);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-user-card-groups a:hover,.ldp-user-card-groups a:focus-visible{border-color:var(--tertiary,#08c);outline:none;
- background:color-mix(in srgb,var(--tertiary,#08c) 14%,var(--secondary,#fff));}
- .ldp-user-card-follow-stats{display:flex;align-items:center;gap:18px;margin:7px 0;}
- .ldp-user-card-follow-stat{display:inline-flex;min-width:0;align-items:baseline;gap:5px;padding:2px 0;border:0;
- background:transparent;color:inherit;text-align:left;cursor:pointer;}
- .ldp-user-card-follow-stat:hover,.ldp-user-card-follow-stat[aria-expanded="true"]{color:var(--tertiary,#08c);}
- .ldp-user-card-follow-stat.is-readonly,.ldp-user-card-follow-stat.is-readonly:hover{color:inherit;cursor:default;}
- .ldp-user-card-follow-stat:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:2px;border-radius:3px;}
- .ldp-user-card-follow-stat:disabled{opacity:.48;cursor:not-allowed;}
- .ldp-user-card-follow-stat strong{font-size:var(--ldp-font-base,13px);font-variant-numeric:tabular-nums;}
- .ldp-user-card-follow-stat span{overflow:hidden;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);
- text-overflow:ellipsis;white-space:nowrap;}
- .ldp-user-card-follow-panel{position:fixed;z-index:2147483610;display:grid;width:min(320px,calc(100vw - 20px));
- max-height:min(420px,calc(100vh - 20px));box-sizing:border-box;gap:6px;margin:0;padding:9px;
- grid-template-rows:auto auto auto auto minmax(0,1fr) auto;
- overflow:hidden;border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff);
- color:var(--primary,#222);box-shadow:0 18px 44px rgba(0,0,0,.22);}
- .ldp-user-card-follow-panel[hidden]{display:none;}
- .ldp-user-card-follow-preview{z-index:2147483611;width:min(320px,calc(100vw - 20px));
- max-height:min(520px,calc(100vh - 20px));}
- .ldp-user-card-follow-panel > header{display:flex;align-items:center;justify-content:space-between;gap:8px;}
- .ldp-user-card-follow-title{font-size:var(--ldp-font-ui,12px);}
- .ldp-user-card-breadcrumbs{display:flex;min-width:0;align-items:center;gap:4px;overflow-x:auto;
- color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);scrollbar-width:none;}
- .ldp-user-card-breadcrumbs[hidden]{display:none;}
- .ldp-user-card-breadcrumbs::-webkit-scrollbar{display:none;}
- .ldp-user-card-breadcrumbs button{flex:none;padding:1px 4px;border:0;border-radius:4px;background:transparent;
- color:inherit;font-size:inherit;cursor:pointer;}
- .ldp-user-card-breadcrumbs button:hover,.ldp-user-card-breadcrumbs button:focus-visible{
- background:var(--primary-very-low,#f7f7f7);color:var(--tertiary,#08c);outline:none;}
- .ldp-user-card-breadcrumbs strong{flex:none;color:var(--primary,#222);}
- .ldp-user-card-follow-panel > header button{width:24px;height:24px;padding:0;border:0;border-radius:50%;
- background:transparent;color:var(--primary-medium,#777);font-size:18px;line-height:1;cursor:pointer;}
- .ldp-user-card-follow-panel > header button:hover{background:var(--primary-very-low,#f7f7f7);}
- .ldp-user-card-follow-search{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:7px;
- color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
- .ldp-user-card-follow-search input{min-width:0;height:30px;padding:4px 8px;border:1px solid var(--primary-low,#ddd);
- border-radius:7px;background:var(--secondary,#fff);color:var(--primary,#222);font:inherit;}
- .ldp-user-card-follow-search input:focus{border-color:var(--tertiary,#08c);outline:none;}
- .ldp-user-card-follow-summary{min-height:1.3em;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);}
- .ldp-user-card-follow-list{display:grid;min-height:0;gap:2px;overflow:auto;overscroll-behavior:contain;}
- .ldp-user-card-follow-pagination{display:flex;align-items:center;justify-content:center;gap:8px;
- color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);}
- .ldp-user-card-follow-pagination[hidden]{display:none;}
- .ldp-user-card-follow-pagination button{padding:3px 8px;border:1px solid var(--primary-low,#ddd);border-radius:6px;
- background:var(--secondary,#fff);color:inherit;font:inherit;cursor:pointer;}
- .ldp-user-card-follow-pagination button:hover:not(:disabled),
- .ldp-user-card-follow-pagination button:focus-visible{border-color:var(--tertiary,#08c);color:var(--tertiary,#08c);outline:none;}
- .ldp-user-card-follow-pagination button:disabled{opacity:.45;cursor:not-allowed;}
- .ldp-user-card-follow-item{display:grid;grid-template-columns:30px minmax(0,1fr);align-items:center;gap:8px;
- min-width:0;padding:5px 6px;border-radius:7px;color:inherit;text-decoration:none;}
- .ldp-user-card-follow-item:hover,.ldp-user-card-follow-item:focus-visible{background:var(--primary-very-low,#f7f7f7);outline:none;}
- .ldp-user-card-follow-avatar{width:28px;height:28px;border-radius:50%;object-fit:cover;}
- .ldp-user-card-follow-item > span{display:grid;min-width:0;}
- .ldp-user-card-follow-item strong,.ldp-user-card-follow-item small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-user-card-follow-item strong{font-size:var(--ldp-font-ui,12px);}
- .ldp-user-card-follow-item small{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);}
- .ldp-user-card-follow-empty{padding:12px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);text-align:center;}
- .ldp-user-card-bio{font-size:var(--ldp-font-ui,12px);line-height:1.45;color:var(--primary-medium,#666);
- margin:8px 0;max-height:96px;overflow:auto;overflow-wrap:anywhere;}
- .ldp-user-card-bio p{margin:0 0 4px;}
- .ldp-user-card-bio img.emoji{width:1.15em;height:1.15em;margin:0 .05em;
- vertical-align:-.2em;}
- .ldp-user-card-badges{display:grid;grid-template-columns:minmax(0,1fr);gap:5px;margin:7px 0;overflow:hidden;}
- .ldp-user-card-badges-label{display:block;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);
- font-weight:600;line-height:1.35;}
- .ldp-user-card-badge-strip{display:grid;grid-template-columns:minmax(0,1fr);align-items:center;gap:1px;}
- .ldp-user-card-badge-strip.is-scrollable{grid-template-columns:18px minmax(0,1fr) 18px;}
- .ldp-user-card-badge-list{display:flex;width:100%;height:42px;min-width:0;align-items:center;
- flex-wrap:nowrap;gap:2px;overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;scrollbar-width:none;}
- .ldp-user-card-badge-list::-webkit-scrollbar{display:none;width:0;height:0;}
- .ldp-user-card-badge-scroll{display:grid;width:18px;height:38px;place-items:center;padding:0;border:0;
- border-radius:999px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
- .ldp-user-card-badge-scroll:disabled{opacity:.2;cursor:default;}
- .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open) .ldp-icon,
- .ldp-popover-search-clear .ldp-icon,.ldp-config-action .ldp-icon,
- .ldp-user-card-badge-scroll .ldp-icon{width:14px;height:14px;}
- .ldp-user-card-badge{display:grid;width:34px;height:38px;flex:none;place-items:center;box-sizing:border-box;
- padding:0;border:0;border-radius:999px;background:transparent;color:var(--primary-high,#333);cursor:copy;}
- .ldp-user-card-badge-scroll:hover:not(:disabled),.ldp-user-card-badge:hover{background:color-mix(in srgb,currentColor 8%,transparent);}
- .ldp-user-card-badge-scroll:focus-visible,.ldp-user-card-badge:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:1px;}
- .ldp-user-card-badge-icon{display:block;width:32px;height:32px;overflow:visible;fill:currentColor;stroke:none;
- stroke-linecap:round;stroke-linejoin:round;}
- .ldp-user-card-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:8px 0;}
- .ldp-user-card-stat{display:block;border:1px solid var(--primary-low,#eee);border-radius:7px;padding:5px;
- text-align:center;background:var(--primary-very-low,#f7f7f7);color:inherit;text-decoration:none;}
- .ldp-user-card-stat:hover{border-color:var(--tertiary,#08c);background:var(--secondary,#fff);}
- .ldp-user-card-stat strong{display:block;font-size:var(--ldp-font-base,13px);color:var(--primary,#222);}
- .ldp-user-card-stat span{font-size:var(--ldp-font-sm,11px);color:var(--primary-medium,#777);}
- .ldp-user-card-actions-wrap{position:relative;}
- .ldp-user-card-actions{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;
- margin:9px 0 5px;overflow:visible;}
- .ldp-user-card-action{position:relative;display:inline-flex;align-items:center;justify-content:center;
- min-width:0;height:36px;padding:0;border:1px solid var(--primary-low,#ddd);border-radius:7px;
- background:var(--secondary,#fff);color:var(--primary-high,#444);cursor:pointer;overflow:visible;}
- .ldp-user-card-action:first-child{border-color:var(--tertiary,#4f8a63);
- background:var(--tertiary,#4f8a63);color:var(--secondary,#fff);}
- .ldp-user-card-action:hover,.ldp-user-card-action:focus-visible{border-color:var(--tertiary,#4f8a63);
- background:color-mix(in srgb,var(--tertiary,#4f8a63) 10%,var(--secondary,#fff));
- color:var(--tertiary,#4f8a63);outline:none;}
- .ldp-user-card-action:first-child:hover,.ldp-user-card-action:first-child:focus-visible{
- background:color-mix(in srgb,var(--tertiary,#4f8a63) 86%,#000);color:var(--secondary,#fff);}
- .ldp-user-card-action.is-active{border-color:var(--tertiary,#4f8a63);color:var(--tertiary,#4f8a63);}
- .ldp-user-card-action:disabled{opacity:.42;cursor:not-allowed;}
- .ldp-user-card-action .ldp-icon{width:18px;height:18px;}
- .ldp-user-card-action::after{content:attr(data-tooltip);position:absolute;left:50%;bottom:calc(100% + 6px);
- z-index:2;width:max-content;max-width:180px;transform:translateX(-50%);padding:4px 7px;
- pointer-events:none;opacity:0;visibility:hidden;transition:none;}
- .ldp-user-card-action:first-child::after{left:0;transform:none;}
- .ldp-user-card-action:last-child::after{right:0;left:auto;transform:none;}
- .ldp-user-card-action:hover::after,.ldp-user-card-action:focus-visible::after{opacity:1;visibility:visible;}
- .ldp-user-card-notification-menu{position:fixed;z-index:4;display:grid;box-sizing:border-box;gap:4px;margin:0;
- max-height:calc(100vh - 20px);overflow:auto;overscroll-behavior:contain;
- padding:5px;border:1px solid var(--primary-low,#ddd);border-radius:8px;background:var(--secondary,#fff);
- box-shadow:0 10px 28px rgba(0,0,0,.16);}
- .ldp-user-card-notification-option{display:grid;grid-template-columns:28px minmax(0,1fr);align-items:center;
- box-sizing:border-box;gap:7px;width:100%;min-height:38px;padding:4px 7px;border:0;border-radius:6px;background:transparent;color:var(--primary,#222);
- text-align:left;cursor:pointer;}
- .ldp-user-card-notification-option:hover,.ldp-user-card-notification-option:focus-visible,
- .ldp-user-card-notification-option.is-active{background:color-mix(in srgb,var(--tertiary,#4f8a63) 12%,var(--secondary,#fff));
- outline:none;}
- .ldp-user-card-notification-option .ldp-icon{width:18px;height:18px;justify-self:center;}
- .ldp-user-card-notification-option-copy{display:grid;gap:1px;min-width:0;}
- .ldp-user-card-notification-option-copy strong{font-size:var(--ldp-font-ui,12px);line-height:1.3;}
- .ldp-user-card-notification-option-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);line-height:1.35;}
- .ldp-user-card-notification-option:disabled{opacity:.5;cursor:wait;}
- .ldp-reader-icon-tooltip.ldp-transient-surface{position:fixed;z-index:2147483647;box-sizing:border-box;width:max-content;max-width:min(380px,calc(100vw - 16px));
- padding:4px 7px;overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;
- pointer-events:none;opacity:1;visibility:visible;transition:none;}
- .ldp-reader-icon-tooltip.ldp-reader-history-tooltip{max-width:min(34rem,calc(100vw - 16px));
- overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;}
- .ldp-reader-icon-tooltip.ldp-connect-help-tooltip{max-width:min(320px,calc(100vw - 16px));
- padding:7px 9px;overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;line-height:1.5;}
- .ldp-user-card-action-status{min-width:0;color:var(--primary-medium,#777);
- font-size:var(--ldp-font-sm,11px);line-height:1.35;}
- .ldp-user-card-action-status:not(:empty){margin-top:7px;}
- .ldp-user-card-action-status.is-error{color:var(--danger,#c33);}
- .ldp-user-card-home{position:absolute;top:12px;right:12px;display:inline-flex;align-items:center;justify-content:center;
- width:36px;height:36px;box-sizing:border-box;border:0;background:transparent;
- color:var(--primary-high,#444);text-decoration:none;}
- .ldp-user-card-home:hover{color:var(--tertiary,#4f8a63);}
- .ldp-user-card-home:focus-visible{color:var(--tertiary,#4f8a63);
- outline:2px solid currentColor;outline-offset:2px;border-radius:4px;}
- .ldp-user-card-home .ldp-icon{width:18px;height:18px;}
- .ldp-user-card-home::after{content:attr(data-tooltip);position:absolute;top:calc(100% + 6px);right:0;
- z-index:2;width:max-content;padding:4px 7px;border-radius:5px;background:rgba(30,30,30,.94);color:#fff;
- font-size:var(--ldp-font-sm,11px);font-weight:500;line-height:1.3;white-space:nowrap;pointer-events:none;
- opacity:0;visibility:hidden;transition:none;}
- .ldp-user-card-home:hover::after,.ldp-user-card-home:focus-visible::after{opacity:1;visibility:visible;}
- .ldp-user-card-progress{display:grid;grid-template-columns:minmax(0,1fr) 72px;align-items:center;gap:8px;
- margin:7px 0 2px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
- .ldp-user-card-progress-track{height:4px;overflow:hidden;border-radius:999px;background:var(--primary-low,#e4e4e4);}
- .ldp-user-card-progress-fill{display:block;width:34%;height:100%;border-radius:inherit;background:var(--tertiary,#08c);
- animation:ldp-user-card-progress 1.1s ease-in-out infinite alternate;}
- .ldp-user-card-progress.is-stats .ldp-user-card-progress-fill{width:72%;}
- @keyframes ldp-user-card-progress{from{transform:translateX(-15%);}to{transform:translateX(115%);}}
- .ldp-transient-surface,
- .ldp-reader-placement-option::after,
- .ldp-user-card-action::after{border:0;border-radius:7px;background:rgba(28,28,28,.86);color:#fff;
- font-size:var(--ldp-font-ui,12px);font-weight:500;line-height:1.35;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.18);}
- .ldp-transient-surface.ldp-setting-help-tooltip{padding:7px 9px;line-height:1.5;white-space:normal;overflow-wrap:anywhere;}
- .ldp-action-surface{border:1px solid var(--primary-low,#ddd);border-radius:7px;
- background:var(--secondary,#fff);color:var(--primary,#222);font-size:var(--ldp-font-ui,12px);
- font-weight:600;line-height:1.35;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.14);}
- .ldp-selection-toolbar{position:fixed;z-index:2147483610;display:flex;overflow:hidden;}
- .ldp-selection-toolbar button{min-height:30px;padding:5px 9px;border:0;border-right:1px solid var(--primary-low,#ddd);
- background:transparent;color:inherit;font:inherit;font-size:inherit;font-weight:inherit;line-height:inherit;cursor:pointer;}
- .ldp-selection-toolbar button:last-child{border-right:0;}
- .ldp-selection-toolbar button:hover,.ldp-selection-toolbar button:focus-visible{
- background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff));
- color:var(--tertiary,#47855f);outline:none;}
- .ldp-selection-toast{position:fixed;left:50%;bottom:28px;z-index:2147483611;transform:translateX(-50%);
- padding:8px 12px;border-radius:6px;background:rgba(32,32,32,.9);color:#fff;font-size:var(--ldp-font-base,13px);
- box-shadow:0 6px 20px rgba(0,0,0,.2);pointer-events:none;}
- .ldp-reader-action-layer{position:absolute;inset:0;z-index:2147483612;display:flex;
- align-items:center;justify-content:center;padding:16px;box-sizing:border-box;
- background:color-mix(in srgb,rgba(0,0,0,.46) 84%,transparent);}
- .ldp-reader-action-dialog{display:flex;flex-direction:column;width:min(430px,100%);max-height:min(620px,calc(100% - 16px));
- overflow:hidden;border:1px solid var(--primary-low,#ddd);border-radius:8px;
- background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 18px 54px rgba(0,0,0,.28);}
- .ldp-reader-action-head{display:flex;align-items:center;justify-content:space-between;gap:12px;
- min-height:46px;padding:7px 9px 7px 14px;box-sizing:border-box;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-reader-action-head strong{min-width:0;font-size:var(--ldp-font-lg,15px);line-height:1.35;overflow-wrap:anywhere;}
- .ldp-reader-action-close{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;
- flex:none;padding:0;border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer;}
- .ldp-reader-action-close:hover{background:var(--primary-very-low,#f2f2f2);}
- .ldp-reader-action-form{display:flex;flex-direction:column;min-height:0;}
- .ldp-reader-action-body{display:grid;gap:13px;min-height:0;padding:14px;overflow-y:auto;overscroll-behavior:contain;}
- .ldp-reader-action-options{display:grid;gap:7px;}
- .ldp-reader-action-option{display:grid;grid-template-columns:18px minmax(0,1fr);gap:9px;align-items:start;
- padding:9px;border:1px solid var(--primary-low,#ddd);border-radius:7px;cursor:pointer;}
- .ldp-reader-action-option:has(input:checked){border-color:color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#ddd));
- background:color-mix(in srgb,var(--tertiary-low,#dceee2) 44%,var(--secondary,#fff));}
- .ldp-reader-action-option input{margin:3px 0 0;}
- .ldp-reader-action-option-copy{display:grid;gap:2px;min-width:0;}
- .ldp-reader-action-option-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.4;}
- .ldp-reader-action-field{display:grid;gap:5px;font-size:var(--ldp-font-ui,12px);font-weight:650;}
- .ldp-reader-action-field input,.ldp-reader-action-field textarea,.ldp-reader-action-field select{width:100%;box-sizing:border-box;
- border:1px solid var(--primary-low,#ccc);border-radius:7px;padding:8px 9px;
- background:var(--secondary,#fff);color:inherit;font:inherit;line-height:1.45;outline:none;}
- .ldp-boost-copy-mode,.ldp-font-weight-select,.ldp-reader-action-field .ldp-reader-select{font-size:var(--ldp-font-base,13px);font-weight:600;line-height:1.35;}
- .ldp-reader-action-field textarea{min-height:86px;resize:vertical;}
- .ldp-reader-action-field input:focus,.ldp-reader-action-field textarea:focus,.ldp-reader-action-field select:focus{border-color:var(--tertiary,#47855f);
- box-shadow:0 0 0 1px var(--tertiary,#47855f);}
- .ldp-reader-action-status{min-height:18px;margin:0;color:var(--danger,#c44);font-size:var(--ldp-font-ui,12px);line-height:1.45;}
- .ldp-reader-action-status.success{color:var(--success,#47855f);}
- .ldp-reader-action-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;
- padding:9px 14px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-reader-action-cancel,.ldp-reader-action-submit{min-height:34px;padding:6px 13px;border-radius:6px;
- font:inherit;font-size:var(--ldp-font-base,13px);font-weight:650;cursor:pointer;}
- .ldp-reader-action-cancel{border:1px solid var(--primary-low,#ddd);background:transparent;color:inherit;}
- .ldp-reader-action-cancel:hover{background:var(--primary-very-low,#f2f2f2);}
- .ldp-reader-action-submit:disabled,.ldp-reader-action-cancel:disabled{cursor:default;opacity:.48;}
- .ldp-reader-confirm-layer{position:fixed;z-index:2147483612;pointer-events:auto;
- background:color-mix(in srgb,rgba(0,0,0,.38) 82%,transparent);}
- .ldp-reader-confirm-dialog{width:min(380px,100%);border:0;border-radius:12px;
- box-shadow:0 8px 24px rgba(0,0,0,.24);animation:ldp-reader-confirm-in .18s cubic-bezier(.22,1,.36,1);}
- .ldp-reader-confirm-content{display:grid;grid-template-columns:40px minmax(0,1fr);gap:12px;padding:18px 18px 14px;}
- .ldp-reader-confirm-icon{display:inline-flex;width:40px;height:40px;align-items:center;justify-content:center;
- border-radius:10px;background:var(--danger-low,#f5d7d7);color:var(--danger,#c94747);}
- .ldp-reader-confirm-icon .ldp-icon{width:19px;height:19px;}
- .ldp-reader-confirm-copy{display:grid;gap:5px;min-width:0;}
- .ldp-reader-confirm-copy strong{font-size:var(--ldp-font-lg,15px);line-height:1.4;overflow-wrap:anywhere;}
- .ldp-reader-confirm-copy p{margin:0;color:var(--primary-high,#444);font-size:var(--ldp-font-base,13px);line-height:1.55;overflow-wrap:anywhere;}
- .ldp-reader-confirm-details{display:grid;gap:5px;margin:4px 0 0;padding:0;list-style:none;}
- .ldp-reader-confirm-details li{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 8px;
- border-radius:6px;background:var(--primary-very-low,#f2f2f2);font-size:var(--ldp-font-ui,12px);line-height:1.4;}
- .ldp-reader-confirm-details strong{font-size:inherit;white-space:nowrap;}
- .ldp-reader-confirm-dialog .ldp-reader-action-footer{padding:0 18px 18px;border-top:0;}
- .ldp-reader-confirm-dialog .ldp-reader-action-cancel,.ldp-reader-confirm-dialog .ldp-reader-action-secondary,
- .ldp-reader-confirm-dialog .ldp-reader-action-submit{min-height:36px;padding:7px 15px;}
- .ldp-reader-action-secondary{border:1px solid color-mix(in srgb,var(--danger,#c94747) 38%,var(--primary-low,#ddd));
- border-radius:6px;background:transparent;color:var(--danger,#c94747);font:inherit;font-size:var(--ldp-font-base,13px);font-weight:650;cursor:pointer;}
- .ldp-reader-action-secondary:hover{background:var(--danger-low,#f5d7d7);}
- .ldp-reader-confirm-dialog .ldp-reader-action-submit{background:var(--danger,#c94747);}
- .ldp-reader-confirm-dialog.is-primary .ldp-reader-confirm-icon{background:var(--tertiary-low,#dceee2);color:var(--tertiary,#47855f);}
- .ldp-reader-confirm-dialog.is-primary .ldp-reader-action-submit{background:var(--tertiary,#47855f);}
- .ldp-native-reader-trigger:focus-visible,.ldp-reader-host-top:focus-visible,.ldp-user-info-tab:focus-visible,
- .ldp-rate-limit-challenge:focus-visible,.ldp-topic-timeline-top:focus-visible,.ldp-composer-drag-handle:focus-visible,
- .ldp-reader-confirm-dialog :is(.ldp-reader-action-cancel,.ldp-reader-action-secondary,.ldp-reader-action-submit):focus-visible{
- outline:2px solid var(--tertiary,#47855f);outline-offset:2px;}
- @keyframes ldp-reader-confirm-in{from{opacity:0;transform:translateY(6px) scale(.98);}to{opacity:1;transform:none;}}
- @media (prefers-reduced-motion:reduce){.ldp-reader-confirm-dialog{animation:none;}}
- .ldp-error{display:grid;justify-items:center;align-content:center;gap:16px;min-height:220px;
- box-sizing:border-box;padding:28px;text-align:center;color:var(--primary-medium,#666);}
- .ldp-error-message{max-width:560px;line-height:1.6;overflow-wrap:anywhere;}
- .ldp-error-actions{display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;}
- .ldp-error-retry,.ldp-error-challenge,.ldp-error-close{min-height:36px;padding:7px 14px;border-radius:7px;
- border:1px solid var(--primary-low,#d9d9d9);font:inherit;font-size:var(--ldp-font-base,13px);font-weight:650;cursor:pointer;}
- .ldp-error-retry{border-color:transparent;background:var(--tertiary,#47855f);color:#fff;}
- .ldp-error-challenge{display:inline-flex;align-items:center;text-decoration:none;}
- .ldp-error-challenge,.ldp-error-close{background:var(--secondary,#fff);color:inherit;}
- .ldp-error-challenge:hover,.ldp-error-close:hover{background:var(--primary-very-low,#f3f3f3);}
- .ldp-error-retry:disabled{cursor:default;opacity:.52;}
- .ldp-sentinel{height:1px;}
- /* 图片浏览器 */
- .ldp-lightbox{position:fixed;inset:0;z-index:2147483600;display:flex;flex-direction:column;
- min-width:0;min-height:0;background:rgba(7,9,12,.8);color:#fff;isolation:isolate;
- --ldp-lb-comments-width-preferred:25%;--ldp-lb-comments-width:clamp(min(240px,50%),var(--ldp-lb-comments-width-preferred),50%);}
- .ldp-lb-toolbar{height:54px;display:flex;align-items:center;gap:10px;flex:none;
- box-sizing:border-box;padding:8px 14px;border-bottom:0;
- background:rgba(10,12,16,.5);}
- .ldp-lb-count{min-width:54px;font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;color:rgba(255,255,255,.72);}
- .ldp-lb-tools{display:flex;align-items:center;justify-content:center;gap:4px;flex:1;min-width:0;}
- .ldp-lb-btn,.ldp-lb-nav{display:inline-flex;align-items:center;justify-content:center;border:0;
- color:#fff;background:rgba(255,255,255,.1);cursor:pointer;transition:background-color .16s ease,opacity .16s ease;}
- .ldp-lb-btn{width:36px;height:36px;border-radius:8px;}
- .ldp-lb-btn:hover:not(:disabled),.ldp-lb-nav:hover:not(:disabled):not([aria-disabled="true"]){background:rgba(255,255,255,.2);}
- .ldp-lb-btn:disabled{opacity:.4;cursor:default;}
- .ldp-lb-btn:focus-visible,.ldp-lb-nav:focus-visible,.ldp-lb-thumb:focus-visible{
- outline:2px solid #8cc7ff;outline-offset:2px;}
- .ldp-lb-btn .ldp-icon,.ldp-lb-nav .ldp-icon{width:19px;height:19px;}
- .ldp-lb-zoom-value{width:58px;font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;}
- .ldp-lb-close{margin-left:auto;}
- .ldp-lb-main{position:relative;display:flex;flex:1;min-width:0;min-height:0;}
- .ldp-lb-stage{position:relative;display:flex;align-items:center;justify-content:center;flex:1;
- min-width:0;min-height:0;overflow:hidden;padding:18px 64px;box-sizing:border-box;touch-action:none;}
- .ldp-lb-canvas{position:relative;display:flex;align-items:center;justify-content:center;
- max-width:100%;max-height:100%;}
- .ldp-lb-image{display:block;max-width:100%;max-height:calc(100vh - 176px);width:auto;height:auto;
- border-radius:4px;user-select:none;-webkit-user-drag:none;transform:translate3d(var(--ldp-lb-pan-x,0),var(--ldp-lb-pan-y,0),0) scale(var(--ldp-lb-scale,1));
- transform-origin:center center;will-change:transform;cursor:zoom-in;}
- .ldp-lb-image.is-zoomed{cursor:grab;}
- .ldp-lb-image.is-dragging{cursor:grabbing;}
- .ldp-lb-status{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;
- flex-direction:column;align-items:center;gap:10px;max-width:min(420px,70vw);text-align:center;
- color:rgba(255,255,255,.78);font-size:var(--ldp-font-base,13px);}
- .ldp-lb-retry{border:0;border-radius:7px;padding:7px 12px;background:#fff;color:#111;cursor:pointer;font:inherit;}
- .ldp-lb-nav{position:absolute;top:50%;z-index:2;width:42px;height:56px;border-radius:10px;
- transform:translateY(-50%);background:rgba(0,0,0,.42);}
- .ldp-lb-prev{left:12px;}
- .ldp-lb-next{right:12px;}
- .ldp-lightbox:not(.ldp-lb-comments-collapsed) .ldp-lb-next{right:calc(var(--ldp-lb-comments-width) + 12px);}
- .ldp-lb-nav:disabled,.ldp-lb-nav[aria-disabled="true"]{opacity:.22;cursor:default;}
- .ldp-lb-comments-toggle{gap:5px;width:auto;min-width:36px;padding:0 9px;white-space:nowrap;}
- .ldp-lb-comments-toggle[aria-expanded="true"]{background:rgba(255,255,255,.24);}
- .ldp-lb-comments-count{font-size:var(--ldp-font-sm,11px);font-variant-numeric:tabular-nums;}
- .ldp-lb-comments{--primary:#f5f7fa;--primary-high:#fff;--primary-medium:rgba(245,247,250,.72);
- --primary-low:rgba(245,247,250,.14);--primary-low-mid:rgba(245,247,250,.22);
- --primary-very-low:rgba(245,247,250,.08);--secondary:transparent;
- --d-post-aside-background:var(--primary-very-low);
- position:relative;display:flex;flex:none;flex-direction:column;width:var(--ldp-lb-comments-width);min-width:0;
- overflow:hidden;box-sizing:border-box;border-left:0;
- background:rgba(7,9,12,.65);color:var(--primary);
- transition:width .18s ease,border-color .18s ease;}
- .ldp-lb-comments-collapsed .ldp-lb-comments{width:0;border-left-color:transparent;}
- .ldp-lb-comments-resizer{position:absolute;z-index:7;top:0;bottom:0;left:-6px;width:12px;padding:0;border:0;
- background:transparent;cursor:col-resize;touch-action:none;}
- .ldp-lb-comments-resizer::after{content:"";position:absolute;top:50%;left:5px;width:2px;height:46px;
- border-radius:999px;background:rgba(245,247,250,.2);transform:translateY(-50%);transition:background-color .16s ease;}
- .ldp-lb-comments-resizer:hover::after,.ldp-lb-comments-resizer:focus-visible::after,.ldp-lightbox.is-resizing-comments .ldp-lb-comments-resizer::after{
- background:rgba(245,247,250,.72);}
- .ldp-lb-comments-resizer:focus-visible{outline:2px solid #8cc7ff;outline-offset:-2px;}
- .ldp-lb-comments-collapsed .ldp-lb-comments-resizer{pointer-events:none;opacity:0;}
- .ldp-lightbox.is-resizing-comments,.ldp-lightbox.is-resizing-comments *{cursor:col-resize!important;user-select:none!important;}
- .ldp-lb-comments-inner{display:flex;flex:1;flex-direction:column;width:100%;min-height:0;}
- .ldp-lb-comments-head{display:flex;align-items:center;gap:8px;min-height:50px;padding:8px 14px;
- box-sizing:border-box;border-bottom:0;}
- .ldp-lb-comments-head strong{font-size:var(--ldp-font-lg,15px);line-height:1.3;}
- .ldp-lb-comments-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;}
- .ldp-lb-description-toggle{display:inline-flex;align-items:center;justify-content:center;width:1em;height:1em;padding:0;border:0;
- background:transparent;color:var(--primary-medium,#777);cursor:pointer;font:inherit;font-size:var(--ldp-font-lg,15px);line-height:1;}
- .ldp-content .ldp-callout-toggle .ldp-icon,.ldp-lb-description-toggle .ldp-icon{width:1em;height:1em;transition:transform .16s ease;}
- .ldp-lb-description-toggle[aria-expanded="true"] .ldp-icon{transform:rotate(90deg);}
- .ldp-lb-description-toggle:hover{color:var(--primary,#f5f7fa);}
- .ldp-lb-description-toggle:focus-visible{outline:2px solid #8cc7ff;outline-offset:2px;}
- .ldp-lb-source{margin:0;border-bottom:0;background:var(--primary-very-low,#f7f7f7);}
- .ldp-lb-source:has(+ .ldp-lb-source-reactions:not([hidden])){border-bottom-color:transparent;}
- .ldp-lb-source summary{display:none;}
- .ldp-lb-source-text{box-sizing:border-box;height:var(--ldp-lb-description-height,120px);min-height:56px;max-height:40vh;
- overflow:auto;overscroll-behavior:contain;resize:vertical;padding:10px 14px 11px;color:var(--primary-medium,#666);
- font-size:var(--ldp-font-ui,12px);line-height:1.55;overflow-wrap:anywhere;}
- .ldp-lb-source-reactions{padding:7px 14px 10px;border-bottom:0;background:var(--primary-very-low,#f7f7f7);}
- .ldp-lb-source-reactions > .ldp-reactions{margin:0;}
- .ldp-lb-source-reactions .ldp-reaction-chip,.ldp-lb-comment-node .ldp-reaction-chip{min-height:24px;}
- .ldp-lb-source-reactions .ldp-reaction-chip > span,.ldp-lb-comment-node .ldp-reaction-chip > span{font-size:18px;line-height:1;}
- .ldp-lb-source-reactions .ldp-reaction-chip img.emoji,.ldp-lb-comment-node .ldp-reaction-chip img.emoji{width:18px;height:18px;}
- .ldp-lb-source-reactions .ldp-reaction-picker,.ldp-lb-comment-node .ldp-reaction-picker{top:calc(100% + 5px);bottom:auto;}
- .ldp-lb-comments-body{display:flex;flex:1;flex-direction:column;min-height:0;overflow-y:auto;overscroll-behavior:contain;}
- .ldp-lb-comments-status,.ldp-lb-comments-empty{display:grid;justify-items:center;gap:10px;padding:28px 18px;
- color:var(--primary-medium,#777);text-align:center;font-size:var(--ldp-font-base,13px);line-height:1.55;}
- .ldp-lb-comment-list{display:block;min-height:0;}
- .ldp-lb-comment-thread{border-bottom:0;}
- .ldp-lb-comment-node > .ldp-post{padding:12px 14px;border-bottom:0;content-visibility:visible;contain-intrinsic-size:none;}
- .ldp-lb-comment-children{margin-left:18px;border-left:var(--ldp-reply-line-secondary-width,2px) solid var(--ldp-reply-line-color,#47855f);}
- .ldp-lb-comment-children .ldp-lb-comment-node{border-top:0;}
- .ldp-lb-comment-children .ldp-post{padding-left:12px;}
- .ldp-lb-comment-node .ldp-post-head{align-items:flex-start;flex-wrap:wrap;gap:5px 7px;}
- .ldp-lb-comment-node .ldp-avatar{width:26px;height:26px;}
- .ldp-lb-comment-node .ldp-author{max-width:44%;font-size:var(--ldp-font-ui,12px);line-height:1.35;}
- .ldp-lb-comment-node .ldp-content{font-size:var(--ldp-font-base,13px);line-height:1.6;}
- .ldp-lb-comment-node .ldp-content > :first-child{margin-top:0;}
- .ldp-lb-comment-node .ldp-content > :last-child{margin-bottom:0;}
- .ldp-lb-comment-node .ldp-reactions{margin:8px 0 0;}
- .ldp-lb-comment-node .ldp-context-actions-slot,.ldp-lb-comment-node .ldp-boostbtn,
- .ldp-lb-comment-node .ldp-boost-list,.ldp-lb-comment-node .ldp-topic-footer-actions,
- .ldp-lb-comment-node .ldp-special-badges{display:none!important;}
- .ldp-lb-comment-node .ldp-floor{cursor:pointer;}
- .ldp-lb-comment-node .ldp-floor:hover{color:var(--tertiary,#47855f);text-decoration:underline;}
- .ldp-lb-add,.ldp-lb-comment-submit,.ldp-lb-comment-cancel{min-height:34px;padding:6px 11px;border-radius:7px;
- font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;cursor:pointer;}
- .ldp-topic-edit-save,.ldp-reader-action-submit,.ldp-lb-add,.ldp-lb-comment-submit{border:1px solid transparent;background:var(--tertiary,#47855f);color:#fff;}
- .ldp-lb-comment-cancel{border:1px solid var(--primary-low,#ddd);background:var(--secondary,#fff);color:inherit;}
- .ldp-lb-comment-form{display:grid;gap:8px;margin:0;padding:12px 14px;border-top:0;
- background:var(--secondary,#fff);}
- .ldp-lb-comment-target{color:var(--primary-medium,#666);font-size:var(--ldp-font-ui,12px);line-height:1.4;}
- .ldp-lb-comment-input{box-sizing:border-box;width:100%;min-height:88px;resize:vertical;padding:9px 10px;
- border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);color:inherit;
- font:inherit;font-size:var(--ldp-font-base,13px);line-height:1.55;}
- .ldp-lb-comment-input::placeholder{color:var(--primary-medium);opacity:1;}
- .ldp-boost-rule-control:focus,.ldp-lb-comment-input:focus{border-color:var(--tertiary,#47855f);outline:none;box-shadow:0 0 0 1px var(--tertiary,#47855f);}
- .ldp-lb-comment-image-option{display:flex;align-items:center;gap:7px;color:var(--primary-medium,#666);font-size:var(--ldp-font-ui,12px);cursor:pointer;}
- .ldp-lb-comment-image-option input{margin:0;accent-color:var(--tertiary,#47855f);}
- .ldp-lb-comment-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;}
- .ldp-lb-comment-form[aria-busy="true"] :is(textarea,button,input){pointer-events:none;opacity:.55;}
- .ldp-lb-comment-error{min-height:0;color:var(--danger,#c44);font-size:var(--ldp-font-ui,12px);line-height:1.45;}
- .ldp-lb-filmstrip{flex:none;box-sizing:border-box;padding:7px 12px 10px;
- border-top:0;background:rgba(10,12,16,.5);}
- .ldp-lb-strip-progress{height:2px;margin:0 4px 7px;border-radius:2px;background:rgba(255,255,255,.12);overflow:hidden;}
- .ldp-lb-strip-progress > span{display:block;height:100%;width:var(--ldp-lb-progress-size,100%);
- background:#8cc7ff;transform:translateX(var(--ldp-lb-progress-x,0));transition:transform .18s ease;}
- .ldp-lb-thumbs{display:flex;gap:8px;overflow-x:auto;overscroll-behavior-x:contain;
- scroll-snap-type:x proximity;padding:2px 4px 4px;scrollbar-width:thin;}
- .ldp-lb-thumb{width:64px;height:56px;flex:none;padding:2px;border:2px solid transparent;border-radius:8px;
- background:transparent;cursor:pointer;scroll-snap-align:center;opacity:.62;box-sizing:border-box;}
- .ldp-lb-thumb:hover{opacity:.9;}
- .ldp-lb-thumb.active{border-color:#8cc7ff;opacity:1;}
- .ldp-lb-thumb img{display:block;width:100%;height:100%;border-radius:4px;object-fit:cover;background:#20242a;}
- .ldp-lb-batch-overlay{position:absolute;z-index:12;inset:0;display:grid;place-items:center;padding:16px;
- box-sizing:border-box;background:rgba(0,0,0,.58);backdrop-filter:blur(3px);}
- .ldp-lb-batch-dialog{display:flex;flex-direction:column;width:min(720px,calc(100vw - 32px));max-height:min(720px,calc(100vh - 32px));
- overflow:hidden;border:1px solid rgba(255,255,255,.16);border-radius:12px;background:#24272c;color:#f5f7fa;
- box-shadow:0 18px 54px rgba(0,0,0,.42);}
- .ldp-lb-batch-head,.ldp-lb-batch-tools,.ldp-lb-batch-actions{display:flex;align-items:center;gap:9px;}
- .ldp-lb-batch-head{min-height:48px;padding:8px 12px 8px 16px;border-bottom:1px solid rgba(255,255,255,.1);}
- .ldp-lb-batch-head strong{font-size:var(--ldp-font-lg,15px);}.ldp-lb-batch-head .ldp-lb-batch-close{margin-left:auto;}
- .ldp-lb-batch-name{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:9px;padding:12px 16px 8px;
- color:rgba(245,247,250,.72);font-size:var(--ldp-font-ui,12px);}
- .ldp-lb-batch-name input{min-width:0;height:34px;box-sizing:border-box;padding:6px 9px;border:1px solid rgba(255,255,255,.16);
- border-radius:7px;background:rgba(255,255,255,.07);color:#fff;font:inherit;outline:none;}
- .ldp-lb-batch-name input:focus{border-color:#8cc7ff;box-shadow:0 0 0 1px #8cc7ff;}
- .ldp-lb-batch-tools{min-height:42px;padding:0 16px 8px;color:rgba(245,247,250,.68);font-size:var(--ldp-font-ui,12px);}
- .ldp-lb-batch-scope{display:flex;align-self:stretch;align-items:stretch;gap:18px;border-bottom:1px solid rgba(255,255,255,.1);}
- .ldp-lb-batch-scope button{position:relative;min-height:38px;padding:6px 2px 9px;border:0;background:transparent;
- color:rgba(245,247,250,.58);cursor:pointer;font:inherit;font-weight:650;white-space:nowrap;}
- .ldp-lb-batch-scope button::after{content:"";position:absolute;right:0;bottom:-1px;left:0;height:2px;border-radius:2px 2px 0 0;
- background:#8cc7ff;opacity:0;transform:scaleX(.5);transition:opacity .14s ease,transform .14s ease;}
- .ldp-lb-batch-scope button:hover:not(:disabled){color:rgba(245,247,250,.88);}
- .ldp-lb-batch-scope button[aria-selected="true"]{color:#fff;}
- .ldp-lb-batch-scope button[aria-selected="true"]::after{opacity:1;transform:scaleX(1);}
- .ldp-lb-batch-scope button:focus-visible,.ldp-lb-batch-select-all:focus-visible,.ldp-lb-batch-more:focus-visible{outline:2px solid rgba(140,199,255,.72);outline-offset:2px;}
- .ldp-lb-batch-scope button:disabled{opacity:.4;cursor:default;}
- .ldp-lb-batch-select-all{display:inline-flex;min-height:32px;align-items:center;gap:6px;margin-left:2px;padding:5px 9px;border:0;
- border-radius:7px;background:rgba(255,255,255,.055);color:rgba(245,247,250,.72);cursor:pointer;font:inherit;font-weight:650;}
- .ldp-lb-batch-select-all:hover:not(:disabled){background:rgba(255,255,255,.1);color:#fff;}
- .ldp-lb-batch-select-all[aria-pressed="true"]{background:rgba(140,199,255,.14);color:#b9dcff;}
- .ldp-reader-host-top .ldp-icon,.ldp-settings-theme-button .ldp-icon,.ldp-rate-limit-icon .ldp-icon,
- .ldp-topic-timeline-top .ldp-icon,.ldp-lb-batch-select-all .ldp-icon{width:15px;height:15px;}
- .ldp-lb-batch-select-all:disabled{opacity:.4;cursor:default;}
- .ldp-lb-batch-count{margin-left:auto;padding:4px 8px;border-radius:999px;background:rgba(255,255,255,.055);
- color:rgba(245,247,250,.68);font-variant-numeric:tabular-nums;white-space:nowrap;}
- .ldp-lb-batch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:9px;min-height:0;overflow:auto;
- padding:2px 16px 14px;overscroll-behavior:contain;}
- .ldp-lb-batch-item{position:relative;display:grid;gap:5px;padding:5px;border:1px solid rgba(255,255,255,.12);border-radius:8px;
- background:rgba(255,255,255,.035);cursor:pointer;}.ldp-lb-batch-item.selected{border-color:#8cc7ff;background:rgba(140,199,255,.12);}
- .ldp-lb-batch-item img{display:block;width:100%;aspect-ratio:4/3;border-radius:5px;object-fit:cover;
- background:linear-gradient(110deg,rgba(255,255,255,.045) 8%,rgba(255,255,255,.11) 18%,rgba(255,255,255,.045) 33%);background-size:220% 100%;
- animation:ldp-lb-batch-thumbnail-loading 1.25s linear infinite;}
- .ldp-lb-batch-item img[data-ldp-batch-thumb-state="loaded"]{background:repeating-conic-gradient(#d8dbe0 0 25%,#f3f4f6 0 50%) 50%/14px 14px;animation:none;}
- .ldp-lb-batch-item img[data-ldp-batch-thumb-state="failed"]{opacity:.28;animation:none;}
- .ldp-lb-batch-item:has(img[data-ldp-batch-thumb-state="failed"])::after{content:"预览加载失败";position:absolute;left:8px;right:8px;top:50%;
- color:rgba(245,247,250,.82);font-size:var(--ldp-font-sm,11px);text-align:center;transform:translateY(-12px);pointer-events:none;}
- @keyframes ldp-lb-batch-thumbnail-loading{to{background-position-x:-220%;}}
- .ldp-lb-batch-item input{position:absolute;top:9px;left:9px;width:17px;height:17px;margin:0;accent-color:#47855f;cursor:pointer;}
- .ldp-lb-batch-item span{overflow:hidden;color:rgba(245,247,250,.72);font-size:var(--ldp-font-sm,11px);text-overflow:ellipsis;white-space:nowrap;}
- .ldp-lb-batch-more{display:grid;min-height:124px;place-items:center;align-content:center;gap:8px;padding:10px;border:1px dashed rgba(140,199,255,.42);
- border-radius:8px;background:rgba(140,199,255,.045);color:rgba(185,220,255,.78);cursor:pointer;font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;}
- .ldp-lb-batch-more:hover:not(:disabled){border-color:#8cc7ff;background:rgba(140,199,255,.1);color:#d9edff;}
- .ldp-lb-batch-more:disabled{opacity:.55;cursor:wait;}.ldp-lb-batch-more .ldp-icon{width:24px;height:24px;}
- .ldp-lb-batch-progress{display:grid;gap:5px;padding:9px 16px;border-top:1px solid rgba(255,255,255,.08);}
- .ldp-lb-batch-progress-copy{display:flex;justify-content:space-between;gap:10px;
- color:rgba(245,247,250,.68);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;}
- .ldp-lb-batch-progress-track{height:6px;overflow:hidden;border-radius:999px;background:rgba(255,255,255,.12);}
- .ldp-lb-batch-progress-fill{display:block;width:var(--ldp-lb-batch-progress,0%);height:100%;border-radius:inherit;
- background:#8cc7ff;transition:width .18s ease;}
- .ldp-lb-batch-actions{justify-content:flex-end;padding:10px 16px 14px;border-top:1px solid rgba(255,255,255,.1);}
- .ldp-lb-batch-status{min-width:0;margin-right:auto;color:rgba(245,247,250,.65);font-size:var(--ldp-font-ui,12px);}
- .ldp-lb-batch-cancel,.ldp-lb-batch-download{min-height:34px;padding:6px 12px;border-radius:7px;cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;}
- .ldp-lb-batch-cancel{border:1px solid rgba(255,255,255,.16);background:transparent;color:#fff;}
- .ldp-lb-batch-download{border:1px solid transparent;background:#47855f;color:#fff;}.ldp-lb-batch-download:disabled{opacity:.45;cursor:default;}
- @media (max-width:700px){
- .ldp-lightbox{--ldp-lb-comments-width:clamp(min(240px,50%),var(--ldp-lb-comments-width-preferred),50%);}
- .ldp-lb-toolbar{height:50px;padding:6px 8px;gap:4px;}
- .ldp-lb-count{min-width:42px;}
- .ldp-lb-btn{width:34px;height:34px;}
- .ldp-lb-stage{padding:12px 46px;}
- .ldp-lb-image{max-height:calc(100vh - 154px);}
- .ldp-lb-nav{width:36px;height:48px;}
- .ldp-lb-prev{left:5px;}.ldp-lb-next{right:5px;}
- .ldp-lb-comments{position:absolute;z-index:5;top:0;right:0;bottom:0;width:var(--ldp-lb-comments-width);
- box-shadow:-12px 0 34px rgba(0,0,0,.22);transition:transform .18s ease;}
- .ldp-lb-comments-collapsed .ldp-lb-comments{width:var(--ldp-lb-comments-width);border-left-color:var(--ldp-divider-line-color,#e5e5e5);transform:translateX(100%);}
- .ldp-lightbox:not(.ldp-lb-comments-collapsed) .ldp-lb-next{right:5px;}
- .ldp-lb-filmstrip{padding:6px 6px 8px;}
- .ldp-lb-thumb{width:54px;height:48px;}
- .ldp-lb-batch-grid{grid-template-columns:repeat(auto-fill,minmax(86px,1fr));}
- }
- @media (prefers-reduced-motion:reduce){
- .ldp-reader-window-capsule,.ldp-reader-window-capsule::before,.ldp-reader-window-capsule > *,.ldp-reader-lock-button,.ldp-reader-pin-button,
- html .ldp-reader-switch-target,html.ldp-reader-embedded-left #ember-app,html.ldp-reader-embedded-right #ember-app,
- html.ldp-reader-embedded-left .d-header,html.ldp-reader-embedded-right .d-header,.ldp-reader-embedded .ldp-modal,
- .ldp-topic-edit-category-trigger .ldp-icon,.ldp-color-group-head,
- .ldp-settings-popover input[type="range"]::-webkit-slider-thumb,
- .ldp-settings-popover input[type="range"]::-moz-range-thumb,
- .ldp-setting-switch-track,.ldp-setting-switch-track::after,.ldp-color-control input[type="color"],
- .ldp-font-family-trigger .ldp-icon,.ldp-setting-help-tooltip,.ldp-topic-timeline-preview,.ldp-topic-timeline-count,
- .ldp-topic-timeline-cursor,.ldp-topic-timeline-cursor > span,.ldp-topic-timeline-cursor > span::before,
- .ldp-nested-rail-toggle::before,.ldp-boost-item-action{transition:none;}
- .ldp-lb-btn,.ldp-lb-nav,.ldp-lb-strip-progress > span,.ldp-lb-comments,.ldp-lb-source summary .ldp-icon,
- .ldp-avatar-viewer-progress-fill,.ldp-topic-timeline-cursor,.ldp-topic-timeline-cursor > span{transition:none;animation:none;}
- }
+ .ldp-user{font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);opacity:.6;min-width:0;max-width:min(14rem,25%);
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-new-user-badge{display:inline-flex;align-items:center;justify-content:center;gap:4px;flex:none;
+ min-height:21px;padding:2px 7px 2px 5px;box-sizing:border-box;
+ border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 42%,transparent);
+ border-radius:999px;background:color-mix(in srgb,var(--tertiary,#47855f) 18%,var(--secondary,#fff));
+ color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1;white-space:nowrap;}
+ .ldp-new-user-badge .ldp-icon{width:12px;height:12px;stroke-width:2.35;}
+ .ldp-new-user-badge:hover{background:color-mix(in srgb,var(--tertiary,#47855f) 27%,var(--secondary,#fff));}
+ .ldp-time{position:relative;font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);white-space:nowrap;flex:none;}
+ .ldp-time-relative{opacity:.55;}
+ .ldp-time[data-exact-time]::after{content:attr(data-exact-time);position:absolute;left:calc(100% + 6px);top:50%;z-index:5;
+ transform:translateY(-50%);display:none;color:inherit;font:inherit;line-height:inherit;opacity:.55;
+ white-space:nowrap;pointer-events:none;}
+ .ldp-time[data-exact-time]:hover::after{display:block;}
+ .ldp-floor{font-size:var(--ldp-font-sm,11px);font-weight:var(--ldp-reader-font-weight-base,400);line-height:1.35;color:var(--primary-medium,#777);
+ opacity:1;margin-left:auto;padding-left:8px;white-space:nowrap;}
+ .ldp-post-read-state{display:inline-flex;flex:none;width:12px;height:12px;align-items:center;justify-content:center;
+ color:var(--tertiary,#47855f);line-height:1;}
+ .ldp-post-read-state .ldp-icon{width:9px;height:9px;stroke-width:2.6;}
+ .ldp-post-read-state.is-read{opacity:.72;}
+ .ldp-post-read-state.is-unread .ldp-icon{stroke:none;fill:currentColor;}
+ .ldp-post-read-state.is-confirming{animation:ldp-post-read-confirm 1.25s ease-in-out both;}
+ @keyframes ldp-post-read-confirm{
+ 0%,100%{opacity:1;transform:scale(1);}
+ 50%{opacity:.12;transform:scale(.72);}
+ }
+ @media (prefers-reduced-motion:reduce){
+ .ldp-post-read-state.is-confirming{animation:none;}
+ }
+ .ldp-content{overflow-x:auto;color:var(--ldp-post-font-color,var(--primary,#222));font-family:var(--ldp-post-font-family,inherit)!important;
+ font-size:var(--ldp-post-font-size,14px);font-weight:var(--ldp-post-font-weight,400);padding-inline:8px;box-sizing:border-box;}
+ .ldp-comments .ldp-post > .ldp-post-head{margin-bottom:0;}
+ .ldp-comments .ldp-post > .ldp-content:not(.ldp-content-pending){padding-block:12px;}
+ .ldp-comments .ldp-post > .ldp-content > :first-child{margin-top:0;}
+ .ldp-comments .ldp-post > .ldp-content > :last-child{margin-bottom:0;}
+ .ldp-content a,.ldp-content a:visited,.ldp-content a:hover,.ldp-content a:active{
+ color:var(--d-link-color,var(--tertiary,#08c));text-decoration:var(--d-link-text-decoration,none);cursor:pointer;}
+ .ldp-content a.inline-onebox{display:inline-flex;max-width:min(100%,72ch);align-items:flex-start;gap:.28em;
+ line-height:1.5;vertical-align:top;}
+ .ldp-content a.inline-onebox > :is(svg,.svg-icon){width:.84em;height:.84em;flex:none;margin-top:.3em;opacity:.8;}
+ .ldp-content a.inline-onebox > .ldp-inline-onebox-label{min-width:0;overflow:hidden;
+ text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-content a.inline-onebox > .ldp-link-click-count{flex:none;margin-inline-start:0;margin-top:.12em;}
+ .ldp-content .ldp-link-click-count{display:inline-flex;min-width:1.55em;height:1.55em;align-items:center;justify-content:center;
+ margin-inline-start:.32em;padding:0 .42em;box-sizing:border-box;border-radius:999px;
+ background:color-mix(in srgb,var(--primary,#222) 10%,var(--secondary,#fff));color:var(--primary-high,#444);
+ font-size:.78em;font-weight:650;line-height:1;font-variant-numeric:tabular-nums;vertical-align:.08em;white-space:nowrap;}
+ .ldp-content h1,.ldp-content h2,.ldp-content h3,.ldp-content h4,.ldp-content h5,.ldp-content h6{
+ margin-top:0;margin-bottom:.5rem;font-family:inherit!important;font-weight:var(--heading-font-weight,bold);}
+ .ldp-content aside.onebox{display:block;width:min(100%,760px);max-width:100%;margin:.85em 0;padding:14px 16px;
+ overflow:hidden;box-sizing:border-box;border:1px solid var(--primary-low,#e5e5e5);border-radius:7px;
+ background:var(--secondary,#fff);color:var(--primary,#222);
+ box-shadow:0 1px 2px rgba(0,0,0,.12),0 4px 8px rgba(0,0,0,.08);}
+ .ldp-content aside.onebox > header.source{display:flex;align-items:center;gap:6px;min-width:0;
+ margin:0 0 11px;color:var(--primary-medium,#777);font-size:.9em;font-weight:600;line-height:1.35;}
+ .ldp-content aside.onebox > header.source :is(img,.site-icon){width:18px!important;height:18px!important;
+ max-width:18px!important;margin:0!important;border-radius:4px;object-fit:cover;cursor:default;zoom:1;flex:none;}
+ .ldp-content aside.onebox > header.source a{min-width:0;overflow:hidden;color:inherit;
+ text-overflow:ellipsis;white-space:nowrap;text-decoration:none;}
+ .ldp-content aside.onebox > article.onebox-body{min-width:0;margin:0;}
+ .ldp-content aside.onebox > article.onebox-body > :is(.aspect-image,.thumbnail),
+ .ldp-content aside.onebox > article.onebox-body > :is(.aspect-image,.thumbnail) img,
+ .ldp-content aside.onebox > article.onebox-body > img.thumbnail{display:none!important;}
+ .ldp-content aside.onebox[data-ldp-github-onebox-normalized="1"] .onebox-body :is(h3,p){
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-content aside.onebox .onebox-body h3{margin:0;font-size:1em;
+ line-height:1.38;text-wrap:pretty;}
+ .ldp-content aside.onebox .onebox-body h3 a{font-weight:650;text-decoration:none;}
+ .ldp-content aside.onebox :is(header.source,.onebox-body h3) :is(svg,.svg-icon){width:.9em;height:.9em;flex:none;}
+ .ldp-content aside.onebox .onebox-body p{margin-top:6px;margin-bottom:0;color:var(--primary-medium,#666);
+ font-size:.92em;line-height:1.5;}
+ .ldp-content aside.onebox .onebox-metadata{margin:6px 0 0;color:var(--primary-medium,#777);font-size:.8em;}
+ .ldp-content aside.onebox .ldp-link-click-count{min-width:1.4em;height:1.4em;padding:0 .36em;font-size:.72em;}
+ .ldp-content hr{display:block;height:1px;margin:1em 0;padding:0;border:0;
+ border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-content blockquote{box-sizing:border-box;margin:1em 0;padding:.75em 1em;
+ border:0;border-inline-start:var(--ldp-quote-line-width,1px) solid var(--ldp-quote-line-color,#d7d7d7);
+ border-radius:0 6px 6px 0;
+ background:color-mix(in srgb,var(--primary,#222) 3%,var(--secondary,#fff));
+ color:var(--primary,#222);line-height:1.65;}
+ .ldp-content blockquote > :first-child{margin-top:0;}
+ .ldp-content blockquote > :last-child{margin-bottom:0;}
+ .ldp-content blockquote.ldp-callout{--ldp-callout-color:#2f78ef;position:relative;margin:1em 0;
+ padding:.8em 1em .8em 2.8em;border:0;border-radius:6px;
+ background:color-mix(in srgb,var(--ldp-callout-color) 12%,var(--secondary,#fff));color:var(--primary,#222);}
+ .ldp-content blockquote:is(.ldp-callout--success,.ldp-callout--check,.ldp-callout--done){--ldp-callout-color:#2f9e64;}
+ .ldp-content blockquote:is(.ldp-callout--question,.ldp-callout--help,.ldp-callout--faq){--ldp-callout-color:#d99c00;}
+ .ldp-content blockquote:is(.ldp-callout--warning,.ldp-callout--caution,.ldp-callout--attention){--ldp-callout-color:#e87524;}
+ .ldp-content blockquote.ldp-callout--example{--ldp-callout-color:#7658d6;}
+ .ldp-content blockquote:is(.ldp-callout--failure,.ldp-callout--fail,.ldp-callout--missing,
+ .ldp-callout--danger,.ldp-callout--error,.ldp-callout--bug){--ldp-callout-color:#d64545;}
+ .ldp-content blockquote:is(.ldp-callout--quote,.ldp-callout--cite){--ldp-callout-color:#6f7782;}
+ .ldp-content blockquote.ldp-callout > .ldp-callout-icon{position:absolute;left:1em;top:.9em;
+ display:inline-flex;width:1.1em;height:1.1em;align-items:center;justify-content:center;color:var(--ldp-callout-color);}
+ .ldp-content blockquote.ldp-callout > .ldp-callout-icon .ldp-icon{width:100%;height:100%;stroke:currentColor;fill:none;stroke-width:2;}
+ .ldp-content blockquote.ldp-callout > .ldp-callout-icon + *{margin-top:0;color:var(--ldp-callout-color);font-weight:700;}
+ .ldp-content blockquote.ldp-callout > :last-child{margin-bottom:0;}
+ .ldp-content blockquote.ldp-callout--foldable{padding-right:3em;}
+ .ldp-content .ldp-callout-toggle{position:absolute;right:.65em;top:.55em;display:inline-flex;width:1.8em;height:1.8em;
+ align-items:center;justify-content:center;padding:0;border:0;border-radius:5px;background:transparent;
+ color:var(--ldp-callout-color);cursor:pointer;}
+ .ldp-content .ldp-callout-toggle:hover{background:color-mix(in srgb,var(--ldp-callout-color) 12%,transparent);}
+ .ldp-content .ldp-callout-body{margin-top:.6em;}
+ .ldp-content aside.ldp-post-quote{position:relative;box-sizing:border-box;width:100%;height:auto!important;max-height:none!important;
+ margin:1em 0;padding:12px 14px;overflow:visible;
+ border-left:var(--ldp-quote-line-emphasis-width,5px) solid var(--ldp-quote-line-color,#d7d7d7);background:var(--d-post-aside-background,var(--primary-very-low,#f6f6f6));}
+ .ldp-content aside.ldp-post-quote > .ldp-quote-title{display:flex;align-items:center;gap:8px;min-height:28px;
+ margin:0 0 10px;padding-right:70px;color:var(--primary-medium,#666);font-size:1em;line-height:1.35;}
+ .ldp-content aside.ldp-post-quote > .ldp-quote-title :is(img,.avatar){width:24px!important;height:24px!important;
+ margin:0!important;border-radius:50%;object-fit:cover;}
+ .ldp-content aside.ldp-post-quote > .ldp-quote-title a{color:inherit;font-weight:650;text-decoration:none;}
+ .ldp-content aside.ldp-post-quote > blockquote{height:auto!important;max-height:none!important;margin:0;padding:0;
+ overflow:visible!important;border:0;background:transparent;}
+ .ldp-content aside.ldp-post-quote.ldp-quote-expanded > blockquote aside.ldp-post-quote{display:block!important;}
+ .ldp-content aside.ldp-post-quote.ldp-quote-expanded{padding-bottom:52px;}
+ .ldp-content .ldp-quote-collapse-bottom{position:absolute;right:14px;bottom:12px;display:inline-flex;width:30px;height:30px;
+ align-items:center;justify-content:center;margin:0;padding:0;border:0;border-radius:6px;
+ background:color-mix(in srgb,var(--primary,#222) 5%,transparent);color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-content .ldp-quote-collapse-bottom:hover{background:color-mix(in srgb,var(--primary,#222) 9%,transparent);color:var(--primary,#222);}
+ .ldp-content aside.ldp-post-quote > .ldp-quote-title > .ldp-quote-controls{position:absolute;top:10px;right:12px;
+ display:flex;align-items:center;gap:4px;margin:0;}
+ .ldp-content .ldp-quote-controls button{display:inline-flex;width:28px;height:28px;align-items:center;justify-content:center;
+ margin:0;padding:0;border:0;border-radius:6px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-content .ldp-quote-controls button:hover{background:color-mix(in srgb,var(--primary,#222) 6%,transparent);color:var(--primary,#222);}
+ .ldp-content .ldp-quote-controls button:disabled{cursor:wait;opacity:.45;}
+ .ldp-content .ldp-quote-controls .ldp-quote-jump{background:color-mix(in srgb,var(--tertiary,#47855f) 13%,transparent);color:var(--tertiary,#47855f);}
+ .ldp-content .ldp-quote-collapse-bottom .ldp-icon,
+ .ldp-content .ldp-quote-controls .ldp-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2.2;}
+ .ldp-content mark.ldp-quote-match{padding:0 .08em;border-radius:3px;background:color-mix(in srgb,#ffd85a 68%,transparent);
+ color:inherit;box-decoration-break:clone;-webkit-box-decoration-break:clone;box-shadow:0 0 0 2px color-mix(in srgb,#d99c00 24%,transparent);cursor:pointer;}
+ .ldp-content mark.ldp-expanded-quote-match{padding:0 .08em;border-radius:3px;
+ background:color-mix(in srgb,#ffd85a 68%,transparent);color:inherit;box-decoration-break:clone;
+ -webkit-box-decoration-break:clone;box-shadow:0 0 0 2px color-mix(in srgb,#d99c00 24%,transparent);}
+ .ldp-content mark.ldp-quote-match.ldp-quote-match-muted{background:transparent;box-shadow:none;
+ text-decoration-line:underline;text-decoration-style:dotted;text-decoration-thickness:1.5px;
+ text-decoration-color:color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);text-underline-offset:.18em;}
+ .ldp-content mark.ldp-quote-match-end{position:relative;}
+ .ldp-content mark.ldp-quote-match-end:focus{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);outline-offset:3px;}
+ .ldp-quote-highlight-hint{position:fixed;z-index:2147483646;display:none;
+ align-items:center;gap:2px;padding:3px;pointer-events:auto;}
+ .ldp-quote-highlight-hint.ldp-quote-hint-visible{display:flex;}
+ .ldp-quote-highlight-hint button{margin:0;padding:3px 6px;border:0;border-radius:5px;background:transparent;
+ color:inherit;font:inherit;line-height:1.35;white-space:nowrap;cursor:pointer;}
+ .ldp-quote-highlight-hint button:hover,
+ .ldp-quote-highlight-hint button:focus-visible{
+ background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff));
+ color:var(--tertiary,#47855f);outline:none;}
+ .ldp-quote-highlight-return{border-left:1px solid var(--primary-low,#ddd)!important;border-radius:0 5px 5px 0!important;}
+ .ldp-content ul,.ldp-content ol,.ldp-content dd{clear:both;margin:1em 0 1em 1.25em;padding-inline-start:1.25em;}
+ .ldp-content li > ul,.ldp-content li > ol{margin:0;}
+ .ldp-content code,.ldp-content pre,.ldp-content kbd{font-family:var(--d-font-family--monospace,monospace);}
+ .ldp-content table{max-width:100%;border-collapse:collapse;}
+ .ldp-content.ldp-content-pending{position:relative;min-height:var(--ldp-content-placeholder-height,72px);
+ overflow:hidden;contain:layout paint;pointer-events:none;}
+ .ldp-content.ldp-content-pending::before{content:"";display:block;width:min(78%,620px);height:52px;
+ margin:7px 0;border-radius:4px;opacity:.52;background:
+ linear-gradient(var(--primary-low,#e5e5e5) 0 0) 0 0/72% 8px no-repeat,
+ linear-gradient(var(--primary-low,#e5e5e5) 0 0) 0 21px/100% 8px no-repeat,
+ linear-gradient(var(--primary-low,#e5e5e5) 0 0) 0 42px/56% 8px no-repeat;}
+ :is(.ldp-content,.ldp-solved-excerpt) img:not(.emoji){max-width:100%;height:auto;cursor:zoom-in;border-radius:4px;
+ zoom:var(--ldp-image-zoom,1);}
+ .ldp-content img.emoji{max-height:1lh;width:auto;object-fit:contain;}
+ .ldp-content svg.svg-icon{display:inline-block;width:1em;height:1em;vertical-align:-.125em;fill:currentColor;}
+ .ldp-content .hashtag-cooked{display:inline-flex;align-items:center;max-width:100%;padding:.06em .45em .08em;
+ border-radius:999px;background:color-mix(in srgb,var(--primary,#222) 9%,transparent);color:var(--primary,#222)!important;
+ line-height:1.35;vertical-align:-.12em;text-decoration:none!important;}
+ .ldp-content .hashtag-cooked > a{display:inline-flex;align-items:center;max-width:100%;color:inherit!important;text-decoration:none!important;}
+ .ldp-content .hashtag-cooked:hover{background:color-mix(in srgb,var(--primary,#222) 14%,transparent);}
+ .ldp-content .hashtag-cooked:focus-visible{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);outline-offset:2px;}
+ .ldp-content .hashtag-cooked .hashtag-icon-placeholder{display:none;}
+ .ldp-content .hashtag-cooked img.emoji,.ldp-content .hashtag-cooked svg{width:.93em;height:.93em;
+ margin-right:var(--space-1,.25em);vertical-align:-.125em;}
+ :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper{display:block;max-width:100%;line-height:0;}
+ :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper > a.lightbox{display:inline-block;max-width:100%;line-height:0;}
+ :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper img:not(.emoji){display:block;}
+ :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper .meta{display:none;}
+ .ldp-content .ldp-media-carousel{max-width:100%;margin:1em 0;}
+ .ldp-content .ldp-media-carousel-track{display:grid;grid-auto-flow:column;grid-auto-columns:100%;gap:10px;
+ max-width:100%;overflow-x:auto;overscroll-behavior-inline:contain;scroll-snap-type:x mandatory;scrollbar-width:thin;}
+ .ldp-content .ldp-media-carousel-track > .lightbox-wrapper{display:grid;min-width:0;place-items:center;
+ overflow:hidden;border-radius:10px;background:var(--primary-very-low,#f5f5f5);scroll-snap-align:start;scroll-snap-stop:always;}
+ .ldp-content .ldp-media-carousel-track > .lightbox-wrapper > a.lightbox{display:grid;width:100%;place-items:center;}
+ .ldp-content .ldp-media-carousel-track img:not(.emoji){width:100%;max-height:min(72vh,760px);object-fit:contain;border-radius:10px;}
+ .ldp-content .ldp-media-carousel-controls{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:8px;}
+ .ldp-content .ldp-media-carousel-controls button{display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;
+ padding:0;border:1px solid var(--primary-low,#ddd);border-radius:999px;background:var(--secondary,#fff);color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-content .ldp-media-carousel-controls button:hover:not(:disabled){border-color:var(--tertiary,#47855f);color:var(--tertiary,#47855f);}
+ .ldp-content .ldp-media-carousel-controls button:disabled{opacity:.32;cursor:default;}
+ .ldp-native-reader-trigger .ldp-icon,.ldp-native-boost-menu .discourse-boosts__emoji-btn .ldp-icon,
+ .ldp-collection-delete .ldp-icon,.ldp-reader-history-nav .ldp-icon,
+ .ldp-content .ldp-media-carousel-controls .ldp-icon{width:17px;height:17px;}
+ .ldp-content .ldp-media-carousel-status{min-width:3.5em;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);text-align:center;font-variant-numeric:tabular-nums;}
+ .ldp-content video{display:block;width:min(100%,960px);height:auto;max-height:min(72vh,760px);margin:1em auto;
+ border-radius:10px;background:#000;box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 12%,transparent);}
+ .ldp-content audio{display:block;width:min(100%,760px);height:42px;margin:.9em 0;}
+ .ldp-content iframe{max-width:100%;border:0;}
+ .ldp-content iframe.ldp-bilibili-player{display:block;width:min(100%,960px);height:auto;aspect-ratio:16/9;margin:1em auto;
+ border-radius:10px;background:#000;box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 12%,transparent);}
+ .ldp-content .ldp-image-retry{display:inline-flex;align-items:center;gap:6px;margin:8px 0;padding:7px 11px;
+ border:1px solid var(--primary-low,#d9d9d9);border-radius:7px;background:var(--secondary,#fff);
+ color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-base,13px);line-height:1.2;cursor:pointer;vertical-align:middle;}
+ .ldp-content .ldp-image-retry:hover{border-color:var(--tertiary,#47855f);color:var(--tertiary,#47855f);}
+ .ldp-content .ldp-image-retry:disabled{opacity:.55;cursor:wait;}
+ .ldp-collection-action .ldp-icon,.ldp-settings-close .ldp-icon,.ldp-user-info-title-refresh .ldp-icon,
+ .ldp-about-feature-icon .ldp-icon,.ldp-content .ldp-image-retry .ldp-icon{width:16px;height:16px;}
+ .ldp-content .discourse-post-event{display:none;}
+ .ldp-code-block{position:relative;max-width:100%;margin:1em 0;}
+ .ldp-code-block > pre{margin:0;}
+ .ldp-code-block-collapsible:not(.ldp-code-block-expanded) > pre{max-height:10lh;overflow-x:auto;overflow-y:hidden;}
+ .ldp-code-block-actions{position:absolute;z-index:2;top:7px;right:7px;display:flex;align-items:center;gap:4px;}
+ .ldp-code-block-action{display:inline-flex;width:28px;height:28px;align-items:center;justify-content:center;
+ margin:0;padding:0;border:1px solid color-mix(in srgb,var(--primary,#222) 8%,transparent);border-radius:6px;
+ background:color-mix(in srgb,var(--tertiary,#47855f) 11%,var(--secondary,#fff));color:var(--primary-high,#444);cursor:pointer;}
+ .ldp-code-block-action:hover,.ldp-code-block-action:focus-visible{border-color:color-mix(in srgb,var(--tertiary,#47855f) 35%,transparent);
+ background:color-mix(in srgb,var(--tertiary,#47855f) 18%,var(--secondary,#fff));color:var(--tertiary,#47855f);outline:none;}
+ .ldp-code-block-action .ldp-icon{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;}
+ .ldp-content pre{overflow:auto;background:var(--primary-very-low,#f6f6f6);
+ padding:10px;border-radius:6px;font-size:inherit;}
+ .ldp-code-preview-layer{position:absolute;z-index:30;inset:0;display:flex;flex-direction:column;min-width:0;min-height:0;
+ background:var(--secondary,#fff);color:var(--primary,#222);}
+ .ldp-code-preview-head{display:flex;flex:none;align-items:center;justify-content:space-between;gap:12px;min-height:50px;
+ padding:8px 12px 8px 16px;border-bottom:1px solid var(--primary-low,#ddd);background:var(--header_background,var(--secondary,#fff));}
+ .ldp-code-preview-head strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--ldp-font-md,14px);}
+ .ldp-code-preview-actions{display:flex;align-items:center;gap:5px;}
+ .ldp-code-preview-body{flex:1;min-width:0;min-height:0;padding:12px;overflow:auto;background:var(--primary-very-low,#f6f6f6);}
+ .ldp-code-preview-body:has(.ldp-code-preview-editor){padding:0;overflow:hidden;}
+ .ldp-code-preview-body pre,.ldp-code-preview-editor{box-sizing:border-box;margin:0;border:0;background:transparent;
+ color:inherit;font-family:var(--ldp-code-preview-font-family,"Cascadia Code","JetBrains Mono",Consolas,"Liberation Mono",monospace);
+ font-size:var(--ldp-code-preview-font-size,var(--ldp-font-base,13px));font-weight:var(--ldp-code-preview-font-weight,400);
+ font-variant-ligatures:none;line-height:var(--ldp-code-preview-line-height,1.65);tab-size:var(--ldp-code-preview-tab-size,4);white-space:pre;}
+ .ldp-code-preview-body pre *{font-family:inherit!important;font-size:inherit!important;line-height:inherit!important;}
+ .ldp-code-preview-body pre{min-width:max-content;}
+ .ldp-code-preview-editor{display:block;width:100%;height:100%;padding:12px;overflow:auto;overscroll-behavior:contain;
+ resize:none;outline:none;touch-action:auto;}
+ .ldp-content .ldp-reader-poll{margin:14px 0;padding:14px 16px;border:1px solid var(--primary-low,#ddd);
+ border-radius:8px;background:var(--secondary,#fff);color:var(--primary,#222);}
+ .ldp-poll-title{margin:0 0 12px;font-size:var(--ldp-font-xl,16px);font-weight:700;line-height:1.45;}
+ .ldp-poll-options,.ldp-poll-results{display:grid;gap:8px;margin:0;padding:0;list-style:none;}
+ .ldp-poll-option{display:flex;align-items:flex-start;gap:9px;padding:9px 10px;border:1px solid var(--primary-low,#ddd);
+ border-radius:7px;cursor:pointer;line-height:1.45;background:var(--secondary,#fff);}
+ .ldp-poll-option:hover{border-color:color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#ddd));
+ background:var(--primary-very-low,#f7f7f7);}
+ .ldp-poll-option:has(input:checked){border-color:var(--tertiary,#47855f);
+ background:color-mix(in srgb,var(--tertiary,#47855f) 8%,var(--secondary,#fff));}
+ .ldp-poll-option input{flex:none;margin:3px 0 0;accent-color:var(--tertiary,#47855f);}
+ .ldp-poll-option-text{min-width:0;overflow-wrap:anywhere;}
+ .ldp-poll-option-text p{display:inline;margin:0;}
+ .ldp-poll-result{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:5px 12px;align-items:center;}
+ .ldp-poll-result-label{min-width:0;overflow-wrap:anywhere;line-height:1.4;}
+ .ldp-poll-result-label p{display:inline;margin:0;}
+ .ldp-poll-result-value{font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);white-space:nowrap;}
+ .ldp-poll-result-track{grid-column:1/-1;height:8px;border-radius:999px;overflow:hidden;
+ background:var(--primary-low,#e5e5e5);}
+ .ldp-poll-result-bar{display:block;height:100%;min-width:0;border-radius:inherit;background:var(--tertiary,#47855f);}
+ .ldp-poll-footer{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:13px;}
+ .ldp-poll-meta{margin-right:auto;font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);}
+ .ldp-poll-button{min-height:32px;padding:5px 11px;border:1px solid var(--primary-low,#d5d5d5);border-radius:6px;
+ background:var(--secondary,#fff);color:inherit;cursor:pointer;font:inherit;font-size:var(--ldp-font-base,13px);}
+ .ldp-poll-button:hover:not(:disabled){background:var(--primary-very-low,#f2f2f2);}
+ .ldp-poll-button-primary{border-color:var(--tertiary,#47855f);background:var(--tertiary,#47855f);color:#fff;}
+ .ldp-poll-button-primary:hover:not(:disabled){filter:brightness(.95);background:var(--tertiary,#47855f);}
+ .ldp-poll-button:disabled,.ldp-reader-poll[aria-busy="true"] .ldp-poll-button,
+ .ldp-reader-poll[aria-busy="true"] .ldp-poll-option{cursor:wait;opacity:.55;pointer-events:none;}
+ .ldp-poll-note{margin:10px 0 0;font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);}
+ .ldp-floor-preview-post .ldp-reader-poll input,.ldp-floor-preview-post .ldp-reader-poll button{pointer-events:none;}
+ .ldp-topic-vote{display:inline-flex;align-items:center;gap:6px;margin-left:6px;padding:3px 9px;
+ border:1px solid var(--primary-low,#ddd);border-radius:999px;background:var(--secondary,#fff);color:inherit;
+ cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;white-space:nowrap;}
+ .ldp-topic-vote.on{border-color:var(--tertiary,#47855f);background:color-mix(in srgb,var(--tertiary,#47855f) 11%,var(--secondary,#fff));color:var(--tertiary,#47855f);}
+ .ldp-topic-vote:disabled{cursor:wait;opacity:.55;}
+ .ldp-special-badges{display:flex;flex-wrap:wrap;gap:5px;margin:0 8px 7px;}
+ .ldp-special-badge{padding:2px 7px;border-radius:999px;background:var(--primary-very-low,#f3f3f3);
+ color:var(--primary-medium,#666);font-size:var(--ldp-font-sm,11px);font-weight:700;}
+ .ldp-post-head > .ldp-hidden-badge{flex:none;white-space:nowrap;}
+ .ldp-special-badge.warn{background:#fff1d6;color:#8a5500}.ldp-special-badge.danger{background:#ffe5e5;color:#a12b2b}
+ .ldp-special-notice{margin:8px;padding:9px 11px;border-left:var(--ldp-reply-line-emphasis-width,3px) solid var(--ldp-reply-line-color,#47855f);
+ border-radius:4px;background:var(--primary-very-low,#f5f5f5);font-size:var(--ldp-font-base,13px);line-height:1.5;}
+ .ldp-system-action{display:flex;align-items:center;gap:8px;margin:8px;padding:9px 11px;
+ border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 28%,var(--primary-low,#ddd));border-radius:6px;
+ background:color-mix(in srgb,var(--tertiary,#47855f) 7%,var(--secondary,#fff));color:var(--primary-high,#444);
+ font-size:var(--ldp-font-base,13px);font-weight:650;line-height:1.5;}
+ .ldp-system-action .ldp-icon{width:17px;height:17px;flex:none;color:var(--tertiary,#47855f);stroke-width:2;}
+ .ldp-system-action-user{color:var(--tertiary,#47855f);font-weight:750;text-decoration:none;}
+ .ldp-system-action-user:hover,.ldp-system-action-user:focus-visible{text-decoration:underline;}
+ .ldp-post.ldp-system-action-compact{display:flex;align-items:center;gap:8px;min-width:0;padding-block:6px;
+ overflow-x:auto;overflow-y:hidden;white-space:nowrap;}
+ .ldp-post.ldp-system-action-compact > .ldp-post-head{display:contents;}
+ .ldp-post.ldp-system-action-compact :is(.ldp-author,.ldp-user){max-width:none;flex:none;overflow:visible;
+ text-overflow:clip;white-space:nowrap;}
+ .ldp-post.ldp-system-action-compact > .ldp-post-head > :is(.ldp-user,.ldp-new-user-badge,.ldp-time,.ldp-floor),
+ .ldp-post.ldp-system-action-compact > .ldp-reactions{color:var(--primary-medium,#777);opacity:.62;}
+ .ldp-post.ldp-system-action-compact > .ldp-post-head > .ldp-new-user-badge{
+ border-color:color-mix(in srgb,var(--primary,#222) 14%,transparent);
+ background:color-mix(in srgb,var(--primary,#222) 4%,var(--secondary,#fff));font-weight:600;}
+ .ldp-post.ldp-system-action-compact > .ldp-reactions:hover,
+ .ldp-post.ldp-system-action-compact > .ldp-reactions:focus-within{opacity:.86;}
+ .ldp-post.ldp-system-action-compact > .ldp-system-action{order:1;flex:none;margin:0;padding:0;
+ border:0;border-radius:0;background:transparent;color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-ui,12px);font-weight:500;white-space:nowrap;}
+ .ldp-post.ldp-system-action-compact > .ldp-system-action .ldp-icon{color:inherit;opacity:.78;}
+ .ldp-post.ldp-system-action-compact > .ldp-system-action .ldp-system-action-user{color:inherit;font-weight:600;}
+ .ldp-post.ldp-system-action-compact > .ldp-post-head > .ldp-floor{order:2;flex:none;margin-left:auto;}
+ .ldp-post.ldp-system-action-compact > .ldp-post-head > .ldp-post-read-state{order:2;}
+ .ldp-post.ldp-system-action-compact > .ldp-content{display:none;}
+ .ldp-solved-card{margin:14px 8px 4px;border:1px solid color-mix(in srgb,var(--success,#3f9625) 42%,var(--primary-low,#ddd));
+ border-radius:8px;overflow:hidden;background:var(--secondary,#fff);}
+ .ldp-solved-head{display:flex;align-items:center;gap:7px;padding:8px 12px;background:var(--success,#3f9625);
+ color:#fff;font-size:var(--ldp-font-md,14px);font-weight:750;line-height:1.3;}
+ .ldp-solved-head .ldp-icon{width:16px;height:16px;flex:none;}
+ .ldp-solved-body{padding:10px 12px 11px;}
+ .ldp-solved-body + .ldp-solved-body{border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-solved-author-row{display:flex;align-items:center;flex-wrap:wrap;gap:7px;min-width:0;margin-bottom:7px;font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#666);}
+ .ldp-solved-avatar{width:26px;height:26px;border-radius:50%;flex:none;}
+ .ldp-solved-author{min-width:0;font-weight:700;color:var(--primary,#222);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-solved-username{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-solved-floor{margin-left:auto;padding:0;border:0;background:transparent;flex:none;color:var(--success,#3f9625);
+ cursor:pointer;font:inherit;font-weight:var(--ldp-reader-font-weight-base,400);}
+ .ldp-solved-floor:hover{text-decoration:underline;}
+ .ldp-solved-excerpt{max-width:75ch;font-size:var(--ldp-font-base,13px);line-height:1.55;overflow-wrap:anywhere;}
+ .ldp-solved-excerpt.ldp-content{padding-inline:0;overflow-x:visible;}
+ .ldp-solved-excerpt > :first-child{margin-top:0}.ldp-solved-excerpt > :last-child{margin-bottom:0}
+ .ldp-solved-jump{margin-top:8px;padding:0;border:0;background:transparent;color:var(--success,#3f9625);
+ cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;}
+ .ldp-solved-jump:hover{text-decoration:underline;}
+ .ldp-post.ldp-whisper{background:color-mix(in srgb,#7b61a8 7%,var(--secondary,#fff));}
+ .ldp-post.ldp-post-voting-answer{padding-left:12px;}
+ .ldp-post.ldp-system-post:not(.ldp-has-reactions) > .ldp-reactions,
+ .ldp-post.ldp-system-post > .ldp-reactions .ldp-actions,.ldp-post-voting-answer > .ldp-reactions .ldp-like,
+ .ldp-post-voting-answer > .ldp-reactions .ldp-replybtn{display:none;}
+ .ldp-pv-layout{display:grid;grid-template-columns:46px minmax(0,1fr);gap:8px;align-items:start;}
+ .ldp-pv-votes{display:flex;flex-direction:column;align-items:center;gap:3px;padding-top:4px;}
+ .ldp-pv-vote{width:32px;height:28px;border:0;border-radius:6px;background:transparent;color:var(--primary-medium,#777);
+ cursor:pointer;line-height:1}.ldp-pv-vote .ldp-icon{width:20px;height:20px;}
+ .ldp-pv-vote:hover,.ldp-pv-vote.on{background:var(--primary-very-low,#eee);color:var(--tertiary,#47855f)}
+ .ldp-pv-vote:disabled{cursor:wait;opacity:.5}.ldp-pv-score{font-weight:750;font-variant-numeric:tabular-nums;}
+ .ldp-pv-main{min-width:0}.ldp-pv-comments{margin:10px 8px 2px 54px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-pv-comment{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:7px;padding:9px 0;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-base,13px);}
+ .ldp-pv-comment-avatar{width:26px;height:26px;border-radius:50%}.ldp-pv-comment-body{min-width:0}.ldp-pv-comment-meta{font-size:var(--ldp-font-sm,11px);opacity:.62;margin-bottom:2px}
+ .ldp-pv-comment-vote,.ldp-pv-comments-toggle,.ldp-pv-comments-more,.ldp-pv-comment-submit{border:0;background:transparent;color:var(--tertiary,#47855f);cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;}
+ .ldp-pv-comment-vote,.ldp-pv-comments-toggle{display:inline-flex;align-items:center;gap:4px;}
+ .ldp-pv-comment-vote .ldp-icon,.ldp-pv-comments-toggle .ldp-icon{width:15px;height:15px;}
+ .ldp-pv-comment-vote.on{color:#c65b24}.ldp-pv-comment-form{display:flex;gap:7px;padding:9px 0}.ldp-pv-comment-input{flex:1;min-width:0;padding:7px 9px;border:1px solid var(--primary-low,#ccc);border-radius:6px;background:var(--secondary,#fff);color:inherit;font:inherit;}
+ .ldp-post.ldp-has-reactions > .ldp-reactions .ldp-like{display:none}.ldp-reactions{display:flex;align-items:center;flex-wrap:wrap;gap:5px;width:fit-content;max-width:calc(100% - 16px);min-height:30px;height:auto;margin:12px 8px 0;overflow:visible;box-sizing:border-box;}
+ .ldp-comments .ldp-post > .ldp-reactions{margin-top:0;}
+ .ldp-reaction-summary{display:flex;flex:none;align-items:center;flex-wrap:wrap;gap:6px;width:fit-content;max-width:100%;min-height:30px;height:auto;padding:4px 9px;border-radius:999px;background:color-mix(in srgb,var(--primary,#222) 2%,transparent);box-sizing:border-box;}
+ .ldp-reaction-picker-anchor{position:relative;display:inline-flex;flex:none;align-items:center;}
+ .ldp-reaction-summary .ldp-like{display:inline-flex;min-width:20px;height:20px;align-items:center;justify-content:center;gap:3px;padding:1px 2px;border:0;background:transparent;}
+ .ldp-reaction-summary .ldp-like .ldp-icon{width:16px;height:16px;}
+ .ldp-reaction-chip,.ldp-reaction-add{display:inline-flex;align-items:center;gap:3px;min-height:20px;padding:1px 2px;border:0;background:transparent;color:inherit;cursor:pointer;font:inherit;font-size:var(--ldp-font-sm,11px);}
+ .ldp-reaction-chip:hover,.ldp-reaction-add:hover{opacity:.72}.ldp-reaction-chip.on{color:var(--tertiary,#47855f);font-weight:700;}
+ .ldp-reaction-chip > span{display:inline-flex;align-items:center;font-size:14px;line-height:1;}
+ .ldp-reaction-chip img.emoji,.ldp-reaction-chip img.emoji.only-emoji{width:14px;height:14px;margin:0;object-fit:contain;}
+ .ldp-reaction-picker{position:absolute;left:0;bottom:calc(100% + 5px);z-index:3;display:flex;align-items:center;justify-content:flex-start;flex-wrap:wrap;gap:6px;width:max-content;max-width:calc(100vw - 24px);min-height:30px;height:auto;padding:4px 9px;border:0;border-radius:999px;background:color-mix(in srgb,var(--secondary,#fff) 90%,transparent);white-space:normal;box-sizing:border-box}
+ .ldp-event-card{margin:12px 8px;padding:14px;border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff);box-shadow:0 2px 10px rgba(0,0,0,.04)}
+ .ldp-event-title{margin:0 0 9px;font-size:var(--ldp-font-2xl,17px)}.ldp-event-grid{display:grid;gap:6px;font-size:var(--ldp-font-base,13px)}.ldp-event-meta{color:var(--primary-medium,#666)}
+ .ldp-event-actions{display:flex;flex-wrap:wrap;gap:7px;margin-top:12px}.ldp-event-action,.ldp-event-ics{padding:6px 10px;border:1px solid var(--primary-low,#d5d5d5);border-radius:6px;background:var(--secondary,#fff);color:inherit;text-decoration:none;cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px)}
+ .ldp-event-action.on{border-color:var(--tertiary,#47855f);background:var(--tertiary,#47855f);color:#fff}.ldp-event-action:disabled{cursor:wait;opacity:.55}
+ .ldp-children{margin-left:22px;padding-left:18px;position:relative;
+ --ldp-nested-line-color:var(--ldp-reply-line-color,#47855f);--ldp-nested-line-width:var(--ldp-reply-line-width,1px);
+ --ldp-nested-turn-rise:15px;--ldp-nested-corner-radius:var(--ldp-reply-line-radius,15px);
+ --ldp-nested-branch-y:17px;--ldp-nested-parent-drop:34px;
+ --ldp-nested-axis-left:3px;--ldp-nested-branch-width:15px;
+ --ldp-nested-parent-head-width:15px;}
+ /* Deeper inline tree levels tighten indent so multi-level threads stay readable. */
+ .ldp-children .ldp-children{margin-left:14px;padding-left:14px;
+ --ldp-nested-parent-drop:28px;--ldp-nested-branch-width:12px;--ldp-nested-parent-head-width:12px;}
+ .ldp-post[data-ldp-nest-depth="2"] > .ldp-children,
+ .ldp-post[data-ldp-nest-depth="3"] > .ldp-children,
+ .ldp-post[data-ldp-nest-depth="4"] > .ldp-children{margin-left:12px;padding-left:12px;}
+ .ldp-children-collapsed > .ldp-children{display:none;}
+ .ldp-post.ldp-has-child-branches{overflow:visible;content-visibility:visible;}
+ .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready > .ldp-children::before{content:"";position:absolute;left:var(--ldp-nested-axis-left);
+ top:calc(0px - var(--ldp-nested-parent-drop) + var(--ldp-nested-turn-rise));
+ height:calc(var(--ldp-nested-parent-drop) - var(--ldp-nested-turn-rise));border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);
+ pointer-events:none;}
+ .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready > .ldp-children::after{content:"";position:absolute;left:var(--ldp-nested-axis-left);
+ top:calc(0px - var(--ldp-nested-parent-drop));width:calc(var(--ldp-nested-parent-head-width) - var(--ldp-nested-line-width));
+ height:var(--ldp-nested-turn-rise);border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);
+ border-top:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);border-top-left-radius:var(--ldp-nested-corner-radius);
+ -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%);
+ mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%);
+ pointer-events:none;}
+ .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children::before,
+ .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children::after,
+ .ldp-post.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children > .ldp-post::before,
+ .ldp-post.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children > .ldp-post::after{display:none;}
+ .ldp-children > .ldp-post{position:relative;overflow:visible;content-visibility:visible;}
+ /* line-left is measured per post (children-axis → post-local). Fallback keeps
+ the legacy padding-left:18 assumption for the first paint before JS measures. */
+ .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post::before{content:"";position:absolute;left:var(--ldp-nested-line-left,calc(var(--ldp-nested-axis-left) - var(--ldp-nested-gutter,18px)));
+ top:0;bottom:0;border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);
+ pointer-events:none;}
+ .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post::after{content:"";position:absolute;left:var(--ldp-nested-line-left,calc(var(--ldp-nested-axis-left) - var(--ldp-nested-gutter,18px)));
+ top:calc(var(--ldp-nested-branch-y) - var(--ldp-nested-turn-rise));
+ width:calc(var(--ldp-nested-branch-width) - var(--ldp-nested-line-width));height:var(--ldp-nested-turn-rise);
+ border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);
+ border-bottom:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);border-bottom-left-radius:var(--ldp-nested-corner-radius);
+ -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%);
+ mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%);
+ pointer-events:none;}
+ .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post.ldp-nested-branch-last::before,
+ .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post.ldp-nested-branch-single::before{bottom:auto;height:calc(var(--ldp-nested-branch-y) - var(--ldp-nested-turn-rise));}
+ .ldp-nested-rail-toggle{display:none;position:absolute;z-index:4;width:20px;min-height:24px;padding:0;border:0;
+ border-radius:999px;background:transparent;color:inherit;cursor:pointer;touch-action:manipulation;}
+ .ldp-post.ldp-nested-branches-ready:not(.ldp-parent-preview-linked) > .ldp-children > .ldp-nested-rail-toggle{display:block;}
+ .ldp-nested-rail-toggle::before{content:"";position:absolute;inset:0 6px;border-radius:999px;background:transparent;
+ transition:background-color .15s ease;}
+ .ldp-nested-rail-toggle:focus-visible::before{
+ background:color-mix(in srgb,var(--ldp-reply-line-color,#47855f) 18%,transparent);}
+ .ldp-nested-rail-toggle:focus-visible{outline:none;}
+ .ldp-nested-branch-toggle{display:none;position:absolute;z-index:4;overflow:visible;pointer-events:none;}
+ .ldp-post.ldp-nested-branches-ready:not(.ldp-parent-preview-linked) > .ldp-children > .ldp-nested-branch-toggle{display:block;}
+ .ldp-nested-branch-hit-path{fill:none;stroke:transparent;stroke-width:20px;stroke-linecap:round;
+ stroke-linejoin:round;pointer-events:stroke;cursor:pointer;}
+ .ldp-children > .ldp-nested-preview{padding:5px 0 7px;line-height:1.5;border-bottom:none;
+ contain-intrinsic-size:auto 96px;}
+ .ldp-children > .ldp-nested-preview > .ldp-content{font-size:calc(var(--ldp-post-font-size,14px) * var(--ldp-nested-content-scale));}
+ /* Exclude tree shells from zoom so deeper levels do not compound shrink. */
+ .ldp-children > .ldp-nested-preview > :not(.ldp-content):not(.ldp-children):not(.ldp-collapse-replies):not(.ldp-sub-loading):not(.ldp-sub-actions){zoom:var(--ldp-nested-component-scale);}
+ .ldp-children > .ldp-nested-preview > .ldp-post-head{margin-bottom:0;flex-wrap:wrap;}
+ .ldp-children > .ldp-nested-preview .ldp-user,
+ .ldp-children > .ldp-nested-preview .ldp-time{opacity:.5;}
+ .ldp-children > .ldp-nested-preview > .ldp-collapse-replies{margin-top:2px;}
+ .ldp-nested-preview > .ldp-children > .ldp-nested-preview{--ldp-nested-content-scale:.95;--ldp-nested-component-scale:.93;}
+ .ldp-floor.ldp-jump-self,.ldp-floor.ldp-jump-parent,.ldp-floor.ldp-jump-preview-self{border:none;cursor:pointer;margin-left:auto;
+ padding:1px 5px;border-radius:5px;white-space:nowrap;line-height:1.35;}
+ .ldp-floor.ldp-jump-parent{font-size:var(--ldp-font-sm,11px);font-weight:var(--ldp-reader-font-weight-base,400);color:var(--ldp-floor-preview-color,#9a5b13);background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 8%,transparent);}
+ .ldp-floor.ldp-jump-parent[aria-busy="true"]{position:relative;color:transparent;cursor:wait;pointer-events:none;text-decoration:none;}
+ .ldp-floor.ldp-jump-parent[aria-busy="true"]::after{content:"";position:absolute;left:50%;top:50%;box-sizing:border-box;
+ width:11px;height:11px;margin:-5.5px 0 0 -5.5px;border:2px solid color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 28%,transparent);
+ border-top-color:var(--ldp-floor-preview-color,#9a5b13);border-radius:50%;animation:ldp-spin .7s linear infinite;}
+ .ldp-floor.ldp-jump-self{font-size:var(--ldp-font-sm,11px);font-weight:var(--ldp-reader-font-weight-base,400);color:#2563b8;background:rgba(37,99,184,.04);}
+ .ldp-floor.ldp-jump-preview-self{height:24px;display:inline-flex;align-items:center;line-height:1;font-size:var(--ldp-font-sm,11px);font-weight:var(--ldp-reader-font-weight-base,400);color:var(--ldp-floor-preview-color,#9a5b13);background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 8%,transparent);}
+ .ldp-floor.ldp-body-floor{font-size:var(--ldp-font-sm,11px);padding:1px 5px;border-radius:5px;
+ background:color-mix(in srgb,var(--primary,#222) 3%,transparent);}
+ .ldp-floor.ldp-current-floor{margin-left:4px;color:var(--primary-medium,#777);
+ font-weight:var(--ldp-reader-font-weight-base,400);opacity:.72;}
+.ldp-descendant-replies-window .ldp-post-head:has(> .ldp-jump-parent){flex-wrap:wrap;row-gap:0;}
+ .ldp-descendant-replies-window .ldp-post-head:has(> .ldp-jump-parent)::after{content:"";flex:0 0 100%;height:0;order:1;}
+ .ldp-descendant-replies-window .ldp-floor.ldp-jump-parent{--ldp-callout-color:#2f78ef;box-sizing:border-box;display:inline-flex;
+ align-items:center;gap:.35em;margin:8px 0 0;padding:.28em .55em;order:2;border:0;border-radius:5px;
+ font:inherit;font-size:.86em;font-weight:600;color:var(--ldp-callout-color);
+ background:color-mix(in srgb,var(--ldp-callout-color) 9%,var(--secondary,#fff));}
+ .ldp-descendant-replies-window .ldp-floor.ldp-jump-parent > .ldp-icon{width:.9em;height:.9em;flex:none;
+ stroke:currentColor;fill:none;stroke-width:2;}
+ .ldp-descendant-replies-window .ldp-floor.ldp-jump-parent:hover{color:var(--ldp-callout-color);
+ background:color-mix(in srgb,var(--ldp-callout-color) 14%,var(--secondary,#fff));text-decoration:none;}
+ .ldp-descendant-replies-window .ldp-floor.ldp-body-floor{margin-left:auto;font:inherit;font-size:.8em;font-weight:600;color:#000;opacity:1;}
+ .ldp-descendant-replies-window .ldp-time[data-exact-time]::after{display:none;}
+ .ldp-descendant-replies-window .ldp-reply-exact-time{display:none;flex:none;white-space:nowrap;
+ color:inherit;font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);opacity:.55;}
+ .ldp-descendant-replies-window .ldp-time[data-exact-time]:hover ~ .ldp-reply-exact-time{display:inline;}
+ .ldp-children > .ldp-nested-preview .ldp-floor.ldp-jump-self{
+ margin-left:0;padding-inline:2px;color:#2f7a68;background:transparent;opacity:.72;}
+ .ldp-children > .ldp-nested-preview .ldp-post-read-state{margin-left:auto;}
+ .ldp-children > .ldp-nested-preview .ldp-time[data-exact-time]::after{
+ position:static;margin-left:6px;transform:none;}
+ .ldp-children > .ldp-nested-preview .ldp-time[data-exact-time]:hover::after{display:inline;}
+ .ldp-floor.ldp-jump-self:hover,.ldp-floor.ldp-jump-parent:hover,
+ .ldp-floor.ldp-jump-preview-self:hover{filter:saturate(1.25);text-decoration:underline;}
+ .ldp-floor-preview{position:relative;z-index:1;box-sizing:border-box;width:auto;max-width:none;
+ max-height:min(var(--ldp-floor-preview-height,300px),calc(100vh - 24px));margin:8px 0 10px;
+ border:1px solid var(--ldp-floor-preview-color,#9a5b13);
+ border-radius:8px;background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 12%,var(--secondary,#fff));
+ color:var(--primary,#222);box-shadow:0 3px 8px rgba(0,0,0,.1);padding:0;overflow:hidden;
+ display:none;flex-direction:column;}
+ .ldp-floor-preview.open{display:flex;}
+ .ldp-floor-preview.open:not(:has(.ldp-floor-preview-post,.ldp-floor-preview-tip)),
+ .ldp-floor-preview.open:not(:has(.ldp-floor-preview-post,.ldp-floor-preview-tip)) + .ldp-floor-preview-link{display:none;}
+ .ldp-floor-preview-close{position:absolute;right:8px;top:9px;z-index:3;border:none;background:transparent;color:inherit;cursor:pointer;
+ width:auto;height:28px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:0 5px;}
+ .ldp-floor-preview-close-hint{display:inline-flex;align-items:center;height:24px;line-height:1;font-size:var(--ldp-font-sm,11px);font-weight:500;color:var(--primary-medium,#777);white-space:nowrap;}
+ .ldp-floor-preview-close:hover{background:transparent;color:var(--primary,#222);}
+ .ldp-floor-preview-body{box-sizing:border-box;padding:10px 12px 12px;min-height:0;
+ max-height:calc(var(--ldp-floor-preview-height,300px) - 52px);overflow-y:auto;}
+ .ldp-floor-preview-post{font-size:var(--ldp-font-base,13px);line-height:1.55;max-width:100%;}
+ .ldp-floor-preview-post .ldp-content{overflow-wrap:anywhere;}
+ .ldp-floor-preview > .ldp-post-head{position:relative;z-index:2;isolation:isolate;flex:none;
+ min-height:40px;box-sizing:border-box;margin:0;padding:5px 92px 5px 10px;align-items:center;
+ gap:var(--ldp-nested-component-gap);
+ background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 12%,var(--secondary,#fff));
+ box-shadow:inset 0 calc(0px - var(--ldp-divider-line-width,1px)) 0 var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-floor-preview > .ldp-post-head > :not(.ldp-floor-preview-close){zoom:var(--ldp-nested-component-scale);}
+ .ldp-floor-preview > .ldp-post-head > .ldp-user + .ldp-op{margin-left:4px;}
+ .ldp-floor-preview > .ldp-post-head > .ldp-floor-preview-close{top:50%;transform:translateY(-50%);}
+ .ldp-floor-preview-link{display:none;position:relative;height:0;margin:0;overflow:visible;
+ pointer-events:none;z-index:2;
+ --ldp-parent-link-color:var(--ldp-reply-line-color,#47855f);--ldp-parent-link-width:var(--ldp-reply-line-width,1px);
+ --ldp-parent-turn-rise:15px;--ldp-parent-corner-radius:var(--ldp-reply-line-radius,15px);
+ --ldp-parent-axis-left:-15px;--ldp-parent-preview-rail-width:15px;
+ --ldp-parent-child-y:60px;--ldp-parent-child-branch-width:15px;
+ --ldp-parent-axis-top:12px;--ldp-parent-preview-y:12px;}
+ .ldp-floor-preview.open + .ldp-floor-preview-link,
+ .ldp-floor-preview-link.open{display:block;}
+ .ldp-floor-preview-link::before{content:"";position:absolute;left:var(--ldp-parent-axis-left);
+ top:calc(var(--ldp-parent-axis-top) + var(--ldp-parent-turn-rise));
+ width:calc(var(--ldp-parent-child-branch-width) - var(--ldp-parent-link-width));
+ height:calc(var(--ldp-parent-child-y) - var(--ldp-parent-axis-top) - var(--ldp-parent-turn-rise));
+ border-left:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color);border-bottom:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color);
+ border-bottom-left-radius:var(--ldp-parent-corner-radius);
+ -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%);
+ mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%);}
+ .ldp-floor-preview-link::after{content:"";position:absolute;left:var(--ldp-parent-axis-left);
+ top:var(--ldp-parent-preview-y);width:calc(var(--ldp-parent-preview-rail-width) - var(--ldp-parent-link-width));
+ height:var(--ldp-parent-turn-rise);border-left:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color);
+ border-top:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color);border-top-left-radius:var(--ldp-parent-corner-radius);
+ -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%);
+ mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%);}
+ .ldp-floor-preview-link.ldp-floor-preview-link-grouped::before,
+ .ldp-floor-preview-link.ldp-floor-preview-link-grouped::after{display:none;}
+ .ldp-post.ldp-parent-preview-linked{position:relative;}
+ .ldp-thread-group-svg{position:absolute;z-index:1;overflow:visible;pointer-events:none;
+ color:var(--ldp-parent-link-color);
+ --ldp-parent-link-color:var(--ldp-reply-line-color,#47855f);
+ --ldp-parent-link-width:var(--ldp-reply-line-width,1px);}
+ .ldp-thread-group-path{fill:none;stroke:var(--ldp-parent-link-color);stroke-width:var(--ldp-parent-link-width);
+ stroke-linecap:butt;stroke-linejoin:round;vector-effect:non-scaling-stroke;}
+ .ldp-floor-preview-post .ldp-actions,
+ .ldp-floor-preview-post .ldp-collapse-replies,
+ .ldp-floor-preview-post .ldp-children,
+ .ldp-floor-preview-post .ldp-nested-toggle{display:none!important;}
+ .ldp-floor-preview-tip{height:100%;display:flex;align-items:center;justify-content:center;
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-base,13px);}
+ .ldp-actions{display:inline-flex;gap:2px;margin:0 0 0 2px;font-size:var(--ldp-font-ui,12px);align-items:center;
+ flex:none;opacity:1;pointer-events:auto;}
+ .ldp-context-actions-slot{display:inline-flex;align-items:center;gap:2px;flex:none;height:20px;
+ width:0;min-width:0;overflow:hidden;}
+ .ldp-reactions:hover .ldp-context-actions-slot,
+ .ldp-reactions:focus-within .ldp-context-actions-slot,
+ .ldp-post.ldp-actions-active > .ldp-reactions .ldp-context-actions-slot{
+ width:calc(var(--ldp-context-action-count,1) * 22px - 2px);overflow:visible;}
+ .ldp-context-actions-slot:empty{pointer-events:none;}
+ .ldp-actions .ldp-btn,.ldp-reaction-add{min-width:20px;height:20px;justify-content:center;padding:2px;gap:2px;}
+ .ldp-actions .ldp-btn:not(.ldp-like),.ldp-reaction-add{width:20px;}
+ .ldp-actions .ldp-replybtn span{display:none;}
+ .ldp-topic-bookmark.on,.ldp-post-bookmark.on{color:var(--tertiary,#47855f);opacity:1;}
+ .ldp-topic-bookmark.on .ldp-icon-bookmark,
+ .ldp-post-bookmark.on .ldp-icon-bookmark{fill:currentColor;}
+ .ldp-like-count{display:inline;font-size:var(--ldp-font-sm,11px);line-height:1;}
+ .ldp-btn{cursor:pointer;border:none;background:transparent;color:inherit;
+ opacity:.7;display:inline-flex;align-items:center;gap:4px;padding:2px 4px;}
+ .ldp-btn:hover{opacity:1;}
+ .ldp-btn:disabled{cursor:default;opacity:.4;}
+ .ldp-like.liked{color:var(--love,#e25822);opacity:1;font-weight:600;}
+ .ldp-like.liked .ldp-icon-heart{fill:currentColor;}
+ .ldp-actions .ldp-context-action{opacity:0;visibility:hidden;pointer-events:none;
+ transition:opacity .12s ease,visibility 0s linear .12s;}
+ .ldp-reactions:hover .ldp-actions .ldp-context-action,
+ .ldp-reactions:focus-within .ldp-actions .ldp-context-action,
+ .ldp-post.ldp-actions-active > .ldp-reactions .ldp-actions .ldp-context-action{
+ opacity:.7;visibility:visible;pointer-events:auto;transition-delay:0s;}
+ .ldp-actions .ldp-context-action:hover{opacity:1;}
+ @media (hover:none){
+ .ldp-context-actions-slot{width:calc(var(--ldp-context-action-count,1) * 22px - 2px);overflow:visible;}
+ .ldp-actions .ldp-context-action{opacity:.7;visibility:visible;pointer-events:auto;transition:none;}
+ }
+ .ldp-topic-footer-actions{display:flex;align-items:center;flex-wrap:nowrap;width:max-content;max-width:100%;
+ gap:2px;margin:10px 0 2px;
+ padding:8px 6px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-ui,12px);}
+ .ldp-topic-footer-button,.ldp-topic-footer-link,.ldp-topic-notification{box-sizing:border-box;width:34px;height:34px;
+ min-height:0;margin:0;display:inline-flex;align-items:center;justify-content:center;padding:6px;
+ border:0;border-radius:6px;background:transparent;color:inherit;
+ font:inherit;line-height:1;vertical-align:top;text-decoration:none;white-space:nowrap;}
+ .ldp-topic-footer-actions > * > .ldp-icon{display:block;}
+ .ldp-topic-footer-button > span,.ldp-topic-footer-link > span{display:none;}
+ .ldp-topic-footer-button,.ldp-topic-footer-link{cursor:pointer;}
+ .ldp-topic-footer-button:hover,.ldp-topic-footer-link:hover,.ldp-topic-notification:hover{
+ background:var(--primary-very-low,#f4f4f4);color:inherit;}
+ .ldp-topic-footer-button:disabled{cursor:default;opacity:.45;}
+ .ldp-topic-footer-button.ldp-topic-reply{background:transparent;color:inherit;}
+ .ldp-topic-footer-button.ldp-topic-reply:hover{background:var(--primary-very-low,#f4f4f4);}
+ .ldp-topic-footer-button.ldp-topic-bookmark.on{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));}
+ .ldp-topic-footer-button.ldp-topic-shared-issue{width:auto;min-width:34px;gap:4px;padding-inline:8px;}
+ .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-label{
+ display:block;font-weight:500;letter-spacing:0;}
+ .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-count{
+ display:block;margin-left:-1px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);
+ font-variant-numeric:tabular-nums;}
+ .ldp-topic-footer-button.ldp-topic-shared-issue.on{opacity:1;}
+ .ldp-topic-footer-button.ldp-topic-shared-issue.on > .ldp-icon,
+ .ldp-topic-footer-button.ldp-topic-shared-issue.on > .ldp-topic-shared-issue-label{
+ color:var(--tertiary,#47855f);}
+ .ldp-topic-footer-separator{flex:none;width:1px;height:14px;padding:0 3px;
+ background:linear-gradient(var(--ldp-divider-line-color,#e5e5e5),var(--ldp-divider-line-color,#e5e5e5)) center/var(--ldp-divider-line-width,1px) 14px no-repeat;
+ transform:translateY(1px);}
+ .ldp-topic-notification{position:relative;padding:6px;cursor:pointer;overflow:hidden;}
+ .ldp-topic-notification select{position:absolute;inset:0;width:100%;height:100%;min-width:0;
+ border:0;opacity:0;cursor:pointer;outline:none;}
+ @supports (appearance:base-select){
+ .ldp-topic-notification .ldp-topic-notification-select{--ldp-select-picker-width:132px;
+ --ldp-select-picker-max-width:min(132px,calc(100vw - 24px));background:transparent;color:transparent;
+ box-shadow:none;opacity:1;}
+ .ldp-topic-notification-select::picker-icon{display:none;}
+ .ldp-topic-notification-select::picker(select){color:var(--primary,#222);}
+ }
+ .ldp-topic-notification.busy{opacity:.55;}
+ .ldp-floor-preview .ldp-topic-footer-actions{display:none!important;}
+ .ldp-boost-list{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:8px;}
+ .ldp-boost-list.ldp-boost-pending{min-height:26px;contain:layout paint;pointer-events:none;}
+ .ldp-boost-list.ldp-boost-pending::before{content:"";width:64px;height:22px;border-radius:999px;
+ background:var(--primary-very-low,#f2f4f6);}
+ .ldp-boost-bubble{position:relative;display:inline-flex;align-items:center;gap:5px;max-width:100%;
+ padding:3px 8px 3px 3px;border-radius:999px;background:var(--primary-very-low,#f2f4f6);
+ color:var(--primary,#222);font-size:var(--ldp-font-ui,12px);line-height:1.35;}
+ .ldp-floor-preview .ldp-boost-bubble{opacity:.82;}
+ .ldp-boost-avatar-link{display:inline-flex;line-height:0;flex:none;}
+ .ldp-boost-avatar{width:20px;height:20px;border-radius:50%;}
+ .ldp-boost-fallback-icon{width:14px;height:14px;margin-left:3px;opacity:.65;}
+ .ldp-boost-identities{display:inline-flex;align-items:center;gap:3px;flex:none;}
+ .ldp-boost-identity{display:inline-flex;align-items:center;gap:2px;min-height:16px;box-sizing:border-box;
+ padding:1px 5px 1px 3px;border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 34%,transparent);
+ border-radius:999px;background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff));
+ color:var(--tertiary,#47855f);font-size:var(--ldp-font-xs,10px);font-weight:700;line-height:1;white-space:nowrap;}
+ .ldp-boost-identity .ldp-icon{width:10px;height:10px;stroke-width:2.35;}
+ .ldp-boost-identity-me{border-color:color-mix(in srgb,var(--success,#3ea66b) 40%,transparent);
+ background:color-mix(in srgb,var(--success,#3ea66b) 12%,var(--secondary,#fff));color:var(--success,#3ea66b);}
+ .ldp-boost-identity-admin,.ldp-boost-identity-moderator{
+ border-color:color-mix(in srgb,var(--primary,#222) 28%,transparent);
+ background:color-mix(in srgb,var(--primary,#222) 7%,var(--secondary,#fff));color:var(--primary-high,#333);}
+ .ldp-boost-cooked{display:inline-flex;align-items:center;min-width:0;overflow:hidden;}
+ .ldp-boost-cooked p{margin:0;display:inline-flex;align-items:center;gap:4px;min-width:0;}
+ .ldp-boost-cooked img.emoji,.ldp-boost-cooked img.emoji.only-emoji{width:16px;height:16px;margin:0;}
+ .ldp-boost-item-action{display:none;align-items:center;justify-content:center;flex:none;
+ width:18px;height:18px;margin-left:1px;padding:2px;border:0;border-radius:50%;cursor:pointer;
+ background:transparent;color:var(--primary-medium,#777);opacity:0;visibility:hidden;pointer-events:none;
+ transition:opacity .16s ease,color .16s ease,background-color .16s ease;}
+ .ldp-boost-delete-action{color:var(--danger,#c94747);}
+ .ldp-boost-delete-action .ldp-icon,.ldp-boost-report-action .ldp-icon{
+ width:15px;height:15px;stroke-width:2.25;}
+ @keyframes ldp-boost-item-action-hover-ready{
+ to{width:18px;height:18px;margin-left:1px;padding:2px;visibility:visible;pointer-events:auto;}}
+ .ldp-boost-bubble:hover > .ldp-boost-item-action{
+ display:inline-flex;width:0;height:0;margin-left:-5px;padding:0;overflow:hidden;opacity:.72;
+ animation:ldp-boost-item-action-hover-ready 1ms linear .5s forwards;}
+ .ldp-boost-bubble:focus-within > .ldp-boost-item-action{
+ display:inline-flex;width:18px;height:18px;margin-left:1px;padding:2px;overflow:visible;
+ opacity:.72;visibility:visible;pointer-events:auto;animation:none;}
+ .ldp-boost-item-action:hover,.ldp-boost-item-action:focus-visible{
+ opacity:1;background:var(--primary-low,#d7dde3);color:var(--primary,#222);outline:none;}
+ .ldp-boost-delete-action:hover,.ldp-boost-delete-action:focus-visible{
+ background:color-mix(in srgb,var(--danger,#c94747) 14%,transparent);color:var(--danger,#c94747);}
+ .ldp-boost-item-action:disabled{cursor:default;opacity:.35;}
+ .ldp-boost-copy-action{position:absolute;left:50%;bottom:calc(100% - 2px);z-index:2;
+ margin:0;padding:6px 9px;border:0;cursor:pointer;font-family:inherit;
+ transform:translateX(-50%);opacity:0;visibility:hidden;pointer-events:none;}
+ .ldp-boost-copy-action.ldp-action-surface{
+ border-color:color-mix(in srgb,var(--tertiary,#47855f) 38%,var(--primary-low,#ddd));
+ color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1.25;}
+ .ldp-boost-bubble:hover > .ldp-boost-copy-action,
+ .ldp-boost-bubble:focus-within > .ldp-boost-copy-action{
+ opacity:1;visibility:visible;pointer-events:auto;}
+ .ldp-boost-copy-action:hover,.ldp-boost-copy-action:focus-visible{
+ border-color:var(--tertiary,#47855f);
+ background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff));
+ color:var(--tertiary,#47855f);outline:none;}
+ .ldp-boost-bubble:has(> .ldp-boost-item-action:hover) > .ldp-boost-copy-action,
+ .ldp-boost-bubble:has(> .ldp-boost-item-action:focus-visible) > .ldp-boost-copy-action{
+ opacity:0;visibility:hidden;pointer-events:none;}
+ @media (hover:none){
+ .ldp-boost-item-action,.ldp-boost-bubble:hover > .ldp-boost-item-action{
+ display:inline-flex;width:18px;height:18px;margin-left:1px;padding:2px;overflow:visible;
+ opacity:.72;visibility:visible;pointer-events:auto;animation:none;transition:none;}
+ }
+ html.ldp-reader-open body > [data-ldp-reader-composer-root]{position:relative!important;
+ z-index:2147483604!important;overflow:visible!important;}
+ html.ldp-reader-open [data-ldp-reader-composer-overflow]{overflow-x:clip!important;}
+ html.ldp-reader-open #reply-control:not(.closed),
+ html.ldp-reader-open .composer-popup:not(.closed){visibility:visible!important;
+ z-index:2147483605!important;}
+ html.ldp-reader-open #reply-control:not(.closed):not([data-ldp-reader-composer-positioned="1"]){visibility:hidden!important;}
+ html.ldp-reader-open :is(#d-menu-portals,.fk-d-menu,.emoji-picker,.composer-popup,.dialog-holder,.d-modal){visibility:visible!important;
+ z-index:2147483610!important;}
+ html.ldp-reader-open #reply-control[data-ldp-reader-top-layer="composer"][popover]{margin:0!important;}
+ html.ldp-reader-open #reply-control:not(.closed){position:fixed!important;
+ left:var(--ldp-composer-left,50%)!important;top:var(--ldp-composer-top,25vh)!important;
+ right:auto!important;bottom:auto!important;
+ transform:var(--ldp-composer-transform,translateX(-50%))!important;
+ width:var(--ldp-composer-width,min(1200px,calc(100vw - 32px)))!important;
+ height:var(--ldp-composer-height,70vh)!important;
+ min-width:min(520px,calc(100vw - 32px))!important;min-height:min(300px,calc(100vh - 32px))!important;
+ max-width:calc(100vw - 32px)!important;max-height:calc(100vh - 32px)!important;
+ box-sizing:border-box!important;border:0!important;outline:0!important;border-radius:10px!important;
+ color:var(--ldp-interface-font-color,var(--primary,#222))!important;
+ font-family:var(--ldp-interface-font-family,inherit)!important;
+ font-size:var(--ldp-font-md,14px)!important;font-weight:var(--ldp-interface-font-weight,400);will-change:translate;
+ box-shadow:0 18px 54px rgba(0,0,0,.28)!important;overflow:hidden!important;
+ animation:none!important;transition:none!important;}
+ html.ldp-reader-open #reply-control:not(.closed).fullscreen{inset:16px!important;
+ width:auto!important;height:auto!important;min-width:0!important;min-height:0!important;
+ max-width:calc(100vw - 32px)!important;max-height:calc(100vh - 32px)!important;
+ transform:none!important;border-radius:10px!important;}
+ html.ldp-reader-open #reply-control .reply-area{min-width:0!important;overflow:hidden!important;}
+ html.ldp-reader-open #reply-control .toggle-minimize{display:none!important;}
+ html.ldp-reader-open #reply-control .reply-to .presence-users{align-items:center!important;
+ flex:0 0 auto!important;min-width:max-content!important;overflow:visible!important;white-space:nowrap!important;}
+ html.ldp-reader-open #reply-control .reply-to .presence-avatars{align-items:center!important;
+ flex:0 0 auto!important;flex-wrap:nowrap!important;overflow:visible!important;}
+ html.ldp-reader-open #reply-control .reply-to .presence-avatars > *{display:inline-flex!important;
+ flex:0 0 auto!important;line-height:0!important;}
+ html.ldp-reader-open #reply-control .reply-to .presence-avatars img.avatar{display:block!important;flex:none!important;}
+ html.ldp-reader-open #reply-control .reply-to span.presence-text{flex:0 0 auto!important;
+ min-width:max-content!important;overflow:visible!important;}
+ html.ldp-reader-open #reply-control .submit-panel{padding-bottom:18px!important;
+ box-sizing:border-box!important;}
+ html.ldp-reader-open #reply-control .d-editor-container{min-width:0!important;}
+ html.ldp-reader-open #reply-control .d-editor-textarea-wrapper,
+ html.ldp-reader-open #reply-control .d-editor-preview-wrapper,
+ html.ldp-reader-open #reply-control .composer-preview,
+ html.ldp-reader-open #reply-control .preview-area{min-width:0!important;
+ width:auto!important;max-width:none!important;overflow:auto!important;}
+ html.ldp-reader-open #reply-control .d-editor-input,
+ html.ldp-reader-open #reply-control textarea,
+ html.ldp-reader-open #reply-control .d-editor-preview,
+ html.ldp-reader-open #reply-control .ProseMirror{color:var(--ldp-composer-font-color,var(--primary,#222))!important;
+ font-family:var(--ldp-composer-font-family,inherit)!important;
+ font-size:var(--ldp-composer-font-size,80%)!important;font-weight:var(--ldp-composer-font-weight,400)!important;
+ line-height:1.5!important;}
+ html.ldp-reader-open #reply-control :is(.d-editor-preview,.composer-preview,.preview-area)
+ aside.quote blockquote img:not(.emoji){display:block!important;width:auto!important;height:auto!important;
+ max-width:70%!important;max-height:360px!important;margin:8px auto!important;object-fit:contain;}
+ html.ldp-reader-open #user-card,
+ html.ldp-reader-open #group-card,
+ html.ldp-reader-open .user-card,
+ html.ldp-reader-open .group-card,
+ html.ldp-reader-open .user-card-container{visibility:visible!important;
+ z-index:2147483607!important;--ldp-native-user-card-scale:.8;color:var(--ldp-interface-font-color,inherit);
+ font-family:var(--ldp-interface-font-family,inherit);font-weight:var(--ldp-interface-font-weight,400);}
+ html.ldp-reader-open #user-card,
+ html.ldp-reader-open #group-card,
+ html.ldp-reader-open .user-card{zoom:var(--ldp-native-user-card-scale)!important;
+ max-width:none!important;}
+ .ldp-composer-window-chrome{position:fixed;z-index:2147483606;left:0;top:0;
+ width:var(--ldp-composer-chrome-width,0);height:var(--ldp-composer-chrome-height,0);
+ transform:none;box-sizing:border-box;pointer-events:none;will-change:transform;}
+ .ldp-composer-window-chrome[data-ldp-reader-top-layer="chrome"][popover]{right:auto!important;bottom:auto!important;
+ margin:0!important;padding:0!important;border:0!important;background:transparent!important;overflow:visible!important;}
+ html.ldp-reader-open [data-ldp-reader-top-layer="portal"][popover]{
+ margin:0!important;padding:0!important;border:0!important;background:transparent!important;overflow:visible!important;}
+ html.ldp-reader-open [data-ldp-reader-top-layer][popover]::backdrop{background:transparent!important;pointer-events:none!important;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"]{
+ color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit);
+ font-size:var(--ldp-font-base,13px);font-weight:var(--ldp-interface-font-weight,400);}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .fk-d-menu__inner-content{
+ box-sizing:border-box!important;border:1px solid var(--primary-low,#d7d7d7)!important;border-radius:8px!important;
+ background:var(--secondary,#fff)!important;color:inherit!important;box-shadow:0 8px 24px rgba(0,0,0,.18)!important;
+ overscroll-behavior:contain;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu{
+ min-width:200px;gap:2px!important;box-sizing:border-box;margin:0!important;padding:5px!important;list-style:none;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a),
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn{
+ display:flex;box-sizing:border-box;width:100%;min-height:36px;align-items:center;gap:10px;padding:6px 8px!important;
+ border:0!important;border-radius:6px!important;background:transparent!important;color:var(--primary,#222)!important;
+ box-shadow:none!important;font:inherit!important;font-size:var(--ldp-font-base,13px)!important;font-weight:550!important;
+ line-height:1.35;text-align:left;text-decoration:none!important;cursor:pointer;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(:hover,:focus-visible),
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(:hover,:focus-visible){
+ background:var(--primary-very-low,#f2f2f2)!important;color:var(--primary,#222)!important;outline:none!important;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(.active,[aria-current="true"],[aria-checked="true"]),
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(.active,[aria-current="true"],[aria-checked="true"]){
+ background:color-mix(in srgb,var(--tertiary-low,#dceee2) 68%,var(--secondary,#fff))!important;
+ color:var(--tertiary,#47855f)!important;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(:disabled,[aria-disabled="true"]),
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(:disabled,[aria-disabled="true"]){
+ background:transparent!important;color:var(--primary-medium,#777)!important;opacity:.48;cursor:default;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .d-icon{
+ width:18px;height:18px;flex:none;color:var(--primary-medium,#777);}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(:hover,:focus-visible,.active,[aria-current="true"],[aria-checked="true"]) .d-icon,
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(:hover,:focus-visible,.active,[aria-current="true"],[aria-checked="true"]) .d-icon{
+ color:inherit;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__divider{
+ margin:3px 4px!important;border-color:var(--ldp-divider-line-color,var(--primary-low,#ddd))!important;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__subheader{
+ padding:7px 8px 3px!important;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:700;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *){
+ scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff)) transparent;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar{
+ width:9px;height:9px;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar-button{
+ display:none;width:0;height:0;}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar-track{
+ border-radius:999px;background:linear-gradient(90deg,transparent 0 calc(50% - .75px),
+ color-mix(in srgb,var(--ldp-reply-line-color,#47855f) 28%,transparent) calc(50% - .75px) calc(50% + .75px),
+ transparent calc(50% + .75px) 100%);}
+ html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar-thumb{
+ border-radius:5px;background:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff));}
+ .ldp-composer-drag-handle{position:absolute;left:50%;top:-32px;z-index:2;
+ width:140px;height:48px;transform:translateX(-50%);box-sizing:border-box;padding:0;
+ border:0;background:transparent;box-shadow:none;color:transparent;
+ cursor:grab;pointer-events:auto;touch-action:none;}
+ .ldp-composer-drag-handle::before{display:none;}
+ .ldp-composer-window-chrome.is-interacting .ldp-composer-drag-handle{cursor:grabbing;}
+ .ldp-composer-resize-handle{position:absolute;z-index:1;display:block;pointer-events:auto;touch-action:none;}
+ .ldp-composer-resize-handle[data-resize="n"]{left:16px;right:16px;top:-6px;height:12px;cursor:n-resize;}
+ .ldp-composer-resize-handle[data-resize="s"]{left:16px;right:16px;bottom:-6px;height:12px;cursor:s-resize;}
+ .ldp-composer-resize-handle[data-resize="e"]{top:16px;right:-6px;bottom:16px;width:12px;cursor:e-resize;}
+ .ldp-composer-resize-handle[data-resize="w"]{top:16px;left:-6px;bottom:16px;width:12px;cursor:w-resize;}
+ .ldp-composer-resize-handle[data-resize="ne"]{top:-7px;right:-7px;width:20px;height:20px;cursor:ne-resize;}
+ .ldp-composer-resize-handle[data-resize="nw"]{top:-7px;left:-7px;width:20px;height:20px;cursor:nw-resize;}
+ .ldp-composer-resize-handle[data-resize="se"]{right:-8px;bottom:-8px;width:28px;height:28px;padding:0;
+ border:0;border-radius:0 0 8px 0;background:transparent;color:var(--primary-medium,#777);cursor:se-resize;}
+ .ldp-composer-resize-handle[data-resize="se"]::before,
+ .ldp-composer-resize-handle[data-resize="se"]::after{content:"";position:absolute;border-right:2px solid currentColor;
+ border-bottom:2px solid currentColor;border-radius:0 0 2px 0;opacity:.58;transition:opacity .15s ease,color .15s ease;}
+ .ldp-composer-resize-handle[data-resize="se"]::before{right:5px;bottom:5px;width:11px;height:11px;}
+ .ldp-composer-resize-handle[data-resize="se"]::after{right:9px;bottom:9px;width:5px;height:5px;}
+ .ldp-composer-resize-handle[data-resize="se"]:hover,
+ .ldp-composer-resize-handle[data-resize="se"]:focus-visible{color:var(--tertiary,#47855f);}
+ .ldp-composer-resize-handle[data-resize="se"]:focus-visible{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 62%,transparent);
+ outline-offset:-3px;}
+ .ldp-composer-resize-handle[data-resize="se"]:hover::before,
+ .ldp-composer-resize-handle[data-resize="se"]:hover::after,
+ .ldp-composer-resize-handle[data-resize="se"]:focus-visible::before,
+ .ldp-composer-resize-handle[data-resize="se"]:focus-visible::after{opacity:1;}
+ .ldp-composer-resize-handle[data-resize="sw"]{left:-7px;bottom:-7px;width:20px;height:20px;cursor:sw-resize;}
+ html.ldp-composer-window-interacting,html.ldp-composer-window-interacting *{user-select:none!important;}
+ @media (max-width:760px){
+ html.ldp-reader-open #reply-control:not(.closed){left:6px!important;right:6px!important;
+ top:auto!important;bottom:0!important;width:auto!important;height:50vh!important;
+ min-width:0!important;min-height:0!important;max-width:none!important;max-height:calc(100vh - 12px)!important;
+ transform:none!important;border-radius:10px 10px 0 0!important;}
+ .ldp-composer-window-chrome{display:none!important;}
+ html.ldp-reader-open #reply-control .d-editor-preview-wrapper,
+ html.ldp-reader-open #reply-control .composer-preview,
+ html.ldp-reader-open #reply-control .preview-area{min-height:140px!important;}
+ }
+ .ldp-user-card-fallback{position:fixed;z-index:2147483608;display:none;width:min(360px,calc(100vw - 24px));
+ max-height:min(480px,calc(100vh - 24px));overflow:auto;overscroll-behavior:contain;box-sizing:border-box;
+ border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff);
+ color:var(--primary,#222);box-shadow:0 18px 44px rgba(0,0,0,.22);padding:12px;}
+ .ldp-user-card-fallback.open{display:block;}
+ .ldp-user-card-fallback:has(.ldp-user-card-badges){max-height:min(520px,calc(100vh - 24px));}
+ .ldp-user-card-background{display:block;width:calc(100% + 24px);height:120px;margin:-12px -12px 12px;
+ overflow:hidden;padding:0;border:0;border-radius:9px 9px 0 0;background:var(--primary-very-low,#f7f7f7);
+ cursor:zoom-in;}
+ .ldp-user-info-cover img,.ldp-user-card-background img{display:block;width:100%;height:100%;object-fit:cover;}
+ .ldp-user-card-background:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:-3px;}
+ .ldp-user-card-head{display:flex;gap:10px;align-items:center;margin-bottom:8px;padding-right:44px;}
+ .ldp-user-card-identity{min-width:0;}
+ .ldp-user-card-name-row{display:flex;align-items:center;gap:6px;min-width:0;}
+ .ldp-user-card-level{flex:none;padding:2px 7px;border-radius:999px;
+ background:color-mix(in srgb,var(--tertiary,#08c) 12%,var(--secondary,#fff));
+ color:var(--tertiary,#08c);font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1.45;}
+ .ldp-user-card-avatar-trigger{display:inline-flex;width:48px;height:48px;flex:none;align-items:center;justify-content:center;
+ padding:0;border:0;border-radius:50%;background:transparent;box-shadow:0 0 0 0 transparent;cursor:zoom-in;}
+ .ldp-user-card-avatar-trigger .ldp-avatar-flair{right:-4px;bottom:-3px;width:19px;height:19px;border-width:2px;}
+ .ldp-user-card-avatar-trigger:hover{box-shadow:0 0 0 2px color-mix(in srgb,var(--tertiary,#08c) 38%,transparent);}
+ .ldp-user-card-avatar-trigger:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:3px;}
+ .ldp-user-card-avatar{width:48px;height:48px;border-radius:50%;flex:none;object-fit:cover;}
+ .ldp-avatar-viewer{position:fixed;z-index:2147483609;display:flex;flex-direction:column;
+ width:min(320px,calc(100vw - 20px));height:min(358px,calc(100vh - 20px));max-height:calc(100vh - 20px);
+ box-sizing:border-box;overflow:hidden;padding:8px;border-radius:12px;background:var(--secondary,#fff);
+ color:var(--primary,#222);box-shadow:0 8px 24px rgba(0,0,0,.28);}
+ .ldp-avatar-viewer.is-background{width:min(560px,calc(100vw - 20px));height:min(360px,calc(100vh - 20px));}
+ .ldp-avatar-viewer.is-image{width:min(480px,calc(100vw - 20px));height:min(480px,calc(100vh - 20px));
+ border:1px solid rgba(255,255,255,.14);background:rgba(7,9,12,.92);color:#fff;box-shadow:0 18px 54px rgba(0,0,0,.48);}
+ .ldp-avatar-viewer-toolbar{display:flex;min-width:0;flex:none;align-items:center;justify-content:flex-end;gap:4px;margin-bottom:6px;}
+ .ldp-avatar-viewer-progress{display:flex;min-width:0;flex:1;align-items:center;gap:7px;margin-right:4px;
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-variant-numeric:tabular-nums;}
+ .ldp-avatar-viewer-progress-track{height:4px;min-width:36px;flex:1;overflow:hidden;border-radius:999px;
+ background:color-mix(in srgb,var(--primary-low,#ddd) 82%,var(--secondary,#fff));}
+ .ldp-avatar-viewer-progress-fill{display:block;width:var(--ldp-avatar-progress,0%);height:100%;border-radius:inherit;
+ background:var(--tertiary,#47855f);transition:width .16s ease-out;}
+ .ldp-avatar-viewer-progress-value{min-width:6.5em;flex:none;text-align:right;white-space:nowrap;}
+ .ldp-avatar-viewer-progress.is-indeterminate .ldp-avatar-viewer-progress-fill{width:34%;
+ animation:ldp-avatar-viewer-progress 1.1s ease-in-out infinite alternate;}
+ @keyframes ldp-avatar-viewer-progress{from{transform:translateX(-15%);}to{transform:translateX(210%);}}
+ .ldp-avatar-viewer-stage{position:relative;display:grid;width:100%;min-width:0;min-height:0;flex:1;
+ place-items:center;overflow:hidden;border-radius:8px;background:var(--secondary,#fff);}
+ .ldp-avatar-viewer-image{display:block;width:100%;height:100%;max-width:100%;max-height:100%;
+ min-width:0;min-height:0;border-radius:6px;object-fit:contain;}
+ .ldp-avatar-viewer-stage > .ldp-avatar-flair{right:12px;bottom:12px;z-index:2;width:56px;height:56px;
+ border-width:3px;box-shadow:0 3px 12px rgba(0,0,0,.18);}
+ .ldp-avatar-viewer .ldp-lb-btn{border:1px solid var(--primary-low,#ddd);background:var(--secondary,#fff);
+ color:var(--primary-high,#444);}
+ .ldp-avatar-viewer .ldp-lb-btn:hover{background:var(--primary-very-low,#f7f7f7);}
+ .ldp-avatar-viewer .ldp-lb-btn:focus-visible{outline-color:var(--tertiary,#08c);}
+ .ldp-avatar-viewer.is-image .ldp-avatar-viewer-toolbar{margin-bottom:8px;}
+ .ldp-avatar-viewer.is-image .ldp-avatar-viewer-stage{overflow:hidden;background:rgba(10,12,16,.5);}
+ .ldp-avatar-viewer.is-image .ldp-avatar-viewer-image{transform:translate(var(--ldp-avatar-pan-x,0),var(--ldp-avatar-pan-y,0)) scale(var(--ldp-avatar-scale,1));
+ transform-origin:center;will-change:transform;}.ldp-avatar-viewer.is-image .ldp-avatar-viewer-image.is-zoomed{cursor:grab;}
+ .ldp-avatar-viewer.is-image .ldp-avatar-viewer-image.is-dragging{cursor:grabbing;}
+ .ldp-avatar-viewer.is-image .ldp-avatar-viewer-progress,.ldp-avatar-viewer.is-image .ldp-avatar-viewer-status{color:rgba(255,255,255,.72);}
+ .ldp-avatar-viewer.is-image .ldp-avatar-viewer-progress-track{background:rgba(255,255,255,.12);}
+ .ldp-avatar-viewer.is-image .ldp-avatar-viewer-progress-fill{background:#8cc7ff;}
+ .ldp-avatar-viewer.is-image .ldp-lb-btn{border:0;background:rgba(255,255,255,.1);color:#fff;}
+ .ldp-avatar-viewer.is-image .ldp-lb-btn:hover:not(:disabled){background:rgba(255,255,255,.2);}
+ .ldp-avatar-viewer.is-image .ldp-lb-btn:focus-visible{outline-color:#8cc7ff;}
+ .ldp-avatar-viewer-selection{display:inline-flex;min-width:0;align-items:center;gap:7px;margin-right:auto;padding:5px 8px;
+ border-radius:7px;background:rgba(255,255,255,.1);color:#fff;font-size:var(--ldp-font-ui,12px);cursor:pointer;}
+ .ldp-avatar-viewer-selection input{width:17px;height:17px;margin:0;accent-color:#47855f;cursor:pointer;}
+ .ldp-avatar-viewer-selection span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-avatar-viewer-nav{position:absolute;z-index:3;top:50%;display:grid;width:42px;height:54px;place-items:center;
+ transform:translateY(-50%);border:0;border-radius:8px;background:rgba(0,0,0,.48);color:#fff;cursor:pointer;}
+ .ldp-avatar-viewer-nav:hover{background:rgba(0,0,0,.68);}.ldp-avatar-viewer-nav:disabled{opacity:.3;cursor:default;}
+ .ldp-avatar-viewer-nav .ldp-icon{width:22px;height:22px;}.ldp-avatar-viewer-prev{left:8px;}
+ .ldp-avatar-viewer-next{right:8px;}.ldp-avatar-viewer-prev .ldp-icon{transform:scaleX(-1);}
+ .ldp-avatar-viewer-status{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-base,13px);}
+ .ldp-user-card-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
+ font-size:var(--ldp-font-lg,15px);font-weight:700;line-height:1.25;}
+ .ldp-user-card-username{font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);line-height:1.3;}
+ .ldp-user-card-title{font-size:var(--ldp-font-ui,12px);color:var(--tertiary,#08c);margin-top:2px;}
+ .ldp-user-profile-facts.is-card{gap:5px 12px;margin:-2px 0 8px;
+ font-size:var(--ldp-font-sm,11px);line-height:1.45;}
+ .ldp-user-profile-facts.is-card .is-wide{max-width:100%;}
+ .ldp-user-profile-facts.is-card .is-wide .ldp-user-profile-fact-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-user-card-groups{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:7px;margin:6px 0;
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;}
+ .ldp-user-card-groups > span{padding-top:3px;white-space:nowrap;}
+ .ldp-user-card-groups > div{display:flex;min-width:0;flex-wrap:wrap;gap:4px;}
+ .ldp-user-card-groups a{display:inline-flex;max-width:100%;padding:2px 7px;border:1px solid var(--primary-low,#ddd);
+ border-radius:999px;background:color-mix(in srgb,var(--tertiary,#08c) 7%,var(--secondary,#fff));
+ color:var(--tertiary,#08c);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-user-card-groups a:hover,.ldp-user-card-groups a:focus-visible{border-color:var(--tertiary,#08c);outline:none;
+ background:color-mix(in srgb,var(--tertiary,#08c) 14%,var(--secondary,#fff));}
+ .ldp-user-card-follow-stats{display:flex;align-items:center;gap:18px;margin:7px 0;}
+ .ldp-user-card-follow-stat{display:inline-flex;min-width:0;align-items:baseline;gap:5px;padding:2px 0;border:0;
+ background:transparent;color:inherit;text-align:left;cursor:pointer;}
+ .ldp-user-card-follow-stat:hover,.ldp-user-card-follow-stat[aria-expanded="true"]{color:var(--tertiary,#08c);}
+ .ldp-user-card-follow-stat.is-readonly,.ldp-user-card-follow-stat.is-readonly:hover{color:inherit;cursor:default;}
+ .ldp-user-card-follow-stat:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:2px;border-radius:3px;}
+ .ldp-user-card-follow-stat:disabled{opacity:.48;cursor:not-allowed;}
+ .ldp-user-card-follow-stat strong{font-size:var(--ldp-font-base,13px);font-variant-numeric:tabular-nums;}
+ .ldp-user-card-follow-stat span{overflow:hidden;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);
+ text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-user-card-follow-panel{position:fixed;z-index:2147483610;display:grid;width:min(320px,calc(100vw - 20px));
+ max-height:min(420px,calc(100vh - 20px));box-sizing:border-box;gap:6px;margin:0;padding:9px;
+ grid-template-rows:auto auto auto auto minmax(0,1fr) auto;
+ overflow:hidden;border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff);
+ color:var(--primary,#222);box-shadow:0 18px 44px rgba(0,0,0,.22);}
+ .ldp-user-card-follow-panel[hidden]{display:none;}
+ .ldp-user-card-follow-preview{z-index:2147483611;width:min(320px,calc(100vw - 20px));
+ max-height:min(520px,calc(100vh - 20px));}
+ .ldp-user-card-follow-panel > header{display:flex;align-items:center;justify-content:space-between;gap:8px;}
+ .ldp-user-card-follow-title{font-size:var(--ldp-font-ui,12px);}
+ .ldp-user-card-breadcrumbs{display:flex;min-width:0;align-items:center;gap:4px;overflow-x:auto;
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);scrollbar-width:none;}
+ .ldp-user-card-breadcrumbs[hidden]{display:none;}
+ .ldp-user-card-breadcrumbs::-webkit-scrollbar{display:none;}
+ .ldp-user-card-breadcrumbs button{flex:none;padding:1px 4px;border:0;border-radius:4px;background:transparent;
+ color:inherit;font-size:inherit;cursor:pointer;}
+ .ldp-user-card-breadcrumbs button:hover,.ldp-user-card-breadcrumbs button:focus-visible{
+ background:var(--primary-very-low,#f7f7f7);color:var(--tertiary,#08c);outline:none;}
+ .ldp-user-card-breadcrumbs strong{flex:none;color:var(--primary,#222);}
+ .ldp-user-card-follow-panel > header button{width:24px;height:24px;padding:0;border:0;border-radius:50%;
+ background:transparent;color:var(--primary-medium,#777);font-size:18px;line-height:1;cursor:pointer;}
+ .ldp-user-card-follow-panel > header button:hover{background:var(--primary-very-low,#f7f7f7);}
+ .ldp-user-card-follow-search{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:7px;
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
+ .ldp-user-card-follow-search input{min-width:0;height:30px;padding:4px 8px;border:1px solid var(--primary-low,#ddd);
+ border-radius:7px;background:var(--secondary,#fff);color:var(--primary,#222);font:inherit;}
+ .ldp-user-card-follow-search input:focus{border-color:var(--tertiary,#08c);outline:none;}
+ .ldp-user-card-follow-summary{min-height:1.3em;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);}
+ .ldp-user-card-follow-list{display:grid;min-height:0;gap:2px;overflow:auto;overscroll-behavior:contain;}
+ .ldp-user-card-follow-pagination{display:flex;align-items:center;justify-content:center;gap:8px;
+ color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);}
+ .ldp-user-card-follow-pagination[hidden]{display:none;}
+ .ldp-user-card-follow-pagination button{padding:3px 8px;border:1px solid var(--primary-low,#ddd);border-radius:6px;
+ background:var(--secondary,#fff);color:inherit;font:inherit;cursor:pointer;}
+ .ldp-user-card-follow-pagination button:hover:not(:disabled),
+ .ldp-user-card-follow-pagination button:focus-visible{border-color:var(--tertiary,#08c);color:var(--tertiary,#08c);outline:none;}
+ .ldp-user-card-follow-pagination button:disabled{opacity:.45;cursor:not-allowed;}
+ .ldp-user-card-follow-item{display:grid;grid-template-columns:30px minmax(0,1fr);align-items:center;gap:8px;
+ min-width:0;padding:5px 6px;border-radius:7px;color:inherit;text-decoration:none;}
+ .ldp-user-card-follow-item:hover,.ldp-user-card-follow-item:focus-visible{background:var(--primary-very-low,#f7f7f7);outline:none;}
+ .ldp-user-card-follow-avatar{width:28px;height:28px;border-radius:50%;object-fit:cover;}
+ .ldp-user-card-follow-item > span{display:grid;min-width:0;}
+ .ldp-user-card-follow-item strong,.ldp-user-card-follow-item small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-user-card-follow-item strong{font-size:var(--ldp-font-ui,12px);}
+ .ldp-user-card-follow-item small{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);}
+ .ldp-user-card-follow-empty{padding:12px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);text-align:center;}
+ .ldp-user-card-bio{font-size:var(--ldp-font-ui,12px);line-height:1.45;color:var(--primary-medium,#666);
+ margin:8px 0;max-height:96px;overflow:auto;overflow-wrap:anywhere;}
+ .ldp-user-card-bio p{margin:0 0 4px;}
+ .ldp-user-card-bio img.emoji{width:1.15em;height:1.15em;margin:0 .05em;
+ vertical-align:-.2em;}
+ .ldp-user-card-badges{display:grid;grid-template-columns:minmax(0,1fr);gap:5px;margin:7px 0;overflow:hidden;}
+ .ldp-user-card-badges-label{display:block;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);
+ font-weight:600;line-height:1.35;}
+ .ldp-user-card-badge-strip{display:grid;grid-template-columns:minmax(0,1fr);align-items:center;gap:1px;}
+ .ldp-user-card-badge-strip.is-scrollable{grid-template-columns:18px minmax(0,1fr) 18px;}
+ .ldp-user-card-badge-list{display:flex;width:100%;height:42px;min-width:0;align-items:center;
+ flex-wrap:nowrap;gap:2px;overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;scrollbar-width:none;}
+ .ldp-user-card-badge-list::-webkit-scrollbar{display:none;width:0;height:0;}
+ .ldp-user-card-badge-scroll{display:grid;width:18px;height:38px;place-items:center;padding:0;border:0;
+ border-radius:999px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;}
+ .ldp-user-card-badge-scroll:disabled{opacity:.2;cursor:default;}
+ .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open) .ldp-icon,
+ .ldp-popover-search-clear .ldp-icon,.ldp-config-action .ldp-icon,
+ .ldp-user-card-badge-scroll .ldp-icon{width:14px;height:14px;}
+ .ldp-user-card-badge{display:grid;width:34px;height:38px;flex:none;place-items:center;box-sizing:border-box;
+ padding:0;border:0;border-radius:999px;background:transparent;color:var(--primary-high,#333);cursor:copy;}
+ .ldp-user-card-badge-scroll:hover:not(:disabled),.ldp-user-card-badge:hover{background:color-mix(in srgb,currentColor 8%,transparent);}
+ .ldp-user-card-badge-scroll:focus-visible,.ldp-user-card-badge:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:1px;}
+ .ldp-user-card-badge-icon{display:block;width:32px;height:32px;overflow:visible;fill:currentColor;stroke:none;
+ stroke-linecap:round;stroke-linejoin:round;}
+ .ldp-user-card-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:8px 0;}
+ .ldp-user-card-stat{display:block;border:1px solid var(--primary-low,#eee);border-radius:7px;padding:5px;
+ text-align:center;background:var(--primary-very-low,#f7f7f7);color:inherit;text-decoration:none;}
+ .ldp-user-card-stat:hover{border-color:var(--tertiary,#08c);background:var(--secondary,#fff);}
+ .ldp-user-card-stat strong{display:block;font-size:var(--ldp-font-base,13px);color:var(--primary,#222);}
+ .ldp-user-card-stat span{font-size:var(--ldp-font-sm,11px);color:var(--primary-medium,#777);}
+ .ldp-user-card-actions-wrap{position:relative;}
+ .ldp-user-card-actions{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;
+ margin:9px 0 5px;overflow:visible;}
+ .ldp-user-card-action{position:relative;display:inline-flex;align-items:center;justify-content:center;
+ min-width:0;height:36px;padding:0;border:1px solid var(--primary-low,#ddd);border-radius:7px;
+ background:var(--secondary,#fff);color:var(--primary-high,#444);cursor:pointer;overflow:visible;}
+ .ldp-user-card-action:first-child{border-color:var(--tertiary,#4f8a63);
+ background:var(--tertiary,#4f8a63);color:var(--secondary,#fff);}
+ .ldp-user-card-action:hover,.ldp-user-card-action:focus-visible{border-color:var(--tertiary,#4f8a63);
+ background:color-mix(in srgb,var(--tertiary,#4f8a63) 10%,var(--secondary,#fff));
+ color:var(--tertiary,#4f8a63);outline:none;}
+ .ldp-user-card-action:first-child:hover,.ldp-user-card-action:first-child:focus-visible{
+ background:color-mix(in srgb,var(--tertiary,#4f8a63) 86%,#000);color:var(--secondary,#fff);}
+ .ldp-user-card-action.is-active{border-color:var(--tertiary,#4f8a63);color:var(--tertiary,#4f8a63);}
+ .ldp-user-card-action:disabled{opacity:.42;cursor:not-allowed;}
+ .ldp-user-card-action .ldp-icon{width:18px;height:18px;}
+ .ldp-user-card-action::after{content:attr(data-tooltip);position:absolute;left:50%;bottom:calc(100% + 6px);
+ z-index:2;width:max-content;max-width:180px;transform:translateX(-50%);padding:4px 7px;
+ pointer-events:none;opacity:0;visibility:hidden;transition:none;}
+ .ldp-user-card-action:first-child::after{left:0;transform:none;}
+ .ldp-user-card-action:last-child::after{right:0;left:auto;transform:none;}
+ .ldp-user-card-action:hover::after,.ldp-user-card-action:focus-visible::after{opacity:1;visibility:visible;}
+ .ldp-user-card-notification-menu{position:fixed;z-index:4;display:grid;box-sizing:border-box;gap:4px;margin:0;
+ max-height:calc(100vh - 20px);overflow:auto;overscroll-behavior:contain;
+ padding:5px;border:1px solid var(--primary-low,#ddd);border-radius:8px;background:var(--secondary,#fff);
+ box-shadow:0 10px 28px rgba(0,0,0,.16);}
+ .ldp-user-card-notification-option{display:grid;grid-template-columns:28px minmax(0,1fr);align-items:center;
+ box-sizing:border-box;gap:7px;width:100%;min-height:38px;padding:4px 7px;border:0;border-radius:6px;background:transparent;color:var(--primary,#222);
+ text-align:left;cursor:pointer;}
+ .ldp-user-card-notification-option:hover,.ldp-user-card-notification-option:focus-visible,
+ .ldp-user-card-notification-option.is-active{background:color-mix(in srgb,var(--tertiary,#4f8a63) 12%,var(--secondary,#fff));
+ outline:none;}
+ .ldp-user-card-notification-option .ldp-icon{width:18px;height:18px;justify-self:center;}
+ .ldp-user-card-notification-option-copy{display:grid;gap:1px;min-width:0;}
+ .ldp-user-card-notification-option-copy strong{font-size:var(--ldp-font-ui,12px);line-height:1.3;}
+ .ldp-user-card-notification-option-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);line-height:1.35;}
+ .ldp-user-card-notification-option:disabled{opacity:.5;cursor:wait;}
+ .ldp-reader-icon-tooltip.ldp-transient-surface{position:fixed;z-index:2147483647;box-sizing:border-box;width:max-content;max-width:min(380px,calc(100vw - 16px));
+ padding:4px 7px;overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;
+ pointer-events:none;opacity:1;visibility:visible;transition:none;}
+ .ldp-reader-icon-tooltip.ldp-reader-history-tooltip{max-width:min(34rem,calc(100vw - 16px));
+ overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;}
+ .ldp-reader-icon-tooltip.ldp-connect-help-tooltip{max-width:min(320px,calc(100vw - 16px));
+ padding:7px 9px;overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;line-height:1.5;}
+ .ldp-user-card-action-status{min-width:0;color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-sm,11px);line-height:1.35;}
+ .ldp-user-card-action-status:not(:empty){margin-top:7px;}
+ .ldp-user-card-action-status.is-error{color:var(--danger,#c33);}
+ .ldp-user-card-home{position:absolute;top:12px;right:12px;display:inline-flex;align-items:center;justify-content:center;
+ width:36px;height:36px;box-sizing:border-box;border:0;background:transparent;
+ color:var(--primary-high,#444);text-decoration:none;}
+ .ldp-user-card-home:hover{color:var(--tertiary,#4f8a63);}
+ .ldp-user-card-home:focus-visible{color:var(--tertiary,#4f8a63);
+ outline:2px solid currentColor;outline-offset:2px;border-radius:4px;}
+ .ldp-user-card-home .ldp-icon{width:18px;height:18px;}
+ .ldp-user-card-home::after{content:attr(data-tooltip);position:absolute;top:calc(100% + 6px);right:0;
+ z-index:2;width:max-content;padding:4px 7px;border-radius:5px;background:rgba(30,30,30,.94);color:#fff;
+ font-size:var(--ldp-font-sm,11px);font-weight:500;line-height:1.3;white-space:nowrap;pointer-events:none;
+ opacity:0;visibility:hidden;transition:none;}
+ .ldp-user-card-home:hover::after,.ldp-user-card-home:focus-visible::after{opacity:1;visibility:visible;}
+ .ldp-user-card-progress{display:grid;grid-template-columns:minmax(0,1fr) 72px;align-items:center;gap:8px;
+ margin:7px 0 2px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);}
+ .ldp-user-card-progress-track{height:4px;overflow:hidden;border-radius:999px;background:var(--primary-low,#e4e4e4);}
+ .ldp-user-card-progress-fill{display:block;width:34%;height:100%;border-radius:inherit;background:var(--tertiary,#08c);
+ animation:ldp-user-card-progress 1.1s ease-in-out infinite alternate;}
+ .ldp-user-card-progress.is-stats .ldp-user-card-progress-fill{width:72%;}
+ @keyframes ldp-user-card-progress{from{transform:translateX(-15%);}to{transform:translateX(115%);}}
+ .ldp-transient-surface,
+ .ldp-reader-placement-option::after,
+ .ldp-user-card-action::after{border:0;border-radius:7px;background:rgba(28,28,28,.86);color:#fff;
+ font-size:var(--ldp-font-ui,12px);font-weight:500;line-height:1.35;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.18);}
+ .ldp-transient-surface.ldp-setting-help-tooltip{padding:7px 9px;line-height:1.5;white-space:normal;overflow-wrap:anywhere;}
+ .ldp-action-surface{border:1px solid var(--primary-low,#ddd);border-radius:7px;
+ background:var(--secondary,#fff);color:var(--primary,#222);font-size:var(--ldp-font-ui,12px);
+ font-weight:600;line-height:1.35;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.14);}
+ .ldp-selection-toolbar{position:fixed;z-index:2147483610;display:flex;overflow:hidden;}
+ .ldp-selection-toolbar button{min-height:30px;padding:5px 9px;border:0;border-right:1px solid var(--primary-low,#ddd);
+ background:transparent;color:inherit;font:inherit;font-size:inherit;font-weight:inherit;line-height:inherit;cursor:pointer;}
+ .ldp-selection-toolbar button:last-child{border-right:0;}
+ .ldp-selection-toolbar button:hover,.ldp-selection-toolbar button:focus-visible{
+ background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff));
+ color:var(--tertiary,#47855f);outline:none;}
+ .ldp-selection-toast{position:fixed;left:50%;bottom:28px;z-index:2147483611;transform:translateX(-50%);
+ padding:8px 12px;border-radius:6px;background:rgba(32,32,32,.9);color:#fff;font-size:var(--ldp-font-base,13px);
+ box-shadow:0 6px 20px rgba(0,0,0,.2);pointer-events:none;}
+ .ldp-reader-action-layer{position:absolute;inset:0;z-index:2147483612;display:flex;
+ align-items:center;justify-content:center;padding:16px;box-sizing:border-box;
+ background:color-mix(in srgb,rgba(0,0,0,.46) 84%,transparent);}
+ .ldp-reader-action-dialog{display:flex;flex-direction:column;width:min(430px,100%);max-height:min(620px,calc(100% - 16px));
+ overflow:hidden;border:1px solid var(--primary-low,#ddd);border-radius:8px;
+ background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 18px 54px rgba(0,0,0,.28);}
+ .ldp-reader-action-head{display:flex;align-items:center;justify-content:space-between;gap:12px;
+ min-height:46px;padding:7px 9px 7px 14px;box-sizing:border-box;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-reader-action-head strong{min-width:0;font-size:var(--ldp-font-lg,15px);line-height:1.35;overflow-wrap:anywhere;}
+ .ldp-reader-action-close{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;
+ flex:none;padding:0;border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer;}
+ .ldp-reader-action-close:hover{background:var(--primary-very-low,#f2f2f2);}
+ .ldp-reader-action-form{display:flex;flex-direction:column;min-height:0;}
+ .ldp-reader-action-body{display:grid;gap:13px;min-height:0;padding:14px;overflow-y:auto;overscroll-behavior:contain;}
+ .ldp-reader-action-options{display:grid;gap:7px;}
+ .ldp-reader-action-option{display:grid;grid-template-columns:18px minmax(0,1fr);gap:9px;align-items:start;
+ padding:9px;border:1px solid var(--primary-low,#ddd);border-radius:7px;cursor:pointer;}
+ .ldp-reader-action-option:has(input:checked){border-color:color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#ddd));
+ background:color-mix(in srgb,var(--tertiary-low,#dceee2) 44%,var(--secondary,#fff));}
+ .ldp-reader-action-option input{margin:3px 0 0;}
+ .ldp-reader-action-option-copy{display:grid;gap:2px;min-width:0;}
+ .ldp-reader-action-option-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.4;}
+ .ldp-reader-action-field{display:grid;gap:5px;font-size:var(--ldp-font-ui,12px);font-weight:650;}
+ .ldp-reader-action-field input,.ldp-reader-action-field textarea,.ldp-reader-action-field select{width:100%;box-sizing:border-box;
+ border:1px solid var(--primary-low,#ccc);border-radius:7px;padding:8px 9px;
+ background:var(--secondary,#fff);color:inherit;font:inherit;line-height:1.45;outline:none;}
+ .ldp-boost-copy-mode,.ldp-font-weight-select,.ldp-reader-action-field .ldp-reader-select{font-size:var(--ldp-font-base,13px);font-weight:600;line-height:1.35;}
+ .ldp-reader-action-field textarea{min-height:86px;resize:vertical;}
+ .ldp-reader-action-field input:focus,.ldp-reader-action-field textarea:focus,.ldp-reader-action-field select:focus{border-color:var(--tertiary,#47855f);
+ box-shadow:0 0 0 1px var(--tertiary,#47855f);}
+ .ldp-reader-action-status{min-height:18px;margin:0;color:var(--danger,#c44);font-size:var(--ldp-font-ui,12px);line-height:1.45;}
+ .ldp-reader-action-status.success{color:var(--success,#47855f);}
+ .ldp-reader-action-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;
+ padding:9px 14px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-reader-action-cancel,.ldp-reader-action-submit{min-height:34px;padding:6px 13px;border-radius:6px;
+ font:inherit;font-size:var(--ldp-font-base,13px);font-weight:650;cursor:pointer;}
+ .ldp-reader-action-cancel{border:1px solid var(--primary-low,#ddd);background:transparent;color:inherit;}
+ .ldp-reader-action-cancel:hover{background:var(--primary-very-low,#f2f2f2);}
+ .ldp-reader-action-submit:disabled,.ldp-reader-action-cancel:disabled{cursor:default;opacity:.48;}
+ .ldp-reader-confirm-layer{position:fixed;z-index:2147483612;pointer-events:auto;
+ background:color-mix(in srgb,rgba(0,0,0,.38) 82%,transparent);}
+ .ldp-reader-confirm-dialog{width:min(380px,100%);border:0;border-radius:12px;
+ box-shadow:0 8px 24px rgba(0,0,0,.24);animation:ldp-reader-confirm-in .18s cubic-bezier(.22,1,.36,1);}
+ .ldp-reader-confirm-content{display:grid;grid-template-columns:40px minmax(0,1fr);gap:12px;padding:18px 18px 14px;}
+ .ldp-reader-confirm-icon{display:inline-flex;width:40px;height:40px;align-items:center;justify-content:center;
+ border-radius:10px;background:var(--danger-low,#f5d7d7);color:var(--danger,#c94747);}
+ .ldp-reader-confirm-icon .ldp-icon{width:19px;height:19px;}
+ .ldp-reader-confirm-copy{display:grid;gap:5px;min-width:0;}
+ .ldp-reader-confirm-copy strong{font-size:var(--ldp-font-lg,15px);line-height:1.4;overflow-wrap:anywhere;}
+ .ldp-reader-confirm-copy p{margin:0;color:var(--primary-high,#444);font-size:var(--ldp-font-base,13px);line-height:1.55;overflow-wrap:anywhere;}
+ .ldp-reader-confirm-details{display:grid;gap:5px;margin:4px 0 0;padding:0;list-style:none;}
+ .ldp-reader-confirm-details li{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 8px;
+ border-radius:6px;background:var(--primary-very-low,#f2f2f2);font-size:var(--ldp-font-ui,12px);line-height:1.4;}
+ .ldp-reader-confirm-details strong{font-size:inherit;white-space:nowrap;}
+ .ldp-reader-confirm-dialog .ldp-reader-action-footer{padding:0 18px 18px;border-top:0;}
+ .ldp-reader-confirm-dialog .ldp-reader-action-cancel,.ldp-reader-confirm-dialog .ldp-reader-action-secondary,
+ .ldp-reader-confirm-dialog .ldp-reader-action-submit{min-height:36px;padding:7px 15px;}
+ .ldp-reader-action-secondary{border:1px solid color-mix(in srgb,var(--danger,#c94747) 38%,var(--primary-low,#ddd));
+ border-radius:6px;background:transparent;color:var(--danger,#c94747);font:inherit;font-size:var(--ldp-font-base,13px);font-weight:650;cursor:pointer;}
+ .ldp-reader-action-secondary:hover{background:var(--danger-low,#f5d7d7);}
+ .ldp-reader-confirm-dialog .ldp-reader-action-submit{background:var(--danger,#c94747);}
+ .ldp-reader-confirm-dialog.is-primary .ldp-reader-confirm-icon{background:var(--tertiary-low,#dceee2);color:var(--tertiary,#47855f);}
+ .ldp-reader-confirm-dialog.is-primary .ldp-reader-action-submit{background:var(--tertiary,#47855f);}
+ .ldp-native-reader-trigger:focus-visible,.ldp-reader-host-top:focus-visible,.ldp-user-info-tab:focus-visible,
+ .ldp-rate-limit-challenge:focus-visible,.ldp-topic-timeline-top:focus-visible,.ldp-composer-drag-handle:focus-visible,
+ .ldp-reader-confirm-dialog :is(.ldp-reader-action-cancel,.ldp-reader-action-secondary,.ldp-reader-action-submit):focus-visible{
+ outline:2px solid var(--tertiary,#47855f);outline-offset:2px;}
+ @keyframes ldp-reader-confirm-in{from{opacity:0;transform:translateY(6px) scale(.98);}to{opacity:1;transform:none;}}
+ @media (prefers-reduced-motion:reduce){.ldp-reader-confirm-dialog{animation:none;}}
+ .ldp-error{display:grid;justify-items:center;align-content:center;gap:16px;min-height:220px;
+ box-sizing:border-box;padding:28px;text-align:center;color:var(--primary-medium,#666);}
+ .ldp-error-message{max-width:560px;line-height:1.6;overflow-wrap:anywhere;}
+ .ldp-error-actions{display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;}
+ .ldp-error-retry,.ldp-error-challenge,.ldp-error-close{min-height:36px;padding:7px 14px;border-radius:7px;
+ border:1px solid var(--primary-low,#d9d9d9);font:inherit;font-size:var(--ldp-font-base,13px);font-weight:650;cursor:pointer;}
+ .ldp-error-retry{border-color:transparent;background:var(--tertiary,#47855f);color:#fff;}
+ .ldp-error-challenge{display:inline-flex;align-items:center;text-decoration:none;}
+ .ldp-error-challenge,.ldp-error-close{background:var(--secondary,#fff);color:inherit;}
+ .ldp-error-challenge:hover,.ldp-error-close:hover{background:var(--primary-very-low,#f3f3f3);}
+ .ldp-error-retry:disabled{cursor:default;opacity:.52;}
+ .ldp-sentinel{height:1px;}
+ /* 图片浏览器 */
+ .ldp-lightbox{position:fixed;inset:0;z-index:2147483600;display:flex;flex-direction:column;
+ min-width:0;min-height:0;background:rgba(7,9,12,.8);color:#fff;isolation:isolate;
+ --ldp-lb-comments-width-preferred:25%;--ldp-lb-comments-width:clamp(min(240px,50%),var(--ldp-lb-comments-width-preferred),50%);}
+ .ldp-lb-toolbar{height:54px;display:flex;align-items:center;gap:10px;flex:none;
+ box-sizing:border-box;padding:8px 14px;border-bottom:0;
+ background:rgba(10,12,16,.5);}
+ .ldp-lb-count{min-width:54px;font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;color:rgba(255,255,255,.72);}
+ .ldp-lb-tools{display:flex;align-items:center;justify-content:center;gap:4px;flex:1;min-width:0;}
+ .ldp-lb-btn,.ldp-lb-nav{display:inline-flex;align-items:center;justify-content:center;border:0;
+ color:#fff;background:rgba(255,255,255,.1);cursor:pointer;transition:background-color .16s ease,opacity .16s ease;}
+ .ldp-lb-btn{width:36px;height:36px;border-radius:8px;}
+ .ldp-lb-btn:hover:not(:disabled),.ldp-lb-nav:hover:not(:disabled):not([aria-disabled="true"]){background:rgba(255,255,255,.2);}
+ .ldp-lb-btn:disabled{opacity:.4;cursor:default;}
+ .ldp-lb-btn:focus-visible,.ldp-lb-nav:focus-visible,.ldp-lb-thumb:focus-visible{
+ outline:2px solid #8cc7ff;outline-offset:2px;}
+ .ldp-lb-btn .ldp-icon,.ldp-lb-nav .ldp-icon{width:19px;height:19px;}
+ .ldp-lb-zoom-value{width:58px;font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;}
+ .ldp-lb-close{margin-left:auto;}
+ .ldp-lb-main{position:relative;display:flex;flex:1;min-width:0;min-height:0;}
+ .ldp-lb-stage{position:relative;display:flex;align-items:center;justify-content:center;flex:1;
+ min-width:0;min-height:0;overflow:hidden;padding:18px 64px;box-sizing:border-box;touch-action:none;}
+ .ldp-lb-canvas{position:relative;display:flex;align-items:center;justify-content:center;
+ max-width:100%;max-height:100%;}
+ .ldp-lb-image{display:block;max-width:100%;max-height:calc(100vh - 176px);width:auto;height:auto;
+ border-radius:4px;user-select:none;-webkit-user-drag:none;transform:translate3d(var(--ldp-lb-pan-x,0),var(--ldp-lb-pan-y,0),0) scale(var(--ldp-lb-scale,1));
+ transform-origin:center center;will-change:transform;cursor:zoom-in;}
+ .ldp-lb-image.is-zoomed{cursor:grab;}
+ .ldp-lb-image.is-dragging{cursor:grabbing;}
+ .ldp-lb-status{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;
+ flex-direction:column;align-items:center;gap:10px;max-width:min(420px,70vw);text-align:center;
+ color:rgba(255,255,255,.78);font-size:var(--ldp-font-base,13px);}
+ .ldp-lb-retry{border:0;border-radius:7px;padding:7px 12px;background:#fff;color:#111;cursor:pointer;font:inherit;}
+ .ldp-lb-nav{position:absolute;top:50%;z-index:2;width:42px;height:56px;border-radius:10px;
+ transform:translateY(-50%);background:rgba(0,0,0,.42);}
+ .ldp-lb-prev{left:12px;}
+ .ldp-lb-next{right:12px;}
+ .ldp-lightbox:not(.ldp-lb-comments-collapsed) .ldp-lb-next{right:calc(var(--ldp-lb-comments-width) + 12px);}
+ .ldp-lb-nav:disabled,.ldp-lb-nav[aria-disabled="true"]{opacity:.22;cursor:default;}
+ .ldp-lb-comments-toggle{gap:5px;width:auto;min-width:36px;padding:0 9px;white-space:nowrap;}
+ .ldp-lb-comments-toggle[aria-expanded="true"]{background:rgba(255,255,255,.24);}
+ .ldp-lb-comments-count{font-size:var(--ldp-font-sm,11px);font-variant-numeric:tabular-nums;}
+ .ldp-lb-comments{--primary:#f5f7fa;--primary-high:#fff;--primary-medium:rgba(245,247,250,.72);
+ --primary-low:rgba(245,247,250,.14);--primary-low-mid:rgba(245,247,250,.22);
+ --primary-very-low:rgba(245,247,250,.08);--secondary:transparent;
+ --d-post-aside-background:var(--primary-very-low);
+ position:relative;display:flex;flex:none;flex-direction:column;width:var(--ldp-lb-comments-width);min-width:0;
+ overflow:hidden;box-sizing:border-box;border-left:0;
+ background:rgba(7,9,12,.65);color:var(--primary);
+ transition:width .18s ease,border-color .18s ease;}
+ .ldp-lb-comments-collapsed .ldp-lb-comments{width:0;border-left-color:transparent;}
+ .ldp-lb-comments-resizer{position:absolute;z-index:7;top:0;bottom:0;left:-6px;width:12px;padding:0;border:0;
+ background:transparent;cursor:col-resize;touch-action:none;}
+ .ldp-lb-comments-resizer::after{content:"";position:absolute;top:50%;left:5px;width:2px;height:46px;
+ border-radius:999px;background:rgba(245,247,250,.2);transform:translateY(-50%);transition:background-color .16s ease;}
+ .ldp-lb-comments-resizer:hover::after,.ldp-lb-comments-resizer:focus-visible::after,.ldp-lightbox.is-resizing-comments .ldp-lb-comments-resizer::after{
+ background:rgba(245,247,250,.72);}
+ .ldp-lb-comments-resizer:focus-visible{outline:2px solid #8cc7ff;outline-offset:-2px;}
+ .ldp-lb-comments-collapsed .ldp-lb-comments-resizer{pointer-events:none;opacity:0;}
+ .ldp-lightbox.is-resizing-comments,.ldp-lightbox.is-resizing-comments *{cursor:col-resize!important;user-select:none!important;}
+ .ldp-lb-comments-inner{display:flex;flex:1;flex-direction:column;width:100%;min-height:0;}
+ .ldp-lb-comments-head{display:flex;align-items:center;gap:8px;min-height:50px;padding:8px 14px;
+ box-sizing:border-box;border-bottom:0;}
+ .ldp-lb-comments-head strong{font-size:var(--ldp-font-lg,15px);line-height:1.3;}
+ .ldp-lb-comments-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;}
+ .ldp-lb-description-toggle{display:inline-flex;align-items:center;justify-content:center;width:1em;height:1em;padding:0;border:0;
+ background:transparent;color:var(--primary-medium,#777);cursor:pointer;font:inherit;font-size:var(--ldp-font-lg,15px);line-height:1;}
+ .ldp-content .ldp-callout-toggle .ldp-icon,.ldp-lb-description-toggle .ldp-icon{width:1em;height:1em;transition:transform .16s ease;}
+ .ldp-lb-description-toggle[aria-expanded="true"] .ldp-icon{transform:rotate(90deg);}
+ .ldp-lb-description-toggle:hover{color:var(--primary,#f5f7fa);}
+ .ldp-lb-description-toggle:focus-visible{outline:2px solid #8cc7ff;outline-offset:2px;}
+ .ldp-lb-source{margin:0;border-bottom:0;background:var(--primary-very-low,#f7f7f7);}
+ .ldp-lb-source:has(+ .ldp-lb-source-reactions:not([hidden])){border-bottom-color:transparent;}
+ .ldp-lb-source summary{display:none;}
+ .ldp-lb-source-text{box-sizing:border-box;height:var(--ldp-lb-description-height,120px);min-height:56px;max-height:40vh;
+ overflow:auto;overscroll-behavior:contain;resize:vertical;padding:10px 14px 11px;color:var(--primary-medium,#666);
+ font-size:var(--ldp-font-ui,12px);line-height:1.55;overflow-wrap:anywhere;}
+ .ldp-lb-source-reactions{padding:7px 14px 10px;border-bottom:0;background:var(--primary-very-low,#f7f7f7);}
+ .ldp-lb-source-reactions > .ldp-reactions{margin:0;}
+ .ldp-lb-source-reactions .ldp-reaction-chip,.ldp-lb-comment-node .ldp-reaction-chip{min-height:24px;}
+ .ldp-lb-source-reactions .ldp-reaction-chip > span,.ldp-lb-comment-node .ldp-reaction-chip > span{font-size:18px;line-height:1;}
+ .ldp-lb-source-reactions .ldp-reaction-chip img.emoji,.ldp-lb-comment-node .ldp-reaction-chip img.emoji{width:18px;height:18px;}
+ .ldp-lb-source-reactions .ldp-reaction-picker,.ldp-lb-comment-node .ldp-reaction-picker{top:calc(100% + 5px);bottom:auto;}
+ .ldp-lb-comments-body{display:flex;flex:1;flex-direction:column;min-height:0;overflow-y:auto;overscroll-behavior:contain;}
+ .ldp-lb-comments-status,.ldp-lb-comments-empty{display:grid;justify-items:center;gap:10px;padding:28px 18px;
+ color:var(--primary-medium,#777);text-align:center;font-size:var(--ldp-font-base,13px);line-height:1.55;}
+ .ldp-lb-comment-list{display:block;min-height:0;}
+ .ldp-lb-comment-thread{border-bottom:0;}
+ .ldp-lb-comment-node > .ldp-post{padding:12px 14px;border-bottom:0;content-visibility:visible;contain-intrinsic-size:none;}
+ .ldp-lb-comment-children{margin-left:18px;border-left:var(--ldp-reply-line-secondary-width,2px) solid var(--ldp-reply-line-color,#47855f);}
+ .ldp-lb-comment-children .ldp-lb-comment-node{border-top:0;}
+ .ldp-lb-comment-children .ldp-post{padding-left:12px;}
+ .ldp-lb-comment-node .ldp-post-head{align-items:flex-start;flex-wrap:wrap;gap:5px 7px;}
+ .ldp-lb-comment-node .ldp-avatar{width:26px;height:26px;}
+ .ldp-lb-comment-node .ldp-author{max-width:44%;font-size:var(--ldp-font-ui,12px);line-height:1.35;}
+ .ldp-lb-comment-node .ldp-content{font-size:var(--ldp-font-base,13px);line-height:1.6;}
+ .ldp-lb-comment-node .ldp-content > :first-child{margin-top:0;}
+ .ldp-lb-comment-node .ldp-content > :last-child{margin-bottom:0;}
+ .ldp-lb-comment-node .ldp-reactions{margin:8px 0 0;}
+ .ldp-lb-comment-node .ldp-context-actions-slot,.ldp-lb-comment-node .ldp-boostbtn,
+ .ldp-lb-comment-node .ldp-boost-list,.ldp-lb-comment-node .ldp-topic-footer-actions,
+ .ldp-lb-comment-node .ldp-special-badges{display:none!important;}
+ .ldp-lb-comment-node .ldp-floor{cursor:pointer;}
+ .ldp-lb-comment-node .ldp-floor:hover{color:var(--tertiary,#47855f);text-decoration:underline;}
+ .ldp-lb-add,.ldp-lb-comment-submit,.ldp-lb-comment-cancel{min-height:34px;padding:6px 11px;border-radius:7px;
+ font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;cursor:pointer;}
+ .ldp-topic-edit-save,.ldp-reader-action-submit,.ldp-lb-add,.ldp-lb-comment-submit{border:1px solid transparent;background:var(--tertiary,#47855f);color:#fff;}
+ .ldp-lb-comment-cancel{border:1px solid var(--primary-low,#ddd);background:var(--secondary,#fff);color:inherit;}
+ .ldp-lb-comment-form{display:grid;gap:8px;margin:0;padding:12px 14px;border-top:0;
+ background:var(--secondary,#fff);}
+ .ldp-lb-comment-target{color:var(--primary-medium,#666);font-size:var(--ldp-font-ui,12px);line-height:1.4;}
+ .ldp-lb-comment-input{box-sizing:border-box;width:100%;min-height:88px;resize:vertical;padding:9px 10px;
+ border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);color:inherit;
+ font:inherit;font-size:var(--ldp-font-base,13px);line-height:1.55;}
+ .ldp-lb-comment-input::placeholder{color:var(--primary-medium);opacity:1;}
+ .ldp-boost-rule-control:focus,.ldp-lb-comment-input:focus{border-color:var(--tertiary,#47855f);outline:none;box-shadow:0 0 0 1px var(--tertiary,#47855f);}
+ .ldp-lb-comment-image-option{display:flex;align-items:center;gap:7px;color:var(--primary-medium,#666);font-size:var(--ldp-font-ui,12px);cursor:pointer;}
+ .ldp-lb-comment-image-option input{margin:0;accent-color:var(--tertiary,#47855f);}
+ .ldp-lb-comment-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;}
+ .ldp-lb-comment-form[aria-busy="true"] :is(textarea,button,input){pointer-events:none;opacity:.55;}
+ .ldp-lb-comment-error{min-height:0;color:var(--danger,#c44);font-size:var(--ldp-font-ui,12px);line-height:1.45;}
+ .ldp-lb-filmstrip{flex:none;box-sizing:border-box;padding:7px 12px 10px;
+ border-top:0;background:rgba(10,12,16,.5);}
+ .ldp-lb-strip-progress{height:2px;margin:0 4px 7px;border-radius:2px;background:rgba(255,255,255,.12);overflow:hidden;}
+ .ldp-lb-strip-progress > span{display:block;height:100%;width:var(--ldp-lb-progress-size,100%);
+ background:#8cc7ff;transform:translateX(var(--ldp-lb-progress-x,0));transition:transform .18s ease;}
+ .ldp-lb-thumbs{display:flex;gap:8px;overflow-x:auto;overscroll-behavior-x:contain;
+ scroll-snap-type:x proximity;padding:2px 4px 4px;scrollbar-width:thin;}
+ .ldp-lb-thumb{width:64px;height:56px;flex:none;padding:2px;border:2px solid transparent;border-radius:8px;
+ background:transparent;cursor:pointer;scroll-snap-align:center;opacity:.62;box-sizing:border-box;}
+ .ldp-lb-thumb:hover{opacity:.9;}
+ .ldp-lb-thumb.active{border-color:#8cc7ff;opacity:1;}
+ .ldp-lb-thumb img{display:block;width:100%;height:100%;border-radius:4px;object-fit:cover;background:#20242a;}
+ .ldp-lb-batch-overlay{position:absolute;z-index:12;inset:0;display:grid;place-items:center;padding:16px;
+ box-sizing:border-box;background:rgba(0,0,0,.58);backdrop-filter:blur(3px);}
+ .ldp-lb-batch-dialog{display:flex;flex-direction:column;width:min(720px,calc(100vw - 32px));max-height:min(720px,calc(100vh - 32px));
+ overflow:hidden;border:1px solid rgba(255,255,255,.16);border-radius:12px;background:#24272c;color:#f5f7fa;
+ box-shadow:0 18px 54px rgba(0,0,0,.42);}
+ .ldp-lb-batch-head,.ldp-lb-batch-tools,.ldp-lb-batch-actions{display:flex;align-items:center;gap:9px;}
+ .ldp-lb-batch-head{min-height:48px;padding:8px 12px 8px 16px;border-bottom:1px solid rgba(255,255,255,.1);}
+ .ldp-lb-batch-head strong{font-size:var(--ldp-font-lg,15px);}.ldp-lb-batch-head .ldp-lb-batch-close{margin-left:auto;}
+ .ldp-lb-batch-name{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:9px;padding:12px 16px 8px;
+ color:rgba(245,247,250,.72);font-size:var(--ldp-font-ui,12px);}
+ .ldp-lb-batch-name input{min-width:0;height:34px;box-sizing:border-box;padding:6px 9px;border:1px solid rgba(255,255,255,.16);
+ border-radius:7px;background:rgba(255,255,255,.07);color:#fff;font:inherit;outline:none;}
+ .ldp-lb-batch-name input:focus{border-color:#8cc7ff;box-shadow:0 0 0 1px #8cc7ff;}
+ .ldp-lb-batch-tools{min-height:42px;padding:0 16px 8px;color:rgba(245,247,250,.68);font-size:var(--ldp-font-ui,12px);}
+ .ldp-lb-batch-scope{display:flex;align-self:stretch;align-items:stretch;gap:18px;border-bottom:1px solid rgba(255,255,255,.1);}
+ .ldp-lb-batch-scope button{position:relative;min-height:38px;padding:6px 2px 9px;border:0;background:transparent;
+ color:rgba(245,247,250,.58);cursor:pointer;font:inherit;font-weight:650;white-space:nowrap;}
+ .ldp-lb-batch-scope button::after{content:"";position:absolute;right:0;bottom:-1px;left:0;height:2px;border-radius:2px 2px 0 0;
+ background:#8cc7ff;opacity:0;transform:scaleX(.5);transition:opacity .14s ease,transform .14s ease;}
+ .ldp-lb-batch-scope button:hover:not(:disabled){color:rgba(245,247,250,.88);}
+ .ldp-lb-batch-scope button[aria-selected="true"]{color:#fff;}
+ .ldp-lb-batch-scope button[aria-selected="true"]::after{opacity:1;transform:scaleX(1);}
+ .ldp-lb-batch-scope button:focus-visible,.ldp-lb-batch-select-all:focus-visible,.ldp-lb-batch-more:focus-visible{outline:2px solid rgba(140,199,255,.72);outline-offset:2px;}
+ .ldp-lb-batch-scope button:disabled{opacity:.4;cursor:default;}
+ .ldp-lb-batch-select-all{display:inline-flex;min-height:32px;align-items:center;gap:6px;margin-left:2px;padding:5px 9px;border:0;
+ border-radius:7px;background:rgba(255,255,255,.055);color:rgba(245,247,250,.72);cursor:pointer;font:inherit;font-weight:650;}
+ .ldp-lb-batch-select-all:hover:not(:disabled){background:rgba(255,255,255,.1);color:#fff;}
+ .ldp-lb-batch-select-all[aria-pressed="true"]{background:rgba(140,199,255,.14);color:#b9dcff;}
+ .ldp-reader-host-top .ldp-icon,.ldp-settings-theme-button .ldp-icon,.ldp-rate-limit-icon .ldp-icon,
+ .ldp-topic-timeline-top .ldp-icon,.ldp-lb-batch-select-all .ldp-icon{width:15px;height:15px;}
+ .ldp-lb-batch-select-all:disabled{opacity:.4;cursor:default;}
+ .ldp-lb-batch-count{margin-left:auto;padding:4px 8px;border-radius:999px;background:rgba(255,255,255,.055);
+ color:rgba(245,247,250,.68);font-variant-numeric:tabular-nums;white-space:nowrap;}
+ .ldp-lb-batch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:9px;min-height:0;overflow:auto;
+ padding:2px 16px 14px;overscroll-behavior:contain;}
+ .ldp-lb-batch-item{position:relative;display:grid;gap:5px;padding:5px;border:1px solid rgba(255,255,255,.12);border-radius:8px;
+ background:rgba(255,255,255,.035);cursor:pointer;}.ldp-lb-batch-item.selected{border-color:#8cc7ff;background:rgba(140,199,255,.12);}
+ .ldp-lb-batch-item img{display:block;width:100%;aspect-ratio:4/3;border-radius:5px;object-fit:cover;
+ background:linear-gradient(110deg,rgba(255,255,255,.045) 8%,rgba(255,255,255,.11) 18%,rgba(255,255,255,.045) 33%);background-size:220% 100%;
+ animation:ldp-lb-batch-thumbnail-loading 1.25s linear infinite;}
+ .ldp-lb-batch-item img[data-ldp-batch-thumb-state="loaded"]{background:repeating-conic-gradient(#d8dbe0 0 25%,#f3f4f6 0 50%) 50%/14px 14px;animation:none;}
+ .ldp-lb-batch-item img[data-ldp-batch-thumb-state="failed"]{opacity:.28;animation:none;}
+ .ldp-lb-batch-item:has(img[data-ldp-batch-thumb-state="failed"])::after{content:"预览加载失败";position:absolute;left:8px;right:8px;top:50%;
+ color:rgba(245,247,250,.82);font-size:var(--ldp-font-sm,11px);text-align:center;transform:translateY(-12px);pointer-events:none;}
+ @keyframes ldp-lb-batch-thumbnail-loading{to{background-position-x:-220%;}}
+ .ldp-lb-batch-item input{position:absolute;top:9px;left:9px;width:17px;height:17px;margin:0;accent-color:#47855f;cursor:pointer;}
+ .ldp-lb-batch-item span{overflow:hidden;color:rgba(245,247,250,.72);font-size:var(--ldp-font-sm,11px);text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-lb-batch-more{display:grid;min-height:124px;place-items:center;align-content:center;gap:8px;padding:10px;border:1px dashed rgba(140,199,255,.42);
+ border-radius:8px;background:rgba(140,199,255,.045);color:rgba(185,220,255,.78);cursor:pointer;font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;}
+ .ldp-lb-batch-more:hover:not(:disabled){border-color:#8cc7ff;background:rgba(140,199,255,.1);color:#d9edff;}
+ .ldp-lb-batch-more:disabled{opacity:.55;cursor:wait;}.ldp-lb-batch-more .ldp-icon{width:24px;height:24px;}
+ .ldp-lb-batch-progress{display:grid;gap:5px;padding:9px 16px;border-top:1px solid rgba(255,255,255,.08);}
+ .ldp-lb-batch-progress-copy{display:flex;justify-content:space-between;gap:10px;
+ color:rgba(245,247,250,.68);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;}
+ .ldp-lb-batch-progress-track{height:6px;overflow:hidden;border-radius:999px;background:rgba(255,255,255,.12);}
+ .ldp-lb-batch-progress-fill{display:block;width:var(--ldp-lb-batch-progress,0%);height:100%;border-radius:inherit;
+ background:#8cc7ff;transition:width .18s ease;}
+ .ldp-lb-batch-actions{justify-content:flex-end;padding:10px 16px 14px;border-top:1px solid rgba(255,255,255,.1);}
+ .ldp-lb-batch-status{min-width:0;margin-right:auto;color:rgba(245,247,250,.65);font-size:var(--ldp-font-ui,12px);}
+ .ldp-lb-batch-cancel,.ldp-lb-batch-download{min-height:34px;padding:6px 12px;border-radius:7px;cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;}
+ .ldp-lb-batch-cancel{border:1px solid rgba(255,255,255,.16);background:transparent;color:#fff;}
+ .ldp-lb-batch-download{border:1px solid transparent;background:#47855f;color:#fff;}.ldp-lb-batch-download:disabled{opacity:.45;cursor:default;}
+ @media (max-width:700px){
+ .ldp-lightbox{--ldp-lb-comments-width:clamp(min(240px,50%),var(--ldp-lb-comments-width-preferred),50%);}
+ .ldp-lb-toolbar{height:50px;padding:6px 8px;gap:4px;}
+ .ldp-lb-count{min-width:42px;}
+ .ldp-lb-btn{width:34px;height:34px;}
+ .ldp-lb-stage{padding:12px 46px;}
+ .ldp-lb-image{max-height:calc(100vh - 154px);}
+ .ldp-lb-nav{width:36px;height:48px;}
+ .ldp-lb-prev{left:5px;}.ldp-lb-next{right:5px;}
+ .ldp-lb-comments{position:absolute;z-index:5;top:0;right:0;bottom:0;width:var(--ldp-lb-comments-width);
+ box-shadow:-12px 0 34px rgba(0,0,0,.22);transition:transform .18s ease;}
+ .ldp-lb-comments-collapsed .ldp-lb-comments{width:var(--ldp-lb-comments-width);border-left-color:var(--ldp-divider-line-color,#e5e5e5);transform:translateX(100%);}
+ .ldp-lightbox:not(.ldp-lb-comments-collapsed) .ldp-lb-next{right:5px;}
+ .ldp-lb-filmstrip{padding:6px 6px 8px;}
+ .ldp-lb-thumb{width:54px;height:48px;}
+ .ldp-lb-batch-grid{grid-template-columns:repeat(auto-fill,minmax(86px,1fr));}
+ }
+ @media (prefers-reduced-motion:reduce){
+ .ldp-reader-window-capsule,.ldp-reader-window-capsule::before,.ldp-reader-window-capsule > *,.ldp-reader-lock-button,.ldp-reader-pin-button,
+ html .ldp-reader-switch-target,html.ldp-reader-embedded-left #ember-app,html.ldp-reader-embedded-right #ember-app,
+ html.ldp-reader-embedded-left .d-header,html.ldp-reader-embedded-right .d-header,.ldp-reader-embedded .ldp-modal,
+ .ldp-topic-edit-category-trigger .ldp-icon,.ldp-color-group-head,
+ .ldp-settings-popover input[type="range"]::-webkit-slider-thumb,
+ .ldp-settings-popover input[type="range"]::-moz-range-thumb,
+ .ldp-setting-switch-track,.ldp-setting-switch-track::after,.ldp-color-control input[type="color"],
+ .ldp-font-family-trigger .ldp-icon,.ldp-setting-help-tooltip,.ldp-topic-timeline-preview,.ldp-topic-timeline-count,
+ .ldp-topic-timeline-cursor,.ldp-topic-timeline-cursor > span,.ldp-topic-timeline-cursor > span::before,
+ .ldp-nested-rail-toggle::before,.ldp-boost-item-action{transition:none;}
+ .ldp-lb-btn,.ldp-lb-nav,.ldp-lb-strip-progress > span,.ldp-lb-comments,.ldp-lb-source summary .ldp-icon,
+ .ldp-avatar-viewer-progress-fill,.ldp-topic-timeline-cursor,.ldp-topic-timeline-cursor > span{transition:none;animation:none;}
+ }
+
+ /* 楼中楼分页按钮 */
+ .ldp-collapse-replies{display:none;margin:8px 0 2px 22px;padding-left:0;
+ width:calc(100% - 22px);justify-content:flex-start;
+ font-size:var(--ldp-font-ui,12px);color:var(--tertiary,#08c);font-weight:600;}
+ .ldp-collapse-replies.show{display:flex;}
+ .ldp-sub-actions{margin-top:6px;display:none;align-items:center;gap:8px;font-size:var(--ldp-font-ui,12px);}
+ .ldp-sub-page-btn{font-size:var(--ldp-font-ui,12px);color:var(--tertiary,#08c);font-weight:600;
+ opacity:.9;padding:4px 0;}
+ .ldp-sub-page-btn:hover:not(:disabled){opacity:1;text-decoration:underline;}
+ .ldp-sub-page-btn:disabled{opacity:.35;cursor:default;}
+ .ldp-topic-edit-category-trigger[aria-expanded="true"] .ldp-icon,.ldp-notification-prev-icon,
+ .ldp-font-family-trigger[aria-expanded="true"] .ldp-icon,.ldp-reader-history-back .ldp-icon,
+ .ldp-content .ldp-callout--foldable:not(.ldp-callout--collapsed) > .ldp-callout-toggle .ldp-icon,
+ .ldp-content aside.ldp-post-quote.ldp-quote-expanded .ldp-quote-toggle .ldp-icon,
+ .ldp-content .ldp-media-carousel-controls button:first-child .ldp-icon,.ldp-user-card-badge-scroll.is-prev .ldp-icon,
+ .ldp-lb-prev .ldp-icon,.ldp-sub-page-prev .ldp-icon{transform:rotate(180deg);}
+ .ldp-sub-page-info{color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);}
+ .ldp-sub-loading{font-size:var(--ldp-font-ui,12px);opacity:.5;margin-left:22px;padding-left:14px;
+ margin-top:2px;display:none;}
+ .ldp-jump-linkbar{display:flex;align-items:center;gap:8px;margin:0 0 6px;
+ font-size:var(--ldp-font-ui,12px);}
+ .ldp-jump-backbar{margin-bottom:8px;}
+ .ldp-jump-linkbar .ldp-jump-parent,.ldp-jump-back{color:var(--tertiary,#08c);font-weight:var(--ldp-reader-font-weight-base,400);}
+ .ldp-jump-highlight{position:relative;border-radius:var(--ldp-jump-highlight-radius,10px);}
+ .ldp-jump-highlight:not(.ldp-post[data-post-number="1"]){
+ animation:ldp-jump-pulse var(--ldp-jump-highlight-duration,1250ms) ease-in-out var(--ldp-jump-highlight-count,2);}
+ .ldp-comments > .ldp-post.ldp-zebra-alt.ldp-jump-highlight:not([data-post-number="1"]){animation:none;}
+ .ldp-comments > .ldp-post.ldp-zebra-alt.ldp-jump-highlight:not([data-post-number="1"])::before{
+ content:"";position:absolute;z-index:-1;inset:6px 0;border-radius:inherit;pointer-events:none;
+ animation:ldp-jump-pulse var(--ldp-jump-highlight-duration,1250ms) ease-in-out var(--ldp-jump-highlight-count,2);}
+ .ldp-post[data-post-number="1"].ldp-jump-highlight{isolation:isolate;}
+ .ldp-post[data-post-number="1"].ldp-jump-highlight::before{content:"";position:absolute;z-index:-1;left:0;right:0;top:0;
+ height:var(--ldp-first-post-jump-highlight-height,100%);border-radius:var(--ldp-jump-highlight-radius,10px);pointer-events:none;
+ animation:ldp-jump-pulse var(--ldp-jump-highlight-duration,1250ms) ease-in-out var(--ldp-jump-highlight-count,2);}
+ @keyframes ldp-jump-pulse{
+ 0%{background:color-mix(in srgb,var(--ldp-jump-highlight-color,#0888cc) 34%,transparent);
+ box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) var(--ldp-jump-highlight-color,#0888cc);}
+ 100%{background:transparent;box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) transparent;}
+ }
+ .ldp-descendant-replies-open .ldp-icon.ldp-jump-highlight{
+ border-radius:0;background:none;box-shadow:none;
+ animation:ldp-jump-icon-pulse var(--ldp-jump-highlight-duration,1250ms) ease-in-out var(--ldp-jump-highlight-count,2);}
+ @keyframes ldp-jump-icon-pulse{
+ 0%,100%{color:inherit;opacity:1;transform:scale(1);}
+ 50%{color:var(--ldp-jump-highlight-color,#0888cc);opacity:.62;transform:scale(1.16);}
+ }
+ @media (prefers-reduced-motion:reduce){
+ .ldp-jump-highlight:not(.ldp-post[data-post-number="1"]){animation:none;background:color-mix(in srgb,var(--ldp-jump-highlight-color,#0888cc) 34%,transparent);
+ box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) var(--ldp-jump-highlight-color,#0888cc);}
+ .ldp-comments > .ldp-post.ldp-zebra-alt.ldp-jump-highlight:not([data-post-number="1"]){background:transparent;box-shadow:none;}
+ .ldp-comments > .ldp-post.ldp-zebra-alt.ldp-jump-highlight:not([data-post-number="1"])::before{animation:none;
+ background:color-mix(in srgb,var(--ldp-jump-highlight-color,#0888cc) 34%,transparent);
+ box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) var(--ldp-jump-highlight-color,#0888cc);}
+ .ldp-post[data-post-number="1"].ldp-jump-highlight::before{animation:none;
+ background:color-mix(in srgb,var(--ldp-jump-highlight-color,#0888cc) 34%,transparent);
+ box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) var(--ldp-jump-highlight-color,#0888cc);}
+ .ldp-descendant-replies-open .ldp-icon.ldp-jump-highlight{
+ background:none;box-shadow:none;color:var(--ldp-jump-highlight-color,#0888cc);opacity:1;}
+ }
+ .ldp-nested-toggle{display:none;align-items:center;height:24px;margin:2px 0;line-height:1;vertical-align:middle;color:var(--tertiary,#08c);font-weight:600;font-size:var(--ldp-font-ui,12px);}
+ .ldp-nested-esc-hint{display:inline-flex;align-items:center;height:24px;margin-left:8px;line-height:1;color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-sm,11px);font-weight:500;white-space:nowrap;vertical-align:middle;}
+ .ldp-reply-collapsible > .ldp-nested-toggle,.ldp-hidden-collapsible > .ldp-nested-toggle{display:inline-flex;}
+ .ldp-nested-preview > .ldp-nested-toggle{display:none;}
+ .ldp-post:has(> .ldp-hidden-reply-marker){position:relative;z-index:3;height:18px;min-height:18px;padding:0;overflow:visible;
+ border:0;background:transparent;content-visibility:visible;contain:none;}
+ .ldp-post:has(> .ldp-hidden-reply-marker) > :not(.ldp-hidden-reply-marker){display:none!important;}
+ .ldp-hidden-reply-marker{position:relative;z-index:4;display:grid;width:100%;height:18px;
+ grid-template-columns:minmax(0,1fr) 14px;align-items:center;column-gap:4px;color:var(--primary-medium);}
+ .ldp-hidden-reply-marker::before{content:"";height:var(--ldp-divider-line-width,1px);
+ background:var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-hidden-reply-toggle{display:grid;width:14px;height:18px;padding:0;place-items:center;line-height:1;}
+ .ldp-hidden-reply-toggle::before{content:"";width:0;height:0;border-top:5px solid transparent;
+ border-bottom:5px solid transparent;border-right:7px solid currentColor;}
+ .ldp-hidden-reply-list{position:absolute;right:0;bottom:calc(50% + 7px);display:flex;max-width:min(70vw,360px);
+ z-index:5;align-items:center;gap:4px;padding:4px;overflow-x:auto;border:1px solid var(--primary-low);border-radius:999px;
+ background:var(--secondary);box-shadow:0 5px 16px rgba(0,0,0,.16);}
+ .ldp-hidden-reply-avatar{width:24px;height:24px;flex:none;padding:0;overflow:hidden;border:0;border-radius:50%;
+ background:var(--primary-low);cursor:pointer;}
+ .ldp-hidden-reply-avatar img{width:100%;height:100%;object-fit:cover;}
+ .ldp-post.ldp-nested-collapsed{display:flex;align-items:center;gap:8px;padding:4px 16px;font-size:var(--ldp-font-ui,12px);cursor:pointer;}
+ .ldp-post.ldp-nested-collapsed > .ldp-nested-toggle{display:inline-flex;flex:none;margin:0;font-size:var(--ldp-font-ui,12px);}
+ .ldp-topic-edit-label-remove .ldp-icon,.ldp-only-op-toggle .ldp-icon,
+ .ldp-color-reset .ldp-icon,.ldp-boost-item-action .ldp-icon,.ldp-nested-toggle .ldp-icon,
+ .ldp-post.ldp-nested-collapsed > .ldp-nested-toggle .ldp-icon{width:12px;height:12px;}
+ .ldp-post.ldp-nested-collapsed > .ldp-post-head{flex:1;min-width:0;margin:0;gap:6px;white-space:nowrap;overflow:hidden;}
+ .ldp-post.ldp-nested-collapsed .ldp-avatar{width:20px;height:20px;}
+ .ldp-post.ldp-nested-collapsed .ldp-author,
+ .ldp-post.ldp-nested-collapsed .ldp-user,
+ .ldp-post.ldp-nested-collapsed .ldp-time{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .ldp-post.ldp-nested-collapsed > .ldp-post-head:has(.ldp-time:hover),
+ .ldp-post.ldp-nested-collapsed .ldp-time:hover{overflow:visible;}
+ .ldp-post.ldp-nested-collapsed .ldp-new-user-badge{min-height:18px;padding:1px 5px 1px 4px;gap:3px;font-size:var(--ldp-font-xs,10px);}
+ .ldp-post.ldp-nested-collapsed .ldp-new-user-badge .ldp-icon{width:11px;height:11px;}
+ .ldp-post.ldp-nested-collapsed .ldp-user,
+ .ldp-post.ldp-nested-collapsed .ldp-time,
+ .ldp-post.ldp-nested-collapsed .ldp-floor:not(.ldp-jump-parent):not(.ldp-jump-self){font-size:var(--ldp-font-sm,11px);opacity:.55;}
+ .ldp-post.ldp-nested-collapsed .ldp-floor.ldp-jump-parent,
+ .ldp-post.ldp-nested-collapsed .ldp-floor.ldp-jump-self{font-size:var(--ldp-font-sm,11px);}
+ .ldp-post.ldp-nested-collapsed .ldp-floor.ldp-body-floor{
+ background:color-mix(in srgb,var(--primary,#222) 3%,transparent);}
+ .ldp-post.ldp-nested-collapsed .ldp-op,
+ .ldp-post.ldp-nested-collapsed .ldp-me{font-size:var(--ldp-font-xs,10px);padding:0 4px;}
+ .ldp-post.ldp-nested-collapsed > .ldp-jump-linkbar,
+ .ldp-post.ldp-nested-collapsed > .ldp-content,
+ .ldp-post.ldp-nested-collapsed > .ldp-boost-list,
+ .ldp-post.ldp-nested-collapsed > .ldp-reactions,
+ .ldp-post.ldp-nested-collapsed > .ldp-special-badges,
+ .ldp-post.ldp-nested-collapsed > .ldp-special-notice,
+ .ldp-post.ldp-nested-collapsed > .ldp-system-action,
+ .ldp-post.ldp-nested-collapsed > .ldp-collapse-replies,
+ .ldp-post.ldp-nested-collapsed > .ldp-children,
+ .ldp-post.ldp-nested-collapsed > .ldp-sub-loading,
+ .ldp-post.ldp-nested-collapsed > .ldp-sub-actions{display:none!important;}
+
+ /* 下拉加载评论时的底部加载提示 */
+ .ldp-loading-tip{padding:14px 0;text-align:center;font-size:var(--ldp-font-base,13px);
+ color:var(--primary-medium,#888);display:none;user-select:none;}
+ .ldp-loading-tip.show{display:block;}
+ .ldp-loading-tip .ldp-tip-icon{display:inline-block;margin-right:6px;
+ animation:ldp-spin .9s linear infinite;}
+ .ldp-end-tip{display:none;padding:6px 0;text-align:center;
+ color:var(--primary-medium,#888);font-size:var(--ldp-font-base,13px);line-height:1.2;user-select:none;}
+ .ldp-end-tip.show{display:block;white-space:nowrap;}
+ @keyframes ldp-spin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}
+ @container ldp-reader (max-width:1200px){
+ .ldp-reader-queue{--ldp-reader-queue-rail-size:36px;--ldp-reader-queue-toggle-size:36px;
+ --ldp-reader-queue-toggle-radius:11px;--ldp-reader-queue-toggle-icon-size:16px;
+ --ldp-reader-queue-avatar-size:32px;--ldp-reader-queue-progress-padding:4px;
+ --ldp-reader-queue-gap:7px;--ldp-reader-queue-bubble-gap:6px;--ldp-reader-queue-bubbles-max-height:192px;
+ --ldp-reader-queue-scroll-size:32px;--ldp-reader-queue-scroll-icon-size:22px;
+ --ldp-reader-queue-badge-size:16px;--ldp-reader-queue-status-size:7px;--ldp-reader-queue-loading-size:9px;
+ --ldp-reader-queue-affordance-size:14px;--ldp-reader-queue-pin-size:13px;--ldp-reader-queue-pin-icon-size:8px;
+ left:12px;}
+ }
+ @container ldp-reader (max-width:900px){
+ .ldp-reader-queue{--ldp-reader-queue-rail-size:32px;--ldp-reader-queue-toggle-size:32px;
+ --ldp-reader-queue-toggle-radius:10px;--ldp-reader-queue-toggle-icon-size:14px;
+ --ldp-reader-queue-avatar-size:28px;--ldp-reader-queue-progress-padding:3px;
+ --ldp-reader-queue-gap:6px;--ldp-reader-queue-bubble-gap:5px;--ldp-reader-queue-bubbles-max-height:168px;
+ --ldp-reader-queue-scroll-size:28px;--ldp-reader-queue-scroll-icon-size:18px;
+ --ldp-reader-queue-badge-size:15px;--ldp-reader-queue-status-size:6px;--ldp-reader-queue-loading-size:8px;
+ --ldp-reader-queue-affordance-size:12px;--ldp-reader-queue-pin-size:11px;--ldp-reader-queue-pin-icon-size:7px;
+ left:10px;}
+ .ldp-reader-main{grid-template-columns:
+ minmax(0,min(var(--ldp-layout-left,10%),48px)) minmax(0,1fr)
+ minmax(0,min(var(--ldp-layout-gap,3%),24px)) clamp(52px,var(--ldp-layout-timeline,10%),72px)
+ minmax(0,min(var(--ldp-layout-right,5%),48px));}
+ .ldp-body > *{padding-left:16px;padding-right:16px;}
+ .ldp-topic-timeline{margin:18px 0;padding-inline:2px;}
+ .ldp-topic-timeline-count,.ldp-topic-timeline-preview{left:24px;max-width:calc(100% - 24px);}
+ }
+ @container ldp-reader (min-width:701px) and (max-width:900px){
+ .ldp-modal:has(.ldp-body > .ldp-loadmask) > .ldp-header .ldp-title-topic-row{
+ min-height:calc(30px * var(--ldp-reader-display-scale,1) + 10px);}
+ }
+ @container ldp-reader (max-width:700px){
+ .ldp-reader-queue{left:8px;top:auto;bottom:12px;max-height:none;}
+ .ldp-reader-queue-bubbles,.ldp-reader-queue-scroll-hint{display:none!important;}
+ .ldp-reader-queue-panel{left:calc(var(--ldp-reader-queue-rail-size) + 8px);top:auto;bottom:0;
+ width:min(330px,calc(100cqw - 60px));max-height:min(70vh,520px);}
+ }
+ @media (max-width:700px){
+ .ldp-overlay.ldp-window-managed:not(.ldp-fullpage),
+ .ldp-overlay.ldp-reader-embedded,
+ .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage{
+ --ldp-window-capsule-rail:0px;}
+ .ldp-overlay{align-items:stretch;justify-content:stretch;background:var(--secondary,#fff);pointer-events:auto;}
+ .ldp-modal{width:100vw;height:100vh;border-radius:0;box-shadow:none;}
+ .ldp-reader-window-capsule,.ldp-reader-resize-handle{display:none!important;}
+ }
+ @container ldp-reader (max-width:700px){
+ .ldp-settings-popover{--ldp-settings-frozen-height:68px;left:8px!important;top:8px!important;right:auto;
+ transform:none!important;grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);
+ bottom:8px;width:calc(100cqw - 16px);height:auto;max-width:none;max-height:none;border-radius:10px;}
+ .ldp-settings-tabs{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;
+ width:100%;height:auto;min-height:0;padding:0 44px 0 8px;border-right:0;
+ border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
+ .ldp-settings-brand{display:none;}
+ .ldp-settings-nav-shell{display:contents;}
+ .ldp-settings-nav{min-width:0;min-height:0;flex:none;flex-direction:row;gap:3px;padding:8px 0;
+ overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;scrollbar-width:none;}
+ .ldp-settings-nav-group{display:contents;}
+ .ldp-settings-nav-group-label{display:none;}
+ .ldp-settings-tab{width:auto;flex:none;gap:6px;padding:7px 9px;white-space:nowrap;}
+ .ldp-settings-tab[data-settings-panel="user"]{margin-right:9px;margin-bottom:0;}
+ .ldp-settings-tab[data-settings-panel="user"]::after{content:"";position:absolute;right:-5px;top:0;bottom:0;width:1px;
+ background:var(--ldp-divider-line-color,#e5e5e5);pointer-events:none;}
+ .ldp-settings-footer{margin:0;padding:8px 0 8px 8px;border-top:0;}
+ .ldp-settings-theme{grid-template-columns:repeat(3,30px);}
+ .ldp-settings-panel{padding:0 14px 14px;}
+ .ldp-settings-search-shell{margin:0 -14px;padding-inline:14px 48px;}
+ .ldp-settings-draft-bar{margin-inline:-14px;padding-inline:14px;}
+ .ldp-settings-intro{margin:0 -14px;padding:15px 48px 12px 14px;cursor:default;touch-action:auto;}
+ .ldp-settings-intro::before{display:none;}
+ .ldp-settings-close{top:10px;right:10px;}
+ }
+ @container ldp-reader (max-width:700px){
+ .ldp-reader-history-edge{position:absolute;inset-block:0;z-index:8;width:56px;
+ display:flex;align-items:center;justify-content:center;pointer-events:none;}
+ .ldp-reader-history-edge-back{left:0;}
+ .ldp-reader-history-edge-forward{right:0;}
+ .ldp-reader-history-edge .ldp-reader-history-nav{position:relative;top:auto;left:auto;right:auto;
+ width:44px;height:44px;border-color:transparent;background:transparent;transform:scale(.92);opacity:0;
+ pointer-events:none;
+ transition:opacity 180ms cubic-bezier(.22,1,.36,1),transform 180ms cubic-bezier(.22,1,.36,1);}
+ .ldp-reader-history-edge.is-active .ldp-reader-history-nav,
+ .ldp-history-buttons-always-visible .ldp-reader-history-edge .ldp-reader-history-nav,
+ .ldp-reader-history-edge .ldp-reader-history-nav:focus-visible{opacity:.35;transform:scale(1);pointer-events:auto;}
+ .ldp-reader-history-edge .ldp-reader-history-nav::before{content:"";position:absolute;inset:5px;
+ border:1px solid var(--primary-low,#ccc);border-radius:50%;background:var(--secondary,#fff);}
+ .ldp-reader-history-edge .ldp-reader-history-nav .ldp-icon{position:relative;z-index:1;}
+ .ldp-reader-history-edge .ldp-reader-history-nav:hover{border-color:transparent;background:transparent;}
+ .ldp-reader-history-edge .ldp-reader-history-nav:hover::before{
+ border-color:var(--primary-medium,#999);background:var(--primary-very-low,#f0f0f0);}
+ .ldp-reader-history-edge .ldp-reader-history-nav:focus-visible{outline:none;}
+ .ldp-reader-history-edge .ldp-reader-history-nav:focus-visible::before{
+ outline:2px solid var(--tertiary,#47855f);outline-offset:2px;}
+ .ldp-reader-history-edge .ldp-reader-history-nav:active{opacity:1;transform:scale(.96);}
+ .ldp-header{--ldp-header-logo-inset:10px;--ldp-header-reserved-right:138px;
+ --ldp-header-compact-padding-block:clamp(2px,calc(5px - .5cqw),3px);
+ --ldp-header-fluid-row-gap:clamp(0px,calc(1.4cqw - 6.7px),3px);
+ --ldp-header-row-tighten:clamp(0px,calc(12px - 2.5cqw),3px);
+ --ldp-home-logo-size:28px;--ldp-home-logo-box-size:32px;
+ display:grid;grid-template-columns:var(--ldp-home-logo-box-size) minmax(0,1fr) auto;grid-template-rows:auto auto;
+ align-items:center;gap:4px 8px;min-height:calc(66px + var(--ldp-window-capsule-rail,0px));}
+ .ldp-home-logo{grid-column:1;grid-row:1 / 3;align-self:center;
+ width:var(--ldp-home-logo-box-size);height:var(--ldp-home-logo-box-size);}
+ .ldp-title-wrap{display:contents;}
+ .ldp-topic-edit-layer{padding:10px;}
+ .ldp-reader-action-body.ldp-topic-edit-body{grid-template-columns:minmax(0,1fr);}
+ .ldp-topic-edit-title,.ldp-topic-edit-status{grid-column:1;}
+ .ldp-topic-edit-category-menu{min-width:0;}
+ .ldp-topic-edit-category-options{max-height:min(220px,32vh);}
+ .ldp-title{grid-column:2 / -1;grid-row:1;min-width:0;display:block;
+ min-height:calc(26px * var(--ldp-reader-display-scale,1));
+ font-size:var(--ldp-reader-title-font-size,16px);line-height:1.2;}
+ .ldp-title-jump{min-width:0;display:block;overflow:visible;text-overflow:clip;white-space:normal;}
+ .ldp-title-actions{--ldp-header-action-gap:6px;top:calc(6px + var(--ldp-window-capsule-rail,0px));right:8px;}
+ .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open){width:26px;height:26px;flex-basis:26px;padding:3px;}
+ .ldp-title-subline{grid-column:1 / 3;grid-row:2;min-width:0;padding-left:calc(var(--ldp-home-logo-box-size) + 8px);
+ align-items:start;justify-items:start;gap:4px;overflow:visible;white-space:normal;text-align:left;}
+ .ldp-meta{overflow:visible;text-overflow:clip;white-space:normal;}
+ .ldp-title-topic-row{min-width:0;overflow:visible;}
+ .ldp-topic-tags{flex-wrap:wrap;min-width:0;overflow:visible;}
+ .ldp-head-btns{grid-column:3;grid-row:2;min-width:0;gap:3px;justify-content:flex-end;margin-top:0;padding-top:8px;}
+ .ldp-layout-toggle{display:none;}
+ .ldp-layout-toggle,.ldp-notifications-toggle,.ldp-history-toggle,.ldp-bookmarks-toggle,.ldp-translate-toggle,.ldp-settings-toggle{
+ width:26px;height:26px;padding:3px;}
+ .ldp-reader-refresh.ldp-icon-btn,.ldp-close.ldp-icon-btn{width:26px;height:26px;padding:2px;}
+ .ldp-reader-main{grid-template-columns:
+ minmax(0,min(var(--ldp-layout-left,10%),20px)) minmax(0,1fr)
+ minmax(0,min(var(--ldp-layout-gap,3%),12px)) clamp(44px,var(--ldp-layout-timeline,12%),52px)
+ minmax(0,min(var(--ldp-layout-right,7%),20px));}
+ .ldp-body > *{padding-left:14px;padding-right:14px;}
+ .ldp-topic-timeline{grid-column:4;width:100%;margin:12px 0;padding:0;justify-self:stretch;}
+ .ldp-topic-timeline-date,.ldp-topic-timeline-relative{padding:0;font-size:var(--ldp-font-micro,9px);line-height:1.2;}
+ .ldp-topic-timeline-footer{gap:6px;}
+ .ldp-topic-timeline-top{width:20px;height:20px;}
+ .ldp-topic-timeline-top .ldp-icon{width:12px;height:12px;}
+ .ldp-topic-timeline-track::before{left:5px;}
+ .ldp-topic-timeline-cursor{left:14px;min-width:34px;height:68px;}
+ .ldp-topic-timeline-cursor > span{transform:translate3d(var(--ldp-lens-shift-x,0),calc(-50% + var(--ldp-lens-offset,0) * 13px),0) scale(var(--ldp-lens-scale,1));font-size:13px;}
+ .ldp-topic-timeline-thumb{left:1px;}
+ .ldp-topic-timeline-count,
+ .ldp-topic-timeline-preview{left:14px;max-width:calc(100% - 14px);font-size:var(--ldp-font-micro,9px);gap:0;}
+ .ldp-layout-toggle span,.ldp-notifications-toggle > span,.ldp-history-toggle > span,.ldp-bookmarks-toggle > span,.ldp-translate-toggle span,.ldp-settings-toggle span{display:none;}
+ }
+ @container ldp-reader (min-width:481px) and (max-width:700px){
+ .ldp-header{--ldp-header-reserved-right:var(--ldp-header-logo-inset);
+ grid-template-columns:var(--ldp-home-logo-box-size) minmax(0,1fr) 113px;
+ grid-template-rows:auto auto auto;row-gap:var(--ldp-header-fluid-row-gap);min-height:0;
+ padding-top:calc(var(--ldp-header-compact-padding-block) + var(--ldp-window-capsule-rail,0px));
+ padding-bottom:var(--ldp-header-compact-padding-block);}
+ .ldp-modal:has(.ldp-body > .ldp-loadmask) > .ldp-header{
+ min-height:calc(var(--ldp-window-capsule-rail,0px) +
+ 58px * var(--ldp-reader-display-scale,1) + 16px);}
+ .ldp-home-logo{grid-row:1 / 4;}
+ .ldp-title{grid-column:2;min-height:0;padding-right:0;}
+ .ldp-header.ldp-title-single-line .ldp-title{line-height:1.1;}
+ .ldp-title-subline{display:contents;}
+ .ldp-meta-row{grid-column:2;grid-row:2;}
+ .ldp-title-topic-row{grid-column:2;grid-row:3;width:calc(100% - 16px);max-width:calc(100% - 16px);
+ box-sizing:border-box;align-self:end;contain:paint;overflow:hidden;}
+ .ldp-topic-tags{min-width:max-content;flex-wrap:nowrap;overflow:visible;}
+ .ldp-header :is(.ldp-title,.ldp-meta-row,.ldp-topic-tags){padding-block:0;}
+ .ldp-head-btns{position:absolute;right:var(--ldp-header-logo-inset);bottom:4px;
+ margin:0;padding-top:0;}
+ }
+ @container ldp-reader (max-width:480px){
+ .ldp-time[data-exact-time]::after{right:0;bottom:auto;left:auto;top:calc(100% + 6px);box-sizing:border-box;
+ transform:none;padding:4px 7px;border:0;border-radius:7px;background:rgba(28,28,28,.86);color:#fff;
+ box-shadow:0 2px 6px rgba(0,0,0,.18);font-size:var(--ldp-font-ui,12px);font-weight:500;line-height:1.35;opacity:1;}
+ .ldp-topic-footer-actions{box-sizing:border-box;width:100%;gap:2px;padding-inline:2px;
+ justify-content:space-between;overflow:visible;}
+ .ldp-topic-footer-actions > *{flex:none;}
+ .ldp-topic-footer-actions :is(.ldp-topic-footer-button,.ldp-topic-footer-link,.ldp-topic-notification){
+ width:30px;height:30px;padding:5px;}
+ .ldp-topic-footer-actions .ldp-topic-shared-issue{width:44px;min-width:44px;gap:2px;padding-inline:4px;}
+ .ldp-topic-footer-actions .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-label{display:none;}
+ .ldp-topic-footer-actions .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-count{
+ margin-left:0;font-size:var(--ldp-font-micro,9px);}
+ .ldp-topic-footer-actions .ldp-topic-footer-separator{display:none;}
+ .ldp-post-head{flex-wrap:nowrap;gap:6px;}
+ .ldp-post-head > .ldp-author{min-width:3ch;max-width:34%;flex:1 1 auto;}
+ .ldp-post-head > .ldp-user{min-width:0;max-width:26%;flex:1 1 auto;}
+ .ldp-floor-preview > .ldp-post-head{padding-right:44px;gap:6px;}
+ .ldp-floor-preview-close-hint{display:none;}
+ .ldp-floor-preview-close{right:6px;width:28px;padding:0;}
+ .ldp-header{--ldp-header-logo-inset:8px;--ldp-header-reserved-right:8px;
+ grid-template-columns:var(--ldp-home-logo-box-size) minmax(0,1fr) minmax(110px,auto);
+ grid-template-rows:auto auto auto;
+ row-gap:var(--ldp-header-fluid-row-gap);column-gap:8px;min-height:0;
+ padding:calc(var(--ldp-header-compact-padding-block) + var(--ldp-window-capsule-rail,0px))
+ 8px var(--ldp-header-compact-padding-block);}
+ .ldp-home-logo{grid-column:1;grid-row:1 / 3;}
+ .ldp-header .ldp-title{grid-column:2;grid-row:1;padding-right:0;}
+ .ldp-title-subline{display:contents;}
+ .ldp-meta-row{grid-column:2;grid-row:2;display:block;min-width:0;width:100%;
+ align-self:end;margin-top:0;overflow:visible;white-space:normal;}
+ .ldp-meta{display:flex;min-width:0;width:100%;align-items:center;flex-wrap:wrap;overflow:visible;white-space:normal;}
+ .ldp-meta-stats{display:block;min-width:0;flex:0 1 auto;overflow:visible;text-overflow:clip;white-space:normal;}
+ .ldp-meta-owner{display:inline-flex;min-width:0;flex:0 1 auto;overflow:visible;}
+ .ldp-meta-owner-copy{min-width:0;overflow:visible;text-overflow:clip;white-space:normal;}
+ .ldp-title-topic-row{grid-column:2;grid-row:3;display:grid!important;min-width:0;
+ width:calc(100% - 16px);max-width:calc(100% - 16px);box-sizing:border-box;
+ align-self:end;align-items:center;margin-top:calc(0px - var(--ldp-header-row-tighten));
+ contain:paint;overflow:hidden;}
+ .ldp-topic-tags{min-width:max-content;flex-wrap:nowrap;overflow:visible;}
+ .ldp-head-btns{position:absolute;right:var(--ldp-header-logo-inset);bottom:var(--ldp-header-compact-padding-block);
+ gap:3px;margin:0;padding:0;}
+ .ldp-title-actions{--ldp-header-action-gap:4px;top:calc(7px + var(--ldp-window-capsule-rail,0px));right:8px;}
+ .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open){width:24px;height:24px;flex-basis:24px;padding:2px;}
+ .ldp-layout-toggle,.ldp-notifications-toggle,.ldp-history-toggle,.ldp-bookmarks-toggle,.ldp-translate-toggle,.ldp-settings-toggle{
+ width:24px;height:24px;padding:2px;}
+ .ldp-reader-refresh.ldp-icon-btn,.ldp-close.ldp-icon-btn{width:24px;height:24px;padding:2px;}
+ }
+ :is(.ldp-modal,.ldp-settings-layer).ldp-settings-surface-short .ldp-settings-popover{--ldp-settings-frozen-height:60px;}
+ :is(.ldp-modal,.ldp-settings-layer).ldp-settings-surface-short .ldp-settings-brand-logo{width:38px;height:38px;}
+ :is(.ldp-modal,.ldp-settings-layer).ldp-settings-surface-short .ldp-settings-nav{padding-top:6px;}
+ :is(.ldp-modal,.ldp-settings-layer).ldp-settings-surface-short .ldp-settings-tab{padding-top:7px;padding-bottom:7px;}
+ :is(.ldp-modal,.ldp-settings-layer).ldp-settings-surface-short .ldp-settings-intro{padding-top:12px;padding-bottom:9px;}
+ .ldp-modal.ldp-reader-surface-short .ldp-header{
+ padding-top:calc(4px + var(--ldp-window-capsule-rail,0px));padding-bottom:4px;}
+ .ldp-modal.ldp-reader-surface-short .ldp-topic-timeline{margin-top:12px;margin-bottom:12px;}
+ @media (prefers-reduced-motion:reduce){
+ @container ldp-reader (max-width:700px){
+ .ldp-reader-history-edge .ldp-reader-history-nav{transition:none;}
+ }
+ }
+ :where(.ldp-overlay,.ldp-settings-layer,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)
+ input:is([type="text"],[type="url"],[type="number"],[type="search"],[type="email"],[type="password"],[type="tel"],:not([type])){
+ --ldp-input-box-height:34px;--ldp-input-border-block:2px;
+ --ldp-input-optical-shift:clamp(1px,.16em,2px);
+ box-sizing:border-box;height:var(--ldp-input-box-height);
+ padding-top:max(0px,calc((var(--ldp-input-box-height) - 1em - var(--ldp-input-border-block))/2 - var(--ldp-input-optical-shift)));
+ padding-bottom:max(0px,calc((var(--ldp-input-box-height) - 1em - var(--ldp-input-border-block))/2 + var(--ldp-input-optical-shift)));
+ text-align:left;line-height:1;}
+ :where(.ldp-overlay,.ldp-settings-layer,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)
+ input:is([type="text"],[type="url"],[type="number"],[type="search"],[type="email"],[type="password"],[type="tel"],:not([type]))::placeholder{
+ text-align:left;line-height:1;}
+ .ldp-settings-panel :is(.ldp-performance-control input,.ldp-reader-window-input,.ldp-boost-rule-control),
+ .ldp-popover-search-input{--ldp-input-box-height:32px;}
+ .ldp-settings-panel .ldp-custom-site-input{--ldp-input-box-height:34px;}
+ :is(.ldp-picker-search input,.ldp-topic-edit-label-input,.ldp-popover-search-input){
+ --ldp-input-border-block:0px;}
+ :is(.ldp-picker-search input,.ldp-user-card-follow-search input){--ldp-input-box-height:30px;}
+ .ldp-topic-edit-label-input{--ldp-input-box-height:26px;}
+ .ldp-topic-edit-field > input{--ldp-input-box-height:38px;}
+ .ldp-popover-search-input{
+ padding-top:max(0px,calc((var(--ldp-input-box-height) - 1em - var(--ldp-input-border-block))/2 - var(--ldp-input-optical-shift)))!important;
+ padding-bottom:max(0px,calc((var(--ldp-input-box-height) - 1em - var(--ldp-input-border-block))/2 + var(--ldp-input-optical-shift)))!important;}
+
+/* Static rules formerly authored inside work/main.js. */
+html[data-ldp-shadow-context-html],body[data-ldp-shadow-context-body]{display:contents!important;}
+html[data-ldp-font-mac-smoothing]{
+ --ldp-font-webkit-smoothing:antialiased;--ldp-font-moz-smoothing:grayscale;}
+.ldp-composer-wheel-layer{position:fixed;inset:0;z-index:2147483590;pointer-events:auto;}
+
+.ldp-reader-queue{
+ --ldp-reader-queue-rail-size:calc(34px * var(--ldp-reader-display-scale,1));
+ --ldp-reader-queue-toggle-size:var(--ldp-reader-queue-rail-size);
+ --ldp-reader-queue-toggle-radius:7px;
+ --ldp-reader-queue-toggle-icon-size:18px;}
+.ldp-reader-queue-toggle{border:0;background:transparent;color:inherit;box-shadow:none;}
+.ldp-reader-queue-toggle:hover{background:var(--primary-very-low,#f4f4f4);}
+.ldp-reader-queue.is-empty .ldp-reader-queue-toggle b{opacity:0;transition:opacity .14s ease-out;}
+.ldp-reader-queue.is-empty:is(:hover,:focus-within) .ldp-reader-queue-toggle b{opacity:1;}
+.ldp-topic-action-rail{
+ position:absolute;z-index:24;display:flex;flex-direction:column;gap:2px;padding:2px;
+ border:0;border-radius:0;background:transparent;box-shadow:none;
+ transition:transform .2s cubic-bezier(.22,1,.36,1);}
+.ldp-topic-action-rail-visible .ldp-reader-main{
+ grid-template-columns:minmax(58px,var(--ldp-layout-left,10%)) minmax(0,1fr)
+ minmax(0,var(--ldp-layout-gap,3%)) minmax(0,var(--ldp-layout-timeline,10%))
+ minmax(0,var(--ldp-layout-right,5%));}
+@container ldp-reader (max-width:900px){
+ .ldp-topic-action-rail-visible .ldp-reader-main{
+ grid-template-columns:58px minmax(0,1fr) minmax(0,min(var(--ldp-layout-gap,3%),24px))
+ clamp(52px,var(--ldp-layout-timeline,10%),72px) minmax(0,min(var(--ldp-layout-right,5%),48px));}}
+@container ldp-reader (max-width:700px){
+ .ldp-topic-action-rail-visible .ldp-reader-main{
+ grid-template-columns:58px minmax(0,1fr) minmax(0,min(var(--ldp-layout-gap,3%),12px))
+ clamp(44px,var(--ldp-layout-timeline,12%),52px) minmax(0,min(var(--ldp-layout-right,7%),20px));}}
+.ldp-topic-action-rail[hidden],.ldp-topic-action-more[hidden]{display:none!important;}
+.ldp-topic-action-rail.is-dragging{user-select:none;}
+.ldp-topic-action-more{display:flex;flex-direction:column;gap:2px;}
+.ldp-topic-action-rail-visible:has(.ldp-topic-action-rail.is-expanded) .ldp-reader-history-nav{
+ opacity:0!important;pointer-events:none!important;}
+.ldp-topic-action-rail:is(.is-docked-left,.is-docked-right):not(.is-dragging):not(:hover):not(:focus-within){
+ pointer-events:none;}
+.ldp-topic-action-rail:is(.is-docked-left,.is-docked-right):not(.is-dragging):not(:hover):not(:focus-within)>:not(.ldp-topic-action-rail-toggle){
+ visibility:hidden;}
+.ldp-topic-action-rail:is(.is-docked-left,.is-docked-right)>.ldp-topic-action-rail-toggle{pointer-events:auto;}
+.ldp-topic-action-rail :is(button,.ldp-topic-notification){
+ box-sizing:border-box;width:calc(34px * var(--ldp-reader-display-scale,1));
+ height:calc(34px * var(--ldp-reader-display-scale,1));min-width:calc(34px * var(--ldp-reader-display-scale,1));
+ display:inline-flex;align-items:center;justify-content:center;padding:6px;border:0;border-radius:7px;
+ background:transparent;color:inherit;}
+.ldp-topic-action-rail :is(button,.ldp-topic-notification):hover{background:var(--primary-very-low,#f4f4f4);}
+.ldp-topic-action-rail :is(.ldp-reactions,.ldp-actions,.ldp-topic-footer-actions){display:contents;}
+.ldp-topic-action-rail .ldp-icon{width:18px;height:18px;}
+.ldp-topic-action-rail .ldp-like{
+ position:relative;height:calc(42px * var(--ldp-reader-display-scale,1));
+ flex-direction:column;gap:1px;padding-block:3px;}
+.ldp-topic-action-rail .ldp-like-count{font-size:9px;line-height:1;opacity:.72;}
+.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip{
+ width:auto;min-width:18px;height:calc(42px * var(--ldp-reader-display-scale,1));
+ flex-direction:column;gap:1px;padding:3px 3px 3px 0;
+ align-items:center;justify-content:center;}
+.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip img.emoji,
+.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip img.emoji.only-emoji{
+ width:18px;height:18px;transform-origin:center;
+ transition:transform .16s cubic-bezier(.22,1,.36,1);}
+.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip:hover>img.emoji,
+.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip:focus-visible>img.emoji{
+ transform:scale(1.2);}
+@media (prefers-reduced-motion:reduce){
+ .ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip img.emoji{
+ transition:none;}}
+.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip>b{
+ display:block;min-height:9px;font-size:9px;font-weight:inherit;line-height:1;opacity:.72;}
+.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip.on>b{
+ color:var(--tertiary,#47855f);font-weight:700;opacity:1;}
+.ldp-topic-action-rail .ldp-reaction-picker{
+ left:100%;right:auto;bottom:0;
+ height:calc(42px * var(--ldp-reader-display-scale,1));
+ min-height:calc(42px * var(--ldp-reader-display-scale,1));
+ padding:4px 6px 4px 2px;border-radius:0 7px 7px 0;
+ background:var(--primary-very-low,#f4f4f4);
+ flex-direction:row;flex-wrap:nowrap;}
+.ldp-topic-action-rail .ldp-reaction-like-picker:has(> .ldp-like > .ldp-topic-action-rail-reaction-badge)
+ >.ldp-reaction-picker{padding-left:14px;}
+.ldp-topic-action-rail.is-docked-right .ldp-reaction-picker{
+ left:100%;right:auto;}
+.ldp-topic-action-rail .ldp-reaction-like-picker:has(> .ldp-reaction-picker:not([hidden]))>.ldp-like{
+ border-radius:7px 0 0 7px;background:var(--primary-very-low,#f4f4f4);}
+.ldp-topic-action-rail :is(button,.ldp-topic-notification)>span:not(.ldp-like-count){display:none;}
+.ldp-topic-action-rail .ldp-like>span.ldp-topic-action-rail-reaction-badge{
+ position:absolute;z-index:4;right:-3px;top:-3px;display:inline-flex;width:15px;height:15px;
+ align-items:center;justify-content:center;border-radius:50%;
+ background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));
+ box-shadow:0 1px 4px rgba(0,0,0,.2);
+ pointer-events:none;}
+.ldp-topic-action-rail .ldp-topic-action-rail-reaction-badge img.emoji{
+ width:12px;height:12px;margin:0;object-fit:contain;}
+.ldp-topic-action-rail-toggle{cursor:grab;touch-action:none;}
+.ldp-topic-action-rail.is-dragging .ldp-topic-action-rail-toggle{cursor:grabbing;}
+.ldp-topic-action-more :is(.ldp-topic-bookmark,.ldp-topic-reply,.ldp-topic-footer-separator){display:none!important;}
+.ldp-topic-action-rail-visible :is(
+ .ldp-topic-timeline-top,
+ .ldp-post[data-post-number="1"]>.ldp-topic-footer-actions,
+ .ldp-post[data-post-number="1"]>.ldp-reactions){display:none!important;}
+.ldp-topic-timeline-jump{
+ flex:none;box-sizing:border-box;min-width:20px;height:24px;padding:0 4px;border:0;border-radius:5px;
+ background:transparent;color:inherit;font:600 var(--ldp-font-ui,12px)/1 var(--ldp-interface-font-family,inherit);
+ cursor:pointer;pointer-events:auto;}
+.ldp-topic-timeline-jump:hover,.ldp-topic-timeline-jump[aria-expanded="true"]{
+ background:var(--primary-very-low,#f4f4f4);color:var(--primary,#222);}
+.ldp-topic-timeline-jump:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:1px;}
+.ldp-topic-timeline-jump-form{
+ position:absolute;right:0;bottom:31px;z-index:4;box-sizing:border-box;display:grid;
+ grid-template-columns:minmax(0,auto) 28px;align-items:center;gap:4px 5px;width:max-content;min-width:126px;
+ padding:5px;border:1px solid var(--primary-low,#d7d7d7);border-radius:8px;
+ background:var(--secondary,#fff);box-shadow:0 5px 18px rgba(0,0,0,.16);pointer-events:auto;}
+.ldp-topic-timeline-jump-form[hidden]{display:none;}
+.ldp-topic-timeline-jump-field{
+ display:flex;align-items:center;gap:3px;color:var(--primary-medium,#777);
+ font:600 var(--ldp-font-ui,12px)/1 var(--ldp-interface-font-family,inherit);}
+.ldp-topic-timeline-jump-input{
+ box-sizing:border-box;width:62px;height:28px;margin:0!important;padding:4px 6px!important;
+ border:1px solid var(--primary-low,#d7d7d7)!important;border-radius:5px!important;
+ background:var(--secondary,#fff)!important;color:var(--primary,#222)!important;
+ font:inherit!important;font-variant-numeric:tabular-nums;appearance:textfield;}
+.ldp-topic-timeline-jump-input::-webkit-inner-spin-button{appearance:none;margin:0;}
+.ldp-topic-timeline-jump-input:focus{
+ border-color:var(--tertiary,#47855f)!important;
+ outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 24%,transparent);outline-offset:0;}
+.ldp-topic-timeline-jump-submit{
+ box-sizing:border-box;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;
+ padding:0;border:0;border-radius:5px;background:var(--tertiary,#47855f);color:#fff;cursor:pointer;}
+.ldp-topic-timeline-jump-submit:hover{filter:brightness(1.06);}
+.ldp-topic-timeline-jump-submit .ldp-icon{width:14px;height:14px;}
+.ldp-topic-timeline-jump-hint{
+ grid-column:1/-1;min-height:11px;color:var(--primary-medium,#777);
+ font:500 var(--ldp-font-micro,9px)/1.2 var(--ldp-interface-font-family,inherit);white-space:nowrap;}
+.ldp-topic-timeline-jump-form.is-invalid .ldp-topic-timeline-jump-input{
+ border-color:var(--danger,#c9362b)!important;}
+.ldp-topic-timeline-jump-form.is-invalid .ldp-topic-timeline-jump-hint{color:var(--danger,#c9362b);}
+.ldp-topic-timeline-jump-submit:disabled{opacity:.36;filter:none;cursor:not-allowed;}
+.ldp-reaction-summary:has(> .ldp-reaction-picker-current:only-child){
+ min-height:30px;padding:4px 9px;background:color-mix(in srgb,var(--primary,#222) 2%,transparent);}
+.ldp-post.ldp-has-reactions > .ldp-reactions .ldp-like{display:inline-flex;}
+.ldp-modal .ldp-icon{zoom:var(--ldp-reader-display-scale,1);}
+.ldp-head-btns>button{
+ width:calc(30px * var(--ldp-reader-display-scale,1));height:calc(30px * var(--ldp-reader-display-scale,1));}
+.ldp-title-actions>:is(button,a),.ldp-icon-btn{
+ width:calc(28px * var(--ldp-reader-display-scale,1));height:calc(28px * var(--ldp-reader-display-scale,1));
+ flex-basis:calc(28px * var(--ldp-reader-display-scale,1));}
+.ldp-post>.ldp-reactions :is(.ldp-btn,.ldp-reaction-add){
+ min-width:calc(20px * var(--ldp-reader-display-scale,1));height:calc(20px * var(--ldp-reader-display-scale,1));}
+
+.ldp-connect-card-credit{
+ display:flex;flex-direction:column;min-height:100%;box-sizing:border-box;gap:4px;padding:7px;}
+.ldp-connect-card-credit .ldp-connect-heading{
+ display:flex;min-width:0;flex:1;align-items:baseline;gap:8px;}
+.ldp-connect-card-credit .ldp-connect-heading strong{flex:none;}
+.ldp-connect-card-credit .ldp-connect-heading small{
+ min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+.ldp-connect-card-credit .ldp-connect-group{gap:3px;}
+.ldp-connect-card-credit .ldp-connect-quotas{gap:3px;}
+.ldp-connect-card-credit .ldp-user-card-stats{gap:5px;margin:0;}
+.ldp-connect-card-credit > .ldp-connect-detail-groups{padding-top:6px;}
+.ldp-connect-card-credit .ldp-connect-quota{
+ min-height:28px;box-sizing:border-box;align-content:center;padding-block:3px;}
+.ldp-connect-metric.is-danger{--ldp-connect-color:var(--danger,#c84545);}
+.ldp-connect-credit-stat{
+ display:grid;align-content:center;min-height:54px;box-sizing:border-box;}
+.ldp-connect-credit-actions{
+ margin:auto 0 0;padding-top:7px;border-top:1px solid var(--ldp-divider-line-color,#e5e5e5);}
+.ldp-connect-credit-actions .ldp-user-card-action{
+ box-sizing:border-box;height:38px;font-size:var(--ldp-font-ui,12px);font-weight:600;text-decoration:none;}
+.ldp-connect-credit-actions .ldp-user-card-action span{margin-left:5px;}
+.ldp-user-info-view[data-user-info-panel="credit"]{
+ box-sizing:border-box;min-height:0;padding-top:10px;overflow-y:auto;overscroll-behavior:contain;
+ scrollbar-width:none;-ms-overflow-style:none;}
+.ldp-user-info-view[data-user-info-panel="credit"]::-webkit-scrollbar{display:none;}
+
+.ldp-descendant-replies-layer{inset:0;pointer-events:auto;}
+.ldp-descendant-replies-layer-inline{
+ position:absolute;z-index:30;background:var(--primary-very-low,#f6f6f6);}
+.ldp-descendant-replies-layer-centered{position:fixed;z-index:2147483590;}
+.ldp-descendant-replies-window{
+ position:absolute;overflow:hidden;border:1px solid var(--primary-low,#ddd);
+ background:var(--secondary,#fff);}
+.ldp-descendant-replies-inline{
+ inset:20px;z-index:31;width:auto;height:auto;min-width:0;min-height:0;border-radius:10px;box-shadow:none;}
+.ldp-descendant-replies-centered{
+ z-index:2147483647;left:50%;top:50%;width:min(960px,calc(100vw - 48px));height:min(78vh,720px);
+ min-width:320px;min-height:240px;max-width:calc(100vw - 32px);max-height:calc(100vh - 32px);
+ transform:translate(-50%,-50%);border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.24);}
+.ldp-descendant-replies-host-open{overflow:visible;z-index:2147483590;clip-path:none!important;}
+.ldp-descendant-replies-header{
+ height:46px;display:flex;align-items:center;gap:8px;padding:0 10px;
+ border-bottom:1px solid var(--primary-low,#ddd);color:var(--primary-medium,#777);
+ font-size:var(--ldp-font-ui,12px);cursor:default;user-select:none;}
+.ldp-descendant-replies-close{
+ flex:none;max-width:none;font-family:inherit;font-size:inherit;font-weight:600;line-height:1;}
+.ldp-descendant-replies-title{flex:1;max-width:none;}
+.ldp-descendant-replies-list{
+ position:relative;height:calc(100% - 47px);box-sizing:border-box;overflow:auto;
+ overscroll-behavior:contain;padding:12px 18px 12px 40px;}
+.ldp-thread-group-svg{
+ left:0;top:0;width:100%;height:var(--ldp-thread-group-height);}
+.ldp-actions .ldp-btn.ldp-descendant-replies-reroot{width:auto;padding-inline:8px;}
+.ldp-descendant-replies-reroot.was-opened{color:var(--tertiary,#47855f);}
+.ldp-descendant-replies-reroot > span{font-size:var(--ldp-font-xs,10px);}
+.ldp-descendant-replies-window .ldp-post[data-ldp-branch-depth]{
+ box-sizing:border-box;margin-inline-start:var(--ldp-descendant-branch-indent);
+ width:calc(100% - var(--ldp-descendant-branch-indent));}
+.ldp-descendant-replies-return{margin-left:0;padding:1px 4px;}
+.ldp-descendant-replies-open{outline:none!important;box-shadow:none!important;}
+
+.ldp-settings-section[data-settings-panel="user"]{
+ height:100%;min-height:0;grid-template-rows:auto minmax(0,1fr);}
+.ldp-settings-section[data-settings-panel="user"] .ldp-user-info-content{
+ min-height:0;grid-template-rows:auto minmax(0,1fr);overflow:hidden;}
+.ldp-topic-action-rail-reset-row{cursor:default;}
+.ldp-reader-queue.is-preview-reversed{flex-direction:column-reverse;}
+.ldp-reader-queue.is-runtime-positioned{bottom:auto;}
+.ldp-reader-queue .ldp-reader-queue-panel.is-collision-positioned{right:auto;bottom:auto;}
+.ldp-head-btns.is-content-aligned{right:0;}
+.ldp-structure-colors-disabled{--ldp-divider-line-color:transparent;}
+.ldp-nested-branch-toggle{left:0;}
+.ldp-sub-actions.show{display:flex;}
+.ldp-sub-loading.show{display:block;}
+.ldp-boost-picker-positioned{
+ position:fixed!important;
+ left:var(--ldp-boost-picker-left,0px)!important;top:var(--ldp-boost-picker-top,0px)!important;
+ right:auto!important;bottom:auto!important;margin:0!important;transform:none!important;translate:none!important;}
+.ldp-boost-picker-constrained .emoji-picker__content{
+ height:auto!important;min-height:0!important;flex:1 1 auto!important;}
+.ldp-boost-picker-constrained .emoji-picker__sections-nav{height:100%!important;}
+.ldp-color-picker-hue{
+ --ldp-color-slider:linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00);}
+.ldp-color-picker-saturation{
+ --ldp-color-slider:linear-gradient(to right,var(--ldp-color-slider-start),var(--ldp-color-slider-end));}
+.ldp-color-picker-brightness{
+ --ldp-color-slider:linear-gradient(to right,#000000,var(--ldp-color-slider-end));}
+.ldp-color-picker-preset[data-color="#F0FFF0"]{--ldp-color-picker-preset:#F0FFF0;}
+.ldp-color-picker-preset[data-color="#FFFFFF"]{--ldp-color-picker-preset:#FFFFFF;}
+.ldp-color-picker-preset[data-color="#E5E7EB"]{--ldp-color-picker-preset:#E5E7EB;}
+.ldp-color-picker-preset[data-color="#94A3B8"]{--ldp-color-picker-preset:#94A3B8;}
+.ldp-color-picker-preset[data-color="#475569"]{--ldp-color-picker-preset:#475569;}
+.ldp-color-picker-preset[data-color="#111827"]{--ldp-color-picker-preset:#111827;}
+.ldp-color-picker-preset[data-color="#47855F"]{--ldp-color-picker-preset:#47855F;}
+.ldp-color-picker-preset[data-color="#22C55E"]{--ldp-color-picker-preset:#22C55E;}
+.ldp-color-picker-preset[data-color="#2563EB"]{--ldp-color-picker-preset:#2563EB;}
+.ldp-color-picker-preset[data-color="#7C3AED"]{--ldp-color-picker-preset:#7C3AED;}
+.ldp-color-picker-preset[data-color="#D97706"]{--ldp-color-picker-preset:#D97706;}
+.ldp-color-picker-preset[data-color="#DC2626"]{--ldp-color-picker-preset:#DC2626;}
+.ldp-reader-hit-test-hidden{display:none!important;}
- /* 楼中楼分页按钮 */
- .ldp-collapse-replies{display:none;margin:8px 0 2px 22px;padding-left:0;
- width:calc(100% - 22px);justify-content:flex-start;
- font-size:var(--ldp-font-ui,12px);color:var(--tertiary,#08c);font-weight:600;}
- .ldp-collapse-replies.show{display:flex;}
- .ldp-sub-actions{margin-top:6px;display:none;align-items:center;gap:8px;font-size:var(--ldp-font-ui,12px);}
- .ldp-sub-page-btn{font-size:var(--ldp-font-ui,12px);color:var(--tertiary,#08c);font-weight:600;
- opacity:.9;padding:4px 0;}
- .ldp-sub-page-btn:hover:not(:disabled){opacity:1;text-decoration:underline;}
- .ldp-sub-page-btn:disabled{opacity:.35;cursor:default;}
- .ldp-topic-edit-category-trigger[aria-expanded="true"] .ldp-icon,.ldp-notification-prev-icon,
- .ldp-font-family-trigger[aria-expanded="true"] .ldp-icon,.ldp-reader-history-back .ldp-icon,
- .ldp-content .ldp-callout--foldable:not(.ldp-callout--collapsed) > .ldp-callout-toggle .ldp-icon,
- .ldp-content aside.ldp-post-quote.ldp-quote-expanded .ldp-quote-toggle .ldp-icon,
- .ldp-content .ldp-media-carousel-controls button:first-child .ldp-icon,.ldp-user-card-badge-scroll.is-prev .ldp-icon,
- .ldp-lb-prev .ldp-icon,.ldp-sub-page-prev .ldp-icon{transform:rotate(180deg);}
- .ldp-sub-page-info{color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);}
- .ldp-sub-loading{font-size:var(--ldp-font-ui,12px);opacity:.5;margin-left:22px;padding-left:14px;
- margin-top:2px;display:none;}
- .ldp-jump-linkbar{display:flex;align-items:center;gap:8px;margin:0 0 6px;
- font-size:var(--ldp-font-ui,12px);}
- .ldp-jump-backbar{margin-bottom:8px;}
- .ldp-jump-linkbar .ldp-jump-parent,.ldp-jump-back{color:var(--tertiary,#08c);font-weight:var(--ldp-reader-font-weight-base,400);}
- .ldp-jump-highlight{position:relative;border-radius:var(--ldp-jump-highlight-radius,10px);}
- .ldp-jump-highlight:not(.ldp-post[data-post-number="1"]){
- animation:ldp-jump-pulse var(--ldp-jump-highlight-duration,1250ms) ease-in-out var(--ldp-jump-highlight-count,2);}
- .ldp-comments > .ldp-post.ldp-zebra-alt.ldp-jump-highlight:not([data-post-number="1"]){animation:none;}
- .ldp-comments > .ldp-post.ldp-zebra-alt.ldp-jump-highlight:not([data-post-number="1"])::before{
- content:"";position:absolute;z-index:-1;inset:6px 0;border-radius:inherit;pointer-events:none;
- animation:ldp-jump-pulse var(--ldp-jump-highlight-duration,1250ms) ease-in-out var(--ldp-jump-highlight-count,2);}
- .ldp-post[data-post-number="1"].ldp-jump-highlight{isolation:isolate;}
- .ldp-post[data-post-number="1"].ldp-jump-highlight::before{content:"";position:absolute;z-index:-1;left:0;right:0;top:0;
- height:var(--ldp-first-post-jump-highlight-height,100%);border-radius:var(--ldp-jump-highlight-radius,10px);pointer-events:none;
- animation:ldp-jump-pulse var(--ldp-jump-highlight-duration,1250ms) ease-in-out var(--ldp-jump-highlight-count,2);}
- @keyframes ldp-jump-pulse{
- 0%{background:color-mix(in srgb,var(--ldp-jump-highlight-color,#0888cc) 34%,transparent);
- box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) var(--ldp-jump-highlight-color,#0888cc);}
- 100%{background:transparent;box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) transparent;}
- }
- .ldp-descendant-replies-open .ldp-icon.ldp-jump-highlight{
- border-radius:0;background:none;box-shadow:none;
- animation:ldp-jump-icon-pulse var(--ldp-jump-highlight-duration,1250ms) ease-in-out var(--ldp-jump-highlight-count,2);}
- @keyframes ldp-jump-icon-pulse{
- 0%,100%{color:inherit;opacity:1;transform:scale(1);}
- 50%{color:var(--ldp-jump-highlight-color,#0888cc);opacity:.62;transform:scale(1.16);}
- }
- @media (prefers-reduced-motion:reduce){
- .ldp-jump-highlight:not(.ldp-post[data-post-number="1"]){animation:none;background:color-mix(in srgb,var(--ldp-jump-highlight-color,#0888cc) 34%,transparent);
- box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) var(--ldp-jump-highlight-color,#0888cc);}
- .ldp-comments > .ldp-post.ldp-zebra-alt.ldp-jump-highlight:not([data-post-number="1"]){background:transparent;box-shadow:none;}
- .ldp-comments > .ldp-post.ldp-zebra-alt.ldp-jump-highlight:not([data-post-number="1"])::before{animation:none;
- background:color-mix(in srgb,var(--ldp-jump-highlight-color,#0888cc) 34%,transparent);
- box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) var(--ldp-jump-highlight-color,#0888cc);}
- .ldp-post[data-post-number="1"].ldp-jump-highlight::before{animation:none;
- background:color-mix(in srgb,var(--ldp-jump-highlight-color,#0888cc) 34%,transparent);
- box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) var(--ldp-jump-highlight-color,#0888cc);}
- .ldp-descendant-replies-open .ldp-icon.ldp-jump-highlight{
- background:none;box-shadow:none;color:var(--ldp-jump-highlight-color,#0888cc);opacity:1;}
- }
- .ldp-nested-toggle{display:none;align-items:center;height:24px;margin:2px 0;line-height:1;vertical-align:middle;color:var(--tertiary,#08c);font-weight:600;font-size:var(--ldp-font-ui,12px);}
- .ldp-nested-esc-hint{display:inline-flex;align-items:center;height:24px;margin-left:8px;line-height:1;color:var(--primary-medium,#777);
- font-size:var(--ldp-font-sm,11px);font-weight:500;white-space:nowrap;vertical-align:middle;}
- .ldp-reply-collapsible > .ldp-nested-toggle,.ldp-hidden-collapsible > .ldp-nested-toggle{display:inline-flex;}
- .ldp-nested-preview > .ldp-nested-toggle{display:none;}
- .ldp-post:has(> .ldp-hidden-reply-marker){position:relative;z-index:3;height:18px;min-height:18px;padding:0;overflow:visible;
- border:0;background:transparent;content-visibility:visible;contain:none;}
- .ldp-post:has(> .ldp-hidden-reply-marker) > :not(.ldp-hidden-reply-marker){display:none!important;}
- .ldp-hidden-reply-marker{position:relative;z-index:4;display:grid;width:100%;height:18px;
- grid-template-columns:minmax(0,1fr) 14px;align-items:center;column-gap:4px;color:var(--primary-medium);}
- .ldp-hidden-reply-marker::before{content:"";height:var(--ldp-divider-line-width,1px);
- background:var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-hidden-reply-toggle{display:grid;width:14px;height:18px;padding:0;place-items:center;line-height:1;}
- .ldp-hidden-reply-toggle::before{content:"";width:0;height:0;border-top:5px solid transparent;
- border-bottom:5px solid transparent;border-right:7px solid currentColor;}
- .ldp-hidden-reply-list{position:absolute;right:0;bottom:calc(50% + 7px);display:flex;max-width:min(70vw,360px);
- z-index:5;align-items:center;gap:4px;padding:4px;overflow-x:auto;border:1px solid var(--primary-low);border-radius:999px;
- background:var(--secondary);box-shadow:0 5px 16px rgba(0,0,0,.16);}
- .ldp-hidden-reply-avatar{width:24px;height:24px;flex:none;padding:0;overflow:hidden;border:0;border-radius:50%;
- background:var(--primary-low);cursor:pointer;}
- .ldp-hidden-reply-avatar img{width:100%;height:100%;object-fit:cover;}
- .ldp-post.ldp-nested-collapsed{display:flex;align-items:center;gap:8px;padding:4px 16px;font-size:var(--ldp-font-ui,12px);cursor:pointer;}
- .ldp-post.ldp-nested-collapsed > .ldp-nested-toggle{display:inline-flex;flex:none;margin:0;font-size:var(--ldp-font-ui,12px);}
- .ldp-topic-edit-label-remove .ldp-icon,.ldp-only-op-toggle .ldp-icon,
- .ldp-color-reset .ldp-icon,.ldp-boost-item-action .ldp-icon,.ldp-nested-toggle .ldp-icon,
- .ldp-post.ldp-nested-collapsed > .ldp-nested-toggle .ldp-icon{width:12px;height:12px;}
- .ldp-post.ldp-nested-collapsed > .ldp-post-head{flex:1;min-width:0;margin:0;gap:6px;white-space:nowrap;overflow:hidden;}
- .ldp-post.ldp-nested-collapsed .ldp-avatar{width:20px;height:20px;}
- .ldp-post.ldp-nested-collapsed .ldp-author,
- .ldp-post.ldp-nested-collapsed .ldp-user,
- .ldp-post.ldp-nested-collapsed .ldp-time{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .ldp-post.ldp-nested-collapsed > .ldp-post-head:has(.ldp-time:hover),
- .ldp-post.ldp-nested-collapsed .ldp-time:hover{overflow:visible;}
- .ldp-post.ldp-nested-collapsed .ldp-new-user-badge{min-height:18px;padding:1px 5px 1px 4px;gap:3px;font-size:var(--ldp-font-xs,10px);}
- .ldp-post.ldp-nested-collapsed .ldp-new-user-badge .ldp-icon{width:11px;height:11px;}
- .ldp-post.ldp-nested-collapsed .ldp-user,
- .ldp-post.ldp-nested-collapsed .ldp-time,
- .ldp-post.ldp-nested-collapsed .ldp-floor:not(.ldp-jump-parent):not(.ldp-jump-self){font-size:var(--ldp-font-sm,11px);opacity:.55;}
- .ldp-post.ldp-nested-collapsed .ldp-floor.ldp-jump-parent,
- .ldp-post.ldp-nested-collapsed .ldp-floor.ldp-jump-self{font-size:var(--ldp-font-sm,11px);}
- .ldp-post.ldp-nested-collapsed .ldp-floor.ldp-body-floor{
- background:color-mix(in srgb,var(--primary,#222) 3%,transparent);}
- .ldp-post.ldp-nested-collapsed .ldp-op,
- .ldp-post.ldp-nested-collapsed .ldp-me{font-size:var(--ldp-font-xs,10px);padding:0 4px;}
- .ldp-post.ldp-nested-collapsed > .ldp-jump-linkbar,
- .ldp-post.ldp-nested-collapsed > .ldp-content,
- .ldp-post.ldp-nested-collapsed > .ldp-boost-list,
- .ldp-post.ldp-nested-collapsed > .ldp-reactions,
- .ldp-post.ldp-nested-collapsed > .ldp-special-badges,
- .ldp-post.ldp-nested-collapsed > .ldp-special-notice,
- .ldp-post.ldp-nested-collapsed > .ldp-system-action,
- .ldp-post.ldp-nested-collapsed > .ldp-collapse-replies,
- .ldp-post.ldp-nested-collapsed > .ldp-children,
- .ldp-post.ldp-nested-collapsed > .ldp-sub-loading,
- .ldp-post.ldp-nested-collapsed > .ldp-sub-actions{display:none!important;}
-
- /* 下拉加载评论时的底部加载提示 */
- .ldp-loading-tip{padding:14px 0;text-align:center;font-size:var(--ldp-font-base,13px);
- color:var(--primary-medium,#888);display:none;user-select:none;}
- .ldp-loading-tip.show{display:block;}
- .ldp-loading-tip .ldp-tip-icon{display:inline-block;margin-right:6px;
- animation:ldp-spin .9s linear infinite;}
- .ldp-end-tip{display:none;padding:6px 0;text-align:center;
- color:var(--primary-medium,#888);font-size:var(--ldp-font-base,13px);line-height:1.2;user-select:none;}
- .ldp-end-tip.show{display:block;white-space:nowrap;}
- @keyframes ldp-spin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}
- @container ldp-reader (max-width:1200px){
- .ldp-reader-queue{--ldp-reader-queue-rail-size:36px;--ldp-reader-queue-toggle-size:36px;
- --ldp-reader-queue-toggle-radius:11px;--ldp-reader-queue-toggle-icon-size:16px;
- --ldp-reader-queue-avatar-size:32px;--ldp-reader-queue-progress-padding:4px;
- --ldp-reader-queue-gap:7px;--ldp-reader-queue-bubble-gap:6px;--ldp-reader-queue-bubbles-max-height:192px;
- --ldp-reader-queue-scroll-size:32px;--ldp-reader-queue-scroll-icon-size:22px;
- --ldp-reader-queue-badge-size:16px;--ldp-reader-queue-status-size:7px;--ldp-reader-queue-loading-size:9px;
- --ldp-reader-queue-affordance-size:14px;--ldp-reader-queue-pin-size:13px;--ldp-reader-queue-pin-icon-size:8px;
- left:12px;}
- }
- @container ldp-reader (max-width:900px){
- .ldp-reader-queue{--ldp-reader-queue-rail-size:32px;--ldp-reader-queue-toggle-size:32px;
- --ldp-reader-queue-toggle-radius:10px;--ldp-reader-queue-toggle-icon-size:14px;
- --ldp-reader-queue-avatar-size:28px;--ldp-reader-queue-progress-padding:3px;
- --ldp-reader-queue-gap:6px;--ldp-reader-queue-bubble-gap:5px;--ldp-reader-queue-bubbles-max-height:168px;
- --ldp-reader-queue-scroll-size:28px;--ldp-reader-queue-scroll-icon-size:18px;
- --ldp-reader-queue-badge-size:15px;--ldp-reader-queue-status-size:6px;--ldp-reader-queue-loading-size:8px;
- --ldp-reader-queue-affordance-size:12px;--ldp-reader-queue-pin-size:11px;--ldp-reader-queue-pin-icon-size:7px;
- left:10px;}
- .ldp-reader-main{grid-template-columns:
- minmax(0,min(var(--ldp-layout-left,10%),48px)) minmax(0,1fr)
- minmax(0,min(var(--ldp-layout-gap,3%),24px)) clamp(52px,var(--ldp-layout-timeline,10%),72px)
- minmax(0,min(var(--ldp-layout-right,5%),48px));}
- .ldp-body > *{padding-left:16px;padding-right:16px;}
- .ldp-topic-timeline{margin:18px 0;padding-inline:2px;}
- .ldp-topic-timeline-count,.ldp-topic-timeline-preview{left:24px;max-width:calc(100% - 24px);}
- }
- @container ldp-reader (min-width:701px) and (max-width:900px){
- .ldp-modal:has(.ldp-body > .ldp-loadmask) > .ldp-header .ldp-title-topic-row{
- min-height:calc(30px * var(--ldp-reader-display-scale,1) + 10px);}
- }
- @container ldp-reader (max-width:700px){
- .ldp-reader-queue{left:8px;top:auto;bottom:12px;max-height:none;}
- .ldp-reader-queue-bubbles,.ldp-reader-queue-scroll-hint{display:none!important;}
- .ldp-reader-queue-panel{left:calc(var(--ldp-reader-queue-rail-size) + 8px);top:auto;bottom:0;
- width:min(330px,calc(100cqw - 60px));max-height:min(70vh,520px);}
- }
- @media (max-width:700px){
- .ldp-overlay.ldp-window-managed:not(.ldp-fullpage),
- .ldp-overlay.ldp-reader-embedded,
- .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage{
- --ldp-window-capsule-rail:0px;}
- .ldp-overlay{align-items:stretch;justify-content:stretch;background:var(--secondary,#fff);pointer-events:auto;}
- .ldp-modal{width:100vw;height:100vh;border-radius:0;box-shadow:none;}
- .ldp-reader-window-capsule,.ldp-reader-resize-handle{display:none!important;}
- }
- @container ldp-reader (max-width:700px){
- .ldp-settings-popover{--ldp-settings-frozen-height:68px;left:8px!important;top:8px!important;right:auto;
- transform:none!important;grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);
- bottom:8px;width:calc(100cqw - 16px);height:auto;max-width:none;max-height:none;border-radius:10px;}
- .ldp-settings-tabs{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;
- width:100%;height:auto;min-height:0;padding:0 44px 0 8px;border-right:0;
- border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);}
- .ldp-settings-brand{display:none;}
- .ldp-settings-nav-shell{display:contents;}
- .ldp-settings-nav{min-width:0;min-height:0;flex:none;flex-direction:row;gap:3px;padding:8px 0;
- overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;scrollbar-width:none;}
- .ldp-settings-nav-group{display:contents;}
- .ldp-settings-nav-group-label{display:none;}
- .ldp-settings-tab{width:auto;flex:none;gap:6px;padding:7px 9px;white-space:nowrap;}
- .ldp-settings-tab[data-settings-panel="user"]{margin-right:9px;margin-bottom:0;}
- .ldp-settings-tab[data-settings-panel="user"]::after{content:"";position:absolute;right:-5px;top:0;bottom:0;width:1px;
- background:var(--ldp-divider-line-color,#e5e5e5);pointer-events:none;}
- .ldp-settings-footer{margin:0;padding:8px 0 8px 8px;border-top:0;}
- .ldp-settings-theme{grid-template-columns:repeat(3,30px);}
- .ldp-settings-panel{padding:0 14px 14px;}
- .ldp-settings-search-shell{margin:0 -14px;padding-inline:14px 48px;}
- .ldp-settings-draft-bar{margin-inline:-14px;padding-inline:14px;}
- .ldp-settings-intro{margin:0 -14px;padding:15px 48px 12px 14px;cursor:default;touch-action:auto;}
- .ldp-settings-intro::before{display:none;}
- .ldp-settings-close{top:10px;right:10px;}
- }
- @container ldp-reader (max-width:700px){
- .ldp-reader-history-edge{position:absolute;inset-block:0;z-index:8;width:56px;
- display:flex;align-items:center;justify-content:center;pointer-events:none;}
- .ldp-reader-history-edge-back{left:0;}
- .ldp-reader-history-edge-forward{right:0;}
- .ldp-reader-history-edge .ldp-reader-history-nav{position:relative;top:auto;left:auto;right:auto;
- width:44px;height:44px;border-color:transparent;background:transparent;transform:scale(.92);opacity:0;
- pointer-events:none;
- transition:opacity 180ms cubic-bezier(.22,1,.36,1),transform 180ms cubic-bezier(.22,1,.36,1);}
- .ldp-reader-history-edge.is-active .ldp-reader-history-nav,
- .ldp-history-buttons-always-visible .ldp-reader-history-edge .ldp-reader-history-nav,
- .ldp-reader-history-edge .ldp-reader-history-nav:focus-visible{opacity:.35;transform:scale(1);pointer-events:auto;}
- .ldp-reader-history-edge .ldp-reader-history-nav::before{content:"";position:absolute;inset:5px;
- border:1px solid var(--primary-low,#ccc);border-radius:50%;background:var(--secondary,#fff);}
- .ldp-reader-history-edge .ldp-reader-history-nav .ldp-icon{position:relative;z-index:1;}
- .ldp-reader-history-edge .ldp-reader-history-nav:hover{border-color:transparent;background:transparent;}
- .ldp-reader-history-edge .ldp-reader-history-nav:hover::before{
- border-color:var(--primary-medium,#999);background:var(--primary-very-low,#f0f0f0);}
- .ldp-reader-history-edge .ldp-reader-history-nav:focus-visible{outline:none;}
- .ldp-reader-history-edge .ldp-reader-history-nav:focus-visible::before{
- outline:2px solid var(--tertiary,#47855f);outline-offset:2px;}
- .ldp-reader-history-edge .ldp-reader-history-nav:active{opacity:1;transform:scale(.96);}
- .ldp-header{--ldp-header-logo-inset:10px;--ldp-header-reserved-right:138px;
- --ldp-header-compact-padding-block:clamp(2px,calc(5px - .5cqw),3px);
- --ldp-header-fluid-row-gap:clamp(0px,calc(1.4cqw - 6.7px),3px);
- --ldp-header-row-tighten:clamp(0px,calc(12px - 2.5cqw),3px);
- --ldp-home-logo-size:28px;--ldp-home-logo-box-size:32px;
- display:grid;grid-template-columns:var(--ldp-home-logo-box-size) minmax(0,1fr) auto;grid-template-rows:auto auto;
- align-items:center;gap:4px 8px;min-height:calc(66px + var(--ldp-window-capsule-rail,0px));}
- .ldp-home-logo{grid-column:1;grid-row:1 / 3;align-self:center;
- width:var(--ldp-home-logo-box-size);height:var(--ldp-home-logo-box-size);}
- .ldp-title-wrap{display:contents;}
- .ldp-topic-edit-layer{padding:10px;}
- .ldp-reader-action-body.ldp-topic-edit-body{grid-template-columns:minmax(0,1fr);}
- .ldp-topic-edit-title,.ldp-topic-edit-status{grid-column:1;}
- .ldp-topic-edit-category-menu{min-width:0;}
- .ldp-topic-edit-category-options{max-height:min(220px,32vh);}
- .ldp-title{grid-column:2 / -1;grid-row:1;min-width:0;display:block;
- min-height:calc(26px * var(--ldp-reader-display-scale,1));
- font-size:var(--ldp-reader-title-font-size,16px);line-height:1.2;}
- .ldp-title-jump{min-width:0;display:block;overflow:visible;text-overflow:clip;white-space:normal;}
- .ldp-title-actions{--ldp-header-action-gap:6px;top:calc(6px + var(--ldp-window-capsule-rail,0px));right:8px;}
- .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open){width:26px;height:26px;flex-basis:26px;padding:3px;}
- .ldp-title-subline{grid-column:1 / 3;grid-row:2;min-width:0;padding-left:calc(var(--ldp-home-logo-box-size) + 8px);
- align-items:start;justify-items:start;gap:4px;overflow:visible;white-space:normal;text-align:left;}
- .ldp-meta{overflow:visible;text-overflow:clip;white-space:normal;}
- .ldp-title-topic-row{min-width:0;overflow:visible;}
- .ldp-topic-tags{flex-wrap:wrap;min-width:0;overflow:visible;}
- .ldp-head-btns{grid-column:3;grid-row:2;min-width:0;gap:3px;justify-content:flex-end;margin-top:0;padding-top:8px;}
- .ldp-layout-toggle{display:none;}
- .ldp-layout-toggle,.ldp-notifications-toggle,.ldp-history-toggle,.ldp-bookmarks-toggle,.ldp-translate-toggle,.ldp-settings-toggle{
- width:26px;height:26px;padding:3px;}
- .ldp-reader-refresh.ldp-icon-btn,.ldp-close.ldp-icon-btn{width:26px;height:26px;padding:2px;}
- .ldp-reader-main{grid-template-columns:
- minmax(0,min(var(--ldp-layout-left,10%),20px)) minmax(0,1fr)
- minmax(0,min(var(--ldp-layout-gap,3%),12px)) clamp(44px,var(--ldp-layout-timeline,12%),52px)
- minmax(0,min(var(--ldp-layout-right,7%),20px));}
- .ldp-body > *{padding-left:14px;padding-right:14px;}
- .ldp-topic-timeline{grid-column:4;width:100%;margin:12px 0;padding:0;justify-self:stretch;}
- .ldp-topic-timeline-date,.ldp-topic-timeline-relative{padding:0;font-size:var(--ldp-font-micro,9px);line-height:1.2;}
- .ldp-topic-timeline-footer{gap:6px;}
- .ldp-topic-timeline-top{width:20px;height:20px;}
- .ldp-topic-timeline-top .ldp-icon{width:12px;height:12px;}
- .ldp-topic-timeline-track::before{left:5px;}
- .ldp-topic-timeline-cursor{left:14px;min-width:34px;height:68px;}
- .ldp-topic-timeline-cursor > span{transform:translate3d(var(--ldp-lens-shift-x,0),calc(-50% + var(--ldp-lens-offset,0) * 13px),0) scale(var(--ldp-lens-scale,1));font-size:13px;}
- .ldp-topic-timeline-thumb{left:1px;}
- .ldp-topic-timeline-count,
- .ldp-topic-timeline-preview{left:14px;max-width:calc(100% - 14px);font-size:var(--ldp-font-micro,9px);gap:0;}
- .ldp-layout-toggle span,.ldp-notifications-toggle > span,.ldp-history-toggle > span,.ldp-bookmarks-toggle > span,.ldp-translate-toggle span,.ldp-settings-toggle span{display:none;}
- }
- @container ldp-reader (min-width:481px) and (max-width:700px){
- .ldp-header{--ldp-header-reserved-right:var(--ldp-header-logo-inset);
- grid-template-columns:var(--ldp-home-logo-box-size) minmax(0,1fr) 113px;
- grid-template-rows:auto auto auto;row-gap:var(--ldp-header-fluid-row-gap);min-height:0;
- padding-top:calc(var(--ldp-header-compact-padding-block) + var(--ldp-window-capsule-rail,0px));
- padding-bottom:var(--ldp-header-compact-padding-block);}
- .ldp-modal:has(.ldp-body > .ldp-loadmask) > .ldp-header{
- min-height:calc(var(--ldp-window-capsule-rail,0px) +
- 58px * var(--ldp-reader-display-scale,1) + 16px);}
- .ldp-home-logo{grid-row:1 / 4;}
- .ldp-title{grid-column:2;min-height:0;padding-right:0;}
- .ldp-header.ldp-title-single-line .ldp-title{line-height:1.1;}
- .ldp-title-subline{display:contents;}
- .ldp-meta-row{grid-column:2;grid-row:2;}
- .ldp-title-topic-row{grid-column:2;grid-row:3;width:calc(100% - 16px);max-width:calc(100% - 16px);
- box-sizing:border-box;align-self:end;contain:paint;overflow:hidden;}
- .ldp-topic-tags{min-width:max-content;flex-wrap:nowrap;overflow:visible;}
- .ldp-header :is(.ldp-title,.ldp-meta-row,.ldp-topic-tags){padding-block:0;}
- .ldp-head-btns{position:absolute;right:var(--ldp-header-logo-inset);bottom:4px;
- margin:0;padding-top:0;}
- }
- @container ldp-reader (max-width:480px){
- .ldp-time[data-exact-time]::after{right:0;bottom:auto;left:auto;top:calc(100% + 6px);box-sizing:border-box;
- transform:none;padding:4px 7px;border:0;border-radius:7px;background:rgba(28,28,28,.86);color:#fff;
- box-shadow:0 2px 6px rgba(0,0,0,.18);font-size:var(--ldp-font-ui,12px);font-weight:500;line-height:1.35;opacity:1;}
- .ldp-topic-footer-actions{box-sizing:border-box;width:100%;gap:2px;padding-inline:2px;
- justify-content:space-between;overflow:visible;}
- .ldp-topic-footer-actions > *{flex:none;}
- .ldp-topic-footer-actions :is(.ldp-topic-footer-button,.ldp-topic-footer-link,.ldp-topic-notification){
- width:30px;height:30px;padding:5px;}
- .ldp-topic-footer-actions .ldp-topic-shared-issue{width:44px;min-width:44px;gap:2px;padding-inline:4px;}
- .ldp-topic-footer-actions .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-label{display:none;}
- .ldp-topic-footer-actions .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-count{
- margin-left:0;font-size:var(--ldp-font-micro,9px);}
- .ldp-topic-footer-actions .ldp-topic-footer-separator{display:none;}
- .ldp-post-head{flex-wrap:nowrap;gap:6px;}
- .ldp-post-head > .ldp-author{min-width:3ch;max-width:34%;flex:1 1 auto;}
- .ldp-post-head > .ldp-user{min-width:0;max-width:26%;flex:1 1 auto;}
- .ldp-floor-preview > .ldp-post-head{padding-right:44px;gap:6px;}
- .ldp-floor-preview-close-hint{display:none;}
- .ldp-floor-preview-close{right:6px;width:28px;padding:0;}
- .ldp-header{--ldp-header-logo-inset:8px;--ldp-header-reserved-right:8px;
- grid-template-columns:var(--ldp-home-logo-box-size) minmax(0,1fr) minmax(110px,auto);
- grid-template-rows:auto auto auto;
- row-gap:var(--ldp-header-fluid-row-gap);column-gap:8px;min-height:0;
- padding:calc(var(--ldp-header-compact-padding-block) + var(--ldp-window-capsule-rail,0px))
- 8px var(--ldp-header-compact-padding-block);}
- .ldp-home-logo{grid-column:1;grid-row:1 / 3;}
- .ldp-header .ldp-title{grid-column:2;grid-row:1;padding-right:0;}
- .ldp-title-subline{display:contents;}
- .ldp-meta-row{grid-column:2;grid-row:2;display:block;min-width:0;width:100%;
- align-self:end;margin-top:0;overflow:visible;white-space:normal;}
- .ldp-meta{display:flex;min-width:0;width:100%;align-items:center;flex-wrap:wrap;overflow:visible;white-space:normal;}
- .ldp-meta-stats{display:block;min-width:0;flex:0 1 auto;overflow:visible;text-overflow:clip;white-space:normal;}
- .ldp-meta-owner{display:inline-flex;min-width:0;flex:0 1 auto;overflow:visible;}
- .ldp-meta-owner-copy{min-width:0;overflow:visible;text-overflow:clip;white-space:normal;}
- .ldp-title-topic-row{grid-column:2;grid-row:3;display:grid!important;min-width:0;
- width:calc(100% - 16px);max-width:calc(100% - 16px);box-sizing:border-box;
- align-self:end;align-items:center;margin-top:calc(0px - var(--ldp-header-row-tighten));
- contain:paint;overflow:hidden;}
- .ldp-topic-tags{min-width:max-content;flex-wrap:nowrap;overflow:visible;}
- .ldp-head-btns{position:absolute;right:var(--ldp-header-logo-inset);bottom:var(--ldp-header-compact-padding-block);
- gap:3px;margin:0;padding:0;}
- .ldp-title-actions{--ldp-header-action-gap:4px;top:calc(7px + var(--ldp-window-capsule-rail,0px));right:8px;}
- .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open){width:24px;height:24px;flex-basis:24px;padding:2px;}
- .ldp-layout-toggle,.ldp-notifications-toggle,.ldp-history-toggle,.ldp-bookmarks-toggle,.ldp-translate-toggle,.ldp-settings-toggle{
- width:24px;height:24px;padding:2px;}
- .ldp-reader-refresh.ldp-icon-btn,.ldp-close.ldp-icon-btn{width:24px;height:24px;padding:2px;}
- }
- :is(.ldp-modal,.ldp-settings-layer).ldp-settings-surface-short .ldp-settings-popover{--ldp-settings-frozen-height:60px;}
- :is(.ldp-modal,.ldp-settings-layer).ldp-settings-surface-short .ldp-settings-brand-logo{width:38px;height:38px;}
- :is(.ldp-modal,.ldp-settings-layer).ldp-settings-surface-short .ldp-settings-nav{padding-top:6px;}
- :is(.ldp-modal,.ldp-settings-layer).ldp-settings-surface-short .ldp-settings-tab{padding-top:7px;padding-bottom:7px;}
- :is(.ldp-modal,.ldp-settings-layer).ldp-settings-surface-short .ldp-settings-intro{padding-top:12px;padding-bottom:9px;}
- .ldp-modal.ldp-reader-surface-short .ldp-header{
- padding-top:calc(4px + var(--ldp-window-capsule-rail,0px));padding-bottom:4px;}
- .ldp-modal.ldp-reader-surface-short .ldp-topic-timeline{margin-top:12px;margin-bottom:12px;}
- @media (prefers-reduced-motion:reduce){
- @container ldp-reader (max-width:700px){
- .ldp-reader-history-edge .ldp-reader-history-nav{transition:none;}
- }
- }
- :where(.ldp-overlay,.ldp-settings-layer,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)
- input:is([type="text"],[type="url"],[type="number"],[type="search"],[type="email"],[type="password"],[type="tel"],:not([type])){
- --ldp-input-box-height:34px;--ldp-input-border-block:2px;
- --ldp-input-optical-shift:clamp(1px,.16em,2px);
- box-sizing:border-box;height:var(--ldp-input-box-height);
- padding-top:max(0px,calc((var(--ldp-input-box-height) - 1em - var(--ldp-input-border-block))/2 - var(--ldp-input-optical-shift)));
- padding-bottom:max(0px,calc((var(--ldp-input-box-height) - 1em - var(--ldp-input-border-block))/2 + var(--ldp-input-optical-shift)));
- text-align:left;line-height:1;}
- :where(.ldp-overlay,.ldp-settings-layer,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)
- input:is([type="text"],[type="url"],[type="number"],[type="search"],[type="email"],[type="password"],[type="tel"],:not([type]))::placeholder{
- text-align:left;line-height:1;}
- .ldp-settings-panel :is(.ldp-performance-control input,.ldp-reader-window-input,.ldp-boost-rule-control),
- .ldp-popover-search-input{--ldp-input-box-height:32px;}
- .ldp-settings-panel .ldp-custom-site-input{--ldp-input-box-height:34px;}
- :is(.ldp-picker-search input,.ldp-topic-edit-label-input,.ldp-popover-search-input){
- --ldp-input-border-block:0px;}
- :is(.ldp-picker-search input,.ldp-user-card-follow-search input){--ldp-input-box-height:30px;}
- .ldp-topic-edit-label-input{--ldp-input-box-height:26px;}
- .ldp-topic-edit-field > input{--ldp-input-box-height:38px;}
- .ldp-popover-search-input{
- padding-top:max(0px,calc((var(--ldp-input-box-height) - 1em - var(--ldp-input-border-block))/2 - var(--ldp-input-optical-shift)))!important;
- padding-bottom:max(0px,calc((var(--ldp-input-box-height) - 1em - var(--ldp-input-border-block))/2 + var(--ldp-input-optical-shift)))!important;}
-
-/* Static rules formerly authored inside work/main.js. */
-html[data-ldp-shadow-context-html],body[data-ldp-shadow-context-body]{display:contents!important;}
-html[data-ldp-font-mac-smoothing]{
- --ldp-font-webkit-smoothing:antialiased;--ldp-font-moz-smoothing:grayscale;}
-.ldp-composer-wheel-layer{position:fixed;inset:0;z-index:2147483590;pointer-events:auto;}
-
-.ldp-reader-queue{
- --ldp-reader-queue-rail-size:calc(34px * var(--ldp-reader-display-scale,1));
- --ldp-reader-queue-toggle-size:var(--ldp-reader-queue-rail-size);
- --ldp-reader-queue-toggle-radius:7px;
- --ldp-reader-queue-toggle-icon-size:18px;}
-.ldp-reader-queue-toggle{border:0;background:transparent;color:inherit;box-shadow:none;}
-.ldp-reader-queue-toggle:hover{background:var(--primary-very-low,#f4f4f4);}
-.ldp-reader-queue.is-empty .ldp-reader-queue-toggle b{opacity:0;transition:opacity .14s ease-out;}
-.ldp-reader-queue.is-empty:is(:hover,:focus-within) .ldp-reader-queue-toggle b{opacity:1;}
-.ldp-topic-action-rail{
- position:absolute;z-index:24;display:flex;flex-direction:column;gap:2px;padding:2px;
- border:0;border-radius:0;background:transparent;box-shadow:none;
- transition:transform .2s cubic-bezier(.22,1,.36,1);}
-.ldp-topic-action-rail-visible .ldp-reader-main{
- grid-template-columns:minmax(58px,var(--ldp-layout-left,10%)) minmax(0,1fr)
- minmax(0,var(--ldp-layout-gap,3%)) minmax(0,var(--ldp-layout-timeline,10%))
- minmax(0,var(--ldp-layout-right,5%));}
-@container ldp-reader (max-width:900px){
- .ldp-topic-action-rail-visible .ldp-reader-main{
- grid-template-columns:58px minmax(0,1fr) minmax(0,min(var(--ldp-layout-gap,3%),24px))
- clamp(52px,var(--ldp-layout-timeline,10%),72px) minmax(0,min(var(--ldp-layout-right,5%),48px));}}
-@container ldp-reader (max-width:700px){
- .ldp-topic-action-rail-visible .ldp-reader-main{
- grid-template-columns:58px minmax(0,1fr) minmax(0,min(var(--ldp-layout-gap,3%),12px))
- clamp(44px,var(--ldp-layout-timeline,12%),52px) minmax(0,min(var(--ldp-layout-right,7%),20px));}}
-.ldp-topic-action-rail[hidden],.ldp-topic-action-more[hidden]{display:none!important;}
-.ldp-topic-action-rail.is-dragging{user-select:none;}
-.ldp-topic-action-more{display:flex;flex-direction:column;gap:2px;}
-.ldp-topic-action-rail-visible:has(.ldp-topic-action-rail.is-expanded) .ldp-reader-history-nav{
- opacity:0!important;pointer-events:none!important;}
-.ldp-topic-action-rail:is(.is-docked-left,.is-docked-right):not(.is-dragging):not(:hover):not(:focus-within){
- pointer-events:none;}
-.ldp-topic-action-rail:is(.is-docked-left,.is-docked-right):not(.is-dragging):not(:hover):not(:focus-within)>:not(.ldp-topic-action-rail-toggle){
- visibility:hidden;}
-.ldp-topic-action-rail:is(.is-docked-left,.is-docked-right)>.ldp-topic-action-rail-toggle{pointer-events:auto;}
-.ldp-topic-action-rail :is(button,.ldp-topic-notification){
- box-sizing:border-box;width:calc(34px * var(--ldp-reader-display-scale,1));
- height:calc(34px * var(--ldp-reader-display-scale,1));min-width:calc(34px * var(--ldp-reader-display-scale,1));
- display:inline-flex;align-items:center;justify-content:center;padding:6px;border:0;border-radius:7px;
- background:transparent;color:inherit;}
-.ldp-topic-action-rail :is(button,.ldp-topic-notification):hover{background:var(--primary-very-low,#f4f4f4);}
-.ldp-topic-action-rail :is(.ldp-reactions,.ldp-actions,.ldp-topic-footer-actions){display:contents;}
-.ldp-topic-action-rail .ldp-icon{width:18px;height:18px;}
-.ldp-topic-action-rail .ldp-like{
- position:relative;height:calc(42px * var(--ldp-reader-display-scale,1));
- flex-direction:column;gap:1px;padding-block:3px;}
-.ldp-topic-action-rail .ldp-like-count{font-size:9px;line-height:1;opacity:.72;}
-.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip{
- width:auto;min-width:18px;height:calc(42px * var(--ldp-reader-display-scale,1));
- flex-direction:column;gap:1px;padding:3px 3px 3px 0;
- align-items:center;justify-content:center;}
-.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip img.emoji,
-.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip img.emoji.only-emoji{
- width:18px;height:18px;transform-origin:center;
- transition:transform .16s cubic-bezier(.22,1,.36,1);}
-.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip:hover>img.emoji,
-.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip:focus-visible>img.emoji{
- transform:scale(1.2);}
-@media (prefers-reduced-motion:reduce){
- .ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip img.emoji{
- transition:none;}}
-.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip>b{
- display:block;min-height:9px;font-size:9px;font-weight:inherit;line-height:1;opacity:.72;}
-.ldp-topic-action-rail .ldp-reaction-picker .ldp-reaction-chip.on>b{
- color:var(--tertiary,#47855f);font-weight:700;opacity:1;}
-.ldp-topic-action-rail .ldp-reaction-picker{
- left:100%;right:auto;bottom:0;
- height:calc(42px * var(--ldp-reader-display-scale,1));
- min-height:calc(42px * var(--ldp-reader-display-scale,1));
- padding:4px 6px 4px 2px;border-radius:0 7px 7px 0;
- background:var(--primary-very-low,#f4f4f4);
- flex-direction:row;flex-wrap:nowrap;}
-.ldp-topic-action-rail .ldp-reaction-like-picker:has(> .ldp-like > .ldp-topic-action-rail-reaction-badge)
- >.ldp-reaction-picker{padding-left:14px;}
-.ldp-topic-action-rail.is-docked-right .ldp-reaction-picker{
- left:100%;right:auto;}
-.ldp-topic-action-rail .ldp-reaction-like-picker:has(> .ldp-reaction-picker:not([hidden]))>.ldp-like{
- border-radius:7px 0 0 7px;background:var(--primary-very-low,#f4f4f4);}
-.ldp-topic-action-rail :is(button,.ldp-topic-notification)>span:not(.ldp-like-count){display:none;}
-.ldp-topic-action-rail .ldp-like>span.ldp-topic-action-rail-reaction-badge{
- position:absolute;z-index:4;right:-3px;top:-3px;display:inline-flex;width:15px;height:15px;
- align-items:center;justify-content:center;border-radius:50%;
- background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));
- box-shadow:0 1px 4px rgba(0,0,0,.2);
- pointer-events:none;}
-.ldp-topic-action-rail .ldp-topic-action-rail-reaction-badge img.emoji{
- width:12px;height:12px;margin:0;object-fit:contain;}
-.ldp-topic-action-rail-toggle{cursor:grab;touch-action:none;}
-.ldp-topic-action-rail.is-dragging .ldp-topic-action-rail-toggle{cursor:grabbing;}
-.ldp-topic-action-more :is(.ldp-topic-bookmark,.ldp-topic-reply,.ldp-topic-footer-separator){display:none!important;}
-.ldp-topic-action-rail-visible :is(
- .ldp-topic-timeline-top,
- .ldp-post[data-post-number="1"]>.ldp-topic-footer-actions,
- .ldp-post[data-post-number="1"]>.ldp-reactions){display:none!important;}
-.ldp-topic-timeline-jump{
- flex:none;box-sizing:border-box;min-width:20px;height:24px;padding:0 4px;border:0;border-radius:5px;
- background:transparent;color:inherit;font:600 var(--ldp-font-ui,12px)/1 var(--ldp-interface-font-family,inherit);
- cursor:pointer;pointer-events:auto;}
-.ldp-topic-timeline-jump:hover,.ldp-topic-timeline-jump[aria-expanded="true"]{
- background:var(--primary-very-low,#f4f4f4);color:var(--primary,#222);}
-.ldp-topic-timeline-jump:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:1px;}
-.ldp-topic-timeline-jump-form{
- position:absolute;right:0;bottom:31px;z-index:4;box-sizing:border-box;display:grid;
- grid-template-columns:minmax(0,auto) 28px;align-items:center;gap:4px 5px;width:max-content;min-width:126px;
- padding:5px;border:1px solid var(--primary-low,#d7d7d7);border-radius:8px;
- background:var(--secondary,#fff);box-shadow:0 5px 18px rgba(0,0,0,.16);pointer-events:auto;}
-.ldp-topic-timeline-jump-form[hidden]{display:none;}
-.ldp-topic-timeline-jump-field{
- display:flex;align-items:center;gap:3px;color:var(--primary-medium,#777);
- font:600 var(--ldp-font-ui,12px)/1 var(--ldp-interface-font-family,inherit);}
-.ldp-topic-timeline-jump-input{
- box-sizing:border-box;width:62px;height:28px;margin:0!important;padding:4px 6px!important;
- border:1px solid var(--primary-low,#d7d7d7)!important;border-radius:5px!important;
- background:var(--secondary,#fff)!important;color:var(--primary,#222)!important;
- font:inherit!important;font-variant-numeric:tabular-nums;appearance:textfield;}
-.ldp-topic-timeline-jump-input::-webkit-inner-spin-button{appearance:none;margin:0;}
-.ldp-topic-timeline-jump-input:focus{
- border-color:var(--tertiary,#47855f)!important;
- outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 24%,transparent);outline-offset:0;}
-.ldp-topic-timeline-jump-submit{
- box-sizing:border-box;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;
- padding:0;border:0;border-radius:5px;background:var(--tertiary,#47855f);color:#fff;cursor:pointer;}
-.ldp-topic-timeline-jump-submit:hover{filter:brightness(1.06);}
-.ldp-topic-timeline-jump-submit .ldp-icon{width:14px;height:14px;}
-.ldp-topic-timeline-jump-hint{
- grid-column:1/-1;min-height:11px;color:var(--primary-medium,#777);
- font:500 var(--ldp-font-micro,9px)/1.2 var(--ldp-interface-font-family,inherit);white-space:nowrap;}
-.ldp-topic-timeline-jump-form.is-invalid .ldp-topic-timeline-jump-input{
- border-color:var(--danger,#c9362b)!important;}
-.ldp-topic-timeline-jump-form.is-invalid .ldp-topic-timeline-jump-hint{color:var(--danger,#c9362b);}
-.ldp-topic-timeline-jump-submit:disabled{opacity:.36;filter:none;cursor:not-allowed;}
-.ldp-reaction-summary:has(> .ldp-reaction-picker-current:only-child){
- min-height:30px;padding:4px 9px;background:color-mix(in srgb,var(--primary,#222) 2%,transparent);}
-.ldp-post.ldp-has-reactions > .ldp-reactions .ldp-like{display:inline-flex;}
-.ldp-modal .ldp-icon{zoom:var(--ldp-reader-display-scale,1);}
-.ldp-head-btns>button{
- width:calc(30px * var(--ldp-reader-display-scale,1));height:calc(30px * var(--ldp-reader-display-scale,1));}
-.ldp-title-actions>:is(button,a),.ldp-icon-btn{
- width:calc(28px * var(--ldp-reader-display-scale,1));height:calc(28px * var(--ldp-reader-display-scale,1));
- flex-basis:calc(28px * var(--ldp-reader-display-scale,1));}
-.ldp-post>.ldp-reactions :is(.ldp-btn,.ldp-reaction-add){
- min-width:calc(20px * var(--ldp-reader-display-scale,1));height:calc(20px * var(--ldp-reader-display-scale,1));}
-
-.ldp-connect-card-credit{
- display:flex;flex-direction:column;min-height:100%;box-sizing:border-box;gap:4px;padding:7px;}
-.ldp-connect-card-credit .ldp-connect-heading{
- display:flex;min-width:0;flex:1;align-items:baseline;gap:8px;}
-.ldp-connect-card-credit .ldp-connect-heading strong{flex:none;}
-.ldp-connect-card-credit .ldp-connect-heading small{
- min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
-.ldp-connect-card-credit .ldp-connect-group{gap:3px;}
-.ldp-connect-card-credit .ldp-connect-quotas{gap:3px;}
-.ldp-connect-card-credit .ldp-user-card-stats{gap:5px;margin:0;}
-.ldp-connect-card-credit > .ldp-connect-detail-groups{padding-top:6px;}
-.ldp-connect-card-credit .ldp-connect-quota{
- min-height:28px;box-sizing:border-box;align-content:center;padding-block:3px;}
-.ldp-connect-metric.is-danger{--ldp-connect-color:var(--danger,#c84545);}
-.ldp-connect-credit-stat{
- display:grid;align-content:center;min-height:54px;box-sizing:border-box;}
-.ldp-connect-credit-actions{
- margin:auto 0 0;padding-top:7px;border-top:1px solid var(--ldp-divider-line-color,#e5e5e5);}
-.ldp-connect-credit-actions .ldp-user-card-action{
- box-sizing:border-box;height:38px;font-size:var(--ldp-font-ui,12px);font-weight:600;text-decoration:none;}
-.ldp-connect-credit-actions .ldp-user-card-action span{margin-left:5px;}
-.ldp-user-info-view[data-user-info-panel="credit"]{
- box-sizing:border-box;min-height:0;padding-top:10px;overflow-y:auto;overscroll-behavior:contain;
- scrollbar-width:none;-ms-overflow-style:none;}
-.ldp-user-info-view[data-user-info-panel="credit"]::-webkit-scrollbar{display:none;}
-
-.ldp-descendant-replies-layer{inset:0;pointer-events:auto;}
-.ldp-descendant-replies-layer-inline{
- position:absolute;z-index:30;background:var(--primary-very-low,#f6f6f6);}
-.ldp-descendant-replies-layer-centered{position:fixed;z-index:2147483590;}
-.ldp-descendant-replies-window{
- position:absolute;overflow:hidden;border:1px solid var(--primary-low,#ddd);
- background:var(--secondary,#fff);}
-.ldp-descendant-replies-inline{
- inset:20px;z-index:31;width:auto;height:auto;min-width:0;min-height:0;border-radius:10px;box-shadow:none;}
-.ldp-descendant-replies-centered{
- z-index:2147483647;left:50%;top:50%;width:min(960px,calc(100vw - 48px));height:min(78vh,720px);
- min-width:320px;min-height:240px;max-width:calc(100vw - 32px);max-height:calc(100vh - 32px);
- transform:translate(-50%,-50%);border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.24);}
-.ldp-descendant-replies-host-open{overflow:visible;z-index:2147483590;clip-path:none!important;}
-.ldp-descendant-replies-header{
- height:46px;display:flex;align-items:center;gap:8px;padding:0 10px;
- border-bottom:1px solid var(--primary-low,#ddd);color:var(--primary-medium,#777);
- font-size:var(--ldp-font-ui,12px);cursor:default;user-select:none;}
-.ldp-descendant-replies-close{
- flex:none;max-width:none;font-family:inherit;font-size:inherit;font-weight:600;line-height:1;}
-.ldp-descendant-replies-title{flex:1;max-width:none;}
-.ldp-descendant-replies-list{
- position:relative;height:calc(100% - 47px);box-sizing:border-box;overflow:auto;
- overscroll-behavior:contain;padding:12px 18px 12px 40px;}
-.ldp-thread-group-svg{
- left:0;top:0;width:100%;height:var(--ldp-thread-group-height);}
-.ldp-actions .ldp-btn.ldp-descendant-replies-reroot{width:auto;padding-inline:8px;}
-.ldp-descendant-replies-reroot.was-opened{color:var(--tertiary,#47855f);}
-.ldp-descendant-replies-reroot > span{font-size:var(--ldp-font-xs,10px);}
-.ldp-descendant-replies-window .ldp-post[data-ldp-branch-depth]{
- box-sizing:border-box;margin-inline-start:var(--ldp-descendant-branch-indent);
- width:calc(100% - var(--ldp-descendant-branch-indent));}
-.ldp-descendant-replies-return{margin-left:0;padding:1px 4px;}
-.ldp-descendant-replies-open{outline:none!important;box-shadow:none!important;}
-
-.ldp-settings-section[data-settings-panel="user"]{
- height:100%;min-height:0;grid-template-rows:auto minmax(0,1fr);}
-.ldp-settings-section[data-settings-panel="user"] .ldp-user-info-content{
- min-height:0;grid-template-rows:auto minmax(0,1fr);overflow:hidden;}
-.ldp-topic-action-rail-reset-row{cursor:default;}
-.ldp-reader-queue.is-preview-reversed{flex-direction:column-reverse;}
-.ldp-reader-queue.is-runtime-positioned{bottom:auto;}
-.ldp-reader-queue .ldp-reader-queue-panel.is-collision-positioned{right:auto;bottom:auto;}
-.ldp-head-btns.is-content-aligned{right:0;}
-.ldp-structure-colors-disabled{--ldp-divider-line-color:transparent;}
-.ldp-nested-branch-toggle{left:0;}
-.ldp-sub-actions.show{display:flex;}
-.ldp-sub-loading.show{display:block;}
-.ldp-boost-picker-positioned{
- position:fixed!important;
- left:var(--ldp-boost-picker-left,0px)!important;top:var(--ldp-boost-picker-top,0px)!important;
- right:auto!important;bottom:auto!important;margin:0!important;transform:none!important;translate:none!important;}
-.ldp-boost-picker-constrained .emoji-picker__content{
- height:auto!important;min-height:0!important;flex:1 1 auto!important;}
-.ldp-boost-picker-constrained .emoji-picker__sections-nav{height:100%!important;}
-.ldp-color-picker-hue{
- --ldp-color-slider:linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00);}
-.ldp-color-picker-saturation{
- --ldp-color-slider:linear-gradient(to right,var(--ldp-color-slider-start),var(--ldp-color-slider-end));}
-.ldp-color-picker-brightness{
- --ldp-color-slider:linear-gradient(to right,#000000,var(--ldp-color-slider-end));}
-.ldp-color-picker-preset[data-color="#F0FFF0"]{--ldp-color-picker-preset:#F0FFF0;}
-.ldp-color-picker-preset[data-color="#FFFFFF"]{--ldp-color-picker-preset:#FFFFFF;}
-.ldp-color-picker-preset[data-color="#E5E7EB"]{--ldp-color-picker-preset:#E5E7EB;}
-.ldp-color-picker-preset[data-color="#94A3B8"]{--ldp-color-picker-preset:#94A3B8;}
-.ldp-color-picker-preset[data-color="#475569"]{--ldp-color-picker-preset:#475569;}
-.ldp-color-picker-preset[data-color="#111827"]{--ldp-color-picker-preset:#111827;}
-.ldp-color-picker-preset[data-color="#47855F"]{--ldp-color-picker-preset:#47855F;}
-.ldp-color-picker-preset[data-color="#22C55E"]{--ldp-color-picker-preset:#22C55E;}
-.ldp-color-picker-preset[data-color="#2563EB"]{--ldp-color-picker-preset:#2563EB;}
-.ldp-color-picker-preset[data-color="#7C3AED"]{--ldp-color-picker-preset:#7C3AED;}
-.ldp-color-picker-preset[data-color="#D97706"]{--ldp-color-picker-preset:#D97706;}
-.ldp-color-picker-preset[data-color="#DC2626"]{--ldp-color-picker-preset:#DC2626;}
-.ldp-reader-hit-test-hidden{display:none!important;}
-
-.ldp-loading-visual-inner > span:nth-child(1){--i:0;}
-.ldp-loading-visual-inner > span:nth-child(2){--i:1;}
-.ldp-loading-visual-inner > span:nth-child(3){--i:2;}
-.ldp-loading-visual-inner > span:nth-child(4){--i:3;}
-.ldp-loading-visual-inner > span:nth-child(5){--i:4;}
-.ldp-loading-visual-inner > span:nth-child(6){--i:5;}
-.ldp-loading-visual-inner > span:nth-child(7){--i:6;}
-.ldp-loading-visual-inner > span:nth-child(8){--i:7;}
-.ldp-loading-visual-inner > span:nth-child(9){--i:8;}
-.ldp-loading-visual-inner > span:nth-child(10){--i:9;}
-.ldp-loading-visual-inner > span:nth-child(11){--i:10;}
-.ldp-loading-visual-inner > span:nth-child(12){--i:11;}
-.ldp-loader-thread-index > span:nth-child(1){--depth:0;}
-.ldp-loader-thread-index > span:nth-child(2),
-.ldp-loader-thread-index > span:nth-child(4){--depth:1;}
-.ldp-loader-thread-index > span:nth-child(3),
-.ldp-loader-thread-index > span:nth-child(5){--depth:2;}
-.ldp-loader-thread-index > span:nth-child(6){--depth:3;}
-.ldp-loader-typewave > span:nth-child(1),
-.ldp-loader-typewave > span:nth-child(4){--row:0;}
-.ldp-loader-typewave > span:nth-child(2),
-.ldp-loader-typewave > span:nth-child(5){--row:1;}
-.ldp-loader-typewave > span:nth-child(3),
-.ldp-loader-typewave > span:nth-child(6){--row:2;}
-.ldp-loader-cache-lanes > span:nth-child(3n+1){--lane:0;}
-.ldp-loader-cache-lanes > span:nth-child(3n+2){--lane:1;}
-.ldp-loader-cache-lanes > span:nth-child(3n){--lane:2;}
-.ldp-loader-inbox-rain > span:nth-child(1){--x:34px;--y:112px;--drop:-72px;}
-.ldp-loader-inbox-rain > span:nth-child(2){--x:77px;--y:98px;--drop:-88px;}
-.ldp-loader-inbox-rain > span:nth-child(3){--x:120px;--y:84px;--drop:-64px;}
-.ldp-loader-inbox-rain > span:nth-child(4){--x:163px;--y:70px;--drop:-94px;}
-.ldp-loader-inbox-rain > span:nth-child(5){--x:206px;--y:56px;--drop:-76px;}
+.ldp-loading-visual-inner > span:nth-child(1){--i:0;}
+.ldp-loading-visual-inner > span:nth-child(2){--i:1;}
+.ldp-loading-visual-inner > span:nth-child(3){--i:2;}
+.ldp-loading-visual-inner > span:nth-child(4){--i:3;}
+.ldp-loading-visual-inner > span:nth-child(5){--i:4;}
+.ldp-loading-visual-inner > span:nth-child(6){--i:5;}
+.ldp-loading-visual-inner > span:nth-child(7){--i:6;}
+.ldp-loading-visual-inner > span:nth-child(8){--i:7;}
+.ldp-loading-visual-inner > span:nth-child(9){--i:8;}
+.ldp-loading-visual-inner > span:nth-child(10){--i:9;}
+.ldp-loading-visual-inner > span:nth-child(11){--i:10;}
+.ldp-loading-visual-inner > span:nth-child(12){--i:11;}
+.ldp-loader-thread-index > span:nth-child(1){--depth:0;}
+.ldp-loader-thread-index > span:nth-child(2),
+.ldp-loader-thread-index > span:nth-child(4){--depth:1;}
+.ldp-loader-thread-index > span:nth-child(3),
+.ldp-loader-thread-index > span:nth-child(5){--depth:2;}
+.ldp-loader-thread-index > span:nth-child(6){--depth:3;}
+.ldp-loader-typewave > span:nth-child(1),
+.ldp-loader-typewave > span:nth-child(4){--row:0;}
+.ldp-loader-typewave > span:nth-child(2),
+.ldp-loader-typewave > span:nth-child(5){--row:1;}
+.ldp-loader-typewave > span:nth-child(3),
+.ldp-loader-typewave > span:nth-child(6){--row:2;}
+.ldp-loader-cache-lanes > span:nth-child(3n+1){--lane:0;}
+.ldp-loader-cache-lanes > span:nth-child(3n+2){--lane:1;}
+.ldp-loader-cache-lanes > span:nth-child(3n){--lane:2;}
+.ldp-loader-inbox-rain > span:nth-child(1){--x:34px;--y:112px;--drop:-72px;}
+.ldp-loader-inbox-rain > span:nth-child(2){--x:77px;--y:98px;--drop:-88px;}
+.ldp-loader-inbox-rain > span:nth-child(3){--x:120px;--y:84px;--drop:-64px;}
+.ldp-loader-inbox-rain > span:nth-child(4){--x:163px;--y:70px;--drop:-94px;}
+.ldp-loader-inbox-rain > span:nth-child(5){--x:206px;--y:56px;--drop:-76px;}
diff --git a/work/main.js b/work/main.js
index 17a21d7..bd02a37 100644
--- a/work/main.js
+++ b/work/main.js
@@ -1,39172 +1,39694 @@
// ==UserScript==
-// @name Awesome LinuxDo Reader
-// @name:zh-CN 更流畅的 LinuxDo 阅读器
-// @namespace https://github.com/sunbigfly/awesome-linuxdo-reader
-// @version 0.1.16
-// @license MIT
-// @description 为 LINUX DO 深度适配、全面兼容标准 Discourse 站点的沉浸式增强阅读器,支持长帖上下文、原站互动、非中文正文翻译、自定义站点与个性布局。
-// @description:en Deeply adapted for LINUX DO and compatible with standard Discourse sites, with threaded reading, native interactions, body translation, custom sites, and personalized layouts.
-// @author sunbigfly
-// @homepageURL https://github.com/sunbigfly/awesome-linuxdo-reader
-// @supportURL https://github.com/sunbigfly/awesome-linuxdo-reader/issues
-// @match https://linux.do/*
-// @match https://community.brave.com/*
-// @match https://devforum.roblox.com/*
-// @match https://community.openai.com/*
-// @match https://community.home-assistant.io/*
-// @match https://forum.cfx.re/*
-// @match https://community.spiceworks.com/*
-// @match https://forum.arduino.cc/*
-// @match https://discussions.unity.com/*
-// @match https://community.cloudflare.com/*
-// @match https://forums.unrealengine.com/*
-// @match https://forum.obsidian.md/*
-// @match https://forum.cursor.com/*
-// @match https://forum.godotengine.org/*
-// @match https://community.n8n.io/*
-// @match https://forum.mikrotik.com/*
-// @match https://meta.discourse.org/*
-// @match https://discuss.python.org/*
-// @match https://forums.swift.org/*
-// @match https://discourse.julialang.org/*
-// @match https://users.rust-lang.org/*
-// @match https://*/*
-// @icon https://cdn3.ldstatic.com/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_512x512.png
-// @grant GM_getValue
-// @grant GM_setValue
-// @grant GM_xmlhttpRequest
-// @grant GM_getResourceText
-// @grant unsafeWindow
-// @connect connect.linux.do
-// @connect credit.linux.do
-// @connect translate.googleapis.com
-// @connect edge.microsoft.com
-// @connect api-edge.cognitive.microsofttranslator.com
-// @connect *
-// @run-at document-start
-// @resource ldpReaderStyles https://cdn.jsdelivr.net/gh/sunbigfly/awesome-linuxdo-reader@6f32c5f548d440963c1c0abb2160908b609f5aec/work/main.css#sha256=0738f4c1470861e1c7fcf29db5b8cb76f788896740492e2a2fc760c90691ff7e
-// @require https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js
-// @require https://cdn.jsdelivr.net/npm/pinyin-pro@3.18.2/dist/index.js
-// @require https://cdn.jsdelivr.net/npm/hls.js@1.6.16/dist/hls.min.js
-// ==/UserScript==
-
-(function () {
- 'use strict';
-
- const CUSTOM_DISCOURSE_SITES_KEY = 'awesome-linuxdo-reader:custom-discourse-sites:v1';
- const BUILTIN_DISCOURSE_HOSTS = new Set([
- 'linux.do', 'community.brave.com', 'devforum.roblox.com', 'community.openai.com',
- 'community.home-assistant.io', 'forum.cfx.re', 'community.spiceworks.com', 'forum.arduino.cc',
- 'discussions.unity.com', 'community.cloudflare.com', 'forums.unrealengine.com', 'forum.obsidian.md',
- 'forum.cursor.com', 'forum.godotengine.org', 'community.n8n.io', 'forum.mikrotik.com',
- 'meta.discourse.org', 'discuss.python.org', 'forums.swift.org', 'discourse.julialang.org',
- 'users.rust-lang.org',
- ]);
- function normalizeCustomDiscourseHost(value) {
- const source = String(value || '').trim();
- if (!source) return '';
- try {
- const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(source) ? source : `https://${source}`);
- return url.protocol === 'https:' && !url.username && !url.password ? url.hostname.toLowerCase() : '';
- } catch { return ''; }
- }
- function readCustomDiscourseSites() {
- try {
- const stored = globalThis.GM_getValue?.(CUSTOM_DISCOURSE_SITES_KEY, []);
- return Array.isArray(stored) ? [...new Set(stored.map(normalizeCustomDiscourseHost).filter(Boolean))] : [];
- } catch { return []; }
- }
- const CURRENT_SITE_HOST = location.hostname.toLowerCase();
- const LDC_USER_BRIDGE_CACHE_KEY = 'awesome-linuxdo-reader:ldc-user-bridge:v1';
- const cacheCreditUser = (data) => Promise.resolve(globalThis.GM_setValue?.(LDC_USER_BRIDGE_CACHE_KEY, { data, cachedAt: Date.now() })).then(() => data);
- if (CURRENT_SITE_HOST === 'credit.linux.do') {
- const syncCreditUser = async () => {
- try {
- const response = await fetch('/api/v1/oauth/user-info', { credentials: 'include', headers: { Accept: 'application/json' } });
- const data = response.ok ? (await response.json())?.data : null;
- if (data) await cacheCreditUser(data);
- } catch { /* 保留最后一次成功缓存 */ }
- };
- const syncAfterLoad = () => setTimeout(() => void syncCreditUser(), 1000);
- if (document.readyState === 'complete') syncAfterLoad();
- else window.addEventListener('load', syncAfterLoad, { once: true });
- return;
- }
- if (!BUILTIN_DISCOURSE_HOSTS.has(CURRENT_SITE_HOST) &&
- !readCustomDiscourseSites().includes(CURRENT_SITE_HOST)) return;
- function writeCustomDiscourseSites(sites) {
- const save = globalThis.GM_setValue;
- if (typeof save !== 'function') throw new Error('脚本没有全局站点存储权限');
- return Promise.resolve(save(CUSTOM_DISCOURSE_SITES_KEY, [...new Set(sites)].sort()));
- }
- function validateCustomDiscourseSite(host) {
- const request = globalThis.GM_xmlhttpRequest;
- if (typeof request !== 'function') return Promise.reject(new Error('脚本没有跨站检测权限'));
- return new Promise((resolve, reject) => request({
- method: 'GET', url: `https://${host}/site/basic-info.json`,
- headers: { Accept: 'application/json' }, responseType: 'json', anonymous: true, timeout: 8000,
- onload: (response) => {
- const info = response.response;
- if (response.status >= 200 && response.status < 300 && info?.title) resolve(info);
- else reject(new Error('未检测到 Discourse'));
- },
- ontimeout: () => reject(new Error('检测超时,请稍后重试')),
- onerror: () => reject(new Error('站点无法访问')),
- }));
- }
-
- const BASE = location.origin;
- const PAGE_ROOT = document.documentElement;
- const makeElement = (tagName) => document.createElement(tagName);
- const READER_VERSION = '0.1.16';
- const HOST_PAGE_WINDOW = globalThis.unsafeWindow;
- const discourseSiteAdapter = (id, name, capabilities = { connect: false }) => Object.freeze({
- id, name, capabilities: Object.freeze(capabilities),
- });
- const GENERIC_DISCOURSE_SITE_ADAPTER = discourseSiteAdapter('discourse', location.hostname);
- // Built-in hosts carry display/capability overrides; user-added Discourse hosts use the generic adapter.
- const DISCOURSE_SITE_ADAPTERS = Object.freeze({
- 'linux.do': discourseSiteAdapter('linux-do', 'LINUX DO', {
- boosts: true,
- reactions: true,
- postVoting: true,
- topicVoting: true,
- connect: true,
- categoryExperts: true,
- follows: true,
- sharedIssue: true,
- }),
- 'community.openai.com': discourseSiteAdapter('openai-community', 'OpenAI Community'),
- 'community.brave.com': discourseSiteAdapter('brave-community', 'Brave Community'),
- 'devforum.roblox.com': discourseSiteAdapter('roblox-developer-forum', 'Roblox Developer Forum'),
- 'forum.cfx.re': discourseSiteAdapter('cfx-forum', 'Cfx.re Forum'),
- 'community.spiceworks.com': discourseSiteAdapter('spiceworks-community', 'Spiceworks Community'),
- 'discussions.unity.com': discourseSiteAdapter('unity-discussions', 'Unity Discussions'),
- 'community.cloudflare.com': discourseSiteAdapter('cloudflare-community', 'Cloudflare Community'),
- 'forums.unrealengine.com': discourseSiteAdapter('epic-developer-community', 'Epic Developer Community'),
- 'forum.obsidian.md': discourseSiteAdapter('obsidian-forum', 'Obsidian Forum'),
- 'forum.cursor.com': discourseSiteAdapter('cursor-community', 'Cursor Community'),
- 'forum.godotengine.org': discourseSiteAdapter('godot-forum', 'Godot Forum'),
- 'community.n8n.io': discourseSiteAdapter('n8n-community', 'n8n Community'),
- 'forum.mikrotik.com': discourseSiteAdapter('mikrotik-forum', 'MikroTik Forum'),
- 'meta.discourse.org': discourseSiteAdapter('discourse-meta', 'Discourse Meta'),
- 'discuss.python.org': discourseSiteAdapter('python-discussions', 'Python Discussions'),
- 'forums.swift.org': discourseSiteAdapter('swift-forums', 'Swift Forums'),
- 'discourse.julialang.org': discourseSiteAdapter('julia-discourse', 'Julia Discourse'),
- 'community.home-assistant.io': discourseSiteAdapter('home-assistant-community', 'Home Assistant Community'),
- 'forum.arduino.cc': discourseSiteAdapter('arduino-forum', 'Arduino Forum'),
- 'users.rust-lang.org': discourseSiteAdapter('rust-users', 'Rust Users Forum'),
- });
- const SITE_ADAPTER = DISCOURSE_SITE_ADAPTERS[location.hostname.toLowerCase()] ||
- GENERIC_DISCOURSE_SITE_ADAPTER;
- const SITE_DISPLAY_NAME = SITE_ADAPTER.name || location.hostname;
- const SITE_LANGUAGE = SITE_ADAPTER === GENERIC_DISCOURSE_SITE_ADAPTER
- ? '' : SITE_ADAPTER.id === 'linux-do' ? 'zh-CN' : 'en';
- function siteAllowsBodyTranslation() {
- return !/^zh(?:[-_]|$)/i.test(SITE_LANGUAGE);
- }
- const TOPIC_CACHE_TTL = 90 * 1000;
- const READ_THRESHOLD = 1500;
- const FLUSH_INTERVAL = 5000;
- const READER_READ_BATCH_SIZE = 20;
- const JUMP_HIGHLIGHT_DEFAULTS = Object.freeze({
- color: '#0888cc',
- radius: 10,
- borderWidth: 1,
- rate: 0.8,
- count: 2,
- });
- const JUMP_HIGHLIGHT_LIMITS = Object.freeze({
- radius: Object.freeze({ min: 0, max: 24, step: 1, integer: true }),
- borderWidth: Object.freeze({ min: 0, max: 4, step: 1, integer: true }),
- rate: Object.freeze({ min: 0.5, max: 2, step: 0.1, integer: false }),
- count: Object.freeze({ min: 1, max: 6, step: 1, integer: true }),
- });
- const JUMP_HIGHLIGHT_SETTING_FIELDS = Object.freeze([
- { key: 'color', pref: 'jumpHighlightColor', label: '提示颜色', type: 'color', format: String,
- help: '选择跳转目标楼层的闪烁颜色;提示使用半透明底色避免遮住正文,并用同色细轮廓准确呈现所选颜色。选择时实时预览,统一保存。' },
- { key: 'radius', pref: 'jumpHighlightRadius', label: '提示圆角', ariaLabel: '跳转提示圆角', format: (value) => `${value}px`,
- help: `控制闪烁背景的圆角,可在 ${JUMP_HIGHLIGHT_LIMITS.radius.min}–${JUMP_HIGHLIGHT_LIMITS.radius.max}px 之间调整。拖动时实时预览,统一保存。` },
- { key: 'borderWidth', pref: 'jumpHighlightBorderWidth', label: '提示轮廓宽度', ariaLabel: '跳转提示轮廓宽度', format: (value) => `${value}px`,
- help: `控制闪烁轮廓的宽度,可在 ${JUMP_HIGHLIGHT_LIMITS.borderWidth.min}–${JUMP_HIGHLIGHT_LIMITS.borderWidth.max}px 之间调整;设为 0px 可关闭边框,颜色跟随闪烁颜色。拖动时实时预览,统一保存。` },
- { key: 'rate', pref: 'jumpHighlightRate', label: '闪烁速度', ariaLabel: '跳转提示闪烁速度', format: (value) => `${value.toFixed(1)} 次/秒`,
- help: `控制每秒闪烁次数,可在 ${JUMP_HIGHLIGHT_LIMITS.rate.min}–${JUMP_HIGHLIGHT_LIMITS.rate.max} 次/秒之间调整;数值越大闪得越快。拖动时实时预览,统一保存。` },
- { key: 'count', pref: 'jumpHighlightCount', label: '闪烁次数', ariaLabel: '跳转提示闪烁次数', format: (value) => `${value} 次`,
- help: `控制一次跳转连续闪烁多少次,可在 ${JUMP_HIGHLIGHT_LIMITS.count.min}–${JUMP_HIGHLIGHT_LIMITS.count.max} 次之间调整。拖动时实时预览,统一保存。` },
- ]);
- const SITE_LOGO_FALLBACK_URL = `${BASE}/favicon.ico`;
- const SITE_LOGO_PLACEHOLDER_URL = `data:image/svg+xml,${encodeURIComponent(
- '
'
- )}`;
- let SITE_LOGO_URL = SITE_LOGO_FALLBACK_URL;
- let SITE_LOGO_CANDIDATES = [SITE_LOGO_FALLBACK_URL, SITE_LOGO_PLACEHOLDER_URL];
-
- function normalizeSiteLogoUrl(value) {
- const source = String(value || '').trim();
- if (!source) return '';
- if (/^data:image\//i.test(source)) return source;
- try {
- const url = new URL(source, BASE);
- return /^https?:$/i.test(url.protocol) ? url.href : '';
- } catch {
- return '';
- }
- }
-
- function syncSiteLogoImages() {
- const candidates = SITE_LOGO_CANDIDATES.slice();
- readerPortalQueryAll('[data-ldp-site-logo]').forEach((image) => {
- image._ldpSiteLogoCandidates = candidates;
- image._ldpSiteLogoIndex = 0;
- image.onerror = () => {
- const nextIndex = Number(image._ldpSiteLogoIndex || 0) + 1;
- image._ldpSiteLogoIndex = nextIndex;
- const next = image._ldpSiteLogoCandidates?.[nextIndex];
- if (next) {
- image.src = next;
- } else {
- image.onerror = null;
- }
- };
- if (image.src !== SITE_LOGO_URL) {
- image.src = SITE_LOGO_URL;
- } else if (image.complete && image.naturalWidth === 0) {
- image.onerror();
- }
- });
- }
-
- function applySiteLogoCandidates(values) {
- const candidates = [...new Set((values || []).map(normalizeSiteLogoUrl).filter(Boolean))];
- if (!candidates.length) return;
- SITE_LOGO_CANDIDATES = [
- ...candidates,
- ...SITE_LOGO_CANDIDATES.filter((candidate) => !candidates.includes(candidate)),
- ];
- SITE_LOGO_URL = SITE_LOGO_CANDIDATES[0];
- syncSiteLogoImages();
- }
-
- function siteLogoCandidatesFromHost() {
- const siteSettings = lookupDiscourse('service:site-settings');
- const hostLogo = document.querySelector(
- '.d-header #site-logo,.d-header img.logo-big,.d-header img.logo-small,.custom-logo-link img'
- );
- const touchIcon = document.querySelector('link[rel~="apple-touch-icon"]');
- const pageIcon = document.querySelector('link[rel~="icon"]');
- return [
- siteSettings?.large_icon,
- siteSettings?.apple_touch_icon,
- siteSettings?.favicon,
- siteSettings?.logo_small,
- siteSettings?.mobile_logo,
- siteSettings?.logo,
- hostLogo?.currentSrc || hostLogo?.src,
- touchIcon?.href,
- pageIcon?.href,
- ];
- }
-
- async function resolveSiteLogo() {
- applySiteLogoCandidates(siteLogoCandidatesFromHost());
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), 5000);
- try {
- const response = await fetch(`${BASE}/site/basic-info.json`, {
- credentials: 'same-origin',
- headers: { Accept: 'application/json' },
- cache: 'force-cache',
- signal: controller.signal,
- });
- if (!response.ok) return;
- const info = await response.json();
- applySiteLogoCandidates([
- info.large_icon_url,
- info.apple_touch_icon_url,
- info.favicon_url,
- info.logo_small_url,
- info.mobile_logo_url,
- info.logo_url,
- ]);
- } catch (error) {
- if (error?.name !== 'AbortError') {
- console.warn('[LDP] site logo discovery failed', error);
- }
- } finally {
- clearTimeout(timer);
- }
- }
- const READER_MANUAL_URL = 'https://sunbigfly.github.io/awesome-linuxdo-reader/';
- const BOOST_MENTION_PROJECT_URL = 'https://greasyfork.org/zh-CN/scripts/580986-linux-do-boost-%E5%A2%9E%E5%BC%BA';
- const EXTERNAL_FONT_RENDERING_MARKER = 'fr-init-once';
- const FONT_RENDERING_PROJECT_URL = 'https://github.com/F9y4ng/GreasyFork-Scripts/';
- const FONT_RENDERING_LICENSE_URL = 'https://github.com/F9y4ng/GreasyFork-Scripts/blob/master/LICENSE';
- const READER_FONT_RENDERING_DEFAULTS = (() => {
- const userAgent = navigator.userAgent || '';
- const isGecko = /Firefox\//.test(userAgent);
- const isWebKit = /AppleWebKit\//.test(userAgent)
- && !/(?:Chrome|Chromium|Edg|OPR|CriOS|FxiOS)\//.test(userAgent);
- return Object.freeze({
- stroke: isGecko ? 0.03 : isWebKit ? 0.05 : 0.015,
- shadow: isGecko ? 0.55 : isWebKit ? 0.45 : 0.75,
- macSmoothing: /Mac/.test(navigator.platform || ''),
- });
- })();
- function applyReaderFontRenderingRuntimeProperties() {
- PAGE_ROOT.style.setProperty('--ldp-font-rendering-stroke-runtime', `${READER_FONT_RENDERING_DEFAULTS.stroke}px currentcolor`);
- PAGE_ROOT.style.setProperty('--ldp-font-rendering-shadow-runtime', `0 0 ${READER_FONT_RENDERING_DEFAULTS.shadow}px #7c7c7cdd`);
- PAGE_ROOT.toggleAttribute('data-ldp-font-mac-smoothing', READER_FONT_RENDERING_DEFAULTS.macSmoothing);
- }
- const LDP_PREF_KEY = 'linuxdo-enhanced-reader:prefs';
- const LDP_CONFIG_EXPORT_FORMAT = 'awesome-linuxdo-reader-settings';
- const LDP_CONFIG_EXPORT_VERSION = 5;
- const LDP_HISTORY_KEY = 'linuxdo-enhanced-reader:history';
- const LDP_READER_QUEUE_KEY = 'linuxdo-enhanced-reader:reader-queue:v1';
- const LDP_REPLY_BRANCH_STATE_KEY = 'linuxdo-enhanced-reader:reply-branches:v1';
- const LDP_REPLY_WINDOW_STATE_KEY = 'linuxdo-enhanced-reader:reply-window:v1';
- const LDP_TOPIC_CACHE_KEY = 'linuxdo-enhanced-reader:cache:topics';
- const LDP_TOPIC_NAV_ICON_CACHE_KEY = 'linuxdo-enhanced-reader:cache:topic-nav-icons';
- const LDP_USER_CARD_CACHE_KEY = 'linuxdo-enhanced-reader:cache:users';
- const LDP_NOTIFICATION_CACHE_KEY = 'linuxdo-enhanced-reader:cache:notifications';
- const LDP_SHARED_REQUEST_STATE_KEY = 'linuxdo-enhanced-reader:shared-request-state:v1';
- const LDP_SHARED_REQUEST_CHANNEL = 'linuxdo-enhanced-reader:shared-request-flow:v1';
- const LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY = 'linuxdo-enhanced-reader:cloudflare-challenge:v1';
- const LDP_CLOUDFLARE_CHALLENGE_LEASE_TTL = 15 * 1000;
- const LDP_READER_READ_SUCCESS_KEY = 'linuxdo-enhanced-reader:read-success:v1';
- const LDP_READER_READ_LOCK_NAME = 'linuxdo-enhanced-reader:read-request:v1';
- const READER_READ_SUCCESS_TTL = 60 * 1000;
- const LDP_EMBEDDED_RELOAD_STATE_KEY = 'linuxdo-enhanced-reader:embedded-reload-state:v1';
- const LDP_EMBEDDED_RELOAD_STATE_TTL = 30 * 1000;
- const LDP_AVATAR_CACHE_NAME = 'linuxdo-enhanced-reader:avatars:v1';
- const LDP_EMOJI_IMAGE_CACHE_NAME = 'linuxdo-enhanced-reader:emoji-images:v1';
- const LDP_LIGHTBOX_IMAGE_CACHE_NAME = 'linuxdo-enhanced-reader:lightbox-images:v1';
- const LDP_RESPONSE_CACHE_DB_NAME = 'linuxdo-enhanced-reader:responses:v1';
- const LDP_RESPONSE_CACHE_STORE = 'responses';
- const PERSISTENT_CACHE_VERSION = 1;
- const TOPIC_SNAPSHOT_VERSION = 1;
- const TOPIC_SNAPSHOT_WRITE_DELAY = 750;
- const TOPIC_SNAPSHOT_WRITE_MAX_DELAY = 3000;
- const MINUTE = 60 * 1000;
- const HOUR = 60 * MINUTE;
- const DAY = 24 * HOUR;
- const LDP_AVATAR_CACHE_MAX_AGE = 90 * DAY;
- const LDP_EMOJI_IMAGE_CACHE_MAX_AGE = 90 * DAY;
- const LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE = 7 * DAY;
- const LDP_SCROLL_LOCK_CLASS = 'ldp-scroll-lock';
- const NATIVE_BYPASS_PARAM = 'ldp_native';
- const NATIVE_BYPASS_TAB_KEY = 'linuxdo-enhanced-reader:native-tab';
-
- const KATEX_VERSION = '0.16.22';
- const IMAGE_SCALE_OPTIONS = [25, 50, 100, 125, 150, 200];
- const LIGHTBOX_DESCRIPTION_HEIGHT_MIN = 56;
- const LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT = 120;
- const LIGHTBOX_COMMENTS_WIDTH_DEFAULT = 25;
- const LIGHTBOX_COMMENTS_WIDTH_MIN = 18;
- const LIGHTBOX_COMMENTS_WIDTH_MAX = 50;
- const IMAGE_SCALE_MIN = 50;
- const IMAGE_SCALE_MAX = 200;
- const FONT_SCALE_MIN = 50;
- const FONT_SCALE_MAX = 250;
- const FONT_SCALE_DEFAULT = 100;
- const COMPOSER_FONT_SCALE_DEFAULT = 80;
- const READER_THEME_MODES = Object.freeze(['light', 'dark', 'system']);
- const READER_THEME_LABELS = Object.freeze({ light: '明', dark: '暗', system: '系统' });
- const READER_THEME_VALUE_ROWS = [
- ['--secondary', '#ffffff', '#171b20'],
- ['--primary', '#20252b', '#e7edf3'],
- ['--primary-high', '#333a42', '#cbd4dc'],
- ['--primary-medium', '#68737e', '#98a4af'],
- ['--primary-low-mid', '#b8c0c8', '#4a5560'],
- ['--primary-low', '#d7dde3', '#39414a'],
- ['--primary-very-low', '#f2f4f6', '#242a31'],
- ['--secondary-low', '#4b545d', '#363c43'],
- ['--secondary-medium', '#89929c', '#7f8790'],
- ['--secondary-high', '#dfe3e6', '#c9d0d8'],
- ['--secondary-very-high', '#f7f8f9', '#e3e8ed'],
- ['--tertiary', '#47855f', '#78c295'],
- ['--tertiary-low', '#dceee2', '#223a2c'],
- ['--header_background', '#ffffff', '#171b20'],
- ['--header_primary', '#20252b', '#e7edf3'],
- ['--header_primary-low', '#e7eaed', '#2a3037'],
- ['--header_primary-low-mid', '#b7bec6', '#4a5560'],
- ['--header_primary-medium', '#68737e', '#98a4af'],
- ['--header_primary-high', '#333a42', '#cbd4dc'],
- ['--highlight', '#fff0a8', '#6b5a16'],
- ['--highlight-low', '#fff8d8', '#302b18'],
- ['--highlight-medium', '#ffe47a', '#8a7420'],
- ['--highlight-high', '#806200', '#ffe47a'],
- ['--quaternary', '#d86645', '#f08a67'],
- ['--danger', '#c84545', '#ff7b72'],
- ['--danger-low', '#f7dddd', '#44262a'],
- ['--danger-low-mid', '#efb5b5', '#6b353b'],
- ['--success', '#47855f', '#78c295'],
- ['--success-low', '#dceee2', '#223a2c'],
- ['--love', '#dc6542', '#ff8a65'],
- ['--love-low', '#f7ddd5', '#472b25'],
- ['--d-link-color', '#2870b8', '#71b7ff'],
- ['--d-post-aside-background', '#f7f9fa', '#20262d'],
- ['--ldp-zebra-color', '#f7f7f7', '#1b2b21'],
- ['--ldp-reply-line-color', '#47855f', '#78c295'],
- ['--ldp-quote-line-color', '#d7d7d7', '#46505a'],
- ['--ldp-divider-line-color', '#e5e5e5', '#343b44'],
- ['--ldp-request-metric-pine', '#315f47', '#86b49a'],
- ['--ldp-request-metric-navy', '#365673', '#8eacc8'],
- ['--ldp-request-metric-plum', '#5e4b72', '#b09bc4'],
- ['--ldp-request-metric-bronze', '#72551f', '#c5a36d'],
- ['--ldp-request-metric-wine', '#743f47', '#c7929a'],
- ];
- const READER_THEME_VARIABLES = Object.freeze(Object.fromEntries(
- ['light', 'dark'].map((mode, valueIndex) => [mode, Object.freeze(Object.fromEntries(
- READER_THEME_VALUE_ROWS.map(([name, ...values]) => [name, values[valueIndex]]),
- ))]),
- ));
- const ACCENT_COLOR_DEFAULT = READER_THEME_VARIABLES.light['--tertiary'];
- const LINK_COLOR_DEFAULT = READER_THEME_VARIABLES.light['--d-link-color'];
- const ZEBRA_COLOR_DEFAULT = '#f7f7f7';
- const LIST_ZEBRA_COLOR_DEFAULT = '#f7f7f7';
- const ZEBRA_RADIUS_MIN = 0;
- const ZEBRA_RADIUS_MAX = 16;
- const ZEBRA_RADIUS_DEFAULT = 10;
- const ZEBRA_RADIUS_STEP = 1;
- const REPLY_LINE_COLOR_DEFAULT = '#6dab85';
- const LINE_WIDTH_MIN = 0.5;
- const LINE_WIDTH_MAX = 4;
- const LINE_WIDTH_STEP = 0.5;
- const REPLY_LINE_WIDTH_DEFAULT = 1;
- const REPLY_LINE_RADIUS_MIN = 0;
- const REPLY_LINE_RADIUS_MAX = 16;
- const REPLY_LINE_RADIUS_DEFAULT = 15;
- const REPLY_LINE_RADIUS_STEP = 1;
- const QUOTE_LINE_COLOR_DEFAULT = '#d7d7d7';
- const QUOTE_LINE_WIDTH_DEFAULT = 0.5;
- const DIVIDER_LINE_COLOR_DEFAULT = '#e5e5e5';
- const DIVIDER_LINE_WIDTH_DEFAULT = 0.5;
- const FLOOR_PREVIEW_COLOR_DEFAULT = '#dba7a7';
- const FLOOR_PREVIEW_COLOR_DARK_DEFAULT = '#f87171';
- const FLOOR_PREVIEW_HEIGHT_MIN = 180;
- const FLOOR_PREVIEW_HEIGHT_MAX = 720;
- const FLOOR_PREVIEW_HEIGHT_DEFAULT = 300;
- const FLOOR_PREVIEW_HEIGHT_STEP = 10;
- const PERFORMANCE_SETTING_GROUPS = [
- {
- id: 'main-request', title: '楼层数据加载',
- description: '设置每次从服务器加载多少楼层;只影响单批数据量,不等于页面同时显示的楼层数。',
- fields: [
- { key: 'pageSize', pref: 'performancePageSize', presets: [16, 40, 48], limits: { min: 12, max: 64, step: 1, integer: true }, title: '每次加载楼层数', unit: '个', inputMode: 'numeric',
- description: '接近已加载内容边缘时,一次再取多少楼层;数值越大,等待次数越少,但单次下载和处理更重。',
- help: '阅读接近已加载内容边缘时,一次向服务器请求多少个楼层。它只决定单批响应大小,不改变页面同时保留的楼层数量。' },
- ],
- },
- {
- id: 'dom', title: '页面楼层保留',
- description: '控制当前页面前后保留多少楼层;远处内容会卸载以节省内存,不会因此发起网络请求。',
- fields: [
- { key: 'streamOverscanViewports', pref: 'performanceStreamOverscan', presets: [0.35, 1.25, 2], limits: { min: 0.25, max: 3, step: 0.05 }, title: '屏幕外预留范围', unit: '屏', inputMode: 'decimal',
- description: '在当前可见区域前后额外保留多少屏内容;数值越大,快速滚动越不容易看到空白,但更占内存。',
- help: '在当前屏幕前后额外保留多少屏楼层元素。它只影响页面渲染和内存,不会发起楼层请求,并受“同时保留楼层上限”约束。' },
- { key: 'streamMaxItems', pref: 'performanceStreamMaxItems', presets: [28, 72, 96], limits: { min: 24, max: 128, step: 1, integer: true }, title: '同时保留楼层上限', unit: '个', inputMode: 'numeric',
- description: '页面同时保留的楼层数量上限;离当前阅读位置较远的楼层会卸载,滚回时再恢复。',
- help: '首次进入、滚动和跳转期间,页面最多同时保留多少个主楼层元素。远处楼层会从页面结构中卸载,并用等高占位保持滚动位置。' },
- ],
- },
- {
- id: 'nested', title: '二级回复预加载',
- description: '父楼层接近屏幕时,自动加载它下面的直接回复。',
- fields: [
- { key: 'nestedPrefetchViewports', pref: 'performanceNestedPrefetch', presets: [0.35, 1.25, 2], limits: { min: 0, max: 3, step: 0.05 }, title: '提前加载距离', unit: '屏', inputMode: 'decimal',
- description: '0 表示父楼层进入屏幕后再加载;大于 0 表示提前对应屏数加载。每个父楼层首批最多加载 20 条直接回复。',
- help: '父楼层距离当前屏幕多远时开始加载直接回复。0 表示进入屏幕后加载;数值越大越早加载,也会更早产生网络请求。' },
- ],
- },
- {
- id: 'request', title: '网络请求节奏',
- description: '帖子、楼层、二级回复、消息和用户资料共用这些限制;遇到原站操作或限流时会自动放慢。',
- fields: [
- { key: 'requestMaxConcurrent', pref: 'performanceRequestConcurrency', presets: [1, 3, 4], limits: { min: 1, max: 4, step: 1, integer: true }, title: '同时请求上限', unit: '路', inputMode: 'numeric',
- description: '最多允许多少个阅读器请求同时进行;实际数量可能因多个页面、原站操作或限流而更低。',
- help: '阅读器网络请求同时进行的最高数量。多个脚本实例、原站操作、服务器限流和恢复状态只会继续降低实际数量。' },
- { key: 'requestMinInterval', pref: 'performanceRequestInterval', presets: [220, 90, 80], limits: { min: 80, max: 500, step: 10, integer: true }, title: '请求最短间隔', unit: 'ms', inputMode: 'numeric',
- description: '正常情况下,两次请求开始之间至少间隔多久;原站繁忙或出现 429 时会自动延长。',
- help: '正常状态下两次阅读器请求开始的最短间隔。自适应调度可以延长它,且不会在恢复后一次性补发积压请求。' },
- { key: 'requestRateTarget', pref: 'performanceRequestRateTarget', presets: [80, 80, 80], limits: { min: 50, max: 95, step: 1, integer: true }, title: '限流额度使用比例', unit: '%', inputMode: 'numeric',
- description: '只使用服务器已探测请求额度的一部分,为原站操作保留余量;比例越低越保守。',
- help: '阅读器计划使用服务器 10 秒与 60 秒请求额度的比例。它只控制请求启动节奏,不改变每次加载楼层数或二级回复数量。' },
- ],
- },
- ];
- const PERFORMANCE_SETTING_FIELDS = PERFORMANCE_SETTING_GROUPS.flatMap((group) => group.fields);
- const PERFORMANCE_PRESETS = Object.freeze(Object.fromEntries(
- ['low', 'balanced', 'high'].map((name, valueIndex) => [name, Object.freeze(Object.fromEntries(
- PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, field.presets[valueIndex]]),
- ))]),
- ));
- const PERFORMANCE_LIMITS = Object.freeze(Object.fromEntries(
- PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, Object.freeze(field.limits)]),
- ));
- const PERFORMANCE_PREF_KEYS = Object.freeze(Object.fromEntries(
- PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, field.pref]),
- ));
- const PERFORMANCE_SETTING_KEYS = Object.freeze(Object.keys(PERFORMANCE_PREF_KEYS));
- const LAYOUT_REGION_KEYS = Object.freeze(['left', 'main', 'gap', 'timeline', 'right']);
- const LAYOUT_REGION_LABELS = Object.freeze({
- left: '左侧留白',
- main: '正文区域',
- gap: '正文与时间轴间距',
- timeline: '楼层时间轴',
- right: '右侧留白',
- });
- const COMPOSER_WINDOW_PREFIX = 'composerWindow';
- const READER_LAYOUT_DEFAULT = Object.freeze({ left: 0, main: 88, gap: 0, timeline: 8, right: 4 });
- const READER_FULLPAGE_LAYOUT_DEFAULT = Object.freeze({ left: 15, main: 70, gap: 5, timeline: 8, right: 2 });
- const FONT_FAMILY_DEFAULT = 'system';
- const FONT_FAMILY_OPTIONS = Object.freeze({
- site: Object.freeze({ label: '跟随原站', stack: 'inherit' }),
- system: Object.freeze({ label: '系统默认字体', stack: 'system-ui,sans-serif' }),
- cjkSans: Object.freeze({ label: '中文无衬线', stack: '"Noto Sans CJK SC","Microsoft YaHei","PingFang SC",system-ui,sans-serif' }),
- serif: Object.freeze({ label: '衬线', stack: '"Noto Serif CJK SC","Songti SC",SimSun,serif' }),
- monospace: Object.freeze({ label: '等宽', stack: 'ui-monospace,SFMono-Regular,Consolas,monospace' }),
- custom: Object.freeze({ label: '自定义本机字体', stack: '' }),
- });
- const FONT_WEIGHT_DEFAULT = 400;
- const FONT_WEIGHT_OPTIONS = Object.freeze([300, 400, 500, 600]);
- const FONT_WEIGHT_LABELS = Object.freeze({ 300: '细', 400: '常规', 500: '中等', 600: '半粗' });
- const HOST_EMBED_SIZE_MIN = 50;
- const HOST_EMBED_SIZE_MAX = 200;
- const HOST_EMBED_SIZE_DEFAULT = 100;
- const HOST_EMBED_SIZE_SETTINGS = Object.freeze({
- title: Object.freeze({ pref: 'hostEmbeddedTitleScale', label: '主题标题', defaultScale: 110, properties: [['--ldp-host-topic-title-size', 15]] }),
- avatar: Object.freeze({ pref: 'hostEmbeddedAvatarScale', label: '头像', defaultScale: 80, properties: [['--ldp-host-topic-avatar-size', 32], ['--ldp-host-topic-avatar-size-medium', 24], ['--ldp-host-topic-avatar-size-small', 20]] }),
- stats: Object.freeze({ pref: 'hostEmbeddedStatsScale', label: '主题统计信息', defaultScale: 120, properties: [['--ldp-host-topic-stats-size', 10], ['--ldp-host-topic-stats-label-size', 9], ['--ldp-host-topic-stats-row-offset', -4]] }),
- labelCard: Object.freeze({ pref: 'hostEmbeddedLabelCardScale', label: '标签卡片', defaultScale: 100, properties: [['--ldp-host-label-card-height', 22], ['--ldp-host-label-card-font-size', 11], ['--ldp-host-label-card-icon-size', 14], ['--ldp-host-label-card-gap', 3], ['--ldp-host-label-card-padding', 7]] }),
- });
- const FONT_SCOPE_KEYS = Object.freeze(['interface', 'post', 'composer']);
- const FONT_SCOPE_CONFIG = Object.freeze({
- interface: Object.freeze({ label: '界面文字', family: 'family', customFamily: 'customFamily', weight: 'weight', color: 'interfaceColor', scale: 'interface' }),
- post: Object.freeze({ label: '帖子正文', family: 'postFamily', customFamily: 'postCustomFamily', weight: 'postWeight', color: 'postColor', scale: 'post' }),
- composer: Object.freeze({ label: '回复输入框', family: 'composerFamily', customFamily: 'composerCustomFamily', weight: 'composerWeight', color: 'composerColor', scale: 'composer' }),
- });
- const HOST_FONT_CONFIG = Object.freeze({
- label: '嵌入阅读时的原站主题列表', family: 'hostFontFamily', customFamily: 'hostFontCustomFamily', weight: 'hostFontWeight', color: 'hostFontColor',
- });
- const HOST_FONT_DEFAULTS = Object.freeze({
- hostFontFamily: FONT_FAMILY_DEFAULT,
- hostFontCustomFamily: '',
- hostFontWeight: FONT_WEIGHT_DEFAULT,
- hostFontColor: '',
- });
- const FONT_PICKER_SCOPE_KEYS = Object.freeze([...FONT_SCOPE_KEYS, 'host']);
- const FONT_PROFILE_VALUE_KEYS = Object.freeze([
- 'family', 'customFamily', 'weight', 'interfaceColor', 'interface',
- 'postFamily', 'postCustomFamily', 'postWeight', 'postColor', 'post',
- 'composerFamily', 'composerCustomFamily', 'composerWeight', 'composerColor', 'composer',
- ]);
- const READER_FONT_DEFAULT = Object.freeze({
- family: FONT_FAMILY_DEFAULT, customFamily: '', weight: FONT_WEIGHT_DEFAULT, interfaceColor: '', interface: 92,
- postFamily: FONT_FAMILY_DEFAULT, postCustomFamily: '', postWeight: FONT_WEIGHT_DEFAULT, postColor: '', post: 95,
- composerFamily: FONT_FAMILY_DEFAULT, composerCustomFamily: '', composerWeight: FONT_WEIGHT_DEFAULT,
- composerColor: '', composer: COMPOSER_FONT_SCALE_DEFAULT,
- });
- const IMAGE_PROFILE_DEFAULT = Object.freeze({ preset: '50', custom: 50 });
- const APPEARANCE_COLOR_KEYS = Object.freeze([
- 'accentColor', 'linkColor', 'zebraColor', 'listZebraColor',
- 'replyLineColor', 'quoteLineColor', 'dividerLineColor', 'floorPreviewColor',
- ]);
- const APPEARANCE_COLOR_THEME_LIMITS = Object.freeze({
- accentColor: Object.freeze({ saturationMax: 82, light: [30, 55], dark: [58, 78] }),
- linkColor: Object.freeze({ saturationMax: 90, light: [30, 55], dark: [60, 80] }),
- zebraColor: Object.freeze({ saturationMax: 70, light: [92, 100], dark: [10, 20] }),
- listZebraColor: Object.freeze({ saturationMax: 38, light: [88, 98], dark: [10, 20] }),
- replyLineColor: Object.freeze({ saturationMax: 76, light: [30, 58], dark: [55, 78] }),
- quoteLineColor: Object.freeze({ saturationMax: 50, light: [72, 92], dark: [22, 42] }),
- dividerLineColor: Object.freeze({ saturationMax: 40, light: [80, 94], dark: [18, 34] }),
- floorPreviewColor: Object.freeze({ saturationMax: 88, light: [35, 58], dark: [58, 78] }),
- });
- const APPEARANCE_PROFILE_SETTING_KEYS = Object.freeze([
- 'accentColor', 'linkColor', 'zebraColor', 'zebraRadius', 'listZebraColor',
- 'structureColorsEnabled', 'replyLineColor', 'replyLineWidth', 'replyLineRadius',
- 'quoteLineColor', 'quoteLineWidth', 'dividerLineColor', 'dividerLineWidth',
- 'floorPreviewColor', 'floorPreviewHeight',
- ]);
- const APPEARANCE_PROFILE_DEFAULT = Object.freeze({
- accentColor: ACCENT_COLOR_DEFAULT,
- accentColorDark: READER_THEME_VARIABLES.dark['--tertiary'],
- linkColor: LINK_COLOR_DEFAULT,
- linkColorDark: READER_THEME_VARIABLES.dark['--d-link-color'],
- zebraColor: ZEBRA_COLOR_DEFAULT,
- zebraColorDark: READER_THEME_VARIABLES.dark['--ldp-zebra-color'],
- zebraRadius: ZEBRA_RADIUS_DEFAULT,
- listZebraColor: LIST_ZEBRA_COLOR_DEFAULT,
- listZebraColorDark: READER_THEME_VARIABLES.dark['--primary-very-low'],
- structureColorsEnabled: true,
- replyLineColor: REPLY_LINE_COLOR_DEFAULT,
- replyLineColorDark: READER_THEME_VARIABLES.dark['--ldp-reply-line-color'],
- replyLineWidth: REPLY_LINE_WIDTH_DEFAULT,
- replyLineRadius: REPLY_LINE_RADIUS_DEFAULT,
- quoteLineColor: QUOTE_LINE_COLOR_DEFAULT,
- quoteLineColorDark: READER_THEME_VARIABLES.dark['--ldp-quote-line-color'],
- quoteLineWidth: QUOTE_LINE_WIDTH_DEFAULT,
- dividerLineColor: DIVIDER_LINE_COLOR_DEFAULT,
- dividerLineColorDark: READER_THEME_VARIABLES.dark['--ldp-divider-line-color'],
- dividerLineWidth: DIVIDER_LINE_WIDTH_DEFAULT,
- floorPreviewColor: FLOOR_PREVIEW_COLOR_DEFAULT,
- floorPreviewColorDark: FLOOR_PREVIEW_COLOR_DARK_DEFAULT,
- floorPreviewHeight: FLOOR_PREVIEW_HEIGHT_DEFAULT,
- });
- const APPEARANCE_PROFILE_VALUE_KEYS = Object.freeze(APPEARANCE_PROFILE_SETTING_KEYS.flatMap((key) =>
- APPEARANCE_COLOR_KEYS.includes(key) ? [key, `${key}Dark`] : [key]
- ));
- const INTERFACE_FONT_TOKEN_BASES = Object.freeze({
- '--ldp-font-micro': 9,
- '--ldp-font-xs': 10,
- '--ldp-font-sm': 11,
- '--ldp-font-ui': 12,
- '--ldp-font-base': 13,
- '--ldp-font-md': 14,
- '--ldp-font-lg': 15,
- '--ldp-font-xl': 16,
- '--ldp-font-2xl': 17,
- '--ldp-font-3xl': 18,
- });
- const POST_FONT_BASE_PX = 14;
- const LAYOUT_MIN_RATIOS = Object.freeze({ left: 0, main: 40, gap: 0, timeline: 6, right: 0 });
- const READER_WINDOW_DEFAULT_WIDTH = 1080;
- const READER_WINDOW_DEFAULT_VIEWPORT_WIDTH = 0.94;
- const READER_WINDOW_DEFAULT_VIEWPORT_HEIGHT = 0.86;
- const READER_WINDOW_MIN_WIDTH = 360;
- const READER_WINDOW_MIN_HEIGHT = 320;
- const READER_WINDOW_COMPACT_WIDTH = 700;
- const READER_TITLE_MIN_BASE_PX = 12;
- const READER_META_MIN_BASE_PX = 9;
- const READER_TOPIC_TAG_MIN_BASE_PX = 9.5;
- const READER_WINDOW_MARGIN = 8;
- const READER_SETTINGS_SURFACE_MARGIN = 8;
- const READER_SETTINGS_SHORT_HEIGHT = 560;
- const READER_WINDOW_HANDLE_DOCK_THRESHOLD = 38;
- const READER_EMBED_MIN_WIDTH = 360;
- const READER_EMBED_DEFAULT_WIDTH = Math.round(window.innerWidth * .45);
- const READER_HOST_MIN_WIDTH = 680;
- const LIST_READER_MODES = Object.freeze(['floating', 'fullpage', 'embed-left', 'embed-right']);
- const TOPIC_READER_MODES = Object.freeze(['floating', 'fullpage']);
- const READER_PRESENTATIONS = Object.freeze({
- floating: Object.freeze({ mode: 'floating', floating: true, fullPage: false, embedded: false, side: '' }),
- fullpage: Object.freeze({ mode: 'fullpage', floating: false, fullPage: true, embedded: false, side: '' }),
- 'embed-left': Object.freeze({ mode: 'embed-left', floating: false, fullPage: false, embedded: true, side: 'left' }),
- 'embed-right': Object.freeze({ mode: 'embed-right', floating: false, fullPage: false, embedded: true, side: 'right' }),
- });
- const BOOST_COPY_MAX_LENGTH = 16;
- const BOOST_COPY_MODES = Object.freeze(['counter', 'text']);
- const BOOST_COPY_DEFAULTS = Object.freeze({
- mode: 'counter',
- prefix: '',
- counterMarker: '+',
- counterStep: 1,
- fixedSuffix: '',
- });
- const BALANCED_PERFORMANCE = PERFORMANCE_PRESETS.balanced;
- const NOTIFICATION_PAGE_SIZE = 24;
- const NOTIFICATION_CACHE_FRESH_FOR = MINUTE;
- const NOTIFICATION_LIVE_REFRESH_DEBOUNCE = 240;
- const LIGHTBOX_COMMENT_RELATION_CACHE_FRESH_FOR = 10 * MINUTE;
- const HISTORY_PAGE_SIZE = 20;
- const BOOKMARKS_PAGE_SIZE = 20;
- const GIVEN_REACTIONS_PAGE_SIZE = 20;
- const GIVEN_LIKES_PAGE_SIZE = 60;
- const USER_CARD_CACHE_FRESH_FOR = 24 * HOUR;
- const USER_FOLLOW_LIST_CACHE_FRESH_FOR = 5 * MINUTE;
- const USER_FOLLOW_LIST_CACHE_MAX_ENTRIES = 32;
- const NOTIFICATION_AVATAR_CACHE_MAX_ENTRIES = 512;
- const USER_FOLLOW_LIST_PAGE_SIZE = 10;
- const NOTIFICATION_GROUPS = {
- all: { mode: 'notifications', source: 'notifications', label: '全部', icon: 'bell', types: [] },
- replies: { mode: 'notifications', source: 'user-actions', label: '回复', icon: 'reply', types: ['replied', 'quoted'], actionTypes: [6, 9], pageSize: 30 },
- likes: { mode: 'notifications', source: 'user-actions', label: '赞', icon: 'heart', types: ['liked', 'liked_consolidated'], actionTypes: [2], pageSize: 30 },
- mentions: { mode: 'notifications', source: 'user-actions', label: '@提及', icon: 'at', types: ['mentioned', 'group_mentioned'], actionTypes: [7], pageSize: 30 },
- edits: { mode: 'notifications', source: 'user-actions', label: '编辑', icon: 'pencil', types: ['edited'], actionTypes: [11], pageSize: 30 },
- links: { mode: 'notifications', source: 'user-actions', label: '链接', icon: 'link2', types: ['linked', 'linked_consolidated'], actionTypes: [17], pageSize: 30 },
- boosts: { mode: 'notifications', source: 'boosts-received', label: 'Boosts', icon: 'rocket', types: ['boost'], pageSize: 20 },
- reactions: { mode: 'notifications', source: 'reactions-received', label: '回应', icon: 'smile', types: ['reaction'], pageSize: 20 },
- inbox: { mode: 'messages', source: 'private-messages', path: 'private-messages', label: '最新', icon: 'mail', pageSize: 30 },
- sent: { mode: 'messages', source: 'private-messages', path: 'private-messages-sent', label: '已发送', icon: 'reply', pageSize: 30 },
- newMessages: { mode: 'messages', source: 'private-messages', path: 'private-messages-new', label: '新', icon: 'plus', pageSize: 30 },
- unreadMessages: { mode: 'messages', source: 'private-messages', path: 'private-messages-unread', label: '未读', icon: 'bell', pageSize: 30 },
- archive: { mode: 'messages', source: 'private-messages', path: 'private-messages-archive', label: '归档', icon: 'database', pageSize: 30 },
- botMessages: { mode: 'messages', source: 'private-messages', path: 'private-messages-warnings', label: '机器人聊天', icon: 'messageSquare', pageSize: 30 },
- };
- const NOTIFICATION_TYPE_ICONS = Object.freeze({
- mentioned: 'at',
- replied: 'reply',
- quoted: 'messageSquare',
- posted: 'messageSquare',
- group_mentioned: 'at',
- liked: 'heart',
- liked_consolidated: 'heart',
- reaction: 'smile',
- boost: 'rocket',
- following: 'userPlus',
- following_created_topic: 'messageSquare',
- following_replied: 'reply',
- watching_first_post: 'bell',
- watching_category_or_tag: 'bell',
- private_message: 'mail',
- invited_to_private_message: 'userPlus',
- group_message_summary: 'mail',
- chat_mention: 'at',
- chat_message: 'messageSquare',
- chat_invitation: 'userPlus',
- chat_group_mention: 'at',
- chat_quoted: 'messageSquare',
- chat_watched_thread: 'bell',
- edited: 'pencil',
- invitee_accepted: 'userPlus',
- moved_post: 'cornerDownRight',
- linked: 'link2',
- granted_badge: 'award',
- invited_to_topic: 'userPlus',
- custom: 'bell',
- topic_reminder: 'bell',
- post_approved: 'check',
- code_review_commit_approved: 'check',
- membership_request_accepted: 'check',
- membership_request_consolidated: 'userPlus',
- bookmark_reminder: 'bookmark',
- votes_released: 'hand',
- event_reminder: 'bell',
- event_invitation: 'mail',
- assigned: 'userRound',
- question_answer_user_commented: 'messageSquare',
- new_features: 'lightbulb',
- admin_problems: 'alertTriangle',
- linked_consolidated: 'link2',
- upcoming_change_available: 'alertTriangle',
- upcoming_change_automatically_promoted: 'alertTriangle',
- suggested_edit_created: 'pencil',
- suggested_edit_accepted: 'check',
- circles_activity: 'activity',
- });
- const PERSISTENT_CACHE_CONFIG = {
- history: { storageKey: LDP_HISTORY_KEY, maxAge: 365 * DAY },
- topics: { maxAge: 180 * DAY, maxEntries: 8, storedMaxEntries: 64, storedMaxBytes: 64 * 1024 * 1024 },
- topicNavIcons: { storageKey: LDP_TOPIC_NAV_ICON_CACHE_KEY, maxAge: 180 * DAY, maxEntries: 512 },
- users: {
- storageKey: LDP_USER_CARD_CACHE_KEY,
- maxAge: 180 * DAY,
- maxEntries: 128,
- storedMaxEntries: 256,
- storedMaxBytes: 16 * 1024 * 1024,
- },
- notifications: {
- storageKey: LDP_NOTIFICATION_CACHE_KEY,
- maxAge: 180 * DAY,
- maxEntries: 32,
- storedMaxEntries: 64,
- storedMaxBytes: 8 * 1024 * 1024,
- },
- responses: { maxAge: 365 * DAY, storedMaxEntries: 256, storedMaxBytes: 32 * 1024 * 1024 },
- assets: { maxAge: 90 * DAY },
- };
- const CACHE_TYPES = [
- { key: 'history', label: '浏览历史', help: '勾选“浏览历史”后再点下方清理,会删除阅读器保存的主题、最近阅读楼层和查看时间;不会删除浏览器本身的访问历史。' },
- { key: 'topics', label: '帖子与楼层内容', help: '勾选“帖子与楼层内容”后清理,会删除本机保存的帖子信息、楼层正文、回复关系、分类标签图标和相关接口数据;下次打开时会重新联网获取。' },
- { key: 'users', label: '用户资料卡', help: '勾选“用户资料卡”后清理,会删除用户名、简介、徽章、用户组等资料快照和相关接口数据;头像仍归“头像、表情与原图”单独管理。' },
- { key: 'notifications', label: '通知与消息', help: '勾选“通知与消息”后清理,会删除通知分页、已读状态、跳转位置和发送者摘要;头像资源保留,也不会删除站点账号里的真实消息。' },
- { key: 'responses', label: '收藏、回应与其他数据', help: '勾选“收藏、回应与其他数据”后清理,会删除收藏列表、给出的回应与点赞、表情目录和其他通用接口结果;不会撤销站点上的真实收藏或回应。' },
- { key: 'assets', label: '头像、表情与原图', help: '勾选“头像、表情与原图”后清理,会删除头像、Boost 表情和实际查看过的原图资源;不会删除帖子中的线上图片。' },
- ];
- const BOOKMARK_TAB_TYPES = Object.freeze(['Reaction', 'Topic', 'Post']);
- const BOOKMARK_TAB_LABELS = Object.freeze({ Reaction: '回应', Topic: '帖子', Post: '楼层' });
- const HISTORY_EDGE_TRIGGER_MIN = 0;
- const HISTORY_EDGE_TRIGGER_MAX = 15;
- const HISTORY_EDGE_TRIGGER_DEFAULT = 15;
- const READER_QUEUE_PREFETCH_BATCH_SIZE = 20;
- const READER_QUEUE_PREFETCH_MAX_POSTS = 200;
- const READER_QUEUE_PREFETCH_URL_BEFORE_POSTS = 100;
- const READER_QUEUE_PREFETCH_HEAD_POSTS = 40;
- const READER_QUEUE_PREFETCH_BEFORE_POSTS = 80;
- const READER_QUEUE_PREFETCH_MEDIA_CONCURRENCY = 2;
- const READER_QUEUE_PREFETCH_IDLE_DELAY = 900;
- const READER_QUEUE_PREFETCH_INTERACTION_SETTLE = 700;
- const READER_QUEUE_DOCK_THRESHOLD_RATIO = .01;
- const READER_LOADING_ANIMATION_KEYS = Object.freeze([
- 'portal', 'constellation', 'corridor', 'typewave', 'crystal',
- 'marginalia', 'chapters', 'quoteecho', 'footnotes', 'inkverse',
- ]);
- const READER_LOADING_ANIMATION_DEFAULT = 'quoteecho';
- const DEFAULT_PREFS = {
- topicReaderMode: 'fullpage',
- imageProfile: IMAGE_PROFILE_DEFAULT,
- lightboxOriginalByDefault: true,
- lightboxCommentsExpandedByDefault: true,
- lightboxDescriptionExpanded: false,
- lightboxDescriptionHeight: LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT,
- lightboxCommentsWidthPercent: LIGHTBOX_COMMENTS_WIDTH_DEFAULT,
- themeMode: 'system',
- fontRenderingEnabled: true,
- fontRenderingOnHost: true,
- ...HOST_FONT_DEFAULTS,
- hostEmbeddedTitleScale: HOST_EMBED_SIZE_SETTINGS.title.defaultScale,
- hostEmbeddedAvatarScale: HOST_EMBED_SIZE_SETTINGS.avatar.defaultScale,
- hostEmbeddedStatsScale: HOST_EMBED_SIZE_SETTINGS.stats.defaultScale,
- hostEmbeddedLabelCardScale: HOST_EMBED_SIZE_SETTINGS.labelCard.defaultScale,
- fontProfile: READER_FONT_DEFAULT,
- appearanceProfile: APPEARANCE_PROFILE_DEFAULT,
- ...performancePrefsPatch(BALANCED_PERFORMANCE, 'balanced'),
- layoutProfile: READER_LAYOUT_DEFAULT,
- fullpageLayoutProfile: READER_FULLPAGE_LAYOUT_DEFAULT,
- ...normalizeReaderWindowPreferenceGroup({}, 'readerWindow'),
- listReaderMode: 'floating',
- listReaderEmbedWidth: READER_EMBED_DEFAULT_WIDTH,
- ...normalizeWindowGeometryPreferenceGroup({}, COMPOSER_WINDOW_PREFIX),
- historySortMode: 'recent-viewed',
- bookmarkTabOrder: BOOKMARK_TAB_TYPES,
- historyButtonsAlwaysVisible: true,
- readerQueueAlwaysVisibleWhenEmpty: true,
- historyEdgeTriggerPercent: HISTORY_EDGE_TRIGGER_DEFAULT,
- loadingAnimation: READER_LOADING_ANIMATION_DEFAULT,
- translationMode: 'original',
- openTopicsAtFirstPost: false,
- doubleEscapeToCloseReader: false,
- confirmNativeComposerClose: true,
- topicActionRailVisible: true, topicActionRailFixed: false,
- topicActionRailPosition: { x: 'left', y: .95 },
- expandNestedRepliesByDefault: true,
- expandLeafNestedReplies: false,
- aggregateDescendantReplies: true,
- hideNestedReplyFloors: true,
- ...jumpHighlightPrefsPatch(JUMP_HIGHLIGHT_DEFAULTS),
- boostCopyMode: BOOST_COPY_DEFAULTS.mode,
- boostCopyPrefix: BOOST_COPY_DEFAULTS.prefix,
- boostCopyCounterMarker: BOOST_COPY_DEFAULTS.counterMarker,
- boostCopyCounterStep: BOOST_COPY_DEFAULTS.counterStep,
- boostCopyFixedSuffix: BOOST_COPY_DEFAULTS.fixedSuffix,
- };
- const bypassReaderForThisTab = consumeNativeBypassTabSignal() || shouldBypassReaderUrl(location.href);
- if (bypassReaderForThisTab) stripBypassParamFromUrl();
- const INITIAL_TOPIC_ID = (extractTopicRouteFromUrl(location.href) || {}).topicId || null;
- let ME_USERNAME = null;
- let ME_CURRENT_USER = null;
- const DISCOURSE_MODULE_CACHE = new Map();
- const DISCOURSE_LOOKUP_CACHE = new Map();
- let CONNECT_TRUST_CACHE = null;
- let CONNECT_TRUST_REQUEST = null;
- const SHARED_ISSUE_FORBIDDEN_TOPIC_IDS = new Set();
- let NOTIFICATION_UNREAD_COUNT = 0;
- let ACTIVE_READER_REQUEST_SCHEDULER = null;
- const READER_QUEUE_ENTRIES = [];
- const READER_QUEUE_BY_TOPIC = new Map();
- let READER_QUEUE_ACTIVE_TOPIC_ID = '';
- let READER_QUEUE_PREFETCH_RUNNING = false;
- const READER_READ_STATES = new Map();
- let READER_QUEUE_PREFETCH_TIMER = 0;
- let READER_QUEUE_PERSIST_TIMER = 0;
- let READER_QUEUE_RESTORING = false;
- let READER_QUEUE_SURFACE_STATE = { x: null, y: null, dock: '' };
- let SHARED_REQUEST_COORDINATOR = null;
- let READER_PORTAL_HOST = null;
- let READER_PORTAL_ROOT = null;
- let READER_PORTAL_CONTEXT_HTML = null;
- let READER_PORTAL_CONTEXT_BODY = null;
- const READER_HOST_STYLE_CLONES = new Map();
- let READER_HOST_STYLE_OBSERVER = null;
- let READER_HOST_CONTEXT_OBSERVER = null;
- let READER_ICON_TOOLTIP_CLEANUP = null;
- if (INITIAL_TOPIC_ID) PAGE_ROOT.classList.add('ldp-route-takeover');
+// @name Awesome LinuxDo Reader
+// @name:zh-CN 更流畅的 LinuxDo 阅读器
+// @namespace https://github.com/sunbigfly/awesome-linuxdo-reader
+// @version 0.1.16
+// @license MIT
+// @description 为 LINUX DO 深度适配、全面兼容标准 Discourse 站点的沉浸式增强阅读器,支持长帖上下文、原站互动、非中文正文翻译、自定义站点与个性布局。
+// @description:en Deeply adapted for LINUX DO and compatible with standard Discourse sites, with threaded reading, native interactions, body translation, custom sites, and personalized layouts.
+// @author sunbigfly
+// @homepageURL https://github.com/sunbigfly/awesome-linuxdo-reader
+// @supportURL https://github.com/sunbigfly/awesome-linuxdo-reader/issues
+// @match https://linux.do/*
+// @match https://community.brave.com/*
+// @match https://devforum.roblox.com/*
+// @match https://community.openai.com/*
+// @match https://community.home-assistant.io/*
+// @match https://forum.cfx.re/*
+// @match https://community.spiceworks.com/*
+// @match https://forum.arduino.cc/*
+// @match https://discussions.unity.com/*
+// @match https://community.cloudflare.com/*
+// @match https://forums.unrealengine.com/*
+// @match https://forum.obsidian.md/*
+// @match https://forum.cursor.com/*
+// @match https://forum.godotengine.org/*
+// @match https://community.n8n.io/*
+// @match https://forum.mikrotik.com/*
+// @match https://meta.discourse.org/*
+// @match https://discuss.python.org/*
+// @match https://forums.swift.org/*
+// @match https://discourse.julialang.org/*
+// @match https://users.rust-lang.org/*
+// @match https://*/*
+// @icon https://cdn3.ldstatic.com/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_512x512.png
+// @grant GM_getValue
+// @grant GM_setValue
+// @grant GM_xmlhttpRequest
+// @grant GM_getResourceText
+// @grant unsafeWindow
+// @connect connect.linux.do
+// @connect credit.linux.do
+// @connect translate.googleapis.com
+// @connect edge.microsoft.com
+// @connect api-edge.cognitive.microsofttranslator.com
+// @connect *
+// @run-at document-start
+// @resource ldpReaderStyles https://cdn.jsdelivr.net/gh/sunbigfly/awesome-linuxdo-reader@6f32c5f548d440963c1c0abb2160908b609f5aec/work/main.css#sha256=0738f4c1470861e1c7fcf29db5b8cb76f788896740492e2a2fc760c90691ff7e
+// @require https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js
+// @require https://cdn.jsdelivr.net/npm/pinyin-pro@3.18.2/dist/index.js
+// @require https://cdn.jsdelivr.net/npm/hls.js@1.6.16/dist/hls.min.js
+// ==/UserScript==
+
+(function () {
+ 'use strict';
+
+ const CUSTOM_DISCOURSE_SITES_KEY = 'awesome-linuxdo-reader:custom-discourse-sites:v1';
+ const BUILTIN_DISCOURSE_HOSTS = new Set([
+ 'linux.do', 'community.brave.com', 'devforum.roblox.com', 'community.openai.com',
+ 'community.home-assistant.io', 'forum.cfx.re', 'community.spiceworks.com', 'forum.arduino.cc',
+ 'discussions.unity.com', 'community.cloudflare.com', 'forums.unrealengine.com', 'forum.obsidian.md',
+ 'forum.cursor.com', 'forum.godotengine.org', 'community.n8n.io', 'forum.mikrotik.com',
+ 'meta.discourse.org', 'discuss.python.org', 'forums.swift.org', 'discourse.julialang.org',
+ 'users.rust-lang.org',
+ ]);
+ function normalizeCustomDiscourseHost(value) {
+ const source = String(value || '').trim();
+ if (!source) return '';
+ try {
+ const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(source) ? source : `https://${source}`);
+ return url.protocol === 'https:' && !url.username && !url.password ? url.hostname.toLowerCase() : '';
+ } catch { return ''; }
+ }
+ function readCustomDiscourseSites() {
+ try {
+ const stored = globalThis.GM_getValue?.(CUSTOM_DISCOURSE_SITES_KEY, []);
+ return Array.isArray(stored) ? [...new Set(stored.map(normalizeCustomDiscourseHost).filter(Boolean))] : [];
+ } catch { return []; }
+ }
+ const CURRENT_SITE_HOST = location.hostname.toLowerCase();
+ const LDC_USER_BRIDGE_CACHE_KEY = 'awesome-linuxdo-reader:ldc-user-bridge:v1';
+ const cacheCreditUser = (data) => Promise.resolve(globalThis.GM_setValue?.(LDC_USER_BRIDGE_CACHE_KEY, { data, cachedAt: Date.now() })).then(() => data);
+ if (CURRENT_SITE_HOST === 'credit.linux.do') {
+ const syncCreditUser = async () => {
+ try {
+ const response = await fetch('/api/v1/oauth/user-info', { credentials: 'include', headers: { Accept: 'application/json' } });
+ const data = response.ok ? (await response.json())?.data : null;
+ if (data) await cacheCreditUser(data);
+ } catch { /* 保留最后一次成功缓存 */ }
+ };
+ const syncAfterLoad = () => setTimeout(() => void syncCreditUser(), 1000);
+ if (document.readyState === 'complete') syncAfterLoad();
+ else window.addEventListener('load', syncAfterLoad, { once: true });
+ return;
+ }
+ if (!BUILTIN_DISCOURSE_HOSTS.has(CURRENT_SITE_HOST) &&
+ !readCustomDiscourseSites().includes(CURRENT_SITE_HOST)) return;
+ function writeCustomDiscourseSites(sites) {
+ const save = globalThis.GM_setValue;
+ if (typeof save !== 'function') throw new Error('脚本没有全局站点存储权限');
+ return Promise.resolve(save(CUSTOM_DISCOURSE_SITES_KEY, [...new Set(sites)].sort()));
+ }
+ function validateCustomDiscourseSite(host) {
+ const request = globalThis.GM_xmlhttpRequest;
+ if (typeof request !== 'function') return Promise.reject(new Error('脚本没有跨站检测权限'));
+ return new Promise((resolve, reject) => request({
+ method: 'GET', url: `https://${host}/site/basic-info.json`,
+ headers: { Accept: 'application/json' }, responseType: 'json', anonymous: true, timeout: 8000,
+ onload: (response) => {
+ const info = response.response;
+ if (response.status >= 200 && response.status < 300 && info?.title) resolve(info);
+ else reject(new Error('未检测到 Discourse'));
+ },
+ ontimeout: () => reject(new Error('检测超时,请稍后重试')),
+ onerror: () => reject(new Error('站点无法访问')),
+ }));
+ }
+
+ const BASE = location.origin;
+ const PAGE_ROOT = document.documentElement;
+ const makeElement = (tagName) => document.createElement(tagName);
+ const READER_VERSION = '0.1.16';
+ const HOST_PAGE_WINDOW = globalThis.unsafeWindow;
+ const discourseSiteAdapter = (id, name, capabilities = { connect: false }) => Object.freeze({
+ id, name, capabilities: Object.freeze(capabilities),
+ });
+ const GENERIC_DISCOURSE_SITE_ADAPTER = discourseSiteAdapter('discourse', location.hostname);
+ // Built-in hosts carry display/capability overrides; user-added Discourse hosts use the generic adapter.
+ const DISCOURSE_SITE_ADAPTERS = Object.freeze({
+ 'linux.do': discourseSiteAdapter('linux-do', 'LINUX DO', {
+ boosts: true,
+ reactions: true,
+ postVoting: true,
+ topicVoting: true,
+ connect: true,
+ categoryExperts: true,
+ follows: true,
+ sharedIssue: true,
+ }),
+ 'community.openai.com': discourseSiteAdapter('openai-community', 'OpenAI Community'),
+ 'community.brave.com': discourseSiteAdapter('brave-community', 'Brave Community'),
+ 'devforum.roblox.com': discourseSiteAdapter('roblox-developer-forum', 'Roblox Developer Forum'),
+ 'forum.cfx.re': discourseSiteAdapter('cfx-forum', 'Cfx.re Forum'),
+ 'community.spiceworks.com': discourseSiteAdapter('spiceworks-community', 'Spiceworks Community'),
+ 'discussions.unity.com': discourseSiteAdapter('unity-discussions', 'Unity Discussions'),
+ 'community.cloudflare.com': discourseSiteAdapter('cloudflare-community', 'Cloudflare Community'),
+ 'forums.unrealengine.com': discourseSiteAdapter('epic-developer-community', 'Epic Developer Community'),
+ 'forum.obsidian.md': discourseSiteAdapter('obsidian-forum', 'Obsidian Forum'),
+ 'forum.cursor.com': discourseSiteAdapter('cursor-community', 'Cursor Community'),
+ 'forum.godotengine.org': discourseSiteAdapter('godot-forum', 'Godot Forum'),
+ 'community.n8n.io': discourseSiteAdapter('n8n-community', 'n8n Community'),
+ 'forum.mikrotik.com': discourseSiteAdapter('mikrotik-forum', 'MikroTik Forum'),
+ 'meta.discourse.org': discourseSiteAdapter('discourse-meta', 'Discourse Meta'),
+ 'discuss.python.org': discourseSiteAdapter('python-discussions', 'Python Discussions'),
+ 'forums.swift.org': discourseSiteAdapter('swift-forums', 'Swift Forums'),
+ 'discourse.julialang.org': discourseSiteAdapter('julia-discourse', 'Julia Discourse'),
+ 'community.home-assistant.io': discourseSiteAdapter('home-assistant-community', 'Home Assistant Community'),
+ 'forum.arduino.cc': discourseSiteAdapter('arduino-forum', 'Arduino Forum'),
+ 'users.rust-lang.org': discourseSiteAdapter('rust-users', 'Rust Users Forum'),
+ });
+ const SITE_ADAPTER = DISCOURSE_SITE_ADAPTERS[location.hostname.toLowerCase()] ||
+ GENERIC_DISCOURSE_SITE_ADAPTER;
+ const SITE_DISPLAY_NAME = SITE_ADAPTER.name || location.hostname;
+ const SITE_LANGUAGE = SITE_ADAPTER === GENERIC_DISCOURSE_SITE_ADAPTER
+ ? '' : SITE_ADAPTER.id === 'linux-do' ? 'zh-CN' : 'en';
+ function siteAllowsBodyTranslation() {
+ return !/^zh(?:[-_]|$)/i.test(SITE_LANGUAGE);
+ }
+ const TOPIC_CACHE_TTL = 90 * 1000;
+ const READ_THRESHOLD = 1500;
+ const FLUSH_INTERVAL = 5000;
+ const READER_READ_BATCH_SIZE = 20;
+ const JUMP_HIGHLIGHT_DEFAULTS = Object.freeze({
+ color: '#0888cc',
+ radius: 10,
+ borderWidth: 1,
+ rate: 0.8,
+ count: 2,
+ });
+ const JUMP_HIGHLIGHT_LIMITS = Object.freeze({
+ radius: Object.freeze({ min: 0, max: 24, step: 1, integer: true }),
+ borderWidth: Object.freeze({ min: 0, max: 4, step: 1, integer: true }),
+ rate: Object.freeze({ min: 0.5, max: 2, step: 0.1, integer: false }),
+ count: Object.freeze({ min: 1, max: 6, step: 1, integer: true }),
+ });
+ const JUMP_HIGHLIGHT_SETTING_FIELDS = Object.freeze([
+ { key: 'color', pref: 'jumpHighlightColor', label: '提示颜色', type: 'color', format: String,
+ help: '选择跳转目标楼层的闪烁颜色;提示使用半透明底色避免遮住正文,并用同色细轮廓准确呈现所选颜色。选择时实时预览,统一保存。' },
+ { key: 'radius', pref: 'jumpHighlightRadius', label: '提示圆角', ariaLabel: '跳转提示圆角', format: (value) => `${value}px`,
+ help: `控制闪烁背景的圆角,可在 ${JUMP_HIGHLIGHT_LIMITS.radius.min}–${JUMP_HIGHLIGHT_LIMITS.radius.max}px 之间调整。拖动时实时预览,统一保存。` },
+ { key: 'borderWidth', pref: 'jumpHighlightBorderWidth', label: '提示轮廓宽度', ariaLabel: '跳转提示轮廓宽度', format: (value) => `${value}px`,
+ help: `控制闪烁轮廓的宽度,可在 ${JUMP_HIGHLIGHT_LIMITS.borderWidth.min}–${JUMP_HIGHLIGHT_LIMITS.borderWidth.max}px 之间调整;设为 0px 可关闭边框,颜色跟随闪烁颜色。拖动时实时预览,统一保存。` },
+ { key: 'rate', pref: 'jumpHighlightRate', label: '闪烁速度', ariaLabel: '跳转提示闪烁速度', format: (value) => `${value.toFixed(1)} 次/秒`,
+ help: `控制每秒闪烁次数,可在 ${JUMP_HIGHLIGHT_LIMITS.rate.min}–${JUMP_HIGHLIGHT_LIMITS.rate.max} 次/秒之间调整;数值越大闪得越快。拖动时实时预览,统一保存。` },
+ { key: 'count', pref: 'jumpHighlightCount', label: '闪烁次数', ariaLabel: '跳转提示闪烁次数', format: (value) => `${value} 次`,
+ help: `控制一次跳转连续闪烁多少次,可在 ${JUMP_HIGHLIGHT_LIMITS.count.min}–${JUMP_HIGHLIGHT_LIMITS.count.max} 次之间调整。拖动时实时预览,统一保存。` },
+ ]);
+ const SITE_LOGO_FALLBACK_URL = `${BASE}/favicon.ico`;
+ const SITE_LOGO_PLACEHOLDER_URL = `data:image/svg+xml,${encodeURIComponent(
+ '
'
+ )}`;
+ let SITE_LOGO_URL = SITE_LOGO_FALLBACK_URL;
+ let SITE_LOGO_CANDIDATES = [SITE_LOGO_FALLBACK_URL, SITE_LOGO_PLACEHOLDER_URL];
+
+ function normalizeSiteLogoUrl(value) {
+ const source = String(value || '').trim();
+ if (!source) return '';
+ if (/^data:image\//i.test(source)) return source;
+ try {
+ const url = new URL(source, BASE);
+ return /^https?:$/i.test(url.protocol) ? url.href : '';
+ } catch {
+ return '';
+ }
+ }
+
+ function syncSiteLogoImages() {
+ const candidates = SITE_LOGO_CANDIDATES.slice();
+ readerPortalQueryAll('[data-ldp-site-logo]').forEach((image) => {
+ image._ldpSiteLogoCandidates = candidates;
+ image._ldpSiteLogoIndex = 0;
+ image.onerror = () => {
+ const nextIndex = Number(image._ldpSiteLogoIndex || 0) + 1;
+ image._ldpSiteLogoIndex = nextIndex;
+ const next = image._ldpSiteLogoCandidates?.[nextIndex];
+ if (next) {
+ image.src = next;
+ } else {
+ image.onerror = null;
+ }
+ };
+ if (image.src !== SITE_LOGO_URL) {
+ image.src = SITE_LOGO_URL;
+ } else if (image.complete && image.naturalWidth === 0) {
+ image.onerror();
+ }
+ });
+ }
+
+ function applySiteLogoCandidates(values) {
+ const candidates = [...new Set((values || []).map(normalizeSiteLogoUrl).filter(Boolean))];
+ if (!candidates.length) return;
+ SITE_LOGO_CANDIDATES = [
+ ...candidates,
+ ...SITE_LOGO_CANDIDATES.filter((candidate) => !candidates.includes(candidate)),
+ ];
+ SITE_LOGO_URL = SITE_LOGO_CANDIDATES[0];
+ syncSiteLogoImages();
+ }
+
+ function siteLogoCandidatesFromHost() {
+ const siteSettings = lookupDiscourse('service:site-settings');
+ const hostLogo = document.querySelector(
+ '.d-header #site-logo,.d-header img.logo-big,.d-header img.logo-small,.custom-logo-link img'
+ );
+ const touchIcon = document.querySelector('link[rel~="apple-touch-icon"]');
+ const pageIcon = document.querySelector('link[rel~="icon"]');
+ return [
+ siteSettings?.large_icon,
+ siteSettings?.apple_touch_icon,
+ siteSettings?.favicon,
+ siteSettings?.logo_small,
+ siteSettings?.mobile_logo,
+ siteSettings?.logo,
+ hostLogo?.currentSrc || hostLogo?.src,
+ touchIcon?.href,
+ pageIcon?.href,
+ ];
+ }
+
+ async function resolveSiteLogo() {
+ applySiteLogoCandidates(siteLogoCandidatesFromHost());
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 5000);
+ try {
+ const response = await fetch(`${BASE}/site/basic-info.json`, {
+ credentials: 'same-origin',
+ headers: { Accept: 'application/json' },
+ cache: 'force-cache',
+ signal: controller.signal,
+ });
+ if (!response.ok) return;
+ const info = await response.json();
+ applySiteLogoCandidates([
+ info.large_icon_url,
+ info.apple_touch_icon_url,
+ info.favicon_url,
+ info.logo_small_url,
+ info.mobile_logo_url,
+ info.logo_url,
+ ]);
+ } catch (error) {
+ if (error?.name !== 'AbortError') {
+ console.warn('[LDP] site logo discovery failed', error);
+ }
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+ const READER_MANUAL_URL = 'https://sunbigfly.github.io/awesome-linuxdo-reader/';
+ const BOOST_MENTION_PROJECT_URL = 'https://greasyfork.org/zh-CN/scripts/580986-linux-do-boost-%E5%A2%9E%E5%BC%BA';
+ const EXTERNAL_FONT_RENDERING_MARKER = 'fr-init-once';
+ const FONT_RENDERING_PROJECT_URL = 'https://github.com/F9y4ng/GreasyFork-Scripts/';
+ const FONT_RENDERING_LICENSE_URL = 'https://github.com/F9y4ng/GreasyFork-Scripts/blob/master/LICENSE';
+ const READER_FONT_RENDERING_DEFAULTS = (() => {
+ const userAgent = navigator.userAgent || '';
+ const isGecko = /Firefox\//.test(userAgent);
+ const isWebKit = /AppleWebKit\//.test(userAgent)
+ && !/(?:Chrome|Chromium|Edg|OPR|CriOS|FxiOS)\//.test(userAgent);
+ return Object.freeze({
+ stroke: isGecko ? 0.03 : isWebKit ? 0.05 : 0.015,
+ shadow: isGecko ? 0.55 : isWebKit ? 0.45 : 0.75,
+ macSmoothing: /Mac/.test(navigator.platform || ''),
+ });
+ })();
+ function applyReaderFontRenderingRuntimeProperties() {
+ PAGE_ROOT.style.setProperty('--ldp-font-rendering-stroke-runtime', `${READER_FONT_RENDERING_DEFAULTS.stroke}px currentcolor`);
+ PAGE_ROOT.style.setProperty('--ldp-font-rendering-shadow-runtime', `0 0 ${READER_FONT_RENDERING_DEFAULTS.shadow}px #7c7c7cdd`);
+ PAGE_ROOT.toggleAttribute('data-ldp-font-mac-smoothing', READER_FONT_RENDERING_DEFAULTS.macSmoothing);
+ }
+ const LDP_PREF_KEY = 'linuxdo-enhanced-reader:prefs';
+ const LDP_CONFIG_EXPORT_FORMAT = 'awesome-linuxdo-reader-settings';
+ const LDP_CONFIG_EXPORT_VERSION = 5;
+ const LDP_HISTORY_KEY = 'linuxdo-enhanced-reader:history';
+ const LDP_READER_QUEUE_KEY = 'linuxdo-enhanced-reader:reader-queue:v1';
+ const LDP_REPLY_BRANCH_STATE_KEY = 'linuxdo-enhanced-reader:reply-branches:v1';
+ const LDP_REPLY_WINDOW_STATE_KEY = 'linuxdo-enhanced-reader:reply-window:v1';
+ const LDP_TOPIC_CACHE_KEY = 'linuxdo-enhanced-reader:cache:topics';
+ const LDP_TOPIC_NAV_ICON_CACHE_KEY = 'linuxdo-enhanced-reader:cache:topic-nav-icons';
+ const LDP_USER_CARD_CACHE_KEY = 'linuxdo-enhanced-reader:cache:users';
+ const LDP_NOTIFICATION_CACHE_KEY = 'linuxdo-enhanced-reader:cache:notifications';
+ const LDP_SHARED_REQUEST_STATE_KEY = 'linuxdo-enhanced-reader:shared-request-state:v1';
+ const LDP_SHARED_REQUEST_CHANNEL = 'linuxdo-enhanced-reader:shared-request-flow:v1';
+ const LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY = 'linuxdo-enhanced-reader:cloudflare-challenge:v1';
+ const LDP_CLOUDFLARE_CHALLENGE_LEASE_TTL = 15 * 1000;
+ const LDP_READER_READ_SUCCESS_KEY = 'linuxdo-enhanced-reader:read-success:v1';
+ const LDP_READER_READ_LOCK_NAME = 'linuxdo-enhanced-reader:read-request:v1';
+ const READER_READ_SUCCESS_TTL = 60 * 1000;
+ const LDP_EMBEDDED_RELOAD_STATE_KEY = 'linuxdo-enhanced-reader:embedded-reload-state:v1';
+ const LDP_EMBEDDED_RELOAD_STATE_TTL = 30 * 1000;
+ const LDP_AVATAR_CACHE_NAME = 'linuxdo-enhanced-reader:avatars:v1';
+ const LDP_EMOJI_IMAGE_CACHE_NAME = 'linuxdo-enhanced-reader:emoji-images:v1';
+ const LDP_LIGHTBOX_IMAGE_CACHE_NAME = 'linuxdo-enhanced-reader:lightbox-images:v1';
+ const LDP_RESPONSE_CACHE_DB_NAME = 'linuxdo-enhanced-reader:responses:v1';
+ const LDP_RESPONSE_CACHE_STORE = 'responses';
+ const PERSISTENT_CACHE_VERSION = 1;
+ const TOPIC_SNAPSHOT_VERSION = 1;
+ const TOPIC_SNAPSHOT_WRITE_DELAY = 750;
+ const TOPIC_SNAPSHOT_WRITE_MAX_DELAY = 3000;
+ const MINUTE = 60 * 1000;
+ const HOUR = 60 * MINUTE;
+ const DAY = 24 * HOUR;
+ const LDP_AVATAR_CACHE_MAX_AGE = 90 * DAY;
+ const LDP_EMOJI_IMAGE_CACHE_MAX_AGE = 90 * DAY;
+ const LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE = 7 * DAY;
+ const LDP_SCROLL_LOCK_CLASS = 'ldp-scroll-lock';
+ const NATIVE_BYPASS_PARAM = 'ldp_native';
+ const NATIVE_BYPASS_TAB_KEY = 'linuxdo-enhanced-reader:native-tab';
+
+ const KATEX_VERSION = '0.16.22';
+ const IMAGE_SCALE_OPTIONS = [25, 50, 100, 125, 150, 200];
+ const LIGHTBOX_DESCRIPTION_HEIGHT_MIN = 56;
+ const LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT = 120;
+ const LIGHTBOX_COMMENTS_WIDTH_DEFAULT = 25;
+ const LIGHTBOX_COMMENTS_WIDTH_MIN = 18;
+ const LIGHTBOX_COMMENTS_WIDTH_MAX = 50;
+ const IMAGE_SCALE_MIN = 50;
+ const IMAGE_SCALE_MAX = 200;
+ const FONT_SCALE_MIN = 50;
+ const FONT_SCALE_MAX = 250;
+ const FONT_SCALE_DEFAULT = 100;
+ const COMPOSER_FONT_SCALE_DEFAULT = 80;
+ const READER_THEME_MODES = Object.freeze(['light', 'dark', 'system']);
+ const READER_THEME_LABELS = Object.freeze({ light: '明', dark: '暗', system: '系统' });
+ const READER_THEME_VALUE_ROWS = [
+ ['--secondary', '#ffffff', '#171b20'],
+ ['--primary', '#20252b', '#e7edf3'],
+ ['--primary-high', '#333a42', '#cbd4dc'],
+ ['--primary-medium', '#68737e', '#98a4af'],
+ ['--primary-low-mid', '#b8c0c8', '#4a5560'],
+ ['--primary-low', '#d7dde3', '#39414a'],
+ ['--primary-very-low', '#f2f4f6', '#242a31'],
+ ['--secondary-low', '#4b545d', '#363c43'],
+ ['--secondary-medium', '#89929c', '#7f8790'],
+ ['--secondary-high', '#dfe3e6', '#c9d0d8'],
+ ['--secondary-very-high', '#f7f8f9', '#e3e8ed'],
+ ['--tertiary', '#47855f', '#78c295'],
+ ['--tertiary-low', '#dceee2', '#223a2c'],
+ ['--header_background', '#ffffff', '#171b20'],
+ ['--header_primary', '#20252b', '#e7edf3'],
+ ['--header_primary-low', '#e7eaed', '#2a3037'],
+ ['--header_primary-low-mid', '#b7bec6', '#4a5560'],
+ ['--header_primary-medium', '#68737e', '#98a4af'],
+ ['--header_primary-high', '#333a42', '#cbd4dc'],
+ ['--highlight', '#fff0a8', '#6b5a16'],
+ ['--highlight-low', '#fff8d8', '#302b18'],
+ ['--highlight-medium', '#ffe47a', '#8a7420'],
+ ['--highlight-high', '#806200', '#ffe47a'],
+ ['--quaternary', '#d86645', '#f08a67'],
+ ['--danger', '#c84545', '#ff7b72'],
+ ['--danger-low', '#f7dddd', '#44262a'],
+ ['--danger-low-mid', '#efb5b5', '#6b353b'],
+ ['--success', '#47855f', '#78c295'],
+ ['--success-low', '#dceee2', '#223a2c'],
+ ['--love', '#dc6542', '#ff8a65'],
+ ['--love-low', '#f7ddd5', '#472b25'],
+ ['--d-link-color', '#2870b8', '#71b7ff'],
+ ['--d-post-aside-background', '#f7f9fa', '#20262d'],
+ ['--ldp-zebra-color', '#f7f7f7', '#1b2b21'],
+ ['--ldp-reply-line-color', '#47855f', '#78c295'],
+ ['--ldp-quote-line-color', '#d7d7d7', '#46505a'],
+ ['--ldp-divider-line-color', '#e5e5e5', '#343b44'],
+ ['--ldp-request-metric-pine', '#315f47', '#86b49a'],
+ ['--ldp-request-metric-navy', '#365673', '#8eacc8'],
+ ['--ldp-request-metric-plum', '#5e4b72', '#b09bc4'],
+ ['--ldp-request-metric-bronze', '#72551f', '#c5a36d'],
+ ['--ldp-request-metric-wine', '#743f47', '#c7929a'],
+ ];
+ const READER_THEME_VARIABLES = Object.freeze(Object.fromEntries(
+ ['light', 'dark'].map((mode, valueIndex) => [mode, Object.freeze(Object.fromEntries(
+ READER_THEME_VALUE_ROWS.map(([name, ...values]) => [name, values[valueIndex]]),
+ ))]),
+ ));
+ const ACCENT_COLOR_DEFAULT = READER_THEME_VARIABLES.light['--tertiary'];
+ const LINK_COLOR_DEFAULT = READER_THEME_VARIABLES.light['--d-link-color'];
+ const ZEBRA_COLOR_DEFAULT = '#f7f7f7';
+ const LIST_ZEBRA_COLOR_DEFAULT = '#f7f7f7';
+ const ZEBRA_RADIUS_MIN = 0;
+ const ZEBRA_RADIUS_MAX = 16;
+ const ZEBRA_RADIUS_DEFAULT = 10;
+ const ZEBRA_RADIUS_STEP = 1;
+ const REPLY_LINE_COLOR_DEFAULT = '#6dab85';
+ const LINE_WIDTH_MIN = 0.5;
+ const LINE_WIDTH_MAX = 4;
+ const LINE_WIDTH_STEP = 0.5;
+ const REPLY_LINE_WIDTH_DEFAULT = 1;
+ const REPLY_LINE_RADIUS_MIN = 0;
+ const REPLY_LINE_RADIUS_MAX = 16;
+ const REPLY_LINE_RADIUS_DEFAULT = 15;
+ const REPLY_LINE_RADIUS_STEP = 1;
+ const QUOTE_LINE_COLOR_DEFAULT = '#d7d7d7';
+ const QUOTE_LINE_WIDTH_DEFAULT = 0.5;
+ const DIVIDER_LINE_COLOR_DEFAULT = '#e5e5e5';
+ const DIVIDER_LINE_WIDTH_DEFAULT = 0.5;
+ const FLOOR_PREVIEW_COLOR_DEFAULT = '#dba7a7';
+ const FLOOR_PREVIEW_COLOR_DARK_DEFAULT = '#f87171';
+ const FLOOR_PREVIEW_HEIGHT_MIN = 180;
+ const FLOOR_PREVIEW_HEIGHT_MAX = 720;
+ const FLOOR_PREVIEW_HEIGHT_DEFAULT = 300;
+ const FLOOR_PREVIEW_HEIGHT_STEP = 10;
+ const PERFORMANCE_SETTING_GROUPS = [
+ {
+ id: 'main-request', title: '楼层数据加载',
+ description: '设置每次从服务器加载多少楼层;只影响单批数据量,不等于页面同时显示的楼层数。',
+ fields: [
+ { key: 'pageSize', pref: 'performancePageSize', presets: [16, 40, 48], limits: { min: 12, max: 64, step: 1, integer: true }, title: '每次加载楼层数', unit: '个', inputMode: 'numeric',
+ description: '接近已加载内容边缘时,一次再取多少楼层;数值越大,等待次数越少,但单次下载和处理更重。',
+ help: '阅读接近已加载内容边缘时,一次向服务器请求多少个楼层。它只决定单批响应大小,不改变页面同时保留的楼层数量。' },
+ ],
+ },
+ {
+ id: 'dom', title: '页面楼层保留',
+ description: '控制当前页面前后保留多少楼层;远处内容会卸载以节省内存,不会因此发起网络请求。',
+ fields: [
+ { key: 'streamOverscanViewports', pref: 'performanceStreamOverscan', presets: [0.35, 1.25, 2], limits: { min: 0.25, max: 3, step: 0.05 }, title: '屏幕外预留范围', unit: '屏', inputMode: 'decimal',
+ description: '在当前可见区域前后额外保留多少屏内容;数值越大,快速滚动越不容易看到空白,但更占内存。',
+ help: '在当前屏幕前后额外保留多少屏楼层元素。它只影响页面渲染和内存,不会发起楼层请求,并受“同时保留楼层上限”约束。' },
+ { key: 'streamMaxItems', pref: 'performanceStreamMaxItems', presets: [28, 72, 96], limits: { min: 24, max: 128, step: 1, integer: true }, title: '同时保留楼层上限', unit: '个', inputMode: 'numeric',
+ description: '页面同时保留的楼层数量上限;离当前阅读位置较远的楼层会卸载,滚回时再恢复。',
+ help: '首次进入、滚动和跳转期间,页面最多同时保留多少个主楼层元素。远处楼层会从页面结构中卸载,并用等高占位保持滚动位置。' },
+ ],
+ },
+ {
+ id: 'nested', title: '二级回复预加载',
+ description: '父楼层接近屏幕时,自动加载它下面的直接回复。',
+ fields: [
+ { key: 'nestedPrefetchViewports', pref: 'performanceNestedPrefetch', presets: [0.35, 1.25, 2], limits: { min: 0, max: 3, step: 0.05 }, title: '提前加载距离', unit: '屏', inputMode: 'decimal',
+ description: '0 表示父楼层进入屏幕后再加载;大于 0 表示提前对应屏数加载。每个父楼层首批最多加载 20 条直接回复。',
+ help: '父楼层距离当前屏幕多远时开始加载直接回复。0 表示进入屏幕后加载;数值越大越早加载,也会更早产生网络请求。' },
+ ],
+ },
+ {
+ id: 'request', title: '网络请求节奏',
+ description: '帖子、楼层、二级回复、消息和用户资料共用这些限制;遇到原站操作或限流时会自动放慢。',
+ fields: [
+ { key: 'requestMaxConcurrent', pref: 'performanceRequestConcurrency', presets: [1, 3, 4], limits: { min: 1, max: 4, step: 1, integer: true }, title: '同时请求上限', unit: '路', inputMode: 'numeric',
+ description: '最多允许多少个阅读器请求同时进行;实际数量可能因多个页面、原站操作或限流而更低。',
+ help: '阅读器网络请求同时进行的最高数量。多个脚本实例、原站操作、服务器限流和恢复状态只会继续降低实际数量。' },
+ { key: 'requestMinInterval', pref: 'performanceRequestInterval', presets: [220, 90, 80], limits: { min: 80, max: 500, step: 10, integer: true }, title: '请求最短间隔', unit: 'ms', inputMode: 'numeric',
+ description: '正常情况下,两次请求开始之间至少间隔多久;原站繁忙或出现 429 时会自动延长。',
+ help: '正常状态下两次阅读器请求开始的最短间隔。自适应调度可以延长它,且不会在恢复后一次性补发积压请求。' },
+ { key: 'requestRateTarget', pref: 'performanceRequestRateTarget', presets: [80, 80, 80], limits: { min: 50, max: 95, step: 1, integer: true }, title: '限流额度使用比例', unit: '%', inputMode: 'numeric',
+ description: '只使用服务器已探测请求额度的一部分,为原站操作保留余量;比例越低越保守。',
+ help: '阅读器计划使用服务器 10 秒与 60 秒请求额度的比例。它只控制请求启动节奏,不改变每次加载楼层数或二级回复数量。' },
+ ],
+ },
+ ];
+ const PERFORMANCE_SETTING_FIELDS = PERFORMANCE_SETTING_GROUPS.flatMap((group) => group.fields);
+ const PERFORMANCE_PRESETS = Object.freeze(Object.fromEntries(
+ ['low', 'balanced', 'high'].map((name, valueIndex) => [name, Object.freeze(Object.fromEntries(
+ PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, field.presets[valueIndex]]),
+ ))]),
+ ));
+ const PERFORMANCE_LIMITS = Object.freeze(Object.fromEntries(
+ PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, Object.freeze(field.limits)]),
+ ));
+ const PERFORMANCE_PREF_KEYS = Object.freeze(Object.fromEntries(
+ PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, field.pref]),
+ ));
+ const PERFORMANCE_SETTING_KEYS = Object.freeze(Object.keys(PERFORMANCE_PREF_KEYS));
+ const LAYOUT_REGION_KEYS = Object.freeze(['left', 'main', 'gap', 'timeline', 'right']);
+ const LAYOUT_REGION_LABELS = Object.freeze({
+ left: '左侧留白',
+ main: '正文区域',
+ gap: '正文与时间轴间距',
+ timeline: '楼层时间轴',
+ right: '右侧留白',
+ });
+ const COMPOSER_WINDOW_PREFIX = 'composerWindow';
+ const READER_LAYOUT_DEFAULT = Object.freeze({ left: 0, main: 88, gap: 0, timeline: 8, right: 4 });
+ const READER_FULLPAGE_LAYOUT_DEFAULT = Object.freeze({ left: 15, main: 70, gap: 5, timeline: 8, right: 2 });
+ const FONT_FAMILY_DEFAULT = 'system';
+ const FONT_FAMILY_OPTIONS = Object.freeze({
+ site: Object.freeze({ label: '跟随原站', stack: 'inherit' }),
+ system: Object.freeze({ label: '系统默认字体', stack: 'system-ui,sans-serif' }),
+ cjkSans: Object.freeze({ label: '中文无衬线', stack: '"Noto Sans CJK SC","Microsoft YaHei","PingFang SC",system-ui,sans-serif' }),
+ serif: Object.freeze({ label: '衬线', stack: '"Noto Serif CJK SC","Songti SC",SimSun,serif' }),
+ monospace: Object.freeze({ label: '等宽', stack: 'ui-monospace,SFMono-Regular,Consolas,monospace' }),
+ custom: Object.freeze({ label: '自定义本机字体', stack: '' }),
+ });
+ const FONT_WEIGHT_DEFAULT = 400;
+ const FONT_WEIGHT_OPTIONS = Object.freeze([300, 400, 500, 600]);
+ const FONT_WEIGHT_LABELS = Object.freeze({ 300: '细', 400: '常规', 500: '中等', 600: '半粗' });
+ const HOST_EMBED_SIZE_MIN = 50;
+ const HOST_EMBED_SIZE_MAX = 200;
+ const HOST_EMBED_SIZE_DEFAULT = 100;
+ const HOST_EMBED_SIZE_SETTINGS = Object.freeze({
+ title: Object.freeze({ pref: 'hostEmbeddedTitleScale', label: '主题标题', defaultScale: 110, properties: [['--ldp-host-topic-title-size', 15]] }),
+ avatar: Object.freeze({ pref: 'hostEmbeddedAvatarScale', label: '头像', defaultScale: 80, properties: [['--ldp-host-topic-avatar-size', 32], ['--ldp-host-topic-avatar-size-medium', 24], ['--ldp-host-topic-avatar-size-small', 20]] }),
+ stats: Object.freeze({ pref: 'hostEmbeddedStatsScale', label: '主题统计信息', defaultScale: 120, properties: [['--ldp-host-topic-stats-size', 10], ['--ldp-host-topic-stats-label-size', 9], ['--ldp-host-topic-stats-row-offset', -4]] }),
+ labelCard: Object.freeze({ pref: 'hostEmbeddedLabelCardScale', label: '标签卡片', defaultScale: 100, properties: [['--ldp-host-label-card-height', 22], ['--ldp-host-label-card-font-size', 11], ['--ldp-host-label-card-icon-size', 14], ['--ldp-host-label-card-gap', 3], ['--ldp-host-label-card-padding', 7]] }),
+ });
+ const FONT_SCOPE_KEYS = Object.freeze(['interface', 'post', 'composer']);
+ const FONT_SCOPE_CONFIG = Object.freeze({
+ interface: Object.freeze({ label: '界面文字', family: 'family', customFamily: 'customFamily', weight: 'weight', color: 'interfaceColor', scale: 'interface' }),
+ post: Object.freeze({ label: '帖子正文', family: 'postFamily', customFamily: 'postCustomFamily', weight: 'postWeight', color: 'postColor', scale: 'post' }),
+ composer: Object.freeze({ label: '回复输入框', family: 'composerFamily', customFamily: 'composerCustomFamily', weight: 'composerWeight', color: 'composerColor', scale: 'composer' }),
+ });
+ const HOST_FONT_CONFIG = Object.freeze({
+ label: '嵌入阅读时的原站主题列表', family: 'hostFontFamily', customFamily: 'hostFontCustomFamily', weight: 'hostFontWeight', color: 'hostFontColor',
+ });
+ const HOST_FONT_DEFAULTS = Object.freeze({
+ hostFontFamily: FONT_FAMILY_DEFAULT,
+ hostFontCustomFamily: '',
+ hostFontWeight: FONT_WEIGHT_DEFAULT,
+ hostFontColor: '',
+ });
+ const FONT_PICKER_SCOPE_KEYS = Object.freeze([...FONT_SCOPE_KEYS, 'host']);
+ const FONT_PROFILE_VALUE_KEYS = Object.freeze([
+ 'family', 'customFamily', 'weight', 'interfaceColor', 'interface',
+ 'postFamily', 'postCustomFamily', 'postWeight', 'postColor', 'post',
+ 'composerFamily', 'composerCustomFamily', 'composerWeight', 'composerColor', 'composer',
+ ]);
+ const READER_FONT_DEFAULT = Object.freeze({
+ family: FONT_FAMILY_DEFAULT, customFamily: '', weight: FONT_WEIGHT_DEFAULT, interfaceColor: '', interface: 92,
+ postFamily: FONT_FAMILY_DEFAULT, postCustomFamily: '', postWeight: FONT_WEIGHT_DEFAULT, postColor: '', post: 95,
+ composerFamily: FONT_FAMILY_DEFAULT, composerCustomFamily: '', composerWeight: FONT_WEIGHT_DEFAULT,
+ composerColor: '', composer: COMPOSER_FONT_SCALE_DEFAULT,
+ });
+ const IMAGE_PROFILE_DEFAULT = Object.freeze({ preset: '50', custom: 50 });
+ const APPEARANCE_COLOR_KEYS = Object.freeze([
+ 'accentColor', 'linkColor', 'zebraColor', 'listZebraColor',
+ 'replyLineColor', 'quoteLineColor', 'dividerLineColor', 'floorPreviewColor',
+ ]);
+ const APPEARANCE_COLOR_THEME_LIMITS = Object.freeze({
+ accentColor: Object.freeze({ saturationMax: 82, light: [30, 55], dark: [58, 78] }),
+ linkColor: Object.freeze({ saturationMax: 90, light: [30, 55], dark: [60, 80] }),
+ zebraColor: Object.freeze({ saturationMax: 70, light: [92, 100], dark: [10, 20] }),
+ listZebraColor: Object.freeze({ saturationMax: 38, light: [88, 98], dark: [10, 20] }),
+ replyLineColor: Object.freeze({ saturationMax: 76, light: [30, 58], dark: [55, 78] }),
+ quoteLineColor: Object.freeze({ saturationMax: 50, light: [72, 92], dark: [22, 42] }),
+ dividerLineColor: Object.freeze({ saturationMax: 40, light: [80, 94], dark: [18, 34] }),
+ floorPreviewColor: Object.freeze({ saturationMax: 88, light: [35, 58], dark: [58, 78] }),
+ });
+ const APPEARANCE_PROFILE_SETTING_KEYS = Object.freeze([
+ 'accentColor', 'linkColor', 'zebraColor', 'zebraRadius', 'listZebraColor',
+ 'structureColorsEnabled', 'replyLineColor', 'replyLineWidth', 'replyLineRadius',
+ 'quoteLineColor', 'quoteLineWidth', 'dividerLineColor', 'dividerLineWidth',
+ 'floorPreviewColor', 'floorPreviewHeight',
+ ]);
+ const APPEARANCE_PROFILE_DEFAULT = Object.freeze({
+ accentColor: ACCENT_COLOR_DEFAULT,
+ accentColorDark: READER_THEME_VARIABLES.dark['--tertiary'],
+ linkColor: LINK_COLOR_DEFAULT,
+ linkColorDark: READER_THEME_VARIABLES.dark['--d-link-color'],
+ zebraColor: ZEBRA_COLOR_DEFAULT,
+ zebraColorDark: READER_THEME_VARIABLES.dark['--ldp-zebra-color'],
+ zebraRadius: ZEBRA_RADIUS_DEFAULT,
+ listZebraColor: LIST_ZEBRA_COLOR_DEFAULT,
+ listZebraColorDark: READER_THEME_VARIABLES.dark['--primary-very-low'],
+ structureColorsEnabled: true,
+ replyLineColor: REPLY_LINE_COLOR_DEFAULT,
+ replyLineColorDark: READER_THEME_VARIABLES.dark['--ldp-reply-line-color'],
+ replyLineWidth: REPLY_LINE_WIDTH_DEFAULT,
+ replyLineRadius: REPLY_LINE_RADIUS_DEFAULT,
+ quoteLineColor: QUOTE_LINE_COLOR_DEFAULT,
+ quoteLineColorDark: READER_THEME_VARIABLES.dark['--ldp-quote-line-color'],
+ quoteLineWidth: QUOTE_LINE_WIDTH_DEFAULT,
+ dividerLineColor: DIVIDER_LINE_COLOR_DEFAULT,
+ dividerLineColorDark: READER_THEME_VARIABLES.dark['--ldp-divider-line-color'],
+ dividerLineWidth: DIVIDER_LINE_WIDTH_DEFAULT,
+ floorPreviewColor: FLOOR_PREVIEW_COLOR_DEFAULT,
+ floorPreviewColorDark: FLOOR_PREVIEW_COLOR_DARK_DEFAULT,
+ floorPreviewHeight: FLOOR_PREVIEW_HEIGHT_DEFAULT,
+ });
+ const APPEARANCE_PROFILE_VALUE_KEYS = Object.freeze(APPEARANCE_PROFILE_SETTING_KEYS.flatMap((key) =>
+ APPEARANCE_COLOR_KEYS.includes(key) ? [key, `${key}Dark`] : [key]
+ ));
+ const INTERFACE_FONT_TOKEN_BASES = Object.freeze({
+ '--ldp-font-micro': 9,
+ '--ldp-font-xs': 10,
+ '--ldp-font-sm': 11,
+ '--ldp-font-ui': 12,
+ '--ldp-font-base': 13,
+ '--ldp-font-md': 14,
+ '--ldp-font-lg': 15,
+ '--ldp-font-xl': 16,
+ '--ldp-font-2xl': 17,
+ '--ldp-font-3xl': 18,
+ });
+ const POST_FONT_BASE_PX = 14;
+ const LAYOUT_MIN_RATIOS = Object.freeze({ left: 0, main: 40, gap: 0, timeline: 6, right: 0 });
+ const READER_WINDOW_DEFAULT_WIDTH = 1080;
+ const READER_WINDOW_DEFAULT_VIEWPORT_WIDTH = 0.94;
+ const READER_WINDOW_DEFAULT_VIEWPORT_HEIGHT = 0.86;
+ const READER_WINDOW_MIN_WIDTH = 360;
+ const READER_WINDOW_MIN_HEIGHT = 320;
+ const READER_WINDOW_COMPACT_WIDTH = 700;
+ const READER_TITLE_MIN_BASE_PX = 12;
+ const READER_META_MIN_BASE_PX = 9;
+ const READER_TOPIC_TAG_MIN_BASE_PX = 9.5;
+ const READER_WINDOW_MARGIN = 8;
+ const READER_SETTINGS_SURFACE_MARGIN = 8;
+ const READER_SETTINGS_SHORT_HEIGHT = 560;
+ const READER_WINDOW_HANDLE_DOCK_THRESHOLD = 38;
+ const READER_EMBED_MIN_WIDTH = 360;
+ const READER_EMBED_DEFAULT_WIDTH = Math.round(window.innerWidth * .45);
+ const READER_HOST_MIN_WIDTH = 680;
+ const LIST_READER_MODES = Object.freeze(['floating', 'fullpage', 'embed-left', 'embed-right']);
+ const TOPIC_READER_MODES = Object.freeze(['floating', 'fullpage']);
+ const READER_PRESENTATIONS = Object.freeze({
+ floating: Object.freeze({ mode: 'floating', floating: true, fullPage: false, embedded: false, side: '' }),
+ fullpage: Object.freeze({ mode: 'fullpage', floating: false, fullPage: true, embedded: false, side: '' }),
+ 'embed-left': Object.freeze({ mode: 'embed-left', floating: false, fullPage: false, embedded: true, side: 'left' }),
+ 'embed-right': Object.freeze({ mode: 'embed-right', floating: false, fullPage: false, embedded: true, side: 'right' }),
+ });
+ const BOOST_COPY_MAX_LENGTH = 16;
+ const BOOST_COPY_MODES = Object.freeze(['counter', 'text']);
+ const BOOST_COPY_DEFAULTS = Object.freeze({
+ mode: 'counter',
+ prefix: '',
+ counterMarker: '+',
+ counterStep: 1,
+ fixedSuffix: '',
+ });
+ const BALANCED_PERFORMANCE = PERFORMANCE_PRESETS.balanced;
+ const NOTIFICATION_PAGE_SIZE = 24;
+ const NOTIFICATION_CACHE_FRESH_FOR = MINUTE;
+ const NOTIFICATION_LIVE_REFRESH_DEBOUNCE = 240;
+ const LIGHTBOX_COMMENT_RELATION_CACHE_FRESH_FOR = 10 * MINUTE;
+ const HISTORY_PAGE_SIZE = 20;
+ const BOOKMARKS_PAGE_SIZE = 20;
+ const GIVEN_REACTIONS_PAGE_SIZE = 20;
+ const GIVEN_LIKES_PAGE_SIZE = 60;
+ const USER_CARD_CACHE_FRESH_FOR = 24 * HOUR;
+ const USER_FOLLOW_LIST_CACHE_FRESH_FOR = 5 * MINUTE;
+ const USER_FOLLOW_LIST_CACHE_MAX_ENTRIES = 32;
+ const NOTIFICATION_AVATAR_CACHE_MAX_ENTRIES = 512;
+ const USER_FOLLOW_LIST_PAGE_SIZE = 10;
+ const NOTIFICATION_GROUPS = {
+ all: { mode: 'notifications', source: 'notifications', label: '全部', icon: 'bell', types: [] },
+ replies: { mode: 'notifications', source: 'user-actions', label: '回复', icon: 'reply', types: ['replied', 'quoted'], actionTypes: [6, 9], pageSize: 30 },
+ likes: { mode: 'notifications', source: 'user-actions', label: '赞', icon: 'heart', types: ['liked', 'liked_consolidated'], actionTypes: [2], pageSize: 30 },
+ mentions: { mode: 'notifications', source: 'user-actions', label: '@提及', icon: 'at', types: ['mentioned', 'group_mentioned'], actionTypes: [7], pageSize: 30 },
+ edits: { mode: 'notifications', source: 'user-actions', label: '编辑', icon: 'pencil', types: ['edited'], actionTypes: [11], pageSize: 30 },
+ links: { mode: 'notifications', source: 'user-actions', label: '链接', icon: 'link2', types: ['linked', 'linked_consolidated'], actionTypes: [17], pageSize: 30 },
+ boosts: { mode: 'notifications', source: 'boosts-received', label: 'Boosts', icon: 'rocket', types: ['boost'], pageSize: 20 },
+ reactions: { mode: 'notifications', source: 'reactions-received', label: '回应', icon: 'smile', types: ['reaction'], pageSize: 20 },
+ inbox: { mode: 'messages', source: 'private-messages', path: 'private-messages', label: '最新', icon: 'mail', pageSize: 30 },
+ sent: { mode: 'messages', source: 'private-messages', path: 'private-messages-sent', label: '已发送', icon: 'reply', pageSize: 30 },
+ newMessages: { mode: 'messages', source: 'private-messages', path: 'private-messages-new', label: '新', icon: 'plus', pageSize: 30 },
+ unreadMessages: { mode: 'messages', source: 'private-messages', path: 'private-messages-unread', label: '未读', icon: 'bell', pageSize: 30 },
+ archive: { mode: 'messages', source: 'private-messages', path: 'private-messages-archive', label: '归档', icon: 'database', pageSize: 30 },
+ botMessages: { mode: 'messages', source: 'private-messages', path: 'private-messages-warnings', label: '机器人聊天', icon: 'messageSquare', pageSize: 30 },
+ };
+ const NOTIFICATION_TYPE_ICONS = Object.freeze({
+ mentioned: 'at',
+ replied: 'reply',
+ quoted: 'messageSquare',
+ posted: 'messageSquare',
+ group_mentioned: 'at',
+ liked: 'heart',
+ liked_consolidated: 'heart',
+ reaction: 'smile',
+ boost: 'rocket',
+ following: 'userPlus',
+ following_created_topic: 'messageSquare',
+ following_replied: 'reply',
+ watching_first_post: 'bell',
+ watching_category_or_tag: 'bell',
+ private_message: 'mail',
+ invited_to_private_message: 'userPlus',
+ group_message_summary: 'mail',
+ chat_mention: 'at',
+ chat_message: 'messageSquare',
+ chat_invitation: 'userPlus',
+ chat_group_mention: 'at',
+ chat_quoted: 'messageSquare',
+ chat_watched_thread: 'bell',
+ edited: 'pencil',
+ invitee_accepted: 'userPlus',
+ moved_post: 'cornerDownRight',
+ linked: 'link2',
+ granted_badge: 'award',
+ invited_to_topic: 'userPlus',
+ custom: 'bell',
+ topic_reminder: 'bell',
+ post_approved: 'check',
+ code_review_commit_approved: 'check',
+ membership_request_accepted: 'check',
+ membership_request_consolidated: 'userPlus',
+ bookmark_reminder: 'bookmark',
+ votes_released: 'hand',
+ event_reminder: 'bell',
+ event_invitation: 'mail',
+ assigned: 'userRound',
+ question_answer_user_commented: 'messageSquare',
+ new_features: 'lightbulb',
+ admin_problems: 'alertTriangle',
+ linked_consolidated: 'link2',
+ upcoming_change_available: 'alertTriangle',
+ upcoming_change_automatically_promoted: 'alertTriangle',
+ suggested_edit_created: 'pencil',
+ suggested_edit_accepted: 'check',
+ circles_activity: 'activity',
+ });
+ const PERSISTENT_CACHE_CONFIG = {
+ history: { storageKey: LDP_HISTORY_KEY, maxAge: 365 * DAY },
+ topics: { maxAge: 180 * DAY, maxEntries: 8, storedMaxEntries: 64, storedMaxBytes: 64 * 1024 * 1024 },
+ topicNavIcons: { storageKey: LDP_TOPIC_NAV_ICON_CACHE_KEY, maxAge: 180 * DAY, maxEntries: 512 },
+ users: {
+ storageKey: LDP_USER_CARD_CACHE_KEY,
+ maxAge: 180 * DAY,
+ maxEntries: 128,
+ storedMaxEntries: 256,
+ storedMaxBytes: 16 * 1024 * 1024,
+ },
+ notifications: {
+ storageKey: LDP_NOTIFICATION_CACHE_KEY,
+ maxAge: 180 * DAY,
+ maxEntries: 32,
+ storedMaxEntries: 64,
+ storedMaxBytes: 8 * 1024 * 1024,
+ },
+ responses: { maxAge: 365 * DAY, storedMaxEntries: 256, storedMaxBytes: 32 * 1024 * 1024 },
+ assets: { maxAge: 90 * DAY },
+ };
+ const CACHE_TYPES = [
+ { key: 'history', label: '浏览历史', help: '勾选“浏览历史”后再点下方清理,会删除阅读器保存的主题、最近阅读楼层和查看时间;不会删除浏览器本身的访问历史。' },
+ { key: 'topics', label: '帖子与楼层内容', help: '勾选“帖子与楼层内容”后清理,会删除本机保存的帖子信息、楼层正文、回复关系、分类标签图标和相关接口数据;下次打开时会重新联网获取。' },
+ { key: 'users', label: '用户资料卡', help: '勾选“用户资料卡”后清理,会删除用户名、简介、徽章、用户组等资料快照和相关接口数据;头像仍归“头像、表情与原图”单独管理。' },
+ { key: 'notifications', label: '通知与消息', help: '勾选“通知与消息”后清理,会删除通知分页、已读状态、跳转位置和发送者摘要;头像资源保留,也不会删除站点账号里的真实消息。' },
+ { key: 'responses', label: '收藏、回应与其他数据', help: '勾选“收藏、回应与其他数据”后清理,会删除收藏列表、给出的回应与点赞、表情目录和其他通用接口结果;不会撤销站点上的真实收藏或回应。' },
+ { key: 'assets', label: '头像、表情与原图', help: '勾选“头像、表情与原图”后清理,会删除头像、Boost 表情和实际查看过的原图资源;不会删除帖子中的线上图片。' },
+ ];
+ const BOOKMARK_TAB_TYPES = Object.freeze(['Reaction', 'Topic', 'Post']);
+ const BOOKMARK_TAB_LABELS = Object.freeze({ Reaction: '回应', Topic: '帖子', Post: '楼层' });
+ const HISTORY_EDGE_TRIGGER_MIN = 0;
+ const HISTORY_EDGE_TRIGGER_MAX = 15;
+ const HISTORY_EDGE_TRIGGER_DEFAULT = 15;
+ const READER_QUEUE_PREFETCH_BATCH_SIZE = 20;
+ const READER_QUEUE_PREFETCH_MAX_POSTS = 200;
+ const READER_QUEUE_PREFETCH_URL_BEFORE_POSTS = 100;
+ const READER_QUEUE_PREFETCH_HEAD_POSTS = 40;
+ const READER_QUEUE_PREFETCH_BEFORE_POSTS = 80;
+ const READER_QUEUE_PREFETCH_MEDIA_CONCURRENCY = 2;
+ const READER_QUEUE_PREFETCH_IDLE_DELAY = 900;
+ const READER_QUEUE_PREFETCH_INTERACTION_SETTLE = 700;
+ const READER_QUEUE_DOCK_THRESHOLD_RATIO = .01;
+ const READER_LOADING_ANIMATION_KEYS = Object.freeze([
+ 'portal', 'constellation', 'corridor', 'typewave', 'crystal',
+ 'marginalia', 'chapters', 'quoteecho', 'footnotes', 'inkverse',
+ ]);
+ const READER_LOADING_ANIMATION_DEFAULT = 'quoteecho';
+ const DEFAULT_PREFS = {
+ topicReaderMode: 'fullpage',
+ imageProfile: IMAGE_PROFILE_DEFAULT,
+ lightboxOriginalByDefault: true,
+ lightboxCommentsExpandedByDefault: true,
+ lightboxDescriptionExpanded: false,
+ lightboxDescriptionHeight: LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT,
+ lightboxCommentsWidthPercent: LIGHTBOX_COMMENTS_WIDTH_DEFAULT,
+ themeMode: 'system',
+ fontRenderingEnabled: true,
+ fontRenderingOnHost: true,
+ ...HOST_FONT_DEFAULTS,
+ hostEmbeddedTitleScale: HOST_EMBED_SIZE_SETTINGS.title.defaultScale,
+ hostEmbeddedAvatarScale: HOST_EMBED_SIZE_SETTINGS.avatar.defaultScale,
+ hostEmbeddedStatsScale: HOST_EMBED_SIZE_SETTINGS.stats.defaultScale,
+ hostEmbeddedLabelCardScale: HOST_EMBED_SIZE_SETTINGS.labelCard.defaultScale,
+ fontProfile: READER_FONT_DEFAULT,
+ appearanceProfile: APPEARANCE_PROFILE_DEFAULT,
+ ...performancePrefsPatch(BALANCED_PERFORMANCE, 'balanced'),
+ layoutProfile: READER_LAYOUT_DEFAULT,
+ fullpageLayoutProfile: READER_FULLPAGE_LAYOUT_DEFAULT,
+ ...normalizeReaderWindowPreferenceGroup({}, 'readerWindow'),
+ listReaderMode: 'floating',
+ listReaderEmbedWidth: READER_EMBED_DEFAULT_WIDTH,
+ ...normalizeWindowGeometryPreferenceGroup({}, COMPOSER_WINDOW_PREFIX),
+ historySortMode: 'recent-viewed',
+ bookmarkTabOrder: BOOKMARK_TAB_TYPES,
+ historyButtonsAlwaysVisible: true,
+ readerQueueAlwaysVisibleWhenEmpty: true,
+ historyEdgeTriggerPercent: HISTORY_EDGE_TRIGGER_DEFAULT,
+ loadingAnimation: READER_LOADING_ANIMATION_DEFAULT,
+ translationMode: 'original',
+ openTopicsAtFirstPost: false,
+ doubleEscapeToCloseReader: false,
+ confirmNativeComposerClose: true,
+ topicActionRailVisible: true, topicActionRailFixed: false,
+ topicActionRailPosition: { x: 'left', y: .95 },
+ expandNestedRepliesByDefault: true,
+ expandLeafNestedReplies: false,
+ aggregateDescendantReplies: true,
+ // 主信息流内联树最大深度:1=仅直属二级(旧行为),3=主楼下最多再嵌套两层
+ inlineReplyTreeMaxDepth: 3,
+ hideNestedReplyFloors: true,
+ ...jumpHighlightPrefsPatch(JUMP_HIGHLIGHT_DEFAULTS),
+ boostCopyMode: BOOST_COPY_DEFAULTS.mode,
+ boostCopyPrefix: BOOST_COPY_DEFAULTS.prefix,
+ boostCopyCounterMarker: BOOST_COPY_DEFAULTS.counterMarker,
+ boostCopyCounterStep: BOOST_COPY_DEFAULTS.counterStep,
+ boostCopyFixedSuffix: BOOST_COPY_DEFAULTS.fixedSuffix,
+ };
+ const bypassReaderForThisTab = consumeNativeBypassTabSignal() || shouldBypassReaderUrl(location.href);
+ if (bypassReaderForThisTab) stripBypassParamFromUrl();
+ const INITIAL_TOPIC_ID = (extractTopicRouteFromUrl(location.href) || {}).topicId || null;
+ let ME_USERNAME = null;
+ let ME_CURRENT_USER = null;
+ const DISCOURSE_MODULE_CACHE = new Map();
+ const DISCOURSE_LOOKUP_CACHE = new Map();
+ let CONNECT_TRUST_CACHE = null;
+ let CONNECT_TRUST_REQUEST = null;
+ const SHARED_ISSUE_FORBIDDEN_TOPIC_IDS = new Set();
+ let NOTIFICATION_UNREAD_COUNT = 0;
+ let ACTIVE_READER_REQUEST_SCHEDULER = null;
+ const READER_QUEUE_ENTRIES = [];
+ const READER_QUEUE_BY_TOPIC = new Map();
+ let READER_QUEUE_ACTIVE_TOPIC_ID = '';
+ let READER_QUEUE_PREFETCH_RUNNING = false;
+ const READER_READ_STATES = new Map();
+ let READER_QUEUE_PREFETCH_TIMER = 0;
+ let READER_QUEUE_PERSIST_TIMER = 0;
+ let READER_QUEUE_RESTORING = false;
+ let READER_QUEUE_SURFACE_STATE = { x: null, y: null, dock: '' };
+ let SHARED_REQUEST_COORDINATOR = null;
+ let READER_PORTAL_HOST = null;
+ let READER_PORTAL_ROOT = null;
+ let READER_PORTAL_CONTEXT_HTML = null;
+ let READER_PORTAL_CONTEXT_BODY = null;
+ const READER_HOST_STYLE_CLONES = new Map();
+ let READER_HOST_STYLE_OBSERVER = null;
+ let READER_HOST_CONTEXT_OBSERVER = null;
+ let READER_ICON_TOOLTIP_CLEANUP = null;
+ if (INITIAL_TOPIC_ID) PAGE_ROOT.classList.add('ldp-route-takeover');
// --- 楼中楼分批加载 & 请求节流 相关配置 ---
- const SUB_REPLY_PAGE_SIZE = 10; // 楼中楼分页每页条数
- const SUB_REPLY_FETCH_SIZE = 20; // Discourse replies 接口单次返回上限
- const REPLIES_HOVER_DELAY = 40; // 楼层真正进入视口并短暂停留后触发抓取(ms)
- const POST_REQUEST_MAX_429_RETRIES = 1;
- const POST_REQUEST_429_BACKOFF = 1500;
- const POST_REQUEST_TIMEOUT = 15000;
- const READER_REQUEST_QUEUE_LIMIT = 24;
- const READER_REQUEST_CONCURRENCY_RECOVERY_SUCCESSES = 6;
- const READER_REQUEST_RATE_PROBE_SUCCESSES = 6;
- const READER_REQUEST_PROBE_RATE_STEP = 1.12;
- const READER_REQUEST_RECOVERY_PROBE_INTERVAL = 1200;
- const READER_REQUEST_RATE_MEMORY_TTL = 10 * 60 * 1000;
- const READER_ENDPOINT_429_EVIDENCE_WINDOW_MS = 4000;
- const READER_ENDPOINT_429_BASE_BLOCK_MS = 5000;
- const READER_ENDPOINT_429_MAX_BLOCK_MS = 5 * 60 * 1000;
- const READER_ENDPOINT_429_STATE_TTL = 10 * 60 * 1000;
- const READER_ENDPOINT_429_MAX_ENTRIES = 128;
- const READER_REQUEST_SHORT_WINDOW_MS = 10 * 1000;
- const READER_REQUEST_LONG_WINDOW_MS = 60 * 1000;
- const SHARED_REQUEST_POLICY_TTL = 30 * 1000;
- const SHARED_CACHE_FLIGHT_TTL = READER_REQUEST_LONG_WINDOW_MS + POST_REQUEST_TIMEOUT + 15000;
- const SHARED_CACHE_FLIGHT_STALE_MS = 35 * 1000;
- // Discourse defaults; each site may override them, so 429 feedback remains authoritative.
- const READER_REQUEST_DEFAULT_SHORT_CEILING = 50;
- const READER_REQUEST_DEFAULT_LONG_CEILING = 200;
- const REQUEST_FLOW_MAX_ENTRIES = 1200;
- const REQUEST_FLOW_RETENTION_MS = 15 * 60 * 1000;
- const REQUEST_FLOW_TRACE_WINDOW_MS = 10 * 1000;
- const REQUEST_FLOW_SLOW_MS = 1800;
- const REQUEST_FLOW_EVENTS = [];
- const REQUEST_FLOW_RESOURCE_DROPS = [];
- const REQUEST_FLOW_FETCH_META = Symbol('ldpRequestFlowFetchMeta');
- const REQUEST_FLOW_AJAX_STATE = new WeakMap();
- let REQUEST_FLOW_RESOURCE_OBSERVER = null;
- let REQUEST_FLOW_RESOURCE_BUFFERED = false;
- let REQUEST_FLOW_DETAILS_ACTIVE = false;
- let REQUEST_FLOW_AJAX_BOUND = false;
- const REQUEST_FLOW_TYPE_LABELS = Object.freeze({
- topic: '正文',
- nested: '二级回复',
- avatar: '头像',
- media: '图片媒体',
- user: '用户资料',
- bookmark: '收藏',
- notification: '消息',
- realtime: '实时通道',
- presence: '在线状态',
- reaction: '回应操作',
- search: '搜索',
- read: '已读上报',
- asset: '静态资源',
- other: '其他',
- });
- const REQUEST_FLOW_SOURCE_LABELS = Object.freeze({ reader: '阅读器', host: '原站', browser: '资源' });
- const REQUEST_FLOW_WAIT_REASON_LABELS = Object.freeze({
- priority: '优先级', concurrency: '并发槽', cooldown: '429 退避', 'endpoint-cooldown': '端点熔断',
- interval: '启动间隔', '10s': '10 秒窗口', '60s': '60 秒窗口',
- });
- const RESOURCE_MONITOR_SCOPE_LABELS = Object.freeze({ reader: '阅读器', host: '原站/未标', shared: '页面共享' });
- let REQUEST_FLOW_SEQUENCE = 0;
-
- function requestFlowNow() {
- const clock = globalThis.performance;
- if (clock && Number.isFinite(clock.timeOrigin) && isFunction(clock.now)) {
- return clock.timeOrigin + clock.now();
- }
- return Date.now();
- }
-
- function requestFlowCallSite() {
- if (!REQUEST_FLOW_DETAILS_ACTIVE) return '';
- let stack = '';
- try { stack = String(new Error().stack || ''); } catch {}
- const internalFrame = /\b(?:requestFlowCallSite|beginRequestFlow|trackedFetch|trackedSend|waitForReaderRequestPermit|requestCachedJSON|requestJSON|fetchJSON|runTask|apiSend)\b/;
- const frame = stack.split('\n').map((line) => line.trim()).find((line, index) =>
- index > 0 && line && !internalFrame.test(line) && !/\b(?:native code|Promise\.then)\b/.test(line));
- if (!frame) return '';
- return frame.replace(/(?:https?|chrome-extension):\/\/[^\s)]+/g, (raw) => {
- const locationSuffix = raw.match(/(:\d+:\d+)$/)?.[1] || '';
- const source = locationSuffix ? raw.slice(0, -locationSuffix.length) : raw;
- try {
- const url = new URL(source);
- return `${url.origin}${url.pathname}${locationSuffix}`;
- } catch {
- return source.split(/[?#]/, 1)[0] + locationSuffix;
- }
- }).replace(/^at\s+/, '').slice(0, 220);
- }
-
- function requestFlowEventIndex(startedAt, id = -Infinity) {
- let low = 0;
- let high = REQUEST_FLOW_EVENTS.length;
- while (low < high) {
- const middle = Math.floor((low + high) / 2);
- const current = REQUEST_FLOW_EVENTS[middle];
- if (current.startedAt < startedAt ||
- (current.startedAt === startedAt && current.id <= id)) low = middle + 1;
- else high = middle;
- }
- return low;
- }
-
- function insertRequestFlowEvent(event) {
- const last = REQUEST_FLOW_EVENTS[REQUEST_FLOW_EVENTS.length - 1];
- if (!last || last.startedAt < event.startedAt ||
- (last.startedAt === event.startedAt && last.id <= event.id)) {
- REQUEST_FLOW_EVENTS.push(event);
- return;
- }
- REQUEST_FLOW_EVENTS.splice(requestFlowEventIndex(event.startedAt, event.id), 0, event);
- }
-
- function requestFlowUrl(input) {
- try {
- const value = input && typeof input === 'object' && input.url ? input.url : input;
- if (value == null || String(value) === '') return null;
- return new URL(String(value), location.href);
- } catch {
- return null;
- }
- }
-
- function readerReadRequestFingerprint(topicId, postNumbers) {
- return `${topicId}:${[...postNumbers].sort((a, b) => a - b).join(',')}`;
- }
-
- function readerReadRequestRecentlySucceeded(fingerprint) {
- try {
- const cutoff = Date.now() - READER_READ_SUCCESS_TTL;
- const entries = JSON.parse(localStorage.getItem(LDP_READER_READ_SUCCESS_KEY) || '[]');
- return Array.isArray(entries) && entries.some((entry) =>
- entry?.fingerprint === fingerprint && Number(entry.at) > cutoff);
- } catch {
- return false;
- }
- }
-
- function rememberReaderReadRequestSuccess(fingerprint) {
- const cutoff = Date.now() - READER_READ_SUCCESS_TTL;
- let entries = [];
- try { entries = JSON.parse(localStorage.getItem(LDP_READER_READ_SUCCESS_KEY) || '[]'); } catch {}
- entries = (Array.isArray(entries) ? entries : [])
- .filter((entry) => Number(entry?.at) > cutoff && entry?.fingerprint !== fingerprint)
- .slice(-15);
- entries.push({ fingerprint, at: Date.now() });
- try { localStorage.setItem(LDP_READER_READ_SUCCESS_KEY, JSON.stringify(entries)); } catch {}
- }
-
- function requestFlowPath(url) {
- if (!url) return '未知请求';
- if (!['http:', 'https:'].includes(url.protocol)) return `${url.protocol.replace(':', '') || '本地'}资源`;
- const prefix = url.origin === location.origin ? '' : url.host;
- return `${prefix}${url.pathname || '/'}`.slice(0, 180);
- }
-
- function requestFlowType(url, initiatorType = '', method = 'GET') {
- const path = String(url?.pathname || '').toLowerCase();
- const host = String(url?.hostname || '').toLowerCase();
- const initiator = String(initiatorType || '').toLowerCase();
- if (/\/posts\/\d+\/replies(?:\.json)?$/.test(path)) return 'nested';
- if (/\/user_avatar\/|\/letter_avatar\//.test(path) || /avatar/.test(host)) return 'avatar';
- if (/\/(?:uploads|optimized)\//.test(path) || /\.(?:avif|gif|jpe?g|png|webp|svg|mp4|webm|mp3|ogg)(?:$|\/)/.test(path)) return 'media';
- if (/\/bookmarks?(?:\/|\.|$)|remove_bookmarks/.test(path)) return 'bookmark';
- if (/\/notifications?(?:\/|\.|$)/.test(path)) return 'notification';
- if (/\/message-bus(?:\/|$)/.test(path)) return 'realtime';
- if (/\/presence\//.test(path)) return 'presence';
- if (/\/search(?:\/|\.|$)|\/filter(?:\/|\.|$)/.test(path)) return 'search';
- if (/topic-timings|\/timings(?:\/|\.|$)/.test(path)) return 'read';
- if (/\/u\/|\/directory_items|\/session\/current|\/category-experts\//.test(path)) return 'user';
- if (/post_actions|user_actions|discourse-reactions|\/boosts?(?:\/|\.|$)|\/emojis\.json$/.test(path)) return 'reaction';
- if (/\/t\/|\/posts(?:\/|\.|$)|\/posts\/by_number\//.test(path)) return 'topic';
- if (!['GET', 'HEAD'].includes(method)) return 'reaction';
- if (['img', 'image', 'video', 'audio'].includes(initiator)) return 'media';
- if (['css', 'link', 'script', 'font'].includes(initiator)) return 'asset';
- return 'other';
- }
-
- function pruneRequestFlowEvents(at = Date.now()) {
- const cutoff = at - REQUEST_FLOW_RETENTION_MS;
- let expired = 0;
- while (expired < REQUEST_FLOW_EVENTS.length && REQUEST_FLOW_EVENTS[expired].startedAt < cutoff) expired++;
- if (expired) REQUEST_FLOW_EVENTS.splice(0, expired);
- if (REQUEST_FLOW_EVENTS.length > REQUEST_FLOW_MAX_ENTRIES) {
- REQUEST_FLOW_EVENTS.splice(0, REQUEST_FLOW_EVENTS.length - REQUEST_FLOW_MAX_ENTRIES);
- }
- while (REQUEST_FLOW_RESOURCE_DROPS.length && REQUEST_FLOW_RESOURCE_DROPS[0].at < cutoff) {
- REQUEST_FLOW_RESOURCE_DROPS.shift();
- }
- }
-
- function createRequestFlowEvent(url, details) {
- const { queuedAt, permittedAt, startedAt } = details;
- return {
- id: ++REQUEST_FLOW_SEQUENCE,
- ...details,
- endedAt: Number(details.endedAt) || 0,
- duration: 0,
- permitWait: Math.max(0, permittedAt - queuedAt),
- dispatchDuration: Math.max(0, startedAt - permittedAt),
- priority: Number.isFinite(details.priority) ? details.priority : null,
- recoveryProbe: details.recoveryProbe === true,
- attempt: Math.max(1, Math.floor(Number(details.attempt) || 1)),
- waitReason: String(details.waitReason || ''),
- callSite: details.callSite == null ? requestFlowCallSite() : String(details.callSite),
- type: details.type || requestFlowType(url, details.transport, details.method),
- path: String(details.path || requestFlowPath(url)),
- sameOrigin: !!url && url.origin === BASE,
- status: details.controlOnly ? 0 : null,
- size: 0,
- pending: details.controlOnly !== true,
- controlOnly: details.controlOnly === true,
- controlReason: String(details.controlReason || ''),
- rateLimitCode: '',
- retryAfter: '',
- serverLimit: '',
- serverRemaining: '',
- serverReset: '',
- error: '',
- resourceTimed: false,
- };
- }
-
- function beginRequestFlow(input, init = {}, transport = 'fetch') {
- const url = requestFlowUrl(input);
- const method = String(init.method || (input?.method) || 'GET').toUpperCase();
- const meta = init?.[REQUEST_FLOW_FETCH_META] || {};
- const startedAt = Number.isFinite(Number(meta.startedAt)) ? Number(meta.startedAt) : requestFlowNow();
- const queuedAt = Number.isFinite(Number(meta.queuedAt))
- ? Math.min(startedAt, Math.max(startedAt - REQUEST_FLOW_RETENTION_MS, Number(meta.queuedAt)))
- : startedAt;
- const permittedAt = Number.isFinite(Number(meta.permittedAt))
- ? Math.min(startedAt, Math.max(queuedAt, Number(meta.permittedAt)))
- : startedAt;
- const event = createRequestFlowEvent(url, {
- queuedAt,
- permittedAt,
- startedAt,
- endedAt: meta.endedAt,
- method,
- transport,
- source: meta.source === 'reader' ? 'reader' : 'host',
- priority: meta.priority,
- recoveryProbe: meta.recoveryProbe,
- attempt: meta.attempt,
- waitReason: meta.waitReason,
- callSite: meta.callSite,
- type: String(meta.type || requestFlowType(url, meta.classificationTransport || transport, method)),
- controlOnly: meta.controlOnly,
- controlReason: meta.controlReason,
- path: meta.path,
- });
- insertRequestFlowEvent(event);
- pruneRequestFlowEvents(event.startedAt);
- if (SHARED_REQUEST_COORDINATOR && !event.controlOnly) SHARED_REQUEST_COORDINATOR.recordHostStart(event);
- return event;
- }
-
- function recordRequestFlowControl(input, options = {}) {
- const endedAt = requestFlowNow();
- return beginRequestFlow(input, {
- method: options.method,
- [REQUEST_FLOW_FETCH_META]: {
- ...options,
- source: 'reader',
- startedAt: endedAt,
- permittedAt: endedAt,
- endedAt,
- controlOnly: true,
- controlReason: options.controlReason || 'cancelled',
- classificationTransport: 'fetch',
- },
- }, 'scheduler');
- }
-
- const REQUEST_FLOW_RESPONSE_HEADERS = {
- rateLimitCode: ['Discourse-Rate-Limit-Error-Code', 'X-Discourse-Rate-Limit-Error-Code'],
- retryAfter: ['Retry-After'],
- serverLimit: ['RateLimit-Limit', 'X-RateLimit-Limit'],
- serverRemaining: ['RateLimit-Remaining', 'X-RateLimit-Remaining'],
- serverReset: ['RateLimit-Reset', 'X-RateLimit-Reset'],
- };
-
- function requestFlowHeader(headers, names, splitHeaders = null) {
- if (typeof headers === 'string') {
- const lines = splitHeaders || headers.split(/\r?\n/);
- for (const name of names) {
- const prefix = `${name.toLowerCase()}:`;
- const line = lines.find((candidate) => candidate.toLowerCase().startsWith(prefix));
- if (line) return line.slice(line.indexOf(':') + 1).trim();
- }
- return '';
- }
- if (!headers || !isFunction(headers.get)) return '';
- for (const name of names) {
- const value = headers.get(name);
- if (value != null && value !== '') return String(value);
- }
- return '';
- }
-
- function requestFlowRateLimitNumber(value) {
- const match = String(value == null ? '' : value).match(/-?\d+(?:\.\d+)?/);
- if (!match) return NaN;
- const number = Number(match[0]);
- return Number.isFinite(number) ? number : NaN;
- }
-
- function requestFlowRateLimitResetDelay(value, at = Date.now()) {
- const raw = String(value == null ? '' : value).trim();
- const number = Number(raw);
- if (!Number.isFinite(number)) {
- const resetAt = Date.parse(raw);
- return Number.isFinite(resetAt) ? Math.max(0, resetAt - at) : 0;
- }
- if (!(number > 0)) return 0;
- if (number > 1e12) return Math.max(0, number - at);
- if (number > at / 1000 - DAY / 1000) return Math.max(0, number * 1000 - at);
- return Math.max(0, number * 1000);
- }
-
- function rateLimitWindowFromCode(code) {
- const short = /10[_-]?(?:secs?|seconds?)/i.test(String(code || ''));
- const long = /60[_-]?(?:secs?|seconds?)|minute/i.test(String(code || ''));
- return {
- short, long, known: short || long,
- label: short && long ? '10s+60s' : short ? '10s' : long ? '60s' : 'unknown',
- };
- }
-
- function requestFlowRateLimitPolicies(event) {
- const rawLimit = String(event?.serverLimit || '');
- if (!rawLimit) return [];
- const remainingParts = String(event?.serverRemaining || '').split(',');
- const resetParts = String(event?.serverReset || '').split(',');
- const rateWindow = rateLimitWindowFromCode(event?.rateLimitCode);
- const byWindow = new Map();
- rawLimit.split(',').forEach((part, index) => {
- const limit = requestFlowRateLimitNumber(part);
- if (!(limit > 0)) return;
- const explicitWindow = String(part).match(/(?:^|;)\s*w\s*=\s*"?(\d+)/i);
- const windowSeconds = explicitWindow ? Number(explicitWindow[1]) : 0;
- const resetDelay = requestFlowRateLimitResetDelay(resetParts[index] || resetParts[0]);
- let window = windowSeconds > 0 && windowSeconds <= 15
- ? '10s'
- : windowSeconds > 15 && windowSeconds <= 90
- ? '60s'
- : '';
- if (explicitWindow && !window) return;
- if (!window) {
- const { short: hitShort, long: hitLong } = rateWindow;
- if (resetDelay > 90000 && !hitShort && !hitLong) return;
- window = hitShort && !hitLong
- ? '10s'
- : hitLong && !hitShort
- ? '60s'
- : limit <= READER_REQUEST_DEFAULT_SHORT_CEILING * 2
- ? '10s'
- : '60s';
- }
- const policy = {
- window,
- limit,
- remaining: requestFlowRateLimitNumber(remainingParts[index] || (index === 0 ? remainingParts[0] : '')),
- resetDelay,
- };
- const previous = byWindow.get(window);
- if (!previous || policy.limit < previous.limit) byWindow.set(window, policy);
- });
- return [...byWindow.values()];
- }
-
- function finishRequestFlow(event, details = {}) {
- if (!event || !event.pending) return;
- event.endedAt = Number(details.endedAt) || requestFlowNow();
- if (!event.resourceTimed) {
- event.duration = Number.isFinite(Number(details.duration))
- ? Math.max(0, Number(details.duration))
- : Math.max(0, event.endedAt - event.startedAt);
- event.size = Math.max(0, Number(details.size) || 0);
- }
- event.status = Number.isFinite(Number(details.status)) ? Number(details.status) : 0;
- event.pending = false;
- event.rateLimitCode = String(details.rateLimitCode || '');
- event.retryAfter = String(details.retryAfter || '');
- event.serverLimit = String(details.serverLimit || '');
- event.serverRemaining = String(details.serverRemaining || '');
- event.serverReset = String(details.serverReset || '');
- event.error = String(details.error || '');
- if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.recordHostFinish(event);
- const hostApiEvent = sharedRequestApiEvent(event);
- const readerApiEvent = sharedRequestApiEvent(event, 'reader');
- const successfulApiEvent = (hostApiEvent || readerApiEvent) && event.status >= 200 && event.status < 400;
- let schedulerState = null;
- if (SHARED_REQUEST_COORDINATOR && (successfulApiEvent || (hostApiEvent && event.status === 429))) {
- try { schedulerState = ACTIVE_READER_REQUEST_SCHEDULER?.snapshot(); } catch {}
- }
- if (successfulApiEvent && SHARED_REQUEST_COORDINATOR && event.serverLimit) {
- SHARED_REQUEST_COORDINATOR.noteServerLimits({
- policies: requestFlowRateLimitPolicies(event),
- rateTarget: schedulerState ? schedulerState.requestRateTarget / 100 : 0.8,
- shortBudgetCap: schedulerState ? schedulerState.configuredShortWindowBudget : 40,
- longBudgetCap: schedulerState ? schedulerState.configuredLongWindowBudget : 160,
- });
- }
- if (successfulApiEvent && SHARED_REQUEST_COORDINATOR) {
- SHARED_REQUEST_COORDINATOR.noteSuccess({
- host: hostApiEvent,
- recoveryProbe: readerApiEvent && event.recoveryProbe,
- shortBudgetCap: schedulerState ? schedulerState.configuredShortWindowBudget : 40,
- longBudgetCap: schedulerState ? schedulerState.configuredLongWindowBudget : 160,
- });
- }
- if (hostApiEvent && event.status === 429 && SHARED_REQUEST_COORDINATOR) {
- const retrySeconds = event.retryAfter.trim() ? Number(event.retryAfter) : NaN;
- const retryDate = !Number.isFinite(retrySeconds) && event.retryAfter ? Date.parse(event.retryAfter) : 0;
- const wait = Number.isFinite(retrySeconds)
- ? retrySeconds * 1000
- : retryDate
- ? Math.max(0, retryDate - Date.now())
- : POST_REQUEST_429_BACKOFF;
- SHARED_REQUEST_COORDINATOR.noteRateLimit({
- wait: Math.max(1000, wait),
- window: rateLimitWindowFromCode(event.rateLimitCode).label,
- shortBudget: schedulerState ? schedulerState.shortWindowBudget : 40,
- longBudget: schedulerState ? schedulerState.longWindowBudget : 160,
- });
- }
- }
-
- function finishResponseRequestFlow(event, responseUrl, status, headers) {
- const finalUrl = requestFlowUrl(responseUrl);
- if (finalUrl) {
- event.path = requestFlowPath(finalUrl);
- event.sameOrigin = finalUrl.origin === BASE;
- if (event.type === 'other') {
- event.type = requestFlowType(finalUrl, event.transport, event.method);
- }
- }
- const splitHeaders = typeof headers === 'string' ? headers.split(/\r?\n/) : null;
- const contentLength = Number(requestFlowHeader(headers, ['Content-Length'], splitHeaders));
- finishRequestFlow(event, {
- status,
- size: Number.isFinite(contentLength) ? contentLength : 0,
- ...Object.fromEntries(Object.entries(REQUEST_FLOW_RESPONSE_HEADERS).map(
- ([key, names]) => [key, requestFlowHeader(headers, names, splitHeaders)]
- )),
- });
- return finalUrl;
- }
-
- function hostTopicResponseId(url) {
- if (!url || url.origin !== BASE) return 0;
- const match = url.pathname.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/|\.json|$)/);
- return match ? Number(match[1]) : 0;
- }
-
- function storeHostTopicResponse(event, url, payload) {
- if (!event || event.source !== 'host' || event.method !== 'GET') return;
- const topicId = hostTopicResponseId(url);
- const postStream = payload?.post_stream;
- if (!topicId || Number(payload?.id) !== topicId || !postStream ||
- !Array.isArray(postStream.stream) || !Array.isArray(postStream.posts)) return;
- const key = String(topicId);
- const readerEntry = TOPIC_DATA_CACHE.get(key);
- if (readerEntry && readerEntry.scope === responseCacheAuthScope() &&
- topicCacheEntryTimestamp(readerEntry) >= Number(event.startedAt || 0)) return;
- const hostEntry = HOST_TOPIC_DATA_CACHE.get(key);
- if (hostEntry && Number(hostEntry.startedAt || 0) > event.startedAt) return;
- HOST_TOPIC_DATA_CACHE.delete(key);
- HOST_TOPIC_DATA_CACHE.set(key, { topic: payload, at: Date.now(), startedAt: event.startedAt });
- setCachedTopicData(topicId, payload, { fetchedAt: Date.now(), source: 'host' });
- while (HOST_TOPIC_DATA_CACHE.size > HOST_TOPIC_CACHE_MAX_ENTRIES) {
- HOST_TOPIC_DATA_CACHE.delete(HOST_TOPIC_DATA_CACHE.keys().next().value);
- }
- }
-
- function captureHostTopicXhrResponse(event, xhr, url) {
- if (!xhr || event.source !== 'host' || event.method !== 'GET' || xhr.status < 200 || xhr.status >= 300 ||
- !hostTopicResponseId(url)) return;
- try {
- const contentType = String(xhr.getResponseHeader('Content-Type') || '').toLowerCase();
- if (!contentType.includes('json')) return;
- const payload = xhr.responseType === 'json' ? xhr.response : JSON.parse(xhr.responseText);
- storeHostTopicResponse(event, url, payload);
- } catch {}
- }
-
- function recordResourceRequestFlow(entry) {
- const initiator = String(entry?.initiatorType || '').toLowerCase();
- if (!entry) return;
- const url = requestFlowUrl(entry.name);
- if (!url || !['http:', 'https:'].includes(url.protocol)) return;
- const startedAt = performance.timeOrigin + Number(entry.startTime || 0);
- const duration = Math.max(0, Number(entry.duration) || 0);
- const size = Math.max(0, Number(entry.transferSize || entry.encodedBodySize) || 0);
- const path = requestFlowPath(url);
- if (['fetch', 'xmlhttprequest'].includes(initiator)) {
- let matchingRequest = null;
- for (let index = requestFlowEventIndex(startedAt - 100); index < REQUEST_FLOW_EVENTS.length; index++) {
- const event = REQUEST_FLOW_EVENTS[index];
- const eventStartedAt = Number(event.startedAt);
- if (eventStartedAt > startedAt + 100) break;
- if (!Number.isFinite(eventStartedAt) || event.controlOnly || event.resourceTimed ||
- event.transport !== initiator || event.path !== path) continue;
- if (!matchingRequest || Math.abs(eventStartedAt - startedAt) < Math.abs(matchingRequest.startedAt - startedAt)) {
- matchingRequest = event;
- }
- }
- if (matchingRequest) {
- Object.assign(matchingRequest, { resourceTimed: true, duration, size });
- return;
- }
- }
- const event = createRequestFlowEvent(url, {
- queuedAt: startedAt,
- permittedAt: startedAt,
- startedAt,
- method: 'GET',
- transport: initiator || 'resource',
- source: 'browser',
- priority: null,
- callSite: initiator ? `${initiator} 资源加载` : '浏览器资源加载',
- type: requestFlowType(url, initiator, 'GET'),
- path,
- });
- finishRequestFlow(event, {
- endedAt: performance.timeOrigin + Number(entry.responseEnd || entry.startTime || 0),
- duration,
- status: Number(entry.responseStatus) || 0,
- size,
- });
- event.resourceTimed = true;
- insertRequestFlowEvent(event);
- }
-
- const SHARED_REQUEST_API_TRANSPORTS = Object.freeze({ host: ['fetch', 'xmlhttprequest'], reader: ['fetch'] });
- function sharedRequestApiEvent(event, source = 'host') {
- return !!event && event.source === source && event.sameOrigin === true &&
- SHARED_REQUEST_API_TRANSPORTS[source]?.includes(event.transport) === true &&
- !['avatar', 'media', 'asset', 'realtime'].includes(event.type);
- }
-
- function createSharedRequestCoordinator() {
- const contextId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
- const lockName = `${LDP_SHARED_REQUEST_CHANNEL}:lock`;
- const cacheLockName = `${LDP_SHARED_REQUEST_CHANNEL}:cache-lock`;
- const waiters = new Set();
- const cacheListeners = new Set();
- let sequence = 0;
- let destroyed = false;
- let channel = null;
-
- const emptyState = () => ({
- version: 2,
- updatedAt: 0,
- events: [],
- intents: [],
- active: [],
- policies: [],
- flights: [],
- cacheEpoch: 0,
- shortBudget: 0,
- longBudget: 0,
- shortServerBudget: 0,
- longServerBudget: 0,
- cooldownUntil: 0,
- nextProbeAt: 0,
- shortServerBlockUntil: 0,
- longServerBlockUntil: 0,
- rateLimited: false,
- successfulSinceLimit: 0,
- lastRateLimitWindow: '',
- learnedUntil: 0,
- });
-
- const requestPolicy = (source = {}, policyContextId = contextId) => ({
- contextId: String(policyContextId),
- shortBudget: Math.max(1, Math.floor(Number(source.shortBudget) || READER_REQUEST_DEFAULT_SHORT_CEILING)),
- longBudget: Math.max(1, Math.floor(Number(source.longBudget) || READER_REQUEST_DEFAULT_LONG_CEILING)),
- minInterval: Math.max(0, Math.floor(Number(source.minInterval) || 0)),
- maxConcurrent: Math.max(1, Math.floor(Number(source.maxConcurrent) || 1)),
- });
-
- const normalizeState = (raw, at = Date.now()) => {
- const state = raw && typeof raw === 'object' ? raw : emptyState();
- state.version = 2;
- state.events = (Array.isArray(state.events) ? state.events : [])
- .map((event) => ({ at: Number(event?.at) || 0 }))
- .filter((event) => event.at > at - READER_REQUEST_LONG_WINDOW_MS && event.at <= at)
- .sort((a, b) => a.at - b.at)
- .slice(-600);
- state.intents = (Array.isArray(state.intents) ? state.intents : [])
- .filter((intent) => intent && Number(intent.expiresAt) > at)
- .slice(-120);
- state.active = (Array.isArray(state.active) ? state.active : [])
- .filter((lease) => lease && Number(lease.expiresAt) > at)
- .slice(-32);
- state.policies = (Array.isArray(state.policies) ? state.policies : [])
- .filter((policy) => policy?.contextId && Number(policy.expiresAt) > at)
- .map((policy) => ({
- ...requestPolicy(policy, policy.contextId),
- expiresAt: Number(policy.expiresAt),
- }))
- .slice(-32);
- state.flights = (Array.isArray(state.flights) ? state.flights : [])
- .map((flight) => ({
- ...flight,
- heartbeatAt: Number(flight?.heartbeatAt) ||
- Math.max(0, Number(flight?.expiresAt) - SHARED_CACHE_FLIGHT_TTL),
- }))
- .filter((flight) => flight?.token && Number(flight.expiresAt) > at &&
- Number(flight.heartbeatAt) > at - SHARED_CACHE_FLIGHT_STALE_MS)
- .sort((a, b) => Number(a.expiresAt) - Number(b.expiresAt))
- .slice(-120);
- state.cacheEpoch = Math.max(0, Math.floor(Number(state.cacheEpoch) || 0));
- state.shortBudget = Math.max(0, Number(state.shortBudget) || 0);
- state.longBudget = Math.max(0, Number(state.longBudget) || 0);
- state.shortServerBudget = Math.max(0, Number(state.shortServerBudget) || 0);
- state.longServerBudget = Math.max(0, Number(state.longServerBudget) || 0);
- state.cooldownUntil = Math.max(0, Number(state.cooldownUntil) || 0);
- state.nextProbeAt = Math.max(0, Number(state.nextProbeAt) || 0);
- state.shortServerBlockUntil = Math.max(0, Number(state.shortServerBlockUntil) || 0);
- state.longServerBlockUntil = Math.max(0, Number(state.longServerBlockUntil) || 0);
- state.successfulSinceLimit = Math.max(0, Number(state.successfulSinceLimit) || 0);
- state.lastRateLimitWindow = String(state.lastRateLimitWindow || '');
- state.learnedUntil = Math.max(0, Number(state.learnedUntil) || 0);
- if (state.learnedUntil <= at) {
- state.shortBudget = 0;
- state.longBudget = 0;
- state.shortServerBudget = 0;
- state.longServerBudget = 0;
- state.rateLimited = false;
- state.successfulSinceLimit = 0;
- state.lastRateLimitWindow = '';
- } else state.rateLimited = state.rateLimited === true;
- return state;
- };
-
- const readState = (at = Date.now()) => {
- try {
- return normalizeState(JSON.parse(localStorage.getItem(LDP_SHARED_REQUEST_STATE_KEY) || 'null'), at);
- } catch {
- return emptyState();
- }
- };
-
- const notifyWaiters = () => {
- waiters.forEach((resolve) => resolve());
- waiters.clear();
- };
-
- const transact = async (operation) => {
- const execute = () => {
- const at = Date.now();
- const state = readState(at);
- const result = operation(state, at);
- state.updatedAt = at;
- try { localStorage.setItem(LDP_SHARED_REQUEST_STATE_KEY, JSON.stringify(state)); } catch {}
- try { if (channel) channel.postMessage({ type: 'updated', contextId }); } catch {}
- notifyWaiters();
- return result;
- };
- const locks = navigator.locks;
- if (isFunction(locks?.request)) {
- return locks.request(lockName, { mode: 'exclusive' }, execute);
- }
- return execute();
- };
-
- const withCacheLock = (operation) => {
- const locks = navigator.locks;
- if (isFunction(locks?.request)) {
- return locks.request(cacheLockName, { mode: 'exclusive' }, operation);
- }
- return Promise.resolve().then(operation);
- };
-
- const windowState = (state, at, requestedShortBudget, requestedLongBudget) => {
- const shortBudget = Math.max(1, Math.floor(Math.min(
- requestedShortBudget,
- state.shortBudget || requestedShortBudget,
- state.shortServerBudget || requestedShortBudget
- )));
- const longBudget = Math.max(1, Math.floor(Math.min(
- requestedLongBudget,
- state.longBudget || requestedLongBudget,
- state.longServerBudget || requestedLongBudget
- )));
- const shortEvents = state.events.filter((event) => event.at > at - READER_REQUEST_SHORT_WINDOW_MS);
- const longEvents = state.events;
- const delayFor = (events, budget, windowMs) => events.length < budget
- ? 0
- : Math.max(0, events[events.length - budget].at + windowMs - at + 1);
- const shortDelay = delayFor(shortEvents, shortBudget, READER_REQUEST_SHORT_WINDOW_MS);
- const longDelay = delayFor(longEvents, longBudget, READER_REQUEST_LONG_WINDOW_MS);
- const shortServerDelay = Math.max(0, state.shortServerBlockUntil - at);
- const longServerDelay = Math.max(0, state.longServerBlockUntil - at);
- const effectiveShortDelay = Math.max(shortDelay, shortServerDelay);
- const effectiveLongDelay = Math.max(longDelay, longServerDelay);
- return {
- shortCount: shortEvents.length,
- longCount: longEvents.length,
- shortBudget,
- longBudget,
- serverDelay: Math.max(shortServerDelay, longServerDelay),
- delay: Math.max(effectiveShortDelay, effectiveLongDelay),
- blockingWindow: effectiveShortDelay >= effectiveLongDelay && effectiveShortDelay
- ? '10s'
- : effectiveLongDelay
- ? '60s'
- : '',
- };
- };
-
- const permitTiming = (state, at, policy) => {
- const windows = windowState(state, at, policy.shortBudget, policy.longBudget);
- const cooldownDelay = Math.max(0, state.cooldownUntil - at);
- const latestStart = state.events.length ? Number(state.events[state.events.length - 1].at) || 0 : 0;
- return { windows, cooldownDelay, intervalDelay: Math.max(0, latestStart + policy.minInterval - at) };
- };
-
- const rememberPolicy = (state, at, options = {}) => {
- const policy = { ...requestPolicy(options), expiresAt: at + SHARED_REQUEST_POLICY_TTL };
- const index = state.policies.findIndex((candidate) => candidate.contextId === contextId);
- if (index >= 0) state.policies[index] = policy;
- else state.policies.push(policy);
- return policy;
- };
-
- const effectivePolicy = (state, ownPolicy) => {
- const effective = {
- shortBudget: ownPolicy.shortBudget,
- longBudget: ownPolicy.longBudget,
- minInterval: ownPolicy.minInterval,
- maxConcurrent: Math.min(state.rateLimited ? 2 : Infinity, ownPolicy.maxConcurrent),
- };
- state.policies.forEach((policy) => {
- if (policy.contextId === contextId) return;
- effective.shortBudget = Math.min(effective.shortBudget, policy.shortBudget);
- effective.longBudget = Math.min(effective.longBudget, policy.longBudget);
- effective.minInterval = Math.max(effective.minInterval, policy.minInterval);
- effective.maxConcurrent = Math.min(effective.maxConcurrent, policy.maxConcurrent);
- });
- return effective;
- };
-
- const intentPriority = (intent, at) => {
- const ageBoost = Math.min(2, Math.floor(Math.max(0, at - intent.queuedAt) / 5000));
- return Number(intent.priority) - ageBoost;
- };
-
- const waitForUpdate = (delay, signal) => new Promise((resolve, reject) => {
- let settled = false;
- const settle = (callback) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- waiters.delete(finish);
- if (signal) signal.removeEventListener('abort', abort);
- callback();
- };
- const finish = () => settle(resolve);
- const abort = () => settle(() => reject(new DOMException('Aborted', 'AbortError')));
- const timer = setTimeout(finish, Math.max(25, Math.min(1000, delay || 80)));
- waiters.add(finish);
- if (signal) signal.addEventListener('abort', abort, { once: true });
- });
-
- const cancelIntent = (intentId) => {
- void transact((state) => {
- state.intents = state.intents.filter((intent) => intent.id !== intentId);
- });
- };
-
- const acquirePermit = async (options = {}) => {
- const intentId = `${contextId}:intent:${++sequence}`;
- const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible;
- const signal = options.signal;
- let granted = false;
- let waitReason = '';
- try {
- while (!destroyed && !(signal?.aborted)) {
- const result = await transact((state, at) => {
- const ownPolicy = rememberPolicy(state, at, options);
- const policy = effectivePolicy(state, ownPolicy);
- const existing = state.intents.find((intent) => intent.id === intentId);
- if (existing) existing.expiresAt = at + 15000;
- else state.intents.push({
- id: intentId,
- contextId,
- priority,
- queuedAt: at,
- expiresAt: at + 15000,
- });
- state.intents.sort((a, b) => intentPriority(a, at) - intentPriority(b, at) || a.queuedAt - b.queuedAt);
- if (!state.intents.length || state.intents[0].id !== intentId) {
- return { granted: false, wait: 80, reason: 'priority' };
- }
- if (state.active.length >= policy.maxConcurrent) {
- return { granted: false, wait: 80, reason: 'concurrency' };
- }
- const { windows, cooldownDelay, intervalDelay } = permitTiming(state, at, policy);
- const canProbe = state.rateLimited && !cooldownDelay && !windows.serverDelay && windows.delay > 0 &&
- !intervalDelay && priority <= POST_REQUEST_PRIORITY.visible && at >= state.nextProbeAt;
- const wait = Math.max(cooldownDelay, intervalDelay, canProbe ? 0 : windows.delay);
- if (wait > 0) {
- return {
- granted: false,
- wait,
- reason: cooldownDelay ? 'cooldown' : intervalDelay ? 'interval' : windows.blockingWindow,
- };
- }
- state.intents.shift();
- if (canProbe) state.nextProbeAt = at + READER_REQUEST_RECOVERY_PROBE_INTERVAL;
- state.events.push({ at });
- const permitId = `${contextId}:permit:${++sequence}`;
- state.active.push({ id: permitId, contextId, expiresAt: at + POST_REQUEST_TIMEOUT + 5000 });
- return { granted: true, permitId, startedAt: at, recoveryProbe: canProbe };
- });
- if (result.granted) {
- granted = true;
- return { ...result, waitReason };
- }
- if (result.cancelled) {
- waitReason = String(result.reason || waitReason || 'cancelled');
- throw new DOMException(waitReason, 'AbortError');
- }
- waitReason = String(result.reason || waitReason);
- await waitForUpdate(result.wait, signal);
- }
- throw new DOMException('Aborted', 'AbortError');
- } finally {
- if (!granted) cancelIntent(intentId);
- }
- };
-
- const recordHostStart = (event) => {
- if (!sharedRequestApiEvent(event) || destroyed) return;
- const activeId = `${contextId}:host-active:${event.id}`;
- event.sharedRequestActiveId = activeId;
- event.sharedRequestStart = transact((state, at) => {
- const startedAt = Math.max(at - 5000, Math.min(at, Number(event.startedAt) || at));
- state.events.push({ at: startedAt });
- state.active.push({ id: activeId, contextId, expiresAt: at + POST_REQUEST_TIMEOUT + 5000 });
- });
- };
-
- const finishPermit = (permitId) => {
- if (!permitId) return;
- void transact((state) => {
- state.active = state.active.filter((lease) => lease.id !== permitId);
- });
- };
-
- const recordHostFinish = (event) => {
- if (!event || !event.sharedRequestActiveId) return;
- Promise.resolve(event.sharedRequestStart).catch(() => {}).then(() => {
- finishPermit(event.sharedRequestActiveId);
- });
- };
-
- const acquireCacheFlight = (token) => transact((state, at) => {
- const normalizedToken = String(token || '');
- if (!normalizedToken) return { producer: true, flightId: '' };
- const existing = state.flights.find((flight) => flight.token === normalizedToken);
- if (existing) {
- return {
- producer: false,
- flightId: existing.id,
- expiresAt: existing.expiresAt,
- cacheEpoch: Number(existing.cacheEpoch) || 0,
- };
- }
- const flightId = `${contextId}:flight:${++sequence}`;
- state.flights.push({
- id: flightId,
- token: normalizedToken,
- contextId,
- cacheEpoch: state.cacheEpoch,
- heartbeatAt: at,
- expiresAt: at + SHARED_CACHE_FLIGHT_TTL,
- });
- return {
- producer: true,
- flightId,
- expiresAt: at + SHARED_CACHE_FLIGHT_TTL,
- cacheEpoch: state.cacheEpoch,
- };
- });
-
- const releaseCacheFlight = (token, flightId) => {
- if (!token || !flightId) return Promise.resolve();
- return transact((state) => {
- state.flights = state.flights.filter((flight) =>
- flight.token !== String(token) || flight.id !== String(flightId));
- });
- };
-
- const renewCacheFlight = (token, flightId) => {
- if (!token || !flightId) return Promise.resolve(false);
- return transact((state, at) => {
- const flight = state.flights.find((candidate) =>
- candidate.token === String(token) && candidate.id === String(flightId));
- if (!flight) return false;
- flight.heartbeatAt = at;
- flight.expiresAt = at + SHARED_CACHE_FLIGHT_TTL;
- return true;
- });
- };
-
- const writeCacheFlight = (token, flightId, cacheEpoch, operation) => withCacheLock(async () => {
- const state = readState();
- const valid = state.cacheEpoch === Number(cacheEpoch || 0) && state.flights.some((flight) =>
- flight.token === String(token) && flight.id === String(flightId));
- if (!valid) return false;
- await operation();
- return true;
- });
-
- const invalidateCacheWrites = () => withCacheLock(() => transact((state) => {
- state.cacheEpoch++;
- return state.cacheEpoch;
- }));
-
- const waitForCacheFlight = async (token, signal, deadline = Date.now() + SHARED_CACHE_FLIGHT_TTL) => {
- const normalizedToken = String(token || '');
- while (!destroyed && Date.now() < deadline && !(signal?.aborted)) {
- const state = readState();
- const flight = state.flights.find((candidate) => candidate.token === normalizedToken);
- if (!flight) return true;
- await waitForUpdate(Math.min(1000, Math.max(25, flight.expiresAt - Date.now())), signal);
- }
- if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
- return false;
- };
-
- const noteRateLimit = (options = {}) => {
- void transact((state, at) => {
- const windows = windowState(state, at, options.shortBudget, options.longBudget);
- const limitWindow = String(options.window || 'unknown');
- const hitShort = limitWindow === 'unknown' || limitWindow.includes('10s');
- const hitLong = limitWindow === 'unknown' || limitWindow.includes('60s');
- if (hitShort) {
- const safe = Math.max(1, Math.ceil(windows.shortCount * 0.9));
- state.shortBudget = state.shortBudget ? Math.min(state.shortBudget, safe) : safe;
- }
- if (hitLong) {
- const safe = Math.max(1, Math.ceil(windows.longCount * 0.9));
- state.longBudget = state.longBudget ? Math.min(state.longBudget, safe) : safe;
- }
- state.cooldownUntil = Math.max(state.cooldownUntil, at + Math.max(1000, Number(options.wait) || 0));
- state.nextProbeAt = state.cooldownUntil;
- state.rateLimited = true;
- state.successfulSinceLimit = 0;
- state.lastRateLimitWindow = limitWindow;
- state.learnedUntil = at + READER_REQUEST_RATE_MEMORY_TTL;
- });
- };
-
- const resetRateLimits = () => transact((state) => {
- [
- 'shortBudget',
- 'longBudget',
- 'shortServerBudget',
- 'longServerBudget',
- 'cooldownUntil',
- 'nextProbeAt',
- 'shortServerBlockUntil',
- 'longServerBlockUntil',
- 'successfulSinceLimit',
- 'learnedUntil',
- ].forEach((key) => { state[key] = 0; });
- state.rateLimited = false;
- state.lastRateLimitWindow = '';
- return true;
- });
-
- const noteServerLimits = (options = {}) => {
- const rateTarget = Math.min(0.95, Math.max(0.5, Number(options.rateTarget) || 0.8));
- const shortCap = Math.max(1, Math.floor(Number(options.shortBudgetCap) || 40));
- const longCap = Math.max(1, Math.floor(Number(options.longBudgetCap) || 160));
- const policies = (Array.isArray(options.policies) ? options.policies : []).map((policy) => {
- const window = policy && policy.window === '10s' ? '10s' : policy && policy.window === '60s' ? '60s' : '';
- const limit = Math.max(0, Number(policy?.limit) || 0);
- const cap = window === '10s' ? shortCap : longCap;
- return {
- window,
- limit,
- remaining: Number(policy?.remaining),
- resetDelay: Math.max(0, Number(policy?.resetDelay) || 0),
- cap,
- desired: Math.max(1, Math.min(cap, Math.floor(limit * rateTarget))),
- };
- }).filter((policy) => policy.window && policy.limit > 0);
- if (!policies.length) return;
- const previewAt = Date.now();
- const preview = readState(previewAt);
- const needsUpdate = policies.some((policy) => {
- const budgetKey = policy.window === '10s' ? 'shortServerBudget' : 'longServerBudget';
- const blockKey = policy.window === '10s' ? 'shortServerBlockUntil' : 'longServerBlockUntil';
- const current = Number(preview[budgetKey]) || 0;
- const effective = current || policy.cap;
- const budgetChanges = policy.desired < effective ||
- (!preview.rateLimited && current > 0 && policy.desired > current);
- const reserve = Math.max(1, Math.ceil(policy.limit * (1 - rateTarget)));
- const shouldBlock = Number.isFinite(policy.remaining) && policy.remaining <= reserve && policy.resetDelay > 0;
- const blockUntil = shouldBlock ? previewAt + policy.resetDelay : 0;
- const blockChanges = shouldBlock
- ? blockUntil > Number(preview[blockKey] || 0) + 500
- : Number(preview[blockKey] || 0) > previewAt;
- return budgetChanges || blockChanges;
- });
- if (!needsUpdate) return;
- void transact((state, at) => {
- let changed = false;
- policies.forEach((policy) => {
- const budgetKey = policy.window === '10s' ? 'shortServerBudget' : 'longServerBudget';
- const blockKey = policy.window === '10s' ? 'shortServerBlockUntil' : 'longServerBlockUntil';
- const windowMs = policy.window === '10s' ? READER_REQUEST_SHORT_WINDOW_MS : READER_REQUEST_LONG_WINDOW_MS;
- const current = Number(state[budgetKey]) || 0;
- if (!current && policy.desired < policy.cap) {
- state[budgetKey] = policy.desired;
- changed = true;
- } else if (current && policy.desired < current) {
- state[budgetKey] = policy.desired;
- changed = true;
- } else if (!state.rateLimited && current && policy.desired > current) {
- const step = Math.max(1, Math.ceil((policy.desired - current) * 0.35));
- const next = Math.min(policy.desired, current + step);
- state[budgetKey] = next >= policy.cap && policy.desired >= policy.cap ? 0 : next;
- changed = true;
- }
- const reserve = Math.max(1, Math.ceil(policy.limit * (1 - rateTarget)));
- const shouldBlock = Number.isFinite(policy.remaining) && policy.remaining <= reserve && policy.resetDelay > 0;
- if (shouldBlock) {
- const blockUntil = at + Math.min(windowMs + 1000, policy.resetDelay);
- if (blockUntil > Number(state[blockKey] || 0) + 500) {
- state[blockKey] = blockUntil;
- changed = true;
- }
- } else if (Number(state[blockKey] || 0) > at) {
- state[blockKey] = 0;
- changed = true;
- }
- });
- if (changed) state.learnedUntil = Math.max(state.learnedUntil, at + READER_REQUEST_RATE_MEMORY_TTL);
- });
- };
-
- const noteSuccess = (options = {}) => {
- const preview = readState();
- if (!preview.rateLimited && !preview.shortBudget && !preview.longBudget) return;
- void transact((state, at) => {
- if (at < state.cooldownUntil || (!state.shortBudget && !state.longBudget)) return;
- const credit = options.recoveryProbe ? 2 : options.host ? 0.5 : 1;
- state.successfulSinceLimit += credit;
- const shouldGrow = state.rateLimited ||
- state.successfulSinceLimit >= READER_REQUEST_RATE_PROBE_SUCCESSES;
- if (!shouldGrow) return;
- const shortCap = Math.max(1, Number(options.shortBudgetCap) || READER_REQUEST_DEFAULT_SHORT_CEILING);
- const longCap = Math.max(1, Number(options.longBudgetCap) || READER_REQUEST_DEFAULT_LONG_CEILING);
- const rateStep = options.recoveryProbe ? READER_REQUEST_PROBE_RATE_STEP : 1.04;
- if (state.shortBudget) {
- state.shortBudget = Math.min(
- shortCap,
- Math.max(state.shortBudget * rateStep, state.shortBudget + (options.recoveryProbe ? 2 : 1))
- );
- }
- if (state.longBudget) {
- state.longBudget = Math.min(
- longCap,
- Math.max(state.longBudget * rateStep, state.longBudget + (options.recoveryProbe ? 6 : 3))
- );
- }
- if (state.successfulSinceLimit >= READER_REQUEST_CONCURRENCY_RECOVERY_SUCCESSES) {
- state.rateLimited = false;
- state.successfulSinceLimit = 0;
- } else if (!state.rateLimited) {
- state.successfulSinceLimit = 0;
- }
- const shortRecovered = !state.shortBudget || state.shortBudget >= shortCap;
- const longRecovered = !state.longBudget || state.longBudget >= longCap;
- if (!state.rateLimited && shortRecovered && longRecovered) {
- state.shortBudget = 0;
- state.longBudget = 0;
- }
- state.learnedUntil = at + READER_REQUEST_RATE_MEMORY_TTL;
- });
- };
-
- const snapshot = (
- requestedShortBudget,
- requestedLongBudget,
- requestedMinInterval = 0,
- requestedMaxConcurrent = 1,
- at = Date.now()
- ) => {
- const state = readState(at);
- const policy = effectivePolicy(state, requestPolicy({
- shortBudget: requestedShortBudget,
- longBudget: requestedLongBudget,
- minInterval: requestedMinInterval,
- maxConcurrent: requestedMaxConcurrent,
- }));
- const { windows, cooldownDelay: cooldownRemaining, intervalDelay } = permitTiming(state, at, policy);
- const nextPermitDelay = Math.max(cooldownRemaining, intervalDelay, windows.delay);
- const liveContexts = new Set([
- ...state.policies.map((entry) => entry.contextId),
- ...state.intents.map((entry) => entry.contextId),
- ...state.active.map((entry) => entry.contextId),
- ].filter(Boolean));
- return {
- ...windows,
- queued: state.intents.length,
- active: state.active.length,
- instances: Math.max(1, liveContexts.size),
- maxConcurrent: policy.maxConcurrent,
- minInterval: policy.minInterval,
- coordinationMode: isFunction(navigator.locks?.request)
- ? 'atomic'
- : 'best-effort',
- coolingDown: cooldownRemaining > 0,
- cooldownRemaining,
- nextPermitDelay,
- blockingReason: cooldownRemaining ? 'cooldown' : intervalDelay ? 'interval' : windows.blockingWindow,
- rateLimited: state.rateLimited,
- lastRateLimitWindow: state.lastRateLimitWindow,
- };
- };
-
- const onStorage = (event) => {
- if (event.key === LDP_SHARED_REQUEST_STATE_KEY) notifyWaiters();
- };
- const publishCache = (message) => {
- try {
- if (channel) channel.postMessage({ type: 'cache', contextId, message });
- } catch {}
- };
- const onChannelMessage = (event) => {
- const data = event?.data;
- if (data && data.type === 'cache' && data.contextId !== contextId)
- cacheListeners.forEach((listener) => {
- try { listener(data.message || {}); } catch {}
- });
- notifyWaiters();
- };
- try {
- channel = new globalThis.BroadcastChannel(LDP_SHARED_REQUEST_CHANNEL);
- channel.addEventListener('message', onChannelMessage);
- } catch {}
- window.addEventListener('storage', onStorage);
-
- return {
- contextId,
- acquirePermit,
- recordHostStart,
- recordHostFinish,
- finishPermit,
- acquireCacheFlight,
- releaseCacheFlight,
- renewCacheFlight,
- writeCacheFlight,
- invalidateCacheWrites,
- waitForCacheFlight,
- noteRateLimit,
- resetRateLimits,
- noteServerLimits,
- noteSuccess,
- snapshot,
- publishCache,
- subscribeCache(listener) {
- if (!isFunction(listener)) return () => {};
- cacheListeners.add(listener);
- return () => cacheListeners.delete(listener);
- },
- destroy() {
- if (destroyed) return;
- destroyed = true;
- void transact((state) => {
- state.intents = state.intents.filter((intent) => intent.contextId !== contextId);
- state.active = state.active.filter((lease) => lease.contextId !== contextId);
- state.policies = state.policies.filter((policy) => policy.contextId !== contextId);
- state.flights = state.flights.filter((flight) => flight.contextId !== contextId);
- });
- window.removeEventListener('storage', onStorage);
- if (channel) channel.close();
- channel = null;
- cacheListeners.clear();
- notifyWaiters();
- },
- };
- }
-
- function installRequestFlowInstrumentation() {
- if (!REQUEST_FLOW_AJAX_BOUND) {
- const jqueryModule = lookupDiscourseModule('jquery');
- const jquery = jqueryModule && (jqueryModule.default || jqueryModule);
- if (isFunction(jquery)) {
- const target = jquery(document);
- target.on('ajaxSend.ldpRequestFlow', (_event, xhr, settings = {}) => {
- const method = String(settings.type || settings.method || 'GET').toUpperCase();
- REQUEST_FLOW_AJAX_STATE.set(xhr, {
- event: beginRequestFlow(settings.url, {
- method,
- [REQUEST_FLOW_FETCH_META]: { callSite: '' },
- }, 'xmlhttprequest'),
- url: settings.url,
- });
- });
- target.on('ajaxComplete.ldpRequestFlow', (_event, xhr) => {
- const state = REQUEST_FLOW_AJAX_STATE.get(xhr);
- if (!state) return;
- REQUEST_FLOW_AJAX_STATE.delete(xhr);
- let headers = '';
- try { headers = xhr.getAllResponseHeaders(); } catch {}
- const finalUrl = finishResponseRequestFlow(
- state.event,
- xhr.responseURL || state.url,
- xhr.status,
- headers
- );
- captureHostTopicXhrResponse(state.event, xhr, finalUrl);
- });
- REQUEST_FLOW_AJAX_BOUND = true;
- }
- }
-
- if (REQUEST_FLOW_DETAILS_ACTIVE && !REQUEST_FLOW_RESOURCE_OBSERVER &&
- isFunction(window.PerformanceObserver)) {
- try {
- REQUEST_FLOW_RESOURCE_OBSERVER = new window.PerformanceObserver((list, _observer, options) => {
- list.getEntries().forEach(recordResourceRequestFlow);
- const dropped = Math.max(0, Number(options?.droppedEntriesCount) || 0);
- if (dropped) REQUEST_FLOW_RESOURCE_DROPS.push({ at: requestFlowNow(), count: dropped });
- pruneRequestFlowEvents();
- });
- REQUEST_FLOW_RESOURCE_OBSERVER.observe({
- type: 'resource',
- buffered: !REQUEST_FLOW_RESOURCE_BUFFERED,
- });
- REQUEST_FLOW_RESOURCE_BUFFERED = true;
- } catch {}
- }
- return REQUEST_FLOW_AJAX_BOUND;
- }
-
- function setRequestFlowDetailsActive(active) {
- REQUEST_FLOW_DETAILS_ACTIVE = active === true;
- if (REQUEST_FLOW_DETAILS_ACTIVE) {
- installRequestFlowInstrumentation();
- return;
- }
- if (REQUEST_FLOW_RESOURCE_OBSERVER) REQUEST_FLOW_RESOURCE_OBSERVER.disconnect();
- REQUEST_FLOW_RESOURCE_OBSERVER = null;
- }
-
- function trackedReaderFetch(input, init = {}) {
- const meta = init[REQUEST_FLOW_FETCH_META] || {};
- const trackedInit = {
- ...init,
- [REQUEST_FLOW_FETCH_META]: { ...meta, source: 'reader' },
- };
- const event = beginRequestFlow(input, trackedInit, 'fetch');
- let request;
- try {
- request = window.fetch(input, init);
- } catch (error) {
- finishRequestFlow(event, { error: error?.name || 'fetch_error' });
- throw error;
- }
- return Promise.resolve(request).then((response) => {
- finishResponseRequestFlow(
- event,
- response?.url,
- response?.status,
- response?.headers
- );
- return response;
- }, (error) => {
- finishRequestFlow(event, { error: error?.name || 'fetch_error' });
- throw error;
- });
- }
-
- SHARED_REQUEST_COORDINATOR = createSharedRequestCoordinator();
- installRequestFlowInstrumentation();
-
- function resetReaderRateAfterChallenge() {
- if (ACTIVE_READER_REQUEST_SCHEDULER?.resetRateLimits) {
- void ACTIVE_READER_REQUEST_SCHEDULER.resetRateLimits();
- return true;
- }
- if (!SHARED_REQUEST_COORDINATOR || !SHARED_REQUEST_COORDINATOR.resetRateLimits) return false;
- void SHARED_REQUEST_COORDINATOR.resetRateLimits();
- return true;
- }
- const POST_REQUEST_PRIORITY = Object.freeze({
- target: -2,
- topic: -1,
- scroll: 0,
- visible: 1,
- nested: 2,
- userCard: 1,
- auxiliary: 3,
- background: 4,
- });
- const STREAM_PLACEHOLDER_MIN_HEIGHT = 64;
- const STREAM_ESTIMATED_HEIGHT = 180;
-
- const USER_CARD_LINK_SEL = [
- '.ldp-user-link[data-user-card]',
- '.ldp-user-avatar-card[data-user-card]',
- '.ldp-topic-owner-link[data-user-card]',
- 'html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.posters,.topic-poster) [data-user-card]',
- ].join(',');
- const USER_CARD_PREFETCH_DELAY = 250;
- const USER_CARD_SHOW_DELAY = 500;
- const HOST_TOPIC_CACHE_MAX_ENTRIES = 8;
- const HOST_TOPIC_DATA_CACHE = new Map();
- const TOPIC_DATA_CACHE = new Map();
- const TOPIC_CACHE_SUBSCRIBERS = new Map();
- const TOPIC_SNAPSHOT_WRITE_QUEUE = new Map();
- const TOPIC_NAV_ICON_CACHE = new Map();
- const USER_CARD_CACHE = new Map();
- const USER_CARD_REQUESTS = new Map();
- const USER_FOLLOW_LIST_CACHE = new Map();
- const USER_FOLLOW_LIST_REQUESTS = new Map();
- const NOTIFICATION_CACHE = new Map();
- const NOTIFICATION_REQUESTS = new Map();
- const NOTIFICATION_AVATAR_CACHE = new Map();
- const AVATAR_OBJECT_URL_CACHE = new Map();
- const AVATAR_RESOURCE_REQUESTS = new Map();
- const AVATAR_HYDRATION_PENDING = new WeakSet();
- const EMOJI_IMAGE_OBJECT_URL_CACHE = new Map();
- const EMOJI_IMAGE_RESOURCE_REQUESTS = new Map();
- const LIGHTBOX_IMAGE_OBJECT_URL_CACHE = new Map();
- const LIGHTBOX_IMAGE_RESOURCE_REQUESTS = new Map();
- const RESOURCE_CACHE_GROUPS = [
- {
- label: '头像', cacheName: LDP_AVATAR_CACHE_NAME, objectUrls: AVATAR_OBJECT_URL_CACHE,
- requests: AVATAR_RESOURCE_REQUESTS, maxEntries: 512, maxBytes: 32 * 1024 * 1024,
- },
- {
- label: '表情', cacheName: LDP_EMOJI_IMAGE_CACHE_NAME, objectUrls: EMOJI_IMAGE_OBJECT_URL_CACHE,
- requests: EMOJI_IMAGE_RESOURCE_REQUESTS, maxEntries: 512, maxBytes: 16 * 1024 * 1024,
- },
- {
- label: '原图', cacheName: LDP_LIGHTBOX_IMAGE_CACHE_NAME, objectUrls: LIGHTBOX_IMAGE_OBJECT_URL_CACHE,
- requests: LIGHTBOX_IMAGE_RESOURCE_REQUESTS, maxEntries: 24, maxBytes: 96 * 1024 * 1024,
- },
- ];
- const RESOURCE_CACHE_WRITES = new Set();
- const RESOURCE_CACHE_PRUNE_REQUESTS = new Map();
- let RESOURCE_CACHE_ABORT_CONTROLLER = new AbortController();
- let RESOURCE_CACHE_CLEARING = false;
- const RESPONSE_CACHE_MEMORY = new Map();
- const RESPONSE_CACHE_REQUESTS = new Map();
- const RESPONSE_CACHE_WRITES = new Map();
- const RESPONSE_CACHE_VALUE_META = new WeakMap();
- const RESPONSE_CACHE_MEMORY_MAX_ENTRIES = 96;
- const RESPONSE_CACHE_MEMORY_MAX_BYTES = 24 * 1024 * 1024;
- const RESOURCE_CACHE_PRUNE_BATCH_SIZE = 32;
- const SEARCH_PINYIN_CACHE_MAX_ENTRIES = 512;
- let responseCacheDbRequest = null;
- let responseCachePersistenceRequested = false;
- let responseCachePruneRequest = null;
- let responseCachePrunedAt = 0;
- const SEARCH_PINYIN_CACHE = new Map();
- const PERSISTENT_CACHE_WRITE_KEYS = new Set();
- let cancelPersistentCacheWrite = null;
- let persistentCacheWriteDueAt = 0;
-
- function trimResponseCacheMemory() {
- let bytes = 0;
- RESPONSE_CACHE_MEMORY.forEach((entry) => { bytes += Math.max(0, Number(entry?.bytes) || 0); });
- while (RESPONSE_CACHE_MEMORY.size > RESPONSE_CACHE_MEMORY_MAX_ENTRIES ||
- (bytes > RESPONSE_CACHE_MEMORY_MAX_BYTES && RESPONSE_CACHE_MEMORY.size > 1)) {
- const oldestId = RESPONSE_CACHE_MEMORY.keys().next().value;
- const oldest = RESPONSE_CACHE_MEMORY.get(oldestId);
- bytes -= Math.max(0, Number(oldest?.bytes) || 0);
- RESPONSE_CACHE_MEMORY.delete(oldestId);
- }
- }
-
- function rememberResponseCacheEntry(id, entry) {
- RESPONSE_CACHE_MEMORY.delete(id);
- RESPONSE_CACHE_MEMORY.set(id, entry);
- trimResponseCacheMemory();
- }
-
- function markResponseCacheStoredAt(value, storedAt) {
- if (value && typeof value === 'object')
- RESPONSE_CACHE_VALUE_META.set(value, Number(storedAt) || Date.now());
- return value;
- }
-
- function responseCacheValueStoredAt(value) {
- return Number(value && typeof value === 'object' && RESPONSE_CACHE_VALUE_META.get(value)) || 0;
- }
-
- function sharedCacheIdToken(value) {
- const source = String(value || '');
- let hash = 2166136261;
- for (let index = 0; index < source.length; index++) {
- hash ^= source.charCodeAt(index);
- hash = Math.imul(hash, 16777619);
- }
- return (hash >>> 0).toString(36);
- }
-
- function openResponseCacheDatabase() {
- if (responseCacheDbRequest) return responseCacheDbRequest;
- if (typeof indexedDB === 'undefined') return Promise.resolve(null);
- let pendingRequest;
- pendingRequest = new Promise((resolve) => {
- let settled = false;
- const finish = (db) => {
- if (settled) {
- if (db) db.close();
- return;
- }
- settled = true;
- resolve(db);
- };
- let request;
- try {
- request = indexedDB.open(LDP_RESPONSE_CACHE_DB_NAME, 1);
- } catch {
- finish(null);
- return;
- }
- request.onupgradeneeded = () => {
- const db = request.result;
- const store = db.objectStoreNames.contains(LDP_RESPONSE_CACHE_STORE)
- ? request.transaction.objectStore(LDP_RESPONSE_CACHE_STORE)
- : db.createObjectStore(LDP_RESPONSE_CACHE_STORE, { keyPath: 'id' });
- if (!store.indexNames.contains('kind')) store.createIndex('kind', 'kind', { unique: false });
- if (!store.indexNames.contains('tags')) store.createIndex('tags', 'tags', { unique: false, multiEntry: true });
- };
- request.onsuccess = () => {
- const db = request.result;
- db.onversionchange = () => {
- db.close();
- if (responseCacheDbRequest === pendingRequest) responseCacheDbRequest = null;
- };
- finish(db);
- };
- request.onerror = () => finish(null);
- request.onblocked = () => finish(null);
- });
- responseCacheDbRequest = pendingRequest;
- pendingRequest.then((db) => {
- if (!db && responseCacheDbRequest === pendingRequest) responseCacheDbRequest = null;
- });
- return pendingRequest;
- }
-
- function runResponseCacheTransaction(db, mode, operation) {
- return new Promise((resolve) => {
- try {
- const transaction = db.transaction(LDP_RESPONSE_CACHE_STORE, mode);
- transaction.oncomplete = () => resolve(true);
- transaction.onerror = transaction.onabort = () => resolve(false);
- operation(transaction.objectStore(LDP_RESPONSE_CACHE_STORE));
- } catch {
- resolve(false);
- }
- });
- }
-
- function publishResponseCacheMutation(action, details) {
- if (SHARED_REQUEST_COORDINATOR)
- SHARED_REQUEST_COORDINATOR.publishCache({ action, ...details });
- }
-
- function responseCacheUrl(url) {
- try {
- const parsed = new URL(String(url || ''), location.href);
- ['track_visit', 'forceLoad', '_'].forEach((key) => parsed.searchParams.delete(key));
- const postIds = parsed.searchParams.getAll('post_ids[]').sort((a, b) => String(a).localeCompare(String(b)));
- if (postIds.length) {
- parsed.searchParams.delete('post_ids[]');
- postIds.forEach((id) => parsed.searchParams.append('post_ids[]', id));
- }
- parsed.searchParams.sort();
- parsed.hash = '';
- return parsed;
- } catch {
- return null;
- }
- }
-
- function responseCacheAuthScope() {
- const currentUserId = Number(ME_CURRENT_USER?.id) || 0;
- const currentUsername = String(ME_USERNAME || '').trim().toLocaleLowerCase();
- const accountIdentity = currentUserId
- ? `id:${currentUserId}`
- : currentUsername
- ? `username:${currentUsername}`
- : ME_USERNAME === ''
- ? 'anonymous'
- : '';
- if (accountIdentity) return `account:${sharedCacheIdToken(accountIdentity)}`;
- const token = String((document.querySelector('meta[name="csrf-token"]') || {}).content || '');
- if (!token) return 'session:anonymous';
- return `session:${sharedCacheIdToken(token)}`;
- }
-
- function responseCachePolicy(url, fetchOptions = {}, options = {}) {
- const method = String(fetchOptions.method || 'GET').toUpperCase();
- const parsed = responseCacheUrl(url);
- if (method !== 'GET' || !parsed || parsed.origin !== BASE) return null;
- const path = parsed.pathname;
- const tags = new Set();
- let kind = 'responses';
- let freshFor = HOUR;
- let retainFor = 30 * DAY;
- let persist = true;
-
- const topicMatch = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/|\.json|$)/);
- const byNumberMatch = path.match(/^\/posts\/by_number\/(\d+)\/(\d+)\.json$/);
- const postMatch = path.match(/^\/posts\/(\d+)(?:\/replies)?\.json$/);
- const userMatch = path.match(/^\/u\/([^/]+)(?:\/[^/]+)?\.json$/);
- const bookmarkListMatch = path.match(/^\/u\/[^/]+\/bookmarks\.json$/);
- const givenReactionsMatch = /^\/discourse-reactions\/posts\/reactions(?:\.json)?$/.test(path);
- const userActionTypes = String(parsed.searchParams.get('filter') || '').split(',');
- const givenLikesMatch = path === '/user_actions.json' && userActionTypes.includes('1');
- const notificationFeedMatch = path === '/notifications.json' ||
- (path === '/user_actions.json' &&
- userActionTypes.some((type) => ['2', '6', '7', '9', '11', '17'].includes(type))) ||
- path === '/discourse-reactions/posts/reactions-received.json' ||
- /^\/discourse-boosts\/users\/[^/]+\/boosts-received\.json$/.test(path) ||
- /^\/topics\/private-messages(?:-(?:sent|new|unread|archive|warnings))?\/[^/]+\.json$/.test(path);
- if (topicMatch) tags.add(`topic:${topicMatch[1]}`);
- if (byNumberMatch) {
- tags.add(`topic:${byNumberMatch[1]}`);
- tags.add(`post-number:${byNumberMatch[1]}:${byNumberMatch[2]}`);
- }
- if (postMatch) tags.add(`post:${postMatch[1]}`);
- parsed.searchParams.getAll('post_ids[]').forEach((id) => tags.add(`post:${id}`));
- const queryPostId = Number(parsed.searchParams.get('post_id'));
- if (queryPostId) tags.add(`post:${queryPostId}`);
-
- if (notificationFeedMatch) {
- kind = 'notifications';
- freshFor = MINUTE;
- retainFor = 5 * MINUTE;
- persist = false;
- tags.add('notifications');
- } else if (bookmarkListMatch) {
- freshFor = MINUTE;
- tags.add('bookmarks');
- } else if (givenReactionsMatch || givenLikesMatch) {
- freshFor = MINUTE;
- tags.add('reactions-given');
- } else if (path === '/search.json') {
- freshFor = MINUTE;
- retainFor = 5 * MINUTE;
- persist = false;
- } else if (topicMatch || byNumberMatch || postMatch || path === '/post_voting/comments') {
- kind = 'topics';
- freshFor = path.includes('/replies.json') || path === '/post_voting/comments' ? 5 * MINUTE : 30 * MINUTE;
- retainFor = 180 * DAY;
- } else if (path.startsWith('/category-experts/endorsable-categories/')) {
- kind = 'users';
- freshFor = MINUTE;
- retainFor = 5 * MINUTE;
- persist = false;
- const endorsedUser = path.match(/^\/category-experts\/endorsable-categories\/([^/.]+)\.json$/);
- if (endorsedUser) tags.add(`user:${decodeURIComponent(endorsedUser[1]).toLocaleLowerCase()}`);
- } else if (userMatch || path.startsWith('/directory_items.json')) {
- kind = 'users';
- freshFor = 24 * HOUR;
- retainFor = 180 * DAY;
- if (userMatch) tags.add(`user:${decodeURIComponent(userMatch[1]).toLocaleLowerCase()}`);
- const directoryUsername = parsed.searchParams.get('username');
- if (directoryUsername) tags.add(`user:${directoryUsername.toLocaleLowerCase()}`);
- }
-
- const scope = responseCacheAuthScope();
- return {
- id: `${scope}|${parsed.href}`,
- kind,
- tags: [...tags],
- freshFor,
- retainFor,
- persist,
- cacheMode: options.cacheMode || 'default',
- };
- }
-
- async function getStoredResponseCacheEntry(id) {
- const cachedEntry = RESPONSE_CACHE_MEMORY.get(id);
- if (cachedEntry) {
- RESPONSE_CACHE_MEMORY.delete(id);
- RESPONSE_CACHE_MEMORY.set(id, cachedEntry);
- return cachedEntry;
- }
- const db = await openResponseCacheDatabase();
- if (!db) return null;
- let entry = null;
- await runResponseCacheTransaction(db, 'readonly', (store) => {
- const request = store.get(id);
- request.onsuccess = () => {
- entry = request.result || null;
- };
- });
- if (entry) rememberResponseCacheEntry(id, entry);
- return entry;
- }
-
- async function deleteStoredResponseCacheEntry(id) {
- RESPONSE_CACHE_MEMORY.delete(id);
- await queueStoredResponseCacheWrite(id, (store) => store.delete(id));
- publishResponseCacheMutation('response-delete', { token: sharedCacheIdToken(id) });
- }
-
- function storedResponseCacheEntry(policy, value, bytes = policy.bytes) {
- const storedAt = Date.now();
- return {
- id: policy.id, kind: policy.kind, tags: policy.tags, storedAt,
- expiresAt: storedAt + policy.retainFor,
- bytes: Number.isFinite(bytes) ? Math.max(0, Number(bytes)) : estimateCacheBytes(value),
- value,
- };
- }
-
- function queueStoredResponseCacheWrite(id, updateStore, afterWrite) {
- const previousWrite = RESPONSE_CACHE_WRITES.get(id) || Promise.resolve();
- return trackSingleFlightRequest(RESPONSE_CACHE_WRITES, id,
- previousWrite.catch(() => {}).then(() => openResponseCacheDatabase())
- .then((db) => db && runResponseCacheTransaction(db, 'readwrite', updateStore))
- .then((stored) => afterWrite ? afterWrite(stored) : stored).catch(() => null));
- }
-
- function setStoredResponseCacheEntry(policy, value) {
- const entry = storedResponseCacheEntry(policy, value);
- rememberResponseCacheEntry(policy.id, entry);
- if (!policy.persist) return Promise.resolve();
- if (!responseCachePersistenceRequested) {
- responseCachePersistenceRequested = true;
- if (isFunction(navigator.storage?.persist))
- navigator.storage.persist().catch(() => false);
- }
- return queueStoredResponseCacheWrite(policy.id, (store) => store.put(entry), (stored) => {
- if (stored) publishResponseCacheMutation('response-write', { token: sharedCacheIdToken(policy.id) });
- });
- }
-
- function topicSnapshotPolicy(topicId, bytes) {
- const safeTopicId = Number(topicId);
- if (!(safeTopicId > 0)) return null;
- return {
- id: `${responseCacheAuthScope()}|snapshot:topic:${safeTopicId}`,
- kind: 'topics',
- tags: [`topic:${safeTopicId}`],
- freshFor: TOPIC_CACHE_TTL,
- retainFor: PERSISTENT_CACHE_CONFIG.topics.maxAge,
- persist: true,
- bytes,
- };
- }
-
- function topicPostStream(topic) {
- return topic?.post_stream || {};
- }
-
- function estimateTopicSnapshotBytes(topic) {
- const postStream = topicPostStream(topic);
- const posts = Array.isArray(postStream.posts) ? postStream.posts : [];
- const stream = Array.isArray(postStream.stream) ? postStream.stream : [];
- let bytes = 4096 + stream.length * 16;
- posts.forEach((post) => {
- if (!post || typeof post !== 'object') return;
- bytes += 1024;
- ['cooked', 'raw', 'name', 'username', 'display_username', 'avatar_template'].forEach((key) => {
- bytes += String(post[key] || '').length * 2;
- });
- });
- return bytes;
- }
-
- function subscribeTopicCache(topicId, listener) {
- const key = String(Number(topicId) || '');
- if (!key || !isFunction(listener)) return () => {};
- if (!TOPIC_CACHE_SUBSCRIBERS.has(key)) TOPIC_CACHE_SUBSCRIBERS.set(key, new Set());
- const listeners = TOPIC_CACHE_SUBSCRIBERS.get(key);
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- if (!listeners.size) TOPIC_CACHE_SUBSCRIBERS.delete(key);
- };
- }
-
- function notifyTopicCacheSubscribers(topicId, topic) {
- const listeners = TOPIC_CACHE_SUBSCRIBERS.get(String(Number(topicId) || ''));
- if (!listeners || !listeners.size || !topic) return;
- listeners.forEach((listener) => {
- try { listener(topic); } catch {}
- });
- }
-
- function mergeStoredTopicSnapshot(policy, payload) {
- let finalEntry = null;
- let finalPayload = null;
- let mergedPosts = false;
- return queueStoredResponseCacheWrite(policy.id, (store) => {
- const request = store.get(policy.id);
- request.onsuccess = () => {
- const existing = request.result;
- const existingValue = existing?.value;
- const existingValid = existingValue && existingValue.version === TOPIC_SNAPSHOT_VERSION &&
- Number(existingValue.topicId) === Number(payload.topicId) && existingValue.topic;
- if (existingValid && Number(existingValue.updatedAt) > Number(payload.updatedAt)) {
- mergedPosts = mergeMissingSnapshotPosts(existingValue.topic, payload.topic);
- finalPayload = {
- ...existingValue,
- lastAccessAt: Math.max(Number(existingValue.lastAccessAt) || 0, Number(payload.lastAccessAt) || 0),
- topic: existingValue.topic,
- };
- } else {
- if (existingValid) mergedPosts = mergeMissingSnapshotPosts(payload.topic, existingValue.topic);
- finalPayload = payload;
- }
- finalEntry = storedResponseCacheEntry(
- policy, finalPayload, estimateTopicSnapshotBytes(finalPayload.topic)
- );
- try {
- store.put(finalEntry);
- } catch {
- finalEntry = null;
- finalPayload = null;
- }
- };
- request.onerror = () => {
- finalEntry = null;
- finalPayload = null;
- };
- }, (stored) => {
- if (stored && finalEntry) {
- rememberResponseCacheEntry(policy.id, finalEntry);
- publishResponseCacheMutation('response-write', { token: sharedCacheIdToken(policy.id) });
- }
- return stored && finalPayload ? { payload: finalPayload, mergedPosts } : null;
- });
- }
-
- async function flushTopicSnapshotWrite(topicId) {
- const key = String(Number(topicId) || '');
- const queued = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key);
- if (queued?.timer) clearTimeout(queued.timer);
- TOPIC_SNAPSHOT_WRITE_QUEUE.delete(key);
- const entry = TOPIC_DATA_CACHE.get(key);
- const scope = responseCacheAuthScope();
- if (!entry || entry.scope !== scope || !entry.topic) return;
- const policy = topicSnapshotPolicy(key, estimateTopicSnapshotBytes(entry.topic));
- if (!policy) return;
- const storedResult = await mergeStoredTopicSnapshot(policy, {
- version: TOPIC_SNAPSHOT_VERSION,
- topicId: Number(key),
- fetchedAt: Number(entry.fetchedAt) || Date.now(),
- lastAccessAt: Number(entry.lastAccessAt) || Date.now(),
- updatedAt: Number(entry.updatedAt) || Date.now(),
- topic: entry.topic,
- });
- if (!storedResult) return;
- const stored = storedResult.payload;
- if (stored.topic !== entry.topic || Number(stored.updatedAt) !== Number(entry.updatedAt)) {
- rememberCachedTopicData(key, stored.topic, {
- fetchedAt: stored.fetchedAt,
- lastAccessAt: stored.lastAccessAt,
- updatedAt: stored.updatedAt,
- full: true,
- notify: true,
- source: 'snapshot-merge',
- });
- } else if (storedResult.mergedPosts) {
- notifyTopicCacheSubscribers(key, stored.topic);
- }
- if (SHARED_REQUEST_COORDINATOR)
- SHARED_REQUEST_COORDINATOR.publishCache({
- action: 'topic-snapshot-write',
- topicId: Number(key),
- scopeToken: sharedCacheIdToken(scope),
- updatedAt: Number(stored.updatedAt) || Date.now(),
- });
- }
-
- function scheduleTopicSnapshotWrite(topicId) {
- const key = String(Number(topicId) || '');
- const entry = TOPIC_DATA_CACHE.get(key);
- if (!key || !entry || entry.scope !== responseCacheAuthScope()) return;
- const now = Date.now();
- const previous = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key);
- if (previous?.timer) clearTimeout(previous.timer);
- const firstQueuedAt = Number(previous?.firstQueuedAt) || now;
- const dueAt = Math.min(now + TOPIC_SNAPSHOT_WRITE_DELAY, firstQueuedAt + TOPIC_SNAPSHOT_WRITE_MAX_DELAY);
- const timer = setTimeout(() => { void flushTopicSnapshotWrite(key); }, Math.max(0, dueAt - now));
- TOPIC_SNAPSHOT_WRITE_QUEUE.set(key, { firstQueuedAt, timer });
- }
-
- function cancelTopicSnapshotWrites(topicId) {
- const keys = topicId == null
- ? [...TOPIC_SNAPSHOT_WRITE_QUEUE.keys()]
- : [String(Number(topicId) || '')];
- keys.forEach((key) => {
- const queued = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key);
- if (queued?.timer) clearTimeout(queued.timer);
- TOPIC_SNAPSHOT_WRITE_QUEUE.delete(key);
- });
- }
-
- function flushTopicSnapshotWrites() {
- return Promise.all(Array.from(TOPIC_SNAPSHOT_WRITE_QUEUE.keys(), (topicId) => (
- flushTopicSnapshotWrite(topicId)
- )));
- }
-
- function mergeMissingSnapshotPosts(targetTopic, sourceTopic) {
- const targetStream = targetTopic?.post_stream;
- const sourceStream = sourceTopic?.post_stream;
- if (!targetStream || !sourceStream) return false;
- const current = Array.isArray(targetStream.posts) ? targetStream.posts : [];
- const indexes = new Map(current.map((post, index) => [Number(post?.id), index]));
- let changed = false;
- (Array.isArray(sourceStream.posts) ? sourceStream.posts : []).forEach((post) => {
- const postId = Number(post?.id);
- if (!postId || indexes.has(postId)) return;
- indexes.set(postId, current.length);
- current.push(post);
- changed = true;
- });
- targetStream.posts = current;
- if ((!Array.isArray(targetStream.stream) || !targetStream.stream.length) && Array.isArray(sourceStream.stream)) {
- targetStream.stream = sourceStream.stream.slice();
- changed = true;
- }
- return changed;
- }
-
- async function getStoredTopicSnapshot(topicId, options = {}) {
- const policy = topicSnapshotPolicy(topicId);
- if (!policy) return null;
- if (options.preferStored === true) RESPONSE_CACHE_MEMORY.delete(policy.id);
- const stored = await getStoredResponseCacheEntry(policy.id);
- const value = stored?.value;
- const now = Date.now();
- const fetchedAt = Number(value?.fetchedAt) || Number(stored?.storedAt) || 0;
- if (!value || value.version !== TOPIC_SNAPSHOT_VERSION || Number(value.topicId) !== Number(topicId) ||
- !value.topic || !(fetchedAt > 0) || now - fetchedAt > PERSISTENT_CACHE_CONFIG.topics.maxAge) {
- if (stored) await deleteStoredResponseCacheEntry(policy.id);
- return null;
- }
- const key = String(Number(topicId));
- const current = TOPIC_DATA_CACHE.get(key);
- const storedUpdatedAt = Number(value.updatedAt) || Number(stored.storedAt) || fetchedAt;
- if (current && current.scope === responseCacheAuthScope() && current.topic) {
- const mergedIntoCurrent = mergeMissingSnapshotPosts(current.topic, value.topic);
- const currentUpdatedAt = Number(current.updatedAt) || 0;
- const currentWins = currentUpdatedAt > storedUpdatedAt ||
- (currentUpdatedAt === storedUpdatedAt && options.preferStored !== true);
- if (currentWins) {
- if (mergedIntoCurrent) notifyTopicCacheSubscribers(topicId, current.topic);
- return current.topic;
- }
- mergeMissingSnapshotPosts(value.topic, current.topic);
- }
- rememberCachedTopicData(topicId, value.topic, {
- fetchedAt,
- lastAccessAt: now,
- updatedAt: storedUpdatedAt,
- full: true,
- notify: true,
- source: options.preferStored === true ? 'shared-snapshot' : 'snapshot',
- });
- return value.topic;
- }
-
- function givenReactionSnapshotPolicy(username) {
- const normalizedUsername = String(username || '').trim().toLocaleLowerCase();
- if (!normalizedUsername) return null;
- return {
- id: `${responseCacheAuthScope()}|snapshot:reactions-given:${encodeURIComponent(normalizedUsername)}`,
- kind: 'responses',
- tags: ['reactions-given'],
- freshFor: MINUTE,
- retainFor: 30 * DAY,
- persist: true,
- };
- }
-
- async function getGivenReactionSnapshot(username) {
- const policy = givenReactionSnapshotPolicy(username);
- if (!policy) return null;
- const cached = await getStoredResponseCacheEntry(policy.id);
- if (!cached) return null;
- const storedAt = Number(cached.storedAt || 0);
- const age = Date.now() - storedAt;
- const value = cached.value || {};
- if (!(storedAt > 0) || age > policy.retainFor || value.version !== 1 || !Array.isArray(value.entries)) {
- await deleteStoredResponseCacheEntry(policy.id);
- return null;
- }
- return {
- entries: value.entries,
- storedAt,
- fresh: age <= policy.freshFor,
- };
- }
-
- function setGivenReactionSnapshot(username, entries) {
- const policy = givenReactionSnapshotPolicy(username);
- if (!policy) return;
- setStoredResponseCacheEntry(policy, {
- version: 1,
- entries: Array.isArray(entries) ? entries : [],
- });
- }
-
- function responseCacheCanFallback(error) {
- if (!error || error.name === 'AbortError') return false;
- const status = Number(error.status || 0);
- return !status || status === 408 || status === 429 || status >= 500;
- }
-
- async function requestCachedJSON(url, fetchOptions, options, networkRequest) {
- if (options.cacheMode === 'bypass') return networkRequest();
- const policy = responseCachePolicy(url, fetchOptions, options);
- if (!policy) return networkRequest();
- let cached = await getStoredResponseCacheEntry(policy.id);
- const cachedBeforeRequest = Number(cached?.storedAt || 0);
- let age = cached ? Date.now() - Number(cached.storedAt || 0) : Infinity;
- if (cached && age > policy.retainFor) {
- await deleteStoredResponseCacheEntry(policy.id);
- cached = null;
- age = Infinity;
- } else if (cached && policy.cacheMode !== 'refresh' && age <= policy.freshFor) {
- return markResponseCacheStoredAt(cached.value, cached.storedAt);
- }
-
- const requestKey = `${policy.cacheMode}:${policy.id}`;
- const pendingRequest = RESPONSE_CACHE_REQUESTS.get(requestKey);
- if (pendingRequest) return pendingRequest;
- const request = (async () => {
- const flightToken = policy.persist && SHARED_REQUEST_COORDINATOR
- ? sharedCacheIdToken(policy.id)
- : '';
- const readLatestSharedCache = async () => {
- RESPONSE_CACHE_MEMORY.delete(policy.id);
- const latest = await getStoredResponseCacheEntry(policy.id);
- const storedAt = Number(latest?.storedAt || 0);
- const latestAge = latest ? Date.now() - storedAt : Infinity;
- if (!latest || latestAge > policy.retainFor) return undefined;
- cached = latest;
- age = latestAge;
- const usable = policy.cacheMode === 'refresh'
- ? storedAt > cachedBeforeRequest
- : latestAge <= policy.freshFor;
- return usable ? markResponseCacheStoredAt(latest.value, storedAt) : undefined;
- };
- let ownedFlight = null;
- let flightHeartbeat = 0;
- try {
- if (flightToken) {
- const flightDeadline = Date.now() + SHARED_CACHE_FLIGHT_TTL * 2 + 5000;
- while (!ownedFlight) {
- if (Date.now() >= flightDeadline) {
- throw requestTimeoutError('等待共享缓存请求超时');
- }
- const flight = await SHARED_REQUEST_COORDINATOR.acquireCacheFlight(flightToken);
- if (!flight.producer) {
- const released = await SHARED_REQUEST_COORDINATOR.waitForCacheFlight(
- flightToken,
- fetchOptions.signal,
- flightDeadline
- );
- if (!released) {
- throw requestTimeoutError('等待共享缓存请求超时');
- }
- const shared = await readLatestSharedCache();
- if (shared !== undefined) return shared;
- continue;
- }
- ownedFlight = flight;
- flightHeartbeat = setInterval(() => {
- SHARED_REQUEST_COORDINATOR.renewCacheFlight(flightToken, flight.flightId).then((renewed) => {
- if (renewed || !flightHeartbeat) return;
- clearInterval(flightHeartbeat);
- flightHeartbeat = 0;
- }).catch(() => {});
- }, 15000);
- const lateCached = await readLatestSharedCache();
- if (lateCached !== undefined) return lateCached;
- }
- }
- const value = markResponseCacheStoredAt(await networkRequest(), Date.now());
- if (ownedFlight && SHARED_REQUEST_COORDINATOR) {
- await SHARED_REQUEST_COORDINATOR.writeCacheFlight(
- flightToken,
- ownedFlight.flightId,
- ownedFlight.cacheEpoch,
- () => setStoredResponseCacheEntry(policy, value)
- );
- } else {
- await setStoredResponseCacheEntry(policy, value);
- }
- return value;
- } catch (error) {
- if (options.allowStaleOnError !== false && cached && age <= policy.retainFor && responseCacheCanFallback(error)) {
- return markResponseCacheStoredAt(cached.value, cached.storedAt);
- }
- throw error;
- } finally {
- if (flightHeartbeat) clearInterval(flightHeartbeat);
- flightHeartbeat = 0;
- if (ownedFlight && SHARED_REQUEST_COORDINATOR) {
- await SHARED_REQUEST_COORDINATOR.releaseCacheFlight(flightToken, ownedFlight.flightId);
- }
- }
- })();
- return trackSingleFlightRequest(RESPONSE_CACHE_REQUESTS, requestKey, request);
- }
-
- async function scanStoredResponseCache(mode, visit) {
- const db = await openResponseCacheDatabase();
- if (!db) return false;
- return runResponseCacheTransaction(db, mode, (store) => {
- const request = store.openCursor();
- request.onsuccess = () => {
- const cursor = request.result;
- if (!cursor) return;
- visit(cursor.value || {}, cursor);
- cursor.continue();
- };
- });
- }
-
- function storedResponseCachePruneIds(entries, entryExpiresAt, now) {
- const removeIds = new Set();
- const entriesByKind = new Map();
- entries.forEach((entry) => {
- const id = String(entry?.id || '');
- if (!id) return;
- if (entryExpiresAt(entry) <= now) {
- removeIds.add(id);
- return;
- }
- const kind = PERSISTENT_CACHE_CONFIG[entry.kind] ? entry.kind : 'responses';
- let kindEntries = entriesByKind.get(kind);
- if (!kindEntries) {
- kindEntries = [];
- entriesByKind.set(kind, kindEntries);
- }
- kindEntries.push(entry);
- });
- entriesByKind.forEach((kindEntries, kind) => {
- const config = PERSISTENT_CACHE_CONFIG[kind] || PERSISTENT_CACHE_CONFIG.responses;
- const maxEntries = Math.max(0, Number(config.storedMaxEntries) || 0);
- const maxBytes = Math.max(0, Number(config.storedMaxBytes) || 0);
- if (!maxEntries && !maxBytes) return;
- let keptEntries = 0;
- let keptBytes = 0;
- kindEntries.sort((left, right) => Number(right?.storedAt || 0) - Number(left?.storedAt || 0));
- kindEntries.forEach((entry) => {
- const bytes = Math.max(0, Number(entry?.bytes) || 0);
- const exceedsEntries = maxEntries > 0 && keptEntries >= maxEntries;
- const exceedsBytes = maxBytes > 0 && keptBytes + bytes > maxBytes;
- if (exceedsEntries || exceedsBytes) {
- removeIds.add(String(entry.id));
- return;
- }
- keptEntries++;
- keptBytes += bytes;
- });
- });
- return removeIds;
- }
-
- async function pruneStoredResponseCache(force = false) {
- const now = Date.now();
- if (!force && now - responseCachePrunedAt < HOUR) return;
- if (responseCachePruneRequest) return responseCachePruneRequest;
- const entryExpiresAt = (entry) => {
- const explicit = Number(entry?.expiresAt);
- if (explicit > 0) return explicit;
- const id = String(entry?.id || '');
- const separator = id.indexOf('|');
- const policy = separator >= 0 ? responseCachePolicy(id.slice(separator + 1)) : null;
- return Number(entry?.storedAt || 0) +
- (policy ? policy.retainFor : PERSISTENT_CACHE_CONFIG.responses.maxAge);
- };
- responseCachePruneRequest = (async () => {
- const db = await openResponseCacheDatabase();
- if (!db) return;
- let scanned = false;
- const pruned = await runResponseCacheTransaction(db, 'readwrite', (store) => {
- const entries = [];
- const request = store.openCursor();
- request.onsuccess = () => {
- const cursor = request.result;
- if (cursor) {
- const entry = cursor.value || {};
- entries.push({
- id: entry.id,
- kind: entry.kind,
- storedAt: entry.storedAt,
- expiresAt: entry.expiresAt,
- bytes: entry.bytes,
- });
- cursor.continue();
- return;
- }
- storedResponseCachePruneIds(entries, entryExpiresAt, now).forEach((id) => {
- RESPONSE_CACHE_MEMORY.delete(id);
- store.delete(id);
- });
- scanned = true;
- };
- });
- if (!pruned || !scanned) return;
- responseCachePrunedAt = now;
- })().finally(() => {
- responseCachePruneRequest = null;
- });
- return responseCachePruneRequest;
- }
-
- async function clearStoredResponseCache({ kinds = [], tags = [], all = false } = {}) {
- if (SHARED_REQUEST_COORDINATOR)
- await SHARED_REQUEST_COORDINATOR.invalidateCacheWrites();
- if (RESPONSE_CACHE_WRITES.size) await Promise.all([...RESPONSE_CACHE_WRITES.values()]);
- const kindSet = new Set(kinds);
- const tagSet = new Set(tags);
- RESPONSE_CACHE_MEMORY.forEach((entry, id) => {
- if (all || kindSet.has(entry.kind) || (entry.tags || []).some((tag) => tagSet.has(tag)))
- RESPONSE_CACHE_MEMORY.delete(id);
- });
- const db = await openResponseCacheDatabase();
- if (db) {
- await runResponseCacheTransaction(db, 'readwrite', (store) => {
- if (all) {
- store.clear();
- } else {
- const lookups = [
- ...Array.from(kindSet, (kind) => [store.index('kind'), kind]),
- ...Array.from(tagSet, (tag) => [store.index('tags'), tag]),
- ];
- const ids = new Set();
- let pending = lookups.length;
- lookups.forEach(([index, value]) => {
- const request = index.getAllKeys(IDBKeyRange.only(value));
- request.onsuccess = () => {
- (request.result || []).forEach((id) => ids.add(id));
- pending--;
- if (!pending) ids.forEach((id) => store.delete(id));
- };
- request.onerror = () => {
- pending--;
- if (!pending) ids.forEach((id) => store.delete(id));
- };
- });
- }
- });
- }
- publishResponseCacheMutation('response-clear', { kinds, tags, all });
- }
-
- async function storedResponseCacheStats() {
- await pruneStoredResponseCache(true);
- const counts = {
- topics: { count: 0, bytes: 0 },
- users: { count: 0, bytes: 0 },
- notifications: { count: 0, bytes: 0 },
- responses: { count: 0, bytes: 0 },
- };
- let topicSnapshotCount = 0;
- let topicRequestCount = 0;
- const topicIds = new Set();
- const postIds = new Set();
- const userNames = new Set();
- const notificationIds = new Set();
- const responseRecords = { bookmarks: 0, reactions: 0, other: 0 };
- const result = () => ({
- topics: { ...counts.topics, topicIds: [...topicIds], postCount: postIds.size,
- topicSnapshotCount, topicRequestCount },
- users: { ...counts.users, userNames: [...userNames] },
- notifications: { ...counts.notifications, notificationIds: [...notificationIds] },
- responses: { ...counts.responses, responseRecords },
- });
- await scanStoredResponseCache('readonly', (entry) => {
- const stat = counts[entry.kind];
- if (stat) {
- stat.count++;
- stat.bytes += Math.max(0, Number(entry.bytes) || 0);
- const tags = new Set(Array.isArray(entry.tags) ? entry.tags.map(String) : []);
- if (entry.kind === 'topics') {
- tags.forEach((tag) => {
- const match = String(tag).match(/^topic:(\d+)$/);
- if (match) topicIds.add(Number(match[1]));
- });
- const value = entry.value;
- const isTopicSnapshot = String(entry.id || '').includes('|snapshot:topic:') ||
- !!(value && value.version === TOPIC_SNAPSHOT_VERSION && value.topicId && value.topic);
- if (isTopicSnapshot) topicSnapshotCount++;
- else topicRequestCount++;
- const snapshotTopic = isTopicSnapshot && value
- ? value.topic
- : value;
- const snapshotTopicId = Number(value?.topicId || snapshotTopic?.id || 0);
- if (snapshotTopicId > 0) topicIds.add(snapshotTopicId);
- postsFromPayload(snapshotTopic).forEach((post) => {
- const topicId = Number(post?.topic_id || snapshotTopicId || 0);
- if (topicId > 0) topicIds.add(topicId);
- const postId = Number(post?.id || 0);
- const postNumber = Number(post?.post_number || 0);
- if (postId > 0) postIds.add(`id:${postId}`);
- else if (topicId > 0 && postNumber > 0) postIds.add(`number:${topicId}:${postNumber}`);
- });
- } else if (entry.kind === 'users') {
- tags.forEach((tag) => {
- const match = tag.match(/^user:(.+)$/);
- if (match?.[1]) userNames.add(match[1].toLocaleLowerCase());
- });
- const value = entry.value || {};
- const candidates = [value.user, value];
- (Array.isArray(value.directory_items) ? value.directory_items : []).forEach((item) => {
- candidates.push(item && (item.user || item));
- });
- candidates.forEach((user) => {
- const username = cleanUsernameValue(user?.username).toLocaleLowerCase();
- if (username) userNames.add(username);
- });
- } else if (entry.kind === 'notifications') {
- const notifications = entry.value && Array.isArray(entry.value.notifications)
- ? entry.value.notifications
- : [];
- addNotificationStatsIdentities(notificationIds, notifications);
- } else if (entry.kind === 'responses') {
- if (tags.has('bookmarks')) responseRecords.bookmarks++;
- else if (tags.has('reactions-given')) responseRecords.reactions++;
- else responseRecords.other++;
- }
- }
- });
- return result();
- }
-
- function responseCacheMutationTags(url, params, data) {
- const parsed = responseCacheUrl(url);
- if (parsed && parsed.pathname === '/topics/timings') return [];
- const tags = new Set();
- const source = Object.assign({}, params || {}, data || {});
- const topicId = Number(source.topic_id || source.topicId ||
- (source.bookmarkable_type === 'Topic' && source.bookmarkable_id) || 0);
- const postId = Number(source.post_id || source.postId ||
- (source.bookmarkable_type === 'Post' && source.bookmarkable_id) ||
- (data?.post && data.post.id) || 0);
- if (topicId) tags.add(`topic:${topicId}`);
- if (postId) tags.add(`post:${postId}`);
- if (parsed) {
- const topicMatch = parsed.pathname.match(/^\/t\/(?:[^/]+\/)?(\d+)/);
- const postMatch = parsed.pathname.match(/^\/(?:posts|post_actions)\/(\d+)/);
- const reactionPostMatch = parsed.pathname.match(/^\/discourse-reactions\/posts\/(\d+)/);
- const userMatch = parsed.pathname.match(/^\/(?:u|follow|category-experts\/endorse)\/([^/.]+)/);
- if (topicMatch) tags.add(`topic:${topicMatch[1]}`);
- if (postMatch) tags.add(`post:${postMatch[1]}`);
- if (reactionPostMatch) {
- tags.add(`post:${reactionPostMatch[1]}`);
- tags.add('reactions-given');
- }
- if (parsed.pathname === '/post_actions' && Number(params?.id))
- tags.add(`post:${Number(params.id)}`);
- if (parsed.pathname === '/post_actions' || /^\/post_actions\/\d+/.test(parsed.pathname))
- tags.add('reactions-given');
- if (parsed.pathname === '/bookmarks' || parsed.pathname.startsWith('/bookmarks/') ||
- /\/remove_bookmarks$/.test(parsed.pathname)) tags.add('bookmarks');
- if (userMatch) tags.add(`user:${decodeURIComponent(userMatch[1]).toLocaleLowerCase()}`);
- if (parsed.pathname.startsWith('/notifications/')) tags.add('notifications');
- }
- return [...tags];
- }
-
- function scheduleIdleWork(callback, timeout = 1200) {
- let cancelled = false;
- const run = () => {
- if (!cancelled) callback();
- };
- if (isFunction(requestIdleCallback)) {
- const id = requestIdleCallback(run, { timeout });
- return () => {
- cancelled = true;
- if (isFunction(cancelIdleCallback)) cancelIdleCallback(id);
- };
- }
- const id = setTimeout(run, timeout);
- return () => {
- cancelled = true;
- clearTimeout(id);
- };
- }
-
- function trimPersistentCacheMap(key) {
- const cache = cacheMapForKey(key);
- const maxEntries = Number(PERSISTENT_CACHE_CONFIG[key]?.maxEntries) || 0;
- if (!cache || !maxEntries) return;
- while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
- }
-
- function topicCacheEntryTimestamp(entry) {
- return Number(entry?.fetchedAt) || Number(entry?.at) || 0;
- }
-
- function rememberCachedTopicData(topicId, topic, options = {}) {
- const key = String(Number(topicId) || '');
- if (!key || !topic) return null;
- const now = Date.now();
- const scope = String(options.scope || responseCacheAuthScope());
- const previous = TOPIC_DATA_CACHE.get(key);
- const sameScope = previous && previous.scope === scope ? previous : null;
- const entry = {
- topic,
- scope,
- fetchedAt: Number(options.fetchedAt) || topicCacheEntryTimestamp(sameScope) || now,
- lastAccessAt: Number(options.lastAccessAt) || now,
- updatedAt: Number(options.updatedAt) || now,
- source: String(options.source || sameScope?.source || 'memory'),
- };
- TOPIC_DATA_CACHE.delete(key);
- TOPIC_DATA_CACHE.set(key, entry);
- trimPersistentCacheMap('topics');
- if (options.notify !== false) notifyTopicCacheSubscribers(key, topic);
- return entry;
- }
-
- function getCachedTopicData(topicId) {
- const key = String(Number(topicId) || '');
- if (!key) return null;
- const now = Date.now();
- const scope = responseCacheAuthScope();
- let entry = TOPIC_DATA_CACHE.get(key);
- if (entry && entry.scope !== scope) {
- if (ME_USERNAME === null && String(entry.scope || '').startsWith('account:')) return null;
- const canRescopeHost = entry.source === 'host' &&
- now - topicCacheEntryTimestamp(entry) <= TOPIC_CACHE_TTL;
- if (canRescopeHost) {
- entry.scope = scope;
- entry.updatedAt = now;
- scheduleTopicSnapshotWrite(key);
- } else {
- TOPIC_DATA_CACHE.delete(key);
- entry = null;
- }
- }
- if (entry && now - topicCacheEntryTimestamp(entry) > PERSISTENT_CACHE_CONFIG.topics.maxAge) {
- TOPIC_DATA_CACHE.delete(key);
- entry = null;
- }
- if (!entry) {
- const hostEntry = HOST_TOPIC_DATA_CACHE.get(key);
- HOST_TOPIC_DATA_CACHE.delete(key);
- if (hostEntry && now - Number(hostEntry.at || 0) <= TOPIC_CACHE_TTL) {
- entry = rememberCachedTopicData(key, hostEntry.topic, {
- fetchedAt: Number(hostEntry.at) || now,
- source: 'host',
- });
- scheduleTopicSnapshotWrite(key);
- }
- }
- if (!entry) return null;
- TOPIC_DATA_CACHE.delete(key);
- entry.lastAccessAt = now;
- TOPIC_DATA_CACHE.set(key, entry);
- return entry.topic;
- }
-
- function setCachedTopicData(topicId, topic, options = {}) {
- const entry = rememberCachedTopicData(topicId, topic, options);
- if (entry) scheduleTopicSnapshotWrite(topicId);
- }
-
- function cleanUsernameValue(value) {
- return String(value || '').replace(/^@/, '').trim();
- }
-
- function authScopedCacheKey(key) {
- return `${responseCacheAuthScope()}|${key}`;
- }
-
- function getCachedUserCard(username) {
- const key = authScopedCacheKey(cleanUsernameValue(username));
- const entry = USER_CARD_CACHE.get(key);
- if (!entry) return null;
- if (Date.now() - Number(entry.at || 0) > PERSISTENT_CACHE_CONFIG.users.maxAge) {
- USER_CARD_CACHE.delete(key);
- schedulePersistentCacheWrite('users');
- return null;
- }
- USER_CARD_CACHE.delete(key);
- entry.at = Date.now();
- USER_CARD_CACHE.set(key, entry);
- return entry.user || null;
- }
-
- function setCachedUserCard(username, user, persistDelay = 250) {
- const cleanUsername = cleanUsernameValue(username);
- if (!cleanUsername || !user) return;
- const key = authScopedCacheKey(cleanUsername);
- USER_CARD_CACHE.delete(key);
- USER_CARD_CACHE.set(key, { user, at: Date.now() });
- trimPersistentCacheMap('users');
- schedulePersistentCacheWrite('users', persistDelay);
- }
-
- function cacheMapForKey(key) {
- if (key === 'topics') return TOPIC_DATA_CACHE;
- if (key === 'topicNavIcons') return TOPIC_NAV_ICON_CACHE;
- if (key === 'users') return USER_CARD_CACHE;
- if (key === 'notifications') return NOTIFICATION_CACHE;
- return null;
- }
-
- function readPersistentCacheEntries(key) {
- const config = PERSISTENT_CACHE_CONFIG[key];
- if (!config || !config.storageKey || key === 'history') return [];
- try {
- const data = JSON.parse(localStorage.getItem(config.storageKey) || 'null');
- if (!data || data.version !== PERSISTENT_CACHE_VERSION || !Array.isArray(data.entries)) return [];
- return data.entries;
- } catch {
- return [];
- }
- }
-
- function boundedPersistentEntries(key, entries) {
- const config = PERSISTENT_CACHE_CONFIG[key];
- if (!config) return [];
- const now = Date.now();
- const authPrefix = ME_USERNAME === null
- ? ''
- : key === 'notifications'
- ? authScopedCacheKey('tabs-v7:')
- : key === 'users'
- ? authScopedCacheKey('')
- : '';
- const bounded = (Array.isArray(entries) ? entries : []).filter((entry) => {
- if (!Array.isArray(entry) || entry.length !== 2 || entry[0] == null || !entry[1]) return false;
- if (authPrefix && !String(entry[0]).startsWith(authPrefix)) return false;
- return Number.isFinite(Number(entry[1].at)) && now - Number(entry[1].at) <= config.maxAge;
- });
- return config.maxEntries ? bounded.slice(-config.maxEntries) : bounded;
- }
-
- function isStorageQuotaError(error) {
- return !!error && (error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
- error.code === 22 || error.code === 1014);
- }
-
- function persistCacheType(key) {
- const config = PERSISTENT_CACHE_CONFIG[key];
- const cache = cacheMapForKey(key);
- if (!config || !cache) return;
- const merged = new Map(readPersistentCacheEntries(key).map(([entryKey, value]) => [String(entryKey), value]));
- cache.forEach((value, entryKey) => {
- const normalizedKey = String(entryKey);
- const previous = merged.get(normalizedKey);
- if (!previous || Number(value?.at || 0) >= Number(previous?.at || 0)) {
- merged.delete(normalizedKey);
- merged.set(normalizedKey, value);
- }
- });
- const entries = boundedPersistentEntries(key, [...merged.entries()]);
- while (true) {
- try {
- if (entries.length) localStorage.setItem(config.storageKey, JSON.stringify({ version: PERSISTENT_CACHE_VERSION, entries }));
- else localStorage.removeItem(config.storageKey);
- break;
- } catch (error) {
- if (!isStorageQuotaError(error) || !entries.length) return;
- entries.shift();
- }
- }
- cache.clear();
- entries.forEach(([entryKey, value]) => cache.set(String(entryKey), value));
- if (SHARED_REQUEST_COORDINATOR)
- SHARED_REQUEST_COORDINATOR.publishCache({ action: 'map-update', key });
- }
-
- function flushPersistentCacheWrites() {
- if (cancelPersistentCacheWrite) {
- const cancel = cancelPersistentCacheWrite;
- cancelPersistentCacheWrite = null;
- cancel();
- }
- persistentCacheWriteDueAt = 0;
- const keys = [...PERSISTENT_CACHE_WRITE_KEYS];
- PERSISTENT_CACHE_WRITE_KEYS.clear();
- keys.forEach(persistCacheType);
- }
-
- function schedulePersistentCacheWrite(key, delay = 2000) {
- if (!cacheMapForKey(key) || !PERSISTENT_CACHE_CONFIG[key]?.storageKey) return;
- PERSISTENT_CACHE_WRITE_KEYS.add(key);
- const safeDelay = Math.max(0, Number(delay) || 0);
- const dueAt = Date.now() + safeDelay;
- if (cancelPersistentCacheWrite && persistentCacheWriteDueAt <= dueAt) return;
- if (cancelPersistentCacheWrite) cancelPersistentCacheWrite();
- persistentCacheWriteDueAt = dueAt;
- cancelPersistentCacheWrite = scheduleIdleWork(() => {
- cancelPersistentCacheWrite = null;
- persistentCacheWriteDueAt = 0;
- flushPersistentCacheWrites();
- }, safeDelay);
- }
-
- function restorePersistentCaches() {
- try { localStorage.removeItem(LDP_TOPIC_CACHE_KEY); } catch {}
- ['topicNavIcons', 'users', 'notifications'].forEach((key) => {
- const cache = cacheMapForKey(key);
- if (!cache) return;
- const rawEntries = readPersistentCacheEntries(key);
- const entries = boundedPersistentEntries(key, rawEntries);
- entries.forEach(([entryKey, value]) => cache.set(String(entryKey), value));
- if (key === 'notifications') {
- entries.forEach(([, entry]) => {
- const notifications = entry?.value && entry.value.notifications;
- if (!Array.isArray(notifications)) return;
- notifications.forEach((notification) => {
- const data = notificationData(notification);
- const template = notification.acting_user_avatar_template || notification.avatar_template ||
- data.acting_user_avatar_template || data.avatar_template || '';
- const actor = notificationActor(notification);
- if (actor && template) rememberNotificationAvatar(actor, template);
- });
- });
- }
- if (entries.length !== rawEntries.length) schedulePersistentCacheWrite(key);
- });
- }
-
- function mergeSharedPersistentCache(key) {
- const cache = cacheMapForKey(key);
- if (!cache) return;
- let changed = false;
- boundedPersistentEntries(key, readPersistentCacheEntries(key)).forEach(([entryKey, value]) => {
- const normalizedKey = String(entryKey);
- const current = cache.get(normalizedKey);
- if (!current || Number(value?.at || 0) > Number(current?.at || 0)) {
- cache.delete(normalizedKey);
- cache.set(normalizedKey, value);
- changed = true;
- }
- });
- trimPersistentCacheMap(key);
- if (changed) schedulePersistentCacheWrite(key, 0);
- }
-
- function clearPersistentCacheMaps(keys) {
- (Array.isArray(keys) ? keys : []).forEach((key) => {
- if (key === 'topics') {
- cancelTopicSnapshotWrites();
- HOST_TOPIC_DATA_CACHE.clear();
- }
- const cache = cacheMapForKey(String(key));
- if (cache) cache.clear();
- if (key === 'users') {
- USER_CARD_REQUESTS.clear();
- USER_FOLLOW_LIST_CACHE.clear();
- USER_FOLLOW_LIST_REQUESTS.clear();
- }
- if (key === 'notifications') NOTIFICATION_REQUESTS.clear();
- });
- }
-
- function applySharedCacheMessage(message) {
- const action = String(message?.action || '');
- if (action === 'topic-snapshot-write') {
- const topicId = Number(message.topicId);
- if (!(topicId > 0) || String(message.scopeToken || '') !== sharedCacheIdToken(responseCacheAuthScope())) return;
- const policy = topicSnapshotPolicy(topicId);
- if (policy) RESPONSE_CACHE_MEMORY.delete(policy.id);
- void getStoredTopicSnapshot(topicId, { preferStored: true });
- return;
- }
- if (action === 'map-update') {
- mergeSharedPersistentCache(String(message.key || ''));
- return;
- }
- if (action === 'map-delete' && message.key === 'topics') {
- const key = String(message.entryKey || '');
- TOPIC_DATA_CACHE.delete(key);
- HOST_TOPIC_DATA_CACHE.delete(key);
- cancelTopicSnapshotWrites(key);
- return;
- }
- if (action === 'map-clear') {
- clearPersistentCacheMaps(message.keys);
- return;
- }
- if (action === 'response-write' || action === 'response-delete') {
- const token = String(message.token || '');
- for (const id of RESPONSE_CACHE_MEMORY.keys()) {
- if (token && sharedCacheIdToken(id) === token) RESPONSE_CACHE_MEMORY.delete(id);
- }
- return;
- }
- if (action === 'response-clear') {
- const kindSet = new Set(Array.isArray(message.kinds) ? message.kinds : []);
- const tagSet = new Set(Array.isArray(message.tags) ? message.tags : []);
- RESPONSE_CACHE_MEMORY.forEach((entry, id) => {
- if (message.all || kindSet.has(entry.kind) || (entry.tags || []).some((tag) => tagSet.has(tag)))
- RESPONSE_CACHE_MEMORY.delete(id);
- });
- }
- }
-
- function readTopicHistory() {
- try {
- const data = JSON.parse(localStorage.getItem(LDP_HISTORY_KEY) || '[]');
- if (!Array.isArray(data)) return [];
- const config = PERSISTENT_CACHE_CONFIG.history;
- const safe = data.filter((entry) => entry && Number(entry.topicId) > 0 &&
- Number(entry.viewedAt || 0) >= Date.now() - config.maxAge);
- if (safe.length !== data.length) return writeTopicHistory(safe);
- return safe;
- } catch {
- return [];
- }
- }
-
- function historyEntryFirstViewedAt(entry) {
- return Number(entry?.firstViewedAt || entry?.viewedAt) || 0;
- }
-
- function historyEntrySortTime(entry) {
- return PREFS.historySortMode === 'recent-viewed'
- ? Number(entry?.viewedAt) || 0
- : historyEntryFirstViewedAt(entry);
- }
-
- function orderedTopicHistory(entries = readTopicHistory()) {
- return entries.slice().sort((left, right) =>
- historyEntrySortTime(right) - historyEntrySortTime(left) ||
- Number(right.topicId || 0) - Number(left.topicId || 0)
- );
- }
-
- function writeTopicHistory(entries) {
- const safeEntries = Array.isArray(entries) ? entries.slice() : [];
- while (true) {
- try {
- if (safeEntries.length) localStorage.setItem(LDP_HISTORY_KEY, JSON.stringify(safeEntries));
- else localStorage.removeItem(LDP_HISTORY_KEY);
- syncReaderQueueReadHistory(safeEntries);
- return safeEntries;
- } catch (error) {
- if (!isStorageQuotaError(error) || !safeEntries.length) return safeEntries;
- safeEntries.pop();
- }
- }
- }
-
- function normalizeReadPostNumbers(values) {
- const source = values instanceof Set ? [...values] : (Array.isArray(values) ? values : []);
- return [...new Set(source
- .map((value) => Math.floor(Number(value) || 0))
- .filter((value) => value > 0))]
- .sort((left, right) => left - right);
- }
-
- function historyEntryReadPostNumbers(entry) {
- return normalizeReadPostNumbers(entry?.readPostNumbers);
- }
-
- function rememberTopic(topic, postNumber = 0, readPostNumbers = []) {
- const topicId = Number(topic?.id);
- if (!topicId) return;
- const history = readTopicHistory();
- const previousEntry = history.find((item) => Number(item.topicId) === topicId);
- const viewedPostNumber = Math.max(
- 1,
- Math.floor(Number(postNumber) || Number(previousEntry?.postNumber) || 1),
- );
- const cumulativeReadPostNumbers = normalizeReadPostNumbers([
- ...historyEntryReadPostNumbers(previousEntry),
- ...normalizeReadPostNumbers(readPostNumbers),
- viewedPostNumber,
- ]);
- const posts = topic?.post_stream && Array.isArray(topic.post_stream.posts)
- ? topic.post_stream.posts : [];
- const firstPost = posts.find((post) => Number(post?.post_number) === 1);
- const viewedAt = Date.now();
- const entry = {
- topicId,
- title: String(topic.title || `帖子 #${topicId}`),
- postsCount: Math.max(0, Number(topic.posts_count) || 0),
- avatarTemplate: String(
- (topic.details?.created_by && topic.details.created_by.avatar_template) ||
- (firstPost?.avatar_template) || ''
- ),
- ownerUsername: String(
- (topic.details?.created_by && topic.details.created_by.username) ||
- (firstPost?.username) || topic._opUsername || ''
- ),
- postNumber: viewedPostNumber,
- readPostNumbers: cumulativeReadPostNumbers,
- firstViewedAt: historyEntryFirstViewedAt(previousEntry) || viewedAt,
- viewedAt,
- };
- writeTopicHistory([entry, ...history.filter((item) => Number(item.topicId) !== topicId)]);
- const readerShell = CURRENT_OVERLAY?._ldpReaderShell;
- if (isFunction(readerShell?.refreshHistoryPanel))
- readerShell.refreshHistoryPanel();
- }
-
- function normalizeReaderHistoryViewport(value) {
- const source = value && typeof value === 'object' ? value : { postNumber: value };
- const postNumber = Math.floor(Number(source.postNumber));
- if (!(postNumber > 0)) return null;
- const postOffset = Number(source.postOffset);
- const scrollTop = Number(source.scrollTop);
- return {
- postNumber,
- postOffset: Number.isFinite(postOffset) ? postOffset : 0,
- scrollTop: Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0,
- };
- }
-
- function normalizeReaderAnchorPoint(value) {
- const number = Math.floor(Number(value?.number));
- if (!(number > 0)) return null;
- return {
- number,
- scrollTop: Math.max(0, Number(value.scrollTop) || 0),
- offset: Number.isFinite(Number(value.offset)) ? Number(value.offset) : 12,
- };
- }
-
- function normalizeReaderReplyWindowState(value) {
- const rootPostNumber = Math.floor(Number(value?.rootPostNumber));
- return rootPostNumber > 0 ? {
- rootPostNumber,
- point: normalizeReaderAnchorPoint(value.point),
- } : null;
- }
-
- function normalizeReaderQuoteSource(value) {
- const topicId = Math.floor(Number(value?.topicId));
- const postNumber = Math.floor(Number(value?.postNumber));
- if (!(topicId > 0) || !(postNumber > 0)) return null;
- const parentPostNumber = Math.floor(Number(value?.parentPostNumber));
- const anchorViewport = normalizeReaderHistoryViewport(value?.anchor?.viewport);
- return {
- topicId,
- postNumber,
- parentPostNumber: parentPostNumber > 0 ? parentPostNumber : 0,
- nested: value?.nested === true && parentPostNumber > 0,
- anchor: anchorViewport ? {
- viewport: anchorViewport,
- replyWindow: normalizeReaderReplyWindowState(value.anchor.replyWindow),
- quoteHighlight: null,
- } : null,
- };
- }
-
- function normalizeReaderQuoteHighlightState(value) {
- const postNumber = Math.floor(Number(value?.postNumber));
- const text = String(value?.text || value?.quoteText || '');
- return postNumber > 0 && text ? {
- postNumber,
- text,
- source: normalizeReaderQuoteSource(value.source),
- active: value.active !== false,
- } : null;
- }
-
- function normalizeReaderAnchorState(value) {
- const source = value && typeof value === 'object' && 'viewport' in value
- ? value : { viewport: value };
- const viewport = normalizeReaderHistoryViewport(source.viewport);
- if (!viewport) return null;
- return {
- viewport,
- replyWindow: normalizeReaderReplyWindowState(source.replyWindow),
- quoteHighlight: normalizeReaderQuoteHighlightState(source.quoteHighlight),
- };
- }
-
- function normalizeReaderHistoryStates(values) {
- const states = {};
- if (!values || typeof values !== 'object') return states;
- Object.entries(values).forEach(([entryTopicId, value]) => {
- const safeTopicId = Number(entryTopicId);
- const state = normalizeReaderAnchorState(value);
- if (safeTopicId > 0 && state) states[safeTopicId] = state;
- });
- return states;
- }
-
- function captureReaderAnchorState(overlay, fallbackPostNumber = 1) {
- const ctx = overlay?._ldpReaderShell?.activeContext;
- const viewport = normalizeReaderHistoryViewport(
- (isFunction(overlay?._ldpCaptureHistoryViewport)
- ? overlay._ldpCaptureHistoryViewport()
- : null) || embeddedReaderViewportState(ctx) || fallbackPostNumber
- );
- if (!viewport) return null;
- return {
- viewport,
- replyWindow: normalizeReaderReplyWindowState(
- isFunction(ctx?.replyThreadWindow?._ldpCaptureAnchorState)
- ? ctx.replyThreadWindow._ldpCaptureAnchorState()
- : null
- ),
- quoteHighlight: normalizeReaderQuoteHighlightState(ctx?.quoteHighlightState),
- };
- }
-
- function normalizeHistoryEdgeTriggerPercent(value) {
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) return HISTORY_EDGE_TRIGGER_DEFAULT;
- return Math.min(HISTORY_EDGE_TRIGGER_MAX, Math.max(HISTORY_EDGE_TRIGGER_MIN, Math.round(numeric)));
- }
-
- function createReaderHistoryNavigation(topicId, source) {
- const currentTopicId = Number(topicId);
- const normalize = (values) => [...new Set((Array.isArray(values) ? values : [])
- .map(Number).filter((id) => id > 0 && id !== currentTopicId))];
- if (source && (Array.isArray(source.back) || Array.isArray(source.forward))) {
- return {
- back: normalize(source.back),
- forward: normalize(source.forward),
- states: normalizeReaderHistoryStates(source.states),
- };
- }
- const orderedTopicIds = orderedTopicHistory().map((entry) => Number(entry.topicId)).filter((id) => id > 0);
- const currentIndex = orderedTopicIds.indexOf(currentTopicId);
- const useFixedPosition = PREFS.historySortMode === 'first-viewed' && currentIndex >= 0;
- return {
- back: normalize(useFixedPosition ? orderedTopicIds.slice(currentIndex + 1) : orderedTopicIds),
- forward: normalize(useFixedPosition ? orderedTopicIds.slice(0, currentIndex).reverse() : []),
- states: {},
- };
- }
-
- function bindReaderHistoryNavigation(overlay, topicId, navigation) {
- const backButton = overlay.querySelector('.ldp-reader-history-back');
- const forwardButton = overlay.querySelector('.ldp-reader-history-forward');
- if (!backButton || !forwardButton) return () => {};
- const backEdge = backButton.closest('.ldp-reader-history-edge');
- const forwardEdge = forwardButton.closest('.ldp-reader-history-edge');
- const modal = overlay.querySelector('.ldp-modal');
- const historyScope = createLifecycleScope();
- let modalPointerBounds = null;
- let backEdgeActive = false;
- let forwardEdgeActive = false;
- const setActiveEdges = (backActive, forwardActive) => {
- if (backEdgeActive !== backActive) {
- backEdgeActive = backActive;
- backEdge?.classList.toggle('is-active', backActive);
- }
- if (forwardEdgeActive !== forwardActive) {
- forwardEdgeActive = forwardActive;
- forwardEdge?.classList.toggle('is-active', forwardActive);
- }
- };
- const clearActiveEdge = () => {
- setActiveEdges(false, false);
- };
- const invalidateModalPointerBounds = () => {
- modalPointerBounds = null;
- };
- const refreshModalPointerBounds = () => {
- const triggerPercent = normalizeHistoryEdgeTriggerPercent(PREFS.historyEdgeTriggerPercent);
- if (triggerPercent <= 0) {
- modalPointerBounds = { backLimit: 0, forwardLimit: 0, enabled: false };
- return modalPointerBounds;
- }
- const rect = modal.getBoundingClientRect();
- const triggerWidth = rect.width * triggerPercent / 100;
- modalPointerBounds = {
- backLimit: rect.left + triggerWidth,
- forwardLimit: rect.right - triggerWidth,
- enabled: true,
- };
- return modalPointerBounds;
- };
- const syncVisibilityPreference = () => {
- const alwaysVisible = PREFS.historyButtonsAlwaysVisible === true;
- overlay.classList.toggle('ldp-history-buttons-always-visible', alwaysVisible);
- invalidateModalPointerBounds();
- if (alwaysVisible) clearActiveEdge();
- };
- const onModalPointerMove = (event) => {
- if (!modal || PREFS.historyButtonsAlwaysVisible === true || (event.pointerType && event.pointerType !== 'mouse')) {
- clearActiveEdge();
- return;
- }
- const bounds = modalPointerBounds || refreshModalPointerBounds();
- const nearBackEdge = bounds.enabled && event.clientX <= bounds.backLimit;
- const nearForwardEdge = bounds.enabled && event.clientX >= bounds.forwardLimit;
- const target = event.target instanceof Element ? event.target : null;
- const overBackButton = !!target && backButton.contains(target);
- const overForwardButton = !!target && forwardButton.contains(target);
- if (!nearBackEdge && !nearForwardEdge && !overBackButton && !overForwardButton) {
- clearActiveEdge();
- return;
- }
- if (target?.closest('.ldp-settings-popover,.ldp-notifications-popover,.ldp-history-popover,.ldp-bookmarks-popover')) {
- clearActiveEdge();
- return;
- }
- setActiveEdges(
- nearBackEdge || overBackButton,
- nearForwardEdge || overForwardButton,
- );
- };
- const onModalPointerEnter = (event) => {
- if (PREFS.historyButtonsAlwaysVisible !== true &&
- (!event.pointerType || event.pointerType === 'mouse')) refreshModalPointerBounds();
- };
- const onModalPointerLeave = () => {
- clearActiveEdge();
- invalidateModalPointerBounds();
- };
- if (modal) historyScope.listen(modal, 'pointerenter', onModalPointerEnter);
- if (modal) historyScope.listen(modal, 'pointermove', onModalPointerMove);
- if (modal) historyScope.listen(modal, 'pointerleave', onModalPointerLeave);
- historyScope.listen(window, 'resize', invalidateModalPointerBounds);
- historyScope.listen(overlay, 'ldp-reader-window-change', invalidateModalPointerBounds);
- historyScope.listen(overlay, 'ldp-reader-workspace-change', invalidateModalPointerBounds);
- overlay._ldpSyncHistoryButtonVisibilityPreference = syncVisibilityPreference;
- syncVisibilityPreference();
- backButton.setAttribute('aria-keyshortcuts', 'ArrowLeft');
- forwardButton.setAttribute('aria-keyshortcuts', 'ArrowRight');
- const topicTitle = (targetTopicId) =>
- readTopicHistory().find((item) => Number(item.topicId) === Number(targetTopicId))?.title ||
- `帖子 #${targetTopicId}`;
- const refreshVisibleTooltip = (button) => {
- refreshTooltips(button);
- };
- const sync = () => {
- // The reader shell can outlive a topic session; its prior navigation may still be busy.
- backButton.disabled = false;
- forwardButton.disabled = false;
- const backTarget = navigation.back[0];
- const forwardTarget = navigation.forward[0];
- backButton.hidden = !backTarget;
- forwardButton.hidden = !forwardTarget;
- if (backEdge) backEdge.hidden = !backTarget;
- if (forwardEdge) forwardEdge.hidden = !forwardTarget;
- if (backTarget) {
- const label = `上一条历史:${topicTitle(backTarget)}`;
- setLabel(backButton, label);
- }
- if (forwardTarget) {
- const label = `下一条历史:${topicTitle(forwardTarget)}`;
- setLabel(forwardButton, label);
- }
- refreshVisibleTooltip(backButton);
- refreshVisibleTooltip(forwardButton);
- };
- const navigate = (direction) => {
- const backward = direction === 'back';
- const source = backward ? navigation.back : navigation.forward;
- const targetTopicId = source[0];
- if (!targetTopicId) return;
- backButton.disabled = true;
- forwardButton.disabled = true;
- const currentPostNumber = Number(
- overlay.querySelector('.ldp-topic-timeline-track')?.getAttribute('aria-valuenow')
- ) || 1;
- const states = {
- ...navigation.states,
- [Number(topicId)]: captureReaderAnchorState(overlay, currentPostNumber),
- };
- const targetState = normalizeReaderAnchorState(states[targetTopicId]);
- const nextNavigation = backward
- ? { back: navigation.back.slice(1), forward: [Number(topicId), ...navigation.forward], states }
- : { back: [Number(topicId), ...navigation.back], forward: navigation.forward.slice(1), states };
- openModal(targetTopicId, {
- targetPostNumber: targetState?.viewport.postNumber || 0,
- restoreState: targetState || undefined,
- historyNavigation: nextNavigation,
- settleTargetJump: true,
- });
- };
- const onBackHover = () => refreshVisibleTooltip(backButton);
- const onForwardHover = () => refreshVisibleTooltip(forwardButton);
- historyScope.listen(backButton, 'click', () => navigate('back'));
- historyScope.listen(forwardButton, 'click', () => navigate('forward'));
- historyScope.listen(backButton, 'pointerenter', onBackHover);
- historyScope.listen(forwardButton, 'pointerenter', onForwardHover);
- const onHistoryKeyDown = (event) => {
- if (CURRENT_OVERLAY !== overlay || !overlay.isConnected || event.defaultPrevented || event.isComposing ||
- event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || readerPortalQuery('.ldp-lightbox')) return;
- const button = event.key === 'ArrowLeft' ? backButton : event.key === 'ArrowRight' ? forwardButton : null;
- if (!button || button.hidden || button.disabled) return;
- const target = event.target instanceof Element ? event.target : null;
- if (target && target !== document.body && target !== PAGE_ROOT && !overlay.contains(target)) return;
- if (target && (target.isContentEditable || target.closest('input,textarea,select,.ldp-topic-timeline-track'))) return;
- consumeEvent(event);
- button.click();
- };
- historyScope.listen(document, 'keydown', onHistoryKeyDown);
- const refreshNavigationOrder = () => {
- const nextNavigation = createReaderHistoryNavigation(topicId);
- navigation.back = nextNavigation.back;
- navigation.forward = nextNavigation.forward;
- sync();
- };
- overlay._ldpRefreshReaderHistoryNavigation = refreshNavigationOrder;
- sync();
- return () => {
- clearActiveEdge();
- historyScope.destroy();
- if (overlay._ldpRefreshReaderHistoryNavigation === refreshNavigationOrder)
- delete overlay._ldpRefreshReaderHistoryNavigation;
- if (overlay._ldpSyncHistoryButtonVisibilityPreference === syncVisibilityPreference)
- delete overlay._ldpSyncHistoryButtonVisibilityPreference;
- };
- }
-
- function readerQueueEntry(topicId) {
- const safeTopicId = Number(topicId);
- return safeTopicId > 0 ? READER_QUEUE_BY_TOPIC.get(String(safeTopicId)) || null : null;
- }
-
- function normalizeReaderQueueSurfaceState(state) {
- const rawX = state?.x;
- const rawY = state?.y;
- const x = Number(rawX);
- const y = Number(rawY);
- const hasPosition = rawX != null && rawX !== '' && rawY != null && rawY !== '' &&
- Number.isFinite(x) && Number.isFinite(y);
- return {
- x: hasPosition ? Math.max(0, Math.min(1, x)) : null,
- y: hasPosition ? Math.max(0, Math.min(1, y)) : null,
- dock: hasPosition && ['left', 'right'].includes(state?.dock)
- ? state.dock
- : '',
- };
- }
-
- function readerQueueSurfaceStateCustomized(state = READER_QUEUE_SURFACE_STATE) {
- return Number.isFinite(state?.x) && Number.isFinite(state?.y);
- }
-
- function flushReaderQueueState() {
- if (READER_QUEUE_PERSIST_TIMER) clearTimeout(READER_QUEUE_PERSIST_TIMER);
- READER_QUEUE_PERSIST_TIMER = 0;
- if (READER_QUEUE_RESTORING) return;
- const entries = READER_QUEUE_ENTRIES.map((entry) => {
- const avatarSource = String(entry?.avatarSource || '');
- return {
- topicId: Number(entry?.topicId) || 0,
- title: String(entry?.title || ''),
- avatarTemplate: String(entry?.avatarTemplate || ''),
- avatarSource: /^https?:\/\//i.test(avatarSource) ? avatarSource : '',
- ownerUsername: String(entry?.ownerUsername || ''),
- addedAt: Math.max(0, Number(entry?.addedAt) || 0),
- pinned: entry && entry.pinned === true,
- totalCount: Math.max(0, Number(entry?.totalCount) || 0),
- urlPostNumber: Math.max(0, Number(entry?.urlPostNumber) || 0),
- anchor: normalizeReaderAnchorState(entry?.anchor),
- };
- })
- .filter((entry) => entry.topicId > 0);
- const surface = normalizeReaderQueueSurfaceState(READER_QUEUE_SURFACE_STATE);
- try {
- if (entries.length || readerQueueSurfaceStateCustomized(surface))
- localStorage.setItem(LDP_READER_QUEUE_KEY, JSON.stringify({ version: 1, entries, surface }));
- else
- localStorage.removeItem(LDP_READER_QUEUE_KEY);
- } catch (error) {
- console.warn('[LDP] 阅读队列保存失败', error?.name || 'storage-error');
- }
- }
-
- function scheduleReaderQueueStatePersist() {
- if (READER_QUEUE_RESTORING || READER_QUEUE_PERSIST_TIMER) return;
- READER_QUEUE_PERSIST_TIMER = setTimeout(flushReaderQueueState, 250);
- }
-
- function restoreReaderQueueState() {
- let storedEntries = [];
- let storedSurface = null;
- try {
- const payload = JSON.parse(localStorage.getItem(LDP_READER_QUEUE_KEY) || 'null');
- storedEntries = Array.isArray(payload) ? payload : (Array.isArray(payload?.entries) ? payload.entries : []);
- storedSurface = !Array.isArray(payload) && payload && payload.surface;
- } catch {
- return;
- }
- READER_QUEUE_SURFACE_STATE = normalizeReaderQueueSurfaceState(storedSurface);
- if (!storedEntries.length) return;
- const restoredTopicIds = new Set();
- READER_QUEUE_RESTORING = true;
- try {
- storedEntries.forEach((storedEntry) => {
- const topicId = Number(storedEntry?.topicId);
- if (!(topicId > 0) || restoredTopicIds.has(topicId)) return;
- restoredTopicIds.add(topicId);
- const entry = ensureReaderQueueEntry(topicId, {
- title: storedEntry.title,
- avatarTemplate: storedEntry.avatarTemplate,
- avatarSource: storedEntry.avatarSource,
- ownerUsername: storedEntry.ownerUsername,
- }, { prefetch: false, silent: true });
- if (!entry) return;
- entry.addedAt = Math.max(0, Number(storedEntry.addedAt) || 0) || entry.addedAt;
- entry.pinned = storedEntry.pinned === true;
- entry.totalCount = Math.max(entry.totalCount, Number(storedEntry.totalCount) || 0);
- entry.urlPostNumber = Math.max(0, Number(storedEntry.urlPostNumber) || 0);
- entry.anchor = normalizeReaderAnchorState(storedEntry.anchor || storedEntry.viewport) || entry.anchor;
- });
- } finally {
- READER_QUEUE_RESTORING = false;
- }
- }
-
- function readerQueueTopicSummary(topicId, metadata = {}, topic = null) {
- const safeTopicId = Number(topicId);
- const cachedTopic = topic || getCachedTopicData(safeTopicId);
- const historyEntry = readTopicHistory().find((item) => Number(item.topicId) === safeTopicId);
- const posts = cachedTopic?.post_stream && Array.isArray(cachedTopic.post_stream.posts)
- ? cachedTopic.post_stream.posts : [];
- const firstPost = posts.find((post) => Number(post?.post_number) === 1);
- return {
- title: String(
- metadata.title || cachedTopic?.title || historyEntry?.title ||
- `帖子 #${safeTopicId}`
- ).replace(/\s+/g, ' ').trim(),
- avatarTemplate: String(
- metadata.avatarTemplate ||
- cachedTopic?.details && cachedTopic.details.created_by &&
- cachedTopic.details.created_by.avatar_template ||
- firstPost?.avatar_template ||
- historyEntry?.avatarTemplate || ''
- ),
- avatarSource: String(metadata.avatarSource || ''),
- ownerUsername: String(
- metadata.ownerUsername ||
- cachedTopic?.details && cachedTopic.details.created_by &&
- cachedTopic.details.created_by.username ||
- firstPost?.username ||
- historyEntry?.ownerUsername || ''
- ),
- navMetadata: mergeTopicNavMetadata(
- metadata.navMetadata || {},
- cachedTopicNavMetadata(cachedTopic),
- ),
- totalCount: Math.max(
- 0,
- Number(cachedTopic && (cachedTopic.highest_post_number || cachedTopic.posts_count)) || 0,
- Number(historyEntry?.postsCount) || 0,
- ),
- historyPostNumber: Math.max(0, Number(historyEntry?.postNumber) || 0),
- urlPostNumber: Math.max(0, Number(metadata.urlPostNumber) || 0),
- readPostNumbers: historyEntryReadPostNumbers(historyEntry),
- };
- }
-
- function readerQueueMetadataFromLink(link, topicId) {
- const source = link && link.nodeType === Node.ELEMENT_NODE ? link : null;
- const card = source?.closest(
- 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result,.category-topic-link'
- );
- const titleLink = card?.querySelector(
- 'a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]'
- );
- const avatar = card?.querySelector(':is(.posters,.topic-poster) img.avatar,img.avatar');
- const ownerNode = card?.querySelector('[data-user-card]');
- const route = extractTopicRouteFromUrl(titleLink && (titleLink.getAttribute('href') || titleLink.href));
- return {
- title: String(
- source && source.dataset.topicTitle ||
- titleLink && (titleLink.dataset.topicTitle || titleLink.textContent) ||
- source?.textContent || `帖子 #${topicId}`
- ).replace(/\s+/g, ' ').trim(),
- avatarTemplate: String(avatar?.dataset.ldpAvatarOriginalTemplate || avatar?.dataset.ldpAvatarTemplate || avatar?.dataset.avatarTemplate || ''),
- avatarSource: String(avatar && (avatar.currentSrc || avatar.src) || ''),
- ownerUsername: String(ownerNode && ownerNode.dataset.userCard || ''),
- navMetadata: readTopicNavMetadata(source, topicId),
- urlPostNumber: Math.max(0, Number(route?.postNumber) || 0),
- };
- }
-
- function readerQueueLoadedCounts(topic) {
- const postStream = topicPostStream(topic);
- const stream = Array.isArray(postStream.stream) ? postStream.stream.filter(Boolean) : [];
- const streamIds = new Set(stream.map(Number).filter(Boolean));
- const loadedIds = new Set((Array.isArray(postStream.posts) ? postStream.posts : [])
- .map((post) => Number(post?.id)).filter((postId) => postId && (!streamIds.size || streamIds.has(postId))));
- return {
- total: stream.length || Math.max(0, Number(topic?.posts_count) || 0),
- loaded: loadedIds.size,
- };
- }
-
- function syncReaderQueueReadHistory(entries) {
- const historyByTopic = new Map((Array.isArray(entries) ? entries : [])
- .map((historyEntry) => [String(Number(historyEntry?.topicId) || 0), historyEntry]));
- let changed = false;
- READER_QUEUE_ENTRIES.forEach((entry) => {
- const historyEntry = historyByTopic.get(String(entry.topicId));
- const nextReadPostNumbers = historyEntry
- ? new Set([...entry.seenPostNumbers, ...historyEntryReadPostNumbers(historyEntry)])
- : new Set();
- if (nextReadPostNumbers.size !== entry.seenPostNumbers.size ||
- [...nextReadPostNumbers].some((postNumber) => !entry.seenPostNumbers.has(postNumber))) {
- entry.seenPostNumbers = nextReadPostNumbers;
- changed = true;
- }
- if (!historyEntry && entry.progressPersistTimer) {
- clearTimeout(entry.progressPersistTimer);
- entry.progressPersistTimer = 0;
- }
- });
- if (changed) syncReaderQueueSurfaces();
- }
-
- function persistReaderQueueReadProgress(entry) {
- if (!entry) return;
- const readPostNumbers = normalizeReadPostNumbers(entry.seenPostNumbers);
- if (!readPostNumbers.length) return;
- const history = readTopicHistory();
- const previousEntry = history.find((item) => Number(item.topicId) === Number(entry.topicId));
- const previousReadPostNumbers = historyEntryReadPostNumbers(previousEntry);
- const cumulativeReadPostNumbers = normalizeReadPostNumbers([
- ...previousReadPostNumbers,
- ...readPostNumbers,
- ]);
- const postsCount = Math.max(
- cumulativeReadPostNumbers[cumulativeReadPostNumbers.length - 1] || 0,
- Number(entry.totalCount) || 0,
- Number(previousEntry?.postsCount) || 0,
- );
- if (cumulativeReadPostNumbers.length === previousReadPostNumbers.length &&
- Number(previousEntry?.postsCount) >= postsCount) return;
- const now = Date.now();
- const nextEntry = {
- ...(previousEntry || {}),
- topicId: Number(entry.topicId),
- title: String(entry.title || previousEntry?.title || `帖子 #${entry.topicId}`),
- postsCount,
- avatarTemplate: String(entry.avatarTemplate || previousEntry?.avatarTemplate || ''),
- ownerUsername: String(entry.ownerUsername || previousEntry?.ownerUsername || ''),
- postNumber: Math.max(
- 1,
- Number(previousEntry?.postNumber) ||
- Number(entry.anchor?.viewport.postNumber) ||
- cumulativeReadPostNumbers[cumulativeReadPostNumbers.length - 1] || 1,
- ),
- readPostNumbers: cumulativeReadPostNumbers,
- firstViewedAt: historyEntryFirstViewedAt(previousEntry) || now,
- viewedAt: Number(previousEntry?.viewedAt) || now,
- };
- delete nextEntry.readPostNumber;
- writeTopicHistory([
- nextEntry,
- ...history.filter((item) => Number(item.topicId) !== Number(entry.topicId)),
- ]);
- }
-
- function scheduleReaderQueueReadProgressPersist(entry) {
- if (!entry || entry.progressPersistTimer) return;
- entry.progressPersistTimer = setTimeout(() => {
- entry.progressPersistTimer = 0;
- persistReaderQueueReadProgress(entry);
- }, 500);
- }
-
- function flushReaderQueueReadProgress(entry) {
- if (!entry) return;
- if (entry.progressPersistTimer) clearTimeout(entry.progressPersistTimer);
- entry.progressPersistTimer = 0;
- persistReaderQueueReadProgress(entry);
- }
-
- function markReaderQueuePostRead(entry, postNumber) {
- const nextPostNumber = Math.max(0, Math.floor(Number(postNumber) || 0));
- if (!entry || !nextPostNumber || entry.seenPostNumbers.has(nextPostNumber)) return false;
- entry.seenPostNumbers.add(nextPostNumber);
- scheduleReaderQueueReadProgressPersist(entry);
- return true;
- }
-
- function readerQueueProgress(entry) {
- const total = Math.max(0, Number(entry?.totalCount) || 0);
- if (!entry || !total) return 0;
- return Math.max(0, Math.min(100, entry.seenPostNumbers.size / total * 100));
- }
-
- function readerQueueStartPostNumber(entry) {
- return !entry || PREFS.openTopicsAtFirstPost === true ? 1 : Math.max(
- 1, Number(entry.urlPostNumber) || Number(entry.anchor?.viewport.postNumber) || 1);
- }
-
- function readerQueueStatusText(entry) {
- const total = Math.max(0, Number(entry.totalCount) || 0);
- const currentFloor = Math.max(0, Number(entry.anchor?.viewport.postNumber) || 0);
- const readCount = entry.seenPostNumbers.size;
- const progress = Math.round(readerQueueProgress(entry));
- const pinned = entry.pinned ? '已固定 · ' : '';
- if (String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID)
- return `${pinned}阅读进度 ${progress}% · 已读 ${readCount}/${total || '?'}` +
- `${currentFloor ? ` · 当前 #${currentFloor}` : ''}`;
- const preloadDetails = `正文 ${Math.max(0, Number(entry.loadedCount) || 0)}/${total || '?'}` +
- `${entry.nestedTotalCount > 0
- ? ` · 二级回复 ${Math.max(0, Number(entry.nestedLoadedCount) || 0)}/${entry.nestedTotalCount}`
- : ''}` +
- `${entry.mediaTotalCount > 0
- ? ` · 图片 ${Math.max(0, Number(entry.mediaLoadedCount) || 0)}/${entry.mediaTotalCount}`
- : ''}`;
- if (entry.loadState === 'ready')
- return `${pinned}已预加载 · ${preloadDetails} · 阅读进度 ${progress}%`;
- if (entry.loadState === 'loading')
- return `${pinned}正在预加载 · ${preloadDetails} · 阅读进度 ${progress}%`;
- if (entry.loadState === 'partial')
- return `${pinned}已分层预加载 · ${preloadDetails} · 阅读进度 ${progress}%`;
- if (entry.loadState === 'error') return `${pinned}预加载失败 · 阅读进度 ${progress}%,点击重试`;
- return `${pinned}等待预加载 · 阅读进度 ${progress}%`;
- }
-
- function readerQueueAvatarHtml(entry) {
- const fallback = String(entry.ownerUsername || entry.title || '?').trim().slice(0, 1).toUpperCase() || '?';
- const avatar = persistentAvatarHtml(
- entry.avatarTemplate || entry.avatarSource,
- 64,
- 'ldp-reader-queue-avatar-image',
- fallback,
- );
- if (avatar) return avatar;
- return `
${esc(fallback)}`;
- }
-
- function syncReaderQueueProgressSurface(entry) {
- if (!entry || !CURRENT_OVERLAY) return;
- const rail = CURRENT_OVERLAY.querySelector('.ldp-reader-queue');
- if (!rail) return;
- const topicId = String(Number(entry.topicId));
- const progress = readerQueueProgress(entry);
- const status = readerQueueStatusText(entry);
- const bubble = rail.querySelector(`.ldp-reader-queue-bubble[data-reader-queue-topic-id="${topicId}"]`);
- if (bubble) {
- ['queued', 'loading', 'partial', 'ready', 'error']
- .forEach((state) => bubble.classList.toggle(`is-${state}`, entry.loadState === state));
- const progressValue = `${progress * 3.6}deg`;
- if (bubble.style.getPropertyValue('--ldp-reader-queue-progress') !== progressValue)
- bubble.style.setProperty('--ldp-reader-queue-progress', progressValue);
- bubble.classList.toggle('is-progress-complete', progress >= 100);
- setLabel(bubble, `${entry.title},${status}`);
- const tooltipLabel = `${entry.title} · ${status}`;
- if (bubble.dataset.ldpTooltipLabel !== tooltipLabel) bubble.dataset.ldpTooltipLabel = tooltipLabel;
- }
- const row = rail.querySelector(`.ldp-reader-queue-row[data-reader-queue-topic-id="${topicId}"]`);
- if (row) {
- const rowProgress = row.querySelector('.ldp-reader-queue-row-progress');
- const progressValue = `${progress * 3.6}deg`;
- if (rowProgress?.style.getPropertyValue('--ldp-reader-queue-progress') !== progressValue)
- rowProgress?.style.setProperty('--ldp-reader-queue-progress', progressValue);
- rowProgress?.classList.toggle('is-progress-complete', progress >= 100);
- const titleNode = row.querySelector('.ldp-reader-queue-row-copy strong');
- if (titleNode && titleNode.textContent !== entry.title) titleNode.textContent = entry.title;
- const statusNode = row.querySelector('.ldp-reader-queue-row-copy small');
- if (statusNode && statusNode.textContent !== status) statusNode.textContent = status;
- }
- }
-
- function syncReaderQueueScrollHint(rail) {
- const bubbles = rail?.querySelector('.ldp-reader-queue-bubbles');
- const hint = rail?.querySelector('.ldp-reader-queue-scroll-hint');
- if (!bubbles || !hint) return;
- const maxScroll = Math.max(0, bubbles.scrollHeight - bubbles.clientHeight);
- const hasOverflow = maxScroll >= 2;
- hint.hidden = !hasOverflow;
- if (!hasOverflow) return;
- const scrollUp = bubbles.scrollTop >= maxScroll - 2;
- hint.classList.toggle('is-up', scrollUp);
- hint.dataset.scrollDirection = scrollUp ? '-1' : '1';
- setLabel(hint, scrollUp ? '回到队列上方头像' : '显示下方更多队列头像');
- }
-
- function syncReaderQueueToggleState(rail) {
- const toggle = rail?.querySelector('.ldp-reader-queue-toggle');
- const count = toggle?.querySelector('b');
- if (!toggle || !count) return;
- const previewExpanded = !rail.classList.contains('is-preview-collapsed');
- setPressed(toggle, previewExpanded);
- setLabel(toggle, READER_QUEUE_ENTRIES.length ? `${previewExpanded ? '收纳' : '展开'}队列头像预览;拖动可移动,贴边可隐藏;悬停显示队列详情,共 ${READER_QUEUE_ENTRIES.length} 篇` : '关闭空阅读队列入口');
- }
-
- function readerQueueSurfaceBounds(rail) {
- const modal = rail?.closest('.ldp-modal');
- if (!modal) return null;
- const modalRect = modal.getBoundingClientRect();
- const headerRect = modal.querySelector(':scope > .ldp-header')?.getBoundingClientRect();
- const maxTop = Math.max(0, modal.clientHeight - rail.offsetHeight);
- return {
- modal,
- modalRect,
- maxLeft: Math.max(0, modal.clientWidth - rail.offsetWidth),
- maxTop,
- minTop: Math.max(0, Math.min(maxTop, (headerRect?.bottom || modalRect.top) - modalRect.top)),
- };
- }
-
- function readerQueueTopAvoidingTopicRail(rail, bounds, requestedTop) {
- const overlay = rail?.closest('.ldp-overlay');
- const topicRail = overlay?.classList.contains('ldp-fullpage') && bounds?.modal?.clientWidth > 700
- ? overlay.querySelector('.ldp-topic-action-rail:not([hidden])')
- : null;
- if (!topicRail) return requestedTop;
- const topicRailRect = topicRail.getBoundingClientRect();
- const topicTop = topicRailRect.top - bounds.modalRect.top;
- const topicBottom = topicRailRect.bottom - bounds.modalRect.top;
- const queueHeight = rail.offsetHeight;
- const gap = Math.max(12, Math.round(
- (rail.querySelector('.ldp-reader-queue-toggle')?.offsetHeight || 0) / 2
- ));
- const clampTop = (top) => Math.max(bounds.minTop, Math.min(bounds.maxTop, top));
- const isClear = (top) => top + queueHeight + gap <= topicTop || top >= topicBottom + gap;
- const targetTop = clampTop(requestedTop);
- if (isClear(targetTop)) return targetTop;
- const defaultTop = clampTop(Math.max(bounds.minTop, bounds.maxTop * .12));
- if (isClear(defaultTop)) return defaultTop;
- const candidates = [
- topicTop - gap - queueHeight,
- topicBottom + gap,
- ].filter((top) => top >= bounds.minTop && top <= bounds.maxTop);
- return candidates.length
- ? candidates.reduce((closest, top) =>
- Math.abs(top - requestedTop) < Math.abs(closest - requestedTop) ? top : closest
- )
- : targetTop;
- }
-
- function applyReaderQueuePanelCollision(rail) {
- const panel = rail?.querySelector('.ldp-reader-queue-panel');
- const modal = rail?.closest('.ldp-modal');
- const overlay = rail?.closest('.ldp-overlay');
- if (!panel || panel.hidden || !modal || !overlay) return;
- const gap = 10;
- const modalRect = modal.getBoundingClientRect();
- const railRect = rail.getBoundingClientRect();
- const panelWidth = panel.offsetWidth, panelHeight = panel.offsetHeight;
- if (!(panelWidth > 0) || !(panelHeight > 0)) return;
- const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
- const minLeft = modalRect.left + gap, maxLeft = Math.max(minLeft, modalRect.right - gap - panelWidth);
- const minTop = modalRect.top + gap, maxTop = Math.max(minTop, modalRect.bottom - gap - panelHeight);
- const leftSpace = railRect.left - minLeft - gap;
- const rightSpace = modalRect.right - gap - railRect.right - gap;
- const openLeft = rail.classList.contains('is-docked-right') ||
- (rightSpace < panelWidth && leftSpace > rightSpace);
- const left = clamp(openLeft ? railRect.left - gap - panelWidth : railRect.right + gap, minLeft, maxLeft);
- let top = clamp(railRect.top, minTop, maxTop);
- const blockers = [...overlay.querySelectorAll(
- ':scope > .ldp-reader-window-capsule,.ldp-modal > .ldp-header,.ldp-rate-limit-notice:not([hidden])'
- )].filter((element) => getComputedStyle(element).display !== 'none')
- .map((element) => element.getBoundingClientRect()).concat(railRect)
- .sort((first, second) => first.top - second.top);
- for (const rect of blockers) {
- if (left >= rect.right + gap || left + panelWidth <= rect.left - gap ||
- top >= rect.bottom + gap || top + panelHeight <= rect.top - gap) continue;
- const below = clamp(rect.bottom + gap, minTop, maxTop);
- top = below + panelHeight <= modalRect.bottom - gap
- ? below : clamp(rect.top - gap - panelHeight, minTop, maxTop);
- }
- panel.classList.add('is-collision-positioned');
- panel.style.left = `${Math.round(left - railRect.left)}px`;
- panel.style.top = `${Math.round(top - railRect.top)}px`;
- }
-
- function applyReaderQueueSurfacePosition(rail) {
- if (!rail) return;
- const bounds = readerQueueSurfaceBounds(rail);
- const modal = bounds?.modal;
- const overlay = rail.closest('.ldp-overlay');
- const state = normalizeReaderQueueSurfaceState(READER_QUEUE_SURFACE_STATE);
- const customized = readerQueueSurfaceStateCustomized(state);
- const modalRect = bounds?.modalRect;
- const topicRailToggle = overlay?.classList.contains('ldp-fullpage') && modal?.clientWidth > 700
- ? overlay.querySelector('.ldp-topic-action-rail:not([hidden]) .ldp-topic-action-rail-toggle')
- : null;
- const topicRailToggleRect = topicRailToggle?.getBoundingClientRect();
- const fullPageAlignedLeft = modalRect && topicRailToggleRect?.width
- ? Math.max(0, Math.min(
- modal.clientWidth - rail.offsetWidth,
- topicRailToggleRect.left + topicRailToggleRect.width / 2 -
- modalRect.left - rail.offsetWidth / 2,
- ))
- : null;
- const fullPageXAligned = Number.isFinite(fullPageAlignedLeft);
- rail.classList.toggle('is-docked-left', customized && !fullPageXAligned && state.dock === 'left');
- rail.classList.toggle('is-docked-right', customized && !fullPageXAligned && state.dock === 'right');
- const toggleRect = rail.querySelector('.ldp-reader-queue-toggle')?.getBoundingClientRect();
- const bubbles = rail.querySelector('.ldp-reader-queue-bubbles');
- if (modalRect && toggleRect && bubbles) {
- const spaceBelow = modalRect.bottom - toggleRect.bottom;
- const previewHeight = Math.min(bubbles.scrollHeight,
- Number.parseFloat(getComputedStyle(bubbles).maxHeight) || bubbles.scrollHeight);
- rail.classList.toggle('is-preview-reversed',
- !rail.classList.contains('is-preview-collapsed') &&
- spaceBelow < previewHeight && toggleRect.top - modalRect.top > spaceBelow
- );
- }
- if (!customized || !modal || rail.hidden) {
- if (!customized) {
- rail.style.removeProperty('left');
- rail.style.removeProperty('top');
- rail.style.removeProperty('bottom');
- rail.classList.remove('is-runtime-positioned');
- if (modal && modal.clientWidth > 700) {
- const readerMain = modal.querySelector('.ldp-reader-main');
- const leftTrack = readerMain ? Number.parseFloat(getComputedStyle(readerMain).gridTemplateColumns) : rail.offsetWidth;
- const maxTop = bounds.maxTop;
- const defaultLeft = fullPageXAligned
- ? fullPageAlignedLeft
- : (Math.max(leftTrack || 0, rail.offsetWidth) - rail.offsetWidth) / 2;
- rail.style.left = `${Math.round(Math.max(0, Math.min(
- modal.clientWidth - rail.offsetWidth, defaultLeft,
- )))}px`;
- rail.style.top = `${Math.round(readerQueueTopAvoidingTopicRail(
- rail, bounds, Math.max(bounds.minTop, maxTop * .12),
- ))}px`;
- rail.classList.add('is-runtime-positioned');
- }
- }
- applyReaderQueuePanelCollision(rail);
- return;
- }
- rail.classList.add('is-runtime-positioned');
- rail.style.left = `${Math.round(fullPageXAligned ? fullPageAlignedLeft : bounds.maxLeft * state.x)}px`;
- rail.style.top = `${Math.round(readerQueueTopAvoidingTopicRail(
- rail, bounds, Math.max(bounds.minTop, bounds.maxTop * state.y),
- ))}px`;
- applyReaderQueuePanelCollision(rail);
- }
-
- function renderReaderQueueSurface(overlay) {
- const rail = overlay?.querySelector('.ldp-reader-queue');
- if (!rail) return;
- const toggle = rail.querySelector('.ldp-reader-queue-toggle');
- const count = toggle?.querySelector('b');
- const bubbles = rail.querySelector('.ldp-reader-queue-bubbles');
- const scrollHint = rail.querySelector('.ldp-reader-queue-scroll-hint');
- const panel = rail.querySelector('.ldp-reader-queue-panel');
- const list = rail.querySelector('.ldp-reader-queue-list');
- const panelCount = rail.querySelector('.ldp-reader-queue-panel-count');
- const clear = rail.querySelector('.ldp-reader-queue-clear');
- const panelWasOpen = panel && !panel.hidden;
- const previousActiveTopicId = rail.dataset.readerQueueActiveTopicId || '';
- const previousBubbleScrollTop = bubbles ? bubbles.scrollTop : 0;
- rail.hidden = !READER_QUEUE_ENTRIES.length && !PREFS.readerQueueAlwaysVisibleWhenEmpty; rail.classList.toggle('is-empty', !READER_QUEUE_ENTRIES.length);
- if (!toggle || !count || !bubbles || !scrollHint || !panel || !list || !panelCount || !clear) return;
- count.textContent = READER_QUEUE_ENTRIES.length ? String(READER_QUEUE_ENTRIES.length) : '×';
- syncReaderQueueToggleState(rail);
- panelCount.textContent = `${READER_QUEUE_ENTRIES.length} 篇`;
- clear.disabled = !READER_QUEUE_ENTRIES.some((entry) => !entry.pinned);
- releasePersistentAvatarImages(bubbles);
- bubbles.innerHTML = READER_QUEUE_ENTRIES.map((entry) => {
- const active = String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID;
- const progress = readerQueueProgress(entry);
- return `
-
- ${entry.pinned ? `${icon('pin')}` : ''}
-
- `;
- }).join('');
- releasePersistentAvatarImages(list);
- list.innerHTML = READER_QUEUE_ENTRIES.map((entry) => {
- const active = String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID;
- const progress = readerQueueProgress(entry);
- return `
-
- ${readerQueueAvatarHtml(entry)}
-
-
- ${esc(entry.title)}
- ${esc(readerQueueStatusText(entry))}
-
-
- ${entry.loadState === 'error'
- ? ``
- : ''}
-
-
-
-
`;
- }).join('');
- hydratePersistentAvatarImages(bubbles, POST_REQUEST_PRIORITY.auxiliary);
- hydratePersistentAvatarImages(list, POST_REQUEST_PRIORITY.auxiliary);
- const activeChanged = previousActiveTopicId !== READER_QUEUE_ACTIVE_TOPIC_ID;
- rail.dataset.readerQueueActiveTopicId = READER_QUEUE_ACTIVE_TOPIC_ID;
- if (activeChanged) {
- const activeBubble = bubbles.querySelector('.ldp-reader-queue-bubble.is-active');
- if (activeBubble) {
- const top = activeBubble.offsetTop;
- const bottom = top + activeBubble.offsetHeight;
- if (top < bubbles.scrollTop) bubbles.scrollTop = Math.max(0, top - 4);
- else if (bottom > bubbles.scrollTop + bubbles.clientHeight) {
- bubbles.scrollTop = Math.max(0, bottom - bubbles.clientHeight + 4);
- }
- }
- } else {
- bubbles.scrollTop = Math.min(previousBubbleScrollTop, Math.max(0, bubbles.scrollHeight - bubbles.clientHeight));
- }
- syncReaderQueueScrollHint(rail);
- panel.hidden = !panelWasOpen;
- setExpanded(toggle, panelWasOpen);
- applyReaderQueueSurfacePosition(rail);
- }
-
- function syncReaderQueueSurfaces() {
- document.querySelectorAll('.ldp-reader-queue-add[data-reader-queue-topic-id]').forEach((button) => {
- const added = !!readerQueueEntry(button.dataset.readerQueueTopicId);
- if (button.dataset.readerQueueAdded === String(added)) return;
- syncReaderQueueAddButton(button, added);
- });
- if (isFunction(CURRENT_OVERLAY?._ldpSyncReaderQueue))
- CURRENT_OVERLAY._ldpSyncReaderQueue();
- }
-
- function syncReaderQueueAddButton(button, added) {
- const label = added ? '从阅读队列移除' : '加入阅读队列并后台预加载';
- button.dataset.readerQueueAdded = String(added);
- button.classList.toggle('is-added', added);
- button.innerHTML = icon(added ? 'check' : 'plus');
- setLabel(button, label);
- button.setAttribute('title', label);
- }
-
- function updateReaderQueueEntryFromTopic(entry, topic, totalHint = 0, options = {}) {
- if (!entry || !topic) return;
- const summary = readerQueueTopicSummary(entry.topicId, entry, topic);
- entry.title = summary.title;
- entry.avatarTemplate = summary.avatarTemplate || entry.avatarTemplate;
- entry.avatarSource = summary.avatarSource || entry.avatarSource;
- entry.ownerUsername = summary.ownerUsername || entry.ownerUsername;
- entry.navMetadata = summary.navMetadata;
- entry.urlPostNumber = summary.urlPostNumber || entry.urlPostNumber || 0;
- const counts = readerQueueLoadedCounts(topic);
- entry.totalCount = Math.max(0, Number(totalHint) || counts.total || entry.totalCount || 0);
- entry.loadedCount = Math.min(entry.totalCount || counts.loaded, counts.loaded);
- if (entry.totalCount > 0 && entry.loadedCount >= entry.totalCount && entry.prefetchEnriched)
- entry.loadState = 'ready';
- else if (entry.loadState === 'ready') entry.loadState = 'queued';
- entry.error = '';
- scheduleReaderQueueStatePersist();
- if (options.progressOnly === true) syncReaderQueueProgressSurface(entry);
- else syncReaderQueueSurfaces();
- }
-
- function ensureReaderQueueEntry(topicId, metadata = {}, options = {}) {
- const safeTopicId = Number(topicId);
- if (!(safeTopicId > 0)) return null;
- let entry = readerQueueEntry(safeTopicId);
- const summary = readerQueueTopicSummary(safeTopicId, metadata);
- if (!entry) {
- entry = {
- topicId: safeTopicId,
- title: summary.title,
- avatarTemplate: summary.avatarTemplate,
- avatarSource: summary.avatarSource,
- ownerUsername: summary.ownerUsername,
- navMetadata: summary.navMetadata,
- addedAt: Date.now(),
- loadState: 'queued',
- loadedCount: 0,
- nestedLoadedCount: 0,
- nestedTotalCount: 0,
- mediaLoadedCount: 0,
- mediaTotalCount: 0,
- totalCount: summary.totalCount,
- urlPostNumber: summary.urlPostNumber,
- anchor: summary.historyPostNumber
- ? normalizeReaderAnchorState(summary.historyPostNumber)
- : null,
- seenPostNumbers: new Set(summary.readPostNumbers),
- progressPersistTimer: 0,
- prefetchController: null,
- prefetchEnriched: false,
- switching: false,
- pinned: false,
- error: '',
- };
- READER_QUEUE_ENTRIES.push(entry);
- READER_QUEUE_BY_TOPIC.set(String(safeTopicId), entry);
- const cachedTopic = getCachedTopicData(safeTopicId);
- if (cachedTopic) updateReaderQueueEntryFromTopic(entry, cachedTopic);
- } else {
- entry.title = summary.title || entry.title;
- entry.avatarTemplate = summary.avatarTemplate || entry.avatarTemplate;
- entry.avatarSource = summary.avatarSource || entry.avatarSource;
- entry.ownerUsername = summary.ownerUsername || entry.ownerUsername;
- entry.navMetadata = mergeTopicNavMetadata(summary.navMetadata, entry.navMetadata);
- entry.urlPostNumber = summary.urlPostNumber || entry.urlPostNumber || 0;
- entry.totalCount = Math.max(Number(entry.totalCount) || 0, summary.totalCount);
- summary.readPostNumbers.forEach((postNumber) => entry.seenPostNumbers.add(postNumber));
- if (!entry.anchor && summary.historyPostNumber)
- entry.anchor = normalizeReaderAnchorState(summary.historyPostNumber);
- if (entry.loadState === 'error' && options.retry === true) {
- entry.loadState = 'queued';
- entry.error = '';
- }
- }
- scheduleReaderQueueStatePersist();
- syncReaderQueueSurfaces();
- if (options.prefetch !== false) scheduleReaderQueuePrefetch();
- return entry;
- }
-
- function saveReaderQueueAnchor(topicId, state) {
- const entry = readerQueueEntry(topicId);
- const normalized = normalizeReaderAnchorState(state);
- if (!entry || !normalized) return;
- entry.anchor = normalized;
- scheduleReaderQueueStatePersist();
- syncReaderQueueSurfaces();
- }
-
- function activateReaderQueueEntry(entry) {
- if (!entry) return;
- READER_QUEUE_ACTIVE_TOPIC_ID = String(entry.topicId);
- if (entry.prefetchController) {
- entry.prefetchController.abort();
- entry.prefetchController = null;
- if (entry.loadState === 'loading') entry.loadState = 'queued';
- }
- syncReaderQueueSurfaces();
- }
-
- async function switchReaderQueueTopic(topicId) {
- const entry = readerQueueEntry(topicId);
- if (!entry || entry.switching) return;
- if (String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID &&
- CURRENT_OVERLAY && Number(CURRENT_OVERLAY.dataset.topicId) === entry.topicId) {
- const panel = CURRENT_OVERLAY.querySelector('.ldp-reader-queue-panel');
- const toggle = CURRENT_OVERLAY.querySelector('.ldp-reader-queue-toggle');
- if (panel) panel.hidden = true;
- if (toggle) setExpanded(toggle, false);
- return;
- }
- const activeTopicId = Number(CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId);
- if (activeTopicId > 0)
- saveReaderQueueAnchor(activeTopicId, captureReaderAnchorState(CURRENT_OVERLAY));
- entry.switching = true;
- syncReaderQueueSurfaces();
- try {
- const startPostNumber = readerQueueStartPostNumber(entry);
- const explicitStart = PREFS.openTopicsAtFirstPost === true || entry.urlPostNumber > 0;
- await openModal(entry.topicId, {
- targetPostNumber: startPostNumber,
- restoreViewport: explicitStart
- ? { postNumber: startPostNumber, postOffset: 0 }
- : undefined,
- restoreState: explicitStart ? undefined : entry.anchor || undefined,
- topicNavMetadata: entry.navMetadata,
- readerQueueMetadata: entry,
- settleTargetJump: true,
- });
- } finally {
- entry.switching = false;
- syncReaderQueueSurfaces();
- }
- }
-
- function removeReaderQueueEntry(topicId) {
- const entry = readerQueueEntry(topicId);
- if (!entry) return false;
- const index = READER_QUEUE_ENTRIES.indexOf(entry);
- const active = String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID;
- flushReaderQueueReadProgress(entry);
- if (entry.prefetchController) entry.prefetchController.abort();
- READER_QUEUE_BY_TOPIC.delete(String(entry.topicId));
- if (index >= 0) READER_QUEUE_ENTRIES.splice(index, 1);
- scheduleReaderQueueStatePersist();
- if (active) READER_QUEUE_ACTIVE_TOPIC_ID = '';
- syncReaderQueueSurfaces();
- scheduleReaderQueuePrefetch();
- return true;
- }
-
- function toggleReaderQueuePinned(topicId) {
- const entry = readerQueueEntry(topicId);
- if (!entry) return false;
- entry.pinned = !entry.pinned;
- scheduleReaderQueueStatePersist();
- syncReaderQueueSurfaces();
- showSelectionToast(entry.pinned ? '已固定,离开后仍保留在队列' : '已取消固定,离开后将自动出队');
- return true;
- }
-
- function autoDequeueReaderQueueEntry(topicId) {
- const entry = readerQueueEntry(topicId);
- if (!entry || entry.pinned) return false;
- return removeReaderQueueEntry(entry.topicId);
- }
-
- function clearReaderQueueEntries(ctx) {
- const retained = READER_QUEUE_ENTRIES.filter((entry) => entry.pinned);
- const retainedEntries = new Set(retained);
- const removable = READER_QUEUE_ENTRIES.filter((entry) => !retainedEntries.has(entry));
- if (!removable.length) {
- showSelectionToast('队列中没有可清理的未固定文章');
- return false;
- }
- const removableTopicIds = new Set(removable.map((entry) => String(entry.topicId)));
- return !!openReaderConfirmDialog(ctx, {
- title: '清理阅读队列?',
- message: retained.length
- ? `将移除 ${removable.length} 篇未固定文章,保留 ${retained.length} 篇固定文章。`
- : `将移除队列中的 ${removable.length} 篇文章。`,
- note: '当前阅读不会关闭,正文、头像和图片缓存不会删除。',
- icon: 'trash',
- confirmLabel: `清理 ${removable.length} 篇`,
- onConfirm: () => {
- const confirmedRemovable = READER_QUEUE_ENTRIES.filter((entry) =>
- removableTopicIds.has(String(entry.topicId)) && !entry.pinned
- );
- if (!confirmedRemovable.length) {
- showSelectionToast('队列中没有可清理的未固定文章');
- return;
- }
- const confirmedRemovableEntries = new Set(confirmedRemovable);
- confirmedRemovable.forEach((entry) => {
- flushReaderQueueReadProgress(entry);
- if (entry.prefetchController) entry.prefetchController.abort();
- READER_QUEUE_BY_TOPIC.delete(String(entry.topicId));
- });
- const latestRetained = READER_QUEUE_ENTRIES.filter((entry) => !confirmedRemovableEntries.has(entry));
- READER_QUEUE_ENTRIES.splice(0, READER_QUEUE_ENTRIES.length, ...latestRetained);
- if (!readerQueueEntry(READER_QUEUE_ACTIVE_TOPIC_ID)) READER_QUEUE_ACTIVE_TOPIC_ID = '';
- scheduleReaderQueueStatePersist();
- syncReaderQueueSurfaces();
- scheduleReaderQueuePrefetch();
- showSelectionToast(`已从队列清理 ${confirmedRemovable.length} 篇文章`);
- },
- });
- }
-
- function readerQueuePrefetchStream(entry, topic) {
- const postStream = topicPostStream(topic);
- const stream = Array.isArray(postStream.stream) ? postStream.stream.filter(Boolean) : [];
- if (stream.length <= READER_QUEUE_PREFETCH_MAX_POSTS) return stream;
- const posts = Array.isArray(postStream.posts) ? postStream.posts : [];
- const startPostNumber = readerQueueStartPostNumber(entry);
- const targetPost = posts.find((post) => +(post?.post_number || 0) === startPostNumber);
- const targetPostId = +(targetPost?.id || 0);
- const foundIndex = targetPostId ? stream.indexOf(targetPostId) : -1;
- const targetIndex = foundIndex >= 0
- ? foundIndex
- : Math.max(0, Math.min(stream.length - 1, startPostNumber - 1));
- if (PREFS.openTopicsAtFirstPost !== true && +(entry?.urlPostNumber || 0) > 0) {
- const before = Math.min(targetIndex, READER_QUEUE_PREFETCH_URL_BEFORE_POSTS);
- let start = Math.max(0, targetIndex - before);
- let end = Math.min(
- stream.length,
- targetIndex + READER_QUEUE_PREFETCH_MAX_POSTS - before
- );
- start = Math.max(0, end - READER_QUEUE_PREFETCH_MAX_POSTS);
- end = Math.min(stream.length, start + READER_QUEUE_PREFETCH_MAX_POSTS);
- return stream.slice(start, end);
- }
- if (targetIndex === 0) return stream.slice(0, READER_QUEUE_PREFETCH_MAX_POSTS);
- const headCount = Math.min(READER_QUEUE_PREFETCH_HEAD_POSTS, READER_QUEUE_PREFETCH_MAX_POSTS);
- const localBudget = READER_QUEUE_PREFETCH_MAX_POSTS - headCount;
- const before = Math.min(targetIndex, READER_QUEUE_PREFETCH_BEFORE_POSTS);
- let localStart = Math.max(0, targetIndex - before);
- let localEnd = Math.min(stream.length, localStart + localBudget);
- localStart = Math.max(0, localEnd - localBudget);
- const selected = new Set(stream.slice(0, headCount));
- stream.slice(localStart, localEnd).forEach((postId) => selected.add(postId));
- if (selected.size < READER_QUEUE_PREFETCH_MAX_POSTS) {
- stream.slice(localEnd, localEnd + READER_QUEUE_PREFETCH_MAX_POSTS - selected.size)
- .forEach((postId) => selected.add(postId));
- }
- return [...selected].slice(0, READER_QUEUE_PREFETCH_MAX_POSTS);
- }
-
- function mergeReaderQueuePostPayload(topic, payload, parentPostNumber = 0) {
- const topicPosts = topic?.post_stream && Array.isArray(topic.post_stream.posts)
- ? topic.post_stream.posts : null;
- if (!topicPosts) return 0;
- const indexes = new Map(topicPosts.map((post, index) => [+(post?.id || 0), index]));
- let merged = 0;
- postsFromPayload(payload).forEach((incoming) => {
- const postId = +(incoming?.id || 0);
- if (!postId) return;
- if (parentPostNumber > 0 && !(+(incoming.reply_to_post_number || 0) > 0)) {
- incoming.reply_to_post_number = +parentPostNumber;
- }
- const index = indexes.get(postId);
- if (index == null) {
- indexes.set(postId, topicPosts.length);
- topicPosts.push(incoming);
- } else {
- Object.assign(topicPosts[index], incoming);
- }
- merged++;
- });
- return merged;
- }
-
- function readerQueueDirectReplies(topic, parentPostNumber) {
- const parent = +parentPostNumber;
- const postStream = topicPostStream(topic);
- return (Array.isArray(postStream.posts) ? postStream.posts : [])
- .filter((reply) => +(reply?.reply_to_post_number || 0) === parent);
- }
-
- async function prefetchReaderQueueNestedReplies(entry, topic, controller, scopePosts) {
- const collected = new Map();
- const parents = [];
- const parentIds = new Set();
- const addPost = (post) => {
- const postId = +(post?.id || 0);
- if (!postId) return;
- collected.set(postId, post);
- if (!(+(post.reply_count || 0) > 0) || parentIds.has(postId)) return;
- parentIds.add(postId);
- parents.push(post);
- };
- (Array.isArray(scopePosts) ? scopePosts : []).forEach(addPost);
- const syncNestedProgress = () => {
- entry.nestedTotalCount = parents.reduce(
- (total, post) => total + Math.max(0, +(post.reply_count || 0)),
- 0
- );
- entry.nestedLoadedCount = parents.reduce(
- (total, post) => total + readerQueueDirectReplies(topic, post.post_number).length,
- 0
- );
- syncReaderQueueProgressSurface(entry);
- };
- syncNestedProgress();
- for (let parentIndex = 0; parentIndex < parents.length; parentIndex++) {
- const parent = parents[parentIndex];
- if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
- const expected = Math.max(0, +(parent.reply_count || 0));
- let known = readerQueueDirectReplies(topic, parent.post_number);
- known.forEach(addPost);
- syncNestedProgress();
- if (known.length >= expected) continue;
- let after = 0;
- const seenCursors = new Set();
- while (known.length < expected) {
- await waitForReaderQueuePrefetchIdle(controller);
- const url = new URL(`${BASE}/posts/${parent.id}/replies.json`);
- if (after > 0) url.searchParams.set('after', String(after));
- let payload;
- try {
- payload = await fetchJSON(url.toString(), {
- signal: controller.signal,
- key: `reader-queue-replies:${entry.topicId}:${parent.id}:${after}`,
- priority: POST_REQUEST_PRIORITY.background,
- });
- } catch (error) {
- if (error && error.name === 'AbortError') throw error;
- break;
- }
- const pagePosts = postsFromPayload(payload);
- const direct = directRepliesForParent(pagePosts, parent.post_number);
- if (direct.length) {
- mergeReaderQueuePostPayload(topic, direct, parent.post_number);
- setCachedTopicData(entry.topicId, topic);
- }
- known = readerQueueDirectReplies(topic, parent.post_number);
- known.forEach(addPost);
- syncNestedProgress();
- const nextAfter = replyPageCursor(pagePosts);
- if (!pagePosts.length || pagePosts.length < SUB_REPLY_FETCH_SIZE ||
- nextAfter <= after || seenCursors.has(nextAfter)) break;
- seenCursors.add(nextAfter);
- after = nextAfter;
- }
- }
- syncNestedProgress();
- return [...collected.values()];
- }
-
- function readerQueueMediaSources(posts, topic = null) {
- const sources = new Set();
- const addSource = (value) => {
- const source = absoluteImageUrl(value);
- if (/^https?:/i.test(source)) sources.add(source);
- };
- const addCookedSources = (cooked) => {
- if (!cooked) return;
- const template = makeElement('template');
- template.innerHTML = String(cooked);
- template.content.querySelectorAll('img').forEach((img) => {
- addSource(
- img.getAttribute('src') ||
- img.getAttribute('data-src') ||
- img.getAttribute('data-large-src')
- );
- });
- };
- (Array.isArray(posts) ? posts : []).forEach((post) => {
- addCookedSources(post?.cooked);
- normalizeBoosts(post?.boosts).forEach((boost) => addCookedSources(boost.cooked));
- (Array.isArray(post?.reactions) ? post.reactions : []).forEach((reaction) => {
- if (reactionEmojiUrls) addSource(reactionEmojiUrls.get(String(reaction?.id || '')));
- });
- });
- (Array.isArray(topic?.valid_reactions) ? topic.valid_reactions : []).forEach((reaction) => {
- const id = String(typeof reaction === 'string'
- ? reaction
- : reaction && (reaction.id || reaction.name) || '');
- if (reactionEmojiUrls) addSource(reactionEmojiUrls.get(id));
- });
- return [...sources];
- }
-
- function readerQueuePrefetchForegroundBusy() {
- if (document.visibilityState !== 'visible') return true;
- const ctx = CURRENT_OVERLAY?._ldpReaderShell?.activeContext;
- if (!ctx) return false;
- if (ctx.readerSession?.isCurrent('opening')) return true;
- return Date.now() - +(ctx.streamLastScrollAt || 0) < READER_QUEUE_PREFETCH_INTERACTION_SETTLE;
- }
-
- async function waitForReaderQueuePrefetchIdle(controller) {
- while (!controller.signal.aborted && readerQueuePrefetchForegroundBusy()) {
- await waitForDelay(180);
- }
- if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
- }
-
- async function prefetchReaderQueueMedia(entry, topic, posts, controller) {
- await loadReactionEmojiRegistry();
- if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
- const candidates = readerQueueMediaSources(posts, topic);
- entry.mediaTotalCount = candidates.length;
- entry.mediaLoadedCount = 0;
- if (!candidates.length) {
- syncReaderQueueProgressSurface(entry);
- return;
- }
- let cursor = 0;
- const worker = async () => {
- while (cursor < candidates.length) {
- const source = candidates[cursor++];
- await waitForReaderQueuePrefetchIdle(controller);
- try {
- const cached = await cachedPersistentLightboxBlob(source);
- if (cached) {
- entry.mediaLoadedCount++;
- syncReaderQueueProgressSurface(entry);
- continue;
- }
- const resource = await fetchReaderPublicResource(
- source,
- POST_REQUEST_PRIORITY.background,
- 'media',
- controller.signal
- );
- if (!resource) continue;
- try {
- const response = resource.response;
- const contentType = String(response.headers.get('content-type') || '');
- if (!response.ok || contentType && !contentType.toLowerCase().startsWith('image/')) continue;
- const blob = await response.blob();
- if (!blob.size) continue;
- await persistLightboxBlob(source, blob, contentType, controller.signal);
- entry.mediaLoadedCount++;
- syncReaderQueueProgressSurface(entry);
- } finally {
- resource.release();
- }
- } catch (error) {
- if (error && error.name === 'AbortError') throw error;
- }
- }
- };
- await Promise.all(Array.from(
- { length: Math.min(READER_QUEUE_PREFETCH_MEDIA_CONCURRENCY, candidates.length) },
- worker
- ));
- }
-
- async function prefetchReaderQueueEntry(entry) {
- if (!entry || !CURRENT_OVERLAY || String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID ||
- !READER_QUEUE_BY_TOPIC.has(String(entry.topicId))) return;
- const controller = new AbortController();
- entry.prefetchController = controller;
- entry.prefetchEnriched = false;
- entry.loadState = 'loading';
- entry.nestedLoadedCount = 0;
- entry.nestedTotalCount = 0;
- entry.mediaLoadedCount = 0;
- entry.mediaTotalCount = 0;
- entry.error = '';
- syncReaderQueueProgressSurface(entry);
- try {
- await waitForReaderQueuePrefetchIdle(controller);
- let topic = getCachedTopicData(entry.topicId);
- const cachedStream = topic?.post_stream && Array.isArray(topic.post_stream.stream)
- ? topic.post_stream.stream : [];
- const expectedCount = Math.max(0, Number(topic?.posts_count) || 0);
- if (!topic || !cachedStream.length || expectedCount > cachedStream.length) {
- const freshTopic = await fetchJSON(`${BASE}/t/${entry.topicId}.json?forceLoad=true`, {
- signal: controller.signal,
- cacheMode: 'refresh',
- key: `reader-queue-topic:${entry.topicId}`,
- priority: POST_REQUEST_PRIORITY.background,
- fetchOptions: discourseJSONFetchOptions(),
- });
- if (topic) mergeMissingSnapshotPosts(freshTopic, topic);
- topic = freshTopic;
- setCachedTopicData(entry.topicId, topic, { fetchedAt: Date.now(), source: 'network' });
- }
- updateReaderQueueEntryFromTopic(entry, topic, 0, { progressOnly: true });
- const postStream = topicPostStream(topic);
- const stream = Array.isArray(postStream.stream) ? postStream.stream.filter(Boolean) : [];
- const preloadStream = readerQueuePrefetchStream(entry, topic);
- let loadedIds = new Set((Array.isArray(postStream.posts) ? postStream.posts : [])
- .map((post) => Number(post?.id)).filter(Boolean));
- let missing = preloadStream.filter((postId) => !loadedIds.has(Number(postId)));
- while (missing.length && READER_QUEUE_BY_TOPIC.has(String(entry.topicId)) &&
- String(entry.topicId) !== READER_QUEUE_ACTIVE_TOPIC_ID) {
- await waitForReaderQueuePrefetchIdle(controller);
- const ids = missing.slice(0, READER_QUEUE_PREFETCH_BATCH_SIZE);
- const query = ids.map((postId) => `post_ids[]=${encodeURIComponent(postId)}`).join('&');
- const part = await fetchJSON(`${BASE}/t/${entry.topicId}/posts.json?${query}`, {
- signal: controller.signal,
- key: `reader-queue-posts:${entry.topicId}:${ids.join(',')}`,
- priority: POST_REQUEST_PRIORITY.background,
- });
- const before = loadedIds.size;
- mergeReaderQueuePostPayload(topic, part);
- loadedIds = new Set((Array.isArray(topic.post_stream?.posts)
- ? topic.post_stream.posts : []).map((post) => Number(post?.id)).filter(Boolean));
- if (loadedIds.size <= before) throw new Error('预加载未返回新的楼层');
- setCachedTopicData(entry.topicId, topic);
- updateReaderQueueEntryFromTopic(entry, topic, stream.length, { progressOnly: true });
- missing = preloadStream.filter((postId) => !loadedIds.has(Number(postId)));
- }
- if (!missing.length) {
- const scopePostStream = topicPostStream(topic);
- const scopePostsById = new Map(
- (Array.isArray(scopePostStream.posts) ? scopePostStream.posts : [])
- .map((post) => [+(post?.id || 0), post])
- );
- const scopePosts = preloadStream.map((postId) => scopePostsById.get(+postId)).filter(Boolean);
- const enrichedPosts = await prefetchReaderQueueNestedReplies(
- entry,
- topic,
- controller,
- scopePosts
- );
- if (controller.signal.aborted || String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID)
- throw new DOMException('Aborted', 'AbortError');
- await prefetchReaderQueueMedia(entry, topic, enrichedPosts, controller);
- entry.prefetchEnriched = true;
- const mainComplete = stream.every((postId) => loadedIds.has(Number(postId)));
- const nestedComplete = entry.nestedLoadedCount >= entry.nestedTotalCount;
- const mediaComplete = entry.mediaLoadedCount >= entry.mediaTotalCount;
- entry.loadState = mainComplete && nestedComplete && mediaComplete ? 'ready' : 'partial';
- }
- else if (entry.loadState === 'loading') entry.loadState = 'queued';
- } catch (error) {
- if (error && error.name === 'AbortError') {
- if (entry.loadState === 'loading') entry.loadState = 'queued';
- } else {
- entry.loadState = 'error';
- entry.error = String(error?.message || '预加载失败');
- }
- } finally {
- if (entry.prefetchController === controller) entry.prefetchController = null;
- if (entry.loadState === 'error') syncReaderQueueSurfaces();
- else syncReaderQueueProgressSurface(entry);
- }
- }
-
- function scheduleReaderQueuePrefetch(delay = READER_QUEUE_PREFETCH_IDLE_DELAY) {
- if (READER_QUEUE_PREFETCH_RUNNING || READER_QUEUE_PREFETCH_TIMER ||
- document.visibilityState !== 'visible' ||
- !CURRENT_OVERLAY || !ACTIVE_READER_REQUEST_SCHEDULER) return;
- READER_QUEUE_PREFETCH_TIMER = setTimeout(() => {
- READER_QUEUE_PREFETCH_TIMER = 0;
- if (document.visibilityState !== 'visible' ||
- !CURRENT_OVERLAY || !ACTIVE_READER_REQUEST_SCHEDULER) return;
- if (readerQueuePrefetchForegroundBusy()) {
- scheduleReaderQueuePrefetch();
- return;
- }
- const entry = READER_QUEUE_ENTRIES.find((candidate) =>
- String(candidate.topicId) !== READER_QUEUE_ACTIVE_TOPIC_ID &&
- candidate.loadState === 'queued' && !candidate.prefetchController
- );
- if (!entry) return;
- READER_QUEUE_PREFETCH_RUNNING = true;
- Promise.resolve(prefetchReaderQueueEntry(entry)).finally(() => {
- READER_QUEUE_PREFETCH_RUNNING = false;
- scheduleReaderQueuePrefetch(300);
- });
- }, Math.max(0, Number(delay) || 0));
- }
-
- function stopReaderQueuePrefetch() {
- if (READER_QUEUE_PREFETCH_TIMER) clearTimeout(READER_QUEUE_PREFETCH_TIMER);
- READER_QUEUE_PREFETCH_TIMER = 0;
- READER_QUEUE_ENTRIES.forEach((entry) => entry.prefetchController?.abort());
- }
- document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible') scheduleReaderQueuePrefetch();
- else stopReaderQueuePrefetch();
- });
-
- function createReaderQueueViewportTracker(ctx, entry) {
- let progressSyncFrame = 0;
- let viewportSyncTimer = 0;
- let pendingPostNumber = 0;
- const qualifies = (intersectionEntry) => {
- if (!intersectionEntry.isIntersecting || intersectionEntry.intersectionRect.width <= 0) return false;
- const nodeHeight = Math.max(1, intersectionEntry.boundingClientRect.height || 1);
- const requiredHeight = Math.min(nodeHeight, 120, Math.max(36, nodeHeight * .3));
- return intersectionEntry.intersectionRect.height >= requiredHeight;
- };
- const scheduleProgressSync = () => {
- if (progressSyncFrame) return;
- progressSyncFrame = requestAnimationFrame(() => {
- progressSyncFrame = 0;
- if (String(ctx.topicId) !== String(entry.topicId)) return;
- syncReaderQueueProgressSurface(entry);
- });
- };
- const observer = new IntersectionObserver((entries) => {
- let readProgressChanged = false;
- entries.forEach((intersectionEntry) => {
- const node = intersectionEntry.target;
- const postNumber = Number(node && node.dataset.postNumber);
- if (postNumber > 0 && qualifies(intersectionEntry)) {
- readProgressChanged = markReaderQueuePostRead(entry, postNumber) || readProgressChanged;
- }
- });
- if (readProgressChanged) scheduleProgressSync();
- }, { root: ctx.scrollRoot, threshold: [0, .1, .3] });
- const captureViewport = () => {
- viewportSyncTimer = 0;
- const postNumber = pendingPostNumber;
- pendingPostNumber = 0;
- if (String(ctx.topicId) !== String(entry.topicId) || !(postNumber > 0)) return;
- const viewport = embeddedReaderPostViewportState(ctx, postNumber);
- if (viewport) entry.anchor = normalizeReaderAnchorState({
- viewport,
- replyWindow: entry.anchor?.replyWindow,
- });
- };
- return {
- observe(node) {
- if (node && !node.classList.contains('ldp-nested-preview')) observer.observe(node);
- },
- unobserve(node) {
- if (!node) return;
- observer.unobserve(node);
- },
- sync(postNumber) {
- pendingPostNumber = Math.max(0, Number(postNumber) || 0);
- if (viewportSyncTimer) clearTimeout(viewportSyncTimer);
- viewportSyncTimer = setTimeout(captureViewport, 240);
- },
- stop() {
- if (progressSyncFrame) cancelAnimationFrame(progressSyncFrame);
- if (viewportSyncTimer) clearTimeout(viewportSyncTimer);
- progressSyncFrame = 0;
- viewportSyncTimer = 0;
- captureViewport();
- observer.disconnect();
- flushReaderQueueReadProgress(entry);
- },
- };
- }
-
- function bindReaderQueueSurface(overlay) {
- const rail = overlay?.querySelector('.ldp-reader-queue');
- if (!rail || overlay._ldpReaderQueueBound) return;
- overlay._ldpReaderQueueBound = true;
- const panel = rail.querySelector('.ldp-reader-queue-panel');
- const toggle = rail.querySelector('.ldp-reader-queue-toggle');
- const bubbles = rail.querySelector('.ldp-reader-queue-bubbles');
- const scrollHint = rail.querySelector('.ldp-reader-queue-scroll-hint');
- const close = rail.querySelector('.ldp-reader-queue-close');
- const clear = rail.querySelector('.ldp-reader-queue-clear');
- const queueScope = createLifecycleScope();
- const on = (target, type, listener, options) =>
- queueScope.listen(target, type, listener, options);
- let hoverCloseTimer = 0;
- let queueDrag = null;
- let suppressToggleClickUntil = 0;
- const schedulePanelPosition = () => requestAnimationFrame(() => applyReaderQueueSurfacePosition(rail));
- const cancelPanelClose = () => {
- if (hoverCloseTimer) clearTimeout(hoverCloseTimer);
- hoverCloseTimer = 0;
- };
- const setPanelOpen = (open) => {
- cancelPanelClose();
- panel.hidden = !open;
- setExpanded(toggle, open);
- if (open) schedulePanelPosition();
- };
- const schedulePanelClose = () => {
- cancelPanelClose();
- if (panel.contains(readerActiveElement())) return;
- hoverCloseTimer = setTimeout(() => {
- hoverCloseTimer = 0;
- setPanelOpen(false);
- }, 180);
- };
- const setPreviewExpanded = (expanded) => {
- rail.classList.toggle('is-preview-collapsed', !expanded);
- syncReaderQueueToggleState(rail);
- if (expanded) requestAnimationFrame(() => syncReaderQueueScrollHint(rail));
- if (!panel.hidden) schedulePanelPosition();
- };
- const finishQueueDrag = (event, cancelled = false) => {
- if (!queueDrag || event.pointerId !== queueDrag.pointerId) return;
- const drag = queueDrag;
- queueDrag = null;
- rail.classList.remove('is-dragging');
- if (toggle.hasPointerCapture(drag.pointerId)) toggle.releasePointerCapture(drag.pointerId);
- if (!drag.moved) return;
- suppressToggleClickUntil = Date.now() + 350;
- if (cancelled) {
- READER_QUEUE_SURFACE_STATE = drag.previousState;
- applyReaderQueueSurfacePosition(rail);
- return;
- }
- const bounds = readerQueueSurfaceBounds(rail);
- if (!bounds) return;
- const left = Math.max(0, Math.min(bounds.maxLeft, Number.parseFloat(rail.style.left) || 0));
- const top = Math.max(bounds.minTop, Math.min(bounds.maxTop, Number.parseFloat(rail.style.top) || 0));
- const distanceLeft = left;
- const distanceRight = bounds.maxLeft - left;
- const dockThreshold = bounds.modal.clientWidth * READER_QUEUE_DOCK_THRESHOLD_RATIO;
- const dock = Math.min(distanceLeft, distanceRight) <= dockThreshold
- ? (distanceLeft <= distanceRight ? 'left' : 'right')
- : '';
- READER_QUEUE_SURFACE_STATE = normalizeReaderQueueSurfaceState({
- x: bounds.maxLeft > 0 ? (dock === 'left' ? 0 : dock === 'right' ? 1 : left / bounds.maxLeft) : 0,
- y: bounds.maxTop > 0 ? top / bounds.maxTop : 0,
- dock,
- });
- applyReaderQueueSurfacePosition(rail);
- scheduleReaderQueueStatePersist();
- };
- on(toggle, 'pointerdown', (event) => {
- if (event.button !== 0 || event.isPrimary === false) return;
- const modal = rail.closest('.ldp-modal');
- if (!modal) return;
- const modalRect = modal.getBoundingClientRect();
- const railRect = rail.getBoundingClientRect();
- queueDrag = {
- pointerId: event.pointerId,
- startX: event.clientX,
- startY: event.clientY,
- startLeft: railRect.left - modalRect.left,
- startTop: railRect.top - modalRect.top,
- previousState: normalizeReaderQueueSurfaceState(READER_QUEUE_SURFACE_STATE),
- moved: false,
- };
- toggle.setPointerCapture(event.pointerId);
- });
- on(toggle, 'pointermove', (event) => {
- if (!queueDrag || event.pointerId !== queueDrag.pointerId) return;
- const deltaX = event.clientX - queueDrag.startX;
- const deltaY = event.clientY - queueDrag.startY;
- if (!queueDrag.moved && Math.hypot(deltaX, deltaY) < 5) return;
- if (!queueDrag.moved) {
- queueDrag.moved = true;
- cancelPanelClose();
- setPanelOpen(false);
- rail.classList.remove('is-docked-left', 'is-docked-right', 'is-dock-revealed');
- rail.classList.add('is-dragging');
- }
- const bounds = readerQueueSurfaceBounds(rail);
- if (!bounds) return;
- const top = Math.max(bounds.minTop,
- Math.min(bounds.maxTop, queueDrag.startTop + deltaY));
- rail.classList.add('is-runtime-positioned');
- rail.style.left = `${Math.round(Math.max(0, Math.min(bounds.maxLeft, queueDrag.startLeft + deltaX)))}px`;
- rail.style.top = `${Math.round(top <= bounds.minTop + toggle.offsetHeight / 2 ? bounds.minTop : top)}px`;
- event.preventDefault();
- });
- on(toggle, 'pointerup', (event) => finishQueueDrag(event));
- on(toggle, 'pointercancel', (event) => finishQueueDrag(event, true));
- on(toggle, 'click', (event) => {
- if (Date.now() < suppressToggleClickUntil) {
- consumeEvent(event);
- return;
- }
- if (!READER_QUEUE_ENTRIES.length) { setPanelOpen(false); setPref('readerQueueAlwaysVisibleWhenEmpty', false); renderReaderQueueSurface(overlay); return; }
- setPreviewExpanded(rail.classList.contains('is-preview-collapsed'));
- });
- on(toggle, 'pointerenter', () => {
- if (!queueDrag) setPanelOpen(true);
- });
- on(toggle, 'keydown', (event) => {
- if (event.key !== 'ArrowDown') return;
- event.preventDefault();
- setPanelOpen(true);
- panel.querySelector('.ldp-reader-queue-row')?.focus();
- });
- on(rail, 'pointerenter', () => {
- rail.classList.add('is-dock-revealed');
- cancelPanelClose();
- });
- on(rail, 'pointerleave', () => {
- rail.classList.remove('is-dock-revealed');
- schedulePanelClose();
- });
- on(panel, 'focusin', cancelPanelClose);
- on(panel, 'focusout', (event) => {
- if (event.relatedTarget instanceof Node && panel.contains(event.relatedTarget)) return;
- schedulePanelClose();
- });
- bindFloatingSurfaceWheel(bubbles);
- on(bubbles, 'scroll', () => syncReaderQueueScrollHint(rail), { passive: true });
- on(scrollHint, 'click', () => {
- const direction = Number(scrollHint.dataset.scrollDirection) || 1;
- const reduceMotion = prefersReducedMotion();
- if (direction < 0) {
- bubbles.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
- return;
- }
- const distance = Math.max(38, Math.round(bubbles.clientHeight * .72));
- bubbles.scrollBy({ top: distance, behavior: reduceMotion ? 'auto' : 'smooth' });
- });
- on(close, 'click', () => {
- setPanelOpen(false);
- toggle.focus();
- });
- on(clear, 'click', () => clearReaderQueueEntries(overlay._ldpReaderShell?.activeContext));
- on(rail, 'click', (event) => {
- const bubbleRemove = event.target.closest('.ldp-reader-queue-bubble-remove');
- if (bubbleRemove) {
- removeReaderQueueEntry(bubbleRemove.dataset.readerQueueTopicId);
- return;
- }
- const bubble = event.target.closest('.ldp-reader-queue-bubble');
- if (bubble) {
- void switchReaderQueueTopic(bubble.dataset.readerQueueTopicId);
- return;
- }
- const row = event.target.closest('.ldp-reader-queue-row');
- if (!row) return;
- const topicId = Number(row.dataset.readerQueueTopicId);
- if (event.target.closest('.ldp-reader-queue-remove')) {
- removeReaderQueueEntry(topicId);
- return;
- }
- if (event.target.closest('.ldp-reader-queue-pin')) {
- toggleReaderQueuePinned(topicId);
- return;
- }
- if (event.target.closest('.ldp-reader-queue-retry')) {
- ensureReaderQueueEntry(topicId, {}, { retry: true });
- return;
- }
- void switchReaderQueueTopic(topicId);
- });
- on(rail, 'keydown', (event) => {
- const row = event.target.closest('.ldp-reader-queue-row');
- if (!row) return;
- if (event.key === 'Escape') {
- event.preventDefault();
- setPanelOpen(false);
- toggle.focus();
- return;
- }
- if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault();
- void switchReaderQueueTopic(row.dataset.readerQueueTopicId);
- return;
- }
- if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return;
- event.preventDefault();
- const rows = [...panel.querySelectorAll('.ldp-reader-queue-row')];
- const index = rows.indexOf(row);
- rows[Math.max(0, Math.min(rows.length - 1, index + (event.key === 'ArrowDown' ? 1 : -1)))]?.focus();
- });
- on(overlay, 'pointerdown', (event) => {
- if (!panel.hidden && !event.target.closest('.ldp-reader-queue')) setPanelOpen(false);
- });
- overlay._ldpSyncReaderQueue = () => renderReaderQueueSurface(overlay);
- overlay._ldpSyncReaderQueuePosition = () => applyReaderQueueSurfacePosition(rail);
- overlay._ldpDestroyReaderQueueSurface = () => {
- cancelPanelClose();
- if (queueDrag) finishQueueDrag({ pointerId: queueDrag.pointerId }, true);
- queueScope.destroy();
- delete overlay._ldpSyncReaderQueue;
- delete overlay._ldpSyncReaderQueuePosition;
- delete overlay._ldpDestroyReaderQueueSurface;
- overlay._ldpReaderQueueBound = false;
- };
- renderReaderQueueSurface(overlay);
- }
-
- function cachePayload(key) {
- if (key === 'history') return readTopicHistory();
- if (key === 'topics') return [...TOPIC_NAV_ICON_CACHE.entries()];
- const cache = cacheMapForKey(key);
- if (cache) return [...cache.entries()];
- return [];
- }
-
- function estimateCacheBytes(value) {
- try {
- const text = JSON.stringify(value);
- return text.length * 2;
- } catch {
- return 0;
- }
- }
-
- async function runInBatches(values, batchSize, worker) {
- for (let index = 0; index < values.length; index += batchSize) {
- await Promise.all(values.slice(index, index + batchSize).map(worker));
- }
- }
-
- async function cacheStorageStats(names) {
- const groups = Object.fromEntries(names.map((name) => [name, { count: 0, bytes: 0 }]));
- if (typeof caches === 'undefined') return { count: 0, bytes: 0, groups };
- let count = 0;
- let bytes = 0;
- let existingNames = [];
- try { existingNames = await caches.keys(); } catch { return { count, bytes, groups }; }
- for (const name of names.filter((entry) => existingNames.includes(entry))) {
- try {
- const storage = await caches.open(name);
- const requests = await storage.keys();
- let groupBytes = 0;
- await runInBatches(requests, RESOURCE_CACHE_PRUNE_BATCH_SIZE, async (request) => {
- const response = await storage.match(request);
- if (!response) return;
- let size = Number(response.headers.get('content-length'));
- if (!(Number.isFinite(size) && size >= 0)) {
- try { size = (await response.blob()).size; } catch { size = 0; }
- }
- groupBytes += size;
- });
- groups[name] = { count: requests.length, bytes: groupBytes };
- count += groups[name].count;
- bytes += groupBytes;
- } catch {}
- }
- return { count, bytes, groups };
- }
-
- async function cacheTypeStats(key, responseStatsRequest) {
- if (key === 'assets')
- return cacheStorageStats(RESOURCE_CACHE_GROUPS.map(({ cacheName }) => cacheName));
- const payload = cachePayload(key);
- const responseKind = ['topics', 'users', 'notifications', 'responses'].includes(key) ? key : '';
- const responseStat = responseKind ? (await responseStatsRequest)[responseKind] : { count: 0, bytes: 0 };
- const payloadBytes = Array.isArray(payload) && !payload.length ? 0 : estimateCacheBytes(payload);
- const stat = {
- count: (Array.isArray(payload) ? payload.length : 0) + responseStat.count,
- bytes: payloadBytes + responseStat.bytes,
- };
- if (key === 'history') {
- stat.topicCount = new Set(payload.map((entry) => Number(entry?.topicId)).filter(Boolean)).size;
- stat.historyRecordCount = payload.length;
- } else if (key === 'topics') {
- const topicIds = new Set(responseStat.topicIds || []);
- stat.topicCount = topicIds.size;
- stat.postCount = Math.max(0, Number(responseStat.postCount) || 0);
- stat.topicSnapshotCount = Math.max(0, Number(responseStat.topicSnapshotCount) || 0);
- stat.topicRequestCount = Math.max(0, Number(responseStat.topicRequestCount) || 0);
- stat.topicNavIconCount = payload.length;
- } else if (key === 'users') {
- const userNames = new Set(responseStat.userNames || []);
- payload.forEach(([username, entry]) => {
- const resolvedUsername = cleanUsernameValue(entry?.user && entry.user.username || username)
- .toLocaleLowerCase();
- if (resolvedUsername) userNames.add(resolvedUsername);
- });
- stat.userCount = userNames.size;
- stat.userProfileCount = payload.length;
- stat.userRequestCount = responseStat.count;
- } else if (key === 'notifications') {
- const notificationIds = new Set(responseStat.notificationIds || []);
- payload.forEach(([, entry]) => {
- const notifications = entry?.value && Array.isArray(entry.value.notifications)
- ? entry.value.notifications
- : [];
- addNotificationStatsIdentities(notificationIds, notifications);
- });
- stat.notificationCount = notificationIds.size;
- stat.notificationPageCount = payload.length;
- stat.notificationRequestCount = responseStat.count;
- } else if (key === 'responses')
- stat.responseRecords = responseStat.responseRecords || {};
- return stat;
- }
-
- function formatCacheBytes(bytes) {
- const value = Math.max(0, Number(bytes) || 0);
- if (value < 1024) return `${value} B`;
- if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
- return `${(value / (1024 * 1024)).toFixed(1)} MB`;
- }
-
- function formatCacheTypeStats(key, stat) {
- const config = PERSISTENT_CACHE_CONFIG[key];
- const storageSuffix = config ? ' · 本机保存' : '';
- if (key === 'history')
- return `${stat.topicCount || 0} 个主题 · ${stat.historyRecordCount || 0} 条浏览记录 · ` +
- `${formatCacheBytes(stat.bytes)}${storageSuffix}`;
- if (key === 'topics')
- return `${stat.topicCount || 0} 个主题 · 已缓存 ${stat.postCount || 0} 个楼层 · ` +
- `快照 ${stat.topicSnapshotCount || 0} / 接口 ${stat.topicRequestCount || 0} / ` +
- `图标 ${stat.topicNavIconCount || 0} · ` +
- `共 ${stat.count || 0} 条记录 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`;
- if (key === 'users')
- return `${stat.userCount || 0} 个用户 · 资料卡 ${stat.userProfileCount || 0} / ` +
- `接口 ${stat.userRequestCount || 0} · 共 ${stat.count || 0} 条记录 · ` +
- `${formatCacheBytes(stat.bytes)}${storageSuffix}`;
- if (key === 'notifications')
- return `${stat.notificationCount || 0} 条消息 · 分页 ${stat.notificationPageCount || 0} / ` +
- `接口 ${stat.notificationRequestCount || 0} · 共 ${stat.count || 0} 条记录 · ` +
- `${formatCacheBytes(stat.bytes)}${storageSuffix}`;
- if (key === 'responses') {
- const records = stat.responseRecords || {};
- return `收藏 ${records.bookmarks || 0} / 回应 ${records.reactions || 0} / ` +
- `其他 ${records.other || 0} · ` +
- `共 ${stat.count || 0} 条响应 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`;
- }
- if (key === 'assets') {
- const groups = stat.groups || {};
- const resourceSummary = (label, cacheName) => {
- const group = groups[cacheName] || { count: 0, bytes: 0 };
- return `${label} ${group.count || 0} 个(${formatCacheBytes(group.bytes)})`;
- };
- return [
- ...RESOURCE_CACHE_GROUPS.map(({ label, cacheName }) => resourceSummary(label, cacheName)),
- `共 ${stat.count || 0} 个资源 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`,
- ].join(' · ');
- }
- return `${stat.count || 0} 条 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`;
- }
-
- function cacheRetentionLabel(key) {
- const config = PERSISTENT_CACHE_CONFIG[key];
- if (!config) return '';
- if (key === 'responses') return '保留 30 天–1 年';
- if (key === 'assets') return '头像与表情 90 天 · 原图 7 天';
- const days = Math.max(1, Math.round(config.maxAge / (24 * 60 * 60 * 1000)));
- return `最多 ${days} 天`;
- }
-
- async function clearSelectedCaches(keys) {
- const selected = new Set(Array.isArray(keys) ? keys : []);
- const kinds = ['topics', 'users', 'notifications', 'responses'].filter((kind) => selected.has(kind));
- const mapKeys = ['topics', 'topicNavIcons', 'users', 'notifications'].filter((key) =>
- selected.has(key) || (key === 'topicNavIcons' && selected.has('topics')));
- const pendingCacheRequests = new Set([
- ...(selected.has('users')
- ? Array.from(USER_CARD_REQUESTS.values(), (entry) => entry?.promise).filter(Boolean) : []),
- ...(selected.has('users') ? USER_FOLLOW_LIST_REQUESTS.values() : []),
- ...(selected.has('notifications') ? NOTIFICATION_REQUESTS.values() : []),
- ...(kinds.length ? RESPONSE_CACHE_REQUESTS.values() : []),
- ]);
- if (pendingCacheRequests.size) await Promise.allSettled(pendingCacheRequests);
- if (selected.has('history')) writeTopicHistory([]);
- clearPersistentCacheMaps(mapKeys);
- mapKeys.forEach((key) => {
- const storageKey = PERSISTENT_CACHE_CONFIG[key]?.storageKey;
- if (!storageKey) return;
- try { localStorage.removeItem(storageKey); } catch {}
- });
- if (selected.has('assets')) {
- RESOURCE_CACHE_CLEARING = true;
- RESOURCE_CACHE_ABORT_CONTROLLER.abort();
- if (RESOURCE_CACHE_PRUNE_REQUESTS.size)
- await Promise.allSettled(RESOURCE_CACHE_PRUNE_REQUESTS.values());
- const pendingResourceRequests = [
- ...RESOURCE_CACHE_GROUPS.flatMap(({ requests }) => [...requests.values()]),
- ...RESOURCE_CACHE_WRITES,
- ];
- if (pendingResourceRequests.length)
- await Promise.allSettled(pendingResourceRequests);
- RESOURCE_CACHE_GROUPS.forEach(({ objectUrls, requests }) => {
- clearResourceObjectUrlCache(objectUrls);
- requests.clear();
- });
- RESOURCE_CACHE_WRITES.clear();
- RESOURCE_CACHE_PRUNE_REQUESTS.clear();
- if (typeof caches !== 'undefined')
- await Promise.all(RESOURCE_CACHE_GROUPS.map(({ cacheName }) => caches.delete(cacheName).catch(() => false)));
- RESOURCE_CACHE_ABORT_CONTROLLER = new AbortController();
- RESOURCE_CACHE_CLEARING = false;
- }
- if (kinds.length) await clearStoredResponseCache({ kinds });
- if (SHARED_REQUEST_COORDINATOR && mapKeys.length)
- SHARED_REQUEST_COORDINATOR.publishCache({ action: 'map-clear', keys: mapKeys });
- }
-
- function topicResponseCacheTags(topicId, ...topicSources) {
- const safeTopicId = Number(topicId);
- const tags = new Set(safeTopicId > 0 ? [`topic:${safeTopicId}`] : []);
- topicSources.filter(Boolean).forEach((topic) => {
- const stream = topic?.post_stream && Array.isArray(topic.post_stream.stream)
- ? topic.post_stream.stream : [];
- stream.forEach((postId) => {
- const safePostId = Number(postId);
- if (safePostId > 0) tags.add(`post:${safePostId}`);
- });
- postsFromPayload(topic).forEach((post) => {
- const safePostId = Number(post?.id);
- if (safePostId > 0) tags.add(`post:${safePostId}`);
- });
- });
- return [...tags];
- }
-
- async function clearCurrentTopicCaches(topicId, topicData) {
- const key = String(topicId || '');
- const cachedEntry = TOPIC_DATA_CACHE.get(key);
- const tags = topicResponseCacheTags(topicId, topicData, cachedEntry?.topic);
- cancelTopicSnapshotWrites(topicId);
- HOST_TOPIC_DATA_CACHE.delete(key);
- TOPIC_DATA_CACHE.delete(key);
- if (tags.length) await clearStoredResponseCache({ tags });
- }
-
- restorePersistentCaches();
- const stopSharedCacheSync = SHARED_REQUEST_COORDINATOR
- ? SHARED_REQUEST_COORDINATOR.subscribeCache(applySharedCacheMessage)
- : () => {};
- scheduleIdleWork(() => {
- void pruneStoredResponseCache();
- void prunePersistentResourceCacheOnce(LDP_AVATAR_CACHE_NAME, LDP_AVATAR_CACHE_MAX_AGE)
- .then(() => prunePersistentResourceCacheOnce(LDP_EMOJI_IMAGE_CACHE_NAME, LDP_EMOJI_IMAGE_CACHE_MAX_AGE));
- }, 2000);
- window.addEventListener('pagehide', (event) => {
- READER_QUEUE_ENTRIES.forEach(flushReaderQueueReadProgress);
- flushReaderQueueState();
- flushPersistentCacheWrites();
- void flushTopicSnapshotWrites();
- if (!event.persisted) {
- stopSharedCacheSync();
- if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.destroy();
- }
- saveEmbeddedReaderReloadState();
- });
-
- /* ============ 1. 样式 ============ */
- const style = makeElement('style');
- const readerStaticStyle = (() => {
- const getResourceText = globalThis.GM_getResourceText;
- if (!isFunction(getResourceText)) return '';
- try {
- return getResourceText('ldpReaderStyles') || '';
- } catch (error) {
- console.error('[Awesome LinuxDo Reader] 静态样式资源读取失败。', error);
- return '';
- }
- })();
- if (!readerStaticStyle.trim()) {
- console.error('[Awesome LinuxDo Reader] 静态样式资源为空,已停止初始化。');
- return;
- }
- style.textContent = readerStaticStyle;
- function ensureReaderStyleMounted() {
- const target = document.head || PAGE_ROOT;
- if (!target) return;
- if (style.parentNode !== target || target.lastElementChild !== style) target.appendChild(style);
- }
- ensureReaderStyleMounted();
-
- function readerHostStyleSources() {
- if (!document.head) return [];
- return [...document.head.querySelectorAll('link[rel~="stylesheet"],style')].filter((source) =>
- source !== style && source.dataset.ldpReaderShadow !== '1' && source.dataset.ldpHostStyle !== '1'
- );
- }
-
- function readerHostStyleLoadsAfterReader(source) {
- return !!(source && style.compareDocumentPosition(source) & Node.DOCUMENT_POSITION_FOLLOWING);
- }
-
- function insertReaderHostStyleClone(source, root, beforeNode) {
- if (!source || !root) return;
- const previous = READER_HOST_STYLE_CLONES.get(source);
- const clone = source.cloneNode(true);
- clone.removeAttribute('id');
- clone.dataset.ldpHostStyle = '1';
- if (previous?.isConnected) {
- previous.replaceWith(clone);
- } else {
- const sources = readerHostStyleSources();
- const sourceIndex = sources.indexOf(source);
- const loadsAfterReader = readerHostStyleLoadsAfterReader(source);
- const nextClone = sources.slice(sourceIndex + 1)
- .filter((item) => readerHostStyleLoadsAfterReader(item) === loadsAfterReader)
- .map((item) => READER_HOST_STYLE_CLONES.get(item))
- .find((item) => item?.isConnected);
- const shadowGuards = root.querySelector('style[data-ldp-reader-shadow-guards]');
- const fallback = loadsAfterReader ? (shadowGuards || beforeNode?.nextSibling) : beforeNode;
- root.insertBefore(clone, nextClone || fallback || root.firstChild);
- }
- READER_HOST_STYLE_CLONES.set(source, clone);
- }
-
- function cloneReaderHostStyles(root, beforeNode) {
- if (!root || !document.head) return;
- root.querySelectorAll('[data-ldp-host-style]').forEach((clone) => clone.remove());
- READER_HOST_STYLE_CLONES.forEach((clone) => clone.remove());
- READER_HOST_STYLE_CLONES.clear();
- readerHostStyleSources().forEach((source) => insertReaderHostStyleClone(source, root, beforeNode));
- if (READER_HOST_STYLE_OBSERVER) return;
- READER_HOST_STYLE_OBSERVER = new MutationObserver((mutations) => {
- const changed = new Set();
- mutations.forEach((mutation) => {
- if (mutation.type === 'attributes') {
- changed.add(mutation.target);
- return;
- }
- const styleOwner = mutation.target.nodeType === Node.ELEMENT_NODE && mutation.target.matches('style')
- ? mutation.target
- : mutation.target.parentElement?.closest('style');
- if (styleOwner) changed.add(styleOwner);
- mutation.removedNodes.forEach((node) => {
- const removed = node.nodeType === Node.ELEMENT_NODE && node.matches('link[rel~="stylesheet"],style')
- ? [node]
- : [...node.querySelectorAll?.('link[rel~="stylesheet"],style') || []];
- removed.forEach((source) => {
- READER_HOST_STYLE_CLONES.get(source)?.remove();
- READER_HOST_STYLE_CLONES.delete(source);
- });
- });
- mutation.addedNodes.forEach((node) => {
- const added = node.nodeType === Node.ELEMENT_NODE && node.matches('link[rel~="stylesheet"],style')
- ? [node]
- : [...node.querySelectorAll?.('link[rel~="stylesheet"],style') || []];
- added.forEach((source) => changed.add(source));
- });
- });
- const shadowStyle = READER_PORTAL_ROOT?.querySelector('style[data-ldp-reader-shadow]');
- if (!shadowStyle) return;
- changed.forEach((source) => {
- if (source === style) return;
- if (!source.isConnected || !source.matches('link[rel~="stylesheet"],style')) {
- READER_HOST_STYLE_CLONES.get(source)?.remove();
- READER_HOST_STYLE_CLONES.delete(source);
- return;
- }
- insertReaderHostStyleClone(source, READER_PORTAL_ROOT, shadowStyle);
- });
- });
- READER_HOST_STYLE_OBSERVER.observe(document.head, {
- subtree: true,
- childList: true,
- attributes: true,
- attributeFilter: ['href', 'rel', 'media', 'disabled'],
- });
- }
-
- function mirrorReaderHostAttributes(source, target, marker) {
- if (!source || !target) return;
- const sourceNames = new Set(source.getAttributeNames().filter((name) =>
- name !== 'style' && !name.startsWith('data-ldp-')
- ));
- target.getAttributeNames().forEach((name) => {
- if (name !== marker && !sourceNames.has(name)) target.removeAttribute(name);
- });
- sourceNames.forEach((name) => {
- const value = name === 'class'
- ? [...source.classList].filter((token) => !token.startsWith('ldp-')).join(' ')
- : source.getAttribute(name);
- if (target.getAttribute(name) !== value) target.setAttribute(name, value);
- });
- target.setAttribute(marker, '1');
- }
-
- function syncReaderHostContextAttributes() {
- mirrorReaderHostAttributes(PAGE_ROOT, READER_PORTAL_CONTEXT_HTML, 'data-ldp-shadow-context-html');
- mirrorReaderHostAttributes(document.body, READER_PORTAL_CONTEXT_BODY, 'data-ldp-shadow-context-body');
- }
-
- function destroyReaderPortal() {
- if (READER_HOST_STYLE_OBSERVER) READER_HOST_STYLE_OBSERVER.disconnect();
- if (READER_HOST_CONTEXT_OBSERVER) READER_HOST_CONTEXT_OBSERVER.disconnect();
- READER_HOST_STYLE_OBSERVER = null;
- READER_HOST_CONTEXT_OBSERVER = null;
- if (READER_ICON_TOOLTIP_CLEANUP) READER_ICON_TOOLTIP_CLEANUP();
- READER_ICON_TOOLTIP_CLEANUP = null;
- READER_HOST_STYLE_CLONES.forEach((clone) => clone.remove());
- READER_HOST_STYLE_CLONES.clear();
- const host = READER_PORTAL_HOST;
- const root = READER_PORTAL_ROOT;
- if (root) releasePersistentAvatarImages(root);
- if (root) root.replaceChildren();
- READER_PORTAL_HOST = null;
- READER_PORTAL_ROOT = null;
- READER_PORTAL_CONTEXT_HTML = null;
- READER_PORTAL_CONTEXT_BODY = null;
- if (host) host.remove();
- }
-
- function clearReaderResourceObjectUrlsIfIdle() {
- if (CURRENT_OVERLAY?.isConnected) return false;
- [AVATAR_OBJECT_URL_CACHE, EMOJI_IMAGE_OBJECT_URL_CACHE, LIGHTBOX_IMAGE_OBJECT_URL_CACHE]
- .forEach(clearResourceObjectUrlCache);
- return true;
- }
-
- function releaseReaderPortalIfIdle() {
- if (CURRENT_OVERLAY?.isConnected) return false;
- destroyReaderPortal();
- const pendingResourceRequests = [
- ...AVATAR_RESOURCE_REQUESTS.values(),
- ...EMOJI_IMAGE_RESOURCE_REQUESTS.values(),
- ...LIGHTBOX_IMAGE_RESOURCE_REQUESTS.values(),
- ];
- clearReaderResourceObjectUrlsIfIdle();
- if (pendingResourceRequests.length)
- void Promise.allSettled(pendingResourceRequests).then(clearReaderResourceObjectUrlsIfIdle);
- return true;
- }
-
- function ensureReaderHostContext(root) {
- if (!root || !PAGE_ROOT || !document.body) return null;
- let contextHtml = root.querySelector(':scope > html[data-ldp-shadow-context-html]');
- if (!contextHtml) {
- contextHtml = makeElement('html');
- contextHtml.dataset.ldpShadowContextHtml = '1';
- root.appendChild(contextHtml);
- }
- let contextBody = contextHtml.querySelector(':scope > body[data-ldp-shadow-context-body]');
- if (!contextBody) {
- contextBody = makeElement('body');
- contextBody.dataset.ldpShadowContextBody = '1';
- contextHtml.appendChild(contextBody);
- }
- READER_PORTAL_CONTEXT_HTML = contextHtml;
- READER_PORTAL_CONTEXT_BODY = contextBody;
- syncReaderHostContextAttributes();
- [...root.childNodes].forEach((node) => {
- if (node === contextHtml || node.nodeType !== Node.ELEMENT_NODE ||
- node.matches('style,link[rel~="stylesheet"]')) return;
- contextBody.appendChild(node);
- });
- if (!READER_HOST_CONTEXT_OBSERVER) {
- READER_HOST_CONTEXT_OBSERVER = new MutationObserver((mutations) => {
- if (mutations.some((mutation) => mutation.attributeName !== 'style'))
- syncReaderHostContextAttributes();
- });
- READER_HOST_CONTEXT_OBSERVER.observe(PAGE_ROOT, { attributes: true });
- READER_HOST_CONTEXT_OBSERVER.observe(document.body, { attributes: true });
- }
- return contextBody;
- }
-
- function ensureReaderPortal() {
- if (READER_PORTAL_HOST?.isConnected && READER_PORTAL_ROOT) return READER_PORTAL_ROOT;
- if (!PAGE_ROOT || !document.body) return null;
- if (READER_PORTAL_HOST || READER_PORTAL_ROOT) destroyReaderPortal();
- let host = PAGE_ROOT.querySelector(':scope > .ldp-reader-portal-host');
- if (!host) {
- host = makeElement('div');
- host.className = 'ldp-reader-portal-host sciapp-ldp-owned';
- PAGE_ROOT.appendChild(host);
- }
- host.classList.add('sciapp-ldp-owned');
- const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
- let shadowStyle = root.querySelector('style[data-ldp-reader-shadow]');
- if (!shadowStyle) {
- shadowStyle = style.cloneNode(true);
- shadowStyle.dataset.ldpReaderShadow = '1';
- root.prepend(shadowStyle);
- }
- let shadowGuards = root.querySelector('style[data-ldp-reader-shadow-guards]');
- if (!shadowGuards) {
- shadowGuards = makeElement('style');
- shadowGuards.dataset.ldpReaderShadowGuards = '1';
- shadowStyle.after(shadowGuards);
- }
- cloneReaderHostStyles(root, shadowStyle);
- READER_PORTAL_CONTEXT_BODY = ensureReaderHostContext(root);
- READER_PORTAL_HOST = host;
- READER_PORTAL_ROOT = root;
- syncReaderFontRenderingState();
- return root;
- }
-
- function appendReaderPortalNode(node) {
- const root = ensureReaderPortal();
- if (!root) throw new Error('reader portal unavailable');
- (READER_PORTAL_CONTEXT_BODY || root).appendChild(node);
- applyReaderThemeVariables(node);
- return node;
- }
-
- function readerPortalQuery(selector) {
- return READER_PORTAL_ROOT?.querySelector(selector) || document.querySelector(selector);
- }
-
- function readerPortalQueryAll(selector) {
- const nodes = READER_PORTAL_ROOT ? [...READER_PORTAL_ROOT.querySelectorAll(selector)] : [];
- document.querySelectorAll(selector).forEach((node) => {
- if (!nodes.includes(node)) nodes.push(node);
- });
- return nodes;
- }
-
- function ensureReaderIconTooltip() {
- const root = ensureReaderPortal();
- if (!root || root._ldpReaderIconTooltipReady) return;
- root._ldpReaderIconTooltipReady = true;
- const tooltip = makeElement('div');
- tooltip.className = 'ldp-reader-icon-tooltip ldp-transient-surface';
- tooltip.setAttribute('role', 'tooltip');
- tooltip.hidden = true;
- (READER_PORTAL_CONTEXT_BODY || root).appendChild(tooltip);
- const tooltipScope = createLifecycleScope();
- const on = (target, type, listener, options) =>
- tooltipScope.listen(target, type, listener, options);
- let activeControl = null;
- const hide = () => {
- if (!activeControl && tooltip.hidden && !tooltip.textContent) return;
- activeControl = null;
- setHidden(tooltip, true);
- setText(tooltip, '');
- };
- const hasVisibleText = (control) => [...control.childNodes].some((node) => {
- if (node.nodeType === Node.TEXT_NODE) return !!String(node.textContent || '').trim();
- if (!(node instanceof Element) || node.matches('.ldp-icon,.ldp-logo,.ldp-notification-unread-badge')) return false;
- const computed = getComputedStyle(node);
- if (computed.display === 'none' || computed.visibility === 'hidden') return false;
- return !!String(node.innerText || node.textContent || '').trim();
- });
- const tooltipControl = (target) => {
- if (!(target instanceof Element)) return null;
- const control = target.closest('button,a,[role="button"],[data-ldp-tooltip-label],.ldp-nested-branch-toggle,.ldp-avatar-flair,.ldp-user-card-badge');
- const inReaderSurface = control && (control.closest('.ldp-overlay,.ldp-lightbox') ||
- control.closest('.ldp-user-card-fallback,.ldp-avatar-viewer') || control.matches('.ldp-native-reader-trigger'));
- if (!inReaderSurface || control.hasAttribute('data-tooltip') ||
- control.matches('.ldp-settings-tab,.ldp-topic-timeline-track,[data-reaction-picker][aria-expanded="true"]')) return null;
- const isFunctionalButton = control.matches('button,[role="button"],.ldp-nested-branch-toggle');
- const isIconOnlyLink = control.matches('a') && control.querySelector('.ldp-icon,.ldp-logo,img') && !hasVisibleText(control);
- const isNamedCopyTarget = control.matches('.ldp-avatar-flair,.ldp-user-card-badge');
- const isNamedTooltipTarget = control.hasAttribute('data-ldp-tooltip-label');
- if (!isFunctionalButton && !isIconOnlyLink && !isNamedCopyTarget && !isNamedTooltipTarget) return null;
- const isTruncatedNarrowTitle = control.matches('.ldp-title-jump') &&
- (control.closest('.ldp-modal')?.getBoundingClientRect().width || 0) <= 480 &&
- control.scrollWidth > control.clientWidth + 1;
- const label = String(isTruncatedNarrowTitle
- ? control.textContent
- : control.getAttribute('aria-label') || control.dataset.ldpTooltipLabel || '').trim();
- return label ? { control, label } : null;
- };
- const position = (control, pointerEvent = null) => {
- const rect = control.getBoundingClientRect();
- const tipRect = tooltip.getBoundingClientRect();
- const gap = 6;
- const edge = 8;
- if (control.matches('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle') && pointerEvent &&
- Number.isFinite(pointerEvent.clientX) && Number.isFinite(pointerEvent.clientY)) {
- const pointerGap = 12;
- let left = pointerEvent.clientX + pointerGap;
- if (left + tipRect.width > window.innerWidth - edge)
- left = pointerEvent.clientX - tipRect.width - pointerGap;
- let top = pointerEvent.clientY + pointerGap;
- if (top + tipRect.height > window.innerHeight - edge)
- top = pointerEvent.clientY - tipRect.height - pointerGap;
- tooltip.style.left = `${Math.round(Math.max(edge, Math.min(left, window.innerWidth - tipRect.width - edge)))}px`;
- tooltip.style.top = `${Math.round(Math.max(edge, Math.min(top, window.innerHeight - tipRect.height - edge)))}px`;
- return;
- }
- let left = rect.left + (rect.width - tipRect.width) / 2;
- left = Math.max(edge, Math.min(left, window.innerWidth - tipRect.width - edge));
- let top = rect.top - tipRect.height - gap;
- if (top < edge) top = Math.min(window.innerHeight - tipRect.height - edge, rect.bottom + gap);
- tooltip.style.left = `${Math.round(left)}px`;
- tooltip.style.top = `${Math.round(Math.max(edge, top))}px`;
- };
- const show = (target, pointerEvent = null, resolvedMatch = null) => {
- const match = resolvedMatch || tooltipControl(target);
- if (!match) return;
- activeControl = match.control;
- setText(tooltip, match.label);
- tooltip.classList.toggle('ldp-reader-history-tooltip', match.control.matches('.ldp-reader-history-nav'));
- tooltip.classList.toggle('ldp-connect-help-tooltip', match.control.matches('.ldp-connect-metric'));
- setHidden(tooltip, false);
- position(match.control, pointerEvent);
- };
- const keepOpen = (control) => control && (control.matches(':hover') || control.matches(':focus-visible'));
- on(root, 'ldp-tooltip-refresh', (event) => {
- if (!(event.target instanceof Element)) return;
- const control = event.target;
- if (activeControl && !activeControl.isConnected) hide();
- const match = tooltipControl(control);
- if (!match || control.hidden || !keepOpen(control)) {
- if (control === activeControl) hide();
- return;
- }
- show(control, null, match);
- });
- on(root, 'click', (event) => {
- const copyTarget = event.target instanceof Element &&
- event.target.closest('.ldp-avatar-flair,.ldp-user-card-badge');
- if (!copyTarget) return;
- consumeEvent(event);
- const label = String(copyTarget.dataset.ldpTooltipLabel || copyTarget.getAttribute('aria-label') || '').trim();
- if (!label) return;
- const showCopyState = (message) => {
- copyTarget.dataset.ldpTooltipLabel = message;
- setLabel(copyTarget, message);
- refreshTooltips(copyTarget);
- setTimeout(() => {
- if (!copyTarget.isConnected || copyTarget.dataset.ldpTooltipLabel !== message) return;
- copyTarget.dataset.ldpTooltipLabel = label;
- setLabel(copyTarget, label);
- refreshTooltips(copyTarget);
- }, 900);
- };
- void writeSelectionQuoteToClipboard(label)
- .then(() => showCopyState('已复制'))
- .catch(() => showCopyState('复制失败'));
- }, true);
- on(root, 'pointerover', (event) => {
- const match = tooltipControl(event.target);
- if (!match || (event.relatedTarget instanceof Node && match.control.contains(event.relatedTarget))) return;
- show(match.control, event, match);
- });
- on(root, 'pointerdown', (event) => {
- if (event.target instanceof Element &&
- event.target.closest('.ldp-header[data-ldp-reader-drag-surface]')) hide();
- }, true);
- on(root, 'pointermove', (event) => {
- if (!activeControl?.matches('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle') ||
- !(event.target instanceof Node) || !activeControl.contains(event.target)) return;
- position(activeControl, event);
- });
- on(root, 'pointerout', (event) => {
- if (!activeControl || (event.relatedTarget instanceof Node && activeControl.contains(event.relatedTarget))) return;
- if (!activeControl.matches(':focus-visible')) hide();
- });
- on(root, 'focusin', (event) => show(event.target));
- on(root, 'focusout', () => {
- queueMicrotask(() => {
- if (!keepOpen(activeControl)) hide();
- });
- });
- on(root, 'scroll', () => {
- const hoveredHistoryControl = root.querySelector('.ldp-reader-history-nav:hover');
- if (hoveredHistoryControl) show(hoveredHistoryControl);
- else hide();
- }, true);
- on(window, 'resize', hide);
- READER_ICON_TOOLTIP_CLEANUP = () => {
- hide();
- tooltipScope.destroy();
- tooltip.remove();
- root._ldpReaderIconTooltipReady = false;
- };
- }
-
- function readerActiveElement() {
- let active = document.activeElement;
- while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
- return active;
- }
-
- function readerSelection() {
- const candidates = [];
- const addCandidate = (selection) => {
- if (selection && !candidates.includes(selection)) candidates.push(selection);
- };
- if (isFunction(READER_PORTAL_ROOT?.getSelection)) {
- try { addCandidate(READER_PORTAL_ROOT.getSelection()); } catch {}
- }
- try { addCandidate(window.getSelection()); } catch {}
- return candidates.find((selection) => selection.rangeCount > 0 && !selection.isCollapsed)
- || candidates.find((selection) => selection.rangeCount > 0)
- || candidates[0]
- || null;
- }
-
- function eventPathClosest(event, selector) {
- const path = isFunction(event?.composedPath) ? event.composedPath() : [];
- const matched = path.find((node) => node && node.nodeType === Node.ELEMENT_NODE && node.matches(selector));
- if (matched) return matched;
- return event?.target && event.target.closest ? event.target.closest(selector) : null;
- }
-
- function eventPathIncludes(event, node) {
- if (!event || !node) return false;
- const path = isFunction(event.composedPath) ? event.composedPath() : [];
- return path.includes(node) || node.contains(event.target);
- }
-
- function containFloatingSurfaceWheel(surface, event) {
- event.stopPropagation();
- const target = event.target instanceof Element ? event.target : null;
- const lineScale = 40;
- const deltaX = event.deltaMode === 1
- ? event.deltaX * lineScale
- : event.deltaMode === 2 ? event.deltaX * surface.clientWidth : event.deltaX;
- const deltaY = event.deltaMode === 1
- ? event.deltaY * lineScale
- : event.deltaMode === 2 ? event.deltaY * surface.clientHeight : event.deltaY;
- if (!target || !surface.contains(target)) {
- event.preventDefault();
- return;
- }
- let scrollTarget = target;
- while (scrollTarget && surface.contains(scrollTarget)) {
- const style = getComputedStyle(scrollTarget);
- const maxScrollLeft = scrollTarget.scrollWidth - scrollTarget.clientWidth;
- const maxScrollTop = scrollTarget.scrollHeight - scrollTarget.clientHeight;
- const canScrollX = deltaX && maxScrollLeft > 1 && /(auto|scroll|overlay)/.test(style.overflowX)
- && (deltaX < 0 ? scrollTarget.scrollLeft > 0 : scrollTarget.scrollLeft < maxScrollLeft - 1);
- const canScrollY = deltaY && maxScrollTop > 1 && /(auto|scroll|overlay)/.test(style.overflowY)
- && (deltaY < 0 ? scrollTarget.scrollTop > 0 : scrollTarget.scrollTop < maxScrollTop - 1);
- if (canScrollX || canScrollY) return;
- if (scrollTarget === surface) break;
- scrollTarget = scrollTarget.parentElement;
- }
- if (deltaX || deltaY) event.preventDefault();
- }
-
- function bindFloatingSurfaceWheel(surface) {
- if (!surface || surface._ldpFloatingSurfaceWheelBound) return;
- surface._ldpFloatingSurfaceWheelBound = true;
- surface.addEventListener('wheel', (event) => containFloatingSurfaceWheel(surface, event), { passive: false });
- }
-
- function createLifecycleScope() {
- const cleanups = [];
- let destroyed = false;
- const scope = {
- add(cleanup) {
- if (destroyed) cleanup();
- else cleanups.push(cleanup);
- return cleanup;
- },
- listen(target, type, listener, options) {
- target.addEventListener(type, listener, options);
- scope.add(() => target.removeEventListener(type, listener, options));
- return listener;
- },
- observe(observer, target, options) {
- observer.observe(target, options);
- scope.add(() => observer.disconnect());
- return observer;
- },
- timer(callback, delay) {
- const timerId = setTimeout(callback, delay);
- scope.add(() => clearTimeout(timerId));
- return timerId;
- },
- raf(callback) {
- const frameId = requestAnimationFrame(callback);
- scope.add(() => cancelAnimationFrame(frameId));
- return frameId;
- },
- destroy() {
- if (destroyed) return;
- destroyed = true;
- cleanups.splice(0).reverse().forEach((cleanup) => {
- try { cleanup(); } catch (error) { console.warn('[LDP] lifecycle cleanup failed', error); }
- });
- },
- };
- return scope;
- }
-
- function createReaderSurfaceManager(overlay) {
- const parking = document.createDocumentFragment();
- return {
- parking,
- mount(surface, host = overlay) {
- if (surface && host && surface.parentNode !== host) host.append(surface);
- return surface;
- },
- park(surface) {
- if (surface && surface.parentNode !== parking) parking.append(surface);
- return surface;
- },
- destroy() {
- releasePersistentAvatarImages(parking);
- parking.replaceChildren();
- },
- };
- }
-
- function syncTabSelection(tabs, dataKey, selectedValue, roving = false) {
- tabs.forEach((tab) => {
- const active = tab.dataset[dataKey] === selectedValue;
- tab.classList.toggle('active', active);
- tab.setAttribute('aria-selected', String(active));
- if (roving) tab.tabIndex = active ? 0 : -1;
- });
- }
-
- function bindReaderPanelSearch(input, clearButton, update, delay = 0) {
- let pending = 0;
- const cancel = () => {
- if (!pending) return;
- if (delay) clearTimeout(pending);
- else cancelAnimationFrame(pending);
- pending = 0;
- };
- const commit = () => {
- pending = 0;
- update(input.value.trim());
- };
- input.addEventListener('input', () => {
- clearButton.hidden = !input.value;
- cancel();
- pending = delay ? setTimeout(commit, delay) : requestAnimationFrame(commit);
- });
- clearButton.addEventListener('click', () => {
- cancel();
- input.value = '';
- clearButton.hidden = true;
- update('');
- input.focus();
- });
- return cancel;
- }
-
- function bindReaderPanelPager(previousButton, nextButton, state, update) {
- previousButton.closest('.ldp-notification-pager').addEventListener('click', (event) => {
- const button = event.target instanceof Element ? event.target.closest('button') : null;
- if (button === previousButton) {
- if (state.page <= 0) return;
- state.page--;
- } else if (button === nextButton) {
- if (state.hasNext === false) return;
- state.page++;
- } else return;
- update();
- });
- }
-
- function toggleReaderPanelSelection(selection, ids) {
- const fullySelected = ids.length > 0 && ids.every((id) => selection.has(id));
- ids.forEach((id) => {
- if (fullySelected) selection.delete(id);
- else selection.add(id);
- });
- }
-
- function bindReaderPanelSelectionList(list, checkboxSelector, itemSelector, idKey, selection, render) {
- list.addEventListener('change', (event) => {
- const checkbox = event.target.closest(checkboxSelector);
- const id = Number(checkbox?.closest(itemSelector)?.dataset[idKey]);
- if (!(id > 0)) return;
- selection[checkbox.checked ? 'add' : 'delete'](id);
- render();
- });
- }
-
- function bindReaderPanelMultiMode(root, state, selection, startButton, doneButton, scopeSelect, render, reset = () => {}) {
- const clear = () => {
- reset();
- selection.clear();
- render();
- };
- root.addEventListener('click', (event) => {
- const button = event.target instanceof Element ? event.target.closest('button') : null;
- if (button === startButton) {
- state.multi = true;
- state.scope = 'page';
- } else if (button === doneButton)
- state.multi = false;
- else return;
- clear();
- });
- scopeSelect.addEventListener('change', () => {
- state.scope = scopeSelect.value === 'all' ? 'all' : 'page';
- selection.clear();
- render();
- });
- }
-
- function handleSearchableMenuKeyDown(event, searchInput, optionsRoot, optionSelector, closeMenu) {
- if (event.key === 'Escape') {
- consumeEvent(event);
- closeMenu(true);
- return;
- }
- if (event.key === 'Enter' && event.target === searchInput) {
- event.preventDefault();
- optionsRoot.querySelector(optionSelector)?.click();
- return;
- }
- if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
- const options = [...optionsRoot.querySelectorAll(optionSelector)];
- if (!options.length) return;
- event.preventDefault();
- const currentIndex = options.indexOf(document.activeElement);
- const direction = event.key === 'ArrowDown' ? 1 : -1;
- const nextIndex = currentIndex < 0
- ? (direction > 0 ? 0 : options.length - 1)
- : (currentIndex + direction + options.length) % options.length;
- options[nextIndex].focus();
- }
-
- /* ============ 2. 工具 ============ */
- const esc = (s) => String(s ?? '').replace(/[<>&]/g, (c) =>
- ({ '<': '<', '>': '>', '&': '&' }[c]));
- const escAttr = (s) => esc(s).replace(/"/g, '"').replace(/'/g, ''');
- function consumeEvent(event) {
- event.preventDefault();
- event.stopPropagation();
- }
- function refreshTooltips(target) {
- target.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true }));
- }
- function setExpanded(target, expanded) {
- const value = String(expanded);
- if (target.getAttribute('aria-expanded') !== value) target.setAttribute('aria-expanded', value);
- }
- function setPressed(target, pressed) {
- const value = String(pressed);
- if (target.getAttribute('aria-pressed') !== value) target.setAttribute('aria-pressed', value);
- }
- function setLabel(target, label) {
- if (target.getAttribute('aria-label') !== label) target.setAttribute('aria-label', label);
- }
- function setHidden(target, hidden) {
- if (target && target.hidden !== hidden) target.hidden = hidden;
- }
- function setText(target, text) {
- if (target && target.textContent !== text) target.textContent = text;
- }
- function setAttributeValue(target, name, value) {
- const next = String(value);
- if (target && target.getAttribute(name) !== next) target.setAttribute(name, next);
- }
- function setStyleValue(target, name, value) {
- const next = String(value);
- if (target && target.style.getPropertyValue(name) !== next) target.style.setProperty(name, next);
- }
- function isFunction(value) {
- return typeof value === 'function';
- }
- function prefersReducedMotion() {
- return isFunction(window.matchMedia) &&
- window.matchMedia('(prefers-reduced-motion: reduce)').matches;
- }
-
- const ICONS = {
- activity: '
',
- database: '
',
- image: '
',
- type: '
',
- alertTriangle: '
',
- bell: '
',
- headerBell: '
',
- bellOff: '
',
- bookmark: '
',
- headerBookmark: '
',
- check: '
',
- square: '
',
- squareCheck: '
',
- circleX: '
',
- arrowUp: '
',
- chevronDown: '
',
- chevronUp: '
',
- chevronRight: '
',
- download: '
',
- upload: '
',
- externalLink: '
',
- copy: '
',
- eyeOff: '
',
- mail: '
',
- palette: '
',
- award: '
',
- tag: '
',
- heart: '
',
- hand: '
',
- history: '
',
- headerHistory: '
',
- info: '
',
- lock: '
',
- unlock: '
',
- pin: '
',
- pencil: '
',
- trash: '
',
- wrench: '
',
- loader: '
',
- maximize2: '
',
- layoutGrid: '
',
- panelLeft: '
',
- panelRight: '
',
- floatingWindow: '
',
- messageSquare: '
',
- minus: '
',
- minimize2: '
',
- cornerDownRight: '
',
- reply: '
',
- plus: '
',
- rocket: '
',
- sun: '
',
- moon: '
',
- monitor: '
',
- settings: '
',
- headerSettings: '
',
- languages: '
',
- smile: '
',
- search: '
',
- at: '
',
- flag: '
',
- link2: '
',
- lightbulb: '
',
- list: '
',
- layers: '
',
- gitBranch: '
',
- listChecks: '
',
- rotateCcw: '
',
- share2: '
',
- shieldHalved: '
',
- userPlus: '
',
- userRound: '
',
- x: '
',
- };
-
- function icon(name, extraClass) {
- return ``;
- }
- function iconActionButtons(className, actionAttribute, actions) {
- return actions.map(([action, label, iconName, attributes = '']) =>
- `
`
- ).join('');
- }
-
- const APPEARANCE_SETTING_FIELDS = [
- { key: 'accentColor', section: 'interaction', label: '按钮与选中状态颜色',
- help: '控制阅读器的主强调色,包括主要按钮、选中态、焦点轮廓、开关开启态、时间轴和操作反馈;不会改变错误、警告、成功和点赞等语义颜色。选择时实时预览,统一保存。' },
- { key: 'linkColor', section: 'interaction', label: '正文链接颜色',
- help: '控制帖子正文中的 URL 链接颜色,与按钮和选中态的界面强调色分开设置;选择时实时预览,统一保存。' },
- { key: 'zebraColor', section: 'zebra', label: '背景颜色', ariaLabel: '交替楼层背景颜色',
- help: '设置阅读器正文楼层隔行出现的淡色背景,用来更容易分清相邻楼层。选择时实时预览,通过底部“保存全部更改”统一保存。' },
- { key: 'zebraRadius', section: 'zebra', label: '背景圆角', ariaLabel: '交替楼层背景圆角', range: [ZEBRA_RADIUS_MIN, ZEBRA_RADIUS_MAX, ZEBRA_RADIUS_STEP], normalize: (value, fallback = ZEBRA_RADIUS_DEFAULT) => normalizeSteppedValue(value, fallback, ZEBRA_RADIUS_MIN, ZEBRA_RADIUS_MAX, ZEBRA_RADIUS_STEP),
- help: `设置交替楼层背景的圆角,${ZEBRA_RADIUS_MIN}px 为直角,最高 ${ZEBRA_RADIUS_MAX}px。拖动时实时预览,通过底部“保存全部更改”统一保存。` },
- { key: 'listZebraColor', section: 'list-zebra', label: '嵌入阅读列表背景', ariaLabel: '嵌入阅读时原站主题列表交替背景颜色', shareLabel: '嵌入阅读列表背景',
- help: `使用左右嵌入阅读时,设置 ${SITE_DISPLAY_NAME} 主题列表卡片的隔行色调;所选颜色会与原站背景适度混合。选择时实时预览,通过底部“保存全部更改”统一保存。` },
- { key: 'replyLineColor', section: 'reply-line', label: '颜色', ariaLabel: '回复连接线颜色',
- help: '控制阅读器里的关系线颜色,包括二级回复连线、上级回复预览、时间轴、特殊提示边框和大图评论层级线。选择时实时预览,统一保存。' },
- { key: 'replyLineWidth', section: 'reply-line', label: '粗细', ariaLabel: '回复连接线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = REPLY_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback),
- help: `控制回复关系线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;二级回复树线、父子回复连接线和相关层级线会同步变化。拖动时实时预览,统一保存。` },
- { key: 'replyLineRadius', section: 'reply-line', label: '转角圆角', ariaLabel: '回复连接线转角圆角', shareLabel: '回复连接线圆角', range: [REPLY_LINE_RADIUS_MIN, REPLY_LINE_RADIUS_MAX, REPLY_LINE_RADIUS_STEP], normalize: normalizeReplyLineRadius,
- help: `控制父子回复和二级回复连接线转折处的圆角,${REPLY_LINE_RADIUS_MIN}px 为直角,最高 ${REPLY_LINE_RADIUS_MAX}px。拖动时实时预览,统一保存。` },
- { key: 'quoteLineColor', section: 'quote-line', label: '颜色', ariaLabel: '引用线颜色',
- help: '控制帖子正文引用块和“引用某楼层”卡片左侧竖线的颜色,只改变引用提示线,不改变引用文字或背景。选择时实时预览,统一保存。' },
- { key: 'quoteLineWidth', section: 'quote-line', label: '粗细', ariaLabel: '引用线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = QUOTE_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback),
- help: `控制引用线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;不同引用样式会保留原有粗细层级。拖动时实时预览,统一保存。` },
- { key: 'dividerLineColor', section: 'divider-line', label: '颜色', ariaLabel: '界面分隔线颜色',
- help: '控制楼层边界、标题栏、设置与通知面板、嵌入阅读边界和大图查看器结构分隔线的颜色;按钮和输入框外框不会跟着改变。选择时实时预览,统一保存。' },
- { key: 'dividerLineWidth', section: 'divider-line', label: '粗细', ariaLabel: '界面分隔线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = DIVIDER_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback),
- help: `控制界面结构分隔线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;按钮和输入框外框不会跟着改变。拖动时实时预览,统一保存。` },
- { key: 'floorPreviewColor', section: 'floor-preview', label: '卡片强调颜色', ariaLabel: '上级回复预览卡片强调颜色', shareLabel: '上级回复预览颜色',
- help: '控制父楼层号悬停后插入的预览卡片边框、淡色背景和楼层标记颜色;不改变二级回复关系线。选择时实时预览,统一保存。' },
- { key: 'floorPreviewHeight', section: 'floor-preview', label: '卡片最大高度', ariaLabel: '上级回复预览卡片最大高度', shareLabel: '上级回复预览高度', range: [FLOOR_PREVIEW_HEIGHT_MIN, FLOOR_PREVIEW_HEIGHT_MAX, FLOOR_PREVIEW_HEIGHT_STEP], normalize: (value, fallback = FLOOR_PREVIEW_HEIGHT_DEFAULT) => normalizeSteppedValue(value, fallback, FLOOR_PREVIEW_HEIGHT_MIN, FLOOR_PREVIEW_HEIGHT_MAX, FLOOR_PREVIEW_HEIGHT_STEP),
- help: `控制父回复插入预览在长内容时允许占用的最大高度,最低 ${FLOOR_PREVIEW_HEIGHT_MIN}px;内容较短时仍按实际高度显示,并始终受当前窗口可用高度限制。拖动时实时预览,统一保存。` },
- ];
- const APPEARANCE_SETTING_GROUPS = [
- { id: 'interaction', title: '按钮与链接', description: '控制按钮、选中状态、焦点、时间轴和正文链接,不改变错误、警告、成功等状态颜色。', sections: ['interaction'] },
- { id: 'background', title: '交替内容背景', description: '用浅色背景区分相邻楼层,以及嵌入阅读时的原站主题列表卡片。',
- sections: [{ id: 'zebra', title: '交替楼层背景' }, 'list-zebra'] },
- { id: 'structure', title: '关系线与分隔线', description: '分别控制回复连接线、引用线和界面分隔线;关闭后隐藏这些线条,已设置的样式会保留。',
- toggleKey: 'structureColorsEnabled', toggleLabel: '显示关系线与分隔线',
- sections: [{ id: 'reply-line', title: '回复连接线' }, { id: 'quote-line', title: '引用线' }, { id: 'divider-line', title: '界面分隔线' }] },
- { id: 'floor-preview', title: '上级回复预览卡片', description: '控制悬停上级楼层号后插入的预览卡片,不影响回复连接线和其他内容。', sections: ['floor-preview'] },
- ];
-
- function appearanceSettingClassName(key) {
- return `ldp-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
- }
-
- function appearanceSettingRowMarkup(field) {
- const className = appearanceSettingClassName(field.key);
- const input = field.range
- ? `
`
- : `
`;
- return `
- ${field.label}
-
- ${input}
-
-
-
`;
- }
-
- function appearanceSettingGroupMarkup(group) {
- const sections = group.sections.map((section) => {
- const { id, title } = typeof section === 'string' ? { id: section } : section;
- const rows = APPEARANCE_SETTING_FIELDS.filter((field) => field.section === id)
- .map(appearanceSettingRowMarkup).join('');
- if (!title) return rows;
- const titleId = `ldp-${id}-setting-group-title`;
- return `
`;
- }).join('');
- const toggle = group.toggleKey
- ? `
-
-
-
-
- `
- : '';
- return `
-
-
-
${group.title}
-
${group.description}
-
- ${toggle}
-
- ${sections}
- `;
- }
-
- function jumpHighlightSettingsMarkup() {
- return JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => {
- const className = `ldp-flash-${field.key}`;
- const limits = JUMP_HIGHLIGHT_LIMITS[field.key];
- const input = field.type === 'color'
- ? `
`
- : `
`;
- return `
- ${field.label}
-
- ${input}<${field.type === 'color' ? 'span' : 'output'} class="${className}-value${field.type === 'color' ? '' : ' ldp-flash-value'}">${field.type === 'color' ? 'span' : 'output'}>
-
- `;
- }).join('');
- }
-
- function settingsCategoryMarkup(id, title, description, content,
- contentClass = 'ldp-settings-category-content', descriptionClass = '') {
- const titleId = `ldp-${id}-title`;
- return `
-
- ${title}
- ${description}
-
- ${content}
- `;
- }
-
- function otherSettingGroupMarkup(id, title, description, content) {
- const titleId = `ldp-other-${id}-title`;
- return `
-
- ${title}
- ${description}
-
- ${content}
- `;
- }
-
- function performanceSettingsMarkup() {
- return PERFORMANCE_SETTING_GROUPS.map((group) => settingsCategoryMarkup(
- `performance-${group.id}`, group.title, group.description,
- group.fields.map((field) => {
- const limits = PERFORMANCE_LIMITS[field.key];
- return `
- ${field.title}${field.description}
- ${field.unit}
- `;
- }).join(''),
- 'ldp-settings-fields ldp-settings-category-list ldp-performance-fields'
- )).join('');
- }
-
- const SETTINGS_PANEL_META = [
- ['user', 'userRound', '用户信息', '集中查看当前账号资料、社区统计、Connect 升级进度与 LDC 账户数据。'],
- ['image', 'image', '图片设置', '设置大图查看器的默认打开方式,以及帖子正文图片的显示大小。'],
- ['font', 'type', '字体设置', '分别设置阅读器界面、帖子正文、回复输入框,以及嵌入阅读时原站主题列表的文字显示。'],
- ['layout', 'layoutGrid', '布局设置', '调整正文区域、楼层时间轴、两者间距和左右留白的宽度比例。'],
- ['window', 'floatingWindow', '浮窗设置', '设置桌面浮窗的大小、位置,以及点击外部时是否保持显示和是否允许拖动。'],
- ['appearance', 'palette', '外观设置', '调整按钮与链接、交替背景、关系线、分隔线和上级回复预览卡片。'],
- ['flash', 'lightbulb', '动画与提示', '设置跳转到指定楼层时的闪烁提示,以及打开或切换帖子时的加载动画。'],
- ['reading', 'headerHistory', '阅读与导航', '管理阅读队列、历史导航、帖子打开位置与阅读器退出方式。'],
- ['interaction', 'gitBranch', '帖子与回复', '管理主帖操作列、二级回复显示位置与 Boost 文本复制规则。'],
- ['sites', 'wrench', '适用站点', '添加并管理可以启用增强阅读器的其他 HTTPS Discourse 论坛。'],
- ['performance', 'rocket', '性能设置', '控制每次加载楼层数、页面保留范围、二级回复预加载和网络请求节奏;下次打开阅读器生效。'],
- ['logs', 'activity', '日志记录', '在请求记录与性能记录之间切换;所有记录仅保留在当前页面内存中,不保存查询参数、请求正文、Cookie、响应内容或个人数据。'],
- ['cache', 'database', '数据管理', '导入或导出阅读器设置,并查看、清理当前浏览器保存的本地缓存。'],
- ['about', 'info', '关于', `专注于长帖阅读、楼层关系和原生社区操作的 ${SITE_DISPLAY_NAME} 阅读工作台。`],
- ];
-
- const SETTINGS_NAV_GROUPS = [
- ['显示与布局', ['image', 'font', 'layout', 'window', 'appearance', 'flash']],
- ['阅读与交互', ['reading', 'interaction']],
- ['系统与数据', ['sites', 'performance', 'logs', 'cache', 'about']],
- ];
-
- function settingsNavigationMarkup() {
- const panels = new Map(SETTINGS_PANEL_META.map((panel) => [panel[0], panel]));
- const button = (id, active = false) => {
- const [, iconName, title] = panels.get(id);
- return `
`;
- };
- return button('user', true) + SETTINGS_NAV_GROUPS.map(([label, ids]) =>
- `
${label}${ids.map((id) => button(id)).join('')}
`).join('');
- }
-
- function settingsPanelIntroMarkup(id) {
- const panel = SETTINGS_PANEL_META.find(([panelId]) => panelId === id);
- if (!panel) return '';
- const extra = id === 'user'
- ? `
最后刷新 · 暂无`
- : '';
- return `
${panel[4] || panel[2]}
${panel[3]}
${extra}
`;
- }
-
- function mountSettingsPanelIntros(root) {
- root.querySelectorAll('.ldp-settings-section[data-settings-panel]').forEach((section) => {
- section.insertAdjacentHTML('afterbegin', settingsPanelIntroMarkup(section.dataset.settingsPanel));
- });
- }
-
- function settingsFooterMarkup(statusClass = '', resetLabel = '', resetClass = '') {
- return ``;
- }
-
- function readerWindowGeometryMarkup() {
- return [
- ['width', `浮窗宽度(最小 ${READER_WINDOW_MIN_WIDTH}px)`, READER_WINDOW_MIN_WIDTH],
- ['height', `浮窗高度(最小 ${READER_WINDOW_MIN_HEIGHT}px)`, READER_WINDOW_MIN_HEIGHT],
- ['x', '距浏览器左侧', READER_WINDOW_MARGIN],
- ['y', '距浏览器顶部', READER_WINDOW_MARGIN],
- ].map(([key, label, min]) => `
${label}
- px`).join('');
- }
-
- function aboutFeaturesMarkup() {
- return [
- ['layoutGrid', '响应式专注阅读', '同一阅读内核支持浮窗、全屏和左右嵌入,元素随容器宽度自动重排。'],
- ['image', '完整内容与楼层关系', '二级回复、引用、时间轴、图片、视频、音频和 Markdown 提示块连贯呈现。'],
- ['heart', '原生社区互动', '回复、点赞、Boost、回应、收藏、通知和帖子编辑无需离开阅读器。'],
- ['rocket', '长帖数据与性能', '按需加载、缓存和请求节奏控制,并集中管理历史、收藏与回应。'],
- ].map(([iconName, title, description]) => `
${icon(iconName)}
- `).join('');
- }
-
- function popoverSearchMarkup(type, placeholder, label, clearLabel) {
- return `
- ${icon('search')}
-
-
- `;
- }
-
- function popoverPagerMarkup(type) {
- const prefix = `ldp-${type}-page`;
- const shared = type === 'notification' ? '' : ' ldp-notification-page';
- return ``;
- }
-
- function collectionBulkActionsMarkup(type, scopeLabel, itemLabel, deleteLabel) {
- return `
-
-
-
-
-
`;
- }
-
- const RESOURCE_MONITOR_ROWS = [
- ['heap', '页面内存估计', '浏览器提供的页面级估算;不能按脚本拆分'],
- ['longTasks', '最近 10 秒主线程卡顿', '次数 / 累计耗时;包含原站与阅读器'],
- ['dom', '阅读器页面元素', '阅读器外壳内全部节点'],
- ['floors', '楼层列表元素', '页面显示 / 内存保留'],
- ['nested', '二级回复缓存', '已建立回复状态的父楼层组'],
- ['media', '正文媒体元素', '图片、音视频与内嵌页面'],
- ['requests', '当前网络请求', '进行中 / 排队'],
- ['network', '最近 60 秒网络', '请求数 / 已知传输量'],
- ];
- const RESOURCE_MONITOR_STAT_KEYS = Object.freeze([
- 'heapUsed', 'longTaskCount', 'longTaskDuration', 'dom', 'mountedFloors', 'retainedFloors',
- 'nested', 'media', 'activeRequests', 'queuedRequests', 'readerRequestCount', 'readerTransfer',
- ]);
-
- function resourceMonitorRowMarkup([key, label, detail]) {
- return `
- ${label}${detail}
- —
- ———
-
`;
- }
-
- const SETTING_SWITCH_FIELDS = [
- { section: 'image-lightbox', className: 'ldp-lightbox-comments-expanded', label: '打开大图时展开评论', description: '打开图片查看器时直接显示右侧评论;关闭后仍可通过顶部评论按钮展开。' },
- { section: 'image-lightbox', className: 'ldp-lightbox-description-expanded', label: '打开大图时展开图片说明', description: '图片说明可以收起,也可拖动底边调整高度;查看器会记住最后状态。' },
- { section: 'image-lightbox', className: 'ldp-lightbox-original-by-default', label: '切换图片时优先加载原图', description: '打开查看器或前后切换时自动加载原图;关闭后先显示预览图,仍可手动查看原图。' },
- { section: 'font-rendering', className: 'ldp-font-rendering-enabled', label: '启用字体显示优化', detailHtml: '
正在检测字体显示环境…' },
- { section: 'font-rendering', className: 'ldp-font-rendering-on-host', label: `同时应用到 ${SITE_DISPLAY_NAME} 原站页面`, ariaLabel: `同时应用字体显示优化到 ${SITE_DISPLAY_NAME} 原站页面`, description: '默认开启;主题列表、帖子原页和其他原站界面也使用相同优化。' },
- { section: 'history', className: 'ldp-history-buttons-always-visible', label: '始终显示前进和后退按钮', description: '历史中有可前进或后退的帖子时一直显示按钮;开启后不再使用边缘唤出范围。' },
- { section: 'reader-queue', className: 'ldp-reader-queue-always-visible-empty', label: '队列为空时仍显示入口', description: '阅读队列没有帖子时仍显示入口;也可在队列图标右上角将其关闭。' },
- { section: 'topic-opening', className: 'ldp-open-topics-first-post', label: '普通帖子从第 1 楼打开', description: '普通帖子链接默认从主楼开始;消息、历史和收藏仍打开各自指定的楼层。' },
- { section: 'reader-exit', className: 'ldp-double-escape-close-reader', label: '按两次 Esc 关闭阅读器', description: '开启后可防止误触;默认关闭,按一次 Esc 即可关闭阅读器。' },
- { section: 'reader-exit', className: 'ldp-confirm-native-composer-close', label: '关闭原生回复窗口前再次确认', description: '开启后,在阅读器内按 Esc、关闭或舍弃时,需要在 1.5 秒内重复同一操作。' },
- { section: 'topic-actions', className: 'ldp-topic-action-rail-visible-setting', label: '始终显示主帖操作列', description: '一直显示回到顶部、点赞、回复、Boost 和收藏;其他帖子功能仍可展开。' },
- { section: 'topic-actions', className: 'ldp-topic-action-rail-fixed-setting', label: '锁定操作列位置', description: '开启后不能拖动操作列;关闭后可长按收纳按钮并拖到其他位置。' },
- { section: 'replies', className: 'ldp-expand-nested-replies-default', label: '在父回复下展开二级回复', description: '默认在父楼层下直接显示它收到的回复;关闭时同时关闭“完整讨论”视图。' },
- { section: 'replies', className: 'ldp-expand-leaf-nested-replies', label: '在楼层列表中展开二级回复', description: '二级回复出现在楼层列表时默认显示完整正文;关闭时必须保留上面的父回复展开方式。', hidden: true },
- { section: 'replies', className: 'ldp-aggregate-descendant-replies', label: '启用“完整讨论”视图', description: '在父回复下预览直接回复,并可打开包含后续回复的完整讨论。' },
- { section: 'replies', className: 'ldp-hide-nested-reply-floors', label: '从楼层列表隐藏二级回复', description: '二级回复固定收纳到对应父楼层。', hidden: true },
- ];
-
- function settingSwitchMarkup(field) {
- return `
-
- ${field.label}
- ${field.detailHtml || `${field.description}`}
-
-
-
-
-
- `;
- }
-
- function settingSwitchesMarkup(section) {
- return SETTING_SWITCH_FIELDS.filter((field) => field.section === section)
- .map(settingSwitchMarkup).join('');
- }
-
- const BOOST_COPY_SETTING_ROWS = [
- { label: '末尾内容方式', control: '
' },
- { label: '前置文字', control: '
' },
- { label: '数字前缀', className: ' ldp-boost-counter-row', control: '
' },
- { label: '递增步长', className: ' ldp-boost-counter-row', control: '
' },
- { label: '固定末尾文字', className: ' ldp-boost-text-row', hidden: true, control: '
' },
- ];
-
- function boostCopySettingsMarkup() {
- return BOOST_COPY_SETTING_ROWS.map((row) => `
- ${row.label}
- ${row.control}
- `).join('');
- }
-
- function fontFieldRowsMarkup(scope, config) {
- const host = scope === 'host';
- const rowClass = 'ldp-setting-row';
- const reset = (field) => `
`;
- const defaultFamily = host ? HOST_FONT_DEFAULTS.hostFontFamily : FONT_FAMILY_DEFAULT;
- const row = (label, controlClass, control, field) => `
- ${label}
- ${control}${reset(field)}
-
`;
- return [
- row('字体', 'ldp-font-option-control', `
-
-
-
-
`, 'family'),
- row('字重', 'ldp-font-option-control', `
-
`, 'weight'),
- row('颜色', 'ldp-color-control ldp-font-color-control', `
-
-
跟随主题`, 'color'),
- ].join('');
- }
-
- function fontScopeSettingsMarkup(scope) {
- const config = FONT_SCOPE_CONFIG[scope];
- const scaleClass = scope === 'interface' ? 'ldp-interface-font-scale' : scope === 'post' ? 'ldp-font-scale' : 'ldp-composer-font-scale';
- return `
- ${fontFieldRowsMarkup(scope, config)}
-
- 字号
-
-
-
-
-
-
-
`;
- }
-
- function normalizePerformanceValue(name, value, fallback) {
- const limits = PERFORMANCE_LIMITS[name];
- const numeric = Number(value);
- if (!limits || !Number.isFinite(numeric)) return fallback;
- const clamped = Math.min(limits.max, Math.max(limits.min, numeric));
- return limits.integer ? Math.round(clamped) : Math.round(clamped * 100) / 100;
- }
-
- function performanceConfigFromPrefs(prefs) {
- const source = prefs || {};
- const fallback = BALANCED_PERFORMANCE;
- return PERFORMANCE_SETTING_KEYS.reduce((config, name) => {
- config[name] = normalizePerformanceValue(
- name,
- source[PERFORMANCE_PREF_KEYS[name]],
- fallback[name]
- );
- return config;
- }, {});
- }
-
- function performancePresetForConfig(config) {
- return Object.entries(PERFORMANCE_PRESETS).find(([, preset]) =>
- PERFORMANCE_SETTING_KEYS.every((name) => config[name] === preset[name])
- )?.[0] ?? 'custom';
- }
-
- function performancePrefsPatch(config, presetName = performancePresetForConfig(config)) {
- return PERFORMANCE_SETTING_KEYS.reduce((patch, name) => {
- patch[PERFORMANCE_PREF_KEYS[name]] = config[name];
- return patch;
- }, { performancePreset: presetName });
- }
-
- function roundLayoutRatio(value) {
- return Math.round(Number(value) * 100) / 100;
- }
-
- function layoutRatioTotal(ratios) {
- return roundLayoutRatio(LAYOUT_REGION_KEYS.reduce((sum, name) => sum + Number(ratios[name] || 0), 0));
- }
-
- function normalizeLayoutRatios(source, fallback = READER_LAYOUT_DEFAULT) {
- const values = source || {};
- const result = LAYOUT_REGION_KEYS.reduce((ratios, name) => {
- const numeric = Number(values[name]);
- const safe = Number.isFinite(numeric) ? numeric : fallback[name];
- ratios[name] = roundLayoutRatio(Math.min(100, Math.max(LAYOUT_MIN_RATIOS[name], safe)));
- return ratios;
- }, {});
- const total = layoutRatioTotal(result);
- if (total <= 100) return result;
- const minimumTotal = LAYOUT_REGION_KEYS.reduce((sum, name) => sum + LAYOUT_MIN_RATIOS[name], 0);
- const extraBudget = 100 - minimumTotal;
- const extras = LAYOUT_REGION_KEYS.reduce((map, name) => {
- map[name] = Math.max(0, result[name] - LAYOUT_MIN_RATIOS[name]);
- return map;
- }, {});
- const extraTotal = LAYOUT_REGION_KEYS.reduce((sum, name) => sum + extras[name], 0) || 1;
- LAYOUT_REGION_KEYS.forEach((name) => {
- result[name] = roundLayoutRatio(LAYOUT_MIN_RATIOS[name] + extraBudget * extras[name] / extraTotal);
- });
- let overflow = roundLayoutRatio(layoutRatioTotal(result) - 100);
- if (overflow > 0) {
- const correctionKey = LAYOUT_REGION_KEYS.find((name) => result[name] - overflow >= LAYOUT_MIN_RATIOS[name]);
- if (correctionKey) result[correctionKey] = roundLayoutRatio(result[correctionKey] - overflow);
- overflow = roundLayoutRatio(layoutRatioTotal(result) - 100);
- if (overflow > 0) result.main = roundLayoutRatio(result.main - overflow);
- }
- return result;
- }
-
- function normalizeListReaderMode(value) {
- return LIST_READER_MODES.includes(value) ? value : 'floating';
- }
-
- function normalizeTopicReaderMode(value) {
- return TOPIC_READER_MODES.includes(value) ? value : 'fullpage';
- }
-
- function normalizeBoostCopyString(value, fallback = '') {
- const normalized = String(value == null ? fallback : value).replace(/\s+/g, ' ');
- return [...normalized].slice(0, BOOST_COPY_MAX_LENGTH).join('');
- }
-
- function boostCopyConfigFromPrefs(prefs) {
- const source = prefs || {};
- const mode = BOOST_COPY_MODES.includes(source.boostCopyMode)
- ? source.boostCopyMode
- : BOOST_COPY_DEFAULTS.mode;
- const counterMarker = normalizeBoostCopyString(
- source.boostCopyCounterMarker,
- BOOST_COPY_DEFAULTS.counterMarker
- ).trim() || BOOST_COPY_DEFAULTS.counterMarker;
- const counterStep = Number(source.boostCopyCounterStep);
- return {
- mode,
- prefix: normalizeBoostCopyString(source.boostCopyPrefix, BOOST_COPY_DEFAULTS.prefix),
- counterMarker,
- counterStep: Number.isFinite(counterStep)
- ? Math.min(99, Math.max(1, Math.round(counterStep)))
- : BOOST_COPY_DEFAULTS.counterStep,
- fixedSuffix: normalizeBoostCopyString(source.boostCopyFixedSuffix, BOOST_COPY_DEFAULTS.fixedSuffix),
- };
- }
-
- function normalizeJumpHighlightValue(name, value) {
- const limits = JUMP_HIGHLIGHT_LIMITS[name];
- const fallback = JUMP_HIGHLIGHT_DEFAULTS[name];
- const numeric = Number(value);
- if (!limits || !Number.isFinite(numeric)) return fallback;
- const clamped = Math.min(limits.max, Math.max(limits.min, numeric));
- if (limits.integer) return Math.round(clamped);
- const stepped = Math.round(clamped / limits.step) * limits.step;
- return Math.round(stepped * 100) / 100;
- }
-
- function jumpHighlightConfigFromPrefs(prefs) {
- const source = prefs || {};
- const color = String(source.jumpHighlightColor || '').trim().toLowerCase();
- return {
- color: /^#[0-9a-f]{6}$/.test(color) ? color : JUMP_HIGHLIGHT_DEFAULTS.color,
- radius: normalizeJumpHighlightValue('radius', source.jumpHighlightRadius),
- borderWidth: normalizeJumpHighlightValue('borderWidth', source.jumpHighlightBorderWidth),
- rate: normalizeJumpHighlightValue('rate', source.jumpHighlightRate),
- count: normalizeJumpHighlightValue('count', source.jumpHighlightCount),
- };
- }
-
- function jumpHighlightPrefsPatch(config) {
- return Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => [field.pref, config[field.key]]));
- }
-
- function jumpHighlightDurationMs(config = jumpHighlightConfigFromPrefs(PREFS)) {
- return Math.round(1000 / config.rate);
- }
-
- function normalizeListReaderEmbedWidth(value) {
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) return READER_EMBED_DEFAULT_WIDTH;
- return Math.max(READER_EMBED_MIN_WIDTH, Math.round(numeric));
- }
-
- function normalizeReaderWindowPreferenceGroup(prefs, prefix) {
- const width = Number(prefs[`${prefix}Width`]);
- const height = Number(prefs[`${prefix}Height`]);
- const x = Number(prefs[`${prefix}X`]);
- const y = Number(prefs[`${prefix}Y`]);
- return {
- [`${prefix}Width`]: Number.isFinite(width) && width > 0 ? Math.max(READER_WINDOW_MIN_WIDTH, Math.round(width)) : 0,
- [`${prefix}Height`]: Number.isFinite(height) && height > 0 ? Math.max(READER_WINDOW_MIN_HEIGHT, Math.round(height)) : 0,
- [`${prefix}X`]: Number.isFinite(x) && x > 0 ? Math.round(x) : 0,
- [`${prefix}Y`]: Number.isFinite(y) && y > 0 ? Math.round(y) : 0,
- [`${prefix}Locked`]: !!prefs[`${prefix}Locked`],
- [`${prefix}Pinned`]: !!prefs[`${prefix}Pinned`],
- };
- }
-
- function normalizeWindowGeometryPreferenceGroup(prefs, prefix) {
- return ['Width', 'Height', 'X', 'Y'].reduce((result, suffix) => {
- const value = Number(prefs[`${prefix}${suffix}`]);
- result[`${prefix}${suffix}`] = Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
- return result;
- }, {});
- }
-
- function normalizeReaderThemeMode(value) {
- return READER_THEME_MODES.includes(value) ? value : 'system';
- }
-
- function normalizeReaderLoadingAnimation(value) {
- return value === 'random' || READER_LOADING_ANIMATION_KEYS.includes(value)
- ? value : READER_LOADING_ANIMATION_DEFAULT;
- }
-
- function normalizeBookmarkTabOrder(value) {
- const stored = Array.isArray(value)
- ? [...new Set(value.filter((type) => BOOKMARK_TAB_TYPES.includes(type)))]
- : [];
- return [...stored, ...BOOKMARK_TAB_TYPES.filter((type) => !stored.includes(type))];
- }
-
- function normalizeReaderPrefs(prefs) {
- const knownPrefs = Object.keys(DEFAULT_PREFS).reduce((result, key) => {
- if (Object.hasOwn(prefs || {}, key)) result[key] = prefs[key];
- return result;
- }, {});
- const performanceConfig = performanceConfigFromPrefs(knownPrefs);
- const performancePrefs = Object.assign({}, DEFAULT_PREFS, knownPrefs, performancePrefsPatch(performanceConfig));
- const boostCopy = boostCopyConfigFromPrefs(performancePrefs);
- const jumpHighlight = jumpHighlightConfigFromPrefs(performancePrefs);
- const expandLeafNestedReplies = performancePrefs.expandLeafNestedReplies === true;
- const aggregateDescendantReplies = performancePrefs.aggregateDescendantReplies === true;
- const expandNestedRepliesByDefault = aggregateDescendantReplies ||
- (performancePrefs.expandNestedRepliesByDefault !== false || !expandLeafNestedReplies);
- return Object.assign({}, performancePrefs, {
- imageProfile: normalizeImageProfile(performancePrefs.imageProfile, IMAGE_PROFILE_DEFAULT),
- lightboxOriginalByDefault: performancePrefs.lightboxOriginalByDefault === true,
- lightboxCommentsExpandedByDefault: performancePrefs.lightboxCommentsExpandedByDefault === true,
- lightboxDescriptionExpanded: performancePrefs.lightboxDescriptionExpanded === true,
- lightboxDescriptionHeight: normalizeLightboxDescriptionHeight(performancePrefs.lightboxDescriptionHeight),
- lightboxCommentsWidthPercent: normalizeLightboxCommentsWidth(performancePrefs.lightboxCommentsWidthPercent),
- fontProfile: normalizeFontProfile(performancePrefs.fontProfile, READER_FONT_DEFAULT),
- layoutProfile: normalizeLayoutRatios(performancePrefs.layoutProfile, READER_LAYOUT_DEFAULT),
- fullpageLayoutProfile: normalizeLayoutRatios(
- performancePrefs.fullpageLayoutProfile,
- READER_FULLPAGE_LAYOUT_DEFAULT
- ),
- appearanceProfile: normalizeAppearanceProfile(performancePrefs.appearanceProfile, APPEARANCE_PROFILE_DEFAULT),
- themeMode: normalizeReaderThemeMode(performancePrefs.themeMode),
- fontRenderingEnabled: performancePrefs.fontRenderingEnabled !== false,
- fontRenderingOnHost: performancePrefs.fontRenderingOnHost === true,
- hostFontFamily: normalizeFontFamily(performancePrefs.hostFontFamily, HOST_FONT_DEFAULTS.hostFontFamily),
- hostFontCustomFamily: normalizeCustomFontFamily(performancePrefs.hostFontCustomFamily, HOST_FONT_DEFAULTS.hostFontCustomFamily),
- hostFontWeight: normalizeFontWeight(performancePrefs.hostFontWeight, HOST_FONT_DEFAULTS.hostFontWeight),
- hostFontColor: normalizeHexColor(performancePrefs.hostFontColor, HOST_FONT_DEFAULTS.hostFontColor),
- ...Object.fromEntries(Object.values(HOST_EMBED_SIZE_SETTINGS).map((config) => [
- config.pref, normalizeHostEmbeddedSizeScale(performancePrefs[config.pref], config.defaultScale),
- ])),
- ...normalizeReaderWindowPreferenceGroup(performancePrefs, 'readerWindow'),
- ...normalizeWindowGeometryPreferenceGroup(performancePrefs, COMPOSER_WINDOW_PREFIX),
- topicReaderMode: normalizeTopicReaderMode(performancePrefs.topicReaderMode),
- listReaderMode: normalizeListReaderMode(performancePrefs.listReaderMode),
- listReaderEmbedWidth: normalizeListReaderEmbedWidth(performancePrefs.listReaderEmbedWidth),
- historySortMode: performancePrefs.historySortMode === 'first-viewed' ? 'first-viewed' : 'recent-viewed',
- bookmarkTabOrder: normalizeBookmarkTabOrder(performancePrefs.bookmarkTabOrder),
- historyButtonsAlwaysVisible: performancePrefs.historyButtonsAlwaysVisible === true,
- readerQueueAlwaysVisibleWhenEmpty: performancePrefs.readerQueueAlwaysVisibleWhenEmpty !== false,
- historyEdgeTriggerPercent: normalizeHistoryEdgeTriggerPercent(performancePrefs.historyEdgeTriggerPercent),
- loadingAnimation: normalizeReaderLoadingAnimation(performancePrefs.loadingAnimation),
- translationMode: ['bilingual', 'translation'].includes(performancePrefs.translationMode)
- ? performancePrefs.translationMode : 'original',
- openTopicsAtFirstPost: performancePrefs.openTopicsAtFirstPost === true,
- doubleEscapeToCloseReader: performancePrefs.doubleEscapeToCloseReader === true,
- confirmNativeComposerClose: performancePrefs.confirmNativeComposerClose !== false,
- topicActionRailVisible: performancePrefs.topicActionRailVisible !== false,
- topicActionRailFixed: performancePrefs.topicActionRailFixed === true,
- expandNestedRepliesByDefault,
- expandLeafNestedReplies,
- aggregateDescendantReplies,
- hideNestedReplyFloors: performancePrefs.hideNestedReplyFloors === true,
- ...jumpHighlightPrefsPatch(jumpHighlight),
- boostCopyMode: boostCopy.mode,
- boostCopyPrefix: boostCopy.prefix,
- boostCopyCounterMarker: boostCopy.counterMarker,
- boostCopyCounterStep: boostCopy.counterStep,
- boostCopyFixedSuffix: boostCopy.fixedSuffix,
- });
- }
-
- function readPrefs() {
- try {
- const stored = JSON.parse(localStorage.getItem(LDP_PREF_KEY) || '{}');
- const merged = Object.assign({}, DEFAULT_PREFS, stored);
- const storedPreset = Object.hasOwn(stored, 'performancePreset')
- ? stored.performancePreset
- : '';
- if (PERFORMANCE_PRESETS[storedPreset])
- Object.assign(merged, performancePrefsPatch(PERFORMANCE_PRESETS[storedPreset], storedPreset));
- return normalizeReaderPrefs(merged);
- } catch {
- return normalizeReaderPrefs({ ...DEFAULT_PREFS });
- }
- }
-
- let PREFS = readPrefs();
-
- function syncReaderFontRenderingState() {
- applyHostEmbeddedSizeSettings();
- applyHostFontAppearance();
- const root = PAGE_ROOT;
- const external = root.hasAttribute(EXTERNAL_FONT_RENDERING_MARKER) &&
- getComputedStyle(root).getPropertyValue('--fr-render-text').trim() !== '';
- const mode = external ? 'external' : PREFS.fontRenderingEnabled ? 'builtin' : 'off';
- root.dataset.ldpFontRendering = mode;
- root.dataset.ldpFontRenderingHost = String(
- mode === 'builtin' && PREFS.fontRenderingOnHost === true
- );
- if (READER_PORTAL_HOST) READER_PORTAL_HOST.dataset.ldpFontRendering = mode;
- readerPortalQueryAll('.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox')
- .forEach((surface) => { surface.dataset.ldpFontRendering = mode; });
- readerPortalQueryAll('.ldp-font-rendering-enabled').forEach((input) => {
- input.checked = PREFS.fontRenderingEnabled !== false;
- input.disabled = external;
- if (external) input.dataset.ldpTooltipLabel = '已由 F9y4ng 字体渲染工具接管';
- else delete input.dataset.ldpTooltipLabel;
- });
- readerPortalQueryAll('.ldp-font-rendering-on-host').forEach((input) => {
- input.checked = PREFS.fontRenderingOnHost === true;
- input.disabled = external || PREFS.fontRenderingEnabled === false;
- if (external) input.dataset.ldpTooltipLabel = '已由 F9y4ng 字体渲染工具接管';
- else delete input.dataset.ldpTooltipLabel;
- });
- readerPortalQueryAll('.ldp-font-rendering-status').forEach((status) => {
- status.dataset.mode = mode;
- status.textContent = external
- ? '检测到 F9y4ng 字体渲染,内置效果已自动让位,避免双重描边与阴影。'
- : mode === 'builtin'
- ? PREFS.fontRenderingOnHost
- ? `字体显示优化已应用到增强阅读器和 ${SITE_DISPLAY_NAME} 原站页面。`
- : `字体显示优化已应用到增强阅读器;${SITE_DISPLAY_NAME} 原站页面保持原样。`
- : '字体显示优化已关闭。';
- });
- }
-
- new MutationObserver((mutations) => {
- if (mutations.some((mutation) => mutation.attributeName === EXTERNAL_FONT_RENDERING_MARKER)) {
- syncReaderFontRenderingState();
- [50, 250, 1000].forEach((delay) => {
- setTimeout(syncReaderFontRenderingState, delay);
- });
- }
- }).observe(PAGE_ROOT, {
- attributes: true,
- attributeFilter: [EXTERNAL_FONT_RENDERING_MARKER],
- });
- window.addEventListener('load', syncReaderFontRenderingState, { once: true });
- syncReaderFontRenderingState();
-
- function setPrefs(patch) {
- PREFS = normalizeReaderPrefs(Object.assign({}, PREFS, patch));
- try { localStorage.setItem(LDP_PREF_KEY, JSON.stringify(PREFS)); } catch {}
- if (Object.keys(patch).some((key) => /^(?:fontRendering|hostFont|hostEmbedded)/.test(key))) syncReaderFontRenderingState();
- }
-
- function replacePrefsAndReload(nextPrefs, failureMessage) {
- const normalized = normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, nextPrefs || {}));
- try {
- localStorage.setItem(LDP_PREF_KEY, JSON.stringify(normalized));
- PREFS = normalized;
- syncReaderFontRenderingState();
- } catch {
- showSelectionToast(failureMessage);
- return;
- }
- location.reload();
- }
-
- const READER_CONFIG_SETTING_KEYS = Object.freeze(
- Object.keys(normalizeReaderPrefs({ ...DEFAULT_PREFS }))
- );
-
- function readerConfigExportPayload() {
- const normalized = normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, PREFS));
- const settings = READER_CONFIG_SETTING_KEYS.reduce((result, key) => {
- result[key] = normalized[key];
- return result;
- }, {});
- return {
- format: LDP_CONFIG_EXPORT_FORMAT,
- schemaVersion: LDP_CONFIG_EXPORT_VERSION,
- scriptVersion: READER_VERSION,
- exportedAt: new Date().toISOString(),
- settingsCount: Object.keys(settings).length,
- settings,
- };
- }
-
- function readerPrefsFromConfigExport(payload) {
- const schemaVersion = Number(payload?.schemaVersion);
- const settingKeys = READER_CONFIG_SETTING_KEYS;
- const payloadSettingKeys = payload?.settings && typeof payload.settings === 'object' &&
- !Array.isArray(payload.settings)
- ? Object.keys(payload.settings)
- : [];
- const importedSource = payload?.settings && typeof payload.settings === 'object' &&
- !Array.isArray(payload.settings)
- ? { ...payload.settings }
- : {};
- const legacyLayoutProfile = schemaVersion === LDP_CONFIG_EXPORT_VERSION &&
- !Object.hasOwn(importedSource, 'fullpageLayoutProfile') &&
- payloadSettingKeys.every((key) => settingKeys.includes(key)) &&
- settingKeys.every((key) => key === 'fullpageLayoutProfile' || Object.hasOwn(importedSource, key));
- if (legacyLayoutProfile) {
- importedSource.fullpageLayoutProfile = READER_FULLPAGE_LAYOUT_DEFAULT;
- }
- const legacyNativeComposerClosePreference = schemaVersion === LDP_CONFIG_EXPORT_VERSION &&
- !Object.hasOwn(importedSource, 'confirmNativeComposerClose') &&
- payloadSettingKeys.every((key) => settingKeys.includes(key)) &&
- settingKeys.every((key) => ['fullpageLayoutProfile', 'confirmNativeComposerClose'].includes(key) ||
- Object.hasOwn(importedSource, key));
- if (legacyNativeComposerClosePreference) {
- if (!Object.hasOwn(importedSource, 'fullpageLayoutProfile'))
- importedSource.fullpageLayoutProfile = READER_FULLPAGE_LAYOUT_DEFAULT;
- importedSource.confirmNativeComposerClose = true;
- }
- const importedSettingKeys = Object.keys(importedSource);
- if (!payload || payload.format !== LDP_CONFIG_EXPORT_FORMAT ||
- schemaVersion !== LDP_CONFIG_EXPORT_VERSION ||
- !payload.settings || typeof payload.settings !== 'object' || Array.isArray(payload.settings) ||
- Number(payload.settingsCount) !== payloadSettingKeys.length ||
- importedSettingKeys.some((key) => !settingKeys.includes(key)) ||
- settingKeys.some((key) => !Object.hasOwn(importedSource, key))) {
- throw new Error('invalid_config');
- }
- const importedSettings = settingKeys.reduce((settings, key) => {
- if (Object.hasOwn(importedSource, key)) settings[key] = importedSource[key];
- return settings;
- }, {});
- return normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, importedSettings));
- }
-
- function downloadReaderConfigExport() {
- const blob = new window.Blob([`${JSON.stringify(readerConfigExportPayload(), null, 2)}\n`], { type: 'application/json' });
- triggerBrowserDownload(blob, `awesome-linuxdo-reader-settings-${new Date().toISOString().slice(0, 10)}.json`);
- }
-
- function setPref(key, value) {
- setPrefs({ [key]: value });
- }
-
- const READER_SYSTEM_THEME_QUERY = isFunction(window.matchMedia)
- ? window.matchMedia('(prefers-color-scheme: dark)')
- : null;
-
- let linuxDoThemeEventService = null;
- let linuxDoThemeChangeInProgress = false;
-
- function linuxDoInterfaceColorService() {
- return lookupDiscourse('service:interface-color');
- }
-
- function linuxDoNativeThemeMode(service = linuxDoInterfaceColorService()) {
- if (service && service.colorMode === 'light') return 'light';
- if (service && service.colorMode === 'dark') return 'dark';
- if (service && service.colorMode === 'auto') return 'system';
- if (service?.lightModeForced) return 'light';
- if (service?.darkModeForced) return 'dark';
- if (service?.selectorAvailable) return 'system';
- if (service?.session) return service.session.defaultColorSchemeIsDark ? 'dark' : 'light';
- return '';
- }
-
- function readerUsesOwnThemePalette() {
- return !!CURRENT_OVERLAY;
- }
-
- function currentReaderThemeMode() {
- return readerUsesOwnThemePalette()
- ? normalizeReaderThemeMode(PREFS.themeMode)
- : linuxDoNativeThemeMode() || normalizeReaderThemeMode(PREFS.themeMode);
- }
-
- function resolvedReaderThemeMode(mode = currentReaderThemeMode()) {
- const normalized = normalizeReaderThemeMode(mode);
- return normalized === 'system' && READER_SYSTEM_THEME_QUERY && READER_SYSTEM_THEME_QUERY.matches
- ? 'dark'
- : normalized === 'dark' ? 'dark' : 'light';
- }
-
- function applyReaderThemeVariables(node, mode = currentReaderThemeMode(), ownPalette = readerUsesOwnThemePalette()) {
- if (!node || !node.style) return;
- const normalized = normalizeReaderThemeMode(mode);
- const resolved = resolvedReaderThemeMode(normalized);
- node.dataset.ldpThemeMode = normalized;
- node.dataset.ldpTheme = resolved;
- node.style.colorScheme = resolved;
- Object.entries(READER_THEME_VARIABLES[resolved]).forEach(([property, value]) => {
- if (ownPalette || property.startsWith('--ldp-')) node.style.setProperty(property, value);
- else node.style.removeProperty(property);
- });
- applyAppearanceInteractionColors(node, currentAppearanceProfile(), normalized);
- }
-
- function bindLinuxDoNativeThemeEvents(service = linuxDoInterfaceColorService()) {
- if (service && linuxDoThemeEventService !== service && isFunction(service.appEvents?.on)) {
- if (linuxDoThemeEventService && isFunction(linuxDoThemeEventService.appEvents?.off))
- linuxDoThemeEventService.appEvents.off('interface-color:changed', document, onLinuxDoInterfaceColorChanged);
- service.appEvents.on('interface-color:changed', document, onLinuxDoInterfaceColorChanged);
- linuxDoThemeEventService = service;
- }
- }
-
- function applyLinuxDoNativeTheme(mode = currentReaderThemeMode()) {
- const service = linuxDoInterfaceColorService();
- bindLinuxDoNativeThemeEvents(service);
- const normalized = normalizeReaderThemeMode(mode);
- const actions = normalized === 'light'
- ? ['forceLightMode']
- : normalized === 'dark' ? ['forceDarkMode'] : ['useAutoMode'];
- const action = actions.find((name) => isFunction(service?.[name]));
- if (!action) return false;
- linuxDoThemeChangeInProgress = true;
- try {
- service[action]();
- return true;
- } catch {
- return false;
- } finally {
- linuxDoThemeChangeInProgress = false;
- }
- }
-
- function syncReaderThemeButtons(
- buttons = readerPortalQueryAll('.ldp-settings-theme-button'),
- mode = currentReaderThemeMode()
- ) {
- const normalizedMode = normalizeReaderThemeMode(mode);
- buttons.forEach((button) => {
- const buttonMode = normalizeReaderThemeMode(button.dataset.readerThemeMode);
- const active = buttonMode === normalizedMode;
- button.classList.toggle('active', active);
- setPressed(button, active);
- setLabel(button, `主题:${READER_THEME_LABELS[buttonMode]}${active ? '(当前)' : ''}`);
- });
- }
-
- function syncReaderThemeSurfaces(themeMode = currentReaderThemeMode()) {
- const mode = normalizeReaderThemeMode(themeMode);
- const ownPalette = readerUsesOwnThemePalette();
- bindLinuxDoNativeThemeEvents();
- [...READER_PORTAL_CONTEXT_BODY?.children || []].forEach((node) =>
- applyReaderThemeVariables(node, mode, ownPalette));
- if (CURRENT_OVERLAY && !CURRENT_OVERLAY.isConnected) applyReaderThemeVariables(CURRENT_OVERLAY, mode, ownPalette);
- document.querySelectorAll('[data-ldp-reader-composer-root]').forEach((node) => applyReaderThemeVariables(node, mode, ownPalette));
- syncReaderThemeButtons(undefined, mode);
- if (isFunction(CURRENT_OVERLAY?._ldpApplyThemeDependentColors))
- CURRENT_OVERLAY._ldpApplyThemeDependentColors();
- }
-
- function onLinuxDoInterfaceColorChanged() {
- if (linuxDoThemeChangeInProgress) return;
- const mode = linuxDoNativeThemeMode();
- if (!mode) return;
- if (normalizeReaderThemeMode(PREFS.themeMode) !== mode) setPref('themeMode', mode);
- syncReaderThemeSurfaces(mode);
- }
-
- function transitionReaderTheme(mode, options = {}) {
- const nextMode = normalizeReaderThemeMode(mode);
- const persist = options.persist !== false;
- const applyHost = options.applyHost !== false;
- if (persist) setPref('themeMode', nextMode);
- if (applyHost) applyLinuxDoNativeTheme(nextMode);
- syncReaderThemeSurfaces(nextMode);
- }
-
- const onReaderSystemThemeChange = () => {
- if (currentReaderThemeMode() === 'system')
- transitionReaderTheme('system', { persist: false, applyHost: false });
- };
- READER_SYSTEM_THEME_QUERY?.addEventListener('change', onReaderSystemThemeChange);
-
- function balanceLayoutRatios(source, adjustable) {
- const result = { ...source };
- const difference = roundLayoutRatio(100 - layoutRatioTotal(result));
- const direction = difference > 0 ? 1 : -1;
- let remaining = Math.abs(difference);
- adjustable.forEach((name) => {
- if (remaining <= 0) return;
- const capacity = roundLayoutRatio(direction > 0
- ? 100 - result[name]
- : Math.max(0, result[name] - LAYOUT_MIN_RATIOS[name]));
- const change = Math.min(capacity, remaining);
- result[name] = roundLayoutRatio(result[name] + direction * change);
- remaining = roundLayoutRatio(remaining - change);
- });
- return result;
- }
-
- function rebalanceLayoutRatiosAfterChange(source, editedName) {
- return balanceLayoutRatios(source,
- ['main', 'left', 'right', 'gap', 'timeline'].filter((name) => name !== editedName));
- }
-
- function currentLayoutRatios(fullPage = false) {
- const defaults = fullPage ? READER_FULLPAGE_LAYOUT_DEFAULT : READER_LAYOUT_DEFAULT;
- const profile = fullPage ? PREFS.fullpageLayoutProfile : PREFS.layoutProfile;
- return normalizeLayoutRatios(profile, defaults);
- }
-
- function currentFontProfile() {
- return normalizeFontProfile(PREFS.fontProfile, READER_FONT_DEFAULT);
- }
-
- function currentPerformanceConfig() {
- return performanceConfigFromPrefs(PREFS);
- }
-
- function setPerformancePreset(name) {
- const preset = PERFORMANCE_PRESETS[name];
- if (!preset) return;
- setPrefs(performancePrefsPatch(preset, name));
- }
-
- function setPerformanceValue(name, value) {
- const prefKey = PERFORMANCE_PREF_KEYS[name];
- if (!prefKey) return;
- const current = currentPerformanceConfig();
- const next = normalizePerformanceValue(name, value, current[name]);
- setPrefs({ [prefKey]: next });
- }
-
- function normalizeImageScalePreset(value) {
- if (value === 'custom') return 'custom';
- const numeric = Number(value);
- return IMAGE_SCALE_OPTIONS.includes(numeric) ? String(numeric) : '100';
- }
-
- function normalizeRoundedRange(value, fallback, minimum, maximum) {
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) return fallback;
- return Math.min(maximum, Math.max(minimum, Math.round(numeric)));
- }
-
- function normalizeImageScalePercent(value, fallback = 100) { return normalizeRoundedRange(value, fallback, IMAGE_SCALE_MIN, IMAGE_SCALE_MAX); }
-
- function normalizeImageProfile(source, fallback = IMAGE_PROFILE_DEFAULT) {
- const values = source || {};
- return {
- preset: normalizeImageScalePreset(values.preset == null ? fallback.preset : values.preset),
- custom: normalizeImageScalePercent(values.custom, fallback.custom),
- };
- }
-
- function currentImageProfile() {
- return normalizeImageProfile(PREFS.imageProfile, IMAGE_PROFILE_DEFAULT);
- }
-
- function currentImageScalePercent() {
- const values = currentImageProfile();
- const preset = normalizeImageScalePreset(values.preset);
- if (preset === 'custom') return normalizeImageScalePercent(values.custom, 100);
- return normalizeImageScalePercent(preset, 100);
- }
-
- function normalizeFontScale(value, fallback) { return normalizeRoundedRange(value, fallback, FONT_SCALE_MIN, FONT_SCALE_MAX); }
-
- function normalizeHostEmbeddedSizeScale(value, fallback = HOST_EMBED_SIZE_DEFAULT) { return normalizeRoundedRange(value, fallback, HOST_EMBED_SIZE_MIN, HOST_EMBED_SIZE_MAX); }
-
- function applyHostEmbeddedSizeSettings() {
- const style = PAGE_ROOT.style;
- Object.values(HOST_EMBED_SIZE_SETTINGS).forEach((setting) => {
- setting.properties.forEach(([property, basePixels]) => {
- const pixels = Math.round(basePixels * normalizeHostEmbeddedSizeScale(PREFS[setting.pref]) * 10 / 100) / 10;
- style.setProperty(property, `${pixels}px`);
- });
- });
- }
-
- function applyHostFontAppearance() {
- const style = PAGE_ROOT.style;
- const family = normalizeFontFamily(PREFS.hostFontFamily, HOST_FONT_DEFAULTS.hostFontFamily);
- const color = normalizeHexColor(PREFS.hostFontColor, HOST_FONT_DEFAULTS.hostFontColor);
- if (family === 'site') style.removeProperty('--ldp-host-font-family');
- else style.setProperty('--ldp-host-font-family', resolvedFontFamily(PREFS, 'host'));
- style.setProperty('--ldp-host-font-weight', String(normalizeFontWeight(PREFS.hostFontWeight, HOST_FONT_DEFAULTS.hostFontWeight)));
- if (color) style.setProperty('--ldp-host-font-color', color);
- else style.removeProperty('--ldp-host-font-color');
- }
-
- function normalizeFontFamily(value, fallback = FONT_FAMILY_DEFAULT) {
- return Object.hasOwn(FONT_FAMILY_OPTIONS, value) ? value : fallback;
- }
-
- function normalizeCustomFontFamily(value, fallback = '') {
- const source = String(value == null ? fallback : value)
- .replace(/[\u0000-\u001f\u007f"'`,;{}<>\\]/g, '')
- .replace(/\s+/g, ' ')
- .trim();
- return [...source].slice(0, 64).join('');
- }
-
- function normalizeFontWeight(value, fallback = FONT_WEIGHT_DEFAULT) {
- const numeric = Number(value);
- return FONT_WEIGHT_OPTIONS.includes(numeric) ? numeric : fallback;
- }
-
- function normalizeHexColor(value, fallback = '') {
- const color = String(value || '').trim().toLowerCase();
- if (/^#[0-9a-f]{6}$/.test(color)) return color;
- const fallbackColor = String(fallback || '').trim().toLowerCase();
- return /^#[0-9a-f]{6}$/.test(fallbackColor) ? fallbackColor : '';
- }
-
- function resolvedSiteFontFamily() {
- try {
- const target = document.body || PAGE_ROOT;
- const family = target && getComputedStyle(target).fontFamily;
- return family && family !== 'inherit' ? family : 'inherit';
- } catch {
- return 'inherit';
- }
- }
-
- function resolvedFontFamily(values, scope = 'interface') {
- const config = scope === 'host' ? HOST_FONT_CONFIG : FONT_SCOPE_CONFIG[scope] || FONT_SCOPE_CONFIG.interface;
- const family = normalizeFontFamily(values?.[config.family]);
- if (family !== 'custom') return family === 'site' ? resolvedSiteFontFamily() : FONT_FAMILY_OPTIONS[family].stack;
- const customFamily = normalizeCustomFontFamily(values?.[config.customFamily]);
- return customFamily
- ? `${JSON.stringify(customFamily)},${FONT_FAMILY_OPTIONS.system.stack}`
- : FONT_FAMILY_OPTIONS.system.stack;
- }
-
- function applyReaderFontAppearance(styleDeclaration, values) {
- if (!styleDeclaration) return;
- FONT_SCOPE_KEYS.forEach((scope) => {
- const config = FONT_SCOPE_CONFIG[scope];
- const prefix = `--ldp-${scope}-font`;
- const color = normalizeHexColor(values?.[config.color]);
- styleDeclaration.setProperty(`${prefix}-family`, resolvedFontFamily(values, scope));
- styleDeclaration.setProperty(`${prefix}-weight`, String(normalizeFontWeight(values?.[config.weight])));
- if (color) styleDeclaration.setProperty(`${prefix}-color`, color);
- else styleDeclaration.removeProperty(`${prefix}-color`);
- });
- styleDeclaration.setProperty('--ldp-reader-font-weight-base', String(normalizeFontWeight(values?.weight)));
- }
-
- function clearReaderFontAppearance(styleDeclaration) {
- if (!styleDeclaration) return;
- FONT_SCOPE_KEYS.forEach((scope) => {
- const prefix = `--ldp-${scope}-font`;
- styleDeclaration.removeProperty(`${prefix}-family`);
- styleDeclaration.removeProperty(`${prefix}-weight`);
- styleDeclaration.removeProperty(`${prefix}-color`);
- });
- styleDeclaration.removeProperty('--ldp-reader-font-weight-base');
- }
-
- function normalizeFontProfile(source, fallback = READER_FONT_DEFAULT) {
- const values = source || {};
- const profile = {};
- const interfaceConfig = FONT_SCOPE_CONFIG.interface;
- FONT_SCOPE_KEYS.forEach((scope) => {
- const config = FONT_SCOPE_CONFIG[scope];
- const inherited = scope === 'interface' ? fallback : profile;
- profile[config.family] = normalizeFontFamily(values[config.family], inherited[interfaceConfig.family]);
- profile[config.customFamily] = normalizeCustomFontFamily(values[config.customFamily], inherited[interfaceConfig.customFamily]);
- profile[config.weight] = normalizeFontWeight(values[config.weight], inherited[interfaceConfig.weight]);
- profile[config.color] = normalizeHexColor(values[config.color], fallback[config.color]);
- profile[config.scale] = normalizeFontScale(values[config.scale], fallback[config.scale]);
- });
- return profile;
- }
-
- function fontScalePixels(basePixels, value, fallback = FONT_SCALE_DEFAULT) {
- const scale = normalizeFontScale(value, fallback) / 100;
- return `${Math.round(basePixels * scale * 100) / 100}px`;
- }
-
- function applyInterfaceFontScale(styleDeclaration, value) {
- if (!styleDeclaration) return;
- const scale = normalizeFontScale(value, FONT_SCALE_DEFAULT);
- Object.entries(INTERFACE_FONT_TOKEN_BASES).forEach(([property, basePixels]) => {
- styleDeclaration.setProperty(property, fontScalePixels(basePixels, scale));
- });
- }
-
- function clearInterfaceFontScale(styleDeclaration) {
- if (!styleDeclaration) return;
- Object.keys(INTERFACE_FONT_TOKEN_BASES).forEach((property) => styleDeclaration.removeProperty(property));
- }
-
- function normalizeSteppedValue(value, fallback, minimum, maximum, step) {
- const parsed = Number(value);
- const safeValue = Number.isFinite(parsed) ? parsed : fallback;
- return Math.min(maximum, Math.max(minimum, Math.round(safeValue / step) * step));
- }
-
- function appearanceDarkColorKey(key) {
- return `${key}Dark`;
- }
-
- function appearanceSettingValueFields(key) {
- return APPEARANCE_COLOR_KEYS.includes(key) ? [key, appearanceDarkColorKey(key)] : [key];
- }
-
- function hexColorToHsl(value) {
- const color = normalizeHexColor(value);
- if (!color) return null;
- const [red, green, blue] = [1, 3, 5].map((index) =>
- parseInt(color.slice(index, index + 2), 16) / 255);
- const maximum = Math.max(red, green, blue);
- const minimum = Math.min(red, green, blue);
- const delta = maximum - minimum;
- const lightness = (maximum + minimum) / 2;
- const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));
- let hue = 0;
- if (delta !== 0) {
- if (maximum === red) hue = 60 * (((green - blue) / delta) % 6);
- else if (maximum === green) hue = 60 * ((blue - red) / delta + 2);
- else hue = 60 * ((red - green) / delta + 4);
- }
- return {
- hue: hue < 0 ? hue + 360 : hue,
- saturation: saturation * 100,
- lightness: lightness * 100,
- };
- }
-
- function hslColorToHex(hue, saturation, lightness) {
- const normalizedHue = ((Number(hue) % 360) + 360) % 360;
- const normalizedSaturation = Math.min(100, Math.max(0, Number(saturation))) / 100;
- const normalizedLightness = Math.min(100, Math.max(0, Number(lightness))) / 100;
- const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
- const intermediate = chroma * (1 - Math.abs((normalizedHue / 60) % 2 - 1));
- const offset = normalizedLightness - chroma / 2;
- const sector = Math.floor(normalizedHue / 60);
- const channels = [
- [chroma, intermediate, 0], [intermediate, chroma, 0], [0, chroma, intermediate],
- [0, intermediate, chroma], [intermediate, 0, chroma], [chroma, 0, intermediate],
- ][sector] || [0, 0, 0];
- return `#${channels.map((channel) =>
- Math.round((channel + offset) * 255).toString(16).padStart(2, '0')).join('')}`;
- }
-
- function adaptAppearanceColorToTheme(key, value, mode) {
- const resolved = mode === 'dark' ? 'dark' : 'light';
- const valueKey = resolved === 'dark' ? appearanceDarkColorKey(key) : key;
- const normalized = normalizeHexColor(value, APPEARANCE_PROFILE_DEFAULT[valueKey]);
- if (normalized === APPEARANCE_PROFILE_DEFAULT[key])
- return APPEARANCE_PROFILE_DEFAULT[valueKey];
- const limits = APPEARANCE_COLOR_THEME_LIMITS[key];
- const hsl = limits && hexColorToHsl(normalized);
- if (!hsl) return normalized;
- const [minimumLightness, maximumLightness] = limits[resolved];
- const saturation = Math.min(hsl.saturation, limits.saturationMax);
- const lightness = Math.min(maximumLightness, Math.max(minimumLightness, hsl.lightness));
- if (Math.abs(saturation - hsl.saturation) < 0.01 && Math.abs(lightness - hsl.lightness) < 0.01)
- return normalized;
- return hslColorToHex(hsl.hue, saturation, lightness);
- }
-
- function appearanceThemeValueKey(key, mode = resolvedReaderThemeMode()) {
- return APPEARANCE_COLOR_KEYS.includes(key) && mode === 'dark'
- ? appearanceDarkColorKey(key)
- : key;
- }
-
- function appearanceThemeColor(values, key, mode = resolvedReaderThemeMode()) {
- return adaptAppearanceColorToTheme(key, values?.[key], mode);
- }
-
- function normalizeAppearanceProfile(source, fallback = APPEARANCE_PROFILE_DEFAULT) {
- const values = source || {};
- const profile = APPEARANCE_SETTING_FIELDS.reduce((result, field) => {
- if (field.range) {
- result[field.key] = field.normalize(values[field.key], fallback[field.key]);
- return result;
- }
- const lightFallback = normalizeHexColor(fallback[field.key], APPEARANCE_PROFILE_DEFAULT[field.key]);
- const storedLight = normalizeHexColor(values[field.key]);
- const darkKey = appearanceDarkColorKey(field.key);
- const storedDark = normalizeHexColor(values[darkKey]);
- const sourceColor = storedLight || storedDark || lightFallback;
- result[field.key] = sourceColor;
- result[darkKey] = sourceColor;
- return result;
- }, {});
- profile.structureColorsEnabled = typeof values.structureColorsEnabled === 'boolean'
- ? values.structureColorsEnabled
- : fallback.structureColorsEnabled !== false;
- return profile;
- }
-
- function currentAppearanceProfile() {
- return normalizeAppearanceProfile(PREFS.appearanceProfile, APPEARANCE_PROFILE_DEFAULT);
- }
-
- function applyAppearanceInteractionColors(node, values = currentAppearanceProfile(), mode = currentReaderThemeMode()) {
- if (!node || !node.style) return;
- const appearance = values || APPEARANCE_PROFILE_DEFAULT;
- const resolved = resolvedReaderThemeMode(mode);
- const accent = appearanceThemeColor(appearance, 'accentColor', resolved);
- const accentDefault = APPEARANCE_PROFILE_DEFAULT[appearanceThemeValueKey('accentColor', resolved)];
- node.style.setProperty('--tertiary', accent);
- node.style.setProperty('--tertiary-low', accent === accentDefault
- ? READER_THEME_VARIABLES[resolved]['--tertiary-low']
- : `color-mix(in srgb,${accent} 18%,var(--secondary,#fff))`);
- node.style.setProperty('--d-link-color', appearanceThemeColor(appearance, 'linkColor', resolved));
- }
-
- function normalizeLineWidth(value, fallback) {
- return normalizeSteppedValue(value, fallback, LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP);
- }
-
- function normalizeReplyLineRadius(value, fallback = REPLY_LINE_RADIUS_DEFAULT) {
- return normalizeSteppedValue(
- value, fallback, REPLY_LINE_RADIUS_MIN, REPLY_LINE_RADIUS_MAX, REPLY_LINE_RADIUS_STEP
- );
- }
-
- function applyComposerFontScale(value = currentFontProfile().composer) {
- const scale = normalizeFontScale(value, COMPOSER_FONT_SCALE_DEFAULT);
- PAGE_ROOT.style.setProperty('--ldp-composer-font-size', `${scale}%`);
- }
- applyComposerFontScale();
-
- function setPageScrollLocked(locked) {
- PAGE_ROOT.classList.toggle(LDP_SCROLL_LOCK_CLASS, locked);
- }
-
- function currentListReaderMode() {
- return normalizeListReaderMode(PREFS.listReaderMode);
- }
-
- function currentTopicReaderMode() {
- return normalizeTopicReaderMode(PREFS.topicReaderMode);
- }
-
- function currentListReaderEmbedWidth() {
- return normalizeListReaderEmbedWidth(PREFS.listReaderEmbedWidth);
- }
-
- function readerPresentation(mode) {
- return READER_PRESENTATIONS[mode] || READER_PRESENTATIONS.floating;
- }
-
- function chromeExtensionRuntime() {
- try {
- if (typeof chrome === 'undefined' || !chrome.runtime || !chrome.runtime.id ||
- !isFunction(chrome.runtime.sendMessage)) return null;
- return chrome.runtime;
- } catch {
- return null;
- }
- }
-
- async function sendChromeExtensionMessage(message) {
- const runtime = chromeExtensionRuntime();
- if (!runtime) return { ok: false, reason: 'extension-unavailable' };
- try {
- const response = await runtime.sendMessage(message);
- return response && typeof response === 'object'
- ? response
- : { ok: false, reason: 'empty-response' };
- } catch (error) {
- return {
- ok: false,
- reason: error?.message ? error.message : 'extension-message-failed',
- };
- }
- }
-
- function setChromeSidePanelActive(active) {
- PAGE_ROOT.classList.toggle('ldp-reader-sidepanel-active', !!active);
- PAGE_ROOT.classList.toggle('ldp-reader-workspace', !!active);
- }
-
- async function closeChromeSidePanel() {
- const result = await sendChromeExtensionMessage({ type: 'ldp-sidepanel-close' });
- setChromeSidePanelActive(false);
- return result || { ok: false, reason: 'sidepanel-close-failed' };
- }
-
- async function closeChromeSidePanelForReader() {
- const result = await closeChromeSidePanel();
- if (!chromeExtensionRuntime() || result.ok) return true;
- showSelectionToast('请先关闭 Chrome 侧边栏,再打开浮窗阅读器');
- return false;
- }
-
- function installChromeSidePanelBridge() {
- const runtime = chromeExtensionRuntime();
- if (!runtime || !runtime.onMessage || !isFunction(runtime.onMessage.addListener)) return;
- runtime.onMessage.addListener((message) => {
- if (!message || message.type !== 'ldp-sidepanel-state') return;
- setChromeSidePanelActive(!!message.active);
- });
- }
- installChromeSidePanelBridge();
-
- function userProfileUrl(username) {
- const discourseUrl = lookupDiscourseModule('discourse/lib/url');
- return isFunction(discourseUrl?.userPath)
- ? discourseUrl.userPath(encodeURIComponent(username || ''))
- : '';
- }
-
- function userActivityUrl(username, path) {
- const suffix = String(path || '').replace(/^\/+/, '');
- const discourseUrl = lookupDiscourseModule('discourse/lib/url');
- const userPath = encodeURIComponent(username || '');
- return isFunction(discourseUrl?.userPath)
- ? discourseUrl.userPath(suffix ? `${userPath}/${suffix}` : userPath)
- : '';
- }
-
- function discoursePageUrl(path) {
- const getUrlModule = lookupDiscourseModule('discourse/lib/get-url');
- const getURL = getUrlModule?.default;
- if (!isFunction(getURL)) return '';
- const resolved = getURL(String(path || ''));
- return resolved ? new URL(resolved, location.origin).href : '';
- }
-
- function topicPageUrl(topicId, postNumber = 0, bypassReader = false) {
- const safeTopicId = Math.max(0, Number(topicId) || 0);
- const safePostNumber = Math.max(0, Number(postNumber) || 0);
- if (!safeTopicId) return '';
- const href = discoursePageUrl(`/t/${safeTopicId}${safePostNumber ? `/${safePostNumber}` : ''}`);
- if (!href || !bypassReader) return href;
- const url = new URL(href);
- url.searchParams.set(NATIVE_BYPASS_PARAM, '1');
- return url.href;
- }
-
- function topicNavText(value) {
- if (value == null) return '';
- if (typeof value === 'object') return String(value.name || value.slug || value.id || '');
- return String(value);
- }
-
- function cleanTopicCategoryName(value) {
- return topicNavText(value)
- .replace(/\s*[,,]\s*Lv\s*\d+\s*$/i, '')
- .replace(/\s+/g, ' ')
- .trim();
- }
-
- function safeTopicNavIconHtml(value) {
- const html = String(value || '').trim();
- if (!/^<(?:svg|img)\b/i.test(html)) return '';
- if (/<(?:script|foreignObject|use)\b/i.test(html) || /\son[a-z:-]*\s*=/i.test(html) ||
- /(?:href|src)\s*=\s*["']?\s*javascript:/i.test(html)) return '';
- return html;
- }
-
- function inlineTopicNavSvgUses(svg) {
- if (!svg || svg.tagName?.toLowerCase() !== 'svg') return true;
- const uses = [...svg.querySelectorAll('use')];
- if (!uses.length) return true;
- let complete = true;
- uses.forEach((use) => {
- const href = String(
- use.getAttribute('href') ||
- use.getAttribute('xlink:href') ||
- use.getAttributeNS('http://www.w3.org/1999/xlink', 'href') ||
- ''
- );
- const symbolId = href.startsWith('#') ? href.slice(1) : '';
- const symbol = symbolId ? document.getElementById(symbolId) : null;
- if (!symbol || !['symbol', 'g'].includes(symbol.tagName?.toLowerCase())) {
- complete = false;
- return;
- }
- if (!svg.hasAttribute('viewBox') && symbol.hasAttribute('viewBox'))
- svg.setAttribute('viewBox', symbol.getAttribute('viewBox'));
- const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
- [...use.attributes].forEach((attribute) => {
- if (attribute.name === 'href' || attribute.name === 'xlink:href') return;
- if (attribute.namespaceURI)
- group.setAttributeNS(attribute.namespaceURI, attribute.name, attribute.value);
- else
- group.setAttribute(attribute.name, attribute.value);
- });
- [...symbol.childNodes].forEach((child) => group.appendChild(child.cloneNode(true)));
- use.replaceWith(group);
- });
- return complete && !svg.querySelector('use');
- }
-
- function topicNavIconCacheKeys(type, value) {
- if (type === 'tag') {
- const name = topicNavText(value && typeof value === 'object' ? value.name : value).trim().toLocaleLowerCase();
- return name ? [`tag:${name}`] : [];
- }
- const metadata = value && typeof value === 'object' ? value : { categoryName: value };
- const keys = [];
- const categoryId = Number(metadata.categoryId || metadata.category_id) || 0;
- const categorySlug = topicNavText(metadata.categorySlug || metadata.category_slug).trim().toLocaleLowerCase();
- const categoryName = cleanTopicCategoryName(metadata.categoryName || metadata.category_name).toLocaleLowerCase();
- if (categoryId) keys.push(`category:id:${categoryId}`);
- if (categorySlug) keys.push(`category:slug:${categorySlug}`);
- if (categoryName) keys.push(`category:name:${categoryName}`);
- return keys;
- }
-
- function rememberTopicNavIcon(type, value, iconHtml) {
- const html = safeTopicNavIconHtml(iconHtml);
- const keys = topicNavIconCacheKeys(type, value);
- if (!html || !keys.length) return '';
- let changed = false;
- keys.forEach((key) => {
- const existing = TOPIC_NAV_ICON_CACHE.get(key);
- TOPIC_NAV_ICON_CACHE.delete(key);
- if (existing && existing.html === html) {
- TOPIC_NAV_ICON_CACHE.set(key, existing);
- return;
- }
- TOPIC_NAV_ICON_CACHE.set(key, { html, at: Date.now() });
- changed = true;
- });
- if (changed) {
- trimPersistentCacheMap('topicNavIcons');
- schedulePersistentCacheWrite('topicNavIcons', 500);
- }
- return html;
- }
-
- function cachedTopicNavIcon(type, value) {
- const keys = topicNavIconCacheKeys(type, value);
- for (const key of keys) {
- const entry = TOPIC_NAV_ICON_CACHE.get(key);
- if (!entry) continue;
- const html = safeTopicNavIconHtml(entry.html);
- if (!html) {
- TOPIC_NAV_ICON_CACHE.delete(key);
- schedulePersistentCacheWrite('topicNavIcons');
- continue;
- }
- TOPIC_NAV_ICON_CACHE.delete(key);
- TOPIC_NAV_ICON_CACHE.set(key, entry);
- return html;
- }
- return '';
- }
-
- function topicNavMetadataWithCachedIcons(metadata = {}) {
- const tagIcons = new Map();
- (Array.isArray(metadata.tagIcons) ? metadata.tagIcons : []).forEach((item) => {
- const name = topicNavText(item?.name).trim();
- const iconHtml = safeTopicNavIconHtml(item?.iconHtml);
- if (name && iconHtml) tagIcons.set(name, { name, iconHtml });
- });
- (Array.isArray(metadata.tags) ? metadata.tags : []).map(topicNavText).map((tag) => tag.trim()).filter(Boolean)
- .forEach((name) => {
- if (tagIcons.has(name)) return;
- const iconHtml = cachedTopicNavIcon('tag', name);
- if (iconHtml) tagIcons.set(name, { name, iconHtml });
- });
- return Object.assign({}, metadata, {
- categoryIconHtml: safeTopicNavIconHtml(metadata.categoryIconHtml) || cachedTopicNavIcon('category', metadata),
- tagIcons: [...tagIcons.values()],
- });
- }
-
- function cacheTopicNavMetadataIcons(metadata = {}) {
- rememberTopicNavIcon('category', metadata, metadata.categoryIconHtml);
- (Array.isArray(metadata.tagIcons) ? metadata.tagIcons : []).forEach((item) => {
- rememberTopicNavIcon('tag', item?.name, item?.iconHtml);
- });
- }
-
- function topicCategoryLevel(value) {
- const match = topicNavText(value).trim().match(/(?:^|[,,]\s*)Lv\s*(\d+)\s*$/i);
- return match ? `Lv${match[1]}` : '';
- }
-
- function topicNavIconHtml(link) {
- if (!link) return '';
- return [...link.querySelectorAll('svg,img')]
- .filter((node) => node.closest('a') === link)
- .map((node) => {
- const copy = node.cloneNode(true);
- if (copy.tagName?.toLowerCase() === 'svg' && !inlineTopicNavSvgUses(copy)) return '';
- copy.querySelectorAll('script,foreignObject').forEach((unsafe) => unsafe.remove());
- [copy, ...copy.querySelectorAll('*')].forEach((part) => {
- [...part.attributes || []].forEach((attribute) => {
- if (/^on/i.test(attribute.name)) part.removeAttribute(attribute.name);
- });
- });
- copy.setAttribute('aria-hidden', 'true');
- if (copy.tagName === 'IMG') copy.setAttribute('alt', '');
- return copy.outerHTML;
- })
- .join('');
- }
-
- function topicCategoryLinkMetadata(link) {
- if (!link) return {};
- let categoryId = Number(link.dataset.categoryId || 0) || 0;
- let categorySlug = String(link.dataset.categorySlug || '').trim();
- try {
- const url = new URL(link.getAttribute('href') || link.href || '', location.href);
- const parts = url.pathname.split('/').filter(Boolean);
- const categoryIndex = parts.indexOf('c');
- const categoryParts = categoryIndex >= 0 ? parts.slice(categoryIndex + 1) : [];
- const idIndex = categoryParts.findIndex((part) => /^\d+$/.test(part));
- if (!categoryId && idIndex >= 0) categoryId = Number(categoryParts[idIndex]) || 0;
- if (!categorySlug && categoryParts.length) {
- const slugPart = categoryParts.find((part, index) => index !== idIndex && !/^\d+$/.test(part));
- if (slugPart) categorySlug = decodeURIComponent(slugPart);
- }
- } catch {}
- const nameNode = link.querySelector('[data-category-name],.badge-category__name,.category-name');
- const categoryText = (nameNode && (nameNode.dataset.categoryName || nameNode.textContent)) ||
- link.dataset.categoryName || link.getAttribute('title') || link.textContent || '';
- const categoryName = cleanTopicCategoryName(categoryText);
- const categoryLevel = topicCategoryLevel(categoryText) || topicCategoryLevel(link.textContent) ||
- topicCategoryLevel(link.getAttribute('title'));
- return { categoryName, categoryLevel, categorySlug, categoryId, categoryIconHtml: topicNavIconHtml(link) };
- }
-
- function readTopicNavMetadata(sourceElement = null, expectedTopicId = 0) {
- const source = sourceElement && sourceElement.nodeType === Node.ELEMENT_NODE ? sourceElement : null;
- let root = null;
- if (source && !source.closest('.ldp-overlay'))
- root = source.closest('tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result,.category-topic-link')
- || source.parentElement;
- if (!root) {
- const currentRoute = extractTopicRouteFromUrl(location.href);
- if (expectedTopicId && (!currentRoute || Number(currentRoute.topicId) !== Number(expectedTopicId))) return {};
- root = [...document.querySelectorAll('#topic-title,.topic-title,.title-wrapper')]
- .find((node) => !node.closest('.ldp-overlay')) || null;
- }
- if (!root) return {};
- const categoryLink = root.querySelector(
- 'a.badge-category__wrapper,a.badge-category,a.topic-category,a[href^="/c/"],a[href*="/c/"]',
- );
- const metadata = topicCategoryLinkMetadata(categoryLink);
- const tagLinks = [...root.querySelectorAll('a.discourse-tag,.discourse-tags a,.topic-tags a')]
- .filter((node) => !node.closest('.ldp-overlay'));
- metadata.tags = tagLinks
- .map((node) => topicNavText(node.dataset.tagName || node.textContent).trim())
- .filter(Boolean);
- metadata.tagIcons = tagLinks.map((node) => ({
- name: topicNavText(node.dataset.tagName || node.textContent).trim(),
- iconHtml: topicNavIconHtml(node),
- })).filter((item) => item.name && item.iconHtml);
- cacheTopicNavMetadataIcons(metadata);
- return topicNavMetadataWithCachedIcons(metadata);
- }
-
- function mergeTopicNavMetadata(primary = {}, fallback = {}) {
- const primaryCategoryName = cleanTopicCategoryName(primary.categoryName || '');
- const fallbackCategoryName = cleanTopicCategoryName(fallback.categoryName || '');
- const sameCategory = !primaryCategoryName || !fallbackCategoryName || primaryCategoryName === fallbackCategoryName;
- const tagIcons = new Map();
- [fallback.tagIcons, primary.tagIcons].forEach((items) => {
- (Array.isArray(items) ? items : []).forEach((item) => {
- const name = topicNavText(item?.name).trim();
- const iconHtml = String(item?.iconHtml || '').trim();
- if (name && iconHtml) tagIcons.set(name, { name, iconHtml });
- });
- });
- const primaryTags = Array.isArray(primary.tags) ? primary.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean) : [];
- const fallbackTags = Array.isArray(fallback.tags) ? fallback.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean) : [];
- return topicNavMetadataWithCachedIcons({
- categoryName: primaryCategoryName || fallbackCategoryName,
- categoryLevel: topicCategoryLevel(primary.categoryLevel || fallback.categoryLevel || ''),
- categorySlug: topicNavText(primary.categorySlug || fallback.categorySlug || '').trim(),
- categoryId: Number(primary.categoryId || fallback.categoryId) || 0,
- categoryIconHtml: String(primary.categoryIconHtml || (sameCategory ? fallback.categoryIconHtml : '') || '').trim(),
- tags: primaryTags.length ? primaryTags : fallbackTags,
- tagIcons: [...tagIcons.values()],
- });
- }
-
- function topicNavMetadataHasIcons(metadata) {
- return !!(metadata && (metadata.categoryIconHtml || (Array.isArray(metadata.tagIcons) && metadata.tagIcons.length)));
- }
-
- function cachedTopicNavMetadata(topic) {
- if (!topic) return {};
- const category = topic.category || {};
- const categoryName = cleanTopicCategoryName(
- topic.category_name || topic.categoryName || category.name || topic.category_slug || topic.categorySlug || category.slug,
- );
- const categoryLevel = [topic.category_level, topic.categoryLevel, topic.category_name, topic.categoryName, category.name]
- .map(topicCategoryLevel).find(Boolean) || '';
- const categorySlug = topicNavText(topic.category_slug || topic.categorySlug || category.slug).trim();
- const categoryId = Number(topic.category_id || topic.categoryId || category.id) || 0;
- const tags = (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).map((tag) => tag.trim()).filter(Boolean);
- const wantedTags = new Set(tags);
- let cached = {};
- const collect = (candidate) => {
- if (!candidate || typeof candidate !== 'object') return;
- const matchingCategory = categoryName && cleanTopicCategoryName(candidate.categoryName) === categoryName;
- cached = mergeTopicNavMetadata(cached, {
- categoryName,
- categoryLevel,
- categorySlug,
- categoryId,
- categoryIconHtml: matchingCategory ? candidate.categoryIconHtml : '',
- tags,
- tagIcons: (Array.isArray(candidate.tagIcons) ? candidate.tagIcons : [])
- .filter((item) => wantedTags.has(topicNavText(item?.name).trim())),
- });
- };
- collect(topic._ldpTopicNavMetadata);
- TOPIC_DATA_CACHE.forEach((entry) => collect(entry?.topic && entry.topic._ldpTopicNavMetadata));
- return mergeTopicNavMetadata({ categoryName, categoryLevel, categorySlug, categoryId, tags }, cached);
- }
-
- function rememberTopicNavMetadata(topicId, topic, metadata) {
- if (!topic || !topicNavMetadataHasIcons(metadata)) return false;
- cacheTopicNavMetadataIcons(metadata);
- const cacheable = cachedTopicNavMetadata(topic);
- const next = mergeTopicNavMetadata(metadata, cacheable);
- if (!topicNavMetadataHasIcons(next)) return false;
- const previous = topic._ldpTopicNavMetadata || {};
- if (JSON.stringify(previous) === JSON.stringify(next)) return false;
- topic._ldpTopicNavMetadata = next;
- setCachedTopicData(topicId, topic);
- return true;
- }
-
- function fillTopicNavMetadata(topic, fallback = {}) {
- if (!topic) return false;
- const category = topic.category || {};
- const existingId = Number(topic.category_id || topic.categoryId || discourseModelValue(category, 'id')) || 0;
- const categoryName = cleanTopicCategoryName(
- topic.category_name || topic.categoryName || discourseModelValue(category, 'name') ||
- fallback.categoryName || '',
- );
- const categoryLevel = [
- topic.category_level,
- topic.categoryLevel,
- topic.category_name,
- topic.categoryName,
- discourseModelValue(category, 'name'),
- fallback.categoryLevel,
- ].map(topicCategoryLevel).find(Boolean) || '';
- const categorySlug = topicNavText(
- topic.category_slug || topic.categorySlug || discourseModelValue(category, 'slug') ||
- fallback.categorySlug || '',
- ).trim();
- const categoryId = existingId || Number(fallback.categoryId) || 0;
- let changed = false;
- if (categoryName && topic.category_name !== categoryName) {
- topic.category_name = categoryName;
- changed = true;
- }
- if (categoryLevel && topic.category_level !== categoryLevel) {
- topic.category_level = categoryLevel;
- changed = true;
- }
- if (categorySlug && topic.category_slug !== categorySlug) {
- topic.category_slug = categorySlug;
- changed = true;
- }
- if (categoryId && Number(topic.category_id) !== categoryId) {
- topic.category_id = categoryId;
- changed = true;
- }
- if ((!Array.isArray(topic.tags) || !topic.tags.length) && Array.isArray(fallback.tags) && fallback.tags.length) {
- topic.tags = [...new Set(fallback.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean))];
- changed = true;
- }
- return changed;
- }
-
- function renderTopicNavLinks(topic, metadata = {}) {
- const items = [];
- const category = topic.category || {};
- const categoryName = cleanTopicCategoryName(topic.category_name || topic.categoryName || category.name || topic.category_slug || topic.categorySlug || category.slug);
- const categoryLevel = [topic.category_level, topic.categoryLevel, topic.category_name, topic.categoryName, category.name]
- .map(topicCategoryLevel).find(Boolean) || '';
- const categoryLabel = categoryName && categoryLevel ? `${categoryName}, ${categoryLevel}` : categoryName;
- const categorySlug = topicNavText(topic.category_slug || topic.categorySlug || category.slug);
- const categoryId = topicNavText(topic.category_id || topic.categoryId || category.id);
- const navMetadata = topicNavMetadataWithCachedIcons(Object.assign({}, metadata, {
- categoryName,
- categorySlug,
- categoryId,
- tags: (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).filter(Boolean),
- }));
- const discourseUrl = lookupDiscourseModule('discourse/lib/url');
- const site = lookupDiscourse('service:site');
- const nativeCategory = [...discourseModelValue(site, 'categories') || []]
- .find((candidate) => Number(discourseModelValue(candidate, 'id')) === Number(categoryId));
- const categoryHref = isFunction(discourseUrl?.getCategoryAndTagUrl) &&
- nativeCategory
- ? discourseUrl.getCategoryAndTagUrl(nativeCategory, true)
- : '';
- if (categoryLabel) {
- const categoryIconHtml = navMetadata.categoryIconHtml || '';
- const categoryTextHtml = `
${esc(categoryName || categoryLabel)}${categoryLevel ? `, ${esc(categoryLevel)}` : ''}`;
- items.push(categoryHref
- ? `
${categoryIconHtml}${categoryTextHtml}`
- : `
${categoryIconHtml}${categoryTextHtml}`);
- }
- (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).filter(Boolean).forEach((tag) => {
- const tagIcon = (Array.isArray(navMetadata.tagIcons) ? navMetadata.tagIcons : [])
- .find((item) => item && item.name === tag);
- const tagHref = isFunction(discourseUrl?.getCategoryAndTagUrl)
- ? discourseUrl.getCategoryAndTagUrl(null, true, tag)
- : '';
- const tagContent = `${tagIcon ? tagIcon.iconHtml : ''}${esc(tag)}`;
- items.push(tagHref
- ? `
${tagContent}`
- : `
${tagContent}`);
- });
- return items.join('');
- }
-
- function avatarUrl(template, size) {
- const normalizedTemplate = String(template || '').trim();
- if (!normalizedTemplate) return '';
- const avatarUtils = lookupDiscourseModule('discourse/lib/avatar-utils');
- return isFunction(avatarUtils?.avatarUrl)
- ? avatarUtils.avatarUrl(normalizedTemplate, size || 48)
- : normalizedTemplate.replace('{size}', String(size || 48));
- }
-
- function persistentAvatarHtml(template, size, className, fallbackText = '?', options = {}) {
- const source = avatarUrl(template, size);
- if (!source) return '';
- const cachedSource = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source);
- const eager = options.eager === true;
- if (eager || cachedSource || source.startsWith('data:') || source.startsWith('blob:'))
- return `
})
`;
- return `
${esc(String(fallbackText || '?').slice(0, 1).toUpperCase())}`;
- }
-
- function safeFlairColor(value) {
- const color = String(value || '').trim();
- if (!/^#?[0-9a-f]{3}(?:[0-9a-f]{3})?(?:[0-9a-f]{2})?$/i.test(color)) return '';
- return color.startsWith('#') ? color : `#${color}`;
- }
-
- function userFlairIconHtml(iconId) {
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.setAttribute('class', 'ldp-avatar-flair-icon');
- svg.setAttribute('aria-hidden', 'true');
- const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
- use.setAttribute('href', `#${iconId}`);
- svg.appendChild(use);
- if (inlineTopicNavSvgUses(svg)) {
- const html = safeTopicNavIconHtml(svg.outerHTML);
- if (html) return html;
- }
- return `
`;
- }
-
- function userFlairHtml(user) {
- const rawUrl = String(user && (
- user.flair_url || user.flairUrl || user.avatar_flair_url || user.avatarFlairUrl
- ) || '').trim();
- if (!rawUrl) return '';
- const label = String(user && (
- user.flair_name || user.flairName || user.primary_group_name || user.primaryGroupName
- ) || '资质').trim();
- const background = safeFlairColor(user && (user.flair_bg_color || user.flairBgColor));
- const color = safeFlairColor(user && (user.flair_color || user.flairColor));
- const style = [
- background ? `--ldp-flair-bg:${background}` : '',
- color ? `--ldp-flair-color:${color}` : '',
- ].filter(Boolean).join(';');
- const styleAttr = style ? ` style="${style}"` : '';
- if (/^(?:https?:)?\/\//i.test(rawUrl) || rawUrl.startsWith('/') || rawUrl.startsWith('data:image/')) {
- const source = absoluteImageUrl(rawUrl);
- return `
})
`;
- }
- const iconId = rawUrl.replace(/^fa-/, '');
- if (!/^[a-z0-9_-]+$/i.test(iconId)) return '';
- return `
${userFlairIconHtml(iconId)}`;
- }
-
- function avatarWithFlairHtml(avatarHtml, user) {
- return avatarHtml
- ? `
${avatarHtml}${userFlairHtml(user)}`
- : '';
- }
-
- function userCardAvatarHtml(avatarHtml, username) {
- const cleanUsername = cleanUsernameValue(username);
- if (!avatarHtml || !cleanUsername) return avatarHtml;
- return `
${avatarHtml}`;
- }
-
- async function waitForReaderRequestPermit(priority, options = {}) {
- const queuedAt = requestFlowNow();
- const callSite = requestFlowCallSite();
- let scheduler = options.surviveReaderSwitch === true ? null : ACTIVE_READER_REQUEST_SCHEDULER;
- if (!scheduler) return { queuedAt, permittedAt: queuedAt, waitReason: '', callSite, scheduler };
- let untrackController = options.controller && scheduler !== options.trackedScheduler &&
- isFunction(scheduler.trackExternalController)
- ? scheduler.trackExternalController(options.controller)
- : () => {};
- for (let attempt = 0; attempt < 2; attempt++) {
- try {
- const permit = await scheduler.waitForPermit(priority, options);
- return {
- queuedAt,
- permittedAt: Number(permit?.startedAt) || requestFlowNow(),
- waitReason: String(permit?.waitReason || ''),
- callSite,
- recoveryProbe: permit && permit.recoveryProbe === true,
- scheduler,
- release: () => {
- if (isFunction(permit?.release)) permit.release();
- untrackController();
- },
- };
- } catch (error) {
- const replacementScheduler = ACTIVE_READER_REQUEST_SCHEDULER;
- if (attempt > 0 || options.signal?.aborted || !error || error.name !== 'AbortError' ||
- !replacementScheduler || replacementScheduler === scheduler) {
- untrackController();
- throw error;
- }
- untrackController();
- scheduler = replacementScheduler;
- untrackController = options.controller && isFunction(scheduler.trackExternalController)
- ? scheduler.trackExternalController(options.controller)
- : () => {};
- }
- }
- }
-
- function persistentResourceResponseIsFresh(
- response,
- maxAge,
- at = Date.now(),
- cachedAt = Number(response && response.headers.get('x-ldp-cached-at')) || 0
- ) {
- return !!response && cachedAt > 0 && at - cachedAt <= maxAge;
- }
-
- function createPersistentResourceResponse(blob, contentType = '') {
- const headers = {
- 'content-type': blob.type || contentType || 'application/octet-stream',
- 'content-length': String(blob.size),
- };
- headers['x-ldp-cached-at'] = String(Date.now());
- return new Response(blob, { headers });
- }
-
- async function persistentResourceCacheEntry(cacheName, source, maxAge) {
- const storage = await caches.open(cacheName);
- const response = await storage.match(source);
- if (response && !persistentResourceResponseIsFresh(response, maxAge)) {
- await storage.delete(source);
- return { storage, response: null };
- }
- return { storage, response: response || null };
- }
-
- async function prunePersistentResourceCache(cacheName, maxAge) {
- try {
- const storage = await caches.open(cacheName);
- const now = Date.now();
- const requests = await storage.keys();
- const entries = [];
- await runInBatches(requests, RESOURCE_CACHE_PRUNE_BATCH_SIZE, async (request) => {
- const response = await storage.match(request);
- const cachedAt = Number(response && response.headers.get('x-ldp-cached-at')) || 0;
- if (!persistentResourceResponseIsFresh(response, maxAge, now, cachedAt)) {
- await storage.delete(request);
- return;
- }
- entries.push({
- request,
- cachedAt,
- bytes: Math.max(0, Number(response.headers.get('content-length')) || 0),
- });
- });
- const group = RESOURCE_CACHE_GROUPS.find((entry) => entry.cacheName === cacheName);
- const maxEntries = Math.max(0, Number(group?.maxEntries) || 0);
- const maxBytes = Math.max(0, Number(group?.maxBytes) || 0);
- if (!maxEntries && !maxBytes) return;
- let keptEntries = 0;
- let keptBytes = 0;
- const removeRequests = [];
- entries.sort((left, right) => right.cachedAt - left.cachedAt).forEach((entry) => {
- const exceedsEntries = maxEntries > 0 && keptEntries >= maxEntries;
- const exceedsBytes = maxBytes > 0 && keptBytes + entry.bytes > maxBytes;
- if (exceedsEntries || exceedsBytes) {
- removeRequests.push(entry.request);
- return;
- }
- keptEntries++;
- keptBytes += entry.bytes;
- });
- await runInBatches(removeRequests, RESOURCE_CACHE_PRUNE_BATCH_SIZE,
- (request) => storage.delete(request));
- } catch {
- // Persistent resource cleanup is best-effort and must not block the reader.
- }
- }
-
- function prunePersistentResourceCacheOnce(cacheName, maxAge) {
- if (typeof caches === 'undefined') return Promise.resolve();
- let request = RESOURCE_CACHE_PRUNE_REQUESTS.get(cacheName);
- if (!request) {
- request = prunePersistentResourceCache(cacheName, maxAge);
- RESOURCE_CACHE_PRUNE_REQUESTS.set(cacheName, request);
- }
- return request;
- }
-
- function cacheResourceObjectUrl(cache, source, objectUrl, maxEntries) {
- const previousUrl = cache.get(source);
- if (previousUrl) {
- URL.revokeObjectURL(previousUrl);
- cache.delete(source);
- }
- while (cache.size >= maxEntries) {
- const oldestSource = cache.keys().next().value;
- const oldestUrl = cache.get(oldestSource);
- if (oldestUrl) URL.revokeObjectURL(oldestUrl);
- cache.delete(oldestSource);
- }
- cache.set(source, objectUrl);
- return objectUrl;
- }
-
- function touchResourceObjectUrl(cache, source) {
- const objectUrl = cache.get(source);
- if (!objectUrl) return '';
- cache.delete(source);
- cache.set(source, objectUrl);
- return objectUrl;
- }
-
- function clearResourceObjectUrlCache(cache) {
- cache.forEach((objectUrl) => {
- if (objectUrl) URL.revokeObjectURL(objectUrl);
- });
- cache.clear();
- }
-
- function trackSingleFlightRequest(requests, key, request) {
- const trackedRequest = request.finally(() => {
- if (requests.get(key) === trackedRequest) requests.delete(key);
- });
- requests.set(key, trackedRequest);
- return trackedRequest;
- }
-
- function trackResourceCacheWrite(request) {
- const trackedRequest = Promise.resolve(request).catch(() => {}).finally(() => {
- RESOURCE_CACHE_WRITES.delete(trackedRequest);
- });
- RESOURCE_CACHE_WRITES.add(trackedRequest);
- return trackedRequest;
- }
-
- async function fetchReaderPublicResource(source, priority, type, signal) {
- const flowPermit = await waitForReaderRequestPermit(priority, {
- countsTowardApi: false, type, signal,
- });
- if (!flowPermit) return null;
- const { release, ...requestFlowMeta } = flowPermit;
- try {
- const response = await trackedReaderFetch(source, {
- // Public assets may redirect to a wildcard-CORS CDN, which is incompatible with credentials.
- credentials: 'omit',
- cache: 'force-cache',
- signal,
- [REQUEST_FLOW_FETCH_META]: { source: 'reader', priority, type, ...requestFlowMeta },
- });
- return { response, release: isFunction(release) ? release : () => {} };
- } catch (error) {
- if (isFunction(release)) release();
- throw error;
- }
- }
-
- async function avatarResponseBlob(response, onProgress) {
- if (!isFunction(onProgress) || !response.body || !isFunction(response.body.getReader)) {
- const blob = await response.blob();
- if (isFunction(onProgress)) onProgress(blob.size, blob.size);
- return blob;
- }
- const total = Math.max(0, Number(response.headers.get('content-length')) || 0);
- const reader = response.body.getReader();
- const chunks = [];
- let loaded = 0;
- onProgress(0, total);
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- if (!value || !value.byteLength) continue;
- chunks.push(value);
- loaded += value.byteLength;
- onProgress(loaded, total);
- }
- const bytes = new Uint8Array(loaded);
- let offset = 0;
- chunks.forEach((chunk) => {
- bytes.set(chunk, offset);
- offset += chunk.byteLength;
- });
- const blob = await new Response(bytes, {
- headers: { 'content-type': response.headers.get('content-type') || 'application/octet-stream' },
- }).blob();
- onProgress(loaded, total || loaded);
- return blob;
- }
-
- async function persistentAvatarSource(template, size = 64, priority = POST_REQUEST_PRIORITY.visible, options = {}) {
- const source = avatarUrl(template, size);
- const onProgress = isFunction(options.onProgress) ? options.onProgress : null;
- const completeProgress = () => onProgress && onProgress(1, 1);
- if (!source || source.startsWith('data:') || source.startsWith('blob:')) {
- if (source) completeProgress();
- return source;
- }
- if (RESOURCE_CACHE_CLEARING) return source;
- const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
- const cachedObjectUrl = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source);
- if (cachedObjectUrl) {
- completeProgress();
- return cachedObjectUrl;
- }
- const pendingRequest = AVATAR_RESOURCE_REQUESTS.get(source);
- if (pendingRequest) {
- const sharedSource = await pendingRequest;
- if (sharedSource) completeProgress();
- return sharedSource;
- }
- const request = (async () => {
- if (typeof caches === 'undefined') return source;
- try {
- let { storage, response } = await persistentResourceCacheEntry(
- LDP_AVATAR_CACHE_NAME, source, LDP_AVATAR_CACHE_MAX_AGE
- );
- let blob = null;
- if (!response) {
- const resource = await fetchReaderPublicResource(
- new URL(source, location.href).href, priority, 'avatar', resourceCacheSignal
- );
- if (!resource) return '';
- try {
- const fetched = resource.response;
- // Keep uncached avatar starts in the reader cadence. If Cache Storage
- // cannot fill them, let the browser load the URL normally.
- if (!fetched.ok) return source;
- if (options.rejectBlankAvatar) {
- const contentType = fetched.headers.get('content-type') || '';
- const lastModified = Date.parse(fetched.headers.get('last-modified') || '');
- const isDiscourseBlank = Number.isFinite(lastModified) && new Date(lastModified).getUTCFullYear() === 1990;
- if ((contentType && !contentType.startsWith('image/')) || isDiscourseBlank) return '';
- }
- blob = await avatarResponseBlob(fetched, onProgress);
- if (!blob.size || resourceCacheSignal.aborted) return source;
- response = createPersistentResourceResponse(blob, fetched.headers.get('content-type'));
- void trackResourceCacheWrite(storage.put(source, response.clone()));
- } finally {
- resource.release();
- }
- }
- if (!blob) blob = await avatarResponseBlob(response, onProgress);
- if (!blob.size || resourceCacheSignal.aborted) return source;
- return cacheResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 256);
- } catch {
- return source;
- }
- })();
- return trackSingleFlightRequest(AVATAR_RESOURCE_REQUESTS, source, request);
- }
-
- function replacePersistentAvatarPlaceholder(placeholder, source) {
- if (!source || !placeholder || !placeholder.isConnected) return;
- const image = makeElement('img');
- image.className = placeholder.dataset.ldpAvatarClass ||
- (placeholder.classList.contains('ldp-notification-avatar') ? 'ldp-notification-avatar' : 'ldp-avatar');
- image.src = source;
- image.alt = '';
- image.loading = 'lazy';
- image.decoding = 'async';
- image.dataset.ldpAvatarOriginalTemplate = placeholder.dataset.ldpAvatarTemplate || '';
- replaceOwnedNode(placeholder, image);
- }
-
- function hydratePersistentAvatarImages(container, priority = POST_REQUEST_PRIORITY.visible) {
- if (!container) return;
- container.querySelectorAll('[data-ldp-avatar-template]').forEach((placeholder) => {
- if (!placeholder.isConnected || AVATAR_HYDRATION_PENDING.has(placeholder)) return;
- const template = placeholder.dataset.ldpAvatarTemplate;
- if (!template) return;
- AVATAR_HYDRATION_PENDING.add(placeholder);
- persistentAvatarSource(template, Number(placeholder.dataset.ldpAvatarSize) || 64, priority)
- .then((source) => replacePersistentAvatarPlaceholder(placeholder, source))
- .finally(() => AVATAR_HYDRATION_PENDING.delete(placeholder));
- });
- }
-
- function releasePersistentAvatarImages(container) {
- if (!container) return;
- const placeholders = new Set(container.querySelectorAll('[data-ldp-avatar-template]'));
- if (container.matches && container.matches('[data-ldp-avatar-template]')) placeholders.add(container);
- placeholders.forEach((placeholder) => {
- AVATAR_HYDRATION_PENDING.delete(placeholder);
- });
- }
-
- function userAvatarDownloadFilename(source, username, kind = 'avatar') {
- let extension = 'png';
- try {
- const match = new URL(source, location.href).pathname.match(/\.([a-z0-9]{2,5})$/i);
- if (match && /^(?:png|apng|jpe?g|gif|webp|avif|svg|bmp|ico)$/i.test(match[1])) extension = match[1].toLowerCase();
- } catch (error) {
- void error;
- }
- const basename = String(username || 'user').replace(/[^a-z0-9._-]+/gi, '-') || 'user';
- return `${basename}-${kind === 'background' ? 'background' : 'avatar'}.${extension === 'jpeg' ? 'jpg' : extension}`;
- }
-
- function createImageTransformController(stage, image, options = {}) {
- const captureTarget = options.captureTarget || stage;
- const transformScope = createLifecycleScope();
- let scale = 1;
- let panX = 0;
- let panY = 0;
- let containedPan = false;
- let pointerId = null;
- let dragX = 0;
- let dragY = 0;
- let pendingPanX = 0;
- let pendingPanY = 0;
- let dragFrame = 0;
-
- const clampPan = () => {
- if (!image.clientWidth || !image.clientHeight) {
- panX = 0;
- panY = 0;
- return;
- }
- const padding = Math.max(0, Number(options.overflowPadding) || 0);
- const scaledWidth = image.clientWidth * scale;
- const scaledHeight = image.clientHeight * scale;
- const maxX = scaledWidth > stage.clientWidth
- ? (scaledWidth - stage.clientWidth) / 2 + padding
- : containedPan ? Math.max(0, (stage.clientWidth - scaledWidth) / 2 - padding) : 0;
- const maxY = scaledHeight > stage.clientHeight
- ? (scaledHeight - stage.clientHeight) / 2 + padding
- : containedPan ? Math.max(0, (stage.clientHeight - scaledHeight) / 2 - padding) : 0;
- panX = Math.max(-maxX, Math.min(maxX, panX));
- panY = Math.max(-maxY, Math.min(maxY, panY));
- };
- const render = () => {
- clampPan();
- options.render({ scale, panX, panY });
- image.classList.toggle('is-zoomed', scale > 1.01);
- if (options.zoomValue) options.zoomValue.textContent = `${Math.round(scale * 100)}%`;
- if (options.zoomOutButton) options.zoomOutButton.disabled = scale <= 0.25;
- if (options.zoomInButton) options.zoomInButton.disabled = scale >= 8;
- };
- const setZoom = (value, clientX = null, clientY = null) => {
- const nextScale = Math.max(0.25, Math.min(8, Number(value) || 1));
- const anchored = Number.isFinite(clientX) && Number.isFinite(clientY) && image.clientWidth && image.clientHeight;
- if (anchored && nextScale !== scale) {
- const imageRect = image.getBoundingClientRect();
- const scaleRatio = nextScale / scale;
- panX += (clientX - (imageRect.left + imageRect.width / 2)) * (1 - scaleRatio);
- panY += (clientY - (imageRect.top + imageRect.height / 2)) * (1 - scaleRatio);
- }
- containedPan = options.allowContainedPan === true && anchored;
- scale = nextScale;
- if (options.resetPanAtFit !== false && scale <= 1.01) panX = panY = 0;
- render();
- };
- const reset = () => {
- scale = 1;
- panX = panY = 0;
- containedPan = false;
- render();
- };
- const flushDrag = () => {
- dragFrame = 0;
- panX = pendingPanX;
- panY = pendingPanY;
- render();
- };
- const onPointerDown = (event) => {
- if (scale <= 1.01 || event.button !== 0 || event.target !== image) return;
- pointerId = event.pointerId;
- dragX = event.clientX - panX;
- dragY = event.clientY - panY;
- pendingPanX = panX;
- pendingPanY = panY;
- captureTarget.setPointerCapture(pointerId);
- image.classList.add('is-dragging');
- if (options.preventDragDefault) event.preventDefault();
- };
- const onPointerMove = (event) => {
- if (pointerId !== event.pointerId) return;
- pendingPanX = event.clientX - dragX;
- pendingPanY = event.clientY - dragY;
- if (!dragFrame) dragFrame = requestAnimationFrame(flushDrag);
- };
- const onPointerEnd = (event) => {
- if (pointerId !== event.pointerId) return;
- if (dragFrame) {
- cancelAnimationFrame(dragFrame);
- flushDrag();
- }
- if (captureTarget.hasPointerCapture(pointerId)) captureTarget.releasePointerCapture(pointerId);
- pointerId = null;
- image.classList.remove('is-dragging');
- };
- transformScope.listen(captureTarget, 'pointerdown', onPointerDown);
- transformScope.listen(captureTarget, 'pointermove', onPointerMove);
- transformScope.listen(captureTarget, 'pointerup', onPointerEnd);
- transformScope.listen(captureTarget, 'pointercancel', onPointerEnd);
- return {
- get scale() { return scale; },
- render,
- setZoom,
- reset,
- destroy() {
- if (dragFrame) cancelAnimationFrame(dragFrame);
- transformScope.destroy();
- },
- };
- }
-
- function handleImageTransformShortcut(event, controller, reset = controller.reset) {
- if (event.key === '+' || event.key === '=') controller.setZoom(controller.scale * 1.2);
- else if (event.key === '-') controller.setZoom(controller.scale / 1.2);
- else if (event.key === '0') reset();
- else return false;
- event.preventDefault();
- return true;
- }
-
- function positionUserAvatarViewer(viewer, anchor) {
- if (!viewer || !viewer.isConnected) return;
- const margin = 10;
- const gap = 10;
- const rect = anchor?.isConnected ? anchor.getBoundingClientRect() : null;
- const imageViewerSize = Math.min(480, window.innerWidth - margin * 2, window.innerHeight - margin * 2);
- const width = viewer.offsetWidth || (viewer.classList.contains('is-image') ? imageViewerSize : 320);
- const height = viewer.offsetHeight || (viewer.classList.contains('is-image') ? imageViewerSize : 358);
- const batchDialog = viewer.classList.contains('is-image') && anchor && anchor.closest
- ? anchor.closest('.ldp-lb-batch-dialog')
- : null;
- if (batchDialog) {
- batchDialog.style.removeProperty('transform');
- const batchRect = batchDialog.getBoundingClientRect();
- const combinedWidth = batchRect.width + gap + width;
- if (combinedWidth <= window.innerWidth - margin * 2) {
- const groupLeft = Math.max(margin, Math.round((window.innerWidth - combinedWidth) / 2));
- batchDialog.style.transform = `translateX(${Math.round(groupLeft - batchRect.left)}px)`;
- viewer.style.left = `${Math.round(groupLeft + batchRect.width + gap)}px`;
- viewer.style.top = `${Math.round(batchRect.top)}px`;
- viewer.style.height = `${Math.round(batchRect.height)}px`;
- viewer._ldpBatchDialog = batchDialog;
- return;
- }
- }
- viewer.style.removeProperty('height');
- let left = Math.max(margin, Math.round((window.innerWidth - width) / 2));
- let top = Math.max(margin, Math.round((window.innerHeight - height) / 2));
- if (rect) {
- const rightSide = rect.right + gap;
- const leftSide = rect.left - width - gap;
- if (rightSide + width <= window.innerWidth - margin) left = rightSide;
- else if (leftSide >= margin) left = leftSide;
- else left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin));
- top = Math.max(margin, Math.min(rect.top, window.innerHeight - height - margin));
- }
- viewer.style.left = `${Math.round(left)}px`;
- viewer.style.top = `${Math.round(top)}px`;
- }
-
- function openUserAvatarViewer(template, username, anchorCard, previewSource = '', onDismiss = null, user = null, options = {}) {
- const isBackground = options.kind === 'background';
- const isImage = options.kind === 'image';
- const highResolutionSource = isBackground || isImage ? String(options.source || '').trim() : avatarUrl(template, 1000);
- const loadImageOriginal = isImage && options.loadOriginal === true && Boolean(highResolutionSource);
- if (!highResolutionSource && !previewSource) return null;
- const currentViewer = readerPortalQuery('.ldp-avatar-viewer');
- if (currentViewer && isImage && options.reuseImageViewer === true && currentViewer._ldpKind === 'image' &&
- isFunction(currentViewer._ldpUpdateImage)) {
- currentViewer._ldpUpdateImage({ template, previewSource, highResolutionSource, options });
- return currentViewer;
- }
- if (currentViewer && currentViewer._ldpUsername === username && currentViewer._ldpTemplate === template &&
- currentViewer._ldpKind === (isBackground ? 'background' : isImage ? 'image' : 'avatar')) {
- positionUserAvatarViewer(currentViewer, anchorCard);
- return currentViewer;
- }
- if (isFunction(currentViewer?._ldpClose)) currentViewer._ldpClose();
-
- const viewer = makeElement('div');
- const viewerScope = createLifecycleScope();
- const mediaLabel = isBackground ? '背景图' : isImage ? '图片' : '头像';
- const accessibleName = username ? `${username} 的${mediaLabel}预览` : `${mediaLabel}预览`;
- viewer.className = `ldp-avatar-viewer${isBackground ? ' is-background' : isImage ? ' is-image' : ''}`;
- viewer.setAttribute('role', 'dialog');
- setLabel(viewer, accessibleName);
- viewer.innerHTML = `
-
-
- ${isImage && isFunction(options.onPrevious) ? `
` : ''}
-
![${escAttr(accessibleName)}]()
- ${isImage && isFunction(options.onNext) ? `
` : ''}
- ${isBackground || isImage ? '' : userFlairHtml(user)}
-
正在加载${mediaLabel}…
-
`;
- const image = viewer.querySelector('.ldp-avatar-viewer-image');
- const stage = viewer.querySelector('.ldp-avatar-viewer-stage');
- const status = viewer.querySelector('.ldp-avatar-viewer-status');
- const progress = viewer.querySelector('.ldp-avatar-viewer-progress');
- const progressValue = viewer.querySelector('.ldp-avatar-viewer-progress-value');
- const downloadButton = viewer.querySelector('[data-avatar-viewer-action="download"]');
- const selectionInput = viewer.querySelector('.ldp-avatar-viewer-selection input');
- const selectionLabel = viewer.querySelector('.ldp-avatar-viewer-selection span');
- const previousButton = viewer.querySelector('[data-avatar-viewer-action="previous"]');
- const nextButton = viewer.querySelector('[data-avatar-viewer-action="next"]');
- const zoomValue = viewer.querySelector('.ldp-avatar-viewer-zoom-value');
- const zoomOutButton = viewer.querySelector('[data-avatar-viewer-action="zoom-out"]');
- const zoomInButton = viewer.querySelector('[data-avatar-viewer-action="zoom-in"]');
- let activeOptions = options;
- let activePreviewSource = previewSource;
- let activeHighResolutionSource = highResolutionSource;
- let imageSourceToken = 0;
- let displayedSource = '';
- let loadingHighResolution = false;
- let highResolutionPending = Boolean(highResolutionSource) && !isBackground && (!isImage || loadImageOriginal);
- let closed = false;
- const imageTransform = isImage ? createImageTransformController(stage, image, {
- captureTarget: image,
- preventDragDefault: true,
- zoomValue,
- zoomOutButton,
- zoomInButton,
- render: ({ scale, panX, panY }) => {
- image.style.transform = `translate3d(${Math.round(panX)}px,${Math.round(panY)}px,0) scale(${scale})`;
- },
- }) : null;
-
- const close = (dismissed = false) => {
- if (closed) return;
- closed = true;
- viewerScope.destroy();
- if (imageTransform) imageTransform.destroy();
- if (viewer._ldpBatchDialog) viewer._ldpBatchDialog.style.removeProperty('transform');
- viewer.remove();
- if (dismissed && isFunction(onDismiss)) onDismiss();
- };
- const download = async () => {
- if (isFunction(activeOptions.onDownload)) {
- void activeOptions.onDownload();
- return;
- }
- try {
- const source = activeHighResolutionSource || activePreviewSource || displayedSource;
- const blob = await fetchLightboxDownloadBlob(source);
- triggerBrowserDownload(
- blob,
- userAvatarDownloadFilename(source, username, isBackground ? 'background' : 'avatar')
- );
- } catch (error) {
- showSelectionToast(`${mediaLabel}下载失败:${error.message || '请重试'}`);
- }
- };
- function onKey(event) {
- if (!viewer.isConnected) return;
- if (event.key === 'Escape') {
- event.preventDefault();
- event.stopImmediatePropagation();
- close(true);
- } else if (isImage && event.key === 'ArrowLeft' && isFunction(activeOptions.onPrevious) && !activeOptions.previousDisabled) {
- event.preventDefault();
- event.stopImmediatePropagation();
- activeOptions.onPrevious();
- } else if (isImage && event.key === 'ArrowRight' && isFunction(activeOptions.onNext) && !activeOptions.nextDisabled) {
- event.preventDefault();
- event.stopImmediatePropagation();
- activeOptions.onNext();
- } else if (isImage && handleImageTransformShortcut(event, imageTransform))
- event.stopImmediatePropagation();
- }
- function onOutside(event) {
- if (eventPathIncludes(event, viewer) || eventPathIncludes(event, anchorCard)) return;
- const batchDialog = viewer._ldpBatchDialog || (anchorCard?.closest && anchorCard.closest('.ldp-lb-batch-dialog'));
- if (isImage && activeOptions.reuseImageViewer === true && batchDialog && eventPathIncludes(event, batchDialog)) return;
- close(true);
- }
- function onViewerWheel(event) {
- if (!viewer.isConnected || !isImage || !eventPathIncludes(event, viewer)) return;
- event.preventDefault();
- event.stopImmediatePropagation();
- const zoomAtPointer = event.target === image;
- imageTransform.setZoom(
- imageTransform.scale * (event.deltaY < 0 ? 1.15 : 1 / 1.15),
- zoomAtPointer ? event.clientX : null,
- zoomAtPointer ? event.clientY : null
- );
- }
- function reposition() {
- positionUserAvatarViewer(viewer, anchorCard);
- }
- function updateProgress(loaded, total) {
- if (closed || !highResolutionPending) return;
- const hasTotal = Number.isFinite(total) && total > 0;
- const percent = hasTotal ? Math.max(0, Math.min(100, Math.round((loaded / total) * 100))) : null;
- progress.hidden = false;
- progress.classList.toggle('is-indeterminate', percent === null);
- if (percent === null) {
- progress.style.removeProperty('--ldp-avatar-progress');
- progress.removeAttribute('aria-valuenow');
- progress.setAttribute('aria-valuetext', `正在加载${mediaLabel}原图`);
- progressValue.textContent = `${mediaLabel}原图加载中`;
- return;
- }
- progress.style.setProperty('--ldp-avatar-progress', `${percent}%`);
- progress.setAttribute('aria-valuenow', String(percent));
- progress.setAttribute('aria-valuetext', `${mediaLabel}原图已加载 ${percent}%`);
- progressValue.textContent = `原图 ${percent}%`;
- }
- function finishProgress() {
- highResolutionPending = false;
- progress.hidden = true;
- }
- const loadViewerImageSources = (nextPreviewSource, nextHighResolutionSource, shouldLoadOriginal) => {
- const token = ++imageSourceToken;
- activePreviewSource = String(nextPreviewSource || '');
- activeHighResolutionSource = String(nextHighResolutionSource || '');
- loadingHighResolution = false;
- highResolutionPending = Boolean(activeHighResolutionSource) && shouldLoadOriginal === true;
- progress.hidden = !highResolutionPending;
- progress.classList.add('is-indeterminate');
- progress.removeAttribute('aria-valuenow');
- progressValue.textContent = '原图加载中';
- displayedSource = '';
- downloadButton.disabled = true;
- status.hidden = Boolean(activePreviewSource);
- if (activePreviewSource) image.src = activePreviewSource;
- imageTransform.reset();
- if (!highResolutionPending) return;
- void persistentLightboxImageSource(activeHighResolutionSource).then((source) => {
- if (closed || token !== imageSourceToken) return;
- if (!source || source === activeHighResolutionSource) {
- finishProgress();
- return;
- }
- loadingHighResolution = true;
- image.src = source;
- }).catch(() => {
- if (!closed && token === imageSourceToken) finishProgress();
- });
- };
-
- viewer._ldpClose = close;
- viewer._ldpUsername = username;
- viewer._ldpTemplate = template;
- viewer._ldpKind = isBackground ? 'background' : isImage ? 'image' : 'avatar';
- viewer._ldpAnchorCard = anchorCard;
- viewer._ldpUpdateImage = (next) => {
- activeOptions = next.options || {};
- viewer._ldpTemplate = next.template;
- if (selectionInput) selectionInput.checked = activeOptions.selected === true;
- if (selectionLabel) selectionLabel.textContent = activeOptions.selectionLabel || '选择当前图片';
- if (previousButton) previousButton.disabled = activeOptions.previousDisabled === true;
- if (nextButton) nextButton.disabled = activeOptions.nextDisabled === true;
- loadViewerImageSources(next.previewSource, next.highResolutionSource, activeOptions.loadOriginal === true);
- };
- viewer.addEventListener('click', (event) => {
- const action = event.target.closest && event.target.closest('[data-avatar-viewer-action]')?.dataset.avatarViewerAction;
- if (action === 'download') download();
- else if (action === 'close') close(true);
- else if (action === 'previous' && isFunction(activeOptions.onPrevious)) activeOptions.onPrevious();
- else if (action === 'next' && isFunction(activeOptions.onNext)) activeOptions.onNext();
- else if (action === 'zoom-out') imageTransform.setZoom(imageTransform.scale / 1.2);
- else if (action === 'zoom-in') imageTransform.setZoom(imageTransform.scale * 1.2);
- else if (action === 'zoom-reset') imageTransform.reset();
- });
- if (selectionInput) selectionInput.addEventListener('change', () => {
- if (isFunction(activeOptions.onSelectionChange)) activeOptions.onSelectionChange(selectionInput.checked);
- });
- image.addEventListener('load', () => {
- if (closed) return;
- const completedHighResolution = loadingHighResolution;
- displayedSource = image.currentSrc || image.src;
- loadingHighResolution = false;
- if (completedHighResolution) finishProgress();
- image.hidden = false;
- status.hidden = true;
- downloadButton.disabled = false;
- if (!isImage) reposition();
- });
- image.addEventListener('error', () => {
- if (closed) return;
- if (loadingHighResolution && activePreviewSource) {
- loadingHighResolution = false;
- finishProgress();
- image.src = activePreviewSource;
- return;
- }
- if (loadingHighResolution) finishProgress();
- if (highResolutionPending) {
- image.hidden = true;
- status.hidden = false;
- status.textContent = `正在加载${mediaLabel}原图…`;
- return;
- }
- image.hidden = true;
- status.hidden = false;
- status.textContent = `未获取到可用${mediaLabel}`;
- downloadButton.disabled = true;
- });
- if (isImage) {
- stage.addEventListener('dblclick', (event) => {
- if (event.target !== image) return;
- imageTransform.setZoom(
- imageTransform.scale > 1.05 ? 1 : Math.max(2, Math.min(8, image.naturalWidth / Math.max(1, image.clientWidth))),
- event.clientX,
- event.clientY
- );
- });
- }
- viewerScope.listen(document, 'keydown', onKey);
- appendReaderPortalNode(viewer);
- reposition();
- if (imageTransform) imageTransform.render();
- viewerScope.listen(document, 'pointerdown', onOutside, true);
- if (isImage) viewerScope.listen(
- document, 'wheel', onViewerWheel, { capture: true, passive: false }
- );
- viewerScope.listen(window, 'resize', reposition);
- if (isBackground) {
- loadingHighResolution = true;
- image.src = highResolutionSource;
- return viewer;
- }
- if (isImage) {
- loadViewerImageSources(previewSource, highResolutionSource, loadImageOriginal);
- return viewer;
- }
- if (previewSource) image.src = previewSource;
- if (!highResolutionSource) return viewer;
- void persistentAvatarSource(template, 1000, POST_REQUEST_PRIORITY.auxiliary, {
- rejectBlankAvatar: true,
- onProgress: updateProgress,
- }).then((source) => {
- if (closed) return;
- if (!source) {
- finishProgress();
- if (!previewSource) status.textContent = '未获取到高清头像';
- return;
- }
- loadingHighResolution = true;
- image.src = source;
- });
- return viewer;
- }
-
- function discourseRelativeTime(iso) {
- const d = new Date(iso);
- if (!Number.isFinite(d.getTime())) return '';
- const formatter = lookupDiscourseModule('discourse/lib/formatter');
- return isFunction(formatter?.relativeAge)
- ? String(formatter.relativeAge(d, { format: 'medium-with-ago', wrapInSpan: false }) || '')
- : '';
- }
-
- function discourseExactTime(iso) {
- const d = new Date(iso);
- if (!Number.isFinite(d.getTime())) return '';
- const formatter = lookupDiscourseModule('discourse/lib/formatter');
- return isFunction(formatter?.longDate)
- ? String(formatter.longDate(d) || '')
- : '';
- }
-
- function refreshPostRelativeTimes(container) {
- if (!container) return;
- container.querySelectorAll('.ldp-time-relative').forEach((relativeEl) => {
- const createdAt = relativeEl.closest('.ldp-post')?.dataset.createdAt || '';
- const time = discourseRelativeTime(createdAt);
- if (!time) return;
- const nextText = `· ${time}`;
- if (relativeEl.textContent !== nextText) relativeEl.textContent = nextText;
- });
- }
-
- async function discourseCsrfToken() {
- const sessionModule = lookupDiscourseModule('discourse/models/session');
- const Session = sessionModule?.default;
- const ajaxModule = lookupDiscourseModule('discourse/lib/ajax');
- if (!Session || !isFunction(Session.currentProp) ||
- !ajaxModule || !isFunction(ajaxModule.updateCsrfToken)) {
- throw new Error('Discourse 会话服务尚未就绪');
- }
- let token = String(Session.currentProp('csrfToken') || '');
- if (!token) {
- await ajaxModule.updateCsrfToken();
- token = String(Session.currentProp('csrfToken') || '');
- }
- if (!token) throw new Error('无法获取 Discourse CSRF Token');
- return token;
- }
-
- function createRequestAbortScope(externalSignal) {
- const controller = new AbortController();
- let timeout = 0;
- let timedOut = false;
- const abortFromExternal = () => controller.abort(externalSignal?.reason);
- if (externalSignal) {
- if (externalSignal.aborted) abortFromExternal();
- else externalSignal.addEventListener('abort', abortFromExternal, { once: true });
- }
- return {
- controller,
- signal: controller.signal,
- startTimeout(delay = POST_REQUEST_TIMEOUT) {
- if (timeout) clearTimeout(timeout);
- if (controller.signal.aborted) return;
- timeout = setTimeout(() => {
- timedOut = true;
- controller.abort();
- }, Math.max(1, Number(delay) || POST_REQUEST_TIMEOUT));
- },
- stopTimeout() {
- if (timeout) clearTimeout(timeout);
- timeout = 0;
- },
- timedOut() { return timedOut; },
- cleanup() {
- if (timeout) clearTimeout(timeout);
- timeout = 0;
- if (externalSignal) externalSignal.removeEventListener('abort', abortFromExternal);
- },
- };
- }
-
- function requestStatusError(message, status) {
- const error = new Error(message);
- error.status = status;
- return error;
- }
-
- function requestTimeoutError(message = '请求超时') {
- return requestStatusError(message, 408);
- }
-
- function acquireSharedReaderRequestPermit(priority, signal, type, scheduler) {
- if (!SHARED_REQUEST_COORDINATOR) return null;
- let requestState = null;
- try { requestState = scheduler?.snapshot(); } catch {}
- return SHARED_REQUEST_COORDINATOR.acquirePermit({
- priority,
- signal,
- shortBudget: requestState ? requestState.shortWindowBudget : 40,
- longBudget: requestState ? requestState.longWindowBudget : 160,
- minInterval: requestState ? requestState.minInterval : 90,
- maxConcurrent: requestState ? requestState.maxConcurrent : 3,
- type,
- });
- }
-
- function discourseJSONFetchOptions(headers) {
- return {
- method: 'GET',
- credentials: 'include',
- cache: 'no-store',
- headers: {
- 'Accept': 'application/json, text/javascript, */*; q=0.01',
- 'X-Requested-With': 'XMLHttpRequest',
- 'Discourse-Present': 'true',
- ...headers,
- },
- };
- }
-
- async function fetchJSON(url, options = {}) {
- const scheduler = options.scheduler || ACTIVE_READER_REQUEST_SCHEDULER;
- const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible;
- const requestCallSite = requestFlowCallSite();
- const fetchOptions = {
- credentials: 'include',
- ...(options.fetchOptions || {}),
- };
- fetchOptions.headers = Object.assign(
- { 'Accept': 'application/json' },
- options.fetchOptions?.headers || {}
- );
- if (options.signal) fetchOptions.signal = options.signal;
- return requestCachedJSON(url, fetchOptions, options, async () => {
- if (scheduler && options.bypassScheduler !== true)
- return scheduler.requestJSON(url, fetchOptions, {
- key: options.key || `reader:${url}`,
- priority,
- callSite: requestCallSite,
- });
- const queuedAt = requestFlowNow();
- const requestScope = createRequestAbortScope(fetchOptions.signal);
- const requestType = requestFlowType(requestFlowUrl(url), 'fetch', fetchOptions.method || 'GET');
- let sharedPermit = null;
- try {
- sharedPermit = await acquireSharedReaderRequestPermit(
- priority, requestScope.signal, requestType, scheduler);
- requestScope.startTimeout();
- const res = await trackedReaderFetch(url, {
- ...fetchOptions,
- signal: requestScope.signal,
- [REQUEST_FLOW_FETCH_META]: {
- source: 'reader',
- priority,
- recoveryProbe: sharedPermit && sharedPermit.recoveryProbe === true,
- queuedAt,
- permittedAt: sharedPermit?.startedAt || requestFlowNow(),
- waitReason: sharedPermit?.waitReason || '',
- callSite: requestCallSite,
- attempt: 1,
- type: requestType,
- },
- });
- noteSharedRequestRateLimit(res);
- if (!res.ok) throw requestStatusError('HTTP ' + res.status, res.status);
- return res.json();
- } catch (error) {
- if (requestScope.timedOut() && !(fetchOptions.signal?.aborted))
- throw requestTimeoutError();
- throw error;
- } finally {
- requestScope.cleanup();
- if (sharedPermit && SHARED_REQUEST_COORDINATOR)
- SHARED_REQUEST_COORDINATOR.finishPermit(sharedPermit.permitId);
- }
- });
- }
-
- function ensureKatexStyles() {
- if (chromeExtensionRuntime()) return;
- if (document.querySelector('link[data-ldp-katex]')) return;
- const link = makeElement('link');
- link.rel = 'stylesheet';
- link.href = `https://cdn.jsdelivr.net/npm/katex@${KATEX_VERSION}/dist/katex.min.css`;
- link.dataset.ldpKatex = '1';
- (document.head || PAGE_ROOT).appendChild(link);
- }
-
- function latexTokenInfo(token) {
- if (token.startsWith('$$') && token.endsWith('$$')) return { tex: token.slice(2, -2), displayMode: true };
- if (token.startsWith('\\[') && token.endsWith('\\]')) return { tex: token.slice(2, -2), displayMode: true };
- if (token.startsWith('\\(') && token.endsWith('\\)')) return { tex: token.slice(2, -2), displayMode: false };
- return { tex: token.slice(1, -1), displayMode: false };
- }
-
- function textContainsLatex(value) {
- return /\\(?:frac|sum|sqrt|int|prod|lim|begin|left|right|mathbf|mathrm|text)|\$\$|\\\[|\\\(|[_^]\{/.test(value || '');
- }
-
- function renderLatexInPost(post) {
- if (!post || post.dataset.ldpLatexRendered === '1') return;
- if (post.dataset.ldpContentHydrated === '0') return;
- const content = post.querySelector(':scope > .ldp-content');
- const katex = globalThis.katex;
- if (!content) return;
- if (!textContainsLatex(content.textContent)) {
- post.dataset.ldpLatexRendered = '1';
- return;
- }
- if (!katex) return;
- ensureKatexStyles();
-
- content.querySelectorAll('p').forEach((paragraph) => {
- if (paragraph.children.length || paragraph.closest('pre,code')) return;
- const text = paragraph.textContent.trim();
- if (!/\\(?:frac|sum|sqrt|int|prod|lim|begin)|^[A-Za-z][^\n=]{0,40}=/.test(text)) return;
- try {
- katex.render(text, paragraph, { displayMode: true, throwOnError: false, strict: 'ignore' });
- } catch {}
- });
-
- const tokenPattern = /(\$\$[\s\S]+?\$\$|\\\[[\s\S]+?\\\]|\\\([\s\S]+?\\\)|\$(?!\s)(?:\\.|[^$\\])+?\$)/g;
- const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
- const textNodes = [];
- while (walker.nextNode()) {
- const node = walker.currentNode;
- const parent = node.parentElement;
- if (!parent || parent.closest('pre,code,a,.katex')) continue;
- if (tokenPattern.test(node.nodeValue || '')) textNodes.push(node);
- tokenPattern.lastIndex = 0;
- }
- textNodes.forEach((textNode) => {
- const value = textNode.nodeValue || '';
- const fragment = document.createDocumentFragment();
- let lastIndex = 0;
- value.replace(tokenPattern, (token, offset) => {
- if (offset > lastIndex) fragment.appendChild(document.createTextNode(value.slice(lastIndex, offset)));
- const info = latexTokenInfo(token);
- const holder = makeElement(info.displayMode ? 'div' : 'span');
- try {
- katex.render(info.tex, holder, { displayMode: info.displayMode, throwOnError: false, strict: 'ignore' });
- fragment.appendChild(holder);
- } catch {
- fragment.appendChild(document.createTextNode(token));
- }
- lastIndex = offset + token.length;
- return token;
- });
- if (lastIndex < value.length) fragment.appendChild(document.createTextNode(value.slice(lastIndex)));
- textNode.replaceWith(fragment);
- tokenPattern.lastIndex = 0;
- });
- post.dataset.ldpLatexRendered = '1';
- }
-
- function notificationData(notification) {
- const raw = notification?.data;
- if (!raw) return {};
- if (typeof raw === 'object') return raw;
- try { return JSON.parse(raw); } catch { return {}; }
- }
-
- function notificationTypeName(notification) {
- if (notification?._ldp_type_name) return String(notification._ldp_type_name);
- const site = lookupDiscourse('service:site');
- const lookup = site && discourseModelValue(site, 'notificationLookup');
- return String(lookup?.[Number(notification && notification.notification_type)] || '');
- }
-
- function notificationTypeLabel(notification) {
- return String(notification?._ldp_type_label || notificationTypeName(notification) || '通知');
- }
-
- function notificationTypeGroupKey(notification) {
- if (Object.hasOwn(NOTIFICATION_GROUPS, notification?._ldp_group))
- return notification._ldp_group;
- const typeName = notificationTypeName(notification);
- return Object.entries(NOTIFICATION_GROUPS)
- .find(([key, entry]) => key !== 'all' && entry.types?.includes(typeName))?.[0] || 'all';
- }
-
- function notificationGroupPageSize(groupKey) {
- return Math.max(1, Number(NOTIFICATION_GROUPS[groupKey]?.pageSize) || NOTIFICATION_PAGE_SIZE);
- }
-
- function notificationGroupMode(groupKey) {
- return NOTIFICATION_GROUPS[groupKey]?.mode || 'notifications';
- }
-
- function notificationTypeIconName(notification) {
- const typeName = notificationTypeName(notification);
- return NOTIFICATION_TYPE_ICONS[typeName] ||
- NOTIFICATION_GROUPS[notificationTypeGroupKey(notification)].icon;
- }
-
- function consolidatedReplyInfo(notification) {
- if (notificationTypeName(notification) !== 'replied') return null;
- const data = notificationData(notification);
- const count = Math.max(0, Number(data.consolidated_count) || 0);
- const parentPostNumber = Math.max(0, Number(data.reply_to_post_number) || 0);
- if (count <= 1 || !parentPostNumber) return null;
- return { count, parentPostNumber };
- }
-
- function notificationPresentationText(value) {
- const html = isFunction(value?.toHTML) ? value.toHTML() : String(value || '');
- if (!html) return '';
- const template = makeElement('template');
- template.innerHTML = html;
- return String(template.content.textContent || '').replace(/\s+/g, ' ').trim();
- }
-
- async function applyDiscourseNotificationPresentation(notifications) {
- const notificationModule = lookupDiscourseModule('discourse/models/notification');
- const Notification = notificationModule?.default;
- const manager = lookupDiscourseModule('discourse/lib/notification-types-manager');
- const currentUser = lookupDiscourse('service:current-user');
- const siteSettings = lookupDiscourse('service:site-settings');
- const site = lookupDiscourse('service:site');
- if (!Notification || !isFunction(Notification.initializeNotifications) ||
- !manager || !isFunction(manager.getRenderDirector) ||
- !currentUser || !siteSettings || !site) {
- throw new Error('Discourse 通知渲染服务尚未就绪');
- }
- const source = (notifications || []).map((notification) => Object.assign(
- {}, notification, { data: notificationData(notification) }
- ));
- const models = await Notification.initializeNotifications(source);
- models.forEach((model, index) => {
- const notification = notifications[index];
- const modelData = notificationData(model);
- const typeName = notificationTypeName(model);
- const director = manager.getRenderDirector(typeName, model, currentUser, siteSettings, site);
- const label = notificationPresentationText(director.label);
- const typeLabel = notificationPresentationText(director.linkTitle) || typeName || '通知';
- const description = notificationPresentationText(director.description);
- const href = String(director.linkHref || '').trim();
- notification._ldp_actor = cleanUsernameValue(
- modelData.display_username || modelData.username || model.username
- );
- notification._ldp_type_label = typeLabel;
- notification._ldp_summary = [label, description].filter(Boolean).join(' · ');
- notification._ldp_href = href || discoursePageUrl('/my/notifications');
- notification._ldp_target_topic_id = Math.max(
- 0,
- Number(discourseModelValue(model, 'topic_id')) ||
- Number(discourseModelValue(model, 'topicId')) ||
- Number(modelData.topic_id) ||
- 0
- );
- notification._ldp_target_post_number = Math.max(
- 0,
- Number(discourseModelValue(model, 'post_number')) ||
- Number(discourseModelValue(model, 'postNumber')) ||
- Number(modelData.post_number) ||
- 0
- );
- });
- return notifications;
- }
-
- function notificationTopicTarget(notification) {
- const presentedHref = String(notification?._ldp_href || '').trim();
- const data = notificationData(notification);
- const presentedRoute = [
- presentedHref,
- data.post_url,
- data.topic_url,
- data.url,
- ].map((value) => extractTopicRouteFromUrl(String(value || ''))).find(Boolean);
- const topicId = Math.max(
- 0,
- Number(presentedRoute?.topicId) ||
- Number(notification?._ldp_target_topic_id) ||
- Number(notification?.topic_id) ||
- Number(data.topic_id) ||
- 0
- );
- if (!topicId) return null;
- const postNumber = Math.max(
- 1,
- Number(presentedRoute?.postNumber) ||
- Number(notification?._ldp_target_post_number) ||
- Number(notification?.post_number) ||
- Number(data.post_number) ||
- 1
- );
- return { topicId, postNumber };
- }
-
- function notificationHref(notification) {
- const target = notificationTopicTarget(notification);
- if (target) return `${BASE}/t/${target.topicId}/${target.postNumber}`;
- return String(notification?._ldp_href || discoursePageUrl('/my/notifications'));
- }
-
- function notificationTargetDataAttributes(notification) {
- const target = notificationTopicTarget(notification);
- return target
- ? ` data-notification-topic-id="${target.topicId}" data-notification-post-number="${target.postNumber}"`
- : '';
- }
-
- function notificationSummary(notification) {
- return String(notification?._ldp_summary || notificationTypeLabel(notification));
- }
-
- function notificationActor(notification) {
- if (notification?._ldp_actor) return String(notification._ldp_actor);
- const data = notificationData(notification);
- return cleanUsernameValue(data.display_username || data.original_username || data.acting_user_name ||
- data.username || notification.username);
- }
-
- function normalizedMessageRecord(groupKey, sourceId, entry = {}) {
- const actor = cleanUsernameValue(entry.actor);
- const createdAt = entry.createdAt || new Date(0).toISOString();
- const topicId = Math.max(0, Number(entry.topicId) || 0);
- const postNumber = Math.max(1, Number(entry.postNumber) || 1);
- return {
- id: 0,
- notification_type: 0,
- read: entry.read,
- created_at: createdAt,
- topic_id: topicId,
- post_number: postNumber,
- acting_user_avatar_template: String(entry.avatarTemplate || ''),
- data: { display_username: actor, topic_title: entry.title || '' },
- _ldp_source: NOTIFICATION_GROUPS[groupKey]?.source || '',
- _ldp_group: groupKey,
- _ldp_type_name: groupKey,
- _ldp_type_label: NOTIFICATION_GROUPS[groupKey]?.label || '消息',
- _ldp_actor: actor,
- _ldp_summary: String(entry.summary || entry.title || ''),
- _ldp_excerpt: notificationPresentationText(entry.excerpt),
- _ldp_state_label: String(entry.stateLabel || ''),
- _ldp_display_identity: `${groupKey}:${sourceId || `${topicId}:${postNumber}:${createdAt}`}`,
- _ldp_target_topic_id: topicId,
- _ldp_target_post_number: postNumber,
- };
- }
-
- function normalizeUserActionMessage(action, groupKey) {
- const actionType = Number(action?.action_type) || 0;
- const actor = action?.acting_username || action?.username;
- const title = notificationPresentationText(action?.title);
- const verb = actionType === 9 ? '引用了你' : {
- replies: '回复了你',
- likes: '赞了你的帖子',
- mentions: '@提及了你',
- edits: '编辑了帖子',
- links: '链接了你的帖子',
- }[groupKey] || NOTIFICATION_GROUPS[groupKey]?.label;
- return normalizedMessageRecord(groupKey, [
- actionType, action?.post_id, action?.acting_user_id, action?.created_at,
- ].filter(Boolean).join(':'), {
- actor,
- avatarTemplate: action?.acting_avatar_template || action?.avatar_template,
- createdAt: action?.created_at,
- topicId: action?.topic_id,
- postNumber: action?.post_number,
- title,
- summary: `${actor ? `@${actor} · ` : ''}${verb}${title ? ` · ${title}` : ''}`,
- excerpt: action?.excerpt,
- });
- }
-
- function normalizeReactionMessage(reaction) {
- const post = reaction?.post || {};
- const actor = reaction?.user?.username;
- const value = reaction?.reaction?.reaction_value || reaction?.reaction_value || reaction?.reaction || '';
- const title = notificationPresentationText(post?.topic?.title || post?.topic_title);
- return normalizedMessageRecord('reactions', reaction?.id, {
- actor,
- avatarTemplate: reaction?.user?.avatar_template,
- createdAt: reaction?.created_at,
- topicId: post?.topic_id,
- postNumber: post?.post_number || extractTopicRouteFromUrl(post?.url)?.postNumber,
- title,
- summary: `${actor ? `@${actor} · ` : ''}${value ? `用 ${value} ` : ''}回应了你的帖子${title ? ` · ${title}` : ''}`,
- excerpt: post?.excerpt,
- });
- }
-
- function normalizeBoostMessage(boost) {
- const post = boost?.post || {};
- const actor = boost?.user?.username;
- const title = notificationPresentationText(post?.topic_title);
- return normalizedMessageRecord('boosts', boost?.id, {
- actor,
- avatarTemplate: boost?.user?.avatar_template,
- createdAt: boost?.created_at,
- topicId: post?.topic_id,
- postNumber: extractTopicRouteFromUrl(post?.url)?.postNumber,
- title,
- summary: `${actor ? `@${actor} · ` : ''}Boost 了你的帖子${title ? ` · ${title}` : ''}`,
- excerpt: boost?.cooked || post?.excerpt,
- });
- }
-
- function normalizePrivateMessageTopic(topic, payload, groupKey) {
- const users = new Map((payload?.users || []).map((user) => [Number(user?.id), user]));
- const participants = (topic?.participants || topic?.posters || [])
- .map((participant) => participant?.username ? participant : users.get(Number(participant?.user_id)))
- .filter(Boolean);
- const currentUsername = cleanUsernameValue(ME_USERNAME || ME_CURRENT_USER?.username).toLocaleLowerCase();
- const lastPoster = [...users.values()].find((user) =>
- cleanUsernameValue(user?.username) === cleanUsernameValue(topic?.last_poster_username)
- );
- const actor = [...participants].reverse().find((user) =>
- cleanUsernameValue(user?.username).toLocaleLowerCase() !== currentUsername
- ) || lastPoster || participants.at(-1) || {};
- const highest = Math.max(1, Number(topic?.highest_post_number) || Number(topic?.posts_count) || 1);
- const lastRead = Math.max(0, Number(topic?.last_read_post_number) || 0);
- const unread = topic?.unseen === true || Number(topic?.unread) > 0 || Number(topic?.new_posts) > 0;
- const stateLabel = topic?.unseen === true || Number(topic?.new_posts) > 0 ? '新' : unread ? '未读' : '已读';
- const target = unread && lastRead < highest ? lastRead + 1 : highest;
- const title = notificationPresentationText(topic?.fancy_title || topic?.title) || '私信';
- return normalizedMessageRecord(groupKey, topic?.id, {
- actor: actor?.username,
- avatarTemplate: actor?.avatar_template,
- createdAt: topic?.last_posted_at || topic?.bumped_at || topic?.created_at,
- topicId: topic?.id,
- postNumber: target,
- title,
- summary: title,
- excerpt: [
- actor?.username ? `@${actor.username}` : '',
- highest > 1 ? `${highest - 1} 条回复` : '',
- ].filter(Boolean).join(' · '),
- read: !unread,
- stateLabel,
- });
- }
-
- function inheritCachedNotificationState(records) {
- const nativeNotifications = cachedNotificationsForGroup('all', false)
- .filter((notification) => !notification?._ldp_group && typeof notification?.read === 'boolean');
- return records.map((record) => {
- const target = notificationTopicTarget(record);
- if (!target) return record;
- const matches = nativeNotifications.filter((notification) => {
- const candidate = notificationTopicTarget(notification);
- return candidate?.topicId === target.topicId &&
- candidate.postNumber === target.postNumber &&
- notificationTypeGroupKey(notification) === record._ldp_group;
- });
- const actor = notificationActor(record).toLocaleLowerCase();
- const match = matches.find((notification) =>
- notificationActor(notification).toLocaleLowerCase() === actor
- ) || (matches.length === 1 ? matches[0] : null);
- if (!match) return record;
- return Object.assign(record, {
- id: Math.max(0, Number(match.id) || 0),
- notification_type: Math.max(0, Number(match.notification_type) || 0),
- read: match.read,
- _ldp_source_notification_id: notificationReadIdentity(match),
- });
- });
- }
-
- function normalizeSearchText(value) {
- return String(value || '').toLocaleLowerCase().replace(/\s+/g, '').trim();
- }
-
- function pinyinSearchForms(value) {
- const source = String(value || '');
- if (!source) return [];
- const cached = SEARCH_PINYIN_CACHE.get(source);
- if (cached) {
- SEARCH_PINYIN_CACHE.delete(source);
- SEARCH_PINYIN_CACHE.set(source, cached);
- return cached;
- }
- const forms = [normalizeSearchText(source)];
- const api = globalThis.pinyinPro;
- if (isFunction(api?.pinyin)) {
- try {
- forms.push(normalizeSearchText(api.pinyin(source, { toneType: 'none', nonZh: 'consecutive' })));
- forms.push(normalizeSearchText(api.pinyin(source, {
- pattern: 'first', toneType: 'none', nonZh: 'consecutive',
- })));
- } catch {}
- }
- const result = [...new Set(forms.filter(Boolean))];
- SEARCH_PINYIN_CACHE.set(source, result);
- while (SEARCH_PINYIN_CACHE.size > SEARCH_PINYIN_CACHE_MAX_ENTRIES) {
- SEARCH_PINYIN_CACHE.delete(SEARCH_PINYIN_CACHE.keys().next().value);
- }
- return result;
- }
-
- function addNotificationStatsIdentities(notificationIds, notifications) {
- notifications.forEach((notification) => {
- const notificationId = String(notification?.id || '') || [
- notification?.topic_id,
- notification?.post_number,
- notification?.created_at,
- ].filter(Boolean).join(':');
- if (notificationId) notificationIds.add(notificationId);
- });
- }
-
- function notificationDisplayIdentity(notification) {
- return String(notification?._ldp_display_identity || '') ||
- String(notification?.id || '') || [
- notificationHref(notification), notification?.created_at, notificationActor(notification),
- ].join('|');
- }
-
- function notificationReadIdentity(notification) {
- return String(notification?._ldp_source_notification_id || '') ||
- String(notification?.id || '') ||
- notificationDisplayIdentity(notification);
- }
-
- function notificationMatchesSearch(notification, needle) {
- if (!needle) return true;
- const data = notificationData(notification);
- return [
- notificationActor(notification),
- notificationSummary(notification),
- notification?._ldp_excerpt,
- data.display_username,
- data.original_username,
- data.acting_user_name,
- data.username,
- data.topic_title,
- notification?.username,
- ...[
- notification?.user_id,
- notification?.acting_user_id,
- data.user_id,
- data.acting_user_id,
- data.original_user_id,
- ].filter((value) => value != null && value !== '').map(String),
- ].some((value) => normalizeSearchText(value).includes(needle));
- }
-
- function historyMatchesSearch(entry, query) {
- const needle = normalizeSearchText(query);
- if (!needle) return true;
- return pinyinSearchForms(entry?.title).some((value) => value.includes(needle));
- }
-
- function reactionRecordMatchesSearch(entry, needle) {
- return !needle || [
- entry?.reaction,
- entry?.authorUsername,
- entry?.postNumber,
- ...pinyinSearchForms(entry?.title),
- ].some((value) => normalizeSearchText(value).includes(needle));
- }
-
- function compareGivenReactionRecords(left, right) {
- return (Date.parse(right?.reactedAt || '') || 0) -
- (Date.parse(left?.reactedAt || '') || 0) ||
- Number(right?.id || 0) - Number(left?.id || 0);
- }
-
- function forEachCachedNotification(callback) {
- if (ME_USERNAME === null) return;
- const authPrefix = authScopedCacheKey('tabs-v7:');
- NOTIFICATION_CACHE.forEach((entry, cacheKey) => {
- if (String(cacheKey).startsWith(authPrefix) && Array.isArray(entry?.value?.notifications))
- entry.value.notifications.forEach(callback);
- });
- }
-
- function cachedNotificationsForGroup(groupKey, sort = true) {
- const seen = new Set();
- const notifications = [];
- forEachCachedNotification((notification) => {
- if (!notificationMatchesGroup(notification, groupKey)) return;
- const key = notificationDisplayIdentity(notification);
- if (seen.has(key)) return;
- seen.add(key);
- notifications.push(notification);
- });
- return sort ? sortNotificationsNewestFirst(notifications) : notifications;
- }
-
- function notificationMatchesGroup(notification, groupKey) {
- const group = NOTIFICATION_GROUPS[groupKey] || NOTIFICATION_GROUPS.all;
- if (notification?._ldp_group) return notification._ldp_group === groupKey;
- return !group.types?.length || group.types.includes(notificationTypeName(notification));
- }
-
- function currentUserUnreadNotificationCount(currentUser) {
- if (!currentUser || typeof currentUser !== 'object') return null;
- const readCount = (key) => {
- const value = discourseModelValue(currentUser, key);
- if (value == null || value === '') return null;
- const count = Number(value);
- if (Number.isFinite(count)) return Math.max(0, Math.floor(count));
- return null;
- };
- const allUnread = readCount('all_unread_notifications_count');
- if (allUnread != null) return allUnread;
- const normalUnread = readCount('unread_notifications');
- const highPriorityUnread = readCount('unread_high_priority_notifications');
- if (normalUnread != null || highPriorityUnread != null)
- return (normalUnread || 0) + (highPriorityUnread || 0);
- return readCount('unread_notification_count');
- }
-
- function cachedUnreadNotificationCount(groupKey = 'all') {
- const unread = new Set();
- cachedNotificationsForGroup(groupKey, false).forEach((notification) => {
- if (notification && notification.read === false) unread.add(notificationReadIdentity(notification));
- });
- return unread.size;
- }
-
- function setCurrentUserNotificationState(currentUser, values) {
- if (!currentUser || !isFunction(currentUser.set)) return;
- if (isFunction(currentUser.setProperties)) {
- currentUser.setProperties(values);
- return;
- }
- Object.entries(values).forEach(([key, value]) => currentUser.set(key, value));
- }
-
- function decrementCurrentUserNotificationType(typeId, highPriority = false) {
- const currentUser = lookupDiscourse('service:current-user');
- if (!currentUser || !isFunction(currentUser.set) || !(Number(typeId) > 0)) return;
- const grouped = {
- ...(discourseModelValue(currentUser, 'grouped_unread_notifications') || {}),
- };
- const unreadCount = Number(grouped[typeId]) || 0;
- if (unreadCount > 0) grouped[typeId] = unreadCount - 1;
- const normalUnread = Math.max(
- 0, Math.floor(Number(discourseModelValue(currentUser, 'unread_notifications')) || 0)
- );
- const highPriorityUnread = Math.max(
- 0, Math.floor(Number(discourseModelValue(currentUser, 'unread_high_priority_notifications')) || 0)
- );
- const nextNormalUnread = highPriority ? normalUnread : Math.max(0, normalUnread - 1);
- const nextHighPriorityUnread = highPriority
- ? Math.max(0, highPriorityUnread - 1)
- : highPriorityUnread;
- const values = {
- grouped_unread_notifications: grouped,
- unread_notifications: nextNormalUnread,
- unread_high_priority_notifications: nextHighPriorityUnread,
- all_unread_notifications_count: nextNormalUnread + nextHighPriorityUnread,
- };
- if (discourseModelValue(currentUser, 'unread_notification_count') != null)
- values.unread_notification_count = Math.max(
- 0, Math.floor(Number(discourseModelValue(currentUser, 'unread_notification_count')) || 0) - 1
- );
- setCurrentUserNotificationState(currentUser, values);
- }
-
- function syncCurrentUserNotificationsDismissed() {
- const currentUser = lookupDiscourse('service:current-user');
- if (!currentUser || !isFunction(currentUser.set)) return;
- const values = {
- all_unread_notifications_count: 0,
- unread_notifications: 0,
- unread_high_priority_notifications: 0,
- grouped_unread_notifications: {},
- };
- if (discourseModelValue(currentUser, 'unread_notification_count') != null)
- values.unread_notification_count = 0;
- setCurrentUserNotificationState(currentUser, values);
- }
-
- function syncNotificationUnreadUi(count = NOTIFICATION_UNREAD_COUNT) {
- NOTIFICATION_UNREAD_COUNT = Math.max(0, Math.floor(Number(count) || 0));
- const badgeText = NOTIFICATION_UNREAD_COUNT > 99 ? '99+' : String(NOTIFICATION_UNREAD_COUNT);
- readerPortalQueryAll('.ldp-notification-unread-badge').forEach((badge) => {
- badge.hidden = NOTIFICATION_UNREAD_COUNT <= 0;
- badge.textContent = badgeText;
- });
- readerPortalQueryAll('.ldp-notification-unread-status').forEach((status) => {
- status.textContent = NOTIFICATION_UNREAD_COUNT > 0 ? `未读 ${NOTIFICATION_UNREAD_COUNT} 条` : '没有未读消息';
- });
- readerPortalQueryAll('.ldp-notification-mark-all').forEach((button) => {
- if (button.dataset.ldpRequestBusy !== '1') button.disabled = NOTIFICATION_UNREAD_COUNT <= 0;
- });
- }
-
- function markCachedNotificationsRead() {
- let changed = false;
- forEachCachedNotification((notification) => {
- if (!notification || notification.read !== false ||
- (notification._ldp_group && !notification._ldp_source_notification_id)) return;
- notification.read = true;
- changed = true;
- });
- if (changed) schedulePersistentCacheWrite('notifications', 0);
- syncNotificationUnreadUi(0);
- }
-
- function markClickedNotificationRead(item) {
- if (!item || item.dataset.notificationSource !== 'notifications' ||
- !item.classList.contains('unread')) return 0;
- const notificationKey = String(item.dataset.notificationReadKey || item.dataset.notificationKey || '');
- let notificationTypeId = 0;
- let notificationHighPriority = false;
- let cacheChanged = false;
- forEachCachedNotification((notification) => {
- if (!notification || notification.read !== false ||
- notificationReadIdentity(notification) !== notificationKey) return;
- notification.read = true;
- notificationTypeId ||= Number(notification.notification_type) || 0;
- notificationHighPriority ||= notification.high_priority === true;
- cacheChanged = true;
- });
- if (cacheChanged) schedulePersistentCacheWrite('notifications', 0);
- decrementCurrentUserNotificationType(notificationTypeId, notificationHighPriority);
- readerPortalQueryAll('.ldp-notification-item').forEach((notificationItem) => {
- if (String(notificationItem.dataset.notificationReadKey || notificationItem.dataset.notificationKey || '') !==
- notificationKey) return;
- notificationItem.classList.remove('unread');
- const readState = notificationItem.querySelector('.ldp-notification-read-state');
- if (readState) readState.textContent = '已读';
- });
- syncNotificationUnreadUi(Math.max(0, NOTIFICATION_UNREAD_COUNT - 1));
- return Math.max(0, Number(item.dataset.notificationId) || 0);
- }
-
- function notificationDateGroup(createdAt) {
- const date = new Date(createdAt || 0);
- if (Number.isNaN(date.getTime())) return '更早';
- const today = new Date();
- const startToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
- const startDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
- if (startDate === startToday) return '今天';
- if (startDate === startToday - 86400000) return '昨天';
- return '更早';
- }
-
- function groupNotificationsByDate(notifications) {
- const buckets = new Map([['今天', []], ['昨天', []], ['更早', []]]);
- (notifications || []).forEach((notification) => {
- buckets.get(notificationDateGroup(notification.created_at)).push(notification);
- });
- return [...buckets.entries()].filter(([, items]) => items.length);
- }
-
- function notificationAvatarHtml(notification) {
- const data = notificationData(notification);
- const actor = notificationActor(notification);
- const template = notification.acting_user_avatar_template || notification.avatar_template ||
- data.acting_user_avatar_template || data.avatar_template || cachedActorAvatar(actor);
- let avatarHtml;
- if (template) {
- const cachedSource = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, avatarUrl(template, 64));
- avatarHtml = cachedSource
- ? `
})
`
- : `
${esc(String(actor || '?').slice(0, 1).toUpperCase())}`;
- } else {
- avatarHtml = `
${esc(String(actor || '?').slice(0, 1).toUpperCase())}`;
- }
- return userCardAvatarHtml(avatarHtml, actor);
- }
-
- function cachedActorAvatar(actor, historySource = readTopicHistory) {
- actor = cleanUsernameValue(actor);
- if (!actor) return '';
- const cachedTemplate = NOTIFICATION_AVATAR_CACHE.get(actor);
- if (cachedTemplate) {
- rememberNotificationAvatar(actor, cachedTemplate);
- return cachedTemplate;
- }
- const cachedUser = getCachedUserCard(actor);
- let template = String(cachedUser?.avatar_template || '').trim();
- if (!template) {
- const actorKey = actor.toLocaleLowerCase();
- const historyEntry = historySource().find((entry) =>
- cleanUsernameValue(entry?.ownerUsername).toLocaleLowerCase() === actorKey && entry.avatarTemplate
- );
- template = String(historyEntry?.avatarTemplate || '').trim();
- }
- if (template) rememberNotificationAvatar(actor, template);
- return template;
- }
-
- function rememberNotificationAvatar(actor, template) {
- actor = cleanUsernameValue(actor);
- template = String(template || '').trim();
- if (!actor || !template) return;
- NOTIFICATION_AVATAR_CACHE.delete(actor);
- NOTIFICATION_AVATAR_CACHE.set(actor, template);
- while (NOTIFICATION_AVATAR_CACHE.size > NOTIFICATION_AVATAR_CACHE_MAX_ENTRIES) {
- NOTIFICATION_AVATAR_CACHE.delete(NOTIFICATION_AVATAR_CACHE.keys().next().value);
- }
- }
-
- function notificationCacheSnapshot(notification) {
- if (!notification || typeof notification !== 'object') return notification;
- const data = notificationData(notification);
- const snapshot = Object.assign({}, notification, { data });
- if (!snapshot._ldp_actor) snapshot._ldp_actor = notificationActor(snapshot);
- const actor = notificationActor(snapshot);
- const template = notification.acting_user_avatar_template || notification.avatar_template ||
- data.acting_user_avatar_template || data.avatar_template || cachedActorAvatar(actor) || '';
- if (template) {
- snapshot.avatar_template = template;
- if (actor) rememberNotificationAvatar(actor, template);
- }
- return snapshot;
- }
-
- function topicReplyMatchesConsolidatedBucket(post, parentPostNumber, latestPostNumber) {
- const postNumber = Math.max(0, Number(post?.post_number) || 0);
- if (!postNumber || postNumber > latestPostNumber) return false;
- const replyTo = Math.max(0, Number(post?.reply_to_post_number) || 0);
- return parentPostNumber === 1 ? replyTo <= 1 && postNumber > 1 : replyTo === parentPostNumber;
- }
-
- function expandedReplyNotification(notification, post) {
- const data = { ...notificationData(notification) };
- const actor = cleanUsernameValue(post?.username);
- const postNumber = Math.max(0, Number(post?.post_number) || 0);
- Object.assign(data, {
- consolidated_count: 1,
- display_username: actor,
- original_username: actor,
- acting_user_name: actor,
- username: actor,
- post_number: postNumber,
- });
- const expanded = Object.assign({}, notification, {
- data,
- post_number: postNumber,
- created_at: post?.created_at || notification.created_at,
- username: actor,
- acting_user_avatar_template: post?.avatar_template ||
- notification.acting_user_avatar_template,
- _ldp_display_identity: `${notification.id || 'reply'}:reply:${postNumber}`,
- _ldp_source_notification_id: notification.id || '',
- });
- delete expanded._ldp_actor;
- delete expanded._ldp_summary;
- delete expanded._ldp_href;
- delete expanded._ldp_type_label;
- return expanded;
- }
-
- async function expandConsolidatedReplyNotification(notification, options = {}) {
- const info = consolidatedReplyInfo(notification);
- const topicId = Math.max(0, Number(notification?.topic_id) || 0);
- const latestPostNumber = Math.max(
- 0,
- Number(notification?.post_number) ||
- Number(notificationData(notification).post_number) ||
- 0
- );
- if (!info || !topicId || !latestPostNumber) return [notification];
- try {
- const payload = await fetchJSON(`${BASE}/t/${topicId}.json?post_number=${latestPostNumber}`, {
- key: `notification-replies:${topicId}:${latestPostNumber}`,
- priority: POST_REQUEST_PRIORITY.auxiliary,
- cacheMode: options.force ? 'refresh' : 'default',
- });
- const replies = postsFromPayload(payload)
- .filter((post) => topicReplyMatchesConsolidatedBucket(
- post,
- info.parentPostNumber,
- latestPostNumber
- ))
- .sort((left, right) => Number(right.post_number) - Number(left.post_number))
- .slice(0, info.count)
- .sort((left, right) => Number(right.post_number) - Number(left.post_number));
- if (replies.length !== info.count) return [notification];
- return replies.map((post) => expandedReplyNotification(notification, post));
- } catch {
- return [notification];
- }
- }
-
- async function expandConsolidatedReplyNotifications(notifications, options = {}) {
- const groups = await Promise.all((notifications || []).map((notification) =>
- expandConsolidatedReplyNotification(notification, options)));
- const expanded = groups.flat();
- await applyDiscourseNotificationPresentation(expanded);
- return expanded.map(notificationCacheSnapshot);
- }
-
- function persistNotificationActorAvatar(actor, template) {
- actor = cleanUsernameValue(actor);
- template = String(template || '').trim();
- if (!actor || !template) return;
- rememberNotificationAvatar(actor, template);
- let changed = false;
- forEachCachedNotification((notification) => {
- if (notificationActor(notification) !== actor || notification.avatar_template === template) return;
- notification.avatar_template = template;
- changed = true;
- });
- if (changed) schedulePersistentCacheWrite('notifications', 0);
- }
-
- function hydrateActorAvatarNodes(container, templates, attribute) {
- if (!container.isConnected || !templates.size) return;
- container.querySelectorAll(`[${attribute}]`).forEach((item) => {
- const template = templates.get(item.getAttribute(attribute));
- const current = template && item.querySelector('.ldp-notification-avatar');
- if (!current || current.tagName === 'IMG') return;
- current.dataset.ldpAvatarTemplate = template;
- });
- }
-
- function cachedActorAvatars(actors, historySource) {
- const templates = new Map();
- actors.forEach((actor) => {
- const template = cachedActorAvatar(actor, historySource);
- if (template) templates.set(actor, template);
- });
- return templates;
- }
-
- function hydrateNotificationAvatars(container, notifications) {
- if (!container) return;
- const actors = new Set();
- (notifications || []).forEach((notification) => {
- const actor = notificationActor(notification);
- const data = notificationData(notification);
- if (actor && !(notification.acting_user_avatar_template || notification.avatar_template ||
- data.acting_user_avatar_template || data.avatar_template)) actors.add(actor);
- });
- let history;
- const templates = cachedActorAvatars(actors, () => history || (history = readTopicHistory()));
- if (container.isConnected)
- templates.forEach((template, actor) => persistNotificationActorAvatar(actor, template));
- hydrateActorAvatarNodes(container, templates, 'data-notification-actor');
- hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.auxiliary);
- }
-
- function hydrateHistoryAvatars(container, entries, storedHistory = null) {
- if (!container) return;
- const actors = new Set();
- (entries || []).forEach((entry) => {
- const actor = cleanUsernameValue(entry?.ownerUsername);
- if (actor && !entry.avatarTemplate) actors.add(actor);
- });
- const history = actors.size ? storedHistory || readTopicHistory() : [];
- const templates = cachedActorAvatars(actors, () => history);
- let historyChanged = false;
- history.forEach((entry) => {
- const template = templates.get(cleanUsernameValue(entry?.ownerUsername));
- if (!template || entry.avatarTemplate === template) return;
- entry.avatarTemplate = template;
- historyChanged = true;
- });
- if (historyChanged) writeTopicHistory(history);
- hydrateActorAvatarNodes(container, templates, 'data-history-actor');
- hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.auxiliary);
- }
-
- function renderNotificationList(notifications) {
- const groups = groupNotificationsByDate(notifications);
- if (!groups.length) return '
暂无消息
';
- return groups.map(([label, items]) => `
-
`).join('');
- }
-
- function collectionLinkMarkup(className, href, avatar, title, meta, metaIsHtml = false) {
- return `
- ${avatar}
-
- ${esc(title)}
- ${metaIsHtml ? meta : esc(meta)}
-
- `;
- }
-
- function getCachedNotificationPage(cacheKey) {
- const entry = NOTIFICATION_CACHE.get(cacheKey);
- if (!entry) return null;
- if (Date.now() - Number(entry.at || 0) > PERSISTENT_CACHE_CONFIG.notifications.maxAge) {
- NOTIFICATION_CACHE.delete(cacheKey);
- schedulePersistentCacheWrite('notifications');
- return null;
- }
- NOTIFICATION_CACHE.delete(cacheKey);
- NOTIFICATION_CACHE.set(cacheKey, entry);
- return entry.value || null;
- }
-
- function setCachedNotificationPage(cacheKey, value) {
- if (!cacheKey || !value) return;
- NOTIFICATION_CACHE.delete(cacheKey);
- NOTIFICATION_CACHE.set(cacheKey, { value, at: Date.now() });
- trimPersistentCacheMap('notifications');
- schedulePersistentCacheWrite('notifications', 0);
- }
-
- function notificationPageCacheKey(groupKey, page) {
- return authScopedCacheKey(`tabs-v7:${groupKey}:${Math.max(0, Number(page) || 0)}`);
- }
-
- function expireNotificationFirstPageCaches() {
- const staleAt = Date.now() - NOTIFICATION_CACHE_FRESH_FOR - 1;
- let changed = false;
- Object.keys(NOTIFICATION_GROUPS).forEach((groupKey) => {
- const entry = NOTIFICATION_CACHE.get(notificationPageCacheKey(groupKey, 0));
- if (!entry) return;
- entry.at = staleAt;
- changed = true;
- });
- if (changed) schedulePersistentCacheWrite('notifications', 0);
- }
-
- function sortNotificationsNewestFirst(notifications) {
- return (notifications || []).slice().sort((a, b) => {
- const timeA = Date.parse(a?.created_at || '') || 0;
- const timeB = Date.parse(b?.created_at || '') || 0;
- return timeB - timeA || Number(b?.id || 0) - Number(a?.id || 0);
- });
- }
-
- async function fetchNotificationPage(groupKey, page, options = {}) {
- const safeGroupKey = Object.hasOwn(NOTIFICATION_GROUPS, groupKey) ? groupKey : 'all';
- const group = NOTIFICATION_GROUPS[safeGroupKey];
- const pageSize = notificationGroupPageSize(safeGroupKey);
- page = Math.max(0, Number(page) || 0);
- const cacheKey = notificationPageCacheKey(safeGroupKey, page);
- const pendingRequest = NOTIFICATION_REQUESTS.get(cacheKey);
- if (pendingRequest) return pendingRequest;
- const request = (async () => {
- const username = cleanUsernameValue(ME_USERNAME || ME_CURRENT_USER?.username).toLocaleLowerCase();
- if (!username && group.source !== 'notifications') throw new Error('登录信息尚未就绪');
- const params = new URLSearchParams();
- let endpoint = '';
- let normalize = (entry) => entry;
- let pickEntries = (data) => data?.notifications;
- let previous = null;
- if (group.source === 'notifications') {
- params.set('offset', String(page * pageSize));
- params.set('limit', String(pageSize));
- if (username) params.set('username', username);
- endpoint = `${BASE}/notifications.json`;
- } else if (group.source === 'user-actions') {
- params.set('offset', String(page * pageSize));
- params.set('limit', String(pageSize));
- params.set('username', username);
- params.set('filter', group.actionTypes.join(','));
- endpoint = `${BASE}/user_actions.json`;
- pickEntries = (data) => data?.user_actions;
- normalize = (entry) => normalizeUserActionMessage(entry, safeGroupKey);
- } else if (group.source === 'boosts-received' ||
- group.source === 'reactions-received') {
- if (page > 0) {
- const previousCacheKey = notificationPageCacheKey(safeGroupKey, page - 1);
- previous = options.force
- ? await fetchNotificationPage(safeGroupKey, page - 1, options)
- : getCachedNotificationPage(previousCacheKey) ||
- await fetchNotificationPage(safeGroupKey, page - 1, options);
- }
- if (group.source === 'boosts-received') {
- if (previous?.nextCursor) params.set('before_boost_id', previous.nextCursor);
- endpoint = `${BASE}/discourse-boosts/users/${encodeURIComponent(username)}/boosts-received.json`;
- pickEntries = (data) => data?.boosts;
- normalize = normalizeBoostMessage;
- } else {
- params.set('username', username);
- if (previous?.nextCursor) params.set('before_reaction_user_id', previous.nextCursor);
- endpoint = `${BASE}/discourse-reactions/posts/reactions-received.json`;
- pickEntries = (data) => data;
- normalize = normalizeReactionMessage;
- }
- } else {
- params.set('page', String(page));
- endpoint = `${BASE}/topics/${group.path}/${encodeURIComponent(username)}.json`;
- pickEntries = (data) => data?.topic_list?.topics;
- normalize = (entry, data) => normalizePrivateMessageTopic(entry, data, safeGroupKey);
- }
- const query = params.toString();
- const data = await fetchJSON(`${endpoint}${query ? `?${query}` : ''}`, {
- key: `notifications:${cacheKey}`,
- priority: POST_REQUEST_PRIORITY.auxiliary,
- cacheMode: options.force ? 'refresh' : 'default',
- });
- const pickedEntries = pickEntries(data);
- const rawEntries = Array.isArray(pickedEntries)
- ? pickedEntries
- : Array.isArray(pickedEntries?.reactions) ? pickedEntries.reactions : [];
- const normalized = rawEntries.map((entry) => normalize(entry, data));
- const enriched = group.mode === 'notifications' && group.source !== 'notifications'
- ? inheritCachedNotificationState(normalized)
- : normalized;
- const notifications = group.source === 'notifications'
- ? sortNotificationsNewestFirst(await expandConsolidatedReplyNotifications(
- enriched.filter((notification) => notificationMatchesGroup(notification, safeGroupKey)),
- options
- ))
- : sortNotificationsNewestFirst(enriched);
- const offset = page * pageSize;
- const serverTotal = Number(data?.total_rows_notifications || data?.topic_list?.total_rows || 0);
- const hasNext = group.source === 'notifications'
- ? !!data.load_more_notifications || (serverTotal > 0 && offset + pageSize < serverTotal)
- : group.source === 'private-messages'
- ? !!data?.topic_list?.more_topics_url || rawEntries.length >= pageSize
- : rawEntries.length >= pageSize;
- const total = serverTotal > 0
- ? serverTotal
- : offset + notifications.length + (hasNext ? pageSize : 0);
- const lastEntry = rawEntries.at(-1);
- const nextCursor = lastEntry?.reaction_user_id || lastEntry?.id || '';
- const result = { notifications, total, hasNext, nextCursor };
- setCachedNotificationPage(cacheKey, result);
- return result;
- })();
- return await trackSingleFlightRequest(NOTIFICATION_REQUESTS, cacheKey, request);
- }
-
- function postsFromPayload(data) {
- if (!data) return [];
- if (Array.isArray(data)) return data;
- if (Array.isArray(data.post_stream?.posts)) return data.post_stream.posts;
- if (Array.isArray(data.posts)) return data.posts;
- if (data.post?.post_number) return [data.post];
- if (data.post_number) return [data];
- return [];
- }
-
- function shouldStopReaderFallback(error) {
- const status = Number(error?.status || 0);
- return !!(error && error.name === 'AbortError') || status === 408 || status === 429 || status >= 500;
- }
-
- function retryAfterMs(response, attempt) {
- const raw = requestFlowHeader(response?.headers, REQUEST_FLOW_RESPONSE_HEADERS.retryAfter);
- let wait = 0;
- if (raw) {
- const seconds = Number(raw);
- wait = Number.isFinite(seconds)
- ? seconds * 1000
- : Math.max(0, Date.parse(raw) - Date.now());
- }
- if (!wait) wait = POST_REQUEST_429_BACKOFF * Math.pow(2, Math.max(0, attempt - 1));
- return Math.max(1000, Math.min(60000, wait));
- }
-
- function requestRateLimitWindow(response) {
- return rateLimitWindowFromCode(requestFlowHeader(
- response?.headers, REQUEST_FLOW_RESPONSE_HEADERS.rateLimitCode
- ));
- }
-
- function noteSharedRequestRateLimit(response, attempt = 1) {
- if (!SHARED_REQUEST_COORDINATOR || !response || response.status !== 429) return;
- SHARED_REQUEST_COORDINATOR.noteRateLimit({
- wait: retryAfterMs(response, attempt),
- window: requestRateLimitWindow(response).label,
- shortBudget: 40,
- longBudget: 160,
- });
- }
-
- function createReaderRequestScheduler(runtimeConfig) {
- const requestMaxConcurrent = runtimeConfig.requestMaxConcurrent;
- const requestMinInterval = runtimeConfig.requestMinInterval;
- const requestRateTarget = Math.min(0.95, Math.max(0.5, runtimeConfig.requestRateTarget / 100));
- const shortWindowBudget = Math.max(1,
- Math.floor(READER_REQUEST_DEFAULT_SHORT_CEILING * requestRateTarget));
- const longWindowBudget = Math.max(1,
- Math.floor(READER_REQUEST_DEFAULT_LONG_CEILING * requestRateTarget));
- const queue = [];
- const tasksByKey = new Map();
- const endpointRateLimitCircuits = new Map();
- const unknownRateLimitEvidence = [];
- const activeControllers = new Set();
- const pendingPermitControllers = new Set();
- const externalControllers = new Set();
- let activeCount = 0;
- let sharedPermitPending = false;
- let sequence = 0;
- let responseLatencyEwma = 0;
- let responseLatencyPeak = 0;
- let responseLatencySamples = 0;
- let pumpTimer = 0;
- let disposed = false;
-
- const abortError = (reason = 'Aborted') => new DOMException(String(reason || 'Aborted'), 'AbortError');
-
- const endpointRateLimitIdentity = (input, method = 'GET') => {
- const url = requestFlowUrl(input);
- const normalizedMethod = String(method || 'GET').toUpperCase();
- if (!url) {
- const invalid = `${normalizedMethod}:invalid:${String(input || '').slice(0, 160)}`;
- return { fingerprint: invalid, route: invalid };
- }
- const params = [...url.searchParams.entries()].filter(([key]) => key !== '_ldp_retry');
- const identity = (routeOnly) => {
- const path = routeOnly
- ? url.pathname
- .replace(/\b\d+\b/g, ':id')
- .replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, ':uuid')
- : url.pathname;
- const sorted = params.map(([key, value]) => routeOnly ? [key, ''] : [key, value])
- .sort(([aKey, aValue], [bKey, bValue]) =>
- aKey.localeCompare(bKey) || aValue.localeCompare(bValue));
- const query = routeOnly
- ? [...new Set(sorted.map(([key]) => encodeURIComponent(key)))].join('&')
- : sorted.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
- return `${normalizedMethod}:${url.origin}${path}${query ? `?${query}` : ''}`;
- };
- return { fingerprint: identity(false), route: identity(true) };
- };
-
- const pruneEndpointRateLimitState = (at = Date.now()) => {
- const evidenceCutoff = at - READER_ENDPOINT_429_EVIDENCE_WINDOW_MS;
- while (unknownRateLimitEvidence.length && unknownRateLimitEvidence[0].at < evidenceCutoff) {
- unknownRateLimitEvidence.shift();
- }
- endpointRateLimitCircuits.forEach((state, key) => {
- if (state.lastAt < at - READER_ENDPOINT_429_STATE_TTL) endpointRateLimitCircuits.delete(key);
- });
- if (endpointRateLimitCircuits.size > READER_ENDPOINT_429_MAX_ENTRIES) {
- [...endpointRateLimitCircuits.entries()]
- .sort(([, a], [, b]) => a.lastAt - b.lastAt)
- .slice(0, endpointRateLimitCircuits.size - READER_ENDPOINT_429_MAX_ENTRIES)
- .forEach(([key]) => endpointRateLimitCircuits.delete(key));
- }
- };
-
- const endpointRateLimitState = (fingerprint, at = Date.now()) => {
- pruneEndpointRateLimitState(at);
- const state = endpointRateLimitCircuits.get(fingerprint);
- return state && state.blockedUntil > at ? state : null;
- };
-
- const noteUnknownEndpointRateLimit = (task, response) => {
- const at = Date.now();
- pruneEndpointRateLimitState(at);
- const corroboratedGlobal = unknownRateLimitEvidence.some((event) =>
- event.route !== task.endpointRoute && event.at >= at - READER_ENDPOINT_429_EVIDENCE_WINDOW_MS);
- unknownRateLimitEvidence.push({
- at,
- route: task.endpointRoute,
- });
- if (corroboratedGlobal) {
- endpointRateLimitCircuits.clear();
- return null;
- }
- const previous = endpointRateLimitCircuits.get(task.endpointFingerprint);
- const strikes = previous && previous.lastAt >= at - READER_ENDPOINT_429_STATE_TTL
- ? previous.strikes + 1
- : 1;
- const exponential = READER_ENDPOINT_429_BASE_BLOCK_MS * Math.pow(2, Math.min(6, strikes - 1));
- const wait = Math.min(
- READER_ENDPOINT_429_MAX_BLOCK_MS,
- Math.max(retryAfterMs(response, strikes), exponential)
- );
- const state = { strikes, lastAt: at, blockedUntil: at + wait };
- endpointRateLimitCircuits.set(task.endpointFingerprint, state);
- return state;
- };
-
- const latencyBoundConcurrent = () => {
- if (responseLatencySamples < 6 || !responseLatencyEwma) return requestMaxConcurrent;
- const latencyBudget = Math.max(responseLatencyEwma * 1.15, responseLatencyPeak);
- return Math.min(requestMaxConcurrent, Math.max(1, Math.ceil(latencyBudget / requestMinInterval)));
- };
-
- const effectiveMaxConcurrent = latencyBoundConcurrent;
-
- const sortQueue = (at = Date.now()) => queue.sort((a, b) => {
- const aAge = Math.min(2, Math.floor(Math.max(0, at - a.queuedAt) / 5000));
- const bAge = Math.min(2, Math.floor(Math.max(0, at - b.queuedAt) / 5000));
- return (a.priority - aAge) - (b.priority - bAge) || a.sequence - b.sequence;
- });
-
- const schedulePump = () => {
- if (disposed || pumpTimer || sharedPermitPending || !queue.length || activeCount >= effectiveMaxConcurrent()) return;
- pumpTimer = setTimeout(pump, 0);
- };
-
- const finishQueuedTask = (task, error) => {
- if (!task.controlRecorded) {
- task.controlRecorded = true;
- const errorReason = error && error.name === 'AbortError' && error.message && error.message !== 'Aborted'
- ? error.message
- : '';
- const controlReason = String(task.controlReason || errorReason || task.waitReason || (disposed ? 'context-close' : 'cancelled'));
- const type = task.sharedType;
- recordRequestFlowControl(task.url, {
- queuedAt: task.queuedAt,
- method: task.fetchOptions.method || 'GET',
- priority: task.priority,
- waitReason: task.waitReason || controlReason,
- callSite: task.callSite,
- type,
- path: task.permitOnly ? `等待${REQUEST_FLOW_TYPE_LABELS[type] || '请求'}许可` : '',
- attempt: task.attempt + 1,
- controlReason,
- });
- }
- tasksByKey.delete(task.key);
- task.state = 'done';
- task.reject(error);
- };
-
- const noteRateLimit = (response, attempt = 1, options = {}) => {
- if (!response || response.status !== 429) return 0;
- const wait = retryAfterMs(response, attempt);
- const rateLimitWindow = options.rateLimitWindow || requestRateLimitWindow(response);
- if (SHARED_REQUEST_COORDINATOR) {
- SHARED_REQUEST_COORDINATOR.noteRateLimit({
- wait,
- window: rateLimitWindow.label,
- shortBudget: shortWindowBudget,
- longBudget: longWindowBudget,
- });
- }
- schedulePump();
- return wait;
- };
-
- const runTask = async (task) => {
- const controller = new AbortController();
- const externalSignal = task.signal;
- const abort = () => controller.abort();
- const startedAt = Date.now();
- let timedOut = false;
- const timeout = setTimeout(() => {
- timedOut = true;
- controller.abort();
- }, POST_REQUEST_TIMEOUT);
- task.state = 'active';
- task.controller = controller;
- activeCount++;
- activeControllers.add(controller);
- if (externalSignal) externalSignal.addEventListener('abort', abort, { once: true });
- try {
- if (task.permitOnly) {
- tasksByKey.delete(task.key);
- task.state = 'done';
- task.keepSharedPermit = true;
- const permitId = task.sharedPermitId;
- task.resolve({
- recoveryProbe: task.sharedRecoveryProbe === true,
- startedAt: task.permittedAt || requestFlowNow(),
- waitReason: task.waitReason,
- release: () => {
- if (!task.keepSharedPermit) return;
- task.keepSharedPermit = false;
- if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.finishPermit(permitId);
- },
- });
- return;
- }
- const response = await trackedReaderFetch(task.url, {
- ...task.fetchOptions,
- signal: controller.signal,
- [REQUEST_FLOW_FETCH_META]: {
- source: 'reader',
- priority: task.priority,
- recoveryProbe: task.sharedRecoveryProbe === true,
- queuedAt: task.queuedAt,
- permittedAt: task.permittedAt,
- waitReason: task.waitReason,
- callSite: task.callSite,
- attempt: task.attempt + 1,
- type: task.sharedType,
- },
- });
- if (response.status === 429) {
- task.attempt++;
- const rateLimitWindow = requestRateLimitWindow(response);
- const rateLimitHeader = requestFlowHeader(
- response.headers, REQUEST_FLOW_RESPONSE_HEADERS.serverLimit
- );
- const knownWindow = rateLimitWindow.known ||
- /(?:^|;)\s*w\s*=\s*"?(?:10|60)\b/i.test(rateLimitHeader);
- const endpointLimit = knownWindow
- ? null
- : noteUnknownEndpointRateLimit(task, response);
- if (!endpointLimit) {
- noteRateLimit(response, task.attempt, {
- rateLimitWindow,
- });
- } else {
- const error = requestStatusError('HTTP 429(端点冷却)', 429);
- error.rateLimitScope = 'endpoint';
- error.retryAt = endpointLimit.blockedUntil;
- task.controlReason = 'endpoint-cooldown';
- throw error;
- }
- if (task.attempt <= POST_REQUEST_MAX_429_RETRIES &&
- task.priority <= POST_REQUEST_PRIORITY.visible) {
- task.state = 'queued';
- task.controller = null;
- task.queuedAt = requestFlowNow();
- task.permittedAt = 0;
- task.waitReason = 'cooldown';
- queue.push(task);
- return;
- }
- }
- if (!response.ok) {
- throw requestStatusError('HTTP ' + response.status, response.status);
- }
- const responseLatency = Math.max(1, Date.now() - startedAt);
- responseLatencyEwma = responseLatencyEwma
- ? responseLatencyEwma * 0.8 + responseLatency * 0.2
- : responseLatency;
- responseLatencyPeak = Math.max(responseLatency, responseLatencyPeak * 0.9);
- responseLatencySamples++;
- endpointRateLimitCircuits.delete(task.endpointFingerprint);
- const payload = await response.json();
- tasksByKey.delete(task.key);
- task.state = 'done';
- task.resolve(payload);
- } catch (error) {
- tasksByKey.delete(task.key);
- task.state = 'done';
- if (timedOut && !(externalSignal?.aborted)) {
- task.reject(requestTimeoutError());
- } else {
- task.reject(error);
- }
- } finally {
- clearTimeout(timeout);
- if (externalSignal) externalSignal.removeEventListener('abort', abort);
- activeControllers.delete(controller);
- task.controller = null;
- if (!task.keepSharedPermit && task.sharedPermitId && SHARED_REQUEST_COORDINATOR) {
- SHARED_REQUEST_COORDINATOR.finishPermit(task.sharedPermitId);
- }
- task.sharedPermitId = '';
- activeCount = Math.max(0, activeCount - 1);
- schedulePump();
- }
- };
-
- function pump() {
- if (pumpTimer) clearTimeout(pumpTimer);
- pumpTimer = 0;
- if (disposed || sharedPermitPending || !queue.length || activeCount >= effectiveMaxConcurrent()) return;
- sortQueue();
- while (queue.length) {
- const task = queue.shift();
- if (task.signal?.aborted) {
- task.controlReason = 'signal';
- finishQueuedTask(task, abortError());
- continue;
- }
- task.state = 'shared-permit';
- sharedPermitPending = true;
- const permitController = new AbortController();
- const abortPermit = () => permitController.abort();
- pendingPermitControllers.add(permitController);
- if (task.signal) task.signal.addEventListener('abort', abortPermit, { once: true });
- const acquire = SHARED_REQUEST_COORDINATOR && task.countsTowardApi
- ? SHARED_REQUEST_COORDINATOR.acquirePermit({
- priority: task.priority,
- signal: permitController.signal,
- shortBudget: shortWindowBudget,
- longBudget: longWindowBudget,
- minInterval: requestMinInterval,
- maxConcurrent: effectiveMaxConcurrent(),
- type: task.sharedType,
- })
- : Promise.resolve({ startedAt: Date.now(), recoveryProbe: false });
- acquire.then((permit) => {
- task.sharedPermitId = String(permit.permitId || '');
- if (disposed || permitController.signal.aborted) throw abortError();
- task.sharedRecoveryProbe = permit.recoveryProbe === true;
- const startedAt = Number(permit.startedAt) || Date.now();
- task.permittedAt = startedAt;
- task.waitReason = String(permit.waitReason || task.waitReason);
- runTask(task);
- }).catch((error) => {
- if (error && error.name === 'AbortError' && error.message && error.message !== 'Aborted') {
- task.controlReason = error.message;
- }
- if (task.sharedPermitId && SHARED_REQUEST_COORDINATOR) {
- SHARED_REQUEST_COORDINATOR.finishPermit(task.sharedPermitId);
- task.sharedPermitId = '';
- }
- if (tasksByKey.get(task.key) === task) finishQueuedTask(task, error);
- }).finally(() => {
- if (task.signal) task.signal.removeEventListener('abort', abortPermit);
- pendingPermitControllers.delete(permitController);
- sharedPermitPending = false;
- schedulePump();
- });
- break;
- }
- schedulePump();
- }
-
- const requestJSON = (url, fetchOptions = {}, requestOptions = {}) => {
- const key = String(requestOptions.key || url);
- const priority = Number.isFinite(requestOptions.priority)
- ? requestOptions.priority
- : POST_REQUEST_PRIORITY.visible;
- const existing = tasksByKey.get(key);
- if (existing) {
- if (priority < existing.priority && existing.state === 'queued') existing.priority = priority;
- schedulePump();
- return existing.promise;
- }
- const callSite = String(requestOptions.callSite || requestFlowCallSite());
- const method = String(fetchOptions.method || 'GET').toUpperCase();
- const { fingerprint: endpointFingerprint, route: endpointRoute } = endpointRateLimitIdentity(url, method);
- const endpointLimit = endpointRateLimitState(endpointFingerprint);
- let controlError = null;
- let controlReason = '';
- if (endpointLimit) {
- controlError = requestStatusError('端点 429 冷却中', 429);
- controlError.rateLimitScope = 'endpoint';
- controlError.retryAt = endpointLimit.blockedUntil;
- controlReason = 'endpoint-cooldown';
- } else if (disposed) {
- controlError = abortError('context-close');
- controlReason = 'context-close';
- } else if (queue.length >= READER_REQUEST_QUEUE_LIMIT && priority >= POST_REQUEST_PRIORITY.nested &&
- requestOptions.sharedType !== 'read') {
- controlError = abortError('queue-limit');
- controlReason = 'queue-limit';
- }
- if (controlError) {
- recordRequestFlowControl(url, {
- method, priority, callSite,
- type: requestOptions.sharedType,
- waitReason: controlReason === 'endpoint-cooldown' ? controlReason : '',
- controlReason,
- });
- return Promise.reject(controlError);
- }
- const { signal, ...options } = fetchOptions;
- let resolveTask;
- let rejectTask;
- const promise = new Promise((resolve, reject) => {
- resolveTask = resolve;
- rejectTask = reject;
- });
- const task = {
- key,
- url,
- fetchOptions: options,
- signal,
- priority,
- sequence: sequence++,
- queuedAt: requestFlowNow(),
- permittedAt: 0,
- waitReason: activeCount >= effectiveMaxConcurrent() ? 'concurrency' : queue.length ? 'priority' : '',
- callSite,
- attempt: 0,
- state: 'queued',
- controller: null,
- sharedRecoveryProbe: false,
- sharedPermitId: '',
- keepSharedPermit: false,
- controlRecorded: false,
- controlReason: '',
- resolve: resolveTask,
- reject: rejectTask,
- promise,
- permitOnly: requestOptions.permitOnly === true,
- countsTowardApi: requestOptions.countsTowardApi !== false,
- sharedType: String(requestOptions.sharedType ||
- requestFlowType(requestFlowUrl(url), 'fetch', options.method || 'GET')),
- endpointFingerprint,
- endpointRoute,
- };
- tasksByKey.set(key, task);
- queue.push(task);
- schedulePump();
- return promise;
- };
-
- const waitForPermit = (priority = POST_REQUEST_PRIORITY.visible, options = {}) => requestJSON('', {
- signal: options.signal,
- }, {
- key: `external-permit:${sequence}:${Date.now()}`,
- priority,
- permitOnly: true,
- countsTowardApi: options.countsTowardApi !== false,
- sharedType: options.type,
- });
-
- const trackExternalController = (controller) => {
- if (!controller || !isFunction(controller.abort)) return () => {};
- if (disposed) {
- controller.abort();
- return () => {};
- }
- externalControllers.add(controller);
- return () => externalControllers.delete(controller);
- };
-
- const cancelQueued = (key) => {
- const task = tasksByKey.get(String(key));
- if (!task || task.state !== 'queued') return false;
- const index = queue.indexOf(task);
- if (index >= 0) queue.splice(index, 1);
- task.controlReason = 'cancelled';
- finishQueuedTask(task, abortError('cancelled'));
- return true;
- };
-
- const resetRateLimits = () => {
- endpointRateLimitCircuits.clear();
- unknownRateLimitEvidence.length = 0;
- schedulePump();
- return SHARED_REQUEST_COORDINATOR?.resetRateLimits
- ? SHARED_REQUEST_COORDINATOR.resetRateLimits()
- : Promise.resolve(true);
- };
-
- const destroy = () => {
- if (disposed) return;
- disposed = true;
- if (pumpTimer) clearTimeout(pumpTimer);
- pumpTimer = 0;
- queue.splice(0).forEach((task) => {
- task.controlReason = 'context-close';
- finishQueuedTask(task, abortError('context-close'));
- });
- pendingPermitControllers.forEach((controller) => controller.abort());
- pendingPermitControllers.clear();
- activeControllers.forEach((controller) => controller.abort());
- activeControllers.clear();
- externalControllers.forEach((controller) => controller.abort());
- externalControllers.clear();
- endpointRateLimitCircuits.clear();
- unknownRateLimitEvidence.length = 0;
- };
-
- const snapshot = () => {
- const at = Date.now();
- pruneEndpointRateLimitState(at);
- const endpointBlocks = [...endpointRateLimitCircuits.values()]
- .filter((state) => state.blockedUntil > at);
- sortQueue(at);
- const shared = SHARED_REQUEST_COORDINATOR?.snapshot(
- shortWindowBudget,
- longWindowBudget,
- requestMinInterval,
- effectiveMaxConcurrent(),
- at
- );
- return {
- active: activeCount,
- queued: queue.length + (sharedPermitPending ? 1 : 0),
- globalQueued: shared ? shared.queued : queue.length,
- globalActive: shared ? shared.active : activeCount,
- globalInstances: shared ? shared.instances : 1,
- globalMaxConcurrent: shared ? shared.maxConcurrent : effectiveMaxConcurrent(),
- globalMinInterval: shared ? shared.minInterval : requestMinInterval,
- coordinationMode: shared ? shared.coordinationMode : 'local',
- maxConcurrent: effectiveMaxConcurrent(),
- minInterval: requestMinInterval,
- configuredMinInterval: requestMinInterval,
- requestRateTarget: Math.round(requestRateTarget * 100),
- configuredShortWindowBudget: shortWindowBudget,
- configuredLongWindowBudget: longWindowBudget,
- shortWindowBudget: shared ? shared.shortBudget : shortWindowBudget,
- longWindowBudget: shared ? shared.longBudget : longWindowBudget,
- shortWindowCount: shared ? shared.shortCount : 0,
- longWindowCount: shared ? shared.longCount : 0,
- blockingReason: shared ? shared.blockingReason : '',
- nextPermitDelay: shared ? shared.nextPermitDelay : 0,
- coolingDown: !!(shared?.coolingDown),
- cooldownRemaining: shared ? shared.cooldownRemaining : 0,
- endpointCoolingDown: endpointBlocks.length > 0,
- endpointCooldownCount: endpointBlocks.length,
- endpointCooldownRemaining: endpointBlocks.reduce(
- (maximum, state) => Math.max(maximum, state.blockedUntil - at),
- 0
- ),
- learnedRateLimit: !!shared && (shared.rateLimited ||
- shared.shortBudget < shortWindowBudget || shared.longBudget < longWindowBudget),
- lastRateLimitWindow: shared ? shared.lastRateLimitWindow : '',
- };
- };
-
- return {
- requestJSON,
- waitForPermit,
- trackExternalController,
- cancelQueued,
- destroy,
- noteRateLimit,
- resetRateLimits,
- snapshot,
- };
- }
-
- async function apiSend(url, method, params, extraHeaders, requestOptions = {}) {
- const priority = Number.isFinite(requestOptions.priority)
- ? requestOptions.priority
- : POST_REQUEST_PRIORITY.visible;
- const requestType = requestFlowType(requestFlowUrl(url), 'fetch', method);
- const requestScope = createRequestAbortScope();
- const initialScheduler = requestOptions.surviveReaderSwitch === true ? null : ACTIVE_READER_REQUEST_SCHEDULER;
- const untrackInitialController = isFunction(initialScheduler?.trackExternalController)
- ? initialScheduler.trackExternalController(requestScope.controller)
- : () => {};
- let requestPermit = null;
- let sharedPermit = null;
- try {
- const opt = {
- method,
- credentials: 'include',
- headers: Object.assign({
- 'Accept': 'application/json',
- 'X-CSRF-Token': await discourseCsrfToken(),
- 'X-Requested-With': 'XMLHttpRequest',
- }, extraHeaders || {}),
- };
- if (requestScope.signal.aborted) throw new DOMException('Aborted', 'AbortError');
- if (params) {
- if (requestOptions.jsonBody === true) {
- opt.headers['Content-Type'] = 'application/json; charset=UTF-8';
- opt.body = JSON.stringify(params);
- } else {
- opt.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
- const body = new URLSearchParams();
- Object.entries(params).forEach(([key, value]) => {
- if (Array.isArray(value)) value.forEach((item) => body.append(`${key}[]`, item));
- else if (value != null) body.append(key, value);
- });
- opt.body = body.toString();
- }
- }
- requestPermit = await waitForReaderRequestPermit(priority, {
- type: requestType,
- signal: requestScope.signal,
- controller: requestScope.controller,
- trackedScheduler: initialScheduler,
- surviveReaderSwitch: requestOptions.surviveReaderSwitch === true,
- });
- if (!requestPermit.scheduler) {
- sharedPermit = await acquireSharedReaderRequestPermit(
- priority, requestScope.signal, requestType, ACTIVE_READER_REQUEST_SCHEDULER);
- }
- requestScope.startTimeout();
- const res = await trackedReaderFetch(url, {
- ...opt,
- signal: requestScope.signal,
- [REQUEST_FLOW_FETCH_META]: {
- source: 'reader',
- priority,
- recoveryProbe: !!(
- requestPermit.recoveryProbe ||
- (sharedPermit?.recoveryProbe)
- ),
- queuedAt: requestPermit.queuedAt,
- permittedAt: requestPermit.permittedAt ||
- sharedPermit?.startedAt || requestFlowNow(),
- waitReason: requestPermit.waitReason ||
- sharedPermit?.waitReason || '',
- callSite: requestPermit.callSite,
- attempt: 1,
- type: requestType,
- },
- });
- if (res.status === 429) {
- const rateScheduler = ACTIVE_READER_REQUEST_SCHEDULER || requestPermit.scheduler;
- if (rateScheduler) {
- rateScheduler.noteRateLimit(res);
- } else noteSharedRequestRateLimit(res);
- }
- let data = {};
- try {
- data = await res.json();
- } catch (error) {
- if (requestScope.signal.aborted) throw error;
- }
- requestScope.stopTimeout();
- if (!res.ok) {
- const message = Array.isArray(data.errors) && data.errors.length
- ? data.errors.join(';')
- : (data.error || data.message || `HTTP ${res.status}`);
- const statusError = requestStatusError(message, res.status);
- statusError.cloudflareMitigated = res.status === 403 &&
- requestFlowHeader(res.headers, ['cf-mitigated']).toLowerCase() === 'challenge';
- throw statusError;
- }
- const mutationTags = [...new Set([
- ...responseCacheMutationTags(url, params, data),
- ...(Array.isArray(requestOptions.invalidateTags) ? requestOptions.invalidateTags : []),
- ])];
- if (mutationTags.length) await clearStoredResponseCache({ tags: mutationTags });
- return data;
- } catch (error) {
- if (requestScope.timedOut()) throw requestTimeoutError();
- throw error;
- } finally {
- if (isFunction(requestPermit?.release)) requestPermit.release();
- if (sharedPermit && SHARED_REQUEST_COORDINATOR) {
- SHARED_REQUEST_COORDINATOR.finishPermit(sharedPermit.permitId);
- }
- untrackInitialController();
- requestScope.cleanup();
- }
- }
-
- function runReaderActionOnce(ctx, key, operation) {
- if (!ctx || !key) return Promise.resolve().then(operation);
- if (!ctx.readerActionRequests) ctx.readerActionRequests = new Map();
- const normalizedKey = String(key);
- const existing = ctx.readerActionRequests.get(normalizedKey);
- if (existing) return existing;
- const request = Promise.resolve().then(operation).finally(() => {
- if (ctx.readerActionRequests && ctx.readerActionRequests.get(normalizedKey) === request)
- ctx.readerActionRequests.delete(normalizedKey);
- });
- ctx.readerActionRequests.set(normalizedKey, request);
- return request;
- }
-
- function setReaderControlBusy(control, busy) {
- if (!control) return;
- if (busy) {
- if (control.dataset.ldpRequestBusy !== '1') {
- control.dataset.ldpRequestWasDisabled = control.disabled ? '1' : '0';
- control.dataset.ldpRequestBusy = '1';
- }
- control.disabled = true;
- return;
- }
- if (control.dataset.ldpRequestBusy !== '1') return;
- control.disabled = control.dataset.ldpRequestWasDisabled === '1';
- delete control.dataset.ldpRequestBusy;
- delete control.dataset.ldpRequestWasDisabled;
- }
-
- function pollName(poll) {
- return String(poll?.name || 'poll');
- }
-
- function pollSavedVotes(postData, poll) {
- const votes = postData?.polls_votes && postData.polls_votes[pollName(poll)];
- return Array.isArray(votes) ? votes.map(String) : [];
- }
-
- function pollIsClosed(poll, ctx) {
- if (!poll) return true;
- if (poll.status === 'closed' || (ctx?.topicData && ctx.topicData.archived)) return true;
- if (!poll.close) return false;
- const closeAt = Date.parse(poll.close);
- return Number.isFinite(closeAt) && closeAt <= Date.now();
- }
-
- function currentUserCanVoteInPoll(poll) {
- if (!ME_USERNAME) return false;
- const required = String((poll?.groups) || '').split(',').map((name) => name.trim().toLowerCase()).filter(Boolean);
- if (!required.length) return true;
- const groups = Array.isArray(ME_CURRENT_USER?.groups)
- ? ME_CURRENT_USER.groups.map((group) => String((group && (group.name || group.full_name)) || '').toLowerCase())
- : [];
- return required.some((name) => groups.includes(name));
- }
-
- function pollCanShowResults(postData, poll, ctx) {
- if (!poll || !Array.isArray(poll.options) || !poll.options.some((option) => option && option.votes != null)) return false;
- const closed = pollIsClosed(poll, ctx);
- const resultRule = String(poll.results || 'always');
- if (resultRule === 'on_close' && !closed) return false;
- if (resultRule === 'staff_only' && !(ME_CURRENT_USER?.staff)) return false;
- if (resultRule === 'on_vote' && !pollSavedVotes(postData, poll).length) {
- const currentUserId = +(ME_CURRENT_USER?.id || 0);
- if (!currentUserId || +postData.user_id !== currentUserId) return false;
- }
- return true;
- }
-
- function pollChoiceLimits(poll) {
- const optionCount = Array.isArray(poll?.options) ? poll.options.length : 0;
- const min = Math.max(1, Number.parseInt(poll?.min, 10) || 1);
- const parsedMax = Number.parseInt(poll?.max, 10);
- const max = Math.max(min, Math.min(optionCount, Number.isFinite(parsedMax) ? parsedMax : optionCount));
- return { min, max };
- }
-
- function pollOptionHtml(option) {
- return String(option?.html || '');
- }
-
- function renderPollChoices(postData, poll, ctx) {
- const name = pollName(poll);
- const saved = new Set(pollSavedVotes(postData, poll));
- const multiple = poll.type === 'multiple';
- const closed = pollIsClosed(poll, ctx);
- const canVote = !closed && currentUserCanVoteInPoll(poll) && poll.type !== 'ranked_choice';
- const inputType = multiple ? 'checkbox' : 'radio';
- const options = (poll.options || []).map((option, index) => {
- const id = String(option.id || '');
- return `
-
- ${pollOptionHtml(option) || `选项 ${index + 1}`}
- `;
- }).join('');
- return `
${options}
`;
- }
-
- function renderPollResults(poll) {
- const voters = Math.max(0, Number(poll.voters) || 0);
- const options = (poll.options || []).map((option) => {
- const votes = Math.max(0, Number(option.votes) || 0);
- const percent = voters > 0 ? Math.round(votes / voters * 100) : 0;
- return `
-
${pollOptionHtml(option)}
-
${votes} 票 · ${percent}%
-
-
`;
- }).join('');
- return `
${options}
`;
- }
-
- function renderReaderPoll(container, postData, poll, ctx, showResultsOverride) {
- const name = pollName(poll);
- const existingTitle = container.querySelector('.poll-title, .ldp-poll-title');
- const title = existingTitle
- ? `
${existingTitle.innerHTML}
`
- : poll?.title ? `
${esc(poll.title)}
` : '';
- const saved = pollSavedVotes(postData, poll);
- const closed = pollIsClosed(poll, ctx);
- const canShowResults = pollCanShowResults(postData, poll, ctx);
- const canVote = !closed && currentUserCanVoteInPoll(poll) && poll.type !== 'ranked_choice';
- const previousMode = container.dataset.ldpPollShowResults;
- let showResults = showResultsOverride == null
- ? (previousMode === '1' || (previousMode === undefined && (saved.length > 0 || closed)))
- : !!showResultsOverride;
- if (!canShowResults) showResults = false;
- container.classList.add('ldp-reader-poll');
- container.dataset.ldpPollName = name;
- container.dataset.ldpPollShowResults = showResults ? '1' : '0';
- container.removeAttribute('aria-busy');
-
- let note = '';
- if (poll.type === 'ranked_choice') note = '排序投票请在原页面参与。';
- else if (closed) note = '投票已结束。';
- else if (!ME_USERNAME) note = '登录后可参与投票。';
- else if (!currentUserCanVoteInPoll(poll)) note = '你不在该投票允许参与的用户组中。';
-
- const { min, max } = pollChoiceLimits(poll);
- const multipleHint = poll.type === 'multiple' ? ` · 可选 ${min}${min === max ? '' : `–${max}`} 项` : '';
- const footerButtons = [];
- if (!showResults && poll.type === 'multiple' && canVote) {
- const valid = saved.length >= min && saved.length <= max;
- footerButtons.push(`
`);
- }
- if (!showResults && saved.length && canVote)
- footerButtons.push('
');
- if (canShowResults && (!showResults || canVote))
- footerButtons.push(`
`);
- const votersLabel = `${Math.max(0, Number(poll.voters) || 0)} 位投票人${multipleHint}`;
- container.innerHTML = `${title}
- ${showResults ? renderPollResults(poll) : renderPollChoices(postData, poll, ctx)}
- ${note ? `
${esc(note)}
` : ''}
- `;
- }
-
- function renderPostPolls(postNode, ctx, mode) {
- if (!postNode) return;
- const postData = postNode._ldpPostData || {};
- const polls = Array.isArray(postData.polls) ? postData.polls : [];
- if (!polls.length) return;
- const content = postNode.querySelector(':scope > .ldp-content');
- if (!content) return;
- const containers = [...content.querySelectorAll('.poll')];
- const used = new Set();
- polls.forEach((poll) => {
- const name = pollName(poll);
- let container = containers.find((item) => !used.has(item) &&
- String(item.dataset.ldpPollName || item.dataset.pollName || 'poll') === name);
- if (!container) container = containers.find((item) => !used.has(item)) || null;
- if (!container) {
- container = makeElement('div');
- container.className = 'poll';
- content.appendChild(container);
- }
- used.add(container);
- const showResults = mode && mode.name === name ? mode.showResults : null;
- renderReaderPoll(container, postData, poll, ctx, showResults);
- });
- }
-
- function readerPostCopies(postId, ctx, includeDetachedNested = false) {
- const copies = new Set();
- const key = String(postId);
- if (ctx?.modal)
- ctx.modal.querySelectorAll('.ldp-post').forEach((node) => {
- if (String(node.dataset.postId || '') === key) copies.add(node);
- });
- if (ctx?.streamNodeMap)
- ctx.streamNodeMap.forEach((node) => {
- if (!node) return;
- if (String(node.dataset.postId || '') === key) copies.add(node);
- if (!includeDetachedNested || !node.querySelectorAll) return;
- node.querySelectorAll('.ldp-post').forEach((nested) => {
- if (String(nested.dataset.postId || '') === key) copies.add(nested);
- });
- });
- if (includeDetachedNested && ctx?.subReplyState)
- ctx.subReplyState.forEach((state) => state?.nodeCache?.forEach((node) => {
- if (String(node?.dataset.postId || '') === key) copies.add(node);
- }));
- const railPost = ctx?.topicActionRail?._ldpRelatedPost; if (railPost && String(railPost.dataset.postId || '') === key) copies.add(railPost);
- return [...copies];
- }
-
- function readerPostOwnControls(postNode, selector) {
- return [...postNode.querySelectorAll(selector)].filter((control) =>
- !postNode.classList.contains('ldp-post') || control.closest('.ldp-post') === postNode
- );
- }
-
- function persistReaderPostState(ctx, postData) {
- const postId = +(postData?.id || 0);
- if (!ctx || !ctx.topicId || !postId) return;
- const entry = TOPIC_DATA_CACHE.get(String(ctx.topicId));
- const topic = entry?.topic || ctx.topicData;
- const cachedPost = postsFromPayload(topic).find((post) => +post.id === postId);
- if (cachedPost) {
- Object.assign(cachedPost, postData);
- setCachedTopicData(ctx.topicId, topic);
- return;
- }
- if (isFunction(ctx.loader?.persistFetchedPosts))
- ctx.loader.persistFetchedPosts([postData]);
- }
-
- function syncPostVotingComments(postId, ctx, comments) {
- const normalized = Array.isArray(comments) ? comments : [];
- let latestPostData = null;
- readerPostCopies(postId, ctx, true).forEach((copy) => {
- const postData = copy._ldpPostData || {};
- postData._ldpPvComments = normalized;
- postData.comments_count = Math.max(+postData.comments_count || 0, normalized.length);
- copy._ldpPostData = postData;
- latestPostData = postData;
- renderPostVoting(copy, ctx);
- });
- if (latestPostData) persistReaderPostState(ctx, latestPostData);
- }
-
- function setReaderPostControlsBusy(postId, ctx, selector, busy, sourcePost = null) {
- new Set([sourcePost, ...readerPostCopies(postId, ctx)].filter(Boolean)).forEach((copy) => {
- readerPostOwnControls(copy, selector).forEach((control) => setReaderControlBusy(control, busy));
- });
- }
-
- function updatePostLikeAction(postData, acted, count) {
- if (!Array.isArray(postData.actions_summary)) postData.actions_summary = [];
- let action = postData.actions_summary.find((item) => +item.id === 2);
- if (!action) {
- action = { id: 2, can_act: true };
- postData.actions_summary.push(action);
- }
- action.acted = !!acted;
- action.count = Math.max(0, +count || 0);
- return action;
- }
-
- function readerPostLikeAction(post, ctx) {
- const like = (post?.actions_summary || []).find((action) => +action.id === 2) || {};
- if (!hasDiscourseCapability('reactions', { post, topic: ctx?.topicData }) ||
- !Array.isArray(post?.reactions)) return like;
- const heart = post.reactions.find((reaction) => String(reaction?.id || '') === 'heart');
- const heartActed = String(post.current_user_reaction?.id || '') === 'heart';
- return {
- ...like,
- can_act: !!ME_USERNAME,
- count: Math.max(0, +heart?.count || 0),
- acted: heartActed,
- reaction: 'heart',
- label: heartActed ? '取消爱心回应' : '用爱心回应',
- };
- }
-
- function readerPostRailReactionAction(post, ctx) {
- const action = readerPostLikeAction(post, ctx);
- if (!action.reaction) return action;
- const current = String(post?.current_user_reaction?.id || '');
- const totalCount = post.reactions.reduce((total, reaction) =>
- total + Math.max(0, +reaction?.count || 0), 0
- );
- return {
- ...action,
- count: totalCount,
- acted: !!current,
- label: current === 'heart'
- ? '取消爱心回应'
- : current ? '改用爱心回应' : '用爱心回应',
- };
- }
-
- function syncReaderLikeButton(button, action) {
- button.classList.toggle('liked', !!action.acted);
- button.dataset.acted = action.acted ? '1' : '0';
- if (action.reaction) button.dataset.reaction = action.reaction;
- else delete button.dataset.reaction;
- setLabel(button, action.label || (action.acted ? '取消点赞' : '点赞'));
- const countEl = button.querySelector('.ldp-like-count');
- if (countEl) countEl.textContent = String(action.count);
- }
-
- function syncReaderPostLike(postId, ctx, acted, count) {
- let latestPostData = null;
- readerPostCopies(postId, ctx, true).forEach((copy) => {
- const postData = copy._ldpPostData || {};
- const action = updatePostLikeAction(postData, acted, count);
- copy._ldpPostData = postData;
- latestPostData = postData;
- readerPostOwnControls(copy, '.ldp-like').forEach((button) => syncReaderLikeButton(button, action));
- });
- const railLike = ctx?.topicActionRail?.querySelector('.ldp-like'); if (railLike && latestPostData) syncReaderLikeButton(railLike, readerPostRailReactionAction(latestPostData, ctx));
- if (latestPostData) persistReaderPostState(ctx, latestPostData);
- }
-
- async function toggleReaderPostLike(postId, ctx, button, options) {
- const { setBusy, beforeSync, postData, persist, onError } = options;
- const countNode = button?.querySelector('.ldp-like-count');
- const acted = !!button && button.dataset.acted === '1';
- const count = Math.max(0, +(countNode?.textContent || 0));
- setBusy(true);
- try {
- const sourcePost = button?.closest('.ldp-post');
- const postModel = detachedNativePostModel(sourcePost, ctx);
- const likeAction = discourseModelValue(postModel, 'likeAction');
- if (!postModel || Number(discourseModelValue(postModel, 'id')) !== Number(postId) ||
- !likeAction || !isFunction(likeAction.togglePromise))
- throw new Error('Discourse 点赞接口尚未就绪');
- const result = await likeAction.togglePromise(postModel);
- if (!result || typeof result.acted !== 'boolean')
- throw new Error('Discourse 点赞结果缺失');
- const currentLikeAction = discourseModelValue(postModel, 'likeAction') || likeAction;
- const nextActed = result.acted;
- const nativeCount = Number(discourseModelValue(currentLikeAction, 'count'));
- const nextCount = Math.max(
- 0,
- Number.isFinite(nativeCount) ? nativeCount : count + (acted ? -1 : 1),
- );
- await clearStoredResponseCache({ tags: [`post:${postId}`, 'reactions-given'] });
- if (beforeSync) beforeSync(nextActed, nextCount);
- syncReaderPostLike(postId, ctx, nextActed, nextCount);
- if (persist) persistReaderPostState(ctx, postData);
- if (isFunction(ctx.refreshBookmarksPanel))
- ctx.refreshBookmarksPanel({ post: postData, topic: ctx.topicData, likeActed: nextActed });
- } catch (error) {
- onError(error);
- } finally {
- setBusy(false);
- }
- }
-
- function syncReaderPollResponse(sourcePost, ctx, name, serverPoll, votes, showResults) {
- let latestPostData = null;
- readerPostCopies(sourcePost.dataset.postId, ctx).forEach((node) => {
- const postData = node._ldpPostData || {};
- const polls = Array.isArray(postData.polls) ? postData.polls : [];
- const current = polls.find((poll) => pollName(poll) === name);
- if (current && serverPoll) Object.assign(current, serverPoll);
- if (!postData.polls_votes) postData.polls_votes = {};
- if (votes == null) delete postData.polls_votes[name];
- else postData.polls_votes[name] = votes.map(String);
- node._ldpPostData = postData;
- latestPostData = postData;
- rememberPostData(ctx, postData);
- renderPostPolls(node, ctx, { name, showResults });
- });
- if (latestPostData) persistReaderPostState(ctx, latestPostData);
- }
-
- function setReaderPollBusy(post, ctx, name, busy) {
- const copies = readerPostCopies(post && post.dataset.postId, ctx);
- (copies.length ? copies : [post]).filter(Boolean).forEach((copy) => {
- readerPostOwnControls(copy, '.ldp-reader-poll').forEach((container) => {
- if (container.dataset.ldpPollName !== name) return;
- if (busy) container.setAttribute('aria-busy', 'true');
- else container.removeAttribute('aria-busy');
- container.querySelectorAll('input,button').forEach((control) => setReaderControlBusy(control, busy));
- });
- });
- }
-
- async function updateReaderPollVote(post, ctx, name, options = null) {
- const removing = options == null;
- return runReaderActionOnce(ctx, `post:${post.dataset.postId}:poll:${name}`, async () => {
- setReaderPollBusy(post, ctx, name, true);
- try {
- const params = {
- post_id: post.dataset.postId,
- poll_name: name,
- ...(removing ? {} : { options }),
- };
- const data = await apiSend(`${BASE}/polls/vote`, removing ? 'DELETE' : 'PUT', params);
- syncReaderPollResponse(post, ctx, name, data.poll, removing ? null : options, !removing);
- } catch (error) {
- readerPostCopies(post.dataset.postId, ctx).forEach((copy) => renderPostPolls(copy, ctx));
- alert(`${removing ? '撤销投票' : '投票'}失败:${error.message}`);
- } finally {
- setReaderPollBusy(post, ctx, name, false);
- }
- });
- }
-
- let reactionEmojiUrls = null;
- let reactionEmojiImageSources = null;
- let reactionEmojiHydration = null;
- let reactionEmojiHydrationRefresh = false;
- let reactionEmojiRegistryNetworkRequested = false;
-
- function loadReactionEmojiRegistry(options = {}) {
- const refresh = options.refresh === true;
- if (!refresh && reactionEmojiUrls) return Promise.resolve(reactionEmojiUrls);
- if (reactionEmojiHydration) {
- if (!refresh || reactionEmojiHydrationRefresh) return reactionEmojiHydration;
- return reactionEmojiHydration.then(() => loadReactionEmojiRegistry({ refresh: true }));
- }
- const emojiStore = lookupDiscourse('service:emoji-store');
- const textModule = lookupDiscourseModule('discourse/lib/text');
- const previousUrls = reactionEmojiUrls;
- const previousSources = reactionEmojiImageSources;
- const requestLatestEmojis = () => {
- reactionEmojiRegistryNetworkRequested = true;
- return fetchJSON(`${BASE}/emojis.json`, {
- key: 'reaction-emoji-registry',
- priority: POST_REQUEST_PRIORITY.auxiliary,
- cacheMode: 'refresh',
- allowStaleOnError: false,
- fetchOptions: discourseJSONFetchOptions(),
- }).then((data) => {
- if (emojiStore) emojiStore.list = data;
- return data;
- });
- };
- const loadEmojis = !refresh && emojiStore?.list
- ? Promise.resolve(emojiStore.list)
- : isFunction(window.fetch)
- ? requestLatestEmojis()
- : emojiStore?.list
- ? Promise.resolve(emojiStore.list)
- : Promise.reject(new Error('Discourse 表情服务尚未就绪'));
- reactionEmojiHydrationRefresh = refresh;
- reactionEmojiHydration = loadEmojis.then((data) => {
- const registry = new Map();
- Object.values(data || {}).forEach((items) => {
- if (!Array.isArray(items)) return;
- items.forEach((item) => {
- const name = String(item?.name || '');
- const url = String(item?.url ||
- (name && textModule && isFunction(textModule.emojiUrlFor)
- ? textModule.emojiUrlFor(name)
- : '') || '');
- if (name && url) registry.set(name, url);
- });
- });
- reactionEmojiUrls = registry;
- reactionEmojiImageSources = new Set([...registry.values()]
- .map(comparableLightboxImageSource)
- .filter(Boolean));
- return registry;
- }).catch(() => {
- reactionEmojiUrls = previousUrls || new Map();
- reactionEmojiImageSources = previousSources || new Set();
- return reactionEmojiUrls;
- }).finally(() => {
- reactionEmojiHydration = null;
- reactionEmojiHydrationRefresh = false;
- });
- return reactionEmojiHydration;
- }
-
- function configuredDiscourseReactionIds(topicData, reactions) {
- const siteSettings = lookupDiscourse('service:site-settings');
- const configuredValue = siteSettings?.discourse_reactions_enabled_reactions;
- const configured = Array.isArray(configuredValue)
- ? configuredValue
- : String(configuredValue || '').split('|');
- const mainReaction = String(siteSettings?.discourse_reactions_reaction_for_like || '');
- const topicReactions = Array.isArray(topicData?.valid_reactions)
- ? topicData.valid_reactions
- : [];
- const source = configured.some(Boolean)
- ? configured
- : topicReactions.length
- ? topicReactions
- : (reactions || []).map((item) => item?.id);
- const ids = source.map((item) => String(
- typeof item === 'string' ? item : (item && (item.id || item.name)) || ''
- )).filter(Boolean);
- if (mainReaction && !ids.includes(mainReaction)) ids.unshift(mainReaction);
- return [...new Set(ids)];
- }
-
- function reactionLabel(id) {
- const key = String(id || '').replace(/^:|:$/g, '');
- if (!key) return '';
- const textModule = lookupDiscourseModule('discourse/lib/text');
- const nativeSource = isFunction(textModule?.emojiUrlFor)
- ? textModule.emojiUrlFor(key)
- : '';
- const source = reactionEmojiUrls?.get(key) || nativeSource;
- if (source) {
- const url = absoluteImageUrl(source);
- if (url)
- return `
})
`;
- }
- return '';
- }
-
- function renderPostReactions(postNode, ctx, options = {}) {
- if (!postNode) return;
- const post = postNode._ldpPostData || {};
- let root = postNode.querySelector(':scope > .ldp-reactions');
- const actions = root?.querySelector(':scope > .ldp-actions') || postNode.querySelector(':scope > .ldp-actions');
- const likeButton = root?.querySelector('.ldp-like') || actions?.querySelector('.ldp-like');
- let likeAnchor = likeButton?.closest('.ldp-reaction-like-picker');
- const collectLike = options.collectLike !== false;
- const railPicker = options.railPicker === true;
- const unwrapLikePicker = () => {
- if (likeAnchor) {
- likeAnchor.replaceWith(likeButton);
- likeAnchor = null;
- }
- if (likeButton && actions && likeButton.parentElement !== actions) actions.prepend(likeButton);
- if (likeButton) {
- delete likeButton.dataset.reactionPicker;
- likeButton.removeAttribute('aria-expanded');
- }
- };
- if (!hasDiscourseCapability('reactions', { post, topic: ctx?.topicData })) {
- unwrapLikePicker();
- root?.querySelector(':scope > .ldp-reaction-summary')?.remove();
- postNode.classList.remove('ldp-has-reactions');
- return;
- }
- const reactions = Array.isArray(post.reactions) ? post.reactions : [];
- const valid = configuredDiscourseReactionIds(ctx?.topicData, reactions);
- const renderedReactions = reactions.map((item) => ({ item, label: reactionLabel(item.id) }))
- .filter((entry) => entry.label);
- const renderedValid = valid.map((id) => ({ id, label: reactionLabel(id) }))
- .filter((entry) => entry.label);
- const canAdd = !!ME_USERNAME && renderedValid.length > 0;
- const usesLikeDisplay = !!likeButton;
- const usesLikeTrigger = !!(canAdd && likeButton);
- const usesFallbackTrigger = !!(canAdd && !likeButton);
- if (!root) {
- root = makeElement('div');
- root.className = 'ldp-reactions';
- const content = postNode.querySelector(':scope > .ldp-content,:scope > .ldp-pv-layout .ldp-content');
- content?.after(root);
- }
- if (!renderedReactions.length && !canAdd) {
- unwrapLikePicker();
- root.querySelector(':scope > .ldp-reaction-summary')?.remove();
- if (actions && actions.parentElement !== root) root.appendChild(actions);
- postNode.classList.remove('ldp-has-reactions');
- return;
- }
- postNode.classList.add('ldp-has-reactions');
- const current = String(post.current_user_reaction?.id || '');
- const reactionCounts = new Map(reactions.map((item) => [String(item?.id || ''), Math.max(0, +item?.count || 0)]));
- const pickerEntries = renderedValid.map((entry, order) => ({ ...entry, order }));
- if (railPicker) pickerEntries.sort((left, right) =>
- (reactionCounts.get(String(right.id)) || 0) - (reactionCounts.get(String(left.id)) || 0) ||
- left.order - right.order
- );
- const pickerOptionsHtml = pickerEntries.map(({ id, label }) => {
- const count = reactionCounts.get(String(id)) || 0;
- return `
`;
- }).join('');
- const pickerHtml = `
${pickerOptionsHtml}
`;
- if (railPicker && likeButton) {
- const badgeLabel = current && current !== 'heart' ? reactionLabel(current) : '';
- let badge = likeButton.querySelector(':scope > .ldp-topic-action-rail-reaction-badge');
- if (badgeLabel) {
- if (!badge) {
- badge = makeElement('span');
- badge.className = 'ldp-topic-action-rail-reaction-badge';
- badge.setAttribute('aria-hidden', 'true');
- likeButton.appendChild(badge);
- }
- if (badge.innerHTML !== badgeLabel) badge.innerHTML = badgeLabel;
- } else {
- badge?.remove();
- }
- }
- let currentRendered = false;
- const summaryHtml = renderedReactions.filter(({ item }) =>
- !usesLikeDisplay || String(item.id || '') !== 'heart'
- ).map(({ item, label }) => {
- const id = String(item.id || '');
- const pickerTrigger = id === current && usesFallbackTrigger;
- const button = `
`;
- if (!pickerTrigger) return button;
- currentRendered = true;
- return `
${button}${pickerHtml}`;
- }).join('') + (usesFallbackTrigger && !currentRendered
- ? `
${pickerHtml}`
- : '');
- let summary = root.querySelector(':scope > .ldp-reaction-summary');
- if (summaryHtml || (collectLike && usesLikeTrigger)) {
- if (!summary) {
- summary = makeElement('div');
- summary.className = 'ldp-reaction-summary';
- root.insertBefore(summary, actions || root.firstChild);
- }
- if (summary.innerHTML !== summaryHtml) summary.innerHTML = summaryHtml;
- } else {
- summary?.remove();
- summary = null;
- }
- if (actions && actions.parentElement !== root) root.appendChild(actions);
- if (usesLikeTrigger) {
- let picker = likeAnchor?.querySelector(':scope > .ldp-reaction-picker');
- const pickerWasOpen = !!(picker && !picker.hidden);
- if (!likeAnchor) {
- likeAnchor = makeElement('span');
- likeAnchor.className = 'ldp-reaction-picker-anchor ldp-reaction-like-picker';
- likeButton.before(likeAnchor);
- likeAnchor.append(likeButton);
- }
- if (!picker) {
- likeAnchor.insertAdjacentHTML('beforeend', pickerHtml);
- picker = likeAnchor.querySelector(':scope > .ldp-reaction-picker');
- } else if (picker.innerHTML !== pickerOptionsHtml) {
- picker.innerHTML = pickerOptionsHtml;
- }
- if (picker) picker.hidden = !pickerWasOpen;
- likeButton.dataset.reactionPicker = '';
- setExpanded(likeButton, pickerWasOpen);
- if (collectLike && summary && likeAnchor.parentElement !== summary)
- summary.insertBefore(likeAnchor, summary.firstChild);
- } else unwrapLikePicker();
- }
-
- function reactionStateSnapshot(post) {
- return {
- reactions: Array.isArray(post?.reactions)
- ? post.reactions.map((item) => ({ ...item }))
- : [],
- current_user_reaction: post?.current_user_reaction
- ? { ...post.current_user_reaction }
- : null,
- reaction_users_count: post?.reaction_users_count,
- };
- }
-
- function toggledPostReactionState(post, reaction) {
- const state = reactionStateSnapshot(post);
- const current = String(state.current_user_reaction?.id || '');
- const target = String(reaction || '');
- const adjustCount = (id, delta) => {
- if (!id) return;
- const item = state.reactions.find((entry) => String(entry.id || '') === id);
- if (item) item.count = Math.max(0, Number(item.count) + delta || 0);
- else if (delta > 0) state.reactions.push({ id, type: 'emoji', count: delta });
- };
- if (current) adjustCount(current, -1);
- if (current !== target) adjustCount(target, 1);
- state.reactions = state.reactions.filter((item) => Number(item.count) > 0);
- state.current_user_reaction = current === target
- ? null
- : { id: target, type: 'emoji', can_undo: true };
- if (Number.isFinite(Number(state.reaction_users_count)))
- state.reaction_users_count = Math.max(0, Number(state.reaction_users_count) + (current ? (current === target ? -1 : 0) : 1));
- return state;
- }
-
- async function toggleReaderPostReaction(postNode, ctx, reaction, onSync, onError) {
- const postId = +(postNode && (postNode.dataset.postId || postNode._ldpPostData?.id) || 0);
- if (!hasDiscourseCapability('reactions', {
- post: postNode?._ldpPostData,
- topic: ctx?.topicData,
- })) return false;
- return runReaderActionOnce(ctx, `post:${postId}:reaction`, async () => {
- const previousState = reactionStateSnapshot(postNode._ldpPostData || {});
- const sync = (fresh) => {
- syncSpecialPostData(postNode, ctx, fresh);
- onSync?.(fresh);
- };
- setReaderPostControlsBusy(postId, ctx, '.ldp-reaction-chip,.ldp-reaction-add,.ldp-like[data-reaction]', true, postNode);
- sync(toggledPostReactionState(postNode._ldpPostData || {}, reaction));
- try {
- const reactionModule = lookupDiscourseModule(
- 'discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction'
- );
- const CustomReaction = reactionModule?.default;
- const appEvents = lookupDiscourse('service:app-events');
- const postModel = detachedNativePostModel(postNode, ctx);
- if (!CustomReaction || !isFunction(CustomReaction.toggle) ||
- !appEvents || !isFunction(appEvents.on) ||
- !isFunction(appEvents.off) || !postModel)
- throw new Error('Discourse 回应接口尚未就绪');
- let serverPost = null;
- const onReactionToggled = (payload) => {
- const nextPost = payload?.post;
- if (Number(nextPost?.id) === postId) serverPost = nextPost;
- };
- appEvents.on('discourse-reactions:reaction-toggled', postNode, onReactionToggled);
- try {
- await CustomReaction.toggle(postModel, reaction, appEvents);
- } finally {
- appEvents.off('discourse-reactions:reaction-toggled', postNode, onReactionToggled);
- }
- if (!serverPost) throw new Error('Discourse 回应结果缺失');
- await clearStoredResponseCache({ tags: [`post:${postId}`, 'reactions-given'] });
- sync(serverPost);
- ctx.refreshBookmarksPanel?.({ post: postNode._ldpPostData, topic: ctx.topicData });
- } catch (error) {
- sync(previousState);
- onError(error);
- } finally {
- setReaderPostControlsBusy(postId, ctx, '.ldp-reaction-chip,.ldp-reaction-add,.ldp-like[data-reaction]', false, postNode);
- }
- });
- }
-
- function postVotingCommentHtml(comment) {
- const user = comment.user || {};
- const username = comment.username || user.username || '';
- const avatar = userCardAvatarHtml(
- persistentAvatarHtml(
- user.avatar_template,
- 28,
- 'ldp-pv-comment-avatar',
- comment.name || user.name || username || '?'
- ),
- username
- );
- return ``;
- }
-
- function postVotingComments(postData) {
- if (Array.isArray(postData?._ldpPvComments)) return postData._ldpPvComments;
- return Array.isArray(postData?.comments) ? postData.comments : [];
- }
-
- function readerPostVotingComments(postId, ctx) {
- return readerPostCopies(postId, ctx)
- .map((copy) => postVotingComments(copy._ldpPostData))
- .sort((left, right) => right.length - left.length)[0] || [];
- }
-
- function postVotingCapabilities(post, ctx) {
- if (!hasDiscourseCapability('postVoting', { post, topic: ctx?.topicData }))
- return { voteControls: false, comments: false };
- const topicEnabled = !!(ctx?.isPostVoting);
- const voteControls = topicEnabled || ['post_voting_vote_count', 'post_voting_user_voted_direction', 'post_voting_has_votes']
- .some((key) => Object.hasOwn(post || {}, key));
- const comments = topicEnabled || ['comments_count', 'comments']
- .some((key) => Object.hasOwn(post || {}, key));
- return { voteControls, comments };
- }
-
- function renderPostVoting(postNode, ctx) {
- if (!postNode || !ctx) return;
- const post = postNode._ldpPostData || {};
- const capabilities = postVotingCapabilities(post, ctx);
- if (!capabilities.voteControls && !capabilities.comments) {
- postNode.classList.remove('ldp-post-voting-answer');
- postNode.querySelector(':scope > .ldp-pv-comments')?.remove();
- return;
- }
- const isQuestion = +post.post_number === 1;
- postNode.classList.toggle('ldp-post-voting-answer', !isQuestion);
- postNode.classList.remove('ldp-reply', 'ldp-reply-collapsible', 'ldp-nested-collapsed');
- postNode.querySelector(':scope > .ldp-nested-toggle')?.remove();
- postNode.querySelector(':scope > .ldp-children')?.remove();
- postNode.querySelector(':scope > .ldp-collapse-replies')?.remove();
- postNode.querySelector(':scope > .ldp-sub-loading')?.remove();
- if (isQuestion) {
- postNode.querySelector(':scope > .ldp-pv-comments')?.remove();
- return;
- }
- const directContent = postNode.querySelector(':scope > .ldp-content');
- const wrappedContent = postNode.querySelector(':scope > .ldp-pv-layout .ldp-content');
- const content = directContent || wrappedContent;
- if (capabilities.voteControls && content && !content.parentElement.classList.contains('ldp-pv-main')) {
- const main = makeElement('div');
- main.className = 'ldp-pv-main';
- content.before(main);
- main.appendChild(content);
- const layout = makeElement('div');
- layout.className = 'ldp-pv-layout';
- const votes = makeElement('div');
- votes.className = 'ldp-pv-votes';
- layout.append(votes, main);
- const head = postNode.querySelector(':scope > .ldp-post-head');
- head?.after(layout);
- }
- if (!capabilities.voteControls && wrappedContent) {
- const layout = wrappedContent.closest('.ldp-pv-layout');
- layout.before(wrappedContent);
- layout.remove();
- }
- const direction = String(post.post_voting_user_voted_direction || '');
- const votes = postNode.querySelector(':scope > .ldp-pv-layout .ldp-pv-votes');
- if (votes) votes.innerHTML = `
${+post.post_voting_vote_count || 0}`;
- let comments = postNode.querySelector(':scope > .ldp-pv-comments');
- if (!capabilities.comments) {
- comments?.remove();
- return;
- }
- const count = Math.max(0, +post.comments_count || (Array.isArray(post.comments) ? post.comments.length : 0));
- if (!comments) {
- comments = makeElement('div');
- comments.className = 'ldp-pv-comments';
- postNode.appendChild(comments);
- }
- const expanded = comments.dataset.expanded === '1';
- const loaded = postVotingComments(post);
- comments.innerHTML = `
${loaded.map(postVotingCommentHtml).join('')}${ME_USERNAME ? '' : ''}
`;
- comments.dataset.expanded = expanded ? '1' : '0';
- hydratePersistentAvatarImages(comments);
- }
-
- function eventDateLabel(event) {
- const options = { year: 'numeric', month: 'short', day: 'numeric', hour: event.all_day ? undefined : '2-digit', minute: event.all_day ? undefined : '2-digit' };
- const start = event.starts_at ? new Date(event.starts_at) : null;
- const end = event.ends_at ? new Date(event.ends_at) : null;
- const format = (date) => date && Number.isFinite(date.getTime()) ? date.toLocaleString('zh-CN', options) : '';
- return [format(start), format(end)].filter(Boolean).join(' — ');
- }
-
- function renderPostEvent(postNode) {
- if (!postNode) return;
- const post = postNode._ldpPostData || {};
- const event = post.event;
- let card = postNode.querySelector(':scope > .ldp-event-card');
- if (!event) { if (card) card.remove(); return; }
- if (!card) { card = makeElement('section'); card.className = 'ldp-event-card'; postNode.querySelector(':scope > .ldp-content')?.after(card); }
- const watching = event.watching_invitee || {};
- const stats = event.stats || {};
- const closed = event.is_closed || event.is_expired || event.can_update_attendance === false;
- const location = event.location && (typeof event.location === 'string'
- ? event.location
- : (event.location.name || event.location.address || event.location.display || ''));
- card.innerHTML = `
${esc(event.name || '活动')}
${esc(eventDateLabel(event))}${event.timezone ? ` ${esc(event.timezone)}` : ''}
${location ? `
地点:${esc(location)}
` : ''}${event.description_html ? `
${event.description_html}
` : ''}
参加 ${+stats.going || 0} · 感兴趣 ${+stats.interested || 0}${event.max_attendees ? ` · 名额 ${+stats.going || 0}/${event.max_attendees}` : ''}${event.is_ongoing ? ' · 进行中' : event.is_expired ? ' · 已结束' : ''}
${['going:参加','interested:感兴趣','not_going:不参加'].map((item) => { const [status,label] = item.split(':'); return `
`; }).join('')}
下载日历`;
- prepareReaderMedia(card);
- }
-
- const SYSTEM_ACTION_LABELS = {
- closed: '主题已关闭', opened: '主题已重新开放', archived: '主题已归档', unarchived: '主题已取消归档',
- pinned: '主题已置顶', unpinned: '主题已取消置顶', autoclosed: '主题已自动关闭',
- 'autoclosed.enabled': '主题已自动关闭', 'autoclosed.disabled': '主题已自动重新开放', split_topic: '帖子已拆分到新主题',
- merged: '主题已合并', moved: '帖子已移动', visible: '主题已公开', invisible: '主题已隐藏',
- 'visible.enabled': '主题已公开', 'visible.disabled': '主题已取消公开', renamed: '主题标题已修改', assigned: '已指定',
- };
-
- const SYSTEM_ACTION_ICONS = {
- visible: 'check', invisible: 'eyeOff', 'visible.enabled': 'check', 'visible.disabled': 'eyeOff',
- assigned: 'userPlus',
- };
-
- function systemActionContentHtml(post, actionCode) {
- const actionLabel = SYSTEM_ACTION_LABELS[actionCode] || `系统操作:${actionCode.replace(/[._]/g, ' ')}`;
- if (actionCode !== 'assigned') return `
${esc(actionLabel)}`;
- const username = cleanUsernameValue(post?.action_code_who);
- if (!username) return `
${esc(actionLabel)}`;
- return `
${esc(`${actionLabel}给 `)}@${esc(username)}`;
- }
-
- function postNoticeType(post) {
- const notice = post?.notice;
- return notice && typeof notice === 'object' ? String(notice.type || '') : '';
- }
-
- function postNoticeIdentity(post, type = postNoticeType(post)) {
- if (type === 'new_user') return { label: '新用户', title: '新用户,首次发帖', icon: 'userPlus' };
- if (type === 'returning_user') return { label: '回归用户', title: '回归用户,久未发帖', icon: 'rotateCcw' };
- if (type !== 'custom') return null;
- const notice = post?.notice;
- const customNotice = notice && typeof notice === 'object'
- ? `${String(notice.raw || '')} ${String(notice.cooked || '')}`
- : '';
- return /\bpremium\b/i.test(customNotice)
- ? { label: 'Premium', title: 'Premium', icon: 'check' }
- : { label: '富可敌国', title: '富可敌国', icon: 'tag' };
- }
-
- function ensureNestedReplyToggle(post, referenceNode) {
- if (post.querySelector(':scope > .ldp-nested-toggle')) return;
- const toggle = makeElement('button');
- toggle.className = 'ldp-btn ldp-nested-toggle';
- toggle.type = 'button';
- post.insertBefore(toggle, referenceNode);
- }
-
- function renderSpecialPostState(postNode) {
- if (!postNode) return;
- const post = postNode._ldpPostData || {};
- const head = postNode.querySelector(':scope > .ldp-post-head');
- let hiddenBadge = head?.querySelector(':scope > .ldp-hidden-badge');
- const hiddenCollapsible = !!post.hidden && !postNode.classList.contains('ldp-nested-preview');
- const wasHiddenCollapsible = postNode.classList.contains('ldp-hidden-collapsible');
- if (post.hidden) {
- if (!hiddenBadge && head) {
- hiddenBadge = makeElement('span');
- hiddenBadge.className = 'ldp-special-badge ldp-hidden-badge warn';
- hiddenBadge.textContent = '已隐藏';
- const floor = head.querySelector(':scope > .ldp-floor');
- head.insertBefore(hiddenBadge, floor || null);
- }
- } else if (hiddenBadge) hiddenBadge.remove();
- if (hiddenCollapsible) {
- postNode.classList.add('ldp-hidden-collapsible');
- ensureNestedReplyToggle(postNode, head || postNode.firstChild);
- if (!wasHiddenCollapsible) setNestedReplyCollapsed(postNode, true);
- } else if (wasHiddenCollapsible) {
- setNestedReplyCollapsed(postNode, false);
- postNode.classList.remove('ldp-hidden-collapsible');
- if (!postNode.classList.contains('ldp-reply-collapsible'))
- postNode.querySelector(':scope > .ldp-nested-toggle')?.remove();
- }
- let badges = postNode.querySelector(':scope > .ldp-special-badges');
- const items = [];
- if (+post.post_type === 4) items.push(['私信回复', '']);
- if (+post.post_type === 2) items.push(['管理操作', 'warn']);
- if (post.wiki) items.push(['Wiki', '']);
- if (post.deleted_at) items.push(['已删除', 'danger']);
- if (post.locked) items.push(['已锁定', 'warn']);
- if (items.length) {
- if (!badges) { badges = makeElement('div'); badges.className = 'ldp-special-badges'; head?.after(badges); }
- badges.innerHTML = items.map(([label, type]) => `
${label}`).join('');
- } else if (badges) badges.remove();
- postNode.classList.toggle('ldp-whisper', +post.post_type === 4);
- postNode.classList.toggle('ldp-system-post', +post.post_type === 2 || +post.post_type === 3);
- let notice = postNode.querySelector(':scope > .ldp-special-notice');
- const noticeType = postNoticeType(post);
- if (post.notice && !['new_user', 'returning_user', 'custom'].includes(noticeType)) {
- if (!notice) { notice = makeElement('div'); notice.className = 'ldp-special-notice'; head?.after(notice); }
- notice.innerHTML = typeof post.notice === 'string' ? post.notice : esc(post.notice.type || '站点提示');
- } else if (notice) notice.remove();
- let action = postNode.querySelector(':scope > .ldp-system-action');
- const actionCode = String(post.action_code || '');
- const needsAction = (+post.post_type === 2 || +post.post_type === 3) && actionCode;
- postNode.classList.toggle('ldp-system-action-compact', needsAction && actionCode === 'assigned');
- if (needsAction) {
- if (!action) { action = makeElement('div'); action.className = 'ldp-system-action'; head?.after(action); }
- action.innerHTML = `${icon(SYSTEM_ACTION_ICONS[actionCode] || 'settings')}${systemActionContentHtml(post, actionCode)}`;
- } else if (action) action.remove();
- }
-
- function acceptedTopicAnswers(ctx) {
- const topic = ctx?.topicData;
- if (!topic) return [];
- const answersByPostNumber = new Map();
- const addAnswer = (answer) => {
- if (!answer) return;
- const candidate = typeof answer === 'object' ? answer : { post_number: answer };
- const postNumber = +(candidate.post_number || 0);
- if (postNumber <= 1) return;
- answersByPostNumber.set(postNumber, Object.assign(
- {},
- answersByPostNumber.get(postNumber) || {},
- candidate,
- { post_number: postNumber }
- ));
- };
- if (Array.isArray(topic.accepted_answers)) topic.accepted_answers.forEach(addAnswer);
- addAnswer(topic.accepted_answer);
- postsFromPayload(topic).forEach((post) => {
- if (post?.accepted_answer) addAnswer(post);
- });
- if (ctx.streamNodeMap)
- ctx.streamNodeMap.forEach((node) => {
- const post = node?._ldpPostData;
- if (post?.accepted_answer) addAnswer(post);
- });
- return [...answersByPostNumber.values()].sort((a, b) => a.post_number - b.post_number);
- }
-
- function renderSolvedAnswerCard(postNode, ctx) {
- if (!postNode) return;
- const post = postNode._ldpPostData || {};
- const answers = acceptedTopicAnswers(ctx);
- let card = postNode.querySelector(':scope > .ldp-solved-card');
- const isStarter = +post.post_number === 1 && !postNode.classList.contains('ldp-nested-preview');
- if (!isStarter || !answers.length) {
- if (card) card.remove();
- return;
- }
-
- const topicPosts = postsFromPayload(ctx.topicData);
- if (!card) {
- card = makeElement('section');
- card.className = 'ldp-solved-card';
- const content = postNode.querySelector(':scope > .ldp-content');
- if (!content) return;
- content.after(card);
- }
- card.innerHTML = `
${icon('check')}${answers.length > 1 ? `已解决 · ${answers.length} 个答案` : '已解决'}
${answers.map((answer) => {
- const postNumber = +answer.post_number;
- const acceptedNode = ctx.streamNodeMap?.get(postNumber);
- const acceptedPost = acceptedNode?._ldpPostData ||
- topicPosts.find((item) => +item.post_number === postNumber) || {};
- const username = String(answer.username || acceptedPost.username || '');
- const name = String(answer.name || acceptedPost.name || username || '已解决回复');
- const profileUrl = username ? userProfileUrl(username) : '';
- const avatar = persistentAvatarHtml(
- answer.avatar_template || acceptedPost.avatar_template,
- 32,
- 'ldp-solved-avatar',
- name
- );
- const time = discourseRelativeTime(answer.created_at || acceptedPost.created_at);
- const answerHtml = String(answer.cooked || acceptedPost.cooked || answer.excerpt || acceptedPost.excerpt || '').trim();
- return `
-
- ${avatar ? `
${avatar}` : ''}
- ${profileUrl ? `
${esc(name)}` : `
${esc(name)}`}
- ${username ? `
@${esc(username)}` : ''}
- ${time ? `
· ${esc(time)}` : ''}
-
-
- ${answerHtml ? `
${readerMediaHtml(answerHtml, { inlineOneboxes: true })}
` : '
查看被采纳的完整回复。
'}
-
-
`;
- }).join('')}`;
- ctx?.translation?.syncPost(postNode);
- }
-
- function prepareReaderImageCarousels(root) {
- root.querySelectorAll('.d-image-grid[data-mode="carousel"]').forEach((grid) => {
- if (grid.dataset.ldpCarouselPrepared === '1') return;
- const items = [...grid.querySelectorAll('.lightbox-wrapper')]
- .filter((item) => item.closest('.d-image-grid') === grid);
- if (items.length < 2) return;
- grid.dataset.ldpCarouselPrepared = '1';
- grid.classList.add('ldp-media-carousel');
- const track = makeElement('div');
- track.className = 'ldp-media-carousel-track';
- track.tabIndex = 0;
- track.setAttribute('role', 'region');
- setLabel(track, `多图轮播,共 ${items.length} 张`);
- items.forEach((item) => track.append(item));
- const controls = makeElement('div');
- controls.className = 'ldp-media-carousel-controls';
- const previous = makeElement('button');
- previous.type = 'button';
- setLabel(previous, '上一张图片');
- previous.innerHTML = icon('chevronRight');
- const status = makeElement('span');
- status.className = 'ldp-media-carousel-status';
- status.setAttribute('aria-live', 'polite');
- const next = makeElement('button');
- next.type = 'button';
- setLabel(next, '下一张图片');
- next.innerHTML = icon('chevronRight');
- controls.append(previous, status, next);
- grid.replaceChildren(track, controls);
-
- let activeIndex = 0;
- let syncFrame = 0;
- const sync = () => {
- syncFrame = 0;
- activeIndex = items.reduce((closest, item, index) => (
- Math.abs(item.offsetLeft - track.scrollLeft) < Math.abs(items[closest].offsetLeft - track.scrollLeft)
- ? index
- : closest
- ), 0);
- previous.disabled = activeIndex === 0;
- next.disabled = activeIndex === items.length - 1;
- status.textContent = `${activeIndex + 1} / ${items.length}`;
- };
- const scheduleSync = () => {
- if (!syncFrame) syncFrame = requestAnimationFrame(sync);
- };
- const show = (index) => {
- const target = items[Math.max(0, Math.min(items.length - 1, index))];
- if (!target) return;
- const reducedMotion = prefersReducedMotion();
- track.scrollTo({ left: target.offsetLeft, behavior: reducedMotion ? 'auto' : 'smooth' });
- };
- controls.addEventListener('click', (event) => {
- const button = event.target instanceof Element ? event.target.closest('button') : null;
- if (button === previous) show(activeIndex - 1);
- else if (button === next) show(activeIndex + 1);
- });
- track.addEventListener('scroll', scheduleSync, { passive: true });
- sync();
- });
- }
-
- function prepareReaderFrame(frame) {
- frame.loading = 'lazy';
- try {
- const url = new URL(frame.getAttribute('src') || '', BASE);
- if (url.hostname !== 'player.bilibili.com') return;
- url.searchParams.set('autoplay', '0');
- frame.src = url.href;
- frame.classList.add('ldp-bilibili-player');
- frame.setAttribute('allow', 'fullscreen; picture-in-picture');
- frame.setAttribute('allowfullscreen', '');
- } catch {}
- }
-
- function readerHlsSource(video) {
- const candidates = [video, ...video.querySelectorAll('source')];
- for (const candidate of candidates) {
- const source = String(candidate.getAttribute('src') || '').trim();
- if (!source) continue;
- const type = String(candidate.getAttribute('type') || '').toLowerCase();
- let isHls = /(?:vnd\.apple\.mpegurl|x-mpegurl)/.test(type);
- if (!isHls) {
- try { isHls = /\.m3u8$/i.test(new URL(source, BASE).pathname); } catch {}
- }
- if (isHls) return new URL(source, BASE).href;
- }
- return '';
- }
-
- function bindReaderHls(video) {
- if (!video || video.dataset.ldpHlsBound === '1') return;
- const source = readerHlsSource(video);
- if (!source) return;
- const nativeHls = !!video.canPlayType('application/vnd.apple.mpegurl') && 'ManagedMediaSource' in window;
- if (nativeHls) return;
- const HlsPlayer = window.Hls;
- if (!HlsPlayer || !isFunction(HlsPlayer.isSupported) || !HlsPlayer.isSupported()) return;
- const player = new HlsPlayer();
- video.dataset.ldpHlsBound = '1';
- video._ldpHlsPlayer = player;
- player.loadSource(source);
- player.attachMedia(video);
- }
-
- function destroyReaderMedia(root) {
- if (!root || !isFunction(root.querySelectorAll)) return;
- root.querySelectorAll('video').forEach((video) => {
- const player = video._ldpHlsPlayer;
- if (isFunction(player?.destroy)) player.destroy();
- video._ldpHlsPlayer = null;
- delete video.dataset.ldpHlsBound;
- });
- }
-
- function suspendReaderMedia(root) {
- if (!root || !isFunction(root.querySelectorAll)) return;
- root.querySelectorAll('video,audio').forEach((media) => {
- try { media.pause(); } catch {}
- });
- destroyReaderMedia(root);
- }
-
- function activateReaderMedia(root) {
- if (!root || !isFunction(root.querySelectorAll) || document.visibilityState !== 'visible') return;
- root.querySelectorAll('video').forEach(bindReaderHls);
- }
-
- function prepareReaderMedia(root, options = {}) {
- if (!root || !isFunction(root.querySelectorAll)) return;
- root.querySelectorAll('[title]').forEach((element) => element.removeAttribute('title'));
- if (options.interactive !== false) prepareReaderImageCarousels(root);
- root.querySelectorAll('iframe').forEach(prepareReaderFrame);
- root.querySelectorAll('video,audio').forEach((media) => {
- media.removeAttribute('autoplay');
- media.autoplay = false;
- if (!media.hasAttribute('preload')) media.preload = 'metadata';
- if (media.tagName === 'VIDEO') media.playsInline = true;
- });
- root.querySelectorAll('img').forEach((img) => {
- if (img.classList.contains('emoji')) return;
- img.loading = 'lazy';
- img.decoding = 'async';
- });
- }
-
- function prepareReaderCodeBlocks(root) {
- if (!root || !isFunction(root.querySelectorAll)) return;
- root.querySelectorAll('pre').forEach((pre) => {
- if (pre.closest('.ldp-code-block')) return;
- const lineCount = String(pre.textContent || '').replace(/\n$/, '').split('\n').length;
- const block = makeElement('div');
- block.className = 'ldp-code-block';
- const actions = makeElement('div');
- actions.className = 'ldp-code-block-actions';
- actions.innerHTML = `
- ${iconActionButtons('ldp-code-block-action', 'data-reader-code-action', [
- ['copy', '复制文本', 'copy'], ['preview', '在阅读器内预览文本', 'maximize2'],
- ...(lineCount > 10 ? [['toggle', `展开全部 ${lineCount} 行`, 'chevronDown',
- ' aria-expanded="false"']] : []),
- ])}`;
- if (lineCount > 10) {
- block.classList.add('ldp-code-block-collapsible');
- block.dataset.readerCodeLines = String(lineCount);
- }
- pre.before(block);
- block.append(pre, actions);
- });
- }
-
- async function copyReaderCode(source, button) {
- if (!source || !button || button.disabled) return;
- button.disabled = true;
- try {
- const text = 'value' in source ? source.value : source.textContent;
- await writeSelectionQuoteToClipboard(text || '');
- button.innerHTML = icon('check');
- setLabel(button, '已复制');
- showSelectionToast('文本已复制');
- setTimeout(() => {
- if (!button.isConnected) return;
- button.innerHTML = icon('copy');
- setLabel(button, '复制文本');
- button.disabled = false;
- }, 1200);
- } catch {
- button.disabled = false;
- showSelectionToast('复制失败,请重试');
- }
- }
-
- function readerCodeDownloadExtension(pre) {
- const code = pre?.querySelector('code');
- const classNames = `${pre?.className || ''} ${code?.className || ''}`;
- const language = (classNames.match(/(?:language|lang)-([a-z0-9_+-]+)/i) || [])[1]?.toLowerCase() || '';
- return ({
- javascript: 'js', js: 'js', typescript: 'ts', ts: 'ts', python: 'py', py: 'py',
- bash: 'sh', shell: 'sh', sh: 'sh', html: 'html', css: 'css', json: 'json', sql: 'sql',
- rust: 'rs', go: 'go', java: 'java', c: 'c', cpp: 'cpp', csharp: 'cs', php: 'php',
- ruby: 'rb', yaml: 'yml', yml: 'yml', markdown: 'md', md: 'md',
- })[language] || 'txt';
- }
-
- function downloadReaderCodeCopy(pre, text) {
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
- const extension = readerCodeDownloadExtension(pre);
- const blob = new window.Blob([String(text || '')], { type: 'text/plain;charset=utf-8' });
- triggerBrowserDownload(blob, `linuxdo-code-copy-${timestamp}.${extension}`);
- showSelectionToast('编辑副本已下载到本地');
- }
-
- function openReaderCodePreview(pre, modal) {
- if (!pre || !modal) return;
- const overlay = modal.closest('.ldp-overlay');
- if (isFunction(overlay?._ldpCloseCodePreview)) overlay._ldpCloseCodePreview(false, true);
- const previousFocus = readerActiveElement();
- const layer = makeElement('section');
- layer.className = 'ldp-code-preview-layer';
- layer.setAttribute('role', 'dialog');
- layer.setAttribute('aria-modal', 'true');
- setLabel(layer, '文本预览');
- const sourceStyle = getComputedStyle(pre);
- layer.style.setProperty('--ldp-code-preview-font-family', sourceStyle.fontFamily);
- layer.style.setProperty('--ldp-code-preview-font-size', sourceStyle.fontSize);
- layer.style.setProperty('--ldp-code-preview-font-weight', sourceStyle.fontWeight);
- layer.style.setProperty('--ldp-code-preview-line-height', sourceStyle.lineHeight);
- layer.style.setProperty('--ldp-code-preview-tab-size', sourceStyle.tabSize || '4');
- layer.innerHTML = `
-
- 文本预览
-
- ${iconActionButtons('ldp-code-block-action', 'data-reader-code-preview-action', [
- ['edit', '编辑文本副本', 'pencil'], ['save', '保存编辑副本到本地', 'download', ' hidden'],
- ['copy', '复制文本', 'copy'], ['close', '关闭文本预览', 'x'],
- ])}
-
-
-
`;
- const previewPre = pre.cloneNode(true);
- const previewBody = layer.querySelector('.ldp-code-preview-body');
- const previewTitle = layer.querySelector('.ldp-code-preview-head strong');
- const editButton = layer.querySelector('[data-reader-code-preview-action="edit"]');
- const saveButton = layer.querySelector('[data-reader-code-preview-action="save"]');
- const closeButton = layer.querySelector('[data-reader-code-preview-action="close"]');
- previewBody.append(previewPre);
- let activeTextSource = previewPre;
- let editing = false;
- let closed = false;
- const restorePreview = () => {
- activeTextSource = previewPre;
- editing = false;
- previewBody.replaceChildren(previewPre);
- previewTitle.textContent = '文本预览';
- setLabel(layer, '文本预览');
- editButton.hidden = false;
- saveButton.hidden = true;
- setLabel(closeButton, '关闭文本预览');
- closeButton.focus();
- };
- const close = (restoreFocus = true, force = false) => {
- if (closed) return;
- if (editing && !force) {
- restorePreview();
- return;
- }
- closed = true;
- document.removeEventListener('keydown', onKeyDown, true);
- layer.remove();
- if (overlay && overlay._ldpCloseCodePreview === close) overlay._ldpCloseCodePreview = null;
- if (restoreFocus && previousFocus && previousFocus.isConnected) previousFocus.focus();
- };
- const onKeyDown = (event) => {
- if (event.key !== 'Escape') return;
- event.preventDefault();
- event.stopImmediatePropagation();
- close();
- };
- layer.addEventListener('click', (event) => {
- const action = event.target.closest('[data-reader-code-preview-action]');
- if (!action) return;
- consumeEvent(event);
- if (action.dataset.readerCodePreviewAction === 'copy')
- void copyReaderCode(activeTextSource, action);
- else if (action.dataset.readerCodePreviewAction === 'save')
- downloadReaderCodeCopy(pre, activeTextSource.value);
- else if (action.dataset.readerCodePreviewAction === 'edit') {
- const editor = makeElement('textarea');
- editor.className = 'ldp-code-preview-editor';
- editor.value = previewPre.textContent || '';
- editor.spellcheck = false;
- setLabel(editor, '文本编辑副本');
- editor.addEventListener('keydown', (keyEvent) => {
- if (keyEvent.key !== 'Tab') return;
- keyEvent.preventDefault();
- editor.setRangeText('\t', editor.selectionStart, editor.selectionEnd, 'end');
- });
- activeTextSource = editor;
- editing = true;
- previewBody.replaceChildren(editor);
- previewTitle.textContent = '文本编辑(副本)';
- setLabel(layer, '文本编辑副本');
- saveButton.hidden = false;
- editButton.hidden = true;
- setLabel(closeButton, '返回文本预览');
- editor.focus();
- } else close();
- });
- modal.append(layer);
- bindFloatingSurfaceWheel(layer);
- if (overlay) overlay._ldpCloseCodePreview = close;
- document.addEventListener('keydown', onKeyDown, true);
- layer.querySelector('[data-reader-code-preview-action="close"]')?.focus();
- }
-
- function prepareReaderHashtags(root) {
- if (!root || !isFunction(root.querySelectorAll)) return;
- const iconTemplate = makeElement('template');
- iconTemplate.innerHTML = icon('tag', 'ldp-hashtag-icon');
- root.querySelectorAll('.hashtag-cooked').forEach((hashtag) => {
- const iconHost = hashtag.matches('a') ? hashtag : hashtag.querySelector('a') || hashtag;
- if (iconHost.querySelector('img.emoji')) return;
- const existingSvg = iconHost.querySelector('svg');
- if (existingSvg?.querySelector('path,circle,rect,ellipse,line,polyline,polygon')) return;
- const tagIcon = iconTemplate.content.firstElementChild.cloneNode(true);
- tagIcon.setAttribute('focusable', 'false');
- const placeholder = iconHost.querySelector('.hashtag-icon-placeholder');
- if (placeholder) placeholder.replaceWith(tagIcon);
- else if (existingSvg) existingSvg.replaceWith(tagIcon);
- else iconHost.prepend(tagIcon);
- });
- }
-
- function prepareReaderUserMentions(root) {
- if (!root || !isFunction(root.querySelectorAll)) return;
- root.querySelectorAll('a.mention').forEach((link) => {
- let username = cleanUsernameValue(link.dataset.username);
- if (!username) {
- try {
- const url = new URL(link.getAttribute('href') || '', BASE);
- const match = url.origin === BASE && url.pathname.match(/^\/u\/([^/]+)\/?$/i);
- username = match ? decodeURIComponent(match[1]) : '';
- } catch {}
- }
- if (!username) username = cleanUsernameValue(link.textContent);
- if (!username) return;
- link.classList.add('ldp-user-link');
- link.dataset.userCard = username;
- });
- }
-
- const READER_CALLOUT_ICONS = Object.freeze(Object.fromEntries([
- ['list', ['abstract', 'example', 'summary', 'tldr']],
- ['alertTriangle', ['attention', 'bug', 'caution', 'danger', 'error', 'warning']],
- ['check', ['check', 'done', 'success', 'todo']],
- ['messageSquare', ['cite', 'quote']],
- ['circleX', ['fail', 'failure', 'missing']],
- ['info', ['faq', 'help', 'info', 'question']],
- ['lightbulb', ['hint', 'important', 'tip']],
- ['pencil', ['note']],
- ].flatMap(([iconName, types]) => types.map((type) => [type, iconName]))));
-
- function prepareReaderCallouts(root) {
- if (!root || !isFunction(root.querySelectorAll)) return;
- root.querySelectorAll('blockquote').forEach((quote) => {
- const firstBlock = quote.firstElementChild;
- if (!firstBlock) return;
- const walker = document.createTreeWalker(firstBlock, NodeFilter.SHOW_TEXT);
- let markerNode = null;
- while (walker.nextNode()) {
- if (String(walker.currentNode.nodeValue || '').trim()) {
- markerNode = walker.currentNode;
- break;
- }
- }
- if (!markerNode) return;
- const match = String(markerNode.nodeValue || '').match(/^\s*\[!([a-z][a-z0-9_-]*)\]([+-])?\s*/i);
- if (!match) return;
- const type = match[1].toLowerCase();
- if (!READER_CALLOUT_ICONS[type]) return;
- markerNode.nodeValue = String(markerNode.nodeValue || '').slice(match[0].length);
- quote.classList.add('ldp-callout', `ldp-callout--${type}`);
- if (match[2]) {
- const body = makeElement('div');
- body.className = 'ldp-callout-body';
- const firstBreak = firstBlock.querySelector('br');
- if (firstBreak) {
- const range = document.createRange();
- range.setStartAfter(firstBreak);
- range.setEnd(firstBlock, firstBlock.childNodes.length);
- body.append(range.extractContents());
- firstBreak.remove();
- }
- let sibling = firstBlock.nextSibling;
- while (sibling) {
- const next = sibling.nextSibling;
- body.append(sibling);
- sibling = next;
- }
- if (String(body.textContent || '').trim() || body.querySelector('*')) {
- quote.classList.add('ldp-callout--foldable');
- quote.append(body);
- const toggle = makeElement('button');
- toggle.type = 'button';
- toggle.className = 'ldp-callout-toggle';
- toggle.innerHTML = icon('chevronDown');
- const setCalloutExpanded = (expanded) => {
- body.hidden = !expanded;
- quote.classList.toggle('ldp-callout--collapsed', !expanded);
- setExpanded(toggle, expanded);
- setLabel(toggle, expanded ? '收起提示内容' : '展开提示内容');
- };
- toggle.addEventListener('click', () =>
- setCalloutExpanded(toggle.getAttribute('aria-expanded') !== 'true'));
- quote.append(toggle);
- setCalloutExpanded(match[2] === '+');
- }
- }
- const marker = makeElement('span');
- marker.className = 'ldp-callout-icon';
- marker.innerHTML = icon(READER_CALLOUT_ICONS[type]);
- quote.prepend(marker);
- });
- }
-
- function prepareReaderPostQuotes(root) {
- if (!root || !isFunction(root.querySelectorAll)) return;
- root.querySelectorAll('aside.quote').forEach((quote) => {
- const title = quote.querySelector(':scope > .title');
- const body = quote.querySelector(':scope > blockquote');
- if (!title || !body) return;
- quote.classList.add('ldp-post-quote');
- quote.dataset.ldpQuoteExpanded = '0';
- quote._ldpQuoteExcerptHtml = body.innerHTML;
- title.classList.add('ldp-quote-title');
- const username = cleanUsernameValue(quote.dataset.username);
- const avatar = [...title.querySelectorAll('img')].find((image) => !image.classList.contains('emoji'));
- if (username && avatar) {
- avatar.classList.add('ldp-user-avatar-card');
- avatar.dataset.userCard = username;
- }
- const targetPostNumber = Math.max(0, +(quote.dataset.post || 0));
- const targetTopicId = Math.max(0, +(quote.dataset.topic || 0));
- let controls = title.querySelector(':scope > .quote-controls');
- if (!controls) {
- controls = makeElement('span');
- controls.className = 'quote-controls';
- title.append(controls);
- }
- controls.classList.add('ldp-quote-controls');
- controls.replaceChildren();
- const toggle = makeElement('button');
- toggle.type = 'button';
- toggle.className = 'ldp-quote-toggle';
- setLabel(toggle, '展开完整引用');
- setExpanded(toggle, false);
- toggle.innerHTML = icon('chevronDown');
- controls.append(toggle);
- if (targetPostNumber) {
- const jump = makeElement('button');
- jump.type = 'button';
- jump.className = 'ldp-quote-jump';
- jump.dataset.targetPostNumber = String(targetPostNumber);
- if (targetTopicId) jump.dataset.targetTopicId = String(targetTopicId);
- setLabel(jump, `跳到被引用楼层 #${targetPostNumber}`);
- jump.innerHTML = icon('arrowUp');
- controls.append(jump);
- }
- });
- }
-
- function cachedReaderQuotedPost(ctx, topicId, postNumber) {
- const targetTopicId = +topicId || +(ctx?.topicId || 0);
- const targetPostNumber = +postNumber || 0;
- if (!targetTopicId || !targetPostNumber) return null;
- if (ctx && targetTopicId === +ctx.topicId) {
- const streamPost = ctx.streamNodeMap?.get(targetPostNumber);
- const streamData = streamPost?._ldpPostData;
- if (streamData && streamData.cooked != null) return streamData;
- const topicPost = postsFromPayload(ctx.topicData).find((post) => +post.post_number === targetPostNumber);
- if (topicPost && topicPost.cooked != null) return topicPost;
- }
- return postsFromPayload(getCachedTopicData(targetTopicId))
- .find((post) => +post.post_number === targetPostNumber && post.cooked != null) || null;
- }
-
- async function loadReaderQuotedPost(quote, ctx) {
- if (!quote || !ctx) return null;
- if (quote._ldpQuoteFullPost && quote._ldpQuoteFullPost.cooked != null) return quote._ldpQuoteFullPost;
- const targetPostNumber = +quote.dataset.post || 0;
- const targetTopicId = +quote.dataset.topic || +ctx.topicId || 0;
- let post = cachedReaderQuotedPost(ctx, targetTopicId, targetPostNumber);
- if (!post && targetTopicId === +ctx.topicId && ctx.loadPost) {
- await ctx.loadPost(targetPostNumber, {
- mount: false,
- priority: POST_REQUEST_PRIORITY.auxiliary,
- });
- post = cachedReaderQuotedPost(ctx, targetTopicId, targetPostNumber);
- }
- if (!post) {
- const urls = [
- `${BASE}/posts/by_number/${targetTopicId}/${targetPostNumber}.json`,
- `${BASE}/t/${targetTopicId}.json?post_number=${targetPostNumber}`,
- ];
- for (const url of urls) {
- try {
- const payload = await fetchJSON(url, {
- scheduler: ctx.postRequestScheduler,
- key: `quote:${targetTopicId}:${targetPostNumber}:${url}`,
- priority: POST_REQUEST_PRIORITY.auxiliary,
- });
- const posts = payload?.post_number ? [payload] : postsFromPayload(payload);
- post = posts.find((item) => +item.post_number === targetPostNumber && item.cooked != null) || null;
- if (post) break;
- } catch (error) {
- if (shouldStopReaderFallback(error)) throw error;
- }
- }
- }
- if (post) quote._ldpQuoteFullPost = post;
- return post;
- }
-
- function preserveReaderQuotePosition(quote, ctx, update) {
- const scrollRoot = ctx?.scrollRoot;
- if (!quote || !quote.isConnected || !scrollRoot) {
- update();
- return;
- }
- const anchorTop = quote.getBoundingClientRect().top;
- scrollRoot.classList.add('ldp-quote-positioning');
- try {
- update();
- const correction = quote.getBoundingClientRect().top - anchorTop;
- if (Math.abs(correction) >= 0.5) {
- const previousScrollTop = scrollRoot.scrollTop;
- scrollRoot.scrollTop += correction;
- markStreamInternalScroll(ctx, previousScrollTop);
- }
- } finally {
- requestAnimationFrame(() => scrollRoot.classList.remove('ldp-quote-positioning'));
- }
- }
-
- function syncReaderExpandableLayout(element, ctx) {
- const post = element?.closest('.ldp-post');
- if (!post || !ctx) return;
- bindPostMediaLayoutSync(post, ctx);
- scheduleNestedBranchSync(post, ctx);
- const owner = streamOwnerPostNode(ctx, post);
- const item = owner && ctx.streamItemMap.get(streamPostNumber(owner));
- if (!owner || !item) return;
- const sync = () => {
- if (!owner.isConnected) return;
- const measured = owner.getBoundingClientRect().height || owner.offsetHeight || 0;
- const height = Math.max(Math.round(measured), STREAM_PLACEHOLDER_MIN_HEIGHT);
- if (Math.abs(height - item.height) >= 1) {
- item.height = height;
- const index = ctx.onlyOp && Number.isInteger(item.layoutIndex) ? item.layoutIndex : item.index;
- markStreamPrefixDirty(ctx, index);
- ctx.streamNeedsRepair = true;
- }
- scheduleStreamWindowSync(ctx);
- };
- requestAnimationFrame(() => {
- sync();
- requestAnimationFrame(sync);
- });
- }
-
- function syncReaderQuoteLayout(quote, ctx) {
- syncReaderExpandableLayout(quote, ctx);
- }
-
- function readerQuoteExcerptText(quote) {
- if (!quote) return '';
- const body = quote.querySelector(':scope > blockquote');
- if (body && quote.dataset.ldpQuoteExpanded !== '1') return String(body.textContent || '').trim();
- const html = quote._ldpQuoteExcerptHtml != null
- ? quote._ldpQuoteExcerptHtml
- : body?.innerHTML;
- const template = makeElement('template');
- template.innerHTML = String(html || '');
- return String(template.content.textContent || '').trim();
- }
-
- function readerQuoteSourceFromJump(jump, ctx) {
- const preview = jump?.closest('.ldp-floor-preview');
- const sourcePost = jump && (jump.closest('.ldp-post') || (preview?._ldpRelatedPost));
- const postNumber = +(sourcePost && sourcePost.dataset.postNumber || 0);
- const topicId = +(ctx?.topicId || 0);
- if (!sourcePost || !postNumber || !topicId) return null;
- const nested = !!(sourcePost.parentElement && sourcePost.parentElement.classList.contains('ldp-children'));
- return {
- topicId,
- postNumber,
- parentPostNumber: nested ? +(sourcePost.dataset.replyToPostNumber || 0) : 0,
- nested,
- anchor: captureReaderAnchorState(CURRENT_OVERLAY),
- };
- }
-
- function quoteHighlightFromJump(jump, ctx) {
- const quote = jump?.closest('.ldp-post-quote');
- const text = readerQuoteExcerptText(quote);
- return text ? { text, source: readerQuoteSourceFromJump(jump, ctx) } : null;
- }
-
- function discourseComparableQuoteText(value) {
- return String(value || '').replace(/[ \t\r\n]/g, '');
- }
-
- function markReaderTextMatch(root, comparableText, excludedSelector, className) {
- const characters = [];
- const positions = [];
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
- let textNode = walker.nextNode();
- while (textNode) {
- const parent = textNode.parentElement;
- if (parent && !parent.closest(excludedSelector)) {
- const value = textNode.nodeValue || '';
- for (let offset = 0; offset < value.length; offset++) {
- const character = value[offset];
- if (/[ \t\r\n]/.test(character)) continue;
- characters.push(character);
- positions.push({ node: textNode, offset });
- }
- }
- textNode = walker.nextNode();
- }
- const matchStart = characters.join('').indexOf(comparableText);
- if (matchStart < 0) return [];
- const matchEnd = matchStart + comparableText.length;
- const groups = [];
- for (let index = matchStart; index < matchEnd; index++) {
- const position = positions[index];
- if (!position) continue;
- const previous = groups[groups.length - 1];
- if (previous && previous.node === position.node) previous.end = position.offset + 1;
- else groups.push({ node: position.node, start: position.offset, end: position.offset + 1 });
- }
- const marks = [];
- for (let index = groups.length - 1; index >= 0; index--) {
- const group = groups[index];
- const range = document.createRange();
- range.setStart(group.node, group.start);
- range.setEnd(group.node, group.end);
- const mark = makeElement('mark');
- mark.className = className;
- range.surroundContents(mark);
- marks.unshift(mark);
- }
- return marks;
- }
-
- function highlightReaderExpandedQuote(quote) {
- const body = quote?.querySelector(':scope > blockquote');
- const comparableQuote = discourseComparableQuoteText(readerQuoteExcerptText(quote));
- if (!body || !comparableQuote) return false;
- return markReaderTextMatch(body, comparableQuote,
- 'button,script,style,noscript,.ldp-quote-title', 'ldp-expanded-quote-match').length > 0;
- }
-
- function clearReaderQuoteHighlight(ctx) {
- const state = ctx?.quoteHighlightState;
- if (!state) return;
- if (state.hintHideTimer) clearTimeout(state.hintHideTimer);
- if (state.hint) state.hint.remove();
- (state.marks || []).forEach((mark) => {
- if (!mark || !mark.parentNode) return;
- const parent = mark.parentNode;
- mark.replaceWith(document.createTextNode(mark.textContent || ''));
- parent.normalize();
- });
- ctx.quoteHighlightState = null;
- }
-
- function setReaderQuoteHighlightActive(ctx, active) {
- const state = ctx?.quoteHighlightState;
- if (!state) return;
- state.active = active !== false;
- const hintText = state.active ? '点击关闭高亮' : '点击继续高亮';
- state.marks.forEach((mark) => mark.classList.toggle('ldp-quote-match-muted', !state.active));
- if (state.lastMark) {
- setPressed(state.lastMark, state.active);
- setLabel(state.lastMark, hintText);
- }
- if (state.toggleControl) state.toggleControl.textContent = state.active ? '关闭高亮' : '继续高亮';
- }
-
- function applyReaderQuoteHighlight(postNumber, ctx, quoteText, options = {}) {
- clearReaderQuoteHighlight(ctx);
- const comparableQuote = discourseComparableQuoteText(quoteText);
- const target = getStreamPostNode(ctx, postNumber, { mount: true }) ||
- ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${postNumber}"]`);
- if (!target || !comparableQuote) return false;
- hydratePostContent(target, ctx);
- const content = target.querySelector(':scope > .ldp-content');
- if (!content) return false;
-
- const marks = markReaderTextMatch(content, comparableQuote,
- 'button,script,style,noscript,.ldp-quote-highlight-close', 'ldp-quote-match');
- if (!marks.length) {
- showSelectionToast('引用原文可能已修改,未找到匹配内容');
- return false;
- }
-
- const firstMark = marks[0];
- const lastMark = marks[marks.length - 1];
- lastMark.classList.add('ldp-quote-match-end');
- lastMark.tabIndex = 0;
- lastMark.setAttribute('role', 'button');
- lastMark.setAttribute('data-tooltip', '');
- const hint = makeElement('span');
- hint.className = 'ldp-quote-highlight-hint ldp-action-surface';
- const toggleControl = makeElement('button');
- toggleControl.type = 'button';
- toggleControl.className = 'ldp-quote-highlight-toggle';
- hint.append(toggleControl);
- const source = options.source || null;
- let returnControl = null;
- if (source) {
- returnControl = makeElement('button');
- returnControl.type = 'button';
- returnControl.className = 'ldp-quote-highlight-return';
- returnControl.textContent = source.nested ? '返回二级回复' : '返回引用楼层';
- hint.append(returnControl);
- }
- appendReaderPortalNode(hint);
- ctx.quoteHighlightState = {
- marks,
- lastMark,
- hint,
- toggleControl,
- active: options.active !== false,
- postNumber: +postNumber,
- quoteText: String(quoteText || ''),
- source,
- hintHideTimer: 0,
- };
- const toggleHighlight = (event) => {
- consumeEvent(event);
- cancelHintHide();
- const state = ctx.quoteHighlightState;
- if (state) setReaderQuoteHighlightActive(ctx, !state.active);
- };
- const cancelHintHide = () => {
- const state = ctx.quoteHighlightState;
- if (!state || !state.hintHideTimer) return;
- clearTimeout(state.hintHideTimer);
- state.hintHideTimer = 0;
- };
- const showHint = (mark, event) => {
- const state = ctx.quoteHighlightState;
- if (!state || !mark) return;
- cancelHintHide();
- state.hint.classList.add('ldp-quote-hint-visible');
- const markRect = mark.getBoundingClientRect();
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const hintRect = state.hint.getBoundingClientRect();
- const anchorX = Number.isFinite(event?.clientX) ? event.clientX : markRect.left + markRect.width / 2;
- const anchorY = Number.isFinite(event?.clientY) ? event.clientY : markRect.top;
- const edge = 8;
- const gap = 10;
- const left = Math.max(edge, Math.min(anchorX - hintRect.width / 2, window.innerWidth - hintRect.width - edge));
- const showBelow = anchorY - hintRect.height - gap < rootRect.top + edge;
- const top = showBelow ? anchorY + gap : anchorY - hintRect.height - gap;
- state.hint.style.left = `${Math.round(left)}px`;
- state.hint.style.top = `${Math.round(Math.max(edge, top))}px`;
- };
- const scheduleHintHide = () => {
- const state = ctx.quoteHighlightState;
- if (!state) return;
- cancelHintHide();
- state.hintHideTimer = setTimeout(() => {
- state.hintHideTimer = 0;
- if (state.hint.matches(':hover,:focus-within') || state.marks.some((mark) => mark.matches(':hover'))) return;
- state.hint.classList.remove('ldp-quote-hint-visible');
- }, 160);
- };
- marks.forEach((mark) => {
- mark.addEventListener('click', toggleHighlight);
- mark.addEventListener('mouseenter', (event) => showHint(mark, event));
- mark.addEventListener('mouseleave', scheduleHintHide);
- });
- hint.addEventListener('mouseenter', cancelHintHide);
- hint.addEventListener('pointerdown', cancelHintHide);
- hint.addEventListener('mouseleave', scheduleHintHide);
- hint.addEventListener('focusout', scheduleHintHide);
- toggleControl.addEventListener('click', toggleHighlight);
- if (returnControl) {
- returnControl.addEventListener('click', async (event) => {
- consumeEvent(event);
- cancelHintHide();
- if (returnControl.disabled) return;
- hint.classList.remove('ldp-quote-hint-visible');
- returnControl.blur();
- returnControl.disabled = true;
- const originalText = returnControl.textContent;
- returnControl.textContent = '返回中…';
- const returned = await returnToReaderQuoteSource(source, ctx);
- if (!returned) showSelectionToast('暂时无法返回引用位置');
- if (returnControl.isConnected) {
- returnControl.disabled = false;
- returnControl.textContent = originalText;
- }
- });
- }
- lastMark.addEventListener('keydown', (event) => {
- if (event.target === lastMark && (event.key === 'Enter' || event.key === ' ')) toggleHighlight(event);
- });
- lastMark.addEventListener('focus', (event) => showHint(lastMark, event));
- lastMark.addEventListener('blur', scheduleHintHide);
- setReaderQuoteHighlightActive(ctx, options.active !== false);
- return firstMark;
- }
-
- function restoreReaderQuoteHighlightForPost(post, ctx) {
- const state = ctx?.quoteHighlightState;
- const postNumber = +(post && post.dataset.postNumber);
- if (!state || !postNumber || +state.postNumber !== postNumber || !state.quoteText) return;
- if (state.marks?.length && state.marks.every((mark) => post.contains(mark))) return;
- applyReaderQuoteHighlight(postNumber, ctx, state.quoteText, { active: state.active, source: state.source });
- }
-
- function readerMediaHtml(value, options = {}) {
- const template = makeElement('template');
- template.innerHTML = String(value || '');
- prepareReaderHashtags(template.content);
- if (options.inlineOneboxes) prepareReaderInlineOneboxes(template.content);
- prepareReaderOneboxes(template.content);
- prepareReaderCodeBlocks(template.content);
- prepareReaderMedia(template.content, { interactive: false });
- return template.innerHTML;
- }
-
- function normalizedPostLinkUrl(value) {
- try {
- return new URL(String(value || '').trim(), BASE).href;
- } catch {
- return '';
- }
- }
-
- function prepareReaderInlineOneboxes(root) {
- if (!root || !isFunction(root.querySelectorAll)) return;
- root.querySelectorAll('a.inline-onebox').forEach((link) => {
- if (link.querySelector(':scope > .ldp-inline-onebox-label')) return;
- const labelNodes = [...link.childNodes].filter((node) =>
- !(node.nodeType === Node.ELEMENT_NODE && node.matches('svg,.svg-icon,.ldp-link-click-count')));
- if (!labelNodes.some((node) => String(node.textContent || '').trim())) return;
- const label = makeElement('span');
- label.className = 'ldp-inline-onebox-label';
- labelNodes.forEach((node) => label.append(node));
- const linkIcon = [...link.children].find((child) => child.matches('svg,.svg-icon'));
- if (linkIcon) linkIcon.after(label);
- else link.prepend(label);
- });
- }
-
- function prepareReaderOneboxes(root) {
- if (!root || !isFunction(root.querySelectorAll)) return;
- const githubSelector = 'aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"])';
- root.querySelectorAll(githubSelector).forEach((onebox) => {
- if (onebox.dataset.ldpGithubOneboxNormalized === '1') return;
- const header = onebox.querySelector(':scope > header.source');
- const body = onebox.querySelector(':scope > article.onebox-body');
- const title = body?.querySelector('h3');
- if (!header || !body || !title) return;
- const description = [...body.querySelectorAll('p')].find((paragraph) =>
- !paragraph.matches('.onebox-metadata') && !paragraph.closest('.onebox-metadata')
- );
- const thumbnail = body.querySelector('img.thumbnail');
- if (thumbnail) {
- header.querySelectorAll(':scope > :is(img,.site-icon)').forEach((iconNode) => iconNode.remove());
- thumbnail.className = 'site-icon ldp-github-onebox-logo';
- thumbnail.removeAttribute('width');
- thumbnail.removeAttribute('height');
- thumbnail.alt = '';
- header.prepend(thumbnail);
- }
- body.replaceChildren(title, ...(description ? [description] : []));
- onebox.dataset.ldpGithubOneboxNormalized = '1';
- });
- }
-
- function decoratePostLinkClickCounts(root, linkCounts) {
- if (!root || !Array.isArray(linkCounts) || !linkCounts.length) return;
- const countsByUrl = new Map();
- linkCounts.forEach((item) => {
- const clicks = Math.max(0, Math.trunc(Number(item?.clicks) || 0));
- const url = !item || item.reflection ? '' : normalizedPostLinkUrl(item.url);
- if (!url || !clicks) return;
- countsByUrl.set(url, Math.max(clicks, countsByUrl.get(url) || 0));
- });
- if (!countsByUrl.size) return;
- root.querySelectorAll('a[href]').forEach((link) => {
- if (link.querySelector(':scope > .ldp-link-click-count')) return;
- const onebox = link.closest('aside.onebox');
- if (onebox && link.closest('header.source')) {
- const titleLink = onebox.querySelector('.onebox-body h3 a[href]');
- if (titleLink && normalizedPostLinkUrl(titleLink.getAttribute('href')) === normalizedPostLinkUrl(link.getAttribute('href'))) return;
- }
- const clicks = countsByUrl.get(normalizedPostLinkUrl(link.getAttribute('href')));
- if (!clicks || !String(link.textContent || '').trim()) return;
- const count = makeElement('span');
- const label = `${clicks.toLocaleString('zh-CN')} 次点击`;
- count.className = 'ldp-link-click-count';
- count.setAttribute('role', 'note');
- setLabel(count, label);
- count.dataset.ldpTooltipLabel = label;
- count.textContent = clicks.toLocaleString('zh-CN');
- link.append(count);
- });
- }
-
- function setPostCookedContent(content, cooked, linkCounts) {
- const template = makeElement('template');
- template.innerHTML = String(cooked || '');
- prepareReaderHashtags(template.content);
- prepareReaderUserMentions(template.content);
- prepareReaderInlineOneboxes(template.content);
- prepareReaderOneboxes(template.content);
- prepareReaderCallouts(template.content);
- prepareReaderPostQuotes(template.content);
- prepareReaderCodeBlocks(template.content);
- prepareReaderMedia(template.content);
- decoratePostLinkClickCounts(template.content, linkCounts);
- destroyReaderMedia(content);
- content.replaceChildren(template.content);
- activateReaderMedia(content);
- }
-
- /* ============ 正文翻译(KISS Translator 架构参考,独立实现) ============ */
- const READER_TRANSLATION_CACHE_KEY = 'awesome-linuxdo-reader:translations:zh-CN:v1';
- const readReaderTranslationCache = () => {
- try {
- const entries = JSON.parse(localStorage.getItem(READER_TRANSLATION_CACHE_KEY) || '[]');
- return new Map(Array.isArray(entries) ? entries.slice(-240) : []);
- } catch { return new Map(); }
- };
- const READER_TRANSLATION_CACHE = readReaderTranslationCache();
- let readerTranslationCacheTimer = 0;
- function persistReaderTranslationCache() {
- if (readerTranslationCacheTimer) clearTimeout(readerTranslationCacheTimer);
- readerTranslationCacheTimer = 0;
- const entries = [...READER_TRANSLATION_CACHE].slice(-240);
- while (entries.length) {
- try {
- localStorage.setItem(READER_TRANSLATION_CACHE_KEY, JSON.stringify(entries));
- return;
- } catch {
- entries.splice(0, Math.min(20, entries.length));
- }
- }
- try { localStorage.removeItem(READER_TRANSLATION_CACHE_KEY); } catch {}
- }
- window.addEventListener('pagehide', persistReaderTranslationCache);
- const READER_TRANSLATION_BLOCKS = 'p,li,blockquote,h1,h2,h3,h4,h5,h6,figcaption,td,th';
- const READER_TRANSLATION_EXCLUDES =
- 'pre,code,kbd,samp,script,style,textarea,.onebox,.poll,.ldp-post-quote,.katex,.MathJax,.math,.ldp-translation-text';
-
- function readerTranslationSourceText(node) {
- if (!node) return '';
- const clone = node.cloneNode(true);
- clone.querySelectorAll(READER_TRANSLATION_EXCLUDES).forEach((item) => item.remove());
- return String(clone.textContent || '').replace(/\s+/g, ' ').trim();
- }
-
- function readerTranslationBlocks(content) {
- if (!content) return [];
- const candidates = [...content.querySelectorAll(READER_TRANSLATION_BLOCKS)]
- .filter((node) => !node.closest(READER_TRANSLATION_EXCLUDES));
- return candidates.filter((node) => {
- if (candidates.some((other) => other !== node && node.contains(other))) return false;
- return readerTranslationSourceText(node).length > 1;
- });
- }
-
- function readerTextIsChinese(text) {
- const letters = text.match(/\p{L}/gu) || [];
- const han = text.match(/\p{Script=Han}/gu) || [];
- const kanaOrHangul = text.match(/[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu) || [];
- return han.length >= 4 && kanaOrHangul.length < 2 && han.length / Math.max(1, letters.length) >= 0.45;
- }
-
- function readerBlockNeedsTranslation(text) {
- const letters = text.match(/\p{L}/gu) || [];
- if (letters.length < 2 || readerTextIsChinese(text)) return false;
- if (/^(?:RFC|ISO|IEC|IEEE|ECMA|W3C|WHATWG)\s*[-#:./]?\s*\d[\w./-]*$/i.test(text)) return false;
- if (/^[\w.-]{1,32}(?:\(\))?$/.test(text) &&
- (/[_-]/.test(text) || /^[A-Z\d.]+$/.test(text) || /[a-z][A-Z]/.test(text))) return false;
- if (/[=±×÷∑∏∫√≈≠≤≥→←↔^]/.test(text) && letters.length / text.length < 0.45) return false;
- if (/^(?:https?:\/\/|www\.|[@#])\S+$/i.test(text)) return false;
- const words = text.match(/\p{L}+(?:['’.-]\p{L}+)*/gu) || [];
- const sentenceLike = words.length >= 4 || text.length >= 32 || /[.!?。!?][”"'’)]?$/.test(text);
- if (!sentenceLike) return false;
- if (words.length <= 6 && words.length > 1 &&
- words.every((word) => /^\p{Lu}[\p{Ll}\p{M}]*$/u.test(word))) return false;
- return true;
- }
-
- function setReaderTranslationCache(text, translation) {
- READER_TRANSLATION_CACHE.delete(text);
- READER_TRANSLATION_CACHE.set(text, translation);
- while (READER_TRANSLATION_CACHE.size > 240) {
- READER_TRANSLATION_CACHE.delete(READER_TRANSLATION_CACHE.keys().next().value);
- }
- if (readerTranslationCacheTimer) clearTimeout(readerTranslationCacheTimer);
- readerTranslationCacheTimer = setTimeout(persistReaderTranslationCache, 300);
- }
-
- function getReaderTranslationCache(text) {
- const translation = READER_TRANSLATION_CACHE.get(text);
- if (translation == null) return null;
- READER_TRANSLATION_CACHE.delete(text);
- READER_TRANSLATION_CACHE.set(text, translation);
- return translation;
- }
-
- function createReaderTranslationController(ctx, button) {
- if (!siteAllowsBodyTranslation()) {
- button.hidden = true;
- return { syncPost() {}, cycleMode() {}, destroy() {} };
- }
- const queue = new Map();
- const requests = new Set();
- let mode = ['bilingual', 'translation'].includes(PREFS.translationMode)
- ? PREFS.translationMode : 'original';
- let active = mode !== 'original';
- let draining = false;
- let destroyed = false;
- let lastRequestAt = 0;
- let microsoftToken = '';
- let microsoftTokenAt = 0;
-
- const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
- const syncUi = () => {
- const busy = draining && active;
- button.classList.toggle('is-active', active);
- button.classList.toggle('is-busy', busy);
- button.setAttribute('aria-busy', String(busy));
- setPressed(button, active);
- setLabel(button, active
- ? `正文翻译:${mode === 'translation' ? '全译文' : '双语显示'}`
- : '翻译正文');
- };
- const applyMode = () => {
- ctx.commentsEl.classList.toggle('ldp-translation-active', active);
- ctx.commentsEl.classList.toggle('ldp-translation-only', active && mode === 'translation');
- syncUi();
- };
- const translationRequest = (requestOptions) => {
- const request = globalThis.GM_xmlhttpRequest;
- if (!isFunction(request)) return Promise.reject(new Error('油猴未提供跨域请求能力'));
- return new Promise((resolve, reject) => {
- let handle = null;
- const finish = (callback, value) => {
- if (handle) requests.delete(handle);
- callback(value);
- };
- handle = request({
- timeout: 20000,
- ...requestOptions,
- onload: (response) => {
- if (response.status >= 200 && response.status < 300) finish(resolve, response);
- else finish(reject, new Error(`翻译服务返回 ${response.status || '未知状态'}`));
- },
- ontimeout: () => finish(reject, new Error('翻译请求超时')),
- onabort: () => finish(reject, new Error('翻译请求已取消')),
- onerror: () => finish(reject, new Error('翻译请求失败')),
- });
- if (handle) requests.add(handle);
- });
- };
- const translateWithGoogle = async (texts) => {
- const url = new URL('https://translate.googleapis.com/translate_a/t');
- url.searchParams.set('client', 'dict-chrome-ex');
- url.searchParams.set('sl', 'auto');
- url.searchParams.set('tl', 'zh-CN');
- texts.forEach((text) => url.searchParams.append('q', text));
- const response = await translationRequest({ method: 'GET', url: url.href });
- const payload = JSON.parse(response.responseText || '[]');
- const translations = payload.map((item) => String(Array.isArray(item) ? item[0] || '' : ''));
- if (translations.length !== texts.length || translations.some((text) => !text)) {
- throw new Error('Google 返回的译文不完整');
- }
- return translations;
- };
- const microsoftAccessToken = async () => {
- if (microsoftToken && Date.now() - microsoftTokenAt < 8 * MINUTE) return microsoftToken;
- const response = await translationRequest({
- method: 'GET',
- url: 'https://edge.microsoft.com/translate/auth',
- });
- microsoftToken = String(response.responseText || '').trim();
- microsoftTokenAt = Date.now();
- if (!microsoftToken) throw new Error('Microsoft 未返回访问令牌');
- return microsoftToken;
- };
- const translateWithMicrosoft = async (texts) => {
- const token = await microsoftAccessToken();
- const response = await translationRequest({
- method: 'POST',
- url: 'https://api-edge.cognitive.microsofttranslator.com/translate?api-version=3.0&to=zh-Hans',
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json',
- },
- data: JSON.stringify(texts.map((text) => ({ Text: text }))),
- });
- const payload = JSON.parse(response.responseText || '[]');
- const translations = payload.map((item) => String(item?.translations?.[0]?.text || ''));
- if (translations.length !== texts.length || translations.some((text) => !text)) {
- throw new Error('Microsoft 返回的译文不完整');
- }
- return translations;
- };
- const translateBatch = async (texts) => {
- const largeBatch = texts.reduce((length, text) => length + text.length, 0) > 2800;
- const providers = largeBatch
- ? [translateWithMicrosoft, translateWithGoogle]
- : [translateWithGoogle, translateWithMicrosoft];
- let failure = null;
- for (let attempt = 0; attempt < providers.length && !destroyed; attempt++) {
- const wait = Math.max(0, 850 - (Date.now() - lastRequestAt));
- if (wait) await delay(wait);
- lastRequestAt = Date.now();
- try {
- return await providers[attempt](texts);
- } catch (error) {
- failure = error;
- if (attempt < providers.length - 1) await delay(1200 * (2 ** attempt));
- }
- }
- throw failure || new Error('翻译服务不可用');
- };
- const attachTranslation = (node, source, translation) => {
- if (!node || readerTranslationSourceText(node) !== source) return;
- let original = node.querySelector(':scope > .ldp-translation-original');
- let output = node.querySelector(':scope > .ldp-translation-text');
- if (!original) {
- original = makeElement('span');
- original.className = 'ldp-translation-original';
- while (node.firstChild) original.append(node.firstChild);
- node.append(original);
- }
- if (!output) {
- output = makeElement('span');
- output.className = 'ldp-translation-text';
- node.append(output);
- }
- node.classList.add('ldp-translation-source');
- output.lang = 'zh-CN';
- output.textContent = translation;
- };
- const queueBlock = (node) => {
- const text = readerTranslationSourceText(node);
- if (!readerBlockNeedsTranslation(text)) return;
- const cached = getReaderTranslationCache(text);
- if (cached != null) {
- attachTranslation(node, text, cached);
- return;
- }
- const entry = queue.get(text) || { text, nodes: new Set() };
- entry.nodes.add(node);
- queue.set(text, entry);
- };
- const nextBatch = () => {
- const entries = [];
- let characters = 0;
- for (const entry of queue.values()) {
- if (entries.length && (entries.length >= 20 || characters + entry.text.length > 3500)) break;
- queue.delete(entry.text);
- entries.push(entry);
- characters += entry.text.length;
- }
- return entries;
- };
- const drain = async () => {
- if (draining || destroyed || !active) return;
- draining = true;
- syncUi();
- await delay(120);
- try {
- while (queue.size && active && !destroyed) {
- const entries = nextBatch();
- const translations = await translateBatch(entries.map((entry) => entry.text));
- entries.forEach((entry, index) => {
- const translation = translations[index];
- setReaderTranslationCache(entry.text, translation);
- entry.nodes.forEach((node) => attachTranslation(node, entry.text, translation));
- });
- syncUi();
- }
- } catch (error) {
- showSelectionToast(`${error.message || '翻译失败'},请稍后重试`);
- } finally {
- draining = false;
- syncUi();
- }
- };
- const syncPost = (post) => {
- if (!post || destroyed) return;
- const postData = post._ldpPostData || {};
- const postType = +(postData.post_type == null ? 1 : postData.post_type);
- const username = String(postData.username || post.dataset.username || '').toLocaleLowerCase();
- if (postType !== 1 || postData.action_code || username === 'system' || username === 'discobot') return;
- const contents = [
- post.querySelector(':scope > .ldp-content'),
- ...post.querySelectorAll(':scope > .ldp-solved-card .ldp-solved-excerpt.ldp-content'),
- ].filter(Boolean);
- if (!contents.length || post.dataset.ldpContentHydrated === '0') return;
- const blocks = contents.flatMap(readerTranslationBlocks);
- if (!active) return;
- blocks.forEach(queueBlock);
- applyMode();
- void drain();
- };
- const syncMountedPosts = () => {
- ctx.commentsEl.querySelectorAll('.ldp-post').forEach(syncPost);
- };
- const setMode = (nextMode) => {
- setPref('translationMode', nextMode);
- if (nextMode === 'original') {
- active = false;
- queue.clear();
- } else {
- active = true;
- mode = nextMode === 'translation' ? 'translation' : 'bilingual';
- syncMountedPosts();
- }
- applyMode();
- };
- const cycleMode = () => {
- const nextMode = !active ? 'bilingual' : mode === 'bilingual' ? 'translation' : 'original';
- setMode(nextMode);
- showSelectionToast(nextMode === 'bilingual'
- ? '正文翻译:双语显示'
- : nextMode === 'translation' ? '正文翻译:全译文' : '已恢复原文');
- };
- const destroy = () => {
- destroyed = true;
- active = false;
- queue.clear();
- requests.forEach((request) => request?.abort?.());
- requests.clear();
- ctx.commentsEl.classList.remove('ldp-translation-active', 'ldp-translation-only');
- button.hidden = true;
- button.classList.remove('is-active', 'is-busy');
- };
- button.hidden = false;
- applyMode();
- requestAnimationFrame(syncMountedPosts);
- return { syncPost, cycleMode, destroy };
- }
-
- function renderNewUserBadge(postNode) {
- if (!postNode) return;
- const post = postNode._ldpPostData || {};
- const noticeType = postNoticeType(post);
- const isSystem = +post.post_type === 3 || String(post.username || '').trim().toLocaleLowerCase() === 'system';
- const isModerator = post.moderator === true || post.group_moderator === true;
- const identity = isSystem
- ? { label: '系统', title: '系统账户', icon: 'settings' }
- : isModerator
- ? { label: '版主', title: '版主', icon: 'shieldHalved' }
- : postNoticeIdentity(post, noticeType);
- const isNewUser = noticeType === 'new_user';
- postNode.classList.toggle('ldp-new-user', isNewUser);
- const head = postNode.querySelector(':scope > .ldp-post-head');
- let badge = head?.querySelector(':scope > .ldp-new-user-badge');
- if (!identity) {
- if (badge) badge.remove();
- return;
- }
- if (!badge && head) {
- badge = makeElement('span');
- badge.className = 'ldp-new-user-badge';
- const username = head.querySelector(':scope > .ldp-user');
- if (username) username.after(badge);
- else head.append(badge);
- }
- if (badge) {
- badge.setAttribute('role', 'img');
- setLabel(badge, identity.title);
- badge.innerHTML = `${icon(identity.icon)}
${identity.label}`;
- }
- }
-
- function renderPostSpecialContent(postNode, ctx) {
- renderNewUserBadge(postNode);
- renderSpecialPostState(postNode);
- renderSolvedAnswerCard(postNode, ctx);
- renderPostReactions(postNode, ctx);
- renderPostEvent(postNode);
- renderPostVoting(postNode, ctx);
- const post = postNode?._ldpPostData || {};
- if (post.accepted_answer && ctx && ctx.streamNodeMap) {
- const firstPost = ctx.streamNodeMap.get(1);
- if (firstPost && firstPost !== postNode) renderSolvedAnswerCard(firstPost, ctx);
- }
- hydratePersistentAvatarImages(postNode);
- }
-
- function comparePostVotingStreamItems(left, right) {
- if (left.postNumber === 1 || right.postNumber === 1)
- return left.postNumber === right.postNumber ? 0 : (left.postNumber === 1 ? -1 : 1);
- return +(right.node._ldpPostData.post_voting_vote_count || 0) -
- +(left.node._ldpPostData.post_voting_vote_count || 0) || left.postNumber - right.postNumber;
- }
-
- function syncSpecialPostData(postNode, ctx, fresh) {
- if (!postNode || !fresh) return;
- let latestPostData = null;
- readerPostCopies(postNode.dataset.postId, ctx, true).forEach((copy) => {
- const previous = copy._ldpPostData || {};
- copy._ldpPostData = Object.assign({}, previous, fresh);
- latestPostData = copy._ldpPostData;
- renderPostSpecialContent(copy, ctx);
- const like = readerPostLikeAction(copy._ldpPostData, ctx);
- readerPostOwnControls(copy, '.ldp-like').forEach((button) => syncReaderLikeButton(button, like));
- });
- const railLike = ctx?.topicActionRail?.querySelector('.ldp-like');
- if (railLike && latestPostData && +latestPostData.post_number === 1) {
- ctx.topicActionRail._ldpPostData = latestPostData;
- syncReaderLikeButton(railLike, readerPostRailReactionAction(latestPostData, ctx));
- renderPostReactions(ctx.topicActionRail, ctx, { collectLike: false, railPicker: true });
- ctx.topicActionRail.querySelector(':scope > .ldp-reactions > .ldp-reaction-summary')?.remove();
- }
- if (latestPostData) persistReaderPostState(ctx, latestPostData);
- if (ctx?.isPostVoting) {
- ctx.streamItems.sort(comparePostVotingStreamItems);
- reindexStreamItems(ctx, 0);
- invalidateStreamLayout(ctx);
- scheduleStreamWindowSync(ctx);
- }
- }
-
- async function fetchPostVotingComments(postNode, ctx) {
- const postId = postNode && postNode.dataset.postId;
- if (!postId) return [];
- return runReaderActionOnce(ctx, `post:${postId}:pv-comments-load`, async () => {
- const copies = readerPostCopies(postId, ctx);
- const sourcePosts = (copies.length ? copies : [postNode]).map((copy) => copy._ldpPostData || {});
- const loaded = sourcePosts.map(postVotingComments).sort((a, b) => b.length - a.length)[0] || [];
- const lastId = loaded.length ? loaded[loaded.length - 1].id : '';
- const url = new URL(`${BASE}/post_voting/comments`);
- url.searchParams.set('post_id', postId);
- if (lastId) url.searchParams.set('last_comment_id', lastId);
- const data = await fetchJSON(url.toString());
- const incoming = Array.isArray(data) ? data : (data.comments || []);
- const merged = new Map(loaded.map((comment) => [String(comment.id), comment]));
- incoming.forEach((comment) => merged.set(String(comment.id), comment));
- const comments = [...merged.values()];
- syncPostVotingComments(postId, ctx, comments);
- return comments;
- });
- }
-
- function normalizeBoosts(boosts) {
- const list = Array.isArray(boosts) ? boosts : boosts ? [boosts] : [];
- return list.map((boost) => {
- if (!boost) return null;
- const user = boost.user || {};
- const raw = boost.raw || '';
- const cooked = boost.cooked || (raw ? `
${esc(raw)}
` : '');
- if (!cooked) return null;
- const username = user.username || boost.username || ME_USERNAME || '';
- return {
- id: boost.id == null ? '' : String(boost.id),
- userId: +(user.id || boost.user_id || 0),
- username,
- name: user.name || boost.name || username,
- avatarTemplate: user.avatar_template || boost.avatar_template || boost.avatarTemplate || boost.avatar || '',
- admin: user.admin === true || boost.admin === true,
- moderator: user.moderator === true || user.group_moderator === true ||
- boost.moderator === true || boost.group_moderator === true,
- noticeType: String(boost.notice?.type || boost.notice_type || ''),
- cooked,
- };
- }).filter(Boolean);
- }
-
- function boostBelongsToCurrentUser(boost) {
- const currentUserId = +(ME_CURRENT_USER?.id || 0);
- if (currentUserId && +boost.userId) return currentUserId === +boost.userId;
- return !!(
- ME_USERNAME && boost.username &&
- String(boost.username).toLowerCase() === String(ME_USERNAME).toLowerCase()
- );
- }
-
- function boostIdentities(boost, ctx) {
- const identities = [];
- const username = String(boost?.username || '');
- const usernameKey = username.toLocaleLowerCase();
- let sourcePost = {};
- if (usernameKey && ctx) {
- const cachedPosts = ctx.loader && Array.isArray(ctx.loader.cachedPosts) ? ctx.loader.cachedPosts : [];
- sourcePost = cachedPosts.concat(postsFromPayload(ctx.topicData)).find((post) => {
- if (String(post?.username || '').toLocaleLowerCase() !== usernameKey) return false;
- return post.admin === true || post.moderator === true || post.group_moderator === true ||
- ['new_user', 'returning_user', 'custom'].includes(postNoticeType(post));
- }) || {};
- }
- const noticeType = postNoticeType(sourcePost) || boost.noticeType;
- const isAdmin = boost.admin === true || sourcePost.admin === true;
- const isModerator = boost.moderator === true || sourcePost.moderator === true ||
- sourcePost.group_moderator === true;
- if (boostBelongsToCurrentUser(boost))
- identities.push({ type: 'me', label: 'ME', title: '当前用户', icon: 'userRound' });
- if (usernameKey && String(ctx?.op || '').toLocaleLowerCase() === usernameKey)
- identities.push({ type: 'op', label: 'OP', title: '楼主', icon: 'award' });
- if (isAdmin)
- identities.push({ type: 'admin', label: '管理员', title: '管理员', icon: 'shieldHalved' });
- else if (isModerator)
- identities.push({ type: 'moderator', label: '版主', title: '版主', icon: 'shieldHalved' });
- const noticeIdentity = postNoticeIdentity(sourcePost, noticeType);
- if (noticeIdentity) {
- const type = noticeType === 'new_user' ? 'new' : noticeType === 'returning_user' ? 'return' : 'custom';
- identities.push(Object.assign({ type }, noticeIdentity));
- }
- return identities;
- }
-
- function renderBoostIdentities(boost, ctx) {
- const identities = boostIdentities(boost, ctx);
- if (!identities.length) return '';
- return `
${identities.map((identity) =>
- `${icon(identity.icon)}${esc(identity.label)}`
- ).join('')}`;
- }
-
- function renderBoostBubble(boost, allowQuickBoost = false, ctx = null) {
- const userCardAttr = boost.username ? `data-user-card="${escAttr(boost.username)}"` : '';
- const avatarLabel = boost.username ? ` aria-label="@${escAttr(boost.username)}"` : '';
- const avatar = boost.avatarTemplate
- ? persistentAvatarHtml(
- boost.avatarTemplate, 24, 'ldp-boost-avatar', boost.name || boost.username || '?', { eager: true }
- )
- : icon('rocket', 'ldp-boost-fallback-icon');
- const avatarHtml = boost.username
- ? `
${avatar}`
- : avatar;
- const quickBoost = allowQuickBoost && boost.username && (!ME_USERNAME || boost.username !== ME_USERNAME)
- ? '
'
- : '';
- const isOwnBoost = boostBelongsToCurrentUser(boost);
- const signedIn = !!(ME_CURRENT_USER?.id || ME_USERNAME);
- const mentionAction = signedIn && boost.username
- ? `
`
- : '';
- const itemAction = signedIn && boost.id
- ? (isOwnBoost
- ? `
`
- : `
`)
- : '';
- const identities = renderBoostIdentities(boost, ctx);
- return `
${avatarHtml}${identities}${readerMediaHtml(boost.cooked)}${quickBoost}${mentionAction}${itemAction}`;
- }
-
- function renderBoostList(boosts, options = {}) {
- const items = normalizeBoosts(boosts);
- const allowQuickBoost = options.canQuickBoost === true &&
- !items.some((boost) => ME_USERNAME && boost.username === ME_USERNAME);
- return `
${items.map((boost) => renderBoostBubble(boost, allowQuickBoost, options.ctx)).join('')}
`;
- }
-
- function syncKnownBoostIdentities(ctx, usernames) {
- if (!ctx || !ctx.modal) return;
- const usernameKeys = new Set((Array.isArray(usernames) ? usernames : [usernames])
- .map((username) => String(username || '').toLocaleLowerCase()).filter(Boolean));
- if (!usernameKeys.size) return;
- ctx.modal.querySelectorAll('.ldp-boost-bubble').forEach((bubble) => {
- const username = String(bubble.dataset.boostUser || '');
- if (!usernameKeys.has(username.toLocaleLowerCase())) return;
- const boost = {
- userId: +(bubble.dataset.boostUserId || 0),
- username,
- admin: bubble.dataset.boostAdmin === '1',
- moderator: bubble.dataset.boostModerator === '1',
- };
- const holder = makeElement('template');
- holder.innerHTML = renderBoostIdentities(boost, ctx);
- const next = holder.content.firstElementChild;
- const current = bubble.querySelector(':scope > .ldp-boost-identities');
- if (current && next) current.replaceWith(next);
- else if (current) current.remove();
- else if (next) {
- const avatar = bubble.querySelector(':scope > .ldp-boost-avatar-link,:scope > .ldp-boost-fallback-icon');
- if (avatar) avatar.after(next);
- else bubble.prepend(next);
- }
- });
- }
-
- function boostBubblePlainText(bubble) {
- const cooked = bubble?.querySelector('.ldp-boost-cooked');
- if (!cooked) return '';
- const copy = cooked.cloneNode(true);
- copy.querySelectorAll('img[alt]').forEach((image) => image.replaceWith(document.createTextNode(image.alt || '')));
- return String(copy.textContent || '').replace(/\s+/g, ' ').trim();
- }
-
- function boostCopyRegExpEscape(value) {
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- }
-
- async function quoteBoostInNativeReply(bubble, post, ctx) {
- const username = cleanUsernameValue(bubble?.dataset.boostUser);
- const content = boostBubblePlainText(bubble);
- if (!username || !content) {
- showSelectionToast('无法读取该 Boost 的用户或内容');
- return false;
- }
- if (!await tryOpenNativeReplyComposer(post, ctx)) return false;
- const composer = lookupDiscourse('service:composer');
- const reply = String(discourseModelValue(discourseModelValue(composer, 'model'), 'reply') || '');
- const mentionPattern = new RegExp(
- `(^|[^A-Za-z0-9_@-])@${boostCopyRegExpEscape(username)}(?=$|[^A-Za-z0-9_-])`, 'i'
- );
- if (mentionPattern.test(reply)) {
- focusReaderNativeComposerInput(ctx);
- showSelectionToast(`回复框中已有 @${username}`);
- return true;
- }
- const quoteHeader = `${username}, post:${+post.dataset.postNumber}, topic:${+ctx.topicId}, username:${username}`;
- const insertion = `[quote="${quoteHeader}"]\n${content}\n[/quote]\n\n@${username} `;
- const appEvents = composer.appEvents || lookupDiscourse('service:app-events');
- appEvents.trigger('composer:insert-block', insertion);
- focusReaderNativeComposerInput(ctx);
- showSelectionToast(`已引用 Boost 并 @${username}`);
- return true;
- }
-
- function fitBoostCopyParts(prefix, base, suffix) {
- const suffixChars = [...suffix].slice(0, BOOST_COPY_MAX_LENGTH);
- const prefixChars = [...prefix].slice(0, BOOST_COPY_MAX_LENGTH - suffixChars.length);
- const baseChars = [...base].slice(0, BOOST_COPY_MAX_LENGTH - suffixChars.length - prefixChars.length);
- return `${prefixChars.join('')}${baseChars.join('')}${suffixChars.join('')}`;
- }
-
- function applyBoostCopyRule(raw, prefs = PREFS) {
- const config = boostCopyConfigFromPrefs(prefs);
- let base = String(raw || '').replace(/\s+/g, ' ').trim();
- if (config.prefix && base.startsWith(config.prefix)) base = base.slice(config.prefix.length);
- let suffix = config.fixedSuffix;
- if (config.mode === 'counter') {
- const countPattern = new RegExp(`${boostCopyRegExpEscape(config.counterMarker)}(\\d+)$`);
- const countMatch = base.match(countPattern);
- const currentCount = countMatch ? Number(countMatch[1]) : 0;
- const nextCount = Number.isSafeInteger(currentCount)
- ? currentCount + config.counterStep
- : config.counterStep;
- if (countMatch) base = base.slice(0, -countMatch[0].length);
- suffix = `${config.counterMarker}${nextCount}`;
- } else if (suffix && base.endsWith(suffix))
- base = base.slice(0, -suffix.length);
- return fitBoostCopyParts(config.prefix, base, suffix);
- }
-
- function syncCurrentUserFromHost(options = {}) {
- const currentUser = lookupDiscourse('service:current-user');
- if (!currentUser) return ME_USERNAME || '';
- ME_CURRENT_USER = currentUser;
- ME_USERNAME = String(discourseModelValue(currentUser, 'username') || '');
- if (options.syncUnread !== false) {
- const unreadCount = currentUserUnreadNotificationCount(currentUser);
- syncNotificationUnreadUi(unreadCount == null ? cachedUnreadNotificationCount() : unreadCount);
- }
- return ME_USERNAME;
- }
- function canShowBoostButton(p) {
- return !!(hasDiscourseCapability('boosts', { post: p }) &&
- p && p.can_boost === true && (!ME_USERNAME || p.username !== ME_USERNAME));
- }
-
- const TOPIC_NOTIFICATION_LEVELS = [
- { value: 1, label: '常规' },
- { value: 2, label: '跟踪' },
- { value: 3, label: '关注' },
- { value: 0, label: '已屏蔽' },
- ];
-
- function topicNotificationLevel(topic) {
- const value = Number(topic && (
- topic.notification_level ??
- (topic.details?.notification_level)
- ));
- return Number.isInteger(value) && value >= 0 && value <= 3 ? value : 1;
- }
-
- function topicActionCapabilities(post, ctx) {
- const data = post || {};
- const topic = (ctx?.topicData) || {};
- const actions = Array.isArray(data.actions_summary) ? data.actions_summary : [];
- const hasFlagAction = actions.some((action) =>
- action && action.can_act === true && ![2, 8].includes(+action.id)
- );
- const signedIn = !!ME_USERNAME;
- const isOwnPost = signedIn && data.username === ME_USERNAME;
- const canReport = signedIn && !isOwnPost && data.can_flag !== false &&
- (data.can_flag === true || hasFlagAction || actions.length <= 1);
- const canAssign = !!(
- data.can_assign === true ||
- topic.can_assign === true ||
- (topic.details && topic.details.can_assign === true)
- );
- const currentUser = ME_CURRENT_USER || {};
- const details = topic.details || {};
- const canAdmin = signedIn && !!(
- currentUser.staff || currentUser.can_manage_topic || currentUser.canManageTopic ||
- currentUser.can_change_post_owner || currentUser.canChangePostOwner ||
- data.can_manage === true || data.can_wiki === true || details.can_edit_staff_notes === true
- );
- return {
- canReport,
- canAssign,
- canBookmark: signedIn,
- canEdit: data.can_edit === true,
- canDelete: data.can_delete === true,
- canAdmin,
- };
- }
-
- function postBookmarkInfo(post) {
- const data = post || {};
- const bookmarkId = data.bookmark_id || (data.bookmark?.id) || null;
- return { bookmarked: !!(data.bookmarked || bookmarkId), bookmarkId };
- }
-
- const POST_CONTEXT_ACTIONS = Object.freeze([
- { name: 'share', event: 'post', action: 'share', label: '复制楼层链接', icon: 'link2' },
- { name: 'report', event: 'reader', action: 'report', label: '举报', icon: 'flag', capability: 'canReport' },
- { name: 'edit', event: 'post', action: 'edit', label: '编辑', icon: 'pencil', capability: 'canEdit' },
- { name: 'bookmark', capability: 'canBookmark' },
- { name: 'delete', event: 'post', action: 'delete', label: '删除', icon: 'trash', capability: 'canDelete' },
- { name: 'assign', event: 'reader', action: 'assign-post', label: '指定楼层负责人', icon: 'userPlus', capability: 'canAssign' },
- { name: 'admin', event: 'post', action: 'admin', label: '管理楼层', icon: 'wrench', capability: 'canAdmin' },
- ]);
-
- function postContextActionsHtml(post, ctx) {
- const capabilities = topicActionCapabilities(post, ctx);
- const isTopicStarter = Number(post?.post_number) === 1;
- const bookmarked = isTopicStarter
- ? !!(ctx?.topicBookmarked)
- : postBookmarkInfo(post).bookmarked;
- const bookmarkButton = isTopicStarter
- ? `
`
- : `
`;
- return POST_CONTEXT_ACTIONS.map((item) => item.name === 'bookmark' ? bookmarkButton :
- `
`
- ).join('\n');
- }
-
- function postContextActionCount(post, ctx) {
- const capabilities = topicActionCapabilities(post, ctx);
- return POST_CONTEXT_ACTIONS.filter((item) =>
- !item.capability || capabilities[item.capability]
- ).length;
- }
-
- function compactPostActionsHtml(post, ctx) {
- const count = postContextActionCount(post, ctx);
- const eager = isFunction(matchMedia) && matchMedia('(hover: none)').matches;
- return `
-
- ${canShowBoostButton(post) ? `
` : ''}
-
${eager ? postContextActionsHtml(post, ctx) : ''}`;
- }
-
- function hydratePostContextActions(postNode, ctx) {
- if (!postNode) return;
- const slot = postNode.querySelector(':scope > .ldp-reactions .ldp-context-actions-slot');
- if (!slot || slot.dataset.ldpContextActions === '1') return;
- slot.innerHTML = postContextActionsHtml(postNode._ldpPostData || {}, ctx);
- slot.dataset.ldpContextActions = '1';
- slot.removeAttribute('aria-hidden');
- syncPostActionControls(postNode, ctx);
- }
-
- function nativeTopicSharedIssueVisible(topic) {
- if (!topic || topic.shared_issue_visible !== true) return false;
- const hasAcceptedAnswers = Array.isArray(topic.accepted_answers) && topic.accepted_answers.length > 0;
- const siteSettings = lookupDiscourse('service:site-settings');
- return !hasAcceptedAnswers || siteSettings?.solved_allow_multiple_solutions === true;
- }
-
- function topicSharedIssueState(post, ctx) {
- const topic = ctx?.topicData || {};
- const count = Math.max(0, Number(topic.shared_issue_count) || 0);
- return {
- visible: hasDiscourseCapability('sharedIssue', { topic }) &&
- ctx?.topicSharedIssueHostVisible === true &&
- !SHARED_ISSUE_FORBIDDEN_TOPIC_IDS.has(Number(ctx?.topicId)),
- active: topic.user_created_shared_issue === true,
- count,
- isAuthor: !!ME_USERNAME && !!post && String(post.username || '') === String(ME_USERNAME),
- };
- }
-
- function topicSharedIssueLabel(state) {
- return `俺也一样(${state.count})`;
- }
-
- function topicFooterActionsHtml(post, ctx) {
- const capabilities = topicActionCapabilities(post, ctx);
- const bookmarked = !!ctx.topicBookmarked;
- const sharedIssue = topicSharedIssueState(post, ctx);
- const sharedIssueLabel = topicSharedIssueLabel(sharedIssue);
- const level = Number.isInteger(ctx.topicNotificationLevel)
- ? ctx.topicNotificationLevel
- : topicNotificationLevel(ctx.topicData);
- const notificationOptions = TOPIC_NOTIFICATION_LEVELS.map((item) =>
- `
`
- ).join('');
- return `
- `;
- }
-
- function syncPostActionControls(postNode, ctx, options = {}) {
- if (!postNode) return;
- const post = postNode._ldpPostData || {};
- const capabilities = topicActionCapabilities(post, ctx);
- const { bookmarked } = postBookmarkInfo(post);
- const actions = postNode.querySelector(':scope > .ldp-reactions .ldp-actions');
- if (!actions) return;
- const contextSlot = actions.querySelector('.ldp-context-actions-slot');
- if (contextSlot)
- contextSlot.style.setProperty('--ldp-context-action-count', String(postContextActionCount(post, ctx)));
- actions.querySelectorAll('.ldp-post-bookmark').forEach((button) => {
- button.classList.toggle('on', bookmarked);
- button.hidden = !capabilities.canBookmark;
- button.disabled = options.bookmarkBusy === true;
- setPressed(button, bookmarked ? 'true' : 'false');
- setLabel(button, bookmarked ? '已收藏该楼层' : '收藏该楼层');
- });
- POST_CONTEXT_ACTIONS.forEach((item) => {
- if (!item.capability || item.name === 'bookmark') return;
- actions.querySelectorAll(`.ldp-post-${item.name}`).forEach((element) => {
- element.hidden = !capabilities[item.capability];
- });
- });
- const showBoostButton = canShowBoostButton(post);
- let boostButton = actions.querySelector('.ldp-boostbtn');
- const canCreateBoostButton = postNode === ctx?.topicActionRail ||
- ctx?.streamNodeMap?.get(+(postNode.dataset.postNumber || 0)) === postNode;
- if (showBoostButton && !boostButton && canCreateBoostButton) {
- const holder = makeElement('template');
- holder.innerHTML = `
`;
- boostButton = holder.content.firstElementChild;
- actions.insertBefore(boostButton, contextSlot || actions.querySelector('.ldp-topic-bookmark'));
- }
- if (boostButton) boostButton.hidden = !showBoostButton;
- }
-
- function syncAllPostActionControls(ctx) {
- if (!ctx || !ctx.modal) return;
- ctx.modal.querySelectorAll('.ldp-post').forEach((postNode) => {
- syncPostActionControls(postNode, ctx);
- });
- if (ctx.topicActionRail) { ctx.topicActionRail._ldpPostData = ctx.streamNodeMap?.get(1)?._ldpPostData || ctx.topicActionRail._ldpPostData; syncPostActionControls(ctx.topicActionRail, ctx); }
- }
-
- function topicActionPostNodes(ctx) {
- const nodes = new Set();
- const streamNode = ctx?.streamNodeMap && ctx.streamNodeMap.get(1);
- if (streamNode) nodes.add(streamNode);
- if (ctx?.topicActionRail?._ldpRelatedPost) nodes.add(ctx.topicActionRail);
- if (ctx?.modal)
- ctx.modal.querySelectorAll('.ldp-post[data-post-number="1"]').forEach((node) => nodes.add(node));
- return nodes;
- }
-
- function syncTopicActionControls(ctx, options = {}) {
- if (!ctx) return;
- const bookmarked = !!ctx.topicBookmarked;
- const level = Number.isInteger(ctx.topicNotificationLevel)
- ? ctx.topicNotificationLevel
- : topicNotificationLevel(ctx.topicData);
- topicActionPostNodes(ctx).forEach((postNode) => {
- const capabilities = topicActionCapabilities(postNode._ldpPostData, ctx);
- const sharedIssue = topicSharedIssueState(postNode._ldpPostData, ctx);
- const sharedIssueLabel = topicSharedIssueLabel(sharedIssue);
- readerPostOwnControls(postNode, '.ldp-topic-shared-issue').forEach((button) => {
- button.hidden = !sharedIssue.visible;
- button.classList.toggle('on', sharedIssue.active);
- button.disabled = ctx.topicSharedIssueBusy === true || sharedIssue.isAuthor;
- setPressed(button, sharedIssue.active ? 'true' : 'false');
- setLabel(button, sharedIssueLabel);
- const count = button.querySelector('.ldp-topic-shared-issue-count');
- if (count) count.textContent = `(${sharedIssue.count})`;
- });
- readerPostOwnControls(postNode, '.ldp-topic-footer-separator').forEach((separator) => {
- separator.hidden = !sharedIssue.visible;
- });
- readerPostOwnControls(postNode, '.ldp-topic-bookmark').forEach((button) => {
- button.classList.toggle('on', bookmarked);
- setPressed(button, bookmarked ? 'true' : 'false');
- setLabel(button, bookmarked ? '已收藏' : '收藏本帖');
- const label = button.querySelector('span');
- if (label) label.textContent = bookmarked ? '已收藏' : '添加为书签';
- button.disabled = options.bookmarkBusy === true;
- });
- readerPostOwnControls(postNode, '.ldp-topic-report').forEach((element) => {
- element.hidden = !capabilities.canReport;
- });
- readerPostOwnControls(postNode, '.ldp-topic-assign').forEach((element) => {
- element.hidden = !capabilities.canAssign;
- });
- readerPostOwnControls(postNode, '.ldp-topic-notification').forEach((control) => {
- control.classList.toggle('busy', options.notificationBusy === true);
- const item = TOPIC_NOTIFICATION_LEVELS.find((option) => option.value === level);
- setLabel(control, `通知:${item ? item.label : '常规'}`);
- });
- readerPostOwnControls(postNode, '.ldp-topic-notification-select').forEach((select) => {
- select.value = String(level);
- select.disabled = options.notificationBusy === true || !ME_USERNAME;
- });
- });
- }
-
- function ensureParentJumpControl(post, parentNumber) {
- const head = post.querySelector(':scope > .ldp-post-head');
- if (!head || head.querySelector(':scope > .ldp-jump-parent')) return;
- const currentFloor = head.querySelector(':scope > .ldp-floor');
- const parentFloor = makeElement('button');
- parentFloor.className = 'ldp-floor ldp-jump-parent';
- parentFloor.type = 'button';
- parentFloor.dataset.targetPostNumber = String(parentNumber);
- setLabel(parentFloor, `跳到父楼层 #${parentNumber}`);
- parentFloor.textContent = `@ #${parentNumber}`;
- if (currentFloor) currentFloor.classList.add('ldp-current-floor');
- head.insertBefore(parentFloor, currentFloor || null);
- }
-
- function ensureStreamNestedReplyPresentation(post, parentPostNumber) {
- const parentNumber = +parentPostNumber;
- if (!post || !parentNumber) return;
- post.classList.add('ldp-reply', 'ldp-reply-collapsible');
- post.dataset.replyToPostNumber = String(parentNumber);
-
- ensureNestedReplyToggle(post, post.firstChild);
- ensureParentJumpControl(post, parentNumber);
- }
-
- function isEscCollapsibleNestedReply(post) {
- if (!post || !post.classList.contains('ldp-reply-collapsible') ||
- post.classList.contains('ldp-nested-preview')) return false;
- return !(+(post._ldpPostData?.reply_count || 0) > 0) &&
- !(+(post.dataset.childReplyTotal || 0) > 0);
- }
-
- function syncCollapsedNestedReplyZebra(post, options = {}) {
- const ctx = post?._ldpReaderContext;
- const postNumber = +(post && post.dataset.postNumber || 0);
- const item = ctx?.streamItemMap && ctx.streamItemMap.get(postNumber);
- if (!item || item.node !== post) return;
- const excluded = !!post._ldpPostData?.hidden || post.classList.contains('ldp-nested-collapsed');
- if (options.deferZebraSync) return;
- if (item.zebraExcluded === excluded) {
- if (excluded) post.classList.remove('ldp-zebra-alt');
- return;
- }
- item.zebraExcluded = excluded;
- reindexStreamItems(ctx, Math.max(0, item.index));
- if (ctx.onlyOp) invalidateStreamLayout(ctx);
- else markStreamPrefixDirty(ctx, Math.max(0, item.index));
- refreshStreamZebra(ctx);
- scheduleStreamWindowSync(ctx);
- }
-
- function setNestedReplyCollapsed(post, collapsed, options = {}) {
- if (!post || (!post.classList.contains('ldp-reply-collapsible') &&
- !post.classList.contains('ldp-hidden-collapsible'))) return;
- if (collapsed) {
- let line = null;
- let preview = post.previousElementSibling;
- if (preview && preview.classList.contains('ldp-floor-preview-link')) {
- line = preview;
- preview = preview.previousElementSibling;
- }
- if (line) line.classList.remove('open');
- if (preview && preview.classList.contains('ldp-floor-preview')) preview.classList.remove('open');
- }
- if (collapsed) post.querySelector(':scope > .ldp-nested-esc-hint')?.remove();
- post.classList.toggle('ldp-nested-collapsed', collapsed);
- syncCollapsedNestedReplyZebra(post, options);
- const parentFloor = post.querySelector(':scope > .ldp-post-head > .ldp-jump-parent');
- if (parentFloor) {
- const parentNumber = +parentFloor.dataset.targetPostNumber || 0;
- setLabel(parentFloor, collapsed
- ? `展开楼层并预览父楼层 #${parentNumber}`
- : `跳到父楼层 #${parentNumber}`);
- }
- const btn = post.querySelector(':scope > .ldp-nested-toggle');
- if (!btn) return;
- const postNumber = post.dataset.postNumber || '';
- const controlKey = `${collapsed ? 'collapsed' : 'open'}:${postNumber}`;
- if (btn.dataset.nestedControlKey !== controlKey) {
- btn.dataset.nestedControlKey = controlKey;
- setExpanded(btn, collapsed ? 'false' : 'true');
- setLabel(btn, collapsed ? `展开楼层 #${postNumber}` : `收起楼层 #${postNumber}`);
- btn.innerHTML = `${icon(collapsed ? 'chevronRight' : 'chevronDown')}
${collapsed ? '展开' : '收起'}`;
- }
- setNestedReplyEscapeHint(post, !collapsed && isEscCollapsibleNestedReply(post));
- }
-
- function setNestedReplyEscapeHint(post, visible) {
- if (!post) return;
- let hint = post.querySelector(':scope > .ldp-nested-esc-hint');
- if (!visible) {
- hint?.remove();
- return;
- }
- const toggle = post.querySelector(':scope > .ldp-nested-toggle');
- if (!toggle) return;
- if (!hint) {
- hint = makeElement('span');
- hint.className = 'ldp-nested-esc-hint';
- toggle.insertAdjacentElement('afterend', hint);
- }
- hint.textContent = 'Esc 收起';
- }
-
- function alignCollapsedFloorPreview(preview, post, collapsedBottom, ctx) {
- if (!preview || !post || !ctx?.scrollRoot || !preview.isConnected || !post.isConnected ||
- !Number.isFinite(collapsedBottom)) return;
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const anchoredBottom = Math.max(rootRect.top, Math.min(rootRect.bottom, collapsedBottom));
- const previewRect = preview.getBoundingClientRect();
- const postRect = post.getBoundingClientRect();
- const availableHeight = Math.max(0, anchoredBottom - rootRect.top);
- const combinedHeight = Math.max(0, postRect.bottom - previewRect.top);
- const correction = combinedHeight > availableHeight + 1
- ? previewRect.top - rootRect.top
- : postRect.bottom - anchoredBottom;
- if (Math.abs(correction) < 1) return;
- const previousScrollTop = ctx.scrollRoot.scrollTop;
- ctx.scrollRoot.scrollTop += correction;
- markStreamInternalScroll(ctx, previousScrollTop);
- scheduleStreamWindowSync(ctx);
- }
-
- function keepOnlyOpPostExpanded(post, ctx) {
- if (!post || !ctx || !ctx.onlyOp || !ctx.op ||
- post._ldpPostData?.hidden || String(post.dataset.username || '') !== String(ctx.op)) return;
- setNestedReplyCollapsed(post, false);
- if (post.classList.contains('ldp-children-collapsed')) {
- post.classList.remove('ldp-children-collapsed');
- syncChildControls(post);
- }
- }
-
- let activeJumpHighlight = null;
-
- function syncFirstPostJumpHighlightRegion(target) {
- if (!target || !target.matches('.ldp-post[data-post-number="1"]')) return null;
- const reactions = target.querySelector(':scope > .ldp-reactions');
- if (!reactions) return null;
- const targetRect = target.getBoundingClientRect();
- const reactionsRect = reactions.getBoundingClientRect();
- const highlightHeight = Math.min(
- targetRect.height,
- Math.max(0, reactionsRect.bottom - targetRect.top)
- );
- target.style.setProperty('--ldp-first-post-jump-highlight-height', `${Math.ceil(highlightHeight)}px`);
- return reactions;
- }
-
- function clearActiveJumpHighlight() {
- if (!activeJumpHighlight) return;
- clearTimeout(activeJumpHighlight.timer);
- if (activeJumpHighlight.resizeObserver) activeJumpHighlight.resizeObserver.disconnect();
- activeJumpHighlight.target.classList.remove('ldp-jump-highlight');
- activeJumpHighlight.target.style.removeProperty('--ldp-first-post-jump-highlight-height');
- if (activeJumpHighlight.target.dataset.jumpHighlightToken === activeJumpHighlight.token)
- delete activeJumpHighlight.target.dataset.jumpHighlightToken;
- activeJumpHighlight = null;
- }
-
- function keepJumpHighlightUntilSettled(target) {
- if (!target) return;
- clearActiveJumpHighlight();
- const token = `${Date.now()}-${Math.random()}`;
- target.classList.remove('ldp-jump-highlight');
- target.style.removeProperty('--ldp-first-post-jump-highlight-height');
- const firstPostReactions = syncFirstPostJumpHighlightRegion(target);
- void target.offsetWidth;
- target.dataset.jumpHighlightToken = token;
- target.classList.add('ldp-jump-highlight');
- let resizeObserver = null;
- if (firstPostReactions && isFunction(ResizeObserver)) {
- resizeObserver = new ResizeObserver(() => {
- if (target.isConnected && target.dataset.jumpHighlightToken === token)
- syncFirstPostJumpHighlightRegion(target);
- });
- resizeObserver.observe(target);
- resizeObserver.observe(firstPostReactions);
- }
- const config = jumpHighlightConfigFromPrefs(PREFS);
- const reducedMotion = prefersReducedMotion();
- const lifetime = reducedMotion ? 1000 : jumpHighlightDurationMs(config) * config.count;
- const timer = setTimeout(() => {
- if (resizeObserver) resizeObserver.disconnect();
- if (target.dataset.jumpHighlightToken === token) {
- target.classList.remove('ldp-jump-highlight');
- target.style.removeProperty('--ldp-first-post-jump-highlight-height');
- delete target.dataset.jumpHighlightToken;
- }
- if (activeJumpHighlight && activeJumpHighlight.token === token) activeJumpHighlight = null;
- }, lifetime);
- activeJumpHighlight = { target, token, timer, resizeObserver };
- }
-
- function streamPostNumber(node) {
- return +(node?.dataset && node.dataset.postNumber) || 0;
- }
-
- function streamOwnerPostNode(ctx, node) {
- if (!ctx || !ctx.commentsEl || !node) return null;
- let owner = node;
- while (owner && owner.parentElement !== ctx.commentsEl) {
- owner = owner.parentElement?.closest('.ldp-post');
- }
- return owner && owner.parentElement === ctx.commentsEl ? owner : null;
- }
-
- function clearStreamProgrammaticTarget(ctx) {
- if (!ctx) return;
- ctx.streamProgrammaticTarget = null;
- ctx.streamWindowResetPending = false;
- }
-
- function beginReaderNavigation(ctx) {
- if (!ctx) return 0;
- clearStreamViewportAnchor(ctx);
- clearStreamProgrammaticTarget(ctx);
- ctx.readerNavigationSequence = +(ctx.readerNavigationSequence || 0) + 1;
- return ctx.readerNavigationSequence;
- }
-
- function cancelReaderNavigation(ctx) {
- if (!ctx) return;
- clearStreamViewportAnchor(ctx);
- clearStreamProgrammaticTarget(ctx);
- ctx.readerNavigationSequence = +(ctx.readerNavigationSequence || 0) + 1;
- }
-
- function readerNavigationIsCurrent(ctx, sequence) {
- if (!ctx || !sequence || +ctx.readerNavigationSequence !== +sequence) return false;
- return !ctx.readerSession || ctx.readerSession.isCurrent('opening', 'active');
- }
-
- function waitForReaderJumpLayout() {
- return new Promise((resolve) => {
- requestAnimationFrame(() => requestAnimationFrame(resolve));
- });
- }
-
- function waitForDelay(delay) {
- return new Promise((resolve) => {
- setTimeout(resolve, delay);
- });
- }
-
- function readerOpenFailureCanAutoRetry(error, phase) {
- const status = Number(error?.status || 0);
- if (status === 429) return false;
- if (status && status !== 408 && status !== 425 && status < 500) return false;
- const name = String(error?.name || '');
- const message = String(error?.message || '');
- if (name === 'AbortError' || name === 'TimeoutError' || status === 408 || status === 425 || status >= 500)
- return true;
- if (/定位失败|恢复失败|布局稳定校验失败|校准失败/.test(message)) return true;
- if (!['load-topic', 'load-target-post', 'load-target-boosts', 'load-target-neighborhood'].includes(String(phase || '')))
- return false;
- return name === 'TypeError' ||
- /加载失败|请求失败|failed to fetch|network|timeout|timed out|aborted|signal/i.test(message);
- }
-
- function readerOpenAutoRetryDelay(attempt, error) {
- const retryAtDelay = Math.max(0, Number(error?.retryAt || 0) - Date.now());
- const delays = [500, 1200, 2500, 5000, 8000];
- const backoff = delays[Math.min(delays.length - 1, Math.max(0, Number(attempt) - 1))];
- return Math.max(backoff, retryAtDelay);
- }
-
- async function waitForReaderInitialLayout(overlay) {
- await waitForReaderJumpLayout();
- const modal = overlay?.querySelector('.ldp-modal');
- if (!modal || !overlay.classList.contains('ldp-reader-embedded') || !isFunction(modal.getAnimations)) return;
- const motions = modal.getAnimations().filter((motion) => motion.playState !== 'finished');
- if (!motions.length) return;
- await Promise.race([
- Promise.allSettled(motions.map((motion) => motion.finished)),
- waitForDelay(400),
- ]);
- await waitForReaderJumpLayout();
- }
-
- function readerTargetNodeIsVisible(target, ctx) {
- if (!target || !target.isConnected) return false;
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const targetRect = target.getBoundingClientRect();
- return targetRect.bottom > rootRect.top + 1 && targetRect.top < rootRect.bottom - 1;
- }
-
- function stableReaderViewportOffset(target, ctx, offset) {
- const requestedOffset = Number(offset);
- if (!Number.isFinite(requestedOffset) || !target || !target.isConnected || !ctx || !ctx.scrollRoot) return 0;
- const rootHeight = Math.max(0, ctx.scrollRoot.getBoundingClientRect().height);
- const targetHeight = Math.max(0, target.getBoundingClientRect().height);
- if (!rootHeight || !targetHeight) return requestedOffset;
- const minimumVisibleHeight = Math.min(40, rootHeight, targetHeight);
- return Math.max(
- minimumVisibleHeight - targetHeight,
- Math.min(rootHeight - minimumVisibleHeight, requestedOffset),
- );
- }
-
- function beginStreamProgrammaticTarget(ctx, postNumber) {
- if (!ctx || !postNumber) return null;
- clearStreamViewportAnchor(ctx);
- const target = { postNumber: +postNumber };
- ctx.streamProgrammaticTarget = target;
- ctx.streamScrollDirection = 0;
- ctx.streamScrollVelocity = 0;
- return target;
- }
-
- function markStreamInternalScroll(ctx, previousScrollTop = NaN) {
- if (!ctx || !ctx.scrollRoot) return;
- const nextScrollTop = ctx.scrollRoot.scrollTop;
- if (Number.isFinite(previousScrollTop)) {
- const appliedDelta = nextScrollTop - previousScrollTop;
- if (Math.abs(appliedDelta) >= 0.01) {
- const pendingDelta = Number.isFinite(ctx.streamInternalScrollDelta)
- ? ctx.streamInternalScrollDelta
- : 0;
- ctx.streamInternalScrollDelta = pendingDelta + appliedDelta;
- }
- }
- ctx.streamInternalScrollTop = nextScrollTop;
- }
-
- function findTopVisibleStreamPost(ctx) {
- if (!ctx || !ctx.scrollRoot || !ctx.commentsEl) return null;
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const allPosts = [...ctx.commentsEl.querySelectorAll(':scope > .ldp-post:not(.ldp-floor-preview)')];
- let posts = allPosts;
- const items = streamLayoutItems(ctx);
- rebuildStreamPrefix(ctx);
- if (items.length && Number.isFinite(ctx.streamListOffset)) {
- const localTop = Math.max(0, ctx.scrollRoot.scrollTop - ctx.streamListOffset);
- const index = findStreamIndexAtOffset(ctx.streamPrefix, localTop);
- const nearby = [];
- for (let cursor = Math.max(0, index - 2); cursor < Math.min(items.length, index + 7); cursor++) {
- const node = items[cursor]?.node;
- if (node && node.parentElement === ctx.commentsEl) nearby.push(node);
- }
- if (nearby.length) posts = nearby;
- }
- const findBest = (candidates) => {
- let best = null;
- candidates.forEach((post) => {
- const marker = post.querySelector(':scope > .ldp-post-head');
- if (!marker) return;
- const rect = marker.getBoundingClientRect();
- if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return;
- const ownerNode = streamOwnerPostNode(ctx, post);
- if (!streamPostNumber(ownerNode)) return;
- const score = Math.max(0, rect.top - rootRect.top);
- if (!best || score < best.score || (score === best.score && rect.top < best.top)) {
- best = { marker, top: rect.top, ownerNode, score };
- }
- });
- if (best) return best;
- candidates.forEach((post) => {
- const rect = post.getBoundingClientRect();
- if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return;
- const ownerNode = streamOwnerPostNode(ctx, post);
- if (!streamPostNumber(ownerNode)) return;
- const score = Math.abs(rect.top - rootRect.top);
- if (!best || score < best.score) best = { marker: post, ownerNode, score };
- });
- return best;
- };
- let best = findBest(posts);
- if (!best && posts !== allPosts) best = findBest(allPosts);
- return best && { marker: best.marker, ownerNode: best.ownerNode };
- }
-
- function findStreamViewportAnchorMarker(ctx) {
- const visible = findTopVisibleStreamPost(ctx);
- const owner = visible?.ownerNode;
- if (!owner || !owner.isConnected) return null;
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const viewportEdge = rootRect.top + 1;
- const candidates = [visible.marker, owner, ...owner.querySelectorAll(
- ':scope > .ldp-post-head, :scope > .ldp-content > *, :scope > .ldp-reactions, ' +
- ':scope > .ldp-boost-list > .ldp-boost-bubble, :scope > .ldp-children > .ldp-post, ' +
- ':scope > .ldp-children > .ldp-post > .ldp-post-head, :scope > .ldp-sub-actions'
- )];
- let best = null;
- const seen = new Set();
- candidates.forEach((candidate) => {
- if (!candidate || seen.has(candidate) || !candidate.isConnected) return;
- seen.add(candidate);
- const rect = candidate.getBoundingClientRect();
- if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return;
- const crossesTop = rect.top <= viewportEdge && rect.bottom > viewportEdge;
- const distance = crossesTop ? 0 : Math.max(0, rect.top - viewportEdge);
- if (!best || distance < best.distance ||
- (distance === best.distance && rect.height < best.height)) {
- best = { marker: candidate, distance, height: rect.height };
- }
- });
- return best?.marker;
- }
-
- function clearStreamViewportAnchor(ctx) {
- if (!ctx) return;
- ctx.streamViewportAnchorCapturePending = false;
- ctx.streamViewportAnchorRestorePending = false;
- ctx.streamViewportAnchor = null;
- if (ctx.scrollRoot) ctx.scrollRoot.classList.remove('ldp-stream-viewport-anchor');
- }
-
- function captureStreamViewportAnchor(ctx) {
- if (!ctx || !ctx.scrollRoot || !ctx.commentsEl || ctx.streamProgrammaticTarget) return null;
- ctx.streamViewportAnchorCapturePending = false;
- const marker = findStreamViewportAnchorMarker(ctx);
- if (!marker) {
- clearStreamViewportAnchor(ctx);
- return null;
- }
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const markerRect = marker.getBoundingClientRect();
- const markerPost = marker.closest('.ldp-post');
- const owner = streamOwnerPostNode(ctx, markerPost);
- const state = {
- marker,
- offset: markerRect.top - rootRect.top,
- owner,
- ownerOffset: owner ? owner.getBoundingClientRect().top - rootRect.top : 0,
- };
- ctx.streamViewportAnchor = state;
- ctx.scrollRoot.classList.add('ldp-stream-viewport-anchor');
- return state;
- }
-
- function queueStreamViewportAnchorCapture(ctx) {
- if (ctx && !ctx.streamProgrammaticTarget) ctx.streamViewportAnchorCapturePending = true;
- }
-
- function beginStreamViewportAnchorRestoreBatch(ctx) {
- if (!ctx) return;
- ctx.streamViewportAnchorRestoreDepth = +(ctx.streamViewportAnchorRestoreDepth || 0) + 1;
- }
-
- function endStreamViewportAnchorRestoreBatch(ctx) {
- if (!ctx || !ctx.streamViewportAnchorRestoreDepth) return;
- ctx.streamViewportAnchorRestoreDepth--;
- if (ctx.streamViewportAnchorRestoreDepth || !ctx.streamViewportAnchorRestorePending) return;
- ctx.streamViewportAnchorRestorePending = false;
- restoreStreamViewportAnchor(ctx);
- }
-
- function applyStreamViewportAnchorRestore(ctx) {
- const state = ctx?.streamViewportAnchor;
- if (!state) return false;
- if (!ctx.scrollRoot || !ctx.commentsEl || ctx.streamProgrammaticTarget) {
- clearStreamViewportAnchor(ctx);
- return false;
- }
- const markerConnected = state.marker?.isConnected && ctx.commentsEl.contains(state.marker);
- const ownerConnected = state.owner?.isConnected && ctx.commentsEl.contains(state.owner);
- const marker = markerConnected ? state.marker : ownerConnected ? state.owner : null;
- if (!marker) {
- clearStreamViewportAnchor(ctx);
- return false;
- }
- const expectedOffset = marker === state.marker ? state.offset : state.ownerOffset;
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const markerRect = marker.getBoundingClientRect();
- const correction = markerRect.top - rootRect.top - expectedOffset;
- if (Math.abs(correction) < 0.5) return true;
- const previousScrollTop = ctx.scrollRoot.scrollTop;
- ctx.scrollRoot.scrollTop += correction;
- if (Math.abs(ctx.scrollRoot.scrollTop - previousScrollTop) >= 0.5) {
- markStreamInternalScroll(ctx, previousScrollTop);
- } else if (marker === state.marker) {
- state.offset = markerRect.top - rootRect.top;
- } else {
- state.ownerOffset = markerRect.top - rootRect.top;
- }
- return true;
- }
-
- function restoreStreamViewportAnchor(ctx) {
- if (!ctx || !ctx.streamViewportAnchor) return false;
- if (ctx.streamViewportAnchorRestoreDepth) {
- ctx.streamViewportAnchorRestorePending = true;
- return true;
- }
- ctx.streamViewportAnchorRestorePending = false;
- return applyStreamViewportAnchorRestore(ctx);
- }
-
- function forEachDirectNestedPost(node, callback) {
- const children = node?.querySelector(':scope > .ldp-children');
- if (!children) return;
- for (const child of children.children) {
- if (child.classList.contains('ldp-post')) callback(child);
- }
- }
-
- function createStreamSpacer(position) {
- const spacer = makeElement('div');
- spacer.className = `ldp-stream-spacer ldp-stream-spacer-${position}`;
- spacer.setAttribute('aria-hidden', 'true');
- setStreamSpacerHeight(spacer, 0);
- return spacer;
- }
-
- function setStreamSpacerHeight(spacer, height) {
- if (!spacer) return;
- const next = Math.max(0, Math.round(+height || 0));
- const value = `${next}px`;
- if (spacer.style.getPropertyValue('--ldp-stream-spacer-height') === value) return;
- spacer.style.setProperty('--ldp-stream-spacer-height', value);
- }
-
- function ensureStreamScaffold(ctx) {
- if (!ctx || !ctx.commentsEl || !ctx.streamTopSpacer || !ctx.streamBottomSpacer) return;
- if (!ctx.streamTopSpacer.isConnected) ctx.commentsEl.appendChild(ctx.streamTopSpacer);
- if (!ctx.streamBottomSpacer.isConnected) ctx.commentsEl.appendChild(ctx.streamBottomSpacer);
- }
-
- function streamItemMatchesFilter(ctx, item) {
- if (!ctx || !ctx.onlyOp) return true;
- return !!item && !!ctx.op && String(item.username || '') === String(ctx.op);
- }
-
- function streamLayoutItems(ctx) {
- if (!ctx || !ctx.onlyOp) return (ctx?.streamItems) || [];
- if (!ctx.streamLayoutDirty && Array.isArray(ctx.streamLayoutItems)) return ctx.streamLayoutItems;
- const items = ctx.streamItems.filter((item) => streamItemMatchesFilter(ctx, item));
- let zebraIndex = 0;
- items.forEach((item, index) => {
- item.layoutIndex = index;
- item.layoutZebraIndex = streamItemParticipatesInZebra(item) ? zebraIndex++ : -1;
- });
- ctx.streamLayoutItems = items;
- ctx.streamLayoutDirty = false;
- return items;
- }
-
- function invalidateStreamLayout(ctx) {
- if (!ctx) return;
- ctx.streamLayoutDirty = true;
- ctx.streamPrefixDirty = true;
- ctx.streamPrefixDirtyFrom = 0;
- ctx.streamListOffset = NaN;
- ctx.streamNeedsRepair = true;
- }
-
- function markStreamPrefixDirty(ctx, index = 0) {
- const items = streamLayoutItems(ctx);
- const next = Math.max(0, Math.min(items.length, +index || 0));
- ctx.streamPrefixDirty = true;
- ctx.streamPrefixDirtyFrom = Number.isInteger(ctx.streamPrefixDirtyFrom)
- ? Math.min(ctx.streamPrefixDirtyFrom, next)
- : next;
- }
-
- function rebuildStreamPrefix(ctx) {
- if (!ctx || !ctx.streamItems) return [0];
- const items = streamLayoutItems(ctx);
- if (!ctx.streamPrefixDirty && ctx.streamPrefix.length === items.length + 1) return ctx.streamPrefix;
- const prefix = ctx.streamPrefix;
- let from = Number.isInteger(ctx.streamPrefixDirtyFrom) ? ctx.streamPrefixDirtyFrom : 0;
- if (!prefix.length || prefix[0] !== 0 || prefix.length > items.length + 1) from = 0;
- from = Math.max(0, Math.min(from, items.length, prefix.length - 1));
- if (from === 0) {
- prefix.length = 1;
- prefix[0] = 0;
- }
- else prefix.length = from + 1;
- for (let index = from; index < items.length; index++) {
- const item = items[index];
- prefix[index + 1] = prefix[index] + (item.node?.hidden ? 0 : item.hiddenReplyMarker ? +(item.height || 28) :
- Math.max(+(item.height || 0), STREAM_PLACEHOLDER_MIN_HEIGHT));
- }
- ctx.streamPrefixDirty = false;
- ctx.streamPrefixDirtyFrom = null;
- return prefix;
- }
-
- function findStreamIndexAtOffset(prefix, offset) {
- if (!prefix || prefix.length <= 1) return 0;
- const target = Math.max(0, +offset || 0);
- let low = 0;
- let high = prefix.length - 2;
- while (low < high) {
- const mid = Math.floor((low + high) / 2);
- if (prefix[mid + 1] <= target) low = mid + 1;
- else high = mid;
- }
- return low;
- }
-
- function createStreamResizeObserver(ctx) {
- if (typeof ResizeObserver === 'undefined')
- return { observe() {}, unobserve() {}, disconnect() {} };
- return new ResizeObserver((entries) => {
- let changed = false;
- entries.forEach((entry) => {
- const item = ctx.streamItemMap.get(streamPostNumber(entry.target));
- if (!item) return;
- const borderBox = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize;
- const measured = (borderBox?.blockSize) || entry.target.offsetHeight || entry.contentRect.height;
- const height = Math.max(Math.round(measured || 0), STREAM_PLACEHOLDER_MIN_HEIGHT);
- if (entry.target.classList.contains('ldp-has-child-branches')) {
- scheduleNestedBranchSync(entry.target, ctx);
- }
- if (Math.abs(height - item.height) < 1) return;
- item.height = height;
- markStreamPrefixDirty(ctx, ctx.onlyOp ? item.layoutIndex : item.index);
- changed = true;
- });
- if (!changed) return;
- restoreStreamViewportAnchor(ctx);
- ctx.streamNeedsRepair = true;
- scheduleStreamWindowSync(ctx);
- });
- }
-
- function updateStreamSpacerHeights(ctx, start, end) {
- const total = streamLayoutItems(ctx).length;
- const totalHeight = ctx.streamPrefix[total] || 0;
- setStreamSpacerHeight(
- ctx.streamTopSpacer,
- Math.max(ctx.streamPrefix[start], +(ctx.initialTargetHeadSpacerHeight || 0))
- );
- setStreamSpacerHeight(
- ctx.streamBottomSpacer,
- Math.max(0, totalHeight - ctx.streamPrefix[end])
- );
- }
-
- function clearInitialTargetHeadSpacer(ctx) {
- if (!ctx || !ctx.initialTargetHeadSpacerHeight) return;
- ctx.initialTargetHeadSpacerHeight = 0;
- updateStreamSpacerHeights(ctx, ctx.streamWindowStart, ctx.streamWindowEnd);
- }
-
- function createPostIdleWorkState(pendingSet) {
- return { cancels: new WeakMap(), pendingSet };
- }
-
- const postContentHydrationWork = createPostIdleWorkState('pendingPostHydrations');
- const postLatexRenderWork = createPostIdleWorkState('pendingPostLatexRenders');
-
- function cancelPostIdleWork(post, ctx, work, pendingKey) {
- if (!post) return;
- const cancel = work.cancels.get(post);
- if (cancel) cancel();
- work.cancels.delete(post);
- if (ctx?.[work.pendingSet]) ctx[work.pendingSet].delete(post);
- delete post.dataset[pendingKey];
- }
-
- function cancelPostContentHydration(post, ctx) {
- cancelPostIdleWork(post, ctx, postContentHydrationWork, 'ldpContentHydrationPending');
- }
-
- function cancelPostLatexRender(post, ctx) {
- cancelPostIdleWork(post, ctx, postLatexRenderWork, 'ldpLatexPending');
- }
-
- function postContentPlaceholderHeight(postData) {
- const cooked = String(postData?.cooked || '');
- if (!cooked) return 72;
- const blockCount = (cooked.match(/<(?:p|li|pre|blockquote|h[1-6]|tr|br)\b/gi) || []).length;
- const lineEstimate = Math.max(2, blockCount, Math.ceil(cooked.length / 180));
- const mediaHeight = /<(?:img|video|audio|iframe)\b|class=["'][^"']*(?:onebox|lightbox|d-image-grid)/i.test(cooked) ? 220 : 0;
- return Math.max(72, Math.min(460, 28 + lineEstimate * 22 + mediaHeight));
- }
-
- function postHasBoostData(boosts) {
- if (Array.isArray(boosts)) return boosts.some(Boolean);
- return !!boosts;
- }
-
- function hydratePostContent(post, ctx) {
- if (!post) return false;
- if (post.dataset.ldpContentHydrated !== '0') {
- syncPostActionControls(post, ctx);
- return true;
- }
- const postData = post._ldpPostData || {};
- const content = post.querySelector(':scope > .ldp-content');
- if (!content) return false;
-
- cancelPostContentHydration(post, ctx);
-
- setPostCookedContent(content, postData.cooked, postData.link_counts);
- content.classList.remove('ldp-content-pending');
- content.style.removeProperty('--ldp-content-placeholder-height');
- content.removeAttribute('aria-busy');
- renderPostPolls(post, ctx);
- renderPostSpecialContent(post, ctx);
-
- const pendingBoosts = post.querySelector(':scope > .ldp-boost-list.ldp-boost-pending');
- if (pendingBoosts) {
- const holder = makeElement('template');
- holder.innerHTML = renderBoostList(postData.boosts, { canQuickBoost: canShowBoostButton(postData), ctx });
- const next = holder.content.firstElementChild;
- if (next) replaceOwnedNode(pendingBoosts, next);
- else removeOwnedNode(pendingBoosts);
- }
- syncPostActionControls(post, ctx);
-
- post.dataset.ldpContentHydrated = '1';
- ctx?.translation?.syncPost(post);
- if (ctx?.streamNodeMap && ctx.streamNodeMap.get(+post.dataset.postNumber) === post) {
- if (ctx.streamHydratedNodes) ctx.streamHydratedNodes.add(post);
- }
- delete post.dataset.ldpLatexRendered;
- bindPostMediaLayoutSync(post, ctx);
- if (post.isConnected) {
- schedulePostLatexRender(post, ctx);
- scheduleNestedBranchSync(post, ctx);
- }
- restoreReaderQuoteHighlightForPost(post, ctx);
- restoreStreamViewportAnchor(ctx);
- return true;
- }
-
- function dehydratePostContent(post, ctx) {
- if (!post || post.isConnected || post.dataset.ldpContentHydrated !== '1') return false;
- if (ctx?.floorPreview && ctx.floorPreview.isOpen()) return false;
- cancelPostContentHydration(post, ctx);
- cancelPostLatexRender(post, ctx);
- const postData = post._ldpPostData || {};
- const content = post.querySelector(':scope > .ldp-content');
- if (content) {
- suspendReaderMedia(content);
- releasePersistentAvatarImages(content);
- content.replaceChildren();
- content.classList.add('ldp-content-pending');
- content.style.setProperty('--ldp-content-placeholder-height', `${postContentPlaceholderHeight(postData)}px`);
- content.setAttribute('aria-busy', 'true');
- }
- const boosts = post.querySelector(':scope > .ldp-boost-list');
- if (boosts) {
- destroyReaderMedia(boosts);
- releasePersistentAvatarImages(boosts);
- boosts.replaceChildren();
- boosts.classList.add('ldp-boost-pending');
- boosts.hidden = !postHasBoostData(postData.boosts);
- boosts.setAttribute('aria-hidden', 'true');
- }
- post.dataset.ldpContentHydrated = '0';
- delete post.dataset.ldpLatexRendered;
- delete post.dataset.ldpLatexPending;
- if (ctx?.streamHydratedNodes) ctx.streamHydratedNodes.delete(post);
- return true;
- }
-
- function pruneStreamHydratedContent(ctx, items, start, end) {
- if (!ctx || !ctx.streamHydratedNodes || ctx.floorPreview?.isOpen()) return;
- const mountedSpan = Math.max(1, end - start);
- const warmBuffer = Math.min(12, Math.max(4, Math.ceil(mountedSpan / 2)));
- const warmStart = Math.max(0, start - warmBuffer);
- const warmEnd = Math.min(items.length, end + warmBuffer);
- [...ctx.streamHydratedNodes].forEach((post) => {
- if (!post || post.isConnected) return;
- const item = ctx.streamItemMap.get(streamPostNumber(post));
- const index = item && (ctx.onlyOp ? item.layoutIndex : item.index);
- const participates = item && (!ctx.onlyOp || streamItemMatchesFilter(ctx, item));
- if (!participates || !Number.isInteger(index) || index < warmStart || index >= warmEnd) {
- dehydratePostContent(post, ctx);
- }
- });
- }
-
- function streamWorkingSetSpan(ctx, total) {
- const viewportItems = Math.ceil(
- Math.max(1, Number(ctx?.scrollRoot?.clientHeight) || 0) / STREAM_ESTIMATED_HEIGHT,
- );
- return Math.min(
- Math.max(0, Number(total) || 0),
- streamPerformance(ctx).streamMaxItems,
- Math.max(12, viewportItems + 8),
- );
- }
-
- function hydrateStreamNavigationNeighborhood(ctx, postNumber) {
- const targetItem = ctx?.streamItemMap && ctx.streamItemMap.get(+postNumber);
- if (!targetItem) return;
- const items = streamLayoutItems(ctx);
- const targetIndex = ctx.onlyOp ? targetItem.layoutIndex : targetItem.index;
- if (!Number.isInteger(targetIndex) || targetIndex < 0 || targetIndex >= items.length) return;
- const span = streamWorkingSetSpan(ctx, items.length);
- const beforeTarget = Math.min(targetIndex, Math.floor(span * 0.75));
- let start = Math.max(0, targetIndex - beforeTarget);
- const end = Math.min(items.length, start + span);
- start = Math.max(0, end - span);
- for (let index = start; index < end; index++) {
- const item = items[index];
- if (item?.node) hydratePostContent(item.node, ctx);
- }
- }
-
- function schedulePostIdleWork(post, ctx, work, options) {
- const { pendingKey, delay, settleMs, isPending, run } = options;
- if (!post || !isPending() || post.dataset[pendingKey] === '1') return;
- if (options.immediate && !streamScrollIsActive(ctx, settleMs)) return run();
- post.dataset[pendingKey] = '1';
- const cancel = () => cancelPostIdleWork(post, ctx, work, pendingKey);
- const queue = () => {
- work.cancels.set(post, scheduleIdleWork(attempt, delay));
- if (ctx?.[work.pendingSet]) ctx[work.pendingSet].add(post);
- };
- const waitForVisibility = () => {
- const resume = () => {
- if (document.visibilityState !== 'visible') return;
- document.removeEventListener('visibilitychange', resume);
- work.cancels.delete(post);
- attempt();
- };
- document.addEventListener('visibilitychange', resume);
- work.cancels.set(post, () => document.removeEventListener('visibilitychange', resume));
- };
- const attempt = () => {
- if (!isPending() || !post.isConnected) return cancel();
- if (document.visibilityState !== 'visible') return waitForVisibility();
- if (streamScrollIsActive(ctx, settleMs)) return queue();
- cancel();
- run();
- };
- queue();
- }
-
- function schedulePostContentHydration(post, ctx, immediate = false) {
- schedulePostIdleWork(post, ctx, postContentHydrationWork, {
- pendingKey: 'ldpContentHydrationPending', delay: 320, settleMs: 180, immediate,
- isPending: () => post.dataset.ldpContentHydrated === '0',
- run: () => hydratePostContent(post, ctx),
- });
- }
-
- function hydrateStreamRangeContent(ctx, items, start, end) {
- if (!ctx || !items || start >= end) return;
- const pressure = scrollLoadPressure(ctx);
- const beforeLead = ctx.streamScrollDirection < 0 ? Math.min(8, Math.max(3, pressure.lookahead)) : 2;
- const afterLead = ctx.streamScrollDirection > 0 ? Math.min(6, Math.max(2, pressure.lookahead)) : 2;
- const priorityStart = Math.max(start, ctx.streamViewportTopIndex - beforeLead);
- const priorityEnd = Math.min(end - 1, ctx.streamViewportBottomIndex + afterLead);
- const idleStart = Math.max(start, priorityStart - 3);
- const idleEnd = Math.min(end - 1, priorityEnd + 3);
- const initialTargetPostNumber = +(ctx.initialTargetHydrationBoundary || 0);
- const initialTargetItem = initialTargetPostNumber && ctx.streamItemMap.get(initialTargetPostNumber);
- const initialTargetIndex = initialTargetItem
- ? (ctx.onlyOp ? initialTargetItem.layoutIndex : initialTargetItem.index)
- : -1;
- beginStreamViewportAnchorRestoreBatch(ctx);
- try {
- for (let index = idleStart; index <= idleEnd; index++) {
- const item = items[index];
- if (!item || !item.node) continue;
- const beforeInitialTarget = initialTargetPostNumber && (
- initialTargetIndex >= 0 ? index < initialTargetIndex : item.postNumber < initialTargetPostNumber
- );
- if (beforeInitialTarget &&
- (ctx.initialTargetPrecedingContentDeferred || ctx.initialTargetPositionPending)) continue;
- const priority = index >= priorityStart && index <= priorityEnd;
- schedulePostContentHydration(item.node, ctx, priority);
- }
- } finally {
- endStreamViewportAnchorRestoreBatch(ctx);
- }
- }
-
- function tryOwnedNodeMutation(mutation) {
- try {
- mutation();
- return true;
- } catch (error) {
- if (error && error.name === 'NotFoundError') return false;
- throw error;
- }
- }
-
- function removeOwnedNode(node, owner = null) {
- if (!node) return false;
- const parent = node.parentNode;
- if (!parent || (owner && parent !== owner)) return false;
- releasePersistentAvatarImages(node);
- return tryOwnedNodeMutation(() => parent.removeChild(node));
- }
-
- function moveOwnedNodeBefore(owner, node, referenceNode = null) {
- if (!owner || !node) return false;
- const reference = referenceNode && referenceNode.parentNode === owner ? referenceNode : null;
- if (node.parentNode === owner && node.nextSibling === reference) return true;
- try {
- owner.insertBefore(node, reference);
- return true;
- } catch (error) {
- if (!error || error.name !== 'NotFoundError') throw error;
- const freshReference = referenceNode && referenceNode.parentNode === owner ? referenceNode : null;
- if (node.parentNode === owner && node.nextSibling === freshReference) return true;
- owner.insertBefore(node, freshReference);
- return true;
- }
- }
-
- function replaceOwnedNode(currentNode, nextNode) {
- if (!currentNode || !nextNode) return false;
- const parent = currentNode.parentNode;
- if (!parent) return false;
- return tryOwnedNodeMutation(() => parent.replaceChild(nextNode, currentNode));
- }
-
- function mountStreamRange(ctx, start, end) {
- if (!ctx || !ctx.streamItems || !ctx.commentsEl) return;
- ensureStreamScaffold(ctx);
- rebuildStreamPrefix(ctx);
- const items = streamLayoutItems(ctx);
- const total = items.length;
- start = Math.max(0, Math.min(total, +start || 0));
- end = Math.max(start, Math.min(total, +end || 0));
-
- hydrateStreamRangeContent(ctx, items, start, end);
- const firstRangeNode = items[start]?.node;
- const lastRangeNode = items[end - 1]?.node;
- const rangeEndpointsConnected = start === end ||
- (firstRangeNode && lastRangeNode &&
- firstRangeNode.parentElement === ctx.commentsEl && lastRangeNode.parentElement === ctx.commentsEl);
- if (ctx.streamMountedItemCount === total &&
- ctx.streamWindowStart === start && ctx.streamWindowEnd === end &&
- ctx.streamMountedNodes.size === end - start && rangeEndpointsConnected) {
- refreshStreamZebra(ctx, items.slice(start, end).map((item) => item.node));
- updateStreamSpacerHeights(ctx, start, end);
- syncCommentsHeaderPlacement(ctx);
- restoreStreamViewportAnchor(ctx);
- return;
- }
-
- const activeElement = readerActiveElement();
- const activePost = ctx.commentsEl.contains(activeElement) && activeElement.closest('.ldp-post');
- if (activePost) {
- const activeItem = ctx.streamItemMap.get(streamPostNumber(activePost));
- const activeIndex = activeItem && (ctx.onlyOp ? activeItem.layoutIndex : activeItem.index);
- if (activeItem && (activeIndex < start || activeIndex >= end) && activeElement.blur) {
- activeElement.blur();
- }
- }
-
- const rangeItems = items.slice(start, end);
- const desired = new Set(rangeItems.map((item) => item.node));
- [...ctx.streamMountedNodes].forEach((node) => {
- if (desired.has(node)) return;
- ctx.streamResizeObserver.unobserve(node);
- if (ctx.tracker?.unobserve) {
- ctx.tracker.unobserve(node);
- forEachDirectNestedPost(node, (child) => ctx.tracker.unobserve(child));
- }
- if (ctx.queueViewportTracker) ctx.queueViewportTracker.unobserve(node);
- if (ctx.repliesIO?.unobserve) ctx.repliesIO.unobserve(node);
- cancelPostContentHydration(node, ctx);
- cancelPostLatexRender(node, ctx);
- suspendReaderMedia(node);
- const bundle = ctx.floorPreview ? ctx.floorPreview.getBundleForPost(node) : [node];
- bundle.forEach((part) => removeOwnedNode(part, ctx.commentsEl));
- if (node.querySelector(':scope > .ldp-content :is(video,audio,iframe),:scope > .ldp-content .ldp-media-carousel')) {
- dehydratePostContent(node, ctx);
- }
- ctx.streamMountedNodes.delete(node);
- });
- pruneStreamHydratedContent(ctx, items, start, end);
-
- let cursor = ctx.streamTopSpacer.nextSibling;
- rangeItems.forEach((item) => {
- const node = item.node;
- const wasConnected = node.parentElement === ctx.commentsEl;
- const deferPreTargetWork = ctx.initialTargetPrecedingContentDeferred &&
- +item.postNumber < +(ctx.initialTargetHydrationBoundary || 0);
- if (!wasConnected) node.classList.remove('ldp-nested-branches-ready');
- keepOnlyOpPostExpanded(node, ctx);
- ctx.streamMountedNodes.add(node);
- const bundle = ctx.floorPreview ? ctx.floorPreview.getBundleForPost(node) : [node];
- bundle.forEach((part) => {
- if (part !== cursor) {
- const reference = cursor && cursor.parentNode === ctx.commentsEl
- ? cursor
- : (ctx.streamBottomSpacer.parentNode === ctx.commentsEl ? ctx.streamBottomSpacer : null);
- moveOwnedNodeBefore(ctx.commentsEl, part, reference);
- }
- cursor = part.nextSibling;
- });
- if (!wasConnected && document.visibilityState === 'visible') activateReaderMedia(node);
- restoreReaderQuoteHighlightForPost(node, ctx);
- if (node.dataset.ldpContentHydrated === '1' && node.dataset.ldpLatexRendered !== '1') {
- schedulePostLatexRender(node, ctx);
- }
- if (!wasConnected) {
- hydratePersistentAvatarImages(node);
- ctx.streamResizeObserver.observe(node);
- ctx.tracker.observe(node);
- if (ctx.queueViewportTracker) ctx.queueViewportTracker.observe(node);
- forEachDirectNestedPost(node, (child) => ctx.tracker.observe(child));
- if (!deferPreTargetWork && node.dataset.childReplyTotal && ctx.repliesIO && ctx.repliesIO.observe) {
- ctx.repliesIO.observe(node);
- }
- const renderedChildren = node.querySelector(':scope > .ldp-children > .ldp-post');
- if (renderedChildren) scheduleNestedBranchSync(node, ctx);
- }
- });
-
- updateStreamSpacerHeights(ctx, start, end);
- ctx.streamWindowStart = start;
- ctx.streamWindowEnd = end;
- ctx.streamMountedItemCount = total;
- refreshStreamZebra(ctx, rangeItems.map((item) => item.node));
- syncCommentsHeaderPlacement(ctx);
- if (ctx.floorPreview?.isOpen()) scheduleFloorPreviewSync(ctx);
- restoreStreamViewportAnchor(ctx);
- }
-
- function streamPerformance(ctx) {
- return ctx?.performance || BALANCED_PERFORMANCE;
- }
-
- function streamScrollIsActive(ctx, settleMs = 120) {
- if (!ctx || !ctx.streamLastScrollAt) return false;
- return Date.now() - ctx.streamLastScrollAt < settleMs && +(ctx.streamScrollVelocity || 0) > 0.08;
- }
-
- function schedulePostLatexRender(post, ctx) {
- schedulePostIdleWork(post, ctx, postLatexRenderWork, {
- pendingKey: 'ldpLatexPending', delay: 360, settleMs: 120, immediate: true,
- isPending: () => post.dataset.ldpLatexRendered !== '1',
- run: () => {
- renderLatexInPost(post);
- restoreReaderQuoteHighlightForPost(post, ctx);
- },
- });
- }
-
- function scrollLoadPressure(ctx) {
- const lastScrollAt = +(ctx?.streamLastScrollAt || 0);
- const age = Date.now() - lastScrollAt;
- const viewportHeight = Math.max(1, +(ctx?.scrollRoot && ctx.scrollRoot.clientHeight || 0));
- const viewportsPerSecond = age > 300
- ? 0
- : Math.max(0, +(ctx?.streamScrollVelocity || 0)) * 1000 / viewportHeight;
- if (viewportsPerSecond >= 0.75) return { batches: 2, lookahead: viewportsPerSecond >= 2.5 ? 24 : 14 };
- return { batches: 1, lookahead: 8 };
- }
-
- function repairBlankStreamViewport(ctx, start, end) {
- if (!ctx || !ctx.streamMountedNodes.size) return;
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const items = streamLayoutItems(ctx);
- let firstNode = items[start]?.node;
- if (!firstNode || !firstNode.isConnected) return;
- let firstRect = firstNode.getBoundingClientRect();
- const leadingGap = firstRect.top - rootRect.top;
- if (start > 0 && leadingGap > 1) {
- const extraRows = Math.max(8, Math.ceil(leadingGap / STREAM_ESTIMATED_HEIGHT) + 4);
- const nextStart = Math.max(0, end - streamPerformance(ctx).streamMaxItems, start - extraRows);
- if (nextStart < start) {
- const range = stableStreamMountRange(ctx, nextStart, end, items.length);
- mountStreamRange(ctx, range.start, range.end);
- start = ctx.streamWindowStart;
- end = ctx.streamWindowEnd;
- firstNode = items[start]?.node;
- firstRect = firstNode?.getBoundingClientRect();
- }
- }
- const commentsRect = ctx.commentsEl.getBoundingClientRect();
- if (commentsRect.bottom < rootRect.top || commentsRect.top > rootRect.bottom) return;
- const lastNode = items[end - 1]?.node;
- if (!firstNode || !lastNode || !firstNode.isConnected || !lastNode.isConnected) return;
- const lastRect = lastNode === firstNode ? firstRect : lastNode.getBoundingClientRect();
- if (lastRect.bottom >= rootRect.top && firstRect.top <= rootRect.bottom) return;
-
- const length = Math.max(1, end - start);
- if (firstRect.top > rootRect.bottom && start > 0) {
- const gap = firstRect.top - rootRect.bottom;
- const extraRows = Math.max(6, Math.ceil(gap / STREAM_ESTIMATED_HEIGHT) + 3);
- const nextStart = Math.max(0, start - extraRows);
- const nextLength = Math.min(streamPerformance(ctx).streamMaxItems, length + extraRows);
- const range = stableStreamMountRange(
- ctx,
- nextStart,
- Math.min(items.length, nextStart + nextLength),
- items.length
- );
- mountStreamRange(ctx, range.start, range.end);
- return;
- }
- if (lastRect.bottom < rootRect.top && end < items.length) {
- const gap = rootRect.top - lastRect.bottom;
- const extraRows = Math.max(6, Math.ceil(gap / STREAM_ESTIMATED_HEIGHT) + 3);
- const nextEnd = Math.min(items.length, end + extraRows);
- const nextLength = Math.min(streamPerformance(ctx).streamMaxItems, length + extraRows);
- const range = stableStreamMountRange(
- ctx,
- Math.max(0, nextEnd - nextLength),
- nextEnd,
- items.length
- );
- mountStreamRange(ctx, range.start, range.end);
- }
- }
-
- function mountStreamPost(ctx, postNumber) {
- const item = ctx?.streamItemMap && ctx.streamItemMap.get(+postNumber);
- if (!item || !item.node || !streamItemMatchesFilter(ctx, item)) return null;
- const items = streamLayoutItems(ctx);
- const layoutIndex = ctx.onlyOp ? item.layoutIndex : item.index;
- rebuildStreamPrefix(ctx);
- const span = streamWorkingSetSpan(ctx, items.length);
- const start = Math.max(0, Math.min(layoutIndex - Math.floor(span / 2), items.length - span));
- const end = Math.min(items.length, start + span);
- mountStreamRange(ctx, start, end);
- hydratePostContent(item.node, ctx);
- return item.node.isConnected ? item.node : null;
- }
-
- function getStreamPostNode(ctx, postNumber, options = {}) {
- const num = +postNumber;
- if (!ctx || !num) return null;
- const node = ctx.streamNodeMap?.get(num);
- if (node?.isConnected) return node;
- if (options.mount) return mountStreamPost(ctx, num);
- return null;
- }
-
- function getStreamListOffset(ctx) {
- if (Number.isFinite(ctx.streamListOffset)) return ctx.streamListOffset;
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const commentsRect = ctx.commentsEl.getBoundingClientRect();
- ctx.streamListOffset = ctx.scrollRoot.scrollTop + commentsRect.top - rootRect.top;
- return ctx.streamListOffset;
- }
-
- function stableStreamMountRange(ctx, start, end, total) {
- start = Math.max(0, Math.min(total, +start || 0));
- end = Math.max(start, Math.min(total, +end || 0));
- if (ctx.streamWindowResetPending) {
- ctx.streamWindowResetPending = false;
- return { start, end };
- }
-
- const currentStart = +ctx.streamWindowStart || 0;
- const currentEnd = +ctx.streamWindowEnd || 0;
- const currentLength = currentEnd - currentStart;
- const currentValid = ctx.streamMountedNodes.size > 0 && currentLength > 0 &&
- currentStart >= 0 && currentEnd <= total;
- if (!currentValid) return { start, end };
- const desiredLength = Math.max(1, end - start);
- const maxItems = Math.max(1, streamPerformance(ctx).streamMaxItems);
- const retention = Math.min(8, Math.max(3, Math.ceil(desiredLength * .3)));
- const stableLimit = Math.min(maxItems, desiredLength + retention);
- const anchorOwner = ctx.streamViewportAnchor?.owner;
- const anchorItem = anchorOwner?.isConnected
- ? ctx.streamItemMap.get(streamPostNumber(anchorOwner))
- : null;
- const anchorIndex = anchorItem && (ctx.onlyOp ? anchorItem.layoutIndex : anchorItem.index);
- const currentContainsAnchor = Number.isInteger(anchorIndex) &&
- anchorIndex >= currentStart && anchorIndex < currentEnd;
- const nextContainsAnchor = Number.isInteger(anchorIndex) && anchorIndex >= start && anchorIndex < end;
- if (currentContainsAnchor && !nextContainsAnchor) {
- const span = Math.min(maxItems, desiredLength + retention);
- if (ctx.streamScrollDirection < 0) {
- const trailingItems = Math.min(6, Math.max(2, Math.ceil(span * 0.1)));
- end = Math.min(total, anchorIndex + trailingItems + 1);
- start = Math.max(0, end - span);
- end = Math.min(total, start + span);
- } else if (ctx.streamScrollDirection > 0) {
- const leadingItems = Math.min(6, Math.max(2, Math.ceil(span * 0.1)));
- start = Math.max(0, anchorIndex - leadingItems);
- end = Math.min(total, start + span);
- start = Math.max(0, end - span);
- } else {
- start = Math.max(0, anchorIndex - Math.floor(span / 2));
- end = Math.min(total, start + span);
- start = Math.max(0, end - span);
- }
- return { start, end };
- }
- const unionStart = Math.min(currentStart, start);
- const unionEnd = Math.max(currentEnd, end);
- if (unionEnd - unionStart <= stableLimit) return { start: unionStart, end: unionEnd };
-
- // Keep only a small trailing overlap. The configured maximum remains a hard
- // ceiling, not a target that gradually retains every visited floor.
- if (start >= currentStart && end >= currentEnd) {
- const nextStart = Math.max(0, start - retention);
- return { start: Math.max(0, end - maxItems, nextStart), end };
- }
- if (start <= currentStart && end <= currentEnd) {
- const nextEnd = Math.min(total, end + retention);
- return { start, end: Math.min(nextEnd, start + maxItems) };
- }
- return { start, end };
- }
-
- function syncStreamWindow(ctx) {
- if (!ctx || !ctx.commentsEl || !ctx.scrollRoot || !ctx.streamItems.length) return;
- const items = streamLayoutItems(ctx);
- ensureStreamScaffold(ctx);
- rebuildStreamPrefix(ctx);
- if (!items.length) {
- mountStreamRange(ctx, 0, 0);
- return;
- }
- const listOffset = getStreamListOffset(ctx);
- const viewportHeight = Math.max(ctx.scrollRoot.clientHeight, 1);
- const performance = streamPerformance(ctx);
- const overscan = viewportHeight * performance.streamOverscanViewports;
- const velocityLead = Math.min(
- viewportHeight * 2,
- Math.max(0, Number(ctx.streamScrollVelocity) || 0) * 700
- );
- const beforeOverscan = overscan + (ctx.streamScrollDirection < 0 ? velocityLead : 0);
- const afterOverscan = overscan + (ctx.streamScrollDirection > 0 ? velocityLead : 0);
- const localTop = Math.max(0, ctx.scrollRoot.scrollTop - listOffset);
- ctx.streamViewportLocalTop = localTop;
- ctx.streamViewportHeight = viewportHeight;
- ctx.streamViewportTopIndex = findStreamIndexAtOffset(ctx.streamPrefix, localTop);
- ctx.streamViewportBottomIndex = findStreamIndexAtOffset(
- ctx.streamPrefix,
- Math.max(0, localTop + viewportHeight - 10)
- );
- let start = findStreamIndexAtOffset(ctx.streamPrefix, Math.max(0, localTop - beforeOverscan));
- let end = findStreamIndexAtOffset(ctx.streamPrefix, localTop + viewportHeight + afterOverscan) + 1;
- if (end - start > performance.streamMaxItems) {
- const center = findStreamIndexAtOffset(ctx.streamPrefix, localTop + viewportHeight / 2);
- start = Math.max(0, center - Math.floor(performance.streamMaxItems / 2));
- end = Math.min(items.length, start + performance.streamMaxItems);
- start = Math.max(0, end - performance.streamMaxItems);
- }
- const stableRange = stableStreamMountRange(
- ctx,
- start,
- end,
- items.length
- );
- start = stableRange.start;
- end = stableRange.end;
- const needsRepair = ctx.streamNeedsRepair || start !== ctx.streamWindowStart || end !== ctx.streamWindowEnd;
- mountStreamRange(ctx, start, end);
- ctx.streamNeedsRepair = false;
- if (needsRepair) {
- repairBlankStreamViewport(ctx, start, end);
- }
- if (ctx.viewportPersistenceEnabled && ctx.loader && isFunction(ctx.loader.persistViewedPostNumbers)) {
- const visibleStart = Math.max(0, Math.min(items.length - 1, ctx.streamViewportTopIndex));
- const visibleEnd = Math.max(visibleStart, Math.min(items.length - 1, ctx.streamViewportBottomIndex));
- ctx.loader.persistViewedPostNumbers(
- items.slice(visibleStart, visibleEnd + 1).map((item) => item?.postNumber)
- );
- }
- }
-
- function scheduleStreamWindowSync(ctx) {
- if (!ctx || ctx.streamWindowFrame) return;
- ctx.streamWindowFrame = requestAnimationFrame(() => {
- ctx.streamWindowFrame = 0;
- if (ctx.streamViewportAnchorCapturePending) captureStreamViewportAnchor(ctx);
- syncStreamWindow(ctx);
- // Defer geometry reads until the mounted window has completed a layout.
- if (ctx.timeline) ctx.timeline.update();
- });
- }
-
- function flushStreamWindow(ctx) {
- if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame);
- ctx.streamWindowFrame = 0;
- if (ctx.streamViewportAnchorCapturePending) captureStreamViewportAnchor(ctx);
- syncStreamWindow(ctx);
- }
-
- function scheduleFloorPreviewSync(ctx) {
- if (!ctx || !ctx.floorPreview || ctx.floorPreviewSyncFrame) return;
- ctx.floorPreviewSyncFrame = requestAnimationFrame(() => {
- ctx.floorPreviewSyncFrame = 0;
- if (ctx.floorPreview?.isOpen()) ctx.floorPreview.syncPlacement();
- });
- }
-
- const PINNED_STREAM_SCROLL_OPTIONS = Object.freeze({
- preferStream: true,
- pinStreamTarget: true,
- });
-
- function scrollToPost(postNumber, ctx, options = {}) {
- clearStreamViewportAnchor(ctx);
- ctx.streamScrollDirection = 0;
- const programmaticTarget = options.pinStreamTarget === true
- ? beginStreamProgrammaticTarget(ctx, postNumber)
- : null;
- if (programmaticTarget) hydrateStreamNavigationNeighborhood(ctx, postNumber);
- let target = null;
- if (options.preferStream && ctx.streamNodeMap) target = getStreamPostNode(ctx, postNumber, { mount: true });
- if (!target && options.requireStream) {
- if (programmaticTarget) clearStreamProgrammaticTarget(ctx);
- return false;
- }
- if (!target) target = ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${postNumber}"]`);
- if (!target) {
- if (programmaticTarget) clearStreamProgrammaticTarget(ctx);
- return false;
- }
- const previousScrollTop = ctx.scrollRoot.scrollTop;
- const alignOffset = Number(options.alignOffset);
- if (!options.alignTop && !options.alignBottom && !options.alignBottomCenter && !Number.isFinite(alignOffset) &&
- +postNumber < +(ctx.initialTargetHydrationBoundary || 0) &&
- isFunction(ctx.releaseInitialPrecedingContent)) {
- ctx.releaseInitialPrecedingContent();
- }
- hydratePostContent(target, ctx);
- const resolvedVisualTarget = isFunction(options.resolveVisualTarget)
- ? options.resolveVisualTarget(target)
- : options.visualNode;
- const scrollTarget = resolvedVisualTarget?.isConnected ? resolvedVisualTarget : target;
- const resolvedHighlightTarget = isFunction(options.resolveHighlightTarget)
- ? options.resolveHighlightTarget(target)
- : options.highlightTarget;
- const highlightTarget = resolvedHighlightTarget?.isConnected
- ? resolvedHighlightTarget
- : target;
- const expandedJumpTarget = target.classList.contains('ldp-nested-collapsed') && !options.keepCollapsed;
- if (expandedJumpTarget) {
- target.dataset.ldpJumpExpanded = '1';
- setNestedReplyCollapsed(target, false);
- }
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- let targetRect = scrollTarget.getBoundingClientRect();
- const fits = targetRect.height <= rootRect.height;
- if (options.alignBottom && fits) {
- const desiredScrollTop = ctx.scrollRoot.scrollTop + targetRect.bottom - rootRect.bottom;
- const currentTopSpacerHeight = parseFloat(
- ctx.streamTopSpacer.style.getPropertyValue('--ldp-stream-spacer-height')
- ) || 0;
- ctx.initialTargetHeadSpacerHeight = Math.max(
- +(ctx.initialTargetHeadSpacerHeight || 0),
- currentTopSpacerHeight + Math.max(0, -desiredScrollTop) + 2
- );
- setStreamSpacerHeight(
- ctx.streamTopSpacer,
- Math.max(currentTopSpacerHeight, ctx.initialTargetHeadSpacerHeight)
- );
- targetRect = scrollTarget.getBoundingClientRect();
- }
- let correction = targetRect.top - rootRect.top;
- if (Number.isFinite(alignOffset)) correction -= alignOffset;
- else if (options.alignBottomCenter)
- correction = targetRect.bottom - (rootRect.top + rootRect.height / 2);
- else if (options.alignBottom && fits) correction = targetRect.bottom - rootRect.bottom;
- else if ((options.alignCenter ||
- (!options.alignTop && !options.alignNearest && +postNumber !== 1)) && fits)
- correction = targetRect.top + targetRect.height / 2 - (rootRect.top + rootRect.height / 2);
- else if (options.alignNearest) {
- if (targetRect.top < rootRect.top && targetRect.bottom < rootRect.bottom)
- correction = targetRect.top - rootRect.top;
- else if (targetRect.bottom > rootRect.bottom && targetRect.top > rootRect.top)
- correction = targetRect.bottom - rootRect.bottom;
- else correction = 0;
- }
- if (Math.abs(correction) >= 1) ctx.scrollRoot.scrollTop += correction;
- markStreamInternalScroll(ctx, previousScrollTop);
- if (programmaticTarget) clearStreamProgrammaticTarget(ctx);
- if (options.highlight !== false) {
- if (expandedJumpTarget) {
- requestAnimationFrame(() => requestAnimationFrame(() => {
- if (highlightTarget.isConnected && !target.classList.contains('ldp-nested-collapsed')) {
- keepJumpHighlightUntilSettled(highlightTarget);
- }
- }));
- } else {
- keepJumpHighlightUntilSettled(highlightTarget);
- }
- }
- scheduleStreamWindowSync(ctx);
- return true;
- }
-
- function clearJumpBackLinks(ctx) {
- ctx.scrollRoot.querySelectorAll('.ldp-jump-backbar').forEach((el) => el.remove());
- }
-
- function showJumpBackLink(targetPostNumber, sourcePostNumber, ctx) {
- const target = getStreamPostNode(ctx, targetPostNumber, { mount: true }) ||
- ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${targetPostNumber}"]`);
- if (!target || !sourcePostNumber || sourcePostNumber === targetPostNumber) return;
- clearJumpBackLinks(ctx);
- const bar = makeElement('div');
- bar.className = 'ldp-jump-linkbar ldp-jump-backbar';
- bar.innerHTML = `
`;
- target.insertBefore(bar, target.firstChild);
- }
-
- function ensureParentJumpOnStreamPost(targetPostNumber, parentPostNumber, ctx) {
- const target = getStreamPostNode(ctx, targetPostNumber);
- const parent = +parentPostNumber;
- if (!target || !parent || parent === +targetPostNumber) return;
- target.dataset.replyToPostNumber = String(parent);
- ensureParentJumpControl(target, parent);
- }
-
- function findFloorPreviewSource(postNumber, ctx) {
- const num = +postNumber;
- if (!num) return null;
- const threadNode = ctx.replyThreadWindow?.querySelector(`.ldp-post[data-post-number="${num}"]`);
- if (threadNode) return threadNode;
- // Previewing must not remount the virtual window. Detached stream nodes still
- // contain the complete post markup and are safe to clone into the preview.
- const streamNode = ctx.streamNodeMap?.get(num);
- if (streamNode) return streamNode;
- return (ctx.scrollRoot?.querySelector(`.ldp-post[data-post-number="${num}"]`)) ||
- null;
- }
-
- async function ensureFloorPreviewSource(postNumber, ctx) {
- const target = +postNumber;
- let source = findFloorPreviewSource(target, ctx);
- if (!target || source || !ctx) return source;
-
- const loaders = isFunction(ctx.loadPost)
- ? ['single', 'around'].map((scope) => () => ctx.loadPost(target, {
- mount: false,
- priority: POST_REQUEST_PRIORITY.target,
- scope,
- })) : [];
-
- for (const load of loaders) {
- try {
- await load();
- } catch (error) {
- if (shouldStopReaderFallback(error)) break;
- }
- source = findFloorPreviewSource(target, ctx);
- if (source) return source;
- }
- return null;
- }
-
- function resetFloorPreview(preview) {
- preview.textContent = '';
- const body = makeElement('div');
- body.className = 'ldp-floor-preview-body';
- const close = makeElement('button');
- close.className = 'ldp-floor-preview-close';
- close.type = 'button';
- setLabel(close, '关闭预览');
- close.innerHTML = `
Esc 关闭${icon('x')}`;
- preview.append(body, close);
- return body;
- }
-
- function fillFloorPreview(preview, source, postNumber) {
- const body = resetFloorPreview(preview, postNumber);
- const wrap = makeElement('div');
- wrap.className = 'ldp-floor-preview-post';
- const head = source.querySelector(':scope > .ldp-post-head')?.cloneNode(true);
- const content = source.querySelector(':scope > .ldp-content')?.cloneNode(true);
- const boosts = source.querySelector(':scope > .ldp-boost-list')?.cloneNode(true);
- if (head) {
- const readState = head.querySelector(':scope > .ldp-post-read-state');
- head.querySelectorAll(':scope > .ldp-floor, :scope > .ldp-actions').forEach((el) => el.remove());
- const floorBtn = makeElement('button');
- floorBtn.className = 'ldp-floor ldp-jump-preview-self';
- floorBtn.type = 'button';
- floorBtn.dataset.targetPostNumber = String(postNumber);
- setLabel(floorBtn, `跳到楼层 #${postNumber}`);
- floorBtn.textContent = `#${postNumber}`;
- head.appendChild(floorBtn);
- if (readState) head.appendChild(readState);
- preview.insertBefore(head, body);
- const close = preview.querySelector(':scope > .ldp-floor-preview-close');
- if (close) head.appendChild(close);
- }
- if (content) wrap.appendChild(content);
- else {
- const empty = makeElement('div');
- empty.className = 'ldp-floor-preview-tip';
- empty.textContent = `#${postNumber} 暂无可预览内容`;
- wrap.appendChild(empty);
- }
- if (boosts) wrap.appendChild(boosts);
- wrap.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
- body.appendChild(wrap);
- }
-
- function syncFloorPreviewLine(preview, line, layer) {
- const lineRect = line.getBoundingClientRect();
- const previewRect = preview.getBoundingClientRect();
- const layerRect = layer ? layer.getBoundingClientRect() : previewRect;
- const avatar = layer?.querySelector(':scope > .ldp-post-head .ldp-avatar');
- const avatarRect = avatar ? avatar.getBoundingClientRect() : layerRect;
- const previewAvatar = preview.querySelector(':scope > .ldp-post-head .ldp-avatar');
- const previewAvatarRect = previewAvatar ? previewAvatar.getBoundingClientRect() : previewRect;
- const previewX = Math.max(0, previewRect.left - lineRect.left);
- const childX = Math.max(0, avatarRect.left - lineRect.left);
- const childY = Math.max(0, avatarRect.top + avatarRect.height / 2 - lineRect.top);
- const previewY = previewAvatarRect.top + previewAvatarRect.height / 2 - lineRect.top;
- const baseRail = 15;
- const axisX = Math.min(previewX, childX) - baseRail;
- const branchGap = 5;
- const previewBranchGap = 5;
- const previewBranchWidth = Math.max(0, previewX - axisX - previewBranchGap);
- const childBranchWidth = Math.max(0, childX - axisX - branchGap);
- line.style.setProperty('--ldp-parent-axis-left', `${Math.round(axisX)}px`);
- line.style.setProperty('--ldp-parent-axis-top', `${Math.round(previewY)}px`);
- line.style.setProperty('--ldp-parent-preview-rail-width', `${Math.round(previewBranchWidth)}px`);
- line.style.setProperty('--ldp-parent-child-branch-width', `${Math.round(childBranchWidth)}px`);
- line.style.setProperty('--ldp-parent-child-y', `${Math.round(childY)}px`);
- line.style.setProperty('--ldp-parent-preview-y', `${Math.round(previewY)}px`);
- }
-
- function keepFloorPreviewAnchor(layer, scrollRoot, anchorTop, ctx = null) {
- if (!layer || !scrollRoot || !layer.isConnected) return;
- const correction = layer.getBoundingClientRect().top - anchorTop;
- if (Math.abs(correction) < 1) return;
- const previousScrollTop = scrollRoot.scrollTop;
- scrollRoot.scrollTop += correction;
- markStreamInternalScroll(ctx, previousScrollTop);
- }
-
- function preserveFloorPreviewAnchor(layer, scrollRoot, update, ctx = null) {
- if (!layer || !scrollRoot) {
- update();
- return;
- }
- const anchorTop = layer.getBoundingClientRect().top;
- update();
- keepFloorPreviewAnchor(layer, scrollRoot, anchorTop, ctx);
- }
-
- function placeFloorPreview(preview, line, anchor, scrollRoot = null, onFinish = null, ctx = null) {
- const layer = anchor.closest('.ldp-post');
- if (!layer || !layer.parentElement) return;
- preview._ldpRelatedPost = layer;
- let placed = false;
- // Insert the complete preview and correct the anchor in the same task so the
- // browser never paints the intermediate shifted position.
- preserveFloorPreviewAnchor(layer, scrollRoot, () => {
- const currentParent = layer.parentElement;
- if (!currentParent) return;
- if (!placed && (preview.parentElement !== currentParent || preview.nextElementSibling !== line || line.nextElementSibling !== layer)) {
- moveOwnedNodeBefore(currentParent, preview, layer);
- moveOwnedNodeBefore(currentParent, line, layer);
- }
- placed = true;
- preview.classList.add('open');
- line.classList.add('open');
- syncFloorPreviewLine(preview, line, layer);
- }, ctx);
- if (!placed) return;
- syncChildControls(layer);
- if (onFinish) onFinish();
- }
-
- function canShowFloorPreview(anchor) {
- const post = anchor?.closest('.ldp-post');
- return !!post && !post.classList.contains('ldp-nested-collapsed');
- }
-
- async function showFloorPreview(anchor, ctx, state, options = {}) {
- const postNumber = +anchor.dataset.targetPostNumber;
- const allowCollapsed = options.allowCollapsed === true;
- if (!postNumber || (!allowCollapsed && !canShowFloorPreview(anchor))) return;
- clearTimeout(state.hideTimer);
- state.anchor = anchor;
- const token = ++state.token;
- const preview = state.preview;
- // Do not expose a loading shell whose later height change would move the
- // virtual list. Materialize the requested floor DOM off-screen first, then
- // insert the final preview exactly once.
- const source = await ensureFloorPreviewSource(postNumber, ctx);
- if (token !== state.token || state.anchor !== anchor || !anchor.isConnected ||
- (!allowCollapsed && !canShowFloorPreview(anchor))) return;
- if (source) {
- hydratePostContent(source, ctx);
- fillFloorPreview(preview, source, postNumber);
- const floorButton = anchor.closest('.ldp-descendant-replies-window') && preview.querySelector('.ldp-jump-preview-self');
- if (floorButton) floorButton.outerHTML = `
#${postNumber}`;
- } else {
- const body = resetFloorPreview(preview, postNumber);
- body.innerHTML = `
${esc(`还没加载到 #${postNumber}`)}
`;
- }
- const relatedPost = anchor.closest('.ldp-post');
- if (isFunction(options.beforePlace)) options.beforePlace();
- if (!canShowFloorPreview(anchor)) return;
- setNestedReplyEscapeHint(relatedPost, isEscCollapsibleNestedReply(relatedPost));
- const previewScrollRoot = anchor.closest('.ldp-descendant-replies-list') || ctx.scrollRoot;
- placeFloorPreview(preview, state.line, anchor, previewScrollRoot, () => {
- alignCollapsedFloorPreview(preview, relatedPost, options.collapsedBottom, ctx);
- if (ctx.timeline) ctx.timeline.sync();
- }, ctx);
- }
-
- function bindFloorPreview(modal, ctx) {
- const root = ctx.scrollRoot || modal;
- const floorPreviewScope = createLifecycleScope();
- const floorPreviewBundles = new Map();
- const openBundles = new Set();
- const bundleByPost = new WeakMap();
- let activeBundle = null;
- const bundlePost = (bundle) => bundle && (bundle.post || (bundle.anchor?.closest('.ldp-post')));
- const unregisterBundle = (bundle) => {
- if (!bundle) return;
- clearTimeout(bundle.showTimer);
- openBundles.delete(bundle);
- if (bundle.anchor) floorPreviewBundles.delete(bundle.anchor);
- const post = bundlePost(bundle);
- if (post)
- bundleByPost.delete(post);
- removeOwnedNode(bundle.preview);
- removeOwnedNode(bundle.line);
- if (activeBundle === bundle) activeBundle = null;
- };
- const cleanupClosedBundles = () => {
- [...openBundles].forEach((bundle) => {
- // Virtual-list unmounting temporarily detaches an open preview together
- // with its post. Keep that bundle so the same DOM can be mounted again.
- if (!bundle.pending && !bundle.preview.classList.contains('open')) unregisterBundle(bundle);
- });
- };
- const activateBundle = (bundle) => {
- const post = bundlePost(bundle);
- openBundles.add(bundle);
- if (post) bundleByPost.set(post, bundle);
- activeBundle = bundle;
- };
- const bundleVisibleInViewport = (bundle) => {
- if (!bundle || !bundle.preview.classList.contains('open')) return false;
- const rect = bundle.preview.getBoundingClientRect();
- const rootRect = (ctx.scrollRoot || modal).getBoundingClientRect();
- return rect.bottom > rootRect.top && rect.top < rootRect.bottom && rect.right > rootRect.left && rect.left < rootRect.right;
- };
- const hideBundle = (bundle) => {
- if (!bundle) return;
- const post = bundlePost(bundle);
- clearTimeout(bundle.hideTimer);
- bundle.token++;
- const previewScrollRoot = bundle.anchor?.closest('.ldp-descendant-replies-list') || root;
- preserveFloorPreviewAnchor(post, previewScrollRoot, () => {
- bundle.preview.classList.remove('open');
- bundle.line.classList.remove('open');
- }, ctx);
- if (post) syncChildControls(post);
- unregisterBundle(bundle);
- return post;
- };
- const hide = (targetPost = null) => {
- cleanupClosedBundles();
- const bundles = [...openBundles];
- let bundle = targetPost
- ? bundles.find((item) => bundlePost(item) === targetPost)
- : null;
- if (targetPost && !bundle) return null;
- if (!bundle && activeBundle && bundleVisibleInViewport(activeBundle)) bundle = activeBundle;
- if (!bundle) {
- const visibleBundles = bundles.filter(bundleVisibleInViewport);
- bundle = visibleBundles[visibleBundles.length - 1] || activeBundle || bundles[bundles.length - 1];
- }
- return hideBundle(bundle);
- };
- function createFloorPreviewBundle(anchor) {
- const preview = makeElement('div');
- preview.className = 'ldp-floor-preview';
- const line = makeElement('div');
- line.className = 'ldp-floor-preview-link';
- line.setAttribute('aria-hidden', 'true');
- root.appendChild(preview);
- root.appendChild(line);
- const bundle = {
- preview, line, anchor, post: anchor.closest('.ldp-post'), token: 0,
- hideTimer: 0, showTimer: 0, hoverPending: false, pending: false,
- };
- floorPreviewBundles.set(anchor, bundle);
- preview.addEventListener('click', async (e) => {
- if (e.target.closest('.ldp-floor-preview-close')) {
- hideBundle(bundle);
- return;
- }
- const floorBtn = e.target.closest('.ldp-jump-preview-self');
- if (!floorBtn) return;
- consumeEvent(e);
- const targetPostNumber = +floorBtn.dataset.targetPostNumber;
- if (!targetPostNumber) return;
- floorBtn.blur();
- clearJumpBackLinks(ctx);
- await ctx.timeline.jumpTo(targetPostNumber);
- });
- return bundle;
- }
- const bundleForAnchor = (anchor) => floorPreviewBundles.get(anchor) || createFloorPreviewBundle(anchor);
- const show = async (anchor, options = {}) => {
- if (!anchor || (!options.allowCollapsed && !canShowFloorPreview(anchor))) return false;
- const bundle = bundleForAnchor(anchor);
- bundle.pending = true;
- activateBundle(bundle);
- try {
- await showFloorPreview(anchor, ctx, bundle, options);
- } finally {
- bundle.pending = false;
- }
- const opened = bundle.preview.classList.contains('open');
- if (!opened) unregisterBundle(bundle);
- return opened;
- };
- const previewVisibleInViewport = () => {
- cleanupClosedBundles();
- return [...openBundles].some(bundleVisibleInViewport);
- };
- const onFloorPreviewMouseOver = (e) => {
- if (e.target.closest('.ldp-floor-preview')) return;
- const btn = e.target.closest('.ldp-jump-parent');
- if (!btn || !modal.contains(btn)) return;
- if (btn.contains(e.relatedTarget)) return;
- if (!canShowFloorPreview(btn)) {
- const bundle = floorPreviewBundles.get(btn);
- if (bundle) hideBundle(bundle);
- return;
- }
- const bundle = bundleForAnchor(btn);
- if (bundle.preview.classList.contains('open')) return;
- clearTimeout(bundle.showTimer);
- bundle.hoverPending = true;
- bundle.showTimer = setTimeout(async () => {
- bundle.showTimer = 0;
- if (!bundle.hoverPending || !btn.isConnected || !btn.matches(':hover')) {
- bundle.hoverPending = false;
- unregisterBundle(bundle);
- return;
- }
- activateBundle(bundle);
- await showFloorPreview(btn, ctx, bundle);
- bundle.hoverPending = false;
- if (!bundle.preview.classList.contains('open')) unregisterBundle(bundle);
- }, 250);
- };
- const onFloorPreviewMouseOut = (e) => {
- const btn = e.target.closest('.ldp-jump-parent');
- if (!btn || btn.contains(e.relatedTarget)) return;
- const bundle = floorPreviewBundles.get(btn);
- if (!bundle || !bundle.hoverPending) return;
- clearTimeout(bundle.showTimer);
- bundle.showTimer = 0;
- bundle.hoverPending = false;
- if (!bundle.preview.classList.contains('open')) {
- bundle.token++;
- unregisterBundle(bundle);
- }
- };
- const onFloorPreviewFocusIn = (e) => {
- const btn = e.target.closest('.ldp-jump-parent');
- if (!btn || !canShowFloorPreview(btn)) return;
- const bundle = bundleForAnchor(btn);
- clearTimeout(bundle.showTimer);
- bundle.showTimer = 0;
- bundle.hoverPending = false;
- activateBundle(bundle);
- showFloorPreview(btn, ctx, bundle);
- };
- floorPreviewScope.listen(modal, 'mouseover', onFloorPreviewMouseOver);
- floorPreviewScope.listen(modal, 'mouseout', onFloorPreviewMouseOut);
- floorPreviewScope.listen(modal, 'focusin', onFloorPreviewFocusIn);
- const getBundleForPost = (post) => {
- cleanupClosedBundles();
- const bundle = bundleByPost.get(post);
- return bundle ? [bundle.preview, bundle.line, post] : [post];
- };
- const syncPlacement = () => {
- cleanupClosedBundles();
- openBundles.forEach((bundle) => {
- const post = bundlePost(bundle);
- if (!post || !post.isConnected) return;
- if (bundle.preview.nextElementSibling !== bundle.line || bundle.line.nextElementSibling !== post) return;
- syncFloorPreviewLine(bundle.preview, bundle.line, post);
- syncChildControls(post);
- });
- };
- const destroy = () => {
- floorPreviewScope.destroy();
- floorPreviewBundles.forEach((bundle) => {
- clearTimeout(bundle.showTimer);
- clearTimeout(bundle.hideTimer);
- bundle.token++;
- removeOwnedNode(bundle.preview);
- removeOwnedNode(bundle.line);
- });
- floorPreviewBundles.clear();
- openBundles.clear();
- activeBundle = null;
- };
- return {
- show,
- hide,
- isOpen: () => {
- cleanupClosedBundles();
- return openBundles.size > 0;
- },
- isVisibleInViewport: previewVisibleInViewport,
- getBundleForPost,
- syncPlacement,
- destroy,
- };
- }
-
- /* ============ 3. 图片浏览器 ============ */
- function absoluteImageUrl(src) {
- try { return new URL(String(src || ''), location.href).href; }
- catch { return String(src || ''); }
- }
-
- async function cachedEmojiImageSource(rawSource) {
- const source = absoluteImageUrl(rawSource);
- if (!source || source.startsWith('data:') || source.startsWith('blob:') ||
- typeof caches === 'undefined' || RESOURCE_CACHE_CLEARING) return '';
- const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
- const cachedObjectUrl = touchResourceObjectUrl(EMOJI_IMAGE_OBJECT_URL_CACHE, source);
- if (cachedObjectUrl) return cachedObjectUrl;
- try {
- const { response } = await persistentResourceCacheEntry(
- LDP_EMOJI_IMAGE_CACHE_NAME, source, LDP_EMOJI_IMAGE_CACHE_MAX_AGE
- );
- if (!response) return '';
- const blob = await response.blob();
- return blob.size && !resourceCacheSignal.aborted
- ? cacheResourceObjectUrl(EMOJI_IMAGE_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 512)
- : '';
- } catch {
- return '';
- }
- }
-
- function persistEmojiImage(rawSource) {
- const source = absoluteImageUrl(rawSource);
- if (!source || source.startsWith('data:') || source.startsWith('blob:') ||
- typeof caches === 'undefined' || RESOURCE_CACHE_CLEARING) {
- return Promise.resolve();
- }
- const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
- const pendingRequest = EMOJI_IMAGE_RESOURCE_REQUESTS.get(source);
- if (pendingRequest) return pendingRequest;
- const request = (async () => {
- try {
- const { storage, response: existing } = await persistentResourceCacheEntry(
- LDP_EMOJI_IMAGE_CACHE_NAME, source, LDP_EMOJI_IMAGE_CACHE_MAX_AGE
- );
- if (existing) return;
- const resource = await fetchReaderPublicResource(
- source, POST_REQUEST_PRIORITY.background, 'media', resourceCacheSignal
- );
- if (!resource) return;
- try {
- const fetched = resource.response;
- if (!fetched.ok) return;
- const blob = await fetched.blob();
- if (!blob.size || resourceCacheSignal.aborted) return;
- await storage.put(source, createPersistentResourceResponse(blob, fetched.headers.get('content-type')));
- } finally {
- resource.release();
- }
- } catch {}
- })();
- return trackSingleFlightRequest(EMOJI_IMAGE_RESOURCE_REQUESTS, source, request);
- }
-
- function watchPersistentEmojiImages(content) {
- if (!content || content._ldpStopEmojiImageCache) return;
- const hydrate = async (image) => {
- if (!(image instanceof Element) || image.tagName !== 'IMG') return;
- const source = absoluteImageUrl(image.currentSrc || image.src);
- if (!source || source.startsWith('data:') || source.startsWith('blob:')) return;
- image.dataset.ldpEmojiCacheSource = source;
- const cachedSource = await cachedEmojiImageSource(source);
- if (cachedSource) {
- if (image.isConnected && image.dataset.ldpEmojiCacheSource === source) image.src = cachedSource;
- return;
- }
- const persist = () => {
- if (image.dataset.ldpEmojiCacheSource === source) void persistEmojiImage(source);
- };
- if (image.complete && image.naturalWidth) persist();
- else image.addEventListener('load', persist, { once: true });
- };
- const hydrateTree = (node) => {
- if (!(node instanceof Element)) return;
- if (node.matches('img[src]')) void hydrate(node);
- node.querySelectorAll('img[src]').forEach((image) => { void hydrate(image); });
- };
- const observer = new MutationObserver((mutations) => {
- mutations.forEach((mutation) => {
- if (mutation.type === 'attributes') hydrateTree(mutation.target);
- mutation.addedNodes.forEach(hydrateTree);
- });
- });
- content.querySelectorAll('.emoji-picker img[src]').forEach((image) => { void hydrate(image); });
- observer.observe(content, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
- content._ldpStopEmojiImageCache = () => {
- observer.disconnect();
- delete content._ldpStopEmojiImageCache;
- };
- }
-
- async function deletePersistentLightboxImage(rawSource) {
- const source = absoluteImageUrl(rawSource);
- const objectUrl = LIGHTBOX_IMAGE_OBJECT_URL_CACHE.get(source);
- if (objectUrl) URL.revokeObjectURL(objectUrl);
- LIGHTBOX_IMAGE_OBJECT_URL_CACHE.delete(source);
- if (typeof caches === 'undefined') return;
- try {
- const storage = await caches.open(LDP_LIGHTBOX_IMAGE_CACHE_NAME);
- await storage.delete(source);
- } catch {}
- }
-
- function comparableLightboxImageSource(value) {
- const source = absoluteImageUrl(value);
- if (!source) return '';
- try {
- const url = new URL(source, location.href);
- const uploadHash = url.pathname.match(/(?:^|\/)([0-9a-f]{40})(?:\.[a-z0-9]+)?(?:$|\/)/i);
- if (uploadHash) return `upload:${uploadHash[1].toLowerCase()}`;
- return `${url.origin}${decodeURIComponent(url.pathname)}`;
- } catch {
- return String(source).split(/[?#]/, 1)[0];
- }
- }
-
- function lightboxImageItem(img, postNumber = 0, imageOrder = 0, postData = null, topicId = 0) {
- if (!img) return null;
- const originalSrc = absoluteImageUrl(resolveOriginalSrc(img));
- if (!originalSrc) return null;
- const ownerPost = img.closest('.ldp-post');
- const sourcePost = postData || (ownerPost?._ldpPostData) || null;
- const item = {
- originalSrc,
- thumbSrc: absoluteImageUrl(img.currentSrc || img.src || originalSrc),
- alt: String(img.alt || '').trim(),
- postNumber: Math.max(0, +postNumber || +(ownerPost && ownerPost.dataset.postNumber) || 0),
- imageOrder: Math.max(0, +imageOrder || 0),
- topicId: Math.max(0, +topicId || +(sourcePost?.topic_id) || 0),
- postId: Math.max(0, +(sourcePost?.id) || +(ownerPost && ownerPost.dataset.postId) || 0),
- username: String(sourcePost?.username || ownerPost && ownerPost.dataset.username || '').trim(),
- sourcePost,
- };
- item.key = [
- Math.max(0, +(item.topicId || 0)),
- Math.max(0, +(item.postNumber || 0)),
- Math.max(0, +(item.imageOrder || 0)),
- comparableLightboxImageSource(item.originalSrc),
- ].join(':');
- return item;
- }
-
- function isLightboxContentImage(img, quoted = false) {
- if (!img || !isFunction(img.closest)) return false;
- const contentSelector = quoted ? '.ldp-content aside.ldp-post-quote > blockquote' : '.ldp-content';
- if (!img.closest(contentSelector)) return false;
- const isBoostEmoji = img.classList.contains('emoji') ||
- !!(reactionEmojiImageSources?.size &&
- [img.currentSrc, img.src, img.getAttribute('src'), resolveOriginalSrc(img)]
- .map(comparableLightboxImageSource)
- .some((source) => source && reactionEmojiImageSources.has(source)));
- if (isBoostEmoji) return false;
- const excludedSelector = quoted
- ? '.ldp-quote-title,[data-user-card],aside.onebox'
- : 'aside.quote,.ldp-quote-title,[data-user-card],aside.onebox';
- if (img.closest(excludedSelector)) return false;
- const hasAvatarClass = [...img.classList || []]
- .some((name) => /(^|[-_])avatar($|[-_])/i.test(name));
- const source = String(img.currentSrc || img.src || img.getAttribute('src') || '');
- return !hasAvatarClass && !/\/user_avatar\//i.test(source);
- }
-
- const isLightboxFloorImage = (img) => isLightboxContentImage(img);
- const isLightboxQuotedImage = (img) => isLightboxContentImage(img, true);
-
- function lightboxImageFromEventIn(event, selector, predicate) {
- const image = eventPathClosest(event, `${selector} img`);
- if (predicate(image)) return image;
- const link = eventPathClosest(event, `${selector} a.lightbox`);
- const linkedImage = link ? link.querySelector('img') : null;
- return predicate(linkedImage) ? linkedImage : null;
- }
-
- const lightboxImageFromEvent = (event) =>
- lightboxImageFromEventIn(event, '.ldp-content', isLightboxFloorImage);
-
- function collectLightboxItems(clickedImg, ctx) {
- const items = [];
- const seen = new Set();
- const add = (img, postNumber = 0, imageOrder = 0, postData = null) => {
- if (!isLightboxFloorImage(img)) return;
- const item = lightboxImageItem(img, postNumber, imageOrder, postData, ctx?.topicId);
- if (!item || seen.has(item.key)) return;
- seen.add(item.key);
- items.push(item);
- };
- const roots = ctx && Array.isArray(ctx.streamItems)
- ? ctx.streamItems.map((item) => item?.node).filter(Boolean)
- : [];
- if (!roots.length && ctx && ctx.commentsEl)
- roots.push(...ctx.commentsEl.querySelectorAll(':scope > .ldp-post'));
- roots.forEach((root) => {
- const postNumber = +(root.dataset.postNumber || (root._ldpPostData?.post_number)) || 0;
- [...root.querySelectorAll(':scope > .ldp-content img')]
- .filter(isLightboxFloorImage)
- .forEach((img, imageOrder) => add(img, postNumber, imageOrder, root._ldpPostData));
- });
- const clickedContent = clickedImg?.closest('.ldp-content');
- const clickedOrder = clickedContent
- ? [...clickedContent.querySelectorAll('img')].filter(isLightboxFloorImage).indexOf(clickedImg)
- : 0;
- const clickedItem = isLightboxFloorImage(clickedImg)
- ? lightboxImageItem(clickedImg, 0, Math.max(0, clickedOrder), null, ctx?.topicId)
- : null;
- if (clickedItem && !seen.has(clickedItem.key)) {
- seen.add(clickedItem.key);
- items.unshift(clickedItem);
- }
- items.sort(compareLightboxItems);
- const initialIndex = clickedItem
- ? Math.max(0, items.findIndex((item) => item.key === clickedItem.key))
- : 0;
- return { items, initialIndex };
- }
-
- function lightboxItemsFromPost(post, topicId = 0) {
- if (!post || !post.cooked) return [];
- const content = makeElement('div');
- content.className = 'ldp-content cooked';
- setPostCookedContent(content, post.cooked);
- return [...content.querySelectorAll('img')]
- .filter(isLightboxFloorImage)
- .map((img, imageOrder) => lightboxImageItem(
- img,
- post.post_number,
- imageOrder,
- post,
- post.topic_id || topicId,
- ));
- }
-
- async function openQuotedImageLightbox(clickedImg, ctx) {
- const quote = clickedImg?.closest('aside.ldp-post-quote');
- if (!quote || !ctx) return false;
- try {
- const sourcePost = await loadReaderQuotedPost(quote, ctx);
- const sourceTopicId = Math.max(0, +quote.dataset.topic || +(sourcePost?.topic_id) || +ctx.topicId || 0);
- const items = lightboxItemsFromPost(sourcePost, sourceTopicId);
- if (!items.length) throw new Error('quoted image unavailable');
- const clickedSource = comparableLightboxImageSource(resolveOriginalSrc(clickedImg));
- const markedOrder = lightboxImageOrderFromAlt(clickedImg.alt);
- let initialIndex = items.findIndex((item) => (
- comparableLightboxImageSource(item.originalSrc) === clickedSource &&
- (markedOrder == null || +item.imageOrder === markedOrder)
- ));
- if (initialIndex < 0) {
- const quoteBody = quote.querySelector(':scope > blockquote');
- const excerptOrder = quoteBody
- ? [...quoteBody.querySelectorAll('img')].filter(isLightboxQuotedImage).indexOf(clickedImg)
- : -1;
- initialIndex = Math.max(0, Math.min(items.length - 1, excerptOrder));
- }
- openLightbox(null, ctx, {
- items,
- initialIndex,
- fixedItems: true,
- comments: sourceTopicId === +ctx.topicId,
- });
- return true;
- } catch {
- showSelectionToast('引用源图片加载失败,请重试');
- return false;
- }
- }
-
- function compareLightboxItems(left, right) {
- return (+left.postNumber || 0) - (+right.postNumber || 0) ||
- (+left.imageOrder || 0) - (+right.imageOrder || 0);
- }
-
- function lightboxImageOrderMarker(imageOrder) {
- const bits = Math.max(0, +imageOrder || 0).toString(2).padStart(8, '0');
- return `\u2063${bits.replace(/0/g, '\u200B').replace(/1/g, '\u200C')}\u2064`;
- }
-
- function lightboxImageOrderFromAlt(value) {
- const match = String(value || '').match(/\u2063([\u200B\u200C]+)\u2064/);
- if (!match) return null;
- const parsed = Number.parseInt(match[1].replace(/\u200B/g, '0').replace(/\u200C/g, '1'), 2);
- return Number.isFinite(parsed) ? parsed : null;
- }
-
- function lightboxImageQuoteRaw(item, ctx) {
- const sourcePost = item?.sourcePost || {};
- const username = cleanUsernameValue(item?.username || sourcePost.username);
- const postNumber = Math.max(0, +(item?.postNumber || 0));
- const topicId = Math.max(0, +(item?.topicId || ctx?.topicId || 0));
- const source = String(item?.originalSrc || '').replace(//g, '%3E');
- const alt = String(item?.alt || '图片')
- .replace(/\\/g, '\\\\')
- .replace(/\[/g, '\\[')
- .replace(/\]/g, '\\]') + lightboxImageOrderMarker(item?.imageOrder);
- if (!username || !postNumber || !topicId || !source) return '';
- return `[quote="${username}, post:${postNumber}, topic:${topicId}"]\n\n[/quote]\n\n`;
- }
-
- function lightboxQuoteContainsImage(quote, item, expectedSource) {
- return [...quote.querySelectorAll(':scope > blockquote img')].some((image) => {
- if (comparableLightboxImageSource(resolveOriginalSrc(image)) !== expectedSource) return false;
- const markedOrder = lightboxImageOrderFromAlt(image.alt);
- return markedOrder == null || markedOrder === +item.imageOrder;
- });
- }
-
- function postQuotesLightboxImage(post, item) {
- if (!post || !item) return false;
- if (Array.isArray(post._ldpImageReferenceKeys) && post._ldpImageReferenceKeys.includes(item.key)) return true;
- if (!post.cooked || !item.postNumber) return false;
- const template = makeElement('template');
- template.innerHTML = String(post.cooked || '');
- const expectedTopicId = +(item.topicId || 0);
- const expectedSource = comparableLightboxImageSource(item.originalSrc);
- return [...template.content.querySelectorAll('aside.quote')].some((quote) => {
- if (+quote.dataset.post !== +item.postNumber) return false;
- const quotedTopicId = +(quote.dataset.topic || 0);
- if (quotedTopicId && expectedTopicId && quotedTopicId !== expectedTopicId) return false;
- return lightboxQuoteContainsImage(quote, item, expectedSource);
- });
- }
-
- function lightboxSourceDescription(post, item) {
- const clean = (value) => {
- const text = String(value || '')
- .replace(/https?:\/\/\S+/gi, ' ')
- .replace(/\b[^\s/\\]+\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|tiff?|webp)\b/gi, ' ')
- .replace(/\b[0-9a-f]{20,}\b/gi, ' ')
- .replace(/\b\d{2,5}\s*[x×]\s*\d{2,5}\b/gi, ' ')
- .replace(/[x×]\s*\d{2,5}\b/gi, ' ')
- .replace(/\b\d+(?:\.\d+)?\s*(?:bytes?|[kmgt]i?b)\b/gi, ' ')
- .replace(/\s+/g, ' ')
- .replace(/^[\s·•|,,;;::/_-]+|[\s·•|,,;;::/_-]+$/g, '')
- .trim();
- return text;
- };
- const altFallback = clean(item?.alt);
- const fallback = !altFallback || /^(?:该楼层)?图片$/.test(altFallback) ? '无描述' : altFallback;
- if (!post || !post.cooked) return fallback;
- const template = makeElement('template');
- template.innerHTML = String(post.cooked || '');
- template.content.querySelectorAll(
- 'aside.quote,img,video,audio,iframe,canvas,svg,.quote-controls,.lightbox-wrapper .meta,a.lightbox .meta'
- ).forEach((node) => node.remove());
- const text = clean(template.content.textContent);
- if (!text) return fallback;
- return text.length > 420 ? `${text.slice(0, 420).trim()}…` : text;
- }
-
- function normalizeLightboxDescriptionHeight(value) {
- const numeric = Math.round(Number(value));
- const maximum = Math.max(LIGHTBOX_DESCRIPTION_HEIGHT_MIN, Math.floor(window.innerHeight * 0.4));
- return Math.min(
- maximum,
- Math.max(LIGHTBOX_DESCRIPTION_HEIGHT_MIN, Number.isFinite(numeric) ? numeric : LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT)
- );
- }
-
- function normalizeLightboxCommentsWidth(value) {
- const numeric = Number(value);
- return Math.min(
- LIGHTBOX_COMMENTS_WIDTH_MAX,
- Math.max(LIGHTBOX_COMMENTS_WIDTH_MIN, Number.isFinite(numeric) ? numeric : LIGHTBOX_COMMENTS_WIDTH_DEFAULT)
- );
- }
-
- function safeDownloadFilename(value, fallback = 'download') {
- const cleaned = String(value || '')
- .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
- .replace(/[.\s]+$/g, '')
- .trim();
- return (cleaned || fallback).slice(0, 120);
- }
-
- function lightboxDownloadTimestamp(date = new Date()) {
- const part = (value) => String(value).padStart(2, '0');
- return `${date.getFullYear()}年${part(date.getMonth() + 1)}月${part(date.getDate())}日${part(date.getHours())}时${part(date.getMinutes())}分${part(date.getSeconds())}秒`;
- }
-
- function lightboxImageDownloadName(item, index, blob) {
- let sourceName = '';
- try { sourceName = decodeURIComponent(new URL(item?.originalSrc || '', location.href).pathname.split('/').pop() || ''); }
- catch {}
- const mimeExtensions = {
- 'image/avif': 'avif', 'image/bmp': 'bmp', 'image/gif': 'gif', 'image/heic': 'heic',
- 'image/heif': 'heif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/svg+xml': 'svg',
- 'image/tiff': 'tiff', 'image/webp': 'webp',
- };
- let extension = mimeExtensions[String(blob?.type || '').toLowerCase()];
- if (!extension) {
- try {
- const match = new URL(item?.originalSrc || '', location.href).pathname.match(/\.([a-z0-9]{2,5})$/i);
- if (match) extension = match[1].toLowerCase();
- } catch {}
- }
- extension ||= 'img';
- const stem = safeDownloadFilename(sourceName.replace(/\.[a-z0-9]{2,5}$/i, ''), `image-${index + 1}`);
- return `${String(index + 1).padStart(3, '0')}-${stem}.${extension}`;
- }
-
- function triggerBrowserDownload(blob, filename) {
- const objectUrl = URL.createObjectURL(blob);
- const link = makeElement('a');
- link.href = objectUrl;
- link.download = safeDownloadFilename(filename);
- link.hidden = true;
- appendReaderPortalNode(link);
- link.click();
- link.remove();
- setTimeout(() => URL.revokeObjectURL(objectUrl), 60 * 1000);
- }
-
- async function cachedPersistentLightboxBlob(rawSource) {
- const source = absoluteImageUrl(rawSource);
- if (!source) return null;
- if (typeof caches !== 'undefined') {
- try {
- const { response } = await persistentResourceCacheEntry(
- LDP_LIGHTBOX_IMAGE_CACHE_NAME, source, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE
- );
- if (response) {
- const blob = await response.blob();
- if (blob.size) return blob;
- }
- } catch {}
- }
- const objectUrl = touchResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source);
- if (!objectUrl) return null;
- try {
- const response = await trackedReaderFetch(objectUrl);
- const blob = await response.blob();
- return blob.size ? blob : null;
- } catch {
- return null;
- }
- }
-
- function cachedLightboxOriginalBlob(item) {
- return cachedPersistentLightboxBlob(item?.originalSrc);
- }
-
- async function persistLightboxBlob(rawSource, blob, contentType = '',
- resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal) {
- const source = absoluteImageUrl(rawSource);
- if (!/^https?:/i.test(source) || !blob || !blob.size || typeof caches === 'undefined' ||
- RESOURCE_CACHE_CLEARING || resourceCacheSignal.aborted) return;
- try {
- void prunePersistentResourceCacheOnce(LDP_LIGHTBOX_IMAGE_CACHE_NAME, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE);
- const storage = await caches.open(LDP_LIGHTBOX_IMAGE_CACHE_NAME);
- if (!resourceCacheSignal.aborted) {
- await storage.put(source, createPersistentResourceResponse(blob, contentType));
- }
- } catch {}
- }
-
- async function fetchLightboxDownloadBlob(source, priority = POST_REQUEST_PRIORITY.auxiliary, options = {}) {
- if (!source) throw new Error('图片获取失败');
- const normalizedSource = absoluteImageUrl(source);
- const persistentSource = /^https?:/i.test(normalizedSource) ? normalizedSource : '';
- const pendingRequest = LIGHTBOX_IMAGE_RESOURCE_REQUESTS.get(persistentSource);
- const request = pendingRequest || (async () => {
- const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
- const localSource = normalizedSource.startsWith('blob:') || normalizedSource.startsWith('data:');
- const abortWithCacheClear = options.abortWithCacheClear === true && !RESOURCE_CACHE_CLEARING;
- if (persistentSource) {
- if (abortWithCacheClear) {
- void prunePersistentResourceCacheOnce(LDP_LIGHTBOX_IMAGE_CACHE_NAME, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE);
- }
- const cached = await cachedPersistentLightboxBlob(persistentSource);
- if (cached) return cached;
- }
- let resource = null;
- let response;
- try {
- if (localSource) {
- response = await trackedReaderFetch(normalizedSource, {
- credentials: 'omit',
- cache: 'force-cache',
- [REQUEST_FLOW_FETCH_META]: { source: 'reader', priority, type: 'media' },
- });
- } else {
- resource = await fetchReaderPublicResource(
- normalizedSource, priority, 'media', abortWithCacheClear ? resourceCacheSignal : undefined
- );
- if (!resource) throw new Error('图片请求已暂停');
- response = resource.response;
- }
- if (!response.ok) throw new Error(`图片获取失败(${response.status})`);
- const blob = await response.blob();
- if (!blob.size) throw new Error('图片内容为空');
- if (persistentSource) {
- await persistLightboxBlob(persistentSource, blob, response.headers.get('content-type'), resourceCacheSignal);
- }
- return blob;
- } finally {
- if (resource) resource.release();
- }
- })();
- const tracked = persistentSource && !pendingRequest
- ? trackSingleFlightRequest(LIGHTBOX_IMAGE_RESOURCE_REQUESTS, persistentSource, request)
- : request;
- try {
- return await tracked;
- } catch (error) {
- if (options.abortWithCacheClear !== true && error && error.name === 'AbortError' && RESOURCE_CACHE_CLEARING) {
- return fetchLightboxDownloadBlob(source, priority, options);
- }
- throw error;
- }
- }
-
- async function persistentLightboxImageSource(rawSource, options = {}) {
- const source = absoluteImageUrl(rawSource);
- if (!source || source.startsWith('data:') || source.startsWith('blob:') || RESOURCE_CACHE_CLEARING) return source;
- if (typeof caches === 'undefined') return options.cachedOnly ? '' : source;
- const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
- const cachedObjectUrl = touchResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source);
- if (cachedObjectUrl) return cachedObjectUrl;
- try {
- const blob = options.cachedOnly
- ? await cachedPersistentLightboxBlob(source)
- : await fetchLightboxDownloadBlob(source, POST_REQUEST_PRIORITY.auxiliary, { abortWithCacheClear: true });
- return blob && !resourceCacheSignal.aborted
- ? cacheResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 32)
- : options.cachedOnly ? '' : source;
- } catch (error) {
- return error && error.message === '图片请求已暂停' ? '' : source;
- }
- }
-
- async function lightboxImageDownloadBlob(item, options = {}) {
- const cachedOriginal = await cachedLightboxOriginalBlob(item);
- if (cachedOriginal) return cachedOriginal;
- const originalSrc = item?.originalSrc;
- const previewSrc = item && (item.thumbSrc || originalSrc);
- if (options.original !== true && previewSrc && previewSrc !== originalSrc) {
- return fetchLightboxDownloadBlob(previewSrc);
- }
- return fetchLightboxDownloadBlob(originalSrc);
- }
-
- async function downloadLightboxItem(item, index) {
- try {
- const missingOriginals = await lightboxMissingOriginalCount([item]);
- const useOriginal = missingOriginals > 0 && globalThis.confirm(
- '当前原图尚未缓存。\n\n确定:按阅读器限速获取并下载原图\n取消:下载当前最高缓存/预览质量'
- );
- const blob = await lightboxImageDownloadBlob(item, { original: useOriginal });
- triggerBrowserDownload(blob, lightboxImageDownloadName(item, index, blob).replace(/^\d+-/, ''));
- } catch (error) {
- showSelectionToast(`图片下载失败:${error.message || '请重试'}`);
- }
- }
-
- async function lightboxMissingOriginalCount(items) {
- const candidates = (Array.isArray(items) ? items : []).filter((item) => (
- item?.originalSrc && item.thumbSrc && item.originalSrc !== item.thumbSrc
- ));
- let missing = 0;
- for (const item of candidates) {
- if (!await cachedLightboxOriginalBlob(item)) missing++;
- }
- return missing;
- }
-
- function lightboxZipCrc32(bytes) {
- if (!lightboxZipCrc32.table) {
- lightboxZipCrc32.table = new Uint32Array(256);
- for (let index = 0; index < 256; index++) {
- let value = index;
- for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ value >>> 1 : value >>> 1;
- lightboxZipCrc32.table[index] = value >>> 0;
- }
- }
- let crc = 0xffffffff;
- for (let index = 0; index < bytes.length; index++) crc = lightboxZipCrc32.table[(crc ^ bytes[index]) & 0xff] ^ crc >>> 8;
- return (crc ^ 0xffffffff) >>> 0;
- }
-
- function createStoredZip(entries) {
- const encoder = new globalThis.TextEncoder();
- const localParts = [];
- const centralParts = [];
- let localOffset = 0;
- const modifiedAt = new Date();
- const dosTime = modifiedAt.getHours() << 11 | modifiedAt.getMinutes() << 5 | modifiedAt.getSeconds() >> 1;
- const dosDate = Math.max(0, modifiedAt.getFullYear() - 1980) << 9 | modifiedAt.getMonth() + 1 << 5 | modifiedAt.getDate();
- entries.forEach((entry) => {
- const name = encoder.encode(entry.name);
- const bytes = entry.bytes;
- const crc = lightboxZipCrc32(bytes);
- const local = new Uint8Array(30 + name.length);
- const localView = new DataView(local.buffer);
- localView.setUint32(0, 0x04034b50, true);
- localView.setUint16(4, 20, true);
- localView.setUint16(6, 0x0800, true);
- localView.setUint16(8, 0, true);
- localView.setUint16(10, dosTime, true);
- localView.setUint16(12, dosDate, true);
- localView.setUint32(14, crc, true);
- localView.setUint32(18, bytes.length, true);
- localView.setUint32(22, bytes.length, true);
- localView.setUint16(26, name.length, true);
- local.set(name, 30);
- const central = new Uint8Array(46 + name.length);
- const centralView = new DataView(central.buffer);
- centralView.setUint32(0, 0x02014b50, true);
- centralView.setUint16(4, 20, true);
- centralView.setUint16(6, 20, true);
- centralView.setUint16(8, 0x0800, true);
- centralView.setUint16(10, 0, true);
- centralView.setUint16(12, dosTime, true);
- centralView.setUint16(14, dosDate, true);
- centralView.setUint32(16, crc, true);
- centralView.setUint32(20, bytes.length, true);
- centralView.setUint32(24, bytes.length, true);
- centralView.setUint16(28, name.length, true);
- centralView.setUint32(42, localOffset, true);
- central.set(name, 46);
- localParts.push(local, bytes);
- centralParts.push(central);
- localOffset += local.length + bytes.length;
- });
- const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
- const end = new Uint8Array(22);
- const endView = new DataView(end.buffer);
- endView.setUint32(0, 0x06054b50, true);
- endView.setUint16(8, entries.length, true);
- endView.setUint16(10, entries.length, true);
- endView.setUint32(12, centralSize, true);
- endView.setUint32(16, localOffset, true);
- return new globalThis.Blob([...localParts, ...centralParts, end], { type: 'application/zip' });
- }
-
- function mergeLightboxPosts(target, posts) {
- (Array.isArray(posts) ? posts : []).forEach((post) => {
- const postNumber = +(post?.post_number || 0);
- if (!postNumber || !post.id) return;
- const previous = target.get(postNumber);
- target.set(postNumber, previous ? Object.assign(previous, post) : post);
- });
- return target;
- }
-
- function knownLightboxPosts(ctx) {
- const posts = new Map();
- mergeLightboxPosts(posts, ctx?.loader && ctx.loader.cachedPosts);
- mergeLightboxPosts(posts, postsFromPayload(ctx?.topicData));
- if (ctx && Array.isArray(ctx.streamItems))
- mergeLightboxPosts(posts, ctx.streamItems.map((item) => item?.node && item.node._ldpPostData));
- if (ctx?.directRepliesByParent)
- ctx.directRepliesByParent.forEach((replies) => mergeLightboxPosts(posts, [...replies.values()]));
- if (ctx?.subReplyState)
- ctx.subReplyState.forEach((state) => mergeLightboxPosts(posts, state?.all));
- if (ctx?.lightboxImageCommentPosts) mergeLightboxPosts(posts, [...ctx.lightboxImageCommentPosts.values()]);
- return posts;
- }
-
- async function loadLightboxTopicPosts(ctx) {
- if (!ctx || !ctx.topicId || !ctx.loader || !isFunction(ctx.loader.loadPostsByIds)) return [];
- if (ctx.lightboxTopicPostsPromise) return ctx.lightboxTopicPostsPromise;
- const request = (async () => {
- const posts = new Map();
- let topic = ctx.loader.topic || ctx.topicData;
- let stream = topicPostStream(topic).stream;
- const expectedCount = Math.max(0, Number(topic?.posts_count) || 0);
- if ((!Array.isArray(stream) || !stream.length || expectedCount > stream.length) &&
- isFunction(ctx.loader.refreshTopicData)) {
- topic = await ctx.loader.refreshTopicData({ priority: POST_REQUEST_PRIORITY.background });
- stream = topicPostStream(topic).stream;
- }
- const postIds = [...new Set((Array.isArray(stream) ? stream : [])
- .map(Number).filter((postId) => postId > 0))];
- const batchSize = 20;
- for (let offset = 0; offset < postIds.length; offset += batchSize) {
- const loaded = await ctx.loader.loadPostsByIds(postIds.slice(offset, offset + batchSize), {
- priority: POST_REQUEST_PRIORITY.background,
- persistFetchedPosts: true,
- });
- mergeLightboxPosts(posts, loaded);
- }
- return [...posts.values()];
- })();
- ctx.lightboxTopicPostsPromise = request.catch((error) => {
- ctx.lightboxTopicPostsPromise = null;
- throw error;
- });
- return ctx.lightboxTopicPostsPromise;
- }
-
- function buildLightboxCommentTree(postsByNumber, directMatchNumbers) {
- const childrenByParent = new Map();
- postsByNumber.forEach((post) => {
- const parentNumber = +(post.reply_to_post_number || 0);
- if (!parentNumber || !postsByNumber.has(parentNumber)) return;
- if (!childrenByParent.has(parentNumber)) childrenByParent.set(parentNumber, []);
- childrenByParent.get(parentNumber).push(post);
- });
- childrenByParent.forEach((children) => children.sort((left, right) => +left.post_number - +right.post_number));
- const roots = [...directMatchNumbers]
- .map((postNumber) => postsByNumber.get(postNumber))
- .filter((post) => post && !postsByNumber.has(+(post.reply_to_post_number || 0)))
- .sort((left, right) => +left.post_number - +right.post_number);
- return { roots, childrenByParent };
- }
-
- function rememberLightboxSourcePost(item, sourcePost) {
- if (!sourcePost) return;
- item.sourcePost = sourcePost;
- item.postId = +(sourcePost.id || item.postId || 0);
- item.username = String(sourcePost.username || item.username || '');
- }
-
- function lightboxCommentTreeState(sourcePost, postsByNumber, directMatchNumbers, partial) {
- const { roots, childrenByParent } = buildLightboxCommentTree(postsByNumber, directMatchNumbers);
- return {
- sourcePost, postsByNumber, directMatchNumbers, roots, childrenByParent,
- partial: partial === true,
- };
- }
-
- function cachedLightboxCommentRelation(item, ctx, knownPosts) {
- const entry = ctx && TOPIC_DATA_CACHE.get(String(ctx.topicId));
- const topic = entry?.topic || ctx?.topicData;
- const relations = topic?._ldpLightboxCommentRelations;
- const relation = relations?.[item && item.key];
- if (!relation || Date.now() - Number(relation.cachedAt || 0) > LIGHTBOX_COMMENT_RELATION_CACHE_FRESH_FOR) return null;
- const postsByNumber = new Map();
- mergeLightboxPosts(postsByNumber, relation.posts);
- if (knownPosts) {
- for (const postNumber of postsByNumber.keys()) {
- const fresh = knownPosts.get(postNumber);
- if (fresh) postsByNumber.set(postNumber, fresh);
- }
- }
- const directMatchNumbers = new Set((relation.directMatchNumbers || []).map(Number).filter((value) => value > 0));
- if (directMatchNumbers.size && ![...directMatchNumbers].every((postNumber) => postsByNumber.has(postNumber))) return null;
- const sourcePostNumber = +(relation.sourcePostNumber || item?.postNumber || 0);
- const sourcePost = knownPosts?.get(sourcePostNumber) || relation.sourcePost || null;
- rememberLightboxSourcePost(item, sourcePost);
- return lightboxCommentTreeState(sourcePost, postsByNumber, directMatchNumbers, relation.partial);
- }
-
- function persistLightboxCommentRelation(item, ctx, state) {
- if (!item || !item.key || !ctx || !ctx.topicId || !state) return;
- const entry = TOPIC_DATA_CACHE.get(String(ctx.topicId));
- const topic = entry?.topic || ctx.topicData;
- if (!topic) return;
- const relations = topic._ldpLightboxCommentRelations && typeof topic._ldpLightboxCommentRelations === 'object'
- ? topic._ldpLightboxCommentRelations
- : {};
- relations[item.key] = {
- cachedAt: Date.now(),
- sourcePostNumber: +(state.sourcePost?.post_number || item.postNumber || 0),
- sourcePost: state.sourcePost || item.sourcePost || null,
- directMatchNumbers: [...state.directMatchNumbers || []],
- posts: [...state.postsByNumber?.values() || []],
- partial: state.partial === true,
- };
- const keys = Object.keys(relations).sort((left, right) => (
- Number(relations[right]?.cachedAt || 0) - Number(relations[left]?.cachedAt || 0)
- ));
- keys.slice(64).forEach((key) => delete relations[key]);
- topic._ldpLightboxCommentRelations = relations;
- setCachedTopicData(ctx.topicId, topic);
- }
-
- async function loadLightboxCommentTree(item, ctx) {
- const knownPosts = knownLightboxPosts(ctx);
- const cachedRelation = cachedLightboxCommentRelation(item, ctx, knownPosts);
- if (cachedRelation) return cachedRelation;
- let sourcePost = item?.sourcePost || knownPosts.get(+(item?.postNumber || 0)) || null;
- if (!sourcePost && ctx && ctx.loader && isFunction(ctx.loader.loadPost)) {
- const loaded = await ctx.loader.loadPost(item.postNumber, {
- advanceCursor: false,
- priority: POST_REQUEST_PRIORITY.visible,
- });
- sourcePost = loaded.find((post) => +post.post_number === +item.postNumber) || null;
- mergeLightboxPosts(knownPosts, loaded);
- }
- rememberLightboxSourcePost(item, sourcePost);
- let partial = false;
- const [sourceRepliesResult, topicPostsResult] = await Promise.allSettled([
- sourcePost ? ctx.repliesIO.loadDirectReplies(sourcePost, { force: true }) : Promise.resolve([]),
- loadLightboxTopicPosts(ctx),
- ]);
- if (sourceRepliesResult.status === 'fulfilled') mergeLightboxPosts(knownPosts, sourceRepliesResult.value);
- else partial = true;
- if (topicPostsResult.status === 'fulfilled') mergeLightboxPosts(knownPosts, topicPostsResult.value);
- else partial = true;
-
- const directMatchNumbers = new Set();
- knownPosts.forEach((post, postNumber) => {
- if (postQuotesLightboxImage(post, item)) directMatchNumbers.add(postNumber);
- });
- const included = new Map();
- directMatchNumbers.forEach((postNumber) => {
- const post = knownPosts.get(postNumber);
- if (post) included.set(postNumber, post);
- });
- const queue = [...included.values()].sort((left, right) => +left.post_number - +right.post_number);
- const fetchedPostIds = new Set();
- const includeReplies = (replies) => {
- replies.forEach((reply) => {
- const replyNumber = +(reply?.post_number || 0);
- if (!replyNumber) return;
- if (postQuotesLightboxImage(reply, item)) directMatchNumbers.add(replyNumber);
- if (!included.has(replyNumber)) {
- included.set(replyNumber, reply);
- queue.push(reply);
- }
- });
- };
- while (queue.length) {
- const parent = queue.shift();
- const postId = +(parent?.id || 0);
- if (!postId || fetchedPostIds.has(postId)) continue;
- fetchedPostIds.add(postId);
- const parentNumber = +(parent.post_number || 0);
- const localChildren = [...knownPosts.values()].filter((post) => (
- +(post?.reply_to_post_number || 0) === parentNumber
- ));
- includeReplies(localChildren);
- const knownChildren = ctx.directRepliesByParent?.get(parentNumber);
- if (!(+(parent.reply_count || 0) > localChildren.length || knownChildren && knownChildren.size > localChildren.length)) continue;
- try {
- const replies = await ctx.repliesIO.loadDirectReplies(parent);
- includeReplies(replies);
- } catch {
- partial = true;
- }
- }
- const state = lightboxCommentTreeState(sourcePost, included, directMatchNumbers, partial);
- persistLightboxCommentRelation(item, ctx, state);
- return state;
- }
-
- function openLightbox(clickedImg, ctx, options = {}) {
- const providedItems = Array.isArray(options.items) ? options.items.filter(Boolean) : null;
- const imageSet = providedItems
- ? {
- items: providedItems.slice(),
- initialIndex: Math.max(0, Math.min(providedItems.length - 1, +options.initialIndex || 0)),
- }
- : collectLightboxItems(clickedImg, ctx);
- const { items, initialIndex } = imageSet;
- if (!items.length) return;
- const siteSettings = lookupDiscourse('service:site-settings');
- const minimumCommentLength = Math.max(1, +(siteSettings?.min_post_length) || 16);
- const lb = makeElement('div');
- const lightboxScope = createLifecycleScope();
- lb.className = 'ldp-lightbox';
- lb.setAttribute('role', 'dialog');
- lb.setAttribute('aria-modal', 'true');
- setLabel(lb, '图片预览');
- lb.innerHTML = `
-
-
-
-
-
-
-
-
1 ? '' : ' hidden'}>
-
-
- ${items.map((item, index) => `
`).join('')}
-
-
-
-
- 批量下载
- 名称
-
-
-
-
-
-
`;
- const stage = lb.querySelector('.ldp-lb-stage');
- const mainImg = lb.querySelector('.ldp-lb-image');
- const status = lb.querySelector('.ldp-lb-status');
- const statusText = status.querySelector('span');
- const retryBtn = status.querySelector('.ldp-lb-retry');
- const countEl = lb.querySelector('.ldp-lb-count');
- const zoomValue = lb.querySelector('.ldp-lb-zoom-value');
- const viewOriginalBtn = lb.querySelector('[data-lb-action="view-original"]');
- const jumpToPostBtn = lb.querySelector('[data-lb-action="jump-to-post"]');
- const commentsToggle = lb.querySelector('.ldp-lb-comments-toggle');
- const commentsPanel = lb.querySelector('.ldp-lb-comments');
- const commentsResizer = lb.querySelector('.ldp-lb-comments-resizer');
- const commentsToggleCount = commentsToggle.querySelector('.ldp-lb-comments-count');
- const commentsHeadingCount = lb.querySelector('.ldp-lb-comments-head span');
- const commentsDescriptionToggle = lb.querySelector('.ldp-lb-description-toggle');
- const commentsSource = lb.querySelector('.ldp-lb-source');
- const commentsSourceText = commentsSource.querySelector('.ldp-lb-source-text');
- const commentsSourceReactions = lb.querySelector('.ldp-lb-source-reactions');
- const commentsStatus = lb.querySelector('.ldp-lb-comments-status');
- const commentsEmpty = lb.querySelector('.ldp-lb-comments-empty');
- const commentsList = lb.querySelector('.ldp-lb-comment-list');
- const commentForm = lb.querySelector('.ldp-lb-comment-form');
- const commentTargetLabel = commentForm.querySelector('.ldp-lb-comment-target');
- const commentInput = commentForm.querySelector('.ldp-lb-comment-input');
- const commentImageOption = commentForm.querySelector('.ldp-lb-comment-image-option');
- const commentImageCheckbox = commentImageOption.querySelector('input');
- const commentError = commentForm.querySelector('.ldp-lb-comment-error');
- const prevBtn = lb.querySelector('.ldp-lb-prev');
- const nextBtn = lb.querySelector('.ldp-lb-next');
- const filmstrip = lb.querySelector('.ldp-lb-filmstrip');
- const thumbsEl = lb.querySelector('.ldp-lb-thumbs');
- let thumbs = [...lb.querySelectorAll('.ldp-lb-thumb')];
- const progress = lb.querySelector('.ldp-lb-strip-progress > span');
- const batchOverlay = lb.querySelector('.ldp-lb-batch-overlay');
- const batchCloseButtons = lb.querySelectorAll('.ldp-lb-batch-close,.ldp-lb-batch-cancel');
- const batchNameInput = lb.querySelector('.ldp-lb-batch-name input');
- const batchScopeButtons = [...lb.querySelectorAll('[data-lb-batch-scope]')];
- const batchSelectAll = lb.querySelector('.ldp-lb-batch-select-all');
- const batchCount = lb.querySelector('.ldp-lb-batch-count');
- const batchGrid = lb.querySelector('.ldp-lb-batch-grid');
- const batchProgress = lb.querySelector('.ldp-lb-batch-progress');
- const batchProgressCopy = lb.querySelector('.ldp-lb-batch-progress-copy');
- const batchProgressTrack = lb.querySelector('.ldp-lb-batch-progress-track');
- const batchStatus = lb.querySelector('.ldp-lb-batch-status');
- const batchDownloadButton = lb.querySelector('.ldp-lb-batch-download');
- const batchSelection = new Set();
- const batchThumbnailUrls = new Map();
- const batchThumbnailQueue = [];
- let batchThumbnailActive = 0;
- let batchThumbnailObserver = null;
- let batchBusy = false;
- let batchScope = 'loaded';
- let batchLoadedScopeKeys = new Set();
- let batchLoadedForwardPostNumber = 0;
- let batchLoadedScopeExhausted = false;
- let batchAllImagesLoaded = false;
- const commentsEnabled = options.comments !== false;
- const fixedItems = options.fixedItems === true;
- commentsToggle.hidden = !commentsEnabled;
- commentsPanel.hidden = !commentsEnabled;
- const seenImageKeys = new Set(items.map((item) => item.key));
- const commentCache = new Map();
- let currentIndex = initialIndex;
- let mainImageRequestToken = 0;
- let commentRequestToken = 0;
- let commentTargetPost = null;
- let commentsManuallyToggled = false;
- let commentsResize = null;
- let commentsResizeFrame = 0;
- const initialPostNumber = +(items[initialIndex]?.postNumber) || 0;
- let exploringPrevious = false;
- let exploringNext = false;
- let jumpingToPost = false;
- let backwardScanPostNumber = initialPostNumber;
- let forwardScanPostNumber = initialPostNumber;
- let backwardExhausted = fixedItems || !initialPostNumber ||
- !(ctx?.loader && isFunction(ctx.loader.loadBeforePost)) ||
- initialPostNumber <= 1;
- let forwardExhausted = fixedItems || !initialPostNumber ||
- !(ctx?.loader && isFunction(ctx.loader.loadAfterPost));
- let lightboxClosed = false;
- const commentsExpandedByDefault = commentsEnabled && PREFS.lightboxCommentsExpandedByDefault === true;
- lb.classList.toggle('ldp-lb-comments-collapsed', !commentsExpandedByDefault);
- setExpanded(commentsToggle, commentsExpandedByDefault);
- setLabel(commentsToggle, commentsExpandedByDefault ? '收起图片评论' : '展开图片评论');
- lb.style.setProperty('--ldp-lb-description-height', `${normalizeLightboxDescriptionHeight(PREFS.lightboxDescriptionHeight)}px`);
- let commentsWidthPercent = normalizeLightboxCommentsWidth(PREFS.lightboxCommentsWidthPercent);
- const setViewOriginalButtonState = (disabled, busy, label) => {
- viewOriginalBtn.disabled = disabled;
- if (busy) viewOriginalBtn.setAttribute('aria-busy', 'true');
- else viewOriginalBtn.removeAttribute('aria-busy');
- if (label) setLabel(viewOriginalBtn, label);
- };
- const imageTransform = createImageTransformController(stage, mainImg, {
- overflowPadding: 24,
- allowContainedPan: true,
- resetPanAtFit: false,
- zoomValue,
- zoomOutButton: lb.querySelector('[data-lb-action="zoom-out"]'),
- zoomInButton: lb.querySelector('[data-lb-action="zoom-in"]'),
- render: ({ scale, panX, panY }) => {
- mainImg.style.setProperty('--ldp-lb-scale', String(scale));
- mainImg.style.setProperty('--ldp-lb-pan-x', `${Math.round(panX)}px`);
- mainImg.style.setProperty('--ldp-lb-pan-y', `${Math.round(panY)}px`);
- },
- });
- const { render: renderTransform, setZoom, reset: resetView } = imageTransform;
- const applyCommentsWidth = (value, persist = false) => {
- commentsWidthPercent = normalizeLightboxCommentsWidth(value);
- lb.style.setProperty('--ldp-lb-comments-width-preferred', `${commentsWidthPercent}%`);
- commentsResizer.setAttribute('aria-valuemin', String(LIGHTBOX_COMMENTS_WIDTH_MIN));
- commentsResizer.setAttribute('aria-valuemax', String(LIGHTBOX_COMMENTS_WIDTH_MAX));
- commentsResizer.setAttribute('aria-valuenow', String(Math.round(commentsWidthPercent)));
- if (persist && commentsWidthPercent !== normalizeLightboxCommentsWidth(PREFS.lightboxCommentsWidthPercent))
- setPref('lightboxCommentsWidthPercent', commentsWidthPercent);
- requestAnimationFrame(() => renderTransform());
- };
- applyCommentsWidth(commentsWidthPercent);
- const totalPostNumber = () => Math.max(
- 0,
- +(ctx?.totalPosts || 0),
- +(ctx?.topicData && ctx.topicData.highest_post_number || 0)
- );
- if (initialPostNumber && totalPostNumber() && initialPostNumber >= totalPostNumber()) {
- forwardExhausted = true;
- }
- const scrollActiveThumbIntoView = () => {
- const thumb = thumbs[currentIndex];
- if (thumb) thumb.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
- };
- const updateNavigationState = () => {
- const exploring = exploringPrevious || exploringNext;
- [
- [prevBtn, exploringPrevious, currentIndex === 0, backwardExhausted,
- ['正在加载更早图片', '已经到头了', '继续点击加载更早图片', '上一张']],
- [nextBtn, exploringNext, currentIndex === items.length - 1, forwardExhausted,
- ['正在加载更多图片', '已经到底了', '继续点击加载更多图片', '下一张']],
- ].forEach(([button, busy, atDiscoveredEdge, exhausted, labels]) => {
- const atPostEdge = atDiscoveredEdge && exhausted;
- button.disabled = exploring;
- button.setAttribute('aria-disabled', String(atPostEdge));
- button.setAttribute('aria-busy', String(busy));
- setLabel(button, labels[busy ? 0 : atPostEdge ? 1 : atDiscoveredEdge ? 2 : 3]);
- });
- };
- const updateJumpToPostState = () => {
- const postNumber = +(items[currentIndex]?.postNumber) || 0;
- jumpToPostBtn.disabled = jumpingToPost || !postNumber;
- jumpToPostBtn.setAttribute('aria-busy', String(jumpingToPost));
- setLabel(jumpToPostBtn, '跳到楼层');
- };
- const syncThumbs = () => {
- filmstrip.hidden = items.length < 2;
- thumbsEl.innerHTML = items.map((item, index) => {
- return `
`;
- }).join('');
- thumbs = [...thumbsEl.querySelectorAll('.ldp-lb-thumb')];
- if (!batchOverlay.hidden) renderBatchSelection();
- };
- const batchScopeItems = () => batchScope === 'all'
- ? items
- : items.filter((item) => batchLoadedScopeKeys.has(item.key));
- const batchCanLoadMore = () => batchScope === 'loaded' && !fixedItems && !batchLoadedScopeExhausted &&
- ctx && ctx.loader && isFunction(ctx.loader.loadAfterPost);
- const syncBatchScopeControls = () => {
- batchScopeButtons.forEach((button) => {
- const active = button.dataset.lbBatchScope === batchScope;
- setPressed(button, active);
- button.setAttribute('aria-selected', String(active));
- button.tabIndex = active ? 0 : -1;
- button.disabled = batchBusy || fixedItems && button.dataset.lbBatchScope === 'all';
- });
- };
- const syncBatchSelectionControls = () => {
- const scopedItems = batchScopeItems();
- const allSelected = scopedItems.length > 0 && scopedItems.every((item) => batchSelection.has(item.key));
- setPressed(batchSelectAll, allSelected);
- batchSelectAll.innerHTML = `${icon(allSelected ? 'squareCheck' : 'square')}
${allSelected ? '全不选' : '全选'}`;
- batchCount.textContent = `已选 ${batchSelection.size} / ${scopedItems.length}`;
- batchSelectAll.disabled = batchBusy;
- batchDownloadButton.disabled = batchBusy || batchSelection.size === 0;
- syncBatchScopeControls();
- };
- const batchThumbnailPersistentKey = (item) => {
- const source = absoluteImageUrl(item && (item.thumbSrc || item.originalSrc));
- if (!/^https?:/i.test(source)) return '';
- try {
- const key = new URL(source);
- key.searchParams.set('__ldp_cached_thumbnail', '240-webp-v1');
- return key.href;
- } catch {
- return '';
- }
- };
- const createBatchThumbnail = async (item) => {
- const persistentKey = batchThumbnailPersistentKey(item);
- const blob = await fetchLightboxDownloadBlob(item.thumbSrc || item.originalSrc, POST_REQUEST_PRIORITY.background);
- if (!isFunction(globalThis.createImageBitmap)) return URL.createObjectURL(blob);
- let bitmap;
- try {
- bitmap = await globalThis.createImageBitmap(blob, { resizeWidth: 240, resizeQuality: 'low' });
- } catch {
- // Some browser-supported image formats cannot be decoded by createImageBitmap.
- // Keep the fetched image usable instead of leaving a permanently empty card.
- return URL.createObjectURL(blob);
- }
- const width = Math.max(1, Math.min(240, bitmap.width));
- const height = Math.max(1, Math.round(bitmap.height * width / bitmap.width));
- const canvas = makeElement('canvas');
- canvas.width = width;
- canvas.height = height;
- const context = canvas.getContext('2d', { alpha: true });
- if (!context) {
- bitmap.close();
- return URL.createObjectURL(blob);
- }
- context.drawImage(bitmap, 0, 0, width, height);
- bitmap.close();
- const thumbnailBlob = await new Promise((resolve) => {
- canvas.toBlob(resolve, 'image/webp', 0.78);
- });
- const result = thumbnailBlob?.size ? thumbnailBlob : blob;
- if (persistentKey && result === thumbnailBlob) await persistLightboxBlob(persistentKey, result, 'image/webp');
- return URL.createObjectURL(result);
- };
- const drainBatchThumbnailQueue = () => {
- while (!lightboxClosed && batchThumbnailActive < 4 && batchThumbnailQueue.length) {
- const task = batchThumbnailQueue.shift();
- batchThumbnailActive++;
- Promise.resolve().then(task).catch(() => {}).finally(() => {
- batchThumbnailActive--;
- drainBatchThumbnailQueue();
- });
- }
- };
- const enqueueBatchThumbnail = (task) => {
- batchThumbnailQueue.push(task);
- drainBatchThumbnailQueue();
- };
- const loadBatchThumbnail = (image, item) => {
- if (!image || !item || image.dataset.ldpBatchThumbLoading === '1') return;
- const cached = batchThumbnailUrls.get(item.key);
- if (cached) {
- image.dataset.ldpBatchThumbState = 'loading';
- image.src = cached;
- return;
- }
- image.dataset.ldpBatchThumbLoading = '1';
- const displayThumbnail = (source) => {
- if (lightboxClosed) {
- URL.revokeObjectURL(source);
- return;
- }
- const previous = batchThumbnailUrls.get(item.key);
- if (previous && previous !== source) URL.revokeObjectURL(source);
- else batchThumbnailUrls.set(item.key, source);
- if (image.isConnected && image.dataset.ldpBatchThumbKey === item.key) {
- image.dataset.ldpBatchThumbState = 'loading';
- image.src = batchThumbnailUrls.get(item.key) || source;
- }
- };
- const generateThumbnail = () => enqueueBatchThumbnail(async () => {
- if (lightboxClosed || batchThumbnailUrls.has(item.key)) return;
- try {
- displayThumbnail(await createBatchThumbnail(item));
- } catch {
- if (image.isConnected && image.dataset.ldpBatchThumbKey === item.key) {
- image.alt = '缩略图加载失败';
- image.dataset.ldpBatchThumbState = 'failed';
- }
- }
- });
- const persistentKey = batchThumbnailPersistentKey(item);
- if (!persistentKey) {
- generateThumbnail();
- return;
- }
- void cachedPersistentLightboxBlob(persistentKey).then((cachedThumbnail) => {
- if (cachedThumbnail) {
- displayThumbnail(URL.createObjectURL(cachedThumbnail));
- return;
- }
- generateThumbnail();
- }).catch(generateThumbnail);
- };
- const observeBatchThumbnails = () => {
- if (batchThumbnailObserver) batchThumbnailObserver.disconnect();
- const images = batchGrid.querySelectorAll('[data-ldp-batch-thumb-key]');
- if (!isFunction(IntersectionObserver)) {
- images.forEach((image) => loadBatchThumbnail(image, items[+image.dataset.ldpBatchIndex]));
- return;
- }
- batchThumbnailObserver = new IntersectionObserver((entries) => {
- entries.forEach((entry) => {
- if (!entry.isIntersecting) return;
- batchThumbnailObserver.unobserve(entry.target);
- loadBatchThumbnail(entry.target, items[+entry.target.dataset.ldpBatchIndex]);
- });
- }, { root: batchGrid, rootMargin: '160px 0px' });
- images.forEach((image) => batchThumbnailObserver.observe(image));
- };
- function renderBatchSelection() {
- const scopedItems = batchScopeItems();
- const availableKeys = new Set(scopedItems.map((item) => item.key));
- batchSelection.forEach((key) => {
- if (!availableKeys.has(key)) batchSelection.delete(key);
- });
- const itemMarkup = scopedItems.map((item, index) => {
- const itemIndex = items.indexOf(item);
- const selected = batchSelection.has(item.key);
- const label = `#${item.postNumber || '?'} · 图片 ${index + 1}`;
- const cachedThumbnail = batchThumbnailUrls.get(item.key);
- const immediateThumbnail = cachedThumbnail || item.thumbSrc || item.originalSrc;
- return `
-
-
- ${esc(label)}
- `;
- }).join('');
- const moreMarkup = batchCanLoadMore()
- ? `
`
- : '';
- batchGrid.innerHTML = itemMarkup + moreMarkup;
- batchGrid.querySelectorAll('[data-ldp-batch-thumb-key]').forEach((image) => {
- const syncThumbnailState = () => {
- if (image.naturalWidth > 0) {
- image.dataset.ldpBatchThumbState = 'loaded';
- return;
- }
- const generatedSource = batchThumbnailUrls.get(image.dataset.ldpBatchThumbKey);
- const currentSource = image.currentSrc || image.src;
- const fallbackSource = image.dataset.ldpBatchThumbFallback || '';
- if (generatedSource && currentSource === generatedSource && fallbackSource && fallbackSource !== generatedSource) {
- batchThumbnailUrls.delete(image.dataset.ldpBatchThumbKey);
- URL.revokeObjectURL(generatedSource);
- image.dataset.ldpBatchThumbState = 'loading';
- image.src = fallbackSource;
- return;
- }
- image.dataset.ldpBatchThumbState = 'failed';
- };
- image.addEventListener('load', syncThumbnailState);
- image.addEventListener('error', syncThumbnailState);
- if (image.complete) syncThumbnailState();
- });
- if (!batchBusy) observeBatchThumbnails();
- syncBatchSelectionControls();
- }
- const setBatchProgress = (completed, total, label) => {
- const percent = total > 0 ? Math.max(0, Math.min(100, Math.round(completed / total * 100))) : 0;
- batchProgress.hidden = false;
- batchProgress.style.setProperty('--ldp-lb-batch-progress', `${percent}%`);
- batchProgressCopy.firstElementChild.textContent = label;
- batchProgressCopy.lastElementChild.textContent = `${percent}%`;
- batchProgressTrack.setAttribute('aria-valuenow', String(percent));
- };
- const openBatchDownload = () => {
- batchSelection.clear();
- batchScope = 'loaded';
- batchLoadedScopeKeys = new Set(items.map((item) => item.key));
- batchLoadedForwardPostNumber = Math.max(0,
- ...items.map((item) => +item.postNumber || 0),
- ...(ctx && Array.isArray(ctx.streamItems) ? ctx.streamItems.map((item) => +item.postNumber || 0) : [])
- );
- batchLoadedScopeExhausted = Boolean(totalPostNumber() && batchLoadedForwardPostNumber >= totalPostNumber());
- batchNameInput.value = '';
- const title = safeDownloadFilename(
- ctx?.topicData && ctx.topicData.title || document.title || '帖子图片',
- '帖子图片'
- );
- batchNameInput.placeholder = `${title}_${lightboxDownloadTimestamp()}.zip`;
- batchStatus.textContent = '请选择要打包的图片';
- batchProgress.hidden = true;
- batchOverlay.hidden = false;
- renderBatchSelection();
- requestAnimationFrame(() => {
- if (batchScopeButtons[0]) batchScopeButtons[0].focus({ preventScroll: true });
- const firstItem = batchScopeItems()[0];
- const firstIndex = firstItem ? items.indexOf(firstItem) : -1;
- const firstNode = firstIndex >= 0 ? batchGrid.querySelector(`[data-lb-batch-index="${firstIndex}"]`) : null;
- if (firstIndex >= 0 && firstNode) void openBatchItemPreview(firstIndex, firstNode);
- });
- };
- const closeBatchDownload = () => {
- if (batchBusy) return;
- const imageViewer = readerPortalQuery('.ldp-avatar-viewer.is-image');
- if (isFunction(imageViewer?._ldpClose)) imageViewer._ldpClose();
- batchOverlay.hidden = true;
- batchSelection.clear();
- };
- const setCommentsExpanded = (expanded, manual = false) => {
- if (manual) commentsManuallyToggled = true;
- lb.classList.toggle('ldp-lb-comments-collapsed', !expanded);
- setExpanded(commentsToggle, !!expanded);
- setLabel(commentsToggle, expanded ? '收起图片评论' : '展开图片评论');
- requestAnimationFrame(renderTransform);
- };
- const closeCommentForm = () => {
- commentTargetPost = null;
- commentForm.hidden = true;
- commentForm.removeAttribute('aria-busy');
- commentForm.dataset.rootComment = '0';
- commentInput.value = '';
- commentError.textContent = '';
- };
- const renderCommentSource = (item, sourcePost = null) => {
- const post = sourcePost || item?.sourcePost || null;
- const postNumber = +(item?.postNumber || post?.post_number || 0);
- commentsSource.hidden = !postNumber;
- commentsDescriptionToggle.hidden = !postNumber;
- commentsSourceText.textContent = lightboxSourceDescription(post, item);
- commentsSourceReactions.hidden = true;
- commentsSourceReactions._ldpPostData = post;
- commentsSourceReactions.dataset.postId = String(post?.id || '');
- commentsSourceReactions.dataset.postNumber = String(postNumber || '');
- if (post?.id) {
- renderPostReactions(commentsSourceReactions, ctx);
- commentsSourceReactions.hidden = !commentsSourceReactions.classList.contains('ldp-has-reactions');
- }
- };
- const quoteMatchesCurrentItem = (quote, item) => {
- if (!quote || +quote.dataset.post !== +item.postNumber) return false;
- const quotedTopicId = +(quote.dataset.topic || 0);
- if (quotedTopicId && item.topicId && quotedTopicId !== +item.topicId) return false;
- const expectedSource = comparableLightboxImageSource(item.originalSrc);
- return lightboxQuoteContainsImage(quote, item, expectedSource);
- };
- const renderLightboxCommentPost = (post, nested, state, item) => {
- const displayPost = Object.assign({}, post, { reply_count: 0 });
- const node = renderPost(displayPost, nested, ctx, {
- forceExpanded: true,
- showParentJump: nested,
- });
- node.classList.remove('ldp-reply-collapsible', 'ldp-nested-collapsed');
- node.querySelectorAll(':scope > .ldp-nested-toggle,:scope > .ldp-collapse-replies,:scope > .ldp-children,:scope > .ldp-sub-loading').forEach((element) => element.remove());
- const content = node.querySelector(':scope > .ldp-content');
- const directReference = state.directMatchNumbers.has(+post.post_number);
- if (content) {
- if (directReference) {
- content.querySelectorAll('aside.quote').forEach((quote) => {
- if (quoteMatchesCurrentItem(quote, item)) quote.remove();
- });
- }
- content.querySelectorAll('img:not(.emoji),video,audio,iframe,canvas,source,picture').forEach((media) => media.remove());
- content.querySelectorAll('.ldp-quote-controls').forEach((controls) => controls.remove());
- content.querySelectorAll('.lightbox-wrapper,figure,a.lightbox').forEach((wrapper) => {
- if (!String(wrapper.textContent || '').trim() && !wrapper.querySelector('img,video,audio,iframe')) wrapper.remove();
- });
- }
- node.querySelectorAll(':scope > .ldp-boost-list,:scope > .ldp-topic-footer-actions').forEach((element) => element.remove());
- node.querySelectorAll('.ldp-context-actions-slot,.ldp-boostbtn').forEach((element) => element.remove());
- node.querySelectorAll('.ldp-post-head .ldp-floor').forEach((floor) => {
- if (!floor.dataset.targetPostNumber) floor.dataset.targetPostNumber = String(post.post_number || '');
- floor.classList.add('ldp-lb-floor-jump');
- if (floor.tagName !== 'BUTTON') {
- floor.tabIndex = 0;
- floor.setAttribute('role', 'button');
- }
- });
- return node;
- };
- const descendantsForComment = (rootPostNumber, state) => {
- const descendants = [];
- const visited = new Set([+rootPostNumber]);
- const visit = (postNumber) => {
- const children = state.childrenByParent.get(+postNumber) || [];
- children.forEach((child) => {
- const childNumber = +(child?.post_number || 0);
- if (!childNumber || visited.has(childNumber)) return;
- visited.add(childNumber);
- descendants.push(child);
- visit(childNumber);
- });
- };
- visit(rootPostNumber);
- return descendants;
- };
- const renderCommentState = (state, item) => {
- const count = state?.postsByNumber ? state.postsByNumber.size : 0;
- commentsToggleCount.textContent = String(count);
- commentsHeadingCount.textContent = `(${count})`;
- renderCommentSource(item, state?.sourcePost);
- commentsList.replaceChildren();
- commentsStatus.hidden = true;
- commentsEmpty.hidden = count > 0;
- if (!count)
- commentsEmpty.querySelector('span').textContent = state?.partial
- ? '暂未找到评论,部分结果加载失败'
- : '还没有人评论这张图片';
- (state?.roots || []).forEach((rootPost) => {
- const thread = makeElement('section');
- thread.className = 'ldp-lb-comment-thread';
- const rootWrap = makeElement('div');
- rootWrap.className = 'ldp-lb-comment-node';
- rootWrap.append(renderLightboxCommentPost(rootPost, false, state, item));
- thread.append(rootWrap);
- const descendants = descendantsForComment(rootPost.post_number, state);
- if (descendants.length) {
- const children = makeElement('div');
- children.className = 'ldp-lb-comment-children';
- descendants.forEach((reply) => {
- const replyWrap = makeElement('div');
- replyWrap.className = 'ldp-lb-comment-node';
- replyWrap.append(renderLightboxCommentPost(reply, true, state, item));
- children.append(replyWrap);
- });
- thread.append(children);
- }
- commentsList.append(thread);
- });
- if (state?.partial && count > 0) {
- commentsStatus.hidden = false;
- commentsStatus.textContent = '部分关联回复暂未加载';
- }
- if (!commentsManuallyToggled) setCommentsExpanded(commentsExpandedByDefault);
- };
- const openCommentForm = (targetPost = null) => {
- const item = items[currentIndex];
- const rootComment = !targetPost;
- commentTargetPost = targetPost || item.sourcePost || {
- id: item.postId,
- post_number: item.postNumber,
- username: item.username,
- };
- const username = cleanUsernameValue(commentTargetPost?.username);
- const targetNumber = +(commentTargetPost?.post_number || item.postNumber || 0);
- commentForm.dataset.rootComment = rootComment ? '1' : '0';
- commentTargetLabel.textContent = rootComment
- ? `${username ? `评论 @${username}` : '评论'} 的图片(回复 #${targetNumber})`
- : `回复 ${username ? `@${username} · ` : ''}#${targetNumber}`;
- commentImageOption.hidden = rootComment;
- commentImageCheckbox.checked = rootComment;
- commentError.textContent = '';
- commentForm.hidden = false;
- setCommentsExpanded(true);
- requestAnimationFrame(() => {
- commentForm.scrollIntoView({ block: 'nearest' });
- commentInput.focus({ preventScroll: true });
- });
- };
- const loadCommentsForItem = async (item) => {
- const token = ++commentRequestToken;
- closeCommentForm();
- renderCommentSource(item);
- const cached = commentCache.get(item.key);
- if (cached) {
- renderCommentState(cached, item);
- return cached;
- }
- commentsToggleCount.textContent = '…';
- commentsHeadingCount.textContent = '(…)';
- commentsStatus.hidden = false;
- commentsStatus.textContent = '正在查找这张图片的评论…';
- commentsEmpty.hidden = true;
- commentsList.replaceChildren();
- if (!commentsManuallyToggled) setCommentsExpanded(commentsExpandedByDefault);
- try {
- const state = await loadLightboxCommentTree(item, ctx);
- if (lightboxClosed || token !== commentRequestToken || items[currentIndex].key !== item.key) return null;
- commentCache.set(item.key, state);
- renderCommentState(state, item);
- return state;
- } catch (error) {
- if (lightboxClosed || token !== commentRequestToken) return null;
- commentsToggleCount.textContent = '0';
- commentsHeadingCount.textContent = '(0)';
- commentsStatus.hidden = false;
- commentsStatus.textContent = `评论加载失败:${error.message || '请重试'}`;
- commentsEmpty.hidden = false;
- commentsEmpty.querySelector('span').textContent = '暂时无法加载图片评论';
- return null;
- }
- };
- const toggleLightboxCommentLike = async (button, postNode) => {
- const postData = postNode?._ldpPostData;
- const postId = +(postData?.id || postNode && postNode.dataset.postId || 0);
- if (!postId || !postData || button.disabled) return;
- await runReaderActionOnce(ctx, `post:${postId}:like`, async () => {
- await toggleReaderPostLike(postId, ctx, button, {
- setBusy: (busy) => setReaderControlBusy(button, busy),
- beforeSync: (acted, count) => syncReaderLikeButton(button, updatePostLikeAction(postData, acted, count)),
- postData,
- persist: true,
- onError: (error) => showSelectionToast(`操作失败:${error.message}`),
- });
- });
- };
- const positionLightboxReactionPicker = (picker) => {
- if (!picker || picker.hidden || !commentsPanel.isConnected) return;
- picker.style.removeProperty('left');
- const panelRect = commentsPanel.getBoundingClientRect();
- const safeInset = 10;
- picker.style.maxWidth = `${Math.max(1, Math.min(320, panelRect.width - safeInset * 2, window.innerWidth - 24))}px`;
- const pickerRect = picker.getBoundingClientRect();
- const safeLeft = Math.max(12, panelRect.left + safeInset);
- const safeRight = Math.min(window.innerWidth - 12, panelRect.right - safeInset);
- let shift = 0;
- if (pickerRect.right > safeRight) shift -= pickerRect.right - safeRight;
- if (pickerRect.left + shift < safeLeft) shift += safeLeft - (pickerRect.left + shift);
- picker.style.left = `${Math.round(shift)}px`;
- };
- let lightboxReactionHoverOpenTimer = 0;
- let lightboxReactionHoverCloseTimer = 0;
- const clearLightboxReactionHoverTimers = () => {
- clearTimeout(lightboxReactionHoverOpenTimer);
- clearTimeout(lightboxReactionHoverCloseTimer);
- lightboxReactionHoverOpenTimer = lightboxReactionHoverCloseTimer = 0;
- };
- const setLightboxReactionPickerExpanded = (postNode, expanded) => {
- clearLightboxReactionHoverTimers();
- const root = postNode?.querySelector(':scope > .ldp-reactions');
- const trigger = root?.querySelector('[data-reaction-picker]');
- const picker = root?.querySelector('.ldp-reaction-picker');
- if (!trigger || !picker) return;
- picker.hidden = !expanded;
- if (expanded) requestAnimationFrame(() => positionLightboxReactionPicker(picker));
- setExpanded(trigger, !!expanded);
- refreshTooltips(trigger);
- };
- const openLightboxReactionPicker = async (postNode) => {
- const currentPicker = postNode?.querySelector(':scope > .ldp-reactions .ldp-reaction-picker');
- if (!currentPicker?.hidden) return;
- if (!reactionEmojiUrls) {
- await loadReactionEmojiRegistry();
- if (lightboxClosed || !postNode?.isConnected) return;
- renderPostReactions(postNode, ctx);
- }
- setLightboxReactionPickerExpanded(postNode, true);
- };
- const toggleLightboxReaction = async (reaction, postNode) => {
- const postData = postNode?._ldpPostData;
- const postId = +(postData?.id || postNode && postNode.dataset.postId || 0);
- reaction = String(reaction || '');
- if (!postId || !reaction) return;
- const item = items[currentIndex];
- await toggleReaderPostReaction(postNode, ctx, reaction, (fresh) => {
- postNode._ldpPostData = Object.assign({}, postData, fresh);
- const state = commentCache.get(item.key);
- const cachedPost = state && state.postsByNumber.get(+(postData?.post_number || 0));
- if (cachedPost && +cachedPost.id === postId) Object.assign(cachedPost, fresh);
- if (item.sourcePost && +item.sourcePost.id === postId) Object.assign(item.sourcePost, fresh);
- renderPostReactions(postNode, ctx);
- }, (error) => showSelectionToast(`回应失败:${error.message}`));
- };
- const jumpToLightboxComment = async (post) => {
- const postNumber = +(post?.post_number || 0);
- if (!postNumber || lightboxClosed) return;
- try {
- const acceptsWork = () => !lightboxClosed;
- const jumped = await ctx.timeline.jumpTo(postNumber, {
- acceptsWork,
- targetPost: post,
- forceNested: true,
- });
- if (!jumped || lightboxClosed) return;
- close();
- } catch {
- showSelectionToast('无法定位到该楼层');
- }
- };
- const onCommentFormSubmit = async (event) => {
- event.preventDefault();
- if (commentForm.getAttribute('aria-busy') === 'true') return;
- const item = items[currentIndex];
- const targetPost = commentTargetPost;
- const targetPostNumber = +(targetPost?.post_number || 0);
- const message = String(commentInput.value || '').trim();
- const messageLength = [...message].length;
- const includeImage = commentForm.dataset.rootComment === '1' || commentImageCheckbox.checked;
- const quoteRaw = includeImage ? lightboxImageQuoteRaw(item, ctx) : '';
- if (!message || !targetPostNumber || includeImage && !quoteRaw) {
- commentError.textContent = !message ? '请输入评论内容' : '无法确认图片引用来源';
- return;
- }
- if (messageLength < minimumCommentLength) {
- commentError.textContent = `评论至少需要 ${minimumCommentLength} 个字符(当前 ${messageLength} 个)`;
- return;
- }
- commentForm.setAttribute('aria-busy', 'true');
- commentError.textContent = '';
- try {
- const payload = await apiSend(`${BASE}/posts`, 'POST', {
- topic_id: +ctx.topicId,
- raw: `${quoteRaw}${message}`,
- reply_to_post_number: targetPostNumber,
- }, null, { priority: POST_REQUEST_PRIORITY.visible });
- const created = postsFromPayload(payload)[0] || payload?.post || payload;
- if (!created || !created.id || !created.post_number) throw new Error('服务器未返回新楼层');
- if (!created.reply_to_post_number) created.reply_to_post_number = targetPostNumber;
- if (!created.topic_id) created.topic_id = +ctx.topicId;
- if (includeImage) {
- created._ldpImageReferenceKeys = [...new Set([
- ...(Array.isArray(created._ldpImageReferenceKeys) ? created._ldpImageReferenceKeys : []),
- item.key,
- ])];
- }
- if (!ctx.lightboxImageCommentPosts) ctx.lightboxImageCommentPosts = new Map();
- ctx.lightboxImageCommentPosts.set(+created.post_number, created);
- if (targetPost?.id) {
- targetPost.reply_count = Math.max(0, +(targetPost.reply_count || 0)) + 1;
- persistReaderPostState(ctx, targetPost);
- }
- rememberPostData(ctx, created, targetPostNumber);
- ctx.loader.persistFetchedPosts([created]);
- ingestLivePosts([created], ctx, { refreshContent: true });
- ctx.totalPosts = Math.max(+(ctx.totalPosts || 0), +created.post_number);
- let state = commentCache.get(item.key);
- if (!state) state = await loadLightboxCommentTree(item, ctx);
- state.postsByNumber.set(+created.post_number, created);
- if (includeImage) state.directMatchNumbers.add(+created.post_number);
- const tree = buildLightboxCommentTree(state.postsByNumber, state.directMatchNumbers);
- state.roots = tree.roots;
- state.childrenByParent = tree.childrenByParent;
- commentCache.set(item.key, state);
- persistLightboxCommentRelation(item, ctx, state);
- commentRequestToken++;
- closeCommentForm();
- if (!lightboxClosed && items[currentIndex].key === item.key) renderCommentState(state, item);
- } catch (error) {
- commentError.textContent = `发送失败:${error.message || '请重试'}`;
- } finally {
- commentForm.removeAttribute('aria-busy');
- }
- };
- const onCommentsReactionPointerOver = (event) => {
- const root = event.target.closest('.ldp-reactions');
- if (!root || !commentsPanel.contains(root)) return;
- clearTimeout(lightboxReactionHoverCloseTimer);
- lightboxReactionHoverCloseTimer = 0;
- const trigger = event.target.closest('[data-reaction-picker]');
- const reactionPost = trigger?.closest('.ldp-lb-comment-node > .ldp-post,.ldp-lb-source-reactions');
- if (!reactionPost || trigger.contains(event.relatedTarget)) return;
- clearTimeout(lightboxReactionHoverOpenTimer);
- lightboxReactionHoverOpenTimer = setTimeout(() => {
- lightboxReactionHoverOpenTimer = 0;
- void openLightboxReactionPicker(reactionPost);
- }, 250);
- };
- const onCommentsReactionPointerOut = (event) => {
- const root = event.target.closest('.ldp-reactions');
- if (!root || !commentsPanel.contains(root)) return;
- const trigger = event.target.closest('[data-reaction-picker]');
- if (trigger && (!event.relatedTarget || !trigger.contains(event.relatedTarget))) {
- clearTimeout(lightboxReactionHoverOpenTimer);
- lightboxReactionHoverOpenTimer = 0;
- }
- if (event.relatedTarget && root.contains(event.relatedTarget)) return;
- clearLightboxReactionHoverTimers();
- const reactionPost = root.closest('.ldp-lb-comment-node > .ldp-post,.ldp-lb-source-reactions');
- lightboxReactionHoverCloseTimer = setTimeout(() => {
- lightboxReactionHoverCloseTimer = 0;
- setLightboxReactionPickerExpanded(reactionPost, false);
- }, 1000);
- };
- const onCommentsClick = (event) => {
- const addButton = event.target.closest('.ldp-lb-add');
- if (addButton) {
- openCommentForm();
- return;
- }
- const reactionPost = event.target.closest('.ldp-lb-comment-node > .ldp-post,.ldp-lb-source-reactions');
- const reactionPicker = event.target.closest('[data-reaction-picker]');
- if (reactionPost && reactionPicker) {
- event.preventDefault();
- setLightboxReactionPickerExpanded(reactionPost, false);
- void toggleLightboxReaction(reactionPicker.dataset.reaction || 'heart', reactionPost);
- return;
- }
- const reactionButton = event.target.closest('[data-reaction]');
- if (reactionPost && reactionButton) {
- event.preventDefault();
- setLightboxReactionPickerExpanded(reactionPost, false);
- void toggleLightboxReaction(reactionButton.dataset.reaction, reactionPost);
- return;
- }
- const postNode = event.target.closest('.ldp-lb-comment-node > .ldp-post');
- if (!postNode) return;
- const postData = postNode._ldpPostData;
- const replyButton = event.target.closest('.ldp-replybtn');
- if (replyButton) {
- event.preventDefault();
- openCommentForm(postData);
- return;
- }
- const likeButton = event.target.closest('.ldp-like');
- if (likeButton) {
- event.preventDefault();
- void toggleLightboxCommentLike(likeButton, postNode);
- return;
- }
- const floor = event.target.closest('.ldp-lb-floor-jump');
- if (floor) {
- event.preventDefault();
- const targetNumber = +(floor.dataset.targetPostNumber || postData?.post_number || 0);
- const state = commentCache.get(items[currentIndex].key);
- const targetPost = state && state.postsByNumber.get(targetNumber) || knownLightboxPosts(ctx).get(targetNumber);
- if (targetPost) void jumpToLightboxComment(targetPost);
- }
- };
- const onCommentsKeydown = (event) => {
- if (event.key !== 'Enter' && event.key !== ' ') return;
- const floor = event.target.closest('.ldp-lb-floor-jump[role="button"]');
- if (!floor) return;
- event.preventDefault();
- floor.click();
- };
- const showItem = (index) => {
- currentIndex = Math.max(0, Math.min(items.length - 1, Number(index) || 0));
- const item = items[currentIndex];
- const previewSrc = item.thumbSrc || item.originalSrc;
- const hasSeparateOriginal = item.originalSrc !== previewSrc;
- mainImageRequestToken++;
- resetView();
- countEl.textContent = `${currentIndex + 1} / ${items.length}`;
- updateNavigationState();
- updateJumpToPostState();
- thumbs.forEach((thumb, thumbIndex) => {
- const active = thumbIndex === currentIndex;
- thumb.classList.toggle('active', active);
- thumb.setAttribute('aria-selected', String(active));
- });
- progress.style.setProperty('--ldp-lb-progress-size', `${100 / items.length}%`);
- progress.style.setProperty('--ldp-lb-progress-x', `${currentIndex * 100}%`);
- mainImg.hidden = true;
- mainImg.alt = item.alt;
- mainImg.dataset.ldpSourceMode = hasSeparateOriginal ? 'preview' : 'original';
- setViewOriginalButtonState(
- !hasSeparateOriginal, false, hasSeparateOriginal ? '查看原图' : '当前已是原图'
- );
- status.hidden = false;
- statusText.textContent = hasSeparateOriginal ? '正在加载预览…' : '正在加载图片…';
- retryBtn.hidden = true;
- mainImg.onload = () => {
- mainImg.hidden = false;
- status.hidden = true;
- void fetchLightboxDownloadBlob(previewSrc, POST_REQUEST_PRIORITY.background).catch(() => {});
- if (mainImg.dataset.ldpSourceMode === 'original')
- setViewOriginalButtonState(true, false, '当前已是原图');
- renderTransform();
- };
- mainImg.onerror = () => {
- mainImg.hidden = true;
- status.hidden = false;
- const originalFailed = mainImg.dataset.ldpSourceMode === 'original' && hasSeparateOriginal;
- statusText.textContent = originalFailed ? '原图加载失败' : '预览图加载失败';
- retryBtn.hidden = !originalFailed;
- setViewOriginalButtonState(!hasSeparateOriginal, false);
- };
- mainImg.removeAttribute('src');
- mainImg.src = previewSrc;
- if (hasSeparateOriginal) {
- loadCurrentOriginal(false, PREFS.lightboxOriginalByDefault !== true);
- }
- if (commentsEnabled) void loadCommentsForItem(item);
- requestAnimationFrame(scrollActiveThumbIntoView);
- };
- const loadCurrentOriginal = (refresh = false, cachedOnly = false) => {
- const item = items[currentIndex];
- const previewSrc = item && (item.thumbSrc || item.originalSrc);
- if (!item || !item.originalSrc || item.originalSrc === previewSrc) return;
- const itemKey = item.key;
- const requestToken = cachedOnly ? mainImageRequestToken : ++mainImageRequestToken;
- const requestIsCurrent = () => !lightboxClosed && requestToken === mainImageRequestToken &&
- lb.isConnected && items[currentIndex].key === itemKey;
- const allowOriginalRetry = (message) => {
- statusText.textContent = message;
- retryBtn.hidden = false;
- setViewOriginalButtonState(false, false, '查看原图');
- };
- if (!cachedOnly) {
- setViewOriginalButtonState(true, true, '正在加载原图');
- status.hidden = false;
- statusText.textContent = refresh ? '正在重新加载原图…' : '正在加载原图…';
- retryBtn.hidden = true;
- }
- const request = cachedOnly
- ? persistentLightboxImageSource(item.originalSrc, { cachedOnly: true })
- : refresh
- ? deletePersistentLightboxImage(item.originalSrc).then(() => persistentLightboxImageSource(item.originalSrc))
- : persistentLightboxImageSource(item.originalSrc);
- void request.then((source) => {
- if (!requestIsCurrent()) return;
- if (cachedOnly && !source) return;
- if (!source) {
- allowOriginalRetry('正文请求优先,原图稍后重试');
- return;
- }
- const originalImage = makeElement('img');
- originalImage.onload = () => {
- if (!requestIsCurrent()) return;
- mainImg.dataset.ldpSourceMode = 'original';
- mainImg.src = source;
- };
- originalImage.onerror = () => {
- if (!requestIsCurrent()) return;
- if (cachedOnly) return;
- allowOriginalRetry('原图加载失败');
- };
- originalImage.src = source;
- });
- };
- const adjacentItemIsScanned = (direction) => {
- const item = items[currentIndex + direction];
- const postNumber = +(item?.postNumber) || 0;
- if (!postNumber) return false;
- return direction < 0
- ? postNumber >= backwardScanPostNumber
- : postNumber <= forwardScanPostNumber;
- };
- const mergeScannedLightboxItems = (posts) => {
- const currentKey = items[currentIndex]?.key;
- let added = false;
- posts.forEach((post) => {
- lightboxItemsFromPost(post, ctx?.topicId).forEach((item) => {
- if (seenImageKeys.has(item.key)) return;
- seenImageKeys.add(item.key);
- items.push(item);
- added = true;
- });
- });
- if (!added) return;
- items.sort(compareLightboxItems);
- currentIndex = Math.max(0, items.findIndex((item) => item.key === currentKey));
- syncThumbs();
- };
- const loadMoreBatchImages = async () => {
- if (batchBusy || !batchCanLoadMore() || lightboxClosed) return;
- batchBusy = true;
- batchStatus.textContent = `正在加载 #${batchLoadedForwardPostNumber} 之后的楼层…`;
- renderBatchSelection();
- try {
- const posts = await ctx.loader.loadAfterPost(batchLoadedForwardPostNumber, {
- priority: POST_REQUEST_PRIORITY.auxiliary,
- persistFetchedPosts: true,
- });
- if (lightboxClosed || batchScope !== 'loaded') return;
- if (!posts.length) {
- batchLoadedScopeExhausted = true;
- batchStatus.textContent = '已加载到帖子末尾';
- return;
- }
- batchLoadedForwardPostNumber = Math.max(
- batchLoadedForwardPostNumber,
- ...posts.map((post) => +post.post_number || 0)
- );
- const discoveredKeys = new Set();
- posts.forEach((post) => {
- lightboxItemsFromPost(post, ctx?.topicId).forEach((item) => discoveredKeys.add(item.key));
- });
- mergeScannedLightboxItems(posts);
- discoveredKeys.forEach((key) => {
- if (seenImageKeys.has(key)) batchLoadedScopeKeys.add(key);
- });
- const highest = totalPostNumber();
- if (highest && batchLoadedForwardPostNumber >= highest) batchLoadedScopeExhausted = true;
- batchStatus.textContent = discoveredKeys.size
- ? `本批新增 ${discoveredKeys.size} 张图片`
- : batchLoadedScopeExhausted ? '已加载到帖子末尾' : '本批没有图片,可继续加载';
- } catch (error) {
- batchStatus.textContent = `加载更多失败:${error.message || '请重试'}`;
- } finally {
- batchBusy = false;
- if (!lightboxClosed && !batchOverlay.hidden) renderBatchSelection();
- }
- };
- const scanAllTopicImages = async () => {
- if (batchBusy || batchAllImagesLoaded || fixedItems || lightboxClosed) return;
- const topic = ctx?.loader && ctx.loader.topic || ctx?.topicData;
- const stream = topic?.post_stream && Array.isArray(topic.post_stream.stream)
- ? topic.post_stream.stream.filter(Boolean)
- : [];
- if (!stream.length || !ctx || !ctx.loader || !isFunction(ctx.loader.loadPostsByIds)) {
- batchStatus.textContent = '未找到完整楼层列表,暂时只能使用当前加载的图片';
- return;
- }
- batchBusy = true;
- batchSelection.clear();
- batchCloseButtons.forEach((button) => { button.disabled = true; });
- renderBatchSelection();
- const batchSize = 20;
- try {
- for (let offset = 0; offset < stream.length; offset += batchSize) {
- const end = Math.min(stream.length, offset + batchSize);
- setBatchProgress(offset, stream.length, `正在扫描全部帖子 ${offset} / ${stream.length}`);
- batchStatus.textContent = '按阅读器请求速率分批扫描,不请求图片原图';
- const posts = await ctx.loader.loadPostsByIds(stream.slice(offset, end), {
- priority: POST_REQUEST_PRIORITY.auxiliary,
- persistFetchedPosts: true,
- });
- if (lightboxClosed || batchScope !== 'all') return;
- mergeScannedLightboxItems(posts);
- setBatchProgress(end, stream.length, `正在扫描全部帖子 ${end} / ${stream.length}`);
- }
- batchAllImagesLoaded = true;
- batchStatus.textContent = `已扫描全部帖子,共找到 ${items.length} 张图片`;
- setBatchProgress(stream.length, stream.length, '全部帖子扫描完成');
- } catch (error) {
- batchStatus.textContent = `全帖扫描中断:${error.message || '请重试'}`;
- } finally {
- batchBusy = false;
- batchCloseButtons.forEach((button) => { button.disabled = false; });
- if (!lightboxClosed && !batchOverlay.hidden) renderBatchSelection();
- }
- };
- const exploreAdjacentItem = async (direction) => {
- const backward = direction < 0;
- if (!direction || exploringPrevious || exploringNext || lightboxClosed ||
- (backward ? backwardExhausted : forwardExhausted)) return;
- if (backward) exploringPrevious = true;
- else exploringNext = true;
- countEl.textContent = `${currentIndex + 1} / ${items.length} · 正在寻找${backward ? '上一张' : '下一张'}…`;
- updateNavigationState();
- let scanStalled = false;
- try {
- const adjacentIndex = currentIndex + direction;
- if (adjacentIndex >= 0 && adjacentIndex < items.length && adjacentItemIsScanned(direction)) {
- showItem(adjacentIndex);
- return;
- }
- const scanPostNumber = backward ? backwardScanPostNumber : forwardScanPostNumber;
- const posts = await (backward
- ? ctx.loader.loadBeforePost(scanPostNumber, {
- priority: POST_REQUEST_PRIORITY.visible,
- persistFetchedPosts: true,
- })
- : ctx.loader.loadAfterPost(scanPostNumber, {
- priority: POST_REQUEST_PRIORITY.visible,
- persistFetchedPosts: true,
- }));
- const scannedPosts = (Array.isArray(posts) ? posts : [])
- .filter((post) => backward
- ? +post.post_number < scanPostNumber
- : +post.post_number > scanPostNumber)
- .sort((left, right) => +left.post_number - +right.post_number);
- if (!scannedPosts.length) {
- if (backward) {
- if (backwardScanPostNumber <= 1) backwardExhausted = true;
- else scanStalled = true;
- } else {
- const highestPostNumber = totalPostNumber();
- if (!highestPostNumber || forwardScanPostNumber >= highestPostNumber) forwardExhausted = true;
- else scanStalled = true;
- }
- } else {
- const nextScanPostNumber = backward
- ? Math.min(...scannedPosts.map((post) => +post.post_number || scanPostNumber))
- : Math.max(...scannedPosts.map((post) => +post.post_number || scanPostNumber));
- if ((backward && nextScanPostNumber >= backwardScanPostNumber) ||
- (!backward && nextScanPostNumber <= forwardScanPostNumber)) {
- scanStalled = true;
- } else {
- if (backward) backwardScanPostNumber = nextScanPostNumber;
- else forwardScanPostNumber = nextScanPostNumber;
- if (lightboxClosed || !lb.isConnected) return;
- const highestPostNumber = totalPostNumber();
- if (backwardScanPostNumber <= 1) backwardExhausted = true;
- if (highestPostNumber && forwardScanPostNumber >= highestPostNumber) forwardExhausted = true;
- mergeScannedLightboxItems(scannedPosts);
- const discoveredIndex = currentIndex + direction;
- if (discoveredIndex >= 0 && discoveredIndex < items.length && adjacentItemIsScanned(direction)) {
- showItem(discoveredIndex);
- return;
- }
- }
- }
- if (!lightboxClosed) {
- countEl.textContent = scanStalled
- ? `${currentIndex + 1} / ${items.length} · ${backward ? '前面' : '后续'}楼层暂未加载,请重试`
- : `${currentIndex + 1} / ${items.length} · 本批没有图片,再翻一次继续查找`;
- }
- } catch {
- if (!lightboxClosed) countEl.textContent = `查找${backward ? '上一张' : '下一张'}失败,请重试`;
- } finally {
- if (backward) exploringPrevious = false;
- else exploringNext = false;
- if (!lightboxClosed) updateNavigationState();
- }
- };
- const move = (direction) => {
- const nextIndex = currentIndex + direction;
- if (nextIndex >= 0 && nextIndex < items.length && adjacentItemIsScanned(direction)) showItem(nextIndex);
- else void exploreAdjacentItem(direction);
- };
- const downloadCurrent = async () => {
- const item = items[currentIndex];
- const button = lb.querySelector('[data-lb-action="download"]');
- if (!item || button.disabled) return;
- button.disabled = true;
- button.setAttribute('aria-busy', 'true');
- setLabel(button, '正在准备下载');
- try {
- await downloadLightboxItem(item, currentIndex);
- } finally {
- button.disabled = false;
- button.removeAttribute('aria-busy');
- setLabel(button, '下载当前图片');
- }
- };
- const downloadBatch = async () => {
- if (batchBusy || !batchSelection.size) return;
- const selectedItems = batchScopeItems().filter((item) => batchSelection.has(item.key));
- if (!selectedItems.length) return;
- batchBusy = true;
- const missingOriginals = await lightboxMissingOriginalCount(selectedItems);
- const useOriginal = missingOriginals > 0 && globalThis.confirm(
- `${missingOriginals} 张原图尚未缓存。\n\n确定:按阅读器限速逐张获取原图\n取消:使用每张图片当前最高缓存/预览质量`
- );
- batchStatus.textContent = useOriginal
- ? '按阅读器请求速率逐张获取原图'
- : '使用每张图片当前最高缓存/预览质量';
- batchCloseButtons.forEach((button) => { button.disabled = true; });
- batchNameInput.disabled = true;
- batchSelectAll.disabled = true;
- batchGrid.querySelectorAll('input').forEach((input) => { input.disabled = true; });
- syncBatchSelectionControls();
- const entries = [];
- const failures = [];
- try {
- for (let index = 0; index < selectedItems.length; index++) {
- const item = selectedItems[index];
- setBatchProgress(index, selectedItems.length, `正在获取第 ${index + 1} / ${selectedItems.length} 张`);
- try {
- const blob = await lightboxImageDownloadBlob(item, { original: useOriginal });
- entries.push({
- name: lightboxImageDownloadName(item, index, blob),
- bytes: new Uint8Array(await blob.arrayBuffer()),
- });
- } catch (error) {
- failures.push({ item, error });
- }
- setBatchProgress(index + 1, selectedItems.length, `已处理 ${index + 1} / ${selectedItems.length} 张`);
- }
- if (!entries.length) throw new Error('所选图片均下载失败');
- setBatchProgress(selectedItems.length, selectedItems.length, '正在生成 ZIP 文件…');
- const zip = createStoredZip(entries);
- const archiveName = String(batchNameInput.value || batchNameInput.placeholder || '')
- .trim().replace(/\.zip$/i, '');
- triggerBrowserDownload(
- zip,
- `${safeDownloadFilename(archiveName, `帖子图片_${lightboxDownloadTimestamp()}`)}.zip`
- );
- batchStatus.textContent = failures.length
- ? `已打包 ${entries.length} 张,${failures.length} 张获取失败`
- : `已打包 ${entries.length} 张图片`;
- } catch (error) {
- batchStatus.textContent = `打包失败:${error.message || '请重试'}`;
- } finally {
- batchBusy = false;
- batchCloseButtons.forEach((button) => { button.disabled = false; });
- batchNameInput.disabled = false;
- batchSelectAll.disabled = false;
- batchGrid.querySelectorAll('input').forEach((input) => { input.disabled = false; });
- syncBatchSelectionControls();
- }
- };
- let batchPreviewRequestToken = 0;
- const ensureBatchPreviewSource = async (item, itemIndex) => {
- const existing = batchThumbnailUrls.get(item.key);
- if (existing) return existing;
- const itemNode = batchGrid.querySelector(`[data-lb-batch-index="${itemIndex}"]`);
- const thumbnailImage = itemNode?.querySelector('img');
- const displayed = thumbnailImage && (thumbnailImage.currentSrc || thumbnailImage.src);
- if (displayed) return displayed;
- const persistentKey = batchThumbnailPersistentKey(item);
- const cached = persistentKey && await cachedPersistentLightboxBlob(persistentKey);
- if (cached) {
- const source = URL.createObjectURL(cached);
- batchThumbnailUrls.set(item.key, source);
- return source;
- }
- return new Promise((resolve, reject) => {
- enqueueBatchThumbnail(async () => {
- try {
- const source = await createBatchThumbnail(item);
- if (lightboxClosed) {
- URL.revokeObjectURL(source);
- reject(new Error('图片预览已关闭'));
- return;
- }
- const previous = batchThumbnailUrls.get(item.key);
- if (previous) URL.revokeObjectURL(source);
- else batchThumbnailUrls.set(item.key, source);
- resolve(previous || source);
- } catch (error) {
- reject(error);
- }
- });
- });
- };
- const setBatchItemSelected = (item, itemIndex, selected, itemNode = null) => {
- if (selected) batchSelection.add(item.key);
- else batchSelection.delete(item.key);
- const card = itemNode || batchGrid.querySelector(`[data-lb-batch-index="${itemIndex}"]`);
- const input = card?.querySelector('input[type="checkbox"]');
- if (input) input.checked = selected;
- if (card) card.classList.toggle('selected', selected);
- syncBatchSelectionControls();
- };
- const openBatchItemPreview = async (itemIndex, anchorCard) => {
- if (batchBusy || lightboxClosed || batchOverlay.hidden) return;
- const item = items[itemIndex];
- const scopedItems = batchScopeItems();
- const scopedIndex = item ? scopedItems.findIndex((candidate) => candidate.key === item.key) : -1;
- if (!item || scopedIndex < 0) return;
- const requestToken = ++batchPreviewRequestToken;
- try {
- const previewSource = await ensureBatchPreviewSource(item, itemIndex);
- if (requestToken !== batchPreviewRequestToken || lightboxClosed || batchOverlay.hidden) return;
- openUserAvatarViewer(item.key, '', anchorCard, previewSource, null, null, {
- kind: 'image',
- reuseImageViewer: true,
- source: item.originalSrc,
- loadOriginal: true,
- selectable: true,
- selected: batchSelection.has(item.key),
- selectionLabel: `${scopedIndex + 1} / ${scopedItems.length} · #${item.postNumber || '?'}`,
- previousDisabled: scopedIndex === 0,
- nextDisabled: scopedIndex === scopedItems.length - 1,
- onPrevious: () => {
- const previous = scopedItems[scopedIndex - 1];
- if (previous) void openBatchItemPreview(items.indexOf(previous), anchorCard);
- },
- onNext: () => {
- const next = scopedItems[scopedIndex + 1];
- if (next) void openBatchItemPreview(items.indexOf(next), anchorCard);
- },
- onSelectionChange: (selected) => setBatchItemSelected(item, itemIndex, selected),
- onDownload: () => downloadLightboxItem(item, itemIndex),
- });
- } catch {
- if (requestToken === batchPreviewRequestToken) showSelectionToast('缩略图加载失败,请重试');
- }
- };
- const jumpToCurrentPost = async () => {
- const currentItem = items[currentIndex];
- const postNumber = +(currentItem?.postNumber) || 0;
- const topicId = +(currentItem?.topicId) || +(ctx?.topicId) || 0;
- if (!postNumber || jumpingToPost || lightboxClosed) return;
- if (topicId && ctx && topicId !== +ctx.topicId) {
- close();
- openModal(topicId, { targetPostNumber: postNumber });
- return;
- }
- jumpingToPost = true;
- updateJumpToPostState();
- try {
- const jumped = !!(ctx?.timeline) && await ctx.timeline.jumpTo(postNumber, {
- acceptsWork: () => !lightboxClosed,
- });
- if (lightboxClosed) return;
- if (!jumped) {
- countEl.textContent = `无法定位到第 #${postNumber} 楼,请重试`;
- return;
- }
- close();
- } catch {
- if (!lightboxClosed) countEl.textContent = `无法定位到第 #${postNumber} 楼,请重试`;
- } finally {
- jumpingToPost = false;
- if (!lightboxClosed) updateJumpToPostState();
- }
- };
- const close = () => {
- lightboxClosed = true;
- mainImageRequestToken++;
- commentRequestToken++;
- clearLightboxReactionHoverTimers();
- clearTimeout(descriptionHeightSaveTimer);
- if (descriptionResizeObserver) descriptionResizeObserver.disconnect();
- if (batchThumbnailObserver) batchThumbnailObserver.disconnect();
- batchThumbnailQueue.length = 0;
- batchThumbnailUrls.forEach((source) => URL.revokeObjectURL(source));
- batchThumbnailUrls.clear();
- if (commentsResizeFrame) cancelAnimationFrame(commentsResizeFrame);
- commentsResizeFrame = 0;
- commentsResize = null;
- imageTransform.destroy();
- lb.remove();
- lightboxScope.destroy();
- };
- let descriptionHeightSaveTimer = 0;
- const descriptionResizeObserver = isFunction(ResizeObserver)
- ? new ResizeObserver(() => {
- if (!commentsSource.open || commentsSource.hidden || lightboxClosed) return;
- clearTimeout(descriptionHeightSaveTimer);
- descriptionHeightSaveTimer = setTimeout(() => {
- const height = normalizeLightboxDescriptionHeight(commentsSourceText.getBoundingClientRect().height);
- if (height === normalizeLightboxDescriptionHeight(PREFS.lightboxDescriptionHeight)) return;
- setPref('lightboxDescriptionHeight', height);
- lb.style.setProperty('--ldp-lb-description-height', `${height}px`);
- }, 160);
- })
- : null;
- if (descriptionResizeObserver) descriptionResizeObserver.observe(commentsSourceText);
- commentsSource.addEventListener('toggle', () => {
- setExpanded(commentsDescriptionToggle, commentsSource.open);
- setLabel(commentsDescriptionToggle, commentsSource.open ? '收纳图片描述' : '展开图片描述');
- setPref('lightboxDescriptionExpanded', commentsSource.open);
- });
- setExpanded(commentsDescriptionToggle, commentsSource.open);
- setLabel(commentsDescriptionToggle, commentsSource.open ? '收纳图片描述' : '展开图片描述');
- commentsDescriptionToggle.addEventListener('click', () => {
- commentsSource.open = !commentsSource.open;
- });
- const renderCommentsResize = () => {
- commentsResizeFrame = 0;
- if (!commentsResize) return;
- const { mainRect, clientX } = commentsResize;
- const minimum = Math.min(
- LIGHTBOX_COMMENTS_WIDTH_MAX,
- Math.max(LIGHTBOX_COMMENTS_WIDTH_MIN, 240 / mainRect.width * 100)
- );
- applyCommentsWidth(Math.min(
- LIGHTBOX_COMMENTS_WIDTH_MAX,
- Math.max(minimum, (mainRect.right - clientX) / mainRect.width * 100)
- ));
- };
- commentsResizer.addEventListener('pointerdown', (event) => {
- if (event.button !== 0 || lb.classList.contains('ldp-lb-comments-collapsed')) return;
- const mainRect = lb.querySelector('.ldp-lb-main').getBoundingClientRect();
- if (!mainRect.width) return;
- commentsResize = {
- pointerId: event.pointerId,
- mainRect,
- clientX: event.clientX,
- };
- commentsResizer.setPointerCapture(event.pointerId);
- lb.classList.add('is-resizing-comments');
- event.preventDefault();
- });
- commentsResizer.addEventListener('pointermove', (event) => {
- if (commentsResize?.pointerId !== event.pointerId) return;
- commentsResize.clientX = event.clientX;
- if (!commentsResizeFrame) commentsResizeFrame = requestAnimationFrame(renderCommentsResize);
- });
- const finishCommentsResize = (event) => {
- if (commentsResize?.pointerId !== event.pointerId) return;
- commentsResize.clientX = event.clientX;
- if (commentsResizeFrame) cancelAnimationFrame(commentsResizeFrame);
- commentsResizeFrame = 0;
- renderCommentsResize();
- commentsResize = null;
- lb.classList.remove('is-resizing-comments');
- applyCommentsWidth(commentsWidthPercent, true);
- };
- ['pointerup', 'pointercancel'].forEach((type) =>
- commentsResizer.addEventListener(type, finishCommentsResize));
- commentsResizer.addEventListener('keydown', (event) => {
- if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
- event.preventDefault();
- applyCommentsWidth(commentsWidthPercent + (event.key === 'ArrowLeft' ? 2 : -2), true);
- });
- function onKey(e) {
- if (!lb.isConnected) return;
- const imageViewer = readerPortalQuery('.ldp-avatar-viewer');
- if (imageViewer?.isConnected) return;
- if (e.key === 'Escape') {
- if (!batchOverlay.hidden) {
- if (!batchBusy) closeBatchDownload();
- e.preventDefault();
- e.stopImmediatePropagation();
- return;
- }
- if (visibleNativeFloatingSurface()) return;
- e.preventDefault();
- e.stopImmediatePropagation();
- close();
- return;
- }
- if (!batchOverlay.hidden) return;
- if (e.target?.closest && e.target.closest('textarea,input,select,[contenteditable="true"]')) return;
- if (e.key === 'ArrowLeft') {
- e.preventDefault();
- move(-1);
- } else if (e.key === 'ArrowRight') {
- e.preventDefault();
- move(1);
- } else handleImageTransformShortcut(e, imageTransform, resetView);
- }
- lb.querySelector('.ldp-lb-toolbar').addEventListener('click', (e) => {
- const button = e.target.closest('[data-lb-action]');
- if (!button) return;
- const action = button.dataset.lbAction;
- if (action === 'close') close();
- else if (action === 'zoom-out') setZoom(imageTransform.scale / 1.2);
- else if (action === 'zoom-in') setZoom(imageTransform.scale * 1.2);
- else if (action === 'reset') resetView();
- else if (action === 'view-original') loadCurrentOriginal();
- else if (action === 'jump-to-post') void jumpToCurrentPost();
- else if (action === 'download') void downloadCurrent();
- else if (action === 'batch-download') openBatchDownload();
- else if (action === 'toggle-comments')
- setCommentsExpanded(lb.classList.contains('ldp-lb-comments-collapsed'), true);
- });
- batchOverlay.addEventListener('click', (event) => {
- const target = event.target instanceof Element ? event.target : null;
- if (target?.closest('.ldp-lb-batch-close,.ldp-lb-batch-cancel')) {
- closeBatchDownload();
- return;
- }
- const scopeButton = target?.closest('[data-lb-batch-scope]');
- if (scopeButton) {
- if (batchBusy || scopeButton.disabled) return;
- const nextScope = scopeButton.dataset.lbBatchScope === 'all' ? 'all' : 'loaded';
- batchScope = nextScope;
- batchSelection.clear();
- batchProgress.hidden = true;
- batchStatus.textContent = nextScope === 'all'
- ? batchAllImagesLoaded ? `已扫描全部帖子,共找到 ${items.length} 张图片` : '准备扫描全部帖子…'
- : '请选择当前已加载的图片';
- if (nextScope === 'all' && !batchAllImagesLoaded) {
- void scanAllTopicImages();
- return;
- }
- renderBatchSelection();
- return;
- }
- if (target?.closest('.ldp-lb-batch-select-all')) {
- if (batchBusy) return;
- const scopedItems = batchScopeItems();
- const allSelected = scopedItems.length > 0 && scopedItems.every((item) => batchSelection.has(item.key));
- batchSelection.clear();
- if (!allSelected) scopedItems.forEach((item) => batchSelection.add(item.key));
- renderBatchSelection();
- } else if (target?.closest('.ldp-lb-batch-download'))
- void downloadBatch();
- });
- batchOverlay.addEventListener('pointerdown', (event) => {
- if (event.target === batchOverlay) closeBatchDownload();
- });
- batchGrid.addEventListener('wheel', (event) => {
- if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
- const delta = event.deltaMode === 1
- ? event.deltaY * 40
- : event.deltaMode === 2 ? event.deltaY * batchGrid.clientHeight : event.deltaY;
- const precisionInput = event.deltaMode === 0 && Math.abs(event.deltaY) < 50;
- const maximum = Math.max(0, batchGrid.scrollHeight - batchGrid.clientHeight);
- const nextScrollTop = Math.max(0, Math.min(maximum,
- batchGrid.scrollTop + delta * (precisionInput ? 1.15 : 1.7)));
- if (nextScrollTop === batchGrid.scrollTop) return;
- consumeEvent(event);
- batchGrid.scrollTop = nextScrollTop;
- }, { passive: false });
- batchGrid.addEventListener('change', (event) => {
- const input = event.target.closest('input[type="checkbox"]');
- const itemNode = input?.closest('[data-lb-batch-index]');
- const itemIndex = itemNode && +itemNode.dataset.lbBatchIndex;
- const item = itemNode && items[itemIndex];
- if (!item || batchBusy) return;
- setBatchItemSelected(item, itemIndex, input.checked, itemNode);
- });
- batchGrid.addEventListener('click', (event) => {
- const loadMoreButton = event.target.closest('.ldp-lb-batch-more');
- if (loadMoreButton) {
- event.preventDefault();
- void loadMoreBatchImages();
- return;
- }
- const image = event.target.closest('.ldp-lb-batch-item img');
- const itemNode = image?.closest('[data-lb-batch-index]');
- const itemIndex = itemNode ? +itemNode.dataset.lbBatchIndex : -1;
- const item = itemIndex >= 0 ? items[itemIndex] : null;
- if (!item || batchBusy) return;
- consumeEvent(event);
- void openBatchItemPreview(itemIndex, itemNode);
- });
- lb.addEventListener('click', (event) => {
- const target = event.target instanceof Element ? event.target : null;
- const thumb = target?.closest('.ldp-lb-thumb');
- if (thumb) showItem(+thumb.dataset.lbIndex);
- else if (target?.closest('.ldp-lb-prev')) move(-1);
- else if (target?.closest('.ldp-lb-next')) move(1);
- else if (target?.closest('.ldp-lb-retry')) loadCurrentOriginal(true);
- else if (target?.closest('.ldp-lb-comment-cancel')) closeCommentForm();
- });
- stage.addEventListener('wheel', (e) => {
- e.preventDefault();
- const nextScale = imageTransform.scale * (e.deltaY < 0 ? 1.15 : 1 / 1.15);
- if (e.target === mainImg) setZoom(nextScale, e.clientX, e.clientY);
- else setZoom(nextScale);
- }, { passive: false });
- stage.addEventListener('dblclick', (e) => {
- if (e.target !== mainImg) return;
- const nativeScale = mainImg.clientWidth ? Math.min(8, mainImg.naturalWidth / mainImg.clientWidth) : 1;
- setZoom(imageTransform.scale > 1.05 ? 1 : Math.max(2, nativeScale));
- });
- lb.querySelector('.ldp-lb-comments').addEventListener('click', onCommentsClick);
- lb.querySelector('.ldp-lb-comments').addEventListener('keydown', onCommentsKeydown);
- lightboxScope.listen(commentsPanel, 'pointerover', onCommentsReactionPointerOver);
- lightboxScope.listen(commentsPanel, 'pointerout', onCommentsReactionPointerOut);
- commentForm.addEventListener('submit', onCommentFormSubmit);
- lightboxScope.listen(document, 'keydown', onKey);
- lightboxScope.listen(window, 'resize', renderTransform);
- syncThumbs();
- appendReaderPortalNode(lb);
- showItem(initialIndex);
- }
+ const SUB_REPLY_PAGE_SIZE = 10; // 楼中楼分页每页条数
+ const SUB_REPLY_FETCH_SIZE = 20; // Discourse replies 接口单次返回上限
+ const INLINE_REPLY_TREE_MAX_DEPTH_MIN = 1;
+ const INLINE_REPLY_TREE_MAX_DEPTH_MAX = 5;
+ const INLINE_REPLY_TREE_MAX_DEPTH_DEFAULT = 3;
+ const REPLIES_HOVER_DELAY = 40; // 楼层真正进入视口并短暂停留后触发抓取(ms)
+ const POST_REQUEST_MAX_429_RETRIES = 1;
+ const POST_REQUEST_429_BACKOFF = 1500;
+ const POST_REQUEST_TIMEOUT = 15000;
+ const READER_REQUEST_QUEUE_LIMIT = 24;
+ const READER_REQUEST_CONCURRENCY_RECOVERY_SUCCESSES = 6;
+ const READER_REQUEST_RATE_PROBE_SUCCESSES = 6;
+ const READER_REQUEST_PROBE_RATE_STEP = 1.12;
+ const READER_REQUEST_RECOVERY_PROBE_INTERVAL = 1200;
+ const READER_REQUEST_RATE_MEMORY_TTL = 10 * 60 * 1000;
+ const READER_ENDPOINT_429_EVIDENCE_WINDOW_MS = 4000;
+ const READER_ENDPOINT_429_BASE_BLOCK_MS = 5000;
+ const READER_ENDPOINT_429_MAX_BLOCK_MS = 5 * 60 * 1000;
+ const READER_ENDPOINT_429_STATE_TTL = 10 * 60 * 1000;
+ const READER_ENDPOINT_429_MAX_ENTRIES = 128;
+ const READER_REQUEST_SHORT_WINDOW_MS = 10 * 1000;
+ const READER_REQUEST_LONG_WINDOW_MS = 60 * 1000;
+ const SHARED_REQUEST_POLICY_TTL = 30 * 1000;
+ const SHARED_CACHE_FLIGHT_TTL = READER_REQUEST_LONG_WINDOW_MS + POST_REQUEST_TIMEOUT + 15000;
+ const SHARED_CACHE_FLIGHT_STALE_MS = 35 * 1000;
+ // Discourse defaults; each site may override them, so 429 feedback remains authoritative.
+ const READER_REQUEST_DEFAULT_SHORT_CEILING = 50;
+ const READER_REQUEST_DEFAULT_LONG_CEILING = 200;
+ const REQUEST_FLOW_MAX_ENTRIES = 1200;
+ const REQUEST_FLOW_RETENTION_MS = 15 * 60 * 1000;
+ const REQUEST_FLOW_TRACE_WINDOW_MS = 10 * 1000;
+ const REQUEST_FLOW_SLOW_MS = 1800;
+ const REQUEST_FLOW_EVENTS = [];
+ const REQUEST_FLOW_RESOURCE_DROPS = [];
+ const REQUEST_FLOW_FETCH_META = Symbol('ldpRequestFlowFetchMeta');
+ const REQUEST_FLOW_AJAX_STATE = new WeakMap();
+ let REQUEST_FLOW_RESOURCE_OBSERVER = null;
+ let REQUEST_FLOW_RESOURCE_BUFFERED = false;
+ let REQUEST_FLOW_DETAILS_ACTIVE = false;
+ let REQUEST_FLOW_AJAX_BOUND = false;
+ const REQUEST_FLOW_TYPE_LABELS = Object.freeze({
+ topic: '正文',
+ nested: '二级回复',
+ avatar: '头像',
+ media: '图片媒体',
+ user: '用户资料',
+ bookmark: '收藏',
+ notification: '消息',
+ realtime: '实时通道',
+ presence: '在线状态',
+ reaction: '回应操作',
+ search: '搜索',
+ read: '已读上报',
+ asset: '静态资源',
+ other: '其他',
+ });
+ const REQUEST_FLOW_SOURCE_LABELS = Object.freeze({ reader: '阅读器', host: '原站', browser: '资源' });
+ const REQUEST_FLOW_WAIT_REASON_LABELS = Object.freeze({
+ priority: '优先级', concurrency: '并发槽', cooldown: '429 退避', 'endpoint-cooldown': '端点熔断',
+ interval: '启动间隔', '10s': '10 秒窗口', '60s': '60 秒窗口',
+ });
+ const RESOURCE_MONITOR_SCOPE_LABELS = Object.freeze({ reader: '阅读器', host: '原站/未标', shared: '页面共享' });
+ let REQUEST_FLOW_SEQUENCE = 0;
- function resolveOriginalSrc(imgEl) {
- const a = imgEl.closest('a.lightbox, a[href]');
- if (a?.getAttribute('href')) {
- const href = a.getAttribute('href');
- if (/\.(png|jpe?g|gif|webp|bmp|avif)(\?|#|$)/i.test(href) || a.classList.contains('lightbox'))
- return href;
+ function requestFlowNow() {
+ const clock = globalThis.performance;
+ if (clock && Number.isFinite(clock.timeOrigin) && isFunction(clock.now)) {
+ return clock.timeOrigin + clock.now();
}
- return imgEl.getAttribute('data-large-src') || imgEl.currentSrc || imgEl.src;
- }
-
- /* ============ 4. 已读追踪器 ============ */
- function readerReadState(topicId) {
- const key = String(topicId);
- let state = READER_READ_STATES.get(key);
- if (!state) {
- state = { confirmed: new Set(), request: null };
- READER_READ_STATES.set(key, state);
- }
- return state;
- }
-
- async function sendReaderReadTimings(topicId, postNumbers, priority = POST_REQUEST_PRIORITY.visible) {
- const requested = [...new Set(Array.from(postNumbers || []).map(Number).filter(Boolean))];
- if (!requested.length) return [];
- const state = readerReadState(topicId);
- const alreadyConfirmed = requested.filter((postNumber) => state.confirmed.has(postNumber));
- if (alreadyConfirmed.length) return alreadyConfirmed;
- const submitted = requested.slice(0, READER_READ_BATCH_SIZE);
- if (state.request) {
- await state.request;
- const confirmed = requested.filter((postNumber) => state.confirmed.has(postNumber));
- if (confirmed.length) return confirmed;
- return sendReaderReadTimings(topicId, requested, priority);
- }
- const send = async () => {
- const fingerprint = readerReadRequestFingerprint(topicId, submitted);
- if (readerReadRequestRecentlySucceeded(fingerprint)) return submitted;
- const params = {};
- submitted.forEach((postNumber) => { params[`timings[${postNumber}]`] = READ_THRESHOLD; });
- params.topic_time = READ_THRESHOLD * submitted.length;
- params.topic_id = topicId;
- const postTimings = () => apiSend(`${BASE}/topics/timings`, 'POST', params, {
- 'Accept': '*/*',
- 'Discourse-Background': 'true',
- 'Discourse-Logged-In': 'true',
- 'Discourse-Present': 'true',
- 'X-SILENCE-LOGGER': 'true',
- }, { priority });
- await postTimings();
- rememberReaderReadRequestSuccess(fingerprint);
- return submitted;
- };
- const request = isFunction(navigator.locks?.request)
- ? navigator.locks.request(LDP_READER_READ_LOCK_NAME, send)
- : send();
- state.request = request;
- try {
- await request;
- submitted.forEach((postNumber) => state.confirmed.add(postNumber));
- return submitted;
- } finally {
- if (state.request === request) state.request = null;
- }
- }
-
- function createReadTracker(topicId, scrollRoot, onReadTracked, onReadPending) {
- const pendingReadPostNumbers = new Set();
- const priorityReadPostNumbers = new Set();
- const readState = readerReadState(topicId);
- const observedNodes = new Set();
- const viewportNodes = new Set();
- const pendingVisibleCallbacks = new Map();
- let readFlushTimer = null;
- let started = false, flushRunning = false;
- let readRetryAt = 0, readRetryCount = 0;
-
- const syncLocalReadState = (postNumbers) => {
- if (!isFunction(onReadTracked)) return;
- try {
- onReadTracked(postNumbers);
- } catch (error) {
- console.warn('[LDP] sync local post read state failed', error);
- }
- };
- const syncLocalPendingState = (postNumbers) => {
- if (!isFunction(onReadPending)) return;
- try {
- onReadPending(postNumbers);
- } catch (error) {
- console.warn('[LDP] sync optimistic post read state failed', error);
- }
- };
-
- const io = new IntersectionObserver((entries) => {
- entries.forEach((en) => {
- const pn = +en.target.dataset.postNumber;
- if (!pn) return;
- const intersection = en.intersectionRect;
- const inViewport = en.isIntersecting &&
- intersection.width > 0 && intersection.height > 0;
- if (inViewport) viewportNodes.add(en.target);
- else viewportNodes.delete(en.target);
- if (inViewport && started && !flushRunning && pendingReadPostNumbers.has(pn)) {
- clearReadFlushTimer();
- scheduleReadFlush(0);
- }
- const onVisible = inViewport && pendingVisibleCallbacks.get(en.target);
- if (onVisible) {
- pendingVisibleCallbacks.delete(en.target);
- onVisible();
- if (!observedNodes.has(en.target)) {
- viewportNodes.delete(en.target);
- io.unobserve(en.target);
- }
- }
- });
- }, { root: scrollRoot, threshold: 0 });
-
- const clearReadFlushTimer = () => {
- if (readFlushTimer) clearTimeout(readFlushTimer);
- readFlushTimer = null;
- };
-
- const scheduleReadFlush = (delay = 0) => {
- if (readFlushTimer || !started || readRetryCount > 1 || !pendingReadPostNumbers.size ||
- document.visibilityState !== 'visible') return;
- const wait = Math.max(0, Number(delay) || 0, readRetryAt - Date.now());
- readFlushTimer = setTimeout(() => {
- readFlushTimer = null;
- void flush();
- }, wait);
- };
-
- const flush = async (options = {}) => {
- if (flushRunning || !pendingReadPostNumbers.size) return false;
- if (options.force !== true && readRetryAt > Date.now()) {
- scheduleReadFlush(readRetryAt - Date.now());
- return false;
- }
- clearReadFlushTimer();
- const visiblePostPriorities = [...viewportNodes].reduce((priorities, node) => {
- const postNumber = +(node?.dataset?.postNumber || 0);
- if (postNumber) priorities.set(postNumber, Math.max(
- priorities.get(postNumber) || 0,
- node.parentElement?.classList.contains('ldp-children') ? 2 : 1
- ));
- return priorities;
- }, new Map());
- priorityReadPostNumbers.forEach((postNumber) => {
- if (pendingReadPostNumbers.has(postNumber))
- visiblePostPriorities.set(postNumber, 3);
- });
- const submitted = [...pendingReadPostNumbers]
- .sort((a, b) => (visiblePostPriorities.get(b) || 0) -
- (visiblePostPriorities.get(a) || 0))
- .slice(0, READER_READ_BATCH_SIZE);
- const hasVisiblePost = submitted.some((postNumber) => visiblePostPriorities.has(postNumber));
- const priority = Number.isFinite(options.priority)
- ? options.priority
- : hasVisiblePost ? POST_REQUEST_PRIORITY.visible : POST_REQUEST_PRIORITY.background;
- flushRunning = true;
- try {
- const confirmed = await sendReaderReadTimings(topicId, submitted, priority);
- confirmed.forEach((postNumber) => {
- pendingReadPostNumbers.delete(postNumber);
- priorityReadPostNumbers.delete(postNumber);
- });
- readRetryAt = 0;
- readRetryCount = 0;
- syncLocalReadState(confirmed);
- return true;
- } catch (error) {
- if (error?.cloudflareMitigated) {
- readRetryCount = 2;
- readRetryAt = 0;
- } else {
- readRetryCount++;
- readRetryAt = Date.now() + FLUSH_INTERVAL;
- }
- return false;
- } finally {
- flushRunning = false;
- if (started && pendingReadPostNumbers.size) scheduleReadFlush();
- }
- };
-
- const hasVisiblePending = () => [...viewportNodes].some((node) =>
- pendingReadPostNumbers.has(+(node?.dataset?.postNumber || 0))) ||
- [...priorityReadPostNumbers].some((postNumber) => pendingReadPostNumbers.has(postNumber));
- const onVisibilityChange = () => {
- if (!started) return;
- if (document.visibilityState === 'visible') {
- if (pendingReadPostNumbers.size) scheduleReadFlush(0);
- return;
- }
- clearReadFlushTimer();
- };
-
- return {
- preload(posts) {
- const wasEmpty = !pendingReadPostNumbers.size;
- const locallyConfirmed = [];
- const newlyPending = [];
- (Array.isArray(posts) ? posts : [posts]).forEach((post) => {
- const postNumber = +(post?.post_number || post || 0);
- if (!postNumber || post?.read === true || pendingReadPostNumbers.has(postNumber)) return;
- if (readState.confirmed.has(postNumber)) {
- locallyConfirmed.push(postNumber);
- return;
- }
- pendingReadPostNumbers.add(postNumber);
- newlyPending.push(postNumber);
- });
- if (locallyConfirmed.length) syncLocalReadState(locallyConfirmed);
- if (newlyPending.length) {
- syncLocalPendingState(newlyPending);
- if (wasEmpty) { readRetryAt = 0; readRetryCount = 0; }
- scheduleReadFlush(0);
- }
- },
- confirm(postNumbers) {
- Array.from(postNumbers || []).map(Number).filter(Boolean).forEach((postNumber) => {
- pendingReadPostNumbers.delete(postNumber);
- priorityReadPostNumbers.delete(postNumber);
- readState.confirmed.add(postNumber);
- });
- if (!pendingReadPostNumbers.size) {
- readRetryAt = 0;
- readRetryCount = 0;
- clearReadFlushTimer();
- }
- },
- setVisible(postNumbers, visible = true) {
- Array.from(postNumbers || []).map(Number).filter(Boolean).forEach((postNumber) => {
- if (visible && pendingReadPostNumbers.has(postNumber)) priorityReadPostNumbers.add(postNumber);
- else priorityReadPostNumbers.delete(postNumber);
- });
- if (visible && priorityReadPostNumbers.size && started && !flushRunning) {
- clearReadFlushTimer();
- scheduleReadFlush(0);
- }
- },
- hasVisiblePending() {
- return hasVisiblePending();
- },
- observe(node) {
- if (!node) return;
- observedNodes.add(node);
- io.observe(node);
- },
- unobserve(node) {
- if (!node) return;
- observedNodes.delete(node);
- viewportNodes.delete(node);
- io.unobserve(node);
- },
- runWhenVisible(node, callback) {
- if (!node || !isFunction(callback)) return false;
- if (viewportNodes.has(node) || readerPostNodeInViewport(node, scrollRoot)) {
- callback();
- return true;
- }
- pendingVisibleCallbacks.set(node, callback);
- io.observe(node);
- return false;
- },
- start() {
- if (started) return;
- started = true;
- document.addEventListener('visibilitychange', onVisibilityChange);
- if (pendingReadPostNumbers.size) scheduleReadFlush(0);
- },
- stop() {
- if (!started) return;
- started = false;
- clearReadFlushTimer();
- document.removeEventListener('visibilitychange', onVisibilityChange);
- io.disconnect();
- observedNodes.clear();
- viewportNodes.clear();
- pendingVisibleCallbacks.clear();
- priorityReadPostNumbers.clear();
- },
- };
- }
+ return Date.now();
+ }
- /* ============ 5. 分块加载(init 带 Discourse 浏览追踪头 + 重试保护) ============ */
- function createLoader(topicId, requestScheduler, runtimeConfig) {
- const pageSize = runtimeConfig.pageSize;
- const onlyOpBatchFloor = Math.min(
- PERFORMANCE_LIMITS.pageSize.max,
- Math.max(pageSize, runtimeConfig.requestMaxConcurrent <= 1 ? 32 : 40)
- );
- let onlyOpPageSize = onlyOpBatchFloor;
- let stream = [];
- const cache = new Map();
- const postByNumber = new Map();
- const unavailablePostNumbers = new Set();
- const pendingPostRequests = new Map();
- let streamIndexById = new Map();
- const requestController = new AbortController();
- let cursor = 0;
- let onlyOpCursor = 0;
- let onlyOpSkippedCount = 0;
- let topic = null;
- let failStreak = 0;
- let onlyOpFailStreak = 0;
- let refreshRequest = null;
- let initializedFromCache = false;
- let stopTopicCacheSync = () => {};
-
- const growOnlyOpBatch = () => {
- let requestState = null;
- try { requestState = requestScheduler?.snapshot(); } catch {}
- const shortPressure = requestState
- ? Number(requestState.shortWindowCount || 0) / Math.max(1, Number(requestState.shortWindowBudget) || 1)
- : 0;
- const longPressure = requestState
- ? Number(requestState.longWindowCount || 0) / Math.max(1, Number(requestState.longWindowBudget) || 1)
- : 0;
- const pressured = requestState && (
- requestState.coolingDown ||
- ['10s', '60s'].includes(requestState.blockingReason) ||
- Number(requestState.globalQueued || 0) > 0 ||
- Math.max(shortPressure, longPressure) >= 0.7
- );
- onlyOpPageSize = Math.min(
- PERFORMANCE_LIMITS.pageSize.max,
- onlyOpPageSize + (pressured ? 12 : 8)
- );
- };
-
- const adjustOnlyOpBatchAfterError = (error) => {
- const status = Number(error?.status || 0);
- if (status === 408 || status === 429 || status >= 500) {
- onlyOpPageSize = PERFORMANCE_LIMITS.pageSize.max;
- return;
- }
- onlyOpPageSize = Math.max(onlyOpBatchFloor, onlyOpPageSize - 8);
- };
-
- const fetchLoaderJSON = (url, options = {}) => fetchJSON(url, {
- scheduler: requestScheduler,
- signal: requestController.signal,
- fetchOptions: options.fetchOptions,
- cacheMode: options.cacheMode || 'default',
- key: options.key || `post:${url}`,
- priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible,
- });
-
- const cachePost = (post) => {
- if (!post || !post.id) return;
- const previous = cache.get(post.id);
- const completePost = previous && previous !== post ? Object.assign(previous, post) : post;
- cache.set(post.id, completePost);
- const postNumber = +(completePost.post_number || 0);
- if (postNumber) postByNumber.set(postNumber, completePost);
- };
-
- const cachePosts = (posts) => (posts || []).forEach(cachePost);
-
- const appendMissingTopicPosts = (topicData, posts) => {
- if (!topicData || !Array.isArray(posts) || !posts.length) return false;
- if (!topicData.post_stream) topicData.post_stream = {};
- const current = Array.isArray(topicData.post_stream.posts) ? topicData.post_stream.posts : [];
- const indexesById = new Map();
- current.forEach((post, index) => {
- if (post?.id) indexesById.set(post.id, index);
- });
- let changed = false;
- posts.forEach((post) => {
- if (!post || !post.id) return;
- const index = indexesById.get(post.id);
- if (Number.isInteger(index)) {
- const previous = current[index];
- if (previous === post) return;
- current[index] = previous ? Object.assign(previous, post) : post;
- changed = true;
- return;
- }
- indexesById.set(post.id, current.length);
- current.push(post);
- changed = true;
- });
- if (changed) topicData.post_stream.posts = current;
- return changed;
- };
-
- const persistFetchedPosts = (posts) => {
- if (!Array.isArray(posts) || !posts.length) return;
- const entry = TOPIC_DATA_CACHE.get(String(topicId));
- const persistedTopic = (entry?.topic) || topic;
- if (!persistedTopic) return;
- const changed = appendMissingTopicPosts(persistedTopic, posts);
- if (topic && topic !== persistedTopic) appendMissingTopicPosts(topic, posts);
- if (changed) setCachedTopicData(topicId, persistedTopic);
- };
-
- const persistViewedPostNumbers = (postNumbers) => {
- const posts = [...new Set((postNumbers || []).map(Number).filter(Boolean))]
- .map((postNumber) => postByNumber.get(postNumber))
- .filter(Boolean);
- persistFetchedPosts(posts);
- };
-
- function normalizeTopicData(data) {
- const posts = Array.isArray(data.post_stream?.posts) ? data.post_stream.posts : [];
- const op = (data.details?.created_by && data.details.created_by.username)
- || (posts.find((p) => p.post_number === 1) || {}).username
- || null;
- data._opUsername = op;
- return {
- posts,
- stream: (data.post_stream?.stream) || posts.map((p) => p.id).filter(Boolean),
- };
- }
-
- function primeTopicData(data) {
- const normalized = normalizeTopicData(data);
- topic = data;
- stream = normalized.stream;
- streamIndexById = new Map(stream.map((id, index) => [id, index]));
- cachePosts(normalized.posts);
- return topic;
- }
-
- const syncTopicCacheData = (incomingTopic) => {
- if (!incomingTopic) return;
- const incoming = normalizeTopicData(incomingTopic);
- cachePosts(incoming.posts);
- if (incomingTopic === topic) return;
- if (!topic) return;
- appendMissingTopicPosts(topic, incoming.posts);
- Object.keys(incomingTopic).forEach((key) => {
- if (key !== 'post_stream') topic[key] = incomingTopic[key];
- });
- if (incoming.stream.length >= stream.length) {
- stream = incoming.stream.slice();
- streamIndexById = new Map(stream.map((id, index) => [id, index]));
- }
- };
- stopTopicCacheSync = subscribeTopicCache(topicId, syncTopicCacheData);
-
- async function refreshTopicData({ prime = true, priority } = {}) {
- syncCurrentUserFromHost();
- if (!refreshRequest) {
- refreshRequest = (async () => {
- const url = `${BASE}/t/${topicId}.json?track_visit=true&forceLoad=true`;
- const data = await fetchJSON(url, {
- scheduler: requestScheduler,
- signal: requestController.signal,
- cacheMode: 'refresh',
- fetchOptions: discourseJSONFetchOptions({
- 'Discourse-Track-View': 'true',
- 'Discourse-Track-View-Topic-Id': String(topicId),
- }),
- key: `topic:${topicId}:refresh`,
- priority: Number.isFinite(priority)
- ? priority
- : topic
- ? POST_REQUEST_PRIORITY.background
- : POST_REQUEST_PRIORITY.topic,
- });
- const entry = TOPIC_DATA_CACHE.get(String(topicId));
- const previousTopic = (entry?.topic) || topic;
- if (previousTopic?.post_stream) {
- appendMissingTopicPosts(data, previousTopic.post_stream.posts);
- }
- normalizeTopicData(data);
- setCachedTopicData(topicId, data, {
- fetchedAt: responseCacheValueStoredAt(data) || Date.now(),
- source: 'network',
- });
- return data;
- })().finally(() => { refreshRequest = null; });
- }
- const data = await refreshRequest;
- if (prime) return primeTopicData(data);
- return data;
- }
-
- async function init() {
- if (topic) return topic;
- syncCurrentUserFromHost();
- const previewTopic = getCachedTopicData(topicId);
- const storedTopic = await getStoredTopicSnapshot(topicId);
- const cachedTopic = storedTopic || previewTopic;
- const cacheEntry = TOPIC_DATA_CACHE.get(String(topicId));
- if (cachedTopic && cacheEntry) {
- initializedFromCache = true;
- primeTopicData(cachedTopic);
- const expectedPostCount = Math.max(0, Number(topic.posts_count) || 0);
- if (expectedPostCount > stream.length) {
- initializedFromCache = false;
- return refreshTopicData();
- }
- const fetchedAt = topicCacheEntryTimestamp(cacheEntry);
- if (!fetchedAt || Date.now() - fetchedAt > TOPIC_CACHE_TTL) {
- refreshTopicData({ prime: false, priority: POST_REQUEST_PRIORITY.background }).catch(() => {});
- }
- return topic;
- }
- initializedFromCache = false;
- return refreshTopicData();
- }
-
- async function next(options = {}) {
- const onlyOpScan = options.onlyOp === true;
- const position = onlyOpScan ? onlyOpCursor : cursor;
- if (position >= stream.length) return { posts: [], done: true };
- const batchSize = onlyOpScan ? onlyOpPageSize : pageSize;
- const slice = stream.slice(position, position + batchSize);
- const resetFailures = () => {
- if (onlyOpScan) onlyOpFailStreak = 0;
- else failStreak = 0;
- };
- const bumpFailures = () => onlyOpScan ? ++onlyOpFailStreak : ++failStreak;
- const advance = (count, skipped = 0) => {
- if (onlyOpScan) {
- onlyOpCursor += count;
- onlyOpSkippedCount += skipped;
- return onlyOpCursor >= stream.length;
- }
- cursor += count;
- return cursor >= stream.length;
- };
- const prepareOnlyOpPosts = (posts) => {
- if (!onlyOpScan) return posts;
- const opUsername = String(options.onlyOpUsername || '');
- if (!opUsername) return posts;
- const matches = [];
- posts.forEach((post) => {
- if (String(post?.username || '') === opUsername) {
- matches.push(post);
- return;
- }
- if (post?.id && cache.get(post.id) === post) cache.delete(post.id);
- const postNumber = +(post?.post_number || 0);
- if (postNumber && postByNumber.get(postNumber) === post) postByNumber.delete(postNumber);
- });
- if (matches.length) persistFetchedPosts(matches);
- return matches;
- };
- const finishBatch = (skipped = 0, growOnlyOp = false) => {
- const done = advance(slice.length, skipped);
- resetFailures();
- const posts = prepareOnlyOpPosts(slice.map((id) => cache.get(id)).filter(Boolean));
- if (growOnlyOp && onlyOpScan) growOnlyOpBatch();
- return { posts, done };
- };
- let missing = slice.filter((id) => !cache.has(id));
- if (isFunction(options.onSource)) {
- options.onSource(missing.length ? 'network' : 'cache', {
- cachedCount: slice.length - missing.length,
- missingCount: missing.length,
- totalCount: slice.length,
- });
- }
- let loadError = null;
- if (missing.length) {
- try {
- await loadPostsByIds(slice, {
- priority: POST_REQUEST_PRIORITY.scroll,
- persistFetchedPosts: !onlyOpScan && options.persistFetchedPosts === true,
- });
- } catch (error) {
- loadError = error;
- }
- missing = slice.filter((id) => !cache.has(id));
- }
-
- if (missing.length) {
- if (loadError) {
- if (onlyOpScan) adjustOnlyOpBatchAfterError(loadError);
- const status = Number(loadError.status || 0);
- if (status === 401 || status === 403) {
- return { posts: [], done: false, fatal: true, error: loadError };
- }
- if (!status || shouldStopReaderFallback(loadError)) {
- resetFailures();
- return { posts: [], done: false, retry: true, error: loadError };
- }
- if (bumpFailures() < 4) return { posts: [], done: false, retry: true, error: loadError };
- return finishBatch(missing.length);
- }
- if (onlyOpScan) onlyOpPageSize = Math.max(onlyOpBatchFloor, onlyOpPageSize - 8);
- if (bumpFailures() >= 4) {
- return finishBatch(missing.length);
- }
- return { posts: [], done: false, retry: true };
- }
-
- return finishBatch(0, true);
- }
-
- async function requestMissingPostIds(ids, priority, shouldPersist) {
- const missing = ids.filter((id) => !cache.has(id));
- const unclaimed = missing.filter((id) => !pendingPostRequests.has(id));
- if (unclaimed.length) {
- const requestIds = [...new Set(unclaimed)];
- const requestKey = requestIds.slice().sort((a, b) => String(a).localeCompare(String(b))).join(',');
- const qs = requestIds.map((id) => `post_ids[]=${id}`).join('&');
- const request = fetchLoaderJSON(`${BASE}/t/${topicId}/posts.json?${qs}`, {
- key: `posts:${topicId}:${requestKey}`,
- priority,
- }).then((part) => {
- cachePosts(part?.post_stream && part.post_stream.posts);
- }).finally(() => {
- requestIds.forEach((id) => {
- if (pendingPostRequests.get(id) === request) pendingPostRequests.delete(id);
- });
- });
- requestIds.forEach((id) => pendingPostRequests.set(id, request));
- }
- const requests = [...new Set(
- missing.map((id) => pendingPostRequests.get(id)).filter(Boolean)
- )];
- if (requests.length) await Promise.all(requests);
- if (shouldPersist) persistFetchedPosts(missing.map((id) => cache.get(id)).filter(Boolean));
- }
-
- async function loadPostsByIds(ids, options = {}) {
- const requestedIds = [...new Set((ids || []).filter(Boolean))];
- let missing = requestedIds.filter((id) => !cache.has(id));
- let lastError = null;
- const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible;
- const maxAttempts = Math.max(1, +(options.maxAttempts || 2));
- for (let attempt = 0; attempt < maxAttempts && missing.length; attempt++) {
- try {
- await requestMissingPostIds(missing, priority, options.persistFetchedPosts === true);
- lastError = null;
- } catch (error) {
- lastError = error;
- const status = Number(error?.status || 0);
- const splitDepth = Math.max(0, Number(options.clientSplitDepth) || 0);
- const canSplitClientError = [400, 404, 413, 414, 422].includes(status) &&
- missing.length > 1 && splitDepth < 1;
- if (canSplitClientError) {
- const middle = Math.ceil(missing.length / 2);
- const splitOptions = { ...options, maxAttempts: 1, clientSplitDepth: splitDepth + 1 };
- const results = await Promise.allSettled([
- loadPostsByIds(missing.slice(0, middle), splitOptions),
- loadPostsByIds(missing.slice(middle), splitOptions),
- ]);
- const authFailure = results.find((result) => result.status === 'rejected' &&
- [401, 403].includes(Number(result.reason?.status || 0)));
- if (authFailure) throw authFailure.reason;
- const transientFailure = results.find((result) => result.status === 'rejected' && (
- !Number(result.reason?.status || 0) || shouldStopReaderFallback(result.reason)
- ));
- if (transientFailure) throw transientFailure.reason;
- lastError = null;
- break;
- }
- if (shouldStopReaderFallback(error) || (status >= 400 && status < 500)) break;
- }
- missing = requestedIds.filter((id) => !cache.has(id));
- if (missing.length && attempt + 1 < maxAttempts) {
- await waitForDelay(240);
- }
- }
- if (missing.length && lastError) throw lastError;
- const posts = requestedIds.map((id) => cache.get(id)).filter(Boolean);
- return posts;
- }
-
- function streamIndexForPostNumber(postNumber) {
- const target = +postNumber;
- const cachedPost = postByNumber.get(target);
- const cachedIndex = cachedPost && streamIndexById.get(cachedPost.id);
- if (Number.isInteger(cachedIndex)) return cachedIndex;
- return Math.min(stream.length - 1, Math.max(0, target - 1));
- }
-
- function advanceCursorPast(posts, targetPostNumber) {
- if (!stream.length) return;
- let maxIndex = -1;
- posts.forEach((p) => {
- const byId = streamIndexById.get(p.id);
- if (Number.isInteger(byId)) {
- maxIndex = Math.max(maxIndex, byId);
- } else if (p.post_number) {
- maxIndex = Math.max(maxIndex, Math.min(stream.length - 1, Math.max(0, +p.post_number - 1)));
- }
- });
- if (maxIndex < 0 && targetPostNumber) {
- maxIndex = Math.min(stream.length - 1, Math.max(0, +targetPostNumber - 1));
- }
- if (maxIndex >= cursor) cursor = maxIndex + 1;
- }
-
- async function loadRelativePosts(postNumber, direction, options = {}) {
- if (!stream.length) return [];
- const last = direction === 'last';
- const targetIndex = last ? stream.length - 1 : streamIndexForPostNumber(postNumber);
- if (last && !stream[targetIndex]) return [];
- if (direction === 'before' && targetIndex <= 0) return [];
- const start = last ? targetIndex : direction === 'before'
- ? Math.max(0, targetIndex - pageSize)
- : Math.min(stream.length, targetIndex + 1);
- const end = last ? stream.length : direction === 'before' ? targetIndex : start + pageSize;
- const posts = await loadPostsByIds(stream.slice(start, end), {
- priority: Number.isFinite(options.priority) ? options.priority :
- last ? POST_REQUEST_PRIORITY.target : POST_REQUEST_PRIORITY.scroll,
- persistFetchedPosts: !last && options.persistFetchedPosts === true,
- });
- if (last && posts.length) advanceCursorPast(posts);
- return posts;
- }
-
- async function loadPost(postNumber, options = {}) {
- const target = +postNumber;
- const forceRefresh = options.forceRefresh === true;
- if (!target || (!forceRefresh && unavailablePostNumbers.has(target))) return [];
- const around = options.scope === 'around';
- const shouldAdvance = around || options.advanceCursor !== false;
- const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.scroll;
- const cached = around || forceRefresh ? null : postByNumber.get(target);
- if (cached) {
- if (isFunction(options.onSource)) options.onSource('cache');
- if (shouldAdvance) advanceCursorPast([cached], target);
- return [cached];
- }
- if (isFunction(options.onSource)) options.onSource('network');
- if (!around) {
- const highestPostNumber = Math.max(0, Number(topic?.highest_post_number) || 0);
- const id = (!highestPostNumber || highestPostNumber === stream.length) ? stream[target - 1] : null;
- const posts = id && !forceRefresh ? await loadPostsByIds([id], { priority }) : [];
- const hit = posts.find((post) => +post.post_number === target);
- if (hit) {
- if (shouldAdvance) advanceCursorPast([hit], target);
- return [hit];
- }
- }
- const slug = encodeURIComponent((topic?.slug) || 'topic');
- const urls = around
- ? [
- `${BASE}/t/${slug}/${topicId}/${target}.json`,
- `${BASE}/t/${slug}/${topicId}.json?post_number=${target}`,
- `${BASE}/t/${topicId}.json?post_number=${target}`,
- `${BASE}/posts/by_number/${topicId}/${target}.json`,
- ]
- : [
- `${BASE}/posts/by_number/${topicId}/${target}.json`,
- `${BASE}/t/${slug}/${topicId}/${target}.json`,
- `${BASE}/t/${topicId}.json?post_number=${target}`,
- ];
- let fallbackPosts = [];
- for (const url of urls) {
- try {
- const payload = await fetchLoaderJSON(url, {
- key: `${forceRefresh ? 'force-' : ''}${around ? 'around' : 'single'}:${topicId}:${target}:${url}`,
- priority,
- fetchOptions: forceRefresh ? { cache: 'no-store' } : {},
- cacheMode: forceRefresh ? 'refresh' : 'default',
- });
- const fetched = postsFromPayload(payload).filter((p) => p?.id && p.post_number);
- cachePosts(fetched);
- const found = fetched.find((p) => +p.post_number === target);
- if (found) {
- if (shouldAdvance) advanceCursorPast(around ? fetched : [found], target);
- return around ? fetched : [found];
- }
- if (around && fetched.length && !fallbackPosts.length) fallbackPosts = fetched;
- } catch (error) {
- const status = Number(error?.status || 0);
- if (!around && (status === 404 || status === 410) && url.includes('/posts/by_number/')) {
- unavailablePostNumbers.add(target);
- return [];
- }
- if (shouldStopReaderFallback(error)) throw error;
- }
- }
- if (around && stream.length) {
- const start = Math.max(0, Math.min(stream.length - pageSize, target - 1 - Math.floor(pageSize / 2)));
- const ids = stream.slice(start, start + pageSize);
- if (ids.length) {
- try {
- const posts = await loadPostsByIds(ids, { priority });
- if (posts.length) {
- advanceCursorPast(posts, target);
- return posts;
- }
- } catch (error) {
- if (shouldStopReaderFallback(error)) throw error;
- }
- }
- }
- if (around && fallbackPosts.length) advanceCursorPast(fallbackPosts, target);
- return around ? fallbackPosts : [];
- }
-
- return {
- init, next, loadPostsByIds, loadPost,
- loadBeforePost: (postNumber, options) => loadRelativePosts(postNumber, 'before', options),
- loadAfterPost: (postNumber, options) => loadRelativePosts(postNumber, 'after', options),
- loadLastPost: (options) => loadRelativePosts(0, 'last', options),
- refreshTopicData,
- persistViewedPostNumbers, persistFetchedPosts,
- destroy() {
- stopTopicCacheSync();
- stopTopicCacheSync = () => {};
- requestController.abort();
- stream.length = 0;
- cache.clear();
- postByNumber.clear();
- unavailablePostNumbers.clear();
- pendingPostRequests.clear();
- streamIndexById.clear();
- topic = null;
- refreshRequest = null;
- },
- get topic() { return topic; },
- get cachedPosts() { return [...cache.values()]; },
- get initializedFromCache() { return initializedFromCache; },
- get loadDone() { return cursor >= stream.length; },
- get scanDone() { return onlyOpCursor >= stream.length; },
- get scanCursor() { return onlyOpCursor; },
- get scanSkippedCount() { return onlyOpSkippedCount; },
- get scanBatchSize() { return onlyOpPageSize; },
- get streamLength() { return stream.length; },
- };
- }
-
- /* ============ 6. 楼层归位 ============ */
- function streamItemParticipatesInZebra(item) {
- return !!(item?.node) &&
- !item.zebraExcluded &&
- !item.hiddenReplyMarker &&
- !item.node.hidden &&
- !item.node.classList.contains('ldp-nested-collapsed') &&
- !item.node.classList.contains('ldp-nested-preview');
- }
-
- function reindexStreamItems(ctx, start = 0) {
- const items = ctx?.streamItems;
- if (!items || !items.length) return;
- const from = Math.max(0, Math.min(items.length - 1, +start || 0));
- let zebraIndex = from > 0 ? +(items[from - 1].zebraCountAfter || 0) : 0;
- for (let index = from; index < items.length; index++) {
- const item = items[index];
- const participates = streamItemParticipatesInZebra(item);
- item.index = index;
- item.zebraIndex = participates ? zebraIndex++ : -1;
- item.zebraCountAfter = zebraIndex;
- }
- }
-
- function insertStreamPost(ctx, node, postNumber, options = {}) {
- const current = +postNumber;
- if (!current || ctx.streamItemMap.has(current)) return;
- let low = 0;
- let high = ctx.streamItems.length;
- const compare = (item) => {
- if (!ctx.isPostVoting) return item.postNumber - current;
- if (item.postNumber === 1 || current === 1) return item.postNumber === current ? 0 : (item.postNumber === 1 ? -1 : 1);
- const left = +(item.node?._ldpPostData && item.node._ldpPostData.post_voting_vote_count || 0);
- const right = +(node._ldpPostData?.post_voting_vote_count || 0);
- return right - left || item.postNumber - current;
- };
- while (low < high) {
- const mid = Math.floor((low + high) / 2);
- if (compare(ctx.streamItems[mid]) < 0) low = mid + 1;
- else high = mid;
- }
- const estimatedHeight = Math.max(
- STREAM_ESTIMATED_HEIGHT,
- postContentPlaceholderHeight(node._ldpPostData) + 72
- );
- if (ctx.streamMountedNodes.size && ctx.streamWindowEnd > ctx.streamWindowStart) {
- if (low < ctx.streamWindowStart) {
- ctx.streamWindowStart++;
- ctx.streamWindowEnd++;
- } else if (low < ctx.streamWindowEnd) {
- ctx.streamWindowEnd++;
- }
- }
- const item = {
- postNumber: current,
- username: String((node._ldpPostData?.username) || node.dataset.username || ''),
- node,
- zebraExcluded: options.zebraExcluded === true,
- height: estimatedHeight,
- index: low,
- };
- ctx.streamItems.splice(low, 0, item);
- ctx.streamItemMap.set(current, item);
- if (options.deferSync) {
- ctx.streamPendingReindexFrom = Number.isInteger(ctx.streamPendingReindexFrom)
- ? Math.min(ctx.streamPendingReindexFrom, low)
- : low;
- } else {
- reindexStreamItems(ctx, low);
- }
- if (ctx.onlyOp) invalidateStreamLayout(ctx);
- else markStreamPrefixDirty(ctx, low);
- ctx.streamNeedsRepair = true;
- ensureStreamScaffold(ctx);
- if (!options.deferSync) scheduleStreamWindowSync(ctx);
- }
-
- function flushStreamPostBatch(ctx, syncNow = false, preserveViewportAnchor = false) {
- if (!ctx || !Number.isInteger(ctx.streamPendingReindexFrom)) return;
- const dirtyFrom = ctx.streamPendingReindexFrom;
- reindexStreamItems(ctx, dirtyFrom);
- ctx.streamPendingReindexFrom = null;
- refreshStreamZebra(ctx);
- if (ctx.onlyOp) invalidateStreamLayout(ctx);
- else markStreamPrefixDirty(ctx, dirtyFrom);
- if (syncNow) {
- const keepMountedWindow = preserveViewportAnchor && ctx.streamViewportAnchor &&
- ctx.streamMountedNodes.size > 0 && ctx.streamWindowEnd > ctx.streamWindowStart;
- if (keepMountedWindow) {
- if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame);
- ctx.streamWindowFrame = 0;
- // Prepending changes every item index before scrollTop can be corrected.
- // Keep the anchored DOM mounted, restore it, then calculate the new window.
- mountStreamRange(ctx, ctx.streamWindowStart, ctx.streamWindowEnd);
- syncStreamWindow(ctx);
- } else {
- flushStreamWindow(ctx);
- }
- if (ctx.timeline) ctx.timeline.update();
- } else {
- scheduleStreamWindowSync(ctx);
- }
- }
-
- function refreshStreamZebra(ctx, nodes = null) {
- if (!ctx || !ctx.commentsEl) return;
- if (ctx.onlyOp) streamLayoutItems(ctx);
- const targets = nodes || ctx.commentsEl.children;
- for (const node of targets) {
- if (!node.classList.contains('ldp-post')) continue;
- const item = ctx.streamItemMap?.get(streamPostNumber(node));
- const zebraIndex = item ? (ctx.onlyOp ? item.layoutZebraIndex : item.zebraIndex) : -1;
- node.classList.toggle('ldp-zebra-alt', zebraIndex >= 0 && zebraIndex % 2 === 1);
- }
- }
-
- function syncHiddenReplyRun(run, atEnd = false) {
- if (!run.length) return null; const lead = run[0], markerClass = 'ldp-hidden-reply-marker';
- let marker = lead.node.querySelector(`:scope > .${markerClass}`); const expanded = marker?.querySelector('.ldp-hidden-reply-list')?.hidden === false;
- if (!marker) {
- marker = makeElement('div'); marker.className = markerClass;
- lead.node.prepend(marker);
- }
- lead.node.classList.toggle('ldp-hidden-replies-at-end', atEnd);
- [...lead.node.children].forEach((child) => { if (child !== marker) child.hidden = true; });
- marker.innerHTML = `
-
${run.map(({ post }) => {
- const name = post.name || post.username || `#${post.post_number}`, src = avatarUrl(post.avatar_template, 24); return `
`;
- }).join('')}
`; let changed = null; run.forEach(({ item, node, index }, offset) => {
- const isLead = offset === 0, hidden = !isLead; if (node.hidden !== hidden || item.hiddenReplyMarker !== isLead) changed = changed == null ? index : Math.min(changed, index);
- node.hidden = hidden; node.classList.remove('ldp-zebra-alt'); item.hiddenReplyMarker = isLead; item.height = isLead ? 18 : 0; }); return changed;
- }
-
- function applyNestedReplyDisplayPreference(ctx, forceZebra = false) {
- if (!ctx || !Array.isArray(ctx.streamItems)) return;
- const collapseChildren = PREFS.expandNestedRepliesByDefault !== true;
- const expandLeaf = PREFS.expandLeafNestedReplies === true;
- let reindexFrom = null, hiddenRun = [];
- const flushHiddenRun = (atEnd = false) => {
- const changed = syncHiddenReplyRun(hiddenRun, atEnd);
- if (changed != null) reindexFrom = reindexFrom == null ? changed : Math.min(reindexFrom, changed);
- hiddenRun = [];
- };
- ctx.streamItems.forEach((item, index) => {
- const node = item?.node;
- if (!node) return;
- node.classList.remove('ldp-hidden-replies-at-end');
- const keepOnlyOpExpanded = !!(ctx.onlyOp && String(item.username || '') === String(ctx.op || ''));
- const post = node._ldpPostData;
- const parentNumber = +(post?.reply_to_post_number || node.dataset.replyToPostNumber || 0);
- const isNestedFloor = !!(post && parentNumber > 0);
- const hideFloor = isNestedFloor && !post.hidden;
- if (hideFloor) hiddenRun.push({ item, node, post, index });
- else flushHiddenRun();
- if (+(node.dataset.childReplyTotal || 0) > 0) {
- setChildRepliesCollapsed(node, keepOnlyOpExpanded ? false : collapseChildren);
- }
- const isLeafNestedReply = !!(post && node.classList.contains('ldp-reply-collapsible') &&
- !post.hidden && !(+post.reply_count > 0));
- const collapsed = !expandLeaf && !keepOnlyOpExpanded;
- if (isLeafNestedReply) setNestedReplyCollapsed(node, collapsed, { deferZebraSync: true });
- const excluded = !!post?.hidden || hideFloor || (isLeafNestedReply && collapsed);
- if (item.zebraExcluded === excluded) return;
- item.zebraExcluded = excluded;
- reindexFrom = reindexFrom == null ? index : Math.min(reindexFrom, index);
- });
- flushHiddenRun(true);
- if (forceZebra) reindexFrom = 0;
- if (reindexFrom == null) return;
- reindexStreamItems(ctx, reindexFrom);
- if (ctx.onlyOp) invalidateStreamLayout(ctx);
- else markStreamPrefixDirty(ctx, reindexFrom);
- refreshStreamZebra(ctx);
- scheduleStreamWindowSync(ctx);
- }
-
- function findReplyListParent(postNumber, ctx) {
- const num = +postNumber;
- if (!num || !ctx) return null;
- const streamNode = getStreamPostNode(ctx, num);
- if (streamNode?.isConnected) return streamNode;
- return (ctx.commentsEl?.querySelector(`:scope > .ldp-post[data-post-number="${num}"]`)) ||
- null;
- }
-
- function rememberLatestReplyTime(ctx, post, options = {}) {
- const createdAt = String((post?.created_at) || '');
- const timestamp = Date.parse(createdAt);
- if (!ctx || !Number.isFinite(timestamp)) return;
- if (!options.force && timestamp <= +(ctx.latestReplyTimestamp || 0)) return;
- ctx.latestReplyAt = createdAt;
- ctx.latestReplyTimestamp = timestamp;
- if (ctx.timeline) ctx.timeline.update();
- }
-
- function rememberPostData(ctx, post, parentHint = 0) {
- const postNumber = +(post?.post_number || 0);
- if (!ctx || !postNumber) return 0;
- const knownParentNumber = +(ctx.replyParentByPost?.get(postNumber) || 0);
- const parentNumber = +(post.reply_to_post_number || parentHint || knownParentNumber || 0);
- if (!parentNumber) return 0;
- if (!post.reply_to_post_number) post.reply_to_post_number = parentNumber;
- if (ctx.replyParentByPost) ctx.replyParentByPost.set(postNumber, parentNumber);
- let replies = ctx.directRepliesByParent.get(parentNumber);
- if (!replies) {
- replies = new Map();
- ctx.directRepliesByParent.set(parentNumber, replies);
- }
- replies.set(postNumber, post);
- return parentNumber;
- }
-
- function attachPostBatch(posts, ctx, options = {}) {
- const batch = Array.isArray(posts) ? posts : [];
- if (!batch.length) return [];
- ctx.tracker.preload(batch);
- const batchOptions = { ...options, deferStreamSync: true };
- const nodes = batch.map((post) => attachPost(post, ctx, batchOptions)).filter(Boolean);
- syncKnownBoostIdentities(ctx, batch.map((post) => post?.username));
- applyNestedReplyDisplayPreference(ctx);
- flushStreamPostBatch(
- ctx,
- options.syncStreamNow === true,
- options.preserveViewportAnchor === true,
- );
- return nodes;
- }
-
- function yieldReaderRenderTask() {
- if (isFunction(globalThis.scheduler?.yield)) return globalThis.scheduler.yield();
- return new Promise((resolve) => setTimeout(resolve, 0));
- }
-
- async function attachPostBatchCooperatively(posts, ctx, options = {}) {
- const batch = Array.isArray(posts) ? posts : [];
- if (!batch.length) return [];
- ctx.tracker.preload(batch);
- const acceptsWork = isFunction(options.acceptsWork) ? options.acceptsWork : () => true;
- const batchOptions = { ...options };
- delete batchOptions.acceptsWork;
- batchOptions.deferStreamSync = true;
- const nodes = [];
- const attachedPosts = [];
- let sliceStartedAt = performance.now();
- for (let index = 0; index < batch.length; index++) {
- if (!acceptsWork()) return nodes;
- const post = batch[index];
- const node = attachPost(post, ctx, batchOptions);
- if (node) nodes.push(node);
- attachedPosts.push(post);
- if ((index + 1) % 4 === 0 && performance.now() - sliceStartedAt >= 8) {
- await yieldReaderRenderTask();
- if (!acceptsWork()) return nodes;
- sliceStartedAt = performance.now();
- }
- }
- syncKnownBoostIdentities(ctx, attachedPosts.map((post) => post?.username));
- applyNestedReplyDisplayPreference(ctx);
- flushStreamPostBatch(
- ctx,
- options.syncStreamNow === true,
- options.preserveViewportAnchor === true,
- );
- return nodes;
- }
-
- function attachPost(p, ctx, options = {}) {
- const rememberedParentNum = rememberPostData(ctx, p);
- rememberLatestReplyTime(ctx, p);
- const fromNestedList = !!options.nestedList;
- const parentNum = +(p.reply_to_post_number || rememberedParentNum || 0);
- const isNestedReply = !ctx.isPostVoting && !!parentNum;
- const renderAsReply = isNestedReply || (fromNestedList && !!parentNum);
- const preferredParentNode = options.preferredParentNode;
- const preferredParentMatches = preferredParentNode?.isConnected &&
- +preferredParentNode.dataset.postNumber === +parentNum;
- const logicalParentNode = fromNestedList && parentNum
- ? (preferredParentMatches ? preferredParentNode : findReplyListParent(parentNum, ctx))
- : null;
- // A nested-list request always belongs to the requested post. Do not walk
- // that post's own parent chain: A -> B -> A conversations would otherwise
- // be reattached to an ancestor and create a visual reply cycle.
- const parentNode = fromNestedList ? logicalParentNode : null;
- if (fromNestedList) {
- const parentChildren = parentNode?.querySelector(':scope > .ldp-children');
- const existingNode = parentChildren?.querySelector(
- `:scope > .ldp-post[data-post-number="${+p.post_number}"]`
- );
- if (existingNode) return existingNode;
- const node = renderPost(p, renderAsReply, ctx, {
- forceExpanded: true,
- nestedPreview: true,
- showParentJump: false,
- });
- if (parentNode) {
- if (!options.deferNestedSync) parentChildren.appendChild(node);
- bindPostMediaLayoutSync(node, ctx);
- const streamNode = ctx.streamNodeMap.get(p.post_number);
- if (streamNode) {
- const streamIsNestedReply = +(p.reply_to_post_number || 0) > 0;
- const keepJumpTargetExpanded = streamNode.dataset.ldpJumpExpanded === '1';
- if (streamIsNestedReply) {
- ensureStreamNestedReplyPresentation(streamNode, p.reply_to_post_number);
- }
- if (streamNode._ldpPostData?.hidden) {
- setNestedReplyCollapsed(streamNode, true);
- } else if (!streamIsNestedReply || p.reply_count > 0) {
- setNestedReplyCollapsed(streamNode, false);
- if (p.reply_count > 0) {
- ctx.repliesIO.observe(streamNode);
- }
- } else {
- setNestedReplyCollapsed(streamNode, !keepJumpTargetExpanded);
- }
- }
- if (!options.deferNestedSync) syncChildControls(parentNode);
- }
- if (parentNode) ctx.tracker.observe(node);
- return node;
- }
-
- if (ctx.streamNodeMap.has(+p.post_number)) return ctx.streamNodeMap.get(+p.post_number) || null;
-
- const hiddenStreamPost = !!p.hidden;
- const preferenceHidden = isNestedReply && parentNum > 0;
- const collapsedStreamReply = hiddenStreamPost || !!(isNestedReply && !(p.reply_count > 0) &&
- PREFS.expandLeafNestedReplies !== true);
- const node = renderPost(p, renderAsReply, ctx, {
- forceCollapsed: collapsedStreamReply,
- showParentJump: isNestedReply,
- deferContent: true,
- });
- node.hidden = preferenceHidden;
- insertStreamPost(ctx, node, p.post_number, {
- deferSync: !!options.deferStreamSync,
- zebraExcluded: hiddenStreamPost || collapsedStreamReply || preferenceHidden,
- });
- bindPostMediaLayoutSync(node, ctx);
- ctx.streamNodeMap.set(+p.post_number, node);
- if (preferenceHidden) ctx.streamItemMap.get(+p.post_number).height = 0;
- ctx.tracker.observe(node);
- if (ctx.queueViewportTracker) ctx.queueViewportTracker.observe(node);
- if (+p.post_number === 1) syncCommentsHeaderPlacement(ctx);
- return node;
- }
-
- let threadGroupGradientSerial = 0;
-
- function findParentPreviewLine(post) {
- const line = post?.previousElementSibling;
- return line && line.classList.contains('ldp-floor-preview-link') ? line : null;
- }
-
- function clearCombinedParentPreviewLine(post) {
- const line = findParentPreviewLine(post);
- if (line) {
- line.classList.remove('ldp-floor-preview-link-grouped');
- line.querySelector(':scope > .ldp-thread-group-svg')?.remove();
- }
- if (post) {
- post.querySelector(':scope > .ldp-thread-group-svg')?.remove();
- post.classList.remove('ldp-parent-preview-linked');
- }
- }
-
- function syncGroupedParentPreviewLine(line, parent, posts) {
- const preview = line?.previousElementSibling;
- if (!line || !line.classList.contains('open') || !preview || !preview.classList.contains('ldp-floor-preview')) return false;
- const parentAvatar = parent?.querySelector(':scope > .ldp-post-head .ldp-avatar');
- const childAvatars = posts.map((post) => post.querySelector(':scope > .ldp-post-head .ldp-avatar'));
- const previewAvatar = preview.querySelector(':scope > .ldp-post-head .ldp-avatar');
- if (!parentAvatar || !previewAvatar || childAvatars.some((avatar) => !avatar)) return false;
- const layerRect = parent.getBoundingClientRect();
- const previewRect = preview.getBoundingClientRect();
- const previewAvatarRect = previewAvatar.getBoundingClientRect();
- const points = [{
- x: previewRect.left - layerRect.left,
- y: previewAvatarRect.top + previewAvatarRect.height / 2 - layerRect.top,
- }];
- [parentAvatar, ...childAvatars].forEach((avatar) => {
- const rect = avatar.getBoundingClientRect();
- points.push({
- x: rect.left - layerRect.left,
- y: rect.top + rect.height / 2 - layerRect.top,
- });
- });
- if (points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y))) return false;
- const lineStyle = getComputedStyle(line);
- const radius = normalizeReplyLineRadius(parseFloat(lineStyle.getPropertyValue('--ldp-reply-line-radius')));
- const lineWidth = normalizeLineWidth(parseFloat(lineStyle.getPropertyValue('--ldp-reply-line-width')), REPLY_LINE_WIDTH_DEFAULT);
- const linePadding = Math.ceil(lineWidth / 2) + 1;
- const branchGap = 5;
- const axisX = Math.min(...points.map((point) => point.x)) - radius;
- const minX = Math.floor(axisX - linePadding);
- const maxX = Math.ceil(Math.max(...points.map((point) => point.x)) + linePadding);
- const minY = Math.floor(points[0].y - linePadding);
- const maxY = Math.ceil(points[points.length - 1].y + linePadding);
- const width = Math.max(1, maxX - minX);
- const height = Math.max(1, maxY - minY);
- const localX = (value) => Math.round(value - minX);
- const localY = (value) => Math.round(value - minY);
- const trunkX = localX(axisX);
- const topY = localY(points[0].y);
- const bottomY = localY(points[points.length - 1].y - radius);
- const curveEndX = localX(axisX + radius);
- const topCurveStartY = localY(points[0].y + radius);
- const solidPathParts = [
- `M ${trunkX} ${topCurveStartY} V ${bottomY}`,
- `M ${trunkX} ${topCurveStartY} Q ${trunkX} ${topY} ${curveEndX} ${topY}`,
- ];
- const topBranchEndX = localX(points[0].x - branchGap);
- const branchPathParts = [{
- d: `M ${curveEndX} ${topY} H ${topBranchEndX}`,
- reverse: topBranchEndX < curveEndX,
- startX: curveEndX,
- endX: topBranchEndX,
- y: topY,
- }];
- points.slice(1).forEach((point) => {
- const y = localY(point.y);
- solidPathParts.push(`M ${trunkX} ${localY(point.y - radius)} Q ${trunkX} ${y} ${curveEndX} ${y}`);
- const branchEndX = localX(point.x - branchGap);
- branchPathParts.push({
- d: `M ${curveEndX} ${y} H ${branchEndX}`,
- reverse: branchEndX < curveEndX,
- startX: curveEndX,
- endX: branchEndX,
- y,
- });
- });
- const pathParts = [{ d: solidPathParts.join(' '), branch: false, reverse: false },
- ...branchPathParts.map((part) => ({ ...part, branch: true }))];
- let svg = parent.querySelector(':scope > .ldp-thread-group-svg');
- if (!svg) {
- svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.classList.add('ldp-thread-group-svg');
- svg.setAttribute('aria-hidden', 'true');
- parent.appendChild(svg);
- }
- svg.style.left = `${minX}px`;
- svg.style.top = `${minY}px`;
- svg.style.width = `${width}px`;
- svg.style.height = `${height}px`;
- svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
- let defs = svg.querySelector(':scope > .ldp-thread-group-defs');
- if (!defs) {
- defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
- defs.classList.add('ldp-thread-group-defs');
- svg.insertBefore(defs, svg.firstChild);
- }
- const gradients = [...defs.querySelectorAll(':scope > .ldp-thread-group-gradient')];
- while (gradients.length < branchPathParts.length) {
- const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
- gradient.classList.add('ldp-thread-group-gradient');
- gradient.id = `ldp-thread-group-gradient-${++threadGroupGradientSerial}`;
- gradient.setAttribute('gradientUnits', 'userSpaceOnUse');
- [0, 1, 1].forEach((offset, index) => {
- const stop = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
- stop.setAttribute('offset', String(offset));
- stop.setAttribute('stop-color', 'currentColor');
- stop.setAttribute('stop-opacity', index === 2 ? '0' : '1');
- gradient.appendChild(stop);
- });
- defs.appendChild(gradient);
- gradients.push(gradient);
- }
- while (gradients.length > branchPathParts.length) gradients.pop().remove();
- const paths = [...svg.querySelectorAll(':scope > .ldp-thread-group-path')];
- while (paths.length < pathParts.length) {
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- path.classList.add('ldp-thread-group-path');
- svg.appendChild(path);
- paths.push(path);
- }
- while (paths.length > pathParts.length) paths.pop().remove();
- let branchGradientIndex = 0;
- paths.forEach((path, index) => {
- const part = pathParts[index];
- path.classList.toggle('ldp-thread-group-branch', part.branch);
- path.classList.toggle('ldp-thread-group-branch-reverse', part.reverse);
- path.setAttribute('d', part.d);
- if (!part.branch) {
- path.style.removeProperty('stroke');
- return;
- }
- const gradient = gradients[branchGradientIndex++];
- const branchLength = Math.max(1, Math.abs(part.endX - part.startX));
- const fadeStart = Math.max(0, 1 - 12 / branchLength);
- gradient.setAttribute('x1', String(part.startX));
- gradient.setAttribute('x2', String(part.endX));
- gradient.setAttribute('y1', String(part.y));
- gradient.setAttribute('y2', String(part.y));
- gradient.children[1].setAttribute('offset', String(fadeStart));
- path.style.stroke = `url(#${gradient.id})`;
- });
- line.classList.add('ldp-floor-preview-link-grouped');
- parent.classList.add('ldp-parent-preview-linked');
- return true;
- }
-
- function syncNestedReplyBranches(children) {
- const posts = [...children.children].filter((node) => node.classList.contains('ldp-post'));
- const parent = children.closest('.ldp-post');
- const parentAvatar = parent?.querySelector(':scope > .ldp-post-head .ldp-avatar');
- const railToggle = children.querySelector(':scope > .ldp-nested-rail-toggle');
- const branchToggle = children.querySelector(':scope > .ldp-nested-branch-toggle');
- if (!parent || !parentAvatar || !posts.length || !children.isConnected) {
- if (parent) {
- parent.classList.remove('ldp-nested-branches-ready');
- clearCombinedParentPreviewLine(parent);
- }
- return;
- }
- const branchGap = 5;
- let branchAxisX = null;
- let branchMetrics = [];
- let railTop = 0;
- let branchCanvasWidth = 0;
- let parentBranchWidth = 0;
- if (parentAvatar && posts.length) {
- const childrenRect = children.getBoundingClientRect();
- const avatarRect = parentAvatar.getBoundingClientRect();
- if (!childrenRect.width || !avatarRect.width || !avatarRect.height) {
- parent.classList.remove('ldp-nested-branches-ready');
- clearCombinedParentPreviewLine(parent);
- return;
- }
- let axisX = Math.round(avatarRect.left - childrenRect.left - 15);
- let dropAnchorY = avatarRect.top + avatarRect.height / 2;
- const parentLine = findParentPreviewLine(parent);
- const parentAxisX = parentLine && parseFloat(parentLine.style.getPropertyValue('--ldp-parent-axis-left'));
- const parentPreviewLinked = !!parentLine && parentLine.classList.contains('open') && Number.isFinite(parentAxisX);
- if (parentPreviewLinked) {
- axisX = Math.round(parentLine.getBoundingClientRect().left + parentAxisX - childrenRect.left);
- dropAnchorY = avatarRect.top + avatarRect.height / 2;
- }
- const firstChildAvatar = posts[0]?.querySelector(':scope > .ldp-post-head .ldp-avatar');
- const firstChildX = firstChildAvatar ? Math.round(firstChildAvatar.getBoundingClientRect().left - childrenRect.left) : 18;
- const firstBranchWidth = Math.max(0, firstChildX - axisX - branchGap);
- const parentHeadWidth = Math.max(0, avatarRect.left - childrenRect.left - axisX - branchGap);
- branchCanvasWidth = Math.max(1, Math.ceil(childrenRect.width));
- parentBranchWidth = parentHeadWidth;
- const drop = Math.max(0, Math.round(childrenRect.top - dropAnchorY));
- railTop = -drop;
- branchAxisX = axisX;
- branchMetrics = posts.map((post) => {
- const childAvatar = post.querySelector(':scope > .ldp-post-head .ldp-avatar');
- if (!childAvatar) return null;
- const childAvatarRect = childAvatar.getBoundingClientRect();
- const postRect = post.getBoundingClientRect();
- return {
- branchY: Math.max(0, Math.round(childAvatarRect.top + childAvatarRect.height / 2 - postRect.top)),
- branchWidth: Math.max(0, Math.round(childAvatarRect.left - childrenRect.left) - branchAxisX - branchGap),
- railY: Math.max(0, Math.round(childAvatarRect.top + childAvatarRect.height / 2 - childrenRect.top)),
- };
- });
- const groupedReady = parentPreviewLinked && branchMetrics.length === posts.length && branchMetrics.every(Boolean) &&
- syncGroupedParentPreviewLine(parentLine, parent, posts);
- if (!groupedReady) clearCombinedParentPreviewLine(parent);
- children.style.setProperty('--ldp-nested-axis-left', `${axisX}px`);
- children.style.setProperty('--ldp-nested-branch-width', `${firstBranchWidth}px`);
- children.style.setProperty('--ldp-nested-parent-drop', `${drop}px`);
- children.style.setProperty('--ldp-nested-parent-head-width', `${parentHeadWidth}px`);
- } else {
- children.style.removeProperty('--ldp-nested-axis-left');
- children.style.removeProperty('--ldp-nested-branch-width');
- children.style.removeProperty('--ldp-nested-parent-drop');
- children.style.removeProperty('--ldp-nested-parent-head-width');
- }
- const firstBranchMetric = branchMetrics.find(Boolean);
- const lastBranchMetric = branchMetrics[branchMetrics.length - 1];
- if (railToggle && branchAxisX !== null && firstBranchMetric && lastBranchMetric) {
- railToggle.style.left = `${branchAxisX - 10}px`;
- railToggle.style.top = `${railTop}px`;
- railToggle.style.height = `${Math.max(24, lastBranchMetric.railY - 15 - railTop)}px`;
- } else if (railToggle) {
- railToggle.style.removeProperty('left');
- railToggle.style.removeProperty('top');
- railToggle.style.removeProperty('height');
- }
- if (branchToggle && branchAxisX !== null && lastBranchMetric && branchCanvasWidth) {
- const radius = normalizeReplyLineRadius(
- parseFloat(getComputedStyle(children).getPropertyValue('--ldp-reply-line-radius'))
- );
- const branchBottom = Math.max(railTop + 1, lastBranchMetric.railY + 10);
- const branchHeight = branchBottom - railTop;
- const pathParts = [
- `M ${branchAxisX} ${railTop} H ${branchAxisX + parentBranchWidth}`,
- `M ${branchAxisX} ${railTop} V ${Math.max(railTop, lastBranchMetric.railY - radius)}`,
- ];
- branchMetrics.forEach((metric) => {
- if (!metric) return;
- pathParts.push(`M ${branchAxisX} ${metric.railY - radius} Q ${branchAxisX} ${metric.railY} ${branchAxisX + radius} ${metric.railY} H ${branchAxisX + metric.branchWidth}`);
- });
- branchToggle.style.top = `${railTop}px`;
- branchToggle.style.width = `${branchCanvasWidth}px`;
- branchToggle.style.height = `${branchHeight}px`;
- branchToggle.setAttribute('viewBox', `0 ${railTop} ${branchCanvasWidth} ${branchHeight}`);
- branchToggle.querySelector(':scope > .ldp-nested-branch-hit-path')?.setAttribute('d', pathParts.join(' '));
- } else if (branchToggle) {
- branchToggle.removeAttribute('viewBox');
- branchToggle.style.removeProperty('top');
- branchToggle.style.removeProperty('width');
- branchToggle.style.removeProperty('height');
- branchToggle.querySelector(':scope > .ldp-nested-branch-hit-path')?.removeAttribute('d');
- }
- posts.forEach((post, index) => {
- if (branchAxisX === null) {
- post.style.removeProperty('--ldp-nested-branch-y');
- post.style.removeProperty('--ldp-nested-branch-width');
- } else {
- const metric = branchMetrics[index];
- if (metric) {
- post.style.setProperty('--ldp-nested-branch-y', `${metric.branchY}px`);
- post.style.setProperty('--ldp-nested-branch-width', `${metric.branchWidth}px`);
- } else {
- post.style.removeProperty('--ldp-nested-branch-y');
- post.style.removeProperty('--ldp-nested-branch-width');
- }
- }
- post.classList.toggle('ldp-nested-branch-last', index === posts.length - 1);
- post.classList.toggle('ldp-nested-branch-single', posts.length === 1);
- });
- parent.classList.toggle('ldp-nested-branches-ready', branchMetrics.length === posts.length && branchMetrics.every(Boolean));
- }
-
- const nestedBranchSyncFrames = new WeakMap();
-
- function scheduleNestedBranchSync(post, ctx) {
- if (!post || !post.isConnected) return;
- const parentNum = +(post.dataset.replyToPostNumber || 0);
- const parent = parentNum && ctx && ctx.streamNodeMap && ctx.streamNodeMap.get(parentNum);
- if (nestedBranchSyncFrames.has(post)) return;
- const run = () => {
- if (!post.isConnected) {
- nestedBranchSyncFrames.delete(post);
- return;
- }
- if (streamScrollIsActive(ctx, 100)) {
- nestedBranchSyncFrames.set(post, setTimeout(run, 110));
- return;
- }
- nestedBranchSyncFrames.delete(post);
- syncChildControls(post);
- if (parent) syncChildControls(parent);
- };
- const handle = streamScrollIsActive(ctx, 100)
- ? setTimeout(run, 110)
- : requestAnimationFrame(run);
- nestedBranchSyncFrames.set(post, handle);
- }
-
- function retryableReaderImageUrl(source) {
- try {
- const url = new URL(String(source || ''), location.href);
- url.searchParams.set('_ldp_retry', String(Date.now()));
- return url.href;
- } catch {
- return String(source || '');
- }
- }
-
- function showReaderImageRetry(img, post, ctx) {
- if (!img || img.classList.contains('emoji')) return;
- let button = img._ldpImageRetryButton;
- if (!button || !button.isConnected) {
- button = makeElement('button');
- button.type = 'button';
- button.className = 'ldp-image-retry';
- button.innerHTML = `${icon('rotateCcw')}
重试图片`;
- button.addEventListener('click', (event) => {
- consumeEvent(event);
- if (button.disabled) return;
- const source = String(img.dataset.ldpRetrySource || img.currentSrc || img.src || '');
- if (!source) return;
- button.disabled = true;
- button.setAttribute('aria-busy', 'true');
- button.querySelector('span').textContent = '正在重试…';
- const retryUrl = retryableReaderImageUrl(source);
- img.loading = 'eager';
- img.srcset = retryUrl;
- img.src = retryUrl;
- scheduleNestedBranchSync(post, ctx);
- });
- const link = img.closest('a');
- if (link && post.contains(link)) link.insertAdjacentElement('afterend', button);
- else img.insertAdjacentElement('afterend', button);
- img._ldpImageRetryButton = button;
- }
- button.disabled = false;
- button.setAttribute('aria-busy', 'false');
- button.querySelector('span').textContent = '重试图片';
- }
-
- function clearReaderImageRetry(img) {
- const button = img?._ldpImageRetryButton;
- if (button) button.remove();
- if (img) img._ldpImageRetryButton = null;
- }
-
- function bindPostMediaLayoutSync(post, ctx) {
- const content = post.querySelector(':scope > .ldp-content');
- if (!content) return;
- content.querySelectorAll('iframe').forEach((frame) => {
- frame.loading = 'lazy';
- });
- content.querySelectorAll('video').forEach((video) => {
- if (!video.hasAttribute('preload')) video.preload = 'metadata';
- });
- activateReaderMedia(content);
- content.querySelectorAll('img').forEach((img) => {
- if (img.dataset.ldpLayoutSyncBound === '1') return;
- img.dataset.ldpLayoutSyncBound = '1';
- if (!img.classList.contains('emoji')) {
- img.loading = 'lazy';
- img.decoding = 'async';
- if (!img.dataset.ldpRetrySource) {
- img.dataset.ldpRetrySource = absoluteImageUrl(img.currentSrc || img.src || img.getAttribute('src') || '');
- }
- }
- const onLoad = () => {
- clearReaderImageRetry(img);
- scheduleNestedBranchSync(post, ctx);
- };
- const onError = () => {
- showReaderImageRetry(img, post, ctx);
- scheduleNestedBranchSync(post, ctx);
- };
- img.addEventListener('load', onLoad);
- img.addEventListener('error', onError);
- if (img.complete) {
- if (img.naturalWidth > 0) onLoad();
- else onError();
- }
- });
- }
-
- function syncChildControls(post) {
- const children = post?.querySelector(':scope > .ldp-children');
- const btn = post?.querySelector(':scope > .ldp-collapse-replies');
- if (!children || !btn) return;
- const collapsed = post.classList.contains('ldp-children-collapsed');
- let renderedCount = 0;
- for (const child of children.children) {
- if (child.classList.contains('ldp-post')) renderedCount++;
- }
- const totalCount = Math.max(+(post.dataset.childReplyTotal || 0), renderedCount);
- const railToggle = children.querySelector(':scope > .ldp-nested-rail-toggle');
- const branchToggle = children.querySelector(':scope > .ldp-nested-branch-toggle');
- if (railToggle) {
- const railLabel = `点击收起 ${totalCount} 条回复`;
- setLabel(railToggle, railLabel);
- if (branchToggle) branchToggle.dataset.ldpTooltipLabel = railLabel;
- }
- if (renderedCount && post.classList.contains('ldp-reply') && !post._ldpPostData?.hidden) {
- setNestedReplyCollapsed(post, false);
- }
- if (!totalCount) {
- btn.classList.remove('show');
- post.classList.remove('ldp-has-child-branches');
- post.classList.remove('ldp-nested-branches-ready');
- clearCombinedParentPreviewLine(post);
- delete btn.dataset.replyControlKey;
- return;
- }
- const showBranches = !!renderedCount && !collapsed;
- post.classList.toggle('ldp-has-child-branches', showBranches);
- if (!showBranches) {
- post.classList.remove('ldp-nested-branches-ready');
- clearCombinedParentPreviewLine(post);
- }
- btn.classList.add('show');
- const controlKey = `${collapsed ? 'collapsed' : 'open'}:${totalCount}`;
- if (btn.dataset.replyControlKey !== controlKey) {
- btn.dataset.replyControlKey = controlKey;
- setExpanded(btn, collapsed ? 'false' : 'true');
- btn.innerHTML = `${icon(collapsed ? 'chevronRight' : 'chevronDown')}
${collapsed ? `展开 ${totalCount} 条回复` : `收起 ${totalCount} 条回复`}`;
- }
- if (!collapsed) syncNestedReplyBranches(children);
- }
-
- function setChildRepliesCollapsed(post, collapsed) {
- if (!post) return;
- post.classList.toggle('ldp-children-collapsed', !!collapsed);
- syncChildControls(post);
- }
-
- /* ============ 7. 渲染单条 ============ */
- function childReplyShellHtml(total) {
- return `
-
-
-
-
-
-
-
-
-
-
-
正在加载二级回复…
`;
- }
-
- function readerPostIsRead(post, ctx) {
- if (!post) return false;
- const postNumber = +(post.post_number || 0);
- if (post.read === true || ctx?.confirmedReadPostNumbers?.has(postNumber) ||
- ctx?.optimisticReadPostNumbers?.has(postNumber)) return true;
- const lastReadPostNumber = +(ctx?.topicData && ctx.topicData.last_read_post_number || 0);
- return postNumber > 0 && lastReadPostNumber >= postNumber;
- }
-
- function postReadStateIconHtml(isRead) {
- return isRead
- ? icon('check', 'ldp-post-read-state-icon')
- : '
';
- }
-
- function readerPostNodeInViewport(postNode, scrollRoot) {
- if (!postNode || !postNode.isConnected || !postNode.getClientRects().length) return false;
- const rect = postNode.getBoundingClientRect();
- const viewport = scrollRoot?.getBoundingClientRect
- ? scrollRoot.getBoundingClientRect()
- : { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 };
- return rect.bottom > viewport.top && rect.top < viewport.bottom &&
- rect.right > viewport.left && rect.left < viewport.right;
- }
-
- function syncPostReadState(postNode, isRead, options = {}) {
- if (!postNode) return;
- const marker = postNode.querySelector(':scope > .ldp-post-head > .ldp-post-read-state');
- if (!marker) return;
- const state = isRead ? 'read' : 'unread';
- if (postNode._ldpPostData && options.updatePostData !== false)
- postNode._ldpPostData.read = !!isRead;
- if (marker.dataset.readState === state || marker.dataset.readTransitionState === state) return;
- const label = isRead ? '该楼层已读' : '该楼层未读';
- const cancelTransition = () => {
- if (marker._ldpReadStateSwapTimer) clearTimeout(marker._ldpReadStateSwapTimer);
- marker._ldpReadStateSwapTimer = 0;
- if (marker._ldpReadStateTimer) clearTimeout(marker._ldpReadStateTimer);
- marker._ldpReadStateTimer = 0;
- if (marker._ldpReadStateFinish) {
- marker.removeEventListener('animationend', marker._ldpReadStateFinish);
- }
- marker._ldpReadStateFinish = null;
- marker.classList.remove('is-confirming');
- delete marker.dataset.readTransitionState;
- };
- const applyVisual = () => {
- marker.dataset.readState = state;
- marker.classList.toggle('is-read', !!isRead);
- marker.classList.toggle('is-unread', !isRead);
- setLabel(marker, label);
- marker.dataset.ldpTooltipLabel = label;
- marker.removeAttribute('title');
- marker.innerHTML = postReadStateIconHtml(isRead);
- refreshTooltips(marker);
- };
- const applyState = () => {
- cancelTransition();
- if (marker.dataset.readState !== state) applyVisual();
- };
- const reduceMotion = prefersReducedMotion();
- if (options.animate === true && isRead && !reduceMotion) {
- marker.dataset.readTransitionState = state;
- marker.classList.add('is-confirming');
- const finish = () => {
- if (marker._ldpReadStateFinish !== finish) return;
- applyState();
- };
- marker._ldpReadStateFinish = finish;
- marker.addEventListener('animationend', finish, { once: true });
- marker._ldpReadStateSwapTimer = setTimeout(() => {
- if (marker._ldpReadStateFinish !== finish) return;
- marker._ldpReadStateSwapTimer = 0;
- applyVisual();
- }, 625);
- marker._ldpReadStateTimer = setTimeout(finish, 1450);
- return;
- }
- applyState();
- }
-
- function syncConfirmedPostReadStateWhenVisible(node, ctx, options = {}) {
- if (!node) return;
- const syncConfirmedState = () => syncPostReadState(node, true, {
- animate: true,
- updatePostData: options.updatePostData,
- });
- const descendantRoot = node.closest('.ldp-descendant-replies-list');
- if (readerPostNodeInViewport(node, descendantRoot || ctx?.scrollRoot)) {
- syncConfirmedState();
- } else if (descendantRoot) {
- syncPostReadState(node, true, { updatePostData: options.updatePostData });
- } else if (ctx?.tracker && isFunction(ctx.tracker.runWhenVisible)) {
- ctx.tracker.runWhenVisible(node, syncConfirmedState);
- } else {
- syncPostReadState(node, true, { updatePostData: options.updatePostData });
- }
- }
-
- function markReaderPostsRead(postNumbers, ctx, options = {}) {
- const readPostNumbers = new Set((postNumbers || []).map(Number).filter(Boolean));
- if (!readPostNumbers.size || !ctx) return;
- const optimistic = options.optimistic === true;
- if (!ctx.optimisticReadPostNumbers) ctx.optimisticReadPostNumbers = new Set();
- if (optimistic) {
- readPostNumbers.forEach((postNumber) => ctx.optimisticReadPostNumbers.add(postNumber));
- } else {
- if (!ctx.confirmedReadPostNumbers) ctx.confirmedReadPostNumbers = new Set();
- readPostNumbers.forEach((postNumber) => {
- ctx.confirmedReadPostNumbers.add(postNumber);
- ctx.optimisticReadPostNumbers.delete(postNumber);
- });
- }
- const persistedPosts = new Map();
- const syncPostData = (post) => {
- if (optimistic || !post || !readPostNumbers.has(+(post.post_number || 0))) return;
- post.read = true;
- if (post.id) persistedPosts.set(+post.id, post);
- };
- const syncNode = (node) => {
- const postNumber = +(node?.dataset && node.dataset.postNumber || 0);
- if (!readPostNumbers.has(postNumber)) return;
- const postData = node._ldpPostData;
- syncPostData(postData);
- syncConfirmedPostReadStateWhenVisible(node, ctx, { updatePostData: !optimistic });
- };
- if (ctx.modal) ctx.modal.querySelectorAll('.ldp-post').forEach(syncNode);
- if (ctx.streamNodeMap) {
- ctx.streamNodeMap.forEach((node) => {
- syncNode(node);
- if (node?.querySelectorAll) node.querySelectorAll('.ldp-post').forEach(syncNode);
- });
- }
- if (ctx.subReplyState) {
- ctx.subReplyState.forEach((state) => {
- if (state?.nodeCache) state.nodeCache.forEach(syncNode);
- if (state && Array.isArray(state.all)) state.all.forEach(syncPostData);
- });
- }
- if (ctx.directRepliesByParent) {
- ctx.directRepliesByParent.forEach((replies) => {
- if (replies) replies.forEach(syncPostData);
- });
- }
- postsFromPayload(ctx.topicData).forEach(syncPostData);
- if (!persistedPosts.size) return;
- if (isFunction(ctx.loader?.persistFetchedPosts)) {
- ctx.loader.persistFetchedPosts([...persistedPosts.values()]);
- } else if (ctx.topicData) {
- setCachedTopicData(ctx.topicId, ctx.topicData);
- }
- }
-
- function renderPost(p, isReply, ctx, options = {}) {
- const avatar = avatarWithFlairHtml(
- persistentAvatarHtml(
- p.avatar_template,
- 48,
- 'ldp-avatar',
- p.name || p.username || '?'
- ),
- p
- );
- const like = readerPostLikeAction(p, ctx);
- const count = like.count || 0;
- const acted = !!like.acted;
- const canAct = !!like.can_act;
- const isOP = ctx.op && p.username === ctx.op;
- const isME = ME_USERNAME && p.username === ME_USERNAME;
- const time = discourseRelativeTime(p.created_at);
- const exactTime = discourseExactTime(p.created_at);
- const profileUrl = userProfileUrl(p.username);
- const userCardAttr = `data-user-card="${escAttr(p.username)}"`;
- const isTopicStarter = !isReply && !options.nestedPreview && +p.post_number === 1;
- const hiddenCollapsible = !!p.hidden && !options.nestedPreview;
- const deferContent = options.deferContent === true;
- const boostListHtml = deferContent
- ? (postHasBoostData(p.boosts)
- ? '
'
- : '
')
- : renderBoostList(p.boosts, { canQuickBoost: canShowBoostButton(p), ctx });
- const contentHtml = deferContent
- ? `
`
- : '
';
- const replyToPostNumber = +(p.reply_to_post_number || 0);
- const parentFloorHtml = options.showParentJump && isReply && replyToPostNumber
- ? `
`
- : '';
- const currentFloorHtml = options.nestedPreview
- ? `
`
- : `
#${p.post_number}`;
- const deferConfirmedReadAnimation = !!(ctx?.confirmedReadPostNumbers &&
- ctx.confirmedReadPostNumbers.has(+(p.post_number || 0)));
- const isRead = deferConfirmedReadAnimation ? false : readerPostIsRead(p, ctx);
- const readState = isRead ? 'read' : 'unread';
- const readStateLabel = isRead ? '该楼层已读' : '该楼层未读';
- const readStateHtml = `
${postReadStateIconHtml(isRead)}`;
- const collapseNested = !options.forceExpanded &&
- (hiddenCollapsible || !!options.forceCollapsed);
- const allowChildReplies = !ctx.isPostVoting && !options.nestedPreview && p.reply_count > 0;
- const childRepliesHtml = allowChildReplies ? childReplyShellHtml(p.reply_count) : '';
-
- const node = makeElement('div');
- node.className = 'ldp-post' + (isReply ? ' ldp-reply ldp-reply-collapsible' : '') +
- (hiddenCollapsible ? ' ldp-hidden-collapsible' : '') +
- (options.nestedPreview ? ' ldp-nested-preview' : '') +
- (collapseNested ? ' ldp-nested-collapsed' : '') +
- (allowChildReplies && (isTopicStarter || PREFS.expandNestedRepliesByDefault !== true)
- ? ' ldp-children-collapsed'
- : '');
- node.dataset.postId = p.id;
- node.dataset.postNumber = p.post_number;
- node.dataset.username = p.username || '';
- node.dataset.createdAt = p.created_at || '';
- node.dataset.ldpContentHydrated = deferContent ? '0' : '1';
- node._ldpPostData = p;
- node._ldpReaderContext = ctx;
- if (replyToPostNumber) node.dataset.replyToPostNumber = replyToPostNumber;
- if (allowChildReplies) node.dataset.childReplyTotal = String(p.reply_count);
- node.innerHTML = `
- ${isReply || hiddenCollapsible ? '
' : ''}
-
- ${avatar ? `
` : ''}
-
${esc(p.name || p.username)}
- ${isME ? '
ME' : ''}
-
@${esc(p.username)}
- ${isOP ? '
OP' : ''}
- ${time ? `
· ${esc(time)}` : ''}
- ${p.hidden ? '
已隐藏' : ''}
- ${parentFloorHtml}${currentFloorHtml}${readStateHtml}
-
- ${contentHtml}
-
-
-
- ${compactPostActionsHtml(p, ctx)}
-
-
- ${boostListHtml}
- ${childRepliesHtml}
- ${isTopicStarter ? topicFooterActionsHtml(p, ctx) : ''}
- `;
- if (deferConfirmedReadAnimation) syncConfirmedPostReadStateWhenVisible(node, ctx);
- if (!deferContent) {
- const content = node.querySelector(':scope > .ldp-content');
- if (content) setPostCookedContent(content, p.cooked, p.link_counts);
- renderPostPolls(node, ctx);
- renderPostSpecialContent(node, ctx);
- }
- prepareReaderMedia(node);
- hydratePersistentAvatarImages(node);
- if (isTopicStarter) { ctx.syncTopicActionRail?.(node); syncTopicActionControls(ctx); }
- syncPostActionControls(node, ctx);
- if (isReply || hiddenCollapsible) setNestedReplyCollapsed(node, collapseNested);
- ctx?.translation?.syncPost(node);
- return node;
- }
-
- /* ============ 8. 回复框 ============ */
- function lookupDiscourseModule(name) {
- if (DISCOURSE_MODULE_CACHE.has(name)) return DISCOURSE_MODULE_CACHE.get(name);
- const hostWindow = HOST_PAGE_WINDOW || window;
- const broker = hostWindow.moduleBroker;
- const resolvers = [
- () => broker && isFunction(broker.lookup) ? broker.lookup(name, true) : null,
- () => isFunction(hostWindow.require) ? hostWindow.require(name) : null,
- () => isFunction(hostWindow.requirejs) ? hostWindow.requirejs(name) : null,
- ];
- for (const resolve of resolvers) {
- try {
- const module = resolve();
- if (module && Object.keys(module).length) {
- DISCOURSE_MODULE_CACHE.set(name, module);
- return module;
- }
- } catch {}
- }
- return null;
- }
-
- function lookupDiscourse(name) {
- if (DISCOURSE_LOOKUP_CACHE.has(name)) return DISCOURSE_LOOKUP_CACHE.get(name);
- const discourseUrlModule = lookupDiscourseModule('discourse/lib/url');
- const discourseUrl = discourseUrlModule?.default;
- const hostWindow = HOST_PAGE_WINDOW || window;
- const container = discourseUrl?.container ||
- hostWindow.Discourse?.__container__ ||
- hostWindow.Discourse?.container;
- if (!container || !isFunction(container.lookup)) return null;
- try {
- const item = container.lookup(name) || null;
- if (item) DISCOURSE_LOOKUP_CACHE.set(name, item);
- return item;
- } catch {
- return null;
- }
- }
-
- function guardNativeReaderTimings() {
- const hostDocument = HOST_PAGE_WINDOW?.document;
- if (!hostDocument) return false;
- try {
- HOST_PAGE_WINDOW.__ldpReaderTimingActive = !!CURRENT_OVERLAY;
- if (!CURRENT_OVERLAY || HOST_PAGE_WINDOW.__ldpReaderTimingGuardReady ||
- HOST_PAGE_WINDOW.__ldpReaderTimingInstallTimer) return true;
- const script = hostDocument.createElement('script');
- const nonce = hostDocument.querySelector('script[nonce]')?.nonce;
- if (nonce) script.nonce = nonce;
- script.textContent = `(() => {
- const install = () => {
- const req = window.requirejs || window.require;
- const url = req?.('discourse/lib/url')?.default;
- const container = url?.container || window.Discourse?.__container__ || window.Discourse?.container;
- const screenTrack = container?.lookup?.('service:screen-track');
- const guard = Symbol.for('ldp.reader.screen-track.guard');
- if (!screenTrack || screenTrack[guard]) return !!screenTrack;
- const nativeFlush = screenTrack.flush;
- const nativeSend = screenTrack.sendNextConsolidatedTiming;
- if (typeof nativeFlush !== 'function' || typeof nativeSend !== 'function') return false;
- screenTrack[guard] = true;
- screenTrack.flush = function (...args) {
- if (!window.__ldpReaderTimingActive) return nativeFlush.apply(this, args);
- this._timings?.forEach((_timing, postNumber) => this._timings.set(postNumber, 0));
- this._topicTime = 0;
- this._lastFlush = 0;
- };
- screenTrack.sendNextConsolidatedTiming = function (...args) {
- return window.__ldpReaderTimingActive ? Promise.resolve() : nativeSend.apply(this, args);
- };
- if (Array.isArray(screenTrack._consolidatedTimings)) screenTrack._consolidatedTimings.length = 0;
- return true;
- };
- if (install()) {
- window.__ldpReaderTimingGuardReady = true;
- return;
- }
- if (!window.__ldpReaderTimingInstallTimer) window.__ldpReaderTimingInstallTimer = setInterval(() => {
- if (window.__ldpReaderTimingActive && !install()) return;
- window.__ldpReaderTimingGuardReady = window.__ldpReaderTimingActive;
- clearInterval(window.__ldpReaderTimingInstallTimer);
- window.__ldpReaderTimingInstallTimer = 0;
- }, 250);
- })();`;
- (hostDocument.head || hostDocument.documentElement).appendChild(script);
- script.remove();
- return !!(HOST_PAGE_WINDOW.__ldpReaderTimingGuardReady ||
- HOST_PAGE_WINDOW.__ldpReaderTimingInstallTimer);
- } catch {
- return false;
- }
- }
-
- const DISCOURSE_CAPABILITY_MODULES = Object.freeze({
- boosts: 'discourse/plugins/discourse-boosts/discourse/lib/create-boost',
- reactions: 'discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction',
- postVoting: 'discourse/plugins/discourse-post-voting/discourse/lib/post-voting-utilities',
- });
- const DISCOURSE_CAPABILITY_SITE_SETTINGS = Object.freeze({
- boosts: Object.freeze(['discourse_boosts_enabled']),
- reactions: Object.freeze(['discourse_reactions_enabled']),
- postVoting: Object.freeze(['post_voting_enabled']),
- topicVoting: Object.freeze(['voting_enabled']),
- });
- const DISCOURSE_CAPABILITY_FIELDS = Object.freeze({
- boosts: Object.freeze({
- post: Object.freeze(['can_boost', 'boosts']),
- }),
- reactions: Object.freeze({
- post: Object.freeze(['reactions', 'current_user_reaction', 'reaction_users_count']),
- topic: Object.freeze(['valid_reactions']),
- }),
- postVoting: Object.freeze({
- post: Object.freeze([
- 'post_voting_vote_count',
- 'post_voting_user_voted_direction',
- 'post_voting_has_votes',
- 'comments_count',
- 'comments',
- ]),
- topic: Object.freeze(['is_post_voting']),
- }),
- topicVoting: Object.freeze({
- topic: Object.freeze(['can_vote', 'user_voted', 'vote_count']),
- }),
- categoryExperts: Object.freeze({
- user: Object.freeze(['category_expert_endorsements']),
- }),
- follows: Object.freeze({
- user: Object.freeze([
- 'can_follow',
- 'is_followed',
- 'total_following',
- 'total_followers',
- 'following_count',
- 'follower_count',
- ]),
- }),
- sharedIssue: Object.freeze({
- topic: Object.freeze([
- 'shared_issue_visible',
- 'shared_issue_count',
- 'user_created_shared_issue',
- ]),
- }),
- });
- const DISCOURSE_CAPABILITY_NAMES = Object.freeze([
- 'boosts',
- 'reactions',
- 'postVoting',
- 'topicVoting',
- 'categoryExperts',
- 'follows',
- 'sharedIssue',
- 'connect',
- ]);
-
- function objectHasAnyOwnField(value, fields) {
- return !!value && Array.isArray(fields) && fields.some((field) => Object.hasOwn(value, field));
- }
-
- function hasDiscourseCapability(name, context = {}) {
- const override = SITE_ADAPTER.capabilities?.[name];
- if (override === true || override === false) return override;
- const fields = DISCOURSE_CAPABILITY_FIELDS[name] || {};
- if (objectHasAnyOwnField(context.post, fields.post) ||
- objectHasAnyOwnField(context.topic, fields.topic) ||
- objectHasAnyOwnField(context.user, fields.user))
- return true;
- const siteSettings = lookupDiscourse('service:site-settings');
- const settingFields = DISCOURSE_CAPABILITY_SITE_SETTINGS[name] || [];
- const declaredSettings = settingFields.filter((field) => Object.hasOwn(siteSettings || {}, field));
- if (declaredSettings.length)
- return declaredSettings.some((field) => siteSettings[field] === true);
- const moduleName = DISCOURSE_CAPABILITY_MODULES[name];
- return !!(moduleName && lookupDiscourseModule(moduleName));
- }
-
- function isDiscourseHostDetected() {
- if (lookupDiscourseModule('discourse/lib/url')) return true;
- if (document.querySelector('meta[name="generator"][content*="Discourse" i],meta[name="discourse_theme_id"],#data-preloaded'))
- return true;
- return !!document.querySelector('#ember-app .d-header,#ember-app .topic-list,#ember-app .topic-post');
- }
-
- function waitForDiscourseHost(timeout = 15000) {
- if (isDiscourseHostDetected()) return Promise.resolve(true);
- return new Promise((resolve) => {
- let settled = false;
- let observer = null;
- let timer = 0;
- const finish = (detected) => {
- if (settled) return;
- settled = true;
- if (timer) clearTimeout(timer);
- observer?.disconnect();
- window.removeEventListener('load', check);
- resolve(detected);
- };
- const check = () => {
- if (isDiscourseHostDetected()) finish(true);
- };
- observer = new MutationObserver(check);
- observer.observe(document.documentElement, { childList: true, subtree: true });
- window.addEventListener('load', check);
- timer = setTimeout(() => finish(isDiscourseHostDetected()), timeout);
- });
- }
-
- function updateDiscourseCapabilityDiagnostics(context = {}) {
- const generator = document.querySelector('meta[name="generator"]')?.content || '';
- const version = String(generator).match(/Discourse\s+([^\s]+)/i)?.[1] || '';
- PAGE_ROOT.dataset.ldpSiteAdapter = SITE_ADAPTER.id;
- if (version) PAGE_ROOT.dataset.ldpDiscourseVersion = version;
- PAGE_ROOT.dataset.ldpCapabilities = DISCOURSE_CAPABILITY_NAMES
- .filter((name) => hasDiscourseCapability(name, context))
- .join(',');
- }
-
- function navigateDiscourseHostRoute(href) {
- let route;
- try {
- const url = new URL(String(href || ''), location.href);
- if (url.origin !== location.origin) return false;
- route = `${url.pathname}${url.search}${url.hash}`;
- } catch {
- return false;
- }
- const discourseUrlModule = lookupDiscourseModule('discourse/lib/url');
- const discourseUrl = discourseUrlModule?.default;
- if (!discourseUrl || !isFunction(discourseUrl.routeTo)) return false;
- try {
- const transition = discourseUrl.routeTo(route);
- if (isFunction(transition?.catch)) transition.catch(() => {});
- return true;
- } catch {
- return false;
- }
- }
-
- function openEmbeddedReaderLinkInHost(event, link, readerWorkspace) {
- if (!link || !readerWorkspace || !readerWorkspace.isEmbedded() || link.hasAttribute('download') || link.matches('a.lightbox,.ldp-open') ||
- event.button && event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false;
- let url;
- try {
- url = new URL(link.getAttribute('href') || link.href || '', location.href);
- } catch {
- return false;
- }
- if (!/^https?:$/i.test(url.protocol) || url.origin !== location.origin) return false;
- url.searchParams.delete(NATIVE_BYPASS_PARAM);
- const topicRoute = extractTopicRouteFromUrl(url.href);
- if (topicRoute) return false;
- const previousSuppressedTopicId = AUTO_OPEN_SUPPRESSED_TOPIC_ID;
- consumeEvent(event);
- if (navigateDiscourseHostRoute(url.href)) return true;
- AUTO_OPEN_SUPPRESSED_TOPIC_ID = previousSuppressedTopicId;
- showSelectionToast('LINUX DO 站内导航暂不可用,请稍后重试');
- return true;
- }
-
- function refreshDiscourseHostRoute() {
- const router = lookupDiscourse('service:router');
- if (!router || !isFunction(router.refresh)) return false;
- try {
- const transition = router.refresh();
- if (isFunction(transition?.catch)) {
- transition.catch(() => {});
- }
- return true;
- } catch {
- return false;
- }
- }
-
- function getUserCardUsername(link) {
- const username = link && (link.dataset.userCard || link.getAttribute('data-user-card'));
- return cleanUsernameValue(username);
- }
-
- async function openDirectPrivateMessage(username, ctx) {
- const href = `/new-message?username=${encodeURIComponent(username)}`;
- if (!navigateDiscourseHostRoute(href)) return false;
- if (!await waitForNativeComposerPopup(ctx)) return false;
- return !!(ctx?.nativeComposerWindow
- && ctx.nativeComposerWindow.open(document.querySelector('#reply-control')));
- }
-
- async function openReaderEndorsementDialog(ctx, user) {
- const username = String(user?.username || '').trim();
- if (!ctx || !username || !hasDiscourseCapability('categoryExperts', { user })) return false;
- const data = await fetchJSON(`${BASE}/category-experts/endorsable-categories/${encodeURIComponent(username)}.json`, {
- key: `user-endorsements:${username}`,
- priority: POST_REQUEST_PRIORITY.userCard,
- });
- const categories = Array.isArray(data?.categories) ? data.categories : [];
- const remaining = Number(data?.extras && data.extras.remaining_endorsements);
- const existingIds = new Set((Array.isArray(user.category_expert_endorsements)
- ? user.category_expert_endorsements
- : []).map((item) => Number(item?.category_id)).filter(Number.isFinite));
- if (!categories.length) throw new Error('当前没有可认可的类别');
- const optionsHtml = categories.map((category) => {
- const categoryId = Number(category?.id);
- const existing = existingIds.has(categoryId);
- return `
-
- ${esc(category?.name || `类别 ${categoryId}`)}
- ${existing ? '已经认可' : '选择后将认可该用户为此类别的专家'}
- `;
- }).join('');
- return !!openReaderActionDialog(ctx, {
- title: `认可 @${username}`,
- intro: Number.isFinite(remaining) ? `今天还可新增 ${remaining} 次认可。` : '选择要认可的专家类别。',
- submitLabel: '确认认可',
- initialFocus: 'input[name="category_id"]:not(:disabled)',
- bodyHtml: `
${optionsHtml}
`,
- onSubmit: async (formData) => {
- if (Number.isFinite(remaining) && remaining < 1) throw new Error('今天的认可次数已用完');
- const addedIds = formData.getAll('category_id').map(Number)
- .filter((categoryId) => Number.isFinite(categoryId) && !existingIds.has(categoryId));
- if (!addedIds.length) throw new Error('请选择一个尚未认可的类别');
- const categoryIds = [...new Set([...existingIds, ...addedIds])];
- const response = await apiSend(
- `${BASE}/category-experts/endorse/${encodeURIComponent(username)}.json`,
- 'PUT',
- { categoryIds }
- );
- user.category_expert_endorsements = Array.isArray(response?.category_expert_endorsements)
- ? response.category_expert_endorsements
- : user.category_expert_endorsements;
- setCachedUserCard(username, user, 0);
- return '认可已提交';
- },
- });
- }
-
- function userNotificationLevel(user) {
- if (user?.ignored) return 'ignore';
- if (user?.muted) return 'mute';
- return 'normal';
- }
-
- const USER_NOTIFICATION_LEVEL_LABELS = {
- normal: '常规',
- mute: '免打扰',
- ignore: '忽略',
- };
-
- async function saveReaderUserNotificationLevel(username, user, level, extraParams = {}) {
- if (!username || !Object.hasOwn(USER_NOTIFICATION_LEVEL_LABELS, level)) {
- throw new Error('无效的消息设置');
- }
- const store = lookupDiscourse('service:store');
- const currentUser = lookupDiscourse('service:current-user');
- if (!store || !isFunction(store.createRecord) || !currentUser) {
- throw new Error('Discourse 用户服务尚未就绪');
- }
- const userModel = store.createRecord('user', { username });
- if (!userModel || !isFunction(userModel.updateNotificationLevel)) {
- throw new Error('Discourse 用户消息设置接口尚未就绪');
- }
- await userModel.updateNotificationLevel({
- level,
- expiringAt: extraParams.expiring_at || null,
- actingUser: currentUser,
- });
- if (user) {
- user.muted = level === 'mute';
- user.ignored = level === 'ignore';
- setCachedUserCard(username, user, 0);
- }
- return USER_NOTIFICATION_LEVEL_LABELS[level];
- }
-
- function openReaderIgnoreDurationDialog(ctx, username, user) {
- const durations = [
- { days: 1, label: '1 天' },
- { days: 7, label: '1 周' },
- { days: 30, label: '1 个月' },
- { days: 120, label: '4 个月' },
- { days: 365, label: '1 年' },
- ];
- return openReaderActionDialog(ctx, {
- title: `忽略 @${username}`,
- intro: 'Discourse 要求为“忽略”设置截止时间;到期后会自动恢复为常规。',
- submitLabel: '确认忽略',
- initialFocus: 'select[name="ignore_days"]',
- bodyHtml: `
忽略期限
-
- `,
- onSubmit: async (formData) => {
- const days = Number(formData.get('ignore_days'));
- if (!durations.some((item) => item.days === days)) throw new Error('请选择有效的忽略期限');
- const expiringAt = new Date(Date.now() + days * 86400000).toISOString();
- await saveReaderUserNotificationLevel(username, user, 'ignore', { expiring_at: expiringAt });
- return `已忽略 @${username}`;
- },
- });
- }
-
- async function runDirectUserCardAction(action, username, user, ctx) {
- if (!username || !['message', 'endorse', 'follow'].includes(action))
- return { ok: false, reason: 'invalid-action' };
- try {
- if (action === 'message') {
- const opened = await openDirectPrivateMessage(username, ctx);
- return { ok: opened, reason: opened ? '' : 'route-unavailable' };
- }
- if (action === 'endorse') {
- const opened = await openReaderEndorsementDialog(ctx, user || { username });
- return { ok: opened, reason: opened ? '' : 'endorsement-unavailable' };
- }
- if (action === 'follow') {
- if (!hasDiscourseCapability('follows', { user }))
- return { ok: false, reason: 'follow-unavailable' };
- if (user && user.can_follow === false && !user.is_followed)
- return { ok: false, reason: 'follow-not-allowed' };
- const followed = !!(user?.is_followed);
- await apiSend(`${BASE}/follow/${encodeURIComponent(username)}.json`, followed ? 'DELETE' : 'PUT');
- if (user) {
- const totalFollowers = Number(user.total_followers);
- user.is_followed = !followed;
- if (Number.isFinite(totalFollowers)) user.total_followers = Math.max(0, totalFollowers + (followed ? -1 : 1));
- setCachedUserCard(username, user, 0);
- }
- invalidateUserFollowList(username, 'followers');
- invalidateUserFollowList(ME_USERNAME, 'following');
- return { ok: true, reason: '', keepOpen: true, followed: !followed };
- }
- } catch (error) {
- console.warn('[LINUX DO reader] direct user card action failed', { action, error });
- return { ok: false, reason: 'action-failed', error };
- }
- }
-
- function emitUserCardUpdate(entry, user, phase, skipListener = null) {
- if (!entry || !entry.listeners) return;
- entry.listeners.forEach((listener) => {
- if (listener === skipListener) return;
- try { listener(user, phase); } catch {}
- });
- }
-
- function userTrustLevel(user) {
- const rawLevel = user?.trust_level;
- const direct = Number(rawLevel);
- if (rawLevel != null && rawLevel !== '' && Number.isFinite(direct)) return direct;
- const labels = [
- user?.title,
- user?.flair_name,
- user?.primary_group_name,
- ...(Array.isArray(user?.groups)
- ? user.groups.flatMap((group) => [group?.name, group?.full_name, group?.title])
- : []),
- ];
- for (const label of labels) {
- const match = String(label || '').match(/(?:^|[,,\s])lv\s*([0-9]+)(?:\b|$)/i);
- if (match) return Number(match[1]);
- }
- return null;
- }
-
- function userDetailsChecked(user, detail) {
- const checkedAt = Number(user?.[`_ldp_${detail}_checked_at`] || 0);
- return checkedAt > 0 && Date.now() - checkedAt < USER_CARD_CACHE_FRESH_FOR;
- }
-
- function fetchUserJSON(path, key, options = null) {
- const requestOptions = { key, priority: POST_REQUEST_PRIORITY.userCard };
- if (options) Object.assign(requestOptions, {
- cacheMode: options.cacheMode,
- allowStaleOnError: options.allowStaleOnError,
- });
- return fetchJSON(`${BASE}${path}`, requestOptions);
- }
-
- async function fetchUserProfileDetails(username, options = {}) {
- const User = lookupDiscourseModule('discourse/models/user')?.default;
- if (!User || !isFunction(User.findByUsername)) {
- throw new Error('Discourse 用户模型尚未就绪');
- }
- try {
- return await User.findByUsername(username, {
- existingRequest: fetchUserJSON(
- `/u/${encodeURIComponent(username)}.json`,
- `user-profile:${username}`,
- options
- ),
- });
- } catch (error) {
- if (shouldStopReaderFallback(error)) throw error;
- return User.findByUsername(username, {
- forCard: true,
- existingRequest: fetchUserJSON(
- `/u/${encodeURIComponent(username)}/card.json`,
- `user-card:${username}`,
- options
- ),
- });
- }
- }
-
- function uniqueUserBadges(source) {
- const unique = new Map();
- (Array.isArray(source) ? source : []).forEach((badge) => {
- if (!badge) return;
- const badgeId = Number(badge.id || badge.badge_id) || 0;
- const name = String(badge.name || '').trim();
- const key = badgeId ? `id:${badgeId}` : `name:${name.toLocaleLowerCase()}`;
- if (!badgeId && !name) return;
- const previous = unique.get(key);
- const grantedAt = Date.parse(badge.granted_at || '') || 0;
- const previousGrantedAt = Date.parse(previous?.granted_at || '') || 0;
- if (!previous || grantedAt >= previousGrantedAt) unique.set(key, badge);
- });
- return [...unique.values()];
- }
-
- async function fetchUserBadges(username) {
- const data = await fetchUserJSON(`/user-badges/${encodeURIComponent(username)}.json`, `user-badges:${username}`);
- const badgesById = new Map((Array.isArray(data?.badges) ? data.badges : [])
- .map((badge) => [Number(badge?.id), badge]));
- return uniqueUserBadges((Array.isArray(data?.user_badges) ? data.user_badges : []).map((grant) => {
- const badge = badgesById.get(Number(grant?.badge_id)) || {};
- return Object.assign({}, badge, grant, {
- id: Number(grant?.badge_id || badge.id) || 0,
- name: String(badge.name || grant?.name || '').trim(),
- });
- }).filter((badge) => badge.id && badge.name && badge.enabled !== false));
- }
-
- function normalizeUserFollowList(data, type) {
- const source = Array.isArray(data)
- ? data
- : Array.isArray(data?.users)
- ? data.users
- : Array.isArray(data?.[type])
- ? data[type]
- : [];
- return source.map((entry) => {
- const user = entry && (entry.user || entry);
- const username = cleanUsernameValue(user?.username);
- if (!username) return null;
- const name = String(user.name || username).trim();
- return {
- username,
- name,
- avatar_template: String(user.avatar_template || ''),
- flair_name: user.flair_name,
- flair_url: user.flair_url,
- flair_bg_color: user.flair_bg_color,
- flair_color: user.flair_color,
- searchText: pinyinSearchForms(`${username} ${name}`).join(' '),
- };
- }).filter(Boolean);
- }
-
- function cachedUserFollowList(key) {
- const cached = USER_FOLLOW_LIST_CACHE.get(key);
- if (!cached || Date.now() - cached.at > USER_FOLLOW_LIST_CACHE_FRESH_FOR) {
- USER_FOLLOW_LIST_CACHE.delete(key);
- return null;
- }
- USER_FOLLOW_LIST_CACHE.delete(key);
- USER_FOLLOW_LIST_CACHE.set(key, cached);
- return cached.users;
- }
-
- function invalidateUserFollowList(username, type) {
- username = cleanUsernameValue(username);
- if (!username || !['following', 'followers'].includes(type)) return;
- USER_FOLLOW_LIST_CACHE.delete(authScopedCacheKey(`${username}:${type}`));
- const policy = responseCachePolicy(`${BASE}/u/${encodeURIComponent(username)}/follow/${type}`);
- if (policy) void deleteStoredResponseCacheEntry(policy.id);
- }
-
- async function fetchUserFollowList(username, type) {
- username = cleanUsernameValue(username);
- if (!username || !['following', 'followers'].includes(type)) return [];
- const cacheKey = authScopedCacheKey(`${username}:${type}`);
- const cached = cachedUserFollowList(cacheKey);
- if (cached) return cached;
- const pendingRequest = USER_FOLLOW_LIST_REQUESTS.get(cacheKey);
- if (pendingRequest) return pendingRequest;
- const request = fetchUserJSON(
- `/u/${encodeURIComponent(username)}/follow/${type}`,
- `user-${type}:${username}`
- ).then((data) => {
- const users = normalizeUserFollowList(data, type);
- USER_FOLLOW_LIST_CACHE.delete(cacheKey);
- USER_FOLLOW_LIST_CACHE.set(cacheKey, { users, at: Date.now() });
- while (USER_FOLLOW_LIST_CACHE.size > USER_FOLLOW_LIST_CACHE_MAX_ENTRIES) {
- USER_FOLLOW_LIST_CACHE.delete(USER_FOLLOW_LIST_CACHE.keys().next().value);
- }
- return users;
- }).finally(() => USER_FOLLOW_LIST_REQUESTS.delete(cacheKey));
- USER_FOLLOW_LIST_REQUESTS.set(cacheKey, request);
- return request;
- }
-
- async function fetchUserCard(username, onUpdate) {
- username = cleanUsernameValue(username);
- if (!username) return null;
- const existing = USER_CARD_REQUESTS.get(username);
- if (existing && !existing.cancelled) {
- if (isFunction(onUpdate)) {
- existing.listeners.add(onUpdate);
- if (existing.basic) onUpdate(existing.basic, 'stats');
- }
- return existing.promise;
- }
- if (existing) USER_CARD_REQUESTS.delete(username);
- const cached = getCachedUserCard(username);
- const profileChecked = cached && userDetailsChecked(cached, 'profile');
- const summaryChecked = cached && userDetailsChecked(cached, 'summary');
- const badgesChecked = cached && userDetailsChecked(cached, 'badges');
- if (cached && profileChecked && summaryChecked && badgesChecked) {
- if (isFunction(onUpdate)) onUpdate(cached, 'ready');
- return cached;
- }
- if (cached && isFunction(onUpdate)) onUpdate(cached, 'stats');
- const cachedStatsListener = cached && profileChecked ? onUpdate : null;
- const profilePromise = profileChecked
- ? Promise.resolve(cached)
- : fetchUserProfileDetails(username);
- const summaryPromise = summaryChecked
- ? Promise.resolve(cached.user_summary)
- : fetchUserSummary(username);
- const badgesPromise = badgesChecked
- ? Promise.resolve(Array.isArray(cached.user_badges) ? cached.user_badges : [])
- : fetchUserBadges(username);
- const supplementalPromise = Promise.allSettled([summaryPromise, badgesPromise]);
- const entry = { listeners: new Set(), basic: null, promise: null, cancelled: false };
- if (isFunction(onUpdate)) entry.listeners.add(onUpdate);
- const request = (async () => {
- const profile = await profilePromise;
- const user = Object.assign({}, cached || {}, profile || {}, {
- _ldp_profile_checked_at: profileChecked
- ? cached._ldp_profile_checked_at
- : Date.now(),
- });
- entry.basic = user;
- setCachedUserCard(username, user);
- emitUserCardUpdate(entry, user, 'stats', cachedStatsListener);
- if (entry.cancelled) return user;
- const [summaryResult, badgesResult] = await supplementalPromise;
- if (badgesResult.status === 'fulfilled')
- user.user_badges = badgesResult.value;
- if (summaryResult.status === 'rejected') throw summaryResult.reason;
- user.user_summary = summaryResult.value;
- user._ldp_summary_checked_at = Date.now();
- setCachedUserCard(username, user, 0);
- emitUserCardUpdate(entry, user, 'ready');
- return user;
- })().finally(() => {
- if (USER_CARD_REQUESTS.get(username) === entry) USER_CARD_REQUESTS.delete(username);
- });
- entry.promise = request;
- USER_CARD_REQUESTS.set(username, entry);
- return request;
- }
-
- function cancelQueuedUserCardRequests(username, scheduler = ACTIVE_READER_REQUEST_SCHEDULER) {
- username = cleanUsernameValue(username);
- if (!username) return;
- const entry = USER_CARD_REQUESTS.get(username);
- if (entry) entry.cancelled = true;
- if (!scheduler) return;
- [
- `user-profile:${username}`,
- `user-card:${username}`,
- `user-summary:${username}`,
- `user-directory:${username}`,
- `user-badges:${username}`,
- `user-following:${username}`,
- `user-followers:${username}`,
- ].forEach((key) => scheduler.cancelQueued(key));
- }
-
- async function fetchUserSummary(username, options = {}) {
- username = cleanUsernameValue(username);
- if (!username) return null;
- let summary = null;
- try {
- const data = await fetchUserJSON(`/u/${encodeURIComponent(username)}/summary.json`, `user-summary:${username}`, options);
- summary = data && (data.user_summary || data.summary || data);
- } catch (e) {
- if (shouldStopReaderFallback(e)) throw e;
- summary = null;
- }
- const hasStats = summary && ['post_count', 'posts_count', 'likes_received', 'likes_given', 'topic_count', 'topics_entered']
- .some((key) => summary[key] != null && summary[key] !== '');
- const directoryStats = hasStats ? null : await fetchUserDirectoryStats(username, options);
- return Object.assign({}, directoryStats || {}, summary || {});
- }
-
- async function fetchUserDirectoryStats(username, options = {}) {
- username = cleanUsernameValue(username);
- if (!username) return null;
- try {
- const qs = new URLSearchParams();
- qs.set('period', 'all');
- qs.set('order', 'likes_received');
- qs.set('username', username);
- const data = await fetchUserJSON(`/directory_items.json?${qs}`, `user-directory:${username}`, options);
- const item = data && Array.isArray(data.directory_items) && data.directory_items[0];
- if (!item) return null;
- return {
- post_count: item.post_count,
- likes_received: item.likes_received,
- likes_given: item.likes_given,
- topic_count: item.topic_count,
- };
- } catch (e) {
- if (shouldStopReaderFallback(e)) throw e;
- return null;
- }
- }
-
- async function fetchSettingsUserInfo(username, force = false) {
- username = cleanUsernameValue(username);
- if (!username) return null;
- const cached = getCachedUserCard(username);
- if (!force && cached && userDetailsChecked(cached, 'profile') && userDetailsChecked(cached, 'summary')) return cached;
- const requestOptions = force
- ? { cacheMode: 'refresh', allowStaleOnError: false }
- : { cacheMode: 'default' };
- const [profile, summary] = await Promise.all([
- fetchUserProfileDetails(username, requestOptions),
- fetchUserSummary(username, requestOptions),
- ]);
- const checkedAt = Date.now();
- const user = Object.assign({}, cached || {}, profile || {}, {
- username,
- user_summary: summary,
- _ldp_profile_checked_at: checkedAt,
- _ldp_summary_checked_at: checkedAt,
- _ldp_user_info_checked_at: checkedAt,
- });
- setCachedUserCard(username, user, 0);
- return user;
- }
-
- function getUserCardSurface(preview = false) {
- const previewClass = 'ldp-user-card-follow-preview';
- let card = readerPortalQuery(preview
- ? `.${previewClass}`
- : `.ldp-user-card-fallback:not(.${previewClass})`);
- if (!card) {
- card = makeElement('div');
- card.className = `ldp-user-card-fallback${preview ? ` ${previewClass}` : ''}`;
- appendReaderPortalNode(card);
- }
- return card;
- }
-
- function getUserCardFollowPanel() {
- let panel = readerPortalQuery('.ldp-user-card-follow-panel');
- if (panel) return panel;
- panel = makeElement('section');
- panel.id = 'ldp-user-card-follow-panel';
- panel.className = 'ldp-user-card-follow-panel';
- setLabel(panel, '关注人员列表');
- panel.hidden = true;
- panel.innerHTML = `
-
-
-
检索
-
-
-
-
- `;
- appendReaderPortalNode(panel);
- return panel;
- }
-
- function hideFallbackUserCard() {
- const card = readerPortalQuery('.ldp-user-card-fallback:not(.ldp-user-card-follow-preview)');
- if (card) card.classList.remove('open');
- }
-
- function fallbackUserCardOpen() {
- return !!readerPortalQuery('.ldp-user-card-fallback.open:not(.ldp-user-card-follow-preview)');
- }
-
- function positionFallbackUserCardNotificationMenu(card) {
- const menu = card?.querySelector('.ldp-user-card-notification-menu:not([hidden])');
- const actions = card?.querySelector('.ldp-user-card-actions');
- if (!menu || !actions) return;
- const margin = 10;
- const gap = 6;
- const rect = actions.getBoundingClientRect();
- const width = Math.min(rect.width, window.innerWidth - margin * 2);
- menu.style.width = `${Math.round(width)}px`;
- const height = menu.offsetHeight;
- const spaceAbove = rect.top - margin - gap;
- const spaceBelow = window.innerHeight - rect.bottom - margin - gap;
- const opensAbove = spaceBelow < height && spaceAbove > spaceBelow;
- const preferredTop = opensAbove ? rect.top - height - gap : rect.bottom + gap;
- const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin));
- const top = Math.max(margin, Math.min(preferredTop, window.innerHeight - height - margin));
- menu.style.left = `${Math.round(left)}px`;
- menu.style.top = `${Math.round(top)}px`;
- }
-
- function positionFallbackUserCard(card, anchor) {
- card.style.removeProperty('max-height');
- const rect = anchor.getBoundingClientRect();
- const margin = 10;
- const width = card.offsetWidth || 300;
- const height = Math.min(card.offsetHeight || 220, window.innerHeight - margin * 2);
- const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin));
- let top = rect.bottom + 8;
- if (top + height > window.innerHeight - margin) top = Math.max(margin, rect.top - height - 8);
- card.style.left = `${Math.round(left)}px`;
- card.style.top = `${Math.round(top)}px`;
- positionFallbackUserCardNotificationMenu(card);
- }
-
- function positionUserCardFollowPanel(panel, card) {
- if (!panel || panel.hidden || !card) return;
- const margin = 10;
- const gap = 8;
- panel.style.removeProperty('max-height');
- card.style.removeProperty('max-height');
- const cardRect = card.getBoundingClientRect();
- const width = panel.offsetWidth || 320;
- const height = Math.min(panel.offsetHeight || 220, window.innerHeight - margin * 2);
- const right = cardRect.right + gap;
- const left = cardRect.left - width - gap;
- const preferredLeft = right + width <= window.innerWidth - margin ? right : left;
- const fallbackLeft = Math.max(margin, Math.min(preferredLeft, window.innerWidth - width - margin));
- const fallbackTop = Math.max(margin, Math.min(cardRect.top, window.innerHeight - height - margin));
- const avatarViewer = readerPortalQuery('.ldp-avatar-viewer');
- const viewerRect = avatarViewer?.isConnected && avatarViewer._ldpAnchorCard === card
- ? avatarViewer.getBoundingClientRect()
- : null;
- const overlaps = (first, second) => first.left < second.right + gap && first.right + gap > second.left &&
- first.top < second.bottom + gap && first.bottom + gap > second.top;
- const fallbackRect = {
- left: fallbackLeft,
- top: fallbackTop,
- right: fallbackLeft + width,
- bottom: fallbackTop + height,
- };
- if (!viewerRect || !overlaps(fallbackRect, viewerRect)) {
- panel.style.left = `${Math.round(fallbackLeft)}px`;
- panel.style.top = `${Math.round(fallbackTop)}px`;
- return;
- }
-
- const minimumPanelHeight = Math.min(height, 150);
- const freeRegions = [
- { left: margin, top: margin, right: viewerRect.left - gap, bottom: window.innerHeight - margin },
- { left: viewerRect.right + gap, top: margin, right: window.innerWidth - margin, bottom: window.innerHeight - margin },
- { left: margin, top: margin, right: window.innerWidth - margin, bottom: viewerRect.top - gap },
- { left: margin, top: viewerRect.bottom + gap, right: window.innerWidth - margin, bottom: window.innerHeight - margin },
- ].filter((region) => region.right - region.left >= width && region.bottom - region.top >= minimumPanelHeight);
- const placements = freeRegions.flatMap((region) => {
- const availableHeight = region.bottom - region.top;
- const placedHeight = Math.min(height, availableHeight);
- const candidateLefts = new Set([
- Math.max(region.left, Math.min(cardRect.left, region.right - width)),
- region.left,
- region.right - width,
- ]);
- const candidateTops = new Set([
- Math.max(region.top, Math.min(cardRect.top, region.bottom - placedHeight)),
- region.top,
- region.bottom - placedHeight,
- ]);
- return [...candidateLefts].flatMap((placedLeft) => [...candidateTops].map((placedTop) => {
- const rect = {
- left: placedLeft,
- top: placedTop,
- right: placedLeft + width,
- bottom: placedTop + placedHeight,
- };
- return {
- left: placedLeft,
- top: placedTop,
- height: placedHeight,
- rect,
- score: Math.abs(placedLeft - fallbackLeft) + Math.abs(placedTop - fallbackTop) + (height - placedHeight) * 3,
- };
- }));
- }).filter((placement) => !overlaps(placement.rect, cardRect))
- .sort((first, second) => first.score - second.score);
- if (placements.length) {
- const placement = placements[0];
- if (placement.height < height) panel.style.maxHeight = `${Math.floor(placement.height)}px`;
- panel.style.left = `${Math.round(placement.left)}px`;
- panel.style.top = `${Math.round(placement.top)}px`;
- return;
- }
-
- const cardWidth = cardRect.width;
- const cardHeight = Math.min(cardRect.height, window.innerHeight - margin * 2);
- const minimumCardHeight = Math.min(cardHeight, 180);
- const stackedRegions = freeRegions.filter((region) =>
- region.right - region.left >= Math.max(cardWidth, width) &&
- region.bottom - region.top >= minimumCardHeight + gap + minimumPanelHeight
- ).map((region) => {
- const availableHeight = region.bottom - region.top - gap;
- const desiredTotal = Math.max(1, cardHeight + height);
- const placedCardHeight = Math.max(minimumCardHeight, Math.min(
- availableHeight - minimumPanelHeight,
- Math.round(availableHeight * cardHeight / desiredTotal)
- ));
- const placedPanelHeight = Math.min(height, availableHeight - placedCardHeight);
- const groupHeight = placedCardHeight + gap + placedPanelHeight;
- const groupTop = Math.max(region.top, Math.min(cardRect.top, region.bottom - groupHeight));
- const placedCardLeft = Math.max(region.left, Math.min(cardRect.left, region.right - cardWidth));
- const placedPanelLeft = Math.max(region.left, Math.min(placedCardLeft, region.right - width));
- return {
- cardLeft: placedCardLeft,
- cardTop: groupTop,
- cardHeight: placedCardHeight,
- panelLeft: placedPanelLeft,
- panelTop: groupTop + placedCardHeight + gap,
- panelHeight: placedPanelHeight,
- score: Math.abs(placedCardLeft - cardRect.left) + Math.abs(groupTop - cardRect.top) +
- (cardHeight - placedCardHeight) * 2 + (height - placedPanelHeight) * 2,
- };
- }).sort((first, second) => first.score - second.score);
- if (stackedRegions.length) {
- const placement = stackedRegions[0];
- card.style.left = `${Math.round(placement.cardLeft)}px`;
- card.style.top = `${Math.round(placement.cardTop)}px`;
- if (placement.cardHeight < cardHeight) card.style.maxHeight = `${Math.floor(placement.cardHeight)}px`;
- panel.style.left = `${Math.round(placement.panelLeft)}px`;
- panel.style.top = `${Math.round(placement.panelTop)}px`;
- if (placement.panelHeight < height) panel.style.maxHeight = `${Math.floor(placement.panelHeight)}px`;
- positionFallbackUserCardNotificationMenu(card);
- return;
- }
-
- panel.style.left = `${Math.round(fallbackLeft)}px`;
- panel.style.top = `${Math.round(fallbackTop)}px`;
- }
-
- function positionUserCardFollowPreview(preview, panel, card) {
- if (!preview || !panel || panel.hidden || !card) return;
- const margin = 10;
- const gap = 8;
- const panelRect = panel.getBoundingClientRect();
- const cardRect = card.getBoundingClientRect();
- const width = preview.offsetWidth || 320;
- const height = Math.min(preview.offsetHeight || 220, window.innerHeight - margin * 2);
- const panelIsRightOfCard = panelRect.left >= cardRect.right;
- const preferredLeft = panelIsRightOfCard ? panelRect.right + gap : panelRect.left - width - gap;
- const alternateLeft = panelIsRightOfCard ? panelRect.left - width - gap : panelRect.right + gap;
- const preferredFits = preferredLeft >= margin && preferredLeft + width <= window.innerWidth - margin;
- const left = preferredFits ? preferredLeft : alternateLeft;
- preview.style.left = `${Math.round(Math.max(margin, Math.min(left, window.innerWidth - width - margin)))}px`;
- preview.style.top = `${Math.round(Math.max(margin, Math.min(panelRect.top, window.innerHeight - height - margin)))}px`;
- }
-
- function userStatValue(user, keys) {
- const sources = [user?.user_summary, user, user?.summary, user?.user_stat, user?.stats].filter(Boolean);
- for (const key of keys) {
- for (const source of sources) {
- const value = source[key];
- if (value != null && value !== '') return value;
- }
- }
- return null;
- }
-
- function userCardStat(value, label, href) {
- const num = Number(value);
- const inner = `
${Number.isFinite(num) ? esc(num) : '-'}${esc(label)}`;
- if (!href) return `
${inner}
`;
- return `
${inner}`;
- }
-
- function isSafeUserCardUrl(value) {
- const url = String(value || '').trim();
- return /^(https?:)?\/\//i.test(url) || url.startsWith('/') || url.startsWith('data:image/');
- }
-
- function sanitizeUserCardBioHtml(value) {
- const template = makeElement('template');
- template.innerHTML = String(value || '');
- const allowedTags = new Set(['A', 'B', 'BR', 'EM', 'I', 'IMG', 'P', 'SPAN', 'STRONG']);
- const allowedAttrs = {
- A: new Set(['href']),
- IMG: new Set(['src', 'alt', 'class', 'width', 'height']),
- SPAN: new Set(['class']),
- };
- const cleanNode = (node) => {
- if (node.nodeType === Node.TEXT_NODE) return esc(node.textContent || '');
- if (node.nodeType !== Node.ELEMENT_NODE) return '';
- const tag = node.tagName;
- const children = [...node.childNodes].map(cleanNode).join('');
- if (!allowedTags.has(tag)) return children;
- const attrs = [...node.attributes]
- .filter((attr) => {
- const name = attr.name.toLowerCase();
- if (name.startsWith('on') || name === 'style') return false;
- if (!(allowedAttrs[tag]?.has(name))) return false;
- if ((name === 'href' || name === 'src') && !isSafeUserCardUrl(attr.value)) return false;
- return true;
- })
- .map((attr) => ` ${attr.name.toLowerCase()}="${escAttr(attr.value)}"`).join('');
- if (tag === 'A') return `
${children}`;
- if (tag === 'IMG') return `
![]()
`;
- return `<${tag.toLowerCase()}${attrs}>${children}${tag.toLowerCase()}>`;
- };
- return [...template.content.childNodes].map(cleanNode).join('');
- }
-
- function userCardProgressHtml(phase) {
- if (phase === 'ready') return '';
- const loadingStats = phase === 'stats';
- const label = loadingStats ? '资料已获取,正在获取统计…' : '正在获取用户资料…';
- return `
- ${label}
-
`;
- }
-
- function userRegistrationDate(value) {
- const date = new Date(value || 0);
- if (!Number.isFinite(date.getTime())) return '';
- return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日`;
- }
-
- function userRecentDate(value) {
- const date = new Date(value || 0);
- if (!Number.isFinite(date.getTime())) return '';
- const elapsed = Date.now() - date.getTime();
- if (elapsed >= 0 && elapsed < 60 * 1000) return '刚刚';
- if (elapsed >= 0 && elapsed < 60 * 60 * 1000) return `${Math.max(1, Math.floor(elapsed / 60000))} 分钟前`;
- if (elapsed >= 0 && elapsed < 24 * 60 * 60 * 1000) return `${Math.floor(elapsed / 3600000)} 小时前`;
- if (date.getFullYear() === new Date().getFullYear()) return `${date.getMonth() + 1} 月 ${date.getDate()} 日`;
- return userRegistrationDate(value);
- }
-
- function userVisibleGroups(user, includePrimaryGroup = false) {
- const visible = new Map();
- const groups = Array.isArray(user?.groups) ? user.groups : [];
- [...groups, includePrimaryGroup && user && user.primary_group_name
- ? { name: user.primary_group_name }
- : null].forEach((group) => {
- const name = String(group?.name || '').trim();
- if (!name || /^trust_level_[0-9]+$/i.test(name)) return;
- const key = name.toLocaleLowerCase();
- if (visible.has(key)) return;
- visible.set(key, {
- name,
- label: String(group.full_name || group.display_name || group.title || name).trim() || name,
- });
- });
- return [...visible.values()];
- }
-
- function userCardGroupsHtml(user) {
- const groups = userVisibleGroups(user, true);
- if (!groups.length) return '';
- const discourseUrl = lookupDiscourseModule('discourse/lib/url');
- return `
-
用户分组${groups.map((group) => {
- const href = isFunction(discourseUrl?.groupPath)
- ? discourseUrl.groupPath(encodeURIComponent(group.name))
- : '';
- return href
- ? `
${esc(group.label)}`
- : `
${esc(group.label)}`;
- }).join('')}
-
`;
- }
-
- function userProfileFactsHtml(user, variant, options = {}) {
- const fact = (label, value, factOptions = {}) => {
- if (value == null || value === '') return '';
- const classes = ['ldp-user-profile-fact'];
- if (factOptions.accent) classes.push('is-accent');
- if (factOptions.wide) classes.push('is-wide');
- const tooltip = factOptions.title ? ` data-ldp-tooltip-label="${escAttr(factOptions.title)}"` : '';
- return `
${esc(label)}${esc(value)}`;
- };
- const numberValue = (keys) => {
- const value = userStatValue(user, keys);
- if (!options.emptyNumberAsZero && (value == null || value === '')) return '';
- const numeric = Number(value);
- return Number.isFinite(numeric) ? numeric.toLocaleString('zh-CN') : '';
- };
- const trustLabels = ['新用户', '基本用户', '成员', '活跃用户', '领导者'];
- const trustLevel = Object.hasOwn(options, 'trustLevel') ? options.trustLevel : userTrustLevel(user);
- const groupText = variant === 'card'
- ? ''
- : userVisibleGroups(user, options.includePrimaryGroup).map((group) => group.label).join(', ');
- const facts = [
- fact('加入日期:', userRegistrationDate(user?.created_at)),
- fact('最后一个帖子', userRecentDate(user && (user.last_posted_at || user.last_post_at))),
- fact('最后活动', userRecentDate(user && (user.last_seen_at || user.last_active_at))),
- fact('浏览量', numberValue(['profile_view_count', 'profile_views', 'views'])),
- fact('信任级别', Number.isFinite(trustLevel) ? (trustLabels[trustLevel] || `Lv${trustLevel}`) : ''),
- fact('电子邮件', user?.email, { wide: true }),
- ...(variant === 'card' ? [] : [fact('群组', groupText, { accent: true, wide: true, title: options.groupTooltip ? groupText : '' })]),
- ...(variant === 'card' ? [] : [
- fact('正在关注', numberValue(['total_following', 'following_count', 'followings_count'])),
- fact('关注者', numberValue(['total_followers', 'follower_count', 'followers_count'])),
- ]),
- fact('点数', numberValue(['gamification_score', 'points']), { accent: true }),
- ].filter(Boolean);
- return facts.length ? `
${facts.join('')}
` : '';
- }
-
- function userCardFollowStatsHtml(user, username, options = {}) {
- if (!hasDiscourseCapability('follows', { user })) return '';
- const interactive = options.interactive !== false;
- const configs = [
- { type: 'following', label: '关注', total: userStatValue(user, ['total_following', 'following_count', 'followings_count']), visible: user && user.can_see_following !== false },
- { type: 'followers', label: '被关注', total: userStatValue(user, ['total_followers', 'follower_count', 'followers_count']), visible: user && user.can_see_followers !== false },
- ];
- return `
- ${configs.map((item) => {
- const total = Number(item.total);
- const count = Number.isFinite(total) ? total.toLocaleString('zh-CN') : '-';
- if (!interactive)
- return `
- ${esc(count)}${esc(item.label)}
- `;
- const label = item.visible ? `查看${item.label}人员` : `${item.label}列表不可见`;
- return ``;
- }).join('')}
-
`;
- }
-
- function userCardFollowItemHtml(user) {
- const username = String(user?.username || '');
- const name = String(user?.name || username);
- const avatar = avatarWithFlairHtml(
- persistentAvatarHtml(user?.avatar_template, 48, 'ldp-user-card-follow-avatar', name),
- user
- );
- return `
- ${avatar}${esc(name)}@${esc(username)}
- `;
- }
-
- function renderUserCardFollowList(panel) {
- if (panel?.hidden) return;
- const list = panel?.querySelector('.ldp-user-card-follow-list');
- const summary = panel?.querySelector('.ldp-user-card-follow-summary');
- const input = panel?.querySelector('input[type="search"]');
- const pagination = panel?.querySelector('.ldp-user-card-follow-pagination');
- if (!panel || !list || !summary || !input || !pagination) return;
- const users = Array.isArray(panel._ldpFollowUsers) ? panel._ldpFollowUsers : [];
- const query = normalizeSearchText(input.value);
- const matches = query ? users.filter((user) => user.searchText.includes(query)) : users;
- const pageCount = Math.max(1, Math.ceil(matches.length / USER_FOLLOW_LIST_PAGE_SIZE));
- const requestedPage = Math.max(0, Number(panel.dataset.followPage) || 0);
- const page = Math.min(requestedPage, pageCount - 1);
- const offset = page * USER_FOLLOW_LIST_PAGE_SIZE;
- const visible = matches.slice(offset, offset + USER_FOLLOW_LIST_PAGE_SIZE);
- panel.dataset.followPage = String(page);
- releasePersistentAvatarImages(list);
- list.innerHTML = visible.length
- ? visible.map(userCardFollowItemHtml).join('')
- : `
${query ? '没有匹配的人员' : '暂无人员'}
`;
- summary.textContent = query ? `找到 ${matches.length} 人` : `${matches.length} 人`;
- pagination.hidden = matches.length <= USER_FOLLOW_LIST_PAGE_SIZE;
- pagination.querySelector('span').textContent = `${page + 1} / ${pageCount}`;
- pagination.querySelector('[data-user-card-follow-page="previous"]').disabled = page === 0;
- pagination.querySelector('[data-user-card-follow-page="next"]').disabled = page >= pageCount - 1;
- list.scrollTop = 0;
- hydratePersistentAvatarImages(list, POST_REQUEST_PRIORITY.userCard);
- }
-
- function connectNumber(value) {
- const match = String(value || '').replace(/,/g, '').match(/-?\d+/);
- return match ? Number(match[0]) : 0;
- }
-
- function connectCurrentTarget(value) {
- const parts = String(value || '').replace(/,/g, '').split('/');
- if (parts.length >= 2) return { current: connectNumber(parts[0]), target: connectNumber(parts[1]) };
- const numbers = String(value || '').replace(/,/g, '').match(/-?\d+/g) || [];
- return { current: Number(numbers[0] || 0), target: Number(numbers[1] || 0) };
- }
-
- function parseConnectTrustHtml(html) {
- const doc = new globalThis.DOMParser().parseFromString(String(html || ''), 'text/html');
- const card = [...doc.querySelectorAll('.card')].find((candidate) => {
- const heading = candidate.querySelector('.card-title,h2');
- return heading && /信任级别\s*\d+\s*的要求/.test(heading.textContent || '');
- });
- if (!card) throw new Error('Connect 未返回升级要求,请先登录 Connect');
- const heading = String(card.querySelector('.card-title,h2')?.textContent || '').trim();
- const targetLevel = Number((heading.match(/信任级别\s*(\d+)\s*的要求/) || [])[1]);
- const subtitle = String(card.querySelector('.card-subtitle')?.textContent || '').trim();
- const username = (subtitle.match(/@([^\s·]+)/) || [])[1] || '';
- const timePeriod = Number((subtitle.match(/过去\s*([\d,]+)\s*天/) || [])[1]?.replace(/,/g, '')) || 100;
- const rings = [...card.querySelectorAll('.tl3-ring')].map((item) => ({
- label: String(item.querySelector('.tl3-ring-label')?.textContent || '').trim(),
- current: connectNumber(item.querySelector('.tl3-ring-current')?.textContent),
- target: connectNumber(item.querySelector('.tl3-ring-target')?.textContent),
- met: item.querySelector('.tl3-ring-circle')?.classList.contains('met') || false,
- })).filter((item) => item.label);
- const requirementGroups = [
- ['bars', '.tl3-bar-item', '.tl3-bar-label', '.tl3-bar-nums'],
- ['quotas', '.tl3-quota-card', '.tl3-quota-label', '.tl3-quota-nums'],
- ['vetoes', '.tl3-veto-item', '.tl3-veto-label', '.tl3-veto-value'],
- ];
- const { bars, quotas, vetoes } = Object.fromEntries(requirementGroups.map(([
- group, itemSelector, labelSelector, valueSelector,
- ]) => [group, [...card.querySelectorAll(itemSelector)].map((item) => {
- const valueNode = item.querySelector(valueSelector);
- const values = group === 'vetoes'
- ? { current: connectNumber(valueNode?.textContent), target: 0 }
- : connectCurrentTarget(valueNode?.textContent);
- const met = group === 'bars'
- ? valueNode?.classList.contains('met') || item.querySelector('.tl3-bar-fill')?.classList.contains('met') || false
- : item.classList.contains('met') || (group === 'quotas' ? values.current <= values.target : values.current === 0);
- return {
- label: String(item.querySelector(labelSelector)?.textContent || '').trim(),
- ...values, met, ...(group === 'bars' ? {} : { reverse: true }),
- };
- }).filter((item) => item.label)]));
- const statusNode = card.querySelector('.status-met,.status-unmet');
- const badgeNode = card.querySelector('.badge');
- const allRequirements = [...rings, ...bars, ...quotas, ...vetoes];
- const met = statusNode
- ? statusNode.classList.contains('status-met')
- : badgeNode ? !/未达到|未达/.test(badgeNode.textContent || '') : allRequirements.every((item) => item.met);
- return {
- targetLevel: Number.isFinite(targetLevel) ? targetLevel : null,
- username,
- timePeriod,
- rings,
- bars,
- quotas,
- vetoes,
- met,
- };
- }
-
- function requestConnectTrustHtml() {
- if (!hasDiscourseCapability('connect'))
- return Promise.reject(new Error('当前站点没有配置 Connect 能力'));
- const request = globalThis.GM_xmlhttpRequest;
- if (!isFunction(request)) return Promise.reject(new Error('当前脚本未获得 Connect 跨域读取权限'));
- return new Promise((resolve, reject) => {
- request({
- method: 'GET',
- url: 'https://connect.linux.do/',
- headers: { 'Accept': 'text/html' },
- timeout: 20000,
- ...(/AppleWebKit\//.test(navigator.userAgent || '') &&
- !/(?:Chrome|Chromium|Edg|OPR|CriOS|FxiOS)\//.test(navigator.userAgent || '') ? { cookiePartition: { topLevelSite: BASE } } : {}),
- onload: (response) => {
- if (response.status >= 200 && response.status < 300) resolve(response.responseText || '');
- else reject(new Error(`Connect 请求失败(${response.status || '未知状态'})`));
- },
- ontimeout: () => reject(new Error('Connect 请求超时')),
- onerror: () => reject(new Error('Connect 请求失败')),
- });
- });
- }
-
- function fetchConnectTrustData(force = false, persistedCache = null) {
- const freshFor = 5 * MINUTE;
- if (!CONNECT_TRUST_CACHE && persistedCache && persistedCache.data)
- CONNECT_TRUST_CACHE = {
- data: persistedCache.data,
- cachedAt: Number(persistedCache.cachedAt || 0),
- };
- if (CONNECT_TRUST_REQUEST) return CONNECT_TRUST_REQUEST;
- if (!force && CONNECT_TRUST_CACHE && Date.now() - CONNECT_TRUST_CACHE.cachedAt < freshFor)
- return Promise.resolve(CONNECT_TRUST_CACHE.data);
- CONNECT_TRUST_REQUEST = requestConnectTrustHtml().then(parseConnectTrustHtml).then((data) => {
- CONNECT_TRUST_CACHE = { data, cachedAt: Date.now() };
- return data;
- }).finally(() => {
- CONNECT_TRUST_REQUEST = null;
- });
- return CONNECT_TRUST_REQUEST;
- }
-
- let LDC_USER_REQUEST = null;
- function fetchLinuxDoCreditUser(username, force = false) {
- const cached = globalThis.GM_getValue?.(LDC_USER_BRIDGE_CACHE_KEY, null);
- const normalize = (value) => String(value || '').trim().toLowerCase();
- const expected = normalize(username), cachedUsername = normalize(cached?.data?.username);
- const cacheMatches = cached?.data && (!expected || !cachedUsername || cachedUsername === expected);
- if (!force && cacheMatches && Date.now() - Number(cached.cachedAt || 0) < 30 * MINUTE)
- return Promise.resolve(cached.data);
- if (LDC_USER_REQUEST) return LDC_USER_REQUEST;
- const request = globalThis.GM_xmlhttpRequest;
- if (typeof request !== 'function') return Promise.reject(new Error('脚本没有 LDC 请求权限'));
- LDC_USER_REQUEST = new Promise((resolve, reject) => request({
- method: 'GET', url: 'https://credit.linux.do/api/v1/oauth/user-info',
- headers: { Accept: 'application/json', Referer: 'https://credit.linux.do/home' },
- responseType: 'json', anonymous: false, withCredentials: true, timeout: 8000,
- onload: ({ status, response }) => status >= 200 && status < 300 && response?.data ? resolve(response.data)
- : reject(new Error([401, 403].includes(status) ? 'LDC 授权已失效,请打开 LDC 完成登录' : `LDC 请求失败(${status || '网络异常'})`)),
- ontimeout: () => reject(new Error('LDC 请求超时')),
- onerror: () => reject(new Error('LDC 网络请求失败')),
- })).then((data) => {
- const actual = normalize(data?.username);
- if (expected && actual && actual !== expected)
- throw new Error('LDC 与当前 LINUX DO 登录账号不一致');
- return cacheCreditUser(data);
- }).catch((error) => {
- if (cacheMatches) return cached.data;
- throw error;
- }).finally(() => { LDC_USER_REQUEST = null; });
- return LDC_USER_REQUEST;
- }
-
- function settingsCreditHtml(user, error) {
- const login = '
打开 LDC 同步';
- if (!user) return `
LINUX DO Credit复用 credit.linux.do 登录会话
${esc(error?.message || '暂时无法读取 LDC 数据')}${login}
`;
- const number = (value) => Number.isFinite(Number(value)) ? Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 }) : String(value ?? '-');
- const available = Number(user.available_balance) || 0;
- const netIncome = (Number(user.total_receive) || 0) - (Number(user.total_payment) || 0);
- const quota = Number(user.remain_quota), dailyLimit = Number(user.daily_limit);
- const highlights = [
- ['可用余额', `LDC ${number(available)}`],
- ['社区余额', number(user.community_balance)],
- ['今日额度', quota < 0 ? '无限制' : dailyLimit > 0 ? `${number(quota)} / ${number(dailyLimit)}` : number(quota)],
- ];
- const groups = [
- ['积分与收支', [['未来积分', user.pending_balance], ['累计社区积分', user.total_community], ['累计收入', user.total_receive], ['累计支出', user.total_payment], ['累计流转', user.total_transfer], ['累计净收入', netIncome]]],
- ['支付与账户', [['支付分', user.pay_score], ['支付等级', ['普通', '黄金', '白金', '黑金'][user.pay_level] || user.pay_level], ['每日限额', user.daily_limit ?? '未设置'], ['支付密钥', user.is_pay_key ? '已设置' : '未设置'], ['管理员', user.is_admin ? '是' : '否'], ['头像', user.avatar_url ? '已同步' : '未提供']]],
- ];
- const groupHtml = ([title, items]) => `
${title}
${items.map(([label, value]) => `
${label}${esc(number(value))}
`).join('')}
`;
- const actions = [['home', '首页', 'externalLink'], ['trade', '活动', 'activity'], ['balance', '积分', 'database'], ['settings', '设置', 'settings']];
- return `
LINUX DO Credit@${esc(user.username || '')} · ${esc(user.nickname || '')} · ID ${esc(user.id)}
Lv${esc(user.trust_level ?? '-')}
- ${highlights.map(([label, value]) => `
${esc(value)}${label}
`).join('')}
- ${groups.map(groupHtml).join('')}
- ${error ? `${esc(error.message || '联网更新失败')}
` : ''}
- `;
- }
-
- function settingsUserProfileHtml(user) {
- const username = String(user?.username || ME_USERNAME || '');
- const name = String(user?.name || username);
- const trustLevel = userTrustLevel(user);
- const background = String(user && (
- user.card_background_upload_url || user.profile_background_upload_url
- ) || '').trim();
- const avatar = avatarWithFlairHtml(
- persistentAvatarHtml(user?.avatar_template, 144, 'ldp-user-info-avatar-image', name),
- user
- );
- const title = String(user && (user.title || user.flair_name) || '').trim();
- const titleFlair = userFlairHtml(user);
- const bio = String(user && (user.bio_excerpt || user.bio_raw) || '').trim();
- const website = String(user && (user.website_name || user.website) || '').trim();
- const websiteUrl = String(user && (user.website || user.website_name) || '').trim();
- const safeWebsite = websiteUrl && isSafeUserCardUrl(websiteUrl)
- ? (websiteUrl.startsWith('/') ? `${BASE}${websiteUrl}` : websiteUrl)
- : '';
- return `
- ${background && isSafeUserCardUrl(background) ? `
)})
` : ''}
-
-
${avatar || esc(name.slice(0, 1).toUpperCase())}
-
- ${bio ? `
${sanitizeUserCardBioHtml(bio)}
` : ''}
- ${userProfileFactsHtml(user, 'settings', { emptyNumberAsZero: true, trustLevel })}
-
- `;
- }
-
- function connectProgress(item) {
- if (item.reverse) {
- if (item.target === 0) return 100;
- return Math.max(0, Math.min(100, item.current / item.target * 100));
- }
- return Math.max(0, Math.min(100, item.target > 0 ? item.current / item.target * 100 : item.met ? 100 : 0));
- }
-
- function connectAchievementClass(item) {
- if (item.reverse || !(item.target > 0) || item.current < item.target) return '';
- const ratio = item.current / item.target;
- if (ratio >= 10) return ' is-goal is-over is-over-high is-over-ultra is-over-epic';
- if (ratio >= 5) return ' is-goal is-over is-over-high is-over-ultra';
- if (ratio >= 2) return ' is-goal is-over is-over-high';
- if (ratio > 1) return ' is-goal is-over';
- return ' is-goal';
- }
-
- function connectMetricBelowTarget(item) {
- return !item.reverse && Number(item.target) > 0 && Number(item.current) < Number(item.target);
- }
-
- const CONNECT_REQUIREMENT_HELP = [
- [/访问天数|days?visited/, (period) => `访问天数:过去 ${period} 天内,访问站点并至少阅读过 1 个帖子的不同自然日数量。`],
- [/浏览话题|topics?viewed/, (period) => `浏览话题:过去 ${period} 天内浏览过的公开话题数量;目标按同期公开话题总量的一定比例计算,并受站点上限限制。`],
- [/浏览帖子|posts?read/, (period) => `浏览帖子:过去 ${period} 天内实际读过的公开帖子数量;目标按同期公开帖子总量的一定比例计算,并受站点上限限制。`],
- [/回复话题|topics?replied/, (period) => `回复话题:过去 ${period} 天内回复过的不同公开话题数量;同一话题回复多次仍只计 1 个。`],
- [/获赞天数|likes?received.*days/, (period) => `获赞天数:过去 ${period} 天内至少收到过 1 个赞的不同自然日数量;同一天收到多个赞仍只计 1 天。`],
- [/获赞用户|likes?received.*users/, (period) => `获赞用户:过去 ${period} 天内给你点过赞的不同用户数量;同一用户多次点赞仍只计 1 人。`],
- [/^获赞$|likes?received/, (period) => `获赞:过去 ${period} 天内,你在公开话题中的帖子收到的点赞总数。`],
- [/^点赞$|likes?given/, (period) => `点赞:过去 ${period} 天内,你在公开话题中送出的点赞总数。`],
- [/被举报帖子|flaggedposts/, (period) => `被举报帖子:过去 ${period} 天内,被其他用户举报且经管理确认的不同帖子数量。这是上限项,数值越少越好。`],
- [/举报用户|userswhoflagged|flaggedbyusers/, (period) => `举报用户:过去 ${period} 天内,对你的帖子发起且经管理确认举报的不同用户数量;不是你举报了多少人。这是上限项,数值越少越好。`],
- [/被禁言|silenced/, () => '被禁言:过去 6 个月内的禁言处罚记录,当前仍在禁言也会计入;此项必须为 0。'],
- [/被封禁|suspended/, () => '被封禁:过去 6 个月内的封禁处罚记录,当前仍在封禁也会计入;此项必须为 0。'],
- ];
-
- function connectRequirementHelp(label, timePeriod = 100) {
- const normalized = String(label || '').replace(/\s+/g, '').toLowerCase();
- const period = Number.isFinite(Number(timePeriod)) && Number(timePeriod) > 0 ? Number(timePeriod) : 100;
- const match = CONNECT_REQUIREMENT_HELP.find(([pattern]) => pattern.test(normalized));
- return match ? match[1](period) : '';
- }
-
- function settingsConnectHtml(data, error) {
- if (!data) return `
Connect 升级进度升级要求来自 connect.linux.do
${esc(error?.message || '暂时无法读取 Connect 数据')}
`;
- const timePeriod = Number(data.timePeriod) > 0 ? Number(data.timePeriod) : 100;
- const valueLabel = (item) => `${Number(item.current).toLocaleString('zh-CN')} / ${Number(item.target).toLocaleString('zh-CN')}`;
- const progressStyle = (item) => `--ldp-connect-progress:${connectProgress(item).toFixed(1)}%`;
- const metricStateClass = (item, forceDanger = false) =>
- forceDanger || connectMetricBelowTarget(item) || !item.met ? ' is-danger' : '';
- const metricHelp = (item) => {
- const help = connectRequirementHelp(item.label, timePeriod);
- return help ? ` data-ldp-tooltip-label="${escAttr(help)}"` : '';
- };
- const barGroup = (title, items) => items.length ? `
${title}
${items.map((item) => `
${esc(item.label)}${esc(valueLabel(item))}
`).join('')}
` : '';
- const quotaItems = [...data.quotas, ...data.vetoes];
- const participationGroup = barGroup('参与互动', data.bars);
- const complianceGroup = quotaItems.length ? `
合规记录
${quotaItems.map((item) => `
${esc(item.label)}${esc(valueLabel(item))}
`).join('')}
` : '';
- return `
- 信任级别 ${esc(data.targetLevel == null ? '' : data.targetLevel)} 的要求${data.username ? `@${esc(data.username)} · ` : ''}过去 ${esc(timePeriod)} 天的数据
${data.met ? '已达到' : '未达到'}
- ${data.rings.length ? `${data.rings.map((item) => `
${esc(Number(item.current).toLocaleString('zh-CN'))}/ ${esc(Number(item.target).toLocaleString('zh-CN'))}
${esc(item.label)}`).join('')}
` : ''}
- ${participationGroup || complianceGroup ? `${participationGroup}${complianceGroup}
` : ''}
- ${error ? `当前显示缓存数据;${esc(error.message || '联网更新失败')}
` : ''}
- `;
- }
-
- function renderSettingsUserInfo(container, user, connectData, connectError, creditUser, creditError) {
- if (!container) return;
- const connectAvailable = hasDiscourseCapability('connect');
- const creditAvailable = SITE_ADAPTER.id === 'linux-do';
- const availableViews = [...(connectAvailable ? ['connect'] : []), 'profile', ...(creditAvailable ? ['credit'] : [])];
- const selectedView = availableViews.includes(container.dataset.userInfoView)
- ? container.dataset.userInfoView
- : availableViews[0];
- container.dataset.userInfoView = selectedView;
- container.innerHTML = `
- ${connectAvailable ? `` : ''}
- ${creditAvailable ? `` : ''}
-
-
- ${connectAvailable ? `
${settingsConnectHtml(connectData, connectError)}
` : ''}
-
${settingsUserProfileHtml(user)}
- ${creditAvailable ? `
${settingsCreditHtml(creditUser, creditError)}
` : ''}`;
- hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.userCard);
- }
-
- function userCardActionsHtml(user, username) {
- const privateMessageUnavailable = user && (
- user.can_send_private_message_to_user === false || user.can_send_private_messages === false
- );
- const followed = !!(user?.is_followed);
- const followUnavailable = !!(user && user.can_follow === false && !followed);
- const endorsementUnavailable = !!(user && user.category_expert_endorsements === null);
- const notificationLevel = userNotificationLevel(user);
- const notificationUnavailable = !!(user && user.can_mute_user === false
- && user.can_ignore_user === false && notificationLevel === 'normal');
- const actions = [
- { action: 'message', icon: 'mail', label: privateMessageUnavailable ? '私信(当前不可用)' : '私信', disabled: privateMessageUnavailable },
- { action: 'notifications', icon: 'bell', label: notificationUnavailable ? '消息设置(当前不可用)' : `消息设置:${USER_NOTIFICATION_LEVEL_LABELS[notificationLevel]}`, active: notificationLevel !== 'normal', disabled: notificationUnavailable, expanded: true },
- { action: 'endorse', icon: 'award', label: endorsementUnavailable ? '认可(当前不可用)' : '认可', disabled: endorsementUnavailable, visible: hasDiscourseCapability('categoryExperts', { user }) },
- { action: 'follow', icon: 'userPlus', label: followUnavailable ? '关注(当前不可用)' : (followed ? '取消关注' : '关注'), active: followed, disabled: followUnavailable, visible: hasDiscourseCapability('follows', { user }) },
- ].filter((item) => item.visible !== false);
- const notificationOptions = [
- { level: 'normal', icon: 'bell', label: '常规', description: '回复、引用或提到您时正常通知。' },
- ...(user && user.can_mute_user === false ? [] : [
- { level: 'mute', icon: 'bellOff', label: '免打扰', description: '不接收此用户的通知、私信和直接聊天。' },
- ]),
- ...(user && user.can_ignore_user === false ? [] : [
- { level: 'ignore', icon: 'eyeOff', label: '忽略', description: '隐藏此用户的内容,并停止相关通知。' },
- ]),
- ];
- return `
-
- ${actions.map((item) => ``).join('')}
-
-
-
`;
- }
-
- function userCardHomeHtml(username) {
- return `
${icon('externalLink')}`;
- }
-
- function userCardBackgroundHtml(user) {
- const backgroundUrl = String(user && (
- user.card_background_upload_url || user.cardBackgroundUploadUrl ||
- user.profile_background_upload_url || user.profileBackgroundUploadUrl
- ) || '').trim();
- if (!backgroundUrl || !isSafeUserCardUrl(backgroundUrl)) return '';
- const source = absoluteImageUrl(backgroundUrl);
- return `
`;
- }
-
- function userCardBadgeHash(badge) {
- const identity = `${badge?.id || ''}|${badge?.name || ''}|${badge?.icon || ''}`;
- let hash = 2166136261;
- for (let index = 0; index < identity.length; index++) {
- hash ^= identity.charCodeAt(index);
- hash = Math.imul(hash, 16777619);
- }
- return hash >>> 0;
- }
-
- const USER_CARD_BADGE_EXACT_KINDS = Object.freeze({
- '种子用户': 'seed', '龙行龘龘': 'dragon', '大预言家': 'crystal', '圆圆满满': 'moon',
- '浴火重生': 'fire', '海纳百川': 'waves', '一元复始': 'clock', '蛇来运转': 'snake',
- '破界者': 'hammer', '不二之选': 'star', '骐骥驰骋': 'horse', '幸运佬': 'clover',
- '金笔杆': 'pencil', '银笔杆': 'pencil', '铜笔杆': 'pencil', '文化宣导员': 'megaphone',
- '元气满满': 'gift', '基本用户': 'user', '成员': 'user', '活跃用户': 'fire', '领导者': 'crown',
- '阅读准则': 'book', '已认证': 'certificate', '已授权': 'certificate', '当月最佳新用户': 'star',
- '爱好者': 'calendar', '百尺竿头': 'calendar', '全年不落': 'calendar', '周年纪念日': 'calendar',
- '推广者': 'userplus', '活动家': 'userplus', '拥护者': 'userplus',
- '指导顾问': 'check', '无所不知': 'check', '解决方案机构': 'check',
- });
- const USER_CARD_BADGE_KIND_RULES = Object.freeze([
- ['mail', /mail|envelope|电子邮件|邮箱/],
- ['flag', /\bflag\b|report|举报/],
- ['at', /at-sign|mention|提及/],
- ['quote', /quote|引用/],
- ['box', /onebox|cube|box/],
- ['certificate', /certificate|认证|授权/],
- ['code', /code|github|commit|contributor|开源|贡献/],
- ['seed', /seed|sprout|幼苗|种子/],
- ['hammer', /hammer|gavel|破界/],
- ['calendar', /calendar|streak|连续|全年|纪念日/],
- ['userplus', /user-plus|invite|邀请|推广者|活动家|拥护者/],
- ['megaphone', /bullhorn|megaphone|announcement|公告|推广|广播/],
- ['heart', /heart|like|love|赞|爱心|喜爱|谢谢|回馈|善解人意/],
- ['eye', /\beye\b|view|reader|阅读|浏览|围观/],
- ['pencil', /pencil|edit|write|author|编辑|创作|作者|书写|笔杆|wiki/],
- ['document', /file|document|post|topic|article|文件|文档|帖子|主题|文章/],
- ['smile', /smile|laugh|emoji|表情|微笑|笑/],
- ['crown', /chess|crown|leader|king|领袖|领导|王者/],
- ['link', /link|share|链接|分享/],
- ['chat', /comment|chat|reply|conversation|回复|讨论|聊天|对话/],
- ['check', /check|solution|accepted|认可|解决|采纳|完成|顾问|无所不知/],
- ['star', /star|award|medal|荣誉|勋章|明星|精选|尊敬|敬仰|最佳/],
- ['shield', /shield|moderator|admin|管理|守护|安全/],
- ['clock', /clock|time|anniversary|year|周年|时间|资历/],
- ['fire', /fire|hot|active|热门|活跃|热心/],
- ['book', /book|learn|guide|tutorial|知识|教程|学习|指南/],
- ['gift', /gift|赠送|礼物/],
- ['user', /user|person|profile|member|用户|新人|成员|欢迎/],
- ]);
-
- function userCardBadgeGlyphKind(badge) {
- const name = String(badge?.name || '').trim();
- if (USER_CARD_BADGE_EXACT_KINDS[name]) return USER_CARD_BADGE_EXACT_KINDS[name];
- const source = `${badge?.icon || ''} ${name}`.toLowerCase();
- return (USER_CARD_BADGE_KIND_RULES.find(([, pattern]) => pattern.test(source)) || ['sigil'])[0];
- }
-
- const USER_CARD_BADGE_GLYPHS = Object.freeze({
- mail: '
',
- flag: '
',
- at: '
',
- quote: '
',
- box: '
',
- certificate: '
',
- code: '
',
- seed: '
',
- hammer: '
',
- calendar: '
',
- userplus: '
',
- megaphone: '
',
- heart: '
',
- eye: '
',
- pencil: '
',
- document: '
',
- smile: '
',
- crown: '
',
- link: '
',
- chat: '
',
- check: '
',
- star: '
',
- shield: '
',
- clock: '
',
- fire: '
',
- book: '
',
- gift: '
',
- user: '
',
- dragon: '
',
- crystal: '
',
- moon: '
',
- waves: '
',
- snake: '
',
- horse: '
',
- clover: '
',
- });
-
- function userCardBadgeGlyphHtml(kind, badge) {
- if (USER_CARD_BADGE_GLYPHS[kind]) return USER_CARD_BADGE_GLYPHS[kind];
- const hash = userCardBadgeHash(badge);
- const points = Array.from({ length: 8 }, (_, index) => {
- const angle = -Math.PI / 2 + index * Math.PI / 4;
- const radius = 7 + ((hash >>> (index * 4)) & 3);
- return `${12 + Math.cos(angle) * radius},${12 + Math.sin(angle) * radius}`;
- }).join(' ');
- const core = 2.5 + ((hash >>> 29) & 3) * .65;
- return `
`;
- }
-
- function userCardBadgeIconHtml(badge) {
- return `
`;
- }
-
- function userCardBadgesHtml(user) {
- const badges = uniqueUserBadges(user?.user_badges);
- if (!badges.length) return '';
- const featuredIds = new Set((Array.isArray(user?.featured_user_badge_ids)
- ? user.featured_user_badge_ids : []).map(Number));
- const badgeSortNumber = (badge, field, minimum = 0) => {
- const value = Number(badge?.[field]);
- return Number.isFinite(value) && value >= minimum ? value : Number.MAX_SAFE_INTEGER;
- };
- const ordered = badges.slice().sort((a, b) =>
- badgeSortNumber(a, 'badge_type_id', 1) - badgeSortNumber(b, 'badge_type_id', 1) ||
- badgeSortNumber(a, 'grant_count') - badgeSortNumber(b, 'grant_count') ||
- Number(featuredIds.has(Number(b?.id))) - Number(featuredIds.has(Number(a?.id))) ||
- String(b?.granted_at || '').localeCompare(String(a?.granted_at || ''))
- );
- return `
-
徽章(${ordered.length})
-
-
-
- ${ordered.map((badge) => {
- const name = String(badge.name || '').trim();
- const tier = Number(badge?.badge_type_id);
- return ``;
- }).join('')}
-
-
-
-
`;
- }
-
- function syncUserCardBadgeScrollControls(root) {
- if (!root) return;
- root.querySelectorAll('.ldp-user-card-badge-strip').forEach((strip) => {
- const list = strip.querySelector('.ldp-user-card-badge-list');
- const previous = strip.querySelector('[data-user-card-badge-scroll="-1"]');
- const next = strip.querySelector('[data-user-card-badge-scroll="1"]');
- if (!list || !previous || !next) return;
- const maxScrollLeft = Math.max(0, list.scrollWidth - list.clientWidth);
- const overflow = maxScrollLeft > 1;
- strip.classList.toggle('is-scrollable', overflow);
- previous.hidden = !overflow;
- next.hidden = !overflow;
- previous.disabled = !overflow || list.scrollLeft <= 1;
- next.disabled = !overflow || list.scrollLeft >= maxScrollLeft - 1;
- });
- }
-
- function userCardAvatarImageHtml(template, name, previewSource = '') {
- const source = avatarUrl(template, 96);
- const hasCachedSource = AVATAR_OBJECT_URL_CACHE.has(source);
- const preview = String(previewSource || '').trim();
- if (!preview || hasCachedSource || source.startsWith('data:') || source.startsWith('blob:'))
- return persistentAvatarHtml(template, 96, 'ldp-user-card-avatar', name);
- return `
})
`;
- }
-
- function renderFallbackUserCard(user, username, phase = 'ready', avatarPreviewSource = '', options = {}) {
- user = user || {};
- username = user.username || username || '';
- const name = user.name || username;
- const avatar = avatarWithFlairHtml(
- userCardAvatarImageHtml(user.avatar_template, name, avatarPreviewSource),
- user
- );
- const title = user.title || user.flair_name || '';
- const bio = user.bio_excerpt || user.bio_raw || '';
- const trustLevel = userTrustLevel(user);
- const avatarControl = !avatar
- ? ''
- : options.mediaInteractive === false
- ? `
${avatar}`
- : `
`;
- return `
- ${options.mediaInteractive === false ? '' : userCardBackgroundHtml(user)}
- ${userCardHomeHtml(username)}
-
- ${avatarControl}
-
-
-
${esc(name)}
- ${Number.isFinite(trustLevel) ? `
Lv${esc(trustLevel)}` : ''}
-
-
@${esc(username)}
- ${title ? `
${esc(title)}
` : ''}
-
-
- ${userProfileFactsHtml(user, 'card', { includePrimaryGroup: true, groupTooltip: true, trustLevel })}
- ${userCardGroupsHtml(user)}
- ${userCardFollowStatsHtml(user, username, { interactive: options.followStatsInteractive !== false })}
- ${bio ? `
${sanitizeUserCardBioHtml(bio)}
` : ''}
- ${userCardBadgesHtml(user)}
-
- ${userCardStat(userStatValue(user, ['post_count', 'posts_count']), '帖子', userActivityUrl(username, 'activity/replies'))}
- ${userCardStat(userStatValue(user, ['likes_received', 'likes_given']), '获赞', userActivityUrl(username, 'activity/likes-received'))}
- ${userCardStat(userStatValue(user, ['topic_count', 'topics_entered']), '主题', userActivityUrl(username, 'activity/topics'))}
-
- ${userCardProgressHtml(phase)}
- ${options.actions === false ? '' : `${userCardActionsHtml(user, username)}
-
`}`;
- }
-
- function userCardSeedFromLink(link, username) {
- const head = link?.closest('.ldp-post-head');
- const post = head?.closest('.ldp-post');
- const postData = post?._ldpPostData || {};
- const avatar = head?.querySelector('.ldp-avatar');
- const author = head?.querySelector('.ldp-author');
- return {
- username,
- name: (author?.textContent || '').trim() || username,
- _ldp_avatar_preview_url: avatar && (avatar.currentSrc || avatar.src) || '',
- avatar_template: avatar && (
- avatar.dataset.ldpAvatarTemplate ||
- avatar.dataset.ldpAvatarOriginalTemplate ||
- avatar.currentSrc ||
- avatar.src
- ) || '',
- flair_name: postData.flair_name,
- flair_url: postData.flair_url,
- flair_bg_color: postData.flair_bg_color,
- flair_color: postData.flair_color,
- };
- }
-
- async function showFallbackUserCard(link, isCurrent, onOpen = null, options = {}) {
- const username = getUserCardUsername(link);
- if (!username || !isCurrent()) return;
- const card = options.card || getUserCardSurface();
- const positionCard = isFunction(options.positionCard)
- ? options.positionCard
- : (target) => positionFallbackUserCard(target, link);
- const seedUser = userCardSeedFromLink(link, username);
- const avatarPreviewSource = seedUser._ldp_avatar_preview_url || '';
- const renderUser = (user, phase = 'ready') => {
- if (!isCurrent()) return;
- releasePersistentAvatarImages(card);
- card._ldpUserData = user || { username };
- card.innerHTML = renderFallbackUserCard(user, username, phase, avatarPreviewSource, options);
- hydratePersistentAvatarImages(card, POST_REQUEST_PRIORITY.userCard);
- syncUserCardBadgeScrollControls(card);
- requestAnimationFrame(() => card.isConnected && syncUserCardBadgeScrollControls(card));
- card.classList.add('open');
- positionCard(card, link);
- };
- renderUser(seedUser, 'profile');
- if (isFunction(onOpen)) onOpen(card._ldpUserData);
- try {
- await fetchUserCard(username, renderUser);
- } catch {
- if (!isCurrent()) return;
- card._ldpUserData = { username };
- card.innerHTML = `${userCardHomeHtml(username)}
用户信息加载失败
${options.actions === false ? '' : `${userCardActionsHtml({}, username)}
`}`;
- positionCard(card, link);
- }
- }
-
- function composerElementAvailable(el) {
- if (!el || el.hidden || el.disabled || el.getAttribute('aria-hidden') === 'true') return false;
- if (el.classList.contains('hidden') || el.classList.contains('d-none')) return false;
- return !el.closest('[hidden],[aria-hidden="true"]');
- }
-
- function nativeComposerVisible(el) {
- if (!composerElementAvailable(el) || el.classList.contains('closed')) return false;
- const style = getComputedStyle(el);
- if (style.display === 'none' || style.visibility === 'hidden' || style.contentVisibility === 'hidden') return false;
- const rect = el.getBoundingClientRect();
- return rect.width > 0 && rect.height > 0 &&
- rect.right > 0 && rect.bottom > 0 && rect.left < window.innerWidth && rect.top < window.innerHeight;
- }
-
- function visibleNativeFloatingSurface() {
- return [...document.querySelectorAll('.fk-d-menu,.emoji-picker')].find(nativeComposerVisible) || null;
- }
-
- function createReaderWindowController(overlay, modal) {
- const capsule = overlay.querySelector('.ldp-reader-window-capsule');
- const header = modal.querySelector(':scope > .ldp-header');
- const lockButton = capsule.querySelector('.ldp-reader-lock-button');
- const pinButton = capsule.querySelector('.ldp-reader-pin-button');
- let state = null;
- let interaction = null;
- let frame = 0;
- let destroyed = false;
- const readerWindowPrefKey = (suffix) => `readerWindow${suffix}`;
- const readerWindowPref = (suffix) => PREFS[readerWindowPrefKey(suffix)];
- const setReaderWindowPref = (suffix, value) => setPref(readerWindowPrefKey(suffix), value);
- const setReaderWindowPrefs = (values) => setPrefs(Object.entries(values).reduce((next, [suffix, value]) => {
- next[readerWindowPrefKey(suffix)] = value;
- return next;
- }, {}));
- const isLocked = () => !!readerWindowPref('Locked');
- const isPinned = () => !!readerWindowPref('Pinned');
-
- const viewportBounds = () => ({
- maxWidth: Math.max(1, window.innerWidth - READER_WINDOW_MARGIN * 2),
- maxHeight: Math.max(1, window.innerHeight - READER_WINDOW_MARGIN * 2),
- });
- const clampSize = (width, height) => {
- const bounds = viewportBounds();
- return {
- width: Math.min(bounds.maxWidth, Math.max(Math.min(READER_WINDOW_MIN_WIDTH, bounds.maxWidth), Math.round(width))),
- height: Math.min(bounds.maxHeight, Math.max(Math.min(READER_WINDOW_MIN_HEIGHT, bounds.maxHeight), Math.round(height))),
- };
- };
- const preferredSize = () => clampSize(
- readerWindowPref('Width') || Math.min(READER_WINDOW_DEFAULT_WIDTH, window.innerWidth * READER_WINDOW_DEFAULT_VIEWPORT_WIDTH),
- readerWindowPref('Height') || window.innerHeight * READER_WINDOW_DEFAULT_VIEWPORT_HEIGHT
- );
- const centeredState = () => {
- const size = preferredSize();
- return clampState({
- ...size,
- left: readerWindowPref('X') || Math.round((window.innerWidth - size.width) / 2),
- top: readerWindowPref('Y') || Math.round((window.innerHeight - size.height) / 2),
- });
- };
- const clampState = (next) => {
- const size = clampSize(next.width, next.height);
- return {
- ...size,
- left: Math.min(window.innerWidth - READER_WINDOW_MARGIN - size.width,
- Math.max(READER_WINDOW_MARGIN, Math.round(next.left))),
- top: Math.min(window.innerHeight - READER_WINDOW_MARGIN - size.height,
- Math.max(READER_WINDOW_MARGIN, Math.round(next.top))),
- };
- };
- const managed = () => readerPresentation(overlay.dataset.readerWorkspaceMode).floating &&
- window.innerWidth > READER_WINDOW_COMPACT_WIDTH;
- const syncHeaderDragSurface = () => {
- const active = managed() && !isLocked();
- header.toggleAttribute('data-ldp-reader-drag-surface', active);
- if (active) header.dataset.ldpTooltipLabel = '按住空白处可拖动';
- else delete header.dataset.ldpTooltipLabel;
- };
- const updateCapsulePlacement = (next) => {
- const docked = managed() && next && next.top < READER_WINDOW_HANDLE_DOCK_THRESHOLD;
- overlay.classList.toggle('ldp-window-handle-docked', !!docked);
- if (!next) return;
- setStyleValue(overlay, '--ldp-reader-window-center-x', `${next.left + next.width / 2}px`);
- setStyleValue(overlay, '--ldp-reader-window-top', `${next.top}px`);
- };
- const syncLockButton = () => {
- const locked = isLocked();
- const label = locked ? '解锁浮窗' : '锁定浮窗';
- lockButton.classList.toggle('active', locked);
- setPressed(lockButton, locked);
- setLabel(lockButton, label);
- if (lockButton.dataset.locked !== String(locked)) {
- lockButton.dataset.locked = String(locked);
- lockButton.innerHTML = icon(locked ? 'lock' : 'unlock');
- }
- };
- const applyGeometry = (next) => {
- setStyleValue(modal, 'left', `${next.left}px`);
- setStyleValue(modal, 'top', `${next.top}px`);
- setStyleValue(modal, 'width', `${next.width}px`);
- setStyleValue(modal, 'height', `${next.height}px`);
- updateCapsulePlacement(next);
- };
- const animateLockButton = () => {
- if (prefersReducedMotion()) return;
- lockButton.classList.remove('ldp-lock-state-changing');
- void lockButton.offsetWidth;
- lockButton.classList.add('ldp-lock-state-changing');
- };
- const apply = () => {
- if (destroyed) return;
- const displayState = managed() ? clampState(state || centeredState()) : null;
- overlay.classList.toggle('ldp-window-locked', isLocked());
- overlay.classList.toggle('ldp-window-pinned', isPinned());
- overlay.classList.toggle('ldp-window-managed', managed());
- syncLockButton();
- pinButton.classList.toggle('active', isPinned());
- setPressed(pinButton, isPinned());
- setLabel(pinButton, isPinned() ? '恢复点击外部关闭' : '点击外部时保持显示');
- syncHeaderDragSurface();
- if (!managed()) {
- updateCapsulePlacement(null);
- ['left', 'top', 'width', 'height'].forEach((name) => {
- if (modal.style.getPropertyValue(name)) modal.style.removeProperty(name);
- });
- return;
- }
- if (!state) state = { ...displayState };
- applyGeometry(displayState);
- };
- const stopInteraction = () => {
- if (!interaction) return;
- const { target, pointerId } = interaction;
- interaction = null;
- if (frame) cancelAnimationFrame(frame);
- frame = 0;
- overlay.classList.remove('ldp-window-interacting');
- modal.style.removeProperty('transform');
- capsule.style.removeProperty('transform');
- try {
- if (target.hasPointerCapture(pointerId)) target.releasePointerCapture(pointerId);
- } catch {}
- };
- const renderInteraction = () => {
- frame = 0;
- if (!interaction || !managed()) return;
- const dx = interaction.x - interaction.startX;
- const dy = interaction.y - interaction.startY;
- const start = interaction.start;
- if (interaction.type === 'drag') {
- interaction.preview = clampState({ ...start, left: start.left + dx, top: start.top + dy });
- modal.style.transform = `translate3d(${interaction.preview.left - start.left}px,${interaction.preview.top - start.top}px,0)`;
- updateCapsulePlacement(interaction.preview);
- return;
- }
- const direction = interaction.type;
- let left = start.left;
- let top = start.top;
- let right = start.left + start.width;
- let bottom = start.top + start.height;
- if (direction.includes('w')) left = Math.min(right - READER_WINDOW_MIN_WIDTH, Math.max(READER_WINDOW_MARGIN, start.left + dx));
- if (direction.includes('e')) right = Math.max(left + READER_WINDOW_MIN_WIDTH, Math.min(window.innerWidth - READER_WINDOW_MARGIN, right + dx));
- if (direction.includes('n')) top = Math.min(bottom - READER_WINDOW_MIN_HEIGHT, Math.max(READER_WINDOW_MARGIN, start.top + dy));
- if (direction.includes('s')) bottom = Math.max(top + READER_WINDOW_MIN_HEIGHT, Math.min(window.innerHeight - READER_WINDOW_MARGIN, bottom + dy));
- state = clampState({ left, top, width: right - left, height: bottom - top });
- applyGeometry(state);
- };
- const onPointerMove = (event) => {
- if (!interaction || event.pointerId !== interaction.pointerId) return;
- interaction.x = event.clientX;
- interaction.y = event.clientY;
- if (!frame) frame = requestAnimationFrame(renderInteraction);
- };
- const persistGeometry = () => {
- setReaderWindowPrefs({
- Width: state.width,
- Height: state.height,
- X: state.left,
- Y: state.top,
- });
- };
- const onPointerEnd = (event) => {
- if (!interaction || event.pointerId !== interaction.pointerId) return;
- if (frame) {
- cancelAnimationFrame(frame);
- frame = 0;
- renderInteraction();
- }
- const resized = interaction.type !== 'drag';
- if (!resized && interaction.preview) state = interaction.preview;
- stopInteraction();
- if (state) {
- apply();
- persistGeometry();
- overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
- }
- };
- const beginInteraction = (event, type, target) => {
- if (event.button !== 0 || isLocked() || !managed()) return;
- consumeEvent(event);
- state = clampState(state || centeredState());
- interaction = {
- type,
- target,
- pointerId: event.pointerId,
- startX: event.clientX,
- startY: event.clientY,
- x: event.clientX,
- y: event.clientY,
- start: { ...state },
- };
- overlay.classList.add('ldp-window-interacting');
- target.setPointerCapture(event.pointerId);
- };
- const windowScope = createLifecycleScope();
- const headerDragBlockedSelector = [
- 'a', 'button', 'input', 'select', 'textarea', 'label', 'summary', '[role="button"]',
- '[contenteditable="true"]', '.ldp-title-jump', '.ldp-meta-row', '.ldp-title-topic-row',
- '.ldp-notifications-popover', '.ldp-history-popover', '.ldp-bookmarks-popover',
- '.ldp-settings-popover', '.ldp-topic-edit-layer',
- ].join(',');
- const readerWindowHandle = (target) => {
- if (!(target instanceof Element)) return null;
- const resizeHandle = target.closest('[data-reader-resize]');
- if (resizeHandle && overlay.contains(resizeHandle)) return resizeHandle;
- const dragSurface = target.closest('.ldp-header[data-ldp-reader-drag-surface]');
- if (!dragSurface || dragSurface !== header || target.closest(headerDragBlockedSelector)) return null;
- return dragSurface;
- };
- const onHandlePointerDown = (event) => {
- const handle = readerWindowHandle(event.target);
- if (!handle) return;
- const type = handle === header ? 'drag' : handle.dataset.readerResize;
- if (!type) return;
- beginInteraction(event, type, handle);
- };
- windowScope.listen(overlay, 'pointerdown', onHandlePointerDown);
- windowScope.listen(overlay, 'pointermove', onPointerMove);
- windowScope.listen(overlay, 'pointerup', onPointerEnd);
- windowScope.listen(overlay, 'pointercancel', onPointerEnd);
- const onLockAnimationEnd = () => lockButton.classList.remove('ldp-lock-state-changing');
- const onLockClick = (event) => {
- consumeEvent(event);
- stopInteraction();
- setReaderWindowPref('Locked', !isLocked());
- apply();
- animateLockButton();
- overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
- };
- windowScope.listen(lockButton, 'click', onLockClick);
- windowScope.listen(lockButton, 'animationend', onLockAnimationEnd);
- const onPinClick = (event) => {
- consumeEvent(event);
- setReaderWindowPref('Pinned', !isPinned());
- apply();
- overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
- };
- windowScope.listen(pinButton, 'click', onPinClick);
- const onResize = () => {
- stopInteraction();
- apply();
- };
- windowScope.listen(window, 'resize', onResize);
- apply();
-
- return {
- syncMode() {
- stopInteraction();
- apply();
- },
- getState() {
- return { ...(state ? (managed() ? clampState(state) : state) : centeredState()) };
- },
- setGeometry(width, height, left, top) {
- if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
- const current = state || centeredState();
- const size = clampSize(Number(width) || current.width, Number(height) || current.height);
- state = clampState({
- ...size,
- left: Number.isFinite(Number(left)) ? Number(left) : current.left,
- top: Number.isFinite(Number(top)) ? Number(top) : current.top,
- });
- persistGeometry();
- apply();
- overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
- },
- setLocked(locked) {
- if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
- stopInteraction();
- setReaderWindowPref('Locked', !!locked);
- apply();
- },
- setPinned(pinned) {
- if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
- setReaderWindowPref('Pinned', !!pinned);
- apply();
- },
- reset() {
- if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
- stopInteraction();
- setReaderWindowPrefs({
- Width: 0,
- Height: 0,
- X: 0,
- Y: 0,
- Locked: false,
- Pinned: false,
- });
- state = centeredState();
- apply();
- overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
- },
- isDefault() {
- return !readerWindowPref('Width') && !readerWindowPref('Height') &&
- !readerWindowPref('X') && !readerWindowPref('Y') &&
- !isLocked() && !isPinned();
- },
- isLocked,
- isPinned,
- destroy() {
- if (destroyed) return;
- stopInteraction();
- destroyed = true;
- header.removeAttribute('data-ldp-reader-drag-surface');
- delete header.dataset.ldpTooltipLabel;
- windowScope.destroy();
- },
- };
- }
-
- function syncNativeTopicStatsComponent(card, reactionCountsByTopicId) {
- const posts = card?.querySelector(':scope > td.posts');
- const views = card?.querySelector(':scope > td.views');
- const activity = card?.querySelector(':scope > td.activity');
- if (!posts || !views || !activity) return;
- let component = posts.querySelector(':scope > .ldp-topic-stats-component');
- const sourceMarkup = (cell) => [...cell.childNodes]
- .filter((node) => node !== component)
- .map((node) => node.nodeType === 1 ? node.outerHTML : String(node.textContent || ''))
- .join('');
- const isOldTopic = views.dataset.ldpNativeOldTopic === 'true';
- const directId = card.dataset.topicId || card.getAttribute('data-topic-id');
- const topicLink = card.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]');
- const topicId = String(directId || extractTopicRouteFromUrl(topicLink?.href)?.topicId || '');
- const responseCount = topicId && reactionCountsByTopicId.has(topicId)
- ? reactionCountsByTopicId.get(topicId)
- : null;
- const sourceSignature = `${sourceMarkup(posts)}\u0001${sourceMarkup(views)}\u0001${sourceMarkup(activity)}\u0001${isOldTopic ? 'old' : 'recent'}\u0001${responseCount == null ? '' : responseCount}`;
- if (component && component.dataset.ldpSourceSignature === sourceSignature && component.querySelectorAll(':scope > .ldp-topic-stats-row').length === 2) return;
- const cloneSourceNodes = (cell) => [...cell.childNodes]
- .filter((node) => node !== component)
- .map((node) => node.cloneNode(true));
- const postNodes = cloneSourceNodes(posts);
- const viewNodes = cloneSourceNodes(views);
- const activityNodes = cloneSourceNodes(activity);
- if (!component) {
- component = makeElement('div');
- component.className = 'ldp-topic-stats-component';
- posts.append(component);
- }
- const makeStat = (kind, label, nodes) => {
- const item = makeElement('span');
- item.className = `ldp-topic-stat ldp-topic-stat--${kind}`;
- const caption = makeElement('span');
- caption.className = 'ldp-topic-stat-label';
- caption.textContent = label;
- const value = makeElement('span');
- value.className = 'ldp-topic-stat-value';
- nodes.forEach((node) => value.append(node));
- item.append(caption, value);
- return item;
- };
- const makeRow = (...items) => {
- const row = makeElement('span');
- row.className = 'ldp-topic-stats-row';
- row.append(...items);
- return row;
- };
- component.replaceChildren(
- makeRow(
- makeStat('reply', '回复', postNodes),
- makeStat(isOldTopic ? 'old' : 'date', isOldTopic ? '旧帖' : '最近回复', viewNodes)
- ),
- makeRow(
- makeStat('activity', '活跃', activityNodes),
- makeStat('response', '回应', [document.createTextNode(responseCount == null ? '—' : String(responseCount))])
- )
- );
- component.dataset.ldpSourceSignature = sourceSignature;
- }
-
- function syncNativeTopicActivityStat(card) {
- const activity = card?.querySelector(':scope > td.activity');
- const component = card?.querySelector(':scope > td.posts > .ldp-topic-stats-component');
- const source = activity?.querySelector('.relative-date');
- const target = component?.querySelector('.ldp-topic-stat--activity .relative-date');
- if (!activity || !component || !source || !target) return false;
- if (target.textContent !== source.textContent) target.textContent = source.textContent;
- const signatureParts = String(component.dataset.ldpSourceSignature || '').split('\u0001');
- if (signatureParts.length === 5) {
- signatureParts[2] = [...activity.childNodes]
- .map((node) => node.nodeType === 1 ? node.outerHTML : String(node.textContent || ''))
- .join('');
- component.dataset.ldpSourceSignature = signatureParts.join('\u0001');
- }
- return true;
- }
-
- function nativeTopicOpReactionCounts() {
- const counts = new Map();
- const router = lookupDiscourse('service:router');
- const routeName = discourseModelValue(router, 'currentRouteName');
- const controllerNames = [
- routeName && `controller:${routeName}`,
- routeName && `controller:${String(routeName).replace(/\./g, '/')}`,
- 'controller:discovery/list',
- 'controller:tag/show',
- 'controller:tags/intersection',
- ].filter(Boolean);
- const seenControllers = new Set();
- for (const name of controllerNames) {
- const controller = lookupDiscourse(name);
- if (!controller || seenControllers.has(controller)) continue;
- seenControllers.add(controller);
- const model = discourseModelValue(controller, 'model');
- const list = discourseModelValue(model, 'list') || discourseModelValue(controller, 'list') || model;
- const topicsValue = discourseModelValue(list, 'topics') || discourseModelValue(list, 'content');
- const topics = Array.isArray(topicsValue)
- ? topicsValue
- : isFunction(topicsValue?.toArray)
- ? topicsValue.toArray()
- : [];
- topics.forEach((topic) => {
- const topicId = String(discourseModelValue(topic, 'id') || '');
- if (!topicId || counts.has(topicId)) return;
- const reactionsData = discourseModelValue(topic, 'op_reactions_data') || discourseModelValue(topic, 'opReactionsData');
- const rawCount = reactionsData
- ? discourseModelValue(reactionsData, 'reaction_users_count') ?? discourseModelValue(reactionsData, 'reactionUsersCount')
- : discourseModelValue(topic, 'op_like_count') ?? discourseModelValue(topic, 'opLikeCount');
- const count = Number(rawCount);
- counts.set(topicId, Number.isFinite(count) && count >= 0 ? Math.trunc(count) : null);
- });
- }
- return counts;
- }
-
- function syncNativeTopicQueueButton(card) {
- if (!card || card.closest('.ldp-overlay')) return;
- const topicLink = card.querySelector(
- 'a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]'
- );
- const route = extractTopicRouteFromUrl(topicLink && (topicLink.getAttribute('href') || topicLink.href));
- const topicId = Number(card.dataset.topicId || card.getAttribute('data-topic-id') || route?.topicId);
- const line = topicLink && (topicLink.closest('.link-top-line') || topicLink.parentElement);
- if (!(topicId > 0) || !line) return;
- let button = line.querySelector(':scope > .ldp-reader-queue-add');
- if (!button) {
- button = makeElement('button');
- button.className = 'ldp-reader-queue-add';
- button.type = 'button';
- line.appendChild(button);
- }
- button.dataset.readerQueueTopicId = String(topicId);
- const added = !!readerQueueEntry(topicId);
- if (button.dataset.readerQueueAdded === String(added) && button.firstElementChild) return;
- syncReaderQueueAddButton(button, added);
- }
-
- function retargetMainOutletObserver(observer, currentRoot, rootOptions, onRoot) {
- const nextRoot = document.querySelector('#main-outlet') ||
- document.querySelector('.list-container,.topic-list,.latest-topic-list') ||
- document.querySelector('#ember-app');
- if (currentRoot === nextRoot && nextRoot?.isConnected) return currentRoot;
- observer.disconnect();
- observer.observe(document.body, { childList: true });
- if (nextRoot) {
- let ancestor = nextRoot.parentElement;
- while (ancestor && ancestor !== document.body) {
- observer.observe(ancestor, { childList: true });
- ancestor = ancestor.parentElement;
- }
- observer.observe(nextRoot, rootOptions);
- if (onRoot) onRoot(nextRoot);
- }
- return nextRoot;
- }
-
- function installNativeTopicQueueButtons() {
- if (!document.body || document.body._ldpReaderQueueButtonsObserver) return;
- const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result';
- const pendingCards = new Set();
- let frame = 0;
- let observerRoot = null;
- const collect = (node) => {
- const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
- if (!element || element.closest('.ldp-overlay')) return;
- if (element.matches(cardSelector)) pendingCards.add(element);
- element.querySelectorAll && element.querySelectorAll(cardSelector).forEach((card) => pendingCards.add(card));
- const parentCard = element.closest(cardSelector);
- if (parentCard) pendingCards.add(parentCard);
- };
- const flush = () => {
- frame = 0;
- const cards = [...pendingCards].filter((card) => card.isConnected);
- pendingCards.clear();
- cards.forEach(syncNativeTopicQueueButton);
- };
- const schedule = () => {
- if (!frame && pendingCards.size) frame = requestAnimationFrame(flush);
- };
- let observer = null;
- const syncObserverTargets = () => {
- observerRoot = retargetMainOutletObserver(
- observer, observerRoot, { childList: true, subtree: true }, collect);
- };
- observer = new MutationObserver((records) => {
- records.forEach((record) => {
- if (record.target === document.body || !observerRoot?.isConnected ||
- !observerRoot.contains(record.target)) syncObserverTargets();
- collect(record.target);
- record.addedNodes.forEach(collect);
- });
- schedule();
- });
- syncObserverTargets();
- document.querySelectorAll(cardSelector).forEach((card) => pendingCards.add(card));
- schedule();
- document.body._ldpReaderQueueButtonsObserver = observer;
- }
-
- function syncNativeTopicTitleTools(card, dndControl) {
- const line = dndControl?.closest('.link-top-line');
- if (!card || !line || !card.contains(line)) return;
- const normalizeText = (node) => String(node?.textContent || '').replace(/\s+/g, ' ').trim();
- const expertLabel = [...line.querySelectorAll('*')].find((node) => normalizeText(node) === '专家回应');
- if (!expertLabel) return;
- const directChild = (node) => {
- let current = node;
- while (current && current.parentElement !== line) current = current.parentElement;
- return current && current.parentElement === line ? current : null;
- };
- const expertItem = directChild(expertLabel);
- const dndItem = directChild(dndControl);
- if (!expertItem || !dndItem) return;
- if (expertItem === dndItem && expertItem.classList.contains('ldp-native-topic-title-tools')) return;
- const children = [...line.children];
- const start = children.indexOf(expertItem);
- const end = children.indexOf(dndItem);
- if (start < 0 || end < start) return;
- const tools = children.slice(start, end + 1);
- const group = makeElement('span');
- group.className = 'ldp-native-topic-title-tools';
- expertItem.before(group);
- group.append(...tools);
- }
-
- function markNativeDoNotDisturbBadges(scope = document, reactionCountsByTopicId = null) {
- const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item';
- const cards = [];
- if (isFunction(scope?.matches) && scope.matches(cardSelector)) cards.push(scope);
- if (isFunction(scope?.querySelectorAll)) cards.push(...scope.querySelectorAll(cardSelector));
- if (!cards.length) return;
- const reactionCounts = reactionCountsByTopicId || nativeTopicOpReactionCounts();
- cards.forEach((card) => {
- const cells = [...card.querySelectorAll(':scope > td')];
- const dateCell = card.querySelector(':scope > td.views') || cells[3] || null;
- cells.forEach((cell) => {
- const text = cell === dateCell ? String(cell.textContent || '').replace(/\s+/g, ' ').trim() : '';
- const hasTopicDate = /\b(?:\d{4}[/-])?\d{1,2}[/-]\d{1,2}\s+\d{1,2}:\d{2}\b/.test(text);
- const hasOldTopicDate = /\b\d{4}[/-]\d{1,2}[/-]\d{1,2}\s+\d{1,2}:\d{2}\b/.test(text);
- if (hasTopicDate && cell.dataset.ldpNativeTopicDate !== 'true') cell.dataset.ldpNativeTopicDate = 'true';
- else if (!hasTopicDate && 'ldpNativeTopicDate' in cell.dataset) delete cell.dataset.ldpNativeTopicDate;
- if (hasOldTopicDate && cell.dataset.ldpNativeOldTopic !== 'true') cell.dataset.ldpNativeOldTopic = 'true';
- else if (!hasOldTopicDate && 'ldpNativeOldTopic' in cell.dataset) delete cell.dataset.ldpNativeOldTopic;
- });
- const controls = [...card.querySelectorAll('a,button,[role="button"]')];
- const matchingControl = controls.find((node) => node.textContent.replace(/\s+/g, ' ').trim() === '免打扰');
- const fallback = matchingControl ? null : [...card.querySelectorAll('span')].find((node) => node.textContent.replace(/\s+/g, ' ').trim() === '免打扰');
- const dndControl = matchingControl || (fallback && (fallback.closest('a,button,[role="button"]') || fallback));
- if (dndControl && dndControl.dataset.ldpNativeDnd !== 'true') dndControl.dataset.ldpNativeDnd = 'true';
- syncNativeTopicTitleTools(card, dndControl);
- syncNativeTopicQueueButton(card);
- syncNativeTopicStatsComponent(card, reactionCounts);
- });
- }
-
- function clearNativeEmbeddedTopicEnhancements(scope = document) {
- if (!scope || !isFunction(scope.querySelectorAll)) return;
- scope.querySelectorAll('.ldp-topic-stats-component').forEach((component) => component.remove());
- scope.querySelectorAll('.ldp-native-topic-title-tools').forEach((group) => {
- group.replaceWith(...[...group.childNodes]);
- });
- scope.querySelectorAll('[data-ldp-native-dnd]').forEach((node) => node.removeAttribute('data-ldp-native-dnd'));
- scope.querySelectorAll('[data-ldp-native-topic-date]').forEach((node) => node.removeAttribute('data-ldp-native-topic-date'));
- scope.querySelectorAll('[data-ldp-native-old-topic]').forEach((node) => node.removeAttribute('data-ldp-native-old-topic'));
- }
-
- function createReaderWorkspaceController(overlay, modal, isDirectTopicRoute) {
- const resizeHandle = modal.querySelector('.ldp-reader-embed-resize');
- const hostScrollbar = overlay.querySelector('.ldp-reader-host-scrollbar');
- const hostScrollbarThumb = hostScrollbar?.querySelector('.ldp-reader-host-scrollbar-thumb');
- const hostTopButton = overlay.querySelector('.ldp-reader-host-top');
- const root = PAGE_ROOT;
- const availableModes = isDirectTopicRoute ? TOPIC_READER_MODES : LIST_READER_MODES;
- const workspaceStyleNodes = [root, overlay];
- const workspaceScope = createLifecycleScope();
- let mode = 'floating';
- let pointer = null;
- let resizeFrame = 0;
- let embedWidth = currentListReaderEmbedWidth();
- let destroyed = false;
- let embeddedHostRoots = new Map();
- let embeddedHostObserver = null;
- let embeddedHostObserverRoot = null;
- let embeddedHostFrame = 0;
- let embeddedHostCardFrame = 0;
- const embeddedHostChangedCards = new Set();
- const embeddedHostActivityCards = new Set();
- let hostScrollbarFrame = 0;
- let hostScrollbarResizeObserver = null;
- let hostScrollbarPointer = null;
- let hostScrollbarState = { maxScroll: 0, maxThumbTop: 0, thumbHeight: 0 };
- let hostScrollbarGeometry = { viewportHeight: 1, scrollHeight: 1, maxScroll: 0, trackHeight: 1 };
- let hostScrollbarGeometryDirty = true;
- let hostTopButtonTimer = 0;
- let hostTopCountdownTimer = 0;
- let hostScrollTop = 0;
- let hostUpwardDistance = 0;
- let hostUpwardStartedAt = 0;
- let hostLastScrollAt = 0;
- let hostPointerClientX = NaN;
- let hostPointerClientY = NaN;
- let hostTopJumping = false;
- let nativeListRowHeight = 0;
- const embeddedMode = () => readerPresentation(mode).embedded;
-
- const setWorkspaceStyle = (name, value) => {
- workspaceStyleNodes.forEach((node) => node.style.setProperty(name, value));
- };
- const clearWorkspaceStyle = (name) => {
- workspaceStyleNodes.forEach((node) => node.style.removeProperty(name));
- };
- const measureNativeListRowHeight = () => {
- const heights = [...document.querySelectorAll('tr.topic-list-item,.topic-list-item,.latest-topic-list-item')]
- .filter((node) => !node.closest('.ldp-overlay'))
- .map((node) => {
- const rect = node.getBoundingClientRect();
- return rect.width > 0 && rect.height >= 48 && rect.height <= 320 ? Math.round(rect.height) : 0;
- })
- .filter(Boolean)
- .sort((left, right) => left - right);
- return heights.length ? heights[Math.floor((heights.length - 1) / 2)] : 0;
- };
- const setEmbedResizing = (active) => {
- root.classList.toggle('ldp-reader-embed-resizing', !!active);
- overlay.classList.toggle('ldp-reader-embed-resizing', !!active);
- };
- const hostPageScrollTop = (maxScroll = Number.POSITIVE_INFINITY) => {
- const scrollingElement = document.scrollingElement || root;
- return Math.min(maxScroll, Math.max(0, Number(
- window.scrollY || scrollingElement?.scrollTop || 0,
- )));
- };
- const pageScrollMetrics = () => {
- const scrollingElement = document.scrollingElement || root;
- const viewportHeight = Math.max(1, Number(window.innerHeight || root.clientHeight || 0));
- const scrollHeight = Math.max(
- viewportHeight,
- Number(scrollingElement?.scrollHeight || 0),
- Number(document.body?.scrollHeight || 0),
- );
- const maxScroll = Math.max(0, scrollHeight - viewportHeight);
- const scrollTop = hostPageScrollTop(maxScroll);
- return { viewportHeight, scrollHeight, maxScroll, scrollTop };
- };
- const syncHostScrollbar = () => {
- hostScrollbarFrame = 0;
- if (destroyed || !embeddedMode() || !hostScrollbar || !hostScrollbarThumb) return;
- if (hostScrollbarGeometryDirty) {
- const metrics = pageScrollMetrics();
- hostScrollbarGeometry = {
- viewportHeight: metrics.viewportHeight,
- scrollHeight: metrics.scrollHeight,
- maxScroll: metrics.maxScroll,
- trackHeight: Math.max(1, hostScrollbar.clientHeight),
- };
- hostScrollbarGeometryDirty = false;
- }
- const metrics = {
- ...hostScrollbarGeometry,
- scrollTop: hostPageScrollTop(hostScrollbarGeometry.maxScroll),
- };
- const trackHeight = metrics.trackHeight;
- const thumbHeight = metrics.maxScroll > 0
- ? Math.min(trackHeight, Math.max(24, trackHeight * metrics.viewportHeight / metrics.scrollHeight))
- : trackHeight;
- const maxThumbTop = Math.max(0, trackHeight - thumbHeight);
- const thumbTop = metrics.maxScroll > 0
- ? maxThumbTop * metrics.scrollTop / metrics.maxScroll
- : 0;
- hostScrollbarState = { maxScroll: metrics.maxScroll, maxThumbTop, thumbHeight };
- hostScrollbar.classList.toggle('ldp-reader-host-scrollbar-inactive', metrics.maxScroll <= 0);
- setAttributeValue(hostScrollbar, 'aria-disabled', metrics.maxScroll <= 0);
- setAttributeValue(hostScrollbar, 'aria-valuemax', Math.round(metrics.maxScroll));
- setAttributeValue(hostScrollbar, 'aria-valuenow', Math.round(metrics.scrollTop));
- const nextHeight = `${Math.round(thumbHeight * 100) / 100}px`;
- const nextTransform = `translateY(${Math.round(thumbTop * 100) / 100}px)`;
- if (hostScrollbarThumb.style.height !== nextHeight) hostScrollbarThumb.style.height = nextHeight;
- if (hostScrollbarThumb.style.transform !== nextTransform) {
- hostScrollbarThumb.style.transform = nextTransform;
- }
- };
- const scheduleHostScrollbarSync = () => {
- if (hostScrollbarFrame || destroyed || !embeddedMode()) return;
- hostScrollbarFrame = requestAnimationFrame(syncHostScrollbar);
- };
- const invalidateHostScrollbarGeometry = () => {
- hostScrollbarGeometryDirty = true;
- scheduleHostScrollbarSync();
- };
- const hideHostTopButton = () => {
- if (hostTopButtonTimer) clearTimeout(hostTopButtonTimer);
- if (hostTopCountdownTimer) clearInterval(hostTopCountdownTimer);
- hostTopButtonTimer = 0;
- hostTopCountdownTimer = 0;
- if (hostTopButton) hostTopButton.hidden = true;
- };
- const resetHostUpwardScroll = (scrollTop = hostPageScrollTop()) => {
- hostScrollTop = scrollTop;
- hostUpwardDistance = 0;
- hostUpwardStartedAt = 0;
- hostLastScrollAt = 0;
- };
- const hostHorizontalBounds = () => mode === 'embed-left'
- ? { left: embedWidth, right: window.innerWidth }
- : { left: 0, right: window.innerWidth - embedWidth };
- const rememberHostPointer = (event) => {
- if (!embeddedMode() || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return;
- const bounds = hostHorizontalBounds();
- if (event.clientX < bounds.left || event.clientX > bounds.right) return;
- hostPointerClientX = event.clientX;
- hostPointerClientY = event.clientY;
- };
- const showHostTopButton = () => {
- if (!hostTopButton || !Number.isFinite(hostPointerClientX) || !Number.isFinite(hostPointerClientY)) return;
- if (hostPageScrollTop() <= 8) {
- hideHostTopButton();
- return;
- }
- if (hostTopButton.hidden) {
- const bounds = hostHorizontalBounds();
- const buttonSize = 30;
- const edgeGap = 8;
- const pointerGap = 12;
- const left = Math.max(bounds.left + edgeGap, Math.min(
- bounds.right - buttonSize - edgeGap,
- hostPointerClientX + pointerGap,
- ));
- const top = Math.max(edgeGap, Math.min(
- window.innerHeight - buttonSize - edgeGap,
- hostPointerClientY - buttonSize / 2,
- ));
- hostTopButton.style.left = `${Math.round(left)}px`;
- hostTopButton.style.top = `${Math.round(top)}px`;
- hostTopButton.hidden = false;
- }
- if (hostTopButtonTimer) clearTimeout(hostTopButtonTimer);
- if (hostTopCountdownTimer) clearInterval(hostTopCountdownTimer);
- const countdown = hostTopButton.querySelector('.ldp-reader-host-top-countdown');
- const hideAt = performance.now() + 3000;
- const updateCountdown = () => {
- if (countdown) countdown.textContent = String(Math.max(1, Math.ceil((hideAt - performance.now()) / 1000)));
- };
- updateCountdown();
- hostTopCountdownTimer = setInterval(updateCountdown, 200);
- hostTopButtonTimer = setTimeout(hideHostTopButton, 3000);
- };
- const onHostScroll = () => {
- scheduleHostScrollbarSync();
- if (!embeddedMode()) return;
- const scrollTop = Math.max(0, Number(
- window.scrollY || document.scrollingElement?.scrollTop || 0,
- ));
- if (hostTopJumping) {
- resetHostUpwardScroll(scrollTop);
- return;
- }
- const now = performance.now();
- const upwardDelta = hostScrollTop - scrollTop;
- hostScrollTop = scrollTop;
- if (scrollTop <= 8) hideHostTopButton();
- if (upwardDelta <= 0) {
- hostUpwardDistance = 0;
- hostUpwardStartedAt = 0;
- hostLastScrollAt = now;
- return;
- }
- if (!hostUpwardStartedAt || now - hostLastScrollAt > 240) {
- hostUpwardStartedAt = now;
- hostUpwardDistance = 0;
- }
- hostLastScrollAt = now;
- hostUpwardDistance += upwardDelta;
- if (hostUpwardDistance >= 240 && now - hostUpwardStartedAt <= 450) {
- showHostTopButton();
- hostUpwardStartedAt = now;
- hostUpwardDistance = 0;
- }
- };
- const onHostTopClick = () => {
- if (!embeddedMode()) return;
- hideHostTopButton();
- hostTopJumping = true;
- window.scrollTo({ top: 0, behavior: 'auto' });
- requestAnimationFrame(() => {
- hostTopJumping = false;
- resetHostUpwardScroll(0);
- });
- };
- const startHostScrollbarSync = () => {
- if (!hostScrollbar || !hostScrollbarThumb) return;
- if (!hostScrollbarResizeObserver && isFunction(ResizeObserver)) {
- hostScrollbarResizeObserver = new ResizeObserver(invalidateHostScrollbarGeometry);
- hostScrollbarResizeObserver.observe(root);
- if (document.body) hostScrollbarResizeObserver.observe(document.body);
- hostScrollbarResizeObserver.observe(hostScrollbar);
- }
- hostScrollbarGeometryDirty = true;
- resetHostUpwardScroll();
- scheduleHostScrollbarSync();
- };
- const stopHostScrollbarSync = () => {
- if (hostScrollbarFrame) cancelAnimationFrame(hostScrollbarFrame);
- hostScrollbarFrame = 0;
- if (hostScrollbarResizeObserver) hostScrollbarResizeObserver.disconnect();
- hostScrollbarResizeObserver = null;
- hostScrollbarPointer = null;
- hostScrollbarState = { maxScroll: 0, maxThumbTop: 0, thumbHeight: 0 };
- hostScrollbarGeometry = { viewportHeight: 1, scrollHeight: 1, maxScroll: 0, trackHeight: 1 };
- hostScrollbarGeometryDirty = true;
- hideHostTopButton();
- resetHostUpwardScroll(0);
- if (!hostScrollbar || !hostScrollbarThumb) return;
- hostScrollbar.classList.remove('ldp-reader-host-scrollbar-dragging');
- hostScrollbar.classList.add('ldp-reader-host-scrollbar-inactive');
- hostScrollbar.setAttribute('aria-disabled', 'true');
- hostScrollbar.setAttribute('aria-valuemax', '0');
- hostScrollbar.setAttribute('aria-valuenow', '0');
- hostScrollbarThumb.style.removeProperty('height');
- hostScrollbarThumb.style.removeProperty('transform');
- };
- const scrollHostFromPointer = (clientY) => {
- if (!hostScrollbar || !hostScrollbarPointer || hostScrollbarState.maxScroll <= 0) return;
- const rect = hostScrollbar.getBoundingClientRect();
- const thumbTop = Math.max(0, Math.min(
- hostScrollbarState.maxThumbTop,
- clientY - rect.top - hostScrollbarPointer.offsetY,
- ));
- const ratio = hostScrollbarState.maxThumbTop > 0 ? thumbTop / hostScrollbarState.maxThumbTop : 0;
- window.scrollTo({ top: ratio * hostScrollbarState.maxScroll, behavior: 'auto' });
- };
- const onHostScrollbarPointerDown = (event) => {
- if (event.button !== 0 || !embeddedMode() || !hostScrollbar || !hostScrollbarThumb) return;
- if (hostScrollbarFrame) cancelAnimationFrame(hostScrollbarFrame);
- hostScrollbarFrame = 0;
- hostScrollbarGeometryDirty = true;
- syncHostScrollbar();
- if (hostScrollbarState.maxScroll <= 0) return;
- consumeEvent(event);
- const thumbRect = hostScrollbarThumb.getBoundingClientRect();
- const onThumb = event.target === hostScrollbarThumb;
- hostScrollbarPointer = {
- pointerId: event.pointerId,
- offsetY: onThumb ? event.clientY - thumbRect.top : hostScrollbarState.thumbHeight / 2,
- };
- hostScrollbar.classList.add('ldp-reader-host-scrollbar-dragging');
- hostScrollbar.setPointerCapture(event.pointerId);
- try { hostScrollbar.focus({ preventScroll: true }); } catch { hostScrollbar.focus(); }
- scrollHostFromPointer(event.clientY);
- };
- const onHostScrollbarPointerMove = (event) => {
- if (!hostScrollbarPointer || event.pointerId !== hostScrollbarPointer.pointerId) return;
- event.preventDefault();
- scrollHostFromPointer(event.clientY);
- };
- const stopHostScrollbarPointer = (event) => {
- if (!hostScrollbarPointer || event && event.pointerId !== hostScrollbarPointer.pointerId) return;
- const pointerId = hostScrollbarPointer.pointerId;
- hostScrollbarPointer = null;
- if (hostScrollbar) {
- hostScrollbar.classList.remove('ldp-reader-host-scrollbar-dragging');
- try {
- if (hostScrollbar.hasPointerCapture(pointerId)) hostScrollbar.releasePointerCapture(pointerId);
- } catch {}
- }
- };
- const onHostScrollbarKeyDown = (event) => {
- if (!embeddedMode()) return;
- const metrics = pageScrollMetrics();
- let nextTop = metrics.scrollTop;
- if (event.key === 'ArrowUp') nextTop -= 40;
- else if (event.key === 'ArrowDown') nextTop += 40;
- else if (event.key === 'PageUp') nextTop -= metrics.viewportHeight * .9;
- else if (event.key === 'PageDown') nextTop += metrics.viewportHeight * .9;
- else if (event.key === 'Home') nextTop = 0;
- else if (event.key === 'End') nextTop = metrics.maxScroll;
- else return;
- consumeEvent(event);
- window.scrollTo({ top: Math.max(0, Math.min(metrics.maxScroll, nextTop)), behavior: 'auto' });
- };
- const topLevelBodyChild = (node) => {
- let current = node && node.nodeType === 1 ? node : null;
- while (current?.parentElement && current.parentElement !== document.body) current = current.parentElement;
- return current && current.parentElement === document.body ? current : null;
- };
- const syncEmbeddedHostObserverTargets = () => {
- if (!embeddedHostObserver || !document.body) return;
- embeddedHostObserverRoot = retargetMainOutletObserver(
- embeddedHostObserver,
- embeddedHostObserverRoot,
- { childList: true, subtree: true, characterData: true },
- );
- };
- const syncEmbeddedHostRoots = () => {
- embeddedHostFrame = 0;
- if (destroyed || !document.body || !embeddedMode()) return;
- const nextRoots = new Map();
- const rank = { sidebar: 1, header: 2, shell: 3 };
- const mark = (node, role) => {
- const topLevel = topLevelBodyChild(node);
- if (!topLevel || topLevel === overlay || topLevel.contains(overlay)) return;
- const previousRole = nextRoots.get(topLevel);
- if (!previousRole || rank[role] > rank[previousRole]) nextRoots.set(topLevel, role);
- };
- document.querySelectorAll('#ember-app,#main-outlet-wrapper,#main-outlet,.list-container,.topic-list,.navigation-container').forEach((node) => mark(node, 'shell'));
- document.querySelectorAll('.d-header,body > header').forEach((node) => mark(node, 'header'));
- document.querySelectorAll('#d-sidebar,.sidebar-wrapper,.sidebar-container').forEach((node) => mark(node, 'sidebar'));
- for (const node of embeddedHostRoots.keys()) {
- if (!nextRoots.has(node)) node.removeAttribute('data-ldp-reader-host-root');
- }
- nextRoots.forEach((role, node) => {
- node.setAttribute('data-ldp-reader-host-root', role);
- if (role === 'shell') markNativeDoNotDisturbBadges(node);
- });
- embeddedHostRoots = nextRoots;
- syncEmbeddedHostObserverTargets();
- };
- const scheduleEmbeddedHostRootSync = () => {
- if (embeddedHostFrame || destroyed || !embeddedMode()) return;
- embeddedHostFrame = requestAnimationFrame(syncEmbeddedHostRoots);
- };
- const flushEmbeddedHostCards = () => {
- embeddedHostCardFrame = 0;
- if (destroyed || !embeddedMode()) {
- embeddedHostChangedCards.clear();
- embeddedHostActivityCards.clear();
- return;
- }
- const activityCards = [...embeddedHostActivityCards]
- .filter((card) => card.isConnected && !embeddedHostChangedCards.has(card));
- embeddedHostActivityCards.clear();
- activityCards.forEach((card) => {
- if (!syncNativeTopicActivityStat(card)) embeddedHostChangedCards.add(card);
- });
- const cards = [...embeddedHostChangedCards].filter((card) => card.isConnected);
- embeddedHostChangedCards.clear();
- if (!cards.length) return;
- const reactionCountsByTopicId = nativeTopicOpReactionCounts();
- cards.forEach((card) => markNativeDoNotDisturbBadges(card, reactionCountsByTopicId));
- };
- const scheduleEmbeddedHostCardSet = (cards, pendingCards) => {
- cards.forEach((card) => pendingCards.add(card));
- if (!pendingCards.size || embeddedHostCardFrame || destroyed || !embeddedMode()) return;
- embeddedHostCardFrame = requestAnimationFrame(flushEmbeddedHostCards);
- };
- const startEmbeddedHostRootObserver = () => {
- if (!embeddedHostObserver && document.body) {
- embeddedHostObserver = new MutationObserver((records) => {
- const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item';
- const changedCards = new Set();
- const collectNearestCard = (node) => {
- const element = node && (node.nodeType === 1 ? node : node.parentElement);
- if (!element || element.closest('.ldp-overlay,.ldp-topic-stats-component')) return;
- const card = element.matches(cardSelector) ? element : element.closest(cardSelector);
- if (card) changedCards.add(card);
- };
- const collectAddedCards = (node) => {
- const element = node && (node.nodeType === 1 ? node : node.parentElement);
- if (!element || element.closest('.ldp-overlay,.ldp-topic-stats-component')) return;
- const card = element.matches(cardSelector) ? element : element.closest(cardSelector);
- if (card) {
- changedCards.add(card);
- return;
- }
- if (element.querySelectorAll) element.querySelectorAll(cardSelector).forEach((item) => changedCards.add(item));
- };
- records.forEach((record) => {
- const activityTarget = record.type === 'childList' && record.target.nodeType === 1 &&
- record.target.matches('.relative-date') && record.target.closest('td.activity');
- const onlyActivityTextChanged = activityTarget && record.addedNodes.length > 0 &&
- record.removedNodes.length > 0 &&
- [...record.addedNodes, ...record.removedNodes].every((node) => node.nodeType === 3);
- if (onlyActivityTextChanged) {
- const card = activityTarget.closest(cardSelector);
- if (card) scheduleEmbeddedHostCardSet([card], embeddedHostActivityCards);
- return;
- }
- if (record.type === 'childList' && (
- record.target === document.body ||
- !embeddedHostObserverRoot?.isConnected ||
- !embeddedHostObserverRoot.contains(record.target)
- )) scheduleEmbeddedHostRootSync();
- const changedNodes = record.type === 'childList'
- ? [...record.addedNodes, ...record.removedNodes]
- : [];
- const onlyOwnedStatsChanged = changedNodes.length > 0 && changedNodes.every((node) =>
- node.nodeType === 1 && (node.matches('.ldp-topic-stats-component') || node.closest('.ldp-topic-stats-component'))
- );
- if (!onlyOwnedStatsChanged) collectNearestCard(record.target);
- record.addedNodes?.forEach(collectAddedCards);
- });
- scheduleEmbeddedHostCardSet(changedCards, embeddedHostChangedCards);
- });
- }
- syncEmbeddedHostRoots();
- };
- const clearEmbeddedHostRoots = () => {
- if (embeddedHostFrame) cancelAnimationFrame(embeddedHostFrame);
- if (embeddedHostCardFrame) cancelAnimationFrame(embeddedHostCardFrame);
- embeddedHostFrame = 0;
- embeddedHostCardFrame = 0;
- embeddedHostChangedCards.clear();
- embeddedHostActivityCards.clear();
- if (embeddedHostObserver) embeddedHostObserver.disconnect();
- embeddedHostObserver = null;
- embeddedHostObserverRoot = null;
- clearNativeEmbeddedTopicEnhancements();
- for (const node of embeddedHostRoots.keys()) node.removeAttribute('data-ldp-reader-host-root');
- embeddedHostRoots = new Map();
- };
- const clearEmbeddedHost = () => {
- setEmbedResizing(false);
- stopHostScrollbarPointer();
- stopHostScrollbarSync();
- clearEmbeddedHostRoots();
- root.classList.remove('ldp-reader-embedded-left', 'ldp-reader-embedded-right');
- if (!root.classList.contains('ldp-reader-sidepanel-active')) root.classList.remove('ldp-reader-workspace');
- clearWorkspaceStyle('--ldp-reader-workspace-width');
- delete overlay.dataset.readerWorkspaceWidth;
- clearWorkspaceStyle('--ldp-reader-host-min-width');
- clearWorkspaceStyle('--ldp-reader-native-row-height');
- nativeListRowHeight = 0;
- root.style.removeProperty('--ldp-reader-list-zebra-color');
- root.style.removeProperty('--ldp-divider-line-color');
- root.style.removeProperty('--ldp-divider-line-width');
- document.querySelectorAll('[data-ldp-reader-active-topic]').forEach((card) => card.removeAttribute('data-ldp-reader-active-topic'));
- };
- const maximumEmbedWidth = () => Math.max(READER_EMBED_MIN_WIDTH, Math.floor(window.innerWidth - READER_HOST_MIN_WIDTH));
- const canEmbed = () => window.innerWidth >= READER_EMBED_MIN_WIDTH + READER_HOST_MIN_WIDTH;
- const clampEmbedWidth = (value) => Math.min(maximumEmbedWidth(), Math.max(READER_EMBED_MIN_WIDTH, Math.round(value)));
- const setEmbedWidth = (value) => {
- embedWidth = clampEmbedWidth(value);
- overlay.dataset.readerWorkspaceWidth = String(embedWidth);
- setWorkspaceStyle('--ldp-reader-workspace-width', `${embedWidth}px`);
- return embedWidth;
- };
- const effectiveMode = (requested) => {
- if (!availableModes.includes(requested)) return isDirectTopicRoute ? 'fullpage' : 'floating';
- if (!readerPresentation(requested).embedded) return requested;
- return canEmbed() ? requested : 'floating';
- };
- const apply = (requested = mode) => {
- const previousMode = mode;
- mode = effectiveMode(requested);
- const presentation = readerPresentation(mode);
- const embedded = presentation.embedded;
- if (readerPresentation(previousMode).embedded && !embedded) {
- stopHostScrollbarPointer();
- stopHostScrollbarSync();
- }
- overlay.dataset.readerWorkspaceMode = mode;
- overlay.classList.toggle('ldp-fullpage', presentation.fullPage);
- overlay.classList.toggle('ldp-reader-embedded', embedded);
- overlay.classList.toggle('ldp-reader-embedded-left', presentation.side === 'left');
- overlay.classList.toggle('ldp-reader-embedded-right', presentation.side === 'right');
- if (!embedded) {
- clearEmbeddedHost();
- return mode;
- }
- ensureReaderStyleMounted();
- if (!root.classList.contains('ldp-reader-workspace')) nativeListRowHeight = measureNativeListRowHeight();
- const width = currentListReaderEmbedWidth();
- root.classList.add('ldp-reader-workspace');
- root.classList.toggle('ldp-reader-embedded-left', presentation.side === 'left');
- root.classList.toggle('ldp-reader-embedded-right', presentation.side === 'right');
- setEmbedWidth(width);
- setWorkspaceStyle('--ldp-reader-host-min-width', `${READER_HOST_MIN_WIDTH}px`);
- if (nativeListRowHeight) setWorkspaceStyle('--ldp-reader-native-row-height', `${nativeListRowHeight}px`);
- const appearance = currentAppearanceProfile();
- const themeMode = resolvedReaderThemeMode();
- const dividerLineColor = appearanceThemeColor(appearance, 'dividerLineColor', themeMode);
- const dividerLineDefault = APPEARANCE_PROFILE_DEFAULT[
- appearanceThemeValueKey('dividerLineColor', themeMode)
- ];
- root.style.setProperty('--ldp-reader-list-zebra-color',
- appearanceThemeColor(appearance, 'listZebraColor', themeMode));
- root.classList.toggle('ldp-structure-colors-disabled', !appearance.structureColorsEnabled);
- if (!appearance.structureColorsEnabled || dividerLineColor === dividerLineDefault)
- root.style.removeProperty('--ldp-divider-line-color');
- else root.style.setProperty('--ldp-divider-line-color', dividerLineColor);
- if (appearance.dividerLineWidth === DIVIDER_LINE_WIDTH_DEFAULT) root.style.removeProperty('--ldp-divider-line-width');
- else root.style.setProperty('--ldp-divider-line-width', `${appearance.dividerLineWidth}px`);
- startEmbeddedHostRootObserver();
- startHostScrollbarSync();
- return mode;
- };
- const emitChange = () => {
- if (!destroyed) overlay.dispatchEvent(new CustomEvent('ldp-reader-workspace-change'));
- };
- const setMode = (nextMode) => {
- if (!availableModes.includes(nextMode)) return false;
- if (isDirectTopicRoute) setPref('topicReaderMode', nextMode);
- else setPrefs({ listReaderMode: nextMode });
- apply(nextMode);
- emitChange();
- return true;
- };
- const applyResizePreview = () => {
- resizeFrame = 0;
- if (!pointer || !embeddedMode()) return;
- const width = mode === 'embed-left'
- ? pointer.clientX
- : window.innerWidth - pointer.clientX;
- setEmbedWidth(width);
- };
- const onResizePointerMove = (event) => {
- if (!pointer || event.pointerId !== pointer.pointerId) return;
- pointer.clientX = event.clientX;
- if (!resizeFrame) resizeFrame = requestAnimationFrame(applyResizePreview);
- };
- const stopResize = (event) => {
- if (!pointer || (event && event.pointerId !== pointer.pointerId)) {
- setEmbedResizing(false);
- return;
- }
- if (resizeFrame) {
- cancelAnimationFrame(resizeFrame);
- resizeFrame = 0;
- applyResizePreview();
- }
- setEmbedResizing(false);
- setPref('listReaderEmbedWidth', embedWidth);
- try {
- if (resizeHandle?.hasPointerCapture(pointer.pointerId)) resizeHandle.releasePointerCapture(pointer.pointerId);
- } catch {}
- pointer = null;
- emitChange();
- };
- const onResizePointerDown = (event) => {
- if (event.button !== 0 || !embeddedMode()) return;
- consumeEvent(event);
- pointer = { pointerId: event.pointerId, clientX: event.clientX };
- setEmbedResizing(true);
- resizeHandle.setPointerCapture(event.pointerId);
- };
- const onViewportResize = () => {
- const requested = isDirectTopicRoute
- ? currentTopicReaderMode()
- : currentListReaderMode();
- apply(requested);
- emitChange();
- };
- if (resizeHandle) {
- workspaceScope.listen(resizeHandle, 'pointerdown', onResizePointerDown);
- workspaceScope.listen(resizeHandle, 'pointermove', onResizePointerMove);
- ['pointerup', 'pointercancel'].forEach((type) =>
- workspaceScope.listen(resizeHandle, type, stopResize));
- }
- workspaceScope.listen(window, 'resize', onViewportResize);
- workspaceScope.listen(window, 'pointermove', rememberHostPointer, { passive: true });
- workspaceScope.listen(window, 'wheel', rememberHostPointer, { capture: true, passive: true });
- workspaceScope.listen(window, 'scroll', onHostScroll, { passive: true });
- if (hostTopButton) workspaceScope.listen(hostTopButton, 'click', onHostTopClick);
- if (hostScrollbar) {
- workspaceScope.listen(hostScrollbar, 'pointerdown', onHostScrollbarPointerDown);
- workspaceScope.listen(hostScrollbar, 'pointermove', onHostScrollbarPointerMove);
- ['pointerup', 'pointercancel'].forEach((type) =>
- workspaceScope.listen(hostScrollbar, type, stopHostScrollbarPointer));
- workspaceScope.listen(hostScrollbar, 'keydown', onHostScrollbarKeyDown);
- }
- apply(isDirectTopicRoute
- ? currentTopicReaderMode()
- : currentListReaderMode());
-
- return {
- mode: () => mode,
- isEmbedded: embeddedMode,
- isFullPage: () => readerPresentation(mode).fullPage,
- isFloating: () => readerPresentation(mode).floating,
- setMode,
- sync: onViewportResize,
- destroy() {
- if (destroyed) return;
- destroyed = true;
- if (resizeFrame) cancelAnimationFrame(resizeFrame);
- stopResize();
- clearEmbeddedHost();
- workspaceScope.destroy();
- },
- };
- }
-
- function createNativeComposerWindowController(readerSession) {
- const root = PAGE_ROOT;
- const rootStyle = root.style;
- const geometryProperties = [
- '--ldp-composer-left', '--ldp-composer-top', '--ldp-composer-width',
- '--ldp-composer-height', '--ldp-composer-transform',
- ];
- const positionedAttribute = 'data-ldp-reader-composer-positioned';
- const resizeDirections = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
- let composer = null;
- let composerRoot = null;
- let composerOverflowLayers = [];
- let composerOverflowFrame = 0;
- let chrome = null;
- let composerObserver = null;
- let composerHostObserver = null;
- let state = null;
- let pointer = null;
- let pointerFrame = 0;
- let pointerClientX = 0;
- let pointerClientY = 0;
- let chromeFrame = 0;
- let wheelLayer = null;
- let statePersistTimer = 0;
- let nativeFloatingFrame = 0;
- let nativeFloatingObserver = null;
- const ownedTopLayerElements = new Set();
- const nativeFloatingSurfaceSelector = '.fk-d-menu,.emoji-picker';
- let destroyed = false;
-
- const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
- const numeric = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback;
- const cssPixels = (value) => `${Math.round(value * 100) / 100}px`;
- const sessionActive = () => !destroyed && (!readerSession || readerSession.isCurrent('opening', 'active'));
- const desktopWindow = () => window.innerWidth > 760;
- const syncWheelLayer = (visible) => {
- if (visible && !wheelLayer) {
- wheelLayer = makeElement('div'); wheelLayer.className = 'ldp-composer-wheel-layer sciapp-ldp-owned';
- appendReaderPortalNode(wheelLayer); bindFloatingSurfaceWheel(wheelLayer);
- }
- if (wheelLayer) wheelLayer.hidden = !visible;
- if (visible) bindFloatingSurfaceWheel(composer);
- };
- const elementInTopLayer = (element) => {
- try {
- return !!(element?.matches(':popover-open'));
- } catch {
- return false;
- }
- };
- const releaseOwnedTopLayer = (element) => {
- if (!element || !ownedTopLayerElements.delete(element)) return;
- try {
- if (elementInTopLayer(element) && isFunction(element.hidePopover)) element.hidePopover();
- } catch {}
- element.removeAttribute('popover');
- delete element.dataset.ldpReaderTopLayer;
- };
- const promoteOwnedTopLayer = (element, type) => {
- if (!element || !isFunction(element.showPopover)) return false;
- if (element.hasAttribute('popover') && !ownedTopLayerElements.has(element)) return elementInTopLayer(element);
- if (!ownedTopLayerElements.has(element)) {
- element.setAttribute('popover', 'manual');
- element.dataset.ldpReaderTopLayer = type;
- ownedTopLayerElements.add(element);
- }
- try {
- if (!elementInTopLayer(element)) element.showPopover();
- return elementInTopLayer(element);
- } catch {
- releaseOwnedTopLayer(element);
- return false;
- }
- };
- const nativeFloatingSurfaceVisible = (element) => {
- if (!element || !element.isConnected || element.closest('#reply-control') || element.contains(composer) || element.closest('dialog[open]')) return false;
- if (element.classList.contains('closed')) return false;
- if (!composerElementAvailable(element)) return false;
- const elementStyle = getComputedStyle(element);
- if (elementStyle.display === 'none' || elementStyle.visibility === 'hidden') return false;
- const rect = element.getBoundingClientRect();
- return rect.width > 0 && rect.height > 0;
- };
- const syncNativeFloatingTopLayers = () => {
- nativeFloatingFrame = 0;
- if (!elementInTopLayer(composer)) return;
- const visible = [...document.querySelectorAll(nativeFloatingSurfaceSelector)]
- .filter(nativeFloatingSurfaceVisible);
- const surfaces = visible.filter((element) => !visible.some((parent) => parent !== element && parent.contains(element)));
- ownedTopLayerElements.forEach((element) => {
- if (element.dataset.ldpReaderTopLayer === 'portal' && !surfaces.includes(element)) releaseOwnedTopLayer(element);
- });
- surfaces.forEach((element) => promoteOwnedTopLayer(element, 'portal'));
- };
- const scheduleNativeFloatingTopLayerSync = () => {
- if (nativeFloatingFrame) return;
- nativeFloatingFrame = requestAnimationFrame(syncNativeFloatingTopLayers);
- };
- const nativeFloatingMutationNodeIsRelevant = (node) => {
- const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
- if (!element) return false;
- return element.matches(nativeFloatingSurfaceSelector) ||
- !!element.closest(nativeFloatingSurfaceSelector) ||
- !!element.querySelector(nativeFloatingSurfaceSelector);
- };
- const nativeFloatingMutationIsRelevant = (mutation) => {
- if (mutation.type === 'attributes') return nativeFloatingMutationNodeIsRelevant(mutation.target);
- return [...mutation.addedNodes, ...mutation.removedNodes].some(nativeFloatingMutationNodeIsRelevant);
- };
- const startNativeFloatingTopLayerSync = () => {
- if (!nativeFloatingObserver && document.body) {
- nativeFloatingObserver = new MutationObserver((mutations) => {
- if (mutations.some(nativeFloatingMutationIsRelevant)) scheduleNativeFloatingTopLayerSync();
- });
- nativeFloatingObserver.observe(document.body, {
- childList: true,
- subtree: true,
- attributes: true,
- attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'open'],
- });
- }
- scheduleNativeFloatingTopLayerSync();
- };
- const releaseOwnedComposerTopLayers = () => {
- if (nativeFloatingFrame) cancelAnimationFrame(nativeFloatingFrame);
- nativeFloatingFrame = 0;
- if (nativeFloatingObserver) nativeFloatingObserver.disconnect();
- nativeFloatingObserver = null;
- [...ownedTopLayerElements].reverse().forEach(releaseOwnedTopLayer);
- };
- const releaseComposerTopLayers = () => {
- releaseOwnedComposerTopLayers();
- root.classList.remove('ldp-composer-host-isolated');
- };
- const syncComposerTopLayers = () => {
- const composerOpen = !!(composer?.isConnected &&
- !composer.classList.contains('closed') && composerElementAvailable(composer));
- syncWheelLayer(composerOpen);
- root.classList.toggle('ldp-composer-host-isolated', composerOpen);
- if (!composerOpen || !promoteOwnedTopLayer(composer, 'composer')) {
- releaseOwnedComposerTopLayers();
- return false;
- }
- if (chrome && !chrome.hidden) promoteOwnedTopLayer(chrome, 'chrome');
- startNativeFloatingTopLayerSync();
- return true;
- };
- const clearComposerRoot = () => {
- if (composerRoot) composerRoot.removeAttribute('data-ldp-reader-composer-root');
- composerRoot = null;
- };
- const syncComposerRoot = () => {
- let next = composer;
- while (next?.parentElement && next.parentElement !== document.body) next = next.parentElement;
- if (!next || next.parentElement !== document.body) next = null;
- if (next === composerRoot) return;
- clearComposerRoot();
- composerRoot = next;
- if (composerRoot) composerRoot.setAttribute('data-ldp-reader-composer-root', '1');
- };
- const clearComposerOverflowLayers = () => {
- if (composerOverflowFrame) cancelAnimationFrame(composerOverflowFrame);
- composerOverflowFrame = 0;
- composerOverflowLayers.forEach((layer) => layer.removeAttribute('data-ldp-reader-composer-overflow'));
- composerOverflowLayers = [];
- };
- const syncComposerOverflowLayers = () => {
- composerOverflowFrame = 0;
- composerOverflowLayers.forEach((layer) => layer.removeAttribute('data-ldp-reader-composer-overflow'));
- composerOverflowLayers = [];
- if (!composer || !composer.isConnected || composer.classList.contains('closed')) return;
- const composerRect = composer.getBoundingClientRect();
- if (composerRect.width <= 0 || composerRect.height <= 0) return;
- const candidates = [composer, ...composer.querySelectorAll('*')].map((candidate) => {
- if (candidate.clientWidth <= 0 || candidate.scrollWidth <= candidate.clientWidth + 1) return false;
- if (candidate.closest('.d-editor-button-bar,[role="toolbar"]')) return false;
- const overflowX = getComputedStyle(candidate).overflowX;
- if (overflowX !== 'auto' && overflowX !== 'scroll') return false;
- const rect = candidate.getBoundingClientRect();
- const coveredWidth = Math.max(0, Math.min(rect.right, composerRect.right) - Math.max(rect.left, composerRect.left));
- const coveredHeight = Math.max(0, Math.min(rect.bottom, composerRect.bottom) - Math.max(rect.top, composerRect.top));
- const widthCoverage = coveredWidth / composerRect.width;
- const heightCoverage = coveredHeight / composerRect.height;
- if (widthCoverage < .85 || heightCoverage < .75) return false;
- return { candidate, coverage: widthCoverage * heightCoverage };
- }).filter(Boolean).sort((left, right) => right.coverage - left.coverage);
- composerOverflowLayers = candidates.length ? [candidates[0].candidate] : [];
- composerOverflowLayers.forEach((layer) => layer.setAttribute('data-ldp-reader-composer-overflow', '1'));
- };
- const scheduleComposerOverflowSync = () => {
- if (composerOverflowFrame) return;
- composerOverflowFrame = requestAnimationFrame(syncComposerOverflowLayers);
- };
- const limits = () => {
- const margin = Math.max(0, Math.min(
- 16,
- Math.floor((window.innerWidth - 1) / 2),
- Math.floor((window.innerHeight - 1) / 2),
- ));
- const left = margin;
- const top = margin;
- const right = window.innerWidth - margin;
- const bottom = window.innerHeight - margin;
- const maxWidth = Math.max(1, right - left);
- const maxHeight = Math.max(1, bottom - top);
- return {
- left,
- top,
- right,
- bottom,
- maxWidth,
- maxHeight,
- minWidth: Math.min(520, maxWidth),
- minHeight: Math.min(300, maxHeight),
- };
- };
- const defaultState = () => {
- const bounds = limits();
- const width = Math.min(1200, bounds.maxWidth);
- const height = clamp(bounds.maxHeight * .7, bounds.minHeight, bounds.maxHeight);
- return {
- left: bounds.left + (bounds.maxWidth - width) / 2,
- top: bounds.top + (bounds.maxHeight - height) / 2,
- width,
- height,
- };
- };
- const normalizeState = (next) => {
- const bounds = limits();
- const fallback = defaultState();
- const width = clamp(numeric(next?.width, fallback.width), bounds.minWidth, bounds.maxWidth);
- const height = clamp(numeric(next?.height, fallback.height), bounds.minHeight, bounds.maxHeight);
- return {
- left: clamp(numeric(next?.left, fallback.left), bounds.left, bounds.right - width),
- top: clamp(numeric(next?.top, fallback.top), bounds.top, bounds.bottom - height),
- width,
- height,
- };
- };
- const preferredState = () => {
- const fallback = defaultState();
- return normalizeState({
- left: PREFS[`${COMPOSER_WINDOW_PREFIX}X`] || fallback.left,
- top: PREFS[`${COMPOSER_WINDOW_PREFIX}Y`] || fallback.top,
- width: PREFS[`${COMPOSER_WINDOW_PREFIX}Width`] || fallback.width,
- height: PREFS[`${COMPOSER_WINDOW_PREFIX}Height`] || fallback.height,
- });
- };
- const syncFont = (fontProfile = currentFontProfile()) => {
- const values = normalizeFontProfile(fontProfile, READER_FONT_DEFAULT);
- if (!state || !composer) {
- if (composer) {
- composer.style.removeProperty('--ldp-composer-font-size');
- clearInterfaceFontScale(composer.style);
- clearReaderFontAppearance(composer.style);
- }
- applyComposerFontScale();
- return;
- }
- const bounds = limits();
- const widthTarget = Math.min(980, bounds.maxWidth);
- const heightTarget = Math.min(720, bounds.maxHeight);
- const widthProgress = clamp((state.width - bounds.minWidth) / Math.max(1, widthTarget - bounds.minWidth), 0, 1);
- const heightProgress = clamp((state.height - bounds.minHeight) / Math.max(1, heightTarget - bounds.minHeight), 0, 1);
- const automaticPixels = 12 + Math.min(widthProgress, heightProgress) * 4;
- const preferenceRatio = normalizeFontScale(values.composer, COMPOSER_FONT_SCALE_DEFAULT) /
- COMPOSER_FONT_SCALE_DEFAULT;
- const pixels = clamp(automaticPixels * preferenceRatio, 8, 40);
- applyInterfaceFontScale(composer.style, values.interface);
- applyReaderFontAppearance(composer.style, values);
- composer.style.setProperty('--ldp-composer-font-size', `${Math.round(pixels * 100) / 100}px`);
- };
- const syncChromeNow = (syncSize = true) => {
- chromeFrame = 0;
- if (!chrome) return;
- const available = sessionActive() && state && desktopWindow() && composerElementAvailable(composer) &&
- !composer.classList.contains('closed') && !composer.classList.contains('fullscreen');
- chrome.hidden = !available;
- if (!available) return;
- if (syncSize) {
- chrome.style.setProperty('--ldp-composer-chrome-width', cssPixels(state.width));
- chrome.style.setProperty('--ldp-composer-chrome-height', cssPixels(state.height));
- }
- chrome.style.transform = `translate3d(${cssPixels(state.left)},${cssPixels(state.top)},0)`;
- };
- const scheduleChromeSync = () => {
- if (chromeFrame) return;
- chromeFrame = requestAnimationFrame(syncChromeNow);
- };
- const applyState = (next) => {
- const previous = state;
- state = normalizeState(next);
- if (!composer) return;
- const composerStyle = composer.style;
- const sizeChanged = !previous || previous.width !== state.width || previous.height !== state.height;
- if (!previous) {
- composerStyle.setProperty('--ldp-composer-left', '0px');
- composerStyle.setProperty('--ldp-composer-top', '0px');
- composerStyle.setProperty('--ldp-composer-transform', 'none');
- }
- if (sizeChanged) {
- composerStyle.setProperty('--ldp-composer-width', cssPixels(state.width));
- composerStyle.setProperty('--ldp-composer-height', cssPixels(state.height));
- }
- if (desktopWindow() && !composer.classList.contains('fullscreen')) {
- composerStyle.setProperty('translate', `${cssPixels(state.left)} ${cssPixels(state.top)}`);
- } else {
- composerStyle.removeProperty('translate');
- }
- if (sizeChanged) syncFont();
- if (chromeFrame) cancelAnimationFrame(chromeFrame);
- syncChromeNow(sizeChanged);
- composer.setAttribute(positionedAttribute, '1');
- };
- const persistState = () => {
- if (statePersistTimer) clearTimeout(statePersistTimer);
- statePersistTimer = 0;
- if (!state || !desktopWindow()) return;
- setPrefs({
- [`${COMPOSER_WINDOW_PREFIX}Width`]: Math.round(state.width),
- [`${COMPOSER_WINDOW_PREFIX}Height`]: Math.round(state.height),
- [`${COMPOSER_WINDOW_PREFIX}X`]: Math.round(state.left),
- [`${COMPOSER_WINDOW_PREFIX}Y`]: Math.round(state.top),
- });
- };
- const scheduleStatePersist = () => {
- if (statePersistTimer) clearTimeout(statePersistTimer);
- statePersistTimer = setTimeout(persistState, 160);
- };
- const syncState = () => applyState(state || preferredState());
- const clearGeometry = () => {
- const composerStyle = composer?.style;
- if (composer) composer.removeAttribute(positionedAttribute);
- geometryProperties.forEach((property) => {
- rootStyle.removeProperty(property);
- if (composerStyle) composerStyle.removeProperty(property);
- });
- if (composerStyle) {
- composerStyle.removeProperty('--ldp-composer-font-size');
- clearInterfaceFontScale(composerStyle);
- clearReaderFontAppearance(composerStyle);
- composerStyle.removeProperty('translate');
- }
- applyComposerFontScale();
- };
- const stopPointer = (event) => {
- if (!pointer) {
- if (chrome) chrome.classList.remove('is-interacting');
- root.classList.remove('ldp-composer-window-interacting');
- return;
- }
- if (event && event.pointerId !== pointer.id) return;
- if (event && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
- pointerClientX = event.clientX;
- pointerClientY = event.clientY;
- }
- if (pointerFrame) cancelAnimationFrame(pointerFrame);
- pointerFrame = 0;
- applyPointerPosition();
- const activePointer = pointer;
- pointer = null;
- const target = activePointer.target;
- if (target?.hasPointerCapture(activePointer.id)) target.releasePointerCapture(activePointer.id);
- if (chrome) chrome.classList.remove('is-interacting');
- root.classList.remove('ldp-composer-window-interacting');
- scheduleComposerOverflowSync();
- persistState();
- };
- const resizeState = (start, direction, deltaX, deltaY) => {
- const bounds = limits();
- let left = start.left;
- let top = start.top;
- let right = start.left + start.width;
- let bottom = start.top + start.height;
- if (direction.includes('w')) left = clamp(start.left + deltaX, bounds.left, right - bounds.minWidth);
- if (direction.includes('e')) right = clamp(start.left + start.width + deltaX, left + bounds.minWidth, bounds.right);
- if (direction.includes('n')) top = clamp(start.top + deltaY, bounds.top, bottom - bounds.minHeight);
- if (direction.includes('s')) bottom = clamp(start.top + start.height + deltaY, top + bounds.minHeight, bounds.bottom);
- return { left, top, width: right - left, height: bottom - top };
- };
- function applyPointerPosition() {
- if (!pointer) return;
- const deltaX = pointerClientX - pointer.startX;
- const deltaY = pointerClientY - pointer.startY;
- if (pointer.mode === 'move') {
- const bounds = limits();
- applyState({
- left: clamp(pointer.state.left + deltaX, bounds.left, bounds.right - pointer.state.width),
- top: clamp(pointer.state.top + deltaY, bounds.top, bounds.bottom - pointer.state.height),
- width: pointer.state.width,
- height: pointer.state.height,
- });
- } else {
- applyState(resizeState(pointer.state, pointer.mode, deltaX, deltaY));
- }
- }
- const movePointer = (event) => {
- if (!pointer || event.pointerId !== pointer.id) return;
- pointerClientX = event.clientX;
- pointerClientY = event.clientY;
- if (!pointerFrame) {
- pointerFrame = requestAnimationFrame(() => {
- pointerFrame = 0;
- applyPointerPosition();
- });
- }
- event.preventDefault();
- };
- const startPointer = (event, mode, target) => {
- if (!state || !desktopWindow() || !nativeComposerVisible(composer) || composer.classList.contains('fullscreen')) return;
- if (event.button !== 0) return;
- pointer = {
- id: event.pointerId,
- mode,
- startX: event.clientX,
- startY: event.clientY,
- state: { ...state },
- target,
- };
- pointerClientX = event.clientX;
- pointerClientY = event.clientY;
- target.setPointerCapture(event.pointerId);
- chrome.classList.add('is-interacting');
- root.classList.add('ldp-composer-window-interacting');
- consumeEvent(event);
- };
- const ensureChrome = () => {
- if (chrome) return chrome;
- chrome = makeElement('div');
- chrome.className = 'ldp-composer-window-chrome sciapp-ldp-owned';
- chrome.hidden = true;
- chrome.innerHTML = `
${resizeDirections.map((direction) => direction === 'se'
- ? '
'
- : `
`).join('')}`;
- appendReaderPortalNode(chrome);
- chrome.addEventListener('pointerdown', (event) => {
- const handle = event.target instanceof Element
- ? event.target.closest('.ldp-composer-drag-handle,[data-resize]')
- : null;
- if (handle && chrome.contains(handle)) startPointer(event, handle.dataset.resize || 'move', handle);
- });
- chrome.addEventListener('keydown', (event) => {
- if (!state || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
- const step = event.shiftKey ? 40 : 12;
- if (event.target.matches('.ldp-composer-drag-handle')) {
- const next = { ...state };
- if (event.key === 'ArrowLeft') next.left -= step;
- else if (event.key === 'ArrowRight') next.left += step;
- else if (event.key === 'ArrowUp') next.top -= step;
- else next.top += step;
- applyState(next);
- } else if (event.target.matches('[data-resize="se"]')) {
- const deltaX = event.key === 'ArrowLeft' ? -step : (event.key === 'ArrowRight' ? step : 0);
- const deltaY = event.key === 'ArrowUp' ? -step : (event.key === 'ArrowDown' ? step : 0);
- applyState(resizeState(state, 'se', deltaX, deltaY));
- } else return;
- scheduleStatePersist();
- event.preventDefault();
- });
- chrome.addEventListener('pointermove', movePointer);
- ['pointerup', 'pointercancel', 'lostpointercapture'].forEach((type) =>
- chrome.addEventListener(type, stopPointer));
- return chrome;
- };
- const deactivate = () => {
- stopPointer();
- persistState();
- releaseComposerTopLayers();
- syncWheelLayer(false);
- state = null;
- if (chrome) chrome.hidden = true;
- clearGeometry();
- clearComposerOverflowLayers();
- clearComposerRoot();
- };
- const observeComposer = (element) => {
- if (composerObserver) composerObserver.disconnect();
- if (composer && composer !== element) releaseComposerTopLayers();
- composer = element;
- composerObserver = new MutationObserver(() => {
- if (!composer || !composer.isConnected || composer.classList.contains('closed') || !composerElementAvailable(composer)) {
- deactivate();
- return;
- }
- ensureChrome();
- syncComposerRoot();
- if (!state) syncState();
- if (composer.classList.contains('fullscreen')) {
- stopPointer();
- composer.style.removeProperty('translate');
- } else if (state && !composer.style.getPropertyValue('translate')) {
- applyState(state);
- }
- scheduleChromeSync();
- syncComposerTopLayers();
- scheduleComposerOverflowSync();
- });
- composerObserver.observe(composer, {
- attributes: true,
- attributeFilter: ['class', 'hidden', 'aria-hidden'],
- childList: true,
- subtree: true,
- });
- };
- const open = (element) => {
- if (!sessionActive() || !element) return false;
- ensureChrome();
- if (composer !== element) observeComposer(element);
- syncComposerRoot();
- if (!state) syncState();
- else scheduleChromeSync();
- syncComposerTopLayers();
- scheduleComposerOverflowSync();
- return true;
- };
- const syncComposerElement = () => {
- const nextComposer = document.querySelector('#reply-control');
- if (nextComposer === composer) {
- if (nativeComposerVisible(nextComposer) && !state) open(nextComposer);
- return;
- }
- if (composerObserver) composerObserver.disconnect();
- composerObserver = null;
- if (composer) deactivate();
- composer = null;
- if (!nextComposer) return;
- observeComposer(nextComposer);
- if (nativeComposerVisible(nextComposer)) open(nextComposer);
- };
- const syncLayer = () => {
- if (!composer || !nativeComposerVisible(composer)) return false;
- syncComposerRoot();
- if (state) syncState();
- syncComposerTopLayers();
- scheduleComposerOverflowSync();
- return true;
- };
- const onWindowResize = () => {
- stopPointer();
- if (state) syncState();
- else scheduleChromeSync();
- syncComposerTopLayers();
- scheduleComposerOverflowSync();
- };
- const destroy = () => {
- if (destroyed) return;
- destroyed = true;
- stopPointer();
- persistState();
- if (composerObserver) composerObserver.disconnect();
- if (composerHostObserver) composerHostObserver.disconnect();
- if (chromeFrame) cancelAnimationFrame(chromeFrame);
- window.removeEventListener('resize', onWindowResize);
- releaseComposerTopLayers();
- if (chrome) removeOwnedNode(chrome);
- if (wheelLayer) removeOwnedNode(wheelLayer);
- chrome = null;
- wheelLayer = null;
- state = null;
- clearGeometry();
- clearComposerOverflowLayers();
- clearComposerRoot();
- composer = null;
- };
- const syncComposerHostObserverTargets = () => {
- if (!composerHostObserver || !document.body) return;
- composerHostObserver.disconnect();
- const nextComposer = document.querySelector('#reply-control');
- if (!nextComposer) {
- const bootstrapRoot = document.querySelector('#ember-app') || document.body;
- composerHostObserver.observe(bootstrapRoot, { childList: true, subtree: true });
- let bootstrapAncestor = bootstrapRoot.parentElement;
- while (bootstrapAncestor) {
- composerHostObserver.observe(bootstrapAncestor, { childList: true });
- if (bootstrapAncestor === document.body) break;
- bootstrapAncestor = bootstrapAncestor.parentElement;
- }
- return;
- }
- let ancestor = nextComposer.parentElement;
- while (ancestor) {
- composerHostObserver.observe(ancestor, { childList: true });
- if (ancestor === document.body) break;
- ancestor = ancestor.parentElement;
- }
- };
- if (document.body) {
- composerHostObserver = new MutationObserver(() => {
- syncComposerElement();
- syncComposerHostObserverTargets();
- });
- syncComposerHostObserverTargets();
- }
- syncComposerElement();
- window.addEventListener('resize', onWindowResize);
- return { open, syncFont, syncLayer, destroy };
- }
-
- function discourseModelValue(model, key) {
- if (!model) return undefined;
- if (isFunction(model.get)) return model.get(key);
- return model[key];
- }
-
- function rememberReaderNativeComposer(ctx, composer, metadata = {}) {
- const model = discourseModelValue(composer, 'model');
- if (!ctx || !composer || !model) return false;
- ctx.nativeComposerSession = { composer, model, ...metadata };
- if (isFunction(ctx.bindNativeComposerRefresh)) ctx.bindNativeComposerRefresh();
- return true;
- }
-
- function cancelNativeComposerDraftDebounce(composer) {
- const runloopModule = lookupDiscourseModule('@ember/runloop');
- const cancelRunloop = runloopModule?.cancel;
- if (isFunction(cancelRunloop)) cancelRunloop(composer?._saveDraftDebounce);
- }
-
- async function discardReaderNativeComposer(ctx) {
- const ownedSession = ctx?.nativeComposerSession;
- const visibleComposer = nativeComposerVisible(document.querySelector('#reply-control'))
- ? lookupDiscourse('service:composer')
- : null;
- const visibleModel = discourseModelValue(visibleComposer, 'model');
- const composer = visibleModel ? visibleComposer : ownedSession?.composer;
- const model = visibleModel || ownedSession?.model;
- if (ctx) ctx.nativeComposerSession = null;
- if (!composer || discourseModelValue(composer, 'model') !== model) return true;
-
- const appEvents = composer.appEvents || lookupDiscourse('service:app-events');
- let cleanupError = null;
- try {
- composer.skipAutoSave = true;
- cancelNativeComposerDraftDebounce(composer);
- } catch (error) {
- cleanupError = error;
- }
-
- try {
- if (isFunction(composer.destroyDraft)) await composer.destroyDraft();
- } catch (error) {
- cleanupError = error;
- }
-
- if (discourseModelValue(composer, 'model') === model) {
- try {
- if (isFunction(model.clearState)) model.clearState();
- } catch (error) {
- cleanupError = cleanupError || error;
- }
- try {
- if (isFunction(composer.close)) composer.close();
- } catch (error) {
- cleanupError = cleanupError || error;
- }
- try {
- if (isFunction(appEvents?.trigger)) appEvents.trigger('composer:cancelled');
- } catch (error) {
- cleanupError = cleanupError || error;
- }
- }
-
- try {
- composer.skipAutoSave = false;
- } catch (error) {
- cleanupError = cleanupError || error;
- }
- if (cleanupError) console.warn('[LINUX DO reader] native composer discard cleanup failed', cleanupError);
- return !cleanupError;
- }
-
- async function saveAndCloseReaderNativeComposer(ctx) {
- const ownedSession = ctx?.nativeComposerSession;
- if (ctx) ctx.nativeComposerSession = null;
- const composer = ownedSession?.composer;
- const model = ownedSession?.model;
- if (!composer || discourseModelValue(composer, 'model') !== model) return true;
-
- try {
- cancelNativeComposerDraftDebounce(composer);
- if (!isFunction(composer.saveAndCloseComposer)) return false;
- const closeResult = composer.saveAndCloseComposer();
- const savePromise = composer._saveDraftPromise;
- await Promise.resolve(closeResult);
- if (savePromise) await savePromise;
- return true;
- } catch (error) {
- console.warn('[LINUX DO reader] native composer draft save failed', error);
- return false;
- } finally {
- try {
- composer.skipAutoSave = false;
- } catch {}
- }
- }
-
- function nativeComposerFailure(reason, error) {
- const messages = {
- target: '无法识别要回复的楼层,请重新展开该楼层后再试',
- service: 'LINUX DO 原生回复组件尚未就绪,请稍后重试',
- model: '无法创建原生回复上下文,请刷新页面后重试',
- draft: '无法获取当前主题草稿信息,请刷新页面后重试',
- open: 'LINUX DO 原生回复打开失败,请稍后重试',
- };
- console.warn(`[LINUX DO reader] native composer: ${reason}`, error || '');
- showSelectionToast(messages[reason] || messages.open);
- return false;
- }
-
- async function waitForNativeComposerPopup(ctx, timeout = 5000) {
- const deadline = Date.now() + timeout;
- let preparedPopup = null;
- while (Date.now() < deadline) {
- const popup = document.querySelector('#reply-control');
- if (composerElementAvailable(popup) && !popup.classList.contains('closed') &&
- popup !== preparedPopup && ctx && ctx.nativeComposerWindow) {
- ctx.nativeComposerWindow.open(popup);
- preparedPopup = popup;
- }
- if (nativeComposerVisible(popup)) return true;
- await waitForDelay(80);
- }
- return false;
- }
-
- function focusReaderNativeComposerInput(ctx) {
- requestAnimationFrame(() => {
- if (ctx?.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return;
- const popup = document.querySelector('#reply-control');
- if (!nativeComposerVisible(popup)) return;
- const input = [...popup.querySelectorAll(
- 'textarea.d-editor-input, textarea, .ProseMirror[contenteditable="true"]'
- )].find((candidate) => composerElementAvailable(candidate) &&
- !candidate.matches('[disabled],[aria-disabled="true"]'));
- if (!input) return;
- try { input.focus({ preventScroll: true }); } catch { input.focus(); }
- });
- }
-
- function createDetachedComposerModels(postNode, ctx, Topic, Post) {
- const topicData = ctx.topicData || {};
- const postData = postNode?._ldpPostData || {};
- const topicModel = Topic.create({
- id: +ctx.topicId,
- title: topicData.title || '',
- fancy_title: topicData.fancy_title || topicData.title || '',
- slug: topicData.slug || 'topic',
- category_id: topicData.category_id,
- archetype: topicData.archetype || 'regular',
- draft_key: topicData.draft_key,
- draft_sequence: topicData.draft_sequence,
- posts_count: topicData.posts_count,
- highest_post_number: topicData.highest_post_number,
- last_posted_at: topicData.last_posted_at,
- chunk_size: topicData.chunk_size,
- details: Object.assign({ can_create_post: true }, topicData.details || {}),
- tags: Array.isArray(topicData.tags) ? topicData.tags.slice() : [],
- });
- if (!isFunction(Post.munge))
- throw new Error('Discourse 楼层模型尚未就绪');
- const postAttributes = Object.assign({}, postData, {
- id: +(postNode && postNode.dataset.postId),
- topic_id: +ctx.topicId,
- post_number: +(postNode && postNode.dataset.postNumber),
- username: postData.username || (postNode && postNode.dataset.username) || '',
- name: postData.name || '',
- avatar_template: postData.avatar_template || '',
- post_type: postData.post_type || 1,
- reply_count: postData.reply_count || 0,
- topic: topicModel,
- actions_summary: Array.isArray(postData.actions_summary)
- ? postData.actions_summary.map((action) => ({ ...action }))
- : [],
- });
- const postModel = Post.create(Post.munge(postAttributes));
- return { topicModel, postModel };
- }
-
- async function tryOpenNativeReplyComposer(post, ctx, initialRaw = '') {
- const postNumber = +(post && post.dataset.postNumber);
- const postId = +(post && post.dataset.postId);
- if (!postNumber || !postId) return nativeComposerFailure('target');
- const composer = lookupDiscourse('service:composer');
- const appEvents = composer && (composer.appEvents || lookupDiscourse('service:app-events'));
- const composerModule = lookupDiscourseModule('discourse/models/composer');
- const topicModule = lookupDiscourseModule('discourse/models/topic');
- const postModule = lookupDiscourseModule('discourse/models/post');
- const draftModule = lookupDiscourseModule('discourse/models/draft');
- const Composer = composerModule?.default;
- const Topic = topicModule?.default;
- const Post = postModule?.default;
- const Draft = draftModule?.default;
- if (!composer || !isFunction(composer.open) || !appEvents)
- return nativeComposerFailure('service');
- if (!Composer || !Composer.REPLY || !Topic || !isFunction(Topic.create) ||
- !Post || !isFunction(Post.create))
- return nativeComposerFailure('model');
- if (!Draft || !isFunction(Draft.get)) return nativeComposerFailure('draft');
- const rememberReplyComposer = () => rememberReaderNativeComposer(ctx, composer, {
- action: 'reply',
- parentPostNumber: postNumber,
- highestPostNumber: Math.max(
- 0,
- +(ctx.totalPosts || 0),
- +(ctx.topicData?.highest_post_number || 0),
- ),
- });
-
- try {
- let topicData = ctx.topicData || {};
- if ((!topicData.draft_key || topicData.draft_sequence == null) &&
- ctx.loader && isFunction(ctx.loader.refreshTopicData)) {
- const freshTopic = await ctx.loader.refreshTopicData({ prime: false });
- if (freshTopic) {
- ctx.topicData = freshTopic;
- topicData = freshTopic;
- }
- }
- if (!topicData.draft_key || topicData.draft_sequence == null)
- return nativeComposerFailure('draft');
- if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false;
-
- const { topicModel, postModel } = createDetachedComposerModels(post, ctx, Topic, Post);
- const quoteRaw = String(initialRaw || '').trim();
- const currentModel = discourseModelValue(composer, 'model');
- const currentTopicId = +discourseModelValue(discourseModelValue(currentModel, 'topic'), 'id');
- const currentViewOpen = discourseModelValue(currentModel, 'viewOpen') === true ||
- String(discourseModelValue(currentModel, 'composeState') || '').toLowerCase() === 'open';
- const currentAction = discourseModelValue(currentModel, 'action');
- const currentReply = String(discourseModelValue(currentModel, 'reply') || '');
- const sameTopicReplyOpen = currentModel && currentViewOpen && currentTopicId === +ctx.topicId &&
- currentAction === Composer.REPLY;
- if (sameTopicReplyOpen && await waitForNativeComposerPopup(ctx, 640)) {
- rememberReplyComposer();
- const replyTarget = postNumber === 1 ? null : postModel;
- currentModel.set('post', replyTarget);
- if (quoteRaw) appEvents.trigger('composer:insert-block', quoteRaw);
- focusReaderNativeComposerInput(ctx);
- return true;
- }
-
- const currentPopup = document.querySelector('#reply-control');
- const currentPopupAvailable = composerElementAvailable(currentPopup) &&
- !currentPopup.classList.contains('closed');
- if (currentViewOpen && (currentTopicId === +ctx.topicId || !currentPopupAvailable)) {
- console.info('[LINUX DO reader] native composer: recovering stale view', {
- topicId: +ctx.topicId,
- previousTopicId: currentTopicId || 0,
- action: currentAction || '',
- popupVisible: nativeComposerVisible(currentPopup),
- });
- if (ctx.nativeComposerSession && ctx.nativeComposerSession.model === currentModel) {
- ctx.nativeComposerSession = null;
- }
- if (isFunction(composer.close)) composer.close();
- await waitForDelay(0);
- }
-
- const options = {
- action: Composer.REPLY,
- draftKey: topicData.draft_key,
- draftSequence: topicData.draft_sequence,
- skipJumpOnSave: true,
- };
- if (postNumber === 1) options.topic = topicModel;
- else options.post = postModel;
-
- const draftData = await Draft.get(options.draftKey);
- if (draftData?.draft) {
- const data = JSON.parse(draftData.draft);
- options.draftSequence = draftData.draft_sequence;
- options.reply = `${String(data.reply || '')}${quoteRaw ? `\n${quoteRaw}` : ''}`;
- if (data.whisper !== undefined) options.whisper = data.whisper;
- } else if (quoteRaw) {
- options.quote = quoteRaw;
- }
- if (currentReply && currentViewOpen && currentTopicId === +ctx.topicId) {
- options.reply = `${currentReply}${quoteRaw ? `\n${quoteRaw}` : ''}`;
- delete options.quote;
- }
- console.info('[LINUX DO reader] native composer: opening', {
- topicId: +ctx.topicId,
- postId,
- postNumber,
- quoted: !!quoteRaw,
- });
- await composer.open(options);
- rememberReplyComposer();
- if (!await waitForNativeComposerPopup(ctx)) return nativeComposerFailure('open');
- focusReaderNativeComposerInput(ctx);
- console.info('[LINUX DO reader] native composer: visible', {
- topicId: +ctx.topicId,
- postId,
- postNumber,
- });
- return true;
- } catch (error) {
- if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false;
- return nativeComposerFailure('open', error);
- }
- }
-
- async function tryOpenNativeEditComposer(post, ctx) {
- const postNumber = +(post && post.dataset.postNumber);
- const postId = +(post && post.dataset.postId);
- const postData = post?._ldpPostData || {};
- if (!postNumber || !postId || postData.can_edit !== true) return false;
- const composer = lookupDiscourse('service:composer');
- const composerModule = lookupDiscourseModule('discourse/models/composer');
- const topicModule = lookupDiscourseModule('discourse/models/topic');
- const postModule = lookupDiscourseModule('discourse/models/post');
- const Composer = composerModule?.default;
- const Topic = topicModule?.default;
- const Post = postModule?.default;
- if (!composer || !isFunction(composer.open) || !Composer || !Composer.EDIT ||
- !Topic || !isFunction(Topic.create) || !Post || !isFunction(Post.create))
- return nativeComposerFailure('service');
- try {
- const freshPost = await fetchJSON(`${BASE}/posts/${postId}.json`, {
- cacheMode: 'refresh',
- allowStaleOnError: false,
- });
- post._ldpPostData = Object.assign({}, postData, freshPost);
- const { postModel } = createDetachedComposerModels(post, ctx, Topic, Post);
- const topicData = ctx.topicData || {};
- await composer.open({
- action: Composer.EDIT,
- post: postModel,
- draftKey: topicData.draft_key,
- draftSequence: topicData.draft_sequence,
- skipJumpOnSave: true,
- });
- rememberReaderNativeComposer(ctx, composer, {
- action: 'edit',
- postNumber,
- postId,
- });
- if (!await waitForNativeComposerPopup(ctx)) return nativeComposerFailure('open');
- return true;
- } catch (error) {
- return nativeComposerFailure('open', error);
- }
- }
-
- function detachedNativePostModel(post, ctx) {
- const topicModule = lookupDiscourseModule('discourse/models/topic');
- const postModule = lookupDiscourseModule('discourse/models/post');
- const Topic = topicModule?.default;
- const Post = postModule?.default;
- if (!Topic || !isFunction(Topic.create) || !Post || !isFunction(Post.create)) return null;
- return createDetachedComposerModels(post, ctx, Topic, Post).postModel;
- }
-
- function openNativeAdminPostMenu(post, anchor, ctx) {
- const menu = lookupDiscourse('service:menu');
- const componentModule = lookupDiscourseModule('discourse/components/admin-post-menu');
- const AdminPostMenu = componentModule?.default;
- const topicController = lookupDiscourse('controller:topic');
- const postModel = detachedNativePostModel(post, ctx);
- if (!menu || !isFunction(menu.show) || !AdminPostMenu || !topicController ||
- !isFunction(topicController.send) || !postModel) {
- showSelectionToast('LINUX DO 原生楼层管理菜单尚未就绪,请稍后重试');
- return false;
- }
- const topicAction = (name) => () => topicController.send(name, postModel);
- menu.show(anchor, {
- identifier: 'admin-post-menu',
- component: AdminPostMenu,
- modalForMobile: true,
- autofocus: true,
- data: {
- post: postModel,
- changeNotice: topicAction('changeNotice'),
- changePostOwner: topicAction('changePostOwner'),
- grantBadge: topicAction('grantBadge'),
- lockPost: topicAction('lockPost'),
- permanentlyDeletePost: topicAction('permanentlyDeletePost'),
- rebakePost: topicAction('rebakePost'),
- showPagePublish: topicAction('showPagePublish'),
- togglePostType: topicAction('togglePostType'),
- toggleWiki: topicAction('toggleWiki'),
- unhidePost: topicAction('unhidePost'),
- unlockPost: topicAction('unlockPost'),
- scheduleRerender: () => ctx.refreshPostByNumber && ctx.refreshPostByNumber(+post.dataset.postNumber),
- },
- });
- return true;
- }
-
- function removeReaderPost(post, ctx) {
- const postId = +(post && post.dataset.postId);
- const postNumber = +(post && post.dataset.postNumber);
- const postData = post?._ldpPostData || {};
- const parentNumber = +(postData.reply_to_post_number || post && post.dataset.replyToPostNumber || 0);
- readerPostCopies(postId, ctx).forEach((node) => removeOwnedNode(node));
- const item = ctx.streamItemMap.get(postNumber);
- if (item) {
- const index = item.index;
- cancelPostContentHydration(item.node, ctx);
- cancelPostLatexRender(item.node, ctx);
- destroyReaderMedia(item.node);
- ctx.streamHydratedNodes.delete(item.node);
- ctx.streamItems.splice(index, 1);
- ctx.streamItemMap.delete(postNumber);
- ctx.streamNodeMap.delete(postNumber);
- ctx.streamMountedNodes.delete(item.node);
- reindexStreamItems(ctx, Math.max(0, index));
- invalidateStreamLayout(ctx);
- scheduleStreamWindowSync(ctx);
- }
- const replies = parentNumber && ctx.directRepliesByParent.get(parentNumber);
- if (replies) replies.delete(postNumber);
- const state = parentNumber && ctx.subReplyState.get(parentNumber);
- const parentNode = parentNumber && ctx.streamNodeMap.get(parentNumber);
- if (state) {
- state.all = state.all.filter((reply) => +reply.post_number !== postNumber);
- if (state.nodeCache) state.nodeCache.delete(postNumber);
- state.totalCount = Math.max(0, state.totalCount - 1);
- state.renderedKey = '';
- if (parentNode?.isConnected) renderSubReplyBatch(parentNumber, ctx, 0, parentNode);
- }
- if (parentNode) {
- const total = Math.max(0, +(parentNode.dataset.childReplyTotal || 0) - 1);
- parentNode.dataset.childReplyTotal = String(total);
- if (parentNode._ldpPostData) parentNode._ldpPostData.reply_count = total;
- syncChildControls(parentNode);
- }
- const cacheEntry = TOPIC_DATA_CACHE.get(String(ctx.topicId));
- const cachedTopic = (cacheEntry?.topic) || ctx.topicData;
- if (cachedTopic?.post_stream && Array.isArray(cachedTopic.post_stream.posts)) {
- cachedTopic.post_stream.posts = cachedTopic.post_stream.posts.filter((item) => +item.id !== postId);
- setCachedTopicData(ctx.topicId, cachedTopic);
- }
- updateCommentsHeader(ctx);
- ctx.timeline.update();
- }
-
- async function deleteReaderPost(post, ctx) {
- const postData = post?._ldpPostData || {};
- const postId = +(post && post.dataset.postId);
- if (!postId || postData.can_delete !== true) return false;
- return runReaderActionOnce(ctx, `post:${postId}:delete`, async () => {
- if (!window.confirm('确定删除这条回复吗?')) return false;
- const postModel = detachedNativePostModel(post, ctx);
- const currentUser = lookupDiscourse('service:current-user');
- if (!postModel || !isFunction(postModel.destroy) || !currentUser)
- throw new Error('Discourse 楼层删除接口尚未就绪');
- await postModel.destroy(currentUser);
- await clearStoredResponseCache({ tags: [`post:${postId}`, `topic:${ctx.topicId}`] });
- removeReaderPost(post, ctx);
- return true;
- });
- }
-
- function positionNativeBoostMenu(anchor, menu) {
- const anchorRect = anchor.getBoundingClientRect();
- const menuRect = menu.getBoundingClientRect();
- const width = Math.max(menuRect.width || 0, 280);
- const height = Math.max(menuRect.height || 0, 84);
- const left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - width - 8));
- const below = anchorRect.bottom + 6;
- const top = below + height <= window.innerHeight - 8
- ? below
- : Math.max(8, anchorRect.top - height - 6);
- menu.style.left = `${Math.round(left)}px`;
- menu.style.top = `${Math.round(top)}px`;
- return true;
- }
-
- function syncRenderedBoost(post, postData, ctx) {
- const current = post.querySelector(':scope > .ldp-boost-list');
- const holder = makeElement('template');
- holder.innerHTML = renderBoostList(postData.boosts, { canQuickBoost: canShowBoostButton(postData), ctx });
- const next = holder.content.firstElementChild;
- if (current && next) current.replaceWith(next);
- else if (next) post.insertBefore(next, post.querySelector(':scope > .ldp-collapse-replies'));
- hydratePersistentAvatarImages(post);
- const actions = post.querySelector(':scope > .ldp-reactions .ldp-actions');
- const boostButton = actions?.querySelector('.ldp-boostbtn');
- if (canShowBoostButton(postData) && actions && !boostButton) {
- const actionHolder = makeElement('template');
- actionHolder.innerHTML = `
`;
- const contextSlot = actions.querySelector('.ldp-context-actions-slot');
- actions.insertBefore(actionHolder.content.firstElementChild, contextSlot || null);
- } else if (!canShowBoostButton(postData))
- boostButton?.remove();
- if (ctx.topicActionRail) { ctx.topicActionRail._ldpPostData = postData; syncPostActionControls(ctx.topicActionRail, ctx); }
- }
-
- function syncDeletedReaderBoost(post, boostId, ctx) {
- const postId = +(post && post.dataset.postId || 0);
- if (!postId || !boostId) return;
- let latestPostData = null;
- const copies = readerPostCopies(postId, ctx);
- (copies.length ? copies : [post]).forEach((copy) => {
- if (!copy) return;
- const postData = copy._ldpPostData || {};
- const boosts = Array.isArray(postData.boosts)
- ? postData.boosts
- : (postData.boosts ? [postData.boosts] : []);
- postData.boosts = boosts.filter((boost) => String(boost?.id || '') !== String(boostId));
- postData.can_boost = true;
- copy._ldpPostData = postData;
- latestPostData = postData;
- syncRenderedBoost(copy, postData, ctx);
- });
- if (latestPostData) persistReaderPostState(ctx, latestPostData);
- }
-
- async function deleteReaderBoost(post, boostBubble, ctx) {
- const boostId = boostBubble && boostBubble.dataset.boostId;
- if (!post || !boostId) return false;
- await apiSend(`${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}`, 'DELETE', null, null, {
- invalidateTags: [`post:${post.dataset.postId}`, `topic:${ctx.topicId}`],
- });
- syncDeletedReaderBoost(post, boostId, ctx);
- showSelectionToast('Boost 已删除');
- return true;
- }
-
- async function openReaderBoostReportDialog(boostBubble, ctx) {
- const boostId = boostBubble && boostBubble.dataset.boostId;
- if (!boostId) return false;
- const boost = await fetchJSON(
- `${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}.json`,
- { cacheMode: 'bypass', allowStaleOnError: false, key: `boost:${boostId}:permissions` }
- );
- if (!boost || boost.can_flag !== true)
- throw new Error(boost?.user_flag_status ? '你已经举报过这个 Boost' : '当前账号不能举报这个 Boost');
- const availableFlags = new Set(Array.isArray(boost.available_flags) ? boost.available_flags : []);
- const site = lookupDiscourse('service:site');
- const siteFlags = [...site?.flagTypes || []];
- const descriptionText = (value) => {
- const holder = makeElement('template');
- holder.innerHTML = String(value || '');
- return String(holder.content.textContent || '').replace(/\s+/g, ' ').trim();
- };
- const reportOptions = siteFlags
- .filter((flag) => (
- flag && flag.enabled !== false && availableFlags.has(flag.name_key) &&
- (!Array.isArray(flag.applies_to) || flag.applies_to.includes('DiscourseBoosts::Boost'))
- ))
- .map((flag) => ({
- id: +flag.id,
- nameKey: flag.name_key,
- label: flag.name || flag.name_key,
- description: descriptionText(flag.description || flag.short_description) || '提交给社区管理人员审核。',
- requireMessage: flag.require_message === true,
- }));
- if (!reportOptions.length) throw new Error('当前没有可用的 Boost 举报类型');
- const username = String(boost.user?.username || boostBubble.dataset.boostUser || '').trim();
- return !!openReaderReportForm(ctx, {
- title: '举报 Boost',
- intro: username
- ? `举报 @${username} 的 Boost 会直接提交给社区,不会离开阅读器。`
- : '举报会直接提交给社区,不会离开阅读器。',
- options: reportOptions,
- placeholder: '选填;所选类型要求说明时必须填写',
- onSubmit: async (actionType, message) => {
- const params = { flag_type_id: actionType };
- if (message) params.message = message;
- await apiSend(`${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}/flags`, 'POST', params);
- return '举报已提交';
- },
- });
- }
-
- function renderNativeBoostMenu(post, anchor, ctx, initialRaw = '') {
- const emojiMenuIdentifier = 'ldp-native-boost-emoji-picker';
- const emojiPickerSurfaceSelector = '.fk-d-menu,.emoji-picker';
- const overlay = post?.closest('.ldp-overlay');
- const postData = post?._ldpPostData;
- const scrollRoot = anchor?.closest?.('.ldp-descendant-replies-list') ||
- (ctx?.scrollRoot?.contains(anchor) ? ctx.scrollRoot : null);
- const createBoostModule = lookupDiscourseModule('discourse/plugins/discourse-boosts/discourse/lib/create-boost');
- const createBoost = createBoostModule?.default;
- const currentUser = ME_CURRENT_USER;
- const nativeMenu = lookupDiscourse('service:menu');
- const prettyTextEmoji = lookupDiscourseModule('pretty-text/emoji');
- const discourseText = lookupDiscourseModule('discourse/lib/text');
- const unicodeEmojiRegex = prettyTextEmoji?.emojiReplacementRegex
- ? new RegExp(prettyTextEmoji.emojiReplacementRegex, 'g')
- : null;
- if (!overlay || !postData || !document.body ||
- !hasDiscourseCapability('boosts', { post: postData, topic: ctx?.topicData })) return false;
-
- const previous = document.body.querySelector(':scope > .ldp-native-boost-menu');
- if (isFunction(previous?._ldpClose)) previous._ldpClose();
- else if (previous) previous.remove();
-
- const menu = makeElement('div');
- const menuScope = createLifecycleScope();
- menu.className = 'ldp-native-boost-menu sciapp-ldp-owned';
- menu.setAttribute('role', 'dialog');
- setLabel(menu, '助推');
- menu.innerHTML = `
-
-
`;
- document.body.appendChild(menu);
- applyReaderThemeVariables(menu);
- positionNativeBoostMenu(anchor, menu);
- bindFloatingSurfaceWheel(menu);
-
- const editor = menu.querySelector('.discourse-boosts__input');
- const count = menu.querySelector('.ldp-native-boost-count');
- const emojiButton = menu.querySelector('.discourse-boosts__emoji-btn');
- const submit = menu.querySelector('.discourse-boosts__submit');
- const cancel = menu.querySelector('.discourse-boosts__cancel');
- const error = menu.querySelector('.ldp-native-boost-error');
- let submitting = false;
- let closed = false;
- let anchorSyncFrame = 0;
- let anchorObserver = null;
- let previousEditorHTML = '';
- let emojiPickerContent = null;
- let emojiPickerObserver = null;
- let composing = false;
-
- const closeEmojiPicker = () => {
- emojiPickerObserver?.disconnect();
- emojiPickerObserver = null;
- if (isFunction(emojiPickerContent?._ldpStopEmojiImageCache)) {
- emojiPickerContent._ldpStopEmojiImageCache();
- }
- emojiPickerContent = null;
- if (!nativeMenu || !isFunction(nativeMenu.close)) return;
- try {
- const result = nativeMenu.close(emojiMenuIdentifier);
- if (isFunction(result?.catch)) result.catch(() => {});
- } catch {}
- };
- const placeEditorCursorAtEnd = () => {
- editor.focus();
- const range = document.createRange();
- range.selectNodeContents(editor);
- range.collapse(false);
- const selection = readerSelection();
- if (!selection) return;
- selection.removeAllRanges();
- selection.addRange(range);
- };
- const textStats = (value) => {
- const text = String(value || '').replace(/\u00a0/g, ' ');
- const unicodeMatches = unicodeEmojiRegex ? text.match(unicodeEmojiRegex) : null;
- return {
- raw: text,
- length: [...unicodeMatches ? text.replace(unicodeEmojiRegex, 'x') : text].length,
- emojiCount: unicodeMatches ? unicodeMatches.length : 0,
- };
- };
- const readInput = () => {
- const stats = { raw: '', length: 0, emojiCount: 0 };
- const visit = (node) => {
- if (node.nodeType === Node.TEXT_NODE) {
- const current = textStats(node.textContent);
- stats.raw += current.raw;
- stats.length += current.length;
- stats.emojiCount += current.emojiCount;
- return;
- }
- if (node.nodeType !== Node.ELEMENT_NODE) return;
- if (node.nodeName === 'IMG' && node.classList.contains('emoji')) {
- stats.raw += node.getAttribute('alt') || '';
- stats.length += 1;
- stats.emojiCount += 1;
- return;
- }
- node.childNodes.forEach(visit);
- };
- editor.childNodes.forEach(visit);
- return stats;
- };
-
- const rectsOverlap = (rect, clip) => rect.bottom > clip.top && rect.top < clip.bottom &&
- rect.right > clip.left && rect.left < clip.right;
- const anchorIsVisible = () => {
- if (!anchor.isConnected || !menu.isConnected) return false;
- const rect = anchor.getBoundingClientRect();
- if (!rect.width || !rect.height) return false;
- const viewport = { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 };
- if (!rectsOverlap(rect, viewport)) return false;
- return !scrollRoot || rectsOverlap(rect, scrollRoot.getBoundingClientRect());
- };
-
- const close = () => {
- if (closed) return;
- closed = true;
- closeEmojiPicker();
- menuScope.destroy();
- menu.remove();
- };
- const onOutside = (event) => {
- if (!menu.isConnected) return close();
- if (eventPathClosest(event, `[data-identifier="${emojiMenuIdentifier}"],.emoji-picker`)) return;
- if (!eventPathIncludes(event, menu) && !eventPathIncludes(event, anchor)) close();
- };
- const syncAnchor = () => {
- anchorSyncFrame = 0;
- if (!anchorIsVisible()) return close();
- positionNativeBoostMenu(anchor, menu);
- };
- const scheduleAnchorSync = () => {
- if (closed || anchorSyncFrame) return;
- anchorSyncFrame = menuScope.raf(syncAnchor);
- };
- const syncInput = () => {
- let stats = readInput();
- if (!composing && (stats.length > 16 || stats.emojiCount > 5)) {
- editor.innerHTML = previousEditorHTML;
- placeEditorCursorAtEnd();
- stats = readInput();
- } else if (!composing) {
- if (stats.length === 0 && editor.innerHTML) editor.innerHTML = '';
- previousEditorHTML = editor.innerHTML;
- }
- count.textContent = `${stats.length}/16`;
- emojiButton.disabled = submitting || composing ||
- stats.length + (stats.length ? 2 : 1) > 16 || stats.emojiCount >= 5;
- submit.disabled = submitting || composing || !stats.raw.trim();
- return stats.raw;
- };
- const insertEmoji = (code) => {
- const emojiCode = String(code || '').replace(/^:|:$/g, '');
- const stats = readInput();
- if (!emojiCode || stats.length + (stats.length ? 2 : 1) > 16 || stats.emojiCount >= 5) return;
- const buildEmojiUrl = prettyTextEmoji?.buildEmojiUrl;
- const isCustomEmoji = prettyTextEmoji?.isCustomEmoji;
- const emojiOptions = discourseText?.emojiOptions;
- if (!isFunction(buildEmojiUrl) || !isFunction(emojiOptions)) {
- error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
- return;
- }
- try {
- const options = emojiOptions();
- const image = makeElement('img');
- image.className = isFunction(isCustomEmoji) && isCustomEmoji(emojiCode, options)
- ? 'emoji emoji-custom'
- : 'emoji';
- image.alt = `:${emojiCode}:`;
- image.src = buildEmojiUrl(emojiCode, options);
- if (stats.length) editor.appendChild(document.createTextNode(' '));
- else editor.innerHTML = '';
- editor.appendChild(image);
- error.textContent = '';
- placeEditorCursorAtEnd();
- syncInput();
- } catch {
- error.textContent = '表情插入失败,请稍后重试';
- }
- };
- const positionEmojiPickerInReader = (content) => {
- const reader = post.closest('.ldp-modal');
- if (!content || !reader) return;
- if (emojiPickerContent !== content) {
- if (isFunction(emojiPickerContent?._ldpStopEmojiImageCache)) {
- emojiPickerContent._ldpStopEmojiImageCache();
- }
- emojiPickerContent = content;
- watchPersistentEmojiImages(content);
- }
- const ownsPopover = !content.hasAttribute('popover');
- try {
- if (ownsPopover) {
- content.setAttribute('popover', 'manual');
- content.dataset.ldpReaderTopLayer = 'portal';
- }
- if (!content.matches(':popover-open')) content.showPopover();
- } catch {
- if (ownsPopover) {
- content.removeAttribute('popover');
- delete content.dataset.ldpReaderTopLayer;
- }
- }
- const padding = 8;
- const gap = 8;
- const readerRect = reader.getBoundingClientRect();
- const anchorRect = menu.getBoundingClientRect();
- const bounds = {
- top: Math.max(padding, readerRect.top + padding),
- right: Math.min(window.innerWidth - padding, readerRect.right - padding),
- bottom: Math.min(window.innerHeight - padding, readerRect.bottom - padding),
- left: Math.max(padding, readerRect.left + padding),
- };
- const picker = content.matches('.emoji-picker') ? content : content.querySelector('.emoji-picker');
- const initialRect = content.getBoundingClientRect();
- const pickerRect = picker?.getBoundingClientRect();
- const naturalHeight = picker && +(picker.dataset.ldpBoostNaturalHeight || picker.offsetHeight || 0);
- if (picker && naturalHeight && !picker.dataset.ldpBoostNaturalHeight) {
- picker.dataset.ldpBoostNaturalHeight = String(naturalHeight);
- }
- const scale = picker?.offsetHeight ? pickerRect.height / picker.offsetHeight : 1;
- const chromeHeight = pickerRect ? Math.max(0, initialRect.height - pickerRect.height) : 0;
- const above = Math.max(0, anchorRect.top - gap - bounds.top);
- if (picker && naturalHeight && scale > 0) {
- picker.classList.add('ldp-boost-picker-constrained');
- const targetVisualHeight = Math.min(
- naturalHeight * scale,
- Math.max(120 * scale, above - chromeHeight),
- );
- picker.style.height = `${Math.round(targetVisualHeight / scale)}px`;
- const pickerContent = picker.querySelector('.emoji-picker__content');
- if (pickerContent) {
- pickerContent.style.height = 'auto';
- pickerContent.style.minHeight = '0';
- pickerContent.style.flex = '1 1 auto';
- }
- const sectionsNav = picker.querySelector('.emoji-picker__sections-nav');
- if (sectionsNav) sectionsNav.style.height = '100%';
- }
- content.classList.add('ldp-boost-picker-positioned');
- Object.assign(content.style, {
- position: 'fixed',
- inset: 'auto',
- right: 'auto',
- bottom: 'auto',
- margin: '0',
- transform: 'none',
- });
- const panelRect = content.getBoundingClientRect();
- let left = anchorRect.left;
- if (left + panelRect.width > bounds.right) left = anchorRect.right - panelRect.width;
- left = Math.max(bounds.left, Math.min(left, Math.max(bounds.left, bounds.right - panelRect.width)));
- const top = Math.max(bounds.top, anchorRect.top - panelRect.height - gap);
- content.style.setProperty('--ldp-boost-picker-left', `${Math.round(left)}px`);
- content.style.setProperty('--ldp-boost-picker-top', `${Math.round(top)}px`);
- const placedRect = content.getBoundingClientRect();
- content.style.setProperty('--ldp-boost-picker-left', `${Math.round(left + left - placedRect.left)}px`);
- content.style.setProperty('--ldp-boost-picker-top', `${Math.round(top + top - placedRect.top)}px`);
- };
- const shownEmojiPickerSurface = () => {
- const identified = document.querySelector(`[data-identifier="${emojiMenuIdentifier}"]`);
- const candidates = [...document.querySelectorAll(emojiPickerSurfaceSelector)].filter((element) => {
- const picker = element.matches('.emoji-picker') ? element : element.querySelector('.emoji-picker');
- if (!picker || !composerElementAvailable(element)) return false;
- const style = getComputedStyle(element);
- if (style.display === 'none' || style.visibility === 'hidden') return false;
- const rect = element.getBoundingClientRect();
- return rect.width > 0 && rect.height > 0;
- });
- if (identified && candidates.includes(identified)) return identified;
- const surfaces = candidates.filter((element) =>
- !candidates.some((parent) => parent !== element && parent.contains(element))
- );
- return surfaces[surfaces.length - 1] || null;
- };
- const positionShownEmojiPicker = () => {
- const content = shownEmojiPickerSurface();
- if (!content) return false;
- emojiPickerObserver?.disconnect();
- emojiPickerObserver = null;
- positionEmojiPickerInReader(content);
- menuScope.raf(() => {
- if (!closed && content.isConnected) positionEmojiPickerInReader(content);
- });
- return true;
- };
- const watchShownEmojiPicker = () => {
- emojiPickerObserver?.disconnect();
- emojiPickerObserver = null;
- if (positionShownEmojiPicker()) return;
- const observer = new MutationObserver(positionShownEmojiPicker);
- emojiPickerObserver = observer;
- menuScope.observe(observer, document.body, {
- childList: true,
- subtree: true,
- attributes: true,
- attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-identifier', 'open'],
- });
- menuScope.timer(() => {
- observer.disconnect();
- if (emojiPickerObserver === observer) emojiPickerObserver = null;
- }, 1000);
- };
- const openEmojiPicker = () => {
- const pickerModule = lookupDiscourseModule('discourse/components/emoji-picker/detached');
- const EmojiPickerDetached = pickerModule?.default;
- if (!nativeMenu || !isFunction(nativeMenu.show) || !EmojiPickerDetached) {
- error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
- return;
- }
- error.textContent = '';
- try {
- const result = nativeMenu.show(emojiButton, {
- identifier: emojiMenuIdentifier,
- component: EmojiPickerDetached,
- modalForMobile: false,
- strategy: 'fixed',
- fallbackPlacements: ['bottom-start', 'top-start', 'bottom-end', 'top-end'],
- computePosition: positionEmojiPickerInReader,
- data: {
- term: '',
- context: 'boost',
- didSelectEmoji: insertEmoji,
- },
- });
- watchShownEmojiPicker();
- if (isFunction(result?.catch)) {
- result.catch(() => {
- if (!closed) error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
- });
- }
- } catch {
- error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
- }
- };
- const send = async () => {
- if (composing) return;
- const raw = syncInput().trim();
- if (!raw || submitting) return;
- if (!isFunction(createBoost) || !currentUser) {
- error.textContent = 'LinuxDo Boost 组件尚未就绪,请稍后重试';
- return;
- }
- submitting = true;
- submit.disabled = true;
- emojiButton.disabled = true;
- cancel.disabled = true;
- editor.contentEditable = 'false';
- error.textContent = '';
- try {
- await createBoost(postData, raw, currentUser);
- syncRenderedBoost(post, postData, ctx);
- persistReaderPostState(ctx, postData);
- await clearStoredResponseCache({ tags: [`post:${postData.id}`, `topic:${ctx.topicId}`] });
- close();
- } catch (e) {
- submitting = false;
- cancel.disabled = false;
- editor.contentEditable = 'true';
- error.textContent = `发送失败:${e?.message ? e.message : '请稍后重试'}`;
- syncInput();
- editor.focus();
- }
- };
-
- menu._ldpCloseEmojiPicker = closeEmojiPicker;
- menu._ldpClose = close;
- editor.addEventListener('compositionstart', () => {
- composing = true;
- syncInput();
- });
- editor.addEventListener('compositionend', () => {
- composing = false;
- syncInput();
- });
- editor.addEventListener('input', syncInput);
- editor.addEventListener('keydown', (event) => {
- if (event.isComposing || composing || event.keyCode === 229) return;
- if (event.key === 'Escape') {
- consumeEvent(event);
- close();
- } else if (event.key === 'Enter' && !event.shiftKey) {
- event.preventDefault();
- send();
- }
- });
- menu.addEventListener('click', (event) => {
- const button = event.target instanceof Element ? event.target.closest('button') : null;
- if (button === emojiButton) openEmojiPicker();
- else if (button === submit) send();
- else if (button === cancel) close();
- });
- if (scrollRoot) menuScope.listen(
- scrollRoot, 'scroll', scheduleAnchorSync, { passive: true }
- );
- menuScope.listen(window, 'resize', scheduleAnchorSync, { passive: true });
- if (isFunction(IntersectionObserver)) {
- anchorObserver = new IntersectionObserver((entries) => {
- const entry = entries[0];
- if (!entry || !entry.isIntersecting) close();
- else scheduleAnchorSync();
- }, { root: scrollRoot || null, threshold: 0 });
- menuScope.observe(anchorObserver, anchor);
- }
- menuScope.timer(() => {
- if (!closed) menuScope.listen(document, 'pointerdown', onOutside, true);
- }, 0);
- editor.textContent = [...String(initialRaw || '')].slice(0, 16).join('');
- syncInput();
- editor.focus();
- if (initialRaw) {
- placeEditorCursorAtEnd();
- }
- return true;
- }
-
- /* ============ 9. 楼中楼分批渲染 ============ */
- function renderSubReplyBatch(postNumber, ctx, pageDelta = 0, preferredParentNode = null) {
- const state = ctx.subReplyState.get(postNumber);
- const parentNode = preferredParentNode?.isConnected && +preferredParentNode.dataset.postNumber === +postNumber
- ? preferredParentNode
- : findReplyListParent(postNumber, ctx);
- if (!state || !parentNode) return;
- if (parentNode.classList.contains('ldp-nested-preview')) return;
- const children = parentNode.querySelector(':scope > .ldp-children');
- if (!children) return;
- const aggregate = PREFS.aggregateDescendantReplies === true;
- const totalCount = Math.max(state.all.length, +(state.totalCount || 0));
- const showAggregate = aggregate && (totalCount > 5 || state.all.some((reply) => [...(ctx.directRepliesByParent.get(+reply.post_number)?.values() || [])].some((child) => +child.reply_to_post_number === +reply.post_number)));
- const pageSize = showAggregate ? 2 : (aggregate ? 5 : SUB_REPLY_PAGE_SIZE);
- const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
- state.pageIndex = Math.max(0, Math.min(pageCount - 1, +(state.pageIndex || 0) + pageDelta));
- if (!state.nodeCache) state.nodeCache = new Map();
- const start = state.pageIndex * pageSize;
- const batch = state.all.slice(start, start + pageSize);
- const renderKey = `${showAggregate ? 1 : 0}:${state.pageIndex}:${state.all.length}:${totalCount}:${state.loadingMore ? 1 : 0}:${batch.map((reply) => +(reply.post_number || 0)).join(',')}`;
- const mountedReplies = [...children.children].filter((node) => node.classList.contains('ldp-post'));
- if (state.renderedParent === parentNode && state.renderedKey === renderKey &&
- mountedReplies.length === batch.length &&
- mountedReplies.every((node, index) => +node.dataset.postNumber === +(batch[index]?.post_number || 0))) {
- return;
- }
- parentNode.classList.remove('ldp-nested-branches-ready');
- clearCombinedParentPreviewLine(parentNode);
- const mountedByPostNumber = new Map(mountedReplies.map((node) => [+node.dataset.postNumber, node]));
- const desiredNodes = [];
- batch.forEach((rp) => {
- if (!rp.reply_to_post_number) rp.reply_to_post_number = postNumber;
- const replyPostNumber = +rp.post_number;
- let node = mountedByPostNumber.get(replyPostNumber) || state.nodeCache.get(replyPostNumber);
- if (!node) {
- node = attachPost(rp, ctx, {
- nestedList: true,
- preferredParentNode: parentNode,
- deferNestedSync: true,
- });
- if (node) {
- state.nodeCache.set(replyPostNumber, node);
- }
- }
- if (node) {
- if (node._ldpPostData !== rp)
- updateLivePostData(rp, ctx, { refreshContent: true });
- state.nodeCache.set(replyPostNumber, node);
- desiredNodes.push(node);
- }
- });
- const actionEl = parentNode.querySelector(':scope > .ldp-children > .ldp-sub-actions');
- const desiredSet = new Set(desiredNodes);
- mountedReplies.forEach((node) => {
- if (desiredSet.has(node)) return;
- state.nodeCache.set(+node.dataset.postNumber, node);
- removeOwnedNode(node, children);
- });
- let before = actionEl || null;
- for (let index = desiredNodes.length - 1; index >= 0; index--) {
- const node = desiredNodes[index];
- if (node.nextSibling !== before) children.insertBefore(node, before);
- ctx.tracker.observe(node);
- hydratePersistentAvatarImages(node);
- before = node;
- }
- parentNode.dataset.childReplyTotal = String(Math.max(+(parentNode.dataset.childReplyTotal || 0), state.all.length));
- const prevBtn = actionEl?.querySelector('.ldp-sub-page-prev');
- const nextBtn = actionEl?.querySelector('.ldp-sub-page-next');
- const infoEl = actionEl?.querySelector('.ldp-sub-page-info');
- const aggregateBtn = actionEl?.querySelector('.ldp-descendant-replies-open');
- [prevBtn, nextBtn, infoEl].forEach((control) => { if (control) control.hidden = showAggregate; });
- if (aggregateBtn) aggregateBtn.hidden = !showAggregate;
- if (actionEl && actionEl.parentNode === children && actionEl.nextSibling) children.appendChild(actionEl);
- if (showAggregate || pageCount > 1) {
- actionEl?.classList.add('show');
- if (prevBtn) prevBtn.disabled = !!state.loadingMore || state.pageIndex <= 0;
- if (nextBtn) nextBtn.disabled = !!state.loadingMore || state.pageIndex >= pageCount - 1;
- if (infoEl) infoEl.textContent = `${state.pageIndex + 1}/${pageCount}`;
- } else actionEl?.classList.remove('show');
- state.renderedParent = parentNode;
- state.renderedKey = renderKey;
- scheduleNestedBranchSync(parentNode, ctx);
- }
-
- function openDescendantRepliesWindow(rootPost, ctx, targetPostNumber = 0, {
- skipEscapeReturn = false,
- returnControl = null,
- returnViewport = null,
- restorePoint = null,
- } = {}) {
- if (!rootPost || !ctx?.modal || !ctx.repliesIO) return;
- ctx.replyThreadWindow?._ldpClose?.();
- const inlineSurface = !ctx.readerWorkspace?.isFullPage?.();
- const sourceControl = returnControl?.isConnected
- ? returnControl
- : document.activeElement?.closest?.('.ldp-descendant-replies-open');
- const sourcePost = sourceControl?.closest('.ldp-post');
- const returnViewportPostNumber = sourcePost
- ? 0
- : Math.max(0, Number(returnViewport?.postNumber) || 0);
- const continueAfterInlineReplies = !!returnControl?.isConnected &&
- +targetPostNumber === +rootPost.post_number;
- const displayedReplyNumbers = new Set(
- [...sourcePost?.querySelectorAll(':scope > .ldp-children .ldp-post[data-post-number]') || []]
- .filter((node) => !node.hidden && node.getClientRects().length > 0)
- .map((node) => +node.dataset.postNumber)
- .filter((number) => number > 0)
- );
- if (continueAfterInlineReplies) targetPostNumber = 0;
- const sourceAnchor = sourceControl?.querySelector('.ldp-icon') || sourceControl;
- const sourceAnchorOffset = sourceAnchor?.isConnected
- ? sourceAnchor.getBoundingClientRect().top - ctx.scrollRoot.getBoundingClientRect().top
- : NaN;
- const panel = makeElement('section');
- const layer = makeElement('div');
- const readWindowState = () => { try { const value = globalThis.GM_getValue?.(LDP_REPLY_WINDOW_STATE_KEY, {}); return value && typeof value === 'object' && !Array.isArray(value) && !isFunction(value.then) ? value : {}; } catch { return {}; } };
- const windowState = readWindowState();
- const surfaceHost = inlineSurface ? ctx.modal.querySelector('.ldp-reader-main') : ctx.modal;
- const resizeDirections = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
- if (!surfaceHost) return;
- layer.className = `ldp-descendant-replies-layer ${inlineSurface ? 'ldp-descendant-replies-layer-inline' : 'ldp-descendant-replies-layer-centered'}`;
- panel.className = `ldp-descendant-replies-window ${inlineSurface ? 'ldp-descendant-replies-inline' : 'ldp-descendant-replies-centered'}`;
- panel.dataset.returnPostNumber = String(
- sourcePost?.dataset.postNumber || returnViewportPostNumber || rootPost.post_number
- );
- panel.innerHTML = `
${inlineSurface ? '' : resizeDirections.map((direction) =>
- `
`
- ).join('')}`;
- if (!inlineSurface) ctx.modal.classList.add('ldp-descendant-replies-host-open');
- layer.append(panel); surfaceHost.append(layer); ctx.replyThreadWindow = panel; bindFloatingSurfaceWheel(layer);
- const header = panel.firstElementChild;
- const list = panel.querySelector('.ldp-descendant-replies-list');
- list.style.setProperty('--ldp-nested-content-scale', '.97');
- list.style.setProperty('--ldp-nested-component-scale', '.95');
- list.style.setProperty('--ldp-nested-component-gap', '7.6px');
- list.style.paddingBlock = '5px 7px';
- const title = header.querySelector('.ldp-descendant-replies-title');
- const closeButton = header.querySelector('.ldp-descendant-replies-close');
- const outerReaderControls = inlineSurface ? [
- ctx.modal.querySelector(':scope > .ldp-header'),
- ...ctx.modal.querySelectorAll(':scope > .ldp-reader-embed-resize,:scope > .ldp-reader-resize-handle'),
- ].filter(Boolean) : [];
- const outerReaderControlZIndexes = new Map(outerReaderControls.map((control) => [
- control,
- [control.style.getPropertyValue('z-index'), control.style.getPropertyPriority('z-index')],
- ]));
- outerReaderControls.forEach((control) => control.style.setProperty('z-index', '32'));
- const restoreOuterReaderControls = () => {
- outerReaderControlZIndexes.forEach(([value, priority], control) => {
- if (value) control.style.setProperty('z-index', value, priority);
- else control.style.removeProperty('z-index');
- });
- };
- const readIO = isFunction(IntersectionObserver) ? new IntersectionObserver((entries) => entries.forEach((entry) => {
- const visible = entry.isIntersecting && entry.intersectionRect.width > 0 &&
- entry.intersectionRect.height > 0;
- if (visible && !hydratePostContent(entry.target, ctx)) return;
- ctx.tracker.setVisible([entry.target.dataset.postNumber], visible);
- }), { root: list, threshold: 0 }) : null;
- const openedScope = `${CURRENT_SITE_HOST || location.host}:${ctx.topicId}`;
- const readOpenedBranchState = () => {
- try {
- const value = globalThis.GM_getValue?.(LDP_REPLY_BRANCH_STATE_KEY, {});
- return value && typeof value === 'object' && !Array.isArray(value) && !isFunction(value.then)
- ? { ...value } : {};
- } catch { return {}; }
- };
- const openedEntry = readOpenedBranchState()[openedScope];
- const openedBranches = new Set((Array.isArray(openedEntry?.posts) ? openedEntry.posts : [])
- .map(Number).filter((number) => Number.isInteger(number) && number > 0).slice(-512));
- let openedBranchesDirty = false;
- const persistOpenedBranches = () => {
- const state = readOpenedBranchState();
- state[openedScope] = { at: Date.now(), posts: [...openedBranches].slice(-512) };
- const bounded = Object.fromEntries(Object.entries(state)
- .filter(([, entry]) => entry && Number.isFinite(+entry.at) && Array.isArray(entry.posts))
- .sort((first, second) => +first[1].at - +second[1].at).slice(-64));
- try { void Promise.resolve(globalThis.GM_setValue?.(LDP_REPLY_BRANCH_STATE_KEY, bounded)).catch(() => {}); } catch {}
- };
- const initialRestorePoint = normalizeReaderAnchorPoint(restorePoint);
- let token = 0, stack = [], known = new Map(), queue = [], loading = false, returnPoint = null, historyPoint = initialRestorePoint, historyAnchor = null, threadRoot = rootPost, branchTarget = null;
- let branchLineSvg = null, branchLineFrame = 0, hoveredBranchNumber = 0, branchLineResizeObserver = null;
- const viewKey = () => `${CURRENT_SITE_HOST || location.host}:${ctx.topicId}:${+threadRoot.post_number}:${+branchTarget?.post_number || 0}`;
- const captureWindowPoint = () => {
- const listRect = list.getBoundingClientRect();
- const anchor = [...list.querySelectorAll(':scope > .ldp-post')]
- .find((node) => node.getBoundingClientRect().bottom > listRect.top);
- return anchor ? {
- number: +anchor.dataset.postNumber,
- scrollTop: Math.max(0, Number(list.scrollTop) || 0),
- offset: anchor.getBoundingClientRect().top - listRect.top,
- } : null;
- };
- const persistWindowState = () => {
- const point = captureWindowPoint();
- if (point) (windowState.views ||= {})[viewKey()] = { at: Date.now(), ...point };
- if (panelGeometryState) windowState.fullPageGeometry = {
- left: Math.round(panelGeometryState.left),
- top: Math.round(panelGeometryState.top),
- width: Math.round(panelGeometryState.width),
- height: Math.round(panelGeometryState.height),
- };
- windowState.views = Object.fromEntries(Object.entries(windowState.views || {}).sort((a, b) => +a[1].at - +b[1].at).slice(-128));
- try { void Promise.resolve(globalThis.GM_setValue?.(LDP_REPLY_WINDOW_STATE_KEY, windowState)).catch(() => {}); } catch {}
- };
- let panelGeometryState = null, panelPointer = null, panelGeometryFrame = 0;
- const panelGeometryLimits = () => {
- const margin = Math.max(0, Math.min(16,
- Math.floor((window.innerWidth - 1) / 2),
- Math.floor((window.innerHeight - 1) / 2)));
- const maxWidth = Math.max(1, window.innerWidth - margin * 2);
- const maxHeight = Math.max(1, window.innerHeight - margin * 2);
- return {
- left: margin,
- top: margin,
- right: window.innerWidth - margin,
- bottom: window.innerHeight - margin,
- minWidth: Math.min(320, maxWidth),
- minHeight: Math.min(240, maxHeight),
- maxWidth,
- maxHeight,
- };
- };
- const defaultPanelGeometry = () => {
- const rect = panel.getBoundingClientRect();
- return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
- };
- const normalizePanelGeometry = (next) => {
- const bounds = panelGeometryLimits();
- const fallback = defaultPanelGeometry();
- const number = (value, fallbackValue) => Number.isFinite(Number(value)) ? Number(value) : fallbackValue;
- const width = Math.max(bounds.minWidth, Math.min(bounds.maxWidth, number(next?.width, fallback.width)));
- const height = Math.max(bounds.minHeight, Math.min(bounds.maxHeight, number(next?.height, fallback.height)));
- return {
- left: Math.max(bounds.left, Math.min(bounds.right - width, number(next?.left, fallback.left))),
- top: Math.max(bounds.top, Math.min(bounds.bottom - height, number(next?.top, fallback.top))),
- width,
- height,
- };
- };
- const applyPanelGeometry = (next) => {
- if (inlineSurface) return;
- panelGeometryState = normalizePanelGeometry(next);
- panel.style.left = `${Math.round(panelGeometryState.left * 100) / 100}px`;
- panel.style.top = `${Math.round(panelGeometryState.top * 100) / 100}px`;
- panel.style.width = `${Math.round(panelGeometryState.width * 100) / 100}px`;
- panel.style.height = `${Math.round(panelGeometryState.height * 100) / 100}px`;
- panel.style.transform = 'none';
- };
- const resizePanelGeometry = (start, direction, deltaX, deltaY) => {
- const bounds = panelGeometryLimits();
- let left = start.left, top = start.top;
- let right = start.left + start.width, bottom = start.top + start.height;
- if (direction.includes('w')) left = Math.max(bounds.left, Math.min(right - bounds.minWidth, start.left + deltaX));
- if (direction.includes('e')) right = Math.max(left + bounds.minWidth, Math.min(bounds.right, start.left + start.width + deltaX));
- if (direction.includes('n')) top = Math.max(bounds.top, Math.min(bottom - bounds.minHeight, start.top + deltaY));
- if (direction.includes('s')) bottom = Math.max(top + bounds.minHeight, Math.min(bounds.bottom, start.top + start.height + deltaY));
- return { left, top, width: right - left, height: bottom - top };
- };
- const renderPanelPointer = () => {
- panelGeometryFrame = 0;
- if (!panelPointer) return;
- const deltaX = panelPointer.clientX - panelPointer.startX;
- const deltaY = panelPointer.clientY - panelPointer.startY;
- if (panelPointer.mode === 'move') {
- const bounds = panelGeometryLimits();
- applyPanelGeometry({
- ...panelPointer.state,
- left: Math.max(bounds.left, Math.min(
- bounds.right - panelPointer.state.width,
- panelPointer.state.left + deltaX,
- )),
- top: Math.max(bounds.top, Math.min(
- bounds.bottom - panelPointer.state.height,
- panelPointer.state.top + deltaY,
- )),
- });
- } else {
- applyPanelGeometry(resizePanelGeometry(panelPointer.state, panelPointer.mode, deltaX, deltaY));
- }
- };
- const stopPanelPointer = (event, persist = true) => {
- if (!panelPointer || event && event.pointerId !== panelPointer.pointerId) return;
- if (event && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
- panelPointer.clientX = event.clientX;
- panelPointer.clientY = event.clientY;
- }
- if (panelGeometryFrame) {
- cancelAnimationFrame(panelGeometryFrame);
- panelGeometryFrame = 0;
- renderPanelPointer();
- }
- const activePointer = panelPointer;
- panelPointer = null;
- try {
- if (activePointer.target.hasPointerCapture(activePointer.pointerId))
- activePointer.target.releasePointerCapture(activePointer.pointerId);
- } catch {}
- panel.classList.remove('ldp-descendant-replies-interacting');
- if (persist) persistWindowState();
- };
- const onPanelPointerDown = (event) => {
- if (inlineSurface || event.button !== 0 || !(event.target instanceof Element)) return;
- const resizeHandle = event.target.closest('.ldp-descendant-replies-resize-handle');
- const dragHeader = event.target.closest('.ldp-descendant-replies-header');
- if (!resizeHandle && (!dragHeader || event.target.closest('button,a,input,select,textarea'))) return;
- const target = resizeHandle || dragHeader;
- panelGeometryState = normalizePanelGeometry(panelGeometryState || windowState.fullPageGeometry);
- applyPanelGeometry(panelGeometryState);
- panelPointer = {
- pointerId: event.pointerId,
- mode: resizeHandle?.dataset.resize || 'move',
- startX: event.clientX,
- startY: event.clientY,
- clientX: event.clientX,
- clientY: event.clientY,
- state: { ...panelGeometryState },
- target,
- };
- target.setPointerCapture(event.pointerId);
- panel.classList.add('ldp-descendant-replies-interacting');
- consumeEvent(event);
- };
- const onPanelPointerMove = (event) => {
- if (!panelPointer || event.pointerId !== panelPointer.pointerId) return;
- panelPointer.clientX = event.clientX;
- panelPointer.clientY = event.clientY;
- if (!panelGeometryFrame) panelGeometryFrame = requestAnimationFrame(renderPanelPointer);
- event.preventDefault();
- };
- const onPanelViewportResize = () => {
- if (!inlineSurface) applyPanelGeometry(panelGeometryState || windowState.fullPageGeometry);
- };
- if (!inlineSurface) {
- header.style.cursor = 'move';
- header.style.touchAction = 'none';
- panel.addEventListener('pointerdown', onPanelPointerDown);
- panel.addEventListener('pointermove', onPanelPointerMove);
- ['pointerup', 'pointercancel', 'lostpointercapture'].forEach((type) =>
- panel.addEventListener(type, stopPanelPointer));
- window.addEventListener('resize', onPanelViewportResize);
- applyPanelGeometry(windowState.fullPageGeometry);
- }
- panel._ldpCaptureAnchorState = () => ({
- rootPostNumber: +rootPost.post_number,
- point: captureWindowPoint(),
- });
- window.addEventListener('pagehide', persistWindowState);
- let resolveTarget = null;
- const targetReady = targetPostNumber ? new Promise((resolve) => { resolveTarget = resolve; }) : Promise.resolve(true);
- const settleTarget = (result) => { if (resolveTarget) { resolveTarget(result); resolveTarget = null; } };
- const resolveReturnVisualTarget = (target) =>
- (sourceAnchor?.isConnected && sourceAnchor) ||
- target.querySelector(':scope > .ldp-children > .ldp-sub-actions > .ldp-descendant-replies-open > .ldp-icon') ||
- target.querySelector(':scope > .ldp-children > .ldp-sub-actions > .ldp-descendant-replies-open') ||
- target.querySelector(':scope > .ldp-content') ||
- target;
- const returnScrollOptions = Number.isFinite(sourceAnchorOffset)
- ? { ...PINNED_STREAM_SCROLL_OPTIONS, highlight: false, resolveVisualTarget: resolveReturnVisualTarget, alignOffset: sourceAnchorOffset }
- : { ...PINNED_STREAM_SCROLL_OPTIONS, highlight: false, resolveVisualTarget: resolveReturnVisualTarget, alignBottomCenter: true };
- const restoreViewportEntry = () => {
- if (!returnViewportPostNumber) return false;
- const returnTarget = getStreamPostNode(ctx, returnViewportPostNumber, { mount: true });
- const returnOffset = stableReaderViewportOffset(returnTarget, ctx, returnViewport?.postOffset);
- const restored = !!returnTarget && scrollToPost(returnViewportPostNumber, ctx, {
- ...PINNED_STREAM_SCROLL_OPTIONS,
- visualNode: returnTarget,
- alignOffset: returnOffset,
- highlight: false,
- });
- if (!restored) {
- const savedScrollTop = Number(returnViewport?.scrollTop);
- if (!Number.isFinite(savedScrollTop)) return false;
- clearStreamViewportAnchor(ctx);
- const previousScrollTop = ctx.scrollRoot.scrollTop;
- ctx.scrollRoot.scrollTop = Math.max(0, savedScrollTop);
- markStreamInternalScroll(ctx, previousScrollTop);
- }
- flushStreamWindow(ctx);
- captureStreamViewportAnchor(ctx);
- ctx.timeline?.sync();
- return true;
- };
- const restoreEntryPosition = () => {
- if (restoreViewportEntry()) return;
- scrollToPost(+panel.dataset.returnPostNumber, ctx, returnScrollOptions);
- };
- const workspaceHost = ctx.modal.closest('.ldp-overlay');
- let onWorkspaceChange = null;
- const close = (restoreEntry = true) => { stopPanelPointer(null, false); persistWindowState(); window.removeEventListener('pagehide', persistWindowState); window.removeEventListener('resize', onPanelViewportResize); if (onWorkspaceChange) workspaceHost?.removeEventListener('ldp-reader-workspace-change', onWorkspaceChange); restoreOuterReaderControls(); token++; settleTarget(false); ctx.tracker.setVisible([...panel.querySelectorAll('.ldp-post')].map((node) => node.dataset.postNumber), false); readIO?.disconnect(); branchLineResizeObserver?.disconnect(); if (branchLineFrame) cancelAnimationFrame(branchLineFrame); const previewPost = panel.querySelector('.ldp-floor-preview')?._ldpRelatedPost; if (previewPost) ctx.floorPreview?.hide(previewPost); panel.querySelectorAll('.ldp-post').forEach((node) => ctx.tracker.unobserve(node)); layer.remove(); if (!inlineSurface) ctx.modal.classList.remove('ldp-descendant-replies-host-open'); if (ctx.replyThreadWindow === panel) ctx.replyThreadWindow = null; if (restoreEntry && !sourceControl?.isConnected) requestAnimationFrame(restoreEntryPosition); };
- onWorkspaceChange = () => {
- if (inlineSurface === !ctx.readerWorkspace?.isFullPage?.()) return;
- close();
- };
- workspaceHost?.addEventListener('ldp-reader-workspace-change', onWorkspaceChange);
- panel._ldpClose = close;
- panel._ldpCloseFromEscape = () => close(returnViewportPostNumber > 0 || !skipEscapeReturn);
- layer.addEventListener('click', (event) => {
- if (event.target !== layer) return;
- const readerTitle = !inlineSurface && ctx.modal.querySelector('.ldp-title-jump');
- const titleRect = readerTitle?.getBoundingClientRect();
- if (titleRect && event.clientX >= titleRect.left && event.clientX <= titleRect.right &&
- event.clientY >= titleRect.top && event.clientY <= titleRect.bottom) {
- consumeEvent(event);
- readerTitle.click();
- return;
- }
- consumeEvent(event);
- close();
- });
- const positionTargetInView = (target) => {
- const listRect = list.getBoundingClientRect(), targetRect = target.getBoundingClientRect();
- const inset = 12;
- const targetFits = targetRect.height <= Math.max(0, listRect.height - inset * 2);
- const desiredTop = targetFits
- ? listRect.top + (listRect.height - targetRect.height) / 2
- : listRect.top + inset;
- const offset = targetRect.top - desiredTop;
- if (Math.abs(offset) >= .5) list.scrollTop += offset;
- const positionedRect = target.getBoundingClientRect();
- historyAnchor = { target, offset: positionedRect.top - listRect.top };
- return positionedRect.bottom > listRect.top + 1 && positionedRect.top < listRect.bottom - 1;
- };
- const focusTarget = () => {
- if (!targetPostNumber) return false;
- const target = list.querySelector(`:scope > .ldp-post[data-post-number="${+targetPostNumber}"]`);
- if (!target) return false;
- hydratePostContent(target, ctx);
- positionTargetInView(target);
- targetPostNumber = 0;
- void waitForReaderJumpLayout().then(() => {
- if (!target.isConnected || !panel.isConnected) {
- settleTarget(false);
- return;
- }
- const visible = positionTargetInView(target);
- keepJumpHighlightUntilSettled(target);
- settleTarget(visible);
- });
- return true;
- };
- const syncHistoryAnchor = () => {
- const target = historyAnchor?.target;
- if (!target?.isConnected) { historyAnchor = null; return; }
- const delta = target.getBoundingClientRect().top - list.getBoundingClientRect().top - historyAnchor.offset;
- if (Math.abs(delta) >= .5) list.scrollTop += delta;
- };
- const restoreHistoryPoint = () => {
- if (!historyPoint) return false;
- const target = list.querySelector(`:scope > .ldp-post[data-post-number="${historyPoint.number}"]`);
- if (!target) return false;
- const point = historyPoint; historyPoint = null; if (targetPostNumber) { targetPostNumber = 0; settleTarget(true); } list.scrollTop = point.scrollTop;
- historyAnchor = { target, offset: point.offset };
- requestAnimationFrame(() => {
- if (!target.isConnected) return;
- syncHistoryAnchor();
- keepJumpHighlightUntilSettled(target);
- });
- return true;
- };
- const parentOf = (post, number = +post?.post_number) =>
- +(post?.reply_to_post_number || ctx.replyParentByPost?.get(number) || 0);
- const cachedDescendantPosts = (postNumber) => {
- const posts = [], pending = [+postNumber], seen = new Set(pending);
- for (let parent; (parent = pending.shift());) {
- ctx.directRepliesByParent.get(parent)?.forEach((post, number) => {
- if (!seen.has(+number)) { seen.add(+number); posts.push(post); pending.push(+number); }
- });
- }
- return posts;
- };
- const replyPath = (post) => {
- const path = [], seen = new Set(); let current = post;
- while (current && !seen.has(+current.post_number)) {
- const number = +current.post_number; seen.add(number); path.unshift(current);
- current = known.get(parentOf(current, number));
- }
- return path;
- };
- const branchDescendantNumbers = (postNumber) => {
- const target = known.get(+postNumber), numbers = new Set(target ? [+postNumber] : []);
- if (!target) return numbers;
- known.forEach((post, number) => {
- const seen = new Set();
- for (let parent = parentOf(post, +number); parent && !seen.has(parent); parent = parentOf(known.get(parent), parent)) {
- if (parent === +postNumber) { numbers.add(+number); break; }
- seen.add(parent);
- }
- });
- return numbers;
- };
- const branchPostNumbers = (postNumber) => {
- const target = known.get(+postNumber), numbers = new Set();
- if (!target) return numbers;
- replyPath(target).forEach((post) => numbers.add(+post.post_number));
- branchDescendantNumbers(postNumber).forEach((number) => numbers.add(number));
- return numbers;
- };
- const branchPosts = (postNumber) => [...branchPostNumbers(postNumber)]
- .map((number) => known.get(number)).filter(Boolean);
- const svgNode = (name) => document.createElementNS('http://www.w3.org/2000/svg', name);
- const drawBranchLine = (postNumber) => {
- branchLineSvg?.remove(); branchLineSvg = null;
- const numbers = branchPostNumbers(postNumber);
- if (numbers.size < 2 || !list.isConnected) return;
- const listRect = list.getBoundingClientRect(), height = Math.max(list.scrollHeight, list.clientHeight);
- const points = new Map();
- numbers.forEach((number) => {
- const avatar = list.querySelector(`:scope > .ldp-post[data-post-number="${number}"] > .ldp-post-head .ldp-avatar`);
- if (avatar) { const rect = avatar.getBoundingClientRect(); points.set(number, { x: rect.left - listRect.left, y: rect.top + rect.height / 2 - listRect.top + list.scrollTop }); }
- });
- if (points.size < 2) return;
- const svg = svgNode('svg'), defs = svgNode('defs');
- svg.classList.add('ldp-thread-group-svg'); svg.setAttribute('aria-hidden', 'true');
- svg.style.setProperty('--ldp-thread-group-height', `${height}px`);
- svg.setAttribute('viewBox', `0 0 ${Math.max(1, list.clientWidth)} ${height}`);
- const branchGap = 5, fadeLength = 12;
- const radius = normalizeReplyLineRadius(parseFloat(getComputedStyle(list).getPropertyValue('--ldp-reply-line-radius')));
- const addPath = (d, stroke = '') => { const path = svgNode('path'); path.classList.add('ldp-thread-group-path'); path.setAttribute('d', d); if (stroke) path.style.stroke = stroke; svg.appendChild(path); };
- const addBranch = (x1, x2, y) => {
- const gradient = svgNode('linearGradient'), id = `ldp-thread-group-gradient-${++threadGroupGradientSerial}`;
- gradient.id = id; gradient.setAttribute('gradientUnits', 'userSpaceOnUse');
- gradient.setAttribute('x1', x1); gradient.setAttribute('x2', x2); gradient.setAttribute('y1', y); gradient.setAttribute('y2', y);
- [0, Math.max(0, 1 - fadeLength / Math.max(1, Math.abs(x2 - x1))), 1].forEach((offset, index) => {
- const stop = svgNode('stop'); stop.setAttribute('offset', offset); stop.setAttribute('stop-color', 'currentColor'); stop.setAttribute('stop-opacity', index === 2 ? '0' : '1'); gradient.appendChild(stop);
- });
- defs.appendChild(gradient); addPath(`M ${x1} ${y} H ${x2}`, `url(#${id})`);
- };
- svg.appendChild(defs);
- const ordered = [...points.values()].sort((a, b) => a.y - b.y);
- const axis = Math.min(...ordered.map((point) => point.x)) - radius - branchGap - fadeLength, first = ordered[0], last = ordered[ordered.length - 1];
- const paths = [`M ${axis} ${first.y + radius} V ${last.y - radius}`, `M ${axis} ${first.y + radius} Q ${axis} ${first.y} ${axis + radius} ${first.y}`];
- ordered.slice(1).forEach((point) => paths.push(`M ${axis} ${point.y - radius} Q ${axis} ${point.y} ${axis + radius} ${point.y}`));
- addPath(paths.join(' ')); ordered.forEach((point) => addBranch(axis + radius, point.x - branchGap, point.y));
- if (svg.querySelector('path')) { list.prepend(svg); branchLineSvg = svg; }
- };
- const scheduleBranchLine = (postNumber = hoveredBranchNumber || +branchTarget?.post_number || 0) => {
- if (branchLineFrame) cancelAnimationFrame(branchLineFrame);
- branchLineFrame = requestAnimationFrame(() => { branchLineFrame = 0; drawBranchLine(postNumber); });
- };
- if (isFunction(ResizeObserver)) {
- branchLineResizeObserver = new ResizeObserver(() => { syncHistoryAnchor(); scheduleBranchLine(); }); branchLineResizeObserver.observe(list);
- }
- const syncBranchControls = () => {
- if (openedBranchesDirty) { openedBranchesDirty = false; persistOpenedBranches(); }
- list.querySelectorAll('.ldp-descendant-replies-reroot').forEach((button) => {
- const number = +button.dataset.postNumber;
- const count = branchDescendantNumbers(number).size, opened = openedBranches.has(number);
- button.hidden = +branchTarget?.post_number === number;
- button.innerHTML = `${icon('gitBranch')}
${count}${opened ? ' ✓' : ''}`;
- button.classList.toggle('was-opened', opened);
- setLabel(button, `${opened ? '已打开过;' : ''}聚焦 #${number} 下的回复分支,当前已加载 ${count} 条`);
- });
- };
- const applyNestedReplySurfaceStyle = (node) => {
- node.classList.add('ldp-descendant-reply-compact');
- node.style.padding = '5px 0 7px';
- node.style.lineHeight = '1.5';
- node.style.borderBottom = 'none';
- node.style.setProperty('contain-intrinsic-size', 'auto 96px');
- const content = node.querySelector(':scope > .ldp-content');
- if (content) {
- content.style.fontSize = 'calc(var(--ldp-post-font-size,14px) * var(--ldp-nested-content-scale))';
- content.style.paddingBlock = '12px';
- if (content.firstElementChild) content.firstElementChild.style.marginTop = '0';
- if (content.lastElementChild) content.lastElementChild.style.marginBottom = '0';
- }
- [...node.children].forEach((element) => {
- if (element !== content) element.style.zoom = 'var(--ldp-nested-component-scale)';
- });
- const reactions = node.querySelector(':scope > .ldp-reactions');
- if (reactions) reactions.style.marginTop = '0';
- const postHead = node.querySelector(':scope > .ldp-post-head');
- if (postHead) {
- postHead.style.marginBottom = '0';
- postHead.style.flexWrap = 'wrap';
- }
- const parentJump = node.querySelector(':scope > .ldp-post-head > .ldp-jump-parent');
- if (parentJump) parentJump.style.marginLeft = '8px';
- node.querySelectorAll(':scope > .ldp-post-head > .ldp-user,:scope > .ldp-post-head > .ldp-time')
- .forEach((element) => { element.style.opacity = '.5'; });
- };
- const appendPost = (post, isRoot = false, depth = null) => {
- const number = +post.post_number;
- if (!number || known.has(number)) return null;
- known.set(number, post);
- if (branchTarget && (number === +branchTarget.post_number || depth === null) &&
- !openedBranches.has(number)) { openedBranches.add(number); openedBranchesDirty = true; }
- const node = renderPost({ ...post, reply_count: 0 }, !isRoot, ctx, { forceExpanded: true, showParentJump: !isRoot });
- node._ldpPostData = post; node.classList.remove('ldp-reply-collapsible', 'ldp-nested-collapsed');
- node.querySelectorAll(':scope > .ldp-nested-toggle,:scope > .ldp-nested-esc-hint,:scope > .ldp-collapse-replies,:scope > .ldp-children,:scope > .ldp-sub-loading').forEach((element) => element.remove());
- applyNestedReplySurfaceStyle(node);
- const parent = known.get(+post.reply_to_post_number);
- const parentJump = node.querySelector(':scope > .ldp-post-head > .ldp-jump-parent');
- const parentDisplayName = parent?.name || parent?.username || '';
- if (parent && parentJump) {
- parentJump.innerHTML = `${icon('info')}
`;
- parentJump.lastElementChild.textContent = `#${+parent.post_number} ${parentDisplayName}`;
- setLabel(parentJump, `在回复浮窗内跳到 ${parentDisplayName} 的楼层 #${+parent.post_number}`);
- const time = node.querySelector(':scope > .ldp-post-head > .ldp-time[data-exact-time]');
- const exactTime = time?.dataset.exactTime;
- if (exactTime) {
- const exactTimeLabel = makeElement('span');
- exactTimeLabel.className = 'ldp-reply-exact-time';
- exactTimeLabel.textContent = exactTime;
- exactTimeLabel.setAttribute('aria-hidden', 'true');
- parentJump.after(exactTimeLabel);
- }
- }
- const actions = node.querySelector(':scope > .ldp-reactions .ldp-actions');
- const branchAnchor = actions?.querySelector('.ldp-boostbtn') ||
- actions?.querySelector('.ldp-replybtn') || actions?.querySelector('.ldp-like');
- branchAnchor?.insertAdjacentHTML('afterend', `
`);
- if (branchTarget) {
- const branchDepth = Number.isInteger(depth) ? depth : +(list.querySelector(`:scope > .ldp-post[data-post-number="${+post.reply_to_post_number}"]`)?.dataset.ldpBranchDepth ?? -1) + 1;
- const indent = Math.min(4, Math.max(0, branchDepth)) * 12;
- node.dataset.ldpBranchDepth = String(branchDepth);
- node.style.setProperty('--ldp-descendant-branch-indent', `${indent}px`);
- }
- const before = [...list.querySelectorAll(':scope > .ldp-post')].find((item) => +item.dataset.postNumber > number);
- list.insertBefore(node, before || null); branchLineResizeObserver?.observe(node); bindPostMediaLayoutSync(node, ctx);
- hydratePersistentAvatarImages(node, POST_REQUEST_PRIORITY.visible);
- if (readerPostIsRead(post, ctx)) syncPostReadState(node, true);
- else if (readIO) readIO.observe(node); else if (hydratePostContent(node, ctx)) ctx.tracker.observe(node);
- if (+post.reply_count > 0) queue.push(post);
- return node;
- };
- const loadMore = async () => {
- if (loading || !queue.length) return;
- loading = true; const current = token; let partial = false;
- do {
- const parent = queue.shift();
- try {
- const children = await ctx.repliesIO.loadDirectReplies(parent, { priority: POST_REQUEST_PRIORITY.visible });
- if (current !== token) return;
- children.forEach((child) => appendPost(child));
- syncBranchControls();
- scheduleBranchLine();
- restoreHistoryPoint(); focusTarget();
- }
- catch { partial = true; }
- } while (current === token && queue.length && (targetPostNumber || historyPoint || list.scrollHeight < list.scrollTop + list.clientHeight * 2));
- if (current !== token || !panel.isConnected) return;
- loading = false; syncTitle(partial);
- if (!restoreHistoryPoint() && historyPoint && !queue.length) historyPoint = null;
- if (!focusTarget() && targetPostNumber && !queue.length) settleTarget(false);
- };
- const syncTitle = (partial = false) => {
- const authorName = threadRoot.name || threadRoot.username || '';
- title.textContent = `#${threadRoot.post_number} · ${authorName} · 查看完整讨论(${known.size})`;
- title.dataset.partial = partial ? '1' : '0';
- setLabel(closeButton, stack.length > 1
- ? `返回分支 #${stack[stack.length - 2].root.post_number}`
- : '关闭完整讨论(Esc)');
- };
- const livePostTasks = new Map();
- panel._ldpIngestLivePost = (post) => {
- const number = +post?.post_number;
- if (!number || known.has(number)) return Promise.resolve(false);
- if (livePostTasks.has(number)) return livePostTasks.get(number);
- const current = token, scopeNumber = +(branchTarget || threadRoot)?.post_number;
- const task = (async () => {
- const path = [], seen = new Set([number]); let child = post;
- for (let guard = 0; child && guard < 32; guard++) {
- const parentNumber = parentOf(child) || (scopeNumber === 1 ? 1 : 0);
- if (parentNumber === scopeNumber) { path.unshift(child); break; }
- if (!parentNumber || seen.has(parentNumber)) return false;
- seen.add(parentNumber); path.unshift(child);
- child = known.get(parentNumber) ||
- ctx.streamNodeMap?.get(parentNumber)?._ldpPostData ||
- ctx.streamItemMap?.get(parentNumber)?.node?._ldpPostData;
- if (!child && isFunction(ctx.loadPost)) {
- await ctx.loadPost(parentNumber, { mount: false, scope: 'single', priority: POST_REQUEST_PRIORITY.visible });
- child = ctx.streamNodeMap?.get(parentNumber)?._ldpPostData;
- }
- if (current !== token || !panel.isConnected) return false;
- }
- if (!path.length || (parentOf(path[0]) || (scopeNumber === 1 ? 1 : 0)) !== scopeNumber) return false;
- const nearBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= 120;
- const listRect = list.getBoundingClientRect();
- const anchor = nearBottom ? null : [...list.querySelectorAll(':scope > .ldp-post')]
- .find((node) => node.getBoundingClientRect().bottom > listRect.top);
- const offset = anchor?.getBoundingClientRect().top - listRect.top;
- path.forEach((item) => appendPost(item));
- if (anchor && Number.isFinite(offset)) { historyAnchor = { target: anchor, offset }; syncHistoryAnchor(); }
- syncBranchControls(); syncTitle(title.textContent.includes('(部分)')); scheduleBranchLine();
- if (nearBottom) requestAnimationFrame(() => { if (current === token) list.scrollTop = list.scrollHeight; });
- return true;
- })().finally(() => livePostTasks.delete(number));
- livePostTasks.set(number, task);
- return task;
- };
- const snapshotFrame = () => { const frame = stack[stack.length - 1]; if (frame) frame.posts = [...known.values()]; };
- const renderThread = (root, push = true, lineage = [], seedPosts = []) => {
- if (push) {
- const trail = [...(stack[stack.length - 1]?.trail || [])], number = +root.post_number;
- if (trail.at(-1) !== number) trail.push(number);
- stack.push({ root, lineage: [...lineage], posts: [...seedPosts], trail });
- }
- token++; branchTarget = lineage.length ? root : null; threadRoot = lineage[0] || root; loading = false; historyAnchor = null;
- if (!historyPoint && !targetPostNumber) historyPoint = windowState.views?.[viewKey()] || null;
- readIO?.disconnect();
- list.querySelectorAll('.ldp-post').forEach((node) => ctx.tracker.unobserve(node));
- branchLineResizeObserver?.disconnect(); branchLineResizeObserver?.observe(list);
- returnPoint = null;
- known = new Map(); queue = [];
- list.replaceChildren();
- lineage.forEach((post, index) => appendPost(post, index === 0, index));
- appendPost(root, !lineage.length, lineage.length);
- [...seedPosts].sort((a, b) => +a.post_number - +b.post_number).forEach((post) => appendPost(post));
- const loadedChildren = new Map();
- known.forEach((post) => { const parent = parentOf(post); if (known.has(parent)) loadedChildren.set(parent, (loadedChildren.get(parent) || 0) + 1); });
- queue = [...branchDescendantNumbers(root.post_number)].map((number) => known.get(number))
- .filter((post) => +post.reply_count > (loadedChildren.get(+post.post_number) || 0));
- syncBranchControls();
- hoveredBranchNumber = 0; scheduleBranchLine();
- syncTitle();
- const positioned = historyPoint ? restoreHistoryPoint() : focusTarget();
- if (queue.length || !positioned) void loadMore();
- };
- panel._ldpBack = () => {
- if (stack.length < 2) return false;
- snapshotFrame(); persistWindowState();
- stack.pop(); const previous = stack[stack.length - 1];
- historyPoint = previous.returnPoint || null; previous.returnPoint = null;
- renderThread(previous.root, false, previous.lineage, previous.posts); return true;
- };
- ['wheel', 'pointerdown', 'touchstart', 'keydown'].forEach((type) => list.addEventListener(type, () => { historyAnchor = null; }, { passive: true }));
- list.addEventListener('scroll', () => { scheduleBranchLine(); if (list.scrollTop + list.clientHeight * 2 >= list.scrollHeight) void loadMore(); }, { passive: true });
- panel.addEventListener('pointerover', (event) => {
- const button = event.target.closest?.('.ldp-descendant-replies-reroot');
- if (!button || button.contains(event.relatedTarget)) return;
- hoveredBranchNumber = +button.dataset.postNumber; scheduleBranchLine(hoveredBranchNumber);
- });
- panel.addEventListener('pointerout', (event) => {
- const button = event.target.closest?.('.ldp-descendant-replies-reroot');
- if (!button || button.contains(event.relatedTarget)) return;
- hoveredBranchNumber = 0; scheduleBranchLine();
- });
- panel.addEventListener('click', (event) => {
- if (event.target.closest('.ldp-descendant-replies-close')) {
- consumeEvent(event);
- if (!panel._ldpBack()) close();
- return;
- }
- if (event.target.closest('.ldp-descendant-replies-top')) { consumeEvent(event); historyPoint = historyAnchor = null; list.scrollTo({ top: 0, behavior: 'auto' }); return; }
- const returnButton = event.target.closest('.ldp-descendant-replies-return');
- if (returnButton) {
- consumeEvent(event);
- const source = returnPoint?.source, scrollTop = returnPoint?.scrollTop;
- returnButton.remove(); returnPoint = null;
- if (Number.isFinite(scrollTop)) list.scrollTo({ top: scrollTop, behavior: 'auto' });
- if (source?.isConnected) keepJumpHighlightUntilSettled(source);
- return;
- }
- const parentJump = event.target.closest('.ldp-jump-parent');
- if (parentJump) {
- consumeEvent(event);
- const source = parentJump.closest('.ldp-post');
- ctx.floorPreview?.hide(source);
- const target = list.querySelector(`:scope > .ldp-post[data-post-number="${+parentJump.dataset.targetPostNumber}"]`);
- if (!target) return;
- list.querySelector('.ldp-descendant-replies-return')?.remove();
- returnPoint = { source, scrollTop: list.scrollTop };
- target.querySelector(':scope > .ldp-post-head > .ldp-body-floor')?.insertAdjacentHTML('afterend', `
`);
- const targetTop = list.scrollTop + target.getBoundingClientRect().top - list.getBoundingClientRect().top;
- list.scrollTo({ top: Math.max(0, targetTop - 12), behavior: 'auto' });
- keepJumpHighlightUntilSettled(target);
- return;
- }
- const reroot = event.target.closest('.ldp-descendant-replies-reroot');
- if (reroot) {
- consumeEvent(event); const number = +reroot.dataset.postNumber, next = known.get(number);
- if (next) {
- const path = replyPath(next);
- if (+path[0]?.post_number !== +threadRoot.post_number ||
- path.some((post, index) => index && parentOf(post) !== +path[index - 1].post_number)) return;
- const wasOpened = openedBranches.has(number), descendants = branchDescendantNumbers(number);
- const seedPosts = branchPosts(number), savedPoint = windowState.views?.[
- `${CURRENT_SITE_HOST || location.host}:${ctx.topicId}:${+threadRoot.post_number}:${number}`
- ];
- const firstUnread = wasOpened && !savedPoint && seedPosts
- .filter((post) => descendants.has(+post.post_number) && !readerPostIsRead(post, ctx))
- .sort((a, b) => +a.post_number - +b.post_number)[0];
- branchDescendantNumbers(number).forEach((member) => {
- if (!openedBranches.has(member)) { openedBranches.add(member); openedBranchesDirty = true; }
- });
- const source = reroot.closest('.ldp-post'), current = stack[stack.length - 1];
- if (source && current) current.returnPoint = { number, scrollTop: list.scrollTop, offset: source.getBoundingClientRect().top - list.getBoundingClientRect().top };
- snapshotFrame(); persistWindowState();
- historyPoint = savedPoint || { number: +(firstUnread?.post_number || number), scrollTop: 0, offset: 12 };
- renderThread(next, true, path.length > 1 ? path.slice(0, -1) : [next], seedPosts);
- }
- }
- });
- const initialPosts = cachedDescendantPosts(rootPost.post_number);
- if (continueAfterInlineReplies && !windowState.views?.[viewKey()]) {
- const firstUnshownReply = initialPosts
- .filter((post) => !displayedReplyNumbers.has(+post.post_number))
- .sort((left, right) => +left.post_number - +right.post_number)[0];
- if (firstUnshownReply) {
- historyPoint = { number: +firstUnshownReply.post_number, scrollTop: 0, offset: 12 };
- }
- }
- renderThread(rootPost, true, [], initialPosts);
- return targetReady;
- }
-
- async function openAggregateReplyTarget(postNumber, ctx, options = {}) {
- const targetNumber = +postNumber;
- let root = ctx?.streamNodeMap?.get(targetNumber)?._ldpPostData;
- if (!root && ctx?.loadPost && targetNumber > 0) {
- await ctx.loadPost(targetNumber, {
- mount: false,
- priority: POST_REQUEST_PRIORITY.visible,
- scope: 'single',
- });
- root = ctx.streamNodeMap?.get(targetNumber)?._ldpPostData;
- }
- if (!root) return false;
- const openOptions = { ...options };
- if (!openOptions.returnControl?.isConnected &&
- !(Number(openOptions.returnViewport?.postNumber) > 0)) {
- openOptions.returnViewport = embeddedReaderViewportState(ctx);
- }
- let parentNumber = +(root.reply_to_post_number || ctx.replyParentByPost?.get(targetNumber) || 0);
- while (parentNumber > 1) {
- if (!ctx.streamNodeMap.has(parentNumber) && ctx.loadPost) {
- await ctx.loadPost(parentNumber, { mount: false, scope: 'single' });
- }
- const parent = ctx.streamNodeMap.get(parentNumber)?._ldpPostData;
- if (!parent) break;
- root = parent;
- parentNumber = +(parent.reply_to_post_number ||
- ctx.replyParentByPost?.get(+parent.post_number) || 0);
- }
- return openDescendantRepliesWindow(
- root,
- ctx,
- Number(openOptions.restorePoint?.number) > 0 ? 0 : targetNumber,
- openOptions,
- );
- }
-
- function ensureLiveReplyShell(parentNode, totalCount, ctx) {
- if (!parentNode) return null;
- const total = Math.max(1, +totalCount || 0);
- parentNode.dataset.childReplyTotal = String(Math.max(+(parentNode.dataset.childReplyTotal || 0), total));
- if (parentNode._ldpPostData) {
- parentNode._ldpPostData.reply_count = Math.max(+(parentNode._ldpPostData.reply_count || 0), total);
- }
- let children = parentNode.querySelector(':scope > .ldp-children');
- const created = !children;
- if (!children) {
- const holder = makeElement('template');
- holder.innerHTML = childReplyShellHtml(total);
- const [collapse, nextChildren, loading] = holder.content.children;
- const footer = parentNode.querySelector(':scope > .ldp-topic-footer-actions');
- [collapse, nextChildren, loading].forEach((node) => parentNode.insertBefore(node, footer || null));
- children = nextChildren;
- }
- setNestedReplyCollapsed(parentNode, parentNode._ldpPostData?.hidden === true);
- if (created) {
- const isTopicStarter = +parentNode.dataset.postNumber === 1;
- setChildRepliesCollapsed(parentNode, isTopicStarter || PREFS.expandNestedRepliesByDefault !== true);
- }
- if (ctx?.repliesIO) ctx.repliesIO.observe(parentNode);
- syncChildControls(parentNode);
- return children;
- }
-
- function syncLiveNestedReply(post, ctx, options = {}) {
- const postNumber = +(post?.post_number || 0);
- const parentNumber = +(post?.reply_to_post_number || 0);
- if (!ctx || !postNumber || parentNumber < 1) return false;
- const previous = ctx.subReplyState.get(parentNumber);
- const previousAll = previous && Array.isArray(previous.all) ? previous.all : [];
- const wasAlreadyKnown = previousAll.some((reply) => +(reply?.post_number || 0) === postNumber);
- rememberPostData(ctx, post, parentNumber);
- const parentNode = ctx.streamNodeMap.get(parentNumber);
- if (!parentNode) return false;
- const knownMap = ctx.directRepliesByParent.get(parentNumber);
- const knownReplies = knownMap ? [...knownMap.values()] : [];
- const merged = mergeDirectReplies(previousAll, knownReplies, [post]);
- const previousTotal = Math.max(previousAll.length, +(previous?.totalCount || 0));
- const pageSize = SUB_REPLY_PAGE_SIZE;
- const previousPageCount = Math.max(1, Math.ceil(previousTotal / pageSize));
- const wasOnLastPage = !previous || +(previous.pageIndex || 0) >= previousPageCount - 1;
- const totalCount = Math.max(
- merged.length,
- previousTotal + (options.incrementTotal === true && !wasAlreadyKnown ? 1 : 0),
- +(parentNode.dataset.childReplyTotal || 0),
- +(parentNode._ldpPostData?.reply_count || 0),
- );
- ensureLiveReplyShell(parentNode, totalCount, ctx);
- const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
- const liveReplyIndex = merged.findIndex((reply) => +(reply?.post_number || 0) === postNumber);
- const liveReplyPage = liveReplyIndex >= 0 ? Math.floor(liveReplyIndex / pageSize) : pageCount - 1;
- const state = {
- all: merged,
- pageIndex: wasOnLastPage ? liveReplyPage : +(previous?.pageIndex || 0),
- nodeCache: previous?.nodeCache,
- renderedKey: '',
- renderedParent: previous?.renderedParent,
- totalCount,
- fetchedReplyCount: Math.max(merged.length, +(previous?.fetchedReplyCount || 0)),
- fetchAfter: Math.max(postNumber, +(previous?.fetchAfter || 0)),
- hasMore: totalCount > merged.length || !!(previous?.hasMore),
- loadingMore: false,
- };
- ctx.subReplyState.set(parentNumber, state);
- if (parentNode.isConnected) renderSubReplyBatch(parentNumber, ctx, 0, parentNode);
- return true;
- }
-
- function updateLivePostData(post, ctx, options = {}) {
- const postNumber = +(post?.post_number || 0);
- const streamNode = ctx?.streamNodeMap && ctx.streamNodeMap.get(postNumber);
- const postId = +(post?.id || streamNode && streamNode.dataset.postId || 0);
- const copies = new Set(postId ? readerPostCopies(postId, ctx, true) : []);
- if (streamNode) copies.add(streamNode);
- if (!copies.size) return false;
- copies.forEach((node) => {
- node._ldpPostData = Object.assign({}, node._ldpPostData || {}, post);
- syncPostReadState(node, readerPostIsRead(node._ldpPostData, ctx));
- const content = node.querySelector(':scope > .ldp-content');
- if (options.refreshContent === true && content && post.cooked != null) {
- setPostCookedContent(content, post.cooked, node._ldpPostData.link_counts);
- content.classList.remove('ldp-content-pending');
- content.removeAttribute('aria-busy');
- bindPostMediaLayoutSync(node, ctx);
- }
- if (Array.isArray(post.polls)) renderPostPolls(node, ctx);
- renderPostSpecialContent(node, ctx);
- syncPostActionControls(node, ctx);
- ctx?.translation?.syncPost(node);
- });
- const mergedPost = streamNode?._ldpPostData || { ...post };
- rememberPostData(ctx, mergedPost);
- const replyCount = +(post.reply_count || 0);
- if (replyCount > 0 && streamNode) ensureLiveReplyShell(streamNode, replyCount, ctx);
- return true;
- }
-
- async function focusLiveNestedReply(post, ctx, options = {}) {
- const postNumber = +(post?.post_number || 0);
- const parentNumber = +(post?.reply_to_post_number || 0);
- if (!ctx || !postNumber || parentNumber < 1) return false;
- const navigationSequence = options.navigationSequence || beginReaderNavigation(ctx);
- const jumpStillCurrent = () => readerNavigationIsCurrent(ctx, navigationSequence) &&
- (!isFunction(options.acceptsWork) || options.acceptsWork());
- if (!jumpStillCurrent()) return false;
- if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition();
- if (!ctx.streamNodeMap.has(parentNumber) && ctx.loadPost) {
- await ctx.loadPost(parentNumber, { mount: true, ensureAround: true, scope: 'around' });
- }
- if (!jumpStillCurrent()) return false;
- const parentNode = getStreamPostNode(ctx, parentNumber, { mount: true }) ||
- ctx.streamNodeMap.get(parentNumber);
- if (!parentNode) return false;
- syncLiveNestedReply(post, ctx, { allowOpParent: true });
- parentNode.classList.remove('ldp-children-collapsed');
- setNestedReplyCollapsed(parentNode, false);
- const state = ctx.subReplyState.get(parentNumber);
- if (!state) return false;
- const pageSize = SUB_REPLY_PAGE_SIZE;
- const replyIndex = state.all.findIndex((reply) => +(reply?.post_number || 0) === postNumber);
- state.pageIndex = replyIndex >= 0
- ? Math.floor(replyIndex / pageSize)
- : Math.max(0, Math.ceil(state.all.length / pageSize) - 1);
- state.renderedKey = '';
- renderSubReplyBatch(parentNumber, ctx, 0, parentNode);
- syncChildControls(parentNode);
- const target = parentNode.querySelector(
- `:scope > .ldp-children > .ldp-post[data-post-number="${postNumber}"]`
- );
- if (!target) return false;
- syncPostActionControls(target, ctx);
- if (!scrollToPost(parentNumber, ctx, {
- ...PINNED_STREAM_SCROLL_OPTIONS,
- visualNode: target,
- highlightTarget: target,
- alignCenter: true,
- })) return false;
- await waitForReaderJumpLayout();
- if (!jumpStillCurrent() || !target.isConnected) return false;
- if (!readerTargetNodeIsVisible(target, ctx)) return false;
- if (ctx.timeline) ctx.timeline.sync();
- return true;
- }
-
- async function returnToReaderQuoteSource(source, ctx) {
- const target = normalizeReaderQuoteSource(source);
- if (!ctx || !target) return false;
- const { topicId, postNumber, parentPostNumber, nested } = target;
- if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false;
- if (topicId !== +ctx.topicId) {
- const sourceState = normalizeReaderAnchorState(
- CURRENT_OVERLAY?._ldpReaderHistoryNavigation?.states?.[topicId]
- ) || normalizeReaderAnchorState(target.anchor);
- beginReaderNavigation(ctx);
- if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition();
- await openModal(topicId, {
- targetPostNumber: sourceState?.viewport.postNumber ||
- (nested ? parentPostNumber : postNumber),
- restoreState: sourceState || undefined,
- quoteSourceTarget: sourceState ? undefined : target,
- });
- return true;
- }
- clearJumpBackLinks(ctx);
- if (nested) {
- let postData = ctx.streamNodeMap?.get(postNumber)?._ldpPostData ||
- postsFromPayload(ctx.topicData).find((post) => +post.post_number === postNumber);
- if (!postData && ctx.loadPost) {
- await ctx.loadPost(postNumber, { mount: false, scope: 'single' });
- postData = ctx.streamNodeMap?.get(postNumber)?._ldpPostData;
- }
- if (!postData) return false;
- postData = Object.assign({}, postData, {
- post_number: postNumber,
- reply_to_post_number: parentPostNumber,
- });
- return !!ctx.timeline && ctx.timeline.jumpTo(postNumber, {
- targetPost: postData,
- forceNested: true,
- });
- }
- return !!ctx.timeline && ctx.timeline.jumpTo(postNumber);
- }
-
- function ingestLivePosts(posts, ctx, options = {}) {
- const ordered = (Array.isArray(posts) ? posts : [])
- .filter((post) => post?.id && post.post_number)
- .sort((a, b) => +(a.post_number || 0) - +(b.post_number || 0));
- if (!ordered.length) return [];
- const fresh = [];
- ordered.forEach((post) => {
- if (!updateLivePostData(post, ctx, options)) fresh.push(post);
- });
- if (fresh.length) attachPostBatch(fresh, ctx, { syncStreamNow: true });
- const freshPostNumbers = new Set(fresh.map((post) => +(post.post_number || 0)));
- ordered.forEach((post) => syncLiveNestedReply(post, ctx, {
- incrementTotal: freshPostNumbers.has(+(post.post_number || 0)),
- }));
- if (ctx.isPostVoting) {
- ctx.streamItems.sort(comparePostVotingStreamItems);
- reindexStreamItems(ctx, 0);
- invalidateStreamLayout(ctx);
- }
- updateCommentsHeader(ctx);
- if (ctx.timeline) ctx.timeline.update();
- return fresh;
- }
-
- function selectedQuoteRaw(post, ctx, selectedText) {
- const postData = post?._ldpPostData || {};
- const username = cleanUsernameValue(postData.username || post && post.dataset.username);
- const postNumber = +(post && post.dataset.postNumber || 0);
- const topicId = +(ctx?.topicId || 0);
- const text = String(selectedText || '').trim();
- if (!username || !postNumber || !topicId || !text) return '';
- return `[quote="${username}, post:${postNumber}, topic:${topicId}"]\n${text}\n[/quote]\n\n`;
- }
-
- async function writeSelectionQuoteToClipboard(raw) {
- if (!navigator.clipboard || !isFunction(navigator.clipboard.writeText))
- throw new Error('clipboard unavailable');
- await navigator.clipboard.writeText(raw);
- }
-
- function showSelectionToast(message) {
- const existingToast = readerPortalQuery('.ldp-selection-toast');
- if (existingToast)
- existingToast.remove();
- const toast = makeElement('div');
- toast.className = 'ldp-selection-toast';
- toast.setAttribute('role', 'status');
- toast.textContent = message;
- appendReaderPortalNode(toast);
- setTimeout(() => toast.remove(), 1800);
- }
-
- async function shareReaderLink(ctx, postNode = null) {
- const sharingPost = !!postNode;
- const postNumber = +(postNode && postNode.dataset.postNumber || 0);
- if (!ctx || !ctx.topicId || (sharingPost && !postNumber)) return;
- const title = String(ctx?.topicData && ctx.topicData.title || document.title || SITE_DISPLAY_NAME);
- const url = topicPageUrl(ctx.topicId, sharingPost ? postNumber : 0);
- if (!url) return;
- if (!sharingPost && navigator.share) {
- try {
- await navigator.share({ title, url });
- return;
- } catch (error) {
- if (error && error.name === 'AbortError') return;
- }
- }
- try {
- await writeSelectionQuoteToClipboard(url);
- showSelectionToast(sharingPost ? `楼层 #${postNumber} 链接已复制到剪切板` : '帖子链接已复制到剪切板');
- } catch {
- showSelectionToast(sharingPost ? '复制楼层链接失败,请重试' : '复制链接失败,请重试');
- }
- }
-
- function syncReaderBookmarkState(ctx, type, itemId, bookmarked, bookmarkId, options = {}) {
- if (type === 'Post') {
- const updated = [];
- readerPostCopies(itemId, ctx, true).forEach((node) => {
- const postData = node._ldpPostData || options.fallback;
- if (!postData) return;
- node._ldpPostData = postData;
- Object.assign(postData, { bookmarked, bookmark_id: bookmarkId });
- syncPostActionControls(node, ctx, { bookmarkBusy: options.busy === true });
- if (options.persist === true) persistReaderPostState(ctx, postData);
- updated.push(postData);
- });
- return updated;
- }
- const topicData = options.fallback || ctx.topicData;
- ctx.topicBookmarked = bookmarked;
- if (topicData) {
- Object.assign(topicData, { bookmarked, bookmark_id: bookmarkId });
- if (options.persist === true) setCachedTopicData(ctx.topicId, topicData);
- }
- syncTopicActionControls(ctx, { bookmarkBusy: options.busy === true });
- return topicData ? [topicData] : [];
- }
-
- function discourseBookmarkApi() {
- const bookmarkApi = lookupDiscourse('service:bookmark-api');
- if (!bookmarkApi || !isFunction(bookmarkApi.buildNewBookmark) ||
- !isFunction(bookmarkApi.create) || !isFunction(bookmarkApi.delete))
- throw new Error('Discourse 收藏服务尚未就绪');
- return bookmarkApi;
- }
-
- async function createDiscourseBookmark(type, itemId) {
- const bookmarkApi = discourseBookmarkApi();
- const formDataModule = lookupDiscourseModule('discourse/lib/bookmark-form-data');
- const BookmarkFormData = formDataModule?.BookmarkFormData;
- if (!isFunction(BookmarkFormData))
- throw new Error('Discourse 收藏表单模型尚未就绪');
- const bookmark = bookmarkApi.buildNewBookmark(type, itemId);
- const saved = await bookmarkApi.create(new BookmarkFormData(bookmark));
- return Number(saved?.id) || null;
- }
-
- async function deleteDiscourseBookmark(bookmarkId) {
- if (!(Number(bookmarkId) > 0)) throw new Error('缺少收藏编号');
- return discourseBookmarkApi().delete(Number(bookmarkId));
- }
-
- async function toggleReaderBookmark(subject, ctx, type) {
- const postBookmark = type === 'Post';
- const item = postBookmark ? subject?._ldpPostData : subject;
- if (!item || !item.id || !ME_USERNAME) return;
- return runReaderActionOnce(ctx, `${type.toLowerCase()}:${item.id}:bookmark`, async () => {
- let { bookmarked, bookmarkId } = postBookmark
- ? postBookmarkInfo(item)
- : { bookmarked: !!item.bookmarked, bookmarkId: item.bookmark_id || null };
- const original = { bookmarked, bookmarkId };
- const apply = (busy = false) => syncReaderBookmarkState(
- ctx, type, item.id, bookmarked, bookmarkId, { busy, fallback: item }
- );
- let mutationSucceeded = false;
- apply(true);
- try {
- if (!bookmarked) {
- bookmarkId = await createDiscourseBookmark(type, item.id);
- bookmarked = true;
- } else {
- if (!bookmarkId && postBookmark) {
- const fresh = await fetchJSON(`${BASE}/posts/${item.id}.json`, {
- cacheMode: 'refresh',
- allowStaleOnError: false,
- });
- bookmarkId = fresh?.bookmark_id;
- }
- if (bookmarkId) {
- await deleteDiscourseBookmark(bookmarkId);
- } else if (postBookmark) {
- throw new Error('缺少楼层书签编号');
- } else {
- const topicModule = lookupDiscourseModule('discourse/models/topic');
- const Topic = topicModule?.default;
- if (!Topic || !isFunction(Topic.create)) {
- throw new Error('Discourse 主题模型尚未就绪');
- }
- const topicModel = Topic.create({ ...item, id: Number(item.id) });
- if (!isFunction(topicModel.deleteBookmarks)) {
- throw new Error('Discourse 主题收藏接口尚未就绪');
- }
- await topicModel.deleteBookmarks();
- }
- bookmarked = false;
- bookmarkId = null;
- }
- await clearStoredResponseCache({
- tags: ['bookmarks', `${type.toLowerCase()}:${item.id}`],
- });
- mutationSucceeded = true;
- if (isFunction(ctx.refreshBookmarksPanel)) ctx.refreshBookmarksPanel();
- if (postBookmark) showSelectionToast(bookmarked ? '已收藏该楼层' : '已取消楼层收藏');
- } catch (error) {
- if (postBookmark || !mutationSucceeded) ({ bookmarked, bookmarkId } = original);
- alert(`${postBookmark ? '楼层' : ''}收藏操作失败:${error.message}`);
- } finally {
- const updated = apply(false);
- if (postBookmark) {
- if (updated[0]) persistReaderPostState(ctx, updated[0]);
- } else {
- setCachedTopicData(ctx.topicId, item);
- }
- }
- });
- }
-
- const READER_REPORT_COPY = Object.freeze({
- spam: { label: '垃圾信息', description: '广告、恶意推广或明显无关的内容。' },
- inappropriate: { label: '不当内容', description: '违反社区文明交流规则的内容。' },
- off_topic: { label: '偏离主题', description: '与当前主题无关,影响讨论阅读。' },
- notify_moderators: {
- label: '通知管理人员',
- description: '需要管理人员结合补充说明进行判断。',
- requireMessage: true,
- },
- });
-
- function readerReportOptions(postModel) {
- const site = lookupDiscourse('service:site');
- const flagTypes = discourseModelValue(site, 'flagTypes');
- const actionByName = discourseModelValue(postModel, 'actionByName');
- if (!Array.isArray(flagTypes) || !actionByName) return [];
- return flagTypes.map((type) => {
- const nameKey = String(discourseModelValue(type, 'name_key') || '');
- const action = discourseModelValue(actionByName, nameKey);
- const id = Number(discourseModelValue(type, 'id'));
- if (!nameKey || !(id > 0) || !action || discourseModelValue(action, 'can_act') !== true)
- return null;
- const copy = READER_REPORT_COPY[nameKey] || {};
- return {
- id,
- nameKey,
- label: copy.label || String(discourseModelValue(type, 'name') || nameKey),
- description: copy.description || String(discourseModelValue(type, 'description') || ''),
- requireMessage: copy.requireMessage === true,
- action,
- };
- }).filter(Boolean);
- }
-
- function openReaderReportForm(ctx, config) {
- const reportOptions = config.options || [];
- if (!reportOptions.length) {
- showSelectionToast('当前没有可用的举报类型');
- return null;
- }
- const postActionTypeModule = lookupDiscourseModule('discourse/models/post-action-type');
- const messageMaxLength = Math.max(
- 1,
- Number(postActionTypeModule?.MAX_MESSAGE_LENGTH) || 500,
- );
- return openReaderActionDialog(ctx, {
- title: config.title,
- intro: config.intro ?? '举报会直接提交给社区,不会离开阅读器。',
- submitLabel: '提交举报',
- initialFocus: 'input[name="report_type"]',
- bodyHtml: `
-
${reportOptions.map((option, index) => `
-
-
-
- ${esc(option.label)}
- ${esc(option.description)}
-
- `).join('')}
-
- 补充说明
-
- `,
- onSubmit: async (formData) => {
- const actionType = +(formData.get('report_type') || 0);
- const message = String(formData.get('message') || '').trim();
- const selectedOption = reportOptions.find((option) => option.id === actionType);
- if (!selectedOption) throw new Error('请选择举报类型');
- if (selectedOption.requireMessage && !message) throw new Error(
- config.requiredMessageError || `${selectedOption.label}需要填写具体原因`
- );
- return config.onSubmit(actionType, message);
- },
- });
- }
-
- function openReaderActionDialog(ctx, options) {
- if (!ctx || !ctx.modal) return null;
- if (ctx.readerActionDialog) ctx.readerActionDialog.close();
- const layer = makeElement('div');
- layer.className = 'ldp-reader-action-layer';
- layer.innerHTML = `
-
-
- ${esc(options.title || '操作')}
-
-
-
- `;
- const form = layer.querySelector('.ldp-reader-action-form');
- const status = layer.querySelector('.ldp-reader-action-status');
- const submit = layer.querySelector('.ldp-reader-action-submit');
- let closeTimer = 0;
- let busy = false;
- const close = () => {
- if (closeTimer) clearTimeout(closeTimer);
- layer.remove();
- if (ctx.readerActionDialog && ctx.readerActionDialog.layer === layer)
- ctx.readerActionDialog = null;
- };
- const setBusy = (nextBusy) => {
- busy = nextBusy;
- form.querySelectorAll('input,textarea,select,button').forEach((control) => {
- control.disabled = nextBusy;
- });
- submit.textContent = nextBusy ? '提交中…' : (options.submitLabel || '提交');
- };
- bindFloatingSurfaceWheel(layer);
- layer.addEventListener('click', (event) => {
- const closeControl = event.target instanceof Element &&
- event.target.closest('.ldp-reader-action-close,.ldp-reader-action-cancel');
- if (closeControl && layer.contains(closeControl)) close();
- else if (event.target === layer && !busy) close();
- });
- form.addEventListener('submit', async (event) => {
- event.preventDefault();
- if (busy) return;
- status.classList.remove('success');
- status.textContent = '';
- setBusy(true);
- try {
- const successMessage = await options.onSubmit(new FormData(form));
- status.classList.add('success');
- status.textContent = successMessage || '操作已完成';
- closeTimer = setTimeout(close, 650);
- } catch (error) {
- status.textContent = error?.message ? error.message : '操作失败,请重试';
- setBusy(false);
- }
- });
- const dialogState = { layer, close };
- ctx.readerActionDialog = dialogState;
- ctx.modal.appendChild(layer);
- requestAnimationFrame(() => {
- const focusTarget = layer.querySelector(options.initialFocus || 'input,textarea,button');
- if (focusTarget) focusTarget.focus();
- });
- return dialogState;
- }
-
- function openReaderConfirmDialog(ctx, options) {
- if (!ctx || !ctx.modal) return null;
- if (ctx.readerActionDialog) ctx.readerActionDialog.close();
- const layer = makeElement('div');
- const previouslyFocused = document.activeElement;
- const note = String(options.note || '').trim();
- const details = Array.isArray(options.details) ? options.details.filter((detail) => detail?.label) : [];
- const secondaryLabel = String(options.secondaryLabel || '').trim();
- layer.className = 'ldp-reader-action-layer ldp-reader-confirm-layer';
- layer.innerHTML = `
-
-
-
${icon(options.icon || 'alertTriangle')}
-
-
${esc(options.title || '确认操作')}
-
${esc(options.message || '')}
- ${details.length ? `
${details.map((detail) => `- ${esc(detail.label)}${esc(detail.value || '')}
`).join('')}
` : ''}
- ${note ? `
${esc(note)}` : ''}
-
-
-
- `;
- const cancelButton = layer.querySelector('.ldp-reader-action-cancel');
- const secondaryButton = layer.querySelector('.ldp-reader-action-secondary');
- const confirmButton = layer.querySelector('.ldp-reader-action-submit');
- let closed = false;
- const close = () => {
- if (closed) return;
- closed = true;
- layer.remove();
- if (ctx.readerActionDialog && ctx.readerActionDialog.layer === layer) ctx.readerActionDialog = null;
- requestAnimationFrame(() => {
- if (previouslyFocused?.isConnected && isFunction(previouslyFocused.focus))
- previouslyFocused.focus();
- });
- };
- bindFloatingSurfaceWheel(layer);
- layer.addEventListener('click', (event) => {
- const target = event.target instanceof Element ? event.target : null;
- if (event.target === layer || target?.closest('.ldp-reader-action-cancel'))
- close();
- else if (target?.closest('.ldp-reader-action-secondary')) {
- if (closed) return;
- if (isFunction(options.onSecondary)) options.onSecondary();
- close();
- } else if (target?.closest('.ldp-reader-action-submit')) {
- if (closed) return;
- if (isFunction(options.onConfirm)) options.onConfirm();
- close();
- }
- });
- layer.addEventListener('keydown', (event) => {
- if (event.key !== 'Tab') return;
- const buttons = [cancelButton, secondaryButton, confirmButton].filter(Boolean);
- if (event.shiftKey && document.activeElement === buttons[0]) {
- event.preventDefault();
- buttons[buttons.length - 1].focus();
- } else if (!event.shiftKey && document.activeElement === buttons[buttons.length - 1]) {
- event.preventDefault();
- buttons[0].focus();
- }
- });
- const dialogState = { layer, close };
- ctx.readerActionDialog = dialogState;
- (ctx.modal.closest('.ldp-overlay') || ctx.modal).appendChild(layer);
- requestAnimationFrame(() => {
- if (layer.isConnected) cancelButton.focus();
- });
- return dialogState;
- }
-
- function openReaderReportDialog(postNode, ctx) {
- const post = postNode && (postNode._ldpPostData || {});
- const postId = +(post?.id || postNode && postNode.dataset.postId || 0);
- const postNumber = +(post?.post_number || postNode && postNode.dataset.postNumber || 0);
- if (!postId || !postNumber) return;
- const postModel = detachedNativePostModel(postNode, ctx);
- if (!postModel) {
- showSelectionToast('Discourse 楼层模型尚未就绪');
- return;
- }
- const reportOptions = readerReportOptions(postModel);
- openReaderReportForm(ctx, {
- title: `举报 #${postNumber}`,
- options: reportOptions,
- requiredMessageError: '通知管理人员时需要填写具体原因',
- onSubmit: async (actionType, message) => {
- const selected = reportOptions.find((option) => option.id === actionType);
- if (!selected || !isFunction(selected.action.act))
- throw new Error('Discourse 举报接口尚未就绪');
- if (postNumber === 1) selected.action.flagTopic = postModel;
- const result = await selected.action.act(postModel, { message });
- if (!result || result.acted !== true) throw new Error('举报提交失败,请重试');
- await clearStoredResponseCache({ tags: [`post:${postId}`] });
- post.can_flag = false;
- persistReaderPostState(ctx, post);
- syncAllPostActionControls(ctx);
- syncTopicActionControls(ctx);
- return '举报已提交';
- },
- });
- }
-
- function openReaderAssignDialog(postNode, ctx, targetType) {
- const post = postNode && (postNode._ldpPostData || {});
- const isTopic = targetType === 'Topic';
- const targetId = isTopic ? +ctx.topicId : +(post?.id || postNode && postNode.dataset.postId || 0);
- const postNumber = +(post?.post_number || postNode && postNode.dataset.postNumber || 0);
- if (!targetId) return;
- const currentAssignment = isTopic
- ? ctx.topicData?.assigned_to_user
- : post?.assigned_to_user;
- const currentUsername = currentAssignment?.username || '';
- openReaderActionDialog(ctx, {
- title: isTopic ? '指定主题负责人' : `指定 #${postNumber} 负责人`,
- intro: '输入社区用户名后直接提交,不会离开阅读器。',
- submitLabel: '确认指定',
- initialFocus: 'input[name="username"]',
- bodyHtml: `
-
- 用户名
-
-
-
- 备注(选填)
-
- `,
- onSubmit: async (formData) => {
- const username = String(formData.get('username') || '').trim().replace(/^@+/, '');
- const note = String(formData.get('note') || '').trim();
- if (!username) throw new Error('请输入要指定的用户名');
- const assignment = {
- username,
- note,
- targetId,
- targetType,
- };
- const taskActions = lookupDiscourse('service:task-actions');
- if (!taskActions || !isFunction(taskActions.putAssignment))
- throw new Error('Discourse 指定任务服务尚未就绪');
- await taskActions.putAssignment(assignment);
- await clearStoredResponseCache({
- tags: [isTopic ? `topic:${targetId}` : `post:${targetId}`],
- });
- const assignmentState = { username };
- if (isTopic && ctx.topicData) {
- ctx.topicData.assigned_to_user = assignmentState;
- setCachedTopicData(ctx.topicId, ctx.topicData);
- }
- if (!isTopic && post) {
- post.assigned_to_user = assignmentState;
- persistReaderPostState(ctx, post);
- }
- return `已指定给 @${username}`;
- },
- });
- }
-
- /* ============ 10. 事件委托 ============ */
- function bindActions(modal, ctx) {
- const floorPreview = bindFloorPreview(modal, ctx);
- const actionScope = createLifecycleScope();
- const onAction = (target, type, listener, options) => actionScope.listen(target, type, listener, options);
- ctx.floorPreview = floorPreview;
- let expandedCodeBlockState = null;
- const syncCodeBlockToggle = (block, expanded) => {
- const button = block?.querySelector(':scope > .ldp-code-block-actions [data-reader-code-action="toggle"]');
- if (!button) return;
- const lineCount = Math.max(11, Number(block.dataset.readerCodeLines) || 11);
- button.innerHTML = icon(expanded ? 'chevronUp' : 'chevronDown');
- setExpanded(button, expanded);
- setLabel(button, expanded ? '收起至前 10 行' : `展开全部 ${lineCount} 行`);
- };
- const collapseReaderCodeBlock = (restorePosition = true) => {
- const state = expandedCodeBlockState;
- if (!state) return false;
- expandedCodeBlockState = null;
- const { block, floorAnchor, floorTop, scrollTop } = state;
- if (block?.isConnected) {
- block.classList.remove('ldp-code-block-expanded');
- syncCodeBlockToggle(block, false);
- }
- if (restorePosition && ctx.scrollRoot) {
- const previousScrollTop = ctx.scrollRoot.scrollTop;
- if (floorAnchor?.isConnected)
- ctx.scrollRoot.scrollTop += floorAnchor.getBoundingClientRect().top - floorTop;
- else
- ctx.scrollRoot.scrollTop = scrollTop;
- if (Math.abs(ctx.scrollRoot.scrollTop - previousScrollTop) >= 0.5)
- markStreamInternalScroll(ctx, previousScrollTop);
- }
- if (block?.isConnected) syncReaderExpandableLayout(block, ctx);
- return true;
- };
- const toggleReaderCodeBlock = (block) => {
- if (!block?.classList.contains('ldp-code-block-collapsible')) return;
- if (expandedCodeBlockState?.block === block) {
- collapseReaderCodeBlock();
- return;
- }
- collapseReaderCodeBlock();
- const post = block.closest('.ldp-post');
- const floorAnchor = post?.querySelector(':scope > .ldp-post-head') || post;
- expandedCodeBlockState = {
- block,
- floorAnchor,
- floorTop: floorAnchor?.getBoundingClientRect().top || 0,
- scrollTop: ctx.scrollRoot?.scrollTop || 0,
- };
- block.classList.add('ldp-code-block-expanded');
- syncCodeBlockToggle(block, true);
- syncReaderExpandableLayout(block, ctx);
- };
- const userCardEventRoot = ensureReaderPortal() || modal.closest('.ldp-overlay') || modal;
- let userCardPrefetchTimer = 0;
- let userCardShowTimer = 0;
- let userCardHideTimer = 0;
- const cancelUserCardOpening = () => {
- clearTimeout(userCardPrefetchTimer);
- clearTimeout(userCardShowTimer);
- userCardPrefetchTimer = 0;
- userCardShowTimer = 0;
- };
- let userCardToken = 0;
- let userCardAnchor = null;
- let userCardPositionFrame = 0;
- let userCardAnchorObserver = null;
- const fallbackUserCard = getUserCardSurface();
- const userCardFollowPanel = getUserCardFollowPanel();
- const userCardFollowPreview = getUserCardSurface(true);
- let userCardFollowPreviewAnchor = null;
- let userCardFollowPreviewToken = 0;
- let userCardFollowPreviewShowTimer = 0;
- let userCardFollowPreviewHoverArmed = true;
- const userCardNavigationStack = [];
- const selectionToolbar = makeElement('div');
- selectionToolbar.className = 'ldp-selection-toolbar ldp-action-surface';
- selectionToolbar.hidden = true;
- selectionToolbar.setAttribute('role', 'toolbar');
- setLabel(selectionToolbar, '引用所选文字');
- selectionToolbar.innerHTML = `
-
-
`;
- appendReaderPortalNode(selectionToolbar);
- const imageQuoteToolbar = makeElement('div');
- imageQuoteToolbar.className = 'ldp-selection-toolbar ldp-image-quote-toolbar ldp-action-surface';
- imageQuoteToolbar.hidden = true;
- imageQuoteToolbar.setAttribute('role', 'toolbar');
- setLabel(imageQuoteToolbar, '引用图片');
- imageQuoteToolbar.innerHTML = '
';
- appendReaderPortalNode(imageQuoteToolbar);
- let selectedQuote = null;
- let selectionToolbarFrame = 0;
- let imageQuoteTarget = null;
- let imageQuotePositionFrame = 0;
- let imageQuoteHideTimer = 0;
- let imageQuoteShowTimer = 0;
- let imageQuoteCycleTimer = 0;
- let imageQuotePointerX = 0;
- let imageQuotePointerY = 0;
- let imageQuoteAnchorX = 0;
- let imageQuoteAnchorY = 0;
- let imageQuotePointerInside = false;
- let activeActionPost = null;
- const reactionHoverOpenTimers = new Map();
- const reactionHoverCloseTimers = new Map();
- let reactionVisibilityObserver = null;
- let reactionRegistryHydration = null;
-
- const clearReactionHoverTimer = (timers, root) => {
- const timer = timers.get(root);
- if (!timer) return;
- clearTimeout(timer);
- timers.delete(root);
- };
- const setReactionPickerExpanded = (root, open) => {
- clearReactionHoverTimer(reactionHoverOpenTimers, root);
- clearReactionHoverTimer(reactionHoverCloseTimers, root);
- if (!root || !root.isConnected) return;
- const picker = root.querySelector('.ldp-reaction-picker');
- const trigger = root.querySelector('[data-reaction-picker]');
- if (picker) picker.hidden = !open;
- if (trigger) {
- setExpanded(trigger, !!open);
- refreshTooltips(trigger);
- }
- if (open && root.getClientRects().length) reactionVisibilityObserver?.observe(root);
- else reactionVisibilityObserver?.unobserve(root);
- };
- const hydrateReactionRegistry = (refresh = false) => {
- if (!refresh && reactionEmojiUrls) return Promise.resolve(reactionEmojiUrls);
- if (reactionRegistryHydration) return reactionRegistryHydration;
- reactionRegistryHydration = loadReactionEmojiRegistry({ refresh }).then(() => {
- if (!reactionEmojiUrls || !modal.isConnected ||
- (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active'))) return;
- modal.querySelectorAll('.ldp-post').forEach((postNode) => {
- const root = postNode.querySelector(':scope > .ldp-reactions');
- const wasExpanded = !!(root?.querySelector('.ldp-reaction-picker:not([hidden])'));
- renderPostReactions(postNode, ctx);
- if (wasExpanded) setReactionPickerExpanded(postNode.querySelector(':scope > .ldp-reactions'), true);
- });
- const rail = ctx.topicActionRail;
- if (rail?._ldpPostData) {
- const railLike = rail.querySelector('.ldp-like');
- if (railLike) syncReaderLikeButton(railLike, readerPostRailReactionAction(rail._ldpPostData, ctx));
- renderPostReactions(rail, ctx, { collectLike: false, railPicker: true });
- rail.querySelector(':scope > .ldp-reactions > .ldp-reaction-summary')?.remove();
- }
- }).finally(() => { reactionRegistryHydration = null; });
- return reactionRegistryHydration;
- };
- hydrateReactionRegistry(!reactionEmojiRegistryNetworkRequested);
- if (isFunction(IntersectionObserver)) {
- reactionVisibilityObserver = new IntersectionObserver((entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting) return;
- const root = entry.target;
- setReactionPickerExpanded(root, false);
- });
- }, { threshold: 0 });
- }
- const onReactionPointerOver = (event) => {
- const root = event.target.closest && event.target.closest('.ldp-reactions');
- if (!root || !modal.contains(root)) return;
- clearReactionHoverTimer(reactionHoverCloseTimers, root);
- const trigger = event.target.closest('[data-reaction-picker]');
- if (!trigger || (event.relatedTarget && trigger.contains(event.relatedTarget))) return;
- hydrateReactionRegistry();
- clearReactionHoverTimer(reactionHoverOpenTimers, root);
- reactionHoverOpenTimers.set(root, setTimeout(() => {
- reactionHoverOpenTimers.delete(root);
- setReactionPickerExpanded(root, true);
- }, 250));
- };
- const onReactionPointerOut = (event) => {
- const root = event.target.closest && event.target.closest('.ldp-reactions');
- if (!root || !modal.contains(root)) return;
- const trigger = event.target.closest('[data-reaction-picker]');
- if (trigger && (!event.relatedTarget || !trigger.contains(event.relatedTarget))) {
- clearReactionHoverTimer(reactionHoverOpenTimers, root);
- }
- if (event.relatedTarget && root.contains(event.relatedTarget)) return;
- clearReactionHoverTimer(reactionHoverOpenTimers, root);
- clearReactionHoverTimer(reactionHoverCloseTimers, root);
- reactionHoverCloseTimers.set(root, setTimeout(() => {
- reactionHoverCloseTimers.delete(root);
- setReactionPickerExpanded(root, false);
- }, 250));
- };
-
- const activatePostActions = (event) => {
- const post = event.target.closest && event.target.closest('.ldp-post');
- if (!post || !modal.contains(post)) return;
- if (activeActionPost !== post) {
- if (activeActionPost) activeActionPost.classList.remove('ldp-actions-active');
- activeActionPost = post;
- activeActionPost.classList.add('ldp-actions-active');
- }
- hydratePostContextActions(post, ctx);
- };
- const clearActivePostActions = () => {
- if (activeActionPost) activeActionPost.classList.remove('ldp-actions-active');
- activeActionPost = null;
- };
- onAction(modal, 'focusin', activatePostActions);
- onAction(modal, 'pointerleave', clearActivePostActions);
-
- const hideSelectionToolbar = () => {
- setHidden(selectionToolbar, true);
- selectedQuote = null;
- };
-
- const clearSelectedText = () => {
- const selection = readerSelection();
- if (isFunction(selection?.removeAllRanges)) selection.removeAllRanges();
- };
-
- const updateSelectionToolbar = () => {
- const selection = readerSelection();
- if (!selection || selection.isCollapsed || !selection.rangeCount) return hideSelectionToolbar();
- const text = String(selection.toString() || '').trim();
- if (!text) return hideSelectionToolbar();
- const range = selection.getRangeAt(0);
- const startNode = range.startContainer.nodeType === Node.ELEMENT_NODE
- ? range.startContainer : range.startContainer.parentElement;
- const endNode = range.endContainer.nodeType === Node.ELEMENT_NODE
- ? range.endContainer : range.endContainer.parentElement;
- const startContent = startNode?.closest && startNode.closest('.ldp-content');
- const endContent = endNode?.closest && endNode.closest('.ldp-content');
- if (!startContent || startContent !== endContent || !modal.contains(startContent)) return hideSelectionToolbar();
- const post = startContent.closest('.ldp-post');
- const raw = selectedQuoteRaw(post, ctx, text);
- if (!post || !raw) return hideSelectionToolbar();
- const rect = range.getBoundingClientRect();
- if (!rect || (!rect.width && !rect.height)) return hideSelectionToolbar();
- selectedQuote = { post, raw };
- setHidden(selectionToolbar, false);
- const toolbarRect = selectionToolbar.getBoundingClientRect();
- const left = Math.max(8, Math.min(
- rect.right - toolbarRect.width,
- window.innerWidth - toolbarRect.width - 8,
- ));
- const above = rect.top - toolbarRect.height - 8;
- const top = above >= 8
- ? above
- : Math.min(window.innerHeight - toolbarRect.height - 8, rect.bottom + 8);
- selectionToolbar.style.left = `${Math.round(left)}px`;
- selectionToolbar.style.top = `${Math.round(Math.max(8, top))}px`;
- };
-
- const scheduleSelectionToolbar = () => {
- if (selectionToolbarFrame) return;
- selectionToolbarFrame = requestAnimationFrame(() => {
- selectionToolbarFrame = 0;
- updateSelectionToolbar();
- });
- };
- const onSelectionPointerDown = (event) => {
- if (selectionToolbar.contains(event.target) || imageQuoteToolbar.contains(event.target)) event.preventDefault();
- };
- const onDocumentPointerDown = (event) => {
- const path = isFunction(event.composedPath) ? event.composedPath() : [];
- if (!selectionToolbar.hidden && !path.includes(selectionToolbar) && !selectionToolbar.contains(event.target)) {
- hideSelectionToolbar();
- }
- };
- const onSelectionAction = async (event) => {
- const button = event.target.closest('[data-selection-action]');
- if (!button || !selectedQuote) return;
- const { post, raw } = selectedQuote;
- if (button.dataset.selectionAction === 'quote') {
- await tryOpenNativeReplyComposer(post, ctx, raw);
- hideSelectionToolbar();
- clearSelectedText();
- return;
- }
- try {
- await writeSelectionQuoteToClipboard(raw);
- showSelectionToast('引用已复制到剪切板');
- } catch {
- showSelectionToast('复制失败,请重试');
- }
- hideSelectionToolbar();
- clearSelectedText();
- };
-
- const clearImageQuoteCycleTimers = () => {
- clearTimeout(imageQuoteShowTimer);
- clearTimeout(imageQuoteCycleTimer);
- imageQuoteShowTimer = 0;
- imageQuoteCycleTimer = 0;
- };
- const hideImageQuoteToolbar = () => {
- clearTimeout(imageQuoteHideTimer);
- clearImageQuoteCycleTimers();
- imageQuoteHideTimer = 0;
- imageQuoteTarget = null;
- imageQuotePointerInside = false;
- setHidden(imageQuoteToolbar, true);
- };
- const closeTransientActions = () => {
- if (!selectionToolbar.hidden) {
- hideSelectionToolbar();
- clearSelectedText();
- return true;
- }
- if (!imageQuoteToolbar.hidden) {
- hideImageQuoteToolbar();
- return true;
- }
- const reactionPicker = modal.querySelector('.ldp-reaction-picker:not([hidden])');
- if (!reactionPicker) return false;
- const root = reactionPicker.closest('.ldp-reactions');
- setReactionPickerExpanded(root, false);
- return true;
- };
- ctx.closeTransientActions = closeTransientActions;
- const scheduleHideImageQuoteToolbar = () => {
- clearTimeout(imageQuoteHideTimer);
- imageQuoteHideTimer = setTimeout(hideImageQuoteToolbar, 140);
- };
- const positionImageQuoteToolbar = () => {
- imageQuotePositionFrame = 0;
- if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return hideImageQuoteToolbar();
- imageQuoteToolbar.hidden = false;
- const toolbarRect = imageQuoteToolbar.getBoundingClientRect();
- const gap = 12;
- const edge = 8;
- let left = imageQuoteAnchorX + gap;
- let top = imageQuoteAnchorY + gap;
- if (left + toolbarRect.width > window.innerWidth - edge) {
- left = imageQuoteAnchorX - toolbarRect.width - gap;
- }
- if (top + toolbarRect.height > window.innerHeight - edge) top = imageQuoteAnchorY - toolbarRect.height - gap;
- imageQuoteToolbar.style.left = `${Math.round(Math.max(edge, Math.min(left, window.innerWidth - toolbarRect.width - edge)))}px`;
- imageQuoteToolbar.style.top = `${Math.round(Math.max(edge, Math.min(top, window.innerHeight - toolbarRect.height - edge)))}px`;
- };
- const scheduleImageQuoteToolbarPosition = () => {
- if (imageQuotePositionFrame) return;
- imageQuotePositionFrame = requestAnimationFrame(positionImageQuoteToolbar);
- };
- const startImageQuoteCycle = () => {
- clearImageQuoteCycleTimers();
- imageQuoteToolbar.hidden = true;
- if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return;
- imageQuoteShowTimer = setTimeout(() => {
- imageQuoteShowTimer = 0;
- if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return;
- imageQuoteAnchorX = imageQuotePointerX;
- imageQuoteAnchorY = imageQuotePointerY;
- scheduleImageQuoteToolbarPosition();
- imageQuoteCycleTimer = setTimeout(() => {
- imageQuoteCycleTimer = 0;
- if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) {
- hideImageQuoteToolbar();
- return;
- }
- startImageQuoteCycle();
- }, 5000);
- }, 350);
- };
- const updateImageQuotePointer = (event) => {
- const image = lightboxImageFromEvent(event);
- if (!image) return;
- const shouldRestart = imageQuoteTarget !== image || !imageQuotePointerInside;
- clearTimeout(imageQuoteHideTimer);
- imageQuoteHideTimer = 0;
- imageQuoteTarget = image;
- imageQuotePointerInside = true;
- imageQuotePointerX = event.clientX;
- imageQuotePointerY = event.clientY;
- if (shouldRestart) startImageQuoteCycle();
- };
- const onImageQuotePointerUpdate = (event) => {
- if (!(event.target instanceof Element) || event.target.tagName !== 'IMG') return;
- updateImageQuotePointer(event);
- };
- const onImageQuotePointerOut = (event) => {
- if (!lightboxImageFromEvent(event)) return;
- imageQuotePointerInside = false;
- clearImageQuoteCycleTimers();
- const related = event.relatedTarget;
- if (related && (imageQuoteToolbar === related || imageQuoteToolbar.contains(related))) return;
- scheduleHideImageQuoteToolbar();
- };
- const keepImageQuoteToolbarOpen = () => {
- clearTimeout(imageQuoteHideTimer);
- imageQuoteHideTimer = 0;
- imageQuotePointerInside = false;
- clearImageQuoteCycleTimers();
- };
- const onImageQuoteAction = async (event) => {
- event.preventDefault();
- const image = imageQuoteTarget;
- const post = image?.closest('.ldp-post');
- const imageSet = image?.isConnected ? collectLightboxItems(image, ctx) : null;
- const item = imageSet && imageSet.items[imageSet.initialIndex];
- const raw = lightboxImageQuoteRaw(item, ctx);
- hideImageQuoteToolbar();
- if (!post || !raw) {
- showSelectionToast('无法确认图片引用来源');
- return;
- }
- await tryOpenNativeReplyComposer(post, ctx, raw);
- };
-
- onAction(modal, 'mouseup', scheduleSelectionToolbar);
- onAction(modal, 'keyup', scheduleSelectionToolbar);
- onAction(modal, 'pointerover', (event) => {
- activatePostActions(event);
- onReactionPointerOver(event);
- onImageQuotePointerUpdate(event);
- });
- onAction(modal, 'pointermove', onImageQuotePointerUpdate);
- onAction(modal, 'pointerout', (event) => {
- onReactionPointerOut(event);
- onImageQuotePointerOut(event);
- });
- onAction(selectionToolbar, 'pointerdown', onSelectionPointerDown);
- onAction(selectionToolbar, 'click', onSelectionAction);
- onAction(imageQuoteToolbar, 'pointerdown', onSelectionPointerDown);
- onAction(imageQuoteToolbar, 'pointerenter', keepImageQuoteToolbarOpen);
- onAction(imageQuoteToolbar, 'pointerleave', scheduleHideImageQuoteToolbar);
- onAction(imageQuoteToolbar, 'click', onImageQuoteAction);
- onAction(document, 'selectionchange', scheduleSelectionToolbar);
- onAction(document, 'pointerdown', onDocumentPointerDown, true);
- onAction(ctx.scrollRoot, 'scroll', () => {
- hideSelectionToolbar();
- hideImageQuoteToolbar();
- }, { passive: true });
-
- const closeUserCardFollowPreview = (options = {}) => {
- userCardFollowPreviewToken++;
- userCardFollowPreviewAnchor = null;
- clearTimeout(userCardFollowPreviewShowTimer);
- userCardFollowPreviewShowTimer = 0;
- if (options.suppressHoverUntilExit) userCardFollowPreviewHoverArmed = false;
- userCardFollowPreviewActionIntent = null;
- releasePersistentAvatarImages(userCardFollowPreview);
- userCardFollowPreview.classList.remove('open');
- delete userCardFollowPreview._ldpUserData;
- renderUserCardBreadcrumbs();
- };
-
- const navigationEntryFromUser = (user) => ({
- username: String(user?.username || ''),
- name: String(user && (user.name || user.username) || ''),
- user,
- followType: '',
- followUsers: null,
- search: '',
- page: 0,
- });
- const ensureUserCardNavigationRoot = () => {
- const user = fallbackUserCard._ldpUserData;
- const username = String(user?.username || '');
- if (!username) return null;
- if (!userCardNavigationStack.length) userCardNavigationStack.push(navigationEntryFromUser(user));
- return userCardNavigationStack[userCardNavigationStack.length - 1];
- };
- const rememberCurrentUserCardNavigationState = () => {
- const entry = ensureUserCardNavigationRoot();
- if (!entry) return;
- const activeCard = userCardNavigationStack.length > 1 ? userCardFollowPreview : fallbackUserCard;
- const displayedUser = activeCard._ldpUserData;
- if (String(displayedUser?.username || '') === entry.username) {
- entry.user = displayedUser;
- }
- entry.name = String(entry.user && (entry.user.name || entry.user.username) || entry.username);
- if (userCardFollowPanel.hidden || userCardFollowPanel.dataset.followUsername !== entry.username) return;
- entry.followType = String(userCardFollowPanel.dataset.followType || '');
- entry.followUsers = Array.isArray(userCardFollowPanel._ldpFollowUsers)
- ? userCardFollowPanel._ldpFollowUsers
- : null;
- entry.search = userCardFollowPanel.querySelector('input[type="search"]')?.value || '';
- entry.page = Math.max(0, Number(userCardFollowPanel.dataset.followPage) || 0);
- };
- const renderUserCardBreadcrumbs = () => {
- const breadcrumbs = userCardFollowPanel.querySelector('.ldp-user-card-breadcrumbs');
- if (!breadcrumbs) return;
- const entries = userCardNavigationStack.map((entry, index) => ({ ...entry, index }));
- const previewUser = userCardFollowPreview.classList.contains('open') && userCardFollowPreview._ldpUserData;
- if (previewUser && String(previewUser.username || '') !== String(entries[entries.length - 1]?.username || '')) {
- entries.push({ ...navigationEntryFromUser(previewUser), index: -1, preview: true });
- }
- breadcrumbs.hidden = entries.length < 2;
- breadcrumbs.innerHTML = entries.map((entry, index) => {
- const label = entry.name || entry.username;
- const last = index === entries.length - 1;
- const crumb = last
- ? `
${esc(label)}`
- : `
`;
- return `${index ? '
›' : ''}${crumb}`;
- }).join('');
- breadcrumbs.scrollLeft = breadcrumbs.scrollWidth;
- };
- const renderSecondaryUserCard = (user) => {
- if (!user || !user.username) return false;
- userCardFollowPreviewToken++;
- userCardFollowPreviewAnchor = null;
- clearTimeout(userCardFollowPreviewShowTimer);
- userCardFollowPreviewShowTimer = 0;
- releasePersistentAvatarImages(userCardFollowPreview);
- userCardFollowPreview._ldpUserData = user;
- userCardFollowPreview.innerHTML = renderFallbackUserCard(
- user,
- user.username,
- 'ready',
- user._ldp_avatar_preview_url || ''
- );
- hydratePersistentAvatarImages(userCardFollowPreview, POST_REQUEST_PRIORITY.userCard);
- syncUserCardBadgeScrollControls(userCardFollowPreview);
- requestAnimationFrame(() => userCardFollowPreview.isConnected &&
- syncUserCardBadgeScrollControls(userCardFollowPreview));
- userCardFollowPreview.classList.add('open');
- positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
- return true;
- };
- const clearUserCardFollowPanelState = () => {
- delete userCardFollowPanel._ldpFollowUsers;
- delete userCardFollowPanel.dataset.followType;
- delete userCardFollowPanel.dataset.followUsername;
- delete userCardFollowPanel.dataset.followPage;
- const list = userCardFollowPanel.querySelector('.ldp-user-card-follow-list');
- if (list) {
- releasePersistentAvatarImages(list);
- list.replaceChildren();
- }
- [fallbackUserCard, userCardFollowPreview].forEach((card) =>
- card.querySelectorAll('[data-user-card-follow-type]')
- .forEach((button) => setExpanded(button, false)));
- };
- const restoreUserCardNavigationEntry = (index) => {
- if (!Number.isInteger(index) || index < 0 || index >= userCardNavigationStack.length) return false;
- rememberCurrentUserCardNavigationState();
- const entry = userCardNavigationStack[index];
- userCardNavigationStack.length = index + 1;
- if (index === 0) closeUserCardFollowPreview();
- else if (!renderSecondaryUserCard(entry.user)) return false;
- clearUserCardFollowPanelState();
- if (entry.followType && Array.isArray(entry.followUsers)) {
- userCardFollowPanel.hidden = false;
- userCardFollowPanel.dataset.followType = entry.followType;
- userCardFollowPanel.dataset.followUsername = entry.username;
- userCardFollowPanel._ldpFollowUsers = entry.followUsers;
- userCardFollowPanel.querySelector('.ldp-user-card-follow-title').textContent =
- entry.followType === 'following' ? '关注的人员' : '被关注的人员';
- const search = userCardFollowPanel.querySelector('input[type="search"]');
- if (search) search.value = entry.search || '';
- userCardFollowPanel.dataset.followPage = String(Math.max(0, Number(entry.page) || 0));
- const activeCard = index === 0 ? fallbackUserCard : userCardFollowPreview;
- activeCard.querySelectorAll('[data-user-card-follow-type]').forEach((button) =>
- setExpanded(button, button.dataset.userCardFollowType === entry.followType));
- renderUserCardFollowList(userCardFollowPanel);
- positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
- if (index > 0) positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
- } else {
- userCardFollowPanel.hidden = true;
- }
- renderUserCardBreadcrumbs();
- return true;
- };
- const promoteUserCardFollowPreview = () => {
- const user = userCardFollowPreview._ldpUserData;
- const username = String(user?.username || '');
- if (!username) return false;
- rememberCurrentUserCardNavigationState();
- const current = userCardNavigationStack[userCardNavigationStack.length - 1];
- if (!current || current.username !== username) userCardNavigationStack.push(navigationEntryFromUser(user));
- else current.user = user;
- clearUserCardFollowPanelState();
- renderUserCardBreadcrumbs();
- return true;
- };
-
- const closeUserCardFollowPanel = () => {
- closeUserCardFollowPreview();
- if (userCardFollowPanel.hidden) return;
- userCardFollowPanel.hidden = true;
- userCardFollowPanel.style.removeProperty('max-height');
- fallbackUserCard.style.removeProperty('max-height');
- if (userCardNavigationStack.length > 1) userCardNavigationStack.length = 1;
- clearUserCardFollowPanelState();
- if (fallbackUserCardOpen() && userCardAnchor) positionFallbackUserCard(fallbackUserCard, userCardAnchor);
- };
-
- const closeUserCard = () => {
- const avatarViewer = readerPortalQuery('.ldp-avatar-viewer');
- if (avatarViewer?._ldpAnchorCard === fallbackUserCard && isFunction(avatarViewer._ldpClose)) {
- avatarViewer._ldpClose();
- }
- closeUserCardFollowPanel();
- cancelQueuedUserCardRequests(getUserCardUsername(userCardAnchor), ctx.postRequestScheduler);
- userCardToken++;
- userCardAnchor = null;
- cancelUserCardOpening();
- clearTimeout(userCardHideTimer);
- userCardHideTimer = 0;
- if (userCardPositionFrame) cancelAnimationFrame(userCardPositionFrame);
- userCardPositionFrame = 0;
- userCardAnchorObserver?.disconnect();
- userCardAnchorObserver = null;
- userCardNavigationStack.length = 0;
- hideFallbackUserCard();
- };
- const closeUserCardLayer = () => {
- if (userCardFollowPreview.classList.contains('open')) {
- const activeEntry = userCardNavigationStack[userCardNavigationStack.length - 1];
- const displayedUsername = String(userCardFollowPreview._ldpUserData?.username || '');
- if (userCardNavigationStack.length < 2 || displayedUsername !== activeEntry?.username) {
- if (userCardNavigationStack.length > 1 && activeEntry?.user) {
- userCardFollowPreviewHoverArmed = false;
- renderSecondaryUserCard(activeEntry.user);
- renderUserCardBreadcrumbs();
- return;
- }
- return closeUserCardFollowPreview({ suppressHoverUntilExit: true });
- }
- }
- if (userCardNavigationStack.length > 1) {
- return restoreUserCardNavigationEntry(userCardNavigationStack.length - 2);
- }
- if (!userCardFollowPanel.hidden) return closeUserCardFollowPanel();
- const notificationMenu = fallbackUserCard.querySelector('.ldp-user-card-notification-menu:not([hidden])');
- if (!notificationMenu) return closeUserCard();
- notificationMenu.hidden = true;
- fallbackUserCard.querySelector('[data-user-card-action="notifications"]')?.setAttribute('aria-expanded', 'false');
- if (userCardAnchor) positionFallbackUserCard(fallbackUserCard, userCardAnchor);
- };
- ctx.closeUserCard = closeUserCard;
- ctx.closeUserCardLayer = closeUserCardLayer;
-
- const userCardAnchorIsVisible = (anchor) => {
- if (!anchor || !anchor.isConnected) return false;
- const rect = anchor.getBoundingClientRect();
- return rect.width > 0 && rect.height > 0 &&
- rect.bottom > 0 && rect.top < window.innerHeight &&
- rect.right > 0 && rect.left < window.innerWidth;
- };
- const syncUserCardPosition = () => {
- userCardPositionFrame = 0;
- if (!fallbackUserCardOpen() || !userCardAnchor) return;
- if (!userCardAnchorIsVisible(userCardAnchor)) return closeUserCard();
- positionFallbackUserCard(fallbackUserCard, userCardAnchor);
- positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
- if (userCardFollowPreview.classList.contains('open')) {
- positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
- }
- };
- const scheduleUserCardPosition = () => {
- if (!fallbackUserCardOpen() || userCardPositionFrame) return;
- userCardPositionFrame = requestAnimationFrame(syncUserCardPosition);
- };
- const observeUserCardAnchor = (anchor) => {
- userCardAnchorObserver?.disconnect();
- userCardAnchorObserver = null;
- if (!isFunction(IntersectionObserver) || !anchor) return;
- userCardAnchorObserver = new IntersectionObserver((entries) => {
- const entry = entries.find((item) => item.target === anchor);
- if (userCardAnchor !== anchor || !entry) return;
- if (!entry.isIntersecting) closeUserCard();
- else scheduleUserCardPosition();
- }, { threshold: 0 });
- userCardAnchorObserver.observe(anchor);
- };
-
- onAction(document, 'scroll', scheduleUserCardPosition, true);
- onAction(window, 'scroll', scheduleUserCardPosition, { passive: true });
- onAction(window, 'resize', scheduleUserCardPosition, { passive: true });
-
- const scheduleCloseUserCard = (event) => {
- if (!userCardFollowPanel.hidden) return;
- const relatedTarget = event?.relatedTarget;
- const avatarViewer = readerPortalQuery('.ldp-avatar-viewer');
- if (relatedTarget && (fallbackUserCard.contains(relatedTarget) || userCardFollowPanel.contains(relatedTarget) ||
- userCardFollowPreview.contains(relatedTarget) || avatarViewer?.contains(relatedTarget))) return;
- clearTimeout(userCardHideTimer);
- userCardHideTimer = setTimeout(() => {
- userCardHideTimer = 0;
- const currentAvatarViewer = readerPortalQuery('.ldp-avatar-viewer');
- if (userCardAnchor?.matches(':hover') || fallbackUserCard.matches(':hover') ||
- userCardFollowPanel.matches(':hover') || userCardFollowPreview.matches(':hover') ||
- currentAvatarViewer?.matches(':hover')) return;
- closeUserCard();
- }, 180);
- };
-
- const keepUserCardOpen = () => clearTimeout(userCardHideTimer);
- const openUserCardMediaViewer = (...args) => {
- const viewer = openUserAvatarViewer(...args);
- if (!viewer) return;
- viewer.addEventListener('mouseenter', keepUserCardOpen);
- viewer.addEventListener('mouseleave', scheduleCloseUserCard);
- };
- onAction(fallbackUserCard, 'mouseenter', keepUserCardOpen);
- onAction(fallbackUserCard, 'mouseleave', scheduleCloseUserCard);
- onAction(userCardFollowPanel, 'mouseenter', keepUserCardOpen);
- onAction(userCardFollowPanel, 'mouseleave', scheduleCloseUserCard);
- onAction(userCardFollowPreview, 'mouseenter', keepUserCardOpen);
-
- const onUserCardHostLinkClick = (event) => {
- const link = event.target.closest && event.target.closest('a[href]');
- if (!link || (!fallbackUserCard.contains(link) && !userCardFollowPanel.contains(link) &&
- !userCardFollowPreview.contains(link))) return;
- openEmbeddedReaderLinkInHost(event, link, ctx.readerWorkspace);
- };
- onAction(fallbackUserCard, 'click', onUserCardHostLinkClick, true);
- onAction(userCardFollowPanel, 'click', onUserCardHostLinkClick, true);
- onAction(userCardFollowPreview, 'click', onUserCardHostLinkClick, true);
-
- const onUserCardWheel = (event) => {
- event.stopPropagation();
- const badgeList = event.target instanceof Element && event.target.closest('.ldp-user-card-badge-list');
- if (badgeList && fallbackUserCard.contains(badgeList) && badgeList.scrollWidth > badgeList.clientWidth + 1) {
- const rawHorizontalDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
- const horizontalDelta = event.deltaMode === 1
- ? rawHorizontalDelta * 40
- : event.deltaMode === 2 ? rawHorizontalDelta * badgeList.clientWidth : rawHorizontalDelta;
- const maxScrollLeft = badgeList.scrollWidth - badgeList.clientWidth;
- const canScrollHorizontally = horizontalDelta < 0
- ? badgeList.scrollLeft > 0
- : badgeList.scrollLeft < maxScrollLeft - 1;
- if (horizontalDelta && canScrollHorizontally) {
- event.preventDefault();
- badgeList.scrollLeft += horizontalDelta;
- return;
- }
- }
- containFloatingSurfaceWheel(fallbackUserCard, event);
- };
- onAction(fallbackUserCard, 'wheel', onUserCardWheel, { passive: false });
- const onUserCardFollowPanelWheel = (event) => {
- const list = userCardFollowPanel.querySelector('.ldp-user-card-follow-list');
- if (list && !list.contains(event.target) && event.deltaY && list.scrollHeight > list.clientHeight + 1) {
- const delta = event.deltaMode === 1
- ? event.deltaY * 40
- : event.deltaMode === 2 ? event.deltaY * list.clientHeight : event.deltaY;
- consumeEvent(event);
- list.scrollTop += delta;
- return;
- }
- containFloatingSurfaceWheel(userCardFollowPanel, event);
- };
- onAction(userCardFollowPanel, 'wheel', onUserCardFollowPanelWheel, { passive: false });
- const onUserCardFollowPreviewWheel = (event) => {
- event.stopPropagation();
- containFloatingSurfaceWheel(userCardFollowPreview, event);
- };
- onAction(userCardFollowPreview, 'wheel', onUserCardFollowPreviewWheel, { passive: false });
- const onUserCardBadgeListScroll = (event) => {
- if (!(event.target instanceof Element) || !event.target.matches('.ldp-user-card-badge-list')) return;
- syncUserCardBadgeScrollControls(event.currentTarget);
- };
- onAction(fallbackUserCard, 'scroll', onUserCardBadgeListScroll, true);
- onAction(userCardFollowPreview, 'scroll', onUserCardBadgeListScroll, true);
-
- const setUserCardActionStatus = (card, message, isError = false) => {
- const status = card?.querySelector('.ldp-user-card-action-status');
- if (!status) return;
- status.textContent = message || '';
- status.classList.toggle('is-error', !!isError);
- };
-
- const onUserCardAction = async (event) => {
- const actionCard = userCardFollowPreview.contains(event.target)
- ? userCardFollowPreview
- : fallbackUserCard.contains(event.target) ? fallbackUserCard : null;
- if (actionCard === userCardFollowPreview) {
- const directControl = event.target.closest && event.target.closest(
- '[data-user-card-follow-type],[data-user-card-action],[data-user-card-background],[data-user-card-avatar],[data-user-card-badge-scroll]'
- );
- if (directControl && directControl !== userCardFollowPreviewActionIntent) return;
- if (directControl) userCardFollowPreviewActionIntent = null;
- }
- const followClose = event.target.closest && event.target.closest('[data-user-card-follow-close]');
- if (followClose && userCardFollowPanel.contains(followClose)) {
- consumeEvent(event);
- closeUserCardLayer();
- return;
- }
- const breadcrumb = event.target.closest && event.target.closest('[data-user-card-breadcrumb-index]');
- if (breadcrumb && userCardFollowPanel.contains(breadcrumb)) {
- consumeEvent(event);
- restoreUserCardNavigationEntry(Number(breadcrumb.dataset.userCardBreadcrumbIndex));
- return;
- }
- const pageButton = event.target.closest && event.target.closest('[data-user-card-follow-page]');
- if (pageButton && userCardFollowPanel.contains(pageButton)) {
- consumeEvent(event);
- if (pageButton.disabled) return;
- const currentPage = Math.max(0, Number(userCardFollowPanel.dataset.followPage) || 0);
- userCardFollowPanel.dataset.followPage = String(pageButton.dataset.userCardFollowPage === 'previous'
- ? Math.max(0, currentPage - 1)
- : currentPage + 1);
- const navigationIndex = Math.max(0, userCardNavigationStack.length - 1);
- if (navigationIndex === 0) closeUserCardFollowPreview();
- renderUserCardFollowList(userCardFollowPanel);
- positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
- if (navigationIndex > 0) {
- positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
- }
- rememberCurrentUserCardNavigationState();
- renderUserCardBreadcrumbs();
- return;
- }
- const followButton = event.target.closest && event.target.closest('[data-user-card-follow-type]');
- if (followButton && actionCard && actionCard.contains(followButton)) {
- consumeEvent(event);
- if (followButton.disabled || followButton.dataset.busy === '1') return;
- clearTimeout(userCardHideTimer);
- const type = followButton.dataset.userCardFollowType;
- const username = cleanUsernameValue(followButton.dataset.username || actionCard._ldpUserData?.username);
- const panel = userCardFollowPanel;
- const anchor = userCardAnchor;
- if (!username || !panel || !anchor || !['following', 'followers'].includes(type)) return;
- cancelUserCardOpening();
- const returnedToRoot = actionCard === fallbackUserCard && userCardNavigationStack.length > 1;
- if (actionCard === userCardFollowPreview) {
- if (!promoteUserCardFollowPreview()) return;
- } else if (returnedToRoot) {
- restoreUserCardNavigationEntry(0);
- }
- const navigationEntry = ensureUserCardNavigationRoot();
- if (!panel.hidden && panel.dataset.followType === type && panel.dataset.followUsername === username) {
- if (returnedToRoot) return;
- closeUserCardFollowPanel();
- return;
- }
- const notificationMenu = actionCard.querySelector('.ldp-user-card-notification-menu');
- if (notificationMenu) notificationMenu.hidden = true;
- actionCard.querySelector('[data-user-card-action="notifications"]')?.setAttribute('aria-expanded', 'false');
- [fallbackUserCard, userCardFollowPreview].forEach((card) =>
- card.querySelectorAll('[data-user-card-follow-type]').forEach((button) =>
- setExpanded(button, card === actionCard && button === followButton)));
- panel.hidden = false;
- panel.dataset.followType = type;
- panel.dataset.followUsername = username;
- panel.dataset.followPage = '0';
- delete panel._ldpFollowUsers;
- if (navigationEntry) {
- navigationEntry.user = actionCard._ldpUserData || navigationEntry.user;
- navigationEntry.followType = type;
- navigationEntry.followUsers = null;
- navigationEntry.search = '';
- navigationEntry.page = 0;
- }
- renderUserCardBreadcrumbs();
- panel.querySelector('.ldp-user-card-follow-title').textContent = type === 'following' ? '关注的人员' : '被关注的人员';
- const search = panel.querySelector('input[type="search"]');
- const summary = panel.querySelector('.ldp-user-card-follow-summary');
- const list = panel.querySelector('.ldp-user-card-follow-list');
- search.value = '';
- summary.textContent = '正在加载…';
- releasePersistentAvatarImages(list);
- list.innerHTML = '
正在加载人员列表…
';
- positionUserCardFollowPanel(panel, fallbackUserCard);
- followButton.dataset.busy = '1';
- try {
- const users = await fetchUserFollowList(username, type);
- if (userCardAnchor !== anchor || !fallbackUserCardOpen() || panel.hidden ||
- panel.dataset.followType !== type || panel.dataset.followUsername !== username) return;
- panel._ldpFollowUsers = users;
- renderUserCardFollowList(panel);
- positionUserCardFollowPanel(panel, fallbackUserCard);
- if (actionCard === userCardFollowPreview) {
- positionUserCardFollowPreview(userCardFollowPreview, panel, fallbackUserCard);
- }
- rememberCurrentUserCardNavigationState();
- renderUserCardBreadcrumbs();
- search.focus({ preventScroll: true });
- } catch (error) {
- if (userCardAnchor !== anchor || !fallbackUserCardOpen() || panel.hidden ||
- panel.dataset.followType !== type || panel.dataset.followUsername !== username) return;
- summary.textContent = Number(error?.status) === 429 ? '请求受限,请过盾后重试' : '人员列表加载失败';
- list.innerHTML = `
${esc(error?.message || '请稍后重试')}
`;
- } finally {
- if (followButton.isConnected) delete followButton.dataset.busy;
- }
- return;
- }
- const badgeScrollButton = event.target.closest && event.target.closest('[data-user-card-badge-scroll]');
- if (badgeScrollButton && actionCard && actionCard.contains(badgeScrollButton)) {
- consumeEvent(event);
- if (badgeScrollButton.disabled) return;
- clearTimeout(userCardHideTimer);
- const list = badgeScrollButton.closest('.ldp-user-card-badge-strip')
- ?.querySelector('.ldp-user-card-badge-list');
- if (!list) return;
- const direction = Number(badgeScrollButton.dataset.userCardBadgeScroll) || 1;
- list.scrollBy({ left: direction * Math.max(96, list.clientWidth * .78), behavior: 'smooth' });
- return;
- }
- const backgroundButton = event.target.closest && event.target.closest('[data-user-card-background]');
- if (backgroundButton && actionCard && actionCard.contains(backgroundButton)) {
- consumeEvent(event);
- clearTimeout(userCardHideTimer);
- const source = String(backgroundButton.dataset.backgroundUrl || '').trim();
- const username = String(actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim();
- openUserCardMediaViewer(
- source,
- username,
- actionCard,
- '',
- actionCard === fallbackUserCard ? closeUserCard : closeUserCardFollowPreview,
- null,
- { kind: 'background', source }
- );
- return;
- }
- const avatarButton = event.target.closest && event.target.closest('[data-user-card-avatar]');
- if (avatarButton && actionCard && actionCard.contains(avatarButton)) {
- consumeEvent(event);
- clearTimeout(userCardHideTimer);
- const template = avatarButton.dataset.avatarTemplate || actionCard._ldpUserData?.avatar_template || '';
- const username = avatarButton.dataset.avatarUsername || getUserCardUsername(userCardAnchor) || '';
- const previewImage = avatarButton.querySelector('img');
- const previewSource = previewImage && (previewImage.currentSrc || previewImage.src) || '';
- openUserCardMediaViewer(
- template,
- username,
- actionCard,
- previewSource,
- actionCard === fallbackUserCard ? closeUserCard : closeUserCardFollowPreview,
- actionCard._ldpUserData
- );
- return;
- }
- const notificationOption = event.target.closest && event.target.closest('[data-user-notification-level]');
- if (notificationOption && actionCard && actionCard.contains(notificationOption)) {
- consumeEvent(event);
- if (notificationOption.disabled || notificationOption.dataset.busy === '1') return;
- const level = notificationOption.dataset.userNotificationLevel;
- const username = String(actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim();
- const user = actionCard._ldpUserData || { username };
- const anchor = userCardAnchor;
- const isCurrent = () => userCardAnchor === anchor && fallbackUserCardOpen() && actionCard.classList.contains('open') &&
- String(actionCard._ldpUserData?.username || '') === username;
- if (!username || !anchor || !Object.hasOwn(USER_NOTIFICATION_LEVEL_LABELS, level)) return;
- clearTimeout(userCardHideTimer);
- if (level === 'ignore') {
- if (openReaderIgnoreDurationDialog(ctx, username, user)) closeUserCard();
- else setUserCardActionStatus(actionCard, '当前无法打开忽略期限选择', true);
- return;
- }
- const menu = notificationOption.closest('.ldp-user-card-notification-menu');
- const options = menu ? [...menu.querySelectorAll('[data-user-notification-level]')] : [];
- options.forEach((option) => {
- option.dataset.busy = '1';
- option.disabled = true;
- });
- setUserCardActionStatus(actionCard, `正在设为${USER_NOTIFICATION_LEVEL_LABELS[level]}…`);
- try {
- await saveReaderUserNotificationLevel(username, user, level);
- if (!isCurrent()) return;
- options.forEach((option) => {
- const active = option.dataset.userNotificationLevel === level;
- option.classList.toggle('is-active', active);
- option.setAttribute('aria-checked', String(active));
- });
- const trigger = actionCard.querySelector('[data-user-card-action="notifications"]');
- if (trigger) {
- const label = `消息设置:${USER_NOTIFICATION_LEVEL_LABELS[level]}`;
- trigger.classList.toggle('is-active', level !== 'normal');
- trigger.dataset.tooltip = label;
- setLabel(trigger, label);
- setExpanded(trigger, false);
- }
- if (menu) menu.hidden = true;
- setUserCardActionStatus(actionCard, `已设为${USER_NOTIFICATION_LEVEL_LABELS[level]}`);
- positionFallbackUserCardNotificationMenu(actionCard);
- } catch (error) {
- if (isCurrent()) {
- setUserCardActionStatus(actionCard, error?.message ? error.message : '消息设置保存失败,请重试', true);
- }
- } finally {
- if (isCurrent()) {
- options.forEach((option) => {
- delete option.dataset.busy;
- option.disabled = false;
- });
- }
- }
- return;
- }
- const button = event.target.closest && event.target.closest('[data-user-card-action]');
- if (!button || !actionCard || !actionCard.contains(button) || button.disabled || button.dataset.busy === '1') return;
- consumeEvent(event);
- const action = button.dataset.userCardAction;
- const username = String(button.dataset.username || actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim();
- const anchor = userCardAnchor;
- if (!action || !username || !anchor) return;
- if (actionCard === userCardFollowPreview) {
- if (!promoteUserCardFollowPreview()) return;
- userCardFollowPanel.hidden = true;
- } else if (userCardNavigationStack.length > 1) {
- restoreUserCardNavigationEntry(0);
- }
- clearTimeout(userCardHideTimer);
- if (action === 'notifications') {
- const menu = actionCard.querySelector('.ldp-user-card-notification-menu');
- if (!menu) return;
- const opening = menu.hidden;
- menu.hidden = !opening;
- setExpanded(button, opening);
- setUserCardActionStatus(actionCard, '');
- positionFallbackUserCardNotificationMenu(actionCard);
- return;
- }
- button.dataset.busy = '1';
- button.setAttribute('aria-busy', 'true');
- button.disabled = true;
- setUserCardActionStatus(actionCard, '正在打开…');
- const isCurrent = () => userCardAnchor === anchor && fallbackUserCardOpen() && actionCard.classList.contains('open') &&
- String(actionCard._ldpUserData?.username || '') === username;
- const outcome = await runDirectUserCardAction(
- action,
- username,
- actionCard._ldpUserData || { username },
- ctx
- );
- if (outcome.ok) {
- if (outcome.keepOpen && button.isConnected && isCurrent()) {
- delete button.dataset.busy;
- button.removeAttribute('aria-busy');
- button.disabled = false;
- if (action === 'follow') {
- const label = outcome.followed ? '取消关注' : '关注';
- button.classList.toggle('is-active', !!outcome.followed);
- button.dataset.tooltip = label;
- setLabel(button, label);
- setUserCardActionStatus(actionCard, outcome.followed ? '已关注' : '已取消关注');
- const followerCount = Number(actionCard._ldpUserData?.total_followers);
- const followerValue = actionCard.querySelector('[data-user-card-follow-type="followers"] strong');
- if (followerValue && Number.isFinite(followerCount)) followerValue.textContent = followerCount.toLocaleString('zh-CN');
- }
- return;
- }
- closeUserCard();
- return;
- }
- if (!button.isConnected || !isCurrent()) return;
- delete button.dataset.busy;
- button.removeAttribute('aria-busy');
- button.disabled = false;
- const label = button.getAttribute('aria-label') || '该功能';
- const failureMessages = {
- 'route-unavailable': `未能打开“${label}”页面,请重试`,
- 'endorsement-unavailable': '当前页面暂时无法打开认可类别选择',
- 'follow-unavailable': '当前站点未提供关注能力',
- 'follow-not-allowed': '该用户当前不允许被关注',
- 'action-failed': `执行“${label}”时发生错误,请重试`,
- };
- const failureMessage = outcome.reason === 'action-failed' && outcome.error && outcome.error.message
- ? outcome.error.message
- : (failureMessages[outcome.reason] || `${label}当前不可用`);
- setUserCardActionStatus(actionCard, failureMessage, true);
- };
- onAction(fallbackUserCard, 'click', onUserCardAction);
- onAction(userCardFollowPanel, 'click', onUserCardAction);
- onAction(userCardFollowPreview, 'click', onUserCardAction);
- let userCardFollowPreviewActionIntent = null;
- const userCardFollowPreviewActionControl = (target) => target?.closest && target.closest(
- '[data-user-card-follow-type],[data-user-card-action],[data-user-card-background],[data-user-card-avatar],[data-user-card-badge-scroll]'
- );
- const armUserCardFollowPreviewAction = (event) => {
- const control = userCardFollowPreviewActionControl(event.target);
- userCardFollowPreviewActionIntent = control && userCardFollowPreview.contains(control) ? control : null;
- };
- const armUserCardFollowPreviewKeyboardAction = (event) => {
- if (event.key !== 'Enter' && event.key !== ' ') return;
- armUserCardFollowPreviewAction(event);
- };
- onAction(userCardFollowPreview, 'pointerdown', armUserCardFollowPreviewAction);
- onAction(userCardFollowPreview, 'pointercancel', () => {
- userCardFollowPreviewActionIntent = null;
- });
- onAction(userCardFollowPreview, 'keydown', armUserCardFollowPreviewKeyboardAction);
- const onUserCardFollowSearch = (event) => {
- if (!(event.target instanceof Element) || !event.target.matches('.ldp-user-card-follow-search input')) return;
- userCardFollowPanel.dataset.followPage = '0';
- renderUserCardFollowList(userCardFollowPanel);
- positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
- };
- onAction(userCardFollowPanel, 'input', onUserCardFollowSearch);
-
- const openUserCardFollowPreview = (link) => {
- const username = getUserCardUsername(link);
- if (!username || userCardFollowPanel.hidden || !userCardFollowPreviewHoverArmed) return;
- clearTimeout(userCardHideTimer);
- clearTimeout(userCardFollowPreviewShowTimer);
- userCardFollowPreviewShowTimer = 0;
- if (getUserCardUsername(userCardFollowPreviewAnchor) === username &&
- userCardFollowPreview.classList.contains('open')) {
- userCardFollowPreviewAnchor = link;
- positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
- renderUserCardBreadcrumbs();
- return;
- }
- userCardFollowPreviewAnchor = link;
- const token = ++userCardFollowPreviewToken;
- userCardFollowPreviewShowTimer = setTimeout(() => {
- userCardFollowPreviewShowTimer = 0;
- void showFallbackUserCard(link, () => token === userCardFollowPreviewToken &&
- userCardFollowPreviewAnchor === link && !userCardFollowPanel.hidden && fallbackUserCardOpen(),
- renderUserCardBreadcrumbs, {
- card: userCardFollowPreview,
- positionCard: (preview) => positionUserCardFollowPreview(preview, userCardFollowPanel, fallbackUserCard),
- });
- }, USER_CARD_SHOW_DELAY);
- };
-
- const onUserCardMouseOver = (event) => {
- const link = eventPathClosest(event, USER_CARD_LINK_SEL);
- if (!link) return;
- const from = event.relatedTarget;
- if (from && typeof from.nodeType === 'number' && link.contains(from)) return;
- if (userCardFollowPanel.contains(link)) {
- openUserCardFollowPreview(link);
- return;
- }
- if (!userCardFollowPanel.hidden) {
- cancelUserCardOpening();
- return;
- }
- const previousUsername = String(fallbackUserCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '');
- const nextUsername = getUserCardUsername(link);
- if (previousUsername && previousUsername !== nextUsername) {
- if (!userCardFollowPanel.contains(link)) closeUserCardFollowPanel();
- cancelQueuedUserCardRequests(previousUsername, ctx.postRequestScheduler);
- }
- clearTimeout(userCardHideTimer);
- userCardHideTimer = 0;
- cancelUserCardOpening();
- if (previousUsername === nextUsername && fallbackUserCardOpen()) {
- userCardAnchor = link;
- observeUserCardAnchor(link);
- positionFallbackUserCard(fallbackUserCard, link);
- return;
- }
- userCardAnchor = link;
- observeUserCardAnchor(link);
- const token = ++userCardToken;
- userCardPrefetchTimer = setTimeout(() => {
- userCardPrefetchTimer = 0;
- if (token !== userCardToken || userCardAnchor !== link) return;
- void fetchUserCard(nextUsername).catch(() => {});
- }, USER_CARD_PREFETCH_DELAY);
- userCardShowTimer = setTimeout(() => {
- userCardShowTimer = 0;
- void showFallbackUserCard(link, () => token === userCardToken &&
- userCardAnchor === link && userCardAnchorIsVisible(link), (user) => {
- userCardNavigationStack.length = 0;
- if (user?.username) userCardNavigationStack.push(navigationEntryFromUser(user));
- renderUserCardBreadcrumbs();
- });
- }, USER_CARD_SHOW_DELAY);
- };
-
- const onUserCardMouseOut = (event) => {
- const link = eventPathClosest(event, USER_CARD_LINK_SEL);
- if (!link) return;
- const to = event.relatedTarget;
- if (userCardFollowPanel.contains(link)) {
- userCardFollowPreviewHoverArmed = true;
- const nextLink = isFunction(to?.closest) ? to.closest(USER_CARD_LINK_SEL) : null;
- if (to && typeof to.nodeType === 'number' &&
- (link.contains(to) || userCardFollowPreview.contains(to) ||
- (nextLink && userCardFollowPanel.contains(nextLink)))) return;
- clearTimeout(userCardFollowPreviewShowTimer);
- userCardFollowPreviewShowTimer = 0;
- return;
- }
- if (to && typeof to.nodeType === 'number' &&
- (link.contains(to) || fallbackUserCard.contains(to) || userCardFollowPanel.contains(to) ||
- userCardFollowPreview.contains(to))) return;
- cancelUserCardOpening();
- const nextLink = isFunction(to?.closest) ? to.closest(USER_CARD_LINK_SEL) : null;
- if (nextLink && getUserCardUsername(nextLink) === getUserCardUsername(link)) {
- return;
- }
- scheduleCloseUserCard();
- };
- const onHostUserCardMouseOver = (event) => {
- const link = eventPathClosest(event, USER_CARD_LINK_SEL);
- if (!link || userCardEventRoot.contains(link)) return;
- onUserCardMouseOver(event);
- };
- const onHostUserCardMouseOut = (event) => {
- const link = eventPathClosest(event, USER_CARD_LINK_SEL);
- if (!link || userCardEventRoot.contains(link)) return;
- onUserCardMouseOut(event);
- };
- onAction(userCardEventRoot, 'mouseover', onUserCardMouseOver, true);
- onAction(userCardEventRoot, 'mouseout', onUserCardMouseOut, true);
- onAction(document, 'mouseover', onHostUserCardMouseOver, true);
- onAction(document, 'mouseout', onHostUserCardMouseOut, true);
-
- const onTopicNotificationChange = async (event) => {
- const select = event.target.closest('.ldp-topic-notification-select');
- if (!select || !modal.contains(select)) return;
- const nextLevel = +select.value;
- if (!Number.isInteger(nextLevel) || nextLevel < 0 || nextLevel > 3) return;
- const previousLevel = Number.isInteger(ctx.topicNotificationLevel)
- ? ctx.topicNotificationLevel
- : topicNotificationLevel(ctx.topicData);
- if (nextLevel === previousLevel) return;
- ctx.topicNotificationLevel = nextLevel;
- syncTopicActionControls(ctx, { notificationBusy: true });
- try {
- const topicModule = lookupDiscourseModule('discourse/models/topic');
- const topicDetailsModule = lookupDiscourseModule('discourse/models/topic-details');
- const Topic = topicModule?.default;
- const TopicDetails = topicDetailsModule?.default;
- if (!Topic || !isFunction(Topic.create) ||
- !TopicDetails || !isFunction(TopicDetails.create))
- throw new Error('Discourse 主题通知接口尚未就绪');
- const topicModel = Topic.create({
- ...(ctx.topicData || {}),
- id: Number(ctx.topicId),
- });
- const detailsModel = TopicDetails.create({
- ...(ctx.topicData?.details || {}),
- topic: topicModel,
- notification_level: previousLevel,
- });
- await detailsModel.updateNotifications(nextLevel);
- await clearStoredResponseCache({ tags: [`topic:${ctx.topicId}`] });
- if (ctx.topicData) {
- ctx.topicData.notification_level = nextLevel;
- if (ctx.topicData.details) ctx.topicData.details.notification_level = nextLevel;
- setCachedTopicData(ctx.topicId, ctx.topicData);
- }
- } catch (error) {
- ctx.topicNotificationLevel = previousLevel;
- alert('通知设置失败:' + error.message);
- } finally {
- syncTopicActionControls(ctx);
- }
- };
- onAction(modal, 'change', onTopicNotificationChange);
-
- const onReaderPollChange = async (event) => {
- const input = event.target.closest('.ldp-reader-poll input[data-poll-option]');
- if (!input || !modal.contains(input) || input.disabled) return;
- const container = input.closest('.ldp-reader-poll');
- const post = input.closest('.ldp-post');
- if (!container || !post) return;
- const name = container.dataset.ldpPollName || 'poll';
- const postData = post._ldpPostData || {};
- const poll = (postData.polls || []).find((item) => pollName(item) === name);
- if (!poll) return;
- if (input.type === 'radio') {
- await updateReaderPollVote(post, ctx, name, [input.value]);
- return;
- }
- const selected = [...container.querySelectorAll('input[data-poll-option]:checked')].map((item) => item.value);
- const { min, max } = pollChoiceLimits(poll);
- const submit = container.querySelector('[data-poll-action="submit"]');
- if (submit) {
- submit.disabled = selected.length < min || selected.length > max;
- submit.textContent = pollSavedVotes(postData, poll).length ? '更新投票' : '提交投票';
- }
- };
- onAction(modal, 'change', onReaderPollChange);
-
- const onPostVotingCommentSubmit = async (event) => {
- const form = event.target.closest('.ldp-pv-comment-form');
- if (!form || !modal.contains(form)) return;
- event.preventDefault();
- const post = form.closest('.ldp-post');
- const input = form.querySelector('.ldp-pv-comment-input');
- const raw = String(input?.value || '').trim();
- if (!post || !raw) return;
- const postId = post.dataset.postId;
- await runReaderActionOnce(ctx, `post:${postId}:pv-comment-create`, async () => {
- setReaderPostControlsBusy(postId, ctx, '.ldp-pv-comment-form input,.ldp-pv-comment-form button', true);
- try {
- const data = await apiSend(`${BASE}/post_voting/comments`, 'POST', { post_id: postId, raw });
- const existing = readerPostVotingComments(postId, ctx);
- const comments = new Map(existing.map((comment) => [String(comment.id), comment]));
- const comment = data.comment || data;
- if (comment?.id) comments.set(String(comment.id), comment);
- readerPostCopies(postId, ctx).forEach((copy) => {
- const root = copy.querySelector(':scope > .ldp-pv-comments');
- if (root) root.dataset.expanded = '1';
- });
- syncPostVotingComments(postId, ctx, [...comments.values()]);
- } catch (error) {
- alert('发送评论失败:' + error.message);
- } finally {
- setReaderPostControlsBusy(postId, ctx, '.ldp-pv-comment-form input,.ldp-pv-comment-form button', false);
- }
- });
- };
- onAction(modal, 'submit', onPostVotingCommentSubmit);
-
- const showReaderAvatarPreview = (avatarPreview) => {
- const post = avatarPreview.closest('.ldp-post');
- const postData = post?._ldpPostData || {};
- const username = String(postData.username || avatarPreview.dataset.userCard || '').trim();
- const avatarImage = avatarPreview.querySelector('.ldp-avatar');
- const template = String(postData.avatar_template || avatarImage?.dataset.ldpAvatarOriginalTemplate ||
- avatarImage?.dataset.ldpAvatarTemplate || '').trim();
- const previewSource = avatarImage && (avatarImage.currentSrc || avatarImage.src) || '';
- const openPreviewFromCard = (user) => {
- if (!fallbackUserCardOpen() || getUserCardUsername(userCardAnchor) !== username) return;
- openUserCardMediaViewer(
- template,
- username,
- fallbackUserCard,
- previewSource,
- closeUserCard,
- user || postData
- );
- };
- clearTimeout(userCardHideTimer);
- clearTimeout(userCardShowTimer);
- userCardShowTimer = 0;
- const displayedUser = fallbackUserCard._ldpUserData;
- const cardShownForUser = fallbackUserCardOpen() && String(displayedUser?.username || '') === username;
- if (cardShownForUser) {
- openPreviewFromCard(displayedUser);
- return;
- }
- userCardAnchor = avatarPreview;
- observeUserCardAnchor(avatarPreview);
- const token = ++userCardToken;
- void showFallbackUserCard(
- avatarPreview,
- () => token === userCardToken && userCardAnchor === avatarPreview && userCardAnchorIsVisible(avatarPreview),
- (user) => {
- userCardNavigationStack.length = 0;
- if (user?.username) userCardNavigationStack.push(navigationEntryFromUser(user));
- renderUserCardBreadcrumbs();
- openPreviewFromCard(user);
- }
- );
- };
- const onPortalAvatarPreviewClick = (event) => {
- const avatarPreview = eventPathClosest(event, '[data-user-avatar-preview]');
- if (!avatarPreview || modal.contains(avatarPreview)) return;
- consumeEvent(event);
- showReaderAvatarPreview(avatarPreview);
- };
- onAction(userCardEventRoot, 'click', onPortalAvatarPreviewClick, true);
-
- const preserveOnlyOpPostExpansion = (post) => {
- const preserve = ctx.onlyOp && !post._ldpPostData?.hidden &&
- String(post.dataset.username || '') === String(ctx.op || '');
- if (preserve) keepOnlyOpPostExpanded(post, ctx);
- return preserve;
- };
- const readerActionHandlers = {
- report: (post) => openReaderReportDialog(post, ctx),
- 'assign-post': (post) => openReaderAssignDialog(post, ctx, 'Post'),
- 'assign-topic': (post) => openReaderAssignDialog(post, ctx, 'Topic'),
- };
- const postActionHandlers = {
- share: (post) => shareReaderLink(ctx, post),
- bookmark: (post) => toggleReaderBookmark(post, ctx, 'Post'),
- edit: (post, postId) => runReaderActionOnce(
- ctx,
- `post:${postId}:edit-composer`,
- () => tryOpenNativeEditComposer(post, ctx)
- ),
- };
- const topicActionHandlers = {
- share: () => shareReaderLink(ctx),
- bookmark: () => isFunction(ctx.toggleTopicBookmark) ? ctx.toggleTopicBookmark() : undefined,
- };
- const onModalClick = async (e) => {
- if (!e.target.closest('.ldp-hidden-reply-marker')) modal.querySelectorAll('.ldp-hidden-reply-list:not([hidden])').forEach((list) => {
- list.hidden = true; setExpanded(list.previousElementSibling, 'false');
- });
- const clickedCodeBlock = eventPathClosest(e, '.ldp-code-block');
- if (!clickedCodeBlock && !eventPathClosest(e, '.ldp-code-preview-layer'))
- collapseReaderCodeBlock();
- const imageCandidate = eventPathClosest(e, '.ldp-content img') ||
- eventPathClosest(e, '.ldp-content a.lightbox')?.querySelector('img');
- if (imageCandidate && !reactionEmojiImageSources) {
- consumeEvent(e);
- await hydrateReactionRegistry();
- }
- const quotedImg = lightboxImageFromEventIn(
- e, '.ldp-content aside.ldp-post-quote > blockquote', isLightboxQuotedImage
- );
- if (quotedImg) {
- consumeEvent(e);
- hideImageQuoteToolbar();
- await openQuotedImageLightbox(quotedImg, ctx);
- return;
- }
-
- const img = lightboxImageFromEvent(e);
- if (img) {
- consumeEvent(e);
- hideImageQuoteToolbar();
- openLightbox(img, ctx);
- return;
- }
-
- const avatarPreview = eventPathClosest(e, '[data-user-avatar-preview]');
- if (avatarPreview) {
- consumeEvent(e);
- showReaderAvatarPreview(avatarPreview);
- return;
- }
-
- const codeAction = eventPathClosest(e, '[data-reader-code-action]');
- if (codeAction) {
- consumeEvent(e);
- const block = codeAction.closest('.ldp-code-block');
- const pre = block?.querySelector(':scope > pre');
- if (codeAction.dataset.readerCodeAction === 'copy') await copyReaderCode(pre, codeAction);
- else if (codeAction.dataset.readerCodeAction === 'preview') openReaderCodePreview(pre, modal);
- else toggleReaderCodeBlock(block);
- return;
- }
-
- const hostLink = eventPathClosest(e, 'a[href]');
- if (openEmbeddedReaderLinkInHost(e, hostLink, ctx.readerWorkspace)) return;
-
- const githubOnebox = eventPathClosest(
- e,
- '.ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"])'
- );
- if (githubOnebox) {
- const selection = window.getSelection && window.getSelection();
- if (selection && !selection.isCollapsed && String(selection).trim()) return;
- const targetLink = hostLink || githubOnebox.querySelector('.onebox-body h3 a[href],header.source a[href],a[href]');
- const targetUrl = String(targetLink?.href || githubOnebox.dataset.oneboxSrc || '').trim();
- if (/^https?:\/\//i.test(targetUrl)) {
- consumeEvent(e);
- const openInCurrentTab = targetLink?.target === '_self' && !e.ctrlKey && !e.metaKey && !e.shiftKey;
- if (openInCurrentTab) window.location.assign(targetUrl);
- else window.open(targetUrl, '_blank', 'noopener');
- return;
- }
- }
-
- const quoteAction = e.target.closest('.ldp-quote-toggle,.ldp-quote-collapse-bottom');
- if (quoteAction) {
- e.preventDefault();
- const quote = quoteAction.closest('.ldp-post-quote');
- const quoteBody = quote?.querySelector(':scope > blockquote');
- const quoteToggle = quote?.querySelector(':scope > .ldp-quote-title .ldp-quote-toggle');
- if (!quote || !quoteBody || !quoteToggle) return;
- if (quote.dataset.ldpQuoteExpanded === '1') {
- preserveReaderQuotePosition(quote, ctx, () => {
- quote.querySelector(':scope > .ldp-quote-collapse-bottom')?.remove();
- setPostCookedContent(quoteBody, quote._ldpQuoteExcerptHtml || '');
- quote.dataset.ldpQuoteExpanded = '0';
- quote.classList.remove('ldp-quote-expanded');
- setExpanded(quoteToggle, false);
- setLabel(quoteToggle, '展开完整引用');
- });
- syncReaderQuoteLayout(quote, ctx);
- return;
- }
- quoteToggle.disabled = true;
- quoteToggle.setAttribute('aria-busy', 'true');
- try {
- const fullPost = await loadReaderQuotedPost(quote, ctx);
- if (!fullPost || fullPost.cooked == null) throw new Error('quoted post unavailable');
- preserveReaderQuotePosition(quote, ctx, () => {
- setPostCookedContent(quoteBody, fullPost.cooked);
- quote.dataset.ldpQuoteExpanded = '1';
- quote.classList.add('ldp-quote-expanded');
- setExpanded(quoteToggle, true);
- setLabel(quoteToggle, '恢复引用摘录');
- highlightReaderExpandedQuote(quote);
- const bottomCollapse = makeElement('button');
- bottomCollapse.type = 'button';
- bottomCollapse.className = 'ldp-quote-collapse-bottom';
- setLabel(bottomCollapse, '恢复引用摘录');
- bottomCollapse.innerHTML = icon('chevronUp');
- quote.append(bottomCollapse);
- });
- syncReaderQuoteLayout(quote, ctx);
- } catch {
- setLabel(quoteToggle, '完整引用加载失败,点击重试');
- } finally {
- quoteToggle.disabled = false;
- quoteToggle.removeAttribute('aria-busy');
- }
- return;
- }
-
- const topicVote = e.target.closest('[data-topic-vote]');
- if (topicVote) {
- e.preventDefault();
- if (!hasDiscourseCapability('topicVoting', { topic: ctx.topicData })) return;
- if (!ME_USERNAME) { alert('登录后才能为主题投票。'); return; }
- await runReaderActionOnce(ctx, `topic:${ctx.topicId}:vote`, async () => {
- const voted = !!(ctx.topicData?.user_voted);
- modal.querySelectorAll('[data-topic-vote]').forEach((button) => { button.disabled = true; });
- try {
- const data = await apiSend(`${BASE}/voting/${voted ? 'unvote' : 'vote'}`, 'POST', { topic_id: ctx.topicId });
- ctx.topicData.user_voted = !voted;
- ctx.topicData.vote_count = Math.max(0, +(data.vote_count ?? data.votes ?? ctx.topicData.vote_count) || 0);
- if (data.can_vote != null) ctx.topicData.can_vote = data.can_vote;
- setCachedTopicData(ctx.topicId, ctx.topicData);
- if (ctx.renderTopicShell) ctx.renderTopicShell(ctx.topicData);
- } catch (error) {
- alert('主题投票失败:' + error.message);
- } finally {
- modal.querySelectorAll('[data-topic-vote]').forEach((button) => {
- button.disabled = ctx.topicData && ctx.topicData.can_vote === false && !ctx.topicData.user_voted;
- });
- }
- });
- return;
- }
-
- const nestedRailToggle = e.target.closest('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle');
- if (nestedRailToggle) {
- e.preventDefault();
- const post = nestedRailToggle.closest('.ldp-post');
- if (preserveOnlyOpPostExpansion(post)) return;
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const parentContent = post.querySelector(':scope > .ldp-content');
- const parentReactions = post.querySelector(':scope > .ldp-reactions');
- const parentBodyEnd = parentReactions && parentReactions.getClientRects().length
- ? parentReactions
- : (parentContent || post);
- const expandedContentRect = (parentContent || parentBodyEnd).getBoundingClientRect();
- const expandedBodyEndRect = parentBodyEnd.getBoundingClientRect();
- const parentBodyEndVisible = expandedBodyEndRect.bottom >= rootRect.top && expandedBodyEndRect.bottom <= rootRect.bottom;
- setChildRepliesCollapsed(post, true);
- const collapsedContentRect = (parentContent || post).getBoundingClientRect();
- if (parentBodyEndVisible) {
- const topOffset = collapsedContentRect.top - expandedContentRect.top;
- if (Math.abs(topOffset) >= 1) {
- const previousScrollTop = ctx.scrollRoot.scrollTop;
- ctx.scrollRoot.scrollTop += topOffset;
- markStreamInternalScroll(ctx, previousScrollTop);
- scheduleStreamWindowSync(ctx);
- }
- return;
- }
- const parentPostNumber = streamPostNumber(post);
- scrollToPost(parentPostNumber, ctx, {
- ...PINNED_STREAM_SCROLL_OPTIONS,
- visualNode: parentBodyEnd,
- alignBottomCenter: true,
- highlight: false,
- });
- return;
- }
-
- const nestedToggle = e.target.closest('.ldp-nested-toggle');
- if (nestedToggle) {
- const post = nestedToggle.closest('.ldp-post');
- if (preserveOnlyOpPostExpansion(post)) return;
- const willCollapse = !post.classList.contains('ldp-nested-collapsed');
- if (willCollapse) delete post.dataset.ldpJumpExpanded;
- setNestedReplyCollapsed(post, willCollapse);
- if (willCollapse) floorPreview.hide(post);
- return;
- }
-
- const rowToggleTarget = e.target.closest('.ldp-post.ldp-nested-collapsed');
- if (rowToggleTarget && !e.target.closest('a,button,input,textarea,select,label')) {
- setNestedReplyCollapsed(rowToggleTarget, false);
- return;
- }
-
- const hiddenReplyAvatar = e.target.closest('.ldp-hidden-reply-avatar');
- if (hiddenReplyAvatar) {
- const target = +hiddenReplyAvatar.dataset.postNumber;
- const targetPost = ctx.streamNodeMap.get(target)?._ldpPostData;
- if (targetPost) await ctx.timeline.jumpTo(target, { targetPost });
- return;
- }
- const hiddenReplyToggle = e.target.closest('.ldp-hidden-reply-toggle');
- if (hiddenReplyToggle) {
- const list = hiddenReplyToggle.nextElementSibling, expanded = list?.hidden !== false;
- if (list) list.hidden = !expanded;
- setExpanded(hiddenReplyToggle, String(expanded));
- return;
- }
-
- const collapseBtn = e.target.closest('.ldp-collapse-replies');
- if (collapseBtn) {
- const post = collapseBtn.closest('.ldp-post');
- if (preserveOnlyOpPostExpansion(post)) return;
- setChildRepliesCollapsed(post, !post.classList.contains('ldp-children-collapsed'));
- return;
- }
-
- const aggregateBtn = e.target.closest('.ldp-descendant-replies-open');
- if (aggregateBtn) {
- const parent = aggregateBtn.closest('.ldp-post');
- void openAggregateReplyTarget(+parent?.dataset.postNumber, ctx, { returnControl: aggregateBtn });
- return;
- }
-
- // 楼中楼分页按钮
- const pageBtn = e.target.closest('.ldp-sub-page-prev, .ldp-sub-page-next');
- if (pageBtn) {
- if (pageBtn.disabled) return;
- const post = pageBtn.closest('.ldp-post');
- const postNumber = +post.dataset.postNumber;
- const direction = pageBtn.classList.contains('ldp-sub-page-next') ? 1 : -1;
- if (direction > 0) {
- const state = ctx.subReplyState.get(postNumber);
- const pageSize = SUB_REPLY_PAGE_SIZE;
- const targetPage = +(state?.pageIndex || 0) + 1;
- const requiredCount = Math.min(+(state?.totalCount || 0), (targetPage + 1) * pageSize);
- if (state && +(state.fetchedReplyCount || 0) < requiredCount && state.hasMore !== false) {
- await ctx.repliesIO.loadNextPage(post, requiredCount);
- }
- const updatedState = ctx.subReplyState.get(postNumber);
- if (!updatedState || targetPage * pageSize >= updatedState.all.length) return;
- }
- renderSubReplyBatch(postNumber, ctx, direction, post);
- return;
- }
-
- const quoteJump = e.target.closest('.ldp-quote-jump');
- const preview = quoteJump?.closest('.ldp-floor-preview');
- const post = e.target.closest('.ldp-post') || e.target.closest('.ldp-topic-action-rail')?._ldpRelatedPost || (preview?._ldpRelatedPost);
- if (!post) return;
- const postId = post.dataset.postId, postNumber = +post.dataset.postNumber;
- if (quoteJump) {
- const targetTopicId = +quoteJump.dataset.targetTopicId || 0;
- if (targetTopicId && targetTopicId !== +ctx.topicId) {
- e.preventDefault();
- const targetPostNumber = +quoteJump.dataset.targetPostNumber || 1;
- const quoteHighlight = quoteHighlightFromJump(quoteJump, ctx);
- openModal(targetTopicId, { targetPostNumber, quoteHighlight });
- return;
- }
- }
- const reactionPicker = e.target.closest('[data-reaction-picker]');
- if (reactionPicker) {
- if (!hasDiscourseCapability('reactions', {
- post: post._ldpPostData,
- topic: ctx.topicData,
- })) return;
- const reactionsRoot = reactionPicker.closest('.ldp-reactions');
- setReactionPickerExpanded(reactionsRoot, false);
- await toggleReaderPostReaction(post, ctx, reactionPicker.dataset.reaction || 'heart', null, (error) => alert('回应失败:' + error.message));
- return;
- }
- const reactionButton = e.target.closest('[data-reaction]');
- if (reactionButton) {
- const reaction = reactionButton.dataset.reaction;
- const reactionsRoot = reactionButton.closest('.ldp-reactions');
- const picker = reactionButton.closest('.ldp-reaction-picker');
- if (picker) {
- setReactionPickerExpanded(reactionsRoot, false);
- }
- await toggleReaderPostReaction(post, ctx, reaction, null, (error) => alert('回应失败:' + error.message));
- return;
- }
- const pvVote = e.target.closest('[data-pv-vote]');
- if (pvVote) {
- const direction = pvVote.dataset.pvVote;
- const postData = post._ldpPostData || {};
- if (!hasDiscourseCapability('postVoting', { post: postData, topic: ctx.topicData })) return;
- const remove = postData.post_voting_user_voted_direction === direction;
- await runReaderActionOnce(ctx, `post:${postId}:pv-vote`, async () => {
- setReaderPostControlsBusy(postId, ctx, '.ldp-pv-vote', true);
- try {
- const votingModule = lookupDiscourseModule(
- 'discourse/plugins/discourse-post-voting/discourse/lib/post-voting-utilities'
- );
- const mutateVote = votingModule && (remove ? votingModule.removeVote : votingModule.castVote);
- if (!isFunction(mutateVote))
- throw new Error('Discourse 回答投票接口尚未就绪');
- const data = await mutateVote(
- remove ? { post_id: postId } : { post_id: postId, direction }
- );
- await clearStoredResponseCache({ tags: [`post:${postId}`] });
- const fresh = data.post || data;
- if (fresh.post_voting_vote_count == null && data.vote_count != null) fresh.post_voting_vote_count = data.vote_count;
- if (fresh.post_voting_user_voted_direction == null) fresh.post_voting_user_voted_direction = remove ? null : direction;
- syncSpecialPostData(post, ctx, fresh);
- } catch (error) {
- alert('回答投票失败:' + error.message);
- readerPostCopies(postId, ctx).forEach((copy) => renderPostVoting(copy, ctx));
- } finally {
- setReaderPostControlsBusy(postId, ctx, '.ldp-pv-vote', false);
- }
- });
- return;
- }
- const commentsToggle = e.target.closest('.ldp-pv-comments-toggle');
- const commentsMore = !commentsToggle && e.target.closest('.ldp-pv-comments-more');
- const commentsAction = commentsToggle || commentsMore;
- if (commentsAction) {
- const root = commentsToggle?.closest('.ldp-pv-comments');
- const open = !commentsToggle || root.dataset.expanded !== '1';
- if (commentsToggle) root.dataset.expanded = open ? '1' : '0';
- const loadComments = commentsMore || (open &&
- !(post._ldpPostData._ldpPvComments || post._ldpPostData.comments || []).length &&
- +post._ldpPostData.comments_count > 0);
- if (loadComments) {
- commentsAction.disabled = true;
- try { await fetchPostVotingComments(post, ctx); } catch (error) { alert('加载评论失败:' + error.message); }
- }
- renderPostVoting(post, ctx);
- return;
- }
- const pvCommentVote = e.target.closest('[data-pv-comment-vote]');
- if (pvCommentVote) {
- const commentNode = pvCommentVote.closest('.ldp-pv-comment');
- const commentId = commentNode && commentNode.dataset.commentId;
- const remove = pvCommentVote.classList.contains('on');
- await runReaderActionOnce(ctx, `post-voting-comment:${commentId}:vote`, async () => {
- setReaderPostControlsBusy(postId, ctx, '[data-pv-comment-vote]', true);
- try {
- const data = await apiSend(
- `${BASE}/post_voting/vote/comment`,
- remove ? 'DELETE' : 'POST',
- { comment_id: commentId },
- null,
- { invalidateTags: [`post:${postId}`] }
- );
- const existing = readerPostVotingComments(postId, ctx);
- const comments = existing.slice();
- const comment = comments.find((item) => String(item.id) === String(commentId));
- if (comment) {
- comment.user_voted = !remove;
- comment.post_voting_vote_count = +(data.vote_count ?? comment.post_voting_vote_count) || 0;
- }
- syncPostVotingComments(postId, ctx, comments);
- } catch (error) {
- alert('评论投票失败:' + error.message);
- } finally {
- setReaderPostControlsBusy(postId, ctx, '[data-pv-comment-vote]', false);
- }
- });
- return;
- }
- const eventAction = e.target.closest('[data-event-status]');
- if (eventAction) {
- const status = eventAction.dataset.eventStatus;
- const eventData = post._ldpPostData?.event || {};
- const invitee = eventData.watching_invitee || {};
- await runReaderActionOnce(ctx, `post:${postId}:event-attendance`, async () => {
- setReaderPostControlsBusy(postId, ctx, '.ldp-event-action', true);
- try {
- const eventApi = lookupDiscourse('service:discourse-post-event-api');
- const eventModule = lookupDiscourseModule(
- 'discourse/plugins/discourse-calendar/discourse/models/discourse-post-event-event'
- );
- const EventModel = eventModule?.default;
- if (!eventApi || !isFunction(eventApi.updateEventAttendance) ||
- !isFunction(eventApi.joinEvent) ||
- !EventModel || !isFunction(EventModel.create))
- throw new Error('Discourse 活动出席服务尚未就绪');
- const eventModel = EventModel.create({
- ...eventData,
- id: Number(eventData.id) || Number(postId),
- });
- const attendance = { status, recurring: false };
- if (invitee.id) await eventApi.updateEventAttendance(eventModel, attendance);
- else await eventApi.joinEvent(eventModel, attendance);
- eventData.watching_invitee = eventModel.watchingInvitee;
- eventData.stats = eventModel.stats;
- await clearStoredResponseCache({ tags: [`post:${postId}`] });
- syncSpecialPostData(post, ctx, { event: eventData });
- } catch (error) {
- alert('活动报名失败:' + error.message);
- readerPostCopies(postId, ctx).forEach(renderPostEvent);
- } finally {
- setReaderPostControlsBusy(postId, ctx, '.ldp-event-action', false);
- }
- });
- return;
- }
- const pollAction = e.target.closest('[data-poll-action]');
- if (pollAction) {
- e.preventDefault();
- const container = pollAction.closest('.ldp-reader-poll');
- const name = container && (container.dataset.ldpPollName || 'poll');
- if (!container || !name) return;
- const action = pollAction.dataset.pollAction;
- if (action === 'toggle-results') {
- const showResults = container.dataset.ldpPollShowResults !== '1';
- readerPostCopies(postId, ctx).forEach((copy) => renderPostPolls(copy, ctx, { name, showResults }));
- return;
- }
- if (action === 'remove') {
- await updateReaderPollVote(post, ctx, name);
- return;
- }
- if (action === 'submit') {
- const selected = [...container.querySelectorAll('input[data-poll-option]:checked')].map((item) => item.value);
- const postData = post._ldpPostData || {};
- const poll = (postData.polls || []).find((item) => pollName(item) === name);
- const { min, max } = pollChoiceLimits(poll);
- if (!poll || selected.length < min || selected.length > max) return;
- await updateReaderPollVote(post, ctx, name, selected);
- return;
- }
- }
- const readerAction = e.target.closest('[data-reader-action]');
- if (readerAction) {
- e.preventDefault();
- readerActionHandlers[readerAction.dataset.readerAction]?.(post);
- return;
- }
- const postAction = e.target.closest('[data-post-action]');
- if (postAction) {
- e.preventDefault();
- const action = postAction.dataset.postAction;
- const handler = postActionHandlers[action];
- if (handler && (action === 'share' || !postAction.disabled)) {
- await handler(post, postId);
- return;
- }
- if (action === 'delete' && !postAction.disabled) {
- postAction.disabled = true;
- try {
- await deleteReaderPost(post, ctx);
- } catch (error) {
- alert('删除失败:' + error.message);
- } finally {
- if (postAction.isConnected) postAction.disabled = false;
- }
- return;
- }
- if (action === 'admin' && !postAction.disabled) {
- openNativeAdminPostMenu(post, postAction, ctx);
- return;
- }
- }
- const topicAction = e.target.closest('[data-topic-action]');
- if (topicAction) {
- const action = topicAction.dataset.topicAction;
- if (action === 'shared-issue' && !topicAction.disabled) {
- if (!ME_USERNAME) syncCurrentUserFromHost();
- if (!ME_USERNAME) {
- alert('登录后才能使用“俺也一样”。');
- return;
- }
- const firstPostNode = topicAction.closest('.ldp-post');
- const sharedIssue = topicSharedIssueState(firstPostNode?._ldpPostData, ctx);
- if (!sharedIssue.visible || sharedIssue.isAuthor) return;
- await runReaderActionOnce(ctx, `topic:${ctx.topicId}:shared-issue`, async () => {
- ctx.topicSharedIssueBusy = true;
- syncTopicActionControls(ctx);
- try {
- const data = await apiSend(`${BASE}/solution/shared_issue`, 'POST', { topic_id: ctx.topicId });
- ctx.topicData.shared_issue_count = Math.max(0, Number(data?.count) || 0);
- ctx.topicData.user_created_shared_issue = !!(data?.user_created_shared_issue);
- if (ctx.topicData.user_created_shared_issue) {
- const currentLevel = Number.isInteger(ctx.topicNotificationLevel)
- ? ctx.topicNotificationLevel
- : topicNotificationLevel(ctx.topicData);
- if (currentLevel < 2) {
- ctx.topicNotificationLevel = 2;
- ctx.topicData.notification_level = 2;
- if (ctx.topicData.details) ctx.topicData.details.notification_level = 2;
- }
- }
- setCachedTopicData(ctx.topicId, ctx.topicData);
- } catch (error) {
- if (Number(error?.status) === 403) {
- SHARED_ISSUE_FORBIDDEN_TOPIC_IDS.add(Number(ctx.topicId));
- } else {
- alert('“俺也一样”操作失败:' + error.message);
- }
- } finally {
- ctx.topicSharedIssueBusy = false;
- syncTopicActionControls(ctx);
- }
- });
- return;
- }
- const handler = topicActionHandlers[action];
- if (handler) {
- await handler();
- return;
- }
- }
- const jumpBtn = e.target.closest('.ldp-jump-parent, .ldp-jump-back, .ldp-jump-self, .ldp-jump-preview-self, .ldp-quote-jump');
- if (jumpBtn) {
- const targetPostNumber = +jumpBtn.dataset.targetPostNumber;
- const quoteHighlight = quoteJump ? quoteHighlightFromJump(jumpBtn, ctx) : null;
- jumpBtn.blur();
- if (!targetPostNumber) return;
- if (jumpBtn.classList.contains('ldp-jump-parent') &&
- post && post.classList.contains('ldp-nested-collapsed')) {
- if (jumpBtn.getAttribute('aria-busy') === 'true') return;
- clearStreamViewportAnchor(ctx);
- ctx.streamScrollDirection = 0;
- ctx.scrollRoot.classList.add('ldp-floor-preview-positioning');
- const collapsedBottom = post.getBoundingClientRect().bottom;
- jumpBtn.setAttribute('aria-busy', 'true');
- setLabel(jumpBtn, `正在准备父楼层 #${targetPostNumber}`);
- try {
- await new Promise((resolve) => {
- requestAnimationFrame(resolve);
- });
- if (!jumpBtn.isConnected || !post.classList.contains('ldp-nested-collapsed')) return;
- await floorPreview.show(jumpBtn, {
- collapsedBottom,
- allowCollapsed: true,
- beforePlace: () => {
- setNestedReplyCollapsed(post, false);
- hydratePostContent(post, ctx);
- },
- });
- } finally {
- jumpBtn.removeAttribute('aria-busy');
- if (jumpBtn.isConnected && post.classList.contains('ldp-nested-collapsed')) {
- setLabel(jumpBtn, `展开楼层并预览父楼层 #${targetPostNumber}`);
- }
- requestAnimationFrame(() => ctx.scrollRoot.classList.remove('ldp-floor-preview-positioning'));
- }
- return;
- }
- let prepareTarget = null;
- if (jumpBtn.classList.contains('ldp-jump-self')) {
- const parentPostNumber = +jumpBtn.dataset.parentPostNumber || +(post && post.dataset.replyToPostNumber) || 0;
- prepareTarget = () => ensureParentJumpOnStreamPost(targetPostNumber, parentPostNumber, ctx);
- } else if (jumpBtn.classList.contains('ldp-jump-parent')) {
- prepareTarget = () => showJumpBackLink(targetPostNumber, postNumber, ctx);
- } else {
- clearJumpBackLinks(ctx);
- if (quoteJump && quoteHighlight) {
- prepareTarget = () => applyReaderQuoteHighlight(
- targetPostNumber,
- ctx,
- quoteHighlight.text,
- { source: quoteHighlight.source },
- );
- } else if (!quoteJump && !jumpBtn.classList.contains('ldp-jump-preview-self')) {
- prepareTarget = () => ensureParentJumpOnStreamPost(targetPostNumber, postNumber, ctx);
- }
- }
- const jumped = await ctx.timeline.jumpTo(targetPostNumber, {
- prepareTarget,
- alignNearest: !!quoteHighlight,
- });
- if (!jumped && quoteHighlight) clearReaderQuoteHighlight(ctx);
- return;
- }
- const boostDeleteAction = e.target.closest('.ldp-boost-delete-action');
- const boostMentionAction = e.target.closest('.ldp-boost-mention-action');
- if (boostMentionAction && !boostMentionAction.disabled) {
- e.preventDefault();
- const boostBubble = boostMentionAction.closest('.ldp-boost-bubble');
- const username = cleanUsernameValue(boostBubble?.dataset.boostUser);
- await runReaderActionOnce(ctx, `post:${postId}:boost-mention:${username.toLocaleLowerCase()}`,
- () => quoteBoostInNativeReply(boostBubble, post, ctx));
- return;
- }
- const boostItemAction = boostDeleteAction || e.target.closest('.ldp-boost-report-action');
- if (boostItemAction && !boostItemAction.disabled) {
- e.preventDefault();
- const boostBubble = boostItemAction.closest('.ldp-boost-bubble');
- const boostId = boostBubble && boostBubble.dataset.boostId;
- const report = !boostDeleteAction;
- boostItemAction.disabled = true;
- try {
- await runReaderActionOnce(
- ctx,
- `boost:${boostId}:${report ? 'report-dialog' : 'delete'}`,
- () => report ? openReaderBoostReportDialog(boostBubble, ctx) : deleteReaderBoost(post, boostBubble, ctx)
- );
- } catch (error) {
- if (report) showSelectionToast(error?.message ? error.message : '举报 Boost 失败,请重试');
- else alert('删除 Boost 失败:' + error.message);
- } finally {
- if (boostItemAction.isConnected) boostItemAction.disabled = false;
- }
- return;
- }
- const boostCopyAction = e.target.closest('.ldp-boost-copy-action');
- const boostMenuAction = boostCopyAction || e.target.closest('.ldp-boostbtn');
- if (boostMenuAction) {
- if (boostCopyAction) e.preventDefault();
- const initialRaw = boostCopyAction
- ? applyBoostCopyRule(boostBubblePlainText(boostCopyAction.closest('.ldp-boost-bubble')))
- : '';
- renderNativeBoostMenu(post, boostMenuAction, ctx, initialRaw);
- return;
- }
- const likeBtn = e.target.closest('.ldp-like');
- if (likeBtn && !likeBtn.disabled) {
- await runReaderActionOnce(ctx, `post:${postId}:like`, async () => {
- const current = readerPostCopies(postId, ctx).find((copy) =>
- readerPostOwnControls(copy, '.ldp-like').length
- ) || post;
- const currentButton = readerPostOwnControls(current, '.ldp-like')[0];
- await toggleReaderPostLike(postId, ctx, currentButton, {
- setBusy: (busy) => setReaderPostControlsBusy(postId, ctx, '.ldp-like', busy),
- postData: post._ldpPostData,
- onError: (error) => alert('操作失败:' + error.message),
- });
- });
- return;
- }
- const replyBtn = e.target.closest('.ldp-replybtn');
- if (replyBtn) {
- await runReaderActionOnce(
- ctx,
- `post:${postId}:reply-composer`,
- () => tryOpenNativeReplyComposer(post, ctx)
- );
- }
- };
- onAction(document, 'keydown', (event) => {
- if (event.key !== 'Escape' || event.isComposing || !expandedCodeBlockState ||
- modal.querySelector('.ldp-code-preview-layer')) return;
- event.preventDefault();
- event.stopImmediatePropagation();
- collapseReaderCodeBlock();
- }, true);
- onAction(modal, 'click', onModalClick);
- return () => {
- const boostMenu = document.body?.querySelector(':scope > .ldp-native-boost-menu');
- if (isFunction(boostMenu?._ldpClose)) boostMenu._ldpClose();
- closeUserCard();
- if (ctx.closeUserCard === closeUserCard) ctx.closeUserCard = null;
- if (ctx.closeUserCardLayer === closeUserCardLayer) ctx.closeUserCardLayer = null;
- if (ctx.closeTransientActions === closeTransientActions) ctx.closeTransientActions = null;
- clearActivePostActions();
- actionScope.destroy();
- if (selectionToolbarFrame) cancelAnimationFrame(selectionToolbarFrame);
- selectionToolbarFrame = 0;
- if (imageQuotePositionFrame) cancelAnimationFrame(imageQuotePositionFrame);
- imageQuotePositionFrame = 0;
- clearTimeout(imageQuoteHideTimer);
- clearImageQuoteCycleTimers();
- reactionHoverOpenTimers.forEach((timer) => clearTimeout(timer));
- reactionHoverCloseTimers.forEach((timer) => clearTimeout(timer));
- reactionVisibilityObserver?.disconnect();
- reactionVisibilityObserver = null;
- reactionHoverOpenTimers.clear();
- reactionHoverCloseTimers.clear();
- selectionToolbar.remove();
- imageQuoteToolbar.remove();
- readerPortalQuery('.ldp-selection-toast')?.remove();
- floorPreview.destroy();
- };
- }
-
- /* ============ 11. 楼中楼补全(分批渲染 + 节流 + 停顿检测) ============ */
- function directRepliesForParent(replies, postNumber) {
- const parent = +postNumber;
- const posts = Array.isArray(replies) ? replies : postsFromPayload(replies);
- return posts.filter((reply) => {
- const rawReplyTo = reply?.reply_to_post_number;
- const replyTo = +(rawReplyTo || 0);
- // /posts/:id/replies.json is already scoped to this post. LinuxDo can
- // omit reply_to_post_number here, but an explicit different parent is a
- // descendant and must stay out of this two-level reply component.
- return !replyTo || replyTo === parent;
- });
- }
-
- function mergeDirectReplies(...groups) {
- const merged = new Map();
- groups.flat().forEach((reply) => {
- if (!reply) return;
- const key = +(reply.post_number || 0) || String(reply.id || '');
- if (key) merged.set(key, reply);
- });
- return [...merged.values()].sort((a, b) => +(a.post_number || 0) - +(b.post_number || 0));
- }
-
- function replyPageCursor(posts) {
- return posts.reduce(
- (latest, reply) => Math.max(latest, +(reply?.post_number || 0)),
- 0,
- );
- }
-
- function createRepliesIO(ctx) {
- const fetchedNodes = new WeakSet();
- const hoverTimers = new WeakMap();
- const hoverTimerNodes = new Set();
- const emptyRetryCounts = new WeakMap();
- const transientRetryCounts = new WeakMap();
- const retryTimers = new Map();
- const replyPagesByKey = new Map();
- const pendingReplyPageStarts = new Map();
- const completeDirectReplyPostIds = new Set();
- const pendingPrefetchNodes = new Set();
- const visiblePrefetchNodes = new Set();
- let nestedPrefetchRunning = false;
- let nestedPrefetchStopped = false;
- let nestedPrefetchPaused = document.visibilityState !== 'visible';
- let nestedPrefetchResumeTimer = 0;
- const sessionActive = () => !ctx.readerSession || ctx.readerSession.isCurrent('opening', 'active');
- const assertSessionActive = () => {
- if (!sessionActive()) throw new DOMException('Reader session ended', 'AbortError');
- };
-
- const replyRequestKey = (postId, after = 0) => `replies:${postId}:${Math.max(0, +after || 0)}`;
-
- const cancelNestedPrefetch = (post, cancelQueued = true) => {
- if (!post) return;
- if (hoverTimers.has(post)) clearTimeout(hoverTimers.get(post));
- hoverTimers.delete(post);
- hoverTimerNodes.delete(post);
- if (retryTimers.has(post)) clearTimeout(retryTimers.get(post));
- retryTimers.delete(post);
- visiblePrefetchNodes.delete(post);
- transientRetryCounts.delete(post);
- pendingPrefetchNodes.delete(post);
- if (cancelQueued) ctx.postRequestScheduler.cancelQueued(replyRequestKey(post.dataset.postId, 0));
- };
-
- function scheduleNestedPrefetchDrain() {
- if (nestedPrefetchStopped || nestedPrefetchPaused ||
- nestedPrefetchResumeTimer || !pendingPrefetchNodes.size) return;
- const scrollAge = Date.now() - +(ctx.streamLastScrollAt || 0);
- nestedPrefetchResumeTimer = setTimeout(() => {
- nestedPrefetchResumeTimer = 0;
- if (sessionActive()) drainNestedPrefetchQueue();
- }, Math.max(120, 240 - scrollAge));
- }
-
- async function drainNestedPrefetchQueue() {
- if (nestedPrefetchRunning || nestedPrefetchStopped || nestedPrefetchPaused) return;
- if (streamScrollIsActive(ctx, 220)) {
- scheduleNestedPrefetchDrain();
- return;
- }
- nestedPrefetchRunning = true;
- try {
- while (!nestedPrefetchStopped && !nestedPrefetchPaused &&
- sessionActive() && pendingPrefetchNodes.size) {
- if (streamScrollIsActive(ctx, 220)) {
- scheduleNestedPrefetchDrain();
- break;
- }
- const candidates = [...pendingPrefetchNodes]
- .filter((node) => node?.isConnected);
- let post = candidates[0] || null;
- if (candidates.length > 1) {
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const rootCenter = (rootRect.top + rootRect.bottom) / 2;
- post = candidates
- .map((node, order) => {
- const rect = node.getBoundingClientRect();
- return {
- node,
- order,
- distance: Math.abs((rect.top + rect.bottom) / 2 - rootCenter),
- };
- })
- .sort((a, b) => a.distance - b.distance || a.order - b.order)[0].node;
- }
- if (!post) {
- pendingPrefetchNodes.clear();
- break;
- }
- pendingPrefetchNodes.delete(post);
- try {
- const loaded = await loadRepliesFor(post, POST_REQUEST_PRIORITY.nested);
- if (loaded) io.unobserve(post);
- transientRetryCounts.delete(post);
- } catch (error) {
- const status = Number(error?.status || 0);
- const transient = !!error && (
- error.name === 'AbortError' ||
- error.name === 'TypeError' ||
- status === 408 ||
- status === 429 ||
- status >= 500
- );
- if (transient) scheduleNestedPrefetchRetry(post, error);
- }
- }
- } finally {
- nestedPrefetchRunning = false;
- if (!nestedPrefetchStopped && !nestedPrefetchPaused &&
- pendingPrefetchNodes.size) scheduleNestedPrefetchDrain();
- }
- }
-
- const enqueueNestedPrefetch = (post) => {
- if (nestedPrefetchStopped || !sessionActive() || !post || !post.isConnected || fetchedNodes.has(post)) return;
- pendingPrefetchNodes.add(post);
- if (nestedPrefetchPaused) return;
- drainNestedPrefetchQueue();
- };
-
- function scheduleNestedPrefetchRetry(post, cause = null) {
- if (nestedPrefetchStopped || !sessionActive() || !post || !post.isConnected ||
- !visiblePrefetchNodes.has(post) || fetchedNodes.has(post) || retryTimers.has(post)) return;
- const retryCount = +(transientRetryCounts.get(post) || 0) + 1;
- transientRetryCounts.set(post, retryCount);
- let requestState = null;
- try { requestState = ctx.postRequestScheduler.snapshot(); } catch {}
- const controllerWait = Math.max(
- Number(requestState?.cooldownRemaining) || 0,
- Number(requestState?.nextPermitDelay) || 0,
- Math.max(0, Number(cause?.retryAt || 0) - Date.now())
- );
- const delay = controllerWait > 0
- ? Math.min(60000, controllerWait + 80)
- : Math.min(15000, 500 * Math.pow(2, Math.min(5, retryCount - 1)));
- const timer = setTimeout(() => {
- retryTimers.delete(post);
- if (sessionActive() && post.isConnected && visiblePrefetchNodes.has(post)) {
- enqueueNestedPrefetch(post);
- }
- }, Math.max(250, delay));
- retryTimers.set(post, timer);
- }
-
- async function fetchReplyPage(postId, after = 0, options = {}) {
- assertSessionActive();
- if (!postId) return [];
- const normalizedAfter = Math.max(0, +after || 0);
- const pageKey = `${postId}:${normalizedAfter}`;
- if (replyPagesByKey.has(pageKey)) {
- const pendingSince = +(pendingReplyPageStarts.get(pageKey) || 0);
- const cached = await replyPagesByKey.get(pageKey);
- if (options.refresh !== true || pendingSince && responseCacheValueStoredAt(cached) >= pendingSince) return cached;
- replyPagesByKey.delete(pageKey);
- }
- const url = new URL(`${BASE}/posts/${postId}/replies.json`);
- if (normalizedAfter > 0) url.searchParams.set('after', String(normalizedAfter));
- const request = fetchJSON(url.toString(), {
- scheduler: ctx.postRequestScheduler,
- fetchOptions: { credentials: 'include', cache: 'no-store' },
- cacheMode: options.refresh === true ? 'refresh' : 'default',
- key: replyRequestKey(postId, normalizedAfter),
- priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.nested,
- });
- pendingReplyPageStarts.set(pageKey, Date.now());
- replyPagesByKey.set(pageKey, request);
- try {
- const payload = await request;
- assertSessionActive();
- ctx.tracker.preload(postsFromPayload(payload));
- replyPagesByKey.set(pageKey, payload);
- return payload;
- } catch (error) {
- replyPagesByKey.delete(pageKey);
- throw error;
- } finally {
- pendingReplyPageStarts.delete(pageKey);
- }
- }
-
- async function loadDirectReplies(post, options = {}) {
- assertSessionActive();
- const postId = +(post?.id || 0);
- const postNumber = +(post?.post_number || 0);
- if (!postId || !postNumber) return [];
- const knownMap = ctx.directRepliesByParent.get(postNumber);
- let merged = mergeDirectReplies(knownMap ? [...knownMap.values()] : []);
- const expected = Math.max(0, +(post.reply_count || 0));
- if ((options.force !== true || completeDirectReplyPostIds.has(postId)) && expected <= merged.length) return merged;
- let after = 0;
- const seenCursors = new Set();
- do {
- const payload = await fetchReplyPage(
- postId, after, {
- priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.background,
- refresh: options.force === true,
- }
- );
- const pagePosts = postsFromPayload(payload);
- const direct = directRepliesForParent(pagePosts, postNumber);
- direct.forEach((reply) => rememberPostData(ctx, reply, postNumber));
- ctx.loader.persistFetchedPosts(direct);
- merged = mergeDirectReplies(merged, direct);
- const nextAfter = replyPageCursor(pagePosts);
- if (!pagePosts.length || pagePosts.length < SUB_REPLY_FETCH_SIZE ||
- nextAfter <= after || seenCursors.has(nextAfter)) break;
- seenCursors.add(nextAfter);
- after = nextAfter;
- } while (!expected || merged.length < expected);
- completeDirectReplyPostIds.add(postId);
- return merged;
- }
-
- function mergeReplyPageIntoState(state, payload, postNumber, expectedCount, after = 0) {
- const pagePosts = postsFromPayload(payload);
- const pageReplies = directRepliesForParent(pagePosts, postNumber);
- const nextAfter = replyPageCursor(pagePosts);
- pageReplies.forEach((reply) => rememberPostData(ctx, reply, postNumber));
- ctx.loader.persistFetchedPosts(pageReplies);
- state.all = mergeDirectReplies(state.all, pageReplies);
- state.fetchedReplyCount = after > 0
- ? +(state.fetchedReplyCount || 0) + pageReplies.length
- : pageReplies.length;
- state.fetchAfter = Math.max(after, nextAfter);
- const previousTotalCount = after > 0 ? +(state.totalCount || 0) : 0;
- const totalCount = Math.max(state.all.length, previousTotalCount, +(expectedCount || 0));
- state.hasMore = nextAfter > after && pagePosts.length >= SUB_REPLY_FETCH_SIZE &&
- state.fetchedReplyCount < totalCount;
- state.totalCount = state.hasMore ? totalCount : state.all.length;
- return pagePosts;
- }
-
- async function loadNextReplyPages(post, requiredCount) {
- if (!sessionActive()) return false;
- const postNumber = +(post && post.dataset.postNumber || 0);
- const state = ctx.subReplyState.get(postNumber);
- if (!postNumber || !state || state.loadingMore || state.hasMore === false) return false;
- const loadingEl = post.querySelector(':scope > .ldp-sub-loading');
- state.loadingMore = true;
- loadingEl?.classList.add('show');
- renderSubReplyBatch(postNumber, ctx, 0, post);
- try {
- while (state.hasMore !== false && +(state.fetchedReplyCount || 0) < requiredCount) {
- const after = +(state.fetchAfter || 0);
- if (!after) {
- state.hasMore = false;
- break;
- }
- const payload = await fetchReplyPage(post.dataset.postId, after, {
- priority: POST_REQUEST_PRIORITY.scroll,
- });
- if (!sessionActive() || !post.isConnected) return false;
- const pagePosts = mergeReplyPageIntoState(
- state, payload, postNumber, post.dataset.childReplyTotal, after
- );
- if (state.fetchAfter <= after || !pagePosts.length) break;
- }
- return state.all.length >= requiredCount;
- } catch {
- return false;
- } finally {
- state.loadingMore = false;
- loadingEl?.classList.remove('show');
- if (sessionActive() && post.isConnected) renderSubReplyBatch(postNumber, ctx, 0, post);
- }
- }
-
- async function loadRepliesFor(post, priority = POST_REQUEST_PRIORITY.nested) {
- if (!sessionActive()) return false;
- const postId = post && post.dataset.postId;
- const postNumber = +(post && post.dataset.postNumber);
- if (!postId || fetchedNodes.has(post)) return false;
- fetchedNodes.add(post);
- const loadingEl = post.querySelector(':scope > .ldp-sub-loading');
- loadingEl?.classList.add('show');
- const expectedCount = +(post.dataset.childReplyTotal || 0);
- const knownReplyMap = ctx.directRepliesByParent.get(postNumber);
- const knownReplies = knownReplyMap ? [...knownReplyMap.values()] : [];
- if (knownReplies.length) {
- const previousState = ctx.subReplyState.get(postNumber);
- const knownComplete = knownReplies.length >= expectedCount;
- const previousHasCursor = +(previousState?.fetchAfter || 0) > 0;
- const needsInitialFetch = !knownComplete && !previousHasCursor &&
- (!previousState || previousState.hasMore !== false);
- ctx.subReplyState.set(postNumber, {
- ...previousState,
- all: mergeDirectReplies(knownReplies),
- pageIndex: +(previousState?.pageIndex || 0),
- totalCount: previousState && previousState.hasMore === false
- ? Math.max(knownReplies.length, +(previousState.totalCount || 0))
- : Math.max(expectedCount, knownReplies.length),
- fetchedReplyCount: knownComplete
- ? knownReplies.length
- : +(previousState?.fetchedReplyCount || 0),
- fetchAfter: +(previousState?.fetchAfter || 0),
- hasMore: knownComplete ? false : previousState ? previousState.hasMore !== false : true,
- loadingMore: needsInitialFetch,
- });
- renderSubReplyBatch(postNumber, ctx, 0, post);
- if (!needsInitialFetch) {
- loadingEl?.classList.remove('show');
- emptyRetryCounts.delete(post);
- return true;
- }
- }
- try {
- const replies = await fetchReplyPage(postId, 0, { priority });
- if (!sessionActive()) return false;
- if (!post.isConnected) {
- fetchedNodes.delete(post);
- return false;
- }
- const previousState = ctx.subReplyState.get(postNumber);
- const nextState = {
- ...previousState,
- all: knownReplies,
- pageIndex: +(previousState?.pageIndex || 0),
- loadingMore: false,
- };
- mergeReplyPageIntoState(nextState, replies, postNumber, expectedCount);
- const directReplies = nextState.all;
- loadingEl?.classList.remove('show');
- if (!directReplies.length) {
- fetchedNodes.delete(post);
- replyPagesByKey.delete(`${postId}:0`);
- const retryCount = +(emptyRetryCounts.get(post) || 0);
- if (expectedCount > 0 && retryCount < 2 && post.isConnected) {
- await clearStoredResponseCache({ tags: [`post:${postId}`] });
- emptyRetryCounts.set(post, retryCount + 1);
- const retryTimer = setTimeout(() => {
- retryTimers.delete(post);
- enqueueNestedPrefetch(post);
- }, 1200 * (retryCount + 1));
- retryTimers.set(post, retryTimer);
- }
- return false;
- }
- emptyRetryCounts.delete(post);
- ctx.subReplyState.set(postNumber, nextState);
- renderSubReplyBatch(postNumber, ctx, 0, post); // 首批只渲染当前分页条数
- return true;
- } catch (e) {
- loadingEl?.classList.remove('show');
- const state = ctx.subReplyState.get(postNumber);
- if (state) {
- state.loadingMore = false;
- renderSubReplyBatch(postNumber, ctx, 0, post);
- }
- fetchedNodes.delete(post); // 失败允许该 DOM 副本下次进入视口重试
- replyPagesByKey.delete(`${postId}:0`);
- throw e;
- }
- }
-
- const scheduleInitialLoad = (post) => {
- if (!sessionActive() || !post || !post.dataset.postId || fetchedNodes.has(post) || hoverTimers.has(post)) return;
- const timer = setTimeout(() => {
- hoverTimers.delete(post);
- hoverTimerNodes.delete(post);
- if (!sessionActive() || !post.isConnected) return;
- enqueueNestedPrefetch(post);
- }, REPLIES_HOVER_DELAY);
- hoverTimers.set(post, timer);
- hoverTimerNodes.add(post);
- };
-
- const nestedPrefetchPixels = Math.round(
- Math.max(ctx.scrollRoot.clientHeight, window.innerHeight * 0.5) *
- streamPerformance(ctx).nestedPrefetchViewports
- );
- const io = new IntersectionObserver((entries) => {
- if (!sessionActive()) return;
- entries.forEach((en) => {
- if (!en.target.dataset.postId) return;
-
- if (en.isIntersecting) {
- visiblePrefetchNodes.add(en.target);
- scheduleInitialLoad(en.target);
- } else {
- // 离开视口时若尚未真正发起请求,则取消该次触发
- cancelNestedPrefetch(en.target);
- }
- });
- }, { root: ctx.scrollRoot, rootMargin: `${nestedPrefetchPixels}px 0px ${nestedPrefetchPixels}px 0px`, threshold: 0.01 });
- const nativeObserve = io.observe.bind(io);
- io.observe = (node) => {
- if (!node || fetchedNodes.has(node) || !(+(node.dataset.childReplyTotal || 0) > 0)) return;
- nativeObserve(node);
- };
- const nativeUnobserve = io.unobserve.bind(io);
- io.unobserve = (node) => {
- cancelNestedPrefetch(node);
- nativeUnobserve(node);
- };
- const nativeDisconnect = io.disconnect.bind(io);
- io.disconnect = () => {
- nestedPrefetchStopped = true;
- if (nestedPrefetchResumeTimer) clearTimeout(nestedPrefetchResumeTimer);
- nestedPrefetchResumeTimer = 0;
- new Set([...hoverTimerNodes, ...retryTimers.keys()]).forEach((node) => cancelNestedPrefetch(node, false));
- pendingPrefetchNodes.clear();
- visiblePrefetchNodes.clear();
- replyPagesByKey.clear();
- pendingReplyPageStarts.clear();
- completeDirectReplyPostIds.clear();
- nativeDisconnect();
- };
- io.loadNextPage = (node, requiredCount) => loadNextReplyPages(node, requiredCount);
- io.loadDirectReplies = loadDirectReplies;
- io.setActive = (active) => {
- nestedPrefetchPaused = active !== true;
- if (nestedPrefetchPaused) {
- if (nestedPrefetchResumeTimer) clearTimeout(nestedPrefetchResumeTimer);
- nestedPrefetchResumeTimer = 0;
- return;
- }
- if (pendingPrefetchNodes.size) scheduleNestedPrefetchDrain();
- };
- return io;
- }
- function syncCommentsHeaderPlacement(ctx) {
- if (!ctx || !ctx.commentsHeader || !ctx.streamNodeMap) return;
- if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return;
- const firstPost = ctx.streamNodeMap.get(1);
- const firstPostMounted = firstPost && firstPost.parentElement === ctx.commentsEl &&
- ctx.streamMountedNodes && ctx.streamMountedNodes.has(firstPost);
- if (!firstPostMounted) {
- setHidden(ctx.commentsHeader, true);
- return;
- }
- const children = firstPost.querySelector(':scope > .ldp-children');
- const childLoading = firstPost.querySelector(':scope > .ldp-sub-loading');
- const footerActions = firstPost.querySelector(':scope > .ldp-topic-footer-actions');
- let anchor = null;
- if (footerActions) {
- const replyEnd = childLoading || children;
- if (replyEnd && footerActions.previousElementSibling !== replyEnd) {
- firstPost.insertBefore(footerActions, replyEnd.nextSibling);
- }
- anchor = footerActions;
- } else if (children) {
- anchor = childLoading || children;
- }
- const correctlyPlaced = ctx.commentsHeader.parentElement === firstPost &&
- (anchor
- ? ctx.commentsHeader.previousSibling === anchor
- : ctx.commentsHeader.nextSibling === null);
- if (!correctlyPlaced) {
- firstPost.insertBefore(ctx.commentsHeader, anchor?.nextSibling || null);
- }
- setHidden(ctx.commentsHeader, false);
- }
-
- function updateCommentsHeader(ctx) {
- const commentCount = Math.max(0, +(ctx?.totalPosts || 0) - 1);
- setText(ctx.countEl, `(${commentCount})`);
- if (ctx.emptyEl) ctx.emptyEl.hidden = !!ctx.totalPosts;
- syncCommentsHeaderPlacement(ctx);
- }
-
- function bindReaderTopicPresence(ctx) {
- if (!ctx || !ctx.commentsHeader || !ctx.topicId) return () => {};
- let presence = ctx.commentsHeader.querySelector(':scope > .ldp-topic-presence');
- if (!presence) {
- presence = makeElement('span');
- presence.className = 'ldp-topic-presence';
- presence.hidden = true;
- presence.setAttribute('aria-live', 'polite');
- ctx.commentsHeader.appendChild(presence);
- }
- const channel = `/discourse-presence/reply/${ctx.topicId}`;
- let stopped = false;
- let presenceChannel = null;
- let presenceChangeHandler = null;
-
- const render = (source) => {
- const users = (Array.isArray(source) ? source : [])
- .filter((user) => user && (!ME_USERNAME || user.username !== ME_USERNAME));
- if (!users.length) {
- presence.hidden = true;
- presence.replaceChildren();
- return;
- }
- const visibleUsers = users.slice(0, 3);
- const avatars = visibleUsers.map((user) => {
- const username = String(user.username || '');
- const name = String(user.name || username || '用户');
- const avatar = persistentAvatarHtml(user.avatar_template, 24, 'ldp-topic-presence-avatar', name);
- return `
${avatar}`;
- }).join('');
- const first = users[0];
- const firstName = String(first.name || first.username || '用户');
- const label = users.length === 1 ? `${firstName} 正在回复…` : `${users.length} 人正在回复…`;
- presence.innerHTML = `
${avatars}${esc(label)}`;
- presence.hidden = false;
- hydratePersistentAvatarImages(presence, POST_REQUEST_PRIORITY.background);
- };
-
- const unsubscribe = async (
- channelProxy = presenceChannel,
- changeHandler = presenceChangeHandler
- ) => {
- if (!channelProxy) return;
- if (changeHandler && isFunction(channelProxy.off)) {
- channelProxy.off('change', changeHandler);
- }
- try {
- await channelProxy.unsubscribe();
- } catch {}
- if (presenceChannel === channelProxy) {
- presenceChangeHandler = null;
- presenceChannel = null;
- }
- };
- const start = async () => {
- const presenceService = lookupDiscourse('service:presence');
- if (!presenceService || !isFunction(presenceService.getChannel)) {
- render([]);
- return;
- }
- const channelProxy = presenceService.getChannel(channel);
- presenceChannel = channelProxy;
- if (!channelProxy || !isFunction(channelProxy.subscribe)) {
- presenceChannel = null;
- render([]);
- return;
- }
- const changeHandler = () => {
- if (!stopped) render([...channelProxy.users || []]);
- };
- presenceChangeHandler = changeHandler;
- if (isFunction(channelProxy.on)) {
- channelProxy.on('change', changeHandler);
- }
- try {
- await channelProxy.subscribe();
- if (stopped) {
- await unsubscribe(channelProxy, changeHandler);
- return;
- }
- changeHandler();
- } catch {
- if (!stopped) render([]);
- await unsubscribe(channelProxy, changeHandler);
- }
- };
- void start();
-
- return () => {
- stopped = true;
- void unsubscribe();
- presence.hidden = true;
- presence.replaceChildren();
- };
- }
-
- const READER_LOADING_ANIMATIONS = Object.freeze([
- { key: 'portal', label: '主题开卷', markup: '
' },
- { key: 'constellation', label: '回帖脉络', markup: '
' },
- { key: 'corridor', label: '楼层时间轴', markup: '
' },
- { key: 'typewave', label: 'Markdown 解析', markup: '
' },
- { key: 'crystal', label: '缓存回环', markup: '
' },
- { key: 'marginalia', label: '只看楼主', markup: '
' },
- { key: 'chapters', label: '分类标签', markup: '
' },
- { key: 'quoteecho', label: '社区信条', markup: '
' },
- { key: 'footnotes', label: '新回复抵达', markup: '
' },
- { key: 'inkverse', label: '互动汇流', markup: '
' },
- ]);
-
- function resolveReaderLoadingAnimation(preference = PREFS.loadingAnimation, randomKey = '', excludedKey = '') {
- const normalized = normalizeReaderLoadingAnimation(preference);
- if (normalized !== 'random')
- return READER_LOADING_ANIMATIONS.find((animation) => animation.key === normalized) || READER_LOADING_ANIMATIONS[0];
- const requestedRandom = READER_LOADING_ANIMATIONS.find((animation) => animation.key === randomKey);
- if (requestedRandom) return requestedRandom;
- const candidates = READER_LOADING_ANIMATIONS.filter((animation) => animation.key !== excludedKey);
- const pool = candidates.length ? candidates : READER_LOADING_ANIMATIONS;
- return pool[Math.floor(Math.random() * pool.length)];
- }
-
- function readerLoadingVisualHTML(animation) {
- return `
- ${animation.markup}
-
`;
- }
-
- let lastRandomReaderLoadingAnimationKey = '';
- function createReaderLoadingHTML(preference = PREFS.loadingAnimation) {
- const normalized = normalizeReaderLoadingAnimation(preference);
- const animation = resolveReaderLoadingAnimation(normalized, '',
- normalized === 'random' ? lastRandomReaderLoadingAnimationKey : '');
- if (normalized === 'random') lastRandomReaderLoadingAnimationKey = animation.key;
- return `
-
- ${readerLoadingVisualHTML(animation)}
-
-
${esc(SITE_DISPLAY_NAME.toUpperCase())} READER · ${animation.label}
-
正在载入帖子
-
正在准备阅读现场…
-
-
`;
- }
-
- function parkReaderCommentsHeader(overlay, preferredHeader = null) {
- const body = overlay?.querySelector('.ldp-body');
- if (!body) return null;
- const readerShell = overlay._ldpReaderShell;
- let commentsHeader = preferredHeader || (readerShell?.commentsHeader) ||
- overlay.querySelector('.ldp-comments-header');
- if (!commentsHeader) {
- commentsHeader = makeElement('div');
- commentsHeader.className = 'ldp-comments-header';
- commentsHeader.hidden = true;
- commentsHeader.innerHTML = `${icon('messageSquare')}
评论`;
- }
- const loadingTip = body.querySelector('.ldp-loading-tip');
- commentsHeader.hidden = true;
- commentsHeader.remove();
- if (loadingTip) body.insertBefore(commentsHeader, loadingTip);
- else body.appendChild(commentsHeader);
- if (readerShell) readerShell.commentsHeader = commentsHeader;
- return commentsHeader;
- }
-
- function resetReaderTopicShell(overlay) {
- const body = overlay?.querySelector('.ldp-body');
- if (!body) return;
- overlay.querySelector('.ldp-header')?.classList.remove('ldp-title-single-line');
- const comments = body.querySelector('.ldp-comments');
- parkReaderCommentsHeader(overlay);
- const loadingTip = body.querySelector('.ldp-loading-tip');
- const endTip = body.querySelector('.ldp-end-tip');
- const timeline = overlay.querySelector('.ldp-topic-timeline');
- const title = overlay.querySelector('.ldp-title-jump');
- const metaStats = overlay.querySelector('.ldp-meta-stats');
- const metaOwner = overlay.querySelector('.ldp-meta-owner');
- const tags = overlay.querySelector('.ldp-topic-tags');
- const voteSlot = overlay.querySelector('.ldp-topic-vote-slot');
- const onlyOp = overlay.querySelector('.ldp-only-op-toggle');
- const onlyOpProgress = overlay.querySelector('.ldp-only-op-progress');
- const nativeOpen = overlay.querySelector('.ldp-open');
- const liveUpdate = overlay.querySelector('.ldp-live-update');
- const rateLimitNotice = overlay.querySelector('.ldp-rate-limit-notice');
-
- body.querySelector('.ldp-loadmask')?.remove();
- const mask = makeElement('div');
- mask.className = 'ldp-loadmask';
- mask.innerHTML = createReaderLoadingHTML();
- body.prepend(mask);
- if (comments) {
- const empty = makeElement('div');
- empty.className = 'ldp-comments-empty';
- empty.hidden = true;
- empty.textContent = '暂无评论';
- comments.replaceChildren(empty);
- }
- if (loadingTip) loadingTip.classList.remove('show');
- if (endTip) endTip.classList.remove('show');
- if (liveUpdate) liveUpdate.hidden = true;
- if (rateLimitNotice) {
- rateLimitNotice.hidden = true;
- delete rateLimitNotice.dataset.cooldownSeconds;
- }
- if (timeline) {
- timeline.hidden = true;
- timeline.querySelector('.ldp-topic-timeline-current')?.replaceChildren();
- timeline.querySelector('.ldp-topic-timeline-total')?.replaceChildren();
- timeline.querySelector('.ldp-topic-timeline-preview')?.replaceChildren();
- }
- if (title) title.textContent = '正在切换主题…';
- if (metaStats) metaStats.textContent = '正在加载主题…';
- if (metaOwner) metaOwner.hidden = true;
- if (tags) {
- tags.replaceChildren();
- tags.hidden = true;
- }
- if (voteSlot) {
- voteSlot.replaceChildren();
- voteSlot.hidden = true;
- }
- if (onlyOp) {
- onlyOp.disabled = true;
- onlyOp.classList.remove('active');
- setPressed(onlyOp, false);
- setLabel(onlyOp, '只看楼主');
- }
- if (onlyOpProgress) {
- onlyOpProgress.hidden = true;
- onlyOpProgress.dataset.state = '';
- onlyOpProgress.style.removeProperty('--ldp-only-op-progress');
- }
- if (nativeOpen) {
- nativeOpen.hidden = true;
- nativeOpen.removeAttribute('href');
- }
- body.scrollTop = 0;
- }
-
- /* ============ 13. 弹窗主体 + 循环泵加载 ============ */
- let CURRENT_OVERLAY = null;
- restoreReaderQueueState();
- let READER_SESSION_SEQUENCE = 0;
- let READER_OPEN_REQUEST_SEQUENCE = 0;
- let ACTIVE_READER_SESSION_ID = 0;
- let HOST_DOCUMENT_TITLE;
- let AUTO_OPEN_SUPPRESSED_TOPIC_ID = bypassReaderForThisTab ? String(INITIAL_TOPIC_ID || '') : '';
- let DISCOURSE_ROUTE_HANDLER_INSTALLED = false;
- let DISCOURSE_UPDATE_EVENT_MONITOR = null;
- let LAST_READER_TOPIC_ROUTE_KEY = '';
-
- function syncReaderDocumentTitle(topicTitle) {
- const title = String(topicTitle || '').trim();
- if (!title) return;
- const documentTitle = lookupDiscourse('service:document-title');
- if (!documentTitle || !isFunction(documentTitle.setTitle)) return;
- if (HOST_DOCUMENT_TITLE === undefined)
- HOST_DOCUMENT_TITLE = isFunction(documentTitle.getTitle)
- ? documentTitle.getTitle()
- : null;
- documentTitle.setTitle(title);
- }
-
- function restoreHostDocumentTitle() {
- if (HOST_DOCUMENT_TITLE === undefined) return;
- const documentTitle = lookupDiscourse('service:document-title');
- if (isFunction(documentTitle?.setTitle))
- documentTitle.setTitle(HOST_DOCUMENT_TITLE);
- HOST_DOCUMENT_TITLE = undefined;
- }
-
- function currentHostRouteKey() {
- return `${location.pathname}${location.search}${location.hash}`;
- }
-
- function embeddedReaderViewportState(ctx) {
- if (!ctx || !ctx.scrollRoot) return null;
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const visible = [...ctx.streamMountedNodes || []]
- .filter((node) => node?.isConnected && +node.dataset.postNumber > 0)
- .map((node) => ({ node, rect: node.getBoundingClientRect() }))
- .filter((item) => item.rect.bottom > rootRect.top && item.rect.top < rootRect.bottom)
- .sort((left, right) => left.rect.top - right.rect.top)[0];
- return {
- postNumber: visible ? +visible.node.dataset.postNumber : 0,
- postOffset: visible ? visible.rect.top - rootRect.top : 0,
- scrollTop: Math.max(0, Number(ctx.scrollRoot.scrollTop) || 0),
- };
- }
-
- function embeddedReaderPostViewportState(ctx, postNumber) {
- const safePostNumber = Math.max(0, Number(postNumber) || 0);
- if (!ctx || !ctx.scrollRoot || !safePostNumber) return null;
- const node = ctx.streamNodeMap?.get(safePostNumber);
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const postOffset = node?.isConnected
- ? node.getBoundingClientRect().top - rootRect.top
- : 0;
- return {
- postNumber: safePostNumber,
- postOffset,
- scrollTop: Math.max(0, Number(ctx.scrollRoot.scrollTop) || 0),
- };
- }
-
- function saveEmbeddedReaderReloadState() {
- const overlay = CURRENT_OVERLAY;
- const readerShell = overlay?._ldpReaderShell;
- const readerWorkspace = readerShell?.readerWorkspace;
- const ctx = readerShell?.activeContext;
- if (!overlay || !readerWorkspace || !readerWorkspace.isEmbedded() || !ctx || !ctx.readerSession ||
- !ctx.readerSession.isCurrent('opening', 'active')) return;
- const anchor = captureReaderAnchorState(overlay, readerShell.targetPostNumber);
- if (!anchor) return;
- const state = {
- savedAt: Date.now(),
- hostRoute: currentHostRouteKey(),
- mode: readerWorkspace.mode(),
- topicId: Number(overlay.dataset.topicId || ctx.topicId) || 0,
- anchor,
- onlyOp: ctx.onlyOp === true,
- topicNavMetadata: readerShell.topicNavMetadata || null,
- };
- if (!state.topicId) return;
- try { sessionStorage.setItem(LDP_EMBEDDED_RELOAD_STATE_KEY, JSON.stringify(state)); } catch {}
- }
-
- function readEmbeddedReaderReloadState({ consume = false } = {}) {
- let state;
- try {
- state = JSON.parse(sessionStorage.getItem(LDP_EMBEDDED_RELOAD_STATE_KEY) || 'null');
- if (consume) sessionStorage.removeItem(LDP_EMBEDDED_RELOAD_STATE_KEY);
- } catch {
- if (consume) {
- try { sessionStorage.removeItem(LDP_EMBEDDED_RELOAD_STATE_KEY); } catch {}
- }
- return null;
- }
- const navigationEntry = performance.getEntriesByType && performance.getEntriesByType('navigation')[0];
- const reloaded = navigationEntry ? navigationEntry.type === 'reload' : performance.navigation?.type === 1;
- if (!reloaded || !state || Date.now() - Number(state.savedAt || 0) > LDP_EMBEDDED_RELOAD_STATE_TTL ||
- state.hostRoute !== currentHostRouteKey() || !readerPresentation(state.mode).embedded || !(Number(state.topicId) > 0))
- return null;
- return state;
- }
-
- async function restoreEmbeddedReaderAfterReload() {
- const state = readEmbeddedReaderReloadState({ consume: true });
- if (!state) return false;
- const anchor = normalizeReaderAnchorState(state.anchor);
- if (!anchor) return false;
- setPref('listReaderMode', state.mode);
- await openModal(state.topicId, {
- targetPostNumber: anchor.viewport.postNumber,
- topicNavMetadata: state.topicNavMetadata || undefined,
- restoreOnlyOp: state.onlyOp === true,
- restoreState: anchor,
- });
- return true;
- }
-
- function timelineMonthLabel(createdAt) {
- const date = new Date(createdAt || 0);
- if (!Number.isFinite(date.getTime())) return '';
- return `${date.getFullYear()} 年\n${date.getMonth() + 1} 月 ${date.getDate()} 日`;
- }
-
- function bindTopicTimeline(timeline, ctx) {
- const trackTopInset = 10;
- const trackBottomInset = 10;
- const track = timeline.querySelector('.ldp-topic-timeline-track');
- const dateEl = timeline.querySelector('.ldp-topic-timeline-date');
- const currentEl = timeline.querySelector('.ldp-topic-timeline-current');
- const totalEl = timeline.querySelector('.ldp-topic-timeline-total');
- const relativeEl = timeline.querySelector('.ldp-topic-timeline-relative');
- const previewEl = timeline.querySelector('.ldp-topic-timeline-preview');
- const cursorEl = timeline.querySelector('.ldp-topic-timeline-cursor');
- const floorJumpTrigger = timeline.querySelector('.ldp-topic-timeline-jump');
- const floorJumpForm = timeline.querySelector('.ldp-topic-timeline-jump-form');
- const floorJumpInput = timeline.querySelector('.ldp-topic-timeline-jump-input');
- const floorJumpHint = timeline.querySelector('.ldp-topic-timeline-jump-hint');
- const floorJumpSubmit = timeline.querySelector('.ldp-topic-timeline-jump-submit');
- const lensFloorElements = new Map();
- const spareLensFloorElements = [];
- const timelineScope = createLifecycleScope();
- let renderedCurrent = 0;
- let renderedDisplayCurrent = 0;
- let renderedTotal = 0;
- let renderedProgress = NaN;
- let renderedDateText = null;
- let renderedRelativeText = null;
- let renderedRelativeDisabled = null;
- let renderedTimelineHidden = null;
- let frame = 0;
- let previewFrame = 0;
- let jumpAnimationFrame = 0;
- let jumpAnimationTimer = 0;
- let previewClientY = 0;
- let previewVisible = false;
- let previewTarget = 0;
- let dragging = false;
- let activePointerId = 0;
- let dragTarget = 1;
- let stablePostNumber = 0;
- let stableCreatedAt = '';
- let lensTargetPostNumber = 0;
- let jumpSequence = 0;
- let timelineTrackRect = null;
- let currentDisplayOverride = 0;
-
- const totalPostCount = () => Math.max(1, ctx.totalPosts || ctx.streamItems.length || 1);
- const syncFloorJumpInputLimit = () => {
- const total = totalPostCount();
- floorJumpInput.maxLength = String(total).length + 1;
- setLabel(floorJumpInput, `楼层号,范围 1 到 ${total}`);
- return total;
- };
- const validateFloorJumpInput = () => {
- const rawValue = floorJumpInput.value;
- const total = syncFloorJumpInputLimit();
- let value = 0;
- let message = '';
- if (!rawValue) message = `请输入楼层号(1–${total})`;
- else if (!/^\d+$/.test(rawValue)) message = '仅支持十进制整数';
- else {
- value = Number(rawValue);
- if (!Number.isSafeInteger(value)) message = '楼层号数值过大';
- else if (value < 1 || value > total) message = `超出范围,请输入 1–${total}`;
- }
- floorJumpInput.setAttribute('aria-invalid', String(!!message));
- floorJumpForm.classList.toggle('is-invalid', !!message);
- floorJumpHint.textContent = message || `有效范围:1–${total}`;
- floorJumpSubmit.disabled = !!message;
- return message ? 0 : value;
- };
- const onlyOpTimelineItems = () => ctx.onlyOp ? streamLayoutItems(ctx) : null;
- const progressForPost = (postNumber) => {
- const items = onlyOpTimelineItems();
- if (items?.length) {
- const item = ctx.streamItemMap.get(+postNumber);
- const index = item && streamItemMatchesFilter(ctx, item) && Number.isInteger(item.layoutIndex)
- ? item.layoutIndex
- : -1;
- if (index >= 0) return items.length > 1 ? index / (items.length - 1) : 0;
- }
- const total = totalPostCount();
- const current = Math.max(1, Math.min(total, +postNumber || 1));
- return total > 1 ? (current - 1) / (total - 1) : 0;
- };
- const targetFromRatio = (ratio) => {
- const items = onlyOpTimelineItems();
- if (items?.length) {
- const index = Math.round(ratio * (items.length - 1));
- return +items[Math.max(0, Math.min(items.length - 1, index))].postNumber;
- }
- const total = totalPostCount();
- return Math.max(1, Math.min(total, Math.round(1 + ratio * (total - 1))));
- };
- const targetFromKey = (current, delta) => {
- const items = onlyOpTimelineItems();
- if (items?.length) {
- const item = ctx.streamItemMap.get(+current);
- const index = item && streamItemMatchesFilter(ctx, item) && Number.isInteger(item.layoutIndex)
- ? item.layoutIndex
- : 0;
- return +items[Math.max(0, Math.min(items.length - 1, index + delta))].postNumber;
- }
- const total = totalPostCount();
- return Math.max(1, Math.min(total, current + delta));
- };
- const refreshTimelineTrackRect = () => {
- timelineTrackRect = track.getBoundingClientRect();
- return timelineTrackRect;
- };
- const timelineTrackMetrics = () => {
- const rect = timelineTrackRect || refreshTimelineTrackRect();
- return {
- rect,
- usableHeight: Math.max(1, rect.height - trackTopInset - trackBottomInset),
- };
- };
- const timelineLensTopAtRatio = (ratio) => {
- const { usableHeight } = timelineTrackMetrics();
- return trackTopInset + Math.max(0, Math.min(1, ratio)) * usableHeight;
- };
- const timelineLensLayout = () => {
- const items = onlyOpTimelineItems();
- if (items?.length)
- return { length: items.length, floorAt: (index) => +items[index].postNumber };
- return { length: totalPostCount(), floorAt: (index) => index + 1 };
- };
- const timelineLensProfile = (offset) => {
- const distance = Math.abs(offset);
- const focus = Math.exp(-0.72 * distance * distance);
- return {
- opacity: Math.pow(Math.max(0, 1 - distance / 3), 1.25),
- scale: 0.46 + 0.54 * focus,
- shiftX: -Math.min(6, distance * 2),
- };
- };
- const renderTimelineLensAtRatio = (ratio, lensTop = timelineLensTopAtRatio(ratio)) => {
- if (!cursorEl) return;
- cursorEl.style.setProperty('--ldp-timeline-lens-top', `${lensTop.toFixed(2)}px`);
- const layout = timelineLensLayout();
- const continuousIndex = Math.max(0, Math.min(layout.length - 1, ratio * (layout.length - 1)));
- const nearestIndex = Math.round(continuousIndex);
- const selectedFloor = layout.floorAt(nearestIndex);
- const firstIndex = Math.max(0, nearestIndex - 3);
- const lastIndex = Math.min(layout.length - 1, nearestIndex + 3);
- const desiredFloors = new Map();
- for (let index = firstIndex; index <= lastIndex; index++) {
- desiredFloors.set(layout.floorAt(index), index - continuousIndex);
- }
- lensFloorElements.forEach((element, floor) => {
- if (desiredFloors.has(floor)) return;
- element.hidden = true;
- lensFloorElements.delete(floor);
- spareLensFloorElements.push(element);
- });
- desiredFloors.forEach((offset, floor) => {
- let element = lensFloorElements.get(floor);
- if (!element) {
- element = spareLensFloorElements.pop() || makeElement('span');
- element.textContent = `#${floor}`;
- lensFloorElements.set(floor, element);
- if (!element.isConnected) cursorEl.append(element);
- element.hidden = false;
- }
- const profile = timelineLensProfile(offset);
- element.style.setProperty('--ldp-lens-offset', String(offset));
- element.style.setProperty('--ldp-lens-scale', profile.scale.toFixed(4));
- element.style.setProperty('--ldp-lens-opacity', profile.opacity.toFixed(4));
- element.style.setProperty('--ldp-lens-shift-x', `${profile.shiftX.toFixed(2)}px`);
- element.classList.toggle('ldp-timeline-lens-selected', floor === selectedFloor);
- element.classList.toggle('ldp-timeline-lens-focus', Math.abs(offset) < 0.001);
- });
- lensTargetPostNumber = selectedFloor || lensTargetPostNumber;
- };
-
- const renderPosition = (postNumber, createdAt = '') => {
- const total = totalPostCount();
- const current = Math.max(1, Math.min(total, +postNumber || 1));
- const displayCurrent = currentDisplayOverride || current;
- const progress = progressForPost(current);
- const currentChanged = renderedCurrent !== current;
- const displayCurrentChanged = renderedDisplayCurrent !== displayCurrent;
- const totalChanged = renderedTotal !== total;
- if (renderedProgress !== progress) {
- renderedProgress = progress;
- timeline.style.setProperty('--ldp-timeline-progress', String(progress));
- }
- if (displayCurrentChanged) currentEl.textContent = String(displayCurrent);
- if (totalChanged) totalEl.textContent = String(total);
- if (totalChanged) {
- syncFloorJumpInputLimit();
- if (!floorJumpForm.hidden) validateFloorJumpInput();
- }
- if (displayCurrentChanged || totalChanged) {
- setLabel(floorJumpTrigger, `跳到指定楼层,当前第 ${displayCurrent} 楼,共 ${total} 楼`);
- }
- const dateText = timelineMonthLabel(createdAt);
- if (renderedDateText !== dateText) {
- renderedDateText = dateText;
- dateEl.textContent = dateText;
- dateEl.disabled = !dateText;
- }
- const latestReplyAt = ctx.latestReplyAt || (ctx.topicData?.last_posted_at) || '';
- const relativeText = latestReplyAt ? discourseRelativeTime(latestReplyAt) : '';
- const relativeDisabled = !latestReplyAt;
- if (renderedRelativeText !== relativeText || renderedRelativeDisabled !== relativeDisabled) {
- renderedRelativeText = relativeText;
- renderedRelativeDisabled = relativeDisabled;
- relativeEl.textContent = relativeText;
- relativeEl.disabled = relativeDisabled;
- }
- if (!renderedCurrent && !renderedTotal) track.setAttribute('aria-valuemin', '1');
- if (totalChanged) track.setAttribute('aria-valuemax', String(total));
- if (currentChanged) track.setAttribute('aria-valuenow', String(current));
- if (currentChanged || totalChanged) {
- track.setAttribute('aria-valuetext', `第 ${current} 楼,共 ${total} 楼`);
- }
- if (ctx.queueViewportTracker) ctx.queueViewportTracker.sync(current);
- if (previewVisible) {
- const previewing = previewTarget > 0 && previewTarget !== current;
- if (track.classList.contains('ldp-timeline-previewing') !== previewing) {
- track.classList.toggle('ldp-timeline-previewing', previewing);
- }
- }
- const timelineHidden = total <= 1;
- if (renderedTimelineHidden !== timelineHidden) {
- renderedTimelineHidden = timelineHidden;
- timeline.hidden = timelineHidden;
- }
- renderedCurrent = current;
- renderedDisplayCurrent = displayCurrent;
- renderedTotal = total;
- };
-
- const renderStablePosition = (postNumber, createdAt = '') => {
- stablePostNumber = +postNumber || stablePostNumber;
- stableCreatedAt = createdAt || stableCreatedAt;
- renderPosition(stablePostNumber, stableCreatedAt);
- };
-
- const renderBoundaryPosition = (postNumber) => {
- const node = ctx.streamNodeMap.get(postNumber);
- const createdAt = (node && node.dataset.createdAt) ||
- (postNumber === 1 ? ctx.topicData?.created_at : (ctx.latestReplyAt || ctx.topicData?.last_posted_at)) || '';
- renderStablePosition(postNumber, createdAt);
- };
-
- const updateNow = () => {
- frame = 0;
- currentDisplayOverride = 0;
- if (!ctx.streamItems.length) {
- timeline.hidden = true;
- return;
- }
- const atScrollStart = ctx.scrollRoot.scrollTop <= 10;
- const lastLoadedPostNumber = +(ctx.streamItems[ctx.streamItems.length - 1]?.postNumber || 0);
- if (atScrollStart && ctx.streamItemMap.has(1)) {
- renderBoundaryPosition(1);
- return;
- }
- const layoutItems = streamLayoutItems(ctx);
- const streamHeight = ctx.streamPrefix[layoutItems.length] || 0;
- const atScrollEnd = ctx.scrollRoot.scrollTop + ctx.scrollRoot.clientHeight >=
- ctx.scrollRoot.scrollHeight - 16;
- const atStreamEnd = +(ctx.streamViewportLocalTop || 0) +
- +(ctx.streamViewportHeight || 0) >= streamHeight - 16;
- const hasLoadedEnd = lastLoadedPostNumber >= Math.max(1, ctx.totalPosts || 1);
- if (atScrollEnd && hasLoadedEnd) currentDisplayOverride = totalPostCount();
- const atLoadedEnd = atStreamEnd && hasLoadedEnd;
- if (atLoadedEnd) {
- const lastTimelineItem = ctx.onlyOp ? layoutItems[layoutItems.length - 1] : null;
- renderBoundaryPosition(lastTimelineItem ? lastTimelineItem.postNumber : totalPostCount());
- return;
- }
- const viewportItem = layoutItems[Math.max(
- 0,
- Math.min(layoutItems.length - 1, +(ctx.streamViewportTopIndex || 0)),
- )];
- const indexedOwnerNode = viewportItem?.node?.isConnected ? viewportItem.node : null;
- const anchoredOwnerNode = ctx.streamViewportAnchor?.owner;
- const visibleAnchor = indexedOwnerNode || anchoredOwnerNode?.isConnected
- ? null
- : findTopVisibleStreamPost(ctx);
- const visibleOwnerNode = indexedOwnerNode ||
- (anchoredOwnerNode?.isConnected ? anchoredOwnerNode : visibleAnchor?.ownerNode);
- const visiblePostNumber = streamPostNumber(visibleOwnerNode);
- const visibleItem = visiblePostNumber && ctx.streamItemMap.get(visiblePostNumber);
- if (visiblePostNumber && (!ctx.onlyOp || streamItemMatchesFilter(ctx, visibleItem))) {
- renderStablePosition(visiblePostNumber, visibleOwnerNode.dataset.createdAt || '');
- return;
- }
- if (stablePostNumber) renderPosition(stablePostNumber, stableCreatedAt);
- };
- const update = () => {
- if (frame || dragging) return;
- frame = requestAnimationFrame(updateNow);
- };
- const sync = () => {
- if (dragging) return;
- if (frame) cancelAnimationFrame(frame);
- frame = 0;
- updateNow();
- };
- const releaseJump = () => {
- jumpSequence++;
- cancelReaderNavigation(ctx);
- update();
- };
- const refreshRelativeTimes = () => refreshPostRelativeTimes(ctx.commentsEl);
- let relativeTimer = 0;
- const setActive = (active) => {
- if (relativeTimer) clearInterval(relativeTimer);
- relativeTimer = 0;
- if (!active) {
- if (frame) cancelAnimationFrame(frame);
- frame = 0;
- return;
- }
- relativeTimer = setInterval(() => {
- update();
- refreshRelativeTimes();
- }, 30000);
- };
- setActive(document.visibilityState === 'visible');
-
- const timelinePointerPosition = (clientY) => {
- const { rect, usableHeight } = timelineTrackMetrics();
- const top = Math.max(trackTopInset, Math.min(rect.height - trackBottomInset, clientY - rect.top));
- return { top, ratio: Math.max(0, Math.min(1, (top - trackTopInset) / usableHeight)) };
- };
- const targetFromPointer = (clientY) => targetFromRatio(timelinePointerPosition(clientY).ratio);
- const showPointerPreview = (clientY) => {
- previewVisible = true;
- previewClientY = clientY;
- if (previewFrame) return;
- previewFrame = requestAnimationFrame(() => {
- previewFrame = 0;
- if (!previewVisible) return;
- const pointerPosition = timelinePointerPosition(previewClientY);
- const target = targetFromRatio(pointerPosition.ratio);
- const progress = pointerPosition.ratio;
- const current = +(track.getAttribute('aria-valuenow') || 0);
- const targetChanged = previewTarget !== target;
- previewTarget = target;
- renderTimelineLensAtRatio(progress, pointerPosition.top);
- if (dragging) previewPointerTarget(previewClientY, target);
- if (targetChanged) previewEl.textContent = `#${target}`;
- const previewing = target !== current;
- if (track.classList.contains('ldp-timeline-previewing') !== previewing) {
- track.classList.toggle('ldp-timeline-previewing', previewing);
- }
- if (!track.classList.contains('ldp-timeline-hovering')) track.classList.add('ldp-timeline-hovering');
- });
- };
- const hidePointerPreview = () => {
- previewVisible = false;
- previewTarget = 0;
- if (previewFrame) cancelAnimationFrame(previewFrame);
- previewFrame = 0;
- timelineTrackRect = null;
- track.classList.remove('ldp-timeline-previewing', 'ldp-timeline-hovering');
- };
- const finishTimelineJumpAnimation = () => {
- if (jumpAnimationFrame) cancelAnimationFrame(jumpAnimationFrame);
- if (jumpAnimationTimer) clearTimeout(jumpAnimationTimer);
- jumpAnimationFrame = 0;
- jumpAnimationTimer = 0;
- track.classList.remove('ldp-timeline-jumping');
- if (previewVisible) showPointerPreview(previewClientY);
- };
- const beginTimelineJumpAnimation = (targetPostNumber) => {
- const reducedMotion = prefersReducedMotion();
- if (reducedMotion || !cursorEl) return;
- if (jumpAnimationFrame) cancelAnimationFrame(jumpAnimationFrame);
- if (jumpAnimationTimer) clearTimeout(jumpAnimationTimer);
- jumpAnimationFrame = 0;
- jumpAnimationTimer = 0;
- const currentPostNumber = stablePostNumber || +(track.getAttribute('aria-valuenow') || 1);
- const alreadyFocusedOnTarget = previewVisible && lensTargetPostNumber === targetPostNumber;
- const currentProgress = progressForPost(currentPostNumber);
- const targetProgress = progressForPost(targetPostNumber);
- if (!alreadyFocusedOnTarget) {
- renderTimelineLensAtRatio(currentProgress);
- }
- track.classList.remove('ldp-timeline-jumping');
- void cursorEl.offsetWidth;
- track.classList.add('ldp-timeline-jumping');
- const shouldTravel = !alreadyFocusedOnTarget && Math.abs(targetProgress - currentProgress) > 0.0001;
- if (!shouldTravel) {
- jumpAnimationFrame = requestAnimationFrame(() => {
- jumpAnimationFrame = 0;
- renderTimelineLensAtRatio(targetProgress);
- jumpAnimationTimer = setTimeout(finishTimelineJumpAnimation, 460);
- });
- return;
- }
- const duration = 360;
- let startedAt = 0;
- const animateJump = (timestamp) => {
- if (!startedAt) startedAt = timestamp;
- const elapsedRatio = Math.min(1, (timestamp - startedAt) / duration);
- const easedRatio = 1 - Math.pow(1 - elapsedRatio, 3);
- const progress = currentProgress + (targetProgress - currentProgress) * easedRatio;
- renderTimelineLensAtRatio(progress);
- if (elapsedRatio < 1) {
- jumpAnimationFrame = requestAnimationFrame(animateJump);
- return;
- }
- jumpAnimationFrame = 0;
- jumpAnimationTimer = setTimeout(finishTimelineJumpAnimation, 300);
- };
- jumpAnimationFrame = requestAnimationFrame(animateJump);
- };
- const previewPointerTarget = (clientY, targetPostNumber = targetFromPointer(clientY)) => {
- if (dragTarget === targetPostNumber) return;
- dragTarget = targetPostNumber;
- const node = ctx.streamNodeMap.get(dragTarget);
- renderPosition(dragTarget, node ? node.dataset.createdAt : '');
- };
- const settleVisibleJumpTarget = async (targetPostNumber, visualTarget, scrollOptions, acceptsWork) => {
- const quietTime = Number(scrollOptions.settleTime) || 0;
- const maxAttempts = quietTime ? 90 : 8;
- let stableAt = 0;
- let stableFrames = 0;
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
- await waitForReaderJumpLayout();
- if (!acceptsWork()) return null;
- const settledPost = getStreamPostNode(ctx, targetPostNumber, { mount: true });
- const settledTarget = visualTarget?.isConnected ? visualTarget : settledPost;
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- const targetRect = settledTarget?.getBoundingClientRect();
- const framed = targetRect && (targetRect.height <= rootRect.height
- ? targetRect.top >= rootRect.top - 1 && targetRect.bottom <= rootRect.bottom + 1
- : targetRect.top >= rootRect.top - 1 && targetRect.top < rootRect.bottom - 1);
- if (framed) {
- const now = performance.now();
- if (!stableAt) stableAt = now;
- if (quietTime ? now - stableAt >= quietTime : ++stableFrames >= 2)
- return { settledPost, settledTarget };
- continue;
- }
- stableAt = 0;
- stableFrames = 0;
- if (!scrollToPost(targetPostNumber, ctx, scrollOptions)) return null;
- }
- return null;
- };
- const jumpTo = async (targetPostNumber, options = {}) => {
- targetPostNumber = +targetPostNumber || 0;
- if (!targetPostNumber) return false;
- const returnViewport = PREFS.aggregateDescendantReplies
- ? embeddedReaderViewportState(ctx)
- : null;
- beginTimelineJumpAnimation(targetPostNumber);
- const currentJumpSequence = ++jumpSequence;
- const navigationSequence = beginReaderNavigation(ctx);
- const jumpStillCurrent = () => currentJumpSequence === jumpSequence &&
- readerNavigationIsCurrent(ctx, navigationSequence) &&
- (!isFunction(options.acceptsWork) || options.acceptsWork());
- if (!jumpStillCurrent()) return false;
- if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition();
- const suppliedTargetPost = +options.targetPost?.post_number === targetPostNumber
- ? options.targetPost
- : null;
- const suppliedTargetIsNested = +(suppliedTargetPost?.reply_to_post_number || 0) > 0;
- const loaded = suppliedTargetIsNested || !ctx.loadPost || await ctx.loadPost(targetPostNumber, {
- ensureAround: targetPostNumber > 1,
- scope: targetPostNumber > 1 ? 'around' : 'single',
- });
- if (!jumpStillCurrent()) return false;
- if (!loaded) {
- update();
- return false;
- }
- if (!jumpStillCurrent()) return false;
- const hiddenTarget = ctx.streamNodeMap.get(targetPostNumber);
- const targetPost = hiddenTarget?._ldpPostData || suppliedTargetPost;
- const nestedTarget = +(targetPost?.reply_to_post_number || 0) > 0;
- if (nestedTarget) {
- const opened = PREFS.aggregateDescendantReplies && options.forceNested !== true
- ? await openAggregateReplyTarget(targetPostNumber, ctx, { returnViewport })
- : await focusLiveNestedReply(targetPost, ctx, {
- navigationSequence,
- acceptsWork: options.acceptsWork,
- });
- if (!jumpStillCurrent()) return false;
- sync();
- return opened;
- }
- if (hiddenTarget?.hidden) {
- hiddenTarget.hidden = false;
- hiddenTarget.dataset.ldpJumpExpanded = '1';
- const item = ctx.streamItemMap.get(targetPostNumber);
- if (item) item.height = Math.max(STREAM_ESTIMATED_HEIGHT, postContentPlaceholderHeight(hiddenTarget._ldpPostData) + 72);
- setNestedReplyCollapsed(hiddenTarget, false);
- markStreamPrefixDirty(ctx, Math.max(0, +(item?.index || 0)));
- flushStreamWindow(ctx);
- }
- let visualTarget = null;
- const scrollOptions = {
- ...PINNED_STREAM_SCROLL_OPTIONS,
- settleTime: 1000,
- resolveVisualTarget: isFunction(options.prepareTarget)
- ? () => {
- visualTarget = options.prepareTarget(targetPostNumber);
- return visualTarget;
- }
- : null,
- alignCenter: options.alignCenter === true,
- alignNearest: options.alignNearest === true,
- alignTop: options.alignBottom !== true,
- alignBottom: options.alignBottom === true,
- highlight: false,
- };
- if (!scrollToPost(targetPostNumber, ctx, scrollOptions)) {
- update();
- return false;
- }
- const settled = await settleVisibleJumpTarget(
- targetPostNumber,
- visualTarget,
- scrollOptions,
- jumpStillCurrent
- );
- if (!settled) {
- clearStreamProgrammaticTarget(ctx);
- update();
- return false;
- }
- if (options.highlight !== false) keepJumpHighlightUntilSettled(settled.settledPost);
- sync();
- return true;
- };
- const jumpToBoundary = async (targetPostNumber, options = {}) => {
- const sequenceBeforeAttempt = jumpSequence;
- let jumped = await jumpTo(targetPostNumber, options);
- if (jumped) return true;
- const attemptWasNotCancelled = jumpSequence === sequenceBeforeAttempt + 1 &&
- (!isFunction(options.acceptsWork) || options.acceptsWork());
- if (!attemptWasNotCancelled) return false;
- ctx.streamWindowResetPending = true;
- syncStreamWindow(ctx);
- await waitForReaderJumpLayout();
- if (jumpSequence !== sequenceBeforeAttempt + 1) return false;
- jumped = await jumpTo(targetPostNumber, options);
- return jumped;
- };
- const completeStartJump = async (options = {}) => {
- if (!await jumpToBoundary(1, options)) return false;
- const completedJumpSequence = jumpSequence;
- ctx.streamWindowResetPending = true;
- const scrollToStart = () => {
- const previousScrollTop = ctx.scrollRoot.scrollTop;
- ctx.scrollRoot.scrollTop = 0;
- markStreamInternalScroll(ctx, previousScrollTop);
- };
- scrollToStart();
- syncStreamWindow(ctx);
- await waitForReaderJumpLayout();
- if (completedJumpSequence !== jumpSequence) return false;
- scrollToStart();
- renderBoundaryPosition(1);
- return true;
- };
- let startJumpPromise = null;
- const jumpToStart = (options = {}) => {
- if (startJumpPromise) return startJumpPromise;
- startJumpPromise = completeStartJump(options)
- .finally(() => { startJumpPromise = null; });
- return startJumpPromise;
- };
- const latestVisibleTimelineItem = (items) => (items || []).reduce((latest, item) => {
- if (!streamItemParticipatesInZebra(item)) return latest;
- return !latest || item.postNumber > latest.postNumber ? item : latest;
- }, null);
- let endJumpPromise = null;
- const jumpToEnd = () => {
- if (endJumpPromise) return endJumpPromise;
- endJumpPromise = (async () => {
- const items = onlyOpTimelineItems();
- let targetItem = latestVisibleTimelineItem(items);
- if (!items && ctx.loader && isFunction(ctx.loader.loadLastPost)) {
- try {
- let boundaryPosts = await ctx.loader.loadLastPost({ priority: POST_REQUEST_PRIORITY.target });
- let previousBoundary = Infinity;
- while (boundaryPosts.length) {
- attachPostBatch(
- boundaryPosts.filter((post) => !ctx.streamNodeMap.has(+post.post_number)),
- ctx
- );
- targetItem = latestVisibleTimelineItem(boundaryPosts.map((post) =>
- ctx.streamItemMap.get(+post.post_number)).filter(Boolean));
- if (targetItem) break;
- const nextBoundary = boundaryPosts.reduce((earliest, post) =>
- Math.min(earliest, +(post?.post_number || Infinity)), Infinity);
- if (!isFunction(ctx.loader.loadBeforePost) || nextBoundary <= 1 ||
- nextBoundary >= previousBoundary) break;
- previousBoundary = nextBoundary;
- boundaryPosts = await ctx.loader.loadBeforePost(nextBoundary, {
- priority: POST_REQUEST_PRIORITY.target,
- });
- }
- } catch {}
- }
- if (!targetItem) targetItem = latestVisibleTimelineItem(items || ctx.streamItems);
- const target = +(targetItem?.postNumber || 1);
- return jumpToBoundary(target, { alignBottom: true });
- })().finally(() => { endJumpPromise = null; });
- return endJumpPromise;
- };
- const onRelativeClick = async () => {
- if (!await jumpToEnd()) showSelectionToast('末尾可见楼层加载失败,请重试');
- };
- const closeFloorJump = (restoreFocus = false) => {
- if (floorJumpForm.hidden) return;
- floorJumpForm.hidden = true;
- setExpanded(floorJumpTrigger, false);
- if (restoreFocus) floorJumpTrigger.focus();
- };
- const openFloorJump = () => {
- hidePointerPreview();
- const current = renderedDisplayCurrent || +(track.getAttribute('aria-valuenow') || 1);
- floorJumpInput.value = String(current);
- floorJumpForm.hidden = false;
- setExpanded(floorJumpTrigger, true);
- validateFloorJumpInput();
- floorJumpInput.focus();
- floorJumpInput.select();
- };
- const onFloorJumpSubmit = async (event) => {
- event.preventDefault();
- const target = validateFloorJumpInput();
- if (!target) return;
- closeFloorJump();
- const current = +(track.getAttribute('aria-valuenow') || 1);
- if (target === current) return;
- try {
- if (!await jumpTo(target)) showSelectionToast(`暂时无法定位到楼层 #${target},可重试`);
- } catch (error) {
- console.warn('[LINUX DO reader] floor jump failed', error);
- showSelectionToast(`楼层 #${target} 加载失败,请重试`);
- }
- };
- const onTrackPointerLeave = () => {
- if (!dragging) hidePointerPreview();
- };
- const onTrackPointerDown = (e) => {
- e.preventDefault();
- currentDisplayOverride = 0;
- dragging = true;
- activePointerId = e.pointerId;
- refreshTimelineTrackRect();
- dragTarget = 0;
- track.setPointerCapture(e.pointerId);
- showPointerPreview(e.clientY);
- };
- const onTrackPointerUp = (e) => {
- if (!dragging) return;
- dragging = false;
- if (track.hasPointerCapture(e.pointerId)) track.releasePointerCapture(e.pointerId);
- activePointerId = 0;
- dragTarget = targetFromPointer(e.clientY);
- if (!track.matches(':hover')) hidePointerPreview();
- jumpTo(dragTarget);
- };
- const onTrackPointerCancel = () => {
- dragging = false;
- activePointerId = 0;
- hidePointerPreview();
- update();
- };
- const onTrackKeyDown = (e) => {
- const current = +(track.getAttribute('aria-valuenow') || 1);
- let target;
- if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') target = targetFromKey(current, -1);
- else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') target = targetFromKey(current, 1);
- else if (e.key === 'PageUp') target = targetFromKey(current, -streamPerformance(ctx).pageSize);
- else if (e.key === 'PageDown') target = targetFromKey(current, streamPerformance(ctx).pageSize);
- else if (e.key === 'Home') target = targetFromKey(current, -Infinity);
- else if (e.key === 'End') target = targetFromKey(current, Infinity);
- else return;
- e.preventDefault();
- jumpTo(target);
- };
- timelineScope.listen(dateEl, 'click',
- () => timeline.querySelector('.ldp-topic-timeline-top')?.click());
- timelineScope.listen(relativeEl, 'click', onRelativeClick);
- timelineScope.listen(floorJumpTrigger, 'click', () => {
- if (floorJumpForm.hidden) openFloorJump();
- else closeFloorJump(true);
- });
- timelineScope.listen(floorJumpInput, 'input', validateFloorJumpInput);
- timelineScope.listen(floorJumpForm, 'submit', (event) => { void onFloorJumpSubmit(event); });
- timelineScope.listen(floorJumpForm, 'keydown', (event) => {
- if (event.key === 'Enter' && floorJumpSubmit.disabled) {
- consumeEvent(event);
- } else if (event.key === 'Escape') {
- consumeEvent(event);
- closeFloorJump(true);
- }
- });
- timelineScope.listen(timeline.getRootNode(), 'pointerdown', (event) => {
- if (floorJumpForm.hidden) return;
- const path = event.composedPath();
- if (!path.includes(floorJumpForm) && !path.includes(floorJumpTrigger)) closeFloorJump();
- }, true);
- timelineScope.listen(track, 'pointerenter',
- (event) => {
- refreshTimelineTrackRect();
- showPointerPreview(event.clientY);
- });
- timelineScope.listen(track, 'pointerleave', onTrackPointerLeave);
- timelineScope.listen(track, 'pointerdown', onTrackPointerDown);
- timelineScope.listen(track, 'pointermove', (event) => {
- if (previewVisible) showPointerPreview(event.clientY);
- });
- timelineScope.listen(track, 'pointerup', onTrackPointerUp);
- timelineScope.listen(track, 'pointercancel', onTrackPointerCancel);
- timelineScope.listen(track, 'keydown', onTrackKeyDown);
-
- return {
- update,
- sync,
- jumpTo,
- jumpToStart,
- releaseJump,
- refreshRelativeTimes,
- setActive,
- destroy() {
- if (frame) cancelAnimationFrame(frame);
- if (previewFrame) cancelAnimationFrame(previewFrame);
- finishTimelineJumpAnimation();
- setActive(false);
- closeFloorJump();
- hidePointerPreview();
- if (cursorEl) cursorEl.replaceChildren();
- lensFloorElements.clear();
- spareLensFloorElements.length = 0;
- lensTargetPostNumber = 0;
- if (activePointerId && track.hasPointerCapture && track.hasPointerCapture(activePointerId)) {
- try { track.releasePointerCapture(activePointerId); } catch {}
- }
- dragging = false;
- activePointerId = 0;
- timelineScope.destroy();
- },
- };
- }
-
- async function openModal(topicId, options = {}) {
- const openRequestId = ++READER_OPEN_REQUEST_SEQUENCE;
- const previousQueueTopicId = Number(CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId) || 0;
- const performance = Object.freeze(currentPerformanceConfig());
- const autoRetryAttempt = Math.max(0, Number(options.autoRetryAttempt) || 0);
- let targetPostNumber = +options.targetPostNumber || 0;
- let aggregateTargetPostNumber = 0;
- let unavailableTargetPostNumber = 0;
- const refreshTargetBoosts = options.refreshTargetBoosts === true;
- const restoreState = normalizeReaderAnchorState(options.restoreState);
- const requestedQuoteHighlight = normalizeReaderQuoteHighlightState({
- ...options.quoteHighlight,
- postNumber: targetPostNumber,
- });
- const initialQuoteHighlight = requestedQuoteHighlight || restoreState?.quoteHighlight || null;
- const initialQuoteSourceTarget = normalizeReaderQuoteSource(options.quoteSourceTarget);
- if (!requestedQuoteHighlight && initialQuoteHighlight)
- targetPostNumber = initialQuoteHighlight.postNumber;
- let restoreViewport = restoreState?.viewport ||
- (options.restoreViewport && typeof options.restoreViewport === 'object'
- ? options.restoreViewport
- : null);
- const restoreOnlyOp = options.restoreOnlyOp === true;
- const settleTargetJump = options.settleTargetJump !== false;
- const directTopicRoute = !!extractTopicRouteFromUrl(location.href);
- const requestedReaderMode = directTopicRoute ? currentTopicReaderMode() : currentListReaderMode();
- const hostTopicPointerAnchor = options.hostTopicPointerAnchor || null;
- const cachedTopicPreview = getCachedTopicData(topicId);
- const reactionsAvailable = hasDiscourseCapability('reactions', { topic: cachedTopicPreview });
- const boostsAvailable = hasDiscourseCapability('boosts');
- const availableNotificationGroups = Object.entries(NOTIFICATION_GROUPS).filter(([key]) =>
- (key !== 'reactions' || reactionsAvailable) && (key !== 'boosts' || boostsAvailable)
- );
- const bookmarkTabOrder = PREFS.bookmarkTabOrder.filter((type) =>
- type !== 'Reaction' || reactionsAvailable
- );
- const bookmarkCollectionLabel = reactionsAvailable ? '收藏与回应' : '收藏';
- let sourceTopicNavMetadata = mergeTopicNavMetadata(
- options.topicNavMetadata || readTopicNavMetadata(null, topicId),
- cachedTopicNavMetadata(cachedTopicPreview),
- );
- let queueEntry = readerQueueEntry(topicId);
- if (queueEntry) {
- queueEntry = ensureReaderQueueEntry(topicId, {
- ...(options.readerQueueMetadata || {}),
- navMetadata: sourceTopicNavMetadata,
- }, { prefetch: false, silent: true });
- }
- const inheritedHistoryStates = normalizeReaderHistoryStates(
- CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderHistoryNavigation?.states
- );
- const historyNavigation = createReaderHistoryNavigation(topicId, options.historyNavigation);
- historyNavigation.states = { ...inheritedHistoryStates, ...historyNavigation.states };
- let reusableOverlay = null;
- if (CURRENT_OVERLAY) {
- const previousOverlay = CURRENT_OVERLAY;
- if (isFunction(previousOverlay._ldpCancelAutoRetryWait)) {
- previousOverlay._ldpCancelAutoRetryWait();
- }
- const previousTopicId = Number(previousOverlay.dataset.topicId);
- const previousState = captureReaderAnchorState(previousOverlay);
- if (previousTopicId > 0 && previousState) {
- historyNavigation.states[previousTopicId] = previousState;
- saveReaderQueueAnchor(previousTopicId, previousState);
- }
- // 阅读器壳层与展示形态都不属于单篇帖子;同一路由上下文切换主题时统一复用。
- const canReuseReaderShell =
- previousOverlay.dataset.readerListModeAllowed === String(!directTopicRoute) &&
- previousOverlay.dataset.readerShellDestroyed !== '1' &&
- previousOverlay._ldpReaderSession && previousOverlay._ldpReaderSession.state !== 'failed' &&
- isFunction(previousOverlay._ldpPrepareTopicSwitch);
- if (canReuseReaderShell) {
- await previousOverlay._ldpPrepareTopicSwitch();
- if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE || CURRENT_OVERLAY !== previousOverlay || !previousOverlay.isConnected) return;
- reusableOverlay = previousOverlay;
- } else if (isFunction(previousOverlay._ldpDispose)) await previousOverlay._ldpDispose();
- else {
- if (isFunction(previousOverlay._ldpStopLatestReplyUpdates)) {
- previousOverlay._ldpStopLatestReplyUpdates();
- }
- previousOverlay.remove();
- if (CURRENT_OVERLAY === previousOverlay) CURRENT_OVERLAY = null;
- setPageScrollLocked(false);
- }
- }
- if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE) return;
- if (PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active') &&
- !await closeChromeSidePanelForReader()) return;
- if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE) return;
- if (queueEntry) activateReaderQueueEntry(queueEntry);
- else {
- READER_QUEUE_ACTIVE_TOPIC_ID = '';
- syncReaderQueueSurfaces();
- }
- if (directTopicRoute) PAGE_ROOT.classList.add('ldp-route-takeover');
- const readerSessionId = ++READER_SESSION_SEQUENCE;
- const reusingShell = !!reusableOverlay;
- const overlay = reusableOverlay || makeElement('div');
- const readerDetachedSurfaceRoot = () => overlay._ldpReaderShell?.surfaceManager?.parking;
- const readerElement = (selector) =>
- overlay.querySelector(selector) || readerDetachedSurfaceRoot()?.querySelector(selector) || null;
- const readerElements = (selector) => [
- ...overlay.querySelectorAll(selector),
- ...(readerDetachedSurfaceRoot()?.querySelectorAll(selector) || []),
- ];
- const readerElementGroup = (...selectors) => selectors.map(readerElement);
- const readerElementsGroup = (...selectors) => selectors.map(readerElements);
- let readerOpenPhase = 'create-overlay';
- const setReaderOpenPhase = (phase) => {
- readerOpenPhase = phase;
- };
- const readerSession = {
- id: readerSessionId,
- state: 'opening',
- isCurrent(...allowedStates) {
- if (ACTIVE_READER_SESSION_ID !== readerSessionId || CURRENT_OVERLAY !== overlay || !overlay.isConnected)
- return false;
- return !allowedStates.length || allowedStates.includes(this.state);
- },
- };
- ACTIVE_READER_SESSION_ID = readerSessionId;
- if (!reusingShell) {
- overlay.className = 'ldp-overlay';
- overlay.dataset.readerWorkspaceMode = 'floating';
- overlay.dataset.readerListModeAllowed = String(!directTopicRoute);
- overlay.innerHTML = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'].map((direction) =>
- `
`).join('')}
-
-
-
-
- 编辑帖子信息
-
-
-
-
-
-
-
-
-
-
-
-
-
-
${createReaderLoadingHTML()}
-
-
-
${icon('loader', 'ldp-tip-icon')}正在加载楼层…
-
已经到底了~
-
-
-
-
-
-
-
-
-
-
-
-
-
`;
- mountSettingsPanelIntros(overlay);
- appendReaderPortalNode(overlay);
- syncSiteLogoImages();
- ensureReaderIconTooltip();
- }
- overlay.dataset.topicId = String(topicId);
- CURRENT_OVERLAY = overlay;
- guardNativeReaderTimings();
- bindReaderQueueSurface(overlay);
- syncReaderFontRenderingState();
- overlay._ldpReaderHistoryNavigation = historyNavigation;
- const disposeHistoryNavigation = bindReaderHistoryNavigation(overlay, topicId, historyNavigation);
- overlay._ldpReaderSession = readerSession;
- PAGE_ROOT.classList.add('ldp-reader-open');
- overlay.dataset.targetPostNumber = targetPostNumber ? String(targetPostNumber) : '';
- overlay.classList.toggle('ldp-initial-target-layout', targetPostNumber > 1);
-
- const modal = readerElement('.ldp-modal'), body = readerElement('.ldp-body');
- if (reusingShell) resetReaderTopicShell(overlay);
- const readerShell = reusingShell ? overlay._ldpReaderShell : {};
- const shellScope = readerShell.lifecycleScope || createLifecycleScope();
- readerShell.lifecycleScope = shellScope;
- const surfaceManager = readerShell.surfaceManager || createReaderSurfaceManager(overlay);
- readerShell.surfaceManager = surfaceManager;
- const onShell = (target, type, listener, options) => shellScope.listen(target, type, listener, options);
- const shouldRestoreHostTopicAnchor = !!hostTopicPointerAnchor && !directTopicRoute &&
- readerPresentation(requestedReaderMode).embedded;
- if (shouldRestoreHostTopicAnchor) PAGE_ROOT.classList.add('ldp-reader-host-anchor-restoring');
- const readerWorkspace = readerShell.readerWorkspace || createReaderWorkspaceController(overlay, modal, directTopicRoute);
- readerWorkspace.sync();
- readerShell.syncReaderTitleActionAlignment = () => {
- const header = readerElement('.ldp-header');
- const titleJump = header?.querySelector('.ldp-title-jump');
- const titleActions = header?.querySelector(':scope > .ldp-title-actions');
- if (!titleJump || !titleActions) return;
- titleActions.style.setProperty('--ldp-title-actions-align-y', '0px');
- const visibleControl = Array.from(titleActions.children)
- .find((control) => !control.hidden && getComputedStyle(control).display !== 'none');
- const actionIcon = visibleControl?.querySelector('.ldp-icon');
- if (!actionIcon) return;
- const shift = titleJump.getBoundingClientRect().top - actionIcon.getBoundingClientRect().top;
- titleActions.style.setProperty('--ldp-title-actions-align-y',
- `${Math.round(shift * 2) / 2}px`);
- };
- readerShell.syncFullPageHeaderAlignment = () => {
- const header = readerElement('.ldp-header');
- if (!header) return;
- const titleActions = header.querySelector(':scope > .ldp-title-actions');
- const headButtons = header.querySelector(':scope > .ldp-head-btns');
- const content = readerElement('.ldp-comments');
- const clearAlignment = () => {
- header.style.removeProperty('--ldp-header-logo-inset');
- header.style.removeProperty('padding-left');
- header.style.removeProperty('padding-right');
- titleActions?.style.removeProperty('right');
- headButtons?.classList.remove('is-content-aligned');
- };
- if (!readerWorkspace.isFullPage() || !content) {
- clearAlignment();
- readerShell.syncReaderTitleActionAlignment();
- return;
- }
- const modalRect = modal.getBoundingClientRect();
- const contentRect = content.getBoundingClientRect();
- if (!(modalRect.width > 0) || !(contentRect.width > 0)) return;
- const left = Math.max(0, Math.round(contentRect.left - modalRect.left));
- const right = Math.max(0, Math.round(modalRect.right - contentRect.right));
- header.style.setProperty('--ldp-header-logo-inset', `${left}px`);
- header.style.paddingLeft = `${left}px`;
- header.style.paddingRight = `${right}px`;
- if (titleActions) titleActions.style.right = `${right}px`;
- headButtons?.classList.add('is-content-aligned');
- readerShell.syncReaderTitleActionAlignment();
- };
- if (shouldRestoreHostTopicAnchor) {
- if (readerWorkspace.isEmbedded()) restoreHostTopicPointerAnchor(hostTopicPointerAnchor);
- shellScope.raf(() => PAGE_ROOT.classList.remove('ldp-reader-host-anchor-restoring'));
- }
- setPageScrollLocked(readerWorkspace.isFullPage() || window.innerWidth <= READER_WINDOW_COMPACT_WIDTH);
- const readerCapsule = readerElement('.ldp-reader-window-capsule');
- const readerPlacementControl = readerElement('.ldp-reader-placement-control');
- const readerPlacementStrip = readerElement('.ldp-reader-placement-strip');
- const readerPlacementOptions = readerElements('[data-reader-placement]');
- const commentsEl = readerElement('.ldp-comments');
- const commentsHeader = readerElement('.ldp-comments-header');
- const countEl = readerElement('.ldp-comments-count'), emptyEl = readerElement('.ldp-comments-empty');
- const maskEl = readerElement('.ldp-loadmask');
- const loadingStage = maskEl?.querySelector('.ldp-loading-stage');
- const loadingStatus = maskEl?.querySelector('.ldp-loading-status span');
- const loadingTarget = maskEl?.querySelector('.ldp-loading-target');
- const loadingDetail = maskEl?.querySelector('.ldp-loading-detail');
- const loadingTip = readerElement('.ldp-loading-tip');
- const loadingTipText = loadingTip?.querySelector('span');
- const syncLoadingState = (state, options = {}) => {
- const isTarget = options.target === true && targetPostNumber > 1;
- const cachedCount = Math.max(0, Number(options.cachedCount) || 0);
- const missingCount = Math.max(0, Number(options.missingCount) || 0);
- const copy = state === 'cache'
- ? {
- status: isTarget ? '正在读取目标楼层缓存' : '正在读取帖子缓存',
- detail: cachedCount ? `已读取 ${cachedCount} 条缓存,正在恢复楼层…` : '正在恢复已缓存楼层…',
- }
- : state === 'network'
- ? {
- status: isTarget ? '正在请求目标楼层' : cachedCount ? '正在补全帖子数据' : '正在请求帖子数据',
- detail: cachedCount
- ? `已读取 ${cachedCount} 条缓存,正在下载 ${missingCount} 条缺失楼层…`
- : missingCount ? `正在下载 ${missingCount} 条缺失楼层…` : '正在下载缺失楼层…',
- }
- : { status: '正在渲染帖子', detail: '正在生成页面…' };
- if (loadingStatus) loadingStatus.textContent = copy.status;
- if (loadingDetail) loadingDetail.textContent = copy.detail;
- if (loadingTipText) loadingTipText.textContent = copy.detail;
- if (loadingTarget) loadingTarget.textContent = isTarget ? `#${targetPostNumber}` : '';
- if (loadingStage) setLabel(loadingStage, `${copy.status},${copy.detail.replace('…', '')}`);
- };
- const cachedTargetAvailable = targetPostNumber <= 1 || !!(cachedTopicPreview?.post_stream &&
- Array.isArray(cachedTopicPreview.post_stream.posts) &&
- cachedTopicPreview.post_stream.posts.some((post) => +post.post_number === targetPostNumber));
- syncLoadingState(cachedTopicPreview && cachedTargetAvailable ? 'cache' : 'network', {
- target: targetPostNumber > 1,
- });
- const endTip = readerElement('.ldp-end-tip');
- const liveUpdateBtn = readerElement('.ldp-live-update');
- const liveUpdateJumpBtn = liveUpdateBtn.querySelector('.ldp-live-update-jump');
- const liveUpdateDismissBtn = liveUpdateBtn.querySelector('.ldp-live-update-dismiss');
- const timelineEl = readerElement('.ldp-topic-timeline');
- const layoutBtn = readerElement('.ldp-layout-toggle');
- const titleTopicRow = readerElement('.ldp-title-topic-row');
- const titleTopicScroller = readerElement('.ldp-title-topic-scroller');
- let titleTopicHintFrame = 0;
- const syncTitleTopicScrollHints = () => {
- titleTopicHintFrame = 0;
- const hasOverflow = titleTopicScroller.scrollWidth > titleTopicRow.clientWidth + 1;
- titleTopicRow.classList.toggle('has-overflow', hasOverflow);
- const maxScrollLeft = Math.max(0, titleTopicScroller.scrollWidth - titleTopicScroller.clientWidth);
- titleTopicRow.classList.toggle('can-scroll-left', hasOverflow && titleTopicScroller.scrollLeft > 1);
- titleTopicRow.classList.toggle('can-scroll-right',
- hasOverflow && titleTopicScroller.scrollLeft < maxScrollLeft - 1);
- };
- const queueTitleTopicScrollHints = () => {
- if (titleTopicHintFrame) return;
- titleTopicHintFrame = requestAnimationFrame(syncTitleTopicScrollHints);
- };
- const onlyOpBtn = readerElement('.ldp-only-op-toggle');
- const onlyOpProgress = readerElement('.ldp-only-op-progress');
- const onlyOpProgressValue = onlyOpProgress?.querySelector('.ldp-only-op-progress-value');
- const rateLimitNotice = readerElement('.ldp-rate-limit-notice');
- const rateLimitDetail = rateLimitNotice?.querySelector('.ldp-rate-limit-detail');
- const rateLimitChallenge = rateLimitNotice?.querySelector('.ldp-rate-limit-challenge');
- const notificationsBtn = readerElement('.ldp-notifications-toggle');
- const notificationsPopover = readerElement('.ldp-notifications-popover');
- const notificationModeTabs = readerElements('.ldp-notification-mode-tab');
- const notificationModePanels = readerElements('[data-notification-mode-panel]');
- const notificationTabs = readerElements('.ldp-notification-tab');
- const queryReaderPanelElements = (prefix, fields) => Object.fromEntries(fields.map((field) => [
- field, readerElement(`.ldp-${prefix}-${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`),
- ]));
- const notificationPanel = queryReaderPanelElements('notification', [
- 'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'toolbar', 'unreadStatus',
- 'markAll', 'newMessage',
- ]);
- const historyPanel = queryReaderPanelElements('history', [
- 'toggle', 'popover', 'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'defaultActions',
- 'bulkActions', 'sortToggle', 'multi', 'multiDone', 'selectScope', 'selectToggle', 'deleteSelected',
- 'deleteSelectedLabel', 'clear',
- ]);
- const bookmarksPanel = queryReaderPanelElements('bookmarks', [
- 'toggle', 'popover', 'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'defaultActions',
- 'bulkActions', 'multi', 'multiDone', 'selectScope', 'selectToggle', 'deleteSelected', 'deleteSelectedLabel',
- ]);
- const [
- bookmarksReactionFilters, bookmarksTabList, translateBtn, settingsBtn, settingsPopover,
- settingsPanel, settingsCloseBtn, settingHelpTooltip,
- ] = readerElementGroup(
- '.ldp-reaction-filters', '.ldp-bookmark-tabs', '.ldp-translate-toggle',
- '.ldp-settings-toggle', '.ldp-settings-popover',
- '.ldp-settings-panel', '.ldp-settings-close', '.ldp-setting-help-tooltip',
- );
- const [bookmarksTabs, themeButtons] = readerElementsGroup(
- '.ldp-bookmark-tab', '.ldp-settings-theme-button');
- let settingsLayer = overlay.querySelector(':scope > .ldp-settings-layer');
- if (!settingsLayer) {
- settingsLayer = makeElement('div');
- settingsLayer.className = 'ldp-settings-layer';
- overlay.append(settingsLayer);
- }
- const settingsSurfaceHost = () => readerWorkspace.isEmbedded() ? settingsLayer : modal;
- const settingsSurfaceBounds = () => {
- const host = settingsSurfaceHost();
- const rect = host.getBoundingClientRect();
- const left = rect.left + host.clientLeft;
- const top = rect.top + host.clientTop;
- const width = host.clientWidth;
- const height = host.clientHeight;
- return { left, top, width, height, right: left + width, bottom: top + height };
- };
- const settingsSurfaceIsCompact = () =>
- settingsSurfaceBounds().width <= READER_WINDOW_COMPACT_WIDTH;
- const syncReaderSurfaceDensity = () => {
- const readerShort = modal.clientHeight <= READER_SETTINGS_SHORT_HEIGHT;
- modal.classList.toggle('ldp-reader-surface-short', readerShort);
- if (settingsPopover.hidden) return;
- const settingsHost = settingsSurfaceHost();
- const shortSurface = settingsHost.clientHeight <= READER_SETTINGS_SHORT_HEIGHT;
- modal.classList.toggle(
- 'ldp-settings-surface-short',
- settingsHost === modal && shortSurface,
- );
- settingsLayer.classList.toggle(
- 'ldp-settings-surface-short',
- settingsHost === settingsLayer && shortSurface,
- );
- };
- surfaceManager.mount(notificationsPopover);
- surfaceManager.mount(historyPanel.popover);
- surfaceManager.mount(bookmarksPanel.popover);
- surfaceManager.mount(settingsPopover, settingsSurfaceHost());
- surfaceManager.mount(settingHelpTooltip, settingsSurfaceHost());
- syncReaderSurfaceDensity();
- syncReaderThemeSurfaces();
- const [
- userInfoContent, userInfoLastRefresh, userInfoTitleRefresh,
- settingsSearchShell, settingsSearchInput, settingsSearchClear, settingsSearchStatus, settingsSearchEmpty,
- settingsDraftBar, settingsDraftStatus, settingsSaveAllBtn,
- imageScaleRange, imageScaleValue, imageScaleReset,
- lightboxOriginalByDefaultInput, lightboxCommentsExpandedInput, lightboxDescriptionExpandedInput,
- fontFamilyMenu, fontFamilySearch, fontFamilyOptions, fontFamilyEmpty,
- fontFamilySourceStatus, fontFamilyManualButton,
- ] = readerElementGroup(
- '.ldp-user-info-content', '.ldp-user-info-last-refresh', '.ldp-user-info-title-refresh',
- '.ldp-settings-search-shell', '.ldp-settings-search-input', '.ldp-settings-search-clear',
- '.ldp-settings-search-status', '.ldp-settings-search-empty',
- '.ldp-settings-draft-bar', '.ldp-settings-draft-status', '.ldp-settings-save-all',
- '.ldp-image-scale-range', '.ldp-image-scale-value', '.ldp-image-scale-reset',
- '.ldp-lightbox-original-by-default', '.ldp-lightbox-comments-expanded',
- '.ldp-lightbox-description-expanded', '.ldp-font-family-menu', '.ldp-font-family-search input',
- '.ldp-font-family-options', '.ldp-font-family-empty', '.ldp-font-family-source-status',
- '.ldp-font-family-manual button',
- );
- const [
- settingsTabs, settingsSections, settingsNavGroups, settingsLogTabs, settingsLogPanels,
- fontFamilyTriggers, fontFamilyCustomInputs,
- ] = readerElementsGroup(
- '.ldp-settings-tab', '.ldp-settings-section', '.ldp-settings-nav-group',
- '.ldp-settings-log-tab', '.ldp-settings-log-panel',
- '.ldp-font-family-trigger', '.ldp-font-family-custom');
- settingsSections.splice(0, settingsSections.length, ...SETTINGS_PANEL_META.map(([id]) =>
- settingsSections.find((section) => section.dataset.settingsPanel === id)));
- settingsPanel.append(...settingsSections.slice(1), settingsDraftBar);
- surfaceManager.mount(fontFamilyMenu, settingsSurfaceHost());
- const [
- interfaceFontScaleRange, interfaceFontScaleValue, fontScaleRange, fontScaleValue,
- composerFontScaleRange, composerFontScaleValue, fontStatus, fontResetBtn,
- fontRenderingEnabledInput, fontRenderingOnHostInput,
- ] = readerElementGroup(
- '.ldp-interface-font-scale-range', '.ldp-interface-font-scale-value', '.ldp-font-scale-range',
- '.ldp-font-scale-value', '.ldp-composer-font-scale-range', '.ldp-composer-font-scale-value',
- '.ldp-font-status', '.ldp-font-reset', '.ldp-font-rendering-enabled',
- '.ldp-font-rendering-on-host',
- );
- const [
- fontWeightSelects, fontColorInputs, fontColorValues, hostEmbeddedSizeInputs, hostEmbeddedSizeValues,
- hostEmbeddedSizeResetButtons, fontFieldResetButtons, fontScopeTabs, fontScopePanels,
- ] = readerElementsGroup(
- '.ldp-font-weight-select', '.ldp-font-color', '.ldp-font-color-value', '[data-host-embed-size]',
- '[data-host-embed-size-value]', '[data-host-embed-size-reset]',
- '[data-host-font-reset],[data-font-field-reset]', '[data-font-scope-tab]', '[data-font-scope-panel]',
- );
- const [layoutRatioInputs, layoutRatioValues] = readerElementsGroup(
- '[data-layout-region]', '[data-layout-value]');
- const [
- layoutTotal, layoutResetBtn, readerWindowWidthInput, readerWindowHeightInput,
- readerWindowXInput, readerWindowYInput, readerWindowPinInput, readerWindowLockInput,
- readerWindowStatus, readerWindowResetBtn,
- ] = readerElementGroup(
- '[data-settings-panel="layout"] .ldp-layout-total',
- '[data-settings-panel="layout"] .ldp-layout-reset',
- '.ldp-reader-window-width', '.ldp-reader-window-height', '.ldp-reader-window-x',
- '.ldp-reader-window-y', '.ldp-reader-window-pin-input', '.ldp-reader-window-lock-input',
- '.ldp-reader-window-status', '.ldp-reader-window-reset',
- );
- const appearanceSettingElements = APPEARANCE_SETTING_FIELDS.map((field) => {
- const className = appearanceSettingClassName(field.key);
- return Object.assign({}, field, {
- input: readerElement(`[data-settings-panel="appearance"] .${className}${field.range ? '-range' : ''}`),
- output: readerElement(`[data-settings-panel="appearance"] .${className}-value`),
- });
- });
- const [
- appearanceThemeNote, appearanceStatus, performanceStatus, performanceResetBtn,
- ] = readerElementGroup(
- '.ldp-appearance-theme-note', '.ldp-appearance-status',
- '.ldp-performance-status', '.ldp-performance-reset',
- );
- const [
- appearanceGroupToggleInputs, colorResetButtons, performancePresetButtons, performanceInputs,
- resourceMonitorMetrics, resourceMonitorAverages, resourceMonitorPeaks, resourceMonitorTrends,
- ] = readerElementsGroup(
- '[data-appearance-group-toggle]', '[data-color-reset]', '.ldp-performance-preset',
- '[data-performance-key]', '[data-resource-monitor-metric]', '[data-resource-monitor-average]',
- '[data-resource-monitor-peak]', '[data-resource-monitor-trend]',
- );
- const resourceMonitorValueColumns = [
- [resourceMonitorMetrics, 'resourceMonitorMetric', ''],
- [resourceMonitorAverages, 'resourceMonitorAverage', 'Average'],
- [resourceMonitorPeaks, 'resourceMonitorPeak', 'Peak'],
- [resourceMonitorTrends, 'resourceMonitorTrend', 'Trend'],
- ];
- const [resourceMonitorChartRows, resourceMonitorScopeRows, requestFlowMetrics] = readerElementsGroup(
- '[data-resource-monitor-chart]', '[data-resource-monitor-scope]', '[data-request-flow-metric]');
- const [
- resourceMonitorHealth, resourceMonitorHealthState, resourceMonitorHealthDetail,
- resourceMonitorTrendWindow, resourceMonitorUpdated, resourceMonitorEvidenceWindow,
- resourceMonitorEventLog, resourceMonitorSection, requestFlowWindow, requestFlowChart,
- requestFlowLegend, requestFlowBottleneck, requestFlowBottleneckState, requestFlowBottleneckDetail,
- requestFlowAnomalyWindow, requestFlowAnomalies, requestFlowTypes, requestFlowLog,
- requestFlowObserved, requestFlowSection,
- ] = readerElementGroup(
- '.ldp-resource-monitor-health', '.ldp-resource-monitor-health-state',
- '.ldp-resource-monitor-health-detail', '.ldp-resource-monitor-trend-window',
- '.ldp-resource-monitor-updated', '.ldp-resource-monitor-evidence-window',
- '.ldp-resource-monitor-event-log',
- '.ldp-settings-log-panel[data-settings-log-panel="performance"]',
- '.ldp-request-flow-window', '.ldp-request-flow-trace', '.ldp-request-flow-legend',
- '.ldp-request-flow-bottleneck', '.ldp-request-flow-bottleneck-state',
- '.ldp-request-flow-bottleneck-detail', '.ldp-request-flow-anomaly-window',
- '.ldp-request-flow-anomalies', '.ldp-request-flow-types', '.ldp-request-flow-log',
- '.ldp-request-flow-observed', '.ldp-settings-log-panel[data-settings-log-panel="request"]',
- );
- const jumpHighlightControls = Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => {
- const className = `.ldp-flash-${field.key}`;
- return [field.key, {
- input: readerElement(className),
- output: readerElement(`${className}-value`),
- }];
- }));
- const jumpHighlightInputs = JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => jumpHighlightControls[field.key].input);
- const [
- jumpHighlightStatus, loadingAnimationSelect, loadingAnimationPreview,
- loadingAnimationPreviewLabel, loadingAnimationReroll, historySortModeSelect,
- historyButtonsAlwaysVisibleInput, readerQueueAlwaysVisibleWhenEmptyInput, historyEdgeTriggerRange, historyEdgeTriggerValue,
- openTopicsAtFirstPostInput, doubleEscapeToCloseReaderInput, confirmNativeComposerCloseInput, topicActionRailVisibleInput, topicActionRailFixedInput, topicActionRailResetBtn, expandNestedRepliesByDefaultInput, expandLeafNestedRepliesInput, aggregateDescendantRepliesInput, hideNestedReplyFloorsInput,
- nestedReplyDisplayWarning, boostCopyModeSelect, boostCopyPrefixInput, boostCopyCounterMarkerInput,
- boostCopyCounterStepInput, boostCopyFixedSuffixInput, boostCopyPreview, configExportBtn,
- configImportBtn, configResetBtn, configFileInput, cacheClearBtn, customSiteInput,
- customSiteAddBtn, customSiteList, customSiteStatus,
- ] = readerElementGroup(
- '.ldp-flash-status', '.ldp-loading-animation-select',
- '.ldp-loading-preview-stage', '.ldp-loading-settings-preview-label',
- '.ldp-loading-preview-reroll', '.ldp-history-sort-mode', '.ldp-history-buttons-always-visible',
- '.ldp-reader-queue-always-visible-empty', '.ldp-history-edge-trigger-range', '.ldp-history-edge-trigger-value',
- '.ldp-open-topics-first-post', '.ldp-double-escape-close-reader', '.ldp-confirm-native-composer-close', '.ldp-topic-action-rail-visible-setting', '.ldp-topic-action-rail-fixed-setting', '.ldp-topic-action-rail-reset', '.ldp-expand-nested-replies-default',
- '.ldp-expand-leaf-nested-replies', '.ldp-aggregate-descendant-replies', '.ldp-hide-nested-reply-floors', '.ldp-nested-display-warning', '.ldp-boost-copy-mode',
- '.ldp-boost-copy-prefix', '.ldp-boost-copy-counter-marker', '.ldp-boost-copy-counter-step',
- '.ldp-boost-copy-fixed-suffix', '.ldp-boost-copy-preview', '.ldp-config-export',
- '.ldp-config-import', '.ldp-config-reset', '.ldp-config-file', '.ldp-cache-clear',
- '.ldp-custom-site-input', '.ldp-custom-site-add', '.ldp-custom-site-list', '.ldp-custom-site-status',
- );
- const [
- boostCopyCounterRows, boostCopyTextRows, cacheSelects, cacheSizeEls, cacheRetentionEls,
- settingsColorInputs,
- ] = readerElementsGroup(
- '.ldp-boost-counter-row', '.ldp-boost-text-row', '.ldp-cache-select', '[data-cache-size]',
- '[data-cache-retention]', '.ldp-color-control input[type="color"]',
- );
- const colorPickerPresets = ['#F0FFF0', '#FFFFFF', '#E5E7EB', '#94A3B8', '#475569', '#111827',
- '#47855F', '#22C55E', '#2563EB', '#7C3AED', '#D97706', '#DC2626'];
- let colorPickerPopover = readerElement('.ldp-color-picker-popover');
- if (!colorPickerPopover) {
- colorPickerPopover = makeElement('div');
- colorPickerPopover.className = 'ldp-color-picker-popover';
- colorPickerPopover.setAttribute('role', 'dialog');
- colorPickerPopover.setAttribute('aria-modal', 'false');
- setLabel(colorPickerPopover, '选择颜色');
- colorPickerPopover.hidden = true;
- colorPickerPopover.innerHTML = `
-
选择颜色
-
- ${colorPickerPresets.map((color) => ``).join('')}
-
-
-
-
-
-
- 色相
- 饱和度
- 明度
-
`;
- surfaceManager.mount(colorPickerPopover, settingsSurfaceHost());
- }
- let lastSettingsSurfaceHost = settingsSurfaceHost();
- const syncSettingsSurfaceHost = (allowParking = true) => {
- const host = settingsSurfaceHost();
- const surfaces = [settingsPopover, settingHelpTooltip, fontFamilyMenu, colorPickerPopover].filter(Boolean);
- if (allowParking && settingsPopover.hidden) {
- surfaces.forEach((surface) => surfaceManager.park(surface));
- setHidden(settingsLayer, true);
- return;
- }
- const hostChanged = lastSettingsSurfaceHost !== host;
- surfaces.forEach((surface) => surfaceManager.mount(surface, host));
- lastSettingsSurfaceHost = host;
- setHidden(settingsLayer, host !== settingsLayer);
- if (hostChanged) {
- settingsPopover.style.removeProperty('left');
- settingsPopover.style.removeProperty('top');
- settingsPopover.style.removeProperty('transform');
- }
- syncReaderSurfaceDensity();
- };
- syncSettingsSurfaceHost(false);
- queueMicrotask(() => {
- if (settingsPopover.hidden) syncSettingsSurfaceHost();
- });
- const colorPickerTitle = colorPickerPopover.querySelector('.ldp-color-picker-title');
- const colorPickerHexInput = colorPickerPopover.querySelector('.ldp-color-picker-hex');
- const colorPickerPresetButtons = colorPickerPopover.querySelectorAll('.ldp-color-picker-preset');
- const colorPickerMoreButton = colorPickerPopover.querySelector('.ldp-color-picker-more');
- const colorPickerAdvanced = colorPickerPopover.querySelector('.ldp-color-picker-advanced');
- const colorPickerHueInput = colorPickerPopover.querySelector('.ldp-color-picker-hue');
- const colorPickerSaturationInput = colorPickerPopover.querySelector('.ldp-color-picker-saturation');
- const colorPickerBrightnessInput = colorPickerPopover.querySelector('.ldp-color-picker-brightness');
- const colorPickerHueValue = colorPickerPopover.querySelector('.ldp-color-picker-hue-value');
- const colorPickerSaturationValue = colorPickerPopover.querySelector('.ldp-color-picker-saturation-value');
- const colorPickerBrightnessValue = colorPickerPopover.querySelector('.ldp-color-picker-brightness-value');
- let activeColorInput = null;
- let colorPickerHsv = { h: 0, s: 0, v: 100 };
- let colorPickerCommitFrame = 0;
- let pendingColorPickerCommit = null;
- const normalizeColorPickerHex = (rawValue) => {
- const match = /^#?([\da-f]{3}|[\da-f]{6})$/i.exec(String(rawValue || '').trim());
- if (!match) return '';
- const digits = match[1].length === 3 ? Array.from(match[1], (digit) => `${digit}${digit}`).join('') : match[1];
- return `#${digits.toUpperCase()}`;
- };
- const colorPickerHexToHsv = (hex) => {
- const value = normalizeColorPickerHex(hex).slice(1);
- const red = parseInt(value.slice(0, 2), 16) / 255;
- const green = parseInt(value.slice(2, 4), 16) / 255;
- const blue = parseInt(value.slice(4, 6), 16) / 255;
- const max = Math.max(red, green, blue);
- const min = Math.min(red, green, blue);
- const delta = max - min;
- let hue = 0;
- if (delta) {
- if (max === red) hue = 60 * (((green - blue) / delta) % 6);
- else if (max === green) hue = 60 * ((blue - red) / delta + 2);
- else hue = 60 * ((red - green) / delta + 4);
- }
- if (hue < 0) hue += 360;
- return { h: Math.round(hue) % 360, s: max ? Math.round((delta / max) * 100) : 0, v: Math.round(max * 100) };
- };
- const colorPickerHsvToHex = ({ h, s, v }) => {
- const saturation = s / 100;
- const brightness = v / 100;
- const chroma = brightness * saturation;
- const sector = h / 60;
- const intermediate = chroma * (1 - Math.abs((sector % 2) - 1));
- const offset = brightness - chroma;
- const channels = sector < 1 ? [chroma, intermediate, 0]
- : sector < 2 ? [intermediate, chroma, 0]
- : sector < 3 ? [0, chroma, intermediate]
- : sector < 4 ? [0, intermediate, chroma]
- : sector < 5 ? [intermediate, 0, chroma] : [chroma, 0, intermediate];
- return `#${channels.map((channel) => Math.round((channel + offset) * 255).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
- };
- const syncColorPickerAdvanced = () => {
- const { h, s, v } = colorPickerHsv;
- colorPickerHueInput.value = String(h);
- colorPickerSaturationInput.value = String(s);
- colorPickerBrightnessInput.value = String(v);
- colorPickerHueValue.textContent = `${h}°`;
- colorPickerSaturationValue.textContent = `${s}%`;
- colorPickerBrightnessValue.textContent = `${v}%`;
- colorPickerHueInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s: 100, v: 100 }));
- colorPickerSaturationInput.style.setProperty('--ldp-color-slider-start', colorPickerHsvToHex({ h, s: 0, v }));
- colorPickerSaturationInput.style.setProperty('--ldp-color-slider-end', colorPickerHsvToHex({ h, s: 100, v }));
- colorPickerSaturationInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s, v }));
- colorPickerBrightnessInput.style.setProperty('--ldp-color-slider-end', colorPickerHsvToHex({ h, s, v: 100 }));
- colorPickerBrightnessInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s, v }));
- };
- const syncColorPickerPopover = () => {
- if (!activeColorInput) return;
- const color = activeColorInput.value.toUpperCase();
- colorPickerTitle.textContent = activeColorInput.getAttribute('aria-label') || '选择颜色';
- colorPickerHexInput.value = color;
- colorPickerHsv = colorPickerHexToHsv(color);
- syncColorPickerAdvanced();
- colorPickerHexInput.setAttribute('aria-invalid', 'false');
- colorPickerPresetButtons.forEach((button) => setPressed(button, button.dataset.color === color));
- };
- const commitScheduledColorPickerValue = () => {
- colorPickerCommitFrame = 0;
- const pending = pendingColorPickerCommit;
- pendingColorPickerCommit = null;
- if (!pending || !pending.input.isConnected) return;
- pending.input.value = pending.value;
- pending.input.dispatchEvent(new window.Event('input', { bubbles: true }));
- };
- const scheduleColorPickerValue = (value) => {
- if (!activeColorInput) return;
- pendingColorPickerCommit = { input: activeColorInput, value };
- if (!colorPickerCommitFrame) colorPickerCommitFrame = window.requestAnimationFrame(commitScheduledColorPickerValue);
- };
- const flushScheduledColorPickerValue = () => {
- if (!pendingColorPickerCommit) return;
- if (colorPickerCommitFrame) window.cancelAnimationFrame(colorPickerCommitFrame);
- commitScheduledColorPickerValue();
- };
- const closeColorPickerPopover = ({ restoreFocus = false } = {}) => {
- if (colorPickerPopover.hidden) return;
- flushScheduledColorPickerValue();
- const previousInput = activeColorInput;
- colorPickerPopover.hidden = true;
- activeColorInput = null;
- if (restoreFocus && previousInput) previousInput.focus();
- };
- const positionColorPickerPopover = (input) => {
- const margin = 12;
- const gap = 8;
- const bounds = settingsSurfaceBounds();
- const inputRect = input.getBoundingClientRect();
- const popoverRect = colorPickerPopover.getBoundingClientRect();
- const minimumLeft = bounds.left + margin;
- const minimumTop = bounds.top + margin;
- const left = Math.min(
- Math.max(minimumLeft, inputRect.left),
- Math.max(minimumLeft, bounds.right - popoverRect.width - margin),
- );
- const below = inputRect.bottom + gap;
- const top = below + popoverRect.height <= bounds.bottom - margin
- ? below
- : Math.max(minimumTop, inputRect.top - popoverRect.height - gap);
- colorPickerPopover.style.left = `${Math.round(left - bounds.left)}px`;
- colorPickerPopover.style.top = `${Math.round(top - bounds.top)}px`;
- };
- const openColorPickerPopover = (input) => {
- if (!input || input.disabled) return;
- hideSettingHelp();
- closeFontFamilyMenu();
- stopSettingsRangeDrag();
- stopSettingsColorPick();
- activeColorInput = input;
- syncColorPickerPopover();
- colorPickerPopover.hidden = false;
- positionColorPickerPopover(input);
- colorPickerHexInput.focus();
- colorPickerHexInput.select();
- };
- const applyColorPickerValue = (value, closeAfter = false) => {
- if (!activeColorInput) return;
- flushScheduledColorPickerValue();
- activeColorInput.value = value;
- activeColorInput.dispatchEvent(new window.Event('input', { bubbles: true }));
- syncColorPickerPopover();
- if (closeAfter) closeColorPickerPopover({ restoreFocus: true });
- };
- if (!reusingShell) {
- settingsColorInputs.forEach((input) => {
- input.setAttribute('aria-haspopup', 'dialog');
- onShell(input, 'click', (event) => {
- event.preventDefault();
- openColorPickerPopover(input);
- });
- onShell(input, 'keydown', (event) => {
- if (event.key !== 'Enter' && event.key !== ' ') return;
- event.preventDefault();
- openColorPickerPopover(input);
- });
- });
- colorPickerPresetButtons.forEach((button) => onShell(button, 'click', () => {
- applyColorPickerValue(button.dataset.color, true);
- }));
- onShell(colorPickerHexInput, 'input', () => {
- const rawValue = colorPickerHexInput.value.trim();
- const color = /^#?[\da-f]{6}$/i.test(rawValue) ? normalizeColorPickerHex(rawValue) : '';
- colorPickerHexInput.setAttribute('aria-invalid', String(rawValue.length >= 7 && !color));
- if (color) applyColorPickerValue(color);
- });
- onShell(colorPickerHexInput, 'keydown', (event) => {
- if (event.key === 'Escape') {
- event.preventDefault();
- closeColorPickerPopover({ restoreFocus: true });
- return;
- }
- if (event.key !== 'Enter') return;
- const color = normalizeColorPickerHex(colorPickerHexInput.value);
- if (!color) {
- colorPickerHexInput.setAttribute('aria-invalid', 'true');
- return;
- }
- event.preventDefault();
- applyColorPickerValue(color, true);
- });
- onShell(colorPickerHexInput, 'blur', () => {
- if (!activeColorInput || colorPickerPopover.contains(document.activeElement)) return;
- colorPickerHexInput.value = activeColorInput.value.toUpperCase();
- colorPickerHexInput.setAttribute('aria-invalid', 'false');
- });
- onShell(colorPickerMoreButton, 'click', () => {
- const expanded = colorPickerAdvanced.hidden;
- colorPickerAdvanced.hidden = !expanded;
- setExpanded(colorPickerMoreButton, expanded);
- colorPickerMoreButton.textContent = expanded ? '收起调色' : '更多颜色';
- if (activeColorInput) positionColorPickerPopover(activeColorInput);
- });
- [colorPickerHueInput, colorPickerSaturationInput, colorPickerBrightnessInput].forEach((input) =>
- onShell(input, 'input', () => {
- colorPickerHsv = {
- h: Number(colorPickerHueInput.value),
- s: Number(colorPickerSaturationInput.value),
- v: Number(colorPickerBrightnessInput.value),
- };
- const color = colorPickerHsvToHex(colorPickerHsv);
- colorPickerHexInput.value = color;
- colorPickerHexInput.setAttribute('aria-invalid', 'false');
- syncColorPickerAdvanced();
- colorPickerPresetButtons.forEach((button) =>
- setPressed(button, button.dataset.color === color));
- scheduleColorPickerValue(color);
- }));
- [colorPickerHueInput, colorPickerSaturationInput, colorPickerBrightnessInput]
- .forEach((input) => onShell(input, 'change', flushScheduledColorPickerValue));
- onShell(colorPickerPopover, 'keydown', (event) => {
- if (event.key !== 'Escape') return;
- event.preventDefault();
- closeColorPickerPopover({ restoreFocus: true });
- });
- onShell(colorPickerPopover, 'pointerdown', (event) => event.stopPropagation());
- onShell(colorPickerPopover, 'click', (event) => event.stopPropagation());
- onShell(overlay, 'pointerdown', (event) => {
- if (colorPickerPopover.hidden || colorPickerPopover.contains(event.target) || event.target === activeColorInput) return;
- closeColorPickerPopover();
- }, true);
- }
- const setSettingHelp = (target, copy) => {
- if (target && copy) target.dataset.settingHelp = copy;
- };
- const setSettingRowHelp = (control, copy) => {
- setSettingHelp(control?.closest('.ldp-setting-row'), copy);
- };
- setSettingRowHelp(imageScaleRange, '控制所有显示形态中帖子正文图片的显示大小。100% 是正常尺寸;调小能少占屏幕,调大方便看细节。修改后立即保存。');
- setSettingRowHelp(lightboxOriginalByDefaultInput, '开启后,大图查看器首次打开和切换图片时会自动请求原图;关闭后不会新请求原图,但本地已有原图缓存时仍会优先展示,否则继续使用预览图。修改后立即保存。');
- setSettingRowHelp(lightboxCommentsExpandedInput, '控制大图查看器首次打开时是否展开评论面板;打开后仍可随时手动收起或展开。修改后立即保存。');
- setSettingRowHelp(lightboxDescriptionExpandedInput, '控制大图查看器首次打开时是否展开图片说明;手动收起、展开或拖动说明高度后也会自动记住。修改后立即保存。');
- const layoutHelp = {
- left: '控制阅读内容左边保留多少空白。数值越大,正文会整体向右并变窄;它需要和另外四个区域合计正好 100%。',
- main: '控制正文区域实际占用的宽度,包括主帖和楼层内容。太小会增加换行,太大会挤压时间轴和两侧留白;五个区域必须合计 100%。',
- gap: '控制正文与右侧时间轴之间的空隙。调大后两块内容更分开,但正文可用宽度会减少;五个区域必须合计 100%。',
- timeline: '控制右侧楼层时间轴、日期和楼层计数占用的宽度。太小可能显得拥挤;五个区域必须合计 100%。',
- right: '控制时间轴右边保留多少空白。调大能让右侧不贴边,但会减少正文或时间轴的空间;五个区域必须合计 100%。',
- };
- layoutRatioInputs.forEach((input) => setSettingRowHelp(input, layoutHelp[input.dataset.layoutRegion]));
- setSettingHelp(readerWindowWidthInput.closest('.ldp-reader-window-field'), '设置桌面浮窗的宽度。数值越大,一行能显示的正文越多;只影响浮窗模式,修改后立即保存。');
- setSettingHelp(readerWindowHeightInput.closest('.ldp-reader-window-field'), '设置桌面浮窗的高度。数值越大,一屏能看到的楼层越多;只影响浮窗模式,修改后立即保存。');
- setSettingHelp(readerWindowXInput.closest('.ldp-reader-window-field'), '设置浮窗左边缘距离浏览器左侧的像素数。数值越大,浮窗越靠右;只影响桌面浮窗并立即保存。');
- setSettingHelp(readerWindowYInput.closest('.ldp-reader-window-field'), '设置浮窗上边缘距离浏览器顶部的像素数。数值越大,浮窗越靠下;只影响桌面浮窗并立即保存。');
- setSettingHelp(readerWindowPinInput.closest('.ldp-reader-window-option'), '开启后,点击阅读器外面的网页区域也不会自动收起浮窗,适合边看帖子边操作原页面。');
- setSettingHelp(readerWindowLockInput.closest('.ldp-reader-window-option'), '开启后禁止拖动和缩放浮窗,避免误操作改变位置或大小;仍可通过小锁或这里重新解锁。');
- appearanceSettingElements.forEach(({ input, help, range }) => setSettingRowHelp(input,
- range ? help : `${help}确定颜色后会保留色相,并按用途自动生成明亮与暗色主题的安全亮度;切换主题时自动应用。`));
- appearanceGroupToggleInputs.forEach((input) => setSettingHelp(input.closest('.ldp-setting-switch'),
- '关闭后隐藏回复连接线、引用线和界面分隔线;已经设置的颜色、粗细和圆角不会丢失,重新开启即可恢复。'));
- const presetHelp = {
- low: '减少每次加载的内容、页面保留的楼层和提前请求量,优先降低网络、内存与主线程占用;下次打开阅读器生效。',
- balanced: '在加载速度、滚动流畅度、内存占用和请求稳定性之间取默认平衡;下次打开阅读器生效。',
- high: '增加每次加载的内容、页面保留的楼层和提前请求距离,优先减少滚动等待,但会增加内存、网络和限流压力;下次打开阅读器生效。',
- custom: '表示下面的性能参数已经手动调整。点击它不会自动改值,可直接修改下方各项;下次打开阅读器生效。',
- };
- performancePresetButtons.forEach((button) => setSettingHelp(button, presetHelp[button.dataset.performancePreset]));
- performanceInputs.forEach((input) => setSettingRowHelp(input,
- PERFORMANCE_SETTING_FIELDS.find((field) => field.key === input.dataset.performanceKey).help));
- JUMP_HIGHLIGHT_SETTING_FIELDS.forEach((field) => setSettingRowHelp(jumpHighlightControls[field.key].input, field.help));
- setSettingRowHelp(historyButtonsAlwaysVisibleInput, '开启后,可用的历史前进和后退按钮会一直显示,并禁用“边缘唤出按钮范围”滑块;关闭后,按钮仅在鼠标进入对应边缘或键盘聚焦时显示。修改后立即保存。');
- setSettingRowHelp(historyEdgeTriggerRange, `设置阅读器左右两侧用于唤出历史前进和后退按钮的范围,各占阅读器宽度的 ${HISTORY_EDGE_TRIGGER_MIN}%–${HISTORY_EDGE_TRIGGER_MAX}%;范围透明且不会遮挡正文操作,0% 表示关闭鼠标唤出。修改后立即保存。`);
- setSettingRowHelp(historySortModeSelect, '“最近打开优先”按每次打开的最新时间倒序排列,重开旧帖后它会回到列表顶部;“首次打开顺序”按每条记录第一次进入历史的时间排列,重开不会改变位置。旧记录无法还原更早的首次打开时间,会从当前保存时间开始计算。修改后立即保存。');
- setSettingRowHelp(openTopicsAtFirstPostInput, '开启后,普通帖子链接会从 #1 主楼开始;消息、历史和收藏面板中的链接仍优先打开各自目标楼层。关闭后,所有链接都会尊重其中指定的楼层号。修改后立即保存。');
- setSettingRowHelp(doubleEscapeToCloseReaderInput, '开启后,需要在 1.5 秒内连续按两次 Esc 才会关闭阅读器;关闭后,按一次 Esc 即可关闭。修改后立即保存。');
- setSettingRowHelp(confirmNativeComposerCloseInput, '开启后,在阅读器内按 Esc、关闭或舍弃 LINUX DO 原生回复窗口时,需要在 1.5 秒内重复同一操作;关闭后,一次操作即可关闭。修改后立即保存。');
- setSettingRowHelp(expandNestedRepliesByDefaultInput, '开启后,在每条父回复下默认展开它的直接二级回复;关闭时会同时关闭“完整讨论”视图。修改后立即保存并应用到当前帖子。');
- setSettingRowHelp(expandLeafNestedRepliesInput, '开启后,二级回复在楼层列表中的对应位置默认完整展开;可与父回复下的二级回复同时显示,但至少要保留一种显示位置。修改后立即保存并应用到当前帖子。');
- setSettingRowHelp(aggregateDescendantRepliesInput, '建立在“在父回复下展开二级回复”之上;开启时会按滚动位置预加载更深层回复,并提供“查看完整讨论”浮窗。修改后立即保存并应用到当前帖子。');
- setSettingRowHelp(hideNestedReplyFloorsInput, '未启用“完整讨论”时,从楼层列表隐藏全部二级回复;启用后先保留未读二级回复,读过后再隐藏。时间轴、跳转和已读记录不受影响;跳转时会临时显示或打开对应讨论。修改后立即保存并应用到当前帖子。');
- setSettingRowHelp(boostCopyModeSelect, '选择复制 Boost 时如何生成末尾内容:“递增数字”每次按设定步长增加,“固定文字”每次追加同一段文字。修改后立即保存。');
- setSettingRowHelp(boostCopyPrefixInput, '填写复制结果开头的前置文字,例如“赞同:”。留空就直接从原 Boost 内容开始,最多 16 个字。修改后立即保存。');
- setSettingRowHelp(boostCopyCounterMarkerInput, '使用递增数字时,填写数字前缀,例如“+”会得到“原 Boost +1”;留空时数字会直接接在原文后面。修改后立即保存。');
- setSettingRowHelp(boostCopyCounterStepInput, '使用递增数字时,每复制一次增加多少。设为 1 会依次得到 1、2、3;设为 5 会得到 5、10、15。修改后立即保存。');
- setSettingRowHelp(boostCopyFixedSuffixInput, '使用固定文字时,每次复制都会把这里的内容追加到原 Boost 后面,例如“俺也一样”。最多 16 个字。修改后立即保存。');
- setSettingRowHelp(boostCopyPreview, '展示当前规则实际会复制出的结果;使用递增数字时会同时展示连续两次复制,方便确认步长。');
- cacheSelects.forEach((input, index) => setSettingHelp(input.closest('.ldp-cache-row'), CACHE_TYPES[index].help));
- setSettingHelp(cacheClearBtn, '只清理上面已经勾选的缓存类型。不会退出登录,也不会删除站点上的帖子、消息或图片;清理后需要时会重新联网获取。');
- const readerWindow = readerShell.readerWindow || createReaderWindowController(overlay, modal);
- readerShell.readerWorkspace = readerWorkspace;
- readerShell.readerWindow = readerWindow;
- readerShell.topicId = Number(topicId);
- readerShell.targetPostNumber = targetPostNumber;
- readerShell.topicNavMetadata = sourceTopicNavMetadata;
- readerShell.commentsHeader = commentsHeader;
- overlay._ldpReaderShell = readerShell;
- const syncReaderPlacementUi = () => {
- const allowedModes = directTopicRoute ? TOPIC_READER_MODES : LIST_READER_MODES;
- const visibleModeCount = allowedModes.length;
- readerPlacementControl.hidden = visibleModeCount < 2;
- readerPlacementStrip.hidden = visibleModeCount < 2;
- readerCapsule.classList.toggle('ldp-reader-placement-available', visibleModeCount > 1);
- const activeMode = readerWorkspace.mode();
- readerPlacementOptions.forEach((option) => {
- const optionMode = option.dataset.readerPlacement;
- option.hidden = !allowedModes.includes(optionMode);
- option.classList.toggle('active', option.dataset.readerPlacement === activeMode);
- });
- };
- const applyReaderPlacement = (nextMode) => {
- const allowedModes = directTopicRoute ? TOPIC_READER_MODES : LIST_READER_MODES;
- if (!allowedModes.includes(nextMode)) return;
- readerWorkspace.setMode(nextMode);
- readerWindow.syncMode();
- syncLayoutBtn();
- syncReaderBackgroundMode();
- applyLayoutRatios();
- if (nextMode !== readerWorkspace.mode()) showSelectionToast('页面空间不足,暂时保持浮窗');
- syncReaderPlacementUi();
- };
- if (!reusingShell) {
- readerPlacementOptions.forEach((option) => {
- onShell(option, 'click', () => applyReaderPlacement(option.dataset.readerPlacement));
- });
- }
- syncReaderPlacementUi();
-
- let latestTopicShellData = cachedTopicPreview;
- let topicNavMetadataObserver = null;
- let topicNavMetadataSyncFrame = 0;
- let topicNavMetadataStopTimer = 0;
- let topicNavMetadataOverride = null;
- let topicEditorState = null;
- const stopTopicNavMetadataHydration = () => {
- if (topicNavMetadataObserver) topicNavMetadataObserver.disconnect();
- if (topicNavMetadataSyncFrame) cancelAnimationFrame(topicNavMetadataSyncFrame);
- if (topicNavMetadataStopTimer) clearTimeout(topicNavMetadataStopTimer);
- topicNavMetadataObserver = null;
- topicNavMetadataSyncFrame = 0;
- topicNavMetadataStopTimer = 0;
- };
- const syncReaderTitleDensity = () => {
- const header = readerElement('.ldp-header');
- const titleJump = header?.querySelector('.ldp-title-jump');
- if (!header || !titleJump) return;
- const range = document.createRange();
- range.selectNodeContents(titleJump);
- const rects = Array.from(range.getClientRects())
- .filter((rect) => rect.width > 0 && rect.height > 0);
- const firstTop = rects[0]?.top;
- header.classList.toggle('ldp-title-single-line',
- rects.length > 0 && rects.every((rect) => Math.abs(rect.top - firstTop) < 1));
- };
- readerShell.syncReaderTitleDensity = syncReaderTitleDensity;
- const renderTopicShell = (topicData) => {
- if (!topicData) return;
- updateDiscourseCapabilityDiagnostics({ topic: topicData });
- latestTopicShellData = topicData;
- const liveTopicNavMetadata = readTopicNavMetadata(null, topicId);
- sourceTopicNavMetadata = mergeTopicNavMetadata(
- topicNavMetadataOverride || liveTopicNavMetadata,
- mergeTopicNavMetadata(sourceTopicNavMetadata, cachedTopicNavMetadata(topicData)),
- );
- if (queueEntry) updateReaderQueueEntryFromTopic(queueEntry, topicData);
- readerShell.topicNavMetadata = sourceTopicNavMetadata;
- const topicMetadataFilled = fillTopicNavMetadata(topicData, sourceTopicNavMetadata);
- const topicMetadataRemembered = rememberTopicNavMetadata(topicId, topicData, sourceTopicNavMetadata);
- if (topicMetadataFilled && !topicMetadataRemembered) setCachedTopicData(topicId, topicData);
- const cachedPosts = Array.isArray(topicData.post_stream?.posts)
- ? topicData.post_stream.posts
- : [];
- const owner = topicData._opUsername
- || (topicData.details?.created_by && topicData.details.created_by.username)
- || (cachedPosts.find((post) => +post.post_number === 1) || {}).username
- || '';
- const title = String(topicData.title || '').trim();
- if (title) {
- readerElement('.ldp-title-jump').textContent = title;
- syncReaderDocumentTitle(title);
- }
- const topicEditTrigger = readerElement('.ldp-topic-edit-trigger');
- const canEditTopic = topicData.details && topicData.details.can_edit === true;
- topicEditTrigger.hidden = !canEditTopic;
- if (!canEditTopic && topicEditorState) topicEditorState.close(true);
- const ownerHtml = owner
- ? `
@${esc(owner)}`
- : '@?';
- const metaParts = [
- `${esc(String(topicData.posts_count || 0))} 帖`,
- `${esc(String(topicData.views || 0))} 浏览`,
- ];
- if (topicData.like_count != null)
- metaParts.push(`${esc(String(Math.max(0, +topicData.like_count || 0)))} 赞`);
- if (topicData.participant_count != null)
- metaParts.push(`${esc(String(Math.max(0, +topicData.participant_count || 0)))} 用户`);
- readerElement('.ldp-meta-stats').innerHTML = metaParts.join(' · ');
- readerElement('.ldp-meta-owner-value').innerHTML = ownerHtml;
- readerElement('.ldp-meta-owner').hidden = false;
- const tagsEl = readerElement('.ldp-topic-tags');
- tagsEl.innerHTML = renderTopicNavLinks(topicData, sourceTopicNavMetadata);
- tagsEl.hidden = !tagsEl.innerHTML;
- const voteSlot = readerElement('.ldp-topic-vote-slot');
- const hasTopicVote = hasDiscourseCapability('topicVoting', { topic: topicData }) &&
- (topicData.can_vote === true || topicData.user_voted === true || +topicData.vote_count > 0);
- voteSlot.hidden = !hasTopicVote;
- voteSlot.innerHTML = hasTopicVote
- ? `
`
- : '';
- syncReaderTitleDensity();
- readerShell.syncReaderTitleActionAlignment();
- queueTitleTopicScrollHints();
- };
- const syncTopicNavMetadataFromHost = () => {
- if (topicNavMetadataOverride) return false;
- if (!overlay.isConnected) {
- stopTopicNavMetadataHydration();
- return false;
- }
- const liveMetadata = readTopicNavMetadata(null, topicId);
- if (!topicNavMetadataHasIcons(liveMetadata)) return false;
- stopTopicNavMetadataHydration();
- sourceTopicNavMetadata = mergeTopicNavMetadata(liveMetadata, sourceTopicNavMetadata);
- readerShell.topicNavMetadata = sourceTopicNavMetadata;
- renderTopicShell(latestTopicShellData);
- return true;
- };
- const queueTopicNavMetadataSync = () => {
- if (topicNavMetadataSyncFrame) return;
- topicNavMetadataSyncFrame = requestAnimationFrame(() => {
- topicNavMetadataSyncFrame = 0;
- syncTopicNavMetadataFromHost();
- });
- };
- const startTopicNavMetadataHydration = () => {
- if (!directTopicRoute || topicNavMetadataHasIcons(sourceTopicNavMetadata) || !document.body) return;
- const metadataRoot = document.querySelector('#main-outlet') ||
- document.querySelector('#ember-app') ||
- document.body;
- topicNavMetadataObserver = new MutationObserver(queueTopicNavMetadataSync);
- topicNavMetadataObserver.observe(metadataRoot, { childList: true, subtree: true });
- topicNavMetadataStopTimer = setTimeout(stopTopicNavMetadataHydration, 5000);
- queueTopicNavMetadataSync();
- };
- renderTopicShell(cachedTopicPreview);
- startTopicNavMetadataHydration();
- const postRequestScheduler = createReaderRequestScheduler(performance);
- ACTIVE_READER_REQUEST_SCHEDULER = postRequestScheduler;
- scheduleReaderQueuePrefetch();
- let rateLimitNoticeTimer = 0;
- let rateLimitChallengePending = false;
- const rateLimitCooldownRemaining = () => {
- try {
- const state = postRequestScheduler.snapshot();
- return Math.max(0, Number(state?.cooldownRemaining) || 0);
- } catch {}
- return 0;
- };
- const syncRateLimitNotice = () => {
- if (!rateLimitNotice || !rateLimitDetail) return;
- const remaining = rateLimitCooldownRemaining();
- if (remaining <= 0) {
- setHidden(rateLimitNotice, true);
- if ('cooldownSeconds' in rateLimitNotice.dataset)
- delete rateLimitNotice.dataset.cooldownSeconds;
- if (rateLimitChallengePending && rateLimitChallenge) {
- rateLimitChallengePending = false;
- openCloudflareChallengePopup(rateLimitChallenge.href);
- }
- return;
- }
- rateLimitChallengePending = true;
- const seconds = Math.max(1, Math.ceil(remaining / 1000));
- if (rateLimitNotice.dataset.cooldownSeconds !== String(seconds)) {
- rateLimitNotice.dataset.cooldownSeconds = String(seconds);
- setText(rateLimitDetail, `触发 429,正在等待 ${seconds} 秒;倒计时结束后将自动打开过盾浮窗。`);
- }
- setHidden(rateLimitNotice, false);
- };
- const stopRateLimitNotice = () => {
- if (rateLimitNoticeTimer) clearInterval(rateLimitNoticeTimer);
- rateLimitNoticeTimer = 0;
- if (!rateLimitNotice) return;
- setHidden(rateLimitNotice, true);
- if ('cooldownSeconds' in rateLimitNotice.dataset) delete rateLimitNotice.dataset.cooldownSeconds;
- };
- const startRateLimitNotice = () => {
- if (rateLimitNoticeTimer) clearInterval(rateLimitNoticeTimer);
- rateLimitNoticeTimer = 0;
- if (document.visibilityState !== 'visible') return;
- syncRateLimitNotice();
- rateLimitNoticeTimer = setInterval(syncRateLimitNotice, 1000);
- };
- startRateLimitNotice();
- const loader = createLoader(topicId, postRequestScheduler, performance);
- const initialTopicRequest = loader.init();
-
- const notificationState = {
- group: 'all', page: 0, token: 0, total: 0, hasNext: false, query: '',
- groups: { notifications: 'all', messages: 'inbox' },
- };
- let notificationQueuedRequestKey = '';
- const currentReaderRequestScheduler = () => (
- readerShell.activeContext?.postRequestScheduler
- ) || ACTIVE_READER_REQUEST_SCHEDULER || postRequestScheduler;
- const historyState = { page: 0, query: '', multi: false, scope: 'page' };
- const historySelection = new Set();
- let historyScopeIds = [];
- const bookmarksState = {
- page: 0,
- query: '',
- token: 0,
- hasNext: false,
- entries: [],
- allEntries: [],
- reactionEntries: [],
- reactionLoadedAt: 0,
- reactionUsername: '',
- reactionFilter: '',
- type: bookmarkTabOrder[0],
- multi: false,
- scope: 'page',
- allIds: null,
- };
- const givenReactionOverrides = readerShell.givenReactionOverrides instanceof Map
- ? readerShell.givenReactionOverrides
- : new Map();
- readerShell.givenReactionOverrides = givenReactionOverrides;
- const bookmarksSelection = new Set();
- const clearBookmarksSelectionScope = () => {
- bookmarksState.allIds = null;
- bookmarksSelection.clear();
- };
- const cachedNotificationWarmup = getCachedNotificationPage(notificationPageCacheKey('all', 0));
- if (cachedNotificationWarmup) {
- notificationState.total = cachedNotificationWarmup.total;
- notificationState.hasNext = cachedNotificationWarmup.hasNext;
- }
- syncNotificationUnreadUi(ME_CURRENT_USER ? NOTIFICATION_UNREAD_COUNT : cachedUnreadNotificationCount());
- const positionHeaderPopover = (button, popover) => {
- if (!button || !popover || popover.hidden || !popover.isConnected) return;
- const buttonRect = button.getBoundingClientRect();
- const popoverRect = popover.getBoundingClientRect();
- const gap = 8;
- const left = Math.max(12, Math.min(window.innerWidth - popoverRect.width - 12,
- buttonRect.right - popoverRect.width));
- const below = buttonRect.bottom + gap;
- const above = buttonRect.top - popoverRect.height - gap;
- const nextLeft = `${Math.round(left)}px`;
- const nextTop = `${Math.round(
- below + popoverRect.height <= window.innerHeight - 12 || above < 12 ? below : above
- )}px`;
- if (popover.style.left !== nextLeft) popover.style.left = nextLeft;
- if (popover.style.top !== nextTop) popover.style.top = nextTop;
- };
- const createCollectionPanelController = ({ toggle, popover, panel, state, close }) => {
- const mount = () => surfaceManager.mount(popover);
- const park = () => {
- setHidden(popover, true);
- setExpanded(toggle, false);
- surfaceManager.park(popover);
- };
- return {
- toggle,
- popover,
- position: () => positionHeaderPopover(toggle, popover),
- open() {
- mount();
- setHidden(popover, false);
- setExpanded(toggle, true);
- },
- close() {
- const wasOpen = !popover.hidden;
- setHidden(popover, true);
- setExpanded(toggle, false);
- surfaceManager.park(popover);
- close?.();
- if (wasOpen) {
- releasePersistentAvatarImages(panel.list);
- panel.list.replaceChildren();
- }
- },
- park,
- message(copy, error = false) {
- releasePersistentAvatarImages(panel.list);
- panel.list.innerHTML = `
${copy}
`;
- panel.pagePrev.disabled = true;
- panel.pageNext.disabled = true;
- },
- paging(totalPages, hasEntries = true, hasNext = state.hasNext) {
- panel.pagePrev.disabled = state.page <= 0;
- panel.pageNext.disabled = !hasNext;
- setText(panel.pageInfo, hasEntries
- ? `${state.page + 1} / ${Math.max(1, totalPages)}`
- : '暂无记录');
- },
- };
- };
- const notificationCollection = createCollectionPanelController({
- toggle: notificationsBtn, popover: notificationsPopover, panel: notificationPanel, state: notificationState,
- });
- const historyCollection = createCollectionPanelController({
- toggle: historyPanel.toggle, popover: historyPanel.popover, panel: historyPanel, state: historyState,
- close: () => {
- historyState.multi = false;
- historySelection.clear();
- },
- });
- const bookmarksCollection = createCollectionPanelController({
- toggle: bookmarksPanel.toggle, popover: bookmarksPanel.popover, panel: bookmarksPanel, state: bookmarksState,
- close: () => {
- readerShell.cancelBookmarksSearch?.();
- bookmarksState.token++;
- bookmarksState.multi = false;
- clearBookmarksSelectionScope();
- },
- });
- const positionNotificationsPopover = notificationCollection.position;
- const closeNotifications = notificationCollection.close;
- readerShell.closeNotifications = closeNotifications;
- const positionHistoryPopover = historyCollection.position;
- const closeHistory = historyCollection.close;
- const positionBookmarksPopover = bookmarksCollection.position;
- const closeBookmarks = bookmarksCollection.close;
- const collectionPanels = [notificationCollection, historyCollection, bookmarksCollection];
- collectionPanels.forEach((panel) => panel.park());
- const positionHeaderPopovers = () => collectionPanels.forEach((panel) => panel.position());
- const syncHistorySortToggle = () => {
- const recentFirst = PREFS.historySortMode === 'recent-viewed';
- const label = recentFirst
- ? '最近打开优先;点击切换为首次打开顺序'
- : '首次打开顺序固定;点击切换为最近打开优先';
- historyPanel.sortToggle.innerHTML = icon(recentFirst ? 'history' : 'pin');
- setLabel(historyPanel.sortToggle, label);
- setPressed(historyPanel.sortToggle, !recentFirst);
- historyPanel.sortToggle.dataset.ldpTooltipLabel = label;
- };
- const syncCollectionSelectionControls = (
- panel, state, selection, scopeIds, itemLabel, multiDisabled, selectDisabled, unavailable = false
- ) => {
- const fullySelected = Array.isArray(scopeIds) && scopeIds.length > 0 &&
- scopeIds.every((id) => selection.has(id));
- panel.defaultActions.hidden = unavailable || state.multi;
- panel.bulkActions.hidden = unavailable || !state.multi;
- panel.multi.disabled = multiDisabled;
- panel.selectScope.value = state.scope;
- panel.selectToggle.disabled = selectDisabled;
- panel.selectToggle.innerHTML = icon(fullySelected ? 'squareCheck' : 'square');
- setPressed(panel.selectToggle, fullySelected);
- setLabel(panel.selectToggle, `${fullySelected ? '全不选' : '全选'}${state.scope === 'all' ? '全部页面' : '本页'}${itemLabel}`);
- panel.deleteSelected.disabled = selection.size === 0;
- panel.deleteSelectedLabel.textContent = String(selection.size);
- panel.deleteSelectedLabel.hidden = selection.size === 0;
- };
- const localCollectionPage = (entries, state, pageSize) => {
- const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
- state.page = Math.max(0, Math.min(totalPages - 1, state.page));
- const start = state.page * pageSize;
- return { entries: entries.slice(start, start + pageSize), totalPages };
- };
- function renderHistoryPage() {
- syncHistorySortToggle();
- const storedHistory = readTopicHistory();
- const allEntries = orderedTopicHistory(storedHistory);
- const availableTopicIds = new Set(allEntries.map((entry) => Number(entry.topicId)));
- historySelection.forEach((topicId) => {
- if (!availableTopicIds.has(topicId)) historySelection.delete(topicId);
- });
- const entries = historyState.query
- ? allEntries.filter((entry) => historyMatchesSearch(entry, historyState.query))
- : allEntries;
- const { entries: pageEntries, totalPages } =
- localCollectionPage(entries, historyState, HISTORY_PAGE_SIZE);
- releasePersistentAvatarImages(historyPanel.list);
- historyPanel.list.innerHTML = pageEntries.length
- ? pageEntries.map((entry) => {
- const cachedSource = entry.avatarTemplate &&
- touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, avatarUrl(entry.avatarTemplate, 64));
- const historyAvatar = cachedSource
- ? `
})
`
- : entry.avatarTemplate
- ? `
${esc(String(entry.ownerUsername || '?').slice(0, 1).toUpperCase())}`
- : `
${icon('history')}`;
- const historyUserAvatar = userCardAvatarHtml(historyAvatar, entry.ownerUsername);
- const selected = historySelection.has(Number(entry.topicId));
- const targetPostNumber = Math.max(1, Number(entry.postNumber) || 1);
- return `
- ${historyState.multi ? `
-
- ` : ''}
- ${collectionLinkMarkup('ldp-history-link', topicPageUrl(entry.topicId, targetPostNumber, true),
- historyUserAvatar, entry.title,
- `${entry.postsCount || 0} 帖 · ${discourseRelativeTime(historyEntrySortTime(entry))}`)}
- ${historyState.multi ? '' : ``}
-
`;
- }).join('')
- : `
${historyState.query ? '没有匹配的浏览历史' : '暂无浏览历史'}
`;
- hydrateHistoryAvatars(historyPanel.list, pageEntries, storedHistory);
- historyState.hasNext = historyState.page < totalPages - 1;
- const scopeEntries = historyState.scope === 'all' ? entries : pageEntries;
- historyScopeIds = scopeEntries.map((entry) => Number(entry.topicId));
- syncCollectionSelectionControls(
- historyPanel, historyState, historySelection, historyScopeIds,
- '浏览历史', allEntries.length === 0, scopeEntries.length === 0
- );
- historyPanel.clear.disabled = allEntries.length === 0;
- historyCollection.paging(totalPages, entries.length > 0);
- positionHistoryPopover();
- }
- readerShell.refreshHistoryPanel = () => {
- if (historyPanel.popover.hidden) return;
- if (PREFS.historySortMode === 'recent-viewed') historyState.page = 0;
- renderHistoryPage();
- };
- const bookmarkAvatarHtml = (entry) => {
- const user = entry?.user || {};
- const avatar = persistentAvatarHtml(
- user.avatar_template,
- 64,
- 'ldp-notification-avatar',
- user.name || user.username || '?'
- );
- return avatar
- ? userCardAvatarHtml(avatar, user.username)
- : `
${icon('bookmark')}`;
- };
- const bookmarkTarget = (entry) => ({
- topicId: Number(entry?.topic_id) || 0,
- postNumber: Math.max(1, Number(entry?.linked_post_number) || 1),
- });
- const reactionPageEntries = (result) => {
- if (Array.isArray(result)) return result;
- if (Array.isArray(result?.user_reactions)) return result.user_reactions;
- if (Array.isArray(result?.reaction_users)) return result.reaction_users;
- if (Array.isArray(result?.reactions)) return result.reactions;
- return [];
- };
- const givenReactionRecord = (post, topic, reaction, id = 0, reactedAt = '', title = '') => {
- const postId = Number(post?.id) || 0;
- const topicId = Number(post?.topic_id || topic?.id) || 0;
- return {
- id: Number(id) || postId,
- topicId,
- postId,
- postNumber: Math.max(1, Number(post?.post_number) || 1),
- reaction,
- title: String(post?.topic_title || title || topic?.title || `帖子 #${topicId}`),
- authorUsername: String(post?.username || ''),
- avatarTemplate: String(post?.avatar_template || ''),
- reactedAt: String(reactedAt || ''),
- };
- };
- const normalizeGivenReaction = (entry) => {
- const post = entry?.post || {};
- const topic = post.topic || {};
- const user = post.user || {};
- const reactionData = entry?.reaction || {};
- const id = Number(entry?.id) || 0;
- const reaction = String(reactionData.reaction_value || entry?.reaction_value || '');
- const sourcePost = {
- id: entry?.post_id || post.id,
- topic_id: post.topic_id || topic.id || entry?.topic_id,
- post_number: post.post_number || entry?.post_number,
- topic_title: post.topic_title || topic.title || entry?.topic_title,
- username: post.username || user.username,
- avatar_template: post.avatar_template || user.avatar_template,
- };
- if (!id || !Number(sourcePost.topic_id) || !Number(sourcePost.id) || !reaction) return null;
- return givenReactionRecord(sourcePost, null, reaction, id, entry?.created_at || reactionData.created_at);
- };
- const normalizeGivenLike = (entry) => {
- const topicId = Number(entry?.topic_id) || 0;
- const postId = Number(entry?.post_id) || 0;
- if (Number(entry?.action_type) !== 1 || !topicId || !postId) return null;
- return givenReactionRecord({ ...entry, id: postId }, null, 'heart', entry.id, entry.created_at, entry.title);
- };
- const givenReactionEntriesByPostId = (entries) => new Map((Array.isArray(entries) ? entries : [])
- .map((entry) => [Number(entry?.postId), entry])
- .filter(([postId]) => postId > 0));
- const mergeGivenReactionOverrides = (entries) => {
- const entriesByPostId = givenReactionEntriesByPostId(entries);
- givenReactionOverrides.forEach((override, postId) => {
- const serverEntry = entriesByPostId.get(postId);
- if (override && Number(serverEntry?.id) > 0 && serverEntry.reaction === override.reaction) {
- givenReactionOverrides.delete(postId);
- return;
- }
- if (!override && !serverEntry) {
- givenReactionOverrides.delete(postId);
- return;
- }
- entriesByPostId.delete(postId);
- if (override) entriesByPostId.set(postId, override);
- });
- return [...entriesByPostId.values()].sort(compareGivenReactionRecords);
- };
- const confirmedGivenReactionRecord = (post, topic, reaction) => {
- const postId = Number(post?.id) || 0;
- return givenReactionRecord(post, topic, reaction, -postId, new Date().toISOString());
- };
- const rememberConfirmedGivenReaction = (post, topic) => {
- const postId = Number(post?.id) || 0;
- if (!postId) return false;
- const current = post?.current_user_reaction;
- const reaction = String(current?.id || '');
- givenReactionOverrides.set(postId, reaction ? confirmedGivenReactionRecord(post, topic, reaction) : null);
- return true;
- };
- const applyConfirmedGivenLike = (entries, post, topic, acted) => {
- const postId = Number(post?.id) || 0;
- if (!postId) return Array.isArray(entries) ? entries : [];
- const entriesByPostId = givenReactionEntriesByPostId(entries);
- const current = entriesByPostId.get(postId);
- if (acted && (!current || current.reaction === 'heart'))
- entriesByPostId.set(postId, confirmedGivenReactionRecord(post, topic, 'heart'));
- else if (!acted && current && current.reaction === 'heart')
- entriesByPostId.delete(postId);
- return [...entriesByPostId.values()].sort(compareGivenReactionRecords);
- };
- const fetchGivenReactionPage = (username, beforeReactionUserId = 0) => {
- const reactionModule = lookupDiscourseModule(
- 'discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction'
- );
- const CustomReaction = reactionModule?.default;
- if (!CustomReaction || !isFunction(CustomReaction.findReactions))
- return Promise.reject(new Error('Discourse 回应记录接口尚未就绪'));
- return CustomReaction.findReactions('reactions', username, {
- beforeReactionUserId: beforeReactionUserId > 0 ? beforeReactionUserId : undefined,
- });
- };
- const collectPagedEntries = async (loadPage, pageRecords, normalize, identity, isCurrent) => {
- const entriesById = new Map();
- const seenCursors = new Set();
- let cursor = 0;
- while (true) {
- const result = await loadPage(cursor);
- if (!isCurrent()) return null;
- const [records, nextCursor] = pageRecords(result, cursor);
- records.forEach((record) => {
- const entry = normalize(record);
- if (entry) entriesById.set(identity(entry), entry);
- });
- if (nextCursor == null || seenCursors.has(nextCursor)) break;
- seenCursors.add(nextCursor);
- cursor = nextCursor;
- }
- return [...entriesById.values()];
- };
- const reactionCollectionIsCurrent = (token) => token === bookmarksState.token &&
- !bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction';
- const fetchAllGivenReactions = (username, token = bookmarksState.token) => collectPagedEntries(
- (cursor) => fetchGivenReactionPage(username, cursor),
- (result) => {
- const records = reactionPageEntries(result);
- const next = records.length >= GIVEN_REACTIONS_PAGE_SIZE && records.reduce((lowest, entry) => {
- const id = Number(entry?.id) || 0;
- return id > 0 && (!lowest || id < lowest) ? id : lowest;
- }, 0);
- return [records, next || null];
- },
- normalizeGivenReaction, (entry) => entry.id, () => reactionCollectionIsCurrent(token)
- );
- const fetchGivenLikePage = (username, offset = 0) => {
- const params = new URLSearchParams({
- username,
- filter: '1',
- offset: String(Math.max(0, Number(offset) || 0)),
- limit: String(GIVEN_LIKES_PAGE_SIZE),
- });
- return fetchJSON(`${BASE}/user_actions.json?${params}`, {
- key: `likes-given:${username}:${offset}`,
- priority: POST_REQUEST_PRIORITY.auxiliary,
- cacheMode: 'default',
- });
- };
- const fetchAllGivenLikes = (username, token = bookmarksState.token) => collectPagedEntries(
- (offset) => fetchGivenLikePage(username, offset),
- (result, offset) => {
- const records = Array.isArray(result?.user_actions) ? result.user_actions : [];
- return [records, records.length < GIVEN_LIKES_PAGE_SIZE ? null : offset + records.length];
- },
- normalizeGivenLike, (entry) => entry.postId, () => reactionCollectionIsCurrent(token)
- );
- const reactionRecordEntries = () => {
- const needle = normalizeSearchText(bookmarksState.query);
- return bookmarksState.reactionEntries.filter((entry) =>
- (!bookmarksState.reactionFilter || entry.reaction === bookmarksState.reactionFilter) &&
- reactionRecordMatchesSearch(entry, needle));
- };
- const reactionAvatarHtml = (entry) => userCardAvatarHtml(
- persistentAvatarHtml(
- entry?.avatarTemplate,
- 64,
- 'ldp-notification-avatar',
- entry?.authorUsername || '?'
- ) || `
${esc(String(entry?.authorUsername || '?').slice(0, 1).toUpperCase())}`,
- entry?.authorUsername
- );
- const bookmarkTypeEntries = () => bookmarksState.type === 'Reaction'
- ? reactionRecordEntries()
- : bookmarksState.allEntries.filter((entry) => entry && entry.bookmarkable_type === bookmarksState.type);
- const renderBookmarksPage = () => {
- const isReaction = bookmarksState.type === 'Reaction';
- const reactionTypeCounts = new Map();
- if (isReaction) bookmarksState.reactionEntries.forEach((entry) => {
- const reaction = String(entry?.reaction || '');
- if (reaction) reactionTypeCounts.set(reaction, (reactionTypeCounts.get(reaction) || 0) + 1);
- });
- const availableReactionTypes = [...reactionTypeCounts.keys()];
- if (bookmarksState.reactionFilter && !availableReactionTypes.includes(bookmarksState.reactionFilter)) {
- bookmarksState.reactionFilter = '';
- }
- bookmarksReactionFilters.hidden = !isReaction || availableReactionTypes.length === 0;
- bookmarksReactionFilters.innerHTML = isReaction && availableReactionTypes.length
- ? [`
`,
- ...availableReactionTypes.map((reaction) => {
- const active = reaction === bookmarksState.reactionFilter;
- const reactionIcon = reactionLabel(reaction) || esc(`:${reaction}:`);
- const count = reactionTypeCounts.get(reaction) || 0;
- return `
`;
- })].join('')
- : '';
- const typeEntries = bookmarkTypeEntries();
- const { entries, totalPages } =
- localCollectionPage(typeEntries, bookmarksState, BOOKMARKS_PAGE_SIZE);
- bookmarksState.entries = entries;
- bookmarksState.hasNext = bookmarksState.page < totalPages - 1;
- if (bookmarksSelection.size) {
- const availableIds = new Set(isReaction ? [] : typeEntries.map((entry) => Number(entry.id)));
- bookmarksSelection.forEach((bookmarkId) => {
- if (!availableIds.has(bookmarkId)) bookmarksSelection.delete(bookmarkId);
- });
- }
- releasePersistentAvatarImages(bookmarksPanel.list);
- if (isReaction) {
- bookmarksPanel.list.innerHTML = entries.length
- ? entries.map((entry) => {
- const reaction = String(entry.reaction || '');
- const reactionIcon = reactionLabel(reaction) || esc(`:${reaction}:`);
- const authorLabel = entry.authorUsername ? ` · @${entry.authorUsername}` : '';
- const topicId = Number(entry.topicId);
- const postNumber = Math.max(1, Number(entry.postNumber) || 1);
- return `
- ${collectionLinkMarkup('ldp-bookmark-link', topicPageUrl(topicId, postNumber, true),
- reactionAvatarHtml(entry), entry.title || `帖子 #${entry.topicId}`,
- `${reactionIcon}${esc(`回应 · 楼层 #${postNumber}${authorLabel} · ${discourseRelativeTime(entry.reactedAt)}`)}`, true)}
-
`;
- }).join('')
- : `
${bookmarksState.query
- ? '没有匹配的回应记录'
- : '暂无回应记录;在楼层下方点回应后会出现在这里。'}
`;
- } else {
- bookmarksPanel.list.innerHTML = entries.length
- ? entries.map((entry) => {
- const bookmarkId = Number(entry.id);
- const target = bookmarkTarget(entry);
- const selected = bookmarksSelection.has(bookmarkId);
- const typeLabel = entry.bookmarkable_type === 'Post' ? `楼层 #${target.postNumber}` : '帖子';
- const postsLabel = Number(entry.highest_post_number) > 0 ? ` · ${Number(entry.highest_post_number)} 帖` : '';
- const nameLabel = String(entry.name || '').trim() ? ` · ${String(entry.name).trim()}` : '';
- return `
- ${bookmarksState.multi ? `
-
- ` : ''}
- ${collectionLinkMarkup('ldp-bookmark-link', topicPageUrl(target.topicId, target.postNumber, true),
- bookmarkAvatarHtml(entry), entry.title || `帖子 #${target.topicId}`,
- `${typeLabel}${postsLabel}${nameLabel} · ${discourseRelativeTime(entry.created_at)}`)}
- ${bookmarksState.multi ? '' : ``}
-
`;
- }).join('')
- : `
${bookmarksState.query
- ? `没有匹配的${bookmarksState.type === 'Post' ? '楼层' : '帖子'}收藏`
- : `暂无${bookmarksState.type === 'Post' ? '楼层' : '帖子'}收藏`}
`;
- }
- hydratePersistentAvatarImages(bookmarksPanel.list, POST_REQUEST_PRIORITY.auxiliary);
- syncTabSelection(bookmarksTabs, 'bookmarkType', bookmarksState.type);
- bookmarksPanel.search.placeholder = isReaction ? '搜索回应、帖子或用户' : '搜索收藏标题或内容';
- setLabel(bookmarksPanel.search, isReaction ? '搜索回应记录' : '搜索收藏');
- setLabel(bookmarksPanel.searchClear, isReaction ? '清空回应搜索' : '清空收藏搜索');
- const currentIds = isReaction ? [] : entries.map((entry) => Number(entry.id)).filter((id) => id > 0);
- const scopeIds = bookmarksState.scope === 'all' ? bookmarksState.allIds : currentIds;
- syncCollectionSelectionControls(
- bookmarksPanel, bookmarksState, bookmarksSelection, scopeIds, '收藏', typeEntries.length === 0,
- bookmarksState.scope === 'page' && currentIds.length === 0, isReaction
- );
- bookmarksCollection.paging(totalPages, typeEntries.length > 0);
- positionBookmarksPopover();
- };
- const fetchAllBookmarkEntries = (query = bookmarksState.query, token = bookmarksState.token) => collectPagedEntries(
- async (page) => {
- const username = syncCurrentUserFromHost();
- if (!username) throw new Error('登录后才能查看收藏');
- const params = new URLSearchParams({ page: String(Math.max(0, Number(page) || 0)) });
- if (query) params.set('q', query);
- return fetchJSON(`${BASE}/u/${encodeURIComponent(username)}/bookmarks.json?${params}`, {
- key: `bookmarks:${page}:${query}`,
- priority: POST_REQUEST_PRIORITY.auxiliary,
- cacheMode: 'default',
- });
- },
- (result, page) => {
- const payload = result?.user_bookmark_list || result || {};
- return [Array.isArray(payload.bookmarks) ? payload.bookmarks : [], payload.more_bookmarks_url ? page + 1 : null];
- },
- (entry) => Number(entry?.id) > 0 ? entry : null,
- (entry) => Number(entry.id),
- () => token === bookmarksState.token && !bookmarksPanel.popover.hidden
- );
- const loadBookmarksPage = async () => {
- const token = ++bookmarksState.token;
- const isReaction = bookmarksState.type === 'Reaction';
- let reactionSnapshotVisible = isReaction && bookmarksState.reactionLoadedAt > 0 &&
- bookmarksState.reactionUsername === ME_USERNAME;
- let reactionIconHydration = null;
- const hydrateReactionIcons = () => {
- if (reactionEmojiUrls || reactionIconHydration) return;
- reactionIconHydration = loadReactionEmojiRegistry().then(() => {
- if (reactionSnapshotVisible && token === bookmarksState.token &&
- !bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction') renderBookmarksPage();
- }).finally(() => { reactionIconHydration = null; });
- };
- if (reactionSnapshotVisible) {
- renderBookmarksPage();
- } else {
- bookmarksCollection.message(`正在加载${isReaction ? '回应' : '收藏'}…`);
- }
- try {
- if (isReaction) {
- const username = syncCurrentUserFromHost();
- if (!username) throw new Error('登录后才能查看回应记录');
- if (bookmarksState.reactionUsername && bookmarksState.reactionUsername !== username) {
- bookmarksState.reactionEntries = [];
- bookmarksState.reactionLoadedAt = 0;
- reactionSnapshotVisible = false;
- }
- if (!reactionSnapshotVisible && bookmarksState.reactionLoadedAt > 0 &&
- bookmarksState.reactionUsername === username) {
- reactionSnapshotVisible = true;
- renderBookmarksPage();
- }
- if (!reactionSnapshotVisible) {
- const snapshot = await getGivenReactionSnapshot(username);
- if (token !== bookmarksState.token || bookmarksPanel.popover.hidden || bookmarksState.type !== 'Reaction') return;
- if (snapshot) {
- bookmarksState.reactionEntries = mergeGivenReactionOverrides(snapshot.entries);
- bookmarksState.reactionLoadedAt = snapshot.storedAt;
- bookmarksState.reactionUsername = username;
- reactionSnapshotVisible = true;
- renderBookmarksPage();
- hydrateReactionIcons();
- if (snapshot.fresh) return;
- }
- } else {
- hydrateReactionIcons();
- if (Date.now() - bookmarksState.reactionLoadedAt <= MINUTE) return;
- }
- const [reactionEntries, likeEntries] = await Promise.all([
- fetchAllGivenReactions(username, token),
- fetchAllGivenLikes(username, token),
- ]);
- if (!reactionEntries || !likeEntries || token !== bookmarksState.token || bookmarksPanel.popover.hidden || bookmarksState.type !== 'Reaction') return;
- bookmarksState.reactionEntries = mergeGivenReactionOverrides([...likeEntries, ...reactionEntries]);
- bookmarksState.reactionLoadedAt = Date.now();
- bookmarksState.reactionUsername = username;
- reactionSnapshotVisible = true;
- setGivenReactionSnapshot(username, bookmarksState.reactionEntries);
- bookmarksState.multi = false;
- clearBookmarksSelectionScope();
- renderBookmarksPage();
- hydrateReactionIcons();
- return;
- }
- const entries = await fetchAllBookmarkEntries(bookmarksState.query, token);
- if (!entries || token !== bookmarksState.token || bookmarksPanel.popover.hidden) return;
- bookmarksState.allEntries = entries;
- bookmarksState.allIds = null;
- renderBookmarksPage();
- } catch (error) {
- if (token !== bookmarksState.token || bookmarksPanel.popover.hidden || error && error.name === 'AbortError') return;
- if (isReaction && reactionSnapshotVisible) return;
- bookmarksState.entries = [];
- if (isReaction) bookmarksState.reactionEntries = [];
- else bookmarksState.allEntries = [];
- bookmarksState.hasNext = false;
- bookmarksCollection.message(`${isReaction ? '回应记录' : '收藏'}加载失败:${esc(error.message)}`, true);
- bookmarksPanel.pageInfo.textContent = `第 ${bookmarksState.page + 1} 页`;
- }
- };
- const applyRemovedBookmarkState = (entry) => {
- const activeCtx = readerShell.activeContext;
- const target = bookmarkTarget(entry);
- if (!activeCtx || Number(activeCtx.topicId) !== target.topicId) return;
- const type = entry && entry.bookmarkable_type === 'Post' ? 'Post' : 'Topic';
- syncReaderBookmarkState(activeCtx, type, entry?.bookmarkable_id, false, null, { persist: true });
- };
- const syncNotificationModeUi = () => {
- const group = NOTIFICATION_GROUPS[notificationState.group] || NOTIFICATION_GROUPS.all;
- const mode = notificationGroupMode(notificationState.group);
- syncTabSelection(notificationModeTabs, 'notificationMode', mode);
- notificationModePanels.forEach((panel) =>
- setHidden(panel, panel.dataset.notificationModePanel !== mode)
- );
- setHidden(notificationPanel.toolbar, group.source !== 'notifications' && mode !== 'messages');
- setHidden(notificationPanel.unreadStatus, group.source !== 'notifications');
- setHidden(notificationPanel.markAll, group.source !== 'notifications');
- setHidden(notificationPanel.newMessage, mode !== 'messages');
- };
- const renderNotificationPage = (result, options = {}) => {
- const visibleNotifications = result.notifications;
- notificationState.total = result.total;
- notificationState.hasNext = result.hasNext;
- releasePersistentAvatarImages(notificationPanel.list);
- notificationPanel.list.innerHTML = visibleNotifications.length
- ? renderNotificationList(visibleNotifications)
- : `
${options.search ? '本地缓存中没有匹配消息' : '暂无消息'}
`;
- hydrateNotificationAvatars(notificationPanel.list, visibleNotifications);
- if (NOTIFICATION_GROUPS[notificationState.group]?.source === 'notifications' &&
- notificationPanel.markAll.dataset.ldpRequestBusy !== '1')
- notificationPanel.markAll.disabled = cachedUnreadNotificationCount(notificationState.group) <= 0;
- const totalPages = Math.max(
- 1,
- Math.ceil(notificationState.total / notificationGroupPageSize(notificationState.group))
- );
- notificationCollection.paging(totalPages, notificationState.total > 0);
- if (!notificationState.total && options.search) notificationPanel.pageInfo.textContent = '本地缓存';
- positionNotificationsPopover();
- };
- const renderNotificationSearchPage = () => {
- const needle = normalizeSearchText(notificationState.query);
- const matches = cachedNotificationsForGroup(notificationState.group)
- .filter((notification) => notificationMatchesSearch(notification, needle));
- const pageSize = notificationGroupPageSize(notificationState.group);
- const { entries, totalPages } =
- localCollectionPage(matches, notificationState, pageSize);
- renderNotificationPage({
- notifications: entries,
- total: matches.length,
- hasNext: notificationState.page < totalPages - 1,
- }, { search: true });
- };
- const loadNotificationPage = async (groupKey, page, options = {}) => {
- const token = ++notificationState.token;
- const force = options.force === true;
- notificationState.group = Object.hasOwn(NOTIFICATION_GROUPS, groupKey) ? groupKey : 'all';
- notificationState.groups[notificationGroupMode(notificationState.group)] = notificationState.group;
- notificationState.page = Math.max(0, Number(page) || 0);
- notificationPanel.pageInfo.removeAttribute('title');
- syncNotificationModeUi();
- syncTabSelection(notificationTabs, 'notificationGroup', notificationState.group);
- if (notificationState.query) {
- if (notificationQueuedRequestKey) currentReaderRequestScheduler().cancelQueued(notificationQueuedRequestKey);
- notificationQueuedRequestKey = '';
- renderNotificationSearchPage();
- return;
- }
- const cacheKey = notificationPageCacheKey(notificationState.group, notificationState.page);
- const requestKey = `notifications:${cacheKey}`;
- if (notificationQueuedRequestKey && notificationQueuedRequestKey !== requestKey)
- currentReaderRequestScheduler().cancelQueued(notificationQueuedRequestKey);
- const cached = getCachedNotificationPage(cacheKey);
- const cachedEntry = cached && NOTIFICATION_CACHE.get(cacheKey);
- const cachedIsFresh = cachedEntry &&
- Date.now() - Number(cachedEntry.at || 0) <= NOTIFICATION_CACHE_FRESH_FOR;
- if (cached) {
- renderNotificationPage(cached);
- if (cachedIsFresh && !force) {
- notificationQueuedRequestKey = '';
- return;
- }
- }
- notificationQueuedRequestKey = requestKey;
- if (!cached)
- notificationCollection.message('正在加载消息…');
- try {
- const result = await fetchNotificationPage(notificationState.group, notificationState.page, {
- force: force || !!cached,
- });
- if (token !== notificationState.token || notificationsPopover.hidden) return;
- renderNotificationPage(result);
- } catch (error) {
- if (error && error.name === 'AbortError') return;
- if (token !== notificationState.token || notificationsPopover.hidden) return;
- if (cached) {
- notificationPanel.pageInfo.textContent = `${notificationState.page + 1} 页 · 缓存更新失败`;
- notificationPanel.pageInfo.title = String(error?.message || '无法更新缓存');
- return;
- }
- notificationCollection.message(`消息加载失败:${esc(error.message)}`, true);
- notificationPanel.pageInfo.textContent = `第 ${notificationState.page + 1} 页`;
- } finally {
- if (notificationQueuedRequestKey === requestKey) notificationQueuedRequestKey = '';
- }
- };
- let notificationLiveAppEvents = null;
- let notificationLiveHandler = null;
- let notificationLiveRefreshTimer = 0;
- const stopNotificationLiveEvents = () => {
- if (notificationLiveRefreshTimer) clearTimeout(notificationLiveRefreshTimer);
- notificationLiveRefreshTimer = 0;
- if (notificationLiveAppEvents && notificationLiveHandler &&
- isFunction(notificationLiveAppEvents.off)) {
- try {
- notificationLiveAppEvents.off('notifications:changed', readerShell, notificationLiveHandler);
- } catch {}
- }
- notificationLiveAppEvents = null;
- notificationLiveHandler = null;
- };
- const scheduleVisibleNotificationRefresh = () => {
- if (notificationsPopover.hidden || notificationLiveRefreshTimer) return;
- notificationLiveRefreshTimer = setTimeout(() => {
- notificationLiveRefreshTimer = 0;
- if (notificationsPopover.hidden) return;
- loadNotificationPage(notificationState.group, notificationState.page, { force: true });
- }, NOTIFICATION_LIVE_REFRESH_DEBOUNCE);
- };
- const applyNotificationLiveEvent = () => {
- syncCurrentUserFromHost();
- expireNotificationFirstPageCaches();
- scheduleVisibleNotificationRefresh();
- };
- const startNotificationLiveEvents = () => {
- if (notificationLiveAppEvents && notificationLiveHandler) return true;
- stopNotificationLiveEvents();
- const appEvents = lookupDiscourse('service:app-events');
- if (!appEvents || !isFunction(appEvents.on)) return false;
- const handler = () => applyNotificationLiveEvent();
- try {
- appEvents.on('notifications:changed', readerShell, handler);
- notificationLiveAppEvents = appEvents;
- notificationLiveHandler = handler;
- return true;
- } catch {
- stopNotificationLiveEvents();
- return false;
- }
- };
- if (!reusingShell) {
- readerShell.startNotificationLiveEvents = startNotificationLiveEvents;
- readerShell.stopNotificationLiveEvents = stopNotificationLiveEvents;
- onShell(document, 'visibilitychange', () => {
- if (document.visibilityState !== 'visible') return;
- syncCurrentUserFromHost();
- readerShell.startNotificationLiveEvents?.();
- expireNotificationFirstPageCaches();
- if (!notificationsPopover.hidden) {
- loadNotificationPage(notificationState.group, notificationState.page, { force: true });
- }
- });
- }
-
- const syncReaderBackgroundMode = () => {
- setPageScrollLocked(readerWorkspace.isFullPage() || window.innerWidth <= READER_WINDOW_COMPACT_WIDTH);
- };
- let layoutDraftFullPage = readerWorkspace.isFullPage();
- let layoutDraft = currentLayoutRatios(layoutDraftFullPage);
- let fontSettingsScope = 'interface';
- let fontDraft = currentFontProfile();
- let appearanceDraft = currentAppearanceProfile();
- let jumpHighlightDraft = jumpHighlightConfigFromPrefs(PREFS);
- const currentFontDraft = () => fontDraft;
- const setCurrentFontDraft = (values) => { fontDraft = normalizeFontProfile(values, READER_FONT_DEFAULT); };
- const currentFontDefaults = () => normalizeFontProfile(READER_FONT_DEFAULT);
- const currentLayoutProfileKey = () => layoutDraftFullPage
- ? 'fullpageLayoutProfile'
- : 'layoutProfile';
- const currentLayoutProfileDefaults = () => layoutDraftFullPage
- ? READER_FULLPAGE_LAYOUT_DEFAULT
- : READER_LAYOUT_DEFAULT;
- const currentSavedLayoutRatios = () => currentLayoutRatios(layoutDraftFullPage);
- const syncLayoutDraftMode = (force = false) => {
- const fullPage = readerWorkspace.isFullPage();
- if (!force && layoutDraftFullPage === fullPage) return;
- layoutDraftFullPage = fullPage;
- layoutDraft = currentLayoutRatios(fullPage);
- };
- const currentLayoutDraft = () => layoutDraft;
- const setCurrentLayoutDraft = (values) => {
- layoutDraft = normalizeLayoutRatios(values, currentLayoutProfileDefaults());
- };
- const currentLayoutDefaults = () => normalizeLayoutRatios(currentLayoutProfileDefaults());
- const currentAppearanceDraft = () => appearanceDraft;
- const setCurrentAppearanceDraft = (values) => {
- appearanceDraft = normalizeAppearanceProfile(values, APPEARANCE_PROFILE_DEFAULT);
- };
- const currentAppearanceDefaults = () => normalizeAppearanceProfile(APPEARANCE_PROFILE_DEFAULT);
- const applyReaderVisualSettings = () => {
- applyImageScale();
- applyLayoutRatios();
- applyFontScales();
- applyAppearanceColors();
- };
- const syncOpenSettingsControls = (includeWindowControls = false) => {
- if (settingsPopover.hidden) return;
- syncImageScaleControls();
- syncLayoutControls();
- syncFontControls();
- syncAppearanceControls();
- if (includeWindowControls) syncReaderWindowControls();
- };
- const syncLayoutBtn = () => {
- const fullPage = readerWorkspace.isFullPage();
- layoutBtn.hidden = !directTopicRoute && !fullPage;
- layoutBtn.innerHTML = `${icon(fullPage ? 'minimize2' : 'maximize2')}
${fullPage ? '浮窗' : '全页'}`;
- setLabel(layoutBtn, fullPage ? '切换到浮窗' : '切换到全页');
- readerWindow.syncMode();
- syncReaderBackgroundMode();
- };
- const onReaderWorkspaceChange = () => {
- syncLayoutBtn();
- syncReaderPlacementUi();
- syncLayoutDraftMode();
- if (readerShell.syncSettingsSurfaceGeometry) readerShell.syncSettingsSurfaceGeometry();
- else syncReaderSurfaceDensity();
- syncReaderThemeSurfaces();
- applyReaderVisualSettings();
- const activeContext = readerShell.activeContext;
- if (activeContext?.nativeComposerWindow) activeContext.nativeComposerWindow.syncLayer();
- positionHeaderPopovers();
- syncOpenSettingsControls(true);
- requestAnimationFrame(() => {
- readerShell.syncFullPageHeaderAlignment?.();
- overlay._ldpSyncTopicActionRailPosition?.();
- });
- };
- if (!reusingShell) {
- syncLayoutBtn();
- onShell(overlay, 'ldp-reader-workspace-change', onReaderWorkspaceChange);
- onShell(layoutBtn, 'click', () => {
- if (!directTopicRoute && !readerWorkspace.isFullPage()) return;
- readerWorkspace.setMode(readerWorkspace.isFullPage() ? 'floating' : 'fullpage');
- syncLayoutBtn();
- syncOpenSettingsControls();
- applyReaderVisualSettings();
- positionHeaderPopovers();
- const activeContext = readerShell.activeContext;
- if (!activeContext) return;
- activeContext.streamListOffset = NaN;
- activeContext.streamNeedsRepair = true;
- scheduleStreamWindowSync(activeContext);
- });
- }
-
- const applyImageScale = () => {
- overlay.style.setProperty('--ldp-image-zoom', String(currentImageScalePercent() / 100));
- };
- const fontSettingsSection = readerElement('[data-settings-panel="font"]');
- const layoutSettingsSection = readerElement('[data-settings-panel="layout"]');
- const layoutSettingsTitle = layoutSettingsSection.querySelector('.ldp-settings-title');
- const layoutSettingsDescription = layoutSettingsSection.querySelector('.ldp-settings-description');
- const appearanceSettingsSection = readerElement('[data-settings-panel="appearance"]');
- const appearanceValuesChanged = () => {
- const values = currentAppearanceDraft();
- const saved = currentAppearanceProfile();
- return APPEARANCE_PROFILE_VALUE_KEYS.some((key) => values[key] !== saved[key]);
- };
- const activeAppearanceColors = () => {
- const previewing = !settingsPopover.hidden && !appearanceSettingsSection.hidden;
- return previewing ? currentAppearanceDraft() : currentAppearanceProfile();
- };
- const activeFontScaleProfile = () => {
- const previewing = !settingsPopover.hidden && !fontSettingsSection.hidden;
- return previewing ? currentFontDraft() : currentFontProfile();
- };
- const applyFontScales = () => {
- const values = activeFontScaleProfile();
- const displayScale = Math.min(1.1, Math.max(1, .73 + modal.clientWidth / 4000));
- const headerWidthProgress = Math.min(1, Math.max(0,
- (modal.clientWidth - READER_WINDOW_MIN_WIDTH) /
- (READER_WINDOW_DEFAULT_WIDTH - READER_WINDOW_MIN_WIDTH)));
- const interfaceScale = normalizeFontScale(values.interface) / 100;
- const headerFontPixels = (minimum, maximum) =>
- `${Math.round((minimum + (maximum - minimum) * headerWidthProgress) * interfaceScale * 10) / 10}px`;
- const scaledValues = { ...values, interface: values.interface * displayScale, post: values.post * displayScale, composer: values.composer * displayScale };
- overlay.style.setProperty('--ldp-reader-display-scale', String(displayScale));
- overlay.style.setProperty('--ldp-reader-title-font-size',
- headerFontPixels(READER_TITLE_MIN_BASE_PX, INTERFACE_FONT_TOKEN_BASES['--ldp-font-xl']));
- overlay.style.setProperty('--ldp-reader-meta-font-size',
- headerFontPixels(READER_META_MIN_BASE_PX, INTERFACE_FONT_TOKEN_BASES['--ldp-font-sm']));
- overlay.style.setProperty('--ldp-reader-topic-tag-font-size',
- headerFontPixels(READER_TOPIC_TAG_MIN_BASE_PX, INTERFACE_FONT_TOKEN_BASES['--ldp-font-sm']));
- applyInterfaceFontScale(overlay.style, scaledValues.interface);
- applyInterfaceFontScale(READER_PORTAL_HOST?.style, scaledValues.interface);
- applyReaderFontAppearance(overlay.style, scaledValues);
- applyReaderFontAppearance(READER_PORTAL_HOST?.style, scaledValues);
- applyReaderFontAppearance(PAGE_ROOT.style, scaledValues);
- const postFontSize = fontScalePixels(POST_FONT_BASE_PX, scaledValues.post);
- overlay.style.setProperty('--ldp-post-font-size', postFontSize);
- READER_PORTAL_HOST?.style.setProperty('--ldp-post-font-size', postFontSize);
- applyComposerFontScale(scaledValues.composer);
- const activeContext = readerShell.activeContext;
- if (activeContext?.nativeComposerWindow)
- activeContext.nativeComposerWindow.syncFont(scaledValues);
- readerShell.syncReaderTitleDensity?.();
- readerShell.syncReaderTitleActionAlignment?.();
- };
- const applyLayoutRatios = () => {
- const fullPage = readerWorkspace.isFullPage();
- const previewing = !settingsPopover.hidden && !layoutSettingsSection.hidden &&
- layoutDraftFullPage === fullPage;
- const ratios = previewing ? currentLayoutDraft() : currentLayoutRatios(fullPage);
- LAYOUT_REGION_KEYS.forEach((name) => {
- overlay.style.setProperty(`--ldp-layout-${name}`, `${ratios[name]}%`);
- });
- requestAnimationFrame(() => readerShell.syncFullPageHeaderAlignment?.());
- };
- const applyInteractionColors = () => {
- const surfaces = new Set([READER_PORTAL_HOST, overlay]);
- [...READER_PORTAL_CONTEXT_BODY?.children || []].forEach((node) => surfaces.add(node));
- document.querySelectorAll('[data-ldp-reader-composer-root]').forEach((node) => surfaces.add(node));
- surfaces.forEach((node) => applyAppearanceInteractionColors(node, activeAppearanceColors()));
- };
- const applyZebraAppearance = () => {
- const values = activeAppearanceColors();
- overlay.style.setProperty('--ldp-zebra-color', appearanceThemeColor(values, 'zebraColor'));
- overlay.style.setProperty('--ldp-zebra-radius', `${values.zebraRadius}px`);
- };
- const applyListZebraColor = () => {
- const value = appearanceThemeColor(activeAppearanceColors(), 'listZebraColor');
- if (!readerWorkspace.isEmbedded()) {
- PAGE_ROOT.style.removeProperty('--ldp-reader-list-zebra-color');
- return;
- }
- PAGE_ROOT.style.setProperty('--ldp-reader-list-zebra-color', value);
- };
- const applyLineAppearance = (type, emphasis) => {
- const values = activeAppearanceColors();
- const key = `${type}Line`;
- const property = `--ldp-${type}-line`;
- const colorKey = `${key}Color`;
- const value = appearanceThemeColor(values, colorKey);
- const defaultValue = APPEARANCE_PROFILE_DEFAULT[appearanceThemeValueKey(colorKey)];
- const width = values[`${key}Width`];
- const enabled = values.structureColorsEnabled !== false;
- overlay.style.setProperty(`${property}-color`, enabled ? value : 'transparent');
- overlay.style.setProperty(`${property}-width`, `${width}px`);
- overlay.style.setProperty(`${property}-emphasis-width`, `${width * emphasis}px`);
- if (type === 'reply') {
- overlay.style.setProperty(`${property}-secondary-width`, `${width + 0.5}px`);
- overlay.style.setProperty(`${property}-radius`, `${values.replyLineRadius}px`);
- readerElements('.ldp-children').forEach(syncNestedReplyBranches);
- const activeContext = readerShell.activeContext;
- if (activeContext?.floorPreview && activeContext.floorPreview.isOpen()) {
- scheduleFloorPreviewSync(activeContext);
- }
- return;
- }
- if (type !== 'divider') return;
- const embeddedStructureColorsDisabled = readerWorkspace.isEmbedded() && !enabled;
- PAGE_ROOT.classList.toggle('ldp-structure-colors-disabled', embeddedStructureColorsDisabled);
- if (!readerWorkspace.isEmbedded() || !enabled || value === defaultValue) {
- PAGE_ROOT.style.removeProperty('--ldp-divider-line-color');
- } else {
- PAGE_ROOT.style.setProperty('--ldp-divider-line-color', value);
- }
- if (!readerWorkspace.isEmbedded() || width === DIVIDER_LINE_WIDTH_DEFAULT) {
- PAGE_ROOT.style.removeProperty('--ldp-divider-line-width');
- } else {
- PAGE_ROOT.style.setProperty('--ldp-divider-line-width', `${width}px`);
- }
- };
- const applyReplyLineAppearance = () => applyLineAppearance('reply', 2);
- const applyQuoteLineAppearance = () => applyLineAppearance('quote', 5);
- const applyDividerLineAppearance = () => applyLineAppearance('divider', 2);
- const applyFloorPreviewAppearance = () => {
- const values = activeAppearanceColors();
- overlay.style.setProperty('--ldp-floor-preview-color',
- appearanceThemeColor(values, 'floorPreviewColor'));
- overlay.style.setProperty('--ldp-floor-preview-height', `${values.floorPreviewHeight}px`);
- const activeContext = readerShell.activeContext;
- if (activeContext?.floorPreview && activeContext.floorPreview.isOpen()) {
- scheduleFloorPreviewSync(activeContext);
- }
- };
- const applyAppearanceColors = () => {
- applyInteractionColors();
- applyZebraAppearance();
- applyListZebraColor();
- applyReplyLineAppearance();
- applyQuoteLineAppearance();
- applyDividerLineAppearance();
- applyFloorPreviewAppearance();
- };
- const applyJumpHighlightSettings = (config = jumpHighlightConfigFromPrefs(PREFS)) => {
- overlay.style.setProperty('--ldp-jump-highlight-color', config.color);
- overlay.style.setProperty('--ldp-jump-highlight-radius', `${config.radius}px`);
- overlay.style.setProperty('--ldp-jump-highlight-border-width', `${config.borderWidth}px`);
- overlay.style.setProperty('--ldp-jump-highlight-duration', `${jumpHighlightDurationMs(config)}ms`);
- overlay.style.setProperty('--ldp-jump-highlight-count', String(config.count));
- };
- overlay._ldpApplyThemeDependentColors = () => {
- applyAppearanceColors();
- if (!settingsPopover.hidden && !appearanceSettingsSection.hidden) syncAppearanceControls();
- };
- const syncRangeVisual = (input) => {
- const min = Number(input.min) || 0;
- const max = Number(input.max) || 100;
- const value = Number(input.value);
- const progress = max > min && Number.isFinite(value)
- ? Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100))
- : 0;
- input.style.setProperty('--ldp-range-progress', `${progress}%`);
- };
- const syncImageScaleControls = () => {
- const scale = currentImageScalePercent();
- imageScaleRange.value = String(scale);
- imageScaleValue.textContent = `${scale}%`;
- syncRangeVisual(imageScaleRange);
- const defaults = IMAGE_PROFILE_DEFAULT;
- const defaultScale = normalizeImageScalePercent(
- defaults.preset === 'custom' ? defaults.custom : defaults.preset,
- 100
- );
- imageScaleReset.disabled = scale === defaultScale;
- lightboxOriginalByDefaultInput.checked = PREFS.lightboxOriginalByDefault === true;
- lightboxCommentsExpandedInput.checked = PREFS.lightboxCommentsExpandedByDefault === true;
- lightboxDescriptionExpandedInput.checked = PREFS.lightboxDescriptionExpanded === true;
- };
- let pageFontFamilies = [];
- let localFontFamilies = [];
- let localFontAccessState = 'idle';
- let localFontAccessRequest = null;
- let activeFontFamilyScope = 'interface';
- const fontFamilyManualEditing = new Set();
- const fontScopeConfig = (scope = activeFontFamilyScope) => scope === 'host'
- ? HOST_FONT_CONFIG
- : FONT_SCOPE_CONFIG[scope] || FONT_SCOPE_CONFIG.interface;
- const fontValuesForScope = (scope = activeFontFamilyScope, readerValues = currentFontDraft()) => scope === 'host'
- ? PREFS
- : readerValues;
- const activeFontFamilyTrigger = () => [...fontFamilyTriggers]
- .find((trigger) => trigger.dataset.fontScope === activeFontFamilyScope);
- const collectAvailableFontFamilies = (fonts, searchFields, fallbackNameField = '') => {
- const entries = new Map();
- [...fonts || []].forEach((font) => {
- const name = normalizeCustomFontFamily(font && (font.family || font[fallbackNameField]));
- if (!name) return;
- const key = name.toLocaleLowerCase();
- const entry = entries.get(key) || { name, search: new Set() };
- searchFields.forEach((field) => {
- if (font?.[field]) entry.search.add(String(font[field]));
- });
- entries.set(key, entry);
- });
- return [...entries.values()].map((entry) => ({
- name: entry.name,
- search: [...entry.search],
- })).sort((left, right) => String(left.name || '').localeCompare(
- String(right.name || ''), 'zh-CN', { sensitivity: 'base', numeric: true }
- ));
- };
- const collectPageFontFamilies = () => {
- try {
- return collectAvailableFontFamilies(
- document.fonts, ['family', 'style', 'weight', 'stretch']
- );
- } catch { return []; }
- };
- const syncFontFamilySelection = (values = currentFontDraft()) => {
- fontFamilyTriggers.forEach((trigger) => {
- const scope = trigger.dataset.fontScope;
- const value = trigger.querySelector('.ldp-font-family-value');
- const config = fontScopeConfig(scope);
- const scopeValues = fontValuesForScope(scope, values);
- const family = normalizeFontFamily(scopeValues[config.family]);
- value.textContent = family !== 'custom'
- ? FONT_FAMILY_OPTIONS[family].label
- : normalizeCustomFontFamily(scopeValues[config.customFamily]) || '手动输入字体名';
- trigger.dataset.ldpTooltipLabel = value.textContent;
- });
- const config = fontScopeConfig();
- const selectedValues = fontValuesForScope(activeFontFamilyScope, values);
- const selectedFamily = normalizeFontFamily(selectedValues[config.family]);
- const selectedName = normalizeSearchText(selectedValues[config.customFamily]);
- fontFamilyOptions.querySelectorAll('.ldp-font-family-option').forEach((option) => {
- const selected = option.dataset.fontKind === 'preset'
- ? selectedFamily === option.dataset.fontValue
- : selectedFamily === 'custom' && normalizeSearchText(option.dataset.fontValue) === selectedName;
- option.setAttribute('aria-selected', String(selected));
- });
- };
- const createFontFamilyOption = ({ kind, value, label, source, stack, search = [] }, values) => {
- const option = makeElement('button');
- const config = fontScopeConfig();
- const selected = kind === 'preset'
- ? normalizeFontFamily(values[config.family]) === value
- : normalizeFontFamily(values[config.family]) === 'custom'
- && normalizeSearchText(values[config.customFamily]) === normalizeSearchText(value);
- option.className = 'ldp-font-family-option ldp-picker-option';
- option.type = 'button';
- option.dataset.fontKind = kind;
- option.dataset.fontValue = value;
- option.dataset.searchText = [label, source, ...search]
- .flatMap((searchValue) => pinyinSearchForms(searchValue)).join(' ');
- option.setAttribute('role', 'option');
- option.setAttribute('aria-selected', String(selected));
- option.innerHTML = `
${esc(label)}${esc(source)}`
- + '
Aa 字';
- option.querySelector('.ldp-font-family-preview').style.fontFamily = stack;
- return option;
- };
- const appendFontFamilyGroup = (fragment, title, items, values) => {
- if (!items.length) return;
- const group = makeElement('div');
- group.className = 'ldp-font-family-group';
- group.setAttribute('role', 'group');
- setLabel(group, title);
- const heading = makeElement('div');
- heading.className = 'ldp-font-family-group-title';
- heading.textContent = `${title} · ${items.length}`;
- group.append(heading);
- items.forEach((item) => group.append(createFontFamilyOption(item, values)));
- fragment.append(group);
- };
- const applyFontFamilySearch = () => {
- const query = normalizeSearchText(fontFamilySearch.value);
- let visibleCount = 0;
- fontFamilyOptions.querySelectorAll('.ldp-font-family-group').forEach((group) => {
- let groupVisible = 0;
- group.querySelectorAll('.ldp-font-family-option').forEach((option) => {
- const visible = !query || String(option.dataset.searchText || '').includes(query);
- option.hidden = !visible;
- if (visible) groupVisible += 1;
- });
- group.hidden = groupVisible === 0;
- visibleCount += groupVisible;
- });
- fontFamilyEmpty.hidden = visibleCount !== 0;
- };
- const renderFontFamilyOptions = () => {
- const values = fontValuesForScope();
- const config = fontScopeConfig();
- const namedItem = (item, source) => ({
- kind: 'named', value: item.name, label: item.name, source,
- stack: `${JSON.stringify(item.name)},${FONT_FAMILY_OPTIONS.system.stack}`, search: item.search,
- });
- const presetItems = Object.entries(FONT_FAMILY_OPTIONS)
- .filter(([value]) => value !== 'custom')
- .map(([value, option]) => ({
- kind: 'preset', value, label: option.label,
- source: value === 'site' ? '站点样式' : value === FONT_FAMILY_DEFAULT ? '默认字体' : '阅读器预设',
- stack: value === 'site' ? resolvedSiteFontFamily() : option.stack, search: [option.stack],
- }));
- const pageItems = pageFontFamilies.map((item) => namedItem(item, '页面已加载'));
- const localItems = localFontFamilies.map((item) => namedItem(item, '本机已安装'));
- const currentName = normalizeCustomFontFamily(values[config.customFamily]);
- const availableNames = new Set([...pageFontFamilies, ...localFontFamilies]
- .map((item) => normalizeSearchText(item.name)));
- const currentItems = normalizeFontFamily(values[config.family]) === 'custom' && currentName
- && !availableNames.has(normalizeSearchText(currentName))
- ? [namedItem({ name: currentName, search: [currentName] }, '当前手动输入')]
- : [];
- const fragment = document.createDocumentFragment();
- appendFontFamilyGroup(fragment, '预设', presetItems, values);
- appendFontFamilyGroup(fragment, '页面已加载', pageItems, values);
- appendFontFamilyGroup(fragment, '本机已安装', localItems, values);
- appendFontFamilyGroup(fragment, '当前选择', currentItems, values);
- fontFamilyOptions.replaceChildren(fragment);
- let statusCopy = '';
- if (localFontAccessState === 'loading') statusCopy = '正在读取浏览器允许访问的本机字体…';
- else if (localFontAccessState === 'unsupported') statusCopy = '当前浏览器未开放本机字体列表;仍可选择页面字体或手动输入。';
- else if (localFontAccessState === 'denied') statusCopy = '未获得本机字体读取权限;仍可选择页面字体或手动输入。';
- else if (localFontAccessState === 'error') statusCopy = '本机字体读取失败;仍可选择页面字体或手动输入。';
- else if (localFontAccessState === 'ready' && !localFontFamilies.length)
- statusCopy = '浏览器没有返回可用的本机字体;仍可手动输入字体名。';
- fontFamilySourceStatus.textContent = statusCopy;
- fontFamilySourceStatus.hidden = !statusCopy;
- applyFontFamilySearch();
- };
- const positionFontFamilyMenu = () => {
- const trigger = activeFontFamilyTrigger();
- if (fontFamilyMenu.hidden || !trigger || !trigger.isConnected) return;
- const margin = 12;
- const gap = 6;
- const bounds = settingsSurfaceBounds();
- const triggerRect = trigger.getBoundingClientRect();
- const width = Math.min(360, Math.max(260, bounds.width - margin * 2));
- const minimumLeft = bounds.left + margin;
- const minimumTop = bounds.top + margin;
- const left = Math.min(
- Math.max(minimumLeft, triggerRect.left),
- Math.max(minimumLeft, bounds.right - width - margin),
- );
- fontFamilyMenu.style.width = `${Math.round(width)}px`;
- fontFamilyMenu.style.top = `${margin}px`;
- const menuHeight = fontFamilyMenu.getBoundingClientRect().height;
- const belowTop = triggerRect.bottom + gap;
- const aboveTop = triggerRect.top - menuHeight - gap;
- const top = belowTop + menuHeight <= bounds.bottom - margin
- ? belowTop
- : aboveTop >= minimumTop
- ? aboveTop
- : Math.max(minimumTop, bounds.bottom - menuHeight - margin);
- const coversTrigger = top <= triggerRect.top && top + menuHeight >= triggerRect.bottom;
- const positionedLeft = coversTrigger ? Math.max(minimumLeft, left - 2) : left;
- fontFamilyMenu.style.left = `${Math.round(positionedLeft - bounds.left)}px`;
- fontFamilyMenu.style.top = `${Math.round(top - bounds.top)}px`;
- };
- const closeFontFamilyMenu = (restoreFocus = false) => {
- if (fontFamilyMenu.hidden) return;
- const trigger = activeFontFamilyTrigger();
- fontFamilyMenu.hidden = true;
- fontFamilyTriggers.forEach((button) => setExpanded(button, false));
- if (restoreFocus && trigger && trigger.isConnected) trigger.focus();
- };
- const requestLocalFontFamilies = () => {
- if (localFontAccessState !== 'idle') return localFontAccessRequest;
- const localFontWindow = isFunction(HOST_PAGE_WINDOW?.queryLocalFonts)
- ? HOST_PAGE_WINDOW : globalThis;
- if (!isFunction(localFontWindow.queryLocalFonts)) {
- localFontAccessState = 'unsupported';
- renderFontFamilyOptions();
- return null;
- }
- const handleLocalFontAccessError = (error) => {
- const denied = error && (error.name === 'NotAllowedError' || error.name === 'SecurityError');
- localFontAccessState = denied ? 'denied' : 'error';
- if (!denied) {
- console.warn('[LDP] local font access failed', {
- name: String(error?.name || 'Error'),
- message: String(error?.message || ''),
- });
- }
- };
- localFontAccessState = 'loading';
- renderFontFamilyOptions();
- try {
- localFontAccessRequest = Promise.resolve(localFontWindow.queryLocalFonts()).then((fonts) => {
- localFontFamilies = collectAvailableFontFamilies(
- fonts, ['family', 'fullName', 'postscriptName', 'style'], 'fullName'
- );
- localFontAccessState = 'ready';
- }).catch((error) => {
- handleLocalFontAccessError(error);
- }).finally(() => {
- localFontAccessRequest = null;
- renderFontFamilyOptions();
- positionFontFamilyMenu();
- });
- } catch (error) {
- handleLocalFontAccessError(error);
- renderFontFamilyOptions();
- }
- return localFontAccessRequest;
- };
- const openFontFamilyMenu = (scope) => {
- if (FONT_PICKER_SCOPE_KEYS.includes(scope)) activeFontFamilyScope = scope;
- fontFamilyTriggers.forEach((button) => setExpanded(button, button.dataset.fontScope === activeFontFamilyScope));
- pageFontFamilies = collectPageFontFamilies();
- fontFamilySearch.value = '';
- renderFontFamilyOptions();
- fontFamilyMenu.hidden = false;
- positionFontFamilyMenu();
- requestLocalFontFamilies();
- fontFamilySearch.focus({ preventScroll: true });
- if (document.fonts?.ready) {
- void Promise.resolve(document.fonts.ready).then(() => {
- pageFontFamilies = collectPageFontFamilies();
- renderFontFamilyOptions();
- positionFontFamilyMenu();
- });
- }
- };
- const fontScaleSettings = [
- { input: interfaceFontScaleRange, output: interfaceFontScaleValue, key: 'interface', normalize: normalizeFontScale },
- { input: fontScaleRange, output: fontScaleValue, key: 'post', normalize: normalizeFontScale },
- { input: composerFontScaleRange, output: composerFontScaleValue, key: 'composer', normalize: normalizeFontScale },
- ];
- const syncFontControls = () => {
- const values = currentFontDraft();
- const savedValues = currentFontProfile();
- const defaultValues = currentFontDefaults();
- const profileLabel = '阅读器';
- hostEmbeddedSizeInputs.forEach((input) => {
- const config = HOST_EMBED_SIZE_SETTINGS[input.dataset.hostEmbedSize];
- if (!config) return;
- const scale = normalizeHostEmbeddedSizeScale(PREFS[config.pref]);
- input.value = String(scale);
- syncRangeVisual(input);
- const output = [...hostEmbeddedSizeValues]
- .find((value) => value.dataset.hostEmbedSizeValue === input.dataset.hostEmbedSize);
- if (output) {
- output.value = `${scale}%`;
- output.textContent = `${scale}%`;
- }
- const detail = input.dataset.hostEmbedSize === 'labelCard'
- ? '卡片高度、文字、SVG/图片图标、间距和横向内边距会按同一比例变化。'
- : input.dataset.hostEmbedSize === 'stats'
- ? '右下角的回复、最近回复、活跃和回应四组信息会一起变化。'
- : '';
- setSettingRowHelp(input, `使用左右嵌入阅读时,调整原站主题列表中${config.label}的大小,默认 ${config.defaultScale}%。${detail}修改后立即保存。`);
- });
- syncTabSelection(fontScopeTabs, 'fontScopeTab', fontSettingsScope, true);
- fontScopePanels.forEach((panel) => {
- panel.hidden = panel.dataset.fontScopePanel !== fontSettingsScope;
- });
- syncFontFamilySelection(values);
- FONT_PICKER_SCOPE_KEYS.forEach((scope) => {
- const host = scope === 'host';
- const scopeValues = host ? PREFS : values;
- const scopeDefaults = host ? HOST_FONT_DEFAULTS : defaultValues;
- const config = fontScopeConfig(scope);
- const customInput = [...fontFamilyCustomInputs].find((input) => input.dataset.fontScope === scope);
- const weightSelect = [...fontWeightSelects].find((select) => select.dataset.fontScope === scope);
- const colorInput = [...fontColorInputs].find((input) => input.dataset.fontScope === scope);
- const colorValue = [...fontColorValues].find((value) => value.dataset.fontScope === scope);
- const color = normalizeHexColor(scopeValues[config.color], scopeDefaults[config.color]);
- if (normalizeFontFamily(scopeValues[config.family]) !== 'custom') fontFamilyManualEditing.delete(scope);
- customInput.value = scopeValues[config.customFamily];
- customInput.hidden = normalizeFontFamily(scopeValues[config.family]) !== 'custom' || !fontFamilyManualEditing.has(scope);
- weightSelect.value = String(scopeValues[config.weight]);
- colorInput.value = color || READER_THEME_VARIABLES[resolvedReaderThemeMode()]['--primary'];
- colorValue.textContent = color ? color.toUpperCase() : '跟随主题';
- const trigger = [...fontFamilyTriggers].find((button) => button.dataset.fontScope === scope);
- if (host) {
- setSettingRowHelp(trigger,
- `选择左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的字体。默认“${FONT_FAMILY_OPTIONS[scopeDefaults[config.family]].label}”;支持页面字体、本机字体和手动输入。修改后立即保存。`);
- setSettingRowHelp(weightSelect,
- `调整左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的字重。默认 ${scopeDefaults[config.weight]} · ${FONT_WEIGHT_LABELS[scopeDefaults[config.weight]]}。修改后立即保存。`);
- setSettingRowHelp(colorInput,
- '调整左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的颜色。默认跟随当前明暗主题。修改后立即保存。');
- } else {
- setSettingRowHelp(trigger,
- `选择${profileLabel}${config.label}文字使用的字体。下拉按预设、页面已加载和本机已安装分组并支持搜索;首次读取本机字体时浏览器可能询问权限。默认“${FONT_FAMILY_OPTIONS[scopeDefaults[config.family]].label}”。`);
- setSettingRowHelp(weightSelect,
- `调整${profileLabel}${config.label}文字的粗细。默认 ${scopeDefaults[config.weight]} · ${FONT_WEIGHT_LABELS[scopeDefaults[config.weight]]}。`);
- setSettingRowHelp(colorInput,
- `调整${profileLabel}${config.label}文字的基础颜色。默认跟随当前明暗主题;自定义颜色只作用于${config.label}文字,不改变链接、错误和状态等语义颜色。`);
- }
- });
- fontScaleSettings.forEach(({ input, output, key }) => {
- input.value = String(values[key]);
- output.textContent = `${values[key]}%`;
- syncRangeVisual(input);
- });
- setSettingRowHelp(interfaceFontScaleRange,
- `调整${profileLabel}界面文字字号,包括标题、元信息、楼层头部、操作区、时间轴、设置面板和提示。默认 ${defaultValues.interface}%;不会改变帖子正文和回复输入文字。`);
- setSettingRowHelp(fontScaleRange,
- `调整${profileLabel}中的帖子正文和各楼层内容,不改变标题、按钮等界面文字。默认 ${defaultValues.post}%;拖动时实时预览,统一保存。`);
- setSettingRowHelp(composerFontScaleRange,
- `调整${profileLabel}回复弹窗里的输入文字和编辑区域字号,不改变已发布正文。默认 ${defaultValues.composer}%;拖动时实时预览,统一保存。`);
- fontFieldResetButtons.forEach((button) => {
- const host = button.dataset.hostFontReset != null;
- const profileField = button.dataset.fontFieldReset;
- const scope = host ? 'host' : FONT_SCOPE_KEYS.find((key) => Object.values(fontScopeConfig(key)).includes(profileField));
- const config = fontScopeConfig(scope);
- const field = host ? config[button.dataset.hostFontReset] : profileField;
- const scopeValues = host ? PREFS : values;
- const scopeDefaults = host ? HOST_FONT_DEFAULTS : defaultValues;
- const defaultValue = scopeDefaults[field];
- const familyField = field === config.family;
- const weightField = field === config.weight;
- const colorField = field === config.color;
- const fieldLabel = `${host ? '' : profileLabel}${config.label}${familyField ? '字体' : weightField ? '字重' : colorField ? '颜色' : '字号'}`;
- const defaultLabel = familyField ? FONT_FAMILY_OPTIONS[defaultValue].label :
- weightField ? String(defaultValue) : colorField ? '跟随主题' : `${defaultValue}%`;
- button.disabled = familyField
- ? scopeValues[config.family] === scopeDefaults[config.family]
- && scopeValues[config.customFamily] === scopeDefaults[config.customFamily]
- : scopeValues[field] === defaultValue;
- button.querySelector('span').textContent = `默认 ${defaultLabel}`;
- setLabel(button, `恢复${fieldLabel}默认值 ${defaultLabel}`);
- });
- const changed = FONT_PROFILE_VALUE_KEYS.some((name) => values[name] !== savedValues[name]);
- fontStatus.textContent = changed
- ? `${profileLabel}字体正在实时预览,等待统一保存。`
- : `${profileLabel}字体当前配置已应用。`;
- fontStatus.classList.toggle('balanced', !changed);
- const hostFontAtDefault = Object.keys(HOST_FONT_DEFAULTS).every((name) => PREFS[name] === HOST_FONT_DEFAULTS[name]);
- fontResetBtn.disabled = hostFontAtDefault && FONT_PROFILE_VALUE_KEYS.every((name) =>
- values[name] === defaultValues[name]
- );
- syncSettingsDraftIndicators();
- };
- const syncLayoutControls = () => {
- const ratios = currentLayoutDraft();
- const savedRatios = currentSavedLayoutRatios();
- const defaultRatios = currentLayoutDefaults();
- const profileLabel = layoutDraftFullPage ? '全屏' : '普通(嵌入/浮窗)';
- setText(layoutSettingsTitle, `布局设置 · ${profileLabel}`);
- setText(layoutSettingsDescription,
- `当前检测为${profileLabel}形态;实时预览、保存和恢复默认只作用于该形态。`);
- const remaining = Math.max(0, roundLayoutRatio(100 - layoutRatioTotal(ratios)));
- layoutRatioInputs.forEach((input) => {
- const name = input.dataset.layoutRegion;
- if (!LAYOUT_REGION_KEYS.includes(name)) return;
- input.max = '100';
- input.value = String(ratios[name]);
- syncRangeVisual(input);
- });
- layoutRatioValues.forEach((valueEl) => {
- const name = valueEl.dataset.layoutValue;
- if (!LAYOUT_REGION_KEYS.includes(name)) return;
- valueEl.textContent = `${Number(ratios[name].toFixed(1))}%`;
- });
- layoutTotal.classList.toggle('warning', remaining > 0);
- layoutTotal.classList.toggle('balanced', remaining <= 0);
- const changed = LAYOUT_REGION_KEYS.some((name) => Math.abs(ratios[name] - savedRatios[name]) >= 0.01);
- layoutTotal.textContent = remaining > 0
- ? `当前形态:${profileLabel}。还剩 ${Number(remaining.toFixed(1))}% 未分配,必须分配完才能保存。`
- : changed
- ? `当前形态:${profileLabel}。正在实时预览,只会保存该形态的布局。`
- : `当前形态:${profileLabel}。当前配置已应用,只会保存该形态的布局。`;
- layoutResetBtn.disabled = LAYOUT_REGION_KEYS.every((name) =>
- Math.abs(ratios[name] - defaultRatios[name]) < 0.01
- );
- syncSettingsDraftIndicators();
- };
- const syncReaderWindowControls = () => {
- const state = readerWindow.getState();
- const compact = window.innerWidth <= READER_WINDOW_COMPACT_WIDTH;
- const floating = readerWorkspace.isFloating();
- const geometry = `${Math.round(state.width)} × ${Math.round(state.height)} · (${Math.round(state.left)}, ${Math.round(state.top)})${readerWindow.isPinned() ? ' · 保持显示' : ''}${readerWindow.isLocked() ? ' · 已锁定' : ''}`;
- readerWindowWidthInput.max = String(Math.max(READER_WINDOW_MIN_WIDTH, window.innerWidth - READER_WINDOW_MARGIN * 2));
- readerWindowHeightInput.max = String(Math.max(READER_WINDOW_MIN_HEIGHT, window.innerHeight - READER_WINDOW_MARGIN * 2));
- readerWindowXInput.max = String(Math.max(READER_WINDOW_MARGIN, window.innerWidth - READER_WINDOW_MARGIN - state.width));
- readerWindowYInput.min = String(READER_WINDOW_MARGIN);
- readerWindowYInput.max = String(Math.max(READER_WINDOW_MARGIN, window.innerHeight - READER_WINDOW_MARGIN - state.height));
- readerWindowWidthInput.value = String(Math.round(state.width));
- readerWindowHeightInput.value = String(Math.round(state.height));
- readerWindowXInput.value = String(Math.round(state.left));
- readerWindowYInput.value = String(Math.round(state.top));
- readerWindowPinInput.checked = readerWindow.isPinned();
- readerWindowLockInput.checked = readerWindow.isLocked();
- [readerWindowWidthInput, readerWindowHeightInput, readerWindowXInput, readerWindowYInput,
- readerWindowPinInput, readerWindowLockInput].forEach((input) => { input.disabled = compact; });
- readerWindowStatus.textContent = compact
- ? '当前视口较窄,阅读器使用同一套窄屏响应式布局。'
- : floating
- ? `${geometry}${readerWindow.isLocked() ? '' : ' · 可拖动缩放'}`
- : `${readerWorkspace.isEmbedded() ? '当前为嵌入阅读' : '当前为全屏阅读'};以下配置将在切换到浮窗后生效。浮窗:${geometry}`;
- readerWindowResetBtn.disabled = compact || readerWindow.isDefault();
- };
- const appearanceApplyByGroup = {
- interaction: applyInteractionColors,
- zebra: applyZebraAppearance,
- 'list-zebra': applyListZebraColor,
- 'reply-line': applyReplyLineAppearance,
- 'quote-line': applyQuoteLineAppearance,
- 'divider-line': applyDividerLineAppearance,
- 'floor-preview': applyFloorPreviewAppearance,
- };
- const appearanceControlSettings = appearanceSettingElements.map((field) => Object.assign({}, field, {
- apply: appearanceApplyByGroup[field.section],
- }));
- const syncAppearanceValueControls = () => {
- const values = currentAppearanceDraft();
- appearanceControlSettings.forEach(({ input, output, key }) => {
- const range = input.type === 'range';
- const value = values[key];
- input.value = range ? String(value) : value;
- output.textContent = range ? `${value}px` : value;
- if (!range) output.title = `当前主题实际应用 ${appearanceThemeColor(values, key).toUpperCase()}`;
- if (range) syncRangeVisual(input);
- });
- };
- const syncColorResetButtons = () => {
- const values = currentAppearanceDraft();
- const defaults = currentAppearanceDefaults();
- colorResetButtons.forEach((button) => {
- const baseKey = button.dataset.colorReset;
- const valueKeys = appearanceSettingValueFields(baseKey);
- const structureDisabled = button.closest('[data-appearance-group="structure"]')
- && values.structureColorsEnabled === false;
- button.disabled = structureDisabled || valueKeys.every((key) =>
- defaults[key] == null || values[key] === defaults[key]);
- });
- };
- const syncAppearanceGroupControls = () => {
- const values = currentAppearanceDraft();
- appearanceGroupToggleInputs.forEach((input) => {
- const key = input.dataset.appearanceGroupToggle;
- const enabled = values[key] !== false;
- const group = input.closest('.ldp-color-group');
- input.checked = enabled;
- group.classList.toggle('is-disabled', !enabled);
- group.querySelector('.ldp-settings-fields').setAttribute('aria-disabled', String(!enabled));
- group.querySelectorAll('.ldp-settings-fields input').forEach((control) => {
- control.disabled = !enabled;
- });
- });
- };
- const syncAppearanceControls = () => {
- const profileLabel = '阅读器';
- const configuredTheme = resolvedReaderThemeMode();
- const selectedTheme = normalizeReaderThemeMode(currentReaderThemeMode());
- const themeLabel = configuredTheme === 'dark' ? '暗色主题' : '明亮主题';
- appearanceThemeNote.innerHTML = `当前预览
${themeLabel}适应色`
- + `${selectedTheme === 'system' ? '(跟随系统)' : ''};HEX 保留所选原色,实际呈现自动限制亮度与对比。`;
- syncAppearanceValueControls();
- syncAppearanceGroupControls();
- syncColorResetButtons();
- const changed = appearanceValuesChanged();
- appearanceStatus.textContent = changed
- ? `${profileLabel}外观正在实时预览,等待统一保存。`
- : `${profileLabel}外观当前配置已应用。`;
- appearanceStatus.classList.toggle('balanced', !changed);
- syncSettingsDraftIndicators();
- };
- let performanceSettingsChanged = false;
- const syncPerformanceControls = () => {
- const config = currentPerformanceConfig();
- const preset = performancePresetForConfig(config);
- performancePresetButtons.forEach((button) => {
- const active = button.dataset.performancePreset === preset;
- button.classList.toggle('active', active);
- setPressed(button, active);
- });
- performanceInputs.forEach((input) => {
- const name = input.dataset.performanceKey;
- const value = config[name];
- if (value == null) return;
- input.value = PERFORMANCE_LIMITS[name]?.integer
- ? String(value)
- : String(Number(value.toFixed(2)));
- });
- performanceResetBtn.disabled = preset === 'balanced';
- const presetLabels = { low: '省资源', balanced: '均衡', high: '减少等待', custom: '自定义' };
- const nextConfig = `每次加载 ${config.pageSize} 个楼层 · 页面保留不超过 ${config.streamMaxItems} 个楼层 · 二级回复提前 ${config.nestedPrefetchViewports} 屏 · 限流额度 ${config.requestRateTarget}%`;
- const requestState = currentReaderRequestScheduler().snapshot();
- const windowStatus = `请求额度 ${requestState.shortWindowBudget} 次/10 秒、${requestState.longWindowBudget} 次/分钟`;
- const runtimeStatus = requestState.endpointCoolingDown && !requestState.coolingDown
- ? ` 当前隔离 ${requestState.endpointCooldownCount} 个异常端点,最长约 ${Math.max(1, Math.ceil(requestState.endpointCooldownRemaining / 1000))} 秒后允许再次尝试;其他请求继续按${windowStatus}运行。`
- : requestState.coolingDown
- ? ` 当前会话命中${requestState.lastRateLimitWindow || '动态'}限流,正在退避,约 ${Math.max(1, Math.ceil(requestState.cooldownRemaining / 1000))} 秒后继续;${windowStatus}。`
- : requestState.learnedRateLimit
- ? ` 当前会话已按最近边界调节;${windowStatus}${requestState.minInterval > requestState.configuredMinInterval ? `,请求最短间隔 ${requestState.minInterval}ms` : ''},稳定承压后继续小步提速,并发按需最高 ${requestState.maxConcurrent} 路。`
- : ` 当前会话正常自适应;${windowStatus},稳定承压后小步上探,并发按需最高 ${requestState.maxConcurrent} 路。`;
- const savedStatus = performanceSettingsChanged
- ? `已保存为${presetLabels[preset] || '自定义'}:${nextConfig};下次打开阅读器生效。`
- : `下次打开采用${presetLabels[preset] || '自定义'}:${nextConfig}。`;
- performanceStatus.textContent = savedStatus + runtimeStatus;
- };
- const formatRequestFlowBytes = (value) => {
- const bytes = Math.max(0, Number(value) || 0);
- if (bytes < 1024) return `${Math.round(bytes)} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
- return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
- };
- const formatRequestFlowDuration = (value) => {
- const milliseconds = Math.max(0, Number(value) || 0);
- if (milliseconds < 10) return `${milliseconds.toFixed(1)}ms`;
- if (milliseconds < 1000) return `${Math.round(milliseconds)}ms`;
- if (milliseconds < 60000) return `${(milliseconds / 1000).toFixed(milliseconds < 10000 ? 1 : 0)}s`;
- return `${(milliseconds / 60000).toFixed(1)}min`;
- };
- const formatRequestFlowTimestamp = (value) => {
- const date = new Date(Number(value) || 0);
- const clock = date.toLocaleTimeString('zh-CN', {
- hour12: false,
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- });
- return `${clock}.${String(date.getMilliseconds()).padStart(3, '0')}`;
- };
- const requestFlowSourceLabel = (source) => REQUEST_FLOW_SOURCE_LABELS[source] || '其他';
- const requestFlowPriorityLabel = (priority) => {
- if (!Number.isFinite(priority)) return '';
- if (priority <= POST_REQUEST_PRIORITY.topic) return '核心';
- if (priority === POST_REQUEST_PRIORITY.scroll) return '滚动';
- if (priority === POST_REQUEST_PRIORITY.visible) return '可见';
- if (priority === POST_REQUEST_PRIORITY.nested) return '预取';
- if (priority === POST_REQUEST_PRIORITY.auxiliary) return '辅助';
- return '后台';
- };
- const requestFlowWaitReasonLabel = (reason) => REQUEST_FLOW_WAIT_REASON_LABELS[reason] || '调度';
- const requestFlowIssue = (event, at = requestFlowNow()) => {
- if (!event) return null;
- const networkDuration = event.pending
- ? Math.max(0, at - event.startedAt)
- : Math.max(0, Number(event.duration) || 0);
- const queueDuration = Math.max(0, Number(event.permitWait) || 0);
- if (event.controlOnly) {
- const reason = String(event.controlReason || event.waitReason || 'cancelled');
- if (reason === 'queue-limit') return { level: 'warning', kind: '队列已满', detail: '辅助请求在发出前被队列上限丢弃' };
- if (reason === 'endpoint-cooldown') return { level: 'warning', kind: '端点熔断', detail: '同一异常 URL 在冷却期内未再次发出,不影响其他请求' };
- if (reason === 'rate-limit-priority' || reason === 'cooldown')
- return { level: 'warning', kind: '限流让位', detail: '低优先级请求在发出前为限流恢复让位' };
- if (reason === 'context-close') return { level: 'warning', kind: '页面取消', detail: '阅读器上下文结束,请求未发出' };
- return { level: 'warning', kind: '调度取消', detail: `请求未发出(${requestFlowWaitReasonLabel(event.waitReason)})` };
- }
- if (event.status === 429)
- return { level: 'danger', kind: '429 限流', detail: event.rateLimitCode || '服务器拒绝了当前请求速率' };
- if (event.status >= 500) return { level: 'danger', kind: `HTTP ${event.status}`, detail: '服务器或上游服务返回错误' };
- if (event.status >= 400) return { level: 'danger', kind: `HTTP ${event.status}`, detail: '请求参数、权限或目标状态异常' };
- if (event.error) {
- const aborted = /abort/i.test(event.error);
- return { level: aborted ? 'warning' : 'danger', kind: aborted ? '中止/超时' : '网络错误', detail: event.error };
- }
- if (event.pending && event.type !== 'realtime' && networkDuration >= POST_REQUEST_TIMEOUT)
- return { level: 'danger', kind: '请求卡住', detail: `网络阶段已持续 ${formatRequestFlowDuration(networkDuration)}` };
- if (queueDuration >= 1000)
- return {
- level: 'warning',
- kind: '排队过久',
- detail: `${requestFlowWaitReasonLabel(event.waitReason)}等待 ${formatRequestFlowDuration(queueDuration)}`,
- };
- if (!event.pending && event.type !== 'realtime' && networkDuration >= REQUEST_FLOW_SLOW_MS)
- return { level: 'warning', kind: '响应偏慢', detail: `网络阶段耗时 ${formatRequestFlowDuration(networkDuration)}` };
- return null;
- };
- const requestFlowTimingLabel = (event, at = requestFlowNow()) => {
- const queueDuration = Math.max(0, Number(event.permitWait) || 0);
- const dispatchDuration = Math.max(0, Number(event.dispatchDuration) || 0);
- const networkDuration = event.pending
- ? Math.max(0, at - event.startedAt)
- : Math.max(0, Number(event.duration) || 0);
- if (event.controlOnly) return `${queueDuration > 0 ? `排 ${formatRequestFlowDuration(queueDuration)} · ` : ''}未发出`;
- return `${queueDuration > 0 ? `排 ${formatRequestFlowDuration(queueDuration)} · ` : ''}${dispatchDuration >= 1 ? `放行 ${formatRequestFlowDuration(dispatchDuration)} · ` : ''}网 ${formatRequestFlowDuration(networkDuration)}`;
- };
- const requestFlowSnapshot = (at = requestFlowNow()) => {
- pruneRequestFlowEvents(at);
- const session = REQUEST_FLOW_EVENTS.slice();
- const recent60 = session.filter((event) => !event.controlOnly && event.startedAt >= at - 60000);
- const recent10 = recent60.filter((event) => event.startedAt >= at - REQUEST_FLOW_TRACE_WINDOW_MS);
- const binEnd = Math.ceil(at / 100) * 100;
- const binStart = binEnd - REQUEST_FLOW_TRACE_WINDOW_MS;
- const bins100 = Array.from({ length: REQUEST_FLOW_TRACE_WINDOW_MS / 100 }, () => 0);
- const typeStats = new Map();
- const sourceCounts = { reader: 0, host: 0, browser: 0 };
- let transfer = 0;
- const durations = [];
- recent60.forEach((event) => {
- const binIndex = Math.floor((event.startedAt - binStart) / 100);
- if (binIndex >= 0 && binIndex < bins100.length) bins100[binIndex]++;
- const stat = typeStats.get(event.type) || { type: event.type, count: 0, completed: 0, durations: [], issues: 0 };
- stat.count++;
- if (!event.pending) {
- stat.completed++;
- if (event.duration > 0 && event.type !== 'realtime') stat.durations.push(event.duration);
- if (event.duration > 0 && event.type !== 'realtime') durations.push(event.duration);
- }
- if (requestFlowIssue(event, at)) stat.issues++;
- typeStats.set(event.type, stat);
- if (Object.hasOwn(sourceCounts, event.source)) sourceCounts[event.source]++;
- transfer += event.size;
- });
- durations.sort((a, b) => a - b);
- const p95Index = durations.length ? Math.min(durations.length - 1, Math.ceil(durations.length * .95) - 1) : -1;
- const latestLimit = session.slice().reverse().find((event) =>
- event.status === 429 || event.retryAfter || event.rateLimitCode || event.serverLimit);
- const recentIssues = session
- .filter((event) => (Number(event.endedAt) || event.startedAt) >= at - 60000)
- .map((event) => ({ event, issue: requestFlowIssue(event, at) }))
- .filter((entry) => entry.issue);
- const traceEvents = session.filter((event) => {
- const lifecycleStart = Number(event.queuedAt) || event.startedAt;
- const lifecycleEnd = event.pending ? at : Number(event.endedAt) || event.startedAt;
- return lifecycleStart <= at && lifecycleEnd >= at - REQUEST_FLOW_TRACE_WINDOW_MS;
- });
- const sortedTypeStats = [...typeStats.values()].map((stat) => {
- stat.durations.sort((a, b) => a - b);
- const index = stat.durations.length
- ? Math.min(stat.durations.length - 1, Math.ceil(stat.durations.length * .95) - 1)
- : -1;
- return { ...stat, p95: index >= 0 ? stat.durations[index] : 0 };
- }).sort((a, b) => b.count - a.count);
- return {
- at,
- session,
- recent60,
- recent10,
- traceEvents,
- typeStats: sortedTypeStats,
- recentIssues,
- sourceCounts,
- transfer,
- peak100: Math.max(0, ...bins100),
- p95: p95Index >= 0 ? durations[p95Index] : 0,
- completed: durations.length,
- active: session.filter((event) => event.pending).length,
- latestLimit,
- };
- };
- const monitorStatusInfo = (level, state, detail) => ({ level, state, detail });
- const requestFlowBottleneckInfo = (snapshot, schedulerState) => {
- const latest429 = snapshot.recent60.slice().reverse().find((event) => event.status === 429);
- if (latest429 || (schedulerState?.coolingDown)) {
- const event = latest429 || snapshot.latestLimit;
- const typeLabel = REQUEST_FLOW_TYPE_LABELS[event?.type] || '请求';
- const wait = event?.retryAfter
- ? `,服务器要求等待 ${event.retryAfter} 秒`
- : schedulerState?.cooldownRemaining
- ? `,约 ${Math.max(1, Math.ceil(schedulerState.cooldownRemaining / 1000))} 秒后继续`
- : '';
- return monitorStatusInfo('danger', '服务器限流',
- `${typeLabel}在最近一分钟收到 429${wait};未启动的辅助和后台请求会让位。`);
- }
- const latestDanger = snapshot.recentIssues.slice().reverse().find((entry) => entry.issue.level === 'danger');
- if (latestDanger) {
- const { event, issue } = latestDanger;
- const typeLabel = REQUEST_FLOW_TYPE_LABELS[event.type] || event.type;
- const caller = event.callSite ? `,发起点 ${event.callSite}` : '';
- return monitorStatusInfo('danger', issue.kind,
- `${typeLabel} ${event.method} ${event.path}:${issue.detail}${caller}。`);
- }
- if (schedulerState && Math.max(schedulerState.queued, schedulerState.globalQueued || 0) >= 3) {
- const wait = Math.max(0, Number(schedulerState.nextPermitDelay) || 0);
- const reason = schedulerState.blockingReason === '10s'
- ? '10 秒请求额度'
- : schedulerState.blockingReason === '60s'
- ? '60 秒请求额度'
- : schedulerState.blockingReason === 'interval'
- ? '请求最短间隔'
- : '';
- const windowDetail = reason
- ? `;正在等待${reason}${wait ? `,约 ${Math.max(1, Math.ceil(wait / 1000))} 秒后放行` : ''}`
- : '';
- return monitorStatusInfo('warning', '阅读器排队',
- `全局 ${schedulerState.globalActive || schedulerState.active} 路执行、${Math.max(schedulerState.queued, schedulerState.globalQueued || 0)} 条排队${windowDetail};原站操作、目标楼层、正文和当前可见的二级回复会优先启动。`);
- }
- if (snapshot.completed >= 5 && snapshot.p95 >= 1800)
- return monitorStatusInfo('warning', '响应偏慢',
- `最近一分钟 95% 的已完成请求不超过 ${formatRequestFlowDuration(snapshot.p95)},瓶颈更可能在网络或服务器响应,而不是请求启动间隔。`);
- const resourceCount = snapshot.typeStats
- .filter((stat) => ['avatar', 'media', 'asset'].includes(stat.type))
- .reduce((sum, stat) => sum + stat.count, 0);
- if (snapshot.recent60.length >= 12 && resourceCount / snapshot.recent60.length >= .7 && snapshot.transfer >= 4 * 1024 * 1024)
- return monitorStatusInfo('warning', '资源占用',
- `头像、图片和静态资源占最近一分钟请求的 ${Math.round(resourceCount / snapshot.recent60.length * 100)}%,已传输 ${formatRequestFlowBytes(snapshot.transfer)}。`);
- if (!snapshot.recent60.length)
- return monitorStatusInfo('normal', '等待采样',
- '打开帖子并滚动后,这里会根据排队、响应耗时、传输量和 429 判断主要瓶颈。');
- return monitorStatusInfo('normal', '节奏正常',
- `最近一分钟 ${snapshot.recent60.length} 次请求,${snapshot.active} 次进行中,95% 请求耗时 ${snapshot.p95 ? formatRequestFlowDuration(snapshot.p95) : '暂无'};当前未发现明显限流或排队瓶颈。`);
- };
- const requestFlowTraceMarkup = (snapshot) => {
- const windowStart = snapshot.at - REQUEST_FLOW_TRACE_WINDOW_MS;
- const position = (value) => Math.max(0, Math.min(100,
- (Number(value) - windowStart) / REQUEST_FLOW_TRACE_WINDOW_MS * 100));
- const width = (start, end) => Math.max(0, position(end) - position(start));
- const sources = [
- { source: 'reader', label: '阅读器' },
- { source: 'host', label: '原站' },
- { source: 'browser', label: '资源' },
- ];
- return sources.map(({ source, label }) => {
- const layerEnds = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
- const events = snapshot.traceEvents.filter((event) => event.source === source)
- .sort((a, b) => (Number(a.queuedAt) || a.startedAt) - (Number(b.queuedAt) || b.startedAt))
- .slice(-80);
- const items = events.map((event) => {
- const lifecycleStart = Number(event.queuedAt) || event.startedAt;
- const lifecycleEnd = event.pending ? snapshot.at : Number(event.endedAt) || event.startedAt;
- let layer = layerEnds.findIndex((end) => end <= lifecycleStart);
- if (layer < 0) layer = layerEnds.indexOf(Math.min(...layerEnds));
- layerEnds[layer] = lifecycleEnd;
- const top = 3 + layer * 10;
- const issue = requestFlowIssue(event, snapshot.at);
- const typeLabel = REQUEST_FLOW_TYPE_LABELS[event.type] || event.type;
- const priorityLabel = requestFlowPriorityLabel(event.priority);
- const status = event.controlOnly ? '未发出' : event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '完成';
- const detail = [
- `${formatRequestFlowTimestamp(event.startedAt)} · ${event.method} ${event.path}`,
- `${requestFlowSourceLabel(event.source)} / ${typeLabel} / ${status}`,
- requestFlowTimingLabel(event, snapshot.at),
- priorityLabel && `${priorityLabel}优先级`,
- event.attempt > 1 && `第 ${event.attempt} 次尝试`,
- event.callSite && `发起点 ${event.callSite}`,
- ].filter(Boolean).join(' · ');
- const wireStart = Math.max(windowStart, event.startedAt);
- const wireEnd = Math.min(snapshot.at, Math.max(event.startedAt, lifecycleEnd));
- const wire = event.controlOnly ? '' : `
`;
- const queueEnd = Math.min(snapshot.at, Number(event.permittedAt) || event.startedAt);
- const queue = lifecycleStart < queueEnd - .5 && queueEnd >= windowStart
- ? `
`
- : '';
- const permit = queue && !event.controlOnly
- ? `
`
- : '';
- const anomalyAt = Math.min(snapshot.at, Math.max(windowStart, lifecycleEnd));
- const anomalyPosition = Math.max(.6, Math.min(99.4, position(anomalyAt)));
- const anomaly = issue
- ? `
`
- : '';
- return queue + permit + wire + anomaly;
- }).join('');
- return `
- ${label}
- ${items}
-
`;
- }).join('');
- };
- let requestFlowTimer = 0;
- const updateRequestFlowHtml = (element, html) => {
- if (!element || element.matches(':hover') || element.innerHTML === html) return;
- const scrollTop = element.scrollTop;
- element.innerHTML = html;
- if (scrollTop) element.scrollTop = scrollTop;
- };
- const syncRequestFlowControls = () => {
- if (!requestFlowChart || !requestFlowSection) return;
- const snapshot = requestFlowSnapshot();
- let schedulerState = null;
- try { schedulerState = currentReaderRequestScheduler().snapshot(); } catch {}
- const recent429 = snapshot.recent60.filter((event) => event.status === 429).length;
- const metricValues = {
- rate10: `${snapshot.recent10.length}次 · ${(snapshot.recent10.length / 10).toFixed(1)}/秒`,
- rate60: `${snapshot.recent60.length}次 · ${(snapshot.recent60.length / 60).toFixed(1)}/秒`,
- peak: `${snapshot.peak100}次/100ms`,
- transfer: formatRequestFlowBytes(snapshot.transfer),
- issues: `${snapshot.recentIssues.length}次${recent429 ? ` · 429 ${recent429}` : ''}`,
- };
- requestFlowMetrics.forEach((element) => {
- element.textContent = metricValues[element.dataset.requestFlowMetric] || '—';
- });
- const queued10 = snapshot.recent10.filter((event) => event.permitWait > .5).length;
- const globalUsage = schedulerState
- ? `全局 ${schedulerState.globalInstances} 实例 · 执行 ${schedulerState.globalActive} · 排队 ${schedulerState.globalQueued} · 共享上限 ${schedulerState.globalMaxConcurrent} 路 · 间隔 ${schedulerState.globalMinInterval}ms${schedulerState.coordinationMode === 'best-effort' ? ' · 协调降级' : ''}`
- : `本页阅读器 ${snapshot.sourceCounts.reader} · 原站 ${snapshot.sourceCounts.host}`;
- const readerWindowUsage = schedulerState
- ? ` · 请求额度 ${schedulerState.shortWindowCount}/${schedulerState.shortWindowBudget}(10 秒)· ${schedulerState.longWindowCount}/${schedulerState.longWindowBudget}(60 秒)`
- : '';
- requestFlowWindow.textContent = `近 10 秒 ${snapshot.recent10.length} 请求 · 排队 ${queued10} · 异常 ${snapshot.recentIssues.filter((entry) => entry.event.startedAt >= snapshot.at - REQUEST_FLOW_TRACE_WINDOW_MS).length} · ${globalUsage}${readerWindowUsage}`;
- updateRequestFlowHtml(requestFlowChart, requestFlowTraceMarkup(snapshot));
- const visibleTypes = snapshot.typeStats.slice(0, 8);
- const typeLegend = visibleTypes.length
- ? visibleTypes.map((stat) => `
${esc(REQUEST_FLOW_TYPE_LABELS[stat.type] || stat.type)}`).join('')
- : '
等待请求';
- updateRequestFlowHtml(requestFlowLegend, `
排队慢/久候错误/限流${typeLegend}`);
- const bottleneck = requestFlowBottleneckInfo(snapshot, schedulerState);
- requestFlowBottleneck.dataset.level = bottleneck.level;
- requestFlowBottleneckState.textContent = bottleneck.state;
- requestFlowBottleneckDetail.textContent = bottleneck.detail;
- if (requestFlowAnomalyWindow) {
- requestFlowAnomalyWindow.textContent = snapshot.recentIssues.length
- ? `最近 60 秒 ${snapshot.recentIssues.length} 个 · 红色为失败/限流`
- : '最近 60 秒未发现异常';
- }
- if (requestFlowAnomalies) {
- const latestIssues = snapshot.recentIssues.slice(-8).reverse();
- updateRequestFlowHtml(requestFlowAnomalies, latestIssues.length
- ? latestIssues.map(({ event, issue }) => {
- const typeLabel = REQUEST_FLOW_TYPE_LABELS[event.type] || event.type;
- const caller = event.callSite || `${event.transport} 发起`;
- const detail = `${requestFlowSourceLabel(event.source)} / ${typeLabel} · ${requestFlowTimingLabel(event, snapshot.at)} · ${issue.detail} · 发起点 ${caller}`;
- return `
- ${esc(formatRequestFlowTimestamp(event.endedAt || event.startedAt))}
- ${esc(issue.kind)}
- ${esc(`${event.method} ${event.path}`)}${esc(detail)}
-
`;
- }).join('')
- : '
最近 60 秒没有 HTTP 错误、网络中止、超时、慢响应或超过 1 秒的排队。
');
- }
- updateRequestFlowHtml(requestFlowTypes, snapshot.typeStats.length
- ? snapshot.typeStats.map((stat) => {
- const p95 = stat.p95 ? formatRequestFlowDuration(stat.p95) : '—';
- return `
- ${esc(REQUEST_FLOW_TYPE_LABELS[stat.type] || stat.type)}
- ${stat.count} 次${p95}${stat.issues || '—'}
-
`;
- }).join('')
- : '
还没有可统计的请求。
');
- const recentLog = snapshot.session.slice(-60).reverse();
- updateRequestFlowHtml(requestFlowLog, recentLog.length
- ? recentLog.map((event) => {
- const status = event.controlOnly ? '未发出' : event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '—';
- const typeLabel = REQUEST_FLOW_TYPE_LABELS[event.type] || event.type;
- const priorityLabel = requestFlowPriorityLabel(event.priority);
- const issue = requestFlowIssue(event, snapshot.at);
- const caller = event.callSite || `${event.transport} 发起`;
- const title = `${event.method} ${event.path}${priorityLabel ? ` · ${priorityLabel}优先级` : ''}${event.attempt > 1 ? ` · 第 ${event.attempt} 次尝试` : ''} · ${requestFlowTimingLabel(event, snapshot.at)} · 发起点 ${caller}`;
- return `
- ${esc(formatRequestFlowTimestamp(event.startedAt))}
- ${requestFlowSourceLabel(event.source)}
- ${esc(typeLabel)}
- ${status}
- ${esc(requestFlowTimingLabel(event, snapshot.at))}
- ${esc(`${event.path} ← ${caller}`)}
-
`;
- }).join('')
- : '
打开帖子或操作原站页面后,请求会按时间出现在这里。
');
- const latestLimit = snapshot.latestLimit;
- if (latestLimit && latestLimit.status === 429) {
- const typeLabel = REQUEST_FLOW_TYPE_LABELS[latestLimit.type] || '请求';
- const details = [
- latestLimit.rateLimitCode && `错误码 ${latestLimit.rateLimitCode}`,
- latestLimit.retryAfter && `Retry-After ${latestLimit.retryAfter} 秒`,
- ].filter(Boolean).join(',');
- requestFlowObserved.textContent = `本页最近观测:${typeLabel}收到 429${details ? `(${details})` : ''}。`;
- } else if (latestLimit?.serverLimit) {
- requestFlowObserved.textContent = `服务器最近返回:上限 ${latestLimit.serverLimit}${latestLimit.serverRemaining ? `,剩余 ${latestLimit.serverRemaining}` : ''}${latestLimit.serverReset ? `,重置 ${latestLimit.serverReset}` : ''}。`;
- } else if (schedulerState?.learnedRateLimit) {
- requestFlowObserved.textContent = `所有实例已共享历史限流与有效策略:${schedulerState.shortWindowBudget} 次/10 秒、${schedulerState.longWindowBudget} 次/分钟、并发 ${schedulerState.globalMaxConcurrent} 路。`;
- } else if (schedulerState) {
- requestFlowObserved.textContent = `当前未收到 429 或服务器上限头;所有实例共同使用 ${schedulerState.shortWindowBudget} 次/10 秒、${schedulerState.longWindowBudget} 次/分钟、并发 ${schedulerState.globalMaxConcurrent} 路。`;
- }
- };
- const stopRequestFlowUpdates = () => {
- if (requestFlowTimer) clearInterval(requestFlowTimer);
- requestFlowTimer = 0;
- setRequestFlowDetailsActive(false);
- };
- const startRequestFlowUpdates = () => {
- stopRequestFlowUpdates();
- if (document.visibilityState !== 'visible') return;
- setRequestFlowDetailsActive(true);
- syncRequestFlowControls();
- requestFlowTimer = setInterval(syncRequestFlowControls, 1000);
- };
- const RESOURCE_MONITOR_RETENTION_MS = 10 * 60 * 1000;
- const RESOURCE_MONITOR_MAX_EVENTS = 1200;
- const resourceMonitorState = readerShell.resourceMonitorState || {
- timer: 0,
- longTaskObserver: null,
- longTasksAvailable: false,
- samples: [],
- };
- const RESOURCE_MONITOR_OBSERVER_KEYS = [
- 'longTaskObserver', 'longFrameObserver', 'resourceObserver', 'mutationObserver',
- 'readerMutationObserver', 'hostMutationObserver',
- ];
- const disconnectResourceMonitorObservers = (keys = RESOURCE_MONITOR_OBSERVER_KEYS) => keys.forEach((key) => {
- if (isFunction(resourceMonitorState[key]?.disconnect)) {
- resourceMonitorState[key].disconnect();
- }
- resourceMonitorState[key] = null;
- });
- if (!Array.isArray(resourceMonitorState.samples)) resourceMonitorState.samples = [];
- if (!Array.isArray(resourceMonitorState.events)) resourceMonitorState.events = [];
- if (!Array.isArray(resourceMonitorState.visibilityTimeline)) resourceMonitorState.visibilityTimeline = [];
- if (!Array.isArray(resourceMonitorState.memorySamples)) resourceMonitorState.memorySamples = [];
- if (!resourceMonitorState.memory || typeof resourceMonitorState.memory !== 'object') {
- resourceMonitorState.memory = { bytes: null, status: 'waiting', nextAt: 0, pending: false };
- }
- if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer);
- disconnectResourceMonitorObservers();
- resourceMonitorState.timer = 0;
- const previousResourceMonitorVisibility = resourceMonitorState.visibilityTimeline[resourceMonitorState.visibilityTimeline.length - 1];
- if (previousResourceMonitorVisibility && previousResourceMonitorVisibility.state !== 'stopped') {
- resourceMonitorState.visibilityTimeline.push({ at: requestFlowNow(), state: 'stopped' });
- }
- resourceMonitorState.collecting = false;
- resourceMonitorState.eventSequence = Number(resourceMonitorState.eventSequence) || 0;
- resourceMonitorState.lastPruneAt = Number(resourceMonitorState.lastPruneAt) || 0;
- readerShell.resourceMonitorState = resourceMonitorState;
- const resourceMonitorLongTasksSupported = isFunction(window.PerformanceObserver) &&
- (!Array.isArray(window.PerformanceObserver.supportedEntryTypes) ||
- window.PerformanceObserver.supportedEntryTypes.includes('longtask'));
- const resourceMonitorLongFramesSupported = isFunction(window.PerformanceObserver) &&
- [...window.PerformanceObserver.supportedEntryTypes || []].includes('long-animation-frame');
- const resourceMonitorNow = () => requestFlowNow();
- const resourceMonitorTimestamp = (at) => {
- const date = new Date(Number(at) || 0);
- const clock = date.toLocaleTimeString('zh-CN', { hour12: false });
- return `${clock}.${String(date.getMilliseconds()).padStart(3, '0')}`;
- };
- const resourceMonitorDuration = (value) => {
- const duration = Math.max(0, Number(value) || 0);
- return `${duration < 100 ? duration.toFixed(1) : Math.round(duration)}ms`;
- };
- const resourceMonitorPrune = (at = resourceMonitorNow()) => {
- const cutoff = at - RESOURCE_MONITOR_RETENTION_MS;
- resourceMonitorState.samples = resourceMonitorState.samples
- .filter((entry) => Number(entry.at) >= cutoff)
- .slice(-600);
- resourceMonitorState.memorySamples = resourceMonitorState.memorySamples
- .filter((entry) => Number(entry.at) >= cutoff)
- .slice(-60);
- resourceMonitorState.events = resourceMonitorState.events
- .filter((entry) => Number(entry.at) >= cutoff)
- .slice(-RESOURCE_MONITOR_MAX_EVENTS);
- resourceMonitorState.visibilityTimeline = resourceMonitorState.visibilityTimeline
- .filter((entry, index, entries) => Number(entry.at) >= cutoff || index === entries.length - 1)
- .slice(-240);
- };
- const resourceMonitorPushEvent = (event) => {
- const at = Number(event?.at);
- if (!Number.isFinite(at)) return;
- resourceMonitorState.events.push(Object.assign({
- id: ++resourceMonitorState.eventSequence,
- visibility: 'unknown',
- scope: 'shared',
- kind: 'event',
- duration: 0,
- detail: '',
- basis: '',
- }, event, { at }));
- if (resourceMonitorState.events.length > RESOURCE_MONITOR_MAX_EVENTS)
- resourceMonitorState.events.splice(0, resourceMonitorState.events.length - RESOURCE_MONITOR_MAX_EVENTS);
- const now = resourceMonitorNow();
- if (now - resourceMonitorState.lastPruneAt >= 1000) {
- resourceMonitorState.lastPruneAt = now;
- resourceMonitorPrune(now);
- }
- };
- const resourceMonitorVisibilityAt = (at) => {
- for (let index = resourceMonitorState.visibilityTimeline.length - 1; index >= 0; index--) {
- const entry = resourceMonitorState.visibilityTimeline[index];
- if (Number(entry.at) > at) continue;
- return ['visible', 'hidden'].includes(entry.state) ? entry.state : 'unknown';
- }
- return 'unknown';
- };
- const resourceMonitorPushTimedEvent = (at, event) => resourceMonitorPushEvent(Object.assign({
- at,
- visibility: resourceMonitorVisibilityAt(at),
- }, event));
- const recordResourceMonitorVisibility = (reason = 'visibilitychange', at = resourceMonitorNow()) => {
- if (!resourceMonitorState.collecting) return;
- const state = document.visibilityState === 'visible' ? 'visible' : 'hidden';
- const previous = resourceMonitorState.visibilityTimeline[resourceMonitorState.visibilityTimeline.length - 1];
- if (previous && previous.state === state) return;
- resourceMonitorState.visibilityTimeline.push({ at, state });
- resourceMonitorPushEvent({
- at,
- visibility: state,
- scope: 'shared',
- kind: 'visibility',
- detail: reason === 'start' ? `开始取证 · 当前${state === 'visible' ? '前台' : '后台'}` : `页面切换为${state === 'visible' ? '前台' : '后台'}`,
- basis: 'Page Visibility',
- });
- };
- const resourceMonitorRequestEvents = (at = resourceMonitorNow()) => {
- const cutoff = at - RESOURCE_MONITOR_RETENTION_MS;
- pruneRequestFlowEvents(at);
- return REQUEST_FLOW_EVENTS.filter((event) => !event.controlOnly &&
- Number(event.startedAt) >= cutoff && Number(event.startedAt) <= at).map((event) => {
- const visibility = resourceMonitorVisibilityAt(Number(event.startedAt));
- const scope = event.source === 'reader' ? 'reader' : event.source === 'host' ? 'host' : 'shared';
- const duration = Math.max(0, event.pending ? at - event.startedAt : Number(event.duration) || 0);
- const bytes = Math.max(0, Number(event.size) || 0);
- const status = event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '完成';
- const initiator = event.resourceTimed
- ? String(event.transport || 'resource')
- : REQUEST_FLOW_TYPE_LABELS[event.type] || event.type;
- const request = event.resourceTimed ? event.path : `${event.method} ${event.path}`;
- const basis = event.resourceTimed
- ? `PerformanceResourceTiming${event.source === 'reader' ? ' + reader 元数据' : event.source === 'host' ? ' + 未标记请求' : ''}`
- : event.source === 'reader' ? '显式 reader 元数据' : event.source === 'host' ? '未标记 fetch/XHR' : 'Resource Timing';
- return {
- id: `request-${event.id}`,
- at: Number(event.startedAt),
- visibility,
- scope,
- kind: 'resource',
- duration,
- bytes,
- detail: `${initiator} · ${request} · ${status} · ${resourceMonitorDuration(duration)}${bytes ? ` · ${formatRequestFlowBytes(bytes)}` : event.resourceTimed ? ' · 传输量受限或缓存命中' : ''}`,
- basis,
- };
- });
- };
- const resourceMonitorResourceDropEvents = (at = resourceMonitorNow()) => REQUEST_FLOW_RESOURCE_DROPS
- .filter((entry) => entry.at >= at - RESOURCE_MONITOR_RETENTION_MS && entry.at <= at)
- .map((entry, index) => ({
- id: `resource-drop-${entry.at}-${index}`,
- at: entry.at,
- visibility: resourceMonitorVisibilityAt(entry.at),
- scope: 'shared',
- kind: 'dropped',
- detail: `Resource Timing 有 ${entry.count} 条浏览器记录被丢弃;该区间统计不完整`,
- basis: 'PerformanceObserver droppedEntriesCount',
- }));
- const resourceMonitorEvidenceEvents = (requestEvents, resourceDropEvents) => resourceMonitorState.events.concat(
- requestEvents.filter((event) => ['visible', 'hidden'].includes(event.visibility)),
- resourceDropEvents.filter((event) => ['visible', 'hidden'].includes(event.visibility))
- );
- const resourceMonitorIsReaderRoot = (node) => {
- if (!node || node.nodeType !== 1) return false;
- if (node === READER_PORTAL_HOST) return true;
- return !!node.closest('.ldp-reader-portal-host,.ldp-overlay,.ldp-native-boost-menu,.ldp-lightbox,.ldp-user-card-fallback');
- };
- const resourceMonitorElementCount = (nodes, excludeReaderRoots = false) => [...nodes || []].reduce((sum, node) => {
- if (!node || node.nodeType !== 1 || (excludeReaderRoots && resourceMonitorIsReaderRoot(node))) return sum;
- return sum + 1 + (isFunction(node.getElementsByTagName) ? node.getElementsByTagName('*').length : 0);
- }, 0);
- const resourceMonitorHostDomCount = () => {
- const total = document.getElementsByTagName('*').length;
- const readerRoots = document.querySelectorAll('html > .ldp-reader-portal-host,body > .ldp-native-boost-menu');
- return Math.max(0, total - resourceMonitorElementCount(readerRoots));
- };
- const resourceMonitorRecordMutations = (scope, records) => {
- let added = 0;
- let removed = 0;
- records.forEach((record) => {
- added += resourceMonitorElementCount(record.addedNodes, scope === 'host');
- removed += resourceMonitorElementCount(record.removedNodes, scope === 'host');
- });
- if (!added && !removed) return;
- const at = resourceMonitorNow();
- resourceMonitorPushTimedEvent(at, {
- scope,
- kind: 'dom',
- added,
- removed,
- detail: `页面元素 +${added} / −${removed} · ${records.length} 个变更记录`,
- basis: 'MutationObserver 回调',
- });
- };
- const resourceMonitorRecordDroppedEntries = (entryType, options) => {
- const dropped = Math.max(0, Number(options?.droppedEntriesCount) || 0);
- if (!dropped) return;
- const at = resourceMonitorNow();
- resourceMonitorPushTimedEvent(at, {
- scope: 'shared',
- kind: 'dropped',
- detail: `${entryType} 有 ${dropped} 条浏览器记录被丢弃;该区间统计不完整`,
- basis: 'PerformanceObserver droppedEntriesCount',
- });
- };
- const resourceMonitorScriptScope = (script) => {
- const rawSource = String(script?.sourceURL || '');
- let source = rawSource;
- try { source = decodeURIComponent(rawSource); } catch {}
- if (/Awesome LinuxDo Reader|更流畅的 LinuxDo 阅读器|katex@0\.16\.22|pinyin-pro@3\.18\.2|hls\.js@1\.6\.16/i.test(source)) return 'reader';
- try {
- const url = new URL(rawSource, location.href);
- if ((url.origin === BASE || /(?:^|\.)linux\.do$/i.test(url.hostname) || /(?:^|\.)ldstatic\.com$/i.test(url.hostname)) &&
- /\/(?:assets|plugins|theme-javascripts)\/|\.(?:m?js)(?:$|[?#])/i.test(url.pathname)) return 'host';
- } catch {}
- return 'shared';
- };
- const resourceMonitorScriptLabel = (script) => {
- const functionName = String(script?.sourceFunctionName || '').trim();
- if (functionName) return functionName.slice(0, 80);
- const source = String(script?.sourceURL || '');
- if (!source) return '匿名脚本';
- try {
- const url = new URL(source, location.href);
- return `${url.host}${url.pathname}`.slice(0, 100);
- } catch {
- return source.split(/[?#]/, 1)[0].slice(0, 100);
- }
- };
- const requestResourceMonitorMemoryMeasurement = (at) => {
- const memory = resourceMonitorState.memory;
- const measure = window.performance?.measureUserAgentSpecificMemory;
- if (!isFunction(measure)) {
- memory.status = 'unsupported';
- return;
- }
- if (window.crossOriginIsolated !== true) {
- memory.status = 'not-isolated';
- return;
- }
- if (memory.pending || at < Number(memory.nextAt || 0)) return;
- memory.pending = true;
- memory.status = 'measuring';
- memory.nextAt = at + 30000 + Math.random() * 30000;
- let measurement;
- try { measurement = measure.call(window.performance); } catch (error) { measurement = Promise.reject(error); }
- Promise.resolve(measurement).then((result) => {
- const bytes = Number(result?.bytes);
- const measuredAt = resourceMonitorNow();
- memory.pending = false;
- if (!Number.isFinite(bytes)) {
- memory.status = 'error';
- return;
- }
- memory.bytes = bytes;
- memory.status = 'measured';
- resourceMonitorState.memorySamples.push({ at: measuredAt, heapUsed: bytes });
- resourceMonitorPushTimedEvent(measuredAt, {
- scope: 'shared',
- kind: 'memory',
- detail: `页面内存估计 ${formatRequestFlowBytes(bytes)}`,
- basis: 'measureUserAgentSpecificMemory',
- });
- }).catch(() => {
- memory.pending = false;
- memory.status = 'error';
- });
- };
- const resourceMonitorStats = (samples, key) => {
- const values = samples.map((sample) => sample[key]).filter(Number.isFinite);
- if (!values.length) return { count: 0, average: 0, peak: 0, delta: 0 };
- return {
- count: values.length,
- average: values.reduce((sum, value) => sum + value, 0) / values.length,
- peak: Math.max(...values),
- delta: values[values.length - 1] - values[0],
- };
- };
- const resourceMonitorSignedBytes = (value) => {
- const numeric = Number(value) || 0;
- if (!numeric) return '0 B';
- return `${numeric > 0 ? '+' : '-'}${formatRequestFlowBytes(Math.abs(numeric))}`;
- };
- const resourceMonitorSignedCount = (value) => {
- const numeric = Math.round(Number(value) || 0);
- return numeric > 0 ? `+${numeric}` : String(numeric);
- };
- const resourceMonitorTrendLabel = (stats, formatter) => {
- if (stats.count < 2) return '→ —';
- const arrow = stats.delta > 0 ? '↑' : stats.delta < 0 ? '↓' : '→';
- return `${arrow} ${formatter(stats.delta)}`;
- };
- const resourceMonitorChartPoints = (samples, key) => {
- const entries = samples.filter((sample) => Number.isFinite(sample[key]));
- if (!entries.length) return '';
- if (entries.length === 1) return '0,17 240,17';
- const stride = Math.max(1, Math.ceil(entries.length / 120));
- const plotted = entries.filter((_entry, index) => index % stride === 0 || index === entries.length - 1);
- const values = plotted.map((entry) => entry[key]);
- const minimum = Math.min(...values);
- const maximum = Math.max(...values);
- const range = maximum - minimum || 1;
- return plotted.map((entry, index) => {
- const x = index / Math.max(1, plotted.length - 1) * 240;
- const y = 31 - (entry[key] - minimum) / range * 28;
- return `${x.toFixed(1)},${y.toFixed(1)}`;
- }).join(' ');
- };
- const resourceMonitorMemoryLabel = () => {
- const memory = resourceMonitorState.memory;
- if (memory.status === 'measured' && Number.isFinite(memory.bytes)) return formatRequestFlowBytes(memory.bytes);
- if (memory.status === 'measuring') return '正在测量';
- if (memory.status === 'not-isolated') return '不可用(未隔离)';
- if (memory.status === 'unsupported') return '浏览器未提供';
- if (memory.status === 'error') return '测量失败';
- return '等待测量';
- };
- const resourceMonitorScopeSnapshot = (at, requestEvents) => {
- const createCell = () => ({ requests: 0, requestDuration: 0, bytes: 0, scripts: 0, longTasks: 0, longTaskDuration: 0, domChanges: 0 });
- const scopes = {
- reader: { visible: createCell(), hidden: createCell() },
- host: { visible: createCell(), hidden: createCell() },
- shared: { visible: createCell(), hidden: createCell() },
- };
- const cutoff = at - 60000;
- requestEvents.forEach((event) => {
- if (event.at < cutoff || !['visible', 'hidden'].includes(event.visibility)) return;
- const cell = scopes[event.scope][event.visibility];
- cell.requests++;
- cell.requestDuration += event.duration;
- cell.bytes += event.bytes;
- });
- resourceMonitorState.events.forEach((event) => {
- if (event.at < cutoff || event.at > at || !['visible', 'hidden'].includes(event.visibility)) return;
- const scope = Object.hasOwn(scopes, event.scope) ? event.scope : 'shared';
- const cell = scopes[scope][event.visibility];
- if (event.kind === 'script') cell.scripts += Math.max(0, Number(event.duration) || 0);
- if (event.kind === 'longtask') {
- cell.longTasks++;
- cell.longTaskDuration += Math.max(0, Number(event.duration) || 0);
- }
- if (event.kind === 'dom') cell.domChanges += Math.max(0, Number(event.added) || 0) + Math.max(0, Number(event.removed) || 0);
- });
- return scopes;
- };
- const resourceMonitorScopeCellLabel = (cell, scope) => {
- const parts = [];
- if (cell.requests) {
- const requestCopy = `请求 ${cell.requests} / ${resourceMonitorDuration(cell.requestDuration)}`;
- parts.push(cell.bytes ? `${requestCopy} / ${formatRequestFlowBytes(cell.bytes)}` : requestCopy);
- }
- if (cell.scripts) parts.push(`已归因脚本 ${resourceMonitorDuration(cell.scripts)}`);
- if (scope === 'shared' && cell.longTasks) parts.push(`长任务 ${cell.longTasks} / ${resourceMonitorDuration(cell.longTaskDuration)}`);
- if (cell.domChanges) parts.push(`页面元素变更 ${cell.domChanges}`);
- return parts.length ? parts.join(' · ') : '—';
- };
- const syncResourceMonitorScopeControls = (sample, at, requestEvents, evidenceEvents) => {
- const scopes = resourceMonitorScopeSnapshot(at, requestEvents);
- const scopeValues = {
- reader: `${sample.dom} 个页面元素 · ${sample.retainedFloors} 个楼层`,
- host: `${sample.hostDom} 个页面元素`,
- shared: resourceMonitorMemoryLabel(),
- readerBasis: `reader 元数据 · Resource Timing · Reader DOM${resourceMonitorLongFramesSupported ? ' · LoAF 已确认脚本' : ''}`,
- hostBasis: `未标记 fetch/XHR · Resource Timing · Document DOM${resourceMonitorLongFramesSupported ? ' · 同源 LoAF 脚本' : ''}`,
- sharedBasis: `Long Tasks · Resource Timing · Page Visibility${resourceMonitorLongFramesSupported ? ' · 未归因 LoAF' : ''}`,
- };
- resourceMonitorScopeRows.forEach((row) => {
- const scope = row.dataset.resourceMonitorScope;
- row.querySelector('[data-resource-monitor-scope-current]').textContent = scopeValues[scope] || '—';
- row.querySelector('[data-resource-monitor-scope-visible]').textContent = resourceMonitorScopeCellLabel(scopes[scope].visible, scope);
- row.querySelector('[data-resource-monitor-scope-hidden]').textContent = resourceMonitorScopeCellLabel(scopes[scope].hidden, scope);
- row.querySelector('[data-resource-monitor-scope-basis]').textContent = scopeValues[`${scope}Basis`] || '—';
- });
- const recentEvents = evidenceEvents.filter((event) => event.at >= at - 60000);
- const visibleCount = recentEvents.filter((event) => event.visibility === 'visible').length;
- const hiddenCount = recentEvents.filter((event) => event.visibility === 'hidden').length;
- const unknownCount = recentEvents.length - visibleCount - hiddenCount;
- resourceMonitorEvidenceWindow.textContent = `${document.visibilityState === 'visible' ? '当前前台' : '当前后台'} · 前 ${visibleCount} / 后 ${hiddenCount}${unknownCount ? ` / 未归档 ${unknownCount}` : ''} 个原始事件`;
- };
- const resourceMonitorScopeLabel = (scope) => RESOURCE_MONITOR_SCOPE_LABELS[scope] || '未归因';
- const syncResourceMonitorEventLog = (at, evidenceEvents) => {
- if (!resourceMonitorEventLog) return;
- const events = evidenceEvents
- .filter((event) => event.at >= at - RESOURCE_MONITOR_RETENTION_MS && event.at <= at)
- .sort((left, right) => right.at - left.at || String(right.id).localeCompare(String(left.id)))
- .slice(0, 48);
- resourceMonitorEventLog.innerHTML = events.length
- ? events.map((event) => {
- const visibilityLabel = event.visibility === 'visible' ? '前台' : event.visibility === 'hidden' ? '后台' : '未知';
- return `
-
- ${visibilityLabel}
- ${esc(resourceMonitorScopeLabel(event.scope))}
- ${esc(event.detail)}
- ${esc(event.basis)}
-
`;
- }).join('')
- : '
等待浏览器性能、网络资源、页面元素或前后台切换事件。
';
- };
- const resourceMonitorHealthInfo = (samples, current, at) => {
- const baselineSamples = samples.filter((sample) => sample.at >= at - 12000);
- if (baselineSamples.length < 10)
- return monitorStatusInfo('normal', '建立基线',
- `最近 12 秒取得 ${baselineSamples.length} 个真实快照;满 10 个后开始判断页面元素增长、主线程卡顿和请求排队。`);
- const recent = samples.filter((sample) => sample.at >= at - 60000);
- const first = recent[0] || current;
- const domGrowth = current.dom - first.dom;
- const retainedFloorGrowth = current.retainedFloors - first.retainedFloors;
- const warnings = [];
- let level = 'normal';
- if (current.longTaskDuration >= 2000) {
- level = 'danger';
- warnings.push(`最近 10 秒长任务累计 ${Math.round(current.longTaskDuration)}ms`);
- } else if (current.longTaskDuration >= 500) {
- level = 'warning';
- warnings.push(`最近 10 秒长任务累计 ${Math.round(current.longTaskDuration)}ms`);
- }
- if (current.queuedRequests >= 10) {
- level = 'danger';
- warnings.push(`当前有 ${current.queuedRequests} 条阅读器请求排队`);
- } else if (current.queuedRequests >= 3) {
- if (level === 'normal') level = 'warning';
- warnings.push(`当前有 ${current.queuedRequests} 条阅读器请求排队`);
- }
- if (domGrowth >= 1000 && retainedFloorGrowth >= 50) {
- if (level === 'normal') level = 'warning';
- warnings.push(`近一分钟页面元素增加 ${domGrowth} 个,保留楼层增加 ${retainedFloorGrowth} 个`);
- }
- if (warnings.length)
- return monitorStatusInfo(level, level === 'danger' ? '资源压力高' : '需要关注',
- `${warnings.join(';')}。继续观察趋势,回落后状态会自动恢复。`);
- return monitorStatusInfo('normal', '采样正常',
- '最近一分钟未发现阅读器页面元素快速膨胀、页面共享主线程卡顿或阅读器请求积压;内存估计不参与自动判定。');
- };
- const syncResourceMonitorControls = () => {
- if (!resourceMonitorSection || !resourceMonitorMetrics.length) return;
- const at = resourceMonitorNow();
- const activeContext = readerShell.activeContext;
- requestResourceMonitorMemoryMeasurement(at);
- resourceMonitorPrune(at);
- const requestEvents = resourceMonitorRequestEvents(at);
- const recentLongTasks = resourceMonitorState.events.filter((entry) =>
- entry.kind === 'longtask' && entry.at >= at - 10000);
- const longTaskDuration = recentLongTasks.reduce((sum, entry) => sum + entry.duration, 0);
- let schedulerState = null;
- try { schedulerState = currentReaderRequestScheduler().snapshot(); } catch {}
- const recentReaderRequests = requestEvents
- .filter((event) => event.scope === 'reader' && event.at >= at - 60000);
- const readerTransfer = recentReaderRequests.reduce((sum, event) => sum + event.bytes, 0);
- const sample = {
- at,
- visibility: document.visibilityState === 'visible' ? 'visible' : 'hidden',
- longTaskCount: resourceMonitorState.longTasksAvailable ? recentLongTasks.length : null,
- longTaskDuration: resourceMonitorState.longTasksAvailable ? longTaskDuration : null,
- dom: overlay.getElementsByTagName('*').length + 1,
- hostDom: resourceMonitorHostDomCount(),
- mountedFloors: activeContext?.streamMountedNodes?.size || 0,
- retainedFloors: activeContext?.streamNodeMap?.size || 0,
- nested: activeContext?.subReplyState?.size || 0,
- media: body.querySelectorAll('img,video,audio,iframe').length,
- activeRequests: schedulerState?.active || 0,
- queuedRequests: schedulerState?.queued || 0,
- readerRequestCount: recentReaderRequests.length,
- readerTransfer,
- };
- const lastSample = resourceMonitorState.samples[resourceMonitorState.samples.length - 1];
- if (!lastSample || at - lastSample.at >= 800) {
- if (lastSample && at - lastSample.at > 1800) {
- const crossedHidden = lastSample.visibility === 'hidden' || resourceMonitorState.visibilityTimeline.some((entry) =>
- entry.at > lastSample.at && entry.at <= at && entry.state === 'hidden');
- const crossedStopped = resourceMonitorState.visibilityTimeline.some((entry) =>
- entry.at > lastSample.at && entry.at <= at && entry.state === 'stopped');
- resourceMonitorPushEvent({
- at,
- visibility: crossedStopped ? 'unknown' : crossedHidden ? 'hidden' : resourceMonitorVisibilityAt(at),
- scope: 'shared',
- kind: 'gap',
- duration: at - lastSample.at,
- detail: `快照空档 ${resourceMonitorDuration(at - lastSample.at)}${crossedStopped ? '(包含取证暂停)' : crossedHidden ? '(包含后台节流或冻结)' : ''};未补造中间样本`,
- basis: 'High Resolution Time',
- });
- }
- resourceMonitorState.samples.push(sample);
- }
- resourceMonitorPrune(at);
- if (document.visibilityState !== 'visible') return;
- const evidenceEvents = resourceMonitorEvidenceEvents(
- requestEvents,
- resourceMonitorResourceDropEvents(at)
- );
- const samples = resourceMonitorState.samples;
- const stats = RESOURCE_MONITOR_STAT_KEYS.reduce((result, key) => {
- result[key] = resourceMonitorStats(key === 'heapUsed' ? resourceMonitorState.memorySamples : samples, key);
- return result;
- }, {});
- const metricValues = {
- heap: resourceMonitorMemoryLabel(),
- longTasks: resourceMonitorState.longTasksAvailable
- ? `${recentLongTasks.length} 次 · ${resourceMonitorDuration(longTaskDuration)}`
- : '浏览器未提供',
- dom: `${sample.dom} 个`,
- floors: `${sample.mountedFloors} / ${sample.retainedFloors} 个`,
- nested: `${sample.nested} 组`,
- media: `${sample.media} 个`,
- requests: schedulerState ? `${schedulerState.active} / ${schedulerState.queued} 条` : '—',
- network: `${recentReaderRequests.length} 次 · ${formatRequestFlowBytes(readerTransfer)}`,
- heapAverage: stats.heapUsed.count ? formatRequestFlowBytes(stats.heapUsed.average) : '—',
- longTasksAverage: stats.longTaskDuration.count
- ? `${stats.longTaskCount.average.toFixed(1)} / ${Math.round(stats.longTaskDuration.average)}ms`
- : '—',
- domAverage: `${Math.round(stats.dom.average)} 个`,
- floorsAverage: `${Math.round(stats.mountedFloors.average)} / ${Math.round(stats.retainedFloors.average)}`,
- nestedAverage: `${Math.round(stats.nested.average)} 组`,
- mediaAverage: `${Math.round(stats.media.average)} 个`,
- requestsAverage: `${stats.activeRequests.average.toFixed(1)} / ${stats.queuedRequests.average.toFixed(1)}`,
- networkAverage: `${Math.round(stats.readerRequestCount.average)} / ${formatRequestFlowBytes(stats.readerTransfer.average)}`,
- heapPeak: stats.heapUsed.count ? formatRequestFlowBytes(stats.heapUsed.peak) : '—',
- longTasksPeak: stats.longTaskDuration.count
- ? `${Math.round(stats.longTaskCount.peak)} / ${Math.round(stats.longTaskDuration.peak)}ms`
- : '—',
- domPeak: `${Math.round(stats.dom.peak)} 个`,
- floorsPeak: `${Math.round(stats.mountedFloors.peak)} / ${Math.round(stats.retainedFloors.peak)}`,
- nestedPeak: `${Math.round(stats.nested.peak)} 组`,
- mediaPeak: `${Math.round(stats.media.peak)} 个`,
- requestsPeak: `${Math.round(stats.activeRequests.peak)} / ${Math.round(stats.queuedRequests.peak)}`,
- networkPeak: `${Math.round(stats.readerRequestCount.peak)} / ${formatRequestFlowBytes(stats.readerTransfer.peak)}`,
- heapTrend: resourceMonitorTrendLabel(stats.heapUsed, resourceMonitorSignedBytes),
- longTasksTrend: resourceMonitorTrendLabel(stats.longTaskDuration, (value) => `${resourceMonitorSignedCount(value)}ms`),
- domTrend: resourceMonitorTrendLabel(stats.dom, resourceMonitorSignedCount),
- floorsTrend: resourceMonitorTrendLabel(stats.retainedFloors, resourceMonitorSignedCount),
- nestedTrend: resourceMonitorTrendLabel(stats.nested, resourceMonitorSignedCount),
- mediaTrend: resourceMonitorTrendLabel(stats.media, resourceMonitorSignedCount),
- requestsTrend: resourceMonitorTrendLabel(stats.queuedRequests, resourceMonitorSignedCount),
- networkTrend: resourceMonitorTrendLabel(stats.readerTransfer, resourceMonitorSignedBytes),
- };
- resourceMonitorValueColumns.forEach(([elements, dataKey, suffix]) => elements.forEach((element) => {
- element.textContent = metricValues[`${element.dataset[dataKey]}${suffix}`] || '—';
- }));
- const chartCurrentValues = {
- heapUsed: metricValues.heap,
- dom: metricValues.dom,
- retainedFloors: `${sample.retainedFloors} 个`,
- };
- resourceMonitorChartRows.forEach((row) => {
- const key = row.dataset.resourceMonitorChart;
- const chartSamples = key === 'heapUsed' ? resourceMonitorState.memorySamples : samples;
- row.querySelector('polyline').setAttribute('points', resourceMonitorChartPoints(chartSamples, key));
- row.querySelector('[data-resource-monitor-chart-current]').textContent = chartCurrentValues[key] || '—';
- });
- const coveredMs = samples.length > 1 ? samples[samples.length - 1].at - samples[0].at : 0;
- const maximumGap = samples.slice(1).reduce((maximum, entry, index) =>
- Math.max(maximum, entry.at - samples[index].at), 0);
- const visibleSamples = samples.filter((entry) => entry.visibility === 'visible').length;
- const hiddenSamples = samples.filter((entry) => entry.visibility === 'hidden').length;
- const coveredLabel = coveredMs >= 60000 ? `${(coveredMs / 60000).toFixed(1)} 分钟` : `${Math.round(coveredMs / 1000)} 秒`;
- resourceMonitorTrendWindow.textContent = `${coveredLabel} · 前 ${visibleSamples} / 后 ${hiddenSamples}${maximumGap > 1800 ? ` · 最大空档 ${formatRequestFlowDuration(maximumGap)}` : ''}`;
- syncResourceMonitorScopeControls(sample, at, requestEvents, evidenceEvents);
- syncResourceMonitorEventLog(at, evidenceEvents);
- const health = resourceMonitorHealthInfo(samples, sample, at);
- resourceMonitorHealth.dataset.level = health.level;
- resourceMonitorHealthState.textContent = health.state;
- resourceMonitorHealthDetail.textContent = health.detail;
- resourceMonitorUpdated.textContent = `最近快照 ${resourceMonitorTimestamp(at)} · ${sample.visibility === 'visible' ? '前台' : '后台'} · 仅内存保留`;
- };
- const stopResourceMonitorUpdates = () => {
- if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer);
- resourceMonitorState.timer = 0;
- disconnectResourceMonitorObservers();
- if (resourceMonitorState.collecting) {
- const at = resourceMonitorNow();
- resourceMonitorPushTimedEvent(at, {
- scope: 'shared',
- kind: 'capture',
- detail: '资源取证暂停',
- basis: '监控面板生命周期',
- });
- resourceMonitorState.visibilityTimeline.push({ at, state: 'stopped' });
- }
- resourceMonitorState.collecting = false;
- };
- const observeResourceMonitorPerformance = (supported, key, type, callback) => {
- if (!supported) return false;
- try {
- resourceMonitorState[key] = new window.PerformanceObserver(callback);
- resourceMonitorState[key].observe({ type });
- return true;
- } catch {
- resourceMonitorState[key] = null;
- return false;
- }
- };
- const startResourceMonitorUpdates = () => {
- if (resourceMonitorState.collecting) {
- syncResourceMonitorControls();
- return;
- }
- if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer);
- resourceMonitorState.timer = 0;
- resourceMonitorState.collecting = true;
- resourceMonitorState.longTasksAvailable = false;
- recordResourceMonitorVisibility('start');
- resourceMonitorState.longTasksAvailable = observeResourceMonitorPerformance(
- resourceMonitorLongTasksSupported, 'longTaskObserver', 'longtask', (list, ...callbackArgs) => {
- resourceMonitorRecordDroppedEntries('Long Tasks', callbackArgs[1]);
- list.getEntries().forEach((entry) => {
- const startedAt = window.performance.timeOrigin + Number(entry.startTime || 0);
- const duration = Math.max(0, Number(entry.duration) || 0);
- resourceMonitorPushTimedEvent(startedAt, {
- scope: 'shared',
- kind: 'longtask',
- duration,
- detail: `页面共享主线程长任务 ${resourceMonitorDuration(duration)}`,
- basis: 'PerformanceLongTaskTiming · 1ms',
- });
- });
- });
- observeResourceMonitorPerformance(
- resourceMonitorLongFramesSupported, 'longFrameObserver', 'long-animation-frame', (list, ...callbackArgs) => {
- resourceMonitorRecordDroppedEntries('Long Animation Frames', callbackArgs[1]);
- list.getEntries().forEach((entry) => {
- const startedAt = window.performance.timeOrigin + Number(entry.startTime || 0);
- const frameDuration = Math.max(0, Number(entry.duration) || 0);
- const blockingDuration = Math.max(0, Number(entry.blockingDuration) || 0);
- resourceMonitorPushTimedEvent(startedAt, {
- scope: 'shared',
- kind: 'frame',
- duration: frameDuration,
- detail: `长动画帧 ${resourceMonitorDuration(frameDuration)} · 阻塞 ${resourceMonitorDuration(blockingDuration)}`,
- basis: 'Long Animation Frames',
- });
- const groups = new Map();
- [...entry.scripts || []].forEach((script) => {
- const duration = Math.max(0, Number(script.duration) || 0);
- if (!duration) return;
- const scope = resourceMonitorScriptScope(script);
- const group = groups.get(scope) || { duration: 0, forced: 0, count: 0, labels: [] };
- group.duration += duration;
- group.forced += Math.max(0, Number(script.forcedStyleAndLayoutDuration) || 0);
- group.count++;
- if (group.labels.length < 2) group.labels.push(resourceMonitorScriptLabel(script));
- groups.set(scope, group);
- });
- groups.forEach((group, scope) => {
- resourceMonitorPushTimedEvent(startedAt, {
- scope,
- kind: 'script',
- duration: group.duration,
- detail: `长帧内已归因脚本 ${resourceMonitorDuration(group.duration)}${group.forced ? ` · 强制布局 ${resourceMonitorDuration(group.forced)}` : ''} · ${group.labels.join(' / ')}`,
- basis: `PerformanceScriptTiming · ${group.count} 段`,
- });
- });
- });
- });
- if (isFunction(window.MutationObserver)) {
- try {
- resourceMonitorState.readerMutationObserver = new window.MutationObserver((records) => {
- resourceMonitorRecordMutations('reader', records);
- });
- resourceMonitorState.readerMutationObserver.observe(READER_PORTAL_ROOT, {
- childList: true,
- subtree: true,
- });
- resourceMonitorState.hostMutationObserver = new window.MutationObserver((records) => {
- resourceMonitorRecordMutations('host', records);
- });
- resourceMonitorState.hostMutationObserver.observe(PAGE_ROOT, {
- childList: true,
- subtree: true,
- });
- } catch {
- disconnectResourceMonitorObservers(['readerMutationObserver', 'hostMutationObserver']);
- }
- }
- syncResourceMonitorControls();
- resourceMonitorState.timer = setInterval(syncResourceMonitorControls, 1000);
- };
- const syncJumpHighlightControls = () => {
- const config = jumpHighlightDraft;
- JUMP_HIGHLIGHT_SETTING_FIELDS.forEach((field) => {
- const control = jumpHighlightControls[field.key];
- control.input.value = String(config[field.key]);
- control.output.textContent = field.format(config[field.key]);
- if (field.type !== 'color') syncRangeVisual(control.input);
- });
- const changed = settingsDraftChangeCount('flash') > 0;
- jumpHighlightStatus.textContent = changed
- ? '跳转楼层提示正在实时预览,等待统一保存。'
- : '跳转楼层提示当前配置已应用。';
- jumpHighlightStatus.classList.toggle('balanced', !changed);
- applyJumpHighlightSettings(config);
- syncSettingsDraftIndicators();
- };
- let loadingAnimationPreviewRandomKey = '';
- const syncLoadingAnimationControls = (reroll = false) => {
- const preference = normalizeReaderLoadingAnimation(PREFS.loadingAnimation);
- if (preference === 'random') {
- if (reroll || !loadingAnimationPreviewRandomKey) {
- const candidates = READER_LOADING_ANIMATIONS.filter((animation) =>
- animation.key !== loadingAnimationPreviewRandomKey);
- const next = candidates[Math.floor(Math.random() * candidates.length)] || READER_LOADING_ANIMATIONS[0];
- loadingAnimationPreviewRandomKey = next.key;
- }
- } else {
- loadingAnimationPreviewRandomKey = '';
- }
- const animation = resolveReaderLoadingAnimation(preference, loadingAnimationPreviewRandomKey);
- loadingAnimationSelect.value = preference;
- loadingAnimationPreview.replaceChildren();
- loadingAnimationPreview.insertAdjacentHTML('afterbegin', readerLoadingVisualHTML(animation));
- const animationNumber = READER_LOADING_ANIMATIONS.findIndex((candidate) => candidate.key === animation.key) + 1;
- loadingAnimationPreviewLabel.textContent = `${String(animationNumber).padStart(2, '0')} / ${READER_LOADING_ANIMATIONS.length} · ${animation.label}${preference === 'random' ? ' · 随机预览' : ' · 固定使用'}`;
- loadingAnimationReroll.hidden = preference !== 'random';
- };
- const boostCopyDraftFromControls = () => ({
- boostCopyMode: boostCopyModeSelect.value,
- boostCopyPrefix: boostCopyPrefixInput.value,
- boostCopyCounterMarker: boostCopyCounterMarkerInput.value,
- boostCopyCounterStep: boostCopyCounterStepInput.value,
- boostCopyFixedSuffix: boostCopyFixedSuffixInput.value,
- });
- const syncBoostCopyPreview = (prefs = boostCopyDraftFromControls()) => {
- const firstResult = applyBoostCopyRule('原 Boost', prefs);
- const config = boostCopyConfigFromPrefs(prefs);
- boostCopyPreview.textContent = config.mode === 'counter'
- ? `${firstResult} → ${applyBoostCopyRule(firstResult, prefs)}`
- : firstResult;
- };
- const syncCustomSiteControls = () => {
- const sites = readCustomDiscourseSites();
- customSiteList.replaceChildren(...sites.map((host) => {
- const item = makeElement('span');
- item.className = 'ldp-custom-site-item';
- item.innerHTML = `
${esc(host)}`;
- return item;
- }));
- customSiteList.hidden = !sites.length;
- };
- const syncOtherControls = () => {
- syncCustomSiteControls();
- const historyButtonsAlwaysVisible = PREFS.historyButtonsAlwaysVisible === true;
- const historyEdgeTriggerPercent = normalizeHistoryEdgeTriggerPercent(PREFS.historyEdgeTriggerPercent);
- historyButtonsAlwaysVisibleInput.checked = historyButtonsAlwaysVisible;
- readerQueueAlwaysVisibleWhenEmptyInput.checked = PREFS.readerQueueAlwaysVisibleWhenEmpty; overlay._ldpSyncReaderQueue?.();
- historyEdgeTriggerRange.value = String(historyEdgeTriggerPercent);
- historyEdgeTriggerRange.disabled = historyButtonsAlwaysVisible;
- historyEdgeTriggerValue.value = `${historyEdgeTriggerPercent}%`;
- historyEdgeTriggerValue.textContent = `${historyEdgeTriggerPercent}%`;
- syncRangeVisual(historyEdgeTriggerRange);
- overlay._ldpSyncHistoryButtonVisibilityPreference?.();
- historySortModeSelect.value = PREFS.historySortMode;
- openTopicsAtFirstPostInput.checked = PREFS.openTopicsAtFirstPost === true;
- doubleEscapeToCloseReaderInput.checked = PREFS.doubleEscapeToCloseReader;
- confirmNativeComposerCloseInput.checked = PREFS.confirmNativeComposerClose !== false;
- topicActionRailVisibleInput.checked = PREFS.topicActionRailVisible;
- topicActionRailFixedInput.checked = PREFS.topicActionRailFixed;
- readerShell.activeContext?.syncTopicActionRail?.();
- expandNestedRepliesByDefaultInput.checked = PREFS.expandNestedRepliesByDefault === true;
- expandNestedRepliesByDefaultInput.disabled = false;
- expandLeafNestedRepliesInput.checked = PREFS.expandLeafNestedReplies === true;
- aggregateDescendantRepliesInput.checked = PREFS.aggregateDescendantReplies === true;
- aggregateDescendantRepliesInput.disabled = false;
- hideNestedReplyFloorsInput.checked = PREFS.hideNestedReplyFloors === true;
- nestedReplyDisplayWarning.hidden = !(PREFS.expandNestedRepliesByDefault && PREFS.expandLeafNestedReplies);
- const boostCopy = boostCopyConfigFromPrefs(PREFS);
- boostCopyModeSelect.value = boostCopy.mode;
- boostCopyPrefixInput.value = boostCopy.prefix;
- boostCopyCounterMarkerInput.value = boostCopy.counterMarker;
- boostCopyCounterStepInput.value = String(boostCopy.counterStep);
- boostCopyFixedSuffixInput.value = boostCopy.fixedSuffix;
- const counterMode = boostCopy.mode === 'counter';
- boostCopyCounterRows.forEach((row) => { row.hidden = !counterMode; });
- boostCopyTextRows.forEach((row) => { row.hidden = counterMode; });
- syncBoostCopyPreview(PREFS);
- };
- let cacheControlsSyncToken = 0;
- const syncCacheControls = async () => {
- const token = ++cacheControlsSyncToken;
- flushPersistentCacheWrites();
- await flushTopicSnapshotWrites();
- const responseStatsRequest = storedResponseCacheStats();
- const stats = await Promise.all([...cacheSizeEls].map(async (el) => ({
- el,
- stat: await cacheTypeStats(el.dataset.cacheSize, responseStatsRequest),
- })));
- if (token !== cacheControlsSyncToken) return;
- stats.forEach(({ el, stat }) => {
- const key = el.dataset.cacheSize;
- el.textContent = formatCacheTypeStats(key, stat);
- });
- cacheRetentionEls.forEach((el) => {
- el.textContent = cacheRetentionLabel(el.dataset.cacheRetention);
- });
- cacheClearBtn.disabled = ![...cacheSelects].some((input) => input.checked);
- };
- settingHelpTooltip.id = 'ldp-setting-help-tooltip';
- let activeSettingHelpTarget = null;
- const hideSettingHelp = () => {
- if (activeSettingHelpTarget && activeSettingHelpTarget.getAttribute('aria-describedby') === settingHelpTooltip.id)
- activeSettingHelpTarget.removeAttribute('aria-describedby');
- activeSettingHelpTarget = null;
- settingHelpTooltip.classList.remove('is-visible');
- settingHelpTooltip.hidden = true;
- };
- const showSettingHelp = (target) => {
- const copy = target && target.dataset.settingHelp;
- if (!copy) return;
- if (activeSettingHelpTarget && activeSettingHelpTarget !== target) hideSettingHelp();
- activeSettingHelpTarget = target;
- target.setAttribute('aria-describedby', settingHelpTooltip.id);
- settingHelpTooltip.textContent = copy;
- settingHelpTooltip.hidden = false;
- settingHelpTooltip.classList.remove('is-visible');
- const targetRect = target.getBoundingClientRect();
- const tooltipRect = settingHelpTooltip.getBoundingClientRect();
- const margin = 12;
- const gap = 8;
- const bounds = settingsSurfaceBounds();
- const minimumLeft = bounds.left + margin;
- const minimumTop = bounds.top + margin;
- const maximumLeft = Math.max(minimumLeft, bounds.right - tooltipRect.width - margin);
- const left = Math.min(maximumLeft, Math.max(
- minimumLeft,
- targetRect.left + (targetRect.width - tooltipRect.width) / 2,
- ));
- let top = targetRect.top - tooltipRect.height - gap;
- if (top < minimumTop) top = targetRect.bottom + gap;
- top = Math.min(
- Math.max(minimumTop, top),
- Math.max(minimumTop, bounds.bottom - tooltipRect.height - margin),
- );
- settingHelpTooltip.style.left = `${Math.round(left - bounds.left)}px`;
- settingHelpTooltip.style.top = `${Math.round(top - bounds.top)}px`;
- settingHelpTooltip.classList.add('is-visible');
- };
- let userInfoLoadId = 0;
- let userInfoLastRefreshAt = 0;
- let userInfoLastRefreshTimer = 0;
- const stopUserInfoLastRefreshTicker = () => {
- if (userInfoLastRefreshTimer) clearInterval(userInfoLastRefreshTimer);
- userInfoLastRefreshTimer = 0;
- };
- const renderUserInfoLastRefresh = () => {
- if (!userInfoLastRefresh) return;
- if (!userInfoLastRefreshAt) {
- userInfoLastRefresh.textContent = '最后刷新 · 暂无';
- delete userInfoLastRefresh.dataset.ldpTooltipLabel;
- refreshTooltips(userInfoLastRefresh);
- return;
- }
- const elapsed = Math.max(0, Date.now() - userInfoLastRefreshAt);
- const seconds = Math.floor(elapsed / 1000);
- const relative = seconds < 5
- ? '刚刚'
- : seconds < 60
- ? `${seconds} 秒前`
- : seconds < 3600
- ? `${Math.floor(seconds / 60)} 分钟前`
- : seconds < 86400
- ? `${Math.floor(seconds / 3600)} 小时前`
- : `${Math.floor(seconds / 86400)} 天前`;
- const beijingTime = new Date(userInfoLastRefreshAt).toLocaleString('zh-CN', {
- timeZone: 'Asia/Shanghai',
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hour12: false,
- }).replaceAll('/', '-').replace(',', '');
- userInfoLastRefresh.textContent = `最后刷新 · ${relative}`;
- userInfoLastRefresh.dataset.ldpTooltipLabel = `北京时间 ${beijingTime}`;
- refreshTooltips(userInfoLastRefresh);
- };
- const syncUserInfoLastRefresh = (value) => {
- userInfoLastRefreshAt = Number(value || 0);
- renderUserInfoLastRefresh();
- stopUserInfoLastRefreshTicker();
- if (userInfoLastRefreshAt && userInfoContent?.closest('.ldp-settings-section')?.hidden === false)
- userInfoLastRefreshTimer = setInterval(renderUserInfoLastRefresh, 1000);
- };
- const syncUserInfoTitleRefresh = (refreshing) => {
- if (!userInfoTitleRefresh) return;
- userInfoTitleRefresh.disabled = refreshing;
- userInfoTitleRefresh.classList.toggle('is-refreshing', refreshing);
- setLabel(userInfoTitleRefresh, refreshing ? '正在刷新用户信息' : '刷新用户信息');
- };
- const loadUserInfoSettings = async (force = false) => {
- if (!userInfoContent) return;
- const loadId = ++userInfoLoadId;
- if (force) syncUserInfoTitleRefresh(true);
- syncCurrentUserFromHost();
- if (loadId !== userInfoLoadId) return;
- const username = String(ME_USERNAME || ME_CURRENT_USER?.username || '').trim();
- if (!username) {
- userInfoContent.innerHTML = `
当前未登录 ${esc(SITE_DISPLAY_NAME)},无法读取用户资料。
`;
- syncUserInfoTitleRefresh(false);
- return;
- }
- const cachedUser = getCachedUserCard(username);
- const cachedConnect = cachedUser?._ldp_connect_trust || null;
- syncUserInfoLastRefresh(Math.max(
- Number(cachedUser && (cachedUser._ldp_user_info_checked_at || cachedUser._ldp_summary_checked_at) || 0),
- Number(cachedConnect?.cachedAt || 0),
- ));
- if (cachedUser)
- renderSettingsUserInfo(
- userInfoContent,
- Object.assign({}, cachedUser, ME_CURRENT_USER || {}, { username }),
- cachedConnect?.data,
- null,
- null,
- null
- );
- else
- userInfoContent.innerHTML = '
正在加载用户信息…
';
- const connectAvailable = hasDiscourseCapability('connect');
- const creditAvailable = SITE_ADAPTER.id === 'linux-do';
- const [profileResult, connectResult, creditResult] = await Promise.allSettled([
- fetchSettingsUserInfo(username, force),
- connectAvailable ? fetchConnectTrustData(force, cachedConnect) : Promise.resolve(null),
- creditAvailable ? fetchLinuxDoCreditUser(username, force) : Promise.resolve(null),
- ]);
- if (loadId !== userInfoLoadId) return;
- const profile = profileResult.status === 'fulfilled' ? profileResult.value : cachedUser;
- const user = Object.assign({}, cachedUser || {}, ME_CURRENT_USER || {}, profile || {}, { username });
- const connectData = connectResult.status === 'fulfilled'
- ? connectResult.value
- : cachedConnect?.data || null;
- const connectError = connectResult.status === 'rejected' ? connectResult.reason : null;
- const creditUser = creditResult.status === 'fulfilled' ? creditResult.value : null;
- const creditError = creditResult.status === 'rejected' ? creditResult.reason : null;
- if (connectAvailable && connectResult.status === 'fulfilled')
- user._ldp_connect_trust = {
- data: connectData,
- cachedAt: Number(CONNECT_TRUST_CACHE?.cachedAt || Date.now()),
- };
- if (profileResult.status === 'fulfilled' || connectResult.status === 'fulfilled') {
- setCachedUserCard(username, user, 0);
- syncUserInfoLastRefresh(Math.max(
- Number(user._ldp_user_info_checked_at || user._ldp_summary_checked_at || 0),
- Number(user._ldp_connect_trust?.cachedAt || 0),
- Date.now(),
- ));
- }
- renderSettingsUserInfo(userInfoContent, user, connectData, connectError, creditUser, creditError);
- syncUserInfoTitleRefresh(false);
- };
- const settingsDraftConfigs = {
- font: {
- label: '字体设置', fields: FONT_PROFILE_VALUE_KEYS,
- draft: currentFontDraft, saved: currentFontProfile,
- reset: () => { fontDraft = currentFontProfile(); },
- patch: () => ({ fontProfile: { ...currentFontDraft() } }),
- apply: applyFontScales, sync: syncFontControls, beforeSave: closeFontFamilyMenu,
- },
- layout: {
- label: '布局设置', fields: LAYOUT_REGION_KEYS,
- draft: currentLayoutDraft, saved: currentSavedLayoutRatios,
- reset: () => { layoutDraft = currentSavedLayoutRatios(); },
- patch: () => ({ [currentLayoutProfileKey()]: { ...currentLayoutDraft() } }),
- apply: applyLayoutRatios, sync: syncLayoutControls,
- changed: (draft, saved, field) => Math.abs(draft[field] - saved[field]) >= 0.01,
- valid: () => layoutRatioTotal(currentLayoutDraft()) === 100,
- },
- appearance: {
- label: '外观设置', fields: APPEARANCE_PROFILE_VALUE_KEYS,
- draft: currentAppearanceDraft, saved: currentAppearanceProfile,
- reset: () => { appearanceDraft = currentAppearanceProfile(); },
- patch: () => ({ appearanceProfile: { ...currentAppearanceDraft() } }),
- apply: applyAppearanceColors, sync: syncAppearanceControls,
- },
- flash: {
- label: '动画与提示', fields: JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => field.key),
- draft: () => jumpHighlightDraft, saved: () => jumpHighlightConfigFromPrefs(PREFS),
- reset: () => { jumpHighlightDraft = jumpHighlightConfigFromPrefs(PREFS); },
- patch: () => jumpHighlightPrefsPatch(jumpHighlightDraft),
- apply: applyJumpHighlightSettings, sync: syncJumpHighlightControls,
- },
- };
- const settingsDraftChangeCount = (panel) => {
- const config = settingsDraftConfigs[panel];
- if (!config) return 0;
- const draft = config.draft();
- const saved = config.saved();
- return config.fields.reduce((count, field) =>
- count + (config.changed ? config.changed(draft, saved, field) : draft[field] !== saved[field]), 0);
- };
- const resetSettingsDrafts = (except = '') => {
- Object.entries(settingsDraftConfigs).forEach(([panel, config]) => {
- if (panel === except) return;
- config.reset();
- config.apply();
- });
- syncSettingsDraftIndicators();
- };
- let activeSettingsPanel = 'user', activeSettingsLogPanel = 'request';
- const normalizeSettingsSearch = (value) => String(value || '').trim().toLocaleLowerCase();
- const settingsMeta = (name) => SETTINGS_PANEL_META.find(([id]) => id === name);
- const syncSettingsLogUpdates = () => {
- const open = activeSettingsPanel === 'logs' && !settingsPopover.hidden;
- (open && activeSettingsLogPanel === 'performance' ? startResourceMonitorUpdates : stopResourceMonitorUpdates)();
- (open && activeSettingsLogPanel === 'request' && document.visibilityState === 'visible' ? startRequestFlowUpdates : stopRequestFlowUpdates)();
- };
- const showSettingsLogPanel = (name) => {
- activeSettingsLogPanel = name === 'performance' ? 'performance' : 'request';
- syncTabSelection(settingsLogTabs, 'settingsLogTab', activeSettingsLogPanel, true);
- settingsLogPanels.forEach((panel) => setHidden(panel, panel.dataset.settingsLogPanel !== activeSettingsLogPanel));
- syncSettingsLogUpdates();
- };
- const setActiveSettingsPanel = (name) => {
- if (activeSettingsPanel === name) return;
- activeSettingsPanel = name;
- settingsTabs.forEach((tab) => {
- const active = tab.dataset.settingsPanel === name;
- tab.classList.toggle('active', active);
- tab.setAttribute('aria-current', active ? 'page' : 'false');
- });
- settingsTabs.find((tab) => tab.dataset.settingsPanel === name)?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
- if (name !== 'user') stopUserInfoLastRefreshTicker();
- syncSettingsLogUpdates();
- };
- const settingsSearchAliases = {
- image: '灯箱',
- font: '宿主 嵌入态',
- window: '置顶',
- appearance: '斑马线',
- flash: '动效',
- reading: '其他设置 队列 历史 导航 打开 退出',
- interaction: '其他设置 主贴 操作 回复 楼中楼 boost 复制',
- sites: '其他设置 自定义 站点 论坛 discourse',
- performance: 'dom 窗口预算 并发',
- logs: '日志 请求 网络 流量 限流 性能 资源 监控 内存 cpu dom',
- };
- const applySettingsSearch = (preferred = activeSettingsPanel) => {
- const query = normalizeSettingsSearch(settingsSearchInput?.value);
- let count = 0, first = '', selected = '';
- setHidden(settingsSearchClear, !query);
- settingsSections.forEach((section) => {
- const name = section.dataset.settingsPanel || '';
- if (name === 'user') return;
- const panel = settingsMeta(name);
- const match = !query || normalizeSettingsSearch(`${panel?.slice(2).join(' ') || ''} ${settingsSearchAliases[name] || ''} ${section.textContent}`).includes(query);
- const tab = settingsTabs.find((entry) => entry.dataset.settingsPanel === name);
- if (tab) setHidden(tab, !!query && !match);
- if (match) { count++; first ||= name; if (name === preferred) selected = name; }
- });
- selected ||= first;
- settingsSections.forEach((section) => setHidden(section, section.dataset.settingsPanel !== selected));
- settingsNavGroups.forEach((group) => setHidden(group, !!query &&
- ![...group.querySelectorAll('.ldp-settings-tab')].some((tab) => !tab.hidden)));
- if (/性能|资源|监控|内存|cpu|dom/.test(query)) showSettingsLogPanel('performance');
- else if (/请求|网络|流量|限流/.test(query)) showSettingsLogPanel('request');
- settingsSearchStatus.textContent = query ? count ? `找到 ${count} 个设置分区` : '没有匹配结果' : '输入名称或功能即可筛选';
- setHidden(settingsSearchEmpty, !query || count > 0);
- setActiveSettingsPanel(selected);
- return selected;
- };
- const syncSettingsPageControls = () => [
- syncImageScaleControls, syncFontControls, syncLayoutControls, syncReaderWindowControls,
- syncAppearanceControls, syncJumpHighlightControls, syncLoadingAnimationControls,
- syncPerformanceControls, syncOtherControls, syncCacheControls,
- ].forEach((sync) => sync());
- const showSettingsPanel = (name) => {
- closeFontFamilyMenu();
- hideSettingHelp();
- settingsRangeSaveReminderCategory = '';
- const nextName = settingsMeta(name) ? name : 'image', userMode = nextName === 'user';
- const wasPages = settingsPanel.classList.contains('is-settings-pages');
- settingsPanel.classList.toggle('is-settings-pages', !userMode);
- setHidden(settingsSearchShell, userMode);
- if (userMode) {
- settingsSearchInput.value = '';
- settingsSections.forEach((section) => setHidden(section, section.dataset.settingsPanel !== 'user'));
- settingsTabs.forEach((tab) => setHidden(tab, false));
- settingsNavGroups.forEach((group) => setHidden(group, false));
- setHidden(settingsSearchEmpty, true);
- setActiveSettingsPanel('user');
- settingsPanel.scrollTop = 0;
- void loadUserInfoSettings(false);
- } else {
- if (!wasPages) syncSettingsPageControls();
- if (settingsTabs.find((tab) => tab.dataset.settingsPanel === nextName)?.hidden) settingsSearchInput.value = '';
- applySettingsSearch(nextName);
- settingsPanel.scrollTop = 0;
- }
- syncSettingsDraftIndicators();
- syncSettingsLogUpdates();
- };
- let settingsRangeDragRow = null;
- let settingsColorPickRow = null;
- let settingsWindowDrag = null;
- let settingsWindowDragFrame = 0;
- let settingsRangeSaveReminderCategory = '';
- const settingsDraftSummary = () => Object.entries(settingsDraftConfigs)
- .map(([panel, config]) => ({ panel, label: config.label, count: settingsDraftChangeCount(panel) }))
- .filter((entry) => entry.count > 0);
- function syncSettingsDraftIndicators() {
- const summary = settingsDraftSummary();
- const counts = Object.fromEntries(summary.map((entry) => [entry.panel, entry.count]));
- const total = summary.reduce((count, entry) => count + entry.count, 0);
- setHidden(settingsDraftBar, !settingsPanel.classList.contains('is-settings-pages') || !total);
- setText(settingsDraftStatus, `${total} 项更改未保存 · ${summary.map((entry) => entry.label).join('、')}`);
- settingsSaveAllBtn.disabled = !total;
- settingsTabs.forEach((tab) => {
- const count = counts[tab.dataset.settingsPanel] || 0;
- const badge = tab.querySelector('.ldp-settings-tab-draft-count');
- const label = settingsMeta(tab.dataset.settingsPanel)?.[2] || '设置';
- tab.classList.toggle('has-draft', count > 0);
- setLabel(tab, count > 0 ? `${label},${count} 项未保存` : label);
- if (!badge) return;
- setHidden(badge, count <= 0);
- setText(badge, count > 99 ? '99+' : String(count));
- });
- }
- const remindUnsavedRangeSetting = (range) => {
- const category = range.closest('.ldp-settings-section')?.dataset.settingsPanel || '';
- if (!settingsDraftChangeCount(category)) {
- if (settingsRangeSaveReminderCategory === category) settingsRangeSaveReminderCategory = '';
- return;
- }
- if (settingsRangeSaveReminderCategory === category) return;
- settingsRangeSaveReminderCategory = category;
- showSelectionToast('修改已实时预览,可在面板底部统一保存全部更改。');
- };
- const stopSettingsRangeDrag = () => {
- settingsPopover.classList.remove('ldp-range-dragging');
- if (settingsRangeDragRow) settingsRangeDragRow.classList.remove('ldp-range-drag-active');
- settingsRangeDragRow = null;
- };
- const stopSettingsColorPick = () => {
- settingsPopover.classList.remove('ldp-color-picking');
- if (settingsColorPickRow) settingsColorPickRow.classList.remove('ldp-color-pick-active');
- settingsColorPickRow = null;
- };
- const moveSettingsWindowTo = (left, top, width, height) => {
- const margin = READER_SETTINGS_SURFACE_MARGIN;
- const bounds = settingsSurfaceBounds();
- const minimumLeft = bounds.left + margin;
- const minimumTop = bounds.top + margin;
- const maximumLeft = Math.max(minimumLeft, bounds.right - width - margin);
- const maximumTop = Math.max(minimumTop, bounds.bottom - height - margin);
- const position = {
- left: Math.round(Math.min(maximumLeft, Math.max(minimumLeft, left))),
- top: Math.round(Math.min(maximumTop, Math.max(minimumTop, top))),
- };
- setStyleValue(settingsPopover, 'left', `${position.left - bounds.left}px`);
- setStyleValue(settingsPopover, 'top', `${position.top - bounds.top}px`);
- setStyleValue(settingsPopover, 'transform', 'none');
- return position;
- };
- const keepSettingsWindowVisible = () => {
- if (settingsPopover.style.transform !== 'none') return;
- const rect = settingsPopover.getBoundingClientRect();
- moveSettingsWindowTo(rect.left, rect.top, rect.width, rect.height);
- };
- const syncSettingsSurfaceGeometry = ({ closeTransients = true } = {}) => {
- syncSettingsSurfaceHost();
- if (closeTransients) {
- closeFontFamilyMenu();
- closeColorPickerPopover();
- hideSettingHelp();
- }
- syncReaderSurfaceDensity();
- if (!settingsPopover.hidden) keepSettingsWindowVisible();
- };
- if (!readerShell.syncSettingsSurfaceGeometry) {
- readerShell.syncSettingsSurfaceGeometry = syncSettingsSurfaceGeometry;
- }
- const renderSettingsWindowDrag = () => {
- settingsWindowDragFrame = 0;
- if (!settingsWindowDrag) return;
- const drag = settingsWindowDrag;
- const margin = READER_SETTINGS_SURFACE_MARGIN;
- const bounds = drag.bounds;
- const minimumLeft = bounds.left + margin;
- const minimumTop = bounds.top + margin;
- const maximumLeft = Math.max(minimumLeft, bounds.right - drag.width - margin);
- const maximumTop = Math.max(minimumTop, bounds.bottom - drag.height - margin);
- drag.previewLeft = Math.min(maximumLeft, Math.max(
- minimumLeft,
- drag.startLeft + drag.clientX - drag.startX,
- ));
- drag.previewTop = Math.min(maximumTop, Math.max(
- minimumTop,
- drag.startTop + drag.clientY - drag.startY,
- ));
- settingsPopover.style.transform = `translate3d(${drag.previewLeft - drag.startLeft}px,${drag.previewTop - drag.startTop}px,0)`;
- };
- const stopSettingsWindowDrag = (event) => {
- if (!settingsWindowDrag || (event && event.pointerId !== settingsWindowDrag.pointerId)) return;
- if (settingsWindowDragFrame) {
- cancelAnimationFrame(settingsWindowDragFrame);
- settingsWindowDragFrame = 0;
- }
- renderSettingsWindowDrag();
- const drag = settingsWindowDrag;
- settingsWindowDrag = null;
- moveSettingsWindowTo(
- drag.previewLeft ?? drag.startLeft,
- drag.previewTop ?? drag.startTop,
- drag.width,
- drag.height,
- );
- settingsPopover.classList.remove('ldp-settings-window-dragging');
- try {
- if (drag.handle.hasPointerCapture(drag.pointerId)) drag.handle.releasePointerCapture(drag.pointerId);
- } catch {}
- };
- const closeSettings = () => {
- closeFontFamilyMenu();
- stopSettingsRangeDrag();
- stopSettingsColorPick();
- stopSettingsWindowDrag();
- stopResourceMonitorUpdates();
- stopRequestFlowUpdates();
- stopUserInfoLastRefreshTicker();
- hideSettingHelp();
- settingsRangeSaveReminderCategory = '';
- fontFamilyManualEditing.clear();
- setHidden(settingsPopover, true);
- syncSettingsSurfaceHost();
- setExpanded(settingsBtn, false);
- resetSettingsDrafts();
- };
- const saveSettingsDraft = (panel) => {
- const config = settingsDraftConfigs[panel];
- if (!config || !settingsDraftChangeCount(panel)) return true;
- if (config.valid && !config.valid()) return false;
- config.beforeSave?.();
- setPrefs(config.patch());
- settingsRangeSaveReminderCategory = '';
- config.reset();
- config.apply();
- config.sync();
- return true;
- };
- const saveAllSettingsDrafts = () => ['layout', 'font', 'appearance', 'flash'].every(saveSettingsDraft);
- const requestCloseSettings = () => {
- const summary = settingsDraftSummary();
- if (summary.length) {
- const total = summary.reduce((count, entry) => count + entry.count, 0);
- openReaderConfirmDialog(readerShell.activeContext, {
- title: `${total} 项设置尚未保存`,
- message: '草稿已保留在各设置页。可以保存全部修改后关闭,也可以明确放弃这些草稿。',
- details: summary.map((entry) => ({ label: entry.label, value: `${entry.count} 项` })),
- note: '选择“继续编辑”会回到当前设置页,草稿不会丢失。',
- icon: 'check',
- tone: 'primary',
- cancelLabel: '继续编辑',
- secondaryLabel: '放弃并关闭',
- confirmLabel: '保存全部并关闭',
- onSecondary: closeSettings,
- onConfirm: () => {
- if (!saveAllSettingsDrafts()) {
- showSettingsPanel('layout');
- showSelectionToast('布局比例还没有分配到 100%,请完成后再保存。');
- return;
- }
- closeSettings();
- showSelectionToast(`已保存 ${total} 项设置`);
- },
- });
- return false;
- }
- closeSettings();
- return true;
- };
- const closeReaderFloatingPanels = () => {
- closeFontFamilyMenu();
- if (!settingsPopover.hidden) requestCloseSettings();
- closeNotifications();
- closeHistory();
- closeBookmarks();
- };
- const readerSurfaceController = readerShell.surfaceController || {
- closeFloatingPanels: closeReaderFloatingPanels,
- closeNotifications,
- closeHistory,
- closeBookmarks,
- closeFontFamilyMenu,
- requestCloseSettings,
- syncOpenSettingsControls,
- };
- readerShell.surfaceController = readerSurfaceController;
- if (!reusingShell) {
- applyReaderVisualSettings();
- applyJumpHighlightSettings();
- syncOtherControls();
- settingsTabs.forEach((tab) => {
- onShell(tab, 'click', () => showSettingsPanel(tab.dataset.settingsPanel || 'image'));
- });
- settingsLogTabs.forEach((tab) => onShell(tab, 'click', () => showSettingsLogPanel(tab.dataset.settingsLogTab)));
- bindReaderPanelSearch(settingsSearchInput, settingsSearchClear, () => { applySettingsSearch(); settingsPanel.scrollTop = 0; });
- onShell(settingsSaveAllBtn, 'click', () => {
- const total = settingsDraftSummary().reduce((count, entry) => count + entry.count, 0);
- if (!saveAllSettingsDrafts()) {
- showSettingsPanel('layout');
- return showSelectionToast('布局比例还没有分配到 100%,请完成后再保存。');
- }
- showSelectionToast(`已保存 ${total} 项设置`);
- });
- onShell(loadingAnimationSelect, 'change', () => {
- setPref('loadingAnimation', normalizeReaderLoadingAnimation(loadingAnimationSelect.value));
- loadingAnimationPreviewRandomKey = '';
- syncLoadingAnimationControls();
- });
- onShell(loadingAnimationSelect, 'wheel', (event) => event.stopPropagation(), { passive: true });
- onShell(loadingAnimationReroll, 'click', () => syncLoadingAnimationControls(true));
- if (userInfoContent) onShell(userInfoContent, 'click', (event) => {
- const viewTab = event.target.closest('[data-user-info-view]');
- if (viewTab) {
- const view = ['profile', 'credit'].includes(viewTab.dataset.userInfoView) ? viewTab.dataset.userInfoView : 'connect';
- userInfoContent.dataset.userInfoView = view;
- syncTabSelection(userInfoContent.querySelectorAll('[data-user-info-view]'), 'userInfoView', view);
- userInfoContent.querySelectorAll('[data-user-info-panel]').forEach((panel) => {
- panel.hidden = panel.dataset.userInfoPanel !== view;
- });
- }
- });
- if (userInfoTitleRefresh) onShell(userInfoTitleRefresh, 'click', () => void loadUserInfoSettings(true));
- onShell(settingsPopover, 'pointerdown', (event) => {
- const row = event.target.closest('.ldp-settings-intro,.ldp-settings-search-shell');
- if (!row || (row.classList.contains('ldp-settings-intro') && settingsPanel.classList.contains('is-settings-pages'))) return;
- if (event.button !== 0) return;
- if (event.target.closest('.ldp-user-info-title-refresh,.ldp-settings-search')) return;
- if (settingsSurfaceIsCompact()) return;
- const rect = settingsPopover.getBoundingClientRect();
- closeFontFamilyMenu();
- stopSettingsRangeDrag();
- stopSettingsColorPick();
- hideSettingHelp();
- const position = moveSettingsWindowTo(rect.left, rect.top, rect.width, rect.height);
- settingsWindowDrag = {
- pointerId: event.pointerId,
- handle: row,
- startX: event.clientX,
- startY: event.clientY,
- clientX: event.clientX,
- clientY: event.clientY,
- startLeft: position.left,
- startTop: position.top,
- width: rect.width,
- height: rect.height,
- bounds: settingsSurfaceBounds(),
- };
- settingsPopover.classList.add('ldp-settings-window-dragging');
- try { row.setPointerCapture(event.pointerId); } catch {}
- event.preventDefault();
- });
- onShell(settingsPopover, 'pointermove', (event) => {
- if (!settingsWindowDrag || event.pointerId !== settingsWindowDrag.pointerId) return;
- const coalescedEvents = isFunction(event.getCoalescedEvents) ? event.getCoalescedEvents() : [];
- const latestEvent = coalescedEvents[coalescedEvents.length - 1] || event;
- settingsWindowDrag.clientX = latestEvent.clientX;
- settingsWindowDrag.clientY = latestEvent.clientY;
- if (!settingsWindowDragFrame) settingsWindowDragFrame = requestAnimationFrame(renderSettingsWindowDrag);
- event.preventDefault();
- });
- const stopSettingsPointerInteractions = (event) => {
- stopSettingsWindowDrag(event);
- stopSettingsRangeDrag();
- };
- ['pointerup', 'pointercancel'].forEach((type) =>
- onShell(settingsPopover, type, stopSettingsPointerInteractions));
- onShell(settingsPopover, 'lostpointercapture', stopSettingsPointerInteractions, true);
- onShell(configExportBtn, 'click', () => {
- try {
- downloadReaderConfigExport();
- showSelectionToast('设置配置已导出');
- } catch {
- showSelectionToast('导出失败,请重试');
- }
- });
- onShell(configImportBtn, 'click', () => {
- configFileInput.value = '';
- configFileInput.click();
- });
- onShell(configFileInput, 'change', async () => {
- const file = configFileInput.files?.[0];
- if (!file) return;
- let importedPrefs;
- try {
- importedPrefs = readerPrefsFromConfigExport(JSON.parse(await file.text()));
- } catch {
- showSelectionToast('配置文件无效或版本不匹配,请选择当前版本导出的 JSON 文件');
- return;
- }
- openReaderConfirmDialog(readerShell.activeContext, {
- title: '导入这份设置配置?',
- message: `将使用“${file.name}”覆盖当前阅读器设置,并刷新页面。`,
- note: '浏览历史和缓存不会改变;浮窗尺寸与位置会自动限制在当前屏幕范围内。',
- confirmLabel: '导入并刷新',
- onConfirm: () => replacePrefsAndReload(importedPrefs, '导入失败,浏览器未能保存设置'),
- });
- });
- onShell(configResetBtn, 'click', () => {
- openReaderConfirmDialog(readerShell.activeContext, {
- title: '恢复全部默认设置?',
- message: '图片、字体、布局、浮窗、外观、动效、性能、阅读、交互与站点设置都会恢复默认,并刷新页面。',
- note: '浏览历史和缓存不会被删除。',
- confirmLabel: '恢复并刷新',
- onConfirm: () => replacePrefsAndReload(DEFAULT_PREFS, '恢复失败,浏览器未能保存设置'),
- });
- });
- cacheSelects.forEach((input) => onShell(input, 'change', () => {
- cacheClearBtn.disabled = ![...cacheSelects].some((candidate) => candidate.checked);
- }));
- onShell(cacheClearBtn, 'click', async () => {
- const selected = [...cacheSelects].filter((input) => input.checked).map((input) => input.value);
- if (!selected.length) return;
- cacheClearBtn.disabled = true;
- await clearSelectedCaches(selected);
- cacheSelects.forEach((input) => { input.checked = false; });
- await syncCacheControls();
- });
- performancePresetButtons.forEach((button) => {
- onShell(button, 'click', () => {
- const name = button.dataset.performancePreset;
- if (name === 'custom') {
- const firstInput = performanceInputs[0];
- if (firstInput) firstInput.focus();
- return;
- }
- setPerformancePreset(name);
- performanceSettingsChanged = true;
- syncPerformanceControls();
- });
- });
- performanceInputs.forEach((input) => {
- onShell(input, 'change', () => {
- setPerformanceValue(input.dataset.performanceKey, input.value);
- performanceSettingsChanged = true;
- syncPerformanceControls();
- });
- });
- onShell(performanceResetBtn, 'click', () => {
- setPerformancePreset('balanced');
- performanceSettingsChanged = true;
- syncPerformanceControls();
- });
- const previewJumpHighlightControls = () => {
- const config = Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) =>
- [field.key, jumpHighlightControls[field.key].input.value]));
- jumpHighlightDraft = jumpHighlightConfigFromPrefs(Object.assign({}, PREFS, jumpHighlightPrefsPatch(config)));
- syncJumpHighlightControls();
- };
- jumpHighlightInputs.forEach((input) => onShell(input, 'input', previewJumpHighlightControls));
- [
- [historyButtonsAlwaysVisibleInput, (checked) => setPref('historyButtonsAlwaysVisible', checked), syncOtherControls],
- [readerQueueAlwaysVisibleWhenEmptyInput, (checked) => setPref('readerQueueAlwaysVisibleWhenEmpty', checked), syncOtherControls],
- [openTopicsAtFirstPostInput, (checked) => setPref('openTopicsAtFirstPost', checked), syncOtherControls],
- [doubleEscapeToCloseReaderInput, (checked) => setPref('doubleEscapeToCloseReader', checked), syncOtherControls],
- [confirmNativeComposerCloseInput, (checked) => setPref('confirmNativeComposerClose', checked), syncOtherControls],
- [topicActionRailVisibleInput, (checked) => setPref('topicActionRailVisible', checked), syncOtherControls],
- [topicActionRailFixedInput, (checked) => setPref('topicActionRailFixed', checked), syncOtherControls],
- [aggregateDescendantRepliesInput, (checked) => {
- setPrefs({
- aggregateDescendantReplies: checked,
- expandNestedRepliesByDefault: checked ? true : PREFS.expandNestedRepliesByDefault,
- });
- readerShell.activeContext?.subReplyState.forEach((state) => { state.pageIndex = 0; state.renderedKey = ''; });
- readerShell.activeContext?.subReplyState.forEach((state, postNumber) => renderSubReplyBatch(postNumber, readerShell.activeContext));
- applyNestedReplyDisplayPreference(readerShell.activeContext);
- }, syncOtherControls],
- [hideNestedReplyFloorsInput, (checked) => { setPref('hideNestedReplyFloors', checked); applyNestedReplyDisplayPreference(readerShell.activeContext, true); }, syncOtherControls],
- [lightboxOriginalByDefaultInput, (checked) => setPref('lightboxOriginalByDefault', checked)],
- [lightboxCommentsExpandedInput, (checked) => setPref('lightboxCommentsExpandedByDefault', checked)],
- [lightboxDescriptionExpandedInput, (checked) => setPref('lightboxDescriptionExpanded', checked)],
- [fontRenderingEnabledInput, (checked) => setPref('fontRenderingEnabled', checked)],
- [fontRenderingOnHostInput, (checked) => setPref('fontRenderingOnHost', checked)],
- [readerWindowPinInput, (checked) => readerWindow.setPinned(checked), syncReaderWindowControls],
- [readerWindowLockInput, (checked) => readerWindow.setLocked(checked), syncReaderWindowControls],
- ].forEach(([input, save, sync]) => onShell(input, 'change', () => {
- save(input.checked);
- if (sync) sync();
- }));
- onShell(topicActionRailResetBtn, 'click', () => { setPref('topicActionRailPosition', { x: 'left', y: .95 }); syncOtherControls(); });
- onShell(historyEdgeTriggerRange, 'input', () => {
- setPref('historyEdgeTriggerPercent', normalizeHistoryEdgeTriggerPercent(historyEdgeTriggerRange.value));
- syncOtherControls();
- });
- onShell(historySortModeSelect, 'change', () => {
- setPref('historySortMode', historySortModeSelect.value);
- syncOtherControls();
- syncHistorySortToggle();
- overlay._ldpRefreshReaderHistoryNavigation?.();
- });
- const addCustomSite = async () => {
- const host = normalizeCustomDiscourseHost(customSiteInput.value);
- if (!host || BUILTIN_DISCOURSE_HOSTS.has(host)) {
- customSiteStatus.textContent = host
- ? `${host} 已内置支持,无需重复添加。`
- : '请输入有效的 HTTPS 域名或网址。';
- return;
- }
- customSiteAddBtn.disabled = true;
- customSiteStatus.textContent = '正在检测 Discourse…';
- try {
- const info = await validateCustomDiscourseSite(host);
- await writeCustomDiscourseSites([...readCustomDiscourseSites(), host]);
- customSiteInput.value = '';
- syncCustomSiteControls();
- customSiteStatus.textContent = `已添加 ${info.title || host},访问该站即可使用。`;
- } catch (error) {
- customSiteStatus.textContent = `${error.message || '检测失败'};仅支持 Discourse 论坛。`;
- } finally {
- customSiteAddBtn.disabled = false;
- }
- };
- onShell(customSiteAddBtn, 'click', addCustomSite);
- onShell(customSiteInput, 'keydown', (event) => {
- if (event.key === 'Enter') addCustomSite();
- });
- onShell(customSiteList, 'click', async (event) => {
- const host = event.target.closest('[data-custom-site-remove]')?.dataset.customSiteRemove;
- if (!host) return;
- await writeCustomDiscourseSites(readCustomDiscourseSites().filter((site) => site !== host));
- syncCustomSiteControls();
- customSiteStatus.textContent = `已移除 ${host}。`;
- });
- const saveNestedReplyDisplayPreference = (changedInput) => {
- const aggregate = PREFS.aggregateDescendantReplies === true;
- let expandAll = expandNestedRepliesByDefaultInput.checked;
- let expandLeaf = expandLeafNestedRepliesInput.checked;
- if (changedInput === expandNestedRepliesByDefaultInput && !expandAll) expandLeaf = true;
- else if (!aggregate && changedInput === expandLeafNestedRepliesInput && !expandLeaf) expandAll = true;
- setPrefs({
- expandNestedRepliesByDefault: expandAll,
- expandLeafNestedReplies: expandLeaf,
- aggregateDescendantReplies: expandAll && aggregate,
- });
- applyNestedReplyDisplayPreference(readerShell.activeContext);
- syncOtherControls();
- };
- [expandNestedRepliesByDefaultInput, expandLeafNestedRepliesInput].forEach((input) =>
- onShell(input, 'change', () => saveNestedReplyDisplayPreference(input)));
- const saveBoostCopyControls = () => {
- setPrefs(boostCopyDraftFromControls());
- syncOtherControls();
- };
- onShell(boostCopyModeSelect, 'change', saveBoostCopyControls);
- [boostCopyPrefixInput, boostCopyCounterMarkerInput, boostCopyCounterStepInput, boostCopyFixedSuffixInput]
- .forEach((input) => {
- onShell(input, 'input', () => syncBoostCopyPreview());
- onShell(input, 'change', saveBoostCopyControls);
- });
- onShell(notificationPanel.markAll, 'click', async () => {
- if (NOTIFICATION_GROUPS[notificationState.group]?.source !== 'notifications' ||
- notificationPanel.markAll.disabled ||
- notificationPanel.markAll.dataset.ldpRequestBusy === '1') return;
- setReaderControlBusy(notificationPanel.markAll, true);
- const previousHtml = notificationPanel.markAll.innerHTML;
- notificationPanel.markAll.innerHTML = `${icon('loader')}
处理中`;
- try {
- await apiSend(`${BASE}/notifications/mark-read`, 'PUT');
- syncCurrentUserNotificationsDismissed();
- markCachedNotificationsRead();
- notificationPanel.list.querySelectorAll('.ldp-notification-item.unread')
- .forEach((item) => {
- item.classList.remove('unread');
- const readState = item.querySelector('.ldp-notification-read-state');
- if (readState) readState.textContent = '已读';
- });
- showSelectionToast('消息已全部标为已读');
- } catch (error) {
- alert(`标记已读失败:${error.message}`);
- } finally {
- setReaderControlBusy(notificationPanel.markAll, false);
- notificationPanel.markAll.innerHTML = previousHtml;
- notificationPanel.markAll.disabled = cachedUnreadNotificationCount(notificationState.group) <= 0;
- }
- });
- const bindCollectionPanelToggle = (controller, peers, openPanel) => {
- onShell(controller.toggle, 'click', (event) => {
- consumeEvent(event);
- const opening = controller.popover.hidden;
- peers.forEach((peer) => peer.close());
- if (!requestCloseSettings()) return;
- if (!opening) return controller.close();
- controller.open();
- openPanel();
- });
- };
- bindCollectionPanelToggle(notificationCollection, [historyCollection, bookmarksCollection], () => {
- positionNotificationsPopover();
- syncCurrentUserFromHost();
- readerShell.startNotificationLiveEvents?.();
- notificationState.page = 0;
- loadNotificationPage(notificationState.group, 0, { force: true });
- });
- bindCollectionPanelToggle(historyCollection, [notificationCollection, bookmarksCollection], renderHistoryPage);
- bindCollectionPanelToggle(bookmarksCollection, [notificationCollection, historyCollection], () => {
- positionBookmarksPopover();
- loadBookmarksPage();
- });
- notificationTabs.forEach((tab) => {
- onShell(tab, 'click', () => {
- notificationState.group = tab.dataset.notificationGroup || 'all';
- notificationState.page = 0;
- loadNotificationPage(notificationState.group, notificationState.page);
- });
- });
- notificationModeTabs.forEach((tab) => {
- onShell(tab, 'click', () => {
- const mode = tab.dataset.notificationMode || 'notifications';
- const targetTab = notificationTabs.find((entry) =>
- entry.dataset.notificationGroup === notificationState.groups[mode]
- ) || notificationTabs.find((entry) =>
- NOTIFICATION_GROUPS[entry.dataset.notificationGroup]?.mode === mode
- );
- if (targetTab) loadNotificationPage(targetTab.dataset.notificationGroup, 0);
- });
- });
- const collectionSearchCancels = {};
- const bindCollectionNavigation = (key, panel, state, search, page, delay = 0) => {
- collectionSearchCancels[key] = bindReaderPanelSearch(panel.search, panel.searchClear, (query) => {
- state.query = query;
- state.page = 0;
- search(query);
- }, delay);
- bindReaderPanelPager(panel.pagePrev, panel.pageNext, state, page);
- };
- bindCollectionNavigation('notification', notificationPanel, notificationState,
- () => loadNotificationPage(notificationState.group, 0),
- () => loadNotificationPage(notificationState.group, notificationState.page));
- onShell(historyPanel.sortToggle, 'click', () => {
- setPref('historySortMode', PREFS.historySortMode === 'recent-viewed' ? 'first-viewed' : 'recent-viewed');
- historyState.page = 0;
- historySelection.clear();
- syncOtherControls();
- renderHistoryPage();
- overlay._ldpRefreshReaderHistoryNavigation?.();
- refreshTooltips(historyPanel.sortToggle);
- });
- bindReaderPanelMultiMode(
- historyPanel.popover, historyState, historySelection,
- historyPanel.multi, historyPanel.multiDone, historyPanel.selectScope, renderHistoryPage
- );
- onShell(historyPanel.selectToggle, 'click', () => {
- toggleReaderPanelSelection(historySelection, historyScopeIds);
- renderHistoryPage();
- });
- bindCollectionNavigation('history', historyPanel, historyState, () => {
- historySelection.clear();
- renderHistoryPage();
- }, renderHistoryPage);
- onShell(historyPanel.deleteSelected, 'click', () => {
- const selectedCount = historySelection.size;
- if (!selectedCount) return;
- openReaderConfirmDialog(readerShell.activeContext, {
- title: '删除所选浏览历史?',
- message: `将删除选中的 ${selectedCount} 条阅读器浏览历史。`,
- note: '不会清除浏览器自身的访问历史。',
- icon: 'trash',
- confirmLabel: `删除 ${selectedCount} 条`,
- onConfirm: () => {
- writeTopicHistory(readTopicHistory().filter((entry) => !historySelection.has(Number(entry.topicId))));
- historySelection.clear();
- renderHistoryPage();
- showSelectionToast(`已删除 ${selectedCount} 条浏览历史`);
- },
- });
- });
- onShell(historyPanel.clear, 'click', () => {
- const historyCount = readTopicHistory().length;
- if (!historyCount) return;
- openReaderConfirmDialog(readerShell.activeContext, {
- title: '清空浏览历史?',
- message: `将删除全部 ${historyCount} 条阅读器浏览历史。`,
- note: '此操作无法撤销,但不会清除浏览器自身的访问历史。',
- icon: 'trash',
- confirmLabel: '清空全部',
- onConfirm: () => {
- writeTopicHistory([]);
- historySelection.clear();
- historyPanel.search.value = '';
- historyPanel.searchClear.hidden = true;
- historyState.query = '';
- historyState.page = 0;
- renderHistoryPage();
- showSelectionToast('浏览历史已清空');
- },
- });
- });
- bookmarksTabs.forEach((tab) => {
- onShell(tab, 'click', () => {
- const requestedType = tab.dataset.bookmarkType;
- const nextType = ['Topic', 'Post', 'Reaction'].includes(requestedType) ? requestedType : 'Topic';
- if (nextType === bookmarksState.type) return;
- bookmarksState.type = nextType;
- bookmarksState.page = 0;
- bookmarksState.multi = false;
- clearBookmarksSelectionScope();
- if (nextType !== 'Reaction' || bookmarksState.reactionLoadedAt <= 0) renderBookmarksPage();
- loadBookmarksPage();
- });
- });
- let bookmarkTabDrag = null;
- let suppressBookmarkTabClick = false;
- const finishBookmarkTabDrag = (event) => {
- if (!bookmarkTabDrag || event.pointerId !== bookmarkTabDrag.pointerId) return;
- const { tab, moved } = bookmarkTabDrag;
- bookmarkTabDrag = null;
- tab.classList.remove('ldp-bookmark-tab-dragging');
- if (!moved) return;
- suppressBookmarkTabClick = true;
- setPref('bookmarkTabOrder', Array.from(bookmarksTabList.children, (item) => item.dataset.bookmarkType));
- };
- onShell(bookmarksTabList, 'click', (event) => {
- if (!suppressBookmarkTabClick) return;
- suppressBookmarkTabClick = false;
- event.preventDefault();
- event.stopImmediatePropagation();
- }, true);
- onShell(bookmarksTabList, 'pointerdown', (event) => {
- if (event.pointerType !== 'mouse' || event.button !== 0) return;
- const tab = event.target.closest('.ldp-bookmark-tab');
- if (!tab) return;
- bookmarkTabDrag = {
- tab,
- pointerId: event.pointerId,
- startX: event.clientX,
- startY: event.clientY,
- moved: false,
- };
- try { tab.setPointerCapture(event.pointerId); } catch {}
- });
- onShell(bookmarksTabList, 'pointermove', (event) => {
- if (!bookmarkTabDrag || event.pointerId !== bookmarkTabDrag.pointerId) return;
- if (!bookmarkTabDrag.moved && Math.hypot(
- event.clientX - bookmarkTabDrag.startX,
- event.clientY - bookmarkTabDrag.startY
- ) < 5) return;
- bookmarkTabDrag.moved = true;
- bookmarkTabDrag.tab.classList.add('ldp-bookmark-tab-dragging');
- event.preventDefault();
- const siblings = [...bookmarksTabList.children].filter((item) => item !== bookmarkTabDrag.tab);
- const next = siblings.find((item) => {
- const rect = item.getBoundingClientRect();
- return event.clientX < rect.left + rect.width / 2;
- });
- bookmarksTabList.insertBefore(bookmarkTabDrag.tab, next || null);
- });
- onShell(bookmarksTabList, 'pointerup', finishBookmarkTabDrag);
- onShell(bookmarksTabList, 'pointercancel', finishBookmarkTabDrag);
- onShell(bookmarksReactionFilters, 'click', (e) => {
- const button = e.target.closest('[data-reaction-filter]');
- if (!button) return;
- bookmarksState.reactionFilter = String(button.dataset.reactionFilter || '');
- bookmarksState.page = 0;
- renderBookmarksPage();
- });
- bindReaderPanelMultiMode(
- bookmarksPanel.popover, bookmarksState, bookmarksSelection,
- bookmarksPanel.multi, bookmarksPanel.multiDone, bookmarksPanel.selectScope, renderBookmarksPage,
- () => { bookmarksState.allIds = null; }
- );
- onShell(bookmarksPanel.selectToggle, 'click', () => {
- let scopeIds = bookmarksState.entries.map((entry) => Number(entry.id)).filter((id) => id > 0);
- if (bookmarksState.scope === 'all') {
- if (!bookmarksState.allIds)
- bookmarksState.allIds = bookmarkTypeEntries()
- .map((entry) => Number(entry.id))
- .filter((bookmarkId) => bookmarkId > 0);
- scopeIds = bookmarksState.allIds || [];
- }
- toggleReaderPanelSelection(bookmarksSelection, scopeIds);
- renderBookmarksPage();
- });
- bindCollectionNavigation('bookmarks', bookmarksPanel, bookmarksState, () => {
- clearBookmarksSelectionScope();
- if (bookmarksState.type === 'Reaction' && bookmarksState.reactionLoadedAt > 0) renderBookmarksPage();
- else loadBookmarksPage();
- }, renderBookmarksPage, 250);
- readerShell.cancelBookmarksSearch = collectionSearchCancels.bookmarks;
- onShell(bookmarksPanel.deleteSelected, 'click', async () => {
- const bookmarkIds = [...bookmarksSelection];
- if (!bookmarkIds.length || bookmarksPanel.deleteSelected.dataset.ldpRequestBusy === '1' ||
- !window.confirm(`确定取消选中的 ${bookmarkIds.length} 条收藏吗?`)) return;
- setReaderControlBusy(bookmarksPanel.deleteSelected, true);
- const visibleRemoved = bookmarksState.entries.filter((entry) => bookmarksSelection.has(Number(entry.id)));
- const selectedRemoved = bookmarksState.allEntries.filter((entry) => bookmarksSelection.has(Number(entry.id)));
- const removedAllMatches = Array.isArray(bookmarksState.allIds) && bookmarksState.allIds.length > 0 &&
- bookmarksState.allIds.every((bookmarkId) => bookmarksSelection.has(bookmarkId));
- try {
- const bookmarkModule = lookupDiscourseModule('discourse/models/bookmark');
- const Bookmark = bookmarkModule?.default;
- if (!Bookmark || !isFunction(Bookmark.bulkOperation))
- throw new Error('Discourse 收藏批处理接口尚未就绪');
- await Bookmark.bulkOperation(
- bookmarkIds.map((id) => ({ id })),
- { type: 'delete' }
- );
- await clearStoredResponseCache({ tags: ['bookmarks'] });
- selectedRemoved.forEach(applyRemovedBookmarkState);
- const clearedPage = bookmarksState.entries.length > 0 && visibleRemoved.length === bookmarksState.entries.length;
- if (removedAllMatches) bookmarksState.page = 0;
- else if (clearedPage && bookmarksState.page > 0) bookmarksState.page -= 1;
- clearBookmarksSelectionScope();
- await loadBookmarksPage();
- showSelectionToast(`已取消 ${bookmarkIds.length} 条收藏`);
- } catch (error) {
- alert(`批量取消收藏失败:${error.message}`);
- } finally {
- setReaderControlBusy(bookmarksPanel.deleteSelected, false);
- if (!bookmarksPanel.popover.hidden) renderBookmarksPage();
- }
- });
- onShell(notificationsPopover, 'click', (e) => e.stopPropagation());
- bindFloatingSurfaceWheel(notificationsPopover);
- bindReaderPanelSelectionList(historyPanel.list, '.ldp-history-select-input', '[data-history-topic-id]',
- 'historyTopicId', historySelection, renderHistoryPage);
- onShell(historyPanel.popover, 'click', (e) => {
- e.stopPropagation();
- const item = e.target.closest('[data-history-topic-id]');
- if (!item) return;
- if (e.target.closest('.ldp-history-select')) return;
- e.preventDefault();
- const targetTopicId = Number(item.dataset.historyTopicId);
- const targetPostNumber = Math.max(1, Number(item.dataset.historyPostNumber) || 1);
- if (e.target.closest('.ldp-history-delete')) {
- writeTopicHistory(readTopicHistory().filter((entry) => Number(entry.topicId) !== targetTopicId));
- historySelection.delete(targetTopicId);
- renderHistoryPage();
- showSelectionToast('已删除这条浏览历史');
- return;
- }
- if (targetTopicId > 0) openModal(targetTopicId, { targetPostNumber });
- });
- bindFloatingSurfaceWheel(historyPanel.popover);
- bindReaderPanelSelectionList(bookmarksPanel.list, '.ldp-bookmark-select-input', '[data-bookmark-id]',
- 'bookmarkId', bookmarksSelection, renderBookmarksPage);
- onShell(bookmarksPanel.popover, 'click', async (e) => {
- e.stopPropagation();
- const reactionItem = e.target.closest('[data-reaction-record]');
- if (reactionItem) {
- e.preventDefault();
- const targetTopicId = Number(reactionItem.dataset.reactionTopicId);
- const targetPostNumber = Math.max(1, Number(reactionItem.dataset.reactionPostNumber) || 1);
- if (targetTopicId > 0) {
- closeBookmarks();
- openModal(targetTopicId, { targetPostNumber });
- }
- return;
- }
- const item = e.target.closest('[data-bookmark-id]');
- if (!item || e.target.closest('.ldp-bookmark-select')) return;
- e.preventDefault();
- const bookmarkId = Number(item.dataset.bookmarkId);
- const entry = bookmarksState.entries.find((candidate) => Number(candidate.id) === bookmarkId);
- if (!entry) return;
- if (e.target.closest('.ldp-bookmark-delete')) {
- const deleteBtn = e.target.closest('.ldp-bookmark-delete');
- if (deleteBtn.dataset.ldpRequestBusy === '1') return;
- setReaderControlBusy(deleteBtn, true);
- try {
- await deleteDiscourseBookmark(bookmarkId);
- await clearStoredResponseCache({ tags: ['bookmarks'] });
- applyRemovedBookmarkState(entry);
- bookmarksSelection.delete(bookmarkId);
- bookmarksState.allIds = null;
- if (bookmarksState.entries.length === 1 && bookmarksState.page > 0) bookmarksState.page -= 1;
- await loadBookmarksPage();
- showSelectionToast('已取消这条收藏');
- } catch (error) {
- alert(`取消收藏失败:${error.message}`);
- setReaderControlBusy(deleteBtn, false);
- }
- return;
- }
- const target = bookmarkTarget(entry);
- if (target.topicId > 0) {
- closeBookmarks();
- openModal(target.topicId, { targetPostNumber: target.postNumber });
- }
- });
- bindFloatingSurfaceWheel(bookmarksPanel.popover);
- themeButtons.forEach((button) => onShell(button, 'click', (event) => {
- consumeEvent(event);
- transitionReaderTheme(button.dataset.readerThemeMode);
- }));
- onShell(translateBtn, 'click', (event) => {
- consumeEvent(event);
- readerShell.activeContext?.translation?.cycleMode();
- });
- onShell(settingsBtn, 'click', (e) => {
- consumeEvent(e);
- const nextHidden = !settingsPopover.hidden;
- closeNotifications();
- closeHistory();
- closeBookmarks();
- if (nextHidden) {
- closeFontFamilyMenu();
- requestCloseSettings();
- return;
- }
- syncLayoutDraftMode(true);
- setHidden(settingsPopover, false);
- fontFamilyManualEditing.clear();
- syncSettingsSurfaceGeometry({ closeTransients: false });
- syncReaderThemeSurfaces();
- setExpanded(settingsBtn, true);
- settingsRangeSaveReminderCategory = '';
- showSettingsPanel('user');
- });
- onShell(settingsCloseBtn, 'click', (event) => {
- event.preventDefault();
- requestCloseSettings();
- });
- onShell(settingsPopover, 'click', (e) => e.stopPropagation());
- bindFloatingSurfaceWheel(settingsPopover);
- onShell(settingsPopover, 'pointerover', (event) => {
- const target = event.target.closest('[data-setting-help]');
- if (!target || (event.relatedTarget && target.contains(event.relatedTarget))) return;
- showSettingHelp(target);
- });
- const scheduleSettingHelpHide = () => {
- requestAnimationFrame(() => {
- if (activeSettingHelpTarget
- && !activeSettingHelpTarget.matches(':hover')
- && !activeSettingHelpTarget.matches(':focus-within')) hideSettingHelp();
- });
- };
- onShell(settingsPopover, 'pointerout', (event) => {
- if (!activeSettingHelpTarget) return;
- if (event.relatedTarget && activeSettingHelpTarget.contains(event.relatedTarget)) return;
- scheduleSettingHelpHide();
- });
- onShell(settingsPopover, 'focusin', (event) => {
- const target = event.target.closest('[data-setting-help]');
- if (target) showSettingHelp(target);
- });
- onShell(settingsPopover, 'focusout', scheduleSettingHelpHide);
- onShell(settingsPanel, 'scroll', () => {
- hideSettingHelp();
- closeFontFamilyMenu();
- closeColorPickerPopover();
- }, { passive: true });
- onShell(settingsPopover, 'pointerdown', (event) => {
- if (!fontFamilyMenu.hidden && !event.target.closest('.ldp-font-family-picker')) closeFontFamilyMenu();
- const color = event.target.closest('input[type="color"]');
- if (color && !color.disabled && event.button === 0) {
- hideSettingHelp();
- stopSettingsRangeDrag();
- stopSettingsColorPick();
- settingsColorPickRow = color.closest('.ldp-setting-row');
- if (settingsColorPickRow) settingsColorPickRow.classList.add('ldp-color-pick-active');
- settingsPopover.classList.add('ldp-color-picking');
- return;
- }
- stopSettingsColorPick();
- const range = event.target.closest('input[type="range"]');
- if (!range || range.disabled || event.button !== 0) return;
- hideSettingHelp();
- stopSettingsRangeDrag();
- settingsRangeDragRow = range.closest('.ldp-setting-row');
- if (settingsRangeDragRow) settingsRangeDragRow.classList.add('ldp-range-drag-active');
- settingsPopover.classList.add('ldp-range-dragging');
- try { range.setPointerCapture(event.pointerId); } catch {}
- });
- onShell(settingsPopover, 'change', (event) => {
- if (event.target.matches('input[type="color"]')) stopSettingsColorPick();
- if (event.target.matches('input[type="range"]')) remindUnsavedRangeSetting(event.target);
- });
- onShell(settingsPopover, 'focusout', (event) => {
- if (event.target.matches('input[type="color"]')) stopSettingsColorPick();
- });
- onShell(modal, 'click', closeReaderFloatingPanels);
- const saveCurrentImageProfile = (values) => {
- setPrefs({ imageProfile: values });
- };
- onShell(imageScaleRange, 'input', () => {
- const next = normalizeImageScalePercent(imageScaleRange.value, currentImageScalePercent());
- saveCurrentImageProfile({
- ...currentImageProfile(),
- preset: 'custom',
- custom: next,
- });
- syncImageScaleControls();
- applyImageScale();
- });
- onShell(imageScaleReset, 'click', () => {
- saveCurrentImageProfile({ ...IMAGE_PROFILE_DEFAULT });
- syncImageScaleControls();
- applyImageScale();
- });
- fontScopeTabs.forEach((tab) => {
- onShell(tab, 'click', () => {
- const scope = tab.dataset.fontScopeTab;
- if (!FONT_SCOPE_KEYS.includes(scope) || scope === fontSettingsScope) return;
- closeFontFamilyMenu();
- fontSettingsScope = scope;
- syncFontControls();
- });
- });
- fontFamilyTriggers.forEach((trigger) => {
- onShell(trigger, 'click', (event) => {
- consumeEvent(event);
- hideSettingHelp();
- const scope = trigger.dataset.fontScope;
- if (!fontFamilyMenu.hidden && activeFontFamilyScope === scope) closeFontFamilyMenu(true);
- else openFontFamilyMenu(scope);
- });
- onShell(trigger, 'keydown', (event) => {
- if (event.key !== 'ArrowDown') return;
- event.preventDefault();
- openFontFamilyMenu(trigger.dataset.fontScope);
- });
- });
- onShell(fontFamilySearch, 'input', applyFontFamilySearch);
- onShell(fontFamilyOptions, 'click', (event) => {
- const option = event.target.closest('.ldp-font-family-option');
- if (!option) return;
- const config = fontScopeConfig();
- const values = { ...fontValuesForScope() };
- if (option.dataset.fontKind === 'preset') {
- values[config.family] = normalizeFontFamily(option.dataset.fontValue, values[config.family]);
- values[config.customFamily] = '';
- } else {
- values[config.family] = 'custom';
- values[config.customFamily] = normalizeCustomFontFamily(option.dataset.fontValue, values[config.customFamily]);
- }
- fontFamilyManualEditing.delete(activeFontFamilyScope);
- if (activeFontFamilyScope === 'host') setPrefs({
- hostFontFamily: values.hostFontFamily,
- hostFontCustomFamily: values.hostFontCustomFamily,
- });
- else setCurrentFontDraft(values);
- closeFontFamilyMenu(true);
- syncFontControls();
- if (activeFontFamilyScope !== 'host') applyFontScales();
- });
- onShell(fontFamilyManualButton, 'click', () => {
- const scope = activeFontFamilyScope;
- const config = fontScopeConfig(scope);
- const values = { ...fontValuesForScope(scope), [config.family]: 'custom' };
- fontFamilyManualEditing.add(scope);
- if (scope === 'host') setPref(config.family, 'custom');
- else setCurrentFontDraft(values);
- closeFontFamilyMenu();
- syncFontControls();
- if (scope !== 'host') applyFontScales();
- const input = [...fontFamilyCustomInputs].find((candidate) => candidate.dataset.fontScope === scope);
- input.focus();
- input.select();
- });
- onShell(fontFamilyMenu, 'keydown', (event) => {
- handleSearchableMenuKeyDown(event, fontFamilySearch, fontFamilyOptions,
- '.ldp-font-family-option:not([hidden])', closeFontFamilyMenu);
- });
- onShell(fontFamilyMenu, 'click', (event) => event.stopPropagation());
- onShell(fontFamilyMenu, 'pointerdown', (event) => event.stopPropagation());
- bindFloatingSurfaceWheel(fontFamilyMenu);
- fontFamilyCustomInputs.forEach((input) => onShell(input, 'input', () => {
- const scope = input.dataset.fontScope;
- const config = fontScopeConfig(scope);
- const values = { ...fontValuesForScope(scope) };
- values[config.family] = 'custom';
- values[config.customFamily] = normalizeCustomFontFamily(input.value, values[config.customFamily]);
- if (scope === 'host') setPrefs({
- hostFontFamily: values.hostFontFamily,
- hostFontCustomFamily: values.hostFontCustomFamily,
- });
- else setCurrentFontDraft(values);
- syncFontControls();
- if (scope !== 'host') applyFontScales();
- }));
- [
- [fontWeightSelects, 'change', 'weight', normalizeFontWeight],
- [fontColorInputs, 'input', 'color', normalizeHexColor],
- ].forEach(([inputs, eventName, configField, normalizeValue]) => inputs.forEach((input) => {
- onShell(input, eventName, () => {
- const scope = input.dataset.fontScope;
- const config = fontScopeConfig(scope);
- const values = { ...fontValuesForScope(scope) };
- const field = config[configField];
- values[field] = normalizeValue(input.value, values[field]);
- if (scope === 'host') setPref(field, values[field]);
- else setCurrentFontDraft(values);
- syncFontControls();
- if (scope !== 'host') applyFontScales();
- });
- }));
- fontScaleSettings.forEach(({ input, key, normalize }) => onShell(input, 'input', () => {
- const values = { ...currentFontDraft() };
- values[key] = normalize(input.value, values[key]);
- setCurrentFontDraft(values);
- syncFontControls();
- applyFontScales();
- }));
- hostEmbeddedSizeInputs.forEach((input) => onShell(input, 'input', () => {
- const config = HOST_EMBED_SIZE_SETTINGS[input.dataset.hostEmbedSize];
- if (!config) return;
- setPref(config.pref, normalizeHostEmbeddedSizeScale(input.value));
- syncFontControls();
- }));
- hostEmbeddedSizeResetButtons.forEach((button) => onShell(button, 'click', () => {
- const config = HOST_EMBED_SIZE_SETTINGS[button.dataset.hostEmbedSizeReset];
- if (!config) return;
- setPref(config.pref, config.defaultScale);
- syncFontControls();
- }));
- fontFieldResetButtons.forEach((button) => onShell(button, 'click', () => {
- const host = button.dataset.hostFontReset != null;
- const profileField = button.dataset.fontFieldReset;
- const scope = host ? 'host' : FONT_SCOPE_KEYS.find((key) => Object.values(fontScopeConfig(key)).includes(profileField));
- if (!scope) return;
- const config = fontScopeConfig(scope);
- const field = host ? config[button.dataset.hostFontReset] : profileField;
- const defaults = host ? HOST_FONT_DEFAULTS : currentFontDefaults();
- if (!Object.hasOwn(defaults, field)) return;
- const patch = { [field]: defaults[field] };
- if (field === config.family) {
- patch[config.customFamily] = defaults[config.customFamily];
- fontFamilyManualEditing.delete(scope);
- }
- if (host) setPrefs(patch);
- else setCurrentFontDraft({ ...currentFontDraft(), ...patch });
- syncFontControls();
- if (!host) applyFontScales();
- }));
- onShell(fontResetBtn, 'click', () => {
- closeFontFamilyMenu();
- fontFamilyManualEditing.clear();
- setPrefs(HOST_FONT_DEFAULTS);
- setCurrentFontDraft({ ...currentFontDefaults() });
- syncFontControls();
- applyFontScales();
- });
- layoutRatioInputs.forEach((input) => {
- onShell(input, 'input', () => {
- const name = input.dataset.layoutRegion;
- if (!LAYOUT_REGION_KEYS.includes(name)) return;
- const ratios = { ...currentLayoutDraft() };
- const desired = Number(input.value);
- const maximum = 100 - LAYOUT_REGION_KEYS.reduce((sum, other) =>
- sum + (other === name ? 0 : LAYOUT_MIN_RATIOS[other]), 0);
- ratios[name] = roundLayoutRatio(Math.min(
- maximum,
- Math.max(LAYOUT_MIN_RATIOS[name], Number.isFinite(desired) ? desired : ratios[name])
- ));
- setCurrentLayoutDraft(rebalanceLayoutRatiosAfterChange(ratios, name));
- applyLayoutRatios();
- syncLayoutControls();
- });
- });
- onShell(layoutResetBtn, 'click', () => {
- setCurrentLayoutDraft({ ...currentLayoutDefaults() });
- applyLayoutRatios();
- syncLayoutControls();
- });
- const applyReaderWindowInputs = () => {
- readerWindow.setGeometry(
- readerWindowWidthInput.value,
- readerWindowHeightInput.value,
- readerWindowXInput.value,
- readerWindowYInput.value
- );
- syncReaderWindowControls();
- };
- onShell(readerWindowWidthInput, 'change', applyReaderWindowInputs);
- onShell(readerWindowHeightInput, 'change', applyReaderWindowInputs);
- onShell(readerWindowXInput, 'change', applyReaderWindowInputs);
- onShell(readerWindowYInput, 'change', applyReaderWindowInputs);
- onShell(readerWindowResetBtn, 'click', () => {
- readerWindow.reset();
- syncReaderWindowControls();
- });
- const setAppearanceDraftValue = (key, value) => {
- setCurrentAppearanceDraft({ ...currentAppearanceDraft(), [key]: value });
- };
- const setAppearanceDraftValues = (values) => {
- setCurrentAppearanceDraft({ ...currentAppearanceDraft(), ...values });
- };
- appearanceGroupToggleInputs.forEach((input) => onShell(input, 'change', () => {
- const key = input.dataset.appearanceGroupToggle;
- if (!APPEARANCE_PROFILE_SETTING_KEYS.includes(key)) return;
- setAppearanceDraftValue(key, input.checked);
- applyAppearanceColors();
- syncAppearanceControls();
- }));
- appearanceControlSettings.forEach(({ input, key, normalize: normalizeValue, apply: applyPreview }) => {
- onShell(input, 'input', () => {
- if (input.type === 'color') {
- const sourceColor = normalizeHexColor(input.value, currentAppearanceDefaults()[key]);
- setAppearanceDraftValues({
- [key]: sourceColor,
- [appearanceDarkColorKey(key)]: sourceColor,
- });
- } else {
- setAppearanceDraftValue(key, normalizeValue(input.value));
- }
- applyPreview();
- syncAppearanceControls();
- });
- });
- colorResetButtons.forEach((button) => onShell(button, 'click', () => {
- const key = button.dataset.colorReset;
- const defaults = currentAppearanceDefaults();
- const valueKeys = appearanceSettingValueFields(key);
- if (valueKeys.some((valueKey) => defaults[valueKey] == null)) return;
- setAppearanceDraftValues(Object.fromEntries(valueKeys.map((valueKey) => [valueKey, defaults[valueKey]])));
- applyAppearanceColors();
- syncAppearanceControls();
- }));
- readerShell.cancelShellFrames = () => {
- if (colorPickerCommitFrame) cancelAnimationFrame(colorPickerCommitFrame);
- colorPickerCommitFrame = 0;
- pendingColorPickerCommit = null;
- if (settingsWindowDragFrame) cancelAnimationFrame(settingsWindowDragFrame);
- settingsWindowDragFrame = 0;
- settingsWindowDrag = null;
- Object.values(collectionSearchCancels).forEach((cancel) => cancel());
- };
- }
-
- const contextScope = createLifecycleScope();
- const onContext = (target, type, listener, options) => contextScope.listen(target, type, listener, options);
- contextScope.add(() => {
- if (titleTopicHintFrame) cancelAnimationFrame(titleTopicHintFrame);
- titleTopicHintFrame = 0;
- });
- onContext(titleTopicScroller, 'scroll', queueTitleTopicScrollHints, { passive: true });
- if (typeof ResizeObserver === 'function') {
- const titleTopicResizeObserver = new ResizeObserver(queueTitleTopicScrollHints);
- titleTopicResizeObserver.observe(titleTopicRow);
- titleTopicResizeObserver.observe(titleTopicScroller);
- titleTopicResizeObserver.observe(readerElement('.ldp-topic-tags'));
- contextScope.add(() => titleTopicResizeObserver.disconnect());
- }
- queueTitleTopicScrollHints();
- const onTitleTopicWheel = (event) => {
- const rawDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
- if (!rawDelta) return;
- event.preventDefault();
- event.stopPropagation();
- const maxScrollLeft = titleTopicScroller.scrollWidth - titleTopicScroller.clientWidth;
- if (maxScrollLeft <= 1) return;
- const delta = event.deltaMode === 1
- ? rawDelta * 40
- : event.deltaMode === 2 ? rawDelta * titleTopicScroller.clientWidth : rawDelta;
- const nextScrollLeft = Math.min(maxScrollLeft, Math.max(0, titleTopicScroller.scrollLeft + delta));
- titleTopicScroller.scrollLeft = nextScrollLeft;
- };
- onContext(titleTopicRow, 'wheel', onTitleTopicWheel, { passive: false });
- let ctx = null;
- const tracker = createReadTracker(
- topicId,
- body,
- (postNumbers) => markReaderPostsRead(postNumbers, ctx),
- (postNumbers) => markReaderPostsRead(postNumbers, ctx, { optimistic: true }),
- );
- ctx = {
- topicId, op: null, topicData: null, loader, modal, commentsEl, commentsHeader, countEl, emptyEl, scrollRoot: body, performance,
- readerSession,
- isPostVoting: !!(cachedTopicPreview?.is_post_voting),
- directRepliesByParent: new Map(), replyParentByPost: new Map(), tracker, totalPosts: 0, repliesIO: null, postRequestScheduler,
- subReplyState: new Map(), // 楼中楼原始数据 + 已渲染数量的状态表
- confirmedReadPostNumbers: new Set(), optimisticReadPostNumbers: new Set(),
- streamNodeMap: new Map(),
- streamItems: [], streamItemMap: new Map(), streamPrefix: [0],
- onlyOp: false, streamLayoutItems: [], streamLayoutDirty: true,
- streamPrefixDirty: true, streamPrefixDirtyFrom: 0,
- streamNeedsRepair: true, streamPendingReindexFrom: null,
- streamWindowStart: 0, streamWindowEnd: 0, streamMountedItemCount: 0,
- streamViewportTopIndex: 0, streamViewportBottomIndex: 0,
- viewportPersistenceEnabled: false,
- streamScrollDirection: 0, streamScrollVelocity: 0, streamLastScrollAt: 0,
- streamProgrammaticTarget: null, streamViewportAnchor: null,
- streamViewportAnchorRestoreDepth: 0, streamViewportAnchorRestorePending: false,
- streamWindowResetPending: false, streamInternalScrollTop: NaN, streamInternalScrollDelta: 0,
- readerNavigationSequence: 0,
- streamListOffset: NaN,
- streamMountedNodes: new Set(), streamHydratedNodes: new Set(),
- pendingPostHydrations: new Set(), pendingPostLatexRenders: new Set(),
- streamTopSpacer: createStreamSpacer('top'), streamBottomSpacer: createStreamSpacer('bottom'),
- streamResizeObserver: null, queueViewportTracker: null, streamWindowFrame: 0, floorPreviewSyncFrame: 0,
- latestReplyAt: '', latestReplyTimestamp: 0,
- topicBookmarked: !!(cachedTopicPreview?.bookmarked),
- topicNotificationLevel: topicNotificationLevel(cachedTopicPreview),
- topicSharedIssueBusy: false,
- topicSharedIssueHostVisible: false,
- initialTargetPositionPending: targetPostNumber > 1,
- initialTargetHydrationBoundary: targetPostNumber > 1 ? targetPostNumber : 0,
- initialTargetPrecedingContentDeferred: targetPostNumber > 1,
- initialTargetHeadSpacerHeight: 0,
- cancelInitialTargetPosition: null, releaseInitialPrecedingContent: null, readerActionDialog: null,
- nativeComposerSession: null, toggleTopicBookmark: null, readerWindow, readerWorkspace,
- };
- ctx.queueViewportTracker = queueEntry
- ? createReaderQueueViewportTracker(ctx, queueEntry)
- : { observe() {}, unobserve() {}, sync() {}, stop() {} };
- readerShell.activeContext = ctx;
- guardNativeReaderTimings();
- const topicActionRail = ctx.topicActionRail = readerElement('.ldp-topic-action-rail'); topicActionRail.hidden = true; topicActionRail.classList.remove('is-dragging'); topicActionRail._ldpRelatedPost = null; overlay.classList.remove('ldp-topic-action-rail-visible');
- let topicRailHoldTimer = 0, topicRailDrag = null, topicRailSuppressClickUntil = 0;
- const positionTopicActionRail = () => {
- if (!topicActionRail || topicActionRail.hidden) return;
- const position = PREFS.topicActionRailPosition || {};
- const railWidth = topicActionRail.offsetWidth;
- const maxLeft = Math.max(0, modal.clientWidth - railWidth);
- const maxTop = Math.max(0, modal.clientHeight - topicActionRail.offsetHeight);
- const readerMain = readerElement('.ldp-reader-main');
- const readerMainRect = readerMain?.getBoundingClientRect();
- const leftTrack = Number.parseFloat(getComputedStyle(readerMain).gridTemplateColumns) || railWidth;
- const railParent = topicActionRail.offsetParent || modal;
- const railParentRect = railParent.getBoundingClientRect();
- const railOriginLeft = railParentRect.left + (railParent.clientLeft || 0);
- const defaultLeft = readerMainRect
- ? readerMainRect.left + Math.max(leftTrack, railWidth) / 2 - railOriginLeft - railWidth / 2
- : (Math.max(leftTrack, railWidth) - railWidth) / 2;
- const customizedX = Number.isFinite(position.x);
- const targetLeft = customizedX
- ? maxLeft * Math.max(0, Math.min(1, position.x))
- : Math.max(0, Math.min(maxLeft, defaultLeft));
- const toggle = topicActionRail.querySelector('.ldp-topic-action-rail-toggle');
- const toggleMaxTop = Math.max(0, modal.clientHeight - (toggle?.offsetHeight || 0));
- topicActionRail.style.left = `${Math.round(targetLeft)}px`;
- topicActionRail.style.top = `${Math.round(Math.max(0, Math.min(maxTop, toggleMaxTop * Math.max(0, Math.min(1, Number(position.y) || 0)) - (toggle?.offsetTop || 0))))}px`;
- const blockers = [overlay.querySelector('.ldp-reader-queue:not([hidden])'), ...overlay.querySelectorAll('.ldp-reader-history-nav:not([hidden])')];
- const modalRect = modal.getBoundingClientRect();
- blockers.filter((blocker) => blocker && (!topicActionRail.classList.contains('is-expanded') || !blocker.matches('.ldp-reader-history-nav'))).forEach((blocker) => {
- const railRect = topicActionRail.getBoundingClientRect(), blockerRect = blocker.getBoundingClientRect();
- if (railRect.left < blockerRect.right + 8 && railRect.right + 8 > blockerRect.left && railRect.top < blockerRect.bottom + 8 && railRect.bottom + 8 > blockerRect.top) topicActionRail.style.left = `${Math.round(blockerRect.left > modalRect.left + modalRect.width / 2 ? Math.max(0, blockerRect.left - railOriginLeft - railWidth - 8) : Math.min(maxLeft, blockerRect.right - railOriginLeft + 8))}px`;
- });
- const railLeft = Number.parseFloat(topicActionRail.style.left) || 0, dockThreshold = modal.clientWidth * READER_QUEUE_DOCK_THRESHOLD_RATIO; topicActionRail.classList.toggle('is-docked-left', customizedX && railLeft <= dockThreshold); topicActionRail.classList.toggle('is-docked-right', customizedX && maxLeft - railLeft <= dockThreshold);
- readerShell.syncFullPageHeaderAlignment?.();
- overlay._ldpSyncReaderQueuePosition?.();
- };
- ctx.syncTopicActionRail = (postNode = topicActionRail?._ldpRelatedPost) => {
- if (!postNode) {
- const starter = postsFromPayload(ctx.topicData).concat(loader.cachedPosts).find((post) => +post?.post_number === 1);
- if (starter) { renderPost(starter, false, ctx, { deferContent: true }); return; }
- if (PREFS.topicActionRailVisible && !topicActionRail._ldpStarterRequest) topicActionRail._ldpStarterRequest = loader.loadPost(1, { advanceCursor: false, priority: POST_REQUEST_PRIORITY.auxiliary }).then((posts) => { const post = posts.find((item) => +item?.post_number === 1); if (post && sessionAcceptsWork()) renderPost(post, false, ctx, { deferContent: true }); }).catch(() => {}).finally(() => { topicActionRail._ldpStarterRequest = null; });
- return;
- }
- const post = postNode?._ldpPostData; if (!topicActionRail || !post) return;
- const like = readerPostRailReactionAction(post, ctx), expanded = topicActionRail.classList.contains('is-expanded');
- topicActionRail._ldpRelatedPost = postNode; topicActionRail._ldpPostData = post; topicActionRail.hidden = PREFS.topicActionRailVisible !== true; overlay.classList.toggle('ldp-topic-action-rail-visible', !topicActionRail.hidden);
- topicActionRail.innerHTML = `
${canShowBoostButton(post) ? `` : ''}
${postContextActionsHtml(post, ctx)}${topicFooterActionsHtml(post, ctx)}
`;
- renderPostReactions(topicActionRail, ctx, { collectLike: false, railPicker: true }); topicActionRail.querySelector(':scope > .ldp-reactions > .ldp-reaction-summary')?.remove(); syncPostActionControls(topicActionRail, ctx); syncTopicActionControls(ctx); requestAnimationFrame(() => { positionTopicActionRail(); requestAnimationFrame(positionTopicActionRail); });
- };
- onContext(topicActionRail, 'click', (event) => { if (Date.now() < topicRailSuppressClickUntil) return consumeEvent(event); if (event.target.closest('.ldp-topic-action-rail-top')) ctx.timeline.jumpToStart(); const toggle = event.target.closest('.ldp-topic-action-rail-toggle'); if (!toggle) return; const expanded = topicActionRail.classList.toggle('is-expanded'); topicActionRail.querySelector('.ldp-topic-action-more').hidden = !expanded; setExpanded(toggle, expanded); setLabel(toggle, `${expanded ? '收纳' : '展开'}其他主题操作;${PREFS.topicActionRailFixed ? '位置已固定' : '长按拖动'}`); toggle.innerHTML = icon(expanded ? 'chevronDown' : 'layers'); requestAnimationFrame(positionTopicActionRail); });
- onContext(topicActionRail, 'pointerdown', (event) => { if (event.button || PREFS.topicActionRailFixed || !event.target.closest('.ldp-topic-action-rail-toggle')) return; clearTimeout(topicRailHoldTimer); topicRailHoldTimer = contextScope.timer(() => { topicRailDrag = { id: event.pointerId, x: event.clientX, y: event.clientY, left: Number.parseFloat(topicActionRail.style.left) || 0, top: Number.parseFloat(topicActionRail.style.top) || 0 }; topicActionRail.classList.add('is-dragging'); }, 420); });
- onContext(document, 'pointermove', (event) => { if (!topicRailDrag || event.pointerId !== topicRailDrag.id) return; const maxLeft = Math.max(0, modal.clientWidth - topicActionRail.offsetWidth), maxTop = Math.max(0, modal.clientHeight - topicActionRail.offsetHeight); topicActionRail.style.left = `${Math.round(Math.max(0, Math.min(maxLeft, topicRailDrag.left + event.clientX - topicRailDrag.x)))}px`; topicActionRail.style.top = `${Math.round(Math.max(0, Math.min(maxTop, topicRailDrag.top + event.clientY - topicRailDrag.y)))}px`; event.preventDefault(); }, true);
- const finishTopicRailDrag = (event) => { clearTimeout(topicRailHoldTimer); if (!topicRailDrag || event.pointerId !== topicRailDrag.id) return; const maxLeft = Math.max(1, modal.clientWidth - topicActionRail.offsetWidth), toggle = topicActionRail.querySelector('.ldp-topic-action-rail-toggle'), toggleMaxTop = Math.max(1, modal.clientHeight - (toggle?.offsetHeight || 0)); setPref('topicActionRailPosition', { x: (Number.parseFloat(topicActionRail.style.left) || 0) / maxLeft, y: ((Number.parseFloat(topicActionRail.style.top) || 0) + (toggle?.offsetTop || 0)) / toggleMaxTop }); topicRailDrag = null; topicActionRail.classList.remove('is-dragging'); topicRailSuppressClickUntil = Date.now() + 300; positionTopicActionRail(); };
- overlay._ldpSyncTopicActionRailPosition = positionTopicActionRail;
- onContext(document, 'pointerup', finishTopicRailDrag, true); onContext(document, 'pointercancel', finishTopicRailDrag, true); onContext(window, 'resize', positionTopicActionRail);
- ctx.translation = createReaderTranslationController(ctx, translateBtn);
- if (!isFunction(readerShell.refreshBookmarksPanel)) {
- readerShell.refreshBookmarksPanel = (confirmedReaction = null) => {
- clearBookmarksSelectionScope();
- const hadCompleteReactionSnapshot = bookmarksState.reactionLoadedAt > 0;
- const confirmedLike = confirmedReaction && typeof confirmedReaction.likeActed === 'boolean';
- const confirmedCustomReaction = confirmedReaction && !confirmedLike &&
- rememberConfirmedGivenReaction(confirmedReaction.post, confirmedReaction.topic);
- if (confirmedLike || confirmedCustomReaction) {
- if (confirmedCustomReaction)
- bookmarksState.reactionEntries = mergeGivenReactionOverrides(bookmarksState.reactionEntries);
- if (hadCompleteReactionSnapshot) {
- if (confirmedLike)
- bookmarksState.reactionEntries = applyConfirmedGivenLike(
- bookmarksState.reactionEntries,
- confirmedReaction.post,
- confirmedReaction.topic,
- confirmedReaction.likeActed
- );
- bookmarksState.reactionLoadedAt = Date.now();
- setGivenReactionSnapshot(bookmarksState.reactionUsername, bookmarksState.reactionEntries);
- }
- if (!bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction') {
- if (hadCompleteReactionSnapshot) {
- bookmarksState.page = 0;
- renderBookmarksPage();
- } else loadBookmarksPage();
- }
- return;
- }
- if (!bookmarksPanel.popover.hidden) loadBookmarksPage();
- };
- }
- ctx.refreshBookmarksPanel = (options) => readerShell.refreshBookmarksPanel(options);
- ctx.nativeComposerWindow = createNativeComposerWindowController(readerSession);
- ctx.renderTopicShell = renderTopicShell;
- ctx.repliesIO = createRepliesIO(ctx);
- ctx.streamResizeObserver = createStreamResizeObserver(ctx);
- const timeline = ctx.timeline = bindTopicTimeline(timelineEl, ctx);
- const loadTopicEditorOptions = () => {
- const site = lookupDiscourse('service:site');
- const categories = [...discourseModelValue(site, 'categories') || []]
- .filter((category) => category && +discourseModelValue(category, 'id') > 0 &&
- discourseModelValue(category, 'name'))
- .map((category) => ({
- id: +discourseModelValue(category, 'id'),
- name: String(discourseModelValue(category, 'name') || ''),
- slug: String(discourseModelValue(category, 'slug') || ''),
- color: String(discourseModelValue(category, 'color') || ''),
- parent_category_id: +discourseModelValue(category, 'parent_category_id') || null,
- }));
- if (!categories.length) return Promise.reject(new Error('原站分类数据尚未就绪,请稍后重试'));
- return Promise.resolve({ categories });
- };
- const openTopicEditor = () => {
- const topic = ctx.topicData;
- if (!topic || !topic.details || topic.details.can_edit !== true) return;
- if (topicEditorState) topicEditorState.close(true);
- const topicEditTrigger = readerElement('.ldp-topic-edit-trigger');
- const layer = readerElement('.ldp-topic-edit-layer');
- const form = readerElement('.ldp-topic-edit-form');
- const titleInput = form.querySelector('input[name="title"]');
- const categoryTrigger = form.querySelector('.ldp-topic-edit-category-trigger');
- const categoryValue = form.querySelector('.ldp-topic-edit-category-value');
- const categoryMenu = form.querySelector('.ldp-topic-edit-category-menu');
- const categorySearch = form.querySelector('.ldp-topic-edit-category-search input');
- const categoryOptions = form.querySelector('.ldp-topic-edit-category-options');
- const labelInput = form.querySelector('.ldp-topic-edit-label-input');
- const labelValues = form.querySelector('.ldp-topic-edit-label-values');
- const labelOptions = form.querySelector('.ldp-topic-edit-label-options');
- const saveButton = form.querySelector('.ldp-topic-edit-save');
- const cancelButton = form.querySelector('.ldp-topic-edit-cancel');
- const closeButton = layer.querySelector('.ldp-topic-edit-close');
- const status = form.querySelector('.ldp-topic-edit-status');
- const originalTitle = String(topic.title || '').trim();
- const originalCategoryId = Number(topic.category_id || topic.categoryId) || 0;
- const originalLabelNames = [...new Set(
- (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).map((name) => name.trim()).filter(Boolean),
- )];
- const labelsByKey = new Map();
- const selectedLabelKeys = new Set();
- const categoriesById = new Map();
- let selectedCategoryId = originalCategoryId;
- let optionsLoaded = false;
- let busy = false;
- let closed = false;
- let labelSearchSequence = 0;
- let labelSearchDebounce = null;
- let pendingLabelSearch = { query: '', sequence: 0 };
- const editorScope = createLifecycleScope();
-
- const labelKey = (name) => String(name || '').trim().toLocaleLowerCase();
- originalLabelNames.forEach((name) => {
- const key = labelKey(name);
- labelsByKey.set(key, { id: null, name });
- selectedLabelKeys.add(key);
- });
- const selectedLabels = () => [...selectedLabelKeys]
- .map((key) => labelsByKey.get(key)).filter(Boolean);
- const renderSelectedLabels = () => {
- labelValues.innerHTML = selectedLabels().map((label) => `
-
- ${esc(label.name)}
-
- `).join('');
- };
- const renderLabelOptions = (labels) => {
- labelOptions.innerHTML = (labels || [])
- .map((label) => `
`).join('');
- };
- const performLabelSearch = async (query, sequence) => {
- const tagUtils = lookupDiscourse('service:tag-utils');
- const siteSettings = lookupDiscourse('service:site-settings');
- if (!tagUtils || !isFunction(tagUtils.searchTags) || !siteSettings)
- throw new Error('Discourse 标签搜索服务尚未就绪');
- const selected = selectedLabels();
- const data = {
- q: query,
- limit: Math.max(1, Number(discourseModelValue(siteSettings, 'max_tag_search_results')) || 20),
- categoryId: selectedCategoryId || undefined,
- filterForInput: true,
- };
- const selectedIds = selected.map((label) => Number(label.id)).filter((id) => id > 0);
- const selectedNames = selected.filter((label) => !(Number(label.id) > 0)).map((label) => label.name);
- if (selectedIds.length) data.selected_tag_ids = selectedIds.slice(0, 100);
- if (selectedNames.length) data.selected_tags = selectedNames.slice(0, 100);
- const results = await tagUtils.searchTags('/tags/filter/search', data, (json) => {
- const incoming = Array.isArray(json?.results) ? json.results : [];
- return isFunction(tagUtils.sortSearchResults)
- ? tagUtils.sortSearchResults(incoming)
- : incoming;
- });
- if (closed || sequence !== labelSearchSequence) return;
- const labels = (Array.isArray(results) ? results : []).map((label) => ({
- id: Number(label?.id) || null,
- name: String(label && (label.name || label.text) || '').trim(),
- })).filter((label) => label.name);
- labels.forEach((label) => labelsByKey.set(labelKey(label.name), label));
- renderLabelOptions(labels);
- };
- const runScheduledLabelSearch = () => {
- const { query, sequence } = pendingLabelSearch;
- void performLabelSearch(query, sequence).catch((error) => {
- if (!closed && sequence === labelSearchSequence)
- status.textContent = error?.message ? error.message : '标签搜索失败,请重试';
- });
- };
- const scheduleLabelSearch = () => {
- if (closed || !optionsLoaded) return;
- const query = String(labelInput.value || '').trim();
- const sequence = ++labelSearchSequence;
- pendingLabelSearch = { query, sequence };
- const runloopModule = lookupDiscourseModule('@ember/runloop');
- const discourseDebounce = runloopModule?.debounce;
- if (!isFunction(discourseDebounce)) {
- status.textContent = 'Discourse 标签搜索服务尚未就绪';
- return;
- }
- labelSearchDebounce = discourseDebounce(labelInput, runScheduledLabelSearch, 300);
- };
- const syncCategorySelection = () => {
- const selected = categoriesById.get(selectedCategoryId);
- const parent = selected && categoriesById.get(Number(selected.parent_category_id));
- categoryValue.textContent = !selected
- ? '请选择类别'
- : parent ? `${parent.name} / ${selected.name}` : String(selected.name || '请选择类别');
- categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => {
- option.setAttribute('aria-selected', +option.dataset.categoryId === selectedCategoryId ? 'true' : 'false');
- });
- };
- const closeCategoryMenu = (restoreFocus = false) => {
- categoryMenu.hidden = true;
- setExpanded(categoryTrigger, false);
- if (restoreFocus && !categoryTrigger.disabled) categoryTrigger.focus();
- };
- const openCategoryMenu = () => {
- if (busy || !optionsLoaded || categoryTrigger.disabled) return;
- categoryMenu.hidden = false;
- setExpanded(categoryTrigger, true);
- categorySearch.value = '';
- categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => {
- option.hidden = false;
- });
- categorySearch.focus();
- categoryOptions.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });
- };
- const renderCategoryOptions = () => {
- const fragment = document.createDocumentFragment();
- categoriesById.forEach((category) => {
- const parent = categoriesById.get(Number(category.parent_category_id));
- const option = makeElement('button');
- const color = String(category.color || '').replace(/^#/, '');
- option.className = 'ldp-topic-edit-category-option ldp-picker-option';
- option.type = 'button';
- option.dataset.categoryId = String(category.id);
- option.dataset.searchText = `${parent ? `${parent.name} ` : ''}${category.name}`.toLocaleLowerCase();
- option.setAttribute('role', 'option');
- option.setAttribute('aria-selected', +category.id === selectedCategoryId ? 'true' : 'false');
- option.innerHTML = `
-
-
- ${esc(String(category.name || '未命名类别'))}
- ${parent ? `${esc(String(parent.name))}` : '一级类别'}
- `;
- if (/^[0-9a-f]{6}$/i.test(color))
- option.querySelector('.ldp-topic-edit-category-dot').style.setProperty('--ldp-category-color', `#${color}`);
- fragment.appendChild(option);
- });
- categoryOptions.replaceChildren(fragment);
- syncCategorySelection();
- };
- const setBusy = (nextBusy) => {
- busy = nextBusy;
- titleInput.disabled = nextBusy;
- categoryTrigger.disabled = nextBusy || !optionsLoaded;
- categorySearch.disabled = nextBusy || !optionsLoaded;
- labelInput.disabled = nextBusy || !optionsLoaded;
- saveButton.disabled = nextBusy || !optionsLoaded;
- cancelButton.disabled = nextBusy;
- closeButton.disabled = nextBusy;
- if (nextBusy) closeCategoryMenu();
- };
- const commitLabelInput = (showError = true) => {
- const raw = String(labelInput.value || '').replace(/[,,]\s*$/, '').trim();
- if (!raw) {
- labelInput.value = '';
- return true;
- }
- const key = labelKey(raw);
- const label = labelsByKey.get(key);
- if (!label) {
- if (showError) status.textContent = `没有找到 label“${raw}”`;
- return false;
- }
- selectedLabelKeys.add(key);
- labelInput.value = '';
- status.textContent = '';
- renderSelectedLabels();
- scheduleLabelSearch();
- return true;
- };
- const onLabelKeyDown = (event) => {
- if (event.key !== 'Enter' && event.key !== ',' && event.key !== ',') return;
- event.preventDefault();
- commitLabelInput();
- };
- const onLabelInput = () => scheduleLabelSearch();
- const onLabelChange = () => commitLabelInput(false);
- const onLabelRemove = (event) => {
- const button = event.target.closest('.ldp-topic-edit-label-remove');
- if (!button || busy) return;
- selectedLabelKeys.delete(button.dataset.labelKey || '');
- renderSelectedLabels();
- scheduleLabelSearch();
- labelInput.focus();
- };
- const onCategoryToggle = () => {
- if (categoryMenu.hidden) openCategoryMenu();
- else closeCategoryMenu(true);
- };
- const onCategoryTriggerKeyDown = (event) => {
- if (event.key !== 'ArrowDown') return;
- event.preventDefault();
- openCategoryMenu();
- };
- const onCategoryOptionClick = (event) => {
- const option = event.target.closest('.ldp-topic-edit-category-option');
- if (!option || busy) return;
- selectedCategoryId = Number(option.dataset.categoryId) || 0;
- syncCategorySelection();
- closeCategoryMenu(true);
- scheduleLabelSearch();
- };
- const onCategorySearchInput = () => {
- const query = String(categorySearch.value || '').trim().toLocaleLowerCase();
- categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => {
- option.hidden = !!query && !String(option.dataset.searchText || '').includes(query);
- });
- };
- const onCategoryMenuKeyDown = (event) => {
- handleSearchableMenuKeyDown(event, categorySearch, categoryOptions,
- '.ldp-topic-edit-category-option:not([hidden])', closeCategoryMenu);
- };
- const onLayerClick = (event) => {
- if (event.target === layer) {
- close();
- return;
- }
- if (!event.target.closest('.ldp-topic-edit-category')) closeCategoryMenu();
- };
- const close = (force = false) => {
- if (closed || (busy && !force)) return;
- closed = true;
- labelSearchSequence++;
- const runloopModule = lookupDiscourseModule('@ember/runloop');
- const cancelRunloop = runloopModule?.cancel;
- if (labelSearchDebounce && isFunction(cancelRunloop)) cancelRunloop(labelSearchDebounce);
- labelSearchDebounce = null;
- closeCategoryMenu();
- layer.hidden = true;
- setExpanded(topicEditTrigger, false);
- editorScope.destroy();
- if (topicEditorState === state) topicEditorState = null;
- if (!force && !topicEditTrigger.hidden) topicEditTrigger.focus();
- };
- const onCancel = () => close();
- const onSubmit = async (event) => {
- event.preventDefault();
- if (busy || !optionsLoaded || !commitLabelInput()) return;
- const nextTitle = String(titleInput.value || '').trim();
- const nextCategoryId = selectedCategoryId;
- if (!nextTitle) {
- status.textContent = '标题不能为空';
- titleInput.focus();
- return;
- }
- const category = categoriesById.get(nextCategoryId);
- if (!category) {
- status.textContent = '请选择类别';
- categoryTrigger.focus();
- return;
- }
- if (!ctx.topicData || !ctx.topicData.details || ctx.topicData.details.can_edit !== true) {
- status.textContent = '当前账号没有编辑该帖子的权限';
- return;
- }
- setBusy(true);
- status.textContent = '正在保存…';
- const nextLabels = selectedLabels();
- const updateProps = {
- title: nextTitle,
- category_id: nextCategoryId,
- tags: nextLabels.map((label) => Object.assign(
- { name: label.name },
- +label.id > 0 ? { id: +label.id } : {},
- )),
- };
- try {
- const topicModule = lookupDiscourseModule('discourse/models/topic');
- const Topic = topicModule?.default;
- if (!Topic || !isFunction(Topic.create) || !isFunction(Topic.update))
- throw new Error('Discourse 主题编辑接口尚未就绪');
- const topicModel = Topic.create({
- ...ctx.topicData,
- id: Number(topicId),
- slug: String(ctx.topicData.slug || ''),
- });
- await Topic.update(topicModel, updateProps, { fastEdit: true });
- await clearStoredResponseCache({ tags: [`topic:${topicId}`] });
- let freshTopic = null;
- try {
- freshTopic = await loader.refreshTopicData({ priority: POST_REQUEST_PRIORITY.visible });
- } catch {}
- const updatedTopic = Object.assign({}, ctx.topicData || {}, {
- title: discourseModelValue(topicModel, 'title'),
- fancy_title: discourseModelValue(topicModel, 'fancy_title'),
- tags: discourseModelValue(topicModel, 'tags'),
- }, freshTopic || {});
- updatedTopic.title = String(updatedTopic.title || nextTitle);
- updatedTopic.fancy_title = String(
- (freshTopic?.fancy_title) || updatedTopic.fancy_title || updatedTopic.title,
- );
- updatedTopic.category_id = nextCategoryId;
- updatedTopic.category_name = cleanTopicCategoryName(category.name);
- updatedTopic.category_level = topicCategoryLevel(category.name);
- updatedTopic.category_slug = String(category.slug || '');
- updatedTopic.tags = nextLabels.map((label) => label.name);
- topicNavMetadataOverride = {
- categoryName: updatedTopic.category_name,
- categoryLevel: updatedTopic.category_level,
- categorySlug: updatedTopic.category_slug,
- categoryId: nextCategoryId,
- categoryIconHtml: Number(sourceTopicNavMetadata.categoryId) === nextCategoryId
- ? sourceTopicNavMetadata.categoryIconHtml : '',
- tags: updatedTopic.tags.slice(),
- tagIcons: (Array.isArray(sourceTopicNavMetadata.tagIcons) ? sourceTopicNavMetadata.tagIcons : [])
- .filter((item) => updatedTopic.tags.includes(topicNavText(item?.name))),
- };
- sourceTopicNavMetadata = mergeTopicNavMetadata(topicNavMetadataOverride, cachedTopicNavMetadata(updatedTopic));
- ctx.topicData = updatedTopic;
- setCachedTopicData(topicId, updatedTopic);
- rememberTopic(updatedTopic, 0, readerQueueEntry(topicId)?.seenPostNumbers);
- busy = false;
- close(true);
- renderTopicShell(updatedTopic);
- syncTopicActionControls(ctx);
- showSelectionToast('帖子信息已更新');
- } catch (error) {
- status.textContent = error?.message ? error.message : '保存失败,请重试';
- setBusy(false);
- }
- };
- const state = {
- close,
- closeTransient: () => {
- if (categoryMenu.hidden) return false;
- closeCategoryMenu(true);
- return true;
- },
- };
- topicEditorState = state;
- titleInput.value = originalTitle;
- categoriesById.set(originalCategoryId, {
- id: originalCategoryId,
- name: cleanTopicCategoryName(topic.category_name || sourceTopicNavMetadata.categoryName || '当前类别'),
- slug: String(topic.category_slug || sourceTopicNavMetadata.categorySlug || ''),
- });
- categoryOptions.replaceChildren();
- syncCategorySelection();
- labelOptions.innerHTML = '';
- labelInput.value = '';
- status.textContent = '正在加载类别和 label…';
- renderSelectedLabels();
- setBusy(false);
- layer.hidden = false;
- setExpanded(topicEditTrigger, true);
- editorScope.listen(labelInput, 'keydown', onLabelKeyDown);
- editorScope.listen(labelInput, 'input', onLabelInput);
- editorScope.listen(labelInput, 'change', onLabelChange);
- editorScope.listen(labelValues, 'click', onLabelRemove);
- editorScope.listen(categoryTrigger, 'click', onCategoryToggle);
- editorScope.listen(categoryTrigger, 'keydown', onCategoryTriggerKeyDown);
- editorScope.listen(categoryOptions, 'click', onCategoryOptionClick);
- editorScope.listen(categorySearch, 'input', onCategorySearchInput);
- editorScope.listen(categoryMenu, 'keydown', onCategoryMenuKeyDown);
- editorScope.listen(layer, 'click', onLayerClick);
- editorScope.listen(form, 'submit', onSubmit);
- editorScope.listen(cancelButton, 'click', onCancel);
- editorScope.listen(closeButton, 'click', onCancel);
- titleInput.focus();
- titleInput.select();
- void loadTopicEditorOptions().then(({ categories }) => {
- if (closed || topicEditorState !== state) return;
- const loadedCategoryIds = new Set(categories.map((category) => +category.id));
- const availableCategories = categories.slice();
- if (originalCategoryId && !loadedCategoryIds.has(originalCategoryId))
- availableCategories.unshift({
- id: originalCategoryId,
- name: cleanTopicCategoryName(topic.category_name || sourceTopicNavMetadata.categoryName || '当前类别'),
- slug: String(topic.category_slug || sourceTopicNavMetadata.categorySlug || ''),
- });
- categoriesById.clear();
- availableCategories.forEach((category) => categoriesById.set(+category.id, category));
- renderCategoryOptions();
- optionsLoaded = true;
- status.textContent = '';
- renderSelectedLabels();
- setBusy(false);
- scheduleLabelSearch();
- }).catch((error) => {
- if (closed || topicEditorState !== state) return;
- status.textContent = `类别和 label 加载失败:${error?.message ? error.message : '请重试'}`;
- });
- };
- onContext(readerElement('.ldp-topic-edit-trigger'), 'click', openTopicEditor);
- overlay._ldpCaptureHistoryViewport = () => {
- timeline.sync();
- const viewport = embeddedReaderViewportState(ctx) || {};
- const timelinePostNumber = Number(
- timelineEl.querySelector('.ldp-topic-timeline-track')?.getAttribute('aria-valuenow')
- );
- const timelineViewport = embeddedReaderPostViewportState(ctx, timelinePostNumber);
- return normalizeReaderHistoryViewport({
- postNumber: Number(timelineViewport?.postNumber) || Number(viewport.postNumber) || 1,
- postOffset: timelineViewport ? timelineViewport.postOffset : viewport.postOffset,
- scrollTop: Number.isFinite(Number(viewport.scrollTop)) ? Number(viewport.scrollTop) : body.scrollTop,
- });
- };
- overlay._ldpCaptureHistoryPostNumber = () => overlay._ldpCaptureHistoryViewport()?.postNumber || 1;
- const rememberCurrentTopicPosition = () => {
- const currentTopic = ctx.topicData;
- if (!currentTopic) return;
- const currentPostNumber = Number(overlay._ldpCaptureHistoryPostNumber?.()) || targetPostNumber || 1;
- rememberTopic(currentTopic, currentPostNumber, readerQueueEntry(topicId)?.seenPostNumbers);
- };
- let readerLayoutSyncFrame = 0;
- const syncReaderWindowLayout = () => {
- readerLayoutSyncFrame = 0;
- syncReaderBackgroundMode();
- applyFontScales();
- readerSurfaceController.syncOpenSettingsControls(true);
- readerShell.syncSettingsSurfaceGeometry?.();
- if (ctx.nativeComposerWindow) ctx.nativeComposerWindow.syncLayer();
- positionHeaderPopovers();
- overlay._ldpSyncReaderQueuePosition?.();
- overlay._ldpSyncTopicActionRailPosition?.();
- ctx.streamListOffset = NaN;
- ctx.streamNeedsRepair = true;
- scheduleStreamWindowSync(ctx);
- };
- const onWindowResize = () => {
- if (!readerLayoutSyncFrame) readerLayoutSyncFrame = requestAnimationFrame(syncReaderWindowLayout);
- };
- contextScope.add(() => {
- if (readerLayoutSyncFrame) cancelAnimationFrame(readerLayoutSyncFrame);
- readerLayoutSyncFrame = 0;
- });
- onContext(window, 'resize', onWindowResize);
- onContext(overlay, 'ldp-reader-window-change', onWindowResize);
- if (typeof ResizeObserver === 'function')
- contextScope.observe(new ResizeObserver(onWindowResize), modal);
-
- let loading = false, done = false, pendingRetry = false, loadHalted = false, loadHaltError = null;
- let loadingPrev = false, loadingNext = false;
- let latestReplyRefreshRunning = false, latestReplyLastRefreshAt = 0;
- let latestReplyMessageBus = null, latestReplyEventTimer = 0;
- let latestReplyMessageSubscriptions = [];
- const livePostRefreshTimers = new Map();
- let pendingLiveReplyCount = 0, liveUpdateTargetPostNumber = 0, liveUpdateDismissed = false;
- let liveUpdateJumpRunning = false;
- let gapLoadTimer = 0, gapLoadDueAt = 0, pendingGapDirection = 0, pendingGapBatches = 1;
- let previousGapRetryCount = 0;
- let previousGapRetryAt = 0;
- let previousGapFailureUntil = 0, nextGapFailureUntil = 0;
- let onBodyScroll = null, onBodyTouchStart = null, onReaderScrollKeyDown = null;
- let onVisibilityChange = null;
- let scrollWorkFrame = 0, wheelPreparationFrame = 0, wheelPreparedDirection = 0;
- let pendingGoingUp = false, filterPumpTimer = 0, onlyOpProgressTimer = 0;
- let onlyOpBatchStartedAt = 0, onlyOpRetryAt = 0, onlyOpRetryStreak = 0;
- let onlyOpLastProgressAt = 0, onlyOpLastScanned = 0;
- let destroyActions = null, stopTopicPresence = null, disposed = false, closePromise = null;
- let cancelInitialNeighborPrefetch = null;
- let failureRetryTimer = 0;
- let nativeAppEvents = null;
- let handleNativeComposerPostRefresh = null, nativeComposerRefreshTimer = 0;
- let nativeComposerHostIsolation = null, suspendedNativeComposerHostTopicBus = null;
- let onOnlyOpToggle = null, onTitleJump = null, onTitleJumpKeyDown = null, onTimelineTopClick = null, onOpenNativePost = null;
- let onReaderRefreshClick = null, onReaderCloseClick = null, onOverlayClick = null, onModalWheel = null;
- let onNativeComposerCloseClick = null, onNativeComposerSubmitClick = null, onNativeComposerSubmitKeyDown = null;
- const clearFailureRetryTimer = () => {
- if (failureRetryTimer) clearTimeout(failureRetryTimer);
- failureRetryTimer = 0;
- };
- const stopOnlyOpProgress = () => {
- if (onlyOpProgressTimer) clearInterval(onlyOpProgressTimer);
- onlyOpProgressTimer = 0;
- };
- const syncOnlyOpProgress = () => {
- if (!onlyOpProgress || !onlyOpProgressValue) return;
- if (!ctx.onlyOp) {
- stopOnlyOpProgress();
- onlyOpProgress.hidden = true;
- return;
- }
- const total = Math.max(1, +(loader.streamLength || ctx.totalPosts || 1));
- const scanned = Math.min(total, Math.max(0, +(loader.scanCursor || 0)));
- const skipped = Math.min(scanned, Math.max(0, +(loader.scanSkippedCount || 0)));
- const found = streamLayoutItems(ctx).length;
- const now = Date.now();
- if (!onlyOpLastProgressAt || scanned !== onlyOpLastScanned) {
- onlyOpLastProgressAt = now;
- onlyOpLastScanned = scanned;
- if (scanned > 0) onlyOpRetryStreak = 0;
- }
- const percent = done ? 100 : Math.min(99.5, scanned / total * 100);
- const batchFlow = done ? '' : ` · 批${Math.max(1, Number(loader.scanBatchSize) || 1)}`;
- const skippedFlow = skipped ? ` · 缺失${skipped}` : '';
- let requestState = null;
- try { requestState = postRequestScheduler.snapshot(); } catch {}
- let state = done ? skipped ? 'partial' : 'done' : 'running';
- let flow = '';
- if (!done && loadHalted) {
- const status = Number(loadHaltError?.status || 0);
- state = 'error';
- flow = ` · 已暂停${status ? ` HTTP ${status}` : ''}`;
- } else if (!done && requestState && requestState.coolingDown) {
- state = 'cooldown';
- flow = ` · 429冷却 ${Math.max(1, Math.ceil(requestState.cooldownRemaining / 1000))}秒`;
- } else if (!done && requestState && ['10s', '60s'].includes(requestState.blockingReason)) {
- state = 'window';
- const label = requestState.blockingReason === '10s' ? '10秒窗' : '60秒窗';
- flow = ` · ${label} ${Math.max(1, Math.ceil(requestState.nextPermitDelay / 1000))}秒`;
- } else if (!done && pendingRetry && onlyOpRetryAt > now) {
- state = 'retry';
- flow = ` · 重试${onlyOpRetryStreak || 1} ${Math.max(1, Math.ceil((onlyOpRetryAt - now) / 1000))}秒`;
- } else if (!done && loading && onlyOpBatchStartedAt) {
- const waitingSeconds = Math.max(1, Math.ceil((now - onlyOpBatchStartedAt) / 1000));
- const globallyBusy = requestState && (
- Number(requestState.globalActive || 0) > 0 || Number(requestState.globalQueued || 0) > 0
- );
- state = waitingSeconds * 1000 >= POST_REQUEST_TIMEOUT ? 'waiting' : 'running';
- flow = globallyBusy
- ? ` · 请求处理中 ${waitingSeconds}秒`
- : ` · 缓存协调中 ${waitingSeconds}秒`;
- } else if (!done && now - onlyOpLastProgressAt >= 2000) {
- state = 'waiting';
- flow = ` · 调度恢复中 ${Math.max(2, Math.ceil((now - onlyOpLastProgressAt) / 1000))}秒`;
- } else if (!done && requestState) {
- const globalQueue = Math.max(requestState.queued, Number(requestState.globalQueued) || 0);
- flow = ` · 全局执行${requestState.globalActive} 排队${globalQueue}`;
- }
- onlyOpProgress.hidden = false;
- onlyOpProgress.dataset.state = state;
- onlyOpProgress.style.setProperty('--ldp-only-op-progress', `${percent.toFixed(1)}%`);
- const phase = done ? skipped ? '部分完成' : '完成' : state === 'error' ? '暂停' : state === 'retry' ? '等待' : '扫描';
- onlyOpProgressValue.textContent = `${phase} ${scanned}/${total} · 楼主${found}${skippedFlow}${batchFlow}${flow}`;
- setLabel(onlyOpProgress, `只看楼主${done ? skipped ? '扫描结束但有缺失' : '扫描完成' : '压测中'},已处理 ${scanned}/${total} 层,找到 ${found} 条楼主内容${skipped ? `,其中 ${skipped} 层未能取回` : ''}${batchFlow}${flow}`);
- onlyOpProgress.setAttribute('data-ldp-tooltip-label', onlyOpProgress.getAttribute('aria-label'));
- };
- const startOnlyOpProgress = (resetBaseline = true) => {
- stopOnlyOpProgress();
- if (resetBaseline || !onlyOpLastProgressAt) {
- onlyOpLastProgressAt = Date.now();
- onlyOpLastScanned = Math.max(0, +(loader.scanCursor || 0));
- }
- syncOnlyOpProgress();
- if (ctx.onlyOp && !done)
- onlyOpProgressTimer = setInterval(() => {
- syncOnlyOpProgress();
- const retryDue = pendingRetry && onlyOpRetryAt > 0 && onlyOpRetryAt <= Date.now();
- if (ctx.onlyOp && !done && !loading && !loadHalted && !filterPumpTimer &&
- (!pendingRetry || retryDue) && Date.now() - onlyOpLastProgressAt >= 2000)
- pump({ maxBatches: 1, forceOnlyOpScan: true });
- }, 500);
- };
- const prevLoadMisses = new Map();
- const nextLoadMisses = new Map();
- const gapMissBlocked = (misses, postNumber) => {
- const blockedUntil = Number(misses.get(+postNumber) || 0);
- if (!blockedUntil) return false;
- if (blockedUntil > Date.now()) return true;
- misses.delete(+postNumber);
- return false;
- };
- const sessionAcceptsWork = () => readerSession.isCurrent('opening', 'active');
- ctx.releaseInitialPrecedingContent = () => {
- if (!ctx.initialTargetPrecedingContentDeferred) return;
- ctx.initialTargetPrecedingContentDeferred = false;
- ctx.streamMountedNodes.forEach((node) => {
- if (+node.dataset.postNumber >= +(ctx.initialTargetHydrationBoundary || 0)) return;
- if (node.dataset.childReplyTotal && ctx.repliesIO && ctx.repliesIO.observe)
- ctx.repliesIO.observe(node);
- });
- scheduleStreamWindowSync(ctx);
- };
-
- const stopLatestReplyUpdates = () => {
- if (latestReplyEventTimer) clearTimeout(latestReplyEventTimer);
- livePostRefreshTimers.forEach((timer) => clearTimeout(timer));
- livePostRefreshTimers.clear();
- latestReplyEventTimer = 0;
- if (latestReplyMessageBus)
- latestReplyMessageSubscriptions.forEach(({ channel, handler }) => {
- try { latestReplyMessageBus.unsubscribe(channel, handler); } catch {}
- });
- latestReplyMessageBus = null;
- latestReplyMessageSubscriptions = [];
- };
- const scheduleFullTopicRefresh = () => {
- if (latestReplyEventTimer || !sessionAcceptsWork() ||
- document.visibilityState !== 'visible') return;
- latestReplyEventTimer = setTimeout(() => {
- latestReplyEventTimer = 0;
- refreshLatestReplyState({ force: true });
- }, 350);
- };
- const targetedLivePostMessageTypes = new Set([
- 'acted', 'created', 'rebaked', 'recovered', 'revised',
- ]);
- const refreshLivePostFromMessage = (payload) => {
- const reactionUpdate = Number(payload?.post_id || 0) > 0 &&
- Array.isArray(payload?.reactions);
- const postId = Number(reactionUpdate ? payload.post_id : payload?.id || 0);
- if (!postId || livePostRefreshTimers.has(postId) || !sessionAcceptsWork() ||
- document.visibilityState !== 'visible') return;
- const messageType = reactionUpdate ? 'acted' : String(payload.type || '');
- if (messageType === 'read') return;
- if (!targetedLivePostMessageTypes.has(messageType)) {
- scheduleFullTopicRefresh();
- return;
- }
- const timer = setTimeout(async () => {
- livePostRefreshTimers.delete(postId);
- if (!sessionAcceptsWork() || !isFunction(ctx.refreshPostById)) return;
- const wasKnown = readerPostCopies(postId, ctx).length > 0;
- const wasNearBottom = body.scrollHeight - body.scrollTop - body.clientHeight <= 120;
- const previousHighestPostNumber = Math.max(0, +(ctx.totalPosts || 0));
- try {
- const post = await ctx.refreshPostById(postId);
- if (!post || !sessionAcceptsWork()) return;
- const postNumber = Math.max(0, Number(post.post_number) || 0);
- const newlyAdded = messageType === 'created' && !wasKnown && postNumber > previousHighestPostNumber;
- if (postNumber) {
- ctx.totalPosts = Math.max(previousHighestPostNumber, postNumber);
- if (ctx.topicData) {
- ctx.topicData.highest_post_number = Math.max(
- Number(ctx.topicData.highest_post_number) || 0,
- postNumber
- );
- if (post.created_at) ctx.topicData.last_posted_at = post.created_at;
- }
- rememberLatestReplyTime(ctx, post);
- updateCommentsHeader(ctx);
- ctx.timeline.update();
- }
- if (!newlyAdded) return;
- void ctx.replyThreadWindow?._ldpIngestLivePost?.(post);
- if (wasNearBottom) {
- clearLiveUpdateButton();
- const previousScrollTop = body.scrollTop;
- body.scrollTop = body.scrollHeight;
- markStreamInternalScroll(ctx, previousScrollTop);
- } else {
- liveUpdateDismissed = false;
- pendingLiveReplyCount++;
- if (!liveUpdateTargetPostNumber || postNumber < liveUpdateTargetPostNumber)
- liveUpdateTargetPostNumber = postNumber;
- syncLiveUpdateButton();
- }
- } catch {
- scheduleFullTopicRefresh();
- }
- }, 120);
- livePostRefreshTimers.set(postId, timer);
- };
- const startLatestReplyUpdates = () => {
- if (latestReplyMessageSubscriptions.length || !ctx.topicData || !sessionAcceptsWork() ||
- document.visibilityState !== 'visible') return;
- const messageBus = lookupDiscourse('service:message-bus');
- if (isFunction(messageBus?.subscribe) && isFunction(messageBus.unsubscribe)) {
- const topicHandler = (message) => {
- const payload = message?.payload && typeof message.payload === 'object'
- ? message.payload
- : message;
- const messageTopicId = Number(message?.topic_id || payload?.topic_id || 0);
- if (messageTopicId && messageTopicId !== +topicId) return;
- refreshLivePostFromMessage(payload);
- };
- const subscriptions = [
- { channel: `/topic/${topicId}`, handler: topicHandler },
- { channel: `/topic/${topicId}/reactions`, handler: topicHandler },
- ];
- try {
- subscriptions.forEach(({ channel, handler }) => messageBus.subscribe(channel, handler));
- latestReplyMessageBus = messageBus;
- latestReplyMessageSubscriptions = subscriptions;
- return;
- } catch {
- subscriptions.forEach(({ channel, handler }) => {
- try { messageBus.unsubscribe(channel, handler); } catch {}
- });
- }
- }
- };
- overlay._ldpStopLatestReplyUpdates = stopLatestReplyUpdates;
-
- const syncLiveUpdateButton = () => {
- liveUpdateBtn.hidden = pendingLiveReplyCount <= 0 || liveUpdateDismissed;
- liveUpdateJumpBtn.disabled = liveUpdateJumpRunning;
- liveUpdateJumpBtn.setAttribute('aria-busy', String(liveUpdateJumpRunning));
- const label = liveUpdateBtn.querySelector('span');
- if (label) label.textContent = `有 ${pendingLiveReplyCount} 条新回复`;
- };
- const clearLiveUpdateButton = () => {
- pendingLiveReplyCount = 0;
- liveUpdateTargetPostNumber = 0;
- liveUpdateDismissed = false;
- syncLiveUpdateButton();
- };
- const onLiveUpdateJump = async () => {
- if (!sessionAcceptsWork() || liveUpdateJumpRunning) return;
- const targetPostNumber = liveUpdateTargetPostNumber;
- const pendingCount = pendingLiveReplyCount;
- clearLiveUpdateButton();
- if (!targetPostNumber) return;
- liveUpdateJumpRunning = true;
- syncLiveUpdateButton();
- let jumped = false;
- try {
- jumped = await ctx.timeline.jumpTo(targetPostNumber);
- if (!jumped && sessionAcceptsWork()) {
- const targetItem = ctx.streamItemMap.get(targetPostNumber);
- if (ctx.onlyOp && !streamItemMatchesFilter(ctx, targetItem) && isFunction(onOnlyOpToggle))
- await onOnlyOpToggle();
- else {
- ctx.streamWindowResetPending = true;
- syncStreamWindow(ctx);
- }
- await waitForReaderJumpLayout();
- if (sessionAcceptsWork()) jumped = await ctx.timeline.jumpTo(targetPostNumber);
- }
- } catch {
- jumped = false;
- } finally {
- liveUpdateJumpRunning = false;
- if (!jumped && sessionAcceptsWork()) {
- pendingLiveReplyCount += pendingCount;
- if (!liveUpdateTargetPostNumber || targetPostNumber < liveUpdateTargetPostNumber)
- liveUpdateTargetPostNumber = targetPostNumber;
- liveUpdateDismissed = false;
- showSelectionToast(`暂时无法定位到楼层 #${targetPostNumber},可重试`);
- }
- syncLiveUpdateButton();
- }
- };
- const onLiveUpdateDismiss = (event) => {
- consumeEvent(event);
- liveUpdateDismissed = true;
- syncLiveUpdateButton();
- };
- onContext(liveUpdateJumpBtn, 'click', onLiveUpdateJump);
- onContext(liveUpdateDismissBtn, 'click', onLiveUpdateDismiss);
-
- const refreshLatestReplyState = async (options = {}) => {
- const force = options.force === true;
- if (latestReplyRefreshRunning || !sessionAcceptsWork() || document.visibilityState !== 'visible') return null;
- const now = Date.now();
- if (!force && now - latestReplyLastRefreshAt < 10000) return null;
- latestReplyLastRefreshAt = now;
- latestReplyRefreshRunning = true;
- try {
- const refreshPriority = Number.isFinite(options.priority)
- ? options.priority
- : POST_REQUEST_PRIORITY.background;
- const previousHighestPostNumber = Math.max(
- 0,
- Number.isFinite(+options.afterPostNumber) ? +options.afterPostNumber : +(ctx.totalPosts || 0),
- );
- const freshTopic = await loader.refreshTopicData({ priority: refreshPriority });
- if (!freshTopic || !sessionAcceptsWork()) return null;
- const freshHighestPostNumber = Math.max(0, +(freshTopic.highest_post_number || 0), +(freshTopic.posts_count || 0));
- const postsByNumber = new Map();
- postsFromPayload(freshTopic).forEach((post) => {
- const postNumber = +(post?.post_number || 0);
- if (!postNumber) return;
- if (ctx.streamNodeMap.has(postNumber) || postNumber > previousHighestPostNumber)
- postsByNumber.set(postNumber, post);
- });
- let fetchAfter = previousHighestPostNumber;
- let pageGuard = 0;
- while (fetchAfter > 0 && fetchAfter < freshHighestPostNumber && pageGuard++ < 10) {
- const page = await loader.loadAfterPost(fetchAfter, {
- priority: refreshPriority,
- });
- if (!sessionAcceptsWork()) return null;
- const newer = page.filter((post) => +(post?.post_number || 0) > fetchAfter);
- if (!newer.length) break;
- newer.forEach((post) => postsByNumber.set(+post.post_number, post));
- const nextAfter = newer.reduce((latest, post) => Math.max(latest, +(post.post_number || 0)), fetchAfter);
- if (nextAfter <= fetchAfter) break;
- fetchAfter = nextAfter;
- }
- const hasNewPostCandidates = [...postsByNumber.keys()]
- .some((postNumber) => postNumber > previousHighestPostNumber);
- const wasNearBottom = hasNewPostCandidates &&
- body.scrollHeight - body.scrollTop - body.clientHeight <= 120;
- const freshPosts = ingestLivePosts([...postsByNumber.values()], ctx);
- freshPosts.forEach((post) => { void ctx.replyThreadWindow?._ldpIngestLivePost?.(post); });
- ctx.topicData = freshTopic;
- ctx.topicSharedIssueHostVisible = nativeTopicSharedIssueVisible(freshTopic);
- ctx.isPostVoting = !!freshTopic.is_post_voting;
- ctx.topicBookmarked = !!freshTopic.bookmarked;
- ctx.topicNotificationLevel = topicNotificationLevel(freshTopic);
- ctx.totalPosts = freshHighestPostNumber;
- rememberLatestReplyTime(ctx, { created_at: freshTopic.last_posted_at }, { force: true });
- updateCommentsHeader(ctx);
- syncTopicActionControls(ctx);
- renderTopicShell(freshTopic);
- syncAllPostActionControls(ctx);
- ctx.timeline.update();
- if (freshPosts.length) {
- if (!liveUpdateTargetPostNumber)
- liveUpdateTargetPostNumber = +(freshPosts[0]?.post_number || 0);
- if (wasNearBottom) {
- clearLiveUpdateButton();
- const previousScrollTop = body.scrollTop;
- body.scrollTop = body.scrollHeight;
- markStreamInternalScroll(ctx, previousScrollTop);
- } else {
- liveUpdateDismissed = false;
- pendingLiveReplyCount += freshPosts.length;
- syncLiveUpdateButton();
- }
- }
- return {
- posts: [...postsByNumber.values()],
- topic: freshTopic,
- };
- } catch {
- // Keep the last known timestamp when a background refresh fails.
- return null;
- } finally {
- latestReplyRefreshRunning = false;
- }
- };
- ctx.refreshPostByNumber = async (postNumber) => {
- const posts = await loader.loadPost(+postNumber, {
- priority: POST_REQUEST_PRIORITY.target,
- forceRefresh: true,
- });
- if (!sessionAcceptsWork()) return null;
- loader.persistFetchedPosts(posts);
- ingestLivePosts(posts, ctx, { refreshContent: true });
- return posts.find((post) => +(post?.post_number || 0) === +postNumber) || posts[0] || null;
- };
- ctx.refreshPostById = async (postId) => {
- const targetPostId = Number(postId) || 0;
- if (!targetPostId) return null;
- const post = await fetchJSON(`${BASE}/posts/${targetPostId}.json`, {
- scheduler: postRequestScheduler,
- priority: POST_REQUEST_PRIORITY.visible,
- cacheMode: 'refresh',
- allowStaleOnError: false,
- fetchOptions: { cache: 'no-store' },
- key: `post:${targetPostId}:live-update`,
- });
- if (!sessionAcceptsWork() || !post || +post.id !== targetPostId || !post.post_number) return null;
- loader.persistFetchedPosts([post]);
- ingestLivePosts([post], ctx, { refreshContent: true });
- return post;
- };
- ctx.refreshNativeComposerSession = async (session) => {
- if (!session || !sessionAcceptsWork()) return false;
- if (session.action === 'edit') {
- const updated = await ctx.refreshPostByNumber(session.postNumber);
- if (!updated) return false;
- return ctx.timeline.jumpTo(+updated.post_number, {
- targetPost: updated,
- forceNested: true,
- });
- }
- if (session.action !== 'reply') return false;
- const parentPostNumber = +(session.parentPostNumber || 0);
- const highestPostNumber = +(session.highestPostNumber || 0);
- const retryDelays = [0, 700, 1400];
- for (let attempt = 0; attempt < retryDelays.length && sessionAcceptsWork(); attempt++) {
- if (retryDelays[attempt]) {
- await waitForDelay(retryDelays[attempt]);
- if (!sessionAcceptsWork()) return false;
- }
- const result = await refreshLatestReplyState({
- force: true,
- afterPostNumber: highestPostNumber,
- priority: POST_REQUEST_PRIORITY.visible,
- });
- const candidates = new Map();
- (result?.posts || []).forEach((post) => candidates.set(+post.post_number, post));
- ctx.streamItems.forEach((item) => {
- const post = item?.node && item.node._ldpPostData;
- if (post && +post.post_number > highestPostNumber) candidates.set(+post.post_number, post);
- });
- const created = [...candidates.values()]
- .filter((post) => +post.post_number > highestPostNumber)
- .filter((post) => !ME_USERNAME || post.username === ME_USERNAME)
- .filter((post) => parentPostNumber <= 1
- ? +(post.reply_to_post_number || 0) <= 1
- : +(post.reply_to_post_number || 0) === parentPostNumber)
- .sort((a, b) => +b.post_number - +a.post_number)[0];
- if (created) {
- if (isFunction(ctx.loader?.persistFetchedPosts))
- ctx.loader.persistFetchedPosts([created]);
- clearLiveUpdateButton();
- return ctx.timeline.jumpTo(+created.post_number, {
- targetPost: created,
- forceNested: true,
- });
- }
- }
- return false;
- };
- ctx.bindNativeComposerRefresh = () => {
- if (handleNativeComposerPostRefresh) return true;
- nativeAppEvents = lookupDiscourse('service:app-events');
- if (!nativeAppEvents || !isFunction(nativeAppEvents.on)) return false;
- handleNativeComposerPostRefresh = () => {
- const session = ctx.nativeComposerSession;
- if (!session || session.refreshing || !sessionAcceptsWork()) return;
- session.refreshing = true;
- nativeComposerRefreshTimer = setTimeout(async () => {
- nativeComposerRefreshTimer = 0;
- try {
- await ctx.refreshNativeComposerSession(session);
- } finally {
- if (ctx.nativeComposerSession === session) ctx.nativeComposerSession = null;
- }
- }, 0);
- };
- nativeAppEvents.on('composer:created-post', handleNativeComposerPostRefresh);
- nativeAppEvents.on('composer:edited-post', handleNativeComposerPostRefresh);
- nativeAppEvents.on('post:created', handleNativeComposerPostRefresh);
- return true;
- };
- ctx.bindNativeComposerRefresh();
- onVisibilityChange = () => {
- if (!sessionAcceptsWork()) return;
- const visible = document.visibilityState === 'visible';
- ctx.timeline.setActive(visible);
- ctx.repliesIO?.setActive?.(visible);
- recordResourceMonitorVisibility();
- if (!visible) {
- stopLatestReplyUpdates();
- stopRateLimitNotice();
- stopOnlyOpProgress();
- stopRequestFlowUpdates();
- suspendReaderMedia(overlay);
- ctx.streamHydratedNodes.forEach((node) => suspendReaderMedia(node));
- return;
- }
- ctx.streamMountedNodes.forEach((node) => activateReaderMedia(node));
- startLatestReplyUpdates();
- startRateLimitNotice();
- if (ctx.onlyOp && !done) startOnlyOpProgress(false);
- if (pendingRetry && !filterPumpTimer && !loading && !done && !loadHalted) {
- onlyOpRetryAt = 0;
- void pump({ maxBatches: 1, forceOnlyOpScan: ctx.onlyOp });
- }
- if (pendingGapDirection) scheduleGapLoad(pendingGapDirection < 0);
- syncSettingsLogUpdates();
- ctx.timeline.update();
- ctx.timeline.refreshRelativeTimes();
- refreshLatestReplyState({ force: true });
- };
- onContext(document, 'visibilitychange', onVisibilityChange);
-
- const readerEscapeExitWindow = 1500;
- let readerEscapeExitDeadline = 0;
- let readerEscapeExitTimer = 0;
- let readerEscapePointerX = Number.NaN;
- let readerEscapePointerY = Number.NaN;
- const disarmReaderEscapeExit = () => {
- if (!readerEscapeExitDeadline && !readerEscapeExitTimer) return;
- readerEscapeExitDeadline = 0;
- if (readerEscapeExitTimer) clearTimeout(readerEscapeExitTimer);
- readerEscapeExitTimer = 0;
- readerPortalQuery('.ldp-selection-toast[data-ldp-reader-exit-hint]')?.remove();
- };
- const armReaderEscapeExit = () => {
- disarmReaderEscapeExit();
- readerEscapeExitDeadline = Date.now() + readerEscapeExitWindow;
- showSelectionToast('再按一次 Esc 退出阅读器');
- const toast = readerPortalQuery('.ldp-selection-toast');
- if (toast) toast.dataset.ldpReaderExitHint = '1';
- readerEscapeExitTimer = setTimeout(disarmReaderEscapeExit, readerEscapeExitWindow);
- };
- const nativeComposerCloseWindow = 1500;
- let nativeComposerCloseAction = '';
- let nativeComposerCloseDeadline = 0;
- let nativeComposerCloseTimer = 0;
- const disarmNativeComposerClose = () => {
- nativeComposerCloseAction = '';
- nativeComposerCloseDeadline = 0;
- if (nativeComposerCloseTimer) clearTimeout(nativeComposerCloseTimer);
- nativeComposerCloseTimer = 0;
- readerPortalQuery('.ldp-selection-toast[data-ldp-composer-close-hint]')?.remove();
- };
- const nativeComposerCloseConfirmed = (action) =>
- nativeComposerCloseAction === action && nativeComposerCloseDeadline > Date.now();
- const armNativeComposerClose = (action, message) => {
- disarmNativeComposerClose();
- nativeComposerCloseAction = action;
- nativeComposerCloseDeadline = Date.now() + nativeComposerCloseWindow;
- showSelectionToast(message);
- const toast = readerPortalQuery('.ldp-selection-toast');
- if (toast) toast.dataset.ldpComposerCloseHint = '1';
- nativeComposerCloseTimer = setTimeout(disarmNativeComposerClose, nativeComposerCloseWindow);
- };
- const releaseReaderNativeComposerHostIsolation = (isolation = nativeComposerHostIsolation) => {
- if (!isolation || isolation.released) return;
- isolation.released = true;
- if (isolation.appEvents && isolation.appEvents.trigger === isolation.trigger) {
- try { isolation.appEvents.trigger = isolation.originalTrigger; } catch {}
- }
- (isolation.routeGuards || []).forEach(({ owner, originalRouteTo, guardedRouteTo }) => {
- if (owner.routeTo !== guardedRouteTo) return;
- try { owner.routeTo = originalRouteTo; } catch {}
- });
- if (nativeComposerHostIsolation === isolation) nativeComposerHostIsolation = null;
- };
- const resumeReaderNativeComposerHostTopicBus = () => {
- const suspended = suspendedNativeComposerHostTopicBus;
- suspendedNativeComposerHostTopicBus = null;
- if (!suspended || !suspended.topicController ||
- !isFunction(suspended.topicController.subscribe)) return;
- const currentModel = discourseModelValue(suspended.topicController, 'model');
- const currentTopicId = +(discourseModelValue(currentModel, 'id') || 0);
- if (currentTopicId !== suspended.hostTopicId) return;
- try { suspended.topicController.subscribe(); } catch {}
- };
- const isolateReaderNativeComposerFromHost = () => {
- const session = ctx.nativeComposerSession;
- if (!session || !['reply', 'edit'].includes(session.action)) return null;
- releaseReaderNativeComposerHostIsolation();
- const appEvents = session.composer && (
- session.composer.appEvents || lookupDiscourse('service:app-events')
- );
- if (!appEvents || !isFunction(appEvents.trigger)) return null;
- const originalTrigger = appEvents.trigger;
- const readerRefreshEvents = new Set(session.action === 'edit'
- ? ['composer:edited-post']
- : ['composer:created-post', 'post:created']);
- const suppressedEvents = new Set([
- ...readerRefreshEvents,
- 'post:highlight',
- ]);
- const isolation = {
- appEvents,
- originalTrigger,
- trigger: null,
- routeGuards: [],
- released: false,
- };
- isolation.trigger = function (eventName, ...args) {
- const name = String(eventName || '');
- if (readerRefreshEvents.has(name) && isFunction(handleNativeComposerPostRefresh))
- handleNativeComposerPostRefresh();
- if (suppressedEvents.has(name)) return this;
- return originalTrigger.call(this, eventName, ...args);
- };
- try {
- appEvents.trigger = isolation.trigger;
- } catch {
- return null;
- }
- const discourseUrlModule = lookupDiscourseModule('discourse/lib/url');
- const routeOwner = discourseUrlModule?.default;
- if (isFunction(routeOwner?.routeTo)) {
- const originalRouteTo = routeOwner.routeTo;
- const guardedRouteTo = function (url, ...args) {
- const route = extractTopicRouteFromUrl(String(url || ''));
- if (route && +route.topicId === +ctx.topicId && +route.postNumber > 0) return;
- return originalRouteTo.call(this, url, ...args);
- };
- try {
- routeOwner.routeTo = guardedRouteTo;
- if (routeOwner.routeTo === guardedRouteTo)
- isolation.routeGuards.push({ owner: routeOwner, originalRouteTo, guardedRouteTo });
- } catch {}
- }
- const topicController = lookupDiscourse('controller:topic');
- const hostModel = discourseModelValue(topicController, 'model');
- const hostTopicId = +(discourseModelValue(hostModel, 'id') || 0);
- const alreadySuspended = suspendedNativeComposerHostTopicBus &&
- suspendedNativeComposerHostTopicBus.topicController === topicController &&
- suspendedNativeComposerHostTopicBus.hostTopicId === hostTopicId;
- if (!alreadySuspended && hostTopicId === +ctx.topicId && topicController &&
- isFunction(topicController.unsubscribe)) {
- resumeReaderNativeComposerHostTopicBus();
- try {
- topicController.unsubscribe();
- suspendedNativeComposerHostTopicBus = { topicController, hostTopicId };
- } catch {}
- }
- nativeComposerHostIsolation = isolation;
- return isolation;
- };
- const nativeReaderComposerButton = () => {
- const session = ctx.nativeComposerSession;
- if (!session || !['reply', 'edit'].includes(session.action) ||
- discourseModelValue(session.composer, 'model') !== session.model) return null;
- const composer = document.querySelector('#reply-control');
- if (!nativeComposerVisible(composer) ||
- !composer.matches(`.composer-action-${session.action}`)) return null;
- return composer.querySelector('.save-or-cancel button.create');
- };
- const submitReaderNativeComposerWithoutHostRefresh = (button) => {
- const session = ctx.nativeComposerSession;
- const composer = session?.composer;
- if (!button || button.disabled || !composer || !isFunction(composer.save)) return false;
- const isolation = isolateReaderNativeComposerFromHost();
- try {
- const saveResult = composer.save(true, { jump: false });
- Promise.resolve(saveResult).then(
- () => releaseReaderNativeComposerHostIsolation(isolation),
- () => releaseReaderNativeComposerHostIsolation(isolation),
- );
- } catch (error) {
- releaseReaderNativeComposerHostIsolation(isolation);
- console.warn('[LINUX DO reader] native composer submit failed', error);
- }
- return true;
- };
- onNativeComposerSubmitClick = (event) => {
- const button = event.target.closest?.('#reply-control .save-or-cancel button.create');
- if (!button || button !== nativeReaderComposerButton()) return;
- event.preventDefault();
- event.stopImmediatePropagation();
- submitReaderNativeComposerWithoutHostRefresh(button);
- };
- onNativeComposerSubmitKeyDown = (event) => {
- if (event.isComposing || !(event.ctrlKey || event.metaKey) ||
- (event.key !== 'Enter' && event.code !== 'Enter')) return;
- const button = nativeReaderComposerButton();
- if (!button) return;
- event.preventDefault();
- event.stopImmediatePropagation();
- submitReaderNativeComposerWithoutHostRefresh(button);
- };
- onNativeComposerCloseClick = (event) => {
- const button = event.target.closest?.('#reply-control .toggle-save-and-close, #reply-control .discard-button');
- if (!button || !nativeComposerVisible(document.querySelector('#reply-control'))) return;
- if (!PREFS.confirmNativeComposerClose) {
- disarmNativeComposerClose();
- return;
- }
- const action = button.classList.contains('discard-button') ? 'discard' : 'save-close';
- if (nativeComposerCloseConfirmed(action)) {
- disarmNativeComposerClose();
- if (action === 'discard') {
- event.preventDefault();
- event.stopImmediatePropagation();
- discardReaderNativeComposer(ctx).catch((error) => {
- console.warn('[LINUX DO reader] native composer discard failed', error);
- });
- }
- return;
- }
- event.preventDefault();
- event.stopImmediatePropagation();
- armNativeComposerClose(action, action === 'discard'
- ? '再点一次舍弃回复'
- : '再点一次关闭回复窗口');
- };
- const onReaderEscapePointerMove = (event) => {
- readerEscapePointerX = event.clientX;
- readerEscapePointerY = event.clientY;
- };
- const visibleExpandedDefaultPosts = () => {
- const rootRect = ctx.scrollRoot.getBoundingClientRect();
- return [...ctx.commentsEl.querySelectorAll(
- ':scope > .ldp-post.ldp-reply-collapsible:not(.ldp-nested-collapsed)',
- )].map((post) => ({ post, rect: post.getBoundingClientRect() }))
- .filter(({ post }) => isEscCollapsibleNestedReply(post))
- .filter(({ rect }) => rect.bottom > rootRect.top && rect.top < rootRect.bottom &&
- rect.right > rootRect.left && rect.left < rootRect.right)
- .sort((a, b) => a.rect.top - b.rect.top);
- };
- const pickExpandedDefaultPost = () => {
- if (ctx.onlyOp) return null;
- const candidates = visibleExpandedDefaultPosts();
- const pointed = candidates.find(({ rect }) =>
- Number.isFinite(readerEscapePointerX) && Number.isFinite(readerEscapePointerY) &&
- readerEscapePointerX >= rect.left && readerEscapePointerX <= rect.right &&
- readerEscapePointerY >= rect.top && readerEscapePointerY <= rect.bottom);
- return (pointed || candidates[0] || {}).post || null;
- };
- const onReaderEscapeExitInteraction = (event) => {
- disarmReaderEscapeExit();
- if (event.type !== 'pointerdown' || event.button !== 0 || !readerWorkspace.isEmbedded() ||
- eventPathIncludes(event, READER_PORTAL_HOST)) return;
- readerSurfaceController.closeFloatingPanels();
- };
- let runtimeStopped = false;
- let shellStopped = false;
- const runReaderCleanup = (label, cleanup) => {
- try { cleanup(); } catch (error) { console.warn(`[LDP] ${label} cleanup failed`, error); }
- };
- const stopReaderRuntime = (options = {}) => {
- const preserveShell = options.preserveShell === true;
- if (!runtimeStopped) {
- runtimeStopped = true;
- if (queueEntry)
- saveReaderQueueAnchor(queueEntry.topicId, captureReaderAnchorState(overlay));
- if (isFunction(overlay._ldpCloseCodePreview)) overlay._ldpCloseCodePreview(false, true);
- if (preserveShell) {
- const parkedCommentsHeader = parkReaderCommentsHeader(overlay, ctx.commentsHeader);
- if (parkedCommentsHeader) readerShell.commentsHeader = parkedCommentsHeader;
- }
- stopLatestReplyUpdates();
- stopOnlyOpProgress();
- stopRateLimitNotice();
- latestReplyRefreshRunning = false;
- runReaderCleanup('topic nav metadata', stopTopicNavMetadataHydration);
- if (nativeComposerRefreshTimer) clearTimeout(nativeComposerRefreshTimer);
- nativeComposerRefreshTimer = 0;
- releaseReaderNativeComposerHostIsolation();
- resumeReaderNativeComposerHostTopicBus();
- if (nativeAppEvents && handleNativeComposerPostRefresh && isFunction(nativeAppEvents.off)) {
- nativeAppEvents.off('composer:created-post', handleNativeComposerPostRefresh);
- nativeAppEvents.off('composer:edited-post', handleNativeComposerPostRefresh);
- nativeAppEvents.off('post:created', handleNativeComposerPostRefresh);
- }
- [
- ['loader', () => loader.destroy()],
- ['reader queue prefetch', stopReaderQueuePrefetch],
- ['request scheduler', () => {
- if (ACTIVE_READER_REQUEST_SCHEDULER === postRequestScheduler) ACTIVE_READER_REQUEST_SCHEDULER = null;
- postRequestScheduler.destroy();
- }],
- ['user card', hideFallbackUserCard],
- ['action dialog', () => ctx.readerActionDialog?.close()],
- ['topic editor', () => topicEditorState?.close(true)],
- ['composer window', () => ctx.nativeComposerWindow.destroy()],
- ['post actions', () => destroyActions && destroyActions()],
- ['read tracker', () => tracker.stop()],
- ['queue viewport tracker', () => ctx.queueViewportTracker.stop()],
- ['nested replies', () => ctx.repliesIO.disconnect()],
- ['descendant replies window', () => ctx.replyThreadWindow?._ldpClose?.()],
- ['body translation', () => ctx.translation.destroy()],
- ['media players', () => suspendReaderMedia(overlay)],
- ['detached media players', () => ctx.streamHydratedNodes.forEach((node) => suspendReaderMedia(node))],
- ['pending content hydration', () => [...ctx.pendingPostHydrations]
- .forEach((node) => cancelPostContentHydration(node, ctx))],
- ['pending latex render', () => [...ctx.pendingPostLatexRenders]
- .forEach((node) => cancelPostLatexRender(node, ctx))],
- ['jump highlight', clearActiveJumpHighlight],
- ['stream viewport anchor', () => clearStreamViewportAnchor(ctx)],
- ['avatar placeholders', () => releasePersistentAvatarImages(overlay)],
- ['resize observer', () => ctx.streamResizeObserver.disconnect()],
- ['timeline', () => ctx.timeline.destroy()],
- ['topic presence', () => stopTopicPresence && stopTopicPresence()],
- ].forEach(([label, cleanup]) => runReaderCleanup(label, cleanup));
- if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame);
- if (ctx.floorPreviewSyncFrame) cancelAnimationFrame(ctx.floorPreviewSyncFrame);
- if (scrollWorkFrame) cancelAnimationFrame(scrollWorkFrame);
- if (wheelPreparationFrame) cancelAnimationFrame(wheelPreparationFrame);
- ctx.streamWindowFrame = 0;
- ctx.floorPreviewSyncFrame = 0;
- scrollWorkFrame = 0;
- wheelPreparationFrame = 0;
- wheelPreparedDirection = 0;
- if (cancelInitialNeighborPrefetch) cancelInitialNeighborPrefetch();
- [gapLoadTimer, filterPumpTimer].forEach((timer) => {
- if (timer) clearTimeout(timer);
- });
- gapLoadTimer = 0;
- filterPumpTimer = 0;
- gapLoadDueAt = 0;
- pendingGapDirection = 0;
- pendingGapBatches = 1;
- contextScope.destroy();
- disarmNativeComposerClose();
- disarmReaderEscapeExit();
- if (readerShell.activeContext === ctx) readerShell.activeContext = null;
- [ctx.streamItems, ctx.streamLayoutItems].forEach((items) => { items.length = 0; });
- [ctx.streamItemMap, ctx.streamMountedNodes, ctx.streamHydratedNodes, ctx.pendingPostHydrations,
- ctx.pendingPostLatexRenders, ctx.streamNodeMap, ctx.subReplyState, ctx.directRepliesByParent,
- ctx.confirmedReadPostNumbers,
- ctx.replyParentByPost].forEach((collection) => collection.clear());
- }
- if (!preserveShell && !shellStopped) {
- shellStopped = true;
- overlay.dataset.readerShellDestroyed = '1';
- runReaderCleanup('avatar observers', () => {
- releasePersistentAvatarImages(notificationPanel.list);
- releasePersistentAvatarImages(historyPanel.list);
- releasePersistentAvatarImages(bookmarksPanel.list);
- });
- runReaderCleanup('resource monitor view', stopResourceMonitorUpdates);
- runReaderCleanup('request flow view', stopRequestFlowUpdates);
- if (readerShell.cancelShellFrames) readerShell.cancelShellFrames();
- runReaderCleanup('notification live events', () => readerShell.stopNotificationLiveEvents?.());
- runReaderCleanup('shell events', () => shellScope.destroy());
- runReaderCleanup('reader window', () => readerShell.readerWindow.destroy());
- runReaderCleanup('reader workspace', () => readerShell.readerWorkspace.destroy());
- runReaderCleanup('reader queue surface', () => overlay._ldpDestroyReaderQueueSurface?.());
- runReaderCleanup('surface manager', () => readerShell.surfaceManager.destroy());
- }
- };
-
- let readerSwitchTarget = null;
- let readerSwitchFrame = 0;
- let readerSwitchX = 0;
- let readerSwitchY = 0;
- const popupCanSwitchTopic = () => !readerWindow.isPinned() && readerWorkspace.isFloating() &&
- window.innerWidth > READER_WINDOW_COMPACT_WIDTH;
- const readerTopicTargetAt = (clientX, clientY) => {
- let source;
- try {
- overlay.classList.add('ldp-reader-hit-test-hidden');
- source = document.elementFromPoint(clientX, clientY);
- } finally {
- overlay.classList.remove('ldp-reader-hit-test-hidden');
- }
- const link = source?.closest('a[href]');
- const route = link && !link.classList.contains('ldp-open') && !shouldBypassReaderUrl(link.href)
- ? extractTopicRouteFromUrl(link.getAttribute('href') || link.href)
- : null;
- return route ? { link, route } : null;
- };
- const setReaderSwitchTarget = (link) => {
- if (readerSwitchTarget === link) return;
- readerSwitchTarget?.classList.remove('ldp-reader-switch-target');
- readerSwitchTarget = link || null;
- readerSwitchTarget?.classList.add('ldp-reader-switch-target');
- overlay.classList.toggle('ldp-reader-switch-ready', !!readerSwitchTarget);
- };
- const stopReaderSwitchTarget = () => {
- if (readerSwitchFrame) cancelAnimationFrame(readerSwitchFrame);
- readerSwitchFrame = 0;
- setReaderSwitchTarget(null);
- };
- const syncReaderSwitchTarget = () => {
- readerSwitchFrame = 0;
- const target = popupCanSwitchTopic() ? readerTopicTargetAt(readerSwitchX, readerSwitchY) : null;
- setReaderSwitchTarget(target?.link);
- };
- const onReaderSwitchPointerMove = (event) => {
- if (event.target !== overlay || event.pointerType === 'touch' || !popupCanSwitchTopic()) {
- stopReaderSwitchTarget();
- return;
- }
- readerSwitchX = event.clientX;
- readerSwitchY = event.clientY;
- if (!readerSwitchFrame) readerSwitchFrame = requestAnimationFrame(syncReaderSwitchTarget);
- };
-
- let prepareTopicSwitchPromise = null;
- const saveReaderNativeComposerDraftSafely = () => {
- let draftSavePromise;
- try {
- draftSavePromise = saveAndCloseReaderNativeComposer(ctx);
- } catch (error) {
- console.warn('[LINUX DO reader] native composer draft save setup failed', error);
- draftSavePromise = Promise.resolve(false);
- }
- return Promise.resolve(draftSavePromise).catch((error) => {
- console.warn('[LINUX DO reader] native composer draft save cleanup failed', error);
- return false;
- });
- };
- const prepareTopicSwitch = () => {
- if (prepareTopicSwitchPromise) return prepareTopicSwitchPromise;
- if (disposed) return Promise.resolve(false);
- clearFailureRetryTimer();
- stopReaderSwitchTarget();
- readerSession.state = 'switching';
- const draftSavePromise = saveReaderNativeComposerDraftSafely();
- rememberCurrentTopicPosition();
- stopReaderRuntime({ preserveShell: true });
- disposeHistoryNavigation();
- overlay._ldpCaptureHistoryViewport = () => null;
- overlay._ldpCaptureHistoryPostNumber = () => 1;
- prepareTopicSwitchPromise = draftSavePromise;
- return prepareTopicSwitchPromise;
- };
-
- const close = (closeOptions = {}) => {
- if (closePromise) return closePromise;
- if (disposed) return Promise.resolve();
- if (isFunction(overlay._ldpCancelAutoRetryWait)) overlay._ldpCancelAutoRetryWait();
- clearFailureRetryTimer();
- stopReaderSwitchTarget();
- disposed = true;
- readerSession.state = 'closing';
- const draftSavePromise = saveReaderNativeComposerDraftSafely();
- rememberCurrentTopicPosition();
- stopReaderRuntime();
- disposeHistoryNavigation();
- removeOwnedNode(overlay);
- if (CURRENT_OVERLAY === overlay) {
- CURRENT_OVERLAY = null;
- guardNativeReaderTimings();
- }
- if (!closeOptions.suppressNavigation) autoDequeueReaderQueueEntry(topicId);
- queueMicrotask(releaseReaderPortalIfIdle);
- if (ACTIVE_READER_SESSION_ID === readerSessionId) ACTIVE_READER_SESSION_ID = 0;
- PAGE_ROOT.classList.remove('ldp-reader-open');
- setPageScrollLocked(false);
- readerSession.state = 'closed';
- if (!closeOptions.preserveDocumentTitle) restoreHostDocumentTitle();
- if (!closeOptions.suppressNavigation) {
- AUTO_OPEN_SUPPRESSED_TOPIC_ID = String(topicId);
- PAGE_ROOT.classList.remove('ldp-route-takeover');
- syncNativeReaderTrigger();
- }
- closePromise = draftSavePromise;
- return closePromise;
- };
- overlay._ldpDispose = () => close({ suppressNavigation: true, preserveDocumentTitle: true });
- overlay._ldpPrepareTopicSwitch = prepareTopicSwitch;
- function onEsc(e) {
- if (e.key !== 'Escape') {
- disarmReaderEscapeExit();
- return;
- }
- if (readerPortalQuery('.ldp-lightbox')) return;
- if (e.repeat) {
- e.preventDefault();
- e.stopImmediatePropagation();
- return;
- }
- const exitConfirmed = readerEscapeExitDeadline > Date.now();
- disarmReaderEscapeExit();
- const boostMenu = document.body?.querySelector(':scope > .ldp-native-boost-menu');
- const boostEmojiPicker = [...document.querySelectorAll('.emoji-picker')].find(nativeComposerVisible);
- if (boostMenu && boostEmojiPicker && isFunction(boostMenu._ldpCloseEmojiPicker)) {
- e.preventDefault();
- e.stopImmediatePropagation();
- boostMenu._ldpCloseEmojiPicker();
- return;
- }
- const nativeFloatingSurface = visibleNativeFloatingSurface();
- if (nativeFloatingSurface) return;
- if (isFunction(boostMenu?._ldpClose)) {
- e.preventDefault();
- e.stopImmediatePropagation();
- boostMenu._ldpClose();
- return;
- }
- const nativeComposer = document.querySelector('#reply-control');
- if (nativeComposerVisible(nativeComposer)) {
- e.preventDefault();
- e.stopImmediatePropagation();
- if (!PREFS.confirmNativeComposerClose || nativeComposerCloseConfirmed('escape')) {
- disarmNativeComposerClose();
- discardReaderNativeComposer(ctx).catch((error) => {
- console.warn('[LINUX DO reader] native composer discard failed', error);
- });
- } else {
- armNativeComposerClose('escape', '再按一次 Esc 舍弃回复');
- }
- return;
- }
- if (!settingsPopover.hidden && ctx.replyThreadWindow?.isConnected) {
- consumeEvent(e);
- if (!fontFamilyMenu.hidden) readerSurfaceController.closeFontFamilyMenu(true);
- else readerSurfaceController.requestCloseSettings();
- return;
- }
- const threadPreview = ctx.replyThreadWindow?.querySelector('.ldp-floor-preview.open');
- if (threadPreview) {
- consumeEvent(e); ctx.floorPreview?.hide(threadPreview._ldpRelatedPost);
- return;
- }
- if (ctx.replyThreadWindow?.isConnected) {
- consumeEvent(e);
- if (!ctx.replyThreadWindow._ldpBack?.()) (ctx.replyThreadWindow._ldpCloseFromEscape || ctx.replyThreadWindow._ldpClose)?.();
- return;
- }
- if (isFunction(overlay._ldpCloseCodePreview)) {
- e.preventDefault();
- e.stopImmediatePropagation();
- overlay._ldpCloseCodePreview();
- return;
- }
- if (topicEditorState) {
- consumeEvent(e);
- if (!topicEditorState.closeTransient || !topicEditorState.closeTransient()) topicEditorState.close();
- return;
- }
- if (ctx.readerActionDialog) {
- consumeEvent(e);
- ctx.readerActionDialog.close();
- return;
- }
- if (isFunction(ctx.closeTransientActions) && ctx.closeTransientActions()) {
- consumeEvent(e);
- return;
- }
- if (fallbackUserCardOpen()) {
- consumeEvent(e);
- if (isFunction(ctx.closeUserCardLayer)) ctx.closeUserCardLayer();
- else if (isFunction(ctx.closeUserCard)) ctx.closeUserCard();
- else hideFallbackUserCard();
- return;
- }
- if (ctx.floorPreview?.isOpen() && ctx.floorPreview.isVisibleInViewport()) {
- consumeEvent(e);
- ctx.floorPreview.hide();
- if (pickExpandedDefaultPost())
- showSelectionToast('再按 Esc 收起楼层:优先鼠标所在位置,否则从上往下');
- return;
- }
- const collapsePost = pickExpandedDefaultPost();
- if (collapsePost) {
- consumeEvent(e);
- setNestedReplyCollapsed(collapsePost, true);
- const nextPost = pickExpandedDefaultPost();
- showSelectionToast(nextPost
- ? `已收起 #${collapsePost.dataset.postNumber || ''} · Esc 继续收起 #${nextPost.dataset.postNumber || ''}`
- : `已收起楼层 #${collapsePost.dataset.postNumber || ''}`);
- return;
- }
- const visibleSurface = [
- [notificationsPopover, readerSurfaceController.closeNotifications],
- [historyPanel.popover, readerSurfaceController.closeHistory],
- [bookmarksPanel.popover, readerSurfaceController.closeBookmarks],
- [fontFamilyMenu, () => readerSurfaceController.closeFontFamilyMenu(true)],
- [settingsPopover, readerSurfaceController.requestCloseSettings],
- ].find(([surface]) => !surface.hidden);
- if (visibleSurface) {
- consumeEvent(e);
- visibleSurface[1]();
- return;
- }
- e.preventDefault();
- e.stopImmediatePropagation();
- if (!PREFS.doubleEscapeToCloseReader || exitConfirmed) close();
- else armReaderEscapeExit();
- }
- const readerRefreshBtn = readerElement('.ldp-reader-refresh');
- readerRefreshBtn.disabled = readerSession.state !== 'active';
- onReaderRefreshClick = async () => {
- if (readerRefreshBtn.disabled || readerSession.state !== 'active') return;
- readerRefreshBtn.disabled = true;
- readerRefreshBtn.classList.add('is-refreshing');
- try {
- const refreshState = captureReaderAnchorState(
- overlay,
- Number(overlay._ldpCaptureHistoryPostNumber?.()) || 1
- );
- const refreshPostNumber = refreshState?.viewport.postNumber || 1;
- const refreshOnlyOp = ctx.onlyOp === true;
- const topicCacheSnapshot = ctx.topicData;
- if (isFunction(overlay._ldpPrepareTopicSwitch))
- await overlay._ldpPrepareTopicSwitch();
- await clearCurrentTopicCaches(topicId, topicCacheSnapshot);
- await openModal(topicId, {
- targetPostNumber: refreshPostNumber,
- restoreOnlyOp: refreshOnlyOp,
- restoreState: refreshState || undefined,
- topicNavMetadata: sourceTopicNavMetadata,
- historyNavigation,
- });
- } finally {
- readerRefreshBtn.classList.remove('is-refreshing');
- if (readerRefreshBtn.isConnected && readerSession.state === 'active') readerRefreshBtn.disabled = false;
- }
- };
- onContext(readerRefreshBtn, 'click', onReaderRefreshClick);
- onReaderCloseClick = () => close();
- onContext(readerElement('.ldp-close'), 'click', onReaderCloseClick);
- onContext(overlay, 'pointermove', onReaderSwitchPointerMove);
- const markWheelScrollPrepared = (direction) => {
- wheelPreparedDirection = direction;
- if (wheelPreparationFrame) return;
- wheelPreparationFrame = requestAnimationFrame(() => {
- wheelPreparationFrame = 0;
- wheelPreparedDirection = 0;
- });
- };
- onOverlayClick = (e) => {
- if (e.target !== overlay) return;
- if (topicEditorState) {
- topicEditorState.close();
- return;
- }
- if (!popupCanSwitchTopic()) return;
- const target = readerTopicTargetAt(e.clientX, e.clientY);
- if (target) {
- stopReaderSwitchTarget();
- AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
- hideNativeReaderTrigger();
- openModal(target.route.topicId, {
- targetPostNumber: topicUrlReaderStartPostNumber(target.route),
- topicNavMetadata: readTopicNavMetadata(target.link),
- });
- return;
- }
- close();
- };
- onContext(overlay, 'click', onOverlayClick);
- onModalWheel = (e) => {
- const floatingSurface = e.target.closest?.('.ldp-topic-action-rail,.ldp-reader-action-layer:not(.ldp-topic-edit-layer)');
- if (floatingSurface) {
- containFloatingSurfaceWheel(floatingSurface, e);
- return;
- }
- if (notificationsPopover.contains(e.target) || historyPanel.popover.contains(e.target) || bookmarksPanel.popover.contains(e.target)) return;
- const topicEditLayer = e.target.closest?.('.ldp-topic-edit-layer');
- if (topicEditLayer) {
- e.preventDefault();
- const categoryMenu = e.target.closest('.ldp-topic-edit-category-menu');
- const categoryOptions = categoryMenu?.querySelector('.ldp-topic-edit-category-options');
- if (categoryOptions && !categoryMenu.hidden) {
- const categoryDelta = e.deltaMode === 1
- ? e.deltaY * 40
- : e.deltaMode === 2 ? e.deltaY * categoryOptions.clientHeight : e.deltaY;
- categoryOptions.scrollTop += categoryDelta;
- }
- return;
- }
- if (e.ctrlKey) return;
- if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
- const delta = e.deltaMode === 1
- ? e.deltaY * 40
- : e.deltaMode === 2 ? e.deltaY * body.clientHeight : e.deltaY;
- let scrollTarget = e.target instanceof Element ? e.target : null;
- while (scrollTarget && scrollTarget !== body) {
- const maxScrollTop = scrollTarget.scrollHeight - scrollTarget.clientHeight;
- if (maxScrollTop > 1 && /(auto|scroll|overlay)/.test(getComputedStyle(scrollTarget).overflowY) &&
- (delta < 0 ? scrollTarget.scrollTop > 0 : scrollTarget.scrollTop < maxScrollTop - 1)) return;
- scrollTarget = scrollTarget.parentElement;
- }
- if (body.contains(e.target) && delta < 0) {
- e.preventDefault();
- body.scrollTop += delta;
- }
- if (delta < 0) {
- ctx.streamScrollDirection = -1;
- ctx.streamLastScrollAt = Date.now();
- ctx.streamScrollVelocity = Math.max(+(ctx.streamScrollVelocity || 0), Math.abs(delta) / 16);
- captureStreamViewportAnchor(ctx);
- clearInitialTargetHeadSpacer(ctx);
- ctx.releaseInitialPrecedingContent();
- scheduleStreamWindowSync(ctx);
- scheduleGapLoad(true);
- } else if (delta > 0) {
- ctx.streamScrollDirection = 1;
- queueStreamViewportAnchorCapture(ctx);
- }
- if (delta) markWheelScrollPrepared(delta < 0 ? -1 : 1);
- if (body.contains(e.target)) return;
- e.preventDefault();
- body.scrollTop += delta;
- };
- onContext(modal, 'wheel', onModalWheel, { passive: false });
- onContext(document, 'pointerdown', onReaderEscapeExitInteraction, true);
- onContext(document, 'scroll', onReaderEscapeExitInteraction, { capture: true, passive: true });
- onContext(document, 'click', onNativeComposerSubmitClick, true);
- onContext(document, 'keydown', onNativeComposerSubmitKeyDown, true);
- onContext(document, 'click', onNativeComposerCloseClick, true);
- onContext(document, 'keydown', onEsc, true);
- onContext(overlay, 'pointermove', onReaderEscapePointerMove);
-
- const sentinelVisible = () => body.scrollTop + body.clientHeight >= body.scrollHeight - 300;
-
- const pump = async (options = {}) => {
- if (loading || loadHalted || !sessionAcceptsWork()) return;
- loading = true;
- const maxBatches = options.maxBatches ?? 1;
- const keepFilling = options.keepFilling === true;
- const pumpOnlyOp = ctx.onlyOp;
- const forceOnlyOpScan = options.forceOnlyOpScan === true && pumpOnlyOp;
- if (pumpOnlyOp) onlyOpBatchStartedAt = Date.now();
- let batches = 0;
- let immediateRetries = 0;
- let retryDelay = 180;
- // 只要还没加载完,进入循环前先亮出底部提示
- if (!done) {
- endTip.classList.remove('show');
- loadingTip.classList.add('show');
- }
- try {
- while (!done && batches < maxBatches &&
- (forceOnlyOpScan || sentinelVisible() || pendingRetry || (keepFilling && batches > 0))) {
- pendingRetry = false;
- const { posts, done: isDone, retry, fatal, error } = await loader.next({
- onlyOp: pumpOnlyOp,
- onlyOpUsername: pumpOnlyOp ? ctx.op : '',
- persistFetchedPosts: options.persistFetchedPosts === true,
- onSource: (source, counts) => syncLoadingState(source, counts),
- });
- if (!sessionAcceptsWork()) return;
- if (posts.length) syncLoadingState('render');
- await attachPostBatchCooperatively(posts, ctx, { acceptsWork: sessionAcceptsWork });
- if (!sessionAcceptsWork()) return;
- if (ctx.onlyOp) syncOnlyOpProgress();
- if (fatal) {
- loadHalted = true;
- loadHaltError = error || null;
- pendingRetry = false;
- break;
- }
- if (retry) {
- pendingRetry = true;
- loadHaltError = error || null;
- const status = Number(error?.status || 0);
- const endpointRetryDelay = Math.max(0, Number(error?.retryAt || 0) - Date.now());
- if (pumpOnlyOp) onlyOpRetryStreak++;
- retryDelay = status === 429 || error && error.name === 'AbortError'
- ? Math.max(250, endpointRetryDelay)
- : status === 408 || status >= 500
- ? 1000
- : 400;
- if (shouldStopReaderFallback(error) || immediateRetries >= 1) break;
- immediateRetries++;
- await waitForDelay(400);
- continue;
- }
- loadHaltError = null;
- onlyOpRetryStreak = 0;
- onlyOpRetryAt = 0;
- retryDelay = 180;
- batches++;
- if (ctx.onlyOp === pumpOnlyOp) done = isDone;
- }
- } catch (error) {
- if (sessionAcceptsWork()) {
- loadHaltError = error || new Error('请求流异常停止');
- const transient = !!error && (
- error.name === 'AbortError' ||
- error.name === 'TypeError' ||
- Number(error.status || 0) === 408 ||
- Number(error.status || 0) === 429 ||
- Number(error.status || 0) >= 500
- );
- if (transient) {
- pendingRetry = true;
- if (pumpOnlyOp) onlyOpRetryStreak++;
- const endpointRetryDelay = Math.max(0, Number(error?.retryAt || 0) - Date.now());
- retryDelay = Math.min(
- endpointRetryDelay > 0 ? READER_ENDPOINT_429_MAX_BLOCK_MS : 15000,
- Math.max(
- endpointRetryDelay,
- 1000 * Math.pow(2, Math.min(4, Math.max(0, onlyOpRetryStreak - 1)))
- )
- );
- } else {
- loadHalted = pumpOnlyOp;
- pendingRetry = !pumpOnlyOp;
- retryDelay = 1000;
- console.warn(
- `[LINUX DO reader] request pump ${pumpOnlyOp ? 'stopped' : 'will retry'} after unexpected error`,
- error
- );
- }
- }
- } finally {
- loading = false;
- if (pumpOnlyOp) onlyOpBatchStartedAt = 0;
- if (sessionAcceptsWork()) {
- // 本轮抓取结束(无论成功、失败或无更多内容)都收起提示
- loadingTip.classList.remove('show');
- endTip.classList.toggle('show', done);
- if (ctx.onlyOp) syncOnlyOpProgress();
- if ((ctx.onlyOp || pendingRetry) && !done && !loadHalted && !filterPumpTimer) {
- if (ctx.onlyOp) onlyOpRetryAt = Date.now() + retryDelay;
- filterPumpTimer = setTimeout(() => {
- filterPumpTimer = 0;
- if (document.visibilityState !== 'visible') {
- onlyOpRetryAt = Date.now();
- return;
- }
- onlyOpRetryAt = 0;
- if (sessionAcceptsWork())
- pump({ maxBatches: 1, forceOnlyOpScan: ctx.onlyOp });
- }, retryDelay);
- } else if (done || loadHalted || !ctx.onlyOp)
- onlyOpRetryAt = 0;
- }
- }
- };
-
- const hasStreamGap = (node, backward) => {
- if (ctx.isPostVoting) return false;
- const num = +(node && node.dataset.postNumber);
- const misses = backward ? prevLoadMisses : nextLoadMisses;
- if (!num || (backward && num <= 1) || gapMissBlocked(misses, num)) return false;
- const item = ctx.streamItemMap.get(num);
- const lastPostNumber = Math.max(1, ctx.totalPosts || 1);
- if (ctx.onlyOp) {
- const layoutItems = streamLayoutItems(ctx);
- const boundary = layoutItems[backward ? 0 : layoutItems.length - 1];
- const loaded = ctx.streamItems[backward ? 0 : ctx.streamItems.length - 1];
- return item && item === boundary && loaded &&
- (backward ? loaded.postNumber > 1 : loaded.postNumber < lastPostNumber);
- }
- const adjacentIndex = item ? item.index + (backward ? -1 : 1) : -1;
- const adjacent = adjacentIndex >= 0 && adjacentIndex < ctx.streamItems.length
- ? ctx.streamItems[adjacentIndex]
- : null;
- if (!adjacent) return backward || num < lastPostNumber;
- return backward ? num - adjacent.postNumber > 1 : adjacent.postNumber - num > 1;
- };
-
- const visibleGapPost = (backward) => {
- const items = streamLayoutItems(ctx);
- const lookahead = scrollLoadPressure(ctx).lookahead;
- const start = Math.max(ctx.streamWindowStart,
- ctx.streamViewportTopIndex - (backward ? lookahead : 1));
- const end = Math.min(ctx.streamWindowEnd,
- ctx.streamViewportBottomIndex + (backward ? 2 : lookahead + 1));
- for (let index = start; index < end; index++) {
- const node = items[index]?.node;
- if (!node || !node.isConnected || !hasStreamGap(node, backward)) continue;
- return node;
- }
- return null;
- };
-
- const loadStreamGap = async (backward, preferredAnchor = null, options = {}) => {
- const loadingGap = backward ? loadingPrev : loadingNext;
- const failureUntil = backward ? previousGapFailureUntil : nextGapFailureUntil;
- if (loadingGap || !sessionAcceptsWork() || Date.now() < failureUntil) return false;
- const hasGap = (node) => hasStreamGap(node, backward);
- const findAnchor = () => visibleGapPost(backward);
- let anchor = preferredAnchor && (!backward || preferredAnchor.isConnected) && hasGap(preferredAnchor)
- ? preferredAnchor
- : findAnchor();
- if (backward && !anchor && ctx.streamScrollDirection < 0 && !ctx.streamItemMap.has(1)) {
- ctx.streamNeedsRepair = true;
- syncStreamWindow(ctx);
- anchor = findAnchor();
- }
- if (!anchor) return false;
- if (backward) captureStreamViewportAnchor(ctx);
- const maxBatches = Math.max(1, Math.min(2, +(options.maxBatches || 1)));
- let anchorNum = +anchor.dataset.postNumber;
- let loadedCount = 0;
- let retryDelay = 0;
- if (backward) loadingPrev = true;
- else loadingNext = true;
- try {
- for (let batch = 0; batch < maxBatches; batch++) {
- const directionChanged = backward ? ctx.streamScrollDirection > 0 : ctx.streamScrollDirection < 0;
- if (batch > 0 && (directionChanged || scrollLoadPressure(ctx).batches < 2)) break;
- const posts = await loader[backward ? 'loadBeforePost' : 'loadAfterPost'](anchorNum, {
- persistFetchedPosts: options.persistFetchedPosts === true,
- });
- if (backward) previousGapFailureUntil = 0;
- else nextGapFailureUntil = 0;
- if (!sessionAcceptsWork()) return false;
- if (backward && ctx.streamScrollDirection >= 0) return false;
- const fresh = posts.filter((p) => p && !ctx.streamNodeMap.has(+p.post_number));
- if (!fresh.length) {
- (backward ? prevLoadMisses : nextLoadMisses).set(anchorNum, Date.now() + 5000);
- if (backward) {
- retryDelay = 5100;
- previousGapRetryAt = Date.now() + retryDelay;
- }
- break;
- }
- const sorted = fresh.slice().sort((a, b) => (a.post_number || 0) - (b.post_number || 0));
- if (backward) {
- ctx.initialTargetHeadSpacerHeight = 0;
- attachPostBatch(sorted, ctx, {
- syncStreamNow: true,
- preserveViewportAnchor: true,
- });
- previousGapRetryCount = 0;
- previousGapRetryAt = 0;
- } else attachPostBatch(sorted, ctx);
- loadedCount += sorted.length;
- const edgePost = sorted[backward ? 0 : sorted.length - 1];
- const nextAnchorNum = +(edgePost?.post_number || 0);
- const nextAnchor = ctx.streamNodeMap.get(nextAnchorNum);
- const advanced = backward ? nextAnchorNum < anchorNum : nextAnchorNum > anchorNum;
- if (!nextAnchorNum || !advanced || !nextAnchor || !hasGap(nextAnchor)) break;
- anchorNum = nextAnchorNum;
- }
- return loadedCount > 0;
- } catch {
- if (backward) {
- previousGapFailureUntil = Date.now() + 3000;
- retryDelay = 3100;
- previousGapRetryAt = Date.now() + retryDelay;
- } else nextGapFailureUntil = Date.now() + 3000;
- } finally {
- if (backward) loadingPrev = false;
- else loadingNext = false;
- if (backward && retryDelay && ctx.streamScrollDirection < 0 && !ctx.streamItemMap.has(1) &&
- previousGapRetryCount < 2) {
- previousGapRetryCount++;
- scheduleGapLoad(true, retryDelay);
- }
- }
- return false;
- };
- const loadPreviousGap = (preferredAnchor = null, options = {}) =>
- loadStreamGap(true, preferredAnchor, options);
- const loadNextGap = (preferredAnchor = null, options = {}) =>
- loadStreamGap(false, preferredAnchor, options);
-
- const scheduleGapLoad = (goingUp, minimumDelay = 0) => {
- const direction = goingUp ? -1 : 1;
- const pressure = scrollLoadPressure(ctx);
- const directionChanged = !!pendingGapDirection && pendingGapDirection !== direction;
- pendingGapDirection = direction;
- pendingGapBatches = Math.max(directionChanged ? 1 : pendingGapBatches, pressure.batches);
- const previousRetryDelay = direction < 0 ? Math.max(0, previousGapRetryAt - Date.now()) : 0;
- const dueAt = Date.now() + Math.max(previousRetryDelay, Math.max(0, +minimumDelay || 0));
- if (gapLoadTimer && !directionChanged && dueAt >= gapLoadDueAt) return;
- if (gapLoadTimer) clearTimeout(gapLoadTimer);
- gapLoadDueAt = dueAt;
- gapLoadTimer = setTimeout(() => {
- gapLoadTimer = 0;
- gapLoadDueAt = 0;
- if (!sessionAcceptsWork() || document.visibilityState !== 'visible') return;
- const nextDirection = pendingGapDirection;
- const maxBatches = pendingGapBatches;
- pendingGapDirection = 0;
- pendingGapBatches = 1;
- if (nextDirection < 0) loadPreviousGap(null, { maxBatches });
- else if (nextDirection > 0) {
- if (loading || sentinelVisible()) return;
- loadNextGap(null, { maxBatches });
- }
- }, Math.max(0, dueAt - Date.now()));
- };
-
- ctx.loadPost = async (postNumber, options = {}) => {
- if (!sessionAcceptsWork()) return false;
- postNumber = +postNumber;
- if (!postNumber) return false;
- const around = options.scope === 'around';
- const shouldMount = options.mount !== false;
- const ensureAround = options.ensureAround === true && postNumber > 1;
- const targetAlreadyKnown = ctx.streamNodeMap.has(postNumber);
- if (targetAlreadyKnown && options.forceRefresh !== true && (!around || !ensureAround))
- return shouldMount ? !!getStreamPostNode(ctx, postNumber, { mount: true }) : true;
- let posts = [];
- try {
- posts = await loader.loadPost(postNumber, {
- onSource: options.onSource,
- priority: Number.isFinite(options.priority) ? options.priority
- : around ? POST_REQUEST_PRIORITY.target : undefined,
- scope: around ? 'around' : 'single',
- forceRefresh: options.forceRefresh === true,
- });
- } catch (error) {
- if (!around || !targetAlreadyKnown || options.forceRefresh === true) throw error;
- }
- if (!sessionAcceptsWork()) return false;
- const fresh = around
- ? posts.slice().sort((a, b) => (a.post_number || 0) - (b.post_number || 0))
- : posts.filter((p) => p && !ctx.streamNodeMap.has(+p.post_number));
- attachPostBatch(fresh, ctx);
- return shouldMount
- ? !!getStreamPostNode(ctx, postNumber, { mount: true })
- : ctx.streamNodeMap.has(postNumber);
- };
- const nearestAvailablePost = (postNumber) => {
- const target = +postNumber || 0;
- if (!target) return null;
- return loader.cachedPosts.reduce((nearest, candidate) => {
- const current = +(candidate?.post_number || 0);
- if (!current) return nearest;
- if (!nearest) return candidate;
- const nearestNumber = +(nearest.post_number || 0);
- const currentDistance = Math.abs(current - target);
- const nearestDistance = Math.abs(nearestNumber - target);
- if (currentDistance < nearestDistance) return candidate;
- if (currentDistance === nearestDistance && current > target && nearestNumber < target) return candidate;
- return nearest;
- }, null);
- };
-
- const syncOnlyOpButton = () => {
- onlyOpBtn.classList.toggle('active', ctx.onlyOp);
- setPressed(onlyOpBtn, ctx.onlyOp);
- setLabel(onlyOpBtn, ctx.onlyOp ? '显示全部楼层' : '只看楼主');
- };
- onOnlyOpToggle = async () => {
- if (!sessionAcceptsWork() || onlyOpBtn.disabled || !ctx.op) return;
- clearStreamViewportAnchor(ctx);
- ctx.onlyOp = !ctx.onlyOp;
- pendingRetry = false;
- done = ctx.onlyOp ? loader.scanDone : loader.loadDone;
- if (!ctx.onlyOp && filterPumpTimer) {
- clearTimeout(filterPumpTimer);
- filterPumpTimer = 0;
- }
- if (!ctx.onlyOp) {
- onlyOpBatchStartedAt = 0;
- onlyOpRetryAt = 0;
- onlyOpRetryStreak = 0;
- }
- syncOnlyOpButton();
- invalidateStreamLayout(ctx);
- if (ctx.onlyOp) startOnlyOpProgress();
- else syncOnlyOpProgress();
- ctx.streamWindowResetPending = true;
- ctx.streamMountedItemCount = -1;
- const previousScrollTop = body.scrollTop;
- body.scrollTop = 0;
- markStreamInternalScroll(ctx, previousScrollTop);
- if (ctx.onlyOp && !ctx.streamItemMap.has(1)) {
- await ctx.loadPost(1, { priority: POST_REQUEST_PRIORITY.target });
- if (!sessionAcceptsWork()) return;
- invalidateStreamLayout(ctx);
- }
- flushStreamWindow(ctx);
- ctx.timeline.update();
- if (ctx.onlyOp) pump({ maxBatches: 1, forceOnlyOpScan: true });
- };
- onContext(onlyOpBtn, 'click', onOnlyOpToggle);
-
- try {
- setReaderOpenPhase('load-topic');
- const topic = await initialTopicRequest;
- if (!sessionAcceptsWork()) return;
- setReaderOpenPhase('initialize-topic');
- ctx.topicData = topic;
- tracker.preload(postsFromPayload(topic));
- ctx.topicSharedIssueHostVisible = !loader.initializedFromCache && nativeTopicSharedIssueVisible(topic);
- postsFromPayload(topic).forEach((post) => rememberPostData(ctx, post));
- ctx.isPostVoting = !!topic.is_post_voting;
- ctx.topicBookmarked = !!topic.bookmarked;
- ctx.topicNotificationLevel = topicNotificationLevel(topic);
- ctx.latestReplyAt = String(topic.last_posted_at || '');
- ctx.latestReplyTimestamp = Date.parse(ctx.latestReplyAt) || 0;
- ctx.op = topic._opUsername;
- onlyOpBtn.disabled = !ctx.op;
- syncOnlyOpButton();
- ctx.totalPosts = Math.max(0, topic.highest_post_number || 0, topic.posts_count || 0);
- renderTopicShell(topic);
- ctx.syncTopicActionRail();
- updateCommentsHeader(ctx);
- syncTopicActionControls(ctx);
- syncCurrentUserFromHost();
- if (sessionAcceptsWork()) {
- readerShell.startNotificationLiveEvents?.();
- syncTopicActionControls(ctx);
- syncAllPostActionControls(ctx);
- readerElements('.ldp-post').forEach((postNode) => {
- renderPostSpecialContent(postNode, ctx);
- if (postNode.querySelector(':scope > .ldp-boost-list:not(.ldp-boost-pending)'))
- syncRenderedBoost(postNode, postNode._ldpPostData || {}, ctx);
- });
- }
- if (sessionAcceptsWork()) startLatestReplyUpdates();
- const titleBtn = readerElement('.ldp-title-jump');
- let cancelInitialTargetJump = false;
- ctx.cancelInitialTargetPosition = () => {
- cancelInitialTargetJump = true;
- ctx.initialTargetPositionPending = false;
- clearInitialTargetHeadSpacer(ctx);
- ctx.releaseInitialPrecedingContent();
- };
- onTitleJump = async () => {
- if (!sessionAcceptsWork()) return;
- ctx.replyThreadWindow?._ldpClose?.(false);
- await ctx.timeline.jumpToStart();
- };
- onContext(titleBtn, 'click', onTitleJump);
- onTitleJumpKeyDown = (event) => {
- if (event.key !== 'Enter' && event.key !== ' ') return;
- event.preventDefault();
- titleBtn.click();
- };
- onContext(titleBtn, 'keydown', onTitleJumpKeyDown);
- onTimelineTopClick = async () => {
- if (!sessionAcceptsWork()) return;
- await ctx.timeline.jumpToStart();
- };
- onContext(timelineEl.querySelector('.ldp-topic-timeline-top'), 'click', onTimelineTopClick);
-
- const openBtn = readerElement('.ldp-open');
- openBtn.href = topicPageUrl(topic.id, 0, true) ||
- `${BASE}/t/${topic.id}?${NATIVE_BYPASS_PARAM}=1`;
- openBtn.hidden = false;
- setLabel(openBtn, '打开原帖');
- onOpenNativePost = (event) => {
- if (event.type === 'click' && event.button && event.button !== 0) return;
- if (event.type === 'auxclick' && event.button !== 1) return;
- if (!openNativeTopicTab(openBtn.href)) return;
- consumeEvent(event);
- };
- onContext(openBtn, 'click', onOpenNativePost);
- onContext(openBtn, 'auxclick', onOpenNativePost);
- ctx.topicBookmarked = !!topic.bookmarked;
- syncTopicActionControls(ctx);
- ctx.toggleTopicBookmark = () => toggleReaderBookmark(topic, ctx, 'Topic');
- stopTopicPresence = bindReaderTopicPresence(ctx);
-
- destroyActions = bindActions(modal, ctx);
- guardNativeReaderTimings();
- tracker.start();
-
- let lastScrollTop = body.scrollTop;
- let lastScrollAt = Date.now();
- ctx.streamInternalScrollTop = NaN;
- ctx.streamInternalScrollDelta = 0;
- onBodyScroll = () => {
- if (!sessionAcceptsWork()) return;
- const now = Date.now();
- const nextScrollTop = body.scrollTop;
- const rawDelta = nextScrollTop - lastScrollTop;
- const elapsed = Math.max(16, now - lastScrollAt);
- lastScrollTop = nextScrollTop;
- lastScrollAt = now;
- const expectedInternalScrollTop = ctx.streamInternalScrollTop;
- const internalScrollDelta = Number.isFinite(ctx.streamInternalScrollDelta)
- ? ctx.streamInternalScrollDelta
- : 0;
- ctx.streamInternalScrollTop = NaN;
- ctx.streamInternalScrollDelta = 0;
- // Anchor compensation and a wheel step can share one scroll event.
- // Remove only the compensation so windowing still sees the user's residual movement.
- let delta = rawDelta - internalScrollDelta;
- if (Math.abs(delta) < 1) delta = 0;
- const hasInternalScroll = Math.abs(internalScrollDelta) >= 0.01 ||
- (Number.isFinite(expectedInternalScrollTop) &&
- Math.abs(nextScrollTop - expectedInternalScrollTop) < 1);
- if (hasInternalScroll && !delta) {
- scheduleStreamWindowSync(ctx);
- return;
- }
- if (ctx.initialTargetPositionPending) {
- scheduleStreamWindowSync(ctx);
- return;
- }
- if (delta && readerSession.state === 'active') ctx.viewportPersistenceEnabled = true;
- const deltaDirection = delta < 0 ? -1 : delta > 0 ? 1 : 0;
- const wheelAlreadyPrepared = !!deltaDirection && wheelPreparedDirection === deltaDirection;
- if (wheelAlreadyPrepared) {
- wheelPreparedDirection = 0;
- if (wheelPreparationFrame) cancelAnimationFrame(wheelPreparationFrame);
- wheelPreparationFrame = 0;
- }
- if (deltaDirection) {
- ctx.streamScrollDirection = deltaDirection;
- if (!wheelAlreadyPrepared) queueStreamViewportAnchorCapture(ctx);
- if (deltaDirection < 0) {
- if (!wheelAlreadyPrepared) {
- clearInitialTargetHeadSpacer(ctx);
- ctx.releaseInitialPrecedingContent();
- }
- } else {
- previousGapRetryCount = 0;
- }
- }
- const followsUserIntent = !deltaDirection || deltaDirection === ctx.streamScrollDirection;
- pendingGoingUp = ctx.streamScrollDirection < 0;
- if (followsUserIntent) {
- const instantVelocity = Math.abs(delta) / elapsed;
- ctx.streamScrollVelocity = ctx.streamScrollVelocity * 0.55 + instantVelocity * 0.45;
- }
- ctx.streamLastScrollAt = now;
- scheduleStreamWindowSync(ctx);
- if (scrollWorkFrame) return;
- scrollWorkFrame = requestAnimationFrame(() => {
- scrollWorkFrame = 0;
- if (!sessionAcceptsWork()) return;
- if (pendingLiveReplyCount > 0 &&
- body.scrollHeight - body.scrollTop - body.clientHeight <= 80)
- clearLiveUpdateButton();
- if (body.scrollTop <= 10 && !ctx.streamItemMap.has(1)) {
- pendingGapDirection = 0;
- pendingGapBatches = 1;
- if (gapLoadTimer) {
- clearTimeout(gapLoadTimer);
- gapLoadTimer = 0;
- gapLoadDueAt = 0;
- }
- loadPreviousGap(null, {
- maxBatches: scrollLoadPressure(ctx).batches,
- });
- } else {
- scheduleGapLoad(pendingGoingUp);
- }
- if (sentinelVisible()) {
- const pressure = scrollLoadPressure(ctx);
- pump({
- maxBatches: pressure.batches,
- keepFilling: pressure.batches > 1,
- });
- }
- });
- };
- onContext(body, 'scroll', onBodyScroll, { passive: true });
- onBodyTouchStart = () => {
- if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
- ctx.streamScrollDirection = 0;
- captureStreamViewportAnchor(ctx);
- ctx.releaseInitialPrecedingContent();
- };
- onContext(body, 'touchstart', onBodyTouchStart, { passive: true });
- onReaderScrollKeyDown = (event) => {
- if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) {
- if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
- ctx.streamScrollDirection = -1;
- captureStreamViewportAnchor(ctx);
- markWheelScrollPrepared(-1);
- clearInitialTargetHeadSpacer(ctx);
- ctx.releaseInitialPrecedingContent();
- scheduleGapLoad(true);
- } else if (['ArrowDown', 'PageDown', 'End'].includes(event.key) || (event.key === ' ' && !event.shiftKey)) {
- if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
- ctx.streamScrollDirection = 1;
- captureStreamViewportAnchor(ctx);
- markWheelScrollPrepared(1);
- }
- };
- onContext(overlay, 'keydown', onReaderScrollKeyDown);
-
- setReaderOpenPhase('load-target-post');
- let targetLoaded = false;
- const syncTargetLoadingSource = (source) => syncLoadingState(source, { target: true });
- targetLoaded = await ctx.loadPost(Math.max(1, targetPostNumber), {
- priority: POST_REQUEST_PRIORITY.target,
- onSource: syncTargetLoadingSource,
- });
- if (targetPostNumber > 1 && !targetLoaded) {
- targetLoaded = await ctx.loadPost(targetPostNumber, {
- onSource: syncTargetLoadingSource,
- scope: 'around',
- });
- }
- if (targetPostNumber > 1 && !targetLoaded && !cancelInitialTargetJump) {
- targetLoaded = await ctx.loadPost(targetPostNumber, {
- priority: POST_REQUEST_PRIORITY.target,
- forceRefresh: true,
- onSource: syncTargetLoadingSource,
- });
- }
- if (refreshTargetBoosts && targetPostNumber > 0 && !cancelInitialTargetJump) {
- setReaderOpenPhase('load-target-boosts');
- targetLoaded = await ctx.loadPost(targetPostNumber, {
- ensureAround: true,
- priority: POST_REQUEST_PRIORITY.target,
- forceRefresh: true,
- onSource: syncTargetLoadingSource,
- scope: 'around',
- }) || targetLoaded;
- if (!sessionAcceptsWork()) return;
- const targetPostNode = ctx.streamNodeMap.get(targetPostNumber);
- if (targetPostNode) {
- syncRenderedBoost(targetPostNode, targetPostNode._ldpPostData || {}, ctx);
- }
- }
- if (targetPostNumber > 1 && !targetLoaded && !cancelInitialTargetJump) {
- const fallbackPost = nearestAvailablePost(targetPostNumber);
- const fallbackPostNumber = +(fallbackPost?.post_number || 0);
- if (fallbackPostNumber) {
- unavailableTargetPostNumber = targetPostNumber;
- attachPostBatch([fallbackPost], ctx);
- targetPostNumber = fallbackPostNumber;
- targetLoaded = !!getStreamPostNode(ctx, targetPostNumber, { mount: true });
- overlay.dataset.targetPostNumber = String(targetPostNumber);
- readerShell.targetPostNumber = targetPostNumber;
- ctx.initialTargetHydrationBoundary = targetPostNumber;
- if (restoreViewport && +restoreViewport.postNumber === unavailableTargetPostNumber) {
- restoreViewport = { ...restoreViewport, postNumber: targetPostNumber, postOffset: 0 };
- }
- }
- if (!targetLoaded) {
- throw new Error(`目标楼层 #${targetPostNumber} 加载失败,请重试`);
- }
- }
- if (targetPostNumber > 1 && targetLoaded && !cancelInitialTargetJump && !refreshTargetBoosts) {
- setReaderOpenPhase('load-target-neighborhood');
- await ctx.loadPost(targetPostNumber, {
- ensureAround: true,
- priority: POST_REQUEST_PRIORITY.visible,
- onSource: syncTargetLoadingSource,
- scope: 'around',
- });
- if (!sessionAcceptsWork()) return;
- }
- const targetPostNode = ctx.streamNodeMap.get(targetPostNumber);
- aggregateTargetPostNumber = targetPostNumber > 1 &&
- +(targetPostNode?._ldpPostData?.reply_to_post_number || 0) > 0
- ? targetPostNumber : 0;
- if (targetPostNumber <= 1 && !targetLoaded) {
- await pump({ maxBatches: 1, persistFetchedPosts: true });
- }
- // The batch path normally mounts on the next animation frame. Flush the
- // first window before removing the mask so opening never exposes a blank frame.
- setReaderOpenPhase('mount-initial-stream');
- syncLoadingState('render');
- if (targetPostNumber > 1 && targetLoaded && !cancelInitialTargetJump) {
- ctx.releaseInitialPrecedingContent();
- hydrateStreamNavigationNeighborhood(ctx, targetPostNumber);
- }
- flushStreamWindow(ctx);
- let initialQuoteVisualTarget = null;
- const resolveInitialQuoteVisualTarget = initialQuoteHighlight && targetPostNumber
- ? () => {
- initialQuoteVisualTarget = applyReaderQuoteHighlight(
- targetPostNumber,
- ctx,
- initialQuoteHighlight.text,
- {
- source: initialQuoteHighlight.source,
- active: initialQuoteHighlight.active,
- },
- );
- return initialQuoteVisualTarget;
- }
- : null;
- const initialStartPostNumber = Math.max(1, +targetPostNumber || 1);
- if (targetPostNumber > 1) {
- if (targetLoaded && !cancelInitialTargetJump && !aggregateTargetPostNumber) {
- const alignInitialTargetBottom = !initialQuoteHighlight && +targetPostNumber === +(ctx.totalPosts || 0);
- const initialTargetScrollOptions = {
- ...PINNED_STREAM_SCROLL_OPTIONS,
- resolveVisualTarget: resolveInitialQuoteVisualTarget,
- alignNearest: !!initialQuoteHighlight,
- alignBottom: alignInitialTargetBottom,
- alignTop: !alignInitialTargetBottom,
- };
- if (!scrollToPost(targetPostNumber, ctx, initialTargetScrollOptions)) {
- throw new Error(`目标楼层 #${targetPostNumber} 定位失败,请重试`);
- }
- captureStreamViewportAnchor(ctx);
- }
- overlay.classList.remove('ldp-initial-target-layout');
- ctx.initialTargetPositionPending = false;
- } else {
- ctx.initialTargetPositionPending = false;
- if (!scrollToPost(initialStartPostNumber, ctx, {
- ...PINNED_STREAM_SCROLL_OPTIONS,
- resolveVisualTarget: resolveInitialQuoteVisualTarget,
- alignNearest: !!initialQuoteHighlight,
- alignTop: true,
- highlight: !!initialQuoteHighlight,
- })) {
- throw new Error(`目标楼层 #${initialStartPostNumber} 定位失败,请重试`);
- }
- }
- if (targetLoaded && (targetPostNumber > 1 || ctx.totalPosts > initialStartPostNumber)) {
- cancelInitialNeighborPrefetch = scheduleIdleWork(async () => {
- cancelInitialNeighborPrefetch = null;
- if (!sessionAcceptsWork()) return;
- const startNode = ctx.streamNodeMap.get(initialStartPostNumber);
- if (!startNode || !startNode.isConnected) return;
- await loadNextGap(startNode);
- }, 1400);
- }
- if (!sessionAcceptsWork()) return;
- if (restoreOnlyOp && !ctx.onlyOp && ctx.op) await onOnlyOpToggle();
- if (!sessionAcceptsWork()) return;
- if (restoreViewport && !aggregateTargetPostNumber) {
- const restoredPostNumber = Math.max(1, Number(restoreViewport.postNumber) || targetPostNumber || 1);
- const restoredPost = getStreamPostNode(ctx, restoredPostNumber, { mount: true });
- const restoredPostOffset = stableReaderViewportOffset(restoredPost, ctx, restoreViewport.postOffset);
- const restored = !!restoredPost && scrollToPost(restoredPostNumber, ctx, {
- ...PINNED_STREAM_SCROLL_OPTIONS,
- visualNode: restoredPost,
- alignOffset: restoredPostOffset,
- highlight: false,
- });
- if (!restored) {
- throw new Error(`历史位置 #${restoredPostNumber} 恢复失败,请重试`);
- }
- flushStreamWindow(ctx);
- captureStreamViewportAnchor(ctx);
- }
- if (!sessionAcceptsWork()) return;
- if (aggregateTargetPostNumber && !await ctx.timeline.jumpTo(aggregateTargetPostNumber, {
- targetPost: targetPostNode?._ldpPostData,
- })) {
- throw new Error(`目标楼层 #${aggregateTargetPostNumber} 完整讨论定位失败,请重试`);
- }
- if (!sessionAcceptsWork()) return;
- if (settleTargetJump && !aggregateTargetPostNumber) {
- await waitForReaderInitialLayout(overlay);
- if (!sessionAcceptsWork()) return;
- ctx.timeline.sync();
- const settledTargetPostNumber = restoreViewport
- ? Math.max(1, Number(restoreViewport.postNumber) || targetPostNumber || 1)
- : targetPostNumber;
- let settledTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected
- ? initialQuoteVisualTarget
- : getStreamPostNode(ctx, settledTargetPostNumber);
- if (settledTargetPostNumber > 0 && !readerTargetNodeIsVisible(settledTarget, ctx)) {
- const retryPost = getStreamPostNode(ctx, settledTargetPostNumber, { mount: true });
- const retryVisualTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected
- ? initialQuoteVisualTarget
- : retryPost;
- const retryRestoreOffset = restoreViewport
- ? stableReaderViewportOffset(retryVisualTarget, ctx, restoreViewport.postOffset)
- : NaN;
- const retryAlignBottom = !restoreViewport && !initialQuoteHighlight &&
- +settledTargetPostNumber === +(ctx.totalPosts || 0);
- if (retryPost && scrollToPost(settledTargetPostNumber, ctx, {
- ...PINNED_STREAM_SCROLL_OPTIONS,
- visualNode: retryVisualTarget,
- resolveVisualTarget: !restoreViewport ? resolveInitialQuoteVisualTarget : null,
- alignOffset: retryRestoreOffset,
- alignNearest: !restoreViewport && !!initialQuoteHighlight,
- alignBottom: retryAlignBottom,
- alignTop: !restoreViewport && !initialQuoteHighlight && !retryAlignBottom,
- highlight: false,
- })) {
- captureStreamViewportAnchor(ctx);
- await waitForReaderJumpLayout();
- if (!sessionAcceptsWork()) return;
- ctx.timeline.sync();
- settledTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected
- ? initialQuoteVisualTarget
- : getStreamPostNode(ctx, settledTargetPostNumber);
- }
- }
- if (settledTargetPostNumber > 0 && !readerTargetNodeIsVisible(settledTarget, ctx)) {
- throw new Error(`目标楼层 #${settledTargetPostNumber} 布局稳定校验失败,请重试`);
- }
- }
- setReaderOpenPhase('activate-reader');
- readerSession.state = 'active';
- if (previousQueueTopicId > 0 && previousQueueTopicId !== Number(topicId)) {
- autoDequeueReaderQueueEntry(previousQueueTopicId);
- }
- readerRefreshBtn.disabled = false;
- if (initialQuoteSourceTarget) await returnToReaderQuoteSource(initialQuoteSourceTarget, ctx);
- ctx.timeline.update();
- removeOwnedNode(maskEl);
- if (restoreState?.replyWindow && sessionAcceptsWork()) {
- try {
- ctx.replyThreadWindow?._ldpClose?.(false);
- await openAggregateReplyTarget(restoreState.replyWindow.rootPostNumber, ctx, {
- returnViewport: restoreViewport,
- restorePoint: restoreState.replyWindow.point,
- });
- } catch (error) {
- console.warn('[LDP] reader discussion restore failed', error);
- }
- }
- if (unavailableTargetPostNumber) {
- showSelectionToast(`楼层 #${unavailableTargetPostNumber} 已不可用,已跳到相邻楼层 #${targetPostNumber}`);
- }
- if (sessionAcceptsWork()) rememberTopic(topic, targetPostNumber || 1, queueEntry?.seenPostNumbers);
- } catch (err) {
- if (disposed || openRequestId !== READER_OPEN_REQUEST_SEQUENCE ||
- !readerSession.isCurrent('opening') || !overlay.isConnected) return;
- const failedPhase = readerOpenPhase;
- const canAutoRetry = readerOpenFailureCanAutoRetry(err, failedPhase);
- if (canAutoRetry) {
- const nextAutoRetryAttempt = autoRetryAttempt + 1;
- const retryDelay = readerOpenAutoRetryDelay(nextAutoRetryAttempt, err);
- console.warn('[LDP] reader open interrupted; retrying automatically', {
- phase: failedPhase,
- topicId: String(topicId || ''),
- targetPostNumber,
- sessionId: readerSessionId,
- attempt: nextAutoRetryAttempt,
- retryDelay,
- });
- readerSession.state = 'retrying';
- overlay.classList.remove('ldp-initial-target-layout');
- ctx.initialTargetPositionPending = false;
- readerRefreshBtn.disabled = true;
- stopReaderRuntime({ preserveShell: true });
- if (loadingStatus) loadingStatus.textContent = '载入暂时中断,正在自动重试';
- if (loadingDetail) {
- loadingDetail.textContent = `第 ${nextAutoRetryAttempt} 次恢复将在 ${Math.max(1, Math.ceil(retryDelay / 1000))} 秒内开始…`;
- }
- if (loadingStage) {
- setLabel(loadingStage, `载入暂时中断,第 ${nextAutoRetryAttempt} 次自动恢复即将开始`);
- }
- clearFailureRetryTimer();
- const retryCloseButton = readerElement('.ldp-close');
- const cancelAutoRetryWait = () => {
- clearFailureRetryTimer();
- retryCloseButton?.removeEventListener('click', closeWhileAutoRetry);
- if (overlay._ldpCancelAutoRetryWait === cancelAutoRetryWait)
- delete overlay._ldpCancelAutoRetryWait;
- };
- const closeWhileAutoRetry = () => {
- cancelAutoRetryWait();
- close();
- };
- overlay._ldpCancelAutoRetryWait = cancelAutoRetryWait;
- retryCloseButton?.addEventListener('click', closeWhileAutoRetry);
- failureRetryTimer = setTimeout(() => {
- cancelAutoRetryWait();
- if (disposed || !readerSession.isCurrent('retrying') || !overlay.isConnected) return;
- Promise.resolve(openModal(topicId, {
- targetPostNumber,
- topicNavMetadata: sourceTopicNavMetadata,
- restoreViewport,
- restoreState: restoreState || undefined,
- restoreOnlyOp,
- historyNavigation,
- settleTargetJump,
- quoteHighlight: initialQuoteHighlight,
- quoteSourceTarget: initialQuoteSourceTarget,
- refreshTargetBoosts,
- autoRetryAttempt: nextAutoRetryAttempt,
- })).catch((retryError) => {
- console.warn('[LDP] reader automatic retry setup failed', retryError);
- });
- }, retryDelay);
- return;
- }
- console.error('[LDP] reader open failed', {
- phase: failedPhase,
- topicId: String(topicId || ''),
- targetPostNumber,
- sessionId: readerSessionId,
- }, err);
- readerSession.state = 'failed';
- overlay.classList.remove('ldp-initial-target-layout');
- ctx.initialTargetPositionPending = false;
- const endpointRateLimitedFailure = Number(err?.status || 0) === 429 && err && err.rateLimitScope === 'endpoint';
- const rateLimitedFailure = Number(err?.status || 0) === 429 && !endpointRateLimitedFailure;
- stopReaderRuntime({ preserveShell: true });
- removeOwnedNode(maskEl);
- const errorEl = makeElement('div');
- errorEl.className = 'ldp-error';
- errorEl.setAttribute('role', 'alert');
- errorEl.innerHTML = `
-
-
`;
- errorEl.querySelector('.ldp-error-message').textContent = rateLimitedFailure
- ? '请求过于频繁,阅读器已暂停加载。倒计时结束后将自动打开过盾浮窗,验证通过后浮窗会自动关闭。'
- : endpointRateLimitedFailure
- ? '当前请求地址暂时返回 429,已只暂停该地址;其他阅读请求不受影响,请稍后重试。'
- : `加载失败(${failedPhase}):${err?.message ? err.message : '未知错误'}`;
- body.replaceChildren(errorEl);
- const retryButton = errorEl.querySelector('.ldp-error-retry');
- const errorChallenge = errorEl.querySelector('.ldp-error-challenge');
- let retryOpening = false;
- let challengeAutoOpened = false;
- const syncRetryState = () => {
- clearFailureRetryTimer();
- if (!overlay.isConnected || readerSession.state !== 'failed') return;
- const remaining = rateLimitCooldownRemaining();
- retryButton.disabled = retryOpening || remaining > 0;
- retryButton.textContent = retryOpening
- ? '正在重新加载…'
- : remaining > 0
- ? `限流冷却中(${Math.ceil(remaining / 1000)} 秒)`
- : '重新加载';
- if (remaining > 0) {
- failureRetryTimer = setTimeout(syncRetryState, Math.min(1000, remaining + 20));
- } else if (rateLimitedFailure && !challengeAutoOpened && errorChallenge) {
- challengeAutoOpened = true;
- openCloudflareChallengePopup(errorChallenge.href);
- }
- };
- errorEl.addEventListener('click', (event) => {
- const target = event.target instanceof Element ? event.target : null;
- if (target?.closest('.ldp-error-close')) {
- onReaderCloseClick();
- return;
- }
- if (!target?.closest('.ldp-error-retry')) return;
- if (retryButton.disabled || retryOpening) return;
- retryOpening = true;
- syncRetryState();
- Promise.resolve(openModal(topicId, {
- targetPostNumber,
- topicNavMetadata: sourceTopicNavMetadata,
- restoreViewport,
- restoreState: restoreState || undefined,
- historyNavigation,
- settleTargetJump,
- refreshTargetBoosts,
- })).catch((retryError) => {
- console.error('[LDP] reader retry failed', retryError);
- retryOpening = false;
- syncRetryState();
- });
- });
- syncRetryState();
- }
- }
-
- /* ============ 14. 接管帖子路由 ============ */
- function shouldBypassReaderUrl(url) {
- let u;
- try { u = new URL(url, location.href); } catch { return false; }
- return u.searchParams.has(NATIVE_BYPASS_PARAM);
- }
-
- function consumeNativeBypassTabSignal() {
- try {
- const bypass = sessionStorage.getItem(NATIVE_BYPASS_TAB_KEY) === '1';
- if (bypass) sessionStorage.removeItem(NATIVE_BYPASS_TAB_KEY);
- return bypass;
- } catch {
- return false;
- }
- }
-
- function openNativeTopicTab(url) {
- let tab = null;
- try { tab = window.open('about:blank', '_blank'); } catch {}
- if (!tab) return false;
- try { tab.sessionStorage.setItem(NATIVE_BYPASS_TAB_KEY, '1'); } catch {}
- try { tab.opener = null; } catch {}
- try { tab.location.replace(url); } catch { tab.location.href = url; }
- return true;
- }
-
- let cloudflareChallengePopup = null;
- let cloudflareChallengePopupTimer = 0;
- let cloudflareChallengePopupState = null;
- let cloudflareChallengeLeaseHeartbeatAt = 0;
- const cloudflareChallengeInstanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
-
- function readCloudflareChallengeLease() {
- try {
- const lease = JSON.parse(localStorage.getItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY) || 'null');
- return lease && typeof lease === 'object' ? lease : null;
- } catch {
- return null;
- }
- }
-
- function writeCloudflareChallengeLease(state, ttl = LDP_CLOUDFLARE_CHALLENGE_LEASE_TTL) {
- const at = Date.now();
- if (state === 'active') cloudflareChallengeLeaseHeartbeatAt = at;
- const lease = {
- owner: cloudflareChallengeInstanceId,
- state,
- updatedAt: at,
- expiresAt: at + ttl,
- };
- try { localStorage.setItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY, JSON.stringify(lease)); } catch {}
- return lease;
- }
-
- function releaseCloudflareChallengeLease() {
- const lease = readCloudflareChallengeLease();
- if (!lease || lease.owner !== cloudflareChallengeInstanceId) return;
- try { localStorage.removeItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY); } catch {}
- }
-
- function onCloudflareChallengeLeaseChange(event) {
- if (event.key !== LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY || !event.newValue) return;
- let lease = null;
- try { lease = JSON.parse(event.newValue); } catch {}
- if (!lease || lease.owner === cloudflareChallengeInstanceId || lease.state !== 'passed') return;
- const resetRate = resetReaderRateAfterChallenge();
- if (resetRate) showSelectionToast('验证已在另一窗口通过,已重置请求流限速');
- }
-
- window.addEventListener('storage', onCloudflareChallengeLeaseChange);
-
- function stopCloudflareChallengePopupMonitor() {
- if (cloudflareChallengePopupTimer) clearInterval(cloudflareChallengePopupTimer);
- cloudflareChallengePopupTimer = 0;
- }
-
- function cloudflareChallengePageInfo(popup) {
- try {
- const popupDocument = popup?.document;
- if (!popupDocument) return { accessible: false, challenge: false, ready: false, status: 0 };
- const href = String(popup.location?.href || '');
- const title = String(popupDocument.title || '');
- const challenge = !!(
- popup._cf_chl_opt ||
- popupDocument.querySelector('script[src*="/cdn-cgi/challenge-platform/"],#challenge-running') ||
- /^(?:Just a moment|请稍候)/i.test(title)
- );
- const navigation = popup.performance?.getEntriesByType
- ? popup.performance.getEntriesByType('navigation')[0]
- : null;
- return {
- accessible: true,
- challenge,
- ready: popupDocument.readyState === 'complete' && href !== 'about:blank',
- status: Math.max(0, Number(navigation?.responseStatus) || 0),
- };
- } catch {
- return { accessible: false, challenge: false, ready: false, status: 0 };
- }
- }
-
- function cloudflareChallengePassed(popup) {
- try {
- const popupDocument = popup?.document;
- if (!popupDocument) return false;
- return !!popupDocument.querySelector('meta[name="discourse-base-uri"],#main-outlet');
- } catch {
- return false;
- }
- }
-
- function completeCloudflareChallengePopup(message) {
- const resetRate = resetReaderRateAfterChallenge();
- writeCloudflareChallengeLease('passed', 10 * 1000);
- try { cloudflareChallengePopup.close(); } catch {}
- cloudflareChallengePopup = null;
- cloudflareChallengePopupState = null;
- stopCloudflareChallengePopupMonitor();
- if (message) showSelectionToast(message);
- else if (resetRate) showSelectionToast('验证已通过,已重置请求流限速');
- }
-
- function failCloudflareChallengePopup(message) {
- try { cloudflareChallengePopup?.close(); } catch {}
- cloudflareChallengePopup = null;
- cloudflareChallengePopupState = null;
- stopCloudflareChallengePopupMonitor();
- releaseCloudflareChallengeLease();
- if (message) showSelectionToast(message);
- }
-
- function verifyCloudflareChallengePopupState(state, result = {}) {
- if (!state || state.verificationStarted || state.verificationFailed) return;
- state.verificationStarted = true;
- const status = Math.max(0, Number(result.status) || 0);
- if (status >= 200 && status < 300) {
- if (state.onFormSuccess) state.onFormSuccess();
- completeCloudflareChallengePopup('已收到后端 2xx,楼层已标记为已读');
- return;
- }
- if (!state.verify) {
- failCloudflareChallengePopup('验证请求未完成;楼层仍保持未读');
- return;
- }
- Promise.resolve(state.verify()).then((verified) => {
- if (cloudflareChallengePopupState !== state) return;
- if (verified === true) {
- completeCloudflareChallengePopup('验证通过且后端已返回 2xx,楼层已标记为已读');
- return;
- }
- failCloudflareChallengePopup('验证后接口仍未返回 2xx;楼层保持未读,本次不会重复触发');
- }).catch((error) => {
- console.warn('[LDP] verify read tracking challenge failed', error);
- if (cloudflareChallengePopupState !== state) return;
- failCloudflareChallengePopup('验证后接口仍未返回 2xx;楼层保持未读,本次不会重复触发');
- });
- }
-
- async function submitCloudflareChallengePost(popup, url, params, state) {
- const token = await discourseCsrfToken();
- if (!popup || popup.closed || cloudflareChallengePopup !== popup || cloudflareChallengePopupState !== state)
- return false;
- const target = 'ldp-cloudflare-challenge';
- try { popup.name = target; } catch {}
- const form = makeElement('form');
- form.method = 'POST';
- form.action = new URL(url, location.href).href;
- form.target = target;
- form.hidden = true;
- const appendField = (name, value) => {
- const input = makeElement('input');
- input.type = 'hidden';
- input.name = name;
- input.value = String(value);
- form.appendChild(input);
- };
- appendField('authenticity_token', token);
- Object.entries(params || {}).forEach(([name, value]) => {
- if (Array.isArray(value)) value.forEach((item) => appendField(`${name}[]`, item));
- else if (value != null) appendField(name, value);
- });
- document.body.appendChild(form);
- state.formSubmitted = true;
- state.submittedAt = Date.now();
- try {
- form.submit();
- } finally {
- form.remove();
- }
- return true;
- }
-
- function openCloudflareChallengePopup(url, options = {}) {
- if (cloudflareChallengePopupState?.formParams &&
- !cloudflareChallengePopupState.verificationFailed) {
- try { cloudflareChallengePopup.focus(); } catch {}
- return true;
- }
- if (cloudflareChallengePopup && !cloudflareChallengePopup.closed) {
- try { cloudflareChallengePopup.focus(); } catch {}
- return true;
- }
- const existingLease = readCloudflareChallengeLease();
- if (existingLease && existingLease.expiresAt > Date.now() && existingLease.owner !== cloudflareChallengeInstanceId) {
- if (existingLease.state === 'passed') {
- const resetRate = resetReaderRateAfterChallenge();
- if (resetRate) showSelectionToast('验证已通过,已重置请求流限速');
- } else {
- showSelectionToast('已有一个过盾窗口正在处理,请在该窗口完成验证');
- }
- return true;
- }
- writeCloudflareChallengeLease('active');
- const availableScreen = window.screen;
- const width = Math.min(760, Math.max(420, availableScreen.availWidth || 760));
- const height = Math.min(720, Math.max(520, availableScreen.availHeight || 720));
- const left = Math.max(0, Math.round((availableScreen.availLeft || 0) + ((availableScreen.availWidth || width) - width) / 2));
- const top = Math.max(0, Math.round((availableScreen.availTop || 0) + ((availableScreen.availHeight || height) - height) / 2));
- const features = `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
- let popup = null;
- try {
- popup = window.open(options.formParams ? 'about:blank' : url, 'ldp-cloudflare-challenge', features);
- } catch {}
- if (!popup) {
- releaseCloudflareChallengeLease();
- showSelectionToast('验证浮窗被浏览器拦截,请允许本站弹出式窗口');
- return false;
- }
- cloudflareChallengePopup = popup;
- const popupState = {
- owner: cloudflareChallengeInstanceId,
- formParams: options.formParams || null,
- formSubmitted: false,
- submittedAt: 0,
- sawChallenge: false,
- verificationStarted: false,
- verificationFailed: false,
- verify: isFunction(options.verify) ? options.verify : null,
- onFormSuccess: isFunction(options.onFormSuccess) ? options.onFormSuccess : null,
- };
- cloudflareChallengePopupState = popupState;
- try { popup.focus(); } catch {}
- if (popupState.formParams) {
- showSelectionToast('请在浮窗完成验证;后端返回 2xx 前楼层仍保持未读');
- void submitCloudflareChallengePost(popup, url, popupState.formParams, popupState).catch((error) => {
- console.warn('[LDP] open read tracking challenge failed', error);
- failCloudflareChallengePopup('无法提交验证请求,楼层仍保持未读');
- });
- }
- stopCloudflareChallengePopupMonitor();
- cloudflareChallengePopupTimer = setInterval(() => {
- if (!cloudflareChallengePopup || cloudflareChallengePopup.closed) {
- cloudflareChallengePopup = null;
- cloudflareChallengePopupState = null;
- stopCloudflareChallengePopupMonitor();
- releaseCloudflareChallengeLease();
- return;
- }
- if (Date.now() - cloudflareChallengeLeaseHeartbeatAt >= 5000)
- writeCloudflareChallengeLease('active');
- const state = cloudflareChallengePopupState;
- const pageInfo = cloudflareChallengePageInfo(cloudflareChallengePopup);
- if (pageInfo.challenge) {
- if (state) state.sawChallenge = true;
- return;
- }
- if (!state || state.verificationFailed || !pageInfo.ready) return;
- if (!state.formParams) {
- if (!cloudflareChallengePassed(cloudflareChallengePopup)) return;
- completeCloudflareChallengePopup();
- return;
- }
- if (!state.formSubmitted || Date.now() - state.submittedAt < 500 || state.verificationStarted) return;
- verifyCloudflareChallengePopupState(state, {
- status: pageInfo.status,
- challenged: state.sawChallenge,
- });
- }, 250);
- return true;
- }
-
- function stripBypassParamFromUrl() {
- try {
- const u = new URL(location.href);
- if (!u.searchParams.has(NATIVE_BYPASS_PARAM)) return;
- u.searchParams.delete(NATIVE_BYPASS_PARAM);
- history.replaceState(history.state, '', u.pathname + u.search + u.hash);
- } catch {}
- }
-
- function extractTopicRouteFromUrl(url) {
- let u;
- try { u = new URL(url, location.href); } catch { return null; }
- if (/^https?:$/i.test(u.protocol) && u.origin !== BASE) return null;
- const parts = u.pathname.split('/').filter(Boolean);
- if (parts[0] !== 't') return null;
- const segments = parts.slice(1);
- if (/^\d+$/.test(segments[0] || '')) {
- const topicId = segments[0];
- const postNumber = /^\d+$/.test(segments[1] || '') ? segments[1] : '';
- return { topicId, postNumber: postNumber ? +postNumber : 0 };
- }
- const topicId = segments[1];
- const postNumber = /^\d+$/.test(segments[2] || '') ? segments[2] : '';
- return /^\d+$/.test(topicId || '') ? { topicId, postNumber: postNumber ? +postNumber : 0 } : null;
- }
-
- function topicUrlReaderStartPostNumber(route) {
- if (!route) return 0;
- return PREFS.openTopicsAtFirstPost ? 1 : (+route.postNumber || 0);
- }
-
- function runWhenBodyReady(fn) {
- if (document.body) fn();
- else document.addEventListener('DOMContentLoaded', fn, { once: true });
- }
-
- function hideNativeReaderTrigger() {
- PAGE_ROOT.classList.remove('ldp-native-reader-trigger-visible');
- readerPortalQueryAll('.ldp-native-reader-trigger-item').forEach((item) => {
- item.hidden = true;
- });
- const button = readerPortalQuery('.ldp-native-reader-trigger');
- if (button && !button.closest('.ldp-native-reader-trigger-item')) button.hidden = true;
- }
-
- function nativeReaderTriggerAvatarHost() {
- return document.querySelector('.d-header-icons .current-user:has(img.avatar)');
- }
-
- function syncNativeReaderTrigger() {
- if (!document.body) return;
- const route = extractTopicRouteFromUrl(location.href);
- let target = route ? { ...route, source: 'route', title: '' } : null;
- if (!target) {
- const firstHistoryTopic = orderedTopicHistory()[0];
- const historyTopicId = Number(firstHistoryTopic?.topicId);
- if (historyTopicId)
- target = {
- topicId: historyTopicId,
- postNumber: Math.max(1, Number(firstHistoryTopic.postNumber) || 1),
- source: 'history',
- title: String(firstHistoryTopic.title || ''),
- };
- }
- const topicId = target && String(target.topicId);
- if (CURRENT_OVERLAY) {
- hideNativeReaderTrigger();
- return;
- }
- const avatarHost = nativeReaderTriggerAvatarHost();
- if (!avatarHost) {
- hideNativeReaderTrigger();
- return;
- }
- PAGE_ROOT.classList.add('ldp-native-reader-trigger-visible');
- let item = document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]');
- if (!item) {
- item = makeElement('div');
- item.className = 'ldp-native-reader-trigger-item';
- item.dataset.ldpPortal = '1';
- }
- if (item.parentElement !== avatarHost) avatarHost.appendChild(item);
- readerPortalQueryAll('.ldp-native-reader-trigger-item').forEach((candidate) => {
- if (candidate !== item) candidate.hidden = true;
- });
- let button = item.querySelector('.ldp-native-reader-trigger');
- if (!button) {
- button = makeElement('button');
- button.className = 'ldp-native-reader-trigger';
- button.type = 'button';
- button.innerHTML = icon('maximize2');
- button.addEventListener('click', async (event) => {
- consumeEvent(event);
- const initialTopicId = Number(button.dataset.topicId);
- const initialPostNumber = Number(button.dataset.postNumber) || 0;
- const initialSource = button.dataset.triggerSource;
- if (!initialTopicId) return;
- AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
- hideNativeReaderTrigger();
- if (PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active') &&
- !await closeChromeSidePanelForReader())
- return stopDirectTopicTakeover(syncNativeReaderTrigger, String(initialTopicId));
- const currentRoute = extractTopicRouteFromUrl(location.href);
- if (initialSource === 'route') {
- if (!currentRoute || Number(currentRoute.topicId) !== initialTopicId) {
- syncNativeReaderTrigger();
- return;
- }
- PAGE_ROOT.classList.add('ldp-route-takeover');
- } else if (currentRoute) {
- syncNativeReaderTrigger();
- return;
- }
- openModal(initialTopicId, {
- targetPostNumber: initialSource === 'route'
- ? topicUrlReaderStartPostNumber(currentRoute)
- : initialPostNumber,
- topicNavMetadata: readTopicNavMetadata(null, initialTopicId),
- });
- });
- item.appendChild(button);
- }
- item.style.removeProperty('left');
- item.style.removeProperty('top');
- delete item.dataset.ldpPlacement;
- item.hidden = false;
- button.hidden = false;
- button.dataset.topicId = topicId || '';
- button.dataset.triggerSource = target ? target.source : 'empty-history';
- const startPostNumber = target
- ? target.source === 'route' ? topicUrlReaderStartPostNumber(target) : target.postNumber
- : 0;
- button.dataset.postNumber = String(startPostNumber || '');
- const sidePanelActive = PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active');
- const label = !target
- ? '暂无浏览历史'
- : target.source === 'history'
- ? target.title ? `打开历史首项:${target.title}` : '打开历史首项'
- : sidePanelActive
- ? '切换到浮窗阅读器'
- : PREFS.openTopicsAtFirstPost
- ? '从 #1 打开浮窗阅读器'
- : target.postNumber ? `从 #${target.postNumber} 打开浮窗阅读器` : '打开浮窗阅读器';
- setLabel(button, label);
- }
-
- function installNativeReaderTriggerObserver() {
- if (!document.body || document.body._ldpNativeReaderTriggerObserver) return;
- const relevantSelector = '.d-header,.d-header-wrap,.d-header-icons,.current-user,' +
- '.ldp-native-reader-trigger-item[data-ldp-portal="1"]';
- const mutationNodeIsRelevant = (node) => {
- if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
- return node.matches(relevantSelector) || !!node.querySelector(relevantSelector);
- };
- let observedHeader = null;
- let syncFrame = 0;
- let observer = null;
- const bindObserverTargets = () => {
- const avatarHost = nativeReaderTriggerAvatarHost();
- const nextHeader = avatarHost?.closest('.d-header-wrap,.d-header,header') ||
- document.querySelector('.d-header-wrap,.d-header');
- if (nextHeader === observedHeader && nextHeader?.isConnected) return;
- observer.disconnect();
- observedHeader = nextHeader || null;
- if (!observedHeader) {
- // Header bootstrap is short-lived; narrow to the header as soon as it appears.
- observer.observe(document.body, { childList: true, subtree: true });
- return;
- }
- observer.observe(observedHeader, { childList: true, subtree: true });
- let ancestor = observedHeader.parentElement;
- while (ancestor) {
- observer.observe(ancestor, { childList: true });
- if (ancestor === document.body) break;
- ancestor = ancestor.parentElement;
- }
- };
- observer = new MutationObserver((mutations) => {
- const topologyChanged = !observedHeader?.isConnected || mutations.some((mutation) =>
- mutation.target !== observedHeader && !observedHeader.contains(mutation.target));
- const contentChanged = mutations.some((mutation) =>
- [...mutation.addedNodes, ...mutation.removedNodes].some(mutationNodeIsRelevant));
- if ((!topologyChanged && !contentChanged) || syncFrame) return;
- syncFrame = requestAnimationFrame(() => {
- syncFrame = 0;
- bindObserverTargets();
- const avatarHost = nativeReaderTriggerAvatarHost();
- const item = document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]');
- if (avatarHost && (!item || item.parentElement !== avatarHost)) syncNativeReaderTrigger();
- });
- });
- bindObserverTargets();
- document.body._ldpNativeReaderTriggerObserver = observer;
- }
-
- function stopDirectTopicTakeover(triggerAction, suppressedTopicId = AUTO_OPEN_SUPPRESSED_TOPIC_ID) {
- PAGE_ROOT.classList.remove('ldp-route-takeover');
- AUTO_OPEN_SUPPRESSED_TOPIC_ID = suppressedTopicId;
- triggerAction();
- return false;
- }
-
- async function takeoverDirectTopicRoute() {
- if (shouldBypassReaderUrl(location.href))
- return stopDirectTopicTakeover(hideNativeReaderTrigger);
- const route = extractTopicRouteFromUrl(location.href);
- if (!route) {
- LAST_READER_TOPIC_ROUTE_KEY = '';
- return stopDirectTopicTakeover(syncNativeReaderTrigger, '');
- }
- const topicId = String(route.topicId);
- const routeKey = `${topicId}:${Math.max(0, Number(route.postNumber) || 0)}`;
- const routeChanged = LAST_READER_TOPIC_ROUTE_KEY !== routeKey;
- LAST_READER_TOPIC_ROUTE_KEY = routeKey;
- const activeReaderWorkspace = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell?.readerWorkspace;
- if (AUTO_OPEN_SUPPRESSED_TOPIC_ID === topicId && activeReaderWorkspace?.isEmbedded())
- return stopDirectTopicTakeover(syncNativeReaderTrigger);
- if (PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active') &&
- !await closeChromeSidePanelForReader())
- return stopDirectTopicTakeover(syncNativeReaderTrigger, topicId);
- const currentRoute = extractTopicRouteFromUrl(location.href);
- if (!currentRoute || String(currentRoute.topicId) !== topicId)
- return stopDirectTopicTakeover(hideNativeReaderTrigger);
- if (AUTO_OPEN_SUPPRESSED_TOPIC_ID && AUTO_OPEN_SUPPRESSED_TOPIC_ID !== topicId)
- AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
- if (AUTO_OPEN_SUPPRESSED_TOPIC_ID === topicId)
- return stopDirectTopicTakeover(syncNativeReaderTrigger);
- hideNativeReaderTrigger();
- if (CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId === topicId) {
- const targetPostNumber = Math.max(0, Number(currentRoute.postNumber) || 0);
- if (!routeChanged || !targetPostNumber) return true;
- const activeContext = CURRENT_OVERLAY._ldpReaderShell?.activeContext;
- if (activeContext?.readerSession?.state === 'active' && activeContext.timeline) {
- await activeContext.timeline.jumpTo(targetPostNumber);
- return true;
- }
- openModal(currentRoute.topicId, {
- targetPostNumber,
- topicNavMetadata: readTopicNavMetadata(null, currentRoute.topicId),
- });
- return true;
- }
- PAGE_ROOT.classList.add('ldp-route-takeover');
- openModal(currentRoute.topicId, {
- targetPostNumber: CURRENT_OVERLAY && currentRoute.postNumber
- ? +currentRoute.postNumber
- : topicUrlReaderStartPostNumber(currentRoute),
- topicNavMetadata: readTopicNavMetadata(null, currentRoute.topicId),
- });
- return true;
- }
-
- function installDiscourseRouteChangeHandler() {
- if (DISCOURSE_ROUTE_HANDLER_INSTALLED) return true;
- const pluginApiModule = lookupDiscourseModule('discourse/lib/plugin-api');
- if (!pluginApiModule || !isFunction(pluginApiModule.withPluginApi)) return false;
- let lastRouteKey = currentHostRouteKey();
- try {
- pluginApiModule.withPluginApi((api) => {
- api.onPageChange(() => {
- setTimeout(() => {
- const routeKey = currentHostRouteKey();
- if (routeKey === lastRouteKey) return;
- lastRouteKey = routeKey;
- takeoverDirectTopicRoute().catch((error) => {
- console.warn('[LDP] route signal takeover failed', error);
- });
- }, 0);
- });
- });
- } catch {
- return false;
- }
- DISCOURSE_ROUTE_HANDLER_INSTALLED = true;
- return true;
- }
-
- function refreshVisibleReaderReactions() {
- const overlay = CURRENT_OVERLAY;
- const ctx = overlay?._ldpReaderShell?.activeContext;
- if (!overlay || !ctx || !ctx.readerSession?.isCurrent('opening', 'active')) return;
- overlay.querySelectorAll('.ldp-post').forEach((postNode) => {
- const wasExpanded = !!postNode.querySelector(
- ':scope > .ldp-reactions .ldp-reaction-picker:not([hidden])'
- );
- renderPostReactions(postNode, ctx);
- if (!wasExpanded) return;
- const root = postNode.querySelector(':scope > .ldp-reactions');
- const picker = root?.querySelector('.ldp-reaction-picker');
- const trigger = root?.querySelector('[data-reaction-picker]');
- if (picker) picker.hidden = false;
- if (trigger) setExpanded(trigger, true);
- });
- const rail = ctx.topicActionRail;
- if (!rail?._ldpPostData) return;
- const railLike = rail.querySelector('.ldp-like');
- if (railLike)
- syncReaderLikeButton(railLike, readerPostRailReactionAction(rail._ldpPostData, ctx));
- renderPostReactions(rail, ctx, { collectLike: false, railPicker: true });
- rail.querySelector(':scope > .ldp-reactions > .ldp-reaction-summary')?.remove();
- }
-
- function clearDiscourseEventResponseCache(tags) {
- void clearStoredResponseCache({ tags }).catch((error) => {
- console.warn('[LDP] Discourse event cache invalidation failed', error);
- });
- }
-
- function installDiscourseUpdateEventMonitor() {
- if (DISCOURSE_UPDATE_EVENT_MONITOR) return true;
- const appEvents = lookupDiscourse('service:app-events');
- const messageBus = lookupDiscourse('service:message-bus');
- if (!appEvents || !isFunction(appEvents.on) ||
- !messageBus || !isFunction(messageBus.subscribe)) return false;
- const owner = document;
- const bindings = [];
- const bindAppEvent = (name, handler) => {
- if (!appEvents || !isFunction(appEvents.on)) return;
- appEvents.on(name, owner, handler);
- bindings.push({ type: 'app', name, handler });
- };
- const clientSettingsHandler = (data) => {
- const name = String(data?.name || '');
- if (![
- 'discourse_reactions_enabled_reactions',
- 'discourse_reactions_reaction_for_like',
- ].includes(name)) return;
- const siteSettings = lookupDiscourse('service:site-settings');
- if (siteSettings) siteSettings[name] = data.value;
- void loadReactionEmojiRegistry({ refresh: true }).then(refreshVisibleReaderReactions);
- };
- try {
- messageBus.subscribe('/client_settings', clientSettingsHandler);
- bindings.push({
- type: 'message-bus',
- name: '/client_settings',
- handler: clientSettingsHandler,
- });
- } catch {
- return false;
- }
- bindAppEvent('notifications:changed', () => {
- expireNotificationFirstPageCaches();
- clearDiscourseEventResponseCache(['notifications']);
- });
- bindAppEvent('bookmarks:changed', () => {
- clearDiscourseEventResponseCache(['bookmarks']);
- CURRENT_OVERLAY?._ldpReaderShell?.refreshBookmarksPanel?.();
- });
- bindAppEvent('discourse-reactions:reaction-toggled', (payload) => {
- const post = payload?.post;
- const postId = Number(post?.id || 0);
- const topicId = Number(post?.topic_id || post?.topic?.id || 0);
- const tags = ['reactions-given'];
- if (postId) tags.push(`post:${postId}`);
- if (topicId) tags.push(`topic:${topicId}`);
- clearDiscourseEventResponseCache(tags);
- const ctx = CURRENT_OVERLAY?._ldpReaderShell?.activeContext;
- if (!postId || !ctx || topicId && topicId !== Number(ctx.topicId)) {
- if (topicId) {
- cancelTopicSnapshotWrites(topicId);
- TOPIC_DATA_CACHE.delete(String(topicId));
- HOST_TOPIC_DATA_CACHE.delete(String(topicId));
- }
- return;
- }
- ctx.loader?.persistFetchedPosts?.([post]);
- ingestLivePosts([post], ctx, { refreshContent: true });
- ctx.refreshBookmarksPanel?.({ post, topic: ctx.topicData });
- });
- bindAppEvent('post:created', (post) => {
- const postId = Number(post?.id || 0);
- const topicId = Number(post?.topic_id || post?.topic?.id || 0);
- const tags = [];
- if (postId) tags.push(`post:${postId}`);
- if (topicId) tags.push(`topic:${topicId}`);
- if (tags.length) clearDiscourseEventResponseCache(tags);
- const activeTopicId = Number(CURRENT_OVERLAY?._ldpReaderShell?.activeContext?.topicId || 0);
- if (topicId && topicId !== activeTopicId) {
- cancelTopicSnapshotWrites(topicId);
- TOPIC_DATA_CACHE.delete(String(topicId));
- HOST_TOPIC_DATA_CACHE.delete(String(topicId));
- }
- });
- bindAppEvent('composer:edited-post', () => {
- const topicId = Number(extractTopicRouteFromUrl(location.href)?.topicId || 0);
- if (!topicId) return;
- clearDiscourseEventResponseCache([`topic:${topicId}`]);
- const activeTopicId = Number(CURRENT_OVERLAY?._ldpReaderShell?.activeContext?.topicId || 0);
- if (topicId === activeTopicId) return;
- cancelTopicSnapshotWrites(topicId);
- TOPIC_DATA_CACHE.delete(String(topicId));
- HOST_TOPIC_DATA_CACHE.delete(String(topicId));
- });
- if (!bindings.length) return false;
- DISCOURSE_UPDATE_EVENT_MONITOR = { appEvents, messageBus, bindings };
- return true;
- }
-
- function captureHostTopicPointerAnchor(event, topicId) {
- if (event.type !== 'click' || event.detail <= 0) return null;
- const card = eventPathClosest(event, 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item');
- if (!card || card.closest('.ldp-overlay')) return null;
- const rect = card.getBoundingClientRect();
- if (rect.width <= 0 || rect.height <= 0) return null;
- return {
- card,
- topicId: String(topicId || ''),
- clientY: event.clientY,
- offsetY: Math.max(0, Math.min(rect.height, event.clientY - rect.top)),
- };
- }
-
- function restoreHostTopicPointerAnchor(anchor) {
- if (!anchor) return false;
- let card = anchor.card?.isConnected ? anchor.card : null;
- if (!card && anchor.topicId) {
- const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item';
- card = [...document.querySelectorAll(cardSelector)].find((candidate) => {
- if (candidate.closest('.ldp-overlay')) return false;
- const directId = candidate.dataset.topicId || candidate.getAttribute('data-topic-id');
- const link = candidate.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]');
- return String(directId || extractTopicRouteFromUrl(link?.href)?.topicId || '') === anchor.topicId;
- }) || null;
- }
- if (!card) return false;
- document.querySelectorAll('[data-ldp-reader-active-topic]').forEach((candidate) => {
- if (candidate !== card) candidate.removeAttribute('data-ldp-reader-active-topic');
- });
- card.dataset.ldpReaderActiveTopic = 'true';
- const rect = card.getBoundingClientRect();
- const delta = rect.top + Number(anchor.offsetY || 0) - Number(anchor.clientY || 0);
- if (Math.abs(delta) >= .5) window.scrollBy({ top: delta, behavior: 'auto' });
- return true;
- }
-
- function closeHostTopicSourceSurface(event) {
- const source = eventPathClosest(event,
- '.fk-d-menu,.menu-panel,.search-menu,.user-menu,.hamburger-panel,.sidebar-hamburger-dropdown,.chat-drawer');
- if (!source || source.closest('.ldp-overlay,.ldp-reader-portal-host,.sciapp-ldp-owned') ||
- source.dataset.ldpReaderTopLayer) return null;
- const surface = source.matches('.fk-d-menu,.menu-panel,.chat-drawer')
- ? source
- : source.closest('.fk-d-menu,.menu-panel,.chat-drawer') ||
- source.querySelector('.fk-d-menu,.menu-panel,.chat-drawer') || source;
- const waitUntilClosed = (closeAction) => {
- let closeResult;
- try {
- closeResult = closeAction();
- } catch {
- return null;
- }
- return Promise.resolve(closeResult).catch(() => {}).then(() => new Promise((resolve) => {
- const deadline = performance.now() + 180;
- const check = () => {
- if (!surface.isConnected || !nativeComposerVisible(surface) || performance.now() >= deadline) {
- resolve();
- return;
- }
- requestAnimationFrame(check);
- };
- requestAnimationFrame(check);
- }));
- };
- const identifier = surface.matches('.fk-d-menu') ? String(surface.dataset.identifier || '') : '';
- const nativeMenu = identifier && lookupDiscourse('service:menu');
- if (identifier && isFunction(nativeMenu?.close))
- return waitUntilClosed(() => nativeMenu.close(identifier));
- const chatClose = surface.matches('.chat-drawer')
- ? surface.querySelector('.chat-drawer-header__close-btn')
- : null;
- if (chatClose && isFunction(chatClose.click)) return waitUntilClosed(() => chatClose.click());
- const controlledIds = [surface.id, source.id].filter(Boolean);
- let trigger = [...document.querySelectorAll('[aria-expanded="true"]')].find((candidate) =>
- controlledIds.some((id) => [candidate.getAttribute('aria-controls'), candidate.getAttribute('aria-owns')]
- .some((value) => String(value || '').split(/\s+/).includes(id)))
- );
- if (!trigger) {
- const sourceKind = eventPathClosest(event,
- '.search-menu,.user-menu,.hamburger-panel,.sidebar-hamburger-dropdown');
- const triggerSelector = sourceKind?.classList.contains('search-menu')
- ? '.d-header-icons .search-dropdown'
- : sourceKind?.classList.contains('user-menu')
- ? '.d-header-icons .current-user'
- : sourceKind
- ? '.d-header-icons .hamburger-dropdown,.hamburger-dropdown'
- : '';
- if (triggerSelector) {
- const triggers = [...document.querySelectorAll(triggerSelector)];
- trigger = triggers.find((candidate) => candidate.getAttribute('aria-expanded') === 'true') ||
- triggers[0];
- }
- }
- if (!trigger || !isFunction(trigger.click)) return null;
- // 捕获阶段接管 topic 点击后,宿主收不到原始点击;复用其 menu service、关闭按钮或 toggle 保持状态一致。
- return waitUntilClosed(() => trigger.click());
- }
-
- function handleTopicLinkTrigger(e) {
- if (e.type === 'click' && e.button && e.button !== 0) return false;
- const queueButton = eventPathClosest(e, '.ldp-reader-queue-add[data-reader-queue-topic-id]');
- if (queueButton) {
- consumeEvent(e);
- const topicId = Number(queueButton.dataset.readerQueueTopicId);
- const existing = readerQueueEntry(topicId);
- if (existing) {
- if (removeReaderQueueEntry(topicId)) showSelectionToast('已从阅读队列移除');
- } else {
- const entry = ensureReaderQueueEntry(topicId, readerQueueMetadataFromLink(queueButton, topicId));
- if (entry) showSelectionToast(`已加入阅读队列:${entry.title}`);
- }
- return true;
- }
- let a = eventPathClosest(e, 'a[href]');
- if (!a && PAGE_ROOT.classList.contains('ldp-reader-workspace')) {
- const card = eventPathClosest(e, 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item');
- const interactive = eventPathClosest(e, 'button,input,select,textarea,[role="button"],[contenteditable="true"]');
- if (card && !card.closest('.ldp-overlay') && !interactive)
- a = card.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]');
- }
- if (!a) return false;
- const hostSearchMenu = a.closest('.search-menu');
- if (hostSearchMenu && !hostSearchMenu.closest('.ldp-overlay,.ldp-reader-portal-host,.sciapp-ldp-owned') &&
- !a.closest('.search-result-topic,.search-result-post'))
- return false;
- if (a.classList.contains('ldp-rate-limit-challenge') || a.classList.contains('ldp-error-challenge')) {
- consumeEvent(e);
- openCloudflareChallengePopup(a.href);
- return true;
- }
- const notificationItem = a.closest('.ldp-notification-item');
- const refreshNotificationTarget = notificationItem?.classList.contains('ldp-notification-message-item') === true;
- const notificationTopicId = refreshNotificationTarget
- ? Math.max(0, Number(notificationItem.dataset.notificationTopicId) || 0)
- : 0;
- const notificationPostNumber = refreshNotificationTarget
- ? Math.max(0, Number(notificationItem.dataset.notificationPostNumber) || 0)
- : 0;
- const notificationRoute = notificationTopicId
- ? { topicId: String(notificationTopicId), postNumber: notificationPostNumber }
- : null;
- const notificationId = markClickedNotificationRead(notificationItem);
- if (notificationId > 0)
- void apiSend(`${BASE}/notifications/mark-read`, 'PUT', { id: notificationId }, null, {
- surviveReaderSwitch: true,
- }).catch((error) => {
- console.warn('[LDP] mark notification read failed', error);
- });
- const activeReaderWorkspace = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell?.readerWorkspace;
- if (activeReaderWorkspace?.isEmbedded() && a.getRootNode() === READER_PORTAL_ROOT &&
- !a.classList.contains('ldp-history-link') &&
- !a.classList.contains('ldp-rate-limit-challenge') &&
- !a.classList.contains('ldp-error-challenge') &&
- openEmbeddedReaderLinkInHost(e, a, activeReaderWorkspace)) return true;
- if (a.classList.contains('ldp-open')) return false;
- if (shouldBypassReaderUrl(a.href)) return false;
- const route = notificationRoute || extractTopicRouteFromUrl(a.getAttribute('href') || a.href);
- if (!route) return false;
- const activeReaderShell = CURRENT_OVERLAY?._ldpReaderShell;
- const activeContext = activeReaderShell?.activeContext;
- if (notificationRoute && notificationPostNumber > 0 &&
- String(activeContext?.topicId || '') === String(notificationTopicId) &&
- activeContext?.readerSession?.state === 'active' && activeContext.timeline) {
- consumeEvent(e);
- activeReaderShell.closeNotifications?.();
- void activeContext.timeline.jumpTo(notificationPostNumber).then((jumped) => {
- if (!jumped) openModal(notificationTopicId, {
- targetPostNumber: notificationPostNumber,
- refreshTargetBoosts: true,
- });
- }).catch(() => openModal(notificationTopicId, {
- targetPostNumber: notificationPostNumber,
- refreshTargetBoosts: true,
- }));
- return true;
- }
- const readerQueueMetadata = readerQueueMetadataFromLink(a, route.topicId);
- readerQueueMetadata.urlPostNumber = Math.max(0, Number(route.postNumber) || 0);
- const hostTopicPointerAnchor = captureHostTopicPointerAnchor(e, route.topicId);
- if (hostTopicPointerAnchor && isFunction(a.blur)) a.blur();
- const hostSurfaceClosing = closeHostTopicSourceSurface(e);
- consumeEvent(e);
- AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
- hideNativeReaderTrigger();
- const openTopic = () => openModal(route.topicId, {
- targetPostNumber: a.dataset.ldpPreserveTargetPost === '1'
- ? +route.postNumber || 0
- : topicUrlReaderStartPostNumber(route),
- topicNavMetadata: readTopicNavMetadata(a),
- readerQueueMetadata,
- hostTopicPointerAnchor,
- refreshTargetBoosts: refreshNotificationTarget,
- });
- if (hostSurfaceClosing) hostSurfaceClosing.then(() => requestAnimationFrame(openTopic));
- else openTopic();
- return true;
- }
-
- function handleReaderHostRefreshShortcut(event) {
- if (event.key !== 'F5') return false;
- const readerShell = CURRENT_OVERLAY?._ldpReaderShell;
- const readerWorkspace = readerShell?.readerWorkspace;
- if (!readerWorkspace || !readerWorkspace.isEmbedded()) return false;
- event.preventDefault();
- event.stopImmediatePropagation();
- if (!refreshDiscourseHostRoute())
- showSelectionToast(`${SITE_ADAPTER.name} 原站刷新暂不可用,请稍后重试`);
- return true;
- }
-
- function handleReaderMouseHistoryButton(event) {
- if (event.button !== 3 && event.button !== 4) return false;
- const overlay = CURRENT_OVERLAY;
- if (!overlay || !overlay.isConnected) return false;
- event.preventDefault();
- event.stopImmediatePropagation();
- if (event.type !== 'mousedown') return true;
- const backward = event.button === 3;
- const button = overlay.querySelector(
- backward ? '.ldp-reader-history-back' : '.ldp-reader-history-forward'
- );
- if (button && !button.hidden && !button.disabled) button.click();
- else showSelectionToast(backward ? '没有更早的阅读记录' : '没有更新的阅读记录');
- return true;
- }
-
- function installReaderHostEventHandlers() {
- if (document._ldpReaderHostEventHandlersInstalled) return;
- document._ldpReaderHostEventHandlersInstalled = true;
- document.addEventListener('keydown', handleReaderHostRefreshShortcut, true);
- document.addEventListener('mousedown', handleReaderMouseHistoryButton, true);
- document.addEventListener('mouseup', handleReaderMouseHistoryButton, true);
- document.addEventListener('auxclick', handleReaderMouseHistoryButton, true);
- document.addEventListener('click', handleTopicLinkTrigger, true);
- }
-
- runWhenBodyReady(async () => {
- if (!await waitForDiscourseHost()) {
- console.warn(`[LDP] ${location.hostname} 未检测到 Discourse,阅读器未启动`);
- return;
- }
- applyReaderFontRenderingRuntimeProperties();
- updateDiscourseCapabilityDiagnostics();
- installReaderHostEventHandlers();
- installRequestFlowInstrumentation();
- void resolveSiteLogo();
- if (!installDiscourseUpdateEventMonitor())
- window.addEventListener('load', () => {
- if (!installDiscourseUpdateEventMonitor())
- console.warn('[LDP] Discourse update events unavailable');
- }, { once: true });
- if (!installDiscourseRouteChangeHandler())
- window.addEventListener('load', () => {
- if (!installDiscourseRouteChangeHandler())
- console.warn('[LDP] Discourse page change signal unavailable');
- }, { once: true });
- ensureReaderStyleMounted();
- installNativeReaderTriggerObserver();
- installNativeTopicQueueButtons();
- let directTopicTakenOver = false;
- try {
- directTopicTakenOver = await takeoverDirectTopicRoute();
- } catch (error) {
- console.warn('[LDP] direct topic takeover failed', error);
- }
- if (directTopicTakenOver) return;
- try {
- await restoreEmbeddedReaderAfterReload();
- } catch (error) {
- console.warn('[LDP] embedded reader reload restore failed', error);
- }
- syncNativeReaderTrigger();
- });
-})();
+ function requestFlowCallSite() {
+ if (!REQUEST_FLOW_DETAILS_ACTIVE) return '';
+ let stack = '';
+ try { stack = String(new Error().stack || ''); } catch {}
+ const internalFrame = /\b(?:requestFlowCallSite|beginRequestFlow|trackedFetch|trackedSend|waitForReaderRequestPermit|requestCachedJSON|requestJSON|fetchJSON|runTask|apiSend)\b/;
+ const frame = stack.split('\n').map((line) => line.trim()).find((line, index) =>
+ index > 0 && line && !internalFrame.test(line) && !/\b(?:native code|Promise\.then)\b/.test(line));
+ if (!frame) return '';
+ return frame.replace(/(?:https?|chrome-extension):\/\/[^\s)]+/g, (raw) => {
+ const locationSuffix = raw.match(/(:\d+:\d+)$/)?.[1] || '';
+ const source = locationSuffix ? raw.slice(0, -locationSuffix.length) : raw;
+ try {
+ const url = new URL(source);
+ return `${url.origin}${url.pathname}${locationSuffix}`;
+ } catch {
+ return source.split(/[?#]/, 1)[0] + locationSuffix;
+ }
+ }).replace(/^at\s+/, '').slice(0, 220);
+ }
+
+ function requestFlowEventIndex(startedAt, id = -Infinity) {
+ let low = 0;
+ let high = REQUEST_FLOW_EVENTS.length;
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2);
+ const current = REQUEST_FLOW_EVENTS[middle];
+ if (current.startedAt < startedAt ||
+ (current.startedAt === startedAt && current.id <= id)) low = middle + 1;
+ else high = middle;
+ }
+ return low;
+ }
+
+ function insertRequestFlowEvent(event) {
+ const last = REQUEST_FLOW_EVENTS[REQUEST_FLOW_EVENTS.length - 1];
+ if (!last || last.startedAt < event.startedAt ||
+ (last.startedAt === event.startedAt && last.id <= event.id)) {
+ REQUEST_FLOW_EVENTS.push(event);
+ return;
+ }
+ REQUEST_FLOW_EVENTS.splice(requestFlowEventIndex(event.startedAt, event.id), 0, event);
+ }
+
+ function requestFlowUrl(input) {
+ try {
+ const value = input && typeof input === 'object' && input.url ? input.url : input;
+ if (value == null || String(value) === '') return null;
+ return new URL(String(value), location.href);
+ } catch {
+ return null;
+ }
+ }
+
+ function readerReadRequestFingerprint(topicId, postNumbers) {
+ return `${topicId}:${[...postNumbers].sort((a, b) => a - b).join(',')}`;
+ }
+
+ function readerReadRequestRecentlySucceeded(fingerprint) {
+ try {
+ const cutoff = Date.now() - READER_READ_SUCCESS_TTL;
+ const entries = JSON.parse(localStorage.getItem(LDP_READER_READ_SUCCESS_KEY) || '[]');
+ return Array.isArray(entries) && entries.some((entry) =>
+ entry?.fingerprint === fingerprint && Number(entry.at) > cutoff);
+ } catch {
+ return false;
+ }
+ }
+
+ function rememberReaderReadRequestSuccess(fingerprint) {
+ const cutoff = Date.now() - READER_READ_SUCCESS_TTL;
+ let entries = [];
+ try { entries = JSON.parse(localStorage.getItem(LDP_READER_READ_SUCCESS_KEY) || '[]'); } catch {}
+ entries = (Array.isArray(entries) ? entries : [])
+ .filter((entry) => Number(entry?.at) > cutoff && entry?.fingerprint !== fingerprint)
+ .slice(-15);
+ entries.push({ fingerprint, at: Date.now() });
+ try { localStorage.setItem(LDP_READER_READ_SUCCESS_KEY, JSON.stringify(entries)); } catch {}
+ }
+
+ function requestFlowPath(url) {
+ if (!url) return '未知请求';
+ if (!['http:', 'https:'].includes(url.protocol)) return `${url.protocol.replace(':', '') || '本地'}资源`;
+ const prefix = url.origin === location.origin ? '' : url.host;
+ return `${prefix}${url.pathname || '/'}`.slice(0, 180);
+ }
+
+ function requestFlowType(url, initiatorType = '', method = 'GET') {
+ const path = String(url?.pathname || '').toLowerCase();
+ const host = String(url?.hostname || '').toLowerCase();
+ const initiator = String(initiatorType || '').toLowerCase();
+ if (/\/posts\/\d+\/replies(?:\.json)?$/.test(path)) return 'nested';
+ if (/\/user_avatar\/|\/letter_avatar\//.test(path) || /avatar/.test(host)) return 'avatar';
+ if (/\/(?:uploads|optimized)\//.test(path) || /\.(?:avif|gif|jpe?g|png|webp|svg|mp4|webm|mp3|ogg)(?:$|\/)/.test(path)) return 'media';
+ if (/\/bookmarks?(?:\/|\.|$)|remove_bookmarks/.test(path)) return 'bookmark';
+ if (/\/notifications?(?:\/|\.|$)/.test(path)) return 'notification';
+ if (/\/message-bus(?:\/|$)/.test(path)) return 'realtime';
+ if (/\/presence\//.test(path)) return 'presence';
+ if (/\/search(?:\/|\.|$)|\/filter(?:\/|\.|$)/.test(path)) return 'search';
+ if (/topic-timings|\/timings(?:\/|\.|$)/.test(path)) return 'read';
+ if (/\/u\/|\/directory_items|\/session\/current|\/category-experts\//.test(path)) return 'user';
+ if (/post_actions|user_actions|discourse-reactions|\/boosts?(?:\/|\.|$)|\/emojis\.json$/.test(path)) return 'reaction';
+ if (/\/t\/|\/posts(?:\/|\.|$)|\/posts\/by_number\//.test(path)) return 'topic';
+ if (!['GET', 'HEAD'].includes(method)) return 'reaction';
+ if (['img', 'image', 'video', 'audio'].includes(initiator)) return 'media';
+ if (['css', 'link', 'script', 'font'].includes(initiator)) return 'asset';
+ return 'other';
+ }
+
+ function pruneRequestFlowEvents(at = Date.now()) {
+ const cutoff = at - REQUEST_FLOW_RETENTION_MS;
+ let expired = 0;
+ while (expired < REQUEST_FLOW_EVENTS.length && REQUEST_FLOW_EVENTS[expired].startedAt < cutoff) expired++;
+ if (expired) REQUEST_FLOW_EVENTS.splice(0, expired);
+ if (REQUEST_FLOW_EVENTS.length > REQUEST_FLOW_MAX_ENTRIES) {
+ REQUEST_FLOW_EVENTS.splice(0, REQUEST_FLOW_EVENTS.length - REQUEST_FLOW_MAX_ENTRIES);
+ }
+ while (REQUEST_FLOW_RESOURCE_DROPS.length && REQUEST_FLOW_RESOURCE_DROPS[0].at < cutoff) {
+ REQUEST_FLOW_RESOURCE_DROPS.shift();
+ }
+ }
+
+ function createRequestFlowEvent(url, details) {
+ const { queuedAt, permittedAt, startedAt } = details;
+ return {
+ id: ++REQUEST_FLOW_SEQUENCE,
+ ...details,
+ endedAt: Number(details.endedAt) || 0,
+ duration: 0,
+ permitWait: Math.max(0, permittedAt - queuedAt),
+ dispatchDuration: Math.max(0, startedAt - permittedAt),
+ priority: Number.isFinite(details.priority) ? details.priority : null,
+ recoveryProbe: details.recoveryProbe === true,
+ attempt: Math.max(1, Math.floor(Number(details.attempt) || 1)),
+ waitReason: String(details.waitReason || ''),
+ callSite: details.callSite == null ? requestFlowCallSite() : String(details.callSite),
+ type: details.type || requestFlowType(url, details.transport, details.method),
+ path: String(details.path || requestFlowPath(url)),
+ sameOrigin: !!url && url.origin === BASE,
+ status: details.controlOnly ? 0 : null,
+ size: 0,
+ pending: details.controlOnly !== true,
+ controlOnly: details.controlOnly === true,
+ controlReason: String(details.controlReason || ''),
+ rateLimitCode: '',
+ retryAfter: '',
+ serverLimit: '',
+ serverRemaining: '',
+ serverReset: '',
+ error: '',
+ resourceTimed: false,
+ };
+ }
+
+ function beginRequestFlow(input, init = {}, transport = 'fetch') {
+ const url = requestFlowUrl(input);
+ const method = String(init.method || (input?.method) || 'GET').toUpperCase();
+ const meta = init?.[REQUEST_FLOW_FETCH_META] || {};
+ const startedAt = Number.isFinite(Number(meta.startedAt)) ? Number(meta.startedAt) : requestFlowNow();
+ const queuedAt = Number.isFinite(Number(meta.queuedAt))
+ ? Math.min(startedAt, Math.max(startedAt - REQUEST_FLOW_RETENTION_MS, Number(meta.queuedAt)))
+ : startedAt;
+ const permittedAt = Number.isFinite(Number(meta.permittedAt))
+ ? Math.min(startedAt, Math.max(queuedAt, Number(meta.permittedAt)))
+ : startedAt;
+ const event = createRequestFlowEvent(url, {
+ queuedAt,
+ permittedAt,
+ startedAt,
+ endedAt: meta.endedAt,
+ method,
+ transport,
+ source: meta.source === 'reader' ? 'reader' : 'host',
+ priority: meta.priority,
+ recoveryProbe: meta.recoveryProbe,
+ attempt: meta.attempt,
+ waitReason: meta.waitReason,
+ callSite: meta.callSite,
+ type: String(meta.type || requestFlowType(url, meta.classificationTransport || transport, method)),
+ controlOnly: meta.controlOnly,
+ controlReason: meta.controlReason,
+ path: meta.path,
+ });
+ insertRequestFlowEvent(event);
+ pruneRequestFlowEvents(event.startedAt);
+ if (SHARED_REQUEST_COORDINATOR && !event.controlOnly) SHARED_REQUEST_COORDINATOR.recordHostStart(event);
+ return event;
+ }
+
+ function recordRequestFlowControl(input, options = {}) {
+ const endedAt = requestFlowNow();
+ return beginRequestFlow(input, {
+ method: options.method,
+ [REQUEST_FLOW_FETCH_META]: {
+ ...options,
+ source: 'reader',
+ startedAt: endedAt,
+ permittedAt: endedAt,
+ endedAt,
+ controlOnly: true,
+ controlReason: options.controlReason || 'cancelled',
+ classificationTransport: 'fetch',
+ },
+ }, 'scheduler');
+ }
+
+ const REQUEST_FLOW_RESPONSE_HEADERS = {
+ rateLimitCode: ['Discourse-Rate-Limit-Error-Code', 'X-Discourse-Rate-Limit-Error-Code'],
+ retryAfter: ['Retry-After'],
+ serverLimit: ['RateLimit-Limit', 'X-RateLimit-Limit'],
+ serverRemaining: ['RateLimit-Remaining', 'X-RateLimit-Remaining'],
+ serverReset: ['RateLimit-Reset', 'X-RateLimit-Reset'],
+ };
+
+ function requestFlowHeader(headers, names, splitHeaders = null) {
+ if (typeof headers === 'string') {
+ const lines = splitHeaders || headers.split(/\r?\n/);
+ for (const name of names) {
+ const prefix = `${name.toLowerCase()}:`;
+ const line = lines.find((candidate) => candidate.toLowerCase().startsWith(prefix));
+ if (line) return line.slice(line.indexOf(':') + 1).trim();
+ }
+ return '';
+ }
+ if (!headers || !isFunction(headers.get)) return '';
+ for (const name of names) {
+ const value = headers.get(name);
+ if (value != null && value !== '') return String(value);
+ }
+ return '';
+ }
+
+ function requestFlowRateLimitNumber(value) {
+ const match = String(value == null ? '' : value).match(/-?\d+(?:\.\d+)?/);
+ if (!match) return NaN;
+ const number = Number(match[0]);
+ return Number.isFinite(number) ? number : NaN;
+ }
+
+ function requestFlowRateLimitResetDelay(value, at = Date.now()) {
+ const raw = String(value == null ? '' : value).trim();
+ const number = Number(raw);
+ if (!Number.isFinite(number)) {
+ const resetAt = Date.parse(raw);
+ return Number.isFinite(resetAt) ? Math.max(0, resetAt - at) : 0;
+ }
+ if (!(number > 0)) return 0;
+ if (number > 1e12) return Math.max(0, number - at);
+ if (number > at / 1000 - DAY / 1000) return Math.max(0, number * 1000 - at);
+ return Math.max(0, number * 1000);
+ }
+
+ function rateLimitWindowFromCode(code) {
+ const short = /10[_-]?(?:secs?|seconds?)/i.test(String(code || ''));
+ const long = /60[_-]?(?:secs?|seconds?)|minute/i.test(String(code || ''));
+ return {
+ short, long, known: short || long,
+ label: short && long ? '10s+60s' : short ? '10s' : long ? '60s' : 'unknown',
+ };
+ }
+
+ function requestFlowRateLimitPolicies(event) {
+ const rawLimit = String(event?.serverLimit || '');
+ if (!rawLimit) return [];
+ const remainingParts = String(event?.serverRemaining || '').split(',');
+ const resetParts = String(event?.serverReset || '').split(',');
+ const rateWindow = rateLimitWindowFromCode(event?.rateLimitCode);
+ const byWindow = new Map();
+ rawLimit.split(',').forEach((part, index) => {
+ const limit = requestFlowRateLimitNumber(part);
+ if (!(limit > 0)) return;
+ const explicitWindow = String(part).match(/(?:^|;)\s*w\s*=\s*"?(\d+)/i);
+ const windowSeconds = explicitWindow ? Number(explicitWindow[1]) : 0;
+ const resetDelay = requestFlowRateLimitResetDelay(resetParts[index] || resetParts[0]);
+ let window = windowSeconds > 0 && windowSeconds <= 15
+ ? '10s'
+ : windowSeconds > 15 && windowSeconds <= 90
+ ? '60s'
+ : '';
+ if (explicitWindow && !window) return;
+ if (!window) {
+ const { short: hitShort, long: hitLong } = rateWindow;
+ if (resetDelay > 90000 && !hitShort && !hitLong) return;
+ window = hitShort && !hitLong
+ ? '10s'
+ : hitLong && !hitShort
+ ? '60s'
+ : limit <= READER_REQUEST_DEFAULT_SHORT_CEILING * 2
+ ? '10s'
+ : '60s';
+ }
+ const policy = {
+ window,
+ limit,
+ remaining: requestFlowRateLimitNumber(remainingParts[index] || (index === 0 ? remainingParts[0] : '')),
+ resetDelay,
+ };
+ const previous = byWindow.get(window);
+ if (!previous || policy.limit < previous.limit) byWindow.set(window, policy);
+ });
+ return [...byWindow.values()];
+ }
+
+ function finishRequestFlow(event, details = {}) {
+ if (!event || !event.pending) return;
+ event.endedAt = Number(details.endedAt) || requestFlowNow();
+ if (!event.resourceTimed) {
+ event.duration = Number.isFinite(Number(details.duration))
+ ? Math.max(0, Number(details.duration))
+ : Math.max(0, event.endedAt - event.startedAt);
+ event.size = Math.max(0, Number(details.size) || 0);
+ }
+ event.status = Number.isFinite(Number(details.status)) ? Number(details.status) : 0;
+ event.pending = false;
+ event.rateLimitCode = String(details.rateLimitCode || '');
+ event.retryAfter = String(details.retryAfter || '');
+ event.serverLimit = String(details.serverLimit || '');
+ event.serverRemaining = String(details.serverRemaining || '');
+ event.serverReset = String(details.serverReset || '');
+ event.error = String(details.error || '');
+ if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.recordHostFinish(event);
+ const hostApiEvent = sharedRequestApiEvent(event);
+ const readerApiEvent = sharedRequestApiEvent(event, 'reader');
+ const successfulApiEvent = (hostApiEvent || readerApiEvent) && event.status >= 200 && event.status < 400;
+ let schedulerState = null;
+ if (SHARED_REQUEST_COORDINATOR && (successfulApiEvent || (hostApiEvent && event.status === 429))) {
+ try { schedulerState = ACTIVE_READER_REQUEST_SCHEDULER?.snapshot(); } catch {}
+ }
+ if (successfulApiEvent && SHARED_REQUEST_COORDINATOR && event.serverLimit) {
+ SHARED_REQUEST_COORDINATOR.noteServerLimits({
+ policies: requestFlowRateLimitPolicies(event),
+ rateTarget: schedulerState ? schedulerState.requestRateTarget / 100 : 0.8,
+ shortBudgetCap: schedulerState ? schedulerState.configuredShortWindowBudget : 40,
+ longBudgetCap: schedulerState ? schedulerState.configuredLongWindowBudget : 160,
+ });
+ }
+ if (successfulApiEvent && SHARED_REQUEST_COORDINATOR) {
+ SHARED_REQUEST_COORDINATOR.noteSuccess({
+ host: hostApiEvent,
+ recoveryProbe: readerApiEvent && event.recoveryProbe,
+ shortBudgetCap: schedulerState ? schedulerState.configuredShortWindowBudget : 40,
+ longBudgetCap: schedulerState ? schedulerState.configuredLongWindowBudget : 160,
+ });
+ }
+ if (hostApiEvent && event.status === 429 && SHARED_REQUEST_COORDINATOR) {
+ const retrySeconds = event.retryAfter.trim() ? Number(event.retryAfter) : NaN;
+ const retryDate = !Number.isFinite(retrySeconds) && event.retryAfter ? Date.parse(event.retryAfter) : 0;
+ const wait = Number.isFinite(retrySeconds)
+ ? retrySeconds * 1000
+ : retryDate
+ ? Math.max(0, retryDate - Date.now())
+ : POST_REQUEST_429_BACKOFF;
+ SHARED_REQUEST_COORDINATOR.noteRateLimit({
+ wait: Math.max(1000, wait),
+ window: rateLimitWindowFromCode(event.rateLimitCode).label,
+ shortBudget: schedulerState ? schedulerState.shortWindowBudget : 40,
+ longBudget: schedulerState ? schedulerState.longWindowBudget : 160,
+ });
+ }
+ }
+
+ function finishResponseRequestFlow(event, responseUrl, status, headers) {
+ const finalUrl = requestFlowUrl(responseUrl);
+ if (finalUrl) {
+ event.path = requestFlowPath(finalUrl);
+ event.sameOrigin = finalUrl.origin === BASE;
+ if (event.type === 'other') {
+ event.type = requestFlowType(finalUrl, event.transport, event.method);
+ }
+ }
+ const splitHeaders = typeof headers === 'string' ? headers.split(/\r?\n/) : null;
+ const contentLength = Number(requestFlowHeader(headers, ['Content-Length'], splitHeaders));
+ finishRequestFlow(event, {
+ status,
+ size: Number.isFinite(contentLength) ? contentLength : 0,
+ ...Object.fromEntries(Object.entries(REQUEST_FLOW_RESPONSE_HEADERS).map(
+ ([key, names]) => [key, requestFlowHeader(headers, names, splitHeaders)]
+ )),
+ });
+ return finalUrl;
+ }
+
+ function hostTopicResponseId(url) {
+ if (!url || url.origin !== BASE) return 0;
+ const match = url.pathname.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/|\.json|$)/);
+ return match ? Number(match[1]) : 0;
+ }
+
+ function storeHostTopicResponse(event, url, payload) {
+ if (!event || event.source !== 'host' || event.method !== 'GET') return;
+ const topicId = hostTopicResponseId(url);
+ const postStream = payload?.post_stream;
+ if (!topicId || Number(payload?.id) !== topicId || !postStream ||
+ !Array.isArray(postStream.stream) || !Array.isArray(postStream.posts)) return;
+ const key = String(topicId);
+ const readerEntry = TOPIC_DATA_CACHE.get(key);
+ if (readerEntry && readerEntry.scope === responseCacheAuthScope() &&
+ topicCacheEntryTimestamp(readerEntry) >= Number(event.startedAt || 0)) return;
+ const hostEntry = HOST_TOPIC_DATA_CACHE.get(key);
+ if (hostEntry && Number(hostEntry.startedAt || 0) > event.startedAt) return;
+ HOST_TOPIC_DATA_CACHE.delete(key);
+ HOST_TOPIC_DATA_CACHE.set(key, { topic: payload, at: Date.now(), startedAt: event.startedAt });
+ setCachedTopicData(topicId, payload, { fetchedAt: Date.now(), source: 'host' });
+ while (HOST_TOPIC_DATA_CACHE.size > HOST_TOPIC_CACHE_MAX_ENTRIES) {
+ HOST_TOPIC_DATA_CACHE.delete(HOST_TOPIC_DATA_CACHE.keys().next().value);
+ }
+ }
+
+ function captureHostTopicXhrResponse(event, xhr, url) {
+ if (!xhr || event.source !== 'host' || event.method !== 'GET' || xhr.status < 200 || xhr.status >= 300 ||
+ !hostTopicResponseId(url)) return;
+ try {
+ const contentType = String(xhr.getResponseHeader('Content-Type') || '').toLowerCase();
+ if (!contentType.includes('json')) return;
+ const payload = xhr.responseType === 'json' ? xhr.response : JSON.parse(xhr.responseText);
+ storeHostTopicResponse(event, url, payload);
+ } catch {}
+ }
+
+ function recordResourceRequestFlow(entry) {
+ const initiator = String(entry?.initiatorType || '').toLowerCase();
+ if (!entry) return;
+ const url = requestFlowUrl(entry.name);
+ if (!url || !['http:', 'https:'].includes(url.protocol)) return;
+ const startedAt = performance.timeOrigin + Number(entry.startTime || 0);
+ const duration = Math.max(0, Number(entry.duration) || 0);
+ const size = Math.max(0, Number(entry.transferSize || entry.encodedBodySize) || 0);
+ const path = requestFlowPath(url);
+ if (['fetch', 'xmlhttprequest'].includes(initiator)) {
+ let matchingRequest = null;
+ for (let index = requestFlowEventIndex(startedAt - 100); index < REQUEST_FLOW_EVENTS.length; index++) {
+ const event = REQUEST_FLOW_EVENTS[index];
+ const eventStartedAt = Number(event.startedAt);
+ if (eventStartedAt > startedAt + 100) break;
+ if (!Number.isFinite(eventStartedAt) || event.controlOnly || event.resourceTimed ||
+ event.transport !== initiator || event.path !== path) continue;
+ if (!matchingRequest || Math.abs(eventStartedAt - startedAt) < Math.abs(matchingRequest.startedAt - startedAt)) {
+ matchingRequest = event;
+ }
+ }
+ if (matchingRequest) {
+ Object.assign(matchingRequest, { resourceTimed: true, duration, size });
+ return;
+ }
+ }
+ const event = createRequestFlowEvent(url, {
+ queuedAt: startedAt,
+ permittedAt: startedAt,
+ startedAt,
+ method: 'GET',
+ transport: initiator || 'resource',
+ source: 'browser',
+ priority: null,
+ callSite: initiator ? `${initiator} 资源加载` : '浏览器资源加载',
+ type: requestFlowType(url, initiator, 'GET'),
+ path,
+ });
+ finishRequestFlow(event, {
+ endedAt: performance.timeOrigin + Number(entry.responseEnd || entry.startTime || 0),
+ duration,
+ status: Number(entry.responseStatus) || 0,
+ size,
+ });
+ event.resourceTimed = true;
+ insertRequestFlowEvent(event);
+ }
+
+ const SHARED_REQUEST_API_TRANSPORTS = Object.freeze({ host: ['fetch', 'xmlhttprequest'], reader: ['fetch'] });
+ function sharedRequestApiEvent(event, source = 'host') {
+ return !!event && event.source === source && event.sameOrigin === true &&
+ SHARED_REQUEST_API_TRANSPORTS[source]?.includes(event.transport) === true &&
+ !['avatar', 'media', 'asset', 'realtime'].includes(event.type);
+ }
+
+ function createSharedRequestCoordinator() {
+ const contextId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
+ const lockName = `${LDP_SHARED_REQUEST_CHANNEL}:lock`;
+ const cacheLockName = `${LDP_SHARED_REQUEST_CHANNEL}:cache-lock`;
+ const waiters = new Set();
+ const cacheListeners = new Set();
+ let sequence = 0;
+ let destroyed = false;
+ let channel = null;
+
+ const emptyState = () => ({
+ version: 2,
+ updatedAt: 0,
+ events: [],
+ intents: [],
+ active: [],
+ policies: [],
+ flights: [],
+ cacheEpoch: 0,
+ shortBudget: 0,
+ longBudget: 0,
+ shortServerBudget: 0,
+ longServerBudget: 0,
+ cooldownUntil: 0,
+ nextProbeAt: 0,
+ shortServerBlockUntil: 0,
+ longServerBlockUntil: 0,
+ rateLimited: false,
+ successfulSinceLimit: 0,
+ lastRateLimitWindow: '',
+ learnedUntil: 0,
+ });
+
+ const requestPolicy = (source = {}, policyContextId = contextId) => ({
+ contextId: String(policyContextId),
+ shortBudget: Math.max(1, Math.floor(Number(source.shortBudget) || READER_REQUEST_DEFAULT_SHORT_CEILING)),
+ longBudget: Math.max(1, Math.floor(Number(source.longBudget) || READER_REQUEST_DEFAULT_LONG_CEILING)),
+ minInterval: Math.max(0, Math.floor(Number(source.minInterval) || 0)),
+ maxConcurrent: Math.max(1, Math.floor(Number(source.maxConcurrent) || 1)),
+ });
+
+ const normalizeState = (raw, at = Date.now()) => {
+ const state = raw && typeof raw === 'object' ? raw : emptyState();
+ state.version = 2;
+ state.events = (Array.isArray(state.events) ? state.events : [])
+ .map((event) => ({ at: Number(event?.at) || 0 }))
+ .filter((event) => event.at > at - READER_REQUEST_LONG_WINDOW_MS && event.at <= at)
+ .sort((a, b) => a.at - b.at)
+ .slice(-600);
+ state.intents = (Array.isArray(state.intents) ? state.intents : [])
+ .filter((intent) => intent && Number(intent.expiresAt) > at)
+ .slice(-120);
+ state.active = (Array.isArray(state.active) ? state.active : [])
+ .filter((lease) => lease && Number(lease.expiresAt) > at)
+ .slice(-32);
+ state.policies = (Array.isArray(state.policies) ? state.policies : [])
+ .filter((policy) => policy?.contextId && Number(policy.expiresAt) > at)
+ .map((policy) => ({
+ ...requestPolicy(policy, policy.contextId),
+ expiresAt: Number(policy.expiresAt),
+ }))
+ .slice(-32);
+ state.flights = (Array.isArray(state.flights) ? state.flights : [])
+ .map((flight) => ({
+ ...flight,
+ heartbeatAt: Number(flight?.heartbeatAt) ||
+ Math.max(0, Number(flight?.expiresAt) - SHARED_CACHE_FLIGHT_TTL),
+ }))
+ .filter((flight) => flight?.token && Number(flight.expiresAt) > at &&
+ Number(flight.heartbeatAt) > at - SHARED_CACHE_FLIGHT_STALE_MS)
+ .sort((a, b) => Number(a.expiresAt) - Number(b.expiresAt))
+ .slice(-120);
+ state.cacheEpoch = Math.max(0, Math.floor(Number(state.cacheEpoch) || 0));
+ state.shortBudget = Math.max(0, Number(state.shortBudget) || 0);
+ state.longBudget = Math.max(0, Number(state.longBudget) || 0);
+ state.shortServerBudget = Math.max(0, Number(state.shortServerBudget) || 0);
+ state.longServerBudget = Math.max(0, Number(state.longServerBudget) || 0);
+ state.cooldownUntil = Math.max(0, Number(state.cooldownUntil) || 0);
+ state.nextProbeAt = Math.max(0, Number(state.nextProbeAt) || 0);
+ state.shortServerBlockUntil = Math.max(0, Number(state.shortServerBlockUntil) || 0);
+ state.longServerBlockUntil = Math.max(0, Number(state.longServerBlockUntil) || 0);
+ state.successfulSinceLimit = Math.max(0, Number(state.successfulSinceLimit) || 0);
+ state.lastRateLimitWindow = String(state.lastRateLimitWindow || '');
+ state.learnedUntil = Math.max(0, Number(state.learnedUntil) || 0);
+ if (state.learnedUntil <= at) {
+ state.shortBudget = 0;
+ state.longBudget = 0;
+ state.shortServerBudget = 0;
+ state.longServerBudget = 0;
+ state.rateLimited = false;
+ state.successfulSinceLimit = 0;
+ state.lastRateLimitWindow = '';
+ } else state.rateLimited = state.rateLimited === true;
+ return state;
+ };
+
+ const readState = (at = Date.now()) => {
+ try {
+ return normalizeState(JSON.parse(localStorage.getItem(LDP_SHARED_REQUEST_STATE_KEY) || 'null'), at);
+ } catch {
+ return emptyState();
+ }
+ };
+
+ const notifyWaiters = () => {
+ waiters.forEach((resolve) => resolve());
+ waiters.clear();
+ };
+
+ const transact = async (operation) => {
+ const execute = () => {
+ const at = Date.now();
+ const state = readState(at);
+ const result = operation(state, at);
+ state.updatedAt = at;
+ try { localStorage.setItem(LDP_SHARED_REQUEST_STATE_KEY, JSON.stringify(state)); } catch {}
+ try { if (channel) channel.postMessage({ type: 'updated', contextId }); } catch {}
+ notifyWaiters();
+ return result;
+ };
+ const locks = navigator.locks;
+ if (isFunction(locks?.request)) {
+ return locks.request(lockName, { mode: 'exclusive' }, execute);
+ }
+ return execute();
+ };
+
+ const withCacheLock = (operation) => {
+ const locks = navigator.locks;
+ if (isFunction(locks?.request)) {
+ return locks.request(cacheLockName, { mode: 'exclusive' }, operation);
+ }
+ return Promise.resolve().then(operation);
+ };
+
+ const windowState = (state, at, requestedShortBudget, requestedLongBudget) => {
+ const shortBudget = Math.max(1, Math.floor(Math.min(
+ requestedShortBudget,
+ state.shortBudget || requestedShortBudget,
+ state.shortServerBudget || requestedShortBudget
+ )));
+ const longBudget = Math.max(1, Math.floor(Math.min(
+ requestedLongBudget,
+ state.longBudget || requestedLongBudget,
+ state.longServerBudget || requestedLongBudget
+ )));
+ const shortEvents = state.events.filter((event) => event.at > at - READER_REQUEST_SHORT_WINDOW_MS);
+ const longEvents = state.events;
+ const delayFor = (events, budget, windowMs) => events.length < budget
+ ? 0
+ : Math.max(0, events[events.length - budget].at + windowMs - at + 1);
+ const shortDelay = delayFor(shortEvents, shortBudget, READER_REQUEST_SHORT_WINDOW_MS);
+ const longDelay = delayFor(longEvents, longBudget, READER_REQUEST_LONG_WINDOW_MS);
+ const shortServerDelay = Math.max(0, state.shortServerBlockUntil - at);
+ const longServerDelay = Math.max(0, state.longServerBlockUntil - at);
+ const effectiveShortDelay = Math.max(shortDelay, shortServerDelay);
+ const effectiveLongDelay = Math.max(longDelay, longServerDelay);
+ return {
+ shortCount: shortEvents.length,
+ longCount: longEvents.length,
+ shortBudget,
+ longBudget,
+ serverDelay: Math.max(shortServerDelay, longServerDelay),
+ delay: Math.max(effectiveShortDelay, effectiveLongDelay),
+ blockingWindow: effectiveShortDelay >= effectiveLongDelay && effectiveShortDelay
+ ? '10s'
+ : effectiveLongDelay
+ ? '60s'
+ : '',
+ };
+ };
+
+ const permitTiming = (state, at, policy) => {
+ const windows = windowState(state, at, policy.shortBudget, policy.longBudget);
+ const cooldownDelay = Math.max(0, state.cooldownUntil - at);
+ const latestStart = state.events.length ? Number(state.events[state.events.length - 1].at) || 0 : 0;
+ return { windows, cooldownDelay, intervalDelay: Math.max(0, latestStart + policy.minInterval - at) };
+ };
+
+ const rememberPolicy = (state, at, options = {}) => {
+ const policy = { ...requestPolicy(options), expiresAt: at + SHARED_REQUEST_POLICY_TTL };
+ const index = state.policies.findIndex((candidate) => candidate.contextId === contextId);
+ if (index >= 0) state.policies[index] = policy;
+ else state.policies.push(policy);
+ return policy;
+ };
+
+ const effectivePolicy = (state, ownPolicy) => {
+ const effective = {
+ shortBudget: ownPolicy.shortBudget,
+ longBudget: ownPolicy.longBudget,
+ minInterval: ownPolicy.minInterval,
+ maxConcurrent: Math.min(state.rateLimited ? 2 : Infinity, ownPolicy.maxConcurrent),
+ };
+ state.policies.forEach((policy) => {
+ if (policy.contextId === contextId) return;
+ effective.shortBudget = Math.min(effective.shortBudget, policy.shortBudget);
+ effective.longBudget = Math.min(effective.longBudget, policy.longBudget);
+ effective.minInterval = Math.max(effective.minInterval, policy.minInterval);
+ effective.maxConcurrent = Math.min(effective.maxConcurrent, policy.maxConcurrent);
+ });
+ return effective;
+ };
+
+ const intentPriority = (intent, at) => {
+ const ageBoost = Math.min(2, Math.floor(Math.max(0, at - intent.queuedAt) / 5000));
+ return Number(intent.priority) - ageBoost;
+ };
+
+ const waitForUpdate = (delay, signal) => new Promise((resolve, reject) => {
+ let settled = false;
+ const settle = (callback) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ waiters.delete(finish);
+ if (signal) signal.removeEventListener('abort', abort);
+ callback();
+ };
+ const finish = () => settle(resolve);
+ const abort = () => settle(() => reject(new DOMException('Aborted', 'AbortError')));
+ const timer = setTimeout(finish, Math.max(25, Math.min(1000, delay || 80)));
+ waiters.add(finish);
+ if (signal) signal.addEventListener('abort', abort, { once: true });
+ });
+
+ const cancelIntent = (intentId) => {
+ void transact((state) => {
+ state.intents = state.intents.filter((intent) => intent.id !== intentId);
+ });
+ };
+
+ const acquirePermit = async (options = {}) => {
+ const intentId = `${contextId}:intent:${++sequence}`;
+ const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible;
+ const signal = options.signal;
+ let granted = false;
+ let waitReason = '';
+ try {
+ while (!destroyed && !(signal?.aborted)) {
+ const result = await transact((state, at) => {
+ const ownPolicy = rememberPolicy(state, at, options);
+ const policy = effectivePolicy(state, ownPolicy);
+ const existing = state.intents.find((intent) => intent.id === intentId);
+ if (existing) existing.expiresAt = at + 15000;
+ else state.intents.push({
+ id: intentId,
+ contextId,
+ priority,
+ queuedAt: at,
+ expiresAt: at + 15000,
+ });
+ state.intents.sort((a, b) => intentPriority(a, at) - intentPriority(b, at) || a.queuedAt - b.queuedAt);
+ if (!state.intents.length || state.intents[0].id !== intentId) {
+ return { granted: false, wait: 80, reason: 'priority' };
+ }
+ if (state.active.length >= policy.maxConcurrent) {
+ return { granted: false, wait: 80, reason: 'concurrency' };
+ }
+ const { windows, cooldownDelay, intervalDelay } = permitTiming(state, at, policy);
+ const canProbe = state.rateLimited && !cooldownDelay && !windows.serverDelay && windows.delay > 0 &&
+ !intervalDelay && priority <= POST_REQUEST_PRIORITY.visible && at >= state.nextProbeAt;
+ const wait = Math.max(cooldownDelay, intervalDelay, canProbe ? 0 : windows.delay);
+ if (wait > 0) {
+ return {
+ granted: false,
+ wait,
+ reason: cooldownDelay ? 'cooldown' : intervalDelay ? 'interval' : windows.blockingWindow,
+ };
+ }
+ state.intents.shift();
+ if (canProbe) state.nextProbeAt = at + READER_REQUEST_RECOVERY_PROBE_INTERVAL;
+ state.events.push({ at });
+ const permitId = `${contextId}:permit:${++sequence}`;
+ state.active.push({ id: permitId, contextId, expiresAt: at + POST_REQUEST_TIMEOUT + 5000 });
+ return { granted: true, permitId, startedAt: at, recoveryProbe: canProbe };
+ });
+ if (result.granted) {
+ granted = true;
+ return { ...result, waitReason };
+ }
+ if (result.cancelled) {
+ waitReason = String(result.reason || waitReason || 'cancelled');
+ throw new DOMException(waitReason, 'AbortError');
+ }
+ waitReason = String(result.reason || waitReason);
+ await waitForUpdate(result.wait, signal);
+ }
+ throw new DOMException('Aborted', 'AbortError');
+ } finally {
+ if (!granted) cancelIntent(intentId);
+ }
+ };
+
+ const recordHostStart = (event) => {
+ if (!sharedRequestApiEvent(event) || destroyed) return;
+ const activeId = `${contextId}:host-active:${event.id}`;
+ event.sharedRequestActiveId = activeId;
+ event.sharedRequestStart = transact((state, at) => {
+ const startedAt = Math.max(at - 5000, Math.min(at, Number(event.startedAt) || at));
+ state.events.push({ at: startedAt });
+ state.active.push({ id: activeId, contextId, expiresAt: at + POST_REQUEST_TIMEOUT + 5000 });
+ });
+ };
+
+ const finishPermit = (permitId) => {
+ if (!permitId) return;
+ void transact((state) => {
+ state.active = state.active.filter((lease) => lease.id !== permitId);
+ });
+ };
+
+ const recordHostFinish = (event) => {
+ if (!event || !event.sharedRequestActiveId) return;
+ Promise.resolve(event.sharedRequestStart).catch(() => {}).then(() => {
+ finishPermit(event.sharedRequestActiveId);
+ });
+ };
+
+ const acquireCacheFlight = (token) => transact((state, at) => {
+ const normalizedToken = String(token || '');
+ if (!normalizedToken) return { producer: true, flightId: '' };
+ const existing = state.flights.find((flight) => flight.token === normalizedToken);
+ if (existing) {
+ return {
+ producer: false,
+ flightId: existing.id,
+ expiresAt: existing.expiresAt,
+ cacheEpoch: Number(existing.cacheEpoch) || 0,
+ };
+ }
+ const flightId = `${contextId}:flight:${++sequence}`;
+ state.flights.push({
+ id: flightId,
+ token: normalizedToken,
+ contextId,
+ cacheEpoch: state.cacheEpoch,
+ heartbeatAt: at,
+ expiresAt: at + SHARED_CACHE_FLIGHT_TTL,
+ });
+ return {
+ producer: true,
+ flightId,
+ expiresAt: at + SHARED_CACHE_FLIGHT_TTL,
+ cacheEpoch: state.cacheEpoch,
+ };
+ });
+
+ const releaseCacheFlight = (token, flightId) => {
+ if (!token || !flightId) return Promise.resolve();
+ return transact((state) => {
+ state.flights = state.flights.filter((flight) =>
+ flight.token !== String(token) || flight.id !== String(flightId));
+ });
+ };
+
+ const renewCacheFlight = (token, flightId) => {
+ if (!token || !flightId) return Promise.resolve(false);
+ return transact((state, at) => {
+ const flight = state.flights.find((candidate) =>
+ candidate.token === String(token) && candidate.id === String(flightId));
+ if (!flight) return false;
+ flight.heartbeatAt = at;
+ flight.expiresAt = at + SHARED_CACHE_FLIGHT_TTL;
+ return true;
+ });
+ };
+
+ const writeCacheFlight = (token, flightId, cacheEpoch, operation) => withCacheLock(async () => {
+ const state = readState();
+ const valid = state.cacheEpoch === Number(cacheEpoch || 0) && state.flights.some((flight) =>
+ flight.token === String(token) && flight.id === String(flightId));
+ if (!valid) return false;
+ await operation();
+ return true;
+ });
+
+ const invalidateCacheWrites = () => withCacheLock(() => transact((state) => {
+ state.cacheEpoch++;
+ return state.cacheEpoch;
+ }));
+
+ const waitForCacheFlight = async (token, signal, deadline = Date.now() + SHARED_CACHE_FLIGHT_TTL) => {
+ const normalizedToken = String(token || '');
+ while (!destroyed && Date.now() < deadline && !(signal?.aborted)) {
+ const state = readState();
+ const flight = state.flights.find((candidate) => candidate.token === normalizedToken);
+ if (!flight) return true;
+ await waitForUpdate(Math.min(1000, Math.max(25, flight.expiresAt - Date.now())), signal);
+ }
+ if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
+ return false;
+ };
+
+ const noteRateLimit = (options = {}) => {
+ void transact((state, at) => {
+ const windows = windowState(state, at, options.shortBudget, options.longBudget);
+ const limitWindow = String(options.window || 'unknown');
+ const hitShort = limitWindow === 'unknown' || limitWindow.includes('10s');
+ const hitLong = limitWindow === 'unknown' || limitWindow.includes('60s');
+ if (hitShort) {
+ const safe = Math.max(1, Math.ceil(windows.shortCount * 0.9));
+ state.shortBudget = state.shortBudget ? Math.min(state.shortBudget, safe) : safe;
+ }
+ if (hitLong) {
+ const safe = Math.max(1, Math.ceil(windows.longCount * 0.9));
+ state.longBudget = state.longBudget ? Math.min(state.longBudget, safe) : safe;
+ }
+ state.cooldownUntil = Math.max(state.cooldownUntil, at + Math.max(1000, Number(options.wait) || 0));
+ state.nextProbeAt = state.cooldownUntil;
+ state.rateLimited = true;
+ state.successfulSinceLimit = 0;
+ state.lastRateLimitWindow = limitWindow;
+ state.learnedUntil = at + READER_REQUEST_RATE_MEMORY_TTL;
+ });
+ };
+
+ const resetRateLimits = () => transact((state) => {
+ [
+ 'shortBudget',
+ 'longBudget',
+ 'shortServerBudget',
+ 'longServerBudget',
+ 'cooldownUntil',
+ 'nextProbeAt',
+ 'shortServerBlockUntil',
+ 'longServerBlockUntil',
+ 'successfulSinceLimit',
+ 'learnedUntil',
+ ].forEach((key) => { state[key] = 0; });
+ state.rateLimited = false;
+ state.lastRateLimitWindow = '';
+ return true;
+ });
+
+ const noteServerLimits = (options = {}) => {
+ const rateTarget = Math.min(0.95, Math.max(0.5, Number(options.rateTarget) || 0.8));
+ const shortCap = Math.max(1, Math.floor(Number(options.shortBudgetCap) || 40));
+ const longCap = Math.max(1, Math.floor(Number(options.longBudgetCap) || 160));
+ const policies = (Array.isArray(options.policies) ? options.policies : []).map((policy) => {
+ const window = policy && policy.window === '10s' ? '10s' : policy && policy.window === '60s' ? '60s' : '';
+ const limit = Math.max(0, Number(policy?.limit) || 0);
+ const cap = window === '10s' ? shortCap : longCap;
+ return {
+ window,
+ limit,
+ remaining: Number(policy?.remaining),
+ resetDelay: Math.max(0, Number(policy?.resetDelay) || 0),
+ cap,
+ desired: Math.max(1, Math.min(cap, Math.floor(limit * rateTarget))),
+ };
+ }).filter((policy) => policy.window && policy.limit > 0);
+ if (!policies.length) return;
+ const previewAt = Date.now();
+ const preview = readState(previewAt);
+ const needsUpdate = policies.some((policy) => {
+ const budgetKey = policy.window === '10s' ? 'shortServerBudget' : 'longServerBudget';
+ const blockKey = policy.window === '10s' ? 'shortServerBlockUntil' : 'longServerBlockUntil';
+ const current = Number(preview[budgetKey]) || 0;
+ const effective = current || policy.cap;
+ const budgetChanges = policy.desired < effective ||
+ (!preview.rateLimited && current > 0 && policy.desired > current);
+ const reserve = Math.max(1, Math.ceil(policy.limit * (1 - rateTarget)));
+ const shouldBlock = Number.isFinite(policy.remaining) && policy.remaining <= reserve && policy.resetDelay > 0;
+ const blockUntil = shouldBlock ? previewAt + policy.resetDelay : 0;
+ const blockChanges = shouldBlock
+ ? blockUntil > Number(preview[blockKey] || 0) + 500
+ : Number(preview[blockKey] || 0) > previewAt;
+ return budgetChanges || blockChanges;
+ });
+ if (!needsUpdate) return;
+ void transact((state, at) => {
+ let changed = false;
+ policies.forEach((policy) => {
+ const budgetKey = policy.window === '10s' ? 'shortServerBudget' : 'longServerBudget';
+ const blockKey = policy.window === '10s' ? 'shortServerBlockUntil' : 'longServerBlockUntil';
+ const windowMs = policy.window === '10s' ? READER_REQUEST_SHORT_WINDOW_MS : READER_REQUEST_LONG_WINDOW_MS;
+ const current = Number(state[budgetKey]) || 0;
+ if (!current && policy.desired < policy.cap) {
+ state[budgetKey] = policy.desired;
+ changed = true;
+ } else if (current && policy.desired < current) {
+ state[budgetKey] = policy.desired;
+ changed = true;
+ } else if (!state.rateLimited && current && policy.desired > current) {
+ const step = Math.max(1, Math.ceil((policy.desired - current) * 0.35));
+ const next = Math.min(policy.desired, current + step);
+ state[budgetKey] = next >= policy.cap && policy.desired >= policy.cap ? 0 : next;
+ changed = true;
+ }
+ const reserve = Math.max(1, Math.ceil(policy.limit * (1 - rateTarget)));
+ const shouldBlock = Number.isFinite(policy.remaining) && policy.remaining <= reserve && policy.resetDelay > 0;
+ if (shouldBlock) {
+ const blockUntil = at + Math.min(windowMs + 1000, policy.resetDelay);
+ if (blockUntil > Number(state[blockKey] || 0) + 500) {
+ state[blockKey] = blockUntil;
+ changed = true;
+ }
+ } else if (Number(state[blockKey] || 0) > at) {
+ state[blockKey] = 0;
+ changed = true;
+ }
+ });
+ if (changed) state.learnedUntil = Math.max(state.learnedUntil, at + READER_REQUEST_RATE_MEMORY_TTL);
+ });
+ };
+
+ const noteSuccess = (options = {}) => {
+ const preview = readState();
+ if (!preview.rateLimited && !preview.shortBudget && !preview.longBudget) return;
+ void transact((state, at) => {
+ if (at < state.cooldownUntil || (!state.shortBudget && !state.longBudget)) return;
+ const credit = options.recoveryProbe ? 2 : options.host ? 0.5 : 1;
+ state.successfulSinceLimit += credit;
+ const shouldGrow = state.rateLimited ||
+ state.successfulSinceLimit >= READER_REQUEST_RATE_PROBE_SUCCESSES;
+ if (!shouldGrow) return;
+ const shortCap = Math.max(1, Number(options.shortBudgetCap) || READER_REQUEST_DEFAULT_SHORT_CEILING);
+ const longCap = Math.max(1, Number(options.longBudgetCap) || READER_REQUEST_DEFAULT_LONG_CEILING);
+ const rateStep = options.recoveryProbe ? READER_REQUEST_PROBE_RATE_STEP : 1.04;
+ if (state.shortBudget) {
+ state.shortBudget = Math.min(
+ shortCap,
+ Math.max(state.shortBudget * rateStep, state.shortBudget + (options.recoveryProbe ? 2 : 1))
+ );
+ }
+ if (state.longBudget) {
+ state.longBudget = Math.min(
+ longCap,
+ Math.max(state.longBudget * rateStep, state.longBudget + (options.recoveryProbe ? 6 : 3))
+ );
+ }
+ if (state.successfulSinceLimit >= READER_REQUEST_CONCURRENCY_RECOVERY_SUCCESSES) {
+ state.rateLimited = false;
+ state.successfulSinceLimit = 0;
+ } else if (!state.rateLimited) {
+ state.successfulSinceLimit = 0;
+ }
+ const shortRecovered = !state.shortBudget || state.shortBudget >= shortCap;
+ const longRecovered = !state.longBudget || state.longBudget >= longCap;
+ if (!state.rateLimited && shortRecovered && longRecovered) {
+ state.shortBudget = 0;
+ state.longBudget = 0;
+ }
+ state.learnedUntil = at + READER_REQUEST_RATE_MEMORY_TTL;
+ });
+ };
+
+ const snapshot = (
+ requestedShortBudget,
+ requestedLongBudget,
+ requestedMinInterval = 0,
+ requestedMaxConcurrent = 1,
+ at = Date.now()
+ ) => {
+ const state = readState(at);
+ const policy = effectivePolicy(state, requestPolicy({
+ shortBudget: requestedShortBudget,
+ longBudget: requestedLongBudget,
+ minInterval: requestedMinInterval,
+ maxConcurrent: requestedMaxConcurrent,
+ }));
+ const { windows, cooldownDelay: cooldownRemaining, intervalDelay } = permitTiming(state, at, policy);
+ const nextPermitDelay = Math.max(cooldownRemaining, intervalDelay, windows.delay);
+ const liveContexts = new Set([
+ ...state.policies.map((entry) => entry.contextId),
+ ...state.intents.map((entry) => entry.contextId),
+ ...state.active.map((entry) => entry.contextId),
+ ].filter(Boolean));
+ return {
+ ...windows,
+ queued: state.intents.length,
+ active: state.active.length,
+ instances: Math.max(1, liveContexts.size),
+ maxConcurrent: policy.maxConcurrent,
+ minInterval: policy.minInterval,
+ coordinationMode: isFunction(navigator.locks?.request)
+ ? 'atomic'
+ : 'best-effort',
+ coolingDown: cooldownRemaining > 0,
+ cooldownRemaining,
+ nextPermitDelay,
+ blockingReason: cooldownRemaining ? 'cooldown' : intervalDelay ? 'interval' : windows.blockingWindow,
+ rateLimited: state.rateLimited,
+ lastRateLimitWindow: state.lastRateLimitWindow,
+ };
+ };
+
+ const onStorage = (event) => {
+ if (event.key === LDP_SHARED_REQUEST_STATE_KEY) notifyWaiters();
+ };
+ const publishCache = (message) => {
+ try {
+ if (channel) channel.postMessage({ type: 'cache', contextId, message });
+ } catch {}
+ };
+ const onChannelMessage = (event) => {
+ const data = event?.data;
+ if (data && data.type === 'cache' && data.contextId !== contextId)
+ cacheListeners.forEach((listener) => {
+ try { listener(data.message || {}); } catch {}
+ });
+ notifyWaiters();
+ };
+ try {
+ channel = new globalThis.BroadcastChannel(LDP_SHARED_REQUEST_CHANNEL);
+ channel.addEventListener('message', onChannelMessage);
+ } catch {}
+ window.addEventListener('storage', onStorage);
+
+ return {
+ contextId,
+ acquirePermit,
+ recordHostStart,
+ recordHostFinish,
+ finishPermit,
+ acquireCacheFlight,
+ releaseCacheFlight,
+ renewCacheFlight,
+ writeCacheFlight,
+ invalidateCacheWrites,
+ waitForCacheFlight,
+ noteRateLimit,
+ resetRateLimits,
+ noteServerLimits,
+ noteSuccess,
+ snapshot,
+ publishCache,
+ subscribeCache(listener) {
+ if (!isFunction(listener)) return () => {};
+ cacheListeners.add(listener);
+ return () => cacheListeners.delete(listener);
+ },
+ destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ void transact((state) => {
+ state.intents = state.intents.filter((intent) => intent.contextId !== contextId);
+ state.active = state.active.filter((lease) => lease.contextId !== contextId);
+ state.policies = state.policies.filter((policy) => policy.contextId !== contextId);
+ state.flights = state.flights.filter((flight) => flight.contextId !== contextId);
+ });
+ window.removeEventListener('storage', onStorage);
+ if (channel) channel.close();
+ channel = null;
+ cacheListeners.clear();
+ notifyWaiters();
+ },
+ };
+ }
+
+ function installRequestFlowInstrumentation() {
+ if (!REQUEST_FLOW_AJAX_BOUND) {
+ const jqueryModule = lookupDiscourseModule('jquery');
+ const jquery = jqueryModule && (jqueryModule.default || jqueryModule);
+ if (isFunction(jquery)) {
+ const target = jquery(document);
+ target.on('ajaxSend.ldpRequestFlow', (_event, xhr, settings = {}) => {
+ const method = String(settings.type || settings.method || 'GET').toUpperCase();
+ REQUEST_FLOW_AJAX_STATE.set(xhr, {
+ event: beginRequestFlow(settings.url, {
+ method,
+ [REQUEST_FLOW_FETCH_META]: { callSite: '' },
+ }, 'xmlhttprequest'),
+ url: settings.url,
+ });
+ });
+ target.on('ajaxComplete.ldpRequestFlow', (_event, xhr) => {
+ const state = REQUEST_FLOW_AJAX_STATE.get(xhr);
+ if (!state) return;
+ REQUEST_FLOW_AJAX_STATE.delete(xhr);
+ let headers = '';
+ try { headers = xhr.getAllResponseHeaders(); } catch {}
+ const finalUrl = finishResponseRequestFlow(
+ state.event,
+ xhr.responseURL || state.url,
+ xhr.status,
+ headers
+ );
+ captureHostTopicXhrResponse(state.event, xhr, finalUrl);
+ });
+ REQUEST_FLOW_AJAX_BOUND = true;
+ }
+ }
+
+ if (REQUEST_FLOW_DETAILS_ACTIVE && !REQUEST_FLOW_RESOURCE_OBSERVER &&
+ isFunction(window.PerformanceObserver)) {
+ try {
+ REQUEST_FLOW_RESOURCE_OBSERVER = new window.PerformanceObserver((list, _observer, options) => {
+ list.getEntries().forEach(recordResourceRequestFlow);
+ const dropped = Math.max(0, Number(options?.droppedEntriesCount) || 0);
+ if (dropped) REQUEST_FLOW_RESOURCE_DROPS.push({ at: requestFlowNow(), count: dropped });
+ pruneRequestFlowEvents();
+ });
+ REQUEST_FLOW_RESOURCE_OBSERVER.observe({
+ type: 'resource',
+ buffered: !REQUEST_FLOW_RESOURCE_BUFFERED,
+ });
+ REQUEST_FLOW_RESOURCE_BUFFERED = true;
+ } catch {}
+ }
+ return REQUEST_FLOW_AJAX_BOUND;
+ }
+
+ function setRequestFlowDetailsActive(active) {
+ REQUEST_FLOW_DETAILS_ACTIVE = active === true;
+ if (REQUEST_FLOW_DETAILS_ACTIVE) {
+ installRequestFlowInstrumentation();
+ return;
+ }
+ if (REQUEST_FLOW_RESOURCE_OBSERVER) REQUEST_FLOW_RESOURCE_OBSERVER.disconnect();
+ REQUEST_FLOW_RESOURCE_OBSERVER = null;
+ }
+
+ function trackedReaderFetch(input, init = {}) {
+ const meta = init[REQUEST_FLOW_FETCH_META] || {};
+ const trackedInit = {
+ ...init,
+ [REQUEST_FLOW_FETCH_META]: { ...meta, source: 'reader' },
+ };
+ const event = beginRequestFlow(input, trackedInit, 'fetch');
+ let request;
+ try {
+ request = window.fetch(input, init);
+ } catch (error) {
+ finishRequestFlow(event, { error: error?.name || 'fetch_error' });
+ throw error;
+ }
+ return Promise.resolve(request).then((response) => {
+ finishResponseRequestFlow(
+ event,
+ response?.url,
+ response?.status,
+ response?.headers
+ );
+ return response;
+ }, (error) => {
+ finishRequestFlow(event, { error: error?.name || 'fetch_error' });
+ throw error;
+ });
+ }
+
+ SHARED_REQUEST_COORDINATOR = createSharedRequestCoordinator();
+ installRequestFlowInstrumentation();
+
+ function resetReaderRateAfterChallenge() {
+ if (ACTIVE_READER_REQUEST_SCHEDULER?.resetRateLimits) {
+ void ACTIVE_READER_REQUEST_SCHEDULER.resetRateLimits();
+ return true;
+ }
+ if (!SHARED_REQUEST_COORDINATOR || !SHARED_REQUEST_COORDINATOR.resetRateLimits) return false;
+ void SHARED_REQUEST_COORDINATOR.resetRateLimits();
+ return true;
+ }
+ const POST_REQUEST_PRIORITY = Object.freeze({
+ target: -2,
+ topic: -1,
+ scroll: 0,
+ visible: 1,
+ nested: 2,
+ userCard: 1,
+ auxiliary: 3,
+ background: 4,
+ });
+ const STREAM_PLACEHOLDER_MIN_HEIGHT = 64;
+ const STREAM_ESTIMATED_HEIGHT = 180;
+
+ const USER_CARD_LINK_SEL = [
+ '.ldp-user-link[data-user-card]',
+ '.ldp-user-avatar-card[data-user-card]',
+ '.ldp-topic-owner-link[data-user-card]',
+ 'html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.posters,.topic-poster) [data-user-card]',
+ ].join(',');
+ const USER_CARD_PREFETCH_DELAY = 250;
+ const USER_CARD_SHOW_DELAY = 500;
+ const HOST_TOPIC_CACHE_MAX_ENTRIES = 8;
+ const HOST_TOPIC_DATA_CACHE = new Map();
+ const TOPIC_DATA_CACHE = new Map();
+ const TOPIC_CACHE_SUBSCRIBERS = new Map();
+ const TOPIC_SNAPSHOT_WRITE_QUEUE = new Map();
+ const TOPIC_NAV_ICON_CACHE = new Map();
+ const USER_CARD_CACHE = new Map();
+ const USER_CARD_REQUESTS = new Map();
+ const USER_FOLLOW_LIST_CACHE = new Map();
+ const USER_FOLLOW_LIST_REQUESTS = new Map();
+ const NOTIFICATION_CACHE = new Map();
+ const NOTIFICATION_REQUESTS = new Map();
+ const NOTIFICATION_AVATAR_CACHE = new Map();
+ const AVATAR_OBJECT_URL_CACHE = new Map();
+ const AVATAR_RESOURCE_REQUESTS = new Map();
+ const AVATAR_HYDRATION_PENDING = new WeakSet();
+ const EMOJI_IMAGE_OBJECT_URL_CACHE = new Map();
+ const EMOJI_IMAGE_RESOURCE_REQUESTS = new Map();
+ const LIGHTBOX_IMAGE_OBJECT_URL_CACHE = new Map();
+ const LIGHTBOX_IMAGE_RESOURCE_REQUESTS = new Map();
+ const RESOURCE_CACHE_GROUPS = [
+ {
+ label: '头像', cacheName: LDP_AVATAR_CACHE_NAME, objectUrls: AVATAR_OBJECT_URL_CACHE,
+ requests: AVATAR_RESOURCE_REQUESTS, maxEntries: 512, maxBytes: 32 * 1024 * 1024,
+ },
+ {
+ label: '表情', cacheName: LDP_EMOJI_IMAGE_CACHE_NAME, objectUrls: EMOJI_IMAGE_OBJECT_URL_CACHE,
+ requests: EMOJI_IMAGE_RESOURCE_REQUESTS, maxEntries: 512, maxBytes: 16 * 1024 * 1024,
+ },
+ {
+ label: '原图', cacheName: LDP_LIGHTBOX_IMAGE_CACHE_NAME, objectUrls: LIGHTBOX_IMAGE_OBJECT_URL_CACHE,
+ requests: LIGHTBOX_IMAGE_RESOURCE_REQUESTS, maxEntries: 24, maxBytes: 96 * 1024 * 1024,
+ },
+ ];
+ const RESOURCE_CACHE_WRITES = new Set();
+ const RESOURCE_CACHE_PRUNE_REQUESTS = new Map();
+ let RESOURCE_CACHE_ABORT_CONTROLLER = new AbortController();
+ let RESOURCE_CACHE_CLEARING = false;
+ const RESPONSE_CACHE_MEMORY = new Map();
+ const RESPONSE_CACHE_REQUESTS = new Map();
+ const RESPONSE_CACHE_WRITES = new Map();
+ const RESPONSE_CACHE_VALUE_META = new WeakMap();
+ const RESPONSE_CACHE_MEMORY_MAX_ENTRIES = 96;
+ const RESPONSE_CACHE_MEMORY_MAX_BYTES = 24 * 1024 * 1024;
+ const RESOURCE_CACHE_PRUNE_BATCH_SIZE = 32;
+ const SEARCH_PINYIN_CACHE_MAX_ENTRIES = 512;
+ let responseCacheDbRequest = null;
+ let responseCachePersistenceRequested = false;
+ let responseCachePruneRequest = null;
+ let responseCachePrunedAt = 0;
+ const SEARCH_PINYIN_CACHE = new Map();
+ const PERSISTENT_CACHE_WRITE_KEYS = new Set();
+ let cancelPersistentCacheWrite = null;
+ let persistentCacheWriteDueAt = 0;
+
+ function trimResponseCacheMemory() {
+ let bytes = 0;
+ RESPONSE_CACHE_MEMORY.forEach((entry) => { bytes += Math.max(0, Number(entry?.bytes) || 0); });
+ while (RESPONSE_CACHE_MEMORY.size > RESPONSE_CACHE_MEMORY_MAX_ENTRIES ||
+ (bytes > RESPONSE_CACHE_MEMORY_MAX_BYTES && RESPONSE_CACHE_MEMORY.size > 1)) {
+ const oldestId = RESPONSE_CACHE_MEMORY.keys().next().value;
+ const oldest = RESPONSE_CACHE_MEMORY.get(oldestId);
+ bytes -= Math.max(0, Number(oldest?.bytes) || 0);
+ RESPONSE_CACHE_MEMORY.delete(oldestId);
+ }
+ }
+
+ function rememberResponseCacheEntry(id, entry) {
+ RESPONSE_CACHE_MEMORY.delete(id);
+ RESPONSE_CACHE_MEMORY.set(id, entry);
+ trimResponseCacheMemory();
+ }
+
+ function markResponseCacheStoredAt(value, storedAt) {
+ if (value && typeof value === 'object')
+ RESPONSE_CACHE_VALUE_META.set(value, Number(storedAt) || Date.now());
+ return value;
+ }
+
+ function responseCacheValueStoredAt(value) {
+ return Number(value && typeof value === 'object' && RESPONSE_CACHE_VALUE_META.get(value)) || 0;
+ }
+
+ function sharedCacheIdToken(value) {
+ const source = String(value || '');
+ let hash = 2166136261;
+ for (let index = 0; index < source.length; index++) {
+ hash ^= source.charCodeAt(index);
+ hash = Math.imul(hash, 16777619);
+ }
+ return (hash >>> 0).toString(36);
+ }
+
+ function openResponseCacheDatabase() {
+ if (responseCacheDbRequest) return responseCacheDbRequest;
+ if (typeof indexedDB === 'undefined') return Promise.resolve(null);
+ let pendingRequest;
+ pendingRequest = new Promise((resolve) => {
+ let settled = false;
+ const finish = (db) => {
+ if (settled) {
+ if (db) db.close();
+ return;
+ }
+ settled = true;
+ resolve(db);
+ };
+ let request;
+ try {
+ request = indexedDB.open(LDP_RESPONSE_CACHE_DB_NAME, 1);
+ } catch {
+ finish(null);
+ return;
+ }
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ const store = db.objectStoreNames.contains(LDP_RESPONSE_CACHE_STORE)
+ ? request.transaction.objectStore(LDP_RESPONSE_CACHE_STORE)
+ : db.createObjectStore(LDP_RESPONSE_CACHE_STORE, { keyPath: 'id' });
+ if (!store.indexNames.contains('kind')) store.createIndex('kind', 'kind', { unique: false });
+ if (!store.indexNames.contains('tags')) store.createIndex('tags', 'tags', { unique: false, multiEntry: true });
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ db.onversionchange = () => {
+ db.close();
+ if (responseCacheDbRequest === pendingRequest) responseCacheDbRequest = null;
+ };
+ finish(db);
+ };
+ request.onerror = () => finish(null);
+ request.onblocked = () => finish(null);
+ });
+ responseCacheDbRequest = pendingRequest;
+ pendingRequest.then((db) => {
+ if (!db && responseCacheDbRequest === pendingRequest) responseCacheDbRequest = null;
+ });
+ return pendingRequest;
+ }
+
+ function runResponseCacheTransaction(db, mode, operation) {
+ return new Promise((resolve) => {
+ try {
+ const transaction = db.transaction(LDP_RESPONSE_CACHE_STORE, mode);
+ transaction.oncomplete = () => resolve(true);
+ transaction.onerror = transaction.onabort = () => resolve(false);
+ operation(transaction.objectStore(LDP_RESPONSE_CACHE_STORE));
+ } catch {
+ resolve(false);
+ }
+ });
+ }
+
+ function publishResponseCacheMutation(action, details) {
+ if (SHARED_REQUEST_COORDINATOR)
+ SHARED_REQUEST_COORDINATOR.publishCache({ action, ...details });
+ }
+
+ function responseCacheUrl(url) {
+ try {
+ const parsed = new URL(String(url || ''), location.href);
+ ['track_visit', 'forceLoad', '_'].forEach((key) => parsed.searchParams.delete(key));
+ const postIds = parsed.searchParams.getAll('post_ids[]').sort((a, b) => String(a).localeCompare(String(b)));
+ if (postIds.length) {
+ parsed.searchParams.delete('post_ids[]');
+ postIds.forEach((id) => parsed.searchParams.append('post_ids[]', id));
+ }
+ parsed.searchParams.sort();
+ parsed.hash = '';
+ return parsed;
+ } catch {
+ return null;
+ }
+ }
+
+ function responseCacheAuthScope() {
+ const currentUserId = Number(ME_CURRENT_USER?.id) || 0;
+ const currentUsername = String(ME_USERNAME || '').trim().toLocaleLowerCase();
+ const accountIdentity = currentUserId
+ ? `id:${currentUserId}`
+ : currentUsername
+ ? `username:${currentUsername}`
+ : ME_USERNAME === ''
+ ? 'anonymous'
+ : '';
+ if (accountIdentity) return `account:${sharedCacheIdToken(accountIdentity)}`;
+ const token = String((document.querySelector('meta[name="csrf-token"]') || {}).content || '');
+ if (!token) return 'session:anonymous';
+ return `session:${sharedCacheIdToken(token)}`;
+ }
+
+ function responseCachePolicy(url, fetchOptions = {}, options = {}) {
+ const method = String(fetchOptions.method || 'GET').toUpperCase();
+ const parsed = responseCacheUrl(url);
+ if (method !== 'GET' || !parsed || parsed.origin !== BASE) return null;
+ const path = parsed.pathname;
+ const tags = new Set();
+ let kind = 'responses';
+ let freshFor = HOUR;
+ let retainFor = 30 * DAY;
+ let persist = true;
+
+ const topicMatch = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/|\.json|$)/);
+ const byNumberMatch = path.match(/^\/posts\/by_number\/(\d+)\/(\d+)\.json$/);
+ const postMatch = path.match(/^\/posts\/(\d+)(?:\/replies)?\.json$/);
+ const userMatch = path.match(/^\/u\/([^/]+)(?:\/[^/]+)?\.json$/);
+ const bookmarkListMatch = path.match(/^\/u\/[^/]+\/bookmarks\.json$/);
+ const givenReactionsMatch = /^\/discourse-reactions\/posts\/reactions(?:\.json)?$/.test(path);
+ const userActionTypes = String(parsed.searchParams.get('filter') || '').split(',');
+ const givenLikesMatch = path === '/user_actions.json' && userActionTypes.includes('1');
+ const notificationFeedMatch = path === '/notifications.json' ||
+ (path === '/user_actions.json' &&
+ userActionTypes.some((type) => ['2', '6', '7', '9', '11', '17'].includes(type))) ||
+ path === '/discourse-reactions/posts/reactions-received.json' ||
+ /^\/discourse-boosts\/users\/[^/]+\/boosts-received\.json$/.test(path) ||
+ /^\/topics\/private-messages(?:-(?:sent|new|unread|archive|warnings))?\/[^/]+\.json$/.test(path);
+ if (topicMatch) tags.add(`topic:${topicMatch[1]}`);
+ if (byNumberMatch) {
+ tags.add(`topic:${byNumberMatch[1]}`);
+ tags.add(`post-number:${byNumberMatch[1]}:${byNumberMatch[2]}`);
+ }
+ if (postMatch) tags.add(`post:${postMatch[1]}`);
+ parsed.searchParams.getAll('post_ids[]').forEach((id) => tags.add(`post:${id}`));
+ const queryPostId = Number(parsed.searchParams.get('post_id'));
+ if (queryPostId) tags.add(`post:${queryPostId}`);
+
+ if (notificationFeedMatch) {
+ kind = 'notifications';
+ freshFor = MINUTE;
+ retainFor = 5 * MINUTE;
+ persist = false;
+ tags.add('notifications');
+ } else if (bookmarkListMatch) {
+ freshFor = MINUTE;
+ tags.add('bookmarks');
+ } else if (givenReactionsMatch || givenLikesMatch) {
+ freshFor = MINUTE;
+ tags.add('reactions-given');
+ } else if (path === '/search.json') {
+ freshFor = MINUTE;
+ retainFor = 5 * MINUTE;
+ persist = false;
+ } else if (topicMatch || byNumberMatch || postMatch || path === '/post_voting/comments') {
+ kind = 'topics';
+ freshFor = path.includes('/replies.json') || path === '/post_voting/comments' ? 5 * MINUTE : 30 * MINUTE;
+ retainFor = 180 * DAY;
+ } else if (path.startsWith('/category-experts/endorsable-categories/')) {
+ kind = 'users';
+ freshFor = MINUTE;
+ retainFor = 5 * MINUTE;
+ persist = false;
+ const endorsedUser = path.match(/^\/category-experts\/endorsable-categories\/([^/.]+)\.json$/);
+ if (endorsedUser) tags.add(`user:${decodeURIComponent(endorsedUser[1]).toLocaleLowerCase()}`);
+ } else if (userMatch || path.startsWith('/directory_items.json')) {
+ kind = 'users';
+ freshFor = 24 * HOUR;
+ retainFor = 180 * DAY;
+ if (userMatch) tags.add(`user:${decodeURIComponent(userMatch[1]).toLocaleLowerCase()}`);
+ const directoryUsername = parsed.searchParams.get('username');
+ if (directoryUsername) tags.add(`user:${directoryUsername.toLocaleLowerCase()}`);
+ }
+
+ const scope = responseCacheAuthScope();
+ return {
+ id: `${scope}|${parsed.href}`,
+ kind,
+ tags: [...tags],
+ freshFor,
+ retainFor,
+ persist,
+ cacheMode: options.cacheMode || 'default',
+ };
+ }
+
+ async function getStoredResponseCacheEntry(id) {
+ const cachedEntry = RESPONSE_CACHE_MEMORY.get(id);
+ if (cachedEntry) {
+ RESPONSE_CACHE_MEMORY.delete(id);
+ RESPONSE_CACHE_MEMORY.set(id, cachedEntry);
+ return cachedEntry;
+ }
+ const db = await openResponseCacheDatabase();
+ if (!db) return null;
+ let entry = null;
+ await runResponseCacheTransaction(db, 'readonly', (store) => {
+ const request = store.get(id);
+ request.onsuccess = () => {
+ entry = request.result || null;
+ };
+ });
+ if (entry) rememberResponseCacheEntry(id, entry);
+ return entry;
+ }
+
+ async function deleteStoredResponseCacheEntry(id) {
+ RESPONSE_CACHE_MEMORY.delete(id);
+ await queueStoredResponseCacheWrite(id, (store) => store.delete(id));
+ publishResponseCacheMutation('response-delete', { token: sharedCacheIdToken(id) });
+ }
+
+ function storedResponseCacheEntry(policy, value, bytes = policy.bytes) {
+ const storedAt = Date.now();
+ return {
+ id: policy.id, kind: policy.kind, tags: policy.tags, storedAt,
+ expiresAt: storedAt + policy.retainFor,
+ bytes: Number.isFinite(bytes) ? Math.max(0, Number(bytes)) : estimateCacheBytes(value),
+ value,
+ };
+ }
+
+ function queueStoredResponseCacheWrite(id, updateStore, afterWrite) {
+ const previousWrite = RESPONSE_CACHE_WRITES.get(id) || Promise.resolve();
+ return trackSingleFlightRequest(RESPONSE_CACHE_WRITES, id,
+ previousWrite.catch(() => {}).then(() => openResponseCacheDatabase())
+ .then((db) => db && runResponseCacheTransaction(db, 'readwrite', updateStore))
+ .then((stored) => afterWrite ? afterWrite(stored) : stored).catch(() => null));
+ }
+
+ function setStoredResponseCacheEntry(policy, value) {
+ const entry = storedResponseCacheEntry(policy, value);
+ rememberResponseCacheEntry(policy.id, entry);
+ if (!policy.persist) return Promise.resolve();
+ if (!responseCachePersistenceRequested) {
+ responseCachePersistenceRequested = true;
+ if (isFunction(navigator.storage?.persist))
+ navigator.storage.persist().catch(() => false);
+ }
+ return queueStoredResponseCacheWrite(policy.id, (store) => store.put(entry), (stored) => {
+ if (stored) publishResponseCacheMutation('response-write', { token: sharedCacheIdToken(policy.id) });
+ });
+ }
+
+ function topicSnapshotPolicy(topicId, bytes) {
+ const safeTopicId = Number(topicId);
+ if (!(safeTopicId > 0)) return null;
+ return {
+ id: `${responseCacheAuthScope()}|snapshot:topic:${safeTopicId}`,
+ kind: 'topics',
+ tags: [`topic:${safeTopicId}`],
+ freshFor: TOPIC_CACHE_TTL,
+ retainFor: PERSISTENT_CACHE_CONFIG.topics.maxAge,
+ persist: true,
+ bytes,
+ };
+ }
+
+ function topicPostStream(topic) {
+ return topic?.post_stream || {};
+ }
+
+ function estimateTopicSnapshotBytes(topic) {
+ const postStream = topicPostStream(topic);
+ const posts = Array.isArray(postStream.posts) ? postStream.posts : [];
+ const stream = Array.isArray(postStream.stream) ? postStream.stream : [];
+ let bytes = 4096 + stream.length * 16;
+ posts.forEach((post) => {
+ if (!post || typeof post !== 'object') return;
+ bytes += 1024;
+ ['cooked', 'raw', 'name', 'username', 'display_username', 'avatar_template'].forEach((key) => {
+ bytes += String(post[key] || '').length * 2;
+ });
+ });
+ return bytes;
+ }
+
+ function subscribeTopicCache(topicId, listener) {
+ const key = String(Number(topicId) || '');
+ if (!key || !isFunction(listener)) return () => {};
+ if (!TOPIC_CACHE_SUBSCRIBERS.has(key)) TOPIC_CACHE_SUBSCRIBERS.set(key, new Set());
+ const listeners = TOPIC_CACHE_SUBSCRIBERS.get(key);
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ if (!listeners.size) TOPIC_CACHE_SUBSCRIBERS.delete(key);
+ };
+ }
+
+ function notifyTopicCacheSubscribers(topicId, topic) {
+ const listeners = TOPIC_CACHE_SUBSCRIBERS.get(String(Number(topicId) || ''));
+ if (!listeners || !listeners.size || !topic) return;
+ listeners.forEach((listener) => {
+ try { listener(topic); } catch {}
+ });
+ }
+
+ function mergeStoredTopicSnapshot(policy, payload) {
+ let finalEntry = null;
+ let finalPayload = null;
+ let mergedPosts = false;
+ return queueStoredResponseCacheWrite(policy.id, (store) => {
+ const request = store.get(policy.id);
+ request.onsuccess = () => {
+ const existing = request.result;
+ const existingValue = existing?.value;
+ const existingValid = existingValue && existingValue.version === TOPIC_SNAPSHOT_VERSION &&
+ Number(existingValue.topicId) === Number(payload.topicId) && existingValue.topic;
+ if (existingValid && Number(existingValue.updatedAt) > Number(payload.updatedAt)) {
+ mergedPosts = mergeMissingSnapshotPosts(existingValue.topic, payload.topic);
+ finalPayload = {
+ ...existingValue,
+ lastAccessAt: Math.max(Number(existingValue.lastAccessAt) || 0, Number(payload.lastAccessAt) || 0),
+ topic: existingValue.topic,
+ };
+ } else {
+ if (existingValid) mergedPosts = mergeMissingSnapshotPosts(payload.topic, existingValue.topic);
+ finalPayload = payload;
+ }
+ finalEntry = storedResponseCacheEntry(
+ policy, finalPayload, estimateTopicSnapshotBytes(finalPayload.topic)
+ );
+ try {
+ store.put(finalEntry);
+ } catch {
+ finalEntry = null;
+ finalPayload = null;
+ }
+ };
+ request.onerror = () => {
+ finalEntry = null;
+ finalPayload = null;
+ };
+ }, (stored) => {
+ if (stored && finalEntry) {
+ rememberResponseCacheEntry(policy.id, finalEntry);
+ publishResponseCacheMutation('response-write', { token: sharedCacheIdToken(policy.id) });
+ }
+ return stored && finalPayload ? { payload: finalPayload, mergedPosts } : null;
+ });
+ }
+
+ async function flushTopicSnapshotWrite(topicId) {
+ const key = String(Number(topicId) || '');
+ const queued = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key);
+ if (queued?.timer) clearTimeout(queued.timer);
+ TOPIC_SNAPSHOT_WRITE_QUEUE.delete(key);
+ const entry = TOPIC_DATA_CACHE.get(key);
+ const scope = responseCacheAuthScope();
+ if (!entry || entry.scope !== scope || !entry.topic) return;
+ const policy = topicSnapshotPolicy(key, estimateTopicSnapshotBytes(entry.topic));
+ if (!policy) return;
+ const storedResult = await mergeStoredTopicSnapshot(policy, {
+ version: TOPIC_SNAPSHOT_VERSION,
+ topicId: Number(key),
+ fetchedAt: Number(entry.fetchedAt) || Date.now(),
+ lastAccessAt: Number(entry.lastAccessAt) || Date.now(),
+ updatedAt: Number(entry.updatedAt) || Date.now(),
+ topic: entry.topic,
+ });
+ if (!storedResult) return;
+ const stored = storedResult.payload;
+ if (stored.topic !== entry.topic || Number(stored.updatedAt) !== Number(entry.updatedAt)) {
+ rememberCachedTopicData(key, stored.topic, {
+ fetchedAt: stored.fetchedAt,
+ lastAccessAt: stored.lastAccessAt,
+ updatedAt: stored.updatedAt,
+ full: true,
+ notify: true,
+ source: 'snapshot-merge',
+ });
+ } else if (storedResult.mergedPosts) {
+ notifyTopicCacheSubscribers(key, stored.topic);
+ }
+ if (SHARED_REQUEST_COORDINATOR)
+ SHARED_REQUEST_COORDINATOR.publishCache({
+ action: 'topic-snapshot-write',
+ topicId: Number(key),
+ scopeToken: sharedCacheIdToken(scope),
+ updatedAt: Number(stored.updatedAt) || Date.now(),
+ });
+ }
+
+ function scheduleTopicSnapshotWrite(topicId) {
+ const key = String(Number(topicId) || '');
+ const entry = TOPIC_DATA_CACHE.get(key);
+ if (!key || !entry || entry.scope !== responseCacheAuthScope()) return;
+ const now = Date.now();
+ const previous = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key);
+ if (previous?.timer) clearTimeout(previous.timer);
+ const firstQueuedAt = Number(previous?.firstQueuedAt) || now;
+ const dueAt = Math.min(now + TOPIC_SNAPSHOT_WRITE_DELAY, firstQueuedAt + TOPIC_SNAPSHOT_WRITE_MAX_DELAY);
+ const timer = setTimeout(() => { void flushTopicSnapshotWrite(key); }, Math.max(0, dueAt - now));
+ TOPIC_SNAPSHOT_WRITE_QUEUE.set(key, { firstQueuedAt, timer });
+ }
+
+ function cancelTopicSnapshotWrites(topicId) {
+ const keys = topicId == null
+ ? [...TOPIC_SNAPSHOT_WRITE_QUEUE.keys()]
+ : [String(Number(topicId) || '')];
+ keys.forEach((key) => {
+ const queued = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key);
+ if (queued?.timer) clearTimeout(queued.timer);
+ TOPIC_SNAPSHOT_WRITE_QUEUE.delete(key);
+ });
+ }
+
+ function flushTopicSnapshotWrites() {
+ return Promise.all(Array.from(TOPIC_SNAPSHOT_WRITE_QUEUE.keys(), (topicId) => (
+ flushTopicSnapshotWrite(topicId)
+ )));
+ }
+
+ function mergeMissingSnapshotPosts(targetTopic, sourceTopic) {
+ const targetStream = targetTopic?.post_stream;
+ const sourceStream = sourceTopic?.post_stream;
+ if (!targetStream || !sourceStream) return false;
+ const current = Array.isArray(targetStream.posts) ? targetStream.posts : [];
+ const indexes = new Map(current.map((post, index) => [Number(post?.id), index]));
+ let changed = false;
+ (Array.isArray(sourceStream.posts) ? sourceStream.posts : []).forEach((post) => {
+ const postId = Number(post?.id);
+ if (!postId || indexes.has(postId)) return;
+ indexes.set(postId, current.length);
+ current.push(post);
+ changed = true;
+ });
+ targetStream.posts = current;
+ if ((!Array.isArray(targetStream.stream) || !targetStream.stream.length) && Array.isArray(sourceStream.stream)) {
+ targetStream.stream = sourceStream.stream.slice();
+ changed = true;
+ }
+ return changed;
+ }
+
+ async function getStoredTopicSnapshot(topicId, options = {}) {
+ const policy = topicSnapshotPolicy(topicId);
+ if (!policy) return null;
+ if (options.preferStored === true) RESPONSE_CACHE_MEMORY.delete(policy.id);
+ const stored = await getStoredResponseCacheEntry(policy.id);
+ const value = stored?.value;
+ const now = Date.now();
+ const fetchedAt = Number(value?.fetchedAt) || Number(stored?.storedAt) || 0;
+ if (!value || value.version !== TOPIC_SNAPSHOT_VERSION || Number(value.topicId) !== Number(topicId) ||
+ !value.topic || !(fetchedAt > 0) || now - fetchedAt > PERSISTENT_CACHE_CONFIG.topics.maxAge) {
+ if (stored) await deleteStoredResponseCacheEntry(policy.id);
+ return null;
+ }
+ const key = String(Number(topicId));
+ const current = TOPIC_DATA_CACHE.get(key);
+ const storedUpdatedAt = Number(value.updatedAt) || Number(stored.storedAt) || fetchedAt;
+ if (current && current.scope === responseCacheAuthScope() && current.topic) {
+ const mergedIntoCurrent = mergeMissingSnapshotPosts(current.topic, value.topic);
+ const currentUpdatedAt = Number(current.updatedAt) || 0;
+ const currentWins = currentUpdatedAt > storedUpdatedAt ||
+ (currentUpdatedAt === storedUpdatedAt && options.preferStored !== true);
+ if (currentWins) {
+ if (mergedIntoCurrent) notifyTopicCacheSubscribers(topicId, current.topic);
+ return current.topic;
+ }
+ mergeMissingSnapshotPosts(value.topic, current.topic);
+ }
+ rememberCachedTopicData(topicId, value.topic, {
+ fetchedAt,
+ lastAccessAt: now,
+ updatedAt: storedUpdatedAt,
+ full: true,
+ notify: true,
+ source: options.preferStored === true ? 'shared-snapshot' : 'snapshot',
+ });
+ return value.topic;
+ }
+
+ function givenReactionSnapshotPolicy(username) {
+ const normalizedUsername = String(username || '').trim().toLocaleLowerCase();
+ if (!normalizedUsername) return null;
+ return {
+ id: `${responseCacheAuthScope()}|snapshot:reactions-given:${encodeURIComponent(normalizedUsername)}`,
+ kind: 'responses',
+ tags: ['reactions-given'],
+ freshFor: MINUTE,
+ retainFor: 30 * DAY,
+ persist: true,
+ };
+ }
+
+ async function getGivenReactionSnapshot(username) {
+ const policy = givenReactionSnapshotPolicy(username);
+ if (!policy) return null;
+ const cached = await getStoredResponseCacheEntry(policy.id);
+ if (!cached) return null;
+ const storedAt = Number(cached.storedAt || 0);
+ const age = Date.now() - storedAt;
+ const value = cached.value || {};
+ if (!(storedAt > 0) || age > policy.retainFor || value.version !== 1 || !Array.isArray(value.entries)) {
+ await deleteStoredResponseCacheEntry(policy.id);
+ return null;
+ }
+ return {
+ entries: value.entries,
+ storedAt,
+ fresh: age <= policy.freshFor,
+ };
+ }
+
+ function setGivenReactionSnapshot(username, entries) {
+ const policy = givenReactionSnapshotPolicy(username);
+ if (!policy) return;
+ setStoredResponseCacheEntry(policy, {
+ version: 1,
+ entries: Array.isArray(entries) ? entries : [],
+ });
+ }
+
+ function responseCacheCanFallback(error) {
+ if (!error || error.name === 'AbortError') return false;
+ const status = Number(error.status || 0);
+ return !status || status === 408 || status === 429 || status >= 500;
+ }
+
+ async function requestCachedJSON(url, fetchOptions, options, networkRequest) {
+ if (options.cacheMode === 'bypass') return networkRequest();
+ const policy = responseCachePolicy(url, fetchOptions, options);
+ if (!policy) return networkRequest();
+ let cached = await getStoredResponseCacheEntry(policy.id);
+ const cachedBeforeRequest = Number(cached?.storedAt || 0);
+ let age = cached ? Date.now() - Number(cached.storedAt || 0) : Infinity;
+ if (cached && age > policy.retainFor) {
+ await deleteStoredResponseCacheEntry(policy.id);
+ cached = null;
+ age = Infinity;
+ } else if (cached && policy.cacheMode !== 'refresh' && age <= policy.freshFor) {
+ return markResponseCacheStoredAt(cached.value, cached.storedAt);
+ }
+
+ const requestKey = `${policy.cacheMode}:${policy.id}`;
+ const pendingRequest = RESPONSE_CACHE_REQUESTS.get(requestKey);
+ if (pendingRequest) return pendingRequest;
+ const request = (async () => {
+ const flightToken = policy.persist && SHARED_REQUEST_COORDINATOR
+ ? sharedCacheIdToken(policy.id)
+ : '';
+ const readLatestSharedCache = async () => {
+ RESPONSE_CACHE_MEMORY.delete(policy.id);
+ const latest = await getStoredResponseCacheEntry(policy.id);
+ const storedAt = Number(latest?.storedAt || 0);
+ const latestAge = latest ? Date.now() - storedAt : Infinity;
+ if (!latest || latestAge > policy.retainFor) return undefined;
+ cached = latest;
+ age = latestAge;
+ const usable = policy.cacheMode === 'refresh'
+ ? storedAt > cachedBeforeRequest
+ : latestAge <= policy.freshFor;
+ return usable ? markResponseCacheStoredAt(latest.value, storedAt) : undefined;
+ };
+ let ownedFlight = null;
+ let flightHeartbeat = 0;
+ try {
+ if (flightToken) {
+ const flightDeadline = Date.now() + SHARED_CACHE_FLIGHT_TTL * 2 + 5000;
+ while (!ownedFlight) {
+ if (Date.now() >= flightDeadline) {
+ throw requestTimeoutError('等待共享缓存请求超时');
+ }
+ const flight = await SHARED_REQUEST_COORDINATOR.acquireCacheFlight(flightToken);
+ if (!flight.producer) {
+ const released = await SHARED_REQUEST_COORDINATOR.waitForCacheFlight(
+ flightToken,
+ fetchOptions.signal,
+ flightDeadline
+ );
+ if (!released) {
+ throw requestTimeoutError('等待共享缓存请求超时');
+ }
+ const shared = await readLatestSharedCache();
+ if (shared !== undefined) return shared;
+ continue;
+ }
+ ownedFlight = flight;
+ flightHeartbeat = setInterval(() => {
+ SHARED_REQUEST_COORDINATOR.renewCacheFlight(flightToken, flight.flightId).then((renewed) => {
+ if (renewed || !flightHeartbeat) return;
+ clearInterval(flightHeartbeat);
+ flightHeartbeat = 0;
+ }).catch(() => {});
+ }, 15000);
+ const lateCached = await readLatestSharedCache();
+ if (lateCached !== undefined) return lateCached;
+ }
+ }
+ const value = markResponseCacheStoredAt(await networkRequest(), Date.now());
+ if (ownedFlight && SHARED_REQUEST_COORDINATOR) {
+ await SHARED_REQUEST_COORDINATOR.writeCacheFlight(
+ flightToken,
+ ownedFlight.flightId,
+ ownedFlight.cacheEpoch,
+ () => setStoredResponseCacheEntry(policy, value)
+ );
+ } else {
+ await setStoredResponseCacheEntry(policy, value);
+ }
+ return value;
+ } catch (error) {
+ if (options.allowStaleOnError !== false && cached && age <= policy.retainFor && responseCacheCanFallback(error)) {
+ return markResponseCacheStoredAt(cached.value, cached.storedAt);
+ }
+ throw error;
+ } finally {
+ if (flightHeartbeat) clearInterval(flightHeartbeat);
+ flightHeartbeat = 0;
+ if (ownedFlight && SHARED_REQUEST_COORDINATOR) {
+ await SHARED_REQUEST_COORDINATOR.releaseCacheFlight(flightToken, ownedFlight.flightId);
+ }
+ }
+ })();
+ return trackSingleFlightRequest(RESPONSE_CACHE_REQUESTS, requestKey, request);
+ }
+
+ async function scanStoredResponseCache(mode, visit) {
+ const db = await openResponseCacheDatabase();
+ if (!db) return false;
+ return runResponseCacheTransaction(db, mode, (store) => {
+ const request = store.openCursor();
+ request.onsuccess = () => {
+ const cursor = request.result;
+ if (!cursor) return;
+ visit(cursor.value || {}, cursor);
+ cursor.continue();
+ };
+ });
+ }
+
+ function storedResponseCachePruneIds(entries, entryExpiresAt, now) {
+ const removeIds = new Set();
+ const entriesByKind = new Map();
+ entries.forEach((entry) => {
+ const id = String(entry?.id || '');
+ if (!id) return;
+ if (entryExpiresAt(entry) <= now) {
+ removeIds.add(id);
+ return;
+ }
+ const kind = PERSISTENT_CACHE_CONFIG[entry.kind] ? entry.kind : 'responses';
+ let kindEntries = entriesByKind.get(kind);
+ if (!kindEntries) {
+ kindEntries = [];
+ entriesByKind.set(kind, kindEntries);
+ }
+ kindEntries.push(entry);
+ });
+ entriesByKind.forEach((kindEntries, kind) => {
+ const config = PERSISTENT_CACHE_CONFIG[kind] || PERSISTENT_CACHE_CONFIG.responses;
+ const maxEntries = Math.max(0, Number(config.storedMaxEntries) || 0);
+ const maxBytes = Math.max(0, Number(config.storedMaxBytes) || 0);
+ if (!maxEntries && !maxBytes) return;
+ let keptEntries = 0;
+ let keptBytes = 0;
+ kindEntries.sort((left, right) => Number(right?.storedAt || 0) - Number(left?.storedAt || 0));
+ kindEntries.forEach((entry) => {
+ const bytes = Math.max(0, Number(entry?.bytes) || 0);
+ const exceedsEntries = maxEntries > 0 && keptEntries >= maxEntries;
+ const exceedsBytes = maxBytes > 0 && keptBytes + bytes > maxBytes;
+ if (exceedsEntries || exceedsBytes) {
+ removeIds.add(String(entry.id));
+ return;
+ }
+ keptEntries++;
+ keptBytes += bytes;
+ });
+ });
+ return removeIds;
+ }
+
+ async function pruneStoredResponseCache(force = false) {
+ const now = Date.now();
+ if (!force && now - responseCachePrunedAt < HOUR) return;
+ if (responseCachePruneRequest) return responseCachePruneRequest;
+ const entryExpiresAt = (entry) => {
+ const explicit = Number(entry?.expiresAt);
+ if (explicit > 0) return explicit;
+ const id = String(entry?.id || '');
+ const separator = id.indexOf('|');
+ const policy = separator >= 0 ? responseCachePolicy(id.slice(separator + 1)) : null;
+ return Number(entry?.storedAt || 0) +
+ (policy ? policy.retainFor : PERSISTENT_CACHE_CONFIG.responses.maxAge);
+ };
+ responseCachePruneRequest = (async () => {
+ const db = await openResponseCacheDatabase();
+ if (!db) return;
+ let scanned = false;
+ const pruned = await runResponseCacheTransaction(db, 'readwrite', (store) => {
+ const entries = [];
+ const request = store.openCursor();
+ request.onsuccess = () => {
+ const cursor = request.result;
+ if (cursor) {
+ const entry = cursor.value || {};
+ entries.push({
+ id: entry.id,
+ kind: entry.kind,
+ storedAt: entry.storedAt,
+ expiresAt: entry.expiresAt,
+ bytes: entry.bytes,
+ });
+ cursor.continue();
+ return;
+ }
+ storedResponseCachePruneIds(entries, entryExpiresAt, now).forEach((id) => {
+ RESPONSE_CACHE_MEMORY.delete(id);
+ store.delete(id);
+ });
+ scanned = true;
+ };
+ });
+ if (!pruned || !scanned) return;
+ responseCachePrunedAt = now;
+ })().finally(() => {
+ responseCachePruneRequest = null;
+ });
+ return responseCachePruneRequest;
+ }
+
+ async function clearStoredResponseCache({ kinds = [], tags = [], all = false } = {}) {
+ if (SHARED_REQUEST_COORDINATOR)
+ await SHARED_REQUEST_COORDINATOR.invalidateCacheWrites();
+ if (RESPONSE_CACHE_WRITES.size) await Promise.all([...RESPONSE_CACHE_WRITES.values()]);
+ const kindSet = new Set(kinds);
+ const tagSet = new Set(tags);
+ RESPONSE_CACHE_MEMORY.forEach((entry, id) => {
+ if (all || kindSet.has(entry.kind) || (entry.tags || []).some((tag) => tagSet.has(tag)))
+ RESPONSE_CACHE_MEMORY.delete(id);
+ });
+ const db = await openResponseCacheDatabase();
+ if (db) {
+ await runResponseCacheTransaction(db, 'readwrite', (store) => {
+ if (all) {
+ store.clear();
+ } else {
+ const lookups = [
+ ...Array.from(kindSet, (kind) => [store.index('kind'), kind]),
+ ...Array.from(tagSet, (tag) => [store.index('tags'), tag]),
+ ];
+ const ids = new Set();
+ let pending = lookups.length;
+ lookups.forEach(([index, value]) => {
+ const request = index.getAllKeys(IDBKeyRange.only(value));
+ request.onsuccess = () => {
+ (request.result || []).forEach((id) => ids.add(id));
+ pending--;
+ if (!pending) ids.forEach((id) => store.delete(id));
+ };
+ request.onerror = () => {
+ pending--;
+ if (!pending) ids.forEach((id) => store.delete(id));
+ };
+ });
+ }
+ });
+ }
+ publishResponseCacheMutation('response-clear', { kinds, tags, all });
+ }
+
+ async function storedResponseCacheStats() {
+ await pruneStoredResponseCache(true);
+ const counts = {
+ topics: { count: 0, bytes: 0 },
+ users: { count: 0, bytes: 0 },
+ notifications: { count: 0, bytes: 0 },
+ responses: { count: 0, bytes: 0 },
+ };
+ let topicSnapshotCount = 0;
+ let topicRequestCount = 0;
+ const topicIds = new Set();
+ const postIds = new Set();
+ const userNames = new Set();
+ const notificationIds = new Set();
+ const responseRecords = { bookmarks: 0, reactions: 0, other: 0 };
+ const result = () => ({
+ topics: { ...counts.topics, topicIds: [...topicIds], postCount: postIds.size,
+ topicSnapshotCount, topicRequestCount },
+ users: { ...counts.users, userNames: [...userNames] },
+ notifications: { ...counts.notifications, notificationIds: [...notificationIds] },
+ responses: { ...counts.responses, responseRecords },
+ });
+ await scanStoredResponseCache('readonly', (entry) => {
+ const stat = counts[entry.kind];
+ if (stat) {
+ stat.count++;
+ stat.bytes += Math.max(0, Number(entry.bytes) || 0);
+ const tags = new Set(Array.isArray(entry.tags) ? entry.tags.map(String) : []);
+ if (entry.kind === 'topics') {
+ tags.forEach((tag) => {
+ const match = String(tag).match(/^topic:(\d+)$/);
+ if (match) topicIds.add(Number(match[1]));
+ });
+ const value = entry.value;
+ const isTopicSnapshot = String(entry.id || '').includes('|snapshot:topic:') ||
+ !!(value && value.version === TOPIC_SNAPSHOT_VERSION && value.topicId && value.topic);
+ if (isTopicSnapshot) topicSnapshotCount++;
+ else topicRequestCount++;
+ const snapshotTopic = isTopicSnapshot && value
+ ? value.topic
+ : value;
+ const snapshotTopicId = Number(value?.topicId || snapshotTopic?.id || 0);
+ if (snapshotTopicId > 0) topicIds.add(snapshotTopicId);
+ postsFromPayload(snapshotTopic).forEach((post) => {
+ const topicId = Number(post?.topic_id || snapshotTopicId || 0);
+ if (topicId > 0) topicIds.add(topicId);
+ const postId = Number(post?.id || 0);
+ const postNumber = Number(post?.post_number || 0);
+ if (postId > 0) postIds.add(`id:${postId}`);
+ else if (topicId > 0 && postNumber > 0) postIds.add(`number:${topicId}:${postNumber}`);
+ });
+ } else if (entry.kind === 'users') {
+ tags.forEach((tag) => {
+ const match = tag.match(/^user:(.+)$/);
+ if (match?.[1]) userNames.add(match[1].toLocaleLowerCase());
+ });
+ const value = entry.value || {};
+ const candidates = [value.user, value];
+ (Array.isArray(value.directory_items) ? value.directory_items : []).forEach((item) => {
+ candidates.push(item && (item.user || item));
+ });
+ candidates.forEach((user) => {
+ const username = cleanUsernameValue(user?.username).toLocaleLowerCase();
+ if (username) userNames.add(username);
+ });
+ } else if (entry.kind === 'notifications') {
+ const notifications = entry.value && Array.isArray(entry.value.notifications)
+ ? entry.value.notifications
+ : [];
+ addNotificationStatsIdentities(notificationIds, notifications);
+ } else if (entry.kind === 'responses') {
+ if (tags.has('bookmarks')) responseRecords.bookmarks++;
+ else if (tags.has('reactions-given')) responseRecords.reactions++;
+ else responseRecords.other++;
+ }
+ }
+ });
+ return result();
+ }
+
+ function responseCacheMutationTags(url, params, data) {
+ const parsed = responseCacheUrl(url);
+ if (parsed && parsed.pathname === '/topics/timings') return [];
+ const tags = new Set();
+ const source = Object.assign({}, params || {}, data || {});
+ const topicId = Number(source.topic_id || source.topicId ||
+ (source.bookmarkable_type === 'Topic' && source.bookmarkable_id) || 0);
+ const postId = Number(source.post_id || source.postId ||
+ (source.bookmarkable_type === 'Post' && source.bookmarkable_id) ||
+ (data?.post && data.post.id) || 0);
+ if (topicId) tags.add(`topic:${topicId}`);
+ if (postId) tags.add(`post:${postId}`);
+ if (parsed) {
+ const topicMatch = parsed.pathname.match(/^\/t\/(?:[^/]+\/)?(\d+)/);
+ const postMatch = parsed.pathname.match(/^\/(?:posts|post_actions)\/(\d+)/);
+ const reactionPostMatch = parsed.pathname.match(/^\/discourse-reactions\/posts\/(\d+)/);
+ const userMatch = parsed.pathname.match(/^\/(?:u|follow|category-experts\/endorse)\/([^/.]+)/);
+ if (topicMatch) tags.add(`topic:${topicMatch[1]}`);
+ if (postMatch) tags.add(`post:${postMatch[1]}`);
+ if (reactionPostMatch) {
+ tags.add(`post:${reactionPostMatch[1]}`);
+ tags.add('reactions-given');
+ }
+ if (parsed.pathname === '/post_actions' && Number(params?.id))
+ tags.add(`post:${Number(params.id)}`);
+ if (parsed.pathname === '/post_actions' || /^\/post_actions\/\d+/.test(parsed.pathname))
+ tags.add('reactions-given');
+ if (parsed.pathname === '/bookmarks' || parsed.pathname.startsWith('/bookmarks/') ||
+ /\/remove_bookmarks$/.test(parsed.pathname)) tags.add('bookmarks');
+ if (userMatch) tags.add(`user:${decodeURIComponent(userMatch[1]).toLocaleLowerCase()}`);
+ if (parsed.pathname.startsWith('/notifications/')) tags.add('notifications');
+ }
+ return [...tags];
+ }
+
+ function scheduleIdleWork(callback, timeout = 1200) {
+ let cancelled = false;
+ const run = () => {
+ if (!cancelled) callback();
+ };
+ if (isFunction(requestIdleCallback)) {
+ const id = requestIdleCallback(run, { timeout });
+ return () => {
+ cancelled = true;
+ if (isFunction(cancelIdleCallback)) cancelIdleCallback(id);
+ };
+ }
+ const id = setTimeout(run, timeout);
+ return () => {
+ cancelled = true;
+ clearTimeout(id);
+ };
+ }
+
+ function trimPersistentCacheMap(key) {
+ const cache = cacheMapForKey(key);
+ const maxEntries = Number(PERSISTENT_CACHE_CONFIG[key]?.maxEntries) || 0;
+ if (!cache || !maxEntries) return;
+ while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
+ }
+
+ function topicCacheEntryTimestamp(entry) {
+ return Number(entry?.fetchedAt) || Number(entry?.at) || 0;
+ }
+
+ function rememberCachedTopicData(topicId, topic, options = {}) {
+ const key = String(Number(topicId) || '');
+ if (!key || !topic) return null;
+ const now = Date.now();
+ const scope = String(options.scope || responseCacheAuthScope());
+ const previous = TOPIC_DATA_CACHE.get(key);
+ const sameScope = previous && previous.scope === scope ? previous : null;
+ const entry = {
+ topic,
+ scope,
+ fetchedAt: Number(options.fetchedAt) || topicCacheEntryTimestamp(sameScope) || now,
+ lastAccessAt: Number(options.lastAccessAt) || now,
+ updatedAt: Number(options.updatedAt) || now,
+ source: String(options.source || sameScope?.source || 'memory'),
+ };
+ TOPIC_DATA_CACHE.delete(key);
+ TOPIC_DATA_CACHE.set(key, entry);
+ trimPersistentCacheMap('topics');
+ if (options.notify !== false) notifyTopicCacheSubscribers(key, topic);
+ return entry;
+ }
+
+ function getCachedTopicData(topicId) {
+ const key = String(Number(topicId) || '');
+ if (!key) return null;
+ const now = Date.now();
+ const scope = responseCacheAuthScope();
+ let entry = TOPIC_DATA_CACHE.get(key);
+ if (entry && entry.scope !== scope) {
+ if (ME_USERNAME === null && String(entry.scope || '').startsWith('account:')) return null;
+ const canRescopeHost = entry.source === 'host' &&
+ now - topicCacheEntryTimestamp(entry) <= TOPIC_CACHE_TTL;
+ if (canRescopeHost) {
+ entry.scope = scope;
+ entry.updatedAt = now;
+ scheduleTopicSnapshotWrite(key);
+ } else {
+ TOPIC_DATA_CACHE.delete(key);
+ entry = null;
+ }
+ }
+ if (entry && now - topicCacheEntryTimestamp(entry) > PERSISTENT_CACHE_CONFIG.topics.maxAge) {
+ TOPIC_DATA_CACHE.delete(key);
+ entry = null;
+ }
+ if (!entry) {
+ const hostEntry = HOST_TOPIC_DATA_CACHE.get(key);
+ HOST_TOPIC_DATA_CACHE.delete(key);
+ if (hostEntry && now - Number(hostEntry.at || 0) <= TOPIC_CACHE_TTL) {
+ entry = rememberCachedTopicData(key, hostEntry.topic, {
+ fetchedAt: Number(hostEntry.at) || now,
+ source: 'host',
+ });
+ scheduleTopicSnapshotWrite(key);
+ }
+ }
+ if (!entry) return null;
+ TOPIC_DATA_CACHE.delete(key);
+ entry.lastAccessAt = now;
+ TOPIC_DATA_CACHE.set(key, entry);
+ return entry.topic;
+ }
+
+ function setCachedTopicData(topicId, topic, options = {}) {
+ const entry = rememberCachedTopicData(topicId, topic, options);
+ if (entry) scheduleTopicSnapshotWrite(topicId);
+ }
+
+ function cleanUsernameValue(value) {
+ return String(value || '').replace(/^@/, '').trim();
+ }
+
+ function authScopedCacheKey(key) {
+ return `${responseCacheAuthScope()}|${key}`;
+ }
+
+ function getCachedUserCard(username) {
+ const key = authScopedCacheKey(cleanUsernameValue(username));
+ const entry = USER_CARD_CACHE.get(key);
+ if (!entry) return null;
+ if (Date.now() - Number(entry.at || 0) > PERSISTENT_CACHE_CONFIG.users.maxAge) {
+ USER_CARD_CACHE.delete(key);
+ schedulePersistentCacheWrite('users');
+ return null;
+ }
+ USER_CARD_CACHE.delete(key);
+ entry.at = Date.now();
+ USER_CARD_CACHE.set(key, entry);
+ return entry.user || null;
+ }
+
+ function setCachedUserCard(username, user, persistDelay = 250) {
+ const cleanUsername = cleanUsernameValue(username);
+ if (!cleanUsername || !user) return;
+ const key = authScopedCacheKey(cleanUsername);
+ USER_CARD_CACHE.delete(key);
+ USER_CARD_CACHE.set(key, { user, at: Date.now() });
+ trimPersistentCacheMap('users');
+ schedulePersistentCacheWrite('users', persistDelay);
+ }
+
+ function cacheMapForKey(key) {
+ if (key === 'topics') return TOPIC_DATA_CACHE;
+ if (key === 'topicNavIcons') return TOPIC_NAV_ICON_CACHE;
+ if (key === 'users') return USER_CARD_CACHE;
+ if (key === 'notifications') return NOTIFICATION_CACHE;
+ return null;
+ }
+
+ function readPersistentCacheEntries(key) {
+ const config = PERSISTENT_CACHE_CONFIG[key];
+ if (!config || !config.storageKey || key === 'history') return [];
+ try {
+ const data = JSON.parse(localStorage.getItem(config.storageKey) || 'null');
+ if (!data || data.version !== PERSISTENT_CACHE_VERSION || !Array.isArray(data.entries)) return [];
+ return data.entries;
+ } catch {
+ return [];
+ }
+ }
+
+ function boundedPersistentEntries(key, entries) {
+ const config = PERSISTENT_CACHE_CONFIG[key];
+ if (!config) return [];
+ const now = Date.now();
+ const authPrefix = ME_USERNAME === null
+ ? ''
+ : key === 'notifications'
+ ? authScopedCacheKey('tabs-v7:')
+ : key === 'users'
+ ? authScopedCacheKey('')
+ : '';
+ const bounded = (Array.isArray(entries) ? entries : []).filter((entry) => {
+ if (!Array.isArray(entry) || entry.length !== 2 || entry[0] == null || !entry[1]) return false;
+ if (authPrefix && !String(entry[0]).startsWith(authPrefix)) return false;
+ return Number.isFinite(Number(entry[1].at)) && now - Number(entry[1].at) <= config.maxAge;
+ });
+ return config.maxEntries ? bounded.slice(-config.maxEntries) : bounded;
+ }
+
+ function isStorageQuotaError(error) {
+ return !!error && (error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
+ error.code === 22 || error.code === 1014);
+ }
+
+ function persistCacheType(key) {
+ const config = PERSISTENT_CACHE_CONFIG[key];
+ const cache = cacheMapForKey(key);
+ if (!config || !cache) return;
+ const merged = new Map(readPersistentCacheEntries(key).map(([entryKey, value]) => [String(entryKey), value]));
+ cache.forEach((value, entryKey) => {
+ const normalizedKey = String(entryKey);
+ const previous = merged.get(normalizedKey);
+ if (!previous || Number(value?.at || 0) >= Number(previous?.at || 0)) {
+ merged.delete(normalizedKey);
+ merged.set(normalizedKey, value);
+ }
+ });
+ const entries = boundedPersistentEntries(key, [...merged.entries()]);
+ while (true) {
+ try {
+ if (entries.length) localStorage.setItem(config.storageKey, JSON.stringify({ version: PERSISTENT_CACHE_VERSION, entries }));
+ else localStorage.removeItem(config.storageKey);
+ break;
+ } catch (error) {
+ if (!isStorageQuotaError(error) || !entries.length) return;
+ entries.shift();
+ }
+ }
+ cache.clear();
+ entries.forEach(([entryKey, value]) => cache.set(String(entryKey), value));
+ if (SHARED_REQUEST_COORDINATOR)
+ SHARED_REQUEST_COORDINATOR.publishCache({ action: 'map-update', key });
+ }
+
+ function flushPersistentCacheWrites() {
+ if (cancelPersistentCacheWrite) {
+ const cancel = cancelPersistentCacheWrite;
+ cancelPersistentCacheWrite = null;
+ cancel();
+ }
+ persistentCacheWriteDueAt = 0;
+ const keys = [...PERSISTENT_CACHE_WRITE_KEYS];
+ PERSISTENT_CACHE_WRITE_KEYS.clear();
+ keys.forEach(persistCacheType);
+ }
+
+ function schedulePersistentCacheWrite(key, delay = 2000) {
+ if (!cacheMapForKey(key) || !PERSISTENT_CACHE_CONFIG[key]?.storageKey) return;
+ PERSISTENT_CACHE_WRITE_KEYS.add(key);
+ const safeDelay = Math.max(0, Number(delay) || 0);
+ const dueAt = Date.now() + safeDelay;
+ if (cancelPersistentCacheWrite && persistentCacheWriteDueAt <= dueAt) return;
+ if (cancelPersistentCacheWrite) cancelPersistentCacheWrite();
+ persistentCacheWriteDueAt = dueAt;
+ cancelPersistentCacheWrite = scheduleIdleWork(() => {
+ cancelPersistentCacheWrite = null;
+ persistentCacheWriteDueAt = 0;
+ flushPersistentCacheWrites();
+ }, safeDelay);
+ }
+
+ function restorePersistentCaches() {
+ try { localStorage.removeItem(LDP_TOPIC_CACHE_KEY); } catch {}
+ ['topicNavIcons', 'users', 'notifications'].forEach((key) => {
+ const cache = cacheMapForKey(key);
+ if (!cache) return;
+ const rawEntries = readPersistentCacheEntries(key);
+ const entries = boundedPersistentEntries(key, rawEntries);
+ entries.forEach(([entryKey, value]) => cache.set(String(entryKey), value));
+ if (key === 'notifications') {
+ entries.forEach(([, entry]) => {
+ const notifications = entry?.value && entry.value.notifications;
+ if (!Array.isArray(notifications)) return;
+ notifications.forEach((notification) => {
+ const data = notificationData(notification);
+ const template = notification.acting_user_avatar_template || notification.avatar_template ||
+ data.acting_user_avatar_template || data.avatar_template || '';
+ const actor = notificationActor(notification);
+ if (actor && template) rememberNotificationAvatar(actor, template);
+ });
+ });
+ }
+ if (entries.length !== rawEntries.length) schedulePersistentCacheWrite(key);
+ });
+ }
+
+ function mergeSharedPersistentCache(key) {
+ const cache = cacheMapForKey(key);
+ if (!cache) return;
+ let changed = false;
+ boundedPersistentEntries(key, readPersistentCacheEntries(key)).forEach(([entryKey, value]) => {
+ const normalizedKey = String(entryKey);
+ const current = cache.get(normalizedKey);
+ if (!current || Number(value?.at || 0) > Number(current?.at || 0)) {
+ cache.delete(normalizedKey);
+ cache.set(normalizedKey, value);
+ changed = true;
+ }
+ });
+ trimPersistentCacheMap(key);
+ if (changed) schedulePersistentCacheWrite(key, 0);
+ }
+
+ function clearPersistentCacheMaps(keys) {
+ (Array.isArray(keys) ? keys : []).forEach((key) => {
+ if (key === 'topics') {
+ cancelTopicSnapshotWrites();
+ HOST_TOPIC_DATA_CACHE.clear();
+ }
+ const cache = cacheMapForKey(String(key));
+ if (cache) cache.clear();
+ if (key === 'users') {
+ USER_CARD_REQUESTS.clear();
+ USER_FOLLOW_LIST_CACHE.clear();
+ USER_FOLLOW_LIST_REQUESTS.clear();
+ }
+ if (key === 'notifications') NOTIFICATION_REQUESTS.clear();
+ });
+ }
+
+ function applySharedCacheMessage(message) {
+ const action = String(message?.action || '');
+ if (action === 'topic-snapshot-write') {
+ const topicId = Number(message.topicId);
+ if (!(topicId > 0) || String(message.scopeToken || '') !== sharedCacheIdToken(responseCacheAuthScope())) return;
+ const policy = topicSnapshotPolicy(topicId);
+ if (policy) RESPONSE_CACHE_MEMORY.delete(policy.id);
+ void getStoredTopicSnapshot(topicId, { preferStored: true });
+ return;
+ }
+ if (action === 'map-update') {
+ mergeSharedPersistentCache(String(message.key || ''));
+ return;
+ }
+ if (action === 'map-delete' && message.key === 'topics') {
+ const key = String(message.entryKey || '');
+ TOPIC_DATA_CACHE.delete(key);
+ HOST_TOPIC_DATA_CACHE.delete(key);
+ cancelTopicSnapshotWrites(key);
+ return;
+ }
+ if (action === 'map-clear') {
+ clearPersistentCacheMaps(message.keys);
+ return;
+ }
+ if (action === 'response-write' || action === 'response-delete') {
+ const token = String(message.token || '');
+ for (const id of RESPONSE_CACHE_MEMORY.keys()) {
+ if (token && sharedCacheIdToken(id) === token) RESPONSE_CACHE_MEMORY.delete(id);
+ }
+ return;
+ }
+ if (action === 'response-clear') {
+ const kindSet = new Set(Array.isArray(message.kinds) ? message.kinds : []);
+ const tagSet = new Set(Array.isArray(message.tags) ? message.tags : []);
+ RESPONSE_CACHE_MEMORY.forEach((entry, id) => {
+ if (message.all || kindSet.has(entry.kind) || (entry.tags || []).some((tag) => tagSet.has(tag)))
+ RESPONSE_CACHE_MEMORY.delete(id);
+ });
+ }
+ }
+
+ function readTopicHistory() {
+ try {
+ const data = JSON.parse(localStorage.getItem(LDP_HISTORY_KEY) || '[]');
+ if (!Array.isArray(data)) return [];
+ const config = PERSISTENT_CACHE_CONFIG.history;
+ const safe = data.filter((entry) => entry && Number(entry.topicId) > 0 &&
+ Number(entry.viewedAt || 0) >= Date.now() - config.maxAge);
+ if (safe.length !== data.length) return writeTopicHistory(safe);
+ return safe;
+ } catch {
+ return [];
+ }
+ }
+
+ function historyEntryFirstViewedAt(entry) {
+ return Number(entry?.firstViewedAt || entry?.viewedAt) || 0;
+ }
+
+ function historyEntrySortTime(entry) {
+ return PREFS.historySortMode === 'recent-viewed'
+ ? Number(entry?.viewedAt) || 0
+ : historyEntryFirstViewedAt(entry);
+ }
+
+ function orderedTopicHistory(entries = readTopicHistory()) {
+ return entries.slice().sort((left, right) =>
+ historyEntrySortTime(right) - historyEntrySortTime(left) ||
+ Number(right.topicId || 0) - Number(left.topicId || 0)
+ );
+ }
+
+ function writeTopicHistory(entries) {
+ const safeEntries = Array.isArray(entries) ? entries.slice() : [];
+ while (true) {
+ try {
+ if (safeEntries.length) localStorage.setItem(LDP_HISTORY_KEY, JSON.stringify(safeEntries));
+ else localStorage.removeItem(LDP_HISTORY_KEY);
+ syncReaderQueueReadHistory(safeEntries);
+ return safeEntries;
+ } catch (error) {
+ if (!isStorageQuotaError(error) || !safeEntries.length) return safeEntries;
+ safeEntries.pop();
+ }
+ }
+ }
+
+ function normalizeReadPostNumbers(values) {
+ const source = values instanceof Set ? [...values] : (Array.isArray(values) ? values : []);
+ return [...new Set(source
+ .map((value) => Math.floor(Number(value) || 0))
+ .filter((value) => value > 0))]
+ .sort((left, right) => left - right);
+ }
+
+ function historyEntryReadPostNumbers(entry) {
+ return normalizeReadPostNumbers(entry?.readPostNumbers);
+ }
+
+ function rememberTopic(topic, postNumber = 0, readPostNumbers = []) {
+ const topicId = Number(topic?.id);
+ if (!topicId) return;
+ const history = readTopicHistory();
+ const previousEntry = history.find((item) => Number(item.topicId) === topicId);
+ const viewedPostNumber = Math.max(
+ 1,
+ Math.floor(Number(postNumber) || Number(previousEntry?.postNumber) || 1),
+ );
+ const cumulativeReadPostNumbers = normalizeReadPostNumbers([
+ ...historyEntryReadPostNumbers(previousEntry),
+ ...normalizeReadPostNumbers(readPostNumbers),
+ viewedPostNumber,
+ ]);
+ const posts = topic?.post_stream && Array.isArray(topic.post_stream.posts)
+ ? topic.post_stream.posts : [];
+ const firstPost = posts.find((post) => Number(post?.post_number) === 1);
+ const viewedAt = Date.now();
+ const entry = {
+ topicId,
+ title: String(topic.title || `帖子 #${topicId}`),
+ postsCount: Math.max(0, Number(topic.posts_count) || 0),
+ avatarTemplate: String(
+ (topic.details?.created_by && topic.details.created_by.avatar_template) ||
+ (firstPost?.avatar_template) || ''
+ ),
+ ownerUsername: String(
+ (topic.details?.created_by && topic.details.created_by.username) ||
+ (firstPost?.username) || topic._opUsername || ''
+ ),
+ postNumber: viewedPostNumber,
+ readPostNumbers: cumulativeReadPostNumbers,
+ firstViewedAt: historyEntryFirstViewedAt(previousEntry) || viewedAt,
+ viewedAt,
+ };
+ writeTopicHistory([entry, ...history.filter((item) => Number(item.topicId) !== topicId)]);
+ const readerShell = CURRENT_OVERLAY?._ldpReaderShell;
+ if (isFunction(readerShell?.refreshHistoryPanel))
+ readerShell.refreshHistoryPanel();
+ }
+
+ function normalizeReaderHistoryViewport(value) {
+ const source = value && typeof value === 'object' ? value : { postNumber: value };
+ const postNumber = Math.floor(Number(source.postNumber));
+ if (!(postNumber > 0)) return null;
+ const postOffset = Number(source.postOffset);
+ const scrollTop = Number(source.scrollTop);
+ return {
+ postNumber,
+ postOffset: Number.isFinite(postOffset) ? postOffset : 0,
+ scrollTop: Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0,
+ };
+ }
+
+ function normalizeReaderAnchorPoint(value) {
+ const number = Math.floor(Number(value?.number));
+ if (!(number > 0)) return null;
+ return {
+ number,
+ scrollTop: Math.max(0, Number(value.scrollTop) || 0),
+ offset: Number.isFinite(Number(value.offset)) ? Number(value.offset) : 12,
+ };
+ }
+
+ function normalizeReaderReplyWindowState(value) {
+ const rootPostNumber = Math.floor(Number(value?.rootPostNumber));
+ return rootPostNumber > 0 ? {
+ rootPostNumber,
+ point: normalizeReaderAnchorPoint(value.point),
+ } : null;
+ }
+
+ function normalizeReaderQuoteSource(value) {
+ const topicId = Math.floor(Number(value?.topicId));
+ const postNumber = Math.floor(Number(value?.postNumber));
+ if (!(topicId > 0) || !(postNumber > 0)) return null;
+ const parentPostNumber = Math.floor(Number(value?.parentPostNumber));
+ const anchorViewport = normalizeReaderHistoryViewport(value?.anchor?.viewport);
+ return {
+ topicId,
+ postNumber,
+ parentPostNumber: parentPostNumber > 0 ? parentPostNumber : 0,
+ nested: value?.nested === true && parentPostNumber > 0,
+ anchor: anchorViewport ? {
+ viewport: anchorViewport,
+ replyWindow: normalizeReaderReplyWindowState(value.anchor.replyWindow),
+ quoteHighlight: null,
+ } : null,
+ };
+ }
+
+ function normalizeReaderQuoteHighlightState(value) {
+ const postNumber = Math.floor(Number(value?.postNumber));
+ const text = String(value?.text || value?.quoteText || '');
+ return postNumber > 0 && text ? {
+ postNumber,
+ text,
+ source: normalizeReaderQuoteSource(value.source),
+ active: value.active !== false,
+ } : null;
+ }
+
+ function normalizeReaderAnchorState(value) {
+ const source = value && typeof value === 'object' && 'viewport' in value
+ ? value : { viewport: value };
+ const viewport = normalizeReaderHistoryViewport(source.viewport);
+ if (!viewport) return null;
+ return {
+ viewport,
+ replyWindow: normalizeReaderReplyWindowState(source.replyWindow),
+ quoteHighlight: normalizeReaderQuoteHighlightState(source.quoteHighlight),
+ };
+ }
+
+ function normalizeReaderHistoryStates(values) {
+ const states = {};
+ if (!values || typeof values !== 'object') return states;
+ Object.entries(values).forEach(([entryTopicId, value]) => {
+ const safeTopicId = Number(entryTopicId);
+ const state = normalizeReaderAnchorState(value);
+ if (safeTopicId > 0 && state) states[safeTopicId] = state;
+ });
+ return states;
+ }
+
+ function captureReaderAnchorState(overlay, fallbackPostNumber = 1) {
+ const ctx = overlay?._ldpReaderShell?.activeContext;
+ const viewport = normalizeReaderHistoryViewport(
+ (isFunction(overlay?._ldpCaptureHistoryViewport)
+ ? overlay._ldpCaptureHistoryViewport()
+ : null) || embeddedReaderViewportState(ctx) || fallbackPostNumber
+ );
+ if (!viewport) return null;
+ return {
+ viewport,
+ replyWindow: normalizeReaderReplyWindowState(
+ isFunction(ctx?.replyThreadWindow?._ldpCaptureAnchorState)
+ ? ctx.replyThreadWindow._ldpCaptureAnchorState()
+ : null
+ ),
+ quoteHighlight: normalizeReaderQuoteHighlightState(ctx?.quoteHighlightState),
+ };
+ }
+
+ function normalizeHistoryEdgeTriggerPercent(value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return HISTORY_EDGE_TRIGGER_DEFAULT;
+ return Math.min(HISTORY_EDGE_TRIGGER_MAX, Math.max(HISTORY_EDGE_TRIGGER_MIN, Math.round(numeric)));
+ }
+
+ function createReaderHistoryNavigation(topicId, source) {
+ const currentTopicId = Number(topicId);
+ const normalize = (values) => [...new Set((Array.isArray(values) ? values : [])
+ .map(Number).filter((id) => id > 0 && id !== currentTopicId))];
+ if (source && (Array.isArray(source.back) || Array.isArray(source.forward))) {
+ return {
+ back: normalize(source.back),
+ forward: normalize(source.forward),
+ states: normalizeReaderHistoryStates(source.states),
+ };
+ }
+ const orderedTopicIds = orderedTopicHistory().map((entry) => Number(entry.topicId)).filter((id) => id > 0);
+ const currentIndex = orderedTopicIds.indexOf(currentTopicId);
+ const useFixedPosition = PREFS.historySortMode === 'first-viewed' && currentIndex >= 0;
+ return {
+ back: normalize(useFixedPosition ? orderedTopicIds.slice(currentIndex + 1) : orderedTopicIds),
+ forward: normalize(useFixedPosition ? orderedTopicIds.slice(0, currentIndex).reverse() : []),
+ states: {},
+ };
+ }
+
+ function bindReaderHistoryNavigation(overlay, topicId, navigation) {
+ const backButton = overlay.querySelector('.ldp-reader-history-back');
+ const forwardButton = overlay.querySelector('.ldp-reader-history-forward');
+ if (!backButton || !forwardButton) return () => {};
+ const backEdge = backButton.closest('.ldp-reader-history-edge');
+ const forwardEdge = forwardButton.closest('.ldp-reader-history-edge');
+ const modal = overlay.querySelector('.ldp-modal');
+ const historyScope = createLifecycleScope();
+ let modalPointerBounds = null;
+ let backEdgeActive = false;
+ let forwardEdgeActive = false;
+ const setActiveEdges = (backActive, forwardActive) => {
+ if (backEdgeActive !== backActive) {
+ backEdgeActive = backActive;
+ backEdge?.classList.toggle('is-active', backActive);
+ }
+ if (forwardEdgeActive !== forwardActive) {
+ forwardEdgeActive = forwardActive;
+ forwardEdge?.classList.toggle('is-active', forwardActive);
+ }
+ };
+ const clearActiveEdge = () => {
+ setActiveEdges(false, false);
+ };
+ const invalidateModalPointerBounds = () => {
+ modalPointerBounds = null;
+ };
+ const refreshModalPointerBounds = () => {
+ const triggerPercent = normalizeHistoryEdgeTriggerPercent(PREFS.historyEdgeTriggerPercent);
+ if (triggerPercent <= 0) {
+ modalPointerBounds = { backLimit: 0, forwardLimit: 0, enabled: false };
+ return modalPointerBounds;
+ }
+ const rect = modal.getBoundingClientRect();
+ const triggerWidth = rect.width * triggerPercent / 100;
+ modalPointerBounds = {
+ backLimit: rect.left + triggerWidth,
+ forwardLimit: rect.right - triggerWidth,
+ enabled: true,
+ };
+ return modalPointerBounds;
+ };
+ const syncVisibilityPreference = () => {
+ const alwaysVisible = PREFS.historyButtonsAlwaysVisible === true;
+ overlay.classList.toggle('ldp-history-buttons-always-visible', alwaysVisible);
+ invalidateModalPointerBounds();
+ if (alwaysVisible) clearActiveEdge();
+ };
+ const onModalPointerMove = (event) => {
+ if (!modal || PREFS.historyButtonsAlwaysVisible === true || (event.pointerType && event.pointerType !== 'mouse')) {
+ clearActiveEdge();
+ return;
+ }
+ const bounds = modalPointerBounds || refreshModalPointerBounds();
+ const nearBackEdge = bounds.enabled && event.clientX <= bounds.backLimit;
+ const nearForwardEdge = bounds.enabled && event.clientX >= bounds.forwardLimit;
+ const target = event.target instanceof Element ? event.target : null;
+ const overBackButton = !!target && backButton.contains(target);
+ const overForwardButton = !!target && forwardButton.contains(target);
+ if (!nearBackEdge && !nearForwardEdge && !overBackButton && !overForwardButton) {
+ clearActiveEdge();
+ return;
+ }
+ if (target?.closest('.ldp-settings-popover,.ldp-notifications-popover,.ldp-history-popover,.ldp-bookmarks-popover')) {
+ clearActiveEdge();
+ return;
+ }
+ setActiveEdges(
+ nearBackEdge || overBackButton,
+ nearForwardEdge || overForwardButton,
+ );
+ };
+ const onModalPointerEnter = (event) => {
+ if (PREFS.historyButtonsAlwaysVisible !== true &&
+ (!event.pointerType || event.pointerType === 'mouse')) refreshModalPointerBounds();
+ };
+ const onModalPointerLeave = () => {
+ clearActiveEdge();
+ invalidateModalPointerBounds();
+ };
+ if (modal) historyScope.listen(modal, 'pointerenter', onModalPointerEnter);
+ if (modal) historyScope.listen(modal, 'pointermove', onModalPointerMove);
+ if (modal) historyScope.listen(modal, 'pointerleave', onModalPointerLeave);
+ historyScope.listen(window, 'resize', invalidateModalPointerBounds);
+ historyScope.listen(overlay, 'ldp-reader-window-change', invalidateModalPointerBounds);
+ historyScope.listen(overlay, 'ldp-reader-workspace-change', invalidateModalPointerBounds);
+ overlay._ldpSyncHistoryButtonVisibilityPreference = syncVisibilityPreference;
+ syncVisibilityPreference();
+ backButton.setAttribute('aria-keyshortcuts', 'ArrowLeft');
+ forwardButton.setAttribute('aria-keyshortcuts', 'ArrowRight');
+ const topicTitle = (targetTopicId) =>
+ readTopicHistory().find((item) => Number(item.topicId) === Number(targetTopicId))?.title ||
+ `帖子 #${targetTopicId}`;
+ const refreshVisibleTooltip = (button) => {
+ refreshTooltips(button);
+ };
+ const sync = () => {
+ // The reader shell can outlive a topic session; its prior navigation may still be busy.
+ backButton.disabled = false;
+ forwardButton.disabled = false;
+ const backTarget = navigation.back[0];
+ const forwardTarget = navigation.forward[0];
+ backButton.hidden = !backTarget;
+ forwardButton.hidden = !forwardTarget;
+ if (backEdge) backEdge.hidden = !backTarget;
+ if (forwardEdge) forwardEdge.hidden = !forwardTarget;
+ if (backTarget) {
+ const label = `上一条历史:${topicTitle(backTarget)}`;
+ setLabel(backButton, label);
+ }
+ if (forwardTarget) {
+ const label = `下一条历史:${topicTitle(forwardTarget)}`;
+ setLabel(forwardButton, label);
+ }
+ refreshVisibleTooltip(backButton);
+ refreshVisibleTooltip(forwardButton);
+ };
+ const navigate = (direction) => {
+ const backward = direction === 'back';
+ const source = backward ? navigation.back : navigation.forward;
+ const targetTopicId = source[0];
+ if (!targetTopicId) return;
+ backButton.disabled = true;
+ forwardButton.disabled = true;
+ const currentPostNumber = Number(
+ overlay.querySelector('.ldp-topic-timeline-track')?.getAttribute('aria-valuenow')
+ ) || 1;
+ const states = {
+ ...navigation.states,
+ [Number(topicId)]: captureReaderAnchorState(overlay, currentPostNumber),
+ };
+ const targetState = normalizeReaderAnchorState(states[targetTopicId]);
+ const nextNavigation = backward
+ ? { back: navigation.back.slice(1), forward: [Number(topicId), ...navigation.forward], states }
+ : { back: [Number(topicId), ...navigation.back], forward: navigation.forward.slice(1), states };
+ openModal(targetTopicId, {
+ targetPostNumber: targetState?.viewport.postNumber || 0,
+ restoreState: targetState || undefined,
+ historyNavigation: nextNavigation,
+ settleTargetJump: true,
+ });
+ };
+ const onBackHover = () => refreshVisibleTooltip(backButton);
+ const onForwardHover = () => refreshVisibleTooltip(forwardButton);
+ historyScope.listen(backButton, 'click', () => navigate('back'));
+ historyScope.listen(forwardButton, 'click', () => navigate('forward'));
+ historyScope.listen(backButton, 'pointerenter', onBackHover);
+ historyScope.listen(forwardButton, 'pointerenter', onForwardHover);
+ const onHistoryKeyDown = (event) => {
+ if (CURRENT_OVERLAY !== overlay || !overlay.isConnected || event.defaultPrevented || event.isComposing ||
+ event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || readerPortalQuery('.ldp-lightbox')) return;
+ const button = event.key === 'ArrowLeft' ? backButton : event.key === 'ArrowRight' ? forwardButton : null;
+ if (!button || button.hidden || button.disabled) return;
+ const target = event.target instanceof Element ? event.target : null;
+ if (target && target !== document.body && target !== PAGE_ROOT && !overlay.contains(target)) return;
+ if (target && (target.isContentEditable || target.closest('input,textarea,select,.ldp-topic-timeline-track'))) return;
+ consumeEvent(event);
+ button.click();
+ };
+ historyScope.listen(document, 'keydown', onHistoryKeyDown);
+ const refreshNavigationOrder = () => {
+ const nextNavigation = createReaderHistoryNavigation(topicId);
+ navigation.back = nextNavigation.back;
+ navigation.forward = nextNavigation.forward;
+ sync();
+ };
+ overlay._ldpRefreshReaderHistoryNavigation = refreshNavigationOrder;
+ sync();
+ return () => {
+ clearActiveEdge();
+ historyScope.destroy();
+ if (overlay._ldpRefreshReaderHistoryNavigation === refreshNavigationOrder)
+ delete overlay._ldpRefreshReaderHistoryNavigation;
+ if (overlay._ldpSyncHistoryButtonVisibilityPreference === syncVisibilityPreference)
+ delete overlay._ldpSyncHistoryButtonVisibilityPreference;
+ };
+ }
+
+ function readerQueueEntry(topicId) {
+ const safeTopicId = Number(topicId);
+ return safeTopicId > 0 ? READER_QUEUE_BY_TOPIC.get(String(safeTopicId)) || null : null;
+ }
+
+ function normalizeReaderQueueSurfaceState(state) {
+ const rawX = state?.x;
+ const rawY = state?.y;
+ const x = Number(rawX);
+ const y = Number(rawY);
+ const hasPosition = rawX != null && rawX !== '' && rawY != null && rawY !== '' &&
+ Number.isFinite(x) && Number.isFinite(y);
+ return {
+ x: hasPosition ? Math.max(0, Math.min(1, x)) : null,
+ y: hasPosition ? Math.max(0, Math.min(1, y)) : null,
+ dock: hasPosition && ['left', 'right'].includes(state?.dock)
+ ? state.dock
+ : '',
+ };
+ }
+
+ function readerQueueSurfaceStateCustomized(state = READER_QUEUE_SURFACE_STATE) {
+ return Number.isFinite(state?.x) && Number.isFinite(state?.y);
+ }
+
+ function flushReaderQueueState() {
+ if (READER_QUEUE_PERSIST_TIMER) clearTimeout(READER_QUEUE_PERSIST_TIMER);
+ READER_QUEUE_PERSIST_TIMER = 0;
+ if (READER_QUEUE_RESTORING) return;
+ const entries = READER_QUEUE_ENTRIES.map((entry) => {
+ const avatarSource = String(entry?.avatarSource || '');
+ return {
+ topicId: Number(entry?.topicId) || 0,
+ title: String(entry?.title || ''),
+ avatarTemplate: String(entry?.avatarTemplate || ''),
+ avatarSource: /^https?:\/\//i.test(avatarSource) ? avatarSource : '',
+ ownerUsername: String(entry?.ownerUsername || ''),
+ addedAt: Math.max(0, Number(entry?.addedAt) || 0),
+ pinned: entry && entry.pinned === true,
+ totalCount: Math.max(0, Number(entry?.totalCount) || 0),
+ urlPostNumber: Math.max(0, Number(entry?.urlPostNumber) || 0),
+ anchor: normalizeReaderAnchorState(entry?.anchor),
+ };
+ })
+ .filter((entry) => entry.topicId > 0);
+ const surface = normalizeReaderQueueSurfaceState(READER_QUEUE_SURFACE_STATE);
+ try {
+ if (entries.length || readerQueueSurfaceStateCustomized(surface))
+ localStorage.setItem(LDP_READER_QUEUE_KEY, JSON.stringify({ version: 1, entries, surface }));
+ else
+ localStorage.removeItem(LDP_READER_QUEUE_KEY);
+ } catch (error) {
+ console.warn('[LDP] 阅读队列保存失败', error?.name || 'storage-error');
+ }
+ }
+
+ function scheduleReaderQueueStatePersist() {
+ if (READER_QUEUE_RESTORING || READER_QUEUE_PERSIST_TIMER) return;
+ READER_QUEUE_PERSIST_TIMER = setTimeout(flushReaderQueueState, 250);
+ }
+
+ function restoreReaderQueueState() {
+ let storedEntries = [];
+ let storedSurface = null;
+ try {
+ const payload = JSON.parse(localStorage.getItem(LDP_READER_QUEUE_KEY) || 'null');
+ storedEntries = Array.isArray(payload) ? payload : (Array.isArray(payload?.entries) ? payload.entries : []);
+ storedSurface = !Array.isArray(payload) && payload && payload.surface;
+ } catch {
+ return;
+ }
+ READER_QUEUE_SURFACE_STATE = normalizeReaderQueueSurfaceState(storedSurface);
+ if (!storedEntries.length) return;
+ const restoredTopicIds = new Set();
+ READER_QUEUE_RESTORING = true;
+ try {
+ storedEntries.forEach((storedEntry) => {
+ const topicId = Number(storedEntry?.topicId);
+ if (!(topicId > 0) || restoredTopicIds.has(topicId)) return;
+ restoredTopicIds.add(topicId);
+ const entry = ensureReaderQueueEntry(topicId, {
+ title: storedEntry.title,
+ avatarTemplate: storedEntry.avatarTemplate,
+ avatarSource: storedEntry.avatarSource,
+ ownerUsername: storedEntry.ownerUsername,
+ }, { prefetch: false, silent: true });
+ if (!entry) return;
+ entry.addedAt = Math.max(0, Number(storedEntry.addedAt) || 0) || entry.addedAt;
+ entry.pinned = storedEntry.pinned === true;
+ entry.totalCount = Math.max(entry.totalCount, Number(storedEntry.totalCount) || 0);
+ entry.urlPostNumber = Math.max(0, Number(storedEntry.urlPostNumber) || 0);
+ entry.anchor = normalizeReaderAnchorState(storedEntry.anchor || storedEntry.viewport) || entry.anchor;
+ });
+ } finally {
+ READER_QUEUE_RESTORING = false;
+ }
+ }
+
+ function readerQueueTopicSummary(topicId, metadata = {}, topic = null) {
+ const safeTopicId = Number(topicId);
+ const cachedTopic = topic || getCachedTopicData(safeTopicId);
+ const historyEntry = readTopicHistory().find((item) => Number(item.topicId) === safeTopicId);
+ const posts = cachedTopic?.post_stream && Array.isArray(cachedTopic.post_stream.posts)
+ ? cachedTopic.post_stream.posts : [];
+ const firstPost = posts.find((post) => Number(post?.post_number) === 1);
+ return {
+ title: String(
+ metadata.title || cachedTopic?.title || historyEntry?.title ||
+ `帖子 #${safeTopicId}`
+ ).replace(/\s+/g, ' ').trim(),
+ avatarTemplate: String(
+ metadata.avatarTemplate ||
+ cachedTopic?.details && cachedTopic.details.created_by &&
+ cachedTopic.details.created_by.avatar_template ||
+ firstPost?.avatar_template ||
+ historyEntry?.avatarTemplate || ''
+ ),
+ avatarSource: String(metadata.avatarSource || ''),
+ ownerUsername: String(
+ metadata.ownerUsername ||
+ cachedTopic?.details && cachedTopic.details.created_by &&
+ cachedTopic.details.created_by.username ||
+ firstPost?.username ||
+ historyEntry?.ownerUsername || ''
+ ),
+ navMetadata: mergeTopicNavMetadata(
+ metadata.navMetadata || {},
+ cachedTopicNavMetadata(cachedTopic),
+ ),
+ totalCount: Math.max(
+ 0,
+ Number(cachedTopic && (cachedTopic.highest_post_number || cachedTopic.posts_count)) || 0,
+ Number(historyEntry?.postsCount) || 0,
+ ),
+ historyPostNumber: Math.max(0, Number(historyEntry?.postNumber) || 0),
+ urlPostNumber: Math.max(0, Number(metadata.urlPostNumber) || 0),
+ readPostNumbers: historyEntryReadPostNumbers(historyEntry),
+ };
+ }
+
+ function readerQueueMetadataFromLink(link, topicId) {
+ const source = link && link.nodeType === Node.ELEMENT_NODE ? link : null;
+ const card = source?.closest(
+ 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result,.category-topic-link'
+ );
+ const titleLink = card?.querySelector(
+ 'a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]'
+ );
+ const avatar = card?.querySelector(':is(.posters,.topic-poster) img.avatar,img.avatar');
+ const ownerNode = card?.querySelector('[data-user-card]');
+ const route = extractTopicRouteFromUrl(titleLink && (titleLink.getAttribute('href') || titleLink.href));
+ return {
+ title: String(
+ source && source.dataset.topicTitle ||
+ titleLink && (titleLink.dataset.topicTitle || titleLink.textContent) ||
+ source?.textContent || `帖子 #${topicId}`
+ ).replace(/\s+/g, ' ').trim(),
+ avatarTemplate: String(avatar?.dataset.ldpAvatarOriginalTemplate || avatar?.dataset.ldpAvatarTemplate || avatar?.dataset.avatarTemplate || ''),
+ avatarSource: String(avatar && (avatar.currentSrc || avatar.src) || ''),
+ ownerUsername: String(ownerNode && ownerNode.dataset.userCard || ''),
+ navMetadata: readTopicNavMetadata(source, topicId),
+ urlPostNumber: Math.max(0, Number(route?.postNumber) || 0),
+ };
+ }
+
+ function readerQueueLoadedCounts(topic) {
+ const postStream = topicPostStream(topic);
+ const stream = Array.isArray(postStream.stream) ? postStream.stream.filter(Boolean) : [];
+ const streamIds = new Set(stream.map(Number).filter(Boolean));
+ const loadedIds = new Set((Array.isArray(postStream.posts) ? postStream.posts : [])
+ .map((post) => Number(post?.id)).filter((postId) => postId && (!streamIds.size || streamIds.has(postId))));
+ return {
+ total: stream.length || Math.max(0, Number(topic?.posts_count) || 0),
+ loaded: loadedIds.size,
+ };
+ }
+
+ function syncReaderQueueReadHistory(entries) {
+ const historyByTopic = new Map((Array.isArray(entries) ? entries : [])
+ .map((historyEntry) => [String(Number(historyEntry?.topicId) || 0), historyEntry]));
+ let changed = false;
+ READER_QUEUE_ENTRIES.forEach((entry) => {
+ const historyEntry = historyByTopic.get(String(entry.topicId));
+ const nextReadPostNumbers = historyEntry
+ ? new Set([...entry.seenPostNumbers, ...historyEntryReadPostNumbers(historyEntry)])
+ : new Set();
+ if (nextReadPostNumbers.size !== entry.seenPostNumbers.size ||
+ [...nextReadPostNumbers].some((postNumber) => !entry.seenPostNumbers.has(postNumber))) {
+ entry.seenPostNumbers = nextReadPostNumbers;
+ changed = true;
+ }
+ if (!historyEntry && entry.progressPersistTimer) {
+ clearTimeout(entry.progressPersistTimer);
+ entry.progressPersistTimer = 0;
+ }
+ });
+ if (changed) syncReaderQueueSurfaces();
+ }
+
+ function persistReaderQueueReadProgress(entry) {
+ if (!entry) return;
+ const readPostNumbers = normalizeReadPostNumbers(entry.seenPostNumbers);
+ if (!readPostNumbers.length) return;
+ const history = readTopicHistory();
+ const previousEntry = history.find((item) => Number(item.topicId) === Number(entry.topicId));
+ const previousReadPostNumbers = historyEntryReadPostNumbers(previousEntry);
+ const cumulativeReadPostNumbers = normalizeReadPostNumbers([
+ ...previousReadPostNumbers,
+ ...readPostNumbers,
+ ]);
+ const postsCount = Math.max(
+ cumulativeReadPostNumbers[cumulativeReadPostNumbers.length - 1] || 0,
+ Number(entry.totalCount) || 0,
+ Number(previousEntry?.postsCount) || 0,
+ );
+ if (cumulativeReadPostNumbers.length === previousReadPostNumbers.length &&
+ Number(previousEntry?.postsCount) >= postsCount) return;
+ const now = Date.now();
+ const nextEntry = {
+ ...(previousEntry || {}),
+ topicId: Number(entry.topicId),
+ title: String(entry.title || previousEntry?.title || `帖子 #${entry.topicId}`),
+ postsCount,
+ avatarTemplate: String(entry.avatarTemplate || previousEntry?.avatarTemplate || ''),
+ ownerUsername: String(entry.ownerUsername || previousEntry?.ownerUsername || ''),
+ postNumber: Math.max(
+ 1,
+ Number(previousEntry?.postNumber) ||
+ Number(entry.anchor?.viewport.postNumber) ||
+ cumulativeReadPostNumbers[cumulativeReadPostNumbers.length - 1] || 1,
+ ),
+ readPostNumbers: cumulativeReadPostNumbers,
+ firstViewedAt: historyEntryFirstViewedAt(previousEntry) || now,
+ viewedAt: Number(previousEntry?.viewedAt) || now,
+ };
+ delete nextEntry.readPostNumber;
+ writeTopicHistory([
+ nextEntry,
+ ...history.filter((item) => Number(item.topicId) !== Number(entry.topicId)),
+ ]);
+ }
+
+ function scheduleReaderQueueReadProgressPersist(entry) {
+ if (!entry || entry.progressPersistTimer) return;
+ entry.progressPersistTimer = setTimeout(() => {
+ entry.progressPersistTimer = 0;
+ persistReaderQueueReadProgress(entry);
+ }, 500);
+ }
+
+ function flushReaderQueueReadProgress(entry) {
+ if (!entry) return;
+ if (entry.progressPersistTimer) clearTimeout(entry.progressPersistTimer);
+ entry.progressPersistTimer = 0;
+ persistReaderQueueReadProgress(entry);
+ }
+
+ function markReaderQueuePostRead(entry, postNumber) {
+ const nextPostNumber = Math.max(0, Math.floor(Number(postNumber) || 0));
+ if (!entry || !nextPostNumber || entry.seenPostNumbers.has(nextPostNumber)) return false;
+ entry.seenPostNumbers.add(nextPostNumber);
+ scheduleReaderQueueReadProgressPersist(entry);
+ return true;
+ }
+
+ function readerQueueProgress(entry) {
+ const total = Math.max(0, Number(entry?.totalCount) || 0);
+ if (!entry || !total) return 0;
+ return Math.max(0, Math.min(100, entry.seenPostNumbers.size / total * 100));
+ }
+
+ function readerQueueStartPostNumber(entry) {
+ return !entry || PREFS.openTopicsAtFirstPost === true ? 1 : Math.max(
+ 1, Number(entry.urlPostNumber) || Number(entry.anchor?.viewport.postNumber) || 1);
+ }
+
+ function readerQueueStatusText(entry) {
+ const total = Math.max(0, Number(entry.totalCount) || 0);
+ const currentFloor = Math.max(0, Number(entry.anchor?.viewport.postNumber) || 0);
+ const readCount = entry.seenPostNumbers.size;
+ const progress = Math.round(readerQueueProgress(entry));
+ const pinned = entry.pinned ? '已固定 · ' : '';
+ if (String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID)
+ return `${pinned}阅读进度 ${progress}% · 已读 ${readCount}/${total || '?'}` +
+ `${currentFloor ? ` · 当前 #${currentFloor}` : ''}`;
+ const preloadDetails = `正文 ${Math.max(0, Number(entry.loadedCount) || 0)}/${total || '?'}` +
+ `${entry.nestedTotalCount > 0
+ ? ` · 二级回复 ${Math.max(0, Number(entry.nestedLoadedCount) || 0)}/${entry.nestedTotalCount}`
+ : ''}` +
+ `${entry.mediaTotalCount > 0
+ ? ` · 图片 ${Math.max(0, Number(entry.mediaLoadedCount) || 0)}/${entry.mediaTotalCount}`
+ : ''}`;
+ if (entry.loadState === 'ready')
+ return `${pinned}已预加载 · ${preloadDetails} · 阅读进度 ${progress}%`;
+ if (entry.loadState === 'loading')
+ return `${pinned}正在预加载 · ${preloadDetails} · 阅读进度 ${progress}%`;
+ if (entry.loadState === 'partial')
+ return `${pinned}已分层预加载 · ${preloadDetails} · 阅读进度 ${progress}%`;
+ if (entry.loadState === 'error') return `${pinned}预加载失败 · 阅读进度 ${progress}%,点击重试`;
+ return `${pinned}等待预加载 · 阅读进度 ${progress}%`;
+ }
+
+ function readerQueueAvatarHtml(entry) {
+ const fallback = String(entry.ownerUsername || entry.title || '?').trim().slice(0, 1).toUpperCase() || '?';
+ const avatar = persistentAvatarHtml(
+ entry.avatarTemplate || entry.avatarSource,
+ 64,
+ 'ldp-reader-queue-avatar-image',
+ fallback,
+ );
+ if (avatar) return avatar;
+ return `
${esc(fallback)}`;
+ }
+
+ function syncReaderQueueProgressSurface(entry) {
+ if (!entry || !CURRENT_OVERLAY) return;
+ const rail = CURRENT_OVERLAY.querySelector('.ldp-reader-queue');
+ if (!rail) return;
+ const topicId = String(Number(entry.topicId));
+ const progress = readerQueueProgress(entry);
+ const status = readerQueueStatusText(entry);
+ const bubble = rail.querySelector(`.ldp-reader-queue-bubble[data-reader-queue-topic-id="${topicId}"]`);
+ if (bubble) {
+ ['queued', 'loading', 'partial', 'ready', 'error']
+ .forEach((state) => bubble.classList.toggle(`is-${state}`, entry.loadState === state));
+ const progressValue = `${progress * 3.6}deg`;
+ if (bubble.style.getPropertyValue('--ldp-reader-queue-progress') !== progressValue)
+ bubble.style.setProperty('--ldp-reader-queue-progress', progressValue);
+ bubble.classList.toggle('is-progress-complete', progress >= 100);
+ setLabel(bubble, `${entry.title},${status}`);
+ const tooltipLabel = `${entry.title} · ${status}`;
+ if (bubble.dataset.ldpTooltipLabel !== tooltipLabel) bubble.dataset.ldpTooltipLabel = tooltipLabel;
+ }
+ const row = rail.querySelector(`.ldp-reader-queue-row[data-reader-queue-topic-id="${topicId}"]`);
+ if (row) {
+ const rowProgress = row.querySelector('.ldp-reader-queue-row-progress');
+ const progressValue = `${progress * 3.6}deg`;
+ if (rowProgress?.style.getPropertyValue('--ldp-reader-queue-progress') !== progressValue)
+ rowProgress?.style.setProperty('--ldp-reader-queue-progress', progressValue);
+ rowProgress?.classList.toggle('is-progress-complete', progress >= 100);
+ const titleNode = row.querySelector('.ldp-reader-queue-row-copy strong');
+ if (titleNode && titleNode.textContent !== entry.title) titleNode.textContent = entry.title;
+ const statusNode = row.querySelector('.ldp-reader-queue-row-copy small');
+ if (statusNode && statusNode.textContent !== status) statusNode.textContent = status;
+ }
+ }
+
+ function syncReaderQueueScrollHint(rail) {
+ const bubbles = rail?.querySelector('.ldp-reader-queue-bubbles');
+ const hint = rail?.querySelector('.ldp-reader-queue-scroll-hint');
+ if (!bubbles || !hint) return;
+ const maxScroll = Math.max(0, bubbles.scrollHeight - bubbles.clientHeight);
+ const hasOverflow = maxScroll >= 2;
+ hint.hidden = !hasOverflow;
+ if (!hasOverflow) return;
+ const scrollUp = bubbles.scrollTop >= maxScroll - 2;
+ hint.classList.toggle('is-up', scrollUp);
+ hint.dataset.scrollDirection = scrollUp ? '-1' : '1';
+ setLabel(hint, scrollUp ? '回到队列上方头像' : '显示下方更多队列头像');
+ }
+
+ function syncReaderQueueToggleState(rail) {
+ const toggle = rail?.querySelector('.ldp-reader-queue-toggle');
+ const count = toggle?.querySelector('b');
+ if (!toggle || !count) return;
+ const previewExpanded = !rail.classList.contains('is-preview-collapsed');
+ setPressed(toggle, previewExpanded);
+ setLabel(toggle, READER_QUEUE_ENTRIES.length ? `${previewExpanded ? '收纳' : '展开'}队列头像预览;拖动可移动,贴边可隐藏;悬停显示队列详情,共 ${READER_QUEUE_ENTRIES.length} 篇` : '关闭空阅读队列入口');
+ }
+
+ function readerQueueSurfaceBounds(rail) {
+ const modal = rail?.closest('.ldp-modal');
+ if (!modal) return null;
+ const modalRect = modal.getBoundingClientRect();
+ const headerRect = modal.querySelector(':scope > .ldp-header')?.getBoundingClientRect();
+ const maxTop = Math.max(0, modal.clientHeight - rail.offsetHeight);
+ return {
+ modal,
+ modalRect,
+ maxLeft: Math.max(0, modal.clientWidth - rail.offsetWidth),
+ maxTop,
+ minTop: Math.max(0, Math.min(maxTop, (headerRect?.bottom || modalRect.top) - modalRect.top)),
+ };
+ }
+
+ function readerQueueTopAvoidingTopicRail(rail, bounds, requestedTop) {
+ const overlay = rail?.closest('.ldp-overlay');
+ const topicRail = overlay?.classList.contains('ldp-fullpage') && bounds?.modal?.clientWidth > 700
+ ? overlay.querySelector('.ldp-topic-action-rail:not([hidden])')
+ : null;
+ if (!topicRail) return requestedTop;
+ const topicRailRect = topicRail.getBoundingClientRect();
+ const topicTop = topicRailRect.top - bounds.modalRect.top;
+ const topicBottom = topicRailRect.bottom - bounds.modalRect.top;
+ const queueHeight = rail.offsetHeight;
+ const gap = Math.max(12, Math.round(
+ (rail.querySelector('.ldp-reader-queue-toggle')?.offsetHeight || 0) / 2
+ ));
+ const clampTop = (top) => Math.max(bounds.minTop, Math.min(bounds.maxTop, top));
+ const isClear = (top) => top + queueHeight + gap <= topicTop || top >= topicBottom + gap;
+ const targetTop = clampTop(requestedTop);
+ if (isClear(targetTop)) return targetTop;
+ const defaultTop = clampTop(Math.max(bounds.minTop, bounds.maxTop * .12));
+ if (isClear(defaultTop)) return defaultTop;
+ const candidates = [
+ topicTop - gap - queueHeight,
+ topicBottom + gap,
+ ].filter((top) => top >= bounds.minTop && top <= bounds.maxTop);
+ return candidates.length
+ ? candidates.reduce((closest, top) =>
+ Math.abs(top - requestedTop) < Math.abs(closest - requestedTop) ? top : closest
+ )
+ : targetTop;
+ }
+
+ function applyReaderQueuePanelCollision(rail) {
+ const panel = rail?.querySelector('.ldp-reader-queue-panel');
+ const modal = rail?.closest('.ldp-modal');
+ const overlay = rail?.closest('.ldp-overlay');
+ if (!panel || panel.hidden || !modal || !overlay) return;
+ const gap = 10;
+ const modalRect = modal.getBoundingClientRect();
+ const railRect = rail.getBoundingClientRect();
+ const panelWidth = panel.offsetWidth, panelHeight = panel.offsetHeight;
+ if (!(panelWidth > 0) || !(panelHeight > 0)) return;
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
+ const minLeft = modalRect.left + gap, maxLeft = Math.max(minLeft, modalRect.right - gap - panelWidth);
+ const minTop = modalRect.top + gap, maxTop = Math.max(minTop, modalRect.bottom - gap - panelHeight);
+ const leftSpace = railRect.left - minLeft - gap;
+ const rightSpace = modalRect.right - gap - railRect.right - gap;
+ const openLeft = rail.classList.contains('is-docked-right') ||
+ (rightSpace < panelWidth && leftSpace > rightSpace);
+ const left = clamp(openLeft ? railRect.left - gap - panelWidth : railRect.right + gap, minLeft, maxLeft);
+ let top = clamp(railRect.top, minTop, maxTop);
+ const blockers = [...overlay.querySelectorAll(
+ ':scope > .ldp-reader-window-capsule,.ldp-modal > .ldp-header,.ldp-rate-limit-notice:not([hidden])'
+ )].filter((element) => getComputedStyle(element).display !== 'none')
+ .map((element) => element.getBoundingClientRect()).concat(railRect)
+ .sort((first, second) => first.top - second.top);
+ for (const rect of blockers) {
+ if (left >= rect.right + gap || left + panelWidth <= rect.left - gap ||
+ top >= rect.bottom + gap || top + panelHeight <= rect.top - gap) continue;
+ const below = clamp(rect.bottom + gap, minTop, maxTop);
+ top = below + panelHeight <= modalRect.bottom - gap
+ ? below : clamp(rect.top - gap - panelHeight, minTop, maxTop);
+ }
+ panel.classList.add('is-collision-positioned');
+ panel.style.left = `${Math.round(left - railRect.left)}px`;
+ panel.style.top = `${Math.round(top - railRect.top)}px`;
+ }
+
+ function applyReaderQueueSurfacePosition(rail) {
+ if (!rail) return;
+ const bounds = readerQueueSurfaceBounds(rail);
+ const modal = bounds?.modal;
+ const overlay = rail.closest('.ldp-overlay');
+ const state = normalizeReaderQueueSurfaceState(READER_QUEUE_SURFACE_STATE);
+ const customized = readerQueueSurfaceStateCustomized(state);
+ const modalRect = bounds?.modalRect;
+ const topicRailToggle = overlay?.classList.contains('ldp-fullpage') && modal?.clientWidth > 700
+ ? overlay.querySelector('.ldp-topic-action-rail:not([hidden]) .ldp-topic-action-rail-toggle')
+ : null;
+ const topicRailToggleRect = topicRailToggle?.getBoundingClientRect();
+ const fullPageAlignedLeft = modalRect && topicRailToggleRect?.width
+ ? Math.max(0, Math.min(
+ modal.clientWidth - rail.offsetWidth,
+ topicRailToggleRect.left + topicRailToggleRect.width / 2 -
+ modalRect.left - rail.offsetWidth / 2,
+ ))
+ : null;
+ const fullPageXAligned = Number.isFinite(fullPageAlignedLeft);
+ rail.classList.toggle('is-docked-left', customized && !fullPageXAligned && state.dock === 'left');
+ rail.classList.toggle('is-docked-right', customized && !fullPageXAligned && state.dock === 'right');
+ const toggleRect = rail.querySelector('.ldp-reader-queue-toggle')?.getBoundingClientRect();
+ const bubbles = rail.querySelector('.ldp-reader-queue-bubbles');
+ if (modalRect && toggleRect && bubbles) {
+ const spaceBelow = modalRect.bottom - toggleRect.bottom;
+ const previewHeight = Math.min(bubbles.scrollHeight,
+ Number.parseFloat(getComputedStyle(bubbles).maxHeight) || bubbles.scrollHeight);
+ rail.classList.toggle('is-preview-reversed',
+ !rail.classList.contains('is-preview-collapsed') &&
+ spaceBelow < previewHeight && toggleRect.top - modalRect.top > spaceBelow
+ );
+ }
+ if (!customized || !modal || rail.hidden) {
+ if (!customized) {
+ rail.style.removeProperty('left');
+ rail.style.removeProperty('top');
+ rail.style.removeProperty('bottom');
+ rail.classList.remove('is-runtime-positioned');
+ if (modal && modal.clientWidth > 700) {
+ const readerMain = modal.querySelector('.ldp-reader-main');
+ const leftTrack = readerMain ? Number.parseFloat(getComputedStyle(readerMain).gridTemplateColumns) : rail.offsetWidth;
+ const maxTop = bounds.maxTop;
+ const defaultLeft = fullPageXAligned
+ ? fullPageAlignedLeft
+ : (Math.max(leftTrack || 0, rail.offsetWidth) - rail.offsetWidth) / 2;
+ rail.style.left = `${Math.round(Math.max(0, Math.min(
+ modal.clientWidth - rail.offsetWidth, defaultLeft,
+ )))}px`;
+ rail.style.top = `${Math.round(readerQueueTopAvoidingTopicRail(
+ rail, bounds, Math.max(bounds.minTop, maxTop * .12),
+ ))}px`;
+ rail.classList.add('is-runtime-positioned');
+ }
+ }
+ applyReaderQueuePanelCollision(rail);
+ return;
+ }
+ rail.classList.add('is-runtime-positioned');
+ rail.style.left = `${Math.round(fullPageXAligned ? fullPageAlignedLeft : bounds.maxLeft * state.x)}px`;
+ rail.style.top = `${Math.round(readerQueueTopAvoidingTopicRail(
+ rail, bounds, Math.max(bounds.minTop, bounds.maxTop * state.y),
+ ))}px`;
+ applyReaderQueuePanelCollision(rail);
+ }
+
+ function renderReaderQueueSurface(overlay) {
+ const rail = overlay?.querySelector('.ldp-reader-queue');
+ if (!rail) return;
+ const toggle = rail.querySelector('.ldp-reader-queue-toggle');
+ const count = toggle?.querySelector('b');
+ const bubbles = rail.querySelector('.ldp-reader-queue-bubbles');
+ const scrollHint = rail.querySelector('.ldp-reader-queue-scroll-hint');
+ const panel = rail.querySelector('.ldp-reader-queue-panel');
+ const list = rail.querySelector('.ldp-reader-queue-list');
+ const panelCount = rail.querySelector('.ldp-reader-queue-panel-count');
+ const clear = rail.querySelector('.ldp-reader-queue-clear');
+ const panelWasOpen = panel && !panel.hidden;
+ const previousActiveTopicId = rail.dataset.readerQueueActiveTopicId || '';
+ const previousBubbleScrollTop = bubbles ? bubbles.scrollTop : 0;
+ rail.hidden = !READER_QUEUE_ENTRIES.length && !PREFS.readerQueueAlwaysVisibleWhenEmpty; rail.classList.toggle('is-empty', !READER_QUEUE_ENTRIES.length);
+ if (!toggle || !count || !bubbles || !scrollHint || !panel || !list || !panelCount || !clear) return;
+ count.textContent = READER_QUEUE_ENTRIES.length ? String(READER_QUEUE_ENTRIES.length) : '×';
+ syncReaderQueueToggleState(rail);
+ panelCount.textContent = `${READER_QUEUE_ENTRIES.length} 篇`;
+ clear.disabled = !READER_QUEUE_ENTRIES.some((entry) => !entry.pinned);
+ releasePersistentAvatarImages(bubbles);
+ bubbles.innerHTML = READER_QUEUE_ENTRIES.map((entry) => {
+ const active = String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID;
+ const progress = readerQueueProgress(entry);
+ return `
+
+ ${entry.pinned ? `${icon('pin')}` : ''}
+
+ `;
+ }).join('');
+ releasePersistentAvatarImages(list);
+ list.innerHTML = READER_QUEUE_ENTRIES.map((entry) => {
+ const active = String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID;
+ const progress = readerQueueProgress(entry);
+ return `
+
+ ${readerQueueAvatarHtml(entry)}
+
+
+ ${esc(entry.title)}
+ ${esc(readerQueueStatusText(entry))}
+
+
+ ${entry.loadState === 'error'
+ ? ``
+ : ''}
+
+
+
+
`;
+ }).join('');
+ hydratePersistentAvatarImages(bubbles, POST_REQUEST_PRIORITY.auxiliary);
+ hydratePersistentAvatarImages(list, POST_REQUEST_PRIORITY.auxiliary);
+ const activeChanged = previousActiveTopicId !== READER_QUEUE_ACTIVE_TOPIC_ID;
+ rail.dataset.readerQueueActiveTopicId = READER_QUEUE_ACTIVE_TOPIC_ID;
+ if (activeChanged) {
+ const activeBubble = bubbles.querySelector('.ldp-reader-queue-bubble.is-active');
+ if (activeBubble) {
+ const top = activeBubble.offsetTop;
+ const bottom = top + activeBubble.offsetHeight;
+ if (top < bubbles.scrollTop) bubbles.scrollTop = Math.max(0, top - 4);
+ else if (bottom > bubbles.scrollTop + bubbles.clientHeight) {
+ bubbles.scrollTop = Math.max(0, bottom - bubbles.clientHeight + 4);
+ }
+ }
+ } else {
+ bubbles.scrollTop = Math.min(previousBubbleScrollTop, Math.max(0, bubbles.scrollHeight - bubbles.clientHeight));
+ }
+ syncReaderQueueScrollHint(rail);
+ panel.hidden = !panelWasOpen;
+ setExpanded(toggle, panelWasOpen);
+ applyReaderQueueSurfacePosition(rail);
+ }
+
+ function syncReaderQueueSurfaces() {
+ document.querySelectorAll('.ldp-reader-queue-add[data-reader-queue-topic-id]').forEach((button) => {
+ const added = !!readerQueueEntry(button.dataset.readerQueueTopicId);
+ if (button.dataset.readerQueueAdded === String(added)) return;
+ syncReaderQueueAddButton(button, added);
+ });
+ if (isFunction(CURRENT_OVERLAY?._ldpSyncReaderQueue))
+ CURRENT_OVERLAY._ldpSyncReaderQueue();
+ }
+
+ function syncReaderQueueAddButton(button, added) {
+ const label = added ? '从阅读队列移除' : '加入阅读队列并后台预加载';
+ button.dataset.readerQueueAdded = String(added);
+ button.classList.toggle('is-added', added);
+ button.innerHTML = icon(added ? 'check' : 'plus');
+ setLabel(button, label);
+ button.setAttribute('title', label);
+ }
+
+ function updateReaderQueueEntryFromTopic(entry, topic, totalHint = 0, options = {}) {
+ if (!entry || !topic) return;
+ const summary = readerQueueTopicSummary(entry.topicId, entry, topic);
+ entry.title = summary.title;
+ entry.avatarTemplate = summary.avatarTemplate || entry.avatarTemplate;
+ entry.avatarSource = summary.avatarSource || entry.avatarSource;
+ entry.ownerUsername = summary.ownerUsername || entry.ownerUsername;
+ entry.navMetadata = summary.navMetadata;
+ entry.urlPostNumber = summary.urlPostNumber || entry.urlPostNumber || 0;
+ const counts = readerQueueLoadedCounts(topic);
+ entry.totalCount = Math.max(0, Number(totalHint) || counts.total || entry.totalCount || 0);
+ entry.loadedCount = Math.min(entry.totalCount || counts.loaded, counts.loaded);
+ if (entry.totalCount > 0 && entry.loadedCount >= entry.totalCount && entry.prefetchEnriched)
+ entry.loadState = 'ready';
+ else if (entry.loadState === 'ready') entry.loadState = 'queued';
+ entry.error = '';
+ scheduleReaderQueueStatePersist();
+ if (options.progressOnly === true) syncReaderQueueProgressSurface(entry);
+ else syncReaderQueueSurfaces();
+ }
+
+ function ensureReaderQueueEntry(topicId, metadata = {}, options = {}) {
+ const safeTopicId = Number(topicId);
+ if (!(safeTopicId > 0)) return null;
+ let entry = readerQueueEntry(safeTopicId);
+ const summary = readerQueueTopicSummary(safeTopicId, metadata);
+ if (!entry) {
+ entry = {
+ topicId: safeTopicId,
+ title: summary.title,
+ avatarTemplate: summary.avatarTemplate,
+ avatarSource: summary.avatarSource,
+ ownerUsername: summary.ownerUsername,
+ navMetadata: summary.navMetadata,
+ addedAt: Date.now(),
+ loadState: 'queued',
+ loadedCount: 0,
+ nestedLoadedCount: 0,
+ nestedTotalCount: 0,
+ mediaLoadedCount: 0,
+ mediaTotalCount: 0,
+ totalCount: summary.totalCount,
+ urlPostNumber: summary.urlPostNumber,
+ anchor: summary.historyPostNumber
+ ? normalizeReaderAnchorState(summary.historyPostNumber)
+ : null,
+ seenPostNumbers: new Set(summary.readPostNumbers),
+ progressPersistTimer: 0,
+ prefetchController: null,
+ prefetchEnriched: false,
+ switching: false,
+ pinned: false,
+ error: '',
+ };
+ READER_QUEUE_ENTRIES.push(entry);
+ READER_QUEUE_BY_TOPIC.set(String(safeTopicId), entry);
+ const cachedTopic = getCachedTopicData(safeTopicId);
+ if (cachedTopic) updateReaderQueueEntryFromTopic(entry, cachedTopic);
+ } else {
+ entry.title = summary.title || entry.title;
+ entry.avatarTemplate = summary.avatarTemplate || entry.avatarTemplate;
+ entry.avatarSource = summary.avatarSource || entry.avatarSource;
+ entry.ownerUsername = summary.ownerUsername || entry.ownerUsername;
+ entry.navMetadata = mergeTopicNavMetadata(summary.navMetadata, entry.navMetadata);
+ entry.urlPostNumber = summary.urlPostNumber || entry.urlPostNumber || 0;
+ entry.totalCount = Math.max(Number(entry.totalCount) || 0, summary.totalCount);
+ summary.readPostNumbers.forEach((postNumber) => entry.seenPostNumbers.add(postNumber));
+ if (!entry.anchor && summary.historyPostNumber)
+ entry.anchor = normalizeReaderAnchorState(summary.historyPostNumber);
+ if (entry.loadState === 'error' && options.retry === true) {
+ entry.loadState = 'queued';
+ entry.error = '';
+ }
+ }
+ scheduleReaderQueueStatePersist();
+ syncReaderQueueSurfaces();
+ if (options.prefetch !== false) scheduleReaderQueuePrefetch();
+ return entry;
+ }
+
+ function saveReaderQueueAnchor(topicId, state) {
+ const entry = readerQueueEntry(topicId);
+ const normalized = normalizeReaderAnchorState(state);
+ if (!entry || !normalized) return;
+ entry.anchor = normalized;
+ scheduleReaderQueueStatePersist();
+ syncReaderQueueSurfaces();
+ }
+
+ function activateReaderQueueEntry(entry) {
+ if (!entry) return;
+ READER_QUEUE_ACTIVE_TOPIC_ID = String(entry.topicId);
+ if (entry.prefetchController) {
+ entry.prefetchController.abort();
+ entry.prefetchController = null;
+ if (entry.loadState === 'loading') entry.loadState = 'queued';
+ }
+ syncReaderQueueSurfaces();
+ }
+
+ async function switchReaderQueueTopic(topicId) {
+ const entry = readerQueueEntry(topicId);
+ if (!entry || entry.switching) return;
+ if (String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID &&
+ CURRENT_OVERLAY && Number(CURRENT_OVERLAY.dataset.topicId) === entry.topicId) {
+ const panel = CURRENT_OVERLAY.querySelector('.ldp-reader-queue-panel');
+ const toggle = CURRENT_OVERLAY.querySelector('.ldp-reader-queue-toggle');
+ if (panel) panel.hidden = true;
+ if (toggle) setExpanded(toggle, false);
+ return;
+ }
+ const activeTopicId = Number(CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId);
+ if (activeTopicId > 0)
+ saveReaderQueueAnchor(activeTopicId, captureReaderAnchorState(CURRENT_OVERLAY));
+ entry.switching = true;
+ syncReaderQueueSurfaces();
+ try {
+ const startPostNumber = readerQueueStartPostNumber(entry);
+ const explicitStart = PREFS.openTopicsAtFirstPost === true || entry.urlPostNumber > 0;
+ await openModal(entry.topicId, {
+ targetPostNumber: startPostNumber,
+ restoreViewport: explicitStart
+ ? { postNumber: startPostNumber, postOffset: 0 }
+ : undefined,
+ restoreState: explicitStart ? undefined : entry.anchor || undefined,
+ topicNavMetadata: entry.navMetadata,
+ readerQueueMetadata: entry,
+ settleTargetJump: true,
+ });
+ } finally {
+ entry.switching = false;
+ syncReaderQueueSurfaces();
+ }
+ }
+
+ function removeReaderQueueEntry(topicId) {
+ const entry = readerQueueEntry(topicId);
+ if (!entry) return false;
+ const index = READER_QUEUE_ENTRIES.indexOf(entry);
+ const active = String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID;
+ flushReaderQueueReadProgress(entry);
+ if (entry.prefetchController) entry.prefetchController.abort();
+ READER_QUEUE_BY_TOPIC.delete(String(entry.topicId));
+ if (index >= 0) READER_QUEUE_ENTRIES.splice(index, 1);
+ scheduleReaderQueueStatePersist();
+ if (active) READER_QUEUE_ACTIVE_TOPIC_ID = '';
+ syncReaderQueueSurfaces();
+ scheduleReaderQueuePrefetch();
+ return true;
+ }
+
+ function toggleReaderQueuePinned(topicId) {
+ const entry = readerQueueEntry(topicId);
+ if (!entry) return false;
+ entry.pinned = !entry.pinned;
+ scheduleReaderQueueStatePersist();
+ syncReaderQueueSurfaces();
+ showSelectionToast(entry.pinned ? '已固定,离开后仍保留在队列' : '已取消固定,离开后将自动出队');
+ return true;
+ }
+
+ function autoDequeueReaderQueueEntry(topicId) {
+ const entry = readerQueueEntry(topicId);
+ if (!entry || entry.pinned) return false;
+ return removeReaderQueueEntry(entry.topicId);
+ }
+
+ function clearReaderQueueEntries(ctx) {
+ const retained = READER_QUEUE_ENTRIES.filter((entry) => entry.pinned);
+ const retainedEntries = new Set(retained);
+ const removable = READER_QUEUE_ENTRIES.filter((entry) => !retainedEntries.has(entry));
+ if (!removable.length) {
+ showSelectionToast('队列中没有可清理的未固定文章');
+ return false;
+ }
+ const removableTopicIds = new Set(removable.map((entry) => String(entry.topicId)));
+ return !!openReaderConfirmDialog(ctx, {
+ title: '清理阅读队列?',
+ message: retained.length
+ ? `将移除 ${removable.length} 篇未固定文章,保留 ${retained.length} 篇固定文章。`
+ : `将移除队列中的 ${removable.length} 篇文章。`,
+ note: '当前阅读不会关闭,正文、头像和图片缓存不会删除。',
+ icon: 'trash',
+ confirmLabel: `清理 ${removable.length} 篇`,
+ onConfirm: () => {
+ const confirmedRemovable = READER_QUEUE_ENTRIES.filter((entry) =>
+ removableTopicIds.has(String(entry.topicId)) && !entry.pinned
+ );
+ if (!confirmedRemovable.length) {
+ showSelectionToast('队列中没有可清理的未固定文章');
+ return;
+ }
+ const confirmedRemovableEntries = new Set(confirmedRemovable);
+ confirmedRemovable.forEach((entry) => {
+ flushReaderQueueReadProgress(entry);
+ if (entry.prefetchController) entry.prefetchController.abort();
+ READER_QUEUE_BY_TOPIC.delete(String(entry.topicId));
+ });
+ const latestRetained = READER_QUEUE_ENTRIES.filter((entry) => !confirmedRemovableEntries.has(entry));
+ READER_QUEUE_ENTRIES.splice(0, READER_QUEUE_ENTRIES.length, ...latestRetained);
+ if (!readerQueueEntry(READER_QUEUE_ACTIVE_TOPIC_ID)) READER_QUEUE_ACTIVE_TOPIC_ID = '';
+ scheduleReaderQueueStatePersist();
+ syncReaderQueueSurfaces();
+ scheduleReaderQueuePrefetch();
+ showSelectionToast(`已从队列清理 ${confirmedRemovable.length} 篇文章`);
+ },
+ });
+ }
+
+ function readerQueuePrefetchStream(entry, topic) {
+ const postStream = topicPostStream(topic);
+ const stream = Array.isArray(postStream.stream) ? postStream.stream.filter(Boolean) : [];
+ if (stream.length <= READER_QUEUE_PREFETCH_MAX_POSTS) return stream;
+ const posts = Array.isArray(postStream.posts) ? postStream.posts : [];
+ const startPostNumber = readerQueueStartPostNumber(entry);
+ const targetPost = posts.find((post) => +(post?.post_number || 0) === startPostNumber);
+ const targetPostId = +(targetPost?.id || 0);
+ const foundIndex = targetPostId ? stream.indexOf(targetPostId) : -1;
+ const targetIndex = foundIndex >= 0
+ ? foundIndex
+ : Math.max(0, Math.min(stream.length - 1, startPostNumber - 1));
+ if (PREFS.openTopicsAtFirstPost !== true && +(entry?.urlPostNumber || 0) > 0) {
+ const before = Math.min(targetIndex, READER_QUEUE_PREFETCH_URL_BEFORE_POSTS);
+ let start = Math.max(0, targetIndex - before);
+ let end = Math.min(
+ stream.length,
+ targetIndex + READER_QUEUE_PREFETCH_MAX_POSTS - before
+ );
+ start = Math.max(0, end - READER_QUEUE_PREFETCH_MAX_POSTS);
+ end = Math.min(stream.length, start + READER_QUEUE_PREFETCH_MAX_POSTS);
+ return stream.slice(start, end);
+ }
+ if (targetIndex === 0) return stream.slice(0, READER_QUEUE_PREFETCH_MAX_POSTS);
+ const headCount = Math.min(READER_QUEUE_PREFETCH_HEAD_POSTS, READER_QUEUE_PREFETCH_MAX_POSTS);
+ const localBudget = READER_QUEUE_PREFETCH_MAX_POSTS - headCount;
+ const before = Math.min(targetIndex, READER_QUEUE_PREFETCH_BEFORE_POSTS);
+ let localStart = Math.max(0, targetIndex - before);
+ let localEnd = Math.min(stream.length, localStart + localBudget);
+ localStart = Math.max(0, localEnd - localBudget);
+ const selected = new Set(stream.slice(0, headCount));
+ stream.slice(localStart, localEnd).forEach((postId) => selected.add(postId));
+ if (selected.size < READER_QUEUE_PREFETCH_MAX_POSTS) {
+ stream.slice(localEnd, localEnd + READER_QUEUE_PREFETCH_MAX_POSTS - selected.size)
+ .forEach((postId) => selected.add(postId));
+ }
+ return [...selected].slice(0, READER_QUEUE_PREFETCH_MAX_POSTS);
+ }
+
+ function mergeReaderQueuePostPayload(topic, payload, parentPostNumber = 0) {
+ const topicPosts = topic?.post_stream && Array.isArray(topic.post_stream.posts)
+ ? topic.post_stream.posts : null;
+ if (!topicPosts) return 0;
+ const indexes = new Map(topicPosts.map((post, index) => [+(post?.id || 0), index]));
+ let merged = 0;
+ postsFromPayload(payload).forEach((incoming) => {
+ const postId = +(incoming?.id || 0);
+ if (!postId) return;
+ if (parentPostNumber > 0 && !(+(incoming.reply_to_post_number || 0) > 0)) {
+ incoming.reply_to_post_number = +parentPostNumber;
+ }
+ const index = indexes.get(postId);
+ if (index == null) {
+ indexes.set(postId, topicPosts.length);
+ topicPosts.push(incoming);
+ } else {
+ Object.assign(topicPosts[index], incoming);
+ }
+ merged++;
+ });
+ return merged;
+ }
+
+ function readerQueueDirectReplies(topic, parentPostNumber) {
+ const parent = +parentPostNumber;
+ const postStream = topicPostStream(topic);
+ return (Array.isArray(postStream.posts) ? postStream.posts : [])
+ .filter((reply) => +(reply?.reply_to_post_number || 0) === parent);
+ }
+
+ async function prefetchReaderQueueNestedReplies(entry, topic, controller, scopePosts) {
+ const collected = new Map();
+ const parents = [];
+ const parentIds = new Set();
+ const addPost = (post) => {
+ const postId = +(post?.id || 0);
+ if (!postId) return;
+ collected.set(postId, post);
+ if (!(+(post.reply_count || 0) > 0) || parentIds.has(postId)) return;
+ parentIds.add(postId);
+ parents.push(post);
+ };
+ (Array.isArray(scopePosts) ? scopePosts : []).forEach(addPost);
+ const syncNestedProgress = () => {
+ entry.nestedTotalCount = parents.reduce(
+ (total, post) => total + Math.max(0, +(post.reply_count || 0)),
+ 0
+ );
+ entry.nestedLoadedCount = parents.reduce(
+ (total, post) => total + readerQueueDirectReplies(topic, post.post_number).length,
+ 0
+ );
+ syncReaderQueueProgressSurface(entry);
+ };
+ syncNestedProgress();
+ for (let parentIndex = 0; parentIndex < parents.length; parentIndex++) {
+ const parent = parents[parentIndex];
+ if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
+ const expected = Math.max(0, +(parent.reply_count || 0));
+ let known = readerQueueDirectReplies(topic, parent.post_number);
+ known.forEach(addPost);
+ syncNestedProgress();
+ if (known.length >= expected) continue;
+ let after = 0;
+ const seenCursors = new Set();
+ while (known.length < expected) {
+ await waitForReaderQueuePrefetchIdle(controller);
+ const url = new URL(`${BASE}/posts/${parent.id}/replies.json`);
+ if (after > 0) url.searchParams.set('after', String(after));
+ let payload;
+ try {
+ payload = await fetchJSON(url.toString(), {
+ signal: controller.signal,
+ key: `reader-queue-replies:${entry.topicId}:${parent.id}:${after}`,
+ priority: POST_REQUEST_PRIORITY.background,
+ });
+ } catch (error) {
+ if (error && error.name === 'AbortError') throw error;
+ break;
+ }
+ const pagePosts = postsFromPayload(payload);
+ const direct = directRepliesForParent(pagePosts, parent.post_number);
+ if (direct.length) {
+ mergeReaderQueuePostPayload(topic, direct, parent.post_number);
+ setCachedTopicData(entry.topicId, topic);
+ }
+ known = readerQueueDirectReplies(topic, parent.post_number);
+ known.forEach(addPost);
+ syncNestedProgress();
+ const nextAfter = replyPageCursor(pagePosts);
+ if (!pagePosts.length || pagePosts.length < SUB_REPLY_FETCH_SIZE ||
+ nextAfter <= after || seenCursors.has(nextAfter)) break;
+ seenCursors.add(nextAfter);
+ after = nextAfter;
+ }
+ }
+ syncNestedProgress();
+ return [...collected.values()];
+ }
+
+ function readerQueueMediaSources(posts, topic = null) {
+ const sources = new Set();
+ const addSource = (value) => {
+ const source = absoluteImageUrl(value);
+ if (/^https?:/i.test(source)) sources.add(source);
+ };
+ const addCookedSources = (cooked) => {
+ if (!cooked) return;
+ const template = makeElement('template');
+ template.innerHTML = String(cooked);
+ template.content.querySelectorAll('img').forEach((img) => {
+ addSource(
+ img.getAttribute('src') ||
+ img.getAttribute('data-src') ||
+ img.getAttribute('data-large-src')
+ );
+ });
+ };
+ (Array.isArray(posts) ? posts : []).forEach((post) => {
+ addCookedSources(post?.cooked);
+ normalizeBoosts(post?.boosts).forEach((boost) => addCookedSources(boost.cooked));
+ (Array.isArray(post?.reactions) ? post.reactions : []).forEach((reaction) => {
+ if (reactionEmojiUrls) addSource(reactionEmojiUrls.get(String(reaction?.id || '')));
+ });
+ });
+ (Array.isArray(topic?.valid_reactions) ? topic.valid_reactions : []).forEach((reaction) => {
+ const id = String(typeof reaction === 'string'
+ ? reaction
+ : reaction && (reaction.id || reaction.name) || '');
+ if (reactionEmojiUrls) addSource(reactionEmojiUrls.get(id));
+ });
+ return [...sources];
+ }
+
+ function readerQueuePrefetchForegroundBusy() {
+ if (document.visibilityState !== 'visible') return true;
+ const ctx = CURRENT_OVERLAY?._ldpReaderShell?.activeContext;
+ if (!ctx) return false;
+ if (ctx.readerSession?.isCurrent('opening')) return true;
+ return Date.now() - +(ctx.streamLastScrollAt || 0) < READER_QUEUE_PREFETCH_INTERACTION_SETTLE;
+ }
+
+ async function waitForReaderQueuePrefetchIdle(controller) {
+ while (!controller.signal.aborted && readerQueuePrefetchForegroundBusy()) {
+ await waitForDelay(180);
+ }
+ if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
+ }
+
+ async function prefetchReaderQueueMedia(entry, topic, posts, controller) {
+ await loadReactionEmojiRegistry();
+ if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
+ const candidates = readerQueueMediaSources(posts, topic);
+ entry.mediaTotalCount = candidates.length;
+ entry.mediaLoadedCount = 0;
+ if (!candidates.length) {
+ syncReaderQueueProgressSurface(entry);
+ return;
+ }
+ let cursor = 0;
+ const worker = async () => {
+ while (cursor < candidates.length) {
+ const source = candidates[cursor++];
+ await waitForReaderQueuePrefetchIdle(controller);
+ try {
+ const cached = await cachedPersistentLightboxBlob(source);
+ if (cached) {
+ entry.mediaLoadedCount++;
+ syncReaderQueueProgressSurface(entry);
+ continue;
+ }
+ const resource = await fetchReaderPublicResource(
+ source,
+ POST_REQUEST_PRIORITY.background,
+ 'media',
+ controller.signal
+ );
+ if (!resource) continue;
+ try {
+ const response = resource.response;
+ const contentType = String(response.headers.get('content-type') || '');
+ if (!response.ok || contentType && !contentType.toLowerCase().startsWith('image/')) continue;
+ const blob = await response.blob();
+ if (!blob.size) continue;
+ await persistLightboxBlob(source, blob, contentType, controller.signal);
+ entry.mediaLoadedCount++;
+ syncReaderQueueProgressSurface(entry);
+ } finally {
+ resource.release();
+ }
+ } catch (error) {
+ if (error && error.name === 'AbortError') throw error;
+ }
+ }
+ };
+ await Promise.all(Array.from(
+ { length: Math.min(READER_QUEUE_PREFETCH_MEDIA_CONCURRENCY, candidates.length) },
+ worker
+ ));
+ }
+
+ async function prefetchReaderQueueEntry(entry) {
+ if (!entry || !CURRENT_OVERLAY || String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID ||
+ !READER_QUEUE_BY_TOPIC.has(String(entry.topicId))) return;
+ const controller = new AbortController();
+ entry.prefetchController = controller;
+ entry.prefetchEnriched = false;
+ entry.loadState = 'loading';
+ entry.nestedLoadedCount = 0;
+ entry.nestedTotalCount = 0;
+ entry.mediaLoadedCount = 0;
+ entry.mediaTotalCount = 0;
+ entry.error = '';
+ syncReaderQueueProgressSurface(entry);
+ try {
+ await waitForReaderQueuePrefetchIdle(controller);
+ let topic = getCachedTopicData(entry.topicId);
+ const cachedStream = topic?.post_stream && Array.isArray(topic.post_stream.stream)
+ ? topic.post_stream.stream : [];
+ const expectedCount = Math.max(0, Number(topic?.posts_count) || 0);
+ if (!topic || !cachedStream.length || expectedCount > cachedStream.length) {
+ const freshTopic = await fetchJSON(`${BASE}/t/${entry.topicId}.json?forceLoad=true`, {
+ signal: controller.signal,
+ cacheMode: 'refresh',
+ key: `reader-queue-topic:${entry.topicId}`,
+ priority: POST_REQUEST_PRIORITY.background,
+ fetchOptions: discourseJSONFetchOptions(),
+ });
+ if (topic) mergeMissingSnapshotPosts(freshTopic, topic);
+ topic = freshTopic;
+ setCachedTopicData(entry.topicId, topic, { fetchedAt: Date.now(), source: 'network' });
+ }
+ updateReaderQueueEntryFromTopic(entry, topic, 0, { progressOnly: true });
+ const postStream = topicPostStream(topic);
+ const stream = Array.isArray(postStream.stream) ? postStream.stream.filter(Boolean) : [];
+ const preloadStream = readerQueuePrefetchStream(entry, topic);
+ let loadedIds = new Set((Array.isArray(postStream.posts) ? postStream.posts : [])
+ .map((post) => Number(post?.id)).filter(Boolean));
+ let missing = preloadStream.filter((postId) => !loadedIds.has(Number(postId)));
+ while (missing.length && READER_QUEUE_BY_TOPIC.has(String(entry.topicId)) &&
+ String(entry.topicId) !== READER_QUEUE_ACTIVE_TOPIC_ID) {
+ await waitForReaderQueuePrefetchIdle(controller);
+ const ids = missing.slice(0, READER_QUEUE_PREFETCH_BATCH_SIZE);
+ const query = ids.map((postId) => `post_ids[]=${encodeURIComponent(postId)}`).join('&');
+ const part = await fetchJSON(`${BASE}/t/${entry.topicId}/posts.json?${query}`, {
+ signal: controller.signal,
+ key: `reader-queue-posts:${entry.topicId}:${ids.join(',')}`,
+ priority: POST_REQUEST_PRIORITY.background,
+ });
+ const before = loadedIds.size;
+ mergeReaderQueuePostPayload(topic, part);
+ loadedIds = new Set((Array.isArray(topic.post_stream?.posts)
+ ? topic.post_stream.posts : []).map((post) => Number(post?.id)).filter(Boolean));
+ if (loadedIds.size <= before) throw new Error('预加载未返回新的楼层');
+ setCachedTopicData(entry.topicId, topic);
+ updateReaderQueueEntryFromTopic(entry, topic, stream.length, { progressOnly: true });
+ missing = preloadStream.filter((postId) => !loadedIds.has(Number(postId)));
+ }
+ if (!missing.length) {
+ const scopePostStream = topicPostStream(topic);
+ const scopePostsById = new Map(
+ (Array.isArray(scopePostStream.posts) ? scopePostStream.posts : [])
+ .map((post) => [+(post?.id || 0), post])
+ );
+ const scopePosts = preloadStream.map((postId) => scopePostsById.get(+postId)).filter(Boolean);
+ const enrichedPosts = await prefetchReaderQueueNestedReplies(
+ entry,
+ topic,
+ controller,
+ scopePosts
+ );
+ if (controller.signal.aborted || String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID)
+ throw new DOMException('Aborted', 'AbortError');
+ await prefetchReaderQueueMedia(entry, topic, enrichedPosts, controller);
+ entry.prefetchEnriched = true;
+ const mainComplete = stream.every((postId) => loadedIds.has(Number(postId)));
+ const nestedComplete = entry.nestedLoadedCount >= entry.nestedTotalCount;
+ const mediaComplete = entry.mediaLoadedCount >= entry.mediaTotalCount;
+ entry.loadState = mainComplete && nestedComplete && mediaComplete ? 'ready' : 'partial';
+ }
+ else if (entry.loadState === 'loading') entry.loadState = 'queued';
+ } catch (error) {
+ if (error && error.name === 'AbortError') {
+ if (entry.loadState === 'loading') entry.loadState = 'queued';
+ } else {
+ entry.loadState = 'error';
+ entry.error = String(error?.message || '预加载失败');
+ }
+ } finally {
+ if (entry.prefetchController === controller) entry.prefetchController = null;
+ if (entry.loadState === 'error') syncReaderQueueSurfaces();
+ else syncReaderQueueProgressSurface(entry);
+ }
+ }
+
+ function scheduleReaderQueuePrefetch(delay = READER_QUEUE_PREFETCH_IDLE_DELAY) {
+ if (READER_QUEUE_PREFETCH_RUNNING || READER_QUEUE_PREFETCH_TIMER ||
+ document.visibilityState !== 'visible' ||
+ !CURRENT_OVERLAY || !ACTIVE_READER_REQUEST_SCHEDULER) return;
+ READER_QUEUE_PREFETCH_TIMER = setTimeout(() => {
+ READER_QUEUE_PREFETCH_TIMER = 0;
+ if (document.visibilityState !== 'visible' ||
+ !CURRENT_OVERLAY || !ACTIVE_READER_REQUEST_SCHEDULER) return;
+ if (readerQueuePrefetchForegroundBusy()) {
+ scheduleReaderQueuePrefetch();
+ return;
+ }
+ const entry = READER_QUEUE_ENTRIES.find((candidate) =>
+ String(candidate.topicId) !== READER_QUEUE_ACTIVE_TOPIC_ID &&
+ candidate.loadState === 'queued' && !candidate.prefetchController
+ );
+ if (!entry) return;
+ READER_QUEUE_PREFETCH_RUNNING = true;
+ Promise.resolve(prefetchReaderQueueEntry(entry)).finally(() => {
+ READER_QUEUE_PREFETCH_RUNNING = false;
+ scheduleReaderQueuePrefetch(300);
+ });
+ }, Math.max(0, Number(delay) || 0));
+ }
+
+ function stopReaderQueuePrefetch() {
+ if (READER_QUEUE_PREFETCH_TIMER) clearTimeout(READER_QUEUE_PREFETCH_TIMER);
+ READER_QUEUE_PREFETCH_TIMER = 0;
+ READER_QUEUE_ENTRIES.forEach((entry) => entry.prefetchController?.abort());
+ }
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') scheduleReaderQueuePrefetch();
+ else stopReaderQueuePrefetch();
+ });
+
+ function createReaderQueueViewportTracker(ctx, entry) {
+ let progressSyncFrame = 0;
+ let viewportSyncTimer = 0;
+ let pendingPostNumber = 0;
+ const qualifies = (intersectionEntry) => {
+ if (!intersectionEntry.isIntersecting || intersectionEntry.intersectionRect.width <= 0) return false;
+ const nodeHeight = Math.max(1, intersectionEntry.boundingClientRect.height || 1);
+ const requiredHeight = Math.min(nodeHeight, 120, Math.max(36, nodeHeight * .3));
+ return intersectionEntry.intersectionRect.height >= requiredHeight;
+ };
+ const scheduleProgressSync = () => {
+ if (progressSyncFrame) return;
+ progressSyncFrame = requestAnimationFrame(() => {
+ progressSyncFrame = 0;
+ if (String(ctx.topicId) !== String(entry.topicId)) return;
+ syncReaderQueueProgressSurface(entry);
+ });
+ };
+ const observer = new IntersectionObserver((entries) => {
+ let readProgressChanged = false;
+ entries.forEach((intersectionEntry) => {
+ const node = intersectionEntry.target;
+ const postNumber = Number(node && node.dataset.postNumber);
+ if (postNumber > 0 && qualifies(intersectionEntry)) {
+ readProgressChanged = markReaderQueuePostRead(entry, postNumber) || readProgressChanged;
+ }
+ });
+ if (readProgressChanged) scheduleProgressSync();
+ }, { root: ctx.scrollRoot, threshold: [0, .1, .3] });
+ const captureViewport = () => {
+ viewportSyncTimer = 0;
+ const postNumber = pendingPostNumber;
+ pendingPostNumber = 0;
+ if (String(ctx.topicId) !== String(entry.topicId) || !(postNumber > 0)) return;
+ const viewport = embeddedReaderPostViewportState(ctx, postNumber);
+ if (viewport) entry.anchor = normalizeReaderAnchorState({
+ viewport,
+ replyWindow: entry.anchor?.replyWindow,
+ });
+ };
+ return {
+ observe(node) {
+ if (node && !node.classList.contains('ldp-nested-preview')) observer.observe(node);
+ },
+ unobserve(node) {
+ if (!node) return;
+ observer.unobserve(node);
+ },
+ sync(postNumber) {
+ pendingPostNumber = Math.max(0, Number(postNumber) || 0);
+ if (viewportSyncTimer) clearTimeout(viewportSyncTimer);
+ viewportSyncTimer = setTimeout(captureViewport, 240);
+ },
+ stop() {
+ if (progressSyncFrame) cancelAnimationFrame(progressSyncFrame);
+ if (viewportSyncTimer) clearTimeout(viewportSyncTimer);
+ progressSyncFrame = 0;
+ viewportSyncTimer = 0;
+ captureViewport();
+ observer.disconnect();
+ flushReaderQueueReadProgress(entry);
+ },
+ };
+ }
+
+ function bindReaderQueueSurface(overlay) {
+ const rail = overlay?.querySelector('.ldp-reader-queue');
+ if (!rail || overlay._ldpReaderQueueBound) return;
+ overlay._ldpReaderQueueBound = true;
+ const panel = rail.querySelector('.ldp-reader-queue-panel');
+ const toggle = rail.querySelector('.ldp-reader-queue-toggle');
+ const bubbles = rail.querySelector('.ldp-reader-queue-bubbles');
+ const scrollHint = rail.querySelector('.ldp-reader-queue-scroll-hint');
+ const close = rail.querySelector('.ldp-reader-queue-close');
+ const clear = rail.querySelector('.ldp-reader-queue-clear');
+ const queueScope = createLifecycleScope();
+ const on = (target, type, listener, options) =>
+ queueScope.listen(target, type, listener, options);
+ let hoverCloseTimer = 0;
+ let queueDrag = null;
+ let suppressToggleClickUntil = 0;
+ const schedulePanelPosition = () => requestAnimationFrame(() => applyReaderQueueSurfacePosition(rail));
+ const cancelPanelClose = () => {
+ if (hoverCloseTimer) clearTimeout(hoverCloseTimer);
+ hoverCloseTimer = 0;
+ };
+ const setPanelOpen = (open) => {
+ cancelPanelClose();
+ panel.hidden = !open;
+ setExpanded(toggle, open);
+ if (open) schedulePanelPosition();
+ };
+ const schedulePanelClose = () => {
+ cancelPanelClose();
+ if (panel.contains(readerActiveElement())) return;
+ hoverCloseTimer = setTimeout(() => {
+ hoverCloseTimer = 0;
+ setPanelOpen(false);
+ }, 180);
+ };
+ const setPreviewExpanded = (expanded) => {
+ rail.classList.toggle('is-preview-collapsed', !expanded);
+ syncReaderQueueToggleState(rail);
+ if (expanded) requestAnimationFrame(() => syncReaderQueueScrollHint(rail));
+ if (!panel.hidden) schedulePanelPosition();
+ };
+ const finishQueueDrag = (event, cancelled = false) => {
+ if (!queueDrag || event.pointerId !== queueDrag.pointerId) return;
+ const drag = queueDrag;
+ queueDrag = null;
+ rail.classList.remove('is-dragging');
+ if (toggle.hasPointerCapture(drag.pointerId)) toggle.releasePointerCapture(drag.pointerId);
+ if (!drag.moved) return;
+ suppressToggleClickUntil = Date.now() + 350;
+ if (cancelled) {
+ READER_QUEUE_SURFACE_STATE = drag.previousState;
+ applyReaderQueueSurfacePosition(rail);
+ return;
+ }
+ const bounds = readerQueueSurfaceBounds(rail);
+ if (!bounds) return;
+ const left = Math.max(0, Math.min(bounds.maxLeft, Number.parseFloat(rail.style.left) || 0));
+ const top = Math.max(bounds.minTop, Math.min(bounds.maxTop, Number.parseFloat(rail.style.top) || 0));
+ const distanceLeft = left;
+ const distanceRight = bounds.maxLeft - left;
+ const dockThreshold = bounds.modal.clientWidth * READER_QUEUE_DOCK_THRESHOLD_RATIO;
+ const dock = Math.min(distanceLeft, distanceRight) <= dockThreshold
+ ? (distanceLeft <= distanceRight ? 'left' : 'right')
+ : '';
+ READER_QUEUE_SURFACE_STATE = normalizeReaderQueueSurfaceState({
+ x: bounds.maxLeft > 0 ? (dock === 'left' ? 0 : dock === 'right' ? 1 : left / bounds.maxLeft) : 0,
+ y: bounds.maxTop > 0 ? top / bounds.maxTop : 0,
+ dock,
+ });
+ applyReaderQueueSurfacePosition(rail);
+ scheduleReaderQueueStatePersist();
+ };
+ on(toggle, 'pointerdown', (event) => {
+ if (event.button !== 0 || event.isPrimary === false) return;
+ const modal = rail.closest('.ldp-modal');
+ if (!modal) return;
+ const modalRect = modal.getBoundingClientRect();
+ const railRect = rail.getBoundingClientRect();
+ queueDrag = {
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startY: event.clientY,
+ startLeft: railRect.left - modalRect.left,
+ startTop: railRect.top - modalRect.top,
+ previousState: normalizeReaderQueueSurfaceState(READER_QUEUE_SURFACE_STATE),
+ moved: false,
+ };
+ toggle.setPointerCapture(event.pointerId);
+ });
+ on(toggle, 'pointermove', (event) => {
+ if (!queueDrag || event.pointerId !== queueDrag.pointerId) return;
+ const deltaX = event.clientX - queueDrag.startX;
+ const deltaY = event.clientY - queueDrag.startY;
+ if (!queueDrag.moved && Math.hypot(deltaX, deltaY) < 5) return;
+ if (!queueDrag.moved) {
+ queueDrag.moved = true;
+ cancelPanelClose();
+ setPanelOpen(false);
+ rail.classList.remove('is-docked-left', 'is-docked-right', 'is-dock-revealed');
+ rail.classList.add('is-dragging');
+ }
+ const bounds = readerQueueSurfaceBounds(rail);
+ if (!bounds) return;
+ const top = Math.max(bounds.minTop,
+ Math.min(bounds.maxTop, queueDrag.startTop + deltaY));
+ rail.classList.add('is-runtime-positioned');
+ rail.style.left = `${Math.round(Math.max(0, Math.min(bounds.maxLeft, queueDrag.startLeft + deltaX)))}px`;
+ rail.style.top = `${Math.round(top <= bounds.minTop + toggle.offsetHeight / 2 ? bounds.minTop : top)}px`;
+ event.preventDefault();
+ });
+ on(toggle, 'pointerup', (event) => finishQueueDrag(event));
+ on(toggle, 'pointercancel', (event) => finishQueueDrag(event, true));
+ on(toggle, 'click', (event) => {
+ if (Date.now() < suppressToggleClickUntil) {
+ consumeEvent(event);
+ return;
+ }
+ if (!READER_QUEUE_ENTRIES.length) { setPanelOpen(false); setPref('readerQueueAlwaysVisibleWhenEmpty', false); renderReaderQueueSurface(overlay); return; }
+ setPreviewExpanded(rail.classList.contains('is-preview-collapsed'));
+ });
+ on(toggle, 'pointerenter', () => {
+ if (!queueDrag) setPanelOpen(true);
+ });
+ on(toggle, 'keydown', (event) => {
+ if (event.key !== 'ArrowDown') return;
+ event.preventDefault();
+ setPanelOpen(true);
+ panel.querySelector('.ldp-reader-queue-row')?.focus();
+ });
+ on(rail, 'pointerenter', () => {
+ rail.classList.add('is-dock-revealed');
+ cancelPanelClose();
+ });
+ on(rail, 'pointerleave', () => {
+ rail.classList.remove('is-dock-revealed');
+ schedulePanelClose();
+ });
+ on(panel, 'focusin', cancelPanelClose);
+ on(panel, 'focusout', (event) => {
+ if (event.relatedTarget instanceof Node && panel.contains(event.relatedTarget)) return;
+ schedulePanelClose();
+ });
+ bindFloatingSurfaceWheel(bubbles);
+ on(bubbles, 'scroll', () => syncReaderQueueScrollHint(rail), { passive: true });
+ on(scrollHint, 'click', () => {
+ const direction = Number(scrollHint.dataset.scrollDirection) || 1;
+ const reduceMotion = prefersReducedMotion();
+ if (direction < 0) {
+ bubbles.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
+ return;
+ }
+ const distance = Math.max(38, Math.round(bubbles.clientHeight * .72));
+ bubbles.scrollBy({ top: distance, behavior: reduceMotion ? 'auto' : 'smooth' });
+ });
+ on(close, 'click', () => {
+ setPanelOpen(false);
+ toggle.focus();
+ });
+ on(clear, 'click', () => clearReaderQueueEntries(overlay._ldpReaderShell?.activeContext));
+ on(rail, 'click', (event) => {
+ const bubbleRemove = event.target.closest('.ldp-reader-queue-bubble-remove');
+ if (bubbleRemove) {
+ removeReaderQueueEntry(bubbleRemove.dataset.readerQueueTopicId);
+ return;
+ }
+ const bubble = event.target.closest('.ldp-reader-queue-bubble');
+ if (bubble) {
+ void switchReaderQueueTopic(bubble.dataset.readerQueueTopicId);
+ return;
+ }
+ const row = event.target.closest('.ldp-reader-queue-row');
+ if (!row) return;
+ const topicId = Number(row.dataset.readerQueueTopicId);
+ if (event.target.closest('.ldp-reader-queue-remove')) {
+ removeReaderQueueEntry(topicId);
+ return;
+ }
+ if (event.target.closest('.ldp-reader-queue-pin')) {
+ toggleReaderQueuePinned(topicId);
+ return;
+ }
+ if (event.target.closest('.ldp-reader-queue-retry')) {
+ ensureReaderQueueEntry(topicId, {}, { retry: true });
+ return;
+ }
+ void switchReaderQueueTopic(topicId);
+ });
+ on(rail, 'keydown', (event) => {
+ const row = event.target.closest('.ldp-reader-queue-row');
+ if (!row) return;
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ setPanelOpen(false);
+ toggle.focus();
+ return;
+ }
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ void switchReaderQueueTopic(row.dataset.readerQueueTopicId);
+ return;
+ }
+ if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return;
+ event.preventDefault();
+ const rows = [...panel.querySelectorAll('.ldp-reader-queue-row')];
+ const index = rows.indexOf(row);
+ rows[Math.max(0, Math.min(rows.length - 1, index + (event.key === 'ArrowDown' ? 1 : -1)))]?.focus();
+ });
+ on(overlay, 'pointerdown', (event) => {
+ if (!panel.hidden && !event.target.closest('.ldp-reader-queue')) setPanelOpen(false);
+ });
+ overlay._ldpSyncReaderQueue = () => renderReaderQueueSurface(overlay);
+ overlay._ldpSyncReaderQueuePosition = () => applyReaderQueueSurfacePosition(rail);
+ overlay._ldpDestroyReaderQueueSurface = () => {
+ cancelPanelClose();
+ if (queueDrag) finishQueueDrag({ pointerId: queueDrag.pointerId }, true);
+ queueScope.destroy();
+ delete overlay._ldpSyncReaderQueue;
+ delete overlay._ldpSyncReaderQueuePosition;
+ delete overlay._ldpDestroyReaderQueueSurface;
+ overlay._ldpReaderQueueBound = false;
+ };
+ renderReaderQueueSurface(overlay);
+ }
+
+ function cachePayload(key) {
+ if (key === 'history') return readTopicHistory();
+ if (key === 'topics') return [...TOPIC_NAV_ICON_CACHE.entries()];
+ const cache = cacheMapForKey(key);
+ if (cache) return [...cache.entries()];
+ return [];
+ }
+
+ function estimateCacheBytes(value) {
+ try {
+ const text = JSON.stringify(value);
+ return text.length * 2;
+ } catch {
+ return 0;
+ }
+ }
+
+ async function runInBatches(values, batchSize, worker) {
+ for (let index = 0; index < values.length; index += batchSize) {
+ await Promise.all(values.slice(index, index + batchSize).map(worker));
+ }
+ }
+
+ async function cacheStorageStats(names) {
+ const groups = Object.fromEntries(names.map((name) => [name, { count: 0, bytes: 0 }]));
+ if (typeof caches === 'undefined') return { count: 0, bytes: 0, groups };
+ let count = 0;
+ let bytes = 0;
+ let existingNames = [];
+ try { existingNames = await caches.keys(); } catch { return { count, bytes, groups }; }
+ for (const name of names.filter((entry) => existingNames.includes(entry))) {
+ try {
+ const storage = await caches.open(name);
+ const requests = await storage.keys();
+ let groupBytes = 0;
+ await runInBatches(requests, RESOURCE_CACHE_PRUNE_BATCH_SIZE, async (request) => {
+ const response = await storage.match(request);
+ if (!response) return;
+ let size = Number(response.headers.get('content-length'));
+ if (!(Number.isFinite(size) && size >= 0)) {
+ try { size = (await response.blob()).size; } catch { size = 0; }
+ }
+ groupBytes += size;
+ });
+ groups[name] = { count: requests.length, bytes: groupBytes };
+ count += groups[name].count;
+ bytes += groupBytes;
+ } catch {}
+ }
+ return { count, bytes, groups };
+ }
+
+ async function cacheTypeStats(key, responseStatsRequest) {
+ if (key === 'assets')
+ return cacheStorageStats(RESOURCE_CACHE_GROUPS.map(({ cacheName }) => cacheName));
+ const payload = cachePayload(key);
+ const responseKind = ['topics', 'users', 'notifications', 'responses'].includes(key) ? key : '';
+ const responseStat = responseKind ? (await responseStatsRequest)[responseKind] : { count: 0, bytes: 0 };
+ const payloadBytes = Array.isArray(payload) && !payload.length ? 0 : estimateCacheBytes(payload);
+ const stat = {
+ count: (Array.isArray(payload) ? payload.length : 0) + responseStat.count,
+ bytes: payloadBytes + responseStat.bytes,
+ };
+ if (key === 'history') {
+ stat.topicCount = new Set(payload.map((entry) => Number(entry?.topicId)).filter(Boolean)).size;
+ stat.historyRecordCount = payload.length;
+ } else if (key === 'topics') {
+ const topicIds = new Set(responseStat.topicIds || []);
+ stat.topicCount = topicIds.size;
+ stat.postCount = Math.max(0, Number(responseStat.postCount) || 0);
+ stat.topicSnapshotCount = Math.max(0, Number(responseStat.topicSnapshotCount) || 0);
+ stat.topicRequestCount = Math.max(0, Number(responseStat.topicRequestCount) || 0);
+ stat.topicNavIconCount = payload.length;
+ } else if (key === 'users') {
+ const userNames = new Set(responseStat.userNames || []);
+ payload.forEach(([username, entry]) => {
+ const resolvedUsername = cleanUsernameValue(entry?.user && entry.user.username || username)
+ .toLocaleLowerCase();
+ if (resolvedUsername) userNames.add(resolvedUsername);
+ });
+ stat.userCount = userNames.size;
+ stat.userProfileCount = payload.length;
+ stat.userRequestCount = responseStat.count;
+ } else if (key === 'notifications') {
+ const notificationIds = new Set(responseStat.notificationIds || []);
+ payload.forEach(([, entry]) => {
+ const notifications = entry?.value && Array.isArray(entry.value.notifications)
+ ? entry.value.notifications
+ : [];
+ addNotificationStatsIdentities(notificationIds, notifications);
+ });
+ stat.notificationCount = notificationIds.size;
+ stat.notificationPageCount = payload.length;
+ stat.notificationRequestCount = responseStat.count;
+ } else if (key === 'responses')
+ stat.responseRecords = responseStat.responseRecords || {};
+ return stat;
+ }
+
+ function formatCacheBytes(bytes) {
+ const value = Math.max(0, Number(bytes) || 0);
+ if (value < 1024) return `${value} B`;
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
+ }
+
+ function formatCacheTypeStats(key, stat) {
+ const config = PERSISTENT_CACHE_CONFIG[key];
+ const storageSuffix = config ? ' · 本机保存' : '';
+ if (key === 'history')
+ return `${stat.topicCount || 0} 个主题 · ${stat.historyRecordCount || 0} 条浏览记录 · ` +
+ `${formatCacheBytes(stat.bytes)}${storageSuffix}`;
+ if (key === 'topics')
+ return `${stat.topicCount || 0} 个主题 · 已缓存 ${stat.postCount || 0} 个楼层 · ` +
+ `快照 ${stat.topicSnapshotCount || 0} / 接口 ${stat.topicRequestCount || 0} / ` +
+ `图标 ${stat.topicNavIconCount || 0} · ` +
+ `共 ${stat.count || 0} 条记录 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`;
+ if (key === 'users')
+ return `${stat.userCount || 0} 个用户 · 资料卡 ${stat.userProfileCount || 0} / ` +
+ `接口 ${stat.userRequestCount || 0} · 共 ${stat.count || 0} 条记录 · ` +
+ `${formatCacheBytes(stat.bytes)}${storageSuffix}`;
+ if (key === 'notifications')
+ return `${stat.notificationCount || 0} 条消息 · 分页 ${stat.notificationPageCount || 0} / ` +
+ `接口 ${stat.notificationRequestCount || 0} · 共 ${stat.count || 0} 条记录 · ` +
+ `${formatCacheBytes(stat.bytes)}${storageSuffix}`;
+ if (key === 'responses') {
+ const records = stat.responseRecords || {};
+ return `收藏 ${records.bookmarks || 0} / 回应 ${records.reactions || 0} / ` +
+ `其他 ${records.other || 0} · ` +
+ `共 ${stat.count || 0} 条响应 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`;
+ }
+ if (key === 'assets') {
+ const groups = stat.groups || {};
+ const resourceSummary = (label, cacheName) => {
+ const group = groups[cacheName] || { count: 0, bytes: 0 };
+ return `${label} ${group.count || 0} 个(${formatCacheBytes(group.bytes)})`;
+ };
+ return [
+ ...RESOURCE_CACHE_GROUPS.map(({ label, cacheName }) => resourceSummary(label, cacheName)),
+ `共 ${stat.count || 0} 个资源 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`,
+ ].join(' · ');
+ }
+ return `${stat.count || 0} 条 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`;
+ }
+
+ function cacheRetentionLabel(key) {
+ const config = PERSISTENT_CACHE_CONFIG[key];
+ if (!config) return '';
+ if (key === 'responses') return '保留 30 天–1 年';
+ if (key === 'assets') return '头像与表情 90 天 · 原图 7 天';
+ const days = Math.max(1, Math.round(config.maxAge / (24 * 60 * 60 * 1000)));
+ return `最多 ${days} 天`;
+ }
+
+ async function clearSelectedCaches(keys) {
+ const selected = new Set(Array.isArray(keys) ? keys : []);
+ const kinds = ['topics', 'users', 'notifications', 'responses'].filter((kind) => selected.has(kind));
+ const mapKeys = ['topics', 'topicNavIcons', 'users', 'notifications'].filter((key) =>
+ selected.has(key) || (key === 'topicNavIcons' && selected.has('topics')));
+ const pendingCacheRequests = new Set([
+ ...(selected.has('users')
+ ? Array.from(USER_CARD_REQUESTS.values(), (entry) => entry?.promise).filter(Boolean) : []),
+ ...(selected.has('users') ? USER_FOLLOW_LIST_REQUESTS.values() : []),
+ ...(selected.has('notifications') ? NOTIFICATION_REQUESTS.values() : []),
+ ...(kinds.length ? RESPONSE_CACHE_REQUESTS.values() : []),
+ ]);
+ if (pendingCacheRequests.size) await Promise.allSettled(pendingCacheRequests);
+ if (selected.has('history')) writeTopicHistory([]);
+ clearPersistentCacheMaps(mapKeys);
+ mapKeys.forEach((key) => {
+ const storageKey = PERSISTENT_CACHE_CONFIG[key]?.storageKey;
+ if (!storageKey) return;
+ try { localStorage.removeItem(storageKey); } catch {}
+ });
+ if (selected.has('assets')) {
+ RESOURCE_CACHE_CLEARING = true;
+ RESOURCE_CACHE_ABORT_CONTROLLER.abort();
+ if (RESOURCE_CACHE_PRUNE_REQUESTS.size)
+ await Promise.allSettled(RESOURCE_CACHE_PRUNE_REQUESTS.values());
+ const pendingResourceRequests = [
+ ...RESOURCE_CACHE_GROUPS.flatMap(({ requests }) => [...requests.values()]),
+ ...RESOURCE_CACHE_WRITES,
+ ];
+ if (pendingResourceRequests.length)
+ await Promise.allSettled(pendingResourceRequests);
+ RESOURCE_CACHE_GROUPS.forEach(({ objectUrls, requests }) => {
+ clearResourceObjectUrlCache(objectUrls);
+ requests.clear();
+ });
+ RESOURCE_CACHE_WRITES.clear();
+ RESOURCE_CACHE_PRUNE_REQUESTS.clear();
+ if (typeof caches !== 'undefined')
+ await Promise.all(RESOURCE_CACHE_GROUPS.map(({ cacheName }) => caches.delete(cacheName).catch(() => false)));
+ RESOURCE_CACHE_ABORT_CONTROLLER = new AbortController();
+ RESOURCE_CACHE_CLEARING = false;
+ }
+ if (kinds.length) await clearStoredResponseCache({ kinds });
+ if (SHARED_REQUEST_COORDINATOR && mapKeys.length)
+ SHARED_REQUEST_COORDINATOR.publishCache({ action: 'map-clear', keys: mapKeys });
+ }
+
+ function topicResponseCacheTags(topicId, ...topicSources) {
+ const safeTopicId = Number(topicId);
+ const tags = new Set(safeTopicId > 0 ? [`topic:${safeTopicId}`] : []);
+ topicSources.filter(Boolean).forEach((topic) => {
+ const stream = topic?.post_stream && Array.isArray(topic.post_stream.stream)
+ ? topic.post_stream.stream : [];
+ stream.forEach((postId) => {
+ const safePostId = Number(postId);
+ if (safePostId > 0) tags.add(`post:${safePostId}`);
+ });
+ postsFromPayload(topic).forEach((post) => {
+ const safePostId = Number(post?.id);
+ if (safePostId > 0) tags.add(`post:${safePostId}`);
+ });
+ });
+ return [...tags];
+ }
+
+ async function clearCurrentTopicCaches(topicId, topicData) {
+ const key = String(topicId || '');
+ const cachedEntry = TOPIC_DATA_CACHE.get(key);
+ const tags = topicResponseCacheTags(topicId, topicData, cachedEntry?.topic);
+ cancelTopicSnapshotWrites(topicId);
+ HOST_TOPIC_DATA_CACHE.delete(key);
+ TOPIC_DATA_CACHE.delete(key);
+ if (tags.length) await clearStoredResponseCache({ tags });
+ }
+
+ restorePersistentCaches();
+ const stopSharedCacheSync = SHARED_REQUEST_COORDINATOR
+ ? SHARED_REQUEST_COORDINATOR.subscribeCache(applySharedCacheMessage)
+ : () => {};
+ scheduleIdleWork(() => {
+ void pruneStoredResponseCache();
+ void prunePersistentResourceCacheOnce(LDP_AVATAR_CACHE_NAME, LDP_AVATAR_CACHE_MAX_AGE)
+ .then(() => prunePersistentResourceCacheOnce(LDP_EMOJI_IMAGE_CACHE_NAME, LDP_EMOJI_IMAGE_CACHE_MAX_AGE));
+ }, 2000);
+ window.addEventListener('pagehide', (event) => {
+ READER_QUEUE_ENTRIES.forEach(flushReaderQueueReadProgress);
+ flushReaderQueueState();
+ flushPersistentCacheWrites();
+ void flushTopicSnapshotWrites();
+ if (!event.persisted) {
+ stopSharedCacheSync();
+ if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.destroy();
+ }
+ saveEmbeddedReaderReloadState();
+ });
+
+ /* ============ 1. 样式 ============ */
+ const style = makeElement('style');
+ const readerStaticStyle = (() => {
+ const getResourceText = globalThis.GM_getResourceText;
+ if (!isFunction(getResourceText)) return '';
+ try {
+ return getResourceText('ldpReaderStyles') || '';
+ } catch (error) {
+ console.error('[Awesome LinuxDo Reader] 静态样式资源读取失败。', error);
+ return '';
+ }
+ })();
+ if (!readerStaticStyle.trim()) {
+ console.error('[Awesome LinuxDo Reader] 静态样式资源为空,已停止初始化。');
+ return;
+ }
+ // Tree-depth CSS is appended here so fork/local builds work before the pinned
+ // @resource main.css on CDN is republished.
+ const inlineReplyTreeStyle = `
+ .ldp-children .ldp-children{margin-left:14px;padding-left:14px;
+ --ldp-nested-parent-drop:28px;--ldp-nested-branch-width:12px;--ldp-nested-parent-head-width:12px;}
+ .ldp-post[data-ldp-nest-depth="2"] > .ldp-children,
+ .ldp-post[data-ldp-nest-depth="3"] > .ldp-children,
+ .ldp-post[data-ldp-nest-depth="4"] > .ldp-children{margin-left:12px;padding-left:12px;}
+ /* Measured per-post line joint; overrides legacy axis-18px assumption. */
+ .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post::before,
+ .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post::after{
+ left:var(--ldp-nested-line-left,calc(var(--ldp-nested-axis-left) - var(--ldp-nested-gutter,18px)));}
+ /* Undo compact zoom on tree chrome so nested levels do not compound. */
+ .ldp-children > .ldp-nested-preview > .ldp-children,
+ .ldp-children > .ldp-nested-preview > .ldp-collapse-replies,
+ .ldp-children > .ldp-nested-preview > .ldp-sub-loading,
+ .ldp-children > .ldp-nested-preview > .ldp-sub-actions{zoom:1;}
+ .ldp-children > .ldp-nested-preview > .ldp-collapse-replies{margin-top:2px;}
+ .ldp-nested-preview > .ldp-children > .ldp-nested-preview{--ldp-nested-content-scale:.95;--ldp-nested-component-scale:.93;}
+ .ldp-inline-reply-tree-enable-row.is-disabled{opacity:.55;cursor:not-allowed;}
+ .ldp-inline-reply-tree-enable-row.is-disabled .ldp-setting-switch input{cursor:not-allowed;}
+ .ldp-inline-reply-tree-depth-row{grid-template-columns:minmax(0,1fr) minmax(96px,132px);align-items:center;}
+ .ldp-inline-reply-tree-depth-row[hidden]{display:none;}
+ .ldp-inline-reply-tree-depth-row .ldp-inline-reply-tree-depth{width:100%;}
+ `;
+ style.textContent = `${readerStaticStyle}\n${inlineReplyTreeStyle}`;
+ function ensureReaderStyleMounted() {
+ const target = document.head || PAGE_ROOT;
+ if (!target) return;
+ if (style.parentNode !== target || target.lastElementChild !== style) target.appendChild(style);
+ }
+ ensureReaderStyleMounted();
+
+ function readerHostStyleSources() {
+ if (!document.head) return [];
+ return [...document.head.querySelectorAll('link[rel~="stylesheet"],style')].filter((source) =>
+ source !== style && source.dataset.ldpReaderShadow !== '1' && source.dataset.ldpHostStyle !== '1'
+ );
+ }
+
+ function readerHostStyleLoadsAfterReader(source) {
+ return !!(source && style.compareDocumentPosition(source) & Node.DOCUMENT_POSITION_FOLLOWING);
+ }
+
+ function insertReaderHostStyleClone(source, root, beforeNode) {
+ if (!source || !root) return;
+ const previous = READER_HOST_STYLE_CLONES.get(source);
+ const clone = source.cloneNode(true);
+ clone.removeAttribute('id');
+ clone.dataset.ldpHostStyle = '1';
+ if (previous?.isConnected) {
+ previous.replaceWith(clone);
+ } else {
+ const sources = readerHostStyleSources();
+ const sourceIndex = sources.indexOf(source);
+ const loadsAfterReader = readerHostStyleLoadsAfterReader(source);
+ const nextClone = sources.slice(sourceIndex + 1)
+ .filter((item) => readerHostStyleLoadsAfterReader(item) === loadsAfterReader)
+ .map((item) => READER_HOST_STYLE_CLONES.get(item))
+ .find((item) => item?.isConnected);
+ const shadowGuards = root.querySelector('style[data-ldp-reader-shadow-guards]');
+ const fallback = loadsAfterReader ? (shadowGuards || beforeNode?.nextSibling) : beforeNode;
+ root.insertBefore(clone, nextClone || fallback || root.firstChild);
+ }
+ READER_HOST_STYLE_CLONES.set(source, clone);
+ }
+
+ function cloneReaderHostStyles(root, beforeNode) {
+ if (!root || !document.head) return;
+ root.querySelectorAll('[data-ldp-host-style]').forEach((clone) => clone.remove());
+ READER_HOST_STYLE_CLONES.forEach((clone) => clone.remove());
+ READER_HOST_STYLE_CLONES.clear();
+ readerHostStyleSources().forEach((source) => insertReaderHostStyleClone(source, root, beforeNode));
+ if (READER_HOST_STYLE_OBSERVER) return;
+ READER_HOST_STYLE_OBSERVER = new MutationObserver((mutations) => {
+ const changed = new Set();
+ mutations.forEach((mutation) => {
+ if (mutation.type === 'attributes') {
+ changed.add(mutation.target);
+ return;
+ }
+ const styleOwner = mutation.target.nodeType === Node.ELEMENT_NODE && mutation.target.matches('style')
+ ? mutation.target
+ : mutation.target.parentElement?.closest('style');
+ if (styleOwner) changed.add(styleOwner);
+ mutation.removedNodes.forEach((node) => {
+ const removed = node.nodeType === Node.ELEMENT_NODE && node.matches('link[rel~="stylesheet"],style')
+ ? [node]
+ : [...node.querySelectorAll?.('link[rel~="stylesheet"],style') || []];
+ removed.forEach((source) => {
+ READER_HOST_STYLE_CLONES.get(source)?.remove();
+ READER_HOST_STYLE_CLONES.delete(source);
+ });
+ });
+ mutation.addedNodes.forEach((node) => {
+ const added = node.nodeType === Node.ELEMENT_NODE && node.matches('link[rel~="stylesheet"],style')
+ ? [node]
+ : [...node.querySelectorAll?.('link[rel~="stylesheet"],style') || []];
+ added.forEach((source) => changed.add(source));
+ });
+ });
+ const shadowStyle = READER_PORTAL_ROOT?.querySelector('style[data-ldp-reader-shadow]');
+ if (!shadowStyle) return;
+ changed.forEach((source) => {
+ if (source === style) return;
+ if (!source.isConnected || !source.matches('link[rel~="stylesheet"],style')) {
+ READER_HOST_STYLE_CLONES.get(source)?.remove();
+ READER_HOST_STYLE_CLONES.delete(source);
+ return;
+ }
+ insertReaderHostStyleClone(source, READER_PORTAL_ROOT, shadowStyle);
+ });
+ });
+ READER_HOST_STYLE_OBSERVER.observe(document.head, {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ attributeFilter: ['href', 'rel', 'media', 'disabled'],
+ });
+ }
+
+ function mirrorReaderHostAttributes(source, target, marker) {
+ if (!source || !target) return;
+ const sourceNames = new Set(source.getAttributeNames().filter((name) =>
+ name !== 'style' && !name.startsWith('data-ldp-')
+ ));
+ target.getAttributeNames().forEach((name) => {
+ if (name !== marker && !sourceNames.has(name)) target.removeAttribute(name);
+ });
+ sourceNames.forEach((name) => {
+ const value = name === 'class'
+ ? [...source.classList].filter((token) => !token.startsWith('ldp-')).join(' ')
+ : source.getAttribute(name);
+ if (target.getAttribute(name) !== value) target.setAttribute(name, value);
+ });
+ target.setAttribute(marker, '1');
+ }
+
+ function syncReaderHostContextAttributes() {
+ mirrorReaderHostAttributes(PAGE_ROOT, READER_PORTAL_CONTEXT_HTML, 'data-ldp-shadow-context-html');
+ mirrorReaderHostAttributes(document.body, READER_PORTAL_CONTEXT_BODY, 'data-ldp-shadow-context-body');
+ }
+
+ function destroyReaderPortal() {
+ if (READER_HOST_STYLE_OBSERVER) READER_HOST_STYLE_OBSERVER.disconnect();
+ if (READER_HOST_CONTEXT_OBSERVER) READER_HOST_CONTEXT_OBSERVER.disconnect();
+ READER_HOST_STYLE_OBSERVER = null;
+ READER_HOST_CONTEXT_OBSERVER = null;
+ if (READER_ICON_TOOLTIP_CLEANUP) READER_ICON_TOOLTIP_CLEANUP();
+ READER_ICON_TOOLTIP_CLEANUP = null;
+ READER_HOST_STYLE_CLONES.forEach((clone) => clone.remove());
+ READER_HOST_STYLE_CLONES.clear();
+ const host = READER_PORTAL_HOST;
+ const root = READER_PORTAL_ROOT;
+ if (root) releasePersistentAvatarImages(root);
+ if (root) root.replaceChildren();
+ READER_PORTAL_HOST = null;
+ READER_PORTAL_ROOT = null;
+ READER_PORTAL_CONTEXT_HTML = null;
+ READER_PORTAL_CONTEXT_BODY = null;
+ if (host) host.remove();
+ }
+
+ function clearReaderResourceObjectUrlsIfIdle() {
+ if (CURRENT_OVERLAY?.isConnected) return false;
+ [AVATAR_OBJECT_URL_CACHE, EMOJI_IMAGE_OBJECT_URL_CACHE, LIGHTBOX_IMAGE_OBJECT_URL_CACHE]
+ .forEach(clearResourceObjectUrlCache);
+ return true;
+ }
+
+ function releaseReaderPortalIfIdle() {
+ if (CURRENT_OVERLAY?.isConnected) return false;
+ destroyReaderPortal();
+ const pendingResourceRequests = [
+ ...AVATAR_RESOURCE_REQUESTS.values(),
+ ...EMOJI_IMAGE_RESOURCE_REQUESTS.values(),
+ ...LIGHTBOX_IMAGE_RESOURCE_REQUESTS.values(),
+ ];
+ clearReaderResourceObjectUrlsIfIdle();
+ if (pendingResourceRequests.length)
+ void Promise.allSettled(pendingResourceRequests).then(clearReaderResourceObjectUrlsIfIdle);
+ return true;
+ }
+
+ function ensureReaderHostContext(root) {
+ if (!root || !PAGE_ROOT || !document.body) return null;
+ let contextHtml = root.querySelector(':scope > html[data-ldp-shadow-context-html]');
+ if (!contextHtml) {
+ contextHtml = makeElement('html');
+ contextHtml.dataset.ldpShadowContextHtml = '1';
+ root.appendChild(contextHtml);
+ }
+ let contextBody = contextHtml.querySelector(':scope > body[data-ldp-shadow-context-body]');
+ if (!contextBody) {
+ contextBody = makeElement('body');
+ contextBody.dataset.ldpShadowContextBody = '1';
+ contextHtml.appendChild(contextBody);
+ }
+ READER_PORTAL_CONTEXT_HTML = contextHtml;
+ READER_PORTAL_CONTEXT_BODY = contextBody;
+ syncReaderHostContextAttributes();
+ [...root.childNodes].forEach((node) => {
+ if (node === contextHtml || node.nodeType !== Node.ELEMENT_NODE ||
+ node.matches('style,link[rel~="stylesheet"]')) return;
+ contextBody.appendChild(node);
+ });
+ if (!READER_HOST_CONTEXT_OBSERVER) {
+ READER_HOST_CONTEXT_OBSERVER = new MutationObserver((mutations) => {
+ if (mutations.some((mutation) => mutation.attributeName !== 'style'))
+ syncReaderHostContextAttributes();
+ });
+ READER_HOST_CONTEXT_OBSERVER.observe(PAGE_ROOT, { attributes: true });
+ READER_HOST_CONTEXT_OBSERVER.observe(document.body, { attributes: true });
+ }
+ return contextBody;
+ }
+
+ function ensureReaderPortal() {
+ if (READER_PORTAL_HOST?.isConnected && READER_PORTAL_ROOT) return READER_PORTAL_ROOT;
+ if (!PAGE_ROOT || !document.body) return null;
+ if (READER_PORTAL_HOST || READER_PORTAL_ROOT) destroyReaderPortal();
+ let host = PAGE_ROOT.querySelector(':scope > .ldp-reader-portal-host');
+ if (!host) {
+ host = makeElement('div');
+ host.className = 'ldp-reader-portal-host sciapp-ldp-owned';
+ PAGE_ROOT.appendChild(host);
+ }
+ host.classList.add('sciapp-ldp-owned');
+ const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
+ let shadowStyle = root.querySelector('style[data-ldp-reader-shadow]');
+ if (!shadowStyle) {
+ shadowStyle = style.cloneNode(true);
+ shadowStyle.dataset.ldpReaderShadow = '1';
+ root.prepend(shadowStyle);
+ }
+ let shadowGuards = root.querySelector('style[data-ldp-reader-shadow-guards]');
+ if (!shadowGuards) {
+ shadowGuards = makeElement('style');
+ shadowGuards.dataset.ldpReaderShadowGuards = '1';
+ shadowStyle.after(shadowGuards);
+ }
+ cloneReaderHostStyles(root, shadowStyle);
+ READER_PORTAL_CONTEXT_BODY = ensureReaderHostContext(root);
+ READER_PORTAL_HOST = host;
+ READER_PORTAL_ROOT = root;
+ syncReaderFontRenderingState();
+ return root;
+ }
+
+ function appendReaderPortalNode(node) {
+ const root = ensureReaderPortal();
+ if (!root) throw new Error('reader portal unavailable');
+ (READER_PORTAL_CONTEXT_BODY || root).appendChild(node);
+ applyReaderThemeVariables(node);
+ return node;
+ }
+
+ function readerPortalQuery(selector) {
+ return READER_PORTAL_ROOT?.querySelector(selector) || document.querySelector(selector);
+ }
+
+ function readerPortalQueryAll(selector) {
+ const nodes = READER_PORTAL_ROOT ? [...READER_PORTAL_ROOT.querySelectorAll(selector)] : [];
+ document.querySelectorAll(selector).forEach((node) => {
+ if (!nodes.includes(node)) nodes.push(node);
+ });
+ return nodes;
+ }
+
+ function ensureReaderIconTooltip() {
+ const root = ensureReaderPortal();
+ if (!root || root._ldpReaderIconTooltipReady) return;
+ root._ldpReaderIconTooltipReady = true;
+ const tooltip = makeElement('div');
+ tooltip.className = 'ldp-reader-icon-tooltip ldp-transient-surface';
+ tooltip.setAttribute('role', 'tooltip');
+ tooltip.hidden = true;
+ (READER_PORTAL_CONTEXT_BODY || root).appendChild(tooltip);
+ const tooltipScope = createLifecycleScope();
+ const on = (target, type, listener, options) =>
+ tooltipScope.listen(target, type, listener, options);
+ let activeControl = null;
+ const hide = () => {
+ if (!activeControl && tooltip.hidden && !tooltip.textContent) return;
+ activeControl = null;
+ setHidden(tooltip, true);
+ setText(tooltip, '');
+ };
+ const hasVisibleText = (control) => [...control.childNodes].some((node) => {
+ if (node.nodeType === Node.TEXT_NODE) return !!String(node.textContent || '').trim();
+ if (!(node instanceof Element) || node.matches('.ldp-icon,.ldp-logo,.ldp-notification-unread-badge')) return false;
+ const computed = getComputedStyle(node);
+ if (computed.display === 'none' || computed.visibility === 'hidden') return false;
+ return !!String(node.innerText || node.textContent || '').trim();
+ });
+ const tooltipControl = (target) => {
+ if (!(target instanceof Element)) return null;
+ const control = target.closest('button,a,[role="button"],[data-ldp-tooltip-label],.ldp-nested-branch-toggle,.ldp-avatar-flair,.ldp-user-card-badge');
+ const inReaderSurface = control && (control.closest('.ldp-overlay,.ldp-lightbox') ||
+ control.closest('.ldp-user-card-fallback,.ldp-avatar-viewer') || control.matches('.ldp-native-reader-trigger'));
+ if (!inReaderSurface || control.hasAttribute('data-tooltip') ||
+ control.matches('.ldp-settings-tab,.ldp-topic-timeline-track,[data-reaction-picker][aria-expanded="true"]')) return null;
+ const isFunctionalButton = control.matches('button,[role="button"],.ldp-nested-branch-toggle');
+ const isIconOnlyLink = control.matches('a') && control.querySelector('.ldp-icon,.ldp-logo,img') && !hasVisibleText(control);
+ const isNamedCopyTarget = control.matches('.ldp-avatar-flair,.ldp-user-card-badge');
+ const isNamedTooltipTarget = control.hasAttribute('data-ldp-tooltip-label');
+ if (!isFunctionalButton && !isIconOnlyLink && !isNamedCopyTarget && !isNamedTooltipTarget) return null;
+ const isTruncatedNarrowTitle = control.matches('.ldp-title-jump') &&
+ (control.closest('.ldp-modal')?.getBoundingClientRect().width || 0) <= 480 &&
+ control.scrollWidth > control.clientWidth + 1;
+ const label = String(isTruncatedNarrowTitle
+ ? control.textContent
+ : control.getAttribute('aria-label') || control.dataset.ldpTooltipLabel || '').trim();
+ return label ? { control, label } : null;
+ };
+ const position = (control, pointerEvent = null) => {
+ const rect = control.getBoundingClientRect();
+ const tipRect = tooltip.getBoundingClientRect();
+ const gap = 6;
+ const edge = 8;
+ if (control.matches('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle') && pointerEvent &&
+ Number.isFinite(pointerEvent.clientX) && Number.isFinite(pointerEvent.clientY)) {
+ const pointerGap = 12;
+ let left = pointerEvent.clientX + pointerGap;
+ if (left + tipRect.width > window.innerWidth - edge)
+ left = pointerEvent.clientX - tipRect.width - pointerGap;
+ let top = pointerEvent.clientY + pointerGap;
+ if (top + tipRect.height > window.innerHeight - edge)
+ top = pointerEvent.clientY - tipRect.height - pointerGap;
+ tooltip.style.left = `${Math.round(Math.max(edge, Math.min(left, window.innerWidth - tipRect.width - edge)))}px`;
+ tooltip.style.top = `${Math.round(Math.max(edge, Math.min(top, window.innerHeight - tipRect.height - edge)))}px`;
+ return;
+ }
+ let left = rect.left + (rect.width - tipRect.width) / 2;
+ left = Math.max(edge, Math.min(left, window.innerWidth - tipRect.width - edge));
+ let top = rect.top - tipRect.height - gap;
+ if (top < edge) top = Math.min(window.innerHeight - tipRect.height - edge, rect.bottom + gap);
+ tooltip.style.left = `${Math.round(left)}px`;
+ tooltip.style.top = `${Math.round(Math.max(edge, top))}px`;
+ };
+ const show = (target, pointerEvent = null, resolvedMatch = null) => {
+ const match = resolvedMatch || tooltipControl(target);
+ if (!match) return;
+ activeControl = match.control;
+ setText(tooltip, match.label);
+ tooltip.classList.toggle('ldp-reader-history-tooltip', match.control.matches('.ldp-reader-history-nav'));
+ tooltip.classList.toggle('ldp-connect-help-tooltip', match.control.matches('.ldp-connect-metric'));
+ setHidden(tooltip, false);
+ position(match.control, pointerEvent);
+ };
+ const keepOpen = (control) => control && (control.matches(':hover') || control.matches(':focus-visible'));
+ on(root, 'ldp-tooltip-refresh', (event) => {
+ if (!(event.target instanceof Element)) return;
+ const control = event.target;
+ if (activeControl && !activeControl.isConnected) hide();
+ const match = tooltipControl(control);
+ if (!match || control.hidden || !keepOpen(control)) {
+ if (control === activeControl) hide();
+ return;
+ }
+ show(control, null, match);
+ });
+ on(root, 'click', (event) => {
+ const copyTarget = event.target instanceof Element &&
+ event.target.closest('.ldp-avatar-flair,.ldp-user-card-badge');
+ if (!copyTarget) return;
+ consumeEvent(event);
+ const label = String(copyTarget.dataset.ldpTooltipLabel || copyTarget.getAttribute('aria-label') || '').trim();
+ if (!label) return;
+ const showCopyState = (message) => {
+ copyTarget.dataset.ldpTooltipLabel = message;
+ setLabel(copyTarget, message);
+ refreshTooltips(copyTarget);
+ setTimeout(() => {
+ if (!copyTarget.isConnected || copyTarget.dataset.ldpTooltipLabel !== message) return;
+ copyTarget.dataset.ldpTooltipLabel = label;
+ setLabel(copyTarget, label);
+ refreshTooltips(copyTarget);
+ }, 900);
+ };
+ void writeSelectionQuoteToClipboard(label)
+ .then(() => showCopyState('已复制'))
+ .catch(() => showCopyState('复制失败'));
+ }, true);
+ on(root, 'pointerover', (event) => {
+ const match = tooltipControl(event.target);
+ if (!match || (event.relatedTarget instanceof Node && match.control.contains(event.relatedTarget))) return;
+ show(match.control, event, match);
+ });
+ on(root, 'pointerdown', (event) => {
+ if (event.target instanceof Element &&
+ event.target.closest('.ldp-header[data-ldp-reader-drag-surface]')) hide();
+ }, true);
+ on(root, 'pointermove', (event) => {
+ if (!activeControl?.matches('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle') ||
+ !(event.target instanceof Node) || !activeControl.contains(event.target)) return;
+ position(activeControl, event);
+ });
+ on(root, 'pointerout', (event) => {
+ if (!activeControl || (event.relatedTarget instanceof Node && activeControl.contains(event.relatedTarget))) return;
+ if (!activeControl.matches(':focus-visible')) hide();
+ });
+ on(root, 'focusin', (event) => show(event.target));
+ on(root, 'focusout', () => {
+ queueMicrotask(() => {
+ if (!keepOpen(activeControl)) hide();
+ });
+ });
+ on(root, 'scroll', () => {
+ const hoveredHistoryControl = root.querySelector('.ldp-reader-history-nav:hover');
+ if (hoveredHistoryControl) show(hoveredHistoryControl);
+ else hide();
+ }, true);
+ on(window, 'resize', hide);
+ READER_ICON_TOOLTIP_CLEANUP = () => {
+ hide();
+ tooltipScope.destroy();
+ tooltip.remove();
+ root._ldpReaderIconTooltipReady = false;
+ };
+ }
+
+ function readerActiveElement() {
+ let active = document.activeElement;
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
+ return active;
+ }
+
+ function readerSelection() {
+ const candidates = [];
+ const addCandidate = (selection) => {
+ if (selection && !candidates.includes(selection)) candidates.push(selection);
+ };
+ if (isFunction(READER_PORTAL_ROOT?.getSelection)) {
+ try { addCandidate(READER_PORTAL_ROOT.getSelection()); } catch {}
+ }
+ try { addCandidate(window.getSelection()); } catch {}
+ return candidates.find((selection) => selection.rangeCount > 0 && !selection.isCollapsed)
+ || candidates.find((selection) => selection.rangeCount > 0)
+ || candidates[0]
+ || null;
+ }
+
+ function eventPathClosest(event, selector) {
+ const path = isFunction(event?.composedPath) ? event.composedPath() : [];
+ const matched = path.find((node) => node && node.nodeType === Node.ELEMENT_NODE && node.matches(selector));
+ if (matched) return matched;
+ return event?.target && event.target.closest ? event.target.closest(selector) : null;
+ }
+
+ function eventPathIncludes(event, node) {
+ if (!event || !node) return false;
+ const path = isFunction(event.composedPath) ? event.composedPath() : [];
+ return path.includes(node) || node.contains(event.target);
+ }
+
+ function containFloatingSurfaceWheel(surface, event) {
+ event.stopPropagation();
+ const target = event.target instanceof Element ? event.target : null;
+ const lineScale = 40;
+ const deltaX = event.deltaMode === 1
+ ? event.deltaX * lineScale
+ : event.deltaMode === 2 ? event.deltaX * surface.clientWidth : event.deltaX;
+ const deltaY = event.deltaMode === 1
+ ? event.deltaY * lineScale
+ : event.deltaMode === 2 ? event.deltaY * surface.clientHeight : event.deltaY;
+ if (!target || !surface.contains(target)) {
+ event.preventDefault();
+ return;
+ }
+ let scrollTarget = target;
+ while (scrollTarget && surface.contains(scrollTarget)) {
+ const style = getComputedStyle(scrollTarget);
+ const maxScrollLeft = scrollTarget.scrollWidth - scrollTarget.clientWidth;
+ const maxScrollTop = scrollTarget.scrollHeight - scrollTarget.clientHeight;
+ const canScrollX = deltaX && maxScrollLeft > 1 && /(auto|scroll|overlay)/.test(style.overflowX)
+ && (deltaX < 0 ? scrollTarget.scrollLeft > 0 : scrollTarget.scrollLeft < maxScrollLeft - 1);
+ const canScrollY = deltaY && maxScrollTop > 1 && /(auto|scroll|overlay)/.test(style.overflowY)
+ && (deltaY < 0 ? scrollTarget.scrollTop > 0 : scrollTarget.scrollTop < maxScrollTop - 1);
+ if (canScrollX || canScrollY) return;
+ if (scrollTarget === surface) break;
+ scrollTarget = scrollTarget.parentElement;
+ }
+ if (deltaX || deltaY) event.preventDefault();
+ }
+
+ function bindFloatingSurfaceWheel(surface) {
+ if (!surface || surface._ldpFloatingSurfaceWheelBound) return;
+ surface._ldpFloatingSurfaceWheelBound = true;
+ surface.addEventListener('wheel', (event) => containFloatingSurfaceWheel(surface, event), { passive: false });
+ }
+
+ function createLifecycleScope() {
+ const cleanups = [];
+ let destroyed = false;
+ const scope = {
+ add(cleanup) {
+ if (destroyed) cleanup();
+ else cleanups.push(cleanup);
+ return cleanup;
+ },
+ listen(target, type, listener, options) {
+ target.addEventListener(type, listener, options);
+ scope.add(() => target.removeEventListener(type, listener, options));
+ return listener;
+ },
+ observe(observer, target, options) {
+ observer.observe(target, options);
+ scope.add(() => observer.disconnect());
+ return observer;
+ },
+ timer(callback, delay) {
+ const timerId = setTimeout(callback, delay);
+ scope.add(() => clearTimeout(timerId));
+ return timerId;
+ },
+ raf(callback) {
+ const frameId = requestAnimationFrame(callback);
+ scope.add(() => cancelAnimationFrame(frameId));
+ return frameId;
+ },
+ destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ cleanups.splice(0).reverse().forEach((cleanup) => {
+ try { cleanup(); } catch (error) { console.warn('[LDP] lifecycle cleanup failed', error); }
+ });
+ },
+ };
+ return scope;
+ }
+
+ function createReaderSurfaceManager(overlay) {
+ const parking = document.createDocumentFragment();
+ return {
+ parking,
+ mount(surface, host = overlay) {
+ if (surface && host && surface.parentNode !== host) host.append(surface);
+ return surface;
+ },
+ park(surface) {
+ if (surface && surface.parentNode !== parking) parking.append(surface);
+ return surface;
+ },
+ destroy() {
+ releasePersistentAvatarImages(parking);
+ parking.replaceChildren();
+ },
+ };
+ }
+
+ function syncTabSelection(tabs, dataKey, selectedValue, roving = false) {
+ tabs.forEach((tab) => {
+ const active = tab.dataset[dataKey] === selectedValue;
+ tab.classList.toggle('active', active);
+ tab.setAttribute('aria-selected', String(active));
+ if (roving) tab.tabIndex = active ? 0 : -1;
+ });
+ }
+
+ function bindReaderPanelSearch(input, clearButton, update, delay = 0) {
+ let pending = 0;
+ const cancel = () => {
+ if (!pending) return;
+ if (delay) clearTimeout(pending);
+ else cancelAnimationFrame(pending);
+ pending = 0;
+ };
+ const commit = () => {
+ pending = 0;
+ update(input.value.trim());
+ };
+ input.addEventListener('input', () => {
+ clearButton.hidden = !input.value;
+ cancel();
+ pending = delay ? setTimeout(commit, delay) : requestAnimationFrame(commit);
+ });
+ clearButton.addEventListener('click', () => {
+ cancel();
+ input.value = '';
+ clearButton.hidden = true;
+ update('');
+ input.focus();
+ });
+ return cancel;
+ }
+
+ function bindReaderPanelPager(previousButton, nextButton, state, update) {
+ previousButton.closest('.ldp-notification-pager').addEventListener('click', (event) => {
+ const button = event.target instanceof Element ? event.target.closest('button') : null;
+ if (button === previousButton) {
+ if (state.page <= 0) return;
+ state.page--;
+ } else if (button === nextButton) {
+ if (state.hasNext === false) return;
+ state.page++;
+ } else return;
+ update();
+ });
+ }
+
+ function toggleReaderPanelSelection(selection, ids) {
+ const fullySelected = ids.length > 0 && ids.every((id) => selection.has(id));
+ ids.forEach((id) => {
+ if (fullySelected) selection.delete(id);
+ else selection.add(id);
+ });
+ }
+
+ function bindReaderPanelSelectionList(list, checkboxSelector, itemSelector, idKey, selection, render) {
+ list.addEventListener('change', (event) => {
+ const checkbox = event.target.closest(checkboxSelector);
+ const id = Number(checkbox?.closest(itemSelector)?.dataset[idKey]);
+ if (!(id > 0)) return;
+ selection[checkbox.checked ? 'add' : 'delete'](id);
+ render();
+ });
+ }
+
+ function bindReaderPanelMultiMode(root, state, selection, startButton, doneButton, scopeSelect, render, reset = () => {}) {
+ const clear = () => {
+ reset();
+ selection.clear();
+ render();
+ };
+ root.addEventListener('click', (event) => {
+ const button = event.target instanceof Element ? event.target.closest('button') : null;
+ if (button === startButton) {
+ state.multi = true;
+ state.scope = 'page';
+ } else if (button === doneButton)
+ state.multi = false;
+ else return;
+ clear();
+ });
+ scopeSelect.addEventListener('change', () => {
+ state.scope = scopeSelect.value === 'all' ? 'all' : 'page';
+ selection.clear();
+ render();
+ });
+ }
+
+ function handleSearchableMenuKeyDown(event, searchInput, optionsRoot, optionSelector, closeMenu) {
+ if (event.key === 'Escape') {
+ consumeEvent(event);
+ closeMenu(true);
+ return;
+ }
+ if (event.key === 'Enter' && event.target === searchInput) {
+ event.preventDefault();
+ optionsRoot.querySelector(optionSelector)?.click();
+ return;
+ }
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
+ const options = [...optionsRoot.querySelectorAll(optionSelector)];
+ if (!options.length) return;
+ event.preventDefault();
+ const currentIndex = options.indexOf(document.activeElement);
+ const direction = event.key === 'ArrowDown' ? 1 : -1;
+ const nextIndex = currentIndex < 0
+ ? (direction > 0 ? 0 : options.length - 1)
+ : (currentIndex + direction + options.length) % options.length;
+ options[nextIndex].focus();
+ }
+
+ /* ============ 2. 工具 ============ */
+ const esc = (s) => String(s ?? '').replace(/[<>&]/g, (c) =>
+ ({ '<': '<', '>': '>', '&': '&' }[c]));
+ const escAttr = (s) => esc(s).replace(/"/g, '"').replace(/'/g, ''');
+ function consumeEvent(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ function refreshTooltips(target) {
+ target.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true }));
+ }
+ function setExpanded(target, expanded) {
+ const value = String(expanded);
+ if (target.getAttribute('aria-expanded') !== value) target.setAttribute('aria-expanded', value);
+ }
+ function setPressed(target, pressed) {
+ const value = String(pressed);
+ if (target.getAttribute('aria-pressed') !== value) target.setAttribute('aria-pressed', value);
+ }
+ function setLabel(target, label) {
+ if (target.getAttribute('aria-label') !== label) target.setAttribute('aria-label', label);
+ }
+ function setHidden(target, hidden) {
+ if (target && target.hidden !== hidden) target.hidden = hidden;
+ }
+ function setText(target, text) {
+ if (target && target.textContent !== text) target.textContent = text;
+ }
+ function setAttributeValue(target, name, value) {
+ const next = String(value);
+ if (target && target.getAttribute(name) !== next) target.setAttribute(name, next);
+ }
+ function setStyleValue(target, name, value) {
+ const next = String(value);
+ if (target && target.style.getPropertyValue(name) !== next) target.style.setProperty(name, next);
+ }
+ function isFunction(value) {
+ return typeof value === 'function';
+ }
+ function prefersReducedMotion() {
+ return isFunction(window.matchMedia) &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ }
+
+ const ICONS = {
+ activity: '
',
+ database: '
',
+ image: '
',
+ type: '
',
+ alertTriangle: '
',
+ bell: '
',
+ headerBell: '
',
+ bellOff: '
',
+ bookmark: '
',
+ headerBookmark: '
',
+ check: '
',
+ square: '
',
+ squareCheck: '
',
+ circleX: '
',
+ arrowUp: '
',
+ chevronDown: '
',
+ chevronUp: '
',
+ chevronRight: '
',
+ download: '
',
+ upload: '
',
+ externalLink: '
',
+ copy: '
',
+ eyeOff: '
',
+ mail: '
',
+ palette: '
',
+ award: '
',
+ tag: '
',
+ heart: '
',
+ hand: '
',
+ history: '
',
+ headerHistory: '
',
+ info: '
',
+ lock: '
',
+ unlock: '
',
+ pin: '
',
+ pencil: '
',
+ trash: '
',
+ wrench: '
',
+ loader: '
',
+ maximize2: '
',
+ layoutGrid: '
',
+ panelLeft: '
',
+ panelRight: '
',
+ floatingWindow: '
',
+ messageSquare: '
',
+ minus: '
',
+ minimize2: '
',
+ cornerDownRight: '
',
+ reply: '
',
+ plus: '
',
+ rocket: '
',
+ sun: '
',
+ moon: '
',
+ monitor: '
',
+ settings: '
',
+ headerSettings: '
',
+ languages: '
',
+ smile: '
',
+ search: '
',
+ at: '
',
+ flag: '
',
+ link2: '
',
+ lightbulb: '
',
+ list: '
',
+ layers: '
',
+ gitBranch: '
',
+ listChecks: '
',
+ rotateCcw: '
',
+ share2: '
',
+ shieldHalved: '
',
+ userPlus: '
',
+ userRound: '
',
+ x: '
',
+ };
+
+ function icon(name, extraClass) {
+ return ``;
+ }
+ function iconActionButtons(className, actionAttribute, actions) {
+ return actions.map(([action, label, iconName, attributes = '']) =>
+ `
`
+ ).join('');
+ }
+
+ const APPEARANCE_SETTING_FIELDS = [
+ { key: 'accentColor', section: 'interaction', label: '按钮与选中状态颜色',
+ help: '控制阅读器的主强调色,包括主要按钮、选中态、焦点轮廓、开关开启态、时间轴和操作反馈;不会改变错误、警告、成功和点赞等语义颜色。选择时实时预览,统一保存。' },
+ { key: 'linkColor', section: 'interaction', label: '正文链接颜色',
+ help: '控制帖子正文中的 URL 链接颜色,与按钮和选中态的界面强调色分开设置;选择时实时预览,统一保存。' },
+ { key: 'zebraColor', section: 'zebra', label: '背景颜色', ariaLabel: '交替楼层背景颜色',
+ help: '设置阅读器正文楼层隔行出现的淡色背景,用来更容易分清相邻楼层。选择时实时预览,通过底部“保存全部更改”统一保存。' },
+ { key: 'zebraRadius', section: 'zebra', label: '背景圆角', ariaLabel: '交替楼层背景圆角', range: [ZEBRA_RADIUS_MIN, ZEBRA_RADIUS_MAX, ZEBRA_RADIUS_STEP], normalize: (value, fallback = ZEBRA_RADIUS_DEFAULT) => normalizeSteppedValue(value, fallback, ZEBRA_RADIUS_MIN, ZEBRA_RADIUS_MAX, ZEBRA_RADIUS_STEP),
+ help: `设置交替楼层背景的圆角,${ZEBRA_RADIUS_MIN}px 为直角,最高 ${ZEBRA_RADIUS_MAX}px。拖动时实时预览,通过底部“保存全部更改”统一保存。` },
+ { key: 'listZebraColor', section: 'list-zebra', label: '嵌入阅读列表背景', ariaLabel: '嵌入阅读时原站主题列表交替背景颜色', shareLabel: '嵌入阅读列表背景',
+ help: `使用左右嵌入阅读时,设置 ${SITE_DISPLAY_NAME} 主题列表卡片的隔行色调;所选颜色会与原站背景适度混合。选择时实时预览,通过底部“保存全部更改”统一保存。` },
+ { key: 'replyLineColor', section: 'reply-line', label: '颜色', ariaLabel: '回复连接线颜色',
+ help: '控制阅读器里的关系线颜色,包括二级回复连线、上级回复预览、时间轴、特殊提示边框和大图评论层级线。选择时实时预览,统一保存。' },
+ { key: 'replyLineWidth', section: 'reply-line', label: '粗细', ariaLabel: '回复连接线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = REPLY_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback),
+ help: `控制回复关系线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;二级回复树线、父子回复连接线和相关层级线会同步变化。拖动时实时预览,统一保存。` },
+ { key: 'replyLineRadius', section: 'reply-line', label: '转角圆角', ariaLabel: '回复连接线转角圆角', shareLabel: '回复连接线圆角', range: [REPLY_LINE_RADIUS_MIN, REPLY_LINE_RADIUS_MAX, REPLY_LINE_RADIUS_STEP], normalize: normalizeReplyLineRadius,
+ help: `控制父子回复和二级回复连接线转折处的圆角,${REPLY_LINE_RADIUS_MIN}px 为直角,最高 ${REPLY_LINE_RADIUS_MAX}px。拖动时实时预览,统一保存。` },
+ { key: 'quoteLineColor', section: 'quote-line', label: '颜色', ariaLabel: '引用线颜色',
+ help: '控制帖子正文引用块和“引用某楼层”卡片左侧竖线的颜色,只改变引用提示线,不改变引用文字或背景。选择时实时预览,统一保存。' },
+ { key: 'quoteLineWidth', section: 'quote-line', label: '粗细', ariaLabel: '引用线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = QUOTE_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback),
+ help: `控制引用线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;不同引用样式会保留原有粗细层级。拖动时实时预览,统一保存。` },
+ { key: 'dividerLineColor', section: 'divider-line', label: '颜色', ariaLabel: '界面分隔线颜色',
+ help: '控制楼层边界、标题栏、设置与通知面板、嵌入阅读边界和大图查看器结构分隔线的颜色;按钮和输入框外框不会跟着改变。选择时实时预览,统一保存。' },
+ { key: 'dividerLineWidth', section: 'divider-line', label: '粗细', ariaLabel: '界面分隔线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = DIVIDER_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback),
+ help: `控制界面结构分隔线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;按钮和输入框外框不会跟着改变。拖动时实时预览,统一保存。` },
+ { key: 'floorPreviewColor', section: 'floor-preview', label: '卡片强调颜色', ariaLabel: '上级回复预览卡片强调颜色', shareLabel: '上级回复预览颜色',
+ help: '控制父楼层号悬停后插入的预览卡片边框、淡色背景和楼层标记颜色;不改变二级回复关系线。选择时实时预览,统一保存。' },
+ { key: 'floorPreviewHeight', section: 'floor-preview', label: '卡片最大高度', ariaLabel: '上级回复预览卡片最大高度', shareLabel: '上级回复预览高度', range: [FLOOR_PREVIEW_HEIGHT_MIN, FLOOR_PREVIEW_HEIGHT_MAX, FLOOR_PREVIEW_HEIGHT_STEP], normalize: (value, fallback = FLOOR_PREVIEW_HEIGHT_DEFAULT) => normalizeSteppedValue(value, fallback, FLOOR_PREVIEW_HEIGHT_MIN, FLOOR_PREVIEW_HEIGHT_MAX, FLOOR_PREVIEW_HEIGHT_STEP),
+ help: `控制父回复插入预览在长内容时允许占用的最大高度,最低 ${FLOOR_PREVIEW_HEIGHT_MIN}px;内容较短时仍按实际高度显示,并始终受当前窗口可用高度限制。拖动时实时预览,统一保存。` },
+ ];
+ const APPEARANCE_SETTING_GROUPS = [
+ { id: 'interaction', title: '按钮与链接', description: '控制按钮、选中状态、焦点、时间轴和正文链接,不改变错误、警告、成功等状态颜色。', sections: ['interaction'] },
+ { id: 'background', title: '交替内容背景', description: '用浅色背景区分相邻楼层,以及嵌入阅读时的原站主题列表卡片。',
+ sections: [{ id: 'zebra', title: '交替楼层背景' }, 'list-zebra'] },
+ { id: 'structure', title: '关系线与分隔线', description: '分别控制回复连接线、引用线和界面分隔线;关闭后隐藏这些线条,已设置的样式会保留。',
+ toggleKey: 'structureColorsEnabled', toggleLabel: '显示关系线与分隔线',
+ sections: [{ id: 'reply-line', title: '回复连接线' }, { id: 'quote-line', title: '引用线' }, { id: 'divider-line', title: '界面分隔线' }] },
+ { id: 'floor-preview', title: '上级回复预览卡片', description: '控制悬停上级楼层号后插入的预览卡片,不影响回复连接线和其他内容。', sections: ['floor-preview'] },
+ ];
+
+ function appearanceSettingClassName(key) {
+ return `ldp-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
+ }
+
+ function appearanceSettingRowMarkup(field) {
+ const className = appearanceSettingClassName(field.key);
+ const input = field.range
+ ? `
`
+ : `
`;
+ return `
+ ${field.label}
+
+ ${input}
+
+
+
`;
+ }
+
+ function appearanceSettingGroupMarkup(group) {
+ const sections = group.sections.map((section) => {
+ const { id, title } = typeof section === 'string' ? { id: section } : section;
+ const rows = APPEARANCE_SETTING_FIELDS.filter((field) => field.section === id)
+ .map(appearanceSettingRowMarkup).join('');
+ if (!title) return rows;
+ const titleId = `ldp-${id}-setting-group-title`;
+ return `
`;
+ }).join('');
+ const toggle = group.toggleKey
+ ? `
+
+
+
+
+ `
+ : '';
+ return `
+
+
+
${group.title}
+
${group.description}
+
+ ${toggle}
+
+ ${sections}
+ `;
+ }
+
+ function jumpHighlightSettingsMarkup() {
+ return JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => {
+ const className = `ldp-flash-${field.key}`;
+ const limits = JUMP_HIGHLIGHT_LIMITS[field.key];
+ const input = field.type === 'color'
+ ? `
`
+ : `
`;
+ return `
+ ${field.label}
+
+ ${input}<${field.type === 'color' ? 'span' : 'output'} class="${className}-value${field.type === 'color' ? '' : ' ldp-flash-value'}">${field.type === 'color' ? 'span' : 'output'}>
+
+ `;
+ }).join('');
+ }
+
+ function settingsCategoryMarkup(id, title, description, content,
+ contentClass = 'ldp-settings-category-content', descriptionClass = '') {
+ const titleId = `ldp-${id}-title`;
+ return `
+
+ ${title}
+ ${description}
+
+ ${content}
+ `;
+ }
+
+ function otherSettingGroupMarkup(id, title, description, content) {
+ const titleId = `ldp-other-${id}-title`;
+ return `
+
+ ${title}
+ ${description}
+
+ ${content}
+ `;
+ }
+
+ function performanceSettingsMarkup() {
+ return PERFORMANCE_SETTING_GROUPS.map((group) => settingsCategoryMarkup(
+ `performance-${group.id}`, group.title, group.description,
+ group.fields.map((field) => {
+ const limits = PERFORMANCE_LIMITS[field.key];
+ return `
+ ${field.title}${field.description}
+ ${field.unit}
+ `;
+ }).join(''),
+ 'ldp-settings-fields ldp-settings-category-list ldp-performance-fields'
+ )).join('');
+ }
+
+ const SETTINGS_PANEL_META = [
+ ['user', 'userRound', '用户信息', '集中查看当前账号资料、社区统计、Connect 升级进度与 LDC 账户数据。'],
+ ['image', 'image', '图片设置', '设置大图查看器的默认打开方式,以及帖子正文图片的显示大小。'],
+ ['font', 'type', '字体设置', '分别设置阅读器界面、帖子正文、回复输入框,以及嵌入阅读时原站主题列表的文字显示。'],
+ ['layout', 'layoutGrid', '布局设置', '调整正文区域、楼层时间轴、两者间距和左右留白的宽度比例。'],
+ ['window', 'floatingWindow', '浮窗设置', '设置桌面浮窗的大小、位置,以及点击外部时是否保持显示和是否允许拖动。'],
+ ['appearance', 'palette', '外观设置', '调整按钮与链接、交替背景、关系线、分隔线和上级回复预览卡片。'],
+ ['flash', 'lightbulb', '动画与提示', '设置跳转到指定楼层时的闪烁提示,以及打开或切换帖子时的加载动画。'],
+ ['reading', 'headerHistory', '阅读与导航', '管理阅读队列、历史导航、帖子打开位置与阅读器退出方式。'],
+ ['interaction', 'gitBranch', '帖子与回复', '管理主帖操作列、二级回复显示位置与 Boost 文本复制规则。'],
+ ['sites', 'wrench', '适用站点', '添加并管理可以启用增强阅读器的其他 HTTPS Discourse 论坛。'],
+ ['performance', 'rocket', '性能设置', '控制每次加载楼层数、页面保留范围、二级回复预加载和网络请求节奏;下次打开阅读器生效。'],
+ ['logs', 'activity', '日志记录', '在请求记录与性能记录之间切换;所有记录仅保留在当前页面内存中,不保存查询参数、请求正文、Cookie、响应内容或个人数据。'],
+ ['cache', 'database', '数据管理', '导入或导出阅读器设置,并查看、清理当前浏览器保存的本地缓存。'],
+ ['about', 'info', '关于', `专注于长帖阅读、楼层关系和原生社区操作的 ${SITE_DISPLAY_NAME} 阅读工作台。`],
+ ];
+
+ const SETTINGS_NAV_GROUPS = [
+ ['显示与布局', ['image', 'font', 'layout', 'window', 'appearance', 'flash']],
+ ['阅读与交互', ['reading', 'interaction']],
+ ['系统与数据', ['sites', 'performance', 'logs', 'cache', 'about']],
+ ];
+
+ function settingsNavigationMarkup() {
+ const panels = new Map(SETTINGS_PANEL_META.map((panel) => [panel[0], panel]));
+ const button = (id, active = false) => {
+ const [, iconName, title] = panels.get(id);
+ return `
`;
+ };
+ return button('user', true) + SETTINGS_NAV_GROUPS.map(([label, ids]) =>
+ `
${label}${ids.map((id) => button(id)).join('')}
`).join('');
+ }
+
+ function settingsPanelIntroMarkup(id) {
+ const panel = SETTINGS_PANEL_META.find(([panelId]) => panelId === id);
+ if (!panel) return '';
+ const extra = id === 'user'
+ ? `
最后刷新 · 暂无`
+ : '';
+ return `
${panel[4] || panel[2]}
${panel[3]}
${extra}
`;
+ }
+
+ function mountSettingsPanelIntros(root) {
+ root.querySelectorAll('.ldp-settings-section[data-settings-panel]').forEach((section) => {
+ section.insertAdjacentHTML('afterbegin', settingsPanelIntroMarkup(section.dataset.settingsPanel));
+ });
+ }
+
+ function settingsFooterMarkup(statusClass = '', resetLabel = '', resetClass = '') {
+ return ``;
+ }
+
+ function readerWindowGeometryMarkup() {
+ return [
+ ['width', `浮窗宽度(最小 ${READER_WINDOW_MIN_WIDTH}px)`, READER_WINDOW_MIN_WIDTH],
+ ['height', `浮窗高度(最小 ${READER_WINDOW_MIN_HEIGHT}px)`, READER_WINDOW_MIN_HEIGHT],
+ ['x', '距浏览器左侧', READER_WINDOW_MARGIN],
+ ['y', '距浏览器顶部', READER_WINDOW_MARGIN],
+ ].map(([key, label, min]) => `
${label}
+ px`).join('');
+ }
+
+ function aboutFeaturesMarkup() {
+ return [
+ ['layoutGrid', '响应式专注阅读', '同一阅读内核支持浮窗、全屏和左右嵌入,元素随容器宽度自动重排。'],
+ ['image', '完整内容与楼层关系', '二级回复、引用、时间轴、图片、视频、音频和 Markdown 提示块连贯呈现。'],
+ ['heart', '原生社区互动', '回复、点赞、Boost、回应、收藏、通知和帖子编辑无需离开阅读器。'],
+ ['rocket', '长帖数据与性能', '按需加载、缓存和请求节奏控制,并集中管理历史、收藏与回应。'],
+ ].map(([iconName, title, description]) => `
${icon(iconName)}
+ `).join('');
+ }
+
+ function popoverSearchMarkup(type, placeholder, label, clearLabel) {
+ return `
+ ${icon('search')}
+
+
+ `;
+ }
+
+ function popoverPagerMarkup(type) {
+ const prefix = `ldp-${type}-page`;
+ const shared = type === 'notification' ? '' : ' ldp-notification-page';
+ return ``;
+ }
+
+ function collectionBulkActionsMarkup(type, scopeLabel, itemLabel, deleteLabel) {
+ return `
+
+
+
+
+
`;
+ }
+
+ const RESOURCE_MONITOR_ROWS = [
+ ['heap', '页面内存估计', '浏览器提供的页面级估算;不能按脚本拆分'],
+ ['longTasks', '最近 10 秒主线程卡顿', '次数 / 累计耗时;包含原站与阅读器'],
+ ['dom', '阅读器页面元素', '阅读器外壳内全部节点'],
+ ['floors', '楼层列表元素', '页面显示 / 内存保留'],
+ ['nested', '二级回复缓存', '已建立回复状态的父楼层组'],
+ ['media', '正文媒体元素', '图片、音视频与内嵌页面'],
+ ['requests', '当前网络请求', '进行中 / 排队'],
+ ['network', '最近 60 秒网络', '请求数 / 已知传输量'],
+ ];
+ const RESOURCE_MONITOR_STAT_KEYS = Object.freeze([
+ 'heapUsed', 'longTaskCount', 'longTaskDuration', 'dom', 'mountedFloors', 'retainedFloors',
+ 'nested', 'media', 'activeRequests', 'queuedRequests', 'readerRequestCount', 'readerTransfer',
+ ]);
+
+ function resourceMonitorRowMarkup([key, label, detail]) {
+ return `
+ ${label}${detail}
+ —
+ ———
+
`;
+ }
+
+ const SETTING_SWITCH_FIELDS = [
+ { section: 'image-lightbox', className: 'ldp-lightbox-comments-expanded', label: '打开大图时展开评论', description: '打开图片查看器时直接显示右侧评论;关闭后仍可通过顶部评论按钮展开。' },
+ { section: 'image-lightbox', className: 'ldp-lightbox-description-expanded', label: '打开大图时展开图片说明', description: '图片说明可以收起,也可拖动底边调整高度;查看器会记住最后状态。' },
+ { section: 'image-lightbox', className: 'ldp-lightbox-original-by-default', label: '切换图片时优先加载原图', description: '打开查看器或前后切换时自动加载原图;关闭后先显示预览图,仍可手动查看原图。' },
+ { section: 'font-rendering', className: 'ldp-font-rendering-enabled', label: '启用字体显示优化', detailHtml: '
正在检测字体显示环境…' },
+ { section: 'font-rendering', className: 'ldp-font-rendering-on-host', label: `同时应用到 ${SITE_DISPLAY_NAME} 原站页面`, ariaLabel: `同时应用字体显示优化到 ${SITE_DISPLAY_NAME} 原站页面`, description: '默认开启;主题列表、帖子原页和其他原站界面也使用相同优化。' },
+ { section: 'history', className: 'ldp-history-buttons-always-visible', label: '始终显示前进和后退按钮', description: '历史中有可前进或后退的帖子时一直显示按钮;开启后不再使用边缘唤出范围。' },
+ { section: 'reader-queue', className: 'ldp-reader-queue-always-visible-empty', label: '队列为空时仍显示入口', description: '阅读队列没有帖子时仍显示入口;也可在队列图标右上角将其关闭。' },
+ { section: 'topic-opening', className: 'ldp-open-topics-first-post', label: '普通帖子从第 1 楼打开', description: '普通帖子链接默认从主楼开始;消息、历史和收藏仍打开各自指定的楼层。' },
+ { section: 'reader-exit', className: 'ldp-double-escape-close-reader', label: '按两次 Esc 关闭阅读器', description: '开启后可防止误触;默认关闭,按一次 Esc 即可关闭阅读器。' },
+ { section: 'reader-exit', className: 'ldp-confirm-native-composer-close', label: '关闭原生回复窗口前再次确认', description: '开启后,在阅读器内按 Esc、关闭或舍弃时,需要在 1.5 秒内重复同一操作。' },
+ { section: 'topic-actions', className: 'ldp-topic-action-rail-visible-setting', label: '始终显示主帖操作列', description: '一直显示回到顶部、点赞、回复、Boost 和收藏;其他帖子功能仍可展开。' },
+ { section: 'topic-actions', className: 'ldp-topic-action-rail-fixed-setting', label: '锁定操作列位置', description: '开启后不能拖动操作列;关闭后可长按收纳按钮并拖到其他位置。' },
+ { section: 'replies', className: 'ldp-expand-nested-replies-default', label: '在父回复下展开二级回复', description: '默认在父楼层下直接显示它收到的回复;关闭时同时关闭“完整讨论”视图。' },
+ { section: 'replies', className: 'ldp-expand-leaf-nested-replies', label: '在楼层列表中展开二级回复', description: '二级回复出现在楼层列表时默认显示完整正文;关闭时必须保留上面的父回复展开方式。', hidden: true },
+ { section: 'replies', className: 'ldp-aggregate-descendant-replies', label: '启用“完整讨论”视图', description: '在父回复下预览直接回复,并可打开包含后续回复的完整讨论。' },
+ { section: 'replies', className: 'ldp-hide-nested-reply-floors', label: '从楼层列表隐藏二级回复', description: '二级回复固定收纳到对应父楼层。', hidden: true },
+ ];
+
+ const INLINE_REPLY_TREE_DEPTH_OPTIONS = Object.freeze([
+ { value: 2, label: '2 层' },
+ { value: 3, label: '3 层(默认)' },
+ { value: 4, label: '4 层' },
+ { value: 5, label: '5 层' },
+ ]);
+ const INLINE_REPLY_TREE_ENABLED_DEFAULT = 3;
+
+ function inlineReplyTreeDepthSettingMarkup() {
+ const options = INLINE_REPLY_TREE_DEPTH_OPTIONS.map((option) =>
+ `
`
+ ).join('');
+ return `
+
+ 启用树状评论
+ 在二级回复下继续嵌套展示更深层回复。需先开启上方的「在父回复下展开二级回复」。
+
+
+
+
+
+
+
+
+ 树状嵌套层数
+ 主信息流内最多展示的回复嵌套层数。超出后仍可打开「完整讨论」。
+
+
+ `;
+ }
+
+ function settingSwitchMarkup(field) {
+ return `
+
+ ${field.label}
+ ${field.detailHtml || `${field.description}`}
+
+
+
+
+
+ `;
+ }
+
+ function settingSwitchesMarkup(section) {
+ return SETTING_SWITCH_FIELDS.filter((field) => field.section === section)
+ .map(settingSwitchMarkup).join('');
+ }
+
+ const BOOST_COPY_SETTING_ROWS = [
+ { label: '末尾内容方式', control: '
' },
+ { label: '前置文字', control: '
' },
+ { label: '数字前缀', className: ' ldp-boost-counter-row', control: '
' },
+ { label: '递增步长', className: ' ldp-boost-counter-row', control: '
' },
+ { label: '固定末尾文字', className: ' ldp-boost-text-row', hidden: true, control: '
' },
+ ];
+
+ function boostCopySettingsMarkup() {
+ return BOOST_COPY_SETTING_ROWS.map((row) => `
+ ${row.label}
+ ${row.control}
+ `).join('');
+ }
+
+ function fontFieldRowsMarkup(scope, config) {
+ const host = scope === 'host';
+ const rowClass = 'ldp-setting-row';
+ const reset = (field) => `
`;
+ const defaultFamily = host ? HOST_FONT_DEFAULTS.hostFontFamily : FONT_FAMILY_DEFAULT;
+ const row = (label, controlClass, control, field) => `
+ ${label}
+ ${control}${reset(field)}
+
`;
+ return [
+ row('字体', 'ldp-font-option-control', `
+
+
+
+
`, 'family'),
+ row('字重', 'ldp-font-option-control', `
+
`, 'weight'),
+ row('颜色', 'ldp-color-control ldp-font-color-control', `
+
+
跟随主题`, 'color'),
+ ].join('');
+ }
+
+ function fontScopeSettingsMarkup(scope) {
+ const config = FONT_SCOPE_CONFIG[scope];
+ const scaleClass = scope === 'interface' ? 'ldp-interface-font-scale' : scope === 'post' ? 'ldp-font-scale' : 'ldp-composer-font-scale';
+ return `
+ ${fontFieldRowsMarkup(scope, config)}
+
+ 字号
+
+
+
+
+
+
+
`;
+ }
+
+ function normalizePerformanceValue(name, value, fallback) {
+ const limits = PERFORMANCE_LIMITS[name];
+ const numeric = Number(value);
+ if (!limits || !Number.isFinite(numeric)) return fallback;
+ const clamped = Math.min(limits.max, Math.max(limits.min, numeric));
+ return limits.integer ? Math.round(clamped) : Math.round(clamped * 100) / 100;
+ }
+
+ function performanceConfigFromPrefs(prefs) {
+ const source = prefs || {};
+ const fallback = BALANCED_PERFORMANCE;
+ return PERFORMANCE_SETTING_KEYS.reduce((config, name) => {
+ config[name] = normalizePerformanceValue(
+ name,
+ source[PERFORMANCE_PREF_KEYS[name]],
+ fallback[name]
+ );
+ return config;
+ }, {});
+ }
+
+ function performancePresetForConfig(config) {
+ return Object.entries(PERFORMANCE_PRESETS).find(([, preset]) =>
+ PERFORMANCE_SETTING_KEYS.every((name) => config[name] === preset[name])
+ )?.[0] ?? 'custom';
+ }
+
+ function performancePrefsPatch(config, presetName = performancePresetForConfig(config)) {
+ return PERFORMANCE_SETTING_KEYS.reduce((patch, name) => {
+ patch[PERFORMANCE_PREF_KEYS[name]] = config[name];
+ return patch;
+ }, { performancePreset: presetName });
+ }
+
+ function roundLayoutRatio(value) {
+ return Math.round(Number(value) * 100) / 100;
+ }
+
+ function layoutRatioTotal(ratios) {
+ return roundLayoutRatio(LAYOUT_REGION_KEYS.reduce((sum, name) => sum + Number(ratios[name] || 0), 0));
+ }
+
+ function normalizeLayoutRatios(source, fallback = READER_LAYOUT_DEFAULT) {
+ const values = source || {};
+ const result = LAYOUT_REGION_KEYS.reduce((ratios, name) => {
+ const numeric = Number(values[name]);
+ const safe = Number.isFinite(numeric) ? numeric : fallback[name];
+ ratios[name] = roundLayoutRatio(Math.min(100, Math.max(LAYOUT_MIN_RATIOS[name], safe)));
+ return ratios;
+ }, {});
+ const total = layoutRatioTotal(result);
+ if (total <= 100) return result;
+ const minimumTotal = LAYOUT_REGION_KEYS.reduce((sum, name) => sum + LAYOUT_MIN_RATIOS[name], 0);
+ const extraBudget = 100 - minimumTotal;
+ const extras = LAYOUT_REGION_KEYS.reduce((map, name) => {
+ map[name] = Math.max(0, result[name] - LAYOUT_MIN_RATIOS[name]);
+ return map;
+ }, {});
+ const extraTotal = LAYOUT_REGION_KEYS.reduce((sum, name) => sum + extras[name], 0) || 1;
+ LAYOUT_REGION_KEYS.forEach((name) => {
+ result[name] = roundLayoutRatio(LAYOUT_MIN_RATIOS[name] + extraBudget * extras[name] / extraTotal);
+ });
+ let overflow = roundLayoutRatio(layoutRatioTotal(result) - 100);
+ if (overflow > 0) {
+ const correctionKey = LAYOUT_REGION_KEYS.find((name) => result[name] - overflow >= LAYOUT_MIN_RATIOS[name]);
+ if (correctionKey) result[correctionKey] = roundLayoutRatio(result[correctionKey] - overflow);
+ overflow = roundLayoutRatio(layoutRatioTotal(result) - 100);
+ if (overflow > 0) result.main = roundLayoutRatio(result.main - overflow);
+ }
+ return result;
+ }
+
+ function normalizeListReaderMode(value) {
+ return LIST_READER_MODES.includes(value) ? value : 'floating';
+ }
+
+ function normalizeTopicReaderMode(value) {
+ return TOPIC_READER_MODES.includes(value) ? value : 'fullpage';
+ }
+
+ function normalizeBoostCopyString(value, fallback = '') {
+ const normalized = String(value == null ? fallback : value).replace(/\s+/g, ' ');
+ return [...normalized].slice(0, BOOST_COPY_MAX_LENGTH).join('');
+ }
+
+ function boostCopyConfigFromPrefs(prefs) {
+ const source = prefs || {};
+ const mode = BOOST_COPY_MODES.includes(source.boostCopyMode)
+ ? source.boostCopyMode
+ : BOOST_COPY_DEFAULTS.mode;
+ const counterMarker = normalizeBoostCopyString(
+ source.boostCopyCounterMarker,
+ BOOST_COPY_DEFAULTS.counterMarker
+ ).trim() || BOOST_COPY_DEFAULTS.counterMarker;
+ const counterStep = Number(source.boostCopyCounterStep);
+ return {
+ mode,
+ prefix: normalizeBoostCopyString(source.boostCopyPrefix, BOOST_COPY_DEFAULTS.prefix),
+ counterMarker,
+ counterStep: Number.isFinite(counterStep)
+ ? Math.min(99, Math.max(1, Math.round(counterStep)))
+ : BOOST_COPY_DEFAULTS.counterStep,
+ fixedSuffix: normalizeBoostCopyString(source.boostCopyFixedSuffix, BOOST_COPY_DEFAULTS.fixedSuffix),
+ };
+ }
+
+ function normalizeJumpHighlightValue(name, value) {
+ const limits = JUMP_HIGHLIGHT_LIMITS[name];
+ const fallback = JUMP_HIGHLIGHT_DEFAULTS[name];
+ const numeric = Number(value);
+ if (!limits || !Number.isFinite(numeric)) return fallback;
+ const clamped = Math.min(limits.max, Math.max(limits.min, numeric));
+ if (limits.integer) return Math.round(clamped);
+ const stepped = Math.round(clamped / limits.step) * limits.step;
+ return Math.round(stepped * 100) / 100;
+ }
+
+ function jumpHighlightConfigFromPrefs(prefs) {
+ const source = prefs || {};
+ const color = String(source.jumpHighlightColor || '').trim().toLowerCase();
+ return {
+ color: /^#[0-9a-f]{6}$/.test(color) ? color : JUMP_HIGHLIGHT_DEFAULTS.color,
+ radius: normalizeJumpHighlightValue('radius', source.jumpHighlightRadius),
+ borderWidth: normalizeJumpHighlightValue('borderWidth', source.jumpHighlightBorderWidth),
+ rate: normalizeJumpHighlightValue('rate', source.jumpHighlightRate),
+ count: normalizeJumpHighlightValue('count', source.jumpHighlightCount),
+ };
+ }
+
+ function jumpHighlightPrefsPatch(config) {
+ return Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => [field.pref, config[field.key]]));
+ }
+
+ function jumpHighlightDurationMs(config = jumpHighlightConfigFromPrefs(PREFS)) {
+ return Math.round(1000 / config.rate);
+ }
+
+ function normalizeListReaderEmbedWidth(value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return READER_EMBED_DEFAULT_WIDTH;
+ return Math.max(READER_EMBED_MIN_WIDTH, Math.round(numeric));
+ }
+
+ function normalizeReaderWindowPreferenceGroup(prefs, prefix) {
+ const width = Number(prefs[`${prefix}Width`]);
+ const height = Number(prefs[`${prefix}Height`]);
+ const x = Number(prefs[`${prefix}X`]);
+ const y = Number(prefs[`${prefix}Y`]);
+ return {
+ [`${prefix}Width`]: Number.isFinite(width) && width > 0 ? Math.max(READER_WINDOW_MIN_WIDTH, Math.round(width)) : 0,
+ [`${prefix}Height`]: Number.isFinite(height) && height > 0 ? Math.max(READER_WINDOW_MIN_HEIGHT, Math.round(height)) : 0,
+ [`${prefix}X`]: Number.isFinite(x) && x > 0 ? Math.round(x) : 0,
+ [`${prefix}Y`]: Number.isFinite(y) && y > 0 ? Math.round(y) : 0,
+ [`${prefix}Locked`]: !!prefs[`${prefix}Locked`],
+ [`${prefix}Pinned`]: !!prefs[`${prefix}Pinned`],
+ };
+ }
+
+ function normalizeWindowGeometryPreferenceGroup(prefs, prefix) {
+ return ['Width', 'Height', 'X', 'Y'].reduce((result, suffix) => {
+ const value = Number(prefs[`${prefix}${suffix}`]);
+ result[`${prefix}${suffix}`] = Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
+ return result;
+ }, {});
+ }
+
+ function normalizeReaderThemeMode(value) {
+ return READER_THEME_MODES.includes(value) ? value : 'system';
+ }
+
+ function normalizeReaderLoadingAnimation(value) {
+ return value === 'random' || READER_LOADING_ANIMATION_KEYS.includes(value)
+ ? value : READER_LOADING_ANIMATION_DEFAULT;
+ }
+
+ function normalizeBookmarkTabOrder(value) {
+ const stored = Array.isArray(value)
+ ? [...new Set(value.filter((type) => BOOKMARK_TAB_TYPES.includes(type)))]
+ : [];
+ return [...stored, ...BOOKMARK_TAB_TYPES.filter((type) => !stored.includes(type))];
+ }
+
+ function normalizeInlineReplyTreeMaxDepth(value) {
+ const depth = Math.round(Number(value));
+ if (!Number.isFinite(depth)) return INLINE_REPLY_TREE_MAX_DEPTH_DEFAULT;
+ return Math.min(
+ INLINE_REPLY_TREE_MAX_DEPTH_MAX,
+ Math.max(INLINE_REPLY_TREE_MAX_DEPTH_MIN, depth),
+ );
+ }
+
+ function inlineReplyTreeMaxDepth() {
+ // Tree nesting requires inline secondary replies; otherwise stay at one level.
+ if (PREFS?.expandNestedRepliesByDefault !== true) return 1;
+ return normalizeInlineReplyTreeMaxDepth(PREFS?.inlineReplyTreeMaxDepth);
+ }
+
+ function inlineReplyTreeEnabled() {
+ return inlineReplyTreeMaxDepth() >= 2;
+ }
+
+ function postNestDepth(node, fallback = 0) {
+ if (!node) return Math.max(0, Number(fallback) || 0);
+ const raw = Number(node.dataset?.ldpNestDepth);
+ return Number.isFinite(raw) && raw >= 0 ? raw : Math.max(0, Number(fallback) || 0);
+ }
+
+ function normalizeReaderPrefs(prefs) {
+ const knownPrefs = Object.keys(DEFAULT_PREFS).reduce((result, key) => {
+ if (Object.hasOwn(prefs || {}, key)) result[key] = prefs[key];
+ return result;
+ }, {});
+ const performanceConfig = performanceConfigFromPrefs(knownPrefs);
+ const performancePrefs = Object.assign({}, DEFAULT_PREFS, knownPrefs, performancePrefsPatch(performanceConfig));
+ const boostCopy = boostCopyConfigFromPrefs(performancePrefs);
+ const jumpHighlight = jumpHighlightConfigFromPrefs(performancePrefs);
+ const expandLeafNestedReplies = performancePrefs.expandLeafNestedReplies === true;
+ const aggregateDescendantReplies = performancePrefs.aggregateDescendantReplies === true;
+ const expandNestedRepliesByDefault = aggregateDescendantReplies ||
+ (performancePrefs.expandNestedRepliesByDefault !== false || !expandLeafNestedReplies);
+ return Object.assign({}, performancePrefs, {
+ imageProfile: normalizeImageProfile(performancePrefs.imageProfile, IMAGE_PROFILE_DEFAULT),
+ lightboxOriginalByDefault: performancePrefs.lightboxOriginalByDefault === true,
+ lightboxCommentsExpandedByDefault: performancePrefs.lightboxCommentsExpandedByDefault === true,
+ lightboxDescriptionExpanded: performancePrefs.lightboxDescriptionExpanded === true,
+ lightboxDescriptionHeight: normalizeLightboxDescriptionHeight(performancePrefs.lightboxDescriptionHeight),
+ lightboxCommentsWidthPercent: normalizeLightboxCommentsWidth(performancePrefs.lightboxCommentsWidthPercent),
+ fontProfile: normalizeFontProfile(performancePrefs.fontProfile, READER_FONT_DEFAULT),
+ layoutProfile: normalizeLayoutRatios(performancePrefs.layoutProfile, READER_LAYOUT_DEFAULT),
+ fullpageLayoutProfile: normalizeLayoutRatios(
+ performancePrefs.fullpageLayoutProfile,
+ READER_FULLPAGE_LAYOUT_DEFAULT
+ ),
+ appearanceProfile: normalizeAppearanceProfile(performancePrefs.appearanceProfile, APPEARANCE_PROFILE_DEFAULT),
+ themeMode: normalizeReaderThemeMode(performancePrefs.themeMode),
+ fontRenderingEnabled: performancePrefs.fontRenderingEnabled !== false,
+ fontRenderingOnHost: performancePrefs.fontRenderingOnHost === true,
+ hostFontFamily: normalizeFontFamily(performancePrefs.hostFontFamily, HOST_FONT_DEFAULTS.hostFontFamily),
+ hostFontCustomFamily: normalizeCustomFontFamily(performancePrefs.hostFontCustomFamily, HOST_FONT_DEFAULTS.hostFontCustomFamily),
+ hostFontWeight: normalizeFontWeight(performancePrefs.hostFontWeight, HOST_FONT_DEFAULTS.hostFontWeight),
+ hostFontColor: normalizeHexColor(performancePrefs.hostFontColor, HOST_FONT_DEFAULTS.hostFontColor),
+ ...Object.fromEntries(Object.values(HOST_EMBED_SIZE_SETTINGS).map((config) => [
+ config.pref, normalizeHostEmbeddedSizeScale(performancePrefs[config.pref], config.defaultScale),
+ ])),
+ ...normalizeReaderWindowPreferenceGroup(performancePrefs, 'readerWindow'),
+ ...normalizeWindowGeometryPreferenceGroup(performancePrefs, COMPOSER_WINDOW_PREFIX),
+ topicReaderMode: normalizeTopicReaderMode(performancePrefs.topicReaderMode),
+ listReaderMode: normalizeListReaderMode(performancePrefs.listReaderMode),
+ listReaderEmbedWidth: normalizeListReaderEmbedWidth(performancePrefs.listReaderEmbedWidth),
+ historySortMode: performancePrefs.historySortMode === 'first-viewed' ? 'first-viewed' : 'recent-viewed',
+ bookmarkTabOrder: normalizeBookmarkTabOrder(performancePrefs.bookmarkTabOrder),
+ historyButtonsAlwaysVisible: performancePrefs.historyButtonsAlwaysVisible === true,
+ readerQueueAlwaysVisibleWhenEmpty: performancePrefs.readerQueueAlwaysVisibleWhenEmpty !== false,
+ historyEdgeTriggerPercent: normalizeHistoryEdgeTriggerPercent(performancePrefs.historyEdgeTriggerPercent),
+ loadingAnimation: normalizeReaderLoadingAnimation(performancePrefs.loadingAnimation),
+ translationMode: ['bilingual', 'translation'].includes(performancePrefs.translationMode)
+ ? performancePrefs.translationMode : 'original',
+ openTopicsAtFirstPost: performancePrefs.openTopicsAtFirstPost === true,
+ doubleEscapeToCloseReader: performancePrefs.doubleEscapeToCloseReader === true,
+ confirmNativeComposerClose: performancePrefs.confirmNativeComposerClose !== false,
+ topicActionRailVisible: performancePrefs.topicActionRailVisible !== false,
+ topicActionRailFixed: performancePrefs.topicActionRailFixed === true,
+ expandNestedRepliesByDefault,
+ expandLeafNestedReplies,
+ aggregateDescendantReplies,
+ inlineReplyTreeMaxDepth: normalizeInlineReplyTreeMaxDepth(
+ performancePrefs.inlineReplyTreeMaxDepth,
+ ),
+ hideNestedReplyFloors: performancePrefs.hideNestedReplyFloors === true,
+ ...jumpHighlightPrefsPatch(jumpHighlight),
+ boostCopyMode: boostCopy.mode,
+ boostCopyPrefix: boostCopy.prefix,
+ boostCopyCounterMarker: boostCopy.counterMarker,
+ boostCopyCounterStep: boostCopy.counterStep,
+ boostCopyFixedSuffix: boostCopy.fixedSuffix,
+ });
+ }
+
+ function readPrefs() {
+ try {
+ const stored = JSON.parse(localStorage.getItem(LDP_PREF_KEY) || '{}');
+ const merged = Object.assign({}, DEFAULT_PREFS, stored);
+ const storedPreset = Object.hasOwn(stored, 'performancePreset')
+ ? stored.performancePreset
+ : '';
+ if (PERFORMANCE_PRESETS[storedPreset])
+ Object.assign(merged, performancePrefsPatch(PERFORMANCE_PRESETS[storedPreset], storedPreset));
+ return normalizeReaderPrefs(merged);
+ } catch {
+ return normalizeReaderPrefs({ ...DEFAULT_PREFS });
+ }
+ }
+
+ let PREFS = readPrefs();
+
+ function syncReaderFontRenderingState() {
+ applyHostEmbeddedSizeSettings();
+ applyHostFontAppearance();
+ const root = PAGE_ROOT;
+ const external = root.hasAttribute(EXTERNAL_FONT_RENDERING_MARKER) &&
+ getComputedStyle(root).getPropertyValue('--fr-render-text').trim() !== '';
+ const mode = external ? 'external' : PREFS.fontRenderingEnabled ? 'builtin' : 'off';
+ root.dataset.ldpFontRendering = mode;
+ root.dataset.ldpFontRenderingHost = String(
+ mode === 'builtin' && PREFS.fontRenderingOnHost === true
+ );
+ if (READER_PORTAL_HOST) READER_PORTAL_HOST.dataset.ldpFontRendering = mode;
+ readerPortalQueryAll('.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox')
+ .forEach((surface) => { surface.dataset.ldpFontRendering = mode; });
+ readerPortalQueryAll('.ldp-font-rendering-enabled').forEach((input) => {
+ input.checked = PREFS.fontRenderingEnabled !== false;
+ input.disabled = external;
+ if (external) input.dataset.ldpTooltipLabel = '已由 F9y4ng 字体渲染工具接管';
+ else delete input.dataset.ldpTooltipLabel;
+ });
+ readerPortalQueryAll('.ldp-font-rendering-on-host').forEach((input) => {
+ input.checked = PREFS.fontRenderingOnHost === true;
+ input.disabled = external || PREFS.fontRenderingEnabled === false;
+ if (external) input.dataset.ldpTooltipLabel = '已由 F9y4ng 字体渲染工具接管';
+ else delete input.dataset.ldpTooltipLabel;
+ });
+ readerPortalQueryAll('.ldp-font-rendering-status').forEach((status) => {
+ status.dataset.mode = mode;
+ status.textContent = external
+ ? '检测到 F9y4ng 字体渲染,内置效果已自动让位,避免双重描边与阴影。'
+ : mode === 'builtin'
+ ? PREFS.fontRenderingOnHost
+ ? `字体显示优化已应用到增强阅读器和 ${SITE_DISPLAY_NAME} 原站页面。`
+ : `字体显示优化已应用到增强阅读器;${SITE_DISPLAY_NAME} 原站页面保持原样。`
+ : '字体显示优化已关闭。';
+ });
+ }
+
+ new MutationObserver((mutations) => {
+ if (mutations.some((mutation) => mutation.attributeName === EXTERNAL_FONT_RENDERING_MARKER)) {
+ syncReaderFontRenderingState();
+ [50, 250, 1000].forEach((delay) => {
+ setTimeout(syncReaderFontRenderingState, delay);
+ });
+ }
+ }).observe(PAGE_ROOT, {
+ attributes: true,
+ attributeFilter: [EXTERNAL_FONT_RENDERING_MARKER],
+ });
+ window.addEventListener('load', syncReaderFontRenderingState, { once: true });
+ syncReaderFontRenderingState();
+
+ function setPrefs(patch) {
+ PREFS = normalizeReaderPrefs(Object.assign({}, PREFS, patch));
+ try { localStorage.setItem(LDP_PREF_KEY, JSON.stringify(PREFS)); } catch {}
+ if (Object.keys(patch).some((key) => /^(?:fontRendering|hostFont|hostEmbedded)/.test(key))) syncReaderFontRenderingState();
+ }
+
+ function replacePrefsAndReload(nextPrefs, failureMessage) {
+ const normalized = normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, nextPrefs || {}));
+ try {
+ localStorage.setItem(LDP_PREF_KEY, JSON.stringify(normalized));
+ PREFS = normalized;
+ syncReaderFontRenderingState();
+ } catch {
+ showSelectionToast(failureMessage);
+ return;
+ }
+ location.reload();
+ }
+
+ const READER_CONFIG_SETTING_KEYS = Object.freeze(
+ Object.keys(normalizeReaderPrefs({ ...DEFAULT_PREFS }))
+ );
+
+ function readerConfigExportPayload() {
+ const normalized = normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, PREFS));
+ const settings = READER_CONFIG_SETTING_KEYS.reduce((result, key) => {
+ result[key] = normalized[key];
+ return result;
+ }, {});
+ return {
+ format: LDP_CONFIG_EXPORT_FORMAT,
+ schemaVersion: LDP_CONFIG_EXPORT_VERSION,
+ scriptVersion: READER_VERSION,
+ exportedAt: new Date().toISOString(),
+ settingsCount: Object.keys(settings).length,
+ settings,
+ };
+ }
+
+ function readerPrefsFromConfigExport(payload) {
+ const schemaVersion = Number(payload?.schemaVersion);
+ const settingKeys = READER_CONFIG_SETTING_KEYS;
+ const payloadSettingKeys = payload?.settings && typeof payload.settings === 'object' &&
+ !Array.isArray(payload.settings)
+ ? Object.keys(payload.settings)
+ : [];
+ const importedSource = payload?.settings && typeof payload.settings === 'object' &&
+ !Array.isArray(payload.settings)
+ ? { ...payload.settings }
+ : {};
+ const legacyLayoutProfile = schemaVersion === LDP_CONFIG_EXPORT_VERSION &&
+ !Object.hasOwn(importedSource, 'fullpageLayoutProfile') &&
+ payloadSettingKeys.every((key) => settingKeys.includes(key)) &&
+ settingKeys.every((key) => key === 'fullpageLayoutProfile' || Object.hasOwn(importedSource, key));
+ if (legacyLayoutProfile) {
+ importedSource.fullpageLayoutProfile = READER_FULLPAGE_LAYOUT_DEFAULT;
+ }
+ const legacyNativeComposerClosePreference = schemaVersion === LDP_CONFIG_EXPORT_VERSION &&
+ !Object.hasOwn(importedSource, 'confirmNativeComposerClose') &&
+ payloadSettingKeys.every((key) => settingKeys.includes(key)) &&
+ settingKeys.every((key) => ['fullpageLayoutProfile', 'confirmNativeComposerClose'].includes(key) ||
+ Object.hasOwn(importedSource, key));
+ if (legacyNativeComposerClosePreference) {
+ if (!Object.hasOwn(importedSource, 'fullpageLayoutProfile'))
+ importedSource.fullpageLayoutProfile = READER_FULLPAGE_LAYOUT_DEFAULT;
+ importedSource.confirmNativeComposerClose = true;
+ }
+ const importedSettingKeys = Object.keys(importedSource);
+ if (!payload || payload.format !== LDP_CONFIG_EXPORT_FORMAT ||
+ schemaVersion !== LDP_CONFIG_EXPORT_VERSION ||
+ !payload.settings || typeof payload.settings !== 'object' || Array.isArray(payload.settings) ||
+ Number(payload.settingsCount) !== payloadSettingKeys.length ||
+ importedSettingKeys.some((key) => !settingKeys.includes(key)) ||
+ settingKeys.some((key) => !Object.hasOwn(importedSource, key))) {
+ throw new Error('invalid_config');
+ }
+ const importedSettings = settingKeys.reduce((settings, key) => {
+ if (Object.hasOwn(importedSource, key)) settings[key] = importedSource[key];
+ return settings;
+ }, {});
+ return normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, importedSettings));
+ }
+
+ function downloadReaderConfigExport() {
+ const blob = new window.Blob([`${JSON.stringify(readerConfigExportPayload(), null, 2)}\n`], { type: 'application/json' });
+ triggerBrowserDownload(blob, `awesome-linuxdo-reader-settings-${new Date().toISOString().slice(0, 10)}.json`);
+ }
+
+ function setPref(key, value) {
+ setPrefs({ [key]: value });
+ }
+
+ const READER_SYSTEM_THEME_QUERY = isFunction(window.matchMedia)
+ ? window.matchMedia('(prefers-color-scheme: dark)')
+ : null;
+
+ let linuxDoThemeEventService = null;
+ let linuxDoThemeChangeInProgress = false;
+
+ function linuxDoInterfaceColorService() {
+ return lookupDiscourse('service:interface-color');
+ }
+
+ function linuxDoNativeThemeMode(service = linuxDoInterfaceColorService()) {
+ if (service && service.colorMode === 'light') return 'light';
+ if (service && service.colorMode === 'dark') return 'dark';
+ if (service && service.colorMode === 'auto') return 'system';
+ if (service?.lightModeForced) return 'light';
+ if (service?.darkModeForced) return 'dark';
+ if (service?.selectorAvailable) return 'system';
+ if (service?.session) return service.session.defaultColorSchemeIsDark ? 'dark' : 'light';
+ return '';
+ }
+
+ function readerUsesOwnThemePalette() {
+ return !!CURRENT_OVERLAY;
+ }
+
+ function currentReaderThemeMode() {
+ return readerUsesOwnThemePalette()
+ ? normalizeReaderThemeMode(PREFS.themeMode)
+ : linuxDoNativeThemeMode() || normalizeReaderThemeMode(PREFS.themeMode);
+ }
+
+ function resolvedReaderThemeMode(mode = currentReaderThemeMode()) {
+ const normalized = normalizeReaderThemeMode(mode);
+ return normalized === 'system' && READER_SYSTEM_THEME_QUERY && READER_SYSTEM_THEME_QUERY.matches
+ ? 'dark'
+ : normalized === 'dark' ? 'dark' : 'light';
+ }
+
+ function applyReaderThemeVariables(node, mode = currentReaderThemeMode(), ownPalette = readerUsesOwnThemePalette()) {
+ if (!node || !node.style) return;
+ const normalized = normalizeReaderThemeMode(mode);
+ const resolved = resolvedReaderThemeMode(normalized);
+ node.dataset.ldpThemeMode = normalized;
+ node.dataset.ldpTheme = resolved;
+ node.style.colorScheme = resolved;
+ Object.entries(READER_THEME_VARIABLES[resolved]).forEach(([property, value]) => {
+ if (ownPalette || property.startsWith('--ldp-')) node.style.setProperty(property, value);
+ else node.style.removeProperty(property);
+ });
+ applyAppearanceInteractionColors(node, currentAppearanceProfile(), normalized);
+ }
+
+ function bindLinuxDoNativeThemeEvents(service = linuxDoInterfaceColorService()) {
+ if (service && linuxDoThemeEventService !== service && isFunction(service.appEvents?.on)) {
+ if (linuxDoThemeEventService && isFunction(linuxDoThemeEventService.appEvents?.off))
+ linuxDoThemeEventService.appEvents.off('interface-color:changed', document, onLinuxDoInterfaceColorChanged);
+ service.appEvents.on('interface-color:changed', document, onLinuxDoInterfaceColorChanged);
+ linuxDoThemeEventService = service;
+ }
+ }
+
+ function applyLinuxDoNativeTheme(mode = currentReaderThemeMode()) {
+ const service = linuxDoInterfaceColorService();
+ bindLinuxDoNativeThemeEvents(service);
+ const normalized = normalizeReaderThemeMode(mode);
+ const actions = normalized === 'light'
+ ? ['forceLightMode']
+ : normalized === 'dark' ? ['forceDarkMode'] : ['useAutoMode'];
+ const action = actions.find((name) => isFunction(service?.[name]));
+ if (!action) return false;
+ linuxDoThemeChangeInProgress = true;
+ try {
+ service[action]();
+ return true;
+ } catch {
+ return false;
+ } finally {
+ linuxDoThemeChangeInProgress = false;
+ }
+ }
+
+ function syncReaderThemeButtons(
+ buttons = readerPortalQueryAll('.ldp-settings-theme-button'),
+ mode = currentReaderThemeMode()
+ ) {
+ const normalizedMode = normalizeReaderThemeMode(mode);
+ buttons.forEach((button) => {
+ const buttonMode = normalizeReaderThemeMode(button.dataset.readerThemeMode);
+ const active = buttonMode === normalizedMode;
+ button.classList.toggle('active', active);
+ setPressed(button, active);
+ setLabel(button, `主题:${READER_THEME_LABELS[buttonMode]}${active ? '(当前)' : ''}`);
+ });
+ }
+
+ function syncReaderThemeSurfaces(themeMode = currentReaderThemeMode()) {
+ const mode = normalizeReaderThemeMode(themeMode);
+ const ownPalette = readerUsesOwnThemePalette();
+ bindLinuxDoNativeThemeEvents();
+ [...READER_PORTAL_CONTEXT_BODY?.children || []].forEach((node) =>
+ applyReaderThemeVariables(node, mode, ownPalette));
+ if (CURRENT_OVERLAY && !CURRENT_OVERLAY.isConnected) applyReaderThemeVariables(CURRENT_OVERLAY, mode, ownPalette);
+ document.querySelectorAll('[data-ldp-reader-composer-root]').forEach((node) => applyReaderThemeVariables(node, mode, ownPalette));
+ syncReaderThemeButtons(undefined, mode);
+ if (isFunction(CURRENT_OVERLAY?._ldpApplyThemeDependentColors))
+ CURRENT_OVERLAY._ldpApplyThemeDependentColors();
+ }
+
+ function onLinuxDoInterfaceColorChanged() {
+ if (linuxDoThemeChangeInProgress) return;
+ const mode = linuxDoNativeThemeMode();
+ if (!mode) return;
+ if (normalizeReaderThemeMode(PREFS.themeMode) !== mode) setPref('themeMode', mode);
+ syncReaderThemeSurfaces(mode);
+ }
+
+ function transitionReaderTheme(mode, options = {}) {
+ const nextMode = normalizeReaderThemeMode(mode);
+ const persist = options.persist !== false;
+ const applyHost = options.applyHost !== false;
+ if (persist) setPref('themeMode', nextMode);
+ if (applyHost) applyLinuxDoNativeTheme(nextMode);
+ syncReaderThemeSurfaces(nextMode);
+ }
+
+ const onReaderSystemThemeChange = () => {
+ if (currentReaderThemeMode() === 'system')
+ transitionReaderTheme('system', { persist: false, applyHost: false });
+ };
+ READER_SYSTEM_THEME_QUERY?.addEventListener('change', onReaderSystemThemeChange);
+
+ function balanceLayoutRatios(source, adjustable) {
+ const result = { ...source };
+ const difference = roundLayoutRatio(100 - layoutRatioTotal(result));
+ const direction = difference > 0 ? 1 : -1;
+ let remaining = Math.abs(difference);
+ adjustable.forEach((name) => {
+ if (remaining <= 0) return;
+ const capacity = roundLayoutRatio(direction > 0
+ ? 100 - result[name]
+ : Math.max(0, result[name] - LAYOUT_MIN_RATIOS[name]));
+ const change = Math.min(capacity, remaining);
+ result[name] = roundLayoutRatio(result[name] + direction * change);
+ remaining = roundLayoutRatio(remaining - change);
+ });
+ return result;
+ }
+
+ function rebalanceLayoutRatiosAfterChange(source, editedName) {
+ return balanceLayoutRatios(source,
+ ['main', 'left', 'right', 'gap', 'timeline'].filter((name) => name !== editedName));
+ }
+
+ function currentLayoutRatios(fullPage = false) {
+ const defaults = fullPage ? READER_FULLPAGE_LAYOUT_DEFAULT : READER_LAYOUT_DEFAULT;
+ const profile = fullPage ? PREFS.fullpageLayoutProfile : PREFS.layoutProfile;
+ return normalizeLayoutRatios(profile, defaults);
+ }
+
+ function currentFontProfile() {
+ return normalizeFontProfile(PREFS.fontProfile, READER_FONT_DEFAULT);
+ }
+
+ function currentPerformanceConfig() {
+ return performanceConfigFromPrefs(PREFS);
+ }
+
+ function setPerformancePreset(name) {
+ const preset = PERFORMANCE_PRESETS[name];
+ if (!preset) return;
+ setPrefs(performancePrefsPatch(preset, name));
+ }
+
+ function setPerformanceValue(name, value) {
+ const prefKey = PERFORMANCE_PREF_KEYS[name];
+ if (!prefKey) return;
+ const current = currentPerformanceConfig();
+ const next = normalizePerformanceValue(name, value, current[name]);
+ setPrefs({ [prefKey]: next });
+ }
+
+ function normalizeImageScalePreset(value) {
+ if (value === 'custom') return 'custom';
+ const numeric = Number(value);
+ return IMAGE_SCALE_OPTIONS.includes(numeric) ? String(numeric) : '100';
+ }
+
+ function normalizeRoundedRange(value, fallback, minimum, maximum) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return fallback;
+ return Math.min(maximum, Math.max(minimum, Math.round(numeric)));
+ }
+
+ function normalizeImageScalePercent(value, fallback = 100) { return normalizeRoundedRange(value, fallback, IMAGE_SCALE_MIN, IMAGE_SCALE_MAX); }
+
+ function normalizeImageProfile(source, fallback = IMAGE_PROFILE_DEFAULT) {
+ const values = source || {};
+ return {
+ preset: normalizeImageScalePreset(values.preset == null ? fallback.preset : values.preset),
+ custom: normalizeImageScalePercent(values.custom, fallback.custom),
+ };
+ }
+
+ function currentImageProfile() {
+ return normalizeImageProfile(PREFS.imageProfile, IMAGE_PROFILE_DEFAULT);
+ }
+
+ function currentImageScalePercent() {
+ const values = currentImageProfile();
+ const preset = normalizeImageScalePreset(values.preset);
+ if (preset === 'custom') return normalizeImageScalePercent(values.custom, 100);
+ return normalizeImageScalePercent(preset, 100);
+ }
+
+ function normalizeFontScale(value, fallback) { return normalizeRoundedRange(value, fallback, FONT_SCALE_MIN, FONT_SCALE_MAX); }
+
+ function normalizeHostEmbeddedSizeScale(value, fallback = HOST_EMBED_SIZE_DEFAULT) { return normalizeRoundedRange(value, fallback, HOST_EMBED_SIZE_MIN, HOST_EMBED_SIZE_MAX); }
+
+ function applyHostEmbeddedSizeSettings() {
+ const style = PAGE_ROOT.style;
+ Object.values(HOST_EMBED_SIZE_SETTINGS).forEach((setting) => {
+ setting.properties.forEach(([property, basePixels]) => {
+ const pixels = Math.round(basePixels * normalizeHostEmbeddedSizeScale(PREFS[setting.pref]) * 10 / 100) / 10;
+ style.setProperty(property, `${pixels}px`);
+ });
+ });
+ }
+
+ function applyHostFontAppearance() {
+ const style = PAGE_ROOT.style;
+ const family = normalizeFontFamily(PREFS.hostFontFamily, HOST_FONT_DEFAULTS.hostFontFamily);
+ const color = normalizeHexColor(PREFS.hostFontColor, HOST_FONT_DEFAULTS.hostFontColor);
+ if (family === 'site') style.removeProperty('--ldp-host-font-family');
+ else style.setProperty('--ldp-host-font-family', resolvedFontFamily(PREFS, 'host'));
+ style.setProperty('--ldp-host-font-weight', String(normalizeFontWeight(PREFS.hostFontWeight, HOST_FONT_DEFAULTS.hostFontWeight)));
+ if (color) style.setProperty('--ldp-host-font-color', color);
+ else style.removeProperty('--ldp-host-font-color');
+ }
+
+ function normalizeFontFamily(value, fallback = FONT_FAMILY_DEFAULT) {
+ return Object.hasOwn(FONT_FAMILY_OPTIONS, value) ? value : fallback;
+ }
+
+ function normalizeCustomFontFamily(value, fallback = '') {
+ const source = String(value == null ? fallback : value)
+ .replace(/[\u0000-\u001f\u007f"'`,;{}<>\\]/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+ return [...source].slice(0, 64).join('');
+ }
+
+ function normalizeFontWeight(value, fallback = FONT_WEIGHT_DEFAULT) {
+ const numeric = Number(value);
+ return FONT_WEIGHT_OPTIONS.includes(numeric) ? numeric : fallback;
+ }
+
+ function normalizeHexColor(value, fallback = '') {
+ const color = String(value || '').trim().toLowerCase();
+ if (/^#[0-9a-f]{6}$/.test(color)) return color;
+ const fallbackColor = String(fallback || '').trim().toLowerCase();
+ return /^#[0-9a-f]{6}$/.test(fallbackColor) ? fallbackColor : '';
+ }
+
+ function resolvedSiteFontFamily() {
+ try {
+ const target = document.body || PAGE_ROOT;
+ const family = target && getComputedStyle(target).fontFamily;
+ return family && family !== 'inherit' ? family : 'inherit';
+ } catch {
+ return 'inherit';
+ }
+ }
+
+ function resolvedFontFamily(values, scope = 'interface') {
+ const config = scope === 'host' ? HOST_FONT_CONFIG : FONT_SCOPE_CONFIG[scope] || FONT_SCOPE_CONFIG.interface;
+ const family = normalizeFontFamily(values?.[config.family]);
+ if (family !== 'custom') return family === 'site' ? resolvedSiteFontFamily() : FONT_FAMILY_OPTIONS[family].stack;
+ const customFamily = normalizeCustomFontFamily(values?.[config.customFamily]);
+ return customFamily
+ ? `${JSON.stringify(customFamily)},${FONT_FAMILY_OPTIONS.system.stack}`
+ : FONT_FAMILY_OPTIONS.system.stack;
+ }
+
+ function applyReaderFontAppearance(styleDeclaration, values) {
+ if (!styleDeclaration) return;
+ FONT_SCOPE_KEYS.forEach((scope) => {
+ const config = FONT_SCOPE_CONFIG[scope];
+ const prefix = `--ldp-${scope}-font`;
+ const color = normalizeHexColor(values?.[config.color]);
+ styleDeclaration.setProperty(`${prefix}-family`, resolvedFontFamily(values, scope));
+ styleDeclaration.setProperty(`${prefix}-weight`, String(normalizeFontWeight(values?.[config.weight])));
+ if (color) styleDeclaration.setProperty(`${prefix}-color`, color);
+ else styleDeclaration.removeProperty(`${prefix}-color`);
+ });
+ styleDeclaration.setProperty('--ldp-reader-font-weight-base', String(normalizeFontWeight(values?.weight)));
+ }
+
+ function clearReaderFontAppearance(styleDeclaration) {
+ if (!styleDeclaration) return;
+ FONT_SCOPE_KEYS.forEach((scope) => {
+ const prefix = `--ldp-${scope}-font`;
+ styleDeclaration.removeProperty(`${prefix}-family`);
+ styleDeclaration.removeProperty(`${prefix}-weight`);
+ styleDeclaration.removeProperty(`${prefix}-color`);
+ });
+ styleDeclaration.removeProperty('--ldp-reader-font-weight-base');
+ }
+
+ function normalizeFontProfile(source, fallback = READER_FONT_DEFAULT) {
+ const values = source || {};
+ const profile = {};
+ const interfaceConfig = FONT_SCOPE_CONFIG.interface;
+ FONT_SCOPE_KEYS.forEach((scope) => {
+ const config = FONT_SCOPE_CONFIG[scope];
+ const inherited = scope === 'interface' ? fallback : profile;
+ profile[config.family] = normalizeFontFamily(values[config.family], inherited[interfaceConfig.family]);
+ profile[config.customFamily] = normalizeCustomFontFamily(values[config.customFamily], inherited[interfaceConfig.customFamily]);
+ profile[config.weight] = normalizeFontWeight(values[config.weight], inherited[interfaceConfig.weight]);
+ profile[config.color] = normalizeHexColor(values[config.color], fallback[config.color]);
+ profile[config.scale] = normalizeFontScale(values[config.scale], fallback[config.scale]);
+ });
+ return profile;
+ }
+
+ function fontScalePixels(basePixels, value, fallback = FONT_SCALE_DEFAULT) {
+ const scale = normalizeFontScale(value, fallback) / 100;
+ return `${Math.round(basePixels * scale * 100) / 100}px`;
+ }
+
+ function applyInterfaceFontScale(styleDeclaration, value) {
+ if (!styleDeclaration) return;
+ const scale = normalizeFontScale(value, FONT_SCALE_DEFAULT);
+ Object.entries(INTERFACE_FONT_TOKEN_BASES).forEach(([property, basePixels]) => {
+ styleDeclaration.setProperty(property, fontScalePixels(basePixels, scale));
+ });
+ }
+
+ function clearInterfaceFontScale(styleDeclaration) {
+ if (!styleDeclaration) return;
+ Object.keys(INTERFACE_FONT_TOKEN_BASES).forEach((property) => styleDeclaration.removeProperty(property));
+ }
+
+ function normalizeSteppedValue(value, fallback, minimum, maximum, step) {
+ const parsed = Number(value);
+ const safeValue = Number.isFinite(parsed) ? parsed : fallback;
+ return Math.min(maximum, Math.max(minimum, Math.round(safeValue / step) * step));
+ }
+
+ function appearanceDarkColorKey(key) {
+ return `${key}Dark`;
+ }
+
+ function appearanceSettingValueFields(key) {
+ return APPEARANCE_COLOR_KEYS.includes(key) ? [key, appearanceDarkColorKey(key)] : [key];
+ }
+
+ function hexColorToHsl(value) {
+ const color = normalizeHexColor(value);
+ if (!color) return null;
+ const [red, green, blue] = [1, 3, 5].map((index) =>
+ parseInt(color.slice(index, index + 2), 16) / 255);
+ const maximum = Math.max(red, green, blue);
+ const minimum = Math.min(red, green, blue);
+ const delta = maximum - minimum;
+ const lightness = (maximum + minimum) / 2;
+ const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));
+ let hue = 0;
+ if (delta !== 0) {
+ if (maximum === red) hue = 60 * (((green - blue) / delta) % 6);
+ else if (maximum === green) hue = 60 * ((blue - red) / delta + 2);
+ else hue = 60 * ((red - green) / delta + 4);
+ }
+ return {
+ hue: hue < 0 ? hue + 360 : hue,
+ saturation: saturation * 100,
+ lightness: lightness * 100,
+ };
+ }
+
+ function hslColorToHex(hue, saturation, lightness) {
+ const normalizedHue = ((Number(hue) % 360) + 360) % 360;
+ const normalizedSaturation = Math.min(100, Math.max(0, Number(saturation))) / 100;
+ const normalizedLightness = Math.min(100, Math.max(0, Number(lightness))) / 100;
+ const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
+ const intermediate = chroma * (1 - Math.abs((normalizedHue / 60) % 2 - 1));
+ const offset = normalizedLightness - chroma / 2;
+ const sector = Math.floor(normalizedHue / 60);
+ const channels = [
+ [chroma, intermediate, 0], [intermediate, chroma, 0], [0, chroma, intermediate],
+ [0, intermediate, chroma], [intermediate, 0, chroma], [chroma, 0, intermediate],
+ ][sector] || [0, 0, 0];
+ return `#${channels.map((channel) =>
+ Math.round((channel + offset) * 255).toString(16).padStart(2, '0')).join('')}`;
+ }
+
+ function adaptAppearanceColorToTheme(key, value, mode) {
+ const resolved = mode === 'dark' ? 'dark' : 'light';
+ const valueKey = resolved === 'dark' ? appearanceDarkColorKey(key) : key;
+ const normalized = normalizeHexColor(value, APPEARANCE_PROFILE_DEFAULT[valueKey]);
+ if (normalized === APPEARANCE_PROFILE_DEFAULT[key])
+ return APPEARANCE_PROFILE_DEFAULT[valueKey];
+ const limits = APPEARANCE_COLOR_THEME_LIMITS[key];
+ const hsl = limits && hexColorToHsl(normalized);
+ if (!hsl) return normalized;
+ const [minimumLightness, maximumLightness] = limits[resolved];
+ const saturation = Math.min(hsl.saturation, limits.saturationMax);
+ const lightness = Math.min(maximumLightness, Math.max(minimumLightness, hsl.lightness));
+ if (Math.abs(saturation - hsl.saturation) < 0.01 && Math.abs(lightness - hsl.lightness) < 0.01)
+ return normalized;
+ return hslColorToHex(hsl.hue, saturation, lightness);
+ }
+
+ function appearanceThemeValueKey(key, mode = resolvedReaderThemeMode()) {
+ return APPEARANCE_COLOR_KEYS.includes(key) && mode === 'dark'
+ ? appearanceDarkColorKey(key)
+ : key;
+ }
+
+ function appearanceThemeColor(values, key, mode = resolvedReaderThemeMode()) {
+ return adaptAppearanceColorToTheme(key, values?.[key], mode);
+ }
+
+ function normalizeAppearanceProfile(source, fallback = APPEARANCE_PROFILE_DEFAULT) {
+ const values = source || {};
+ const profile = APPEARANCE_SETTING_FIELDS.reduce((result, field) => {
+ if (field.range) {
+ result[field.key] = field.normalize(values[field.key], fallback[field.key]);
+ return result;
+ }
+ const lightFallback = normalizeHexColor(fallback[field.key], APPEARANCE_PROFILE_DEFAULT[field.key]);
+ const storedLight = normalizeHexColor(values[field.key]);
+ const darkKey = appearanceDarkColorKey(field.key);
+ const storedDark = normalizeHexColor(values[darkKey]);
+ const sourceColor = storedLight || storedDark || lightFallback;
+ result[field.key] = sourceColor;
+ result[darkKey] = sourceColor;
+ return result;
+ }, {});
+ profile.structureColorsEnabled = typeof values.structureColorsEnabled === 'boolean'
+ ? values.structureColorsEnabled
+ : fallback.structureColorsEnabled !== false;
+ return profile;
+ }
+
+ function currentAppearanceProfile() {
+ return normalizeAppearanceProfile(PREFS.appearanceProfile, APPEARANCE_PROFILE_DEFAULT);
+ }
+
+ function applyAppearanceInteractionColors(node, values = currentAppearanceProfile(), mode = currentReaderThemeMode()) {
+ if (!node || !node.style) return;
+ const appearance = values || APPEARANCE_PROFILE_DEFAULT;
+ const resolved = resolvedReaderThemeMode(mode);
+ const accent = appearanceThemeColor(appearance, 'accentColor', resolved);
+ const accentDefault = APPEARANCE_PROFILE_DEFAULT[appearanceThemeValueKey('accentColor', resolved)];
+ node.style.setProperty('--tertiary', accent);
+ node.style.setProperty('--tertiary-low', accent === accentDefault
+ ? READER_THEME_VARIABLES[resolved]['--tertiary-low']
+ : `color-mix(in srgb,${accent} 18%,var(--secondary,#fff))`);
+ node.style.setProperty('--d-link-color', appearanceThemeColor(appearance, 'linkColor', resolved));
+ }
+
+ function normalizeLineWidth(value, fallback) {
+ return normalizeSteppedValue(value, fallback, LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP);
+ }
+
+ function normalizeReplyLineRadius(value, fallback = REPLY_LINE_RADIUS_DEFAULT) {
+ return normalizeSteppedValue(
+ value, fallback, REPLY_LINE_RADIUS_MIN, REPLY_LINE_RADIUS_MAX, REPLY_LINE_RADIUS_STEP
+ );
+ }
+
+ function applyComposerFontScale(value = currentFontProfile().composer) {
+ const scale = normalizeFontScale(value, COMPOSER_FONT_SCALE_DEFAULT);
+ PAGE_ROOT.style.setProperty('--ldp-composer-font-size', `${scale}%`);
+ }
+ applyComposerFontScale();
+
+ function setPageScrollLocked(locked) {
+ PAGE_ROOT.classList.toggle(LDP_SCROLL_LOCK_CLASS, locked);
+ }
+
+ function currentListReaderMode() {
+ return normalizeListReaderMode(PREFS.listReaderMode);
+ }
+
+ function currentTopicReaderMode() {
+ return normalizeTopicReaderMode(PREFS.topicReaderMode);
+ }
+
+ function currentListReaderEmbedWidth() {
+ return normalizeListReaderEmbedWidth(PREFS.listReaderEmbedWidth);
+ }
+
+ function readerPresentation(mode) {
+ return READER_PRESENTATIONS[mode] || READER_PRESENTATIONS.floating;
+ }
+
+ function chromeExtensionRuntime() {
+ try {
+ if (typeof chrome === 'undefined' || !chrome.runtime || !chrome.runtime.id ||
+ !isFunction(chrome.runtime.sendMessage)) return null;
+ return chrome.runtime;
+ } catch {
+ return null;
+ }
+ }
+
+ async function sendChromeExtensionMessage(message) {
+ const runtime = chromeExtensionRuntime();
+ if (!runtime) return { ok: false, reason: 'extension-unavailable' };
+ try {
+ const response = await runtime.sendMessage(message);
+ return response && typeof response === 'object'
+ ? response
+ : { ok: false, reason: 'empty-response' };
+ } catch (error) {
+ return {
+ ok: false,
+ reason: error?.message ? error.message : 'extension-message-failed',
+ };
+ }
+ }
+
+ function setChromeSidePanelActive(active) {
+ PAGE_ROOT.classList.toggle('ldp-reader-sidepanel-active', !!active);
+ PAGE_ROOT.classList.toggle('ldp-reader-workspace', !!active);
+ }
+
+ async function closeChromeSidePanel() {
+ const result = await sendChromeExtensionMessage({ type: 'ldp-sidepanel-close' });
+ setChromeSidePanelActive(false);
+ return result || { ok: false, reason: 'sidepanel-close-failed' };
+ }
+
+ async function closeChromeSidePanelForReader() {
+ const result = await closeChromeSidePanel();
+ if (!chromeExtensionRuntime() || result.ok) return true;
+ showSelectionToast('请先关闭 Chrome 侧边栏,再打开浮窗阅读器');
+ return false;
+ }
+
+ function installChromeSidePanelBridge() {
+ const runtime = chromeExtensionRuntime();
+ if (!runtime || !runtime.onMessage || !isFunction(runtime.onMessage.addListener)) return;
+ runtime.onMessage.addListener((message) => {
+ if (!message || message.type !== 'ldp-sidepanel-state') return;
+ setChromeSidePanelActive(!!message.active);
+ });
+ }
+ installChromeSidePanelBridge();
+
+ function userProfileUrl(username) {
+ const discourseUrl = lookupDiscourseModule('discourse/lib/url');
+ return isFunction(discourseUrl?.userPath)
+ ? discourseUrl.userPath(encodeURIComponent(username || ''))
+ : '';
+ }
+
+ function userActivityUrl(username, path) {
+ const suffix = String(path || '').replace(/^\/+/, '');
+ const discourseUrl = lookupDiscourseModule('discourse/lib/url');
+ const userPath = encodeURIComponent(username || '');
+ return isFunction(discourseUrl?.userPath)
+ ? discourseUrl.userPath(suffix ? `${userPath}/${suffix}` : userPath)
+ : '';
+ }
+
+ function discoursePageUrl(path) {
+ const getUrlModule = lookupDiscourseModule('discourse/lib/get-url');
+ const getURL = getUrlModule?.default;
+ if (!isFunction(getURL)) return '';
+ const resolved = getURL(String(path || ''));
+ return resolved ? new URL(resolved, location.origin).href : '';
+ }
+
+ function topicPageUrl(topicId, postNumber = 0, bypassReader = false) {
+ const safeTopicId = Math.max(0, Number(topicId) || 0);
+ const safePostNumber = Math.max(0, Number(postNumber) || 0);
+ if (!safeTopicId) return '';
+ const href = discoursePageUrl(`/t/${safeTopicId}${safePostNumber ? `/${safePostNumber}` : ''}`);
+ if (!href || !bypassReader) return href;
+ const url = new URL(href);
+ url.searchParams.set(NATIVE_BYPASS_PARAM, '1');
+ return url.href;
+ }
+
+ function topicNavText(value) {
+ if (value == null) return '';
+ if (typeof value === 'object') return String(value.name || value.slug || value.id || '');
+ return String(value);
+ }
+
+ function cleanTopicCategoryName(value) {
+ return topicNavText(value)
+ .replace(/\s*[,,]\s*Lv\s*\d+\s*$/i, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+ }
+
+ function safeTopicNavIconHtml(value) {
+ const html = String(value || '').trim();
+ if (!/^<(?:svg|img)\b/i.test(html)) return '';
+ if (/<(?:script|foreignObject|use)\b/i.test(html) || /\son[a-z:-]*\s*=/i.test(html) ||
+ /(?:href|src)\s*=\s*["']?\s*javascript:/i.test(html)) return '';
+ return html;
+ }
+
+ function inlineTopicNavSvgUses(svg) {
+ if (!svg || svg.tagName?.toLowerCase() !== 'svg') return true;
+ const uses = [...svg.querySelectorAll('use')];
+ if (!uses.length) return true;
+ let complete = true;
+ uses.forEach((use) => {
+ const href = String(
+ use.getAttribute('href') ||
+ use.getAttribute('xlink:href') ||
+ use.getAttributeNS('http://www.w3.org/1999/xlink', 'href') ||
+ ''
+ );
+ const symbolId = href.startsWith('#') ? href.slice(1) : '';
+ const symbol = symbolId ? document.getElementById(symbolId) : null;
+ if (!symbol || !['symbol', 'g'].includes(symbol.tagName?.toLowerCase())) {
+ complete = false;
+ return;
+ }
+ if (!svg.hasAttribute('viewBox') && symbol.hasAttribute('viewBox'))
+ svg.setAttribute('viewBox', symbol.getAttribute('viewBox'));
+ const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ [...use.attributes].forEach((attribute) => {
+ if (attribute.name === 'href' || attribute.name === 'xlink:href') return;
+ if (attribute.namespaceURI)
+ group.setAttributeNS(attribute.namespaceURI, attribute.name, attribute.value);
+ else
+ group.setAttribute(attribute.name, attribute.value);
+ });
+ [...symbol.childNodes].forEach((child) => group.appendChild(child.cloneNode(true)));
+ use.replaceWith(group);
+ });
+ return complete && !svg.querySelector('use');
+ }
+
+ function topicNavIconCacheKeys(type, value) {
+ if (type === 'tag') {
+ const name = topicNavText(value && typeof value === 'object' ? value.name : value).trim().toLocaleLowerCase();
+ return name ? [`tag:${name}`] : [];
+ }
+ const metadata = value && typeof value === 'object' ? value : { categoryName: value };
+ const keys = [];
+ const categoryId = Number(metadata.categoryId || metadata.category_id) || 0;
+ const categorySlug = topicNavText(metadata.categorySlug || metadata.category_slug).trim().toLocaleLowerCase();
+ const categoryName = cleanTopicCategoryName(metadata.categoryName || metadata.category_name).toLocaleLowerCase();
+ if (categoryId) keys.push(`category:id:${categoryId}`);
+ if (categorySlug) keys.push(`category:slug:${categorySlug}`);
+ if (categoryName) keys.push(`category:name:${categoryName}`);
+ return keys;
+ }
+
+ function rememberTopicNavIcon(type, value, iconHtml) {
+ const html = safeTopicNavIconHtml(iconHtml);
+ const keys = topicNavIconCacheKeys(type, value);
+ if (!html || !keys.length) return '';
+ let changed = false;
+ keys.forEach((key) => {
+ const existing = TOPIC_NAV_ICON_CACHE.get(key);
+ TOPIC_NAV_ICON_CACHE.delete(key);
+ if (existing && existing.html === html) {
+ TOPIC_NAV_ICON_CACHE.set(key, existing);
+ return;
+ }
+ TOPIC_NAV_ICON_CACHE.set(key, { html, at: Date.now() });
+ changed = true;
+ });
+ if (changed) {
+ trimPersistentCacheMap('topicNavIcons');
+ schedulePersistentCacheWrite('topicNavIcons', 500);
+ }
+ return html;
+ }
+
+ function cachedTopicNavIcon(type, value) {
+ const keys = topicNavIconCacheKeys(type, value);
+ for (const key of keys) {
+ const entry = TOPIC_NAV_ICON_CACHE.get(key);
+ if (!entry) continue;
+ const html = safeTopicNavIconHtml(entry.html);
+ if (!html) {
+ TOPIC_NAV_ICON_CACHE.delete(key);
+ schedulePersistentCacheWrite('topicNavIcons');
+ continue;
+ }
+ TOPIC_NAV_ICON_CACHE.delete(key);
+ TOPIC_NAV_ICON_CACHE.set(key, entry);
+ return html;
+ }
+ return '';
+ }
+
+ function topicNavMetadataWithCachedIcons(metadata = {}) {
+ const tagIcons = new Map();
+ (Array.isArray(metadata.tagIcons) ? metadata.tagIcons : []).forEach((item) => {
+ const name = topicNavText(item?.name).trim();
+ const iconHtml = safeTopicNavIconHtml(item?.iconHtml);
+ if (name && iconHtml) tagIcons.set(name, { name, iconHtml });
+ });
+ (Array.isArray(metadata.tags) ? metadata.tags : []).map(topicNavText).map((tag) => tag.trim()).filter(Boolean)
+ .forEach((name) => {
+ if (tagIcons.has(name)) return;
+ const iconHtml = cachedTopicNavIcon('tag', name);
+ if (iconHtml) tagIcons.set(name, { name, iconHtml });
+ });
+ return Object.assign({}, metadata, {
+ categoryIconHtml: safeTopicNavIconHtml(metadata.categoryIconHtml) || cachedTopicNavIcon('category', metadata),
+ tagIcons: [...tagIcons.values()],
+ });
+ }
+
+ function cacheTopicNavMetadataIcons(metadata = {}) {
+ rememberTopicNavIcon('category', metadata, metadata.categoryIconHtml);
+ (Array.isArray(metadata.tagIcons) ? metadata.tagIcons : []).forEach((item) => {
+ rememberTopicNavIcon('tag', item?.name, item?.iconHtml);
+ });
+ }
+
+ function topicCategoryLevel(value) {
+ const match = topicNavText(value).trim().match(/(?:^|[,,]\s*)Lv\s*(\d+)\s*$/i);
+ return match ? `Lv${match[1]}` : '';
+ }
+
+ function topicNavIconHtml(link) {
+ if (!link) return '';
+ return [...link.querySelectorAll('svg,img')]
+ .filter((node) => node.closest('a') === link)
+ .map((node) => {
+ const copy = node.cloneNode(true);
+ if (copy.tagName?.toLowerCase() === 'svg' && !inlineTopicNavSvgUses(copy)) return '';
+ copy.querySelectorAll('script,foreignObject').forEach((unsafe) => unsafe.remove());
+ [copy, ...copy.querySelectorAll('*')].forEach((part) => {
+ [...part.attributes || []].forEach((attribute) => {
+ if (/^on/i.test(attribute.name)) part.removeAttribute(attribute.name);
+ });
+ });
+ copy.setAttribute('aria-hidden', 'true');
+ if (copy.tagName === 'IMG') copy.setAttribute('alt', '');
+ return copy.outerHTML;
+ })
+ .join('');
+ }
+
+ function topicCategoryLinkMetadata(link) {
+ if (!link) return {};
+ let categoryId = Number(link.dataset.categoryId || 0) || 0;
+ let categorySlug = String(link.dataset.categorySlug || '').trim();
+ try {
+ const url = new URL(link.getAttribute('href') || link.href || '', location.href);
+ const parts = url.pathname.split('/').filter(Boolean);
+ const categoryIndex = parts.indexOf('c');
+ const categoryParts = categoryIndex >= 0 ? parts.slice(categoryIndex + 1) : [];
+ const idIndex = categoryParts.findIndex((part) => /^\d+$/.test(part));
+ if (!categoryId && idIndex >= 0) categoryId = Number(categoryParts[idIndex]) || 0;
+ if (!categorySlug && categoryParts.length) {
+ const slugPart = categoryParts.find((part, index) => index !== idIndex && !/^\d+$/.test(part));
+ if (slugPart) categorySlug = decodeURIComponent(slugPart);
+ }
+ } catch {}
+ const nameNode = link.querySelector('[data-category-name],.badge-category__name,.category-name');
+ const categoryText = (nameNode && (nameNode.dataset.categoryName || nameNode.textContent)) ||
+ link.dataset.categoryName || link.getAttribute('title') || link.textContent || '';
+ const categoryName = cleanTopicCategoryName(categoryText);
+ const categoryLevel = topicCategoryLevel(categoryText) || topicCategoryLevel(link.textContent) ||
+ topicCategoryLevel(link.getAttribute('title'));
+ return { categoryName, categoryLevel, categorySlug, categoryId, categoryIconHtml: topicNavIconHtml(link) };
+ }
+
+ function readTopicNavMetadata(sourceElement = null, expectedTopicId = 0) {
+ const source = sourceElement && sourceElement.nodeType === Node.ELEMENT_NODE ? sourceElement : null;
+ let root = null;
+ if (source && !source.closest('.ldp-overlay'))
+ root = source.closest('tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result,.category-topic-link')
+ || source.parentElement;
+ if (!root) {
+ const currentRoute = extractTopicRouteFromUrl(location.href);
+ if (expectedTopicId && (!currentRoute || Number(currentRoute.topicId) !== Number(expectedTopicId))) return {};
+ root = [...document.querySelectorAll('#topic-title,.topic-title,.title-wrapper')]
+ .find((node) => !node.closest('.ldp-overlay')) || null;
+ }
+ if (!root) return {};
+ const categoryLink = root.querySelector(
+ 'a.badge-category__wrapper,a.badge-category,a.topic-category,a[href^="/c/"],a[href*="/c/"]',
+ );
+ const metadata = topicCategoryLinkMetadata(categoryLink);
+ const tagLinks = [...root.querySelectorAll('a.discourse-tag,.discourse-tags a,.topic-tags a')]
+ .filter((node) => !node.closest('.ldp-overlay'));
+ metadata.tags = tagLinks
+ .map((node) => topicNavText(node.dataset.tagName || node.textContent).trim())
+ .filter(Boolean);
+ metadata.tagIcons = tagLinks.map((node) => ({
+ name: topicNavText(node.dataset.tagName || node.textContent).trim(),
+ iconHtml: topicNavIconHtml(node),
+ })).filter((item) => item.name && item.iconHtml);
+ cacheTopicNavMetadataIcons(metadata);
+ return topicNavMetadataWithCachedIcons(metadata);
+ }
+
+ function mergeTopicNavMetadata(primary = {}, fallback = {}) {
+ const primaryCategoryName = cleanTopicCategoryName(primary.categoryName || '');
+ const fallbackCategoryName = cleanTopicCategoryName(fallback.categoryName || '');
+ const sameCategory = !primaryCategoryName || !fallbackCategoryName || primaryCategoryName === fallbackCategoryName;
+ const tagIcons = new Map();
+ [fallback.tagIcons, primary.tagIcons].forEach((items) => {
+ (Array.isArray(items) ? items : []).forEach((item) => {
+ const name = topicNavText(item?.name).trim();
+ const iconHtml = String(item?.iconHtml || '').trim();
+ if (name && iconHtml) tagIcons.set(name, { name, iconHtml });
+ });
+ });
+ const primaryTags = Array.isArray(primary.tags) ? primary.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean) : [];
+ const fallbackTags = Array.isArray(fallback.tags) ? fallback.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean) : [];
+ return topicNavMetadataWithCachedIcons({
+ categoryName: primaryCategoryName || fallbackCategoryName,
+ categoryLevel: topicCategoryLevel(primary.categoryLevel || fallback.categoryLevel || ''),
+ categorySlug: topicNavText(primary.categorySlug || fallback.categorySlug || '').trim(),
+ categoryId: Number(primary.categoryId || fallback.categoryId) || 0,
+ categoryIconHtml: String(primary.categoryIconHtml || (sameCategory ? fallback.categoryIconHtml : '') || '').trim(),
+ tags: primaryTags.length ? primaryTags : fallbackTags,
+ tagIcons: [...tagIcons.values()],
+ });
+ }
+
+ function topicNavMetadataHasIcons(metadata) {
+ return !!(metadata && (metadata.categoryIconHtml || (Array.isArray(metadata.tagIcons) && metadata.tagIcons.length)));
+ }
+
+ function cachedTopicNavMetadata(topic) {
+ if (!topic) return {};
+ const category = topic.category || {};
+ const categoryName = cleanTopicCategoryName(
+ topic.category_name || topic.categoryName || category.name || topic.category_slug || topic.categorySlug || category.slug,
+ );
+ const categoryLevel = [topic.category_level, topic.categoryLevel, topic.category_name, topic.categoryName, category.name]
+ .map(topicCategoryLevel).find(Boolean) || '';
+ const categorySlug = topicNavText(topic.category_slug || topic.categorySlug || category.slug).trim();
+ const categoryId = Number(topic.category_id || topic.categoryId || category.id) || 0;
+ const tags = (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).map((tag) => tag.trim()).filter(Boolean);
+ const wantedTags = new Set(tags);
+ let cached = {};
+ const collect = (candidate) => {
+ if (!candidate || typeof candidate !== 'object') return;
+ const matchingCategory = categoryName && cleanTopicCategoryName(candidate.categoryName) === categoryName;
+ cached = mergeTopicNavMetadata(cached, {
+ categoryName,
+ categoryLevel,
+ categorySlug,
+ categoryId,
+ categoryIconHtml: matchingCategory ? candidate.categoryIconHtml : '',
+ tags,
+ tagIcons: (Array.isArray(candidate.tagIcons) ? candidate.tagIcons : [])
+ .filter((item) => wantedTags.has(topicNavText(item?.name).trim())),
+ });
+ };
+ collect(topic._ldpTopicNavMetadata);
+ TOPIC_DATA_CACHE.forEach((entry) => collect(entry?.topic && entry.topic._ldpTopicNavMetadata));
+ return mergeTopicNavMetadata({ categoryName, categoryLevel, categorySlug, categoryId, tags }, cached);
+ }
+
+ function rememberTopicNavMetadata(topicId, topic, metadata) {
+ if (!topic || !topicNavMetadataHasIcons(metadata)) return false;
+ cacheTopicNavMetadataIcons(metadata);
+ const cacheable = cachedTopicNavMetadata(topic);
+ const next = mergeTopicNavMetadata(metadata, cacheable);
+ if (!topicNavMetadataHasIcons(next)) return false;
+ const previous = topic._ldpTopicNavMetadata || {};
+ if (JSON.stringify(previous) === JSON.stringify(next)) return false;
+ topic._ldpTopicNavMetadata = next;
+ setCachedTopicData(topicId, topic);
+ return true;
+ }
+
+ function fillTopicNavMetadata(topic, fallback = {}) {
+ if (!topic) return false;
+ const category = topic.category || {};
+ const existingId = Number(topic.category_id || topic.categoryId || discourseModelValue(category, 'id')) || 0;
+ const categoryName = cleanTopicCategoryName(
+ topic.category_name || topic.categoryName || discourseModelValue(category, 'name') ||
+ fallback.categoryName || '',
+ );
+ const categoryLevel = [
+ topic.category_level,
+ topic.categoryLevel,
+ topic.category_name,
+ topic.categoryName,
+ discourseModelValue(category, 'name'),
+ fallback.categoryLevel,
+ ].map(topicCategoryLevel).find(Boolean) || '';
+ const categorySlug = topicNavText(
+ topic.category_slug || topic.categorySlug || discourseModelValue(category, 'slug') ||
+ fallback.categorySlug || '',
+ ).trim();
+ const categoryId = existingId || Number(fallback.categoryId) || 0;
+ let changed = false;
+ if (categoryName && topic.category_name !== categoryName) {
+ topic.category_name = categoryName;
+ changed = true;
+ }
+ if (categoryLevel && topic.category_level !== categoryLevel) {
+ topic.category_level = categoryLevel;
+ changed = true;
+ }
+ if (categorySlug && topic.category_slug !== categorySlug) {
+ topic.category_slug = categorySlug;
+ changed = true;
+ }
+ if (categoryId && Number(topic.category_id) !== categoryId) {
+ topic.category_id = categoryId;
+ changed = true;
+ }
+ if ((!Array.isArray(topic.tags) || !topic.tags.length) && Array.isArray(fallback.tags) && fallback.tags.length) {
+ topic.tags = [...new Set(fallback.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean))];
+ changed = true;
+ }
+ return changed;
+ }
+
+ function renderTopicNavLinks(topic, metadata = {}) {
+ const items = [];
+ const category = topic.category || {};
+ const categoryName = cleanTopicCategoryName(topic.category_name || topic.categoryName || category.name || topic.category_slug || topic.categorySlug || category.slug);
+ const categoryLevel = [topic.category_level, topic.categoryLevel, topic.category_name, topic.categoryName, category.name]
+ .map(topicCategoryLevel).find(Boolean) || '';
+ const categoryLabel = categoryName && categoryLevel ? `${categoryName}, ${categoryLevel}` : categoryName;
+ const categorySlug = topicNavText(topic.category_slug || topic.categorySlug || category.slug);
+ const categoryId = topicNavText(topic.category_id || topic.categoryId || category.id);
+ const navMetadata = topicNavMetadataWithCachedIcons(Object.assign({}, metadata, {
+ categoryName,
+ categorySlug,
+ categoryId,
+ tags: (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).filter(Boolean),
+ }));
+ const discourseUrl = lookupDiscourseModule('discourse/lib/url');
+ const site = lookupDiscourse('service:site');
+ const nativeCategory = [...discourseModelValue(site, 'categories') || []]
+ .find((candidate) => Number(discourseModelValue(candidate, 'id')) === Number(categoryId));
+ const categoryHref = isFunction(discourseUrl?.getCategoryAndTagUrl) &&
+ nativeCategory
+ ? discourseUrl.getCategoryAndTagUrl(nativeCategory, true)
+ : '';
+ if (categoryLabel) {
+ const categoryIconHtml = navMetadata.categoryIconHtml || '';
+ const categoryTextHtml = `
${esc(categoryName || categoryLabel)}${categoryLevel ? `, ${esc(categoryLevel)}` : ''}`;
+ items.push(categoryHref
+ ? `
${categoryIconHtml}${categoryTextHtml}`
+ : `
${categoryIconHtml}${categoryTextHtml}`);
+ }
+ (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).filter(Boolean).forEach((tag) => {
+ const tagIcon = (Array.isArray(navMetadata.tagIcons) ? navMetadata.tagIcons : [])
+ .find((item) => item && item.name === tag);
+ const tagHref = isFunction(discourseUrl?.getCategoryAndTagUrl)
+ ? discourseUrl.getCategoryAndTagUrl(null, true, tag)
+ : '';
+ const tagContent = `${tagIcon ? tagIcon.iconHtml : ''}${esc(tag)}`;
+ items.push(tagHref
+ ? `
${tagContent}`
+ : `
${tagContent}`);
+ });
+ return items.join('');
+ }
+
+ function avatarUrl(template, size) {
+ const normalizedTemplate = String(template || '').trim();
+ if (!normalizedTemplate) return '';
+ const avatarUtils = lookupDiscourseModule('discourse/lib/avatar-utils');
+ return isFunction(avatarUtils?.avatarUrl)
+ ? avatarUtils.avatarUrl(normalizedTemplate, size || 48)
+ : normalizedTemplate.replace('{size}', String(size || 48));
+ }
+
+ function persistentAvatarHtml(template, size, className, fallbackText = '?', options = {}) {
+ const source = avatarUrl(template, size);
+ if (!source) return '';
+ const cachedSource = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source);
+ const eager = options.eager === true;
+ if (eager || cachedSource || source.startsWith('data:') || source.startsWith('blob:'))
+ return `
})
`;
+ return `
${esc(String(fallbackText || '?').slice(0, 1).toUpperCase())}`;
+ }
+
+ function safeFlairColor(value) {
+ const color = String(value || '').trim();
+ if (!/^#?[0-9a-f]{3}(?:[0-9a-f]{3})?(?:[0-9a-f]{2})?$/i.test(color)) return '';
+ return color.startsWith('#') ? color : `#${color}`;
+ }
+
+ function userFlairIconHtml(iconId) {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('class', 'ldp-avatar-flair-icon');
+ svg.setAttribute('aria-hidden', 'true');
+ const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
+ use.setAttribute('href', `#${iconId}`);
+ svg.appendChild(use);
+ if (inlineTopicNavSvgUses(svg)) {
+ const html = safeTopicNavIconHtml(svg.outerHTML);
+ if (html) return html;
+ }
+ return `
`;
+ }
+
+ function userFlairHtml(user) {
+ const rawUrl = String(user && (
+ user.flair_url || user.flairUrl || user.avatar_flair_url || user.avatarFlairUrl
+ ) || '').trim();
+ if (!rawUrl) return '';
+ const label = String(user && (
+ user.flair_name || user.flairName || user.primary_group_name || user.primaryGroupName
+ ) || '资质').trim();
+ const background = safeFlairColor(user && (user.flair_bg_color || user.flairBgColor));
+ const color = safeFlairColor(user && (user.flair_color || user.flairColor));
+ const style = [
+ background ? `--ldp-flair-bg:${background}` : '',
+ color ? `--ldp-flair-color:${color}` : '',
+ ].filter(Boolean).join(';');
+ const styleAttr = style ? ` style="${style}"` : '';
+ if (/^(?:https?:)?\/\//i.test(rawUrl) || rawUrl.startsWith('/') || rawUrl.startsWith('data:image/')) {
+ const source = absoluteImageUrl(rawUrl);
+ return `
})
`;
+ }
+ const iconId = rawUrl.replace(/^fa-/, '');
+ if (!/^[a-z0-9_-]+$/i.test(iconId)) return '';
+ return `
${userFlairIconHtml(iconId)}`;
+ }
+
+ function avatarWithFlairHtml(avatarHtml, user) {
+ return avatarHtml
+ ? `
${avatarHtml}${userFlairHtml(user)}`
+ : '';
+ }
+
+ function userCardAvatarHtml(avatarHtml, username) {
+ const cleanUsername = cleanUsernameValue(username);
+ if (!avatarHtml || !cleanUsername) return avatarHtml;
+ return `
${avatarHtml}`;
+ }
+
+ async function waitForReaderRequestPermit(priority, options = {}) {
+ const queuedAt = requestFlowNow();
+ const callSite = requestFlowCallSite();
+ let scheduler = options.surviveReaderSwitch === true ? null : ACTIVE_READER_REQUEST_SCHEDULER;
+ if (!scheduler) return { queuedAt, permittedAt: queuedAt, waitReason: '', callSite, scheduler };
+ let untrackController = options.controller && scheduler !== options.trackedScheduler &&
+ isFunction(scheduler.trackExternalController)
+ ? scheduler.trackExternalController(options.controller)
+ : () => {};
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ const permit = await scheduler.waitForPermit(priority, options);
+ return {
+ queuedAt,
+ permittedAt: Number(permit?.startedAt) || requestFlowNow(),
+ waitReason: String(permit?.waitReason || ''),
+ callSite,
+ recoveryProbe: permit && permit.recoveryProbe === true,
+ scheduler,
+ release: () => {
+ if (isFunction(permit?.release)) permit.release();
+ untrackController();
+ },
+ };
+ } catch (error) {
+ const replacementScheduler = ACTIVE_READER_REQUEST_SCHEDULER;
+ if (attempt > 0 || options.signal?.aborted || !error || error.name !== 'AbortError' ||
+ !replacementScheduler || replacementScheduler === scheduler) {
+ untrackController();
+ throw error;
+ }
+ untrackController();
+ scheduler = replacementScheduler;
+ untrackController = options.controller && isFunction(scheduler.trackExternalController)
+ ? scheduler.trackExternalController(options.controller)
+ : () => {};
+ }
+ }
+ }
+
+ function persistentResourceResponseIsFresh(
+ response,
+ maxAge,
+ at = Date.now(),
+ cachedAt = Number(response && response.headers.get('x-ldp-cached-at')) || 0
+ ) {
+ return !!response && cachedAt > 0 && at - cachedAt <= maxAge;
+ }
+
+ function createPersistentResourceResponse(blob, contentType = '') {
+ const headers = {
+ 'content-type': blob.type || contentType || 'application/octet-stream',
+ 'content-length': String(blob.size),
+ };
+ headers['x-ldp-cached-at'] = String(Date.now());
+ return new Response(blob, { headers });
+ }
+
+ async function persistentResourceCacheEntry(cacheName, source, maxAge) {
+ const storage = await caches.open(cacheName);
+ const response = await storage.match(source);
+ if (response && !persistentResourceResponseIsFresh(response, maxAge)) {
+ await storage.delete(source);
+ return { storage, response: null };
+ }
+ return { storage, response: response || null };
+ }
+
+ async function prunePersistentResourceCache(cacheName, maxAge) {
+ try {
+ const storage = await caches.open(cacheName);
+ const now = Date.now();
+ const requests = await storage.keys();
+ const entries = [];
+ await runInBatches(requests, RESOURCE_CACHE_PRUNE_BATCH_SIZE, async (request) => {
+ const response = await storage.match(request);
+ const cachedAt = Number(response && response.headers.get('x-ldp-cached-at')) || 0;
+ if (!persistentResourceResponseIsFresh(response, maxAge, now, cachedAt)) {
+ await storage.delete(request);
+ return;
+ }
+ entries.push({
+ request,
+ cachedAt,
+ bytes: Math.max(0, Number(response.headers.get('content-length')) || 0),
+ });
+ });
+ const group = RESOURCE_CACHE_GROUPS.find((entry) => entry.cacheName === cacheName);
+ const maxEntries = Math.max(0, Number(group?.maxEntries) || 0);
+ const maxBytes = Math.max(0, Number(group?.maxBytes) || 0);
+ if (!maxEntries && !maxBytes) return;
+ let keptEntries = 0;
+ let keptBytes = 0;
+ const removeRequests = [];
+ entries.sort((left, right) => right.cachedAt - left.cachedAt).forEach((entry) => {
+ const exceedsEntries = maxEntries > 0 && keptEntries >= maxEntries;
+ const exceedsBytes = maxBytes > 0 && keptBytes + entry.bytes > maxBytes;
+ if (exceedsEntries || exceedsBytes) {
+ removeRequests.push(entry.request);
+ return;
+ }
+ keptEntries++;
+ keptBytes += entry.bytes;
+ });
+ await runInBatches(removeRequests, RESOURCE_CACHE_PRUNE_BATCH_SIZE,
+ (request) => storage.delete(request));
+ } catch {
+ // Persistent resource cleanup is best-effort and must not block the reader.
+ }
+ }
+
+ function prunePersistentResourceCacheOnce(cacheName, maxAge) {
+ if (typeof caches === 'undefined') return Promise.resolve();
+ let request = RESOURCE_CACHE_PRUNE_REQUESTS.get(cacheName);
+ if (!request) {
+ request = prunePersistentResourceCache(cacheName, maxAge);
+ RESOURCE_CACHE_PRUNE_REQUESTS.set(cacheName, request);
+ }
+ return request;
+ }
+
+ function cacheResourceObjectUrl(cache, source, objectUrl, maxEntries) {
+ const previousUrl = cache.get(source);
+ if (previousUrl) {
+ URL.revokeObjectURL(previousUrl);
+ cache.delete(source);
+ }
+ while (cache.size >= maxEntries) {
+ const oldestSource = cache.keys().next().value;
+ const oldestUrl = cache.get(oldestSource);
+ if (oldestUrl) URL.revokeObjectURL(oldestUrl);
+ cache.delete(oldestSource);
+ }
+ cache.set(source, objectUrl);
+ return objectUrl;
+ }
+
+ function touchResourceObjectUrl(cache, source) {
+ const objectUrl = cache.get(source);
+ if (!objectUrl) return '';
+ cache.delete(source);
+ cache.set(source, objectUrl);
+ return objectUrl;
+ }
+
+ function clearResourceObjectUrlCache(cache) {
+ cache.forEach((objectUrl) => {
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ });
+ cache.clear();
+ }
+
+ function trackSingleFlightRequest(requests, key, request) {
+ const trackedRequest = request.finally(() => {
+ if (requests.get(key) === trackedRequest) requests.delete(key);
+ });
+ requests.set(key, trackedRequest);
+ return trackedRequest;
+ }
+
+ function trackResourceCacheWrite(request) {
+ const trackedRequest = Promise.resolve(request).catch(() => {}).finally(() => {
+ RESOURCE_CACHE_WRITES.delete(trackedRequest);
+ });
+ RESOURCE_CACHE_WRITES.add(trackedRequest);
+ return trackedRequest;
+ }
+
+ async function fetchReaderPublicResource(source, priority, type, signal) {
+ const flowPermit = await waitForReaderRequestPermit(priority, {
+ countsTowardApi: false, type, signal,
+ });
+ if (!flowPermit) return null;
+ const { release, ...requestFlowMeta } = flowPermit;
+ try {
+ const response = await trackedReaderFetch(source, {
+ // Public assets may redirect to a wildcard-CORS CDN, which is incompatible with credentials.
+ credentials: 'omit',
+ cache: 'force-cache',
+ signal,
+ [REQUEST_FLOW_FETCH_META]: { source: 'reader', priority, type, ...requestFlowMeta },
+ });
+ return { response, release: isFunction(release) ? release : () => {} };
+ } catch (error) {
+ if (isFunction(release)) release();
+ throw error;
+ }
+ }
+
+ async function avatarResponseBlob(response, onProgress) {
+ if (!isFunction(onProgress) || !response.body || !isFunction(response.body.getReader)) {
+ const blob = await response.blob();
+ if (isFunction(onProgress)) onProgress(blob.size, blob.size);
+ return blob;
+ }
+ const total = Math.max(0, Number(response.headers.get('content-length')) || 0);
+ const reader = response.body.getReader();
+ const chunks = [];
+ let loaded = 0;
+ onProgress(0, total);
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (!value || !value.byteLength) continue;
+ chunks.push(value);
+ loaded += value.byteLength;
+ onProgress(loaded, total);
+ }
+ const bytes = new Uint8Array(loaded);
+ let offset = 0;
+ chunks.forEach((chunk) => {
+ bytes.set(chunk, offset);
+ offset += chunk.byteLength;
+ });
+ const blob = await new Response(bytes, {
+ headers: { 'content-type': response.headers.get('content-type') || 'application/octet-stream' },
+ }).blob();
+ onProgress(loaded, total || loaded);
+ return blob;
+ }
+
+ async function persistentAvatarSource(template, size = 64, priority = POST_REQUEST_PRIORITY.visible, options = {}) {
+ const source = avatarUrl(template, size);
+ const onProgress = isFunction(options.onProgress) ? options.onProgress : null;
+ const completeProgress = () => onProgress && onProgress(1, 1);
+ if (!source || source.startsWith('data:') || source.startsWith('blob:')) {
+ if (source) completeProgress();
+ return source;
+ }
+ if (RESOURCE_CACHE_CLEARING) return source;
+ const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
+ const cachedObjectUrl = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source);
+ if (cachedObjectUrl) {
+ completeProgress();
+ return cachedObjectUrl;
+ }
+ const pendingRequest = AVATAR_RESOURCE_REQUESTS.get(source);
+ if (pendingRequest) {
+ const sharedSource = await pendingRequest;
+ if (sharedSource) completeProgress();
+ return sharedSource;
+ }
+ const request = (async () => {
+ if (typeof caches === 'undefined') return source;
+ try {
+ let { storage, response } = await persistentResourceCacheEntry(
+ LDP_AVATAR_CACHE_NAME, source, LDP_AVATAR_CACHE_MAX_AGE
+ );
+ let blob = null;
+ if (!response) {
+ const resource = await fetchReaderPublicResource(
+ new URL(source, location.href).href, priority, 'avatar', resourceCacheSignal
+ );
+ if (!resource) return '';
+ try {
+ const fetched = resource.response;
+ // Keep uncached avatar starts in the reader cadence. If Cache Storage
+ // cannot fill them, let the browser load the URL normally.
+ if (!fetched.ok) return source;
+ if (options.rejectBlankAvatar) {
+ const contentType = fetched.headers.get('content-type') || '';
+ const lastModified = Date.parse(fetched.headers.get('last-modified') || '');
+ const isDiscourseBlank = Number.isFinite(lastModified) && new Date(lastModified).getUTCFullYear() === 1990;
+ if ((contentType && !contentType.startsWith('image/')) || isDiscourseBlank) return '';
+ }
+ blob = await avatarResponseBlob(fetched, onProgress);
+ if (!blob.size || resourceCacheSignal.aborted) return source;
+ response = createPersistentResourceResponse(blob, fetched.headers.get('content-type'));
+ void trackResourceCacheWrite(storage.put(source, response.clone()));
+ } finally {
+ resource.release();
+ }
+ }
+ if (!blob) blob = await avatarResponseBlob(response, onProgress);
+ if (!blob.size || resourceCacheSignal.aborted) return source;
+ return cacheResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 256);
+ } catch {
+ return source;
+ }
+ })();
+ return trackSingleFlightRequest(AVATAR_RESOURCE_REQUESTS, source, request);
+ }
+
+ function replacePersistentAvatarPlaceholder(placeholder, source) {
+ if (!source || !placeholder || !placeholder.isConnected) return;
+ const image = makeElement('img');
+ image.className = placeholder.dataset.ldpAvatarClass ||
+ (placeholder.classList.contains('ldp-notification-avatar') ? 'ldp-notification-avatar' : 'ldp-avatar');
+ image.src = source;
+ image.alt = '';
+ image.loading = 'lazy';
+ image.decoding = 'async';
+ image.dataset.ldpAvatarOriginalTemplate = placeholder.dataset.ldpAvatarTemplate || '';
+ replaceOwnedNode(placeholder, image);
+ }
+
+ function hydratePersistentAvatarImages(container, priority = POST_REQUEST_PRIORITY.visible) {
+ if (!container) return;
+ container.querySelectorAll('[data-ldp-avatar-template]').forEach((placeholder) => {
+ if (!placeholder.isConnected || AVATAR_HYDRATION_PENDING.has(placeholder)) return;
+ const template = placeholder.dataset.ldpAvatarTemplate;
+ if (!template) return;
+ AVATAR_HYDRATION_PENDING.add(placeholder);
+ persistentAvatarSource(template, Number(placeholder.dataset.ldpAvatarSize) || 64, priority)
+ .then((source) => replacePersistentAvatarPlaceholder(placeholder, source))
+ .finally(() => AVATAR_HYDRATION_PENDING.delete(placeholder));
+ });
+ }
+
+ function releasePersistentAvatarImages(container) {
+ if (!container) return;
+ const placeholders = new Set(container.querySelectorAll('[data-ldp-avatar-template]'));
+ if (container.matches && container.matches('[data-ldp-avatar-template]')) placeholders.add(container);
+ placeholders.forEach((placeholder) => {
+ AVATAR_HYDRATION_PENDING.delete(placeholder);
+ });
+ }
+
+ function userAvatarDownloadFilename(source, username, kind = 'avatar') {
+ let extension = 'png';
+ try {
+ const match = new URL(source, location.href).pathname.match(/\.([a-z0-9]{2,5})$/i);
+ if (match && /^(?:png|apng|jpe?g|gif|webp|avif|svg|bmp|ico)$/i.test(match[1])) extension = match[1].toLowerCase();
+ } catch (error) {
+ void error;
+ }
+ const basename = String(username || 'user').replace(/[^a-z0-9._-]+/gi, '-') || 'user';
+ return `${basename}-${kind === 'background' ? 'background' : 'avatar'}.${extension === 'jpeg' ? 'jpg' : extension}`;
+ }
+
+ function createImageTransformController(stage, image, options = {}) {
+ const captureTarget = options.captureTarget || stage;
+ const transformScope = createLifecycleScope();
+ let scale = 1;
+ let panX = 0;
+ let panY = 0;
+ let containedPan = false;
+ let pointerId = null;
+ let dragX = 0;
+ let dragY = 0;
+ let pendingPanX = 0;
+ let pendingPanY = 0;
+ let dragFrame = 0;
+
+ const clampPan = () => {
+ if (!image.clientWidth || !image.clientHeight) {
+ panX = 0;
+ panY = 0;
+ return;
+ }
+ const padding = Math.max(0, Number(options.overflowPadding) || 0);
+ const scaledWidth = image.clientWidth * scale;
+ const scaledHeight = image.clientHeight * scale;
+ const maxX = scaledWidth > stage.clientWidth
+ ? (scaledWidth - stage.clientWidth) / 2 + padding
+ : containedPan ? Math.max(0, (stage.clientWidth - scaledWidth) / 2 - padding) : 0;
+ const maxY = scaledHeight > stage.clientHeight
+ ? (scaledHeight - stage.clientHeight) / 2 + padding
+ : containedPan ? Math.max(0, (stage.clientHeight - scaledHeight) / 2 - padding) : 0;
+ panX = Math.max(-maxX, Math.min(maxX, panX));
+ panY = Math.max(-maxY, Math.min(maxY, panY));
+ };
+ const render = () => {
+ clampPan();
+ options.render({ scale, panX, panY });
+ image.classList.toggle('is-zoomed', scale > 1.01);
+ if (options.zoomValue) options.zoomValue.textContent = `${Math.round(scale * 100)}%`;
+ if (options.zoomOutButton) options.zoomOutButton.disabled = scale <= 0.25;
+ if (options.zoomInButton) options.zoomInButton.disabled = scale >= 8;
+ };
+ const setZoom = (value, clientX = null, clientY = null) => {
+ const nextScale = Math.max(0.25, Math.min(8, Number(value) || 1));
+ const anchored = Number.isFinite(clientX) && Number.isFinite(clientY) && image.clientWidth && image.clientHeight;
+ if (anchored && nextScale !== scale) {
+ const imageRect = image.getBoundingClientRect();
+ const scaleRatio = nextScale / scale;
+ panX += (clientX - (imageRect.left + imageRect.width / 2)) * (1 - scaleRatio);
+ panY += (clientY - (imageRect.top + imageRect.height / 2)) * (1 - scaleRatio);
+ }
+ containedPan = options.allowContainedPan === true && anchored;
+ scale = nextScale;
+ if (options.resetPanAtFit !== false && scale <= 1.01) panX = panY = 0;
+ render();
+ };
+ const reset = () => {
+ scale = 1;
+ panX = panY = 0;
+ containedPan = false;
+ render();
+ };
+ const flushDrag = () => {
+ dragFrame = 0;
+ panX = pendingPanX;
+ panY = pendingPanY;
+ render();
+ };
+ const onPointerDown = (event) => {
+ if (scale <= 1.01 || event.button !== 0 || event.target !== image) return;
+ pointerId = event.pointerId;
+ dragX = event.clientX - panX;
+ dragY = event.clientY - panY;
+ pendingPanX = panX;
+ pendingPanY = panY;
+ captureTarget.setPointerCapture(pointerId);
+ image.classList.add('is-dragging');
+ if (options.preventDragDefault) event.preventDefault();
+ };
+ const onPointerMove = (event) => {
+ if (pointerId !== event.pointerId) return;
+ pendingPanX = event.clientX - dragX;
+ pendingPanY = event.clientY - dragY;
+ if (!dragFrame) dragFrame = requestAnimationFrame(flushDrag);
+ };
+ const onPointerEnd = (event) => {
+ if (pointerId !== event.pointerId) return;
+ if (dragFrame) {
+ cancelAnimationFrame(dragFrame);
+ flushDrag();
+ }
+ if (captureTarget.hasPointerCapture(pointerId)) captureTarget.releasePointerCapture(pointerId);
+ pointerId = null;
+ image.classList.remove('is-dragging');
+ };
+ transformScope.listen(captureTarget, 'pointerdown', onPointerDown);
+ transformScope.listen(captureTarget, 'pointermove', onPointerMove);
+ transformScope.listen(captureTarget, 'pointerup', onPointerEnd);
+ transformScope.listen(captureTarget, 'pointercancel', onPointerEnd);
+ return {
+ get scale() { return scale; },
+ render,
+ setZoom,
+ reset,
+ destroy() {
+ if (dragFrame) cancelAnimationFrame(dragFrame);
+ transformScope.destroy();
+ },
+ };
+ }
+
+ function handleImageTransformShortcut(event, controller, reset = controller.reset) {
+ if (event.key === '+' || event.key === '=') controller.setZoom(controller.scale * 1.2);
+ else if (event.key === '-') controller.setZoom(controller.scale / 1.2);
+ else if (event.key === '0') reset();
+ else return false;
+ event.preventDefault();
+ return true;
+ }
+
+ function positionUserAvatarViewer(viewer, anchor) {
+ if (!viewer || !viewer.isConnected) return;
+ const margin = 10;
+ const gap = 10;
+ const rect = anchor?.isConnected ? anchor.getBoundingClientRect() : null;
+ const imageViewerSize = Math.min(480, window.innerWidth - margin * 2, window.innerHeight - margin * 2);
+ const width = viewer.offsetWidth || (viewer.classList.contains('is-image') ? imageViewerSize : 320);
+ const height = viewer.offsetHeight || (viewer.classList.contains('is-image') ? imageViewerSize : 358);
+ const batchDialog = viewer.classList.contains('is-image') && anchor && anchor.closest
+ ? anchor.closest('.ldp-lb-batch-dialog')
+ : null;
+ if (batchDialog) {
+ batchDialog.style.removeProperty('transform');
+ const batchRect = batchDialog.getBoundingClientRect();
+ const combinedWidth = batchRect.width + gap + width;
+ if (combinedWidth <= window.innerWidth - margin * 2) {
+ const groupLeft = Math.max(margin, Math.round((window.innerWidth - combinedWidth) / 2));
+ batchDialog.style.transform = `translateX(${Math.round(groupLeft - batchRect.left)}px)`;
+ viewer.style.left = `${Math.round(groupLeft + batchRect.width + gap)}px`;
+ viewer.style.top = `${Math.round(batchRect.top)}px`;
+ viewer.style.height = `${Math.round(batchRect.height)}px`;
+ viewer._ldpBatchDialog = batchDialog;
+ return;
+ }
+ }
+ viewer.style.removeProperty('height');
+ let left = Math.max(margin, Math.round((window.innerWidth - width) / 2));
+ let top = Math.max(margin, Math.round((window.innerHeight - height) / 2));
+ if (rect) {
+ const rightSide = rect.right + gap;
+ const leftSide = rect.left - width - gap;
+ if (rightSide + width <= window.innerWidth - margin) left = rightSide;
+ else if (leftSide >= margin) left = leftSide;
+ else left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin));
+ top = Math.max(margin, Math.min(rect.top, window.innerHeight - height - margin));
+ }
+ viewer.style.left = `${Math.round(left)}px`;
+ viewer.style.top = `${Math.round(top)}px`;
+ }
+
+ function openUserAvatarViewer(template, username, anchorCard, previewSource = '', onDismiss = null, user = null, options = {}) {
+ const isBackground = options.kind === 'background';
+ const isImage = options.kind === 'image';
+ const highResolutionSource = isBackground || isImage ? String(options.source || '').trim() : avatarUrl(template, 1000);
+ const loadImageOriginal = isImage && options.loadOriginal === true && Boolean(highResolutionSource);
+ if (!highResolutionSource && !previewSource) return null;
+ const currentViewer = readerPortalQuery('.ldp-avatar-viewer');
+ if (currentViewer && isImage && options.reuseImageViewer === true && currentViewer._ldpKind === 'image' &&
+ isFunction(currentViewer._ldpUpdateImage)) {
+ currentViewer._ldpUpdateImage({ template, previewSource, highResolutionSource, options });
+ return currentViewer;
+ }
+ if (currentViewer && currentViewer._ldpUsername === username && currentViewer._ldpTemplate === template &&
+ currentViewer._ldpKind === (isBackground ? 'background' : isImage ? 'image' : 'avatar')) {
+ positionUserAvatarViewer(currentViewer, anchorCard);
+ return currentViewer;
+ }
+ if (isFunction(currentViewer?._ldpClose)) currentViewer._ldpClose();
+
+ const viewer = makeElement('div');
+ const viewerScope = createLifecycleScope();
+ const mediaLabel = isBackground ? '背景图' : isImage ? '图片' : '头像';
+ const accessibleName = username ? `${username} 的${mediaLabel}预览` : `${mediaLabel}预览`;
+ viewer.className = `ldp-avatar-viewer${isBackground ? ' is-background' : isImage ? ' is-image' : ''}`;
+ viewer.setAttribute('role', 'dialog');
+ setLabel(viewer, accessibleName);
+ viewer.innerHTML = `
+
+
+ ${isImage && isFunction(options.onPrevious) ? `
` : ''}
+
![${escAttr(accessibleName)}]()
+ ${isImage && isFunction(options.onNext) ? `
` : ''}
+ ${isBackground || isImage ? '' : userFlairHtml(user)}
+
正在加载${mediaLabel}…
+
`;
+ const image = viewer.querySelector('.ldp-avatar-viewer-image');
+ const stage = viewer.querySelector('.ldp-avatar-viewer-stage');
+ const status = viewer.querySelector('.ldp-avatar-viewer-status');
+ const progress = viewer.querySelector('.ldp-avatar-viewer-progress');
+ const progressValue = viewer.querySelector('.ldp-avatar-viewer-progress-value');
+ const downloadButton = viewer.querySelector('[data-avatar-viewer-action="download"]');
+ const selectionInput = viewer.querySelector('.ldp-avatar-viewer-selection input');
+ const selectionLabel = viewer.querySelector('.ldp-avatar-viewer-selection span');
+ const previousButton = viewer.querySelector('[data-avatar-viewer-action="previous"]');
+ const nextButton = viewer.querySelector('[data-avatar-viewer-action="next"]');
+ const zoomValue = viewer.querySelector('.ldp-avatar-viewer-zoom-value');
+ const zoomOutButton = viewer.querySelector('[data-avatar-viewer-action="zoom-out"]');
+ const zoomInButton = viewer.querySelector('[data-avatar-viewer-action="zoom-in"]');
+ let activeOptions = options;
+ let activePreviewSource = previewSource;
+ let activeHighResolutionSource = highResolutionSource;
+ let imageSourceToken = 0;
+ let displayedSource = '';
+ let loadingHighResolution = false;
+ let highResolutionPending = Boolean(highResolutionSource) && !isBackground && (!isImage || loadImageOriginal);
+ let closed = false;
+ const imageTransform = isImage ? createImageTransformController(stage, image, {
+ captureTarget: image,
+ preventDragDefault: true,
+ zoomValue,
+ zoomOutButton,
+ zoomInButton,
+ render: ({ scale, panX, panY }) => {
+ image.style.transform = `translate3d(${Math.round(panX)}px,${Math.round(panY)}px,0) scale(${scale})`;
+ },
+ }) : null;
+
+ const close = (dismissed = false) => {
+ if (closed) return;
+ closed = true;
+ viewerScope.destroy();
+ if (imageTransform) imageTransform.destroy();
+ if (viewer._ldpBatchDialog) viewer._ldpBatchDialog.style.removeProperty('transform');
+ viewer.remove();
+ if (dismissed && isFunction(onDismiss)) onDismiss();
+ };
+ const download = async () => {
+ if (isFunction(activeOptions.onDownload)) {
+ void activeOptions.onDownload();
+ return;
+ }
+ try {
+ const source = activeHighResolutionSource || activePreviewSource || displayedSource;
+ const blob = await fetchLightboxDownloadBlob(source);
+ triggerBrowserDownload(
+ blob,
+ userAvatarDownloadFilename(source, username, isBackground ? 'background' : 'avatar')
+ );
+ } catch (error) {
+ showSelectionToast(`${mediaLabel}下载失败:${error.message || '请重试'}`);
+ }
+ };
+ function onKey(event) {
+ if (!viewer.isConnected) return;
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ close(true);
+ } else if (isImage && event.key === 'ArrowLeft' && isFunction(activeOptions.onPrevious) && !activeOptions.previousDisabled) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ activeOptions.onPrevious();
+ } else if (isImage && event.key === 'ArrowRight' && isFunction(activeOptions.onNext) && !activeOptions.nextDisabled) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ activeOptions.onNext();
+ } else if (isImage && handleImageTransformShortcut(event, imageTransform))
+ event.stopImmediatePropagation();
+ }
+ function onOutside(event) {
+ if (eventPathIncludes(event, viewer) || eventPathIncludes(event, anchorCard)) return;
+ const batchDialog = viewer._ldpBatchDialog || (anchorCard?.closest && anchorCard.closest('.ldp-lb-batch-dialog'));
+ if (isImage && activeOptions.reuseImageViewer === true && batchDialog && eventPathIncludes(event, batchDialog)) return;
+ close(true);
+ }
+ function onViewerWheel(event) {
+ if (!viewer.isConnected || !isImage || !eventPathIncludes(event, viewer)) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ const zoomAtPointer = event.target === image;
+ imageTransform.setZoom(
+ imageTransform.scale * (event.deltaY < 0 ? 1.15 : 1 / 1.15),
+ zoomAtPointer ? event.clientX : null,
+ zoomAtPointer ? event.clientY : null
+ );
+ }
+ function reposition() {
+ positionUserAvatarViewer(viewer, anchorCard);
+ }
+ function updateProgress(loaded, total) {
+ if (closed || !highResolutionPending) return;
+ const hasTotal = Number.isFinite(total) && total > 0;
+ const percent = hasTotal ? Math.max(0, Math.min(100, Math.round((loaded / total) * 100))) : null;
+ progress.hidden = false;
+ progress.classList.toggle('is-indeterminate', percent === null);
+ if (percent === null) {
+ progress.style.removeProperty('--ldp-avatar-progress');
+ progress.removeAttribute('aria-valuenow');
+ progress.setAttribute('aria-valuetext', `正在加载${mediaLabel}原图`);
+ progressValue.textContent = `${mediaLabel}原图加载中`;
+ return;
+ }
+ progress.style.setProperty('--ldp-avatar-progress', `${percent}%`);
+ progress.setAttribute('aria-valuenow', String(percent));
+ progress.setAttribute('aria-valuetext', `${mediaLabel}原图已加载 ${percent}%`);
+ progressValue.textContent = `原图 ${percent}%`;
+ }
+ function finishProgress() {
+ highResolutionPending = false;
+ progress.hidden = true;
+ }
+ const loadViewerImageSources = (nextPreviewSource, nextHighResolutionSource, shouldLoadOriginal) => {
+ const token = ++imageSourceToken;
+ activePreviewSource = String(nextPreviewSource || '');
+ activeHighResolutionSource = String(nextHighResolutionSource || '');
+ loadingHighResolution = false;
+ highResolutionPending = Boolean(activeHighResolutionSource) && shouldLoadOriginal === true;
+ progress.hidden = !highResolutionPending;
+ progress.classList.add('is-indeterminate');
+ progress.removeAttribute('aria-valuenow');
+ progressValue.textContent = '原图加载中';
+ displayedSource = '';
+ downloadButton.disabled = true;
+ status.hidden = Boolean(activePreviewSource);
+ if (activePreviewSource) image.src = activePreviewSource;
+ imageTransform.reset();
+ if (!highResolutionPending) return;
+ void persistentLightboxImageSource(activeHighResolutionSource).then((source) => {
+ if (closed || token !== imageSourceToken) return;
+ if (!source || source === activeHighResolutionSource) {
+ finishProgress();
+ return;
+ }
+ loadingHighResolution = true;
+ image.src = source;
+ }).catch(() => {
+ if (!closed && token === imageSourceToken) finishProgress();
+ });
+ };
+
+ viewer._ldpClose = close;
+ viewer._ldpUsername = username;
+ viewer._ldpTemplate = template;
+ viewer._ldpKind = isBackground ? 'background' : isImage ? 'image' : 'avatar';
+ viewer._ldpAnchorCard = anchorCard;
+ viewer._ldpUpdateImage = (next) => {
+ activeOptions = next.options || {};
+ viewer._ldpTemplate = next.template;
+ if (selectionInput) selectionInput.checked = activeOptions.selected === true;
+ if (selectionLabel) selectionLabel.textContent = activeOptions.selectionLabel || '选择当前图片';
+ if (previousButton) previousButton.disabled = activeOptions.previousDisabled === true;
+ if (nextButton) nextButton.disabled = activeOptions.nextDisabled === true;
+ loadViewerImageSources(next.previewSource, next.highResolutionSource, activeOptions.loadOriginal === true);
+ };
+ viewer.addEventListener('click', (event) => {
+ const action = event.target.closest && event.target.closest('[data-avatar-viewer-action]')?.dataset.avatarViewerAction;
+ if (action === 'download') download();
+ else if (action === 'close') close(true);
+ else if (action === 'previous' && isFunction(activeOptions.onPrevious)) activeOptions.onPrevious();
+ else if (action === 'next' && isFunction(activeOptions.onNext)) activeOptions.onNext();
+ else if (action === 'zoom-out') imageTransform.setZoom(imageTransform.scale / 1.2);
+ else if (action === 'zoom-in') imageTransform.setZoom(imageTransform.scale * 1.2);
+ else if (action === 'zoom-reset') imageTransform.reset();
+ });
+ if (selectionInput) selectionInput.addEventListener('change', () => {
+ if (isFunction(activeOptions.onSelectionChange)) activeOptions.onSelectionChange(selectionInput.checked);
+ });
+ image.addEventListener('load', () => {
+ if (closed) return;
+ const completedHighResolution = loadingHighResolution;
+ displayedSource = image.currentSrc || image.src;
+ loadingHighResolution = false;
+ if (completedHighResolution) finishProgress();
+ image.hidden = false;
+ status.hidden = true;
+ downloadButton.disabled = false;
+ if (!isImage) reposition();
+ });
+ image.addEventListener('error', () => {
+ if (closed) return;
+ if (loadingHighResolution && activePreviewSource) {
+ loadingHighResolution = false;
+ finishProgress();
+ image.src = activePreviewSource;
+ return;
+ }
+ if (loadingHighResolution) finishProgress();
+ if (highResolutionPending) {
+ image.hidden = true;
+ status.hidden = false;
+ status.textContent = `正在加载${mediaLabel}原图…`;
+ return;
+ }
+ image.hidden = true;
+ status.hidden = false;
+ status.textContent = `未获取到可用${mediaLabel}`;
+ downloadButton.disabled = true;
+ });
+ if (isImage) {
+ stage.addEventListener('dblclick', (event) => {
+ if (event.target !== image) return;
+ imageTransform.setZoom(
+ imageTransform.scale > 1.05 ? 1 : Math.max(2, Math.min(8, image.naturalWidth / Math.max(1, image.clientWidth))),
+ event.clientX,
+ event.clientY
+ );
+ });
+ }
+ viewerScope.listen(document, 'keydown', onKey);
+ appendReaderPortalNode(viewer);
+ reposition();
+ if (imageTransform) imageTransform.render();
+ viewerScope.listen(document, 'pointerdown', onOutside, true);
+ if (isImage) viewerScope.listen(
+ document, 'wheel', onViewerWheel, { capture: true, passive: false }
+ );
+ viewerScope.listen(window, 'resize', reposition);
+ if (isBackground) {
+ loadingHighResolution = true;
+ image.src = highResolutionSource;
+ return viewer;
+ }
+ if (isImage) {
+ loadViewerImageSources(previewSource, highResolutionSource, loadImageOriginal);
+ return viewer;
+ }
+ if (previewSource) image.src = previewSource;
+ if (!highResolutionSource) return viewer;
+ void persistentAvatarSource(template, 1000, POST_REQUEST_PRIORITY.auxiliary, {
+ rejectBlankAvatar: true,
+ onProgress: updateProgress,
+ }).then((source) => {
+ if (closed) return;
+ if (!source) {
+ finishProgress();
+ if (!previewSource) status.textContent = '未获取到高清头像';
+ return;
+ }
+ loadingHighResolution = true;
+ image.src = source;
+ });
+ return viewer;
+ }
+
+ function discourseRelativeTime(iso) {
+ const d = new Date(iso);
+ if (!Number.isFinite(d.getTime())) return '';
+ const formatter = lookupDiscourseModule('discourse/lib/formatter');
+ return isFunction(formatter?.relativeAge)
+ ? String(formatter.relativeAge(d, { format: 'medium-with-ago', wrapInSpan: false }) || '')
+ : '';
+ }
+
+ function discourseExactTime(iso) {
+ const d = new Date(iso);
+ if (!Number.isFinite(d.getTime())) return '';
+ const formatter = lookupDiscourseModule('discourse/lib/formatter');
+ return isFunction(formatter?.longDate)
+ ? String(formatter.longDate(d) || '')
+ : '';
+ }
+
+ function refreshPostRelativeTimes(container) {
+ if (!container) return;
+ container.querySelectorAll('.ldp-time-relative').forEach((relativeEl) => {
+ const createdAt = relativeEl.closest('.ldp-post')?.dataset.createdAt || '';
+ const time = discourseRelativeTime(createdAt);
+ if (!time) return;
+ const nextText = `· ${time}`;
+ if (relativeEl.textContent !== nextText) relativeEl.textContent = nextText;
+ });
+ }
+
+ async function discourseCsrfToken() {
+ const sessionModule = lookupDiscourseModule('discourse/models/session');
+ const Session = sessionModule?.default;
+ const ajaxModule = lookupDiscourseModule('discourse/lib/ajax');
+ if (!Session || !isFunction(Session.currentProp) ||
+ !ajaxModule || !isFunction(ajaxModule.updateCsrfToken)) {
+ throw new Error('Discourse 会话服务尚未就绪');
+ }
+ let token = String(Session.currentProp('csrfToken') || '');
+ if (!token) {
+ await ajaxModule.updateCsrfToken();
+ token = String(Session.currentProp('csrfToken') || '');
+ }
+ if (!token) throw new Error('无法获取 Discourse CSRF Token');
+ return token;
+ }
+
+ function createRequestAbortScope(externalSignal) {
+ const controller = new AbortController();
+ let timeout = 0;
+ let timedOut = false;
+ const abortFromExternal = () => controller.abort(externalSignal?.reason);
+ if (externalSignal) {
+ if (externalSignal.aborted) abortFromExternal();
+ else externalSignal.addEventListener('abort', abortFromExternal, { once: true });
+ }
+ return {
+ controller,
+ signal: controller.signal,
+ startTimeout(delay = POST_REQUEST_TIMEOUT) {
+ if (timeout) clearTimeout(timeout);
+ if (controller.signal.aborted) return;
+ timeout = setTimeout(() => {
+ timedOut = true;
+ controller.abort();
+ }, Math.max(1, Number(delay) || POST_REQUEST_TIMEOUT));
+ },
+ stopTimeout() {
+ if (timeout) clearTimeout(timeout);
+ timeout = 0;
+ },
+ timedOut() { return timedOut; },
+ cleanup() {
+ if (timeout) clearTimeout(timeout);
+ timeout = 0;
+ if (externalSignal) externalSignal.removeEventListener('abort', abortFromExternal);
+ },
+ };
+ }
+
+ function requestStatusError(message, status) {
+ const error = new Error(message);
+ error.status = status;
+ return error;
+ }
+
+ function requestTimeoutError(message = '请求超时') {
+ return requestStatusError(message, 408);
+ }
+
+ function acquireSharedReaderRequestPermit(priority, signal, type, scheduler) {
+ if (!SHARED_REQUEST_COORDINATOR) return null;
+ let requestState = null;
+ try { requestState = scheduler?.snapshot(); } catch {}
+ return SHARED_REQUEST_COORDINATOR.acquirePermit({
+ priority,
+ signal,
+ shortBudget: requestState ? requestState.shortWindowBudget : 40,
+ longBudget: requestState ? requestState.longWindowBudget : 160,
+ minInterval: requestState ? requestState.minInterval : 90,
+ maxConcurrent: requestState ? requestState.maxConcurrent : 3,
+ type,
+ });
+ }
+
+ function discourseJSONFetchOptions(headers) {
+ return {
+ method: 'GET',
+ credentials: 'include',
+ cache: 'no-store',
+ headers: {
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Discourse-Present': 'true',
+ ...headers,
+ },
+ };
+ }
+
+ async function fetchJSON(url, options = {}) {
+ const scheduler = options.scheduler || ACTIVE_READER_REQUEST_SCHEDULER;
+ const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible;
+ const requestCallSite = requestFlowCallSite();
+ const fetchOptions = {
+ credentials: 'include',
+ ...(options.fetchOptions || {}),
+ };
+ fetchOptions.headers = Object.assign(
+ { 'Accept': 'application/json' },
+ options.fetchOptions?.headers || {}
+ );
+ if (options.signal) fetchOptions.signal = options.signal;
+ return requestCachedJSON(url, fetchOptions, options, async () => {
+ if (scheduler && options.bypassScheduler !== true)
+ return scheduler.requestJSON(url, fetchOptions, {
+ key: options.key || `reader:${url}`,
+ priority,
+ callSite: requestCallSite,
+ });
+ const queuedAt = requestFlowNow();
+ const requestScope = createRequestAbortScope(fetchOptions.signal);
+ const requestType = requestFlowType(requestFlowUrl(url), 'fetch', fetchOptions.method || 'GET');
+ let sharedPermit = null;
+ try {
+ sharedPermit = await acquireSharedReaderRequestPermit(
+ priority, requestScope.signal, requestType, scheduler);
+ requestScope.startTimeout();
+ const res = await trackedReaderFetch(url, {
+ ...fetchOptions,
+ signal: requestScope.signal,
+ [REQUEST_FLOW_FETCH_META]: {
+ source: 'reader',
+ priority,
+ recoveryProbe: sharedPermit && sharedPermit.recoveryProbe === true,
+ queuedAt,
+ permittedAt: sharedPermit?.startedAt || requestFlowNow(),
+ waitReason: sharedPermit?.waitReason || '',
+ callSite: requestCallSite,
+ attempt: 1,
+ type: requestType,
+ },
+ });
+ noteSharedRequestRateLimit(res);
+ if (!res.ok) throw requestStatusError('HTTP ' + res.status, res.status);
+ return res.json();
+ } catch (error) {
+ if (requestScope.timedOut() && !(fetchOptions.signal?.aborted))
+ throw requestTimeoutError();
+ throw error;
+ } finally {
+ requestScope.cleanup();
+ if (sharedPermit && SHARED_REQUEST_COORDINATOR)
+ SHARED_REQUEST_COORDINATOR.finishPermit(sharedPermit.permitId);
+ }
+ });
+ }
+
+ function ensureKatexStyles() {
+ if (chromeExtensionRuntime()) return;
+ if (document.querySelector('link[data-ldp-katex]')) return;
+ const link = makeElement('link');
+ link.rel = 'stylesheet';
+ link.href = `https://cdn.jsdelivr.net/npm/katex@${KATEX_VERSION}/dist/katex.min.css`;
+ link.dataset.ldpKatex = '1';
+ (document.head || PAGE_ROOT).appendChild(link);
+ }
+
+ function latexTokenInfo(token) {
+ if (token.startsWith('$$') && token.endsWith('$$')) return { tex: token.slice(2, -2), displayMode: true };
+ if (token.startsWith('\\[') && token.endsWith('\\]')) return { tex: token.slice(2, -2), displayMode: true };
+ if (token.startsWith('\\(') && token.endsWith('\\)')) return { tex: token.slice(2, -2), displayMode: false };
+ return { tex: token.slice(1, -1), displayMode: false };
+ }
+
+ function textContainsLatex(value) {
+ return /\\(?:frac|sum|sqrt|int|prod|lim|begin|left|right|mathbf|mathrm|text)|\$\$|\\\[|\\\(|[_^]\{/.test(value || '');
+ }
+
+ function renderLatexInPost(post) {
+ if (!post || post.dataset.ldpLatexRendered === '1') return;
+ if (post.dataset.ldpContentHydrated === '0') return;
+ const content = post.querySelector(':scope > .ldp-content');
+ const katex = globalThis.katex;
+ if (!content) return;
+ if (!textContainsLatex(content.textContent)) {
+ post.dataset.ldpLatexRendered = '1';
+ return;
+ }
+ if (!katex) return;
+ ensureKatexStyles();
+
+ content.querySelectorAll('p').forEach((paragraph) => {
+ if (paragraph.children.length || paragraph.closest('pre,code')) return;
+ const text = paragraph.textContent.trim();
+ if (!/\\(?:frac|sum|sqrt|int|prod|lim|begin)|^[A-Za-z][^\n=]{0,40}=/.test(text)) return;
+ try {
+ katex.render(text, paragraph, { displayMode: true, throwOnError: false, strict: 'ignore' });
+ } catch {}
+ });
+
+ const tokenPattern = /(\$\$[\s\S]+?\$\$|\\\[[\s\S]+?\\\]|\\\([\s\S]+?\\\)|\$(?!\s)(?:\\.|[^$\\])+?\$)/g;
+ const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
+ const textNodes = [];
+ while (walker.nextNode()) {
+ const node = walker.currentNode;
+ const parent = node.parentElement;
+ if (!parent || parent.closest('pre,code,a,.katex')) continue;
+ if (tokenPattern.test(node.nodeValue || '')) textNodes.push(node);
+ tokenPattern.lastIndex = 0;
+ }
+ textNodes.forEach((textNode) => {
+ const value = textNode.nodeValue || '';
+ const fragment = document.createDocumentFragment();
+ let lastIndex = 0;
+ value.replace(tokenPattern, (token, offset) => {
+ if (offset > lastIndex) fragment.appendChild(document.createTextNode(value.slice(lastIndex, offset)));
+ const info = latexTokenInfo(token);
+ const holder = makeElement(info.displayMode ? 'div' : 'span');
+ try {
+ katex.render(info.tex, holder, { displayMode: info.displayMode, throwOnError: false, strict: 'ignore' });
+ fragment.appendChild(holder);
+ } catch {
+ fragment.appendChild(document.createTextNode(token));
+ }
+ lastIndex = offset + token.length;
+ return token;
+ });
+ if (lastIndex < value.length) fragment.appendChild(document.createTextNode(value.slice(lastIndex)));
+ textNode.replaceWith(fragment);
+ tokenPattern.lastIndex = 0;
+ });
+ post.dataset.ldpLatexRendered = '1';
+ }
+
+ function notificationData(notification) {
+ const raw = notification?.data;
+ if (!raw) return {};
+ if (typeof raw === 'object') return raw;
+ try { return JSON.parse(raw); } catch { return {}; }
+ }
+
+ function notificationTypeName(notification) {
+ if (notification?._ldp_type_name) return String(notification._ldp_type_name);
+ const site = lookupDiscourse('service:site');
+ const lookup = site && discourseModelValue(site, 'notificationLookup');
+ return String(lookup?.[Number(notification && notification.notification_type)] || '');
+ }
+
+ function notificationTypeLabel(notification) {
+ return String(notification?._ldp_type_label || notificationTypeName(notification) || '通知');
+ }
+
+ function notificationTypeGroupKey(notification) {
+ if (Object.hasOwn(NOTIFICATION_GROUPS, notification?._ldp_group))
+ return notification._ldp_group;
+ const typeName = notificationTypeName(notification);
+ return Object.entries(NOTIFICATION_GROUPS)
+ .find(([key, entry]) => key !== 'all' && entry.types?.includes(typeName))?.[0] || 'all';
+ }
+
+ function notificationGroupPageSize(groupKey) {
+ return Math.max(1, Number(NOTIFICATION_GROUPS[groupKey]?.pageSize) || NOTIFICATION_PAGE_SIZE);
+ }
+
+ function notificationGroupMode(groupKey) {
+ return NOTIFICATION_GROUPS[groupKey]?.mode || 'notifications';
+ }
+
+ function notificationTypeIconName(notification) {
+ const typeName = notificationTypeName(notification);
+ return NOTIFICATION_TYPE_ICONS[typeName] ||
+ NOTIFICATION_GROUPS[notificationTypeGroupKey(notification)].icon;
+ }
+
+ function consolidatedReplyInfo(notification) {
+ if (notificationTypeName(notification) !== 'replied') return null;
+ const data = notificationData(notification);
+ const count = Math.max(0, Number(data.consolidated_count) || 0);
+ const parentPostNumber = Math.max(0, Number(data.reply_to_post_number) || 0);
+ if (count <= 1 || !parentPostNumber) return null;
+ return { count, parentPostNumber };
+ }
+
+ function notificationPresentationText(value) {
+ const html = isFunction(value?.toHTML) ? value.toHTML() : String(value || '');
+ if (!html) return '';
+ const template = makeElement('template');
+ template.innerHTML = html;
+ return String(template.content.textContent || '').replace(/\s+/g, ' ').trim();
+ }
+
+ async function applyDiscourseNotificationPresentation(notifications) {
+ const notificationModule = lookupDiscourseModule('discourse/models/notification');
+ const Notification = notificationModule?.default;
+ const manager = lookupDiscourseModule('discourse/lib/notification-types-manager');
+ const currentUser = lookupDiscourse('service:current-user');
+ const siteSettings = lookupDiscourse('service:site-settings');
+ const site = lookupDiscourse('service:site');
+ if (!Notification || !isFunction(Notification.initializeNotifications) ||
+ !manager || !isFunction(manager.getRenderDirector) ||
+ !currentUser || !siteSettings || !site) {
+ throw new Error('Discourse 通知渲染服务尚未就绪');
+ }
+ const source = (notifications || []).map((notification) => Object.assign(
+ {}, notification, { data: notificationData(notification) }
+ ));
+ const models = await Notification.initializeNotifications(source);
+ models.forEach((model, index) => {
+ const notification = notifications[index];
+ const modelData = notificationData(model);
+ const typeName = notificationTypeName(model);
+ const director = manager.getRenderDirector(typeName, model, currentUser, siteSettings, site);
+ const label = notificationPresentationText(director.label);
+ const typeLabel = notificationPresentationText(director.linkTitle) || typeName || '通知';
+ const description = notificationPresentationText(director.description);
+ const href = String(director.linkHref || '').trim();
+ notification._ldp_actor = cleanUsernameValue(
+ modelData.display_username || modelData.username || model.username
+ );
+ notification._ldp_type_label = typeLabel;
+ notification._ldp_summary = [label, description].filter(Boolean).join(' · ');
+ notification._ldp_href = href || discoursePageUrl('/my/notifications');
+ notification._ldp_target_topic_id = Math.max(
+ 0,
+ Number(discourseModelValue(model, 'topic_id')) ||
+ Number(discourseModelValue(model, 'topicId')) ||
+ Number(modelData.topic_id) ||
+ 0
+ );
+ notification._ldp_target_post_number = Math.max(
+ 0,
+ Number(discourseModelValue(model, 'post_number')) ||
+ Number(discourseModelValue(model, 'postNumber')) ||
+ Number(modelData.post_number) ||
+ 0
+ );
+ });
+ return notifications;
+ }
+
+ function notificationTopicTarget(notification) {
+ const presentedHref = String(notification?._ldp_href || '').trim();
+ const data = notificationData(notification);
+ const presentedRoute = [
+ presentedHref,
+ data.post_url,
+ data.topic_url,
+ data.url,
+ ].map((value) => extractTopicRouteFromUrl(String(value || ''))).find(Boolean);
+ const topicId = Math.max(
+ 0,
+ Number(presentedRoute?.topicId) ||
+ Number(notification?._ldp_target_topic_id) ||
+ Number(notification?.topic_id) ||
+ Number(data.topic_id) ||
+ 0
+ );
+ if (!topicId) return null;
+ const postNumber = Math.max(
+ 1,
+ Number(presentedRoute?.postNumber) ||
+ Number(notification?._ldp_target_post_number) ||
+ Number(notification?.post_number) ||
+ Number(data.post_number) ||
+ 1
+ );
+ return { topicId, postNumber };
+ }
+
+ function notificationHref(notification) {
+ const target = notificationTopicTarget(notification);
+ if (target) return `${BASE}/t/${target.topicId}/${target.postNumber}`;
+ return String(notification?._ldp_href || discoursePageUrl('/my/notifications'));
+ }
+
+ function notificationTargetDataAttributes(notification) {
+ const target = notificationTopicTarget(notification);
+ return target
+ ? ` data-notification-topic-id="${target.topicId}" data-notification-post-number="${target.postNumber}"`
+ : '';
+ }
+
+ function notificationSummary(notification) {
+ return String(notification?._ldp_summary || notificationTypeLabel(notification));
+ }
+
+ function notificationActor(notification) {
+ if (notification?._ldp_actor) return String(notification._ldp_actor);
+ const data = notificationData(notification);
+ return cleanUsernameValue(data.display_username || data.original_username || data.acting_user_name ||
+ data.username || notification.username);
+ }
+
+ function normalizedMessageRecord(groupKey, sourceId, entry = {}) {
+ const actor = cleanUsernameValue(entry.actor);
+ const createdAt = entry.createdAt || new Date(0).toISOString();
+ const topicId = Math.max(0, Number(entry.topicId) || 0);
+ const postNumber = Math.max(1, Number(entry.postNumber) || 1);
+ return {
+ id: 0,
+ notification_type: 0,
+ read: entry.read,
+ created_at: createdAt,
+ topic_id: topicId,
+ post_number: postNumber,
+ acting_user_avatar_template: String(entry.avatarTemplate || ''),
+ data: { display_username: actor, topic_title: entry.title || '' },
+ _ldp_source: NOTIFICATION_GROUPS[groupKey]?.source || '',
+ _ldp_group: groupKey,
+ _ldp_type_name: groupKey,
+ _ldp_type_label: NOTIFICATION_GROUPS[groupKey]?.label || '消息',
+ _ldp_actor: actor,
+ _ldp_summary: String(entry.summary || entry.title || ''),
+ _ldp_excerpt: notificationPresentationText(entry.excerpt),
+ _ldp_state_label: String(entry.stateLabel || ''),
+ _ldp_display_identity: `${groupKey}:${sourceId || `${topicId}:${postNumber}:${createdAt}`}`,
+ _ldp_target_topic_id: topicId,
+ _ldp_target_post_number: postNumber,
+ };
+ }
+
+ function normalizeUserActionMessage(action, groupKey) {
+ const actionType = Number(action?.action_type) || 0;
+ const actor = action?.acting_username || action?.username;
+ const title = notificationPresentationText(action?.title);
+ const verb = actionType === 9 ? '引用了你' : {
+ replies: '回复了你',
+ likes: '赞了你的帖子',
+ mentions: '@提及了你',
+ edits: '编辑了帖子',
+ links: '链接了你的帖子',
+ }[groupKey] || NOTIFICATION_GROUPS[groupKey]?.label;
+ return normalizedMessageRecord(groupKey, [
+ actionType, action?.post_id, action?.acting_user_id, action?.created_at,
+ ].filter(Boolean).join(':'), {
+ actor,
+ avatarTemplate: action?.acting_avatar_template || action?.avatar_template,
+ createdAt: action?.created_at,
+ topicId: action?.topic_id,
+ postNumber: action?.post_number,
+ title,
+ summary: `${actor ? `@${actor} · ` : ''}${verb}${title ? ` · ${title}` : ''}`,
+ excerpt: action?.excerpt,
+ });
+ }
+
+ function normalizeReactionMessage(reaction) {
+ const post = reaction?.post || {};
+ const actor = reaction?.user?.username;
+ const value = reaction?.reaction?.reaction_value || reaction?.reaction_value || reaction?.reaction || '';
+ const title = notificationPresentationText(post?.topic?.title || post?.topic_title);
+ return normalizedMessageRecord('reactions', reaction?.id, {
+ actor,
+ avatarTemplate: reaction?.user?.avatar_template,
+ createdAt: reaction?.created_at,
+ topicId: post?.topic_id,
+ postNumber: post?.post_number || extractTopicRouteFromUrl(post?.url)?.postNumber,
+ title,
+ summary: `${actor ? `@${actor} · ` : ''}${value ? `用 ${value} ` : ''}回应了你的帖子${title ? ` · ${title}` : ''}`,
+ excerpt: post?.excerpt,
+ });
+ }
+
+ function normalizeBoostMessage(boost) {
+ const post = boost?.post || {};
+ const actor = boost?.user?.username;
+ const title = notificationPresentationText(post?.topic_title);
+ return normalizedMessageRecord('boosts', boost?.id, {
+ actor,
+ avatarTemplate: boost?.user?.avatar_template,
+ createdAt: boost?.created_at,
+ topicId: post?.topic_id,
+ postNumber: extractTopicRouteFromUrl(post?.url)?.postNumber,
+ title,
+ summary: `${actor ? `@${actor} · ` : ''}Boost 了你的帖子${title ? ` · ${title}` : ''}`,
+ excerpt: boost?.cooked || post?.excerpt,
+ });
+ }
+
+ function normalizePrivateMessageTopic(topic, payload, groupKey) {
+ const users = new Map((payload?.users || []).map((user) => [Number(user?.id), user]));
+ const participants = (topic?.participants || topic?.posters || [])
+ .map((participant) => participant?.username ? participant : users.get(Number(participant?.user_id)))
+ .filter(Boolean);
+ const currentUsername = cleanUsernameValue(ME_USERNAME || ME_CURRENT_USER?.username).toLocaleLowerCase();
+ const lastPoster = [...users.values()].find((user) =>
+ cleanUsernameValue(user?.username) === cleanUsernameValue(topic?.last_poster_username)
+ );
+ const actor = [...participants].reverse().find((user) =>
+ cleanUsernameValue(user?.username).toLocaleLowerCase() !== currentUsername
+ ) || lastPoster || participants.at(-1) || {};
+ const highest = Math.max(1, Number(topic?.highest_post_number) || Number(topic?.posts_count) || 1);
+ const lastRead = Math.max(0, Number(topic?.last_read_post_number) || 0);
+ const unread = topic?.unseen === true || Number(topic?.unread) > 0 || Number(topic?.new_posts) > 0;
+ const stateLabel = topic?.unseen === true || Number(topic?.new_posts) > 0 ? '新' : unread ? '未读' : '已读';
+ const target = unread && lastRead < highest ? lastRead + 1 : highest;
+ const title = notificationPresentationText(topic?.fancy_title || topic?.title) || '私信';
+ return normalizedMessageRecord(groupKey, topic?.id, {
+ actor: actor?.username,
+ avatarTemplate: actor?.avatar_template,
+ createdAt: topic?.last_posted_at || topic?.bumped_at || topic?.created_at,
+ topicId: topic?.id,
+ postNumber: target,
+ title,
+ summary: title,
+ excerpt: [
+ actor?.username ? `@${actor.username}` : '',
+ highest > 1 ? `${highest - 1} 条回复` : '',
+ ].filter(Boolean).join(' · '),
+ read: !unread,
+ stateLabel,
+ });
+ }
+
+ function inheritCachedNotificationState(records) {
+ const nativeNotifications = cachedNotificationsForGroup('all', false)
+ .filter((notification) => !notification?._ldp_group && typeof notification?.read === 'boolean');
+ return records.map((record) => {
+ const target = notificationTopicTarget(record);
+ if (!target) return record;
+ const matches = nativeNotifications.filter((notification) => {
+ const candidate = notificationTopicTarget(notification);
+ return candidate?.topicId === target.topicId &&
+ candidate.postNumber === target.postNumber &&
+ notificationTypeGroupKey(notification) === record._ldp_group;
+ });
+ const actor = notificationActor(record).toLocaleLowerCase();
+ const match = matches.find((notification) =>
+ notificationActor(notification).toLocaleLowerCase() === actor
+ ) || (matches.length === 1 ? matches[0] : null);
+ if (!match) return record;
+ return Object.assign(record, {
+ id: Math.max(0, Number(match.id) || 0),
+ notification_type: Math.max(0, Number(match.notification_type) || 0),
+ read: match.read,
+ _ldp_source_notification_id: notificationReadIdentity(match),
+ });
+ });
+ }
+
+ function normalizeSearchText(value) {
+ return String(value || '').toLocaleLowerCase().replace(/\s+/g, '').trim();
+ }
+
+ function pinyinSearchForms(value) {
+ const source = String(value || '');
+ if (!source) return [];
+ const cached = SEARCH_PINYIN_CACHE.get(source);
+ if (cached) {
+ SEARCH_PINYIN_CACHE.delete(source);
+ SEARCH_PINYIN_CACHE.set(source, cached);
+ return cached;
+ }
+ const forms = [normalizeSearchText(source)];
+ const api = globalThis.pinyinPro;
+ if (isFunction(api?.pinyin)) {
+ try {
+ forms.push(normalizeSearchText(api.pinyin(source, { toneType: 'none', nonZh: 'consecutive' })));
+ forms.push(normalizeSearchText(api.pinyin(source, {
+ pattern: 'first', toneType: 'none', nonZh: 'consecutive',
+ })));
+ } catch {}
+ }
+ const result = [...new Set(forms.filter(Boolean))];
+ SEARCH_PINYIN_CACHE.set(source, result);
+ while (SEARCH_PINYIN_CACHE.size > SEARCH_PINYIN_CACHE_MAX_ENTRIES) {
+ SEARCH_PINYIN_CACHE.delete(SEARCH_PINYIN_CACHE.keys().next().value);
+ }
+ return result;
+ }
+
+ function addNotificationStatsIdentities(notificationIds, notifications) {
+ notifications.forEach((notification) => {
+ const notificationId = String(notification?.id || '') || [
+ notification?.topic_id,
+ notification?.post_number,
+ notification?.created_at,
+ ].filter(Boolean).join(':');
+ if (notificationId) notificationIds.add(notificationId);
+ });
+ }
+
+ function notificationDisplayIdentity(notification) {
+ return String(notification?._ldp_display_identity || '') ||
+ String(notification?.id || '') || [
+ notificationHref(notification), notification?.created_at, notificationActor(notification),
+ ].join('|');
+ }
+
+ function notificationReadIdentity(notification) {
+ return String(notification?._ldp_source_notification_id || '') ||
+ String(notification?.id || '') ||
+ notificationDisplayIdentity(notification);
+ }
+
+ function notificationMatchesSearch(notification, needle) {
+ if (!needle) return true;
+ const data = notificationData(notification);
+ return [
+ notificationActor(notification),
+ notificationSummary(notification),
+ notification?._ldp_excerpt,
+ data.display_username,
+ data.original_username,
+ data.acting_user_name,
+ data.username,
+ data.topic_title,
+ notification?.username,
+ ...[
+ notification?.user_id,
+ notification?.acting_user_id,
+ data.user_id,
+ data.acting_user_id,
+ data.original_user_id,
+ ].filter((value) => value != null && value !== '').map(String),
+ ].some((value) => normalizeSearchText(value).includes(needle));
+ }
+
+ function historyMatchesSearch(entry, query) {
+ const needle = normalizeSearchText(query);
+ if (!needle) return true;
+ return pinyinSearchForms(entry?.title).some((value) => value.includes(needle));
+ }
+
+ function reactionRecordMatchesSearch(entry, needle) {
+ return !needle || [
+ entry?.reaction,
+ entry?.authorUsername,
+ entry?.postNumber,
+ ...pinyinSearchForms(entry?.title),
+ ].some((value) => normalizeSearchText(value).includes(needle));
+ }
+
+ function compareGivenReactionRecords(left, right) {
+ return (Date.parse(right?.reactedAt || '') || 0) -
+ (Date.parse(left?.reactedAt || '') || 0) ||
+ Number(right?.id || 0) - Number(left?.id || 0);
+ }
+
+ function forEachCachedNotification(callback) {
+ if (ME_USERNAME === null) return;
+ const authPrefix = authScopedCacheKey('tabs-v7:');
+ NOTIFICATION_CACHE.forEach((entry, cacheKey) => {
+ if (String(cacheKey).startsWith(authPrefix) && Array.isArray(entry?.value?.notifications))
+ entry.value.notifications.forEach(callback);
+ });
+ }
+
+ function cachedNotificationsForGroup(groupKey, sort = true) {
+ const seen = new Set();
+ const notifications = [];
+ forEachCachedNotification((notification) => {
+ if (!notificationMatchesGroup(notification, groupKey)) return;
+ const key = notificationDisplayIdentity(notification);
+ if (seen.has(key)) return;
+ seen.add(key);
+ notifications.push(notification);
+ });
+ return sort ? sortNotificationsNewestFirst(notifications) : notifications;
+ }
+
+ function notificationMatchesGroup(notification, groupKey) {
+ const group = NOTIFICATION_GROUPS[groupKey] || NOTIFICATION_GROUPS.all;
+ if (notification?._ldp_group) return notification._ldp_group === groupKey;
+ return !group.types?.length || group.types.includes(notificationTypeName(notification));
+ }
+
+ function currentUserUnreadNotificationCount(currentUser) {
+ if (!currentUser || typeof currentUser !== 'object') return null;
+ const readCount = (key) => {
+ const value = discourseModelValue(currentUser, key);
+ if (value == null || value === '') return null;
+ const count = Number(value);
+ if (Number.isFinite(count)) return Math.max(0, Math.floor(count));
+ return null;
+ };
+ const allUnread = readCount('all_unread_notifications_count');
+ if (allUnread != null) return allUnread;
+ const normalUnread = readCount('unread_notifications');
+ const highPriorityUnread = readCount('unread_high_priority_notifications');
+ if (normalUnread != null || highPriorityUnread != null)
+ return (normalUnread || 0) + (highPriorityUnread || 0);
+ return readCount('unread_notification_count');
+ }
+
+ function cachedUnreadNotificationCount(groupKey = 'all') {
+ const unread = new Set();
+ cachedNotificationsForGroup(groupKey, false).forEach((notification) => {
+ if (notification && notification.read === false) unread.add(notificationReadIdentity(notification));
+ });
+ return unread.size;
+ }
+
+ function setCurrentUserNotificationState(currentUser, values) {
+ if (!currentUser || !isFunction(currentUser.set)) return;
+ if (isFunction(currentUser.setProperties)) {
+ currentUser.setProperties(values);
+ return;
+ }
+ Object.entries(values).forEach(([key, value]) => currentUser.set(key, value));
+ }
+
+ function decrementCurrentUserNotificationType(typeId, highPriority = false) {
+ const currentUser = lookupDiscourse('service:current-user');
+ if (!currentUser || !isFunction(currentUser.set) || !(Number(typeId) > 0)) return;
+ const grouped = {
+ ...(discourseModelValue(currentUser, 'grouped_unread_notifications') || {}),
+ };
+ const unreadCount = Number(grouped[typeId]) || 0;
+ if (unreadCount > 0) grouped[typeId] = unreadCount - 1;
+ const normalUnread = Math.max(
+ 0, Math.floor(Number(discourseModelValue(currentUser, 'unread_notifications')) || 0)
+ );
+ const highPriorityUnread = Math.max(
+ 0, Math.floor(Number(discourseModelValue(currentUser, 'unread_high_priority_notifications')) || 0)
+ );
+ const nextNormalUnread = highPriority ? normalUnread : Math.max(0, normalUnread - 1);
+ const nextHighPriorityUnread = highPriority
+ ? Math.max(0, highPriorityUnread - 1)
+ : highPriorityUnread;
+ const values = {
+ grouped_unread_notifications: grouped,
+ unread_notifications: nextNormalUnread,
+ unread_high_priority_notifications: nextHighPriorityUnread,
+ all_unread_notifications_count: nextNormalUnread + nextHighPriorityUnread,
+ };
+ if (discourseModelValue(currentUser, 'unread_notification_count') != null)
+ values.unread_notification_count = Math.max(
+ 0, Math.floor(Number(discourseModelValue(currentUser, 'unread_notification_count')) || 0) - 1
+ );
+ setCurrentUserNotificationState(currentUser, values);
+ }
+
+ function syncCurrentUserNotificationsDismissed() {
+ const currentUser = lookupDiscourse('service:current-user');
+ if (!currentUser || !isFunction(currentUser.set)) return;
+ const values = {
+ all_unread_notifications_count: 0,
+ unread_notifications: 0,
+ unread_high_priority_notifications: 0,
+ grouped_unread_notifications: {},
+ };
+ if (discourseModelValue(currentUser, 'unread_notification_count') != null)
+ values.unread_notification_count = 0;
+ setCurrentUserNotificationState(currentUser, values);
+ }
+
+ function syncNotificationUnreadUi(count = NOTIFICATION_UNREAD_COUNT) {
+ NOTIFICATION_UNREAD_COUNT = Math.max(0, Math.floor(Number(count) || 0));
+ const badgeText = NOTIFICATION_UNREAD_COUNT > 99 ? '99+' : String(NOTIFICATION_UNREAD_COUNT);
+ readerPortalQueryAll('.ldp-notification-unread-badge').forEach((badge) => {
+ badge.hidden = NOTIFICATION_UNREAD_COUNT <= 0;
+ badge.textContent = badgeText;
+ });
+ readerPortalQueryAll('.ldp-notification-unread-status').forEach((status) => {
+ status.textContent = NOTIFICATION_UNREAD_COUNT > 0 ? `未读 ${NOTIFICATION_UNREAD_COUNT} 条` : '没有未读消息';
+ });
+ readerPortalQueryAll('.ldp-notification-mark-all').forEach((button) => {
+ if (button.dataset.ldpRequestBusy !== '1') button.disabled = NOTIFICATION_UNREAD_COUNT <= 0;
+ });
+ }
+
+ function markCachedNotificationsRead() {
+ let changed = false;
+ forEachCachedNotification((notification) => {
+ if (!notification || notification.read !== false ||
+ (notification._ldp_group && !notification._ldp_source_notification_id)) return;
+ notification.read = true;
+ changed = true;
+ });
+ if (changed) schedulePersistentCacheWrite('notifications', 0);
+ syncNotificationUnreadUi(0);
+ }
+
+ function markClickedNotificationRead(item) {
+ if (!item || item.dataset.notificationSource !== 'notifications' ||
+ !item.classList.contains('unread')) return 0;
+ const notificationKey = String(item.dataset.notificationReadKey || item.dataset.notificationKey || '');
+ let notificationTypeId = 0;
+ let notificationHighPriority = false;
+ let cacheChanged = false;
+ forEachCachedNotification((notification) => {
+ if (!notification || notification.read !== false ||
+ notificationReadIdentity(notification) !== notificationKey) return;
+ notification.read = true;
+ notificationTypeId ||= Number(notification.notification_type) || 0;
+ notificationHighPriority ||= notification.high_priority === true;
+ cacheChanged = true;
+ });
+ if (cacheChanged) schedulePersistentCacheWrite('notifications', 0);
+ decrementCurrentUserNotificationType(notificationTypeId, notificationHighPriority);
+ readerPortalQueryAll('.ldp-notification-item').forEach((notificationItem) => {
+ if (String(notificationItem.dataset.notificationReadKey || notificationItem.dataset.notificationKey || '') !==
+ notificationKey) return;
+ notificationItem.classList.remove('unread');
+ const readState = notificationItem.querySelector('.ldp-notification-read-state');
+ if (readState) readState.textContent = '已读';
+ });
+ syncNotificationUnreadUi(Math.max(0, NOTIFICATION_UNREAD_COUNT - 1));
+ return Math.max(0, Number(item.dataset.notificationId) || 0);
+ }
+
+ function notificationDateGroup(createdAt) {
+ const date = new Date(createdAt || 0);
+ if (Number.isNaN(date.getTime())) return '更早';
+ const today = new Date();
+ const startToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
+ const startDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
+ if (startDate === startToday) return '今天';
+ if (startDate === startToday - 86400000) return '昨天';
+ return '更早';
+ }
+
+ function groupNotificationsByDate(notifications) {
+ const buckets = new Map([['今天', []], ['昨天', []], ['更早', []]]);
+ (notifications || []).forEach((notification) => {
+ buckets.get(notificationDateGroup(notification.created_at)).push(notification);
+ });
+ return [...buckets.entries()].filter(([, items]) => items.length);
+ }
+
+ function notificationAvatarHtml(notification) {
+ const data = notificationData(notification);
+ const actor = notificationActor(notification);
+ const template = notification.acting_user_avatar_template || notification.avatar_template ||
+ data.acting_user_avatar_template || data.avatar_template || cachedActorAvatar(actor);
+ let avatarHtml;
+ if (template) {
+ const cachedSource = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, avatarUrl(template, 64));
+ avatarHtml = cachedSource
+ ? `
})
`
+ : `
${esc(String(actor || '?').slice(0, 1).toUpperCase())}`;
+ } else {
+ avatarHtml = `
${esc(String(actor || '?').slice(0, 1).toUpperCase())}`;
+ }
+ return userCardAvatarHtml(avatarHtml, actor);
+ }
+
+ function cachedActorAvatar(actor, historySource = readTopicHistory) {
+ actor = cleanUsernameValue(actor);
+ if (!actor) return '';
+ const cachedTemplate = NOTIFICATION_AVATAR_CACHE.get(actor);
+ if (cachedTemplate) {
+ rememberNotificationAvatar(actor, cachedTemplate);
+ return cachedTemplate;
+ }
+ const cachedUser = getCachedUserCard(actor);
+ let template = String(cachedUser?.avatar_template || '').trim();
+ if (!template) {
+ const actorKey = actor.toLocaleLowerCase();
+ const historyEntry = historySource().find((entry) =>
+ cleanUsernameValue(entry?.ownerUsername).toLocaleLowerCase() === actorKey && entry.avatarTemplate
+ );
+ template = String(historyEntry?.avatarTemplate || '').trim();
+ }
+ if (template) rememberNotificationAvatar(actor, template);
+ return template;
+ }
+
+ function rememberNotificationAvatar(actor, template) {
+ actor = cleanUsernameValue(actor);
+ template = String(template || '').trim();
+ if (!actor || !template) return;
+ NOTIFICATION_AVATAR_CACHE.delete(actor);
+ NOTIFICATION_AVATAR_CACHE.set(actor, template);
+ while (NOTIFICATION_AVATAR_CACHE.size > NOTIFICATION_AVATAR_CACHE_MAX_ENTRIES) {
+ NOTIFICATION_AVATAR_CACHE.delete(NOTIFICATION_AVATAR_CACHE.keys().next().value);
+ }
+ }
+
+ function notificationCacheSnapshot(notification) {
+ if (!notification || typeof notification !== 'object') return notification;
+ const data = notificationData(notification);
+ const snapshot = Object.assign({}, notification, { data });
+ if (!snapshot._ldp_actor) snapshot._ldp_actor = notificationActor(snapshot);
+ const actor = notificationActor(snapshot);
+ const template = notification.acting_user_avatar_template || notification.avatar_template ||
+ data.acting_user_avatar_template || data.avatar_template || cachedActorAvatar(actor) || '';
+ if (template) {
+ snapshot.avatar_template = template;
+ if (actor) rememberNotificationAvatar(actor, template);
+ }
+ return snapshot;
+ }
+
+ function topicReplyMatchesConsolidatedBucket(post, parentPostNumber, latestPostNumber) {
+ const postNumber = Math.max(0, Number(post?.post_number) || 0);
+ if (!postNumber || postNumber > latestPostNumber) return false;
+ const replyTo = Math.max(0, Number(post?.reply_to_post_number) || 0);
+ return parentPostNumber === 1 ? replyTo <= 1 && postNumber > 1 : replyTo === parentPostNumber;
+ }
+
+ function expandedReplyNotification(notification, post) {
+ const data = { ...notificationData(notification) };
+ const actor = cleanUsernameValue(post?.username);
+ const postNumber = Math.max(0, Number(post?.post_number) || 0);
+ Object.assign(data, {
+ consolidated_count: 1,
+ display_username: actor,
+ original_username: actor,
+ acting_user_name: actor,
+ username: actor,
+ post_number: postNumber,
+ });
+ const expanded = Object.assign({}, notification, {
+ data,
+ post_number: postNumber,
+ created_at: post?.created_at || notification.created_at,
+ username: actor,
+ acting_user_avatar_template: post?.avatar_template ||
+ notification.acting_user_avatar_template,
+ _ldp_display_identity: `${notification.id || 'reply'}:reply:${postNumber}`,
+ _ldp_source_notification_id: notification.id || '',
+ });
+ delete expanded._ldp_actor;
+ delete expanded._ldp_summary;
+ delete expanded._ldp_href;
+ delete expanded._ldp_type_label;
+ return expanded;
+ }
+
+ async function expandConsolidatedReplyNotification(notification, options = {}) {
+ const info = consolidatedReplyInfo(notification);
+ const topicId = Math.max(0, Number(notification?.topic_id) || 0);
+ const latestPostNumber = Math.max(
+ 0,
+ Number(notification?.post_number) ||
+ Number(notificationData(notification).post_number) ||
+ 0
+ );
+ if (!info || !topicId || !latestPostNumber) return [notification];
+ try {
+ const payload = await fetchJSON(`${BASE}/t/${topicId}.json?post_number=${latestPostNumber}`, {
+ key: `notification-replies:${topicId}:${latestPostNumber}`,
+ priority: POST_REQUEST_PRIORITY.auxiliary,
+ cacheMode: options.force ? 'refresh' : 'default',
+ });
+ const replies = postsFromPayload(payload)
+ .filter((post) => topicReplyMatchesConsolidatedBucket(
+ post,
+ info.parentPostNumber,
+ latestPostNumber
+ ))
+ .sort((left, right) => Number(right.post_number) - Number(left.post_number))
+ .slice(0, info.count)
+ .sort((left, right) => Number(right.post_number) - Number(left.post_number));
+ if (replies.length !== info.count) return [notification];
+ return replies.map((post) => expandedReplyNotification(notification, post));
+ } catch {
+ return [notification];
+ }
+ }
+
+ async function expandConsolidatedReplyNotifications(notifications, options = {}) {
+ const groups = await Promise.all((notifications || []).map((notification) =>
+ expandConsolidatedReplyNotification(notification, options)));
+ const expanded = groups.flat();
+ await applyDiscourseNotificationPresentation(expanded);
+ return expanded.map(notificationCacheSnapshot);
+ }
+
+ function persistNotificationActorAvatar(actor, template) {
+ actor = cleanUsernameValue(actor);
+ template = String(template || '').trim();
+ if (!actor || !template) return;
+ rememberNotificationAvatar(actor, template);
+ let changed = false;
+ forEachCachedNotification((notification) => {
+ if (notificationActor(notification) !== actor || notification.avatar_template === template) return;
+ notification.avatar_template = template;
+ changed = true;
+ });
+ if (changed) schedulePersistentCacheWrite('notifications', 0);
+ }
+
+ function hydrateActorAvatarNodes(container, templates, attribute) {
+ if (!container.isConnected || !templates.size) return;
+ container.querySelectorAll(`[${attribute}]`).forEach((item) => {
+ const template = templates.get(item.getAttribute(attribute));
+ const current = template && item.querySelector('.ldp-notification-avatar');
+ if (!current || current.tagName === 'IMG') return;
+ current.dataset.ldpAvatarTemplate = template;
+ });
+ }
+
+ function cachedActorAvatars(actors, historySource) {
+ const templates = new Map();
+ actors.forEach((actor) => {
+ const template = cachedActorAvatar(actor, historySource);
+ if (template) templates.set(actor, template);
+ });
+ return templates;
+ }
+
+ function hydrateNotificationAvatars(container, notifications) {
+ if (!container) return;
+ const actors = new Set();
+ (notifications || []).forEach((notification) => {
+ const actor = notificationActor(notification);
+ const data = notificationData(notification);
+ if (actor && !(notification.acting_user_avatar_template || notification.avatar_template ||
+ data.acting_user_avatar_template || data.avatar_template)) actors.add(actor);
+ });
+ let history;
+ const templates = cachedActorAvatars(actors, () => history || (history = readTopicHistory()));
+ if (container.isConnected)
+ templates.forEach((template, actor) => persistNotificationActorAvatar(actor, template));
+ hydrateActorAvatarNodes(container, templates, 'data-notification-actor');
+ hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.auxiliary);
+ }
+
+ function hydrateHistoryAvatars(container, entries, storedHistory = null) {
+ if (!container) return;
+ const actors = new Set();
+ (entries || []).forEach((entry) => {
+ const actor = cleanUsernameValue(entry?.ownerUsername);
+ if (actor && !entry.avatarTemplate) actors.add(actor);
+ });
+ const history = actors.size ? storedHistory || readTopicHistory() : [];
+ const templates = cachedActorAvatars(actors, () => history);
+ let historyChanged = false;
+ history.forEach((entry) => {
+ const template = templates.get(cleanUsernameValue(entry?.ownerUsername));
+ if (!template || entry.avatarTemplate === template) return;
+ entry.avatarTemplate = template;
+ historyChanged = true;
+ });
+ if (historyChanged) writeTopicHistory(history);
+ hydrateActorAvatarNodes(container, templates, 'data-history-actor');
+ hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.auxiliary);
+ }
+
+ function renderNotificationList(notifications) {
+ const groups = groupNotificationsByDate(notifications);
+ if (!groups.length) return '
暂无消息
';
+ return groups.map(([label, items]) => `
+
`).join('');
+ }
+
+ function collectionLinkMarkup(className, href, avatar, title, meta, metaIsHtml = false) {
+ return `
+ ${avatar}
+
+ ${esc(title)}
+ ${metaIsHtml ? meta : esc(meta)}
+
+ `;
+ }
+
+ function getCachedNotificationPage(cacheKey) {
+ const entry = NOTIFICATION_CACHE.get(cacheKey);
+ if (!entry) return null;
+ if (Date.now() - Number(entry.at || 0) > PERSISTENT_CACHE_CONFIG.notifications.maxAge) {
+ NOTIFICATION_CACHE.delete(cacheKey);
+ schedulePersistentCacheWrite('notifications');
+ return null;
+ }
+ NOTIFICATION_CACHE.delete(cacheKey);
+ NOTIFICATION_CACHE.set(cacheKey, entry);
+ return entry.value || null;
+ }
+
+ function setCachedNotificationPage(cacheKey, value) {
+ if (!cacheKey || !value) return;
+ NOTIFICATION_CACHE.delete(cacheKey);
+ NOTIFICATION_CACHE.set(cacheKey, { value, at: Date.now() });
+ trimPersistentCacheMap('notifications');
+ schedulePersistentCacheWrite('notifications', 0);
+ }
+
+ function notificationPageCacheKey(groupKey, page) {
+ return authScopedCacheKey(`tabs-v7:${groupKey}:${Math.max(0, Number(page) || 0)}`);
+ }
+
+ function expireNotificationFirstPageCaches() {
+ const staleAt = Date.now() - NOTIFICATION_CACHE_FRESH_FOR - 1;
+ let changed = false;
+ Object.keys(NOTIFICATION_GROUPS).forEach((groupKey) => {
+ const entry = NOTIFICATION_CACHE.get(notificationPageCacheKey(groupKey, 0));
+ if (!entry) return;
+ entry.at = staleAt;
+ changed = true;
+ });
+ if (changed) schedulePersistentCacheWrite('notifications', 0);
+ }
+
+ function sortNotificationsNewestFirst(notifications) {
+ return (notifications || []).slice().sort((a, b) => {
+ const timeA = Date.parse(a?.created_at || '') || 0;
+ const timeB = Date.parse(b?.created_at || '') || 0;
+ return timeB - timeA || Number(b?.id || 0) - Number(a?.id || 0);
+ });
+ }
+
+ async function fetchNotificationPage(groupKey, page, options = {}) {
+ const safeGroupKey = Object.hasOwn(NOTIFICATION_GROUPS, groupKey) ? groupKey : 'all';
+ const group = NOTIFICATION_GROUPS[safeGroupKey];
+ const pageSize = notificationGroupPageSize(safeGroupKey);
+ page = Math.max(0, Number(page) || 0);
+ const cacheKey = notificationPageCacheKey(safeGroupKey, page);
+ const pendingRequest = NOTIFICATION_REQUESTS.get(cacheKey);
+ if (pendingRequest) return pendingRequest;
+ const request = (async () => {
+ const username = cleanUsernameValue(ME_USERNAME || ME_CURRENT_USER?.username).toLocaleLowerCase();
+ if (!username && group.source !== 'notifications') throw new Error('登录信息尚未就绪');
+ const params = new URLSearchParams();
+ let endpoint = '';
+ let normalize = (entry) => entry;
+ let pickEntries = (data) => data?.notifications;
+ let previous = null;
+ if (group.source === 'notifications') {
+ params.set('offset', String(page * pageSize));
+ params.set('limit', String(pageSize));
+ if (username) params.set('username', username);
+ endpoint = `${BASE}/notifications.json`;
+ } else if (group.source === 'user-actions') {
+ params.set('offset', String(page * pageSize));
+ params.set('limit', String(pageSize));
+ params.set('username', username);
+ params.set('filter', group.actionTypes.join(','));
+ endpoint = `${BASE}/user_actions.json`;
+ pickEntries = (data) => data?.user_actions;
+ normalize = (entry) => normalizeUserActionMessage(entry, safeGroupKey);
+ } else if (group.source === 'boosts-received' ||
+ group.source === 'reactions-received') {
+ if (page > 0) {
+ const previousCacheKey = notificationPageCacheKey(safeGroupKey, page - 1);
+ previous = options.force
+ ? await fetchNotificationPage(safeGroupKey, page - 1, options)
+ : getCachedNotificationPage(previousCacheKey) ||
+ await fetchNotificationPage(safeGroupKey, page - 1, options);
+ }
+ if (group.source === 'boosts-received') {
+ if (previous?.nextCursor) params.set('before_boost_id', previous.nextCursor);
+ endpoint = `${BASE}/discourse-boosts/users/${encodeURIComponent(username)}/boosts-received.json`;
+ pickEntries = (data) => data?.boosts;
+ normalize = normalizeBoostMessage;
+ } else {
+ params.set('username', username);
+ if (previous?.nextCursor) params.set('before_reaction_user_id', previous.nextCursor);
+ endpoint = `${BASE}/discourse-reactions/posts/reactions-received.json`;
+ pickEntries = (data) => data;
+ normalize = normalizeReactionMessage;
+ }
+ } else {
+ params.set('page', String(page));
+ endpoint = `${BASE}/topics/${group.path}/${encodeURIComponent(username)}.json`;
+ pickEntries = (data) => data?.topic_list?.topics;
+ normalize = (entry, data) => normalizePrivateMessageTopic(entry, data, safeGroupKey);
+ }
+ const query = params.toString();
+ const data = await fetchJSON(`${endpoint}${query ? `?${query}` : ''}`, {
+ key: `notifications:${cacheKey}`,
+ priority: POST_REQUEST_PRIORITY.auxiliary,
+ cacheMode: options.force ? 'refresh' : 'default',
+ });
+ const pickedEntries = pickEntries(data);
+ const rawEntries = Array.isArray(pickedEntries)
+ ? pickedEntries
+ : Array.isArray(pickedEntries?.reactions) ? pickedEntries.reactions : [];
+ const normalized = rawEntries.map((entry) => normalize(entry, data));
+ const enriched = group.mode === 'notifications' && group.source !== 'notifications'
+ ? inheritCachedNotificationState(normalized)
+ : normalized;
+ const notifications = group.source === 'notifications'
+ ? sortNotificationsNewestFirst(await expandConsolidatedReplyNotifications(
+ enriched.filter((notification) => notificationMatchesGroup(notification, safeGroupKey)),
+ options
+ ))
+ : sortNotificationsNewestFirst(enriched);
+ const offset = page * pageSize;
+ const serverTotal = Number(data?.total_rows_notifications || data?.topic_list?.total_rows || 0);
+ const hasNext = group.source === 'notifications'
+ ? !!data.load_more_notifications || (serverTotal > 0 && offset + pageSize < serverTotal)
+ : group.source === 'private-messages'
+ ? !!data?.topic_list?.more_topics_url || rawEntries.length >= pageSize
+ : rawEntries.length >= pageSize;
+ const total = serverTotal > 0
+ ? serverTotal
+ : offset + notifications.length + (hasNext ? pageSize : 0);
+ const lastEntry = rawEntries.at(-1);
+ const nextCursor = lastEntry?.reaction_user_id || lastEntry?.id || '';
+ const result = { notifications, total, hasNext, nextCursor };
+ setCachedNotificationPage(cacheKey, result);
+ return result;
+ })();
+ return await trackSingleFlightRequest(NOTIFICATION_REQUESTS, cacheKey, request);
+ }
+
+ function postsFromPayload(data) {
+ if (!data) return [];
+ if (Array.isArray(data)) return data;
+ if (Array.isArray(data.post_stream?.posts)) return data.post_stream.posts;
+ if (Array.isArray(data.posts)) return data.posts;
+ if (data.post?.post_number) return [data.post];
+ if (data.post_number) return [data];
+ return [];
+ }
+
+ function shouldStopReaderFallback(error) {
+ const status = Number(error?.status || 0);
+ return !!(error && error.name === 'AbortError') || status === 408 || status === 429 || status >= 500;
+ }
+
+ function retryAfterMs(response, attempt) {
+ const raw = requestFlowHeader(response?.headers, REQUEST_FLOW_RESPONSE_HEADERS.retryAfter);
+ let wait = 0;
+ if (raw) {
+ const seconds = Number(raw);
+ wait = Number.isFinite(seconds)
+ ? seconds * 1000
+ : Math.max(0, Date.parse(raw) - Date.now());
+ }
+ if (!wait) wait = POST_REQUEST_429_BACKOFF * Math.pow(2, Math.max(0, attempt - 1));
+ return Math.max(1000, Math.min(60000, wait));
+ }
+
+ function requestRateLimitWindow(response) {
+ return rateLimitWindowFromCode(requestFlowHeader(
+ response?.headers, REQUEST_FLOW_RESPONSE_HEADERS.rateLimitCode
+ ));
+ }
+
+ function noteSharedRequestRateLimit(response, attempt = 1) {
+ if (!SHARED_REQUEST_COORDINATOR || !response || response.status !== 429) return;
+ SHARED_REQUEST_COORDINATOR.noteRateLimit({
+ wait: retryAfterMs(response, attempt),
+ window: requestRateLimitWindow(response).label,
+ shortBudget: 40,
+ longBudget: 160,
+ });
+ }
+
+ function createReaderRequestScheduler(runtimeConfig) {
+ const requestMaxConcurrent = runtimeConfig.requestMaxConcurrent;
+ const requestMinInterval = runtimeConfig.requestMinInterval;
+ const requestRateTarget = Math.min(0.95, Math.max(0.5, runtimeConfig.requestRateTarget / 100));
+ const shortWindowBudget = Math.max(1,
+ Math.floor(READER_REQUEST_DEFAULT_SHORT_CEILING * requestRateTarget));
+ const longWindowBudget = Math.max(1,
+ Math.floor(READER_REQUEST_DEFAULT_LONG_CEILING * requestRateTarget));
+ const queue = [];
+ const tasksByKey = new Map();
+ const endpointRateLimitCircuits = new Map();
+ const unknownRateLimitEvidence = [];
+ const activeControllers = new Set();
+ const pendingPermitControllers = new Set();
+ const externalControllers = new Set();
+ let activeCount = 0;
+ let sharedPermitPending = false;
+ let sequence = 0;
+ let responseLatencyEwma = 0;
+ let responseLatencyPeak = 0;
+ let responseLatencySamples = 0;
+ let pumpTimer = 0;
+ let disposed = false;
+
+ const abortError = (reason = 'Aborted') => new DOMException(String(reason || 'Aborted'), 'AbortError');
+
+ const endpointRateLimitIdentity = (input, method = 'GET') => {
+ const url = requestFlowUrl(input);
+ const normalizedMethod = String(method || 'GET').toUpperCase();
+ if (!url) {
+ const invalid = `${normalizedMethod}:invalid:${String(input || '').slice(0, 160)}`;
+ return { fingerprint: invalid, route: invalid };
+ }
+ const params = [...url.searchParams.entries()].filter(([key]) => key !== '_ldp_retry');
+ const identity = (routeOnly) => {
+ const path = routeOnly
+ ? url.pathname
+ .replace(/\b\d+\b/g, ':id')
+ .replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, ':uuid')
+ : url.pathname;
+ const sorted = params.map(([key, value]) => routeOnly ? [key, ''] : [key, value])
+ .sort(([aKey, aValue], [bKey, bValue]) =>
+ aKey.localeCompare(bKey) || aValue.localeCompare(bValue));
+ const query = routeOnly
+ ? [...new Set(sorted.map(([key]) => encodeURIComponent(key)))].join('&')
+ : sorted.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
+ return `${normalizedMethod}:${url.origin}${path}${query ? `?${query}` : ''}`;
+ };
+ return { fingerprint: identity(false), route: identity(true) };
+ };
+
+ const pruneEndpointRateLimitState = (at = Date.now()) => {
+ const evidenceCutoff = at - READER_ENDPOINT_429_EVIDENCE_WINDOW_MS;
+ while (unknownRateLimitEvidence.length && unknownRateLimitEvidence[0].at < evidenceCutoff) {
+ unknownRateLimitEvidence.shift();
+ }
+ endpointRateLimitCircuits.forEach((state, key) => {
+ if (state.lastAt < at - READER_ENDPOINT_429_STATE_TTL) endpointRateLimitCircuits.delete(key);
+ });
+ if (endpointRateLimitCircuits.size > READER_ENDPOINT_429_MAX_ENTRIES) {
+ [...endpointRateLimitCircuits.entries()]
+ .sort(([, a], [, b]) => a.lastAt - b.lastAt)
+ .slice(0, endpointRateLimitCircuits.size - READER_ENDPOINT_429_MAX_ENTRIES)
+ .forEach(([key]) => endpointRateLimitCircuits.delete(key));
+ }
+ };
+
+ const endpointRateLimitState = (fingerprint, at = Date.now()) => {
+ pruneEndpointRateLimitState(at);
+ const state = endpointRateLimitCircuits.get(fingerprint);
+ return state && state.blockedUntil > at ? state : null;
+ };
+
+ const noteUnknownEndpointRateLimit = (task, response) => {
+ const at = Date.now();
+ pruneEndpointRateLimitState(at);
+ const corroboratedGlobal = unknownRateLimitEvidence.some((event) =>
+ event.route !== task.endpointRoute && event.at >= at - READER_ENDPOINT_429_EVIDENCE_WINDOW_MS);
+ unknownRateLimitEvidence.push({
+ at,
+ route: task.endpointRoute,
+ });
+ if (corroboratedGlobal) {
+ endpointRateLimitCircuits.clear();
+ return null;
+ }
+ const previous = endpointRateLimitCircuits.get(task.endpointFingerprint);
+ const strikes = previous && previous.lastAt >= at - READER_ENDPOINT_429_STATE_TTL
+ ? previous.strikes + 1
+ : 1;
+ const exponential = READER_ENDPOINT_429_BASE_BLOCK_MS * Math.pow(2, Math.min(6, strikes - 1));
+ const wait = Math.min(
+ READER_ENDPOINT_429_MAX_BLOCK_MS,
+ Math.max(retryAfterMs(response, strikes), exponential)
+ );
+ const state = { strikes, lastAt: at, blockedUntil: at + wait };
+ endpointRateLimitCircuits.set(task.endpointFingerprint, state);
+ return state;
+ };
+
+ const latencyBoundConcurrent = () => {
+ if (responseLatencySamples < 6 || !responseLatencyEwma) return requestMaxConcurrent;
+ const latencyBudget = Math.max(responseLatencyEwma * 1.15, responseLatencyPeak);
+ return Math.min(requestMaxConcurrent, Math.max(1, Math.ceil(latencyBudget / requestMinInterval)));
+ };
+
+ const effectiveMaxConcurrent = latencyBoundConcurrent;
+
+ const sortQueue = (at = Date.now()) => queue.sort((a, b) => {
+ const aAge = Math.min(2, Math.floor(Math.max(0, at - a.queuedAt) / 5000));
+ const bAge = Math.min(2, Math.floor(Math.max(0, at - b.queuedAt) / 5000));
+ return (a.priority - aAge) - (b.priority - bAge) || a.sequence - b.sequence;
+ });
+
+ const schedulePump = () => {
+ if (disposed || pumpTimer || sharedPermitPending || !queue.length || activeCount >= effectiveMaxConcurrent()) return;
+ pumpTimer = setTimeout(pump, 0);
+ };
+
+ const finishQueuedTask = (task, error) => {
+ if (!task.controlRecorded) {
+ task.controlRecorded = true;
+ const errorReason = error && error.name === 'AbortError' && error.message && error.message !== 'Aborted'
+ ? error.message
+ : '';
+ const controlReason = String(task.controlReason || errorReason || task.waitReason || (disposed ? 'context-close' : 'cancelled'));
+ const type = task.sharedType;
+ recordRequestFlowControl(task.url, {
+ queuedAt: task.queuedAt,
+ method: task.fetchOptions.method || 'GET',
+ priority: task.priority,
+ waitReason: task.waitReason || controlReason,
+ callSite: task.callSite,
+ type,
+ path: task.permitOnly ? `等待${REQUEST_FLOW_TYPE_LABELS[type] || '请求'}许可` : '',
+ attempt: task.attempt + 1,
+ controlReason,
+ });
+ }
+ tasksByKey.delete(task.key);
+ task.state = 'done';
+ task.reject(error);
+ };
+
+ const noteRateLimit = (response, attempt = 1, options = {}) => {
+ if (!response || response.status !== 429) return 0;
+ const wait = retryAfterMs(response, attempt);
+ const rateLimitWindow = options.rateLimitWindow || requestRateLimitWindow(response);
+ if (SHARED_REQUEST_COORDINATOR) {
+ SHARED_REQUEST_COORDINATOR.noteRateLimit({
+ wait,
+ window: rateLimitWindow.label,
+ shortBudget: shortWindowBudget,
+ longBudget: longWindowBudget,
+ });
+ }
+ schedulePump();
+ return wait;
+ };
+
+ const runTask = async (task) => {
+ const controller = new AbortController();
+ const externalSignal = task.signal;
+ const abort = () => controller.abort();
+ const startedAt = Date.now();
+ let timedOut = false;
+ const timeout = setTimeout(() => {
+ timedOut = true;
+ controller.abort();
+ }, POST_REQUEST_TIMEOUT);
+ task.state = 'active';
+ task.controller = controller;
+ activeCount++;
+ activeControllers.add(controller);
+ if (externalSignal) externalSignal.addEventListener('abort', abort, { once: true });
+ try {
+ if (task.permitOnly) {
+ tasksByKey.delete(task.key);
+ task.state = 'done';
+ task.keepSharedPermit = true;
+ const permitId = task.sharedPermitId;
+ task.resolve({
+ recoveryProbe: task.sharedRecoveryProbe === true,
+ startedAt: task.permittedAt || requestFlowNow(),
+ waitReason: task.waitReason,
+ release: () => {
+ if (!task.keepSharedPermit) return;
+ task.keepSharedPermit = false;
+ if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.finishPermit(permitId);
+ },
+ });
+ return;
+ }
+ const response = await trackedReaderFetch(task.url, {
+ ...task.fetchOptions,
+ signal: controller.signal,
+ [REQUEST_FLOW_FETCH_META]: {
+ source: 'reader',
+ priority: task.priority,
+ recoveryProbe: task.sharedRecoveryProbe === true,
+ queuedAt: task.queuedAt,
+ permittedAt: task.permittedAt,
+ waitReason: task.waitReason,
+ callSite: task.callSite,
+ attempt: task.attempt + 1,
+ type: task.sharedType,
+ },
+ });
+ if (response.status === 429) {
+ task.attempt++;
+ const rateLimitWindow = requestRateLimitWindow(response);
+ const rateLimitHeader = requestFlowHeader(
+ response.headers, REQUEST_FLOW_RESPONSE_HEADERS.serverLimit
+ );
+ const knownWindow = rateLimitWindow.known ||
+ /(?:^|;)\s*w\s*=\s*"?(?:10|60)\b/i.test(rateLimitHeader);
+ const endpointLimit = knownWindow
+ ? null
+ : noteUnknownEndpointRateLimit(task, response);
+ if (!endpointLimit) {
+ noteRateLimit(response, task.attempt, {
+ rateLimitWindow,
+ });
+ } else {
+ const error = requestStatusError('HTTP 429(端点冷却)', 429);
+ error.rateLimitScope = 'endpoint';
+ error.retryAt = endpointLimit.blockedUntil;
+ task.controlReason = 'endpoint-cooldown';
+ throw error;
+ }
+ if (task.attempt <= POST_REQUEST_MAX_429_RETRIES &&
+ task.priority <= POST_REQUEST_PRIORITY.visible) {
+ task.state = 'queued';
+ task.controller = null;
+ task.queuedAt = requestFlowNow();
+ task.permittedAt = 0;
+ task.waitReason = 'cooldown';
+ queue.push(task);
+ return;
+ }
+ }
+ if (!response.ok) {
+ throw requestStatusError('HTTP ' + response.status, response.status);
+ }
+ const responseLatency = Math.max(1, Date.now() - startedAt);
+ responseLatencyEwma = responseLatencyEwma
+ ? responseLatencyEwma * 0.8 + responseLatency * 0.2
+ : responseLatency;
+ responseLatencyPeak = Math.max(responseLatency, responseLatencyPeak * 0.9);
+ responseLatencySamples++;
+ endpointRateLimitCircuits.delete(task.endpointFingerprint);
+ const payload = await response.json();
+ tasksByKey.delete(task.key);
+ task.state = 'done';
+ task.resolve(payload);
+ } catch (error) {
+ tasksByKey.delete(task.key);
+ task.state = 'done';
+ if (timedOut && !(externalSignal?.aborted)) {
+ task.reject(requestTimeoutError());
+ } else {
+ task.reject(error);
+ }
+ } finally {
+ clearTimeout(timeout);
+ if (externalSignal) externalSignal.removeEventListener('abort', abort);
+ activeControllers.delete(controller);
+ task.controller = null;
+ if (!task.keepSharedPermit && task.sharedPermitId && SHARED_REQUEST_COORDINATOR) {
+ SHARED_REQUEST_COORDINATOR.finishPermit(task.sharedPermitId);
+ }
+ task.sharedPermitId = '';
+ activeCount = Math.max(0, activeCount - 1);
+ schedulePump();
+ }
+ };
+
+ function pump() {
+ if (pumpTimer) clearTimeout(pumpTimer);
+ pumpTimer = 0;
+ if (disposed || sharedPermitPending || !queue.length || activeCount >= effectiveMaxConcurrent()) return;
+ sortQueue();
+ while (queue.length) {
+ const task = queue.shift();
+ if (task.signal?.aborted) {
+ task.controlReason = 'signal';
+ finishQueuedTask(task, abortError());
+ continue;
+ }
+ task.state = 'shared-permit';
+ sharedPermitPending = true;
+ const permitController = new AbortController();
+ const abortPermit = () => permitController.abort();
+ pendingPermitControllers.add(permitController);
+ if (task.signal) task.signal.addEventListener('abort', abortPermit, { once: true });
+ const acquire = SHARED_REQUEST_COORDINATOR && task.countsTowardApi
+ ? SHARED_REQUEST_COORDINATOR.acquirePermit({
+ priority: task.priority,
+ signal: permitController.signal,
+ shortBudget: shortWindowBudget,
+ longBudget: longWindowBudget,
+ minInterval: requestMinInterval,
+ maxConcurrent: effectiveMaxConcurrent(),
+ type: task.sharedType,
+ })
+ : Promise.resolve({ startedAt: Date.now(), recoveryProbe: false });
+ acquire.then((permit) => {
+ task.sharedPermitId = String(permit.permitId || '');
+ if (disposed || permitController.signal.aborted) throw abortError();
+ task.sharedRecoveryProbe = permit.recoveryProbe === true;
+ const startedAt = Number(permit.startedAt) || Date.now();
+ task.permittedAt = startedAt;
+ task.waitReason = String(permit.waitReason || task.waitReason);
+ runTask(task);
+ }).catch((error) => {
+ if (error && error.name === 'AbortError' && error.message && error.message !== 'Aborted') {
+ task.controlReason = error.message;
+ }
+ if (task.sharedPermitId && SHARED_REQUEST_COORDINATOR) {
+ SHARED_REQUEST_COORDINATOR.finishPermit(task.sharedPermitId);
+ task.sharedPermitId = '';
+ }
+ if (tasksByKey.get(task.key) === task) finishQueuedTask(task, error);
+ }).finally(() => {
+ if (task.signal) task.signal.removeEventListener('abort', abortPermit);
+ pendingPermitControllers.delete(permitController);
+ sharedPermitPending = false;
+ schedulePump();
+ });
+ break;
+ }
+ schedulePump();
+ }
+
+ const requestJSON = (url, fetchOptions = {}, requestOptions = {}) => {
+ const key = String(requestOptions.key || url);
+ const priority = Number.isFinite(requestOptions.priority)
+ ? requestOptions.priority
+ : POST_REQUEST_PRIORITY.visible;
+ const existing = tasksByKey.get(key);
+ if (existing) {
+ if (priority < existing.priority && existing.state === 'queued') existing.priority = priority;
+ schedulePump();
+ return existing.promise;
+ }
+ const callSite = String(requestOptions.callSite || requestFlowCallSite());
+ const method = String(fetchOptions.method || 'GET').toUpperCase();
+ const { fingerprint: endpointFingerprint, route: endpointRoute } = endpointRateLimitIdentity(url, method);
+ const endpointLimit = endpointRateLimitState(endpointFingerprint);
+ let controlError = null;
+ let controlReason = '';
+ if (endpointLimit) {
+ controlError = requestStatusError('端点 429 冷却中', 429);
+ controlError.rateLimitScope = 'endpoint';
+ controlError.retryAt = endpointLimit.blockedUntil;
+ controlReason = 'endpoint-cooldown';
+ } else if (disposed) {
+ controlError = abortError('context-close');
+ controlReason = 'context-close';
+ } else if (queue.length >= READER_REQUEST_QUEUE_LIMIT && priority >= POST_REQUEST_PRIORITY.nested &&
+ requestOptions.sharedType !== 'read') {
+ controlError = abortError('queue-limit');
+ controlReason = 'queue-limit';
+ }
+ if (controlError) {
+ recordRequestFlowControl(url, {
+ method, priority, callSite,
+ type: requestOptions.sharedType,
+ waitReason: controlReason === 'endpoint-cooldown' ? controlReason : '',
+ controlReason,
+ });
+ return Promise.reject(controlError);
+ }
+ const { signal, ...options } = fetchOptions;
+ let resolveTask;
+ let rejectTask;
+ const promise = new Promise((resolve, reject) => {
+ resolveTask = resolve;
+ rejectTask = reject;
+ });
+ const task = {
+ key,
+ url,
+ fetchOptions: options,
+ signal,
+ priority,
+ sequence: sequence++,
+ queuedAt: requestFlowNow(),
+ permittedAt: 0,
+ waitReason: activeCount >= effectiveMaxConcurrent() ? 'concurrency' : queue.length ? 'priority' : '',
+ callSite,
+ attempt: 0,
+ state: 'queued',
+ controller: null,
+ sharedRecoveryProbe: false,
+ sharedPermitId: '',
+ keepSharedPermit: false,
+ controlRecorded: false,
+ controlReason: '',
+ resolve: resolveTask,
+ reject: rejectTask,
+ promise,
+ permitOnly: requestOptions.permitOnly === true,
+ countsTowardApi: requestOptions.countsTowardApi !== false,
+ sharedType: String(requestOptions.sharedType ||
+ requestFlowType(requestFlowUrl(url), 'fetch', options.method || 'GET')),
+ endpointFingerprint,
+ endpointRoute,
+ };
+ tasksByKey.set(key, task);
+ queue.push(task);
+ schedulePump();
+ return promise;
+ };
+
+ const waitForPermit = (priority = POST_REQUEST_PRIORITY.visible, options = {}) => requestJSON('', {
+ signal: options.signal,
+ }, {
+ key: `external-permit:${sequence}:${Date.now()}`,
+ priority,
+ permitOnly: true,
+ countsTowardApi: options.countsTowardApi !== false,
+ sharedType: options.type,
+ });
+
+ const trackExternalController = (controller) => {
+ if (!controller || !isFunction(controller.abort)) return () => {};
+ if (disposed) {
+ controller.abort();
+ return () => {};
+ }
+ externalControllers.add(controller);
+ return () => externalControllers.delete(controller);
+ };
+
+ const cancelQueued = (key) => {
+ const task = tasksByKey.get(String(key));
+ if (!task || task.state !== 'queued') return false;
+ const index = queue.indexOf(task);
+ if (index >= 0) queue.splice(index, 1);
+ task.controlReason = 'cancelled';
+ finishQueuedTask(task, abortError('cancelled'));
+ return true;
+ };
+
+ const resetRateLimits = () => {
+ endpointRateLimitCircuits.clear();
+ unknownRateLimitEvidence.length = 0;
+ schedulePump();
+ return SHARED_REQUEST_COORDINATOR?.resetRateLimits
+ ? SHARED_REQUEST_COORDINATOR.resetRateLimits()
+ : Promise.resolve(true);
+ };
+
+ const destroy = () => {
+ if (disposed) return;
+ disposed = true;
+ if (pumpTimer) clearTimeout(pumpTimer);
+ pumpTimer = 0;
+ queue.splice(0).forEach((task) => {
+ task.controlReason = 'context-close';
+ finishQueuedTask(task, abortError('context-close'));
+ });
+ pendingPermitControllers.forEach((controller) => controller.abort());
+ pendingPermitControllers.clear();
+ activeControllers.forEach((controller) => controller.abort());
+ activeControllers.clear();
+ externalControllers.forEach((controller) => controller.abort());
+ externalControllers.clear();
+ endpointRateLimitCircuits.clear();
+ unknownRateLimitEvidence.length = 0;
+ };
+
+ const snapshot = () => {
+ const at = Date.now();
+ pruneEndpointRateLimitState(at);
+ const endpointBlocks = [...endpointRateLimitCircuits.values()]
+ .filter((state) => state.blockedUntil > at);
+ sortQueue(at);
+ const shared = SHARED_REQUEST_COORDINATOR?.snapshot(
+ shortWindowBudget,
+ longWindowBudget,
+ requestMinInterval,
+ effectiveMaxConcurrent(),
+ at
+ );
+ return {
+ active: activeCount,
+ queued: queue.length + (sharedPermitPending ? 1 : 0),
+ globalQueued: shared ? shared.queued : queue.length,
+ globalActive: shared ? shared.active : activeCount,
+ globalInstances: shared ? shared.instances : 1,
+ globalMaxConcurrent: shared ? shared.maxConcurrent : effectiveMaxConcurrent(),
+ globalMinInterval: shared ? shared.minInterval : requestMinInterval,
+ coordinationMode: shared ? shared.coordinationMode : 'local',
+ maxConcurrent: effectiveMaxConcurrent(),
+ minInterval: requestMinInterval,
+ configuredMinInterval: requestMinInterval,
+ requestRateTarget: Math.round(requestRateTarget * 100),
+ configuredShortWindowBudget: shortWindowBudget,
+ configuredLongWindowBudget: longWindowBudget,
+ shortWindowBudget: shared ? shared.shortBudget : shortWindowBudget,
+ longWindowBudget: shared ? shared.longBudget : longWindowBudget,
+ shortWindowCount: shared ? shared.shortCount : 0,
+ longWindowCount: shared ? shared.longCount : 0,
+ blockingReason: shared ? shared.blockingReason : '',
+ nextPermitDelay: shared ? shared.nextPermitDelay : 0,
+ coolingDown: !!(shared?.coolingDown),
+ cooldownRemaining: shared ? shared.cooldownRemaining : 0,
+ endpointCoolingDown: endpointBlocks.length > 0,
+ endpointCooldownCount: endpointBlocks.length,
+ endpointCooldownRemaining: endpointBlocks.reduce(
+ (maximum, state) => Math.max(maximum, state.blockedUntil - at),
+ 0
+ ),
+ learnedRateLimit: !!shared && (shared.rateLimited ||
+ shared.shortBudget < shortWindowBudget || shared.longBudget < longWindowBudget),
+ lastRateLimitWindow: shared ? shared.lastRateLimitWindow : '',
+ };
+ };
+
+ return {
+ requestJSON,
+ waitForPermit,
+ trackExternalController,
+ cancelQueued,
+ destroy,
+ noteRateLimit,
+ resetRateLimits,
+ snapshot,
+ };
+ }
+
+ async function apiSend(url, method, params, extraHeaders, requestOptions = {}) {
+ const priority = Number.isFinite(requestOptions.priority)
+ ? requestOptions.priority
+ : POST_REQUEST_PRIORITY.visible;
+ const requestType = requestFlowType(requestFlowUrl(url), 'fetch', method);
+ const requestScope = createRequestAbortScope();
+ const initialScheduler = requestOptions.surviveReaderSwitch === true ? null : ACTIVE_READER_REQUEST_SCHEDULER;
+ const untrackInitialController = isFunction(initialScheduler?.trackExternalController)
+ ? initialScheduler.trackExternalController(requestScope.controller)
+ : () => {};
+ let requestPermit = null;
+ let sharedPermit = null;
+ try {
+ const opt = {
+ method,
+ credentials: 'include',
+ headers: Object.assign({
+ 'Accept': 'application/json',
+ 'X-CSRF-Token': await discourseCsrfToken(),
+ 'X-Requested-With': 'XMLHttpRequest',
+ }, extraHeaders || {}),
+ };
+ if (requestScope.signal.aborted) throw new DOMException('Aborted', 'AbortError');
+ if (params) {
+ if (requestOptions.jsonBody === true) {
+ opt.headers['Content-Type'] = 'application/json; charset=UTF-8';
+ opt.body = JSON.stringify(params);
+ } else {
+ opt.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
+ const body = new URLSearchParams();
+ Object.entries(params).forEach(([key, value]) => {
+ if (Array.isArray(value)) value.forEach((item) => body.append(`${key}[]`, item));
+ else if (value != null) body.append(key, value);
+ });
+ opt.body = body.toString();
+ }
+ }
+ requestPermit = await waitForReaderRequestPermit(priority, {
+ type: requestType,
+ signal: requestScope.signal,
+ controller: requestScope.controller,
+ trackedScheduler: initialScheduler,
+ surviveReaderSwitch: requestOptions.surviveReaderSwitch === true,
+ });
+ if (!requestPermit.scheduler) {
+ sharedPermit = await acquireSharedReaderRequestPermit(
+ priority, requestScope.signal, requestType, ACTIVE_READER_REQUEST_SCHEDULER);
+ }
+ requestScope.startTimeout();
+ const res = await trackedReaderFetch(url, {
+ ...opt,
+ signal: requestScope.signal,
+ [REQUEST_FLOW_FETCH_META]: {
+ source: 'reader',
+ priority,
+ recoveryProbe: !!(
+ requestPermit.recoveryProbe ||
+ (sharedPermit?.recoveryProbe)
+ ),
+ queuedAt: requestPermit.queuedAt,
+ permittedAt: requestPermit.permittedAt ||
+ sharedPermit?.startedAt || requestFlowNow(),
+ waitReason: requestPermit.waitReason ||
+ sharedPermit?.waitReason || '',
+ callSite: requestPermit.callSite,
+ attempt: 1,
+ type: requestType,
+ },
+ });
+ if (res.status === 429) {
+ const rateScheduler = ACTIVE_READER_REQUEST_SCHEDULER || requestPermit.scheduler;
+ if (rateScheduler) {
+ rateScheduler.noteRateLimit(res);
+ } else noteSharedRequestRateLimit(res);
+ }
+ let data = {};
+ try {
+ data = await res.json();
+ } catch (error) {
+ if (requestScope.signal.aborted) throw error;
+ }
+ requestScope.stopTimeout();
+ if (!res.ok) {
+ const message = Array.isArray(data.errors) && data.errors.length
+ ? data.errors.join(';')
+ : (data.error || data.message || `HTTP ${res.status}`);
+ const statusError = requestStatusError(message, res.status);
+ statusError.cloudflareMitigated = res.status === 403 &&
+ requestFlowHeader(res.headers, ['cf-mitigated']).toLowerCase() === 'challenge';
+ throw statusError;
+ }
+ const mutationTags = [...new Set([
+ ...responseCacheMutationTags(url, params, data),
+ ...(Array.isArray(requestOptions.invalidateTags) ? requestOptions.invalidateTags : []),
+ ])];
+ if (mutationTags.length) await clearStoredResponseCache({ tags: mutationTags });
+ return data;
+ } catch (error) {
+ if (requestScope.timedOut()) throw requestTimeoutError();
+ throw error;
+ } finally {
+ if (isFunction(requestPermit?.release)) requestPermit.release();
+ if (sharedPermit && SHARED_REQUEST_COORDINATOR) {
+ SHARED_REQUEST_COORDINATOR.finishPermit(sharedPermit.permitId);
+ }
+ untrackInitialController();
+ requestScope.cleanup();
+ }
+ }
+
+ function runReaderActionOnce(ctx, key, operation) {
+ if (!ctx || !key) return Promise.resolve().then(operation);
+ if (!ctx.readerActionRequests) ctx.readerActionRequests = new Map();
+ const normalizedKey = String(key);
+ const existing = ctx.readerActionRequests.get(normalizedKey);
+ if (existing) return existing;
+ const request = Promise.resolve().then(operation).finally(() => {
+ if (ctx.readerActionRequests && ctx.readerActionRequests.get(normalizedKey) === request)
+ ctx.readerActionRequests.delete(normalizedKey);
+ });
+ ctx.readerActionRequests.set(normalizedKey, request);
+ return request;
+ }
+
+ function setReaderControlBusy(control, busy) {
+ if (!control) return;
+ if (busy) {
+ if (control.dataset.ldpRequestBusy !== '1') {
+ control.dataset.ldpRequestWasDisabled = control.disabled ? '1' : '0';
+ control.dataset.ldpRequestBusy = '1';
+ }
+ control.disabled = true;
+ return;
+ }
+ if (control.dataset.ldpRequestBusy !== '1') return;
+ control.disabled = control.dataset.ldpRequestWasDisabled === '1';
+ delete control.dataset.ldpRequestBusy;
+ delete control.dataset.ldpRequestWasDisabled;
+ }
+
+ function pollName(poll) {
+ return String(poll?.name || 'poll');
+ }
+
+ function pollSavedVotes(postData, poll) {
+ const votes = postData?.polls_votes && postData.polls_votes[pollName(poll)];
+ return Array.isArray(votes) ? votes.map(String) : [];
+ }
+
+ function pollIsClosed(poll, ctx) {
+ if (!poll) return true;
+ if (poll.status === 'closed' || (ctx?.topicData && ctx.topicData.archived)) return true;
+ if (!poll.close) return false;
+ const closeAt = Date.parse(poll.close);
+ return Number.isFinite(closeAt) && closeAt <= Date.now();
+ }
+
+ function currentUserCanVoteInPoll(poll) {
+ if (!ME_USERNAME) return false;
+ const required = String((poll?.groups) || '').split(',').map((name) => name.trim().toLowerCase()).filter(Boolean);
+ if (!required.length) return true;
+ const groups = Array.isArray(ME_CURRENT_USER?.groups)
+ ? ME_CURRENT_USER.groups.map((group) => String((group && (group.name || group.full_name)) || '').toLowerCase())
+ : [];
+ return required.some((name) => groups.includes(name));
+ }
+
+ function pollCanShowResults(postData, poll, ctx) {
+ if (!poll || !Array.isArray(poll.options) || !poll.options.some((option) => option && option.votes != null)) return false;
+ const closed = pollIsClosed(poll, ctx);
+ const resultRule = String(poll.results || 'always');
+ if (resultRule === 'on_close' && !closed) return false;
+ if (resultRule === 'staff_only' && !(ME_CURRENT_USER?.staff)) return false;
+ if (resultRule === 'on_vote' && !pollSavedVotes(postData, poll).length) {
+ const currentUserId = +(ME_CURRENT_USER?.id || 0);
+ if (!currentUserId || +postData.user_id !== currentUserId) return false;
+ }
+ return true;
+ }
+
+ function pollChoiceLimits(poll) {
+ const optionCount = Array.isArray(poll?.options) ? poll.options.length : 0;
+ const min = Math.max(1, Number.parseInt(poll?.min, 10) || 1);
+ const parsedMax = Number.parseInt(poll?.max, 10);
+ const max = Math.max(min, Math.min(optionCount, Number.isFinite(parsedMax) ? parsedMax : optionCount));
+ return { min, max };
+ }
+
+ function pollOptionHtml(option) {
+ return String(option?.html || '');
+ }
+
+ function renderPollChoices(postData, poll, ctx) {
+ const name = pollName(poll);
+ const saved = new Set(pollSavedVotes(postData, poll));
+ const multiple = poll.type === 'multiple';
+ const closed = pollIsClosed(poll, ctx);
+ const canVote = !closed && currentUserCanVoteInPoll(poll) && poll.type !== 'ranked_choice';
+ const inputType = multiple ? 'checkbox' : 'radio';
+ const options = (poll.options || []).map((option, index) => {
+ const id = String(option.id || '');
+ return `
+
+ ${pollOptionHtml(option) || `选项 ${index + 1}`}
+ `;
+ }).join('');
+ return `
${options}
`;
+ }
+
+ function renderPollResults(poll) {
+ const voters = Math.max(0, Number(poll.voters) || 0);
+ const options = (poll.options || []).map((option) => {
+ const votes = Math.max(0, Number(option.votes) || 0);
+ const percent = voters > 0 ? Math.round(votes / voters * 100) : 0;
+ return `
+
${pollOptionHtml(option)}
+
${votes} 票 · ${percent}%
+
+
`;
+ }).join('');
+ return `
${options}
`;
+ }
+
+ function renderReaderPoll(container, postData, poll, ctx, showResultsOverride) {
+ const name = pollName(poll);
+ const existingTitle = container.querySelector('.poll-title, .ldp-poll-title');
+ const title = existingTitle
+ ? `
${existingTitle.innerHTML}
`
+ : poll?.title ? `
${esc(poll.title)}
` : '';
+ const saved = pollSavedVotes(postData, poll);
+ const closed = pollIsClosed(poll, ctx);
+ const canShowResults = pollCanShowResults(postData, poll, ctx);
+ const canVote = !closed && currentUserCanVoteInPoll(poll) && poll.type !== 'ranked_choice';
+ const previousMode = container.dataset.ldpPollShowResults;
+ let showResults = showResultsOverride == null
+ ? (previousMode === '1' || (previousMode === undefined && (saved.length > 0 || closed)))
+ : !!showResultsOverride;
+ if (!canShowResults) showResults = false;
+ container.classList.add('ldp-reader-poll');
+ container.dataset.ldpPollName = name;
+ container.dataset.ldpPollShowResults = showResults ? '1' : '0';
+ container.removeAttribute('aria-busy');
+
+ let note = '';
+ if (poll.type === 'ranked_choice') note = '排序投票请在原页面参与。';
+ else if (closed) note = '投票已结束。';
+ else if (!ME_USERNAME) note = '登录后可参与投票。';
+ else if (!currentUserCanVoteInPoll(poll)) note = '你不在该投票允许参与的用户组中。';
+
+ const { min, max } = pollChoiceLimits(poll);
+ const multipleHint = poll.type === 'multiple' ? ` · 可选 ${min}${min === max ? '' : `–${max}`} 项` : '';
+ const footerButtons = [];
+ if (!showResults && poll.type === 'multiple' && canVote) {
+ const valid = saved.length >= min && saved.length <= max;
+ footerButtons.push(`
`);
+ }
+ if (!showResults && saved.length && canVote)
+ footerButtons.push('
');
+ if (canShowResults && (!showResults || canVote))
+ footerButtons.push(`
`);
+ const votersLabel = `${Math.max(0, Number(poll.voters) || 0)} 位投票人${multipleHint}`;
+ container.innerHTML = `${title}
+ ${showResults ? renderPollResults(poll) : renderPollChoices(postData, poll, ctx)}
+ ${note ? `
${esc(note)}
` : ''}
+ `;
+ }
+
+ function renderPostPolls(postNode, ctx, mode) {
+ if (!postNode) return;
+ const postData = postNode._ldpPostData || {};
+ const polls = Array.isArray(postData.polls) ? postData.polls : [];
+ if (!polls.length) return;
+ const content = postNode.querySelector(':scope > .ldp-content');
+ if (!content) return;
+ const containers = [...content.querySelectorAll('.poll')];
+ const used = new Set();
+ polls.forEach((poll) => {
+ const name = pollName(poll);
+ let container = containers.find((item) => !used.has(item) &&
+ String(item.dataset.ldpPollName || item.dataset.pollName || 'poll') === name);
+ if (!container) container = containers.find((item) => !used.has(item)) || null;
+ if (!container) {
+ container = makeElement('div');
+ container.className = 'poll';
+ content.appendChild(container);
+ }
+ used.add(container);
+ const showResults = mode && mode.name === name ? mode.showResults : null;
+ renderReaderPoll(container, postData, poll, ctx, showResults);
+ });
+ }
+
+ function readerPostCopies(postId, ctx, includeDetachedNested = false) {
+ const copies = new Set();
+ const key = String(postId);
+ if (ctx?.modal)
+ ctx.modal.querySelectorAll('.ldp-post').forEach((node) => {
+ if (String(node.dataset.postId || '') === key) copies.add(node);
+ });
+ if (ctx?.streamNodeMap)
+ ctx.streamNodeMap.forEach((node) => {
+ if (!node) return;
+ if (String(node.dataset.postId || '') === key) copies.add(node);
+ if (!includeDetachedNested || !node.querySelectorAll) return;
+ node.querySelectorAll('.ldp-post').forEach((nested) => {
+ if (String(nested.dataset.postId || '') === key) copies.add(nested);
+ });
+ });
+ if (includeDetachedNested && ctx?.subReplyState)
+ ctx.subReplyState.forEach((state) => state?.nodeCache?.forEach((node) => {
+ if (String(node?.dataset.postId || '') === key) copies.add(node);
+ }));
+ const railPost = ctx?.topicActionRail?._ldpRelatedPost; if (railPost && String(railPost.dataset.postId || '') === key) copies.add(railPost);
+ return [...copies];
+ }
+
+ function readerPostOwnControls(postNode, selector) {
+ return [...postNode.querySelectorAll(selector)].filter((control) =>
+ !postNode.classList.contains('ldp-post') || control.closest('.ldp-post') === postNode
+ );
+ }
+
+ function persistReaderPostState(ctx, postData) {
+ const postId = +(postData?.id || 0);
+ if (!ctx || !ctx.topicId || !postId) return;
+ const entry = TOPIC_DATA_CACHE.get(String(ctx.topicId));
+ const topic = entry?.topic || ctx.topicData;
+ const cachedPost = postsFromPayload(topic).find((post) => +post.id === postId);
+ if (cachedPost) {
+ Object.assign(cachedPost, postData);
+ setCachedTopicData(ctx.topicId, topic);
+ return;
+ }
+ if (isFunction(ctx.loader?.persistFetchedPosts))
+ ctx.loader.persistFetchedPosts([postData]);
+ }
+
+ function syncPostVotingComments(postId, ctx, comments) {
+ const normalized = Array.isArray(comments) ? comments : [];
+ let latestPostData = null;
+ readerPostCopies(postId, ctx, true).forEach((copy) => {
+ const postData = copy._ldpPostData || {};
+ postData._ldpPvComments = normalized;
+ postData.comments_count = Math.max(+postData.comments_count || 0, normalized.length);
+ copy._ldpPostData = postData;
+ latestPostData = postData;
+ renderPostVoting(copy, ctx);
+ });
+ if (latestPostData) persistReaderPostState(ctx, latestPostData);
+ }
+
+ function setReaderPostControlsBusy(postId, ctx, selector, busy, sourcePost = null) {
+ new Set([sourcePost, ...readerPostCopies(postId, ctx)].filter(Boolean)).forEach((copy) => {
+ readerPostOwnControls(copy, selector).forEach((control) => setReaderControlBusy(control, busy));
+ });
+ }
+
+ function updatePostLikeAction(postData, acted, count) {
+ if (!Array.isArray(postData.actions_summary)) postData.actions_summary = [];
+ let action = postData.actions_summary.find((item) => +item.id === 2);
+ if (!action) {
+ action = { id: 2, can_act: true };
+ postData.actions_summary.push(action);
+ }
+ action.acted = !!acted;
+ action.count = Math.max(0, +count || 0);
+ return action;
+ }
+
+ function readerPostLikeAction(post, ctx) {
+ const like = (post?.actions_summary || []).find((action) => +action.id === 2) || {};
+ if (!hasDiscourseCapability('reactions', { post, topic: ctx?.topicData }) ||
+ !Array.isArray(post?.reactions)) return like;
+ const heart = post.reactions.find((reaction) => String(reaction?.id || '') === 'heart');
+ const heartActed = String(post.current_user_reaction?.id || '') === 'heart';
+ return {
+ ...like,
+ can_act: !!ME_USERNAME,
+ count: Math.max(0, +heart?.count || 0),
+ acted: heartActed,
+ reaction: 'heart',
+ label: heartActed ? '取消爱心回应' : '用爱心回应',
+ };
+ }
+
+ function readerPostRailReactionAction(post, ctx) {
+ const action = readerPostLikeAction(post, ctx);
+ if (!action.reaction) return action;
+ const current = String(post?.current_user_reaction?.id || '');
+ const totalCount = post.reactions.reduce((total, reaction) =>
+ total + Math.max(0, +reaction?.count || 0), 0
+ );
+ return {
+ ...action,
+ count: totalCount,
+ acted: !!current,
+ label: current === 'heart'
+ ? '取消爱心回应'
+ : current ? '改用爱心回应' : '用爱心回应',
+ };
+ }
+
+ function syncReaderLikeButton(button, action) {
+ button.classList.toggle('liked', !!action.acted);
+ button.dataset.acted = action.acted ? '1' : '0';
+ if (action.reaction) button.dataset.reaction = action.reaction;
+ else delete button.dataset.reaction;
+ setLabel(button, action.label || (action.acted ? '取消点赞' : '点赞'));
+ const countEl = button.querySelector('.ldp-like-count');
+ if (countEl) countEl.textContent = String(action.count);
+ }
+
+ function syncReaderPostLike(postId, ctx, acted, count) {
+ let latestPostData = null;
+ readerPostCopies(postId, ctx, true).forEach((copy) => {
+ const postData = copy._ldpPostData || {};
+ const action = updatePostLikeAction(postData, acted, count);
+ copy._ldpPostData = postData;
+ latestPostData = postData;
+ readerPostOwnControls(copy, '.ldp-like').forEach((button) => syncReaderLikeButton(button, action));
+ });
+ const railLike = ctx?.topicActionRail?.querySelector('.ldp-like'); if (railLike && latestPostData) syncReaderLikeButton(railLike, readerPostRailReactionAction(latestPostData, ctx));
+ if (latestPostData) persistReaderPostState(ctx, latestPostData);
+ }
+
+ async function toggleReaderPostLike(postId, ctx, button, options) {
+ const { setBusy, beforeSync, postData, persist, onError } = options;
+ const countNode = button?.querySelector('.ldp-like-count');
+ const acted = !!button && button.dataset.acted === '1';
+ const count = Math.max(0, +(countNode?.textContent || 0));
+ setBusy(true);
+ try {
+ const sourcePost = button?.closest('.ldp-post');
+ const postModel = detachedNativePostModel(sourcePost, ctx);
+ const likeAction = discourseModelValue(postModel, 'likeAction');
+ if (!postModel || Number(discourseModelValue(postModel, 'id')) !== Number(postId) ||
+ !likeAction || !isFunction(likeAction.togglePromise))
+ throw new Error('Discourse 点赞接口尚未就绪');
+ const result = await likeAction.togglePromise(postModel);
+ if (!result || typeof result.acted !== 'boolean')
+ throw new Error('Discourse 点赞结果缺失');
+ const currentLikeAction = discourseModelValue(postModel, 'likeAction') || likeAction;
+ const nextActed = result.acted;
+ const nativeCount = Number(discourseModelValue(currentLikeAction, 'count'));
+ const nextCount = Math.max(
+ 0,
+ Number.isFinite(nativeCount) ? nativeCount : count + (acted ? -1 : 1),
+ );
+ await clearStoredResponseCache({ tags: [`post:${postId}`, 'reactions-given'] });
+ if (beforeSync) beforeSync(nextActed, nextCount);
+ syncReaderPostLike(postId, ctx, nextActed, nextCount);
+ if (persist) persistReaderPostState(ctx, postData);
+ if (isFunction(ctx.refreshBookmarksPanel))
+ ctx.refreshBookmarksPanel({ post: postData, topic: ctx.topicData, likeActed: nextActed });
+ } catch (error) {
+ onError(error);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ function syncReaderPollResponse(sourcePost, ctx, name, serverPoll, votes, showResults) {
+ let latestPostData = null;
+ readerPostCopies(sourcePost.dataset.postId, ctx).forEach((node) => {
+ const postData = node._ldpPostData || {};
+ const polls = Array.isArray(postData.polls) ? postData.polls : [];
+ const current = polls.find((poll) => pollName(poll) === name);
+ if (current && serverPoll) Object.assign(current, serverPoll);
+ if (!postData.polls_votes) postData.polls_votes = {};
+ if (votes == null) delete postData.polls_votes[name];
+ else postData.polls_votes[name] = votes.map(String);
+ node._ldpPostData = postData;
+ latestPostData = postData;
+ rememberPostData(ctx, postData);
+ renderPostPolls(node, ctx, { name, showResults });
+ });
+ if (latestPostData) persistReaderPostState(ctx, latestPostData);
+ }
+
+ function setReaderPollBusy(post, ctx, name, busy) {
+ const copies = readerPostCopies(post && post.dataset.postId, ctx);
+ (copies.length ? copies : [post]).filter(Boolean).forEach((copy) => {
+ readerPostOwnControls(copy, '.ldp-reader-poll').forEach((container) => {
+ if (container.dataset.ldpPollName !== name) return;
+ if (busy) container.setAttribute('aria-busy', 'true');
+ else container.removeAttribute('aria-busy');
+ container.querySelectorAll('input,button').forEach((control) => setReaderControlBusy(control, busy));
+ });
+ });
+ }
+
+ async function updateReaderPollVote(post, ctx, name, options = null) {
+ const removing = options == null;
+ return runReaderActionOnce(ctx, `post:${post.dataset.postId}:poll:${name}`, async () => {
+ setReaderPollBusy(post, ctx, name, true);
+ try {
+ const params = {
+ post_id: post.dataset.postId,
+ poll_name: name,
+ ...(removing ? {} : { options }),
+ };
+ const data = await apiSend(`${BASE}/polls/vote`, removing ? 'DELETE' : 'PUT', params);
+ syncReaderPollResponse(post, ctx, name, data.poll, removing ? null : options, !removing);
+ } catch (error) {
+ readerPostCopies(post.dataset.postId, ctx).forEach((copy) => renderPostPolls(copy, ctx));
+ alert(`${removing ? '撤销投票' : '投票'}失败:${error.message}`);
+ } finally {
+ setReaderPollBusy(post, ctx, name, false);
+ }
+ });
+ }
+
+ let reactionEmojiUrls = null;
+ let reactionEmojiImageSources = null;
+ let reactionEmojiHydration = null;
+ let reactionEmojiHydrationRefresh = false;
+ let reactionEmojiRegistryNetworkRequested = false;
+
+ function loadReactionEmojiRegistry(options = {}) {
+ const refresh = options.refresh === true;
+ if (!refresh && reactionEmojiUrls) return Promise.resolve(reactionEmojiUrls);
+ if (reactionEmojiHydration) {
+ if (!refresh || reactionEmojiHydrationRefresh) return reactionEmojiHydration;
+ return reactionEmojiHydration.then(() => loadReactionEmojiRegistry({ refresh: true }));
+ }
+ const emojiStore = lookupDiscourse('service:emoji-store');
+ const textModule = lookupDiscourseModule('discourse/lib/text');
+ const previousUrls = reactionEmojiUrls;
+ const previousSources = reactionEmojiImageSources;
+ const requestLatestEmojis = () => {
+ reactionEmojiRegistryNetworkRequested = true;
+ return fetchJSON(`${BASE}/emojis.json`, {
+ key: 'reaction-emoji-registry',
+ priority: POST_REQUEST_PRIORITY.auxiliary,
+ cacheMode: 'refresh',
+ allowStaleOnError: false,
+ fetchOptions: discourseJSONFetchOptions(),
+ }).then((data) => {
+ if (emojiStore) emojiStore.list = data;
+ return data;
+ });
+ };
+ const loadEmojis = !refresh && emojiStore?.list
+ ? Promise.resolve(emojiStore.list)
+ : isFunction(window.fetch)
+ ? requestLatestEmojis()
+ : emojiStore?.list
+ ? Promise.resolve(emojiStore.list)
+ : Promise.reject(new Error('Discourse 表情服务尚未就绪'));
+ reactionEmojiHydrationRefresh = refresh;
+ reactionEmojiHydration = loadEmojis.then((data) => {
+ const registry = new Map();
+ Object.values(data || {}).forEach((items) => {
+ if (!Array.isArray(items)) return;
+ items.forEach((item) => {
+ const name = String(item?.name || '');
+ const url = String(item?.url ||
+ (name && textModule && isFunction(textModule.emojiUrlFor)
+ ? textModule.emojiUrlFor(name)
+ : '') || '');
+ if (name && url) registry.set(name, url);
+ });
+ });
+ reactionEmojiUrls = registry;
+ reactionEmojiImageSources = new Set([...registry.values()]
+ .map(comparableLightboxImageSource)
+ .filter(Boolean));
+ return registry;
+ }).catch(() => {
+ reactionEmojiUrls = previousUrls || new Map();
+ reactionEmojiImageSources = previousSources || new Set();
+ return reactionEmojiUrls;
+ }).finally(() => {
+ reactionEmojiHydration = null;
+ reactionEmojiHydrationRefresh = false;
+ });
+ return reactionEmojiHydration;
+ }
+
+ function configuredDiscourseReactionIds(topicData, reactions) {
+ const siteSettings = lookupDiscourse('service:site-settings');
+ const configuredValue = siteSettings?.discourse_reactions_enabled_reactions;
+ const configured = Array.isArray(configuredValue)
+ ? configuredValue
+ : String(configuredValue || '').split('|');
+ const mainReaction = String(siteSettings?.discourse_reactions_reaction_for_like || '');
+ const topicReactions = Array.isArray(topicData?.valid_reactions)
+ ? topicData.valid_reactions
+ : [];
+ const source = configured.some(Boolean)
+ ? configured
+ : topicReactions.length
+ ? topicReactions
+ : (reactions || []).map((item) => item?.id);
+ const ids = source.map((item) => String(
+ typeof item === 'string' ? item : (item && (item.id || item.name)) || ''
+ )).filter(Boolean);
+ if (mainReaction && !ids.includes(mainReaction)) ids.unshift(mainReaction);
+ return [...new Set(ids)];
+ }
+
+ function reactionLabel(id) {
+ const key = String(id || '').replace(/^:|:$/g, '');
+ if (!key) return '';
+ const textModule = lookupDiscourseModule('discourse/lib/text');
+ const nativeSource = isFunction(textModule?.emojiUrlFor)
+ ? textModule.emojiUrlFor(key)
+ : '';
+ const source = reactionEmojiUrls?.get(key) || nativeSource;
+ if (source) {
+ const url = absoluteImageUrl(source);
+ if (url)
+ return `
})
`;
+ }
+ return '';
+ }
+
+ function renderPostReactions(postNode, ctx, options = {}) {
+ if (!postNode) return;
+ const post = postNode._ldpPostData || {};
+ let root = postNode.querySelector(':scope > .ldp-reactions');
+ const actions = root?.querySelector(':scope > .ldp-actions') || postNode.querySelector(':scope > .ldp-actions');
+ const likeButton = root?.querySelector('.ldp-like') || actions?.querySelector('.ldp-like');
+ let likeAnchor = likeButton?.closest('.ldp-reaction-like-picker');
+ const collectLike = options.collectLike !== false;
+ const railPicker = options.railPicker === true;
+ const unwrapLikePicker = () => {
+ if (likeAnchor) {
+ likeAnchor.replaceWith(likeButton);
+ likeAnchor = null;
+ }
+ if (likeButton && actions && likeButton.parentElement !== actions) actions.prepend(likeButton);
+ if (likeButton) {
+ delete likeButton.dataset.reactionPicker;
+ likeButton.removeAttribute('aria-expanded');
+ }
+ };
+ if (!hasDiscourseCapability('reactions', { post, topic: ctx?.topicData })) {
+ unwrapLikePicker();
+ root?.querySelector(':scope > .ldp-reaction-summary')?.remove();
+ postNode.classList.remove('ldp-has-reactions');
+ return;
+ }
+ const reactions = Array.isArray(post.reactions) ? post.reactions : [];
+ const valid = configuredDiscourseReactionIds(ctx?.topicData, reactions);
+ const renderedReactions = reactions.map((item) => ({ item, label: reactionLabel(item.id) }))
+ .filter((entry) => entry.label);
+ const renderedValid = valid.map((id) => ({ id, label: reactionLabel(id) }))
+ .filter((entry) => entry.label);
+ const canAdd = !!ME_USERNAME && renderedValid.length > 0;
+ const usesLikeDisplay = !!likeButton;
+ const usesLikeTrigger = !!(canAdd && likeButton);
+ const usesFallbackTrigger = !!(canAdd && !likeButton);
+ if (!root) {
+ root = makeElement('div');
+ root.className = 'ldp-reactions';
+ const content = postNode.querySelector(':scope > .ldp-content,:scope > .ldp-pv-layout .ldp-content');
+ content?.after(root);
+ }
+ if (!renderedReactions.length && !canAdd) {
+ unwrapLikePicker();
+ root.querySelector(':scope > .ldp-reaction-summary')?.remove();
+ if (actions && actions.parentElement !== root) root.appendChild(actions);
+ postNode.classList.remove('ldp-has-reactions');
+ return;
+ }
+ postNode.classList.add('ldp-has-reactions');
+ const current = String(post.current_user_reaction?.id || '');
+ const reactionCounts = new Map(reactions.map((item) => [String(item?.id || ''), Math.max(0, +item?.count || 0)]));
+ const pickerEntries = renderedValid.map((entry, order) => ({ ...entry, order }));
+ if (railPicker) pickerEntries.sort((left, right) =>
+ (reactionCounts.get(String(right.id)) || 0) - (reactionCounts.get(String(left.id)) || 0) ||
+ left.order - right.order
+ );
+ const pickerOptionsHtml = pickerEntries.map(({ id, label }) => {
+ const count = reactionCounts.get(String(id)) || 0;
+ return `
`;
+ }).join('');
+ const pickerHtml = `
${pickerOptionsHtml}
`;
+ if (railPicker && likeButton) {
+ const badgeLabel = current && current !== 'heart' ? reactionLabel(current) : '';
+ let badge = likeButton.querySelector(':scope > .ldp-topic-action-rail-reaction-badge');
+ if (badgeLabel) {
+ if (!badge) {
+ badge = makeElement('span');
+ badge.className = 'ldp-topic-action-rail-reaction-badge';
+ badge.setAttribute('aria-hidden', 'true');
+ likeButton.appendChild(badge);
+ }
+ if (badge.innerHTML !== badgeLabel) badge.innerHTML = badgeLabel;
+ } else {
+ badge?.remove();
+ }
+ }
+ let currentRendered = false;
+ const summaryHtml = renderedReactions.filter(({ item }) =>
+ !usesLikeDisplay || String(item.id || '') !== 'heart'
+ ).map(({ item, label }) => {
+ const id = String(item.id || '');
+ const pickerTrigger = id === current && usesFallbackTrigger;
+ const button = `
`;
+ if (!pickerTrigger) return button;
+ currentRendered = true;
+ return `
${button}${pickerHtml}`;
+ }).join('') + (usesFallbackTrigger && !currentRendered
+ ? `
${pickerHtml}`
+ : '');
+ let summary = root.querySelector(':scope > .ldp-reaction-summary');
+ if (summaryHtml || (collectLike && usesLikeTrigger)) {
+ if (!summary) {
+ summary = makeElement('div');
+ summary.className = 'ldp-reaction-summary';
+ root.insertBefore(summary, actions || root.firstChild);
+ }
+ if (summary.innerHTML !== summaryHtml) summary.innerHTML = summaryHtml;
+ } else {
+ summary?.remove();
+ summary = null;
+ }
+ if (actions && actions.parentElement !== root) root.appendChild(actions);
+ if (usesLikeTrigger) {
+ let picker = likeAnchor?.querySelector(':scope > .ldp-reaction-picker');
+ const pickerWasOpen = !!(picker && !picker.hidden);
+ if (!likeAnchor) {
+ likeAnchor = makeElement('span');
+ likeAnchor.className = 'ldp-reaction-picker-anchor ldp-reaction-like-picker';
+ likeButton.before(likeAnchor);
+ likeAnchor.append(likeButton);
+ }
+ if (!picker) {
+ likeAnchor.insertAdjacentHTML('beforeend', pickerHtml);
+ picker = likeAnchor.querySelector(':scope > .ldp-reaction-picker');
+ } else if (picker.innerHTML !== pickerOptionsHtml) {
+ picker.innerHTML = pickerOptionsHtml;
+ }
+ if (picker) picker.hidden = !pickerWasOpen;
+ likeButton.dataset.reactionPicker = '';
+ setExpanded(likeButton, pickerWasOpen);
+ if (collectLike && summary && likeAnchor.parentElement !== summary)
+ summary.insertBefore(likeAnchor, summary.firstChild);
+ } else unwrapLikePicker();
+ }
+
+ function reactionStateSnapshot(post) {
+ return {
+ reactions: Array.isArray(post?.reactions)
+ ? post.reactions.map((item) => ({ ...item }))
+ : [],
+ current_user_reaction: post?.current_user_reaction
+ ? { ...post.current_user_reaction }
+ : null,
+ reaction_users_count: post?.reaction_users_count,
+ };
+ }
+
+ function toggledPostReactionState(post, reaction) {
+ const state = reactionStateSnapshot(post);
+ const current = String(state.current_user_reaction?.id || '');
+ const target = String(reaction || '');
+ const adjustCount = (id, delta) => {
+ if (!id) return;
+ const item = state.reactions.find((entry) => String(entry.id || '') === id);
+ if (item) item.count = Math.max(0, Number(item.count) + delta || 0);
+ else if (delta > 0) state.reactions.push({ id, type: 'emoji', count: delta });
+ };
+ if (current) adjustCount(current, -1);
+ if (current !== target) adjustCount(target, 1);
+ state.reactions = state.reactions.filter((item) => Number(item.count) > 0);
+ state.current_user_reaction = current === target
+ ? null
+ : { id: target, type: 'emoji', can_undo: true };
+ if (Number.isFinite(Number(state.reaction_users_count)))
+ state.reaction_users_count = Math.max(0, Number(state.reaction_users_count) + (current ? (current === target ? -1 : 0) : 1));
+ return state;
+ }
+
+ async function toggleReaderPostReaction(postNode, ctx, reaction, onSync, onError) {
+ const postId = +(postNode && (postNode.dataset.postId || postNode._ldpPostData?.id) || 0);
+ if (!hasDiscourseCapability('reactions', {
+ post: postNode?._ldpPostData,
+ topic: ctx?.topicData,
+ })) return false;
+ return runReaderActionOnce(ctx, `post:${postId}:reaction`, async () => {
+ const previousState = reactionStateSnapshot(postNode._ldpPostData || {});
+ const sync = (fresh) => {
+ syncSpecialPostData(postNode, ctx, fresh);
+ onSync?.(fresh);
+ };
+ setReaderPostControlsBusy(postId, ctx, '.ldp-reaction-chip,.ldp-reaction-add,.ldp-like[data-reaction]', true, postNode);
+ sync(toggledPostReactionState(postNode._ldpPostData || {}, reaction));
+ try {
+ const reactionModule = lookupDiscourseModule(
+ 'discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction'
+ );
+ const CustomReaction = reactionModule?.default;
+ const appEvents = lookupDiscourse('service:app-events');
+ const postModel = detachedNativePostModel(postNode, ctx);
+ if (!CustomReaction || !isFunction(CustomReaction.toggle) ||
+ !appEvents || !isFunction(appEvents.on) ||
+ !isFunction(appEvents.off) || !postModel)
+ throw new Error('Discourse 回应接口尚未就绪');
+ let serverPost = null;
+ const onReactionToggled = (payload) => {
+ const nextPost = payload?.post;
+ if (Number(nextPost?.id) === postId) serverPost = nextPost;
+ };
+ appEvents.on('discourse-reactions:reaction-toggled', postNode, onReactionToggled);
+ try {
+ await CustomReaction.toggle(postModel, reaction, appEvents);
+ } finally {
+ appEvents.off('discourse-reactions:reaction-toggled', postNode, onReactionToggled);
+ }
+ if (!serverPost) throw new Error('Discourse 回应结果缺失');
+ await clearStoredResponseCache({ tags: [`post:${postId}`, 'reactions-given'] });
+ sync(serverPost);
+ ctx.refreshBookmarksPanel?.({ post: postNode._ldpPostData, topic: ctx.topicData });
+ } catch (error) {
+ sync(previousState);
+ onError(error);
+ } finally {
+ setReaderPostControlsBusy(postId, ctx, '.ldp-reaction-chip,.ldp-reaction-add,.ldp-like[data-reaction]', false, postNode);
+ }
+ });
+ }
+
+ function postVotingCommentHtml(comment) {
+ const user = comment.user || {};
+ const username = comment.username || user.username || '';
+ const avatar = userCardAvatarHtml(
+ persistentAvatarHtml(
+ user.avatar_template,
+ 28,
+ 'ldp-pv-comment-avatar',
+ comment.name || user.name || username || '?'
+ ),
+ username
+ );
+ return ``;
+ }
+
+ function postVotingComments(postData) {
+ if (Array.isArray(postData?._ldpPvComments)) return postData._ldpPvComments;
+ return Array.isArray(postData?.comments) ? postData.comments : [];
+ }
+
+ function readerPostVotingComments(postId, ctx) {
+ return readerPostCopies(postId, ctx)
+ .map((copy) => postVotingComments(copy._ldpPostData))
+ .sort((left, right) => right.length - left.length)[0] || [];
+ }
+
+ function postVotingCapabilities(post, ctx) {
+ if (!hasDiscourseCapability('postVoting', { post, topic: ctx?.topicData }))
+ return { voteControls: false, comments: false };
+ const topicEnabled = !!(ctx?.isPostVoting);
+ const voteControls = topicEnabled || ['post_voting_vote_count', 'post_voting_user_voted_direction', 'post_voting_has_votes']
+ .some((key) => Object.hasOwn(post || {}, key));
+ const comments = topicEnabled || ['comments_count', 'comments']
+ .some((key) => Object.hasOwn(post || {}, key));
+ return { voteControls, comments };
+ }
+
+ function renderPostVoting(postNode, ctx) {
+ if (!postNode || !ctx) return;
+ const post = postNode._ldpPostData || {};
+ const capabilities = postVotingCapabilities(post, ctx);
+ if (!capabilities.voteControls && !capabilities.comments) {
+ postNode.classList.remove('ldp-post-voting-answer');
+ postNode.querySelector(':scope > .ldp-pv-comments')?.remove();
+ return;
+ }
+ const isQuestion = +post.post_number === 1;
+ postNode.classList.toggle('ldp-post-voting-answer', !isQuestion);
+ postNode.classList.remove('ldp-reply', 'ldp-reply-collapsible', 'ldp-nested-collapsed');
+ postNode.querySelector(':scope > .ldp-nested-toggle')?.remove();
+ postNode.querySelector(':scope > .ldp-children')?.remove();
+ postNode.querySelector(':scope > .ldp-collapse-replies')?.remove();
+ postNode.querySelector(':scope > .ldp-sub-loading')?.remove();
+ if (isQuestion) {
+ postNode.querySelector(':scope > .ldp-pv-comments')?.remove();
+ return;
+ }
+ const directContent = postNode.querySelector(':scope > .ldp-content');
+ const wrappedContent = postNode.querySelector(':scope > .ldp-pv-layout .ldp-content');
+ const content = directContent || wrappedContent;
+ if (capabilities.voteControls && content && !content.parentElement.classList.contains('ldp-pv-main')) {
+ const main = makeElement('div');
+ main.className = 'ldp-pv-main';
+ content.before(main);
+ main.appendChild(content);
+ const layout = makeElement('div');
+ layout.className = 'ldp-pv-layout';
+ const votes = makeElement('div');
+ votes.className = 'ldp-pv-votes';
+ layout.append(votes, main);
+ const head = postNode.querySelector(':scope > .ldp-post-head');
+ head?.after(layout);
+ }
+ if (!capabilities.voteControls && wrappedContent) {
+ const layout = wrappedContent.closest('.ldp-pv-layout');
+ layout.before(wrappedContent);
+ layout.remove();
+ }
+ const direction = String(post.post_voting_user_voted_direction || '');
+ const votes = postNode.querySelector(':scope > .ldp-pv-layout .ldp-pv-votes');
+ if (votes) votes.innerHTML = `
${+post.post_voting_vote_count || 0}`;
+ let comments = postNode.querySelector(':scope > .ldp-pv-comments');
+ if (!capabilities.comments) {
+ comments?.remove();
+ return;
+ }
+ const count = Math.max(0, +post.comments_count || (Array.isArray(post.comments) ? post.comments.length : 0));
+ if (!comments) {
+ comments = makeElement('div');
+ comments.className = 'ldp-pv-comments';
+ postNode.appendChild(comments);
+ }
+ const expanded = comments.dataset.expanded === '1';
+ const loaded = postVotingComments(post);
+ comments.innerHTML = `
${loaded.map(postVotingCommentHtml).join('')}${ME_USERNAME ? '' : ''}
`;
+ comments.dataset.expanded = expanded ? '1' : '0';
+ hydratePersistentAvatarImages(comments);
+ }
+
+ function eventDateLabel(event) {
+ const options = { year: 'numeric', month: 'short', day: 'numeric', hour: event.all_day ? undefined : '2-digit', minute: event.all_day ? undefined : '2-digit' };
+ const start = event.starts_at ? new Date(event.starts_at) : null;
+ const end = event.ends_at ? new Date(event.ends_at) : null;
+ const format = (date) => date && Number.isFinite(date.getTime()) ? date.toLocaleString('zh-CN', options) : '';
+ return [format(start), format(end)].filter(Boolean).join(' — ');
+ }
+
+ function renderPostEvent(postNode) {
+ if (!postNode) return;
+ const post = postNode._ldpPostData || {};
+ const event = post.event;
+ let card = postNode.querySelector(':scope > .ldp-event-card');
+ if (!event) { if (card) card.remove(); return; }
+ if (!card) { card = makeElement('section'); card.className = 'ldp-event-card'; postNode.querySelector(':scope > .ldp-content')?.after(card); }
+ const watching = event.watching_invitee || {};
+ const stats = event.stats || {};
+ const closed = event.is_closed || event.is_expired || event.can_update_attendance === false;
+ const location = event.location && (typeof event.location === 'string'
+ ? event.location
+ : (event.location.name || event.location.address || event.location.display || ''));
+ card.innerHTML = `
${esc(event.name || '活动')}
${esc(eventDateLabel(event))}${event.timezone ? ` ${esc(event.timezone)}` : ''}
${location ? `
地点:${esc(location)}
` : ''}${event.description_html ? `
${event.description_html}
` : ''}
参加 ${+stats.going || 0} · 感兴趣 ${+stats.interested || 0}${event.max_attendees ? ` · 名额 ${+stats.going || 0}/${event.max_attendees}` : ''}${event.is_ongoing ? ' · 进行中' : event.is_expired ? ' · 已结束' : ''}
${['going:参加','interested:感兴趣','not_going:不参加'].map((item) => { const [status,label] = item.split(':'); return `
`; }).join('')}
下载日历`;
+ prepareReaderMedia(card);
+ }
+
+ const SYSTEM_ACTION_LABELS = {
+ closed: '主题已关闭', opened: '主题已重新开放', archived: '主题已归档', unarchived: '主题已取消归档',
+ pinned: '主题已置顶', unpinned: '主题已取消置顶', autoclosed: '主题已自动关闭',
+ 'autoclosed.enabled': '主题已自动关闭', 'autoclosed.disabled': '主题已自动重新开放', split_topic: '帖子已拆分到新主题',
+ merged: '主题已合并', moved: '帖子已移动', visible: '主题已公开', invisible: '主题已隐藏',
+ 'visible.enabled': '主题已公开', 'visible.disabled': '主题已取消公开', renamed: '主题标题已修改', assigned: '已指定',
+ };
+
+ const SYSTEM_ACTION_ICONS = {
+ visible: 'check', invisible: 'eyeOff', 'visible.enabled': 'check', 'visible.disabled': 'eyeOff',
+ assigned: 'userPlus',
+ };
+
+ function systemActionContentHtml(post, actionCode) {
+ const actionLabel = SYSTEM_ACTION_LABELS[actionCode] || `系统操作:${actionCode.replace(/[._]/g, ' ')}`;
+ if (actionCode !== 'assigned') return `
${esc(actionLabel)}`;
+ const username = cleanUsernameValue(post?.action_code_who);
+ if (!username) return `
${esc(actionLabel)}`;
+ return `
${esc(`${actionLabel}给 `)}@${esc(username)}`;
+ }
+
+ function postNoticeType(post) {
+ const notice = post?.notice;
+ return notice && typeof notice === 'object' ? String(notice.type || '') : '';
+ }
+
+ function postNoticeIdentity(post, type = postNoticeType(post)) {
+ if (type === 'new_user') return { label: '新用户', title: '新用户,首次发帖', icon: 'userPlus' };
+ if (type === 'returning_user') return { label: '回归用户', title: '回归用户,久未发帖', icon: 'rotateCcw' };
+ if (type !== 'custom') return null;
+ const notice = post?.notice;
+ const customNotice = notice && typeof notice === 'object'
+ ? `${String(notice.raw || '')} ${String(notice.cooked || '')}`
+ : '';
+ return /\bpremium\b/i.test(customNotice)
+ ? { label: 'Premium', title: 'Premium', icon: 'check' }
+ : { label: '富可敌国', title: '富可敌国', icon: 'tag' };
+ }
+
+ function ensureNestedReplyToggle(post, referenceNode) {
+ if (post.querySelector(':scope > .ldp-nested-toggle')) return;
+ const toggle = makeElement('button');
+ toggle.className = 'ldp-btn ldp-nested-toggle';
+ toggle.type = 'button';
+ post.insertBefore(toggle, referenceNode);
+ }
+
+ function renderSpecialPostState(postNode) {
+ if (!postNode) return;
+ const post = postNode._ldpPostData || {};
+ const head = postNode.querySelector(':scope > .ldp-post-head');
+ let hiddenBadge = head?.querySelector(':scope > .ldp-hidden-badge');
+ const hiddenCollapsible = !!post.hidden && !postNode.classList.contains('ldp-nested-preview');
+ const wasHiddenCollapsible = postNode.classList.contains('ldp-hidden-collapsible');
+ if (post.hidden) {
+ if (!hiddenBadge && head) {
+ hiddenBadge = makeElement('span');
+ hiddenBadge.className = 'ldp-special-badge ldp-hidden-badge warn';
+ hiddenBadge.textContent = '已隐藏';
+ const floor = head.querySelector(':scope > .ldp-floor');
+ head.insertBefore(hiddenBadge, floor || null);
+ }
+ } else if (hiddenBadge) hiddenBadge.remove();
+ if (hiddenCollapsible) {
+ postNode.classList.add('ldp-hidden-collapsible');
+ ensureNestedReplyToggle(postNode, head || postNode.firstChild);
+ if (!wasHiddenCollapsible) setNestedReplyCollapsed(postNode, true);
+ } else if (wasHiddenCollapsible) {
+ setNestedReplyCollapsed(postNode, false);
+ postNode.classList.remove('ldp-hidden-collapsible');
+ if (!postNode.classList.contains('ldp-reply-collapsible'))
+ postNode.querySelector(':scope > .ldp-nested-toggle')?.remove();
+ }
+ let badges = postNode.querySelector(':scope > .ldp-special-badges');
+ const items = [];
+ if (+post.post_type === 4) items.push(['私信回复', '']);
+ if (+post.post_type === 2) items.push(['管理操作', 'warn']);
+ if (post.wiki) items.push(['Wiki', '']);
+ if (post.deleted_at) items.push(['已删除', 'danger']);
+ if (post.locked) items.push(['已锁定', 'warn']);
+ if (items.length) {
+ if (!badges) { badges = makeElement('div'); badges.className = 'ldp-special-badges'; head?.after(badges); }
+ badges.innerHTML = items.map(([label, type]) => `
${label}`).join('');
+ } else if (badges) badges.remove();
+ postNode.classList.toggle('ldp-whisper', +post.post_type === 4);
+ postNode.classList.toggle('ldp-system-post', +post.post_type === 2 || +post.post_type === 3);
+ let notice = postNode.querySelector(':scope > .ldp-special-notice');
+ const noticeType = postNoticeType(post);
+ if (post.notice && !['new_user', 'returning_user', 'custom'].includes(noticeType)) {
+ if (!notice) { notice = makeElement('div'); notice.className = 'ldp-special-notice'; head?.after(notice); }
+ notice.innerHTML = typeof post.notice === 'string' ? post.notice : esc(post.notice.type || '站点提示');
+ } else if (notice) notice.remove();
+ let action = postNode.querySelector(':scope > .ldp-system-action');
+ const actionCode = String(post.action_code || '');
+ const needsAction = (+post.post_type === 2 || +post.post_type === 3) && actionCode;
+ postNode.classList.toggle('ldp-system-action-compact', needsAction && actionCode === 'assigned');
+ if (needsAction) {
+ if (!action) { action = makeElement('div'); action.className = 'ldp-system-action'; head?.after(action); }
+ action.innerHTML = `${icon(SYSTEM_ACTION_ICONS[actionCode] || 'settings')}${systemActionContentHtml(post, actionCode)}`;
+ } else if (action) action.remove();
+ }
+
+ function acceptedTopicAnswers(ctx) {
+ const topic = ctx?.topicData;
+ if (!topic) return [];
+ const answersByPostNumber = new Map();
+ const addAnswer = (answer) => {
+ if (!answer) return;
+ const candidate = typeof answer === 'object' ? answer : { post_number: answer };
+ const postNumber = +(candidate.post_number || 0);
+ if (postNumber <= 1) return;
+ answersByPostNumber.set(postNumber, Object.assign(
+ {},
+ answersByPostNumber.get(postNumber) || {},
+ candidate,
+ { post_number: postNumber }
+ ));
+ };
+ if (Array.isArray(topic.accepted_answers)) topic.accepted_answers.forEach(addAnswer);
+ addAnswer(topic.accepted_answer);
+ postsFromPayload(topic).forEach((post) => {
+ if (post?.accepted_answer) addAnswer(post);
+ });
+ if (ctx.streamNodeMap)
+ ctx.streamNodeMap.forEach((node) => {
+ const post = node?._ldpPostData;
+ if (post?.accepted_answer) addAnswer(post);
+ });
+ return [...answersByPostNumber.values()].sort((a, b) => a.post_number - b.post_number);
+ }
+
+ function renderSolvedAnswerCard(postNode, ctx) {
+ if (!postNode) return;
+ const post = postNode._ldpPostData || {};
+ const answers = acceptedTopicAnswers(ctx);
+ let card = postNode.querySelector(':scope > .ldp-solved-card');
+ const isStarter = +post.post_number === 1 && !postNode.classList.contains('ldp-nested-preview');
+ if (!isStarter || !answers.length) {
+ if (card) card.remove();
+ return;
+ }
+
+ const topicPosts = postsFromPayload(ctx.topicData);
+ if (!card) {
+ card = makeElement('section');
+ card.className = 'ldp-solved-card';
+ const content = postNode.querySelector(':scope > .ldp-content');
+ if (!content) return;
+ content.after(card);
+ }
+ card.innerHTML = `
${icon('check')}${answers.length > 1 ? `已解决 · ${answers.length} 个答案` : '已解决'}
${answers.map((answer) => {
+ const postNumber = +answer.post_number;
+ const acceptedNode = ctx.streamNodeMap?.get(postNumber);
+ const acceptedPost = acceptedNode?._ldpPostData ||
+ topicPosts.find((item) => +item.post_number === postNumber) || {};
+ const username = String(answer.username || acceptedPost.username || '');
+ const name = String(answer.name || acceptedPost.name || username || '已解决回复');
+ const profileUrl = username ? userProfileUrl(username) : '';
+ const avatar = persistentAvatarHtml(
+ answer.avatar_template || acceptedPost.avatar_template,
+ 32,
+ 'ldp-solved-avatar',
+ name
+ );
+ const time = discourseRelativeTime(answer.created_at || acceptedPost.created_at);
+ const answerHtml = String(answer.cooked || acceptedPost.cooked || answer.excerpt || acceptedPost.excerpt || '').trim();
+ return `
+
+ ${avatar ? `
${avatar}` : ''}
+ ${profileUrl ? `
${esc(name)}` : `
${esc(name)}`}
+ ${username ? `
@${esc(username)}` : ''}
+ ${time ? `
· ${esc(time)}` : ''}
+
+
+ ${answerHtml ? `
${readerMediaHtml(answerHtml, { inlineOneboxes: true })}
` : '
查看被采纳的完整回复。
'}
+
+
`;
+ }).join('')}`;
+ ctx?.translation?.syncPost(postNode);
+ }
+
+ function prepareReaderImageCarousels(root) {
+ root.querySelectorAll('.d-image-grid[data-mode="carousel"]').forEach((grid) => {
+ if (grid.dataset.ldpCarouselPrepared === '1') return;
+ const items = [...grid.querySelectorAll('.lightbox-wrapper')]
+ .filter((item) => item.closest('.d-image-grid') === grid);
+ if (items.length < 2) return;
+ grid.dataset.ldpCarouselPrepared = '1';
+ grid.classList.add('ldp-media-carousel');
+ const track = makeElement('div');
+ track.className = 'ldp-media-carousel-track';
+ track.tabIndex = 0;
+ track.setAttribute('role', 'region');
+ setLabel(track, `多图轮播,共 ${items.length} 张`);
+ items.forEach((item) => track.append(item));
+ const controls = makeElement('div');
+ controls.className = 'ldp-media-carousel-controls';
+ const previous = makeElement('button');
+ previous.type = 'button';
+ setLabel(previous, '上一张图片');
+ previous.innerHTML = icon('chevronRight');
+ const status = makeElement('span');
+ status.className = 'ldp-media-carousel-status';
+ status.setAttribute('aria-live', 'polite');
+ const next = makeElement('button');
+ next.type = 'button';
+ setLabel(next, '下一张图片');
+ next.innerHTML = icon('chevronRight');
+ controls.append(previous, status, next);
+ grid.replaceChildren(track, controls);
+
+ let activeIndex = 0;
+ let syncFrame = 0;
+ const sync = () => {
+ syncFrame = 0;
+ activeIndex = items.reduce((closest, item, index) => (
+ Math.abs(item.offsetLeft - track.scrollLeft) < Math.abs(items[closest].offsetLeft - track.scrollLeft)
+ ? index
+ : closest
+ ), 0);
+ previous.disabled = activeIndex === 0;
+ next.disabled = activeIndex === items.length - 1;
+ status.textContent = `${activeIndex + 1} / ${items.length}`;
+ };
+ const scheduleSync = () => {
+ if (!syncFrame) syncFrame = requestAnimationFrame(sync);
+ };
+ const show = (index) => {
+ const target = items[Math.max(0, Math.min(items.length - 1, index))];
+ if (!target) return;
+ const reducedMotion = prefersReducedMotion();
+ track.scrollTo({ left: target.offsetLeft, behavior: reducedMotion ? 'auto' : 'smooth' });
+ };
+ controls.addEventListener('click', (event) => {
+ const button = event.target instanceof Element ? event.target.closest('button') : null;
+ if (button === previous) show(activeIndex - 1);
+ else if (button === next) show(activeIndex + 1);
+ });
+ track.addEventListener('scroll', scheduleSync, { passive: true });
+ sync();
+ });
+ }
+
+ function prepareReaderFrame(frame) {
+ frame.loading = 'lazy';
+ try {
+ const url = new URL(frame.getAttribute('src') || '', BASE);
+ if (url.hostname !== 'player.bilibili.com') return;
+ url.searchParams.set('autoplay', '0');
+ frame.src = url.href;
+ frame.classList.add('ldp-bilibili-player');
+ frame.setAttribute('allow', 'fullscreen; picture-in-picture');
+ frame.setAttribute('allowfullscreen', '');
+ } catch {}
+ }
+
+ function readerHlsSource(video) {
+ const candidates = [video, ...video.querySelectorAll('source')];
+ for (const candidate of candidates) {
+ const source = String(candidate.getAttribute('src') || '').trim();
+ if (!source) continue;
+ const type = String(candidate.getAttribute('type') || '').toLowerCase();
+ let isHls = /(?:vnd\.apple\.mpegurl|x-mpegurl)/.test(type);
+ if (!isHls) {
+ try { isHls = /\.m3u8$/i.test(new URL(source, BASE).pathname); } catch {}
+ }
+ if (isHls) return new URL(source, BASE).href;
+ }
+ return '';
+ }
+
+ function bindReaderHls(video) {
+ if (!video || video.dataset.ldpHlsBound === '1') return;
+ const source = readerHlsSource(video);
+ if (!source) return;
+ const nativeHls = !!video.canPlayType('application/vnd.apple.mpegurl') && 'ManagedMediaSource' in window;
+ if (nativeHls) return;
+ const HlsPlayer = window.Hls;
+ if (!HlsPlayer || !isFunction(HlsPlayer.isSupported) || !HlsPlayer.isSupported()) return;
+ const player = new HlsPlayer();
+ video.dataset.ldpHlsBound = '1';
+ video._ldpHlsPlayer = player;
+ player.loadSource(source);
+ player.attachMedia(video);
+ }
+
+ function destroyReaderMedia(root) {
+ if (!root || !isFunction(root.querySelectorAll)) return;
+ root.querySelectorAll('video').forEach((video) => {
+ const player = video._ldpHlsPlayer;
+ if (isFunction(player?.destroy)) player.destroy();
+ video._ldpHlsPlayer = null;
+ delete video.dataset.ldpHlsBound;
+ });
+ }
+
+ function suspendReaderMedia(root) {
+ if (!root || !isFunction(root.querySelectorAll)) return;
+ root.querySelectorAll('video,audio').forEach((media) => {
+ try { media.pause(); } catch {}
+ });
+ destroyReaderMedia(root);
+ }
+
+ function activateReaderMedia(root) {
+ if (!root || !isFunction(root.querySelectorAll) || document.visibilityState !== 'visible') return;
+ root.querySelectorAll('video').forEach(bindReaderHls);
+ }
+
+ function prepareReaderMedia(root, options = {}) {
+ if (!root || !isFunction(root.querySelectorAll)) return;
+ root.querySelectorAll('[title]').forEach((element) => element.removeAttribute('title'));
+ if (options.interactive !== false) prepareReaderImageCarousels(root);
+ root.querySelectorAll('iframe').forEach(prepareReaderFrame);
+ root.querySelectorAll('video,audio').forEach((media) => {
+ media.removeAttribute('autoplay');
+ media.autoplay = false;
+ if (!media.hasAttribute('preload')) media.preload = 'metadata';
+ if (media.tagName === 'VIDEO') media.playsInline = true;
+ });
+ root.querySelectorAll('img').forEach((img) => {
+ if (img.classList.contains('emoji')) return;
+ img.loading = 'lazy';
+ img.decoding = 'async';
+ });
+ }
+
+ function prepareReaderCodeBlocks(root) {
+ if (!root || !isFunction(root.querySelectorAll)) return;
+ root.querySelectorAll('pre').forEach((pre) => {
+ if (pre.closest('.ldp-code-block')) return;
+ const lineCount = String(pre.textContent || '').replace(/\n$/, '').split('\n').length;
+ const block = makeElement('div');
+ block.className = 'ldp-code-block';
+ const actions = makeElement('div');
+ actions.className = 'ldp-code-block-actions';
+ actions.innerHTML = `
+ ${iconActionButtons('ldp-code-block-action', 'data-reader-code-action', [
+ ['copy', '复制文本', 'copy'], ['preview', '在阅读器内预览文本', 'maximize2'],
+ ...(lineCount > 10 ? [['toggle', `展开全部 ${lineCount} 行`, 'chevronDown',
+ ' aria-expanded="false"']] : []),
+ ])}`;
+ if (lineCount > 10) {
+ block.classList.add('ldp-code-block-collapsible');
+ block.dataset.readerCodeLines = String(lineCount);
+ }
+ pre.before(block);
+ block.append(pre, actions);
+ });
+ }
+
+ async function copyReaderCode(source, button) {
+ if (!source || !button || button.disabled) return;
+ button.disabled = true;
+ try {
+ const text = 'value' in source ? source.value : source.textContent;
+ await writeSelectionQuoteToClipboard(text || '');
+ button.innerHTML = icon('check');
+ setLabel(button, '已复制');
+ showSelectionToast('文本已复制');
+ setTimeout(() => {
+ if (!button.isConnected) return;
+ button.innerHTML = icon('copy');
+ setLabel(button, '复制文本');
+ button.disabled = false;
+ }, 1200);
+ } catch {
+ button.disabled = false;
+ showSelectionToast('复制失败,请重试');
+ }
+ }
+
+ function readerCodeDownloadExtension(pre) {
+ const code = pre?.querySelector('code');
+ const classNames = `${pre?.className || ''} ${code?.className || ''}`;
+ const language = (classNames.match(/(?:language|lang)-([a-z0-9_+-]+)/i) || [])[1]?.toLowerCase() || '';
+ return ({
+ javascript: 'js', js: 'js', typescript: 'ts', ts: 'ts', python: 'py', py: 'py',
+ bash: 'sh', shell: 'sh', sh: 'sh', html: 'html', css: 'css', json: 'json', sql: 'sql',
+ rust: 'rs', go: 'go', java: 'java', c: 'c', cpp: 'cpp', csharp: 'cs', php: 'php',
+ ruby: 'rb', yaml: 'yml', yml: 'yml', markdown: 'md', md: 'md',
+ })[language] || 'txt';
+ }
+
+ function downloadReaderCodeCopy(pre, text) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const extension = readerCodeDownloadExtension(pre);
+ const blob = new window.Blob([String(text || '')], { type: 'text/plain;charset=utf-8' });
+ triggerBrowserDownload(blob, `linuxdo-code-copy-${timestamp}.${extension}`);
+ showSelectionToast('编辑副本已下载到本地');
+ }
+
+ function openReaderCodePreview(pre, modal) {
+ if (!pre || !modal) return;
+ const overlay = modal.closest('.ldp-overlay');
+ if (isFunction(overlay?._ldpCloseCodePreview)) overlay._ldpCloseCodePreview(false, true);
+ const previousFocus = readerActiveElement();
+ const layer = makeElement('section');
+ layer.className = 'ldp-code-preview-layer';
+ layer.setAttribute('role', 'dialog');
+ layer.setAttribute('aria-modal', 'true');
+ setLabel(layer, '文本预览');
+ const sourceStyle = getComputedStyle(pre);
+ layer.style.setProperty('--ldp-code-preview-font-family', sourceStyle.fontFamily);
+ layer.style.setProperty('--ldp-code-preview-font-size', sourceStyle.fontSize);
+ layer.style.setProperty('--ldp-code-preview-font-weight', sourceStyle.fontWeight);
+ layer.style.setProperty('--ldp-code-preview-line-height', sourceStyle.lineHeight);
+ layer.style.setProperty('--ldp-code-preview-tab-size', sourceStyle.tabSize || '4');
+ layer.innerHTML = `
+
+ 文本预览
+
+ ${iconActionButtons('ldp-code-block-action', 'data-reader-code-preview-action', [
+ ['edit', '编辑文本副本', 'pencil'], ['save', '保存编辑副本到本地', 'download', ' hidden'],
+ ['copy', '复制文本', 'copy'], ['close', '关闭文本预览', 'x'],
+ ])}
+
+
+
`;
+ const previewPre = pre.cloneNode(true);
+ const previewBody = layer.querySelector('.ldp-code-preview-body');
+ const previewTitle = layer.querySelector('.ldp-code-preview-head strong');
+ const editButton = layer.querySelector('[data-reader-code-preview-action="edit"]');
+ const saveButton = layer.querySelector('[data-reader-code-preview-action="save"]');
+ const closeButton = layer.querySelector('[data-reader-code-preview-action="close"]');
+ previewBody.append(previewPre);
+ let activeTextSource = previewPre;
+ let editing = false;
+ let closed = false;
+ const restorePreview = () => {
+ activeTextSource = previewPre;
+ editing = false;
+ previewBody.replaceChildren(previewPre);
+ previewTitle.textContent = '文本预览';
+ setLabel(layer, '文本预览');
+ editButton.hidden = false;
+ saveButton.hidden = true;
+ setLabel(closeButton, '关闭文本预览');
+ closeButton.focus();
+ };
+ const close = (restoreFocus = true, force = false) => {
+ if (closed) return;
+ if (editing && !force) {
+ restorePreview();
+ return;
+ }
+ closed = true;
+ document.removeEventListener('keydown', onKeyDown, true);
+ layer.remove();
+ if (overlay && overlay._ldpCloseCodePreview === close) overlay._ldpCloseCodePreview = null;
+ if (restoreFocus && previousFocus && previousFocus.isConnected) previousFocus.focus();
+ };
+ const onKeyDown = (event) => {
+ if (event.key !== 'Escape') return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ close();
+ };
+ layer.addEventListener('click', (event) => {
+ const action = event.target.closest('[data-reader-code-preview-action]');
+ if (!action) return;
+ consumeEvent(event);
+ if (action.dataset.readerCodePreviewAction === 'copy')
+ void copyReaderCode(activeTextSource, action);
+ else if (action.dataset.readerCodePreviewAction === 'save')
+ downloadReaderCodeCopy(pre, activeTextSource.value);
+ else if (action.dataset.readerCodePreviewAction === 'edit') {
+ const editor = makeElement('textarea');
+ editor.className = 'ldp-code-preview-editor';
+ editor.value = previewPre.textContent || '';
+ editor.spellcheck = false;
+ setLabel(editor, '文本编辑副本');
+ editor.addEventListener('keydown', (keyEvent) => {
+ if (keyEvent.key !== 'Tab') return;
+ keyEvent.preventDefault();
+ editor.setRangeText('\t', editor.selectionStart, editor.selectionEnd, 'end');
+ });
+ activeTextSource = editor;
+ editing = true;
+ previewBody.replaceChildren(editor);
+ previewTitle.textContent = '文本编辑(副本)';
+ setLabel(layer, '文本编辑副本');
+ saveButton.hidden = false;
+ editButton.hidden = true;
+ setLabel(closeButton, '返回文本预览');
+ editor.focus();
+ } else close();
+ });
+ modal.append(layer);
+ bindFloatingSurfaceWheel(layer);
+ if (overlay) overlay._ldpCloseCodePreview = close;
+ document.addEventListener('keydown', onKeyDown, true);
+ layer.querySelector('[data-reader-code-preview-action="close"]')?.focus();
+ }
+
+ function prepareReaderHashtags(root) {
+ if (!root || !isFunction(root.querySelectorAll)) return;
+ const iconTemplate = makeElement('template');
+ iconTemplate.innerHTML = icon('tag', 'ldp-hashtag-icon');
+ root.querySelectorAll('.hashtag-cooked').forEach((hashtag) => {
+ const iconHost = hashtag.matches('a') ? hashtag : hashtag.querySelector('a') || hashtag;
+ if (iconHost.querySelector('img.emoji')) return;
+ const existingSvg = iconHost.querySelector('svg');
+ if (existingSvg?.querySelector('path,circle,rect,ellipse,line,polyline,polygon')) return;
+ const tagIcon = iconTemplate.content.firstElementChild.cloneNode(true);
+ tagIcon.setAttribute('focusable', 'false');
+ const placeholder = iconHost.querySelector('.hashtag-icon-placeholder');
+ if (placeholder) placeholder.replaceWith(tagIcon);
+ else if (existingSvg) existingSvg.replaceWith(tagIcon);
+ else iconHost.prepend(tagIcon);
+ });
+ }
+
+ function prepareReaderUserMentions(root) {
+ if (!root || !isFunction(root.querySelectorAll)) return;
+ root.querySelectorAll('a.mention').forEach((link) => {
+ let username = cleanUsernameValue(link.dataset.username);
+ if (!username) {
+ try {
+ const url = new URL(link.getAttribute('href') || '', BASE);
+ const match = url.origin === BASE && url.pathname.match(/^\/u\/([^/]+)\/?$/i);
+ username = match ? decodeURIComponent(match[1]) : '';
+ } catch {}
+ }
+ if (!username) username = cleanUsernameValue(link.textContent);
+ if (!username) return;
+ link.classList.add('ldp-user-link');
+ link.dataset.userCard = username;
+ });
+ }
+
+ const READER_CALLOUT_ICONS = Object.freeze(Object.fromEntries([
+ ['list', ['abstract', 'example', 'summary', 'tldr']],
+ ['alertTriangle', ['attention', 'bug', 'caution', 'danger', 'error', 'warning']],
+ ['check', ['check', 'done', 'success', 'todo']],
+ ['messageSquare', ['cite', 'quote']],
+ ['circleX', ['fail', 'failure', 'missing']],
+ ['info', ['faq', 'help', 'info', 'question']],
+ ['lightbulb', ['hint', 'important', 'tip']],
+ ['pencil', ['note']],
+ ].flatMap(([iconName, types]) => types.map((type) => [type, iconName]))));
+
+ function prepareReaderCallouts(root) {
+ if (!root || !isFunction(root.querySelectorAll)) return;
+ root.querySelectorAll('blockquote').forEach((quote) => {
+ const firstBlock = quote.firstElementChild;
+ if (!firstBlock) return;
+ const walker = document.createTreeWalker(firstBlock, NodeFilter.SHOW_TEXT);
+ let markerNode = null;
+ while (walker.nextNode()) {
+ if (String(walker.currentNode.nodeValue || '').trim()) {
+ markerNode = walker.currentNode;
+ break;
+ }
+ }
+ if (!markerNode) return;
+ const match = String(markerNode.nodeValue || '').match(/^\s*\[!([a-z][a-z0-9_-]*)\]([+-])?\s*/i);
+ if (!match) return;
+ const type = match[1].toLowerCase();
+ if (!READER_CALLOUT_ICONS[type]) return;
+ markerNode.nodeValue = String(markerNode.nodeValue || '').slice(match[0].length);
+ quote.classList.add('ldp-callout', `ldp-callout--${type}`);
+ if (match[2]) {
+ const body = makeElement('div');
+ body.className = 'ldp-callout-body';
+ const firstBreak = firstBlock.querySelector('br');
+ if (firstBreak) {
+ const range = document.createRange();
+ range.setStartAfter(firstBreak);
+ range.setEnd(firstBlock, firstBlock.childNodes.length);
+ body.append(range.extractContents());
+ firstBreak.remove();
+ }
+ let sibling = firstBlock.nextSibling;
+ while (sibling) {
+ const next = sibling.nextSibling;
+ body.append(sibling);
+ sibling = next;
+ }
+ if (String(body.textContent || '').trim() || body.querySelector('*')) {
+ quote.classList.add('ldp-callout--foldable');
+ quote.append(body);
+ const toggle = makeElement('button');
+ toggle.type = 'button';
+ toggle.className = 'ldp-callout-toggle';
+ toggle.innerHTML = icon('chevronDown');
+ const setCalloutExpanded = (expanded) => {
+ body.hidden = !expanded;
+ quote.classList.toggle('ldp-callout--collapsed', !expanded);
+ setExpanded(toggle, expanded);
+ setLabel(toggle, expanded ? '收起提示内容' : '展开提示内容');
+ };
+ toggle.addEventListener('click', () =>
+ setCalloutExpanded(toggle.getAttribute('aria-expanded') !== 'true'));
+ quote.append(toggle);
+ setCalloutExpanded(match[2] === '+');
+ }
+ }
+ const marker = makeElement('span');
+ marker.className = 'ldp-callout-icon';
+ marker.innerHTML = icon(READER_CALLOUT_ICONS[type]);
+ quote.prepend(marker);
+ });
+ }
+
+ function prepareReaderPostQuotes(root) {
+ if (!root || !isFunction(root.querySelectorAll)) return;
+ root.querySelectorAll('aside.quote').forEach((quote) => {
+ const title = quote.querySelector(':scope > .title');
+ const body = quote.querySelector(':scope > blockquote');
+ if (!title || !body) return;
+ quote.classList.add('ldp-post-quote');
+ quote.dataset.ldpQuoteExpanded = '0';
+ quote._ldpQuoteExcerptHtml = body.innerHTML;
+ title.classList.add('ldp-quote-title');
+ const username = cleanUsernameValue(quote.dataset.username);
+ const avatar = [...title.querySelectorAll('img')].find((image) => !image.classList.contains('emoji'));
+ if (username && avatar) {
+ avatar.classList.add('ldp-user-avatar-card');
+ avatar.dataset.userCard = username;
+ }
+ const targetPostNumber = Math.max(0, +(quote.dataset.post || 0));
+ const targetTopicId = Math.max(0, +(quote.dataset.topic || 0));
+ let controls = title.querySelector(':scope > .quote-controls');
+ if (!controls) {
+ controls = makeElement('span');
+ controls.className = 'quote-controls';
+ title.append(controls);
+ }
+ controls.classList.add('ldp-quote-controls');
+ controls.replaceChildren();
+ const toggle = makeElement('button');
+ toggle.type = 'button';
+ toggle.className = 'ldp-quote-toggle';
+ setLabel(toggle, '展开完整引用');
+ setExpanded(toggle, false);
+ toggle.innerHTML = icon('chevronDown');
+ controls.append(toggle);
+ if (targetPostNumber) {
+ const jump = makeElement('button');
+ jump.type = 'button';
+ jump.className = 'ldp-quote-jump';
+ jump.dataset.targetPostNumber = String(targetPostNumber);
+ if (targetTopicId) jump.dataset.targetTopicId = String(targetTopicId);
+ setLabel(jump, `跳到被引用楼层 #${targetPostNumber}`);
+ jump.innerHTML = icon('arrowUp');
+ controls.append(jump);
+ }
+ });
+ }
+
+ function cachedReaderQuotedPost(ctx, topicId, postNumber) {
+ const targetTopicId = +topicId || +(ctx?.topicId || 0);
+ const targetPostNumber = +postNumber || 0;
+ if (!targetTopicId || !targetPostNumber) return null;
+ if (ctx && targetTopicId === +ctx.topicId) {
+ const streamPost = ctx.streamNodeMap?.get(targetPostNumber);
+ const streamData = streamPost?._ldpPostData;
+ if (streamData && streamData.cooked != null) return streamData;
+ const topicPost = postsFromPayload(ctx.topicData).find((post) => +post.post_number === targetPostNumber);
+ if (topicPost && topicPost.cooked != null) return topicPost;
+ }
+ return postsFromPayload(getCachedTopicData(targetTopicId))
+ .find((post) => +post.post_number === targetPostNumber && post.cooked != null) || null;
+ }
+
+ async function loadReaderQuotedPost(quote, ctx) {
+ if (!quote || !ctx) return null;
+ if (quote._ldpQuoteFullPost && quote._ldpQuoteFullPost.cooked != null) return quote._ldpQuoteFullPost;
+ const targetPostNumber = +quote.dataset.post || 0;
+ const targetTopicId = +quote.dataset.topic || +ctx.topicId || 0;
+ let post = cachedReaderQuotedPost(ctx, targetTopicId, targetPostNumber);
+ if (!post && targetTopicId === +ctx.topicId && ctx.loadPost) {
+ await ctx.loadPost(targetPostNumber, {
+ mount: false,
+ priority: POST_REQUEST_PRIORITY.auxiliary,
+ });
+ post = cachedReaderQuotedPost(ctx, targetTopicId, targetPostNumber);
+ }
+ if (!post) {
+ const urls = [
+ `${BASE}/posts/by_number/${targetTopicId}/${targetPostNumber}.json`,
+ `${BASE}/t/${targetTopicId}.json?post_number=${targetPostNumber}`,
+ ];
+ for (const url of urls) {
+ try {
+ const payload = await fetchJSON(url, {
+ scheduler: ctx.postRequestScheduler,
+ key: `quote:${targetTopicId}:${targetPostNumber}:${url}`,
+ priority: POST_REQUEST_PRIORITY.auxiliary,
+ });
+ const posts = payload?.post_number ? [payload] : postsFromPayload(payload);
+ post = posts.find((item) => +item.post_number === targetPostNumber && item.cooked != null) || null;
+ if (post) break;
+ } catch (error) {
+ if (shouldStopReaderFallback(error)) throw error;
+ }
+ }
+ }
+ if (post) quote._ldpQuoteFullPost = post;
+ return post;
+ }
+
+ function preserveReaderQuotePosition(quote, ctx, update) {
+ const scrollRoot = ctx?.scrollRoot;
+ if (!quote || !quote.isConnected || !scrollRoot) {
+ update();
+ return;
+ }
+ const anchorTop = quote.getBoundingClientRect().top;
+ scrollRoot.classList.add('ldp-quote-positioning');
+ try {
+ update();
+ const correction = quote.getBoundingClientRect().top - anchorTop;
+ if (Math.abs(correction) >= 0.5) {
+ const previousScrollTop = scrollRoot.scrollTop;
+ scrollRoot.scrollTop += correction;
+ markStreamInternalScroll(ctx, previousScrollTop);
+ }
+ } finally {
+ requestAnimationFrame(() => scrollRoot.classList.remove('ldp-quote-positioning'));
+ }
+ }
+
+ function syncReaderExpandableLayout(element, ctx) {
+ const post = element?.closest('.ldp-post');
+ if (!post || !ctx) return;
+ bindPostMediaLayoutSync(post, ctx);
+ scheduleNestedBranchSync(post, ctx);
+ const owner = streamOwnerPostNode(ctx, post);
+ const item = owner && ctx.streamItemMap.get(streamPostNumber(owner));
+ if (!owner || !item) return;
+ const sync = () => {
+ if (!owner.isConnected) return;
+ const measured = owner.getBoundingClientRect().height || owner.offsetHeight || 0;
+ const height = Math.max(Math.round(measured), STREAM_PLACEHOLDER_MIN_HEIGHT);
+ if (Math.abs(height - item.height) >= 1) {
+ item.height = height;
+ const index = ctx.onlyOp && Number.isInteger(item.layoutIndex) ? item.layoutIndex : item.index;
+ markStreamPrefixDirty(ctx, index);
+ ctx.streamNeedsRepair = true;
+ }
+ scheduleStreamWindowSync(ctx);
+ };
+ requestAnimationFrame(() => {
+ sync();
+ requestAnimationFrame(sync);
+ });
+ }
+
+ function syncReaderQuoteLayout(quote, ctx) {
+ syncReaderExpandableLayout(quote, ctx);
+ }
+
+ function readerQuoteExcerptText(quote) {
+ if (!quote) return '';
+ const body = quote.querySelector(':scope > blockquote');
+ if (body && quote.dataset.ldpQuoteExpanded !== '1') return String(body.textContent || '').trim();
+ const html = quote._ldpQuoteExcerptHtml != null
+ ? quote._ldpQuoteExcerptHtml
+ : body?.innerHTML;
+ const template = makeElement('template');
+ template.innerHTML = String(html || '');
+ return String(template.content.textContent || '').trim();
+ }
+
+ function readerQuoteSourceFromJump(jump, ctx) {
+ const preview = jump?.closest('.ldp-floor-preview');
+ const sourcePost = jump && (jump.closest('.ldp-post') || (preview?._ldpRelatedPost));
+ const postNumber = +(sourcePost && sourcePost.dataset.postNumber || 0);
+ const topicId = +(ctx?.topicId || 0);
+ if (!sourcePost || !postNumber || !topicId) return null;
+ const nested = !!(sourcePost.parentElement && sourcePost.parentElement.classList.contains('ldp-children'));
+ return {
+ topicId,
+ postNumber,
+ parentPostNumber: nested ? +(sourcePost.dataset.replyToPostNumber || 0) : 0,
+ nested,
+ anchor: captureReaderAnchorState(CURRENT_OVERLAY),
+ };
+ }
+
+ function quoteHighlightFromJump(jump, ctx) {
+ const quote = jump?.closest('.ldp-post-quote');
+ const text = readerQuoteExcerptText(quote);
+ return text ? { text, source: readerQuoteSourceFromJump(jump, ctx) } : null;
+ }
+
+ function discourseComparableQuoteText(value) {
+ return String(value || '').replace(/[ \t\r\n]/g, '');
+ }
+
+ function markReaderTextMatch(root, comparableText, excludedSelector, className) {
+ const characters = [];
+ const positions = [];
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let textNode = walker.nextNode();
+ while (textNode) {
+ const parent = textNode.parentElement;
+ if (parent && !parent.closest(excludedSelector)) {
+ const value = textNode.nodeValue || '';
+ for (let offset = 0; offset < value.length; offset++) {
+ const character = value[offset];
+ if (/[ \t\r\n]/.test(character)) continue;
+ characters.push(character);
+ positions.push({ node: textNode, offset });
+ }
+ }
+ textNode = walker.nextNode();
+ }
+ const matchStart = characters.join('').indexOf(comparableText);
+ if (matchStart < 0) return [];
+ const matchEnd = matchStart + comparableText.length;
+ const groups = [];
+ for (let index = matchStart; index < matchEnd; index++) {
+ const position = positions[index];
+ if (!position) continue;
+ const previous = groups[groups.length - 1];
+ if (previous && previous.node === position.node) previous.end = position.offset + 1;
+ else groups.push({ node: position.node, start: position.offset, end: position.offset + 1 });
+ }
+ const marks = [];
+ for (let index = groups.length - 1; index >= 0; index--) {
+ const group = groups[index];
+ const range = document.createRange();
+ range.setStart(group.node, group.start);
+ range.setEnd(group.node, group.end);
+ const mark = makeElement('mark');
+ mark.className = className;
+ range.surroundContents(mark);
+ marks.unshift(mark);
+ }
+ return marks;
+ }
+
+ function highlightReaderExpandedQuote(quote) {
+ const body = quote?.querySelector(':scope > blockquote');
+ const comparableQuote = discourseComparableQuoteText(readerQuoteExcerptText(quote));
+ if (!body || !comparableQuote) return false;
+ return markReaderTextMatch(body, comparableQuote,
+ 'button,script,style,noscript,.ldp-quote-title', 'ldp-expanded-quote-match').length > 0;
+ }
+
+ function clearReaderQuoteHighlight(ctx) {
+ const state = ctx?.quoteHighlightState;
+ if (!state) return;
+ if (state.hintHideTimer) clearTimeout(state.hintHideTimer);
+ if (state.hint) state.hint.remove();
+ (state.marks || []).forEach((mark) => {
+ if (!mark || !mark.parentNode) return;
+ const parent = mark.parentNode;
+ mark.replaceWith(document.createTextNode(mark.textContent || ''));
+ parent.normalize();
+ });
+ ctx.quoteHighlightState = null;
+ }
+
+ function setReaderQuoteHighlightActive(ctx, active) {
+ const state = ctx?.quoteHighlightState;
+ if (!state) return;
+ state.active = active !== false;
+ const hintText = state.active ? '点击关闭高亮' : '点击继续高亮';
+ state.marks.forEach((mark) => mark.classList.toggle('ldp-quote-match-muted', !state.active));
+ if (state.lastMark) {
+ setPressed(state.lastMark, state.active);
+ setLabel(state.lastMark, hintText);
+ }
+ if (state.toggleControl) state.toggleControl.textContent = state.active ? '关闭高亮' : '继续高亮';
+ }
+
+ function applyReaderQuoteHighlight(postNumber, ctx, quoteText, options = {}) {
+ clearReaderQuoteHighlight(ctx);
+ const comparableQuote = discourseComparableQuoteText(quoteText);
+ const target = getStreamPostNode(ctx, postNumber, { mount: true }) ||
+ ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${postNumber}"]`);
+ if (!target || !comparableQuote) return false;
+ hydratePostContent(target, ctx);
+ const content = target.querySelector(':scope > .ldp-content');
+ if (!content) return false;
+
+ const marks = markReaderTextMatch(content, comparableQuote,
+ 'button,script,style,noscript,.ldp-quote-highlight-close', 'ldp-quote-match');
+ if (!marks.length) {
+ showSelectionToast('引用原文可能已修改,未找到匹配内容');
+ return false;
+ }
+
+ const firstMark = marks[0];
+ const lastMark = marks[marks.length - 1];
+ lastMark.classList.add('ldp-quote-match-end');
+ lastMark.tabIndex = 0;
+ lastMark.setAttribute('role', 'button');
+ lastMark.setAttribute('data-tooltip', '');
+ const hint = makeElement('span');
+ hint.className = 'ldp-quote-highlight-hint ldp-action-surface';
+ const toggleControl = makeElement('button');
+ toggleControl.type = 'button';
+ toggleControl.className = 'ldp-quote-highlight-toggle';
+ hint.append(toggleControl);
+ const source = options.source || null;
+ let returnControl = null;
+ if (source) {
+ returnControl = makeElement('button');
+ returnControl.type = 'button';
+ returnControl.className = 'ldp-quote-highlight-return';
+ returnControl.textContent = source.nested ? '返回二级回复' : '返回引用楼层';
+ hint.append(returnControl);
+ }
+ appendReaderPortalNode(hint);
+ ctx.quoteHighlightState = {
+ marks,
+ lastMark,
+ hint,
+ toggleControl,
+ active: options.active !== false,
+ postNumber: +postNumber,
+ quoteText: String(quoteText || ''),
+ source,
+ hintHideTimer: 0,
+ };
+ const toggleHighlight = (event) => {
+ consumeEvent(event);
+ cancelHintHide();
+ const state = ctx.quoteHighlightState;
+ if (state) setReaderQuoteHighlightActive(ctx, !state.active);
+ };
+ const cancelHintHide = () => {
+ const state = ctx.quoteHighlightState;
+ if (!state || !state.hintHideTimer) return;
+ clearTimeout(state.hintHideTimer);
+ state.hintHideTimer = 0;
+ };
+ const showHint = (mark, event) => {
+ const state = ctx.quoteHighlightState;
+ if (!state || !mark) return;
+ cancelHintHide();
+ state.hint.classList.add('ldp-quote-hint-visible');
+ const markRect = mark.getBoundingClientRect();
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const hintRect = state.hint.getBoundingClientRect();
+ const anchorX = Number.isFinite(event?.clientX) ? event.clientX : markRect.left + markRect.width / 2;
+ const anchorY = Number.isFinite(event?.clientY) ? event.clientY : markRect.top;
+ const edge = 8;
+ const gap = 10;
+ const left = Math.max(edge, Math.min(anchorX - hintRect.width / 2, window.innerWidth - hintRect.width - edge));
+ const showBelow = anchorY - hintRect.height - gap < rootRect.top + edge;
+ const top = showBelow ? anchorY + gap : anchorY - hintRect.height - gap;
+ state.hint.style.left = `${Math.round(left)}px`;
+ state.hint.style.top = `${Math.round(Math.max(edge, top))}px`;
+ };
+ const scheduleHintHide = () => {
+ const state = ctx.quoteHighlightState;
+ if (!state) return;
+ cancelHintHide();
+ state.hintHideTimer = setTimeout(() => {
+ state.hintHideTimer = 0;
+ if (state.hint.matches(':hover,:focus-within') || state.marks.some((mark) => mark.matches(':hover'))) return;
+ state.hint.classList.remove('ldp-quote-hint-visible');
+ }, 160);
+ };
+ marks.forEach((mark) => {
+ mark.addEventListener('click', toggleHighlight);
+ mark.addEventListener('mouseenter', (event) => showHint(mark, event));
+ mark.addEventListener('mouseleave', scheduleHintHide);
+ });
+ hint.addEventListener('mouseenter', cancelHintHide);
+ hint.addEventListener('pointerdown', cancelHintHide);
+ hint.addEventListener('mouseleave', scheduleHintHide);
+ hint.addEventListener('focusout', scheduleHintHide);
+ toggleControl.addEventListener('click', toggleHighlight);
+ if (returnControl) {
+ returnControl.addEventListener('click', async (event) => {
+ consumeEvent(event);
+ cancelHintHide();
+ if (returnControl.disabled) return;
+ hint.classList.remove('ldp-quote-hint-visible');
+ returnControl.blur();
+ returnControl.disabled = true;
+ const originalText = returnControl.textContent;
+ returnControl.textContent = '返回中…';
+ const returned = await returnToReaderQuoteSource(source, ctx);
+ if (!returned) showSelectionToast('暂时无法返回引用位置');
+ if (returnControl.isConnected) {
+ returnControl.disabled = false;
+ returnControl.textContent = originalText;
+ }
+ });
+ }
+ lastMark.addEventListener('keydown', (event) => {
+ if (event.target === lastMark && (event.key === 'Enter' || event.key === ' ')) toggleHighlight(event);
+ });
+ lastMark.addEventListener('focus', (event) => showHint(lastMark, event));
+ lastMark.addEventListener('blur', scheduleHintHide);
+ setReaderQuoteHighlightActive(ctx, options.active !== false);
+ return firstMark;
+ }
+
+ function restoreReaderQuoteHighlightForPost(post, ctx) {
+ const state = ctx?.quoteHighlightState;
+ const postNumber = +(post && post.dataset.postNumber);
+ if (!state || !postNumber || +state.postNumber !== postNumber || !state.quoteText) return;
+ if (state.marks?.length && state.marks.every((mark) => post.contains(mark))) return;
+ applyReaderQuoteHighlight(postNumber, ctx, state.quoteText, { active: state.active, source: state.source });
+ }
+
+ function readerMediaHtml(value, options = {}) {
+ const template = makeElement('template');
+ template.innerHTML = String(value || '');
+ prepareReaderHashtags(template.content);
+ if (options.inlineOneboxes) prepareReaderInlineOneboxes(template.content);
+ prepareReaderOneboxes(template.content);
+ prepareReaderCodeBlocks(template.content);
+ prepareReaderMedia(template.content, { interactive: false });
+ return template.innerHTML;
+ }
+
+ function normalizedPostLinkUrl(value) {
+ try {
+ return new URL(String(value || '').trim(), BASE).href;
+ } catch {
+ return '';
+ }
+ }
+
+ function prepareReaderInlineOneboxes(root) {
+ if (!root || !isFunction(root.querySelectorAll)) return;
+ root.querySelectorAll('a.inline-onebox').forEach((link) => {
+ if (link.querySelector(':scope > .ldp-inline-onebox-label')) return;
+ const labelNodes = [...link.childNodes].filter((node) =>
+ !(node.nodeType === Node.ELEMENT_NODE && node.matches('svg,.svg-icon,.ldp-link-click-count')));
+ if (!labelNodes.some((node) => String(node.textContent || '').trim())) return;
+ const label = makeElement('span');
+ label.className = 'ldp-inline-onebox-label';
+ labelNodes.forEach((node) => label.append(node));
+ const linkIcon = [...link.children].find((child) => child.matches('svg,.svg-icon'));
+ if (linkIcon) linkIcon.after(label);
+ else link.prepend(label);
+ });
+ }
+
+ function prepareReaderOneboxes(root) {
+ if (!root || !isFunction(root.querySelectorAll)) return;
+ const githubSelector = 'aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"])';
+ root.querySelectorAll(githubSelector).forEach((onebox) => {
+ if (onebox.dataset.ldpGithubOneboxNormalized === '1') return;
+ const header = onebox.querySelector(':scope > header.source');
+ const body = onebox.querySelector(':scope > article.onebox-body');
+ const title = body?.querySelector('h3');
+ if (!header || !body || !title) return;
+ const description = [...body.querySelectorAll('p')].find((paragraph) =>
+ !paragraph.matches('.onebox-metadata') && !paragraph.closest('.onebox-metadata')
+ );
+ const thumbnail = body.querySelector('img.thumbnail');
+ if (thumbnail) {
+ header.querySelectorAll(':scope > :is(img,.site-icon)').forEach((iconNode) => iconNode.remove());
+ thumbnail.className = 'site-icon ldp-github-onebox-logo';
+ thumbnail.removeAttribute('width');
+ thumbnail.removeAttribute('height');
+ thumbnail.alt = '';
+ header.prepend(thumbnail);
+ }
+ body.replaceChildren(title, ...(description ? [description] : []));
+ onebox.dataset.ldpGithubOneboxNormalized = '1';
+ });
+ }
+
+ function decoratePostLinkClickCounts(root, linkCounts) {
+ if (!root || !Array.isArray(linkCounts) || !linkCounts.length) return;
+ const countsByUrl = new Map();
+ linkCounts.forEach((item) => {
+ const clicks = Math.max(0, Math.trunc(Number(item?.clicks) || 0));
+ const url = !item || item.reflection ? '' : normalizedPostLinkUrl(item.url);
+ if (!url || !clicks) return;
+ countsByUrl.set(url, Math.max(clicks, countsByUrl.get(url) || 0));
+ });
+ if (!countsByUrl.size) return;
+ root.querySelectorAll('a[href]').forEach((link) => {
+ if (link.querySelector(':scope > .ldp-link-click-count')) return;
+ const onebox = link.closest('aside.onebox');
+ if (onebox && link.closest('header.source')) {
+ const titleLink = onebox.querySelector('.onebox-body h3 a[href]');
+ if (titleLink && normalizedPostLinkUrl(titleLink.getAttribute('href')) === normalizedPostLinkUrl(link.getAttribute('href'))) return;
+ }
+ const clicks = countsByUrl.get(normalizedPostLinkUrl(link.getAttribute('href')));
+ if (!clicks || !String(link.textContent || '').trim()) return;
+ const count = makeElement('span');
+ const label = `${clicks.toLocaleString('zh-CN')} 次点击`;
+ count.className = 'ldp-link-click-count';
+ count.setAttribute('role', 'note');
+ setLabel(count, label);
+ count.dataset.ldpTooltipLabel = label;
+ count.textContent = clicks.toLocaleString('zh-CN');
+ link.append(count);
+ });
+ }
+
+ function setPostCookedContent(content, cooked, linkCounts) {
+ const template = makeElement('template');
+ template.innerHTML = String(cooked || '');
+ prepareReaderHashtags(template.content);
+ prepareReaderUserMentions(template.content);
+ prepareReaderInlineOneboxes(template.content);
+ prepareReaderOneboxes(template.content);
+ prepareReaderCallouts(template.content);
+ prepareReaderPostQuotes(template.content);
+ prepareReaderCodeBlocks(template.content);
+ prepareReaderMedia(template.content);
+ decoratePostLinkClickCounts(template.content, linkCounts);
+ destroyReaderMedia(content);
+ content.replaceChildren(template.content);
+ activateReaderMedia(content);
+ }
+
+ /* ============ 正文翻译(KISS Translator 架构参考,独立实现) ============ */
+ const READER_TRANSLATION_CACHE_KEY = 'awesome-linuxdo-reader:translations:zh-CN:v1';
+ const readReaderTranslationCache = () => {
+ try {
+ const entries = JSON.parse(localStorage.getItem(READER_TRANSLATION_CACHE_KEY) || '[]');
+ return new Map(Array.isArray(entries) ? entries.slice(-240) : []);
+ } catch { return new Map(); }
+ };
+ const READER_TRANSLATION_CACHE = readReaderTranslationCache();
+ let readerTranslationCacheTimer = 0;
+ function persistReaderTranslationCache() {
+ if (readerTranslationCacheTimer) clearTimeout(readerTranslationCacheTimer);
+ readerTranslationCacheTimer = 0;
+ const entries = [...READER_TRANSLATION_CACHE].slice(-240);
+ while (entries.length) {
+ try {
+ localStorage.setItem(READER_TRANSLATION_CACHE_KEY, JSON.stringify(entries));
+ return;
+ } catch {
+ entries.splice(0, Math.min(20, entries.length));
+ }
+ }
+ try { localStorage.removeItem(READER_TRANSLATION_CACHE_KEY); } catch {}
+ }
+ window.addEventListener('pagehide', persistReaderTranslationCache);
+ const READER_TRANSLATION_BLOCKS = 'p,li,blockquote,h1,h2,h3,h4,h5,h6,figcaption,td,th';
+ const READER_TRANSLATION_EXCLUDES =
+ 'pre,code,kbd,samp,script,style,textarea,.onebox,.poll,.ldp-post-quote,.katex,.MathJax,.math,.ldp-translation-text';
+
+ function readerTranslationSourceText(node) {
+ if (!node) return '';
+ const clone = node.cloneNode(true);
+ clone.querySelectorAll(READER_TRANSLATION_EXCLUDES).forEach((item) => item.remove());
+ return String(clone.textContent || '').replace(/\s+/g, ' ').trim();
+ }
+
+ function readerTranslationBlocks(content) {
+ if (!content) return [];
+ const candidates = [...content.querySelectorAll(READER_TRANSLATION_BLOCKS)]
+ .filter((node) => !node.closest(READER_TRANSLATION_EXCLUDES));
+ return candidates.filter((node) => {
+ if (candidates.some((other) => other !== node && node.contains(other))) return false;
+ return readerTranslationSourceText(node).length > 1;
+ });
+ }
+
+ function readerTextIsChinese(text) {
+ const letters = text.match(/\p{L}/gu) || [];
+ const han = text.match(/\p{Script=Han}/gu) || [];
+ const kanaOrHangul = text.match(/[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu) || [];
+ return han.length >= 4 && kanaOrHangul.length < 2 && han.length / Math.max(1, letters.length) >= 0.45;
+ }
+
+ function readerBlockNeedsTranslation(text) {
+ const letters = text.match(/\p{L}/gu) || [];
+ if (letters.length < 2 || readerTextIsChinese(text)) return false;
+ if (/^(?:RFC|ISO|IEC|IEEE|ECMA|W3C|WHATWG)\s*[-#:./]?\s*\d[\w./-]*$/i.test(text)) return false;
+ if (/^[\w.-]{1,32}(?:\(\))?$/.test(text) &&
+ (/[_-]/.test(text) || /^[A-Z\d.]+$/.test(text) || /[a-z][A-Z]/.test(text))) return false;
+ if (/[=±×÷∑∏∫√≈≠≤≥→←↔^]/.test(text) && letters.length / text.length < 0.45) return false;
+ if (/^(?:https?:\/\/|www\.|[@#])\S+$/i.test(text)) return false;
+ const words = text.match(/\p{L}+(?:['’.-]\p{L}+)*/gu) || [];
+ const sentenceLike = words.length >= 4 || text.length >= 32 || /[.!?。!?][”"'’)]?$/.test(text);
+ if (!sentenceLike) return false;
+ if (words.length <= 6 && words.length > 1 &&
+ words.every((word) => /^\p{Lu}[\p{Ll}\p{M}]*$/u.test(word))) return false;
+ return true;
+ }
+
+ function setReaderTranslationCache(text, translation) {
+ READER_TRANSLATION_CACHE.delete(text);
+ READER_TRANSLATION_CACHE.set(text, translation);
+ while (READER_TRANSLATION_CACHE.size > 240) {
+ READER_TRANSLATION_CACHE.delete(READER_TRANSLATION_CACHE.keys().next().value);
+ }
+ if (readerTranslationCacheTimer) clearTimeout(readerTranslationCacheTimer);
+ readerTranslationCacheTimer = setTimeout(persistReaderTranslationCache, 300);
+ }
+
+ function getReaderTranslationCache(text) {
+ const translation = READER_TRANSLATION_CACHE.get(text);
+ if (translation == null) return null;
+ READER_TRANSLATION_CACHE.delete(text);
+ READER_TRANSLATION_CACHE.set(text, translation);
+ return translation;
+ }
+
+ function createReaderTranslationController(ctx, button) {
+ if (!siteAllowsBodyTranslation()) {
+ button.hidden = true;
+ return { syncPost() {}, cycleMode() {}, destroy() {} };
+ }
+ const queue = new Map();
+ const requests = new Set();
+ let mode = ['bilingual', 'translation'].includes(PREFS.translationMode)
+ ? PREFS.translationMode : 'original';
+ let active = mode !== 'original';
+ let draining = false;
+ let destroyed = false;
+ let lastRequestAt = 0;
+ let microsoftToken = '';
+ let microsoftTokenAt = 0;
+
+ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
+ const syncUi = () => {
+ const busy = draining && active;
+ button.classList.toggle('is-active', active);
+ button.classList.toggle('is-busy', busy);
+ button.setAttribute('aria-busy', String(busy));
+ setPressed(button, active);
+ setLabel(button, active
+ ? `正文翻译:${mode === 'translation' ? '全译文' : '双语显示'}`
+ : '翻译正文');
+ };
+ const applyMode = () => {
+ ctx.commentsEl.classList.toggle('ldp-translation-active', active);
+ ctx.commentsEl.classList.toggle('ldp-translation-only', active && mode === 'translation');
+ syncUi();
+ };
+ const translationRequest = (requestOptions) => {
+ const request = globalThis.GM_xmlhttpRequest;
+ if (!isFunction(request)) return Promise.reject(new Error('油猴未提供跨域请求能力'));
+ return new Promise((resolve, reject) => {
+ let handle = null;
+ const finish = (callback, value) => {
+ if (handle) requests.delete(handle);
+ callback(value);
+ };
+ handle = request({
+ timeout: 20000,
+ ...requestOptions,
+ onload: (response) => {
+ if (response.status >= 200 && response.status < 300) finish(resolve, response);
+ else finish(reject, new Error(`翻译服务返回 ${response.status || '未知状态'}`));
+ },
+ ontimeout: () => finish(reject, new Error('翻译请求超时')),
+ onabort: () => finish(reject, new Error('翻译请求已取消')),
+ onerror: () => finish(reject, new Error('翻译请求失败')),
+ });
+ if (handle) requests.add(handle);
+ });
+ };
+ const translateWithGoogle = async (texts) => {
+ const url = new URL('https://translate.googleapis.com/translate_a/t');
+ url.searchParams.set('client', 'dict-chrome-ex');
+ url.searchParams.set('sl', 'auto');
+ url.searchParams.set('tl', 'zh-CN');
+ texts.forEach((text) => url.searchParams.append('q', text));
+ const response = await translationRequest({ method: 'GET', url: url.href });
+ const payload = JSON.parse(response.responseText || '[]');
+ const translations = payload.map((item) => String(Array.isArray(item) ? item[0] || '' : ''));
+ if (translations.length !== texts.length || translations.some((text) => !text)) {
+ throw new Error('Google 返回的译文不完整');
+ }
+ return translations;
+ };
+ const microsoftAccessToken = async () => {
+ if (microsoftToken && Date.now() - microsoftTokenAt < 8 * MINUTE) return microsoftToken;
+ const response = await translationRequest({
+ method: 'GET',
+ url: 'https://edge.microsoft.com/translate/auth',
+ });
+ microsoftToken = String(response.responseText || '').trim();
+ microsoftTokenAt = Date.now();
+ if (!microsoftToken) throw new Error('Microsoft 未返回访问令牌');
+ return microsoftToken;
+ };
+ const translateWithMicrosoft = async (texts) => {
+ const token = await microsoftAccessToken();
+ const response = await translationRequest({
+ method: 'POST',
+ url: 'https://api-edge.cognitive.microsofttranslator.com/translate?api-version=3.0&to=zh-Hans',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ data: JSON.stringify(texts.map((text) => ({ Text: text }))),
+ });
+ const payload = JSON.parse(response.responseText || '[]');
+ const translations = payload.map((item) => String(item?.translations?.[0]?.text || ''));
+ if (translations.length !== texts.length || translations.some((text) => !text)) {
+ throw new Error('Microsoft 返回的译文不完整');
+ }
+ return translations;
+ };
+ const translateBatch = async (texts) => {
+ const largeBatch = texts.reduce((length, text) => length + text.length, 0) > 2800;
+ const providers = largeBatch
+ ? [translateWithMicrosoft, translateWithGoogle]
+ : [translateWithGoogle, translateWithMicrosoft];
+ let failure = null;
+ for (let attempt = 0; attempt < providers.length && !destroyed; attempt++) {
+ const wait = Math.max(0, 850 - (Date.now() - lastRequestAt));
+ if (wait) await delay(wait);
+ lastRequestAt = Date.now();
+ try {
+ return await providers[attempt](texts);
+ } catch (error) {
+ failure = error;
+ if (attempt < providers.length - 1) await delay(1200 * (2 ** attempt));
+ }
+ }
+ throw failure || new Error('翻译服务不可用');
+ };
+ const attachTranslation = (node, source, translation) => {
+ if (!node || readerTranslationSourceText(node) !== source) return;
+ let original = node.querySelector(':scope > .ldp-translation-original');
+ let output = node.querySelector(':scope > .ldp-translation-text');
+ if (!original) {
+ original = makeElement('span');
+ original.className = 'ldp-translation-original';
+ while (node.firstChild) original.append(node.firstChild);
+ node.append(original);
+ }
+ if (!output) {
+ output = makeElement('span');
+ output.className = 'ldp-translation-text';
+ node.append(output);
+ }
+ node.classList.add('ldp-translation-source');
+ output.lang = 'zh-CN';
+ output.textContent = translation;
+ };
+ const queueBlock = (node) => {
+ const text = readerTranslationSourceText(node);
+ if (!readerBlockNeedsTranslation(text)) return;
+ const cached = getReaderTranslationCache(text);
+ if (cached != null) {
+ attachTranslation(node, text, cached);
+ return;
+ }
+ const entry = queue.get(text) || { text, nodes: new Set() };
+ entry.nodes.add(node);
+ queue.set(text, entry);
+ };
+ const nextBatch = () => {
+ const entries = [];
+ let characters = 0;
+ for (const entry of queue.values()) {
+ if (entries.length && (entries.length >= 20 || characters + entry.text.length > 3500)) break;
+ queue.delete(entry.text);
+ entries.push(entry);
+ characters += entry.text.length;
+ }
+ return entries;
+ };
+ const drain = async () => {
+ if (draining || destroyed || !active) return;
+ draining = true;
+ syncUi();
+ await delay(120);
+ try {
+ while (queue.size && active && !destroyed) {
+ const entries = nextBatch();
+ const translations = await translateBatch(entries.map((entry) => entry.text));
+ entries.forEach((entry, index) => {
+ const translation = translations[index];
+ setReaderTranslationCache(entry.text, translation);
+ entry.nodes.forEach((node) => attachTranslation(node, entry.text, translation));
+ });
+ syncUi();
+ }
+ } catch (error) {
+ showSelectionToast(`${error.message || '翻译失败'},请稍后重试`);
+ } finally {
+ draining = false;
+ syncUi();
+ }
+ };
+ const syncPost = (post) => {
+ if (!post || destroyed) return;
+ const postData = post._ldpPostData || {};
+ const postType = +(postData.post_type == null ? 1 : postData.post_type);
+ const username = String(postData.username || post.dataset.username || '').toLocaleLowerCase();
+ if (postType !== 1 || postData.action_code || username === 'system' || username === 'discobot') return;
+ const contents = [
+ post.querySelector(':scope > .ldp-content'),
+ ...post.querySelectorAll(':scope > .ldp-solved-card .ldp-solved-excerpt.ldp-content'),
+ ].filter(Boolean);
+ if (!contents.length || post.dataset.ldpContentHydrated === '0') return;
+ const blocks = contents.flatMap(readerTranslationBlocks);
+ if (!active) return;
+ blocks.forEach(queueBlock);
+ applyMode();
+ void drain();
+ };
+ const syncMountedPosts = () => {
+ ctx.commentsEl.querySelectorAll('.ldp-post').forEach(syncPost);
+ };
+ const setMode = (nextMode) => {
+ setPref('translationMode', nextMode);
+ if (nextMode === 'original') {
+ active = false;
+ queue.clear();
+ } else {
+ active = true;
+ mode = nextMode === 'translation' ? 'translation' : 'bilingual';
+ syncMountedPosts();
+ }
+ applyMode();
+ };
+ const cycleMode = () => {
+ const nextMode = !active ? 'bilingual' : mode === 'bilingual' ? 'translation' : 'original';
+ setMode(nextMode);
+ showSelectionToast(nextMode === 'bilingual'
+ ? '正文翻译:双语显示'
+ : nextMode === 'translation' ? '正文翻译:全译文' : '已恢复原文');
+ };
+ const destroy = () => {
+ destroyed = true;
+ active = false;
+ queue.clear();
+ requests.forEach((request) => request?.abort?.());
+ requests.clear();
+ ctx.commentsEl.classList.remove('ldp-translation-active', 'ldp-translation-only');
+ button.hidden = true;
+ button.classList.remove('is-active', 'is-busy');
+ };
+ button.hidden = false;
+ applyMode();
+ requestAnimationFrame(syncMountedPosts);
+ return { syncPost, cycleMode, destroy };
+ }
+
+ function renderNewUserBadge(postNode) {
+ if (!postNode) return;
+ const post = postNode._ldpPostData || {};
+ const noticeType = postNoticeType(post);
+ const isSystem = +post.post_type === 3 || String(post.username || '').trim().toLocaleLowerCase() === 'system';
+ const isModerator = post.moderator === true || post.group_moderator === true;
+ const identity = isSystem
+ ? { label: '系统', title: '系统账户', icon: 'settings' }
+ : isModerator
+ ? { label: '版主', title: '版主', icon: 'shieldHalved' }
+ : postNoticeIdentity(post, noticeType);
+ const isNewUser = noticeType === 'new_user';
+ postNode.classList.toggle('ldp-new-user', isNewUser);
+ const head = postNode.querySelector(':scope > .ldp-post-head');
+ let badge = head?.querySelector(':scope > .ldp-new-user-badge');
+ if (!identity) {
+ if (badge) badge.remove();
+ return;
+ }
+ if (!badge && head) {
+ badge = makeElement('span');
+ badge.className = 'ldp-new-user-badge';
+ const username = head.querySelector(':scope > .ldp-user');
+ if (username) username.after(badge);
+ else head.append(badge);
+ }
+ if (badge) {
+ badge.setAttribute('role', 'img');
+ setLabel(badge, identity.title);
+ badge.innerHTML = `${icon(identity.icon)}
${identity.label}`;
+ }
+ }
+
+ function renderPostSpecialContent(postNode, ctx) {
+ renderNewUserBadge(postNode);
+ renderSpecialPostState(postNode);
+ renderSolvedAnswerCard(postNode, ctx);
+ renderPostReactions(postNode, ctx);
+ renderPostEvent(postNode);
+ renderPostVoting(postNode, ctx);
+ const post = postNode?._ldpPostData || {};
+ if (post.accepted_answer && ctx && ctx.streamNodeMap) {
+ const firstPost = ctx.streamNodeMap.get(1);
+ if (firstPost && firstPost !== postNode) renderSolvedAnswerCard(firstPost, ctx);
+ }
+ hydratePersistentAvatarImages(postNode);
+ }
+
+ function comparePostVotingStreamItems(left, right) {
+ if (left.postNumber === 1 || right.postNumber === 1)
+ return left.postNumber === right.postNumber ? 0 : (left.postNumber === 1 ? -1 : 1);
+ return +(right.node._ldpPostData.post_voting_vote_count || 0) -
+ +(left.node._ldpPostData.post_voting_vote_count || 0) || left.postNumber - right.postNumber;
+ }
+
+ function syncSpecialPostData(postNode, ctx, fresh) {
+ if (!postNode || !fresh) return;
+ let latestPostData = null;
+ readerPostCopies(postNode.dataset.postId, ctx, true).forEach((copy) => {
+ const previous = copy._ldpPostData || {};
+ copy._ldpPostData = Object.assign({}, previous, fresh);
+ latestPostData = copy._ldpPostData;
+ renderPostSpecialContent(copy, ctx);
+ const like = readerPostLikeAction(copy._ldpPostData, ctx);
+ readerPostOwnControls(copy, '.ldp-like').forEach((button) => syncReaderLikeButton(button, like));
+ });
+ const railLike = ctx?.topicActionRail?.querySelector('.ldp-like');
+ if (railLike && latestPostData && +latestPostData.post_number === 1) {
+ ctx.topicActionRail._ldpPostData = latestPostData;
+ syncReaderLikeButton(railLike, readerPostRailReactionAction(latestPostData, ctx));
+ renderPostReactions(ctx.topicActionRail, ctx, { collectLike: false, railPicker: true });
+ ctx.topicActionRail.querySelector(':scope > .ldp-reactions > .ldp-reaction-summary')?.remove();
+ }
+ if (latestPostData) persistReaderPostState(ctx, latestPostData);
+ if (ctx?.isPostVoting) {
+ ctx.streamItems.sort(comparePostVotingStreamItems);
+ reindexStreamItems(ctx, 0);
+ invalidateStreamLayout(ctx);
+ scheduleStreamWindowSync(ctx);
+ }
+ }
+
+ async function fetchPostVotingComments(postNode, ctx) {
+ const postId = postNode && postNode.dataset.postId;
+ if (!postId) return [];
+ return runReaderActionOnce(ctx, `post:${postId}:pv-comments-load`, async () => {
+ const copies = readerPostCopies(postId, ctx);
+ const sourcePosts = (copies.length ? copies : [postNode]).map((copy) => copy._ldpPostData || {});
+ const loaded = sourcePosts.map(postVotingComments).sort((a, b) => b.length - a.length)[0] || [];
+ const lastId = loaded.length ? loaded[loaded.length - 1].id : '';
+ const url = new URL(`${BASE}/post_voting/comments`);
+ url.searchParams.set('post_id', postId);
+ if (lastId) url.searchParams.set('last_comment_id', lastId);
+ const data = await fetchJSON(url.toString());
+ const incoming = Array.isArray(data) ? data : (data.comments || []);
+ const merged = new Map(loaded.map((comment) => [String(comment.id), comment]));
+ incoming.forEach((comment) => merged.set(String(comment.id), comment));
+ const comments = [...merged.values()];
+ syncPostVotingComments(postId, ctx, comments);
+ return comments;
+ });
+ }
+
+ function normalizeBoosts(boosts) {
+ const list = Array.isArray(boosts) ? boosts : boosts ? [boosts] : [];
+ return list.map((boost) => {
+ if (!boost) return null;
+ const user = boost.user || {};
+ const raw = boost.raw || '';
+ const cooked = boost.cooked || (raw ? `
${esc(raw)}
` : '');
+ if (!cooked) return null;
+ const username = user.username || boost.username || ME_USERNAME || '';
+ return {
+ id: boost.id == null ? '' : String(boost.id),
+ userId: +(user.id || boost.user_id || 0),
+ username,
+ name: user.name || boost.name || username,
+ avatarTemplate: user.avatar_template || boost.avatar_template || boost.avatarTemplate || boost.avatar || '',
+ admin: user.admin === true || boost.admin === true,
+ moderator: user.moderator === true || user.group_moderator === true ||
+ boost.moderator === true || boost.group_moderator === true,
+ noticeType: String(boost.notice?.type || boost.notice_type || ''),
+ cooked,
+ };
+ }).filter(Boolean);
+ }
+
+ function boostBelongsToCurrentUser(boost) {
+ const currentUserId = +(ME_CURRENT_USER?.id || 0);
+ if (currentUserId && +boost.userId) return currentUserId === +boost.userId;
+ return !!(
+ ME_USERNAME && boost.username &&
+ String(boost.username).toLowerCase() === String(ME_USERNAME).toLowerCase()
+ );
+ }
+
+ function boostIdentities(boost, ctx) {
+ const identities = [];
+ const username = String(boost?.username || '');
+ const usernameKey = username.toLocaleLowerCase();
+ let sourcePost = {};
+ if (usernameKey && ctx) {
+ const cachedPosts = ctx.loader && Array.isArray(ctx.loader.cachedPosts) ? ctx.loader.cachedPosts : [];
+ sourcePost = cachedPosts.concat(postsFromPayload(ctx.topicData)).find((post) => {
+ if (String(post?.username || '').toLocaleLowerCase() !== usernameKey) return false;
+ return post.admin === true || post.moderator === true || post.group_moderator === true ||
+ ['new_user', 'returning_user', 'custom'].includes(postNoticeType(post));
+ }) || {};
+ }
+ const noticeType = postNoticeType(sourcePost) || boost.noticeType;
+ const isAdmin = boost.admin === true || sourcePost.admin === true;
+ const isModerator = boost.moderator === true || sourcePost.moderator === true ||
+ sourcePost.group_moderator === true;
+ if (boostBelongsToCurrentUser(boost))
+ identities.push({ type: 'me', label: 'ME', title: '当前用户', icon: 'userRound' });
+ if (usernameKey && String(ctx?.op || '').toLocaleLowerCase() === usernameKey)
+ identities.push({ type: 'op', label: 'OP', title: '楼主', icon: 'award' });
+ if (isAdmin)
+ identities.push({ type: 'admin', label: '管理员', title: '管理员', icon: 'shieldHalved' });
+ else if (isModerator)
+ identities.push({ type: 'moderator', label: '版主', title: '版主', icon: 'shieldHalved' });
+ const noticeIdentity = postNoticeIdentity(sourcePost, noticeType);
+ if (noticeIdentity) {
+ const type = noticeType === 'new_user' ? 'new' : noticeType === 'returning_user' ? 'return' : 'custom';
+ identities.push(Object.assign({ type }, noticeIdentity));
+ }
+ return identities;
+ }
+
+ function renderBoostIdentities(boost, ctx) {
+ const identities = boostIdentities(boost, ctx);
+ if (!identities.length) return '';
+ return `
${identities.map((identity) =>
+ `${icon(identity.icon)}${esc(identity.label)}`
+ ).join('')}`;
+ }
+
+ function renderBoostBubble(boost, allowQuickBoost = false, ctx = null) {
+ const userCardAttr = boost.username ? `data-user-card="${escAttr(boost.username)}"` : '';
+ const avatarLabel = boost.username ? ` aria-label="@${escAttr(boost.username)}"` : '';
+ const avatar = boost.avatarTemplate
+ ? persistentAvatarHtml(
+ boost.avatarTemplate, 24, 'ldp-boost-avatar', boost.name || boost.username || '?', { eager: true }
+ )
+ : icon('rocket', 'ldp-boost-fallback-icon');
+ const avatarHtml = boost.username
+ ? `
${avatar}`
+ : avatar;
+ const quickBoost = allowQuickBoost && boost.username && (!ME_USERNAME || boost.username !== ME_USERNAME)
+ ? '
'
+ : '';
+ const isOwnBoost = boostBelongsToCurrentUser(boost);
+ const signedIn = !!(ME_CURRENT_USER?.id || ME_USERNAME);
+ const mentionAction = signedIn && boost.username
+ ? `
`
+ : '';
+ const itemAction = signedIn && boost.id
+ ? (isOwnBoost
+ ? `
`
+ : `
`)
+ : '';
+ const identities = renderBoostIdentities(boost, ctx);
+ return `
${avatarHtml}${identities}${readerMediaHtml(boost.cooked)}${quickBoost}${mentionAction}${itemAction}`;
+ }
+
+ function renderBoostList(boosts, options = {}) {
+ const items = normalizeBoosts(boosts);
+ const allowQuickBoost = options.canQuickBoost === true &&
+ !items.some((boost) => ME_USERNAME && boost.username === ME_USERNAME);
+ return `
${items.map((boost) => renderBoostBubble(boost, allowQuickBoost, options.ctx)).join('')}
`;
+ }
+
+ function syncKnownBoostIdentities(ctx, usernames) {
+ if (!ctx || !ctx.modal) return;
+ const usernameKeys = new Set((Array.isArray(usernames) ? usernames : [usernames])
+ .map((username) => String(username || '').toLocaleLowerCase()).filter(Boolean));
+ if (!usernameKeys.size) return;
+ ctx.modal.querySelectorAll('.ldp-boost-bubble').forEach((bubble) => {
+ const username = String(bubble.dataset.boostUser || '');
+ if (!usernameKeys.has(username.toLocaleLowerCase())) return;
+ const boost = {
+ userId: +(bubble.dataset.boostUserId || 0),
+ username,
+ admin: bubble.dataset.boostAdmin === '1',
+ moderator: bubble.dataset.boostModerator === '1',
+ };
+ const holder = makeElement('template');
+ holder.innerHTML = renderBoostIdentities(boost, ctx);
+ const next = holder.content.firstElementChild;
+ const current = bubble.querySelector(':scope > .ldp-boost-identities');
+ if (current && next) current.replaceWith(next);
+ else if (current) current.remove();
+ else if (next) {
+ const avatar = bubble.querySelector(':scope > .ldp-boost-avatar-link,:scope > .ldp-boost-fallback-icon');
+ if (avatar) avatar.after(next);
+ else bubble.prepend(next);
+ }
+ });
+ }
+
+ function boostBubblePlainText(bubble) {
+ const cooked = bubble?.querySelector('.ldp-boost-cooked');
+ if (!cooked) return '';
+ const copy = cooked.cloneNode(true);
+ copy.querySelectorAll('img[alt]').forEach((image) => image.replaceWith(document.createTextNode(image.alt || '')));
+ return String(copy.textContent || '').replace(/\s+/g, ' ').trim();
+ }
+
+ function boostCopyRegExpEscape(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+
+ async function quoteBoostInNativeReply(bubble, post, ctx) {
+ const username = cleanUsernameValue(bubble?.dataset.boostUser);
+ const content = boostBubblePlainText(bubble);
+ if (!username || !content) {
+ showSelectionToast('无法读取该 Boost 的用户或内容');
+ return false;
+ }
+ if (!await tryOpenNativeReplyComposer(post, ctx)) return false;
+ const composer = lookupDiscourse('service:composer');
+ const reply = String(discourseModelValue(discourseModelValue(composer, 'model'), 'reply') || '');
+ const mentionPattern = new RegExp(
+ `(^|[^A-Za-z0-9_@-])@${boostCopyRegExpEscape(username)}(?=$|[^A-Za-z0-9_-])`, 'i'
+ );
+ if (mentionPattern.test(reply)) {
+ focusReaderNativeComposerInput(ctx);
+ showSelectionToast(`回复框中已有 @${username}`);
+ return true;
+ }
+ const quoteHeader = `${username}, post:${+post.dataset.postNumber}, topic:${+ctx.topicId}, username:${username}`;
+ const insertion = `[quote="${quoteHeader}"]\n${content}\n[/quote]\n\n@${username} `;
+ const appEvents = composer.appEvents || lookupDiscourse('service:app-events');
+ appEvents.trigger('composer:insert-block', insertion);
+ focusReaderNativeComposerInput(ctx);
+ showSelectionToast(`已引用 Boost 并 @${username}`);
+ return true;
+ }
+
+ function fitBoostCopyParts(prefix, base, suffix) {
+ const suffixChars = [...suffix].slice(0, BOOST_COPY_MAX_LENGTH);
+ const prefixChars = [...prefix].slice(0, BOOST_COPY_MAX_LENGTH - suffixChars.length);
+ const baseChars = [...base].slice(0, BOOST_COPY_MAX_LENGTH - suffixChars.length - prefixChars.length);
+ return `${prefixChars.join('')}${baseChars.join('')}${suffixChars.join('')}`;
+ }
+
+ function applyBoostCopyRule(raw, prefs = PREFS) {
+ const config = boostCopyConfigFromPrefs(prefs);
+ let base = String(raw || '').replace(/\s+/g, ' ').trim();
+ if (config.prefix && base.startsWith(config.prefix)) base = base.slice(config.prefix.length);
+ let suffix = config.fixedSuffix;
+ if (config.mode === 'counter') {
+ const countPattern = new RegExp(`${boostCopyRegExpEscape(config.counterMarker)}(\\d+)$`);
+ const countMatch = base.match(countPattern);
+ const currentCount = countMatch ? Number(countMatch[1]) : 0;
+ const nextCount = Number.isSafeInteger(currentCount)
+ ? currentCount + config.counterStep
+ : config.counterStep;
+ if (countMatch) base = base.slice(0, -countMatch[0].length);
+ suffix = `${config.counterMarker}${nextCount}`;
+ } else if (suffix && base.endsWith(suffix))
+ base = base.slice(0, -suffix.length);
+ return fitBoostCopyParts(config.prefix, base, suffix);
+ }
+
+ function syncCurrentUserFromHost(options = {}) {
+ const currentUser = lookupDiscourse('service:current-user');
+ if (!currentUser) return ME_USERNAME || '';
+ ME_CURRENT_USER = currentUser;
+ ME_USERNAME = String(discourseModelValue(currentUser, 'username') || '');
+ if (options.syncUnread !== false) {
+ const unreadCount = currentUserUnreadNotificationCount(currentUser);
+ syncNotificationUnreadUi(unreadCount == null ? cachedUnreadNotificationCount() : unreadCount);
+ }
+ return ME_USERNAME;
+ }
+ function canShowBoostButton(p) {
+ return !!(hasDiscourseCapability('boosts', { post: p }) &&
+ p && p.can_boost === true && (!ME_USERNAME || p.username !== ME_USERNAME));
+ }
+
+ const TOPIC_NOTIFICATION_LEVELS = [
+ { value: 1, label: '常规' },
+ { value: 2, label: '跟踪' },
+ { value: 3, label: '关注' },
+ { value: 0, label: '已屏蔽' },
+ ];
+
+ function topicNotificationLevel(topic) {
+ const value = Number(topic && (
+ topic.notification_level ??
+ (topic.details?.notification_level)
+ ));
+ return Number.isInteger(value) && value >= 0 && value <= 3 ? value : 1;
+ }
+
+ function topicActionCapabilities(post, ctx) {
+ const data = post || {};
+ const topic = (ctx?.topicData) || {};
+ const actions = Array.isArray(data.actions_summary) ? data.actions_summary : [];
+ const hasFlagAction = actions.some((action) =>
+ action && action.can_act === true && ![2, 8].includes(+action.id)
+ );
+ const signedIn = !!ME_USERNAME;
+ const isOwnPost = signedIn && data.username === ME_USERNAME;
+ const canReport = signedIn && !isOwnPost && data.can_flag !== false &&
+ (data.can_flag === true || hasFlagAction || actions.length <= 1);
+ const canAssign = !!(
+ data.can_assign === true ||
+ topic.can_assign === true ||
+ (topic.details && topic.details.can_assign === true)
+ );
+ const currentUser = ME_CURRENT_USER || {};
+ const details = topic.details || {};
+ const canAdmin = signedIn && !!(
+ currentUser.staff || currentUser.can_manage_topic || currentUser.canManageTopic ||
+ currentUser.can_change_post_owner || currentUser.canChangePostOwner ||
+ data.can_manage === true || data.can_wiki === true || details.can_edit_staff_notes === true
+ );
+ return {
+ canReport,
+ canAssign,
+ canBookmark: signedIn,
+ canEdit: data.can_edit === true,
+ canDelete: data.can_delete === true,
+ canAdmin,
+ };
+ }
+
+ function postBookmarkInfo(post) {
+ const data = post || {};
+ const bookmarkId = data.bookmark_id || (data.bookmark?.id) || null;
+ return { bookmarked: !!(data.bookmarked || bookmarkId), bookmarkId };
+ }
+
+ const POST_CONTEXT_ACTIONS = Object.freeze([
+ { name: 'share', event: 'post', action: 'share', label: '复制楼层链接', icon: 'link2' },
+ { name: 'report', event: 'reader', action: 'report', label: '举报', icon: 'flag', capability: 'canReport' },
+ { name: 'edit', event: 'post', action: 'edit', label: '编辑', icon: 'pencil', capability: 'canEdit' },
+ { name: 'bookmark', capability: 'canBookmark' },
+ { name: 'delete', event: 'post', action: 'delete', label: '删除', icon: 'trash', capability: 'canDelete' },
+ { name: 'assign', event: 'reader', action: 'assign-post', label: '指定楼层负责人', icon: 'userPlus', capability: 'canAssign' },
+ { name: 'admin', event: 'post', action: 'admin', label: '管理楼层', icon: 'wrench', capability: 'canAdmin' },
+ ]);
+
+ function postContextActionsHtml(post, ctx) {
+ const capabilities = topicActionCapabilities(post, ctx);
+ const isTopicStarter = Number(post?.post_number) === 1;
+ const bookmarked = isTopicStarter
+ ? !!(ctx?.topicBookmarked)
+ : postBookmarkInfo(post).bookmarked;
+ const bookmarkButton = isTopicStarter
+ ? `
`
+ : `
`;
+ return POST_CONTEXT_ACTIONS.map((item) => item.name === 'bookmark' ? bookmarkButton :
+ `
`
+ ).join('\n');
+ }
+
+ function postContextActionCount(post, ctx) {
+ const capabilities = topicActionCapabilities(post, ctx);
+ return POST_CONTEXT_ACTIONS.filter((item) =>
+ !item.capability || capabilities[item.capability]
+ ).length;
+ }
+
+ function compactPostActionsHtml(post, ctx) {
+ const count = postContextActionCount(post, ctx);
+ const eager = isFunction(matchMedia) && matchMedia('(hover: none)').matches;
+ return `
+
+ ${canShowBoostButton(post) ? `
` : ''}
+
${eager ? postContextActionsHtml(post, ctx) : ''}`;
+ }
+
+ function hydratePostContextActions(postNode, ctx) {
+ if (!postNode) return;
+ const slot = postNode.querySelector(':scope > .ldp-reactions .ldp-context-actions-slot');
+ if (!slot || slot.dataset.ldpContextActions === '1') return;
+ slot.innerHTML = postContextActionsHtml(postNode._ldpPostData || {}, ctx);
+ slot.dataset.ldpContextActions = '1';
+ slot.removeAttribute('aria-hidden');
+ syncPostActionControls(postNode, ctx);
+ }
+
+ function nativeTopicSharedIssueVisible(topic) {
+ if (!topic || topic.shared_issue_visible !== true) return false;
+ const hasAcceptedAnswers = Array.isArray(topic.accepted_answers) && topic.accepted_answers.length > 0;
+ const siteSettings = lookupDiscourse('service:site-settings');
+ return !hasAcceptedAnswers || siteSettings?.solved_allow_multiple_solutions === true;
+ }
+
+ function topicSharedIssueState(post, ctx) {
+ const topic = ctx?.topicData || {};
+ const count = Math.max(0, Number(topic.shared_issue_count) || 0);
+ return {
+ visible: hasDiscourseCapability('sharedIssue', { topic }) &&
+ ctx?.topicSharedIssueHostVisible === true &&
+ !SHARED_ISSUE_FORBIDDEN_TOPIC_IDS.has(Number(ctx?.topicId)),
+ active: topic.user_created_shared_issue === true,
+ count,
+ isAuthor: !!ME_USERNAME && !!post && String(post.username || '') === String(ME_USERNAME),
+ };
+ }
+
+ function topicSharedIssueLabel(state) {
+ return `俺也一样(${state.count})`;
+ }
+
+ function topicFooterActionsHtml(post, ctx) {
+ const capabilities = topicActionCapabilities(post, ctx);
+ const bookmarked = !!ctx.topicBookmarked;
+ const sharedIssue = topicSharedIssueState(post, ctx);
+ const sharedIssueLabel = topicSharedIssueLabel(sharedIssue);
+ const level = Number.isInteger(ctx.topicNotificationLevel)
+ ? ctx.topicNotificationLevel
+ : topicNotificationLevel(ctx.topicData);
+ const notificationOptions = TOPIC_NOTIFICATION_LEVELS.map((item) =>
+ `
`
+ ).join('');
+ return `
+ `;
+ }
+
+ function syncPostActionControls(postNode, ctx, options = {}) {
+ if (!postNode) return;
+ const post = postNode._ldpPostData || {};
+ const capabilities = topicActionCapabilities(post, ctx);
+ const { bookmarked } = postBookmarkInfo(post);
+ const actions = postNode.querySelector(':scope > .ldp-reactions .ldp-actions');
+ if (!actions) return;
+ const contextSlot = actions.querySelector('.ldp-context-actions-slot');
+ if (contextSlot)
+ contextSlot.style.setProperty('--ldp-context-action-count', String(postContextActionCount(post, ctx)));
+ actions.querySelectorAll('.ldp-post-bookmark').forEach((button) => {
+ button.classList.toggle('on', bookmarked);
+ button.hidden = !capabilities.canBookmark;
+ button.disabled = options.bookmarkBusy === true;
+ setPressed(button, bookmarked ? 'true' : 'false');
+ setLabel(button, bookmarked ? '已收藏该楼层' : '收藏该楼层');
+ });
+ POST_CONTEXT_ACTIONS.forEach((item) => {
+ if (!item.capability || item.name === 'bookmark') return;
+ actions.querySelectorAll(`.ldp-post-${item.name}`).forEach((element) => {
+ element.hidden = !capabilities[item.capability];
+ });
+ });
+ const showBoostButton = canShowBoostButton(post);
+ let boostButton = actions.querySelector('.ldp-boostbtn');
+ const canCreateBoostButton = postNode === ctx?.topicActionRail ||
+ ctx?.streamNodeMap?.get(+(postNode.dataset.postNumber || 0)) === postNode;
+ if (showBoostButton && !boostButton && canCreateBoostButton) {
+ const holder = makeElement('template');
+ holder.innerHTML = `
`;
+ boostButton = holder.content.firstElementChild;
+ actions.insertBefore(boostButton, contextSlot || actions.querySelector('.ldp-topic-bookmark'));
+ }
+ if (boostButton) boostButton.hidden = !showBoostButton;
+ }
+
+ function syncAllPostActionControls(ctx) {
+ if (!ctx || !ctx.modal) return;
+ ctx.modal.querySelectorAll('.ldp-post').forEach((postNode) => {
+ syncPostActionControls(postNode, ctx);
+ });
+ if (ctx.topicActionRail) { ctx.topicActionRail._ldpPostData = ctx.streamNodeMap?.get(1)?._ldpPostData || ctx.topicActionRail._ldpPostData; syncPostActionControls(ctx.topicActionRail, ctx); }
+ }
+
+ function topicActionPostNodes(ctx) {
+ const nodes = new Set();
+ const streamNode = ctx?.streamNodeMap && ctx.streamNodeMap.get(1);
+ if (streamNode) nodes.add(streamNode);
+ if (ctx?.topicActionRail?._ldpRelatedPost) nodes.add(ctx.topicActionRail);
+ if (ctx?.modal)
+ ctx.modal.querySelectorAll('.ldp-post[data-post-number="1"]').forEach((node) => nodes.add(node));
+ return nodes;
+ }
+
+ function syncTopicActionControls(ctx, options = {}) {
+ if (!ctx) return;
+ const bookmarked = !!ctx.topicBookmarked;
+ const level = Number.isInteger(ctx.topicNotificationLevel)
+ ? ctx.topicNotificationLevel
+ : topicNotificationLevel(ctx.topicData);
+ topicActionPostNodes(ctx).forEach((postNode) => {
+ const capabilities = topicActionCapabilities(postNode._ldpPostData, ctx);
+ const sharedIssue = topicSharedIssueState(postNode._ldpPostData, ctx);
+ const sharedIssueLabel = topicSharedIssueLabel(sharedIssue);
+ readerPostOwnControls(postNode, '.ldp-topic-shared-issue').forEach((button) => {
+ button.hidden = !sharedIssue.visible;
+ button.classList.toggle('on', sharedIssue.active);
+ button.disabled = ctx.topicSharedIssueBusy === true || sharedIssue.isAuthor;
+ setPressed(button, sharedIssue.active ? 'true' : 'false');
+ setLabel(button, sharedIssueLabel);
+ const count = button.querySelector('.ldp-topic-shared-issue-count');
+ if (count) count.textContent = `(${sharedIssue.count})`;
+ });
+ readerPostOwnControls(postNode, '.ldp-topic-footer-separator').forEach((separator) => {
+ separator.hidden = !sharedIssue.visible;
+ });
+ readerPostOwnControls(postNode, '.ldp-topic-bookmark').forEach((button) => {
+ button.classList.toggle('on', bookmarked);
+ setPressed(button, bookmarked ? 'true' : 'false');
+ setLabel(button, bookmarked ? '已收藏' : '收藏本帖');
+ const label = button.querySelector('span');
+ if (label) label.textContent = bookmarked ? '已收藏' : '添加为书签';
+ button.disabled = options.bookmarkBusy === true;
+ });
+ readerPostOwnControls(postNode, '.ldp-topic-report').forEach((element) => {
+ element.hidden = !capabilities.canReport;
+ });
+ readerPostOwnControls(postNode, '.ldp-topic-assign').forEach((element) => {
+ element.hidden = !capabilities.canAssign;
+ });
+ readerPostOwnControls(postNode, '.ldp-topic-notification').forEach((control) => {
+ control.classList.toggle('busy', options.notificationBusy === true);
+ const item = TOPIC_NOTIFICATION_LEVELS.find((option) => option.value === level);
+ setLabel(control, `通知:${item ? item.label : '常规'}`);
+ });
+ readerPostOwnControls(postNode, '.ldp-topic-notification-select').forEach((select) => {
+ select.value = String(level);
+ select.disabled = options.notificationBusy === true || !ME_USERNAME;
+ });
+ });
+ }
+
+ function ensureParentJumpControl(post, parentNumber) {
+ const head = post.querySelector(':scope > .ldp-post-head');
+ if (!head || head.querySelector(':scope > .ldp-jump-parent')) return;
+ const currentFloor = head.querySelector(':scope > .ldp-floor');
+ const parentFloor = makeElement('button');
+ parentFloor.className = 'ldp-floor ldp-jump-parent';
+ parentFloor.type = 'button';
+ parentFloor.dataset.targetPostNumber = String(parentNumber);
+ setLabel(parentFloor, `跳到父楼层 #${parentNumber}`);
+ parentFloor.textContent = `@ #${parentNumber}`;
+ if (currentFloor) currentFloor.classList.add('ldp-current-floor');
+ head.insertBefore(parentFloor, currentFloor || null);
+ }
+
+ function ensureStreamNestedReplyPresentation(post, parentPostNumber) {
+ const parentNumber = +parentPostNumber;
+ if (!post || !parentNumber) return;
+ post.classList.add('ldp-reply', 'ldp-reply-collapsible');
+ post.dataset.replyToPostNumber = String(parentNumber);
+
+ ensureNestedReplyToggle(post, post.firstChild);
+ ensureParentJumpControl(post, parentNumber);
+ }
+
+ function isEscCollapsibleNestedReply(post) {
+ if (!post || !post.classList.contains('ldp-reply-collapsible') ||
+ post.classList.contains('ldp-nested-preview')) return false;
+ return !(+(post._ldpPostData?.reply_count || 0) > 0) &&
+ !(+(post.dataset.childReplyTotal || 0) > 0);
+ }
+
+ function syncCollapsedNestedReplyZebra(post, options = {}) {
+ const ctx = post?._ldpReaderContext;
+ const postNumber = +(post && post.dataset.postNumber || 0);
+ const item = ctx?.streamItemMap && ctx.streamItemMap.get(postNumber);
+ if (!item || item.node !== post) return;
+ const excluded = !!post._ldpPostData?.hidden || post.classList.contains('ldp-nested-collapsed');
+ if (options.deferZebraSync) return;
+ if (item.zebraExcluded === excluded) {
+ if (excluded) post.classList.remove('ldp-zebra-alt');
+ return;
+ }
+ item.zebraExcluded = excluded;
+ reindexStreamItems(ctx, Math.max(0, item.index));
+ if (ctx.onlyOp) invalidateStreamLayout(ctx);
+ else markStreamPrefixDirty(ctx, Math.max(0, item.index));
+ refreshStreamZebra(ctx);
+ scheduleStreamWindowSync(ctx);
+ }
+
+ function setNestedReplyCollapsed(post, collapsed, options = {}) {
+ if (!post || (!post.classList.contains('ldp-reply-collapsible') &&
+ !post.classList.contains('ldp-hidden-collapsible'))) return;
+ if (collapsed) {
+ let line = null;
+ let preview = post.previousElementSibling;
+ if (preview && preview.classList.contains('ldp-floor-preview-link')) {
+ line = preview;
+ preview = preview.previousElementSibling;
+ }
+ if (line) line.classList.remove('open');
+ if (preview && preview.classList.contains('ldp-floor-preview')) preview.classList.remove('open');
+ }
+ if (collapsed) post.querySelector(':scope > .ldp-nested-esc-hint')?.remove();
+ post.classList.toggle('ldp-nested-collapsed', collapsed);
+ syncCollapsedNestedReplyZebra(post, options);
+ const parentFloor = post.querySelector(':scope > .ldp-post-head > .ldp-jump-parent');
+ if (parentFloor) {
+ const parentNumber = +parentFloor.dataset.targetPostNumber || 0;
+ setLabel(parentFloor, collapsed
+ ? `展开楼层并预览父楼层 #${parentNumber}`
+ : `跳到父楼层 #${parentNumber}`);
+ }
+ const btn = post.querySelector(':scope > .ldp-nested-toggle');
+ if (!btn) return;
+ const postNumber = post.dataset.postNumber || '';
+ const controlKey = `${collapsed ? 'collapsed' : 'open'}:${postNumber}`;
+ if (btn.dataset.nestedControlKey !== controlKey) {
+ btn.dataset.nestedControlKey = controlKey;
+ setExpanded(btn, collapsed ? 'false' : 'true');
+ setLabel(btn, collapsed ? `展开楼层 #${postNumber}` : `收起楼层 #${postNumber}`);
+ btn.innerHTML = `${icon(collapsed ? 'chevronRight' : 'chevronDown')}
${collapsed ? '展开' : '收起'}`;
+ }
+ setNestedReplyEscapeHint(post, !collapsed && isEscCollapsibleNestedReply(post));
+ }
+
+ function setNestedReplyEscapeHint(post, visible) {
+ if (!post) return;
+ let hint = post.querySelector(':scope > .ldp-nested-esc-hint');
+ if (!visible) {
+ hint?.remove();
+ return;
+ }
+ const toggle = post.querySelector(':scope > .ldp-nested-toggle');
+ if (!toggle) return;
+ if (!hint) {
+ hint = makeElement('span');
+ hint.className = 'ldp-nested-esc-hint';
+ toggle.insertAdjacentElement('afterend', hint);
+ }
+ hint.textContent = 'Esc 收起';
+ }
+
+ function alignCollapsedFloorPreview(preview, post, collapsedBottom, ctx) {
+ if (!preview || !post || !ctx?.scrollRoot || !preview.isConnected || !post.isConnected ||
+ !Number.isFinite(collapsedBottom)) return;
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const anchoredBottom = Math.max(rootRect.top, Math.min(rootRect.bottom, collapsedBottom));
+ const previewRect = preview.getBoundingClientRect();
+ const postRect = post.getBoundingClientRect();
+ const availableHeight = Math.max(0, anchoredBottom - rootRect.top);
+ const combinedHeight = Math.max(0, postRect.bottom - previewRect.top);
+ const correction = combinedHeight > availableHeight + 1
+ ? previewRect.top - rootRect.top
+ : postRect.bottom - anchoredBottom;
+ if (Math.abs(correction) < 1) return;
+ const previousScrollTop = ctx.scrollRoot.scrollTop;
+ ctx.scrollRoot.scrollTop += correction;
+ markStreamInternalScroll(ctx, previousScrollTop);
+ scheduleStreamWindowSync(ctx);
+ }
+
+ function keepOnlyOpPostExpanded(post, ctx) {
+ if (!post || !ctx || !ctx.onlyOp || !ctx.op ||
+ post._ldpPostData?.hidden || String(post.dataset.username || '') !== String(ctx.op)) return;
+ setNestedReplyCollapsed(post, false);
+ if (post.classList.contains('ldp-children-collapsed')) {
+ post.classList.remove('ldp-children-collapsed');
+ syncChildControls(post);
+ }
+ }
+
+ let activeJumpHighlight = null;
+
+ function syncFirstPostJumpHighlightRegion(target) {
+ if (!target || !target.matches('.ldp-post[data-post-number="1"]')) return null;
+ const reactions = target.querySelector(':scope > .ldp-reactions');
+ if (!reactions) return null;
+ const targetRect = target.getBoundingClientRect();
+ const reactionsRect = reactions.getBoundingClientRect();
+ const highlightHeight = Math.min(
+ targetRect.height,
+ Math.max(0, reactionsRect.bottom - targetRect.top)
+ );
+ target.style.setProperty('--ldp-first-post-jump-highlight-height', `${Math.ceil(highlightHeight)}px`);
+ return reactions;
+ }
+
+ function clearActiveJumpHighlight() {
+ if (!activeJumpHighlight) return;
+ clearTimeout(activeJumpHighlight.timer);
+ if (activeJumpHighlight.resizeObserver) activeJumpHighlight.resizeObserver.disconnect();
+ activeJumpHighlight.target.classList.remove('ldp-jump-highlight');
+ activeJumpHighlight.target.style.removeProperty('--ldp-first-post-jump-highlight-height');
+ if (activeJumpHighlight.target.dataset.jumpHighlightToken === activeJumpHighlight.token)
+ delete activeJumpHighlight.target.dataset.jumpHighlightToken;
+ activeJumpHighlight = null;
+ }
+
+ function keepJumpHighlightUntilSettled(target) {
+ if (!target) return;
+ clearActiveJumpHighlight();
+ const token = `${Date.now()}-${Math.random()}`;
+ target.classList.remove('ldp-jump-highlight');
+ target.style.removeProperty('--ldp-first-post-jump-highlight-height');
+ const firstPostReactions = syncFirstPostJumpHighlightRegion(target);
+ void target.offsetWidth;
+ target.dataset.jumpHighlightToken = token;
+ target.classList.add('ldp-jump-highlight');
+ let resizeObserver = null;
+ if (firstPostReactions && isFunction(ResizeObserver)) {
+ resizeObserver = new ResizeObserver(() => {
+ if (target.isConnected && target.dataset.jumpHighlightToken === token)
+ syncFirstPostJumpHighlightRegion(target);
+ });
+ resizeObserver.observe(target);
+ resizeObserver.observe(firstPostReactions);
+ }
+ const config = jumpHighlightConfigFromPrefs(PREFS);
+ const reducedMotion = prefersReducedMotion();
+ const lifetime = reducedMotion ? 1000 : jumpHighlightDurationMs(config) * config.count;
+ const timer = setTimeout(() => {
+ if (resizeObserver) resizeObserver.disconnect();
+ if (target.dataset.jumpHighlightToken === token) {
+ target.classList.remove('ldp-jump-highlight');
+ target.style.removeProperty('--ldp-first-post-jump-highlight-height');
+ delete target.dataset.jumpHighlightToken;
+ }
+ if (activeJumpHighlight && activeJumpHighlight.token === token) activeJumpHighlight = null;
+ }, lifetime);
+ activeJumpHighlight = { target, token, timer, resizeObserver };
+ }
+
+ function streamPostNumber(node) {
+ return +(node?.dataset && node.dataset.postNumber) || 0;
+ }
+
+ function streamOwnerPostNode(ctx, node) {
+ if (!ctx || !ctx.commentsEl || !node) return null;
+ let owner = node;
+ while (owner && owner.parentElement !== ctx.commentsEl) {
+ owner = owner.parentElement?.closest('.ldp-post');
+ }
+ return owner && owner.parentElement === ctx.commentsEl ? owner : null;
+ }
+
+ function clearStreamProgrammaticTarget(ctx) {
+ if (!ctx) return;
+ ctx.streamProgrammaticTarget = null;
+ ctx.streamWindowResetPending = false;
+ }
+
+ function beginReaderNavigation(ctx) {
+ if (!ctx) return 0;
+ clearStreamViewportAnchor(ctx);
+ clearStreamProgrammaticTarget(ctx);
+ ctx.readerNavigationSequence = +(ctx.readerNavigationSequence || 0) + 1;
+ return ctx.readerNavigationSequence;
+ }
+
+ function cancelReaderNavigation(ctx) {
+ if (!ctx) return;
+ clearStreamViewportAnchor(ctx);
+ clearStreamProgrammaticTarget(ctx);
+ ctx.readerNavigationSequence = +(ctx.readerNavigationSequence || 0) + 1;
+ }
+
+ function readerNavigationIsCurrent(ctx, sequence) {
+ if (!ctx || !sequence || +ctx.readerNavigationSequence !== +sequence) return false;
+ return !ctx.readerSession || ctx.readerSession.isCurrent('opening', 'active');
+ }
+
+ function waitForReaderJumpLayout() {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(resolve));
+ });
+ }
+
+ function waitForDelay(delay) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, delay);
+ });
+ }
+
+ function readerOpenFailureCanAutoRetry(error, phase) {
+ const status = Number(error?.status || 0);
+ if (status === 429) return false;
+ if (status && status !== 408 && status !== 425 && status < 500) return false;
+ const name = String(error?.name || '');
+ const message = String(error?.message || '');
+ if (name === 'AbortError' || name === 'TimeoutError' || status === 408 || status === 425 || status >= 500)
+ return true;
+ if (/定位失败|恢复失败|布局稳定校验失败|校准失败/.test(message)) return true;
+ if (!['load-topic', 'load-target-post', 'load-target-boosts', 'load-target-neighborhood'].includes(String(phase || '')))
+ return false;
+ return name === 'TypeError' ||
+ /加载失败|请求失败|failed to fetch|network|timeout|timed out|aborted|signal/i.test(message);
+ }
+
+ function readerOpenAutoRetryDelay(attempt, error) {
+ const retryAtDelay = Math.max(0, Number(error?.retryAt || 0) - Date.now());
+ const delays = [500, 1200, 2500, 5000, 8000];
+ const backoff = delays[Math.min(delays.length - 1, Math.max(0, Number(attempt) - 1))];
+ return Math.max(backoff, retryAtDelay);
+ }
+
+ async function waitForReaderInitialLayout(overlay) {
+ await waitForReaderJumpLayout();
+ const modal = overlay?.querySelector('.ldp-modal');
+ if (!modal || !overlay.classList.contains('ldp-reader-embedded') || !isFunction(modal.getAnimations)) return;
+ const motions = modal.getAnimations().filter((motion) => motion.playState !== 'finished');
+ if (!motions.length) return;
+ await Promise.race([
+ Promise.allSettled(motions.map((motion) => motion.finished)),
+ waitForDelay(400),
+ ]);
+ await waitForReaderJumpLayout();
+ }
+
+ function readerTargetNodeIsVisible(target, ctx) {
+ if (!target || !target.isConnected) return false;
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const targetRect = target.getBoundingClientRect();
+ return targetRect.bottom > rootRect.top + 1 && targetRect.top < rootRect.bottom - 1;
+ }
+
+ function stableReaderViewportOffset(target, ctx, offset) {
+ const requestedOffset = Number(offset);
+ if (!Number.isFinite(requestedOffset) || !target || !target.isConnected || !ctx || !ctx.scrollRoot) return 0;
+ const rootHeight = Math.max(0, ctx.scrollRoot.getBoundingClientRect().height);
+ const targetHeight = Math.max(0, target.getBoundingClientRect().height);
+ if (!rootHeight || !targetHeight) return requestedOffset;
+ const minimumVisibleHeight = Math.min(40, rootHeight, targetHeight);
+ return Math.max(
+ minimumVisibleHeight - targetHeight,
+ Math.min(rootHeight - minimumVisibleHeight, requestedOffset),
+ );
+ }
+
+ function beginStreamProgrammaticTarget(ctx, postNumber) {
+ if (!ctx || !postNumber) return null;
+ clearStreamViewportAnchor(ctx);
+ const target = { postNumber: +postNumber };
+ ctx.streamProgrammaticTarget = target;
+ ctx.streamScrollDirection = 0;
+ ctx.streamScrollVelocity = 0;
+ return target;
+ }
+
+ function markStreamInternalScroll(ctx, previousScrollTop = NaN) {
+ if (!ctx || !ctx.scrollRoot) return;
+ const nextScrollTop = ctx.scrollRoot.scrollTop;
+ if (Number.isFinite(previousScrollTop)) {
+ const appliedDelta = nextScrollTop - previousScrollTop;
+ if (Math.abs(appliedDelta) >= 0.01) {
+ const pendingDelta = Number.isFinite(ctx.streamInternalScrollDelta)
+ ? ctx.streamInternalScrollDelta
+ : 0;
+ ctx.streamInternalScrollDelta = pendingDelta + appliedDelta;
+ }
+ }
+ ctx.streamInternalScrollTop = nextScrollTop;
+ }
+
+ function findTopVisibleStreamPost(ctx) {
+ if (!ctx || !ctx.scrollRoot || !ctx.commentsEl) return null;
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const allPosts = [...ctx.commentsEl.querySelectorAll(':scope > .ldp-post:not(.ldp-floor-preview)')];
+ let posts = allPosts;
+ const items = streamLayoutItems(ctx);
+ rebuildStreamPrefix(ctx);
+ if (items.length && Number.isFinite(ctx.streamListOffset)) {
+ const localTop = Math.max(0, ctx.scrollRoot.scrollTop - ctx.streamListOffset);
+ const index = findStreamIndexAtOffset(ctx.streamPrefix, localTop);
+ const nearby = [];
+ for (let cursor = Math.max(0, index - 2); cursor < Math.min(items.length, index + 7); cursor++) {
+ const node = items[cursor]?.node;
+ if (node && node.parentElement === ctx.commentsEl) nearby.push(node);
+ }
+ if (nearby.length) posts = nearby;
+ }
+ const findBest = (candidates) => {
+ let best = null;
+ candidates.forEach((post) => {
+ const marker = post.querySelector(':scope > .ldp-post-head');
+ if (!marker) return;
+ const rect = marker.getBoundingClientRect();
+ if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return;
+ const ownerNode = streamOwnerPostNode(ctx, post);
+ if (!streamPostNumber(ownerNode)) return;
+ const score = Math.max(0, rect.top - rootRect.top);
+ if (!best || score < best.score || (score === best.score && rect.top < best.top)) {
+ best = { marker, top: rect.top, ownerNode, score };
+ }
+ });
+ if (best) return best;
+ candidates.forEach((post) => {
+ const rect = post.getBoundingClientRect();
+ if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return;
+ const ownerNode = streamOwnerPostNode(ctx, post);
+ if (!streamPostNumber(ownerNode)) return;
+ const score = Math.abs(rect.top - rootRect.top);
+ if (!best || score < best.score) best = { marker: post, ownerNode, score };
+ });
+ return best;
+ };
+ let best = findBest(posts);
+ if (!best && posts !== allPosts) best = findBest(allPosts);
+ return best && { marker: best.marker, ownerNode: best.ownerNode };
+ }
+
+ function findStreamViewportAnchorMarker(ctx) {
+ const visible = findTopVisibleStreamPost(ctx);
+ const owner = visible?.ownerNode;
+ if (!owner || !owner.isConnected) return null;
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const viewportEdge = rootRect.top + 1;
+ const candidates = [visible.marker, owner, ...owner.querySelectorAll(
+ ':scope > .ldp-post-head, :scope > .ldp-content > *, :scope > .ldp-reactions, ' +
+ ':scope > .ldp-boost-list > .ldp-boost-bubble, :scope > .ldp-children > .ldp-post, ' +
+ ':scope > .ldp-children > .ldp-post > .ldp-post-head, :scope > .ldp-sub-actions'
+ )];
+ let best = null;
+ const seen = new Set();
+ candidates.forEach((candidate) => {
+ if (!candidate || seen.has(candidate) || !candidate.isConnected) return;
+ seen.add(candidate);
+ const rect = candidate.getBoundingClientRect();
+ if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return;
+ const crossesTop = rect.top <= viewportEdge && rect.bottom > viewportEdge;
+ const distance = crossesTop ? 0 : Math.max(0, rect.top - viewportEdge);
+ if (!best || distance < best.distance ||
+ (distance === best.distance && rect.height < best.height)) {
+ best = { marker: candidate, distance, height: rect.height };
+ }
+ });
+ return best?.marker;
+ }
+
+ function clearStreamViewportAnchor(ctx) {
+ if (!ctx) return;
+ ctx.streamViewportAnchorCapturePending = false;
+ ctx.streamViewportAnchorRestorePending = false;
+ ctx.streamViewportAnchor = null;
+ if (ctx.scrollRoot) ctx.scrollRoot.classList.remove('ldp-stream-viewport-anchor');
+ }
+
+ function captureStreamViewportAnchor(ctx) {
+ if (!ctx || !ctx.scrollRoot || !ctx.commentsEl || ctx.streamProgrammaticTarget) return null;
+ ctx.streamViewportAnchorCapturePending = false;
+ const marker = findStreamViewportAnchorMarker(ctx);
+ if (!marker) {
+ clearStreamViewportAnchor(ctx);
+ return null;
+ }
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const markerRect = marker.getBoundingClientRect();
+ const markerPost = marker.closest('.ldp-post');
+ const owner = streamOwnerPostNode(ctx, markerPost);
+ const state = {
+ marker,
+ offset: markerRect.top - rootRect.top,
+ owner,
+ ownerOffset: owner ? owner.getBoundingClientRect().top - rootRect.top : 0,
+ };
+ ctx.streamViewportAnchor = state;
+ ctx.scrollRoot.classList.add('ldp-stream-viewport-anchor');
+ return state;
+ }
+
+ function queueStreamViewportAnchorCapture(ctx) {
+ if (ctx && !ctx.streamProgrammaticTarget) ctx.streamViewportAnchorCapturePending = true;
+ }
+
+ function beginStreamViewportAnchorRestoreBatch(ctx) {
+ if (!ctx) return;
+ ctx.streamViewportAnchorRestoreDepth = +(ctx.streamViewportAnchorRestoreDepth || 0) + 1;
+ }
+
+ function endStreamViewportAnchorRestoreBatch(ctx) {
+ if (!ctx || !ctx.streamViewportAnchorRestoreDepth) return;
+ ctx.streamViewportAnchorRestoreDepth--;
+ if (ctx.streamViewportAnchorRestoreDepth || !ctx.streamViewportAnchorRestorePending) return;
+ ctx.streamViewportAnchorRestorePending = false;
+ restoreStreamViewportAnchor(ctx);
+ }
+
+ function applyStreamViewportAnchorRestore(ctx) {
+ const state = ctx?.streamViewportAnchor;
+ if (!state) return false;
+ if (!ctx.scrollRoot || !ctx.commentsEl || ctx.streamProgrammaticTarget) {
+ clearStreamViewportAnchor(ctx);
+ return false;
+ }
+ const markerConnected = state.marker?.isConnected && ctx.commentsEl.contains(state.marker);
+ const ownerConnected = state.owner?.isConnected && ctx.commentsEl.contains(state.owner);
+ const marker = markerConnected ? state.marker : ownerConnected ? state.owner : null;
+ if (!marker) {
+ clearStreamViewportAnchor(ctx);
+ return false;
+ }
+ const expectedOffset = marker === state.marker ? state.offset : state.ownerOffset;
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const markerRect = marker.getBoundingClientRect();
+ const correction = markerRect.top - rootRect.top - expectedOffset;
+ if (Math.abs(correction) < 0.5) return true;
+ const previousScrollTop = ctx.scrollRoot.scrollTop;
+ ctx.scrollRoot.scrollTop += correction;
+ if (Math.abs(ctx.scrollRoot.scrollTop - previousScrollTop) >= 0.5) {
+ markStreamInternalScroll(ctx, previousScrollTop);
+ } else if (marker === state.marker) {
+ state.offset = markerRect.top - rootRect.top;
+ } else {
+ state.ownerOffset = markerRect.top - rootRect.top;
+ }
+ return true;
+ }
+
+ function restoreStreamViewportAnchor(ctx) {
+ if (!ctx || !ctx.streamViewportAnchor) return false;
+ if (ctx.streamViewportAnchorRestoreDepth) {
+ ctx.streamViewportAnchorRestorePending = true;
+ return true;
+ }
+ ctx.streamViewportAnchorRestorePending = false;
+ return applyStreamViewportAnchorRestore(ctx);
+ }
+
+ function forEachDirectNestedPost(node, callback) {
+ const children = node?.querySelector(':scope > .ldp-children');
+ if (!children) return;
+ for (const child of children.children) {
+ if (child.classList.contains('ldp-post')) callback(child);
+ }
+ }
+
+ function createStreamSpacer(position) {
+ const spacer = makeElement('div');
+ spacer.className = `ldp-stream-spacer ldp-stream-spacer-${position}`;
+ spacer.setAttribute('aria-hidden', 'true');
+ setStreamSpacerHeight(spacer, 0);
+ return spacer;
+ }
+
+ function setStreamSpacerHeight(spacer, height) {
+ if (!spacer) return;
+ const next = Math.max(0, Math.round(+height || 0));
+ const value = `${next}px`;
+ if (spacer.style.getPropertyValue('--ldp-stream-spacer-height') === value) return;
+ spacer.style.setProperty('--ldp-stream-spacer-height', value);
+ }
+
+ function ensureStreamScaffold(ctx) {
+ if (!ctx || !ctx.commentsEl || !ctx.streamTopSpacer || !ctx.streamBottomSpacer) return;
+ if (!ctx.streamTopSpacer.isConnected) ctx.commentsEl.appendChild(ctx.streamTopSpacer);
+ if (!ctx.streamBottomSpacer.isConnected) ctx.commentsEl.appendChild(ctx.streamBottomSpacer);
+ }
+
+ function streamItemMatchesFilter(ctx, item) {
+ if (!ctx || !ctx.onlyOp) return true;
+ return !!item && !!ctx.op && String(item.username || '') === String(ctx.op);
+ }
+
+ function streamLayoutItems(ctx) {
+ if (!ctx || !ctx.onlyOp) return (ctx?.streamItems) || [];
+ if (!ctx.streamLayoutDirty && Array.isArray(ctx.streamLayoutItems)) return ctx.streamLayoutItems;
+ const items = ctx.streamItems.filter((item) => streamItemMatchesFilter(ctx, item));
+ let zebraIndex = 0;
+ items.forEach((item, index) => {
+ item.layoutIndex = index;
+ item.layoutZebraIndex = streamItemParticipatesInZebra(item) ? zebraIndex++ : -1;
+ });
+ ctx.streamLayoutItems = items;
+ ctx.streamLayoutDirty = false;
+ return items;
+ }
+
+ function invalidateStreamLayout(ctx) {
+ if (!ctx) return;
+ ctx.streamLayoutDirty = true;
+ ctx.streamPrefixDirty = true;
+ ctx.streamPrefixDirtyFrom = 0;
+ ctx.streamListOffset = NaN;
+ ctx.streamNeedsRepair = true;
+ }
+
+ function markStreamPrefixDirty(ctx, index = 0) {
+ const items = streamLayoutItems(ctx);
+ const next = Math.max(0, Math.min(items.length, +index || 0));
+ ctx.streamPrefixDirty = true;
+ ctx.streamPrefixDirtyFrom = Number.isInteger(ctx.streamPrefixDirtyFrom)
+ ? Math.min(ctx.streamPrefixDirtyFrom, next)
+ : next;
+ }
+
+ function rebuildStreamPrefix(ctx) {
+ if (!ctx || !ctx.streamItems) return [0];
+ const items = streamLayoutItems(ctx);
+ if (!ctx.streamPrefixDirty && ctx.streamPrefix.length === items.length + 1) return ctx.streamPrefix;
+ const prefix = ctx.streamPrefix;
+ let from = Number.isInteger(ctx.streamPrefixDirtyFrom) ? ctx.streamPrefixDirtyFrom : 0;
+ if (!prefix.length || prefix[0] !== 0 || prefix.length > items.length + 1) from = 0;
+ from = Math.max(0, Math.min(from, items.length, prefix.length - 1));
+ if (from === 0) {
+ prefix.length = 1;
+ prefix[0] = 0;
+ }
+ else prefix.length = from + 1;
+ for (let index = from; index < items.length; index++) {
+ const item = items[index];
+ prefix[index + 1] = prefix[index] + (item.node?.hidden ? 0 : item.hiddenReplyMarker ? +(item.height || 28) :
+ Math.max(+(item.height || 0), STREAM_PLACEHOLDER_MIN_HEIGHT));
+ }
+ ctx.streamPrefixDirty = false;
+ ctx.streamPrefixDirtyFrom = null;
+ return prefix;
+ }
+
+ function findStreamIndexAtOffset(prefix, offset) {
+ if (!prefix || prefix.length <= 1) return 0;
+ const target = Math.max(0, +offset || 0);
+ let low = 0;
+ let high = prefix.length - 2;
+ while (low < high) {
+ const mid = Math.floor((low + high) / 2);
+ if (prefix[mid + 1] <= target) low = mid + 1;
+ else high = mid;
+ }
+ return low;
+ }
+
+ function createStreamResizeObserver(ctx) {
+ if (typeof ResizeObserver === 'undefined')
+ return { observe() {}, unobserve() {}, disconnect() {} };
+ return new ResizeObserver((entries) => {
+ let changed = false;
+ entries.forEach((entry) => {
+ const item = ctx.streamItemMap.get(streamPostNumber(entry.target));
+ if (!item) return;
+ const borderBox = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize;
+ const measured = (borderBox?.blockSize) || entry.target.offsetHeight || entry.contentRect.height;
+ const height = Math.max(Math.round(measured || 0), STREAM_PLACEHOLDER_MIN_HEIGHT);
+ if (entry.target.classList.contains('ldp-has-child-branches')) {
+ scheduleNestedBranchSync(entry.target, ctx);
+ }
+ if (Math.abs(height - item.height) < 1) return;
+ item.height = height;
+ markStreamPrefixDirty(ctx, ctx.onlyOp ? item.layoutIndex : item.index);
+ changed = true;
+ });
+ if (!changed) return;
+ restoreStreamViewportAnchor(ctx);
+ ctx.streamNeedsRepair = true;
+ scheduleStreamWindowSync(ctx);
+ });
+ }
+
+ function updateStreamSpacerHeights(ctx, start, end) {
+ const total = streamLayoutItems(ctx).length;
+ const totalHeight = ctx.streamPrefix[total] || 0;
+ setStreamSpacerHeight(
+ ctx.streamTopSpacer,
+ Math.max(ctx.streamPrefix[start], +(ctx.initialTargetHeadSpacerHeight || 0))
+ );
+ setStreamSpacerHeight(
+ ctx.streamBottomSpacer,
+ Math.max(0, totalHeight - ctx.streamPrefix[end])
+ );
+ }
+
+ function clearInitialTargetHeadSpacer(ctx) {
+ if (!ctx || !ctx.initialTargetHeadSpacerHeight) return;
+ ctx.initialTargetHeadSpacerHeight = 0;
+ updateStreamSpacerHeights(ctx, ctx.streamWindowStart, ctx.streamWindowEnd);
+ }
+
+ function createPostIdleWorkState(pendingSet) {
+ return { cancels: new WeakMap(), pendingSet };
+ }
+
+ const postContentHydrationWork = createPostIdleWorkState('pendingPostHydrations');
+ const postLatexRenderWork = createPostIdleWorkState('pendingPostLatexRenders');
+
+ function cancelPostIdleWork(post, ctx, work, pendingKey) {
+ if (!post) return;
+ const cancel = work.cancels.get(post);
+ if (cancel) cancel();
+ work.cancels.delete(post);
+ if (ctx?.[work.pendingSet]) ctx[work.pendingSet].delete(post);
+ delete post.dataset[pendingKey];
+ }
+
+ function cancelPostContentHydration(post, ctx) {
+ cancelPostIdleWork(post, ctx, postContentHydrationWork, 'ldpContentHydrationPending');
+ }
+
+ function cancelPostLatexRender(post, ctx) {
+ cancelPostIdleWork(post, ctx, postLatexRenderWork, 'ldpLatexPending');
+ }
+
+ function postContentPlaceholderHeight(postData) {
+ const cooked = String(postData?.cooked || '');
+ if (!cooked) return 72;
+ const blockCount = (cooked.match(/<(?:p|li|pre|blockquote|h[1-6]|tr|br)\b/gi) || []).length;
+ const lineEstimate = Math.max(2, blockCount, Math.ceil(cooked.length / 180));
+ const mediaHeight = /<(?:img|video|audio|iframe)\b|class=["'][^"']*(?:onebox|lightbox|d-image-grid)/i.test(cooked) ? 220 : 0;
+ return Math.max(72, Math.min(460, 28 + lineEstimate * 22 + mediaHeight));
+ }
+
+ function postHasBoostData(boosts) {
+ if (Array.isArray(boosts)) return boosts.some(Boolean);
+ return !!boosts;
+ }
+
+ function hydratePostContent(post, ctx) {
+ if (!post) return false;
+ if (post.dataset.ldpContentHydrated !== '0') {
+ syncPostActionControls(post, ctx);
+ return true;
+ }
+ const postData = post._ldpPostData || {};
+ const content = post.querySelector(':scope > .ldp-content');
+ if (!content) return false;
+
+ cancelPostContentHydration(post, ctx);
+
+ setPostCookedContent(content, postData.cooked, postData.link_counts);
+ content.classList.remove('ldp-content-pending');
+ content.style.removeProperty('--ldp-content-placeholder-height');
+ content.removeAttribute('aria-busy');
+ renderPostPolls(post, ctx);
+ renderPostSpecialContent(post, ctx);
+
+ const pendingBoosts = post.querySelector(':scope > .ldp-boost-list.ldp-boost-pending');
+ if (pendingBoosts) {
+ const holder = makeElement('template');
+ holder.innerHTML = renderBoostList(postData.boosts, { canQuickBoost: canShowBoostButton(postData), ctx });
+ const next = holder.content.firstElementChild;
+ if (next) replaceOwnedNode(pendingBoosts, next);
+ else removeOwnedNode(pendingBoosts);
+ }
+ syncPostActionControls(post, ctx);
+
+ post.dataset.ldpContentHydrated = '1';
+ ctx?.translation?.syncPost(post);
+ if (ctx?.streamNodeMap && ctx.streamNodeMap.get(+post.dataset.postNumber) === post) {
+ if (ctx.streamHydratedNodes) ctx.streamHydratedNodes.add(post);
+ }
+ delete post.dataset.ldpLatexRendered;
+ bindPostMediaLayoutSync(post, ctx);
+ if (post.isConnected) {
+ schedulePostLatexRender(post, ctx);
+ scheduleNestedBranchSync(post, ctx);
+ }
+ restoreReaderQuoteHighlightForPost(post, ctx);
+ restoreStreamViewportAnchor(ctx);
+ return true;
+ }
+
+ function dehydratePostContent(post, ctx) {
+ if (!post || post.isConnected || post.dataset.ldpContentHydrated !== '1') return false;
+ if (ctx?.floorPreview && ctx.floorPreview.isOpen()) return false;
+ cancelPostContentHydration(post, ctx);
+ cancelPostLatexRender(post, ctx);
+ const postData = post._ldpPostData || {};
+ const content = post.querySelector(':scope > .ldp-content');
+ if (content) {
+ suspendReaderMedia(content);
+ releasePersistentAvatarImages(content);
+ content.replaceChildren();
+ content.classList.add('ldp-content-pending');
+ content.style.setProperty('--ldp-content-placeholder-height', `${postContentPlaceholderHeight(postData)}px`);
+ content.setAttribute('aria-busy', 'true');
+ }
+ const boosts = post.querySelector(':scope > .ldp-boost-list');
+ if (boosts) {
+ destroyReaderMedia(boosts);
+ releasePersistentAvatarImages(boosts);
+ boosts.replaceChildren();
+ boosts.classList.add('ldp-boost-pending');
+ boosts.hidden = !postHasBoostData(postData.boosts);
+ boosts.setAttribute('aria-hidden', 'true');
+ }
+ post.dataset.ldpContentHydrated = '0';
+ delete post.dataset.ldpLatexRendered;
+ delete post.dataset.ldpLatexPending;
+ if (ctx?.streamHydratedNodes) ctx.streamHydratedNodes.delete(post);
+ return true;
+ }
+
+ function pruneStreamHydratedContent(ctx, items, start, end) {
+ if (!ctx || !ctx.streamHydratedNodes || ctx.floorPreview?.isOpen()) return;
+ const mountedSpan = Math.max(1, end - start);
+ const warmBuffer = Math.min(12, Math.max(4, Math.ceil(mountedSpan / 2)));
+ const warmStart = Math.max(0, start - warmBuffer);
+ const warmEnd = Math.min(items.length, end + warmBuffer);
+ [...ctx.streamHydratedNodes].forEach((post) => {
+ if (!post || post.isConnected) return;
+ const item = ctx.streamItemMap.get(streamPostNumber(post));
+ const index = item && (ctx.onlyOp ? item.layoutIndex : item.index);
+ const participates = item && (!ctx.onlyOp || streamItemMatchesFilter(ctx, item));
+ if (!participates || !Number.isInteger(index) || index < warmStart || index >= warmEnd) {
+ dehydratePostContent(post, ctx);
+ }
+ });
+ }
+
+ function streamWorkingSetSpan(ctx, total) {
+ const viewportItems = Math.ceil(
+ Math.max(1, Number(ctx?.scrollRoot?.clientHeight) || 0) / STREAM_ESTIMATED_HEIGHT,
+ );
+ return Math.min(
+ Math.max(0, Number(total) || 0),
+ streamPerformance(ctx).streamMaxItems,
+ Math.max(12, viewportItems + 8),
+ );
+ }
+
+ function hydrateStreamNavigationNeighborhood(ctx, postNumber) {
+ const targetItem = ctx?.streamItemMap && ctx.streamItemMap.get(+postNumber);
+ if (!targetItem) return;
+ const items = streamLayoutItems(ctx);
+ const targetIndex = ctx.onlyOp ? targetItem.layoutIndex : targetItem.index;
+ if (!Number.isInteger(targetIndex) || targetIndex < 0 || targetIndex >= items.length) return;
+ const span = streamWorkingSetSpan(ctx, items.length);
+ const beforeTarget = Math.min(targetIndex, Math.floor(span * 0.75));
+ let start = Math.max(0, targetIndex - beforeTarget);
+ const end = Math.min(items.length, start + span);
+ start = Math.max(0, end - span);
+ for (let index = start; index < end; index++) {
+ const item = items[index];
+ if (item?.node) hydratePostContent(item.node, ctx);
+ }
+ }
+
+ function schedulePostIdleWork(post, ctx, work, options) {
+ const { pendingKey, delay, settleMs, isPending, run } = options;
+ if (!post || !isPending() || post.dataset[pendingKey] === '1') return;
+ if (options.immediate && !streamScrollIsActive(ctx, settleMs)) return run();
+ post.dataset[pendingKey] = '1';
+ const cancel = () => cancelPostIdleWork(post, ctx, work, pendingKey);
+ const queue = () => {
+ work.cancels.set(post, scheduleIdleWork(attempt, delay));
+ if (ctx?.[work.pendingSet]) ctx[work.pendingSet].add(post);
+ };
+ const waitForVisibility = () => {
+ const resume = () => {
+ if (document.visibilityState !== 'visible') return;
+ document.removeEventListener('visibilitychange', resume);
+ work.cancels.delete(post);
+ attempt();
+ };
+ document.addEventListener('visibilitychange', resume);
+ work.cancels.set(post, () => document.removeEventListener('visibilitychange', resume));
+ };
+ const attempt = () => {
+ if (!isPending() || !post.isConnected) return cancel();
+ if (document.visibilityState !== 'visible') return waitForVisibility();
+ if (streamScrollIsActive(ctx, settleMs)) return queue();
+ cancel();
+ run();
+ };
+ queue();
+ }
+
+ function schedulePostContentHydration(post, ctx, immediate = false) {
+ schedulePostIdleWork(post, ctx, postContentHydrationWork, {
+ pendingKey: 'ldpContentHydrationPending', delay: 320, settleMs: 180, immediate,
+ isPending: () => post.dataset.ldpContentHydrated === '0',
+ run: () => hydratePostContent(post, ctx),
+ });
+ }
+
+ function hydrateStreamRangeContent(ctx, items, start, end) {
+ if (!ctx || !items || start >= end) return;
+ const pressure = scrollLoadPressure(ctx);
+ const beforeLead = ctx.streamScrollDirection < 0 ? Math.min(8, Math.max(3, pressure.lookahead)) : 2;
+ const afterLead = ctx.streamScrollDirection > 0 ? Math.min(6, Math.max(2, pressure.lookahead)) : 2;
+ const priorityStart = Math.max(start, ctx.streamViewportTopIndex - beforeLead);
+ const priorityEnd = Math.min(end - 1, ctx.streamViewportBottomIndex + afterLead);
+ const idleStart = Math.max(start, priorityStart - 3);
+ const idleEnd = Math.min(end - 1, priorityEnd + 3);
+ const initialTargetPostNumber = +(ctx.initialTargetHydrationBoundary || 0);
+ const initialTargetItem = initialTargetPostNumber && ctx.streamItemMap.get(initialTargetPostNumber);
+ const initialTargetIndex = initialTargetItem
+ ? (ctx.onlyOp ? initialTargetItem.layoutIndex : initialTargetItem.index)
+ : -1;
+ beginStreamViewportAnchorRestoreBatch(ctx);
+ try {
+ for (let index = idleStart; index <= idleEnd; index++) {
+ const item = items[index];
+ if (!item || !item.node) continue;
+ const beforeInitialTarget = initialTargetPostNumber && (
+ initialTargetIndex >= 0 ? index < initialTargetIndex : item.postNumber < initialTargetPostNumber
+ );
+ if (beforeInitialTarget &&
+ (ctx.initialTargetPrecedingContentDeferred || ctx.initialTargetPositionPending)) continue;
+ const priority = index >= priorityStart && index <= priorityEnd;
+ schedulePostContentHydration(item.node, ctx, priority);
+ }
+ } finally {
+ endStreamViewportAnchorRestoreBatch(ctx);
+ }
+ }
+
+ function tryOwnedNodeMutation(mutation) {
+ try {
+ mutation();
+ return true;
+ } catch (error) {
+ if (error && error.name === 'NotFoundError') return false;
+ throw error;
+ }
+ }
+
+ function removeOwnedNode(node, owner = null) {
+ if (!node) return false;
+ const parent = node.parentNode;
+ if (!parent || (owner && parent !== owner)) return false;
+ releasePersistentAvatarImages(node);
+ return tryOwnedNodeMutation(() => parent.removeChild(node));
+ }
+
+ function moveOwnedNodeBefore(owner, node, referenceNode = null) {
+ if (!owner || !node) return false;
+ const reference = referenceNode && referenceNode.parentNode === owner ? referenceNode : null;
+ if (node.parentNode === owner && node.nextSibling === reference) return true;
+ try {
+ owner.insertBefore(node, reference);
+ return true;
+ } catch (error) {
+ if (!error || error.name !== 'NotFoundError') throw error;
+ const freshReference = referenceNode && referenceNode.parentNode === owner ? referenceNode : null;
+ if (node.parentNode === owner && node.nextSibling === freshReference) return true;
+ owner.insertBefore(node, freshReference);
+ return true;
+ }
+ }
+
+ function replaceOwnedNode(currentNode, nextNode) {
+ if (!currentNode || !nextNode) return false;
+ const parent = currentNode.parentNode;
+ if (!parent) return false;
+ return tryOwnedNodeMutation(() => parent.replaceChild(nextNode, currentNode));
+ }
+
+ function mountStreamRange(ctx, start, end) {
+ if (!ctx || !ctx.streamItems || !ctx.commentsEl) return;
+ ensureStreamScaffold(ctx);
+ rebuildStreamPrefix(ctx);
+ const items = streamLayoutItems(ctx);
+ const total = items.length;
+ start = Math.max(0, Math.min(total, +start || 0));
+ end = Math.max(start, Math.min(total, +end || 0));
+
+ hydrateStreamRangeContent(ctx, items, start, end);
+ const firstRangeNode = items[start]?.node;
+ const lastRangeNode = items[end - 1]?.node;
+ const rangeEndpointsConnected = start === end ||
+ (firstRangeNode && lastRangeNode &&
+ firstRangeNode.parentElement === ctx.commentsEl && lastRangeNode.parentElement === ctx.commentsEl);
+ if (ctx.streamMountedItemCount === total &&
+ ctx.streamWindowStart === start && ctx.streamWindowEnd === end &&
+ ctx.streamMountedNodes.size === end - start && rangeEndpointsConnected) {
+ refreshStreamZebra(ctx, items.slice(start, end).map((item) => item.node));
+ updateStreamSpacerHeights(ctx, start, end);
+ syncCommentsHeaderPlacement(ctx);
+ restoreStreamViewportAnchor(ctx);
+ return;
+ }
+
+ const activeElement = readerActiveElement();
+ const activePost = ctx.commentsEl.contains(activeElement) && activeElement.closest('.ldp-post');
+ if (activePost) {
+ const activeItem = ctx.streamItemMap.get(streamPostNumber(activePost));
+ const activeIndex = activeItem && (ctx.onlyOp ? activeItem.layoutIndex : activeItem.index);
+ if (activeItem && (activeIndex < start || activeIndex >= end) && activeElement.blur) {
+ activeElement.blur();
+ }
+ }
+
+ const rangeItems = items.slice(start, end);
+ const desired = new Set(rangeItems.map((item) => item.node));
+ [...ctx.streamMountedNodes].forEach((node) => {
+ if (desired.has(node)) return;
+ ctx.streamResizeObserver.unobserve(node);
+ if (ctx.tracker?.unobserve) {
+ ctx.tracker.unobserve(node);
+ forEachDirectNestedPost(node, (child) => ctx.tracker.unobserve(child));
+ }
+ if (ctx.queueViewportTracker) ctx.queueViewportTracker.unobserve(node);
+ if (ctx.repliesIO?.unobserve) ctx.repliesIO.unobserve(node);
+ cancelPostContentHydration(node, ctx);
+ cancelPostLatexRender(node, ctx);
+ suspendReaderMedia(node);
+ const bundle = ctx.floorPreview ? ctx.floorPreview.getBundleForPost(node) : [node];
+ bundle.forEach((part) => removeOwnedNode(part, ctx.commentsEl));
+ if (node.querySelector(':scope > .ldp-content :is(video,audio,iframe),:scope > .ldp-content .ldp-media-carousel')) {
+ dehydratePostContent(node, ctx);
+ }
+ ctx.streamMountedNodes.delete(node);
+ });
+ pruneStreamHydratedContent(ctx, items, start, end);
+
+ let cursor = ctx.streamTopSpacer.nextSibling;
+ rangeItems.forEach((item) => {
+ const node = item.node;
+ const wasConnected = node.parentElement === ctx.commentsEl;
+ const deferPreTargetWork = ctx.initialTargetPrecedingContentDeferred &&
+ +item.postNumber < +(ctx.initialTargetHydrationBoundary || 0);
+ if (!wasConnected) node.classList.remove('ldp-nested-branches-ready');
+ keepOnlyOpPostExpanded(node, ctx);
+ ctx.streamMountedNodes.add(node);
+ const bundle = ctx.floorPreview ? ctx.floorPreview.getBundleForPost(node) : [node];
+ bundle.forEach((part) => {
+ if (part !== cursor) {
+ const reference = cursor && cursor.parentNode === ctx.commentsEl
+ ? cursor
+ : (ctx.streamBottomSpacer.parentNode === ctx.commentsEl ? ctx.streamBottomSpacer : null);
+ moveOwnedNodeBefore(ctx.commentsEl, part, reference);
+ }
+ cursor = part.nextSibling;
+ });
+ if (!wasConnected && document.visibilityState === 'visible') activateReaderMedia(node);
+ restoreReaderQuoteHighlightForPost(node, ctx);
+ if (node.dataset.ldpContentHydrated === '1' && node.dataset.ldpLatexRendered !== '1') {
+ schedulePostLatexRender(node, ctx);
+ }
+ if (!wasConnected) {
+ hydratePersistentAvatarImages(node);
+ ctx.streamResizeObserver.observe(node);
+ ctx.tracker.observe(node);
+ if (ctx.queueViewportTracker) ctx.queueViewportTracker.observe(node);
+ forEachDirectNestedPost(node, (child) => ctx.tracker.observe(child));
+ if (!deferPreTargetWork && node.dataset.childReplyTotal && ctx.repliesIO && ctx.repliesIO.observe) {
+ ctx.repliesIO.observe(node);
+ }
+ const renderedChildren = node.querySelector(':scope > .ldp-children > .ldp-post');
+ if (renderedChildren) scheduleNestedBranchSync(node, ctx);
+ }
+ });
+
+ updateStreamSpacerHeights(ctx, start, end);
+ ctx.streamWindowStart = start;
+ ctx.streamWindowEnd = end;
+ ctx.streamMountedItemCount = total;
+ refreshStreamZebra(ctx, rangeItems.map((item) => item.node));
+ syncCommentsHeaderPlacement(ctx);
+ if (ctx.floorPreview?.isOpen()) scheduleFloorPreviewSync(ctx);
+ restoreStreamViewportAnchor(ctx);
+ }
+
+ function streamPerformance(ctx) {
+ return ctx?.performance || BALANCED_PERFORMANCE;
+ }
+
+ function streamScrollIsActive(ctx, settleMs = 120) {
+ if (!ctx || !ctx.streamLastScrollAt) return false;
+ return Date.now() - ctx.streamLastScrollAt < settleMs && +(ctx.streamScrollVelocity || 0) > 0.08;
+ }
+
+ function schedulePostLatexRender(post, ctx) {
+ schedulePostIdleWork(post, ctx, postLatexRenderWork, {
+ pendingKey: 'ldpLatexPending', delay: 360, settleMs: 120, immediate: true,
+ isPending: () => post.dataset.ldpLatexRendered !== '1',
+ run: () => {
+ renderLatexInPost(post);
+ restoreReaderQuoteHighlightForPost(post, ctx);
+ },
+ });
+ }
+
+ function scrollLoadPressure(ctx) {
+ const lastScrollAt = +(ctx?.streamLastScrollAt || 0);
+ const age = Date.now() - lastScrollAt;
+ const viewportHeight = Math.max(1, +(ctx?.scrollRoot && ctx.scrollRoot.clientHeight || 0));
+ const viewportsPerSecond = age > 300
+ ? 0
+ : Math.max(0, +(ctx?.streamScrollVelocity || 0)) * 1000 / viewportHeight;
+ if (viewportsPerSecond >= 0.75) return { batches: 2, lookahead: viewportsPerSecond >= 2.5 ? 24 : 14 };
+ return { batches: 1, lookahead: 8 };
+ }
+
+ function repairBlankStreamViewport(ctx, start, end) {
+ if (!ctx || !ctx.streamMountedNodes.size) return;
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const items = streamLayoutItems(ctx);
+ let firstNode = items[start]?.node;
+ if (!firstNode || !firstNode.isConnected) return;
+ let firstRect = firstNode.getBoundingClientRect();
+ const leadingGap = firstRect.top - rootRect.top;
+ if (start > 0 && leadingGap > 1) {
+ const extraRows = Math.max(8, Math.ceil(leadingGap / STREAM_ESTIMATED_HEIGHT) + 4);
+ const nextStart = Math.max(0, end - streamPerformance(ctx).streamMaxItems, start - extraRows);
+ if (nextStart < start) {
+ const range = stableStreamMountRange(ctx, nextStart, end, items.length);
+ mountStreamRange(ctx, range.start, range.end);
+ start = ctx.streamWindowStart;
+ end = ctx.streamWindowEnd;
+ firstNode = items[start]?.node;
+ firstRect = firstNode?.getBoundingClientRect();
+ }
+ }
+ const commentsRect = ctx.commentsEl.getBoundingClientRect();
+ if (commentsRect.bottom < rootRect.top || commentsRect.top > rootRect.bottom) return;
+ const lastNode = items[end - 1]?.node;
+ if (!firstNode || !lastNode || !firstNode.isConnected || !lastNode.isConnected) return;
+ const lastRect = lastNode === firstNode ? firstRect : lastNode.getBoundingClientRect();
+ if (lastRect.bottom >= rootRect.top && firstRect.top <= rootRect.bottom) return;
+
+ const length = Math.max(1, end - start);
+ if (firstRect.top > rootRect.bottom && start > 0) {
+ const gap = firstRect.top - rootRect.bottom;
+ const extraRows = Math.max(6, Math.ceil(gap / STREAM_ESTIMATED_HEIGHT) + 3);
+ const nextStart = Math.max(0, start - extraRows);
+ const nextLength = Math.min(streamPerformance(ctx).streamMaxItems, length + extraRows);
+ const range = stableStreamMountRange(
+ ctx,
+ nextStart,
+ Math.min(items.length, nextStart + nextLength),
+ items.length
+ );
+ mountStreamRange(ctx, range.start, range.end);
+ return;
+ }
+ if (lastRect.bottom < rootRect.top && end < items.length) {
+ const gap = rootRect.top - lastRect.bottom;
+ const extraRows = Math.max(6, Math.ceil(gap / STREAM_ESTIMATED_HEIGHT) + 3);
+ const nextEnd = Math.min(items.length, end + extraRows);
+ const nextLength = Math.min(streamPerformance(ctx).streamMaxItems, length + extraRows);
+ const range = stableStreamMountRange(
+ ctx,
+ Math.max(0, nextEnd - nextLength),
+ nextEnd,
+ items.length
+ );
+ mountStreamRange(ctx, range.start, range.end);
+ }
+ }
+
+ function mountStreamPost(ctx, postNumber) {
+ const item = ctx?.streamItemMap && ctx.streamItemMap.get(+postNumber);
+ if (!item || !item.node || !streamItemMatchesFilter(ctx, item)) return null;
+ const items = streamLayoutItems(ctx);
+ const layoutIndex = ctx.onlyOp ? item.layoutIndex : item.index;
+ rebuildStreamPrefix(ctx);
+ const span = streamWorkingSetSpan(ctx, items.length);
+ const start = Math.max(0, Math.min(layoutIndex - Math.floor(span / 2), items.length - span));
+ const end = Math.min(items.length, start + span);
+ mountStreamRange(ctx, start, end);
+ hydratePostContent(item.node, ctx);
+ return item.node.isConnected ? item.node : null;
+ }
+
+ function getStreamPostNode(ctx, postNumber, options = {}) {
+ const num = +postNumber;
+ if (!ctx || !num) return null;
+ const node = ctx.streamNodeMap?.get(num);
+ if (node?.isConnected) return node;
+ if (options.mount) return mountStreamPost(ctx, num);
+ return null;
+ }
+
+ function getStreamListOffset(ctx) {
+ if (Number.isFinite(ctx.streamListOffset)) return ctx.streamListOffset;
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const commentsRect = ctx.commentsEl.getBoundingClientRect();
+ ctx.streamListOffset = ctx.scrollRoot.scrollTop + commentsRect.top - rootRect.top;
+ return ctx.streamListOffset;
+ }
+
+ function stableStreamMountRange(ctx, start, end, total) {
+ start = Math.max(0, Math.min(total, +start || 0));
+ end = Math.max(start, Math.min(total, +end || 0));
+ if (ctx.streamWindowResetPending) {
+ ctx.streamWindowResetPending = false;
+ return { start, end };
+ }
+
+ const currentStart = +ctx.streamWindowStart || 0;
+ const currentEnd = +ctx.streamWindowEnd || 0;
+ const currentLength = currentEnd - currentStart;
+ const currentValid = ctx.streamMountedNodes.size > 0 && currentLength > 0 &&
+ currentStart >= 0 && currentEnd <= total;
+ if (!currentValid) return { start, end };
+ const desiredLength = Math.max(1, end - start);
+ const maxItems = Math.max(1, streamPerformance(ctx).streamMaxItems);
+ const retention = Math.min(8, Math.max(3, Math.ceil(desiredLength * .3)));
+ const stableLimit = Math.min(maxItems, desiredLength + retention);
+ const anchorOwner = ctx.streamViewportAnchor?.owner;
+ const anchorItem = anchorOwner?.isConnected
+ ? ctx.streamItemMap.get(streamPostNumber(anchorOwner))
+ : null;
+ const anchorIndex = anchorItem && (ctx.onlyOp ? anchorItem.layoutIndex : anchorItem.index);
+ const currentContainsAnchor = Number.isInteger(anchorIndex) &&
+ anchorIndex >= currentStart && anchorIndex < currentEnd;
+ const nextContainsAnchor = Number.isInteger(anchorIndex) && anchorIndex >= start && anchorIndex < end;
+ if (currentContainsAnchor && !nextContainsAnchor) {
+ const span = Math.min(maxItems, desiredLength + retention);
+ if (ctx.streamScrollDirection < 0) {
+ const trailingItems = Math.min(6, Math.max(2, Math.ceil(span * 0.1)));
+ end = Math.min(total, anchorIndex + trailingItems + 1);
+ start = Math.max(0, end - span);
+ end = Math.min(total, start + span);
+ } else if (ctx.streamScrollDirection > 0) {
+ const leadingItems = Math.min(6, Math.max(2, Math.ceil(span * 0.1)));
+ start = Math.max(0, anchorIndex - leadingItems);
+ end = Math.min(total, start + span);
+ start = Math.max(0, end - span);
+ } else {
+ start = Math.max(0, anchorIndex - Math.floor(span / 2));
+ end = Math.min(total, start + span);
+ start = Math.max(0, end - span);
+ }
+ return { start, end };
+ }
+ const unionStart = Math.min(currentStart, start);
+ const unionEnd = Math.max(currentEnd, end);
+ if (unionEnd - unionStart <= stableLimit) return { start: unionStart, end: unionEnd };
+
+ // Keep only a small trailing overlap. The configured maximum remains a hard
+ // ceiling, not a target that gradually retains every visited floor.
+ if (start >= currentStart && end >= currentEnd) {
+ const nextStart = Math.max(0, start - retention);
+ return { start: Math.max(0, end - maxItems, nextStart), end };
+ }
+ if (start <= currentStart && end <= currentEnd) {
+ const nextEnd = Math.min(total, end + retention);
+ return { start, end: Math.min(nextEnd, start + maxItems) };
+ }
+ return { start, end };
+ }
+
+ function syncStreamWindow(ctx) {
+ if (!ctx || !ctx.commentsEl || !ctx.scrollRoot || !ctx.streamItems.length) return;
+ const items = streamLayoutItems(ctx);
+ ensureStreamScaffold(ctx);
+ rebuildStreamPrefix(ctx);
+ if (!items.length) {
+ mountStreamRange(ctx, 0, 0);
+ return;
+ }
+ const listOffset = getStreamListOffset(ctx);
+ const viewportHeight = Math.max(ctx.scrollRoot.clientHeight, 1);
+ const performance = streamPerformance(ctx);
+ const overscan = viewportHeight * performance.streamOverscanViewports;
+ const velocityLead = Math.min(
+ viewportHeight * 2,
+ Math.max(0, Number(ctx.streamScrollVelocity) || 0) * 700
+ );
+ const beforeOverscan = overscan + (ctx.streamScrollDirection < 0 ? velocityLead : 0);
+ const afterOverscan = overscan + (ctx.streamScrollDirection > 0 ? velocityLead : 0);
+ const localTop = Math.max(0, ctx.scrollRoot.scrollTop - listOffset);
+ ctx.streamViewportLocalTop = localTop;
+ ctx.streamViewportHeight = viewportHeight;
+ ctx.streamViewportTopIndex = findStreamIndexAtOffset(ctx.streamPrefix, localTop);
+ ctx.streamViewportBottomIndex = findStreamIndexAtOffset(
+ ctx.streamPrefix,
+ Math.max(0, localTop + viewportHeight - 10)
+ );
+ let start = findStreamIndexAtOffset(ctx.streamPrefix, Math.max(0, localTop - beforeOverscan));
+ let end = findStreamIndexAtOffset(ctx.streamPrefix, localTop + viewportHeight + afterOverscan) + 1;
+ if (end - start > performance.streamMaxItems) {
+ const center = findStreamIndexAtOffset(ctx.streamPrefix, localTop + viewportHeight / 2);
+ start = Math.max(0, center - Math.floor(performance.streamMaxItems / 2));
+ end = Math.min(items.length, start + performance.streamMaxItems);
+ start = Math.max(0, end - performance.streamMaxItems);
+ }
+ const stableRange = stableStreamMountRange(
+ ctx,
+ start,
+ end,
+ items.length
+ );
+ start = stableRange.start;
+ end = stableRange.end;
+ const needsRepair = ctx.streamNeedsRepair || start !== ctx.streamWindowStart || end !== ctx.streamWindowEnd;
+ mountStreamRange(ctx, start, end);
+ ctx.streamNeedsRepair = false;
+ if (needsRepair) {
+ repairBlankStreamViewport(ctx, start, end);
+ }
+ if (ctx.viewportPersistenceEnabled && ctx.loader && isFunction(ctx.loader.persistViewedPostNumbers)) {
+ const visibleStart = Math.max(0, Math.min(items.length - 1, ctx.streamViewportTopIndex));
+ const visibleEnd = Math.max(visibleStart, Math.min(items.length - 1, ctx.streamViewportBottomIndex));
+ ctx.loader.persistViewedPostNumbers(
+ items.slice(visibleStart, visibleEnd + 1).map((item) => item?.postNumber)
+ );
+ }
+ }
+
+ function scheduleStreamWindowSync(ctx) {
+ if (!ctx || ctx.streamWindowFrame) return;
+ ctx.streamWindowFrame = requestAnimationFrame(() => {
+ ctx.streamWindowFrame = 0;
+ if (ctx.streamViewportAnchorCapturePending) captureStreamViewportAnchor(ctx);
+ syncStreamWindow(ctx);
+ // Defer geometry reads until the mounted window has completed a layout.
+ if (ctx.timeline) ctx.timeline.update();
+ });
+ }
+
+ function flushStreamWindow(ctx) {
+ if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame);
+ ctx.streamWindowFrame = 0;
+ if (ctx.streamViewportAnchorCapturePending) captureStreamViewportAnchor(ctx);
+ syncStreamWindow(ctx);
+ }
+
+ function scheduleFloorPreviewSync(ctx) {
+ if (!ctx || !ctx.floorPreview || ctx.floorPreviewSyncFrame) return;
+ ctx.floorPreviewSyncFrame = requestAnimationFrame(() => {
+ ctx.floorPreviewSyncFrame = 0;
+ if (ctx.floorPreview?.isOpen()) ctx.floorPreview.syncPlacement();
+ });
+ }
+
+ const PINNED_STREAM_SCROLL_OPTIONS = Object.freeze({
+ preferStream: true,
+ pinStreamTarget: true,
+ });
+
+ function scrollToPost(postNumber, ctx, options = {}) {
+ clearStreamViewportAnchor(ctx);
+ ctx.streamScrollDirection = 0;
+ const programmaticTarget = options.pinStreamTarget === true
+ ? beginStreamProgrammaticTarget(ctx, postNumber)
+ : null;
+ if (programmaticTarget) hydrateStreamNavigationNeighborhood(ctx, postNumber);
+ let target = null;
+ if (options.preferStream && ctx.streamNodeMap) target = getStreamPostNode(ctx, postNumber, { mount: true });
+ if (!target && options.requireStream) {
+ if (programmaticTarget) clearStreamProgrammaticTarget(ctx);
+ return false;
+ }
+ if (!target) target = ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${postNumber}"]`);
+ if (!target) {
+ if (programmaticTarget) clearStreamProgrammaticTarget(ctx);
+ return false;
+ }
+ const previousScrollTop = ctx.scrollRoot.scrollTop;
+ const alignOffset = Number(options.alignOffset);
+ if (!options.alignTop && !options.alignBottom && !options.alignBottomCenter && !Number.isFinite(alignOffset) &&
+ +postNumber < +(ctx.initialTargetHydrationBoundary || 0) &&
+ isFunction(ctx.releaseInitialPrecedingContent)) {
+ ctx.releaseInitialPrecedingContent();
+ }
+ hydratePostContent(target, ctx);
+ const resolvedVisualTarget = isFunction(options.resolveVisualTarget)
+ ? options.resolveVisualTarget(target)
+ : options.visualNode;
+ const scrollTarget = resolvedVisualTarget?.isConnected ? resolvedVisualTarget : target;
+ const resolvedHighlightTarget = isFunction(options.resolveHighlightTarget)
+ ? options.resolveHighlightTarget(target)
+ : options.highlightTarget;
+ const highlightTarget = resolvedHighlightTarget?.isConnected
+ ? resolvedHighlightTarget
+ : target;
+ const expandedJumpTarget = target.classList.contains('ldp-nested-collapsed') && !options.keepCollapsed;
+ if (expandedJumpTarget) {
+ target.dataset.ldpJumpExpanded = '1';
+ setNestedReplyCollapsed(target, false);
+ }
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ let targetRect = scrollTarget.getBoundingClientRect();
+ const fits = targetRect.height <= rootRect.height;
+ if (options.alignBottom && fits) {
+ const desiredScrollTop = ctx.scrollRoot.scrollTop + targetRect.bottom - rootRect.bottom;
+ const currentTopSpacerHeight = parseFloat(
+ ctx.streamTopSpacer.style.getPropertyValue('--ldp-stream-spacer-height')
+ ) || 0;
+ ctx.initialTargetHeadSpacerHeight = Math.max(
+ +(ctx.initialTargetHeadSpacerHeight || 0),
+ currentTopSpacerHeight + Math.max(0, -desiredScrollTop) + 2
+ );
+ setStreamSpacerHeight(
+ ctx.streamTopSpacer,
+ Math.max(currentTopSpacerHeight, ctx.initialTargetHeadSpacerHeight)
+ );
+ targetRect = scrollTarget.getBoundingClientRect();
+ }
+ let correction = targetRect.top - rootRect.top;
+ if (Number.isFinite(alignOffset)) correction -= alignOffset;
+ else if (options.alignBottomCenter)
+ correction = targetRect.bottom - (rootRect.top + rootRect.height / 2);
+ else if (options.alignBottom && fits) correction = targetRect.bottom - rootRect.bottom;
+ else if ((options.alignCenter ||
+ (!options.alignTop && !options.alignNearest && +postNumber !== 1)) && fits)
+ correction = targetRect.top + targetRect.height / 2 - (rootRect.top + rootRect.height / 2);
+ else if (options.alignNearest) {
+ if (targetRect.top < rootRect.top && targetRect.bottom < rootRect.bottom)
+ correction = targetRect.top - rootRect.top;
+ else if (targetRect.bottom > rootRect.bottom && targetRect.top > rootRect.top)
+ correction = targetRect.bottom - rootRect.bottom;
+ else correction = 0;
+ }
+ if (Math.abs(correction) >= 1) ctx.scrollRoot.scrollTop += correction;
+ markStreamInternalScroll(ctx, previousScrollTop);
+ if (programmaticTarget) clearStreamProgrammaticTarget(ctx);
+ if (options.highlight !== false) {
+ if (expandedJumpTarget) {
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ if (highlightTarget.isConnected && !target.classList.contains('ldp-nested-collapsed')) {
+ keepJumpHighlightUntilSettled(highlightTarget);
+ }
+ }));
+ } else {
+ keepJumpHighlightUntilSettled(highlightTarget);
+ }
+ }
+ scheduleStreamWindowSync(ctx);
+ return true;
+ }
+
+ function clearJumpBackLinks(ctx) {
+ ctx.scrollRoot.querySelectorAll('.ldp-jump-backbar').forEach((el) => el.remove());
+ }
+
+ function showJumpBackLink(targetPostNumber, sourcePostNumber, ctx) {
+ const target = getStreamPostNode(ctx, targetPostNumber, { mount: true }) ||
+ ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${targetPostNumber}"]`);
+ if (!target || !sourcePostNumber || sourcePostNumber === targetPostNumber) return;
+ clearJumpBackLinks(ctx);
+ const bar = makeElement('div');
+ bar.className = 'ldp-jump-linkbar ldp-jump-backbar';
+ bar.innerHTML = `
`;
+ target.insertBefore(bar, target.firstChild);
+ }
+
+ function ensureParentJumpOnStreamPost(targetPostNumber, parentPostNumber, ctx) {
+ const target = getStreamPostNode(ctx, targetPostNumber);
+ const parent = +parentPostNumber;
+ if (!target || !parent || parent === +targetPostNumber) return;
+ target.dataset.replyToPostNumber = String(parent);
+ ensureParentJumpControl(target, parent);
+ }
+
+ function findFloorPreviewSource(postNumber, ctx) {
+ const num = +postNumber;
+ if (!num) return null;
+ const threadNode = ctx.replyThreadWindow?.querySelector(`.ldp-post[data-post-number="${num}"]`);
+ if (threadNode) return threadNode;
+ // Previewing must not remount the virtual window. Detached stream nodes still
+ // contain the complete post markup and are safe to clone into the preview.
+ const streamNode = ctx.streamNodeMap?.get(num);
+ if (streamNode) return streamNode;
+ return (ctx.scrollRoot?.querySelector(`.ldp-post[data-post-number="${num}"]`)) ||
+ null;
+ }
+
+ async function ensureFloorPreviewSource(postNumber, ctx) {
+ const target = +postNumber;
+ let source = findFloorPreviewSource(target, ctx);
+ if (!target || source || !ctx) return source;
+
+ const loaders = isFunction(ctx.loadPost)
+ ? ['single', 'around'].map((scope) => () => ctx.loadPost(target, {
+ mount: false,
+ priority: POST_REQUEST_PRIORITY.target,
+ scope,
+ })) : [];
+
+ for (const load of loaders) {
+ try {
+ await load();
+ } catch (error) {
+ if (shouldStopReaderFallback(error)) break;
+ }
+ source = findFloorPreviewSource(target, ctx);
+ if (source) return source;
+ }
+ return null;
+ }
+
+ function resetFloorPreview(preview) {
+ preview.textContent = '';
+ const body = makeElement('div');
+ body.className = 'ldp-floor-preview-body';
+ const close = makeElement('button');
+ close.className = 'ldp-floor-preview-close';
+ close.type = 'button';
+ setLabel(close, '关闭预览');
+ close.innerHTML = `
Esc 关闭${icon('x')}`;
+ preview.append(body, close);
+ return body;
+ }
+
+ function fillFloorPreview(preview, source, postNumber) {
+ const body = resetFloorPreview(preview, postNumber);
+ const wrap = makeElement('div');
+ wrap.className = 'ldp-floor-preview-post';
+ const head = source.querySelector(':scope > .ldp-post-head')?.cloneNode(true);
+ const content = source.querySelector(':scope > .ldp-content')?.cloneNode(true);
+ const boosts = source.querySelector(':scope > .ldp-boost-list')?.cloneNode(true);
+ if (head) {
+ const readState = head.querySelector(':scope > .ldp-post-read-state');
+ head.querySelectorAll(':scope > .ldp-floor, :scope > .ldp-actions').forEach((el) => el.remove());
+ const floorBtn = makeElement('button');
+ floorBtn.className = 'ldp-floor ldp-jump-preview-self';
+ floorBtn.type = 'button';
+ floorBtn.dataset.targetPostNumber = String(postNumber);
+ setLabel(floorBtn, `跳到楼层 #${postNumber}`);
+ floorBtn.textContent = `#${postNumber}`;
+ head.appendChild(floorBtn);
+ if (readState) head.appendChild(readState);
+ preview.insertBefore(head, body);
+ const close = preview.querySelector(':scope > .ldp-floor-preview-close');
+ if (close) head.appendChild(close);
+ }
+ if (content) wrap.appendChild(content);
+ else {
+ const empty = makeElement('div');
+ empty.className = 'ldp-floor-preview-tip';
+ empty.textContent = `#${postNumber} 暂无可预览内容`;
+ wrap.appendChild(empty);
+ }
+ if (boosts) wrap.appendChild(boosts);
+ wrap.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
+ body.appendChild(wrap);
+ }
+
+ function syncFloorPreviewLine(preview, line, layer) {
+ const lineRect = line.getBoundingClientRect();
+ const previewRect = preview.getBoundingClientRect();
+ const layerRect = layer ? layer.getBoundingClientRect() : previewRect;
+ const avatar = layer?.querySelector(':scope > .ldp-post-head .ldp-avatar');
+ const avatarRect = avatar ? avatar.getBoundingClientRect() : layerRect;
+ const previewAvatar = preview.querySelector(':scope > .ldp-post-head .ldp-avatar');
+ const previewAvatarRect = previewAvatar ? previewAvatar.getBoundingClientRect() : previewRect;
+ const previewX = Math.max(0, previewRect.left - lineRect.left);
+ const childX = Math.max(0, avatarRect.left - lineRect.left);
+ const childY = Math.max(0, avatarRect.top + avatarRect.height / 2 - lineRect.top);
+ const previewY = previewAvatarRect.top + previewAvatarRect.height / 2 - lineRect.top;
+ const baseRail = 15;
+ const axisX = Math.min(previewX, childX) - baseRail;
+ const branchGap = 5;
+ const previewBranchGap = 5;
+ const previewBranchWidth = Math.max(0, previewX - axisX - previewBranchGap);
+ const childBranchWidth = Math.max(0, childX - axisX - branchGap);
+ line.style.setProperty('--ldp-parent-axis-left', `${Math.round(axisX)}px`);
+ line.style.setProperty('--ldp-parent-axis-top', `${Math.round(previewY)}px`);
+ line.style.setProperty('--ldp-parent-preview-rail-width', `${Math.round(previewBranchWidth)}px`);
+ line.style.setProperty('--ldp-parent-child-branch-width', `${Math.round(childBranchWidth)}px`);
+ line.style.setProperty('--ldp-parent-child-y', `${Math.round(childY)}px`);
+ line.style.setProperty('--ldp-parent-preview-y', `${Math.round(previewY)}px`);
+ }
+
+ function keepFloorPreviewAnchor(layer, scrollRoot, anchorTop, ctx = null) {
+ if (!layer || !scrollRoot || !layer.isConnected) return;
+ const correction = layer.getBoundingClientRect().top - anchorTop;
+ if (Math.abs(correction) < 1) return;
+ const previousScrollTop = scrollRoot.scrollTop;
+ scrollRoot.scrollTop += correction;
+ markStreamInternalScroll(ctx, previousScrollTop);
+ }
+
+ function preserveFloorPreviewAnchor(layer, scrollRoot, update, ctx = null) {
+ if (!layer || !scrollRoot) {
+ update();
+ return;
+ }
+ const anchorTop = layer.getBoundingClientRect().top;
+ update();
+ keepFloorPreviewAnchor(layer, scrollRoot, anchorTop, ctx);
+ }
+
+ function placeFloorPreview(preview, line, anchor, scrollRoot = null, onFinish = null, ctx = null) {
+ const layer = anchor.closest('.ldp-post');
+ if (!layer || !layer.parentElement) return;
+ preview._ldpRelatedPost = layer;
+ let placed = false;
+ // Insert the complete preview and correct the anchor in the same task so the
+ // browser never paints the intermediate shifted position.
+ preserveFloorPreviewAnchor(layer, scrollRoot, () => {
+ const currentParent = layer.parentElement;
+ if (!currentParent) return;
+ if (!placed && (preview.parentElement !== currentParent || preview.nextElementSibling !== line || line.nextElementSibling !== layer)) {
+ moveOwnedNodeBefore(currentParent, preview, layer);
+ moveOwnedNodeBefore(currentParent, line, layer);
+ }
+ placed = true;
+ preview.classList.add('open');
+ line.classList.add('open');
+ syncFloorPreviewLine(preview, line, layer);
+ }, ctx);
+ if (!placed) return;
+ syncChildControls(layer);
+ if (onFinish) onFinish();
+ }
+
+ function canShowFloorPreview(anchor) {
+ const post = anchor?.closest('.ldp-post');
+ return !!post && !post.classList.contains('ldp-nested-collapsed');
+ }
+
+ async function showFloorPreview(anchor, ctx, state, options = {}) {
+ const postNumber = +anchor.dataset.targetPostNumber;
+ const allowCollapsed = options.allowCollapsed === true;
+ if (!postNumber || (!allowCollapsed && !canShowFloorPreview(anchor))) return;
+ clearTimeout(state.hideTimer);
+ state.anchor = anchor;
+ const token = ++state.token;
+ const preview = state.preview;
+ // Do not expose a loading shell whose later height change would move the
+ // virtual list. Materialize the requested floor DOM off-screen first, then
+ // insert the final preview exactly once.
+ const source = await ensureFloorPreviewSource(postNumber, ctx);
+ if (token !== state.token || state.anchor !== anchor || !anchor.isConnected ||
+ (!allowCollapsed && !canShowFloorPreview(anchor))) return;
+ if (source) {
+ hydratePostContent(source, ctx);
+ fillFloorPreview(preview, source, postNumber);
+ const floorButton = anchor.closest('.ldp-descendant-replies-window') && preview.querySelector('.ldp-jump-preview-self');
+ if (floorButton) floorButton.outerHTML = `
#${postNumber}`;
+ } else {
+ const body = resetFloorPreview(preview, postNumber);
+ body.innerHTML = `
${esc(`还没加载到 #${postNumber}`)}
`;
+ }
+ const relatedPost = anchor.closest('.ldp-post');
+ if (isFunction(options.beforePlace)) options.beforePlace();
+ if (!canShowFloorPreview(anchor)) return;
+ setNestedReplyEscapeHint(relatedPost, isEscCollapsibleNestedReply(relatedPost));
+ const previewScrollRoot = anchor.closest('.ldp-descendant-replies-list') || ctx.scrollRoot;
+ placeFloorPreview(preview, state.line, anchor, previewScrollRoot, () => {
+ alignCollapsedFloorPreview(preview, relatedPost, options.collapsedBottom, ctx);
+ if (ctx.timeline) ctx.timeline.sync();
+ }, ctx);
+ }
+
+ function bindFloorPreview(modal, ctx) {
+ const root = ctx.scrollRoot || modal;
+ const floorPreviewScope = createLifecycleScope();
+ const floorPreviewBundles = new Map();
+ const openBundles = new Set();
+ const bundleByPost = new WeakMap();
+ let activeBundle = null;
+ const bundlePost = (bundle) => bundle && (bundle.post || (bundle.anchor?.closest('.ldp-post')));
+ const unregisterBundle = (bundle) => {
+ if (!bundle) return;
+ clearTimeout(bundle.showTimer);
+ openBundles.delete(bundle);
+ if (bundle.anchor) floorPreviewBundles.delete(bundle.anchor);
+ const post = bundlePost(bundle);
+ if (post)
+ bundleByPost.delete(post);
+ removeOwnedNode(bundle.preview);
+ removeOwnedNode(bundle.line);
+ if (activeBundle === bundle) activeBundle = null;
+ };
+ const cleanupClosedBundles = () => {
+ [...openBundles].forEach((bundle) => {
+ // Virtual-list unmounting temporarily detaches an open preview together
+ // with its post. Keep that bundle so the same DOM can be mounted again.
+ if (!bundle.pending && !bundle.preview.classList.contains('open')) unregisterBundle(bundle);
+ });
+ };
+ const activateBundle = (bundle) => {
+ const post = bundlePost(bundle);
+ openBundles.add(bundle);
+ if (post) bundleByPost.set(post, bundle);
+ activeBundle = bundle;
+ };
+ const bundleVisibleInViewport = (bundle) => {
+ if (!bundle || !bundle.preview.classList.contains('open')) return false;
+ const rect = bundle.preview.getBoundingClientRect();
+ const rootRect = (ctx.scrollRoot || modal).getBoundingClientRect();
+ return rect.bottom > rootRect.top && rect.top < rootRect.bottom && rect.right > rootRect.left && rect.left < rootRect.right;
+ };
+ const hideBundle = (bundle) => {
+ if (!bundle) return;
+ const post = bundlePost(bundle);
+ clearTimeout(bundle.hideTimer);
+ bundle.token++;
+ const previewScrollRoot = bundle.anchor?.closest('.ldp-descendant-replies-list') || root;
+ preserveFloorPreviewAnchor(post, previewScrollRoot, () => {
+ bundle.preview.classList.remove('open');
+ bundle.line.classList.remove('open');
+ }, ctx);
+ if (post) syncChildControls(post);
+ unregisterBundle(bundle);
+ return post;
+ };
+ const hide = (targetPost = null) => {
+ cleanupClosedBundles();
+ const bundles = [...openBundles];
+ let bundle = targetPost
+ ? bundles.find((item) => bundlePost(item) === targetPost)
+ : null;
+ if (targetPost && !bundle) return null;
+ if (!bundle && activeBundle && bundleVisibleInViewport(activeBundle)) bundle = activeBundle;
+ if (!bundle) {
+ const visibleBundles = bundles.filter(bundleVisibleInViewport);
+ bundle = visibleBundles[visibleBundles.length - 1] || activeBundle || bundles[bundles.length - 1];
+ }
+ return hideBundle(bundle);
+ };
+ function createFloorPreviewBundle(anchor) {
+ const preview = makeElement('div');
+ preview.className = 'ldp-floor-preview';
+ const line = makeElement('div');
+ line.className = 'ldp-floor-preview-link';
+ line.setAttribute('aria-hidden', 'true');
+ root.appendChild(preview);
+ root.appendChild(line);
+ const bundle = {
+ preview, line, anchor, post: anchor.closest('.ldp-post'), token: 0,
+ hideTimer: 0, showTimer: 0, hoverPending: false, pending: false,
+ };
+ floorPreviewBundles.set(anchor, bundle);
+ preview.addEventListener('click', async (e) => {
+ if (e.target.closest('.ldp-floor-preview-close')) {
+ hideBundle(bundle);
+ return;
+ }
+ const floorBtn = e.target.closest('.ldp-jump-preview-self');
+ if (!floorBtn) return;
+ consumeEvent(e);
+ const targetPostNumber = +floorBtn.dataset.targetPostNumber;
+ if (!targetPostNumber) return;
+ floorBtn.blur();
+ clearJumpBackLinks(ctx);
+ await ctx.timeline.jumpTo(targetPostNumber);
+ });
+ return bundle;
+ }
+ const bundleForAnchor = (anchor) => floorPreviewBundles.get(anchor) || createFloorPreviewBundle(anchor);
+ const show = async (anchor, options = {}) => {
+ if (!anchor || (!options.allowCollapsed && !canShowFloorPreview(anchor))) return false;
+ const bundle = bundleForAnchor(anchor);
+ bundle.pending = true;
+ activateBundle(bundle);
+ try {
+ await showFloorPreview(anchor, ctx, bundle, options);
+ } finally {
+ bundle.pending = false;
+ }
+ const opened = bundle.preview.classList.contains('open');
+ if (!opened) unregisterBundle(bundle);
+ return opened;
+ };
+ const previewVisibleInViewport = () => {
+ cleanupClosedBundles();
+ return [...openBundles].some(bundleVisibleInViewport);
+ };
+ const onFloorPreviewMouseOver = (e) => {
+ if (e.target.closest('.ldp-floor-preview')) return;
+ const btn = e.target.closest('.ldp-jump-parent');
+ if (!btn || !modal.contains(btn)) return;
+ if (btn.contains(e.relatedTarget)) return;
+ if (!canShowFloorPreview(btn)) {
+ const bundle = floorPreviewBundles.get(btn);
+ if (bundle) hideBundle(bundle);
+ return;
+ }
+ const bundle = bundleForAnchor(btn);
+ if (bundle.preview.classList.contains('open')) return;
+ clearTimeout(bundle.showTimer);
+ bundle.hoverPending = true;
+ bundle.showTimer = setTimeout(async () => {
+ bundle.showTimer = 0;
+ if (!bundle.hoverPending || !btn.isConnected || !btn.matches(':hover')) {
+ bundle.hoverPending = false;
+ unregisterBundle(bundle);
+ return;
+ }
+ activateBundle(bundle);
+ await showFloorPreview(btn, ctx, bundle);
+ bundle.hoverPending = false;
+ if (!bundle.preview.classList.contains('open')) unregisterBundle(bundle);
+ }, 250);
+ };
+ const onFloorPreviewMouseOut = (e) => {
+ const btn = e.target.closest('.ldp-jump-parent');
+ if (!btn || btn.contains(e.relatedTarget)) return;
+ const bundle = floorPreviewBundles.get(btn);
+ if (!bundle || !bundle.hoverPending) return;
+ clearTimeout(bundle.showTimer);
+ bundle.showTimer = 0;
+ bundle.hoverPending = false;
+ if (!bundle.preview.classList.contains('open')) {
+ bundle.token++;
+ unregisterBundle(bundle);
+ }
+ };
+ const onFloorPreviewFocusIn = (e) => {
+ const btn = e.target.closest('.ldp-jump-parent');
+ if (!btn || !canShowFloorPreview(btn)) return;
+ const bundle = bundleForAnchor(btn);
+ clearTimeout(bundle.showTimer);
+ bundle.showTimer = 0;
+ bundle.hoverPending = false;
+ activateBundle(bundle);
+ showFloorPreview(btn, ctx, bundle);
+ };
+ floorPreviewScope.listen(modal, 'mouseover', onFloorPreviewMouseOver);
+ floorPreviewScope.listen(modal, 'mouseout', onFloorPreviewMouseOut);
+ floorPreviewScope.listen(modal, 'focusin', onFloorPreviewFocusIn);
+ const getBundleForPost = (post) => {
+ cleanupClosedBundles();
+ const bundle = bundleByPost.get(post);
+ return bundle ? [bundle.preview, bundle.line, post] : [post];
+ };
+ const syncPlacement = () => {
+ cleanupClosedBundles();
+ openBundles.forEach((bundle) => {
+ const post = bundlePost(bundle);
+ if (!post || !post.isConnected) return;
+ if (bundle.preview.nextElementSibling !== bundle.line || bundle.line.nextElementSibling !== post) return;
+ syncFloorPreviewLine(bundle.preview, bundle.line, post);
+ syncChildControls(post);
+ });
+ };
+ const destroy = () => {
+ floorPreviewScope.destroy();
+ floorPreviewBundles.forEach((bundle) => {
+ clearTimeout(bundle.showTimer);
+ clearTimeout(bundle.hideTimer);
+ bundle.token++;
+ removeOwnedNode(bundle.preview);
+ removeOwnedNode(bundle.line);
+ });
+ floorPreviewBundles.clear();
+ openBundles.clear();
+ activeBundle = null;
+ };
+ return {
+ show,
+ hide,
+ isOpen: () => {
+ cleanupClosedBundles();
+ return openBundles.size > 0;
+ },
+ isVisibleInViewport: previewVisibleInViewport,
+ getBundleForPost,
+ syncPlacement,
+ destroy,
+ };
+ }
+
+ /* ============ 3. 图片浏览器 ============ */
+ function absoluteImageUrl(src) {
+ try { return new URL(String(src || ''), location.href).href; }
+ catch { return String(src || ''); }
+ }
+
+ async function cachedEmojiImageSource(rawSource) {
+ const source = absoluteImageUrl(rawSource);
+ if (!source || source.startsWith('data:') || source.startsWith('blob:') ||
+ typeof caches === 'undefined' || RESOURCE_CACHE_CLEARING) return '';
+ const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
+ const cachedObjectUrl = touchResourceObjectUrl(EMOJI_IMAGE_OBJECT_URL_CACHE, source);
+ if (cachedObjectUrl) return cachedObjectUrl;
+ try {
+ const { response } = await persistentResourceCacheEntry(
+ LDP_EMOJI_IMAGE_CACHE_NAME, source, LDP_EMOJI_IMAGE_CACHE_MAX_AGE
+ );
+ if (!response) return '';
+ const blob = await response.blob();
+ return blob.size && !resourceCacheSignal.aborted
+ ? cacheResourceObjectUrl(EMOJI_IMAGE_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 512)
+ : '';
+ } catch {
+ return '';
+ }
+ }
+
+ function persistEmojiImage(rawSource) {
+ const source = absoluteImageUrl(rawSource);
+ if (!source || source.startsWith('data:') || source.startsWith('blob:') ||
+ typeof caches === 'undefined' || RESOURCE_CACHE_CLEARING) {
+ return Promise.resolve();
+ }
+ const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
+ const pendingRequest = EMOJI_IMAGE_RESOURCE_REQUESTS.get(source);
+ if (pendingRequest) return pendingRequest;
+ const request = (async () => {
+ try {
+ const { storage, response: existing } = await persistentResourceCacheEntry(
+ LDP_EMOJI_IMAGE_CACHE_NAME, source, LDP_EMOJI_IMAGE_CACHE_MAX_AGE
+ );
+ if (existing) return;
+ const resource = await fetchReaderPublicResource(
+ source, POST_REQUEST_PRIORITY.background, 'media', resourceCacheSignal
+ );
+ if (!resource) return;
+ try {
+ const fetched = resource.response;
+ if (!fetched.ok) return;
+ const blob = await fetched.blob();
+ if (!blob.size || resourceCacheSignal.aborted) return;
+ await storage.put(source, createPersistentResourceResponse(blob, fetched.headers.get('content-type')));
+ } finally {
+ resource.release();
+ }
+ } catch {}
+ })();
+ return trackSingleFlightRequest(EMOJI_IMAGE_RESOURCE_REQUESTS, source, request);
+ }
+
+ function watchPersistentEmojiImages(content) {
+ if (!content || content._ldpStopEmojiImageCache) return;
+ const hydrate = async (image) => {
+ if (!(image instanceof Element) || image.tagName !== 'IMG') return;
+ const source = absoluteImageUrl(image.currentSrc || image.src);
+ if (!source || source.startsWith('data:') || source.startsWith('blob:')) return;
+ image.dataset.ldpEmojiCacheSource = source;
+ const cachedSource = await cachedEmojiImageSource(source);
+ if (cachedSource) {
+ if (image.isConnected && image.dataset.ldpEmojiCacheSource === source) image.src = cachedSource;
+ return;
+ }
+ const persist = () => {
+ if (image.dataset.ldpEmojiCacheSource === source) void persistEmojiImage(source);
+ };
+ if (image.complete && image.naturalWidth) persist();
+ else image.addEventListener('load', persist, { once: true });
+ };
+ const hydrateTree = (node) => {
+ if (!(node instanceof Element)) return;
+ if (node.matches('img[src]')) void hydrate(node);
+ node.querySelectorAll('img[src]').forEach((image) => { void hydrate(image); });
+ };
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ if (mutation.type === 'attributes') hydrateTree(mutation.target);
+ mutation.addedNodes.forEach(hydrateTree);
+ });
+ });
+ content.querySelectorAll('.emoji-picker img[src]').forEach((image) => { void hydrate(image); });
+ observer.observe(content, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
+ content._ldpStopEmojiImageCache = () => {
+ observer.disconnect();
+ delete content._ldpStopEmojiImageCache;
+ };
+ }
+
+ async function deletePersistentLightboxImage(rawSource) {
+ const source = absoluteImageUrl(rawSource);
+ const objectUrl = LIGHTBOX_IMAGE_OBJECT_URL_CACHE.get(source);
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ LIGHTBOX_IMAGE_OBJECT_URL_CACHE.delete(source);
+ if (typeof caches === 'undefined') return;
+ try {
+ const storage = await caches.open(LDP_LIGHTBOX_IMAGE_CACHE_NAME);
+ await storage.delete(source);
+ } catch {}
+ }
+
+ function comparableLightboxImageSource(value) {
+ const source = absoluteImageUrl(value);
+ if (!source) return '';
+ try {
+ const url = new URL(source, location.href);
+ const uploadHash = url.pathname.match(/(?:^|\/)([0-9a-f]{40})(?:\.[a-z0-9]+)?(?:$|\/)/i);
+ if (uploadHash) return `upload:${uploadHash[1].toLowerCase()}`;
+ return `${url.origin}${decodeURIComponent(url.pathname)}`;
+ } catch {
+ return String(source).split(/[?#]/, 1)[0];
+ }
+ }
+
+ function lightboxImageItem(img, postNumber = 0, imageOrder = 0, postData = null, topicId = 0) {
+ if (!img) return null;
+ const originalSrc = absoluteImageUrl(resolveOriginalSrc(img));
+ if (!originalSrc) return null;
+ const ownerPost = img.closest('.ldp-post');
+ const sourcePost = postData || (ownerPost?._ldpPostData) || null;
+ const item = {
+ originalSrc,
+ thumbSrc: absoluteImageUrl(img.currentSrc || img.src || originalSrc),
+ alt: String(img.alt || '').trim(),
+ postNumber: Math.max(0, +postNumber || +(ownerPost && ownerPost.dataset.postNumber) || 0),
+ imageOrder: Math.max(0, +imageOrder || 0),
+ topicId: Math.max(0, +topicId || +(sourcePost?.topic_id) || 0),
+ postId: Math.max(0, +(sourcePost?.id) || +(ownerPost && ownerPost.dataset.postId) || 0),
+ username: String(sourcePost?.username || ownerPost && ownerPost.dataset.username || '').trim(),
+ sourcePost,
+ };
+ item.key = [
+ Math.max(0, +(item.topicId || 0)),
+ Math.max(0, +(item.postNumber || 0)),
+ Math.max(0, +(item.imageOrder || 0)),
+ comparableLightboxImageSource(item.originalSrc),
+ ].join(':');
+ return item;
+ }
+
+ function isLightboxContentImage(img, quoted = false) {
+ if (!img || !isFunction(img.closest)) return false;
+ const contentSelector = quoted ? '.ldp-content aside.ldp-post-quote > blockquote' : '.ldp-content';
+ if (!img.closest(contentSelector)) return false;
+ const isBoostEmoji = img.classList.contains('emoji') ||
+ !!(reactionEmojiImageSources?.size &&
+ [img.currentSrc, img.src, img.getAttribute('src'), resolveOriginalSrc(img)]
+ .map(comparableLightboxImageSource)
+ .some((source) => source && reactionEmojiImageSources.has(source)));
+ if (isBoostEmoji) return false;
+ const excludedSelector = quoted
+ ? '.ldp-quote-title,[data-user-card],aside.onebox'
+ : 'aside.quote,.ldp-quote-title,[data-user-card],aside.onebox';
+ if (img.closest(excludedSelector)) return false;
+ const hasAvatarClass = [...img.classList || []]
+ .some((name) => /(^|[-_])avatar($|[-_])/i.test(name));
+ const source = String(img.currentSrc || img.src || img.getAttribute('src') || '');
+ return !hasAvatarClass && !/\/user_avatar\//i.test(source);
+ }
+
+ const isLightboxFloorImage = (img) => isLightboxContentImage(img);
+ const isLightboxQuotedImage = (img) => isLightboxContentImage(img, true);
+
+ function lightboxImageFromEventIn(event, selector, predicate) {
+ const image = eventPathClosest(event, `${selector} img`);
+ if (predicate(image)) return image;
+ const link = eventPathClosest(event, `${selector} a.lightbox`);
+ const linkedImage = link ? link.querySelector('img') : null;
+ return predicate(linkedImage) ? linkedImage : null;
+ }
+
+ const lightboxImageFromEvent = (event) =>
+ lightboxImageFromEventIn(event, '.ldp-content', isLightboxFloorImage);
+
+ function collectLightboxItems(clickedImg, ctx) {
+ const items = [];
+ const seen = new Set();
+ const add = (img, postNumber = 0, imageOrder = 0, postData = null) => {
+ if (!isLightboxFloorImage(img)) return;
+ const item = lightboxImageItem(img, postNumber, imageOrder, postData, ctx?.topicId);
+ if (!item || seen.has(item.key)) return;
+ seen.add(item.key);
+ items.push(item);
+ };
+ const roots = ctx && Array.isArray(ctx.streamItems)
+ ? ctx.streamItems.map((item) => item?.node).filter(Boolean)
+ : [];
+ if (!roots.length && ctx && ctx.commentsEl)
+ roots.push(...ctx.commentsEl.querySelectorAll(':scope > .ldp-post'));
+ roots.forEach((root) => {
+ const postNumber = +(root.dataset.postNumber || (root._ldpPostData?.post_number)) || 0;
+ [...root.querySelectorAll(':scope > .ldp-content img')]
+ .filter(isLightboxFloorImage)
+ .forEach((img, imageOrder) => add(img, postNumber, imageOrder, root._ldpPostData));
+ });
+ const clickedContent = clickedImg?.closest('.ldp-content');
+ const clickedOrder = clickedContent
+ ? [...clickedContent.querySelectorAll('img')].filter(isLightboxFloorImage).indexOf(clickedImg)
+ : 0;
+ const clickedItem = isLightboxFloorImage(clickedImg)
+ ? lightboxImageItem(clickedImg, 0, Math.max(0, clickedOrder), null, ctx?.topicId)
+ : null;
+ if (clickedItem && !seen.has(clickedItem.key)) {
+ seen.add(clickedItem.key);
+ items.unshift(clickedItem);
+ }
+ items.sort(compareLightboxItems);
+ const initialIndex = clickedItem
+ ? Math.max(0, items.findIndex((item) => item.key === clickedItem.key))
+ : 0;
+ return { items, initialIndex };
+ }
+
+ function lightboxItemsFromPost(post, topicId = 0) {
+ if (!post || !post.cooked) return [];
+ const content = makeElement('div');
+ content.className = 'ldp-content cooked';
+ setPostCookedContent(content, post.cooked);
+ return [...content.querySelectorAll('img')]
+ .filter(isLightboxFloorImage)
+ .map((img, imageOrder) => lightboxImageItem(
+ img,
+ post.post_number,
+ imageOrder,
+ post,
+ post.topic_id || topicId,
+ ));
+ }
+
+ async function openQuotedImageLightbox(clickedImg, ctx) {
+ const quote = clickedImg?.closest('aside.ldp-post-quote');
+ if (!quote || !ctx) return false;
+ try {
+ const sourcePost = await loadReaderQuotedPost(quote, ctx);
+ const sourceTopicId = Math.max(0, +quote.dataset.topic || +(sourcePost?.topic_id) || +ctx.topicId || 0);
+ const items = lightboxItemsFromPost(sourcePost, sourceTopicId);
+ if (!items.length) throw new Error('quoted image unavailable');
+ const clickedSource = comparableLightboxImageSource(resolveOriginalSrc(clickedImg));
+ const markedOrder = lightboxImageOrderFromAlt(clickedImg.alt);
+ let initialIndex = items.findIndex((item) => (
+ comparableLightboxImageSource(item.originalSrc) === clickedSource &&
+ (markedOrder == null || +item.imageOrder === markedOrder)
+ ));
+ if (initialIndex < 0) {
+ const quoteBody = quote.querySelector(':scope > blockquote');
+ const excerptOrder = quoteBody
+ ? [...quoteBody.querySelectorAll('img')].filter(isLightboxQuotedImage).indexOf(clickedImg)
+ : -1;
+ initialIndex = Math.max(0, Math.min(items.length - 1, excerptOrder));
+ }
+ openLightbox(null, ctx, {
+ items,
+ initialIndex,
+ fixedItems: true,
+ comments: sourceTopicId === +ctx.topicId,
+ });
+ return true;
+ } catch {
+ showSelectionToast('引用源图片加载失败,请重试');
+ return false;
+ }
+ }
+
+ function compareLightboxItems(left, right) {
+ return (+left.postNumber || 0) - (+right.postNumber || 0) ||
+ (+left.imageOrder || 0) - (+right.imageOrder || 0);
+ }
+
+ function lightboxImageOrderMarker(imageOrder) {
+ const bits = Math.max(0, +imageOrder || 0).toString(2).padStart(8, '0');
+ return `\u2063${bits.replace(/0/g, '\u200B').replace(/1/g, '\u200C')}\u2064`;
+ }
+
+ function lightboxImageOrderFromAlt(value) {
+ const match = String(value || '').match(/\u2063([\u200B\u200C]+)\u2064/);
+ if (!match) return null;
+ const parsed = Number.parseInt(match[1].replace(/\u200B/g, '0').replace(/\u200C/g, '1'), 2);
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+
+ function lightboxImageQuoteRaw(item, ctx) {
+ const sourcePost = item?.sourcePost || {};
+ const username = cleanUsernameValue(item?.username || sourcePost.username);
+ const postNumber = Math.max(0, +(item?.postNumber || 0));
+ const topicId = Math.max(0, +(item?.topicId || ctx?.topicId || 0));
+ const source = String(item?.originalSrc || '').replace(//g, '%3E');
+ const alt = String(item?.alt || '图片')
+ .replace(/\\/g, '\\\\')
+ .replace(/\[/g, '\\[')
+ .replace(/\]/g, '\\]') + lightboxImageOrderMarker(item?.imageOrder);
+ if (!username || !postNumber || !topicId || !source) return '';
+ return `[quote="${username}, post:${postNumber}, topic:${topicId}"]\n\n[/quote]\n\n`;
+ }
+
+ function lightboxQuoteContainsImage(quote, item, expectedSource) {
+ return [...quote.querySelectorAll(':scope > blockquote img')].some((image) => {
+ if (comparableLightboxImageSource(resolveOriginalSrc(image)) !== expectedSource) return false;
+ const markedOrder = lightboxImageOrderFromAlt(image.alt);
+ return markedOrder == null || markedOrder === +item.imageOrder;
+ });
+ }
+
+ function postQuotesLightboxImage(post, item) {
+ if (!post || !item) return false;
+ if (Array.isArray(post._ldpImageReferenceKeys) && post._ldpImageReferenceKeys.includes(item.key)) return true;
+ if (!post.cooked || !item.postNumber) return false;
+ const template = makeElement('template');
+ template.innerHTML = String(post.cooked || '');
+ const expectedTopicId = +(item.topicId || 0);
+ const expectedSource = comparableLightboxImageSource(item.originalSrc);
+ return [...template.content.querySelectorAll('aside.quote')].some((quote) => {
+ if (+quote.dataset.post !== +item.postNumber) return false;
+ const quotedTopicId = +(quote.dataset.topic || 0);
+ if (quotedTopicId && expectedTopicId && quotedTopicId !== expectedTopicId) return false;
+ return lightboxQuoteContainsImage(quote, item, expectedSource);
+ });
+ }
+
+ function lightboxSourceDescription(post, item) {
+ const clean = (value) => {
+ const text = String(value || '')
+ .replace(/https?:\/\/\S+/gi, ' ')
+ .replace(/\b[^\s/\\]+\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|tiff?|webp)\b/gi, ' ')
+ .replace(/\b[0-9a-f]{20,}\b/gi, ' ')
+ .replace(/\b\d{2,5}\s*[x×]\s*\d{2,5}\b/gi, ' ')
+ .replace(/[x×]\s*\d{2,5}\b/gi, ' ')
+ .replace(/\b\d+(?:\.\d+)?\s*(?:bytes?|[kmgt]i?b)\b/gi, ' ')
+ .replace(/\s+/g, ' ')
+ .replace(/^[\s·•|,,;;::/_-]+|[\s·•|,,;;::/_-]+$/g, '')
+ .trim();
+ return text;
+ };
+ const altFallback = clean(item?.alt);
+ const fallback = !altFallback || /^(?:该楼层)?图片$/.test(altFallback) ? '无描述' : altFallback;
+ if (!post || !post.cooked) return fallback;
+ const template = makeElement('template');
+ template.innerHTML = String(post.cooked || '');
+ template.content.querySelectorAll(
+ 'aside.quote,img,video,audio,iframe,canvas,svg,.quote-controls,.lightbox-wrapper .meta,a.lightbox .meta'
+ ).forEach((node) => node.remove());
+ const text = clean(template.content.textContent);
+ if (!text) return fallback;
+ return text.length > 420 ? `${text.slice(0, 420).trim()}…` : text;
+ }
+
+ function normalizeLightboxDescriptionHeight(value) {
+ const numeric = Math.round(Number(value));
+ const maximum = Math.max(LIGHTBOX_DESCRIPTION_HEIGHT_MIN, Math.floor(window.innerHeight * 0.4));
+ return Math.min(
+ maximum,
+ Math.max(LIGHTBOX_DESCRIPTION_HEIGHT_MIN, Number.isFinite(numeric) ? numeric : LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT)
+ );
+ }
+
+ function normalizeLightboxCommentsWidth(value) {
+ const numeric = Number(value);
+ return Math.min(
+ LIGHTBOX_COMMENTS_WIDTH_MAX,
+ Math.max(LIGHTBOX_COMMENTS_WIDTH_MIN, Number.isFinite(numeric) ? numeric : LIGHTBOX_COMMENTS_WIDTH_DEFAULT)
+ );
+ }
+
+ function safeDownloadFilename(value, fallback = 'download') {
+ const cleaned = String(value || '')
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
+ .replace(/[.\s]+$/g, '')
+ .trim();
+ return (cleaned || fallback).slice(0, 120);
+ }
+
+ function lightboxDownloadTimestamp(date = new Date()) {
+ const part = (value) => String(value).padStart(2, '0');
+ return `${date.getFullYear()}年${part(date.getMonth() + 1)}月${part(date.getDate())}日${part(date.getHours())}时${part(date.getMinutes())}分${part(date.getSeconds())}秒`;
+ }
+
+ function lightboxImageDownloadName(item, index, blob) {
+ let sourceName = '';
+ try { sourceName = decodeURIComponent(new URL(item?.originalSrc || '', location.href).pathname.split('/').pop() || ''); }
+ catch {}
+ const mimeExtensions = {
+ 'image/avif': 'avif', 'image/bmp': 'bmp', 'image/gif': 'gif', 'image/heic': 'heic',
+ 'image/heif': 'heif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/svg+xml': 'svg',
+ 'image/tiff': 'tiff', 'image/webp': 'webp',
+ };
+ let extension = mimeExtensions[String(blob?.type || '').toLowerCase()];
+ if (!extension) {
+ try {
+ const match = new URL(item?.originalSrc || '', location.href).pathname.match(/\.([a-z0-9]{2,5})$/i);
+ if (match) extension = match[1].toLowerCase();
+ } catch {}
+ }
+ extension ||= 'img';
+ const stem = safeDownloadFilename(sourceName.replace(/\.[a-z0-9]{2,5}$/i, ''), `image-${index + 1}`);
+ return `${String(index + 1).padStart(3, '0')}-${stem}.${extension}`;
+ }
+
+ function triggerBrowserDownload(blob, filename) {
+ const objectUrl = URL.createObjectURL(blob);
+ const link = makeElement('a');
+ link.href = objectUrl;
+ link.download = safeDownloadFilename(filename);
+ link.hidden = true;
+ appendReaderPortalNode(link);
+ link.click();
+ link.remove();
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 60 * 1000);
+ }
+
+ async function cachedPersistentLightboxBlob(rawSource) {
+ const source = absoluteImageUrl(rawSource);
+ if (!source) return null;
+ if (typeof caches !== 'undefined') {
+ try {
+ const { response } = await persistentResourceCacheEntry(
+ LDP_LIGHTBOX_IMAGE_CACHE_NAME, source, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE
+ );
+ if (response) {
+ const blob = await response.blob();
+ if (blob.size) return blob;
+ }
+ } catch {}
+ }
+ const objectUrl = touchResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source);
+ if (!objectUrl) return null;
+ try {
+ const response = await trackedReaderFetch(objectUrl);
+ const blob = await response.blob();
+ return blob.size ? blob : null;
+ } catch {
+ return null;
+ }
+ }
+
+ function cachedLightboxOriginalBlob(item) {
+ return cachedPersistentLightboxBlob(item?.originalSrc);
+ }
+
+ async function persistLightboxBlob(rawSource, blob, contentType = '',
+ resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal) {
+ const source = absoluteImageUrl(rawSource);
+ if (!/^https?:/i.test(source) || !blob || !blob.size || typeof caches === 'undefined' ||
+ RESOURCE_CACHE_CLEARING || resourceCacheSignal.aborted) return;
+ try {
+ void prunePersistentResourceCacheOnce(LDP_LIGHTBOX_IMAGE_CACHE_NAME, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE);
+ const storage = await caches.open(LDP_LIGHTBOX_IMAGE_CACHE_NAME);
+ if (!resourceCacheSignal.aborted) {
+ await storage.put(source, createPersistentResourceResponse(blob, contentType));
+ }
+ } catch {}
+ }
+
+ async function fetchLightboxDownloadBlob(source, priority = POST_REQUEST_PRIORITY.auxiliary, options = {}) {
+ if (!source) throw new Error('图片获取失败');
+ const normalizedSource = absoluteImageUrl(source);
+ const persistentSource = /^https?:/i.test(normalizedSource) ? normalizedSource : '';
+ const pendingRequest = LIGHTBOX_IMAGE_RESOURCE_REQUESTS.get(persistentSource);
+ const request = pendingRequest || (async () => {
+ const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
+ const localSource = normalizedSource.startsWith('blob:') || normalizedSource.startsWith('data:');
+ const abortWithCacheClear = options.abortWithCacheClear === true && !RESOURCE_CACHE_CLEARING;
+ if (persistentSource) {
+ if (abortWithCacheClear) {
+ void prunePersistentResourceCacheOnce(LDP_LIGHTBOX_IMAGE_CACHE_NAME, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE);
+ }
+ const cached = await cachedPersistentLightboxBlob(persistentSource);
+ if (cached) return cached;
+ }
+ let resource = null;
+ let response;
+ try {
+ if (localSource) {
+ response = await trackedReaderFetch(normalizedSource, {
+ credentials: 'omit',
+ cache: 'force-cache',
+ [REQUEST_FLOW_FETCH_META]: { source: 'reader', priority, type: 'media' },
+ });
+ } else {
+ resource = await fetchReaderPublicResource(
+ normalizedSource, priority, 'media', abortWithCacheClear ? resourceCacheSignal : undefined
+ );
+ if (!resource) throw new Error('图片请求已暂停');
+ response = resource.response;
+ }
+ if (!response.ok) throw new Error(`图片获取失败(${response.status})`);
+ const blob = await response.blob();
+ if (!blob.size) throw new Error('图片内容为空');
+ if (persistentSource) {
+ await persistLightboxBlob(persistentSource, blob, response.headers.get('content-type'), resourceCacheSignal);
+ }
+ return blob;
+ } finally {
+ if (resource) resource.release();
+ }
+ })();
+ const tracked = persistentSource && !pendingRequest
+ ? trackSingleFlightRequest(LIGHTBOX_IMAGE_RESOURCE_REQUESTS, persistentSource, request)
+ : request;
+ try {
+ return await tracked;
+ } catch (error) {
+ if (options.abortWithCacheClear !== true && error && error.name === 'AbortError' && RESOURCE_CACHE_CLEARING) {
+ return fetchLightboxDownloadBlob(source, priority, options);
+ }
+ throw error;
+ }
+ }
+
+ async function persistentLightboxImageSource(rawSource, options = {}) {
+ const source = absoluteImageUrl(rawSource);
+ if (!source || source.startsWith('data:') || source.startsWith('blob:') || RESOURCE_CACHE_CLEARING) return source;
+ if (typeof caches === 'undefined') return options.cachedOnly ? '' : source;
+ const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
+ const cachedObjectUrl = touchResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source);
+ if (cachedObjectUrl) return cachedObjectUrl;
+ try {
+ const blob = options.cachedOnly
+ ? await cachedPersistentLightboxBlob(source)
+ : await fetchLightboxDownloadBlob(source, POST_REQUEST_PRIORITY.auxiliary, { abortWithCacheClear: true });
+ return blob && !resourceCacheSignal.aborted
+ ? cacheResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 32)
+ : options.cachedOnly ? '' : source;
+ } catch (error) {
+ return error && error.message === '图片请求已暂停' ? '' : source;
+ }
+ }
+
+ async function lightboxImageDownloadBlob(item, options = {}) {
+ const cachedOriginal = await cachedLightboxOriginalBlob(item);
+ if (cachedOriginal) return cachedOriginal;
+ const originalSrc = item?.originalSrc;
+ const previewSrc = item && (item.thumbSrc || originalSrc);
+ if (options.original !== true && previewSrc && previewSrc !== originalSrc) {
+ return fetchLightboxDownloadBlob(previewSrc);
+ }
+ return fetchLightboxDownloadBlob(originalSrc);
+ }
+
+ async function downloadLightboxItem(item, index) {
+ try {
+ const missingOriginals = await lightboxMissingOriginalCount([item]);
+ const useOriginal = missingOriginals > 0 && globalThis.confirm(
+ '当前原图尚未缓存。\n\n确定:按阅读器限速获取并下载原图\n取消:下载当前最高缓存/预览质量'
+ );
+ const blob = await lightboxImageDownloadBlob(item, { original: useOriginal });
+ triggerBrowserDownload(blob, lightboxImageDownloadName(item, index, blob).replace(/^\d+-/, ''));
+ } catch (error) {
+ showSelectionToast(`图片下载失败:${error.message || '请重试'}`);
+ }
+ }
+
+ async function lightboxMissingOriginalCount(items) {
+ const candidates = (Array.isArray(items) ? items : []).filter((item) => (
+ item?.originalSrc && item.thumbSrc && item.originalSrc !== item.thumbSrc
+ ));
+ let missing = 0;
+ for (const item of candidates) {
+ if (!await cachedLightboxOriginalBlob(item)) missing++;
+ }
+ return missing;
+ }
+
+ function lightboxZipCrc32(bytes) {
+ if (!lightboxZipCrc32.table) {
+ lightboxZipCrc32.table = new Uint32Array(256);
+ for (let index = 0; index < 256; index++) {
+ let value = index;
+ for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ value >>> 1 : value >>> 1;
+ lightboxZipCrc32.table[index] = value >>> 0;
+ }
+ }
+ let crc = 0xffffffff;
+ for (let index = 0; index < bytes.length; index++) crc = lightboxZipCrc32.table[(crc ^ bytes[index]) & 0xff] ^ crc >>> 8;
+ return (crc ^ 0xffffffff) >>> 0;
+ }
+
+ function createStoredZip(entries) {
+ const encoder = new globalThis.TextEncoder();
+ const localParts = [];
+ const centralParts = [];
+ let localOffset = 0;
+ const modifiedAt = new Date();
+ const dosTime = modifiedAt.getHours() << 11 | modifiedAt.getMinutes() << 5 | modifiedAt.getSeconds() >> 1;
+ const dosDate = Math.max(0, modifiedAt.getFullYear() - 1980) << 9 | modifiedAt.getMonth() + 1 << 5 | modifiedAt.getDate();
+ entries.forEach((entry) => {
+ const name = encoder.encode(entry.name);
+ const bytes = entry.bytes;
+ const crc = lightboxZipCrc32(bytes);
+ const local = new Uint8Array(30 + name.length);
+ const localView = new DataView(local.buffer);
+ localView.setUint32(0, 0x04034b50, true);
+ localView.setUint16(4, 20, true);
+ localView.setUint16(6, 0x0800, true);
+ localView.setUint16(8, 0, true);
+ localView.setUint16(10, dosTime, true);
+ localView.setUint16(12, dosDate, true);
+ localView.setUint32(14, crc, true);
+ localView.setUint32(18, bytes.length, true);
+ localView.setUint32(22, bytes.length, true);
+ localView.setUint16(26, name.length, true);
+ local.set(name, 30);
+ const central = new Uint8Array(46 + name.length);
+ const centralView = new DataView(central.buffer);
+ centralView.setUint32(0, 0x02014b50, true);
+ centralView.setUint16(4, 20, true);
+ centralView.setUint16(6, 20, true);
+ centralView.setUint16(8, 0x0800, true);
+ centralView.setUint16(10, 0, true);
+ centralView.setUint16(12, dosTime, true);
+ centralView.setUint16(14, dosDate, true);
+ centralView.setUint32(16, crc, true);
+ centralView.setUint32(20, bytes.length, true);
+ centralView.setUint32(24, bytes.length, true);
+ centralView.setUint16(28, name.length, true);
+ centralView.setUint32(42, localOffset, true);
+ central.set(name, 46);
+ localParts.push(local, bytes);
+ centralParts.push(central);
+ localOffset += local.length + bytes.length;
+ });
+ const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
+ const end = new Uint8Array(22);
+ const endView = new DataView(end.buffer);
+ endView.setUint32(0, 0x06054b50, true);
+ endView.setUint16(8, entries.length, true);
+ endView.setUint16(10, entries.length, true);
+ endView.setUint32(12, centralSize, true);
+ endView.setUint32(16, localOffset, true);
+ return new globalThis.Blob([...localParts, ...centralParts, end], { type: 'application/zip' });
+ }
+
+ function mergeLightboxPosts(target, posts) {
+ (Array.isArray(posts) ? posts : []).forEach((post) => {
+ const postNumber = +(post?.post_number || 0);
+ if (!postNumber || !post.id) return;
+ const previous = target.get(postNumber);
+ target.set(postNumber, previous ? Object.assign(previous, post) : post);
+ });
+ return target;
+ }
+
+ function knownLightboxPosts(ctx) {
+ const posts = new Map();
+ mergeLightboxPosts(posts, ctx?.loader && ctx.loader.cachedPosts);
+ mergeLightboxPosts(posts, postsFromPayload(ctx?.topicData));
+ if (ctx && Array.isArray(ctx.streamItems))
+ mergeLightboxPosts(posts, ctx.streamItems.map((item) => item?.node && item.node._ldpPostData));
+ if (ctx?.directRepliesByParent)
+ ctx.directRepliesByParent.forEach((replies) => mergeLightboxPosts(posts, [...replies.values()]));
+ if (ctx?.subReplyState)
+ ctx.subReplyState.forEach((state) => mergeLightboxPosts(posts, state?.all));
+ if (ctx?.lightboxImageCommentPosts) mergeLightboxPosts(posts, [...ctx.lightboxImageCommentPosts.values()]);
+ return posts;
+ }
+
+ async function loadLightboxTopicPosts(ctx) {
+ if (!ctx || !ctx.topicId || !ctx.loader || !isFunction(ctx.loader.loadPostsByIds)) return [];
+ if (ctx.lightboxTopicPostsPromise) return ctx.lightboxTopicPostsPromise;
+ const request = (async () => {
+ const posts = new Map();
+ let topic = ctx.loader.topic || ctx.topicData;
+ let stream = topicPostStream(topic).stream;
+ const expectedCount = Math.max(0, Number(topic?.posts_count) || 0);
+ if ((!Array.isArray(stream) || !stream.length || expectedCount > stream.length) &&
+ isFunction(ctx.loader.refreshTopicData)) {
+ topic = await ctx.loader.refreshTopicData({ priority: POST_REQUEST_PRIORITY.background });
+ stream = topicPostStream(topic).stream;
+ }
+ const postIds = [...new Set((Array.isArray(stream) ? stream : [])
+ .map(Number).filter((postId) => postId > 0))];
+ const batchSize = 20;
+ for (let offset = 0; offset < postIds.length; offset += batchSize) {
+ const loaded = await ctx.loader.loadPostsByIds(postIds.slice(offset, offset + batchSize), {
+ priority: POST_REQUEST_PRIORITY.background,
+ persistFetchedPosts: true,
+ });
+ mergeLightboxPosts(posts, loaded);
+ }
+ return [...posts.values()];
+ })();
+ ctx.lightboxTopicPostsPromise = request.catch((error) => {
+ ctx.lightboxTopicPostsPromise = null;
+ throw error;
+ });
+ return ctx.lightboxTopicPostsPromise;
+ }
+
+ function buildLightboxCommentTree(postsByNumber, directMatchNumbers) {
+ const childrenByParent = new Map();
+ postsByNumber.forEach((post) => {
+ const parentNumber = +(post.reply_to_post_number || 0);
+ if (!parentNumber || !postsByNumber.has(parentNumber)) return;
+ if (!childrenByParent.has(parentNumber)) childrenByParent.set(parentNumber, []);
+ childrenByParent.get(parentNumber).push(post);
+ });
+ childrenByParent.forEach((children) => children.sort((left, right) => +left.post_number - +right.post_number));
+ const roots = [...directMatchNumbers]
+ .map((postNumber) => postsByNumber.get(postNumber))
+ .filter((post) => post && !postsByNumber.has(+(post.reply_to_post_number || 0)))
+ .sort((left, right) => +left.post_number - +right.post_number);
+ return { roots, childrenByParent };
+ }
+
+ function rememberLightboxSourcePost(item, sourcePost) {
+ if (!sourcePost) return;
+ item.sourcePost = sourcePost;
+ item.postId = +(sourcePost.id || item.postId || 0);
+ item.username = String(sourcePost.username || item.username || '');
+ }
+
+ function lightboxCommentTreeState(sourcePost, postsByNumber, directMatchNumbers, partial) {
+ const { roots, childrenByParent } = buildLightboxCommentTree(postsByNumber, directMatchNumbers);
+ return {
+ sourcePost, postsByNumber, directMatchNumbers, roots, childrenByParent,
+ partial: partial === true,
+ };
+ }
+
+ function cachedLightboxCommentRelation(item, ctx, knownPosts) {
+ const entry = ctx && TOPIC_DATA_CACHE.get(String(ctx.topicId));
+ const topic = entry?.topic || ctx?.topicData;
+ const relations = topic?._ldpLightboxCommentRelations;
+ const relation = relations?.[item && item.key];
+ if (!relation || Date.now() - Number(relation.cachedAt || 0) > LIGHTBOX_COMMENT_RELATION_CACHE_FRESH_FOR) return null;
+ const postsByNumber = new Map();
+ mergeLightboxPosts(postsByNumber, relation.posts);
+ if (knownPosts) {
+ for (const postNumber of postsByNumber.keys()) {
+ const fresh = knownPosts.get(postNumber);
+ if (fresh) postsByNumber.set(postNumber, fresh);
+ }
+ }
+ const directMatchNumbers = new Set((relation.directMatchNumbers || []).map(Number).filter((value) => value > 0));
+ if (directMatchNumbers.size && ![...directMatchNumbers].every((postNumber) => postsByNumber.has(postNumber))) return null;
+ const sourcePostNumber = +(relation.sourcePostNumber || item?.postNumber || 0);
+ const sourcePost = knownPosts?.get(sourcePostNumber) || relation.sourcePost || null;
+ rememberLightboxSourcePost(item, sourcePost);
+ return lightboxCommentTreeState(sourcePost, postsByNumber, directMatchNumbers, relation.partial);
+ }
+
+ function persistLightboxCommentRelation(item, ctx, state) {
+ if (!item || !item.key || !ctx || !ctx.topicId || !state) return;
+ const entry = TOPIC_DATA_CACHE.get(String(ctx.topicId));
+ const topic = entry?.topic || ctx.topicData;
+ if (!topic) return;
+ const relations = topic._ldpLightboxCommentRelations && typeof topic._ldpLightboxCommentRelations === 'object'
+ ? topic._ldpLightboxCommentRelations
+ : {};
+ relations[item.key] = {
+ cachedAt: Date.now(),
+ sourcePostNumber: +(state.sourcePost?.post_number || item.postNumber || 0),
+ sourcePost: state.sourcePost || item.sourcePost || null,
+ directMatchNumbers: [...state.directMatchNumbers || []],
+ posts: [...state.postsByNumber?.values() || []],
+ partial: state.partial === true,
+ };
+ const keys = Object.keys(relations).sort((left, right) => (
+ Number(relations[right]?.cachedAt || 0) - Number(relations[left]?.cachedAt || 0)
+ ));
+ keys.slice(64).forEach((key) => delete relations[key]);
+ topic._ldpLightboxCommentRelations = relations;
+ setCachedTopicData(ctx.topicId, topic);
+ }
+
+ async function loadLightboxCommentTree(item, ctx) {
+ const knownPosts = knownLightboxPosts(ctx);
+ const cachedRelation = cachedLightboxCommentRelation(item, ctx, knownPosts);
+ if (cachedRelation) return cachedRelation;
+ let sourcePost = item?.sourcePost || knownPosts.get(+(item?.postNumber || 0)) || null;
+ if (!sourcePost && ctx && ctx.loader && isFunction(ctx.loader.loadPost)) {
+ const loaded = await ctx.loader.loadPost(item.postNumber, {
+ advanceCursor: false,
+ priority: POST_REQUEST_PRIORITY.visible,
+ });
+ sourcePost = loaded.find((post) => +post.post_number === +item.postNumber) || null;
+ mergeLightboxPosts(knownPosts, loaded);
+ }
+ rememberLightboxSourcePost(item, sourcePost);
+ let partial = false;
+ const [sourceRepliesResult, topicPostsResult] = await Promise.allSettled([
+ sourcePost ? ctx.repliesIO.loadDirectReplies(sourcePost, { force: true }) : Promise.resolve([]),
+ loadLightboxTopicPosts(ctx),
+ ]);
+ if (sourceRepliesResult.status === 'fulfilled') mergeLightboxPosts(knownPosts, sourceRepliesResult.value);
+ else partial = true;
+ if (topicPostsResult.status === 'fulfilled') mergeLightboxPosts(knownPosts, topicPostsResult.value);
+ else partial = true;
+
+ const directMatchNumbers = new Set();
+ knownPosts.forEach((post, postNumber) => {
+ if (postQuotesLightboxImage(post, item)) directMatchNumbers.add(postNumber);
+ });
+ const included = new Map();
+ directMatchNumbers.forEach((postNumber) => {
+ const post = knownPosts.get(postNumber);
+ if (post) included.set(postNumber, post);
+ });
+ const queue = [...included.values()].sort((left, right) => +left.post_number - +right.post_number);
+ const fetchedPostIds = new Set();
+ const includeReplies = (replies) => {
+ replies.forEach((reply) => {
+ const replyNumber = +(reply?.post_number || 0);
+ if (!replyNumber) return;
+ if (postQuotesLightboxImage(reply, item)) directMatchNumbers.add(replyNumber);
+ if (!included.has(replyNumber)) {
+ included.set(replyNumber, reply);
+ queue.push(reply);
+ }
+ });
+ };
+ while (queue.length) {
+ const parent = queue.shift();
+ const postId = +(parent?.id || 0);
+ if (!postId || fetchedPostIds.has(postId)) continue;
+ fetchedPostIds.add(postId);
+ const parentNumber = +(parent.post_number || 0);
+ const localChildren = [...knownPosts.values()].filter((post) => (
+ +(post?.reply_to_post_number || 0) === parentNumber
+ ));
+ includeReplies(localChildren);
+ const knownChildren = ctx.directRepliesByParent?.get(parentNumber);
+ if (!(+(parent.reply_count || 0) > localChildren.length || knownChildren && knownChildren.size > localChildren.length)) continue;
+ try {
+ const replies = await ctx.repliesIO.loadDirectReplies(parent);
+ includeReplies(replies);
+ } catch {
+ partial = true;
+ }
+ }
+ const state = lightboxCommentTreeState(sourcePost, included, directMatchNumbers, partial);
+ persistLightboxCommentRelation(item, ctx, state);
+ return state;
+ }
+
+ function openLightbox(clickedImg, ctx, options = {}) {
+ const providedItems = Array.isArray(options.items) ? options.items.filter(Boolean) : null;
+ const imageSet = providedItems
+ ? {
+ items: providedItems.slice(),
+ initialIndex: Math.max(0, Math.min(providedItems.length - 1, +options.initialIndex || 0)),
+ }
+ : collectLightboxItems(clickedImg, ctx);
+ const { items, initialIndex } = imageSet;
+ if (!items.length) return;
+ const siteSettings = lookupDiscourse('service:site-settings');
+ const minimumCommentLength = Math.max(1, +(siteSettings?.min_post_length) || 16);
+ const lb = makeElement('div');
+ const lightboxScope = createLifecycleScope();
+ lb.className = 'ldp-lightbox';
+ lb.setAttribute('role', 'dialog');
+ lb.setAttribute('aria-modal', 'true');
+ setLabel(lb, '图片预览');
+ lb.innerHTML = `
+
+
+
+
+
+
+
+
1 ? '' : ' hidden'}>
+
+
+ ${items.map((item, index) => `
`).join('')}
+
+
+
+
+ 批量下载
+ 名称
+
+
+
+
+
+
`;
+ const stage = lb.querySelector('.ldp-lb-stage');
+ const mainImg = lb.querySelector('.ldp-lb-image');
+ const status = lb.querySelector('.ldp-lb-status');
+ const statusText = status.querySelector('span');
+ const retryBtn = status.querySelector('.ldp-lb-retry');
+ const countEl = lb.querySelector('.ldp-lb-count');
+ const zoomValue = lb.querySelector('.ldp-lb-zoom-value');
+ const viewOriginalBtn = lb.querySelector('[data-lb-action="view-original"]');
+ const jumpToPostBtn = lb.querySelector('[data-lb-action="jump-to-post"]');
+ const commentsToggle = lb.querySelector('.ldp-lb-comments-toggle');
+ const commentsPanel = lb.querySelector('.ldp-lb-comments');
+ const commentsResizer = lb.querySelector('.ldp-lb-comments-resizer');
+ const commentsToggleCount = commentsToggle.querySelector('.ldp-lb-comments-count');
+ const commentsHeadingCount = lb.querySelector('.ldp-lb-comments-head span');
+ const commentsDescriptionToggle = lb.querySelector('.ldp-lb-description-toggle');
+ const commentsSource = lb.querySelector('.ldp-lb-source');
+ const commentsSourceText = commentsSource.querySelector('.ldp-lb-source-text');
+ const commentsSourceReactions = lb.querySelector('.ldp-lb-source-reactions');
+ const commentsStatus = lb.querySelector('.ldp-lb-comments-status');
+ const commentsEmpty = lb.querySelector('.ldp-lb-comments-empty');
+ const commentsList = lb.querySelector('.ldp-lb-comment-list');
+ const commentForm = lb.querySelector('.ldp-lb-comment-form');
+ const commentTargetLabel = commentForm.querySelector('.ldp-lb-comment-target');
+ const commentInput = commentForm.querySelector('.ldp-lb-comment-input');
+ const commentImageOption = commentForm.querySelector('.ldp-lb-comment-image-option');
+ const commentImageCheckbox = commentImageOption.querySelector('input');
+ const commentError = commentForm.querySelector('.ldp-lb-comment-error');
+ const prevBtn = lb.querySelector('.ldp-lb-prev');
+ const nextBtn = lb.querySelector('.ldp-lb-next');
+ const filmstrip = lb.querySelector('.ldp-lb-filmstrip');
+ const thumbsEl = lb.querySelector('.ldp-lb-thumbs');
+ let thumbs = [...lb.querySelectorAll('.ldp-lb-thumb')];
+ const progress = lb.querySelector('.ldp-lb-strip-progress > span');
+ const batchOverlay = lb.querySelector('.ldp-lb-batch-overlay');
+ const batchCloseButtons = lb.querySelectorAll('.ldp-lb-batch-close,.ldp-lb-batch-cancel');
+ const batchNameInput = lb.querySelector('.ldp-lb-batch-name input');
+ const batchScopeButtons = [...lb.querySelectorAll('[data-lb-batch-scope]')];
+ const batchSelectAll = lb.querySelector('.ldp-lb-batch-select-all');
+ const batchCount = lb.querySelector('.ldp-lb-batch-count');
+ const batchGrid = lb.querySelector('.ldp-lb-batch-grid');
+ const batchProgress = lb.querySelector('.ldp-lb-batch-progress');
+ const batchProgressCopy = lb.querySelector('.ldp-lb-batch-progress-copy');
+ const batchProgressTrack = lb.querySelector('.ldp-lb-batch-progress-track');
+ const batchStatus = lb.querySelector('.ldp-lb-batch-status');
+ const batchDownloadButton = lb.querySelector('.ldp-lb-batch-download');
+ const batchSelection = new Set();
+ const batchThumbnailUrls = new Map();
+ const batchThumbnailQueue = [];
+ let batchThumbnailActive = 0;
+ let batchThumbnailObserver = null;
+ let batchBusy = false;
+ let batchScope = 'loaded';
+ let batchLoadedScopeKeys = new Set();
+ let batchLoadedForwardPostNumber = 0;
+ let batchLoadedScopeExhausted = false;
+ let batchAllImagesLoaded = false;
+ const commentsEnabled = options.comments !== false;
+ const fixedItems = options.fixedItems === true;
+ commentsToggle.hidden = !commentsEnabled;
+ commentsPanel.hidden = !commentsEnabled;
+ const seenImageKeys = new Set(items.map((item) => item.key));
+ const commentCache = new Map();
+ let currentIndex = initialIndex;
+ let mainImageRequestToken = 0;
+ let commentRequestToken = 0;
+ let commentTargetPost = null;
+ let commentsManuallyToggled = false;
+ let commentsResize = null;
+ let commentsResizeFrame = 0;
+ const initialPostNumber = +(items[initialIndex]?.postNumber) || 0;
+ let exploringPrevious = false;
+ let exploringNext = false;
+ let jumpingToPost = false;
+ let backwardScanPostNumber = initialPostNumber;
+ let forwardScanPostNumber = initialPostNumber;
+ let backwardExhausted = fixedItems || !initialPostNumber ||
+ !(ctx?.loader && isFunction(ctx.loader.loadBeforePost)) ||
+ initialPostNumber <= 1;
+ let forwardExhausted = fixedItems || !initialPostNumber ||
+ !(ctx?.loader && isFunction(ctx.loader.loadAfterPost));
+ let lightboxClosed = false;
+ const commentsExpandedByDefault = commentsEnabled && PREFS.lightboxCommentsExpandedByDefault === true;
+ lb.classList.toggle('ldp-lb-comments-collapsed', !commentsExpandedByDefault);
+ setExpanded(commentsToggle, commentsExpandedByDefault);
+ setLabel(commentsToggle, commentsExpandedByDefault ? '收起图片评论' : '展开图片评论');
+ lb.style.setProperty('--ldp-lb-description-height', `${normalizeLightboxDescriptionHeight(PREFS.lightboxDescriptionHeight)}px`);
+ let commentsWidthPercent = normalizeLightboxCommentsWidth(PREFS.lightboxCommentsWidthPercent);
+ const setViewOriginalButtonState = (disabled, busy, label) => {
+ viewOriginalBtn.disabled = disabled;
+ if (busy) viewOriginalBtn.setAttribute('aria-busy', 'true');
+ else viewOriginalBtn.removeAttribute('aria-busy');
+ if (label) setLabel(viewOriginalBtn, label);
+ };
+ const imageTransform = createImageTransformController(stage, mainImg, {
+ overflowPadding: 24,
+ allowContainedPan: true,
+ resetPanAtFit: false,
+ zoomValue,
+ zoomOutButton: lb.querySelector('[data-lb-action="zoom-out"]'),
+ zoomInButton: lb.querySelector('[data-lb-action="zoom-in"]'),
+ render: ({ scale, panX, panY }) => {
+ mainImg.style.setProperty('--ldp-lb-scale', String(scale));
+ mainImg.style.setProperty('--ldp-lb-pan-x', `${Math.round(panX)}px`);
+ mainImg.style.setProperty('--ldp-lb-pan-y', `${Math.round(panY)}px`);
+ },
+ });
+ const { render: renderTransform, setZoom, reset: resetView } = imageTransform;
+ const applyCommentsWidth = (value, persist = false) => {
+ commentsWidthPercent = normalizeLightboxCommentsWidth(value);
+ lb.style.setProperty('--ldp-lb-comments-width-preferred', `${commentsWidthPercent}%`);
+ commentsResizer.setAttribute('aria-valuemin', String(LIGHTBOX_COMMENTS_WIDTH_MIN));
+ commentsResizer.setAttribute('aria-valuemax', String(LIGHTBOX_COMMENTS_WIDTH_MAX));
+ commentsResizer.setAttribute('aria-valuenow', String(Math.round(commentsWidthPercent)));
+ if (persist && commentsWidthPercent !== normalizeLightboxCommentsWidth(PREFS.lightboxCommentsWidthPercent))
+ setPref('lightboxCommentsWidthPercent', commentsWidthPercent);
+ requestAnimationFrame(() => renderTransform());
+ };
+ applyCommentsWidth(commentsWidthPercent);
+ const totalPostNumber = () => Math.max(
+ 0,
+ +(ctx?.totalPosts || 0),
+ +(ctx?.topicData && ctx.topicData.highest_post_number || 0)
+ );
+ if (initialPostNumber && totalPostNumber() && initialPostNumber >= totalPostNumber()) {
+ forwardExhausted = true;
+ }
+ const scrollActiveThumbIntoView = () => {
+ const thumb = thumbs[currentIndex];
+ if (thumb) thumb.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
+ };
+ const updateNavigationState = () => {
+ const exploring = exploringPrevious || exploringNext;
+ [
+ [prevBtn, exploringPrevious, currentIndex === 0, backwardExhausted,
+ ['正在加载更早图片', '已经到头了', '继续点击加载更早图片', '上一张']],
+ [nextBtn, exploringNext, currentIndex === items.length - 1, forwardExhausted,
+ ['正在加载更多图片', '已经到底了', '继续点击加载更多图片', '下一张']],
+ ].forEach(([button, busy, atDiscoveredEdge, exhausted, labels]) => {
+ const atPostEdge = atDiscoveredEdge && exhausted;
+ button.disabled = exploring;
+ button.setAttribute('aria-disabled', String(atPostEdge));
+ button.setAttribute('aria-busy', String(busy));
+ setLabel(button, labels[busy ? 0 : atPostEdge ? 1 : atDiscoveredEdge ? 2 : 3]);
+ });
+ };
+ const updateJumpToPostState = () => {
+ const postNumber = +(items[currentIndex]?.postNumber) || 0;
+ jumpToPostBtn.disabled = jumpingToPost || !postNumber;
+ jumpToPostBtn.setAttribute('aria-busy', String(jumpingToPost));
+ setLabel(jumpToPostBtn, '跳到楼层');
+ };
+ const syncThumbs = () => {
+ filmstrip.hidden = items.length < 2;
+ thumbsEl.innerHTML = items.map((item, index) => {
+ return `
`;
+ }).join('');
+ thumbs = [...thumbsEl.querySelectorAll('.ldp-lb-thumb')];
+ if (!batchOverlay.hidden) renderBatchSelection();
+ };
+ const batchScopeItems = () => batchScope === 'all'
+ ? items
+ : items.filter((item) => batchLoadedScopeKeys.has(item.key));
+ const batchCanLoadMore = () => batchScope === 'loaded' && !fixedItems && !batchLoadedScopeExhausted &&
+ ctx && ctx.loader && isFunction(ctx.loader.loadAfterPost);
+ const syncBatchScopeControls = () => {
+ batchScopeButtons.forEach((button) => {
+ const active = button.dataset.lbBatchScope === batchScope;
+ setPressed(button, active);
+ button.setAttribute('aria-selected', String(active));
+ button.tabIndex = active ? 0 : -1;
+ button.disabled = batchBusy || fixedItems && button.dataset.lbBatchScope === 'all';
+ });
+ };
+ const syncBatchSelectionControls = () => {
+ const scopedItems = batchScopeItems();
+ const allSelected = scopedItems.length > 0 && scopedItems.every((item) => batchSelection.has(item.key));
+ setPressed(batchSelectAll, allSelected);
+ batchSelectAll.innerHTML = `${icon(allSelected ? 'squareCheck' : 'square')}
${allSelected ? '全不选' : '全选'}`;
+ batchCount.textContent = `已选 ${batchSelection.size} / ${scopedItems.length}`;
+ batchSelectAll.disabled = batchBusy;
+ batchDownloadButton.disabled = batchBusy || batchSelection.size === 0;
+ syncBatchScopeControls();
+ };
+ const batchThumbnailPersistentKey = (item) => {
+ const source = absoluteImageUrl(item && (item.thumbSrc || item.originalSrc));
+ if (!/^https?:/i.test(source)) return '';
+ try {
+ const key = new URL(source);
+ key.searchParams.set('__ldp_cached_thumbnail', '240-webp-v1');
+ return key.href;
+ } catch {
+ return '';
+ }
+ };
+ const createBatchThumbnail = async (item) => {
+ const persistentKey = batchThumbnailPersistentKey(item);
+ const blob = await fetchLightboxDownloadBlob(item.thumbSrc || item.originalSrc, POST_REQUEST_PRIORITY.background);
+ if (!isFunction(globalThis.createImageBitmap)) return URL.createObjectURL(blob);
+ let bitmap;
+ try {
+ bitmap = await globalThis.createImageBitmap(blob, { resizeWidth: 240, resizeQuality: 'low' });
+ } catch {
+ // Some browser-supported image formats cannot be decoded by createImageBitmap.
+ // Keep the fetched image usable instead of leaving a permanently empty card.
+ return URL.createObjectURL(blob);
+ }
+ const width = Math.max(1, Math.min(240, bitmap.width));
+ const height = Math.max(1, Math.round(bitmap.height * width / bitmap.width));
+ const canvas = makeElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const context = canvas.getContext('2d', { alpha: true });
+ if (!context) {
+ bitmap.close();
+ return URL.createObjectURL(blob);
+ }
+ context.drawImage(bitmap, 0, 0, width, height);
+ bitmap.close();
+ const thumbnailBlob = await new Promise((resolve) => {
+ canvas.toBlob(resolve, 'image/webp', 0.78);
+ });
+ const result = thumbnailBlob?.size ? thumbnailBlob : blob;
+ if (persistentKey && result === thumbnailBlob) await persistLightboxBlob(persistentKey, result, 'image/webp');
+ return URL.createObjectURL(result);
+ };
+ const drainBatchThumbnailQueue = () => {
+ while (!lightboxClosed && batchThumbnailActive < 4 && batchThumbnailQueue.length) {
+ const task = batchThumbnailQueue.shift();
+ batchThumbnailActive++;
+ Promise.resolve().then(task).catch(() => {}).finally(() => {
+ batchThumbnailActive--;
+ drainBatchThumbnailQueue();
+ });
+ }
+ };
+ const enqueueBatchThumbnail = (task) => {
+ batchThumbnailQueue.push(task);
+ drainBatchThumbnailQueue();
+ };
+ const loadBatchThumbnail = (image, item) => {
+ if (!image || !item || image.dataset.ldpBatchThumbLoading === '1') return;
+ const cached = batchThumbnailUrls.get(item.key);
+ if (cached) {
+ image.dataset.ldpBatchThumbState = 'loading';
+ image.src = cached;
+ return;
+ }
+ image.dataset.ldpBatchThumbLoading = '1';
+ const displayThumbnail = (source) => {
+ if (lightboxClosed) {
+ URL.revokeObjectURL(source);
+ return;
+ }
+ const previous = batchThumbnailUrls.get(item.key);
+ if (previous && previous !== source) URL.revokeObjectURL(source);
+ else batchThumbnailUrls.set(item.key, source);
+ if (image.isConnected && image.dataset.ldpBatchThumbKey === item.key) {
+ image.dataset.ldpBatchThumbState = 'loading';
+ image.src = batchThumbnailUrls.get(item.key) || source;
+ }
+ };
+ const generateThumbnail = () => enqueueBatchThumbnail(async () => {
+ if (lightboxClosed || batchThumbnailUrls.has(item.key)) return;
+ try {
+ displayThumbnail(await createBatchThumbnail(item));
+ } catch {
+ if (image.isConnected && image.dataset.ldpBatchThumbKey === item.key) {
+ image.alt = '缩略图加载失败';
+ image.dataset.ldpBatchThumbState = 'failed';
+ }
+ }
+ });
+ const persistentKey = batchThumbnailPersistentKey(item);
+ if (!persistentKey) {
+ generateThumbnail();
+ return;
+ }
+ void cachedPersistentLightboxBlob(persistentKey).then((cachedThumbnail) => {
+ if (cachedThumbnail) {
+ displayThumbnail(URL.createObjectURL(cachedThumbnail));
+ return;
+ }
+ generateThumbnail();
+ }).catch(generateThumbnail);
+ };
+ const observeBatchThumbnails = () => {
+ if (batchThumbnailObserver) batchThumbnailObserver.disconnect();
+ const images = batchGrid.querySelectorAll('[data-ldp-batch-thumb-key]');
+ if (!isFunction(IntersectionObserver)) {
+ images.forEach((image) => loadBatchThumbnail(image, items[+image.dataset.ldpBatchIndex]));
+ return;
+ }
+ batchThumbnailObserver = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (!entry.isIntersecting) return;
+ batchThumbnailObserver.unobserve(entry.target);
+ loadBatchThumbnail(entry.target, items[+entry.target.dataset.ldpBatchIndex]);
+ });
+ }, { root: batchGrid, rootMargin: '160px 0px' });
+ images.forEach((image) => batchThumbnailObserver.observe(image));
+ };
+ function renderBatchSelection() {
+ const scopedItems = batchScopeItems();
+ const availableKeys = new Set(scopedItems.map((item) => item.key));
+ batchSelection.forEach((key) => {
+ if (!availableKeys.has(key)) batchSelection.delete(key);
+ });
+ const itemMarkup = scopedItems.map((item, index) => {
+ const itemIndex = items.indexOf(item);
+ const selected = batchSelection.has(item.key);
+ const label = `#${item.postNumber || '?'} · 图片 ${index + 1}`;
+ const cachedThumbnail = batchThumbnailUrls.get(item.key);
+ const immediateThumbnail = cachedThumbnail || item.thumbSrc || item.originalSrc;
+ return `
+
+
+ ${esc(label)}
+ `;
+ }).join('');
+ const moreMarkup = batchCanLoadMore()
+ ? `
`
+ : '';
+ batchGrid.innerHTML = itemMarkup + moreMarkup;
+ batchGrid.querySelectorAll('[data-ldp-batch-thumb-key]').forEach((image) => {
+ const syncThumbnailState = () => {
+ if (image.naturalWidth > 0) {
+ image.dataset.ldpBatchThumbState = 'loaded';
+ return;
+ }
+ const generatedSource = batchThumbnailUrls.get(image.dataset.ldpBatchThumbKey);
+ const currentSource = image.currentSrc || image.src;
+ const fallbackSource = image.dataset.ldpBatchThumbFallback || '';
+ if (generatedSource && currentSource === generatedSource && fallbackSource && fallbackSource !== generatedSource) {
+ batchThumbnailUrls.delete(image.dataset.ldpBatchThumbKey);
+ URL.revokeObjectURL(generatedSource);
+ image.dataset.ldpBatchThumbState = 'loading';
+ image.src = fallbackSource;
+ return;
+ }
+ image.dataset.ldpBatchThumbState = 'failed';
+ };
+ image.addEventListener('load', syncThumbnailState);
+ image.addEventListener('error', syncThumbnailState);
+ if (image.complete) syncThumbnailState();
+ });
+ if (!batchBusy) observeBatchThumbnails();
+ syncBatchSelectionControls();
+ }
+ const setBatchProgress = (completed, total, label) => {
+ const percent = total > 0 ? Math.max(0, Math.min(100, Math.round(completed / total * 100))) : 0;
+ batchProgress.hidden = false;
+ batchProgress.style.setProperty('--ldp-lb-batch-progress', `${percent}%`);
+ batchProgressCopy.firstElementChild.textContent = label;
+ batchProgressCopy.lastElementChild.textContent = `${percent}%`;
+ batchProgressTrack.setAttribute('aria-valuenow', String(percent));
+ };
+ const openBatchDownload = () => {
+ batchSelection.clear();
+ batchScope = 'loaded';
+ batchLoadedScopeKeys = new Set(items.map((item) => item.key));
+ batchLoadedForwardPostNumber = Math.max(0,
+ ...items.map((item) => +item.postNumber || 0),
+ ...(ctx && Array.isArray(ctx.streamItems) ? ctx.streamItems.map((item) => +item.postNumber || 0) : [])
+ );
+ batchLoadedScopeExhausted = Boolean(totalPostNumber() && batchLoadedForwardPostNumber >= totalPostNumber());
+ batchNameInput.value = '';
+ const title = safeDownloadFilename(
+ ctx?.topicData && ctx.topicData.title || document.title || '帖子图片',
+ '帖子图片'
+ );
+ batchNameInput.placeholder = `${title}_${lightboxDownloadTimestamp()}.zip`;
+ batchStatus.textContent = '请选择要打包的图片';
+ batchProgress.hidden = true;
+ batchOverlay.hidden = false;
+ renderBatchSelection();
+ requestAnimationFrame(() => {
+ if (batchScopeButtons[0]) batchScopeButtons[0].focus({ preventScroll: true });
+ const firstItem = batchScopeItems()[0];
+ const firstIndex = firstItem ? items.indexOf(firstItem) : -1;
+ const firstNode = firstIndex >= 0 ? batchGrid.querySelector(`[data-lb-batch-index="${firstIndex}"]`) : null;
+ if (firstIndex >= 0 && firstNode) void openBatchItemPreview(firstIndex, firstNode);
+ });
+ };
+ const closeBatchDownload = () => {
+ if (batchBusy) return;
+ const imageViewer = readerPortalQuery('.ldp-avatar-viewer.is-image');
+ if (isFunction(imageViewer?._ldpClose)) imageViewer._ldpClose();
+ batchOverlay.hidden = true;
+ batchSelection.clear();
+ };
+ const setCommentsExpanded = (expanded, manual = false) => {
+ if (manual) commentsManuallyToggled = true;
+ lb.classList.toggle('ldp-lb-comments-collapsed', !expanded);
+ setExpanded(commentsToggle, !!expanded);
+ setLabel(commentsToggle, expanded ? '收起图片评论' : '展开图片评论');
+ requestAnimationFrame(renderTransform);
+ };
+ const closeCommentForm = () => {
+ commentTargetPost = null;
+ commentForm.hidden = true;
+ commentForm.removeAttribute('aria-busy');
+ commentForm.dataset.rootComment = '0';
+ commentInput.value = '';
+ commentError.textContent = '';
+ };
+ const renderCommentSource = (item, sourcePost = null) => {
+ const post = sourcePost || item?.sourcePost || null;
+ const postNumber = +(item?.postNumber || post?.post_number || 0);
+ commentsSource.hidden = !postNumber;
+ commentsDescriptionToggle.hidden = !postNumber;
+ commentsSourceText.textContent = lightboxSourceDescription(post, item);
+ commentsSourceReactions.hidden = true;
+ commentsSourceReactions._ldpPostData = post;
+ commentsSourceReactions.dataset.postId = String(post?.id || '');
+ commentsSourceReactions.dataset.postNumber = String(postNumber || '');
+ if (post?.id) {
+ renderPostReactions(commentsSourceReactions, ctx);
+ commentsSourceReactions.hidden = !commentsSourceReactions.classList.contains('ldp-has-reactions');
+ }
+ };
+ const quoteMatchesCurrentItem = (quote, item) => {
+ if (!quote || +quote.dataset.post !== +item.postNumber) return false;
+ const quotedTopicId = +(quote.dataset.topic || 0);
+ if (quotedTopicId && item.topicId && quotedTopicId !== +item.topicId) return false;
+ const expectedSource = comparableLightboxImageSource(item.originalSrc);
+ return lightboxQuoteContainsImage(quote, item, expectedSource);
+ };
+ const renderLightboxCommentPost = (post, nested, state, item) => {
+ const displayPost = Object.assign({}, post, { reply_count: 0 });
+ const node = renderPost(displayPost, nested, ctx, {
+ forceExpanded: true,
+ showParentJump: nested,
+ });
+ node.classList.remove('ldp-reply-collapsible', 'ldp-nested-collapsed');
+ node.querySelectorAll(':scope > .ldp-nested-toggle,:scope > .ldp-collapse-replies,:scope > .ldp-children,:scope > .ldp-sub-loading').forEach((element) => element.remove());
+ const content = node.querySelector(':scope > .ldp-content');
+ const directReference = state.directMatchNumbers.has(+post.post_number);
+ if (content) {
+ if (directReference) {
+ content.querySelectorAll('aside.quote').forEach((quote) => {
+ if (quoteMatchesCurrentItem(quote, item)) quote.remove();
+ });
+ }
+ content.querySelectorAll('img:not(.emoji),video,audio,iframe,canvas,source,picture').forEach((media) => media.remove());
+ content.querySelectorAll('.ldp-quote-controls').forEach((controls) => controls.remove());
+ content.querySelectorAll('.lightbox-wrapper,figure,a.lightbox').forEach((wrapper) => {
+ if (!String(wrapper.textContent || '').trim() && !wrapper.querySelector('img,video,audio,iframe')) wrapper.remove();
+ });
+ }
+ node.querySelectorAll(':scope > .ldp-boost-list,:scope > .ldp-topic-footer-actions').forEach((element) => element.remove());
+ node.querySelectorAll('.ldp-context-actions-slot,.ldp-boostbtn').forEach((element) => element.remove());
+ node.querySelectorAll('.ldp-post-head .ldp-floor').forEach((floor) => {
+ if (!floor.dataset.targetPostNumber) floor.dataset.targetPostNumber = String(post.post_number || '');
+ floor.classList.add('ldp-lb-floor-jump');
+ if (floor.tagName !== 'BUTTON') {
+ floor.tabIndex = 0;
+ floor.setAttribute('role', 'button');
+ }
+ });
+ return node;
+ };
+ const descendantsForComment = (rootPostNumber, state) => {
+ const descendants = [];
+ const visited = new Set([+rootPostNumber]);
+ const visit = (postNumber) => {
+ const children = state.childrenByParent.get(+postNumber) || [];
+ children.forEach((child) => {
+ const childNumber = +(child?.post_number || 0);
+ if (!childNumber || visited.has(childNumber)) return;
+ visited.add(childNumber);
+ descendants.push(child);
+ visit(childNumber);
+ });
+ };
+ visit(rootPostNumber);
+ return descendants;
+ };
+ const renderCommentState = (state, item) => {
+ const count = state?.postsByNumber ? state.postsByNumber.size : 0;
+ commentsToggleCount.textContent = String(count);
+ commentsHeadingCount.textContent = `(${count})`;
+ renderCommentSource(item, state?.sourcePost);
+ commentsList.replaceChildren();
+ commentsStatus.hidden = true;
+ commentsEmpty.hidden = count > 0;
+ if (!count)
+ commentsEmpty.querySelector('span').textContent = state?.partial
+ ? '暂未找到评论,部分结果加载失败'
+ : '还没有人评论这张图片';
+ (state?.roots || []).forEach((rootPost) => {
+ const thread = makeElement('section');
+ thread.className = 'ldp-lb-comment-thread';
+ const rootWrap = makeElement('div');
+ rootWrap.className = 'ldp-lb-comment-node';
+ rootWrap.append(renderLightboxCommentPost(rootPost, false, state, item));
+ thread.append(rootWrap);
+ const descendants = descendantsForComment(rootPost.post_number, state);
+ if (descendants.length) {
+ const children = makeElement('div');
+ children.className = 'ldp-lb-comment-children';
+ descendants.forEach((reply) => {
+ const replyWrap = makeElement('div');
+ replyWrap.className = 'ldp-lb-comment-node';
+ replyWrap.append(renderLightboxCommentPost(reply, true, state, item));
+ children.append(replyWrap);
+ });
+ thread.append(children);
+ }
+ commentsList.append(thread);
+ });
+ if (state?.partial && count > 0) {
+ commentsStatus.hidden = false;
+ commentsStatus.textContent = '部分关联回复暂未加载';
+ }
+ if (!commentsManuallyToggled) setCommentsExpanded(commentsExpandedByDefault);
+ };
+ const openCommentForm = (targetPost = null) => {
+ const item = items[currentIndex];
+ const rootComment = !targetPost;
+ commentTargetPost = targetPost || item.sourcePost || {
+ id: item.postId,
+ post_number: item.postNumber,
+ username: item.username,
+ };
+ const username = cleanUsernameValue(commentTargetPost?.username);
+ const targetNumber = +(commentTargetPost?.post_number || item.postNumber || 0);
+ commentForm.dataset.rootComment = rootComment ? '1' : '0';
+ commentTargetLabel.textContent = rootComment
+ ? `${username ? `评论 @${username}` : '评论'} 的图片(回复 #${targetNumber})`
+ : `回复 ${username ? `@${username} · ` : ''}#${targetNumber}`;
+ commentImageOption.hidden = rootComment;
+ commentImageCheckbox.checked = rootComment;
+ commentError.textContent = '';
+ commentForm.hidden = false;
+ setCommentsExpanded(true);
+ requestAnimationFrame(() => {
+ commentForm.scrollIntoView({ block: 'nearest' });
+ commentInput.focus({ preventScroll: true });
+ });
+ };
+ const loadCommentsForItem = async (item) => {
+ const token = ++commentRequestToken;
+ closeCommentForm();
+ renderCommentSource(item);
+ const cached = commentCache.get(item.key);
+ if (cached) {
+ renderCommentState(cached, item);
+ return cached;
+ }
+ commentsToggleCount.textContent = '…';
+ commentsHeadingCount.textContent = '(…)';
+ commentsStatus.hidden = false;
+ commentsStatus.textContent = '正在查找这张图片的评论…';
+ commentsEmpty.hidden = true;
+ commentsList.replaceChildren();
+ if (!commentsManuallyToggled) setCommentsExpanded(commentsExpandedByDefault);
+ try {
+ const state = await loadLightboxCommentTree(item, ctx);
+ if (lightboxClosed || token !== commentRequestToken || items[currentIndex].key !== item.key) return null;
+ commentCache.set(item.key, state);
+ renderCommentState(state, item);
+ return state;
+ } catch (error) {
+ if (lightboxClosed || token !== commentRequestToken) return null;
+ commentsToggleCount.textContent = '0';
+ commentsHeadingCount.textContent = '(0)';
+ commentsStatus.hidden = false;
+ commentsStatus.textContent = `评论加载失败:${error.message || '请重试'}`;
+ commentsEmpty.hidden = false;
+ commentsEmpty.querySelector('span').textContent = '暂时无法加载图片评论';
+ return null;
+ }
+ };
+ const toggleLightboxCommentLike = async (button, postNode) => {
+ const postData = postNode?._ldpPostData;
+ const postId = +(postData?.id || postNode && postNode.dataset.postId || 0);
+ if (!postId || !postData || button.disabled) return;
+ await runReaderActionOnce(ctx, `post:${postId}:like`, async () => {
+ await toggleReaderPostLike(postId, ctx, button, {
+ setBusy: (busy) => setReaderControlBusy(button, busy),
+ beforeSync: (acted, count) => syncReaderLikeButton(button, updatePostLikeAction(postData, acted, count)),
+ postData,
+ persist: true,
+ onError: (error) => showSelectionToast(`操作失败:${error.message}`),
+ });
+ });
+ };
+ const positionLightboxReactionPicker = (picker) => {
+ if (!picker || picker.hidden || !commentsPanel.isConnected) return;
+ picker.style.removeProperty('left');
+ const panelRect = commentsPanel.getBoundingClientRect();
+ const safeInset = 10;
+ picker.style.maxWidth = `${Math.max(1, Math.min(320, panelRect.width - safeInset * 2, window.innerWidth - 24))}px`;
+ const pickerRect = picker.getBoundingClientRect();
+ const safeLeft = Math.max(12, panelRect.left + safeInset);
+ const safeRight = Math.min(window.innerWidth - 12, panelRect.right - safeInset);
+ let shift = 0;
+ if (pickerRect.right > safeRight) shift -= pickerRect.right - safeRight;
+ if (pickerRect.left + shift < safeLeft) shift += safeLeft - (pickerRect.left + shift);
+ picker.style.left = `${Math.round(shift)}px`;
+ };
+ let lightboxReactionHoverOpenTimer = 0;
+ let lightboxReactionHoverCloseTimer = 0;
+ const clearLightboxReactionHoverTimers = () => {
+ clearTimeout(lightboxReactionHoverOpenTimer);
+ clearTimeout(lightboxReactionHoverCloseTimer);
+ lightboxReactionHoverOpenTimer = lightboxReactionHoverCloseTimer = 0;
+ };
+ const setLightboxReactionPickerExpanded = (postNode, expanded) => {
+ clearLightboxReactionHoverTimers();
+ const root = postNode?.querySelector(':scope > .ldp-reactions');
+ const trigger = root?.querySelector('[data-reaction-picker]');
+ const picker = root?.querySelector('.ldp-reaction-picker');
+ if (!trigger || !picker) return;
+ picker.hidden = !expanded;
+ if (expanded) requestAnimationFrame(() => positionLightboxReactionPicker(picker));
+ setExpanded(trigger, !!expanded);
+ refreshTooltips(trigger);
+ };
+ const openLightboxReactionPicker = async (postNode) => {
+ const currentPicker = postNode?.querySelector(':scope > .ldp-reactions .ldp-reaction-picker');
+ if (!currentPicker?.hidden) return;
+ if (!reactionEmojiUrls) {
+ await loadReactionEmojiRegistry();
+ if (lightboxClosed || !postNode?.isConnected) return;
+ renderPostReactions(postNode, ctx);
+ }
+ setLightboxReactionPickerExpanded(postNode, true);
+ };
+ const toggleLightboxReaction = async (reaction, postNode) => {
+ const postData = postNode?._ldpPostData;
+ const postId = +(postData?.id || postNode && postNode.dataset.postId || 0);
+ reaction = String(reaction || '');
+ if (!postId || !reaction) return;
+ const item = items[currentIndex];
+ await toggleReaderPostReaction(postNode, ctx, reaction, (fresh) => {
+ postNode._ldpPostData = Object.assign({}, postData, fresh);
+ const state = commentCache.get(item.key);
+ const cachedPost = state && state.postsByNumber.get(+(postData?.post_number || 0));
+ if (cachedPost && +cachedPost.id === postId) Object.assign(cachedPost, fresh);
+ if (item.sourcePost && +item.sourcePost.id === postId) Object.assign(item.sourcePost, fresh);
+ renderPostReactions(postNode, ctx);
+ }, (error) => showSelectionToast(`回应失败:${error.message}`));
+ };
+ const jumpToLightboxComment = async (post) => {
+ const postNumber = +(post?.post_number || 0);
+ if (!postNumber || lightboxClosed) return;
+ try {
+ const acceptsWork = () => !lightboxClosed;
+ const jumped = await ctx.timeline.jumpTo(postNumber, {
+ acceptsWork,
+ targetPost: post,
+ forceNested: true,
+ });
+ if (!jumped || lightboxClosed) return;
+ close();
+ } catch {
+ showSelectionToast('无法定位到该楼层');
+ }
+ };
+ const onCommentFormSubmit = async (event) => {
+ event.preventDefault();
+ if (commentForm.getAttribute('aria-busy') === 'true') return;
+ const item = items[currentIndex];
+ const targetPost = commentTargetPost;
+ const targetPostNumber = +(targetPost?.post_number || 0);
+ const message = String(commentInput.value || '').trim();
+ const messageLength = [...message].length;
+ const includeImage = commentForm.dataset.rootComment === '1' || commentImageCheckbox.checked;
+ const quoteRaw = includeImage ? lightboxImageQuoteRaw(item, ctx) : '';
+ if (!message || !targetPostNumber || includeImage && !quoteRaw) {
+ commentError.textContent = !message ? '请输入评论内容' : '无法确认图片引用来源';
+ return;
+ }
+ if (messageLength < minimumCommentLength) {
+ commentError.textContent = `评论至少需要 ${minimumCommentLength} 个字符(当前 ${messageLength} 个)`;
+ return;
+ }
+ commentForm.setAttribute('aria-busy', 'true');
+ commentError.textContent = '';
+ try {
+ const payload = await apiSend(`${BASE}/posts`, 'POST', {
+ topic_id: +ctx.topicId,
+ raw: `${quoteRaw}${message}`,
+ reply_to_post_number: targetPostNumber,
+ }, null, { priority: POST_REQUEST_PRIORITY.visible });
+ const created = postsFromPayload(payload)[0] || payload?.post || payload;
+ if (!created || !created.id || !created.post_number) throw new Error('服务器未返回新楼层');
+ if (!created.reply_to_post_number) created.reply_to_post_number = targetPostNumber;
+ if (!created.topic_id) created.topic_id = +ctx.topicId;
+ if (includeImage) {
+ created._ldpImageReferenceKeys = [...new Set([
+ ...(Array.isArray(created._ldpImageReferenceKeys) ? created._ldpImageReferenceKeys : []),
+ item.key,
+ ])];
+ }
+ if (!ctx.lightboxImageCommentPosts) ctx.lightboxImageCommentPosts = new Map();
+ ctx.lightboxImageCommentPosts.set(+created.post_number, created);
+ if (targetPost?.id) {
+ targetPost.reply_count = Math.max(0, +(targetPost.reply_count || 0)) + 1;
+ persistReaderPostState(ctx, targetPost);
+ }
+ rememberPostData(ctx, created, targetPostNumber);
+ ctx.loader.persistFetchedPosts([created]);
+ ingestLivePosts([created], ctx, { refreshContent: true });
+ ctx.totalPosts = Math.max(+(ctx.totalPosts || 0), +created.post_number);
+ let state = commentCache.get(item.key);
+ if (!state) state = await loadLightboxCommentTree(item, ctx);
+ state.postsByNumber.set(+created.post_number, created);
+ if (includeImage) state.directMatchNumbers.add(+created.post_number);
+ const tree = buildLightboxCommentTree(state.postsByNumber, state.directMatchNumbers);
+ state.roots = tree.roots;
+ state.childrenByParent = tree.childrenByParent;
+ commentCache.set(item.key, state);
+ persistLightboxCommentRelation(item, ctx, state);
+ commentRequestToken++;
+ closeCommentForm();
+ if (!lightboxClosed && items[currentIndex].key === item.key) renderCommentState(state, item);
+ } catch (error) {
+ commentError.textContent = `发送失败:${error.message || '请重试'}`;
+ } finally {
+ commentForm.removeAttribute('aria-busy');
+ }
+ };
+ const onCommentsReactionPointerOver = (event) => {
+ const root = event.target.closest('.ldp-reactions');
+ if (!root || !commentsPanel.contains(root)) return;
+ clearTimeout(lightboxReactionHoverCloseTimer);
+ lightboxReactionHoverCloseTimer = 0;
+ const trigger = event.target.closest('[data-reaction-picker]');
+ const reactionPost = trigger?.closest('.ldp-lb-comment-node > .ldp-post,.ldp-lb-source-reactions');
+ if (!reactionPost || trigger.contains(event.relatedTarget)) return;
+ clearTimeout(lightboxReactionHoverOpenTimer);
+ lightboxReactionHoverOpenTimer = setTimeout(() => {
+ lightboxReactionHoverOpenTimer = 0;
+ void openLightboxReactionPicker(reactionPost);
+ }, 250);
+ };
+ const onCommentsReactionPointerOut = (event) => {
+ const root = event.target.closest('.ldp-reactions');
+ if (!root || !commentsPanel.contains(root)) return;
+ const trigger = event.target.closest('[data-reaction-picker]');
+ if (trigger && (!event.relatedTarget || !trigger.contains(event.relatedTarget))) {
+ clearTimeout(lightboxReactionHoverOpenTimer);
+ lightboxReactionHoverOpenTimer = 0;
+ }
+ if (event.relatedTarget && root.contains(event.relatedTarget)) return;
+ clearLightboxReactionHoverTimers();
+ const reactionPost = root.closest('.ldp-lb-comment-node > .ldp-post,.ldp-lb-source-reactions');
+ lightboxReactionHoverCloseTimer = setTimeout(() => {
+ lightboxReactionHoverCloseTimer = 0;
+ setLightboxReactionPickerExpanded(reactionPost, false);
+ }, 1000);
+ };
+ const onCommentsClick = (event) => {
+ const addButton = event.target.closest('.ldp-lb-add');
+ if (addButton) {
+ openCommentForm();
+ return;
+ }
+ const reactionPost = event.target.closest('.ldp-lb-comment-node > .ldp-post,.ldp-lb-source-reactions');
+ const reactionPicker = event.target.closest('[data-reaction-picker]');
+ if (reactionPost && reactionPicker) {
+ event.preventDefault();
+ setLightboxReactionPickerExpanded(reactionPost, false);
+ void toggleLightboxReaction(reactionPicker.dataset.reaction || 'heart', reactionPost);
+ return;
+ }
+ const reactionButton = event.target.closest('[data-reaction]');
+ if (reactionPost && reactionButton) {
+ event.preventDefault();
+ setLightboxReactionPickerExpanded(reactionPost, false);
+ void toggleLightboxReaction(reactionButton.dataset.reaction, reactionPost);
+ return;
+ }
+ const postNode = event.target.closest('.ldp-lb-comment-node > .ldp-post');
+ if (!postNode) return;
+ const postData = postNode._ldpPostData;
+ const replyButton = event.target.closest('.ldp-replybtn');
+ if (replyButton) {
+ event.preventDefault();
+ openCommentForm(postData);
+ return;
+ }
+ const likeButton = event.target.closest('.ldp-like');
+ if (likeButton) {
+ event.preventDefault();
+ void toggleLightboxCommentLike(likeButton, postNode);
+ return;
+ }
+ const floor = event.target.closest('.ldp-lb-floor-jump');
+ if (floor) {
+ event.preventDefault();
+ const targetNumber = +(floor.dataset.targetPostNumber || postData?.post_number || 0);
+ const state = commentCache.get(items[currentIndex].key);
+ const targetPost = state && state.postsByNumber.get(targetNumber) || knownLightboxPosts(ctx).get(targetNumber);
+ if (targetPost) void jumpToLightboxComment(targetPost);
+ }
+ };
+ const onCommentsKeydown = (event) => {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ const floor = event.target.closest('.ldp-lb-floor-jump[role="button"]');
+ if (!floor) return;
+ event.preventDefault();
+ floor.click();
+ };
+ const showItem = (index) => {
+ currentIndex = Math.max(0, Math.min(items.length - 1, Number(index) || 0));
+ const item = items[currentIndex];
+ const previewSrc = item.thumbSrc || item.originalSrc;
+ const hasSeparateOriginal = item.originalSrc !== previewSrc;
+ mainImageRequestToken++;
+ resetView();
+ countEl.textContent = `${currentIndex + 1} / ${items.length}`;
+ updateNavigationState();
+ updateJumpToPostState();
+ thumbs.forEach((thumb, thumbIndex) => {
+ const active = thumbIndex === currentIndex;
+ thumb.classList.toggle('active', active);
+ thumb.setAttribute('aria-selected', String(active));
+ });
+ progress.style.setProperty('--ldp-lb-progress-size', `${100 / items.length}%`);
+ progress.style.setProperty('--ldp-lb-progress-x', `${currentIndex * 100}%`);
+ mainImg.hidden = true;
+ mainImg.alt = item.alt;
+ mainImg.dataset.ldpSourceMode = hasSeparateOriginal ? 'preview' : 'original';
+ setViewOriginalButtonState(
+ !hasSeparateOriginal, false, hasSeparateOriginal ? '查看原图' : '当前已是原图'
+ );
+ status.hidden = false;
+ statusText.textContent = hasSeparateOriginal ? '正在加载预览…' : '正在加载图片…';
+ retryBtn.hidden = true;
+ mainImg.onload = () => {
+ mainImg.hidden = false;
+ status.hidden = true;
+ void fetchLightboxDownloadBlob(previewSrc, POST_REQUEST_PRIORITY.background).catch(() => {});
+ if (mainImg.dataset.ldpSourceMode === 'original')
+ setViewOriginalButtonState(true, false, '当前已是原图');
+ renderTransform();
+ };
+ mainImg.onerror = () => {
+ mainImg.hidden = true;
+ status.hidden = false;
+ const originalFailed = mainImg.dataset.ldpSourceMode === 'original' && hasSeparateOriginal;
+ statusText.textContent = originalFailed ? '原图加载失败' : '预览图加载失败';
+ retryBtn.hidden = !originalFailed;
+ setViewOriginalButtonState(!hasSeparateOriginal, false);
+ };
+ mainImg.removeAttribute('src');
+ mainImg.src = previewSrc;
+ if (hasSeparateOriginal) {
+ loadCurrentOriginal(false, PREFS.lightboxOriginalByDefault !== true);
+ }
+ if (commentsEnabled) void loadCommentsForItem(item);
+ requestAnimationFrame(scrollActiveThumbIntoView);
+ };
+ const loadCurrentOriginal = (refresh = false, cachedOnly = false) => {
+ const item = items[currentIndex];
+ const previewSrc = item && (item.thumbSrc || item.originalSrc);
+ if (!item || !item.originalSrc || item.originalSrc === previewSrc) return;
+ const itemKey = item.key;
+ const requestToken = cachedOnly ? mainImageRequestToken : ++mainImageRequestToken;
+ const requestIsCurrent = () => !lightboxClosed && requestToken === mainImageRequestToken &&
+ lb.isConnected && items[currentIndex].key === itemKey;
+ const allowOriginalRetry = (message) => {
+ statusText.textContent = message;
+ retryBtn.hidden = false;
+ setViewOriginalButtonState(false, false, '查看原图');
+ };
+ if (!cachedOnly) {
+ setViewOriginalButtonState(true, true, '正在加载原图');
+ status.hidden = false;
+ statusText.textContent = refresh ? '正在重新加载原图…' : '正在加载原图…';
+ retryBtn.hidden = true;
+ }
+ const request = cachedOnly
+ ? persistentLightboxImageSource(item.originalSrc, { cachedOnly: true })
+ : refresh
+ ? deletePersistentLightboxImage(item.originalSrc).then(() => persistentLightboxImageSource(item.originalSrc))
+ : persistentLightboxImageSource(item.originalSrc);
+ void request.then((source) => {
+ if (!requestIsCurrent()) return;
+ if (cachedOnly && !source) return;
+ if (!source) {
+ allowOriginalRetry('正文请求优先,原图稍后重试');
+ return;
+ }
+ const originalImage = makeElement('img');
+ originalImage.onload = () => {
+ if (!requestIsCurrent()) return;
+ mainImg.dataset.ldpSourceMode = 'original';
+ mainImg.src = source;
+ };
+ originalImage.onerror = () => {
+ if (!requestIsCurrent()) return;
+ if (cachedOnly) return;
+ allowOriginalRetry('原图加载失败');
+ };
+ originalImage.src = source;
+ });
+ };
+ const adjacentItemIsScanned = (direction) => {
+ const item = items[currentIndex + direction];
+ const postNumber = +(item?.postNumber) || 0;
+ if (!postNumber) return false;
+ return direction < 0
+ ? postNumber >= backwardScanPostNumber
+ : postNumber <= forwardScanPostNumber;
+ };
+ const mergeScannedLightboxItems = (posts) => {
+ const currentKey = items[currentIndex]?.key;
+ let added = false;
+ posts.forEach((post) => {
+ lightboxItemsFromPost(post, ctx?.topicId).forEach((item) => {
+ if (seenImageKeys.has(item.key)) return;
+ seenImageKeys.add(item.key);
+ items.push(item);
+ added = true;
+ });
+ });
+ if (!added) return;
+ items.sort(compareLightboxItems);
+ currentIndex = Math.max(0, items.findIndex((item) => item.key === currentKey));
+ syncThumbs();
+ };
+ const loadMoreBatchImages = async () => {
+ if (batchBusy || !batchCanLoadMore() || lightboxClosed) return;
+ batchBusy = true;
+ batchStatus.textContent = `正在加载 #${batchLoadedForwardPostNumber} 之后的楼层…`;
+ renderBatchSelection();
+ try {
+ const posts = await ctx.loader.loadAfterPost(batchLoadedForwardPostNumber, {
+ priority: POST_REQUEST_PRIORITY.auxiliary,
+ persistFetchedPosts: true,
+ });
+ if (lightboxClosed || batchScope !== 'loaded') return;
+ if (!posts.length) {
+ batchLoadedScopeExhausted = true;
+ batchStatus.textContent = '已加载到帖子末尾';
+ return;
+ }
+ batchLoadedForwardPostNumber = Math.max(
+ batchLoadedForwardPostNumber,
+ ...posts.map((post) => +post.post_number || 0)
+ );
+ const discoveredKeys = new Set();
+ posts.forEach((post) => {
+ lightboxItemsFromPost(post, ctx?.topicId).forEach((item) => discoveredKeys.add(item.key));
+ });
+ mergeScannedLightboxItems(posts);
+ discoveredKeys.forEach((key) => {
+ if (seenImageKeys.has(key)) batchLoadedScopeKeys.add(key);
+ });
+ const highest = totalPostNumber();
+ if (highest && batchLoadedForwardPostNumber >= highest) batchLoadedScopeExhausted = true;
+ batchStatus.textContent = discoveredKeys.size
+ ? `本批新增 ${discoveredKeys.size} 张图片`
+ : batchLoadedScopeExhausted ? '已加载到帖子末尾' : '本批没有图片,可继续加载';
+ } catch (error) {
+ batchStatus.textContent = `加载更多失败:${error.message || '请重试'}`;
+ } finally {
+ batchBusy = false;
+ if (!lightboxClosed && !batchOverlay.hidden) renderBatchSelection();
+ }
+ };
+ const scanAllTopicImages = async () => {
+ if (batchBusy || batchAllImagesLoaded || fixedItems || lightboxClosed) return;
+ const topic = ctx?.loader && ctx.loader.topic || ctx?.topicData;
+ const stream = topic?.post_stream && Array.isArray(topic.post_stream.stream)
+ ? topic.post_stream.stream.filter(Boolean)
+ : [];
+ if (!stream.length || !ctx || !ctx.loader || !isFunction(ctx.loader.loadPostsByIds)) {
+ batchStatus.textContent = '未找到完整楼层列表,暂时只能使用当前加载的图片';
+ return;
+ }
+ batchBusy = true;
+ batchSelection.clear();
+ batchCloseButtons.forEach((button) => { button.disabled = true; });
+ renderBatchSelection();
+ const batchSize = 20;
+ try {
+ for (let offset = 0; offset < stream.length; offset += batchSize) {
+ const end = Math.min(stream.length, offset + batchSize);
+ setBatchProgress(offset, stream.length, `正在扫描全部帖子 ${offset} / ${stream.length}`);
+ batchStatus.textContent = '按阅读器请求速率分批扫描,不请求图片原图';
+ const posts = await ctx.loader.loadPostsByIds(stream.slice(offset, end), {
+ priority: POST_REQUEST_PRIORITY.auxiliary,
+ persistFetchedPosts: true,
+ });
+ if (lightboxClosed || batchScope !== 'all') return;
+ mergeScannedLightboxItems(posts);
+ setBatchProgress(end, stream.length, `正在扫描全部帖子 ${end} / ${stream.length}`);
+ }
+ batchAllImagesLoaded = true;
+ batchStatus.textContent = `已扫描全部帖子,共找到 ${items.length} 张图片`;
+ setBatchProgress(stream.length, stream.length, '全部帖子扫描完成');
+ } catch (error) {
+ batchStatus.textContent = `全帖扫描中断:${error.message || '请重试'}`;
+ } finally {
+ batchBusy = false;
+ batchCloseButtons.forEach((button) => { button.disabled = false; });
+ if (!lightboxClosed && !batchOverlay.hidden) renderBatchSelection();
+ }
+ };
+ const exploreAdjacentItem = async (direction) => {
+ const backward = direction < 0;
+ if (!direction || exploringPrevious || exploringNext || lightboxClosed ||
+ (backward ? backwardExhausted : forwardExhausted)) return;
+ if (backward) exploringPrevious = true;
+ else exploringNext = true;
+ countEl.textContent = `${currentIndex + 1} / ${items.length} · 正在寻找${backward ? '上一张' : '下一张'}…`;
+ updateNavigationState();
+ let scanStalled = false;
+ try {
+ const adjacentIndex = currentIndex + direction;
+ if (adjacentIndex >= 0 && adjacentIndex < items.length && adjacentItemIsScanned(direction)) {
+ showItem(adjacentIndex);
+ return;
+ }
+ const scanPostNumber = backward ? backwardScanPostNumber : forwardScanPostNumber;
+ const posts = await (backward
+ ? ctx.loader.loadBeforePost(scanPostNumber, {
+ priority: POST_REQUEST_PRIORITY.visible,
+ persistFetchedPosts: true,
+ })
+ : ctx.loader.loadAfterPost(scanPostNumber, {
+ priority: POST_REQUEST_PRIORITY.visible,
+ persistFetchedPosts: true,
+ }));
+ const scannedPosts = (Array.isArray(posts) ? posts : [])
+ .filter((post) => backward
+ ? +post.post_number < scanPostNumber
+ : +post.post_number > scanPostNumber)
+ .sort((left, right) => +left.post_number - +right.post_number);
+ if (!scannedPosts.length) {
+ if (backward) {
+ if (backwardScanPostNumber <= 1) backwardExhausted = true;
+ else scanStalled = true;
+ } else {
+ const highestPostNumber = totalPostNumber();
+ if (!highestPostNumber || forwardScanPostNumber >= highestPostNumber) forwardExhausted = true;
+ else scanStalled = true;
+ }
+ } else {
+ const nextScanPostNumber = backward
+ ? Math.min(...scannedPosts.map((post) => +post.post_number || scanPostNumber))
+ : Math.max(...scannedPosts.map((post) => +post.post_number || scanPostNumber));
+ if ((backward && nextScanPostNumber >= backwardScanPostNumber) ||
+ (!backward && nextScanPostNumber <= forwardScanPostNumber)) {
+ scanStalled = true;
+ } else {
+ if (backward) backwardScanPostNumber = nextScanPostNumber;
+ else forwardScanPostNumber = nextScanPostNumber;
+ if (lightboxClosed || !lb.isConnected) return;
+ const highestPostNumber = totalPostNumber();
+ if (backwardScanPostNumber <= 1) backwardExhausted = true;
+ if (highestPostNumber && forwardScanPostNumber >= highestPostNumber) forwardExhausted = true;
+ mergeScannedLightboxItems(scannedPosts);
+ const discoveredIndex = currentIndex + direction;
+ if (discoveredIndex >= 0 && discoveredIndex < items.length && adjacentItemIsScanned(direction)) {
+ showItem(discoveredIndex);
+ return;
+ }
+ }
+ }
+ if (!lightboxClosed) {
+ countEl.textContent = scanStalled
+ ? `${currentIndex + 1} / ${items.length} · ${backward ? '前面' : '后续'}楼层暂未加载,请重试`
+ : `${currentIndex + 1} / ${items.length} · 本批没有图片,再翻一次继续查找`;
+ }
+ } catch {
+ if (!lightboxClosed) countEl.textContent = `查找${backward ? '上一张' : '下一张'}失败,请重试`;
+ } finally {
+ if (backward) exploringPrevious = false;
+ else exploringNext = false;
+ if (!lightboxClosed) updateNavigationState();
+ }
+ };
+ const move = (direction) => {
+ const nextIndex = currentIndex + direction;
+ if (nextIndex >= 0 && nextIndex < items.length && adjacentItemIsScanned(direction)) showItem(nextIndex);
+ else void exploreAdjacentItem(direction);
+ };
+ const downloadCurrent = async () => {
+ const item = items[currentIndex];
+ const button = lb.querySelector('[data-lb-action="download"]');
+ if (!item || button.disabled) return;
+ button.disabled = true;
+ button.setAttribute('aria-busy', 'true');
+ setLabel(button, '正在准备下载');
+ try {
+ await downloadLightboxItem(item, currentIndex);
+ } finally {
+ button.disabled = false;
+ button.removeAttribute('aria-busy');
+ setLabel(button, '下载当前图片');
+ }
+ };
+ const downloadBatch = async () => {
+ if (batchBusy || !batchSelection.size) return;
+ const selectedItems = batchScopeItems().filter((item) => batchSelection.has(item.key));
+ if (!selectedItems.length) return;
+ batchBusy = true;
+ const missingOriginals = await lightboxMissingOriginalCount(selectedItems);
+ const useOriginal = missingOriginals > 0 && globalThis.confirm(
+ `${missingOriginals} 张原图尚未缓存。\n\n确定:按阅读器限速逐张获取原图\n取消:使用每张图片当前最高缓存/预览质量`
+ );
+ batchStatus.textContent = useOriginal
+ ? '按阅读器请求速率逐张获取原图'
+ : '使用每张图片当前最高缓存/预览质量';
+ batchCloseButtons.forEach((button) => { button.disabled = true; });
+ batchNameInput.disabled = true;
+ batchSelectAll.disabled = true;
+ batchGrid.querySelectorAll('input').forEach((input) => { input.disabled = true; });
+ syncBatchSelectionControls();
+ const entries = [];
+ const failures = [];
+ try {
+ for (let index = 0; index < selectedItems.length; index++) {
+ const item = selectedItems[index];
+ setBatchProgress(index, selectedItems.length, `正在获取第 ${index + 1} / ${selectedItems.length} 张`);
+ try {
+ const blob = await lightboxImageDownloadBlob(item, { original: useOriginal });
+ entries.push({
+ name: lightboxImageDownloadName(item, index, blob),
+ bytes: new Uint8Array(await blob.arrayBuffer()),
+ });
+ } catch (error) {
+ failures.push({ item, error });
+ }
+ setBatchProgress(index + 1, selectedItems.length, `已处理 ${index + 1} / ${selectedItems.length} 张`);
+ }
+ if (!entries.length) throw new Error('所选图片均下载失败');
+ setBatchProgress(selectedItems.length, selectedItems.length, '正在生成 ZIP 文件…');
+ const zip = createStoredZip(entries);
+ const archiveName = String(batchNameInput.value || batchNameInput.placeholder || '')
+ .trim().replace(/\.zip$/i, '');
+ triggerBrowserDownload(
+ zip,
+ `${safeDownloadFilename(archiveName, `帖子图片_${lightboxDownloadTimestamp()}`)}.zip`
+ );
+ batchStatus.textContent = failures.length
+ ? `已打包 ${entries.length} 张,${failures.length} 张获取失败`
+ : `已打包 ${entries.length} 张图片`;
+ } catch (error) {
+ batchStatus.textContent = `打包失败:${error.message || '请重试'}`;
+ } finally {
+ batchBusy = false;
+ batchCloseButtons.forEach((button) => { button.disabled = false; });
+ batchNameInput.disabled = false;
+ batchSelectAll.disabled = false;
+ batchGrid.querySelectorAll('input').forEach((input) => { input.disabled = false; });
+ syncBatchSelectionControls();
+ }
+ };
+ let batchPreviewRequestToken = 0;
+ const ensureBatchPreviewSource = async (item, itemIndex) => {
+ const existing = batchThumbnailUrls.get(item.key);
+ if (existing) return existing;
+ const itemNode = batchGrid.querySelector(`[data-lb-batch-index="${itemIndex}"]`);
+ const thumbnailImage = itemNode?.querySelector('img');
+ const displayed = thumbnailImage && (thumbnailImage.currentSrc || thumbnailImage.src);
+ if (displayed) return displayed;
+ const persistentKey = batchThumbnailPersistentKey(item);
+ const cached = persistentKey && await cachedPersistentLightboxBlob(persistentKey);
+ if (cached) {
+ const source = URL.createObjectURL(cached);
+ batchThumbnailUrls.set(item.key, source);
+ return source;
+ }
+ return new Promise((resolve, reject) => {
+ enqueueBatchThumbnail(async () => {
+ try {
+ const source = await createBatchThumbnail(item);
+ if (lightboxClosed) {
+ URL.revokeObjectURL(source);
+ reject(new Error('图片预览已关闭'));
+ return;
+ }
+ const previous = batchThumbnailUrls.get(item.key);
+ if (previous) URL.revokeObjectURL(source);
+ else batchThumbnailUrls.set(item.key, source);
+ resolve(previous || source);
+ } catch (error) {
+ reject(error);
+ }
+ });
+ });
+ };
+ const setBatchItemSelected = (item, itemIndex, selected, itemNode = null) => {
+ if (selected) batchSelection.add(item.key);
+ else batchSelection.delete(item.key);
+ const card = itemNode || batchGrid.querySelector(`[data-lb-batch-index="${itemIndex}"]`);
+ const input = card?.querySelector('input[type="checkbox"]');
+ if (input) input.checked = selected;
+ if (card) card.classList.toggle('selected', selected);
+ syncBatchSelectionControls();
+ };
+ const openBatchItemPreview = async (itemIndex, anchorCard) => {
+ if (batchBusy || lightboxClosed || batchOverlay.hidden) return;
+ const item = items[itemIndex];
+ const scopedItems = batchScopeItems();
+ const scopedIndex = item ? scopedItems.findIndex((candidate) => candidate.key === item.key) : -1;
+ if (!item || scopedIndex < 0) return;
+ const requestToken = ++batchPreviewRequestToken;
+ try {
+ const previewSource = await ensureBatchPreviewSource(item, itemIndex);
+ if (requestToken !== batchPreviewRequestToken || lightboxClosed || batchOverlay.hidden) return;
+ openUserAvatarViewer(item.key, '', anchorCard, previewSource, null, null, {
+ kind: 'image',
+ reuseImageViewer: true,
+ source: item.originalSrc,
+ loadOriginal: true,
+ selectable: true,
+ selected: batchSelection.has(item.key),
+ selectionLabel: `${scopedIndex + 1} / ${scopedItems.length} · #${item.postNumber || '?'}`,
+ previousDisabled: scopedIndex === 0,
+ nextDisabled: scopedIndex === scopedItems.length - 1,
+ onPrevious: () => {
+ const previous = scopedItems[scopedIndex - 1];
+ if (previous) void openBatchItemPreview(items.indexOf(previous), anchorCard);
+ },
+ onNext: () => {
+ const next = scopedItems[scopedIndex + 1];
+ if (next) void openBatchItemPreview(items.indexOf(next), anchorCard);
+ },
+ onSelectionChange: (selected) => setBatchItemSelected(item, itemIndex, selected),
+ onDownload: () => downloadLightboxItem(item, itemIndex),
+ });
+ } catch {
+ if (requestToken === batchPreviewRequestToken) showSelectionToast('缩略图加载失败,请重试');
+ }
+ };
+ const jumpToCurrentPost = async () => {
+ const currentItem = items[currentIndex];
+ const postNumber = +(currentItem?.postNumber) || 0;
+ const topicId = +(currentItem?.topicId) || +(ctx?.topicId) || 0;
+ if (!postNumber || jumpingToPost || lightboxClosed) return;
+ if (topicId && ctx && topicId !== +ctx.topicId) {
+ close();
+ openModal(topicId, { targetPostNumber: postNumber });
+ return;
+ }
+ jumpingToPost = true;
+ updateJumpToPostState();
+ try {
+ const jumped = !!(ctx?.timeline) && await ctx.timeline.jumpTo(postNumber, {
+ acceptsWork: () => !lightboxClosed,
+ });
+ if (lightboxClosed) return;
+ if (!jumped) {
+ countEl.textContent = `无法定位到第 #${postNumber} 楼,请重试`;
+ return;
+ }
+ close();
+ } catch {
+ if (!lightboxClosed) countEl.textContent = `无法定位到第 #${postNumber} 楼,请重试`;
+ } finally {
+ jumpingToPost = false;
+ if (!lightboxClosed) updateJumpToPostState();
+ }
+ };
+ const close = () => {
+ lightboxClosed = true;
+ mainImageRequestToken++;
+ commentRequestToken++;
+ clearLightboxReactionHoverTimers();
+ clearTimeout(descriptionHeightSaveTimer);
+ if (descriptionResizeObserver) descriptionResizeObserver.disconnect();
+ if (batchThumbnailObserver) batchThumbnailObserver.disconnect();
+ batchThumbnailQueue.length = 0;
+ batchThumbnailUrls.forEach((source) => URL.revokeObjectURL(source));
+ batchThumbnailUrls.clear();
+ if (commentsResizeFrame) cancelAnimationFrame(commentsResizeFrame);
+ commentsResizeFrame = 0;
+ commentsResize = null;
+ imageTransform.destroy();
+ lb.remove();
+ lightboxScope.destroy();
+ };
+ let descriptionHeightSaveTimer = 0;
+ const descriptionResizeObserver = isFunction(ResizeObserver)
+ ? new ResizeObserver(() => {
+ if (!commentsSource.open || commentsSource.hidden || lightboxClosed) return;
+ clearTimeout(descriptionHeightSaveTimer);
+ descriptionHeightSaveTimer = setTimeout(() => {
+ const height = normalizeLightboxDescriptionHeight(commentsSourceText.getBoundingClientRect().height);
+ if (height === normalizeLightboxDescriptionHeight(PREFS.lightboxDescriptionHeight)) return;
+ setPref('lightboxDescriptionHeight', height);
+ lb.style.setProperty('--ldp-lb-description-height', `${height}px`);
+ }, 160);
+ })
+ : null;
+ if (descriptionResizeObserver) descriptionResizeObserver.observe(commentsSourceText);
+ commentsSource.addEventListener('toggle', () => {
+ setExpanded(commentsDescriptionToggle, commentsSource.open);
+ setLabel(commentsDescriptionToggle, commentsSource.open ? '收纳图片描述' : '展开图片描述');
+ setPref('lightboxDescriptionExpanded', commentsSource.open);
+ });
+ setExpanded(commentsDescriptionToggle, commentsSource.open);
+ setLabel(commentsDescriptionToggle, commentsSource.open ? '收纳图片描述' : '展开图片描述');
+ commentsDescriptionToggle.addEventListener('click', () => {
+ commentsSource.open = !commentsSource.open;
+ });
+ const renderCommentsResize = () => {
+ commentsResizeFrame = 0;
+ if (!commentsResize) return;
+ const { mainRect, clientX } = commentsResize;
+ const minimum = Math.min(
+ LIGHTBOX_COMMENTS_WIDTH_MAX,
+ Math.max(LIGHTBOX_COMMENTS_WIDTH_MIN, 240 / mainRect.width * 100)
+ );
+ applyCommentsWidth(Math.min(
+ LIGHTBOX_COMMENTS_WIDTH_MAX,
+ Math.max(minimum, (mainRect.right - clientX) / mainRect.width * 100)
+ ));
+ };
+ commentsResizer.addEventListener('pointerdown', (event) => {
+ if (event.button !== 0 || lb.classList.contains('ldp-lb-comments-collapsed')) return;
+ const mainRect = lb.querySelector('.ldp-lb-main').getBoundingClientRect();
+ if (!mainRect.width) return;
+ commentsResize = {
+ pointerId: event.pointerId,
+ mainRect,
+ clientX: event.clientX,
+ };
+ commentsResizer.setPointerCapture(event.pointerId);
+ lb.classList.add('is-resizing-comments');
+ event.preventDefault();
+ });
+ commentsResizer.addEventListener('pointermove', (event) => {
+ if (commentsResize?.pointerId !== event.pointerId) return;
+ commentsResize.clientX = event.clientX;
+ if (!commentsResizeFrame) commentsResizeFrame = requestAnimationFrame(renderCommentsResize);
+ });
+ const finishCommentsResize = (event) => {
+ if (commentsResize?.pointerId !== event.pointerId) return;
+ commentsResize.clientX = event.clientX;
+ if (commentsResizeFrame) cancelAnimationFrame(commentsResizeFrame);
+ commentsResizeFrame = 0;
+ renderCommentsResize();
+ commentsResize = null;
+ lb.classList.remove('is-resizing-comments');
+ applyCommentsWidth(commentsWidthPercent, true);
+ };
+ ['pointerup', 'pointercancel'].forEach((type) =>
+ commentsResizer.addEventListener(type, finishCommentsResize));
+ commentsResizer.addEventListener('keydown', (event) => {
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
+ event.preventDefault();
+ applyCommentsWidth(commentsWidthPercent + (event.key === 'ArrowLeft' ? 2 : -2), true);
+ });
+ function onKey(e) {
+ if (!lb.isConnected) return;
+ const imageViewer = readerPortalQuery('.ldp-avatar-viewer');
+ if (imageViewer?.isConnected) return;
+ if (e.key === 'Escape') {
+ if (!batchOverlay.hidden) {
+ if (!batchBusy) closeBatchDownload();
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ return;
+ }
+ if (visibleNativeFloatingSurface()) return;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ close();
+ return;
+ }
+ if (!batchOverlay.hidden) return;
+ if (e.target?.closest && e.target.closest('textarea,input,select,[contenteditable="true"]')) return;
+ if (e.key === 'ArrowLeft') {
+ e.preventDefault();
+ move(-1);
+ } else if (e.key === 'ArrowRight') {
+ e.preventDefault();
+ move(1);
+ } else handleImageTransformShortcut(e, imageTransform, resetView);
+ }
+ lb.querySelector('.ldp-lb-toolbar').addEventListener('click', (e) => {
+ const button = e.target.closest('[data-lb-action]');
+ if (!button) return;
+ const action = button.dataset.lbAction;
+ if (action === 'close') close();
+ else if (action === 'zoom-out') setZoom(imageTransform.scale / 1.2);
+ else if (action === 'zoom-in') setZoom(imageTransform.scale * 1.2);
+ else if (action === 'reset') resetView();
+ else if (action === 'view-original') loadCurrentOriginal();
+ else if (action === 'jump-to-post') void jumpToCurrentPost();
+ else if (action === 'download') void downloadCurrent();
+ else if (action === 'batch-download') openBatchDownload();
+ else if (action === 'toggle-comments')
+ setCommentsExpanded(lb.classList.contains('ldp-lb-comments-collapsed'), true);
+ });
+ batchOverlay.addEventListener('click', (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ if (target?.closest('.ldp-lb-batch-close,.ldp-lb-batch-cancel')) {
+ closeBatchDownload();
+ return;
+ }
+ const scopeButton = target?.closest('[data-lb-batch-scope]');
+ if (scopeButton) {
+ if (batchBusy || scopeButton.disabled) return;
+ const nextScope = scopeButton.dataset.lbBatchScope === 'all' ? 'all' : 'loaded';
+ batchScope = nextScope;
+ batchSelection.clear();
+ batchProgress.hidden = true;
+ batchStatus.textContent = nextScope === 'all'
+ ? batchAllImagesLoaded ? `已扫描全部帖子,共找到 ${items.length} 张图片` : '准备扫描全部帖子…'
+ : '请选择当前已加载的图片';
+ if (nextScope === 'all' && !batchAllImagesLoaded) {
+ void scanAllTopicImages();
+ return;
+ }
+ renderBatchSelection();
+ return;
+ }
+ if (target?.closest('.ldp-lb-batch-select-all')) {
+ if (batchBusy) return;
+ const scopedItems = batchScopeItems();
+ const allSelected = scopedItems.length > 0 && scopedItems.every((item) => batchSelection.has(item.key));
+ batchSelection.clear();
+ if (!allSelected) scopedItems.forEach((item) => batchSelection.add(item.key));
+ renderBatchSelection();
+ } else if (target?.closest('.ldp-lb-batch-download'))
+ void downloadBatch();
+ });
+ batchOverlay.addEventListener('pointerdown', (event) => {
+ if (event.target === batchOverlay) closeBatchDownload();
+ });
+ batchGrid.addEventListener('wheel', (event) => {
+ if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
+ const delta = event.deltaMode === 1
+ ? event.deltaY * 40
+ : event.deltaMode === 2 ? event.deltaY * batchGrid.clientHeight : event.deltaY;
+ const precisionInput = event.deltaMode === 0 && Math.abs(event.deltaY) < 50;
+ const maximum = Math.max(0, batchGrid.scrollHeight - batchGrid.clientHeight);
+ const nextScrollTop = Math.max(0, Math.min(maximum,
+ batchGrid.scrollTop + delta * (precisionInput ? 1.15 : 1.7)));
+ if (nextScrollTop === batchGrid.scrollTop) return;
+ consumeEvent(event);
+ batchGrid.scrollTop = nextScrollTop;
+ }, { passive: false });
+ batchGrid.addEventListener('change', (event) => {
+ const input = event.target.closest('input[type="checkbox"]');
+ const itemNode = input?.closest('[data-lb-batch-index]');
+ const itemIndex = itemNode && +itemNode.dataset.lbBatchIndex;
+ const item = itemNode && items[itemIndex];
+ if (!item || batchBusy) return;
+ setBatchItemSelected(item, itemIndex, input.checked, itemNode);
+ });
+ batchGrid.addEventListener('click', (event) => {
+ const loadMoreButton = event.target.closest('.ldp-lb-batch-more');
+ if (loadMoreButton) {
+ event.preventDefault();
+ void loadMoreBatchImages();
+ return;
+ }
+ const image = event.target.closest('.ldp-lb-batch-item img');
+ const itemNode = image?.closest('[data-lb-batch-index]');
+ const itemIndex = itemNode ? +itemNode.dataset.lbBatchIndex : -1;
+ const item = itemIndex >= 0 ? items[itemIndex] : null;
+ if (!item || batchBusy) return;
+ consumeEvent(event);
+ void openBatchItemPreview(itemIndex, itemNode);
+ });
+ lb.addEventListener('click', (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ const thumb = target?.closest('.ldp-lb-thumb');
+ if (thumb) showItem(+thumb.dataset.lbIndex);
+ else if (target?.closest('.ldp-lb-prev')) move(-1);
+ else if (target?.closest('.ldp-lb-next')) move(1);
+ else if (target?.closest('.ldp-lb-retry')) loadCurrentOriginal(true);
+ else if (target?.closest('.ldp-lb-comment-cancel')) closeCommentForm();
+ });
+ stage.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ const nextScale = imageTransform.scale * (e.deltaY < 0 ? 1.15 : 1 / 1.15);
+ if (e.target === mainImg) setZoom(nextScale, e.clientX, e.clientY);
+ else setZoom(nextScale);
+ }, { passive: false });
+ stage.addEventListener('dblclick', (e) => {
+ if (e.target !== mainImg) return;
+ const nativeScale = mainImg.clientWidth ? Math.min(8, mainImg.naturalWidth / mainImg.clientWidth) : 1;
+ setZoom(imageTransform.scale > 1.05 ? 1 : Math.max(2, nativeScale));
+ });
+ lb.querySelector('.ldp-lb-comments').addEventListener('click', onCommentsClick);
+ lb.querySelector('.ldp-lb-comments').addEventListener('keydown', onCommentsKeydown);
+ lightboxScope.listen(commentsPanel, 'pointerover', onCommentsReactionPointerOver);
+ lightboxScope.listen(commentsPanel, 'pointerout', onCommentsReactionPointerOut);
+ commentForm.addEventListener('submit', onCommentFormSubmit);
+ lightboxScope.listen(document, 'keydown', onKey);
+ lightboxScope.listen(window, 'resize', renderTransform);
+ syncThumbs();
+ appendReaderPortalNode(lb);
+ showItem(initialIndex);
+ }
+
+ function resolveOriginalSrc(imgEl) {
+ const a = imgEl.closest('a.lightbox, a[href]');
+ if (a?.getAttribute('href')) {
+ const href = a.getAttribute('href');
+ if (/\.(png|jpe?g|gif|webp|bmp|avif)(\?|#|$)/i.test(href) || a.classList.contains('lightbox'))
+ return href;
+ }
+ return imgEl.getAttribute('data-large-src') || imgEl.currentSrc || imgEl.src;
+ }
+
+ /* ============ 4. 已读追踪器 ============ */
+ function readerReadState(topicId) {
+ const key = String(topicId);
+ let state = READER_READ_STATES.get(key);
+ if (!state) {
+ state = { confirmed: new Set(), request: null };
+ READER_READ_STATES.set(key, state);
+ }
+ return state;
+ }
+
+ async function sendReaderReadTimings(topicId, postNumbers, priority = POST_REQUEST_PRIORITY.visible) {
+ const requested = [...new Set(Array.from(postNumbers || []).map(Number).filter(Boolean))];
+ if (!requested.length) return [];
+ const state = readerReadState(topicId);
+ const alreadyConfirmed = requested.filter((postNumber) => state.confirmed.has(postNumber));
+ if (alreadyConfirmed.length) return alreadyConfirmed;
+ const submitted = requested.slice(0, READER_READ_BATCH_SIZE);
+ if (state.request) {
+ await state.request;
+ const confirmed = requested.filter((postNumber) => state.confirmed.has(postNumber));
+ if (confirmed.length) return confirmed;
+ return sendReaderReadTimings(topicId, requested, priority);
+ }
+ const send = async () => {
+ const fingerprint = readerReadRequestFingerprint(topicId, submitted);
+ if (readerReadRequestRecentlySucceeded(fingerprint)) return submitted;
+ const params = {};
+ submitted.forEach((postNumber) => { params[`timings[${postNumber}]`] = READ_THRESHOLD; });
+ params.topic_time = READ_THRESHOLD * submitted.length;
+ params.topic_id = topicId;
+ const postTimings = () => apiSend(`${BASE}/topics/timings`, 'POST', params, {
+ 'Accept': '*/*',
+ 'Discourse-Background': 'true',
+ 'Discourse-Logged-In': 'true',
+ 'Discourse-Present': 'true',
+ 'X-SILENCE-LOGGER': 'true',
+ }, { priority });
+ await postTimings();
+ rememberReaderReadRequestSuccess(fingerprint);
+ return submitted;
+ };
+ const request = isFunction(navigator.locks?.request)
+ ? navigator.locks.request(LDP_READER_READ_LOCK_NAME, send)
+ : send();
+ state.request = request;
+ try {
+ await request;
+ submitted.forEach((postNumber) => state.confirmed.add(postNumber));
+ return submitted;
+ } finally {
+ if (state.request === request) state.request = null;
+ }
+ }
+
+ function createReadTracker(topicId, scrollRoot, onReadTracked, onReadPending) {
+ const pendingReadPostNumbers = new Set();
+ const priorityReadPostNumbers = new Set();
+ const readState = readerReadState(topicId);
+ const observedNodes = new Set();
+ const viewportNodes = new Set();
+ const pendingVisibleCallbacks = new Map();
+ let readFlushTimer = null;
+ let started = false, flushRunning = false;
+ let readRetryAt = 0, readRetryCount = 0;
+
+ const syncLocalReadState = (postNumbers) => {
+ if (!isFunction(onReadTracked)) return;
+ try {
+ onReadTracked(postNumbers);
+ } catch (error) {
+ console.warn('[LDP] sync local post read state failed', error);
+ }
+ };
+ const syncLocalPendingState = (postNumbers) => {
+ if (!isFunction(onReadPending)) return;
+ try {
+ onReadPending(postNumbers);
+ } catch (error) {
+ console.warn('[LDP] sync optimistic post read state failed', error);
+ }
+ };
+
+ const io = new IntersectionObserver((entries) => {
+ entries.forEach((en) => {
+ const pn = +en.target.dataset.postNumber;
+ if (!pn) return;
+ const intersection = en.intersectionRect;
+ const inViewport = en.isIntersecting &&
+ intersection.width > 0 && intersection.height > 0;
+ if (inViewport) viewportNodes.add(en.target);
+ else viewportNodes.delete(en.target);
+ if (inViewport && started && !flushRunning && pendingReadPostNumbers.has(pn)) {
+ clearReadFlushTimer();
+ scheduleReadFlush(0);
+ }
+ const onVisible = inViewport && pendingVisibleCallbacks.get(en.target);
+ if (onVisible) {
+ pendingVisibleCallbacks.delete(en.target);
+ onVisible();
+ if (!observedNodes.has(en.target)) {
+ viewportNodes.delete(en.target);
+ io.unobserve(en.target);
+ }
+ }
+ });
+ }, { root: scrollRoot, threshold: 0 });
+
+ const clearReadFlushTimer = () => {
+ if (readFlushTimer) clearTimeout(readFlushTimer);
+ readFlushTimer = null;
+ };
+
+ const scheduleReadFlush = (delay = 0) => {
+ if (readFlushTimer || !started || readRetryCount > 1 || !pendingReadPostNumbers.size ||
+ document.visibilityState !== 'visible') return;
+ const wait = Math.max(0, Number(delay) || 0, readRetryAt - Date.now());
+ readFlushTimer = setTimeout(() => {
+ readFlushTimer = null;
+ void flush();
+ }, wait);
+ };
+
+ const flush = async (options = {}) => {
+ if (flushRunning || !pendingReadPostNumbers.size) return false;
+ if (options.force !== true && readRetryAt > Date.now()) {
+ scheduleReadFlush(readRetryAt - Date.now());
+ return false;
+ }
+ clearReadFlushTimer();
+ const visiblePostPriorities = [...viewportNodes].reduce((priorities, node) => {
+ const postNumber = +(node?.dataset?.postNumber || 0);
+ if (postNumber) priorities.set(postNumber, Math.max(
+ priorities.get(postNumber) || 0,
+ node.parentElement?.classList.contains('ldp-children') ? 2 : 1
+ ));
+ return priorities;
+ }, new Map());
+ priorityReadPostNumbers.forEach((postNumber) => {
+ if (pendingReadPostNumbers.has(postNumber))
+ visiblePostPriorities.set(postNumber, 3);
+ });
+ const submitted = [...pendingReadPostNumbers]
+ .sort((a, b) => (visiblePostPriorities.get(b) || 0) -
+ (visiblePostPriorities.get(a) || 0))
+ .slice(0, READER_READ_BATCH_SIZE);
+ const hasVisiblePost = submitted.some((postNumber) => visiblePostPriorities.has(postNumber));
+ const priority = Number.isFinite(options.priority)
+ ? options.priority
+ : hasVisiblePost ? POST_REQUEST_PRIORITY.visible : POST_REQUEST_PRIORITY.background;
+ flushRunning = true;
+ try {
+ const confirmed = await sendReaderReadTimings(topicId, submitted, priority);
+ confirmed.forEach((postNumber) => {
+ pendingReadPostNumbers.delete(postNumber);
+ priorityReadPostNumbers.delete(postNumber);
+ });
+ readRetryAt = 0;
+ readRetryCount = 0;
+ syncLocalReadState(confirmed);
+ return true;
+ } catch (error) {
+ if (error?.cloudflareMitigated) {
+ readRetryCount = 2;
+ readRetryAt = 0;
+ } else {
+ readRetryCount++;
+ readRetryAt = Date.now() + FLUSH_INTERVAL;
+ }
+ return false;
+ } finally {
+ flushRunning = false;
+ if (started && pendingReadPostNumbers.size) scheduleReadFlush();
+ }
+ };
+
+ const hasVisiblePending = () => [...viewportNodes].some((node) =>
+ pendingReadPostNumbers.has(+(node?.dataset?.postNumber || 0))) ||
+ [...priorityReadPostNumbers].some((postNumber) => pendingReadPostNumbers.has(postNumber));
+ const onVisibilityChange = () => {
+ if (!started) return;
+ if (document.visibilityState === 'visible') {
+ if (pendingReadPostNumbers.size) scheduleReadFlush(0);
+ return;
+ }
+ clearReadFlushTimer();
+ };
+
+ return {
+ preload(posts) {
+ const wasEmpty = !pendingReadPostNumbers.size;
+ const locallyConfirmed = [];
+ const newlyPending = [];
+ (Array.isArray(posts) ? posts : [posts]).forEach((post) => {
+ const postNumber = +(post?.post_number || post || 0);
+ if (!postNumber || post?.read === true || pendingReadPostNumbers.has(postNumber)) return;
+ if (readState.confirmed.has(postNumber)) {
+ locallyConfirmed.push(postNumber);
+ return;
+ }
+ pendingReadPostNumbers.add(postNumber);
+ newlyPending.push(postNumber);
+ });
+ if (locallyConfirmed.length) syncLocalReadState(locallyConfirmed);
+ if (newlyPending.length) {
+ syncLocalPendingState(newlyPending);
+ if (wasEmpty) { readRetryAt = 0; readRetryCount = 0; }
+ scheduleReadFlush(0);
+ }
+ },
+ confirm(postNumbers) {
+ Array.from(postNumbers || []).map(Number).filter(Boolean).forEach((postNumber) => {
+ pendingReadPostNumbers.delete(postNumber);
+ priorityReadPostNumbers.delete(postNumber);
+ readState.confirmed.add(postNumber);
+ });
+ if (!pendingReadPostNumbers.size) {
+ readRetryAt = 0;
+ readRetryCount = 0;
+ clearReadFlushTimer();
+ }
+ },
+ setVisible(postNumbers, visible = true) {
+ Array.from(postNumbers || []).map(Number).filter(Boolean).forEach((postNumber) => {
+ if (visible && pendingReadPostNumbers.has(postNumber)) priorityReadPostNumbers.add(postNumber);
+ else priorityReadPostNumbers.delete(postNumber);
+ });
+ if (visible && priorityReadPostNumbers.size && started && !flushRunning) {
+ clearReadFlushTimer();
+ scheduleReadFlush(0);
+ }
+ },
+ hasVisiblePending() {
+ return hasVisiblePending();
+ },
+ observe(node) {
+ if (!node) return;
+ observedNodes.add(node);
+ io.observe(node);
+ },
+ unobserve(node) {
+ if (!node) return;
+ observedNodes.delete(node);
+ viewportNodes.delete(node);
+ io.unobserve(node);
+ },
+ runWhenVisible(node, callback) {
+ if (!node || !isFunction(callback)) return false;
+ if (viewportNodes.has(node) || readerPostNodeInViewport(node, scrollRoot)) {
+ callback();
+ return true;
+ }
+ pendingVisibleCallbacks.set(node, callback);
+ io.observe(node);
+ return false;
+ },
+ start() {
+ if (started) return;
+ started = true;
+ document.addEventListener('visibilitychange', onVisibilityChange);
+ if (pendingReadPostNumbers.size) scheduleReadFlush(0);
+ },
+ stop() {
+ if (!started) return;
+ started = false;
+ clearReadFlushTimer();
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ io.disconnect();
+ observedNodes.clear();
+ viewportNodes.clear();
+ pendingVisibleCallbacks.clear();
+ priorityReadPostNumbers.clear();
+ },
+ };
+ }
+
+ /* ============ 5. 分块加载(init 带 Discourse 浏览追踪头 + 重试保护) ============ */
+ function createLoader(topicId, requestScheduler, runtimeConfig) {
+ const pageSize = runtimeConfig.pageSize;
+ const onlyOpBatchFloor = Math.min(
+ PERFORMANCE_LIMITS.pageSize.max,
+ Math.max(pageSize, runtimeConfig.requestMaxConcurrent <= 1 ? 32 : 40)
+ );
+ let onlyOpPageSize = onlyOpBatchFloor;
+ let stream = [];
+ const cache = new Map();
+ const postByNumber = new Map();
+ const unavailablePostNumbers = new Set();
+ const pendingPostRequests = new Map();
+ let streamIndexById = new Map();
+ const requestController = new AbortController();
+ let cursor = 0;
+ let onlyOpCursor = 0;
+ let onlyOpSkippedCount = 0;
+ let topic = null;
+ let failStreak = 0;
+ let onlyOpFailStreak = 0;
+ let refreshRequest = null;
+ let initializedFromCache = false;
+ let stopTopicCacheSync = () => {};
+
+ const growOnlyOpBatch = () => {
+ let requestState = null;
+ try { requestState = requestScheduler?.snapshot(); } catch {}
+ const shortPressure = requestState
+ ? Number(requestState.shortWindowCount || 0) / Math.max(1, Number(requestState.shortWindowBudget) || 1)
+ : 0;
+ const longPressure = requestState
+ ? Number(requestState.longWindowCount || 0) / Math.max(1, Number(requestState.longWindowBudget) || 1)
+ : 0;
+ const pressured = requestState && (
+ requestState.coolingDown ||
+ ['10s', '60s'].includes(requestState.blockingReason) ||
+ Number(requestState.globalQueued || 0) > 0 ||
+ Math.max(shortPressure, longPressure) >= 0.7
+ );
+ onlyOpPageSize = Math.min(
+ PERFORMANCE_LIMITS.pageSize.max,
+ onlyOpPageSize + (pressured ? 12 : 8)
+ );
+ };
+
+ const adjustOnlyOpBatchAfterError = (error) => {
+ const status = Number(error?.status || 0);
+ if (status === 408 || status === 429 || status >= 500) {
+ onlyOpPageSize = PERFORMANCE_LIMITS.pageSize.max;
+ return;
+ }
+ onlyOpPageSize = Math.max(onlyOpBatchFloor, onlyOpPageSize - 8);
+ };
+
+ const fetchLoaderJSON = (url, options = {}) => fetchJSON(url, {
+ scheduler: requestScheduler,
+ signal: requestController.signal,
+ fetchOptions: options.fetchOptions,
+ cacheMode: options.cacheMode || 'default',
+ key: options.key || `post:${url}`,
+ priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible,
+ });
+
+ const cachePost = (post) => {
+ if (!post || !post.id) return;
+ const previous = cache.get(post.id);
+ const completePost = previous && previous !== post ? Object.assign(previous, post) : post;
+ cache.set(post.id, completePost);
+ const postNumber = +(completePost.post_number || 0);
+ if (postNumber) postByNumber.set(postNumber, completePost);
+ };
+
+ const cachePosts = (posts) => (posts || []).forEach(cachePost);
+
+ const appendMissingTopicPosts = (topicData, posts) => {
+ if (!topicData || !Array.isArray(posts) || !posts.length) return false;
+ if (!topicData.post_stream) topicData.post_stream = {};
+ const current = Array.isArray(topicData.post_stream.posts) ? topicData.post_stream.posts : [];
+ const indexesById = new Map();
+ current.forEach((post, index) => {
+ if (post?.id) indexesById.set(post.id, index);
+ });
+ let changed = false;
+ posts.forEach((post) => {
+ if (!post || !post.id) return;
+ const index = indexesById.get(post.id);
+ if (Number.isInteger(index)) {
+ const previous = current[index];
+ if (previous === post) return;
+ current[index] = previous ? Object.assign(previous, post) : post;
+ changed = true;
+ return;
+ }
+ indexesById.set(post.id, current.length);
+ current.push(post);
+ changed = true;
+ });
+ if (changed) topicData.post_stream.posts = current;
+ return changed;
+ };
+
+ const persistFetchedPosts = (posts) => {
+ if (!Array.isArray(posts) || !posts.length) return;
+ const entry = TOPIC_DATA_CACHE.get(String(topicId));
+ const persistedTopic = (entry?.topic) || topic;
+ if (!persistedTopic) return;
+ const changed = appendMissingTopicPosts(persistedTopic, posts);
+ if (topic && topic !== persistedTopic) appendMissingTopicPosts(topic, posts);
+ if (changed) setCachedTopicData(topicId, persistedTopic);
+ };
+
+ const persistViewedPostNumbers = (postNumbers) => {
+ const posts = [...new Set((postNumbers || []).map(Number).filter(Boolean))]
+ .map((postNumber) => postByNumber.get(postNumber))
+ .filter(Boolean);
+ persistFetchedPosts(posts);
+ };
+
+ function normalizeTopicData(data) {
+ const posts = Array.isArray(data.post_stream?.posts) ? data.post_stream.posts : [];
+ const op = (data.details?.created_by && data.details.created_by.username)
+ || (posts.find((p) => p.post_number === 1) || {}).username
+ || null;
+ data._opUsername = op;
+ return {
+ posts,
+ stream: (data.post_stream?.stream) || posts.map((p) => p.id).filter(Boolean),
+ };
+ }
+
+ function primeTopicData(data) {
+ const normalized = normalizeTopicData(data);
+ topic = data;
+ stream = normalized.stream;
+ streamIndexById = new Map(stream.map((id, index) => [id, index]));
+ cachePosts(normalized.posts);
+ return topic;
+ }
+
+ const syncTopicCacheData = (incomingTopic) => {
+ if (!incomingTopic) return;
+ const incoming = normalizeTopicData(incomingTopic);
+ cachePosts(incoming.posts);
+ if (incomingTopic === topic) return;
+ if (!topic) return;
+ appendMissingTopicPosts(topic, incoming.posts);
+ Object.keys(incomingTopic).forEach((key) => {
+ if (key !== 'post_stream') topic[key] = incomingTopic[key];
+ });
+ if (incoming.stream.length >= stream.length) {
+ stream = incoming.stream.slice();
+ streamIndexById = new Map(stream.map((id, index) => [id, index]));
+ }
+ };
+ stopTopicCacheSync = subscribeTopicCache(topicId, syncTopicCacheData);
+
+ async function refreshTopicData({ prime = true, priority } = {}) {
+ syncCurrentUserFromHost();
+ if (!refreshRequest) {
+ refreshRequest = (async () => {
+ const url = `${BASE}/t/${topicId}.json?track_visit=true&forceLoad=true`;
+ const data = await fetchJSON(url, {
+ scheduler: requestScheduler,
+ signal: requestController.signal,
+ cacheMode: 'refresh',
+ fetchOptions: discourseJSONFetchOptions({
+ 'Discourse-Track-View': 'true',
+ 'Discourse-Track-View-Topic-Id': String(topicId),
+ }),
+ key: `topic:${topicId}:refresh`,
+ priority: Number.isFinite(priority)
+ ? priority
+ : topic
+ ? POST_REQUEST_PRIORITY.background
+ : POST_REQUEST_PRIORITY.topic,
+ });
+ const entry = TOPIC_DATA_CACHE.get(String(topicId));
+ const previousTopic = (entry?.topic) || topic;
+ if (previousTopic?.post_stream) {
+ appendMissingTopicPosts(data, previousTopic.post_stream.posts);
+ }
+ normalizeTopicData(data);
+ setCachedTopicData(topicId, data, {
+ fetchedAt: responseCacheValueStoredAt(data) || Date.now(),
+ source: 'network',
+ });
+ return data;
+ })().finally(() => { refreshRequest = null; });
+ }
+ const data = await refreshRequest;
+ if (prime) return primeTopicData(data);
+ return data;
+ }
+
+ async function init() {
+ if (topic) return topic;
+ syncCurrentUserFromHost();
+ const previewTopic = getCachedTopicData(topicId);
+ const storedTopic = await getStoredTopicSnapshot(topicId);
+ const cachedTopic = storedTopic || previewTopic;
+ const cacheEntry = TOPIC_DATA_CACHE.get(String(topicId));
+ if (cachedTopic && cacheEntry) {
+ initializedFromCache = true;
+ primeTopicData(cachedTopic);
+ const expectedPostCount = Math.max(0, Number(topic.posts_count) || 0);
+ if (expectedPostCount > stream.length) {
+ initializedFromCache = false;
+ return refreshTopicData();
+ }
+ const fetchedAt = topicCacheEntryTimestamp(cacheEntry);
+ if (!fetchedAt || Date.now() - fetchedAt > TOPIC_CACHE_TTL) {
+ refreshTopicData({ prime: false, priority: POST_REQUEST_PRIORITY.background }).catch(() => {});
+ }
+ return topic;
+ }
+ initializedFromCache = false;
+ return refreshTopicData();
+ }
+
+ async function next(options = {}) {
+ const onlyOpScan = options.onlyOp === true;
+ const position = onlyOpScan ? onlyOpCursor : cursor;
+ if (position >= stream.length) return { posts: [], done: true };
+ const batchSize = onlyOpScan ? onlyOpPageSize : pageSize;
+ const slice = stream.slice(position, position + batchSize);
+ const resetFailures = () => {
+ if (onlyOpScan) onlyOpFailStreak = 0;
+ else failStreak = 0;
+ };
+ const bumpFailures = () => onlyOpScan ? ++onlyOpFailStreak : ++failStreak;
+ const advance = (count, skipped = 0) => {
+ if (onlyOpScan) {
+ onlyOpCursor += count;
+ onlyOpSkippedCount += skipped;
+ return onlyOpCursor >= stream.length;
+ }
+ cursor += count;
+ return cursor >= stream.length;
+ };
+ const prepareOnlyOpPosts = (posts) => {
+ if (!onlyOpScan) return posts;
+ const opUsername = String(options.onlyOpUsername || '');
+ if (!opUsername) return posts;
+ const matches = [];
+ posts.forEach((post) => {
+ if (String(post?.username || '') === opUsername) {
+ matches.push(post);
+ return;
+ }
+ if (post?.id && cache.get(post.id) === post) cache.delete(post.id);
+ const postNumber = +(post?.post_number || 0);
+ if (postNumber && postByNumber.get(postNumber) === post) postByNumber.delete(postNumber);
+ });
+ if (matches.length) persistFetchedPosts(matches);
+ return matches;
+ };
+ const finishBatch = (skipped = 0, growOnlyOp = false) => {
+ const done = advance(slice.length, skipped);
+ resetFailures();
+ const posts = prepareOnlyOpPosts(slice.map((id) => cache.get(id)).filter(Boolean));
+ if (growOnlyOp && onlyOpScan) growOnlyOpBatch();
+ return { posts, done };
+ };
+ let missing = slice.filter((id) => !cache.has(id));
+ if (isFunction(options.onSource)) {
+ options.onSource(missing.length ? 'network' : 'cache', {
+ cachedCount: slice.length - missing.length,
+ missingCount: missing.length,
+ totalCount: slice.length,
+ });
+ }
+ let loadError = null;
+ if (missing.length) {
+ try {
+ await loadPostsByIds(slice, {
+ priority: POST_REQUEST_PRIORITY.scroll,
+ persistFetchedPosts: !onlyOpScan && options.persistFetchedPosts === true,
+ });
+ } catch (error) {
+ loadError = error;
+ }
+ missing = slice.filter((id) => !cache.has(id));
+ }
+
+ if (missing.length) {
+ if (loadError) {
+ if (onlyOpScan) adjustOnlyOpBatchAfterError(loadError);
+ const status = Number(loadError.status || 0);
+ if (status === 401 || status === 403) {
+ return { posts: [], done: false, fatal: true, error: loadError };
+ }
+ if (!status || shouldStopReaderFallback(loadError)) {
+ resetFailures();
+ return { posts: [], done: false, retry: true, error: loadError };
+ }
+ if (bumpFailures() < 4) return { posts: [], done: false, retry: true, error: loadError };
+ return finishBatch(missing.length);
+ }
+ if (onlyOpScan) onlyOpPageSize = Math.max(onlyOpBatchFloor, onlyOpPageSize - 8);
+ if (bumpFailures() >= 4) {
+ return finishBatch(missing.length);
+ }
+ return { posts: [], done: false, retry: true };
+ }
+
+ return finishBatch(0, true);
+ }
+
+ async function requestMissingPostIds(ids, priority, shouldPersist) {
+ const missing = ids.filter((id) => !cache.has(id));
+ const unclaimed = missing.filter((id) => !pendingPostRequests.has(id));
+ if (unclaimed.length) {
+ const requestIds = [...new Set(unclaimed)];
+ const requestKey = requestIds.slice().sort((a, b) => String(a).localeCompare(String(b))).join(',');
+ const qs = requestIds.map((id) => `post_ids[]=${id}`).join('&');
+ const request = fetchLoaderJSON(`${BASE}/t/${topicId}/posts.json?${qs}`, {
+ key: `posts:${topicId}:${requestKey}`,
+ priority,
+ }).then((part) => {
+ cachePosts(part?.post_stream && part.post_stream.posts);
+ }).finally(() => {
+ requestIds.forEach((id) => {
+ if (pendingPostRequests.get(id) === request) pendingPostRequests.delete(id);
+ });
+ });
+ requestIds.forEach((id) => pendingPostRequests.set(id, request));
+ }
+ const requests = [...new Set(
+ missing.map((id) => pendingPostRequests.get(id)).filter(Boolean)
+ )];
+ if (requests.length) await Promise.all(requests);
+ if (shouldPersist) persistFetchedPosts(missing.map((id) => cache.get(id)).filter(Boolean));
+ }
+
+ async function loadPostsByIds(ids, options = {}) {
+ const requestedIds = [...new Set((ids || []).filter(Boolean))];
+ let missing = requestedIds.filter((id) => !cache.has(id));
+ let lastError = null;
+ const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible;
+ const maxAttempts = Math.max(1, +(options.maxAttempts || 2));
+ for (let attempt = 0; attempt < maxAttempts && missing.length; attempt++) {
+ try {
+ await requestMissingPostIds(missing, priority, options.persistFetchedPosts === true);
+ lastError = null;
+ } catch (error) {
+ lastError = error;
+ const status = Number(error?.status || 0);
+ const splitDepth = Math.max(0, Number(options.clientSplitDepth) || 0);
+ const canSplitClientError = [400, 404, 413, 414, 422].includes(status) &&
+ missing.length > 1 && splitDepth < 1;
+ if (canSplitClientError) {
+ const middle = Math.ceil(missing.length / 2);
+ const splitOptions = { ...options, maxAttempts: 1, clientSplitDepth: splitDepth + 1 };
+ const results = await Promise.allSettled([
+ loadPostsByIds(missing.slice(0, middle), splitOptions),
+ loadPostsByIds(missing.slice(middle), splitOptions),
+ ]);
+ const authFailure = results.find((result) => result.status === 'rejected' &&
+ [401, 403].includes(Number(result.reason?.status || 0)));
+ if (authFailure) throw authFailure.reason;
+ const transientFailure = results.find((result) => result.status === 'rejected' && (
+ !Number(result.reason?.status || 0) || shouldStopReaderFallback(result.reason)
+ ));
+ if (transientFailure) throw transientFailure.reason;
+ lastError = null;
+ break;
+ }
+ if (shouldStopReaderFallback(error) || (status >= 400 && status < 500)) break;
+ }
+ missing = requestedIds.filter((id) => !cache.has(id));
+ if (missing.length && attempt + 1 < maxAttempts) {
+ await waitForDelay(240);
+ }
+ }
+ if (missing.length && lastError) throw lastError;
+ const posts = requestedIds.map((id) => cache.get(id)).filter(Boolean);
+ return posts;
+ }
+
+ function streamIndexForPostNumber(postNumber) {
+ const target = +postNumber;
+ const cachedPost = postByNumber.get(target);
+ const cachedIndex = cachedPost && streamIndexById.get(cachedPost.id);
+ if (Number.isInteger(cachedIndex)) return cachedIndex;
+ return Math.min(stream.length - 1, Math.max(0, target - 1));
+ }
+
+ function advanceCursorPast(posts, targetPostNumber) {
+ if (!stream.length) return;
+ let maxIndex = -1;
+ posts.forEach((p) => {
+ const byId = streamIndexById.get(p.id);
+ if (Number.isInteger(byId)) {
+ maxIndex = Math.max(maxIndex, byId);
+ } else if (p.post_number) {
+ maxIndex = Math.max(maxIndex, Math.min(stream.length - 1, Math.max(0, +p.post_number - 1)));
+ }
+ });
+ if (maxIndex < 0 && targetPostNumber) {
+ maxIndex = Math.min(stream.length - 1, Math.max(0, +targetPostNumber - 1));
+ }
+ if (maxIndex >= cursor) cursor = maxIndex + 1;
+ }
+
+ async function loadRelativePosts(postNumber, direction, options = {}) {
+ if (!stream.length) return [];
+ const last = direction === 'last';
+ const targetIndex = last ? stream.length - 1 : streamIndexForPostNumber(postNumber);
+ if (last && !stream[targetIndex]) return [];
+ if (direction === 'before' && targetIndex <= 0) return [];
+ const start = last ? targetIndex : direction === 'before'
+ ? Math.max(0, targetIndex - pageSize)
+ : Math.min(stream.length, targetIndex + 1);
+ const end = last ? stream.length : direction === 'before' ? targetIndex : start + pageSize;
+ const posts = await loadPostsByIds(stream.slice(start, end), {
+ priority: Number.isFinite(options.priority) ? options.priority :
+ last ? POST_REQUEST_PRIORITY.target : POST_REQUEST_PRIORITY.scroll,
+ persistFetchedPosts: !last && options.persistFetchedPosts === true,
+ });
+ if (last && posts.length) advanceCursorPast(posts);
+ return posts;
+ }
+
+ async function loadPost(postNumber, options = {}) {
+ const target = +postNumber;
+ const forceRefresh = options.forceRefresh === true;
+ if (!target || (!forceRefresh && unavailablePostNumbers.has(target))) return [];
+ const around = options.scope === 'around';
+ const shouldAdvance = around || options.advanceCursor !== false;
+ const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.scroll;
+ const cached = around || forceRefresh ? null : postByNumber.get(target);
+ if (cached) {
+ if (isFunction(options.onSource)) options.onSource('cache');
+ if (shouldAdvance) advanceCursorPast([cached], target);
+ return [cached];
+ }
+ if (isFunction(options.onSource)) options.onSource('network');
+ if (!around) {
+ const highestPostNumber = Math.max(0, Number(topic?.highest_post_number) || 0);
+ const id = (!highestPostNumber || highestPostNumber === stream.length) ? stream[target - 1] : null;
+ const posts = id && !forceRefresh ? await loadPostsByIds([id], { priority }) : [];
+ const hit = posts.find((post) => +post.post_number === target);
+ if (hit) {
+ if (shouldAdvance) advanceCursorPast([hit], target);
+ return [hit];
+ }
+ }
+ const slug = encodeURIComponent((topic?.slug) || 'topic');
+ const urls = around
+ ? [
+ `${BASE}/t/${slug}/${topicId}/${target}.json`,
+ `${BASE}/t/${slug}/${topicId}.json?post_number=${target}`,
+ `${BASE}/t/${topicId}.json?post_number=${target}`,
+ `${BASE}/posts/by_number/${topicId}/${target}.json`,
+ ]
+ : [
+ `${BASE}/posts/by_number/${topicId}/${target}.json`,
+ `${BASE}/t/${slug}/${topicId}/${target}.json`,
+ `${BASE}/t/${topicId}.json?post_number=${target}`,
+ ];
+ let fallbackPosts = [];
+ for (const url of urls) {
+ try {
+ const payload = await fetchLoaderJSON(url, {
+ key: `${forceRefresh ? 'force-' : ''}${around ? 'around' : 'single'}:${topicId}:${target}:${url}`,
+ priority,
+ fetchOptions: forceRefresh ? { cache: 'no-store' } : {},
+ cacheMode: forceRefresh ? 'refresh' : 'default',
+ });
+ const fetched = postsFromPayload(payload).filter((p) => p?.id && p.post_number);
+ cachePosts(fetched);
+ const found = fetched.find((p) => +p.post_number === target);
+ if (found) {
+ if (shouldAdvance) advanceCursorPast(around ? fetched : [found], target);
+ return around ? fetched : [found];
+ }
+ if (around && fetched.length && !fallbackPosts.length) fallbackPosts = fetched;
+ } catch (error) {
+ const status = Number(error?.status || 0);
+ if (!around && (status === 404 || status === 410) && url.includes('/posts/by_number/')) {
+ unavailablePostNumbers.add(target);
+ return [];
+ }
+ if (shouldStopReaderFallback(error)) throw error;
+ }
+ }
+ if (around && stream.length) {
+ const start = Math.max(0, Math.min(stream.length - pageSize, target - 1 - Math.floor(pageSize / 2)));
+ const ids = stream.slice(start, start + pageSize);
+ if (ids.length) {
+ try {
+ const posts = await loadPostsByIds(ids, { priority });
+ if (posts.length) {
+ advanceCursorPast(posts, target);
+ return posts;
+ }
+ } catch (error) {
+ if (shouldStopReaderFallback(error)) throw error;
+ }
+ }
+ }
+ if (around && fallbackPosts.length) advanceCursorPast(fallbackPosts, target);
+ return around ? fallbackPosts : [];
+ }
+
+ return {
+ init, next, loadPostsByIds, loadPost,
+ loadBeforePost: (postNumber, options) => loadRelativePosts(postNumber, 'before', options),
+ loadAfterPost: (postNumber, options) => loadRelativePosts(postNumber, 'after', options),
+ loadLastPost: (options) => loadRelativePosts(0, 'last', options),
+ refreshTopicData,
+ persistViewedPostNumbers, persistFetchedPosts,
+ destroy() {
+ stopTopicCacheSync();
+ stopTopicCacheSync = () => {};
+ requestController.abort();
+ stream.length = 0;
+ cache.clear();
+ postByNumber.clear();
+ unavailablePostNumbers.clear();
+ pendingPostRequests.clear();
+ streamIndexById.clear();
+ topic = null;
+ refreshRequest = null;
+ },
+ get topic() { return topic; },
+ get cachedPosts() { return [...cache.values()]; },
+ get initializedFromCache() { return initializedFromCache; },
+ get loadDone() { return cursor >= stream.length; },
+ get scanDone() { return onlyOpCursor >= stream.length; },
+ get scanCursor() { return onlyOpCursor; },
+ get scanSkippedCount() { return onlyOpSkippedCount; },
+ get scanBatchSize() { return onlyOpPageSize; },
+ get streamLength() { return stream.length; },
+ };
+ }
+
+ /* ============ 6. 楼层归位 ============ */
+ function streamItemParticipatesInZebra(item) {
+ return !!(item?.node) &&
+ !item.zebraExcluded &&
+ !item.hiddenReplyMarker &&
+ !item.node.hidden &&
+ !item.node.classList.contains('ldp-nested-collapsed') &&
+ !item.node.classList.contains('ldp-nested-preview');
+ }
+
+ function reindexStreamItems(ctx, start = 0) {
+ const items = ctx?.streamItems;
+ if (!items || !items.length) return;
+ const from = Math.max(0, Math.min(items.length - 1, +start || 0));
+ let zebraIndex = from > 0 ? +(items[from - 1].zebraCountAfter || 0) : 0;
+ for (let index = from; index < items.length; index++) {
+ const item = items[index];
+ const participates = streamItemParticipatesInZebra(item);
+ item.index = index;
+ item.zebraIndex = participates ? zebraIndex++ : -1;
+ item.zebraCountAfter = zebraIndex;
+ }
+ }
+
+ function insertStreamPost(ctx, node, postNumber, options = {}) {
+ const current = +postNumber;
+ if (!current || ctx.streamItemMap.has(current)) return;
+ let low = 0;
+ let high = ctx.streamItems.length;
+ const compare = (item) => {
+ if (!ctx.isPostVoting) return item.postNumber - current;
+ if (item.postNumber === 1 || current === 1) return item.postNumber === current ? 0 : (item.postNumber === 1 ? -1 : 1);
+ const left = +(item.node?._ldpPostData && item.node._ldpPostData.post_voting_vote_count || 0);
+ const right = +(node._ldpPostData?.post_voting_vote_count || 0);
+ return right - left || item.postNumber - current;
+ };
+ while (low < high) {
+ const mid = Math.floor((low + high) / 2);
+ if (compare(ctx.streamItems[mid]) < 0) low = mid + 1;
+ else high = mid;
+ }
+ const estimatedHeight = Math.max(
+ STREAM_ESTIMATED_HEIGHT,
+ postContentPlaceholderHeight(node._ldpPostData) + 72
+ );
+ if (ctx.streamMountedNodes.size && ctx.streamWindowEnd > ctx.streamWindowStart) {
+ if (low < ctx.streamWindowStart) {
+ ctx.streamWindowStart++;
+ ctx.streamWindowEnd++;
+ } else if (low < ctx.streamWindowEnd) {
+ ctx.streamWindowEnd++;
+ }
+ }
+ const item = {
+ postNumber: current,
+ username: String((node._ldpPostData?.username) || node.dataset.username || ''),
+ node,
+ zebraExcluded: options.zebraExcluded === true,
+ height: estimatedHeight,
+ index: low,
+ };
+ ctx.streamItems.splice(low, 0, item);
+ ctx.streamItemMap.set(current, item);
+ if (options.deferSync) {
+ ctx.streamPendingReindexFrom = Number.isInteger(ctx.streamPendingReindexFrom)
+ ? Math.min(ctx.streamPendingReindexFrom, low)
+ : low;
+ } else {
+ reindexStreamItems(ctx, low);
+ }
+ if (ctx.onlyOp) invalidateStreamLayout(ctx);
+ else markStreamPrefixDirty(ctx, low);
+ ctx.streamNeedsRepair = true;
+ ensureStreamScaffold(ctx);
+ if (!options.deferSync) scheduleStreamWindowSync(ctx);
+ }
+
+ function flushStreamPostBatch(ctx, syncNow = false, preserveViewportAnchor = false) {
+ if (!ctx || !Number.isInteger(ctx.streamPendingReindexFrom)) return;
+ const dirtyFrom = ctx.streamPendingReindexFrom;
+ reindexStreamItems(ctx, dirtyFrom);
+ ctx.streamPendingReindexFrom = null;
+ refreshStreamZebra(ctx);
+ if (ctx.onlyOp) invalidateStreamLayout(ctx);
+ else markStreamPrefixDirty(ctx, dirtyFrom);
+ if (syncNow) {
+ const keepMountedWindow = preserveViewportAnchor && ctx.streamViewportAnchor &&
+ ctx.streamMountedNodes.size > 0 && ctx.streamWindowEnd > ctx.streamWindowStart;
+ if (keepMountedWindow) {
+ if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame);
+ ctx.streamWindowFrame = 0;
+ // Prepending changes every item index before scrollTop can be corrected.
+ // Keep the anchored DOM mounted, restore it, then calculate the new window.
+ mountStreamRange(ctx, ctx.streamWindowStart, ctx.streamWindowEnd);
+ syncStreamWindow(ctx);
+ } else {
+ flushStreamWindow(ctx);
+ }
+ if (ctx.timeline) ctx.timeline.update();
+ } else {
+ scheduleStreamWindowSync(ctx);
+ }
+ }
+
+ function refreshStreamZebra(ctx, nodes = null) {
+ if (!ctx || !ctx.commentsEl) return;
+ if (ctx.onlyOp) streamLayoutItems(ctx);
+ const targets = nodes || ctx.commentsEl.children;
+ for (const node of targets) {
+ if (!node.classList.contains('ldp-post')) continue;
+ const item = ctx.streamItemMap?.get(streamPostNumber(node));
+ const zebraIndex = item ? (ctx.onlyOp ? item.layoutZebraIndex : item.zebraIndex) : -1;
+ node.classList.toggle('ldp-zebra-alt', zebraIndex >= 0 && zebraIndex % 2 === 1);
+ }
+ }
+
+ function syncHiddenReplyRun(run, atEnd = false) {
+ if (!run.length) return null; const lead = run[0], markerClass = 'ldp-hidden-reply-marker';
+ let marker = lead.node.querySelector(`:scope > .${markerClass}`); const expanded = marker?.querySelector('.ldp-hidden-reply-list')?.hidden === false;
+ if (!marker) {
+ marker = makeElement('div'); marker.className = markerClass;
+ lead.node.prepend(marker);
+ }
+ lead.node.classList.toggle('ldp-hidden-replies-at-end', atEnd);
+ [...lead.node.children].forEach((child) => { if (child !== marker) child.hidden = true; });
+ marker.innerHTML = `
+
${run.map(({ post }) => {
+ const name = post.name || post.username || `#${post.post_number}`, src = avatarUrl(post.avatar_template, 24); return `
`;
+ }).join('')}
`; let changed = null; run.forEach(({ item, node, index }, offset) => {
+ const isLead = offset === 0, hidden = !isLead; if (node.hidden !== hidden || item.hiddenReplyMarker !== isLead) changed = changed == null ? index : Math.min(changed, index);
+ node.hidden = hidden; node.classList.remove('ldp-zebra-alt'); item.hiddenReplyMarker = isLead; item.height = isLead ? 18 : 0; }); return changed;
+ }
+
+ function applyNestedReplyDisplayPreference(ctx, forceZebra = false) {
+ if (!ctx || !Array.isArray(ctx.streamItems)) return;
+ const collapseChildren = PREFS.expandNestedRepliesByDefault !== true;
+ const expandLeaf = PREFS.expandLeafNestedReplies === true;
+ let reindexFrom = null, hiddenRun = [];
+ const flushHiddenRun = (atEnd = false) => {
+ const changed = syncHiddenReplyRun(hiddenRun, atEnd);
+ if (changed != null) reindexFrom = reindexFrom == null ? changed : Math.min(reindexFrom, changed);
+ hiddenRun = [];
+ };
+ ctx.streamItems.forEach((item, index) => {
+ const node = item?.node;
+ if (!node) return;
+ node.classList.remove('ldp-hidden-replies-at-end');
+ const keepOnlyOpExpanded = !!(ctx.onlyOp && String(item.username || '') === String(ctx.op || ''));
+ const post = node._ldpPostData;
+ const parentNumber = +(post?.reply_to_post_number || node.dataset.replyToPostNumber || 0);
+ const isNestedFloor = !!(post && parentNumber > 0);
+ const hideFloor = isNestedFloor && !post.hidden;
+ if (hideFloor) hiddenRun.push({ item, node, post, index });
+ else flushHiddenRun();
+ if (+(node.dataset.childReplyTotal || 0) > 0) {
+ setChildRepliesCollapsed(node, keepOnlyOpExpanded ? false : collapseChildren);
+ }
+ const isLeafNestedReply = !!(post && node.classList.contains('ldp-reply-collapsible') &&
+ !post.hidden && !(+post.reply_count > 0));
+ const collapsed = !expandLeaf && !keepOnlyOpExpanded;
+ if (isLeafNestedReply) setNestedReplyCollapsed(node, collapsed, { deferZebraSync: true });
+ const excluded = !!post?.hidden || hideFloor || (isLeafNestedReply && collapsed);
+ if (item.zebraExcluded === excluded) return;
+ item.zebraExcluded = excluded;
+ reindexFrom = reindexFrom == null ? index : Math.min(reindexFrom, index);
+ });
+ flushHiddenRun(true);
+ if (forceZebra) reindexFrom = 0;
+ if (reindexFrom == null) return;
+ reindexStreamItems(ctx, reindexFrom);
+ if (ctx.onlyOp) invalidateStreamLayout(ctx);
+ else markStreamPrefixDirty(ctx, reindexFrom);
+ refreshStreamZebra(ctx);
+ scheduleStreamWindowSync(ctx);
+ }
+
+ function findReplyListParent(postNumber, ctx) {
+ const num = +postNumber;
+ if (!num || !ctx) return null;
+ const streamNode = getStreamPostNode(ctx, num);
+ if (streamNode?.isConnected) return streamNode;
+ if (ctx.subReplyState) {
+ for (const state of ctx.subReplyState.values()) {
+ const cached = state?.nodeCache?.get(num);
+ if (cached?.isConnected) return cached;
+ }
+ }
+ // Nested tree nodes live under .ldp-children, not only as top-level stream posts.
+ return ctx.commentsEl?.querySelector(`.ldp-post[data-post-number="${num}"]`) || null;
+ }
+
+ function rememberLatestReplyTime(ctx, post, options = {}) {
+ const createdAt = String((post?.created_at) || '');
+ const timestamp = Date.parse(createdAt);
+ if (!ctx || !Number.isFinite(timestamp)) return;
+ if (!options.force && timestamp <= +(ctx.latestReplyTimestamp || 0)) return;
+ ctx.latestReplyAt = createdAt;
+ ctx.latestReplyTimestamp = timestamp;
+ if (ctx.timeline) ctx.timeline.update();
+ }
+
+ function rememberPostData(ctx, post, parentHint = 0) {
+ const postNumber = +(post?.post_number || 0);
+ if (!ctx || !postNumber) return 0;
+ const knownParentNumber = +(ctx.replyParentByPost?.get(postNumber) || 0);
+ const parentNumber = +(post.reply_to_post_number || parentHint || knownParentNumber || 0);
+ if (!parentNumber) return 0;
+ if (!post.reply_to_post_number) post.reply_to_post_number = parentNumber;
+ if (ctx.replyParentByPost) ctx.replyParentByPost.set(postNumber, parentNumber);
+ let replies = ctx.directRepliesByParent.get(parentNumber);
+ if (!replies) {
+ replies = new Map();
+ ctx.directRepliesByParent.set(parentNumber, replies);
+ }
+ replies.set(postNumber, post);
+ return parentNumber;
+ }
+
+ function attachPostBatch(posts, ctx, options = {}) {
+ const batch = Array.isArray(posts) ? posts : [];
+ if (!batch.length) return [];
+ ctx.tracker.preload(batch);
+ const batchOptions = { ...options, deferStreamSync: true };
+ const nodes = batch.map((post) => attachPost(post, ctx, batchOptions)).filter(Boolean);
+ syncKnownBoostIdentities(ctx, batch.map((post) => post?.username));
+ applyNestedReplyDisplayPreference(ctx);
+ flushStreamPostBatch(
+ ctx,
+ options.syncStreamNow === true,
+ options.preserveViewportAnchor === true,
+ );
+ return nodes;
+ }
+
+ function yieldReaderRenderTask() {
+ if (isFunction(globalThis.scheduler?.yield)) return globalThis.scheduler.yield();
+ return new Promise((resolve) => setTimeout(resolve, 0));
+ }
+
+ async function attachPostBatchCooperatively(posts, ctx, options = {}) {
+ const batch = Array.isArray(posts) ? posts : [];
+ if (!batch.length) return [];
+ ctx.tracker.preload(batch);
+ const acceptsWork = isFunction(options.acceptsWork) ? options.acceptsWork : () => true;
+ const batchOptions = { ...options };
+ delete batchOptions.acceptsWork;
+ batchOptions.deferStreamSync = true;
+ const nodes = [];
+ const attachedPosts = [];
+ let sliceStartedAt = performance.now();
+ for (let index = 0; index < batch.length; index++) {
+ if (!acceptsWork()) return nodes;
+ const post = batch[index];
+ const node = attachPost(post, ctx, batchOptions);
+ if (node) nodes.push(node);
+ attachedPosts.push(post);
+ if ((index + 1) % 4 === 0 && performance.now() - sliceStartedAt >= 8) {
+ await yieldReaderRenderTask();
+ if (!acceptsWork()) return nodes;
+ sliceStartedAt = performance.now();
+ }
+ }
+ syncKnownBoostIdentities(ctx, attachedPosts.map((post) => post?.username));
+ applyNestedReplyDisplayPreference(ctx);
+ flushStreamPostBatch(
+ ctx,
+ options.syncStreamNow === true,
+ options.preserveViewportAnchor === true,
+ );
+ return nodes;
+ }
+
+ function attachPost(p, ctx, options = {}) {
+ const rememberedParentNum = rememberPostData(ctx, p);
+ rememberLatestReplyTime(ctx, p);
+ const fromNestedList = !!options.nestedList;
+ const parentNum = +(p.reply_to_post_number || rememberedParentNum || 0);
+ const isNestedReply = !ctx.isPostVoting && !!parentNum;
+ const renderAsReply = isNestedReply || (fromNestedList && !!parentNum);
+ const preferredParentNode = options.preferredParentNode;
+ const preferredParentMatches = preferredParentNode?.isConnected &&
+ +preferredParentNode.dataset.postNumber === +parentNum;
+ const logicalParentNode = fromNestedList && parentNum
+ ? (preferredParentMatches ? preferredParentNode : findReplyListParent(parentNum, ctx))
+ : null;
+ // A nested-list request always belongs to the requested post. Do not walk
+ // that post's own parent chain: A -> B -> A conversations would otherwise
+ // be reattached to an ancestor and create a visual reply cycle.
+ const parentNode = fromNestedList ? logicalParentNode : null;
+ if (fromNestedList) {
+ const parentChildren = parentNode?.querySelector(':scope > .ldp-children');
+ const existingNode = parentChildren?.querySelector(
+ `:scope > .ldp-post[data-post-number="${+p.post_number}"]`
+ );
+ if (existingNode) return existingNode;
+ const parentDepth = postNestDepth(parentNode, 0);
+ const nestDepth = Number.isFinite(Number(options.nestDepth))
+ ? Math.max(0, Number(options.nestDepth))
+ : parentDepth + 1;
+ const maxDepth = inlineReplyTreeMaxDepth();
+ const allowNestedChildren = nestDepth < maxDepth;
+ const node = renderPost(p, renderAsReply, ctx, {
+ forceExpanded: true,
+ nestedPreview: true,
+ showParentJump: false,
+ nestDepth,
+ allowNestedChildren,
+ });
+ node.dataset.ldpNestDepth = String(nestDepth);
+ // Deeper than direct replies: keep the branch collapsed until the user opens it
+ // or a jump/focus path expands it. Depth-1 still follows expandNestedRepliesByDefault
+ // via the parent shell, not this node body.
+ if (allowNestedChildren && nestDepth >= 2 && +(p.reply_count || 0) > 0) {
+ node.classList.add('ldp-children-collapsed');
+ }
+ if (parentNode) {
+ if (!options.deferNestedSync && parentChildren) parentChildren.appendChild(node);
+ bindPostMediaLayoutSync(node, ctx);
+ const streamNode = ctx.streamNodeMap.get(p.post_number);
+ if (streamNode) {
+ const streamIsNestedReply = +(p.reply_to_post_number || 0) > 0;
+ const keepJumpTargetExpanded = streamNode.dataset.ldpJumpExpanded === '1';
+ if (streamIsNestedReply) {
+ ensureStreamNestedReplyPresentation(streamNode, p.reply_to_post_number);
+ }
+ if (streamNode._ldpPostData?.hidden) {
+ setNestedReplyCollapsed(streamNode, true);
+ } else if (!streamIsNestedReply || p.reply_count > 0) {
+ setNestedReplyCollapsed(streamNode, false);
+ if (p.reply_count > 0) {
+ ctx.repliesIO.observe(streamNode);
+ }
+ } else {
+ setNestedReplyCollapsed(streamNode, !keepJumpTargetExpanded);
+ }
+ }
+ if (allowNestedChildren && +(p.reply_count || 0) > 0 && ctx.repliesIO) {
+ ctx.repliesIO.observe(node);
+ }
+ if (!options.deferNestedSync) syncChildControls(parentNode);
+ }
+ if (parentNode) ctx.tracker.observe(node);
+ return node;
+ }
+
+ if (ctx.streamNodeMap.has(+p.post_number)) return ctx.streamNodeMap.get(+p.post_number) || null;
+
+ const hiddenStreamPost = !!p.hidden;
+ const preferenceHidden = isNestedReply && parentNum > 0;
+ const collapsedStreamReply = hiddenStreamPost || !!(isNestedReply && !(p.reply_count > 0) &&
+ PREFS.expandLeafNestedReplies !== true);
+ const node = renderPost(p, renderAsReply, ctx, {
+ forceCollapsed: collapsedStreamReply,
+ showParentJump: isNestedReply,
+ deferContent: true,
+ nestDepth: 0,
+ });
+ node.dataset.ldpNestDepth = '0';
+ node.hidden = preferenceHidden;
+ insertStreamPost(ctx, node, p.post_number, {
+ deferSync: !!options.deferStreamSync,
+ zebraExcluded: hiddenStreamPost || collapsedStreamReply || preferenceHidden,
+ });
+ bindPostMediaLayoutSync(node, ctx);
+ ctx.streamNodeMap.set(+p.post_number, node);
+ if (preferenceHidden) ctx.streamItemMap.get(+p.post_number).height = 0;
+ ctx.tracker.observe(node);
+ if (ctx.queueViewportTracker) ctx.queueViewportTracker.observe(node);
+ if (+p.post_number === 1) syncCommentsHeaderPlacement(ctx);
+ return node;
+ }
+
+ let threadGroupGradientSerial = 0;
+
+ function findParentPreviewLine(post) {
+ const line = post?.previousElementSibling;
+ return line && line.classList.contains('ldp-floor-preview-link') ? line : null;
+ }
+
+ function clearCombinedParentPreviewLine(post) {
+ const line = findParentPreviewLine(post);
+ if (line) {
+ line.classList.remove('ldp-floor-preview-link-grouped');
+ line.querySelector(':scope > .ldp-thread-group-svg')?.remove();
+ }
+ if (post) {
+ post.querySelector(':scope > .ldp-thread-group-svg')?.remove();
+ post.classList.remove('ldp-parent-preview-linked');
+ }
+ }
+
+ function syncGroupedParentPreviewLine(line, parent, posts) {
+ const preview = line?.previousElementSibling;
+ if (!line || !line.classList.contains('open') || !preview || !preview.classList.contains('ldp-floor-preview')) return false;
+ const parentAvatar = parent?.querySelector(':scope > .ldp-post-head .ldp-avatar');
+ const childAvatars = posts.map((post) => post.querySelector(':scope > .ldp-post-head .ldp-avatar'));
+ const previewAvatar = preview.querySelector(':scope > .ldp-post-head .ldp-avatar');
+ if (!parentAvatar || !previewAvatar || childAvatars.some((avatar) => !avatar)) return false;
+ const layerRect = parent.getBoundingClientRect();
+ const previewRect = preview.getBoundingClientRect();
+ const previewAvatarRect = previewAvatar.getBoundingClientRect();
+ const points = [{
+ x: previewRect.left - layerRect.left,
+ y: previewAvatarRect.top + previewAvatarRect.height / 2 - layerRect.top,
+ }];
+ [parentAvatar, ...childAvatars].forEach((avatar) => {
+ const rect = avatar.getBoundingClientRect();
+ points.push({
+ x: rect.left - layerRect.left,
+ y: rect.top + rect.height / 2 - layerRect.top,
+ });
+ });
+ if (points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y))) return false;
+ const lineStyle = getComputedStyle(line);
+ const radius = normalizeReplyLineRadius(parseFloat(lineStyle.getPropertyValue('--ldp-reply-line-radius')));
+ const lineWidth = normalizeLineWidth(parseFloat(lineStyle.getPropertyValue('--ldp-reply-line-width')), REPLY_LINE_WIDTH_DEFAULT);
+ const linePadding = Math.ceil(lineWidth / 2) + 1;
+ const branchGap = 5;
+ const axisX = Math.min(...points.map((point) => point.x)) - radius;
+ const minX = Math.floor(axisX - linePadding);
+ const maxX = Math.ceil(Math.max(...points.map((point) => point.x)) + linePadding);
+ const minY = Math.floor(points[0].y - linePadding);
+ const maxY = Math.ceil(points[points.length - 1].y + linePadding);
+ const width = Math.max(1, maxX - minX);
+ const height = Math.max(1, maxY - minY);
+ const localX = (value) => Math.round(value - minX);
+ const localY = (value) => Math.round(value - minY);
+ const trunkX = localX(axisX);
+ const topY = localY(points[0].y);
+ const bottomY = localY(points[points.length - 1].y - radius);
+ const curveEndX = localX(axisX + radius);
+ const topCurveStartY = localY(points[0].y + radius);
+ const solidPathParts = [
+ `M ${trunkX} ${topCurveStartY} V ${bottomY}`,
+ `M ${trunkX} ${topCurveStartY} Q ${trunkX} ${topY} ${curveEndX} ${topY}`,
+ ];
+ const topBranchEndX = localX(points[0].x - branchGap);
+ const branchPathParts = [{
+ d: `M ${curveEndX} ${topY} H ${topBranchEndX}`,
+ reverse: topBranchEndX < curveEndX,
+ startX: curveEndX,
+ endX: topBranchEndX,
+ y: topY,
+ }];
+ points.slice(1).forEach((point) => {
+ const y = localY(point.y);
+ solidPathParts.push(`M ${trunkX} ${localY(point.y - radius)} Q ${trunkX} ${y} ${curveEndX} ${y}`);
+ const branchEndX = localX(point.x - branchGap);
+ branchPathParts.push({
+ d: `M ${curveEndX} ${y} H ${branchEndX}`,
+ reverse: branchEndX < curveEndX,
+ startX: curveEndX,
+ endX: branchEndX,
+ y,
+ });
+ });
+ const pathParts = [{ d: solidPathParts.join(' '), branch: false, reverse: false },
+ ...branchPathParts.map((part) => ({ ...part, branch: true }))];
+ let svg = parent.querySelector(':scope > .ldp-thread-group-svg');
+ if (!svg) {
+ svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.classList.add('ldp-thread-group-svg');
+ svg.setAttribute('aria-hidden', 'true');
+ parent.appendChild(svg);
+ }
+ svg.style.left = `${minX}px`;
+ svg.style.top = `${minY}px`;
+ svg.style.width = `${width}px`;
+ svg.style.height = `${height}px`;
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
+ let defs = svg.querySelector(':scope > .ldp-thread-group-defs');
+ if (!defs) {
+ defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
+ defs.classList.add('ldp-thread-group-defs');
+ svg.insertBefore(defs, svg.firstChild);
+ }
+ const gradients = [...defs.querySelectorAll(':scope > .ldp-thread-group-gradient')];
+ while (gradients.length < branchPathParts.length) {
+ const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
+ gradient.classList.add('ldp-thread-group-gradient');
+ gradient.id = `ldp-thread-group-gradient-${++threadGroupGradientSerial}`;
+ gradient.setAttribute('gradientUnits', 'userSpaceOnUse');
+ [0, 1, 1].forEach((offset, index) => {
+ const stop = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
+ stop.setAttribute('offset', String(offset));
+ stop.setAttribute('stop-color', 'currentColor');
+ stop.setAttribute('stop-opacity', index === 2 ? '0' : '1');
+ gradient.appendChild(stop);
+ });
+ defs.appendChild(gradient);
+ gradients.push(gradient);
+ }
+ while (gradients.length > branchPathParts.length) gradients.pop().remove();
+ const paths = [...svg.querySelectorAll(':scope > .ldp-thread-group-path')];
+ while (paths.length < pathParts.length) {
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ path.classList.add('ldp-thread-group-path');
+ svg.appendChild(path);
+ paths.push(path);
+ }
+ while (paths.length > pathParts.length) paths.pop().remove();
+ let branchGradientIndex = 0;
+ paths.forEach((path, index) => {
+ const part = pathParts[index];
+ path.classList.toggle('ldp-thread-group-branch', part.branch);
+ path.classList.toggle('ldp-thread-group-branch-reverse', part.reverse);
+ path.setAttribute('d', part.d);
+ if (!part.branch) {
+ path.style.removeProperty('stroke');
+ return;
+ }
+ const gradient = gradients[branchGradientIndex++];
+ const branchLength = Math.max(1, Math.abs(part.endX - part.startX));
+ const fadeStart = Math.max(0, 1 - 12 / branchLength);
+ gradient.setAttribute('x1', String(part.startX));
+ gradient.setAttribute('x2', String(part.endX));
+ gradient.setAttribute('y1', String(part.y));
+ gradient.setAttribute('y2', String(part.y));
+ gradient.children[1].setAttribute('offset', String(fadeStart));
+ path.style.stroke = `url(#${gradient.id})`;
+ });
+ line.classList.add('ldp-floor-preview-link-grouped');
+ parent.classList.add('ldp-parent-preview-linked');
+ return true;
+ }
+
+ function syncNestedReplyBranches(children) {
+ const posts = [...children.children].filter((node) => node.classList.contains('ldp-post'));
+ const parent = children.parentElement?.classList?.contains('ldp-post')
+ ? children.parentElement
+ : children.closest('.ldp-post');
+ const parentAvatar = parent?.querySelector(':scope > .ldp-post-head .ldp-avatar');
+ const railToggle = children.querySelector(':scope > .ldp-nested-rail-toggle');
+ const branchToggle = children.querySelector(':scope > .ldp-nested-branch-toggle');
+ if (!parent || !parentAvatar || !posts.length || !children.isConnected) {
+ if (parent) {
+ parent.classList.remove('ldp-nested-branches-ready');
+ clearCombinedParentPreviewLine(parent);
+ }
+ return;
+ }
+ const branchGap = 5;
+ let branchAxisX = null;
+ let branchMetrics = [];
+ let railTop = 0;
+ let branchCanvasWidth = 0;
+ let parentBranchWidth = 0;
+ if (parentAvatar && posts.length) {
+ const childrenRect = children.getBoundingClientRect();
+ const avatarRect = parentAvatar.getBoundingClientRect();
+ if (!childrenRect.width || !avatarRect.width || !avatarRect.height) {
+ parent.classList.remove('ldp-nested-branches-ready');
+ clearCombinedParentPreviewLine(parent);
+ return;
+ }
+ // Rail sits just left of the parent avatar (legacy: avatar.left - 15).
+ let axisX = Math.round(avatarRect.left - childrenRect.left - 15);
+ let dropAnchorY = avatarRect.top + avatarRect.height / 2;
+ const parentLine = findParentPreviewLine(parent);
+ const parentAxisX = parentLine && parseFloat(parentLine.style.getPropertyValue('--ldp-parent-axis-left'));
+ const parentPreviewLinked = !!parentLine && parentLine.classList.contains('open') && Number.isFinite(parentAxisX);
+ if (parentPreviewLinked) {
+ axisX = Math.round(parentLine.getBoundingClientRect().left + parentAxisX - childrenRect.left);
+ dropAnchorY = avatarRect.top + avatarRect.height / 2;
+ }
+ const firstChildAvatar = posts[0]?.querySelector(':scope > .ldp-post-head .ldp-avatar');
+ const firstChildX = firstChildAvatar
+ ? Math.round(firstChildAvatar.getBoundingClientRect().left - childrenRect.left)
+ : Math.round(parseFloat(getComputedStyle(children).paddingLeft) || 18);
+ const firstBranchWidth = Math.max(0, firstChildX - axisX - branchGap);
+ const parentHeadWidth = Math.max(0, avatarRect.left - childrenRect.left - axisX - branchGap);
+ branchCanvasWidth = Math.max(1, Math.ceil(childrenRect.width));
+ parentBranchWidth = parentHeadWidth;
+ const drop = Math.max(0, Math.round(childrenRect.top - dropAnchorY));
+ railTop = -drop;
+ branchAxisX = axisX;
+ branchMetrics = posts.map((post) => {
+ const childAvatar = post.querySelector(':scope > .ldp-post-head .ldp-avatar');
+ if (!childAvatar) return null;
+ const childAvatarRect = childAvatar.getBoundingClientRect();
+ const postRect = post.getBoundingClientRect();
+ // Convert children-local axis into post-local left. The old CSS used
+ // `left: calc(axis - 18px)` assuming padding-left === 18; multi-depth
+ // gutters and zoomed heads break that constant, so measure instead.
+ const postOffsetX = postRect.left - childrenRect.left;
+ const lineLeft = Math.round(axisX - postOffsetX);
+ return {
+ branchY: Math.max(0, Math.round(childAvatarRect.top + childAvatarRect.height / 2 - postRect.top)),
+ branchWidth: Math.max(0, Math.round(childAvatarRect.left - childrenRect.left) - axisX - branchGap),
+ railY: Math.max(0, Math.round(childAvatarRect.top + childAvatarRect.height / 2 - childrenRect.top)),
+ lineLeft,
+ };
+ });
+ const groupedReady = parentPreviewLinked && branchMetrics.length === posts.length && branchMetrics.every(Boolean) &&
+ syncGroupedParentPreviewLine(parentLine, parent, posts);
+ if (!groupedReady) clearCombinedParentPreviewLine(parent);
+ children.style.setProperty('--ldp-nested-axis-left', `${axisX}px`);
+ children.style.setProperty('--ldp-nested-branch-width', `${firstBranchWidth}px`);
+ children.style.setProperty('--ldp-nested-parent-drop', `${drop}px`);
+ children.style.setProperty('--ldp-nested-parent-head-width', `${parentHeadWidth}px`);
+ // Gutter used only as fallback when a post has not received --ldp-nested-line-left yet.
+ const gutter = Math.max(0, Math.round(
+ (posts[0]?.getBoundingClientRect().left || childrenRect.left) - childrenRect.left
+ ));
+ children.style.setProperty('--ldp-nested-gutter', `${gutter || 18}px`);
+ } else {
+ children.style.removeProperty('--ldp-nested-axis-left');
+ children.style.removeProperty('--ldp-nested-branch-width');
+ children.style.removeProperty('--ldp-nested-parent-drop');
+ children.style.removeProperty('--ldp-nested-parent-head-width');
+ children.style.removeProperty('--ldp-nested-gutter');
+ }
+ const firstBranchMetric = branchMetrics.find(Boolean);
+ const lastBranchMetric = branchMetrics[branchMetrics.length - 1];
+ if (railToggle && branchAxisX !== null && firstBranchMetric && lastBranchMetric) {
+ railToggle.style.left = `${branchAxisX - 10}px`;
+ railToggle.style.top = `${railTop}px`;
+ railToggle.style.height = `${Math.max(24, lastBranchMetric.railY - 15 - railTop)}px`;
+ } else if (railToggle) {
+ railToggle.style.removeProperty('left');
+ railToggle.style.removeProperty('top');
+ railToggle.style.removeProperty('height');
+ }
+ if (branchToggle && branchAxisX !== null && lastBranchMetric && branchCanvasWidth) {
+ const radius = normalizeReplyLineRadius(
+ parseFloat(getComputedStyle(children).getPropertyValue('--ldp-reply-line-radius'))
+ );
+ const branchBottom = Math.max(railTop + 1, lastBranchMetric.railY + 10);
+ const branchHeight = branchBottom - railTop;
+ const pathParts = [
+ `M ${branchAxisX} ${railTop} H ${branchAxisX + parentBranchWidth}`,
+ `M ${branchAxisX} ${railTop} V ${Math.max(railTop, lastBranchMetric.railY - radius)}`,
+ ];
+ branchMetrics.forEach((metric) => {
+ if (!metric) return;
+ pathParts.push(`M ${branchAxisX} ${metric.railY - radius} Q ${branchAxisX} ${metric.railY} ${branchAxisX + radius} ${metric.railY} H ${branchAxisX + metric.branchWidth}`);
+ });
+ branchToggle.style.top = `${railTop}px`;
+ branchToggle.style.width = `${branchCanvasWidth}px`;
+ branchToggle.style.height = `${branchHeight}px`;
+ branchToggle.setAttribute('viewBox', `0 ${railTop} ${branchCanvasWidth} ${branchHeight}`);
+ branchToggle.querySelector(':scope > .ldp-nested-branch-hit-path')?.setAttribute('d', pathParts.join(' '));
+ } else if (branchToggle) {
+ branchToggle.removeAttribute('viewBox');
+ branchToggle.style.removeProperty('top');
+ branchToggle.style.removeProperty('width');
+ branchToggle.style.removeProperty('height');
+ branchToggle.querySelector(':scope > .ldp-nested-branch-hit-path')?.removeAttribute('d');
+ }
+ posts.forEach((post, index) => {
+ if (branchAxisX === null) {
+ post.style.removeProperty('--ldp-nested-branch-y');
+ post.style.removeProperty('--ldp-nested-branch-width');
+ post.style.removeProperty('--ldp-nested-line-left');
+ } else {
+ const metric = branchMetrics[index];
+ if (metric) {
+ post.style.setProperty('--ldp-nested-branch-y', `${metric.branchY}px`);
+ post.style.setProperty('--ldp-nested-branch-width', `${metric.branchWidth}px`);
+ post.style.setProperty('--ldp-nested-line-left', `${metric.lineLeft}px`);
+ } else {
+ post.style.removeProperty('--ldp-nested-branch-y');
+ post.style.removeProperty('--ldp-nested-branch-width');
+ post.style.removeProperty('--ldp-nested-line-left');
+ }
+ }
+ // Keep sibling trunk continuous: only the true last child stops at branch-y.
+ // Nested subtrees are indented on their own axis and must not cut the parent rail.
+ post.classList.toggle('ldp-nested-branch-last', index === posts.length - 1);
+ post.classList.toggle('ldp-nested-branch-single', posts.length === 1);
+ });
+ parent.classList.toggle('ldp-nested-branches-ready', branchMetrics.length === posts.length && branchMetrics.every(Boolean));
+ }
+
+ const nestedBranchSyncFrames = new WeakMap();
+
+ function scheduleNestedBranchSync(post, ctx) {
+ if (!post || !post.isConnected) return;
+ if (nestedBranchSyncFrames.has(post)) return;
+ const run = () => {
+ if (!post.isConnected) {
+ nestedBranchSyncFrames.delete(post);
+ return;
+ }
+ if (streamScrollIsActive(ctx, 100)) {
+ nestedBranchSyncFrames.set(post, setTimeout(run, 110));
+ return;
+ }
+ nestedBranchSyncFrames.delete(post);
+ // Walk the open ancestor chain so each level remeasures after a deeper
+ // batch is spliced in (padding/zoom/height all affect the joint).
+ const chain = [];
+ let cursor = post;
+ const seen = new Set();
+ while (cursor && cursor.classList?.contains('ldp-post') && !seen.has(cursor)) {
+ seen.add(cursor);
+ chain.push(cursor);
+ const parentChildren = cursor.parentElement?.classList?.contains('ldp-children')
+ ? cursor.parentElement
+ : null;
+ cursor = parentChildren?.parentElement?.classList?.contains('ldp-post')
+ ? parentChildren.parentElement
+ : null;
+ }
+ chain.forEach((node) => syncChildControls(node));
+ };
+ // Double rAF: wait until the newly spliced DOM has real layout boxes
+ // before measuring avatars; a single frame often still sees width 0.
+ const handle = streamScrollIsActive(ctx, 100)
+ ? setTimeout(run, 110)
+ : requestAnimationFrame(() => {
+ if (!post.isConnected) {
+ nestedBranchSyncFrames.delete(post);
+ return;
+ }
+ nestedBranchSyncFrames.set(post, requestAnimationFrame(run));
+ });
+ nestedBranchSyncFrames.set(post, handle);
+ }
+
+ function retryableReaderImageUrl(source) {
+ try {
+ const url = new URL(String(source || ''), location.href);
+ url.searchParams.set('_ldp_retry', String(Date.now()));
+ return url.href;
+ } catch {
+ return String(source || '');
+ }
+ }
+
+ function showReaderImageRetry(img, post, ctx) {
+ if (!img || img.classList.contains('emoji')) return;
+ let button = img._ldpImageRetryButton;
+ if (!button || !button.isConnected) {
+ button = makeElement('button');
+ button.type = 'button';
+ button.className = 'ldp-image-retry';
+ button.innerHTML = `${icon('rotateCcw')}
重试图片`;
+ button.addEventListener('click', (event) => {
+ consumeEvent(event);
+ if (button.disabled) return;
+ const source = String(img.dataset.ldpRetrySource || img.currentSrc || img.src || '');
+ if (!source) return;
+ button.disabled = true;
+ button.setAttribute('aria-busy', 'true');
+ button.querySelector('span').textContent = '正在重试…';
+ const retryUrl = retryableReaderImageUrl(source);
+ img.loading = 'eager';
+ img.srcset = retryUrl;
+ img.src = retryUrl;
+ scheduleNestedBranchSync(post, ctx);
+ });
+ const link = img.closest('a');
+ if (link && post.contains(link)) link.insertAdjacentElement('afterend', button);
+ else img.insertAdjacentElement('afterend', button);
+ img._ldpImageRetryButton = button;
+ }
+ button.disabled = false;
+ button.setAttribute('aria-busy', 'false');
+ button.querySelector('span').textContent = '重试图片';
+ }
+
+ function clearReaderImageRetry(img) {
+ const button = img?._ldpImageRetryButton;
+ if (button) button.remove();
+ if (img) img._ldpImageRetryButton = null;
+ }
+
+ function bindPostMediaLayoutSync(post, ctx) {
+ const content = post.querySelector(':scope > .ldp-content');
+ if (!content) return;
+ content.querySelectorAll('iframe').forEach((frame) => {
+ frame.loading = 'lazy';
+ });
+ content.querySelectorAll('video').forEach((video) => {
+ if (!video.hasAttribute('preload')) video.preload = 'metadata';
+ });
+ activateReaderMedia(content);
+ content.querySelectorAll('img').forEach((img) => {
+ if (img.dataset.ldpLayoutSyncBound === '1') return;
+ img.dataset.ldpLayoutSyncBound = '1';
+ if (!img.classList.contains('emoji')) {
+ img.loading = 'lazy';
+ img.decoding = 'async';
+ if (!img.dataset.ldpRetrySource) {
+ img.dataset.ldpRetrySource = absoluteImageUrl(img.currentSrc || img.src || img.getAttribute('src') || '');
+ }
+ }
+ const onLoad = () => {
+ clearReaderImageRetry(img);
+ scheduleNestedBranchSync(post, ctx);
+ };
+ const onError = () => {
+ showReaderImageRetry(img, post, ctx);
+ scheduleNestedBranchSync(post, ctx);
+ };
+ img.addEventListener('load', onLoad);
+ img.addEventListener('error', onError);
+ if (img.complete) {
+ if (img.naturalWidth > 0) onLoad();
+ else onError();
+ }
+ });
+ }
+
+ function countRenderedChildReplies(post) {
+ const children = post?.querySelector(':scope > .ldp-children');
+ if (!children) return 0;
+ let renderedCount = 0;
+ for (const child of children.children) {
+ if (child.classList.contains('ldp-post')) renderedCount++;
+ }
+ return renderedCount;
+ }
+
+ function clearEmptyChildReplyShell(post, ctx = post?._ldpReaderContext) {
+ if (!post) return;
+ const postNumber = +(post.dataset.postNumber || 0);
+ post.classList.remove('ldp-children-collapsed', 'ldp-has-child-branches', 'ldp-nested-branches-ready');
+ clearCombinedParentPreviewLine(post);
+ post.querySelector(':scope > .ldp-collapse-replies')?.remove();
+ post.querySelector(':scope > .ldp-children')?.remove();
+ post.querySelector(':scope > .ldp-sub-loading')?.remove();
+ delete post.dataset.childReplyTotal;
+ if (post._ldpPostData && Object.hasOwn(post._ldpPostData, 'reply_count')) {
+ post._ldpPostData.reply_count = 0;
+ }
+ if (postNumber && ctx?.subReplyState) ctx.subReplyState.delete(postNumber);
+ if (postNumber && ctx?.directRepliesByParent) ctx.directRepliesByParent.delete(postNumber);
+ ctx?.repliesIO?.unobserve?.(post);
+ }
+
+ // After /replies fetch settles, align the expand control with actual direct replies.
+ // Discourse reply_count can be stale, include filtered descendants, or count hidden posts.
+ function reconcilePostChildReplyTotal(post, actualCount, ctx = post?._ldpReaderContext, options = {}) {
+ if (!post) return 0;
+ const renderedCount = countRenderedChildReplies(post);
+ const loadedCount = Math.max(0, Number(actualCount) || 0);
+ const hasMore = options.hasMore === true;
+ const total = hasMore
+ ? Math.max(loadedCount, renderedCount, +(options.totalCount || 0), +(post.dataset.childReplyTotal || 0))
+ : Math.max(loadedCount, renderedCount);
+ if (total <= 0 && !hasMore) {
+ clearEmptyChildReplyShell(post, ctx);
+ return 0;
+ }
+ post.dataset.childReplyTotal = String(total);
+ if (post._ldpPostData && !hasMore) {
+ post._ldpPostData.reply_count = total;
+ }
+ syncChildControls(post);
+ if (total > 0 && ctx?.repliesIO) ctx.repliesIO.observe(post);
+ return total;
+ }
+
+ function syncChildControls(post) {
+ const children = post?.querySelector(':scope > .ldp-children');
+ const btn = post?.querySelector(':scope > .ldp-collapse-replies');
+ if (!children || !btn) return;
+ const collapsed = post.classList.contains('ldp-children-collapsed');
+ const renderedCount = countRenderedChildReplies(post);
+ const ctx = post._ldpReaderContext;
+ const postNumber = +(post.dataset.postNumber || 0);
+ const state = postNumber && ctx?.subReplyState ? ctx.subReplyState.get(postNumber) : null;
+ // Prefer settled fetch results over optimistic reply_count baked into dataset.
+ const totalCount = state && state.hasMore === false
+ ? Math.max(state.all?.length || 0, renderedCount)
+ : Math.max(
+ +(post.dataset.childReplyTotal || 0),
+ state?.all?.length || 0,
+ renderedCount,
+ );
+ if (state && state.hasMore === false && totalCount !== +(post.dataset.childReplyTotal || 0)) {
+ post.dataset.childReplyTotal = String(totalCount);
+ }
+ const railToggle = children.querySelector(':scope > .ldp-nested-rail-toggle');
+ const branchToggle = children.querySelector(':scope > .ldp-nested-branch-toggle');
+ if (railToggle) {
+ const railLabel = `点击收起 ${totalCount} 条回复`;
+ setLabel(railToggle, railLabel);
+ if (branchToggle) branchToggle.dataset.ldpTooltipLabel = railLabel;
+ }
+ if (renderedCount && post.classList.contains('ldp-reply') && !post._ldpPostData?.hidden) {
+ setNestedReplyCollapsed(post, false);
+ }
+ if (!totalCount) {
+ // Settled empty: tear down phantom "展开 1 条回复" chrome.
+ if (state && state.hasMore === false) {
+ clearEmptyChildReplyShell(post, ctx);
+ return;
+ }
+ btn.classList.remove('show');
+ post.classList.remove('ldp-has-child-branches');
+ post.classList.remove('ldp-nested-branches-ready');
+ clearCombinedParentPreviewLine(post);
+ delete btn.dataset.replyControlKey;
+ return;
+ }
+ const showBranches = !!renderedCount && !collapsed;
+ post.classList.toggle('ldp-has-child-branches', showBranches);
+ if (!showBranches) {
+ post.classList.remove('ldp-nested-branches-ready');
+ clearCombinedParentPreviewLine(post);
+ }
+ btn.classList.add('show');
+ const controlKey = `${collapsed ? 'collapsed' : 'open'}:${totalCount}`;
+ if (btn.dataset.replyControlKey !== controlKey) {
+ btn.dataset.replyControlKey = controlKey;
+ setExpanded(btn, collapsed ? 'false' : 'true');
+ btn.innerHTML = `${icon(collapsed ? 'chevronRight' : 'chevronDown')}
${collapsed ? `展开 ${totalCount} 条回复` : `收起 ${totalCount} 条回复`}`;
+ }
+ if (!collapsed) syncNestedReplyBranches(children);
+ }
+
+ function setChildRepliesCollapsed(post, collapsed) {
+ if (!post) return;
+ post.classList.toggle('ldp-children-collapsed', !!collapsed);
+ syncChildControls(post);
+ }
+
+ /* ============ 7. 渲染单条 ============ */
+ function childReplyShellHtml(total) {
+ return `
+
+
+
+
+
+
+
+
+
+
+
正在加载回复…
`;
+ }
+
+ function readerPostIsRead(post, ctx) {
+ if (!post) return false;
+ const postNumber = +(post.post_number || 0);
+ if (post.read === true || ctx?.confirmedReadPostNumbers?.has(postNumber) ||
+ ctx?.optimisticReadPostNumbers?.has(postNumber)) return true;
+ const lastReadPostNumber = +(ctx?.topicData && ctx.topicData.last_read_post_number || 0);
+ return postNumber > 0 && lastReadPostNumber >= postNumber;
+ }
+
+ function postReadStateIconHtml(isRead) {
+ return isRead
+ ? icon('check', 'ldp-post-read-state-icon')
+ : '
';
+ }
+
+ function readerPostNodeInViewport(postNode, scrollRoot) {
+ if (!postNode || !postNode.isConnected || !postNode.getClientRects().length) return false;
+ const rect = postNode.getBoundingClientRect();
+ const viewport = scrollRoot?.getBoundingClientRect
+ ? scrollRoot.getBoundingClientRect()
+ : { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 };
+ return rect.bottom > viewport.top && rect.top < viewport.bottom &&
+ rect.right > viewport.left && rect.left < viewport.right;
+ }
+
+ function syncPostReadState(postNode, isRead, options = {}) {
+ if (!postNode) return;
+ const marker = postNode.querySelector(':scope > .ldp-post-head > .ldp-post-read-state');
+ if (!marker) return;
+ const state = isRead ? 'read' : 'unread';
+ if (postNode._ldpPostData && options.updatePostData !== false)
+ postNode._ldpPostData.read = !!isRead;
+ if (marker.dataset.readState === state || marker.dataset.readTransitionState === state) return;
+ const label = isRead ? '该楼层已读' : '该楼层未读';
+ const cancelTransition = () => {
+ if (marker._ldpReadStateSwapTimer) clearTimeout(marker._ldpReadStateSwapTimer);
+ marker._ldpReadStateSwapTimer = 0;
+ if (marker._ldpReadStateTimer) clearTimeout(marker._ldpReadStateTimer);
+ marker._ldpReadStateTimer = 0;
+ if (marker._ldpReadStateFinish) {
+ marker.removeEventListener('animationend', marker._ldpReadStateFinish);
+ }
+ marker._ldpReadStateFinish = null;
+ marker.classList.remove('is-confirming');
+ delete marker.dataset.readTransitionState;
+ };
+ const applyVisual = () => {
+ marker.dataset.readState = state;
+ marker.classList.toggle('is-read', !!isRead);
+ marker.classList.toggle('is-unread', !isRead);
+ setLabel(marker, label);
+ marker.dataset.ldpTooltipLabel = label;
+ marker.removeAttribute('title');
+ marker.innerHTML = postReadStateIconHtml(isRead);
+ refreshTooltips(marker);
+ };
+ const applyState = () => {
+ cancelTransition();
+ if (marker.dataset.readState !== state) applyVisual();
+ };
+ const reduceMotion = prefersReducedMotion();
+ if (options.animate === true && isRead && !reduceMotion) {
+ marker.dataset.readTransitionState = state;
+ marker.classList.add('is-confirming');
+ const finish = () => {
+ if (marker._ldpReadStateFinish !== finish) return;
+ applyState();
+ };
+ marker._ldpReadStateFinish = finish;
+ marker.addEventListener('animationend', finish, { once: true });
+ marker._ldpReadStateSwapTimer = setTimeout(() => {
+ if (marker._ldpReadStateFinish !== finish) return;
+ marker._ldpReadStateSwapTimer = 0;
+ applyVisual();
+ }, 625);
+ marker._ldpReadStateTimer = setTimeout(finish, 1450);
+ return;
+ }
+ applyState();
+ }
+
+ function syncConfirmedPostReadStateWhenVisible(node, ctx, options = {}) {
+ if (!node) return;
+ const syncConfirmedState = () => syncPostReadState(node, true, {
+ animate: true,
+ updatePostData: options.updatePostData,
+ });
+ const descendantRoot = node.closest('.ldp-descendant-replies-list');
+ if (readerPostNodeInViewport(node, descendantRoot || ctx?.scrollRoot)) {
+ syncConfirmedState();
+ } else if (descendantRoot) {
+ syncPostReadState(node, true, { updatePostData: options.updatePostData });
+ } else if (ctx?.tracker && isFunction(ctx.tracker.runWhenVisible)) {
+ ctx.tracker.runWhenVisible(node, syncConfirmedState);
+ } else {
+ syncPostReadState(node, true, { updatePostData: options.updatePostData });
+ }
+ }
+
+ function markReaderPostsRead(postNumbers, ctx, options = {}) {
+ const readPostNumbers = new Set((postNumbers || []).map(Number).filter(Boolean));
+ if (!readPostNumbers.size || !ctx) return;
+ const optimistic = options.optimistic === true;
+ if (!ctx.optimisticReadPostNumbers) ctx.optimisticReadPostNumbers = new Set();
+ if (optimistic) {
+ readPostNumbers.forEach((postNumber) => ctx.optimisticReadPostNumbers.add(postNumber));
+ } else {
+ if (!ctx.confirmedReadPostNumbers) ctx.confirmedReadPostNumbers = new Set();
+ readPostNumbers.forEach((postNumber) => {
+ ctx.confirmedReadPostNumbers.add(postNumber);
+ ctx.optimisticReadPostNumbers.delete(postNumber);
+ });
+ }
+ const persistedPosts = new Map();
+ const syncPostData = (post) => {
+ if (optimistic || !post || !readPostNumbers.has(+(post.post_number || 0))) return;
+ post.read = true;
+ if (post.id) persistedPosts.set(+post.id, post);
+ };
+ const syncNode = (node) => {
+ const postNumber = +(node?.dataset && node.dataset.postNumber || 0);
+ if (!readPostNumbers.has(postNumber)) return;
+ const postData = node._ldpPostData;
+ syncPostData(postData);
+ syncConfirmedPostReadStateWhenVisible(node, ctx, { updatePostData: !optimistic });
+ };
+ if (ctx.modal) ctx.modal.querySelectorAll('.ldp-post').forEach(syncNode);
+ if (ctx.streamNodeMap) {
+ ctx.streamNodeMap.forEach((node) => {
+ syncNode(node);
+ if (node?.querySelectorAll) node.querySelectorAll('.ldp-post').forEach(syncNode);
+ });
+ }
+ if (ctx.subReplyState) {
+ ctx.subReplyState.forEach((state) => {
+ if (state?.nodeCache) state.nodeCache.forEach(syncNode);
+ if (state && Array.isArray(state.all)) state.all.forEach(syncPostData);
+ });
+ }
+ if (ctx.directRepliesByParent) {
+ ctx.directRepliesByParent.forEach((replies) => {
+ if (replies) replies.forEach(syncPostData);
+ });
+ }
+ postsFromPayload(ctx.topicData).forEach(syncPostData);
+ if (!persistedPosts.size) return;
+ if (isFunction(ctx.loader?.persistFetchedPosts)) {
+ ctx.loader.persistFetchedPosts([...persistedPosts.values()]);
+ } else if (ctx.topicData) {
+ setCachedTopicData(ctx.topicId, ctx.topicData);
+ }
+ }
+
+ function renderPost(p, isReply, ctx, options = {}) {
+ const avatar = avatarWithFlairHtml(
+ persistentAvatarHtml(
+ p.avatar_template,
+ 48,
+ 'ldp-avatar',
+ p.name || p.username || '?'
+ ),
+ p
+ );
+ const like = readerPostLikeAction(p, ctx);
+ const count = like.count || 0;
+ const acted = !!like.acted;
+ const canAct = !!like.can_act;
+ const isOP = ctx.op && p.username === ctx.op;
+ const isME = ME_USERNAME && p.username === ME_USERNAME;
+ const time = discourseRelativeTime(p.created_at);
+ const exactTime = discourseExactTime(p.created_at);
+ const profileUrl = userProfileUrl(p.username);
+ const userCardAttr = `data-user-card="${escAttr(p.username)}"`;
+ const isTopicStarter = !isReply && !options.nestedPreview && +p.post_number === 1;
+ const hiddenCollapsible = !!p.hidden && !options.nestedPreview;
+ const deferContent = options.deferContent === true;
+ const boostListHtml = deferContent
+ ? (postHasBoostData(p.boosts)
+ ? '
'
+ : '
')
+ : renderBoostList(p.boosts, { canQuickBoost: canShowBoostButton(p), ctx });
+ const contentHtml = deferContent
+ ? `
`
+ : '
';
+ const replyToPostNumber = +(p.reply_to_post_number || 0);
+ const parentFloorHtml = options.showParentJump && isReply && replyToPostNumber
+ ? `
`
+ : '';
+ const currentFloorHtml = options.nestedPreview
+ ? `
`
+ : `
#${p.post_number}`;
+ const deferConfirmedReadAnimation = !!(ctx?.confirmedReadPostNumbers &&
+ ctx.confirmedReadPostNumbers.has(+(p.post_number || 0)));
+ const isRead = deferConfirmedReadAnimation ? false : readerPostIsRead(p, ctx);
+ const readState = isRead ? 'read' : 'unread';
+ const readStateLabel = isRead ? '该楼层已读' : '该楼层未读';
+ const readStateHtml = `
${postReadStateIconHtml(isRead)}`;
+ const collapseNested = !options.forceExpanded &&
+ (hiddenCollapsible || !!options.forceCollapsed);
+ const nestDepth = Math.max(0, Number(options.nestDepth) || 0);
+ const maxInlineDepth = inlineReplyTreeMaxDepth();
+ // Compact nested previews may still host a child shell when the inline tree
+ // depth budget allows it (Scheme A). nestedPreview alone no longer blocks children.
+ const allowChildReplies = !ctx.isPostVoting &&
+ +(p.reply_count || 0) > 0 &&
+ nestDepth < maxInlineDepth &&
+ (!options.nestedPreview || options.allowNestedChildren === true);
+ const childRepliesHtml = allowChildReplies ? childReplyShellHtml(p.reply_count) : '';
+ // Depth 0 follows the existing default; depth >= 2 starts collapsed to limit DOM growth.
+ const collapseChildShell = allowChildReplies && (
+ nestDepth >= 2 ||
+ (nestDepth === 0 && (isTopicStarter || PREFS.expandNestedRepliesByDefault !== true))
+ );
+
+ const node = makeElement('div');
+ node.className = 'ldp-post' + (isReply ? ' ldp-reply ldp-reply-collapsible' : '') +
+ (hiddenCollapsible ? ' ldp-hidden-collapsible' : '') +
+ (options.nestedPreview ? ' ldp-nested-preview' : '') +
+ (collapseNested ? ' ldp-nested-collapsed' : '') +
+ (collapseChildShell ? ' ldp-children-collapsed' : '');
+ node.dataset.postId = p.id;
+ node.dataset.postNumber = p.post_number;
+ node.dataset.username = p.username || '';
+ node.dataset.createdAt = p.created_at || '';
+ node.dataset.ldpContentHydrated = deferContent ? '0' : '1';
+ node.dataset.ldpNestDepth = String(nestDepth);
+ node._ldpPostData = p;
+ node._ldpReaderContext = ctx;
+ if (replyToPostNumber) node.dataset.replyToPostNumber = replyToPostNumber;
+ if (allowChildReplies) node.dataset.childReplyTotal = String(p.reply_count);
+ node.innerHTML = `
+ ${isReply || hiddenCollapsible ? '
' : ''}
+
+ ${avatar ? `
` : ''}
+
${esc(p.name || p.username)}
+ ${isME ? '
ME' : ''}
+
@${esc(p.username)}
+ ${isOP ? '
OP' : ''}
+ ${time ? `
· ${esc(time)}` : ''}
+ ${p.hidden ? '
已隐藏' : ''}
+ ${parentFloorHtml}${currentFloorHtml}${readStateHtml}
+
+ ${contentHtml}
+
+
+
+ ${compactPostActionsHtml(p, ctx)}
+
+
+ ${boostListHtml}
+ ${childRepliesHtml}
+ ${isTopicStarter ? topicFooterActionsHtml(p, ctx) : ''}
+ `;
+ if (deferConfirmedReadAnimation) syncConfirmedPostReadStateWhenVisible(node, ctx);
+ if (!deferContent) {
+ const content = node.querySelector(':scope > .ldp-content');
+ if (content) setPostCookedContent(content, p.cooked, p.link_counts);
+ renderPostPolls(node, ctx);
+ renderPostSpecialContent(node, ctx);
+ }
+ prepareReaderMedia(node);
+ hydratePersistentAvatarImages(node);
+ if (isTopicStarter) { ctx.syncTopicActionRail?.(node); syncTopicActionControls(ctx); }
+ syncPostActionControls(node, ctx);
+ if (isReply || hiddenCollapsible) setNestedReplyCollapsed(node, collapseNested);
+ ctx?.translation?.syncPost(node);
+ return node;
+ }
+
+ /* ============ 8. 回复框 ============ */
+ function lookupDiscourseModule(name) {
+ if (DISCOURSE_MODULE_CACHE.has(name)) return DISCOURSE_MODULE_CACHE.get(name);
+ const hostWindow = HOST_PAGE_WINDOW || window;
+ const broker = hostWindow.moduleBroker;
+ const resolvers = [
+ () => broker && isFunction(broker.lookup) ? broker.lookup(name, true) : null,
+ () => isFunction(hostWindow.require) ? hostWindow.require(name) : null,
+ () => isFunction(hostWindow.requirejs) ? hostWindow.requirejs(name) : null,
+ ];
+ for (const resolve of resolvers) {
+ try {
+ const module = resolve();
+ if (module && Object.keys(module).length) {
+ DISCOURSE_MODULE_CACHE.set(name, module);
+ return module;
+ }
+ } catch {}
+ }
+ return null;
+ }
+
+ function lookupDiscourse(name) {
+ if (DISCOURSE_LOOKUP_CACHE.has(name)) return DISCOURSE_LOOKUP_CACHE.get(name);
+ const discourseUrlModule = lookupDiscourseModule('discourse/lib/url');
+ const discourseUrl = discourseUrlModule?.default;
+ const hostWindow = HOST_PAGE_WINDOW || window;
+ const container = discourseUrl?.container ||
+ hostWindow.Discourse?.__container__ ||
+ hostWindow.Discourse?.container;
+ if (!container || !isFunction(container.lookup)) return null;
+ try {
+ const item = container.lookup(name) || null;
+ if (item) DISCOURSE_LOOKUP_CACHE.set(name, item);
+ return item;
+ } catch {
+ return null;
+ }
+ }
+
+ function guardNativeReaderTimings() {
+ const hostDocument = HOST_PAGE_WINDOW?.document;
+ if (!hostDocument) return false;
+ try {
+ HOST_PAGE_WINDOW.__ldpReaderTimingActive = !!CURRENT_OVERLAY;
+ if (!CURRENT_OVERLAY || HOST_PAGE_WINDOW.__ldpReaderTimingGuardReady ||
+ HOST_PAGE_WINDOW.__ldpReaderTimingInstallTimer) return true;
+ const script = hostDocument.createElement('script');
+ const nonce = hostDocument.querySelector('script[nonce]')?.nonce;
+ if (nonce) script.nonce = nonce;
+ script.textContent = `(() => {
+ const install = () => {
+ const req = window.requirejs || window.require;
+ const url = req?.('discourse/lib/url')?.default;
+ const container = url?.container || window.Discourse?.__container__ || window.Discourse?.container;
+ const screenTrack = container?.lookup?.('service:screen-track');
+ const guard = Symbol.for('ldp.reader.screen-track.guard');
+ if (!screenTrack || screenTrack[guard]) return !!screenTrack;
+ const nativeFlush = screenTrack.flush;
+ const nativeSend = screenTrack.sendNextConsolidatedTiming;
+ if (typeof nativeFlush !== 'function' || typeof nativeSend !== 'function') return false;
+ screenTrack[guard] = true;
+ screenTrack.flush = function (...args) {
+ if (!window.__ldpReaderTimingActive) return nativeFlush.apply(this, args);
+ this._timings?.forEach((_timing, postNumber) => this._timings.set(postNumber, 0));
+ this._topicTime = 0;
+ this._lastFlush = 0;
+ };
+ screenTrack.sendNextConsolidatedTiming = function (...args) {
+ return window.__ldpReaderTimingActive ? Promise.resolve() : nativeSend.apply(this, args);
+ };
+ if (Array.isArray(screenTrack._consolidatedTimings)) screenTrack._consolidatedTimings.length = 0;
+ return true;
+ };
+ if (install()) {
+ window.__ldpReaderTimingGuardReady = true;
+ return;
+ }
+ if (!window.__ldpReaderTimingInstallTimer) window.__ldpReaderTimingInstallTimer = setInterval(() => {
+ if (window.__ldpReaderTimingActive && !install()) return;
+ window.__ldpReaderTimingGuardReady = window.__ldpReaderTimingActive;
+ clearInterval(window.__ldpReaderTimingInstallTimer);
+ window.__ldpReaderTimingInstallTimer = 0;
+ }, 250);
+ })();`;
+ (hostDocument.head || hostDocument.documentElement).appendChild(script);
+ script.remove();
+ return !!(HOST_PAGE_WINDOW.__ldpReaderTimingGuardReady ||
+ HOST_PAGE_WINDOW.__ldpReaderTimingInstallTimer);
+ } catch {
+ return false;
+ }
+ }
+
+ const DISCOURSE_CAPABILITY_MODULES = Object.freeze({
+ boosts: 'discourse/plugins/discourse-boosts/discourse/lib/create-boost',
+ reactions: 'discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction',
+ postVoting: 'discourse/plugins/discourse-post-voting/discourse/lib/post-voting-utilities',
+ });
+ const DISCOURSE_CAPABILITY_SITE_SETTINGS = Object.freeze({
+ boosts: Object.freeze(['discourse_boosts_enabled']),
+ reactions: Object.freeze(['discourse_reactions_enabled']),
+ postVoting: Object.freeze(['post_voting_enabled']),
+ topicVoting: Object.freeze(['voting_enabled']),
+ });
+ const DISCOURSE_CAPABILITY_FIELDS = Object.freeze({
+ boosts: Object.freeze({
+ post: Object.freeze(['can_boost', 'boosts']),
+ }),
+ reactions: Object.freeze({
+ post: Object.freeze(['reactions', 'current_user_reaction', 'reaction_users_count']),
+ topic: Object.freeze(['valid_reactions']),
+ }),
+ postVoting: Object.freeze({
+ post: Object.freeze([
+ 'post_voting_vote_count',
+ 'post_voting_user_voted_direction',
+ 'post_voting_has_votes',
+ 'comments_count',
+ 'comments',
+ ]),
+ topic: Object.freeze(['is_post_voting']),
+ }),
+ topicVoting: Object.freeze({
+ topic: Object.freeze(['can_vote', 'user_voted', 'vote_count']),
+ }),
+ categoryExperts: Object.freeze({
+ user: Object.freeze(['category_expert_endorsements']),
+ }),
+ follows: Object.freeze({
+ user: Object.freeze([
+ 'can_follow',
+ 'is_followed',
+ 'total_following',
+ 'total_followers',
+ 'following_count',
+ 'follower_count',
+ ]),
+ }),
+ sharedIssue: Object.freeze({
+ topic: Object.freeze([
+ 'shared_issue_visible',
+ 'shared_issue_count',
+ 'user_created_shared_issue',
+ ]),
+ }),
+ });
+ const DISCOURSE_CAPABILITY_NAMES = Object.freeze([
+ 'boosts',
+ 'reactions',
+ 'postVoting',
+ 'topicVoting',
+ 'categoryExperts',
+ 'follows',
+ 'sharedIssue',
+ 'connect',
+ ]);
+
+ function objectHasAnyOwnField(value, fields) {
+ return !!value && Array.isArray(fields) && fields.some((field) => Object.hasOwn(value, field));
+ }
+
+ function hasDiscourseCapability(name, context = {}) {
+ const override = SITE_ADAPTER.capabilities?.[name];
+ if (override === true || override === false) return override;
+ const fields = DISCOURSE_CAPABILITY_FIELDS[name] || {};
+ if (objectHasAnyOwnField(context.post, fields.post) ||
+ objectHasAnyOwnField(context.topic, fields.topic) ||
+ objectHasAnyOwnField(context.user, fields.user))
+ return true;
+ const siteSettings = lookupDiscourse('service:site-settings');
+ const settingFields = DISCOURSE_CAPABILITY_SITE_SETTINGS[name] || [];
+ const declaredSettings = settingFields.filter((field) => Object.hasOwn(siteSettings || {}, field));
+ if (declaredSettings.length)
+ return declaredSettings.some((field) => siteSettings[field] === true);
+ const moduleName = DISCOURSE_CAPABILITY_MODULES[name];
+ return !!(moduleName && lookupDiscourseModule(moduleName));
+ }
+
+ function isDiscourseHostDetected() {
+ if (lookupDiscourseModule('discourse/lib/url')) return true;
+ if (document.querySelector('meta[name="generator"][content*="Discourse" i],meta[name="discourse_theme_id"],#data-preloaded'))
+ return true;
+ return !!document.querySelector('#ember-app .d-header,#ember-app .topic-list,#ember-app .topic-post');
+ }
+
+ function waitForDiscourseHost(timeout = 15000) {
+ if (isDiscourseHostDetected()) return Promise.resolve(true);
+ return new Promise((resolve) => {
+ let settled = false;
+ let observer = null;
+ let timer = 0;
+ const finish = (detected) => {
+ if (settled) return;
+ settled = true;
+ if (timer) clearTimeout(timer);
+ observer?.disconnect();
+ window.removeEventListener('load', check);
+ resolve(detected);
+ };
+ const check = () => {
+ if (isDiscourseHostDetected()) finish(true);
+ };
+ observer = new MutationObserver(check);
+ observer.observe(document.documentElement, { childList: true, subtree: true });
+ window.addEventListener('load', check);
+ timer = setTimeout(() => finish(isDiscourseHostDetected()), timeout);
+ });
+ }
+
+ function updateDiscourseCapabilityDiagnostics(context = {}) {
+ const generator = document.querySelector('meta[name="generator"]')?.content || '';
+ const version = String(generator).match(/Discourse\s+([^\s]+)/i)?.[1] || '';
+ PAGE_ROOT.dataset.ldpSiteAdapter = SITE_ADAPTER.id;
+ if (version) PAGE_ROOT.dataset.ldpDiscourseVersion = version;
+ PAGE_ROOT.dataset.ldpCapabilities = DISCOURSE_CAPABILITY_NAMES
+ .filter((name) => hasDiscourseCapability(name, context))
+ .join(',');
+ }
+
+ function navigateDiscourseHostRoute(href) {
+ let route;
+ try {
+ const url = new URL(String(href || ''), location.href);
+ if (url.origin !== location.origin) return false;
+ route = `${url.pathname}${url.search}${url.hash}`;
+ } catch {
+ return false;
+ }
+ const discourseUrlModule = lookupDiscourseModule('discourse/lib/url');
+ const discourseUrl = discourseUrlModule?.default;
+ if (!discourseUrl || !isFunction(discourseUrl.routeTo)) return false;
+ try {
+ const transition = discourseUrl.routeTo(route);
+ if (isFunction(transition?.catch)) transition.catch(() => {});
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ function openEmbeddedReaderLinkInHost(event, link, readerWorkspace) {
+ if (!link || !readerWorkspace || !readerWorkspace.isEmbedded() || link.hasAttribute('download') || link.matches('a.lightbox,.ldp-open') ||
+ event.button && event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false;
+ let url;
+ try {
+ url = new URL(link.getAttribute('href') || link.href || '', location.href);
+ } catch {
+ return false;
+ }
+ if (!/^https?:$/i.test(url.protocol) || url.origin !== location.origin) return false;
+ url.searchParams.delete(NATIVE_BYPASS_PARAM);
+ const topicRoute = extractTopicRouteFromUrl(url.href);
+ if (topicRoute) return false;
+ const previousSuppressedTopicId = AUTO_OPEN_SUPPRESSED_TOPIC_ID;
+ consumeEvent(event);
+ if (navigateDiscourseHostRoute(url.href)) return true;
+ AUTO_OPEN_SUPPRESSED_TOPIC_ID = previousSuppressedTopicId;
+ showSelectionToast('LINUX DO 站内导航暂不可用,请稍后重试');
+ return true;
+ }
+
+ function refreshDiscourseHostRoute() {
+ const router = lookupDiscourse('service:router');
+ if (!router || !isFunction(router.refresh)) return false;
+ try {
+ const transition = router.refresh();
+ if (isFunction(transition?.catch)) {
+ transition.catch(() => {});
+ }
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ function getUserCardUsername(link) {
+ const username = link && (link.dataset.userCard || link.getAttribute('data-user-card'));
+ return cleanUsernameValue(username);
+ }
+
+ async function openDirectPrivateMessage(username, ctx) {
+ const href = `/new-message?username=${encodeURIComponent(username)}`;
+ if (!navigateDiscourseHostRoute(href)) return false;
+ if (!await waitForNativeComposerPopup(ctx)) return false;
+ return !!(ctx?.nativeComposerWindow
+ && ctx.nativeComposerWindow.open(document.querySelector('#reply-control')));
+ }
+
+ async function openReaderEndorsementDialog(ctx, user) {
+ const username = String(user?.username || '').trim();
+ if (!ctx || !username || !hasDiscourseCapability('categoryExperts', { user })) return false;
+ const data = await fetchJSON(`${BASE}/category-experts/endorsable-categories/${encodeURIComponent(username)}.json`, {
+ key: `user-endorsements:${username}`,
+ priority: POST_REQUEST_PRIORITY.userCard,
+ });
+ const categories = Array.isArray(data?.categories) ? data.categories : [];
+ const remaining = Number(data?.extras && data.extras.remaining_endorsements);
+ const existingIds = new Set((Array.isArray(user.category_expert_endorsements)
+ ? user.category_expert_endorsements
+ : []).map((item) => Number(item?.category_id)).filter(Number.isFinite));
+ if (!categories.length) throw new Error('当前没有可认可的类别');
+ const optionsHtml = categories.map((category) => {
+ const categoryId = Number(category?.id);
+ const existing = existingIds.has(categoryId);
+ return `
+
+ ${esc(category?.name || `类别 ${categoryId}`)}
+ ${existing ? '已经认可' : '选择后将认可该用户为此类别的专家'}
+ `;
+ }).join('');
+ return !!openReaderActionDialog(ctx, {
+ title: `认可 @${username}`,
+ intro: Number.isFinite(remaining) ? `今天还可新增 ${remaining} 次认可。` : '选择要认可的专家类别。',
+ submitLabel: '确认认可',
+ initialFocus: 'input[name="category_id"]:not(:disabled)',
+ bodyHtml: `
${optionsHtml}
`,
+ onSubmit: async (formData) => {
+ if (Number.isFinite(remaining) && remaining < 1) throw new Error('今天的认可次数已用完');
+ const addedIds = formData.getAll('category_id').map(Number)
+ .filter((categoryId) => Number.isFinite(categoryId) && !existingIds.has(categoryId));
+ if (!addedIds.length) throw new Error('请选择一个尚未认可的类别');
+ const categoryIds = [...new Set([...existingIds, ...addedIds])];
+ const response = await apiSend(
+ `${BASE}/category-experts/endorse/${encodeURIComponent(username)}.json`,
+ 'PUT',
+ { categoryIds }
+ );
+ user.category_expert_endorsements = Array.isArray(response?.category_expert_endorsements)
+ ? response.category_expert_endorsements
+ : user.category_expert_endorsements;
+ setCachedUserCard(username, user, 0);
+ return '认可已提交';
+ },
+ });
+ }
+
+ function userNotificationLevel(user) {
+ if (user?.ignored) return 'ignore';
+ if (user?.muted) return 'mute';
+ return 'normal';
+ }
+
+ const USER_NOTIFICATION_LEVEL_LABELS = {
+ normal: '常规',
+ mute: '免打扰',
+ ignore: '忽略',
+ };
+
+ async function saveReaderUserNotificationLevel(username, user, level, extraParams = {}) {
+ if (!username || !Object.hasOwn(USER_NOTIFICATION_LEVEL_LABELS, level)) {
+ throw new Error('无效的消息设置');
+ }
+ const store = lookupDiscourse('service:store');
+ const currentUser = lookupDiscourse('service:current-user');
+ if (!store || !isFunction(store.createRecord) || !currentUser) {
+ throw new Error('Discourse 用户服务尚未就绪');
+ }
+ const userModel = store.createRecord('user', { username });
+ if (!userModel || !isFunction(userModel.updateNotificationLevel)) {
+ throw new Error('Discourse 用户消息设置接口尚未就绪');
+ }
+ await userModel.updateNotificationLevel({
+ level,
+ expiringAt: extraParams.expiring_at || null,
+ actingUser: currentUser,
+ });
+ if (user) {
+ user.muted = level === 'mute';
+ user.ignored = level === 'ignore';
+ setCachedUserCard(username, user, 0);
+ }
+ return USER_NOTIFICATION_LEVEL_LABELS[level];
+ }
+
+ function openReaderIgnoreDurationDialog(ctx, username, user) {
+ const durations = [
+ { days: 1, label: '1 天' },
+ { days: 7, label: '1 周' },
+ { days: 30, label: '1 个月' },
+ { days: 120, label: '4 个月' },
+ { days: 365, label: '1 年' },
+ ];
+ return openReaderActionDialog(ctx, {
+ title: `忽略 @${username}`,
+ intro: 'Discourse 要求为“忽略”设置截止时间;到期后会自动恢复为常规。',
+ submitLabel: '确认忽略',
+ initialFocus: 'select[name="ignore_days"]',
+ bodyHtml: `
忽略期限
+
+ `,
+ onSubmit: async (formData) => {
+ const days = Number(formData.get('ignore_days'));
+ if (!durations.some((item) => item.days === days)) throw new Error('请选择有效的忽略期限');
+ const expiringAt = new Date(Date.now() + days * 86400000).toISOString();
+ await saveReaderUserNotificationLevel(username, user, 'ignore', { expiring_at: expiringAt });
+ return `已忽略 @${username}`;
+ },
+ });
+ }
+
+ async function runDirectUserCardAction(action, username, user, ctx) {
+ if (!username || !['message', 'endorse', 'follow'].includes(action))
+ return { ok: false, reason: 'invalid-action' };
+ try {
+ if (action === 'message') {
+ const opened = await openDirectPrivateMessage(username, ctx);
+ return { ok: opened, reason: opened ? '' : 'route-unavailable' };
+ }
+ if (action === 'endorse') {
+ const opened = await openReaderEndorsementDialog(ctx, user || { username });
+ return { ok: opened, reason: opened ? '' : 'endorsement-unavailable' };
+ }
+ if (action === 'follow') {
+ if (!hasDiscourseCapability('follows', { user }))
+ return { ok: false, reason: 'follow-unavailable' };
+ if (user && user.can_follow === false && !user.is_followed)
+ return { ok: false, reason: 'follow-not-allowed' };
+ const followed = !!(user?.is_followed);
+ await apiSend(`${BASE}/follow/${encodeURIComponent(username)}.json`, followed ? 'DELETE' : 'PUT');
+ if (user) {
+ const totalFollowers = Number(user.total_followers);
+ user.is_followed = !followed;
+ if (Number.isFinite(totalFollowers)) user.total_followers = Math.max(0, totalFollowers + (followed ? -1 : 1));
+ setCachedUserCard(username, user, 0);
+ }
+ invalidateUserFollowList(username, 'followers');
+ invalidateUserFollowList(ME_USERNAME, 'following');
+ return { ok: true, reason: '', keepOpen: true, followed: !followed };
+ }
+ } catch (error) {
+ console.warn('[LINUX DO reader] direct user card action failed', { action, error });
+ return { ok: false, reason: 'action-failed', error };
+ }
+ }
+
+ function emitUserCardUpdate(entry, user, phase, skipListener = null) {
+ if (!entry || !entry.listeners) return;
+ entry.listeners.forEach((listener) => {
+ if (listener === skipListener) return;
+ try { listener(user, phase); } catch {}
+ });
+ }
+
+ function userTrustLevel(user) {
+ const rawLevel = user?.trust_level;
+ const direct = Number(rawLevel);
+ if (rawLevel != null && rawLevel !== '' && Number.isFinite(direct)) return direct;
+ const labels = [
+ user?.title,
+ user?.flair_name,
+ user?.primary_group_name,
+ ...(Array.isArray(user?.groups)
+ ? user.groups.flatMap((group) => [group?.name, group?.full_name, group?.title])
+ : []),
+ ];
+ for (const label of labels) {
+ const match = String(label || '').match(/(?:^|[,,\s])lv\s*([0-9]+)(?:\b|$)/i);
+ if (match) return Number(match[1]);
+ }
+ return null;
+ }
+
+ function userDetailsChecked(user, detail) {
+ const checkedAt = Number(user?.[`_ldp_${detail}_checked_at`] || 0);
+ return checkedAt > 0 && Date.now() - checkedAt < USER_CARD_CACHE_FRESH_FOR;
+ }
+
+ function fetchUserJSON(path, key, options = null) {
+ const requestOptions = { key, priority: POST_REQUEST_PRIORITY.userCard };
+ if (options) Object.assign(requestOptions, {
+ cacheMode: options.cacheMode,
+ allowStaleOnError: options.allowStaleOnError,
+ });
+ return fetchJSON(`${BASE}${path}`, requestOptions);
+ }
+
+ async function fetchUserProfileDetails(username, options = {}) {
+ const User = lookupDiscourseModule('discourse/models/user')?.default;
+ if (!User || !isFunction(User.findByUsername)) {
+ throw new Error('Discourse 用户模型尚未就绪');
+ }
+ try {
+ return await User.findByUsername(username, {
+ existingRequest: fetchUserJSON(
+ `/u/${encodeURIComponent(username)}.json`,
+ `user-profile:${username}`,
+ options
+ ),
+ });
+ } catch (error) {
+ if (shouldStopReaderFallback(error)) throw error;
+ return User.findByUsername(username, {
+ forCard: true,
+ existingRequest: fetchUserJSON(
+ `/u/${encodeURIComponent(username)}/card.json`,
+ `user-card:${username}`,
+ options
+ ),
+ });
+ }
+ }
+
+ function uniqueUserBadges(source) {
+ const unique = new Map();
+ (Array.isArray(source) ? source : []).forEach((badge) => {
+ if (!badge) return;
+ const badgeId = Number(badge.id || badge.badge_id) || 0;
+ const name = String(badge.name || '').trim();
+ const key = badgeId ? `id:${badgeId}` : `name:${name.toLocaleLowerCase()}`;
+ if (!badgeId && !name) return;
+ const previous = unique.get(key);
+ const grantedAt = Date.parse(badge.granted_at || '') || 0;
+ const previousGrantedAt = Date.parse(previous?.granted_at || '') || 0;
+ if (!previous || grantedAt >= previousGrantedAt) unique.set(key, badge);
+ });
+ return [...unique.values()];
+ }
+
+ async function fetchUserBadges(username) {
+ const data = await fetchUserJSON(`/user-badges/${encodeURIComponent(username)}.json`, `user-badges:${username}`);
+ const badgesById = new Map((Array.isArray(data?.badges) ? data.badges : [])
+ .map((badge) => [Number(badge?.id), badge]));
+ return uniqueUserBadges((Array.isArray(data?.user_badges) ? data.user_badges : []).map((grant) => {
+ const badge = badgesById.get(Number(grant?.badge_id)) || {};
+ return Object.assign({}, badge, grant, {
+ id: Number(grant?.badge_id || badge.id) || 0,
+ name: String(badge.name || grant?.name || '').trim(),
+ });
+ }).filter((badge) => badge.id && badge.name && badge.enabled !== false));
+ }
+
+ function normalizeUserFollowList(data, type) {
+ const source = Array.isArray(data)
+ ? data
+ : Array.isArray(data?.users)
+ ? data.users
+ : Array.isArray(data?.[type])
+ ? data[type]
+ : [];
+ return source.map((entry) => {
+ const user = entry && (entry.user || entry);
+ const username = cleanUsernameValue(user?.username);
+ if (!username) return null;
+ const name = String(user.name || username).trim();
+ return {
+ username,
+ name,
+ avatar_template: String(user.avatar_template || ''),
+ flair_name: user.flair_name,
+ flair_url: user.flair_url,
+ flair_bg_color: user.flair_bg_color,
+ flair_color: user.flair_color,
+ searchText: pinyinSearchForms(`${username} ${name}`).join(' '),
+ };
+ }).filter(Boolean);
+ }
+
+ function cachedUserFollowList(key) {
+ const cached = USER_FOLLOW_LIST_CACHE.get(key);
+ if (!cached || Date.now() - cached.at > USER_FOLLOW_LIST_CACHE_FRESH_FOR) {
+ USER_FOLLOW_LIST_CACHE.delete(key);
+ return null;
+ }
+ USER_FOLLOW_LIST_CACHE.delete(key);
+ USER_FOLLOW_LIST_CACHE.set(key, cached);
+ return cached.users;
+ }
+
+ function invalidateUserFollowList(username, type) {
+ username = cleanUsernameValue(username);
+ if (!username || !['following', 'followers'].includes(type)) return;
+ USER_FOLLOW_LIST_CACHE.delete(authScopedCacheKey(`${username}:${type}`));
+ const policy = responseCachePolicy(`${BASE}/u/${encodeURIComponent(username)}/follow/${type}`);
+ if (policy) void deleteStoredResponseCacheEntry(policy.id);
+ }
+
+ async function fetchUserFollowList(username, type) {
+ username = cleanUsernameValue(username);
+ if (!username || !['following', 'followers'].includes(type)) return [];
+ const cacheKey = authScopedCacheKey(`${username}:${type}`);
+ const cached = cachedUserFollowList(cacheKey);
+ if (cached) return cached;
+ const pendingRequest = USER_FOLLOW_LIST_REQUESTS.get(cacheKey);
+ if (pendingRequest) return pendingRequest;
+ const request = fetchUserJSON(
+ `/u/${encodeURIComponent(username)}/follow/${type}`,
+ `user-${type}:${username}`
+ ).then((data) => {
+ const users = normalizeUserFollowList(data, type);
+ USER_FOLLOW_LIST_CACHE.delete(cacheKey);
+ USER_FOLLOW_LIST_CACHE.set(cacheKey, { users, at: Date.now() });
+ while (USER_FOLLOW_LIST_CACHE.size > USER_FOLLOW_LIST_CACHE_MAX_ENTRIES) {
+ USER_FOLLOW_LIST_CACHE.delete(USER_FOLLOW_LIST_CACHE.keys().next().value);
+ }
+ return users;
+ }).finally(() => USER_FOLLOW_LIST_REQUESTS.delete(cacheKey));
+ USER_FOLLOW_LIST_REQUESTS.set(cacheKey, request);
+ return request;
+ }
+
+ async function fetchUserCard(username, onUpdate) {
+ username = cleanUsernameValue(username);
+ if (!username) return null;
+ const existing = USER_CARD_REQUESTS.get(username);
+ if (existing && !existing.cancelled) {
+ if (isFunction(onUpdate)) {
+ existing.listeners.add(onUpdate);
+ if (existing.basic) onUpdate(existing.basic, 'stats');
+ }
+ return existing.promise;
+ }
+ if (existing) USER_CARD_REQUESTS.delete(username);
+ const cached = getCachedUserCard(username);
+ const profileChecked = cached && userDetailsChecked(cached, 'profile');
+ const summaryChecked = cached && userDetailsChecked(cached, 'summary');
+ const badgesChecked = cached && userDetailsChecked(cached, 'badges');
+ if (cached && profileChecked && summaryChecked && badgesChecked) {
+ if (isFunction(onUpdate)) onUpdate(cached, 'ready');
+ return cached;
+ }
+ if (cached && isFunction(onUpdate)) onUpdate(cached, 'stats');
+ const cachedStatsListener = cached && profileChecked ? onUpdate : null;
+ const profilePromise = profileChecked
+ ? Promise.resolve(cached)
+ : fetchUserProfileDetails(username);
+ const summaryPromise = summaryChecked
+ ? Promise.resolve(cached.user_summary)
+ : fetchUserSummary(username);
+ const badgesPromise = badgesChecked
+ ? Promise.resolve(Array.isArray(cached.user_badges) ? cached.user_badges : [])
+ : fetchUserBadges(username);
+ const supplementalPromise = Promise.allSettled([summaryPromise, badgesPromise]);
+ const entry = { listeners: new Set(), basic: null, promise: null, cancelled: false };
+ if (isFunction(onUpdate)) entry.listeners.add(onUpdate);
+ const request = (async () => {
+ const profile = await profilePromise;
+ const user = Object.assign({}, cached || {}, profile || {}, {
+ _ldp_profile_checked_at: profileChecked
+ ? cached._ldp_profile_checked_at
+ : Date.now(),
+ });
+ entry.basic = user;
+ setCachedUserCard(username, user);
+ emitUserCardUpdate(entry, user, 'stats', cachedStatsListener);
+ if (entry.cancelled) return user;
+ const [summaryResult, badgesResult] = await supplementalPromise;
+ if (badgesResult.status === 'fulfilled')
+ user.user_badges = badgesResult.value;
+ if (summaryResult.status === 'rejected') throw summaryResult.reason;
+ user.user_summary = summaryResult.value;
+ user._ldp_summary_checked_at = Date.now();
+ setCachedUserCard(username, user, 0);
+ emitUserCardUpdate(entry, user, 'ready');
+ return user;
+ })().finally(() => {
+ if (USER_CARD_REQUESTS.get(username) === entry) USER_CARD_REQUESTS.delete(username);
+ });
+ entry.promise = request;
+ USER_CARD_REQUESTS.set(username, entry);
+ return request;
+ }
+
+ function cancelQueuedUserCardRequests(username, scheduler = ACTIVE_READER_REQUEST_SCHEDULER) {
+ username = cleanUsernameValue(username);
+ if (!username) return;
+ const entry = USER_CARD_REQUESTS.get(username);
+ if (entry) entry.cancelled = true;
+ if (!scheduler) return;
+ [
+ `user-profile:${username}`,
+ `user-card:${username}`,
+ `user-summary:${username}`,
+ `user-directory:${username}`,
+ `user-badges:${username}`,
+ `user-following:${username}`,
+ `user-followers:${username}`,
+ ].forEach((key) => scheduler.cancelQueued(key));
+ }
+
+ async function fetchUserSummary(username, options = {}) {
+ username = cleanUsernameValue(username);
+ if (!username) return null;
+ let summary = null;
+ try {
+ const data = await fetchUserJSON(`/u/${encodeURIComponent(username)}/summary.json`, `user-summary:${username}`, options);
+ summary = data && (data.user_summary || data.summary || data);
+ } catch (e) {
+ if (shouldStopReaderFallback(e)) throw e;
+ summary = null;
+ }
+ const hasStats = summary && ['post_count', 'posts_count', 'likes_received', 'likes_given', 'topic_count', 'topics_entered']
+ .some((key) => summary[key] != null && summary[key] !== '');
+ const directoryStats = hasStats ? null : await fetchUserDirectoryStats(username, options);
+ return Object.assign({}, directoryStats || {}, summary || {});
+ }
+
+ async function fetchUserDirectoryStats(username, options = {}) {
+ username = cleanUsernameValue(username);
+ if (!username) return null;
+ try {
+ const qs = new URLSearchParams();
+ qs.set('period', 'all');
+ qs.set('order', 'likes_received');
+ qs.set('username', username);
+ const data = await fetchUserJSON(`/directory_items.json?${qs}`, `user-directory:${username}`, options);
+ const item = data && Array.isArray(data.directory_items) && data.directory_items[0];
+ if (!item) return null;
+ return {
+ post_count: item.post_count,
+ likes_received: item.likes_received,
+ likes_given: item.likes_given,
+ topic_count: item.topic_count,
+ };
+ } catch (e) {
+ if (shouldStopReaderFallback(e)) throw e;
+ return null;
+ }
+ }
+
+ async function fetchSettingsUserInfo(username, force = false) {
+ username = cleanUsernameValue(username);
+ if (!username) return null;
+ const cached = getCachedUserCard(username);
+ if (!force && cached && userDetailsChecked(cached, 'profile') && userDetailsChecked(cached, 'summary')) return cached;
+ const requestOptions = force
+ ? { cacheMode: 'refresh', allowStaleOnError: false }
+ : { cacheMode: 'default' };
+ const [profile, summary] = await Promise.all([
+ fetchUserProfileDetails(username, requestOptions),
+ fetchUserSummary(username, requestOptions),
+ ]);
+ const checkedAt = Date.now();
+ const user = Object.assign({}, cached || {}, profile || {}, {
+ username,
+ user_summary: summary,
+ _ldp_profile_checked_at: checkedAt,
+ _ldp_summary_checked_at: checkedAt,
+ _ldp_user_info_checked_at: checkedAt,
+ });
+ setCachedUserCard(username, user, 0);
+ return user;
+ }
+
+ function getUserCardSurface(preview = false) {
+ const previewClass = 'ldp-user-card-follow-preview';
+ let card = readerPortalQuery(preview
+ ? `.${previewClass}`
+ : `.ldp-user-card-fallback:not(.${previewClass})`);
+ if (!card) {
+ card = makeElement('div');
+ card.className = `ldp-user-card-fallback${preview ? ` ${previewClass}` : ''}`;
+ appendReaderPortalNode(card);
+ }
+ return card;
+ }
+
+ function getUserCardFollowPanel() {
+ let panel = readerPortalQuery('.ldp-user-card-follow-panel');
+ if (panel) return panel;
+ panel = makeElement('section');
+ panel.id = 'ldp-user-card-follow-panel';
+ panel.className = 'ldp-user-card-follow-panel';
+ setLabel(panel, '关注人员列表');
+ panel.hidden = true;
+ panel.innerHTML = `
+
+
+
检索
+
+
+
+
+ `;
+ appendReaderPortalNode(panel);
+ return panel;
+ }
+
+ function hideFallbackUserCard() {
+ const card = readerPortalQuery('.ldp-user-card-fallback:not(.ldp-user-card-follow-preview)');
+ if (card) card.classList.remove('open');
+ }
+
+ function fallbackUserCardOpen() {
+ return !!readerPortalQuery('.ldp-user-card-fallback.open:not(.ldp-user-card-follow-preview)');
+ }
+
+ function positionFallbackUserCardNotificationMenu(card) {
+ const menu = card?.querySelector('.ldp-user-card-notification-menu:not([hidden])');
+ const actions = card?.querySelector('.ldp-user-card-actions');
+ if (!menu || !actions) return;
+ const margin = 10;
+ const gap = 6;
+ const rect = actions.getBoundingClientRect();
+ const width = Math.min(rect.width, window.innerWidth - margin * 2);
+ menu.style.width = `${Math.round(width)}px`;
+ const height = menu.offsetHeight;
+ const spaceAbove = rect.top - margin - gap;
+ const spaceBelow = window.innerHeight - rect.bottom - margin - gap;
+ const opensAbove = spaceBelow < height && spaceAbove > spaceBelow;
+ const preferredTop = opensAbove ? rect.top - height - gap : rect.bottom + gap;
+ const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin));
+ const top = Math.max(margin, Math.min(preferredTop, window.innerHeight - height - margin));
+ menu.style.left = `${Math.round(left)}px`;
+ menu.style.top = `${Math.round(top)}px`;
+ }
+
+ function positionFallbackUserCard(card, anchor) {
+ card.style.removeProperty('max-height');
+ const rect = anchor.getBoundingClientRect();
+ const margin = 10;
+ const width = card.offsetWidth || 300;
+ const height = Math.min(card.offsetHeight || 220, window.innerHeight - margin * 2);
+ const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin));
+ let top = rect.bottom + 8;
+ if (top + height > window.innerHeight - margin) top = Math.max(margin, rect.top - height - 8);
+ card.style.left = `${Math.round(left)}px`;
+ card.style.top = `${Math.round(top)}px`;
+ positionFallbackUserCardNotificationMenu(card);
+ }
+
+ function positionUserCardFollowPanel(panel, card) {
+ if (!panel || panel.hidden || !card) return;
+ const margin = 10;
+ const gap = 8;
+ panel.style.removeProperty('max-height');
+ card.style.removeProperty('max-height');
+ const cardRect = card.getBoundingClientRect();
+ const width = panel.offsetWidth || 320;
+ const height = Math.min(panel.offsetHeight || 220, window.innerHeight - margin * 2);
+ const right = cardRect.right + gap;
+ const left = cardRect.left - width - gap;
+ const preferredLeft = right + width <= window.innerWidth - margin ? right : left;
+ const fallbackLeft = Math.max(margin, Math.min(preferredLeft, window.innerWidth - width - margin));
+ const fallbackTop = Math.max(margin, Math.min(cardRect.top, window.innerHeight - height - margin));
+ const avatarViewer = readerPortalQuery('.ldp-avatar-viewer');
+ const viewerRect = avatarViewer?.isConnected && avatarViewer._ldpAnchorCard === card
+ ? avatarViewer.getBoundingClientRect()
+ : null;
+ const overlaps = (first, second) => first.left < second.right + gap && first.right + gap > second.left &&
+ first.top < second.bottom + gap && first.bottom + gap > second.top;
+ const fallbackRect = {
+ left: fallbackLeft,
+ top: fallbackTop,
+ right: fallbackLeft + width,
+ bottom: fallbackTop + height,
+ };
+ if (!viewerRect || !overlaps(fallbackRect, viewerRect)) {
+ panel.style.left = `${Math.round(fallbackLeft)}px`;
+ panel.style.top = `${Math.round(fallbackTop)}px`;
+ return;
+ }
+
+ const minimumPanelHeight = Math.min(height, 150);
+ const freeRegions = [
+ { left: margin, top: margin, right: viewerRect.left - gap, bottom: window.innerHeight - margin },
+ { left: viewerRect.right + gap, top: margin, right: window.innerWidth - margin, bottom: window.innerHeight - margin },
+ { left: margin, top: margin, right: window.innerWidth - margin, bottom: viewerRect.top - gap },
+ { left: margin, top: viewerRect.bottom + gap, right: window.innerWidth - margin, bottom: window.innerHeight - margin },
+ ].filter((region) => region.right - region.left >= width && region.bottom - region.top >= minimumPanelHeight);
+ const placements = freeRegions.flatMap((region) => {
+ const availableHeight = region.bottom - region.top;
+ const placedHeight = Math.min(height, availableHeight);
+ const candidateLefts = new Set([
+ Math.max(region.left, Math.min(cardRect.left, region.right - width)),
+ region.left,
+ region.right - width,
+ ]);
+ const candidateTops = new Set([
+ Math.max(region.top, Math.min(cardRect.top, region.bottom - placedHeight)),
+ region.top,
+ region.bottom - placedHeight,
+ ]);
+ return [...candidateLefts].flatMap((placedLeft) => [...candidateTops].map((placedTop) => {
+ const rect = {
+ left: placedLeft,
+ top: placedTop,
+ right: placedLeft + width,
+ bottom: placedTop + placedHeight,
+ };
+ return {
+ left: placedLeft,
+ top: placedTop,
+ height: placedHeight,
+ rect,
+ score: Math.abs(placedLeft - fallbackLeft) + Math.abs(placedTop - fallbackTop) + (height - placedHeight) * 3,
+ };
+ }));
+ }).filter((placement) => !overlaps(placement.rect, cardRect))
+ .sort((first, second) => first.score - second.score);
+ if (placements.length) {
+ const placement = placements[0];
+ if (placement.height < height) panel.style.maxHeight = `${Math.floor(placement.height)}px`;
+ panel.style.left = `${Math.round(placement.left)}px`;
+ panel.style.top = `${Math.round(placement.top)}px`;
+ return;
+ }
+
+ const cardWidth = cardRect.width;
+ const cardHeight = Math.min(cardRect.height, window.innerHeight - margin * 2);
+ const minimumCardHeight = Math.min(cardHeight, 180);
+ const stackedRegions = freeRegions.filter((region) =>
+ region.right - region.left >= Math.max(cardWidth, width) &&
+ region.bottom - region.top >= minimumCardHeight + gap + minimumPanelHeight
+ ).map((region) => {
+ const availableHeight = region.bottom - region.top - gap;
+ const desiredTotal = Math.max(1, cardHeight + height);
+ const placedCardHeight = Math.max(minimumCardHeight, Math.min(
+ availableHeight - minimumPanelHeight,
+ Math.round(availableHeight * cardHeight / desiredTotal)
+ ));
+ const placedPanelHeight = Math.min(height, availableHeight - placedCardHeight);
+ const groupHeight = placedCardHeight + gap + placedPanelHeight;
+ const groupTop = Math.max(region.top, Math.min(cardRect.top, region.bottom - groupHeight));
+ const placedCardLeft = Math.max(region.left, Math.min(cardRect.left, region.right - cardWidth));
+ const placedPanelLeft = Math.max(region.left, Math.min(placedCardLeft, region.right - width));
+ return {
+ cardLeft: placedCardLeft,
+ cardTop: groupTop,
+ cardHeight: placedCardHeight,
+ panelLeft: placedPanelLeft,
+ panelTop: groupTop + placedCardHeight + gap,
+ panelHeight: placedPanelHeight,
+ score: Math.abs(placedCardLeft - cardRect.left) + Math.abs(groupTop - cardRect.top) +
+ (cardHeight - placedCardHeight) * 2 + (height - placedPanelHeight) * 2,
+ };
+ }).sort((first, second) => first.score - second.score);
+ if (stackedRegions.length) {
+ const placement = stackedRegions[0];
+ card.style.left = `${Math.round(placement.cardLeft)}px`;
+ card.style.top = `${Math.round(placement.cardTop)}px`;
+ if (placement.cardHeight < cardHeight) card.style.maxHeight = `${Math.floor(placement.cardHeight)}px`;
+ panel.style.left = `${Math.round(placement.panelLeft)}px`;
+ panel.style.top = `${Math.round(placement.panelTop)}px`;
+ if (placement.panelHeight < height) panel.style.maxHeight = `${Math.floor(placement.panelHeight)}px`;
+ positionFallbackUserCardNotificationMenu(card);
+ return;
+ }
+
+ panel.style.left = `${Math.round(fallbackLeft)}px`;
+ panel.style.top = `${Math.round(fallbackTop)}px`;
+ }
+
+ function positionUserCardFollowPreview(preview, panel, card) {
+ if (!preview || !panel || panel.hidden || !card) return;
+ const margin = 10;
+ const gap = 8;
+ const panelRect = panel.getBoundingClientRect();
+ const cardRect = card.getBoundingClientRect();
+ const width = preview.offsetWidth || 320;
+ const height = Math.min(preview.offsetHeight || 220, window.innerHeight - margin * 2);
+ const panelIsRightOfCard = panelRect.left >= cardRect.right;
+ const preferredLeft = panelIsRightOfCard ? panelRect.right + gap : panelRect.left - width - gap;
+ const alternateLeft = panelIsRightOfCard ? panelRect.left - width - gap : panelRect.right + gap;
+ const preferredFits = preferredLeft >= margin && preferredLeft + width <= window.innerWidth - margin;
+ const left = preferredFits ? preferredLeft : alternateLeft;
+ preview.style.left = `${Math.round(Math.max(margin, Math.min(left, window.innerWidth - width - margin)))}px`;
+ preview.style.top = `${Math.round(Math.max(margin, Math.min(panelRect.top, window.innerHeight - height - margin)))}px`;
+ }
+
+ function userStatValue(user, keys) {
+ const sources = [user?.user_summary, user, user?.summary, user?.user_stat, user?.stats].filter(Boolean);
+ for (const key of keys) {
+ for (const source of sources) {
+ const value = source[key];
+ if (value != null && value !== '') return value;
+ }
+ }
+ return null;
+ }
+
+ function userCardStat(value, label, href) {
+ const num = Number(value);
+ const inner = `
${Number.isFinite(num) ? esc(num) : '-'}${esc(label)}`;
+ if (!href) return `
${inner}
`;
+ return `
${inner}`;
+ }
+
+ function isSafeUserCardUrl(value) {
+ const url = String(value || '').trim();
+ return /^(https?:)?\/\//i.test(url) || url.startsWith('/') || url.startsWith('data:image/');
+ }
+
+ function sanitizeUserCardBioHtml(value) {
+ const template = makeElement('template');
+ template.innerHTML = String(value || '');
+ const allowedTags = new Set(['A', 'B', 'BR', 'EM', 'I', 'IMG', 'P', 'SPAN', 'STRONG']);
+ const allowedAttrs = {
+ A: new Set(['href']),
+ IMG: new Set(['src', 'alt', 'class', 'width', 'height']),
+ SPAN: new Set(['class']),
+ };
+ const cleanNode = (node) => {
+ if (node.nodeType === Node.TEXT_NODE) return esc(node.textContent || '');
+ if (node.nodeType !== Node.ELEMENT_NODE) return '';
+ const tag = node.tagName;
+ const children = [...node.childNodes].map(cleanNode).join('');
+ if (!allowedTags.has(tag)) return children;
+ const attrs = [...node.attributes]
+ .filter((attr) => {
+ const name = attr.name.toLowerCase();
+ if (name.startsWith('on') || name === 'style') return false;
+ if (!(allowedAttrs[tag]?.has(name))) return false;
+ if ((name === 'href' || name === 'src') && !isSafeUserCardUrl(attr.value)) return false;
+ return true;
+ })
+ .map((attr) => ` ${attr.name.toLowerCase()}="${escAttr(attr.value)}"`).join('');
+ if (tag === 'A') return `
${children}`;
+ if (tag === 'IMG') return `
![]()
`;
+ return `<${tag.toLowerCase()}${attrs}>${children}${tag.toLowerCase()}>`;
+ };
+ return [...template.content.childNodes].map(cleanNode).join('');
+ }
+
+ function userCardProgressHtml(phase) {
+ if (phase === 'ready') return '';
+ const loadingStats = phase === 'stats';
+ const label = loadingStats ? '资料已获取,正在获取统计…' : '正在获取用户资料…';
+ return `
+ ${label}
+
`;
+ }
+
+ function userRegistrationDate(value) {
+ const date = new Date(value || 0);
+ if (!Number.isFinite(date.getTime())) return '';
+ return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日`;
+ }
+
+ function userRecentDate(value) {
+ const date = new Date(value || 0);
+ if (!Number.isFinite(date.getTime())) return '';
+ const elapsed = Date.now() - date.getTime();
+ if (elapsed >= 0 && elapsed < 60 * 1000) return '刚刚';
+ if (elapsed >= 0 && elapsed < 60 * 60 * 1000) return `${Math.max(1, Math.floor(elapsed / 60000))} 分钟前`;
+ if (elapsed >= 0 && elapsed < 24 * 60 * 60 * 1000) return `${Math.floor(elapsed / 3600000)} 小时前`;
+ if (date.getFullYear() === new Date().getFullYear()) return `${date.getMonth() + 1} 月 ${date.getDate()} 日`;
+ return userRegistrationDate(value);
+ }
+
+ function userVisibleGroups(user, includePrimaryGroup = false) {
+ const visible = new Map();
+ const groups = Array.isArray(user?.groups) ? user.groups : [];
+ [...groups, includePrimaryGroup && user && user.primary_group_name
+ ? { name: user.primary_group_name }
+ : null].forEach((group) => {
+ const name = String(group?.name || '').trim();
+ if (!name || /^trust_level_[0-9]+$/i.test(name)) return;
+ const key = name.toLocaleLowerCase();
+ if (visible.has(key)) return;
+ visible.set(key, {
+ name,
+ label: String(group.full_name || group.display_name || group.title || name).trim() || name,
+ });
+ });
+ return [...visible.values()];
+ }
+
+ function userCardGroupsHtml(user) {
+ const groups = userVisibleGroups(user, true);
+ if (!groups.length) return '';
+ const discourseUrl = lookupDiscourseModule('discourse/lib/url');
+ return `
+
用户分组${groups.map((group) => {
+ const href = isFunction(discourseUrl?.groupPath)
+ ? discourseUrl.groupPath(encodeURIComponent(group.name))
+ : '';
+ return href
+ ? `
${esc(group.label)}`
+ : `
${esc(group.label)}`;
+ }).join('')}
+
`;
+ }
+
+ function userProfileFactsHtml(user, variant, options = {}) {
+ const fact = (label, value, factOptions = {}) => {
+ if (value == null || value === '') return '';
+ const classes = ['ldp-user-profile-fact'];
+ if (factOptions.accent) classes.push('is-accent');
+ if (factOptions.wide) classes.push('is-wide');
+ const tooltip = factOptions.title ? ` data-ldp-tooltip-label="${escAttr(factOptions.title)}"` : '';
+ return `
${esc(label)}${esc(value)}`;
+ };
+ const numberValue = (keys) => {
+ const value = userStatValue(user, keys);
+ if (!options.emptyNumberAsZero && (value == null || value === '')) return '';
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? numeric.toLocaleString('zh-CN') : '';
+ };
+ const trustLabels = ['新用户', '基本用户', '成员', '活跃用户', '领导者'];
+ const trustLevel = Object.hasOwn(options, 'trustLevel') ? options.trustLevel : userTrustLevel(user);
+ const groupText = variant === 'card'
+ ? ''
+ : userVisibleGroups(user, options.includePrimaryGroup).map((group) => group.label).join(', ');
+ const facts = [
+ fact('加入日期:', userRegistrationDate(user?.created_at)),
+ fact('最后一个帖子', userRecentDate(user && (user.last_posted_at || user.last_post_at))),
+ fact('最后活动', userRecentDate(user && (user.last_seen_at || user.last_active_at))),
+ fact('浏览量', numberValue(['profile_view_count', 'profile_views', 'views'])),
+ fact('信任级别', Number.isFinite(trustLevel) ? (trustLabels[trustLevel] || `Lv${trustLevel}`) : ''),
+ fact('电子邮件', user?.email, { wide: true }),
+ ...(variant === 'card' ? [] : [fact('群组', groupText, { accent: true, wide: true, title: options.groupTooltip ? groupText : '' })]),
+ ...(variant === 'card' ? [] : [
+ fact('正在关注', numberValue(['total_following', 'following_count', 'followings_count'])),
+ fact('关注者', numberValue(['total_followers', 'follower_count', 'followers_count'])),
+ ]),
+ fact('点数', numberValue(['gamification_score', 'points']), { accent: true }),
+ ].filter(Boolean);
+ return facts.length ? `
${facts.join('')}
` : '';
+ }
+
+ function userCardFollowStatsHtml(user, username, options = {}) {
+ if (!hasDiscourseCapability('follows', { user })) return '';
+ const interactive = options.interactive !== false;
+ const configs = [
+ { type: 'following', label: '关注', total: userStatValue(user, ['total_following', 'following_count', 'followings_count']), visible: user && user.can_see_following !== false },
+ { type: 'followers', label: '被关注', total: userStatValue(user, ['total_followers', 'follower_count', 'followers_count']), visible: user && user.can_see_followers !== false },
+ ];
+ return `
+ ${configs.map((item) => {
+ const total = Number(item.total);
+ const count = Number.isFinite(total) ? total.toLocaleString('zh-CN') : '-';
+ if (!interactive)
+ return `
+ ${esc(count)}${esc(item.label)}
+ `;
+ const label = item.visible ? `查看${item.label}人员` : `${item.label}列表不可见`;
+ return ``;
+ }).join('')}
+
`;
+ }
+
+ function userCardFollowItemHtml(user) {
+ const username = String(user?.username || '');
+ const name = String(user?.name || username);
+ const avatar = avatarWithFlairHtml(
+ persistentAvatarHtml(user?.avatar_template, 48, 'ldp-user-card-follow-avatar', name),
+ user
+ );
+ return `
+ ${avatar}${esc(name)}@${esc(username)}
+ `;
+ }
+
+ function renderUserCardFollowList(panel) {
+ if (panel?.hidden) return;
+ const list = panel?.querySelector('.ldp-user-card-follow-list');
+ const summary = panel?.querySelector('.ldp-user-card-follow-summary');
+ const input = panel?.querySelector('input[type="search"]');
+ const pagination = panel?.querySelector('.ldp-user-card-follow-pagination');
+ if (!panel || !list || !summary || !input || !pagination) return;
+ const users = Array.isArray(panel._ldpFollowUsers) ? panel._ldpFollowUsers : [];
+ const query = normalizeSearchText(input.value);
+ const matches = query ? users.filter((user) => user.searchText.includes(query)) : users;
+ const pageCount = Math.max(1, Math.ceil(matches.length / USER_FOLLOW_LIST_PAGE_SIZE));
+ const requestedPage = Math.max(0, Number(panel.dataset.followPage) || 0);
+ const page = Math.min(requestedPage, pageCount - 1);
+ const offset = page * USER_FOLLOW_LIST_PAGE_SIZE;
+ const visible = matches.slice(offset, offset + USER_FOLLOW_LIST_PAGE_SIZE);
+ panel.dataset.followPage = String(page);
+ releasePersistentAvatarImages(list);
+ list.innerHTML = visible.length
+ ? visible.map(userCardFollowItemHtml).join('')
+ : `
${query ? '没有匹配的人员' : '暂无人员'}
`;
+ summary.textContent = query ? `找到 ${matches.length} 人` : `${matches.length} 人`;
+ pagination.hidden = matches.length <= USER_FOLLOW_LIST_PAGE_SIZE;
+ pagination.querySelector('span').textContent = `${page + 1} / ${pageCount}`;
+ pagination.querySelector('[data-user-card-follow-page="previous"]').disabled = page === 0;
+ pagination.querySelector('[data-user-card-follow-page="next"]').disabled = page >= pageCount - 1;
+ list.scrollTop = 0;
+ hydratePersistentAvatarImages(list, POST_REQUEST_PRIORITY.userCard);
+ }
+
+ function connectNumber(value) {
+ const match = String(value || '').replace(/,/g, '').match(/-?\d+/);
+ return match ? Number(match[0]) : 0;
+ }
+
+ function connectCurrentTarget(value) {
+ const parts = String(value || '').replace(/,/g, '').split('/');
+ if (parts.length >= 2) return { current: connectNumber(parts[0]), target: connectNumber(parts[1]) };
+ const numbers = String(value || '').replace(/,/g, '').match(/-?\d+/g) || [];
+ return { current: Number(numbers[0] || 0), target: Number(numbers[1] || 0) };
+ }
+
+ function parseConnectTrustHtml(html) {
+ const doc = new globalThis.DOMParser().parseFromString(String(html || ''), 'text/html');
+ const card = [...doc.querySelectorAll('.card')].find((candidate) => {
+ const heading = candidate.querySelector('.card-title,h2');
+ return heading && /信任级别\s*\d+\s*的要求/.test(heading.textContent || '');
+ });
+ if (!card) throw new Error('Connect 未返回升级要求,请先登录 Connect');
+ const heading = String(card.querySelector('.card-title,h2')?.textContent || '').trim();
+ const targetLevel = Number((heading.match(/信任级别\s*(\d+)\s*的要求/) || [])[1]);
+ const subtitle = String(card.querySelector('.card-subtitle')?.textContent || '').trim();
+ const username = (subtitle.match(/@([^\s·]+)/) || [])[1] || '';
+ const timePeriod = Number((subtitle.match(/过去\s*([\d,]+)\s*天/) || [])[1]?.replace(/,/g, '')) || 100;
+ const rings = [...card.querySelectorAll('.tl3-ring')].map((item) => ({
+ label: String(item.querySelector('.tl3-ring-label')?.textContent || '').trim(),
+ current: connectNumber(item.querySelector('.tl3-ring-current')?.textContent),
+ target: connectNumber(item.querySelector('.tl3-ring-target')?.textContent),
+ met: item.querySelector('.tl3-ring-circle')?.classList.contains('met') || false,
+ })).filter((item) => item.label);
+ const requirementGroups = [
+ ['bars', '.tl3-bar-item', '.tl3-bar-label', '.tl3-bar-nums'],
+ ['quotas', '.tl3-quota-card', '.tl3-quota-label', '.tl3-quota-nums'],
+ ['vetoes', '.tl3-veto-item', '.tl3-veto-label', '.tl3-veto-value'],
+ ];
+ const { bars, quotas, vetoes } = Object.fromEntries(requirementGroups.map(([
+ group, itemSelector, labelSelector, valueSelector,
+ ]) => [group, [...card.querySelectorAll(itemSelector)].map((item) => {
+ const valueNode = item.querySelector(valueSelector);
+ const values = group === 'vetoes'
+ ? { current: connectNumber(valueNode?.textContent), target: 0 }
+ : connectCurrentTarget(valueNode?.textContent);
+ const met = group === 'bars'
+ ? valueNode?.classList.contains('met') || item.querySelector('.tl3-bar-fill')?.classList.contains('met') || false
+ : item.classList.contains('met') || (group === 'quotas' ? values.current <= values.target : values.current === 0);
+ return {
+ label: String(item.querySelector(labelSelector)?.textContent || '').trim(),
+ ...values, met, ...(group === 'bars' ? {} : { reverse: true }),
+ };
+ }).filter((item) => item.label)]));
+ const statusNode = card.querySelector('.status-met,.status-unmet');
+ const badgeNode = card.querySelector('.badge');
+ const allRequirements = [...rings, ...bars, ...quotas, ...vetoes];
+ const met = statusNode
+ ? statusNode.classList.contains('status-met')
+ : badgeNode ? !/未达到|未达/.test(badgeNode.textContent || '') : allRequirements.every((item) => item.met);
+ return {
+ targetLevel: Number.isFinite(targetLevel) ? targetLevel : null,
+ username,
+ timePeriod,
+ rings,
+ bars,
+ quotas,
+ vetoes,
+ met,
+ };
+ }
+
+ function requestConnectTrustHtml() {
+ if (!hasDiscourseCapability('connect'))
+ return Promise.reject(new Error('当前站点没有配置 Connect 能力'));
+ const request = globalThis.GM_xmlhttpRequest;
+ if (!isFunction(request)) return Promise.reject(new Error('当前脚本未获得 Connect 跨域读取权限'));
+ return new Promise((resolve, reject) => {
+ request({
+ method: 'GET',
+ url: 'https://connect.linux.do/',
+ headers: { 'Accept': 'text/html' },
+ timeout: 20000,
+ ...(/AppleWebKit\//.test(navigator.userAgent || '') &&
+ !/(?:Chrome|Chromium|Edg|OPR|CriOS|FxiOS)\//.test(navigator.userAgent || '') ? { cookiePartition: { topLevelSite: BASE } } : {}),
+ onload: (response) => {
+ if (response.status >= 200 && response.status < 300) resolve(response.responseText || '');
+ else reject(new Error(`Connect 请求失败(${response.status || '未知状态'})`));
+ },
+ ontimeout: () => reject(new Error('Connect 请求超时')),
+ onerror: () => reject(new Error('Connect 请求失败')),
+ });
+ });
+ }
+
+ function fetchConnectTrustData(force = false, persistedCache = null) {
+ const freshFor = 5 * MINUTE;
+ if (!CONNECT_TRUST_CACHE && persistedCache && persistedCache.data)
+ CONNECT_TRUST_CACHE = {
+ data: persistedCache.data,
+ cachedAt: Number(persistedCache.cachedAt || 0),
+ };
+ if (CONNECT_TRUST_REQUEST) return CONNECT_TRUST_REQUEST;
+ if (!force && CONNECT_TRUST_CACHE && Date.now() - CONNECT_TRUST_CACHE.cachedAt < freshFor)
+ return Promise.resolve(CONNECT_TRUST_CACHE.data);
+ CONNECT_TRUST_REQUEST = requestConnectTrustHtml().then(parseConnectTrustHtml).then((data) => {
+ CONNECT_TRUST_CACHE = { data, cachedAt: Date.now() };
+ return data;
+ }).finally(() => {
+ CONNECT_TRUST_REQUEST = null;
+ });
+ return CONNECT_TRUST_REQUEST;
+ }
+
+ let LDC_USER_REQUEST = null;
+ function fetchLinuxDoCreditUser(username, force = false) {
+ const cached = globalThis.GM_getValue?.(LDC_USER_BRIDGE_CACHE_KEY, null);
+ const normalize = (value) => String(value || '').trim().toLowerCase();
+ const expected = normalize(username), cachedUsername = normalize(cached?.data?.username);
+ const cacheMatches = cached?.data && (!expected || !cachedUsername || cachedUsername === expected);
+ if (!force && cacheMatches && Date.now() - Number(cached.cachedAt || 0) < 30 * MINUTE)
+ return Promise.resolve(cached.data);
+ if (LDC_USER_REQUEST) return LDC_USER_REQUEST;
+ const request = globalThis.GM_xmlhttpRequest;
+ if (typeof request !== 'function') return Promise.reject(new Error('脚本没有 LDC 请求权限'));
+ LDC_USER_REQUEST = new Promise((resolve, reject) => request({
+ method: 'GET', url: 'https://credit.linux.do/api/v1/oauth/user-info',
+ headers: { Accept: 'application/json', Referer: 'https://credit.linux.do/home' },
+ responseType: 'json', anonymous: false, withCredentials: true, timeout: 8000,
+ onload: ({ status, response }) => status >= 200 && status < 300 && response?.data ? resolve(response.data)
+ : reject(new Error([401, 403].includes(status) ? 'LDC 授权已失效,请打开 LDC 完成登录' : `LDC 请求失败(${status || '网络异常'})`)),
+ ontimeout: () => reject(new Error('LDC 请求超时')),
+ onerror: () => reject(new Error('LDC 网络请求失败')),
+ })).then((data) => {
+ const actual = normalize(data?.username);
+ if (expected && actual && actual !== expected)
+ throw new Error('LDC 与当前 LINUX DO 登录账号不一致');
+ return cacheCreditUser(data);
+ }).catch((error) => {
+ if (cacheMatches) return cached.data;
+ throw error;
+ }).finally(() => { LDC_USER_REQUEST = null; });
+ return LDC_USER_REQUEST;
+ }
+
+ function settingsCreditHtml(user, error) {
+ const login = '
打开 LDC 同步';
+ if (!user) return `
LINUX DO Credit复用 credit.linux.do 登录会话
${esc(error?.message || '暂时无法读取 LDC 数据')}${login}
`;
+ const number = (value) => Number.isFinite(Number(value)) ? Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 }) : String(value ?? '-');
+ const available = Number(user.available_balance) || 0;
+ const netIncome = (Number(user.total_receive) || 0) - (Number(user.total_payment) || 0);
+ const quota = Number(user.remain_quota), dailyLimit = Number(user.daily_limit);
+ const highlights = [
+ ['可用余额', `LDC ${number(available)}`],
+ ['社区余额', number(user.community_balance)],
+ ['今日额度', quota < 0 ? '无限制' : dailyLimit > 0 ? `${number(quota)} / ${number(dailyLimit)}` : number(quota)],
+ ];
+ const groups = [
+ ['积分与收支', [['未来积分', user.pending_balance], ['累计社区积分', user.total_community], ['累计收入', user.total_receive], ['累计支出', user.total_payment], ['累计流转', user.total_transfer], ['累计净收入', netIncome]]],
+ ['支付与账户', [['支付分', user.pay_score], ['支付等级', ['普通', '黄金', '白金', '黑金'][user.pay_level] || user.pay_level], ['每日限额', user.daily_limit ?? '未设置'], ['支付密钥', user.is_pay_key ? '已设置' : '未设置'], ['管理员', user.is_admin ? '是' : '否'], ['头像', user.avatar_url ? '已同步' : '未提供']]],
+ ];
+ const groupHtml = ([title, items]) => `
${title}
${items.map(([label, value]) => `
${label}${esc(number(value))}
`).join('')}
`;
+ const actions = [['home', '首页', 'externalLink'], ['trade', '活动', 'activity'], ['balance', '积分', 'database'], ['settings', '设置', 'settings']];
+ return `
LINUX DO Credit@${esc(user.username || '')} · ${esc(user.nickname || '')} · ID ${esc(user.id)}
Lv${esc(user.trust_level ?? '-')}
+ ${highlights.map(([label, value]) => `
${esc(value)}${label}
`).join('')}
+ ${groups.map(groupHtml).join('')}
+ ${error ? `${esc(error.message || '联网更新失败')}
` : ''}
+ `;
+ }
+
+ function settingsUserProfileHtml(user) {
+ const username = String(user?.username || ME_USERNAME || '');
+ const name = String(user?.name || username);
+ const trustLevel = userTrustLevel(user);
+ const background = String(user && (
+ user.card_background_upload_url || user.profile_background_upload_url
+ ) || '').trim();
+ const avatar = avatarWithFlairHtml(
+ persistentAvatarHtml(user?.avatar_template, 144, 'ldp-user-info-avatar-image', name),
+ user
+ );
+ const title = String(user && (user.title || user.flair_name) || '').trim();
+ const titleFlair = userFlairHtml(user);
+ const bio = String(user && (user.bio_excerpt || user.bio_raw) || '').trim();
+ const website = String(user && (user.website_name || user.website) || '').trim();
+ const websiteUrl = String(user && (user.website || user.website_name) || '').trim();
+ const safeWebsite = websiteUrl && isSafeUserCardUrl(websiteUrl)
+ ? (websiteUrl.startsWith('/') ? `${BASE}${websiteUrl}` : websiteUrl)
+ : '';
+ return `
+ ${background && isSafeUserCardUrl(background) ? `
)})
` : ''}
+
+
${avatar || esc(name.slice(0, 1).toUpperCase())}
+
+ ${bio ? `
${sanitizeUserCardBioHtml(bio)}
` : ''}
+ ${userProfileFactsHtml(user, 'settings', { emptyNumberAsZero: true, trustLevel })}
+
+ `;
+ }
+
+ function connectProgress(item) {
+ if (item.reverse) {
+ if (item.target === 0) return 100;
+ return Math.max(0, Math.min(100, item.current / item.target * 100));
+ }
+ return Math.max(0, Math.min(100, item.target > 0 ? item.current / item.target * 100 : item.met ? 100 : 0));
+ }
+
+ function connectAchievementClass(item) {
+ if (item.reverse || !(item.target > 0) || item.current < item.target) return '';
+ const ratio = item.current / item.target;
+ if (ratio >= 10) return ' is-goal is-over is-over-high is-over-ultra is-over-epic';
+ if (ratio >= 5) return ' is-goal is-over is-over-high is-over-ultra';
+ if (ratio >= 2) return ' is-goal is-over is-over-high';
+ if (ratio > 1) return ' is-goal is-over';
+ return ' is-goal';
+ }
+
+ function connectMetricBelowTarget(item) {
+ return !item.reverse && Number(item.target) > 0 && Number(item.current) < Number(item.target);
+ }
+
+ const CONNECT_REQUIREMENT_HELP = [
+ [/访问天数|days?visited/, (period) => `访问天数:过去 ${period} 天内,访问站点并至少阅读过 1 个帖子的不同自然日数量。`],
+ [/浏览话题|topics?viewed/, (period) => `浏览话题:过去 ${period} 天内浏览过的公开话题数量;目标按同期公开话题总量的一定比例计算,并受站点上限限制。`],
+ [/浏览帖子|posts?read/, (period) => `浏览帖子:过去 ${period} 天内实际读过的公开帖子数量;目标按同期公开帖子总量的一定比例计算,并受站点上限限制。`],
+ [/回复话题|topics?replied/, (period) => `回复话题:过去 ${period} 天内回复过的不同公开话题数量;同一话题回复多次仍只计 1 个。`],
+ [/获赞天数|likes?received.*days/, (period) => `获赞天数:过去 ${period} 天内至少收到过 1 个赞的不同自然日数量;同一天收到多个赞仍只计 1 天。`],
+ [/获赞用户|likes?received.*users/, (period) => `获赞用户:过去 ${period} 天内给你点过赞的不同用户数量;同一用户多次点赞仍只计 1 人。`],
+ [/^获赞$|likes?received/, (period) => `获赞:过去 ${period} 天内,你在公开话题中的帖子收到的点赞总数。`],
+ [/^点赞$|likes?given/, (period) => `点赞:过去 ${period} 天内,你在公开话题中送出的点赞总数。`],
+ [/被举报帖子|flaggedposts/, (period) => `被举报帖子:过去 ${period} 天内,被其他用户举报且经管理确认的不同帖子数量。这是上限项,数值越少越好。`],
+ [/举报用户|userswhoflagged|flaggedbyusers/, (period) => `举报用户:过去 ${period} 天内,对你的帖子发起且经管理确认举报的不同用户数量;不是你举报了多少人。这是上限项,数值越少越好。`],
+ [/被禁言|silenced/, () => '被禁言:过去 6 个月内的禁言处罚记录,当前仍在禁言也会计入;此项必须为 0。'],
+ [/被封禁|suspended/, () => '被封禁:过去 6 个月内的封禁处罚记录,当前仍在封禁也会计入;此项必须为 0。'],
+ ];
+
+ function connectRequirementHelp(label, timePeriod = 100) {
+ const normalized = String(label || '').replace(/\s+/g, '').toLowerCase();
+ const period = Number.isFinite(Number(timePeriod)) && Number(timePeriod) > 0 ? Number(timePeriod) : 100;
+ const match = CONNECT_REQUIREMENT_HELP.find(([pattern]) => pattern.test(normalized));
+ return match ? match[1](period) : '';
+ }
+
+ function settingsConnectHtml(data, error) {
+ if (!data) return `
Connect 升级进度升级要求来自 connect.linux.do
${esc(error?.message || '暂时无法读取 Connect 数据')}
`;
+ const timePeriod = Number(data.timePeriod) > 0 ? Number(data.timePeriod) : 100;
+ const valueLabel = (item) => `${Number(item.current).toLocaleString('zh-CN')} / ${Number(item.target).toLocaleString('zh-CN')}`;
+ const progressStyle = (item) => `--ldp-connect-progress:${connectProgress(item).toFixed(1)}%`;
+ const metricStateClass = (item, forceDanger = false) =>
+ forceDanger || connectMetricBelowTarget(item) || !item.met ? ' is-danger' : '';
+ const metricHelp = (item) => {
+ const help = connectRequirementHelp(item.label, timePeriod);
+ return help ? ` data-ldp-tooltip-label="${escAttr(help)}"` : '';
+ };
+ const barGroup = (title, items) => items.length ? `
${title}
${items.map((item) => `
${esc(item.label)}${esc(valueLabel(item))}
`).join('')}
` : '';
+ const quotaItems = [...data.quotas, ...data.vetoes];
+ const participationGroup = barGroup('参与互动', data.bars);
+ const complianceGroup = quotaItems.length ? `
合规记录
${quotaItems.map((item) => `
${esc(item.label)}${esc(valueLabel(item))}
`).join('')}
` : '';
+ return `
+ 信任级别 ${esc(data.targetLevel == null ? '' : data.targetLevel)} 的要求${data.username ? `@${esc(data.username)} · ` : ''}过去 ${esc(timePeriod)} 天的数据
${data.met ? '已达到' : '未达到'}
+ ${data.rings.length ? `${data.rings.map((item) => `
${esc(Number(item.current).toLocaleString('zh-CN'))}/ ${esc(Number(item.target).toLocaleString('zh-CN'))}
${esc(item.label)}`).join('')}
` : ''}
+ ${participationGroup || complianceGroup ? `${participationGroup}${complianceGroup}
` : ''}
+ ${error ? `当前显示缓存数据;${esc(error.message || '联网更新失败')}
` : ''}
+ `;
+ }
+
+ function renderSettingsUserInfo(container, user, connectData, connectError, creditUser, creditError) {
+ if (!container) return;
+ const connectAvailable = hasDiscourseCapability('connect');
+ const creditAvailable = SITE_ADAPTER.id === 'linux-do';
+ const availableViews = [...(connectAvailable ? ['connect'] : []), 'profile', ...(creditAvailable ? ['credit'] : [])];
+ const selectedView = availableViews.includes(container.dataset.userInfoView)
+ ? container.dataset.userInfoView
+ : availableViews[0];
+ container.dataset.userInfoView = selectedView;
+ container.innerHTML = `
+ ${connectAvailable ? `` : ''}
+ ${creditAvailable ? `` : ''}
+
+
+ ${connectAvailable ? `
${settingsConnectHtml(connectData, connectError)}
` : ''}
+
${settingsUserProfileHtml(user)}
+ ${creditAvailable ? `
${settingsCreditHtml(creditUser, creditError)}
` : ''}`;
+ hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.userCard);
+ }
+
+ function userCardActionsHtml(user, username) {
+ const privateMessageUnavailable = user && (
+ user.can_send_private_message_to_user === false || user.can_send_private_messages === false
+ );
+ const followed = !!(user?.is_followed);
+ const followUnavailable = !!(user && user.can_follow === false && !followed);
+ const endorsementUnavailable = !!(user && user.category_expert_endorsements === null);
+ const notificationLevel = userNotificationLevel(user);
+ const notificationUnavailable = !!(user && user.can_mute_user === false
+ && user.can_ignore_user === false && notificationLevel === 'normal');
+ const actions = [
+ { action: 'message', icon: 'mail', label: privateMessageUnavailable ? '私信(当前不可用)' : '私信', disabled: privateMessageUnavailable },
+ { action: 'notifications', icon: 'bell', label: notificationUnavailable ? '消息设置(当前不可用)' : `消息设置:${USER_NOTIFICATION_LEVEL_LABELS[notificationLevel]}`, active: notificationLevel !== 'normal', disabled: notificationUnavailable, expanded: true },
+ { action: 'endorse', icon: 'award', label: endorsementUnavailable ? '认可(当前不可用)' : '认可', disabled: endorsementUnavailable, visible: hasDiscourseCapability('categoryExperts', { user }) },
+ { action: 'follow', icon: 'userPlus', label: followUnavailable ? '关注(当前不可用)' : (followed ? '取消关注' : '关注'), active: followed, disabled: followUnavailable, visible: hasDiscourseCapability('follows', { user }) },
+ ].filter((item) => item.visible !== false);
+ const notificationOptions = [
+ { level: 'normal', icon: 'bell', label: '常规', description: '回复、引用或提到您时正常通知。' },
+ ...(user && user.can_mute_user === false ? [] : [
+ { level: 'mute', icon: 'bellOff', label: '免打扰', description: '不接收此用户的通知、私信和直接聊天。' },
+ ]),
+ ...(user && user.can_ignore_user === false ? [] : [
+ { level: 'ignore', icon: 'eyeOff', label: '忽略', description: '隐藏此用户的内容,并停止相关通知。' },
+ ]),
+ ];
+ return `
+
+ ${actions.map((item) => ``).join('')}
+
+
+
`;
+ }
+
+ function userCardHomeHtml(username) {
+ return `
${icon('externalLink')}`;
+ }
+
+ function userCardBackgroundHtml(user) {
+ const backgroundUrl = String(user && (
+ user.card_background_upload_url || user.cardBackgroundUploadUrl ||
+ user.profile_background_upload_url || user.profileBackgroundUploadUrl
+ ) || '').trim();
+ if (!backgroundUrl || !isSafeUserCardUrl(backgroundUrl)) return '';
+ const source = absoluteImageUrl(backgroundUrl);
+ return `
`;
+ }
+
+ function userCardBadgeHash(badge) {
+ const identity = `${badge?.id || ''}|${badge?.name || ''}|${badge?.icon || ''}`;
+ let hash = 2166136261;
+ for (let index = 0; index < identity.length; index++) {
+ hash ^= identity.charCodeAt(index);
+ hash = Math.imul(hash, 16777619);
+ }
+ return hash >>> 0;
+ }
+
+ const USER_CARD_BADGE_EXACT_KINDS = Object.freeze({
+ '种子用户': 'seed', '龙行龘龘': 'dragon', '大预言家': 'crystal', '圆圆满满': 'moon',
+ '浴火重生': 'fire', '海纳百川': 'waves', '一元复始': 'clock', '蛇来运转': 'snake',
+ '破界者': 'hammer', '不二之选': 'star', '骐骥驰骋': 'horse', '幸运佬': 'clover',
+ '金笔杆': 'pencil', '银笔杆': 'pencil', '铜笔杆': 'pencil', '文化宣导员': 'megaphone',
+ '元气满满': 'gift', '基本用户': 'user', '成员': 'user', '活跃用户': 'fire', '领导者': 'crown',
+ '阅读准则': 'book', '已认证': 'certificate', '已授权': 'certificate', '当月最佳新用户': 'star',
+ '爱好者': 'calendar', '百尺竿头': 'calendar', '全年不落': 'calendar', '周年纪念日': 'calendar',
+ '推广者': 'userplus', '活动家': 'userplus', '拥护者': 'userplus',
+ '指导顾问': 'check', '无所不知': 'check', '解决方案机构': 'check',
+ });
+ const USER_CARD_BADGE_KIND_RULES = Object.freeze([
+ ['mail', /mail|envelope|电子邮件|邮箱/],
+ ['flag', /\bflag\b|report|举报/],
+ ['at', /at-sign|mention|提及/],
+ ['quote', /quote|引用/],
+ ['box', /onebox|cube|box/],
+ ['certificate', /certificate|认证|授权/],
+ ['code', /code|github|commit|contributor|开源|贡献/],
+ ['seed', /seed|sprout|幼苗|种子/],
+ ['hammer', /hammer|gavel|破界/],
+ ['calendar', /calendar|streak|连续|全年|纪念日/],
+ ['userplus', /user-plus|invite|邀请|推广者|活动家|拥护者/],
+ ['megaphone', /bullhorn|megaphone|announcement|公告|推广|广播/],
+ ['heart', /heart|like|love|赞|爱心|喜爱|谢谢|回馈|善解人意/],
+ ['eye', /\beye\b|view|reader|阅读|浏览|围观/],
+ ['pencil', /pencil|edit|write|author|编辑|创作|作者|书写|笔杆|wiki/],
+ ['document', /file|document|post|topic|article|文件|文档|帖子|主题|文章/],
+ ['smile', /smile|laugh|emoji|表情|微笑|笑/],
+ ['crown', /chess|crown|leader|king|领袖|领导|王者/],
+ ['link', /link|share|链接|分享/],
+ ['chat', /comment|chat|reply|conversation|回复|讨论|聊天|对话/],
+ ['check', /check|solution|accepted|认可|解决|采纳|完成|顾问|无所不知/],
+ ['star', /star|award|medal|荣誉|勋章|明星|精选|尊敬|敬仰|最佳/],
+ ['shield', /shield|moderator|admin|管理|守护|安全/],
+ ['clock', /clock|time|anniversary|year|周年|时间|资历/],
+ ['fire', /fire|hot|active|热门|活跃|热心/],
+ ['book', /book|learn|guide|tutorial|知识|教程|学习|指南/],
+ ['gift', /gift|赠送|礼物/],
+ ['user', /user|person|profile|member|用户|新人|成员|欢迎/],
+ ]);
+
+ function userCardBadgeGlyphKind(badge) {
+ const name = String(badge?.name || '').trim();
+ if (USER_CARD_BADGE_EXACT_KINDS[name]) return USER_CARD_BADGE_EXACT_KINDS[name];
+ const source = `${badge?.icon || ''} ${name}`.toLowerCase();
+ return (USER_CARD_BADGE_KIND_RULES.find(([, pattern]) => pattern.test(source)) || ['sigil'])[0];
+ }
+
+ const USER_CARD_BADGE_GLYPHS = Object.freeze({
+ mail: '
',
+ flag: '
',
+ at: '
',
+ quote: '
',
+ box: '
',
+ certificate: '
',
+ code: '
',
+ seed: '
',
+ hammer: '
',
+ calendar: '
',
+ userplus: '
',
+ megaphone: '
',
+ heart: '
',
+ eye: '
',
+ pencil: '
',
+ document: '
',
+ smile: '
',
+ crown: '
',
+ link: '
',
+ chat: '
',
+ check: '
',
+ star: '
',
+ shield: '
',
+ clock: '
',
+ fire: '
',
+ book: '
',
+ gift: '
',
+ user: '
',
+ dragon: '
',
+ crystal: '
',
+ moon: '
',
+ waves: '
',
+ snake: '
',
+ horse: '
',
+ clover: '
',
+ });
+
+ function userCardBadgeGlyphHtml(kind, badge) {
+ if (USER_CARD_BADGE_GLYPHS[kind]) return USER_CARD_BADGE_GLYPHS[kind];
+ const hash = userCardBadgeHash(badge);
+ const points = Array.from({ length: 8 }, (_, index) => {
+ const angle = -Math.PI / 2 + index * Math.PI / 4;
+ const radius = 7 + ((hash >>> (index * 4)) & 3);
+ return `${12 + Math.cos(angle) * radius},${12 + Math.sin(angle) * radius}`;
+ }).join(' ');
+ const core = 2.5 + ((hash >>> 29) & 3) * .65;
+ return `
`;
+ }
+
+ function userCardBadgeIconHtml(badge) {
+ return `
`;
+ }
+
+ function userCardBadgesHtml(user) {
+ const badges = uniqueUserBadges(user?.user_badges);
+ if (!badges.length) return '';
+ const featuredIds = new Set((Array.isArray(user?.featured_user_badge_ids)
+ ? user.featured_user_badge_ids : []).map(Number));
+ const badgeSortNumber = (badge, field, minimum = 0) => {
+ const value = Number(badge?.[field]);
+ return Number.isFinite(value) && value >= minimum ? value : Number.MAX_SAFE_INTEGER;
+ };
+ const ordered = badges.slice().sort((a, b) =>
+ badgeSortNumber(a, 'badge_type_id', 1) - badgeSortNumber(b, 'badge_type_id', 1) ||
+ badgeSortNumber(a, 'grant_count') - badgeSortNumber(b, 'grant_count') ||
+ Number(featuredIds.has(Number(b?.id))) - Number(featuredIds.has(Number(a?.id))) ||
+ String(b?.granted_at || '').localeCompare(String(a?.granted_at || ''))
+ );
+ return `
+
徽章(${ordered.length})
+
+
+
+ ${ordered.map((badge) => {
+ const name = String(badge.name || '').trim();
+ const tier = Number(badge?.badge_type_id);
+ return ``;
+ }).join('')}
+
+
+
+
`;
+ }
+
+ function syncUserCardBadgeScrollControls(root) {
+ if (!root) return;
+ root.querySelectorAll('.ldp-user-card-badge-strip').forEach((strip) => {
+ const list = strip.querySelector('.ldp-user-card-badge-list');
+ const previous = strip.querySelector('[data-user-card-badge-scroll="-1"]');
+ const next = strip.querySelector('[data-user-card-badge-scroll="1"]');
+ if (!list || !previous || !next) return;
+ const maxScrollLeft = Math.max(0, list.scrollWidth - list.clientWidth);
+ const overflow = maxScrollLeft > 1;
+ strip.classList.toggle('is-scrollable', overflow);
+ previous.hidden = !overflow;
+ next.hidden = !overflow;
+ previous.disabled = !overflow || list.scrollLeft <= 1;
+ next.disabled = !overflow || list.scrollLeft >= maxScrollLeft - 1;
+ });
+ }
+
+ function userCardAvatarImageHtml(template, name, previewSource = '') {
+ const source = avatarUrl(template, 96);
+ const hasCachedSource = AVATAR_OBJECT_URL_CACHE.has(source);
+ const preview = String(previewSource || '').trim();
+ if (!preview || hasCachedSource || source.startsWith('data:') || source.startsWith('blob:'))
+ return persistentAvatarHtml(template, 96, 'ldp-user-card-avatar', name);
+ return `
})
`;
+ }
+
+ function renderFallbackUserCard(user, username, phase = 'ready', avatarPreviewSource = '', options = {}) {
+ user = user || {};
+ username = user.username || username || '';
+ const name = user.name || username;
+ const avatar = avatarWithFlairHtml(
+ userCardAvatarImageHtml(user.avatar_template, name, avatarPreviewSource),
+ user
+ );
+ const title = user.title || user.flair_name || '';
+ const bio = user.bio_excerpt || user.bio_raw || '';
+ const trustLevel = userTrustLevel(user);
+ const avatarControl = !avatar
+ ? ''
+ : options.mediaInteractive === false
+ ? `
${avatar}`
+ : `
`;
+ return `
+ ${options.mediaInteractive === false ? '' : userCardBackgroundHtml(user)}
+ ${userCardHomeHtml(username)}
+
+ ${avatarControl}
+
+
+
${esc(name)}
+ ${Number.isFinite(trustLevel) ? `
Lv${esc(trustLevel)}` : ''}
+
+
@${esc(username)}
+ ${title ? `
${esc(title)}
` : ''}
+
+
+ ${userProfileFactsHtml(user, 'card', { includePrimaryGroup: true, groupTooltip: true, trustLevel })}
+ ${userCardGroupsHtml(user)}
+ ${userCardFollowStatsHtml(user, username, { interactive: options.followStatsInteractive !== false })}
+ ${bio ? `
${sanitizeUserCardBioHtml(bio)}
` : ''}
+ ${userCardBadgesHtml(user)}
+
+ ${userCardStat(userStatValue(user, ['post_count', 'posts_count']), '帖子', userActivityUrl(username, 'activity/replies'))}
+ ${userCardStat(userStatValue(user, ['likes_received', 'likes_given']), '获赞', userActivityUrl(username, 'activity/likes-received'))}
+ ${userCardStat(userStatValue(user, ['topic_count', 'topics_entered']), '主题', userActivityUrl(username, 'activity/topics'))}
+
+ ${userCardProgressHtml(phase)}
+ ${options.actions === false ? '' : `${userCardActionsHtml(user, username)}
+
`}`;
+ }
+
+ function userCardSeedFromLink(link, username) {
+ const head = link?.closest('.ldp-post-head');
+ const post = head?.closest('.ldp-post');
+ const postData = post?._ldpPostData || {};
+ const avatar = head?.querySelector('.ldp-avatar');
+ const author = head?.querySelector('.ldp-author');
+ return {
+ username,
+ name: (author?.textContent || '').trim() || username,
+ _ldp_avatar_preview_url: avatar && (avatar.currentSrc || avatar.src) || '',
+ avatar_template: avatar && (
+ avatar.dataset.ldpAvatarTemplate ||
+ avatar.dataset.ldpAvatarOriginalTemplate ||
+ avatar.currentSrc ||
+ avatar.src
+ ) || '',
+ flair_name: postData.flair_name,
+ flair_url: postData.flair_url,
+ flair_bg_color: postData.flair_bg_color,
+ flair_color: postData.flair_color,
+ };
+ }
+
+ async function showFallbackUserCard(link, isCurrent, onOpen = null, options = {}) {
+ const username = getUserCardUsername(link);
+ if (!username || !isCurrent()) return;
+ const card = options.card || getUserCardSurface();
+ const positionCard = isFunction(options.positionCard)
+ ? options.positionCard
+ : (target) => positionFallbackUserCard(target, link);
+ const seedUser = userCardSeedFromLink(link, username);
+ const avatarPreviewSource = seedUser._ldp_avatar_preview_url || '';
+ const renderUser = (user, phase = 'ready') => {
+ if (!isCurrent()) return;
+ releasePersistentAvatarImages(card);
+ card._ldpUserData = user || { username };
+ card.innerHTML = renderFallbackUserCard(user, username, phase, avatarPreviewSource, options);
+ hydratePersistentAvatarImages(card, POST_REQUEST_PRIORITY.userCard);
+ syncUserCardBadgeScrollControls(card);
+ requestAnimationFrame(() => card.isConnected && syncUserCardBadgeScrollControls(card));
+ card.classList.add('open');
+ positionCard(card, link);
+ };
+ renderUser(seedUser, 'profile');
+ if (isFunction(onOpen)) onOpen(card._ldpUserData);
+ try {
+ await fetchUserCard(username, renderUser);
+ } catch {
+ if (!isCurrent()) return;
+ card._ldpUserData = { username };
+ card.innerHTML = `${userCardHomeHtml(username)}
用户信息加载失败
${options.actions === false ? '' : `${userCardActionsHtml({}, username)}
`}`;
+ positionCard(card, link);
+ }
+ }
+
+ function composerElementAvailable(el) {
+ if (!el || el.hidden || el.disabled || el.getAttribute('aria-hidden') === 'true') return false;
+ if (el.classList.contains('hidden') || el.classList.contains('d-none')) return false;
+ return !el.closest('[hidden],[aria-hidden="true"]');
+ }
+
+ function nativeComposerVisible(el) {
+ if (!composerElementAvailable(el) || el.classList.contains('closed')) return false;
+ const style = getComputedStyle(el);
+ if (style.display === 'none' || style.visibility === 'hidden' || style.contentVisibility === 'hidden') return false;
+ const rect = el.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0 &&
+ rect.right > 0 && rect.bottom > 0 && rect.left < window.innerWidth && rect.top < window.innerHeight;
+ }
+
+ function visibleNativeFloatingSurface() {
+ return [...document.querySelectorAll('.fk-d-menu,.emoji-picker')].find(nativeComposerVisible) || null;
+ }
+
+ function createReaderWindowController(overlay, modal) {
+ const capsule = overlay.querySelector('.ldp-reader-window-capsule');
+ const header = modal.querySelector(':scope > .ldp-header');
+ const lockButton = capsule.querySelector('.ldp-reader-lock-button');
+ const pinButton = capsule.querySelector('.ldp-reader-pin-button');
+ let state = null;
+ let interaction = null;
+ let frame = 0;
+ let destroyed = false;
+ const readerWindowPrefKey = (suffix) => `readerWindow${suffix}`;
+ const readerWindowPref = (suffix) => PREFS[readerWindowPrefKey(suffix)];
+ const setReaderWindowPref = (suffix, value) => setPref(readerWindowPrefKey(suffix), value);
+ const setReaderWindowPrefs = (values) => setPrefs(Object.entries(values).reduce((next, [suffix, value]) => {
+ next[readerWindowPrefKey(suffix)] = value;
+ return next;
+ }, {}));
+ const isLocked = () => !!readerWindowPref('Locked');
+ const isPinned = () => !!readerWindowPref('Pinned');
+
+ const viewportBounds = () => ({
+ maxWidth: Math.max(1, window.innerWidth - READER_WINDOW_MARGIN * 2),
+ maxHeight: Math.max(1, window.innerHeight - READER_WINDOW_MARGIN * 2),
+ });
+ const clampSize = (width, height) => {
+ const bounds = viewportBounds();
+ return {
+ width: Math.min(bounds.maxWidth, Math.max(Math.min(READER_WINDOW_MIN_WIDTH, bounds.maxWidth), Math.round(width))),
+ height: Math.min(bounds.maxHeight, Math.max(Math.min(READER_WINDOW_MIN_HEIGHT, bounds.maxHeight), Math.round(height))),
+ };
+ };
+ const preferredSize = () => clampSize(
+ readerWindowPref('Width') || Math.min(READER_WINDOW_DEFAULT_WIDTH, window.innerWidth * READER_WINDOW_DEFAULT_VIEWPORT_WIDTH),
+ readerWindowPref('Height') || window.innerHeight * READER_WINDOW_DEFAULT_VIEWPORT_HEIGHT
+ );
+ const centeredState = () => {
+ const size = preferredSize();
+ return clampState({
+ ...size,
+ left: readerWindowPref('X') || Math.round((window.innerWidth - size.width) / 2),
+ top: readerWindowPref('Y') || Math.round((window.innerHeight - size.height) / 2),
+ });
+ };
+ const clampState = (next) => {
+ const size = clampSize(next.width, next.height);
+ return {
+ ...size,
+ left: Math.min(window.innerWidth - READER_WINDOW_MARGIN - size.width,
+ Math.max(READER_WINDOW_MARGIN, Math.round(next.left))),
+ top: Math.min(window.innerHeight - READER_WINDOW_MARGIN - size.height,
+ Math.max(READER_WINDOW_MARGIN, Math.round(next.top))),
+ };
+ };
+ const managed = () => readerPresentation(overlay.dataset.readerWorkspaceMode).floating &&
+ window.innerWidth > READER_WINDOW_COMPACT_WIDTH;
+ const syncHeaderDragSurface = () => {
+ const active = managed() && !isLocked();
+ header.toggleAttribute('data-ldp-reader-drag-surface', active);
+ if (active) header.dataset.ldpTooltipLabel = '按住空白处可拖动';
+ else delete header.dataset.ldpTooltipLabel;
+ };
+ const updateCapsulePlacement = (next) => {
+ const docked = managed() && next && next.top < READER_WINDOW_HANDLE_DOCK_THRESHOLD;
+ overlay.classList.toggle('ldp-window-handle-docked', !!docked);
+ if (!next) return;
+ setStyleValue(overlay, '--ldp-reader-window-center-x', `${next.left + next.width / 2}px`);
+ setStyleValue(overlay, '--ldp-reader-window-top', `${next.top}px`);
+ };
+ const syncLockButton = () => {
+ const locked = isLocked();
+ const label = locked ? '解锁浮窗' : '锁定浮窗';
+ lockButton.classList.toggle('active', locked);
+ setPressed(lockButton, locked);
+ setLabel(lockButton, label);
+ if (lockButton.dataset.locked !== String(locked)) {
+ lockButton.dataset.locked = String(locked);
+ lockButton.innerHTML = icon(locked ? 'lock' : 'unlock');
+ }
+ };
+ const applyGeometry = (next) => {
+ setStyleValue(modal, 'left', `${next.left}px`);
+ setStyleValue(modal, 'top', `${next.top}px`);
+ setStyleValue(modal, 'width', `${next.width}px`);
+ setStyleValue(modal, 'height', `${next.height}px`);
+ updateCapsulePlacement(next);
+ };
+ const animateLockButton = () => {
+ if (prefersReducedMotion()) return;
+ lockButton.classList.remove('ldp-lock-state-changing');
+ void lockButton.offsetWidth;
+ lockButton.classList.add('ldp-lock-state-changing');
+ };
+ const apply = () => {
+ if (destroyed) return;
+ const displayState = managed() ? clampState(state || centeredState()) : null;
+ overlay.classList.toggle('ldp-window-locked', isLocked());
+ overlay.classList.toggle('ldp-window-pinned', isPinned());
+ overlay.classList.toggle('ldp-window-managed', managed());
+ syncLockButton();
+ pinButton.classList.toggle('active', isPinned());
+ setPressed(pinButton, isPinned());
+ setLabel(pinButton, isPinned() ? '恢复点击外部关闭' : '点击外部时保持显示');
+ syncHeaderDragSurface();
+ if (!managed()) {
+ updateCapsulePlacement(null);
+ ['left', 'top', 'width', 'height'].forEach((name) => {
+ if (modal.style.getPropertyValue(name)) modal.style.removeProperty(name);
+ });
+ return;
+ }
+ if (!state) state = { ...displayState };
+ applyGeometry(displayState);
+ };
+ const stopInteraction = () => {
+ if (!interaction) return;
+ const { target, pointerId } = interaction;
+ interaction = null;
+ if (frame) cancelAnimationFrame(frame);
+ frame = 0;
+ overlay.classList.remove('ldp-window-interacting');
+ modal.style.removeProperty('transform');
+ capsule.style.removeProperty('transform');
+ try {
+ if (target.hasPointerCapture(pointerId)) target.releasePointerCapture(pointerId);
+ } catch {}
+ };
+ const renderInteraction = () => {
+ frame = 0;
+ if (!interaction || !managed()) return;
+ const dx = interaction.x - interaction.startX;
+ const dy = interaction.y - interaction.startY;
+ const start = interaction.start;
+ if (interaction.type === 'drag') {
+ interaction.preview = clampState({ ...start, left: start.left + dx, top: start.top + dy });
+ modal.style.transform = `translate3d(${interaction.preview.left - start.left}px,${interaction.preview.top - start.top}px,0)`;
+ updateCapsulePlacement(interaction.preview);
+ return;
+ }
+ const direction = interaction.type;
+ let left = start.left;
+ let top = start.top;
+ let right = start.left + start.width;
+ let bottom = start.top + start.height;
+ if (direction.includes('w')) left = Math.min(right - READER_WINDOW_MIN_WIDTH, Math.max(READER_WINDOW_MARGIN, start.left + dx));
+ if (direction.includes('e')) right = Math.max(left + READER_WINDOW_MIN_WIDTH, Math.min(window.innerWidth - READER_WINDOW_MARGIN, right + dx));
+ if (direction.includes('n')) top = Math.min(bottom - READER_WINDOW_MIN_HEIGHT, Math.max(READER_WINDOW_MARGIN, start.top + dy));
+ if (direction.includes('s')) bottom = Math.max(top + READER_WINDOW_MIN_HEIGHT, Math.min(window.innerHeight - READER_WINDOW_MARGIN, bottom + dy));
+ state = clampState({ left, top, width: right - left, height: bottom - top });
+ applyGeometry(state);
+ };
+ const onPointerMove = (event) => {
+ if (!interaction || event.pointerId !== interaction.pointerId) return;
+ interaction.x = event.clientX;
+ interaction.y = event.clientY;
+ if (!frame) frame = requestAnimationFrame(renderInteraction);
+ };
+ const persistGeometry = () => {
+ setReaderWindowPrefs({
+ Width: state.width,
+ Height: state.height,
+ X: state.left,
+ Y: state.top,
+ });
+ };
+ const onPointerEnd = (event) => {
+ if (!interaction || event.pointerId !== interaction.pointerId) return;
+ if (frame) {
+ cancelAnimationFrame(frame);
+ frame = 0;
+ renderInteraction();
+ }
+ const resized = interaction.type !== 'drag';
+ if (!resized && interaction.preview) state = interaction.preview;
+ stopInteraction();
+ if (state) {
+ apply();
+ persistGeometry();
+ overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
+ }
+ };
+ const beginInteraction = (event, type, target) => {
+ if (event.button !== 0 || isLocked() || !managed()) return;
+ consumeEvent(event);
+ state = clampState(state || centeredState());
+ interaction = {
+ type,
+ target,
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startY: event.clientY,
+ x: event.clientX,
+ y: event.clientY,
+ start: { ...state },
+ };
+ overlay.classList.add('ldp-window-interacting');
+ target.setPointerCapture(event.pointerId);
+ };
+ const windowScope = createLifecycleScope();
+ const headerDragBlockedSelector = [
+ 'a', 'button', 'input', 'select', 'textarea', 'label', 'summary', '[role="button"]',
+ '[contenteditable="true"]', '.ldp-title-jump', '.ldp-meta-row', '.ldp-title-topic-row',
+ '.ldp-notifications-popover', '.ldp-history-popover', '.ldp-bookmarks-popover',
+ '.ldp-settings-popover', '.ldp-topic-edit-layer',
+ ].join(',');
+ const readerWindowHandle = (target) => {
+ if (!(target instanceof Element)) return null;
+ const resizeHandle = target.closest('[data-reader-resize]');
+ if (resizeHandle && overlay.contains(resizeHandle)) return resizeHandle;
+ const dragSurface = target.closest('.ldp-header[data-ldp-reader-drag-surface]');
+ if (!dragSurface || dragSurface !== header || target.closest(headerDragBlockedSelector)) return null;
+ return dragSurface;
+ };
+ const onHandlePointerDown = (event) => {
+ const handle = readerWindowHandle(event.target);
+ if (!handle) return;
+ const type = handle === header ? 'drag' : handle.dataset.readerResize;
+ if (!type) return;
+ beginInteraction(event, type, handle);
+ };
+ windowScope.listen(overlay, 'pointerdown', onHandlePointerDown);
+ windowScope.listen(overlay, 'pointermove', onPointerMove);
+ windowScope.listen(overlay, 'pointerup', onPointerEnd);
+ windowScope.listen(overlay, 'pointercancel', onPointerEnd);
+ const onLockAnimationEnd = () => lockButton.classList.remove('ldp-lock-state-changing');
+ const onLockClick = (event) => {
+ consumeEvent(event);
+ stopInteraction();
+ setReaderWindowPref('Locked', !isLocked());
+ apply();
+ animateLockButton();
+ overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
+ };
+ windowScope.listen(lockButton, 'click', onLockClick);
+ windowScope.listen(lockButton, 'animationend', onLockAnimationEnd);
+ const onPinClick = (event) => {
+ consumeEvent(event);
+ setReaderWindowPref('Pinned', !isPinned());
+ apply();
+ overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
+ };
+ windowScope.listen(pinButton, 'click', onPinClick);
+ const onResize = () => {
+ stopInteraction();
+ apply();
+ };
+ windowScope.listen(window, 'resize', onResize);
+ apply();
+
+ return {
+ syncMode() {
+ stopInteraction();
+ apply();
+ },
+ getState() {
+ return { ...(state ? (managed() ? clampState(state) : state) : centeredState()) };
+ },
+ setGeometry(width, height, left, top) {
+ if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
+ const current = state || centeredState();
+ const size = clampSize(Number(width) || current.width, Number(height) || current.height);
+ state = clampState({
+ ...size,
+ left: Number.isFinite(Number(left)) ? Number(left) : current.left,
+ top: Number.isFinite(Number(top)) ? Number(top) : current.top,
+ });
+ persistGeometry();
+ apply();
+ overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
+ },
+ setLocked(locked) {
+ if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
+ stopInteraction();
+ setReaderWindowPref('Locked', !!locked);
+ apply();
+ },
+ setPinned(pinned) {
+ if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
+ setReaderWindowPref('Pinned', !!pinned);
+ apply();
+ },
+ reset() {
+ if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
+ stopInteraction();
+ setReaderWindowPrefs({
+ Width: 0,
+ Height: 0,
+ X: 0,
+ Y: 0,
+ Locked: false,
+ Pinned: false,
+ });
+ state = centeredState();
+ apply();
+ overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
+ },
+ isDefault() {
+ return !readerWindowPref('Width') && !readerWindowPref('Height') &&
+ !readerWindowPref('X') && !readerWindowPref('Y') &&
+ !isLocked() && !isPinned();
+ },
+ isLocked,
+ isPinned,
+ destroy() {
+ if (destroyed) return;
+ stopInteraction();
+ destroyed = true;
+ header.removeAttribute('data-ldp-reader-drag-surface');
+ delete header.dataset.ldpTooltipLabel;
+ windowScope.destroy();
+ },
+ };
+ }
+
+ function syncNativeTopicStatsComponent(card, reactionCountsByTopicId) {
+ const posts = card?.querySelector(':scope > td.posts');
+ const views = card?.querySelector(':scope > td.views');
+ const activity = card?.querySelector(':scope > td.activity');
+ if (!posts || !views || !activity) return;
+ let component = posts.querySelector(':scope > .ldp-topic-stats-component');
+ const sourceMarkup = (cell) => [...cell.childNodes]
+ .filter((node) => node !== component)
+ .map((node) => node.nodeType === 1 ? node.outerHTML : String(node.textContent || ''))
+ .join('');
+ const isOldTopic = views.dataset.ldpNativeOldTopic === 'true';
+ const directId = card.dataset.topicId || card.getAttribute('data-topic-id');
+ const topicLink = card.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]');
+ const topicId = String(directId || extractTopicRouteFromUrl(topicLink?.href)?.topicId || '');
+ const responseCount = topicId && reactionCountsByTopicId.has(topicId)
+ ? reactionCountsByTopicId.get(topicId)
+ : null;
+ const sourceSignature = `${sourceMarkup(posts)}\u0001${sourceMarkup(views)}\u0001${sourceMarkup(activity)}\u0001${isOldTopic ? 'old' : 'recent'}\u0001${responseCount == null ? '' : responseCount}`;
+ if (component && component.dataset.ldpSourceSignature === sourceSignature && component.querySelectorAll(':scope > .ldp-topic-stats-row').length === 2) return;
+ const cloneSourceNodes = (cell) => [...cell.childNodes]
+ .filter((node) => node !== component)
+ .map((node) => node.cloneNode(true));
+ const postNodes = cloneSourceNodes(posts);
+ const viewNodes = cloneSourceNodes(views);
+ const activityNodes = cloneSourceNodes(activity);
+ if (!component) {
+ component = makeElement('div');
+ component.className = 'ldp-topic-stats-component';
+ posts.append(component);
+ }
+ const makeStat = (kind, label, nodes) => {
+ const item = makeElement('span');
+ item.className = `ldp-topic-stat ldp-topic-stat--${kind}`;
+ const caption = makeElement('span');
+ caption.className = 'ldp-topic-stat-label';
+ caption.textContent = label;
+ const value = makeElement('span');
+ value.className = 'ldp-topic-stat-value';
+ nodes.forEach((node) => value.append(node));
+ item.append(caption, value);
+ return item;
+ };
+ const makeRow = (...items) => {
+ const row = makeElement('span');
+ row.className = 'ldp-topic-stats-row';
+ row.append(...items);
+ return row;
+ };
+ component.replaceChildren(
+ makeRow(
+ makeStat('reply', '回复', postNodes),
+ makeStat(isOldTopic ? 'old' : 'date', isOldTopic ? '旧帖' : '最近回复', viewNodes)
+ ),
+ makeRow(
+ makeStat('activity', '活跃', activityNodes),
+ makeStat('response', '回应', [document.createTextNode(responseCount == null ? '—' : String(responseCount))])
+ )
+ );
+ component.dataset.ldpSourceSignature = sourceSignature;
+ }
+
+ function syncNativeTopicActivityStat(card) {
+ const activity = card?.querySelector(':scope > td.activity');
+ const component = card?.querySelector(':scope > td.posts > .ldp-topic-stats-component');
+ const source = activity?.querySelector('.relative-date');
+ const target = component?.querySelector('.ldp-topic-stat--activity .relative-date');
+ if (!activity || !component || !source || !target) return false;
+ if (target.textContent !== source.textContent) target.textContent = source.textContent;
+ const signatureParts = String(component.dataset.ldpSourceSignature || '').split('\u0001');
+ if (signatureParts.length === 5) {
+ signatureParts[2] = [...activity.childNodes]
+ .map((node) => node.nodeType === 1 ? node.outerHTML : String(node.textContent || ''))
+ .join('');
+ component.dataset.ldpSourceSignature = signatureParts.join('\u0001');
+ }
+ return true;
+ }
+
+ function nativeTopicOpReactionCounts() {
+ const counts = new Map();
+ const router = lookupDiscourse('service:router');
+ const routeName = discourseModelValue(router, 'currentRouteName');
+ const controllerNames = [
+ routeName && `controller:${routeName}`,
+ routeName && `controller:${String(routeName).replace(/\./g, '/')}`,
+ 'controller:discovery/list',
+ 'controller:tag/show',
+ 'controller:tags/intersection',
+ ].filter(Boolean);
+ const seenControllers = new Set();
+ for (const name of controllerNames) {
+ const controller = lookupDiscourse(name);
+ if (!controller || seenControllers.has(controller)) continue;
+ seenControllers.add(controller);
+ const model = discourseModelValue(controller, 'model');
+ const list = discourseModelValue(model, 'list') || discourseModelValue(controller, 'list') || model;
+ const topicsValue = discourseModelValue(list, 'topics') || discourseModelValue(list, 'content');
+ const topics = Array.isArray(topicsValue)
+ ? topicsValue
+ : isFunction(topicsValue?.toArray)
+ ? topicsValue.toArray()
+ : [];
+ topics.forEach((topic) => {
+ const topicId = String(discourseModelValue(topic, 'id') || '');
+ if (!topicId || counts.has(topicId)) return;
+ const reactionsData = discourseModelValue(topic, 'op_reactions_data') || discourseModelValue(topic, 'opReactionsData');
+ const rawCount = reactionsData
+ ? discourseModelValue(reactionsData, 'reaction_users_count') ?? discourseModelValue(reactionsData, 'reactionUsersCount')
+ : discourseModelValue(topic, 'op_like_count') ?? discourseModelValue(topic, 'opLikeCount');
+ const count = Number(rawCount);
+ counts.set(topicId, Number.isFinite(count) && count >= 0 ? Math.trunc(count) : null);
+ });
+ }
+ return counts;
+ }
+
+ function syncNativeTopicQueueButton(card) {
+ if (!card || card.closest('.ldp-overlay')) return;
+ const topicLink = card.querySelector(
+ 'a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]'
+ );
+ const route = extractTopicRouteFromUrl(topicLink && (topicLink.getAttribute('href') || topicLink.href));
+ const topicId = Number(card.dataset.topicId || card.getAttribute('data-topic-id') || route?.topicId);
+ const line = topicLink && (topicLink.closest('.link-top-line') || topicLink.parentElement);
+ if (!(topicId > 0) || !line) return;
+ let button = line.querySelector(':scope > .ldp-reader-queue-add');
+ if (!button) {
+ button = makeElement('button');
+ button.className = 'ldp-reader-queue-add';
+ button.type = 'button';
+ line.appendChild(button);
+ }
+ button.dataset.readerQueueTopicId = String(topicId);
+ const added = !!readerQueueEntry(topicId);
+ if (button.dataset.readerQueueAdded === String(added) && button.firstElementChild) return;
+ syncReaderQueueAddButton(button, added);
+ }
+
+ function retargetMainOutletObserver(observer, currentRoot, rootOptions, onRoot) {
+ const nextRoot = document.querySelector('#main-outlet') ||
+ document.querySelector('.list-container,.topic-list,.latest-topic-list') ||
+ document.querySelector('#ember-app');
+ if (currentRoot === nextRoot && nextRoot?.isConnected) return currentRoot;
+ observer.disconnect();
+ observer.observe(document.body, { childList: true });
+ if (nextRoot) {
+ let ancestor = nextRoot.parentElement;
+ while (ancestor && ancestor !== document.body) {
+ observer.observe(ancestor, { childList: true });
+ ancestor = ancestor.parentElement;
+ }
+ observer.observe(nextRoot, rootOptions);
+ if (onRoot) onRoot(nextRoot);
+ }
+ return nextRoot;
+ }
+
+ function installNativeTopicQueueButtons() {
+ if (!document.body || document.body._ldpReaderQueueButtonsObserver) return;
+ const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result';
+ const pendingCards = new Set();
+ let frame = 0;
+ let observerRoot = null;
+ const collect = (node) => {
+ const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
+ if (!element || element.closest('.ldp-overlay')) return;
+ if (element.matches(cardSelector)) pendingCards.add(element);
+ element.querySelectorAll && element.querySelectorAll(cardSelector).forEach((card) => pendingCards.add(card));
+ const parentCard = element.closest(cardSelector);
+ if (parentCard) pendingCards.add(parentCard);
+ };
+ const flush = () => {
+ frame = 0;
+ const cards = [...pendingCards].filter((card) => card.isConnected);
+ pendingCards.clear();
+ cards.forEach(syncNativeTopicQueueButton);
+ };
+ const schedule = () => {
+ if (!frame && pendingCards.size) frame = requestAnimationFrame(flush);
+ };
+ let observer = null;
+ const syncObserverTargets = () => {
+ observerRoot = retargetMainOutletObserver(
+ observer, observerRoot, { childList: true, subtree: true }, collect);
+ };
+ observer = new MutationObserver((records) => {
+ records.forEach((record) => {
+ if (record.target === document.body || !observerRoot?.isConnected ||
+ !observerRoot.contains(record.target)) syncObserverTargets();
+ collect(record.target);
+ record.addedNodes.forEach(collect);
+ });
+ schedule();
+ });
+ syncObserverTargets();
+ document.querySelectorAll(cardSelector).forEach((card) => pendingCards.add(card));
+ schedule();
+ document.body._ldpReaderQueueButtonsObserver = observer;
+ }
+
+ function syncNativeTopicTitleTools(card, dndControl) {
+ const line = dndControl?.closest('.link-top-line');
+ if (!card || !line || !card.contains(line)) return;
+ const normalizeText = (node) => String(node?.textContent || '').replace(/\s+/g, ' ').trim();
+ const expertLabel = [...line.querySelectorAll('*')].find((node) => normalizeText(node) === '专家回应');
+ if (!expertLabel) return;
+ const directChild = (node) => {
+ let current = node;
+ while (current && current.parentElement !== line) current = current.parentElement;
+ return current && current.parentElement === line ? current : null;
+ };
+ const expertItem = directChild(expertLabel);
+ const dndItem = directChild(dndControl);
+ if (!expertItem || !dndItem) return;
+ if (expertItem === dndItem && expertItem.classList.contains('ldp-native-topic-title-tools')) return;
+ const children = [...line.children];
+ const start = children.indexOf(expertItem);
+ const end = children.indexOf(dndItem);
+ if (start < 0 || end < start) return;
+ const tools = children.slice(start, end + 1);
+ const group = makeElement('span');
+ group.className = 'ldp-native-topic-title-tools';
+ expertItem.before(group);
+ group.append(...tools);
+ }
+
+ function markNativeDoNotDisturbBadges(scope = document, reactionCountsByTopicId = null) {
+ const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item';
+ const cards = [];
+ if (isFunction(scope?.matches) && scope.matches(cardSelector)) cards.push(scope);
+ if (isFunction(scope?.querySelectorAll)) cards.push(...scope.querySelectorAll(cardSelector));
+ if (!cards.length) return;
+ const reactionCounts = reactionCountsByTopicId || nativeTopicOpReactionCounts();
+ cards.forEach((card) => {
+ const cells = [...card.querySelectorAll(':scope > td')];
+ const dateCell = card.querySelector(':scope > td.views') || cells[3] || null;
+ cells.forEach((cell) => {
+ const text = cell === dateCell ? String(cell.textContent || '').replace(/\s+/g, ' ').trim() : '';
+ const hasTopicDate = /\b(?:\d{4}[/-])?\d{1,2}[/-]\d{1,2}\s+\d{1,2}:\d{2}\b/.test(text);
+ const hasOldTopicDate = /\b\d{4}[/-]\d{1,2}[/-]\d{1,2}\s+\d{1,2}:\d{2}\b/.test(text);
+ if (hasTopicDate && cell.dataset.ldpNativeTopicDate !== 'true') cell.dataset.ldpNativeTopicDate = 'true';
+ else if (!hasTopicDate && 'ldpNativeTopicDate' in cell.dataset) delete cell.dataset.ldpNativeTopicDate;
+ if (hasOldTopicDate && cell.dataset.ldpNativeOldTopic !== 'true') cell.dataset.ldpNativeOldTopic = 'true';
+ else if (!hasOldTopicDate && 'ldpNativeOldTopic' in cell.dataset) delete cell.dataset.ldpNativeOldTopic;
+ });
+ const controls = [...card.querySelectorAll('a,button,[role="button"]')];
+ const matchingControl = controls.find((node) => node.textContent.replace(/\s+/g, ' ').trim() === '免打扰');
+ const fallback = matchingControl ? null : [...card.querySelectorAll('span')].find((node) => node.textContent.replace(/\s+/g, ' ').trim() === '免打扰');
+ const dndControl = matchingControl || (fallback && (fallback.closest('a,button,[role="button"]') || fallback));
+ if (dndControl && dndControl.dataset.ldpNativeDnd !== 'true') dndControl.dataset.ldpNativeDnd = 'true';
+ syncNativeTopicTitleTools(card, dndControl);
+ syncNativeTopicQueueButton(card);
+ syncNativeTopicStatsComponent(card, reactionCounts);
+ });
+ }
+
+ function clearNativeEmbeddedTopicEnhancements(scope = document) {
+ if (!scope || !isFunction(scope.querySelectorAll)) return;
+ scope.querySelectorAll('.ldp-topic-stats-component').forEach((component) => component.remove());
+ scope.querySelectorAll('.ldp-native-topic-title-tools').forEach((group) => {
+ group.replaceWith(...[...group.childNodes]);
+ });
+ scope.querySelectorAll('[data-ldp-native-dnd]').forEach((node) => node.removeAttribute('data-ldp-native-dnd'));
+ scope.querySelectorAll('[data-ldp-native-topic-date]').forEach((node) => node.removeAttribute('data-ldp-native-topic-date'));
+ scope.querySelectorAll('[data-ldp-native-old-topic]').forEach((node) => node.removeAttribute('data-ldp-native-old-topic'));
+ }
+
+ function createReaderWorkspaceController(overlay, modal, isDirectTopicRoute) {
+ const resizeHandle = modal.querySelector('.ldp-reader-embed-resize');
+ const hostScrollbar = overlay.querySelector('.ldp-reader-host-scrollbar');
+ const hostScrollbarThumb = hostScrollbar?.querySelector('.ldp-reader-host-scrollbar-thumb');
+ const hostTopButton = overlay.querySelector('.ldp-reader-host-top');
+ const root = PAGE_ROOT;
+ const availableModes = isDirectTopicRoute ? TOPIC_READER_MODES : LIST_READER_MODES;
+ const workspaceStyleNodes = [root, overlay];
+ const workspaceScope = createLifecycleScope();
+ let mode = 'floating';
+ let pointer = null;
+ let resizeFrame = 0;
+ let embedWidth = currentListReaderEmbedWidth();
+ let destroyed = false;
+ let embeddedHostRoots = new Map();
+ let embeddedHostObserver = null;
+ let embeddedHostObserverRoot = null;
+ let embeddedHostFrame = 0;
+ let embeddedHostCardFrame = 0;
+ const embeddedHostChangedCards = new Set();
+ const embeddedHostActivityCards = new Set();
+ let hostScrollbarFrame = 0;
+ let hostScrollbarResizeObserver = null;
+ let hostScrollbarPointer = null;
+ let hostScrollbarState = { maxScroll: 0, maxThumbTop: 0, thumbHeight: 0 };
+ let hostScrollbarGeometry = { viewportHeight: 1, scrollHeight: 1, maxScroll: 0, trackHeight: 1 };
+ let hostScrollbarGeometryDirty = true;
+ let hostTopButtonTimer = 0;
+ let hostTopCountdownTimer = 0;
+ let hostScrollTop = 0;
+ let hostUpwardDistance = 0;
+ let hostUpwardStartedAt = 0;
+ let hostLastScrollAt = 0;
+ let hostPointerClientX = NaN;
+ let hostPointerClientY = NaN;
+ let hostTopJumping = false;
+ let nativeListRowHeight = 0;
+ const embeddedMode = () => readerPresentation(mode).embedded;
+
+ const setWorkspaceStyle = (name, value) => {
+ workspaceStyleNodes.forEach((node) => node.style.setProperty(name, value));
+ };
+ const clearWorkspaceStyle = (name) => {
+ workspaceStyleNodes.forEach((node) => node.style.removeProperty(name));
+ };
+ const measureNativeListRowHeight = () => {
+ const heights = [...document.querySelectorAll('tr.topic-list-item,.topic-list-item,.latest-topic-list-item')]
+ .filter((node) => !node.closest('.ldp-overlay'))
+ .map((node) => {
+ const rect = node.getBoundingClientRect();
+ return rect.width > 0 && rect.height >= 48 && rect.height <= 320 ? Math.round(rect.height) : 0;
+ })
+ .filter(Boolean)
+ .sort((left, right) => left - right);
+ return heights.length ? heights[Math.floor((heights.length - 1) / 2)] : 0;
+ };
+ const setEmbedResizing = (active) => {
+ root.classList.toggle('ldp-reader-embed-resizing', !!active);
+ overlay.classList.toggle('ldp-reader-embed-resizing', !!active);
+ };
+ const hostPageScrollTop = (maxScroll = Number.POSITIVE_INFINITY) => {
+ const scrollingElement = document.scrollingElement || root;
+ return Math.min(maxScroll, Math.max(0, Number(
+ window.scrollY || scrollingElement?.scrollTop || 0,
+ )));
+ };
+ const pageScrollMetrics = () => {
+ const scrollingElement = document.scrollingElement || root;
+ const viewportHeight = Math.max(1, Number(window.innerHeight || root.clientHeight || 0));
+ const scrollHeight = Math.max(
+ viewportHeight,
+ Number(scrollingElement?.scrollHeight || 0),
+ Number(document.body?.scrollHeight || 0),
+ );
+ const maxScroll = Math.max(0, scrollHeight - viewportHeight);
+ const scrollTop = hostPageScrollTop(maxScroll);
+ return { viewportHeight, scrollHeight, maxScroll, scrollTop };
+ };
+ const syncHostScrollbar = () => {
+ hostScrollbarFrame = 0;
+ if (destroyed || !embeddedMode() || !hostScrollbar || !hostScrollbarThumb) return;
+ if (hostScrollbarGeometryDirty) {
+ const metrics = pageScrollMetrics();
+ hostScrollbarGeometry = {
+ viewportHeight: metrics.viewportHeight,
+ scrollHeight: metrics.scrollHeight,
+ maxScroll: metrics.maxScroll,
+ trackHeight: Math.max(1, hostScrollbar.clientHeight),
+ };
+ hostScrollbarGeometryDirty = false;
+ }
+ const metrics = {
+ ...hostScrollbarGeometry,
+ scrollTop: hostPageScrollTop(hostScrollbarGeometry.maxScroll),
+ };
+ const trackHeight = metrics.trackHeight;
+ const thumbHeight = metrics.maxScroll > 0
+ ? Math.min(trackHeight, Math.max(24, trackHeight * metrics.viewportHeight / metrics.scrollHeight))
+ : trackHeight;
+ const maxThumbTop = Math.max(0, trackHeight - thumbHeight);
+ const thumbTop = metrics.maxScroll > 0
+ ? maxThumbTop * metrics.scrollTop / metrics.maxScroll
+ : 0;
+ hostScrollbarState = { maxScroll: metrics.maxScroll, maxThumbTop, thumbHeight };
+ hostScrollbar.classList.toggle('ldp-reader-host-scrollbar-inactive', metrics.maxScroll <= 0);
+ setAttributeValue(hostScrollbar, 'aria-disabled', metrics.maxScroll <= 0);
+ setAttributeValue(hostScrollbar, 'aria-valuemax', Math.round(metrics.maxScroll));
+ setAttributeValue(hostScrollbar, 'aria-valuenow', Math.round(metrics.scrollTop));
+ const nextHeight = `${Math.round(thumbHeight * 100) / 100}px`;
+ const nextTransform = `translateY(${Math.round(thumbTop * 100) / 100}px)`;
+ if (hostScrollbarThumb.style.height !== nextHeight) hostScrollbarThumb.style.height = nextHeight;
+ if (hostScrollbarThumb.style.transform !== nextTransform) {
+ hostScrollbarThumb.style.transform = nextTransform;
+ }
+ };
+ const scheduleHostScrollbarSync = () => {
+ if (hostScrollbarFrame || destroyed || !embeddedMode()) return;
+ hostScrollbarFrame = requestAnimationFrame(syncHostScrollbar);
+ };
+ const invalidateHostScrollbarGeometry = () => {
+ hostScrollbarGeometryDirty = true;
+ scheduleHostScrollbarSync();
+ };
+ const hideHostTopButton = () => {
+ if (hostTopButtonTimer) clearTimeout(hostTopButtonTimer);
+ if (hostTopCountdownTimer) clearInterval(hostTopCountdownTimer);
+ hostTopButtonTimer = 0;
+ hostTopCountdownTimer = 0;
+ if (hostTopButton) hostTopButton.hidden = true;
+ };
+ const resetHostUpwardScroll = (scrollTop = hostPageScrollTop()) => {
+ hostScrollTop = scrollTop;
+ hostUpwardDistance = 0;
+ hostUpwardStartedAt = 0;
+ hostLastScrollAt = 0;
+ };
+ const hostHorizontalBounds = () => mode === 'embed-left'
+ ? { left: embedWidth, right: window.innerWidth }
+ : { left: 0, right: window.innerWidth - embedWidth };
+ const rememberHostPointer = (event) => {
+ if (!embeddedMode() || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return;
+ const bounds = hostHorizontalBounds();
+ if (event.clientX < bounds.left || event.clientX > bounds.right) return;
+ hostPointerClientX = event.clientX;
+ hostPointerClientY = event.clientY;
+ };
+ const showHostTopButton = () => {
+ if (!hostTopButton || !Number.isFinite(hostPointerClientX) || !Number.isFinite(hostPointerClientY)) return;
+ if (hostPageScrollTop() <= 8) {
+ hideHostTopButton();
+ return;
+ }
+ if (hostTopButton.hidden) {
+ const bounds = hostHorizontalBounds();
+ const buttonSize = 30;
+ const edgeGap = 8;
+ const pointerGap = 12;
+ const left = Math.max(bounds.left + edgeGap, Math.min(
+ bounds.right - buttonSize - edgeGap,
+ hostPointerClientX + pointerGap,
+ ));
+ const top = Math.max(edgeGap, Math.min(
+ window.innerHeight - buttonSize - edgeGap,
+ hostPointerClientY - buttonSize / 2,
+ ));
+ hostTopButton.style.left = `${Math.round(left)}px`;
+ hostTopButton.style.top = `${Math.round(top)}px`;
+ hostTopButton.hidden = false;
+ }
+ if (hostTopButtonTimer) clearTimeout(hostTopButtonTimer);
+ if (hostTopCountdownTimer) clearInterval(hostTopCountdownTimer);
+ const countdown = hostTopButton.querySelector('.ldp-reader-host-top-countdown');
+ const hideAt = performance.now() + 3000;
+ const updateCountdown = () => {
+ if (countdown) countdown.textContent = String(Math.max(1, Math.ceil((hideAt - performance.now()) / 1000)));
+ };
+ updateCountdown();
+ hostTopCountdownTimer = setInterval(updateCountdown, 200);
+ hostTopButtonTimer = setTimeout(hideHostTopButton, 3000);
+ };
+ const onHostScroll = () => {
+ scheduleHostScrollbarSync();
+ if (!embeddedMode()) return;
+ const scrollTop = Math.max(0, Number(
+ window.scrollY || document.scrollingElement?.scrollTop || 0,
+ ));
+ if (hostTopJumping) {
+ resetHostUpwardScroll(scrollTop);
+ return;
+ }
+ const now = performance.now();
+ const upwardDelta = hostScrollTop - scrollTop;
+ hostScrollTop = scrollTop;
+ if (scrollTop <= 8) hideHostTopButton();
+ if (upwardDelta <= 0) {
+ hostUpwardDistance = 0;
+ hostUpwardStartedAt = 0;
+ hostLastScrollAt = now;
+ return;
+ }
+ if (!hostUpwardStartedAt || now - hostLastScrollAt > 240) {
+ hostUpwardStartedAt = now;
+ hostUpwardDistance = 0;
+ }
+ hostLastScrollAt = now;
+ hostUpwardDistance += upwardDelta;
+ if (hostUpwardDistance >= 240 && now - hostUpwardStartedAt <= 450) {
+ showHostTopButton();
+ hostUpwardStartedAt = now;
+ hostUpwardDistance = 0;
+ }
+ };
+ const onHostTopClick = () => {
+ if (!embeddedMode()) return;
+ hideHostTopButton();
+ hostTopJumping = true;
+ window.scrollTo({ top: 0, behavior: 'auto' });
+ requestAnimationFrame(() => {
+ hostTopJumping = false;
+ resetHostUpwardScroll(0);
+ });
+ };
+ const startHostScrollbarSync = () => {
+ if (!hostScrollbar || !hostScrollbarThumb) return;
+ if (!hostScrollbarResizeObserver && isFunction(ResizeObserver)) {
+ hostScrollbarResizeObserver = new ResizeObserver(invalidateHostScrollbarGeometry);
+ hostScrollbarResizeObserver.observe(root);
+ if (document.body) hostScrollbarResizeObserver.observe(document.body);
+ hostScrollbarResizeObserver.observe(hostScrollbar);
+ }
+ hostScrollbarGeometryDirty = true;
+ resetHostUpwardScroll();
+ scheduleHostScrollbarSync();
+ };
+ const stopHostScrollbarSync = () => {
+ if (hostScrollbarFrame) cancelAnimationFrame(hostScrollbarFrame);
+ hostScrollbarFrame = 0;
+ if (hostScrollbarResizeObserver) hostScrollbarResizeObserver.disconnect();
+ hostScrollbarResizeObserver = null;
+ hostScrollbarPointer = null;
+ hostScrollbarState = { maxScroll: 0, maxThumbTop: 0, thumbHeight: 0 };
+ hostScrollbarGeometry = { viewportHeight: 1, scrollHeight: 1, maxScroll: 0, trackHeight: 1 };
+ hostScrollbarGeometryDirty = true;
+ hideHostTopButton();
+ resetHostUpwardScroll(0);
+ if (!hostScrollbar || !hostScrollbarThumb) return;
+ hostScrollbar.classList.remove('ldp-reader-host-scrollbar-dragging');
+ hostScrollbar.classList.add('ldp-reader-host-scrollbar-inactive');
+ hostScrollbar.setAttribute('aria-disabled', 'true');
+ hostScrollbar.setAttribute('aria-valuemax', '0');
+ hostScrollbar.setAttribute('aria-valuenow', '0');
+ hostScrollbarThumb.style.removeProperty('height');
+ hostScrollbarThumb.style.removeProperty('transform');
+ };
+ const scrollHostFromPointer = (clientY) => {
+ if (!hostScrollbar || !hostScrollbarPointer || hostScrollbarState.maxScroll <= 0) return;
+ const rect = hostScrollbar.getBoundingClientRect();
+ const thumbTop = Math.max(0, Math.min(
+ hostScrollbarState.maxThumbTop,
+ clientY - rect.top - hostScrollbarPointer.offsetY,
+ ));
+ const ratio = hostScrollbarState.maxThumbTop > 0 ? thumbTop / hostScrollbarState.maxThumbTop : 0;
+ window.scrollTo({ top: ratio * hostScrollbarState.maxScroll, behavior: 'auto' });
+ };
+ const onHostScrollbarPointerDown = (event) => {
+ if (event.button !== 0 || !embeddedMode() || !hostScrollbar || !hostScrollbarThumb) return;
+ if (hostScrollbarFrame) cancelAnimationFrame(hostScrollbarFrame);
+ hostScrollbarFrame = 0;
+ hostScrollbarGeometryDirty = true;
+ syncHostScrollbar();
+ if (hostScrollbarState.maxScroll <= 0) return;
+ consumeEvent(event);
+ const thumbRect = hostScrollbarThumb.getBoundingClientRect();
+ const onThumb = event.target === hostScrollbarThumb;
+ hostScrollbarPointer = {
+ pointerId: event.pointerId,
+ offsetY: onThumb ? event.clientY - thumbRect.top : hostScrollbarState.thumbHeight / 2,
+ };
+ hostScrollbar.classList.add('ldp-reader-host-scrollbar-dragging');
+ hostScrollbar.setPointerCapture(event.pointerId);
+ try { hostScrollbar.focus({ preventScroll: true }); } catch { hostScrollbar.focus(); }
+ scrollHostFromPointer(event.clientY);
+ };
+ const onHostScrollbarPointerMove = (event) => {
+ if (!hostScrollbarPointer || event.pointerId !== hostScrollbarPointer.pointerId) return;
+ event.preventDefault();
+ scrollHostFromPointer(event.clientY);
+ };
+ const stopHostScrollbarPointer = (event) => {
+ if (!hostScrollbarPointer || event && event.pointerId !== hostScrollbarPointer.pointerId) return;
+ const pointerId = hostScrollbarPointer.pointerId;
+ hostScrollbarPointer = null;
+ if (hostScrollbar) {
+ hostScrollbar.classList.remove('ldp-reader-host-scrollbar-dragging');
+ try {
+ if (hostScrollbar.hasPointerCapture(pointerId)) hostScrollbar.releasePointerCapture(pointerId);
+ } catch {}
+ }
+ };
+ const onHostScrollbarKeyDown = (event) => {
+ if (!embeddedMode()) return;
+ const metrics = pageScrollMetrics();
+ let nextTop = metrics.scrollTop;
+ if (event.key === 'ArrowUp') nextTop -= 40;
+ else if (event.key === 'ArrowDown') nextTop += 40;
+ else if (event.key === 'PageUp') nextTop -= metrics.viewportHeight * .9;
+ else if (event.key === 'PageDown') nextTop += metrics.viewportHeight * .9;
+ else if (event.key === 'Home') nextTop = 0;
+ else if (event.key === 'End') nextTop = metrics.maxScroll;
+ else return;
+ consumeEvent(event);
+ window.scrollTo({ top: Math.max(0, Math.min(metrics.maxScroll, nextTop)), behavior: 'auto' });
+ };
+ const topLevelBodyChild = (node) => {
+ let current = node && node.nodeType === 1 ? node : null;
+ while (current?.parentElement && current.parentElement !== document.body) current = current.parentElement;
+ return current && current.parentElement === document.body ? current : null;
+ };
+ const syncEmbeddedHostObserverTargets = () => {
+ if (!embeddedHostObserver || !document.body) return;
+ embeddedHostObserverRoot = retargetMainOutletObserver(
+ embeddedHostObserver,
+ embeddedHostObserverRoot,
+ { childList: true, subtree: true, characterData: true },
+ );
+ };
+ const syncEmbeddedHostRoots = () => {
+ embeddedHostFrame = 0;
+ if (destroyed || !document.body || !embeddedMode()) return;
+ const nextRoots = new Map();
+ const rank = { sidebar: 1, header: 2, shell: 3 };
+ const mark = (node, role) => {
+ const topLevel = topLevelBodyChild(node);
+ if (!topLevel || topLevel === overlay || topLevel.contains(overlay)) return;
+ const previousRole = nextRoots.get(topLevel);
+ if (!previousRole || rank[role] > rank[previousRole]) nextRoots.set(topLevel, role);
+ };
+ document.querySelectorAll('#ember-app,#main-outlet-wrapper,#main-outlet,.list-container,.topic-list,.navigation-container').forEach((node) => mark(node, 'shell'));
+ document.querySelectorAll('.d-header,body > header').forEach((node) => mark(node, 'header'));
+ document.querySelectorAll('#d-sidebar,.sidebar-wrapper,.sidebar-container').forEach((node) => mark(node, 'sidebar'));
+ for (const node of embeddedHostRoots.keys()) {
+ if (!nextRoots.has(node)) node.removeAttribute('data-ldp-reader-host-root');
+ }
+ nextRoots.forEach((role, node) => {
+ node.setAttribute('data-ldp-reader-host-root', role);
+ if (role === 'shell') markNativeDoNotDisturbBadges(node);
+ });
+ embeddedHostRoots = nextRoots;
+ syncEmbeddedHostObserverTargets();
+ };
+ const scheduleEmbeddedHostRootSync = () => {
+ if (embeddedHostFrame || destroyed || !embeddedMode()) return;
+ embeddedHostFrame = requestAnimationFrame(syncEmbeddedHostRoots);
+ };
+ const flushEmbeddedHostCards = () => {
+ embeddedHostCardFrame = 0;
+ if (destroyed || !embeddedMode()) {
+ embeddedHostChangedCards.clear();
+ embeddedHostActivityCards.clear();
+ return;
+ }
+ const activityCards = [...embeddedHostActivityCards]
+ .filter((card) => card.isConnected && !embeddedHostChangedCards.has(card));
+ embeddedHostActivityCards.clear();
+ activityCards.forEach((card) => {
+ if (!syncNativeTopicActivityStat(card)) embeddedHostChangedCards.add(card);
+ });
+ const cards = [...embeddedHostChangedCards].filter((card) => card.isConnected);
+ embeddedHostChangedCards.clear();
+ if (!cards.length) return;
+ const reactionCountsByTopicId = nativeTopicOpReactionCounts();
+ cards.forEach((card) => markNativeDoNotDisturbBadges(card, reactionCountsByTopicId));
+ };
+ const scheduleEmbeddedHostCardSet = (cards, pendingCards) => {
+ cards.forEach((card) => pendingCards.add(card));
+ if (!pendingCards.size || embeddedHostCardFrame || destroyed || !embeddedMode()) return;
+ embeddedHostCardFrame = requestAnimationFrame(flushEmbeddedHostCards);
+ };
+ const startEmbeddedHostRootObserver = () => {
+ if (!embeddedHostObserver && document.body) {
+ embeddedHostObserver = new MutationObserver((records) => {
+ const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item';
+ const changedCards = new Set();
+ const collectNearestCard = (node) => {
+ const element = node && (node.nodeType === 1 ? node : node.parentElement);
+ if (!element || element.closest('.ldp-overlay,.ldp-topic-stats-component')) return;
+ const card = element.matches(cardSelector) ? element : element.closest(cardSelector);
+ if (card) changedCards.add(card);
+ };
+ const collectAddedCards = (node) => {
+ const element = node && (node.nodeType === 1 ? node : node.parentElement);
+ if (!element || element.closest('.ldp-overlay,.ldp-topic-stats-component')) return;
+ const card = element.matches(cardSelector) ? element : element.closest(cardSelector);
+ if (card) {
+ changedCards.add(card);
+ return;
+ }
+ if (element.querySelectorAll) element.querySelectorAll(cardSelector).forEach((item) => changedCards.add(item));
+ };
+ records.forEach((record) => {
+ const activityTarget = record.type === 'childList' && record.target.nodeType === 1 &&
+ record.target.matches('.relative-date') && record.target.closest('td.activity');
+ const onlyActivityTextChanged = activityTarget && record.addedNodes.length > 0 &&
+ record.removedNodes.length > 0 &&
+ [...record.addedNodes, ...record.removedNodes].every((node) => node.nodeType === 3);
+ if (onlyActivityTextChanged) {
+ const card = activityTarget.closest(cardSelector);
+ if (card) scheduleEmbeddedHostCardSet([card], embeddedHostActivityCards);
+ return;
+ }
+ if (record.type === 'childList' && (
+ record.target === document.body ||
+ !embeddedHostObserverRoot?.isConnected ||
+ !embeddedHostObserverRoot.contains(record.target)
+ )) scheduleEmbeddedHostRootSync();
+ const changedNodes = record.type === 'childList'
+ ? [...record.addedNodes, ...record.removedNodes]
+ : [];
+ const onlyOwnedStatsChanged = changedNodes.length > 0 && changedNodes.every((node) =>
+ node.nodeType === 1 && (node.matches('.ldp-topic-stats-component') || node.closest('.ldp-topic-stats-component'))
+ );
+ if (!onlyOwnedStatsChanged) collectNearestCard(record.target);
+ record.addedNodes?.forEach(collectAddedCards);
+ });
+ scheduleEmbeddedHostCardSet(changedCards, embeddedHostChangedCards);
+ });
+ }
+ syncEmbeddedHostRoots();
+ };
+ const clearEmbeddedHostRoots = () => {
+ if (embeddedHostFrame) cancelAnimationFrame(embeddedHostFrame);
+ if (embeddedHostCardFrame) cancelAnimationFrame(embeddedHostCardFrame);
+ embeddedHostFrame = 0;
+ embeddedHostCardFrame = 0;
+ embeddedHostChangedCards.clear();
+ embeddedHostActivityCards.clear();
+ if (embeddedHostObserver) embeddedHostObserver.disconnect();
+ embeddedHostObserver = null;
+ embeddedHostObserverRoot = null;
+ clearNativeEmbeddedTopicEnhancements();
+ for (const node of embeddedHostRoots.keys()) node.removeAttribute('data-ldp-reader-host-root');
+ embeddedHostRoots = new Map();
+ };
+ const clearEmbeddedHost = () => {
+ setEmbedResizing(false);
+ stopHostScrollbarPointer();
+ stopHostScrollbarSync();
+ clearEmbeddedHostRoots();
+ root.classList.remove('ldp-reader-embedded-left', 'ldp-reader-embedded-right');
+ if (!root.classList.contains('ldp-reader-sidepanel-active')) root.classList.remove('ldp-reader-workspace');
+ clearWorkspaceStyle('--ldp-reader-workspace-width');
+ delete overlay.dataset.readerWorkspaceWidth;
+ clearWorkspaceStyle('--ldp-reader-host-min-width');
+ clearWorkspaceStyle('--ldp-reader-native-row-height');
+ nativeListRowHeight = 0;
+ root.style.removeProperty('--ldp-reader-list-zebra-color');
+ root.style.removeProperty('--ldp-divider-line-color');
+ root.style.removeProperty('--ldp-divider-line-width');
+ document.querySelectorAll('[data-ldp-reader-active-topic]').forEach((card) => card.removeAttribute('data-ldp-reader-active-topic'));
+ };
+ const maximumEmbedWidth = () => Math.max(READER_EMBED_MIN_WIDTH, Math.floor(window.innerWidth - READER_HOST_MIN_WIDTH));
+ const canEmbed = () => window.innerWidth >= READER_EMBED_MIN_WIDTH + READER_HOST_MIN_WIDTH;
+ const clampEmbedWidth = (value) => Math.min(maximumEmbedWidth(), Math.max(READER_EMBED_MIN_WIDTH, Math.round(value)));
+ const setEmbedWidth = (value) => {
+ embedWidth = clampEmbedWidth(value);
+ overlay.dataset.readerWorkspaceWidth = String(embedWidth);
+ setWorkspaceStyle('--ldp-reader-workspace-width', `${embedWidth}px`);
+ return embedWidth;
+ };
+ const effectiveMode = (requested) => {
+ if (!availableModes.includes(requested)) return isDirectTopicRoute ? 'fullpage' : 'floating';
+ if (!readerPresentation(requested).embedded) return requested;
+ return canEmbed() ? requested : 'floating';
+ };
+ const apply = (requested = mode) => {
+ const previousMode = mode;
+ mode = effectiveMode(requested);
+ const presentation = readerPresentation(mode);
+ const embedded = presentation.embedded;
+ if (readerPresentation(previousMode).embedded && !embedded) {
+ stopHostScrollbarPointer();
+ stopHostScrollbarSync();
+ }
+ overlay.dataset.readerWorkspaceMode = mode;
+ overlay.classList.toggle('ldp-fullpage', presentation.fullPage);
+ overlay.classList.toggle('ldp-reader-embedded', embedded);
+ overlay.classList.toggle('ldp-reader-embedded-left', presentation.side === 'left');
+ overlay.classList.toggle('ldp-reader-embedded-right', presentation.side === 'right');
+ if (!embedded) {
+ clearEmbeddedHost();
+ return mode;
+ }
+ ensureReaderStyleMounted();
+ if (!root.classList.contains('ldp-reader-workspace')) nativeListRowHeight = measureNativeListRowHeight();
+ const width = currentListReaderEmbedWidth();
+ root.classList.add('ldp-reader-workspace');
+ root.classList.toggle('ldp-reader-embedded-left', presentation.side === 'left');
+ root.classList.toggle('ldp-reader-embedded-right', presentation.side === 'right');
+ setEmbedWidth(width);
+ setWorkspaceStyle('--ldp-reader-host-min-width', `${READER_HOST_MIN_WIDTH}px`);
+ if (nativeListRowHeight) setWorkspaceStyle('--ldp-reader-native-row-height', `${nativeListRowHeight}px`);
+ const appearance = currentAppearanceProfile();
+ const themeMode = resolvedReaderThemeMode();
+ const dividerLineColor = appearanceThemeColor(appearance, 'dividerLineColor', themeMode);
+ const dividerLineDefault = APPEARANCE_PROFILE_DEFAULT[
+ appearanceThemeValueKey('dividerLineColor', themeMode)
+ ];
+ root.style.setProperty('--ldp-reader-list-zebra-color',
+ appearanceThemeColor(appearance, 'listZebraColor', themeMode));
+ root.classList.toggle('ldp-structure-colors-disabled', !appearance.structureColorsEnabled);
+ if (!appearance.structureColorsEnabled || dividerLineColor === dividerLineDefault)
+ root.style.removeProperty('--ldp-divider-line-color');
+ else root.style.setProperty('--ldp-divider-line-color', dividerLineColor);
+ if (appearance.dividerLineWidth === DIVIDER_LINE_WIDTH_DEFAULT) root.style.removeProperty('--ldp-divider-line-width');
+ else root.style.setProperty('--ldp-divider-line-width', `${appearance.dividerLineWidth}px`);
+ startEmbeddedHostRootObserver();
+ startHostScrollbarSync();
+ return mode;
+ };
+ const emitChange = () => {
+ if (!destroyed) overlay.dispatchEvent(new CustomEvent('ldp-reader-workspace-change'));
+ };
+ const setMode = (nextMode) => {
+ if (!availableModes.includes(nextMode)) return false;
+ if (isDirectTopicRoute) setPref('topicReaderMode', nextMode);
+ else setPrefs({ listReaderMode: nextMode });
+ apply(nextMode);
+ emitChange();
+ return true;
+ };
+ const applyResizePreview = () => {
+ resizeFrame = 0;
+ if (!pointer || !embeddedMode()) return;
+ const width = mode === 'embed-left'
+ ? pointer.clientX
+ : window.innerWidth - pointer.clientX;
+ setEmbedWidth(width);
+ };
+ const onResizePointerMove = (event) => {
+ if (!pointer || event.pointerId !== pointer.pointerId) return;
+ pointer.clientX = event.clientX;
+ if (!resizeFrame) resizeFrame = requestAnimationFrame(applyResizePreview);
+ };
+ const stopResize = (event) => {
+ if (!pointer || (event && event.pointerId !== pointer.pointerId)) {
+ setEmbedResizing(false);
+ return;
+ }
+ if (resizeFrame) {
+ cancelAnimationFrame(resizeFrame);
+ resizeFrame = 0;
+ applyResizePreview();
+ }
+ setEmbedResizing(false);
+ setPref('listReaderEmbedWidth', embedWidth);
+ try {
+ if (resizeHandle?.hasPointerCapture(pointer.pointerId)) resizeHandle.releasePointerCapture(pointer.pointerId);
+ } catch {}
+ pointer = null;
+ emitChange();
+ };
+ const onResizePointerDown = (event) => {
+ if (event.button !== 0 || !embeddedMode()) return;
+ consumeEvent(event);
+ pointer = { pointerId: event.pointerId, clientX: event.clientX };
+ setEmbedResizing(true);
+ resizeHandle.setPointerCapture(event.pointerId);
+ };
+ const onViewportResize = () => {
+ const requested = isDirectTopicRoute
+ ? currentTopicReaderMode()
+ : currentListReaderMode();
+ apply(requested);
+ emitChange();
+ };
+ if (resizeHandle) {
+ workspaceScope.listen(resizeHandle, 'pointerdown', onResizePointerDown);
+ workspaceScope.listen(resizeHandle, 'pointermove', onResizePointerMove);
+ ['pointerup', 'pointercancel'].forEach((type) =>
+ workspaceScope.listen(resizeHandle, type, stopResize));
+ }
+ workspaceScope.listen(window, 'resize', onViewportResize);
+ workspaceScope.listen(window, 'pointermove', rememberHostPointer, { passive: true });
+ workspaceScope.listen(window, 'wheel', rememberHostPointer, { capture: true, passive: true });
+ workspaceScope.listen(window, 'scroll', onHostScroll, { passive: true });
+ if (hostTopButton) workspaceScope.listen(hostTopButton, 'click', onHostTopClick);
+ if (hostScrollbar) {
+ workspaceScope.listen(hostScrollbar, 'pointerdown', onHostScrollbarPointerDown);
+ workspaceScope.listen(hostScrollbar, 'pointermove', onHostScrollbarPointerMove);
+ ['pointerup', 'pointercancel'].forEach((type) =>
+ workspaceScope.listen(hostScrollbar, type, stopHostScrollbarPointer));
+ workspaceScope.listen(hostScrollbar, 'keydown', onHostScrollbarKeyDown);
+ }
+ apply(isDirectTopicRoute
+ ? currentTopicReaderMode()
+ : currentListReaderMode());
+
+ return {
+ mode: () => mode,
+ isEmbedded: embeddedMode,
+ isFullPage: () => readerPresentation(mode).fullPage,
+ isFloating: () => readerPresentation(mode).floating,
+ setMode,
+ sync: onViewportResize,
+ destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ if (resizeFrame) cancelAnimationFrame(resizeFrame);
+ stopResize();
+ clearEmbeddedHost();
+ workspaceScope.destroy();
+ },
+ };
+ }
+
+ function createNativeComposerWindowController(readerSession) {
+ const root = PAGE_ROOT;
+ const rootStyle = root.style;
+ const geometryProperties = [
+ '--ldp-composer-left', '--ldp-composer-top', '--ldp-composer-width',
+ '--ldp-composer-height', '--ldp-composer-transform',
+ ];
+ const positionedAttribute = 'data-ldp-reader-composer-positioned';
+ const resizeDirections = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
+ let composer = null;
+ let composerRoot = null;
+ let composerOverflowLayers = [];
+ let composerOverflowFrame = 0;
+ let chrome = null;
+ let composerObserver = null;
+ let composerHostObserver = null;
+ let state = null;
+ let pointer = null;
+ let pointerFrame = 0;
+ let pointerClientX = 0;
+ let pointerClientY = 0;
+ let chromeFrame = 0;
+ let wheelLayer = null;
+ let statePersistTimer = 0;
+ let nativeFloatingFrame = 0;
+ let nativeFloatingObserver = null;
+ const ownedTopLayerElements = new Set();
+ const nativeFloatingSurfaceSelector = '.fk-d-menu,.emoji-picker';
+ let destroyed = false;
+
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
+ const numeric = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback;
+ const cssPixels = (value) => `${Math.round(value * 100) / 100}px`;
+ const sessionActive = () => !destroyed && (!readerSession || readerSession.isCurrent('opening', 'active'));
+ const desktopWindow = () => window.innerWidth > 760;
+ const syncWheelLayer = (visible) => {
+ if (visible && !wheelLayer) {
+ wheelLayer = makeElement('div'); wheelLayer.className = 'ldp-composer-wheel-layer sciapp-ldp-owned';
+ appendReaderPortalNode(wheelLayer); bindFloatingSurfaceWheel(wheelLayer);
+ }
+ if (wheelLayer) wheelLayer.hidden = !visible;
+ if (visible) bindFloatingSurfaceWheel(composer);
+ };
+ const elementInTopLayer = (element) => {
+ try {
+ return !!(element?.matches(':popover-open'));
+ } catch {
+ return false;
+ }
+ };
+ const releaseOwnedTopLayer = (element) => {
+ if (!element || !ownedTopLayerElements.delete(element)) return;
+ try {
+ if (elementInTopLayer(element) && isFunction(element.hidePopover)) element.hidePopover();
+ } catch {}
+ element.removeAttribute('popover');
+ delete element.dataset.ldpReaderTopLayer;
+ };
+ const promoteOwnedTopLayer = (element, type) => {
+ if (!element || !isFunction(element.showPopover)) return false;
+ if (element.hasAttribute('popover') && !ownedTopLayerElements.has(element)) return elementInTopLayer(element);
+ if (!ownedTopLayerElements.has(element)) {
+ element.setAttribute('popover', 'manual');
+ element.dataset.ldpReaderTopLayer = type;
+ ownedTopLayerElements.add(element);
+ }
+ try {
+ if (!elementInTopLayer(element)) element.showPopover();
+ return elementInTopLayer(element);
+ } catch {
+ releaseOwnedTopLayer(element);
+ return false;
+ }
+ };
+ const nativeFloatingSurfaceVisible = (element) => {
+ if (!element || !element.isConnected || element.closest('#reply-control') || element.contains(composer) || element.closest('dialog[open]')) return false;
+ if (element.classList.contains('closed')) return false;
+ if (!composerElementAvailable(element)) return false;
+ const elementStyle = getComputedStyle(element);
+ if (elementStyle.display === 'none' || elementStyle.visibility === 'hidden') return false;
+ const rect = element.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ };
+ const syncNativeFloatingTopLayers = () => {
+ nativeFloatingFrame = 0;
+ if (!elementInTopLayer(composer)) return;
+ const visible = [...document.querySelectorAll(nativeFloatingSurfaceSelector)]
+ .filter(nativeFloatingSurfaceVisible);
+ const surfaces = visible.filter((element) => !visible.some((parent) => parent !== element && parent.contains(element)));
+ ownedTopLayerElements.forEach((element) => {
+ if (element.dataset.ldpReaderTopLayer === 'portal' && !surfaces.includes(element)) releaseOwnedTopLayer(element);
+ });
+ surfaces.forEach((element) => promoteOwnedTopLayer(element, 'portal'));
+ };
+ const scheduleNativeFloatingTopLayerSync = () => {
+ if (nativeFloatingFrame) return;
+ nativeFloatingFrame = requestAnimationFrame(syncNativeFloatingTopLayers);
+ };
+ const nativeFloatingMutationNodeIsRelevant = (node) => {
+ const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
+ if (!element) return false;
+ return element.matches(nativeFloatingSurfaceSelector) ||
+ !!element.closest(nativeFloatingSurfaceSelector) ||
+ !!element.querySelector(nativeFloatingSurfaceSelector);
+ };
+ const nativeFloatingMutationIsRelevant = (mutation) => {
+ if (mutation.type === 'attributes') return nativeFloatingMutationNodeIsRelevant(mutation.target);
+ return [...mutation.addedNodes, ...mutation.removedNodes].some(nativeFloatingMutationNodeIsRelevant);
+ };
+ const startNativeFloatingTopLayerSync = () => {
+ if (!nativeFloatingObserver && document.body) {
+ nativeFloatingObserver = new MutationObserver((mutations) => {
+ if (mutations.some(nativeFloatingMutationIsRelevant)) scheduleNativeFloatingTopLayerSync();
+ });
+ nativeFloatingObserver.observe(document.body, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'open'],
+ });
+ }
+ scheduleNativeFloatingTopLayerSync();
+ };
+ const releaseOwnedComposerTopLayers = () => {
+ if (nativeFloatingFrame) cancelAnimationFrame(nativeFloatingFrame);
+ nativeFloatingFrame = 0;
+ if (nativeFloatingObserver) nativeFloatingObserver.disconnect();
+ nativeFloatingObserver = null;
+ [...ownedTopLayerElements].reverse().forEach(releaseOwnedTopLayer);
+ };
+ const releaseComposerTopLayers = () => {
+ releaseOwnedComposerTopLayers();
+ root.classList.remove('ldp-composer-host-isolated');
+ };
+ const syncComposerTopLayers = () => {
+ const composerOpen = !!(composer?.isConnected &&
+ !composer.classList.contains('closed') && composerElementAvailable(composer));
+ syncWheelLayer(composerOpen);
+ root.classList.toggle('ldp-composer-host-isolated', composerOpen);
+ if (!composerOpen || !promoteOwnedTopLayer(composer, 'composer')) {
+ releaseOwnedComposerTopLayers();
+ return false;
+ }
+ if (chrome && !chrome.hidden) promoteOwnedTopLayer(chrome, 'chrome');
+ startNativeFloatingTopLayerSync();
+ return true;
+ };
+ const clearComposerRoot = () => {
+ if (composerRoot) composerRoot.removeAttribute('data-ldp-reader-composer-root');
+ composerRoot = null;
+ };
+ const syncComposerRoot = () => {
+ let next = composer;
+ while (next?.parentElement && next.parentElement !== document.body) next = next.parentElement;
+ if (!next || next.parentElement !== document.body) next = null;
+ if (next === composerRoot) return;
+ clearComposerRoot();
+ composerRoot = next;
+ if (composerRoot) composerRoot.setAttribute('data-ldp-reader-composer-root', '1');
+ };
+ const clearComposerOverflowLayers = () => {
+ if (composerOverflowFrame) cancelAnimationFrame(composerOverflowFrame);
+ composerOverflowFrame = 0;
+ composerOverflowLayers.forEach((layer) => layer.removeAttribute('data-ldp-reader-composer-overflow'));
+ composerOverflowLayers = [];
+ };
+ const syncComposerOverflowLayers = () => {
+ composerOverflowFrame = 0;
+ composerOverflowLayers.forEach((layer) => layer.removeAttribute('data-ldp-reader-composer-overflow'));
+ composerOverflowLayers = [];
+ if (!composer || !composer.isConnected || composer.classList.contains('closed')) return;
+ const composerRect = composer.getBoundingClientRect();
+ if (composerRect.width <= 0 || composerRect.height <= 0) return;
+ const candidates = [composer, ...composer.querySelectorAll('*')].map((candidate) => {
+ if (candidate.clientWidth <= 0 || candidate.scrollWidth <= candidate.clientWidth + 1) return false;
+ if (candidate.closest('.d-editor-button-bar,[role="toolbar"]')) return false;
+ const overflowX = getComputedStyle(candidate).overflowX;
+ if (overflowX !== 'auto' && overflowX !== 'scroll') return false;
+ const rect = candidate.getBoundingClientRect();
+ const coveredWidth = Math.max(0, Math.min(rect.right, composerRect.right) - Math.max(rect.left, composerRect.left));
+ const coveredHeight = Math.max(0, Math.min(rect.bottom, composerRect.bottom) - Math.max(rect.top, composerRect.top));
+ const widthCoverage = coveredWidth / composerRect.width;
+ const heightCoverage = coveredHeight / composerRect.height;
+ if (widthCoverage < .85 || heightCoverage < .75) return false;
+ return { candidate, coverage: widthCoverage * heightCoverage };
+ }).filter(Boolean).sort((left, right) => right.coverage - left.coverage);
+ composerOverflowLayers = candidates.length ? [candidates[0].candidate] : [];
+ composerOverflowLayers.forEach((layer) => layer.setAttribute('data-ldp-reader-composer-overflow', '1'));
+ };
+ const scheduleComposerOverflowSync = () => {
+ if (composerOverflowFrame) return;
+ composerOverflowFrame = requestAnimationFrame(syncComposerOverflowLayers);
+ };
+ const limits = () => {
+ const margin = Math.max(0, Math.min(
+ 16,
+ Math.floor((window.innerWidth - 1) / 2),
+ Math.floor((window.innerHeight - 1) / 2),
+ ));
+ const left = margin;
+ const top = margin;
+ const right = window.innerWidth - margin;
+ const bottom = window.innerHeight - margin;
+ const maxWidth = Math.max(1, right - left);
+ const maxHeight = Math.max(1, bottom - top);
+ return {
+ left,
+ top,
+ right,
+ bottom,
+ maxWidth,
+ maxHeight,
+ minWidth: Math.min(520, maxWidth),
+ minHeight: Math.min(300, maxHeight),
+ };
+ };
+ const defaultState = () => {
+ const bounds = limits();
+ const width = Math.min(1200, bounds.maxWidth);
+ const height = clamp(bounds.maxHeight * .7, bounds.minHeight, bounds.maxHeight);
+ return {
+ left: bounds.left + (bounds.maxWidth - width) / 2,
+ top: bounds.top + (bounds.maxHeight - height) / 2,
+ width,
+ height,
+ };
+ };
+ const normalizeState = (next) => {
+ const bounds = limits();
+ const fallback = defaultState();
+ const width = clamp(numeric(next?.width, fallback.width), bounds.minWidth, bounds.maxWidth);
+ const height = clamp(numeric(next?.height, fallback.height), bounds.minHeight, bounds.maxHeight);
+ return {
+ left: clamp(numeric(next?.left, fallback.left), bounds.left, bounds.right - width),
+ top: clamp(numeric(next?.top, fallback.top), bounds.top, bounds.bottom - height),
+ width,
+ height,
+ };
+ };
+ const preferredState = () => {
+ const fallback = defaultState();
+ return normalizeState({
+ left: PREFS[`${COMPOSER_WINDOW_PREFIX}X`] || fallback.left,
+ top: PREFS[`${COMPOSER_WINDOW_PREFIX}Y`] || fallback.top,
+ width: PREFS[`${COMPOSER_WINDOW_PREFIX}Width`] || fallback.width,
+ height: PREFS[`${COMPOSER_WINDOW_PREFIX}Height`] || fallback.height,
+ });
+ };
+ const syncFont = (fontProfile = currentFontProfile()) => {
+ const values = normalizeFontProfile(fontProfile, READER_FONT_DEFAULT);
+ if (!state || !composer) {
+ if (composer) {
+ composer.style.removeProperty('--ldp-composer-font-size');
+ clearInterfaceFontScale(composer.style);
+ clearReaderFontAppearance(composer.style);
+ }
+ applyComposerFontScale();
+ return;
+ }
+ const bounds = limits();
+ const widthTarget = Math.min(980, bounds.maxWidth);
+ const heightTarget = Math.min(720, bounds.maxHeight);
+ const widthProgress = clamp((state.width - bounds.minWidth) / Math.max(1, widthTarget - bounds.minWidth), 0, 1);
+ const heightProgress = clamp((state.height - bounds.minHeight) / Math.max(1, heightTarget - bounds.minHeight), 0, 1);
+ const automaticPixels = 12 + Math.min(widthProgress, heightProgress) * 4;
+ const preferenceRatio = normalizeFontScale(values.composer, COMPOSER_FONT_SCALE_DEFAULT) /
+ COMPOSER_FONT_SCALE_DEFAULT;
+ const pixels = clamp(automaticPixels * preferenceRatio, 8, 40);
+ applyInterfaceFontScale(composer.style, values.interface);
+ applyReaderFontAppearance(composer.style, values);
+ composer.style.setProperty('--ldp-composer-font-size', `${Math.round(pixels * 100) / 100}px`);
+ };
+ const syncChromeNow = (syncSize = true) => {
+ chromeFrame = 0;
+ if (!chrome) return;
+ const available = sessionActive() && state && desktopWindow() && composerElementAvailable(composer) &&
+ !composer.classList.contains('closed') && !composer.classList.contains('fullscreen');
+ chrome.hidden = !available;
+ if (!available) return;
+ if (syncSize) {
+ chrome.style.setProperty('--ldp-composer-chrome-width', cssPixels(state.width));
+ chrome.style.setProperty('--ldp-composer-chrome-height', cssPixels(state.height));
+ }
+ chrome.style.transform = `translate3d(${cssPixels(state.left)},${cssPixels(state.top)},0)`;
+ };
+ const scheduleChromeSync = () => {
+ if (chromeFrame) return;
+ chromeFrame = requestAnimationFrame(syncChromeNow);
+ };
+ const applyState = (next) => {
+ const previous = state;
+ state = normalizeState(next);
+ if (!composer) return;
+ const composerStyle = composer.style;
+ const sizeChanged = !previous || previous.width !== state.width || previous.height !== state.height;
+ if (!previous) {
+ composerStyle.setProperty('--ldp-composer-left', '0px');
+ composerStyle.setProperty('--ldp-composer-top', '0px');
+ composerStyle.setProperty('--ldp-composer-transform', 'none');
+ }
+ if (sizeChanged) {
+ composerStyle.setProperty('--ldp-composer-width', cssPixels(state.width));
+ composerStyle.setProperty('--ldp-composer-height', cssPixels(state.height));
+ }
+ if (desktopWindow() && !composer.classList.contains('fullscreen')) {
+ composerStyle.setProperty('translate', `${cssPixels(state.left)} ${cssPixels(state.top)}`);
+ } else {
+ composerStyle.removeProperty('translate');
+ }
+ if (sizeChanged) syncFont();
+ if (chromeFrame) cancelAnimationFrame(chromeFrame);
+ syncChromeNow(sizeChanged);
+ composer.setAttribute(positionedAttribute, '1');
+ };
+ const persistState = () => {
+ if (statePersistTimer) clearTimeout(statePersistTimer);
+ statePersistTimer = 0;
+ if (!state || !desktopWindow()) return;
+ setPrefs({
+ [`${COMPOSER_WINDOW_PREFIX}Width`]: Math.round(state.width),
+ [`${COMPOSER_WINDOW_PREFIX}Height`]: Math.round(state.height),
+ [`${COMPOSER_WINDOW_PREFIX}X`]: Math.round(state.left),
+ [`${COMPOSER_WINDOW_PREFIX}Y`]: Math.round(state.top),
+ });
+ };
+ const scheduleStatePersist = () => {
+ if (statePersistTimer) clearTimeout(statePersistTimer);
+ statePersistTimer = setTimeout(persistState, 160);
+ };
+ const syncState = () => applyState(state || preferredState());
+ const clearGeometry = () => {
+ const composerStyle = composer?.style;
+ if (composer) composer.removeAttribute(positionedAttribute);
+ geometryProperties.forEach((property) => {
+ rootStyle.removeProperty(property);
+ if (composerStyle) composerStyle.removeProperty(property);
+ });
+ if (composerStyle) {
+ composerStyle.removeProperty('--ldp-composer-font-size');
+ clearInterfaceFontScale(composerStyle);
+ clearReaderFontAppearance(composerStyle);
+ composerStyle.removeProperty('translate');
+ }
+ applyComposerFontScale();
+ };
+ const stopPointer = (event) => {
+ if (!pointer) {
+ if (chrome) chrome.classList.remove('is-interacting');
+ root.classList.remove('ldp-composer-window-interacting');
+ return;
+ }
+ if (event && event.pointerId !== pointer.id) return;
+ if (event && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
+ pointerClientX = event.clientX;
+ pointerClientY = event.clientY;
+ }
+ if (pointerFrame) cancelAnimationFrame(pointerFrame);
+ pointerFrame = 0;
+ applyPointerPosition();
+ const activePointer = pointer;
+ pointer = null;
+ const target = activePointer.target;
+ if (target?.hasPointerCapture(activePointer.id)) target.releasePointerCapture(activePointer.id);
+ if (chrome) chrome.classList.remove('is-interacting');
+ root.classList.remove('ldp-composer-window-interacting');
+ scheduleComposerOverflowSync();
+ persistState();
+ };
+ const resizeState = (start, direction, deltaX, deltaY) => {
+ const bounds = limits();
+ let left = start.left;
+ let top = start.top;
+ let right = start.left + start.width;
+ let bottom = start.top + start.height;
+ if (direction.includes('w')) left = clamp(start.left + deltaX, bounds.left, right - bounds.minWidth);
+ if (direction.includes('e')) right = clamp(start.left + start.width + deltaX, left + bounds.minWidth, bounds.right);
+ if (direction.includes('n')) top = clamp(start.top + deltaY, bounds.top, bottom - bounds.minHeight);
+ if (direction.includes('s')) bottom = clamp(start.top + start.height + deltaY, top + bounds.minHeight, bounds.bottom);
+ return { left, top, width: right - left, height: bottom - top };
+ };
+ function applyPointerPosition() {
+ if (!pointer) return;
+ const deltaX = pointerClientX - pointer.startX;
+ const deltaY = pointerClientY - pointer.startY;
+ if (pointer.mode === 'move') {
+ const bounds = limits();
+ applyState({
+ left: clamp(pointer.state.left + deltaX, bounds.left, bounds.right - pointer.state.width),
+ top: clamp(pointer.state.top + deltaY, bounds.top, bounds.bottom - pointer.state.height),
+ width: pointer.state.width,
+ height: pointer.state.height,
+ });
+ } else {
+ applyState(resizeState(pointer.state, pointer.mode, deltaX, deltaY));
+ }
+ }
+ const movePointer = (event) => {
+ if (!pointer || event.pointerId !== pointer.id) return;
+ pointerClientX = event.clientX;
+ pointerClientY = event.clientY;
+ if (!pointerFrame) {
+ pointerFrame = requestAnimationFrame(() => {
+ pointerFrame = 0;
+ applyPointerPosition();
+ });
+ }
+ event.preventDefault();
+ };
+ const startPointer = (event, mode, target) => {
+ if (!state || !desktopWindow() || !nativeComposerVisible(composer) || composer.classList.contains('fullscreen')) return;
+ if (event.button !== 0) return;
+ pointer = {
+ id: event.pointerId,
+ mode,
+ startX: event.clientX,
+ startY: event.clientY,
+ state: { ...state },
+ target,
+ };
+ pointerClientX = event.clientX;
+ pointerClientY = event.clientY;
+ target.setPointerCapture(event.pointerId);
+ chrome.classList.add('is-interacting');
+ root.classList.add('ldp-composer-window-interacting');
+ consumeEvent(event);
+ };
+ const ensureChrome = () => {
+ if (chrome) return chrome;
+ chrome = makeElement('div');
+ chrome.className = 'ldp-composer-window-chrome sciapp-ldp-owned';
+ chrome.hidden = true;
+ chrome.innerHTML = `
${resizeDirections.map((direction) => direction === 'se'
+ ? '
'
+ : `
`).join('')}`;
+ appendReaderPortalNode(chrome);
+ chrome.addEventListener('pointerdown', (event) => {
+ const handle = event.target instanceof Element
+ ? event.target.closest('.ldp-composer-drag-handle,[data-resize]')
+ : null;
+ if (handle && chrome.contains(handle)) startPointer(event, handle.dataset.resize || 'move', handle);
+ });
+ chrome.addEventListener('keydown', (event) => {
+ if (!state || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
+ const step = event.shiftKey ? 40 : 12;
+ if (event.target.matches('.ldp-composer-drag-handle')) {
+ const next = { ...state };
+ if (event.key === 'ArrowLeft') next.left -= step;
+ else if (event.key === 'ArrowRight') next.left += step;
+ else if (event.key === 'ArrowUp') next.top -= step;
+ else next.top += step;
+ applyState(next);
+ } else if (event.target.matches('[data-resize="se"]')) {
+ const deltaX = event.key === 'ArrowLeft' ? -step : (event.key === 'ArrowRight' ? step : 0);
+ const deltaY = event.key === 'ArrowUp' ? -step : (event.key === 'ArrowDown' ? step : 0);
+ applyState(resizeState(state, 'se', deltaX, deltaY));
+ } else return;
+ scheduleStatePersist();
+ event.preventDefault();
+ });
+ chrome.addEventListener('pointermove', movePointer);
+ ['pointerup', 'pointercancel', 'lostpointercapture'].forEach((type) =>
+ chrome.addEventListener(type, stopPointer));
+ return chrome;
+ };
+ const deactivate = () => {
+ stopPointer();
+ persistState();
+ releaseComposerTopLayers();
+ syncWheelLayer(false);
+ state = null;
+ if (chrome) chrome.hidden = true;
+ clearGeometry();
+ clearComposerOverflowLayers();
+ clearComposerRoot();
+ };
+ const observeComposer = (element) => {
+ if (composerObserver) composerObserver.disconnect();
+ if (composer && composer !== element) releaseComposerTopLayers();
+ composer = element;
+ composerObserver = new MutationObserver(() => {
+ if (!composer || !composer.isConnected || composer.classList.contains('closed') || !composerElementAvailable(composer)) {
+ deactivate();
+ return;
+ }
+ ensureChrome();
+ syncComposerRoot();
+ if (!state) syncState();
+ if (composer.classList.contains('fullscreen')) {
+ stopPointer();
+ composer.style.removeProperty('translate');
+ } else if (state && !composer.style.getPropertyValue('translate')) {
+ applyState(state);
+ }
+ scheduleChromeSync();
+ syncComposerTopLayers();
+ scheduleComposerOverflowSync();
+ });
+ composerObserver.observe(composer, {
+ attributes: true,
+ attributeFilter: ['class', 'hidden', 'aria-hidden'],
+ childList: true,
+ subtree: true,
+ });
+ };
+ const open = (element) => {
+ if (!sessionActive() || !element) return false;
+ ensureChrome();
+ if (composer !== element) observeComposer(element);
+ syncComposerRoot();
+ if (!state) syncState();
+ else scheduleChromeSync();
+ syncComposerTopLayers();
+ scheduleComposerOverflowSync();
+ return true;
+ };
+ const syncComposerElement = () => {
+ const nextComposer = document.querySelector('#reply-control');
+ if (nextComposer === composer) {
+ if (nativeComposerVisible(nextComposer) && !state) open(nextComposer);
+ return;
+ }
+ if (composerObserver) composerObserver.disconnect();
+ composerObserver = null;
+ if (composer) deactivate();
+ composer = null;
+ if (!nextComposer) return;
+ observeComposer(nextComposer);
+ if (nativeComposerVisible(nextComposer)) open(nextComposer);
+ };
+ const syncLayer = () => {
+ if (!composer || !nativeComposerVisible(composer)) return false;
+ syncComposerRoot();
+ if (state) syncState();
+ syncComposerTopLayers();
+ scheduleComposerOverflowSync();
+ return true;
+ };
+ const onWindowResize = () => {
+ stopPointer();
+ if (state) syncState();
+ else scheduleChromeSync();
+ syncComposerTopLayers();
+ scheduleComposerOverflowSync();
+ };
+ const destroy = () => {
+ if (destroyed) return;
+ destroyed = true;
+ stopPointer();
+ persistState();
+ if (composerObserver) composerObserver.disconnect();
+ if (composerHostObserver) composerHostObserver.disconnect();
+ if (chromeFrame) cancelAnimationFrame(chromeFrame);
+ window.removeEventListener('resize', onWindowResize);
+ releaseComposerTopLayers();
+ if (chrome) removeOwnedNode(chrome);
+ if (wheelLayer) removeOwnedNode(wheelLayer);
+ chrome = null;
+ wheelLayer = null;
+ state = null;
+ clearGeometry();
+ clearComposerOverflowLayers();
+ clearComposerRoot();
+ composer = null;
+ };
+ const syncComposerHostObserverTargets = () => {
+ if (!composerHostObserver || !document.body) return;
+ composerHostObserver.disconnect();
+ const nextComposer = document.querySelector('#reply-control');
+ if (!nextComposer) {
+ const bootstrapRoot = document.querySelector('#ember-app') || document.body;
+ composerHostObserver.observe(bootstrapRoot, { childList: true, subtree: true });
+ let bootstrapAncestor = bootstrapRoot.parentElement;
+ while (bootstrapAncestor) {
+ composerHostObserver.observe(bootstrapAncestor, { childList: true });
+ if (bootstrapAncestor === document.body) break;
+ bootstrapAncestor = bootstrapAncestor.parentElement;
+ }
+ return;
+ }
+ let ancestor = nextComposer.parentElement;
+ while (ancestor) {
+ composerHostObserver.observe(ancestor, { childList: true });
+ if (ancestor === document.body) break;
+ ancestor = ancestor.parentElement;
+ }
+ };
+ if (document.body) {
+ composerHostObserver = new MutationObserver(() => {
+ syncComposerElement();
+ syncComposerHostObserverTargets();
+ });
+ syncComposerHostObserverTargets();
+ }
+ syncComposerElement();
+ window.addEventListener('resize', onWindowResize);
+ return { open, syncFont, syncLayer, destroy };
+ }
+
+ function discourseModelValue(model, key) {
+ if (!model) return undefined;
+ if (isFunction(model.get)) return model.get(key);
+ return model[key];
+ }
+
+ function rememberReaderNativeComposer(ctx, composer, metadata = {}) {
+ const model = discourseModelValue(composer, 'model');
+ if (!ctx || !composer || !model) return false;
+ ctx.nativeComposerSession = { composer, model, ...metadata };
+ if (isFunction(ctx.bindNativeComposerRefresh)) ctx.bindNativeComposerRefresh();
+ return true;
+ }
+
+ function cancelNativeComposerDraftDebounce(composer) {
+ const runloopModule = lookupDiscourseModule('@ember/runloop');
+ const cancelRunloop = runloopModule?.cancel;
+ if (isFunction(cancelRunloop)) cancelRunloop(composer?._saveDraftDebounce);
+ }
+
+ async function discardReaderNativeComposer(ctx) {
+ const ownedSession = ctx?.nativeComposerSession;
+ const visibleComposer = nativeComposerVisible(document.querySelector('#reply-control'))
+ ? lookupDiscourse('service:composer')
+ : null;
+ const visibleModel = discourseModelValue(visibleComposer, 'model');
+ const composer = visibleModel ? visibleComposer : ownedSession?.composer;
+ const model = visibleModel || ownedSession?.model;
+ if (ctx) ctx.nativeComposerSession = null;
+ if (!composer || discourseModelValue(composer, 'model') !== model) return true;
+
+ const appEvents = composer.appEvents || lookupDiscourse('service:app-events');
+ let cleanupError = null;
+ try {
+ composer.skipAutoSave = true;
+ cancelNativeComposerDraftDebounce(composer);
+ } catch (error) {
+ cleanupError = error;
+ }
+
+ try {
+ if (isFunction(composer.destroyDraft)) await composer.destroyDraft();
+ } catch (error) {
+ cleanupError = error;
+ }
+
+ if (discourseModelValue(composer, 'model') === model) {
+ try {
+ if (isFunction(model.clearState)) model.clearState();
+ } catch (error) {
+ cleanupError = cleanupError || error;
+ }
+ try {
+ if (isFunction(composer.close)) composer.close();
+ } catch (error) {
+ cleanupError = cleanupError || error;
+ }
+ try {
+ if (isFunction(appEvents?.trigger)) appEvents.trigger('composer:cancelled');
+ } catch (error) {
+ cleanupError = cleanupError || error;
+ }
+ }
+
+ try {
+ composer.skipAutoSave = false;
+ } catch (error) {
+ cleanupError = cleanupError || error;
+ }
+ if (cleanupError) console.warn('[LINUX DO reader] native composer discard cleanup failed', cleanupError);
+ return !cleanupError;
+ }
+
+ async function saveAndCloseReaderNativeComposer(ctx) {
+ const ownedSession = ctx?.nativeComposerSession;
+ if (ctx) ctx.nativeComposerSession = null;
+ const composer = ownedSession?.composer;
+ const model = ownedSession?.model;
+ if (!composer || discourseModelValue(composer, 'model') !== model) return true;
+
+ try {
+ cancelNativeComposerDraftDebounce(composer);
+ if (!isFunction(composer.saveAndCloseComposer)) return false;
+ const closeResult = composer.saveAndCloseComposer();
+ const savePromise = composer._saveDraftPromise;
+ await Promise.resolve(closeResult);
+ if (savePromise) await savePromise;
+ return true;
+ } catch (error) {
+ console.warn('[LINUX DO reader] native composer draft save failed', error);
+ return false;
+ } finally {
+ try {
+ composer.skipAutoSave = false;
+ } catch {}
+ }
+ }
+
+ function nativeComposerFailure(reason, error) {
+ const messages = {
+ target: '无法识别要回复的楼层,请重新展开该楼层后再试',
+ service: 'LINUX DO 原生回复组件尚未就绪,请稍后重试',
+ model: '无法创建原生回复上下文,请刷新页面后重试',
+ draft: '无法获取当前主题草稿信息,请刷新页面后重试',
+ open: 'LINUX DO 原生回复打开失败,请稍后重试',
+ };
+ console.warn(`[LINUX DO reader] native composer: ${reason}`, error || '');
+ showSelectionToast(messages[reason] || messages.open);
+ return false;
+ }
+
+ async function waitForNativeComposerPopup(ctx, timeout = 5000) {
+ const deadline = Date.now() + timeout;
+ let preparedPopup = null;
+ while (Date.now() < deadline) {
+ const popup = document.querySelector('#reply-control');
+ if (composerElementAvailable(popup) && !popup.classList.contains('closed') &&
+ popup !== preparedPopup && ctx && ctx.nativeComposerWindow) {
+ ctx.nativeComposerWindow.open(popup);
+ preparedPopup = popup;
+ }
+ if (nativeComposerVisible(popup)) return true;
+ await waitForDelay(80);
+ }
+ return false;
+ }
+
+ function focusReaderNativeComposerInput(ctx) {
+ requestAnimationFrame(() => {
+ if (ctx?.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return;
+ const popup = document.querySelector('#reply-control');
+ if (!nativeComposerVisible(popup)) return;
+ const input = [...popup.querySelectorAll(
+ 'textarea.d-editor-input, textarea, .ProseMirror[contenteditable="true"]'
+ )].find((candidate) => composerElementAvailable(candidate) &&
+ !candidate.matches('[disabled],[aria-disabled="true"]'));
+ if (!input) return;
+ try { input.focus({ preventScroll: true }); } catch { input.focus(); }
+ });
+ }
+
+ function createDetachedComposerModels(postNode, ctx, Topic, Post) {
+ const topicData = ctx.topicData || {};
+ const postData = postNode?._ldpPostData || {};
+ const topicModel = Topic.create({
+ id: +ctx.topicId,
+ title: topicData.title || '',
+ fancy_title: topicData.fancy_title || topicData.title || '',
+ slug: topicData.slug || 'topic',
+ category_id: topicData.category_id,
+ archetype: topicData.archetype || 'regular',
+ draft_key: topicData.draft_key,
+ draft_sequence: topicData.draft_sequence,
+ posts_count: topicData.posts_count,
+ highest_post_number: topicData.highest_post_number,
+ last_posted_at: topicData.last_posted_at,
+ chunk_size: topicData.chunk_size,
+ details: Object.assign({ can_create_post: true }, topicData.details || {}),
+ tags: Array.isArray(topicData.tags) ? topicData.tags.slice() : [],
+ });
+ if (!isFunction(Post.munge))
+ throw new Error('Discourse 楼层模型尚未就绪');
+ const postAttributes = Object.assign({}, postData, {
+ id: +(postNode && postNode.dataset.postId),
+ topic_id: +ctx.topicId,
+ post_number: +(postNode && postNode.dataset.postNumber),
+ username: postData.username || (postNode && postNode.dataset.username) || '',
+ name: postData.name || '',
+ avatar_template: postData.avatar_template || '',
+ post_type: postData.post_type || 1,
+ reply_count: postData.reply_count || 0,
+ topic: topicModel,
+ actions_summary: Array.isArray(postData.actions_summary)
+ ? postData.actions_summary.map((action) => ({ ...action }))
+ : [],
+ });
+ const postModel = Post.create(Post.munge(postAttributes));
+ return { topicModel, postModel };
+ }
+
+ async function tryOpenNativeReplyComposer(post, ctx, initialRaw = '') {
+ const postNumber = +(post && post.dataset.postNumber);
+ const postId = +(post && post.dataset.postId);
+ if (!postNumber || !postId) return nativeComposerFailure('target');
+ const composer = lookupDiscourse('service:composer');
+ const appEvents = composer && (composer.appEvents || lookupDiscourse('service:app-events'));
+ const composerModule = lookupDiscourseModule('discourse/models/composer');
+ const topicModule = lookupDiscourseModule('discourse/models/topic');
+ const postModule = lookupDiscourseModule('discourse/models/post');
+ const draftModule = lookupDiscourseModule('discourse/models/draft');
+ const Composer = composerModule?.default;
+ const Topic = topicModule?.default;
+ const Post = postModule?.default;
+ const Draft = draftModule?.default;
+ if (!composer || !isFunction(composer.open) || !appEvents)
+ return nativeComposerFailure('service');
+ if (!Composer || !Composer.REPLY || !Topic || !isFunction(Topic.create) ||
+ !Post || !isFunction(Post.create))
+ return nativeComposerFailure('model');
+ if (!Draft || !isFunction(Draft.get)) return nativeComposerFailure('draft');
+ const rememberReplyComposer = () => rememberReaderNativeComposer(ctx, composer, {
+ action: 'reply',
+ parentPostNumber: postNumber,
+ highestPostNumber: Math.max(
+ 0,
+ +(ctx.totalPosts || 0),
+ +(ctx.topicData?.highest_post_number || 0),
+ ),
+ });
+
+ try {
+ let topicData = ctx.topicData || {};
+ if ((!topicData.draft_key || topicData.draft_sequence == null) &&
+ ctx.loader && isFunction(ctx.loader.refreshTopicData)) {
+ const freshTopic = await ctx.loader.refreshTopicData({ prime: false });
+ if (freshTopic) {
+ ctx.topicData = freshTopic;
+ topicData = freshTopic;
+ }
+ }
+ if (!topicData.draft_key || topicData.draft_sequence == null)
+ return nativeComposerFailure('draft');
+ if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false;
+
+ const { topicModel, postModel } = createDetachedComposerModels(post, ctx, Topic, Post);
+ const quoteRaw = String(initialRaw || '').trim();
+ const currentModel = discourseModelValue(composer, 'model');
+ const currentTopicId = +discourseModelValue(discourseModelValue(currentModel, 'topic'), 'id');
+ const currentViewOpen = discourseModelValue(currentModel, 'viewOpen') === true ||
+ String(discourseModelValue(currentModel, 'composeState') || '').toLowerCase() === 'open';
+ const currentAction = discourseModelValue(currentModel, 'action');
+ const currentReply = String(discourseModelValue(currentModel, 'reply') || '');
+ const sameTopicReplyOpen = currentModel && currentViewOpen && currentTopicId === +ctx.topicId &&
+ currentAction === Composer.REPLY;
+ if (sameTopicReplyOpen && await waitForNativeComposerPopup(ctx, 640)) {
+ rememberReplyComposer();
+ const replyTarget = postNumber === 1 ? null : postModel;
+ currentModel.set('post', replyTarget);
+ if (quoteRaw) appEvents.trigger('composer:insert-block', quoteRaw);
+ focusReaderNativeComposerInput(ctx);
+ return true;
+ }
+
+ const currentPopup = document.querySelector('#reply-control');
+ const currentPopupAvailable = composerElementAvailable(currentPopup) &&
+ !currentPopup.classList.contains('closed');
+ if (currentViewOpen && (currentTopicId === +ctx.topicId || !currentPopupAvailable)) {
+ console.info('[LINUX DO reader] native composer: recovering stale view', {
+ topicId: +ctx.topicId,
+ previousTopicId: currentTopicId || 0,
+ action: currentAction || '',
+ popupVisible: nativeComposerVisible(currentPopup),
+ });
+ if (ctx.nativeComposerSession && ctx.nativeComposerSession.model === currentModel) {
+ ctx.nativeComposerSession = null;
+ }
+ if (isFunction(composer.close)) composer.close();
+ await waitForDelay(0);
+ }
+
+ const options = {
+ action: Composer.REPLY,
+ draftKey: topicData.draft_key,
+ draftSequence: topicData.draft_sequence,
+ skipJumpOnSave: true,
+ };
+ if (postNumber === 1) options.topic = topicModel;
+ else options.post = postModel;
+
+ const draftData = await Draft.get(options.draftKey);
+ if (draftData?.draft) {
+ const data = JSON.parse(draftData.draft);
+ options.draftSequence = draftData.draft_sequence;
+ options.reply = `${String(data.reply || '')}${quoteRaw ? `\n${quoteRaw}` : ''}`;
+ if (data.whisper !== undefined) options.whisper = data.whisper;
+ } else if (quoteRaw) {
+ options.quote = quoteRaw;
+ }
+ if (currentReply && currentViewOpen && currentTopicId === +ctx.topicId) {
+ options.reply = `${currentReply}${quoteRaw ? `\n${quoteRaw}` : ''}`;
+ delete options.quote;
+ }
+ console.info('[LINUX DO reader] native composer: opening', {
+ topicId: +ctx.topicId,
+ postId,
+ postNumber,
+ quoted: !!quoteRaw,
+ });
+ await composer.open(options);
+ rememberReplyComposer();
+ if (!await waitForNativeComposerPopup(ctx)) return nativeComposerFailure('open');
+ focusReaderNativeComposerInput(ctx);
+ console.info('[LINUX DO reader] native composer: visible', {
+ topicId: +ctx.topicId,
+ postId,
+ postNumber,
+ });
+ return true;
+ } catch (error) {
+ if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false;
+ return nativeComposerFailure('open', error);
+ }
+ }
+
+ async function tryOpenNativeEditComposer(post, ctx) {
+ const postNumber = +(post && post.dataset.postNumber);
+ const postId = +(post && post.dataset.postId);
+ const postData = post?._ldpPostData || {};
+ if (!postNumber || !postId || postData.can_edit !== true) return false;
+ const composer = lookupDiscourse('service:composer');
+ const composerModule = lookupDiscourseModule('discourse/models/composer');
+ const topicModule = lookupDiscourseModule('discourse/models/topic');
+ const postModule = lookupDiscourseModule('discourse/models/post');
+ const Composer = composerModule?.default;
+ const Topic = topicModule?.default;
+ const Post = postModule?.default;
+ if (!composer || !isFunction(composer.open) || !Composer || !Composer.EDIT ||
+ !Topic || !isFunction(Topic.create) || !Post || !isFunction(Post.create))
+ return nativeComposerFailure('service');
+ try {
+ const freshPost = await fetchJSON(`${BASE}/posts/${postId}.json`, {
+ cacheMode: 'refresh',
+ allowStaleOnError: false,
+ });
+ post._ldpPostData = Object.assign({}, postData, freshPost);
+ const { postModel } = createDetachedComposerModels(post, ctx, Topic, Post);
+ const topicData = ctx.topicData || {};
+ await composer.open({
+ action: Composer.EDIT,
+ post: postModel,
+ draftKey: topicData.draft_key,
+ draftSequence: topicData.draft_sequence,
+ skipJumpOnSave: true,
+ });
+ rememberReaderNativeComposer(ctx, composer, {
+ action: 'edit',
+ postNumber,
+ postId,
+ });
+ if (!await waitForNativeComposerPopup(ctx)) return nativeComposerFailure('open');
+ return true;
+ } catch (error) {
+ return nativeComposerFailure('open', error);
+ }
+ }
+
+ function detachedNativePostModel(post, ctx) {
+ const topicModule = lookupDiscourseModule('discourse/models/topic');
+ const postModule = lookupDiscourseModule('discourse/models/post');
+ const Topic = topicModule?.default;
+ const Post = postModule?.default;
+ if (!Topic || !isFunction(Topic.create) || !Post || !isFunction(Post.create)) return null;
+ return createDetachedComposerModels(post, ctx, Topic, Post).postModel;
+ }
+
+ function openNativeAdminPostMenu(post, anchor, ctx) {
+ const menu = lookupDiscourse('service:menu');
+ const componentModule = lookupDiscourseModule('discourse/components/admin-post-menu');
+ const AdminPostMenu = componentModule?.default;
+ const topicController = lookupDiscourse('controller:topic');
+ const postModel = detachedNativePostModel(post, ctx);
+ if (!menu || !isFunction(menu.show) || !AdminPostMenu || !topicController ||
+ !isFunction(topicController.send) || !postModel) {
+ showSelectionToast('LINUX DO 原生楼层管理菜单尚未就绪,请稍后重试');
+ return false;
+ }
+ const topicAction = (name) => () => topicController.send(name, postModel);
+ menu.show(anchor, {
+ identifier: 'admin-post-menu',
+ component: AdminPostMenu,
+ modalForMobile: true,
+ autofocus: true,
+ data: {
+ post: postModel,
+ changeNotice: topicAction('changeNotice'),
+ changePostOwner: topicAction('changePostOwner'),
+ grantBadge: topicAction('grantBadge'),
+ lockPost: topicAction('lockPost'),
+ permanentlyDeletePost: topicAction('permanentlyDeletePost'),
+ rebakePost: topicAction('rebakePost'),
+ showPagePublish: topicAction('showPagePublish'),
+ togglePostType: topicAction('togglePostType'),
+ toggleWiki: topicAction('toggleWiki'),
+ unhidePost: topicAction('unhidePost'),
+ unlockPost: topicAction('unlockPost'),
+ scheduleRerender: () => ctx.refreshPostByNumber && ctx.refreshPostByNumber(+post.dataset.postNumber),
+ },
+ });
+ return true;
+ }
+
+ function removeReaderPost(post, ctx) {
+ const postId = +(post && post.dataset.postId);
+ const postNumber = +(post && post.dataset.postNumber);
+ const postData = post?._ldpPostData || {};
+ const parentNumber = +(postData.reply_to_post_number || post && post.dataset.replyToPostNumber || 0);
+ readerPostCopies(postId, ctx).forEach((node) => removeOwnedNode(node));
+ const item = ctx.streamItemMap.get(postNumber);
+ if (item) {
+ const index = item.index;
+ cancelPostContentHydration(item.node, ctx);
+ cancelPostLatexRender(item.node, ctx);
+ destroyReaderMedia(item.node);
+ ctx.streamHydratedNodes.delete(item.node);
+ ctx.streamItems.splice(index, 1);
+ ctx.streamItemMap.delete(postNumber);
+ ctx.streamNodeMap.delete(postNumber);
+ ctx.streamMountedNodes.delete(item.node);
+ reindexStreamItems(ctx, Math.max(0, index));
+ invalidateStreamLayout(ctx);
+ scheduleStreamWindowSync(ctx);
+ }
+ const replies = parentNumber && ctx.directRepliesByParent.get(parentNumber);
+ if (replies) replies.delete(postNumber);
+ const state = parentNumber && ctx.subReplyState.get(parentNumber);
+ const parentNode = parentNumber && ctx.streamNodeMap.get(parentNumber);
+ if (state) {
+ state.all = state.all.filter((reply) => +reply.post_number !== postNumber);
+ if (state.nodeCache) state.nodeCache.delete(postNumber);
+ state.totalCount = Math.max(0, state.totalCount - 1);
+ state.renderedKey = '';
+ if (parentNode?.isConnected) renderSubReplyBatch(parentNumber, ctx, 0, parentNode);
+ }
+ if (parentNode) {
+ const total = Math.max(0, +(parentNode.dataset.childReplyTotal || 0) - 1);
+ parentNode.dataset.childReplyTotal = String(total);
+ if (parentNode._ldpPostData) parentNode._ldpPostData.reply_count = total;
+ syncChildControls(parentNode);
+ }
+ const cacheEntry = TOPIC_DATA_CACHE.get(String(ctx.topicId));
+ const cachedTopic = (cacheEntry?.topic) || ctx.topicData;
+ if (cachedTopic?.post_stream && Array.isArray(cachedTopic.post_stream.posts)) {
+ cachedTopic.post_stream.posts = cachedTopic.post_stream.posts.filter((item) => +item.id !== postId);
+ setCachedTopicData(ctx.topicId, cachedTopic);
+ }
+ updateCommentsHeader(ctx);
+ ctx.timeline.update();
+ }
+
+ async function deleteReaderPost(post, ctx) {
+ const postData = post?._ldpPostData || {};
+ const postId = +(post && post.dataset.postId);
+ if (!postId || postData.can_delete !== true) return false;
+ return runReaderActionOnce(ctx, `post:${postId}:delete`, async () => {
+ if (!window.confirm('确定删除这条回复吗?')) return false;
+ const postModel = detachedNativePostModel(post, ctx);
+ const currentUser = lookupDiscourse('service:current-user');
+ if (!postModel || !isFunction(postModel.destroy) || !currentUser)
+ throw new Error('Discourse 楼层删除接口尚未就绪');
+ await postModel.destroy(currentUser);
+ await clearStoredResponseCache({ tags: [`post:${postId}`, `topic:${ctx.topicId}`] });
+ removeReaderPost(post, ctx);
+ return true;
+ });
+ }
+
+ function positionNativeBoostMenu(anchor, menu) {
+ const anchorRect = anchor.getBoundingClientRect();
+ const menuRect = menu.getBoundingClientRect();
+ const width = Math.max(menuRect.width || 0, 280);
+ const height = Math.max(menuRect.height || 0, 84);
+ const left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - width - 8));
+ const below = anchorRect.bottom + 6;
+ const top = below + height <= window.innerHeight - 8
+ ? below
+ : Math.max(8, anchorRect.top - height - 6);
+ menu.style.left = `${Math.round(left)}px`;
+ menu.style.top = `${Math.round(top)}px`;
+ return true;
+ }
+
+ function syncRenderedBoost(post, postData, ctx) {
+ const current = post.querySelector(':scope > .ldp-boost-list');
+ const holder = makeElement('template');
+ holder.innerHTML = renderBoostList(postData.boosts, { canQuickBoost: canShowBoostButton(postData), ctx });
+ const next = holder.content.firstElementChild;
+ if (current && next) current.replaceWith(next);
+ else if (next) post.insertBefore(next, post.querySelector(':scope > .ldp-collapse-replies'));
+ hydratePersistentAvatarImages(post);
+ const actions = post.querySelector(':scope > .ldp-reactions .ldp-actions');
+ const boostButton = actions?.querySelector('.ldp-boostbtn');
+ if (canShowBoostButton(postData) && actions && !boostButton) {
+ const actionHolder = makeElement('template');
+ actionHolder.innerHTML = `
`;
+ const contextSlot = actions.querySelector('.ldp-context-actions-slot');
+ actions.insertBefore(actionHolder.content.firstElementChild, contextSlot || null);
+ } else if (!canShowBoostButton(postData))
+ boostButton?.remove();
+ if (ctx.topicActionRail) { ctx.topicActionRail._ldpPostData = postData; syncPostActionControls(ctx.topicActionRail, ctx); }
+ }
+
+ function syncDeletedReaderBoost(post, boostId, ctx) {
+ const postId = +(post && post.dataset.postId || 0);
+ if (!postId || !boostId) return;
+ let latestPostData = null;
+ const copies = readerPostCopies(postId, ctx);
+ (copies.length ? copies : [post]).forEach((copy) => {
+ if (!copy) return;
+ const postData = copy._ldpPostData || {};
+ const boosts = Array.isArray(postData.boosts)
+ ? postData.boosts
+ : (postData.boosts ? [postData.boosts] : []);
+ postData.boosts = boosts.filter((boost) => String(boost?.id || '') !== String(boostId));
+ postData.can_boost = true;
+ copy._ldpPostData = postData;
+ latestPostData = postData;
+ syncRenderedBoost(copy, postData, ctx);
+ });
+ if (latestPostData) persistReaderPostState(ctx, latestPostData);
+ }
+
+ async function deleteReaderBoost(post, boostBubble, ctx) {
+ const boostId = boostBubble && boostBubble.dataset.boostId;
+ if (!post || !boostId) return false;
+ await apiSend(`${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}`, 'DELETE', null, null, {
+ invalidateTags: [`post:${post.dataset.postId}`, `topic:${ctx.topicId}`],
+ });
+ syncDeletedReaderBoost(post, boostId, ctx);
+ showSelectionToast('Boost 已删除');
+ return true;
+ }
+
+ async function openReaderBoostReportDialog(boostBubble, ctx) {
+ const boostId = boostBubble && boostBubble.dataset.boostId;
+ if (!boostId) return false;
+ const boost = await fetchJSON(
+ `${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}.json`,
+ { cacheMode: 'bypass', allowStaleOnError: false, key: `boost:${boostId}:permissions` }
+ );
+ if (!boost || boost.can_flag !== true)
+ throw new Error(boost?.user_flag_status ? '你已经举报过这个 Boost' : '当前账号不能举报这个 Boost');
+ const availableFlags = new Set(Array.isArray(boost.available_flags) ? boost.available_flags : []);
+ const site = lookupDiscourse('service:site');
+ const siteFlags = [...site?.flagTypes || []];
+ const descriptionText = (value) => {
+ const holder = makeElement('template');
+ holder.innerHTML = String(value || '');
+ return String(holder.content.textContent || '').replace(/\s+/g, ' ').trim();
+ };
+ const reportOptions = siteFlags
+ .filter((flag) => (
+ flag && flag.enabled !== false && availableFlags.has(flag.name_key) &&
+ (!Array.isArray(flag.applies_to) || flag.applies_to.includes('DiscourseBoosts::Boost'))
+ ))
+ .map((flag) => ({
+ id: +flag.id,
+ nameKey: flag.name_key,
+ label: flag.name || flag.name_key,
+ description: descriptionText(flag.description || flag.short_description) || '提交给社区管理人员审核。',
+ requireMessage: flag.require_message === true,
+ }));
+ if (!reportOptions.length) throw new Error('当前没有可用的 Boost 举报类型');
+ const username = String(boost.user?.username || boostBubble.dataset.boostUser || '').trim();
+ return !!openReaderReportForm(ctx, {
+ title: '举报 Boost',
+ intro: username
+ ? `举报 @${username} 的 Boost 会直接提交给社区,不会离开阅读器。`
+ : '举报会直接提交给社区,不会离开阅读器。',
+ options: reportOptions,
+ placeholder: '选填;所选类型要求说明时必须填写',
+ onSubmit: async (actionType, message) => {
+ const params = { flag_type_id: actionType };
+ if (message) params.message = message;
+ await apiSend(`${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}/flags`, 'POST', params);
+ return '举报已提交';
+ },
+ });
+ }
+
+ function renderNativeBoostMenu(post, anchor, ctx, initialRaw = '') {
+ const emojiMenuIdentifier = 'ldp-native-boost-emoji-picker';
+ const emojiPickerSurfaceSelector = '.fk-d-menu,.emoji-picker';
+ const overlay = post?.closest('.ldp-overlay');
+ const postData = post?._ldpPostData;
+ const scrollRoot = anchor?.closest?.('.ldp-descendant-replies-list') ||
+ (ctx?.scrollRoot?.contains(anchor) ? ctx.scrollRoot : null);
+ const createBoostModule = lookupDiscourseModule('discourse/plugins/discourse-boosts/discourse/lib/create-boost');
+ const createBoost = createBoostModule?.default;
+ const currentUser = ME_CURRENT_USER;
+ const nativeMenu = lookupDiscourse('service:menu');
+ const prettyTextEmoji = lookupDiscourseModule('pretty-text/emoji');
+ const discourseText = lookupDiscourseModule('discourse/lib/text');
+ const unicodeEmojiRegex = prettyTextEmoji?.emojiReplacementRegex
+ ? new RegExp(prettyTextEmoji.emojiReplacementRegex, 'g')
+ : null;
+ if (!overlay || !postData || !document.body ||
+ !hasDiscourseCapability('boosts', { post: postData, topic: ctx?.topicData })) return false;
+
+ const previous = document.body.querySelector(':scope > .ldp-native-boost-menu');
+ if (isFunction(previous?._ldpClose)) previous._ldpClose();
+ else if (previous) previous.remove();
+
+ const menu = makeElement('div');
+ const menuScope = createLifecycleScope();
+ menu.className = 'ldp-native-boost-menu sciapp-ldp-owned';
+ menu.setAttribute('role', 'dialog');
+ setLabel(menu, '助推');
+ menu.innerHTML = `
+
+
`;
+ document.body.appendChild(menu);
+ applyReaderThemeVariables(menu);
+ positionNativeBoostMenu(anchor, menu);
+ bindFloatingSurfaceWheel(menu);
+
+ const editor = menu.querySelector('.discourse-boosts__input');
+ const count = menu.querySelector('.ldp-native-boost-count');
+ const emojiButton = menu.querySelector('.discourse-boosts__emoji-btn');
+ const submit = menu.querySelector('.discourse-boosts__submit');
+ const cancel = menu.querySelector('.discourse-boosts__cancel');
+ const error = menu.querySelector('.ldp-native-boost-error');
+ let submitting = false;
+ let closed = false;
+ let anchorSyncFrame = 0;
+ let anchorObserver = null;
+ let previousEditorHTML = '';
+ let emojiPickerContent = null;
+ let emojiPickerObserver = null;
+ let composing = false;
+
+ const closeEmojiPicker = () => {
+ emojiPickerObserver?.disconnect();
+ emojiPickerObserver = null;
+ if (isFunction(emojiPickerContent?._ldpStopEmojiImageCache)) {
+ emojiPickerContent._ldpStopEmojiImageCache();
+ }
+ emojiPickerContent = null;
+ if (!nativeMenu || !isFunction(nativeMenu.close)) return;
+ try {
+ const result = nativeMenu.close(emojiMenuIdentifier);
+ if (isFunction(result?.catch)) result.catch(() => {});
+ } catch {}
+ };
+ const placeEditorCursorAtEnd = () => {
+ editor.focus();
+ const range = document.createRange();
+ range.selectNodeContents(editor);
+ range.collapse(false);
+ const selection = readerSelection();
+ if (!selection) return;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ };
+ const textStats = (value) => {
+ const text = String(value || '').replace(/\u00a0/g, ' ');
+ const unicodeMatches = unicodeEmojiRegex ? text.match(unicodeEmojiRegex) : null;
+ return {
+ raw: text,
+ length: [...unicodeMatches ? text.replace(unicodeEmojiRegex, 'x') : text].length,
+ emojiCount: unicodeMatches ? unicodeMatches.length : 0,
+ };
+ };
+ const readInput = () => {
+ const stats = { raw: '', length: 0, emojiCount: 0 };
+ const visit = (node) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const current = textStats(node.textContent);
+ stats.raw += current.raw;
+ stats.length += current.length;
+ stats.emojiCount += current.emojiCount;
+ return;
+ }
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
+ if (node.nodeName === 'IMG' && node.classList.contains('emoji')) {
+ stats.raw += node.getAttribute('alt') || '';
+ stats.length += 1;
+ stats.emojiCount += 1;
+ return;
+ }
+ node.childNodes.forEach(visit);
+ };
+ editor.childNodes.forEach(visit);
+ return stats;
+ };
+
+ const rectsOverlap = (rect, clip) => rect.bottom > clip.top && rect.top < clip.bottom &&
+ rect.right > clip.left && rect.left < clip.right;
+ const anchorIsVisible = () => {
+ if (!anchor.isConnected || !menu.isConnected) return false;
+ const rect = anchor.getBoundingClientRect();
+ if (!rect.width || !rect.height) return false;
+ const viewport = { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 };
+ if (!rectsOverlap(rect, viewport)) return false;
+ return !scrollRoot || rectsOverlap(rect, scrollRoot.getBoundingClientRect());
+ };
+
+ const close = () => {
+ if (closed) return;
+ closed = true;
+ closeEmojiPicker();
+ menuScope.destroy();
+ menu.remove();
+ };
+ const onOutside = (event) => {
+ if (!menu.isConnected) return close();
+ if (eventPathClosest(event, `[data-identifier="${emojiMenuIdentifier}"],.emoji-picker`)) return;
+ if (!eventPathIncludes(event, menu) && !eventPathIncludes(event, anchor)) close();
+ };
+ const syncAnchor = () => {
+ anchorSyncFrame = 0;
+ if (!anchorIsVisible()) return close();
+ positionNativeBoostMenu(anchor, menu);
+ };
+ const scheduleAnchorSync = () => {
+ if (closed || anchorSyncFrame) return;
+ anchorSyncFrame = menuScope.raf(syncAnchor);
+ };
+ const syncInput = () => {
+ let stats = readInput();
+ if (!composing && (stats.length > 16 || stats.emojiCount > 5)) {
+ editor.innerHTML = previousEditorHTML;
+ placeEditorCursorAtEnd();
+ stats = readInput();
+ } else if (!composing) {
+ if (stats.length === 0 && editor.innerHTML) editor.innerHTML = '';
+ previousEditorHTML = editor.innerHTML;
+ }
+ count.textContent = `${stats.length}/16`;
+ emojiButton.disabled = submitting || composing ||
+ stats.length + (stats.length ? 2 : 1) > 16 || stats.emojiCount >= 5;
+ submit.disabled = submitting || composing || !stats.raw.trim();
+ return stats.raw;
+ };
+ const insertEmoji = (code) => {
+ const emojiCode = String(code || '').replace(/^:|:$/g, '');
+ const stats = readInput();
+ if (!emojiCode || stats.length + (stats.length ? 2 : 1) > 16 || stats.emojiCount >= 5) return;
+ const buildEmojiUrl = prettyTextEmoji?.buildEmojiUrl;
+ const isCustomEmoji = prettyTextEmoji?.isCustomEmoji;
+ const emojiOptions = discourseText?.emojiOptions;
+ if (!isFunction(buildEmojiUrl) || !isFunction(emojiOptions)) {
+ error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
+ return;
+ }
+ try {
+ const options = emojiOptions();
+ const image = makeElement('img');
+ image.className = isFunction(isCustomEmoji) && isCustomEmoji(emojiCode, options)
+ ? 'emoji emoji-custom'
+ : 'emoji';
+ image.alt = `:${emojiCode}:`;
+ image.src = buildEmojiUrl(emojiCode, options);
+ if (stats.length) editor.appendChild(document.createTextNode(' '));
+ else editor.innerHTML = '';
+ editor.appendChild(image);
+ error.textContent = '';
+ placeEditorCursorAtEnd();
+ syncInput();
+ } catch {
+ error.textContent = '表情插入失败,请稍后重试';
+ }
+ };
+ const positionEmojiPickerInReader = (content) => {
+ const reader = post.closest('.ldp-modal');
+ if (!content || !reader) return;
+ if (emojiPickerContent !== content) {
+ if (isFunction(emojiPickerContent?._ldpStopEmojiImageCache)) {
+ emojiPickerContent._ldpStopEmojiImageCache();
+ }
+ emojiPickerContent = content;
+ watchPersistentEmojiImages(content);
+ }
+ const ownsPopover = !content.hasAttribute('popover');
+ try {
+ if (ownsPopover) {
+ content.setAttribute('popover', 'manual');
+ content.dataset.ldpReaderTopLayer = 'portal';
+ }
+ if (!content.matches(':popover-open')) content.showPopover();
+ } catch {
+ if (ownsPopover) {
+ content.removeAttribute('popover');
+ delete content.dataset.ldpReaderTopLayer;
+ }
+ }
+ const padding = 8;
+ const gap = 8;
+ const readerRect = reader.getBoundingClientRect();
+ const anchorRect = menu.getBoundingClientRect();
+ const bounds = {
+ top: Math.max(padding, readerRect.top + padding),
+ right: Math.min(window.innerWidth - padding, readerRect.right - padding),
+ bottom: Math.min(window.innerHeight - padding, readerRect.bottom - padding),
+ left: Math.max(padding, readerRect.left + padding),
+ };
+ const picker = content.matches('.emoji-picker') ? content : content.querySelector('.emoji-picker');
+ const initialRect = content.getBoundingClientRect();
+ const pickerRect = picker?.getBoundingClientRect();
+ const naturalHeight = picker && +(picker.dataset.ldpBoostNaturalHeight || picker.offsetHeight || 0);
+ if (picker && naturalHeight && !picker.dataset.ldpBoostNaturalHeight) {
+ picker.dataset.ldpBoostNaturalHeight = String(naturalHeight);
+ }
+ const scale = picker?.offsetHeight ? pickerRect.height / picker.offsetHeight : 1;
+ const chromeHeight = pickerRect ? Math.max(0, initialRect.height - pickerRect.height) : 0;
+ const above = Math.max(0, anchorRect.top - gap - bounds.top);
+ if (picker && naturalHeight && scale > 0) {
+ picker.classList.add('ldp-boost-picker-constrained');
+ const targetVisualHeight = Math.min(
+ naturalHeight * scale,
+ Math.max(120 * scale, above - chromeHeight),
+ );
+ picker.style.height = `${Math.round(targetVisualHeight / scale)}px`;
+ const pickerContent = picker.querySelector('.emoji-picker__content');
+ if (pickerContent) {
+ pickerContent.style.height = 'auto';
+ pickerContent.style.minHeight = '0';
+ pickerContent.style.flex = '1 1 auto';
+ }
+ const sectionsNav = picker.querySelector('.emoji-picker__sections-nav');
+ if (sectionsNav) sectionsNav.style.height = '100%';
+ }
+ content.classList.add('ldp-boost-picker-positioned');
+ Object.assign(content.style, {
+ position: 'fixed',
+ inset: 'auto',
+ right: 'auto',
+ bottom: 'auto',
+ margin: '0',
+ transform: 'none',
+ });
+ const panelRect = content.getBoundingClientRect();
+ let left = anchorRect.left;
+ if (left + panelRect.width > bounds.right) left = anchorRect.right - panelRect.width;
+ left = Math.max(bounds.left, Math.min(left, Math.max(bounds.left, bounds.right - panelRect.width)));
+ const top = Math.max(bounds.top, anchorRect.top - panelRect.height - gap);
+ content.style.setProperty('--ldp-boost-picker-left', `${Math.round(left)}px`);
+ content.style.setProperty('--ldp-boost-picker-top', `${Math.round(top)}px`);
+ const placedRect = content.getBoundingClientRect();
+ content.style.setProperty('--ldp-boost-picker-left', `${Math.round(left + left - placedRect.left)}px`);
+ content.style.setProperty('--ldp-boost-picker-top', `${Math.round(top + top - placedRect.top)}px`);
+ };
+ const shownEmojiPickerSurface = () => {
+ const identified = document.querySelector(`[data-identifier="${emojiMenuIdentifier}"]`);
+ const candidates = [...document.querySelectorAll(emojiPickerSurfaceSelector)].filter((element) => {
+ const picker = element.matches('.emoji-picker') ? element : element.querySelector('.emoji-picker');
+ if (!picker || !composerElementAvailable(element)) return false;
+ const style = getComputedStyle(element);
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
+ const rect = element.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ });
+ if (identified && candidates.includes(identified)) return identified;
+ const surfaces = candidates.filter((element) =>
+ !candidates.some((parent) => parent !== element && parent.contains(element))
+ );
+ return surfaces[surfaces.length - 1] || null;
+ };
+ const positionShownEmojiPicker = () => {
+ const content = shownEmojiPickerSurface();
+ if (!content) return false;
+ emojiPickerObserver?.disconnect();
+ emojiPickerObserver = null;
+ positionEmojiPickerInReader(content);
+ menuScope.raf(() => {
+ if (!closed && content.isConnected) positionEmojiPickerInReader(content);
+ });
+ return true;
+ };
+ const watchShownEmojiPicker = () => {
+ emojiPickerObserver?.disconnect();
+ emojiPickerObserver = null;
+ if (positionShownEmojiPicker()) return;
+ const observer = new MutationObserver(positionShownEmojiPicker);
+ emojiPickerObserver = observer;
+ menuScope.observe(observer, document.body, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-identifier', 'open'],
+ });
+ menuScope.timer(() => {
+ observer.disconnect();
+ if (emojiPickerObserver === observer) emojiPickerObserver = null;
+ }, 1000);
+ };
+ const openEmojiPicker = () => {
+ const pickerModule = lookupDiscourseModule('discourse/components/emoji-picker/detached');
+ const EmojiPickerDetached = pickerModule?.default;
+ if (!nativeMenu || !isFunction(nativeMenu.show) || !EmojiPickerDetached) {
+ error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
+ return;
+ }
+ error.textContent = '';
+ try {
+ const result = nativeMenu.show(emojiButton, {
+ identifier: emojiMenuIdentifier,
+ component: EmojiPickerDetached,
+ modalForMobile: false,
+ strategy: 'fixed',
+ fallbackPlacements: ['bottom-start', 'top-start', 'bottom-end', 'top-end'],
+ computePosition: positionEmojiPickerInReader,
+ data: {
+ term: '',
+ context: 'boost',
+ didSelectEmoji: insertEmoji,
+ },
+ });
+ watchShownEmojiPicker();
+ if (isFunction(result?.catch)) {
+ result.catch(() => {
+ if (!closed) error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
+ });
+ }
+ } catch {
+ error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
+ }
+ };
+ const send = async () => {
+ if (composing) return;
+ const raw = syncInput().trim();
+ if (!raw || submitting) return;
+ if (!isFunction(createBoost) || !currentUser) {
+ error.textContent = 'LinuxDo Boost 组件尚未就绪,请稍后重试';
+ return;
+ }
+ submitting = true;
+ submit.disabled = true;
+ emojiButton.disabled = true;
+ cancel.disabled = true;
+ editor.contentEditable = 'false';
+ error.textContent = '';
+ try {
+ await createBoost(postData, raw, currentUser);
+ syncRenderedBoost(post, postData, ctx);
+ persistReaderPostState(ctx, postData);
+ await clearStoredResponseCache({ tags: [`post:${postData.id}`, `topic:${ctx.topicId}`] });
+ close();
+ } catch (e) {
+ submitting = false;
+ cancel.disabled = false;
+ editor.contentEditable = 'true';
+ error.textContent = `发送失败:${e?.message ? e.message : '请稍后重试'}`;
+ syncInput();
+ editor.focus();
+ }
+ };
+
+ menu._ldpCloseEmojiPicker = closeEmojiPicker;
+ menu._ldpClose = close;
+ editor.addEventListener('compositionstart', () => {
+ composing = true;
+ syncInput();
+ });
+ editor.addEventListener('compositionend', () => {
+ composing = false;
+ syncInput();
+ });
+ editor.addEventListener('input', syncInput);
+ editor.addEventListener('keydown', (event) => {
+ if (event.isComposing || composing || event.keyCode === 229) return;
+ if (event.key === 'Escape') {
+ consumeEvent(event);
+ close();
+ } else if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ send();
+ }
+ });
+ menu.addEventListener('click', (event) => {
+ const button = event.target instanceof Element ? event.target.closest('button') : null;
+ if (button === emojiButton) openEmojiPicker();
+ else if (button === submit) send();
+ else if (button === cancel) close();
+ });
+ if (scrollRoot) menuScope.listen(
+ scrollRoot, 'scroll', scheduleAnchorSync, { passive: true }
+ );
+ menuScope.listen(window, 'resize', scheduleAnchorSync, { passive: true });
+ if (isFunction(IntersectionObserver)) {
+ anchorObserver = new IntersectionObserver((entries) => {
+ const entry = entries[0];
+ if (!entry || !entry.isIntersecting) close();
+ else scheduleAnchorSync();
+ }, { root: scrollRoot || null, threshold: 0 });
+ menuScope.observe(anchorObserver, anchor);
+ }
+ menuScope.timer(() => {
+ if (!closed) menuScope.listen(document, 'pointerdown', onOutside, true);
+ }, 0);
+ editor.textContent = [...String(initialRaw || '')].slice(0, 16).join('');
+ syncInput();
+ editor.focus();
+ if (initialRaw) {
+ placeEditorCursorAtEnd();
+ }
+ return true;
+ }
+
+ /* ============ 9. 楼中楼分批渲染 ============ */
+ function renderSubReplyBatch(postNumber, ctx, pageDelta = 0, preferredParentNode = null) {
+ const state = ctx.subReplyState.get(postNumber);
+ const parentNode = preferredParentNode?.isConnected && +preferredParentNode.dataset.postNumber === +postNumber
+ ? preferredParentNode
+ : findReplyListParent(postNumber, ctx);
+ if (!state || !parentNode) return;
+ // Nested previews may host deeper tree shells when max depth allows it.
+ const children = parentNode.querySelector(':scope > .ldp-children');
+ if (!children) return;
+ const parentDepth = postNestDepth(parentNode, parentNode.classList.contains('ldp-nested-preview') ? 1 : 0);
+ const maxDepth = inlineReplyTreeMaxDepth();
+ const childDepth = parentDepth + 1;
+ const aggregate = PREFS.aggregateDescendantReplies === true;
+ const totalCount = Math.max(state.all.length, +(state.totalCount || 0));
+ const hasGrandchildren = state.all.some((reply) =>
+ [...(ctx.directRepliesByParent.get(+reply.post_number)?.values() || [])]
+ .some((child) => +child.reply_to_post_number === +reply.post_number) ||
+ +(reply.reply_count || 0) > 0
+ );
+ // With an inline tree (maxDepth > 1), grandchildren no longer force the shallow
+ // aggregate preview on the root parent — the tree can show them in place.
+ const treeEnabled = maxDepth > 1;
+ const showAggregate = parentDepth === 0 && aggregate && (
+ treeEnabled
+ ? totalCount > 8
+ : (totalCount > 5 || hasGrandchildren)
+ );
+ const pageSize = showAggregate
+ ? 2
+ : (aggregate && parentDepth === 0 ? 5 : (parentDepth >= 1 ? Math.min(5, SUB_REPLY_PAGE_SIZE) : SUB_REPLY_PAGE_SIZE));
+ const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
+ state.pageIndex = Math.max(0, Math.min(pageCount - 1, +(state.pageIndex || 0) + pageDelta));
+ if (!state.nodeCache) state.nodeCache = new Map();
+ const start = state.pageIndex * pageSize;
+ const batch = state.all.slice(start, start + pageSize);
+ const renderKey = `${showAggregate ? 1 : 0}:${maxDepth}:${childDepth}:${state.pageIndex}:${state.all.length}:${totalCount}:${state.loadingMore ? 1 : 0}:${batch.map((reply) => +(reply.post_number || 0)).join(',')}`;
+ const mountedReplies = [...children.children].filter((node) => node.classList.contains('ldp-post'));
+ if (state.renderedParent === parentNode && state.renderedKey === renderKey &&
+ mountedReplies.length === batch.length &&
+ mountedReplies.every((node, index) => +node.dataset.postNumber === +(batch[index]?.post_number || 0))) {
+ return;
+ }
+ parentNode.classList.remove('ldp-nested-branches-ready');
+ clearCombinedParentPreviewLine(parentNode);
+ const mountedByPostNumber = new Map(mountedReplies.map((node) => [+node.dataset.postNumber, node]));
+ const desiredNodes = [];
+ batch.forEach((rp) => {
+ if (!rp.reply_to_post_number) rp.reply_to_post_number = postNumber;
+ const replyPostNumber = +rp.post_number;
+ let node = mountedByPostNumber.get(replyPostNumber) || state.nodeCache.get(replyPostNumber);
+ if (!node) {
+ node = attachPost(rp, ctx, {
+ nestedList: true,
+ preferredParentNode: parentNode,
+ deferNestedSync: true,
+ nestDepth: childDepth,
+ });
+ if (node) {
+ state.nodeCache.set(replyPostNumber, node);
+ }
+ } else {
+ node.dataset.ldpNestDepth = String(childDepth);
+ }
+ if (node) {
+ if (node._ldpPostData !== rp)
+ updateLivePostData(rp, ctx, { refreshContent: true });
+ state.nodeCache.set(replyPostNumber, node);
+ desiredNodes.push(node);
+ if (+(node.dataset.childReplyTotal || node._ldpPostData?.reply_count || 0) > 0 &&
+ childDepth < maxDepth && ctx.repliesIO) {
+ ctx.repliesIO.observe(node);
+ }
+ }
+ });
+ const actionEl = parentNode.querySelector(':scope > .ldp-children > .ldp-sub-actions');
+ const desiredSet = new Set(desiredNodes);
+ mountedReplies.forEach((node) => {
+ if (desiredSet.has(node)) return;
+ state.nodeCache.set(+node.dataset.postNumber, node);
+ removeOwnedNode(node, children);
+ });
+ let before = actionEl || null;
+ for (let index = desiredNodes.length - 1; index >= 0; index--) {
+ const node = desiredNodes[index];
+ if (node.nextSibling !== before) children.insertBefore(node, before);
+ ctx.tracker.observe(node);
+ hydratePersistentAvatarImages(node);
+ before = node;
+ }
+ parentNode.dataset.childReplyTotal = String(Math.max(+(parentNode.dataset.childReplyTotal || 0), state.all.length));
+ const prevBtn = actionEl?.querySelector('.ldp-sub-page-prev');
+ const nextBtn = actionEl?.querySelector('.ldp-sub-page-next');
+ const infoEl = actionEl?.querySelector('.ldp-sub-page-info');
+ const aggregateBtn = actionEl?.querySelector('.ldp-descendant-replies-open');
+ // Full discussion remains available when the inline tree cannot go deeper, or when
+ // the root parent is still in aggregate preview mode.
+ const showFullDiscussion = !!aggregate && (
+ showAggregate ||
+ (hasGrandchildren && childDepth >= maxDepth)
+ );
+ [prevBtn, nextBtn, infoEl].forEach((control) => { if (control) control.hidden = showAggregate; });
+ if (aggregateBtn) aggregateBtn.hidden = !showFullDiscussion;
+ if (actionEl && actionEl.parentNode === children && actionEl.nextSibling) children.appendChild(actionEl);
+ // Do NOT force ldp-nested-branches-ready here: that class must wait for
+ // syncNestedReplyBranches to measure real avatar/axis geometry. Premature
+ // ready state paints lines with the CSS defaults (axis 3px, -18px gutter)
+ // and looks like a broken splice.
+ if (showFullDiscussion || (!showAggregate && pageCount > 1) || showAggregate) {
+ actionEl?.classList.add('show');
+ if (prevBtn) prevBtn.disabled = !!state.loadingMore || state.pageIndex <= 0;
+ if (nextBtn) nextBtn.disabled = !!state.loadingMore || state.pageIndex >= pageCount - 1;
+ if (infoEl) infoEl.textContent = `${state.pageIndex + 1}/${pageCount}`;
+ } else actionEl?.classList.remove('show');
+ state.renderedParent = parentNode;
+ state.renderedKey = renderKey;
+ scheduleNestedBranchSync(parentNode, ctx);
+ }
+
+ function openDescendantRepliesWindow(rootPost, ctx, targetPostNumber = 0, {
+ skipEscapeReturn = false,
+ returnControl = null,
+ returnViewport = null,
+ restorePoint = null,
+ } = {}) {
+ if (!rootPost || !ctx?.modal || !ctx.repliesIO) return;
+ ctx.replyThreadWindow?._ldpClose?.();
+ const inlineSurface = !ctx.readerWorkspace?.isFullPage?.();
+ const sourceControl = returnControl?.isConnected
+ ? returnControl
+ : document.activeElement?.closest?.('.ldp-descendant-replies-open');
+ const sourcePost = sourceControl?.closest('.ldp-post');
+ const returnViewportPostNumber = sourcePost
+ ? 0
+ : Math.max(0, Number(returnViewport?.postNumber) || 0);
+ const continueAfterInlineReplies = !!returnControl?.isConnected &&
+ +targetPostNumber === +rootPost.post_number;
+ const displayedReplyNumbers = new Set(
+ [...sourcePost?.querySelectorAll(':scope > .ldp-children .ldp-post[data-post-number]') || []]
+ .filter((node) => !node.hidden && node.getClientRects().length > 0)
+ .map((node) => +node.dataset.postNumber)
+ .filter((number) => number > 0)
+ );
+ if (continueAfterInlineReplies) targetPostNumber = 0;
+ const sourceAnchor = sourceControl?.querySelector('.ldp-icon') || sourceControl;
+ const sourceAnchorOffset = sourceAnchor?.isConnected
+ ? sourceAnchor.getBoundingClientRect().top - ctx.scrollRoot.getBoundingClientRect().top
+ : NaN;
+ const panel = makeElement('section');
+ const layer = makeElement('div');
+ const readWindowState = () => { try { const value = globalThis.GM_getValue?.(LDP_REPLY_WINDOW_STATE_KEY, {}); return value && typeof value === 'object' && !Array.isArray(value) && !isFunction(value.then) ? value : {}; } catch { return {}; } };
+ const windowState = readWindowState();
+ const surfaceHost = inlineSurface ? ctx.modal.querySelector('.ldp-reader-main') : ctx.modal;
+ const resizeDirections = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
+ if (!surfaceHost) return;
+ layer.className = `ldp-descendant-replies-layer ${inlineSurface ? 'ldp-descendant-replies-layer-inline' : 'ldp-descendant-replies-layer-centered'}`;
+ panel.className = `ldp-descendant-replies-window ${inlineSurface ? 'ldp-descendant-replies-inline' : 'ldp-descendant-replies-centered'}`;
+ panel.dataset.returnPostNumber = String(
+ sourcePost?.dataset.postNumber || returnViewportPostNumber || rootPost.post_number
+ );
+ panel.innerHTML = `
${inlineSurface ? '' : resizeDirections.map((direction) =>
+ `
`
+ ).join('')}`;
+ if (!inlineSurface) ctx.modal.classList.add('ldp-descendant-replies-host-open');
+ layer.append(panel); surfaceHost.append(layer); ctx.replyThreadWindow = panel; bindFloatingSurfaceWheel(layer);
+ const header = panel.firstElementChild;
+ const list = panel.querySelector('.ldp-descendant-replies-list');
+ list.style.setProperty('--ldp-nested-content-scale', '.97');
+ list.style.setProperty('--ldp-nested-component-scale', '.95');
+ list.style.setProperty('--ldp-nested-component-gap', '7.6px');
+ list.style.paddingBlock = '5px 7px';
+ const title = header.querySelector('.ldp-descendant-replies-title');
+ const closeButton = header.querySelector('.ldp-descendant-replies-close');
+ const outerReaderControls = inlineSurface ? [
+ ctx.modal.querySelector(':scope > .ldp-header'),
+ ...ctx.modal.querySelectorAll(':scope > .ldp-reader-embed-resize,:scope > .ldp-reader-resize-handle'),
+ ].filter(Boolean) : [];
+ const outerReaderControlZIndexes = new Map(outerReaderControls.map((control) => [
+ control,
+ [control.style.getPropertyValue('z-index'), control.style.getPropertyPriority('z-index')],
+ ]));
+ outerReaderControls.forEach((control) => control.style.setProperty('z-index', '32'));
+ const restoreOuterReaderControls = () => {
+ outerReaderControlZIndexes.forEach(([value, priority], control) => {
+ if (value) control.style.setProperty('z-index', value, priority);
+ else control.style.removeProperty('z-index');
+ });
+ };
+ const readIO = isFunction(IntersectionObserver) ? new IntersectionObserver((entries) => entries.forEach((entry) => {
+ const visible = entry.isIntersecting && entry.intersectionRect.width > 0 &&
+ entry.intersectionRect.height > 0;
+ if (visible && !hydratePostContent(entry.target, ctx)) return;
+ ctx.tracker.setVisible([entry.target.dataset.postNumber], visible);
+ }), { root: list, threshold: 0 }) : null;
+ const openedScope = `${CURRENT_SITE_HOST || location.host}:${ctx.topicId}`;
+ const readOpenedBranchState = () => {
+ try {
+ const value = globalThis.GM_getValue?.(LDP_REPLY_BRANCH_STATE_KEY, {});
+ return value && typeof value === 'object' && !Array.isArray(value) && !isFunction(value.then)
+ ? { ...value } : {};
+ } catch { return {}; }
+ };
+ const openedEntry = readOpenedBranchState()[openedScope];
+ const openedBranches = new Set((Array.isArray(openedEntry?.posts) ? openedEntry.posts : [])
+ .map(Number).filter((number) => Number.isInteger(number) && number > 0).slice(-512));
+ let openedBranchesDirty = false;
+ const persistOpenedBranches = () => {
+ const state = readOpenedBranchState();
+ state[openedScope] = { at: Date.now(), posts: [...openedBranches].slice(-512) };
+ const bounded = Object.fromEntries(Object.entries(state)
+ .filter(([, entry]) => entry && Number.isFinite(+entry.at) && Array.isArray(entry.posts))
+ .sort((first, second) => +first[1].at - +second[1].at).slice(-64));
+ try { void Promise.resolve(globalThis.GM_setValue?.(LDP_REPLY_BRANCH_STATE_KEY, bounded)).catch(() => {}); } catch {}
+ };
+ const initialRestorePoint = normalizeReaderAnchorPoint(restorePoint);
+ let token = 0, stack = [], known = new Map(), queue = [], loading = false, returnPoint = null, historyPoint = initialRestorePoint, historyAnchor = null, threadRoot = rootPost, branchTarget = null;
+ let branchLineSvg = null, branchLineFrame = 0, hoveredBranchNumber = 0, branchLineResizeObserver = null;
+ const viewKey = () => `${CURRENT_SITE_HOST || location.host}:${ctx.topicId}:${+threadRoot.post_number}:${+branchTarget?.post_number || 0}`;
+ const captureWindowPoint = () => {
+ const listRect = list.getBoundingClientRect();
+ const anchor = [...list.querySelectorAll(':scope > .ldp-post')]
+ .find((node) => node.getBoundingClientRect().bottom > listRect.top);
+ return anchor ? {
+ number: +anchor.dataset.postNumber,
+ scrollTop: Math.max(0, Number(list.scrollTop) || 0),
+ offset: anchor.getBoundingClientRect().top - listRect.top,
+ } : null;
+ };
+ const persistWindowState = () => {
+ const point = captureWindowPoint();
+ if (point) (windowState.views ||= {})[viewKey()] = { at: Date.now(), ...point };
+ if (panelGeometryState) windowState.fullPageGeometry = {
+ left: Math.round(panelGeometryState.left),
+ top: Math.round(panelGeometryState.top),
+ width: Math.round(panelGeometryState.width),
+ height: Math.round(panelGeometryState.height),
+ };
+ windowState.views = Object.fromEntries(Object.entries(windowState.views || {}).sort((a, b) => +a[1].at - +b[1].at).slice(-128));
+ try { void Promise.resolve(globalThis.GM_setValue?.(LDP_REPLY_WINDOW_STATE_KEY, windowState)).catch(() => {}); } catch {}
+ };
+ let panelGeometryState = null, panelPointer = null, panelGeometryFrame = 0;
+ const panelGeometryLimits = () => {
+ const margin = Math.max(0, Math.min(16,
+ Math.floor((window.innerWidth - 1) / 2),
+ Math.floor((window.innerHeight - 1) / 2)));
+ const maxWidth = Math.max(1, window.innerWidth - margin * 2);
+ const maxHeight = Math.max(1, window.innerHeight - margin * 2);
+ return {
+ left: margin,
+ top: margin,
+ right: window.innerWidth - margin,
+ bottom: window.innerHeight - margin,
+ minWidth: Math.min(320, maxWidth),
+ minHeight: Math.min(240, maxHeight),
+ maxWidth,
+ maxHeight,
+ };
+ };
+ const defaultPanelGeometry = () => {
+ const rect = panel.getBoundingClientRect();
+ return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
+ };
+ const normalizePanelGeometry = (next) => {
+ const bounds = panelGeometryLimits();
+ const fallback = defaultPanelGeometry();
+ const number = (value, fallbackValue) => Number.isFinite(Number(value)) ? Number(value) : fallbackValue;
+ const width = Math.max(bounds.minWidth, Math.min(bounds.maxWidth, number(next?.width, fallback.width)));
+ const height = Math.max(bounds.minHeight, Math.min(bounds.maxHeight, number(next?.height, fallback.height)));
+ return {
+ left: Math.max(bounds.left, Math.min(bounds.right - width, number(next?.left, fallback.left))),
+ top: Math.max(bounds.top, Math.min(bounds.bottom - height, number(next?.top, fallback.top))),
+ width,
+ height,
+ };
+ };
+ const applyPanelGeometry = (next) => {
+ if (inlineSurface) return;
+ panelGeometryState = normalizePanelGeometry(next);
+ panel.style.left = `${Math.round(panelGeometryState.left * 100) / 100}px`;
+ panel.style.top = `${Math.round(panelGeometryState.top * 100) / 100}px`;
+ panel.style.width = `${Math.round(panelGeometryState.width * 100) / 100}px`;
+ panel.style.height = `${Math.round(panelGeometryState.height * 100) / 100}px`;
+ panel.style.transform = 'none';
+ };
+ const resizePanelGeometry = (start, direction, deltaX, deltaY) => {
+ const bounds = panelGeometryLimits();
+ let left = start.left, top = start.top;
+ let right = start.left + start.width, bottom = start.top + start.height;
+ if (direction.includes('w')) left = Math.max(bounds.left, Math.min(right - bounds.minWidth, start.left + deltaX));
+ if (direction.includes('e')) right = Math.max(left + bounds.minWidth, Math.min(bounds.right, start.left + start.width + deltaX));
+ if (direction.includes('n')) top = Math.max(bounds.top, Math.min(bottom - bounds.minHeight, start.top + deltaY));
+ if (direction.includes('s')) bottom = Math.max(top + bounds.minHeight, Math.min(bounds.bottom, start.top + start.height + deltaY));
+ return { left, top, width: right - left, height: bottom - top };
+ };
+ const renderPanelPointer = () => {
+ panelGeometryFrame = 0;
+ if (!panelPointer) return;
+ const deltaX = panelPointer.clientX - panelPointer.startX;
+ const deltaY = panelPointer.clientY - panelPointer.startY;
+ if (panelPointer.mode === 'move') {
+ const bounds = panelGeometryLimits();
+ applyPanelGeometry({
+ ...panelPointer.state,
+ left: Math.max(bounds.left, Math.min(
+ bounds.right - panelPointer.state.width,
+ panelPointer.state.left + deltaX,
+ )),
+ top: Math.max(bounds.top, Math.min(
+ bounds.bottom - panelPointer.state.height,
+ panelPointer.state.top + deltaY,
+ )),
+ });
+ } else {
+ applyPanelGeometry(resizePanelGeometry(panelPointer.state, panelPointer.mode, deltaX, deltaY));
+ }
+ };
+ const stopPanelPointer = (event, persist = true) => {
+ if (!panelPointer || event && event.pointerId !== panelPointer.pointerId) return;
+ if (event && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
+ panelPointer.clientX = event.clientX;
+ panelPointer.clientY = event.clientY;
+ }
+ if (panelGeometryFrame) {
+ cancelAnimationFrame(panelGeometryFrame);
+ panelGeometryFrame = 0;
+ renderPanelPointer();
+ }
+ const activePointer = panelPointer;
+ panelPointer = null;
+ try {
+ if (activePointer.target.hasPointerCapture(activePointer.pointerId))
+ activePointer.target.releasePointerCapture(activePointer.pointerId);
+ } catch {}
+ panel.classList.remove('ldp-descendant-replies-interacting');
+ if (persist) persistWindowState();
+ };
+ const onPanelPointerDown = (event) => {
+ if (inlineSurface || event.button !== 0 || !(event.target instanceof Element)) return;
+ const resizeHandle = event.target.closest('.ldp-descendant-replies-resize-handle');
+ const dragHeader = event.target.closest('.ldp-descendant-replies-header');
+ if (!resizeHandle && (!dragHeader || event.target.closest('button,a,input,select,textarea'))) return;
+ const target = resizeHandle || dragHeader;
+ panelGeometryState = normalizePanelGeometry(panelGeometryState || windowState.fullPageGeometry);
+ applyPanelGeometry(panelGeometryState);
+ panelPointer = {
+ pointerId: event.pointerId,
+ mode: resizeHandle?.dataset.resize || 'move',
+ startX: event.clientX,
+ startY: event.clientY,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ state: { ...panelGeometryState },
+ target,
+ };
+ target.setPointerCapture(event.pointerId);
+ panel.classList.add('ldp-descendant-replies-interacting');
+ consumeEvent(event);
+ };
+ const onPanelPointerMove = (event) => {
+ if (!panelPointer || event.pointerId !== panelPointer.pointerId) return;
+ panelPointer.clientX = event.clientX;
+ panelPointer.clientY = event.clientY;
+ if (!panelGeometryFrame) panelGeometryFrame = requestAnimationFrame(renderPanelPointer);
+ event.preventDefault();
+ };
+ const onPanelViewportResize = () => {
+ if (!inlineSurface) applyPanelGeometry(panelGeometryState || windowState.fullPageGeometry);
+ };
+ if (!inlineSurface) {
+ header.style.cursor = 'move';
+ header.style.touchAction = 'none';
+ panel.addEventListener('pointerdown', onPanelPointerDown);
+ panel.addEventListener('pointermove', onPanelPointerMove);
+ ['pointerup', 'pointercancel', 'lostpointercapture'].forEach((type) =>
+ panel.addEventListener(type, stopPanelPointer));
+ window.addEventListener('resize', onPanelViewportResize);
+ applyPanelGeometry(windowState.fullPageGeometry);
+ }
+ panel._ldpCaptureAnchorState = () => ({
+ rootPostNumber: +rootPost.post_number,
+ point: captureWindowPoint(),
+ });
+ window.addEventListener('pagehide', persistWindowState);
+ let resolveTarget = null;
+ const targetReady = targetPostNumber ? new Promise((resolve) => { resolveTarget = resolve; }) : Promise.resolve(true);
+ const settleTarget = (result) => { if (resolveTarget) { resolveTarget(result); resolveTarget = null; } };
+ const resolveReturnVisualTarget = (target) =>
+ (sourceAnchor?.isConnected && sourceAnchor) ||
+ target.querySelector(':scope > .ldp-children > .ldp-sub-actions > .ldp-descendant-replies-open > .ldp-icon') ||
+ target.querySelector(':scope > .ldp-children > .ldp-sub-actions > .ldp-descendant-replies-open') ||
+ target.querySelector(':scope > .ldp-content') ||
+ target;
+ const returnScrollOptions = Number.isFinite(sourceAnchorOffset)
+ ? { ...PINNED_STREAM_SCROLL_OPTIONS, highlight: false, resolveVisualTarget: resolveReturnVisualTarget, alignOffset: sourceAnchorOffset }
+ : { ...PINNED_STREAM_SCROLL_OPTIONS, highlight: false, resolveVisualTarget: resolveReturnVisualTarget, alignBottomCenter: true };
+ const restoreViewportEntry = () => {
+ if (!returnViewportPostNumber) return false;
+ const returnTarget = getStreamPostNode(ctx, returnViewportPostNumber, { mount: true });
+ const returnOffset = stableReaderViewportOffset(returnTarget, ctx, returnViewport?.postOffset);
+ const restored = !!returnTarget && scrollToPost(returnViewportPostNumber, ctx, {
+ ...PINNED_STREAM_SCROLL_OPTIONS,
+ visualNode: returnTarget,
+ alignOffset: returnOffset,
+ highlight: false,
+ });
+ if (!restored) {
+ const savedScrollTop = Number(returnViewport?.scrollTop);
+ if (!Number.isFinite(savedScrollTop)) return false;
+ clearStreamViewportAnchor(ctx);
+ const previousScrollTop = ctx.scrollRoot.scrollTop;
+ ctx.scrollRoot.scrollTop = Math.max(0, savedScrollTop);
+ markStreamInternalScroll(ctx, previousScrollTop);
+ }
+ flushStreamWindow(ctx);
+ captureStreamViewportAnchor(ctx);
+ ctx.timeline?.sync();
+ return true;
+ };
+ const restoreEntryPosition = () => {
+ if (restoreViewportEntry()) return;
+ scrollToPost(+panel.dataset.returnPostNumber, ctx, returnScrollOptions);
+ };
+ const workspaceHost = ctx.modal.closest('.ldp-overlay');
+ let onWorkspaceChange = null;
+ const close = (restoreEntry = true) => { stopPanelPointer(null, false); persistWindowState(); window.removeEventListener('pagehide', persistWindowState); window.removeEventListener('resize', onPanelViewportResize); if (onWorkspaceChange) workspaceHost?.removeEventListener('ldp-reader-workspace-change', onWorkspaceChange); restoreOuterReaderControls(); token++; settleTarget(false); ctx.tracker.setVisible([...panel.querySelectorAll('.ldp-post')].map((node) => node.dataset.postNumber), false); readIO?.disconnect(); branchLineResizeObserver?.disconnect(); if (branchLineFrame) cancelAnimationFrame(branchLineFrame); const previewPost = panel.querySelector('.ldp-floor-preview')?._ldpRelatedPost; if (previewPost) ctx.floorPreview?.hide(previewPost); panel.querySelectorAll('.ldp-post').forEach((node) => ctx.tracker.unobserve(node)); layer.remove(); if (!inlineSurface) ctx.modal.classList.remove('ldp-descendant-replies-host-open'); if (ctx.replyThreadWindow === panel) ctx.replyThreadWindow = null; if (restoreEntry && !sourceControl?.isConnected) requestAnimationFrame(restoreEntryPosition); };
+ onWorkspaceChange = () => {
+ if (inlineSurface === !ctx.readerWorkspace?.isFullPage?.()) return;
+ close();
+ };
+ workspaceHost?.addEventListener('ldp-reader-workspace-change', onWorkspaceChange);
+ panel._ldpClose = close;
+ panel._ldpCloseFromEscape = () => close(returnViewportPostNumber > 0 || !skipEscapeReturn);
+ layer.addEventListener('click', (event) => {
+ if (event.target !== layer) return;
+ const readerTitle = !inlineSurface && ctx.modal.querySelector('.ldp-title-jump');
+ const titleRect = readerTitle?.getBoundingClientRect();
+ if (titleRect && event.clientX >= titleRect.left && event.clientX <= titleRect.right &&
+ event.clientY >= titleRect.top && event.clientY <= titleRect.bottom) {
+ consumeEvent(event);
+ readerTitle.click();
+ return;
+ }
+ consumeEvent(event);
+ close();
+ });
+ const positionTargetInView = (target) => {
+ const listRect = list.getBoundingClientRect(), targetRect = target.getBoundingClientRect();
+ const inset = 12;
+ const targetFits = targetRect.height <= Math.max(0, listRect.height - inset * 2);
+ const desiredTop = targetFits
+ ? listRect.top + (listRect.height - targetRect.height) / 2
+ : listRect.top + inset;
+ const offset = targetRect.top - desiredTop;
+ if (Math.abs(offset) >= .5) list.scrollTop += offset;
+ const positionedRect = target.getBoundingClientRect();
+ historyAnchor = { target, offset: positionedRect.top - listRect.top };
+ return positionedRect.bottom > listRect.top + 1 && positionedRect.top < listRect.bottom - 1;
+ };
+ const focusTarget = () => {
+ if (!targetPostNumber) return false;
+ const target = list.querySelector(`:scope > .ldp-post[data-post-number="${+targetPostNumber}"]`);
+ if (!target) return false;
+ hydratePostContent(target, ctx);
+ positionTargetInView(target);
+ targetPostNumber = 0;
+ void waitForReaderJumpLayout().then(() => {
+ if (!target.isConnected || !panel.isConnected) {
+ settleTarget(false);
+ return;
+ }
+ const visible = positionTargetInView(target);
+ keepJumpHighlightUntilSettled(target);
+ settleTarget(visible);
+ });
+ return true;
+ };
+ const syncHistoryAnchor = () => {
+ const target = historyAnchor?.target;
+ if (!target?.isConnected) { historyAnchor = null; return; }
+ const delta = target.getBoundingClientRect().top - list.getBoundingClientRect().top - historyAnchor.offset;
+ if (Math.abs(delta) >= .5) list.scrollTop += delta;
+ };
+ const restoreHistoryPoint = () => {
+ if (!historyPoint) return false;
+ const target = list.querySelector(`:scope > .ldp-post[data-post-number="${historyPoint.number}"]`);
+ if (!target) return false;
+ const point = historyPoint; historyPoint = null; if (targetPostNumber) { targetPostNumber = 0; settleTarget(true); } list.scrollTop = point.scrollTop;
+ historyAnchor = { target, offset: point.offset };
+ requestAnimationFrame(() => {
+ if (!target.isConnected) return;
+ syncHistoryAnchor();
+ keepJumpHighlightUntilSettled(target);
+ });
+ return true;
+ };
+ const parentOf = (post, number = +post?.post_number) =>
+ +(post?.reply_to_post_number || ctx.replyParentByPost?.get(number) || 0);
+ const cachedDescendantPosts = (postNumber) => {
+ const posts = [], pending = [+postNumber], seen = new Set(pending);
+ for (let parent; (parent = pending.shift());) {
+ ctx.directRepliesByParent.get(parent)?.forEach((post, number) => {
+ if (!seen.has(+number)) { seen.add(+number); posts.push(post); pending.push(+number); }
+ });
+ }
+ return posts;
+ };
+ const replyPath = (post) => {
+ const path = [], seen = new Set(); let current = post;
+ while (current && !seen.has(+current.post_number)) {
+ const number = +current.post_number; seen.add(number); path.unshift(current);
+ current = known.get(parentOf(current, number));
+ }
+ return path;
+ };
+ const branchDescendantNumbers = (postNumber) => {
+ const target = known.get(+postNumber), numbers = new Set(target ? [+postNumber] : []);
+ if (!target) return numbers;
+ known.forEach((post, number) => {
+ const seen = new Set();
+ for (let parent = parentOf(post, +number); parent && !seen.has(parent); parent = parentOf(known.get(parent), parent)) {
+ if (parent === +postNumber) { numbers.add(+number); break; }
+ seen.add(parent);
+ }
+ });
+ return numbers;
+ };
+ const branchPostNumbers = (postNumber) => {
+ const target = known.get(+postNumber), numbers = new Set();
+ if (!target) return numbers;
+ replyPath(target).forEach((post) => numbers.add(+post.post_number));
+ branchDescendantNumbers(postNumber).forEach((number) => numbers.add(number));
+ return numbers;
+ };
+ const branchPosts = (postNumber) => [...branchPostNumbers(postNumber)]
+ .map((number) => known.get(number)).filter(Boolean);
+ const svgNode = (name) => document.createElementNS('http://www.w3.org/2000/svg', name);
+ const drawBranchLine = (postNumber) => {
+ branchLineSvg?.remove(); branchLineSvg = null;
+ const numbers = branchPostNumbers(postNumber);
+ if (numbers.size < 2 || !list.isConnected) return;
+ const listRect = list.getBoundingClientRect(), height = Math.max(list.scrollHeight, list.clientHeight);
+ const points = new Map();
+ numbers.forEach((number) => {
+ const avatar = list.querySelector(`:scope > .ldp-post[data-post-number="${number}"] > .ldp-post-head .ldp-avatar`);
+ if (avatar) { const rect = avatar.getBoundingClientRect(); points.set(number, { x: rect.left - listRect.left, y: rect.top + rect.height / 2 - listRect.top + list.scrollTop }); }
+ });
+ if (points.size < 2) return;
+ const svg = svgNode('svg'), defs = svgNode('defs');
+ svg.classList.add('ldp-thread-group-svg'); svg.setAttribute('aria-hidden', 'true');
+ svg.style.setProperty('--ldp-thread-group-height', `${height}px`);
+ svg.setAttribute('viewBox', `0 0 ${Math.max(1, list.clientWidth)} ${height}`);
+ const branchGap = 5, fadeLength = 12;
+ const radius = normalizeReplyLineRadius(parseFloat(getComputedStyle(list).getPropertyValue('--ldp-reply-line-radius')));
+ const addPath = (d, stroke = '') => { const path = svgNode('path'); path.classList.add('ldp-thread-group-path'); path.setAttribute('d', d); if (stroke) path.style.stroke = stroke; svg.appendChild(path); };
+ const addBranch = (x1, x2, y) => {
+ const gradient = svgNode('linearGradient'), id = `ldp-thread-group-gradient-${++threadGroupGradientSerial}`;
+ gradient.id = id; gradient.setAttribute('gradientUnits', 'userSpaceOnUse');
+ gradient.setAttribute('x1', x1); gradient.setAttribute('x2', x2); gradient.setAttribute('y1', y); gradient.setAttribute('y2', y);
+ [0, Math.max(0, 1 - fadeLength / Math.max(1, Math.abs(x2 - x1))), 1].forEach((offset, index) => {
+ const stop = svgNode('stop'); stop.setAttribute('offset', offset); stop.setAttribute('stop-color', 'currentColor'); stop.setAttribute('stop-opacity', index === 2 ? '0' : '1'); gradient.appendChild(stop);
+ });
+ defs.appendChild(gradient); addPath(`M ${x1} ${y} H ${x2}`, `url(#${id})`);
+ };
+ svg.appendChild(defs);
+ const ordered = [...points.values()].sort((a, b) => a.y - b.y);
+ const axis = Math.min(...ordered.map((point) => point.x)) - radius - branchGap - fadeLength, first = ordered[0], last = ordered[ordered.length - 1];
+ const paths = [`M ${axis} ${first.y + radius} V ${last.y - radius}`, `M ${axis} ${first.y + radius} Q ${axis} ${first.y} ${axis + radius} ${first.y}`];
+ ordered.slice(1).forEach((point) => paths.push(`M ${axis} ${point.y - radius} Q ${axis} ${point.y} ${axis + radius} ${point.y}`));
+ addPath(paths.join(' ')); ordered.forEach((point) => addBranch(axis + radius, point.x - branchGap, point.y));
+ if (svg.querySelector('path')) { list.prepend(svg); branchLineSvg = svg; }
+ };
+ const scheduleBranchLine = (postNumber = hoveredBranchNumber || +branchTarget?.post_number || 0) => {
+ if (branchLineFrame) cancelAnimationFrame(branchLineFrame);
+ branchLineFrame = requestAnimationFrame(() => { branchLineFrame = 0; drawBranchLine(postNumber); });
+ };
+ if (isFunction(ResizeObserver)) {
+ branchLineResizeObserver = new ResizeObserver(() => { syncHistoryAnchor(); scheduleBranchLine(); }); branchLineResizeObserver.observe(list);
+ }
+ const syncBranchControls = () => {
+ if (openedBranchesDirty) { openedBranchesDirty = false; persistOpenedBranches(); }
+ list.querySelectorAll('.ldp-descendant-replies-reroot').forEach((button) => {
+ const number = +button.dataset.postNumber;
+ const count = branchDescendantNumbers(number).size, opened = openedBranches.has(number);
+ button.hidden = +branchTarget?.post_number === number;
+ button.innerHTML = `${icon('gitBranch')}
${count}${opened ? ' ✓' : ''}`;
+ button.classList.toggle('was-opened', opened);
+ setLabel(button, `${opened ? '已打开过;' : ''}聚焦 #${number} 下的回复分支,当前已加载 ${count} 条`);
+ });
+ };
+ const applyNestedReplySurfaceStyle = (node) => {
+ node.classList.add('ldp-descendant-reply-compact');
+ node.style.padding = '5px 0 7px';
+ node.style.lineHeight = '1.5';
+ node.style.borderBottom = 'none';
+ node.style.setProperty('contain-intrinsic-size', 'auto 96px');
+ const content = node.querySelector(':scope > .ldp-content');
+ if (content) {
+ content.style.fontSize = 'calc(var(--ldp-post-font-size,14px) * var(--ldp-nested-content-scale))';
+ content.style.paddingBlock = '12px';
+ if (content.firstElementChild) content.firstElementChild.style.marginTop = '0';
+ if (content.lastElementChild) content.lastElementChild.style.marginBottom = '0';
+ }
+ [...node.children].forEach((element) => {
+ if (element !== content) element.style.zoom = 'var(--ldp-nested-component-scale)';
+ });
+ const reactions = node.querySelector(':scope > .ldp-reactions');
+ if (reactions) reactions.style.marginTop = '0';
+ const postHead = node.querySelector(':scope > .ldp-post-head');
+ if (postHead) {
+ postHead.style.marginBottom = '0';
+ postHead.style.flexWrap = 'wrap';
+ }
+ const parentJump = node.querySelector(':scope > .ldp-post-head > .ldp-jump-parent');
+ if (parentJump) parentJump.style.marginLeft = '8px';
+ node.querySelectorAll(':scope > .ldp-post-head > .ldp-user,:scope > .ldp-post-head > .ldp-time')
+ .forEach((element) => { element.style.opacity = '.5'; });
+ };
+ const appendPost = (post, isRoot = false, depth = null) => {
+ const number = +post.post_number;
+ if (!number || known.has(number)) return null;
+ known.set(number, post);
+ if (branchTarget && (number === +branchTarget.post_number || depth === null) &&
+ !openedBranches.has(number)) { openedBranches.add(number); openedBranchesDirty = true; }
+ const node = renderPost({ ...post, reply_count: 0 }, !isRoot, ctx, { forceExpanded: true, showParentJump: !isRoot });
+ node._ldpPostData = post; node.classList.remove('ldp-reply-collapsible', 'ldp-nested-collapsed');
+ node.querySelectorAll(':scope > .ldp-nested-toggle,:scope > .ldp-nested-esc-hint,:scope > .ldp-collapse-replies,:scope > .ldp-children,:scope > .ldp-sub-loading').forEach((element) => element.remove());
+ applyNestedReplySurfaceStyle(node);
+ const parent = known.get(+post.reply_to_post_number);
+ const parentJump = node.querySelector(':scope > .ldp-post-head > .ldp-jump-parent');
+ const parentDisplayName = parent?.name || parent?.username || '';
+ if (parent && parentJump) {
+ parentJump.innerHTML = `${icon('info')}
`;
+ parentJump.lastElementChild.textContent = `#${+parent.post_number} ${parentDisplayName}`;
+ setLabel(parentJump, `在回复浮窗内跳到 ${parentDisplayName} 的楼层 #${+parent.post_number}`);
+ const time = node.querySelector(':scope > .ldp-post-head > .ldp-time[data-exact-time]');
+ const exactTime = time?.dataset.exactTime;
+ if (exactTime) {
+ const exactTimeLabel = makeElement('span');
+ exactTimeLabel.className = 'ldp-reply-exact-time';
+ exactTimeLabel.textContent = exactTime;
+ exactTimeLabel.setAttribute('aria-hidden', 'true');
+ parentJump.after(exactTimeLabel);
+ }
+ }
+ const actions = node.querySelector(':scope > .ldp-reactions .ldp-actions');
+ const branchAnchor = actions?.querySelector('.ldp-boostbtn') ||
+ actions?.querySelector('.ldp-replybtn') || actions?.querySelector('.ldp-like');
+ branchAnchor?.insertAdjacentHTML('afterend', `
`);
+ if (branchTarget) {
+ const branchDepth = Number.isInteger(depth) ? depth : +(list.querySelector(`:scope > .ldp-post[data-post-number="${+post.reply_to_post_number}"]`)?.dataset.ldpBranchDepth ?? -1) + 1;
+ const indent = Math.min(4, Math.max(0, branchDepth)) * 12;
+ node.dataset.ldpBranchDepth = String(branchDepth);
+ node.style.setProperty('--ldp-descendant-branch-indent', `${indent}px`);
+ }
+ const before = [...list.querySelectorAll(':scope > .ldp-post')].find((item) => +item.dataset.postNumber > number);
+ list.insertBefore(node, before || null); branchLineResizeObserver?.observe(node); bindPostMediaLayoutSync(node, ctx);
+ hydratePersistentAvatarImages(node, POST_REQUEST_PRIORITY.visible);
+ if (readerPostIsRead(post, ctx)) syncPostReadState(node, true);
+ else if (readIO) readIO.observe(node); else if (hydratePostContent(node, ctx)) ctx.tracker.observe(node);
+ if (+post.reply_count > 0) queue.push(post);
+ return node;
+ };
+ const loadMore = async () => {
+ if (loading || !queue.length) return;
+ loading = true; const current = token; let partial = false;
+ do {
+ const parent = queue.shift();
+ try {
+ const children = await ctx.repliesIO.loadDirectReplies(parent, { priority: POST_REQUEST_PRIORITY.visible });
+ if (current !== token) return;
+ children.forEach((child) => appendPost(child));
+ syncBranchControls();
+ scheduleBranchLine();
+ restoreHistoryPoint(); focusTarget();
+ }
+ catch { partial = true; }
+ } while (current === token && queue.length && (targetPostNumber || historyPoint || list.scrollHeight < list.scrollTop + list.clientHeight * 2));
+ if (current !== token || !panel.isConnected) return;
+ loading = false; syncTitle(partial);
+ if (!restoreHistoryPoint() && historyPoint && !queue.length) historyPoint = null;
+ if (!focusTarget() && targetPostNumber && !queue.length) settleTarget(false);
+ };
+ const syncTitle = (partial = false) => {
+ const authorName = threadRoot.name || threadRoot.username || '';
+ title.textContent = `#${threadRoot.post_number} · ${authorName} · 查看完整讨论(${known.size})`;
+ title.dataset.partial = partial ? '1' : '0';
+ setLabel(closeButton, stack.length > 1
+ ? `返回分支 #${stack[stack.length - 2].root.post_number}`
+ : '关闭完整讨论(Esc)');
+ };
+ const livePostTasks = new Map();
+ panel._ldpIngestLivePost = (post) => {
+ const number = +post?.post_number;
+ if (!number || known.has(number)) return Promise.resolve(false);
+ if (livePostTasks.has(number)) return livePostTasks.get(number);
+ const current = token, scopeNumber = +(branchTarget || threadRoot)?.post_number;
+ const task = (async () => {
+ const path = [], seen = new Set([number]); let child = post;
+ for (let guard = 0; child && guard < 32; guard++) {
+ const parentNumber = parentOf(child) || (scopeNumber === 1 ? 1 : 0);
+ if (parentNumber === scopeNumber) { path.unshift(child); break; }
+ if (!parentNumber || seen.has(parentNumber)) return false;
+ seen.add(parentNumber); path.unshift(child);
+ child = known.get(parentNumber) ||
+ ctx.streamNodeMap?.get(parentNumber)?._ldpPostData ||
+ ctx.streamItemMap?.get(parentNumber)?.node?._ldpPostData;
+ if (!child && isFunction(ctx.loadPost)) {
+ await ctx.loadPost(parentNumber, { mount: false, scope: 'single', priority: POST_REQUEST_PRIORITY.visible });
+ child = ctx.streamNodeMap?.get(parentNumber)?._ldpPostData;
+ }
+ if (current !== token || !panel.isConnected) return false;
+ }
+ if (!path.length || (parentOf(path[0]) || (scopeNumber === 1 ? 1 : 0)) !== scopeNumber) return false;
+ const nearBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= 120;
+ const listRect = list.getBoundingClientRect();
+ const anchor = nearBottom ? null : [...list.querySelectorAll(':scope > .ldp-post')]
+ .find((node) => node.getBoundingClientRect().bottom > listRect.top);
+ const offset = anchor?.getBoundingClientRect().top - listRect.top;
+ path.forEach((item) => appendPost(item));
+ if (anchor && Number.isFinite(offset)) { historyAnchor = { target: anchor, offset }; syncHistoryAnchor(); }
+ syncBranchControls(); syncTitle(title.textContent.includes('(部分)')); scheduleBranchLine();
+ if (nearBottom) requestAnimationFrame(() => { if (current === token) list.scrollTop = list.scrollHeight; });
+ return true;
+ })().finally(() => livePostTasks.delete(number));
+ livePostTasks.set(number, task);
+ return task;
+ };
+ const snapshotFrame = () => { const frame = stack[stack.length - 1]; if (frame) frame.posts = [...known.values()]; };
+ const renderThread = (root, push = true, lineage = [], seedPosts = []) => {
+ if (push) {
+ const trail = [...(stack[stack.length - 1]?.trail || [])], number = +root.post_number;
+ if (trail.at(-1) !== number) trail.push(number);
+ stack.push({ root, lineage: [...lineage], posts: [...seedPosts], trail });
+ }
+ token++; branchTarget = lineage.length ? root : null; threadRoot = lineage[0] || root; loading = false; historyAnchor = null;
+ if (!historyPoint && !targetPostNumber) historyPoint = windowState.views?.[viewKey()] || null;
+ readIO?.disconnect();
+ list.querySelectorAll('.ldp-post').forEach((node) => ctx.tracker.unobserve(node));
+ branchLineResizeObserver?.disconnect(); branchLineResizeObserver?.observe(list);
+ returnPoint = null;
+ known = new Map(); queue = [];
+ list.replaceChildren();
+ lineage.forEach((post, index) => appendPost(post, index === 0, index));
+ appendPost(root, !lineage.length, lineage.length);
+ [...seedPosts].sort((a, b) => +a.post_number - +b.post_number).forEach((post) => appendPost(post));
+ const loadedChildren = new Map();
+ known.forEach((post) => { const parent = parentOf(post); if (known.has(parent)) loadedChildren.set(parent, (loadedChildren.get(parent) || 0) + 1); });
+ queue = [...branchDescendantNumbers(root.post_number)].map((number) => known.get(number))
+ .filter((post) => +post.reply_count > (loadedChildren.get(+post.post_number) || 0));
+ syncBranchControls();
+ hoveredBranchNumber = 0; scheduleBranchLine();
+ syncTitle();
+ const positioned = historyPoint ? restoreHistoryPoint() : focusTarget();
+ if (queue.length || !positioned) void loadMore();
+ };
+ panel._ldpBack = () => {
+ if (stack.length < 2) return false;
+ snapshotFrame(); persistWindowState();
+ stack.pop(); const previous = stack[stack.length - 1];
+ historyPoint = previous.returnPoint || null; previous.returnPoint = null;
+ renderThread(previous.root, false, previous.lineage, previous.posts); return true;
+ };
+ ['wheel', 'pointerdown', 'touchstart', 'keydown'].forEach((type) => list.addEventListener(type, () => { historyAnchor = null; }, { passive: true }));
+ list.addEventListener('scroll', () => { scheduleBranchLine(); if (list.scrollTop + list.clientHeight * 2 >= list.scrollHeight) void loadMore(); }, { passive: true });
+ panel.addEventListener('pointerover', (event) => {
+ const button = event.target.closest?.('.ldp-descendant-replies-reroot');
+ if (!button || button.contains(event.relatedTarget)) return;
+ hoveredBranchNumber = +button.dataset.postNumber; scheduleBranchLine(hoveredBranchNumber);
+ });
+ panel.addEventListener('pointerout', (event) => {
+ const button = event.target.closest?.('.ldp-descendant-replies-reroot');
+ if (!button || button.contains(event.relatedTarget)) return;
+ hoveredBranchNumber = 0; scheduleBranchLine();
+ });
+ panel.addEventListener('click', (event) => {
+ if (event.target.closest('.ldp-descendant-replies-close')) {
+ consumeEvent(event);
+ if (!panel._ldpBack()) close();
+ return;
+ }
+ if (event.target.closest('.ldp-descendant-replies-top')) { consumeEvent(event); historyPoint = historyAnchor = null; list.scrollTo({ top: 0, behavior: 'auto' }); return; }
+ const returnButton = event.target.closest('.ldp-descendant-replies-return');
+ if (returnButton) {
+ consumeEvent(event);
+ const source = returnPoint?.source, scrollTop = returnPoint?.scrollTop;
+ returnButton.remove(); returnPoint = null;
+ if (Number.isFinite(scrollTop)) list.scrollTo({ top: scrollTop, behavior: 'auto' });
+ if (source?.isConnected) keepJumpHighlightUntilSettled(source);
+ return;
+ }
+ const parentJump = event.target.closest('.ldp-jump-parent');
+ if (parentJump) {
+ consumeEvent(event);
+ const source = parentJump.closest('.ldp-post');
+ ctx.floorPreview?.hide(source);
+ const target = list.querySelector(`:scope > .ldp-post[data-post-number="${+parentJump.dataset.targetPostNumber}"]`);
+ if (!target) return;
+ list.querySelector('.ldp-descendant-replies-return')?.remove();
+ returnPoint = { source, scrollTop: list.scrollTop };
+ target.querySelector(':scope > .ldp-post-head > .ldp-body-floor')?.insertAdjacentHTML('afterend', `
`);
+ const targetTop = list.scrollTop + target.getBoundingClientRect().top - list.getBoundingClientRect().top;
+ list.scrollTo({ top: Math.max(0, targetTop - 12), behavior: 'auto' });
+ keepJumpHighlightUntilSettled(target);
+ return;
+ }
+ const reroot = event.target.closest('.ldp-descendant-replies-reroot');
+ if (reroot) {
+ consumeEvent(event); const number = +reroot.dataset.postNumber, next = known.get(number);
+ if (next) {
+ const path = replyPath(next);
+ if (+path[0]?.post_number !== +threadRoot.post_number ||
+ path.some((post, index) => index && parentOf(post) !== +path[index - 1].post_number)) return;
+ const wasOpened = openedBranches.has(number), descendants = branchDescendantNumbers(number);
+ const seedPosts = branchPosts(number), savedPoint = windowState.views?.[
+ `${CURRENT_SITE_HOST || location.host}:${ctx.topicId}:${+threadRoot.post_number}:${number}`
+ ];
+ const firstUnread = wasOpened && !savedPoint && seedPosts
+ .filter((post) => descendants.has(+post.post_number) && !readerPostIsRead(post, ctx))
+ .sort((a, b) => +a.post_number - +b.post_number)[0];
+ branchDescendantNumbers(number).forEach((member) => {
+ if (!openedBranches.has(member)) { openedBranches.add(member); openedBranchesDirty = true; }
+ });
+ const source = reroot.closest('.ldp-post'), current = stack[stack.length - 1];
+ if (source && current) current.returnPoint = { number, scrollTop: list.scrollTop, offset: source.getBoundingClientRect().top - list.getBoundingClientRect().top };
+ snapshotFrame(); persistWindowState();
+ historyPoint = savedPoint || { number: +(firstUnread?.post_number || number), scrollTop: 0, offset: 12 };
+ renderThread(next, true, path.length > 1 ? path.slice(0, -1) : [next], seedPosts);
+ }
+ }
+ });
+ const initialPosts = cachedDescendantPosts(rootPost.post_number);
+ if (continueAfterInlineReplies && !windowState.views?.[viewKey()]) {
+ const firstUnshownReply = initialPosts
+ .filter((post) => !displayedReplyNumbers.has(+post.post_number))
+ .sort((left, right) => +left.post_number - +right.post_number)[0];
+ if (firstUnshownReply) {
+ historyPoint = { number: +firstUnshownReply.post_number, scrollTop: 0, offset: 12 };
+ }
+ }
+ renderThread(rootPost, true, [], initialPosts);
+ return targetReady;
+ }
+
+ async function openAggregateReplyTarget(postNumber, ctx, options = {}) {
+ const targetNumber = +postNumber;
+ let root = ctx?.streamNodeMap?.get(targetNumber)?._ldpPostData;
+ if (!root && ctx?.loadPost && targetNumber > 0) {
+ await ctx.loadPost(targetNumber, {
+ mount: false,
+ priority: POST_REQUEST_PRIORITY.visible,
+ scope: 'single',
+ });
+ root = ctx.streamNodeMap?.get(targetNumber)?._ldpPostData;
+ }
+ if (!root) return false;
+ const openOptions = { ...options };
+ if (!openOptions.returnControl?.isConnected &&
+ !(Number(openOptions.returnViewport?.postNumber) > 0)) {
+ openOptions.returnViewport = embeddedReaderViewportState(ctx);
+ }
+ let parentNumber = +(root.reply_to_post_number || ctx.replyParentByPost?.get(targetNumber) || 0);
+ while (parentNumber > 1) {
+ if (!ctx.streamNodeMap.has(parentNumber) && ctx.loadPost) {
+ await ctx.loadPost(parentNumber, { mount: false, scope: 'single' });
+ }
+ const parent = ctx.streamNodeMap.get(parentNumber)?._ldpPostData;
+ if (!parent) break;
+ root = parent;
+ parentNumber = +(parent.reply_to_post_number ||
+ ctx.replyParentByPost?.get(+parent.post_number) || 0);
+ }
+ return openDescendantRepliesWindow(
+ root,
+ ctx,
+ Number(openOptions.restorePoint?.number) > 0 ? 0 : targetNumber,
+ openOptions,
+ );
+ }
+
+ function ensureLiveReplyShell(parentNode, totalCount, ctx) {
+ if (!parentNode) return null;
+ const parentDepth = postNestDepth(
+ parentNode,
+ parentNode.classList.contains('ldp-nested-preview') ? 1 : 0,
+ );
+ const maxDepth = inlineReplyTreeMaxDepth();
+ // Do not create a child shell past the configured inline tree budget.
+ if (parentDepth >= maxDepth) return parentNode.querySelector(':scope > .ldp-children');
+ const total = Math.max(1, +totalCount || 0);
+ parentNode.dataset.childReplyTotal = String(Math.max(+(parentNode.dataset.childReplyTotal || 0), total));
+ if (parentNode._ldpPostData) {
+ parentNode._ldpPostData.reply_count = Math.max(+(parentNode._ldpPostData.reply_count || 0), total);
+ }
+ let children = parentNode.querySelector(':scope > .ldp-children');
+ const created = !children;
+ if (!children) {
+ const holder = makeElement('template');
+ holder.innerHTML = childReplyShellHtml(total);
+ const [collapse, nextChildren, loading] = holder.content.children;
+ const footer = parentNode.querySelector(':scope > .ldp-topic-footer-actions');
+ [collapse, nextChildren, loading].forEach((node) => parentNode.insertBefore(node, footer || null));
+ children = nextChildren;
+ }
+ setNestedReplyCollapsed(parentNode, parentNode._ldpPostData?.hidden === true);
+ if (created) {
+ const isTopicStarter = +parentNode.dataset.postNumber === 1;
+ const collapseByDefault = parentDepth >= 2 ||
+ (parentDepth === 0 && (isTopicStarter || PREFS.expandNestedRepliesByDefault !== true));
+ setChildRepliesCollapsed(parentNode, collapseByDefault);
+ }
+ if (ctx?.repliesIO) ctx.repliesIO.observe(parentNode);
+ syncChildControls(parentNode);
+ return children;
+ }
+
+ function syncLiveNestedReply(post, ctx, options = {}) {
+ const postNumber = +(post?.post_number || 0);
+ const parentNumber = +(post?.reply_to_post_number || 0);
+ if (!ctx || !postNumber || parentNumber < 1) return false;
+ const previous = ctx.subReplyState.get(parentNumber);
+ const previousAll = previous && Array.isArray(previous.all) ? previous.all : [];
+ const wasAlreadyKnown = previousAll.some((reply) => +(reply?.post_number || 0) === postNumber);
+ rememberPostData(ctx, post, parentNumber);
+ const parentNode = findReplyListParent(parentNumber, ctx) || ctx.streamNodeMap.get(parentNumber);
+ if (!parentNode) return false;
+ const knownMap = ctx.directRepliesByParent.get(parentNumber);
+ const knownReplies = knownMap ? [...knownMap.values()] : [];
+ const merged = mergeDirectReplies(previousAll, knownReplies, [post]);
+ const previousTotal = Math.max(previousAll.length, +(previous?.totalCount || 0));
+ const pageSize = SUB_REPLY_PAGE_SIZE;
+ const previousPageCount = Math.max(1, Math.ceil(previousTotal / pageSize));
+ const wasOnLastPage = !previous || +(previous.pageIndex || 0) >= previousPageCount - 1;
+ const totalCount = Math.max(
+ merged.length,
+ previousTotal + (options.incrementTotal === true && !wasAlreadyKnown ? 1 : 0),
+ +(parentNode.dataset.childReplyTotal || 0),
+ +(parentNode._ldpPostData?.reply_count || 0),
+ );
+ ensureLiveReplyShell(parentNode, totalCount, ctx);
+ const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
+ const liveReplyIndex = merged.findIndex((reply) => +(reply?.post_number || 0) === postNumber);
+ const liveReplyPage = liveReplyIndex >= 0 ? Math.floor(liveReplyIndex / pageSize) : pageCount - 1;
+ const state = {
+ all: merged,
+ pageIndex: wasOnLastPage ? liveReplyPage : +(previous?.pageIndex || 0),
+ nodeCache: previous?.nodeCache,
+ renderedKey: '',
+ renderedParent: previous?.renderedParent,
+ totalCount,
+ fetchedReplyCount: Math.max(merged.length, +(previous?.fetchedReplyCount || 0)),
+ fetchAfter: Math.max(postNumber, +(previous?.fetchAfter || 0)),
+ hasMore: totalCount > merged.length || !!(previous?.hasMore),
+ loadingMore: false,
+ };
+ ctx.subReplyState.set(parentNumber, state);
+ if (parentNode.isConnected) renderSubReplyBatch(parentNumber, ctx, 0, parentNode);
+ return true;
+ }
+
+ function updateLivePostData(post, ctx, options = {}) {
+ const postNumber = +(post?.post_number || 0);
+ const streamNode = ctx?.streamNodeMap && ctx.streamNodeMap.get(postNumber);
+ const postId = +(post?.id || streamNode && streamNode.dataset.postId || 0);
+ const copies = new Set(postId ? readerPostCopies(postId, ctx, true) : []);
+ if (streamNode) copies.add(streamNode);
+ if (!copies.size) return false;
+ copies.forEach((node) => {
+ node._ldpPostData = Object.assign({}, node._ldpPostData || {}, post);
+ syncPostReadState(node, readerPostIsRead(node._ldpPostData, ctx));
+ const content = node.querySelector(':scope > .ldp-content');
+ if (options.refreshContent === true && content && post.cooked != null) {
+ setPostCookedContent(content, post.cooked, node._ldpPostData.link_counts);
+ content.classList.remove('ldp-content-pending');
+ content.removeAttribute('aria-busy');
+ bindPostMediaLayoutSync(node, ctx);
+ }
+ if (Array.isArray(post.polls)) renderPostPolls(node, ctx);
+ renderPostSpecialContent(node, ctx);
+ syncPostActionControls(node, ctx);
+ ctx?.translation?.syncPost(node);
+ });
+ const mergedPost = streamNode?._ldpPostData || { ...post };
+ rememberPostData(ctx, mergedPost);
+ const replyCount = +(post.reply_count || 0);
+ if (replyCount > 0) {
+ copies.forEach((node) => {
+ if (postNestDepth(node, node.classList.contains('ldp-nested-preview') ? 1 : 0) < inlineReplyTreeMaxDepth()) {
+ ensureLiveReplyShell(node, replyCount, ctx);
+ }
+ });
+ }
+ return true;
+ }
+
+ function resolveKnownPostData(ctx, postNumber, fallback = null) {
+ const number = +postNumber;
+ if (!number || !ctx) return fallback;
+ const streamData = ctx.streamNodeMap?.get(number)?._ldpPostData;
+ if (streamData) return streamData;
+ const nestedNode = findReplyListParent(number, ctx);
+ if (nestedNode?._ldpPostData) return nestedNode._ldpPostData;
+ if (ctx.directRepliesByParent) {
+ for (const replies of ctx.directRepliesByParent.values()) {
+ const found = replies?.get?.(number);
+ if (found) return found;
+ }
+ }
+ return fallback;
+ }
+
+ function buildNestedReplyAncestorChain(post, ctx) {
+ const targetNumber = +(post?.post_number || 0);
+ if (!targetNumber) return [];
+ const chain = [];
+ const seen = new Set();
+ let current = post;
+ let number = targetNumber;
+ for (let guard = 0; number > 0 && !seen.has(number) && guard < 32; guard++) {
+ seen.add(number);
+ chain.unshift(number);
+ const parentNumber = +(
+ current?.reply_to_post_number ||
+ ctx.replyParentByPost?.get(number) ||
+ 0
+ );
+ if (!parentNumber) break;
+ number = parentNumber;
+ current = resolveKnownPostData(ctx, number);
+ }
+ return chain;
+ }
+
+ async function focusLiveNestedReply(post, ctx, options = {}) {
+ const postNumber = +(post?.post_number || 0);
+ const parentNumber = +(post?.reply_to_post_number || 0);
+ if (!ctx || !postNumber || parentNumber < 1) return false;
+ const navigationSequence = options.navigationSequence || beginReaderNavigation(ctx);
+ const jumpStillCurrent = () => readerNavigationIsCurrent(ctx, navigationSequence) &&
+ (!isFunction(options.acceptsWork) || options.acceptsWork());
+ if (!jumpStillCurrent()) return false;
+ if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition();
+ rememberPostData(ctx, post, parentNumber);
+ const chain = buildNestedReplyAncestorChain(post, ctx);
+ if (chain.length < 2) return false;
+ // Ensure the highest stream-mounted ancestor is available, then walk down the tree.
+ const rootNumber = chain[0];
+ if (!ctx.streamNodeMap.has(rootNumber) && ctx.loadPost) {
+ await ctx.loadPost(rootNumber, { mount: true, ensureAround: true, scope: 'around' });
+ }
+ if (!jumpStillCurrent()) return false;
+ let parentNode = getStreamPostNode(ctx, rootNumber, { mount: true }) ||
+ findReplyListParent(rootNumber, ctx) ||
+ ctx.streamNodeMap.get(rootNumber);
+ if (!parentNode) return false;
+ let target = null;
+ for (let index = 0; index < chain.length - 1; index++) {
+ if (!jumpStillCurrent()) return false;
+ const ancestorNumber = chain[index];
+ const childNumber = chain[index + 1];
+ const childPost = childNumber === postNumber
+ ? post
+ : resolveKnownPostData(ctx, childNumber);
+ if (childPost) rememberPostData(ctx, childPost, ancestorNumber);
+ const ancestorData = parentNode._ldpPostData || resolveKnownPostData(ctx, ancestorNumber);
+ const expectedCount = Math.max(
+ +(parentNode.dataset.childReplyTotal || 0),
+ +(ancestorData?.reply_count || 0),
+ 1,
+ );
+ ensureLiveReplyShell(parentNode, expectedCount, ctx);
+ parentNode.classList.remove('ldp-children-collapsed');
+ setNestedReplyCollapsed(parentNode, false);
+ if (ancestorData && ctx.repliesIO?.loadDirectReplies) {
+ try {
+ await ctx.repliesIO.loadDirectReplies(ancestorData, {
+ priority: POST_REQUEST_PRIORITY.visible,
+ });
+ } catch { /* keep best-effort path expansion */ }
+ }
+ if (!jumpStillCurrent() || !parentNode.isConnected) return false;
+ syncLiveNestedReply(childPost || { post_number: childNumber, reply_to_post_number: ancestorNumber }, ctx, {
+ allowOpParent: true,
+ });
+ const state = ctx.subReplyState.get(ancestorNumber);
+ if (state) {
+ const parentDepth = postNestDepth(parentNode, 0);
+ const pageSize = parentDepth >= 1
+ ? Math.min(5, SUB_REPLY_PAGE_SIZE)
+ : (PREFS.aggregateDescendantReplies === true ? 5 : SUB_REPLY_PAGE_SIZE);
+ const replyIndex = state.all.findIndex((reply) => +(reply?.post_number || 0) === childNumber);
+ state.pageIndex = replyIndex >= 0
+ ? Math.floor(replyIndex / pageSize)
+ : Math.max(0, Math.ceil(state.all.length / pageSize) - 1);
+ state.renderedKey = '';
+ renderSubReplyBatch(ancestorNumber, ctx, 0, parentNode);
+ }
+ syncChildControls(parentNode);
+ const childNode = parentNode.querySelector(
+ `:scope > .ldp-children > .ldp-post[data-post-number="${childNumber}"]`
+ );
+ if (!childNode) return false;
+ setNestedReplyCollapsed(childNode, false);
+ childNode.classList.remove('ldp-children-collapsed');
+ if (childNumber === postNumber) {
+ target = childNode;
+ break;
+ }
+ parentNode = childNode;
+ }
+ if (!target) return false;
+ syncPostActionControls(target, ctx);
+ const scrollParentNumber = +(target.dataset.replyToPostNumber || parentNumber || rootNumber);
+ if (!scrollToPost(scrollParentNumber, ctx, {
+ ...PINNED_STREAM_SCROLL_OPTIONS,
+ visualNode: target,
+ highlightTarget: target,
+ alignCenter: true,
+ })) return false;
+ await waitForReaderJumpLayout();
+ if (!jumpStillCurrent() || !target.isConnected) return false;
+ if (!readerTargetNodeIsVisible(target, ctx)) return false;
+ if (ctx.timeline) ctx.timeline.sync();
+ return true;
+ }
+
+ async function returnToReaderQuoteSource(source, ctx) {
+ const target = normalizeReaderQuoteSource(source);
+ if (!ctx || !target) return false;
+ const { topicId, postNumber, parentPostNumber, nested } = target;
+ if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false;
+ if (topicId !== +ctx.topicId) {
+ const sourceState = normalizeReaderAnchorState(
+ CURRENT_OVERLAY?._ldpReaderHistoryNavigation?.states?.[topicId]
+ ) || normalizeReaderAnchorState(target.anchor);
+ beginReaderNavigation(ctx);
+ if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition();
+ await openModal(topicId, {
+ targetPostNumber: sourceState?.viewport.postNumber ||
+ (nested ? parentPostNumber : postNumber),
+ restoreState: sourceState || undefined,
+ quoteSourceTarget: sourceState ? undefined : target,
+ });
+ return true;
+ }
+ clearJumpBackLinks(ctx);
+ if (nested) {
+ let postData = ctx.streamNodeMap?.get(postNumber)?._ldpPostData ||
+ postsFromPayload(ctx.topicData).find((post) => +post.post_number === postNumber);
+ if (!postData && ctx.loadPost) {
+ await ctx.loadPost(postNumber, { mount: false, scope: 'single' });
+ postData = ctx.streamNodeMap?.get(postNumber)?._ldpPostData;
+ }
+ if (!postData) return false;
+ postData = Object.assign({}, postData, {
+ post_number: postNumber,
+ reply_to_post_number: parentPostNumber,
+ });
+ return !!ctx.timeline && ctx.timeline.jumpTo(postNumber, {
+ targetPost: postData,
+ forceNested: true,
+ });
+ }
+ return !!ctx.timeline && ctx.timeline.jumpTo(postNumber);
+ }
+
+ function ingestLivePosts(posts, ctx, options = {}) {
+ const ordered = (Array.isArray(posts) ? posts : [])
+ .filter((post) => post?.id && post.post_number)
+ .sort((a, b) => +(a.post_number || 0) - +(b.post_number || 0));
+ if (!ordered.length) return [];
+ const fresh = [];
+ ordered.forEach((post) => {
+ if (!updateLivePostData(post, ctx, options)) fresh.push(post);
+ });
+ if (fresh.length) attachPostBatch(fresh, ctx, { syncStreamNow: true });
+ const freshPostNumbers = new Set(fresh.map((post) => +(post.post_number || 0)));
+ ordered.forEach((post) => syncLiveNestedReply(post, ctx, {
+ incrementTotal: freshPostNumbers.has(+(post.post_number || 0)),
+ }));
+ if (ctx.isPostVoting) {
+ ctx.streamItems.sort(comparePostVotingStreamItems);
+ reindexStreamItems(ctx, 0);
+ invalidateStreamLayout(ctx);
+ }
+ updateCommentsHeader(ctx);
+ if (ctx.timeline) ctx.timeline.update();
+ return fresh;
+ }
+
+ function selectedQuoteRaw(post, ctx, selectedText) {
+ const postData = post?._ldpPostData || {};
+ const username = cleanUsernameValue(postData.username || post && post.dataset.username);
+ const postNumber = +(post && post.dataset.postNumber || 0);
+ const topicId = +(ctx?.topicId || 0);
+ const text = String(selectedText || '').trim();
+ if (!username || !postNumber || !topicId || !text) return '';
+ return `[quote="${username}, post:${postNumber}, topic:${topicId}"]\n${text}\n[/quote]\n\n`;
+ }
+
+ async function writeSelectionQuoteToClipboard(raw) {
+ if (!navigator.clipboard || !isFunction(navigator.clipboard.writeText))
+ throw new Error('clipboard unavailable');
+ await navigator.clipboard.writeText(raw);
+ }
+
+ function showSelectionToast(message) {
+ const existingToast = readerPortalQuery('.ldp-selection-toast');
+ if (existingToast)
+ existingToast.remove();
+ const toast = makeElement('div');
+ toast.className = 'ldp-selection-toast';
+ toast.setAttribute('role', 'status');
+ toast.textContent = message;
+ appendReaderPortalNode(toast);
+ setTimeout(() => toast.remove(), 1800);
+ }
+
+ async function shareReaderLink(ctx, postNode = null) {
+ const sharingPost = !!postNode;
+ const postNumber = +(postNode && postNode.dataset.postNumber || 0);
+ if (!ctx || !ctx.topicId || (sharingPost && !postNumber)) return;
+ const title = String(ctx?.topicData && ctx.topicData.title || document.title || SITE_DISPLAY_NAME);
+ const url = topicPageUrl(ctx.topicId, sharingPost ? postNumber : 0);
+ if (!url) return;
+ if (!sharingPost && navigator.share) {
+ try {
+ await navigator.share({ title, url });
+ return;
+ } catch (error) {
+ if (error && error.name === 'AbortError') return;
+ }
+ }
+ try {
+ await writeSelectionQuoteToClipboard(url);
+ showSelectionToast(sharingPost ? `楼层 #${postNumber} 链接已复制到剪切板` : '帖子链接已复制到剪切板');
+ } catch {
+ showSelectionToast(sharingPost ? '复制楼层链接失败,请重试' : '复制链接失败,请重试');
+ }
+ }
+
+ function syncReaderBookmarkState(ctx, type, itemId, bookmarked, bookmarkId, options = {}) {
+ if (type === 'Post') {
+ const updated = [];
+ readerPostCopies(itemId, ctx, true).forEach((node) => {
+ const postData = node._ldpPostData || options.fallback;
+ if (!postData) return;
+ node._ldpPostData = postData;
+ Object.assign(postData, { bookmarked, bookmark_id: bookmarkId });
+ syncPostActionControls(node, ctx, { bookmarkBusy: options.busy === true });
+ if (options.persist === true) persistReaderPostState(ctx, postData);
+ updated.push(postData);
+ });
+ return updated;
+ }
+ const topicData = options.fallback || ctx.topicData;
+ ctx.topicBookmarked = bookmarked;
+ if (topicData) {
+ Object.assign(topicData, { bookmarked, bookmark_id: bookmarkId });
+ if (options.persist === true) setCachedTopicData(ctx.topicId, topicData);
+ }
+ syncTopicActionControls(ctx, { bookmarkBusy: options.busy === true });
+ return topicData ? [topicData] : [];
+ }
+
+ function discourseBookmarkApi() {
+ const bookmarkApi = lookupDiscourse('service:bookmark-api');
+ if (!bookmarkApi || !isFunction(bookmarkApi.buildNewBookmark) ||
+ !isFunction(bookmarkApi.create) || !isFunction(bookmarkApi.delete))
+ throw new Error('Discourse 收藏服务尚未就绪');
+ return bookmarkApi;
+ }
+
+ async function createDiscourseBookmark(type, itemId) {
+ const bookmarkApi = discourseBookmarkApi();
+ const formDataModule = lookupDiscourseModule('discourse/lib/bookmark-form-data');
+ const BookmarkFormData = formDataModule?.BookmarkFormData;
+ if (!isFunction(BookmarkFormData))
+ throw new Error('Discourse 收藏表单模型尚未就绪');
+ const bookmark = bookmarkApi.buildNewBookmark(type, itemId);
+ const saved = await bookmarkApi.create(new BookmarkFormData(bookmark));
+ return Number(saved?.id) || null;
+ }
+
+ async function deleteDiscourseBookmark(bookmarkId) {
+ if (!(Number(bookmarkId) > 0)) throw new Error('缺少收藏编号');
+ return discourseBookmarkApi().delete(Number(bookmarkId));
+ }
+
+ async function toggleReaderBookmark(subject, ctx, type) {
+ const postBookmark = type === 'Post';
+ const item = postBookmark ? subject?._ldpPostData : subject;
+ if (!item || !item.id || !ME_USERNAME) return;
+ return runReaderActionOnce(ctx, `${type.toLowerCase()}:${item.id}:bookmark`, async () => {
+ let { bookmarked, bookmarkId } = postBookmark
+ ? postBookmarkInfo(item)
+ : { bookmarked: !!item.bookmarked, bookmarkId: item.bookmark_id || null };
+ const original = { bookmarked, bookmarkId };
+ const apply = (busy = false) => syncReaderBookmarkState(
+ ctx, type, item.id, bookmarked, bookmarkId, { busy, fallback: item }
+ );
+ let mutationSucceeded = false;
+ apply(true);
+ try {
+ if (!bookmarked) {
+ bookmarkId = await createDiscourseBookmark(type, item.id);
+ bookmarked = true;
+ } else {
+ if (!bookmarkId && postBookmark) {
+ const fresh = await fetchJSON(`${BASE}/posts/${item.id}.json`, {
+ cacheMode: 'refresh',
+ allowStaleOnError: false,
+ });
+ bookmarkId = fresh?.bookmark_id;
+ }
+ if (bookmarkId) {
+ await deleteDiscourseBookmark(bookmarkId);
+ } else if (postBookmark) {
+ throw new Error('缺少楼层书签编号');
+ } else {
+ const topicModule = lookupDiscourseModule('discourse/models/topic');
+ const Topic = topicModule?.default;
+ if (!Topic || !isFunction(Topic.create)) {
+ throw new Error('Discourse 主题模型尚未就绪');
+ }
+ const topicModel = Topic.create({ ...item, id: Number(item.id) });
+ if (!isFunction(topicModel.deleteBookmarks)) {
+ throw new Error('Discourse 主题收藏接口尚未就绪');
+ }
+ await topicModel.deleteBookmarks();
+ }
+ bookmarked = false;
+ bookmarkId = null;
+ }
+ await clearStoredResponseCache({
+ tags: ['bookmarks', `${type.toLowerCase()}:${item.id}`],
+ });
+ mutationSucceeded = true;
+ if (isFunction(ctx.refreshBookmarksPanel)) ctx.refreshBookmarksPanel();
+ if (postBookmark) showSelectionToast(bookmarked ? '已收藏该楼层' : '已取消楼层收藏');
+ } catch (error) {
+ if (postBookmark || !mutationSucceeded) ({ bookmarked, bookmarkId } = original);
+ alert(`${postBookmark ? '楼层' : ''}收藏操作失败:${error.message}`);
+ } finally {
+ const updated = apply(false);
+ if (postBookmark) {
+ if (updated[0]) persistReaderPostState(ctx, updated[0]);
+ } else {
+ setCachedTopicData(ctx.topicId, item);
+ }
+ }
+ });
+ }
+
+ const READER_REPORT_COPY = Object.freeze({
+ spam: { label: '垃圾信息', description: '广告、恶意推广或明显无关的内容。' },
+ inappropriate: { label: '不当内容', description: '违反社区文明交流规则的内容。' },
+ off_topic: { label: '偏离主题', description: '与当前主题无关,影响讨论阅读。' },
+ notify_moderators: {
+ label: '通知管理人员',
+ description: '需要管理人员结合补充说明进行判断。',
+ requireMessage: true,
+ },
+ });
+
+ function readerReportOptions(postModel) {
+ const site = lookupDiscourse('service:site');
+ const flagTypes = discourseModelValue(site, 'flagTypes');
+ const actionByName = discourseModelValue(postModel, 'actionByName');
+ if (!Array.isArray(flagTypes) || !actionByName) return [];
+ return flagTypes.map((type) => {
+ const nameKey = String(discourseModelValue(type, 'name_key') || '');
+ const action = discourseModelValue(actionByName, nameKey);
+ const id = Number(discourseModelValue(type, 'id'));
+ if (!nameKey || !(id > 0) || !action || discourseModelValue(action, 'can_act') !== true)
+ return null;
+ const copy = READER_REPORT_COPY[nameKey] || {};
+ return {
+ id,
+ nameKey,
+ label: copy.label || String(discourseModelValue(type, 'name') || nameKey),
+ description: copy.description || String(discourseModelValue(type, 'description') || ''),
+ requireMessage: copy.requireMessage === true,
+ action,
+ };
+ }).filter(Boolean);
+ }
+
+ function openReaderReportForm(ctx, config) {
+ const reportOptions = config.options || [];
+ if (!reportOptions.length) {
+ showSelectionToast('当前没有可用的举报类型');
+ return null;
+ }
+ const postActionTypeModule = lookupDiscourseModule('discourse/models/post-action-type');
+ const messageMaxLength = Math.max(
+ 1,
+ Number(postActionTypeModule?.MAX_MESSAGE_LENGTH) || 500,
+ );
+ return openReaderActionDialog(ctx, {
+ title: config.title,
+ intro: config.intro ?? '举报会直接提交给社区,不会离开阅读器。',
+ submitLabel: '提交举报',
+ initialFocus: 'input[name="report_type"]',
+ bodyHtml: `
+
${reportOptions.map((option, index) => `
+
+
+
+ ${esc(option.label)}
+ ${esc(option.description)}
+
+ `).join('')}
+
+ 补充说明
+
+ `,
+ onSubmit: async (formData) => {
+ const actionType = +(formData.get('report_type') || 0);
+ const message = String(formData.get('message') || '').trim();
+ const selectedOption = reportOptions.find((option) => option.id === actionType);
+ if (!selectedOption) throw new Error('请选择举报类型');
+ if (selectedOption.requireMessage && !message) throw new Error(
+ config.requiredMessageError || `${selectedOption.label}需要填写具体原因`
+ );
+ return config.onSubmit(actionType, message);
+ },
+ });
+ }
+
+ function openReaderActionDialog(ctx, options) {
+ if (!ctx || !ctx.modal) return null;
+ if (ctx.readerActionDialog) ctx.readerActionDialog.close();
+ const layer = makeElement('div');
+ layer.className = 'ldp-reader-action-layer';
+ layer.innerHTML = `
+
+
+ ${esc(options.title || '操作')}
+
+
+
+ `;
+ const form = layer.querySelector('.ldp-reader-action-form');
+ const status = layer.querySelector('.ldp-reader-action-status');
+ const submit = layer.querySelector('.ldp-reader-action-submit');
+ let closeTimer = 0;
+ let busy = false;
+ const close = () => {
+ if (closeTimer) clearTimeout(closeTimer);
+ layer.remove();
+ if (ctx.readerActionDialog && ctx.readerActionDialog.layer === layer)
+ ctx.readerActionDialog = null;
+ };
+ const setBusy = (nextBusy) => {
+ busy = nextBusy;
+ form.querySelectorAll('input,textarea,select,button').forEach((control) => {
+ control.disabled = nextBusy;
+ });
+ submit.textContent = nextBusy ? '提交中…' : (options.submitLabel || '提交');
+ };
+ bindFloatingSurfaceWheel(layer);
+ layer.addEventListener('click', (event) => {
+ const closeControl = event.target instanceof Element &&
+ event.target.closest('.ldp-reader-action-close,.ldp-reader-action-cancel');
+ if (closeControl && layer.contains(closeControl)) close();
+ else if (event.target === layer && !busy) close();
+ });
+ form.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ if (busy) return;
+ status.classList.remove('success');
+ status.textContent = '';
+ setBusy(true);
+ try {
+ const successMessage = await options.onSubmit(new FormData(form));
+ status.classList.add('success');
+ status.textContent = successMessage || '操作已完成';
+ closeTimer = setTimeout(close, 650);
+ } catch (error) {
+ status.textContent = error?.message ? error.message : '操作失败,请重试';
+ setBusy(false);
+ }
+ });
+ const dialogState = { layer, close };
+ ctx.readerActionDialog = dialogState;
+ ctx.modal.appendChild(layer);
+ requestAnimationFrame(() => {
+ const focusTarget = layer.querySelector(options.initialFocus || 'input,textarea,button');
+ if (focusTarget) focusTarget.focus();
+ });
+ return dialogState;
+ }
+
+ function openReaderConfirmDialog(ctx, options) {
+ if (!ctx || !ctx.modal) return null;
+ if (ctx.readerActionDialog) ctx.readerActionDialog.close();
+ const layer = makeElement('div');
+ const previouslyFocused = document.activeElement;
+ const note = String(options.note || '').trim();
+ const details = Array.isArray(options.details) ? options.details.filter((detail) => detail?.label) : [];
+ const secondaryLabel = String(options.secondaryLabel || '').trim();
+ layer.className = 'ldp-reader-action-layer ldp-reader-confirm-layer';
+ layer.innerHTML = `
+
+
+
${icon(options.icon || 'alertTriangle')}
+
+
${esc(options.title || '确认操作')}
+
${esc(options.message || '')}
+ ${details.length ? `
${details.map((detail) => `- ${esc(detail.label)}${esc(detail.value || '')}
`).join('')}
` : ''}
+ ${note ? `
${esc(note)}` : ''}
+
+
+
+ `;
+ const cancelButton = layer.querySelector('.ldp-reader-action-cancel');
+ const secondaryButton = layer.querySelector('.ldp-reader-action-secondary');
+ const confirmButton = layer.querySelector('.ldp-reader-action-submit');
+ let closed = false;
+ const close = () => {
+ if (closed) return;
+ closed = true;
+ layer.remove();
+ if (ctx.readerActionDialog && ctx.readerActionDialog.layer === layer) ctx.readerActionDialog = null;
+ requestAnimationFrame(() => {
+ if (previouslyFocused?.isConnected && isFunction(previouslyFocused.focus))
+ previouslyFocused.focus();
+ });
+ };
+ bindFloatingSurfaceWheel(layer);
+ layer.addEventListener('click', (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ if (event.target === layer || target?.closest('.ldp-reader-action-cancel'))
+ close();
+ else if (target?.closest('.ldp-reader-action-secondary')) {
+ if (closed) return;
+ if (isFunction(options.onSecondary)) options.onSecondary();
+ close();
+ } else if (target?.closest('.ldp-reader-action-submit')) {
+ if (closed) return;
+ if (isFunction(options.onConfirm)) options.onConfirm();
+ close();
+ }
+ });
+ layer.addEventListener('keydown', (event) => {
+ if (event.key !== 'Tab') return;
+ const buttons = [cancelButton, secondaryButton, confirmButton].filter(Boolean);
+ if (event.shiftKey && document.activeElement === buttons[0]) {
+ event.preventDefault();
+ buttons[buttons.length - 1].focus();
+ } else if (!event.shiftKey && document.activeElement === buttons[buttons.length - 1]) {
+ event.preventDefault();
+ buttons[0].focus();
+ }
+ });
+ const dialogState = { layer, close };
+ ctx.readerActionDialog = dialogState;
+ (ctx.modal.closest('.ldp-overlay') || ctx.modal).appendChild(layer);
+ requestAnimationFrame(() => {
+ if (layer.isConnected) cancelButton.focus();
+ });
+ return dialogState;
+ }
+
+ function openReaderReportDialog(postNode, ctx) {
+ const post = postNode && (postNode._ldpPostData || {});
+ const postId = +(post?.id || postNode && postNode.dataset.postId || 0);
+ const postNumber = +(post?.post_number || postNode && postNode.dataset.postNumber || 0);
+ if (!postId || !postNumber) return;
+ const postModel = detachedNativePostModel(postNode, ctx);
+ if (!postModel) {
+ showSelectionToast('Discourse 楼层模型尚未就绪');
+ return;
+ }
+ const reportOptions = readerReportOptions(postModel);
+ openReaderReportForm(ctx, {
+ title: `举报 #${postNumber}`,
+ options: reportOptions,
+ requiredMessageError: '通知管理人员时需要填写具体原因',
+ onSubmit: async (actionType, message) => {
+ const selected = reportOptions.find((option) => option.id === actionType);
+ if (!selected || !isFunction(selected.action.act))
+ throw new Error('Discourse 举报接口尚未就绪');
+ if (postNumber === 1) selected.action.flagTopic = postModel;
+ const result = await selected.action.act(postModel, { message });
+ if (!result || result.acted !== true) throw new Error('举报提交失败,请重试');
+ await clearStoredResponseCache({ tags: [`post:${postId}`] });
+ post.can_flag = false;
+ persistReaderPostState(ctx, post);
+ syncAllPostActionControls(ctx);
+ syncTopicActionControls(ctx);
+ return '举报已提交';
+ },
+ });
+ }
+
+ function openReaderAssignDialog(postNode, ctx, targetType) {
+ const post = postNode && (postNode._ldpPostData || {});
+ const isTopic = targetType === 'Topic';
+ const targetId = isTopic ? +ctx.topicId : +(post?.id || postNode && postNode.dataset.postId || 0);
+ const postNumber = +(post?.post_number || postNode && postNode.dataset.postNumber || 0);
+ if (!targetId) return;
+ const currentAssignment = isTopic
+ ? ctx.topicData?.assigned_to_user
+ : post?.assigned_to_user;
+ const currentUsername = currentAssignment?.username || '';
+ openReaderActionDialog(ctx, {
+ title: isTopic ? '指定主题负责人' : `指定 #${postNumber} 负责人`,
+ intro: '输入社区用户名后直接提交,不会离开阅读器。',
+ submitLabel: '确认指定',
+ initialFocus: 'input[name="username"]',
+ bodyHtml: `
+
+ 用户名
+
+
+
+ 备注(选填)
+
+ `,
+ onSubmit: async (formData) => {
+ const username = String(formData.get('username') || '').trim().replace(/^@+/, '');
+ const note = String(formData.get('note') || '').trim();
+ if (!username) throw new Error('请输入要指定的用户名');
+ const assignment = {
+ username,
+ note,
+ targetId,
+ targetType,
+ };
+ const taskActions = lookupDiscourse('service:task-actions');
+ if (!taskActions || !isFunction(taskActions.putAssignment))
+ throw new Error('Discourse 指定任务服务尚未就绪');
+ await taskActions.putAssignment(assignment);
+ await clearStoredResponseCache({
+ tags: [isTopic ? `topic:${targetId}` : `post:${targetId}`],
+ });
+ const assignmentState = { username };
+ if (isTopic && ctx.topicData) {
+ ctx.topicData.assigned_to_user = assignmentState;
+ setCachedTopicData(ctx.topicId, ctx.topicData);
+ }
+ if (!isTopic && post) {
+ post.assigned_to_user = assignmentState;
+ persistReaderPostState(ctx, post);
+ }
+ return `已指定给 @${username}`;
+ },
+ });
+ }
+
+ /* ============ 10. 事件委托 ============ */
+ function bindActions(modal, ctx) {
+ const floorPreview = bindFloorPreview(modal, ctx);
+ const actionScope = createLifecycleScope();
+ const onAction = (target, type, listener, options) => actionScope.listen(target, type, listener, options);
+ ctx.floorPreview = floorPreview;
+ let expandedCodeBlockState = null;
+ const syncCodeBlockToggle = (block, expanded) => {
+ const button = block?.querySelector(':scope > .ldp-code-block-actions [data-reader-code-action="toggle"]');
+ if (!button) return;
+ const lineCount = Math.max(11, Number(block.dataset.readerCodeLines) || 11);
+ button.innerHTML = icon(expanded ? 'chevronUp' : 'chevronDown');
+ setExpanded(button, expanded);
+ setLabel(button, expanded ? '收起至前 10 行' : `展开全部 ${lineCount} 行`);
+ };
+ const collapseReaderCodeBlock = (restorePosition = true) => {
+ const state = expandedCodeBlockState;
+ if (!state) return false;
+ expandedCodeBlockState = null;
+ const { block, floorAnchor, floorTop, scrollTop } = state;
+ if (block?.isConnected) {
+ block.classList.remove('ldp-code-block-expanded');
+ syncCodeBlockToggle(block, false);
+ }
+ if (restorePosition && ctx.scrollRoot) {
+ const previousScrollTop = ctx.scrollRoot.scrollTop;
+ if (floorAnchor?.isConnected)
+ ctx.scrollRoot.scrollTop += floorAnchor.getBoundingClientRect().top - floorTop;
+ else
+ ctx.scrollRoot.scrollTop = scrollTop;
+ if (Math.abs(ctx.scrollRoot.scrollTop - previousScrollTop) >= 0.5)
+ markStreamInternalScroll(ctx, previousScrollTop);
+ }
+ if (block?.isConnected) syncReaderExpandableLayout(block, ctx);
+ return true;
+ };
+ const toggleReaderCodeBlock = (block) => {
+ if (!block?.classList.contains('ldp-code-block-collapsible')) return;
+ if (expandedCodeBlockState?.block === block) {
+ collapseReaderCodeBlock();
+ return;
+ }
+ collapseReaderCodeBlock();
+ const post = block.closest('.ldp-post');
+ const floorAnchor = post?.querySelector(':scope > .ldp-post-head') || post;
+ expandedCodeBlockState = {
+ block,
+ floorAnchor,
+ floorTop: floorAnchor?.getBoundingClientRect().top || 0,
+ scrollTop: ctx.scrollRoot?.scrollTop || 0,
+ };
+ block.classList.add('ldp-code-block-expanded');
+ syncCodeBlockToggle(block, true);
+ syncReaderExpandableLayout(block, ctx);
+ };
+ const userCardEventRoot = ensureReaderPortal() || modal.closest('.ldp-overlay') || modal;
+ let userCardPrefetchTimer = 0;
+ let userCardShowTimer = 0;
+ let userCardHideTimer = 0;
+ const cancelUserCardOpening = () => {
+ clearTimeout(userCardPrefetchTimer);
+ clearTimeout(userCardShowTimer);
+ userCardPrefetchTimer = 0;
+ userCardShowTimer = 0;
+ };
+ let userCardToken = 0;
+ let userCardAnchor = null;
+ let userCardPositionFrame = 0;
+ let userCardAnchorObserver = null;
+ const fallbackUserCard = getUserCardSurface();
+ const userCardFollowPanel = getUserCardFollowPanel();
+ const userCardFollowPreview = getUserCardSurface(true);
+ let userCardFollowPreviewAnchor = null;
+ let userCardFollowPreviewToken = 0;
+ let userCardFollowPreviewShowTimer = 0;
+ let userCardFollowPreviewHoverArmed = true;
+ const userCardNavigationStack = [];
+ const selectionToolbar = makeElement('div');
+ selectionToolbar.className = 'ldp-selection-toolbar ldp-action-surface';
+ selectionToolbar.hidden = true;
+ selectionToolbar.setAttribute('role', 'toolbar');
+ setLabel(selectionToolbar, '引用所选文字');
+ selectionToolbar.innerHTML = `
+
+
`;
+ appendReaderPortalNode(selectionToolbar);
+ const imageQuoteToolbar = makeElement('div');
+ imageQuoteToolbar.className = 'ldp-selection-toolbar ldp-image-quote-toolbar ldp-action-surface';
+ imageQuoteToolbar.hidden = true;
+ imageQuoteToolbar.setAttribute('role', 'toolbar');
+ setLabel(imageQuoteToolbar, '引用图片');
+ imageQuoteToolbar.innerHTML = '
';
+ appendReaderPortalNode(imageQuoteToolbar);
+ let selectedQuote = null;
+ let selectionToolbarFrame = 0;
+ let imageQuoteTarget = null;
+ let imageQuotePositionFrame = 0;
+ let imageQuoteHideTimer = 0;
+ let imageQuoteShowTimer = 0;
+ let imageQuoteCycleTimer = 0;
+ let imageQuotePointerX = 0;
+ let imageQuotePointerY = 0;
+ let imageQuoteAnchorX = 0;
+ let imageQuoteAnchorY = 0;
+ let imageQuotePointerInside = false;
+ let activeActionPost = null;
+ const reactionHoverOpenTimers = new Map();
+ const reactionHoverCloseTimers = new Map();
+ let reactionVisibilityObserver = null;
+ let reactionRegistryHydration = null;
+
+ const clearReactionHoverTimer = (timers, root) => {
+ const timer = timers.get(root);
+ if (!timer) return;
+ clearTimeout(timer);
+ timers.delete(root);
+ };
+ const setReactionPickerExpanded = (root, open) => {
+ clearReactionHoverTimer(reactionHoverOpenTimers, root);
+ clearReactionHoverTimer(reactionHoverCloseTimers, root);
+ if (!root || !root.isConnected) return;
+ const picker = root.querySelector('.ldp-reaction-picker');
+ const trigger = root.querySelector('[data-reaction-picker]');
+ if (picker) picker.hidden = !open;
+ if (trigger) {
+ setExpanded(trigger, !!open);
+ refreshTooltips(trigger);
+ }
+ if (open && root.getClientRects().length) reactionVisibilityObserver?.observe(root);
+ else reactionVisibilityObserver?.unobserve(root);
+ };
+ const hydrateReactionRegistry = (refresh = false) => {
+ if (!refresh && reactionEmojiUrls) return Promise.resolve(reactionEmojiUrls);
+ if (reactionRegistryHydration) return reactionRegistryHydration;
+ reactionRegistryHydration = loadReactionEmojiRegistry({ refresh }).then(() => {
+ if (!reactionEmojiUrls || !modal.isConnected ||
+ (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active'))) return;
+ modal.querySelectorAll('.ldp-post').forEach((postNode) => {
+ const root = postNode.querySelector(':scope > .ldp-reactions');
+ const wasExpanded = !!(root?.querySelector('.ldp-reaction-picker:not([hidden])'));
+ renderPostReactions(postNode, ctx);
+ if (wasExpanded) setReactionPickerExpanded(postNode.querySelector(':scope > .ldp-reactions'), true);
+ });
+ const rail = ctx.topicActionRail;
+ if (rail?._ldpPostData) {
+ const railLike = rail.querySelector('.ldp-like');
+ if (railLike) syncReaderLikeButton(railLike, readerPostRailReactionAction(rail._ldpPostData, ctx));
+ renderPostReactions(rail, ctx, { collectLike: false, railPicker: true });
+ rail.querySelector(':scope > .ldp-reactions > .ldp-reaction-summary')?.remove();
+ }
+ }).finally(() => { reactionRegistryHydration = null; });
+ return reactionRegistryHydration;
+ };
+ hydrateReactionRegistry(!reactionEmojiRegistryNetworkRequested);
+ if (isFunction(IntersectionObserver)) {
+ reactionVisibilityObserver = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) return;
+ const root = entry.target;
+ setReactionPickerExpanded(root, false);
+ });
+ }, { threshold: 0 });
+ }
+ const onReactionPointerOver = (event) => {
+ const root = event.target.closest && event.target.closest('.ldp-reactions');
+ if (!root || !modal.contains(root)) return;
+ clearReactionHoverTimer(reactionHoverCloseTimers, root);
+ const trigger = event.target.closest('[data-reaction-picker]');
+ if (!trigger || (event.relatedTarget && trigger.contains(event.relatedTarget))) return;
+ hydrateReactionRegistry();
+ clearReactionHoverTimer(reactionHoverOpenTimers, root);
+ reactionHoverOpenTimers.set(root, setTimeout(() => {
+ reactionHoverOpenTimers.delete(root);
+ setReactionPickerExpanded(root, true);
+ }, 250));
+ };
+ const onReactionPointerOut = (event) => {
+ const root = event.target.closest && event.target.closest('.ldp-reactions');
+ if (!root || !modal.contains(root)) return;
+ const trigger = event.target.closest('[data-reaction-picker]');
+ if (trigger && (!event.relatedTarget || !trigger.contains(event.relatedTarget))) {
+ clearReactionHoverTimer(reactionHoverOpenTimers, root);
+ }
+ if (event.relatedTarget && root.contains(event.relatedTarget)) return;
+ clearReactionHoverTimer(reactionHoverOpenTimers, root);
+ clearReactionHoverTimer(reactionHoverCloseTimers, root);
+ reactionHoverCloseTimers.set(root, setTimeout(() => {
+ reactionHoverCloseTimers.delete(root);
+ setReactionPickerExpanded(root, false);
+ }, 250));
+ };
+
+ const activatePostActions = (event) => {
+ const post = event.target.closest && event.target.closest('.ldp-post');
+ if (!post || !modal.contains(post)) return;
+ if (activeActionPost !== post) {
+ if (activeActionPost) activeActionPost.classList.remove('ldp-actions-active');
+ activeActionPost = post;
+ activeActionPost.classList.add('ldp-actions-active');
+ }
+ hydratePostContextActions(post, ctx);
+ };
+ const clearActivePostActions = () => {
+ if (activeActionPost) activeActionPost.classList.remove('ldp-actions-active');
+ activeActionPost = null;
+ };
+ onAction(modal, 'focusin', activatePostActions);
+ onAction(modal, 'pointerleave', clearActivePostActions);
+
+ const hideSelectionToolbar = () => {
+ setHidden(selectionToolbar, true);
+ selectedQuote = null;
+ };
+
+ const clearSelectedText = () => {
+ const selection = readerSelection();
+ if (isFunction(selection?.removeAllRanges)) selection.removeAllRanges();
+ };
+
+ const updateSelectionToolbar = () => {
+ const selection = readerSelection();
+ if (!selection || selection.isCollapsed || !selection.rangeCount) return hideSelectionToolbar();
+ const text = String(selection.toString() || '').trim();
+ if (!text) return hideSelectionToolbar();
+ const range = selection.getRangeAt(0);
+ const startNode = range.startContainer.nodeType === Node.ELEMENT_NODE
+ ? range.startContainer : range.startContainer.parentElement;
+ const endNode = range.endContainer.nodeType === Node.ELEMENT_NODE
+ ? range.endContainer : range.endContainer.parentElement;
+ const startContent = startNode?.closest && startNode.closest('.ldp-content');
+ const endContent = endNode?.closest && endNode.closest('.ldp-content');
+ if (!startContent || startContent !== endContent || !modal.contains(startContent)) return hideSelectionToolbar();
+ const post = startContent.closest('.ldp-post');
+ const raw = selectedQuoteRaw(post, ctx, text);
+ if (!post || !raw) return hideSelectionToolbar();
+ const rect = range.getBoundingClientRect();
+ if (!rect || (!rect.width && !rect.height)) return hideSelectionToolbar();
+ selectedQuote = { post, raw };
+ setHidden(selectionToolbar, false);
+ const toolbarRect = selectionToolbar.getBoundingClientRect();
+ const left = Math.max(8, Math.min(
+ rect.right - toolbarRect.width,
+ window.innerWidth - toolbarRect.width - 8,
+ ));
+ const above = rect.top - toolbarRect.height - 8;
+ const top = above >= 8
+ ? above
+ : Math.min(window.innerHeight - toolbarRect.height - 8, rect.bottom + 8);
+ selectionToolbar.style.left = `${Math.round(left)}px`;
+ selectionToolbar.style.top = `${Math.round(Math.max(8, top))}px`;
+ };
+
+ const scheduleSelectionToolbar = () => {
+ if (selectionToolbarFrame) return;
+ selectionToolbarFrame = requestAnimationFrame(() => {
+ selectionToolbarFrame = 0;
+ updateSelectionToolbar();
+ });
+ };
+ const onSelectionPointerDown = (event) => {
+ if (selectionToolbar.contains(event.target) || imageQuoteToolbar.contains(event.target)) event.preventDefault();
+ };
+ const onDocumentPointerDown = (event) => {
+ const path = isFunction(event.composedPath) ? event.composedPath() : [];
+ if (!selectionToolbar.hidden && !path.includes(selectionToolbar) && !selectionToolbar.contains(event.target)) {
+ hideSelectionToolbar();
+ }
+ };
+ const onSelectionAction = async (event) => {
+ const button = event.target.closest('[data-selection-action]');
+ if (!button || !selectedQuote) return;
+ const { post, raw } = selectedQuote;
+ if (button.dataset.selectionAction === 'quote') {
+ await tryOpenNativeReplyComposer(post, ctx, raw);
+ hideSelectionToolbar();
+ clearSelectedText();
+ return;
+ }
+ try {
+ await writeSelectionQuoteToClipboard(raw);
+ showSelectionToast('引用已复制到剪切板');
+ } catch {
+ showSelectionToast('复制失败,请重试');
+ }
+ hideSelectionToolbar();
+ clearSelectedText();
+ };
+
+ const clearImageQuoteCycleTimers = () => {
+ clearTimeout(imageQuoteShowTimer);
+ clearTimeout(imageQuoteCycleTimer);
+ imageQuoteShowTimer = 0;
+ imageQuoteCycleTimer = 0;
+ };
+ const hideImageQuoteToolbar = () => {
+ clearTimeout(imageQuoteHideTimer);
+ clearImageQuoteCycleTimers();
+ imageQuoteHideTimer = 0;
+ imageQuoteTarget = null;
+ imageQuotePointerInside = false;
+ setHidden(imageQuoteToolbar, true);
+ };
+ const closeTransientActions = () => {
+ if (!selectionToolbar.hidden) {
+ hideSelectionToolbar();
+ clearSelectedText();
+ return true;
+ }
+ if (!imageQuoteToolbar.hidden) {
+ hideImageQuoteToolbar();
+ return true;
+ }
+ const reactionPicker = modal.querySelector('.ldp-reaction-picker:not([hidden])');
+ if (!reactionPicker) return false;
+ const root = reactionPicker.closest('.ldp-reactions');
+ setReactionPickerExpanded(root, false);
+ return true;
+ };
+ ctx.closeTransientActions = closeTransientActions;
+ const scheduleHideImageQuoteToolbar = () => {
+ clearTimeout(imageQuoteHideTimer);
+ imageQuoteHideTimer = setTimeout(hideImageQuoteToolbar, 140);
+ };
+ const positionImageQuoteToolbar = () => {
+ imageQuotePositionFrame = 0;
+ if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return hideImageQuoteToolbar();
+ imageQuoteToolbar.hidden = false;
+ const toolbarRect = imageQuoteToolbar.getBoundingClientRect();
+ const gap = 12;
+ const edge = 8;
+ let left = imageQuoteAnchorX + gap;
+ let top = imageQuoteAnchorY + gap;
+ if (left + toolbarRect.width > window.innerWidth - edge) {
+ left = imageQuoteAnchorX - toolbarRect.width - gap;
+ }
+ if (top + toolbarRect.height > window.innerHeight - edge) top = imageQuoteAnchorY - toolbarRect.height - gap;
+ imageQuoteToolbar.style.left = `${Math.round(Math.max(edge, Math.min(left, window.innerWidth - toolbarRect.width - edge)))}px`;
+ imageQuoteToolbar.style.top = `${Math.round(Math.max(edge, Math.min(top, window.innerHeight - toolbarRect.height - edge)))}px`;
+ };
+ const scheduleImageQuoteToolbarPosition = () => {
+ if (imageQuotePositionFrame) return;
+ imageQuotePositionFrame = requestAnimationFrame(positionImageQuoteToolbar);
+ };
+ const startImageQuoteCycle = () => {
+ clearImageQuoteCycleTimers();
+ imageQuoteToolbar.hidden = true;
+ if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return;
+ imageQuoteShowTimer = setTimeout(() => {
+ imageQuoteShowTimer = 0;
+ if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return;
+ imageQuoteAnchorX = imageQuotePointerX;
+ imageQuoteAnchorY = imageQuotePointerY;
+ scheduleImageQuoteToolbarPosition();
+ imageQuoteCycleTimer = setTimeout(() => {
+ imageQuoteCycleTimer = 0;
+ if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) {
+ hideImageQuoteToolbar();
+ return;
+ }
+ startImageQuoteCycle();
+ }, 5000);
+ }, 350);
+ };
+ const updateImageQuotePointer = (event) => {
+ const image = lightboxImageFromEvent(event);
+ if (!image) return;
+ const shouldRestart = imageQuoteTarget !== image || !imageQuotePointerInside;
+ clearTimeout(imageQuoteHideTimer);
+ imageQuoteHideTimer = 0;
+ imageQuoteTarget = image;
+ imageQuotePointerInside = true;
+ imageQuotePointerX = event.clientX;
+ imageQuotePointerY = event.clientY;
+ if (shouldRestart) startImageQuoteCycle();
+ };
+ const onImageQuotePointerUpdate = (event) => {
+ if (!(event.target instanceof Element) || event.target.tagName !== 'IMG') return;
+ updateImageQuotePointer(event);
+ };
+ const onImageQuotePointerOut = (event) => {
+ if (!lightboxImageFromEvent(event)) return;
+ imageQuotePointerInside = false;
+ clearImageQuoteCycleTimers();
+ const related = event.relatedTarget;
+ if (related && (imageQuoteToolbar === related || imageQuoteToolbar.contains(related))) return;
+ scheduleHideImageQuoteToolbar();
+ };
+ const keepImageQuoteToolbarOpen = () => {
+ clearTimeout(imageQuoteHideTimer);
+ imageQuoteHideTimer = 0;
+ imageQuotePointerInside = false;
+ clearImageQuoteCycleTimers();
+ };
+ const onImageQuoteAction = async (event) => {
+ event.preventDefault();
+ const image = imageQuoteTarget;
+ const post = image?.closest('.ldp-post');
+ const imageSet = image?.isConnected ? collectLightboxItems(image, ctx) : null;
+ const item = imageSet && imageSet.items[imageSet.initialIndex];
+ const raw = lightboxImageQuoteRaw(item, ctx);
+ hideImageQuoteToolbar();
+ if (!post || !raw) {
+ showSelectionToast('无法确认图片引用来源');
+ return;
+ }
+ await tryOpenNativeReplyComposer(post, ctx, raw);
+ };
+
+ onAction(modal, 'mouseup', scheduleSelectionToolbar);
+ onAction(modal, 'keyup', scheduleSelectionToolbar);
+ onAction(modal, 'pointerover', (event) => {
+ activatePostActions(event);
+ onReactionPointerOver(event);
+ onImageQuotePointerUpdate(event);
+ });
+ onAction(modal, 'pointermove', onImageQuotePointerUpdate);
+ onAction(modal, 'pointerout', (event) => {
+ onReactionPointerOut(event);
+ onImageQuotePointerOut(event);
+ });
+ onAction(selectionToolbar, 'pointerdown', onSelectionPointerDown);
+ onAction(selectionToolbar, 'click', onSelectionAction);
+ onAction(imageQuoteToolbar, 'pointerdown', onSelectionPointerDown);
+ onAction(imageQuoteToolbar, 'pointerenter', keepImageQuoteToolbarOpen);
+ onAction(imageQuoteToolbar, 'pointerleave', scheduleHideImageQuoteToolbar);
+ onAction(imageQuoteToolbar, 'click', onImageQuoteAction);
+ onAction(document, 'selectionchange', scheduleSelectionToolbar);
+ onAction(document, 'pointerdown', onDocumentPointerDown, true);
+ onAction(ctx.scrollRoot, 'scroll', () => {
+ hideSelectionToolbar();
+ hideImageQuoteToolbar();
+ }, { passive: true });
+
+ const closeUserCardFollowPreview = (options = {}) => {
+ userCardFollowPreviewToken++;
+ userCardFollowPreviewAnchor = null;
+ clearTimeout(userCardFollowPreviewShowTimer);
+ userCardFollowPreviewShowTimer = 0;
+ if (options.suppressHoverUntilExit) userCardFollowPreviewHoverArmed = false;
+ userCardFollowPreviewActionIntent = null;
+ releasePersistentAvatarImages(userCardFollowPreview);
+ userCardFollowPreview.classList.remove('open');
+ delete userCardFollowPreview._ldpUserData;
+ renderUserCardBreadcrumbs();
+ };
+
+ const navigationEntryFromUser = (user) => ({
+ username: String(user?.username || ''),
+ name: String(user && (user.name || user.username) || ''),
+ user,
+ followType: '',
+ followUsers: null,
+ search: '',
+ page: 0,
+ });
+ const ensureUserCardNavigationRoot = () => {
+ const user = fallbackUserCard._ldpUserData;
+ const username = String(user?.username || '');
+ if (!username) return null;
+ if (!userCardNavigationStack.length) userCardNavigationStack.push(navigationEntryFromUser(user));
+ return userCardNavigationStack[userCardNavigationStack.length - 1];
+ };
+ const rememberCurrentUserCardNavigationState = () => {
+ const entry = ensureUserCardNavigationRoot();
+ if (!entry) return;
+ const activeCard = userCardNavigationStack.length > 1 ? userCardFollowPreview : fallbackUserCard;
+ const displayedUser = activeCard._ldpUserData;
+ if (String(displayedUser?.username || '') === entry.username) {
+ entry.user = displayedUser;
+ }
+ entry.name = String(entry.user && (entry.user.name || entry.user.username) || entry.username);
+ if (userCardFollowPanel.hidden || userCardFollowPanel.dataset.followUsername !== entry.username) return;
+ entry.followType = String(userCardFollowPanel.dataset.followType || '');
+ entry.followUsers = Array.isArray(userCardFollowPanel._ldpFollowUsers)
+ ? userCardFollowPanel._ldpFollowUsers
+ : null;
+ entry.search = userCardFollowPanel.querySelector('input[type="search"]')?.value || '';
+ entry.page = Math.max(0, Number(userCardFollowPanel.dataset.followPage) || 0);
+ };
+ const renderUserCardBreadcrumbs = () => {
+ const breadcrumbs = userCardFollowPanel.querySelector('.ldp-user-card-breadcrumbs');
+ if (!breadcrumbs) return;
+ const entries = userCardNavigationStack.map((entry, index) => ({ ...entry, index }));
+ const previewUser = userCardFollowPreview.classList.contains('open') && userCardFollowPreview._ldpUserData;
+ if (previewUser && String(previewUser.username || '') !== String(entries[entries.length - 1]?.username || '')) {
+ entries.push({ ...navigationEntryFromUser(previewUser), index: -1, preview: true });
+ }
+ breadcrumbs.hidden = entries.length < 2;
+ breadcrumbs.innerHTML = entries.map((entry, index) => {
+ const label = entry.name || entry.username;
+ const last = index === entries.length - 1;
+ const crumb = last
+ ? `
${esc(label)}`
+ : `
`;
+ return `${index ? '
›' : ''}${crumb}`;
+ }).join('');
+ breadcrumbs.scrollLeft = breadcrumbs.scrollWidth;
+ };
+ const renderSecondaryUserCard = (user) => {
+ if (!user || !user.username) return false;
+ userCardFollowPreviewToken++;
+ userCardFollowPreviewAnchor = null;
+ clearTimeout(userCardFollowPreviewShowTimer);
+ userCardFollowPreviewShowTimer = 0;
+ releasePersistentAvatarImages(userCardFollowPreview);
+ userCardFollowPreview._ldpUserData = user;
+ userCardFollowPreview.innerHTML = renderFallbackUserCard(
+ user,
+ user.username,
+ 'ready',
+ user._ldp_avatar_preview_url || ''
+ );
+ hydratePersistentAvatarImages(userCardFollowPreview, POST_REQUEST_PRIORITY.userCard);
+ syncUserCardBadgeScrollControls(userCardFollowPreview);
+ requestAnimationFrame(() => userCardFollowPreview.isConnected &&
+ syncUserCardBadgeScrollControls(userCardFollowPreview));
+ userCardFollowPreview.classList.add('open');
+ positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
+ return true;
+ };
+ const clearUserCardFollowPanelState = () => {
+ delete userCardFollowPanel._ldpFollowUsers;
+ delete userCardFollowPanel.dataset.followType;
+ delete userCardFollowPanel.dataset.followUsername;
+ delete userCardFollowPanel.dataset.followPage;
+ const list = userCardFollowPanel.querySelector('.ldp-user-card-follow-list');
+ if (list) {
+ releasePersistentAvatarImages(list);
+ list.replaceChildren();
+ }
+ [fallbackUserCard, userCardFollowPreview].forEach((card) =>
+ card.querySelectorAll('[data-user-card-follow-type]')
+ .forEach((button) => setExpanded(button, false)));
+ };
+ const restoreUserCardNavigationEntry = (index) => {
+ if (!Number.isInteger(index) || index < 0 || index >= userCardNavigationStack.length) return false;
+ rememberCurrentUserCardNavigationState();
+ const entry = userCardNavigationStack[index];
+ userCardNavigationStack.length = index + 1;
+ if (index === 0) closeUserCardFollowPreview();
+ else if (!renderSecondaryUserCard(entry.user)) return false;
+ clearUserCardFollowPanelState();
+ if (entry.followType && Array.isArray(entry.followUsers)) {
+ userCardFollowPanel.hidden = false;
+ userCardFollowPanel.dataset.followType = entry.followType;
+ userCardFollowPanel.dataset.followUsername = entry.username;
+ userCardFollowPanel._ldpFollowUsers = entry.followUsers;
+ userCardFollowPanel.querySelector('.ldp-user-card-follow-title').textContent =
+ entry.followType === 'following' ? '关注的人员' : '被关注的人员';
+ const search = userCardFollowPanel.querySelector('input[type="search"]');
+ if (search) search.value = entry.search || '';
+ userCardFollowPanel.dataset.followPage = String(Math.max(0, Number(entry.page) || 0));
+ const activeCard = index === 0 ? fallbackUserCard : userCardFollowPreview;
+ activeCard.querySelectorAll('[data-user-card-follow-type]').forEach((button) =>
+ setExpanded(button, button.dataset.userCardFollowType === entry.followType));
+ renderUserCardFollowList(userCardFollowPanel);
+ positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
+ if (index > 0) positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
+ } else {
+ userCardFollowPanel.hidden = true;
+ }
+ renderUserCardBreadcrumbs();
+ return true;
+ };
+ const promoteUserCardFollowPreview = () => {
+ const user = userCardFollowPreview._ldpUserData;
+ const username = String(user?.username || '');
+ if (!username) return false;
+ rememberCurrentUserCardNavigationState();
+ const current = userCardNavigationStack[userCardNavigationStack.length - 1];
+ if (!current || current.username !== username) userCardNavigationStack.push(navigationEntryFromUser(user));
+ else current.user = user;
+ clearUserCardFollowPanelState();
+ renderUserCardBreadcrumbs();
+ return true;
+ };
+
+ const closeUserCardFollowPanel = () => {
+ closeUserCardFollowPreview();
+ if (userCardFollowPanel.hidden) return;
+ userCardFollowPanel.hidden = true;
+ userCardFollowPanel.style.removeProperty('max-height');
+ fallbackUserCard.style.removeProperty('max-height');
+ if (userCardNavigationStack.length > 1) userCardNavigationStack.length = 1;
+ clearUserCardFollowPanelState();
+ if (fallbackUserCardOpen() && userCardAnchor) positionFallbackUserCard(fallbackUserCard, userCardAnchor);
+ };
+
+ const closeUserCard = () => {
+ const avatarViewer = readerPortalQuery('.ldp-avatar-viewer');
+ if (avatarViewer?._ldpAnchorCard === fallbackUserCard && isFunction(avatarViewer._ldpClose)) {
+ avatarViewer._ldpClose();
+ }
+ closeUserCardFollowPanel();
+ cancelQueuedUserCardRequests(getUserCardUsername(userCardAnchor), ctx.postRequestScheduler);
+ userCardToken++;
+ userCardAnchor = null;
+ cancelUserCardOpening();
+ clearTimeout(userCardHideTimer);
+ userCardHideTimer = 0;
+ if (userCardPositionFrame) cancelAnimationFrame(userCardPositionFrame);
+ userCardPositionFrame = 0;
+ userCardAnchorObserver?.disconnect();
+ userCardAnchorObserver = null;
+ userCardNavigationStack.length = 0;
+ hideFallbackUserCard();
+ };
+ const closeUserCardLayer = () => {
+ if (userCardFollowPreview.classList.contains('open')) {
+ const activeEntry = userCardNavigationStack[userCardNavigationStack.length - 1];
+ const displayedUsername = String(userCardFollowPreview._ldpUserData?.username || '');
+ if (userCardNavigationStack.length < 2 || displayedUsername !== activeEntry?.username) {
+ if (userCardNavigationStack.length > 1 && activeEntry?.user) {
+ userCardFollowPreviewHoverArmed = false;
+ renderSecondaryUserCard(activeEntry.user);
+ renderUserCardBreadcrumbs();
+ return;
+ }
+ return closeUserCardFollowPreview({ suppressHoverUntilExit: true });
+ }
+ }
+ if (userCardNavigationStack.length > 1) {
+ return restoreUserCardNavigationEntry(userCardNavigationStack.length - 2);
+ }
+ if (!userCardFollowPanel.hidden) return closeUserCardFollowPanel();
+ const notificationMenu = fallbackUserCard.querySelector('.ldp-user-card-notification-menu:not([hidden])');
+ if (!notificationMenu) return closeUserCard();
+ notificationMenu.hidden = true;
+ fallbackUserCard.querySelector('[data-user-card-action="notifications"]')?.setAttribute('aria-expanded', 'false');
+ if (userCardAnchor) positionFallbackUserCard(fallbackUserCard, userCardAnchor);
+ };
+ ctx.closeUserCard = closeUserCard;
+ ctx.closeUserCardLayer = closeUserCardLayer;
+
+ const userCardAnchorIsVisible = (anchor) => {
+ if (!anchor || !anchor.isConnected) return false;
+ const rect = anchor.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0 &&
+ rect.bottom > 0 && rect.top < window.innerHeight &&
+ rect.right > 0 && rect.left < window.innerWidth;
+ };
+ const syncUserCardPosition = () => {
+ userCardPositionFrame = 0;
+ if (!fallbackUserCardOpen() || !userCardAnchor) return;
+ if (!userCardAnchorIsVisible(userCardAnchor)) return closeUserCard();
+ positionFallbackUserCard(fallbackUserCard, userCardAnchor);
+ positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
+ if (userCardFollowPreview.classList.contains('open')) {
+ positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
+ }
+ };
+ const scheduleUserCardPosition = () => {
+ if (!fallbackUserCardOpen() || userCardPositionFrame) return;
+ userCardPositionFrame = requestAnimationFrame(syncUserCardPosition);
+ };
+ const observeUserCardAnchor = (anchor) => {
+ userCardAnchorObserver?.disconnect();
+ userCardAnchorObserver = null;
+ if (!isFunction(IntersectionObserver) || !anchor) return;
+ userCardAnchorObserver = new IntersectionObserver((entries) => {
+ const entry = entries.find((item) => item.target === anchor);
+ if (userCardAnchor !== anchor || !entry) return;
+ if (!entry.isIntersecting) closeUserCard();
+ else scheduleUserCardPosition();
+ }, { threshold: 0 });
+ userCardAnchorObserver.observe(anchor);
+ };
+
+ onAction(document, 'scroll', scheduleUserCardPosition, true);
+ onAction(window, 'scroll', scheduleUserCardPosition, { passive: true });
+ onAction(window, 'resize', scheduleUserCardPosition, { passive: true });
+
+ const scheduleCloseUserCard = (event) => {
+ if (!userCardFollowPanel.hidden) return;
+ const relatedTarget = event?.relatedTarget;
+ const avatarViewer = readerPortalQuery('.ldp-avatar-viewer');
+ if (relatedTarget && (fallbackUserCard.contains(relatedTarget) || userCardFollowPanel.contains(relatedTarget) ||
+ userCardFollowPreview.contains(relatedTarget) || avatarViewer?.contains(relatedTarget))) return;
+ clearTimeout(userCardHideTimer);
+ userCardHideTimer = setTimeout(() => {
+ userCardHideTimer = 0;
+ const currentAvatarViewer = readerPortalQuery('.ldp-avatar-viewer');
+ if (userCardAnchor?.matches(':hover') || fallbackUserCard.matches(':hover') ||
+ userCardFollowPanel.matches(':hover') || userCardFollowPreview.matches(':hover') ||
+ currentAvatarViewer?.matches(':hover')) return;
+ closeUserCard();
+ }, 180);
+ };
+
+ const keepUserCardOpen = () => clearTimeout(userCardHideTimer);
+ const openUserCardMediaViewer = (...args) => {
+ const viewer = openUserAvatarViewer(...args);
+ if (!viewer) return;
+ viewer.addEventListener('mouseenter', keepUserCardOpen);
+ viewer.addEventListener('mouseleave', scheduleCloseUserCard);
+ };
+ onAction(fallbackUserCard, 'mouseenter', keepUserCardOpen);
+ onAction(fallbackUserCard, 'mouseleave', scheduleCloseUserCard);
+ onAction(userCardFollowPanel, 'mouseenter', keepUserCardOpen);
+ onAction(userCardFollowPanel, 'mouseleave', scheduleCloseUserCard);
+ onAction(userCardFollowPreview, 'mouseenter', keepUserCardOpen);
+
+ const onUserCardHostLinkClick = (event) => {
+ const link = event.target.closest && event.target.closest('a[href]');
+ if (!link || (!fallbackUserCard.contains(link) && !userCardFollowPanel.contains(link) &&
+ !userCardFollowPreview.contains(link))) return;
+ openEmbeddedReaderLinkInHost(event, link, ctx.readerWorkspace);
+ };
+ onAction(fallbackUserCard, 'click', onUserCardHostLinkClick, true);
+ onAction(userCardFollowPanel, 'click', onUserCardHostLinkClick, true);
+ onAction(userCardFollowPreview, 'click', onUserCardHostLinkClick, true);
+
+ const onUserCardWheel = (event) => {
+ event.stopPropagation();
+ const badgeList = event.target instanceof Element && event.target.closest('.ldp-user-card-badge-list');
+ if (badgeList && fallbackUserCard.contains(badgeList) && badgeList.scrollWidth > badgeList.clientWidth + 1) {
+ const rawHorizontalDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
+ const horizontalDelta = event.deltaMode === 1
+ ? rawHorizontalDelta * 40
+ : event.deltaMode === 2 ? rawHorizontalDelta * badgeList.clientWidth : rawHorizontalDelta;
+ const maxScrollLeft = badgeList.scrollWidth - badgeList.clientWidth;
+ const canScrollHorizontally = horizontalDelta < 0
+ ? badgeList.scrollLeft > 0
+ : badgeList.scrollLeft < maxScrollLeft - 1;
+ if (horizontalDelta && canScrollHorizontally) {
+ event.preventDefault();
+ badgeList.scrollLeft += horizontalDelta;
+ return;
+ }
+ }
+ containFloatingSurfaceWheel(fallbackUserCard, event);
+ };
+ onAction(fallbackUserCard, 'wheel', onUserCardWheel, { passive: false });
+ const onUserCardFollowPanelWheel = (event) => {
+ const list = userCardFollowPanel.querySelector('.ldp-user-card-follow-list');
+ if (list && !list.contains(event.target) && event.deltaY && list.scrollHeight > list.clientHeight + 1) {
+ const delta = event.deltaMode === 1
+ ? event.deltaY * 40
+ : event.deltaMode === 2 ? event.deltaY * list.clientHeight : event.deltaY;
+ consumeEvent(event);
+ list.scrollTop += delta;
+ return;
+ }
+ containFloatingSurfaceWheel(userCardFollowPanel, event);
+ };
+ onAction(userCardFollowPanel, 'wheel', onUserCardFollowPanelWheel, { passive: false });
+ const onUserCardFollowPreviewWheel = (event) => {
+ event.stopPropagation();
+ containFloatingSurfaceWheel(userCardFollowPreview, event);
+ };
+ onAction(userCardFollowPreview, 'wheel', onUserCardFollowPreviewWheel, { passive: false });
+ const onUserCardBadgeListScroll = (event) => {
+ if (!(event.target instanceof Element) || !event.target.matches('.ldp-user-card-badge-list')) return;
+ syncUserCardBadgeScrollControls(event.currentTarget);
+ };
+ onAction(fallbackUserCard, 'scroll', onUserCardBadgeListScroll, true);
+ onAction(userCardFollowPreview, 'scroll', onUserCardBadgeListScroll, true);
+
+ const setUserCardActionStatus = (card, message, isError = false) => {
+ const status = card?.querySelector('.ldp-user-card-action-status');
+ if (!status) return;
+ status.textContent = message || '';
+ status.classList.toggle('is-error', !!isError);
+ };
+
+ const onUserCardAction = async (event) => {
+ const actionCard = userCardFollowPreview.contains(event.target)
+ ? userCardFollowPreview
+ : fallbackUserCard.contains(event.target) ? fallbackUserCard : null;
+ if (actionCard === userCardFollowPreview) {
+ const directControl = event.target.closest && event.target.closest(
+ '[data-user-card-follow-type],[data-user-card-action],[data-user-card-background],[data-user-card-avatar],[data-user-card-badge-scroll]'
+ );
+ if (directControl && directControl !== userCardFollowPreviewActionIntent) return;
+ if (directControl) userCardFollowPreviewActionIntent = null;
+ }
+ const followClose = event.target.closest && event.target.closest('[data-user-card-follow-close]');
+ if (followClose && userCardFollowPanel.contains(followClose)) {
+ consumeEvent(event);
+ closeUserCardLayer();
+ return;
+ }
+ const breadcrumb = event.target.closest && event.target.closest('[data-user-card-breadcrumb-index]');
+ if (breadcrumb && userCardFollowPanel.contains(breadcrumb)) {
+ consumeEvent(event);
+ restoreUserCardNavigationEntry(Number(breadcrumb.dataset.userCardBreadcrumbIndex));
+ return;
+ }
+ const pageButton = event.target.closest && event.target.closest('[data-user-card-follow-page]');
+ if (pageButton && userCardFollowPanel.contains(pageButton)) {
+ consumeEvent(event);
+ if (pageButton.disabled) return;
+ const currentPage = Math.max(0, Number(userCardFollowPanel.dataset.followPage) || 0);
+ userCardFollowPanel.dataset.followPage = String(pageButton.dataset.userCardFollowPage === 'previous'
+ ? Math.max(0, currentPage - 1)
+ : currentPage + 1);
+ const navigationIndex = Math.max(0, userCardNavigationStack.length - 1);
+ if (navigationIndex === 0) closeUserCardFollowPreview();
+ renderUserCardFollowList(userCardFollowPanel);
+ positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
+ if (navigationIndex > 0) {
+ positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
+ }
+ rememberCurrentUserCardNavigationState();
+ renderUserCardBreadcrumbs();
+ return;
+ }
+ const followButton = event.target.closest && event.target.closest('[data-user-card-follow-type]');
+ if (followButton && actionCard && actionCard.contains(followButton)) {
+ consumeEvent(event);
+ if (followButton.disabled || followButton.dataset.busy === '1') return;
+ clearTimeout(userCardHideTimer);
+ const type = followButton.dataset.userCardFollowType;
+ const username = cleanUsernameValue(followButton.dataset.username || actionCard._ldpUserData?.username);
+ const panel = userCardFollowPanel;
+ const anchor = userCardAnchor;
+ if (!username || !panel || !anchor || !['following', 'followers'].includes(type)) return;
+ cancelUserCardOpening();
+ const returnedToRoot = actionCard === fallbackUserCard && userCardNavigationStack.length > 1;
+ if (actionCard === userCardFollowPreview) {
+ if (!promoteUserCardFollowPreview()) return;
+ } else if (returnedToRoot) {
+ restoreUserCardNavigationEntry(0);
+ }
+ const navigationEntry = ensureUserCardNavigationRoot();
+ if (!panel.hidden && panel.dataset.followType === type && panel.dataset.followUsername === username) {
+ if (returnedToRoot) return;
+ closeUserCardFollowPanel();
+ return;
+ }
+ const notificationMenu = actionCard.querySelector('.ldp-user-card-notification-menu');
+ if (notificationMenu) notificationMenu.hidden = true;
+ actionCard.querySelector('[data-user-card-action="notifications"]')?.setAttribute('aria-expanded', 'false');
+ [fallbackUserCard, userCardFollowPreview].forEach((card) =>
+ card.querySelectorAll('[data-user-card-follow-type]').forEach((button) =>
+ setExpanded(button, card === actionCard && button === followButton)));
+ panel.hidden = false;
+ panel.dataset.followType = type;
+ panel.dataset.followUsername = username;
+ panel.dataset.followPage = '0';
+ delete panel._ldpFollowUsers;
+ if (navigationEntry) {
+ navigationEntry.user = actionCard._ldpUserData || navigationEntry.user;
+ navigationEntry.followType = type;
+ navigationEntry.followUsers = null;
+ navigationEntry.search = '';
+ navigationEntry.page = 0;
+ }
+ renderUserCardBreadcrumbs();
+ panel.querySelector('.ldp-user-card-follow-title').textContent = type === 'following' ? '关注的人员' : '被关注的人员';
+ const search = panel.querySelector('input[type="search"]');
+ const summary = panel.querySelector('.ldp-user-card-follow-summary');
+ const list = panel.querySelector('.ldp-user-card-follow-list');
+ search.value = '';
+ summary.textContent = '正在加载…';
+ releasePersistentAvatarImages(list);
+ list.innerHTML = '
正在加载人员列表…
';
+ positionUserCardFollowPanel(panel, fallbackUserCard);
+ followButton.dataset.busy = '1';
+ try {
+ const users = await fetchUserFollowList(username, type);
+ if (userCardAnchor !== anchor || !fallbackUserCardOpen() || panel.hidden ||
+ panel.dataset.followType !== type || panel.dataset.followUsername !== username) return;
+ panel._ldpFollowUsers = users;
+ renderUserCardFollowList(panel);
+ positionUserCardFollowPanel(panel, fallbackUserCard);
+ if (actionCard === userCardFollowPreview) {
+ positionUserCardFollowPreview(userCardFollowPreview, panel, fallbackUserCard);
+ }
+ rememberCurrentUserCardNavigationState();
+ renderUserCardBreadcrumbs();
+ search.focus({ preventScroll: true });
+ } catch (error) {
+ if (userCardAnchor !== anchor || !fallbackUserCardOpen() || panel.hidden ||
+ panel.dataset.followType !== type || panel.dataset.followUsername !== username) return;
+ summary.textContent = Number(error?.status) === 429 ? '请求受限,请过盾后重试' : '人员列表加载失败';
+ list.innerHTML = `
${esc(error?.message || '请稍后重试')}
`;
+ } finally {
+ if (followButton.isConnected) delete followButton.dataset.busy;
+ }
+ return;
+ }
+ const badgeScrollButton = event.target.closest && event.target.closest('[data-user-card-badge-scroll]');
+ if (badgeScrollButton && actionCard && actionCard.contains(badgeScrollButton)) {
+ consumeEvent(event);
+ if (badgeScrollButton.disabled) return;
+ clearTimeout(userCardHideTimer);
+ const list = badgeScrollButton.closest('.ldp-user-card-badge-strip')
+ ?.querySelector('.ldp-user-card-badge-list');
+ if (!list) return;
+ const direction = Number(badgeScrollButton.dataset.userCardBadgeScroll) || 1;
+ list.scrollBy({ left: direction * Math.max(96, list.clientWidth * .78), behavior: 'smooth' });
+ return;
+ }
+ const backgroundButton = event.target.closest && event.target.closest('[data-user-card-background]');
+ if (backgroundButton && actionCard && actionCard.contains(backgroundButton)) {
+ consumeEvent(event);
+ clearTimeout(userCardHideTimer);
+ const source = String(backgroundButton.dataset.backgroundUrl || '').trim();
+ const username = String(actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim();
+ openUserCardMediaViewer(
+ source,
+ username,
+ actionCard,
+ '',
+ actionCard === fallbackUserCard ? closeUserCard : closeUserCardFollowPreview,
+ null,
+ { kind: 'background', source }
+ );
+ return;
+ }
+ const avatarButton = event.target.closest && event.target.closest('[data-user-card-avatar]');
+ if (avatarButton && actionCard && actionCard.contains(avatarButton)) {
+ consumeEvent(event);
+ clearTimeout(userCardHideTimer);
+ const template = avatarButton.dataset.avatarTemplate || actionCard._ldpUserData?.avatar_template || '';
+ const username = avatarButton.dataset.avatarUsername || getUserCardUsername(userCardAnchor) || '';
+ const previewImage = avatarButton.querySelector('img');
+ const previewSource = previewImage && (previewImage.currentSrc || previewImage.src) || '';
+ openUserCardMediaViewer(
+ template,
+ username,
+ actionCard,
+ previewSource,
+ actionCard === fallbackUserCard ? closeUserCard : closeUserCardFollowPreview,
+ actionCard._ldpUserData
+ );
+ return;
+ }
+ const notificationOption = event.target.closest && event.target.closest('[data-user-notification-level]');
+ if (notificationOption && actionCard && actionCard.contains(notificationOption)) {
+ consumeEvent(event);
+ if (notificationOption.disabled || notificationOption.dataset.busy === '1') return;
+ const level = notificationOption.dataset.userNotificationLevel;
+ const username = String(actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim();
+ const user = actionCard._ldpUserData || { username };
+ const anchor = userCardAnchor;
+ const isCurrent = () => userCardAnchor === anchor && fallbackUserCardOpen() && actionCard.classList.contains('open') &&
+ String(actionCard._ldpUserData?.username || '') === username;
+ if (!username || !anchor || !Object.hasOwn(USER_NOTIFICATION_LEVEL_LABELS, level)) return;
+ clearTimeout(userCardHideTimer);
+ if (level === 'ignore') {
+ if (openReaderIgnoreDurationDialog(ctx, username, user)) closeUserCard();
+ else setUserCardActionStatus(actionCard, '当前无法打开忽略期限选择', true);
+ return;
+ }
+ const menu = notificationOption.closest('.ldp-user-card-notification-menu');
+ const options = menu ? [...menu.querySelectorAll('[data-user-notification-level]')] : [];
+ options.forEach((option) => {
+ option.dataset.busy = '1';
+ option.disabled = true;
+ });
+ setUserCardActionStatus(actionCard, `正在设为${USER_NOTIFICATION_LEVEL_LABELS[level]}…`);
+ try {
+ await saveReaderUserNotificationLevel(username, user, level);
+ if (!isCurrent()) return;
+ options.forEach((option) => {
+ const active = option.dataset.userNotificationLevel === level;
+ option.classList.toggle('is-active', active);
+ option.setAttribute('aria-checked', String(active));
+ });
+ const trigger = actionCard.querySelector('[data-user-card-action="notifications"]');
+ if (trigger) {
+ const label = `消息设置:${USER_NOTIFICATION_LEVEL_LABELS[level]}`;
+ trigger.classList.toggle('is-active', level !== 'normal');
+ trigger.dataset.tooltip = label;
+ setLabel(trigger, label);
+ setExpanded(trigger, false);
+ }
+ if (menu) menu.hidden = true;
+ setUserCardActionStatus(actionCard, `已设为${USER_NOTIFICATION_LEVEL_LABELS[level]}`);
+ positionFallbackUserCardNotificationMenu(actionCard);
+ } catch (error) {
+ if (isCurrent()) {
+ setUserCardActionStatus(actionCard, error?.message ? error.message : '消息设置保存失败,请重试', true);
+ }
+ } finally {
+ if (isCurrent()) {
+ options.forEach((option) => {
+ delete option.dataset.busy;
+ option.disabled = false;
+ });
+ }
+ }
+ return;
+ }
+ const button = event.target.closest && event.target.closest('[data-user-card-action]');
+ if (!button || !actionCard || !actionCard.contains(button) || button.disabled || button.dataset.busy === '1') return;
+ consumeEvent(event);
+ const action = button.dataset.userCardAction;
+ const username = String(button.dataset.username || actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim();
+ const anchor = userCardAnchor;
+ if (!action || !username || !anchor) return;
+ if (actionCard === userCardFollowPreview) {
+ if (!promoteUserCardFollowPreview()) return;
+ userCardFollowPanel.hidden = true;
+ } else if (userCardNavigationStack.length > 1) {
+ restoreUserCardNavigationEntry(0);
+ }
+ clearTimeout(userCardHideTimer);
+ if (action === 'notifications') {
+ const menu = actionCard.querySelector('.ldp-user-card-notification-menu');
+ if (!menu) return;
+ const opening = menu.hidden;
+ menu.hidden = !opening;
+ setExpanded(button, opening);
+ setUserCardActionStatus(actionCard, '');
+ positionFallbackUserCardNotificationMenu(actionCard);
+ return;
+ }
+ button.dataset.busy = '1';
+ button.setAttribute('aria-busy', 'true');
+ button.disabled = true;
+ setUserCardActionStatus(actionCard, '正在打开…');
+ const isCurrent = () => userCardAnchor === anchor && fallbackUserCardOpen() && actionCard.classList.contains('open') &&
+ String(actionCard._ldpUserData?.username || '') === username;
+ const outcome = await runDirectUserCardAction(
+ action,
+ username,
+ actionCard._ldpUserData || { username },
+ ctx
+ );
+ if (outcome.ok) {
+ if (outcome.keepOpen && button.isConnected && isCurrent()) {
+ delete button.dataset.busy;
+ button.removeAttribute('aria-busy');
+ button.disabled = false;
+ if (action === 'follow') {
+ const label = outcome.followed ? '取消关注' : '关注';
+ button.classList.toggle('is-active', !!outcome.followed);
+ button.dataset.tooltip = label;
+ setLabel(button, label);
+ setUserCardActionStatus(actionCard, outcome.followed ? '已关注' : '已取消关注');
+ const followerCount = Number(actionCard._ldpUserData?.total_followers);
+ const followerValue = actionCard.querySelector('[data-user-card-follow-type="followers"] strong');
+ if (followerValue && Number.isFinite(followerCount)) followerValue.textContent = followerCount.toLocaleString('zh-CN');
+ }
+ return;
+ }
+ closeUserCard();
+ return;
+ }
+ if (!button.isConnected || !isCurrent()) return;
+ delete button.dataset.busy;
+ button.removeAttribute('aria-busy');
+ button.disabled = false;
+ const label = button.getAttribute('aria-label') || '该功能';
+ const failureMessages = {
+ 'route-unavailable': `未能打开“${label}”页面,请重试`,
+ 'endorsement-unavailable': '当前页面暂时无法打开认可类别选择',
+ 'follow-unavailable': '当前站点未提供关注能力',
+ 'follow-not-allowed': '该用户当前不允许被关注',
+ 'action-failed': `执行“${label}”时发生错误,请重试`,
+ };
+ const failureMessage = outcome.reason === 'action-failed' && outcome.error && outcome.error.message
+ ? outcome.error.message
+ : (failureMessages[outcome.reason] || `${label}当前不可用`);
+ setUserCardActionStatus(actionCard, failureMessage, true);
+ };
+ onAction(fallbackUserCard, 'click', onUserCardAction);
+ onAction(userCardFollowPanel, 'click', onUserCardAction);
+ onAction(userCardFollowPreview, 'click', onUserCardAction);
+ let userCardFollowPreviewActionIntent = null;
+ const userCardFollowPreviewActionControl = (target) => target?.closest && target.closest(
+ '[data-user-card-follow-type],[data-user-card-action],[data-user-card-background],[data-user-card-avatar],[data-user-card-badge-scroll]'
+ );
+ const armUserCardFollowPreviewAction = (event) => {
+ const control = userCardFollowPreviewActionControl(event.target);
+ userCardFollowPreviewActionIntent = control && userCardFollowPreview.contains(control) ? control : null;
+ };
+ const armUserCardFollowPreviewKeyboardAction = (event) => {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ armUserCardFollowPreviewAction(event);
+ };
+ onAction(userCardFollowPreview, 'pointerdown', armUserCardFollowPreviewAction);
+ onAction(userCardFollowPreview, 'pointercancel', () => {
+ userCardFollowPreviewActionIntent = null;
+ });
+ onAction(userCardFollowPreview, 'keydown', armUserCardFollowPreviewKeyboardAction);
+ const onUserCardFollowSearch = (event) => {
+ if (!(event.target instanceof Element) || !event.target.matches('.ldp-user-card-follow-search input')) return;
+ userCardFollowPanel.dataset.followPage = '0';
+ renderUserCardFollowList(userCardFollowPanel);
+ positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
+ };
+ onAction(userCardFollowPanel, 'input', onUserCardFollowSearch);
+
+ const openUserCardFollowPreview = (link) => {
+ const username = getUserCardUsername(link);
+ if (!username || userCardFollowPanel.hidden || !userCardFollowPreviewHoverArmed) return;
+ clearTimeout(userCardHideTimer);
+ clearTimeout(userCardFollowPreviewShowTimer);
+ userCardFollowPreviewShowTimer = 0;
+ if (getUserCardUsername(userCardFollowPreviewAnchor) === username &&
+ userCardFollowPreview.classList.contains('open')) {
+ userCardFollowPreviewAnchor = link;
+ positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
+ renderUserCardBreadcrumbs();
+ return;
+ }
+ userCardFollowPreviewAnchor = link;
+ const token = ++userCardFollowPreviewToken;
+ userCardFollowPreviewShowTimer = setTimeout(() => {
+ userCardFollowPreviewShowTimer = 0;
+ void showFallbackUserCard(link, () => token === userCardFollowPreviewToken &&
+ userCardFollowPreviewAnchor === link && !userCardFollowPanel.hidden && fallbackUserCardOpen(),
+ renderUserCardBreadcrumbs, {
+ card: userCardFollowPreview,
+ positionCard: (preview) => positionUserCardFollowPreview(preview, userCardFollowPanel, fallbackUserCard),
+ });
+ }, USER_CARD_SHOW_DELAY);
+ };
+
+ const onUserCardMouseOver = (event) => {
+ const link = eventPathClosest(event, USER_CARD_LINK_SEL);
+ if (!link) return;
+ const from = event.relatedTarget;
+ if (from && typeof from.nodeType === 'number' && link.contains(from)) return;
+ if (userCardFollowPanel.contains(link)) {
+ openUserCardFollowPreview(link);
+ return;
+ }
+ if (!userCardFollowPanel.hidden) {
+ cancelUserCardOpening();
+ return;
+ }
+ const previousUsername = String(fallbackUserCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '');
+ const nextUsername = getUserCardUsername(link);
+ if (previousUsername && previousUsername !== nextUsername) {
+ if (!userCardFollowPanel.contains(link)) closeUserCardFollowPanel();
+ cancelQueuedUserCardRequests(previousUsername, ctx.postRequestScheduler);
+ }
+ clearTimeout(userCardHideTimer);
+ userCardHideTimer = 0;
+ cancelUserCardOpening();
+ if (previousUsername === nextUsername && fallbackUserCardOpen()) {
+ userCardAnchor = link;
+ observeUserCardAnchor(link);
+ positionFallbackUserCard(fallbackUserCard, link);
+ return;
+ }
+ userCardAnchor = link;
+ observeUserCardAnchor(link);
+ const token = ++userCardToken;
+ userCardPrefetchTimer = setTimeout(() => {
+ userCardPrefetchTimer = 0;
+ if (token !== userCardToken || userCardAnchor !== link) return;
+ void fetchUserCard(nextUsername).catch(() => {});
+ }, USER_CARD_PREFETCH_DELAY);
+ userCardShowTimer = setTimeout(() => {
+ userCardShowTimer = 0;
+ void showFallbackUserCard(link, () => token === userCardToken &&
+ userCardAnchor === link && userCardAnchorIsVisible(link), (user) => {
+ userCardNavigationStack.length = 0;
+ if (user?.username) userCardNavigationStack.push(navigationEntryFromUser(user));
+ renderUserCardBreadcrumbs();
+ });
+ }, USER_CARD_SHOW_DELAY);
+ };
+
+ const onUserCardMouseOut = (event) => {
+ const link = eventPathClosest(event, USER_CARD_LINK_SEL);
+ if (!link) return;
+ const to = event.relatedTarget;
+ if (userCardFollowPanel.contains(link)) {
+ userCardFollowPreviewHoverArmed = true;
+ const nextLink = isFunction(to?.closest) ? to.closest(USER_CARD_LINK_SEL) : null;
+ if (to && typeof to.nodeType === 'number' &&
+ (link.contains(to) || userCardFollowPreview.contains(to) ||
+ (nextLink && userCardFollowPanel.contains(nextLink)))) return;
+ clearTimeout(userCardFollowPreviewShowTimer);
+ userCardFollowPreviewShowTimer = 0;
+ return;
+ }
+ if (to && typeof to.nodeType === 'number' &&
+ (link.contains(to) || fallbackUserCard.contains(to) || userCardFollowPanel.contains(to) ||
+ userCardFollowPreview.contains(to))) return;
+ cancelUserCardOpening();
+ const nextLink = isFunction(to?.closest) ? to.closest(USER_CARD_LINK_SEL) : null;
+ if (nextLink && getUserCardUsername(nextLink) === getUserCardUsername(link)) {
+ return;
+ }
+ scheduleCloseUserCard();
+ };
+ const onHostUserCardMouseOver = (event) => {
+ const link = eventPathClosest(event, USER_CARD_LINK_SEL);
+ if (!link || userCardEventRoot.contains(link)) return;
+ onUserCardMouseOver(event);
+ };
+ const onHostUserCardMouseOut = (event) => {
+ const link = eventPathClosest(event, USER_CARD_LINK_SEL);
+ if (!link || userCardEventRoot.contains(link)) return;
+ onUserCardMouseOut(event);
+ };
+ onAction(userCardEventRoot, 'mouseover', onUserCardMouseOver, true);
+ onAction(userCardEventRoot, 'mouseout', onUserCardMouseOut, true);
+ onAction(document, 'mouseover', onHostUserCardMouseOver, true);
+ onAction(document, 'mouseout', onHostUserCardMouseOut, true);
+
+ const onTopicNotificationChange = async (event) => {
+ const select = event.target.closest('.ldp-topic-notification-select');
+ if (!select || !modal.contains(select)) return;
+ const nextLevel = +select.value;
+ if (!Number.isInteger(nextLevel) || nextLevel < 0 || nextLevel > 3) return;
+ const previousLevel = Number.isInteger(ctx.topicNotificationLevel)
+ ? ctx.topicNotificationLevel
+ : topicNotificationLevel(ctx.topicData);
+ if (nextLevel === previousLevel) return;
+ ctx.topicNotificationLevel = nextLevel;
+ syncTopicActionControls(ctx, { notificationBusy: true });
+ try {
+ const topicModule = lookupDiscourseModule('discourse/models/topic');
+ const topicDetailsModule = lookupDiscourseModule('discourse/models/topic-details');
+ const Topic = topicModule?.default;
+ const TopicDetails = topicDetailsModule?.default;
+ if (!Topic || !isFunction(Topic.create) ||
+ !TopicDetails || !isFunction(TopicDetails.create))
+ throw new Error('Discourse 主题通知接口尚未就绪');
+ const topicModel = Topic.create({
+ ...(ctx.topicData || {}),
+ id: Number(ctx.topicId),
+ });
+ const detailsModel = TopicDetails.create({
+ ...(ctx.topicData?.details || {}),
+ topic: topicModel,
+ notification_level: previousLevel,
+ });
+ await detailsModel.updateNotifications(nextLevel);
+ await clearStoredResponseCache({ tags: [`topic:${ctx.topicId}`] });
+ if (ctx.topicData) {
+ ctx.topicData.notification_level = nextLevel;
+ if (ctx.topicData.details) ctx.topicData.details.notification_level = nextLevel;
+ setCachedTopicData(ctx.topicId, ctx.topicData);
+ }
+ } catch (error) {
+ ctx.topicNotificationLevel = previousLevel;
+ alert('通知设置失败:' + error.message);
+ } finally {
+ syncTopicActionControls(ctx);
+ }
+ };
+ onAction(modal, 'change', onTopicNotificationChange);
+
+ const onReaderPollChange = async (event) => {
+ const input = event.target.closest('.ldp-reader-poll input[data-poll-option]');
+ if (!input || !modal.contains(input) || input.disabled) return;
+ const container = input.closest('.ldp-reader-poll');
+ const post = input.closest('.ldp-post');
+ if (!container || !post) return;
+ const name = container.dataset.ldpPollName || 'poll';
+ const postData = post._ldpPostData || {};
+ const poll = (postData.polls || []).find((item) => pollName(item) === name);
+ if (!poll) return;
+ if (input.type === 'radio') {
+ await updateReaderPollVote(post, ctx, name, [input.value]);
+ return;
+ }
+ const selected = [...container.querySelectorAll('input[data-poll-option]:checked')].map((item) => item.value);
+ const { min, max } = pollChoiceLimits(poll);
+ const submit = container.querySelector('[data-poll-action="submit"]');
+ if (submit) {
+ submit.disabled = selected.length < min || selected.length > max;
+ submit.textContent = pollSavedVotes(postData, poll).length ? '更新投票' : '提交投票';
+ }
+ };
+ onAction(modal, 'change', onReaderPollChange);
+
+ const onPostVotingCommentSubmit = async (event) => {
+ const form = event.target.closest('.ldp-pv-comment-form');
+ if (!form || !modal.contains(form)) return;
+ event.preventDefault();
+ const post = form.closest('.ldp-post');
+ const input = form.querySelector('.ldp-pv-comment-input');
+ const raw = String(input?.value || '').trim();
+ if (!post || !raw) return;
+ const postId = post.dataset.postId;
+ await runReaderActionOnce(ctx, `post:${postId}:pv-comment-create`, async () => {
+ setReaderPostControlsBusy(postId, ctx, '.ldp-pv-comment-form input,.ldp-pv-comment-form button', true);
+ try {
+ const data = await apiSend(`${BASE}/post_voting/comments`, 'POST', { post_id: postId, raw });
+ const existing = readerPostVotingComments(postId, ctx);
+ const comments = new Map(existing.map((comment) => [String(comment.id), comment]));
+ const comment = data.comment || data;
+ if (comment?.id) comments.set(String(comment.id), comment);
+ readerPostCopies(postId, ctx).forEach((copy) => {
+ const root = copy.querySelector(':scope > .ldp-pv-comments');
+ if (root) root.dataset.expanded = '1';
+ });
+ syncPostVotingComments(postId, ctx, [...comments.values()]);
+ } catch (error) {
+ alert('发送评论失败:' + error.message);
+ } finally {
+ setReaderPostControlsBusy(postId, ctx, '.ldp-pv-comment-form input,.ldp-pv-comment-form button', false);
+ }
+ });
+ };
+ onAction(modal, 'submit', onPostVotingCommentSubmit);
+
+ const showReaderAvatarPreview = (avatarPreview) => {
+ const post = avatarPreview.closest('.ldp-post');
+ const postData = post?._ldpPostData || {};
+ const username = String(postData.username || avatarPreview.dataset.userCard || '').trim();
+ const avatarImage = avatarPreview.querySelector('.ldp-avatar');
+ const template = String(postData.avatar_template || avatarImage?.dataset.ldpAvatarOriginalTemplate ||
+ avatarImage?.dataset.ldpAvatarTemplate || '').trim();
+ const previewSource = avatarImage && (avatarImage.currentSrc || avatarImage.src) || '';
+ const openPreviewFromCard = (user) => {
+ if (!fallbackUserCardOpen() || getUserCardUsername(userCardAnchor) !== username) return;
+ openUserCardMediaViewer(
+ template,
+ username,
+ fallbackUserCard,
+ previewSource,
+ closeUserCard,
+ user || postData
+ );
+ };
+ clearTimeout(userCardHideTimer);
+ clearTimeout(userCardShowTimer);
+ userCardShowTimer = 0;
+ const displayedUser = fallbackUserCard._ldpUserData;
+ const cardShownForUser = fallbackUserCardOpen() && String(displayedUser?.username || '') === username;
+ if (cardShownForUser) {
+ openPreviewFromCard(displayedUser);
+ return;
+ }
+ userCardAnchor = avatarPreview;
+ observeUserCardAnchor(avatarPreview);
+ const token = ++userCardToken;
+ void showFallbackUserCard(
+ avatarPreview,
+ () => token === userCardToken && userCardAnchor === avatarPreview && userCardAnchorIsVisible(avatarPreview),
+ (user) => {
+ userCardNavigationStack.length = 0;
+ if (user?.username) userCardNavigationStack.push(navigationEntryFromUser(user));
+ renderUserCardBreadcrumbs();
+ openPreviewFromCard(user);
+ }
+ );
+ };
+ const onPortalAvatarPreviewClick = (event) => {
+ const avatarPreview = eventPathClosest(event, '[data-user-avatar-preview]');
+ if (!avatarPreview || modal.contains(avatarPreview)) return;
+ consumeEvent(event);
+ showReaderAvatarPreview(avatarPreview);
+ };
+ onAction(userCardEventRoot, 'click', onPortalAvatarPreviewClick, true);
+
+ const preserveOnlyOpPostExpansion = (post) => {
+ const preserve = ctx.onlyOp && !post._ldpPostData?.hidden &&
+ String(post.dataset.username || '') === String(ctx.op || '');
+ if (preserve) keepOnlyOpPostExpanded(post, ctx);
+ return preserve;
+ };
+ const readerActionHandlers = {
+ report: (post) => openReaderReportDialog(post, ctx),
+ 'assign-post': (post) => openReaderAssignDialog(post, ctx, 'Post'),
+ 'assign-topic': (post) => openReaderAssignDialog(post, ctx, 'Topic'),
+ };
+ const postActionHandlers = {
+ share: (post) => shareReaderLink(ctx, post),
+ bookmark: (post) => toggleReaderBookmark(post, ctx, 'Post'),
+ edit: (post, postId) => runReaderActionOnce(
+ ctx,
+ `post:${postId}:edit-composer`,
+ () => tryOpenNativeEditComposer(post, ctx)
+ ),
+ };
+ const topicActionHandlers = {
+ share: () => shareReaderLink(ctx),
+ bookmark: () => isFunction(ctx.toggleTopicBookmark) ? ctx.toggleTopicBookmark() : undefined,
+ };
+ const onModalClick = async (e) => {
+ if (!e.target.closest('.ldp-hidden-reply-marker')) modal.querySelectorAll('.ldp-hidden-reply-list:not([hidden])').forEach((list) => {
+ list.hidden = true; setExpanded(list.previousElementSibling, 'false');
+ });
+ const clickedCodeBlock = eventPathClosest(e, '.ldp-code-block');
+ if (!clickedCodeBlock && !eventPathClosest(e, '.ldp-code-preview-layer'))
+ collapseReaderCodeBlock();
+ const imageCandidate = eventPathClosest(e, '.ldp-content img') ||
+ eventPathClosest(e, '.ldp-content a.lightbox')?.querySelector('img');
+ if (imageCandidate && !reactionEmojiImageSources) {
+ consumeEvent(e);
+ await hydrateReactionRegistry();
+ }
+ const quotedImg = lightboxImageFromEventIn(
+ e, '.ldp-content aside.ldp-post-quote > blockquote', isLightboxQuotedImage
+ );
+ if (quotedImg) {
+ consumeEvent(e);
+ hideImageQuoteToolbar();
+ await openQuotedImageLightbox(quotedImg, ctx);
+ return;
+ }
+
+ const img = lightboxImageFromEvent(e);
+ if (img) {
+ consumeEvent(e);
+ hideImageQuoteToolbar();
+ openLightbox(img, ctx);
+ return;
+ }
+
+ const avatarPreview = eventPathClosest(e, '[data-user-avatar-preview]');
+ if (avatarPreview) {
+ consumeEvent(e);
+ showReaderAvatarPreview(avatarPreview);
+ return;
+ }
+
+ const codeAction = eventPathClosest(e, '[data-reader-code-action]');
+ if (codeAction) {
+ consumeEvent(e);
+ const block = codeAction.closest('.ldp-code-block');
+ const pre = block?.querySelector(':scope > pre');
+ if (codeAction.dataset.readerCodeAction === 'copy') await copyReaderCode(pre, codeAction);
+ else if (codeAction.dataset.readerCodeAction === 'preview') openReaderCodePreview(pre, modal);
+ else toggleReaderCodeBlock(block);
+ return;
+ }
+
+ const hostLink = eventPathClosest(e, 'a[href]');
+ if (openEmbeddedReaderLinkInHost(e, hostLink, ctx.readerWorkspace)) return;
+
+ const githubOnebox = eventPathClosest(
+ e,
+ '.ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"])'
+ );
+ if (githubOnebox) {
+ const selection = window.getSelection && window.getSelection();
+ if (selection && !selection.isCollapsed && String(selection).trim()) return;
+ const targetLink = hostLink || githubOnebox.querySelector('.onebox-body h3 a[href],header.source a[href],a[href]');
+ const targetUrl = String(targetLink?.href || githubOnebox.dataset.oneboxSrc || '').trim();
+ if (/^https?:\/\//i.test(targetUrl)) {
+ consumeEvent(e);
+ const openInCurrentTab = targetLink?.target === '_self' && !e.ctrlKey && !e.metaKey && !e.shiftKey;
+ if (openInCurrentTab) window.location.assign(targetUrl);
+ else window.open(targetUrl, '_blank', 'noopener');
+ return;
+ }
+ }
+
+ const quoteAction = e.target.closest('.ldp-quote-toggle,.ldp-quote-collapse-bottom');
+ if (quoteAction) {
+ e.preventDefault();
+ const quote = quoteAction.closest('.ldp-post-quote');
+ const quoteBody = quote?.querySelector(':scope > blockquote');
+ const quoteToggle = quote?.querySelector(':scope > .ldp-quote-title .ldp-quote-toggle');
+ if (!quote || !quoteBody || !quoteToggle) return;
+ if (quote.dataset.ldpQuoteExpanded === '1') {
+ preserveReaderQuotePosition(quote, ctx, () => {
+ quote.querySelector(':scope > .ldp-quote-collapse-bottom')?.remove();
+ setPostCookedContent(quoteBody, quote._ldpQuoteExcerptHtml || '');
+ quote.dataset.ldpQuoteExpanded = '0';
+ quote.classList.remove('ldp-quote-expanded');
+ setExpanded(quoteToggle, false);
+ setLabel(quoteToggle, '展开完整引用');
+ });
+ syncReaderQuoteLayout(quote, ctx);
+ return;
+ }
+ quoteToggle.disabled = true;
+ quoteToggle.setAttribute('aria-busy', 'true');
+ try {
+ const fullPost = await loadReaderQuotedPost(quote, ctx);
+ if (!fullPost || fullPost.cooked == null) throw new Error('quoted post unavailable');
+ preserveReaderQuotePosition(quote, ctx, () => {
+ setPostCookedContent(quoteBody, fullPost.cooked);
+ quote.dataset.ldpQuoteExpanded = '1';
+ quote.classList.add('ldp-quote-expanded');
+ setExpanded(quoteToggle, true);
+ setLabel(quoteToggle, '恢复引用摘录');
+ highlightReaderExpandedQuote(quote);
+ const bottomCollapse = makeElement('button');
+ bottomCollapse.type = 'button';
+ bottomCollapse.className = 'ldp-quote-collapse-bottom';
+ setLabel(bottomCollapse, '恢复引用摘录');
+ bottomCollapse.innerHTML = icon('chevronUp');
+ quote.append(bottomCollapse);
+ });
+ syncReaderQuoteLayout(quote, ctx);
+ } catch {
+ setLabel(quoteToggle, '完整引用加载失败,点击重试');
+ } finally {
+ quoteToggle.disabled = false;
+ quoteToggle.removeAttribute('aria-busy');
+ }
+ return;
+ }
+
+ const topicVote = e.target.closest('[data-topic-vote]');
+ if (topicVote) {
+ e.preventDefault();
+ if (!hasDiscourseCapability('topicVoting', { topic: ctx.topicData })) return;
+ if (!ME_USERNAME) { alert('登录后才能为主题投票。'); return; }
+ await runReaderActionOnce(ctx, `topic:${ctx.topicId}:vote`, async () => {
+ const voted = !!(ctx.topicData?.user_voted);
+ modal.querySelectorAll('[data-topic-vote]').forEach((button) => { button.disabled = true; });
+ try {
+ const data = await apiSend(`${BASE}/voting/${voted ? 'unvote' : 'vote'}`, 'POST', { topic_id: ctx.topicId });
+ ctx.topicData.user_voted = !voted;
+ ctx.topicData.vote_count = Math.max(0, +(data.vote_count ?? data.votes ?? ctx.topicData.vote_count) || 0);
+ if (data.can_vote != null) ctx.topicData.can_vote = data.can_vote;
+ setCachedTopicData(ctx.topicId, ctx.topicData);
+ if (ctx.renderTopicShell) ctx.renderTopicShell(ctx.topicData);
+ } catch (error) {
+ alert('主题投票失败:' + error.message);
+ } finally {
+ modal.querySelectorAll('[data-topic-vote]').forEach((button) => {
+ button.disabled = ctx.topicData && ctx.topicData.can_vote === false && !ctx.topicData.user_voted;
+ });
+ }
+ });
+ return;
+ }
+
+ const nestedRailToggle = e.target.closest('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle');
+ if (nestedRailToggle) {
+ e.preventDefault();
+ const post = nestedRailToggle.closest('.ldp-post');
+ if (preserveOnlyOpPostExpansion(post)) return;
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const parentContent = post.querySelector(':scope > .ldp-content');
+ const parentReactions = post.querySelector(':scope > .ldp-reactions');
+ const parentBodyEnd = parentReactions && parentReactions.getClientRects().length
+ ? parentReactions
+ : (parentContent || post);
+ const expandedContentRect = (parentContent || parentBodyEnd).getBoundingClientRect();
+ const expandedBodyEndRect = parentBodyEnd.getBoundingClientRect();
+ const parentBodyEndVisible = expandedBodyEndRect.bottom >= rootRect.top && expandedBodyEndRect.bottom <= rootRect.bottom;
+ setChildRepliesCollapsed(post, true);
+ const collapsedContentRect = (parentContent || post).getBoundingClientRect();
+ if (parentBodyEndVisible) {
+ const topOffset = collapsedContentRect.top - expandedContentRect.top;
+ if (Math.abs(topOffset) >= 1) {
+ const previousScrollTop = ctx.scrollRoot.scrollTop;
+ ctx.scrollRoot.scrollTop += topOffset;
+ markStreamInternalScroll(ctx, previousScrollTop);
+ scheduleStreamWindowSync(ctx);
+ }
+ return;
+ }
+ const parentPostNumber = streamPostNumber(post);
+ scrollToPost(parentPostNumber, ctx, {
+ ...PINNED_STREAM_SCROLL_OPTIONS,
+ visualNode: parentBodyEnd,
+ alignBottomCenter: true,
+ highlight: false,
+ });
+ return;
+ }
+
+ const nestedToggle = e.target.closest('.ldp-nested-toggle');
+ if (nestedToggle) {
+ const post = nestedToggle.closest('.ldp-post');
+ if (preserveOnlyOpPostExpansion(post)) return;
+ const willCollapse = !post.classList.contains('ldp-nested-collapsed');
+ if (willCollapse) delete post.dataset.ldpJumpExpanded;
+ setNestedReplyCollapsed(post, willCollapse);
+ if (willCollapse) floorPreview.hide(post);
+ return;
+ }
+
+ const rowToggleTarget = e.target.closest('.ldp-post.ldp-nested-collapsed');
+ if (rowToggleTarget && !e.target.closest('a,button,input,textarea,select,label')) {
+ setNestedReplyCollapsed(rowToggleTarget, false);
+ return;
+ }
+
+ const hiddenReplyAvatar = e.target.closest('.ldp-hidden-reply-avatar');
+ if (hiddenReplyAvatar) {
+ const target = +hiddenReplyAvatar.dataset.postNumber;
+ const targetPost = ctx.streamNodeMap.get(target)?._ldpPostData;
+ if (targetPost) await ctx.timeline.jumpTo(target, { targetPost });
+ return;
+ }
+ const hiddenReplyToggle = e.target.closest('.ldp-hidden-reply-toggle');
+ if (hiddenReplyToggle) {
+ const list = hiddenReplyToggle.nextElementSibling, expanded = list?.hidden !== false;
+ if (list) list.hidden = !expanded;
+ setExpanded(hiddenReplyToggle, String(expanded));
+ return;
+ }
+
+ const collapseBtn = e.target.closest('.ldp-collapse-replies');
+ if (collapseBtn) {
+ const post = collapseBtn.closest('.ldp-post');
+ if (preserveOnlyOpPostExpansion(post)) return;
+ const willExpand = post.classList.contains('ldp-children-collapsed');
+ setChildRepliesCollapsed(post, !willExpand);
+ // Expanding an empty shell: fetch now; if API has no direct replies, chrome is removed.
+ if (willExpand && countRenderedChildReplies(post) === 0 && ctx.repliesIO) {
+ const postNumber = +(post.dataset.postNumber || 0);
+ const state = ctx.subReplyState.get(postNumber);
+ if (state && state.hasMore === false && !(state.all?.length)) {
+ reconcilePostChildReplyTotal(post, 0, ctx, { hasMore: false });
+ return;
+ }
+ void Promise.resolve(ctx.repliesIO.loadDirectReplies?.(post._ldpPostData || {
+ id: +post.dataset.postId,
+ post_number: postNumber,
+ reply_count: +(post.dataset.childReplyTotal || 0),
+ }, { priority: POST_REQUEST_PRIORITY.visible, force: true })
+ ).then((replies) => {
+ if (!post.isConnected) return;
+ const list = Array.isArray(replies) ? replies : [];
+ const existing = ctx.subReplyState.get(postNumber);
+ ctx.subReplyState.set(postNumber, {
+ ...existing,
+ all: mergeDirectReplies(list),
+ pageIndex: 0,
+ totalCount: list.length,
+ fetchedReplyCount: list.length,
+ hasMore: false,
+ loadingMore: false,
+ renderedKey: '',
+ });
+ if (list.length) renderSubReplyBatch(postNumber, ctx, 0, post);
+ reconcilePostChildReplyTotal(post, list.length, ctx, { hasMore: false });
+ }).catch(() => {});
+ }
+ return;
+ }
+
+ const aggregateBtn = e.target.closest('.ldp-descendant-replies-open');
+ if (aggregateBtn) {
+ const parent = aggregateBtn.closest('.ldp-post');
+ void openAggregateReplyTarget(+parent?.dataset.postNumber, ctx, { returnControl: aggregateBtn });
+ return;
+ }
+
+ // 楼中楼分页按钮
+ const pageBtn = e.target.closest('.ldp-sub-page-prev, .ldp-sub-page-next');
+ if (pageBtn) {
+ if (pageBtn.disabled) return;
+ const post = pageBtn.closest('.ldp-post');
+ const postNumber = +post.dataset.postNumber;
+ const direction = pageBtn.classList.contains('ldp-sub-page-next') ? 1 : -1;
+ if (direction > 0) {
+ const state = ctx.subReplyState.get(postNumber);
+ const pageSize = SUB_REPLY_PAGE_SIZE;
+ const targetPage = +(state?.pageIndex || 0) + 1;
+ const requiredCount = Math.min(+(state?.totalCount || 0), (targetPage + 1) * pageSize);
+ if (state && +(state.fetchedReplyCount || 0) < requiredCount && state.hasMore !== false) {
+ await ctx.repliesIO.loadNextPage(post, requiredCount);
+ }
+ const updatedState = ctx.subReplyState.get(postNumber);
+ if (!updatedState || targetPage * pageSize >= updatedState.all.length) return;
+ }
+ renderSubReplyBatch(postNumber, ctx, direction, post);
+ return;
+ }
+
+ const quoteJump = e.target.closest('.ldp-quote-jump');
+ const preview = quoteJump?.closest('.ldp-floor-preview');
+ const post = e.target.closest('.ldp-post') || e.target.closest('.ldp-topic-action-rail')?._ldpRelatedPost || (preview?._ldpRelatedPost);
+ if (!post) return;
+ const postId = post.dataset.postId, postNumber = +post.dataset.postNumber;
+ if (quoteJump) {
+ const targetTopicId = +quoteJump.dataset.targetTopicId || 0;
+ if (targetTopicId && targetTopicId !== +ctx.topicId) {
+ e.preventDefault();
+ const targetPostNumber = +quoteJump.dataset.targetPostNumber || 1;
+ const quoteHighlight = quoteHighlightFromJump(quoteJump, ctx);
+ openModal(targetTopicId, { targetPostNumber, quoteHighlight });
+ return;
+ }
+ }
+ const reactionPicker = e.target.closest('[data-reaction-picker]');
+ if (reactionPicker) {
+ if (!hasDiscourseCapability('reactions', {
+ post: post._ldpPostData,
+ topic: ctx.topicData,
+ })) return;
+ const reactionsRoot = reactionPicker.closest('.ldp-reactions');
+ setReactionPickerExpanded(reactionsRoot, false);
+ await toggleReaderPostReaction(post, ctx, reactionPicker.dataset.reaction || 'heart', null, (error) => alert('回应失败:' + error.message));
+ return;
+ }
+ const reactionButton = e.target.closest('[data-reaction]');
+ if (reactionButton) {
+ const reaction = reactionButton.dataset.reaction;
+ const reactionsRoot = reactionButton.closest('.ldp-reactions');
+ const picker = reactionButton.closest('.ldp-reaction-picker');
+ if (picker) {
+ setReactionPickerExpanded(reactionsRoot, false);
+ }
+ await toggleReaderPostReaction(post, ctx, reaction, null, (error) => alert('回应失败:' + error.message));
+ return;
+ }
+ const pvVote = e.target.closest('[data-pv-vote]');
+ if (pvVote) {
+ const direction = pvVote.dataset.pvVote;
+ const postData = post._ldpPostData || {};
+ if (!hasDiscourseCapability('postVoting', { post: postData, topic: ctx.topicData })) return;
+ const remove = postData.post_voting_user_voted_direction === direction;
+ await runReaderActionOnce(ctx, `post:${postId}:pv-vote`, async () => {
+ setReaderPostControlsBusy(postId, ctx, '.ldp-pv-vote', true);
+ try {
+ const votingModule = lookupDiscourseModule(
+ 'discourse/plugins/discourse-post-voting/discourse/lib/post-voting-utilities'
+ );
+ const mutateVote = votingModule && (remove ? votingModule.removeVote : votingModule.castVote);
+ if (!isFunction(mutateVote))
+ throw new Error('Discourse 回答投票接口尚未就绪');
+ const data = await mutateVote(
+ remove ? { post_id: postId } : { post_id: postId, direction }
+ );
+ await clearStoredResponseCache({ tags: [`post:${postId}`] });
+ const fresh = data.post || data;
+ if (fresh.post_voting_vote_count == null && data.vote_count != null) fresh.post_voting_vote_count = data.vote_count;
+ if (fresh.post_voting_user_voted_direction == null) fresh.post_voting_user_voted_direction = remove ? null : direction;
+ syncSpecialPostData(post, ctx, fresh);
+ } catch (error) {
+ alert('回答投票失败:' + error.message);
+ readerPostCopies(postId, ctx).forEach((copy) => renderPostVoting(copy, ctx));
+ } finally {
+ setReaderPostControlsBusy(postId, ctx, '.ldp-pv-vote', false);
+ }
+ });
+ return;
+ }
+ const commentsToggle = e.target.closest('.ldp-pv-comments-toggle');
+ const commentsMore = !commentsToggle && e.target.closest('.ldp-pv-comments-more');
+ const commentsAction = commentsToggle || commentsMore;
+ if (commentsAction) {
+ const root = commentsToggle?.closest('.ldp-pv-comments');
+ const open = !commentsToggle || root.dataset.expanded !== '1';
+ if (commentsToggle) root.dataset.expanded = open ? '1' : '0';
+ const loadComments = commentsMore || (open &&
+ !(post._ldpPostData._ldpPvComments || post._ldpPostData.comments || []).length &&
+ +post._ldpPostData.comments_count > 0);
+ if (loadComments) {
+ commentsAction.disabled = true;
+ try { await fetchPostVotingComments(post, ctx); } catch (error) { alert('加载评论失败:' + error.message); }
+ }
+ renderPostVoting(post, ctx);
+ return;
+ }
+ const pvCommentVote = e.target.closest('[data-pv-comment-vote]');
+ if (pvCommentVote) {
+ const commentNode = pvCommentVote.closest('.ldp-pv-comment');
+ const commentId = commentNode && commentNode.dataset.commentId;
+ const remove = pvCommentVote.classList.contains('on');
+ await runReaderActionOnce(ctx, `post-voting-comment:${commentId}:vote`, async () => {
+ setReaderPostControlsBusy(postId, ctx, '[data-pv-comment-vote]', true);
+ try {
+ const data = await apiSend(
+ `${BASE}/post_voting/vote/comment`,
+ remove ? 'DELETE' : 'POST',
+ { comment_id: commentId },
+ null,
+ { invalidateTags: [`post:${postId}`] }
+ );
+ const existing = readerPostVotingComments(postId, ctx);
+ const comments = existing.slice();
+ const comment = comments.find((item) => String(item.id) === String(commentId));
+ if (comment) {
+ comment.user_voted = !remove;
+ comment.post_voting_vote_count = +(data.vote_count ?? comment.post_voting_vote_count) || 0;
+ }
+ syncPostVotingComments(postId, ctx, comments);
+ } catch (error) {
+ alert('评论投票失败:' + error.message);
+ } finally {
+ setReaderPostControlsBusy(postId, ctx, '[data-pv-comment-vote]', false);
+ }
+ });
+ return;
+ }
+ const eventAction = e.target.closest('[data-event-status]');
+ if (eventAction) {
+ const status = eventAction.dataset.eventStatus;
+ const eventData = post._ldpPostData?.event || {};
+ const invitee = eventData.watching_invitee || {};
+ await runReaderActionOnce(ctx, `post:${postId}:event-attendance`, async () => {
+ setReaderPostControlsBusy(postId, ctx, '.ldp-event-action', true);
+ try {
+ const eventApi = lookupDiscourse('service:discourse-post-event-api');
+ const eventModule = lookupDiscourseModule(
+ 'discourse/plugins/discourse-calendar/discourse/models/discourse-post-event-event'
+ );
+ const EventModel = eventModule?.default;
+ if (!eventApi || !isFunction(eventApi.updateEventAttendance) ||
+ !isFunction(eventApi.joinEvent) ||
+ !EventModel || !isFunction(EventModel.create))
+ throw new Error('Discourse 活动出席服务尚未就绪');
+ const eventModel = EventModel.create({
+ ...eventData,
+ id: Number(eventData.id) || Number(postId),
+ });
+ const attendance = { status, recurring: false };
+ if (invitee.id) await eventApi.updateEventAttendance(eventModel, attendance);
+ else await eventApi.joinEvent(eventModel, attendance);
+ eventData.watching_invitee = eventModel.watchingInvitee;
+ eventData.stats = eventModel.stats;
+ await clearStoredResponseCache({ tags: [`post:${postId}`] });
+ syncSpecialPostData(post, ctx, { event: eventData });
+ } catch (error) {
+ alert('活动报名失败:' + error.message);
+ readerPostCopies(postId, ctx).forEach(renderPostEvent);
+ } finally {
+ setReaderPostControlsBusy(postId, ctx, '.ldp-event-action', false);
+ }
+ });
+ return;
+ }
+ const pollAction = e.target.closest('[data-poll-action]');
+ if (pollAction) {
+ e.preventDefault();
+ const container = pollAction.closest('.ldp-reader-poll');
+ const name = container && (container.dataset.ldpPollName || 'poll');
+ if (!container || !name) return;
+ const action = pollAction.dataset.pollAction;
+ if (action === 'toggle-results') {
+ const showResults = container.dataset.ldpPollShowResults !== '1';
+ readerPostCopies(postId, ctx).forEach((copy) => renderPostPolls(copy, ctx, { name, showResults }));
+ return;
+ }
+ if (action === 'remove') {
+ await updateReaderPollVote(post, ctx, name);
+ return;
+ }
+ if (action === 'submit') {
+ const selected = [...container.querySelectorAll('input[data-poll-option]:checked')].map((item) => item.value);
+ const postData = post._ldpPostData || {};
+ const poll = (postData.polls || []).find((item) => pollName(item) === name);
+ const { min, max } = pollChoiceLimits(poll);
+ if (!poll || selected.length < min || selected.length > max) return;
+ await updateReaderPollVote(post, ctx, name, selected);
+ return;
+ }
+ }
+ const readerAction = e.target.closest('[data-reader-action]');
+ if (readerAction) {
+ e.preventDefault();
+ readerActionHandlers[readerAction.dataset.readerAction]?.(post);
+ return;
+ }
+ const postAction = e.target.closest('[data-post-action]');
+ if (postAction) {
+ e.preventDefault();
+ const action = postAction.dataset.postAction;
+ const handler = postActionHandlers[action];
+ if (handler && (action === 'share' || !postAction.disabled)) {
+ await handler(post, postId);
+ return;
+ }
+ if (action === 'delete' && !postAction.disabled) {
+ postAction.disabled = true;
+ try {
+ await deleteReaderPost(post, ctx);
+ } catch (error) {
+ alert('删除失败:' + error.message);
+ } finally {
+ if (postAction.isConnected) postAction.disabled = false;
+ }
+ return;
+ }
+ if (action === 'admin' && !postAction.disabled) {
+ openNativeAdminPostMenu(post, postAction, ctx);
+ return;
+ }
+ }
+ const topicAction = e.target.closest('[data-topic-action]');
+ if (topicAction) {
+ const action = topicAction.dataset.topicAction;
+ if (action === 'shared-issue' && !topicAction.disabled) {
+ if (!ME_USERNAME) syncCurrentUserFromHost();
+ if (!ME_USERNAME) {
+ alert('登录后才能使用“俺也一样”。');
+ return;
+ }
+ const firstPostNode = topicAction.closest('.ldp-post');
+ const sharedIssue = topicSharedIssueState(firstPostNode?._ldpPostData, ctx);
+ if (!sharedIssue.visible || sharedIssue.isAuthor) return;
+ await runReaderActionOnce(ctx, `topic:${ctx.topicId}:shared-issue`, async () => {
+ ctx.topicSharedIssueBusy = true;
+ syncTopicActionControls(ctx);
+ try {
+ const data = await apiSend(`${BASE}/solution/shared_issue`, 'POST', { topic_id: ctx.topicId });
+ ctx.topicData.shared_issue_count = Math.max(0, Number(data?.count) || 0);
+ ctx.topicData.user_created_shared_issue = !!(data?.user_created_shared_issue);
+ if (ctx.topicData.user_created_shared_issue) {
+ const currentLevel = Number.isInteger(ctx.topicNotificationLevel)
+ ? ctx.topicNotificationLevel
+ : topicNotificationLevel(ctx.topicData);
+ if (currentLevel < 2) {
+ ctx.topicNotificationLevel = 2;
+ ctx.topicData.notification_level = 2;
+ if (ctx.topicData.details) ctx.topicData.details.notification_level = 2;
+ }
+ }
+ setCachedTopicData(ctx.topicId, ctx.topicData);
+ } catch (error) {
+ if (Number(error?.status) === 403) {
+ SHARED_ISSUE_FORBIDDEN_TOPIC_IDS.add(Number(ctx.topicId));
+ } else {
+ alert('“俺也一样”操作失败:' + error.message);
+ }
+ } finally {
+ ctx.topicSharedIssueBusy = false;
+ syncTopicActionControls(ctx);
+ }
+ });
+ return;
+ }
+ const handler = topicActionHandlers[action];
+ if (handler) {
+ await handler();
+ return;
+ }
+ }
+ const jumpBtn = e.target.closest('.ldp-jump-parent, .ldp-jump-back, .ldp-jump-self, .ldp-jump-preview-self, .ldp-quote-jump');
+ if (jumpBtn) {
+ const targetPostNumber = +jumpBtn.dataset.targetPostNumber;
+ const quoteHighlight = quoteJump ? quoteHighlightFromJump(jumpBtn, ctx) : null;
+ jumpBtn.blur();
+ if (!targetPostNumber) return;
+ if (jumpBtn.classList.contains('ldp-jump-parent') &&
+ post && post.classList.contains('ldp-nested-collapsed')) {
+ if (jumpBtn.getAttribute('aria-busy') === 'true') return;
+ clearStreamViewportAnchor(ctx);
+ ctx.streamScrollDirection = 0;
+ ctx.scrollRoot.classList.add('ldp-floor-preview-positioning');
+ const collapsedBottom = post.getBoundingClientRect().bottom;
+ jumpBtn.setAttribute('aria-busy', 'true');
+ setLabel(jumpBtn, `正在准备父楼层 #${targetPostNumber}`);
+ try {
+ await new Promise((resolve) => {
+ requestAnimationFrame(resolve);
+ });
+ if (!jumpBtn.isConnected || !post.classList.contains('ldp-nested-collapsed')) return;
+ await floorPreview.show(jumpBtn, {
+ collapsedBottom,
+ allowCollapsed: true,
+ beforePlace: () => {
+ setNestedReplyCollapsed(post, false);
+ hydratePostContent(post, ctx);
+ },
+ });
+ } finally {
+ jumpBtn.removeAttribute('aria-busy');
+ if (jumpBtn.isConnected && post.classList.contains('ldp-nested-collapsed')) {
+ setLabel(jumpBtn, `展开楼层并预览父楼层 #${targetPostNumber}`);
+ }
+ requestAnimationFrame(() => ctx.scrollRoot.classList.remove('ldp-floor-preview-positioning'));
+ }
+ return;
+ }
+ let prepareTarget = null;
+ if (jumpBtn.classList.contains('ldp-jump-self')) {
+ const parentPostNumber = +jumpBtn.dataset.parentPostNumber || +(post && post.dataset.replyToPostNumber) || 0;
+ prepareTarget = () => ensureParentJumpOnStreamPost(targetPostNumber, parentPostNumber, ctx);
+ } else if (jumpBtn.classList.contains('ldp-jump-parent')) {
+ prepareTarget = () => showJumpBackLink(targetPostNumber, postNumber, ctx);
+ } else {
+ clearJumpBackLinks(ctx);
+ if (quoteJump && quoteHighlight) {
+ prepareTarget = () => applyReaderQuoteHighlight(
+ targetPostNumber,
+ ctx,
+ quoteHighlight.text,
+ { source: quoteHighlight.source },
+ );
+ } else if (!quoteJump && !jumpBtn.classList.contains('ldp-jump-preview-self')) {
+ prepareTarget = () => ensureParentJumpOnStreamPost(targetPostNumber, postNumber, ctx);
+ }
+ }
+ const jumped = await ctx.timeline.jumpTo(targetPostNumber, {
+ prepareTarget,
+ alignNearest: !!quoteHighlight,
+ });
+ if (!jumped && quoteHighlight) clearReaderQuoteHighlight(ctx);
+ return;
+ }
+ const boostDeleteAction = e.target.closest('.ldp-boost-delete-action');
+ const boostMentionAction = e.target.closest('.ldp-boost-mention-action');
+ if (boostMentionAction && !boostMentionAction.disabled) {
+ e.preventDefault();
+ const boostBubble = boostMentionAction.closest('.ldp-boost-bubble');
+ const username = cleanUsernameValue(boostBubble?.dataset.boostUser);
+ await runReaderActionOnce(ctx, `post:${postId}:boost-mention:${username.toLocaleLowerCase()}`,
+ () => quoteBoostInNativeReply(boostBubble, post, ctx));
+ return;
+ }
+ const boostItemAction = boostDeleteAction || e.target.closest('.ldp-boost-report-action');
+ if (boostItemAction && !boostItemAction.disabled) {
+ e.preventDefault();
+ const boostBubble = boostItemAction.closest('.ldp-boost-bubble');
+ const boostId = boostBubble && boostBubble.dataset.boostId;
+ const report = !boostDeleteAction;
+ boostItemAction.disabled = true;
+ try {
+ await runReaderActionOnce(
+ ctx,
+ `boost:${boostId}:${report ? 'report-dialog' : 'delete'}`,
+ () => report ? openReaderBoostReportDialog(boostBubble, ctx) : deleteReaderBoost(post, boostBubble, ctx)
+ );
+ } catch (error) {
+ if (report) showSelectionToast(error?.message ? error.message : '举报 Boost 失败,请重试');
+ else alert('删除 Boost 失败:' + error.message);
+ } finally {
+ if (boostItemAction.isConnected) boostItemAction.disabled = false;
+ }
+ return;
+ }
+ const boostCopyAction = e.target.closest('.ldp-boost-copy-action');
+ const boostMenuAction = boostCopyAction || e.target.closest('.ldp-boostbtn');
+ if (boostMenuAction) {
+ if (boostCopyAction) e.preventDefault();
+ const initialRaw = boostCopyAction
+ ? applyBoostCopyRule(boostBubblePlainText(boostCopyAction.closest('.ldp-boost-bubble')))
+ : '';
+ renderNativeBoostMenu(post, boostMenuAction, ctx, initialRaw);
+ return;
+ }
+ const likeBtn = e.target.closest('.ldp-like');
+ if (likeBtn && !likeBtn.disabled) {
+ await runReaderActionOnce(ctx, `post:${postId}:like`, async () => {
+ const current = readerPostCopies(postId, ctx).find((copy) =>
+ readerPostOwnControls(copy, '.ldp-like').length
+ ) || post;
+ const currentButton = readerPostOwnControls(current, '.ldp-like')[0];
+ await toggleReaderPostLike(postId, ctx, currentButton, {
+ setBusy: (busy) => setReaderPostControlsBusy(postId, ctx, '.ldp-like', busy),
+ postData: post._ldpPostData,
+ onError: (error) => alert('操作失败:' + error.message),
+ });
+ });
+ return;
+ }
+ const replyBtn = e.target.closest('.ldp-replybtn');
+ if (replyBtn) {
+ await runReaderActionOnce(
+ ctx,
+ `post:${postId}:reply-composer`,
+ () => tryOpenNativeReplyComposer(post, ctx)
+ );
+ }
+ };
+ onAction(document, 'keydown', (event) => {
+ if (event.key !== 'Escape' || event.isComposing || !expandedCodeBlockState ||
+ modal.querySelector('.ldp-code-preview-layer')) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ collapseReaderCodeBlock();
+ }, true);
+ onAction(modal, 'click', onModalClick);
+ return () => {
+ const boostMenu = document.body?.querySelector(':scope > .ldp-native-boost-menu');
+ if (isFunction(boostMenu?._ldpClose)) boostMenu._ldpClose();
+ closeUserCard();
+ if (ctx.closeUserCard === closeUserCard) ctx.closeUserCard = null;
+ if (ctx.closeUserCardLayer === closeUserCardLayer) ctx.closeUserCardLayer = null;
+ if (ctx.closeTransientActions === closeTransientActions) ctx.closeTransientActions = null;
+ clearActivePostActions();
+ actionScope.destroy();
+ if (selectionToolbarFrame) cancelAnimationFrame(selectionToolbarFrame);
+ selectionToolbarFrame = 0;
+ if (imageQuotePositionFrame) cancelAnimationFrame(imageQuotePositionFrame);
+ imageQuotePositionFrame = 0;
+ clearTimeout(imageQuoteHideTimer);
+ clearImageQuoteCycleTimers();
+ reactionHoverOpenTimers.forEach((timer) => clearTimeout(timer));
+ reactionHoverCloseTimers.forEach((timer) => clearTimeout(timer));
+ reactionVisibilityObserver?.disconnect();
+ reactionVisibilityObserver = null;
+ reactionHoverOpenTimers.clear();
+ reactionHoverCloseTimers.clear();
+ selectionToolbar.remove();
+ imageQuoteToolbar.remove();
+ readerPortalQuery('.ldp-selection-toast')?.remove();
+ floorPreview.destroy();
+ };
+ }
+
+ /* ============ 11. 楼中楼补全(分批渲染 + 节流 + 停顿检测) ============ */
+ function directRepliesForParent(replies, postNumber) {
+ const parent = +postNumber;
+ const posts = Array.isArray(replies) ? replies : postsFromPayload(replies);
+ return posts.filter((reply) => {
+ const rawReplyTo = reply?.reply_to_post_number;
+ const replyTo = +(rawReplyTo || 0);
+ // /posts/:id/replies.json is already scoped to this post. LinuxDo can
+ // omit reply_to_post_number here, but an explicit different parent is a
+ // descendant and must stay out of this two-level reply component.
+ return !replyTo || replyTo === parent;
+ });
+ }
+
+ function mergeDirectReplies(...groups) {
+ const merged = new Map();
+ groups.flat().forEach((reply) => {
+ if (!reply) return;
+ const key = +(reply.post_number || 0) || String(reply.id || '');
+ if (key) merged.set(key, reply);
+ });
+ return [...merged.values()].sort((a, b) => +(a.post_number || 0) - +(b.post_number || 0));
+ }
+
+ function replyPageCursor(posts) {
+ return posts.reduce(
+ (latest, reply) => Math.max(latest, +(reply?.post_number || 0)),
+ 0,
+ );
+ }
+
+ function createRepliesIO(ctx) {
+ const fetchedNodes = new WeakSet();
+ const hoverTimers = new WeakMap();
+ const hoverTimerNodes = new Set();
+ const emptyRetryCounts = new WeakMap();
+ const transientRetryCounts = new WeakMap();
+ const retryTimers = new Map();
+ const replyPagesByKey = new Map();
+ const pendingReplyPageStarts = new Map();
+ const completeDirectReplyPostIds = new Set();
+ const pendingPrefetchNodes = new Set();
+ const visiblePrefetchNodes = new Set();
+ let nestedPrefetchRunning = false;
+ let nestedPrefetchStopped = false;
+ let nestedPrefetchPaused = document.visibilityState !== 'visible';
+ let nestedPrefetchResumeTimer = 0;
+ const sessionActive = () => !ctx.readerSession || ctx.readerSession.isCurrent('opening', 'active');
+ const assertSessionActive = () => {
+ if (!sessionActive()) throw new DOMException('Reader session ended', 'AbortError');
+ };
+
+ const replyRequestKey = (postId, after = 0) => `replies:${postId}:${Math.max(0, +after || 0)}`;
+
+ const cancelNestedPrefetch = (post, cancelQueued = true) => {
+ if (!post) return;
+ if (hoverTimers.has(post)) clearTimeout(hoverTimers.get(post));
+ hoverTimers.delete(post);
+ hoverTimerNodes.delete(post);
+ if (retryTimers.has(post)) clearTimeout(retryTimers.get(post));
+ retryTimers.delete(post);
+ visiblePrefetchNodes.delete(post);
+ transientRetryCounts.delete(post);
+ pendingPrefetchNodes.delete(post);
+ if (cancelQueued) ctx.postRequestScheduler.cancelQueued(replyRequestKey(post.dataset.postId, 0));
+ };
+
+ function scheduleNestedPrefetchDrain() {
+ if (nestedPrefetchStopped || nestedPrefetchPaused ||
+ nestedPrefetchResumeTimer || !pendingPrefetchNodes.size) return;
+ const scrollAge = Date.now() - +(ctx.streamLastScrollAt || 0);
+ nestedPrefetchResumeTimer = setTimeout(() => {
+ nestedPrefetchResumeTimer = 0;
+ if (sessionActive()) drainNestedPrefetchQueue();
+ }, Math.max(120, 240 - scrollAge));
+ }
+
+ async function drainNestedPrefetchQueue() {
+ if (nestedPrefetchRunning || nestedPrefetchStopped || nestedPrefetchPaused) return;
+ if (streamScrollIsActive(ctx, 220)) {
+ scheduleNestedPrefetchDrain();
+ return;
+ }
+ nestedPrefetchRunning = true;
+ try {
+ while (!nestedPrefetchStopped && !nestedPrefetchPaused &&
+ sessionActive() && pendingPrefetchNodes.size) {
+ if (streamScrollIsActive(ctx, 220)) {
+ scheduleNestedPrefetchDrain();
+ break;
+ }
+ const candidates = [...pendingPrefetchNodes]
+ .filter((node) => node?.isConnected);
+ let post = candidates[0] || null;
+ if (candidates.length > 1) {
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const rootCenter = (rootRect.top + rootRect.bottom) / 2;
+ post = candidates
+ .map((node, order) => {
+ const rect = node.getBoundingClientRect();
+ return {
+ node,
+ order,
+ distance: Math.abs((rect.top + rect.bottom) / 2 - rootCenter),
+ };
+ })
+ .sort((a, b) => a.distance - b.distance || a.order - b.order)[0].node;
+ }
+ if (!post) {
+ pendingPrefetchNodes.clear();
+ break;
+ }
+ pendingPrefetchNodes.delete(post);
+ try {
+ const loaded = await loadRepliesFor(post, POST_REQUEST_PRIORITY.nested);
+ if (loaded) io.unobserve(post);
+ transientRetryCounts.delete(post);
+ } catch (error) {
+ const status = Number(error?.status || 0);
+ const transient = !!error && (
+ error.name === 'AbortError' ||
+ error.name === 'TypeError' ||
+ status === 408 ||
+ status === 429 ||
+ status >= 500
+ );
+ if (transient) scheduleNestedPrefetchRetry(post, error);
+ }
+ }
+ } finally {
+ nestedPrefetchRunning = false;
+ if (!nestedPrefetchStopped && !nestedPrefetchPaused &&
+ pendingPrefetchNodes.size) scheduleNestedPrefetchDrain();
+ }
+ }
+
+ const enqueueNestedPrefetch = (post) => {
+ if (nestedPrefetchStopped || !sessionActive() || !post || !post.isConnected || fetchedNodes.has(post)) return;
+ pendingPrefetchNodes.add(post);
+ if (nestedPrefetchPaused) return;
+ drainNestedPrefetchQueue();
+ };
+
+ function scheduleNestedPrefetchRetry(post, cause = null) {
+ if (nestedPrefetchStopped || !sessionActive() || !post || !post.isConnected ||
+ !visiblePrefetchNodes.has(post) || fetchedNodes.has(post) || retryTimers.has(post)) return;
+ const retryCount = +(transientRetryCounts.get(post) || 0) + 1;
+ transientRetryCounts.set(post, retryCount);
+ let requestState = null;
+ try { requestState = ctx.postRequestScheduler.snapshot(); } catch {}
+ const controllerWait = Math.max(
+ Number(requestState?.cooldownRemaining) || 0,
+ Number(requestState?.nextPermitDelay) || 0,
+ Math.max(0, Number(cause?.retryAt || 0) - Date.now())
+ );
+ const delay = controllerWait > 0
+ ? Math.min(60000, controllerWait + 80)
+ : Math.min(15000, 500 * Math.pow(2, Math.min(5, retryCount - 1)));
+ const timer = setTimeout(() => {
+ retryTimers.delete(post);
+ if (sessionActive() && post.isConnected && visiblePrefetchNodes.has(post)) {
+ enqueueNestedPrefetch(post);
+ }
+ }, Math.max(250, delay));
+ retryTimers.set(post, timer);
+ }
+
+ async function fetchReplyPage(postId, after = 0, options = {}) {
+ assertSessionActive();
+ if (!postId) return [];
+ const normalizedAfter = Math.max(0, +after || 0);
+ const pageKey = `${postId}:${normalizedAfter}`;
+ if (replyPagesByKey.has(pageKey)) {
+ const pendingSince = +(pendingReplyPageStarts.get(pageKey) || 0);
+ const cached = await replyPagesByKey.get(pageKey);
+ if (options.refresh !== true || pendingSince && responseCacheValueStoredAt(cached) >= pendingSince) return cached;
+ replyPagesByKey.delete(pageKey);
+ }
+ const url = new URL(`${BASE}/posts/${postId}/replies.json`);
+ if (normalizedAfter > 0) url.searchParams.set('after', String(normalizedAfter));
+ const request = fetchJSON(url.toString(), {
+ scheduler: ctx.postRequestScheduler,
+ fetchOptions: { credentials: 'include', cache: 'no-store' },
+ cacheMode: options.refresh === true ? 'refresh' : 'default',
+ key: replyRequestKey(postId, normalizedAfter),
+ priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.nested,
+ });
+ pendingReplyPageStarts.set(pageKey, Date.now());
+ replyPagesByKey.set(pageKey, request);
+ try {
+ const payload = await request;
+ assertSessionActive();
+ ctx.tracker.preload(postsFromPayload(payload));
+ replyPagesByKey.set(pageKey, payload);
+ return payload;
+ } catch (error) {
+ replyPagesByKey.delete(pageKey);
+ throw error;
+ } finally {
+ pendingReplyPageStarts.delete(pageKey);
+ }
+ }
+
+ async function loadDirectReplies(post, options = {}) {
+ assertSessionActive();
+ const postId = +(post?.id || 0);
+ const postNumber = +(post?.post_number || 0);
+ if (!postId || !postNumber) return [];
+ const knownMap = ctx.directRepliesByParent.get(postNumber);
+ let merged = mergeDirectReplies(knownMap ? [...knownMap.values()] : []);
+ const expected = Math.max(0, +(post.reply_count || 0));
+ if ((options.force !== true || completeDirectReplyPostIds.has(postId)) && expected <= merged.length) return merged;
+ let after = 0;
+ const seenCursors = new Set();
+ do {
+ const payload = await fetchReplyPage(
+ postId, after, {
+ priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.background,
+ refresh: options.force === true,
+ }
+ );
+ const pagePosts = postsFromPayload(payload);
+ const direct = directRepliesForParent(pagePosts, postNumber);
+ direct.forEach((reply) => rememberPostData(ctx, reply, postNumber));
+ ctx.loader.persistFetchedPosts(direct);
+ merged = mergeDirectReplies(merged, direct);
+ const nextAfter = replyPageCursor(pagePosts);
+ if (!pagePosts.length || pagePosts.length < SUB_REPLY_FETCH_SIZE ||
+ nextAfter <= after || seenCursors.has(nextAfter)) break;
+ seenCursors.add(nextAfter);
+ after = nextAfter;
+ } while (!expected || merged.length < expected);
+ completeDirectReplyPostIds.add(postId);
+ return merged;
+ }
+
+ function mergeReplyPageIntoState(state, payload, postNumber, expectedCount, after = 0) {
+ const pagePosts = postsFromPayload(payload);
+ const pageReplies = directRepliesForParent(pagePosts, postNumber);
+ const nextAfter = replyPageCursor(pagePosts);
+ pageReplies.forEach((reply) => rememberPostData(ctx, reply, postNumber));
+ ctx.loader.persistFetchedPosts(pageReplies);
+ state.all = mergeDirectReplies(state.all, pageReplies);
+ state.fetchedReplyCount = after > 0
+ ? +(state.fetchedReplyCount || 0) + pageReplies.length
+ : pageReplies.length;
+ state.fetchAfter = Math.max(after, nextAfter);
+ const previousTotalCount = after > 0 ? +(state.totalCount || 0) : 0;
+ // Do not keep an optimistic expectedCount once the first page is short or empty of
+ // direct replies — that produces phantom "展开 1 条回复" controls.
+ const pageExhausted = !pagePosts.length || pagePosts.length < SUB_REPLY_FETCH_SIZE ||
+ nextAfter <= after;
+ const totalCount = pageExhausted && after === 0 && !pageReplies.length
+ ? state.all.length
+ : Math.max(
+ state.all.length,
+ previousTotalCount,
+ pageExhausted ? state.all.length : +(expectedCount || 0),
+ );
+ state.hasMore = !pageExhausted && nextAfter > after && pagePosts.length >= SUB_REPLY_FETCH_SIZE &&
+ state.fetchedReplyCount < Math.max(totalCount, +(expectedCount || 0), 1);
+ state.totalCount = state.hasMore ? Math.max(totalCount, +(expectedCount || 0)) : state.all.length;
+ return pagePosts;
+ }
+
+ async function loadNextReplyPages(post, requiredCount) {
+ if (!sessionActive()) return false;
+ const postNumber = +(post && post.dataset.postNumber || 0);
+ const state = ctx.subReplyState.get(postNumber);
+ if (!postNumber || !state || state.loadingMore || state.hasMore === false) return false;
+ const loadingEl = post.querySelector(':scope > .ldp-sub-loading');
+ state.loadingMore = true;
+ loadingEl?.classList.add('show');
+ renderSubReplyBatch(postNumber, ctx, 0, post);
+ try {
+ while (state.hasMore !== false && +(state.fetchedReplyCount || 0) < requiredCount) {
+ const after = +(state.fetchAfter || 0);
+ if (!after) {
+ state.hasMore = false;
+ break;
+ }
+ const payload = await fetchReplyPage(post.dataset.postId, after, {
+ priority: POST_REQUEST_PRIORITY.scroll,
+ });
+ if (!sessionActive() || !post.isConnected) return false;
+ const pagePosts = mergeReplyPageIntoState(
+ state, payload, postNumber, post.dataset.childReplyTotal, after
+ );
+ if (state.fetchAfter <= after || !pagePosts.length) break;
+ }
+ return state.all.length >= requiredCount;
+ } catch {
+ return false;
+ } finally {
+ state.loadingMore = false;
+ loadingEl?.classList.remove('show');
+ if (sessionActive() && post.isConnected) {
+ renderSubReplyBatch(postNumber, ctx, 0, post);
+ const settled = ctx.subReplyState.get(postNumber);
+ if (settled) {
+ reconcilePostChildReplyTotal(post, settled.all?.length || 0, ctx, {
+ hasMore: settled.hasMore === true,
+ totalCount: settled.totalCount,
+ });
+ }
+ }
+ }
+ }
+
+ async function loadRepliesFor(post, priority = POST_REQUEST_PRIORITY.nested) {
+ if (!sessionActive()) return false;
+ const postId = post && post.dataset.postId;
+ const postNumber = +(post && post.dataset.postNumber);
+ if (!postId || fetchedNodes.has(post)) return false;
+ fetchedNodes.add(post);
+ const loadingEl = post.querySelector(':scope > .ldp-sub-loading');
+ loadingEl?.classList.add('show');
+ const expectedCount = +(post.dataset.childReplyTotal || 0);
+ const knownReplyMap = ctx.directRepliesByParent.get(postNumber);
+ const knownReplies = knownReplyMap ? [...knownReplyMap.values()] : [];
+ if (knownReplies.length) {
+ const previousState = ctx.subReplyState.get(postNumber);
+ const knownComplete = knownReplies.length >= expectedCount;
+ const previousHasCursor = +(previousState?.fetchAfter || 0) > 0;
+ const needsInitialFetch = !knownComplete && !previousHasCursor &&
+ (!previousState || previousState.hasMore !== false);
+ ctx.subReplyState.set(postNumber, {
+ ...previousState,
+ all: mergeDirectReplies(knownReplies),
+ pageIndex: +(previousState?.pageIndex || 0),
+ totalCount: previousState && previousState.hasMore === false
+ ? Math.max(knownReplies.length, +(previousState.totalCount || 0))
+ : Math.max(expectedCount, knownReplies.length),
+ fetchedReplyCount: knownComplete
+ ? knownReplies.length
+ : +(previousState?.fetchedReplyCount || 0),
+ fetchAfter: +(previousState?.fetchAfter || 0),
+ hasMore: knownComplete ? false : previousState ? previousState.hasMore !== false : true,
+ loadingMore: needsInitialFetch,
+ });
+ renderSubReplyBatch(postNumber, ctx, 0, post);
+ if (!needsInitialFetch) {
+ loadingEl?.classList.remove('show');
+ emptyRetryCounts.delete(post);
+ reconcilePostChildReplyTotal(post, knownReplies.length, ctx, {
+ hasMore: false,
+ totalCount: knownReplies.length,
+ });
+ return true;
+ }
+ }
+ try {
+ const replies = await fetchReplyPage(postId, 0, { priority });
+ if (!sessionActive()) return false;
+ if (!post.isConnected) {
+ fetchedNodes.delete(post);
+ return false;
+ }
+ const previousState = ctx.subReplyState.get(postNumber);
+ const nextState = {
+ ...previousState,
+ all: knownReplies,
+ pageIndex: +(previousState?.pageIndex || 0),
+ loadingMore: false,
+ };
+ mergeReplyPageIntoState(nextState, replies, postNumber, expectedCount);
+ const directReplies = nextState.all;
+ loadingEl?.classList.remove('show');
+ if (!directReplies.length) {
+ fetchedNodes.delete(post);
+ replyPagesByKey.delete(`${postId}:0`);
+ const retryCount = +(emptyRetryCounts.get(post) || 0);
+ // Still expect more pages of mixed/descendant payload — keep loading later.
+ if (nextState.hasMore === true && post.isConnected) {
+ ctx.subReplyState.set(postNumber, nextState);
+ reconcilePostChildReplyTotal(post, 0, ctx, {
+ hasMore: true,
+ totalCount: nextState.totalCount,
+ });
+ return false;
+ }
+ if (expectedCount > 0 && retryCount < 2 && post.isConnected) {
+ await clearStoredResponseCache({ tags: [`post:${postId}`] });
+ emptyRetryCounts.set(post, retryCount + 1);
+ const retryTimer = setTimeout(() => {
+ retryTimers.delete(post);
+ enqueueNestedPrefetch(post);
+ }, 1200 * (retryCount + 1));
+ retryTimers.set(post, retryTimer);
+ // While retrying, keep chrome but do not claim settled replies.
+ ctx.subReplyState.set(postNumber, {
+ ...nextState,
+ all: [],
+ totalCount: expectedCount,
+ hasMore: true,
+ loadingMore: false,
+ });
+ return false;
+ }
+ // Settled empty: strip phantom expand control.
+ emptyRetryCounts.delete(post);
+ ctx.subReplyState.set(postNumber, {
+ ...nextState,
+ all: [],
+ totalCount: 0,
+ hasMore: false,
+ loadingMore: false,
+ });
+ reconcilePostChildReplyTotal(post, 0, ctx, { hasMore: false });
+ return false;
+ }
+ emptyRetryCounts.delete(post);
+ ctx.subReplyState.set(postNumber, nextState);
+ renderSubReplyBatch(postNumber, ctx, 0, post); // 首批只渲染当前分页条数
+ reconcilePostChildReplyTotal(post, nextState.all.length, ctx, {
+ hasMore: nextState.hasMore === true,
+ totalCount: nextState.totalCount,
+ });
+ return true;
+ } catch (e) {
+ loadingEl?.classList.remove('show');
+ const state = ctx.subReplyState.get(postNumber);
+ if (state) {
+ state.loadingMore = false;
+ renderSubReplyBatch(postNumber, ctx, 0, post);
+ }
+ fetchedNodes.delete(post); // 失败允许该 DOM 副本下次进入视口重试
+ replyPagesByKey.delete(`${postId}:0`);
+ throw e;
+ }
+ }
+
+ const scheduleInitialLoad = (post) => {
+ if (!sessionActive() || !post || !post.dataset.postId || fetchedNodes.has(post) || hoverTimers.has(post)) return;
+ const timer = setTimeout(() => {
+ hoverTimers.delete(post);
+ hoverTimerNodes.delete(post);
+ if (!sessionActive() || !post.isConnected) return;
+ enqueueNestedPrefetch(post);
+ }, REPLIES_HOVER_DELAY);
+ hoverTimers.set(post, timer);
+ hoverTimerNodes.add(post);
+ };
+
+ const nestedPrefetchPixels = Math.round(
+ Math.max(ctx.scrollRoot.clientHeight, window.innerHeight * 0.5) *
+ streamPerformance(ctx).nestedPrefetchViewports
+ );
+ const io = new IntersectionObserver((entries) => {
+ if (!sessionActive()) return;
+ entries.forEach((en) => {
+ if (!en.target.dataset.postId) return;
+
+ if (en.isIntersecting) {
+ visiblePrefetchNodes.add(en.target);
+ scheduleInitialLoad(en.target);
+ } else {
+ // 离开视口时若尚未真正发起请求,则取消该次触发
+ cancelNestedPrefetch(en.target);
+ }
+ });
+ }, { root: ctx.scrollRoot, rootMargin: `${nestedPrefetchPixels}px 0px ${nestedPrefetchPixels}px 0px`, threshold: 0.01 });
+ const nativeObserve = io.observe.bind(io);
+ io.observe = (node) => {
+ if (!node || fetchedNodes.has(node) || !(+(node.dataset.childReplyTotal || 0) > 0)) return;
+ nativeObserve(node);
+ };
+ const nativeUnobserve = io.unobserve.bind(io);
+ io.unobserve = (node) => {
+ cancelNestedPrefetch(node);
+ nativeUnobserve(node);
+ };
+ const nativeDisconnect = io.disconnect.bind(io);
+ io.disconnect = () => {
+ nestedPrefetchStopped = true;
+ if (nestedPrefetchResumeTimer) clearTimeout(nestedPrefetchResumeTimer);
+ nestedPrefetchResumeTimer = 0;
+ new Set([...hoverTimerNodes, ...retryTimers.keys()]).forEach((node) => cancelNestedPrefetch(node, false));
+ pendingPrefetchNodes.clear();
+ visiblePrefetchNodes.clear();
+ replyPagesByKey.clear();
+ pendingReplyPageStarts.clear();
+ completeDirectReplyPostIds.clear();
+ nativeDisconnect();
+ };
+ io.loadNextPage = (node, requiredCount) => loadNextReplyPages(node, requiredCount);
+ io.loadDirectReplies = loadDirectReplies;
+ io.setActive = (active) => {
+ nestedPrefetchPaused = active !== true;
+ if (nestedPrefetchPaused) {
+ if (nestedPrefetchResumeTimer) clearTimeout(nestedPrefetchResumeTimer);
+ nestedPrefetchResumeTimer = 0;
+ return;
+ }
+ if (pendingPrefetchNodes.size) scheduleNestedPrefetchDrain();
+ };
+ return io;
+ }
+ function syncCommentsHeaderPlacement(ctx) {
+ if (!ctx || !ctx.commentsHeader || !ctx.streamNodeMap) return;
+ if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return;
+ const firstPost = ctx.streamNodeMap.get(1);
+ const firstPostMounted = firstPost && firstPost.parentElement === ctx.commentsEl &&
+ ctx.streamMountedNodes && ctx.streamMountedNodes.has(firstPost);
+ if (!firstPostMounted) {
+ setHidden(ctx.commentsHeader, true);
+ return;
+ }
+ const children = firstPost.querySelector(':scope > .ldp-children');
+ const childLoading = firstPost.querySelector(':scope > .ldp-sub-loading');
+ const footerActions = firstPost.querySelector(':scope > .ldp-topic-footer-actions');
+ let anchor = null;
+ if (footerActions) {
+ const replyEnd = childLoading || children;
+ if (replyEnd && footerActions.previousElementSibling !== replyEnd) {
+ firstPost.insertBefore(footerActions, replyEnd.nextSibling);
+ }
+ anchor = footerActions;
+ } else if (children) {
+ anchor = childLoading || children;
+ }
+ const correctlyPlaced = ctx.commentsHeader.parentElement === firstPost &&
+ (anchor
+ ? ctx.commentsHeader.previousSibling === anchor
+ : ctx.commentsHeader.nextSibling === null);
+ if (!correctlyPlaced) {
+ firstPost.insertBefore(ctx.commentsHeader, anchor?.nextSibling || null);
+ }
+ setHidden(ctx.commentsHeader, false);
+ }
+
+ function updateCommentsHeader(ctx) {
+ const commentCount = Math.max(0, +(ctx?.totalPosts || 0) - 1);
+ setText(ctx.countEl, `(${commentCount})`);
+ if (ctx.emptyEl) ctx.emptyEl.hidden = !!ctx.totalPosts;
+ syncCommentsHeaderPlacement(ctx);
+ }
+
+ function bindReaderTopicPresence(ctx) {
+ if (!ctx || !ctx.commentsHeader || !ctx.topicId) return () => {};
+ let presence = ctx.commentsHeader.querySelector(':scope > .ldp-topic-presence');
+ if (!presence) {
+ presence = makeElement('span');
+ presence.className = 'ldp-topic-presence';
+ presence.hidden = true;
+ presence.setAttribute('aria-live', 'polite');
+ ctx.commentsHeader.appendChild(presence);
+ }
+ const channel = `/discourse-presence/reply/${ctx.topicId}`;
+ let stopped = false;
+ let presenceChannel = null;
+ let presenceChangeHandler = null;
+
+ const render = (source) => {
+ const users = (Array.isArray(source) ? source : [])
+ .filter((user) => user && (!ME_USERNAME || user.username !== ME_USERNAME));
+ if (!users.length) {
+ presence.hidden = true;
+ presence.replaceChildren();
+ return;
+ }
+ const visibleUsers = users.slice(0, 3);
+ const avatars = visibleUsers.map((user) => {
+ const username = String(user.username || '');
+ const name = String(user.name || username || '用户');
+ const avatar = persistentAvatarHtml(user.avatar_template, 24, 'ldp-topic-presence-avatar', name);
+ return `
${avatar}`;
+ }).join('');
+ const first = users[0];
+ const firstName = String(first.name || first.username || '用户');
+ const label = users.length === 1 ? `${firstName} 正在回复…` : `${users.length} 人正在回复…`;
+ presence.innerHTML = `
${avatars}${esc(label)}`;
+ presence.hidden = false;
+ hydratePersistentAvatarImages(presence, POST_REQUEST_PRIORITY.background);
+ };
+
+ const unsubscribe = async (
+ channelProxy = presenceChannel,
+ changeHandler = presenceChangeHandler
+ ) => {
+ if (!channelProxy) return;
+ if (changeHandler && isFunction(channelProxy.off)) {
+ channelProxy.off('change', changeHandler);
+ }
+ try {
+ await channelProxy.unsubscribe();
+ } catch {}
+ if (presenceChannel === channelProxy) {
+ presenceChangeHandler = null;
+ presenceChannel = null;
+ }
+ };
+ const start = async () => {
+ const presenceService = lookupDiscourse('service:presence');
+ if (!presenceService || !isFunction(presenceService.getChannel)) {
+ render([]);
+ return;
+ }
+ const channelProxy = presenceService.getChannel(channel);
+ presenceChannel = channelProxy;
+ if (!channelProxy || !isFunction(channelProxy.subscribe)) {
+ presenceChannel = null;
+ render([]);
+ return;
+ }
+ const changeHandler = () => {
+ if (!stopped) render([...channelProxy.users || []]);
+ };
+ presenceChangeHandler = changeHandler;
+ if (isFunction(channelProxy.on)) {
+ channelProxy.on('change', changeHandler);
+ }
+ try {
+ await channelProxy.subscribe();
+ if (stopped) {
+ await unsubscribe(channelProxy, changeHandler);
+ return;
+ }
+ changeHandler();
+ } catch {
+ if (!stopped) render([]);
+ await unsubscribe(channelProxy, changeHandler);
+ }
+ };
+ void start();
+
+ return () => {
+ stopped = true;
+ void unsubscribe();
+ presence.hidden = true;
+ presence.replaceChildren();
+ };
+ }
+
+ const READER_LOADING_ANIMATIONS = Object.freeze([
+ { key: 'portal', label: '主题开卷', markup: '
' },
+ { key: 'constellation', label: '回帖脉络', markup: '
' },
+ { key: 'corridor', label: '楼层时间轴', markup: '
' },
+ { key: 'typewave', label: 'Markdown 解析', markup: '
' },
+ { key: 'crystal', label: '缓存回环', markup: '
' },
+ { key: 'marginalia', label: '只看楼主', markup: '
' },
+ { key: 'chapters', label: '分类标签', markup: '
' },
+ { key: 'quoteecho', label: '社区信条', markup: '
' },
+ { key: 'footnotes', label: '新回复抵达', markup: '
' },
+ { key: 'inkverse', label: '互动汇流', markup: '
' },
+ ]);
+
+ function resolveReaderLoadingAnimation(preference = PREFS.loadingAnimation, randomKey = '', excludedKey = '') {
+ const normalized = normalizeReaderLoadingAnimation(preference);
+ if (normalized !== 'random')
+ return READER_LOADING_ANIMATIONS.find((animation) => animation.key === normalized) || READER_LOADING_ANIMATIONS[0];
+ const requestedRandom = READER_LOADING_ANIMATIONS.find((animation) => animation.key === randomKey);
+ if (requestedRandom) return requestedRandom;
+ const candidates = READER_LOADING_ANIMATIONS.filter((animation) => animation.key !== excludedKey);
+ const pool = candidates.length ? candidates : READER_LOADING_ANIMATIONS;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ function readerLoadingVisualHTML(animation) {
+ return `
+ ${animation.markup}
+
`;
+ }
+
+ let lastRandomReaderLoadingAnimationKey = '';
+ function createReaderLoadingHTML(preference = PREFS.loadingAnimation) {
+ const normalized = normalizeReaderLoadingAnimation(preference);
+ const animation = resolveReaderLoadingAnimation(normalized, '',
+ normalized === 'random' ? lastRandomReaderLoadingAnimationKey : '');
+ if (normalized === 'random') lastRandomReaderLoadingAnimationKey = animation.key;
+ return `
+
+ ${readerLoadingVisualHTML(animation)}
+
+
${esc(SITE_DISPLAY_NAME.toUpperCase())} READER · ${animation.label}
+
正在载入帖子
+
正在准备阅读现场…
+
+
`;
+ }
+
+ function parkReaderCommentsHeader(overlay, preferredHeader = null) {
+ const body = overlay?.querySelector('.ldp-body');
+ if (!body) return null;
+ const readerShell = overlay._ldpReaderShell;
+ let commentsHeader = preferredHeader || (readerShell?.commentsHeader) ||
+ overlay.querySelector('.ldp-comments-header');
+ if (!commentsHeader) {
+ commentsHeader = makeElement('div');
+ commentsHeader.className = 'ldp-comments-header';
+ commentsHeader.hidden = true;
+ commentsHeader.innerHTML = `${icon('messageSquare')}
评论`;
+ }
+ const loadingTip = body.querySelector('.ldp-loading-tip');
+ commentsHeader.hidden = true;
+ commentsHeader.remove();
+ if (loadingTip) body.insertBefore(commentsHeader, loadingTip);
+ else body.appendChild(commentsHeader);
+ if (readerShell) readerShell.commentsHeader = commentsHeader;
+ return commentsHeader;
+ }
+
+ function resetReaderTopicShell(overlay) {
+ const body = overlay?.querySelector('.ldp-body');
+ if (!body) return;
+ overlay.querySelector('.ldp-header')?.classList.remove('ldp-title-single-line');
+ const comments = body.querySelector('.ldp-comments');
+ parkReaderCommentsHeader(overlay);
+ const loadingTip = body.querySelector('.ldp-loading-tip');
+ const endTip = body.querySelector('.ldp-end-tip');
+ const timeline = overlay.querySelector('.ldp-topic-timeline');
+ const title = overlay.querySelector('.ldp-title-jump');
+ const metaStats = overlay.querySelector('.ldp-meta-stats');
+ const metaOwner = overlay.querySelector('.ldp-meta-owner');
+ const tags = overlay.querySelector('.ldp-topic-tags');
+ const voteSlot = overlay.querySelector('.ldp-topic-vote-slot');
+ const onlyOp = overlay.querySelector('.ldp-only-op-toggle');
+ const onlyOpProgress = overlay.querySelector('.ldp-only-op-progress');
+ const nativeOpen = overlay.querySelector('.ldp-open');
+ const liveUpdate = overlay.querySelector('.ldp-live-update');
+ const rateLimitNotice = overlay.querySelector('.ldp-rate-limit-notice');
+
+ body.querySelector('.ldp-loadmask')?.remove();
+ const mask = makeElement('div');
+ mask.className = 'ldp-loadmask';
+ mask.innerHTML = createReaderLoadingHTML();
+ body.prepend(mask);
+ if (comments) {
+ const empty = makeElement('div');
+ empty.className = 'ldp-comments-empty';
+ empty.hidden = true;
+ empty.textContent = '暂无评论';
+ comments.replaceChildren(empty);
+ }
+ if (loadingTip) loadingTip.classList.remove('show');
+ if (endTip) endTip.classList.remove('show');
+ if (liveUpdate) liveUpdate.hidden = true;
+ if (rateLimitNotice) {
+ rateLimitNotice.hidden = true;
+ delete rateLimitNotice.dataset.cooldownSeconds;
+ }
+ if (timeline) {
+ timeline.hidden = true;
+ timeline.querySelector('.ldp-topic-timeline-current')?.replaceChildren();
+ timeline.querySelector('.ldp-topic-timeline-total')?.replaceChildren();
+ timeline.querySelector('.ldp-topic-timeline-preview')?.replaceChildren();
+ }
+ if (title) title.textContent = '正在切换主题…';
+ if (metaStats) metaStats.textContent = '正在加载主题…';
+ if (metaOwner) metaOwner.hidden = true;
+ if (tags) {
+ tags.replaceChildren();
+ tags.hidden = true;
+ }
+ if (voteSlot) {
+ voteSlot.replaceChildren();
+ voteSlot.hidden = true;
+ }
+ if (onlyOp) {
+ onlyOp.disabled = true;
+ onlyOp.classList.remove('active');
+ setPressed(onlyOp, false);
+ setLabel(onlyOp, '只看楼主');
+ }
+ if (onlyOpProgress) {
+ onlyOpProgress.hidden = true;
+ onlyOpProgress.dataset.state = '';
+ onlyOpProgress.style.removeProperty('--ldp-only-op-progress');
+ }
+ if (nativeOpen) {
+ nativeOpen.hidden = true;
+ nativeOpen.removeAttribute('href');
+ }
+ body.scrollTop = 0;
+ }
+
+ /* ============ 13. 弹窗主体 + 循环泵加载 ============ */
+ let CURRENT_OVERLAY = null;
+ restoreReaderQueueState();
+ let READER_SESSION_SEQUENCE = 0;
+ let READER_OPEN_REQUEST_SEQUENCE = 0;
+ let ACTIVE_READER_SESSION_ID = 0;
+ let HOST_DOCUMENT_TITLE;
+ let AUTO_OPEN_SUPPRESSED_TOPIC_ID = bypassReaderForThisTab ? String(INITIAL_TOPIC_ID || '') : '';
+ let DISCOURSE_ROUTE_HANDLER_INSTALLED = false;
+ let DISCOURSE_UPDATE_EVENT_MONITOR = null;
+ let LAST_READER_TOPIC_ROUTE_KEY = '';
+
+ function syncReaderDocumentTitle(topicTitle) {
+ const title = String(topicTitle || '').trim();
+ if (!title) return;
+ const documentTitle = lookupDiscourse('service:document-title');
+ if (!documentTitle || !isFunction(documentTitle.setTitle)) return;
+ if (HOST_DOCUMENT_TITLE === undefined)
+ HOST_DOCUMENT_TITLE = isFunction(documentTitle.getTitle)
+ ? documentTitle.getTitle()
+ : null;
+ documentTitle.setTitle(title);
+ }
+
+ function restoreHostDocumentTitle() {
+ if (HOST_DOCUMENT_TITLE === undefined) return;
+ const documentTitle = lookupDiscourse('service:document-title');
+ if (isFunction(documentTitle?.setTitle))
+ documentTitle.setTitle(HOST_DOCUMENT_TITLE);
+ HOST_DOCUMENT_TITLE = undefined;
+ }
+
+ function currentHostRouteKey() {
+ return `${location.pathname}${location.search}${location.hash}`;
+ }
+
+ function embeddedReaderViewportState(ctx) {
+ if (!ctx || !ctx.scrollRoot) return null;
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const visible = [...ctx.streamMountedNodes || []]
+ .filter((node) => node?.isConnected && +node.dataset.postNumber > 0)
+ .map((node) => ({ node, rect: node.getBoundingClientRect() }))
+ .filter((item) => item.rect.bottom > rootRect.top && item.rect.top < rootRect.bottom)
+ .sort((left, right) => left.rect.top - right.rect.top)[0];
+ return {
+ postNumber: visible ? +visible.node.dataset.postNumber : 0,
+ postOffset: visible ? visible.rect.top - rootRect.top : 0,
+ scrollTop: Math.max(0, Number(ctx.scrollRoot.scrollTop) || 0),
+ };
+ }
+
+ function embeddedReaderPostViewportState(ctx, postNumber) {
+ const safePostNumber = Math.max(0, Number(postNumber) || 0);
+ if (!ctx || !ctx.scrollRoot || !safePostNumber) return null;
+ const node = ctx.streamNodeMap?.get(safePostNumber);
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const postOffset = node?.isConnected
+ ? node.getBoundingClientRect().top - rootRect.top
+ : 0;
+ return {
+ postNumber: safePostNumber,
+ postOffset,
+ scrollTop: Math.max(0, Number(ctx.scrollRoot.scrollTop) || 0),
+ };
+ }
+
+ function saveEmbeddedReaderReloadState() {
+ const overlay = CURRENT_OVERLAY;
+ const readerShell = overlay?._ldpReaderShell;
+ const readerWorkspace = readerShell?.readerWorkspace;
+ const ctx = readerShell?.activeContext;
+ if (!overlay || !readerWorkspace || !readerWorkspace.isEmbedded() || !ctx || !ctx.readerSession ||
+ !ctx.readerSession.isCurrent('opening', 'active')) return;
+ const anchor = captureReaderAnchorState(overlay, readerShell.targetPostNumber);
+ if (!anchor) return;
+ const state = {
+ savedAt: Date.now(),
+ hostRoute: currentHostRouteKey(),
+ mode: readerWorkspace.mode(),
+ topicId: Number(overlay.dataset.topicId || ctx.topicId) || 0,
+ anchor,
+ onlyOp: ctx.onlyOp === true,
+ topicNavMetadata: readerShell.topicNavMetadata || null,
+ };
+ if (!state.topicId) return;
+ try { sessionStorage.setItem(LDP_EMBEDDED_RELOAD_STATE_KEY, JSON.stringify(state)); } catch {}
+ }
+
+ function readEmbeddedReaderReloadState({ consume = false } = {}) {
+ let state;
+ try {
+ state = JSON.parse(sessionStorage.getItem(LDP_EMBEDDED_RELOAD_STATE_KEY) || 'null');
+ if (consume) sessionStorage.removeItem(LDP_EMBEDDED_RELOAD_STATE_KEY);
+ } catch {
+ if (consume) {
+ try { sessionStorage.removeItem(LDP_EMBEDDED_RELOAD_STATE_KEY); } catch {}
+ }
+ return null;
+ }
+ const navigationEntry = performance.getEntriesByType && performance.getEntriesByType('navigation')[0];
+ const reloaded = navigationEntry ? navigationEntry.type === 'reload' : performance.navigation?.type === 1;
+ if (!reloaded || !state || Date.now() - Number(state.savedAt || 0) > LDP_EMBEDDED_RELOAD_STATE_TTL ||
+ state.hostRoute !== currentHostRouteKey() || !readerPresentation(state.mode).embedded || !(Number(state.topicId) > 0))
+ return null;
+ return state;
+ }
+
+ async function restoreEmbeddedReaderAfterReload() {
+ const state = readEmbeddedReaderReloadState({ consume: true });
+ if (!state) return false;
+ const anchor = normalizeReaderAnchorState(state.anchor);
+ if (!anchor) return false;
+ setPref('listReaderMode', state.mode);
+ await openModal(state.topicId, {
+ targetPostNumber: anchor.viewport.postNumber,
+ topicNavMetadata: state.topicNavMetadata || undefined,
+ restoreOnlyOp: state.onlyOp === true,
+ restoreState: anchor,
+ });
+ return true;
+ }
+
+ function timelineMonthLabel(createdAt) {
+ const date = new Date(createdAt || 0);
+ if (!Number.isFinite(date.getTime())) return '';
+ return `${date.getFullYear()} 年\n${date.getMonth() + 1} 月 ${date.getDate()} 日`;
+ }
+
+ function bindTopicTimeline(timeline, ctx) {
+ const trackTopInset = 10;
+ const trackBottomInset = 10;
+ const track = timeline.querySelector('.ldp-topic-timeline-track');
+ const dateEl = timeline.querySelector('.ldp-topic-timeline-date');
+ const currentEl = timeline.querySelector('.ldp-topic-timeline-current');
+ const totalEl = timeline.querySelector('.ldp-topic-timeline-total');
+ const relativeEl = timeline.querySelector('.ldp-topic-timeline-relative');
+ const previewEl = timeline.querySelector('.ldp-topic-timeline-preview');
+ const cursorEl = timeline.querySelector('.ldp-topic-timeline-cursor');
+ const floorJumpTrigger = timeline.querySelector('.ldp-topic-timeline-jump');
+ const floorJumpForm = timeline.querySelector('.ldp-topic-timeline-jump-form');
+ const floorJumpInput = timeline.querySelector('.ldp-topic-timeline-jump-input');
+ const floorJumpHint = timeline.querySelector('.ldp-topic-timeline-jump-hint');
+ const floorJumpSubmit = timeline.querySelector('.ldp-topic-timeline-jump-submit');
+ const lensFloorElements = new Map();
+ const spareLensFloorElements = [];
+ const timelineScope = createLifecycleScope();
+ let renderedCurrent = 0;
+ let renderedDisplayCurrent = 0;
+ let renderedTotal = 0;
+ let renderedProgress = NaN;
+ let renderedDateText = null;
+ let renderedRelativeText = null;
+ let renderedRelativeDisabled = null;
+ let renderedTimelineHidden = null;
+ let frame = 0;
+ let previewFrame = 0;
+ let jumpAnimationFrame = 0;
+ let jumpAnimationTimer = 0;
+ let previewClientY = 0;
+ let previewVisible = false;
+ let previewTarget = 0;
+ let dragging = false;
+ let activePointerId = 0;
+ let dragTarget = 1;
+ let stablePostNumber = 0;
+ let stableCreatedAt = '';
+ let lensTargetPostNumber = 0;
+ let jumpSequence = 0;
+ let timelineTrackRect = null;
+ let currentDisplayOverride = 0;
+
+ const totalPostCount = () => Math.max(1, ctx.totalPosts || ctx.streamItems.length || 1);
+ const syncFloorJumpInputLimit = () => {
+ const total = totalPostCount();
+ floorJumpInput.maxLength = String(total).length + 1;
+ setLabel(floorJumpInput, `楼层号,范围 1 到 ${total}`);
+ return total;
+ };
+ const validateFloorJumpInput = () => {
+ const rawValue = floorJumpInput.value;
+ const total = syncFloorJumpInputLimit();
+ let value = 0;
+ let message = '';
+ if (!rawValue) message = `请输入楼层号(1–${total})`;
+ else if (!/^\d+$/.test(rawValue)) message = '仅支持十进制整数';
+ else {
+ value = Number(rawValue);
+ if (!Number.isSafeInteger(value)) message = '楼层号数值过大';
+ else if (value < 1 || value > total) message = `超出范围,请输入 1–${total}`;
+ }
+ floorJumpInput.setAttribute('aria-invalid', String(!!message));
+ floorJumpForm.classList.toggle('is-invalid', !!message);
+ floorJumpHint.textContent = message || `有效范围:1–${total}`;
+ floorJumpSubmit.disabled = !!message;
+ return message ? 0 : value;
+ };
+ const onlyOpTimelineItems = () => ctx.onlyOp ? streamLayoutItems(ctx) : null;
+ const progressForPost = (postNumber) => {
+ const items = onlyOpTimelineItems();
+ if (items?.length) {
+ const item = ctx.streamItemMap.get(+postNumber);
+ const index = item && streamItemMatchesFilter(ctx, item) && Number.isInteger(item.layoutIndex)
+ ? item.layoutIndex
+ : -1;
+ if (index >= 0) return items.length > 1 ? index / (items.length - 1) : 0;
+ }
+ const total = totalPostCount();
+ const current = Math.max(1, Math.min(total, +postNumber || 1));
+ return total > 1 ? (current - 1) / (total - 1) : 0;
+ };
+ const targetFromRatio = (ratio) => {
+ const items = onlyOpTimelineItems();
+ if (items?.length) {
+ const index = Math.round(ratio * (items.length - 1));
+ return +items[Math.max(0, Math.min(items.length - 1, index))].postNumber;
+ }
+ const total = totalPostCount();
+ return Math.max(1, Math.min(total, Math.round(1 + ratio * (total - 1))));
+ };
+ const targetFromKey = (current, delta) => {
+ const items = onlyOpTimelineItems();
+ if (items?.length) {
+ const item = ctx.streamItemMap.get(+current);
+ const index = item && streamItemMatchesFilter(ctx, item) && Number.isInteger(item.layoutIndex)
+ ? item.layoutIndex
+ : 0;
+ return +items[Math.max(0, Math.min(items.length - 1, index + delta))].postNumber;
+ }
+ const total = totalPostCount();
+ return Math.max(1, Math.min(total, current + delta));
+ };
+ const refreshTimelineTrackRect = () => {
+ timelineTrackRect = track.getBoundingClientRect();
+ return timelineTrackRect;
+ };
+ const timelineTrackMetrics = () => {
+ const rect = timelineTrackRect || refreshTimelineTrackRect();
+ return {
+ rect,
+ usableHeight: Math.max(1, rect.height - trackTopInset - trackBottomInset),
+ };
+ };
+ const timelineLensTopAtRatio = (ratio) => {
+ const { usableHeight } = timelineTrackMetrics();
+ return trackTopInset + Math.max(0, Math.min(1, ratio)) * usableHeight;
+ };
+ const timelineLensLayout = () => {
+ const items = onlyOpTimelineItems();
+ if (items?.length)
+ return { length: items.length, floorAt: (index) => +items[index].postNumber };
+ return { length: totalPostCount(), floorAt: (index) => index + 1 };
+ };
+ const timelineLensProfile = (offset) => {
+ const distance = Math.abs(offset);
+ const focus = Math.exp(-0.72 * distance * distance);
+ return {
+ opacity: Math.pow(Math.max(0, 1 - distance / 3), 1.25),
+ scale: 0.46 + 0.54 * focus,
+ shiftX: -Math.min(6, distance * 2),
+ };
+ };
+ const renderTimelineLensAtRatio = (ratio, lensTop = timelineLensTopAtRatio(ratio)) => {
+ if (!cursorEl) return;
+ cursorEl.style.setProperty('--ldp-timeline-lens-top', `${lensTop.toFixed(2)}px`);
+ const layout = timelineLensLayout();
+ const continuousIndex = Math.max(0, Math.min(layout.length - 1, ratio * (layout.length - 1)));
+ const nearestIndex = Math.round(continuousIndex);
+ const selectedFloor = layout.floorAt(nearestIndex);
+ const firstIndex = Math.max(0, nearestIndex - 3);
+ const lastIndex = Math.min(layout.length - 1, nearestIndex + 3);
+ const desiredFloors = new Map();
+ for (let index = firstIndex; index <= lastIndex; index++) {
+ desiredFloors.set(layout.floorAt(index), index - continuousIndex);
+ }
+ lensFloorElements.forEach((element, floor) => {
+ if (desiredFloors.has(floor)) return;
+ element.hidden = true;
+ lensFloorElements.delete(floor);
+ spareLensFloorElements.push(element);
+ });
+ desiredFloors.forEach((offset, floor) => {
+ let element = lensFloorElements.get(floor);
+ if (!element) {
+ element = spareLensFloorElements.pop() || makeElement('span');
+ element.textContent = `#${floor}`;
+ lensFloorElements.set(floor, element);
+ if (!element.isConnected) cursorEl.append(element);
+ element.hidden = false;
+ }
+ const profile = timelineLensProfile(offset);
+ element.style.setProperty('--ldp-lens-offset', String(offset));
+ element.style.setProperty('--ldp-lens-scale', profile.scale.toFixed(4));
+ element.style.setProperty('--ldp-lens-opacity', profile.opacity.toFixed(4));
+ element.style.setProperty('--ldp-lens-shift-x', `${profile.shiftX.toFixed(2)}px`);
+ element.classList.toggle('ldp-timeline-lens-selected', floor === selectedFloor);
+ element.classList.toggle('ldp-timeline-lens-focus', Math.abs(offset) < 0.001);
+ });
+ lensTargetPostNumber = selectedFloor || lensTargetPostNumber;
+ };
+
+ const renderPosition = (postNumber, createdAt = '') => {
+ const total = totalPostCount();
+ const current = Math.max(1, Math.min(total, +postNumber || 1));
+ const displayCurrent = currentDisplayOverride || current;
+ const progress = progressForPost(current);
+ const currentChanged = renderedCurrent !== current;
+ const displayCurrentChanged = renderedDisplayCurrent !== displayCurrent;
+ const totalChanged = renderedTotal !== total;
+ if (renderedProgress !== progress) {
+ renderedProgress = progress;
+ timeline.style.setProperty('--ldp-timeline-progress', String(progress));
+ }
+ if (displayCurrentChanged) currentEl.textContent = String(displayCurrent);
+ if (totalChanged) totalEl.textContent = String(total);
+ if (totalChanged) {
+ syncFloorJumpInputLimit();
+ if (!floorJumpForm.hidden) validateFloorJumpInput();
+ }
+ if (displayCurrentChanged || totalChanged) {
+ setLabel(floorJumpTrigger, `跳到指定楼层,当前第 ${displayCurrent} 楼,共 ${total} 楼`);
+ }
+ const dateText = timelineMonthLabel(createdAt);
+ if (renderedDateText !== dateText) {
+ renderedDateText = dateText;
+ dateEl.textContent = dateText;
+ dateEl.disabled = !dateText;
+ }
+ const latestReplyAt = ctx.latestReplyAt || (ctx.topicData?.last_posted_at) || '';
+ const relativeText = latestReplyAt ? discourseRelativeTime(latestReplyAt) : '';
+ const relativeDisabled = !latestReplyAt;
+ if (renderedRelativeText !== relativeText || renderedRelativeDisabled !== relativeDisabled) {
+ renderedRelativeText = relativeText;
+ renderedRelativeDisabled = relativeDisabled;
+ relativeEl.textContent = relativeText;
+ relativeEl.disabled = relativeDisabled;
+ }
+ if (!renderedCurrent && !renderedTotal) track.setAttribute('aria-valuemin', '1');
+ if (totalChanged) track.setAttribute('aria-valuemax', String(total));
+ if (currentChanged) track.setAttribute('aria-valuenow', String(current));
+ if (currentChanged || totalChanged) {
+ track.setAttribute('aria-valuetext', `第 ${current} 楼,共 ${total} 楼`);
+ }
+ if (ctx.queueViewportTracker) ctx.queueViewportTracker.sync(current);
+ if (previewVisible) {
+ const previewing = previewTarget > 0 && previewTarget !== current;
+ if (track.classList.contains('ldp-timeline-previewing') !== previewing) {
+ track.classList.toggle('ldp-timeline-previewing', previewing);
+ }
+ }
+ const timelineHidden = total <= 1;
+ if (renderedTimelineHidden !== timelineHidden) {
+ renderedTimelineHidden = timelineHidden;
+ timeline.hidden = timelineHidden;
+ }
+ renderedCurrent = current;
+ renderedDisplayCurrent = displayCurrent;
+ renderedTotal = total;
+ };
+
+ const renderStablePosition = (postNumber, createdAt = '') => {
+ stablePostNumber = +postNumber || stablePostNumber;
+ stableCreatedAt = createdAt || stableCreatedAt;
+ renderPosition(stablePostNumber, stableCreatedAt);
+ };
+
+ const renderBoundaryPosition = (postNumber) => {
+ const node = ctx.streamNodeMap.get(postNumber);
+ const createdAt = (node && node.dataset.createdAt) ||
+ (postNumber === 1 ? ctx.topicData?.created_at : (ctx.latestReplyAt || ctx.topicData?.last_posted_at)) || '';
+ renderStablePosition(postNumber, createdAt);
+ };
+
+ const updateNow = () => {
+ frame = 0;
+ currentDisplayOverride = 0;
+ if (!ctx.streamItems.length) {
+ timeline.hidden = true;
+ return;
+ }
+ const atScrollStart = ctx.scrollRoot.scrollTop <= 10;
+ const lastLoadedPostNumber = +(ctx.streamItems[ctx.streamItems.length - 1]?.postNumber || 0);
+ if (atScrollStart && ctx.streamItemMap.has(1)) {
+ renderBoundaryPosition(1);
+ return;
+ }
+ const layoutItems = streamLayoutItems(ctx);
+ const streamHeight = ctx.streamPrefix[layoutItems.length] || 0;
+ const atScrollEnd = ctx.scrollRoot.scrollTop + ctx.scrollRoot.clientHeight >=
+ ctx.scrollRoot.scrollHeight - 16;
+ const atStreamEnd = +(ctx.streamViewportLocalTop || 0) +
+ +(ctx.streamViewportHeight || 0) >= streamHeight - 16;
+ const hasLoadedEnd = lastLoadedPostNumber >= Math.max(1, ctx.totalPosts || 1);
+ if (atScrollEnd && hasLoadedEnd) currentDisplayOverride = totalPostCount();
+ const atLoadedEnd = atStreamEnd && hasLoadedEnd;
+ if (atLoadedEnd) {
+ const lastTimelineItem = ctx.onlyOp ? layoutItems[layoutItems.length - 1] : null;
+ renderBoundaryPosition(lastTimelineItem ? lastTimelineItem.postNumber : totalPostCount());
+ return;
+ }
+ const viewportItem = layoutItems[Math.max(
+ 0,
+ Math.min(layoutItems.length - 1, +(ctx.streamViewportTopIndex || 0)),
+ )];
+ const indexedOwnerNode = viewportItem?.node?.isConnected ? viewportItem.node : null;
+ const anchoredOwnerNode = ctx.streamViewportAnchor?.owner;
+ const visibleAnchor = indexedOwnerNode || anchoredOwnerNode?.isConnected
+ ? null
+ : findTopVisibleStreamPost(ctx);
+ const visibleOwnerNode = indexedOwnerNode ||
+ (anchoredOwnerNode?.isConnected ? anchoredOwnerNode : visibleAnchor?.ownerNode);
+ const visiblePostNumber = streamPostNumber(visibleOwnerNode);
+ const visibleItem = visiblePostNumber && ctx.streamItemMap.get(visiblePostNumber);
+ if (visiblePostNumber && (!ctx.onlyOp || streamItemMatchesFilter(ctx, visibleItem))) {
+ renderStablePosition(visiblePostNumber, visibleOwnerNode.dataset.createdAt || '');
+ return;
+ }
+ if (stablePostNumber) renderPosition(stablePostNumber, stableCreatedAt);
+ };
+ const update = () => {
+ if (frame || dragging) return;
+ frame = requestAnimationFrame(updateNow);
+ };
+ const sync = () => {
+ if (dragging) return;
+ if (frame) cancelAnimationFrame(frame);
+ frame = 0;
+ updateNow();
+ };
+ const releaseJump = () => {
+ jumpSequence++;
+ cancelReaderNavigation(ctx);
+ update();
+ };
+ const refreshRelativeTimes = () => refreshPostRelativeTimes(ctx.commentsEl);
+ let relativeTimer = 0;
+ const setActive = (active) => {
+ if (relativeTimer) clearInterval(relativeTimer);
+ relativeTimer = 0;
+ if (!active) {
+ if (frame) cancelAnimationFrame(frame);
+ frame = 0;
+ return;
+ }
+ relativeTimer = setInterval(() => {
+ update();
+ refreshRelativeTimes();
+ }, 30000);
+ };
+ setActive(document.visibilityState === 'visible');
+
+ const timelinePointerPosition = (clientY) => {
+ const { rect, usableHeight } = timelineTrackMetrics();
+ const top = Math.max(trackTopInset, Math.min(rect.height - trackBottomInset, clientY - rect.top));
+ return { top, ratio: Math.max(0, Math.min(1, (top - trackTopInset) / usableHeight)) };
+ };
+ const targetFromPointer = (clientY) => targetFromRatio(timelinePointerPosition(clientY).ratio);
+ const showPointerPreview = (clientY) => {
+ previewVisible = true;
+ previewClientY = clientY;
+ if (previewFrame) return;
+ previewFrame = requestAnimationFrame(() => {
+ previewFrame = 0;
+ if (!previewVisible) return;
+ const pointerPosition = timelinePointerPosition(previewClientY);
+ const target = targetFromRatio(pointerPosition.ratio);
+ const progress = pointerPosition.ratio;
+ const current = +(track.getAttribute('aria-valuenow') || 0);
+ const targetChanged = previewTarget !== target;
+ previewTarget = target;
+ renderTimelineLensAtRatio(progress, pointerPosition.top);
+ if (dragging) previewPointerTarget(previewClientY, target);
+ if (targetChanged) previewEl.textContent = `#${target}`;
+ const previewing = target !== current;
+ if (track.classList.contains('ldp-timeline-previewing') !== previewing) {
+ track.classList.toggle('ldp-timeline-previewing', previewing);
+ }
+ if (!track.classList.contains('ldp-timeline-hovering')) track.classList.add('ldp-timeline-hovering');
+ });
+ };
+ const hidePointerPreview = () => {
+ previewVisible = false;
+ previewTarget = 0;
+ if (previewFrame) cancelAnimationFrame(previewFrame);
+ previewFrame = 0;
+ timelineTrackRect = null;
+ track.classList.remove('ldp-timeline-previewing', 'ldp-timeline-hovering');
+ };
+ const finishTimelineJumpAnimation = () => {
+ if (jumpAnimationFrame) cancelAnimationFrame(jumpAnimationFrame);
+ if (jumpAnimationTimer) clearTimeout(jumpAnimationTimer);
+ jumpAnimationFrame = 0;
+ jumpAnimationTimer = 0;
+ track.classList.remove('ldp-timeline-jumping');
+ if (previewVisible) showPointerPreview(previewClientY);
+ };
+ const beginTimelineJumpAnimation = (targetPostNumber) => {
+ const reducedMotion = prefersReducedMotion();
+ if (reducedMotion || !cursorEl) return;
+ if (jumpAnimationFrame) cancelAnimationFrame(jumpAnimationFrame);
+ if (jumpAnimationTimer) clearTimeout(jumpAnimationTimer);
+ jumpAnimationFrame = 0;
+ jumpAnimationTimer = 0;
+ const currentPostNumber = stablePostNumber || +(track.getAttribute('aria-valuenow') || 1);
+ const alreadyFocusedOnTarget = previewVisible && lensTargetPostNumber === targetPostNumber;
+ const currentProgress = progressForPost(currentPostNumber);
+ const targetProgress = progressForPost(targetPostNumber);
+ if (!alreadyFocusedOnTarget) {
+ renderTimelineLensAtRatio(currentProgress);
+ }
+ track.classList.remove('ldp-timeline-jumping');
+ void cursorEl.offsetWidth;
+ track.classList.add('ldp-timeline-jumping');
+ const shouldTravel = !alreadyFocusedOnTarget && Math.abs(targetProgress - currentProgress) > 0.0001;
+ if (!shouldTravel) {
+ jumpAnimationFrame = requestAnimationFrame(() => {
+ jumpAnimationFrame = 0;
+ renderTimelineLensAtRatio(targetProgress);
+ jumpAnimationTimer = setTimeout(finishTimelineJumpAnimation, 460);
+ });
+ return;
+ }
+ const duration = 360;
+ let startedAt = 0;
+ const animateJump = (timestamp) => {
+ if (!startedAt) startedAt = timestamp;
+ const elapsedRatio = Math.min(1, (timestamp - startedAt) / duration);
+ const easedRatio = 1 - Math.pow(1 - elapsedRatio, 3);
+ const progress = currentProgress + (targetProgress - currentProgress) * easedRatio;
+ renderTimelineLensAtRatio(progress);
+ if (elapsedRatio < 1) {
+ jumpAnimationFrame = requestAnimationFrame(animateJump);
+ return;
+ }
+ jumpAnimationFrame = 0;
+ jumpAnimationTimer = setTimeout(finishTimelineJumpAnimation, 300);
+ };
+ jumpAnimationFrame = requestAnimationFrame(animateJump);
+ };
+ const previewPointerTarget = (clientY, targetPostNumber = targetFromPointer(clientY)) => {
+ if (dragTarget === targetPostNumber) return;
+ dragTarget = targetPostNumber;
+ const node = ctx.streamNodeMap.get(dragTarget);
+ renderPosition(dragTarget, node ? node.dataset.createdAt : '');
+ };
+ const settleVisibleJumpTarget = async (targetPostNumber, visualTarget, scrollOptions, acceptsWork) => {
+ const quietTime = Number(scrollOptions.settleTime) || 0;
+ const maxAttempts = quietTime ? 90 : 8;
+ let stableAt = 0;
+ let stableFrames = 0;
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ await waitForReaderJumpLayout();
+ if (!acceptsWork()) return null;
+ const settledPost = getStreamPostNode(ctx, targetPostNumber, { mount: true });
+ const settledTarget = visualTarget?.isConnected ? visualTarget : settledPost;
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ const targetRect = settledTarget?.getBoundingClientRect();
+ const framed = targetRect && (targetRect.height <= rootRect.height
+ ? targetRect.top >= rootRect.top - 1 && targetRect.bottom <= rootRect.bottom + 1
+ : targetRect.top >= rootRect.top - 1 && targetRect.top < rootRect.bottom - 1);
+ if (framed) {
+ const now = performance.now();
+ if (!stableAt) stableAt = now;
+ if (quietTime ? now - stableAt >= quietTime : ++stableFrames >= 2)
+ return { settledPost, settledTarget };
+ continue;
+ }
+ stableAt = 0;
+ stableFrames = 0;
+ if (!scrollToPost(targetPostNumber, ctx, scrollOptions)) return null;
+ }
+ return null;
+ };
+ const jumpTo = async (targetPostNumber, options = {}) => {
+ targetPostNumber = +targetPostNumber || 0;
+ if (!targetPostNumber) return false;
+ const returnViewport = PREFS.aggregateDescendantReplies
+ ? embeddedReaderViewportState(ctx)
+ : null;
+ beginTimelineJumpAnimation(targetPostNumber);
+ const currentJumpSequence = ++jumpSequence;
+ const navigationSequence = beginReaderNavigation(ctx);
+ const jumpStillCurrent = () => currentJumpSequence === jumpSequence &&
+ readerNavigationIsCurrent(ctx, navigationSequence) &&
+ (!isFunction(options.acceptsWork) || options.acceptsWork());
+ if (!jumpStillCurrent()) return false;
+ if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition();
+ const suppliedTargetPost = +options.targetPost?.post_number === targetPostNumber
+ ? options.targetPost
+ : null;
+ const suppliedTargetIsNested = +(suppliedTargetPost?.reply_to_post_number || 0) > 0;
+ const loaded = suppliedTargetIsNested || !ctx.loadPost || await ctx.loadPost(targetPostNumber, {
+ ensureAround: targetPostNumber > 1,
+ scope: targetPostNumber > 1 ? 'around' : 'single',
+ });
+ if (!jumpStillCurrent()) return false;
+ if (!loaded) {
+ update();
+ return false;
+ }
+ if (!jumpStillCurrent()) return false;
+ const hiddenTarget = ctx.streamNodeMap.get(targetPostNumber);
+ const targetPost = hiddenTarget?._ldpPostData || suppliedTargetPost;
+ const nestedTarget = +(targetPost?.reply_to_post_number || 0) > 0;
+ if (nestedTarget) {
+ const opened = PREFS.aggregateDescendantReplies && options.forceNested !== true
+ ? await openAggregateReplyTarget(targetPostNumber, ctx, { returnViewport })
+ : await focusLiveNestedReply(targetPost, ctx, {
+ navigationSequence,
+ acceptsWork: options.acceptsWork,
+ });
+ if (!jumpStillCurrent()) return false;
+ sync();
+ return opened;
+ }
+ if (hiddenTarget?.hidden) {
+ hiddenTarget.hidden = false;
+ hiddenTarget.dataset.ldpJumpExpanded = '1';
+ const item = ctx.streamItemMap.get(targetPostNumber);
+ if (item) item.height = Math.max(STREAM_ESTIMATED_HEIGHT, postContentPlaceholderHeight(hiddenTarget._ldpPostData) + 72);
+ setNestedReplyCollapsed(hiddenTarget, false);
+ markStreamPrefixDirty(ctx, Math.max(0, +(item?.index || 0)));
+ flushStreamWindow(ctx);
+ }
+ let visualTarget = null;
+ const scrollOptions = {
+ ...PINNED_STREAM_SCROLL_OPTIONS,
+ settleTime: 1000,
+ resolveVisualTarget: isFunction(options.prepareTarget)
+ ? () => {
+ visualTarget = options.prepareTarget(targetPostNumber);
+ return visualTarget;
+ }
+ : null,
+ alignCenter: options.alignCenter === true,
+ alignNearest: options.alignNearest === true,
+ alignTop: options.alignBottom !== true,
+ alignBottom: options.alignBottom === true,
+ highlight: false,
+ };
+ if (!scrollToPost(targetPostNumber, ctx, scrollOptions)) {
+ update();
+ return false;
+ }
+ const settled = await settleVisibleJumpTarget(
+ targetPostNumber,
+ visualTarget,
+ scrollOptions,
+ jumpStillCurrent
+ );
+ if (!settled) {
+ clearStreamProgrammaticTarget(ctx);
+ update();
+ return false;
+ }
+ if (options.highlight !== false) keepJumpHighlightUntilSettled(settled.settledPost);
+ sync();
+ return true;
+ };
+ const jumpToBoundary = async (targetPostNumber, options = {}) => {
+ const sequenceBeforeAttempt = jumpSequence;
+ let jumped = await jumpTo(targetPostNumber, options);
+ if (jumped) return true;
+ const attemptWasNotCancelled = jumpSequence === sequenceBeforeAttempt + 1 &&
+ (!isFunction(options.acceptsWork) || options.acceptsWork());
+ if (!attemptWasNotCancelled) return false;
+ ctx.streamWindowResetPending = true;
+ syncStreamWindow(ctx);
+ await waitForReaderJumpLayout();
+ if (jumpSequence !== sequenceBeforeAttempt + 1) return false;
+ jumped = await jumpTo(targetPostNumber, options);
+ return jumped;
+ };
+ const completeStartJump = async (options = {}) => {
+ if (!await jumpToBoundary(1, options)) return false;
+ const completedJumpSequence = jumpSequence;
+ ctx.streamWindowResetPending = true;
+ const scrollToStart = () => {
+ const previousScrollTop = ctx.scrollRoot.scrollTop;
+ ctx.scrollRoot.scrollTop = 0;
+ markStreamInternalScroll(ctx, previousScrollTop);
+ };
+ scrollToStart();
+ syncStreamWindow(ctx);
+ await waitForReaderJumpLayout();
+ if (completedJumpSequence !== jumpSequence) return false;
+ scrollToStart();
+ renderBoundaryPosition(1);
+ return true;
+ };
+ let startJumpPromise = null;
+ const jumpToStart = (options = {}) => {
+ if (startJumpPromise) return startJumpPromise;
+ startJumpPromise = completeStartJump(options)
+ .finally(() => { startJumpPromise = null; });
+ return startJumpPromise;
+ };
+ const latestVisibleTimelineItem = (items) => (items || []).reduce((latest, item) => {
+ if (!streamItemParticipatesInZebra(item)) return latest;
+ return !latest || item.postNumber > latest.postNumber ? item : latest;
+ }, null);
+ let endJumpPromise = null;
+ const jumpToEnd = () => {
+ if (endJumpPromise) return endJumpPromise;
+ endJumpPromise = (async () => {
+ const items = onlyOpTimelineItems();
+ let targetItem = latestVisibleTimelineItem(items);
+ if (!items && ctx.loader && isFunction(ctx.loader.loadLastPost)) {
+ try {
+ let boundaryPosts = await ctx.loader.loadLastPost({ priority: POST_REQUEST_PRIORITY.target });
+ let previousBoundary = Infinity;
+ while (boundaryPosts.length) {
+ attachPostBatch(
+ boundaryPosts.filter((post) => !ctx.streamNodeMap.has(+post.post_number)),
+ ctx
+ );
+ targetItem = latestVisibleTimelineItem(boundaryPosts.map((post) =>
+ ctx.streamItemMap.get(+post.post_number)).filter(Boolean));
+ if (targetItem) break;
+ const nextBoundary = boundaryPosts.reduce((earliest, post) =>
+ Math.min(earliest, +(post?.post_number || Infinity)), Infinity);
+ if (!isFunction(ctx.loader.loadBeforePost) || nextBoundary <= 1 ||
+ nextBoundary >= previousBoundary) break;
+ previousBoundary = nextBoundary;
+ boundaryPosts = await ctx.loader.loadBeforePost(nextBoundary, {
+ priority: POST_REQUEST_PRIORITY.target,
+ });
+ }
+ } catch {}
+ }
+ if (!targetItem) targetItem = latestVisibleTimelineItem(items || ctx.streamItems);
+ const target = +(targetItem?.postNumber || 1);
+ return jumpToBoundary(target, { alignBottom: true });
+ })().finally(() => { endJumpPromise = null; });
+ return endJumpPromise;
+ };
+ const onRelativeClick = async () => {
+ if (!await jumpToEnd()) showSelectionToast('末尾可见楼层加载失败,请重试');
+ };
+ const closeFloorJump = (restoreFocus = false) => {
+ if (floorJumpForm.hidden) return;
+ floorJumpForm.hidden = true;
+ setExpanded(floorJumpTrigger, false);
+ if (restoreFocus) floorJumpTrigger.focus();
+ };
+ const openFloorJump = () => {
+ hidePointerPreview();
+ const current = renderedDisplayCurrent || +(track.getAttribute('aria-valuenow') || 1);
+ floorJumpInput.value = String(current);
+ floorJumpForm.hidden = false;
+ setExpanded(floorJumpTrigger, true);
+ validateFloorJumpInput();
+ floorJumpInput.focus();
+ floorJumpInput.select();
+ };
+ const onFloorJumpSubmit = async (event) => {
+ event.preventDefault();
+ const target = validateFloorJumpInput();
+ if (!target) return;
+ closeFloorJump();
+ const current = +(track.getAttribute('aria-valuenow') || 1);
+ if (target === current) return;
+ try {
+ if (!await jumpTo(target)) showSelectionToast(`暂时无法定位到楼层 #${target},可重试`);
+ } catch (error) {
+ console.warn('[LINUX DO reader] floor jump failed', error);
+ showSelectionToast(`楼层 #${target} 加载失败,请重试`);
+ }
+ };
+ const onTrackPointerLeave = () => {
+ if (!dragging) hidePointerPreview();
+ };
+ const onTrackPointerDown = (e) => {
+ e.preventDefault();
+ currentDisplayOverride = 0;
+ dragging = true;
+ activePointerId = e.pointerId;
+ refreshTimelineTrackRect();
+ dragTarget = 0;
+ track.setPointerCapture(e.pointerId);
+ showPointerPreview(e.clientY);
+ };
+ const onTrackPointerUp = (e) => {
+ if (!dragging) return;
+ dragging = false;
+ if (track.hasPointerCapture(e.pointerId)) track.releasePointerCapture(e.pointerId);
+ activePointerId = 0;
+ dragTarget = targetFromPointer(e.clientY);
+ if (!track.matches(':hover')) hidePointerPreview();
+ jumpTo(dragTarget);
+ };
+ const onTrackPointerCancel = () => {
+ dragging = false;
+ activePointerId = 0;
+ hidePointerPreview();
+ update();
+ };
+ const onTrackKeyDown = (e) => {
+ const current = +(track.getAttribute('aria-valuenow') || 1);
+ let target;
+ if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') target = targetFromKey(current, -1);
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') target = targetFromKey(current, 1);
+ else if (e.key === 'PageUp') target = targetFromKey(current, -streamPerformance(ctx).pageSize);
+ else if (e.key === 'PageDown') target = targetFromKey(current, streamPerformance(ctx).pageSize);
+ else if (e.key === 'Home') target = targetFromKey(current, -Infinity);
+ else if (e.key === 'End') target = targetFromKey(current, Infinity);
+ else return;
+ e.preventDefault();
+ jumpTo(target);
+ };
+ timelineScope.listen(dateEl, 'click',
+ () => timeline.querySelector('.ldp-topic-timeline-top')?.click());
+ timelineScope.listen(relativeEl, 'click', onRelativeClick);
+ timelineScope.listen(floorJumpTrigger, 'click', () => {
+ if (floorJumpForm.hidden) openFloorJump();
+ else closeFloorJump(true);
+ });
+ timelineScope.listen(floorJumpInput, 'input', validateFloorJumpInput);
+ timelineScope.listen(floorJumpForm, 'submit', (event) => { void onFloorJumpSubmit(event); });
+ timelineScope.listen(floorJumpForm, 'keydown', (event) => {
+ if (event.key === 'Enter' && floorJumpSubmit.disabled) {
+ consumeEvent(event);
+ } else if (event.key === 'Escape') {
+ consumeEvent(event);
+ closeFloorJump(true);
+ }
+ });
+ timelineScope.listen(timeline.getRootNode(), 'pointerdown', (event) => {
+ if (floorJumpForm.hidden) return;
+ const path = event.composedPath();
+ if (!path.includes(floorJumpForm) && !path.includes(floorJumpTrigger)) closeFloorJump();
+ }, true);
+ timelineScope.listen(track, 'pointerenter',
+ (event) => {
+ refreshTimelineTrackRect();
+ showPointerPreview(event.clientY);
+ });
+ timelineScope.listen(track, 'pointerleave', onTrackPointerLeave);
+ timelineScope.listen(track, 'pointerdown', onTrackPointerDown);
+ timelineScope.listen(track, 'pointermove', (event) => {
+ if (previewVisible) showPointerPreview(event.clientY);
+ });
+ timelineScope.listen(track, 'pointerup', onTrackPointerUp);
+ timelineScope.listen(track, 'pointercancel', onTrackPointerCancel);
+ timelineScope.listen(track, 'keydown', onTrackKeyDown);
+
+ return {
+ update,
+ sync,
+ jumpTo,
+ jumpToStart,
+ releaseJump,
+ refreshRelativeTimes,
+ setActive,
+ destroy() {
+ if (frame) cancelAnimationFrame(frame);
+ if (previewFrame) cancelAnimationFrame(previewFrame);
+ finishTimelineJumpAnimation();
+ setActive(false);
+ closeFloorJump();
+ hidePointerPreview();
+ if (cursorEl) cursorEl.replaceChildren();
+ lensFloorElements.clear();
+ spareLensFloorElements.length = 0;
+ lensTargetPostNumber = 0;
+ if (activePointerId && track.hasPointerCapture && track.hasPointerCapture(activePointerId)) {
+ try { track.releasePointerCapture(activePointerId); } catch {}
+ }
+ dragging = false;
+ activePointerId = 0;
+ timelineScope.destroy();
+ },
+ };
+ }
+
+ async function openModal(topicId, options = {}) {
+ const openRequestId = ++READER_OPEN_REQUEST_SEQUENCE;
+ const previousQueueTopicId = Number(CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId) || 0;
+ const performance = Object.freeze(currentPerformanceConfig());
+ const autoRetryAttempt = Math.max(0, Number(options.autoRetryAttempt) || 0);
+ let targetPostNumber = +options.targetPostNumber || 0;
+ let aggregateTargetPostNumber = 0;
+ let unavailableTargetPostNumber = 0;
+ const refreshTargetBoosts = options.refreshTargetBoosts === true;
+ const restoreState = normalizeReaderAnchorState(options.restoreState);
+ const requestedQuoteHighlight = normalizeReaderQuoteHighlightState({
+ ...options.quoteHighlight,
+ postNumber: targetPostNumber,
+ });
+ const initialQuoteHighlight = requestedQuoteHighlight || restoreState?.quoteHighlight || null;
+ const initialQuoteSourceTarget = normalizeReaderQuoteSource(options.quoteSourceTarget);
+ if (!requestedQuoteHighlight && initialQuoteHighlight)
+ targetPostNumber = initialQuoteHighlight.postNumber;
+ let restoreViewport = restoreState?.viewport ||
+ (options.restoreViewport && typeof options.restoreViewport === 'object'
+ ? options.restoreViewport
+ : null);
+ const restoreOnlyOp = options.restoreOnlyOp === true;
+ const settleTargetJump = options.settleTargetJump !== false;
+ const directTopicRoute = !!extractTopicRouteFromUrl(location.href);
+ const requestedReaderMode = directTopicRoute ? currentTopicReaderMode() : currentListReaderMode();
+ const hostTopicPointerAnchor = options.hostTopicPointerAnchor || null;
+ const cachedTopicPreview = getCachedTopicData(topicId);
+ const reactionsAvailable = hasDiscourseCapability('reactions', { topic: cachedTopicPreview });
+ const boostsAvailable = hasDiscourseCapability('boosts');
+ const availableNotificationGroups = Object.entries(NOTIFICATION_GROUPS).filter(([key]) =>
+ (key !== 'reactions' || reactionsAvailable) && (key !== 'boosts' || boostsAvailable)
+ );
+ const bookmarkTabOrder = PREFS.bookmarkTabOrder.filter((type) =>
+ type !== 'Reaction' || reactionsAvailable
+ );
+ const bookmarkCollectionLabel = reactionsAvailable ? '收藏与回应' : '收藏';
+ let sourceTopicNavMetadata = mergeTopicNavMetadata(
+ options.topicNavMetadata || readTopicNavMetadata(null, topicId),
+ cachedTopicNavMetadata(cachedTopicPreview),
+ );
+ let queueEntry = readerQueueEntry(topicId);
+ if (queueEntry) {
+ queueEntry = ensureReaderQueueEntry(topicId, {
+ ...(options.readerQueueMetadata || {}),
+ navMetadata: sourceTopicNavMetadata,
+ }, { prefetch: false, silent: true });
+ }
+ const inheritedHistoryStates = normalizeReaderHistoryStates(
+ CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderHistoryNavigation?.states
+ );
+ const historyNavigation = createReaderHistoryNavigation(topicId, options.historyNavigation);
+ historyNavigation.states = { ...inheritedHistoryStates, ...historyNavigation.states };
+ let reusableOverlay = null;
+ if (CURRENT_OVERLAY) {
+ const previousOverlay = CURRENT_OVERLAY;
+ if (isFunction(previousOverlay._ldpCancelAutoRetryWait)) {
+ previousOverlay._ldpCancelAutoRetryWait();
+ }
+ const previousTopicId = Number(previousOverlay.dataset.topicId);
+ const previousState = captureReaderAnchorState(previousOverlay);
+ if (previousTopicId > 0 && previousState) {
+ historyNavigation.states[previousTopicId] = previousState;
+ saveReaderQueueAnchor(previousTopicId, previousState);
+ }
+ // 阅读器壳层与展示形态都不属于单篇帖子;同一路由上下文切换主题时统一复用。
+ const canReuseReaderShell =
+ previousOverlay.dataset.readerListModeAllowed === String(!directTopicRoute) &&
+ previousOverlay.dataset.readerShellDestroyed !== '1' &&
+ previousOverlay._ldpReaderSession && previousOverlay._ldpReaderSession.state !== 'failed' &&
+ isFunction(previousOverlay._ldpPrepareTopicSwitch);
+ if (canReuseReaderShell) {
+ await previousOverlay._ldpPrepareTopicSwitch();
+ if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE || CURRENT_OVERLAY !== previousOverlay || !previousOverlay.isConnected) return;
+ reusableOverlay = previousOverlay;
+ } else if (isFunction(previousOverlay._ldpDispose)) await previousOverlay._ldpDispose();
+ else {
+ if (isFunction(previousOverlay._ldpStopLatestReplyUpdates)) {
+ previousOverlay._ldpStopLatestReplyUpdates();
+ }
+ previousOverlay.remove();
+ if (CURRENT_OVERLAY === previousOverlay) CURRENT_OVERLAY = null;
+ setPageScrollLocked(false);
+ }
+ }
+ if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE) return;
+ if (PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active') &&
+ !await closeChromeSidePanelForReader()) return;
+ if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE) return;
+ if (queueEntry) activateReaderQueueEntry(queueEntry);
+ else {
+ READER_QUEUE_ACTIVE_TOPIC_ID = '';
+ syncReaderQueueSurfaces();
+ }
+ if (directTopicRoute) PAGE_ROOT.classList.add('ldp-route-takeover');
+ const readerSessionId = ++READER_SESSION_SEQUENCE;
+ const reusingShell = !!reusableOverlay;
+ const overlay = reusableOverlay || makeElement('div');
+ const readerDetachedSurfaceRoot = () => overlay._ldpReaderShell?.surfaceManager?.parking;
+ const readerElement = (selector) =>
+ overlay.querySelector(selector) || readerDetachedSurfaceRoot()?.querySelector(selector) || null;
+ const readerElements = (selector) => [
+ ...overlay.querySelectorAll(selector),
+ ...(readerDetachedSurfaceRoot()?.querySelectorAll(selector) || []),
+ ];
+ const readerElementGroup = (...selectors) => selectors.map(readerElement);
+ const readerElementsGroup = (...selectors) => selectors.map(readerElements);
+ let readerOpenPhase = 'create-overlay';
+ const setReaderOpenPhase = (phase) => {
+ readerOpenPhase = phase;
+ };
+ const readerSession = {
+ id: readerSessionId,
+ state: 'opening',
+ isCurrent(...allowedStates) {
+ if (ACTIVE_READER_SESSION_ID !== readerSessionId || CURRENT_OVERLAY !== overlay || !overlay.isConnected)
+ return false;
+ return !allowedStates.length || allowedStates.includes(this.state);
+ },
+ };
+ ACTIVE_READER_SESSION_ID = readerSessionId;
+ if (!reusingShell) {
+ overlay.className = 'ldp-overlay';
+ overlay.dataset.readerWorkspaceMode = 'floating';
+ overlay.dataset.readerListModeAllowed = String(!directTopicRoute);
+ overlay.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'].map((direction) =>
+ `
`).join('')}
+
+
+
+
+ 编辑帖子信息
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${createReaderLoadingHTML()}
+
+
+
${icon('loader', 'ldp-tip-icon')}正在加载楼层…
+
已经到底了~
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+ mountSettingsPanelIntros(overlay);
+ appendReaderPortalNode(overlay);
+ syncSiteLogoImages();
+ ensureReaderIconTooltip();
+ }
+ overlay.dataset.topicId = String(topicId);
+ CURRENT_OVERLAY = overlay;
+ guardNativeReaderTimings();
+ bindReaderQueueSurface(overlay);
+ syncReaderFontRenderingState();
+ overlay._ldpReaderHistoryNavigation = historyNavigation;
+ const disposeHistoryNavigation = bindReaderHistoryNavigation(overlay, topicId, historyNavigation);
+ overlay._ldpReaderSession = readerSession;
+ PAGE_ROOT.classList.add('ldp-reader-open');
+ overlay.dataset.targetPostNumber = targetPostNumber ? String(targetPostNumber) : '';
+ overlay.classList.toggle('ldp-initial-target-layout', targetPostNumber > 1);
+
+ const modal = readerElement('.ldp-modal'), body = readerElement('.ldp-body');
+ if (reusingShell) resetReaderTopicShell(overlay);
+ const readerShell = reusingShell ? overlay._ldpReaderShell : {};
+ const shellScope = readerShell.lifecycleScope || createLifecycleScope();
+ readerShell.lifecycleScope = shellScope;
+ const surfaceManager = readerShell.surfaceManager || createReaderSurfaceManager(overlay);
+ readerShell.surfaceManager = surfaceManager;
+ const onShell = (target, type, listener, options) => shellScope.listen(target, type, listener, options);
+ const shouldRestoreHostTopicAnchor = !!hostTopicPointerAnchor && !directTopicRoute &&
+ readerPresentation(requestedReaderMode).embedded;
+ if (shouldRestoreHostTopicAnchor) PAGE_ROOT.classList.add('ldp-reader-host-anchor-restoring');
+ const readerWorkspace = readerShell.readerWorkspace || createReaderWorkspaceController(overlay, modal, directTopicRoute);
+ readerWorkspace.sync();
+ readerShell.syncReaderTitleActionAlignment = () => {
+ const header = readerElement('.ldp-header');
+ const titleJump = header?.querySelector('.ldp-title-jump');
+ const titleActions = header?.querySelector(':scope > .ldp-title-actions');
+ if (!titleJump || !titleActions) return;
+ titleActions.style.setProperty('--ldp-title-actions-align-y', '0px');
+ const visibleControl = Array.from(titleActions.children)
+ .find((control) => !control.hidden && getComputedStyle(control).display !== 'none');
+ const actionIcon = visibleControl?.querySelector('.ldp-icon');
+ if (!actionIcon) return;
+ const shift = titleJump.getBoundingClientRect().top - actionIcon.getBoundingClientRect().top;
+ titleActions.style.setProperty('--ldp-title-actions-align-y',
+ `${Math.round(shift * 2) / 2}px`);
+ };
+ readerShell.syncFullPageHeaderAlignment = () => {
+ const header = readerElement('.ldp-header');
+ if (!header) return;
+ const titleActions = header.querySelector(':scope > .ldp-title-actions');
+ const headButtons = header.querySelector(':scope > .ldp-head-btns');
+ const content = readerElement('.ldp-comments');
+ const clearAlignment = () => {
+ header.style.removeProperty('--ldp-header-logo-inset');
+ header.style.removeProperty('padding-left');
+ header.style.removeProperty('padding-right');
+ titleActions?.style.removeProperty('right');
+ headButtons?.classList.remove('is-content-aligned');
+ };
+ if (!readerWorkspace.isFullPage() || !content) {
+ clearAlignment();
+ readerShell.syncReaderTitleActionAlignment();
+ return;
+ }
+ const modalRect = modal.getBoundingClientRect();
+ const contentRect = content.getBoundingClientRect();
+ if (!(modalRect.width > 0) || !(contentRect.width > 0)) return;
+ const left = Math.max(0, Math.round(contentRect.left - modalRect.left));
+ const right = Math.max(0, Math.round(modalRect.right - contentRect.right));
+ header.style.setProperty('--ldp-header-logo-inset', `${left}px`);
+ header.style.paddingLeft = `${left}px`;
+ header.style.paddingRight = `${right}px`;
+ if (titleActions) titleActions.style.right = `${right}px`;
+ headButtons?.classList.add('is-content-aligned');
+ readerShell.syncReaderTitleActionAlignment();
+ };
+ if (shouldRestoreHostTopicAnchor) {
+ if (readerWorkspace.isEmbedded()) restoreHostTopicPointerAnchor(hostTopicPointerAnchor);
+ shellScope.raf(() => PAGE_ROOT.classList.remove('ldp-reader-host-anchor-restoring'));
+ }
+ setPageScrollLocked(readerWorkspace.isFullPage() || window.innerWidth <= READER_WINDOW_COMPACT_WIDTH);
+ const readerCapsule = readerElement('.ldp-reader-window-capsule');
+ const readerPlacementControl = readerElement('.ldp-reader-placement-control');
+ const readerPlacementStrip = readerElement('.ldp-reader-placement-strip');
+ const readerPlacementOptions = readerElements('[data-reader-placement]');
+ const commentsEl = readerElement('.ldp-comments');
+ const commentsHeader = readerElement('.ldp-comments-header');
+ const countEl = readerElement('.ldp-comments-count'), emptyEl = readerElement('.ldp-comments-empty');
+ const maskEl = readerElement('.ldp-loadmask');
+ const loadingStage = maskEl?.querySelector('.ldp-loading-stage');
+ const loadingStatus = maskEl?.querySelector('.ldp-loading-status span');
+ const loadingTarget = maskEl?.querySelector('.ldp-loading-target');
+ const loadingDetail = maskEl?.querySelector('.ldp-loading-detail');
+ const loadingTip = readerElement('.ldp-loading-tip');
+ const loadingTipText = loadingTip?.querySelector('span');
+ const syncLoadingState = (state, options = {}) => {
+ const isTarget = options.target === true && targetPostNumber > 1;
+ const cachedCount = Math.max(0, Number(options.cachedCount) || 0);
+ const missingCount = Math.max(0, Number(options.missingCount) || 0);
+ const copy = state === 'cache'
+ ? {
+ status: isTarget ? '正在读取目标楼层缓存' : '正在读取帖子缓存',
+ detail: cachedCount ? `已读取 ${cachedCount} 条缓存,正在恢复楼层…` : '正在恢复已缓存楼层…',
+ }
+ : state === 'network'
+ ? {
+ status: isTarget ? '正在请求目标楼层' : cachedCount ? '正在补全帖子数据' : '正在请求帖子数据',
+ detail: cachedCount
+ ? `已读取 ${cachedCount} 条缓存,正在下载 ${missingCount} 条缺失楼层…`
+ : missingCount ? `正在下载 ${missingCount} 条缺失楼层…` : '正在下载缺失楼层…',
+ }
+ : { status: '正在渲染帖子', detail: '正在生成页面…' };
+ if (loadingStatus) loadingStatus.textContent = copy.status;
+ if (loadingDetail) loadingDetail.textContent = copy.detail;
+ if (loadingTipText) loadingTipText.textContent = copy.detail;
+ if (loadingTarget) loadingTarget.textContent = isTarget ? `#${targetPostNumber}` : '';
+ if (loadingStage) setLabel(loadingStage, `${copy.status},${copy.detail.replace('…', '')}`);
+ };
+ const cachedTargetAvailable = targetPostNumber <= 1 || !!(cachedTopicPreview?.post_stream &&
+ Array.isArray(cachedTopicPreview.post_stream.posts) &&
+ cachedTopicPreview.post_stream.posts.some((post) => +post.post_number === targetPostNumber));
+ syncLoadingState(cachedTopicPreview && cachedTargetAvailable ? 'cache' : 'network', {
+ target: targetPostNumber > 1,
+ });
+ const endTip = readerElement('.ldp-end-tip');
+ const liveUpdateBtn = readerElement('.ldp-live-update');
+ const liveUpdateJumpBtn = liveUpdateBtn.querySelector('.ldp-live-update-jump');
+ const liveUpdateDismissBtn = liveUpdateBtn.querySelector('.ldp-live-update-dismiss');
+ const timelineEl = readerElement('.ldp-topic-timeline');
+ const layoutBtn = readerElement('.ldp-layout-toggle');
+ const titleTopicRow = readerElement('.ldp-title-topic-row');
+ const titleTopicScroller = readerElement('.ldp-title-topic-scroller');
+ let titleTopicHintFrame = 0;
+ const syncTitleTopicScrollHints = () => {
+ titleTopicHintFrame = 0;
+ const hasOverflow = titleTopicScroller.scrollWidth > titleTopicRow.clientWidth + 1;
+ titleTopicRow.classList.toggle('has-overflow', hasOverflow);
+ const maxScrollLeft = Math.max(0, titleTopicScroller.scrollWidth - titleTopicScroller.clientWidth);
+ titleTopicRow.classList.toggle('can-scroll-left', hasOverflow && titleTopicScroller.scrollLeft > 1);
+ titleTopicRow.classList.toggle('can-scroll-right',
+ hasOverflow && titleTopicScroller.scrollLeft < maxScrollLeft - 1);
+ };
+ const queueTitleTopicScrollHints = () => {
+ if (titleTopicHintFrame) return;
+ titleTopicHintFrame = requestAnimationFrame(syncTitleTopicScrollHints);
+ };
+ const onlyOpBtn = readerElement('.ldp-only-op-toggle');
+ const onlyOpProgress = readerElement('.ldp-only-op-progress');
+ const onlyOpProgressValue = onlyOpProgress?.querySelector('.ldp-only-op-progress-value');
+ const rateLimitNotice = readerElement('.ldp-rate-limit-notice');
+ const rateLimitDetail = rateLimitNotice?.querySelector('.ldp-rate-limit-detail');
+ const rateLimitChallenge = rateLimitNotice?.querySelector('.ldp-rate-limit-challenge');
+ const notificationsBtn = readerElement('.ldp-notifications-toggle');
+ const notificationsPopover = readerElement('.ldp-notifications-popover');
+ const notificationModeTabs = readerElements('.ldp-notification-mode-tab');
+ const notificationModePanels = readerElements('[data-notification-mode-panel]');
+ const notificationTabs = readerElements('.ldp-notification-tab');
+ const queryReaderPanelElements = (prefix, fields) => Object.fromEntries(fields.map((field) => [
+ field, readerElement(`.ldp-${prefix}-${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`),
+ ]));
+ const notificationPanel = queryReaderPanelElements('notification', [
+ 'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'toolbar', 'unreadStatus',
+ 'markAll', 'newMessage',
+ ]);
+ const historyPanel = queryReaderPanelElements('history', [
+ 'toggle', 'popover', 'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'defaultActions',
+ 'bulkActions', 'sortToggle', 'multi', 'multiDone', 'selectScope', 'selectToggle', 'deleteSelected',
+ 'deleteSelectedLabel', 'clear',
+ ]);
+ const bookmarksPanel = queryReaderPanelElements('bookmarks', [
+ 'toggle', 'popover', 'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'defaultActions',
+ 'bulkActions', 'multi', 'multiDone', 'selectScope', 'selectToggle', 'deleteSelected', 'deleteSelectedLabel',
+ ]);
+ const [
+ bookmarksReactionFilters, bookmarksTabList, translateBtn, settingsBtn, settingsPopover,
+ settingsPanel, settingsCloseBtn, settingHelpTooltip,
+ ] = readerElementGroup(
+ '.ldp-reaction-filters', '.ldp-bookmark-tabs', '.ldp-translate-toggle',
+ '.ldp-settings-toggle', '.ldp-settings-popover',
+ '.ldp-settings-panel', '.ldp-settings-close', '.ldp-setting-help-tooltip',
+ );
+ const [bookmarksTabs, themeButtons] = readerElementsGroup(
+ '.ldp-bookmark-tab', '.ldp-settings-theme-button');
+ let settingsLayer = overlay.querySelector(':scope > .ldp-settings-layer');
+ if (!settingsLayer) {
+ settingsLayer = makeElement('div');
+ settingsLayer.className = 'ldp-settings-layer';
+ overlay.append(settingsLayer);
+ }
+ const settingsSurfaceHost = () => readerWorkspace.isEmbedded() ? settingsLayer : modal;
+ const settingsSurfaceBounds = () => {
+ const host = settingsSurfaceHost();
+ const rect = host.getBoundingClientRect();
+ const left = rect.left + host.clientLeft;
+ const top = rect.top + host.clientTop;
+ const width = host.clientWidth;
+ const height = host.clientHeight;
+ return { left, top, width, height, right: left + width, bottom: top + height };
+ };
+ const settingsSurfaceIsCompact = () =>
+ settingsSurfaceBounds().width <= READER_WINDOW_COMPACT_WIDTH;
+ const syncReaderSurfaceDensity = () => {
+ const readerShort = modal.clientHeight <= READER_SETTINGS_SHORT_HEIGHT;
+ modal.classList.toggle('ldp-reader-surface-short', readerShort);
+ if (settingsPopover.hidden) return;
+ const settingsHost = settingsSurfaceHost();
+ const shortSurface = settingsHost.clientHeight <= READER_SETTINGS_SHORT_HEIGHT;
+ modal.classList.toggle(
+ 'ldp-settings-surface-short',
+ settingsHost === modal && shortSurface,
+ );
+ settingsLayer.classList.toggle(
+ 'ldp-settings-surface-short',
+ settingsHost === settingsLayer && shortSurface,
+ );
+ };
+ surfaceManager.mount(notificationsPopover);
+ surfaceManager.mount(historyPanel.popover);
+ surfaceManager.mount(bookmarksPanel.popover);
+ surfaceManager.mount(settingsPopover, settingsSurfaceHost());
+ surfaceManager.mount(settingHelpTooltip, settingsSurfaceHost());
+ syncReaderSurfaceDensity();
+ syncReaderThemeSurfaces();
+ const [
+ userInfoContent, userInfoLastRefresh, userInfoTitleRefresh,
+ settingsSearchShell, settingsSearchInput, settingsSearchClear, settingsSearchStatus, settingsSearchEmpty,
+ settingsDraftBar, settingsDraftStatus, settingsSaveAllBtn,
+ imageScaleRange, imageScaleValue, imageScaleReset,
+ lightboxOriginalByDefaultInput, lightboxCommentsExpandedInput, lightboxDescriptionExpandedInput,
+ fontFamilyMenu, fontFamilySearch, fontFamilyOptions, fontFamilyEmpty,
+ fontFamilySourceStatus, fontFamilyManualButton,
+ ] = readerElementGroup(
+ '.ldp-user-info-content', '.ldp-user-info-last-refresh', '.ldp-user-info-title-refresh',
+ '.ldp-settings-search-shell', '.ldp-settings-search-input', '.ldp-settings-search-clear',
+ '.ldp-settings-search-status', '.ldp-settings-search-empty',
+ '.ldp-settings-draft-bar', '.ldp-settings-draft-status', '.ldp-settings-save-all',
+ '.ldp-image-scale-range', '.ldp-image-scale-value', '.ldp-image-scale-reset',
+ '.ldp-lightbox-original-by-default', '.ldp-lightbox-comments-expanded',
+ '.ldp-lightbox-description-expanded', '.ldp-font-family-menu', '.ldp-font-family-search input',
+ '.ldp-font-family-options', '.ldp-font-family-empty', '.ldp-font-family-source-status',
+ '.ldp-font-family-manual button',
+ );
+ const [
+ settingsTabs, settingsSections, settingsNavGroups, settingsLogTabs, settingsLogPanels,
+ fontFamilyTriggers, fontFamilyCustomInputs,
+ ] = readerElementsGroup(
+ '.ldp-settings-tab', '.ldp-settings-section', '.ldp-settings-nav-group',
+ '.ldp-settings-log-tab', '.ldp-settings-log-panel',
+ '.ldp-font-family-trigger', '.ldp-font-family-custom');
+ settingsSections.splice(0, settingsSections.length, ...SETTINGS_PANEL_META.map(([id]) =>
+ settingsSections.find((section) => section.dataset.settingsPanel === id)));
+ settingsPanel.append(...settingsSections.slice(1), settingsDraftBar);
+ surfaceManager.mount(fontFamilyMenu, settingsSurfaceHost());
+ const [
+ interfaceFontScaleRange, interfaceFontScaleValue, fontScaleRange, fontScaleValue,
+ composerFontScaleRange, composerFontScaleValue, fontStatus, fontResetBtn,
+ fontRenderingEnabledInput, fontRenderingOnHostInput,
+ ] = readerElementGroup(
+ '.ldp-interface-font-scale-range', '.ldp-interface-font-scale-value', '.ldp-font-scale-range',
+ '.ldp-font-scale-value', '.ldp-composer-font-scale-range', '.ldp-composer-font-scale-value',
+ '.ldp-font-status', '.ldp-font-reset', '.ldp-font-rendering-enabled',
+ '.ldp-font-rendering-on-host',
+ );
+ const [
+ fontWeightSelects, fontColorInputs, fontColorValues, hostEmbeddedSizeInputs, hostEmbeddedSizeValues,
+ hostEmbeddedSizeResetButtons, fontFieldResetButtons, fontScopeTabs, fontScopePanels,
+ ] = readerElementsGroup(
+ '.ldp-font-weight-select', '.ldp-font-color', '.ldp-font-color-value', '[data-host-embed-size]',
+ '[data-host-embed-size-value]', '[data-host-embed-size-reset]',
+ '[data-host-font-reset],[data-font-field-reset]', '[data-font-scope-tab]', '[data-font-scope-panel]',
+ );
+ const [layoutRatioInputs, layoutRatioValues] = readerElementsGroup(
+ '[data-layout-region]', '[data-layout-value]');
+ const [
+ layoutTotal, layoutResetBtn, readerWindowWidthInput, readerWindowHeightInput,
+ readerWindowXInput, readerWindowYInput, readerWindowPinInput, readerWindowLockInput,
+ readerWindowStatus, readerWindowResetBtn,
+ ] = readerElementGroup(
+ '[data-settings-panel="layout"] .ldp-layout-total',
+ '[data-settings-panel="layout"] .ldp-layout-reset',
+ '.ldp-reader-window-width', '.ldp-reader-window-height', '.ldp-reader-window-x',
+ '.ldp-reader-window-y', '.ldp-reader-window-pin-input', '.ldp-reader-window-lock-input',
+ '.ldp-reader-window-status', '.ldp-reader-window-reset',
+ );
+ const appearanceSettingElements = APPEARANCE_SETTING_FIELDS.map((field) => {
+ const className = appearanceSettingClassName(field.key);
+ return Object.assign({}, field, {
+ input: readerElement(`[data-settings-panel="appearance"] .${className}${field.range ? '-range' : ''}`),
+ output: readerElement(`[data-settings-panel="appearance"] .${className}-value`),
+ });
+ });
+ const [
+ appearanceThemeNote, appearanceStatus, performanceStatus, performanceResetBtn,
+ ] = readerElementGroup(
+ '.ldp-appearance-theme-note', '.ldp-appearance-status',
+ '.ldp-performance-status', '.ldp-performance-reset',
+ );
+ const [
+ appearanceGroupToggleInputs, colorResetButtons, performancePresetButtons, performanceInputs,
+ resourceMonitorMetrics, resourceMonitorAverages, resourceMonitorPeaks, resourceMonitorTrends,
+ ] = readerElementsGroup(
+ '[data-appearance-group-toggle]', '[data-color-reset]', '.ldp-performance-preset',
+ '[data-performance-key]', '[data-resource-monitor-metric]', '[data-resource-monitor-average]',
+ '[data-resource-monitor-peak]', '[data-resource-monitor-trend]',
+ );
+ const resourceMonitorValueColumns = [
+ [resourceMonitorMetrics, 'resourceMonitorMetric', ''],
+ [resourceMonitorAverages, 'resourceMonitorAverage', 'Average'],
+ [resourceMonitorPeaks, 'resourceMonitorPeak', 'Peak'],
+ [resourceMonitorTrends, 'resourceMonitorTrend', 'Trend'],
+ ];
+ const [resourceMonitorChartRows, resourceMonitorScopeRows, requestFlowMetrics] = readerElementsGroup(
+ '[data-resource-monitor-chart]', '[data-resource-monitor-scope]', '[data-request-flow-metric]');
+ const [
+ resourceMonitorHealth, resourceMonitorHealthState, resourceMonitorHealthDetail,
+ resourceMonitorTrendWindow, resourceMonitorUpdated, resourceMonitorEvidenceWindow,
+ resourceMonitorEventLog, resourceMonitorSection, requestFlowWindow, requestFlowChart,
+ requestFlowLegend, requestFlowBottleneck, requestFlowBottleneckState, requestFlowBottleneckDetail,
+ requestFlowAnomalyWindow, requestFlowAnomalies, requestFlowTypes, requestFlowLog,
+ requestFlowObserved, requestFlowSection,
+ ] = readerElementGroup(
+ '.ldp-resource-monitor-health', '.ldp-resource-monitor-health-state',
+ '.ldp-resource-monitor-health-detail', '.ldp-resource-monitor-trend-window',
+ '.ldp-resource-monitor-updated', '.ldp-resource-monitor-evidence-window',
+ '.ldp-resource-monitor-event-log',
+ '.ldp-settings-log-panel[data-settings-log-panel="performance"]',
+ '.ldp-request-flow-window', '.ldp-request-flow-trace', '.ldp-request-flow-legend',
+ '.ldp-request-flow-bottleneck', '.ldp-request-flow-bottleneck-state',
+ '.ldp-request-flow-bottleneck-detail', '.ldp-request-flow-anomaly-window',
+ '.ldp-request-flow-anomalies', '.ldp-request-flow-types', '.ldp-request-flow-log',
+ '.ldp-request-flow-observed', '.ldp-settings-log-panel[data-settings-log-panel="request"]',
+ );
+ const jumpHighlightControls = Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => {
+ const className = `.ldp-flash-${field.key}`;
+ return [field.key, {
+ input: readerElement(className),
+ output: readerElement(`${className}-value`),
+ }];
+ }));
+ const jumpHighlightInputs = JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => jumpHighlightControls[field.key].input);
+ const [
+ jumpHighlightStatus, loadingAnimationSelect, loadingAnimationPreview,
+ loadingAnimationPreviewLabel, loadingAnimationReroll, historySortModeSelect,
+ historyButtonsAlwaysVisibleInput, readerQueueAlwaysVisibleWhenEmptyInput, historyEdgeTriggerRange, historyEdgeTriggerValue,
+ openTopicsAtFirstPostInput, doubleEscapeToCloseReaderInput, confirmNativeComposerCloseInput, topicActionRailVisibleInput, topicActionRailFixedInput, topicActionRailResetBtn, expandNestedRepliesByDefaultInput, expandLeafNestedRepliesInput, aggregateDescendantRepliesInput, inlineReplyTreeEnableInput, inlineReplyTreeDepthSelect, hideNestedReplyFloorsInput,
+ nestedReplyDisplayWarning, boostCopyModeSelect, boostCopyPrefixInput, boostCopyCounterMarkerInput,
+ boostCopyCounterStepInput, boostCopyFixedSuffixInput, boostCopyPreview, configExportBtn,
+ configImportBtn, configResetBtn, configFileInput, cacheClearBtn, customSiteInput,
+ customSiteAddBtn, customSiteList, customSiteStatus,
+ ] = readerElementGroup(
+ '.ldp-flash-status', '.ldp-loading-animation-select',
+ '.ldp-loading-preview-stage', '.ldp-loading-settings-preview-label',
+ '.ldp-loading-preview-reroll', '.ldp-history-sort-mode', '.ldp-history-buttons-always-visible',
+ '.ldp-reader-queue-always-visible-empty', '.ldp-history-edge-trigger-range', '.ldp-history-edge-trigger-value',
+ '.ldp-open-topics-first-post', '.ldp-double-escape-close-reader', '.ldp-confirm-native-composer-close', '.ldp-topic-action-rail-visible-setting', '.ldp-topic-action-rail-fixed-setting', '.ldp-topic-action-rail-reset', '.ldp-expand-nested-replies-default',
+ '.ldp-expand-leaf-nested-replies', '.ldp-aggregate-descendant-replies', '.ldp-inline-reply-tree-enable', '.ldp-inline-reply-tree-depth', '.ldp-hide-nested-reply-floors', '.ldp-nested-display-warning', '.ldp-boost-copy-mode',
+ '.ldp-boost-copy-prefix', '.ldp-boost-copy-counter-marker', '.ldp-boost-copy-counter-step',
+ '.ldp-boost-copy-fixed-suffix', '.ldp-boost-copy-preview', '.ldp-config-export',
+ '.ldp-config-import', '.ldp-config-reset', '.ldp-config-file', '.ldp-cache-clear',
+ '.ldp-custom-site-input', '.ldp-custom-site-add', '.ldp-custom-site-list', '.ldp-custom-site-status',
+ );
+ const inlineReplyTreeDepthRow = inlineReplyTreeDepthSelect?.closest('.ldp-inline-reply-tree-depth-row') || null;
+ const [
+ boostCopyCounterRows, boostCopyTextRows, cacheSelects, cacheSizeEls, cacheRetentionEls,
+ settingsColorInputs,
+ ] = readerElementsGroup(
+ '.ldp-boost-counter-row', '.ldp-boost-text-row', '.ldp-cache-select', '[data-cache-size]',
+ '[data-cache-retention]', '.ldp-color-control input[type="color"]',
+ );
+ const colorPickerPresets = ['#F0FFF0', '#FFFFFF', '#E5E7EB', '#94A3B8', '#475569', '#111827',
+ '#47855F', '#22C55E', '#2563EB', '#7C3AED', '#D97706', '#DC2626'];
+ let colorPickerPopover = readerElement('.ldp-color-picker-popover');
+ if (!colorPickerPopover) {
+ colorPickerPopover = makeElement('div');
+ colorPickerPopover.className = 'ldp-color-picker-popover';
+ colorPickerPopover.setAttribute('role', 'dialog');
+ colorPickerPopover.setAttribute('aria-modal', 'false');
+ setLabel(colorPickerPopover, '选择颜色');
+ colorPickerPopover.hidden = true;
+ colorPickerPopover.innerHTML = `
+
选择颜色
+
+ ${colorPickerPresets.map((color) => ``).join('')}
+
+
+
+
+
+
+ 色相
+ 饱和度
+ 明度
+
`;
+ surfaceManager.mount(colorPickerPopover, settingsSurfaceHost());
+ }
+ let lastSettingsSurfaceHost = settingsSurfaceHost();
+ const syncSettingsSurfaceHost = (allowParking = true) => {
+ const host = settingsSurfaceHost();
+ const surfaces = [settingsPopover, settingHelpTooltip, fontFamilyMenu, colorPickerPopover].filter(Boolean);
+ if (allowParking && settingsPopover.hidden) {
+ surfaces.forEach((surface) => surfaceManager.park(surface));
+ setHidden(settingsLayer, true);
+ return;
+ }
+ const hostChanged = lastSettingsSurfaceHost !== host;
+ surfaces.forEach((surface) => surfaceManager.mount(surface, host));
+ lastSettingsSurfaceHost = host;
+ setHidden(settingsLayer, host !== settingsLayer);
+ if (hostChanged) {
+ settingsPopover.style.removeProperty('left');
+ settingsPopover.style.removeProperty('top');
+ settingsPopover.style.removeProperty('transform');
+ }
+ syncReaderSurfaceDensity();
+ };
+ syncSettingsSurfaceHost(false);
+ queueMicrotask(() => {
+ if (settingsPopover.hidden) syncSettingsSurfaceHost();
+ });
+ const colorPickerTitle = colorPickerPopover.querySelector('.ldp-color-picker-title');
+ const colorPickerHexInput = colorPickerPopover.querySelector('.ldp-color-picker-hex');
+ const colorPickerPresetButtons = colorPickerPopover.querySelectorAll('.ldp-color-picker-preset');
+ const colorPickerMoreButton = colorPickerPopover.querySelector('.ldp-color-picker-more');
+ const colorPickerAdvanced = colorPickerPopover.querySelector('.ldp-color-picker-advanced');
+ const colorPickerHueInput = colorPickerPopover.querySelector('.ldp-color-picker-hue');
+ const colorPickerSaturationInput = colorPickerPopover.querySelector('.ldp-color-picker-saturation');
+ const colorPickerBrightnessInput = colorPickerPopover.querySelector('.ldp-color-picker-brightness');
+ const colorPickerHueValue = colorPickerPopover.querySelector('.ldp-color-picker-hue-value');
+ const colorPickerSaturationValue = colorPickerPopover.querySelector('.ldp-color-picker-saturation-value');
+ const colorPickerBrightnessValue = colorPickerPopover.querySelector('.ldp-color-picker-brightness-value');
+ let activeColorInput = null;
+ let colorPickerHsv = { h: 0, s: 0, v: 100 };
+ let colorPickerCommitFrame = 0;
+ let pendingColorPickerCommit = null;
+ const normalizeColorPickerHex = (rawValue) => {
+ const match = /^#?([\da-f]{3}|[\da-f]{6})$/i.exec(String(rawValue || '').trim());
+ if (!match) return '';
+ const digits = match[1].length === 3 ? Array.from(match[1], (digit) => `${digit}${digit}`).join('') : match[1];
+ return `#${digits.toUpperCase()}`;
+ };
+ const colorPickerHexToHsv = (hex) => {
+ const value = normalizeColorPickerHex(hex).slice(1);
+ const red = parseInt(value.slice(0, 2), 16) / 255;
+ const green = parseInt(value.slice(2, 4), 16) / 255;
+ const blue = parseInt(value.slice(4, 6), 16) / 255;
+ const max = Math.max(red, green, blue);
+ const min = Math.min(red, green, blue);
+ const delta = max - min;
+ let hue = 0;
+ if (delta) {
+ if (max === red) hue = 60 * (((green - blue) / delta) % 6);
+ else if (max === green) hue = 60 * ((blue - red) / delta + 2);
+ else hue = 60 * ((red - green) / delta + 4);
+ }
+ if (hue < 0) hue += 360;
+ return { h: Math.round(hue) % 360, s: max ? Math.round((delta / max) * 100) : 0, v: Math.round(max * 100) };
+ };
+ const colorPickerHsvToHex = ({ h, s, v }) => {
+ const saturation = s / 100;
+ const brightness = v / 100;
+ const chroma = brightness * saturation;
+ const sector = h / 60;
+ const intermediate = chroma * (1 - Math.abs((sector % 2) - 1));
+ const offset = brightness - chroma;
+ const channels = sector < 1 ? [chroma, intermediate, 0]
+ : sector < 2 ? [intermediate, chroma, 0]
+ : sector < 3 ? [0, chroma, intermediate]
+ : sector < 4 ? [0, intermediate, chroma]
+ : sector < 5 ? [intermediate, 0, chroma] : [chroma, 0, intermediate];
+ return `#${channels.map((channel) => Math.round((channel + offset) * 255).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
+ };
+ const syncColorPickerAdvanced = () => {
+ const { h, s, v } = colorPickerHsv;
+ colorPickerHueInput.value = String(h);
+ colorPickerSaturationInput.value = String(s);
+ colorPickerBrightnessInput.value = String(v);
+ colorPickerHueValue.textContent = `${h}°`;
+ colorPickerSaturationValue.textContent = `${s}%`;
+ colorPickerBrightnessValue.textContent = `${v}%`;
+ colorPickerHueInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s: 100, v: 100 }));
+ colorPickerSaturationInput.style.setProperty('--ldp-color-slider-start', colorPickerHsvToHex({ h, s: 0, v }));
+ colorPickerSaturationInput.style.setProperty('--ldp-color-slider-end', colorPickerHsvToHex({ h, s: 100, v }));
+ colorPickerSaturationInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s, v }));
+ colorPickerBrightnessInput.style.setProperty('--ldp-color-slider-end', colorPickerHsvToHex({ h, s, v: 100 }));
+ colorPickerBrightnessInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s, v }));
+ };
+ const syncColorPickerPopover = () => {
+ if (!activeColorInput) return;
+ const color = activeColorInput.value.toUpperCase();
+ colorPickerTitle.textContent = activeColorInput.getAttribute('aria-label') || '选择颜色';
+ colorPickerHexInput.value = color;
+ colorPickerHsv = colorPickerHexToHsv(color);
+ syncColorPickerAdvanced();
+ colorPickerHexInput.setAttribute('aria-invalid', 'false');
+ colorPickerPresetButtons.forEach((button) => setPressed(button, button.dataset.color === color));
+ };
+ const commitScheduledColorPickerValue = () => {
+ colorPickerCommitFrame = 0;
+ const pending = pendingColorPickerCommit;
+ pendingColorPickerCommit = null;
+ if (!pending || !pending.input.isConnected) return;
+ pending.input.value = pending.value;
+ pending.input.dispatchEvent(new window.Event('input', { bubbles: true }));
+ };
+ const scheduleColorPickerValue = (value) => {
+ if (!activeColorInput) return;
+ pendingColorPickerCommit = { input: activeColorInput, value };
+ if (!colorPickerCommitFrame) colorPickerCommitFrame = window.requestAnimationFrame(commitScheduledColorPickerValue);
+ };
+ const flushScheduledColorPickerValue = () => {
+ if (!pendingColorPickerCommit) return;
+ if (colorPickerCommitFrame) window.cancelAnimationFrame(colorPickerCommitFrame);
+ commitScheduledColorPickerValue();
+ };
+ const closeColorPickerPopover = ({ restoreFocus = false } = {}) => {
+ if (colorPickerPopover.hidden) return;
+ flushScheduledColorPickerValue();
+ const previousInput = activeColorInput;
+ colorPickerPopover.hidden = true;
+ activeColorInput = null;
+ if (restoreFocus && previousInput) previousInput.focus();
+ };
+ const positionColorPickerPopover = (input) => {
+ const margin = 12;
+ const gap = 8;
+ const bounds = settingsSurfaceBounds();
+ const inputRect = input.getBoundingClientRect();
+ const popoverRect = colorPickerPopover.getBoundingClientRect();
+ const minimumLeft = bounds.left + margin;
+ const minimumTop = bounds.top + margin;
+ const left = Math.min(
+ Math.max(minimumLeft, inputRect.left),
+ Math.max(minimumLeft, bounds.right - popoverRect.width - margin),
+ );
+ const below = inputRect.bottom + gap;
+ const top = below + popoverRect.height <= bounds.bottom - margin
+ ? below
+ : Math.max(minimumTop, inputRect.top - popoverRect.height - gap);
+ colorPickerPopover.style.left = `${Math.round(left - bounds.left)}px`;
+ colorPickerPopover.style.top = `${Math.round(top - bounds.top)}px`;
+ };
+ const openColorPickerPopover = (input) => {
+ if (!input || input.disabled) return;
+ hideSettingHelp();
+ closeFontFamilyMenu();
+ stopSettingsRangeDrag();
+ stopSettingsColorPick();
+ activeColorInput = input;
+ syncColorPickerPopover();
+ colorPickerPopover.hidden = false;
+ positionColorPickerPopover(input);
+ colorPickerHexInput.focus();
+ colorPickerHexInput.select();
+ };
+ const applyColorPickerValue = (value, closeAfter = false) => {
+ if (!activeColorInput) return;
+ flushScheduledColorPickerValue();
+ activeColorInput.value = value;
+ activeColorInput.dispatchEvent(new window.Event('input', { bubbles: true }));
+ syncColorPickerPopover();
+ if (closeAfter) closeColorPickerPopover({ restoreFocus: true });
+ };
+ if (!reusingShell) {
+ settingsColorInputs.forEach((input) => {
+ input.setAttribute('aria-haspopup', 'dialog');
+ onShell(input, 'click', (event) => {
+ event.preventDefault();
+ openColorPickerPopover(input);
+ });
+ onShell(input, 'keydown', (event) => {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ event.preventDefault();
+ openColorPickerPopover(input);
+ });
+ });
+ colorPickerPresetButtons.forEach((button) => onShell(button, 'click', () => {
+ applyColorPickerValue(button.dataset.color, true);
+ }));
+ onShell(colorPickerHexInput, 'input', () => {
+ const rawValue = colorPickerHexInput.value.trim();
+ const color = /^#?[\da-f]{6}$/i.test(rawValue) ? normalizeColorPickerHex(rawValue) : '';
+ colorPickerHexInput.setAttribute('aria-invalid', String(rawValue.length >= 7 && !color));
+ if (color) applyColorPickerValue(color);
+ });
+ onShell(colorPickerHexInput, 'keydown', (event) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ closeColorPickerPopover({ restoreFocus: true });
+ return;
+ }
+ if (event.key !== 'Enter') return;
+ const color = normalizeColorPickerHex(colorPickerHexInput.value);
+ if (!color) {
+ colorPickerHexInput.setAttribute('aria-invalid', 'true');
+ return;
+ }
+ event.preventDefault();
+ applyColorPickerValue(color, true);
+ });
+ onShell(colorPickerHexInput, 'blur', () => {
+ if (!activeColorInput || colorPickerPopover.contains(document.activeElement)) return;
+ colorPickerHexInput.value = activeColorInput.value.toUpperCase();
+ colorPickerHexInput.setAttribute('aria-invalid', 'false');
+ });
+ onShell(colorPickerMoreButton, 'click', () => {
+ const expanded = colorPickerAdvanced.hidden;
+ colorPickerAdvanced.hidden = !expanded;
+ setExpanded(colorPickerMoreButton, expanded);
+ colorPickerMoreButton.textContent = expanded ? '收起调色' : '更多颜色';
+ if (activeColorInput) positionColorPickerPopover(activeColorInput);
+ });
+ [colorPickerHueInput, colorPickerSaturationInput, colorPickerBrightnessInput].forEach((input) =>
+ onShell(input, 'input', () => {
+ colorPickerHsv = {
+ h: Number(colorPickerHueInput.value),
+ s: Number(colorPickerSaturationInput.value),
+ v: Number(colorPickerBrightnessInput.value),
+ };
+ const color = colorPickerHsvToHex(colorPickerHsv);
+ colorPickerHexInput.value = color;
+ colorPickerHexInput.setAttribute('aria-invalid', 'false');
+ syncColorPickerAdvanced();
+ colorPickerPresetButtons.forEach((button) =>
+ setPressed(button, button.dataset.color === color));
+ scheduleColorPickerValue(color);
+ }));
+ [colorPickerHueInput, colorPickerSaturationInput, colorPickerBrightnessInput]
+ .forEach((input) => onShell(input, 'change', flushScheduledColorPickerValue));
+ onShell(colorPickerPopover, 'keydown', (event) => {
+ if (event.key !== 'Escape') return;
+ event.preventDefault();
+ closeColorPickerPopover({ restoreFocus: true });
+ });
+ onShell(colorPickerPopover, 'pointerdown', (event) => event.stopPropagation());
+ onShell(colorPickerPopover, 'click', (event) => event.stopPropagation());
+ onShell(overlay, 'pointerdown', (event) => {
+ if (colorPickerPopover.hidden || colorPickerPopover.contains(event.target) || event.target === activeColorInput) return;
+ closeColorPickerPopover();
+ }, true);
+ }
+ const setSettingHelp = (target, copy) => {
+ if (target && copy) target.dataset.settingHelp = copy;
+ };
+ const setSettingRowHelp = (control, copy) => {
+ setSettingHelp(control?.closest('.ldp-setting-row'), copy);
+ };
+ setSettingRowHelp(imageScaleRange, '控制所有显示形态中帖子正文图片的显示大小。100% 是正常尺寸;调小能少占屏幕,调大方便看细节。修改后立即保存。');
+ setSettingRowHelp(lightboxOriginalByDefaultInput, '开启后,大图查看器首次打开和切换图片时会自动请求原图;关闭后不会新请求原图,但本地已有原图缓存时仍会优先展示,否则继续使用预览图。修改后立即保存。');
+ setSettingRowHelp(lightboxCommentsExpandedInput, '控制大图查看器首次打开时是否展开评论面板;打开后仍可随时手动收起或展开。修改后立即保存。');
+ setSettingRowHelp(lightboxDescriptionExpandedInput, '控制大图查看器首次打开时是否展开图片说明;手动收起、展开或拖动说明高度后也会自动记住。修改后立即保存。');
+ const layoutHelp = {
+ left: '控制阅读内容左边保留多少空白。数值越大,正文会整体向右并变窄;它需要和另外四个区域合计正好 100%。',
+ main: '控制正文区域实际占用的宽度,包括主帖和楼层内容。太小会增加换行,太大会挤压时间轴和两侧留白;五个区域必须合计 100%。',
+ gap: '控制正文与右侧时间轴之间的空隙。调大后两块内容更分开,但正文可用宽度会减少;五个区域必须合计 100%。',
+ timeline: '控制右侧楼层时间轴、日期和楼层计数占用的宽度。太小可能显得拥挤;五个区域必须合计 100%。',
+ right: '控制时间轴右边保留多少空白。调大能让右侧不贴边,但会减少正文或时间轴的空间;五个区域必须合计 100%。',
+ };
+ layoutRatioInputs.forEach((input) => setSettingRowHelp(input, layoutHelp[input.dataset.layoutRegion]));
+ setSettingHelp(readerWindowWidthInput.closest('.ldp-reader-window-field'), '设置桌面浮窗的宽度。数值越大,一行能显示的正文越多;只影响浮窗模式,修改后立即保存。');
+ setSettingHelp(readerWindowHeightInput.closest('.ldp-reader-window-field'), '设置桌面浮窗的高度。数值越大,一屏能看到的楼层越多;只影响浮窗模式,修改后立即保存。');
+ setSettingHelp(readerWindowXInput.closest('.ldp-reader-window-field'), '设置浮窗左边缘距离浏览器左侧的像素数。数值越大,浮窗越靠右;只影响桌面浮窗并立即保存。');
+ setSettingHelp(readerWindowYInput.closest('.ldp-reader-window-field'), '设置浮窗上边缘距离浏览器顶部的像素数。数值越大,浮窗越靠下;只影响桌面浮窗并立即保存。');
+ setSettingHelp(readerWindowPinInput.closest('.ldp-reader-window-option'), '开启后,点击阅读器外面的网页区域也不会自动收起浮窗,适合边看帖子边操作原页面。');
+ setSettingHelp(readerWindowLockInput.closest('.ldp-reader-window-option'), '开启后禁止拖动和缩放浮窗,避免误操作改变位置或大小;仍可通过小锁或这里重新解锁。');
+ appearanceSettingElements.forEach(({ input, help, range }) => setSettingRowHelp(input,
+ range ? help : `${help}确定颜色后会保留色相,并按用途自动生成明亮与暗色主题的安全亮度;切换主题时自动应用。`));
+ appearanceGroupToggleInputs.forEach((input) => setSettingHelp(input.closest('.ldp-setting-switch'),
+ '关闭后隐藏回复连接线、引用线和界面分隔线;已经设置的颜色、粗细和圆角不会丢失,重新开启即可恢复。'));
+ const presetHelp = {
+ low: '减少每次加载的内容、页面保留的楼层和提前请求量,优先降低网络、内存与主线程占用;下次打开阅读器生效。',
+ balanced: '在加载速度、滚动流畅度、内存占用和请求稳定性之间取默认平衡;下次打开阅读器生效。',
+ high: '增加每次加载的内容、页面保留的楼层和提前请求距离,优先减少滚动等待,但会增加内存、网络和限流压力;下次打开阅读器生效。',
+ custom: '表示下面的性能参数已经手动调整。点击它不会自动改值,可直接修改下方各项;下次打开阅读器生效。',
+ };
+ performancePresetButtons.forEach((button) => setSettingHelp(button, presetHelp[button.dataset.performancePreset]));
+ performanceInputs.forEach((input) => setSettingRowHelp(input,
+ PERFORMANCE_SETTING_FIELDS.find((field) => field.key === input.dataset.performanceKey).help));
+ JUMP_HIGHLIGHT_SETTING_FIELDS.forEach((field) => setSettingRowHelp(jumpHighlightControls[field.key].input, field.help));
+ setSettingRowHelp(historyButtonsAlwaysVisibleInput, '开启后,可用的历史前进和后退按钮会一直显示,并禁用“边缘唤出按钮范围”滑块;关闭后,按钮仅在鼠标进入对应边缘或键盘聚焦时显示。修改后立即保存。');
+ setSettingRowHelp(historyEdgeTriggerRange, `设置阅读器左右两侧用于唤出历史前进和后退按钮的范围,各占阅读器宽度的 ${HISTORY_EDGE_TRIGGER_MIN}%–${HISTORY_EDGE_TRIGGER_MAX}%;范围透明且不会遮挡正文操作,0% 表示关闭鼠标唤出。修改后立即保存。`);
+ setSettingRowHelp(historySortModeSelect, '“最近打开优先”按每次打开的最新时间倒序排列,重开旧帖后它会回到列表顶部;“首次打开顺序”按每条记录第一次进入历史的时间排列,重开不会改变位置。旧记录无法还原更早的首次打开时间,会从当前保存时间开始计算。修改后立即保存。');
+ setSettingRowHelp(openTopicsAtFirstPostInput, '开启后,普通帖子链接会从 #1 主楼开始;消息、历史和收藏面板中的链接仍优先打开各自目标楼层。关闭后,所有链接都会尊重其中指定的楼层号。修改后立即保存。');
+ setSettingRowHelp(doubleEscapeToCloseReaderInput, '开启后,需要在 1.5 秒内连续按两次 Esc 才会关闭阅读器;关闭后,按一次 Esc 即可关闭。修改后立即保存。');
+ setSettingRowHelp(confirmNativeComposerCloseInput, '开启后,在阅读器内按 Esc、关闭或舍弃 LINUX DO 原生回复窗口时,需要在 1.5 秒内重复同一操作;关闭后,一次操作即可关闭。修改后立即保存。');
+ setSettingRowHelp(expandNestedRepliesByDefaultInput, '开启后,在每条父回复下默认展开它的直接二级回复;关闭时会同时关闭“完整讨论”视图与树状评论。修改后立即保存并应用到当前帖子。');
+ setSettingRowHelp(expandLeafNestedRepliesInput, '开启后,二级回复在楼层列表中的对应位置默认完整展开;可与父回复下的二级回复同时显示,但至少要保留一种显示位置。修改后立即保存并应用到当前帖子。');
+ setSettingRowHelp(aggregateDescendantRepliesInput, '建立在“在父回复下展开二级回复”之上;开启时会按滚动位置预加载更深层回复,并提供“查看完整讨论”浮窗。修改后立即保存并应用到当前帖子。');
+ if (inlineReplyTreeEnableInput) {
+ setSettingRowHelp(inlineReplyTreeEnableInput, '需先开启「在父回复下展开二级回复」。关闭时仅显示一层直属二级回复;开启后可在 2–5 层之间选择主信息流嵌套深度。修改后立即保存并应用到当前帖子。');
+ }
+ if (inlineReplyTreeDepthSelect) {
+ setSettingRowHelp(inlineReplyTreeDepthSelect, '仅在启用树状评论后可选。层数从主楼下的直属回复算起;超出部分仍可通过「完整讨论」查看。修改后立即保存并应用到当前帖子。');
+ }
+ setSettingRowHelp(hideNestedReplyFloorsInput, '未启用“完整讨论”时,从楼层列表隐藏全部二级回复;启用后先保留未读二级回复,读过后再隐藏。时间轴、跳转和已读记录不受影响;跳转时会临时显示或打开对应讨论。修改后立即保存并应用到当前帖子。');
+ setSettingRowHelp(boostCopyModeSelect, '选择复制 Boost 时如何生成末尾内容:“递增数字”每次按设定步长增加,“固定文字”每次追加同一段文字。修改后立即保存。');
+ setSettingRowHelp(boostCopyPrefixInput, '填写复制结果开头的前置文字,例如“赞同:”。留空就直接从原 Boost 内容开始,最多 16 个字。修改后立即保存。');
+ setSettingRowHelp(boostCopyCounterMarkerInput, '使用递增数字时,填写数字前缀,例如“+”会得到“原 Boost +1”;留空时数字会直接接在原文后面。修改后立即保存。');
+ setSettingRowHelp(boostCopyCounterStepInput, '使用递增数字时,每复制一次增加多少。设为 1 会依次得到 1、2、3;设为 5 会得到 5、10、15。修改后立即保存。');
+ setSettingRowHelp(boostCopyFixedSuffixInput, '使用固定文字时,每次复制都会把这里的内容追加到原 Boost 后面,例如“俺也一样”。最多 16 个字。修改后立即保存。');
+ setSettingRowHelp(boostCopyPreview, '展示当前规则实际会复制出的结果;使用递增数字时会同时展示连续两次复制,方便确认步长。');
+ cacheSelects.forEach((input, index) => setSettingHelp(input.closest('.ldp-cache-row'), CACHE_TYPES[index].help));
+ setSettingHelp(cacheClearBtn, '只清理上面已经勾选的缓存类型。不会退出登录,也不会删除站点上的帖子、消息或图片;清理后需要时会重新联网获取。');
+ const readerWindow = readerShell.readerWindow || createReaderWindowController(overlay, modal);
+ readerShell.readerWorkspace = readerWorkspace;
+ readerShell.readerWindow = readerWindow;
+ readerShell.topicId = Number(topicId);
+ readerShell.targetPostNumber = targetPostNumber;
+ readerShell.topicNavMetadata = sourceTopicNavMetadata;
+ readerShell.commentsHeader = commentsHeader;
+ overlay._ldpReaderShell = readerShell;
+ const syncReaderPlacementUi = () => {
+ const allowedModes = directTopicRoute ? TOPIC_READER_MODES : LIST_READER_MODES;
+ const visibleModeCount = allowedModes.length;
+ readerPlacementControl.hidden = visibleModeCount < 2;
+ readerPlacementStrip.hidden = visibleModeCount < 2;
+ readerCapsule.classList.toggle('ldp-reader-placement-available', visibleModeCount > 1);
+ const activeMode = readerWorkspace.mode();
+ readerPlacementOptions.forEach((option) => {
+ const optionMode = option.dataset.readerPlacement;
+ option.hidden = !allowedModes.includes(optionMode);
+ option.classList.toggle('active', option.dataset.readerPlacement === activeMode);
+ });
+ };
+ const applyReaderPlacement = (nextMode) => {
+ const allowedModes = directTopicRoute ? TOPIC_READER_MODES : LIST_READER_MODES;
+ if (!allowedModes.includes(nextMode)) return;
+ readerWorkspace.setMode(nextMode);
+ readerWindow.syncMode();
+ syncLayoutBtn();
+ syncReaderBackgroundMode();
+ applyLayoutRatios();
+ if (nextMode !== readerWorkspace.mode()) showSelectionToast('页面空间不足,暂时保持浮窗');
+ syncReaderPlacementUi();
+ };
+ if (!reusingShell) {
+ readerPlacementOptions.forEach((option) => {
+ onShell(option, 'click', () => applyReaderPlacement(option.dataset.readerPlacement));
+ });
+ }
+ syncReaderPlacementUi();
+
+ let latestTopicShellData = cachedTopicPreview;
+ let topicNavMetadataObserver = null;
+ let topicNavMetadataSyncFrame = 0;
+ let topicNavMetadataStopTimer = 0;
+ let topicNavMetadataOverride = null;
+ let topicEditorState = null;
+ const stopTopicNavMetadataHydration = () => {
+ if (topicNavMetadataObserver) topicNavMetadataObserver.disconnect();
+ if (topicNavMetadataSyncFrame) cancelAnimationFrame(topicNavMetadataSyncFrame);
+ if (topicNavMetadataStopTimer) clearTimeout(topicNavMetadataStopTimer);
+ topicNavMetadataObserver = null;
+ topicNavMetadataSyncFrame = 0;
+ topicNavMetadataStopTimer = 0;
+ };
+ const syncReaderTitleDensity = () => {
+ const header = readerElement('.ldp-header');
+ const titleJump = header?.querySelector('.ldp-title-jump');
+ if (!header || !titleJump) return;
+ const range = document.createRange();
+ range.selectNodeContents(titleJump);
+ const rects = Array.from(range.getClientRects())
+ .filter((rect) => rect.width > 0 && rect.height > 0);
+ const firstTop = rects[0]?.top;
+ header.classList.toggle('ldp-title-single-line',
+ rects.length > 0 && rects.every((rect) => Math.abs(rect.top - firstTop) < 1));
+ };
+ readerShell.syncReaderTitleDensity = syncReaderTitleDensity;
+ const renderTopicShell = (topicData) => {
+ if (!topicData) return;
+ updateDiscourseCapabilityDiagnostics({ topic: topicData });
+ latestTopicShellData = topicData;
+ const liveTopicNavMetadata = readTopicNavMetadata(null, topicId);
+ sourceTopicNavMetadata = mergeTopicNavMetadata(
+ topicNavMetadataOverride || liveTopicNavMetadata,
+ mergeTopicNavMetadata(sourceTopicNavMetadata, cachedTopicNavMetadata(topicData)),
+ );
+ if (queueEntry) updateReaderQueueEntryFromTopic(queueEntry, topicData);
+ readerShell.topicNavMetadata = sourceTopicNavMetadata;
+ const topicMetadataFilled = fillTopicNavMetadata(topicData, sourceTopicNavMetadata);
+ const topicMetadataRemembered = rememberTopicNavMetadata(topicId, topicData, sourceTopicNavMetadata);
+ if (topicMetadataFilled && !topicMetadataRemembered) setCachedTopicData(topicId, topicData);
+ const cachedPosts = Array.isArray(topicData.post_stream?.posts)
+ ? topicData.post_stream.posts
+ : [];
+ const owner = topicData._opUsername
+ || (topicData.details?.created_by && topicData.details.created_by.username)
+ || (cachedPosts.find((post) => +post.post_number === 1) || {}).username
+ || '';
+ const title = String(topicData.title || '').trim();
+ if (title) {
+ readerElement('.ldp-title-jump').textContent = title;
+ syncReaderDocumentTitle(title);
+ }
+ const topicEditTrigger = readerElement('.ldp-topic-edit-trigger');
+ const canEditTopic = topicData.details && topicData.details.can_edit === true;
+ topicEditTrigger.hidden = !canEditTopic;
+ if (!canEditTopic && topicEditorState) topicEditorState.close(true);
+ const ownerHtml = owner
+ ? `
@${esc(owner)}`
+ : '@?';
+ const metaParts = [
+ `${esc(String(topicData.posts_count || 0))} 帖`,
+ `${esc(String(topicData.views || 0))} 浏览`,
+ ];
+ if (topicData.like_count != null)
+ metaParts.push(`${esc(String(Math.max(0, +topicData.like_count || 0)))} 赞`);
+ if (topicData.participant_count != null)
+ metaParts.push(`${esc(String(Math.max(0, +topicData.participant_count || 0)))} 用户`);
+ readerElement('.ldp-meta-stats').innerHTML = metaParts.join(' · ');
+ readerElement('.ldp-meta-owner-value').innerHTML = ownerHtml;
+ readerElement('.ldp-meta-owner').hidden = false;
+ const tagsEl = readerElement('.ldp-topic-tags');
+ tagsEl.innerHTML = renderTopicNavLinks(topicData, sourceTopicNavMetadata);
+ tagsEl.hidden = !tagsEl.innerHTML;
+ const voteSlot = readerElement('.ldp-topic-vote-slot');
+ const hasTopicVote = hasDiscourseCapability('topicVoting', { topic: topicData }) &&
+ (topicData.can_vote === true || topicData.user_voted === true || +topicData.vote_count > 0);
+ voteSlot.hidden = !hasTopicVote;
+ voteSlot.innerHTML = hasTopicVote
+ ? `
`
+ : '';
+ syncReaderTitleDensity();
+ readerShell.syncReaderTitleActionAlignment();
+ queueTitleTopicScrollHints();
+ };
+ const syncTopicNavMetadataFromHost = () => {
+ if (topicNavMetadataOverride) return false;
+ if (!overlay.isConnected) {
+ stopTopicNavMetadataHydration();
+ return false;
+ }
+ const liveMetadata = readTopicNavMetadata(null, topicId);
+ if (!topicNavMetadataHasIcons(liveMetadata)) return false;
+ stopTopicNavMetadataHydration();
+ sourceTopicNavMetadata = mergeTopicNavMetadata(liveMetadata, sourceTopicNavMetadata);
+ readerShell.topicNavMetadata = sourceTopicNavMetadata;
+ renderTopicShell(latestTopicShellData);
+ return true;
+ };
+ const queueTopicNavMetadataSync = () => {
+ if (topicNavMetadataSyncFrame) return;
+ topicNavMetadataSyncFrame = requestAnimationFrame(() => {
+ topicNavMetadataSyncFrame = 0;
+ syncTopicNavMetadataFromHost();
+ });
+ };
+ const startTopicNavMetadataHydration = () => {
+ if (!directTopicRoute || topicNavMetadataHasIcons(sourceTopicNavMetadata) || !document.body) return;
+ const metadataRoot = document.querySelector('#main-outlet') ||
+ document.querySelector('#ember-app') ||
+ document.body;
+ topicNavMetadataObserver = new MutationObserver(queueTopicNavMetadataSync);
+ topicNavMetadataObserver.observe(metadataRoot, { childList: true, subtree: true });
+ topicNavMetadataStopTimer = setTimeout(stopTopicNavMetadataHydration, 5000);
+ queueTopicNavMetadataSync();
+ };
+ renderTopicShell(cachedTopicPreview);
+ startTopicNavMetadataHydration();
+ const postRequestScheduler = createReaderRequestScheduler(performance);
+ ACTIVE_READER_REQUEST_SCHEDULER = postRequestScheduler;
+ scheduleReaderQueuePrefetch();
+ let rateLimitNoticeTimer = 0;
+ let rateLimitChallengePending = false;
+ const rateLimitCooldownRemaining = () => {
+ try {
+ const state = postRequestScheduler.snapshot();
+ return Math.max(0, Number(state?.cooldownRemaining) || 0);
+ } catch {}
+ return 0;
+ };
+ const syncRateLimitNotice = () => {
+ if (!rateLimitNotice || !rateLimitDetail) return;
+ const remaining = rateLimitCooldownRemaining();
+ if (remaining <= 0) {
+ setHidden(rateLimitNotice, true);
+ if ('cooldownSeconds' in rateLimitNotice.dataset)
+ delete rateLimitNotice.dataset.cooldownSeconds;
+ if (rateLimitChallengePending && rateLimitChallenge) {
+ rateLimitChallengePending = false;
+ openCloudflareChallengePopup(rateLimitChallenge.href);
+ }
+ return;
+ }
+ rateLimitChallengePending = true;
+ const seconds = Math.max(1, Math.ceil(remaining / 1000));
+ if (rateLimitNotice.dataset.cooldownSeconds !== String(seconds)) {
+ rateLimitNotice.dataset.cooldownSeconds = String(seconds);
+ setText(rateLimitDetail, `触发 429,正在等待 ${seconds} 秒;倒计时结束后将自动打开过盾浮窗。`);
+ }
+ setHidden(rateLimitNotice, false);
+ };
+ const stopRateLimitNotice = () => {
+ if (rateLimitNoticeTimer) clearInterval(rateLimitNoticeTimer);
+ rateLimitNoticeTimer = 0;
+ if (!rateLimitNotice) return;
+ setHidden(rateLimitNotice, true);
+ if ('cooldownSeconds' in rateLimitNotice.dataset) delete rateLimitNotice.dataset.cooldownSeconds;
+ };
+ const startRateLimitNotice = () => {
+ if (rateLimitNoticeTimer) clearInterval(rateLimitNoticeTimer);
+ rateLimitNoticeTimer = 0;
+ if (document.visibilityState !== 'visible') return;
+ syncRateLimitNotice();
+ rateLimitNoticeTimer = setInterval(syncRateLimitNotice, 1000);
+ };
+ startRateLimitNotice();
+ const loader = createLoader(topicId, postRequestScheduler, performance);
+ const initialTopicRequest = loader.init();
+
+ const notificationState = {
+ group: 'all', page: 0, token: 0, total: 0, hasNext: false, query: '',
+ groups: { notifications: 'all', messages: 'inbox' },
+ };
+ let notificationQueuedRequestKey = '';
+ const currentReaderRequestScheduler = () => (
+ readerShell.activeContext?.postRequestScheduler
+ ) || ACTIVE_READER_REQUEST_SCHEDULER || postRequestScheduler;
+ const historyState = { page: 0, query: '', multi: false, scope: 'page' };
+ const historySelection = new Set();
+ let historyScopeIds = [];
+ const bookmarksState = {
+ page: 0,
+ query: '',
+ token: 0,
+ hasNext: false,
+ entries: [],
+ allEntries: [],
+ reactionEntries: [],
+ reactionLoadedAt: 0,
+ reactionUsername: '',
+ reactionFilter: '',
+ type: bookmarkTabOrder[0],
+ multi: false,
+ scope: 'page',
+ allIds: null,
+ };
+ const givenReactionOverrides = readerShell.givenReactionOverrides instanceof Map
+ ? readerShell.givenReactionOverrides
+ : new Map();
+ readerShell.givenReactionOverrides = givenReactionOverrides;
+ const bookmarksSelection = new Set();
+ const clearBookmarksSelectionScope = () => {
+ bookmarksState.allIds = null;
+ bookmarksSelection.clear();
+ };
+ const cachedNotificationWarmup = getCachedNotificationPage(notificationPageCacheKey('all', 0));
+ if (cachedNotificationWarmup) {
+ notificationState.total = cachedNotificationWarmup.total;
+ notificationState.hasNext = cachedNotificationWarmup.hasNext;
+ }
+ syncNotificationUnreadUi(ME_CURRENT_USER ? NOTIFICATION_UNREAD_COUNT : cachedUnreadNotificationCount());
+ const positionHeaderPopover = (button, popover) => {
+ if (!button || !popover || popover.hidden || !popover.isConnected) return;
+ const buttonRect = button.getBoundingClientRect();
+ const popoverRect = popover.getBoundingClientRect();
+ const gap = 8;
+ const left = Math.max(12, Math.min(window.innerWidth - popoverRect.width - 12,
+ buttonRect.right - popoverRect.width));
+ const below = buttonRect.bottom + gap;
+ const above = buttonRect.top - popoverRect.height - gap;
+ const nextLeft = `${Math.round(left)}px`;
+ const nextTop = `${Math.round(
+ below + popoverRect.height <= window.innerHeight - 12 || above < 12 ? below : above
+ )}px`;
+ if (popover.style.left !== nextLeft) popover.style.left = nextLeft;
+ if (popover.style.top !== nextTop) popover.style.top = nextTop;
+ };
+ const createCollectionPanelController = ({ toggle, popover, panel, state, close }) => {
+ const mount = () => surfaceManager.mount(popover);
+ const park = () => {
+ setHidden(popover, true);
+ setExpanded(toggle, false);
+ surfaceManager.park(popover);
+ };
+ return {
+ toggle,
+ popover,
+ position: () => positionHeaderPopover(toggle, popover),
+ open() {
+ mount();
+ setHidden(popover, false);
+ setExpanded(toggle, true);
+ },
+ close() {
+ const wasOpen = !popover.hidden;
+ setHidden(popover, true);
+ setExpanded(toggle, false);
+ surfaceManager.park(popover);
+ close?.();
+ if (wasOpen) {
+ releasePersistentAvatarImages(panel.list);
+ panel.list.replaceChildren();
+ }
+ },
+ park,
+ message(copy, error = false) {
+ releasePersistentAvatarImages(panel.list);
+ panel.list.innerHTML = `
${copy}
`;
+ panel.pagePrev.disabled = true;
+ panel.pageNext.disabled = true;
+ },
+ paging(totalPages, hasEntries = true, hasNext = state.hasNext) {
+ panel.pagePrev.disabled = state.page <= 0;
+ panel.pageNext.disabled = !hasNext;
+ setText(panel.pageInfo, hasEntries
+ ? `${state.page + 1} / ${Math.max(1, totalPages)}`
+ : '暂无记录');
+ },
+ };
+ };
+ const notificationCollection = createCollectionPanelController({
+ toggle: notificationsBtn, popover: notificationsPopover, panel: notificationPanel, state: notificationState,
+ });
+ const historyCollection = createCollectionPanelController({
+ toggle: historyPanel.toggle, popover: historyPanel.popover, panel: historyPanel, state: historyState,
+ close: () => {
+ historyState.multi = false;
+ historySelection.clear();
+ },
+ });
+ const bookmarksCollection = createCollectionPanelController({
+ toggle: bookmarksPanel.toggle, popover: bookmarksPanel.popover, panel: bookmarksPanel, state: bookmarksState,
+ close: () => {
+ readerShell.cancelBookmarksSearch?.();
+ bookmarksState.token++;
+ bookmarksState.multi = false;
+ clearBookmarksSelectionScope();
+ },
+ });
+ const positionNotificationsPopover = notificationCollection.position;
+ const closeNotifications = notificationCollection.close;
+ readerShell.closeNotifications = closeNotifications;
+ const positionHistoryPopover = historyCollection.position;
+ const closeHistory = historyCollection.close;
+ const positionBookmarksPopover = bookmarksCollection.position;
+ const closeBookmarks = bookmarksCollection.close;
+ const collectionPanels = [notificationCollection, historyCollection, bookmarksCollection];
+ collectionPanels.forEach((panel) => panel.park());
+ const positionHeaderPopovers = () => collectionPanels.forEach((panel) => panel.position());
+ const syncHistorySortToggle = () => {
+ const recentFirst = PREFS.historySortMode === 'recent-viewed';
+ const label = recentFirst
+ ? '最近打开优先;点击切换为首次打开顺序'
+ : '首次打开顺序固定;点击切换为最近打开优先';
+ historyPanel.sortToggle.innerHTML = icon(recentFirst ? 'history' : 'pin');
+ setLabel(historyPanel.sortToggle, label);
+ setPressed(historyPanel.sortToggle, !recentFirst);
+ historyPanel.sortToggle.dataset.ldpTooltipLabel = label;
+ };
+ const syncCollectionSelectionControls = (
+ panel, state, selection, scopeIds, itemLabel, multiDisabled, selectDisabled, unavailable = false
+ ) => {
+ const fullySelected = Array.isArray(scopeIds) && scopeIds.length > 0 &&
+ scopeIds.every((id) => selection.has(id));
+ panel.defaultActions.hidden = unavailable || state.multi;
+ panel.bulkActions.hidden = unavailable || !state.multi;
+ panel.multi.disabled = multiDisabled;
+ panel.selectScope.value = state.scope;
+ panel.selectToggle.disabled = selectDisabled;
+ panel.selectToggle.innerHTML = icon(fullySelected ? 'squareCheck' : 'square');
+ setPressed(panel.selectToggle, fullySelected);
+ setLabel(panel.selectToggle, `${fullySelected ? '全不选' : '全选'}${state.scope === 'all' ? '全部页面' : '本页'}${itemLabel}`);
+ panel.deleteSelected.disabled = selection.size === 0;
+ panel.deleteSelectedLabel.textContent = String(selection.size);
+ panel.deleteSelectedLabel.hidden = selection.size === 0;
+ };
+ const localCollectionPage = (entries, state, pageSize) => {
+ const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
+ state.page = Math.max(0, Math.min(totalPages - 1, state.page));
+ const start = state.page * pageSize;
+ return { entries: entries.slice(start, start + pageSize), totalPages };
+ };
+ function renderHistoryPage() {
+ syncHistorySortToggle();
+ const storedHistory = readTopicHistory();
+ const allEntries = orderedTopicHistory(storedHistory);
+ const availableTopicIds = new Set(allEntries.map((entry) => Number(entry.topicId)));
+ historySelection.forEach((topicId) => {
+ if (!availableTopicIds.has(topicId)) historySelection.delete(topicId);
+ });
+ const entries = historyState.query
+ ? allEntries.filter((entry) => historyMatchesSearch(entry, historyState.query))
+ : allEntries;
+ const { entries: pageEntries, totalPages } =
+ localCollectionPage(entries, historyState, HISTORY_PAGE_SIZE);
+ releasePersistentAvatarImages(historyPanel.list);
+ historyPanel.list.innerHTML = pageEntries.length
+ ? pageEntries.map((entry) => {
+ const cachedSource = entry.avatarTemplate &&
+ touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, avatarUrl(entry.avatarTemplate, 64));
+ const historyAvatar = cachedSource
+ ? `
})
`
+ : entry.avatarTemplate
+ ? `
${esc(String(entry.ownerUsername || '?').slice(0, 1).toUpperCase())}`
+ : `
${icon('history')}`;
+ const historyUserAvatar = userCardAvatarHtml(historyAvatar, entry.ownerUsername);
+ const selected = historySelection.has(Number(entry.topicId));
+ const targetPostNumber = Math.max(1, Number(entry.postNumber) || 1);
+ return `
+ ${historyState.multi ? `
+
+ ` : ''}
+ ${collectionLinkMarkup('ldp-history-link', topicPageUrl(entry.topicId, targetPostNumber, true),
+ historyUserAvatar, entry.title,
+ `${entry.postsCount || 0} 帖 · ${discourseRelativeTime(historyEntrySortTime(entry))}`)}
+ ${historyState.multi ? '' : ``}
+
`;
+ }).join('')
+ : `
${historyState.query ? '没有匹配的浏览历史' : '暂无浏览历史'}
`;
+ hydrateHistoryAvatars(historyPanel.list, pageEntries, storedHistory);
+ historyState.hasNext = historyState.page < totalPages - 1;
+ const scopeEntries = historyState.scope === 'all' ? entries : pageEntries;
+ historyScopeIds = scopeEntries.map((entry) => Number(entry.topicId));
+ syncCollectionSelectionControls(
+ historyPanel, historyState, historySelection, historyScopeIds,
+ '浏览历史', allEntries.length === 0, scopeEntries.length === 0
+ );
+ historyPanel.clear.disabled = allEntries.length === 0;
+ historyCollection.paging(totalPages, entries.length > 0);
+ positionHistoryPopover();
+ }
+ readerShell.refreshHistoryPanel = () => {
+ if (historyPanel.popover.hidden) return;
+ if (PREFS.historySortMode === 'recent-viewed') historyState.page = 0;
+ renderHistoryPage();
+ };
+ const bookmarkAvatarHtml = (entry) => {
+ const user = entry?.user || {};
+ const avatar = persistentAvatarHtml(
+ user.avatar_template,
+ 64,
+ 'ldp-notification-avatar',
+ user.name || user.username || '?'
+ );
+ return avatar
+ ? userCardAvatarHtml(avatar, user.username)
+ : `
${icon('bookmark')}`;
+ };
+ const bookmarkTarget = (entry) => ({
+ topicId: Number(entry?.topic_id) || 0,
+ postNumber: Math.max(1, Number(entry?.linked_post_number) || 1),
+ });
+ const reactionPageEntries = (result) => {
+ if (Array.isArray(result)) return result;
+ if (Array.isArray(result?.user_reactions)) return result.user_reactions;
+ if (Array.isArray(result?.reaction_users)) return result.reaction_users;
+ if (Array.isArray(result?.reactions)) return result.reactions;
+ return [];
+ };
+ const givenReactionRecord = (post, topic, reaction, id = 0, reactedAt = '', title = '') => {
+ const postId = Number(post?.id) || 0;
+ const topicId = Number(post?.topic_id || topic?.id) || 0;
+ return {
+ id: Number(id) || postId,
+ topicId,
+ postId,
+ postNumber: Math.max(1, Number(post?.post_number) || 1),
+ reaction,
+ title: String(post?.topic_title || title || topic?.title || `帖子 #${topicId}`),
+ authorUsername: String(post?.username || ''),
+ avatarTemplate: String(post?.avatar_template || ''),
+ reactedAt: String(reactedAt || ''),
+ };
+ };
+ const normalizeGivenReaction = (entry) => {
+ const post = entry?.post || {};
+ const topic = post.topic || {};
+ const user = post.user || {};
+ const reactionData = entry?.reaction || {};
+ const id = Number(entry?.id) || 0;
+ const reaction = String(reactionData.reaction_value || entry?.reaction_value || '');
+ const sourcePost = {
+ id: entry?.post_id || post.id,
+ topic_id: post.topic_id || topic.id || entry?.topic_id,
+ post_number: post.post_number || entry?.post_number,
+ topic_title: post.topic_title || topic.title || entry?.topic_title,
+ username: post.username || user.username,
+ avatar_template: post.avatar_template || user.avatar_template,
+ };
+ if (!id || !Number(sourcePost.topic_id) || !Number(sourcePost.id) || !reaction) return null;
+ return givenReactionRecord(sourcePost, null, reaction, id, entry?.created_at || reactionData.created_at);
+ };
+ const normalizeGivenLike = (entry) => {
+ const topicId = Number(entry?.topic_id) || 0;
+ const postId = Number(entry?.post_id) || 0;
+ if (Number(entry?.action_type) !== 1 || !topicId || !postId) return null;
+ return givenReactionRecord({ ...entry, id: postId }, null, 'heart', entry.id, entry.created_at, entry.title);
+ };
+ const givenReactionEntriesByPostId = (entries) => new Map((Array.isArray(entries) ? entries : [])
+ .map((entry) => [Number(entry?.postId), entry])
+ .filter(([postId]) => postId > 0));
+ const mergeGivenReactionOverrides = (entries) => {
+ const entriesByPostId = givenReactionEntriesByPostId(entries);
+ givenReactionOverrides.forEach((override, postId) => {
+ const serverEntry = entriesByPostId.get(postId);
+ if (override && Number(serverEntry?.id) > 0 && serverEntry.reaction === override.reaction) {
+ givenReactionOverrides.delete(postId);
+ return;
+ }
+ if (!override && !serverEntry) {
+ givenReactionOverrides.delete(postId);
+ return;
+ }
+ entriesByPostId.delete(postId);
+ if (override) entriesByPostId.set(postId, override);
+ });
+ return [...entriesByPostId.values()].sort(compareGivenReactionRecords);
+ };
+ const confirmedGivenReactionRecord = (post, topic, reaction) => {
+ const postId = Number(post?.id) || 0;
+ return givenReactionRecord(post, topic, reaction, -postId, new Date().toISOString());
+ };
+ const rememberConfirmedGivenReaction = (post, topic) => {
+ const postId = Number(post?.id) || 0;
+ if (!postId) return false;
+ const current = post?.current_user_reaction;
+ const reaction = String(current?.id || '');
+ givenReactionOverrides.set(postId, reaction ? confirmedGivenReactionRecord(post, topic, reaction) : null);
+ return true;
+ };
+ const applyConfirmedGivenLike = (entries, post, topic, acted) => {
+ const postId = Number(post?.id) || 0;
+ if (!postId) return Array.isArray(entries) ? entries : [];
+ const entriesByPostId = givenReactionEntriesByPostId(entries);
+ const current = entriesByPostId.get(postId);
+ if (acted && (!current || current.reaction === 'heart'))
+ entriesByPostId.set(postId, confirmedGivenReactionRecord(post, topic, 'heart'));
+ else if (!acted && current && current.reaction === 'heart')
+ entriesByPostId.delete(postId);
+ return [...entriesByPostId.values()].sort(compareGivenReactionRecords);
+ };
+ const fetchGivenReactionPage = (username, beforeReactionUserId = 0) => {
+ const reactionModule = lookupDiscourseModule(
+ 'discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction'
+ );
+ const CustomReaction = reactionModule?.default;
+ if (!CustomReaction || !isFunction(CustomReaction.findReactions))
+ return Promise.reject(new Error('Discourse 回应记录接口尚未就绪'));
+ return CustomReaction.findReactions('reactions', username, {
+ beforeReactionUserId: beforeReactionUserId > 0 ? beforeReactionUserId : undefined,
+ });
+ };
+ const collectPagedEntries = async (loadPage, pageRecords, normalize, identity, isCurrent) => {
+ const entriesById = new Map();
+ const seenCursors = new Set();
+ let cursor = 0;
+ while (true) {
+ const result = await loadPage(cursor);
+ if (!isCurrent()) return null;
+ const [records, nextCursor] = pageRecords(result, cursor);
+ records.forEach((record) => {
+ const entry = normalize(record);
+ if (entry) entriesById.set(identity(entry), entry);
+ });
+ if (nextCursor == null || seenCursors.has(nextCursor)) break;
+ seenCursors.add(nextCursor);
+ cursor = nextCursor;
+ }
+ return [...entriesById.values()];
+ };
+ const reactionCollectionIsCurrent = (token) => token === bookmarksState.token &&
+ !bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction';
+ const fetchAllGivenReactions = (username, token = bookmarksState.token) => collectPagedEntries(
+ (cursor) => fetchGivenReactionPage(username, cursor),
+ (result) => {
+ const records = reactionPageEntries(result);
+ const next = records.length >= GIVEN_REACTIONS_PAGE_SIZE && records.reduce((lowest, entry) => {
+ const id = Number(entry?.id) || 0;
+ return id > 0 && (!lowest || id < lowest) ? id : lowest;
+ }, 0);
+ return [records, next || null];
+ },
+ normalizeGivenReaction, (entry) => entry.id, () => reactionCollectionIsCurrent(token)
+ );
+ const fetchGivenLikePage = (username, offset = 0) => {
+ const params = new URLSearchParams({
+ username,
+ filter: '1',
+ offset: String(Math.max(0, Number(offset) || 0)),
+ limit: String(GIVEN_LIKES_PAGE_SIZE),
+ });
+ return fetchJSON(`${BASE}/user_actions.json?${params}`, {
+ key: `likes-given:${username}:${offset}`,
+ priority: POST_REQUEST_PRIORITY.auxiliary,
+ cacheMode: 'default',
+ });
+ };
+ const fetchAllGivenLikes = (username, token = bookmarksState.token) => collectPagedEntries(
+ (offset) => fetchGivenLikePage(username, offset),
+ (result, offset) => {
+ const records = Array.isArray(result?.user_actions) ? result.user_actions : [];
+ return [records, records.length < GIVEN_LIKES_PAGE_SIZE ? null : offset + records.length];
+ },
+ normalizeGivenLike, (entry) => entry.postId, () => reactionCollectionIsCurrent(token)
+ );
+ const reactionRecordEntries = () => {
+ const needle = normalizeSearchText(bookmarksState.query);
+ return bookmarksState.reactionEntries.filter((entry) =>
+ (!bookmarksState.reactionFilter || entry.reaction === bookmarksState.reactionFilter) &&
+ reactionRecordMatchesSearch(entry, needle));
+ };
+ const reactionAvatarHtml = (entry) => userCardAvatarHtml(
+ persistentAvatarHtml(
+ entry?.avatarTemplate,
+ 64,
+ 'ldp-notification-avatar',
+ entry?.authorUsername || '?'
+ ) || `
${esc(String(entry?.authorUsername || '?').slice(0, 1).toUpperCase())}`,
+ entry?.authorUsername
+ );
+ const bookmarkTypeEntries = () => bookmarksState.type === 'Reaction'
+ ? reactionRecordEntries()
+ : bookmarksState.allEntries.filter((entry) => entry && entry.bookmarkable_type === bookmarksState.type);
+ const renderBookmarksPage = () => {
+ const isReaction = bookmarksState.type === 'Reaction';
+ const reactionTypeCounts = new Map();
+ if (isReaction) bookmarksState.reactionEntries.forEach((entry) => {
+ const reaction = String(entry?.reaction || '');
+ if (reaction) reactionTypeCounts.set(reaction, (reactionTypeCounts.get(reaction) || 0) + 1);
+ });
+ const availableReactionTypes = [...reactionTypeCounts.keys()];
+ if (bookmarksState.reactionFilter && !availableReactionTypes.includes(bookmarksState.reactionFilter)) {
+ bookmarksState.reactionFilter = '';
+ }
+ bookmarksReactionFilters.hidden = !isReaction || availableReactionTypes.length === 0;
+ bookmarksReactionFilters.innerHTML = isReaction && availableReactionTypes.length
+ ? [`
`,
+ ...availableReactionTypes.map((reaction) => {
+ const active = reaction === bookmarksState.reactionFilter;
+ const reactionIcon = reactionLabel(reaction) || esc(`:${reaction}:`);
+ const count = reactionTypeCounts.get(reaction) || 0;
+ return `
`;
+ })].join('')
+ : '';
+ const typeEntries = bookmarkTypeEntries();
+ const { entries, totalPages } =
+ localCollectionPage(typeEntries, bookmarksState, BOOKMARKS_PAGE_SIZE);
+ bookmarksState.entries = entries;
+ bookmarksState.hasNext = bookmarksState.page < totalPages - 1;
+ if (bookmarksSelection.size) {
+ const availableIds = new Set(isReaction ? [] : typeEntries.map((entry) => Number(entry.id)));
+ bookmarksSelection.forEach((bookmarkId) => {
+ if (!availableIds.has(bookmarkId)) bookmarksSelection.delete(bookmarkId);
+ });
+ }
+ releasePersistentAvatarImages(bookmarksPanel.list);
+ if (isReaction) {
+ bookmarksPanel.list.innerHTML = entries.length
+ ? entries.map((entry) => {
+ const reaction = String(entry.reaction || '');
+ const reactionIcon = reactionLabel(reaction) || esc(`:${reaction}:`);
+ const authorLabel = entry.authorUsername ? ` · @${entry.authorUsername}` : '';
+ const topicId = Number(entry.topicId);
+ const postNumber = Math.max(1, Number(entry.postNumber) || 1);
+ return `
+ ${collectionLinkMarkup('ldp-bookmark-link', topicPageUrl(topicId, postNumber, true),
+ reactionAvatarHtml(entry), entry.title || `帖子 #${entry.topicId}`,
+ `${reactionIcon}${esc(`回应 · 楼层 #${postNumber}${authorLabel} · ${discourseRelativeTime(entry.reactedAt)}`)}`, true)}
+
`;
+ }).join('')
+ : `
${bookmarksState.query
+ ? '没有匹配的回应记录'
+ : '暂无回应记录;在楼层下方点回应后会出现在这里。'}
`;
+ } else {
+ bookmarksPanel.list.innerHTML = entries.length
+ ? entries.map((entry) => {
+ const bookmarkId = Number(entry.id);
+ const target = bookmarkTarget(entry);
+ const selected = bookmarksSelection.has(bookmarkId);
+ const typeLabel = entry.bookmarkable_type === 'Post' ? `楼层 #${target.postNumber}` : '帖子';
+ const postsLabel = Number(entry.highest_post_number) > 0 ? ` · ${Number(entry.highest_post_number)} 帖` : '';
+ const nameLabel = String(entry.name || '').trim() ? ` · ${String(entry.name).trim()}` : '';
+ return `
+ ${bookmarksState.multi ? `
+
+ ` : ''}
+ ${collectionLinkMarkup('ldp-bookmark-link', topicPageUrl(target.topicId, target.postNumber, true),
+ bookmarkAvatarHtml(entry), entry.title || `帖子 #${target.topicId}`,
+ `${typeLabel}${postsLabel}${nameLabel} · ${discourseRelativeTime(entry.created_at)}`)}
+ ${bookmarksState.multi ? '' : ``}
+
`;
+ }).join('')
+ : `
${bookmarksState.query
+ ? `没有匹配的${bookmarksState.type === 'Post' ? '楼层' : '帖子'}收藏`
+ : `暂无${bookmarksState.type === 'Post' ? '楼层' : '帖子'}收藏`}
`;
+ }
+ hydratePersistentAvatarImages(bookmarksPanel.list, POST_REQUEST_PRIORITY.auxiliary);
+ syncTabSelection(bookmarksTabs, 'bookmarkType', bookmarksState.type);
+ bookmarksPanel.search.placeholder = isReaction ? '搜索回应、帖子或用户' : '搜索收藏标题或内容';
+ setLabel(bookmarksPanel.search, isReaction ? '搜索回应记录' : '搜索收藏');
+ setLabel(bookmarksPanel.searchClear, isReaction ? '清空回应搜索' : '清空收藏搜索');
+ const currentIds = isReaction ? [] : entries.map((entry) => Number(entry.id)).filter((id) => id > 0);
+ const scopeIds = bookmarksState.scope === 'all' ? bookmarksState.allIds : currentIds;
+ syncCollectionSelectionControls(
+ bookmarksPanel, bookmarksState, bookmarksSelection, scopeIds, '收藏', typeEntries.length === 0,
+ bookmarksState.scope === 'page' && currentIds.length === 0, isReaction
+ );
+ bookmarksCollection.paging(totalPages, typeEntries.length > 0);
+ positionBookmarksPopover();
+ };
+ const fetchAllBookmarkEntries = (query = bookmarksState.query, token = bookmarksState.token) => collectPagedEntries(
+ async (page) => {
+ const username = syncCurrentUserFromHost();
+ if (!username) throw new Error('登录后才能查看收藏');
+ const params = new URLSearchParams({ page: String(Math.max(0, Number(page) || 0)) });
+ if (query) params.set('q', query);
+ return fetchJSON(`${BASE}/u/${encodeURIComponent(username)}/bookmarks.json?${params}`, {
+ key: `bookmarks:${page}:${query}`,
+ priority: POST_REQUEST_PRIORITY.auxiliary,
+ cacheMode: 'default',
+ });
+ },
+ (result, page) => {
+ const payload = result?.user_bookmark_list || result || {};
+ return [Array.isArray(payload.bookmarks) ? payload.bookmarks : [], payload.more_bookmarks_url ? page + 1 : null];
+ },
+ (entry) => Number(entry?.id) > 0 ? entry : null,
+ (entry) => Number(entry.id),
+ () => token === bookmarksState.token && !bookmarksPanel.popover.hidden
+ );
+ const loadBookmarksPage = async () => {
+ const token = ++bookmarksState.token;
+ const isReaction = bookmarksState.type === 'Reaction';
+ let reactionSnapshotVisible = isReaction && bookmarksState.reactionLoadedAt > 0 &&
+ bookmarksState.reactionUsername === ME_USERNAME;
+ let reactionIconHydration = null;
+ const hydrateReactionIcons = () => {
+ if (reactionEmojiUrls || reactionIconHydration) return;
+ reactionIconHydration = loadReactionEmojiRegistry().then(() => {
+ if (reactionSnapshotVisible && token === bookmarksState.token &&
+ !bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction') renderBookmarksPage();
+ }).finally(() => { reactionIconHydration = null; });
+ };
+ if (reactionSnapshotVisible) {
+ renderBookmarksPage();
+ } else {
+ bookmarksCollection.message(`正在加载${isReaction ? '回应' : '收藏'}…`);
+ }
+ try {
+ if (isReaction) {
+ const username = syncCurrentUserFromHost();
+ if (!username) throw new Error('登录后才能查看回应记录');
+ if (bookmarksState.reactionUsername && bookmarksState.reactionUsername !== username) {
+ bookmarksState.reactionEntries = [];
+ bookmarksState.reactionLoadedAt = 0;
+ reactionSnapshotVisible = false;
+ }
+ if (!reactionSnapshotVisible && bookmarksState.reactionLoadedAt > 0 &&
+ bookmarksState.reactionUsername === username) {
+ reactionSnapshotVisible = true;
+ renderBookmarksPage();
+ }
+ if (!reactionSnapshotVisible) {
+ const snapshot = await getGivenReactionSnapshot(username);
+ if (token !== bookmarksState.token || bookmarksPanel.popover.hidden || bookmarksState.type !== 'Reaction') return;
+ if (snapshot) {
+ bookmarksState.reactionEntries = mergeGivenReactionOverrides(snapshot.entries);
+ bookmarksState.reactionLoadedAt = snapshot.storedAt;
+ bookmarksState.reactionUsername = username;
+ reactionSnapshotVisible = true;
+ renderBookmarksPage();
+ hydrateReactionIcons();
+ if (snapshot.fresh) return;
+ }
+ } else {
+ hydrateReactionIcons();
+ if (Date.now() - bookmarksState.reactionLoadedAt <= MINUTE) return;
+ }
+ const [reactionEntries, likeEntries] = await Promise.all([
+ fetchAllGivenReactions(username, token),
+ fetchAllGivenLikes(username, token),
+ ]);
+ if (!reactionEntries || !likeEntries || token !== bookmarksState.token || bookmarksPanel.popover.hidden || bookmarksState.type !== 'Reaction') return;
+ bookmarksState.reactionEntries = mergeGivenReactionOverrides([...likeEntries, ...reactionEntries]);
+ bookmarksState.reactionLoadedAt = Date.now();
+ bookmarksState.reactionUsername = username;
+ reactionSnapshotVisible = true;
+ setGivenReactionSnapshot(username, bookmarksState.reactionEntries);
+ bookmarksState.multi = false;
+ clearBookmarksSelectionScope();
+ renderBookmarksPage();
+ hydrateReactionIcons();
+ return;
+ }
+ const entries = await fetchAllBookmarkEntries(bookmarksState.query, token);
+ if (!entries || token !== bookmarksState.token || bookmarksPanel.popover.hidden) return;
+ bookmarksState.allEntries = entries;
+ bookmarksState.allIds = null;
+ renderBookmarksPage();
+ } catch (error) {
+ if (token !== bookmarksState.token || bookmarksPanel.popover.hidden || error && error.name === 'AbortError') return;
+ if (isReaction && reactionSnapshotVisible) return;
+ bookmarksState.entries = [];
+ if (isReaction) bookmarksState.reactionEntries = [];
+ else bookmarksState.allEntries = [];
+ bookmarksState.hasNext = false;
+ bookmarksCollection.message(`${isReaction ? '回应记录' : '收藏'}加载失败:${esc(error.message)}`, true);
+ bookmarksPanel.pageInfo.textContent = `第 ${bookmarksState.page + 1} 页`;
+ }
+ };
+ const applyRemovedBookmarkState = (entry) => {
+ const activeCtx = readerShell.activeContext;
+ const target = bookmarkTarget(entry);
+ if (!activeCtx || Number(activeCtx.topicId) !== target.topicId) return;
+ const type = entry && entry.bookmarkable_type === 'Post' ? 'Post' : 'Topic';
+ syncReaderBookmarkState(activeCtx, type, entry?.bookmarkable_id, false, null, { persist: true });
+ };
+ const syncNotificationModeUi = () => {
+ const group = NOTIFICATION_GROUPS[notificationState.group] || NOTIFICATION_GROUPS.all;
+ const mode = notificationGroupMode(notificationState.group);
+ syncTabSelection(notificationModeTabs, 'notificationMode', mode);
+ notificationModePanels.forEach((panel) =>
+ setHidden(panel, panel.dataset.notificationModePanel !== mode)
+ );
+ setHidden(notificationPanel.toolbar, group.source !== 'notifications' && mode !== 'messages');
+ setHidden(notificationPanel.unreadStatus, group.source !== 'notifications');
+ setHidden(notificationPanel.markAll, group.source !== 'notifications');
+ setHidden(notificationPanel.newMessage, mode !== 'messages');
+ };
+ const renderNotificationPage = (result, options = {}) => {
+ const visibleNotifications = result.notifications;
+ notificationState.total = result.total;
+ notificationState.hasNext = result.hasNext;
+ releasePersistentAvatarImages(notificationPanel.list);
+ notificationPanel.list.innerHTML = visibleNotifications.length
+ ? renderNotificationList(visibleNotifications)
+ : `
${options.search ? '本地缓存中没有匹配消息' : '暂无消息'}
`;
+ hydrateNotificationAvatars(notificationPanel.list, visibleNotifications);
+ if (NOTIFICATION_GROUPS[notificationState.group]?.source === 'notifications' &&
+ notificationPanel.markAll.dataset.ldpRequestBusy !== '1')
+ notificationPanel.markAll.disabled = cachedUnreadNotificationCount(notificationState.group) <= 0;
+ const totalPages = Math.max(
+ 1,
+ Math.ceil(notificationState.total / notificationGroupPageSize(notificationState.group))
+ );
+ notificationCollection.paging(totalPages, notificationState.total > 0);
+ if (!notificationState.total && options.search) notificationPanel.pageInfo.textContent = '本地缓存';
+ positionNotificationsPopover();
+ };
+ const renderNotificationSearchPage = () => {
+ const needle = normalizeSearchText(notificationState.query);
+ const matches = cachedNotificationsForGroup(notificationState.group)
+ .filter((notification) => notificationMatchesSearch(notification, needle));
+ const pageSize = notificationGroupPageSize(notificationState.group);
+ const { entries, totalPages } =
+ localCollectionPage(matches, notificationState, pageSize);
+ renderNotificationPage({
+ notifications: entries,
+ total: matches.length,
+ hasNext: notificationState.page < totalPages - 1,
+ }, { search: true });
+ };
+ const loadNotificationPage = async (groupKey, page, options = {}) => {
+ const token = ++notificationState.token;
+ const force = options.force === true;
+ notificationState.group = Object.hasOwn(NOTIFICATION_GROUPS, groupKey) ? groupKey : 'all';
+ notificationState.groups[notificationGroupMode(notificationState.group)] = notificationState.group;
+ notificationState.page = Math.max(0, Number(page) || 0);
+ notificationPanel.pageInfo.removeAttribute('title');
+ syncNotificationModeUi();
+ syncTabSelection(notificationTabs, 'notificationGroup', notificationState.group);
+ if (notificationState.query) {
+ if (notificationQueuedRequestKey) currentReaderRequestScheduler().cancelQueued(notificationQueuedRequestKey);
+ notificationQueuedRequestKey = '';
+ renderNotificationSearchPage();
+ return;
+ }
+ const cacheKey = notificationPageCacheKey(notificationState.group, notificationState.page);
+ const requestKey = `notifications:${cacheKey}`;
+ if (notificationQueuedRequestKey && notificationQueuedRequestKey !== requestKey)
+ currentReaderRequestScheduler().cancelQueued(notificationQueuedRequestKey);
+ const cached = getCachedNotificationPage(cacheKey);
+ const cachedEntry = cached && NOTIFICATION_CACHE.get(cacheKey);
+ const cachedIsFresh = cachedEntry &&
+ Date.now() - Number(cachedEntry.at || 0) <= NOTIFICATION_CACHE_FRESH_FOR;
+ if (cached) {
+ renderNotificationPage(cached);
+ if (cachedIsFresh && !force) {
+ notificationQueuedRequestKey = '';
+ return;
+ }
+ }
+ notificationQueuedRequestKey = requestKey;
+ if (!cached)
+ notificationCollection.message('正在加载消息…');
+ try {
+ const result = await fetchNotificationPage(notificationState.group, notificationState.page, {
+ force: force || !!cached,
+ });
+ if (token !== notificationState.token || notificationsPopover.hidden) return;
+ renderNotificationPage(result);
+ } catch (error) {
+ if (error && error.name === 'AbortError') return;
+ if (token !== notificationState.token || notificationsPopover.hidden) return;
+ if (cached) {
+ notificationPanel.pageInfo.textContent = `${notificationState.page + 1} 页 · 缓存更新失败`;
+ notificationPanel.pageInfo.title = String(error?.message || '无法更新缓存');
+ return;
+ }
+ notificationCollection.message(`消息加载失败:${esc(error.message)}`, true);
+ notificationPanel.pageInfo.textContent = `第 ${notificationState.page + 1} 页`;
+ } finally {
+ if (notificationQueuedRequestKey === requestKey) notificationQueuedRequestKey = '';
+ }
+ };
+ let notificationLiveAppEvents = null;
+ let notificationLiveHandler = null;
+ let notificationLiveRefreshTimer = 0;
+ const stopNotificationLiveEvents = () => {
+ if (notificationLiveRefreshTimer) clearTimeout(notificationLiveRefreshTimer);
+ notificationLiveRefreshTimer = 0;
+ if (notificationLiveAppEvents && notificationLiveHandler &&
+ isFunction(notificationLiveAppEvents.off)) {
+ try {
+ notificationLiveAppEvents.off('notifications:changed', readerShell, notificationLiveHandler);
+ } catch {}
+ }
+ notificationLiveAppEvents = null;
+ notificationLiveHandler = null;
+ };
+ const scheduleVisibleNotificationRefresh = () => {
+ if (notificationsPopover.hidden || notificationLiveRefreshTimer) return;
+ notificationLiveRefreshTimer = setTimeout(() => {
+ notificationLiveRefreshTimer = 0;
+ if (notificationsPopover.hidden) return;
+ loadNotificationPage(notificationState.group, notificationState.page, { force: true });
+ }, NOTIFICATION_LIVE_REFRESH_DEBOUNCE);
+ };
+ const applyNotificationLiveEvent = () => {
+ syncCurrentUserFromHost();
+ expireNotificationFirstPageCaches();
+ scheduleVisibleNotificationRefresh();
+ };
+ const startNotificationLiveEvents = () => {
+ if (notificationLiveAppEvents && notificationLiveHandler) return true;
+ stopNotificationLiveEvents();
+ const appEvents = lookupDiscourse('service:app-events');
+ if (!appEvents || !isFunction(appEvents.on)) return false;
+ const handler = () => applyNotificationLiveEvent();
+ try {
+ appEvents.on('notifications:changed', readerShell, handler);
+ notificationLiveAppEvents = appEvents;
+ notificationLiveHandler = handler;
+ return true;
+ } catch {
+ stopNotificationLiveEvents();
+ return false;
+ }
+ };
+ if (!reusingShell) {
+ readerShell.startNotificationLiveEvents = startNotificationLiveEvents;
+ readerShell.stopNotificationLiveEvents = stopNotificationLiveEvents;
+ onShell(document, 'visibilitychange', () => {
+ if (document.visibilityState !== 'visible') return;
+ syncCurrentUserFromHost();
+ readerShell.startNotificationLiveEvents?.();
+ expireNotificationFirstPageCaches();
+ if (!notificationsPopover.hidden) {
+ loadNotificationPage(notificationState.group, notificationState.page, { force: true });
+ }
+ });
+ }
+
+ const syncReaderBackgroundMode = () => {
+ setPageScrollLocked(readerWorkspace.isFullPage() || window.innerWidth <= READER_WINDOW_COMPACT_WIDTH);
+ };
+ let layoutDraftFullPage = readerWorkspace.isFullPage();
+ let layoutDraft = currentLayoutRatios(layoutDraftFullPage);
+ let fontSettingsScope = 'interface';
+ let fontDraft = currentFontProfile();
+ let appearanceDraft = currentAppearanceProfile();
+ let jumpHighlightDraft = jumpHighlightConfigFromPrefs(PREFS);
+ const currentFontDraft = () => fontDraft;
+ const setCurrentFontDraft = (values) => { fontDraft = normalizeFontProfile(values, READER_FONT_DEFAULT); };
+ const currentFontDefaults = () => normalizeFontProfile(READER_FONT_DEFAULT);
+ const currentLayoutProfileKey = () => layoutDraftFullPage
+ ? 'fullpageLayoutProfile'
+ : 'layoutProfile';
+ const currentLayoutProfileDefaults = () => layoutDraftFullPage
+ ? READER_FULLPAGE_LAYOUT_DEFAULT
+ : READER_LAYOUT_DEFAULT;
+ const currentSavedLayoutRatios = () => currentLayoutRatios(layoutDraftFullPage);
+ const syncLayoutDraftMode = (force = false) => {
+ const fullPage = readerWorkspace.isFullPage();
+ if (!force && layoutDraftFullPage === fullPage) return;
+ layoutDraftFullPage = fullPage;
+ layoutDraft = currentLayoutRatios(fullPage);
+ };
+ const currentLayoutDraft = () => layoutDraft;
+ const setCurrentLayoutDraft = (values) => {
+ layoutDraft = normalizeLayoutRatios(values, currentLayoutProfileDefaults());
+ };
+ const currentLayoutDefaults = () => normalizeLayoutRatios(currentLayoutProfileDefaults());
+ const currentAppearanceDraft = () => appearanceDraft;
+ const setCurrentAppearanceDraft = (values) => {
+ appearanceDraft = normalizeAppearanceProfile(values, APPEARANCE_PROFILE_DEFAULT);
+ };
+ const currentAppearanceDefaults = () => normalizeAppearanceProfile(APPEARANCE_PROFILE_DEFAULT);
+ const applyReaderVisualSettings = () => {
+ applyImageScale();
+ applyLayoutRatios();
+ applyFontScales();
+ applyAppearanceColors();
+ };
+ const syncOpenSettingsControls = (includeWindowControls = false) => {
+ if (settingsPopover.hidden) return;
+ syncImageScaleControls();
+ syncLayoutControls();
+ syncFontControls();
+ syncAppearanceControls();
+ if (includeWindowControls) syncReaderWindowControls();
+ };
+ const syncLayoutBtn = () => {
+ const fullPage = readerWorkspace.isFullPage();
+ layoutBtn.hidden = !directTopicRoute && !fullPage;
+ layoutBtn.innerHTML = `${icon(fullPage ? 'minimize2' : 'maximize2')}
${fullPage ? '浮窗' : '全页'}`;
+ setLabel(layoutBtn, fullPage ? '切换到浮窗' : '切换到全页');
+ readerWindow.syncMode();
+ syncReaderBackgroundMode();
+ };
+ const onReaderWorkspaceChange = () => {
+ syncLayoutBtn();
+ syncReaderPlacementUi();
+ syncLayoutDraftMode();
+ if (readerShell.syncSettingsSurfaceGeometry) readerShell.syncSettingsSurfaceGeometry();
+ else syncReaderSurfaceDensity();
+ syncReaderThemeSurfaces();
+ applyReaderVisualSettings();
+ const activeContext = readerShell.activeContext;
+ if (activeContext?.nativeComposerWindow) activeContext.nativeComposerWindow.syncLayer();
+ positionHeaderPopovers();
+ syncOpenSettingsControls(true);
+ requestAnimationFrame(() => {
+ readerShell.syncFullPageHeaderAlignment?.();
+ overlay._ldpSyncTopicActionRailPosition?.();
+ });
+ };
+ if (!reusingShell) {
+ syncLayoutBtn();
+ onShell(overlay, 'ldp-reader-workspace-change', onReaderWorkspaceChange);
+ onShell(layoutBtn, 'click', () => {
+ if (!directTopicRoute && !readerWorkspace.isFullPage()) return;
+ readerWorkspace.setMode(readerWorkspace.isFullPage() ? 'floating' : 'fullpage');
+ syncLayoutBtn();
+ syncOpenSettingsControls();
+ applyReaderVisualSettings();
+ positionHeaderPopovers();
+ const activeContext = readerShell.activeContext;
+ if (!activeContext) return;
+ activeContext.streamListOffset = NaN;
+ activeContext.streamNeedsRepair = true;
+ scheduleStreamWindowSync(activeContext);
+ });
+ }
+
+ const applyImageScale = () => {
+ overlay.style.setProperty('--ldp-image-zoom', String(currentImageScalePercent() / 100));
+ };
+ const fontSettingsSection = readerElement('[data-settings-panel="font"]');
+ const layoutSettingsSection = readerElement('[data-settings-panel="layout"]');
+ const layoutSettingsTitle = layoutSettingsSection.querySelector('.ldp-settings-title');
+ const layoutSettingsDescription = layoutSettingsSection.querySelector('.ldp-settings-description');
+ const appearanceSettingsSection = readerElement('[data-settings-panel="appearance"]');
+ const appearanceValuesChanged = () => {
+ const values = currentAppearanceDraft();
+ const saved = currentAppearanceProfile();
+ return APPEARANCE_PROFILE_VALUE_KEYS.some((key) => values[key] !== saved[key]);
+ };
+ const activeAppearanceColors = () => {
+ const previewing = !settingsPopover.hidden && !appearanceSettingsSection.hidden;
+ return previewing ? currentAppearanceDraft() : currentAppearanceProfile();
+ };
+ const activeFontScaleProfile = () => {
+ const previewing = !settingsPopover.hidden && !fontSettingsSection.hidden;
+ return previewing ? currentFontDraft() : currentFontProfile();
+ };
+ const applyFontScales = () => {
+ const values = activeFontScaleProfile();
+ const displayScale = Math.min(1.1, Math.max(1, .73 + modal.clientWidth / 4000));
+ const headerWidthProgress = Math.min(1, Math.max(0,
+ (modal.clientWidth - READER_WINDOW_MIN_WIDTH) /
+ (READER_WINDOW_DEFAULT_WIDTH - READER_WINDOW_MIN_WIDTH)));
+ const interfaceScale = normalizeFontScale(values.interface) / 100;
+ const headerFontPixels = (minimum, maximum) =>
+ `${Math.round((minimum + (maximum - minimum) * headerWidthProgress) * interfaceScale * 10) / 10}px`;
+ const scaledValues = { ...values, interface: values.interface * displayScale, post: values.post * displayScale, composer: values.composer * displayScale };
+ overlay.style.setProperty('--ldp-reader-display-scale', String(displayScale));
+ overlay.style.setProperty('--ldp-reader-title-font-size',
+ headerFontPixels(READER_TITLE_MIN_BASE_PX, INTERFACE_FONT_TOKEN_BASES['--ldp-font-xl']));
+ overlay.style.setProperty('--ldp-reader-meta-font-size',
+ headerFontPixels(READER_META_MIN_BASE_PX, INTERFACE_FONT_TOKEN_BASES['--ldp-font-sm']));
+ overlay.style.setProperty('--ldp-reader-topic-tag-font-size',
+ headerFontPixels(READER_TOPIC_TAG_MIN_BASE_PX, INTERFACE_FONT_TOKEN_BASES['--ldp-font-sm']));
+ applyInterfaceFontScale(overlay.style, scaledValues.interface);
+ applyInterfaceFontScale(READER_PORTAL_HOST?.style, scaledValues.interface);
+ applyReaderFontAppearance(overlay.style, scaledValues);
+ applyReaderFontAppearance(READER_PORTAL_HOST?.style, scaledValues);
+ applyReaderFontAppearance(PAGE_ROOT.style, scaledValues);
+ const postFontSize = fontScalePixels(POST_FONT_BASE_PX, scaledValues.post);
+ overlay.style.setProperty('--ldp-post-font-size', postFontSize);
+ READER_PORTAL_HOST?.style.setProperty('--ldp-post-font-size', postFontSize);
+ applyComposerFontScale(scaledValues.composer);
+ const activeContext = readerShell.activeContext;
+ if (activeContext?.nativeComposerWindow)
+ activeContext.nativeComposerWindow.syncFont(scaledValues);
+ readerShell.syncReaderTitleDensity?.();
+ readerShell.syncReaderTitleActionAlignment?.();
+ };
+ const applyLayoutRatios = () => {
+ const fullPage = readerWorkspace.isFullPage();
+ const previewing = !settingsPopover.hidden && !layoutSettingsSection.hidden &&
+ layoutDraftFullPage === fullPage;
+ const ratios = previewing ? currentLayoutDraft() : currentLayoutRatios(fullPage);
+ LAYOUT_REGION_KEYS.forEach((name) => {
+ overlay.style.setProperty(`--ldp-layout-${name}`, `${ratios[name]}%`);
+ });
+ requestAnimationFrame(() => readerShell.syncFullPageHeaderAlignment?.());
+ };
+ const applyInteractionColors = () => {
+ const surfaces = new Set([READER_PORTAL_HOST, overlay]);
+ [...READER_PORTAL_CONTEXT_BODY?.children || []].forEach((node) => surfaces.add(node));
+ document.querySelectorAll('[data-ldp-reader-composer-root]').forEach((node) => surfaces.add(node));
+ surfaces.forEach((node) => applyAppearanceInteractionColors(node, activeAppearanceColors()));
+ };
+ const applyZebraAppearance = () => {
+ const values = activeAppearanceColors();
+ overlay.style.setProperty('--ldp-zebra-color', appearanceThemeColor(values, 'zebraColor'));
+ overlay.style.setProperty('--ldp-zebra-radius', `${values.zebraRadius}px`);
+ };
+ const applyListZebraColor = () => {
+ const value = appearanceThemeColor(activeAppearanceColors(), 'listZebraColor');
+ if (!readerWorkspace.isEmbedded()) {
+ PAGE_ROOT.style.removeProperty('--ldp-reader-list-zebra-color');
+ return;
+ }
+ PAGE_ROOT.style.setProperty('--ldp-reader-list-zebra-color', value);
+ };
+ const applyLineAppearance = (type, emphasis) => {
+ const values = activeAppearanceColors();
+ const key = `${type}Line`;
+ const property = `--ldp-${type}-line`;
+ const colorKey = `${key}Color`;
+ const value = appearanceThemeColor(values, colorKey);
+ const defaultValue = APPEARANCE_PROFILE_DEFAULT[appearanceThemeValueKey(colorKey)];
+ const width = values[`${key}Width`];
+ const enabled = values.structureColorsEnabled !== false;
+ overlay.style.setProperty(`${property}-color`, enabled ? value : 'transparent');
+ overlay.style.setProperty(`${property}-width`, `${width}px`);
+ overlay.style.setProperty(`${property}-emphasis-width`, `${width * emphasis}px`);
+ if (type === 'reply') {
+ overlay.style.setProperty(`${property}-secondary-width`, `${width + 0.5}px`);
+ overlay.style.setProperty(`${property}-radius`, `${values.replyLineRadius}px`);
+ readerElements('.ldp-children').forEach(syncNestedReplyBranches);
+ const activeContext = readerShell.activeContext;
+ if (activeContext?.floorPreview && activeContext.floorPreview.isOpen()) {
+ scheduleFloorPreviewSync(activeContext);
+ }
+ return;
+ }
+ if (type !== 'divider') return;
+ const embeddedStructureColorsDisabled = readerWorkspace.isEmbedded() && !enabled;
+ PAGE_ROOT.classList.toggle('ldp-structure-colors-disabled', embeddedStructureColorsDisabled);
+ if (!readerWorkspace.isEmbedded() || !enabled || value === defaultValue) {
+ PAGE_ROOT.style.removeProperty('--ldp-divider-line-color');
+ } else {
+ PAGE_ROOT.style.setProperty('--ldp-divider-line-color', value);
+ }
+ if (!readerWorkspace.isEmbedded() || width === DIVIDER_LINE_WIDTH_DEFAULT) {
+ PAGE_ROOT.style.removeProperty('--ldp-divider-line-width');
+ } else {
+ PAGE_ROOT.style.setProperty('--ldp-divider-line-width', `${width}px`);
+ }
+ };
+ const applyReplyLineAppearance = () => applyLineAppearance('reply', 2);
+ const applyQuoteLineAppearance = () => applyLineAppearance('quote', 5);
+ const applyDividerLineAppearance = () => applyLineAppearance('divider', 2);
+ const applyFloorPreviewAppearance = () => {
+ const values = activeAppearanceColors();
+ overlay.style.setProperty('--ldp-floor-preview-color',
+ appearanceThemeColor(values, 'floorPreviewColor'));
+ overlay.style.setProperty('--ldp-floor-preview-height', `${values.floorPreviewHeight}px`);
+ const activeContext = readerShell.activeContext;
+ if (activeContext?.floorPreview && activeContext.floorPreview.isOpen()) {
+ scheduleFloorPreviewSync(activeContext);
+ }
+ };
+ const applyAppearanceColors = () => {
+ applyInteractionColors();
+ applyZebraAppearance();
+ applyListZebraColor();
+ applyReplyLineAppearance();
+ applyQuoteLineAppearance();
+ applyDividerLineAppearance();
+ applyFloorPreviewAppearance();
+ };
+ const applyJumpHighlightSettings = (config = jumpHighlightConfigFromPrefs(PREFS)) => {
+ overlay.style.setProperty('--ldp-jump-highlight-color', config.color);
+ overlay.style.setProperty('--ldp-jump-highlight-radius', `${config.radius}px`);
+ overlay.style.setProperty('--ldp-jump-highlight-border-width', `${config.borderWidth}px`);
+ overlay.style.setProperty('--ldp-jump-highlight-duration', `${jumpHighlightDurationMs(config)}ms`);
+ overlay.style.setProperty('--ldp-jump-highlight-count', String(config.count));
+ };
+ overlay._ldpApplyThemeDependentColors = () => {
+ applyAppearanceColors();
+ if (!settingsPopover.hidden && !appearanceSettingsSection.hidden) syncAppearanceControls();
+ };
+ const syncRangeVisual = (input) => {
+ const min = Number(input.min) || 0;
+ const max = Number(input.max) || 100;
+ const value = Number(input.value);
+ const progress = max > min && Number.isFinite(value)
+ ? Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100))
+ : 0;
+ input.style.setProperty('--ldp-range-progress', `${progress}%`);
+ };
+ const syncImageScaleControls = () => {
+ const scale = currentImageScalePercent();
+ imageScaleRange.value = String(scale);
+ imageScaleValue.textContent = `${scale}%`;
+ syncRangeVisual(imageScaleRange);
+ const defaults = IMAGE_PROFILE_DEFAULT;
+ const defaultScale = normalizeImageScalePercent(
+ defaults.preset === 'custom' ? defaults.custom : defaults.preset,
+ 100
+ );
+ imageScaleReset.disabled = scale === defaultScale;
+ lightboxOriginalByDefaultInput.checked = PREFS.lightboxOriginalByDefault === true;
+ lightboxCommentsExpandedInput.checked = PREFS.lightboxCommentsExpandedByDefault === true;
+ lightboxDescriptionExpandedInput.checked = PREFS.lightboxDescriptionExpanded === true;
+ };
+ let pageFontFamilies = [];
+ let localFontFamilies = [];
+ let localFontAccessState = 'idle';
+ let localFontAccessRequest = null;
+ let activeFontFamilyScope = 'interface';
+ const fontFamilyManualEditing = new Set();
+ const fontScopeConfig = (scope = activeFontFamilyScope) => scope === 'host'
+ ? HOST_FONT_CONFIG
+ : FONT_SCOPE_CONFIG[scope] || FONT_SCOPE_CONFIG.interface;
+ const fontValuesForScope = (scope = activeFontFamilyScope, readerValues = currentFontDraft()) => scope === 'host'
+ ? PREFS
+ : readerValues;
+ const activeFontFamilyTrigger = () => [...fontFamilyTriggers]
+ .find((trigger) => trigger.dataset.fontScope === activeFontFamilyScope);
+ const collectAvailableFontFamilies = (fonts, searchFields, fallbackNameField = '') => {
+ const entries = new Map();
+ [...fonts || []].forEach((font) => {
+ const name = normalizeCustomFontFamily(font && (font.family || font[fallbackNameField]));
+ if (!name) return;
+ const key = name.toLocaleLowerCase();
+ const entry = entries.get(key) || { name, search: new Set() };
+ searchFields.forEach((field) => {
+ if (font?.[field]) entry.search.add(String(font[field]));
+ });
+ entries.set(key, entry);
+ });
+ return [...entries.values()].map((entry) => ({
+ name: entry.name,
+ search: [...entry.search],
+ })).sort((left, right) => String(left.name || '').localeCompare(
+ String(right.name || ''), 'zh-CN', { sensitivity: 'base', numeric: true }
+ ));
+ };
+ const collectPageFontFamilies = () => {
+ try {
+ return collectAvailableFontFamilies(
+ document.fonts, ['family', 'style', 'weight', 'stretch']
+ );
+ } catch { return []; }
+ };
+ const syncFontFamilySelection = (values = currentFontDraft()) => {
+ fontFamilyTriggers.forEach((trigger) => {
+ const scope = trigger.dataset.fontScope;
+ const value = trigger.querySelector('.ldp-font-family-value');
+ const config = fontScopeConfig(scope);
+ const scopeValues = fontValuesForScope(scope, values);
+ const family = normalizeFontFamily(scopeValues[config.family]);
+ value.textContent = family !== 'custom'
+ ? FONT_FAMILY_OPTIONS[family].label
+ : normalizeCustomFontFamily(scopeValues[config.customFamily]) || '手动输入字体名';
+ trigger.dataset.ldpTooltipLabel = value.textContent;
+ });
+ const config = fontScopeConfig();
+ const selectedValues = fontValuesForScope(activeFontFamilyScope, values);
+ const selectedFamily = normalizeFontFamily(selectedValues[config.family]);
+ const selectedName = normalizeSearchText(selectedValues[config.customFamily]);
+ fontFamilyOptions.querySelectorAll('.ldp-font-family-option').forEach((option) => {
+ const selected = option.dataset.fontKind === 'preset'
+ ? selectedFamily === option.dataset.fontValue
+ : selectedFamily === 'custom' && normalizeSearchText(option.dataset.fontValue) === selectedName;
+ option.setAttribute('aria-selected', String(selected));
+ });
+ };
+ const createFontFamilyOption = ({ kind, value, label, source, stack, search = [] }, values) => {
+ const option = makeElement('button');
+ const config = fontScopeConfig();
+ const selected = kind === 'preset'
+ ? normalizeFontFamily(values[config.family]) === value
+ : normalizeFontFamily(values[config.family]) === 'custom'
+ && normalizeSearchText(values[config.customFamily]) === normalizeSearchText(value);
+ option.className = 'ldp-font-family-option ldp-picker-option';
+ option.type = 'button';
+ option.dataset.fontKind = kind;
+ option.dataset.fontValue = value;
+ option.dataset.searchText = [label, source, ...search]
+ .flatMap((searchValue) => pinyinSearchForms(searchValue)).join(' ');
+ option.setAttribute('role', 'option');
+ option.setAttribute('aria-selected', String(selected));
+ option.innerHTML = `
${esc(label)}${esc(source)}`
+ + '
Aa 字';
+ option.querySelector('.ldp-font-family-preview').style.fontFamily = stack;
+ return option;
+ };
+ const appendFontFamilyGroup = (fragment, title, items, values) => {
+ if (!items.length) return;
+ const group = makeElement('div');
+ group.className = 'ldp-font-family-group';
+ group.setAttribute('role', 'group');
+ setLabel(group, title);
+ const heading = makeElement('div');
+ heading.className = 'ldp-font-family-group-title';
+ heading.textContent = `${title} · ${items.length}`;
+ group.append(heading);
+ items.forEach((item) => group.append(createFontFamilyOption(item, values)));
+ fragment.append(group);
+ };
+ const applyFontFamilySearch = () => {
+ const query = normalizeSearchText(fontFamilySearch.value);
+ let visibleCount = 0;
+ fontFamilyOptions.querySelectorAll('.ldp-font-family-group').forEach((group) => {
+ let groupVisible = 0;
+ group.querySelectorAll('.ldp-font-family-option').forEach((option) => {
+ const visible = !query || String(option.dataset.searchText || '').includes(query);
+ option.hidden = !visible;
+ if (visible) groupVisible += 1;
+ });
+ group.hidden = groupVisible === 0;
+ visibleCount += groupVisible;
+ });
+ fontFamilyEmpty.hidden = visibleCount !== 0;
+ };
+ const renderFontFamilyOptions = () => {
+ const values = fontValuesForScope();
+ const config = fontScopeConfig();
+ const namedItem = (item, source) => ({
+ kind: 'named', value: item.name, label: item.name, source,
+ stack: `${JSON.stringify(item.name)},${FONT_FAMILY_OPTIONS.system.stack}`, search: item.search,
+ });
+ const presetItems = Object.entries(FONT_FAMILY_OPTIONS)
+ .filter(([value]) => value !== 'custom')
+ .map(([value, option]) => ({
+ kind: 'preset', value, label: option.label,
+ source: value === 'site' ? '站点样式' : value === FONT_FAMILY_DEFAULT ? '默认字体' : '阅读器预设',
+ stack: value === 'site' ? resolvedSiteFontFamily() : option.stack, search: [option.stack],
+ }));
+ const pageItems = pageFontFamilies.map((item) => namedItem(item, '页面已加载'));
+ const localItems = localFontFamilies.map((item) => namedItem(item, '本机已安装'));
+ const currentName = normalizeCustomFontFamily(values[config.customFamily]);
+ const availableNames = new Set([...pageFontFamilies, ...localFontFamilies]
+ .map((item) => normalizeSearchText(item.name)));
+ const currentItems = normalizeFontFamily(values[config.family]) === 'custom' && currentName
+ && !availableNames.has(normalizeSearchText(currentName))
+ ? [namedItem({ name: currentName, search: [currentName] }, '当前手动输入')]
+ : [];
+ const fragment = document.createDocumentFragment();
+ appendFontFamilyGroup(fragment, '预设', presetItems, values);
+ appendFontFamilyGroup(fragment, '页面已加载', pageItems, values);
+ appendFontFamilyGroup(fragment, '本机已安装', localItems, values);
+ appendFontFamilyGroup(fragment, '当前选择', currentItems, values);
+ fontFamilyOptions.replaceChildren(fragment);
+ let statusCopy = '';
+ if (localFontAccessState === 'loading') statusCopy = '正在读取浏览器允许访问的本机字体…';
+ else if (localFontAccessState === 'unsupported') statusCopy = '当前浏览器未开放本机字体列表;仍可选择页面字体或手动输入。';
+ else if (localFontAccessState === 'denied') statusCopy = '未获得本机字体读取权限;仍可选择页面字体或手动输入。';
+ else if (localFontAccessState === 'error') statusCopy = '本机字体读取失败;仍可选择页面字体或手动输入。';
+ else if (localFontAccessState === 'ready' && !localFontFamilies.length)
+ statusCopy = '浏览器没有返回可用的本机字体;仍可手动输入字体名。';
+ fontFamilySourceStatus.textContent = statusCopy;
+ fontFamilySourceStatus.hidden = !statusCopy;
+ applyFontFamilySearch();
+ };
+ const positionFontFamilyMenu = () => {
+ const trigger = activeFontFamilyTrigger();
+ if (fontFamilyMenu.hidden || !trigger || !trigger.isConnected) return;
+ const margin = 12;
+ const gap = 6;
+ const bounds = settingsSurfaceBounds();
+ const triggerRect = trigger.getBoundingClientRect();
+ const width = Math.min(360, Math.max(260, bounds.width - margin * 2));
+ const minimumLeft = bounds.left + margin;
+ const minimumTop = bounds.top + margin;
+ const left = Math.min(
+ Math.max(minimumLeft, triggerRect.left),
+ Math.max(minimumLeft, bounds.right - width - margin),
+ );
+ fontFamilyMenu.style.width = `${Math.round(width)}px`;
+ fontFamilyMenu.style.top = `${margin}px`;
+ const menuHeight = fontFamilyMenu.getBoundingClientRect().height;
+ const belowTop = triggerRect.bottom + gap;
+ const aboveTop = triggerRect.top - menuHeight - gap;
+ const top = belowTop + menuHeight <= bounds.bottom - margin
+ ? belowTop
+ : aboveTop >= minimumTop
+ ? aboveTop
+ : Math.max(minimumTop, bounds.bottom - menuHeight - margin);
+ const coversTrigger = top <= triggerRect.top && top + menuHeight >= triggerRect.bottom;
+ const positionedLeft = coversTrigger ? Math.max(minimumLeft, left - 2) : left;
+ fontFamilyMenu.style.left = `${Math.round(positionedLeft - bounds.left)}px`;
+ fontFamilyMenu.style.top = `${Math.round(top - bounds.top)}px`;
+ };
+ const closeFontFamilyMenu = (restoreFocus = false) => {
+ if (fontFamilyMenu.hidden) return;
+ const trigger = activeFontFamilyTrigger();
+ fontFamilyMenu.hidden = true;
+ fontFamilyTriggers.forEach((button) => setExpanded(button, false));
+ if (restoreFocus && trigger && trigger.isConnected) trigger.focus();
+ };
+ const requestLocalFontFamilies = () => {
+ if (localFontAccessState !== 'idle') return localFontAccessRequest;
+ const localFontWindow = isFunction(HOST_PAGE_WINDOW?.queryLocalFonts)
+ ? HOST_PAGE_WINDOW : globalThis;
+ if (!isFunction(localFontWindow.queryLocalFonts)) {
+ localFontAccessState = 'unsupported';
+ renderFontFamilyOptions();
+ return null;
+ }
+ const handleLocalFontAccessError = (error) => {
+ const denied = error && (error.name === 'NotAllowedError' || error.name === 'SecurityError');
+ localFontAccessState = denied ? 'denied' : 'error';
+ if (!denied) {
+ console.warn('[LDP] local font access failed', {
+ name: String(error?.name || 'Error'),
+ message: String(error?.message || ''),
+ });
+ }
+ };
+ localFontAccessState = 'loading';
+ renderFontFamilyOptions();
+ try {
+ localFontAccessRequest = Promise.resolve(localFontWindow.queryLocalFonts()).then((fonts) => {
+ localFontFamilies = collectAvailableFontFamilies(
+ fonts, ['family', 'fullName', 'postscriptName', 'style'], 'fullName'
+ );
+ localFontAccessState = 'ready';
+ }).catch((error) => {
+ handleLocalFontAccessError(error);
+ }).finally(() => {
+ localFontAccessRequest = null;
+ renderFontFamilyOptions();
+ positionFontFamilyMenu();
+ });
+ } catch (error) {
+ handleLocalFontAccessError(error);
+ renderFontFamilyOptions();
+ }
+ return localFontAccessRequest;
+ };
+ const openFontFamilyMenu = (scope) => {
+ if (FONT_PICKER_SCOPE_KEYS.includes(scope)) activeFontFamilyScope = scope;
+ fontFamilyTriggers.forEach((button) => setExpanded(button, button.dataset.fontScope === activeFontFamilyScope));
+ pageFontFamilies = collectPageFontFamilies();
+ fontFamilySearch.value = '';
+ renderFontFamilyOptions();
+ fontFamilyMenu.hidden = false;
+ positionFontFamilyMenu();
+ requestLocalFontFamilies();
+ fontFamilySearch.focus({ preventScroll: true });
+ if (document.fonts?.ready) {
+ void Promise.resolve(document.fonts.ready).then(() => {
+ pageFontFamilies = collectPageFontFamilies();
+ renderFontFamilyOptions();
+ positionFontFamilyMenu();
+ });
+ }
+ };
+ const fontScaleSettings = [
+ { input: interfaceFontScaleRange, output: interfaceFontScaleValue, key: 'interface', normalize: normalizeFontScale },
+ { input: fontScaleRange, output: fontScaleValue, key: 'post', normalize: normalizeFontScale },
+ { input: composerFontScaleRange, output: composerFontScaleValue, key: 'composer', normalize: normalizeFontScale },
+ ];
+ const syncFontControls = () => {
+ const values = currentFontDraft();
+ const savedValues = currentFontProfile();
+ const defaultValues = currentFontDefaults();
+ const profileLabel = '阅读器';
+ hostEmbeddedSizeInputs.forEach((input) => {
+ const config = HOST_EMBED_SIZE_SETTINGS[input.dataset.hostEmbedSize];
+ if (!config) return;
+ const scale = normalizeHostEmbeddedSizeScale(PREFS[config.pref]);
+ input.value = String(scale);
+ syncRangeVisual(input);
+ const output = [...hostEmbeddedSizeValues]
+ .find((value) => value.dataset.hostEmbedSizeValue === input.dataset.hostEmbedSize);
+ if (output) {
+ output.value = `${scale}%`;
+ output.textContent = `${scale}%`;
+ }
+ const detail = input.dataset.hostEmbedSize === 'labelCard'
+ ? '卡片高度、文字、SVG/图片图标、间距和横向内边距会按同一比例变化。'
+ : input.dataset.hostEmbedSize === 'stats'
+ ? '右下角的回复、最近回复、活跃和回应四组信息会一起变化。'
+ : '';
+ setSettingRowHelp(input, `使用左右嵌入阅读时,调整原站主题列表中${config.label}的大小,默认 ${config.defaultScale}%。${detail}修改后立即保存。`);
+ });
+ syncTabSelection(fontScopeTabs, 'fontScopeTab', fontSettingsScope, true);
+ fontScopePanels.forEach((panel) => {
+ panel.hidden = panel.dataset.fontScopePanel !== fontSettingsScope;
+ });
+ syncFontFamilySelection(values);
+ FONT_PICKER_SCOPE_KEYS.forEach((scope) => {
+ const host = scope === 'host';
+ const scopeValues = host ? PREFS : values;
+ const scopeDefaults = host ? HOST_FONT_DEFAULTS : defaultValues;
+ const config = fontScopeConfig(scope);
+ const customInput = [...fontFamilyCustomInputs].find((input) => input.dataset.fontScope === scope);
+ const weightSelect = [...fontWeightSelects].find((select) => select.dataset.fontScope === scope);
+ const colorInput = [...fontColorInputs].find((input) => input.dataset.fontScope === scope);
+ const colorValue = [...fontColorValues].find((value) => value.dataset.fontScope === scope);
+ const color = normalizeHexColor(scopeValues[config.color], scopeDefaults[config.color]);
+ if (normalizeFontFamily(scopeValues[config.family]) !== 'custom') fontFamilyManualEditing.delete(scope);
+ customInput.value = scopeValues[config.customFamily];
+ customInput.hidden = normalizeFontFamily(scopeValues[config.family]) !== 'custom' || !fontFamilyManualEditing.has(scope);
+ weightSelect.value = String(scopeValues[config.weight]);
+ colorInput.value = color || READER_THEME_VARIABLES[resolvedReaderThemeMode()]['--primary'];
+ colorValue.textContent = color ? color.toUpperCase() : '跟随主题';
+ const trigger = [...fontFamilyTriggers].find((button) => button.dataset.fontScope === scope);
+ if (host) {
+ setSettingRowHelp(trigger,
+ `选择左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的字体。默认“${FONT_FAMILY_OPTIONS[scopeDefaults[config.family]].label}”;支持页面字体、本机字体和手动输入。修改后立即保存。`);
+ setSettingRowHelp(weightSelect,
+ `调整左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的字重。默认 ${scopeDefaults[config.weight]} · ${FONT_WEIGHT_LABELS[scopeDefaults[config.weight]]}。修改后立即保存。`);
+ setSettingRowHelp(colorInput,
+ '调整左右嵌入阅读时原站主题标题、标签卡片和主题统计信息共享的颜色。默认跟随当前明暗主题。修改后立即保存。');
+ } else {
+ setSettingRowHelp(trigger,
+ `选择${profileLabel}${config.label}文字使用的字体。下拉按预设、页面已加载和本机已安装分组并支持搜索;首次读取本机字体时浏览器可能询问权限。默认“${FONT_FAMILY_OPTIONS[scopeDefaults[config.family]].label}”。`);
+ setSettingRowHelp(weightSelect,
+ `调整${profileLabel}${config.label}文字的粗细。默认 ${scopeDefaults[config.weight]} · ${FONT_WEIGHT_LABELS[scopeDefaults[config.weight]]}。`);
+ setSettingRowHelp(colorInput,
+ `调整${profileLabel}${config.label}文字的基础颜色。默认跟随当前明暗主题;自定义颜色只作用于${config.label}文字,不改变链接、错误和状态等语义颜色。`);
+ }
+ });
+ fontScaleSettings.forEach(({ input, output, key }) => {
+ input.value = String(values[key]);
+ output.textContent = `${values[key]}%`;
+ syncRangeVisual(input);
+ });
+ setSettingRowHelp(interfaceFontScaleRange,
+ `调整${profileLabel}界面文字字号,包括标题、元信息、楼层头部、操作区、时间轴、设置面板和提示。默认 ${defaultValues.interface}%;不会改变帖子正文和回复输入文字。`);
+ setSettingRowHelp(fontScaleRange,
+ `调整${profileLabel}中的帖子正文和各楼层内容,不改变标题、按钮等界面文字。默认 ${defaultValues.post}%;拖动时实时预览,统一保存。`);
+ setSettingRowHelp(composerFontScaleRange,
+ `调整${profileLabel}回复弹窗里的输入文字和编辑区域字号,不改变已发布正文。默认 ${defaultValues.composer}%;拖动时实时预览,统一保存。`);
+ fontFieldResetButtons.forEach((button) => {
+ const host = button.dataset.hostFontReset != null;
+ const profileField = button.dataset.fontFieldReset;
+ const scope = host ? 'host' : FONT_SCOPE_KEYS.find((key) => Object.values(fontScopeConfig(key)).includes(profileField));
+ const config = fontScopeConfig(scope);
+ const field = host ? config[button.dataset.hostFontReset] : profileField;
+ const scopeValues = host ? PREFS : values;
+ const scopeDefaults = host ? HOST_FONT_DEFAULTS : defaultValues;
+ const defaultValue = scopeDefaults[field];
+ const familyField = field === config.family;
+ const weightField = field === config.weight;
+ const colorField = field === config.color;
+ const fieldLabel = `${host ? '' : profileLabel}${config.label}${familyField ? '字体' : weightField ? '字重' : colorField ? '颜色' : '字号'}`;
+ const defaultLabel = familyField ? FONT_FAMILY_OPTIONS[defaultValue].label :
+ weightField ? String(defaultValue) : colorField ? '跟随主题' : `${defaultValue}%`;
+ button.disabled = familyField
+ ? scopeValues[config.family] === scopeDefaults[config.family]
+ && scopeValues[config.customFamily] === scopeDefaults[config.customFamily]
+ : scopeValues[field] === defaultValue;
+ button.querySelector('span').textContent = `默认 ${defaultLabel}`;
+ setLabel(button, `恢复${fieldLabel}默认值 ${defaultLabel}`);
+ });
+ const changed = FONT_PROFILE_VALUE_KEYS.some((name) => values[name] !== savedValues[name]);
+ fontStatus.textContent = changed
+ ? `${profileLabel}字体正在实时预览,等待统一保存。`
+ : `${profileLabel}字体当前配置已应用。`;
+ fontStatus.classList.toggle('balanced', !changed);
+ const hostFontAtDefault = Object.keys(HOST_FONT_DEFAULTS).every((name) => PREFS[name] === HOST_FONT_DEFAULTS[name]);
+ fontResetBtn.disabled = hostFontAtDefault && FONT_PROFILE_VALUE_KEYS.every((name) =>
+ values[name] === defaultValues[name]
+ );
+ syncSettingsDraftIndicators();
+ };
+ const syncLayoutControls = () => {
+ const ratios = currentLayoutDraft();
+ const savedRatios = currentSavedLayoutRatios();
+ const defaultRatios = currentLayoutDefaults();
+ const profileLabel = layoutDraftFullPage ? '全屏' : '普通(嵌入/浮窗)';
+ setText(layoutSettingsTitle, `布局设置 · ${profileLabel}`);
+ setText(layoutSettingsDescription,
+ `当前检测为${profileLabel}形态;实时预览、保存和恢复默认只作用于该形态。`);
+ const remaining = Math.max(0, roundLayoutRatio(100 - layoutRatioTotal(ratios)));
+ layoutRatioInputs.forEach((input) => {
+ const name = input.dataset.layoutRegion;
+ if (!LAYOUT_REGION_KEYS.includes(name)) return;
+ input.max = '100';
+ input.value = String(ratios[name]);
+ syncRangeVisual(input);
+ });
+ layoutRatioValues.forEach((valueEl) => {
+ const name = valueEl.dataset.layoutValue;
+ if (!LAYOUT_REGION_KEYS.includes(name)) return;
+ valueEl.textContent = `${Number(ratios[name].toFixed(1))}%`;
+ });
+ layoutTotal.classList.toggle('warning', remaining > 0);
+ layoutTotal.classList.toggle('balanced', remaining <= 0);
+ const changed = LAYOUT_REGION_KEYS.some((name) => Math.abs(ratios[name] - savedRatios[name]) >= 0.01);
+ layoutTotal.textContent = remaining > 0
+ ? `当前形态:${profileLabel}。还剩 ${Number(remaining.toFixed(1))}% 未分配,必须分配完才能保存。`
+ : changed
+ ? `当前形态:${profileLabel}。正在实时预览,只会保存该形态的布局。`
+ : `当前形态:${profileLabel}。当前配置已应用,只会保存该形态的布局。`;
+ layoutResetBtn.disabled = LAYOUT_REGION_KEYS.every((name) =>
+ Math.abs(ratios[name] - defaultRatios[name]) < 0.01
+ );
+ syncSettingsDraftIndicators();
+ };
+ const syncReaderWindowControls = () => {
+ const state = readerWindow.getState();
+ const compact = window.innerWidth <= READER_WINDOW_COMPACT_WIDTH;
+ const floating = readerWorkspace.isFloating();
+ const geometry = `${Math.round(state.width)} × ${Math.round(state.height)} · (${Math.round(state.left)}, ${Math.round(state.top)})${readerWindow.isPinned() ? ' · 保持显示' : ''}${readerWindow.isLocked() ? ' · 已锁定' : ''}`;
+ readerWindowWidthInput.max = String(Math.max(READER_WINDOW_MIN_WIDTH, window.innerWidth - READER_WINDOW_MARGIN * 2));
+ readerWindowHeightInput.max = String(Math.max(READER_WINDOW_MIN_HEIGHT, window.innerHeight - READER_WINDOW_MARGIN * 2));
+ readerWindowXInput.max = String(Math.max(READER_WINDOW_MARGIN, window.innerWidth - READER_WINDOW_MARGIN - state.width));
+ readerWindowYInput.min = String(READER_WINDOW_MARGIN);
+ readerWindowYInput.max = String(Math.max(READER_WINDOW_MARGIN, window.innerHeight - READER_WINDOW_MARGIN - state.height));
+ readerWindowWidthInput.value = String(Math.round(state.width));
+ readerWindowHeightInput.value = String(Math.round(state.height));
+ readerWindowXInput.value = String(Math.round(state.left));
+ readerWindowYInput.value = String(Math.round(state.top));
+ readerWindowPinInput.checked = readerWindow.isPinned();
+ readerWindowLockInput.checked = readerWindow.isLocked();
+ [readerWindowWidthInput, readerWindowHeightInput, readerWindowXInput, readerWindowYInput,
+ readerWindowPinInput, readerWindowLockInput].forEach((input) => { input.disabled = compact; });
+ readerWindowStatus.textContent = compact
+ ? '当前视口较窄,阅读器使用同一套窄屏响应式布局。'
+ : floating
+ ? `${geometry}${readerWindow.isLocked() ? '' : ' · 可拖动缩放'}`
+ : `${readerWorkspace.isEmbedded() ? '当前为嵌入阅读' : '当前为全屏阅读'};以下配置将在切换到浮窗后生效。浮窗:${geometry}`;
+ readerWindowResetBtn.disabled = compact || readerWindow.isDefault();
+ };
+ const appearanceApplyByGroup = {
+ interaction: applyInteractionColors,
+ zebra: applyZebraAppearance,
+ 'list-zebra': applyListZebraColor,
+ 'reply-line': applyReplyLineAppearance,
+ 'quote-line': applyQuoteLineAppearance,
+ 'divider-line': applyDividerLineAppearance,
+ 'floor-preview': applyFloorPreviewAppearance,
+ };
+ const appearanceControlSettings = appearanceSettingElements.map((field) => Object.assign({}, field, {
+ apply: appearanceApplyByGroup[field.section],
+ }));
+ const syncAppearanceValueControls = () => {
+ const values = currentAppearanceDraft();
+ appearanceControlSettings.forEach(({ input, output, key }) => {
+ const range = input.type === 'range';
+ const value = values[key];
+ input.value = range ? String(value) : value;
+ output.textContent = range ? `${value}px` : value;
+ if (!range) output.title = `当前主题实际应用 ${appearanceThemeColor(values, key).toUpperCase()}`;
+ if (range) syncRangeVisual(input);
+ });
+ };
+ const syncColorResetButtons = () => {
+ const values = currentAppearanceDraft();
+ const defaults = currentAppearanceDefaults();
+ colorResetButtons.forEach((button) => {
+ const baseKey = button.dataset.colorReset;
+ const valueKeys = appearanceSettingValueFields(baseKey);
+ const structureDisabled = button.closest('[data-appearance-group="structure"]')
+ && values.structureColorsEnabled === false;
+ button.disabled = structureDisabled || valueKeys.every((key) =>
+ defaults[key] == null || values[key] === defaults[key]);
+ });
+ };
+ const syncAppearanceGroupControls = () => {
+ const values = currentAppearanceDraft();
+ appearanceGroupToggleInputs.forEach((input) => {
+ const key = input.dataset.appearanceGroupToggle;
+ const enabled = values[key] !== false;
+ const group = input.closest('.ldp-color-group');
+ input.checked = enabled;
+ group.classList.toggle('is-disabled', !enabled);
+ group.querySelector('.ldp-settings-fields').setAttribute('aria-disabled', String(!enabled));
+ group.querySelectorAll('.ldp-settings-fields input').forEach((control) => {
+ control.disabled = !enabled;
+ });
+ });
+ };
+ const syncAppearanceControls = () => {
+ const profileLabel = '阅读器';
+ const configuredTheme = resolvedReaderThemeMode();
+ const selectedTheme = normalizeReaderThemeMode(currentReaderThemeMode());
+ const themeLabel = configuredTheme === 'dark' ? '暗色主题' : '明亮主题';
+ appearanceThemeNote.innerHTML = `当前预览
${themeLabel}适应色`
+ + `${selectedTheme === 'system' ? '(跟随系统)' : ''};HEX 保留所选原色,实际呈现自动限制亮度与对比。`;
+ syncAppearanceValueControls();
+ syncAppearanceGroupControls();
+ syncColorResetButtons();
+ const changed = appearanceValuesChanged();
+ appearanceStatus.textContent = changed
+ ? `${profileLabel}外观正在实时预览,等待统一保存。`
+ : `${profileLabel}外观当前配置已应用。`;
+ appearanceStatus.classList.toggle('balanced', !changed);
+ syncSettingsDraftIndicators();
+ };
+ let performanceSettingsChanged = false;
+ const syncPerformanceControls = () => {
+ const config = currentPerformanceConfig();
+ const preset = performancePresetForConfig(config);
+ performancePresetButtons.forEach((button) => {
+ const active = button.dataset.performancePreset === preset;
+ button.classList.toggle('active', active);
+ setPressed(button, active);
+ });
+ performanceInputs.forEach((input) => {
+ const name = input.dataset.performanceKey;
+ const value = config[name];
+ if (value == null) return;
+ input.value = PERFORMANCE_LIMITS[name]?.integer
+ ? String(value)
+ : String(Number(value.toFixed(2)));
+ });
+ performanceResetBtn.disabled = preset === 'balanced';
+ const presetLabels = { low: '省资源', balanced: '均衡', high: '减少等待', custom: '自定义' };
+ const nextConfig = `每次加载 ${config.pageSize} 个楼层 · 页面保留不超过 ${config.streamMaxItems} 个楼层 · 二级回复提前 ${config.nestedPrefetchViewports} 屏 · 限流额度 ${config.requestRateTarget}%`;
+ const requestState = currentReaderRequestScheduler().snapshot();
+ const windowStatus = `请求额度 ${requestState.shortWindowBudget} 次/10 秒、${requestState.longWindowBudget} 次/分钟`;
+ const runtimeStatus = requestState.endpointCoolingDown && !requestState.coolingDown
+ ? ` 当前隔离 ${requestState.endpointCooldownCount} 个异常端点,最长约 ${Math.max(1, Math.ceil(requestState.endpointCooldownRemaining / 1000))} 秒后允许再次尝试;其他请求继续按${windowStatus}运行。`
+ : requestState.coolingDown
+ ? ` 当前会话命中${requestState.lastRateLimitWindow || '动态'}限流,正在退避,约 ${Math.max(1, Math.ceil(requestState.cooldownRemaining / 1000))} 秒后继续;${windowStatus}。`
+ : requestState.learnedRateLimit
+ ? ` 当前会话已按最近边界调节;${windowStatus}${requestState.minInterval > requestState.configuredMinInterval ? `,请求最短间隔 ${requestState.minInterval}ms` : ''},稳定承压后继续小步提速,并发按需最高 ${requestState.maxConcurrent} 路。`
+ : ` 当前会话正常自适应;${windowStatus},稳定承压后小步上探,并发按需最高 ${requestState.maxConcurrent} 路。`;
+ const savedStatus = performanceSettingsChanged
+ ? `已保存为${presetLabels[preset] || '自定义'}:${nextConfig};下次打开阅读器生效。`
+ : `下次打开采用${presetLabels[preset] || '自定义'}:${nextConfig}。`;
+ performanceStatus.textContent = savedStatus + runtimeStatus;
+ };
+ const formatRequestFlowBytes = (value) => {
+ const bytes = Math.max(0, Number(value) || 0);
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
+ };
+ const formatRequestFlowDuration = (value) => {
+ const milliseconds = Math.max(0, Number(value) || 0);
+ if (milliseconds < 10) return `${milliseconds.toFixed(1)}ms`;
+ if (milliseconds < 1000) return `${Math.round(milliseconds)}ms`;
+ if (milliseconds < 60000) return `${(milliseconds / 1000).toFixed(milliseconds < 10000 ? 1 : 0)}s`;
+ return `${(milliseconds / 60000).toFixed(1)}min`;
+ };
+ const formatRequestFlowTimestamp = (value) => {
+ const date = new Date(Number(value) || 0);
+ const clock = date.toLocaleTimeString('zh-CN', {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+ return `${clock}.${String(date.getMilliseconds()).padStart(3, '0')}`;
+ };
+ const requestFlowSourceLabel = (source) => REQUEST_FLOW_SOURCE_LABELS[source] || '其他';
+ const requestFlowPriorityLabel = (priority) => {
+ if (!Number.isFinite(priority)) return '';
+ if (priority <= POST_REQUEST_PRIORITY.topic) return '核心';
+ if (priority === POST_REQUEST_PRIORITY.scroll) return '滚动';
+ if (priority === POST_REQUEST_PRIORITY.visible) return '可见';
+ if (priority === POST_REQUEST_PRIORITY.nested) return '预取';
+ if (priority === POST_REQUEST_PRIORITY.auxiliary) return '辅助';
+ return '后台';
+ };
+ const requestFlowWaitReasonLabel = (reason) => REQUEST_FLOW_WAIT_REASON_LABELS[reason] || '调度';
+ const requestFlowIssue = (event, at = requestFlowNow()) => {
+ if (!event) return null;
+ const networkDuration = event.pending
+ ? Math.max(0, at - event.startedAt)
+ : Math.max(0, Number(event.duration) || 0);
+ const queueDuration = Math.max(0, Number(event.permitWait) || 0);
+ if (event.controlOnly) {
+ const reason = String(event.controlReason || event.waitReason || 'cancelled');
+ if (reason === 'queue-limit') return { level: 'warning', kind: '队列已满', detail: '辅助请求在发出前被队列上限丢弃' };
+ if (reason === 'endpoint-cooldown') return { level: 'warning', kind: '端点熔断', detail: '同一异常 URL 在冷却期内未再次发出,不影响其他请求' };
+ if (reason === 'rate-limit-priority' || reason === 'cooldown')
+ return { level: 'warning', kind: '限流让位', detail: '低优先级请求在发出前为限流恢复让位' };
+ if (reason === 'context-close') return { level: 'warning', kind: '页面取消', detail: '阅读器上下文结束,请求未发出' };
+ return { level: 'warning', kind: '调度取消', detail: `请求未发出(${requestFlowWaitReasonLabel(event.waitReason)})` };
+ }
+ if (event.status === 429)
+ return { level: 'danger', kind: '429 限流', detail: event.rateLimitCode || '服务器拒绝了当前请求速率' };
+ if (event.status >= 500) return { level: 'danger', kind: `HTTP ${event.status}`, detail: '服务器或上游服务返回错误' };
+ if (event.status >= 400) return { level: 'danger', kind: `HTTP ${event.status}`, detail: '请求参数、权限或目标状态异常' };
+ if (event.error) {
+ const aborted = /abort/i.test(event.error);
+ return { level: aborted ? 'warning' : 'danger', kind: aborted ? '中止/超时' : '网络错误', detail: event.error };
+ }
+ if (event.pending && event.type !== 'realtime' && networkDuration >= POST_REQUEST_TIMEOUT)
+ return { level: 'danger', kind: '请求卡住', detail: `网络阶段已持续 ${formatRequestFlowDuration(networkDuration)}` };
+ if (queueDuration >= 1000)
+ return {
+ level: 'warning',
+ kind: '排队过久',
+ detail: `${requestFlowWaitReasonLabel(event.waitReason)}等待 ${formatRequestFlowDuration(queueDuration)}`,
+ };
+ if (!event.pending && event.type !== 'realtime' && networkDuration >= REQUEST_FLOW_SLOW_MS)
+ return { level: 'warning', kind: '响应偏慢', detail: `网络阶段耗时 ${formatRequestFlowDuration(networkDuration)}` };
+ return null;
+ };
+ const requestFlowTimingLabel = (event, at = requestFlowNow()) => {
+ const queueDuration = Math.max(0, Number(event.permitWait) || 0);
+ const dispatchDuration = Math.max(0, Number(event.dispatchDuration) || 0);
+ const networkDuration = event.pending
+ ? Math.max(0, at - event.startedAt)
+ : Math.max(0, Number(event.duration) || 0);
+ if (event.controlOnly) return `${queueDuration > 0 ? `排 ${formatRequestFlowDuration(queueDuration)} · ` : ''}未发出`;
+ return `${queueDuration > 0 ? `排 ${formatRequestFlowDuration(queueDuration)} · ` : ''}${dispatchDuration >= 1 ? `放行 ${formatRequestFlowDuration(dispatchDuration)} · ` : ''}网 ${formatRequestFlowDuration(networkDuration)}`;
+ };
+ const requestFlowSnapshot = (at = requestFlowNow()) => {
+ pruneRequestFlowEvents(at);
+ const session = REQUEST_FLOW_EVENTS.slice();
+ const recent60 = session.filter((event) => !event.controlOnly && event.startedAt >= at - 60000);
+ const recent10 = recent60.filter((event) => event.startedAt >= at - REQUEST_FLOW_TRACE_WINDOW_MS);
+ const binEnd = Math.ceil(at / 100) * 100;
+ const binStart = binEnd - REQUEST_FLOW_TRACE_WINDOW_MS;
+ const bins100 = Array.from({ length: REQUEST_FLOW_TRACE_WINDOW_MS / 100 }, () => 0);
+ const typeStats = new Map();
+ const sourceCounts = { reader: 0, host: 0, browser: 0 };
+ let transfer = 0;
+ const durations = [];
+ recent60.forEach((event) => {
+ const binIndex = Math.floor((event.startedAt - binStart) / 100);
+ if (binIndex >= 0 && binIndex < bins100.length) bins100[binIndex]++;
+ const stat = typeStats.get(event.type) || { type: event.type, count: 0, completed: 0, durations: [], issues: 0 };
+ stat.count++;
+ if (!event.pending) {
+ stat.completed++;
+ if (event.duration > 0 && event.type !== 'realtime') stat.durations.push(event.duration);
+ if (event.duration > 0 && event.type !== 'realtime') durations.push(event.duration);
+ }
+ if (requestFlowIssue(event, at)) stat.issues++;
+ typeStats.set(event.type, stat);
+ if (Object.hasOwn(sourceCounts, event.source)) sourceCounts[event.source]++;
+ transfer += event.size;
+ });
+ durations.sort((a, b) => a - b);
+ const p95Index = durations.length ? Math.min(durations.length - 1, Math.ceil(durations.length * .95) - 1) : -1;
+ const latestLimit = session.slice().reverse().find((event) =>
+ event.status === 429 || event.retryAfter || event.rateLimitCode || event.serverLimit);
+ const recentIssues = session
+ .filter((event) => (Number(event.endedAt) || event.startedAt) >= at - 60000)
+ .map((event) => ({ event, issue: requestFlowIssue(event, at) }))
+ .filter((entry) => entry.issue);
+ const traceEvents = session.filter((event) => {
+ const lifecycleStart = Number(event.queuedAt) || event.startedAt;
+ const lifecycleEnd = event.pending ? at : Number(event.endedAt) || event.startedAt;
+ return lifecycleStart <= at && lifecycleEnd >= at - REQUEST_FLOW_TRACE_WINDOW_MS;
+ });
+ const sortedTypeStats = [...typeStats.values()].map((stat) => {
+ stat.durations.sort((a, b) => a - b);
+ const index = stat.durations.length
+ ? Math.min(stat.durations.length - 1, Math.ceil(stat.durations.length * .95) - 1)
+ : -1;
+ return { ...stat, p95: index >= 0 ? stat.durations[index] : 0 };
+ }).sort((a, b) => b.count - a.count);
+ return {
+ at,
+ session,
+ recent60,
+ recent10,
+ traceEvents,
+ typeStats: sortedTypeStats,
+ recentIssues,
+ sourceCounts,
+ transfer,
+ peak100: Math.max(0, ...bins100),
+ p95: p95Index >= 0 ? durations[p95Index] : 0,
+ completed: durations.length,
+ active: session.filter((event) => event.pending).length,
+ latestLimit,
+ };
+ };
+ const monitorStatusInfo = (level, state, detail) => ({ level, state, detail });
+ const requestFlowBottleneckInfo = (snapshot, schedulerState) => {
+ const latest429 = snapshot.recent60.slice().reverse().find((event) => event.status === 429);
+ if (latest429 || (schedulerState?.coolingDown)) {
+ const event = latest429 || snapshot.latestLimit;
+ const typeLabel = REQUEST_FLOW_TYPE_LABELS[event?.type] || '请求';
+ const wait = event?.retryAfter
+ ? `,服务器要求等待 ${event.retryAfter} 秒`
+ : schedulerState?.cooldownRemaining
+ ? `,约 ${Math.max(1, Math.ceil(schedulerState.cooldownRemaining / 1000))} 秒后继续`
+ : '';
+ return monitorStatusInfo('danger', '服务器限流',
+ `${typeLabel}在最近一分钟收到 429${wait};未启动的辅助和后台请求会让位。`);
+ }
+ const latestDanger = snapshot.recentIssues.slice().reverse().find((entry) => entry.issue.level === 'danger');
+ if (latestDanger) {
+ const { event, issue } = latestDanger;
+ const typeLabel = REQUEST_FLOW_TYPE_LABELS[event.type] || event.type;
+ const caller = event.callSite ? `,发起点 ${event.callSite}` : '';
+ return monitorStatusInfo('danger', issue.kind,
+ `${typeLabel} ${event.method} ${event.path}:${issue.detail}${caller}。`);
+ }
+ if (schedulerState && Math.max(schedulerState.queued, schedulerState.globalQueued || 0) >= 3) {
+ const wait = Math.max(0, Number(schedulerState.nextPermitDelay) || 0);
+ const reason = schedulerState.blockingReason === '10s'
+ ? '10 秒请求额度'
+ : schedulerState.blockingReason === '60s'
+ ? '60 秒请求额度'
+ : schedulerState.blockingReason === 'interval'
+ ? '请求最短间隔'
+ : '';
+ const windowDetail = reason
+ ? `;正在等待${reason}${wait ? `,约 ${Math.max(1, Math.ceil(wait / 1000))} 秒后放行` : ''}`
+ : '';
+ return monitorStatusInfo('warning', '阅读器排队',
+ `全局 ${schedulerState.globalActive || schedulerState.active} 路执行、${Math.max(schedulerState.queued, schedulerState.globalQueued || 0)} 条排队${windowDetail};原站操作、目标楼层、正文和当前可见的二级回复会优先启动。`);
+ }
+ if (snapshot.completed >= 5 && snapshot.p95 >= 1800)
+ return monitorStatusInfo('warning', '响应偏慢',
+ `最近一分钟 95% 的已完成请求不超过 ${formatRequestFlowDuration(snapshot.p95)},瓶颈更可能在网络或服务器响应,而不是请求启动间隔。`);
+ const resourceCount = snapshot.typeStats
+ .filter((stat) => ['avatar', 'media', 'asset'].includes(stat.type))
+ .reduce((sum, stat) => sum + stat.count, 0);
+ if (snapshot.recent60.length >= 12 && resourceCount / snapshot.recent60.length >= .7 && snapshot.transfer >= 4 * 1024 * 1024)
+ return monitorStatusInfo('warning', '资源占用',
+ `头像、图片和静态资源占最近一分钟请求的 ${Math.round(resourceCount / snapshot.recent60.length * 100)}%,已传输 ${formatRequestFlowBytes(snapshot.transfer)}。`);
+ if (!snapshot.recent60.length)
+ return monitorStatusInfo('normal', '等待采样',
+ '打开帖子并滚动后,这里会根据排队、响应耗时、传输量和 429 判断主要瓶颈。');
+ return monitorStatusInfo('normal', '节奏正常',
+ `最近一分钟 ${snapshot.recent60.length} 次请求,${snapshot.active} 次进行中,95% 请求耗时 ${snapshot.p95 ? formatRequestFlowDuration(snapshot.p95) : '暂无'};当前未发现明显限流或排队瓶颈。`);
+ };
+ const requestFlowTraceMarkup = (snapshot) => {
+ const windowStart = snapshot.at - REQUEST_FLOW_TRACE_WINDOW_MS;
+ const position = (value) => Math.max(0, Math.min(100,
+ (Number(value) - windowStart) / REQUEST_FLOW_TRACE_WINDOW_MS * 100));
+ const width = (start, end) => Math.max(0, position(end) - position(start));
+ const sources = [
+ { source: 'reader', label: '阅读器' },
+ { source: 'host', label: '原站' },
+ { source: 'browser', label: '资源' },
+ ];
+ return sources.map(({ source, label }) => {
+ const layerEnds = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
+ const events = snapshot.traceEvents.filter((event) => event.source === source)
+ .sort((a, b) => (Number(a.queuedAt) || a.startedAt) - (Number(b.queuedAt) || b.startedAt))
+ .slice(-80);
+ const items = events.map((event) => {
+ const lifecycleStart = Number(event.queuedAt) || event.startedAt;
+ const lifecycleEnd = event.pending ? snapshot.at : Number(event.endedAt) || event.startedAt;
+ let layer = layerEnds.findIndex((end) => end <= lifecycleStart);
+ if (layer < 0) layer = layerEnds.indexOf(Math.min(...layerEnds));
+ layerEnds[layer] = lifecycleEnd;
+ const top = 3 + layer * 10;
+ const issue = requestFlowIssue(event, snapshot.at);
+ const typeLabel = REQUEST_FLOW_TYPE_LABELS[event.type] || event.type;
+ const priorityLabel = requestFlowPriorityLabel(event.priority);
+ const status = event.controlOnly ? '未发出' : event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '完成';
+ const detail = [
+ `${formatRequestFlowTimestamp(event.startedAt)} · ${event.method} ${event.path}`,
+ `${requestFlowSourceLabel(event.source)} / ${typeLabel} / ${status}`,
+ requestFlowTimingLabel(event, snapshot.at),
+ priorityLabel && `${priorityLabel}优先级`,
+ event.attempt > 1 && `第 ${event.attempt} 次尝试`,
+ event.callSite && `发起点 ${event.callSite}`,
+ ].filter(Boolean).join(' · ');
+ const wireStart = Math.max(windowStart, event.startedAt);
+ const wireEnd = Math.min(snapshot.at, Math.max(event.startedAt, lifecycleEnd));
+ const wire = event.controlOnly ? '' : `
`;
+ const queueEnd = Math.min(snapshot.at, Number(event.permittedAt) || event.startedAt);
+ const queue = lifecycleStart < queueEnd - .5 && queueEnd >= windowStart
+ ? `
`
+ : '';
+ const permit = queue && !event.controlOnly
+ ? `
`
+ : '';
+ const anomalyAt = Math.min(snapshot.at, Math.max(windowStart, lifecycleEnd));
+ const anomalyPosition = Math.max(.6, Math.min(99.4, position(anomalyAt)));
+ const anomaly = issue
+ ? `
`
+ : '';
+ return queue + permit + wire + anomaly;
+ }).join('');
+ return `
+ ${label}
+ ${items}
+
`;
+ }).join('');
+ };
+ let requestFlowTimer = 0;
+ const updateRequestFlowHtml = (element, html) => {
+ if (!element || element.matches(':hover') || element.innerHTML === html) return;
+ const scrollTop = element.scrollTop;
+ element.innerHTML = html;
+ if (scrollTop) element.scrollTop = scrollTop;
+ };
+ const syncRequestFlowControls = () => {
+ if (!requestFlowChart || !requestFlowSection) return;
+ const snapshot = requestFlowSnapshot();
+ let schedulerState = null;
+ try { schedulerState = currentReaderRequestScheduler().snapshot(); } catch {}
+ const recent429 = snapshot.recent60.filter((event) => event.status === 429).length;
+ const metricValues = {
+ rate10: `${snapshot.recent10.length}次 · ${(snapshot.recent10.length / 10).toFixed(1)}/秒`,
+ rate60: `${snapshot.recent60.length}次 · ${(snapshot.recent60.length / 60).toFixed(1)}/秒`,
+ peak: `${snapshot.peak100}次/100ms`,
+ transfer: formatRequestFlowBytes(snapshot.transfer),
+ issues: `${snapshot.recentIssues.length}次${recent429 ? ` · 429 ${recent429}` : ''}`,
+ };
+ requestFlowMetrics.forEach((element) => {
+ element.textContent = metricValues[element.dataset.requestFlowMetric] || '—';
+ });
+ const queued10 = snapshot.recent10.filter((event) => event.permitWait > .5).length;
+ const globalUsage = schedulerState
+ ? `全局 ${schedulerState.globalInstances} 实例 · 执行 ${schedulerState.globalActive} · 排队 ${schedulerState.globalQueued} · 共享上限 ${schedulerState.globalMaxConcurrent} 路 · 间隔 ${schedulerState.globalMinInterval}ms${schedulerState.coordinationMode === 'best-effort' ? ' · 协调降级' : ''}`
+ : `本页阅读器 ${snapshot.sourceCounts.reader} · 原站 ${snapshot.sourceCounts.host}`;
+ const readerWindowUsage = schedulerState
+ ? ` · 请求额度 ${schedulerState.shortWindowCount}/${schedulerState.shortWindowBudget}(10 秒)· ${schedulerState.longWindowCount}/${schedulerState.longWindowBudget}(60 秒)`
+ : '';
+ requestFlowWindow.textContent = `近 10 秒 ${snapshot.recent10.length} 请求 · 排队 ${queued10} · 异常 ${snapshot.recentIssues.filter((entry) => entry.event.startedAt >= snapshot.at - REQUEST_FLOW_TRACE_WINDOW_MS).length} · ${globalUsage}${readerWindowUsage}`;
+ updateRequestFlowHtml(requestFlowChart, requestFlowTraceMarkup(snapshot));
+ const visibleTypes = snapshot.typeStats.slice(0, 8);
+ const typeLegend = visibleTypes.length
+ ? visibleTypes.map((stat) => `
${esc(REQUEST_FLOW_TYPE_LABELS[stat.type] || stat.type)}`).join('')
+ : '
等待请求';
+ updateRequestFlowHtml(requestFlowLegend, `
排队慢/久候错误/限流${typeLegend}`);
+ const bottleneck = requestFlowBottleneckInfo(snapshot, schedulerState);
+ requestFlowBottleneck.dataset.level = bottleneck.level;
+ requestFlowBottleneckState.textContent = bottleneck.state;
+ requestFlowBottleneckDetail.textContent = bottleneck.detail;
+ if (requestFlowAnomalyWindow) {
+ requestFlowAnomalyWindow.textContent = snapshot.recentIssues.length
+ ? `最近 60 秒 ${snapshot.recentIssues.length} 个 · 红色为失败/限流`
+ : '最近 60 秒未发现异常';
+ }
+ if (requestFlowAnomalies) {
+ const latestIssues = snapshot.recentIssues.slice(-8).reverse();
+ updateRequestFlowHtml(requestFlowAnomalies, latestIssues.length
+ ? latestIssues.map(({ event, issue }) => {
+ const typeLabel = REQUEST_FLOW_TYPE_LABELS[event.type] || event.type;
+ const caller = event.callSite || `${event.transport} 发起`;
+ const detail = `${requestFlowSourceLabel(event.source)} / ${typeLabel} · ${requestFlowTimingLabel(event, snapshot.at)} · ${issue.detail} · 发起点 ${caller}`;
+ return `
+ ${esc(formatRequestFlowTimestamp(event.endedAt || event.startedAt))}
+ ${esc(issue.kind)}
+ ${esc(`${event.method} ${event.path}`)}${esc(detail)}
+
`;
+ }).join('')
+ : '
最近 60 秒没有 HTTP 错误、网络中止、超时、慢响应或超过 1 秒的排队。
');
+ }
+ updateRequestFlowHtml(requestFlowTypes, snapshot.typeStats.length
+ ? snapshot.typeStats.map((stat) => {
+ const p95 = stat.p95 ? formatRequestFlowDuration(stat.p95) : '—';
+ return `
+ ${esc(REQUEST_FLOW_TYPE_LABELS[stat.type] || stat.type)}
+ ${stat.count} 次${p95}${stat.issues || '—'}
+
`;
+ }).join('')
+ : '
还没有可统计的请求。
');
+ const recentLog = snapshot.session.slice(-60).reverse();
+ updateRequestFlowHtml(requestFlowLog, recentLog.length
+ ? recentLog.map((event) => {
+ const status = event.controlOnly ? '未发出' : event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '—';
+ const typeLabel = REQUEST_FLOW_TYPE_LABELS[event.type] || event.type;
+ const priorityLabel = requestFlowPriorityLabel(event.priority);
+ const issue = requestFlowIssue(event, snapshot.at);
+ const caller = event.callSite || `${event.transport} 发起`;
+ const title = `${event.method} ${event.path}${priorityLabel ? ` · ${priorityLabel}优先级` : ''}${event.attempt > 1 ? ` · 第 ${event.attempt} 次尝试` : ''} · ${requestFlowTimingLabel(event, snapshot.at)} · 发起点 ${caller}`;
+ return `
+ ${esc(formatRequestFlowTimestamp(event.startedAt))}
+ ${requestFlowSourceLabel(event.source)}
+ ${esc(typeLabel)}
+ ${status}
+ ${esc(requestFlowTimingLabel(event, snapshot.at))}
+ ${esc(`${event.path} ← ${caller}`)}
+
`;
+ }).join('')
+ : '
打开帖子或操作原站页面后,请求会按时间出现在这里。
');
+ const latestLimit = snapshot.latestLimit;
+ if (latestLimit && latestLimit.status === 429) {
+ const typeLabel = REQUEST_FLOW_TYPE_LABELS[latestLimit.type] || '请求';
+ const details = [
+ latestLimit.rateLimitCode && `错误码 ${latestLimit.rateLimitCode}`,
+ latestLimit.retryAfter && `Retry-After ${latestLimit.retryAfter} 秒`,
+ ].filter(Boolean).join(',');
+ requestFlowObserved.textContent = `本页最近观测:${typeLabel}收到 429${details ? `(${details})` : ''}。`;
+ } else if (latestLimit?.serverLimit) {
+ requestFlowObserved.textContent = `服务器最近返回:上限 ${latestLimit.serverLimit}${latestLimit.serverRemaining ? `,剩余 ${latestLimit.serverRemaining}` : ''}${latestLimit.serverReset ? `,重置 ${latestLimit.serverReset}` : ''}。`;
+ } else if (schedulerState?.learnedRateLimit) {
+ requestFlowObserved.textContent = `所有实例已共享历史限流与有效策略:${schedulerState.shortWindowBudget} 次/10 秒、${schedulerState.longWindowBudget} 次/分钟、并发 ${schedulerState.globalMaxConcurrent} 路。`;
+ } else if (schedulerState) {
+ requestFlowObserved.textContent = `当前未收到 429 或服务器上限头;所有实例共同使用 ${schedulerState.shortWindowBudget} 次/10 秒、${schedulerState.longWindowBudget} 次/分钟、并发 ${schedulerState.globalMaxConcurrent} 路。`;
+ }
+ };
+ const stopRequestFlowUpdates = () => {
+ if (requestFlowTimer) clearInterval(requestFlowTimer);
+ requestFlowTimer = 0;
+ setRequestFlowDetailsActive(false);
+ };
+ const startRequestFlowUpdates = () => {
+ stopRequestFlowUpdates();
+ if (document.visibilityState !== 'visible') return;
+ setRequestFlowDetailsActive(true);
+ syncRequestFlowControls();
+ requestFlowTimer = setInterval(syncRequestFlowControls, 1000);
+ };
+ const RESOURCE_MONITOR_RETENTION_MS = 10 * 60 * 1000;
+ const RESOURCE_MONITOR_MAX_EVENTS = 1200;
+ const resourceMonitorState = readerShell.resourceMonitorState || {
+ timer: 0,
+ longTaskObserver: null,
+ longTasksAvailable: false,
+ samples: [],
+ };
+ const RESOURCE_MONITOR_OBSERVER_KEYS = [
+ 'longTaskObserver', 'longFrameObserver', 'resourceObserver', 'mutationObserver',
+ 'readerMutationObserver', 'hostMutationObserver',
+ ];
+ const disconnectResourceMonitorObservers = (keys = RESOURCE_MONITOR_OBSERVER_KEYS) => keys.forEach((key) => {
+ if (isFunction(resourceMonitorState[key]?.disconnect)) {
+ resourceMonitorState[key].disconnect();
+ }
+ resourceMonitorState[key] = null;
+ });
+ if (!Array.isArray(resourceMonitorState.samples)) resourceMonitorState.samples = [];
+ if (!Array.isArray(resourceMonitorState.events)) resourceMonitorState.events = [];
+ if (!Array.isArray(resourceMonitorState.visibilityTimeline)) resourceMonitorState.visibilityTimeline = [];
+ if (!Array.isArray(resourceMonitorState.memorySamples)) resourceMonitorState.memorySamples = [];
+ if (!resourceMonitorState.memory || typeof resourceMonitorState.memory !== 'object') {
+ resourceMonitorState.memory = { bytes: null, status: 'waiting', nextAt: 0, pending: false };
+ }
+ if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer);
+ disconnectResourceMonitorObservers();
+ resourceMonitorState.timer = 0;
+ const previousResourceMonitorVisibility = resourceMonitorState.visibilityTimeline[resourceMonitorState.visibilityTimeline.length - 1];
+ if (previousResourceMonitorVisibility && previousResourceMonitorVisibility.state !== 'stopped') {
+ resourceMonitorState.visibilityTimeline.push({ at: requestFlowNow(), state: 'stopped' });
+ }
+ resourceMonitorState.collecting = false;
+ resourceMonitorState.eventSequence = Number(resourceMonitorState.eventSequence) || 0;
+ resourceMonitorState.lastPruneAt = Number(resourceMonitorState.lastPruneAt) || 0;
+ readerShell.resourceMonitorState = resourceMonitorState;
+ const resourceMonitorLongTasksSupported = isFunction(window.PerformanceObserver) &&
+ (!Array.isArray(window.PerformanceObserver.supportedEntryTypes) ||
+ window.PerformanceObserver.supportedEntryTypes.includes('longtask'));
+ const resourceMonitorLongFramesSupported = isFunction(window.PerformanceObserver) &&
+ [...window.PerformanceObserver.supportedEntryTypes || []].includes('long-animation-frame');
+ const resourceMonitorNow = () => requestFlowNow();
+ const resourceMonitorTimestamp = (at) => {
+ const date = new Date(Number(at) || 0);
+ const clock = date.toLocaleTimeString('zh-CN', { hour12: false });
+ return `${clock}.${String(date.getMilliseconds()).padStart(3, '0')}`;
+ };
+ const resourceMonitorDuration = (value) => {
+ const duration = Math.max(0, Number(value) || 0);
+ return `${duration < 100 ? duration.toFixed(1) : Math.round(duration)}ms`;
+ };
+ const resourceMonitorPrune = (at = resourceMonitorNow()) => {
+ const cutoff = at - RESOURCE_MONITOR_RETENTION_MS;
+ resourceMonitorState.samples = resourceMonitorState.samples
+ .filter((entry) => Number(entry.at) >= cutoff)
+ .slice(-600);
+ resourceMonitorState.memorySamples = resourceMonitorState.memorySamples
+ .filter((entry) => Number(entry.at) >= cutoff)
+ .slice(-60);
+ resourceMonitorState.events = resourceMonitorState.events
+ .filter((entry) => Number(entry.at) >= cutoff)
+ .slice(-RESOURCE_MONITOR_MAX_EVENTS);
+ resourceMonitorState.visibilityTimeline = resourceMonitorState.visibilityTimeline
+ .filter((entry, index, entries) => Number(entry.at) >= cutoff || index === entries.length - 1)
+ .slice(-240);
+ };
+ const resourceMonitorPushEvent = (event) => {
+ const at = Number(event?.at);
+ if (!Number.isFinite(at)) return;
+ resourceMonitorState.events.push(Object.assign({
+ id: ++resourceMonitorState.eventSequence,
+ visibility: 'unknown',
+ scope: 'shared',
+ kind: 'event',
+ duration: 0,
+ detail: '',
+ basis: '',
+ }, event, { at }));
+ if (resourceMonitorState.events.length > RESOURCE_MONITOR_MAX_EVENTS)
+ resourceMonitorState.events.splice(0, resourceMonitorState.events.length - RESOURCE_MONITOR_MAX_EVENTS);
+ const now = resourceMonitorNow();
+ if (now - resourceMonitorState.lastPruneAt >= 1000) {
+ resourceMonitorState.lastPruneAt = now;
+ resourceMonitorPrune(now);
+ }
+ };
+ const resourceMonitorVisibilityAt = (at) => {
+ for (let index = resourceMonitorState.visibilityTimeline.length - 1; index >= 0; index--) {
+ const entry = resourceMonitorState.visibilityTimeline[index];
+ if (Number(entry.at) > at) continue;
+ return ['visible', 'hidden'].includes(entry.state) ? entry.state : 'unknown';
+ }
+ return 'unknown';
+ };
+ const resourceMonitorPushTimedEvent = (at, event) => resourceMonitorPushEvent(Object.assign({
+ at,
+ visibility: resourceMonitorVisibilityAt(at),
+ }, event));
+ const recordResourceMonitorVisibility = (reason = 'visibilitychange', at = resourceMonitorNow()) => {
+ if (!resourceMonitorState.collecting) return;
+ const state = document.visibilityState === 'visible' ? 'visible' : 'hidden';
+ const previous = resourceMonitorState.visibilityTimeline[resourceMonitorState.visibilityTimeline.length - 1];
+ if (previous && previous.state === state) return;
+ resourceMonitorState.visibilityTimeline.push({ at, state });
+ resourceMonitorPushEvent({
+ at,
+ visibility: state,
+ scope: 'shared',
+ kind: 'visibility',
+ detail: reason === 'start' ? `开始取证 · 当前${state === 'visible' ? '前台' : '后台'}` : `页面切换为${state === 'visible' ? '前台' : '后台'}`,
+ basis: 'Page Visibility',
+ });
+ };
+ const resourceMonitorRequestEvents = (at = resourceMonitorNow()) => {
+ const cutoff = at - RESOURCE_MONITOR_RETENTION_MS;
+ pruneRequestFlowEvents(at);
+ return REQUEST_FLOW_EVENTS.filter((event) => !event.controlOnly &&
+ Number(event.startedAt) >= cutoff && Number(event.startedAt) <= at).map((event) => {
+ const visibility = resourceMonitorVisibilityAt(Number(event.startedAt));
+ const scope = event.source === 'reader' ? 'reader' : event.source === 'host' ? 'host' : 'shared';
+ const duration = Math.max(0, event.pending ? at - event.startedAt : Number(event.duration) || 0);
+ const bytes = Math.max(0, Number(event.size) || 0);
+ const status = event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '完成';
+ const initiator = event.resourceTimed
+ ? String(event.transport || 'resource')
+ : REQUEST_FLOW_TYPE_LABELS[event.type] || event.type;
+ const request = event.resourceTimed ? event.path : `${event.method} ${event.path}`;
+ const basis = event.resourceTimed
+ ? `PerformanceResourceTiming${event.source === 'reader' ? ' + reader 元数据' : event.source === 'host' ? ' + 未标记请求' : ''}`
+ : event.source === 'reader' ? '显式 reader 元数据' : event.source === 'host' ? '未标记 fetch/XHR' : 'Resource Timing';
+ return {
+ id: `request-${event.id}`,
+ at: Number(event.startedAt),
+ visibility,
+ scope,
+ kind: 'resource',
+ duration,
+ bytes,
+ detail: `${initiator} · ${request} · ${status} · ${resourceMonitorDuration(duration)}${bytes ? ` · ${formatRequestFlowBytes(bytes)}` : event.resourceTimed ? ' · 传输量受限或缓存命中' : ''}`,
+ basis,
+ };
+ });
+ };
+ const resourceMonitorResourceDropEvents = (at = resourceMonitorNow()) => REQUEST_FLOW_RESOURCE_DROPS
+ .filter((entry) => entry.at >= at - RESOURCE_MONITOR_RETENTION_MS && entry.at <= at)
+ .map((entry, index) => ({
+ id: `resource-drop-${entry.at}-${index}`,
+ at: entry.at,
+ visibility: resourceMonitorVisibilityAt(entry.at),
+ scope: 'shared',
+ kind: 'dropped',
+ detail: `Resource Timing 有 ${entry.count} 条浏览器记录被丢弃;该区间统计不完整`,
+ basis: 'PerformanceObserver droppedEntriesCount',
+ }));
+ const resourceMonitorEvidenceEvents = (requestEvents, resourceDropEvents) => resourceMonitorState.events.concat(
+ requestEvents.filter((event) => ['visible', 'hidden'].includes(event.visibility)),
+ resourceDropEvents.filter((event) => ['visible', 'hidden'].includes(event.visibility))
+ );
+ const resourceMonitorIsReaderRoot = (node) => {
+ if (!node || node.nodeType !== 1) return false;
+ if (node === READER_PORTAL_HOST) return true;
+ return !!node.closest('.ldp-reader-portal-host,.ldp-overlay,.ldp-native-boost-menu,.ldp-lightbox,.ldp-user-card-fallback');
+ };
+ const resourceMonitorElementCount = (nodes, excludeReaderRoots = false) => [...nodes || []].reduce((sum, node) => {
+ if (!node || node.nodeType !== 1 || (excludeReaderRoots && resourceMonitorIsReaderRoot(node))) return sum;
+ return sum + 1 + (isFunction(node.getElementsByTagName) ? node.getElementsByTagName('*').length : 0);
+ }, 0);
+ const resourceMonitorHostDomCount = () => {
+ const total = document.getElementsByTagName('*').length;
+ const readerRoots = document.querySelectorAll('html > .ldp-reader-portal-host,body > .ldp-native-boost-menu');
+ return Math.max(0, total - resourceMonitorElementCount(readerRoots));
+ };
+ const resourceMonitorRecordMutations = (scope, records) => {
+ let added = 0;
+ let removed = 0;
+ records.forEach((record) => {
+ added += resourceMonitorElementCount(record.addedNodes, scope === 'host');
+ removed += resourceMonitorElementCount(record.removedNodes, scope === 'host');
+ });
+ if (!added && !removed) return;
+ const at = resourceMonitorNow();
+ resourceMonitorPushTimedEvent(at, {
+ scope,
+ kind: 'dom',
+ added,
+ removed,
+ detail: `页面元素 +${added} / −${removed} · ${records.length} 个变更记录`,
+ basis: 'MutationObserver 回调',
+ });
+ };
+ const resourceMonitorRecordDroppedEntries = (entryType, options) => {
+ const dropped = Math.max(0, Number(options?.droppedEntriesCount) || 0);
+ if (!dropped) return;
+ const at = resourceMonitorNow();
+ resourceMonitorPushTimedEvent(at, {
+ scope: 'shared',
+ kind: 'dropped',
+ detail: `${entryType} 有 ${dropped} 条浏览器记录被丢弃;该区间统计不完整`,
+ basis: 'PerformanceObserver droppedEntriesCount',
+ });
+ };
+ const resourceMonitorScriptScope = (script) => {
+ const rawSource = String(script?.sourceURL || '');
+ let source = rawSource;
+ try { source = decodeURIComponent(rawSource); } catch {}
+ if (/Awesome LinuxDo Reader|更流畅的 LinuxDo 阅读器|katex@0\.16\.22|pinyin-pro@3\.18\.2|hls\.js@1\.6\.16/i.test(source)) return 'reader';
+ try {
+ const url = new URL(rawSource, location.href);
+ if ((url.origin === BASE || /(?:^|\.)linux\.do$/i.test(url.hostname) || /(?:^|\.)ldstatic\.com$/i.test(url.hostname)) &&
+ /\/(?:assets|plugins|theme-javascripts)\/|\.(?:m?js)(?:$|[?#])/i.test(url.pathname)) return 'host';
+ } catch {}
+ return 'shared';
+ };
+ const resourceMonitorScriptLabel = (script) => {
+ const functionName = String(script?.sourceFunctionName || '').trim();
+ if (functionName) return functionName.slice(0, 80);
+ const source = String(script?.sourceURL || '');
+ if (!source) return '匿名脚本';
+ try {
+ const url = new URL(source, location.href);
+ return `${url.host}${url.pathname}`.slice(0, 100);
+ } catch {
+ return source.split(/[?#]/, 1)[0].slice(0, 100);
+ }
+ };
+ const requestResourceMonitorMemoryMeasurement = (at) => {
+ const memory = resourceMonitorState.memory;
+ const measure = window.performance?.measureUserAgentSpecificMemory;
+ if (!isFunction(measure)) {
+ memory.status = 'unsupported';
+ return;
+ }
+ if (window.crossOriginIsolated !== true) {
+ memory.status = 'not-isolated';
+ return;
+ }
+ if (memory.pending || at < Number(memory.nextAt || 0)) return;
+ memory.pending = true;
+ memory.status = 'measuring';
+ memory.nextAt = at + 30000 + Math.random() * 30000;
+ let measurement;
+ try { measurement = measure.call(window.performance); } catch (error) { measurement = Promise.reject(error); }
+ Promise.resolve(measurement).then((result) => {
+ const bytes = Number(result?.bytes);
+ const measuredAt = resourceMonitorNow();
+ memory.pending = false;
+ if (!Number.isFinite(bytes)) {
+ memory.status = 'error';
+ return;
+ }
+ memory.bytes = bytes;
+ memory.status = 'measured';
+ resourceMonitorState.memorySamples.push({ at: measuredAt, heapUsed: bytes });
+ resourceMonitorPushTimedEvent(measuredAt, {
+ scope: 'shared',
+ kind: 'memory',
+ detail: `页面内存估计 ${formatRequestFlowBytes(bytes)}`,
+ basis: 'measureUserAgentSpecificMemory',
+ });
+ }).catch(() => {
+ memory.pending = false;
+ memory.status = 'error';
+ });
+ };
+ const resourceMonitorStats = (samples, key) => {
+ const values = samples.map((sample) => sample[key]).filter(Number.isFinite);
+ if (!values.length) return { count: 0, average: 0, peak: 0, delta: 0 };
+ return {
+ count: values.length,
+ average: values.reduce((sum, value) => sum + value, 0) / values.length,
+ peak: Math.max(...values),
+ delta: values[values.length - 1] - values[0],
+ };
+ };
+ const resourceMonitorSignedBytes = (value) => {
+ const numeric = Number(value) || 0;
+ if (!numeric) return '0 B';
+ return `${numeric > 0 ? '+' : '-'}${formatRequestFlowBytes(Math.abs(numeric))}`;
+ };
+ const resourceMonitorSignedCount = (value) => {
+ const numeric = Math.round(Number(value) || 0);
+ return numeric > 0 ? `+${numeric}` : String(numeric);
+ };
+ const resourceMonitorTrendLabel = (stats, formatter) => {
+ if (stats.count < 2) return '→ —';
+ const arrow = stats.delta > 0 ? '↑' : stats.delta < 0 ? '↓' : '→';
+ return `${arrow} ${formatter(stats.delta)}`;
+ };
+ const resourceMonitorChartPoints = (samples, key) => {
+ const entries = samples.filter((sample) => Number.isFinite(sample[key]));
+ if (!entries.length) return '';
+ if (entries.length === 1) return '0,17 240,17';
+ const stride = Math.max(1, Math.ceil(entries.length / 120));
+ const plotted = entries.filter((_entry, index) => index % stride === 0 || index === entries.length - 1);
+ const values = plotted.map((entry) => entry[key]);
+ const minimum = Math.min(...values);
+ const maximum = Math.max(...values);
+ const range = maximum - minimum || 1;
+ return plotted.map((entry, index) => {
+ const x = index / Math.max(1, plotted.length - 1) * 240;
+ const y = 31 - (entry[key] - minimum) / range * 28;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ }).join(' ');
+ };
+ const resourceMonitorMemoryLabel = () => {
+ const memory = resourceMonitorState.memory;
+ if (memory.status === 'measured' && Number.isFinite(memory.bytes)) return formatRequestFlowBytes(memory.bytes);
+ if (memory.status === 'measuring') return '正在测量';
+ if (memory.status === 'not-isolated') return '不可用(未隔离)';
+ if (memory.status === 'unsupported') return '浏览器未提供';
+ if (memory.status === 'error') return '测量失败';
+ return '等待测量';
+ };
+ const resourceMonitorScopeSnapshot = (at, requestEvents) => {
+ const createCell = () => ({ requests: 0, requestDuration: 0, bytes: 0, scripts: 0, longTasks: 0, longTaskDuration: 0, domChanges: 0 });
+ const scopes = {
+ reader: { visible: createCell(), hidden: createCell() },
+ host: { visible: createCell(), hidden: createCell() },
+ shared: { visible: createCell(), hidden: createCell() },
+ };
+ const cutoff = at - 60000;
+ requestEvents.forEach((event) => {
+ if (event.at < cutoff || !['visible', 'hidden'].includes(event.visibility)) return;
+ const cell = scopes[event.scope][event.visibility];
+ cell.requests++;
+ cell.requestDuration += event.duration;
+ cell.bytes += event.bytes;
+ });
+ resourceMonitorState.events.forEach((event) => {
+ if (event.at < cutoff || event.at > at || !['visible', 'hidden'].includes(event.visibility)) return;
+ const scope = Object.hasOwn(scopes, event.scope) ? event.scope : 'shared';
+ const cell = scopes[scope][event.visibility];
+ if (event.kind === 'script') cell.scripts += Math.max(0, Number(event.duration) || 0);
+ if (event.kind === 'longtask') {
+ cell.longTasks++;
+ cell.longTaskDuration += Math.max(0, Number(event.duration) || 0);
+ }
+ if (event.kind === 'dom') cell.domChanges += Math.max(0, Number(event.added) || 0) + Math.max(0, Number(event.removed) || 0);
+ });
+ return scopes;
+ };
+ const resourceMonitorScopeCellLabel = (cell, scope) => {
+ const parts = [];
+ if (cell.requests) {
+ const requestCopy = `请求 ${cell.requests} / ${resourceMonitorDuration(cell.requestDuration)}`;
+ parts.push(cell.bytes ? `${requestCopy} / ${formatRequestFlowBytes(cell.bytes)}` : requestCopy);
+ }
+ if (cell.scripts) parts.push(`已归因脚本 ${resourceMonitorDuration(cell.scripts)}`);
+ if (scope === 'shared' && cell.longTasks) parts.push(`长任务 ${cell.longTasks} / ${resourceMonitorDuration(cell.longTaskDuration)}`);
+ if (cell.domChanges) parts.push(`页面元素变更 ${cell.domChanges}`);
+ return parts.length ? parts.join(' · ') : '—';
+ };
+ const syncResourceMonitorScopeControls = (sample, at, requestEvents, evidenceEvents) => {
+ const scopes = resourceMonitorScopeSnapshot(at, requestEvents);
+ const scopeValues = {
+ reader: `${sample.dom} 个页面元素 · ${sample.retainedFloors} 个楼层`,
+ host: `${sample.hostDom} 个页面元素`,
+ shared: resourceMonitorMemoryLabel(),
+ readerBasis: `reader 元数据 · Resource Timing · Reader DOM${resourceMonitorLongFramesSupported ? ' · LoAF 已确认脚本' : ''}`,
+ hostBasis: `未标记 fetch/XHR · Resource Timing · Document DOM${resourceMonitorLongFramesSupported ? ' · 同源 LoAF 脚本' : ''}`,
+ sharedBasis: `Long Tasks · Resource Timing · Page Visibility${resourceMonitorLongFramesSupported ? ' · 未归因 LoAF' : ''}`,
+ };
+ resourceMonitorScopeRows.forEach((row) => {
+ const scope = row.dataset.resourceMonitorScope;
+ row.querySelector('[data-resource-monitor-scope-current]').textContent = scopeValues[scope] || '—';
+ row.querySelector('[data-resource-monitor-scope-visible]').textContent = resourceMonitorScopeCellLabel(scopes[scope].visible, scope);
+ row.querySelector('[data-resource-monitor-scope-hidden]').textContent = resourceMonitorScopeCellLabel(scopes[scope].hidden, scope);
+ row.querySelector('[data-resource-monitor-scope-basis]').textContent = scopeValues[`${scope}Basis`] || '—';
+ });
+ const recentEvents = evidenceEvents.filter((event) => event.at >= at - 60000);
+ const visibleCount = recentEvents.filter((event) => event.visibility === 'visible').length;
+ const hiddenCount = recentEvents.filter((event) => event.visibility === 'hidden').length;
+ const unknownCount = recentEvents.length - visibleCount - hiddenCount;
+ resourceMonitorEvidenceWindow.textContent = `${document.visibilityState === 'visible' ? '当前前台' : '当前后台'} · 前 ${visibleCount} / 后 ${hiddenCount}${unknownCount ? ` / 未归档 ${unknownCount}` : ''} 个原始事件`;
+ };
+ const resourceMonitorScopeLabel = (scope) => RESOURCE_MONITOR_SCOPE_LABELS[scope] || '未归因';
+ const syncResourceMonitorEventLog = (at, evidenceEvents) => {
+ if (!resourceMonitorEventLog) return;
+ const events = evidenceEvents
+ .filter((event) => event.at >= at - RESOURCE_MONITOR_RETENTION_MS && event.at <= at)
+ .sort((left, right) => right.at - left.at || String(right.id).localeCompare(String(left.id)))
+ .slice(0, 48);
+ resourceMonitorEventLog.innerHTML = events.length
+ ? events.map((event) => {
+ const visibilityLabel = event.visibility === 'visible' ? '前台' : event.visibility === 'hidden' ? '后台' : '未知';
+ return `
+
+ ${visibilityLabel}
+ ${esc(resourceMonitorScopeLabel(event.scope))}
+ ${esc(event.detail)}
+ ${esc(event.basis)}
+
`;
+ }).join('')
+ : '
等待浏览器性能、网络资源、页面元素或前后台切换事件。
';
+ };
+ const resourceMonitorHealthInfo = (samples, current, at) => {
+ const baselineSamples = samples.filter((sample) => sample.at >= at - 12000);
+ if (baselineSamples.length < 10)
+ return monitorStatusInfo('normal', '建立基线',
+ `最近 12 秒取得 ${baselineSamples.length} 个真实快照;满 10 个后开始判断页面元素增长、主线程卡顿和请求排队。`);
+ const recent = samples.filter((sample) => sample.at >= at - 60000);
+ const first = recent[0] || current;
+ const domGrowth = current.dom - first.dom;
+ const retainedFloorGrowth = current.retainedFloors - first.retainedFloors;
+ const warnings = [];
+ let level = 'normal';
+ if (current.longTaskDuration >= 2000) {
+ level = 'danger';
+ warnings.push(`最近 10 秒长任务累计 ${Math.round(current.longTaskDuration)}ms`);
+ } else if (current.longTaskDuration >= 500) {
+ level = 'warning';
+ warnings.push(`最近 10 秒长任务累计 ${Math.round(current.longTaskDuration)}ms`);
+ }
+ if (current.queuedRequests >= 10) {
+ level = 'danger';
+ warnings.push(`当前有 ${current.queuedRequests} 条阅读器请求排队`);
+ } else if (current.queuedRequests >= 3) {
+ if (level === 'normal') level = 'warning';
+ warnings.push(`当前有 ${current.queuedRequests} 条阅读器请求排队`);
+ }
+ if (domGrowth >= 1000 && retainedFloorGrowth >= 50) {
+ if (level === 'normal') level = 'warning';
+ warnings.push(`近一分钟页面元素增加 ${domGrowth} 个,保留楼层增加 ${retainedFloorGrowth} 个`);
+ }
+ if (warnings.length)
+ return monitorStatusInfo(level, level === 'danger' ? '资源压力高' : '需要关注',
+ `${warnings.join(';')}。继续观察趋势,回落后状态会自动恢复。`);
+ return monitorStatusInfo('normal', '采样正常',
+ '最近一分钟未发现阅读器页面元素快速膨胀、页面共享主线程卡顿或阅读器请求积压;内存估计不参与自动判定。');
+ };
+ const syncResourceMonitorControls = () => {
+ if (!resourceMonitorSection || !resourceMonitorMetrics.length) return;
+ const at = resourceMonitorNow();
+ const activeContext = readerShell.activeContext;
+ requestResourceMonitorMemoryMeasurement(at);
+ resourceMonitorPrune(at);
+ const requestEvents = resourceMonitorRequestEvents(at);
+ const recentLongTasks = resourceMonitorState.events.filter((entry) =>
+ entry.kind === 'longtask' && entry.at >= at - 10000);
+ const longTaskDuration = recentLongTasks.reduce((sum, entry) => sum + entry.duration, 0);
+ let schedulerState = null;
+ try { schedulerState = currentReaderRequestScheduler().snapshot(); } catch {}
+ const recentReaderRequests = requestEvents
+ .filter((event) => event.scope === 'reader' && event.at >= at - 60000);
+ const readerTransfer = recentReaderRequests.reduce((sum, event) => sum + event.bytes, 0);
+ const sample = {
+ at,
+ visibility: document.visibilityState === 'visible' ? 'visible' : 'hidden',
+ longTaskCount: resourceMonitorState.longTasksAvailable ? recentLongTasks.length : null,
+ longTaskDuration: resourceMonitorState.longTasksAvailable ? longTaskDuration : null,
+ dom: overlay.getElementsByTagName('*').length + 1,
+ hostDom: resourceMonitorHostDomCount(),
+ mountedFloors: activeContext?.streamMountedNodes?.size || 0,
+ retainedFloors: activeContext?.streamNodeMap?.size || 0,
+ nested: activeContext?.subReplyState?.size || 0,
+ media: body.querySelectorAll('img,video,audio,iframe').length,
+ activeRequests: schedulerState?.active || 0,
+ queuedRequests: schedulerState?.queued || 0,
+ readerRequestCount: recentReaderRequests.length,
+ readerTransfer,
+ };
+ const lastSample = resourceMonitorState.samples[resourceMonitorState.samples.length - 1];
+ if (!lastSample || at - lastSample.at >= 800) {
+ if (lastSample && at - lastSample.at > 1800) {
+ const crossedHidden = lastSample.visibility === 'hidden' || resourceMonitorState.visibilityTimeline.some((entry) =>
+ entry.at > lastSample.at && entry.at <= at && entry.state === 'hidden');
+ const crossedStopped = resourceMonitorState.visibilityTimeline.some((entry) =>
+ entry.at > lastSample.at && entry.at <= at && entry.state === 'stopped');
+ resourceMonitorPushEvent({
+ at,
+ visibility: crossedStopped ? 'unknown' : crossedHidden ? 'hidden' : resourceMonitorVisibilityAt(at),
+ scope: 'shared',
+ kind: 'gap',
+ duration: at - lastSample.at,
+ detail: `快照空档 ${resourceMonitorDuration(at - lastSample.at)}${crossedStopped ? '(包含取证暂停)' : crossedHidden ? '(包含后台节流或冻结)' : ''};未补造中间样本`,
+ basis: 'High Resolution Time',
+ });
+ }
+ resourceMonitorState.samples.push(sample);
+ }
+ resourceMonitorPrune(at);
+ if (document.visibilityState !== 'visible') return;
+ const evidenceEvents = resourceMonitorEvidenceEvents(
+ requestEvents,
+ resourceMonitorResourceDropEvents(at)
+ );
+ const samples = resourceMonitorState.samples;
+ const stats = RESOURCE_MONITOR_STAT_KEYS.reduce((result, key) => {
+ result[key] = resourceMonitorStats(key === 'heapUsed' ? resourceMonitorState.memorySamples : samples, key);
+ return result;
+ }, {});
+ const metricValues = {
+ heap: resourceMonitorMemoryLabel(),
+ longTasks: resourceMonitorState.longTasksAvailable
+ ? `${recentLongTasks.length} 次 · ${resourceMonitorDuration(longTaskDuration)}`
+ : '浏览器未提供',
+ dom: `${sample.dom} 个`,
+ floors: `${sample.mountedFloors} / ${sample.retainedFloors} 个`,
+ nested: `${sample.nested} 组`,
+ media: `${sample.media} 个`,
+ requests: schedulerState ? `${schedulerState.active} / ${schedulerState.queued} 条` : '—',
+ network: `${recentReaderRequests.length} 次 · ${formatRequestFlowBytes(readerTransfer)}`,
+ heapAverage: stats.heapUsed.count ? formatRequestFlowBytes(stats.heapUsed.average) : '—',
+ longTasksAverage: stats.longTaskDuration.count
+ ? `${stats.longTaskCount.average.toFixed(1)} / ${Math.round(stats.longTaskDuration.average)}ms`
+ : '—',
+ domAverage: `${Math.round(stats.dom.average)} 个`,
+ floorsAverage: `${Math.round(stats.mountedFloors.average)} / ${Math.round(stats.retainedFloors.average)}`,
+ nestedAverage: `${Math.round(stats.nested.average)} 组`,
+ mediaAverage: `${Math.round(stats.media.average)} 个`,
+ requestsAverage: `${stats.activeRequests.average.toFixed(1)} / ${stats.queuedRequests.average.toFixed(1)}`,
+ networkAverage: `${Math.round(stats.readerRequestCount.average)} / ${formatRequestFlowBytes(stats.readerTransfer.average)}`,
+ heapPeak: stats.heapUsed.count ? formatRequestFlowBytes(stats.heapUsed.peak) : '—',
+ longTasksPeak: stats.longTaskDuration.count
+ ? `${Math.round(stats.longTaskCount.peak)} / ${Math.round(stats.longTaskDuration.peak)}ms`
+ : '—',
+ domPeak: `${Math.round(stats.dom.peak)} 个`,
+ floorsPeak: `${Math.round(stats.mountedFloors.peak)} / ${Math.round(stats.retainedFloors.peak)}`,
+ nestedPeak: `${Math.round(stats.nested.peak)} 组`,
+ mediaPeak: `${Math.round(stats.media.peak)} 个`,
+ requestsPeak: `${Math.round(stats.activeRequests.peak)} / ${Math.round(stats.queuedRequests.peak)}`,
+ networkPeak: `${Math.round(stats.readerRequestCount.peak)} / ${formatRequestFlowBytes(stats.readerTransfer.peak)}`,
+ heapTrend: resourceMonitorTrendLabel(stats.heapUsed, resourceMonitorSignedBytes),
+ longTasksTrend: resourceMonitorTrendLabel(stats.longTaskDuration, (value) => `${resourceMonitorSignedCount(value)}ms`),
+ domTrend: resourceMonitorTrendLabel(stats.dom, resourceMonitorSignedCount),
+ floorsTrend: resourceMonitorTrendLabel(stats.retainedFloors, resourceMonitorSignedCount),
+ nestedTrend: resourceMonitorTrendLabel(stats.nested, resourceMonitorSignedCount),
+ mediaTrend: resourceMonitorTrendLabel(stats.media, resourceMonitorSignedCount),
+ requestsTrend: resourceMonitorTrendLabel(stats.queuedRequests, resourceMonitorSignedCount),
+ networkTrend: resourceMonitorTrendLabel(stats.readerTransfer, resourceMonitorSignedBytes),
+ };
+ resourceMonitorValueColumns.forEach(([elements, dataKey, suffix]) => elements.forEach((element) => {
+ element.textContent = metricValues[`${element.dataset[dataKey]}${suffix}`] || '—';
+ }));
+ const chartCurrentValues = {
+ heapUsed: metricValues.heap,
+ dom: metricValues.dom,
+ retainedFloors: `${sample.retainedFloors} 个`,
+ };
+ resourceMonitorChartRows.forEach((row) => {
+ const key = row.dataset.resourceMonitorChart;
+ const chartSamples = key === 'heapUsed' ? resourceMonitorState.memorySamples : samples;
+ row.querySelector('polyline').setAttribute('points', resourceMonitorChartPoints(chartSamples, key));
+ row.querySelector('[data-resource-monitor-chart-current]').textContent = chartCurrentValues[key] || '—';
+ });
+ const coveredMs = samples.length > 1 ? samples[samples.length - 1].at - samples[0].at : 0;
+ const maximumGap = samples.slice(1).reduce((maximum, entry, index) =>
+ Math.max(maximum, entry.at - samples[index].at), 0);
+ const visibleSamples = samples.filter((entry) => entry.visibility === 'visible').length;
+ const hiddenSamples = samples.filter((entry) => entry.visibility === 'hidden').length;
+ const coveredLabel = coveredMs >= 60000 ? `${(coveredMs / 60000).toFixed(1)} 分钟` : `${Math.round(coveredMs / 1000)} 秒`;
+ resourceMonitorTrendWindow.textContent = `${coveredLabel} · 前 ${visibleSamples} / 后 ${hiddenSamples}${maximumGap > 1800 ? ` · 最大空档 ${formatRequestFlowDuration(maximumGap)}` : ''}`;
+ syncResourceMonitorScopeControls(sample, at, requestEvents, evidenceEvents);
+ syncResourceMonitorEventLog(at, evidenceEvents);
+ const health = resourceMonitorHealthInfo(samples, sample, at);
+ resourceMonitorHealth.dataset.level = health.level;
+ resourceMonitorHealthState.textContent = health.state;
+ resourceMonitorHealthDetail.textContent = health.detail;
+ resourceMonitorUpdated.textContent = `最近快照 ${resourceMonitorTimestamp(at)} · ${sample.visibility === 'visible' ? '前台' : '后台'} · 仅内存保留`;
+ };
+ const stopResourceMonitorUpdates = () => {
+ if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer);
+ resourceMonitorState.timer = 0;
+ disconnectResourceMonitorObservers();
+ if (resourceMonitorState.collecting) {
+ const at = resourceMonitorNow();
+ resourceMonitorPushTimedEvent(at, {
+ scope: 'shared',
+ kind: 'capture',
+ detail: '资源取证暂停',
+ basis: '监控面板生命周期',
+ });
+ resourceMonitorState.visibilityTimeline.push({ at, state: 'stopped' });
+ }
+ resourceMonitorState.collecting = false;
+ };
+ const observeResourceMonitorPerformance = (supported, key, type, callback) => {
+ if (!supported) return false;
+ try {
+ resourceMonitorState[key] = new window.PerformanceObserver(callback);
+ resourceMonitorState[key].observe({ type });
+ return true;
+ } catch {
+ resourceMonitorState[key] = null;
+ return false;
+ }
+ };
+ const startResourceMonitorUpdates = () => {
+ if (resourceMonitorState.collecting) {
+ syncResourceMonitorControls();
+ return;
+ }
+ if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer);
+ resourceMonitorState.timer = 0;
+ resourceMonitorState.collecting = true;
+ resourceMonitorState.longTasksAvailable = false;
+ recordResourceMonitorVisibility('start');
+ resourceMonitorState.longTasksAvailable = observeResourceMonitorPerformance(
+ resourceMonitorLongTasksSupported, 'longTaskObserver', 'longtask', (list, ...callbackArgs) => {
+ resourceMonitorRecordDroppedEntries('Long Tasks', callbackArgs[1]);
+ list.getEntries().forEach((entry) => {
+ const startedAt = window.performance.timeOrigin + Number(entry.startTime || 0);
+ const duration = Math.max(0, Number(entry.duration) || 0);
+ resourceMonitorPushTimedEvent(startedAt, {
+ scope: 'shared',
+ kind: 'longtask',
+ duration,
+ detail: `页面共享主线程长任务 ${resourceMonitorDuration(duration)}`,
+ basis: 'PerformanceLongTaskTiming · 1ms',
+ });
+ });
+ });
+ observeResourceMonitorPerformance(
+ resourceMonitorLongFramesSupported, 'longFrameObserver', 'long-animation-frame', (list, ...callbackArgs) => {
+ resourceMonitorRecordDroppedEntries('Long Animation Frames', callbackArgs[1]);
+ list.getEntries().forEach((entry) => {
+ const startedAt = window.performance.timeOrigin + Number(entry.startTime || 0);
+ const frameDuration = Math.max(0, Number(entry.duration) || 0);
+ const blockingDuration = Math.max(0, Number(entry.blockingDuration) || 0);
+ resourceMonitorPushTimedEvent(startedAt, {
+ scope: 'shared',
+ kind: 'frame',
+ duration: frameDuration,
+ detail: `长动画帧 ${resourceMonitorDuration(frameDuration)} · 阻塞 ${resourceMonitorDuration(blockingDuration)}`,
+ basis: 'Long Animation Frames',
+ });
+ const groups = new Map();
+ [...entry.scripts || []].forEach((script) => {
+ const duration = Math.max(0, Number(script.duration) || 0);
+ if (!duration) return;
+ const scope = resourceMonitorScriptScope(script);
+ const group = groups.get(scope) || { duration: 0, forced: 0, count: 0, labels: [] };
+ group.duration += duration;
+ group.forced += Math.max(0, Number(script.forcedStyleAndLayoutDuration) || 0);
+ group.count++;
+ if (group.labels.length < 2) group.labels.push(resourceMonitorScriptLabel(script));
+ groups.set(scope, group);
+ });
+ groups.forEach((group, scope) => {
+ resourceMonitorPushTimedEvent(startedAt, {
+ scope,
+ kind: 'script',
+ duration: group.duration,
+ detail: `长帧内已归因脚本 ${resourceMonitorDuration(group.duration)}${group.forced ? ` · 强制布局 ${resourceMonitorDuration(group.forced)}` : ''} · ${group.labels.join(' / ')}`,
+ basis: `PerformanceScriptTiming · ${group.count} 段`,
+ });
+ });
+ });
+ });
+ if (isFunction(window.MutationObserver)) {
+ try {
+ resourceMonitorState.readerMutationObserver = new window.MutationObserver((records) => {
+ resourceMonitorRecordMutations('reader', records);
+ });
+ resourceMonitorState.readerMutationObserver.observe(READER_PORTAL_ROOT, {
+ childList: true,
+ subtree: true,
+ });
+ resourceMonitorState.hostMutationObserver = new window.MutationObserver((records) => {
+ resourceMonitorRecordMutations('host', records);
+ });
+ resourceMonitorState.hostMutationObserver.observe(PAGE_ROOT, {
+ childList: true,
+ subtree: true,
+ });
+ } catch {
+ disconnectResourceMonitorObservers(['readerMutationObserver', 'hostMutationObserver']);
+ }
+ }
+ syncResourceMonitorControls();
+ resourceMonitorState.timer = setInterval(syncResourceMonitorControls, 1000);
+ };
+ const syncJumpHighlightControls = () => {
+ const config = jumpHighlightDraft;
+ JUMP_HIGHLIGHT_SETTING_FIELDS.forEach((field) => {
+ const control = jumpHighlightControls[field.key];
+ control.input.value = String(config[field.key]);
+ control.output.textContent = field.format(config[field.key]);
+ if (field.type !== 'color') syncRangeVisual(control.input);
+ });
+ const changed = settingsDraftChangeCount('flash') > 0;
+ jumpHighlightStatus.textContent = changed
+ ? '跳转楼层提示正在实时预览,等待统一保存。'
+ : '跳转楼层提示当前配置已应用。';
+ jumpHighlightStatus.classList.toggle('balanced', !changed);
+ applyJumpHighlightSettings(config);
+ syncSettingsDraftIndicators();
+ };
+ let loadingAnimationPreviewRandomKey = '';
+ const syncLoadingAnimationControls = (reroll = false) => {
+ const preference = normalizeReaderLoadingAnimation(PREFS.loadingAnimation);
+ if (preference === 'random') {
+ if (reroll || !loadingAnimationPreviewRandomKey) {
+ const candidates = READER_LOADING_ANIMATIONS.filter((animation) =>
+ animation.key !== loadingAnimationPreviewRandomKey);
+ const next = candidates[Math.floor(Math.random() * candidates.length)] || READER_LOADING_ANIMATIONS[0];
+ loadingAnimationPreviewRandomKey = next.key;
+ }
+ } else {
+ loadingAnimationPreviewRandomKey = '';
+ }
+ const animation = resolveReaderLoadingAnimation(preference, loadingAnimationPreviewRandomKey);
+ loadingAnimationSelect.value = preference;
+ loadingAnimationPreview.replaceChildren();
+ loadingAnimationPreview.insertAdjacentHTML('afterbegin', readerLoadingVisualHTML(animation));
+ const animationNumber = READER_LOADING_ANIMATIONS.findIndex((candidate) => candidate.key === animation.key) + 1;
+ loadingAnimationPreviewLabel.textContent = `${String(animationNumber).padStart(2, '0')} / ${READER_LOADING_ANIMATIONS.length} · ${animation.label}${preference === 'random' ? ' · 随机预览' : ' · 固定使用'}`;
+ loadingAnimationReroll.hidden = preference !== 'random';
+ };
+ const boostCopyDraftFromControls = () => ({
+ boostCopyMode: boostCopyModeSelect.value,
+ boostCopyPrefix: boostCopyPrefixInput.value,
+ boostCopyCounterMarker: boostCopyCounterMarkerInput.value,
+ boostCopyCounterStep: boostCopyCounterStepInput.value,
+ boostCopyFixedSuffix: boostCopyFixedSuffixInput.value,
+ });
+ const syncBoostCopyPreview = (prefs = boostCopyDraftFromControls()) => {
+ const firstResult = applyBoostCopyRule('原 Boost', prefs);
+ const config = boostCopyConfigFromPrefs(prefs);
+ boostCopyPreview.textContent = config.mode === 'counter'
+ ? `${firstResult} → ${applyBoostCopyRule(firstResult, prefs)}`
+ : firstResult;
+ };
+ const syncCustomSiteControls = () => {
+ const sites = readCustomDiscourseSites();
+ customSiteList.replaceChildren(...sites.map((host) => {
+ const item = makeElement('span');
+ item.className = 'ldp-custom-site-item';
+ item.innerHTML = `
${esc(host)}`;
+ return item;
+ }));
+ customSiteList.hidden = !sites.length;
+ };
+ const syncOtherControls = () => {
+ syncCustomSiteControls();
+ const historyButtonsAlwaysVisible = PREFS.historyButtonsAlwaysVisible === true;
+ const historyEdgeTriggerPercent = normalizeHistoryEdgeTriggerPercent(PREFS.historyEdgeTriggerPercent);
+ historyButtonsAlwaysVisibleInput.checked = historyButtonsAlwaysVisible;
+ readerQueueAlwaysVisibleWhenEmptyInput.checked = PREFS.readerQueueAlwaysVisibleWhenEmpty; overlay._ldpSyncReaderQueue?.();
+ historyEdgeTriggerRange.value = String(historyEdgeTriggerPercent);
+ historyEdgeTriggerRange.disabled = historyButtonsAlwaysVisible;
+ historyEdgeTriggerValue.value = `${historyEdgeTriggerPercent}%`;
+ historyEdgeTriggerValue.textContent = `${historyEdgeTriggerPercent}%`;
+ syncRangeVisual(historyEdgeTriggerRange);
+ overlay._ldpSyncHistoryButtonVisibilityPreference?.();
+ historySortModeSelect.value = PREFS.historySortMode;
+ openTopicsAtFirstPostInput.checked = PREFS.openTopicsAtFirstPost === true;
+ doubleEscapeToCloseReaderInput.checked = PREFS.doubleEscapeToCloseReader;
+ confirmNativeComposerCloseInput.checked = PREFS.confirmNativeComposerClose !== false;
+ topicActionRailVisibleInput.checked = PREFS.topicActionRailVisible;
+ topicActionRailFixedInput.checked = PREFS.topicActionRailFixed;
+ readerShell.activeContext?.syncTopicActionRail?.();
+ expandNestedRepliesByDefaultInput.checked = PREFS.expandNestedRepliesByDefault === true;
+ expandNestedRepliesByDefaultInput.disabled = false;
+ expandLeafNestedRepliesInput.checked = PREFS.expandLeafNestedReplies === true;
+ aggregateDescendantRepliesInput.checked = PREFS.aggregateDescendantReplies === true;
+ aggregateDescendantRepliesInput.disabled = false;
+ {
+ const expandNestedOn = PREFS.expandNestedRepliesByDefault === true;
+ const storedDepth = normalizeInlineReplyTreeMaxDepth(PREFS.inlineReplyTreeMaxDepth);
+ const treeOn = expandNestedOn && storedDepth >= 2;
+ if (inlineReplyTreeEnableInput) {
+ inlineReplyTreeEnableInput.checked = treeOn;
+ inlineReplyTreeEnableInput.disabled = !expandNestedOn;
+ const enableRow = inlineReplyTreeEnableInput.closest('.ldp-inline-reply-tree-enable-row');
+ if (enableRow) enableRow.classList.toggle('is-disabled', !expandNestedOn);
+ }
+ if (inlineReplyTreeDepthSelect) {
+ const selectDepth = treeOn
+ ? Math.max(2, storedDepth)
+ : Math.max(2, storedDepth >= 2 ? storedDepth : INLINE_REPLY_TREE_ENABLED_DEFAULT);
+ inlineReplyTreeDepthSelect.value = String(normalizeInlineReplyTreeMaxDepth(selectDepth));
+ inlineReplyTreeDepthSelect.disabled = !treeOn;
+ }
+ if (inlineReplyTreeDepthRow) inlineReplyTreeDepthRow.hidden = !treeOn;
+ }
+ hideNestedReplyFloorsInput.checked = PREFS.hideNestedReplyFloors === true;
+ nestedReplyDisplayWarning.hidden = !(PREFS.expandNestedRepliesByDefault && PREFS.expandLeafNestedReplies);
+ const boostCopy = boostCopyConfigFromPrefs(PREFS);
+ boostCopyModeSelect.value = boostCopy.mode;
+ boostCopyPrefixInput.value = boostCopy.prefix;
+ boostCopyCounterMarkerInput.value = boostCopy.counterMarker;
+ boostCopyCounterStepInput.value = String(boostCopy.counterStep);
+ boostCopyFixedSuffixInput.value = boostCopy.fixedSuffix;
+ const counterMode = boostCopy.mode === 'counter';
+ boostCopyCounterRows.forEach((row) => { row.hidden = !counterMode; });
+ boostCopyTextRows.forEach((row) => { row.hidden = counterMode; });
+ syncBoostCopyPreview(PREFS);
+ };
+ let cacheControlsSyncToken = 0;
+ const syncCacheControls = async () => {
+ const token = ++cacheControlsSyncToken;
+ flushPersistentCacheWrites();
+ await flushTopicSnapshotWrites();
+ const responseStatsRequest = storedResponseCacheStats();
+ const stats = await Promise.all([...cacheSizeEls].map(async (el) => ({
+ el,
+ stat: await cacheTypeStats(el.dataset.cacheSize, responseStatsRequest),
+ })));
+ if (token !== cacheControlsSyncToken) return;
+ stats.forEach(({ el, stat }) => {
+ const key = el.dataset.cacheSize;
+ el.textContent = formatCacheTypeStats(key, stat);
+ });
+ cacheRetentionEls.forEach((el) => {
+ el.textContent = cacheRetentionLabel(el.dataset.cacheRetention);
+ });
+ cacheClearBtn.disabled = ![...cacheSelects].some((input) => input.checked);
+ };
+ settingHelpTooltip.id = 'ldp-setting-help-tooltip';
+ let activeSettingHelpTarget = null;
+ const hideSettingHelp = () => {
+ if (activeSettingHelpTarget && activeSettingHelpTarget.getAttribute('aria-describedby') === settingHelpTooltip.id)
+ activeSettingHelpTarget.removeAttribute('aria-describedby');
+ activeSettingHelpTarget = null;
+ settingHelpTooltip.classList.remove('is-visible');
+ settingHelpTooltip.hidden = true;
+ };
+ const showSettingHelp = (target) => {
+ const copy = target && target.dataset.settingHelp;
+ if (!copy) return;
+ if (activeSettingHelpTarget && activeSettingHelpTarget !== target) hideSettingHelp();
+ activeSettingHelpTarget = target;
+ target.setAttribute('aria-describedby', settingHelpTooltip.id);
+ settingHelpTooltip.textContent = copy;
+ settingHelpTooltip.hidden = false;
+ settingHelpTooltip.classList.remove('is-visible');
+ const targetRect = target.getBoundingClientRect();
+ const tooltipRect = settingHelpTooltip.getBoundingClientRect();
+ const margin = 12;
+ const gap = 8;
+ const bounds = settingsSurfaceBounds();
+ const minimumLeft = bounds.left + margin;
+ const minimumTop = bounds.top + margin;
+ const maximumLeft = Math.max(minimumLeft, bounds.right - tooltipRect.width - margin);
+ const left = Math.min(maximumLeft, Math.max(
+ minimumLeft,
+ targetRect.left + (targetRect.width - tooltipRect.width) / 2,
+ ));
+ let top = targetRect.top - tooltipRect.height - gap;
+ if (top < minimumTop) top = targetRect.bottom + gap;
+ top = Math.min(
+ Math.max(minimumTop, top),
+ Math.max(minimumTop, bounds.bottom - tooltipRect.height - margin),
+ );
+ settingHelpTooltip.style.left = `${Math.round(left - bounds.left)}px`;
+ settingHelpTooltip.style.top = `${Math.round(top - bounds.top)}px`;
+ settingHelpTooltip.classList.add('is-visible');
+ };
+ let userInfoLoadId = 0;
+ let userInfoLastRefreshAt = 0;
+ let userInfoLastRefreshTimer = 0;
+ const stopUserInfoLastRefreshTicker = () => {
+ if (userInfoLastRefreshTimer) clearInterval(userInfoLastRefreshTimer);
+ userInfoLastRefreshTimer = 0;
+ };
+ const renderUserInfoLastRefresh = () => {
+ if (!userInfoLastRefresh) return;
+ if (!userInfoLastRefreshAt) {
+ userInfoLastRefresh.textContent = '最后刷新 · 暂无';
+ delete userInfoLastRefresh.dataset.ldpTooltipLabel;
+ refreshTooltips(userInfoLastRefresh);
+ return;
+ }
+ const elapsed = Math.max(0, Date.now() - userInfoLastRefreshAt);
+ const seconds = Math.floor(elapsed / 1000);
+ const relative = seconds < 5
+ ? '刚刚'
+ : seconds < 60
+ ? `${seconds} 秒前`
+ : seconds < 3600
+ ? `${Math.floor(seconds / 60)} 分钟前`
+ : seconds < 86400
+ ? `${Math.floor(seconds / 3600)} 小时前`
+ : `${Math.floor(seconds / 86400)} 天前`;
+ const beijingTime = new Date(userInfoLastRefreshAt).toLocaleString('zh-CN', {
+ timeZone: 'Asia/Shanghai',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ }).replaceAll('/', '-').replace(',', '');
+ userInfoLastRefresh.textContent = `最后刷新 · ${relative}`;
+ userInfoLastRefresh.dataset.ldpTooltipLabel = `北京时间 ${beijingTime}`;
+ refreshTooltips(userInfoLastRefresh);
+ };
+ const syncUserInfoLastRefresh = (value) => {
+ userInfoLastRefreshAt = Number(value || 0);
+ renderUserInfoLastRefresh();
+ stopUserInfoLastRefreshTicker();
+ if (userInfoLastRefreshAt && userInfoContent?.closest('.ldp-settings-section')?.hidden === false)
+ userInfoLastRefreshTimer = setInterval(renderUserInfoLastRefresh, 1000);
+ };
+ const syncUserInfoTitleRefresh = (refreshing) => {
+ if (!userInfoTitleRefresh) return;
+ userInfoTitleRefresh.disabled = refreshing;
+ userInfoTitleRefresh.classList.toggle('is-refreshing', refreshing);
+ setLabel(userInfoTitleRefresh, refreshing ? '正在刷新用户信息' : '刷新用户信息');
+ };
+ const loadUserInfoSettings = async (force = false) => {
+ if (!userInfoContent) return;
+ const loadId = ++userInfoLoadId;
+ if (force) syncUserInfoTitleRefresh(true);
+ syncCurrentUserFromHost();
+ if (loadId !== userInfoLoadId) return;
+ const username = String(ME_USERNAME || ME_CURRENT_USER?.username || '').trim();
+ if (!username) {
+ userInfoContent.innerHTML = `
当前未登录 ${esc(SITE_DISPLAY_NAME)},无法读取用户资料。
`;
+ syncUserInfoTitleRefresh(false);
+ return;
+ }
+ const cachedUser = getCachedUserCard(username);
+ const cachedConnect = cachedUser?._ldp_connect_trust || null;
+ syncUserInfoLastRefresh(Math.max(
+ Number(cachedUser && (cachedUser._ldp_user_info_checked_at || cachedUser._ldp_summary_checked_at) || 0),
+ Number(cachedConnect?.cachedAt || 0),
+ ));
+ if (cachedUser)
+ renderSettingsUserInfo(
+ userInfoContent,
+ Object.assign({}, cachedUser, ME_CURRENT_USER || {}, { username }),
+ cachedConnect?.data,
+ null,
+ null,
+ null
+ );
+ else
+ userInfoContent.innerHTML = '
正在加载用户信息…
';
+ const connectAvailable = hasDiscourseCapability('connect');
+ const creditAvailable = SITE_ADAPTER.id === 'linux-do';
+ const [profileResult, connectResult, creditResult] = await Promise.allSettled([
+ fetchSettingsUserInfo(username, force),
+ connectAvailable ? fetchConnectTrustData(force, cachedConnect) : Promise.resolve(null),
+ creditAvailable ? fetchLinuxDoCreditUser(username, force) : Promise.resolve(null),
+ ]);
+ if (loadId !== userInfoLoadId) return;
+ const profile = profileResult.status === 'fulfilled' ? profileResult.value : cachedUser;
+ const user = Object.assign({}, cachedUser || {}, ME_CURRENT_USER || {}, profile || {}, { username });
+ const connectData = connectResult.status === 'fulfilled'
+ ? connectResult.value
+ : cachedConnect?.data || null;
+ const connectError = connectResult.status === 'rejected' ? connectResult.reason : null;
+ const creditUser = creditResult.status === 'fulfilled' ? creditResult.value : null;
+ const creditError = creditResult.status === 'rejected' ? creditResult.reason : null;
+ if (connectAvailable && connectResult.status === 'fulfilled')
+ user._ldp_connect_trust = {
+ data: connectData,
+ cachedAt: Number(CONNECT_TRUST_CACHE?.cachedAt || Date.now()),
+ };
+ if (profileResult.status === 'fulfilled' || connectResult.status === 'fulfilled') {
+ setCachedUserCard(username, user, 0);
+ syncUserInfoLastRefresh(Math.max(
+ Number(user._ldp_user_info_checked_at || user._ldp_summary_checked_at || 0),
+ Number(user._ldp_connect_trust?.cachedAt || 0),
+ Date.now(),
+ ));
+ }
+ renderSettingsUserInfo(userInfoContent, user, connectData, connectError, creditUser, creditError);
+ syncUserInfoTitleRefresh(false);
+ };
+ const settingsDraftConfigs = {
+ font: {
+ label: '字体设置', fields: FONT_PROFILE_VALUE_KEYS,
+ draft: currentFontDraft, saved: currentFontProfile,
+ reset: () => { fontDraft = currentFontProfile(); },
+ patch: () => ({ fontProfile: { ...currentFontDraft() } }),
+ apply: applyFontScales, sync: syncFontControls, beforeSave: closeFontFamilyMenu,
+ },
+ layout: {
+ label: '布局设置', fields: LAYOUT_REGION_KEYS,
+ draft: currentLayoutDraft, saved: currentSavedLayoutRatios,
+ reset: () => { layoutDraft = currentSavedLayoutRatios(); },
+ patch: () => ({ [currentLayoutProfileKey()]: { ...currentLayoutDraft() } }),
+ apply: applyLayoutRatios, sync: syncLayoutControls,
+ changed: (draft, saved, field) => Math.abs(draft[field] - saved[field]) >= 0.01,
+ valid: () => layoutRatioTotal(currentLayoutDraft()) === 100,
+ },
+ appearance: {
+ label: '外观设置', fields: APPEARANCE_PROFILE_VALUE_KEYS,
+ draft: currentAppearanceDraft, saved: currentAppearanceProfile,
+ reset: () => { appearanceDraft = currentAppearanceProfile(); },
+ patch: () => ({ appearanceProfile: { ...currentAppearanceDraft() } }),
+ apply: applyAppearanceColors, sync: syncAppearanceControls,
+ },
+ flash: {
+ label: '动画与提示', fields: JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => field.key),
+ draft: () => jumpHighlightDraft, saved: () => jumpHighlightConfigFromPrefs(PREFS),
+ reset: () => { jumpHighlightDraft = jumpHighlightConfigFromPrefs(PREFS); },
+ patch: () => jumpHighlightPrefsPatch(jumpHighlightDraft),
+ apply: applyJumpHighlightSettings, sync: syncJumpHighlightControls,
+ },
+ };
+ const settingsDraftChangeCount = (panel) => {
+ const config = settingsDraftConfigs[panel];
+ if (!config) return 0;
+ const draft = config.draft();
+ const saved = config.saved();
+ return config.fields.reduce((count, field) =>
+ count + (config.changed ? config.changed(draft, saved, field) : draft[field] !== saved[field]), 0);
+ };
+ const resetSettingsDrafts = (except = '') => {
+ Object.entries(settingsDraftConfigs).forEach(([panel, config]) => {
+ if (panel === except) return;
+ config.reset();
+ config.apply();
+ });
+ syncSettingsDraftIndicators();
+ };
+ let activeSettingsPanel = 'user', activeSettingsLogPanel = 'request';
+ const normalizeSettingsSearch = (value) => String(value || '').trim().toLocaleLowerCase();
+ const settingsMeta = (name) => SETTINGS_PANEL_META.find(([id]) => id === name);
+ const syncSettingsLogUpdates = () => {
+ const open = activeSettingsPanel === 'logs' && !settingsPopover.hidden;
+ (open && activeSettingsLogPanel === 'performance' ? startResourceMonitorUpdates : stopResourceMonitorUpdates)();
+ (open && activeSettingsLogPanel === 'request' && document.visibilityState === 'visible' ? startRequestFlowUpdates : stopRequestFlowUpdates)();
+ };
+ const showSettingsLogPanel = (name) => {
+ activeSettingsLogPanel = name === 'performance' ? 'performance' : 'request';
+ syncTabSelection(settingsLogTabs, 'settingsLogTab', activeSettingsLogPanel, true);
+ settingsLogPanels.forEach((panel) => setHidden(panel, panel.dataset.settingsLogPanel !== activeSettingsLogPanel));
+ syncSettingsLogUpdates();
+ };
+ const setActiveSettingsPanel = (name) => {
+ if (activeSettingsPanel === name) return;
+ activeSettingsPanel = name;
+ settingsTabs.forEach((tab) => {
+ const active = tab.dataset.settingsPanel === name;
+ tab.classList.toggle('active', active);
+ tab.setAttribute('aria-current', active ? 'page' : 'false');
+ });
+ settingsTabs.find((tab) => tab.dataset.settingsPanel === name)?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+ if (name !== 'user') stopUserInfoLastRefreshTicker();
+ syncSettingsLogUpdates();
+ };
+ const settingsSearchAliases = {
+ image: '灯箱',
+ font: '宿主 嵌入态',
+ window: '置顶',
+ appearance: '斑马线',
+ flash: '动效',
+ reading: '其他设置 队列 历史 导航 打开 退出',
+ interaction: '其他设置 主贴 操作 回复 楼中楼 boost 复制',
+ sites: '其他设置 自定义 站点 论坛 discourse',
+ performance: 'dom 窗口预算 并发',
+ logs: '日志 请求 网络 流量 限流 性能 资源 监控 内存 cpu dom',
+ };
+ const applySettingsSearch = (preferred = activeSettingsPanel) => {
+ const query = normalizeSettingsSearch(settingsSearchInput?.value);
+ let count = 0, first = '', selected = '';
+ setHidden(settingsSearchClear, !query);
+ settingsSections.forEach((section) => {
+ const name = section.dataset.settingsPanel || '';
+ if (name === 'user') return;
+ const panel = settingsMeta(name);
+ const match = !query || normalizeSettingsSearch(`${panel?.slice(2).join(' ') || ''} ${settingsSearchAliases[name] || ''} ${section.textContent}`).includes(query);
+ const tab = settingsTabs.find((entry) => entry.dataset.settingsPanel === name);
+ if (tab) setHidden(tab, !!query && !match);
+ if (match) { count++; first ||= name; if (name === preferred) selected = name; }
+ });
+ selected ||= first;
+ settingsSections.forEach((section) => setHidden(section, section.dataset.settingsPanel !== selected));
+ settingsNavGroups.forEach((group) => setHidden(group, !!query &&
+ ![...group.querySelectorAll('.ldp-settings-tab')].some((tab) => !tab.hidden)));
+ if (/性能|资源|监控|内存|cpu|dom/.test(query)) showSettingsLogPanel('performance');
+ else if (/请求|网络|流量|限流/.test(query)) showSettingsLogPanel('request');
+ settingsSearchStatus.textContent = query ? count ? `找到 ${count} 个设置分区` : '没有匹配结果' : '输入名称或功能即可筛选';
+ setHidden(settingsSearchEmpty, !query || count > 0);
+ setActiveSettingsPanel(selected);
+ return selected;
+ };
+ const syncSettingsPageControls = () => [
+ syncImageScaleControls, syncFontControls, syncLayoutControls, syncReaderWindowControls,
+ syncAppearanceControls, syncJumpHighlightControls, syncLoadingAnimationControls,
+ syncPerformanceControls, syncOtherControls, syncCacheControls,
+ ].forEach((sync) => sync());
+ const showSettingsPanel = (name) => {
+ closeFontFamilyMenu();
+ hideSettingHelp();
+ settingsRangeSaveReminderCategory = '';
+ const nextName = settingsMeta(name) ? name : 'image', userMode = nextName === 'user';
+ const wasPages = settingsPanel.classList.contains('is-settings-pages');
+ settingsPanel.classList.toggle('is-settings-pages', !userMode);
+ setHidden(settingsSearchShell, userMode);
+ if (userMode) {
+ settingsSearchInput.value = '';
+ settingsSections.forEach((section) => setHidden(section, section.dataset.settingsPanel !== 'user'));
+ settingsTabs.forEach((tab) => setHidden(tab, false));
+ settingsNavGroups.forEach((group) => setHidden(group, false));
+ setHidden(settingsSearchEmpty, true);
+ setActiveSettingsPanel('user');
+ settingsPanel.scrollTop = 0;
+ void loadUserInfoSettings(false);
+ } else {
+ if (!wasPages) syncSettingsPageControls();
+ if (settingsTabs.find((tab) => tab.dataset.settingsPanel === nextName)?.hidden) settingsSearchInput.value = '';
+ applySettingsSearch(nextName);
+ settingsPanel.scrollTop = 0;
+ }
+ syncSettingsDraftIndicators();
+ syncSettingsLogUpdates();
+ };
+ let settingsRangeDragRow = null;
+ let settingsColorPickRow = null;
+ let settingsWindowDrag = null;
+ let settingsWindowDragFrame = 0;
+ let settingsRangeSaveReminderCategory = '';
+ const settingsDraftSummary = () => Object.entries(settingsDraftConfigs)
+ .map(([panel, config]) => ({ panel, label: config.label, count: settingsDraftChangeCount(panel) }))
+ .filter((entry) => entry.count > 0);
+ function syncSettingsDraftIndicators() {
+ const summary = settingsDraftSummary();
+ const counts = Object.fromEntries(summary.map((entry) => [entry.panel, entry.count]));
+ const total = summary.reduce((count, entry) => count + entry.count, 0);
+ setHidden(settingsDraftBar, !settingsPanel.classList.contains('is-settings-pages') || !total);
+ setText(settingsDraftStatus, `${total} 项更改未保存 · ${summary.map((entry) => entry.label).join('、')}`);
+ settingsSaveAllBtn.disabled = !total;
+ settingsTabs.forEach((tab) => {
+ const count = counts[tab.dataset.settingsPanel] || 0;
+ const badge = tab.querySelector('.ldp-settings-tab-draft-count');
+ const label = settingsMeta(tab.dataset.settingsPanel)?.[2] || '设置';
+ tab.classList.toggle('has-draft', count > 0);
+ setLabel(tab, count > 0 ? `${label},${count} 项未保存` : label);
+ if (!badge) return;
+ setHidden(badge, count <= 0);
+ setText(badge, count > 99 ? '99+' : String(count));
+ });
+ }
+ const remindUnsavedRangeSetting = (range) => {
+ const category = range.closest('.ldp-settings-section')?.dataset.settingsPanel || '';
+ if (!settingsDraftChangeCount(category)) {
+ if (settingsRangeSaveReminderCategory === category) settingsRangeSaveReminderCategory = '';
+ return;
+ }
+ if (settingsRangeSaveReminderCategory === category) return;
+ settingsRangeSaveReminderCategory = category;
+ showSelectionToast('修改已实时预览,可在面板底部统一保存全部更改。');
+ };
+ const stopSettingsRangeDrag = () => {
+ settingsPopover.classList.remove('ldp-range-dragging');
+ if (settingsRangeDragRow) settingsRangeDragRow.classList.remove('ldp-range-drag-active');
+ settingsRangeDragRow = null;
+ };
+ const stopSettingsColorPick = () => {
+ settingsPopover.classList.remove('ldp-color-picking');
+ if (settingsColorPickRow) settingsColorPickRow.classList.remove('ldp-color-pick-active');
+ settingsColorPickRow = null;
+ };
+ const moveSettingsWindowTo = (left, top, width, height) => {
+ const margin = READER_SETTINGS_SURFACE_MARGIN;
+ const bounds = settingsSurfaceBounds();
+ const minimumLeft = bounds.left + margin;
+ const minimumTop = bounds.top + margin;
+ const maximumLeft = Math.max(minimumLeft, bounds.right - width - margin);
+ const maximumTop = Math.max(minimumTop, bounds.bottom - height - margin);
+ const position = {
+ left: Math.round(Math.min(maximumLeft, Math.max(minimumLeft, left))),
+ top: Math.round(Math.min(maximumTop, Math.max(minimumTop, top))),
+ };
+ setStyleValue(settingsPopover, 'left', `${position.left - bounds.left}px`);
+ setStyleValue(settingsPopover, 'top', `${position.top - bounds.top}px`);
+ setStyleValue(settingsPopover, 'transform', 'none');
+ return position;
+ };
+ const keepSettingsWindowVisible = () => {
+ if (settingsPopover.style.transform !== 'none') return;
+ const rect = settingsPopover.getBoundingClientRect();
+ moveSettingsWindowTo(rect.left, rect.top, rect.width, rect.height);
+ };
+ const syncSettingsSurfaceGeometry = ({ closeTransients = true } = {}) => {
+ syncSettingsSurfaceHost();
+ if (closeTransients) {
+ closeFontFamilyMenu();
+ closeColorPickerPopover();
+ hideSettingHelp();
+ }
+ syncReaderSurfaceDensity();
+ if (!settingsPopover.hidden) keepSettingsWindowVisible();
+ };
+ if (!readerShell.syncSettingsSurfaceGeometry) {
+ readerShell.syncSettingsSurfaceGeometry = syncSettingsSurfaceGeometry;
+ }
+ const renderSettingsWindowDrag = () => {
+ settingsWindowDragFrame = 0;
+ if (!settingsWindowDrag) return;
+ const drag = settingsWindowDrag;
+ const margin = READER_SETTINGS_SURFACE_MARGIN;
+ const bounds = drag.bounds;
+ const minimumLeft = bounds.left + margin;
+ const minimumTop = bounds.top + margin;
+ const maximumLeft = Math.max(minimumLeft, bounds.right - drag.width - margin);
+ const maximumTop = Math.max(minimumTop, bounds.bottom - drag.height - margin);
+ drag.previewLeft = Math.min(maximumLeft, Math.max(
+ minimumLeft,
+ drag.startLeft + drag.clientX - drag.startX,
+ ));
+ drag.previewTop = Math.min(maximumTop, Math.max(
+ minimumTop,
+ drag.startTop + drag.clientY - drag.startY,
+ ));
+ settingsPopover.style.transform = `translate3d(${drag.previewLeft - drag.startLeft}px,${drag.previewTop - drag.startTop}px,0)`;
+ };
+ const stopSettingsWindowDrag = (event) => {
+ if (!settingsWindowDrag || (event && event.pointerId !== settingsWindowDrag.pointerId)) return;
+ if (settingsWindowDragFrame) {
+ cancelAnimationFrame(settingsWindowDragFrame);
+ settingsWindowDragFrame = 0;
+ }
+ renderSettingsWindowDrag();
+ const drag = settingsWindowDrag;
+ settingsWindowDrag = null;
+ moveSettingsWindowTo(
+ drag.previewLeft ?? drag.startLeft,
+ drag.previewTop ?? drag.startTop,
+ drag.width,
+ drag.height,
+ );
+ settingsPopover.classList.remove('ldp-settings-window-dragging');
+ try {
+ if (drag.handle.hasPointerCapture(drag.pointerId)) drag.handle.releasePointerCapture(drag.pointerId);
+ } catch {}
+ };
+ const closeSettings = () => {
+ closeFontFamilyMenu();
+ stopSettingsRangeDrag();
+ stopSettingsColorPick();
+ stopSettingsWindowDrag();
+ stopResourceMonitorUpdates();
+ stopRequestFlowUpdates();
+ stopUserInfoLastRefreshTicker();
+ hideSettingHelp();
+ settingsRangeSaveReminderCategory = '';
+ fontFamilyManualEditing.clear();
+ setHidden(settingsPopover, true);
+ syncSettingsSurfaceHost();
+ setExpanded(settingsBtn, false);
+ resetSettingsDrafts();
+ };
+ const saveSettingsDraft = (panel) => {
+ const config = settingsDraftConfigs[panel];
+ if (!config || !settingsDraftChangeCount(panel)) return true;
+ if (config.valid && !config.valid()) return false;
+ config.beforeSave?.();
+ setPrefs(config.patch());
+ settingsRangeSaveReminderCategory = '';
+ config.reset();
+ config.apply();
+ config.sync();
+ return true;
+ };
+ const saveAllSettingsDrafts = () => ['layout', 'font', 'appearance', 'flash'].every(saveSettingsDraft);
+ const requestCloseSettings = () => {
+ const summary = settingsDraftSummary();
+ if (summary.length) {
+ const total = summary.reduce((count, entry) => count + entry.count, 0);
+ openReaderConfirmDialog(readerShell.activeContext, {
+ title: `${total} 项设置尚未保存`,
+ message: '草稿已保留在各设置页。可以保存全部修改后关闭,也可以明确放弃这些草稿。',
+ details: summary.map((entry) => ({ label: entry.label, value: `${entry.count} 项` })),
+ note: '选择“继续编辑”会回到当前设置页,草稿不会丢失。',
+ icon: 'check',
+ tone: 'primary',
+ cancelLabel: '继续编辑',
+ secondaryLabel: '放弃并关闭',
+ confirmLabel: '保存全部并关闭',
+ onSecondary: closeSettings,
+ onConfirm: () => {
+ if (!saveAllSettingsDrafts()) {
+ showSettingsPanel('layout');
+ showSelectionToast('布局比例还没有分配到 100%,请完成后再保存。');
+ return;
+ }
+ closeSettings();
+ showSelectionToast(`已保存 ${total} 项设置`);
+ },
+ });
+ return false;
+ }
+ closeSettings();
+ return true;
+ };
+ const closeReaderFloatingPanels = () => {
+ closeFontFamilyMenu();
+ if (!settingsPopover.hidden) requestCloseSettings();
+ closeNotifications();
+ closeHistory();
+ closeBookmarks();
+ };
+ const readerSurfaceController = readerShell.surfaceController || {
+ closeFloatingPanels: closeReaderFloatingPanels,
+ closeNotifications,
+ closeHistory,
+ closeBookmarks,
+ closeFontFamilyMenu,
+ requestCloseSettings,
+ syncOpenSettingsControls,
+ };
+ readerShell.surfaceController = readerSurfaceController;
+ if (!reusingShell) {
+ applyReaderVisualSettings();
+ applyJumpHighlightSettings();
+ syncOtherControls();
+ settingsTabs.forEach((tab) => {
+ onShell(tab, 'click', () => showSettingsPanel(tab.dataset.settingsPanel || 'image'));
+ });
+ settingsLogTabs.forEach((tab) => onShell(tab, 'click', () => showSettingsLogPanel(tab.dataset.settingsLogTab)));
+ bindReaderPanelSearch(settingsSearchInput, settingsSearchClear, () => { applySettingsSearch(); settingsPanel.scrollTop = 0; });
+ onShell(settingsSaveAllBtn, 'click', () => {
+ const total = settingsDraftSummary().reduce((count, entry) => count + entry.count, 0);
+ if (!saveAllSettingsDrafts()) {
+ showSettingsPanel('layout');
+ return showSelectionToast('布局比例还没有分配到 100%,请完成后再保存。');
+ }
+ showSelectionToast(`已保存 ${total} 项设置`);
+ });
+ onShell(loadingAnimationSelect, 'change', () => {
+ setPref('loadingAnimation', normalizeReaderLoadingAnimation(loadingAnimationSelect.value));
+ loadingAnimationPreviewRandomKey = '';
+ syncLoadingAnimationControls();
+ });
+ onShell(loadingAnimationSelect, 'wheel', (event) => event.stopPropagation(), { passive: true });
+ onShell(loadingAnimationReroll, 'click', () => syncLoadingAnimationControls(true));
+ if (userInfoContent) onShell(userInfoContent, 'click', (event) => {
+ const viewTab = event.target.closest('[data-user-info-view]');
+ if (viewTab) {
+ const view = ['profile', 'credit'].includes(viewTab.dataset.userInfoView) ? viewTab.dataset.userInfoView : 'connect';
+ userInfoContent.dataset.userInfoView = view;
+ syncTabSelection(userInfoContent.querySelectorAll('[data-user-info-view]'), 'userInfoView', view);
+ userInfoContent.querySelectorAll('[data-user-info-panel]').forEach((panel) => {
+ panel.hidden = panel.dataset.userInfoPanel !== view;
+ });
+ }
+ });
+ if (userInfoTitleRefresh) onShell(userInfoTitleRefresh, 'click', () => void loadUserInfoSettings(true));
+ onShell(settingsPopover, 'pointerdown', (event) => {
+ const row = event.target.closest('.ldp-settings-intro,.ldp-settings-search-shell');
+ if (!row || (row.classList.contains('ldp-settings-intro') && settingsPanel.classList.contains('is-settings-pages'))) return;
+ if (event.button !== 0) return;
+ if (event.target.closest('.ldp-user-info-title-refresh,.ldp-settings-search')) return;
+ if (settingsSurfaceIsCompact()) return;
+ const rect = settingsPopover.getBoundingClientRect();
+ closeFontFamilyMenu();
+ stopSettingsRangeDrag();
+ stopSettingsColorPick();
+ hideSettingHelp();
+ const position = moveSettingsWindowTo(rect.left, rect.top, rect.width, rect.height);
+ settingsWindowDrag = {
+ pointerId: event.pointerId,
+ handle: row,
+ startX: event.clientX,
+ startY: event.clientY,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ startLeft: position.left,
+ startTop: position.top,
+ width: rect.width,
+ height: rect.height,
+ bounds: settingsSurfaceBounds(),
+ };
+ settingsPopover.classList.add('ldp-settings-window-dragging');
+ try { row.setPointerCapture(event.pointerId); } catch {}
+ event.preventDefault();
+ });
+ onShell(settingsPopover, 'pointermove', (event) => {
+ if (!settingsWindowDrag || event.pointerId !== settingsWindowDrag.pointerId) return;
+ const coalescedEvents = isFunction(event.getCoalescedEvents) ? event.getCoalescedEvents() : [];
+ const latestEvent = coalescedEvents[coalescedEvents.length - 1] || event;
+ settingsWindowDrag.clientX = latestEvent.clientX;
+ settingsWindowDrag.clientY = latestEvent.clientY;
+ if (!settingsWindowDragFrame) settingsWindowDragFrame = requestAnimationFrame(renderSettingsWindowDrag);
+ event.preventDefault();
+ });
+ const stopSettingsPointerInteractions = (event) => {
+ stopSettingsWindowDrag(event);
+ stopSettingsRangeDrag();
+ };
+ ['pointerup', 'pointercancel'].forEach((type) =>
+ onShell(settingsPopover, type, stopSettingsPointerInteractions));
+ onShell(settingsPopover, 'lostpointercapture', stopSettingsPointerInteractions, true);
+ onShell(configExportBtn, 'click', () => {
+ try {
+ downloadReaderConfigExport();
+ showSelectionToast('设置配置已导出');
+ } catch {
+ showSelectionToast('导出失败,请重试');
+ }
+ });
+ onShell(configImportBtn, 'click', () => {
+ configFileInput.value = '';
+ configFileInput.click();
+ });
+ onShell(configFileInput, 'change', async () => {
+ const file = configFileInput.files?.[0];
+ if (!file) return;
+ let importedPrefs;
+ try {
+ importedPrefs = readerPrefsFromConfigExport(JSON.parse(await file.text()));
+ } catch {
+ showSelectionToast('配置文件无效或版本不匹配,请选择当前版本导出的 JSON 文件');
+ return;
+ }
+ openReaderConfirmDialog(readerShell.activeContext, {
+ title: '导入这份设置配置?',
+ message: `将使用“${file.name}”覆盖当前阅读器设置,并刷新页面。`,
+ note: '浏览历史和缓存不会改变;浮窗尺寸与位置会自动限制在当前屏幕范围内。',
+ confirmLabel: '导入并刷新',
+ onConfirm: () => replacePrefsAndReload(importedPrefs, '导入失败,浏览器未能保存设置'),
+ });
+ });
+ onShell(configResetBtn, 'click', () => {
+ openReaderConfirmDialog(readerShell.activeContext, {
+ title: '恢复全部默认设置?',
+ message: '图片、字体、布局、浮窗、外观、动效、性能、阅读、交互与站点设置都会恢复默认,并刷新页面。',
+ note: '浏览历史和缓存不会被删除。',
+ confirmLabel: '恢复并刷新',
+ onConfirm: () => replacePrefsAndReload(DEFAULT_PREFS, '恢复失败,浏览器未能保存设置'),
+ });
+ });
+ cacheSelects.forEach((input) => onShell(input, 'change', () => {
+ cacheClearBtn.disabled = ![...cacheSelects].some((candidate) => candidate.checked);
+ }));
+ onShell(cacheClearBtn, 'click', async () => {
+ const selected = [...cacheSelects].filter((input) => input.checked).map((input) => input.value);
+ if (!selected.length) return;
+ cacheClearBtn.disabled = true;
+ await clearSelectedCaches(selected);
+ cacheSelects.forEach((input) => { input.checked = false; });
+ await syncCacheControls();
+ });
+ performancePresetButtons.forEach((button) => {
+ onShell(button, 'click', () => {
+ const name = button.dataset.performancePreset;
+ if (name === 'custom') {
+ const firstInput = performanceInputs[0];
+ if (firstInput) firstInput.focus();
+ return;
+ }
+ setPerformancePreset(name);
+ performanceSettingsChanged = true;
+ syncPerformanceControls();
+ });
+ });
+ performanceInputs.forEach((input) => {
+ onShell(input, 'change', () => {
+ setPerformanceValue(input.dataset.performanceKey, input.value);
+ performanceSettingsChanged = true;
+ syncPerformanceControls();
+ });
+ });
+ onShell(performanceResetBtn, 'click', () => {
+ setPerformancePreset('balanced');
+ performanceSettingsChanged = true;
+ syncPerformanceControls();
+ });
+ const previewJumpHighlightControls = () => {
+ const config = Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) =>
+ [field.key, jumpHighlightControls[field.key].input.value]));
+ jumpHighlightDraft = jumpHighlightConfigFromPrefs(Object.assign({}, PREFS, jumpHighlightPrefsPatch(config)));
+ syncJumpHighlightControls();
+ };
+ jumpHighlightInputs.forEach((input) => onShell(input, 'input', previewJumpHighlightControls));
+ [
+ [historyButtonsAlwaysVisibleInput, (checked) => setPref('historyButtonsAlwaysVisible', checked), syncOtherControls],
+ [readerQueueAlwaysVisibleWhenEmptyInput, (checked) => setPref('readerQueueAlwaysVisibleWhenEmpty', checked), syncOtherControls],
+ [openTopicsAtFirstPostInput, (checked) => setPref('openTopicsAtFirstPost', checked), syncOtherControls],
+ [doubleEscapeToCloseReaderInput, (checked) => setPref('doubleEscapeToCloseReader', checked), syncOtherControls],
+ [confirmNativeComposerCloseInput, (checked) => setPref('confirmNativeComposerClose', checked), syncOtherControls],
+ [topicActionRailVisibleInput, (checked) => setPref('topicActionRailVisible', checked), syncOtherControls],
+ [topicActionRailFixedInput, (checked) => setPref('topicActionRailFixed', checked), syncOtherControls],
+ [aggregateDescendantRepliesInput, (checked) => {
+ setPrefs({
+ aggregateDescendantReplies: checked,
+ expandNestedRepliesByDefault: checked ? true : PREFS.expandNestedRepliesByDefault,
+ });
+ readerShell.activeContext?.subReplyState.forEach((state) => { state.pageIndex = 0; state.renderedKey = ''; });
+ readerShell.activeContext?.subReplyState.forEach((state, postNumber) => renderSubReplyBatch(postNumber, readerShell.activeContext));
+ applyNestedReplyDisplayPreference(readerShell.activeContext);
+ }, syncOtherControls],
+ [hideNestedReplyFloorsInput, (checked) => { setPref('hideNestedReplyFloors', checked); applyNestedReplyDisplayPreference(readerShell.activeContext, true); }, syncOtherControls],
+ [lightboxOriginalByDefaultInput, (checked) => setPref('lightboxOriginalByDefault', checked)],
+ [lightboxCommentsExpandedInput, (checked) => setPref('lightboxCommentsExpandedByDefault', checked)],
+ [lightboxDescriptionExpandedInput, (checked) => setPref('lightboxDescriptionExpanded', checked)],
+ [fontRenderingEnabledInput, (checked) => setPref('fontRenderingEnabled', checked)],
+ [fontRenderingOnHostInput, (checked) => setPref('fontRenderingOnHost', checked)],
+ [readerWindowPinInput, (checked) => readerWindow.setPinned(checked), syncReaderWindowControls],
+ [readerWindowLockInput, (checked) => readerWindow.setLocked(checked), syncReaderWindowControls],
+ ].forEach(([input, save, sync]) => onShell(input, 'change', () => {
+ save(input.checked);
+ if (sync) sync();
+ }));
+ onShell(topicActionRailResetBtn, 'click', () => { setPref('topicActionRailPosition', { x: 'left', y: .95 }); syncOtherControls(); });
+ onShell(historyEdgeTriggerRange, 'input', () => {
+ setPref('historyEdgeTriggerPercent', normalizeHistoryEdgeTriggerPercent(historyEdgeTriggerRange.value));
+ syncOtherControls();
+ });
+ onShell(historySortModeSelect, 'change', () => {
+ setPref('historySortMode', historySortModeSelect.value);
+ syncOtherControls();
+ syncHistorySortToggle();
+ overlay._ldpRefreshReaderHistoryNavigation?.();
+ });
+ const addCustomSite = async () => {
+ const host = normalizeCustomDiscourseHost(customSiteInput.value);
+ if (!host || BUILTIN_DISCOURSE_HOSTS.has(host)) {
+ customSiteStatus.textContent = host
+ ? `${host} 已内置支持,无需重复添加。`
+ : '请输入有效的 HTTPS 域名或网址。';
+ return;
+ }
+ customSiteAddBtn.disabled = true;
+ customSiteStatus.textContent = '正在检测 Discourse…';
+ try {
+ const info = await validateCustomDiscourseSite(host);
+ await writeCustomDiscourseSites([...readCustomDiscourseSites(), host]);
+ customSiteInput.value = '';
+ syncCustomSiteControls();
+ customSiteStatus.textContent = `已添加 ${info.title || host},访问该站即可使用。`;
+ } catch (error) {
+ customSiteStatus.textContent = `${error.message || '检测失败'};仅支持 Discourse 论坛。`;
+ } finally {
+ customSiteAddBtn.disabled = false;
+ }
+ };
+ onShell(customSiteAddBtn, 'click', addCustomSite);
+ onShell(customSiteInput, 'keydown', (event) => {
+ if (event.key === 'Enter') addCustomSite();
+ });
+ onShell(customSiteList, 'click', async (event) => {
+ const host = event.target.closest('[data-custom-site-remove]')?.dataset.customSiteRemove;
+ if (!host) return;
+ await writeCustomDiscourseSites(readCustomDiscourseSites().filter((site) => site !== host));
+ syncCustomSiteControls();
+ customSiteStatus.textContent = `已移除 ${host}。`;
+ });
+ const reapplyInlineReplyTreePreference = () => {
+ const ctx = readerShell.activeContext;
+ if (ctx?.subReplyState) {
+ ctx.subReplyState.forEach((state) => { state.pageIndex = 0; state.renderedKey = ''; });
+ ctx.subReplyState.forEach((state, postNumber) => renderSubReplyBatch(postNumber, ctx));
+ }
+ applyNestedReplyDisplayPreference(ctx);
+ };
+ const saveNestedReplyDisplayPreference = (changedInput) => {
+ const aggregate = PREFS.aggregateDescendantReplies === true;
+ let expandAll = expandNestedRepliesByDefaultInput.checked;
+ let expandLeaf = expandLeafNestedRepliesInput.checked;
+ if (changedInput === expandNestedRepliesByDefaultInput && !expandAll) expandLeaf = true;
+ else if (!aggregate && changedInput === expandLeafNestedRepliesInput && !expandLeaf) expandAll = true;
+ const treeWasActive = inlineReplyTreeEnabled();
+ setPrefs({
+ expandNestedRepliesByDefault: expandAll,
+ expandLeafNestedReplies: expandLeaf,
+ aggregateDescendantReplies: expandAll && aggregate,
+ });
+ applyNestedReplyDisplayPreference(readerShell.activeContext);
+ syncOtherControls();
+ // Effective tree depth becomes 1 while secondary replies are off; re-render if that changed.
+ if (treeWasActive !== inlineReplyTreeEnabled()) reapplyInlineReplyTreePreference();
+ };
+ [expandNestedRepliesByDefaultInput, expandLeafNestedRepliesInput].forEach((input) =>
+ onShell(input, 'change', () => saveNestedReplyDisplayPreference(input)));
+ if (inlineReplyTreeEnableInput) {
+ onShell(inlineReplyTreeEnableInput, 'change', () => {
+ if (!PREFS.expandNestedRepliesByDefault) {
+ inlineReplyTreeEnableInput.checked = false;
+ syncOtherControls();
+ return;
+ }
+ const enabled = inlineReplyTreeEnableInput.checked;
+ const selectDepth = normalizeInlineReplyTreeMaxDepth(
+ inlineReplyTreeDepthSelect?.value || INLINE_REPLY_TREE_ENABLED_DEFAULT,
+ );
+ const nextDepth = enabled
+ ? Math.max(2, selectDepth >= 2 ? selectDepth : INLINE_REPLY_TREE_ENABLED_DEFAULT)
+ : 1;
+ setPref('inlineReplyTreeMaxDepth', nextDepth);
+ reapplyInlineReplyTreePreference();
+ syncOtherControls();
+ });
+ }
+ if (inlineReplyTreeDepthSelect) {
+ onShell(inlineReplyTreeDepthSelect, 'change', () => {
+ if (!PREFS.expandNestedRepliesByDefault || !inlineReplyTreeEnableInput?.checked) {
+ syncOtherControls();
+ return;
+ }
+ const nextDepth = Math.max(2, normalizeInlineReplyTreeMaxDepth(inlineReplyTreeDepthSelect.value));
+ setPref('inlineReplyTreeMaxDepth', nextDepth);
+ reapplyInlineReplyTreePreference();
+ syncOtherControls();
+ });
+ }
+ const saveBoostCopyControls = () => {
+ setPrefs(boostCopyDraftFromControls());
+ syncOtherControls();
+ };
+ onShell(boostCopyModeSelect, 'change', saveBoostCopyControls);
+ [boostCopyPrefixInput, boostCopyCounterMarkerInput, boostCopyCounterStepInput, boostCopyFixedSuffixInput]
+ .forEach((input) => {
+ onShell(input, 'input', () => syncBoostCopyPreview());
+ onShell(input, 'change', saveBoostCopyControls);
+ });
+ onShell(notificationPanel.markAll, 'click', async () => {
+ if (NOTIFICATION_GROUPS[notificationState.group]?.source !== 'notifications' ||
+ notificationPanel.markAll.disabled ||
+ notificationPanel.markAll.dataset.ldpRequestBusy === '1') return;
+ setReaderControlBusy(notificationPanel.markAll, true);
+ const previousHtml = notificationPanel.markAll.innerHTML;
+ notificationPanel.markAll.innerHTML = `${icon('loader')}
处理中`;
+ try {
+ await apiSend(`${BASE}/notifications/mark-read`, 'PUT');
+ syncCurrentUserNotificationsDismissed();
+ markCachedNotificationsRead();
+ notificationPanel.list.querySelectorAll('.ldp-notification-item.unread')
+ .forEach((item) => {
+ item.classList.remove('unread');
+ const readState = item.querySelector('.ldp-notification-read-state');
+ if (readState) readState.textContent = '已读';
+ });
+ showSelectionToast('消息已全部标为已读');
+ } catch (error) {
+ alert(`标记已读失败:${error.message}`);
+ } finally {
+ setReaderControlBusy(notificationPanel.markAll, false);
+ notificationPanel.markAll.innerHTML = previousHtml;
+ notificationPanel.markAll.disabled = cachedUnreadNotificationCount(notificationState.group) <= 0;
+ }
+ });
+ const bindCollectionPanelToggle = (controller, peers, openPanel) => {
+ onShell(controller.toggle, 'click', (event) => {
+ consumeEvent(event);
+ const opening = controller.popover.hidden;
+ peers.forEach((peer) => peer.close());
+ if (!requestCloseSettings()) return;
+ if (!opening) return controller.close();
+ controller.open();
+ openPanel();
+ });
+ };
+ bindCollectionPanelToggle(notificationCollection, [historyCollection, bookmarksCollection], () => {
+ positionNotificationsPopover();
+ syncCurrentUserFromHost();
+ readerShell.startNotificationLiveEvents?.();
+ notificationState.page = 0;
+ loadNotificationPage(notificationState.group, 0, { force: true });
+ });
+ bindCollectionPanelToggle(historyCollection, [notificationCollection, bookmarksCollection], renderHistoryPage);
+ bindCollectionPanelToggle(bookmarksCollection, [notificationCollection, historyCollection], () => {
+ positionBookmarksPopover();
+ loadBookmarksPage();
+ });
+ notificationTabs.forEach((tab) => {
+ onShell(tab, 'click', () => {
+ notificationState.group = tab.dataset.notificationGroup || 'all';
+ notificationState.page = 0;
+ loadNotificationPage(notificationState.group, notificationState.page);
+ });
+ });
+ notificationModeTabs.forEach((tab) => {
+ onShell(tab, 'click', () => {
+ const mode = tab.dataset.notificationMode || 'notifications';
+ const targetTab = notificationTabs.find((entry) =>
+ entry.dataset.notificationGroup === notificationState.groups[mode]
+ ) || notificationTabs.find((entry) =>
+ NOTIFICATION_GROUPS[entry.dataset.notificationGroup]?.mode === mode
+ );
+ if (targetTab) loadNotificationPage(targetTab.dataset.notificationGroup, 0);
+ });
+ });
+ const collectionSearchCancels = {};
+ const bindCollectionNavigation = (key, panel, state, search, page, delay = 0) => {
+ collectionSearchCancels[key] = bindReaderPanelSearch(panel.search, panel.searchClear, (query) => {
+ state.query = query;
+ state.page = 0;
+ search(query);
+ }, delay);
+ bindReaderPanelPager(panel.pagePrev, panel.pageNext, state, page);
+ };
+ bindCollectionNavigation('notification', notificationPanel, notificationState,
+ () => loadNotificationPage(notificationState.group, 0),
+ () => loadNotificationPage(notificationState.group, notificationState.page));
+ onShell(historyPanel.sortToggle, 'click', () => {
+ setPref('historySortMode', PREFS.historySortMode === 'recent-viewed' ? 'first-viewed' : 'recent-viewed');
+ historyState.page = 0;
+ historySelection.clear();
+ syncOtherControls();
+ renderHistoryPage();
+ overlay._ldpRefreshReaderHistoryNavigation?.();
+ refreshTooltips(historyPanel.sortToggle);
+ });
+ bindReaderPanelMultiMode(
+ historyPanel.popover, historyState, historySelection,
+ historyPanel.multi, historyPanel.multiDone, historyPanel.selectScope, renderHistoryPage
+ );
+ onShell(historyPanel.selectToggle, 'click', () => {
+ toggleReaderPanelSelection(historySelection, historyScopeIds);
+ renderHistoryPage();
+ });
+ bindCollectionNavigation('history', historyPanel, historyState, () => {
+ historySelection.clear();
+ renderHistoryPage();
+ }, renderHistoryPage);
+ onShell(historyPanel.deleteSelected, 'click', () => {
+ const selectedCount = historySelection.size;
+ if (!selectedCount) return;
+ openReaderConfirmDialog(readerShell.activeContext, {
+ title: '删除所选浏览历史?',
+ message: `将删除选中的 ${selectedCount} 条阅读器浏览历史。`,
+ note: '不会清除浏览器自身的访问历史。',
+ icon: 'trash',
+ confirmLabel: `删除 ${selectedCount} 条`,
+ onConfirm: () => {
+ writeTopicHistory(readTopicHistory().filter((entry) => !historySelection.has(Number(entry.topicId))));
+ historySelection.clear();
+ renderHistoryPage();
+ showSelectionToast(`已删除 ${selectedCount} 条浏览历史`);
+ },
+ });
+ });
+ onShell(historyPanel.clear, 'click', () => {
+ const historyCount = readTopicHistory().length;
+ if (!historyCount) return;
+ openReaderConfirmDialog(readerShell.activeContext, {
+ title: '清空浏览历史?',
+ message: `将删除全部 ${historyCount} 条阅读器浏览历史。`,
+ note: '此操作无法撤销,但不会清除浏览器自身的访问历史。',
+ icon: 'trash',
+ confirmLabel: '清空全部',
+ onConfirm: () => {
+ writeTopicHistory([]);
+ historySelection.clear();
+ historyPanel.search.value = '';
+ historyPanel.searchClear.hidden = true;
+ historyState.query = '';
+ historyState.page = 0;
+ renderHistoryPage();
+ showSelectionToast('浏览历史已清空');
+ },
+ });
+ });
+ bookmarksTabs.forEach((tab) => {
+ onShell(tab, 'click', () => {
+ const requestedType = tab.dataset.bookmarkType;
+ const nextType = ['Topic', 'Post', 'Reaction'].includes(requestedType) ? requestedType : 'Topic';
+ if (nextType === bookmarksState.type) return;
+ bookmarksState.type = nextType;
+ bookmarksState.page = 0;
+ bookmarksState.multi = false;
+ clearBookmarksSelectionScope();
+ if (nextType !== 'Reaction' || bookmarksState.reactionLoadedAt <= 0) renderBookmarksPage();
+ loadBookmarksPage();
+ });
+ });
+ let bookmarkTabDrag = null;
+ let suppressBookmarkTabClick = false;
+ const finishBookmarkTabDrag = (event) => {
+ if (!bookmarkTabDrag || event.pointerId !== bookmarkTabDrag.pointerId) return;
+ const { tab, moved } = bookmarkTabDrag;
+ bookmarkTabDrag = null;
+ tab.classList.remove('ldp-bookmark-tab-dragging');
+ if (!moved) return;
+ suppressBookmarkTabClick = true;
+ setPref('bookmarkTabOrder', Array.from(bookmarksTabList.children, (item) => item.dataset.bookmarkType));
+ };
+ onShell(bookmarksTabList, 'click', (event) => {
+ if (!suppressBookmarkTabClick) return;
+ suppressBookmarkTabClick = false;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }, true);
+ onShell(bookmarksTabList, 'pointerdown', (event) => {
+ if (event.pointerType !== 'mouse' || event.button !== 0) return;
+ const tab = event.target.closest('.ldp-bookmark-tab');
+ if (!tab) return;
+ bookmarkTabDrag = {
+ tab,
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startY: event.clientY,
+ moved: false,
+ };
+ try { tab.setPointerCapture(event.pointerId); } catch {}
+ });
+ onShell(bookmarksTabList, 'pointermove', (event) => {
+ if (!bookmarkTabDrag || event.pointerId !== bookmarkTabDrag.pointerId) return;
+ if (!bookmarkTabDrag.moved && Math.hypot(
+ event.clientX - bookmarkTabDrag.startX,
+ event.clientY - bookmarkTabDrag.startY
+ ) < 5) return;
+ bookmarkTabDrag.moved = true;
+ bookmarkTabDrag.tab.classList.add('ldp-bookmark-tab-dragging');
+ event.preventDefault();
+ const siblings = [...bookmarksTabList.children].filter((item) => item !== bookmarkTabDrag.tab);
+ const next = siblings.find((item) => {
+ const rect = item.getBoundingClientRect();
+ return event.clientX < rect.left + rect.width / 2;
+ });
+ bookmarksTabList.insertBefore(bookmarkTabDrag.tab, next || null);
+ });
+ onShell(bookmarksTabList, 'pointerup', finishBookmarkTabDrag);
+ onShell(bookmarksTabList, 'pointercancel', finishBookmarkTabDrag);
+ onShell(bookmarksReactionFilters, 'click', (e) => {
+ const button = e.target.closest('[data-reaction-filter]');
+ if (!button) return;
+ bookmarksState.reactionFilter = String(button.dataset.reactionFilter || '');
+ bookmarksState.page = 0;
+ renderBookmarksPage();
+ });
+ bindReaderPanelMultiMode(
+ bookmarksPanel.popover, bookmarksState, bookmarksSelection,
+ bookmarksPanel.multi, bookmarksPanel.multiDone, bookmarksPanel.selectScope, renderBookmarksPage,
+ () => { bookmarksState.allIds = null; }
+ );
+ onShell(bookmarksPanel.selectToggle, 'click', () => {
+ let scopeIds = bookmarksState.entries.map((entry) => Number(entry.id)).filter((id) => id > 0);
+ if (bookmarksState.scope === 'all') {
+ if (!bookmarksState.allIds)
+ bookmarksState.allIds = bookmarkTypeEntries()
+ .map((entry) => Number(entry.id))
+ .filter((bookmarkId) => bookmarkId > 0);
+ scopeIds = bookmarksState.allIds || [];
+ }
+ toggleReaderPanelSelection(bookmarksSelection, scopeIds);
+ renderBookmarksPage();
+ });
+ bindCollectionNavigation('bookmarks', bookmarksPanel, bookmarksState, () => {
+ clearBookmarksSelectionScope();
+ if (bookmarksState.type === 'Reaction' && bookmarksState.reactionLoadedAt > 0) renderBookmarksPage();
+ else loadBookmarksPage();
+ }, renderBookmarksPage, 250);
+ readerShell.cancelBookmarksSearch = collectionSearchCancels.bookmarks;
+ onShell(bookmarksPanel.deleteSelected, 'click', async () => {
+ const bookmarkIds = [...bookmarksSelection];
+ if (!bookmarkIds.length || bookmarksPanel.deleteSelected.dataset.ldpRequestBusy === '1' ||
+ !window.confirm(`确定取消选中的 ${bookmarkIds.length} 条收藏吗?`)) return;
+ setReaderControlBusy(bookmarksPanel.deleteSelected, true);
+ const visibleRemoved = bookmarksState.entries.filter((entry) => bookmarksSelection.has(Number(entry.id)));
+ const selectedRemoved = bookmarksState.allEntries.filter((entry) => bookmarksSelection.has(Number(entry.id)));
+ const removedAllMatches = Array.isArray(bookmarksState.allIds) && bookmarksState.allIds.length > 0 &&
+ bookmarksState.allIds.every((bookmarkId) => bookmarksSelection.has(bookmarkId));
+ try {
+ const bookmarkModule = lookupDiscourseModule('discourse/models/bookmark');
+ const Bookmark = bookmarkModule?.default;
+ if (!Bookmark || !isFunction(Bookmark.bulkOperation))
+ throw new Error('Discourse 收藏批处理接口尚未就绪');
+ await Bookmark.bulkOperation(
+ bookmarkIds.map((id) => ({ id })),
+ { type: 'delete' }
+ );
+ await clearStoredResponseCache({ tags: ['bookmarks'] });
+ selectedRemoved.forEach(applyRemovedBookmarkState);
+ const clearedPage = bookmarksState.entries.length > 0 && visibleRemoved.length === bookmarksState.entries.length;
+ if (removedAllMatches) bookmarksState.page = 0;
+ else if (clearedPage && bookmarksState.page > 0) bookmarksState.page -= 1;
+ clearBookmarksSelectionScope();
+ await loadBookmarksPage();
+ showSelectionToast(`已取消 ${bookmarkIds.length} 条收藏`);
+ } catch (error) {
+ alert(`批量取消收藏失败:${error.message}`);
+ } finally {
+ setReaderControlBusy(bookmarksPanel.deleteSelected, false);
+ if (!bookmarksPanel.popover.hidden) renderBookmarksPage();
+ }
+ });
+ onShell(notificationsPopover, 'click', (e) => e.stopPropagation());
+ bindFloatingSurfaceWheel(notificationsPopover);
+ bindReaderPanelSelectionList(historyPanel.list, '.ldp-history-select-input', '[data-history-topic-id]',
+ 'historyTopicId', historySelection, renderHistoryPage);
+ onShell(historyPanel.popover, 'click', (e) => {
+ e.stopPropagation();
+ const item = e.target.closest('[data-history-topic-id]');
+ if (!item) return;
+ if (e.target.closest('.ldp-history-select')) return;
+ e.preventDefault();
+ const targetTopicId = Number(item.dataset.historyTopicId);
+ const targetPostNumber = Math.max(1, Number(item.dataset.historyPostNumber) || 1);
+ if (e.target.closest('.ldp-history-delete')) {
+ writeTopicHistory(readTopicHistory().filter((entry) => Number(entry.topicId) !== targetTopicId));
+ historySelection.delete(targetTopicId);
+ renderHistoryPage();
+ showSelectionToast('已删除这条浏览历史');
+ return;
+ }
+ if (targetTopicId > 0) openModal(targetTopicId, { targetPostNumber });
+ });
+ bindFloatingSurfaceWheel(historyPanel.popover);
+ bindReaderPanelSelectionList(bookmarksPanel.list, '.ldp-bookmark-select-input', '[data-bookmark-id]',
+ 'bookmarkId', bookmarksSelection, renderBookmarksPage);
+ onShell(bookmarksPanel.popover, 'click', async (e) => {
+ e.stopPropagation();
+ const reactionItem = e.target.closest('[data-reaction-record]');
+ if (reactionItem) {
+ e.preventDefault();
+ const targetTopicId = Number(reactionItem.dataset.reactionTopicId);
+ const targetPostNumber = Math.max(1, Number(reactionItem.dataset.reactionPostNumber) || 1);
+ if (targetTopicId > 0) {
+ closeBookmarks();
+ openModal(targetTopicId, { targetPostNumber });
+ }
+ return;
+ }
+ const item = e.target.closest('[data-bookmark-id]');
+ if (!item || e.target.closest('.ldp-bookmark-select')) return;
+ e.preventDefault();
+ const bookmarkId = Number(item.dataset.bookmarkId);
+ const entry = bookmarksState.entries.find((candidate) => Number(candidate.id) === bookmarkId);
+ if (!entry) return;
+ if (e.target.closest('.ldp-bookmark-delete')) {
+ const deleteBtn = e.target.closest('.ldp-bookmark-delete');
+ if (deleteBtn.dataset.ldpRequestBusy === '1') return;
+ setReaderControlBusy(deleteBtn, true);
+ try {
+ await deleteDiscourseBookmark(bookmarkId);
+ await clearStoredResponseCache({ tags: ['bookmarks'] });
+ applyRemovedBookmarkState(entry);
+ bookmarksSelection.delete(bookmarkId);
+ bookmarksState.allIds = null;
+ if (bookmarksState.entries.length === 1 && bookmarksState.page > 0) bookmarksState.page -= 1;
+ await loadBookmarksPage();
+ showSelectionToast('已取消这条收藏');
+ } catch (error) {
+ alert(`取消收藏失败:${error.message}`);
+ setReaderControlBusy(deleteBtn, false);
+ }
+ return;
+ }
+ const target = bookmarkTarget(entry);
+ if (target.topicId > 0) {
+ closeBookmarks();
+ openModal(target.topicId, { targetPostNumber: target.postNumber });
+ }
+ });
+ bindFloatingSurfaceWheel(bookmarksPanel.popover);
+ themeButtons.forEach((button) => onShell(button, 'click', (event) => {
+ consumeEvent(event);
+ transitionReaderTheme(button.dataset.readerThemeMode);
+ }));
+ onShell(translateBtn, 'click', (event) => {
+ consumeEvent(event);
+ readerShell.activeContext?.translation?.cycleMode();
+ });
+ onShell(settingsBtn, 'click', (e) => {
+ consumeEvent(e);
+ const nextHidden = !settingsPopover.hidden;
+ closeNotifications();
+ closeHistory();
+ closeBookmarks();
+ if (nextHidden) {
+ closeFontFamilyMenu();
+ requestCloseSettings();
+ return;
+ }
+ syncLayoutDraftMode(true);
+ setHidden(settingsPopover, false);
+ fontFamilyManualEditing.clear();
+ syncSettingsSurfaceGeometry({ closeTransients: false });
+ syncReaderThemeSurfaces();
+ setExpanded(settingsBtn, true);
+ settingsRangeSaveReminderCategory = '';
+ showSettingsPanel('user');
+ });
+ onShell(settingsCloseBtn, 'click', (event) => {
+ event.preventDefault();
+ requestCloseSettings();
+ });
+ onShell(settingsPopover, 'click', (e) => e.stopPropagation());
+ bindFloatingSurfaceWheel(settingsPopover);
+ onShell(settingsPopover, 'pointerover', (event) => {
+ const target = event.target.closest('[data-setting-help]');
+ if (!target || (event.relatedTarget && target.contains(event.relatedTarget))) return;
+ showSettingHelp(target);
+ });
+ const scheduleSettingHelpHide = () => {
+ requestAnimationFrame(() => {
+ if (activeSettingHelpTarget
+ && !activeSettingHelpTarget.matches(':hover')
+ && !activeSettingHelpTarget.matches(':focus-within')) hideSettingHelp();
+ });
+ };
+ onShell(settingsPopover, 'pointerout', (event) => {
+ if (!activeSettingHelpTarget) return;
+ if (event.relatedTarget && activeSettingHelpTarget.contains(event.relatedTarget)) return;
+ scheduleSettingHelpHide();
+ });
+ onShell(settingsPopover, 'focusin', (event) => {
+ const target = event.target.closest('[data-setting-help]');
+ if (target) showSettingHelp(target);
+ });
+ onShell(settingsPopover, 'focusout', scheduleSettingHelpHide);
+ onShell(settingsPanel, 'scroll', () => {
+ hideSettingHelp();
+ closeFontFamilyMenu();
+ closeColorPickerPopover();
+ }, { passive: true });
+ onShell(settingsPopover, 'pointerdown', (event) => {
+ if (!fontFamilyMenu.hidden && !event.target.closest('.ldp-font-family-picker')) closeFontFamilyMenu();
+ const color = event.target.closest('input[type="color"]');
+ if (color && !color.disabled && event.button === 0) {
+ hideSettingHelp();
+ stopSettingsRangeDrag();
+ stopSettingsColorPick();
+ settingsColorPickRow = color.closest('.ldp-setting-row');
+ if (settingsColorPickRow) settingsColorPickRow.classList.add('ldp-color-pick-active');
+ settingsPopover.classList.add('ldp-color-picking');
+ return;
+ }
+ stopSettingsColorPick();
+ const range = event.target.closest('input[type="range"]');
+ if (!range || range.disabled || event.button !== 0) return;
+ hideSettingHelp();
+ stopSettingsRangeDrag();
+ settingsRangeDragRow = range.closest('.ldp-setting-row');
+ if (settingsRangeDragRow) settingsRangeDragRow.classList.add('ldp-range-drag-active');
+ settingsPopover.classList.add('ldp-range-dragging');
+ try { range.setPointerCapture(event.pointerId); } catch {}
+ });
+ onShell(settingsPopover, 'change', (event) => {
+ if (event.target.matches('input[type="color"]')) stopSettingsColorPick();
+ if (event.target.matches('input[type="range"]')) remindUnsavedRangeSetting(event.target);
+ });
+ onShell(settingsPopover, 'focusout', (event) => {
+ if (event.target.matches('input[type="color"]')) stopSettingsColorPick();
+ });
+ onShell(modal, 'click', closeReaderFloatingPanels);
+ const saveCurrentImageProfile = (values) => {
+ setPrefs({ imageProfile: values });
+ };
+ onShell(imageScaleRange, 'input', () => {
+ const next = normalizeImageScalePercent(imageScaleRange.value, currentImageScalePercent());
+ saveCurrentImageProfile({
+ ...currentImageProfile(),
+ preset: 'custom',
+ custom: next,
+ });
+ syncImageScaleControls();
+ applyImageScale();
+ });
+ onShell(imageScaleReset, 'click', () => {
+ saveCurrentImageProfile({ ...IMAGE_PROFILE_DEFAULT });
+ syncImageScaleControls();
+ applyImageScale();
+ });
+ fontScopeTabs.forEach((tab) => {
+ onShell(tab, 'click', () => {
+ const scope = tab.dataset.fontScopeTab;
+ if (!FONT_SCOPE_KEYS.includes(scope) || scope === fontSettingsScope) return;
+ closeFontFamilyMenu();
+ fontSettingsScope = scope;
+ syncFontControls();
+ });
+ });
+ fontFamilyTriggers.forEach((trigger) => {
+ onShell(trigger, 'click', (event) => {
+ consumeEvent(event);
+ hideSettingHelp();
+ const scope = trigger.dataset.fontScope;
+ if (!fontFamilyMenu.hidden && activeFontFamilyScope === scope) closeFontFamilyMenu(true);
+ else openFontFamilyMenu(scope);
+ });
+ onShell(trigger, 'keydown', (event) => {
+ if (event.key !== 'ArrowDown') return;
+ event.preventDefault();
+ openFontFamilyMenu(trigger.dataset.fontScope);
+ });
+ });
+ onShell(fontFamilySearch, 'input', applyFontFamilySearch);
+ onShell(fontFamilyOptions, 'click', (event) => {
+ const option = event.target.closest('.ldp-font-family-option');
+ if (!option) return;
+ const config = fontScopeConfig();
+ const values = { ...fontValuesForScope() };
+ if (option.dataset.fontKind === 'preset') {
+ values[config.family] = normalizeFontFamily(option.dataset.fontValue, values[config.family]);
+ values[config.customFamily] = '';
+ } else {
+ values[config.family] = 'custom';
+ values[config.customFamily] = normalizeCustomFontFamily(option.dataset.fontValue, values[config.customFamily]);
+ }
+ fontFamilyManualEditing.delete(activeFontFamilyScope);
+ if (activeFontFamilyScope === 'host') setPrefs({
+ hostFontFamily: values.hostFontFamily,
+ hostFontCustomFamily: values.hostFontCustomFamily,
+ });
+ else setCurrentFontDraft(values);
+ closeFontFamilyMenu(true);
+ syncFontControls();
+ if (activeFontFamilyScope !== 'host') applyFontScales();
+ });
+ onShell(fontFamilyManualButton, 'click', () => {
+ const scope = activeFontFamilyScope;
+ const config = fontScopeConfig(scope);
+ const values = { ...fontValuesForScope(scope), [config.family]: 'custom' };
+ fontFamilyManualEditing.add(scope);
+ if (scope === 'host') setPref(config.family, 'custom');
+ else setCurrentFontDraft(values);
+ closeFontFamilyMenu();
+ syncFontControls();
+ if (scope !== 'host') applyFontScales();
+ const input = [...fontFamilyCustomInputs].find((candidate) => candidate.dataset.fontScope === scope);
+ input.focus();
+ input.select();
+ });
+ onShell(fontFamilyMenu, 'keydown', (event) => {
+ handleSearchableMenuKeyDown(event, fontFamilySearch, fontFamilyOptions,
+ '.ldp-font-family-option:not([hidden])', closeFontFamilyMenu);
+ });
+ onShell(fontFamilyMenu, 'click', (event) => event.stopPropagation());
+ onShell(fontFamilyMenu, 'pointerdown', (event) => event.stopPropagation());
+ bindFloatingSurfaceWheel(fontFamilyMenu);
+ fontFamilyCustomInputs.forEach((input) => onShell(input, 'input', () => {
+ const scope = input.dataset.fontScope;
+ const config = fontScopeConfig(scope);
+ const values = { ...fontValuesForScope(scope) };
+ values[config.family] = 'custom';
+ values[config.customFamily] = normalizeCustomFontFamily(input.value, values[config.customFamily]);
+ if (scope === 'host') setPrefs({
+ hostFontFamily: values.hostFontFamily,
+ hostFontCustomFamily: values.hostFontCustomFamily,
+ });
+ else setCurrentFontDraft(values);
+ syncFontControls();
+ if (scope !== 'host') applyFontScales();
+ }));
+ [
+ [fontWeightSelects, 'change', 'weight', normalizeFontWeight],
+ [fontColorInputs, 'input', 'color', normalizeHexColor],
+ ].forEach(([inputs, eventName, configField, normalizeValue]) => inputs.forEach((input) => {
+ onShell(input, eventName, () => {
+ const scope = input.dataset.fontScope;
+ const config = fontScopeConfig(scope);
+ const values = { ...fontValuesForScope(scope) };
+ const field = config[configField];
+ values[field] = normalizeValue(input.value, values[field]);
+ if (scope === 'host') setPref(field, values[field]);
+ else setCurrentFontDraft(values);
+ syncFontControls();
+ if (scope !== 'host') applyFontScales();
+ });
+ }));
+ fontScaleSettings.forEach(({ input, key, normalize }) => onShell(input, 'input', () => {
+ const values = { ...currentFontDraft() };
+ values[key] = normalize(input.value, values[key]);
+ setCurrentFontDraft(values);
+ syncFontControls();
+ applyFontScales();
+ }));
+ hostEmbeddedSizeInputs.forEach((input) => onShell(input, 'input', () => {
+ const config = HOST_EMBED_SIZE_SETTINGS[input.dataset.hostEmbedSize];
+ if (!config) return;
+ setPref(config.pref, normalizeHostEmbeddedSizeScale(input.value));
+ syncFontControls();
+ }));
+ hostEmbeddedSizeResetButtons.forEach((button) => onShell(button, 'click', () => {
+ const config = HOST_EMBED_SIZE_SETTINGS[button.dataset.hostEmbedSizeReset];
+ if (!config) return;
+ setPref(config.pref, config.defaultScale);
+ syncFontControls();
+ }));
+ fontFieldResetButtons.forEach((button) => onShell(button, 'click', () => {
+ const host = button.dataset.hostFontReset != null;
+ const profileField = button.dataset.fontFieldReset;
+ const scope = host ? 'host' : FONT_SCOPE_KEYS.find((key) => Object.values(fontScopeConfig(key)).includes(profileField));
+ if (!scope) return;
+ const config = fontScopeConfig(scope);
+ const field = host ? config[button.dataset.hostFontReset] : profileField;
+ const defaults = host ? HOST_FONT_DEFAULTS : currentFontDefaults();
+ if (!Object.hasOwn(defaults, field)) return;
+ const patch = { [field]: defaults[field] };
+ if (field === config.family) {
+ patch[config.customFamily] = defaults[config.customFamily];
+ fontFamilyManualEditing.delete(scope);
+ }
+ if (host) setPrefs(patch);
+ else setCurrentFontDraft({ ...currentFontDraft(), ...patch });
+ syncFontControls();
+ if (!host) applyFontScales();
+ }));
+ onShell(fontResetBtn, 'click', () => {
+ closeFontFamilyMenu();
+ fontFamilyManualEditing.clear();
+ setPrefs(HOST_FONT_DEFAULTS);
+ setCurrentFontDraft({ ...currentFontDefaults() });
+ syncFontControls();
+ applyFontScales();
+ });
+ layoutRatioInputs.forEach((input) => {
+ onShell(input, 'input', () => {
+ const name = input.dataset.layoutRegion;
+ if (!LAYOUT_REGION_KEYS.includes(name)) return;
+ const ratios = { ...currentLayoutDraft() };
+ const desired = Number(input.value);
+ const maximum = 100 - LAYOUT_REGION_KEYS.reduce((sum, other) =>
+ sum + (other === name ? 0 : LAYOUT_MIN_RATIOS[other]), 0);
+ ratios[name] = roundLayoutRatio(Math.min(
+ maximum,
+ Math.max(LAYOUT_MIN_RATIOS[name], Number.isFinite(desired) ? desired : ratios[name])
+ ));
+ setCurrentLayoutDraft(rebalanceLayoutRatiosAfterChange(ratios, name));
+ applyLayoutRatios();
+ syncLayoutControls();
+ });
+ });
+ onShell(layoutResetBtn, 'click', () => {
+ setCurrentLayoutDraft({ ...currentLayoutDefaults() });
+ applyLayoutRatios();
+ syncLayoutControls();
+ });
+ const applyReaderWindowInputs = () => {
+ readerWindow.setGeometry(
+ readerWindowWidthInput.value,
+ readerWindowHeightInput.value,
+ readerWindowXInput.value,
+ readerWindowYInput.value
+ );
+ syncReaderWindowControls();
+ };
+ onShell(readerWindowWidthInput, 'change', applyReaderWindowInputs);
+ onShell(readerWindowHeightInput, 'change', applyReaderWindowInputs);
+ onShell(readerWindowXInput, 'change', applyReaderWindowInputs);
+ onShell(readerWindowYInput, 'change', applyReaderWindowInputs);
+ onShell(readerWindowResetBtn, 'click', () => {
+ readerWindow.reset();
+ syncReaderWindowControls();
+ });
+ const setAppearanceDraftValue = (key, value) => {
+ setCurrentAppearanceDraft({ ...currentAppearanceDraft(), [key]: value });
+ };
+ const setAppearanceDraftValues = (values) => {
+ setCurrentAppearanceDraft({ ...currentAppearanceDraft(), ...values });
+ };
+ appearanceGroupToggleInputs.forEach((input) => onShell(input, 'change', () => {
+ const key = input.dataset.appearanceGroupToggle;
+ if (!APPEARANCE_PROFILE_SETTING_KEYS.includes(key)) return;
+ setAppearanceDraftValue(key, input.checked);
+ applyAppearanceColors();
+ syncAppearanceControls();
+ }));
+ appearanceControlSettings.forEach(({ input, key, normalize: normalizeValue, apply: applyPreview }) => {
+ onShell(input, 'input', () => {
+ if (input.type === 'color') {
+ const sourceColor = normalizeHexColor(input.value, currentAppearanceDefaults()[key]);
+ setAppearanceDraftValues({
+ [key]: sourceColor,
+ [appearanceDarkColorKey(key)]: sourceColor,
+ });
+ } else {
+ setAppearanceDraftValue(key, normalizeValue(input.value));
+ }
+ applyPreview();
+ syncAppearanceControls();
+ });
+ });
+ colorResetButtons.forEach((button) => onShell(button, 'click', () => {
+ const key = button.dataset.colorReset;
+ const defaults = currentAppearanceDefaults();
+ const valueKeys = appearanceSettingValueFields(key);
+ if (valueKeys.some((valueKey) => defaults[valueKey] == null)) return;
+ setAppearanceDraftValues(Object.fromEntries(valueKeys.map((valueKey) => [valueKey, defaults[valueKey]])));
+ applyAppearanceColors();
+ syncAppearanceControls();
+ }));
+ readerShell.cancelShellFrames = () => {
+ if (colorPickerCommitFrame) cancelAnimationFrame(colorPickerCommitFrame);
+ colorPickerCommitFrame = 0;
+ pendingColorPickerCommit = null;
+ if (settingsWindowDragFrame) cancelAnimationFrame(settingsWindowDragFrame);
+ settingsWindowDragFrame = 0;
+ settingsWindowDrag = null;
+ Object.values(collectionSearchCancels).forEach((cancel) => cancel());
+ };
+ }
+
+ const contextScope = createLifecycleScope();
+ const onContext = (target, type, listener, options) => contextScope.listen(target, type, listener, options);
+ contextScope.add(() => {
+ if (titleTopicHintFrame) cancelAnimationFrame(titleTopicHintFrame);
+ titleTopicHintFrame = 0;
+ });
+ onContext(titleTopicScroller, 'scroll', queueTitleTopicScrollHints, { passive: true });
+ if (typeof ResizeObserver === 'function') {
+ const titleTopicResizeObserver = new ResizeObserver(queueTitleTopicScrollHints);
+ titleTopicResizeObserver.observe(titleTopicRow);
+ titleTopicResizeObserver.observe(titleTopicScroller);
+ titleTopicResizeObserver.observe(readerElement('.ldp-topic-tags'));
+ contextScope.add(() => titleTopicResizeObserver.disconnect());
+ }
+ queueTitleTopicScrollHints();
+ const onTitleTopicWheel = (event) => {
+ const rawDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
+ if (!rawDelta) return;
+ event.preventDefault();
+ event.stopPropagation();
+ const maxScrollLeft = titleTopicScroller.scrollWidth - titleTopicScroller.clientWidth;
+ if (maxScrollLeft <= 1) return;
+ const delta = event.deltaMode === 1
+ ? rawDelta * 40
+ : event.deltaMode === 2 ? rawDelta * titleTopicScroller.clientWidth : rawDelta;
+ const nextScrollLeft = Math.min(maxScrollLeft, Math.max(0, titleTopicScroller.scrollLeft + delta));
+ titleTopicScroller.scrollLeft = nextScrollLeft;
+ };
+ onContext(titleTopicRow, 'wheel', onTitleTopicWheel, { passive: false });
+ let ctx = null;
+ const tracker = createReadTracker(
+ topicId,
+ body,
+ (postNumbers) => markReaderPostsRead(postNumbers, ctx),
+ (postNumbers) => markReaderPostsRead(postNumbers, ctx, { optimistic: true }),
+ );
+ ctx = {
+ topicId, op: null, topicData: null, loader, modal, commentsEl, commentsHeader, countEl, emptyEl, scrollRoot: body, performance,
+ readerSession,
+ isPostVoting: !!(cachedTopicPreview?.is_post_voting),
+ directRepliesByParent: new Map(), replyParentByPost: new Map(), tracker, totalPosts: 0, repliesIO: null, postRequestScheduler,
+ subReplyState: new Map(), // 楼中楼原始数据 + 已渲染数量的状态表
+ confirmedReadPostNumbers: new Set(), optimisticReadPostNumbers: new Set(),
+ streamNodeMap: new Map(),
+ streamItems: [], streamItemMap: new Map(), streamPrefix: [0],
+ onlyOp: false, streamLayoutItems: [], streamLayoutDirty: true,
+ streamPrefixDirty: true, streamPrefixDirtyFrom: 0,
+ streamNeedsRepair: true, streamPendingReindexFrom: null,
+ streamWindowStart: 0, streamWindowEnd: 0, streamMountedItemCount: 0,
+ streamViewportTopIndex: 0, streamViewportBottomIndex: 0,
+ viewportPersistenceEnabled: false,
+ streamScrollDirection: 0, streamScrollVelocity: 0, streamLastScrollAt: 0,
+ streamProgrammaticTarget: null, streamViewportAnchor: null,
+ streamViewportAnchorRestoreDepth: 0, streamViewportAnchorRestorePending: false,
+ streamWindowResetPending: false, streamInternalScrollTop: NaN, streamInternalScrollDelta: 0,
+ readerNavigationSequence: 0,
+ streamListOffset: NaN,
+ streamMountedNodes: new Set(), streamHydratedNodes: new Set(),
+ pendingPostHydrations: new Set(), pendingPostLatexRenders: new Set(),
+ streamTopSpacer: createStreamSpacer('top'), streamBottomSpacer: createStreamSpacer('bottom'),
+ streamResizeObserver: null, queueViewportTracker: null, streamWindowFrame: 0, floorPreviewSyncFrame: 0,
+ latestReplyAt: '', latestReplyTimestamp: 0,
+ topicBookmarked: !!(cachedTopicPreview?.bookmarked),
+ topicNotificationLevel: topicNotificationLevel(cachedTopicPreview),
+ topicSharedIssueBusy: false,
+ topicSharedIssueHostVisible: false,
+ initialTargetPositionPending: targetPostNumber > 1,
+ initialTargetHydrationBoundary: targetPostNumber > 1 ? targetPostNumber : 0,
+ initialTargetPrecedingContentDeferred: targetPostNumber > 1,
+ initialTargetHeadSpacerHeight: 0,
+ cancelInitialTargetPosition: null, releaseInitialPrecedingContent: null, readerActionDialog: null,
+ nativeComposerSession: null, toggleTopicBookmark: null, readerWindow, readerWorkspace,
+ };
+ ctx.queueViewportTracker = queueEntry
+ ? createReaderQueueViewportTracker(ctx, queueEntry)
+ : { observe() {}, unobserve() {}, sync() {}, stop() {} };
+ readerShell.activeContext = ctx;
+ guardNativeReaderTimings();
+ const topicActionRail = ctx.topicActionRail = readerElement('.ldp-topic-action-rail'); topicActionRail.hidden = true; topicActionRail.classList.remove('is-dragging'); topicActionRail._ldpRelatedPost = null; overlay.classList.remove('ldp-topic-action-rail-visible');
+ let topicRailHoldTimer = 0, topicRailDrag = null, topicRailSuppressClickUntil = 0;
+ const positionTopicActionRail = () => {
+ if (!topicActionRail || topicActionRail.hidden) return;
+ const position = PREFS.topicActionRailPosition || {};
+ const railWidth = topicActionRail.offsetWidth;
+ const maxLeft = Math.max(0, modal.clientWidth - railWidth);
+ const maxTop = Math.max(0, modal.clientHeight - topicActionRail.offsetHeight);
+ const readerMain = readerElement('.ldp-reader-main');
+ const readerMainRect = readerMain?.getBoundingClientRect();
+ const leftTrack = Number.parseFloat(getComputedStyle(readerMain).gridTemplateColumns) || railWidth;
+ const railParent = topicActionRail.offsetParent || modal;
+ const railParentRect = railParent.getBoundingClientRect();
+ const railOriginLeft = railParentRect.left + (railParent.clientLeft || 0);
+ const defaultLeft = readerMainRect
+ ? readerMainRect.left + Math.max(leftTrack, railWidth) / 2 - railOriginLeft - railWidth / 2
+ : (Math.max(leftTrack, railWidth) - railWidth) / 2;
+ const customizedX = Number.isFinite(position.x);
+ const targetLeft = customizedX
+ ? maxLeft * Math.max(0, Math.min(1, position.x))
+ : Math.max(0, Math.min(maxLeft, defaultLeft));
+ const toggle = topicActionRail.querySelector('.ldp-topic-action-rail-toggle');
+ const toggleMaxTop = Math.max(0, modal.clientHeight - (toggle?.offsetHeight || 0));
+ topicActionRail.style.left = `${Math.round(targetLeft)}px`;
+ topicActionRail.style.top = `${Math.round(Math.max(0, Math.min(maxTop, toggleMaxTop * Math.max(0, Math.min(1, Number(position.y) || 0)) - (toggle?.offsetTop || 0))))}px`;
+ const blockers = [overlay.querySelector('.ldp-reader-queue:not([hidden])'), ...overlay.querySelectorAll('.ldp-reader-history-nav:not([hidden])')];
+ const modalRect = modal.getBoundingClientRect();
+ blockers.filter((blocker) => blocker && (!topicActionRail.classList.contains('is-expanded') || !blocker.matches('.ldp-reader-history-nav'))).forEach((blocker) => {
+ const railRect = topicActionRail.getBoundingClientRect(), blockerRect = blocker.getBoundingClientRect();
+ if (railRect.left < blockerRect.right + 8 && railRect.right + 8 > blockerRect.left && railRect.top < blockerRect.bottom + 8 && railRect.bottom + 8 > blockerRect.top) topicActionRail.style.left = `${Math.round(blockerRect.left > modalRect.left + modalRect.width / 2 ? Math.max(0, blockerRect.left - railOriginLeft - railWidth - 8) : Math.min(maxLeft, blockerRect.right - railOriginLeft + 8))}px`;
+ });
+ const railLeft = Number.parseFloat(topicActionRail.style.left) || 0, dockThreshold = modal.clientWidth * READER_QUEUE_DOCK_THRESHOLD_RATIO; topicActionRail.classList.toggle('is-docked-left', customizedX && railLeft <= dockThreshold); topicActionRail.classList.toggle('is-docked-right', customizedX && maxLeft - railLeft <= dockThreshold);
+ readerShell.syncFullPageHeaderAlignment?.();
+ overlay._ldpSyncReaderQueuePosition?.();
+ };
+ ctx.syncTopicActionRail = (postNode = topicActionRail?._ldpRelatedPost) => {
+ if (!postNode) {
+ const starter = postsFromPayload(ctx.topicData).concat(loader.cachedPosts).find((post) => +post?.post_number === 1);
+ if (starter) { renderPost(starter, false, ctx, { deferContent: true }); return; }
+ if (PREFS.topicActionRailVisible && !topicActionRail._ldpStarterRequest) topicActionRail._ldpStarterRequest = loader.loadPost(1, { advanceCursor: false, priority: POST_REQUEST_PRIORITY.auxiliary }).then((posts) => { const post = posts.find((item) => +item?.post_number === 1); if (post && sessionAcceptsWork()) renderPost(post, false, ctx, { deferContent: true }); }).catch(() => {}).finally(() => { topicActionRail._ldpStarterRequest = null; });
+ return;
+ }
+ const post = postNode?._ldpPostData; if (!topicActionRail || !post) return;
+ const like = readerPostRailReactionAction(post, ctx), expanded = topicActionRail.classList.contains('is-expanded');
+ topicActionRail._ldpRelatedPost = postNode; topicActionRail._ldpPostData = post; topicActionRail.hidden = PREFS.topicActionRailVisible !== true; overlay.classList.toggle('ldp-topic-action-rail-visible', !topicActionRail.hidden);
+ topicActionRail.innerHTML = `
${canShowBoostButton(post) ? `` : ''}
${postContextActionsHtml(post, ctx)}${topicFooterActionsHtml(post, ctx)}
`;
+ renderPostReactions(topicActionRail, ctx, { collectLike: false, railPicker: true }); topicActionRail.querySelector(':scope > .ldp-reactions > .ldp-reaction-summary')?.remove(); syncPostActionControls(topicActionRail, ctx); syncTopicActionControls(ctx); requestAnimationFrame(() => { positionTopicActionRail(); requestAnimationFrame(positionTopicActionRail); });
+ };
+ onContext(topicActionRail, 'click', (event) => { if (Date.now() < topicRailSuppressClickUntil) return consumeEvent(event); if (event.target.closest('.ldp-topic-action-rail-top')) ctx.timeline.jumpToStart(); const toggle = event.target.closest('.ldp-topic-action-rail-toggle'); if (!toggle) return; const expanded = topicActionRail.classList.toggle('is-expanded'); topicActionRail.querySelector('.ldp-topic-action-more').hidden = !expanded; setExpanded(toggle, expanded); setLabel(toggle, `${expanded ? '收纳' : '展开'}其他主题操作;${PREFS.topicActionRailFixed ? '位置已固定' : '长按拖动'}`); toggle.innerHTML = icon(expanded ? 'chevronDown' : 'layers'); requestAnimationFrame(positionTopicActionRail); });
+ onContext(topicActionRail, 'pointerdown', (event) => { if (event.button || PREFS.topicActionRailFixed || !event.target.closest('.ldp-topic-action-rail-toggle')) return; clearTimeout(topicRailHoldTimer); topicRailHoldTimer = contextScope.timer(() => { topicRailDrag = { id: event.pointerId, x: event.clientX, y: event.clientY, left: Number.parseFloat(topicActionRail.style.left) || 0, top: Number.parseFloat(topicActionRail.style.top) || 0 }; topicActionRail.classList.add('is-dragging'); }, 420); });
+ onContext(document, 'pointermove', (event) => { if (!topicRailDrag || event.pointerId !== topicRailDrag.id) return; const maxLeft = Math.max(0, modal.clientWidth - topicActionRail.offsetWidth), maxTop = Math.max(0, modal.clientHeight - topicActionRail.offsetHeight); topicActionRail.style.left = `${Math.round(Math.max(0, Math.min(maxLeft, topicRailDrag.left + event.clientX - topicRailDrag.x)))}px`; topicActionRail.style.top = `${Math.round(Math.max(0, Math.min(maxTop, topicRailDrag.top + event.clientY - topicRailDrag.y)))}px`; event.preventDefault(); }, true);
+ const finishTopicRailDrag = (event) => { clearTimeout(topicRailHoldTimer); if (!topicRailDrag || event.pointerId !== topicRailDrag.id) return; const maxLeft = Math.max(1, modal.clientWidth - topicActionRail.offsetWidth), toggle = topicActionRail.querySelector('.ldp-topic-action-rail-toggle'), toggleMaxTop = Math.max(1, modal.clientHeight - (toggle?.offsetHeight || 0)); setPref('topicActionRailPosition', { x: (Number.parseFloat(topicActionRail.style.left) || 0) / maxLeft, y: ((Number.parseFloat(topicActionRail.style.top) || 0) + (toggle?.offsetTop || 0)) / toggleMaxTop }); topicRailDrag = null; topicActionRail.classList.remove('is-dragging'); topicRailSuppressClickUntil = Date.now() + 300; positionTopicActionRail(); };
+ overlay._ldpSyncTopicActionRailPosition = positionTopicActionRail;
+ onContext(document, 'pointerup', finishTopicRailDrag, true); onContext(document, 'pointercancel', finishTopicRailDrag, true); onContext(window, 'resize', positionTopicActionRail);
+ ctx.translation = createReaderTranslationController(ctx, translateBtn);
+ if (!isFunction(readerShell.refreshBookmarksPanel)) {
+ readerShell.refreshBookmarksPanel = (confirmedReaction = null) => {
+ clearBookmarksSelectionScope();
+ const hadCompleteReactionSnapshot = bookmarksState.reactionLoadedAt > 0;
+ const confirmedLike = confirmedReaction && typeof confirmedReaction.likeActed === 'boolean';
+ const confirmedCustomReaction = confirmedReaction && !confirmedLike &&
+ rememberConfirmedGivenReaction(confirmedReaction.post, confirmedReaction.topic);
+ if (confirmedLike || confirmedCustomReaction) {
+ if (confirmedCustomReaction)
+ bookmarksState.reactionEntries = mergeGivenReactionOverrides(bookmarksState.reactionEntries);
+ if (hadCompleteReactionSnapshot) {
+ if (confirmedLike)
+ bookmarksState.reactionEntries = applyConfirmedGivenLike(
+ bookmarksState.reactionEntries,
+ confirmedReaction.post,
+ confirmedReaction.topic,
+ confirmedReaction.likeActed
+ );
+ bookmarksState.reactionLoadedAt = Date.now();
+ setGivenReactionSnapshot(bookmarksState.reactionUsername, bookmarksState.reactionEntries);
+ }
+ if (!bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction') {
+ if (hadCompleteReactionSnapshot) {
+ bookmarksState.page = 0;
+ renderBookmarksPage();
+ } else loadBookmarksPage();
+ }
+ return;
+ }
+ if (!bookmarksPanel.popover.hidden) loadBookmarksPage();
+ };
+ }
+ ctx.refreshBookmarksPanel = (options) => readerShell.refreshBookmarksPanel(options);
+ ctx.nativeComposerWindow = createNativeComposerWindowController(readerSession);
+ ctx.renderTopicShell = renderTopicShell;
+ ctx.repliesIO = createRepliesIO(ctx);
+ ctx.streamResizeObserver = createStreamResizeObserver(ctx);
+ const timeline = ctx.timeline = bindTopicTimeline(timelineEl, ctx);
+ const loadTopicEditorOptions = () => {
+ const site = lookupDiscourse('service:site');
+ const categories = [...discourseModelValue(site, 'categories') || []]
+ .filter((category) => category && +discourseModelValue(category, 'id') > 0 &&
+ discourseModelValue(category, 'name'))
+ .map((category) => ({
+ id: +discourseModelValue(category, 'id'),
+ name: String(discourseModelValue(category, 'name') || ''),
+ slug: String(discourseModelValue(category, 'slug') || ''),
+ color: String(discourseModelValue(category, 'color') || ''),
+ parent_category_id: +discourseModelValue(category, 'parent_category_id') || null,
+ }));
+ if (!categories.length) return Promise.reject(new Error('原站分类数据尚未就绪,请稍后重试'));
+ return Promise.resolve({ categories });
+ };
+ const openTopicEditor = () => {
+ const topic = ctx.topicData;
+ if (!topic || !topic.details || topic.details.can_edit !== true) return;
+ if (topicEditorState) topicEditorState.close(true);
+ const topicEditTrigger = readerElement('.ldp-topic-edit-trigger');
+ const layer = readerElement('.ldp-topic-edit-layer');
+ const form = readerElement('.ldp-topic-edit-form');
+ const titleInput = form.querySelector('input[name="title"]');
+ const categoryTrigger = form.querySelector('.ldp-topic-edit-category-trigger');
+ const categoryValue = form.querySelector('.ldp-topic-edit-category-value');
+ const categoryMenu = form.querySelector('.ldp-topic-edit-category-menu');
+ const categorySearch = form.querySelector('.ldp-topic-edit-category-search input');
+ const categoryOptions = form.querySelector('.ldp-topic-edit-category-options');
+ const labelInput = form.querySelector('.ldp-topic-edit-label-input');
+ const labelValues = form.querySelector('.ldp-topic-edit-label-values');
+ const labelOptions = form.querySelector('.ldp-topic-edit-label-options');
+ const saveButton = form.querySelector('.ldp-topic-edit-save');
+ const cancelButton = form.querySelector('.ldp-topic-edit-cancel');
+ const closeButton = layer.querySelector('.ldp-topic-edit-close');
+ const status = form.querySelector('.ldp-topic-edit-status');
+ const originalTitle = String(topic.title || '').trim();
+ const originalCategoryId = Number(topic.category_id || topic.categoryId) || 0;
+ const originalLabelNames = [...new Set(
+ (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).map((name) => name.trim()).filter(Boolean),
+ )];
+ const labelsByKey = new Map();
+ const selectedLabelKeys = new Set();
+ const categoriesById = new Map();
+ let selectedCategoryId = originalCategoryId;
+ let optionsLoaded = false;
+ let busy = false;
+ let closed = false;
+ let labelSearchSequence = 0;
+ let labelSearchDebounce = null;
+ let pendingLabelSearch = { query: '', sequence: 0 };
+ const editorScope = createLifecycleScope();
+
+ const labelKey = (name) => String(name || '').trim().toLocaleLowerCase();
+ originalLabelNames.forEach((name) => {
+ const key = labelKey(name);
+ labelsByKey.set(key, { id: null, name });
+ selectedLabelKeys.add(key);
+ });
+ const selectedLabels = () => [...selectedLabelKeys]
+ .map((key) => labelsByKey.get(key)).filter(Boolean);
+ const renderSelectedLabels = () => {
+ labelValues.innerHTML = selectedLabels().map((label) => `
+
+ ${esc(label.name)}
+
+ `).join('');
+ };
+ const renderLabelOptions = (labels) => {
+ labelOptions.innerHTML = (labels || [])
+ .map((label) => `
`).join('');
+ };
+ const performLabelSearch = async (query, sequence) => {
+ const tagUtils = lookupDiscourse('service:tag-utils');
+ const siteSettings = lookupDiscourse('service:site-settings');
+ if (!tagUtils || !isFunction(tagUtils.searchTags) || !siteSettings)
+ throw new Error('Discourse 标签搜索服务尚未就绪');
+ const selected = selectedLabels();
+ const data = {
+ q: query,
+ limit: Math.max(1, Number(discourseModelValue(siteSettings, 'max_tag_search_results')) || 20),
+ categoryId: selectedCategoryId || undefined,
+ filterForInput: true,
+ };
+ const selectedIds = selected.map((label) => Number(label.id)).filter((id) => id > 0);
+ const selectedNames = selected.filter((label) => !(Number(label.id) > 0)).map((label) => label.name);
+ if (selectedIds.length) data.selected_tag_ids = selectedIds.slice(0, 100);
+ if (selectedNames.length) data.selected_tags = selectedNames.slice(0, 100);
+ const results = await tagUtils.searchTags('/tags/filter/search', data, (json) => {
+ const incoming = Array.isArray(json?.results) ? json.results : [];
+ return isFunction(tagUtils.sortSearchResults)
+ ? tagUtils.sortSearchResults(incoming)
+ : incoming;
+ });
+ if (closed || sequence !== labelSearchSequence) return;
+ const labels = (Array.isArray(results) ? results : []).map((label) => ({
+ id: Number(label?.id) || null,
+ name: String(label && (label.name || label.text) || '').trim(),
+ })).filter((label) => label.name);
+ labels.forEach((label) => labelsByKey.set(labelKey(label.name), label));
+ renderLabelOptions(labels);
+ };
+ const runScheduledLabelSearch = () => {
+ const { query, sequence } = pendingLabelSearch;
+ void performLabelSearch(query, sequence).catch((error) => {
+ if (!closed && sequence === labelSearchSequence)
+ status.textContent = error?.message ? error.message : '标签搜索失败,请重试';
+ });
+ };
+ const scheduleLabelSearch = () => {
+ if (closed || !optionsLoaded) return;
+ const query = String(labelInput.value || '').trim();
+ const sequence = ++labelSearchSequence;
+ pendingLabelSearch = { query, sequence };
+ const runloopModule = lookupDiscourseModule('@ember/runloop');
+ const discourseDebounce = runloopModule?.debounce;
+ if (!isFunction(discourseDebounce)) {
+ status.textContent = 'Discourse 标签搜索服务尚未就绪';
+ return;
+ }
+ labelSearchDebounce = discourseDebounce(labelInput, runScheduledLabelSearch, 300);
+ };
+ const syncCategorySelection = () => {
+ const selected = categoriesById.get(selectedCategoryId);
+ const parent = selected && categoriesById.get(Number(selected.parent_category_id));
+ categoryValue.textContent = !selected
+ ? '请选择类别'
+ : parent ? `${parent.name} / ${selected.name}` : String(selected.name || '请选择类别');
+ categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => {
+ option.setAttribute('aria-selected', +option.dataset.categoryId === selectedCategoryId ? 'true' : 'false');
+ });
+ };
+ const closeCategoryMenu = (restoreFocus = false) => {
+ categoryMenu.hidden = true;
+ setExpanded(categoryTrigger, false);
+ if (restoreFocus && !categoryTrigger.disabled) categoryTrigger.focus();
+ };
+ const openCategoryMenu = () => {
+ if (busy || !optionsLoaded || categoryTrigger.disabled) return;
+ categoryMenu.hidden = false;
+ setExpanded(categoryTrigger, true);
+ categorySearch.value = '';
+ categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => {
+ option.hidden = false;
+ });
+ categorySearch.focus();
+ categoryOptions.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });
+ };
+ const renderCategoryOptions = () => {
+ const fragment = document.createDocumentFragment();
+ categoriesById.forEach((category) => {
+ const parent = categoriesById.get(Number(category.parent_category_id));
+ const option = makeElement('button');
+ const color = String(category.color || '').replace(/^#/, '');
+ option.className = 'ldp-topic-edit-category-option ldp-picker-option';
+ option.type = 'button';
+ option.dataset.categoryId = String(category.id);
+ option.dataset.searchText = `${parent ? `${parent.name} ` : ''}${category.name}`.toLocaleLowerCase();
+ option.setAttribute('role', 'option');
+ option.setAttribute('aria-selected', +category.id === selectedCategoryId ? 'true' : 'false');
+ option.innerHTML = `
+
+
+ ${esc(String(category.name || '未命名类别'))}
+ ${parent ? `${esc(String(parent.name))}` : '一级类别'}
+ `;
+ if (/^[0-9a-f]{6}$/i.test(color))
+ option.querySelector('.ldp-topic-edit-category-dot').style.setProperty('--ldp-category-color', `#${color}`);
+ fragment.appendChild(option);
+ });
+ categoryOptions.replaceChildren(fragment);
+ syncCategorySelection();
+ };
+ const setBusy = (nextBusy) => {
+ busy = nextBusy;
+ titleInput.disabled = nextBusy;
+ categoryTrigger.disabled = nextBusy || !optionsLoaded;
+ categorySearch.disabled = nextBusy || !optionsLoaded;
+ labelInput.disabled = nextBusy || !optionsLoaded;
+ saveButton.disabled = nextBusy || !optionsLoaded;
+ cancelButton.disabled = nextBusy;
+ closeButton.disabled = nextBusy;
+ if (nextBusy) closeCategoryMenu();
+ };
+ const commitLabelInput = (showError = true) => {
+ const raw = String(labelInput.value || '').replace(/[,,]\s*$/, '').trim();
+ if (!raw) {
+ labelInput.value = '';
+ return true;
+ }
+ const key = labelKey(raw);
+ const label = labelsByKey.get(key);
+ if (!label) {
+ if (showError) status.textContent = `没有找到 label“${raw}”`;
+ return false;
+ }
+ selectedLabelKeys.add(key);
+ labelInput.value = '';
+ status.textContent = '';
+ renderSelectedLabels();
+ scheduleLabelSearch();
+ return true;
+ };
+ const onLabelKeyDown = (event) => {
+ if (event.key !== 'Enter' && event.key !== ',' && event.key !== ',') return;
+ event.preventDefault();
+ commitLabelInput();
+ };
+ const onLabelInput = () => scheduleLabelSearch();
+ const onLabelChange = () => commitLabelInput(false);
+ const onLabelRemove = (event) => {
+ const button = event.target.closest('.ldp-topic-edit-label-remove');
+ if (!button || busy) return;
+ selectedLabelKeys.delete(button.dataset.labelKey || '');
+ renderSelectedLabels();
+ scheduleLabelSearch();
+ labelInput.focus();
+ };
+ const onCategoryToggle = () => {
+ if (categoryMenu.hidden) openCategoryMenu();
+ else closeCategoryMenu(true);
+ };
+ const onCategoryTriggerKeyDown = (event) => {
+ if (event.key !== 'ArrowDown') return;
+ event.preventDefault();
+ openCategoryMenu();
+ };
+ const onCategoryOptionClick = (event) => {
+ const option = event.target.closest('.ldp-topic-edit-category-option');
+ if (!option || busy) return;
+ selectedCategoryId = Number(option.dataset.categoryId) || 0;
+ syncCategorySelection();
+ closeCategoryMenu(true);
+ scheduleLabelSearch();
+ };
+ const onCategorySearchInput = () => {
+ const query = String(categorySearch.value || '').trim().toLocaleLowerCase();
+ categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => {
+ option.hidden = !!query && !String(option.dataset.searchText || '').includes(query);
+ });
+ };
+ const onCategoryMenuKeyDown = (event) => {
+ handleSearchableMenuKeyDown(event, categorySearch, categoryOptions,
+ '.ldp-topic-edit-category-option:not([hidden])', closeCategoryMenu);
+ };
+ const onLayerClick = (event) => {
+ if (event.target === layer) {
+ close();
+ return;
+ }
+ if (!event.target.closest('.ldp-topic-edit-category')) closeCategoryMenu();
+ };
+ const close = (force = false) => {
+ if (closed || (busy && !force)) return;
+ closed = true;
+ labelSearchSequence++;
+ const runloopModule = lookupDiscourseModule('@ember/runloop');
+ const cancelRunloop = runloopModule?.cancel;
+ if (labelSearchDebounce && isFunction(cancelRunloop)) cancelRunloop(labelSearchDebounce);
+ labelSearchDebounce = null;
+ closeCategoryMenu();
+ layer.hidden = true;
+ setExpanded(topicEditTrigger, false);
+ editorScope.destroy();
+ if (topicEditorState === state) topicEditorState = null;
+ if (!force && !topicEditTrigger.hidden) topicEditTrigger.focus();
+ };
+ const onCancel = () => close();
+ const onSubmit = async (event) => {
+ event.preventDefault();
+ if (busy || !optionsLoaded || !commitLabelInput()) return;
+ const nextTitle = String(titleInput.value || '').trim();
+ const nextCategoryId = selectedCategoryId;
+ if (!nextTitle) {
+ status.textContent = '标题不能为空';
+ titleInput.focus();
+ return;
+ }
+ const category = categoriesById.get(nextCategoryId);
+ if (!category) {
+ status.textContent = '请选择类别';
+ categoryTrigger.focus();
+ return;
+ }
+ if (!ctx.topicData || !ctx.topicData.details || ctx.topicData.details.can_edit !== true) {
+ status.textContent = '当前账号没有编辑该帖子的权限';
+ return;
+ }
+ setBusy(true);
+ status.textContent = '正在保存…';
+ const nextLabels = selectedLabels();
+ const updateProps = {
+ title: nextTitle,
+ category_id: nextCategoryId,
+ tags: nextLabels.map((label) => Object.assign(
+ { name: label.name },
+ +label.id > 0 ? { id: +label.id } : {},
+ )),
+ };
+ try {
+ const topicModule = lookupDiscourseModule('discourse/models/topic');
+ const Topic = topicModule?.default;
+ if (!Topic || !isFunction(Topic.create) || !isFunction(Topic.update))
+ throw new Error('Discourse 主题编辑接口尚未就绪');
+ const topicModel = Topic.create({
+ ...ctx.topicData,
+ id: Number(topicId),
+ slug: String(ctx.topicData.slug || ''),
+ });
+ await Topic.update(topicModel, updateProps, { fastEdit: true });
+ await clearStoredResponseCache({ tags: [`topic:${topicId}`] });
+ let freshTopic = null;
+ try {
+ freshTopic = await loader.refreshTopicData({ priority: POST_REQUEST_PRIORITY.visible });
+ } catch {}
+ const updatedTopic = Object.assign({}, ctx.topicData || {}, {
+ title: discourseModelValue(topicModel, 'title'),
+ fancy_title: discourseModelValue(topicModel, 'fancy_title'),
+ tags: discourseModelValue(topicModel, 'tags'),
+ }, freshTopic || {});
+ updatedTopic.title = String(updatedTopic.title || nextTitle);
+ updatedTopic.fancy_title = String(
+ (freshTopic?.fancy_title) || updatedTopic.fancy_title || updatedTopic.title,
+ );
+ updatedTopic.category_id = nextCategoryId;
+ updatedTopic.category_name = cleanTopicCategoryName(category.name);
+ updatedTopic.category_level = topicCategoryLevel(category.name);
+ updatedTopic.category_slug = String(category.slug || '');
+ updatedTopic.tags = nextLabels.map((label) => label.name);
+ topicNavMetadataOverride = {
+ categoryName: updatedTopic.category_name,
+ categoryLevel: updatedTopic.category_level,
+ categorySlug: updatedTopic.category_slug,
+ categoryId: nextCategoryId,
+ categoryIconHtml: Number(sourceTopicNavMetadata.categoryId) === nextCategoryId
+ ? sourceTopicNavMetadata.categoryIconHtml : '',
+ tags: updatedTopic.tags.slice(),
+ tagIcons: (Array.isArray(sourceTopicNavMetadata.tagIcons) ? sourceTopicNavMetadata.tagIcons : [])
+ .filter((item) => updatedTopic.tags.includes(topicNavText(item?.name))),
+ };
+ sourceTopicNavMetadata = mergeTopicNavMetadata(topicNavMetadataOverride, cachedTopicNavMetadata(updatedTopic));
+ ctx.topicData = updatedTopic;
+ setCachedTopicData(topicId, updatedTopic);
+ rememberTopic(updatedTopic, 0, readerQueueEntry(topicId)?.seenPostNumbers);
+ busy = false;
+ close(true);
+ renderTopicShell(updatedTopic);
+ syncTopicActionControls(ctx);
+ showSelectionToast('帖子信息已更新');
+ } catch (error) {
+ status.textContent = error?.message ? error.message : '保存失败,请重试';
+ setBusy(false);
+ }
+ };
+ const state = {
+ close,
+ closeTransient: () => {
+ if (categoryMenu.hidden) return false;
+ closeCategoryMenu(true);
+ return true;
+ },
+ };
+ topicEditorState = state;
+ titleInput.value = originalTitle;
+ categoriesById.set(originalCategoryId, {
+ id: originalCategoryId,
+ name: cleanTopicCategoryName(topic.category_name || sourceTopicNavMetadata.categoryName || '当前类别'),
+ slug: String(topic.category_slug || sourceTopicNavMetadata.categorySlug || ''),
+ });
+ categoryOptions.replaceChildren();
+ syncCategorySelection();
+ labelOptions.innerHTML = '';
+ labelInput.value = '';
+ status.textContent = '正在加载类别和 label…';
+ renderSelectedLabels();
+ setBusy(false);
+ layer.hidden = false;
+ setExpanded(topicEditTrigger, true);
+ editorScope.listen(labelInput, 'keydown', onLabelKeyDown);
+ editorScope.listen(labelInput, 'input', onLabelInput);
+ editorScope.listen(labelInput, 'change', onLabelChange);
+ editorScope.listen(labelValues, 'click', onLabelRemove);
+ editorScope.listen(categoryTrigger, 'click', onCategoryToggle);
+ editorScope.listen(categoryTrigger, 'keydown', onCategoryTriggerKeyDown);
+ editorScope.listen(categoryOptions, 'click', onCategoryOptionClick);
+ editorScope.listen(categorySearch, 'input', onCategorySearchInput);
+ editorScope.listen(categoryMenu, 'keydown', onCategoryMenuKeyDown);
+ editorScope.listen(layer, 'click', onLayerClick);
+ editorScope.listen(form, 'submit', onSubmit);
+ editorScope.listen(cancelButton, 'click', onCancel);
+ editorScope.listen(closeButton, 'click', onCancel);
+ titleInput.focus();
+ titleInput.select();
+ void loadTopicEditorOptions().then(({ categories }) => {
+ if (closed || topicEditorState !== state) return;
+ const loadedCategoryIds = new Set(categories.map((category) => +category.id));
+ const availableCategories = categories.slice();
+ if (originalCategoryId && !loadedCategoryIds.has(originalCategoryId))
+ availableCategories.unshift({
+ id: originalCategoryId,
+ name: cleanTopicCategoryName(topic.category_name || sourceTopicNavMetadata.categoryName || '当前类别'),
+ slug: String(topic.category_slug || sourceTopicNavMetadata.categorySlug || ''),
+ });
+ categoriesById.clear();
+ availableCategories.forEach((category) => categoriesById.set(+category.id, category));
+ renderCategoryOptions();
+ optionsLoaded = true;
+ status.textContent = '';
+ renderSelectedLabels();
+ setBusy(false);
+ scheduleLabelSearch();
+ }).catch((error) => {
+ if (closed || topicEditorState !== state) return;
+ status.textContent = `类别和 label 加载失败:${error?.message ? error.message : '请重试'}`;
+ });
+ };
+ onContext(readerElement('.ldp-topic-edit-trigger'), 'click', openTopicEditor);
+ overlay._ldpCaptureHistoryViewport = () => {
+ timeline.sync();
+ const viewport = embeddedReaderViewportState(ctx) || {};
+ const timelinePostNumber = Number(
+ timelineEl.querySelector('.ldp-topic-timeline-track')?.getAttribute('aria-valuenow')
+ );
+ const timelineViewport = embeddedReaderPostViewportState(ctx, timelinePostNumber);
+ return normalizeReaderHistoryViewport({
+ postNumber: Number(timelineViewport?.postNumber) || Number(viewport.postNumber) || 1,
+ postOffset: timelineViewport ? timelineViewport.postOffset : viewport.postOffset,
+ scrollTop: Number.isFinite(Number(viewport.scrollTop)) ? Number(viewport.scrollTop) : body.scrollTop,
+ });
+ };
+ overlay._ldpCaptureHistoryPostNumber = () => overlay._ldpCaptureHistoryViewport()?.postNumber || 1;
+ const rememberCurrentTopicPosition = () => {
+ const currentTopic = ctx.topicData;
+ if (!currentTopic) return;
+ const currentPostNumber = Number(overlay._ldpCaptureHistoryPostNumber?.()) || targetPostNumber || 1;
+ rememberTopic(currentTopic, currentPostNumber, readerQueueEntry(topicId)?.seenPostNumbers);
+ };
+ let readerLayoutSyncFrame = 0;
+ const syncReaderWindowLayout = () => {
+ readerLayoutSyncFrame = 0;
+ syncReaderBackgroundMode();
+ applyFontScales();
+ readerSurfaceController.syncOpenSettingsControls(true);
+ readerShell.syncSettingsSurfaceGeometry?.();
+ if (ctx.nativeComposerWindow) ctx.nativeComposerWindow.syncLayer();
+ positionHeaderPopovers();
+ overlay._ldpSyncReaderQueuePosition?.();
+ overlay._ldpSyncTopicActionRailPosition?.();
+ ctx.streamListOffset = NaN;
+ ctx.streamNeedsRepair = true;
+ scheduleStreamWindowSync(ctx);
+ };
+ const onWindowResize = () => {
+ if (!readerLayoutSyncFrame) readerLayoutSyncFrame = requestAnimationFrame(syncReaderWindowLayout);
+ };
+ contextScope.add(() => {
+ if (readerLayoutSyncFrame) cancelAnimationFrame(readerLayoutSyncFrame);
+ readerLayoutSyncFrame = 0;
+ });
+ onContext(window, 'resize', onWindowResize);
+ onContext(overlay, 'ldp-reader-window-change', onWindowResize);
+ if (typeof ResizeObserver === 'function')
+ contextScope.observe(new ResizeObserver(onWindowResize), modal);
+
+ let loading = false, done = false, pendingRetry = false, loadHalted = false, loadHaltError = null;
+ let loadingPrev = false, loadingNext = false;
+ let latestReplyRefreshRunning = false, latestReplyLastRefreshAt = 0;
+ let latestReplyMessageBus = null, latestReplyEventTimer = 0;
+ let latestReplyMessageSubscriptions = [];
+ const livePostRefreshTimers = new Map();
+ let pendingLiveReplyCount = 0, liveUpdateTargetPostNumber = 0, liveUpdateDismissed = false;
+ let liveUpdateJumpRunning = false;
+ let gapLoadTimer = 0, gapLoadDueAt = 0, pendingGapDirection = 0, pendingGapBatches = 1;
+ let previousGapRetryCount = 0;
+ let previousGapRetryAt = 0;
+ let previousGapFailureUntil = 0, nextGapFailureUntil = 0;
+ let onBodyScroll = null, onBodyTouchStart = null, onReaderScrollKeyDown = null;
+ let onVisibilityChange = null;
+ let scrollWorkFrame = 0, wheelPreparationFrame = 0, wheelPreparedDirection = 0;
+ let pendingGoingUp = false, filterPumpTimer = 0, onlyOpProgressTimer = 0;
+ let onlyOpBatchStartedAt = 0, onlyOpRetryAt = 0, onlyOpRetryStreak = 0;
+ let onlyOpLastProgressAt = 0, onlyOpLastScanned = 0;
+ let destroyActions = null, stopTopicPresence = null, disposed = false, closePromise = null;
+ let cancelInitialNeighborPrefetch = null;
+ let failureRetryTimer = 0;
+ let nativeAppEvents = null;
+ let handleNativeComposerPostRefresh = null, nativeComposerRefreshTimer = 0;
+ let nativeComposerHostIsolation = null, suspendedNativeComposerHostTopicBus = null;
+ let onOnlyOpToggle = null, onTitleJump = null, onTitleJumpKeyDown = null, onTimelineTopClick = null, onOpenNativePost = null;
+ let onReaderRefreshClick = null, onReaderCloseClick = null, onOverlayClick = null, onModalWheel = null;
+ let onNativeComposerCloseClick = null, onNativeComposerSubmitClick = null, onNativeComposerSubmitKeyDown = null;
+ const clearFailureRetryTimer = () => {
+ if (failureRetryTimer) clearTimeout(failureRetryTimer);
+ failureRetryTimer = 0;
+ };
+ const stopOnlyOpProgress = () => {
+ if (onlyOpProgressTimer) clearInterval(onlyOpProgressTimer);
+ onlyOpProgressTimer = 0;
+ };
+ const syncOnlyOpProgress = () => {
+ if (!onlyOpProgress || !onlyOpProgressValue) return;
+ if (!ctx.onlyOp) {
+ stopOnlyOpProgress();
+ onlyOpProgress.hidden = true;
+ return;
+ }
+ const total = Math.max(1, +(loader.streamLength || ctx.totalPosts || 1));
+ const scanned = Math.min(total, Math.max(0, +(loader.scanCursor || 0)));
+ const skipped = Math.min(scanned, Math.max(0, +(loader.scanSkippedCount || 0)));
+ const found = streamLayoutItems(ctx).length;
+ const now = Date.now();
+ if (!onlyOpLastProgressAt || scanned !== onlyOpLastScanned) {
+ onlyOpLastProgressAt = now;
+ onlyOpLastScanned = scanned;
+ if (scanned > 0) onlyOpRetryStreak = 0;
+ }
+ const percent = done ? 100 : Math.min(99.5, scanned / total * 100);
+ const batchFlow = done ? '' : ` · 批${Math.max(1, Number(loader.scanBatchSize) || 1)}`;
+ const skippedFlow = skipped ? ` · 缺失${skipped}` : '';
+ let requestState = null;
+ try { requestState = postRequestScheduler.snapshot(); } catch {}
+ let state = done ? skipped ? 'partial' : 'done' : 'running';
+ let flow = '';
+ if (!done && loadHalted) {
+ const status = Number(loadHaltError?.status || 0);
+ state = 'error';
+ flow = ` · 已暂停${status ? ` HTTP ${status}` : ''}`;
+ } else if (!done && requestState && requestState.coolingDown) {
+ state = 'cooldown';
+ flow = ` · 429冷却 ${Math.max(1, Math.ceil(requestState.cooldownRemaining / 1000))}秒`;
+ } else if (!done && requestState && ['10s', '60s'].includes(requestState.blockingReason)) {
+ state = 'window';
+ const label = requestState.blockingReason === '10s' ? '10秒窗' : '60秒窗';
+ flow = ` · ${label} ${Math.max(1, Math.ceil(requestState.nextPermitDelay / 1000))}秒`;
+ } else if (!done && pendingRetry && onlyOpRetryAt > now) {
+ state = 'retry';
+ flow = ` · 重试${onlyOpRetryStreak || 1} ${Math.max(1, Math.ceil((onlyOpRetryAt - now) / 1000))}秒`;
+ } else if (!done && loading && onlyOpBatchStartedAt) {
+ const waitingSeconds = Math.max(1, Math.ceil((now - onlyOpBatchStartedAt) / 1000));
+ const globallyBusy = requestState && (
+ Number(requestState.globalActive || 0) > 0 || Number(requestState.globalQueued || 0) > 0
+ );
+ state = waitingSeconds * 1000 >= POST_REQUEST_TIMEOUT ? 'waiting' : 'running';
+ flow = globallyBusy
+ ? ` · 请求处理中 ${waitingSeconds}秒`
+ : ` · 缓存协调中 ${waitingSeconds}秒`;
+ } else if (!done && now - onlyOpLastProgressAt >= 2000) {
+ state = 'waiting';
+ flow = ` · 调度恢复中 ${Math.max(2, Math.ceil((now - onlyOpLastProgressAt) / 1000))}秒`;
+ } else if (!done && requestState) {
+ const globalQueue = Math.max(requestState.queued, Number(requestState.globalQueued) || 0);
+ flow = ` · 全局执行${requestState.globalActive} 排队${globalQueue}`;
+ }
+ onlyOpProgress.hidden = false;
+ onlyOpProgress.dataset.state = state;
+ onlyOpProgress.style.setProperty('--ldp-only-op-progress', `${percent.toFixed(1)}%`);
+ const phase = done ? skipped ? '部分完成' : '完成' : state === 'error' ? '暂停' : state === 'retry' ? '等待' : '扫描';
+ onlyOpProgressValue.textContent = `${phase} ${scanned}/${total} · 楼主${found}${skippedFlow}${batchFlow}${flow}`;
+ setLabel(onlyOpProgress, `只看楼主${done ? skipped ? '扫描结束但有缺失' : '扫描完成' : '压测中'},已处理 ${scanned}/${total} 层,找到 ${found} 条楼主内容${skipped ? `,其中 ${skipped} 层未能取回` : ''}${batchFlow}${flow}`);
+ onlyOpProgress.setAttribute('data-ldp-tooltip-label', onlyOpProgress.getAttribute('aria-label'));
+ };
+ const startOnlyOpProgress = (resetBaseline = true) => {
+ stopOnlyOpProgress();
+ if (resetBaseline || !onlyOpLastProgressAt) {
+ onlyOpLastProgressAt = Date.now();
+ onlyOpLastScanned = Math.max(0, +(loader.scanCursor || 0));
+ }
+ syncOnlyOpProgress();
+ if (ctx.onlyOp && !done)
+ onlyOpProgressTimer = setInterval(() => {
+ syncOnlyOpProgress();
+ const retryDue = pendingRetry && onlyOpRetryAt > 0 && onlyOpRetryAt <= Date.now();
+ if (ctx.onlyOp && !done && !loading && !loadHalted && !filterPumpTimer &&
+ (!pendingRetry || retryDue) && Date.now() - onlyOpLastProgressAt >= 2000)
+ pump({ maxBatches: 1, forceOnlyOpScan: true });
+ }, 500);
+ };
+ const prevLoadMisses = new Map();
+ const nextLoadMisses = new Map();
+ const gapMissBlocked = (misses, postNumber) => {
+ const blockedUntil = Number(misses.get(+postNumber) || 0);
+ if (!blockedUntil) return false;
+ if (blockedUntil > Date.now()) return true;
+ misses.delete(+postNumber);
+ return false;
+ };
+ const sessionAcceptsWork = () => readerSession.isCurrent('opening', 'active');
+ ctx.releaseInitialPrecedingContent = () => {
+ if (!ctx.initialTargetPrecedingContentDeferred) return;
+ ctx.initialTargetPrecedingContentDeferred = false;
+ ctx.streamMountedNodes.forEach((node) => {
+ if (+node.dataset.postNumber >= +(ctx.initialTargetHydrationBoundary || 0)) return;
+ if (node.dataset.childReplyTotal && ctx.repliesIO && ctx.repliesIO.observe)
+ ctx.repliesIO.observe(node);
+ });
+ scheduleStreamWindowSync(ctx);
+ };
+
+ const stopLatestReplyUpdates = () => {
+ if (latestReplyEventTimer) clearTimeout(latestReplyEventTimer);
+ livePostRefreshTimers.forEach((timer) => clearTimeout(timer));
+ livePostRefreshTimers.clear();
+ latestReplyEventTimer = 0;
+ if (latestReplyMessageBus)
+ latestReplyMessageSubscriptions.forEach(({ channel, handler }) => {
+ try { latestReplyMessageBus.unsubscribe(channel, handler); } catch {}
+ });
+ latestReplyMessageBus = null;
+ latestReplyMessageSubscriptions = [];
+ };
+ const scheduleFullTopicRefresh = () => {
+ if (latestReplyEventTimer || !sessionAcceptsWork() ||
+ document.visibilityState !== 'visible') return;
+ latestReplyEventTimer = setTimeout(() => {
+ latestReplyEventTimer = 0;
+ refreshLatestReplyState({ force: true });
+ }, 350);
+ };
+ const targetedLivePostMessageTypes = new Set([
+ 'acted', 'created', 'rebaked', 'recovered', 'revised',
+ ]);
+ const refreshLivePostFromMessage = (payload) => {
+ const reactionUpdate = Number(payload?.post_id || 0) > 0 &&
+ Array.isArray(payload?.reactions);
+ const postId = Number(reactionUpdate ? payload.post_id : payload?.id || 0);
+ if (!postId || livePostRefreshTimers.has(postId) || !sessionAcceptsWork() ||
+ document.visibilityState !== 'visible') return;
+ const messageType = reactionUpdate ? 'acted' : String(payload.type || '');
+ if (messageType === 'read') return;
+ if (!targetedLivePostMessageTypes.has(messageType)) {
+ scheduleFullTopicRefresh();
+ return;
+ }
+ const timer = setTimeout(async () => {
+ livePostRefreshTimers.delete(postId);
+ if (!sessionAcceptsWork() || !isFunction(ctx.refreshPostById)) return;
+ const wasKnown = readerPostCopies(postId, ctx).length > 0;
+ const wasNearBottom = body.scrollHeight - body.scrollTop - body.clientHeight <= 120;
+ const previousHighestPostNumber = Math.max(0, +(ctx.totalPosts || 0));
+ try {
+ const post = await ctx.refreshPostById(postId);
+ if (!post || !sessionAcceptsWork()) return;
+ const postNumber = Math.max(0, Number(post.post_number) || 0);
+ const newlyAdded = messageType === 'created' && !wasKnown && postNumber > previousHighestPostNumber;
+ if (postNumber) {
+ ctx.totalPosts = Math.max(previousHighestPostNumber, postNumber);
+ if (ctx.topicData) {
+ ctx.topicData.highest_post_number = Math.max(
+ Number(ctx.topicData.highest_post_number) || 0,
+ postNumber
+ );
+ if (post.created_at) ctx.topicData.last_posted_at = post.created_at;
+ }
+ rememberLatestReplyTime(ctx, post);
+ updateCommentsHeader(ctx);
+ ctx.timeline.update();
+ }
+ if (!newlyAdded) return;
+ void ctx.replyThreadWindow?._ldpIngestLivePost?.(post);
+ if (wasNearBottom) {
+ clearLiveUpdateButton();
+ const previousScrollTop = body.scrollTop;
+ body.scrollTop = body.scrollHeight;
+ markStreamInternalScroll(ctx, previousScrollTop);
+ } else {
+ liveUpdateDismissed = false;
+ pendingLiveReplyCount++;
+ if (!liveUpdateTargetPostNumber || postNumber < liveUpdateTargetPostNumber)
+ liveUpdateTargetPostNumber = postNumber;
+ syncLiveUpdateButton();
+ }
+ } catch {
+ scheduleFullTopicRefresh();
+ }
+ }, 120);
+ livePostRefreshTimers.set(postId, timer);
+ };
+ const startLatestReplyUpdates = () => {
+ if (latestReplyMessageSubscriptions.length || !ctx.topicData || !sessionAcceptsWork() ||
+ document.visibilityState !== 'visible') return;
+ const messageBus = lookupDiscourse('service:message-bus');
+ if (isFunction(messageBus?.subscribe) && isFunction(messageBus.unsubscribe)) {
+ const topicHandler = (message) => {
+ const payload = message?.payload && typeof message.payload === 'object'
+ ? message.payload
+ : message;
+ const messageTopicId = Number(message?.topic_id || payload?.topic_id || 0);
+ if (messageTopicId && messageTopicId !== +topicId) return;
+ refreshLivePostFromMessage(payload);
+ };
+ const subscriptions = [
+ { channel: `/topic/${topicId}`, handler: topicHandler },
+ { channel: `/topic/${topicId}/reactions`, handler: topicHandler },
+ ];
+ try {
+ subscriptions.forEach(({ channel, handler }) => messageBus.subscribe(channel, handler));
+ latestReplyMessageBus = messageBus;
+ latestReplyMessageSubscriptions = subscriptions;
+ return;
+ } catch {
+ subscriptions.forEach(({ channel, handler }) => {
+ try { messageBus.unsubscribe(channel, handler); } catch {}
+ });
+ }
+ }
+ };
+ overlay._ldpStopLatestReplyUpdates = stopLatestReplyUpdates;
+
+ const syncLiveUpdateButton = () => {
+ liveUpdateBtn.hidden = pendingLiveReplyCount <= 0 || liveUpdateDismissed;
+ liveUpdateJumpBtn.disabled = liveUpdateJumpRunning;
+ liveUpdateJumpBtn.setAttribute('aria-busy', String(liveUpdateJumpRunning));
+ const label = liveUpdateBtn.querySelector('span');
+ if (label) label.textContent = `有 ${pendingLiveReplyCount} 条新回复`;
+ };
+ const clearLiveUpdateButton = () => {
+ pendingLiveReplyCount = 0;
+ liveUpdateTargetPostNumber = 0;
+ liveUpdateDismissed = false;
+ syncLiveUpdateButton();
+ };
+ const onLiveUpdateJump = async () => {
+ if (!sessionAcceptsWork() || liveUpdateJumpRunning) return;
+ const targetPostNumber = liveUpdateTargetPostNumber;
+ const pendingCount = pendingLiveReplyCount;
+ clearLiveUpdateButton();
+ if (!targetPostNumber) return;
+ liveUpdateJumpRunning = true;
+ syncLiveUpdateButton();
+ let jumped = false;
+ try {
+ jumped = await ctx.timeline.jumpTo(targetPostNumber);
+ if (!jumped && sessionAcceptsWork()) {
+ const targetItem = ctx.streamItemMap.get(targetPostNumber);
+ if (ctx.onlyOp && !streamItemMatchesFilter(ctx, targetItem) && isFunction(onOnlyOpToggle))
+ await onOnlyOpToggle();
+ else {
+ ctx.streamWindowResetPending = true;
+ syncStreamWindow(ctx);
+ }
+ await waitForReaderJumpLayout();
+ if (sessionAcceptsWork()) jumped = await ctx.timeline.jumpTo(targetPostNumber);
+ }
+ } catch {
+ jumped = false;
+ } finally {
+ liveUpdateJumpRunning = false;
+ if (!jumped && sessionAcceptsWork()) {
+ pendingLiveReplyCount += pendingCount;
+ if (!liveUpdateTargetPostNumber || targetPostNumber < liveUpdateTargetPostNumber)
+ liveUpdateTargetPostNumber = targetPostNumber;
+ liveUpdateDismissed = false;
+ showSelectionToast(`暂时无法定位到楼层 #${targetPostNumber},可重试`);
+ }
+ syncLiveUpdateButton();
+ }
+ };
+ const onLiveUpdateDismiss = (event) => {
+ consumeEvent(event);
+ liveUpdateDismissed = true;
+ syncLiveUpdateButton();
+ };
+ onContext(liveUpdateJumpBtn, 'click', onLiveUpdateJump);
+ onContext(liveUpdateDismissBtn, 'click', onLiveUpdateDismiss);
+
+ const refreshLatestReplyState = async (options = {}) => {
+ const force = options.force === true;
+ if (latestReplyRefreshRunning || !sessionAcceptsWork() || document.visibilityState !== 'visible') return null;
+ const now = Date.now();
+ if (!force && now - latestReplyLastRefreshAt < 10000) return null;
+ latestReplyLastRefreshAt = now;
+ latestReplyRefreshRunning = true;
+ try {
+ const refreshPriority = Number.isFinite(options.priority)
+ ? options.priority
+ : POST_REQUEST_PRIORITY.background;
+ const previousHighestPostNumber = Math.max(
+ 0,
+ Number.isFinite(+options.afterPostNumber) ? +options.afterPostNumber : +(ctx.totalPosts || 0),
+ );
+ const freshTopic = await loader.refreshTopicData({ priority: refreshPriority });
+ if (!freshTopic || !sessionAcceptsWork()) return null;
+ const freshHighestPostNumber = Math.max(0, +(freshTopic.highest_post_number || 0), +(freshTopic.posts_count || 0));
+ const postsByNumber = new Map();
+ postsFromPayload(freshTopic).forEach((post) => {
+ const postNumber = +(post?.post_number || 0);
+ if (!postNumber) return;
+ if (ctx.streamNodeMap.has(postNumber) || postNumber > previousHighestPostNumber)
+ postsByNumber.set(postNumber, post);
+ });
+ let fetchAfter = previousHighestPostNumber;
+ let pageGuard = 0;
+ while (fetchAfter > 0 && fetchAfter < freshHighestPostNumber && pageGuard++ < 10) {
+ const page = await loader.loadAfterPost(fetchAfter, {
+ priority: refreshPriority,
+ });
+ if (!sessionAcceptsWork()) return null;
+ const newer = page.filter((post) => +(post?.post_number || 0) > fetchAfter);
+ if (!newer.length) break;
+ newer.forEach((post) => postsByNumber.set(+post.post_number, post));
+ const nextAfter = newer.reduce((latest, post) => Math.max(latest, +(post.post_number || 0)), fetchAfter);
+ if (nextAfter <= fetchAfter) break;
+ fetchAfter = nextAfter;
+ }
+ const hasNewPostCandidates = [...postsByNumber.keys()]
+ .some((postNumber) => postNumber > previousHighestPostNumber);
+ const wasNearBottom = hasNewPostCandidates &&
+ body.scrollHeight - body.scrollTop - body.clientHeight <= 120;
+ const freshPosts = ingestLivePosts([...postsByNumber.values()], ctx);
+ freshPosts.forEach((post) => { void ctx.replyThreadWindow?._ldpIngestLivePost?.(post); });
+ ctx.topicData = freshTopic;
+ ctx.topicSharedIssueHostVisible = nativeTopicSharedIssueVisible(freshTopic);
+ ctx.isPostVoting = !!freshTopic.is_post_voting;
+ ctx.topicBookmarked = !!freshTopic.bookmarked;
+ ctx.topicNotificationLevel = topicNotificationLevel(freshTopic);
+ ctx.totalPosts = freshHighestPostNumber;
+ rememberLatestReplyTime(ctx, { created_at: freshTopic.last_posted_at }, { force: true });
+ updateCommentsHeader(ctx);
+ syncTopicActionControls(ctx);
+ renderTopicShell(freshTopic);
+ syncAllPostActionControls(ctx);
+ ctx.timeline.update();
+ if (freshPosts.length) {
+ if (!liveUpdateTargetPostNumber)
+ liveUpdateTargetPostNumber = +(freshPosts[0]?.post_number || 0);
+ if (wasNearBottom) {
+ clearLiveUpdateButton();
+ const previousScrollTop = body.scrollTop;
+ body.scrollTop = body.scrollHeight;
+ markStreamInternalScroll(ctx, previousScrollTop);
+ } else {
+ liveUpdateDismissed = false;
+ pendingLiveReplyCount += freshPosts.length;
+ syncLiveUpdateButton();
+ }
+ }
+ return {
+ posts: [...postsByNumber.values()],
+ topic: freshTopic,
+ };
+ } catch {
+ // Keep the last known timestamp when a background refresh fails.
+ return null;
+ } finally {
+ latestReplyRefreshRunning = false;
+ }
+ };
+ ctx.refreshPostByNumber = async (postNumber) => {
+ const posts = await loader.loadPost(+postNumber, {
+ priority: POST_REQUEST_PRIORITY.target,
+ forceRefresh: true,
+ });
+ if (!sessionAcceptsWork()) return null;
+ loader.persistFetchedPosts(posts);
+ ingestLivePosts(posts, ctx, { refreshContent: true });
+ return posts.find((post) => +(post?.post_number || 0) === +postNumber) || posts[0] || null;
+ };
+ ctx.refreshPostById = async (postId) => {
+ const targetPostId = Number(postId) || 0;
+ if (!targetPostId) return null;
+ const post = await fetchJSON(`${BASE}/posts/${targetPostId}.json`, {
+ scheduler: postRequestScheduler,
+ priority: POST_REQUEST_PRIORITY.visible,
+ cacheMode: 'refresh',
+ allowStaleOnError: false,
+ fetchOptions: { cache: 'no-store' },
+ key: `post:${targetPostId}:live-update`,
+ });
+ if (!sessionAcceptsWork() || !post || +post.id !== targetPostId || !post.post_number) return null;
+ loader.persistFetchedPosts([post]);
+ ingestLivePosts([post], ctx, { refreshContent: true });
+ return post;
+ };
+ ctx.refreshNativeComposerSession = async (session) => {
+ if (!session || !sessionAcceptsWork()) return false;
+ if (session.action === 'edit') {
+ const updated = await ctx.refreshPostByNumber(session.postNumber);
+ if (!updated) return false;
+ return ctx.timeline.jumpTo(+updated.post_number, {
+ targetPost: updated,
+ forceNested: true,
+ });
+ }
+ if (session.action !== 'reply') return false;
+ const parentPostNumber = +(session.parentPostNumber || 0);
+ const highestPostNumber = +(session.highestPostNumber || 0);
+ const retryDelays = [0, 700, 1400];
+ for (let attempt = 0; attempt < retryDelays.length && sessionAcceptsWork(); attempt++) {
+ if (retryDelays[attempt]) {
+ await waitForDelay(retryDelays[attempt]);
+ if (!sessionAcceptsWork()) return false;
+ }
+ const result = await refreshLatestReplyState({
+ force: true,
+ afterPostNumber: highestPostNumber,
+ priority: POST_REQUEST_PRIORITY.visible,
+ });
+ const candidates = new Map();
+ (result?.posts || []).forEach((post) => candidates.set(+post.post_number, post));
+ ctx.streamItems.forEach((item) => {
+ const post = item?.node && item.node._ldpPostData;
+ if (post && +post.post_number > highestPostNumber) candidates.set(+post.post_number, post);
+ });
+ const created = [...candidates.values()]
+ .filter((post) => +post.post_number > highestPostNumber)
+ .filter((post) => !ME_USERNAME || post.username === ME_USERNAME)
+ .filter((post) => parentPostNumber <= 1
+ ? +(post.reply_to_post_number || 0) <= 1
+ : +(post.reply_to_post_number || 0) === parentPostNumber)
+ .sort((a, b) => +b.post_number - +a.post_number)[0];
+ if (created) {
+ if (isFunction(ctx.loader?.persistFetchedPosts))
+ ctx.loader.persistFetchedPosts([created]);
+ clearLiveUpdateButton();
+ return ctx.timeline.jumpTo(+created.post_number, {
+ targetPost: created,
+ forceNested: true,
+ });
+ }
+ }
+ return false;
+ };
+ ctx.bindNativeComposerRefresh = () => {
+ if (handleNativeComposerPostRefresh) return true;
+ nativeAppEvents = lookupDiscourse('service:app-events');
+ if (!nativeAppEvents || !isFunction(nativeAppEvents.on)) return false;
+ handleNativeComposerPostRefresh = () => {
+ const session = ctx.nativeComposerSession;
+ if (!session || session.refreshing || !sessionAcceptsWork()) return;
+ session.refreshing = true;
+ nativeComposerRefreshTimer = setTimeout(async () => {
+ nativeComposerRefreshTimer = 0;
+ try {
+ await ctx.refreshNativeComposerSession(session);
+ } finally {
+ if (ctx.nativeComposerSession === session) ctx.nativeComposerSession = null;
+ }
+ }, 0);
+ };
+ nativeAppEvents.on('composer:created-post', handleNativeComposerPostRefresh);
+ nativeAppEvents.on('composer:edited-post', handleNativeComposerPostRefresh);
+ nativeAppEvents.on('post:created', handleNativeComposerPostRefresh);
+ return true;
+ };
+ ctx.bindNativeComposerRefresh();
+ onVisibilityChange = () => {
+ if (!sessionAcceptsWork()) return;
+ const visible = document.visibilityState === 'visible';
+ ctx.timeline.setActive(visible);
+ ctx.repliesIO?.setActive?.(visible);
+ recordResourceMonitorVisibility();
+ if (!visible) {
+ stopLatestReplyUpdates();
+ stopRateLimitNotice();
+ stopOnlyOpProgress();
+ stopRequestFlowUpdates();
+ suspendReaderMedia(overlay);
+ ctx.streamHydratedNodes.forEach((node) => suspendReaderMedia(node));
+ return;
+ }
+ ctx.streamMountedNodes.forEach((node) => activateReaderMedia(node));
+ startLatestReplyUpdates();
+ startRateLimitNotice();
+ if (ctx.onlyOp && !done) startOnlyOpProgress(false);
+ if (pendingRetry && !filterPumpTimer && !loading && !done && !loadHalted) {
+ onlyOpRetryAt = 0;
+ void pump({ maxBatches: 1, forceOnlyOpScan: ctx.onlyOp });
+ }
+ if (pendingGapDirection) scheduleGapLoad(pendingGapDirection < 0);
+ syncSettingsLogUpdates();
+ ctx.timeline.update();
+ ctx.timeline.refreshRelativeTimes();
+ refreshLatestReplyState({ force: true });
+ };
+ onContext(document, 'visibilitychange', onVisibilityChange);
+
+ const readerEscapeExitWindow = 1500;
+ let readerEscapeExitDeadline = 0;
+ let readerEscapeExitTimer = 0;
+ let readerEscapePointerX = Number.NaN;
+ let readerEscapePointerY = Number.NaN;
+ const disarmReaderEscapeExit = () => {
+ if (!readerEscapeExitDeadline && !readerEscapeExitTimer) return;
+ readerEscapeExitDeadline = 0;
+ if (readerEscapeExitTimer) clearTimeout(readerEscapeExitTimer);
+ readerEscapeExitTimer = 0;
+ readerPortalQuery('.ldp-selection-toast[data-ldp-reader-exit-hint]')?.remove();
+ };
+ const armReaderEscapeExit = () => {
+ disarmReaderEscapeExit();
+ readerEscapeExitDeadline = Date.now() + readerEscapeExitWindow;
+ showSelectionToast('再按一次 Esc 退出阅读器');
+ const toast = readerPortalQuery('.ldp-selection-toast');
+ if (toast) toast.dataset.ldpReaderExitHint = '1';
+ readerEscapeExitTimer = setTimeout(disarmReaderEscapeExit, readerEscapeExitWindow);
+ };
+ const nativeComposerCloseWindow = 1500;
+ let nativeComposerCloseAction = '';
+ let nativeComposerCloseDeadline = 0;
+ let nativeComposerCloseTimer = 0;
+ const disarmNativeComposerClose = () => {
+ nativeComposerCloseAction = '';
+ nativeComposerCloseDeadline = 0;
+ if (nativeComposerCloseTimer) clearTimeout(nativeComposerCloseTimer);
+ nativeComposerCloseTimer = 0;
+ readerPortalQuery('.ldp-selection-toast[data-ldp-composer-close-hint]')?.remove();
+ };
+ const nativeComposerCloseConfirmed = (action) =>
+ nativeComposerCloseAction === action && nativeComposerCloseDeadline > Date.now();
+ const armNativeComposerClose = (action, message) => {
+ disarmNativeComposerClose();
+ nativeComposerCloseAction = action;
+ nativeComposerCloseDeadline = Date.now() + nativeComposerCloseWindow;
+ showSelectionToast(message);
+ const toast = readerPortalQuery('.ldp-selection-toast');
+ if (toast) toast.dataset.ldpComposerCloseHint = '1';
+ nativeComposerCloseTimer = setTimeout(disarmNativeComposerClose, nativeComposerCloseWindow);
+ };
+ const releaseReaderNativeComposerHostIsolation = (isolation = nativeComposerHostIsolation) => {
+ if (!isolation || isolation.released) return;
+ isolation.released = true;
+ if (isolation.appEvents && isolation.appEvents.trigger === isolation.trigger) {
+ try { isolation.appEvents.trigger = isolation.originalTrigger; } catch {}
+ }
+ (isolation.routeGuards || []).forEach(({ owner, originalRouteTo, guardedRouteTo }) => {
+ if (owner.routeTo !== guardedRouteTo) return;
+ try { owner.routeTo = originalRouteTo; } catch {}
+ });
+ if (nativeComposerHostIsolation === isolation) nativeComposerHostIsolation = null;
+ };
+ const resumeReaderNativeComposerHostTopicBus = () => {
+ const suspended = suspendedNativeComposerHostTopicBus;
+ suspendedNativeComposerHostTopicBus = null;
+ if (!suspended || !suspended.topicController ||
+ !isFunction(suspended.topicController.subscribe)) return;
+ const currentModel = discourseModelValue(suspended.topicController, 'model');
+ const currentTopicId = +(discourseModelValue(currentModel, 'id') || 0);
+ if (currentTopicId !== suspended.hostTopicId) return;
+ try { suspended.topicController.subscribe(); } catch {}
+ };
+ const isolateReaderNativeComposerFromHost = () => {
+ const session = ctx.nativeComposerSession;
+ if (!session || !['reply', 'edit'].includes(session.action)) return null;
+ releaseReaderNativeComposerHostIsolation();
+ const appEvents = session.composer && (
+ session.composer.appEvents || lookupDiscourse('service:app-events')
+ );
+ if (!appEvents || !isFunction(appEvents.trigger)) return null;
+ const originalTrigger = appEvents.trigger;
+ const readerRefreshEvents = new Set(session.action === 'edit'
+ ? ['composer:edited-post']
+ : ['composer:created-post', 'post:created']);
+ const suppressedEvents = new Set([
+ ...readerRefreshEvents,
+ 'post:highlight',
+ ]);
+ const isolation = {
+ appEvents,
+ originalTrigger,
+ trigger: null,
+ routeGuards: [],
+ released: false,
+ };
+ isolation.trigger = function (eventName, ...args) {
+ const name = String(eventName || '');
+ if (readerRefreshEvents.has(name) && isFunction(handleNativeComposerPostRefresh))
+ handleNativeComposerPostRefresh();
+ if (suppressedEvents.has(name)) return this;
+ return originalTrigger.call(this, eventName, ...args);
+ };
+ try {
+ appEvents.trigger = isolation.trigger;
+ } catch {
+ return null;
+ }
+ const discourseUrlModule = lookupDiscourseModule('discourse/lib/url');
+ const routeOwner = discourseUrlModule?.default;
+ if (isFunction(routeOwner?.routeTo)) {
+ const originalRouteTo = routeOwner.routeTo;
+ const guardedRouteTo = function (url, ...args) {
+ const route = extractTopicRouteFromUrl(String(url || ''));
+ if (route && +route.topicId === +ctx.topicId && +route.postNumber > 0) return;
+ return originalRouteTo.call(this, url, ...args);
+ };
+ try {
+ routeOwner.routeTo = guardedRouteTo;
+ if (routeOwner.routeTo === guardedRouteTo)
+ isolation.routeGuards.push({ owner: routeOwner, originalRouteTo, guardedRouteTo });
+ } catch {}
+ }
+ const topicController = lookupDiscourse('controller:topic');
+ const hostModel = discourseModelValue(topicController, 'model');
+ const hostTopicId = +(discourseModelValue(hostModel, 'id') || 0);
+ const alreadySuspended = suspendedNativeComposerHostTopicBus &&
+ suspendedNativeComposerHostTopicBus.topicController === topicController &&
+ suspendedNativeComposerHostTopicBus.hostTopicId === hostTopicId;
+ if (!alreadySuspended && hostTopicId === +ctx.topicId && topicController &&
+ isFunction(topicController.unsubscribe)) {
+ resumeReaderNativeComposerHostTopicBus();
+ try {
+ topicController.unsubscribe();
+ suspendedNativeComposerHostTopicBus = { topicController, hostTopicId };
+ } catch {}
+ }
+ nativeComposerHostIsolation = isolation;
+ return isolation;
+ };
+ const nativeReaderComposerButton = () => {
+ const session = ctx.nativeComposerSession;
+ if (!session || !['reply', 'edit'].includes(session.action) ||
+ discourseModelValue(session.composer, 'model') !== session.model) return null;
+ const composer = document.querySelector('#reply-control');
+ if (!nativeComposerVisible(composer) ||
+ !composer.matches(`.composer-action-${session.action}`)) return null;
+ return composer.querySelector('.save-or-cancel button.create');
+ };
+ const submitReaderNativeComposerWithoutHostRefresh = (button) => {
+ const session = ctx.nativeComposerSession;
+ const composer = session?.composer;
+ if (!button || button.disabled || !composer || !isFunction(composer.save)) return false;
+ const isolation = isolateReaderNativeComposerFromHost();
+ try {
+ const saveResult = composer.save(true, { jump: false });
+ Promise.resolve(saveResult).then(
+ () => releaseReaderNativeComposerHostIsolation(isolation),
+ () => releaseReaderNativeComposerHostIsolation(isolation),
+ );
+ } catch (error) {
+ releaseReaderNativeComposerHostIsolation(isolation);
+ console.warn('[LINUX DO reader] native composer submit failed', error);
+ }
+ return true;
+ };
+ onNativeComposerSubmitClick = (event) => {
+ const button = event.target.closest?.('#reply-control .save-or-cancel button.create');
+ if (!button || button !== nativeReaderComposerButton()) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ submitReaderNativeComposerWithoutHostRefresh(button);
+ };
+ onNativeComposerSubmitKeyDown = (event) => {
+ if (event.isComposing || !(event.ctrlKey || event.metaKey) ||
+ (event.key !== 'Enter' && event.code !== 'Enter')) return;
+ const button = nativeReaderComposerButton();
+ if (!button) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ submitReaderNativeComposerWithoutHostRefresh(button);
+ };
+ onNativeComposerCloseClick = (event) => {
+ const button = event.target.closest?.('#reply-control .toggle-save-and-close, #reply-control .discard-button');
+ if (!button || !nativeComposerVisible(document.querySelector('#reply-control'))) return;
+ if (!PREFS.confirmNativeComposerClose) {
+ disarmNativeComposerClose();
+ return;
+ }
+ const action = button.classList.contains('discard-button') ? 'discard' : 'save-close';
+ if (nativeComposerCloseConfirmed(action)) {
+ disarmNativeComposerClose();
+ if (action === 'discard') {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ discardReaderNativeComposer(ctx).catch((error) => {
+ console.warn('[LINUX DO reader] native composer discard failed', error);
+ });
+ }
+ return;
+ }
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ armNativeComposerClose(action, action === 'discard'
+ ? '再点一次舍弃回复'
+ : '再点一次关闭回复窗口');
+ };
+ const onReaderEscapePointerMove = (event) => {
+ readerEscapePointerX = event.clientX;
+ readerEscapePointerY = event.clientY;
+ };
+ const visibleExpandedDefaultPosts = () => {
+ const rootRect = ctx.scrollRoot.getBoundingClientRect();
+ return [...ctx.commentsEl.querySelectorAll(
+ ':scope > .ldp-post.ldp-reply-collapsible:not(.ldp-nested-collapsed)',
+ )].map((post) => ({ post, rect: post.getBoundingClientRect() }))
+ .filter(({ post }) => isEscCollapsibleNestedReply(post))
+ .filter(({ rect }) => rect.bottom > rootRect.top && rect.top < rootRect.bottom &&
+ rect.right > rootRect.left && rect.left < rootRect.right)
+ .sort((a, b) => a.rect.top - b.rect.top);
+ };
+ const pickExpandedDefaultPost = () => {
+ if (ctx.onlyOp) return null;
+ const candidates = visibleExpandedDefaultPosts();
+ const pointed = candidates.find(({ rect }) =>
+ Number.isFinite(readerEscapePointerX) && Number.isFinite(readerEscapePointerY) &&
+ readerEscapePointerX >= rect.left && readerEscapePointerX <= rect.right &&
+ readerEscapePointerY >= rect.top && readerEscapePointerY <= rect.bottom);
+ return (pointed || candidates[0] || {}).post || null;
+ };
+ const onReaderEscapeExitInteraction = (event) => {
+ disarmReaderEscapeExit();
+ if (event.type !== 'pointerdown' || event.button !== 0 || !readerWorkspace.isEmbedded() ||
+ eventPathIncludes(event, READER_PORTAL_HOST)) return;
+ readerSurfaceController.closeFloatingPanels();
+ };
+ let runtimeStopped = false;
+ let shellStopped = false;
+ const runReaderCleanup = (label, cleanup) => {
+ try { cleanup(); } catch (error) { console.warn(`[LDP] ${label} cleanup failed`, error); }
+ };
+ const stopReaderRuntime = (options = {}) => {
+ const preserveShell = options.preserveShell === true;
+ if (!runtimeStopped) {
+ runtimeStopped = true;
+ if (queueEntry)
+ saveReaderQueueAnchor(queueEntry.topicId, captureReaderAnchorState(overlay));
+ if (isFunction(overlay._ldpCloseCodePreview)) overlay._ldpCloseCodePreview(false, true);
+ if (preserveShell) {
+ const parkedCommentsHeader = parkReaderCommentsHeader(overlay, ctx.commentsHeader);
+ if (parkedCommentsHeader) readerShell.commentsHeader = parkedCommentsHeader;
+ }
+ stopLatestReplyUpdates();
+ stopOnlyOpProgress();
+ stopRateLimitNotice();
+ latestReplyRefreshRunning = false;
+ runReaderCleanup('topic nav metadata', stopTopicNavMetadataHydration);
+ if (nativeComposerRefreshTimer) clearTimeout(nativeComposerRefreshTimer);
+ nativeComposerRefreshTimer = 0;
+ releaseReaderNativeComposerHostIsolation();
+ resumeReaderNativeComposerHostTopicBus();
+ if (nativeAppEvents && handleNativeComposerPostRefresh && isFunction(nativeAppEvents.off)) {
+ nativeAppEvents.off('composer:created-post', handleNativeComposerPostRefresh);
+ nativeAppEvents.off('composer:edited-post', handleNativeComposerPostRefresh);
+ nativeAppEvents.off('post:created', handleNativeComposerPostRefresh);
+ }
+ [
+ ['loader', () => loader.destroy()],
+ ['reader queue prefetch', stopReaderQueuePrefetch],
+ ['request scheduler', () => {
+ if (ACTIVE_READER_REQUEST_SCHEDULER === postRequestScheduler) ACTIVE_READER_REQUEST_SCHEDULER = null;
+ postRequestScheduler.destroy();
+ }],
+ ['user card', hideFallbackUserCard],
+ ['action dialog', () => ctx.readerActionDialog?.close()],
+ ['topic editor', () => topicEditorState?.close(true)],
+ ['composer window', () => ctx.nativeComposerWindow.destroy()],
+ ['post actions', () => destroyActions && destroyActions()],
+ ['read tracker', () => tracker.stop()],
+ ['queue viewport tracker', () => ctx.queueViewportTracker.stop()],
+ ['nested replies', () => ctx.repliesIO.disconnect()],
+ ['descendant replies window', () => ctx.replyThreadWindow?._ldpClose?.()],
+ ['body translation', () => ctx.translation.destroy()],
+ ['media players', () => suspendReaderMedia(overlay)],
+ ['detached media players', () => ctx.streamHydratedNodes.forEach((node) => suspendReaderMedia(node))],
+ ['pending content hydration', () => [...ctx.pendingPostHydrations]
+ .forEach((node) => cancelPostContentHydration(node, ctx))],
+ ['pending latex render', () => [...ctx.pendingPostLatexRenders]
+ .forEach((node) => cancelPostLatexRender(node, ctx))],
+ ['jump highlight', clearActiveJumpHighlight],
+ ['stream viewport anchor', () => clearStreamViewportAnchor(ctx)],
+ ['avatar placeholders', () => releasePersistentAvatarImages(overlay)],
+ ['resize observer', () => ctx.streamResizeObserver.disconnect()],
+ ['timeline', () => ctx.timeline.destroy()],
+ ['topic presence', () => stopTopicPresence && stopTopicPresence()],
+ ].forEach(([label, cleanup]) => runReaderCleanup(label, cleanup));
+ if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame);
+ if (ctx.floorPreviewSyncFrame) cancelAnimationFrame(ctx.floorPreviewSyncFrame);
+ if (scrollWorkFrame) cancelAnimationFrame(scrollWorkFrame);
+ if (wheelPreparationFrame) cancelAnimationFrame(wheelPreparationFrame);
+ ctx.streamWindowFrame = 0;
+ ctx.floorPreviewSyncFrame = 0;
+ scrollWorkFrame = 0;
+ wheelPreparationFrame = 0;
+ wheelPreparedDirection = 0;
+ if (cancelInitialNeighborPrefetch) cancelInitialNeighborPrefetch();
+ [gapLoadTimer, filterPumpTimer].forEach((timer) => {
+ if (timer) clearTimeout(timer);
+ });
+ gapLoadTimer = 0;
+ filterPumpTimer = 0;
+ gapLoadDueAt = 0;
+ pendingGapDirection = 0;
+ pendingGapBatches = 1;
+ contextScope.destroy();
+ disarmNativeComposerClose();
+ disarmReaderEscapeExit();
+ if (readerShell.activeContext === ctx) readerShell.activeContext = null;
+ [ctx.streamItems, ctx.streamLayoutItems].forEach((items) => { items.length = 0; });
+ [ctx.streamItemMap, ctx.streamMountedNodes, ctx.streamHydratedNodes, ctx.pendingPostHydrations,
+ ctx.pendingPostLatexRenders, ctx.streamNodeMap, ctx.subReplyState, ctx.directRepliesByParent,
+ ctx.confirmedReadPostNumbers,
+ ctx.replyParentByPost].forEach((collection) => collection.clear());
+ }
+ if (!preserveShell && !shellStopped) {
+ shellStopped = true;
+ overlay.dataset.readerShellDestroyed = '1';
+ runReaderCleanup('avatar observers', () => {
+ releasePersistentAvatarImages(notificationPanel.list);
+ releasePersistentAvatarImages(historyPanel.list);
+ releasePersistentAvatarImages(bookmarksPanel.list);
+ });
+ runReaderCleanup('resource monitor view', stopResourceMonitorUpdates);
+ runReaderCleanup('request flow view', stopRequestFlowUpdates);
+ if (readerShell.cancelShellFrames) readerShell.cancelShellFrames();
+ runReaderCleanup('notification live events', () => readerShell.stopNotificationLiveEvents?.());
+ runReaderCleanup('shell events', () => shellScope.destroy());
+ runReaderCleanup('reader window', () => readerShell.readerWindow.destroy());
+ runReaderCleanup('reader workspace', () => readerShell.readerWorkspace.destroy());
+ runReaderCleanup('reader queue surface', () => overlay._ldpDestroyReaderQueueSurface?.());
+ runReaderCleanup('surface manager', () => readerShell.surfaceManager.destroy());
+ }
+ };
+
+ let readerSwitchTarget = null;
+ let readerSwitchFrame = 0;
+ let readerSwitchX = 0;
+ let readerSwitchY = 0;
+ const popupCanSwitchTopic = () => !readerWindow.isPinned() && readerWorkspace.isFloating() &&
+ window.innerWidth > READER_WINDOW_COMPACT_WIDTH;
+ const readerTopicTargetAt = (clientX, clientY) => {
+ let source;
+ try {
+ overlay.classList.add('ldp-reader-hit-test-hidden');
+ source = document.elementFromPoint(clientX, clientY);
+ } finally {
+ overlay.classList.remove('ldp-reader-hit-test-hidden');
+ }
+ const link = source?.closest('a[href]');
+ const route = link && !link.classList.contains('ldp-open') && !shouldBypassReaderUrl(link.href)
+ ? extractTopicRouteFromUrl(link.getAttribute('href') || link.href)
+ : null;
+ return route ? { link, route } : null;
+ };
+ const setReaderSwitchTarget = (link) => {
+ if (readerSwitchTarget === link) return;
+ readerSwitchTarget?.classList.remove('ldp-reader-switch-target');
+ readerSwitchTarget = link || null;
+ readerSwitchTarget?.classList.add('ldp-reader-switch-target');
+ overlay.classList.toggle('ldp-reader-switch-ready', !!readerSwitchTarget);
+ };
+ const stopReaderSwitchTarget = () => {
+ if (readerSwitchFrame) cancelAnimationFrame(readerSwitchFrame);
+ readerSwitchFrame = 0;
+ setReaderSwitchTarget(null);
+ };
+ const syncReaderSwitchTarget = () => {
+ readerSwitchFrame = 0;
+ const target = popupCanSwitchTopic() ? readerTopicTargetAt(readerSwitchX, readerSwitchY) : null;
+ setReaderSwitchTarget(target?.link);
+ };
+ const onReaderSwitchPointerMove = (event) => {
+ if (event.target !== overlay || event.pointerType === 'touch' || !popupCanSwitchTopic()) {
+ stopReaderSwitchTarget();
+ return;
+ }
+ readerSwitchX = event.clientX;
+ readerSwitchY = event.clientY;
+ if (!readerSwitchFrame) readerSwitchFrame = requestAnimationFrame(syncReaderSwitchTarget);
+ };
+
+ let prepareTopicSwitchPromise = null;
+ const saveReaderNativeComposerDraftSafely = () => {
+ let draftSavePromise;
+ try {
+ draftSavePromise = saveAndCloseReaderNativeComposer(ctx);
+ } catch (error) {
+ console.warn('[LINUX DO reader] native composer draft save setup failed', error);
+ draftSavePromise = Promise.resolve(false);
+ }
+ return Promise.resolve(draftSavePromise).catch((error) => {
+ console.warn('[LINUX DO reader] native composer draft save cleanup failed', error);
+ return false;
+ });
+ };
+ const prepareTopicSwitch = () => {
+ if (prepareTopicSwitchPromise) return prepareTopicSwitchPromise;
+ if (disposed) return Promise.resolve(false);
+ clearFailureRetryTimer();
+ stopReaderSwitchTarget();
+ readerSession.state = 'switching';
+ const draftSavePromise = saveReaderNativeComposerDraftSafely();
+ rememberCurrentTopicPosition();
+ stopReaderRuntime({ preserveShell: true });
+ disposeHistoryNavigation();
+ overlay._ldpCaptureHistoryViewport = () => null;
+ overlay._ldpCaptureHistoryPostNumber = () => 1;
+ prepareTopicSwitchPromise = draftSavePromise;
+ return prepareTopicSwitchPromise;
+ };
+
+ const close = (closeOptions = {}) => {
+ if (closePromise) return closePromise;
+ if (disposed) return Promise.resolve();
+ if (isFunction(overlay._ldpCancelAutoRetryWait)) overlay._ldpCancelAutoRetryWait();
+ clearFailureRetryTimer();
+ stopReaderSwitchTarget();
+ disposed = true;
+ readerSession.state = 'closing';
+ const draftSavePromise = saveReaderNativeComposerDraftSafely();
+ rememberCurrentTopicPosition();
+ stopReaderRuntime();
+ disposeHistoryNavigation();
+ removeOwnedNode(overlay);
+ if (CURRENT_OVERLAY === overlay) {
+ CURRENT_OVERLAY = null;
+ guardNativeReaderTimings();
+ }
+ if (!closeOptions.suppressNavigation) autoDequeueReaderQueueEntry(topicId);
+ queueMicrotask(releaseReaderPortalIfIdle);
+ if (ACTIVE_READER_SESSION_ID === readerSessionId) ACTIVE_READER_SESSION_ID = 0;
+ PAGE_ROOT.classList.remove('ldp-reader-open');
+ setPageScrollLocked(false);
+ readerSession.state = 'closed';
+ if (!closeOptions.preserveDocumentTitle) restoreHostDocumentTitle();
+ if (!closeOptions.suppressNavigation) {
+ AUTO_OPEN_SUPPRESSED_TOPIC_ID = String(topicId);
+ PAGE_ROOT.classList.remove('ldp-route-takeover');
+ syncNativeReaderTrigger();
+ }
+ closePromise = draftSavePromise;
+ return closePromise;
+ };
+ overlay._ldpDispose = () => close({ suppressNavigation: true, preserveDocumentTitle: true });
+ overlay._ldpPrepareTopicSwitch = prepareTopicSwitch;
+ function onEsc(e) {
+ if (e.key !== 'Escape') {
+ disarmReaderEscapeExit();
+ return;
+ }
+ if (readerPortalQuery('.ldp-lightbox')) return;
+ if (e.repeat) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ return;
+ }
+ const exitConfirmed = readerEscapeExitDeadline > Date.now();
+ disarmReaderEscapeExit();
+ const boostMenu = document.body?.querySelector(':scope > .ldp-native-boost-menu');
+ const boostEmojiPicker = [...document.querySelectorAll('.emoji-picker')].find(nativeComposerVisible);
+ if (boostMenu && boostEmojiPicker && isFunction(boostMenu._ldpCloseEmojiPicker)) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ boostMenu._ldpCloseEmojiPicker();
+ return;
+ }
+ const nativeFloatingSurface = visibleNativeFloatingSurface();
+ if (nativeFloatingSurface) return;
+ if (isFunction(boostMenu?._ldpClose)) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ boostMenu._ldpClose();
+ return;
+ }
+ const nativeComposer = document.querySelector('#reply-control');
+ if (nativeComposerVisible(nativeComposer)) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ if (!PREFS.confirmNativeComposerClose || nativeComposerCloseConfirmed('escape')) {
+ disarmNativeComposerClose();
+ discardReaderNativeComposer(ctx).catch((error) => {
+ console.warn('[LINUX DO reader] native composer discard failed', error);
+ });
+ } else {
+ armNativeComposerClose('escape', '再按一次 Esc 舍弃回复');
+ }
+ return;
+ }
+ if (!settingsPopover.hidden && ctx.replyThreadWindow?.isConnected) {
+ consumeEvent(e);
+ if (!fontFamilyMenu.hidden) readerSurfaceController.closeFontFamilyMenu(true);
+ else readerSurfaceController.requestCloseSettings();
+ return;
+ }
+ const threadPreview = ctx.replyThreadWindow?.querySelector('.ldp-floor-preview.open');
+ if (threadPreview) {
+ consumeEvent(e); ctx.floorPreview?.hide(threadPreview._ldpRelatedPost);
+ return;
+ }
+ if (ctx.replyThreadWindow?.isConnected) {
+ consumeEvent(e);
+ if (!ctx.replyThreadWindow._ldpBack?.()) (ctx.replyThreadWindow._ldpCloseFromEscape || ctx.replyThreadWindow._ldpClose)?.();
+ return;
+ }
+ if (isFunction(overlay._ldpCloseCodePreview)) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ overlay._ldpCloseCodePreview();
+ return;
+ }
+ if (topicEditorState) {
+ consumeEvent(e);
+ if (!topicEditorState.closeTransient || !topicEditorState.closeTransient()) topicEditorState.close();
+ return;
+ }
+ if (ctx.readerActionDialog) {
+ consumeEvent(e);
+ ctx.readerActionDialog.close();
+ return;
+ }
+ if (isFunction(ctx.closeTransientActions) && ctx.closeTransientActions()) {
+ consumeEvent(e);
+ return;
+ }
+ if (fallbackUserCardOpen()) {
+ consumeEvent(e);
+ if (isFunction(ctx.closeUserCardLayer)) ctx.closeUserCardLayer();
+ else if (isFunction(ctx.closeUserCard)) ctx.closeUserCard();
+ else hideFallbackUserCard();
+ return;
+ }
+ if (ctx.floorPreview?.isOpen() && ctx.floorPreview.isVisibleInViewport()) {
+ consumeEvent(e);
+ ctx.floorPreview.hide();
+ if (pickExpandedDefaultPost())
+ showSelectionToast('再按 Esc 收起楼层:优先鼠标所在位置,否则从上往下');
+ return;
+ }
+ const collapsePost = pickExpandedDefaultPost();
+ if (collapsePost) {
+ consumeEvent(e);
+ setNestedReplyCollapsed(collapsePost, true);
+ const nextPost = pickExpandedDefaultPost();
+ showSelectionToast(nextPost
+ ? `已收起 #${collapsePost.dataset.postNumber || ''} · Esc 继续收起 #${nextPost.dataset.postNumber || ''}`
+ : `已收起楼层 #${collapsePost.dataset.postNumber || ''}`);
+ return;
+ }
+ const visibleSurface = [
+ [notificationsPopover, readerSurfaceController.closeNotifications],
+ [historyPanel.popover, readerSurfaceController.closeHistory],
+ [bookmarksPanel.popover, readerSurfaceController.closeBookmarks],
+ [fontFamilyMenu, () => readerSurfaceController.closeFontFamilyMenu(true)],
+ [settingsPopover, readerSurfaceController.requestCloseSettings],
+ ].find(([surface]) => !surface.hidden);
+ if (visibleSurface) {
+ consumeEvent(e);
+ visibleSurface[1]();
+ return;
+ }
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ if (!PREFS.doubleEscapeToCloseReader || exitConfirmed) close();
+ else armReaderEscapeExit();
+ }
+ const readerRefreshBtn = readerElement('.ldp-reader-refresh');
+ readerRefreshBtn.disabled = readerSession.state !== 'active';
+ onReaderRefreshClick = async () => {
+ if (readerRefreshBtn.disabled || readerSession.state !== 'active') return;
+ readerRefreshBtn.disabled = true;
+ readerRefreshBtn.classList.add('is-refreshing');
+ try {
+ const refreshState = captureReaderAnchorState(
+ overlay,
+ Number(overlay._ldpCaptureHistoryPostNumber?.()) || 1
+ );
+ const refreshPostNumber = refreshState?.viewport.postNumber || 1;
+ const refreshOnlyOp = ctx.onlyOp === true;
+ const topicCacheSnapshot = ctx.topicData;
+ if (isFunction(overlay._ldpPrepareTopicSwitch))
+ await overlay._ldpPrepareTopicSwitch();
+ await clearCurrentTopicCaches(topicId, topicCacheSnapshot);
+ await openModal(topicId, {
+ targetPostNumber: refreshPostNumber,
+ restoreOnlyOp: refreshOnlyOp,
+ restoreState: refreshState || undefined,
+ topicNavMetadata: sourceTopicNavMetadata,
+ historyNavigation,
+ });
+ } finally {
+ readerRefreshBtn.classList.remove('is-refreshing');
+ if (readerRefreshBtn.isConnected && readerSession.state === 'active') readerRefreshBtn.disabled = false;
+ }
+ };
+ onContext(readerRefreshBtn, 'click', onReaderRefreshClick);
+ onReaderCloseClick = () => close();
+ onContext(readerElement('.ldp-close'), 'click', onReaderCloseClick);
+ onContext(overlay, 'pointermove', onReaderSwitchPointerMove);
+ const markWheelScrollPrepared = (direction) => {
+ wheelPreparedDirection = direction;
+ if (wheelPreparationFrame) return;
+ wheelPreparationFrame = requestAnimationFrame(() => {
+ wheelPreparationFrame = 0;
+ wheelPreparedDirection = 0;
+ });
+ };
+ onOverlayClick = (e) => {
+ if (e.target !== overlay) return;
+ if (topicEditorState) {
+ topicEditorState.close();
+ return;
+ }
+ if (!popupCanSwitchTopic()) return;
+ const target = readerTopicTargetAt(e.clientX, e.clientY);
+ if (target) {
+ stopReaderSwitchTarget();
+ AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
+ hideNativeReaderTrigger();
+ openModal(target.route.topicId, {
+ targetPostNumber: topicUrlReaderStartPostNumber(target.route),
+ topicNavMetadata: readTopicNavMetadata(target.link),
+ });
+ return;
+ }
+ close();
+ };
+ onContext(overlay, 'click', onOverlayClick);
+ onModalWheel = (e) => {
+ const floatingSurface = e.target.closest?.('.ldp-topic-action-rail,.ldp-reader-action-layer:not(.ldp-topic-edit-layer)');
+ if (floatingSurface) {
+ containFloatingSurfaceWheel(floatingSurface, e);
+ return;
+ }
+ if (notificationsPopover.contains(e.target) || historyPanel.popover.contains(e.target) || bookmarksPanel.popover.contains(e.target)) return;
+ const topicEditLayer = e.target.closest?.('.ldp-topic-edit-layer');
+ if (topicEditLayer) {
+ e.preventDefault();
+ const categoryMenu = e.target.closest('.ldp-topic-edit-category-menu');
+ const categoryOptions = categoryMenu?.querySelector('.ldp-topic-edit-category-options');
+ if (categoryOptions && !categoryMenu.hidden) {
+ const categoryDelta = e.deltaMode === 1
+ ? e.deltaY * 40
+ : e.deltaMode === 2 ? e.deltaY * categoryOptions.clientHeight : e.deltaY;
+ categoryOptions.scrollTop += categoryDelta;
+ }
+ return;
+ }
+ if (e.ctrlKey) return;
+ if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
+ const delta = e.deltaMode === 1
+ ? e.deltaY * 40
+ : e.deltaMode === 2 ? e.deltaY * body.clientHeight : e.deltaY;
+ let scrollTarget = e.target instanceof Element ? e.target : null;
+ while (scrollTarget && scrollTarget !== body) {
+ const maxScrollTop = scrollTarget.scrollHeight - scrollTarget.clientHeight;
+ if (maxScrollTop > 1 && /(auto|scroll|overlay)/.test(getComputedStyle(scrollTarget).overflowY) &&
+ (delta < 0 ? scrollTarget.scrollTop > 0 : scrollTarget.scrollTop < maxScrollTop - 1)) return;
+ scrollTarget = scrollTarget.parentElement;
+ }
+ if (body.contains(e.target) && delta < 0) {
+ e.preventDefault();
+ body.scrollTop += delta;
+ }
+ if (delta < 0) {
+ ctx.streamScrollDirection = -1;
+ ctx.streamLastScrollAt = Date.now();
+ ctx.streamScrollVelocity = Math.max(+(ctx.streamScrollVelocity || 0), Math.abs(delta) / 16);
+ captureStreamViewportAnchor(ctx);
+ clearInitialTargetHeadSpacer(ctx);
+ ctx.releaseInitialPrecedingContent();
+ scheduleStreamWindowSync(ctx);
+ scheduleGapLoad(true);
+ } else if (delta > 0) {
+ ctx.streamScrollDirection = 1;
+ queueStreamViewportAnchorCapture(ctx);
+ }
+ if (delta) markWheelScrollPrepared(delta < 0 ? -1 : 1);
+ if (body.contains(e.target)) return;
+ e.preventDefault();
+ body.scrollTop += delta;
+ };
+ onContext(modal, 'wheel', onModalWheel, { passive: false });
+ onContext(document, 'pointerdown', onReaderEscapeExitInteraction, true);
+ onContext(document, 'scroll', onReaderEscapeExitInteraction, { capture: true, passive: true });
+ onContext(document, 'click', onNativeComposerSubmitClick, true);
+ onContext(document, 'keydown', onNativeComposerSubmitKeyDown, true);
+ onContext(document, 'click', onNativeComposerCloseClick, true);
+ onContext(document, 'keydown', onEsc, true);
+ onContext(overlay, 'pointermove', onReaderEscapePointerMove);
+
+ const sentinelVisible = () => body.scrollTop + body.clientHeight >= body.scrollHeight - 300;
+
+ const pump = async (options = {}) => {
+ if (loading || loadHalted || !sessionAcceptsWork()) return;
+ loading = true;
+ const maxBatches = options.maxBatches ?? 1;
+ const keepFilling = options.keepFilling === true;
+ const pumpOnlyOp = ctx.onlyOp;
+ const forceOnlyOpScan = options.forceOnlyOpScan === true && pumpOnlyOp;
+ if (pumpOnlyOp) onlyOpBatchStartedAt = Date.now();
+ let batches = 0;
+ let immediateRetries = 0;
+ let retryDelay = 180;
+ // 只要还没加载完,进入循环前先亮出底部提示
+ if (!done) {
+ endTip.classList.remove('show');
+ loadingTip.classList.add('show');
+ }
+ try {
+ while (!done && batches < maxBatches &&
+ (forceOnlyOpScan || sentinelVisible() || pendingRetry || (keepFilling && batches > 0))) {
+ pendingRetry = false;
+ const { posts, done: isDone, retry, fatal, error } = await loader.next({
+ onlyOp: pumpOnlyOp,
+ onlyOpUsername: pumpOnlyOp ? ctx.op : '',
+ persistFetchedPosts: options.persistFetchedPosts === true,
+ onSource: (source, counts) => syncLoadingState(source, counts),
+ });
+ if (!sessionAcceptsWork()) return;
+ if (posts.length) syncLoadingState('render');
+ await attachPostBatchCooperatively(posts, ctx, { acceptsWork: sessionAcceptsWork });
+ if (!sessionAcceptsWork()) return;
+ if (ctx.onlyOp) syncOnlyOpProgress();
+ if (fatal) {
+ loadHalted = true;
+ loadHaltError = error || null;
+ pendingRetry = false;
+ break;
+ }
+ if (retry) {
+ pendingRetry = true;
+ loadHaltError = error || null;
+ const status = Number(error?.status || 0);
+ const endpointRetryDelay = Math.max(0, Number(error?.retryAt || 0) - Date.now());
+ if (pumpOnlyOp) onlyOpRetryStreak++;
+ retryDelay = status === 429 || error && error.name === 'AbortError'
+ ? Math.max(250, endpointRetryDelay)
+ : status === 408 || status >= 500
+ ? 1000
+ : 400;
+ if (shouldStopReaderFallback(error) || immediateRetries >= 1) break;
+ immediateRetries++;
+ await waitForDelay(400);
+ continue;
+ }
+ loadHaltError = null;
+ onlyOpRetryStreak = 0;
+ onlyOpRetryAt = 0;
+ retryDelay = 180;
+ batches++;
+ if (ctx.onlyOp === pumpOnlyOp) done = isDone;
+ }
+ } catch (error) {
+ if (sessionAcceptsWork()) {
+ loadHaltError = error || new Error('请求流异常停止');
+ const transient = !!error && (
+ error.name === 'AbortError' ||
+ error.name === 'TypeError' ||
+ Number(error.status || 0) === 408 ||
+ Number(error.status || 0) === 429 ||
+ Number(error.status || 0) >= 500
+ );
+ if (transient) {
+ pendingRetry = true;
+ if (pumpOnlyOp) onlyOpRetryStreak++;
+ const endpointRetryDelay = Math.max(0, Number(error?.retryAt || 0) - Date.now());
+ retryDelay = Math.min(
+ endpointRetryDelay > 0 ? READER_ENDPOINT_429_MAX_BLOCK_MS : 15000,
+ Math.max(
+ endpointRetryDelay,
+ 1000 * Math.pow(2, Math.min(4, Math.max(0, onlyOpRetryStreak - 1)))
+ )
+ );
+ } else {
+ loadHalted = pumpOnlyOp;
+ pendingRetry = !pumpOnlyOp;
+ retryDelay = 1000;
+ console.warn(
+ `[LINUX DO reader] request pump ${pumpOnlyOp ? 'stopped' : 'will retry'} after unexpected error`,
+ error
+ );
+ }
+ }
+ } finally {
+ loading = false;
+ if (pumpOnlyOp) onlyOpBatchStartedAt = 0;
+ if (sessionAcceptsWork()) {
+ // 本轮抓取结束(无论成功、失败或无更多内容)都收起提示
+ loadingTip.classList.remove('show');
+ endTip.classList.toggle('show', done);
+ if (ctx.onlyOp) syncOnlyOpProgress();
+ if ((ctx.onlyOp || pendingRetry) && !done && !loadHalted && !filterPumpTimer) {
+ if (ctx.onlyOp) onlyOpRetryAt = Date.now() + retryDelay;
+ filterPumpTimer = setTimeout(() => {
+ filterPumpTimer = 0;
+ if (document.visibilityState !== 'visible') {
+ onlyOpRetryAt = Date.now();
+ return;
+ }
+ onlyOpRetryAt = 0;
+ if (sessionAcceptsWork())
+ pump({ maxBatches: 1, forceOnlyOpScan: ctx.onlyOp });
+ }, retryDelay);
+ } else if (done || loadHalted || !ctx.onlyOp)
+ onlyOpRetryAt = 0;
+ }
+ }
+ };
+
+ const hasStreamGap = (node, backward) => {
+ if (ctx.isPostVoting) return false;
+ const num = +(node && node.dataset.postNumber);
+ const misses = backward ? prevLoadMisses : nextLoadMisses;
+ if (!num || (backward && num <= 1) || gapMissBlocked(misses, num)) return false;
+ const item = ctx.streamItemMap.get(num);
+ const lastPostNumber = Math.max(1, ctx.totalPosts || 1);
+ if (ctx.onlyOp) {
+ const layoutItems = streamLayoutItems(ctx);
+ const boundary = layoutItems[backward ? 0 : layoutItems.length - 1];
+ const loaded = ctx.streamItems[backward ? 0 : ctx.streamItems.length - 1];
+ return item && item === boundary && loaded &&
+ (backward ? loaded.postNumber > 1 : loaded.postNumber < lastPostNumber);
+ }
+ const adjacentIndex = item ? item.index + (backward ? -1 : 1) : -1;
+ const adjacent = adjacentIndex >= 0 && adjacentIndex < ctx.streamItems.length
+ ? ctx.streamItems[adjacentIndex]
+ : null;
+ if (!adjacent) return backward || num < lastPostNumber;
+ return backward ? num - adjacent.postNumber > 1 : adjacent.postNumber - num > 1;
+ };
+
+ const visibleGapPost = (backward) => {
+ const items = streamLayoutItems(ctx);
+ const lookahead = scrollLoadPressure(ctx).lookahead;
+ const start = Math.max(ctx.streamWindowStart,
+ ctx.streamViewportTopIndex - (backward ? lookahead : 1));
+ const end = Math.min(ctx.streamWindowEnd,
+ ctx.streamViewportBottomIndex + (backward ? 2 : lookahead + 1));
+ for (let index = start; index < end; index++) {
+ const node = items[index]?.node;
+ if (!node || !node.isConnected || !hasStreamGap(node, backward)) continue;
+ return node;
+ }
+ return null;
+ };
+
+ const loadStreamGap = async (backward, preferredAnchor = null, options = {}) => {
+ const loadingGap = backward ? loadingPrev : loadingNext;
+ const failureUntil = backward ? previousGapFailureUntil : nextGapFailureUntil;
+ if (loadingGap || !sessionAcceptsWork() || Date.now() < failureUntil) return false;
+ const hasGap = (node) => hasStreamGap(node, backward);
+ const findAnchor = () => visibleGapPost(backward);
+ let anchor = preferredAnchor && (!backward || preferredAnchor.isConnected) && hasGap(preferredAnchor)
+ ? preferredAnchor
+ : findAnchor();
+ if (backward && !anchor && ctx.streamScrollDirection < 0 && !ctx.streamItemMap.has(1)) {
+ ctx.streamNeedsRepair = true;
+ syncStreamWindow(ctx);
+ anchor = findAnchor();
+ }
+ if (!anchor) return false;
+ if (backward) captureStreamViewportAnchor(ctx);
+ const maxBatches = Math.max(1, Math.min(2, +(options.maxBatches || 1)));
+ let anchorNum = +anchor.dataset.postNumber;
+ let loadedCount = 0;
+ let retryDelay = 0;
+ if (backward) loadingPrev = true;
+ else loadingNext = true;
+ try {
+ for (let batch = 0; batch < maxBatches; batch++) {
+ const directionChanged = backward ? ctx.streamScrollDirection > 0 : ctx.streamScrollDirection < 0;
+ if (batch > 0 && (directionChanged || scrollLoadPressure(ctx).batches < 2)) break;
+ const posts = await loader[backward ? 'loadBeforePost' : 'loadAfterPost'](anchorNum, {
+ persistFetchedPosts: options.persistFetchedPosts === true,
+ });
+ if (backward) previousGapFailureUntil = 0;
+ else nextGapFailureUntil = 0;
+ if (!sessionAcceptsWork()) return false;
+ if (backward && ctx.streamScrollDirection >= 0) return false;
+ const fresh = posts.filter((p) => p && !ctx.streamNodeMap.has(+p.post_number));
+ if (!fresh.length) {
+ (backward ? prevLoadMisses : nextLoadMisses).set(anchorNum, Date.now() + 5000);
+ if (backward) {
+ retryDelay = 5100;
+ previousGapRetryAt = Date.now() + retryDelay;
+ }
+ break;
+ }
+ const sorted = fresh.slice().sort((a, b) => (a.post_number || 0) - (b.post_number || 0));
+ if (backward) {
+ ctx.initialTargetHeadSpacerHeight = 0;
+ attachPostBatch(sorted, ctx, {
+ syncStreamNow: true,
+ preserveViewportAnchor: true,
+ });
+ previousGapRetryCount = 0;
+ previousGapRetryAt = 0;
+ } else attachPostBatch(sorted, ctx);
+ loadedCount += sorted.length;
+ const edgePost = sorted[backward ? 0 : sorted.length - 1];
+ const nextAnchorNum = +(edgePost?.post_number || 0);
+ const nextAnchor = ctx.streamNodeMap.get(nextAnchorNum);
+ const advanced = backward ? nextAnchorNum < anchorNum : nextAnchorNum > anchorNum;
+ if (!nextAnchorNum || !advanced || !nextAnchor || !hasGap(nextAnchor)) break;
+ anchorNum = nextAnchorNum;
+ }
+ return loadedCount > 0;
+ } catch {
+ if (backward) {
+ previousGapFailureUntil = Date.now() + 3000;
+ retryDelay = 3100;
+ previousGapRetryAt = Date.now() + retryDelay;
+ } else nextGapFailureUntil = Date.now() + 3000;
+ } finally {
+ if (backward) loadingPrev = false;
+ else loadingNext = false;
+ if (backward && retryDelay && ctx.streamScrollDirection < 0 && !ctx.streamItemMap.has(1) &&
+ previousGapRetryCount < 2) {
+ previousGapRetryCount++;
+ scheduleGapLoad(true, retryDelay);
+ }
+ }
+ return false;
+ };
+ const loadPreviousGap = (preferredAnchor = null, options = {}) =>
+ loadStreamGap(true, preferredAnchor, options);
+ const loadNextGap = (preferredAnchor = null, options = {}) =>
+ loadStreamGap(false, preferredAnchor, options);
+
+ const scheduleGapLoad = (goingUp, minimumDelay = 0) => {
+ const direction = goingUp ? -1 : 1;
+ const pressure = scrollLoadPressure(ctx);
+ const directionChanged = !!pendingGapDirection && pendingGapDirection !== direction;
+ pendingGapDirection = direction;
+ pendingGapBatches = Math.max(directionChanged ? 1 : pendingGapBatches, pressure.batches);
+ const previousRetryDelay = direction < 0 ? Math.max(0, previousGapRetryAt - Date.now()) : 0;
+ const dueAt = Date.now() + Math.max(previousRetryDelay, Math.max(0, +minimumDelay || 0));
+ if (gapLoadTimer && !directionChanged && dueAt >= gapLoadDueAt) return;
+ if (gapLoadTimer) clearTimeout(gapLoadTimer);
+ gapLoadDueAt = dueAt;
+ gapLoadTimer = setTimeout(() => {
+ gapLoadTimer = 0;
+ gapLoadDueAt = 0;
+ if (!sessionAcceptsWork() || document.visibilityState !== 'visible') return;
+ const nextDirection = pendingGapDirection;
+ const maxBatches = pendingGapBatches;
+ pendingGapDirection = 0;
+ pendingGapBatches = 1;
+ if (nextDirection < 0) loadPreviousGap(null, { maxBatches });
+ else if (nextDirection > 0) {
+ if (loading || sentinelVisible()) return;
+ loadNextGap(null, { maxBatches });
+ }
+ }, Math.max(0, dueAt - Date.now()));
+ };
+
+ ctx.loadPost = async (postNumber, options = {}) => {
+ if (!sessionAcceptsWork()) return false;
+ postNumber = +postNumber;
+ if (!postNumber) return false;
+ const around = options.scope === 'around';
+ const shouldMount = options.mount !== false;
+ const ensureAround = options.ensureAround === true && postNumber > 1;
+ const targetAlreadyKnown = ctx.streamNodeMap.has(postNumber);
+ if (targetAlreadyKnown && options.forceRefresh !== true && (!around || !ensureAround))
+ return shouldMount ? !!getStreamPostNode(ctx, postNumber, { mount: true }) : true;
+ let posts = [];
+ try {
+ posts = await loader.loadPost(postNumber, {
+ onSource: options.onSource,
+ priority: Number.isFinite(options.priority) ? options.priority
+ : around ? POST_REQUEST_PRIORITY.target : undefined,
+ scope: around ? 'around' : 'single',
+ forceRefresh: options.forceRefresh === true,
+ });
+ } catch (error) {
+ if (!around || !targetAlreadyKnown || options.forceRefresh === true) throw error;
+ }
+ if (!sessionAcceptsWork()) return false;
+ const fresh = around
+ ? posts.slice().sort((a, b) => (a.post_number || 0) - (b.post_number || 0))
+ : posts.filter((p) => p && !ctx.streamNodeMap.has(+p.post_number));
+ attachPostBatch(fresh, ctx);
+ return shouldMount
+ ? !!getStreamPostNode(ctx, postNumber, { mount: true })
+ : ctx.streamNodeMap.has(postNumber);
+ };
+ const nearestAvailablePost = (postNumber) => {
+ const target = +postNumber || 0;
+ if (!target) return null;
+ return loader.cachedPosts.reduce((nearest, candidate) => {
+ const current = +(candidate?.post_number || 0);
+ if (!current) return nearest;
+ if (!nearest) return candidate;
+ const nearestNumber = +(nearest.post_number || 0);
+ const currentDistance = Math.abs(current - target);
+ const nearestDistance = Math.abs(nearestNumber - target);
+ if (currentDistance < nearestDistance) return candidate;
+ if (currentDistance === nearestDistance && current > target && nearestNumber < target) return candidate;
+ return nearest;
+ }, null);
+ };
+
+ const syncOnlyOpButton = () => {
+ onlyOpBtn.classList.toggle('active', ctx.onlyOp);
+ setPressed(onlyOpBtn, ctx.onlyOp);
+ setLabel(onlyOpBtn, ctx.onlyOp ? '显示全部楼层' : '只看楼主');
+ };
+ onOnlyOpToggle = async () => {
+ if (!sessionAcceptsWork() || onlyOpBtn.disabled || !ctx.op) return;
+ clearStreamViewportAnchor(ctx);
+ ctx.onlyOp = !ctx.onlyOp;
+ pendingRetry = false;
+ done = ctx.onlyOp ? loader.scanDone : loader.loadDone;
+ if (!ctx.onlyOp && filterPumpTimer) {
+ clearTimeout(filterPumpTimer);
+ filterPumpTimer = 0;
+ }
+ if (!ctx.onlyOp) {
+ onlyOpBatchStartedAt = 0;
+ onlyOpRetryAt = 0;
+ onlyOpRetryStreak = 0;
+ }
+ syncOnlyOpButton();
+ invalidateStreamLayout(ctx);
+ if (ctx.onlyOp) startOnlyOpProgress();
+ else syncOnlyOpProgress();
+ ctx.streamWindowResetPending = true;
+ ctx.streamMountedItemCount = -1;
+ const previousScrollTop = body.scrollTop;
+ body.scrollTop = 0;
+ markStreamInternalScroll(ctx, previousScrollTop);
+ if (ctx.onlyOp && !ctx.streamItemMap.has(1)) {
+ await ctx.loadPost(1, { priority: POST_REQUEST_PRIORITY.target });
+ if (!sessionAcceptsWork()) return;
+ invalidateStreamLayout(ctx);
+ }
+ flushStreamWindow(ctx);
+ ctx.timeline.update();
+ if (ctx.onlyOp) pump({ maxBatches: 1, forceOnlyOpScan: true });
+ };
+ onContext(onlyOpBtn, 'click', onOnlyOpToggle);
+
+ try {
+ setReaderOpenPhase('load-topic');
+ const topic = await initialTopicRequest;
+ if (!sessionAcceptsWork()) return;
+ setReaderOpenPhase('initialize-topic');
+ ctx.topicData = topic;
+ tracker.preload(postsFromPayload(topic));
+ ctx.topicSharedIssueHostVisible = !loader.initializedFromCache && nativeTopicSharedIssueVisible(topic);
+ postsFromPayload(topic).forEach((post) => rememberPostData(ctx, post));
+ ctx.isPostVoting = !!topic.is_post_voting;
+ ctx.topicBookmarked = !!topic.bookmarked;
+ ctx.topicNotificationLevel = topicNotificationLevel(topic);
+ ctx.latestReplyAt = String(topic.last_posted_at || '');
+ ctx.latestReplyTimestamp = Date.parse(ctx.latestReplyAt) || 0;
+ ctx.op = topic._opUsername;
+ onlyOpBtn.disabled = !ctx.op;
+ syncOnlyOpButton();
+ ctx.totalPosts = Math.max(0, topic.highest_post_number || 0, topic.posts_count || 0);
+ renderTopicShell(topic);
+ ctx.syncTopicActionRail();
+ updateCommentsHeader(ctx);
+ syncTopicActionControls(ctx);
+ syncCurrentUserFromHost();
+ if (sessionAcceptsWork()) {
+ readerShell.startNotificationLiveEvents?.();
+ syncTopicActionControls(ctx);
+ syncAllPostActionControls(ctx);
+ readerElements('.ldp-post').forEach((postNode) => {
+ renderPostSpecialContent(postNode, ctx);
+ if (postNode.querySelector(':scope > .ldp-boost-list:not(.ldp-boost-pending)'))
+ syncRenderedBoost(postNode, postNode._ldpPostData || {}, ctx);
+ });
+ }
+ if (sessionAcceptsWork()) startLatestReplyUpdates();
+ const titleBtn = readerElement('.ldp-title-jump');
+ let cancelInitialTargetJump = false;
+ ctx.cancelInitialTargetPosition = () => {
+ cancelInitialTargetJump = true;
+ ctx.initialTargetPositionPending = false;
+ clearInitialTargetHeadSpacer(ctx);
+ ctx.releaseInitialPrecedingContent();
+ };
+ onTitleJump = async () => {
+ if (!sessionAcceptsWork()) return;
+ ctx.replyThreadWindow?._ldpClose?.(false);
+ await ctx.timeline.jumpToStart();
+ };
+ onContext(titleBtn, 'click', onTitleJump);
+ onTitleJumpKeyDown = (event) => {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ event.preventDefault();
+ titleBtn.click();
+ };
+ onContext(titleBtn, 'keydown', onTitleJumpKeyDown);
+ onTimelineTopClick = async () => {
+ if (!sessionAcceptsWork()) return;
+ await ctx.timeline.jumpToStart();
+ };
+ onContext(timelineEl.querySelector('.ldp-topic-timeline-top'), 'click', onTimelineTopClick);
+
+ const openBtn = readerElement('.ldp-open');
+ openBtn.href = topicPageUrl(topic.id, 0, true) ||
+ `${BASE}/t/${topic.id}?${NATIVE_BYPASS_PARAM}=1`;
+ openBtn.hidden = false;
+ setLabel(openBtn, '打开原帖');
+ onOpenNativePost = (event) => {
+ if (event.type === 'click' && event.button && event.button !== 0) return;
+ if (event.type === 'auxclick' && event.button !== 1) return;
+ if (!openNativeTopicTab(openBtn.href)) return;
+ consumeEvent(event);
+ };
+ onContext(openBtn, 'click', onOpenNativePost);
+ onContext(openBtn, 'auxclick', onOpenNativePost);
+ ctx.topicBookmarked = !!topic.bookmarked;
+ syncTopicActionControls(ctx);
+ ctx.toggleTopicBookmark = () => toggleReaderBookmark(topic, ctx, 'Topic');
+ stopTopicPresence = bindReaderTopicPresence(ctx);
+
+ destroyActions = bindActions(modal, ctx);
+ guardNativeReaderTimings();
+ tracker.start();
+
+ let lastScrollTop = body.scrollTop;
+ let lastScrollAt = Date.now();
+ ctx.streamInternalScrollTop = NaN;
+ ctx.streamInternalScrollDelta = 0;
+ onBodyScroll = () => {
+ if (!sessionAcceptsWork()) return;
+ const now = Date.now();
+ const nextScrollTop = body.scrollTop;
+ const rawDelta = nextScrollTop - lastScrollTop;
+ const elapsed = Math.max(16, now - lastScrollAt);
+ lastScrollTop = nextScrollTop;
+ lastScrollAt = now;
+ const expectedInternalScrollTop = ctx.streamInternalScrollTop;
+ const internalScrollDelta = Number.isFinite(ctx.streamInternalScrollDelta)
+ ? ctx.streamInternalScrollDelta
+ : 0;
+ ctx.streamInternalScrollTop = NaN;
+ ctx.streamInternalScrollDelta = 0;
+ // Anchor compensation and a wheel step can share one scroll event.
+ // Remove only the compensation so windowing still sees the user's residual movement.
+ let delta = rawDelta - internalScrollDelta;
+ if (Math.abs(delta) < 1) delta = 0;
+ const hasInternalScroll = Math.abs(internalScrollDelta) >= 0.01 ||
+ (Number.isFinite(expectedInternalScrollTop) &&
+ Math.abs(nextScrollTop - expectedInternalScrollTop) < 1);
+ if (hasInternalScroll && !delta) {
+ scheduleStreamWindowSync(ctx);
+ return;
+ }
+ if (ctx.initialTargetPositionPending) {
+ scheduleStreamWindowSync(ctx);
+ return;
+ }
+ if (delta && readerSession.state === 'active') ctx.viewportPersistenceEnabled = true;
+ const deltaDirection = delta < 0 ? -1 : delta > 0 ? 1 : 0;
+ const wheelAlreadyPrepared = !!deltaDirection && wheelPreparedDirection === deltaDirection;
+ if (wheelAlreadyPrepared) {
+ wheelPreparedDirection = 0;
+ if (wheelPreparationFrame) cancelAnimationFrame(wheelPreparationFrame);
+ wheelPreparationFrame = 0;
+ }
+ if (deltaDirection) {
+ ctx.streamScrollDirection = deltaDirection;
+ if (!wheelAlreadyPrepared) queueStreamViewportAnchorCapture(ctx);
+ if (deltaDirection < 0) {
+ if (!wheelAlreadyPrepared) {
+ clearInitialTargetHeadSpacer(ctx);
+ ctx.releaseInitialPrecedingContent();
+ }
+ } else {
+ previousGapRetryCount = 0;
+ }
+ }
+ const followsUserIntent = !deltaDirection || deltaDirection === ctx.streamScrollDirection;
+ pendingGoingUp = ctx.streamScrollDirection < 0;
+ if (followsUserIntent) {
+ const instantVelocity = Math.abs(delta) / elapsed;
+ ctx.streamScrollVelocity = ctx.streamScrollVelocity * 0.55 + instantVelocity * 0.45;
+ }
+ ctx.streamLastScrollAt = now;
+ scheduleStreamWindowSync(ctx);
+ if (scrollWorkFrame) return;
+ scrollWorkFrame = requestAnimationFrame(() => {
+ scrollWorkFrame = 0;
+ if (!sessionAcceptsWork()) return;
+ if (pendingLiveReplyCount > 0 &&
+ body.scrollHeight - body.scrollTop - body.clientHeight <= 80)
+ clearLiveUpdateButton();
+ if (body.scrollTop <= 10 && !ctx.streamItemMap.has(1)) {
+ pendingGapDirection = 0;
+ pendingGapBatches = 1;
+ if (gapLoadTimer) {
+ clearTimeout(gapLoadTimer);
+ gapLoadTimer = 0;
+ gapLoadDueAt = 0;
+ }
+ loadPreviousGap(null, {
+ maxBatches: scrollLoadPressure(ctx).batches,
+ });
+ } else {
+ scheduleGapLoad(pendingGoingUp);
+ }
+ if (sentinelVisible()) {
+ const pressure = scrollLoadPressure(ctx);
+ pump({
+ maxBatches: pressure.batches,
+ keepFilling: pressure.batches > 1,
+ });
+ }
+ });
+ };
+ onContext(body, 'scroll', onBodyScroll, { passive: true });
+ onBodyTouchStart = () => {
+ if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
+ ctx.streamScrollDirection = 0;
+ captureStreamViewportAnchor(ctx);
+ ctx.releaseInitialPrecedingContent();
+ };
+ onContext(body, 'touchstart', onBodyTouchStart, { passive: true });
+ onReaderScrollKeyDown = (event) => {
+ if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) {
+ if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
+ ctx.streamScrollDirection = -1;
+ captureStreamViewportAnchor(ctx);
+ markWheelScrollPrepared(-1);
+ clearInitialTargetHeadSpacer(ctx);
+ ctx.releaseInitialPrecedingContent();
+ scheduleGapLoad(true);
+ } else if (['ArrowDown', 'PageDown', 'End'].includes(event.key) || (event.key === ' ' && !event.shiftKey)) {
+ if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
+ ctx.streamScrollDirection = 1;
+ captureStreamViewportAnchor(ctx);
+ markWheelScrollPrepared(1);
+ }
+ };
+ onContext(overlay, 'keydown', onReaderScrollKeyDown);
+
+ setReaderOpenPhase('load-target-post');
+ let targetLoaded = false;
+ const syncTargetLoadingSource = (source) => syncLoadingState(source, { target: true });
+ targetLoaded = await ctx.loadPost(Math.max(1, targetPostNumber), {
+ priority: POST_REQUEST_PRIORITY.target,
+ onSource: syncTargetLoadingSource,
+ });
+ if (targetPostNumber > 1 && !targetLoaded) {
+ targetLoaded = await ctx.loadPost(targetPostNumber, {
+ onSource: syncTargetLoadingSource,
+ scope: 'around',
+ });
+ }
+ if (targetPostNumber > 1 && !targetLoaded && !cancelInitialTargetJump) {
+ targetLoaded = await ctx.loadPost(targetPostNumber, {
+ priority: POST_REQUEST_PRIORITY.target,
+ forceRefresh: true,
+ onSource: syncTargetLoadingSource,
+ });
+ }
+ if (refreshTargetBoosts && targetPostNumber > 0 && !cancelInitialTargetJump) {
+ setReaderOpenPhase('load-target-boosts');
+ targetLoaded = await ctx.loadPost(targetPostNumber, {
+ ensureAround: true,
+ priority: POST_REQUEST_PRIORITY.target,
+ forceRefresh: true,
+ onSource: syncTargetLoadingSource,
+ scope: 'around',
+ }) || targetLoaded;
+ if (!sessionAcceptsWork()) return;
+ const targetPostNode = ctx.streamNodeMap.get(targetPostNumber);
+ if (targetPostNode) {
+ syncRenderedBoost(targetPostNode, targetPostNode._ldpPostData || {}, ctx);
+ }
+ }
+ if (targetPostNumber > 1 && !targetLoaded && !cancelInitialTargetJump) {
+ const fallbackPost = nearestAvailablePost(targetPostNumber);
+ const fallbackPostNumber = +(fallbackPost?.post_number || 0);
+ if (fallbackPostNumber) {
+ unavailableTargetPostNumber = targetPostNumber;
+ attachPostBatch([fallbackPost], ctx);
+ targetPostNumber = fallbackPostNumber;
+ targetLoaded = !!getStreamPostNode(ctx, targetPostNumber, { mount: true });
+ overlay.dataset.targetPostNumber = String(targetPostNumber);
+ readerShell.targetPostNumber = targetPostNumber;
+ ctx.initialTargetHydrationBoundary = targetPostNumber;
+ if (restoreViewport && +restoreViewport.postNumber === unavailableTargetPostNumber) {
+ restoreViewport = { ...restoreViewport, postNumber: targetPostNumber, postOffset: 0 };
+ }
+ }
+ if (!targetLoaded) {
+ throw new Error(`目标楼层 #${targetPostNumber} 加载失败,请重试`);
+ }
+ }
+ if (targetPostNumber > 1 && targetLoaded && !cancelInitialTargetJump && !refreshTargetBoosts) {
+ setReaderOpenPhase('load-target-neighborhood');
+ await ctx.loadPost(targetPostNumber, {
+ ensureAround: true,
+ priority: POST_REQUEST_PRIORITY.visible,
+ onSource: syncTargetLoadingSource,
+ scope: 'around',
+ });
+ if (!sessionAcceptsWork()) return;
+ }
+ const targetPostNode = ctx.streamNodeMap.get(targetPostNumber);
+ aggregateTargetPostNumber = targetPostNumber > 1 &&
+ +(targetPostNode?._ldpPostData?.reply_to_post_number || 0) > 0
+ ? targetPostNumber : 0;
+ if (targetPostNumber <= 1 && !targetLoaded) {
+ await pump({ maxBatches: 1, persistFetchedPosts: true });
+ }
+ // The batch path normally mounts on the next animation frame. Flush the
+ // first window before removing the mask so opening never exposes a blank frame.
+ setReaderOpenPhase('mount-initial-stream');
+ syncLoadingState('render');
+ if (targetPostNumber > 1 && targetLoaded && !cancelInitialTargetJump) {
+ ctx.releaseInitialPrecedingContent();
+ hydrateStreamNavigationNeighborhood(ctx, targetPostNumber);
+ }
+ flushStreamWindow(ctx);
+ let initialQuoteVisualTarget = null;
+ const resolveInitialQuoteVisualTarget = initialQuoteHighlight && targetPostNumber
+ ? () => {
+ initialQuoteVisualTarget = applyReaderQuoteHighlight(
+ targetPostNumber,
+ ctx,
+ initialQuoteHighlight.text,
+ {
+ source: initialQuoteHighlight.source,
+ active: initialQuoteHighlight.active,
+ },
+ );
+ return initialQuoteVisualTarget;
+ }
+ : null;
+ const initialStartPostNumber = Math.max(1, +targetPostNumber || 1);
+ if (targetPostNumber > 1) {
+ if (targetLoaded && !cancelInitialTargetJump && !aggregateTargetPostNumber) {
+ const alignInitialTargetBottom = !initialQuoteHighlight && +targetPostNumber === +(ctx.totalPosts || 0);
+ const initialTargetScrollOptions = {
+ ...PINNED_STREAM_SCROLL_OPTIONS,
+ resolveVisualTarget: resolveInitialQuoteVisualTarget,
+ alignNearest: !!initialQuoteHighlight,
+ alignBottom: alignInitialTargetBottom,
+ alignTop: !alignInitialTargetBottom,
+ };
+ if (!scrollToPost(targetPostNumber, ctx, initialTargetScrollOptions)) {
+ throw new Error(`目标楼层 #${targetPostNumber} 定位失败,请重试`);
+ }
+ captureStreamViewportAnchor(ctx);
+ }
+ overlay.classList.remove('ldp-initial-target-layout');
+ ctx.initialTargetPositionPending = false;
+ } else {
+ ctx.initialTargetPositionPending = false;
+ if (!scrollToPost(initialStartPostNumber, ctx, {
+ ...PINNED_STREAM_SCROLL_OPTIONS,
+ resolveVisualTarget: resolveInitialQuoteVisualTarget,
+ alignNearest: !!initialQuoteHighlight,
+ alignTop: true,
+ highlight: !!initialQuoteHighlight,
+ })) {
+ throw new Error(`目标楼层 #${initialStartPostNumber} 定位失败,请重试`);
+ }
+ }
+ if (targetLoaded && (targetPostNumber > 1 || ctx.totalPosts > initialStartPostNumber)) {
+ cancelInitialNeighborPrefetch = scheduleIdleWork(async () => {
+ cancelInitialNeighborPrefetch = null;
+ if (!sessionAcceptsWork()) return;
+ const startNode = ctx.streamNodeMap.get(initialStartPostNumber);
+ if (!startNode || !startNode.isConnected) return;
+ await loadNextGap(startNode);
+ }, 1400);
+ }
+ if (!sessionAcceptsWork()) return;
+ if (restoreOnlyOp && !ctx.onlyOp && ctx.op) await onOnlyOpToggle();
+ if (!sessionAcceptsWork()) return;
+ if (restoreViewport && !aggregateTargetPostNumber) {
+ const restoredPostNumber = Math.max(1, Number(restoreViewport.postNumber) || targetPostNumber || 1);
+ const restoredPost = getStreamPostNode(ctx, restoredPostNumber, { mount: true });
+ const restoredPostOffset = stableReaderViewportOffset(restoredPost, ctx, restoreViewport.postOffset);
+ const restored = !!restoredPost && scrollToPost(restoredPostNumber, ctx, {
+ ...PINNED_STREAM_SCROLL_OPTIONS,
+ visualNode: restoredPost,
+ alignOffset: restoredPostOffset,
+ highlight: false,
+ });
+ if (!restored) {
+ throw new Error(`历史位置 #${restoredPostNumber} 恢复失败,请重试`);
+ }
+ flushStreamWindow(ctx);
+ captureStreamViewportAnchor(ctx);
+ }
+ if (!sessionAcceptsWork()) return;
+ if (aggregateTargetPostNumber && !await ctx.timeline.jumpTo(aggregateTargetPostNumber, {
+ targetPost: targetPostNode?._ldpPostData,
+ })) {
+ throw new Error(`目标楼层 #${aggregateTargetPostNumber} 完整讨论定位失败,请重试`);
+ }
+ if (!sessionAcceptsWork()) return;
+ if (settleTargetJump && !aggregateTargetPostNumber) {
+ await waitForReaderInitialLayout(overlay);
+ if (!sessionAcceptsWork()) return;
+ ctx.timeline.sync();
+ const settledTargetPostNumber = restoreViewport
+ ? Math.max(1, Number(restoreViewport.postNumber) || targetPostNumber || 1)
+ : targetPostNumber;
+ let settledTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected
+ ? initialQuoteVisualTarget
+ : getStreamPostNode(ctx, settledTargetPostNumber);
+ if (settledTargetPostNumber > 0 && !readerTargetNodeIsVisible(settledTarget, ctx)) {
+ const retryPost = getStreamPostNode(ctx, settledTargetPostNumber, { mount: true });
+ const retryVisualTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected
+ ? initialQuoteVisualTarget
+ : retryPost;
+ const retryRestoreOffset = restoreViewport
+ ? stableReaderViewportOffset(retryVisualTarget, ctx, restoreViewport.postOffset)
+ : NaN;
+ const retryAlignBottom = !restoreViewport && !initialQuoteHighlight &&
+ +settledTargetPostNumber === +(ctx.totalPosts || 0);
+ if (retryPost && scrollToPost(settledTargetPostNumber, ctx, {
+ ...PINNED_STREAM_SCROLL_OPTIONS,
+ visualNode: retryVisualTarget,
+ resolveVisualTarget: !restoreViewport ? resolveInitialQuoteVisualTarget : null,
+ alignOffset: retryRestoreOffset,
+ alignNearest: !restoreViewport && !!initialQuoteHighlight,
+ alignBottom: retryAlignBottom,
+ alignTop: !restoreViewport && !initialQuoteHighlight && !retryAlignBottom,
+ highlight: false,
+ })) {
+ captureStreamViewportAnchor(ctx);
+ await waitForReaderJumpLayout();
+ if (!sessionAcceptsWork()) return;
+ ctx.timeline.sync();
+ settledTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected
+ ? initialQuoteVisualTarget
+ : getStreamPostNode(ctx, settledTargetPostNumber);
+ }
+ }
+ if (settledTargetPostNumber > 0 && !readerTargetNodeIsVisible(settledTarget, ctx)) {
+ throw new Error(`目标楼层 #${settledTargetPostNumber} 布局稳定校验失败,请重试`);
+ }
+ }
+ setReaderOpenPhase('activate-reader');
+ readerSession.state = 'active';
+ if (previousQueueTopicId > 0 && previousQueueTopicId !== Number(topicId)) {
+ autoDequeueReaderQueueEntry(previousQueueTopicId);
+ }
+ readerRefreshBtn.disabled = false;
+ if (initialQuoteSourceTarget) await returnToReaderQuoteSource(initialQuoteSourceTarget, ctx);
+ ctx.timeline.update();
+ removeOwnedNode(maskEl);
+ if (restoreState?.replyWindow && sessionAcceptsWork()) {
+ try {
+ ctx.replyThreadWindow?._ldpClose?.(false);
+ await openAggregateReplyTarget(restoreState.replyWindow.rootPostNumber, ctx, {
+ returnViewport: restoreViewport,
+ restorePoint: restoreState.replyWindow.point,
+ });
+ } catch (error) {
+ console.warn('[LDP] reader discussion restore failed', error);
+ }
+ }
+ if (unavailableTargetPostNumber) {
+ showSelectionToast(`楼层 #${unavailableTargetPostNumber} 已不可用,已跳到相邻楼层 #${targetPostNumber}`);
+ }
+ if (sessionAcceptsWork()) rememberTopic(topic, targetPostNumber || 1, queueEntry?.seenPostNumbers);
+ } catch (err) {
+ if (disposed || openRequestId !== READER_OPEN_REQUEST_SEQUENCE ||
+ !readerSession.isCurrent('opening') || !overlay.isConnected) return;
+ const failedPhase = readerOpenPhase;
+ const canAutoRetry = readerOpenFailureCanAutoRetry(err, failedPhase);
+ if (canAutoRetry) {
+ const nextAutoRetryAttempt = autoRetryAttempt + 1;
+ const retryDelay = readerOpenAutoRetryDelay(nextAutoRetryAttempt, err);
+ console.warn('[LDP] reader open interrupted; retrying automatically', {
+ phase: failedPhase,
+ topicId: String(topicId || ''),
+ targetPostNumber,
+ sessionId: readerSessionId,
+ attempt: nextAutoRetryAttempt,
+ retryDelay,
+ });
+ readerSession.state = 'retrying';
+ overlay.classList.remove('ldp-initial-target-layout');
+ ctx.initialTargetPositionPending = false;
+ readerRefreshBtn.disabled = true;
+ stopReaderRuntime({ preserveShell: true });
+ if (loadingStatus) loadingStatus.textContent = '载入暂时中断,正在自动重试';
+ if (loadingDetail) {
+ loadingDetail.textContent = `第 ${nextAutoRetryAttempt} 次恢复将在 ${Math.max(1, Math.ceil(retryDelay / 1000))} 秒内开始…`;
+ }
+ if (loadingStage) {
+ setLabel(loadingStage, `载入暂时中断,第 ${nextAutoRetryAttempt} 次自动恢复即将开始`);
+ }
+ clearFailureRetryTimer();
+ const retryCloseButton = readerElement('.ldp-close');
+ const cancelAutoRetryWait = () => {
+ clearFailureRetryTimer();
+ retryCloseButton?.removeEventListener('click', closeWhileAutoRetry);
+ if (overlay._ldpCancelAutoRetryWait === cancelAutoRetryWait)
+ delete overlay._ldpCancelAutoRetryWait;
+ };
+ const closeWhileAutoRetry = () => {
+ cancelAutoRetryWait();
+ close();
+ };
+ overlay._ldpCancelAutoRetryWait = cancelAutoRetryWait;
+ retryCloseButton?.addEventListener('click', closeWhileAutoRetry);
+ failureRetryTimer = setTimeout(() => {
+ cancelAutoRetryWait();
+ if (disposed || !readerSession.isCurrent('retrying') || !overlay.isConnected) return;
+ Promise.resolve(openModal(topicId, {
+ targetPostNumber,
+ topicNavMetadata: sourceTopicNavMetadata,
+ restoreViewport,
+ restoreState: restoreState || undefined,
+ restoreOnlyOp,
+ historyNavigation,
+ settleTargetJump,
+ quoteHighlight: initialQuoteHighlight,
+ quoteSourceTarget: initialQuoteSourceTarget,
+ refreshTargetBoosts,
+ autoRetryAttempt: nextAutoRetryAttempt,
+ })).catch((retryError) => {
+ console.warn('[LDP] reader automatic retry setup failed', retryError);
+ });
+ }, retryDelay);
+ return;
+ }
+ console.error('[LDP] reader open failed', {
+ phase: failedPhase,
+ topicId: String(topicId || ''),
+ targetPostNumber,
+ sessionId: readerSessionId,
+ }, err);
+ readerSession.state = 'failed';
+ overlay.classList.remove('ldp-initial-target-layout');
+ ctx.initialTargetPositionPending = false;
+ const endpointRateLimitedFailure = Number(err?.status || 0) === 429 && err && err.rateLimitScope === 'endpoint';
+ const rateLimitedFailure = Number(err?.status || 0) === 429 && !endpointRateLimitedFailure;
+ stopReaderRuntime({ preserveShell: true });
+ removeOwnedNode(maskEl);
+ const errorEl = makeElement('div');
+ errorEl.className = 'ldp-error';
+ errorEl.setAttribute('role', 'alert');
+ errorEl.innerHTML = `
+
+
`;
+ errorEl.querySelector('.ldp-error-message').textContent = rateLimitedFailure
+ ? '请求过于频繁,阅读器已暂停加载。倒计时结束后将自动打开过盾浮窗,验证通过后浮窗会自动关闭。'
+ : endpointRateLimitedFailure
+ ? '当前请求地址暂时返回 429,已只暂停该地址;其他阅读请求不受影响,请稍后重试。'
+ : `加载失败(${failedPhase}):${err?.message ? err.message : '未知错误'}`;
+ body.replaceChildren(errorEl);
+ const retryButton = errorEl.querySelector('.ldp-error-retry');
+ const errorChallenge = errorEl.querySelector('.ldp-error-challenge');
+ let retryOpening = false;
+ let challengeAutoOpened = false;
+ const syncRetryState = () => {
+ clearFailureRetryTimer();
+ if (!overlay.isConnected || readerSession.state !== 'failed') return;
+ const remaining = rateLimitCooldownRemaining();
+ retryButton.disabled = retryOpening || remaining > 0;
+ retryButton.textContent = retryOpening
+ ? '正在重新加载…'
+ : remaining > 0
+ ? `限流冷却中(${Math.ceil(remaining / 1000)} 秒)`
+ : '重新加载';
+ if (remaining > 0) {
+ failureRetryTimer = setTimeout(syncRetryState, Math.min(1000, remaining + 20));
+ } else if (rateLimitedFailure && !challengeAutoOpened && errorChallenge) {
+ challengeAutoOpened = true;
+ openCloudflareChallengePopup(errorChallenge.href);
+ }
+ };
+ errorEl.addEventListener('click', (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ if (target?.closest('.ldp-error-close')) {
+ onReaderCloseClick();
+ return;
+ }
+ if (!target?.closest('.ldp-error-retry')) return;
+ if (retryButton.disabled || retryOpening) return;
+ retryOpening = true;
+ syncRetryState();
+ Promise.resolve(openModal(topicId, {
+ targetPostNumber,
+ topicNavMetadata: sourceTopicNavMetadata,
+ restoreViewport,
+ restoreState: restoreState || undefined,
+ historyNavigation,
+ settleTargetJump,
+ refreshTargetBoosts,
+ })).catch((retryError) => {
+ console.error('[LDP] reader retry failed', retryError);
+ retryOpening = false;
+ syncRetryState();
+ });
+ });
+ syncRetryState();
+ }
+ }
+
+ /* ============ 14. 接管帖子路由 ============ */
+ function shouldBypassReaderUrl(url) {
+ let u;
+ try { u = new URL(url, location.href); } catch { return false; }
+ return u.searchParams.has(NATIVE_BYPASS_PARAM);
+ }
+
+ function consumeNativeBypassTabSignal() {
+ try {
+ const bypass = sessionStorage.getItem(NATIVE_BYPASS_TAB_KEY) === '1';
+ if (bypass) sessionStorage.removeItem(NATIVE_BYPASS_TAB_KEY);
+ return bypass;
+ } catch {
+ return false;
+ }
+ }
+
+ function openNativeTopicTab(url) {
+ let tab = null;
+ try { tab = window.open('about:blank', '_blank'); } catch {}
+ if (!tab) return false;
+ try { tab.sessionStorage.setItem(NATIVE_BYPASS_TAB_KEY, '1'); } catch {}
+ try { tab.opener = null; } catch {}
+ try { tab.location.replace(url); } catch { tab.location.href = url; }
+ return true;
+ }
+
+ let cloudflareChallengePopup = null;
+ let cloudflareChallengePopupTimer = 0;
+ let cloudflareChallengePopupState = null;
+ let cloudflareChallengeLeaseHeartbeatAt = 0;
+ const cloudflareChallengeInstanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
+
+ function readCloudflareChallengeLease() {
+ try {
+ const lease = JSON.parse(localStorage.getItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY) || 'null');
+ return lease && typeof lease === 'object' ? lease : null;
+ } catch {
+ return null;
+ }
+ }
+
+ function writeCloudflareChallengeLease(state, ttl = LDP_CLOUDFLARE_CHALLENGE_LEASE_TTL) {
+ const at = Date.now();
+ if (state === 'active') cloudflareChallengeLeaseHeartbeatAt = at;
+ const lease = {
+ owner: cloudflareChallengeInstanceId,
+ state,
+ updatedAt: at,
+ expiresAt: at + ttl,
+ };
+ try { localStorage.setItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY, JSON.stringify(lease)); } catch {}
+ return lease;
+ }
+
+ function releaseCloudflareChallengeLease() {
+ const lease = readCloudflareChallengeLease();
+ if (!lease || lease.owner !== cloudflareChallengeInstanceId) return;
+ try { localStorage.removeItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY); } catch {}
+ }
+
+ function onCloudflareChallengeLeaseChange(event) {
+ if (event.key !== LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY || !event.newValue) return;
+ let lease = null;
+ try { lease = JSON.parse(event.newValue); } catch {}
+ if (!lease || lease.owner === cloudflareChallengeInstanceId || lease.state !== 'passed') return;
+ const resetRate = resetReaderRateAfterChallenge();
+ if (resetRate) showSelectionToast('验证已在另一窗口通过,已重置请求流限速');
+ }
+
+ window.addEventListener('storage', onCloudflareChallengeLeaseChange);
+
+ function stopCloudflareChallengePopupMonitor() {
+ if (cloudflareChallengePopupTimer) clearInterval(cloudflareChallengePopupTimer);
+ cloudflareChallengePopupTimer = 0;
+ }
+
+ function cloudflareChallengePageInfo(popup) {
+ try {
+ const popupDocument = popup?.document;
+ if (!popupDocument) return { accessible: false, challenge: false, ready: false, status: 0 };
+ const href = String(popup.location?.href || '');
+ const title = String(popupDocument.title || '');
+ const challenge = !!(
+ popup._cf_chl_opt ||
+ popupDocument.querySelector('script[src*="/cdn-cgi/challenge-platform/"],#challenge-running') ||
+ /^(?:Just a moment|请稍候)/i.test(title)
+ );
+ const navigation = popup.performance?.getEntriesByType
+ ? popup.performance.getEntriesByType('navigation')[0]
+ : null;
+ return {
+ accessible: true,
+ challenge,
+ ready: popupDocument.readyState === 'complete' && href !== 'about:blank',
+ status: Math.max(0, Number(navigation?.responseStatus) || 0),
+ };
+ } catch {
+ return { accessible: false, challenge: false, ready: false, status: 0 };
+ }
+ }
+
+ function cloudflareChallengePassed(popup) {
+ try {
+ const popupDocument = popup?.document;
+ if (!popupDocument) return false;
+ return !!popupDocument.querySelector('meta[name="discourse-base-uri"],#main-outlet');
+ } catch {
+ return false;
+ }
+ }
+
+ function completeCloudflareChallengePopup(message) {
+ const resetRate = resetReaderRateAfterChallenge();
+ writeCloudflareChallengeLease('passed', 10 * 1000);
+ try { cloudflareChallengePopup.close(); } catch {}
+ cloudflareChallengePopup = null;
+ cloudflareChallengePopupState = null;
+ stopCloudflareChallengePopupMonitor();
+ if (message) showSelectionToast(message);
+ else if (resetRate) showSelectionToast('验证已通过,已重置请求流限速');
+ }
+
+ function failCloudflareChallengePopup(message) {
+ try { cloudflareChallengePopup?.close(); } catch {}
+ cloudflareChallengePopup = null;
+ cloudflareChallengePopupState = null;
+ stopCloudflareChallengePopupMonitor();
+ releaseCloudflareChallengeLease();
+ if (message) showSelectionToast(message);
+ }
+
+ function verifyCloudflareChallengePopupState(state, result = {}) {
+ if (!state || state.verificationStarted || state.verificationFailed) return;
+ state.verificationStarted = true;
+ const status = Math.max(0, Number(result.status) || 0);
+ if (status >= 200 && status < 300) {
+ if (state.onFormSuccess) state.onFormSuccess();
+ completeCloudflareChallengePopup('已收到后端 2xx,楼层已标记为已读');
+ return;
+ }
+ if (!state.verify) {
+ failCloudflareChallengePopup('验证请求未完成;楼层仍保持未读');
+ return;
+ }
+ Promise.resolve(state.verify()).then((verified) => {
+ if (cloudflareChallengePopupState !== state) return;
+ if (verified === true) {
+ completeCloudflareChallengePopup('验证通过且后端已返回 2xx,楼层已标记为已读');
+ return;
+ }
+ failCloudflareChallengePopup('验证后接口仍未返回 2xx;楼层保持未读,本次不会重复触发');
+ }).catch((error) => {
+ console.warn('[LDP] verify read tracking challenge failed', error);
+ if (cloudflareChallengePopupState !== state) return;
+ failCloudflareChallengePopup('验证后接口仍未返回 2xx;楼层保持未读,本次不会重复触发');
+ });
+ }
+
+ async function submitCloudflareChallengePost(popup, url, params, state) {
+ const token = await discourseCsrfToken();
+ if (!popup || popup.closed || cloudflareChallengePopup !== popup || cloudflareChallengePopupState !== state)
+ return false;
+ const target = 'ldp-cloudflare-challenge';
+ try { popup.name = target; } catch {}
+ const form = makeElement('form');
+ form.method = 'POST';
+ form.action = new URL(url, location.href).href;
+ form.target = target;
+ form.hidden = true;
+ const appendField = (name, value) => {
+ const input = makeElement('input');
+ input.type = 'hidden';
+ input.name = name;
+ input.value = String(value);
+ form.appendChild(input);
+ };
+ appendField('authenticity_token', token);
+ Object.entries(params || {}).forEach(([name, value]) => {
+ if (Array.isArray(value)) value.forEach((item) => appendField(`${name}[]`, item));
+ else if (value != null) appendField(name, value);
+ });
+ document.body.appendChild(form);
+ state.formSubmitted = true;
+ state.submittedAt = Date.now();
+ try {
+ form.submit();
+ } finally {
+ form.remove();
+ }
+ return true;
+ }
+
+ function openCloudflareChallengePopup(url, options = {}) {
+ if (cloudflareChallengePopupState?.formParams &&
+ !cloudflareChallengePopupState.verificationFailed) {
+ try { cloudflareChallengePopup.focus(); } catch {}
+ return true;
+ }
+ if (cloudflareChallengePopup && !cloudflareChallengePopup.closed) {
+ try { cloudflareChallengePopup.focus(); } catch {}
+ return true;
+ }
+ const existingLease = readCloudflareChallengeLease();
+ if (existingLease && existingLease.expiresAt > Date.now() && existingLease.owner !== cloudflareChallengeInstanceId) {
+ if (existingLease.state === 'passed') {
+ const resetRate = resetReaderRateAfterChallenge();
+ if (resetRate) showSelectionToast('验证已通过,已重置请求流限速');
+ } else {
+ showSelectionToast('已有一个过盾窗口正在处理,请在该窗口完成验证');
+ }
+ return true;
+ }
+ writeCloudflareChallengeLease('active');
+ const availableScreen = window.screen;
+ const width = Math.min(760, Math.max(420, availableScreen.availWidth || 760));
+ const height = Math.min(720, Math.max(520, availableScreen.availHeight || 720));
+ const left = Math.max(0, Math.round((availableScreen.availLeft || 0) + ((availableScreen.availWidth || width) - width) / 2));
+ const top = Math.max(0, Math.round((availableScreen.availTop || 0) + ((availableScreen.availHeight || height) - height) / 2));
+ const features = `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
+ let popup = null;
+ try {
+ popup = window.open(options.formParams ? 'about:blank' : url, 'ldp-cloudflare-challenge', features);
+ } catch {}
+ if (!popup) {
+ releaseCloudflareChallengeLease();
+ showSelectionToast('验证浮窗被浏览器拦截,请允许本站弹出式窗口');
+ return false;
+ }
+ cloudflareChallengePopup = popup;
+ const popupState = {
+ owner: cloudflareChallengeInstanceId,
+ formParams: options.formParams || null,
+ formSubmitted: false,
+ submittedAt: 0,
+ sawChallenge: false,
+ verificationStarted: false,
+ verificationFailed: false,
+ verify: isFunction(options.verify) ? options.verify : null,
+ onFormSuccess: isFunction(options.onFormSuccess) ? options.onFormSuccess : null,
+ };
+ cloudflareChallengePopupState = popupState;
+ try { popup.focus(); } catch {}
+ if (popupState.formParams) {
+ showSelectionToast('请在浮窗完成验证;后端返回 2xx 前楼层仍保持未读');
+ void submitCloudflareChallengePost(popup, url, popupState.formParams, popupState).catch((error) => {
+ console.warn('[LDP] open read tracking challenge failed', error);
+ failCloudflareChallengePopup('无法提交验证请求,楼层仍保持未读');
+ });
+ }
+ stopCloudflareChallengePopupMonitor();
+ cloudflareChallengePopupTimer = setInterval(() => {
+ if (!cloudflareChallengePopup || cloudflareChallengePopup.closed) {
+ cloudflareChallengePopup = null;
+ cloudflareChallengePopupState = null;
+ stopCloudflareChallengePopupMonitor();
+ releaseCloudflareChallengeLease();
+ return;
+ }
+ if (Date.now() - cloudflareChallengeLeaseHeartbeatAt >= 5000)
+ writeCloudflareChallengeLease('active');
+ const state = cloudflareChallengePopupState;
+ const pageInfo = cloudflareChallengePageInfo(cloudflareChallengePopup);
+ if (pageInfo.challenge) {
+ if (state) state.sawChallenge = true;
+ return;
+ }
+ if (!state || state.verificationFailed || !pageInfo.ready) return;
+ if (!state.formParams) {
+ if (!cloudflareChallengePassed(cloudflareChallengePopup)) return;
+ completeCloudflareChallengePopup();
+ return;
+ }
+ if (!state.formSubmitted || Date.now() - state.submittedAt < 500 || state.verificationStarted) return;
+ verifyCloudflareChallengePopupState(state, {
+ status: pageInfo.status,
+ challenged: state.sawChallenge,
+ });
+ }, 250);
+ return true;
+ }
+
+ function stripBypassParamFromUrl() {
+ try {
+ const u = new URL(location.href);
+ if (!u.searchParams.has(NATIVE_BYPASS_PARAM)) return;
+ u.searchParams.delete(NATIVE_BYPASS_PARAM);
+ history.replaceState(history.state, '', u.pathname + u.search + u.hash);
+ } catch {}
+ }
+
+ function extractTopicRouteFromUrl(url) {
+ let u;
+ try { u = new URL(url, location.href); } catch { return null; }
+ if (/^https?:$/i.test(u.protocol) && u.origin !== BASE) return null;
+ const parts = u.pathname.split('/').filter(Boolean);
+ if (parts[0] !== 't') return null;
+ const segments = parts.slice(1);
+ if (/^\d+$/.test(segments[0] || '')) {
+ const topicId = segments[0];
+ const postNumber = /^\d+$/.test(segments[1] || '') ? segments[1] : '';
+ return { topicId, postNumber: postNumber ? +postNumber : 0 };
+ }
+ const topicId = segments[1];
+ const postNumber = /^\d+$/.test(segments[2] || '') ? segments[2] : '';
+ return /^\d+$/.test(topicId || '') ? { topicId, postNumber: postNumber ? +postNumber : 0 } : null;
+ }
+
+ function topicUrlReaderStartPostNumber(route) {
+ if (!route) return 0;
+ return PREFS.openTopicsAtFirstPost ? 1 : (+route.postNumber || 0);
+ }
+
+ function runWhenBodyReady(fn) {
+ if (document.body) fn();
+ else document.addEventListener('DOMContentLoaded', fn, { once: true });
+ }
+
+ function hideNativeReaderTrigger() {
+ PAGE_ROOT.classList.remove('ldp-native-reader-trigger-visible');
+ readerPortalQueryAll('.ldp-native-reader-trigger-item').forEach((item) => {
+ item.hidden = true;
+ });
+ const button = readerPortalQuery('.ldp-native-reader-trigger');
+ if (button && !button.closest('.ldp-native-reader-trigger-item')) button.hidden = true;
+ }
+
+ function nativeReaderTriggerAvatarHost() {
+ return document.querySelector('.d-header-icons .current-user:has(img.avatar)');
+ }
+
+ function syncNativeReaderTrigger() {
+ if (!document.body) return;
+ const route = extractTopicRouteFromUrl(location.href);
+ let target = route ? { ...route, source: 'route', title: '' } : null;
+ if (!target) {
+ const firstHistoryTopic = orderedTopicHistory()[0];
+ const historyTopicId = Number(firstHistoryTopic?.topicId);
+ if (historyTopicId)
+ target = {
+ topicId: historyTopicId,
+ postNumber: Math.max(1, Number(firstHistoryTopic.postNumber) || 1),
+ source: 'history',
+ title: String(firstHistoryTopic.title || ''),
+ };
+ }
+ const topicId = target && String(target.topicId);
+ if (CURRENT_OVERLAY) {
+ hideNativeReaderTrigger();
+ return;
+ }
+ const avatarHost = nativeReaderTriggerAvatarHost();
+ if (!avatarHost) {
+ hideNativeReaderTrigger();
+ return;
+ }
+ PAGE_ROOT.classList.add('ldp-native-reader-trigger-visible');
+ let item = document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]');
+ if (!item) {
+ item = makeElement('div');
+ item.className = 'ldp-native-reader-trigger-item';
+ item.dataset.ldpPortal = '1';
+ }
+ if (item.parentElement !== avatarHost) avatarHost.appendChild(item);
+ readerPortalQueryAll('.ldp-native-reader-trigger-item').forEach((candidate) => {
+ if (candidate !== item) candidate.hidden = true;
+ });
+ let button = item.querySelector('.ldp-native-reader-trigger');
+ if (!button) {
+ button = makeElement('button');
+ button.className = 'ldp-native-reader-trigger';
+ button.type = 'button';
+ button.innerHTML = icon('maximize2');
+ button.addEventListener('click', async (event) => {
+ consumeEvent(event);
+ const initialTopicId = Number(button.dataset.topicId);
+ const initialPostNumber = Number(button.dataset.postNumber) || 0;
+ const initialSource = button.dataset.triggerSource;
+ if (!initialTopicId) return;
+ AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
+ hideNativeReaderTrigger();
+ if (PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active') &&
+ !await closeChromeSidePanelForReader())
+ return stopDirectTopicTakeover(syncNativeReaderTrigger, String(initialTopicId));
+ const currentRoute = extractTopicRouteFromUrl(location.href);
+ if (initialSource === 'route') {
+ if (!currentRoute || Number(currentRoute.topicId) !== initialTopicId) {
+ syncNativeReaderTrigger();
+ return;
+ }
+ PAGE_ROOT.classList.add('ldp-route-takeover');
+ } else if (currentRoute) {
+ syncNativeReaderTrigger();
+ return;
+ }
+ openModal(initialTopicId, {
+ targetPostNumber: initialSource === 'route'
+ ? topicUrlReaderStartPostNumber(currentRoute)
+ : initialPostNumber,
+ topicNavMetadata: readTopicNavMetadata(null, initialTopicId),
+ });
+ });
+ item.appendChild(button);
+ }
+ item.style.removeProperty('left');
+ item.style.removeProperty('top');
+ delete item.dataset.ldpPlacement;
+ item.hidden = false;
+ button.hidden = false;
+ button.dataset.topicId = topicId || '';
+ button.dataset.triggerSource = target ? target.source : 'empty-history';
+ const startPostNumber = target
+ ? target.source === 'route' ? topicUrlReaderStartPostNumber(target) : target.postNumber
+ : 0;
+ button.dataset.postNumber = String(startPostNumber || '');
+ const sidePanelActive = PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active');
+ const label = !target
+ ? '暂无浏览历史'
+ : target.source === 'history'
+ ? target.title ? `打开历史首项:${target.title}` : '打开历史首项'
+ : sidePanelActive
+ ? '切换到浮窗阅读器'
+ : PREFS.openTopicsAtFirstPost
+ ? '从 #1 打开浮窗阅读器'
+ : target.postNumber ? `从 #${target.postNumber} 打开浮窗阅读器` : '打开浮窗阅读器';
+ setLabel(button, label);
+ }
+
+ function installNativeReaderTriggerObserver() {
+ if (!document.body || document.body._ldpNativeReaderTriggerObserver) return;
+ const relevantSelector = '.d-header,.d-header-wrap,.d-header-icons,.current-user,' +
+ '.ldp-native-reader-trigger-item[data-ldp-portal="1"]';
+ const mutationNodeIsRelevant = (node) => {
+ if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
+ return node.matches(relevantSelector) || !!node.querySelector(relevantSelector);
+ };
+ let observedHeader = null;
+ let syncFrame = 0;
+ let observer = null;
+ const bindObserverTargets = () => {
+ const avatarHost = nativeReaderTriggerAvatarHost();
+ const nextHeader = avatarHost?.closest('.d-header-wrap,.d-header,header') ||
+ document.querySelector('.d-header-wrap,.d-header');
+ if (nextHeader === observedHeader && nextHeader?.isConnected) return;
+ observer.disconnect();
+ observedHeader = nextHeader || null;
+ if (!observedHeader) {
+ // Header bootstrap is short-lived; narrow to the header as soon as it appears.
+ observer.observe(document.body, { childList: true, subtree: true });
+ return;
+ }
+ observer.observe(observedHeader, { childList: true, subtree: true });
+ let ancestor = observedHeader.parentElement;
+ while (ancestor) {
+ observer.observe(ancestor, { childList: true });
+ if (ancestor === document.body) break;
+ ancestor = ancestor.parentElement;
+ }
+ };
+ observer = new MutationObserver((mutations) => {
+ const topologyChanged = !observedHeader?.isConnected || mutations.some((mutation) =>
+ mutation.target !== observedHeader && !observedHeader.contains(mutation.target));
+ const contentChanged = mutations.some((mutation) =>
+ [...mutation.addedNodes, ...mutation.removedNodes].some(mutationNodeIsRelevant));
+ if ((!topologyChanged && !contentChanged) || syncFrame) return;
+ syncFrame = requestAnimationFrame(() => {
+ syncFrame = 0;
+ bindObserverTargets();
+ const avatarHost = nativeReaderTriggerAvatarHost();
+ const item = document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]');
+ if (avatarHost && (!item || item.parentElement !== avatarHost)) syncNativeReaderTrigger();
+ });
+ });
+ bindObserverTargets();
+ document.body._ldpNativeReaderTriggerObserver = observer;
+ }
+
+ function stopDirectTopicTakeover(triggerAction, suppressedTopicId = AUTO_OPEN_SUPPRESSED_TOPIC_ID) {
+ PAGE_ROOT.classList.remove('ldp-route-takeover');
+ AUTO_OPEN_SUPPRESSED_TOPIC_ID = suppressedTopicId;
+ triggerAction();
+ return false;
+ }
+
+ async function takeoverDirectTopicRoute() {
+ if (shouldBypassReaderUrl(location.href))
+ return stopDirectTopicTakeover(hideNativeReaderTrigger);
+ const route = extractTopicRouteFromUrl(location.href);
+ if (!route) {
+ LAST_READER_TOPIC_ROUTE_KEY = '';
+ return stopDirectTopicTakeover(syncNativeReaderTrigger, '');
+ }
+ const topicId = String(route.topicId);
+ const routeKey = `${topicId}:${Math.max(0, Number(route.postNumber) || 0)}`;
+ const routeChanged = LAST_READER_TOPIC_ROUTE_KEY !== routeKey;
+ LAST_READER_TOPIC_ROUTE_KEY = routeKey;
+ const activeReaderWorkspace = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell?.readerWorkspace;
+ if (AUTO_OPEN_SUPPRESSED_TOPIC_ID === topicId && activeReaderWorkspace?.isEmbedded())
+ return stopDirectTopicTakeover(syncNativeReaderTrigger);
+ if (PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active') &&
+ !await closeChromeSidePanelForReader())
+ return stopDirectTopicTakeover(syncNativeReaderTrigger, topicId);
+ const currentRoute = extractTopicRouteFromUrl(location.href);
+ if (!currentRoute || String(currentRoute.topicId) !== topicId)
+ return stopDirectTopicTakeover(hideNativeReaderTrigger);
+ if (AUTO_OPEN_SUPPRESSED_TOPIC_ID && AUTO_OPEN_SUPPRESSED_TOPIC_ID !== topicId)
+ AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
+ if (AUTO_OPEN_SUPPRESSED_TOPIC_ID === topicId)
+ return stopDirectTopicTakeover(syncNativeReaderTrigger);
+ hideNativeReaderTrigger();
+ if (CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId === topicId) {
+ const targetPostNumber = Math.max(0, Number(currentRoute.postNumber) || 0);
+ if (!routeChanged || !targetPostNumber) return true;
+ const activeContext = CURRENT_OVERLAY._ldpReaderShell?.activeContext;
+ if (activeContext?.readerSession?.state === 'active' && activeContext.timeline) {
+ await activeContext.timeline.jumpTo(targetPostNumber);
+ return true;
+ }
+ openModal(currentRoute.topicId, {
+ targetPostNumber,
+ topicNavMetadata: readTopicNavMetadata(null, currentRoute.topicId),
+ });
+ return true;
+ }
+ PAGE_ROOT.classList.add('ldp-route-takeover');
+ openModal(currentRoute.topicId, {
+ targetPostNumber: CURRENT_OVERLAY && currentRoute.postNumber
+ ? +currentRoute.postNumber
+ : topicUrlReaderStartPostNumber(currentRoute),
+ topicNavMetadata: readTopicNavMetadata(null, currentRoute.topicId),
+ });
+ return true;
+ }
+
+ function installDiscourseRouteChangeHandler() {
+ if (DISCOURSE_ROUTE_HANDLER_INSTALLED) return true;
+ const pluginApiModule = lookupDiscourseModule('discourse/lib/plugin-api');
+ if (!pluginApiModule || !isFunction(pluginApiModule.withPluginApi)) return false;
+ let lastRouteKey = currentHostRouteKey();
+ try {
+ pluginApiModule.withPluginApi((api) => {
+ api.onPageChange(() => {
+ setTimeout(() => {
+ const routeKey = currentHostRouteKey();
+ if (routeKey === lastRouteKey) return;
+ lastRouteKey = routeKey;
+ takeoverDirectTopicRoute().catch((error) => {
+ console.warn('[LDP] route signal takeover failed', error);
+ });
+ }, 0);
+ });
+ });
+ } catch {
+ return false;
+ }
+ DISCOURSE_ROUTE_HANDLER_INSTALLED = true;
+ return true;
+ }
+
+ function refreshVisibleReaderReactions() {
+ const overlay = CURRENT_OVERLAY;
+ const ctx = overlay?._ldpReaderShell?.activeContext;
+ if (!overlay || !ctx || !ctx.readerSession?.isCurrent('opening', 'active')) return;
+ overlay.querySelectorAll('.ldp-post').forEach((postNode) => {
+ const wasExpanded = !!postNode.querySelector(
+ ':scope > .ldp-reactions .ldp-reaction-picker:not([hidden])'
+ );
+ renderPostReactions(postNode, ctx);
+ if (!wasExpanded) return;
+ const root = postNode.querySelector(':scope > .ldp-reactions');
+ const picker = root?.querySelector('.ldp-reaction-picker');
+ const trigger = root?.querySelector('[data-reaction-picker]');
+ if (picker) picker.hidden = false;
+ if (trigger) setExpanded(trigger, true);
+ });
+ const rail = ctx.topicActionRail;
+ if (!rail?._ldpPostData) return;
+ const railLike = rail.querySelector('.ldp-like');
+ if (railLike)
+ syncReaderLikeButton(railLike, readerPostRailReactionAction(rail._ldpPostData, ctx));
+ renderPostReactions(rail, ctx, { collectLike: false, railPicker: true });
+ rail.querySelector(':scope > .ldp-reactions > .ldp-reaction-summary')?.remove();
+ }
+
+ function clearDiscourseEventResponseCache(tags) {
+ void clearStoredResponseCache({ tags }).catch((error) => {
+ console.warn('[LDP] Discourse event cache invalidation failed', error);
+ });
+ }
+
+ function installDiscourseUpdateEventMonitor() {
+ if (DISCOURSE_UPDATE_EVENT_MONITOR) return true;
+ const appEvents = lookupDiscourse('service:app-events');
+ const messageBus = lookupDiscourse('service:message-bus');
+ if (!appEvents || !isFunction(appEvents.on) ||
+ !messageBus || !isFunction(messageBus.subscribe)) return false;
+ const owner = document;
+ const bindings = [];
+ const bindAppEvent = (name, handler) => {
+ if (!appEvents || !isFunction(appEvents.on)) return;
+ appEvents.on(name, owner, handler);
+ bindings.push({ type: 'app', name, handler });
+ };
+ const clientSettingsHandler = (data) => {
+ const name = String(data?.name || '');
+ if (![
+ 'discourse_reactions_enabled_reactions',
+ 'discourse_reactions_reaction_for_like',
+ ].includes(name)) return;
+ const siteSettings = lookupDiscourse('service:site-settings');
+ if (siteSettings) siteSettings[name] = data.value;
+ void loadReactionEmojiRegistry({ refresh: true }).then(refreshVisibleReaderReactions);
+ };
+ try {
+ messageBus.subscribe('/client_settings', clientSettingsHandler);
+ bindings.push({
+ type: 'message-bus',
+ name: '/client_settings',
+ handler: clientSettingsHandler,
+ });
+ } catch {
+ return false;
+ }
+ bindAppEvent('notifications:changed', () => {
+ expireNotificationFirstPageCaches();
+ clearDiscourseEventResponseCache(['notifications']);
+ });
+ bindAppEvent('bookmarks:changed', () => {
+ clearDiscourseEventResponseCache(['bookmarks']);
+ CURRENT_OVERLAY?._ldpReaderShell?.refreshBookmarksPanel?.();
+ });
+ bindAppEvent('discourse-reactions:reaction-toggled', (payload) => {
+ const post = payload?.post;
+ const postId = Number(post?.id || 0);
+ const topicId = Number(post?.topic_id || post?.topic?.id || 0);
+ const tags = ['reactions-given'];
+ if (postId) tags.push(`post:${postId}`);
+ if (topicId) tags.push(`topic:${topicId}`);
+ clearDiscourseEventResponseCache(tags);
+ const ctx = CURRENT_OVERLAY?._ldpReaderShell?.activeContext;
+ if (!postId || !ctx || topicId && topicId !== Number(ctx.topicId)) {
+ if (topicId) {
+ cancelTopicSnapshotWrites(topicId);
+ TOPIC_DATA_CACHE.delete(String(topicId));
+ HOST_TOPIC_DATA_CACHE.delete(String(topicId));
+ }
+ return;
+ }
+ ctx.loader?.persistFetchedPosts?.([post]);
+ ingestLivePosts([post], ctx, { refreshContent: true });
+ ctx.refreshBookmarksPanel?.({ post, topic: ctx.topicData });
+ });
+ bindAppEvent('post:created', (post) => {
+ const postId = Number(post?.id || 0);
+ const topicId = Number(post?.topic_id || post?.topic?.id || 0);
+ const tags = [];
+ if (postId) tags.push(`post:${postId}`);
+ if (topicId) tags.push(`topic:${topicId}`);
+ if (tags.length) clearDiscourseEventResponseCache(tags);
+ const activeTopicId = Number(CURRENT_OVERLAY?._ldpReaderShell?.activeContext?.topicId || 0);
+ if (topicId && topicId !== activeTopicId) {
+ cancelTopicSnapshotWrites(topicId);
+ TOPIC_DATA_CACHE.delete(String(topicId));
+ HOST_TOPIC_DATA_CACHE.delete(String(topicId));
+ }
+ });
+ bindAppEvent('composer:edited-post', () => {
+ const topicId = Number(extractTopicRouteFromUrl(location.href)?.topicId || 0);
+ if (!topicId) return;
+ clearDiscourseEventResponseCache([`topic:${topicId}`]);
+ const activeTopicId = Number(CURRENT_OVERLAY?._ldpReaderShell?.activeContext?.topicId || 0);
+ if (topicId === activeTopicId) return;
+ cancelTopicSnapshotWrites(topicId);
+ TOPIC_DATA_CACHE.delete(String(topicId));
+ HOST_TOPIC_DATA_CACHE.delete(String(topicId));
+ });
+ if (!bindings.length) return false;
+ DISCOURSE_UPDATE_EVENT_MONITOR = { appEvents, messageBus, bindings };
+ return true;
+ }
+
+ function captureHostTopicPointerAnchor(event, topicId) {
+ if (event.type !== 'click' || event.detail <= 0) return null;
+ const card = eventPathClosest(event, 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item');
+ if (!card || card.closest('.ldp-overlay')) return null;
+ const rect = card.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return null;
+ return {
+ card,
+ topicId: String(topicId || ''),
+ clientY: event.clientY,
+ offsetY: Math.max(0, Math.min(rect.height, event.clientY - rect.top)),
+ };
+ }
+
+ function restoreHostTopicPointerAnchor(anchor) {
+ if (!anchor) return false;
+ let card = anchor.card?.isConnected ? anchor.card : null;
+ if (!card && anchor.topicId) {
+ const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item';
+ card = [...document.querySelectorAll(cardSelector)].find((candidate) => {
+ if (candidate.closest('.ldp-overlay')) return false;
+ const directId = candidate.dataset.topicId || candidate.getAttribute('data-topic-id');
+ const link = candidate.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]');
+ return String(directId || extractTopicRouteFromUrl(link?.href)?.topicId || '') === anchor.topicId;
+ }) || null;
+ }
+ if (!card) return false;
+ document.querySelectorAll('[data-ldp-reader-active-topic]').forEach((candidate) => {
+ if (candidate !== card) candidate.removeAttribute('data-ldp-reader-active-topic');
+ });
+ card.dataset.ldpReaderActiveTopic = 'true';
+ const rect = card.getBoundingClientRect();
+ const delta = rect.top + Number(anchor.offsetY || 0) - Number(anchor.clientY || 0);
+ if (Math.abs(delta) >= .5) window.scrollBy({ top: delta, behavior: 'auto' });
+ return true;
+ }
+
+ function closeHostTopicSourceSurface(event) {
+ const source = eventPathClosest(event,
+ '.fk-d-menu,.menu-panel,.search-menu,.user-menu,.hamburger-panel,.sidebar-hamburger-dropdown,.chat-drawer');
+ if (!source || source.closest('.ldp-overlay,.ldp-reader-portal-host,.sciapp-ldp-owned') ||
+ source.dataset.ldpReaderTopLayer) return null;
+ const surface = source.matches('.fk-d-menu,.menu-panel,.chat-drawer')
+ ? source
+ : source.closest('.fk-d-menu,.menu-panel,.chat-drawer') ||
+ source.querySelector('.fk-d-menu,.menu-panel,.chat-drawer') || source;
+ const waitUntilClosed = (closeAction) => {
+ let closeResult;
+ try {
+ closeResult = closeAction();
+ } catch {
+ return null;
+ }
+ return Promise.resolve(closeResult).catch(() => {}).then(() => new Promise((resolve) => {
+ const deadline = performance.now() + 180;
+ const check = () => {
+ if (!surface.isConnected || !nativeComposerVisible(surface) || performance.now() >= deadline) {
+ resolve();
+ return;
+ }
+ requestAnimationFrame(check);
+ };
+ requestAnimationFrame(check);
+ }));
+ };
+ const identifier = surface.matches('.fk-d-menu') ? String(surface.dataset.identifier || '') : '';
+ const nativeMenu = identifier && lookupDiscourse('service:menu');
+ if (identifier && isFunction(nativeMenu?.close))
+ return waitUntilClosed(() => nativeMenu.close(identifier));
+ const chatClose = surface.matches('.chat-drawer')
+ ? surface.querySelector('.chat-drawer-header__close-btn')
+ : null;
+ if (chatClose && isFunction(chatClose.click)) return waitUntilClosed(() => chatClose.click());
+ const controlledIds = [surface.id, source.id].filter(Boolean);
+ let trigger = [...document.querySelectorAll('[aria-expanded="true"]')].find((candidate) =>
+ controlledIds.some((id) => [candidate.getAttribute('aria-controls'), candidate.getAttribute('aria-owns')]
+ .some((value) => String(value || '').split(/\s+/).includes(id)))
+ );
+ if (!trigger) {
+ const sourceKind = eventPathClosest(event,
+ '.search-menu,.user-menu,.hamburger-panel,.sidebar-hamburger-dropdown');
+ const triggerSelector = sourceKind?.classList.contains('search-menu')
+ ? '.d-header-icons .search-dropdown'
+ : sourceKind?.classList.contains('user-menu')
+ ? '.d-header-icons .current-user'
+ : sourceKind
+ ? '.d-header-icons .hamburger-dropdown,.hamburger-dropdown'
+ : '';
+ if (triggerSelector) {
+ const triggers = [...document.querySelectorAll(triggerSelector)];
+ trigger = triggers.find((candidate) => candidate.getAttribute('aria-expanded') === 'true') ||
+ triggers[0];
+ }
+ }
+ if (!trigger || !isFunction(trigger.click)) return null;
+ // 捕获阶段接管 topic 点击后,宿主收不到原始点击;复用其 menu service、关闭按钮或 toggle 保持状态一致。
+ return waitUntilClosed(() => trigger.click());
+ }
+
+ function handleTopicLinkTrigger(e) {
+ if (e.type === 'click' && e.button && e.button !== 0) return false;
+ const queueButton = eventPathClosest(e, '.ldp-reader-queue-add[data-reader-queue-topic-id]');
+ if (queueButton) {
+ consumeEvent(e);
+ const topicId = Number(queueButton.dataset.readerQueueTopicId);
+ const existing = readerQueueEntry(topicId);
+ if (existing) {
+ if (removeReaderQueueEntry(topicId)) showSelectionToast('已从阅读队列移除');
+ } else {
+ const entry = ensureReaderQueueEntry(topicId, readerQueueMetadataFromLink(queueButton, topicId));
+ if (entry) showSelectionToast(`已加入阅读队列:${entry.title}`);
+ }
+ return true;
+ }
+ let a = eventPathClosest(e, 'a[href]');
+ if (!a && PAGE_ROOT.classList.contains('ldp-reader-workspace')) {
+ const card = eventPathClosest(e, 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item');
+ const interactive = eventPathClosest(e, 'button,input,select,textarea,[role="button"],[contenteditable="true"]');
+ if (card && !card.closest('.ldp-overlay') && !interactive)
+ a = card.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]');
+ }
+ if (!a) return false;
+ const hostSearchMenu = a.closest('.search-menu');
+ if (hostSearchMenu && !hostSearchMenu.closest('.ldp-overlay,.ldp-reader-portal-host,.sciapp-ldp-owned') &&
+ !a.closest('.search-result-topic,.search-result-post'))
+ return false;
+ if (a.classList.contains('ldp-rate-limit-challenge') || a.classList.contains('ldp-error-challenge')) {
+ consumeEvent(e);
+ openCloudflareChallengePopup(a.href);
+ return true;
+ }
+ const notificationItem = a.closest('.ldp-notification-item');
+ const refreshNotificationTarget = notificationItem?.classList.contains('ldp-notification-message-item') === true;
+ const notificationTopicId = refreshNotificationTarget
+ ? Math.max(0, Number(notificationItem.dataset.notificationTopicId) || 0)
+ : 0;
+ const notificationPostNumber = refreshNotificationTarget
+ ? Math.max(0, Number(notificationItem.dataset.notificationPostNumber) || 0)
+ : 0;
+ const notificationRoute = notificationTopicId
+ ? { topicId: String(notificationTopicId), postNumber: notificationPostNumber }
+ : null;
+ const notificationId = markClickedNotificationRead(notificationItem);
+ if (notificationId > 0)
+ void apiSend(`${BASE}/notifications/mark-read`, 'PUT', { id: notificationId }, null, {
+ surviveReaderSwitch: true,
+ }).catch((error) => {
+ console.warn('[LDP] mark notification read failed', error);
+ });
+ const activeReaderWorkspace = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell?.readerWorkspace;
+ if (activeReaderWorkspace?.isEmbedded() && a.getRootNode() === READER_PORTAL_ROOT &&
+ !a.classList.contains('ldp-history-link') &&
+ !a.classList.contains('ldp-rate-limit-challenge') &&
+ !a.classList.contains('ldp-error-challenge') &&
+ openEmbeddedReaderLinkInHost(e, a, activeReaderWorkspace)) return true;
+ if (a.classList.contains('ldp-open')) return false;
+ if (shouldBypassReaderUrl(a.href)) return false;
+ const route = notificationRoute || extractTopicRouteFromUrl(a.getAttribute('href') || a.href);
+ if (!route) return false;
+ const activeReaderShell = CURRENT_OVERLAY?._ldpReaderShell;
+ const activeContext = activeReaderShell?.activeContext;
+ if (notificationRoute && notificationPostNumber > 0 &&
+ String(activeContext?.topicId || '') === String(notificationTopicId) &&
+ activeContext?.readerSession?.state === 'active' && activeContext.timeline) {
+ consumeEvent(e);
+ activeReaderShell.closeNotifications?.();
+ void activeContext.timeline.jumpTo(notificationPostNumber).then((jumped) => {
+ if (!jumped) openModal(notificationTopicId, {
+ targetPostNumber: notificationPostNumber,
+ refreshTargetBoosts: true,
+ });
+ }).catch(() => openModal(notificationTopicId, {
+ targetPostNumber: notificationPostNumber,
+ refreshTargetBoosts: true,
+ }));
+ return true;
+ }
+ const readerQueueMetadata = readerQueueMetadataFromLink(a, route.topicId);
+ readerQueueMetadata.urlPostNumber = Math.max(0, Number(route.postNumber) || 0);
+ const hostTopicPointerAnchor = captureHostTopicPointerAnchor(e, route.topicId);
+ if (hostTopicPointerAnchor && isFunction(a.blur)) a.blur();
+ const hostSurfaceClosing = closeHostTopicSourceSurface(e);
+ consumeEvent(e);
+ AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
+ hideNativeReaderTrigger();
+ const openTopic = () => openModal(route.topicId, {
+ targetPostNumber: a.dataset.ldpPreserveTargetPost === '1'
+ ? +route.postNumber || 0
+ : topicUrlReaderStartPostNumber(route),
+ topicNavMetadata: readTopicNavMetadata(a),
+ readerQueueMetadata,
+ hostTopicPointerAnchor,
+ refreshTargetBoosts: refreshNotificationTarget,
+ });
+ if (hostSurfaceClosing) hostSurfaceClosing.then(() => requestAnimationFrame(openTopic));
+ else openTopic();
+ return true;
+ }
+
+ function handleReaderHostRefreshShortcut(event) {
+ if (event.key !== 'F5') return false;
+ const readerShell = CURRENT_OVERLAY?._ldpReaderShell;
+ const readerWorkspace = readerShell?.readerWorkspace;
+ if (!readerWorkspace || !readerWorkspace.isEmbedded()) return false;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ if (!refreshDiscourseHostRoute())
+ showSelectionToast(`${SITE_ADAPTER.name} 原站刷新暂不可用,请稍后重试`);
+ return true;
+ }
+
+ function handleReaderMouseHistoryButton(event) {
+ if (event.button !== 3 && event.button !== 4) return false;
+ const overlay = CURRENT_OVERLAY;
+ if (!overlay || !overlay.isConnected) return false;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ if (event.type !== 'mousedown') return true;
+ const backward = event.button === 3;
+ const button = overlay.querySelector(
+ backward ? '.ldp-reader-history-back' : '.ldp-reader-history-forward'
+ );
+ if (button && !button.hidden && !button.disabled) button.click();
+ else showSelectionToast(backward ? '没有更早的阅读记录' : '没有更新的阅读记录');
+ return true;
+ }
+
+ function installReaderHostEventHandlers() {
+ if (document._ldpReaderHostEventHandlersInstalled) return;
+ document._ldpReaderHostEventHandlersInstalled = true;
+ document.addEventListener('keydown', handleReaderHostRefreshShortcut, true);
+ document.addEventListener('mousedown', handleReaderMouseHistoryButton, true);
+ document.addEventListener('mouseup', handleReaderMouseHistoryButton, true);
+ document.addEventListener('auxclick', handleReaderMouseHistoryButton, true);
+ document.addEventListener('click', handleTopicLinkTrigger, true);
+ }
+
+ runWhenBodyReady(async () => {
+ if (!await waitForDiscourseHost()) {
+ console.warn(`[LDP] ${location.hostname} 未检测到 Discourse,阅读器未启动`);
+ return;
+ }
+ applyReaderFontRenderingRuntimeProperties();
+ updateDiscourseCapabilityDiagnostics();
+ installReaderHostEventHandlers();
+ installRequestFlowInstrumentation();
+ void resolveSiteLogo();
+ if (!installDiscourseUpdateEventMonitor())
+ window.addEventListener('load', () => {
+ if (!installDiscourseUpdateEventMonitor())
+ console.warn('[LDP] Discourse update events unavailable');
+ }, { once: true });
+ if (!installDiscourseRouteChangeHandler())
+ window.addEventListener('load', () => {
+ if (!installDiscourseRouteChangeHandler())
+ console.warn('[LDP] Discourse page change signal unavailable');
+ }, { once: true });
+ ensureReaderStyleMounted();
+ installNativeReaderTriggerObserver();
+ installNativeTopicQueueButtons();
+ let directTopicTakenOver = false;
+ try {
+ directTopicTakenOver = await takeoverDirectTopicRoute();
+ } catch (error) {
+ console.warn('[LDP] direct topic takeover failed', error);
+ }
+ if (directTopicTakenOver) return;
+ try {
+ await restoreEmbeddedReaderAfterReload();
+ } catch (error) {
+ console.warn('[LDP] embedded reader reload restore failed', error);
+ }
+ syncNativeReaderTrigger();
+ });
+})();