Core runtime modules for Awesome LinuxDo Reader Lite.
Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @require https://update.greasyfork.org/scripts/590254/1914292/Awesome%20LinuxDo%20Reader%20Lite%20Core%20Library.js
// ==UserScript==
// @name Awesome LinuxDo Reader Lite Core Library
// @name:zh-CN Awesome LinuxDo Reader Lite 核心库
// @namespace https://github.com/sunbigfly/awesome-linuxdo-reader
// @version 1.6.3
// @description Core runtime and presentation modules for Awesome LinuxDo Reader Lite.
// @description:zh-CN 应用、Shell、主题、流、布局与 userscript 运行核心
// @author sunbigfly
// @license MIT
// @homepageURL https://github.com/sunbigfly/awesome-linuxdo-reader
// @supportURL https://github.com/sunbigfly/awesome-linuxdo-reader/issues
// @match https://linux.do/*
// @grant none
// ==/UserScript==
/* Awesome LinuxDo Reader Lite 1.6.3 - main-lite-core
* 应用、Shell、主题、流、布局与 userscript 运行核心
* 项目 TypeScript 源码保持可读;固定版本第三方依赖压缩打包。
* 不要直接编辑此文件;修改 lite/src 后重新构建。
*/
(function () {
'use strict';
const root = globalThis;
const runtimeKey = "__AWESOME_LINUXDO_READER_LITE_MODULE_RUNTIME__";
let runtime = root[runtimeKey];
if (runtime === undefined) {
const factories = new Map();
const sourceHashes = new Map();
const modules = new Map();
const libraries = new Set();
let started = false;
const externalModuleIds = Object.freeze({"@xsai/generate-text":"vendor/xsai-generate-text.js"});
const resolve = (parentId, request) => {
const externalId = externalModuleIds[request];
if (externalId) return externalId;
if (!request.startsWith('.')) {
throw new Error(`[main-lite] unsupported external module: ${request}`);
}
const parts = parentId.split('/');
parts.pop();
for (const part of request.split('/')) {
if (!part || part === '.') continue;
if (part === '..') {
if (!parts.length) {
throw new Error(`[main-lite] module escapes root: ${parentId} -> ${request}`);
}
parts.pop();
} else {
parts.push(part);
}
}
const resolved = parts.join('/');
return /\.(?:js|json)$/.test(resolved) ? resolved : `${resolved}.js`;
};
const requireModule = (id) => {
const cached = modules.get(id);
if (cached) return cached.exports;
const factory = factories.get(id);
if (!factory) throw new Error(`[main-lite] missing module: ${id}`);
const module = { exports: {} };
modules.set(id, module);
try {
factory(module, module.exports, (request) => (
requireModule(resolve(id, request))
));
} catch (error) {
modules.delete(id);
throw error;
}
return module.exports;
};
runtime = Object.freeze({
schemaVersion: 1,
sourceVersion: "1.6.3",
register(id, factory, sourceHash) {
const currentHash = sourceHashes.get(id);
if (currentHash !== undefined) {
if (currentHash !== sourceHash) {
throw new Error(`[main-lite] conflicting module: ${id}`);
}
return;
}
factories.set(id, factory);
sourceHashes.set(id, sourceHash);
},
markLibrary(name) {
libraries.add(name);
},
start(entryId, expectedLibraries) {
for (const name of expectedLibraries) {
if (!libraries.has(name)) {
throw new Error(`[main-lite] missing library: ${name}`);
}
}
if (started) return requireModule(entryId);
started = true;
try {
return requireModule(entryId);
} catch (error) {
started = false;
throw error;
}
},
});
Object.defineProperty(root, runtimeKey, {
configurable: true,
enumerable: false,
writable: false,
value: runtime,
});
}
if (runtime.schemaVersion !== 1 || runtime.sourceVersion !== "1.6.3") {
throw new Error('[main-lite] Library 版本不匹配');
}
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: !0 });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from == "object" || typeof from == "function")
for (let key of __getOwnPropNames(from))
!__hasOwnProp.call(to, key) && key !== except && __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: !0 }), mod);
var __create = Object.create;
var __getProtoOf = Object.getPrototypeOf;
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: !0 }) : target,
mod
));
/* Source: lite/src/app/reader-application-stages.ts */
runtime.register("src/app/reader-application-stages.js", function(module, exports, require) {
var reader_application_stages_exports = {};
__export(reader_application_stages_exports, {
createPreferencesStorageSyncStage: () => createPreferencesStorageSyncStage
});
module.exports = __toCommonJS(reader_application_stages_exports);
function createPreferencesStorageSyncStage(options) {
const key = String(options.key).trim();
if (!key) throw new Error("preferences storage key 不能为空");
const windowPort = options.window ?? window;
return Object.freeze({
name: "preferences-storage-sync",
required: !1,
setup: (scope) => {
scope.listen(windowPort, "storage", (rawEvent) => {
const event = rawEvent;
if (!(event.key !== key && event.key !== null || event.key === key && event.oldValue === event.newValue))
try {
options.repository.reloadExternal();
} catch (cause) {
options.onError?.(cause);
}
});
}
});
}
}, "9ba835be1c1dd532abf442a2488ce815108b4324cf870d46ed435f814888efac");
/* Source: lite/src/app/reader-application.ts */
runtime.register("src/app/reader-application.js", function(module, exports, require) {
var reader_application_exports = {};
__export(reader_application_exports, {
BrowserDiscourseHostPort: () => BrowserDiscourseHostPort,
ReaderApplication: () => ReaderApplication,
browserBodyReady: () => browserBodyReady,
detectDiscourseHost: () => detectDiscourseHost
});
module.exports = __toCommonJS(reader_application_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js");
function abortError(reason) {
return reason instanceof Error ? reason : new DOMException("Reader application 已销毁", "AbortError");
}
function stageName(value) {
const normalized = String(value).trim();
if (!normalized) throw new Error("application stage name 不能为空");
return normalized;
}
class ReaderApplication {
changes = new import_signal.Signal();
diagnostics = new import_signal.Signal();
#options;
#scope = new import_lifecycle.LifecycleScope();
#controller = new AbortController();
#state = "idle";
#startPromise = null;
constructor(options) {
const names = options.stages.map((stage) => stageName(stage.name));
if (new Set(names).size !== names.length)
throw new Error("application stage name 不能重复");
this.#options = options, this.#scope.add(() => {
this.#controller.signal.aborted || this.#controller.abort(abortError(null));
});
}
get state() {
return this.#state;
}
get scope() {
return this.#scope;
}
start() {
return this.#startPromise ? this.#startPromise : this.#state === "destroyed" ? Promise.resolve("destroyed") : (this.#startPromise = this.#run(), this.#startPromise);
}
destroy() {
this.#state !== "destroyed" && (this.#setState("destroyed"), this.#scope.destroy());
}
async #run() {
try {
this.#setState("waiting-body"), await this.#options.bodyReady(this.#controller.signal), this.#throwIfDestroyed();
let preferences = this.#options.preferences.load().value;
const preferenceChanges = new import_signal.Signal();
this.#scope.add(() => preferenceChanges.clear());
const publishPreferences = (value) => {
if (preferences !== value) {
preferences = value;
for (const cause of preferenceChanges.emit(preferences))
this.diagnostics.emit(Object.freeze({
stage: "preferences-live",
required: !1,
cause
}));
}
}, stopPreferenceChanges = this.#options.preferences.changes?.subscribe((snapshot) => {
publishPreferences(snapshot.value);
}, this.#scope);
this.#setState("waiting-host");
const host = await this.#options.host.waitForHost(this.#controller.signal);
if (this.#throwIfDestroyed(), !host)
return stopPreferenceChanges?.(), this.#setState("skipped"), this.#state;
this.#setState("starting");
const context = Object.freeze({
preferences,
readPreferences: () => preferences,
...this.#options.preferences.update === void 0 ? {} : {
updatePreferences: (patch) => {
this.#throwIfDestroyed();
const snapshot = this.#options.preferences.update(patch);
return publishPreferences(snapshot.value), snapshot.value;
}
},
preferenceChanges,
host
});
for (const stage of this.#options.stages) {
this.#throwIfDestroyed();
const child = this.#scope.child();
try {
const cleanup = await stage.setup(child, context);
typeof cleanup == "function" && child.add(cleanup), this.#throwIfDestroyed();
} catch (cause) {
try {
child.destroy();
} catch (cleanupCause) {
this.diagnostics.emit(Object.freeze({
stage: `${stage.name}:cleanup`,
required: stage.required,
cause: cleanupCause
}));
}
if (this.diagnostics.emit(Object.freeze({
stage: stage.name,
required: stage.required,
cause
})), stage.required) throw cause;
}
}
return this.#throwIfDestroyed(), this.#setState("running"), this.#state;
} catch {
if (this.#state === "destroyed" || this.#controller.signal.aborted)
return "destroyed";
this.#setState("failed");
try {
this.#scope.destroy();
} catch (cleanupCause) {
this.diagnostics.emit(Object.freeze({
stage: "application:cleanup",
required: !0,
cause: cleanupCause
}));
}
return this.#state;
}
}
#throwIfDestroyed() {
if (this.#state === "destroyed" || this.#controller.signal.aborted)
throw abortError(this.#controller.signal.reason);
}
#setState(state) {
this.#state !== state && (this.#state = state, this.changes.emit(state));
}
}
function detectDiscourseHost(moduleLookup, documentPort) {
try {
if (moduleLookup("discourse/lib/url"))
return Object.freeze({ detection: "native-module" });
} catch {
}
return documentPort.querySelector(
'meta[name="generator"][content*="Discourse" i],meta[name="discourse_theme_id"],#data-preloaded,#ember-app .d-header,#ember-app .topic-list,#ember-app .topic-post'
) ? Object.freeze({ detection: "dom-marker" }) : null;
}
class BrowserDiscourseHostPort {
#moduleLookup;
#document;
#window;
#timeoutMs;
#createObserver;
constructor(options) {
this.#moduleLookup = options.moduleLookup, this.#document = options.document ?? document, this.#window = options.window ?? window;
const timeoutMs = options.timeoutMs ?? 15e3;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 12e4)
throw new RangeError("Discourse host timeout 必须是 1..120000 的安全整数");
this.#timeoutMs = timeoutMs, this.#createObserver = options.createObserver ?? ((callback) => new MutationObserver(callback));
}
waitForHost(signal) {
const immediate = detectDiscourseHost(this.#moduleLookup, this.#document);
return immediate ? Promise.resolve(immediate) : signal.aborted ? Promise.reject(abortError(signal.reason)) : this.#document.readyState === "complete" ? Promise.resolve(null) : new Promise((resolve, reject) => {
let settled = !1, timer = 0;
const observer = this.#createObserver?.(() => check()), cleanup = () => {
timer && this.#window.clearTimeout(timer), observer?.disconnect(), this.#window.removeEventListener("load", onLoad), signal.removeEventListener("abort", onAbort);
}, finish = (value) => {
settled || (settled = !0, cleanup(), resolve(value));
}, check = () => {
const detected = detectDiscourseHost(this.#moduleLookup, this.#document);
return detected && finish(detected), detected;
}, onLoad = () => {
finish(check());
}, onAbort = () => {
settled || (settled = !0, cleanup(), reject(abortError(signal.reason)));
};
observer?.observe(this.#document.documentElement, {
childList: !0,
subtree: !0
}), this.#window.addEventListener("load", onLoad), signal.addEventListener("abort", onAbort, { once: !0 }), timer = this.#window.setTimeout(() => {
finish(detectDiscourseHost(this.#moduleLookup, this.#document));
}, this.#timeoutMs);
});
}
}
function browserBodyReady(documentPort, signal) {
return documentPort.body ? Promise.resolve() : signal.aborted ? Promise.reject(abortError(signal.reason)) : new Promise((resolve, reject) => {
const cleanup = () => {
documentPort.removeEventListener("DOMContentLoaded", onReady), signal.removeEventListener("abort", onAbort);
}, onReady = () => {
cleanup(), resolve();
}, onAbort = () => {
cleanup(), reject(abortError(signal.reason));
};
documentPort.addEventListener("DOMContentLoaded", onReady, { once: !0 }), signal.addEventListener("abort", onAbort, { once: !0 });
});
}
}, "06e20cfe8a3ff2bb72a0055cda065487e96fe86b16de928d7c7f9b70a9f770ce");
/* Source: lite/src/app/reader-browser-runtime.ts */
runtime.register("src/app/reader-browser-runtime.js", function(module, exports, require) {
var reader_browser_runtime_exports = {};
__export(reader_browser_runtime_exports, {
ReaderBrowserRuntime: () => ReaderBrowserRuntime,
createReaderBrowserRuntimeStage: () => createReaderBrowserRuntimeStage,
readerWebDavCacheClearPlan: () => readerWebDavCacheClearPlan
});
module.exports = __toCommonJS(reader_browser_runtime_exports);
var import_native_host_api = require("../discourse/native-host-api.js"), import_native_request_descriptors = require("../discourse/native-request-descriptors.js"), import_reader_cache_management_surface = require("../cache/reader-cache-management-surface.js"), import_reader_collection_page_repository = require("../cache/reader-collection-page-repository.js"), import_browser_asset_cache = require("../cache/browser-asset-cache.js"), import_discourse_application_cache_invalidation = require("../cache/discourse-application-cache-invalidation.js"), import_topic_snapshot_handoff = require("../cache/topic-snapshot-handoff.js"), import_reader_settings_config_manager = require("../state/reader-settings-config-manager.js"), import_native_composer = require("../discourse/native-composer.js"), import_reader_control_tooltip = require("../components/reader-control-tooltip.js"), import_reader_icon = require("../components/reader-icon.js"), import_reader_native_composer_window = require("../discourse/reader-native-composer-window.js"), import_reader_native_post_admin_menu = require("../discourse/reader-native-post-admin-menu.js"), import_native_post_model_factory = require("../discourse/native-post-model-factory.js"), import_native_presence = require("../discourse/native-presence.js"), import_identifiers = require("../discourse/identifiers.js"), import_reader_history_navigation_controller = require("../history/reader-history-navigation-controller.js"), import_reader_history_model = require("../history/reader-history-model.js"), import_reader_history_repository = require("../history/reader-history-repository.js"), import_reader_history_navigation_view = require("../history/reader-history-navigation-view.js"), import_reader_history_panel_view = require("../history/reader-history-panel-view.js"), import_reader_chronicle_repository = require("../history/reader-chronicle-repository.js"), import_reader_chronicle_view = require("../history/reader-chronicle-view.js"), import_reader_unwanted_topic_repository = require("../collection/reader-unwanted-topic-repository.js"), import_reader_unwanted_topic_view = require("../collection/reader-unwanted-topic-view.js"), import_discourse_bookmark_adapter = require("../bookmark/discourse-bookmark-adapter.js"), import_reader_bookmark_controller = require("../bookmark/reader-bookmark-controller.js"), import_reader_bookmark_panel_view = require("../bookmark/reader-bookmark-panel-view.js"), import_reader_bookmark_model = require("../bookmark/reader-bookmark-model.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_post_author_filter_feature = require("../topic/reader-post-author-filter-feature.js"), import_signal = require("../kernel/signal.js"), import_reader_topic_live_navigation_controller = require("../live/reader-topic-live-navigation-controller.js"), import_reader_resource_monitor = require("../monitor/reader-resource-monitor.js"), import_reader_pipeline_observer = require("../monitor/reader-pipeline-observer.js"), import_browser_request_observation = require("../network/browser-request-observation.js"), import_reader_topic_live_navigation_view = require("../live/reader-topic-live-navigation-view.js"), import_reader_rate_limit_notice = require("../shell/reader-rate-limit-notice.js"), import_reader_escape_surface = require("../shell/reader-escape-surface.js"), import_reader_image_download_service = require("../media/reader-image-download-service.js"), import_reader_image_resource_service = require("../media/reader-image-resource-service.js"), import_reader_lightbox_image_picker = require("../media/reader-lightbox-image-picker.js"), import_reader_media_prefetch_service = require("../media/reader-media-prefetch-service.js"), import_reader_topic_image_index = require("../media/reader-topic-image-index.js"), import_reader_topic_image_interaction = require("../media/reader-topic-image-interaction.js"), import_reader_lightbox_feature = require("../media/reader-lightbox-feature.js"), import_reader_compact_image_viewer = require("../media/reader-compact-image-viewer.js"), import_reader_topic_media_feature = require("../media/reader-topic-media-feature.js"), import_reader_media_controller = require("../media/reader-media-controller.js"), import_reader_cooked_content_feature = require("../media/reader-cooked-content-feature.js"), import_reader_poll_feature = require("../media/reader-poll-feature.js"), import_browser_shared_request_permit = require("../network/browser-shared-request-permit.js"), import_coordinated_request_client = require("../network/coordinated-request-client.js"), import_discourse_native_read_transport = require("../network/discourse-native-read-transport.js"), import_public_resource_request_adapter = require("../network/public-resource-request-adapter.js"), import_reader_business_request_policy = require("../network/reader-business-request-policy.js"), import_reader_business_request_config = require("../network/reader-business-request-config.js"), import_reader_request_flow_config = require("../network/reader-request-flow-config.js"), import_discourse_native_user_port = require("../user/discourse-native-user-port.js"), import_reader_user_domain_session = require("../user/reader-user-domain-session.js"), import_reader_credit_account_adapter = require("../user/reader-credit-account-adapter.js"), import_reader_community_score_adapter = require("../user/reader-community-score-adapter.js"), import_reader_connect_trust_adapter = require("../user/reader-connect-trust-adapter.js"), import_reader_user_endorsement_adapter = require("../user/reader-user-endorsement-adapter.js"), import_reader_user_card_view = require("../user/reader-user-card-view.js"), import_discourse_user_observation_adapter = require("../user/discourse-user-observation-adapter.js"), import_reader_user_observation_session = require("../user/reader-user-observation-session.js"), import_reader_user_observation_page_repository = require("../user/reader-user-observation-page-repository.js"), import_reader_user_observation_model = require("../user/reader-user-observation-model.js"), import_reader_user_observation_view = require("../user/reader-user-observation-view.js"), import_reader_settings_user_view = require("../user/reader-settings-user-view.js"), import_discourse_notification_adapter = require("../notification/discourse-notification-adapter.js"), import_reader_notification_controller = require("../notification/reader-notification-controller.js"), import_reader_notification_model = require("../notification/reader-notification-model.js"), import_reader_notification_panel_view = require("../notification/reader-notification-panel-view.js"), import_action_request_adapter = require("../post/action-request-adapter.js"), import_discourse_action_transport = require("../post/discourse-action-transport.js"), import_post_action_controller = require("../post/post-action-controller.js"), import_discourse_action_descriptors = require("../post/discourse-action-descriptors.js"), import_post_action_feature_commands = require("../post/post-action-feature-commands.js"), import_user_action_feature_commands = require("../post/user-action-feature-commands.js"), import_reader_post_action_feature = require("../post/reader-post-action-feature.js"), import_reader_bookmark_action_coordinator = require("../post/reader-bookmark-action-coordinator.js"), import_reader_share_action_coordinator = require("../post/reader-share-action-coordinator.js"), import_reader_topic_notification_coordinator = require("../post/reader-topic-notification-coordinator.js"), import_reader_topic_shared_issue_coordinator = require("../post/reader-topic-shared-issue-coordinator.js"), import_topic_action_feature_commands = require("../post/topic-action-feature-commands.js"), import_reader_topic_action_rail = require("../post/reader-topic-action-rail.js"), import_reader_topic_summary_request_adapter = require("../post/reader-topic-summary-request-adapter.js"), import_reader_topic_summary_surface = require("../post/reader-topic-summary-surface.js"), import_reader_topic_custom_summary = require("../post/reader-topic-custom-summary.js"), import_reader_post_management_action_coordinator = require("../post/reader-post-management-action-coordinator.js"), import_reader_selection_quote_feature = require("../post/reader-selection-quote-feature.js"), import_reader_mobile_return_controller = require("../shell/reader-mobile-return-controller.js"), import_reader_workspace_coordinator = require("../shell/reader-workspace-coordinator.js"), import_reader_feedback_surface = require("../shell/reader-feedback-surface.js"), import_reader_action_surface_coordinator = require("../shell/reader-action-surface-coordinator.js"), import_reader_exclusive_panel_coordinator = require("../shell/reader-exclusive-panel-coordinator.js"), import_reader_shell_recovery_view = require("../shell/reader-shell-recovery-view.js"), import_reader_report_form_surface = require("../shell/reader-report-form-surface.js"), import_reader_assignment_form_surface = require("../shell/reader-assignment-form-surface.js"), import_reader_choice_form_surface = require("../shell/reader-choice-form-surface.js"), import_reader_topic_edit_form_surface = require("../shell/reader-topic-edit-form-surface.js"), import_reader_settings_controller = require("../settings/reader-settings-controller.js"), import_reader_settings_view = require("../settings/reader-settings-view.js"), import_reader_theme_settings_control = require("../settings/reader-theme-settings-control.js"), import_reader_window_settings_form = require("../settings/reader-window-settings-form.js"), import_reader_shortcut_settings_form = require("../settings/reader-shortcut-settings-form.js"), import_reader_custom_site_settings_form = require("../settings/reader-custom-site-settings-form.js"), import_reader_webdav_settings_form = require("../settings/reader-webdav-settings-form.js"), import_browser_discourse_site_probe = require("../site/browser-discourse-site-probe.js"), import_reader_webdav_coordinator = require("../sync/reader-webdav-coordinator.js"), import_reader_webdav_category_ports = require("../sync/reader-webdav-category-ports.js"), import_reader_performance_settings_form = require("../settings/reader-performance-settings-form.js"), import_reader_reading_settings_form = require("../settings/reader-reading-settings-form.js"), import_reader_translation_settings_form = require("../settings/reader-translation-settings-form.js"), import_reader_ai_service_settings_form = require("../settings/reader-ai-service-settings-form.js"), import_reader_appearance_settings_form = require("../settings/reader-appearance-settings-form.js"), import_reader_font_settings_form = require("../settings/reader-font-settings-form.js"), import_reader_motion_settings_form = require("../settings/reader-motion-settings-form.js"), import_reader_layout_settings_form = require("../settings/reader-layout-settings-form.js"), import_reader_interaction_settings_form = require("../settings/reader-interaction-settings-form.js"), import_reader_image_settings_form = require("../settings/reader-image-settings-form.js"), import_reader_select_surface = require("../shell/reader-select-surface.js"), import_reader_floating_window_frame = require("../shell/reader-floating-window-frame.js"), import_reader_collection_floating_window = require("../collection/reader-collection-floating-window.js"), import_reader_image_preferences = require("../media/reader-image-preferences.js"), import_reader_reply_tree_preferences = require("../topic/reader-reply-tree-preferences.js"), import_reader_about_settings_content = require("../settings/reader-about-settings-content.js"), import_reader_open_queue_session = require("../queue/reader-open-queue-session.js"), import_reader_topic_download_manager = require("../queue/reader-topic-download-manager.js"), import_reader_topic_offline_document = require("../archive/reader-topic-offline-document.js"), import_reader_topic_offline_artifact_repository = require("../archive/reader-topic-offline-artifact-repository.js"), import_reader_cooked_content_feature2 = require("../media/reader-cooked-content-feature.js"), import_reader_katex_controller = require("../media/reader-katex-controller.js"), import_reader_shortcut_controller = require("../shell/reader-shortcut-controller.js"), import_reader_appearance_style_controller = require("../appearance/reader-appearance-style-controller.js"), import_reader_theme_controller = require("../appearance/reader-theme-controller.js"), import_reader_font_style_controller = require("../font/reader-font-style-controller.js"), import_reader_font_catalog = require("../font/reader-font-catalog.js"), import_reader_loading_animation_view = require("../motion/reader-loading-animation-view.js"), import_reader_layout_style_controller = require("../layout/reader-layout-style-controller.js"), import_reader_topic_factory = require("../topic/reader-topic-factory.js"), import_reader_topic_navigation_controller = require("../topic/reader-topic-navigation-controller.js"), import_reader_native_topic_route = require("../topic/reader-native-topic-route.js"), import_reader_topic_flow_controller = require("../topic/reader-topic-flow-controller.js"), import_reader_topic_navigation_preferences = require("../topic/reader-topic-navigation-preferences.js"), import_reader_topic_scroll_adapter = require("../topic/reader-topic-scroll-adapter.js"), import_reader_topic_local_archive_feature = require("../topic/reader-topic-local-archive-feature.js"), import_reader_topic_timeline_controller = require("../topic/reader-topic-timeline-controller.js"), import_reader_topic_timeline_end_resolver = require("../topic/reader-topic-timeline-end-resolver.js"), import_reader_topic_timeline_view = require("../topic/reader-topic-timeline-view.js"), import_reader_topic_header = require("../topic/reader-topic-header.js"), import_reader_topic_edit_controller = require("../topic/reader-topic-edit-controller.js"), import_reader_topic_comments_header = require("../topic/reader-topic-comments-header.js"), import_reader_topic_only_op_controller = require("../topic/reader-topic-only-op-controller.js"), import_reader_topic_special_content_feature = require("../topic/reader-topic-special-content-feature.js"), import_reader_topic_context_controller = require("../topic/reader-topic-context-controller.js"), import_reader_topic_context_surface = require("../topic/reader-topic-context-surface.js"), import_reader_topic_context_state = require("../topic/reader-topic-context-state.js"), import_reader_information_flow_coordinator = require("../state/reader-information-flow-coordinator.js"), import_reader_topic_state_projection = require("../state/reader-topic-state-projection.js"), import_topic_session = require("../topic/topic-session.js"), import_reader_translation_feature = require("../translation/reader-translation-feature.js"), import_translation_request_adapter = require("../translation/translation-request-adapter.js"), import_reader_data_runtime = require("./reader-data-runtime.js"), import_reader_performance_policy = require("./reader-performance-policy.js");
const readerSurfaceOnlyCloseEvents = /* @__PURE__ */ new WeakSet(), hostTopicUserCardSelector = "html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.posters,.topic-poster) [data-user-card]", hostTopicAvatarLongPressSelector = "html.mobile-view:not(.ldp-reader-workspace) .topic-list-item > .topic-list-data > .pull-left > [data-user-card][data-user-card-long-press]";
function readerWebDavCacheClearPlan(categories) {
const webDavCategories = [], protectedCategories = [];
return categories.includes("history") && (webDavCategories.push("history"), protectedCategories.push("history")), categories.includes("notifications") && (webDavCategories.push("notification-history"), protectedCategories.push("notifications")), categories.includes("responses") && (webDavCategories.push(
"bookmarks",
"translation-cache",
"activity-history"
), protectedCategories.push("responses")), Object.freeze({
webDavCategories: Object.freeze(webDavCategories),
protectedCategories: Object.freeze(protectedCategories)
});
}
function createReaderBrowserActivity(document, scope) {
const listeners = /* @__PURE__ */ new Set(), publish = () => {
for (const listener of [...listeners]) listener();
};
scope.listen(document, "visibilitychange", publish);
const view = document.defaultView;
return view && (scope.listen(view, "focus", publish), scope.listen(view, "online", publish), scope.listen(view, "pageshow", publish), scope.listen(view, "pagehide", publish)), scope.add(() => listeners.clear()), Object.freeze({
visible: () => document.visibilityState !== "hidden",
subscribe(listener) {
return listeners.add(listener), () => listeners.delete(listener);
}
});
}
function readerReportOptions(document, flagTypes, availableNames, appliesTo) {
return Object.freeze(flagTypes.filter(
(flag) => flag.enabled && availableNames.has(flag.nameKey) && (!flag.appliesTo.length || flag.appliesTo.includes(appliesTo))
).map((flag) => {
const template = document.createElement("template");
template.innerHTML = flag.description;
const description = String(
template.content.textContent ?? ""
).replace(/\s+/g, " ").trim();
return Object.freeze({
id: flag.id,
label: flag.label,
description: description || "提交给社区管理人员审核。",
requireMessage: flag.requireMessage
});
}));
}
function readerNativeModelValue(value, key) {
if (!value || typeof value != "object" && typeof value != "function")
return;
const model = value;
return typeof model.get == "function" ? model.get(key) : model[key];
}
function readerPollViewer(currentUser, fallbackUsername) {
const id = Number(readerNativeModelValue(currentUser, "id")), username = String(
readerNativeModelValue(currentUser, "username") ?? fallbackUsername
).trim(), rawGroups = readerNativeModelValue(currentUser, "groups"), groups = Array.isArray(rawGroups) ? rawGroups.map((group) => String(
readerNativeModelValue(group, "name") ?? group
).trim()).filter(Boolean) : [];
return Object.freeze({
id: Number.isSafeInteger(id) && id > 0 ? id : null,
username: username || null,
staff: ["staff", "admin", "moderator"].some((key) => readerNativeModelValue(currentUser, key) === !0),
groups: Object.freeze(groups)
});
}
function readerShellElement(root, selector, owner) {
const value = root.querySelector(selector);
if (!value) throw new Error(`${owner} 缺少 Shell 命名控件:${selector}`);
return value;
}
function readerTopicHeaderElements(root) {
const query = (selector) => readerShellElement(root, selector, "主题 Header");
return Object.freeze({
titleJump: query(".ldp-title-jump"),
metaHost: query(".ldp-meta"),
metaStats: query(".ldp-meta-stats"),
metaOwner: query(".ldp-meta-owner"),
metaOwnerValue: query(".ldp-meta-owner-value"),
onlyOpToggle: query(".ldp-only-op-toggle"),
onlyOpProgress: query(".ldp-only-op-progress"),
onlyOpProgressValue: query(".ldp-only-op-progress-value"),
topicIdentityHost: query(".ldp-title-topic-row")
});
}
function readerTopicTimelineElements(root) {
const query = (selector) => readerShellElement(root, selector, "时间轴 View");
return Object.freeze({
root,
timeline: query(".ldp-topic-timeline"),
date: query(".ldp-topic-timeline-date"),
track: query(".ldp-topic-timeline-track"),
cursor: query(".ldp-topic-timeline-cursor"),
current: query(".ldp-topic-timeline-current"),
total: query(".ldp-topic-timeline-total"),
preview: query(".ldp-topic-timeline-preview"),
relative: query(".ldp-topic-timeline-relative"),
jump: query(".ldp-topic-timeline-jump"),
top: query(".ldp-topic-timeline-top"),
jumpForm: query(".ldp-topic-timeline-jump-form"),
jumpInput: query(".ldp-topic-timeline-jump-input"),
jumpSubmit: query(
".ldp-topic-timeline-jump-submit"
),
jumpHint: query(".ldp-topic-timeline-jump-hint")
});
}
function readerTopicLiveNavigationElements(root) {
const liveRoot = root.querySelector(".ldp-live-update"), jump = root.querySelector(".ldp-live-update-jump"), label = jump?.querySelector("span") ?? null, dismiss = root.querySelector(
".ldp-live-update-dismiss"
);
if (!liveRoot || !jump || !label || !dismiss)
throw new Error("实时新回复 View 缺少 Shell 命名控件");
return Object.freeze({
root: liveRoot,
jump,
label,
dismiss
});
}
function readerNotificationPanelElements(root) {
const query = (selector) => readerShellElement(root, selector, "消息面板"), modeTabs = [...root.querySelectorAll(
".ldp-notification-mode-tab"
)], groupPanels = [...root.querySelectorAll(
"[data-notification-mode-panel]"
)], groupTabs = [...root.querySelectorAll(
".ldp-notification-tab"
)];
if (modeTabs.length !== 2 || groupPanels.length !== 2 || groupTabs.length !== 14)
throw new Error("消息面板必须提供 2 个模式与完整 14 个分类锚点");
return Object.freeze({
root,
toggle: query(".ldp-notifications-toggle"),
badge: query(".ldp-notification-unread-badge"),
popover: query(".ldp-notifications-popover"),
modeTabs: Object.freeze(modeTabs),
groupPanels: Object.freeze(groupPanels),
groupTabs: Object.freeze(groupTabs),
toolbar: query(".ldp-notification-toolbar"),
unreadStatus: query(".ldp-notification-unread-status"),
markAll: query(".ldp-notification-mark-all"),
newMessage: query(".ldp-notification-new-message"),
search: query(".ldp-notification-search"),
searchClear: query(".ldp-notification-search-clear"),
categoryFilter: query(
".ldp-notification-category-filter"
),
tagFilter: query(".ldp-notification-tag-filter"),
list: query(".ldp-notification-list"),
pagePrevious: query(".ldp-notification-page-prev"),
pageInfo: query(".ldp-notification-page-info"),
pageNext: query(".ldp-notification-page-next")
});
}
function readerBookmarkPanelElements(root) {
const query = (selector) => readerShellElement(root, selector, "收藏面板"), tabs = [...root.querySelectorAll(
".ldp-bookmark-tab"
)];
if (tabs.length !== 5)
throw new Error(
"收藏面板必须提供回应、Boost、回复、帖子、楼层五个分类锚点"
);
return Object.freeze({
root,
toggle: query(".ldp-bookmarks-toggle"),
popover: query(".ldp-bookmarks-popover"),
tabs: Object.freeze(tabs),
defaultActions: query(".ldp-bookmarks-default-actions"),
multiButton: query(".ldp-bookmarks-multi"),
bulkActions: query(".ldp-bookmarks-bulk-actions"),
selectScope: query(".ldp-bookmarks-select-scope"),
selectToggle: query(".ldp-bookmarks-select-toggle"),
deleteSelected: query(
".ldp-bookmarks-delete-selected"
),
deleteSelectedLabel: query(
".ldp-bookmarks-delete-selected-label"
),
multiDone: query(".ldp-bookmarks-multi-done"),
search: query(".ldp-bookmarks-search"),
searchClear: query(".ldp-bookmarks-search-clear"),
categoryFilter: query(
".ldp-bookmarks-category-filter"
),
tagFilter: query(".ldp-bookmarks-tag-filter"),
reactionFilters: query(".ldp-reaction-filters"),
list: query(".ldp-bookmarks-list"),
pagePrevious: query(".ldp-bookmarks-page-prev"),
pageInfo: query(".ldp-bookmarks-page-info"),
pageNext: query(".ldp-bookmarks-page-next")
});
}
function readerShellFailureKind(cause) {
if (cause && typeof cause == "object") {
if (("code" in cause ? String(cause.code ?? "") : "") === "network-error") return "network";
const kind = "kind" in cause ? String(cause.kind ?? "") : "";
if ([
"cloudflare",
"rate-limit",
"authentication",
"forbidden",
"not-found",
"conflict",
"validation",
"client",
"timeout",
"server"
].includes(kind))
return kind;
if ("cloudflareMitigated" in cause && cause.cloudflareMitigated === !0)
return "cloudflare";
const status = "status" in cause ? Number(cause.status) : 0;
if (status === 400 || status === 422) return "validation";
if (status === 401) return "authentication";
if (status === 403) return "forbidden";
if (status === 404 || status === 410) return "not-found";
if (status === 429) return "rate-limit";
if (status === 408) return "timeout";
if (status === 409 || status === 412) return "conflict";
if (status === 425 || status >= 500 && status <= 599) return "server";
if (status >= 400 && status <= 499) return "client";
const name = "name" in cause ? String(cause.name ?? "") : "";
if (name === "TimeoutError") return "timeout";
if (name === "TypeError" || name === "NetworkError" || name === "OfflineError")
return "network";
}
return "unknown";
}
function readerShellRecoveryFailure(cause, challengeHref) {
const kind = readerShellFailureKind(cause), copy = {
cloudflare: [
"Cloudflare 验证尚未完成",
"新的 Reader 请求已暂停,后台不会继续打开新验证页;请手动完成唯一验证后重新加载。"
],
"rate-limit": [
"请求收到 429",
"当前逻辑请求已按 Retry-After 有界等待;固定预防窗口仍保留真实启动记录。稍后可手动重试,无需刷新页面或清缓存。"
],
authentication: [
"登录状态已失效",
"服务器返回 401;请先在原站恢复登录,再重新加载当前帖子。"
],
forbidden: [
"当前请求无权限",
"服务器返回 403;该请求不会被当成 429,也不会触发限流恢复。"
],
"not-found": [
"帖子或楼层不存在",
"服务器返回 404/410;内容可能已删除、移动或当前账号不可见。"
],
conflict: [
"请求状态发生冲突",
"服务器返回 409/412;保留当前页面状态,刷新对应内容后再试。"
],
validation: [
"请求内容未通过校验",
"服务器返回 400/422;不会自动重放,请检查当前输入或页面状态。"
],
client: [
"请求被服务器拒绝",
"这是当前请求自身的 4xx 异常;不会升级成 429 或全局验证闸门。"
],
timeout: [
"请求超时",
"有界自动重试仍未恢复;可检查网络后手动重试。"
],
network: [
"网络暂时不可用",
"当前 Topic 状态未被伪造或清空;网络恢复后可手动重试。"
],
server: [
"服务器暂时不可用",
"有界自动重试仍未取得成功响应;稍后可继续手动重试。"
],
unknown: [
"帖子加载失败",
"当前失败已保留为诊断事实;可手动重试或关闭阅读器。"
]
};
return Object.freeze({
kind,
message: copy[kind][0],
detail: copy[kind][1],
...kind === "cloudflare" && challengeHref ? { challengeHref } : {}
});
}
function readerShellOpenRetryable(cause) {
return ["timeout", "network", "server"].includes(
readerShellFailureKind(cause)
);
}
function documentTopicId(document) {
const segments = document.location.pathname.split("/").filter(Boolean), topicIndex = segments.indexOf("t");
if (topicIndex < 0) return null;
const tail = segments.slice(topicIndex + 1), topicOffset = /^\d+$/.test(tail[0] ?? "") ? 0 : 1, topicId = Number(tail[topicOffset]);
return Number.isSafeInteger(topicId) && topicId > 0 ? topicId : null;
}
function createReaderLocalFontQuery(document) {
const browserWindow = document.defaultView;
if (!browserWindow?.queryLocalFonts) return;
let resolved = null, pending = null;
return async () => {
if (resolved) return resolved;
if (pending) return pending;
pending = browserWindow.queryLocalFonts().then((entries) => Object.freeze([...new Set(
entries.map((entry) => String(
entry.family ?? entry.fullName ?? entry.postscriptName ?? ""
).trim()).filter(Boolean)
)].sort((left, right) => left.localeCompare(right)))).then((names) => (resolved = names, names));
try {
return await pending;
} finally {
pending = null;
}
};
}
class ReaderBrowserRuntime {
scope;
shell;
workspace;
permit;
data;
activity;
nativeAjax;
userNative;
users;
connectHistory;
creditAccount;
userEndorsements;
userActions;
userObservations;
userObservationView;
userCardView;
translationRequests;
translationFeature;
resourceRequests;
imageResources;
mediaPrefetch;
imageDownloads;
blobDownloads;
userMediaViewer;
assetCaches;
postReactions;
composerIsolation;
applicationCacheInvalidation;
composer;
controlTooltip;
actionSurfaces;
feedback;
recovery;
rateLimitNotice;
reportForm;
assignmentForm;
choiceForm;
topicEditForm;
selectSurface;
threadContextState;
history;
topicStates;
chronicle;
chronicleView;
unwantedTopics;
unwantedTopicView;
historyNavigation;
historyNavigationView;
historyPanelView;
notificationNative;
notificationRequests;
notificationActions;
notificationController;
notificationPanelView;
bookmarkNative;
bookmarkRequests;
bookmarkActions;
bookmarkController;
bookmarkPanelView;
#collectionActionEvents = new import_signal.Signal();
#chronicleRequestIds = /* @__PURE__ */ new Set();
#topicSummarySurfaces = /* @__PURE__ */ new Set();
#preheatedTopicSnapshots;
pipeline;
topicFactory;
#performance;
#openRecoveryController = null;
#lastFailedRequest = null;
#challengeHref;
#openRetryDelay;
#loadingProgress;
#manualChallengeController;
#boostTargetHighlight;
#manualChallengePromise = null;
#destroyed = !1;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => this.#collectionActionEvents.clear()), this.activity = createReaderBrowserActivity(options.document, this.scope), this.pipeline = new import_reader_pipeline_observer.ReaderPipelineObserver({
sourceId: options.sourceId
}), this.#manualChallengeController = this.scope.abortController(
new DOMException("Reader runtime 已销毁", "AbortError")
), this.shell = options.shell, this.workspace = options.workspace, this.#openRetryDelay = options.openRetryDelay ?? import_coordinated_request_client.abortableDelay, this.#loadingProgress = options.loadingProgress ?? null, this.#boostTargetHighlight = new import_reader_topic_scroll_adapter.ReaderBoostTargetHighlightController({
...options.navigation?.readLifetimeMs ? { readLifetimeMs: options.navigation.readLifetimeMs } : {},
...options.navigation?.prefersReducedMotion ? { prefersReducedMotion: options.navigation.prefersReducedMotion } : {},
...options.navigation?.schedule ? { schedule: options.navigation.schedule } : {},
...options.navigation?.cancel ? { cancel: options.navigation.cancel } : {},
parentScope: this.scope
}), this.#performance = options.performance ?? Object.freeze({
pageSize: options.topic.pageSize,
streamOverscanScreens: 1,
streamMaxMountedPostCount: 64,
nestedPrefetchScreens: 2,
requestMaxConcurrent: options.data.scheduler.maxConcurrent,
requestMinIntervalMs: options.permit.minIntervalMs,
requestRateTargetPercent: 85,
readStateRequestsPerMinute: 10,
readStateTimingsPerMinute: 240,
businessRequestSettings: import_reader_business_request_config.READER_BUSINESS_REQUEST_DEFAULTS,
requestFlowSettings: import_reader_request_flow_config.READER_REQUEST_FLOW_DEFAULTS,
requestShortBudget: options.permit.shortBudget,
requestLongBudget: options.permit.longBudget,
preheatMaxConcurrent: 2,
preheatHandoffMaxEntries: 3,
preheatHandoffMaxBytes: 8 * 1024 * 1024,
preheatHandoffTtlMs: 6e4,
responseMemoryMaxEntries: 72,
responseMemoryMaxBytes: 16 * 1024 * 1024,
projectionHydrationBatchSize: 1
}), this.#preheatedTopicSnapshots = new import_topic_snapshot_handoff.TopicSnapshotHandoff({
authScope: options.topic.authScope,
maxEntries: this.#performance.preheatHandoffMaxEntries,
maxBytes: this.#performance.preheatHandoffMaxBytes,
ttlMs: this.#performance.preheatHandoffTtlMs
}), this.scope.add(() => this.#preheatedTopicSnapshots.clear()), this.assetCaches = options.assetCacheStorage ? new import_browser_asset_cache.ReaderBrowserAssetCacheRepository(options.assetCacheStorage) : null;
let challengeHref = "";
try {
challengeHref = (0, import_browser_shared_request_permit.browserCloudflareChallengeHref)(
options.topic.origin ?? options.data.rateLimit.baseUrl ?? options.document.baseURI,
options.document.location?.href ?? options.document.baseURI
);
} catch {
}
this.#challengeHref = challengeHref;
const reportTopicFeature = (topicId, feature, cause) => {
try {
options.onTopicFeatureError?.(Object.freeze({
topicId,
feature,
cause
}));
} catch {
}
};
try {
const nativeUserCatalog = (0, import_native_host_api.discourseNativeUnwantedTopicRuleCatalog)(options.host);
this.selectSurface = new import_reader_select_surface.ReaderSelectSurface({
document: options.document,
root: this.shell.view.surfaceHost,
parentScope: this.scope
}), this.actionSurfaces = new import_reader_action_surface_coordinator.ReaderActionSurfaceCoordinator({
parentScope: this.scope
}), this.controlTooltip = new import_reader_control_tooltip.ReaderControlTooltip({
document: options.document,
surfaceHost: this.shell.view.surfaceHost,
...options.share ? { copyText: (value) => options.share.copyText(value) } : {},
parentScope: this.scope
});
const openNative = this.shell.view.root.querySelector("a.ldp-open"), browserWindow = options.document.defaultView;
if (openNative && browserWindow) {
const openNativeTopic = (event) => {
const pointer = event;
event.type === "click" && pointer.button !== 0 || event.type === "auxclick" && pointer.button !== 1 || !openNative.href || openNative.hidden || !(0, import_reader_native_topic_route.openReaderNativeTopicTab)(browserWindow, openNative.href) || (event.preventDefault(), event.stopPropagation());
};
this.scope.listen(openNative, "click", openNativeTopic), this.scope.listen(openNative, "auxclick", openNativeTopic);
}
if (this.feedback = new import_reader_feedback_surface.ReaderFeedbackSurface({
document: options.document,
root: this.shell.view.surfaceHost,
coordinator: this.actionSurfaces,
...options.renderIcon ? { renderIcon: options.renderIcon } : {},
parentScope: this.scope
}), this.recovery = new import_reader_shell_recovery_view.ReaderShellRecoveryView({
document: options.document,
host: this.shell.view.topicHost,
onRetry: async () => {
const request = this.#lastFailedRequest;
if (!request) return !1;
await this.data.client.resetRateLimits();
const result = await this.openTarget({
...request,
forceRefresh: !0
});
return result.topic.status === "opened" || result.topic.status === "reused";
},
onClose: async () => {
await this.close();
},
parentScope: this.scope
}), this.reportForm = new import_reader_report_form_surface.ReaderReportFormSurface({
document: options.document,
root: this.shell.view.surfaceHost,
coordinator: this.actionSurfaces,
...options.renderIcon ? { renderIcon: options.renderIcon } : {},
parentScope: this.scope
}), this.assignmentForm = new import_reader_assignment_form_surface.ReaderAssignmentFormSurface({
document: options.document,
root: this.shell.view.surfaceHost,
users: nativeUserCatalog,
coordinator: this.actionSurfaces,
...options.renderIcon ? { renderIcon: options.renderIcon } : {},
parentScope: this.scope
}), this.choiceForm = new import_reader_choice_form_surface.ReaderChoiceFormSurface({
document: options.document,
root: this.shell.view.surfaceHost,
coordinator: this.actionSurfaces,
...options.renderIcon ? { renderIcon: options.renderIcon } : {},
parentScope: this.scope
}), this.topicEditForm = new import_reader_topic_edit_form_surface.ReaderTopicEditFormSurface({
document: options.document,
root: this.shell.view.surfaceHost,
...options.renderIcon ? { renderIcon: options.renderIcon } : {},
parentScope: this.scope
}), this.threadContextState = new import_reader_topic_context_state.ReaderTopicContextStateRepository({
storage: options.threadContextStorage ?? (0, import_reader_topic_context_state.readerTopicContextWebStorage)(options.storage),
authScope: options.topic.authScope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"thread-context",
cause
)
}), this.threadContextState.load(), options.lightbox && options.openTopicImage)
throw new Error(
"标准 Lightbox 与自定义 openTopicImage 只能配置一个"
);
const challengeOrigin = String(
options.topic.origin ?? options.data.rateLimit.baseUrl ?? options.document.location?.origin ?? ""
), challengeWindow = options.document.defaultView;
this.nativeAjax = new import_discourse_native_read_transport.BrowserDiscourseNativeAjaxPort(
options.host,
options.topic.origin === void 0 ? {} : { origin: options.topic.origin }
);
let challengeRequestObserver = null;
this.permit = new import_browser_shared_request_permit.BrowserSharedRequestPermit({
...options.permit,
storage: options.storage,
sourceId: options.sourceId,
locks: options.locks ?? null,
storageEvents: options.storageEvents ?? null,
...options.broadcastChannelFactory === void 0 ? {} : { broadcastChannelFactory: options.broadcastChannelFactory },
.../^https?:\/\//i.test(challengeOrigin) && typeof challengeWindow?.open == "function" ? {
challenge: {
origin: challengeOrigin,
redirectHref: options.document.location?.href ?? options.document.baseURI,
verify: async (signal) => {
const observer = challengeRequestObserver, observationId = observer?.begin({
href: "/session/current.json?_=cache-bust",
method: "GET",
transport: "xmlhttprequest",
source: "reader",
priority: "critical",
logicalId: "CF-probe",
profile: "challenge-probe",
namespace: "cloudflare-session",
lane: "control",
cacheMode: "no-store",
max429Retries: 0,
maxChallengeRetries: 0,
blockOnCloudflareChallenge: !1,
suppressAfterChallengeWait: !0,
droppable: !1,
callSite: "cloudflare-challenge / session-probe"
}) ?? null;
let response;
try {
response = await this.nativeAjax.request({
path: "/session/current.json",
method: "GET",
signal,
noStore: !0
});
} catch (error) {
if (observationId !== null) {
const decision = signal.aborted ? "challenge-probe-cancelled" : "challenge-probe-failed";
observer?.finish(observationId, {
error: signal.aborted ? "AbortError" : "request-failed",
decision
}) === !1 && observer?.update(observationId, { decision });
}
throw error;
}
if (observationId !== null) {
const decision = response.cloudflareMitigated === !0 ? "challenge-probe-blocked" : response.status === 429 ? "challenge-probe-rate-limited-pass" : "challenge-probe-passed";
observer?.finish(observationId, {
status: response.status,
cloudflareMitigated: response.cloudflareMitigated === !0,
retryAfter: String(response.retryAfter ?? ""),
rateLimitCode: String(response.rateLimitCode ?? ""),
serverLimit: String(response.serverLimit ?? ""),
serverRemaining: String(response.serverRemaining ?? ""),
serverReset: String(response.serverReset ?? ""),
decision
}) === !1 && observer?.update(observationId, { decision });
}
return response.status >= 100 && response.cloudflareMitigated !== !0;
},
screen: challengeWindow.screen,
open: (url, name, features) => challengeWindow.open(
url,
name,
features
)
}
} : {},
parentScope: this.scope
});
const rateLimitNotice = this.shell.view.root.querySelector(
".ldp-rate-limit-notice"
), rateLimitDetail = rateLimitNotice?.querySelector(
".ldp-rate-limit-detail"
), rateLimitChallenge = rateLimitNotice?.querySelector(
".ldp-rate-limit-challenge"
);
if (!rateLimitNotice || !rateLimitDetail || !rateLimitChallenge)
throw new Error("Reader Shell 缺少 429 状态投影锚点");
this.rateLimitNotice = new import_reader_rate_limit_notice.ReaderRateLimitNotice({
document: options.document,
elements: {
root: rateLimitNotice,
detail: rateLimitDetail,
challenge: rateLimitChallenge
},
challengeHref: this.#challengeHref,
snapshot: () => this.permit.snapshot(),
subscribe: (listener) => this.permit.subscribeStateChanges(listener),
parentScope: this.scope
}), this.scope.listen(this.shell.view.root, "click", (event) => {
const target = event.target, anchor = typeof target?.closest == "function" ? target.closest(
"a.ldp-rate-limit-challenge,a.ldp-error-challenge"
) : null;
!anchor?.href || !this.#challengeHref || (event.preventDefault(), event.stopPropagation(), this.#openManualCloudflareChallenge(anchor.href));
}), this.data = new import_reader_data_runtime.ReaderDataRuntime({
...options.data,
trace: this.pipeline,
permit: this.permit,
storage: options.storage,
sourceId: options.sourceId,
locks: options.locks ?? null,
indexedDb: options.indexedDb ?? null,
...options.broadcastChannelFactory === void 0 ? {} : { broadcastChannelFactory: options.broadcastChannelFactory },
parentScope: this.scope
}), this.scope.add(this.data.responses.subscribeInvalidation((query) => {
this.#preheatedTopicSnapshots.invalidate(query);
})), challengeRequestObserver = this.data.requests, this.permit.reconcileCloudflareChallenge().then(() => this.rateLimitNotice.refresh()).catch(() => {
}), this.composerIsolation = new import_native_composer.DiscourseComposerHostIsolation({
host: options.host,
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"post-action",
cause
)
}), this.applicationCacheInvalidation = new import_discourse_application_cache_invalidation.DiscourseApplicationCacheInvalidationCoordinator({
host: options.host,
composerEvents: this.composerIsolation,
cache: this.data.responses,
currentTopicId: () => this.shell.activeTopicId === null ? documentTopicId(options.document) : Number(this.shell.activeTopicId),
onPostChanged: (post) => {
this.shell.activeValue?.services.live.ingestPostDelta(post);
},
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"post-action",
cause
)
});
const nativeActions = new import_discourse_action_transport.BrowserDiscourseNativeActionPort(
options.host,
this.nativeAjax,
this.composerIsolation
), nativeReads = new import_discourse_native_read_transport.BrowserDiscourseNativeReadTransport(
this.nativeAjax
), actionDescriptors = new import_discourse_action_descriptors.DiscourseActionDescriptors();
this.userNative = new import_discourse_native_user_port.BrowserDiscourseNativeUserPort(options.host, {
readTransport: nativeReads,
...options.topic.origin === void 0 ? {} : { basePath: options.topic.origin },
categoryExperts: options.document.location?.hostname === "linux.do"
}), this.userEndorsements = new import_reader_user_endorsement_adapter.ReaderUserEndorsementAdapter({
gateway: this.data.gateway,
transport: nativeReads,
authScope: options.topic.authScope
});
const connect = options.connect ? new import_reader_connect_trust_adapter.ReaderConnectTrustAdapter({
gateway: this.data.gateway,
http: options.connect.http,
authScope: options.topic.authScope,
document: options.document
}) : null;
this.connectHistory = connect ? new import_reader_connect_trust_adapter.ReaderConnectTrustHistoryAdapter({
gateway: this.data.gateway,
ajax: this.nativeAjax,
storage: options.storage,
confirmations: this.data.readCoordination,
authScope: options.topic.authScope
}) : null, this.connectHistory && this.scope.add(this.data.readCoordination.subscribeConfirmations(
(confirmation) => {
this.connectHistory?.recordReadConfirmation(confirmation);
}
)), this.creditAccount = options.credit ? new import_reader_credit_account_adapter.ReaderCreditAccountAdapter({
gateway: this.data.gateway,
http: options.credit.http,
authScope: options.topic.authScope,
...options.credit.storage ? { storage: options.credit.storage } : {}
}) : null;
const credit = this.creditAccount, communityScore = options.communityScore ? new import_reader_community_score_adapter.ReaderCommunityScoreAdapter({
gateway: this.data.gateway,
http: options.communityScore.http,
authScope: options.topic.authScope
}) : null;
this.users = new import_reader_user_domain_session.ReaderUserDomainSession({
gateway: this.data.gateway,
native: this.userNative,
authScope: options.topic.authScope,
...options.searchForms === void 0 ? {} : { searchForms: options.searchForms },
...connect ? { connect } : {},
...credit ? { credit } : {},
...communityScore ? { communityScore } : {},
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"user",
cause
)
});
const userAbort = this.scope.abortController(
new Error("Reader 用户 action scope 已销毁")
), userMutation = new import_action_request_adapter.ActionRequestAdapter({
gateway: this.data.gateway,
nativeActions,
authScope: options.topic.authScope,
signal: userAbort.signal
});
this.userActions = new import_post_action_controller.PostActionController({
mutation: userMutation,
cache: this.data.responses,
scope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"user",
cause
)
});
const userCommands = new import_user_action_feature_commands.UserActionFeatureCommands({
state: this.users
}), setUserNotificationLevel = async (username, level, expiringAt) => {
const binding = this.userNative.actionBinding(username);
await this.userActions.dispatch(
userCommands.notificationLevel(
username,
level,
actionDescriptors.userNotificationLevel({
username,
user: binding.user,
level,
expiringAt: expiringAt ?? null,
actingUser: binding.actingUser
})
)
);
};
this.composer = new import_native_composer.DiscourseComposerCoordinator({
host: options.host,
document: options.document,
isolation: this.composerIsolation,
parentScope: this.scope,
onError: (cause) => {
try {
options.lightbox?.onError?.(cause);
} catch {
}
}
}), this.composer.installSubmitGuard({
document: options.document,
parentScope: this.scope
});
const userObservationPresentation = (0, import_native_host_api.discourseNativeTopicPresentation)(options.host), userObservationPages = new import_reader_user_observation_page_repository.ReaderUserObservationPageRepository(
this.data.responses,
options.topic.authScope,
this.data.cacheCoordination
);
if (this.userObservations = new import_reader_user_observation_session.ReaderUserObservationSession({
requests: new import_discourse_user_observation_adapter.DiscourseUserObservationAdapter({
gateway: this.data.gateway,
ajax: this.nativeAjax,
authScope: options.topic.authScope,
cache: {
kind: "discourse-user-observation",
tags: ["users", "user-observation"],
freshForMs: 10 * 6e4,
retainForMs: options.topic.caches.posts.retainForMs,
persist: !0
},
categoryName: (categoryId) => userObservationPresentation.categoryName?.(categoryId) ?? ""
}),
storage: options.storage,
pages: userObservationPages,
authScope: options.topic.authScope,
historyCoordination: this.data.cacheCoordination,
historyCoordinationKey: `reader-user-observation-history:v1:${options.topic.authScope}`,
requestResume: (cause) => this.data.client.requestResume(cause),
notify: (message) => this.feedback.show(message),
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"user",
cause
)
}), this.scope.add(this.data.cacheCoordination.subscribeInvalidation((query) => {
this.users.applyExternalCacheInvalidation(query), this.userObservations.applyExternalCacheInvalidation(query);
})), this.userObservationView = new import_reader_user_observation_view.ReaderUserObservationView({
document: options.document,
mount: this.shell.view.surfaceHost,
session: this.userObservations,
storage: options.storage,
pages: userObservationPages,
avatarSource: (template, size) => this.userNative.avatarSource(template, size),
emojiSource: (id) => (0, import_native_host_api.discourseNativeEmojiUrl)(options.host, id),
openTarget: async (topicId, postNumber, record) => {
const boostId = record.kind === "boost" ? Number(record.identity.match(/^boost:(\d+)$/)?.[1]) : 0, result = await this.openTarget({
topicId,
postNumber,
...Number.isSafeInteger(boostId) && boostId > 0 ? { boostId } : {},
source: "link",
highlight: !0
});
return (result.topic.status === "opened" || result.topic.status === "reused") && result.navigation?.status === "revealed";
},
openChallenge: (username) => {
this.#openManualCloudflareChallenge(
this.#challengeHref,
username
);
},
notify: (message) => this.feedback.show(message),
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"user",
cause
)
}), this.chronicle = new import_reader_chronicle_repository.ReaderChronicleRepository({
storage: options.storage,
authScope: options.topic.authScope
}), this.chronicle.load(), this.chronicleView = new import_reader_chronicle_view.ReaderChronicleView({
document: options.document,
mount: this.shell.view.surfaceHost,
chronicle: this.chronicle,
storage: options.storage,
openTarget: async (topicId, postNumber, record) => {
const result = await this.openTarget({
topicId,
postNumber,
source: "chronicle",
highlight: !0,
...record.kind === "reply" ? {
cachedOnly: !0,
revealAsFloor: !0,
localArchive: Object.freeze({
status: record.status === "deleted" ? 410 : record.status,
confirmedAt: record.lastObservedAt,
requestPath: record.requestPath
})
} : {}
});
return (result.topic.status === "opened" || result.topic.status === "reused") && result.navigation?.status === "revealed";
},
notify: (message) => this.feedback.show(message),
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"history",
cause
)
}), this.unwantedTopics = new import_reader_unwanted_topic_repository.ReaderUnwantedTopicRepository({
storage: options.storage,
authScope: options.topic.authScope
}), this.unwantedTopics.load(), this.unwantedTopicView = new import_reader_unwanted_topic_view.ReaderUnwantedTopicView({
document: options.document,
mount: this.shell.view.surfaceHost,
topics: this.unwantedTopics,
...options.unwantedTopicFilter ? {
filterPreferences: options.unwantedTopicFilter,
filterCatalog: nativeUserCatalog
} : {},
storage: options.storage,
emojiSource: (id) => (0, import_native_host_api.discourseNativeEmojiUrl)(options.host, id),
openTarget: async (record) => {
const result = await this.openTarget({
topicId: record.topicId,
postNumber: 1,
source: "link",
highlight: !0
});
return result.topic.status === "opened" || result.topic.status === "reused";
},
notify: (message) => this.feedback.show(message),
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"history",
cause
)
}), this.userObservations.resume({ allowNetwork: !1 }), this.userCardView = new import_reader_user_card_view.ReaderUserCardView({
document: options.document,
root: this.shell.view.surfaceHost,
hoverDelegates: Object.freeze([Object.freeze({
root: options.document,
selector: hostTopicUserCardSelector,
capture: !0
})]),
longPressDelegates: Object.freeze([Object.freeze({
root: options.document,
selector: hostTopicAvatarLongPressSelector,
capture: !0
})]),
session: this.users,
userHref: (username) => this.userNative.requestIdentity(username),
avatarSource: (template, size) => this.userNative.avatarSource(template, size),
recoverAvatarSource: (source, signal) => this.#recoverAvatarSource(source, signal),
toggleFollow: async (username, followed) => {
await this.userActions.dispatch(userCommands.follow(
username,
followed,
actionDescriptors.userFollowToggle({ username, followed }),
(0, import_native_host_api.discourseNativeCurrentUsername)(options.host)
));
},
openMessage: async (username) => {
await this.composer.openPrivateMessage(username);
},
observeUser: (profile) => this.userObservationView.observe(profile),
isObserved: (username) => this.userObservations.isObserved(username),
setNotificationLevel: async (username, level, expiringAt) => {
await setUserNotificationLevel(username, level, expiringAt), this.feedback.show(
level === "normal" ? `已恢复 @${username} 的常规通知` : level === "mute" ? `已将 @${username} 设为免打扰` : `已忽略 @${username}`
);
},
ignoreUser: (username) => this.choiceForm.open({
title: `忽略 @${username}`,
intro: "Discourse 要求为“忽略”设置截止时间;到期后会自动恢复为常规。",
fieldLabel: "忽略期限",
mode: "select",
options: Object.freeze([
Object.freeze({ value: "1", label: "1 天" }),
Object.freeze({ value: "7", label: "1 周" }),
Object.freeze({ value: "30", label: "1 个月", selected: !0 }),
Object.freeze({ value: "120", label: "4 个月" }),
Object.freeze({ value: "365", label: "1 年" })
]),
submitLabel: "确认忽略",
emptySelectionError: "请选择有效的忽略期限",
submit: async ([value]) => {
const days = Number(value);
if (![1, 7, 30, 120, 365].includes(days))
throw new Error("请选择有效的忽略期限");
return await setUserNotificationLevel(
username,
"ignore",
new Date(Date.now() + days * 864e5).toISOString()
), `已忽略 @${username}`;
}
}),
endorseUser: async (profile) => {
const username = profile.identity.username, catalog = await this.userEndorsements.load(
username,
userAbort.signal
);
if (!catalog.categories.length)
throw new Error("当前没有可认可的类别");
const existingIds = new Set(
(profile.categoryExperts.endorsements ?? []).map((item) => item.categoryId)
);
return this.choiceForm.open({
title: `认可 @${username}`,
intro: catalog.remainingEndorsements === null ? "选择要认可的专家类别。" : `今天还可新增 ${catalog.remainingEndorsements} 次认可。`,
mode: "multiple",
options: Object.freeze(catalog.categories.map((category) => {
const existing = existingIds.has(category.id);
return Object.freeze({
value: String(category.id),
label: category.name,
selected: existing,
disabled: existing,
description: existing ? "已经认可" : "选择后将认可该用户为此类别的专家"
});
})),
submitLabel: "确认认可",
emptySelectionError: "请选择一个尚未认可的类别",
submit: async (values) => {
if (catalog.remainingEndorsements !== null && catalog.remainingEndorsements < 1)
throw new Error("今天的认可次数已用完");
const addedIds = values.map(Number).filter((categoryId) => Number.isSafeInteger(categoryId) && categoryId > 0 && !existingIds.has(categoryId));
if (!addedIds.length)
throw new Error("请选择一个尚未认可的类别");
return await this.userActions.dispatch(userCommands.endorse(
username,
actionDescriptors.categoryExpertEndorse({
username,
categoryIds: Object.freeze([
.../* @__PURE__ */ new Set([...existingIds, ...addedIds])
])
})
)), "认可已提交";
},
signal: userAbort.signal
}).catch((cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"user",
cause
)), !0;
},
...options.lightbox ? {
openMedia: (items, initialIndex, anchor, profile, returnFocus) => {
const viewer = this.userMediaViewer, descriptor = items[initialIndex];
if (!viewer || !descriptor)
throw new Error(
"用户媒体紧凑查看器尚未装配"
);
const item = Object.freeze({
key: `user:${descriptor.kind}:${descriptor.src}`,
previewSrc: descriptor.src,
originalSrc: descriptor.originalSrc ?? descriptor.src,
alt: descriptor.alt,
topicId: this.shell.activeTopicId ?? 1,
sourcePostNumber: 1,
imageOrder: initialIndex
});
viewer.open({
item,
kind: descriptor.kind === "avatar" ? "avatar" : "background",
anchor,
returnFocus: () => returnFocus ?? anchor,
outsideSafeSurface: anchor,
flair: descriptor.kind === "avatar" ? profile.flair : null,
...this.imageDownloads ? {
onDownload: async () => {
await this.imageDownloads.download(
item,
initialIndex,
{ original: !0 }
);
}
} : {},
onDismiss: () => this.userCardView.close()
});
}
} : {},
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"user",
cause
)
}), this.#applyPerformanceInfrastructure(), this.translationRequests = options.translation ? new import_translation_request_adapter.TranslationRequestAdapter({
...options.translation,
gateway: this.data.gateway
}) : null, this.scope.add(() => this.translationRequests?.destroy()), options.translationView === !1)
this.translationFeature = null;
else if (this.translationRequests) {
const {
buttonHost,
initialMode,
renderIcon: translationRenderIcon,
onError: translationOnError,
...translationViewOptions
} = options.translationView ?? {}, resolvedButtonHost = buttonHost ?? this.shell.view.root.querySelector(
".ldp-head-btns"
);
if (!resolvedButtonHost)
throw new Error("翻译 View 缺少稳定 Shell headerActions");
this.translationFeature = new import_reader_translation_feature.ReaderTranslationFeature({
...translationViewOptions,
document: options.document,
translator: this.translationRequests,
buttonHost: resolvedButtonHost,
surfaces: () => {
const discussion = this.shell.view.modal.querySelector(
":scope > .ldp-descendant-replies-layer"
);
return Object.freeze([
this.shell.view.body,
...discussion ? [discussion] : []
]);
},
...translationRenderIcon ? { renderIcon: translationRenderIcon } : options.renderIcon ? {
renderIcon: (document) => options.renderIcon("languages", document)
} : {},
initialMode: initialMode ?? "original",
parentScope: this.scope,
notify: (message) => this.feedback.show(message),
onError: (cause) => {
try {
translationOnError?.(cause);
} catch {
}
reportTopicFeature(
this.shell.activeTopicId ?? 0,
"translation",
cause
);
}
});
} else {
if (options.translationView)
throw new Error(
"翻译 View 需要先配置唯一 TranslationRequestAdapter"
);
this.translationFeature = null;
}
if (options.resources) {
const {
objectUrls,
maxObjectUrls,
downloadMount,
downloadUrlRevokeAfterMs,
...resourceOptions
} = options.resources;
this.resourceRequests = new import_public_resource_request_adapter.PublicResourceRequestAdapter({
...resourceOptions,
gateway: this.data.gateway
}), this.imageResources = new import_reader_image_resource_service.ReaderImageResourceService({
resources: this.resourceRequests,
objectUrls,
...maxObjectUrls === void 0 ? {} : { maxObjectUrls },
parentScope: this.scope
}), this.mediaPrefetch = new import_reader_media_prefetch_service.ReaderMediaPrefetchService({
document: options.document,
baseUrl: resourceOptions.baseUrl,
resources: this.resourceRequests,
concurrency: 2
}), this.blobDownloads = new import_reader_image_download_service.BrowserBlobDownloadPort({
document: options.document,
mount: downloadMount ?? options.document.body ?? options.document.documentElement,
objectUrls,
...downloadUrlRevokeAfterMs === void 0 ? {} : { revokeAfterMs: downloadUrlRevokeAfterMs },
parentScope: this.scope
}), this.imageDownloads = new import_reader_image_download_service.ReaderImageDownloadService({
resources: this.imageResources,
downloads: this.blobDownloads
});
} else
this.resourceRequests = null, this.imageResources = null, this.mediaPrefetch = null, this.imageDownloads = null, this.blobDownloads = null;
this.userMediaViewer = options.lightbox ? new import_reader_compact_image_viewer.ReaderCompactImageViewer({
document: options.document,
mount: typeof options.lightbox.mount == "function" ? options.lightbox.mount() : options.lightbox.mount,
...this.imageResources || options.lightbox.originalSources ? {
originalSources: this.imageResources ?? options.lightbox.originalSources
} : {},
...options.lightbox.frameScheduler ? { frameScheduler: options.lightbox.frameScheduler } : {},
notify: (message) => this.feedback.show(message),
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"user",
cause
)
}) : null;
const {
onReady: topicReady,
createDomOptions,
...topicFactoryOptions
} = options.topicFactory, topicBaseUrl = options.topic.origin ?? options.data.rateLimit.baseUrl ?? options.document.baseURI, nativeRelativeTime = (0, import_native_host_api.discourseNativeRelativeTimeFormatter)(options.host), nativeExactTime = (0, import_native_host_api.discourseNativeExactTimeFormatter)(options.host), nativeTopicPresentation = (0, import_native_host_api.discourseNativeTopicPresentation)(options.host), nativeTopicEditCatalog = (0, import_native_host_api.discourseNativeTopicEditCatalog)(options.host), nativePresence = new import_native_presence.BrowserDiscoursePresencePort(options.host), nativeTopicLinks = (0, import_native_host_api.discourseNativeTopicLinks)(options.host, topicBaseUrl), topicSummaryTransport = (() => {
try {
return new URL(
topicBaseUrl,
options.document.baseURI
).hostname.toLocaleLowerCase() === "linux.do" ? new import_discourse_native_read_transport.BrowserDiscourseNativeMutationTransport(this.nativeAjax) : null;
} catch {
return null;
}
})(), nativeFlagCatalog = (0, import_native_host_api.discourseNativeFlagCatalog)(options.host), nativeEmojiMenu = (0, import_native_host_api.discourseNativeEmojiMenu)(options.host), nativeAdminMenu = (0, import_native_host_api.discourseNativePostAdminMenu)(options.host, {
computePosition: (anchor, content) => {
(0, import_reader_native_post_admin_menu.positionReaderNativePostAdminMenu)({
document: options.document,
reader: this.shell.view.modal,
anchor,
content
});
}
}), nativePostModels = new import_native_post_model_factory.DiscourseNativePostModelFactory(options.host), nativeBookmarkForm = new import_native_host_api.BrowserDiscourseNativeBookmarkForm(options.host);
this.postReactions = new import_reader_post_action_feature.DiscoursePostReactionCatalog(
nativePostModels
);
const postReactions = this.postReactions, topicFeatures = /* @__PURE__ */ new WeakMap(), topicDoms = /* @__PURE__ */ new WeakMap(), topicContextSurfaces = /* @__PURE__ */ new WeakMap(), topicHeaders = /* @__PURE__ */ new WeakMap(), topicHeaderViews = /* @__PURE__ */ new WeakMap(), topicOnlyOpControllers = /* @__PURE__ */ new WeakMap(), topicNavigations = /* @__PURE__ */ new WeakMap(), topicFlows = /* @__PURE__ */ new WeakMap(), topicTimelines = /* @__PURE__ */ new WeakMap(), topicTimelineViews = /* @__PURE__ */ new WeakMap(), topicLiveNavigations = /* @__PURE__ */ new WeakMap(), topicLiveNavigationViews = /* @__PURE__ */ new WeakMap(), topicSummaryPreviews = /* @__PURE__ */ new WeakMap(), authenticatedCollectionScope = options.topic.authScope.startsWith("account:");
if (options.notifications === !1 || !authenticatedCollectionScope)
this.notificationNative = null, this.notificationRequests = null, this.notificationActions = null, this.notificationController = null, this.notificationPanelView = null;
else {
const notificationOptions = options.notifications ?? {}, notificationAbort = this.scope.abortController(
new Error("Reader 通知 application scope 已销毁")
);
this.notificationNative = new import_native_host_api.BrowserDiscourseNotificationNativeState(options.host), this.notificationRequests = new import_discourse_notification_adapter.DiscourseNotificationRequestAdapter({
gateway: this.data.gateway,
ajax: this.nativeAjax,
native: this.notificationNative,
authScope: options.topic.authScope,
signal: notificationAbort.signal,
...options.topic.basePath === void 0 ? {} : { basePath: options.topic.basePath },
replyExpansionCache: {
kind: "discourse-topic-posts",
tags: ["notifications"],
...options.topic.caches.posts
},
categoryNameFor: (categoryId) => userObservationPresentation.categoryName?.(categoryId) ?? ""
});
const notificationMutation = new import_action_request_adapter.ActionRequestAdapter({
gateway: this.data.gateway,
nativeActions,
authScope: options.topic.authScope,
signal: notificationAbort.signal
});
this.notificationActions = new import_post_action_controller.PostActionController({
mutation: notificationMutation,
cache: this.data.responses,
scope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"notification",
cause
)
}), this.notificationController = new import_reader_notification_controller.ReaderNotificationController({
requests: this.notificationRequests,
projection: new import_reader_collection_page_repository.ReaderCollectionPageRepository({
responses: this.data.responses,
authScope: options.topic.authScope,
namespace: "notifications",
kind: "reader-notification-projection",
tags: ["notification-projection"],
normalizeRecord: import_reader_notification_model.normalizeStoredReaderNotification,
sortRecords: import_reader_notification_model.sortReaderNotifications,
pageSize: 60,
retainForMs: 4320 * 60 * 6e4,
permanent: !0,
coordination: this.data.cacheCoordination
}),
native: this.notificationNative,
actions: this.notificationActions,
cache: this.data.responses,
target: {
openTarget: async (request) => {
const result = await this.openTarget(request);
return (result.topic.status === "opened" || result.topic.status === "reused") && result.navigation?.status === "revealed";
}
},
...notificationOptions.maxCachedPages === void 0 ? {} : {
maxCachedPages: notificationOptions.maxCachedPages
},
...notificationOptions.liveRefreshDelayMs === void 0 ? {} : {
liveRefreshDelayMs: notificationOptions.liveRefreshDelayMs
},
backgroundWarmDelayMs: notificationOptions.backgroundWarmDelayMs ?? 1800,
...notificationOptions.openRevalidateMs === void 0 ? {} : {
openRevalidateMs: notificationOptions.openRevalidateMs
},
...notificationOptions.nativePollIntervalMs === void 0 ? {} : {
nativePollIntervalMs: notificationOptions.nativePollIntervalMs
},
...notificationOptions.syntheticPollIntervalMs === void 0 ? {} : {
syntheticPollIntervalMs: notificationOptions.syntheticPollIntervalMs
},
...notificationOptions.historyStepDelayMs === void 0 ? {} : {
historyStepDelayMs: notificationOptions.historyStepDelayMs
},
...notificationOptions.historyRetryDelayMs === void 0 ? {} : {
historyRetryDelayMs: notificationOptions.historyRetryDelayMs
},
visibleHistoryConcurrency: notificationOptions.visibleHistoryConcurrency ?? 3,
historyCoordination: this.data.cacheCoordination,
historyCoordinationKey: `reader-notification-history:v1:${options.topic.authScope}`,
activity: this.activity,
...notificationOptions.schedule === void 0 ? {} : { schedule: notificationOptions.schedule },
...notificationOptions.cancel === void 0 ? {} : { cancel: notificationOptions.cancel },
...(notificationOptions.searchForms ?? options.searchForms) === void 0 ? {} : {
searchForms: notificationOptions.searchForms ?? options.searchForms
},
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"notification",
cause
)
}), this.notificationPanelView = new import_reader_notification_panel_view.ReaderNotificationPanelView({
...notificationOptions,
...notificationOptions.renderIcon ? {} : options.renderIcon ? { renderIcon: options.renderIcon } : {},
document: options.document,
mount: this.shell.view.surfaceHost,
storage: options.storage,
controller: this.notificationController,
elements: readerNotificationPanelElements(
this.shell.view.root
),
baseUrl: topicBaseUrl,
relativeTime: nativeRelativeTime,
emojiSource: notificationOptions.emojiSource ?? ((id) => (0, import_native_host_api.discourseNativeEmojiUrl)(options.host, id)),
archiveMarker: (topicId, postNumber) => this.#historyArchiveMarker(topicId, postNumber),
notify: (message) => this.feedback.show(message),
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"notification",
cause
)
});
}
if (options.bookmarks === !1 || !authenticatedCollectionScope)
this.bookmarkNative = null, this.bookmarkRequests = null, this.bookmarkActions = null, this.bookmarkController = null, this.bookmarkPanelView = null;
else {
const bookmarkOptions = options.bookmarks ?? {}, bookmarkAbort = this.scope.abortController(
new Error("Reader 收藏 application scope 已销毁")
);
this.bookmarkNative = new import_native_host_api.BrowserDiscourseBookmarkNativeState(options.host), this.bookmarkRequests = new import_discourse_bookmark_adapter.DiscourseBookmarkRequestAdapter({
gateway: this.data.gateway,
ajax: this.nativeAjax,
native: this.bookmarkNative,
authScope: options.topic.authScope,
signal: bookmarkAbort.signal,
cache: {
kind: "discourse-bookmark-collection",
tags: [
"bookmarks",
"reactions-given",
"boosts-given",
"replied-topics"
],
freshForMs: 30 * 6e4,
retainForMs: options.topic.caches.posts.retainForMs,
persist: !0
},
categoryNameFor: (categoryId) => userObservationPresentation.categoryName?.(categoryId) ?? ""
});
const bookmarkMutation = new import_action_request_adapter.ActionRequestAdapter({
gateway: this.data.gateway,
nativeActions,
authScope: options.topic.authScope,
signal: bookmarkAbort.signal
});
this.bookmarkActions = new import_post_action_controller.PostActionController({
mutation: bookmarkMutation,
cache: this.data.responses,
scope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"bookmark",
cause
)
}), this.bookmarkController = new import_reader_bookmark_controller.ReaderBookmarkController({
requests: this.bookmarkRequests,
projection: new import_reader_collection_page_repository.ReaderCollectionPageRepository({
responses: this.data.responses,
authScope: options.topic.authScope,
namespace: "bookmarks",
kind: "reader-bookmark-projection",
tags: ["bookmark-projection"],
normalizeRecord: import_reader_bookmark_model.normalizeStoredReaderBookmark,
sortRecords: import_reader_bookmark_model.sortReaderBookmarkRecords,
pageSize: 60,
retainForMs: 4320 * 60 * 6e4,
permanent: !0,
coordination: this.data.cacheCoordination
}),
native: this.bookmarkNative,
actions: this.bookmarkActions,
reactionEvents: this.#collectionActionEvents,
activityEvents: this.#collectionActionEvents,
cache: this.data.responses,
target: {
openTarget: async (request) => {
const result = await this.openTarget(request);
return (result.topic.status === "opened" || result.topic.status === "reused") && result.navigation?.status === "revealed";
}
},
...bookmarkOptions.tabOrder === void 0 ? {} : { tabOrder: bookmarkOptions.tabOrder },
...bookmarkOptions.pageSize === void 0 ? {} : { pageSize: bookmarkOptions.pageSize },
...bookmarkOptions.liveRefreshDelayMs === void 0 ? {} : {
liveRefreshDelayMs: bookmarkOptions.liveRefreshDelayMs
},
backgroundWarmDelayMs: bookmarkOptions.backgroundWarmDelayMs ?? 2400,
visibleHistoryConcurrency: bookmarkOptions.visibleHistoryConcurrency ?? 3,
historyCoordination: this.data.cacheCoordination,
historyCoordinationKey: `reader-bookmark-history:v1:${options.topic.authScope}`,
activity: this.activity,
...bookmarkOptions.changeTabOrder === void 0 ? {} : {
changeTabOrder: bookmarkOptions.changeTabOrder
},
...(bookmarkOptions.searchForms ?? options.searchForms) === void 0 ? {} : {
searchForms: bookmarkOptions.searchForms ?? options.searchForms
},
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"bookmark",
cause
)
}), this.bookmarkPanelView = new import_reader_bookmark_panel_view.ReaderBookmarkPanelView({
...bookmarkOptions,
...bookmarkOptions.renderIcon ? {} : options.renderIcon ? { renderIcon: options.renderIcon } : {},
document: options.document,
mount: this.shell.view.surfaceHost,
storage: options.storage,
controller: this.bookmarkController,
elements: readerBookmarkPanelElements(
this.shell.view.root
),
baseUrl: topicBaseUrl,
relativeTime: nativeRelativeTime,
emojiSource: bookmarkOptions.emojiSource ?? ((id) => (0, import_native_host_api.discourseNativeEmojiUrl)(options.host, id)),
archiveMarker: (topicId, postNumber) => this.#historyArchiveMarker(topicId, postNumber),
reactionIconSource: bookmarkOptions.reactionIconSource ?? ((reaction) => (0, import_native_host_api.discourseNativeEmojiUrl)(
options.host,
reaction
)),
confirmDelete: bookmarkOptions.confirmDelete ?? ((request) => this.feedback.confirm({
title: request.title,
message: request.message,
note: "该操作会同步到 Discourse 收藏。",
confirmLabel: request.confirmLabel,
tone: "danger",
details: [{
label: "收藏数量",
value: String(request.count)
}]
})),
notify: (message) => this.feedback.show(message),
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"bookmark",
cause
)
});
}
this.notificationController?.startBackgroundCache(), this.bookmarkController?.startBackgroundCache();
const selfObservationUsername = (0, import_native_host_api.discourseNativeCurrentUsername)(options.host);
if (selfObservationUsername) {
const currentUser = (0, import_native_host_api.discourseNativeCurrentUser)(options.host);
this.userObservations.observeSelf({
username: selfObservationUsername,
name: String(readerNativeModelValue(currentUser, "name") ?? "").trim(),
avatarTemplate: String(
readerNativeModelValue(currentUser, "avatar_template") ?? ""
).trim()
});
}
const coreTopicFactory = (0, import_reader_topic_factory.createReaderTopicFactory)({
...topicFactoryOptions,
document: options.document,
onPhase: (phase, context) => {
options.loadingProgress?.update({
topicId: Number(context.topicId),
phase
});
},
createDomOptions: (bundle, context, root) => {
bundle.services.actions.events.subscribe(
(event) => this.#collectionActionEvents.emit(event),
context.scope
);
const currentUsername = (0, import_native_host_api.discourseNativeCurrentUsername)(options.host), topicImages = new import_reader_topic_image_index.ReaderTopicImageIndex({
document: options.document,
baseUrl: topicBaseUrl,
topicId: context.topicId,
session: bundle.services.session,
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"image-index",
cause
)
}), {
onError: mediaOnError,
visibility: mediaVisibility,
renderRetryIcon,
onLayoutChanged: mediaImageLayoutChanged,
onContentLayoutChanged: mediaContentLayoutChanged,
...mediaOptions
} = options.media ?? {}, notifyTopicLayoutChanged = () => {
topicDoms.get(context.scope)?.notifyContentLayoutChanged();
}, topicMedia = new import_reader_topic_media_feature.ReaderTopicMediaFeature({
...mediaOptions,
...options.renderIcon ? { renderIcon: options.renderIcon } : {},
...renderRetryIcon ? { renderRetryIcon } : options.renderIcon ? {
renderRetryIcon: (document) => options.renderIcon(
"rotate-ccw",
document
)
} : {},
document: options.document,
baseUrl: topicBaseUrl,
visibility: mediaVisibility ?? (() => options.document.visibilityState),
onLayoutChanged: (image) => {
mediaImageLayoutChanged?.(image), notifyTopicLayoutChanged();
},
onContentLayoutChanged: (root2) => {
mediaContentLayoutChanged?.(root2), notifyTopicLayoutChanged();
},
parentScope: context.scope,
onError: (cause) => {
try {
mediaOnError?.(cause);
} catch {
}
reportTopicFeature(
context.topicId,
"post-media",
cause
);
}
}), topicCookedContent = new import_reader_cooked_content_feature.ReaderCookedContentFeature({
document: options.document,
mount: this.shell.view.modal,
baseUrl: topicBaseUrl,
...options.share ? { clipboard: options.share } : {},
...this.blobDownloads ? { downloads: this.blobDownloads } : {},
notify: (message) => this.feedback.show(message),
onLayoutChanged: notifyTopicLayoutChanged,
onPrepared: (root2) => {
this.translationFeature?.syncPost(root2);
},
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"cooked-content",
cause
)
}), domOptions = createDomOptions(
bundle,
context,
root,
Object.freeze({
composer: this.composer,
presentation: nativeTopicPresentation,
relativeTime: nativeRelativeTime,
exactTime: nativeExactTime,
currentUsername,
recoverAvatarSource: (source, signal) => this.#recoverAvatarSource(source, signal)
})
), topicHeaderElements = readerTopicHeaderElements(
this.shell.view.root
), topicDetails = readerShellElement(
this.shell.view.root,
".ldp-topic-details",
"帖子详情"
), replyTreePresentation = domOptions.replyTreePresentation ?? new import_reader_reply_tree_preferences.ReaderReplyTreePresentation(
bundle.replies.topology,
domOptions.replyTreePreferences?.read(),
{
canonicalCoverageComplete: () => bundle.replies.coverage().complete,
canonicalPostStreamRevision: () => bundle.session.postStreamRevision ?? 0,
canonicalPostStreamGapCount: (postNumber, previousRootPostNumber) => bundle.session.postStreamGapCount?.(
previousRootPostNumber,
postNumber
)
}
), topicPresentationChanges = new import_signal.Signal(), topicPostCommands = new import_post_action_feature_commands.PostActionFeatureCommands(
bundle.services.postActions
), topicPoll = new import_reader_poll_feature.ReaderTopicPollFeature({
document: options.document,
actions: bundle.services.actions,
commands: topicPostCommands,
descriptors: actionDescriptors,
readPost: (postId) => bundle.services.session.postById(postId),
viewer: () => readerPollViewer(
nativePostModels.currentUser(),
currentUsername
),
topicArchived: () => bundle.services.session.topic?.archived === !0,
emojiSource: (id) => (0, import_native_host_api.discourseNativeEmojiUrl)(options.host, id),
notify: (message) => this.feedback.show(message),
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"poll",
cause
)
}), topicBookmarkActions = new import_reader_bookmark_action_coordinator.ReaderBookmarkActionCoordinator({
topicId: context.topicId,
session: bundle.services.session,
actions: bundle.services.actions,
postCommands: topicPostCommands,
descriptors: actionDescriptors,
forms: nativeBookmarkForm,
models: nativePostModels
}), topicShareActions = options.share ? new import_reader_share_action_coordinator.ReaderShareActionCoordinator({
topicId: context.topicId,
topic: () => {
const topic = bundle.services.session.topic;
if (!topic)
throw new Error(
"分享链接时 canonical Topic 尚未就绪"
);
return topic;
},
links: nativeTopicLinks,
surface: options.share,
fallbackTitle: () => options.document.title || "LINUX DO"
}) : null, topicNotificationActions = new import_reader_topic_notification_coordinator.ReaderTopicNotificationCoordinator({
topicId: context.topicId,
session: bundle.services.session,
actions: bundle.services.actions,
descriptors: actionDescriptors,
models: nativePostModels
}), topicSharedIssueActions = new import_reader_topic_shared_issue_coordinator.ReaderTopicSharedIssueCoordinator({
topicId: context.topicId,
session: bundle.services.session,
actions: bundle.services.actions,
descriptors: actionDescriptors,
settings: nativePostModels,
currentUsername
}), assignmentAbort = context.scope.abortController(
new Error("Reader Topic 指定表单生命周期已结束")
), topicManagementActions = new import_reader_post_management_action_coordinator.ReaderPostManagementActionCoordinator({
topicId: context.topicId,
session: bundle.services.session,
actions: bundle.services.actions,
postCommands: topicPostCommands,
descriptors: actionDescriptors,
models: nativePostModels,
composer: this.composer,
assignments: this.assignmentForm,
assignmentSignal: assignmentAbort.signal,
feedback: this.feedback,
adminMenu: nativeAdminMenu,
onError: (cause) => reportTopicFeature(
context.topicId,
"post-action",
cause
)
}), topicPostActions = new import_reader_post_action_feature.ReaderPostActionFeature({
document: options.document,
...options.renderIcon ? { renderIcon: options.renderIcon } : {},
surfaceHost: this.shell.view.surfaceHost,
topic: () => {
const topic = bundle.services.session.topic;
if (!topic)
throw new Error(
"楼层动作渲染时 canonical Topic 尚未就绪"
);
return topic;
},
actions: bundle.services.actions,
commands: topicPostCommands,
descriptors: actionDescriptors,
models: nativePostModels,
reactions: postReactions,
capabilityInput: (post) => {
const topic = bundle.services.session.topic, currentUser = nativePostModels.currentUser();
return Object.freeze({
post,
...topic ? { topic } : {},
...currentUser ? { currentUser } : {},
currentUsername: (0, import_native_host_api.discourseNativeCurrentUsername)(
options.host
),
plugins: Object.freeze({
boosts: (0, import_native_host_api.discourseNativeBoostsAvailable)(
options.host
) || Object.hasOwn(post, "can_boost") || Array.isArray(post.boosts)
})
});
},
topicActionRail: !!options.topicActionRail,
refreshMissingCapabilities: async (post) => {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(
post.post_number
);
postNumber !== null && await bundle.services.session.loadTarget(postNumber, {
scope: "around",
forceRefresh: !0,
advanceCursor: !1
});
},
presentation: nativeTopicPresentation,
currentUsername,
...options.boostCopy ? {
readBoostCopySettings: options.boostCopy.readSettings
} : {},
emojiMenu: nativeEmojiMenu,
confirmBoostDelete: ({ username }) => this.feedback.confirm({
title: "删除 Boost",
message: username ? `确认删除 @${username} 的这条 Boost?` : "确认删除这条 Boost?",
note: "该操作会同步到 Discourse。",
confirmLabel: "删除",
tone: "danger"
}),
reportBoost: async ({
postId,
boostId,
username
}) => {
const access = await bundle.services.boostReportAccess.load(
boostId
);
if (!access.canFlag)
throw new Error(
access.alreadyFlagged ? "你已经举报过这个 Boost" : "当前账号不能举报这个 Boost"
);
const available = new Set(
access.availableFlagNames
), reportOptions = readerReportOptions(
options.document,
nativeFlagCatalog.flagTypes(),
available,
"DiscourseBoosts::Boost"
), reportedUsername = access.username || username;
return this.reportForm.open({
title: "举报 Boost",
intro: reportedUsername ? `举报 @${reportedUsername} 的 Boost 会直接提交给社区,不会离开阅读器。` : "举报会直接提交给社区,不会离开阅读器。",
options: reportOptions,
messageMaxLength: nativeFlagCatalog.messageMaxLength(),
submit: async ({
optionId,
message
}) => (await bundle.services.actions.dispatch(
topicPostCommands.boostReport(
postId,
actionDescriptors.boostReport({
boostId,
flagTypeId: optionId,
...message ? { message } : {}
})
)
), "举报已提交")
});
},
reportPost: async (post) => {
const topic = bundle.services.session.topic;
if (!topic)
throw new Error(
"楼层举报时 canonical Topic 尚未就绪"
);
const flagTypes = nativeFlagCatalog.flagTypes(), native = nativePostModels.reportContext(
topic,
post,
flagTypes.map((flag) => flag.nameKey)
), actionByName = new Map(
native.actions.map((entry) => [
entry.nameKey,
entry.action
])
), reportOptions = readerReportOptions(
options.document,
flagTypes,
new Set(actionByName.keys()),
"Post"
), flagById = new Map(
flagTypes.map((flag) => [
flag.id,
flag
])
), postId = Number(post.id), postNumber = Number(post.post_number), topicStarter = postNumber === 1;
return this.reportForm.open({
title: topicStarter ? "举报主题" : "举报楼层",
intro: topicStarter ? "举报主题会直接提交给社区,不会离开阅读器。" : postNumber > 0 ? `举报 #${postNumber} 楼会直接提交给社区,不会离开阅读器。` : "举报会直接提交给社区,不会离开阅读器。",
options: reportOptions,
messageMaxLength: nativeFlagCatalog.messageMaxLength(),
submit: async ({
optionId,
message
}) => {
const flag = flagById.get(optionId), postAction = flag ? actionByName.get(flag.nameKey) : null;
if (!flag || !postAction)
throw new Error(
"当前举报类型已不可用,请重新打开表单"
);
return await bundle.services.actions.dispatch(
topicPostCommands.report(
postId,
actionDescriptors.postReport({
postId,
post: native.post,
postAction,
flagTypeId: optionId,
...message ? { message } : {}
})
)
), "举报已提交";
}
});
},
bookmarks: topicBookmarkActions,
...topicShareActions ? { shares: topicShareActions } : {},
topicNotifications: topicNotificationActions,
sharedIssue: topicSharedIssueActions,
management: topicManagementActions,
notify: (message) => this.feedback.show(message),
composer: this.composer,
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"post-action",
cause
)
});
let topicActionRail = null;
const topicSummaryImagePicker = this.imageResources ? new import_reader_lightbox_image_picker.ReaderLightboxImagePicker({
document: options.document,
mount: this.shell.view.surfaceHost,
catalog: topicImages,
originalSources: this.imageResources,
maximumSelected: 6,
notify: (message) => this.feedback.show(message),
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"image-index",
cause
)
}) : null, topicSummaryPreview = options.lightbox && options.resources ? { open: null } : null;
topicSummaryPreview && (topicSummaryPreviews.set(context.scope, topicSummaryPreview), context.scope.add(() => topicSummaryPreviews.delete(context.scope)));
const topicSummarySurface = topicSummaryTransport && options.topicActionRail ? new import_reader_topic_summary_surface.ReaderTopicSummarySurface({
document: options.document,
mount: this.shell.view.surfaceHost,
request: new import_reader_topic_summary_request_adapter.ReaderTopicSummaryRequestAdapter({
gateway: this.data.gateway,
transport: topicSummaryTransport,
authScope: options.topic.authScope,
topicId: context.topicId,
signal: context.signal,
...options.topic.basePath === void 0 ? {} : { basePath: options.topic.basePath }
}),
...this.translationRequests ? {
aiModels: this.translationRequests,
customRequest: new import_reader_topic_custom_summary.ReaderTopicCustomSummaryRequestAdapter({
document: options.document,
baseUrl: topicBaseUrl,
session: bundle.services.session,
topology: bundle.services.replies.topology,
completion: this.translationRequests,
signal: context.signal
})
} : {},
...topicSummaryImagePicker && this.imageResources ? {
imagePicker: topicSummaryImagePicker,
imageResources: this.imageResources
} : {},
uploader: new import_reader_topic_summary_request_adapter.ReaderTopicSummaryImageUploadAdapter({
gateway: this.data.gateway,
transport: topicSummaryTransport,
authScope: options.topic.authScope,
topicId: context.topicId,
signal: context.signal,
createFormData: () => {
const Constructor = options.document.defaultView?.FormData ?? FormData;
return new Constructor();
},
...options.topic.basePath === void 0 ? {} : { basePath: options.topic.basePath }
}),
topicTitle: () => {
const topic = bundle.services.session.topic;
return String(
topic?.title ?? options.document.title ?? ""
);
},
topicUrl: () => nativeTopicLinks.topicHref(context.topicId),
openReply: async (raw) => {
const topic = bundle.services.session.topic, firstPost = bundle.services.session.postByNumber(1);
if (!topic || !firstPost)
throw new Error("当前主题 #1 楼尚未就绪");
await this.composer.openReply({
topic,
post: firstPost,
initialRaw: raw
});
},
...options.share ? { clipboard: options.share } : {},
...this.blobDownloads ? { downloads: this.blobDownloads } : {},
settingsStorage: options.storage,
positionMode: () => this.shell.view.root.dataset.readerWorkspaceMode ?? "floating",
...options.topicSummaryFonts ? { fonts: options.topicSummaryFonts } : {},
...topicSummaryPreview ? {
previewImage: (input) => {
if (!topicSummaryPreview.open)
throw new Error("主题灯箱尚未完成装配");
topicSummaryPreview.open(input);
}
} : {},
notify: (message) => this.feedback.show(message),
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"post-action",
cause
)
}) : null;
topicSummarySurface && (this.#topicSummarySurfaces.add(topicSummarySurface), context.scope.add(() => {
this.#topicSummarySurfaces.delete(topicSummarySurface);
})), topicActionRail = options.topicActionRail ? new import_reader_topic_action_rail.ReaderTopicActionRail({
document: options.document,
mount: this.shell.view.modal,
shellRoot: this.shell.view.root,
identity: domOptions.identity,
actions: topicPostActions,
preferences: options.topicActionRail,
onlyOpToggle: topicHeaderElements.onlyOpToggle,
jumpToTop: async () => {
const timeline = topicTimelines.get(context.scope);
if (!timeline)
throw new Error(
"主帖操作列跳转时 Topic timeline 尚未就绪"
);
const result = await timeline.jumpTo(1, {
alignment: "start",
highlight: !0
});
if (result.status !== "revealed")
throw new Error(
`主帖操作列回顶失败:${result.status}`
);
},
...topicSummarySurface ? { openTopicSummary: () => topicSummarySurface.open() } : {},
...options.downloadCurrentTopic ? { downloadCurrentTopic: options.downloadCurrentTopic } : {},
openChronicle: () => this.chronicleView.open(),
openUnwantedTopics: () => this.unwantedTopicView.open(),
openUserObservations: () => this.userObservationView.openList(),
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"post-action",
cause
)
}) : null, topicActionRail && (0, import_reader_topic_action_rail.bindReaderTopicActionRailStarter)({
readStarter: () => bundle.services.session.postByNumber(1),
loadStarter: () => bundle.services.session.loadTarget(1, {
scope: "single",
advanceCursor: !1
}),
waitUntilReady: () => bundle.services.session.init(),
subscribe: (listener, scope) => bundle.services.session.changes.subscribe(
listener,
scope
),
update: (starter) => topicActionRail.update(starter),
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"post-action",
cause
)
});
const topicSpecialContent = new import_reader_topic_special_content_feature.ReaderTopicSpecialContentFeature({
document: options.document,
session: bundle.services.session,
presentationChanges: topicPresentationChanges,
presentation: nativeTopicPresentation,
relativeTime: nativeRelativeTime,
actions: bundle.services.actions,
commands: topicPostCommands,
descriptors: actionDescriptors,
models: nativePostModels,
loadPostVotingComments: (postId, afterCommentId) => bundle.services.requests.loadPostVotingComments(postId, {
afterCommentId,
refresh: !0
}),
...options.renderIcon ? { renderIcon: options.renderIcon } : {},
navigate: async (postNumber) => {
const navigation = topicNavigations.get(
context.scope
);
if (!navigation)
throw new Error(
"特殊正文跳转时 Topic navigation 尚未就绪"
);
const result = await navigation.navigate({
postNumber,
source: "solved-answer",
alignment: "center",
highlight: !0
});
if (result.status !== "revealed")
throw new Error(
`特殊正文楼层 #${postNumber} 跳转失败:${result.status}`
);
},
onBodyLayerChanged: (view) => {
topicCookedContent.refresh(view), topicMedia.refresh(view), this.translationFeature?.syncPost(
view.slots.root
), notifyTopicLayoutChanged();
},
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"special-content",
cause
)
}), topicContext = new import_reader_topic_context_controller.ReaderTopicContextController({
session: bundle.services.session,
replies: bundle.replies,
loadCrossTopicQuotedPost: async (targetTopicId, targetPostNumber) => {
const targetOptions = { scope: "single" };
let lastError;
for (const candidate of bundle.services.requests.targetCandidates(
targetPostNumber,
targetOptions,
targetTopicId
))
try {
const payload = await bundle.services.requests.loadTargetCandidate(
candidate,
targetPostNumber,
targetOptions,
targetTopicId
), post = (0, import_topic_session.discoursePostsFromPayload)(payload).find((value) => Number(value.post_number) === targetPostNumber);
if (post) return post;
} catch (error) {
lastError = error;
const status = Number(
error?.status
);
if (error instanceof DOMException && error.name === "AbortError") throw error;
if ((0, import_native_request_descriptors.discourseNativeTargetFailureIsDefinitive)({
endpoint: candidate.endpoint,
scope: targetOptions.scope,
status
})) return null;
if ([401, 403, 429].includes(status)) throw error;
}
if (lastError) throw lastError;
return null;
},
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"thread-context",
cause
)
}), topicScroll = new import_reader_topic_scroll_adapter.ReaderTopicScrollAdapter({
...options.navigation,
scrollRoot: this.shell.view.body,
viewportChangeTarget: this.shell.view.root,
parentScope: context.scope
}), topicContextFeature = new import_reader_topic_context_surface.ReaderTopicContextFeature({
document: options.document,
...options.renderIcon ? { renderIcon: options.renderIcon } : {},
controller: topicContext,
replies: bundle.replies,
presentationChanges: topicPresentationChanges,
presentation: replyTreePresentation,
avatarSource: (template, size) => nativeTopicPresentation.avatarSource(template, size),
scrollRoot: this.shell.view.body,
quoteHintHost: this.shell.view.surfaceHost,
notify: (message) => this.feedback.show(message),
navigate: () => topicNavigations.get(context.scope) ?? null,
target: {
open: async (request) => {
await this.openTarget(request);
}
},
onQuoteBodyChanged: (view) => {
topicMedia.refresh(view), this.translationFeature?.syncPost(
view.slots.root
);
},
onRevealNextReplyLevel: (postNumber) => topicDoms.get(context.scope)?.revealNextReplyLevel(postNumber) ?? !1,
revealQuoteTarget: (target, mode) => {
typeof target.getBoundingClientRect == "function" && topicScroll.alignPost(target, {
source: mode === "match" ? "quote-match" : "quote",
alignment: mode === "match" ? "nearest" : "start",
highlight: mode === "floor"
});
},
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"thread-context",
cause
)
}), topicCommentsHeader = new import_reader_topic_comments_header.ReaderTopicCommentsHeader({
document: options.document,
topicId: context.topicId,
topicRoot: root,
topicDetails,
session: bundle.services.session,
presence: nativePresence,
presentation: nativeTopicPresentation,
currentUsername,
...options.renderIcon ? { renderIcon: options.renderIcon } : {},
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"topic-header",
cause
)
}), topicLocalArchive = new import_reader_topic_local_archive_feature.ReaderTopicLocalArchiveFeature({
document: options.document,
topicRoot: root,
session: bundle.services.session,
parentScope: context.scope
}), postAuthorFilter = options.unwantedTopicFilter ? new import_reader_post_author_filter_feature.ReaderPostAuthorFilterFeature({
preferences: options.unwantedTopicFilter,
recordHiddenPostAuthor: (username) => {
const topic = bundle.services.session.topic;
this.unwantedTopics.remember({
topicId: context.topicId,
title: String(topic?.title ?? "").trim() || `帖子 #${context.topicId}`,
href: nativeTopicLinks.topicHref(context.topicId),
categoryId: topic?.category_id,
source: "automatic",
matchedRule: `楼层用户:@${username}`,
matchedCategory: !1
});
},
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"history",
cause
)
}) : null;
if (context.scope.add(
bundle.services.snapshots.setPersistenceDelayReader(
(minimumIdleMs) => topicScroll.remainingUserIdleMs(minimumIdleMs)
)
), domOptions.postFeatures?.some(
(feature) => feature instanceof import_reader_topic_media_feature.ReaderTopicMediaFeature
))
throw new Error(
"ReaderBrowserRuntime 已拥有唯一 ReaderTopicMediaFeature"
);
if (domOptions.postFeatures?.some(
(feature) => feature instanceof import_reader_cooked_content_feature.ReaderCookedContentFeature
))
throw new Error(
"ReaderBrowserRuntime 已拥有唯一 ReaderCookedContentFeature"
);
if (domOptions.postFeatures?.some(
(feature) => feature instanceof import_reader_topic_special_content_feature.ReaderTopicSpecialContentFeature
))
throw new Error(
"ReaderBrowserRuntime 已拥有唯一 ReaderTopicSpecialContentFeature"
);
if (domOptions.postFeatures?.some(
(feature) => feature instanceof import_reader_topic_context_surface.ReaderTopicContextFeature
))
throw new Error(
"ReaderBrowserRuntime 已拥有唯一 ReaderTopicContextFeature"
);
if (domOptions.postFeatures?.some(
(feature) => feature instanceof import_reader_post_action_feature.ReaderPostActionFeature
))
throw new Error(
"ReaderBrowserRuntime 已拥有唯一 ReaderPostActionFeature"
);
if (domOptions.postFeatures?.some(
(feature) => feature instanceof import_reader_poll_feature.ReaderTopicPollFeature
))
throw new Error(
"ReaderBrowserRuntime 已拥有唯一 ReaderTopicPollFeature"
);
const postFeatures = Object.freeze([
topicLocalArchive,
...postAuthorFilter ? [postAuthorFilter] : [],
topicCookedContent,
topicPoll,
topicSpecialContent,
topicContextFeature,
topicPostActions,
topicCommentsHeader,
...domOptions.postFeatures ?? [],
topicMedia
]);
return topicFeatures.set(context.scope, Object.freeze({
topicImages,
topicMedia,
topicCookedContent,
topicSpecialContent,
topicContext,
topicContextFeature,
highlightDiscussionTarget: (target) => topicScroll.highlight.highlight(target),
topicActionRail,
topicPostActions,
presentation: Object.freeze({
identity: domOptions.identity,
renderPost: domOptions.render,
postFeatures
})
})), Object.freeze({
...domOptions,
replyTreePresentation,
presentationChanges: topicPresentationChanges,
postFeatures,
scroll: topicScroll,
readDirectReplyPrefetchScreens: () => this.#performance.nestedPrefetchScreens,
readDirectReplyPrefetchIdleMs: () => Math.max(
120,
this.#performance.requestMinIntervalMs + 30
),
readDirectReplyPrefetchConcurrency: () => Math.max(
1,
Math.min(
2,
this.#performance.requestMaxConcurrent - 1
)
),
readProjectionHydrationBatchSize: () => this.#performance.projectionHydrationBatchSize,
...options.topicFlowScheduler === void 0 ? {} : {
directReplyPrefetchScheduler: {
schedule: (callback, delayMs) => options.topicFlowScheduler.schedule(
callback,
"near-window",
delayMs
),
cancel: (handle) => options.topicFlowScheduler.cancel(
handle
)
}
}
});
},
createBundle: (context) => {
const handoff = this.#preheatedTopicSnapshots.takeEntry(
context.topicId
);
return handoff && this.pipeline.markTopic(
context.topicId,
"preheat-handoff-hit",
{
detail: {
preheatTraceId: handoff.traceId
}
}
), this.data.createTopicBundle(
context,
{
...options.topic,
...handoff === null ? {} : { initialSnapshot: handoff.snapshot },
pageSize: this.#performance.pageSize,
host: options.host,
nativeAjax: this.nativeAjax,
nativeActions,
composerEvents: this.composerIsolation,
...options.loadingProgress ? {
onLoadingSource: (source, counts) => {
options.loadingProgress.update({
topicId: Number(context.topicId),
phase: source,
cachedCount: counts.cachedCount,
missingCount: counts.missingCount
});
}
} : {}
}
);
},
onAssembled: (value, context) => {
topicDoms.set(context.scope, value.dom), this.pipeline.markTopic(context.topicId, "canonical-ready", {
detail: {
initializedFromCache: value.services.session.initializedFromCache,
cachedPosts: value.services.session.cachedPosts().length
}
});
const assemblyTraceId = this.pipeline.activeTrace(context.topicId);
let firstDomRecorded = !1, firstVisibleFrame = 0, scrollTraceId = null, scrollFrameRecorded = !1, scrollTraceGeneration = 0;
const browserWindow2 = options.document.defaultView, recordDomCommit = (commit) => {
if (!firstDomRecorded) {
firstDomRecorded = !0, this.pipeline.markTopic(context.topicId, "dom-first-commit", {
detail: {
attachedRoots: commit.attachedRoots.length,
mountedRoots: commit.tree.mountedRoots.length
}
});
const visible = () => {
firstVisibleFrame = 0, assemblyTraceId ? this.pipeline.mark(
assemblyTraceId,
"first-visible-frame"
) : this.pipeline.markTopic(
context.topicId,
"first-visible-frame"
);
};
typeof browserWindow2?.requestAnimationFrame == "function" ? firstVisibleFrame = browserWindow2.requestAnimationFrame(visible) : queueMicrotask(visible);
}
const activeTrace = this.pipeline.activeTrace(context.topicId);
if (scrollTraceId && activeTrace === scrollTraceId) {
const userScrollAt = value.dom.lastUserScrollAt(), scrollDuration = userScrollAt > 0 ? Math.max(
0,
(browserWindow2?.performance.now() ?? userScrollAt) - userScrollAt
) : 0;
this.pipeline.mark(scrollTraceId, "scroll-dom-commit", {
durationMs: scrollDuration,
detail: {
attachedRoots: commit.attachedRoots.length,
detachedRoots: commit.detachedRoots.length
}
}), this.pipeline.finish(scrollTraceId), scrollTraceId = null;
} else
this.pipeline.markTopic(context.topicId, "dom-commit", {
detail: {
attachedRoots: commit.attachedRoots.length,
detachedRoots: commit.detachedRoots.length
}
});
}, initialDomCommit = value.dom.frame.lastCommit;
initialDomCommit && recordDomCommit(initialDomCommit), context.scope.add(value.dom.listenDirectUserScrollIntent(() => {
scrollTraceId && this.pipeline.finish(scrollTraceId, "superseded"), scrollFrameRecorded = !1;
const traceId = this.pipeline.begin({
kind: "scroll",
topicId: context.topicId,
source: "direct-user-scroll"
});
scrollTraceId = traceId;
const generation = ++scrollTraceGeneration;
value.dom.waitForScrollIdle().then(() => {
context.scope.destroyed || generation !== scrollTraceGeneration || scrollTraceId !== traceId || (this.pipeline.finish(traceId), scrollTraceId = null);
}).catch((cause) => {
generation === scrollTraceGeneration && scrollTraceId === traceId && (this.pipeline.finish(traceId, "failed"), scrollTraceId = null), reportTopicFeature(context.topicId, "navigation", cause);
});
})), context.scope.add(value.dom.listenScrollFrameCommit(() => {
if (!scrollTraceId || scrollFrameRecorded || this.pipeline.activeTrace(context.topicId) !== scrollTraceId) return;
scrollFrameRecorded = !0;
const userScrollAt = value.dom.lastUserScrollAt();
this.pipeline.mark(scrollTraceId, "scroll-frame-commit", {
durationMs: userScrollAt > 0 ? Math.max(
0,
(browserWindow2?.performance.now() ?? userScrollAt) - userScrollAt
) : 0
});
})), context.scope.add(() => {
scrollTraceGeneration += 1, firstVisibleFrame && typeof browserWindow2?.cancelAnimationFrame == "function" && browserWindow2.cancelAnimationFrame(firstVisibleFrame), scrollTraceId && this.pipeline.finish(scrollTraceId);
});
const features = topicFeatures.get(context.scope);
if (!features)
throw new Error(
"Topic context surface 装配时 presentation 尚未就绪"
);
const translationGeneration = this.translationFeature?.activateTopic(context.topicId);
context.scope.add(() => this.translationFeature?.deactivateTopic(
context.topicId,
translationGeneration
));
let translationWindowPostNumbers = /* @__PURE__ */ new Set();
const updateTranslationWindow = () => {
const posts = [...translationWindowPostNumbers].map((postNumber) => value.services.session.postByNumber(postNumber)).filter((post) => post !== void 0);
this.translationFeature?.updatePreloadWindow(
context.topicId,
posts,
translationGeneration
);
};
value.dom.windowChanges.subscribe((commit) => {
recordDomCommit(commit), translationWindowPostNumbers = /* @__PURE__ */ new Set([
...commit.tree.mountedRoots,
...commit.tree.mountedReplies
]), updateTranslationWindow(), this.translationFeature?.syncMountedPosts();
}, context.scope), value.services.session.changes.subscribe((commit) => {
this.pipeline.markTopic(context.topicId, "canonical-commit", {
detail: {
source: commit.source,
acceptedPosts: commit.acceptedPosts,
changedPosts: commit.changedPostNumbers.length
}
}), this.#rememberChronicleDeletedPosts(
value,
commit.changedPostNumbers,
commit.observedAt
);
let changedWindow = !1;
for (const postNumber of commit.changedPostNumbers) {
const post = value.services.session.postByNumber(postNumber), parentPostNumber = (0, import_identifiers.tryDiscoursePostNumber)(
post?.reply_to_post_number
);
!translationWindowPostNumbers.has(postNumber) && (parentPostNumber === null || !translationWindowPostNumbers.has(parentPostNumber)) || (translationWindowPostNumbers.add(postNumber), changedWindow = !0);
}
changedWindow && updateTranslationWindow();
}, context.scope), value.services.live.changes.subscribe((change) => {
change.kind === "deleted" && this.#rememberChronicleDeletedPost(
value,
change.postNumber,
Date.now(),
"message-bus:deleted",
!1
);
}, context.scope), value.services.session.archiveChanges.subscribe(() => {
const active = this.shell.activeValue;
active?.services.session === value.services.session && (this.#rememberHistoryTopicMetadata(active), this.#rememberChronicleArchives(active), this.#collectChronicleRequests(this.data.requests.snapshot));
}, context.scope), this.#rememberChronicleArchives(value), this.#rememberChronicleDeletedPosts(value), this.#collectChronicleRequests(
this.data.requests.snapshot,
value
);
let contextSurface = null;
const navigation = new import_reader_topic_navigation_controller.ReaderTopicNavigationController({
session: value.services.session,
dom: value.dom,
hidden: {
isHidden: (postNumber) => value.dom.isPostHidden(postNumber),
async revealPost(postNumber) {
const surface = contextSurface;
return surface ? surface.revealDiscussionPost(postNumber) : null;
}
},
listenUserScrollIntent: (listener) => value.dom.listenUserScrollIntent(listener),
onMilestone: (milestone) => {
this.pipeline.markTopic(
context.topicId,
milestone.stage,
{
durationMs: milestone.durationMs,
detail: {
postNumber: Number(milestone.postNumber),
source: milestone.source
}
}
);
},
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"navigation",
cause
)
});
topicNavigations.set(context.scope, navigation);
const topicFlow = new import_reader_topic_flow_controller.ReaderTopicFlowController({
dom: value.dom,
readPerformance: () => this.#performance,
sessionChanges: value.services.session.changes,
readLoadDone: () => value.services.session.loadDone,
...options.topicFlowScheduler === void 0 ? {} : {
scheduler: options.topicFlowScheduler
},
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"navigation",
cause
)
});
topicFlows.set(context.scope, topicFlow), contextSurface = new import_reader_topic_context_surface.ReaderTopicContextSurface({
document: options.document,
controller: features.topicContext,
replies: value.replies,
discussionHost: this.shell.view.modal,
workspace: this.workspace.workspace,
identity: features.presentation.identity,
renderPost: features.presentation.renderPost,
postFeatures: features.presentation.postFeatures,
postProjector: value.dom.postProjector,
readDiscussionMaterializedPostLimit: () => Math.max(
12,
Math.floor(
this.#performance.streamMaxMountedPostCount / 2
)
),
highlight: features.highlightDiscussionTarget,
stateRepository: this.threadContextState,
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"thread-context",
cause
)
}), topicContextSurfaces.set(context.scope, contextSurface), features.topicContextFeature.connectQuoteSource({
captureAnchor: () => {
const viewport = value.dom.captureViewportAnchor();
return viewport ? Object.freeze({
viewport: Object.freeze({
...viewport,
postNumber: (0, import_identifiers.discoursePostNumber)(
viewport.postNumber
)
}),
replyWindow: contextSurface.captureDiscussionState(),
quoteHighlight: null
}) : null;
},
restore: async (source) => this.#restoreQuoteSource(source)
}), this.translationFeature?.syncMountedPosts();
const header = new import_reader_topic_header.ReaderTopicHeaderController({
session: value.services.session,
presentation: nativeTopicPresentation,
parentScope: context.scope,
onError: (cause) => {
reportTopicFeature(
context.topicId,
"topic-header",
cause
), this.feedback.show(
cause instanceof Error ? cause.message : "打开帖子编辑器失败"
);
}
});
topicHeaders.set(context.scope, header);
const hostDocumentTitle = options.document.title;
let readerDocumentTitle = "";
const syncDocumentTitle = (title) => {
const next = title.trim();
next && (readerDocumentTitle = next, options.document.title = next);
};
syncDocumentTitle(header.snapshot.title), header.changes.subscribe(
(snapshot) => syncDocumentTitle(snapshot.title),
context.scope
), context.scope.add(() => {
options.document.title === readerDocumentTitle && (options.document.title = hostDocumentTitle);
});
const onlyOp = new import_reader_topic_only_op_controller.ReaderTopicOnlyOpController({
session: value.services.session,
presentationChanges: value.dom.presentationChanges,
presentation: value.dom.replyTreePresentation,
onProjectionChanged: (resetScroll) => {
value.dom.refreshRootProjection(resetScroll);
},
onEnabledChanged: (enabled) => {
topicFlow.setProjectionPriority(enabled);
},
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"navigation",
cause
)
});
topicOnlyOpControllers.set(context.scope, onlyOp);
const topicVoteSession = value.services.session, topicVoteCommands = new import_topic_action_feature_commands.TopicActionFeatureCommands({
topicId: context.topicId,
session: topicVoteSession
}), headerView = new import_reader_topic_header.ReaderTopicHeaderView({
controller: header,
hostDocument: options.document,
elements: readerTopicHeaderElements(
this.shell.view.root
),
onlyOp,
...options.renderIcon ? { renderIcon: options.renderIcon } : {},
onJumpFirst: async () => {
const result = await navigation.navigate({
postNumber: 1,
source: "link",
alignment: "start",
highlight: !0
});
if (result.status !== "revealed")
throw new Error(
`主题标题跳转失败:${result.status}`
);
},
onToggleTopicVote: async (voted) => {
if (!(0, import_native_host_api.discourseNativeCurrentUsername)(options.host))
throw new Error("登录后才能为主题投票。");
await value.services.actions.dispatch(topicVoteCommands.vote(
voted,
actionDescriptors.topicVoteToggle({
topicId: context.topicId,
voted
})
));
},
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"topic-header",
cause
)
});
topicHeaderViews.set(context.scope, headerView);
const topicEditTrigger = this.shell.view.root.querySelector(
".ldp-topic-edit-trigger"
);
if (!topicEditTrigger)
throw new Error("Reader Header 缺少 Topic 编辑入口");
new import_reader_topic_edit_controller.ReaderTopicEditController({
topicId: context.topicId,
session: value.services.session,
trigger: topicEditTrigger,
form: this.topicEditForm,
catalog: nativeTopicEditCatalog,
actions: value.services.actions,
descriptors: actionDescriptors,
models: nativePostModels,
notify: (message) => this.feedback.show(message),
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"topic-header",
cause
)
});
const readTotalPostCount = () => {
const topic = value.services.session.topic ?? value.topic, candidates = [
Number(topic.highest_post_number),
Number(topic.posts_count),
...value.services.session.cachedPosts().map((post) => (0, import_identifiers.tryDiscoursePostNumber)(post.post_number) ?? 0)
];
return Math.max(
1,
...candidates.filter((candidate) => Number.isSafeInteger(candidate) && candidate > 0)
);
}, timelinePresentation = value.dom.replyTreePresentation, timeline = new import_reader_topic_timeline_controller.ReaderTopicTimelineController({
navigation,
readTotalPostCount,
readNavigablePostNumbers: () => timelinePresentation.roots(),
resolveEndPostNumber: () => (0, import_reader_topic_timeline_end_resolver.resolveReaderTopicTimelineEndPostNumber)({
readTotalPostCount,
readCachedPosts: () => value.services.session.cachedPosts(),
loadAround: (postNumber) => value.services.session.loadTarget(postNumber, {
scope: "around",
advanceCursor: !1
}),
loadBefore: (postNumber) => value.services.session.loadBeforePost(postNumber)
}),
readNavigablePostNumbersComplete: () => !timelinePresentation.canonicalFrozen && timelinePresentation.coverageComplete,
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"navigation",
cause
)
});
topicTimelines.set(context.scope, timeline);
const timelineViewOptions = options.timelineView;
if (timelineViewOptions) {
const {
readPreferences,
preferences,
formatRelative,
notify,
...viewOptions
} = timelineViewOptions, timestamp = (value2) => typeof value2 == "string" && value2.trim() ? value2 : null, currentTopic = () => value.services.session.topic ?? value.topic, timelineView = new import_reader_topic_timeline_view.ReaderTopicTimelineView({
...viewOptions,
controller: timeline,
elements: readerTopicTimelineElements(
this.shell.view.root
),
preferences: readPreferences?.() ?? preferences,
readCreatedAt: (postNumber) => {
const post = value.services.session.postByNumber(postNumber);
return timestamp(post?.created_at) ?? (postNumber === 1 ? timestamp(currentTopic().created_at) : null);
},
readLatestReplyAt: () => timestamp(currentTopic().last_posted_at),
formatRelative: formatRelative ?? nativeRelativeTime,
reachEnd: async () => {
await value.dom.reachStreamEnd();
},
notify: notify ?? ((message) => this.feedback.show(message)),
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"navigation",
cause
)
});
topicTimelineViews.set(context.scope, timelineView);
}
const liveNavigation = new import_reader_topic_live_navigation_controller.ReaderTopicLiveNavigationController({
live: value.services.live,
navigation,
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"navigation",
cause
)
});
topicLiveNavigations.set(context.scope, liveNavigation), topicLiveNavigationViews.set(
context.scope,
new import_reader_topic_live_navigation_view.ReaderTopicLiveNavigationView({
navigation: liveNavigation,
elements: readerTopicLiveNavigationElements(
this.shell.view.root
),
notify: (message) => this.feedback.show(message),
parentScope: context.scope
})
), value.dom.visibleRootChanges.subscribe((change) => {
const visibleRootPostNumber = timelinePresentation.rootOf(change.postNumber) ?? change.postNumber;
timeline.syncVisiblePost(visibleRootPostNumber, change), liveNavigation.syncViewport(change);
}, context.scope), value.services.session.changes.subscribe(() => {
timeline.refresh(), topicTimelineViews.get(context.scope)?.refresh();
}, context.scope), value.services.composerEvents.changes.subscribe((commit) => {
commit.postNumber !== null && navigation.navigate({
postNumber: commit.postNumber,
source: "composer",
alignment: "nearest",
focus: !0,
highlight: !0
}).catch(() => {
});
}, context.scope);
}
});
this.topicFactory = async (context) => {
const result = await coreTopicFactory(context), openNative2 = this.shell.view.root.querySelector(
".ldp-open"
);
if (!openNative2)
throw new Error("Reader Shell 缺少原生主题入口");
const nativeHref = (0, import_reader_native_topic_route.readerNativeTopicHref)(
nativeTopicLinks.topicHref(context.topicId),
topicBaseUrl
);
openNative2.href = nativeHref, openNative2.hidden = !nativeHref, context.scope.add(() => {
openNative2.href === nativeHref && (openNative2.removeAttribute("href"), openNative2.hidden = !0);
});
const features = topicFeatures.get(context.scope);
if (!features) throw new Error("Topic presentation features 未完成装配");
const topicHeader = topicHeaders.get(context.scope);
if (!topicHeader) throw new Error("Topic header 未完成装配");
const topicHeaderView = topicHeaderViews.get(context.scope);
if (!topicHeaderView) throw new Error("Topic header View 未完成装配");
const topicOnlyOp = topicOnlyOpControllers.get(context.scope);
if (!topicOnlyOp) throw new Error("只看楼主控制器未完成装配");
const topicNavigation = topicNavigations.get(context.scope);
if (!topicNavigation) throw new Error("Topic navigation 未完成装配");
const topicFlow = topicFlows.get(context.scope);
if (!topicFlow) throw new Error("Topic flow 未完成装配");
const topicContextSurface = topicContextSurfaces.get(context.scope);
if (!topicContextSurface)
throw new Error("Topic context surface 未完成装配");
const topicTimeline = topicTimelines.get(context.scope);
if (!topicTimeline) throw new Error("Topic timeline 未完成装配");
const topicTimelineView = topicTimelineViews.get(context.scope) ?? null, topicLiveNavigation = topicLiveNavigations.get(context.scope);
if (!topicLiveNavigation)
throw new Error("Topic live navigation 未完成装配");
const topicLiveNavigationView = topicLiveNavigationViews.get(context.scope);
if (!topicLiveNavigationView)
throw new Error("Topic live navigation View 未完成装配");
let value = null;
const lightboxOptions = options.lightbox, lightboxPostCommands = new import_post_action_feature_commands.PostActionFeatureCommands(
result.value.services.postActions
), topicLightbox = lightboxOptions ? new import_reader_lightbox_feature.ReaderLightboxFeature({
document: options.document,
mount: typeof lightboxOptions.mount == "function" ? lightboxOptions.mount() : lightboxOptions.mount,
topic: () => result.value.services.session.topic ?? result.value.topic,
session: result.value.services.session,
replies: result.value.replies,
composer: this.composer,
identity: features.presentation.identity,
renderPost: features.presentation.renderPost,
postFeatures: features.presentation.postFeatures,
reactionSurfaces: features.topicPostActions,
postProjector: result.value.dom.postProjector,
minimumCommentLength: () => nativePostModels.minimumPostLength(),
submitComment: async ({ topic, targetPost, raw }) => (await this.composer.openReply({
topic,
post: targetPost,
initialRaw: raw,
replaceRaw: !0
}), result.value.services.actions.dispatch(
lightboxPostCommands.reply(
actionDescriptors.replyCreate({
postId: Number(targetPost.id),
replyToPostNumber: Number(
targetPost.post_number
)
})
)
)),
topicImages: features.topicImages,
...lightboxOptions.originalSources ? { originalSources: lightboxOptions.originalSources } : {},
...this.imageResources ? { imageResources: this.imageResources } : {},
...this.imageDownloads ? { imageDownloads: this.imageDownloads } : {},
...lightboxOptions.confirmOriginalDownload ? {
confirmOriginalDownload: lightboxOptions.confirmOriginalDownload
} : {
confirmOriginalDownload: (missing, total) => this.feedback.confirm({
title: total === 1 ? "下载当前图片" : "批量下载图片",
message: total === 1 ? "当前原图尚未缓存,是否按阅读器限速获取原图?" : `${missing} 张原图尚未缓存,是否按阅读器限速逐张获取?`,
note: "取消将使用每张图片当前最高缓存或预览质量。",
confirmLabel: "获取原图",
cancelLabel: "使用预览质量",
tone: "primary"
})
},
notify: (message) => this.feedback.show(message),
...lightboxOptions.originalByDefault === void 0 ? {} : {
originalByDefault: lightboxOptions.originalByDefault
},
...lightboxOptions.commentsExpandedByDefault === void 0 ? {} : {
commentsExpandedByDefault: lightboxOptions.commentsExpandedByDefault
},
...lightboxOptions.descriptionExpandedByDefault === void 0 ? {} : {
descriptionExpandedByDefault: lightboxOptions.descriptionExpandedByDefault
},
...lightboxOptions.readDefaults ? { readDefaults: lightboxOptions.readDefaults } : {},
...lightboxOptions.preferences ? { preferences: lightboxOptions.preferences } : {},
...lightboxOptions.commentsEnabled === void 0 ? {} : { commentsEnabled: lightboxOptions.commentsEnabled },
...lightboxOptions.frameScheduler ? { frameScheduler: lightboxOptions.frameScheduler } : {},
onJumpToPost: async (item) => {
if (!value)
throw new Error("Topic context 尚未完成装配");
await value.topicNavigation.navigate({
postNumber: item.sourcePostNumber,
source: "lightbox",
alignment: "center",
highlight: !0
}), await lightboxOptions.onJumpToPost?.(item, value);
},
onClose: () => {
value && lightboxOptions.onClose?.(value);
},
parentScope: context.scope,
onError: (cause) => {
try {
lightboxOptions.onError?.(cause);
} catch {
}
reportTopicFeature(
context.topicId,
"image-interaction",
cause
);
}
}) : null, topicSummaryPreview = topicSummaryPreviews.get(context.scope), topicSummaryPreviewObjectUrls = options.resources?.objectUrls;
topicLightbox && topicSummaryPreview && topicSummaryPreviewObjectUrls && (topicSummaryPreview.open = ({ blob, alt, returnFocus }) => {
const source = topicSummaryPreviewObjectUrls.createObjectURL(blob);
try {
topicLightbox.open({
items: [Object.freeze({
key: `topic-summary-share:${context.topicId}:${Date.now()}`,
topicId: context.topicId,
sourcePostNumber: 1,
imageOrder: 0,
previewSrc: source,
originalSrc: source,
alt
})],
initialIndex: 0,
returnFocus,
commentsExpanded: !1,
descriptionExpanded: !1,
commentsEnabled: !1,
includeTopicImages: !1,
batchEnabled: !1
}).view.scope.add(() => {
topicSummaryPreviewObjectUrls.revokeObjectURL(source);
});
} catch (cause) {
throw topicSummaryPreviewObjectUrls.revokeObjectURL(source), cause;
}
}, context.scope.add(() => {
topicSummaryPreview.open = null;
}));
const openTopicImage = options.openTopicImage, topicImageInteraction = topicLightbox || openTopicImage ? new import_reader_topic_image_interaction.ReaderTopicImageInteraction({
topicHost: result.value.root,
additionalHosts: [
topicContextSurface.discussionContentHost
],
images: features.topicImages,
open: (request) => {
if (topicLightbox) {
topicLightbox.open({
items: request.items,
initialIndex: request.initialIndex,
returnFocus: request.returnFocus,
...request.commentsEnabled === void 0 ? {} : { commentsEnabled: request.commentsEnabled },
...request.includeTopicImages === void 0 ? {} : { includeTopicImages: request.includeTopicImages }
});
return;
}
if (!value) throw new Error("Topic context 尚未完成装配");
return openTopicImage(request, value);
},
currentTopicId: context.topicId,
loadQuotedPost: (targetTopicId, targetPostNumber) => features.topicContext.loadQuotedPost(
targetTopicId,
targetPostNumber
),
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"image-interaction",
cause
)
}) : null, topicSelectionQuote = new import_reader_selection_quote_feature.ReaderSelectionQuoteFeature({
document: options.document,
root: this.shell.view.surfaceHost,
contentRoot: this.shell.view.modal,
topicId: context.topicId,
topic: () => result.value.services.session.topic ?? result.value.topic,
postById: (postId) => result.value.services.session.postById(postId),
postByNumber: (postNumber) => result.value.services.session.postByNumber(postNumber),
images: features.topicImages,
composer: this.composer,
...options.share ? { clipboard: options.share } : {},
feedback: this.feedback,
parentScope: context.scope,
onError: (cause) => reportTopicFeature(
context.topicId,
"selection-quote",
cause
)
});
value = Object.freeze({
...result.value,
topicImages: features.topicImages,
topicMedia: features.topicMedia,
topicCookedContent: features.topicCookedContent,
topicHeader,
topicHeaderView,
topicOnlyOp,
topicSpecialContent: features.topicSpecialContent,
topicContext: features.topicContext,
topicContextFeature: features.topicContextFeature,
topicContextSurface,
topicLiveNavigation,
topicLiveNavigationView,
topicNavigation,
topicFlow,
topicSelectionQuote,
topicActionRail: features.topicActionRail,
topicTimeline,
topicTimelineView,
topicImageInteraction,
topicLightbox
});
const readyCleanup = topicReady?.(value, context);
return typeof readyCleanup == "function" && context.scope.add(readyCleanup), Object.freeze({
...result,
value,
prepareClose: async (reason) => {
await result.prepareClose?.(reason), this.#rememberHistoryTopicMetadata(value);
}
});
}, this.history = new import_reader_history_repository.ReaderHistoryRepository({
storage: options.storage,
authScope: options.topic.authScope,
...options.history?.key === void 0 ? {} : { key: options.history.key },
...options.history?.maxAgeMs === void 0 ? {} : { maxAgeMs: options.history.maxAgeMs },
...options.history?.now === void 0 ? {} : { now: options.history.now }
}), this.history.load(), this.topicStates = new import_reader_topic_state_projection.ReaderTopicStateProjection({
history: this.history,
unwanted: this.unwantedTopics,
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"history",
cause
)
}), this.data.requests.changes.subscribe(
(snapshot) => this.#collectChronicleRequests(snapshot),
this.scope
), this.#collectChronicleRequests(this.data.requests.snapshot), this.history.changes.subscribe(() => {
this.notificationPanelView?.syncArchiveMarkers(), this.bookmarkPanelView?.syncArchiveMarkers();
}, this.scope), this.historyNavigation = new import_reader_history_navigation_controller.ReaderHistoryNavigationController({
history: this.history,
readSortMode: options.history?.readSortMode ?? (() => "recent-viewed"),
port: {
activeTopicId: () => this.shell.activeTopicId,
captureAnchor: () => this.#captureAndRememberHistoryAnchor(),
openTopic: async (topicId) => {
const result = await this.shell.open(
topicId,
this.topicFactory
);
return (result.status === "opened" || result.status === "reused") && this.#rememberHistoryTopicMetadata(result.value), this.#historyOpenResult(result);
},
restoreAnchor: async (topicId, anchor, restoreOptions) => {
const value = this.shell.activeValue;
if (this.shell.activeTopicId !== topicId || value === null)
throw new Error(
`历史目标 Topic ${topicId} 未处于 active 状态`
);
const proportional = anchor.viewport.scrollRatio !== void 0, restoreSemanticState = restoreOptions?.restoreSemanticState !== !1;
if (!proportional && restoreSemanticState) {
const result = await value.topicNavigation.navigate({
postNumber: anchor.viewport.postNumber,
source: "history",
alignment: "nearest",
highlight: restoreOptions?.highlight !== !1
});
if (result.status !== "revealed")
throw new Error(
`旧历史楼层 #${anchor.viewport.postNumber} 恢复失败:${result.status}`
);
}
const navigationRevision = value.topicNavigation.revision;
if (restoreSemanticState && anchor.replyWindow ? await value.topicContextSurface.restoreDiscussionState(anchor.replyWindow) : value.topicContext.closeDiscussion(), !value.topicNavigation.isCurrent(navigationRevision)) return;
const quoteHighlight = restoreSemanticState ? anchor.quoteHighlight : null;
if (!await value.topicContextFeature.restoreQuoteHighlightState(quoteHighlight))
throw new Error(
`历史引用高亮 #${quoteHighlight?.postNumber ?? 0} 恢复失败`
);
if (!(quoteHighlight === null && !value.topicNavigation.isCurrent(navigationRevision))) {
if ((proportional || restoreSemanticState) && !value.dom.restoreViewportAnchor(anchor.viewport))
throw new Error("历史 Reader 高度锚点恢复失败");
!proportional && restoreSemanticState && value.topicTimeline.syncVisiblePost(anchor.viewport.postNumber), proportional ? this.#rememberHistoryTopic(
value,
anchor.viewport.postNumber,
anchor.viewport
) : this.#rememberHistoryTopicMetadata(value);
}
}
},
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"history",
cause
)
});
const historyViewOptions = options.history?.navigationView;
if (historyViewOptions) {
const root = this.shell.view.root, backEdge = root.querySelector(
".ldp-reader-history-edge-back"
), forwardEdge = root.querySelector(
".ldp-reader-history-edge-forward"
), backButton = root.querySelector(
".ldp-reader-history-back"
), forwardButton = root.querySelector(
".ldp-reader-history-forward"
);
if (!backEdge || !forwardEdge || !backButton || !forwardButton)
throw new Error(
"历史导航 View 缺少 Shell 命名边缘或按钮"
);
this.historyNavigationView = new import_reader_history_navigation_view.ReaderHistoryNavigationView({
navigation: this.historyNavigation,
elements: {
root,
modal: this.shell.view.modal,
backEdge,
forwardEdge,
backButton,
forwardButton
},
preferences: historyViewOptions.preferences,
window: historyViewOptions.window === void 0 ? options.document.defaultView : historyViewOptions.window,
topicTitle: (topicId) => this.history.entry(topicId)?.title ?? null,
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"history",
cause
)
});
} else
this.historyNavigationView = null;
const historyPanelOptions = options.history?.panelView;
if (historyPanelOptions) {
const root = this.shell.view.root, toggle = root.querySelector(
".ldp-history-toggle"
), popover = root.querySelector(
".ldp-history-popover"
), sortToggle = root.querySelector(
".ldp-history-sort-toggle"
), multiButton = root.querySelector(
".ldp-history-multi"
), clearButton = root.querySelector(
".ldp-history-clear"
), defaultActions = root.querySelector(
".ldp-history-default-actions"
), bulkActions = root.querySelector(
".ldp-history-bulk-actions"
), selectScope = root.querySelector(
".ldp-history-select-scope"
), selectToggle = root.querySelector(
".ldp-history-select-toggle"
), deleteSelected = root.querySelector(
".ldp-history-delete-selected"
), deleteSelectedLabel = root.querySelector(
".ldp-history-delete-selected-label"
), multiDone = root.querySelector(
".ldp-history-multi-done"
), search = root.querySelector(
".ldp-history-search"
), searchClear = root.querySelector(
".ldp-history-search-clear"
), categoryFilter = root.querySelector(
".ldp-history-category-filter"
), tagFilter = root.querySelector(
".ldp-history-tag-filter"
), list = root.querySelector(
".ldp-history-list"
), pagePrevious = root.querySelector(
".ldp-history-page-prev"
), pageInfo = root.querySelector(
".ldp-history-page-info"
), pageNext = root.querySelector(
".ldp-history-page-next"
);
if (!toggle || !popover || !sortToggle || !multiButton || !clearButton || !defaultActions || !bulkActions || !selectScope || !selectToggle || !deleteSelected || !deleteSelectedLabel || !multiDone || !search || !searchClear || !categoryFilter || !tagFilter || !list || !pagePrevious || !pageInfo || !pageNext)
throw new Error(
"历史列表 View 缺少 Shell 命名面板控件"
);
this.historyPanelView = new import_reader_history_panel_view.ReaderHistoryPanelView({
...historyPanelOptions,
...(historyPanelOptions.searchForms ?? options.searchForms) === void 0 ? {} : {
searchForms: historyPanelOptions.searchForms ?? options.searchForms
},
confirmDelete: historyPanelOptions.confirmDelete ?? ((request) => this.feedback.confirm(request)),
notify: historyPanelOptions.notify ?? ((message) => this.feedback.show(message)),
document: options.document,
mount: this.shell.view.surfaceHost,
storage: options.storage,
history: this.history,
topicStates: this.topicStates,
elements: {
root,
toggle,
popover,
sortToggle,
multiButton,
clearButton,
defaultActions,
bulkActions,
selectScope,
selectToggle,
deleteSelected,
deleteSelectedLabel,
multiDone,
search,
searchClear,
categoryFilter,
tagFilter,
list,
pagePrevious,
pageInfo,
pageNext
},
openEntry: async (entry) => {
await this.#openHistoryEntry(entry);
},
emojiSource: historyPanelOptions.emojiSource ?? ((id) => (0, import_native_host_api.discourseNativeEmojiUrl)(options.host, id)),
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"history",
cause
)
});
} else
this.historyPanelView = null;
this.shell.changes.subscribe((state) => {
(state === "switching" || state === "closed") && this.#closeApplicationSurfaces();
}, this.scope);
} catch (error) {
throw this.scope.destroy(), error;
}
}
get performance() {
return this.#performance;
}
rememberPreheatedTopicSnapshot(snapshot, traceId = "") {
return this.#destroyed || this.scope.destroyed ? !1 : this.#preheatedTopicSnapshots.remember(snapshot, traceId);
}
beginPreheatTrace(topicId, source) {
return this.pipeline.begin({
kind: "topic-preheat",
topicId,
source
});
}
finishPreheatTrace(traceId, stage = "finished") {
this.pipeline.finish(traceId, stage);
}
forgetPreheatedTopicSnapshot(topicId) {
return this.#preheatedTopicSnapshots.forget(topicId);
}
reloadExternalTopicSummaryState() {
for (const surface of this.#topicSummarySurfaces)
surface.reloadExternalState();
}
applyPerformance(snapshot) {
if (this.#destroyed || this.scope.destroyed) return;
this.#performance = snapshot, this.#preheatedTopicSnapshots.applyPolicy({
maxEntries: snapshot.preheatHandoffMaxEntries,
maxBytes: snapshot.preheatHandoffMaxBytes,
ttlMs: snapshot.preheatHandoffTtlMs
}), this.#applyPerformanceInfrastructure();
const active = this.shell.activeValue;
active?.services.session.applyPageSize(snapshot.pageSize), active?.topicFlow.refreshPerformance();
}
async open(topicId) {
return (await this.openTarget({
topicId,
source: "link"
})).topic;
}
async openTarget(request) {
if (this.#destroyed || this.scope.destroyed)
return Object.freeze({
topic: Object.freeze({
status: "failed",
topicId: (0, import_identifiers.discourseTopicId)(request.topicId),
cause: new Error("ReaderBrowserRuntime 已销毁")
}),
navigation: null
});
this.#boostTargetHighlight.clear();
const normalizedTopicId = (0, import_identifiers.discourseTopicId)(request.topicId), traceId = this.pipeline.begin({
kind: "topic-open",
topicId: normalizedTopicId,
source: request.source,
...request.postNumber === void 0 ? {} : { targetPostNumber: request.postNumber }
}), chronicleRequestFloor = this.data.requests.snapshot.events.at(-1)?.id ?? 0;
this.#openRecoveryController?.abort(
new DOMException("新的打开事务已开始", "AbortError")
);
const recoveryController = new AbortController();
this.#openRecoveryController = recoveryController;
const transactionIsCurrent = () => !this.#destroyed && !this.scope.destroyed && !recoveryController.signal.aborted && this.#openRecoveryController === recoveryController, superseded = () => (this.pipeline.finish(traceId, "superseded"), Object.freeze({
topic: Object.freeze({
status: "superseded",
topicId: normalizedTopicId
}),
navigation: null
})), releaseLoading = this.#loadingProgress?.begin(
Number(normalizedTopicId),
request.postNumber
);
try {
this.recovery.clear();
const previousTopicId = this.shell.activeTopicId !== null && this.shell.activeTopicId !== normalizedTopicId ? this.shell.activeTopicId : null, previousAnchor = previousTopicId === null ? null : this.historyNavigation.captureCurrent();
let result;
for (let attempt = 0; ; attempt += 1) {
if (result = await this.shell.open(
normalizedTopicId,
this.topicFactory
), !transactionIsCurrent()) return superseded();
if (result.status !== "failed" || !readerShellOpenRetryable(result.cause) || attempt >= 2)
break;
this.feedback.show(
`帖子加载暂时失败,${attempt + 1} 秒后自动重试一次`
);
try {
await this.#openRetryDelay(
(attempt + 1) * 1e3,
recoveryController.signal
);
} catch {
return superseded();
}
}
if (result.status === "opened" || result.status === "reused") {
this.pipeline.mark(traceId, "topic-ready", {
detail: {
status: result.status,
initializedFromCache: result.value.services.session.initializedFromCache
}
}), this.#lastFailedRequest = null, this.#rememberHistoryTopicMetadata(result.value), this.historyNavigation.snapshot.activeTopicId !== result.topicId && this.historyNavigation.activate(result.topicId);
let navigation = null;
try {
if (request.postNumber !== void 0) {
request.localArchive && await result.value.services.session.restoreUnavailablePostFromCache(
request.postNumber,
request.localArchive.status,
request.localArchive.confirmedAt,
request.localArchive.requestPath
);
for (let attempt = 0; ; attempt += 1) {
let navigationCause = null;
try {
navigation = await result.value.topicNavigation.navigate({
postNumber: request.postNumber,
source: request.source,
...request.alignment === void 0 ? {} : { alignment: request.alignment },
...request.focus === void 0 ? {} : { focus: request.focus },
...request.highlight === void 0 ? {} : { highlight: request.highlight },
...request.forceRefresh === void 0 ? {} : { forceRefresh: request.forceRefresh },
...request.cachedOnly === void 0 ? {} : { cachedOnly: request.cachedOnly },
...request.revealAsFloor === void 0 ? {} : { revealAsFloor: request.revealAsFloor }
});
} catch (cause) {
navigationCause = cause;
}
if (!transactionIsCurrent()) return superseded();
if (!(navigationCause !== null ? readerShellOpenRetryable(navigationCause) : navigation?.status === "unresolved-tree") || attempt >= 2) {
if (navigationCause !== null)
return this.#lastFailedRequest = Object.freeze({ ...request }), this.feedback.show(
"帖子已打开,但目标楼层定位失败;当前 Topic 已保留,可稍后再次跳转"
), Object.freeze({ topic: result, navigation: null });
navigation?.status === "unresolved-tree" && this.feedback.show(
"帖子已打开,但目标楼层的回复树暂未完成挂载;当前 Topic 已保留,可稍后再次跳转"
);
break;
}
this.feedback.show(
`目标楼层定位暂时失败,${attempt + 1} 秒后自动重试一次`
);
try {
await this.#openRetryDelay(
(attempt + 1) * 1e3,
recoveryController.signal
);
} catch {
return superseded();
}
}
if (!transactionIsCurrent()) return superseded();
if (navigation?.status !== "revealed")
return Object.freeze({ topic: result, navigation });
const targetPost = result.value.services.session.postByNumber(
request.postNumber
), treeParent = result.value.replies.topology.parentOf(
request.postNumber
), canonicalParent = treeParent === void 0 ? (0, import_identifiers.tryDiscoursePostNumber)(
targetPost?.reply_to_post_number
) : treeParent;
if (request.revealAsFloor !== !0 && canonicalParent !== null && canonicalParent !== void 0 && canonicalParent > 1 && !navigation.element?.closest(
".ldp-descendant-replies-layer"
) && (await result.value.topicContext.openDiscussion(
request.postNumber
), !transactionIsCurrent()))
return superseded();
request.quoteHighlight && (navigation.element && result.value.topicContextFeature.applyRevealedQuoteHighlight(
request.quoteHighlight,
navigation.element
) || this.feedback.show(
`目的地内容已修改;已定位到楼层 #${request.postNumber}`
)), request.highlight !== !1 && request.boostId !== void 0 && this.#highlightBoostTarget(request, navigation.element);
}
return Object.freeze({ topic: result, navigation });
} finally {
transactionIsCurrent() && (this.#rememberHistoryTopicMetadata(result.value), this.historyNavigation.snapshot.activeTopicId !== result.topicId && this.historyNavigation.activate(result.topicId));
}
}
if (result.status === "failed") {
if (this.pipeline.finish(traceId, "failed", {
detail: { phase: "topic-open" }
}), !transactionIsCurrent()) return superseded();
this.#lastFailedRequest = Object.freeze({ ...request }), readerShellFailureKind(result.cause) === "cloudflare" && (await this.permit.noteCloudflareChallenge({
href: this.#challengeHref,
force: !0
}), await this.rateLimitNotice.refresh(), this.#openManualCloudflareChallenge(
this.#challengeHref,
"",
!1
));
let previousRestored = previousTopicId !== null && this.shell.activeTopicId === previousTopicId;
if (previousTopicId !== null && !previousRestored && this.shell.activeTopicId === null) {
const previous = await this.shell.open(
previousTopicId,
this.topicFactory
);
if (!transactionIsCurrent()) return superseded();
if (previousRestored = previous.status === "opened" || previous.status === "reused", previousRestored && previousAnchor)
try {
await this.historyNavigation.restore(
previousTopicId,
previousAnchor
);
} catch {
this.feedback.show(
"原帖子已恢复,但之前的阅读位置未能完整还原"
);
}
}
previousRestored ? this.feedback.show("切换帖子失败,已保留当前帖子") : this.recovery.show(
readerShellRecoveryFailure(
result.cause,
this.#challengeHref
)
);
}
return Object.freeze({ topic: result, navigation: null });
} catch (error) {
throw this.pipeline.finish(traceId, "failed", {
detail: {
error: error instanceof Error ? error.name : "unknown"
}
}), error;
} finally {
this.pipeline.finish(traceId), this.#rememberBoostChronicleRequest(request, chronicleRequestFloor), this.#openRecoveryController === recoveryController && (this.#openRecoveryController = null), releaseLoading?.();
}
}
#highlightBoostTarget(request, navigationElement) {
const boostId = Number(request.boostId), postNumber = Number(request.postNumber);
if (!Number.isSafeInteger(boostId) || boostId <= 0 || !Number.isSafeInteger(postNumber) || postNumber <= 0) return;
const roots = /* @__PURE__ */ new Set(), navigationRoot = navigationElement?.matches(".ldp-post") ? navigationElement : navigationElement?.closest(".ldp-post");
navigationRoot?.isConnected && roots.add(navigationRoot);
for (const root of this.shell.view.root.querySelectorAll(
`.ldp-post[data-post-number="${postNumber}"]`
))
roots.add(root);
const visibleCandidates = [];
let hiddenFallback = null;
for (const root of roots)
for (const bubble of root.querySelectorAll(
".ldp-boost-bubble[data-boost-id]"
))
bubble.closest(".ldp-post") !== root || Number(bubble.dataset.boostId) !== boostId || (bubble.closest('[hidden],[aria-hidden="true"]') ? hiddenFallback ??= bubble : visibleCandidates.push(bubble));
const target = visibleCandidates.find((bubble) => bubble.closest(".ldp-descendant-replies-layer")) ?? visibleCandidates.find((bubble) => bubble.closest(".ldp-post") === navigationRoot) ?? visibleCandidates[0] ?? hiddenFallback;
target && this.#boostTargetHighlight.highlight(target);
}
async #recoverAvatarSource(source, signal) {
return this.imageResources?.resolveAvatarSource(source, signal) ?? "";
}
async close() {
return this.#boostTargetHighlight.clear(), this.#openRecoveryController?.abort(
new DOMException("Reader 已关闭", "AbortError")
), this.#openRecoveryController = null, this.#lastFailedRequest = null, this.recovery.clear(), this.historyNavigation.captureCurrent(), this.#closeApplicationSurfaces(), this.shell.closeTopic();
}
async #restoreQuoteSource(source) {
if (this.#destroyed || this.scope.destroyed) return !1;
const topicId = (0, import_identifiers.discourseTopicId)(source.topicId), anchor = source.anchor === null ? null : (0, import_reader_history_model.normalizeReaderHistoryAnchorState)(source.anchor), active = this.shell.activeValue;
if (this.shell.activeTopicId === topicId && active) {
if (anchor) {
await this.historyNavigation.restore(topicId, anchor, {
highlight: !1
});
const restored2 = this.shell.activeValue;
return !restored2 || !this.#restoreExactQuoteViewport(restored2, anchor) ? !1 : (this.#highlightQuoteSource(restored2, source), !0);
}
return (await active.topicNavigation.navigate({
postNumber: source.postNumber,
source: "quote",
alignment: "nearest",
highlight: !0
})).status === "revealed";
}
const opened = await this.openTarget({
topicId,
postNumber: anchor?.viewport.postNumber ?? source.postNumber,
source: "quote",
alignment: "nearest",
highlight: anchor === null
});
if (opened.topic.status !== "opened" && opened.topic.status !== "reused")
return !1;
if (!anchor) return opened.navigation?.status === "revealed";
await this.historyNavigation.restore(topicId, anchor, {
highlight: !1
});
const restored = this.shell.activeValue;
return !restored || !this.#restoreExactQuoteViewport(restored, anchor) ? !1 : (this.#highlightQuoteSource(restored, source), !0);
}
#restoreExactQuoteViewport(value, anchor) {
return value.dom.captureViewportAnchor()?.postNumber === anchor.viewport.postNumber ? !0 : value.dom.restoreViewportAnchor({
postNumber: anchor.viewport.postNumber,
postOffset: anchor.viewport.postOffset,
scrollTop: anchor.viewport.scrollTop
});
}
#highlightQuoteSource(value, source) {
source.anchor?.replyWindow && value.topicContextSurface.highlightDiscussionPost(source.postNumber) || value.dom.highlightPost(source.postNumber);
}
readerSurfaceOpen() {
if (this.shell.view.root.hidden) return !1;
if (this.actionSurfaces.active || this.userCardView.isOpen || this.userMediaViewer?.activeRoot?.isConnected) return !0;
const document = this.shell.view.root.ownerDocument;
return (0, import_reader_native_composer_window.visibleDiscourseNativeFloatingSurface)(document) ? !0 : (0, import_reader_escape_surface.readerFrontmostEscapeSurface)(document) !== null;
}
readerExitBlocked() {
return this.composer.isOpen() || this.readerSurfaceOpen();
}
readerShortcutContextBlocked() {
if (this.composer.isOpen()) return !0;
const document = this.shell.view.root.ownerDocument;
return !!(0, import_reader_escape_surface.readerSurfaceQuery)(document, [
".ldp-settings-popover:not([hidden])",
".ldp-reader-action-layer:not([hidden])",
".ldp-lightbox",
".ldp-code-preview-layer"
].join(","));
}
handleCloseReaderShortcut(event) {
if ((0, import_reader_shortcut_controller.readerShortcutBindingFromEvent)(event) !== "Escape") {
if (this.readerShortcutContextBlocked())
return event.type !== "keydown";
if (this.readerExitBlocked())
return this.#dispatchSurfaceCloseEscape(), !0;
}
return this.closeExpandedReply() || this.close();
}
closeExpandedReply() {
const postNumber = this.shell.activeValue?.topicContextFeature.collapseExpandedDefaultPost() ?? null;
return postNumber ? (this.feedback.show(`已收起楼层 #${postNumber}`), !0) : !1;
}
#closeApplicationSurfaces() {
this.userMediaViewer?.close(), this.userCardView.close(), this.actionSurfaces.closeActive();
}
#dispatchSurfaceCloseEscape() {
const document = this.shell.view.root.ownerDocument, window = document.defaultView;
if (!window) return;
let event;
typeof window.KeyboardEvent == "function" ? event = new window.KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
bubbles: !0,
cancelable: !0
}) : (event = new window.Event("keydown", {
bubbles: !0,
cancelable: !0
}), Object.defineProperties(event, {
key: { value: "Escape", configurable: !0 },
code: { value: "Escape", configurable: !0 }
})), readerSurfaceOnlyCloseEvents.add(event), document.dispatchEvent(event);
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.#openRecoveryController?.abort(
new DOMException("Reader runtime 已销毁", "AbortError")
), this.#openRecoveryController = null, this.#lastFailedRequest = null, this.historyNavigation.captureCurrent(), this.scope.destroy());
}
#openManualCloudflareChallenge(href, observationUsername = "", focus = !0) {
if (this.scope.destroyed) return;
if (this.#manualChallengePromise) {
focus && this.permit.resolveCloudflareChallenge({
href,
signal: this.#manualChallengeController.signal,
focus: !0
}).catch(() => {
}), observationUsername && this.#manualChallengePromise.then((passed) => {
passed && !this.scope.destroyed && this.userObservations.retry(observationUsername);
}).catch(() => {
});
return;
}
const failedRequest = this.#lastFailedRequest, promise = this.permit.resolveCloudflareChallenge({
href,
signal: this.#manualChallengeController.signal,
focus
}).then(async (passed) => {
if (this.scope.destroyed) return passed;
let retried = !1, recovered = !1;
if (passed) {
if (await this.data.client.resetRateLimits(), await this.rateLimitNotice.refresh(), failedRequest !== null && this.#lastFailedRequest === failedRequest) {
retried = !0;
const result = await this.openTarget({
...failedRequest,
forceRefresh: !0
});
recovered = result.topic.status === "opened" || result.topic.status === "reused";
}
observationUsername ? this.userObservations.retry(observationUsername) : (!retried || recovered) && this.userObservations.resumeRecoverable("cloudflare-challenge");
}
return this.feedback.show(
passed ? observationUsername ? `Cloudflare 验证已通过,@${observationUsername} 已从断点继续` : retried ? recovered ? "Cloudflare 验证已通过,目标帖子已继续加载" : "验证会话已通过,但目标帖子仍被限制;未继续追发请求" : "Cloudflare 验证已通过,请求已回到原有有序管线" : "验证浮窗未完成;请允许弹出窗口后重试"
), passed;
}).catch((error) => {
throw this.#manualChallengeController.signal.aborted || this.feedback.show("Cloudflare 验证未完成,请稍后重试"), error;
}).finally(() => {
this.#manualChallengePromise === promise && (this.#manualChallengePromise = null);
});
this.#manualChallengePromise = promise, promise.catch(() => {
});
}
#applyPerformanceInfrastructure() {
const snapshot = this.#performance, requestFlowSettings = (0, import_reader_request_flow_config.normalizeReaderRequestFlowSettings)(
snapshot.requestFlowSettings
);
this.permit.applyRuntimePolicy({
shortBudget: snapshot.requestShortBudget,
longBudget: snapshot.requestLongBudget,
minIntervalMs: snapshot.requestMinIntervalMs,
maxConcurrent: snapshot.requestMaxConcurrent,
backgroundIdleIntervalMs: requestFlowSettings.backgroundIdleIntervalMs,
backgroundMaxDeferMs: requestFlowSettings.backgroundMaxDeferMs
}), this.data.applyRequestRuntimePolicy({
maxConcurrent: snapshot.requestMaxConcurrent,
responseMemoryMaxEntries: snapshot.responseMemoryMaxEntries,
responseMemoryMaxBytes: snapshot.responseMemoryMaxBytes,
readStateRequestsPerMinute: snapshot.readStateRequestsPerMinute,
readStateTimingsPerMinute: snapshot.readStateTimingsPerMinute,
requestFlowSettings,
...snapshot.businessRequestSettings === void 0 ? {} : { businessRequestSettings: snapshot.businessRequestSettings }
});
}
#historyArchiveMarker(topicId, postNumber) {
return this.history?.archiveMarker(topicId, postNumber) ?? null;
}
#captureAndRememberHistoryAnchor() {
const anchor = this.#captureHistoryAnchor(), value = this.shell.activeValue;
return anchor && value && this.#rememberHistoryTopic(
value,
anchor.viewport.postNumber,
anchor.viewport
), anchor;
}
#captureHistoryAnchor() {
const value = this.shell.activeValue;
if (!value) return null;
const viewport = value.dom.captureViewportAnchor();
return viewport ? (0, import_reader_history_model.normalizeReaderHistoryAnchorState)({
viewport,
replyWindow: value.topicContextSurface.captureDiscussionState(),
quoteHighlight: value.topicContextFeature.captureQuoteHighlightState()
}) : null;
}
#collectChronicleRequests(snapshot, context) {
const retained = new Set(snapshot.events.map((event) => event.id));
for (const id of this.#chronicleRequestIds)
retained.has(id) || this.#chronicleRequestIds.delete(id);
for (const event of snapshot.events) {
if (this.#chronicleRequestIds.has(event.id) || event.pending) continue;
const status = (0, import_reader_chronicle_repository.readerChronicleHttpStatus)(event.status);
if (event.phase !== "finished" || status === null || event.cloudflareMitigated || !event.sameOrigin) {
this.#chronicleRequestIds.add(event.id);
continue;
}
const target = (0, import_reader_chronicle_repository.readerChronicleRequestTarget)(event.path);
if (!target) {
this.#chronicleRequestIds.add(event.id);
continue;
}
const input = this.#chronicleInput(target, event, context);
if (input)
try {
this.chronicle.remember(input);
} catch {
} finally {
this.#chronicleRequestIds.add(event.id);
}
}
}
#rememberChronicleArchives(value) {
const session = value.services.session, topicId = Number(session.topicId);
if (!Number.isSafeInteger(topicId) || topicId <= 0) return;
const topic = session.topic ?? value.topic, topicTitle = String(topic?.title ?? "").trim() || this.history.entry(topicId)?.title || `帖子 #${topicId}`, alreadyRemembered = (kind, postNumber, status) => this.chronicle.snapshot.records.some(
(record) => record.kind === kind && Number(record.topicId) === topicId && (kind === "topic" || Number(record.postNumber) === postNumber) && record.status === status
), remember = (kind, postNumber, status, confirmedAt) => {
if (alreadyRemembered(kind, postNumber, status)) return;
const post = postNumber === null ? session.postByNumber(1) : session.postByNumber(postNumber);
if (kind === "reply" && (!post || post.reader_local_archive_placeholder === !0) || kind === "topic" && !topic && !session.cachedPosts().length) return;
const rawPostId = Number(post?.id);
try {
this.chronicle.remember({
kind,
status,
bodyCached: !0,
topicId,
topicTitle,
...postNumber === null ? {} : { postNumber },
...Number.isSafeInteger(rawPostId) && rawPostId > 0 ? { postId: rawPostId } : {},
requestPath: postNumber === null ? `/t/${topicId}.json` : `/posts/by_number/${topicId}/${postNumber}.json`,
requestMethod: "GET",
requestSource: "reader",
callSite: "topic-local-archive",
observedAt: confirmedAt
});
} catch {
}
}, archive = session.localArchiveState();
archive.topic && remember(
"topic",
null,
archive.topic.status,
archive.topic.confirmedAt
);
for (const entry of archive.posts)
remember(
"reply",
Number(entry.postNumber),
entry.status,
entry.confirmedAt
);
}
#rememberChronicleDeletedPosts(value, postNumbers = value.services.session.cachedPosts().map((post) => Number(post.post_number)), observedAt = Date.now()) {
for (const postNumber of postNumbers)
this.#rememberChronicleDeletedPost(
value,
Number(postNumber),
observedAt,
"post-model:deleted_at",
!0
);
}
#rememberChronicleDeletedPost(value, postNumber, observedAt, callSite, requireDeletedModel) {
if (!Number.isSafeInteger(postNumber) || postNumber < 1) return;
const post = value.services.session.postByNumber(postNumber);
if (!post) return;
const source = post, deletedAt = source.deleted_at ?? source.deletedAt;
if (requireDeletedModel && !deletedAt) return;
const topicId = Number(value.services.session.topicId), kind = postNumber === 1 ? "topic" : "reply";
if (this.chronicle.snapshot.records.some(
(record) => record.kind === kind && Number(record.topicId) === topicId && (kind === "topic" || Number(record.postNumber) === postNumber) && record.status === "deleted"
)) return;
const topic = value.services.session.topic ?? value.topic, parsedDeletedAt = Date.parse(String(deletedAt ?? ""));
try {
this.chronicle.remember({
kind,
status: "deleted",
bodyCached: !0,
topicId,
topicTitle: String(topic.title ?? "").trim() || this.history.entry(topicId)?.title,
...kind === "reply" ? { postNumber } : {},
postId: Number(post.id),
requestPath: kind === "topic" ? `/t/${topicId}.json` : `/posts/by_number/${topicId}/${postNumber}.json`,
requestMethod: "GET",
requestSource: "host",
callSite,
observedAt: Number.isFinite(parsedDeletedAt) ? parsedDeletedAt : observedAt
});
} catch {
}
}
#rememberBoostChronicleRequest(request, requestFloor) {
const boostId = Number(request.boostId);
if (!Number.isSafeInteger(boostId) || boostId <= 0) return;
const resolvedBoost = this.#chronicleBoostTarget(boostId);
for (const event of this.data.requests.snapshot.events) {
const status = (0, import_reader_chronicle_repository.readerChronicleHttpStatus)(event.status);
if (event.id <= requestFloor || event.phase !== "finished" || status === null || event.cloudflareMitigated || !event.sameOrigin) continue;
const target = (0, import_reader_chronicle_repository.readerChronicleRequestTarget)(event.path);
if (!target || target.kind === "boost") continue;
const resolvedRequest = this.#chronicleInput(target, event);
if (!(!resolvedRequest || Number(resolvedRequest.topicId) !== request.topicId))
try {
this.chronicle.remember({
kind: "boost",
status,
bodyCached: !0,
topicId: request.topicId,
topicTitle: resolvedBoost?.topicTitle || this.history.entry(request.topicId)?.title,
postNumber: resolvedBoost?.postNumber ?? request.postNumber,
postId: resolvedBoost?.postId,
boostId,
requestPath: event.path,
requestMethod: event.method,
requestSource: event.source,
callSite: [
`boost-target:${request.source}`,
event.callSite
].filter(Boolean).join(" · "),
observedAt: event.endedAt || event.startedAt
});
} catch {
}
}
}
#chronicleInput(target, event, context) {
const status = (0, import_reader_chronicle_repository.readerChronicleHttpStatus)(event.status);
if (status === null || event.cloudflareMitigated) return null;
let targetKind = target.kind, topicId = target.topicId, postNumber = target.postNumber, postId = target.postId;
const boostId = target.boostId;
let topicTitle = "";
const active = context ?? this.shell.activeValue;
if (postId !== null && active) {
const post = active.services.session.postById(postId), resolved = (0, import_identifiers.tryDiscoursePostNumber)(post?.post_number);
resolved !== null && (topicId = Number(active.services.session.topicId), postNumber = resolved, targetKind = resolved === 1 ? "topic" : "reply");
}
if (targetKind === "boost" && boostId !== null) {
const resolved = this.#chronicleBoostTarget(boostId);
resolved && (topicId = resolved.topicId, postNumber = resolved.postNumber, postId = resolved.postId, topicTitle = resolved.topicTitle);
}
if (topicId === null && postId !== null) {
const activity = this.bookmarkController?.activitySyncRecords().find(
(entry) => entry.postId === postId
);
activity && (topicId = Number(activity.topicId), postNumber = Number(activity.postNumber), topicTitle = activity.title, targetKind = postNumber === 1 ? "topic" : "reply");
}
if (topicId === null || targetKind === "reply" && postNumber === null && postId === null || targetKind === "boost" && boostId === null) return null;
if (!topicTitle && active && Number(active.services.session.topicId) === topicId) {
const topic = active.services.session.topic ?? active.topic;
topicTitle = String(topic.title ?? "").trim();
}
topicTitle ||= this.history.entry(topicId)?.title ?? `帖子 #${topicId}`;
const cachedPost = active && Number(active.services.session.topicId) === topicId ? postNumber !== null ? active.services.session.postByNumber(postNumber) : postId !== null ? active.services.session.postById(postId) : targetKind === "topic" ? active.services.session.postByNumber(1) : void 0 : void 0;
return !cachedPost || cachedPost.reader_local_archive_placeholder === !0 ? null : Object.freeze({
kind: targetKind,
status,
bodyCached: !0,
topicId,
topicTitle,
...postNumber === null ? {} : { postNumber },
...postId === null ? {} : { postId },
...boostId === null ? {} : { boostId },
requestPath: event.path,
requestMethod: event.method,
requestSource: event.source,
callSite: event.callSite,
observedAt: event.endedAt || event.startedAt
});
}
#chronicleBoostTarget(boostId) {
const active = this.shell.activeValue;
if (active)
for (const post of active.services.session.cachedPosts()) {
const source = post;
if (!(Array.isArray(source.boosts) ? source.boosts : source.boosts ? [source.boosts] : []).some((value) => value !== null && typeof value == "object" && Number(value.id) === boostId)) continue;
const topic = active.services.session.topic ?? active.topic;
return Object.freeze({
topicId: Number(active.services.session.topicId),
postNumber: Number(post.post_number),
postId: Number.isSafeInteger(Number(post.id)) ? Number(post.id) : null,
topicTitle: String(topic.title ?? "").trim()
});
}
const activity = this.bookmarkController?.activitySyncRecords().find(
(entry) => entry.tab === "Boost" && entry.identity === `boost:${boostId}`
);
if (activity)
return Object.freeze({
topicId: Number(activity.topicId),
postNumber: Number(activity.postNumber),
postId: activity.postId === null ? null : Number(activity.postId),
topicTitle: activity.title
});
const notification = this.notificationController?.syncHistoryRecords().find(
(entry) => entry.group === "boosts" && entry.identity === `boosts:${boostId}`
);
return notification?.target ? Object.freeze({
topicId: Number(notification.target.topicId),
postNumber: Number(notification.target.postNumber),
postId: null,
topicTitle: this.history.entry(notification.target.topicId)?.title ?? `帖子 #${notification.target.topicId}`
}) : null;
}
#rememberHistoryTopicMetadata(value) {
this.#rememberHistoryTopic(
value,
this.history.entry(value.services.session.topicId)?.postNumber ?? 1
);
}
#rememberHistoryTopic(value, postNumber = value.topicTimeline.snapshot.currentPostNumber, viewport) {
const topic = value.services.session.topic ?? value.topic, topicHeader = value.topicHeader.snapshot, posts = value.services.session.cachedPosts(), firstPost = posts.find(
(post) => (0, import_identifiers.tryDiscoursePostNumber)(post.post_number) === 1
), topicObservationMetadata = (0, import_reader_user_observation_model.normalizeReaderUserTopicMetadata)(
value.services.session.topicId,
topic,
firstPost
), headerObservationMetadata = (0, import_reader_user_observation_model.normalizeReaderUserTopicMetadata)(
value.services.session.topicId,
Object.freeze({
title: topicHeader.title,
category_id: topicHeader.categoryId || null,
category_name: topicHeader.category?.name ?? "",
tags: Object.freeze(topicHeader.tags.map((tag) => tag.name))
})
), observationMetadata = topicObservationMetadata && headerObservationMetadata ? (0, import_reader_user_observation_model.mergeReaderUserTopicMetadata)(
topicObservationMetadata,
headerObservationMetadata
) : topicObservationMetadata ?? headerObservationMetadata;
observationMetadata && this.userObservations.rememberTopicMetadata(observationMetadata);
const readPostNumbers = posts.filter(
(post) => post.read === !0
).map((post) => post.post_number);
readPostNumbers.push(
...value.services.read.snapshot().confirmed
);
const archive = value.services.session.localArchiveState(), floorArchive = archive.posts.find(
(entry) => entry.postNumber === postNumber
) ?? null;
try {
this.history.remember({
topicId: value.services.session.topicId,
title: topicHeader.title || topic.title,
postsCount: Math.max(
Number(topic.posts_count) || 0,
Number(topic.highest_post_number) || 0
),
avatarTemplate: topic.details?.created_by?.avatar_template ?? firstPost?.avatar_template,
ownerUsername: topic.details?.created_by?.username ?? firstPost?.username,
topicSubtitle: topicHeader.statsText === "主题信息暂不可用" ? "" : topicHeader.statsText,
categoryId: topicHeader.categoryId || null,
categoryName: topicHeader.category?.name ?? "",
tags: topicHeader.tags.map((tag) => tag.name),
...viewport === void 0 ? {} : { viewport },
postNumber,
readPostNumbers,
archiveStatus: archive.topic?.status ?? floorArchive?.status ?? null,
archivePostNumber: archive.topic === null ? floorArchive?.postNumber ?? null : null
});
} catch {
}
}
#historyOpenResult(result) {
return result.status === "failed" ? Object.freeze({
status: "failed",
topicId: result.topicId,
cause: result.cause
}) : Object.freeze({
status: result.status,
topicId: result.topicId
});
}
async #openHistoryEntry(entry) {
const anchor = this.historyNavigation.snapshot.states[String(entry.topicId)] ?? (entry.viewport === null ? null : (0, import_reader_history_model.normalizeReaderHistoryAnchorState)({ viewport: entry.viewport })), opened = await this.openTarget({
topicId: entry.topicId,
source: "restore"
});
anchor === null || opened.topic.status !== "opened" && opened.topic.status !== "reused" || await this.historyNavigation.restore(entry.topicId, anchor, {
highlight: !1,
restoreSemanticState: !1
});
}
}
function createReaderBrowserRuntimeStage(options) {
const appearanceByShell = /* @__PURE__ */ new WeakMap(), themeByShell = /* @__PURE__ */ new WeakMap(), fontByShell = /* @__PURE__ */ new WeakMap(), appearancePreferences = options.appearance || void 0, themeOptions = options.theme || void 0, fontPreferences = options.font || void 0, motionPreferences = options.motion || void 0, boostCopyPreferences = options.boostCopy || void 0;
if (motionPreferences && options.selectNavigationPreferences)
throw new Error(
"动画设置与独立导航偏好选择器只能配置一个"
);
const selectNavigationPreferences = motionPreferences ? (preferences) => (0, import_reader_motion_settings_form.readerMotionNavigationPreferences)(
motionPreferences.read(preferences)
) : options.selectNavigationPreferences, shellOptions = appearancePreferences || themeOptions || fontPreferences ? Object.freeze({
...options.shell,
createWorkspaceOptions: (shell, context) => {
const workspaceOptions = options.shell.createWorkspaceOptions(shell, context), theme = themeOptions ? new import_reader_theme_controller.ReaderThemeController({
root: shell.view.root,
preferences: themeOptions.preferences,
readPreferences: context.readPreferences,
preferenceChanges: context.preferenceChanges,
system: themeOptions.system,
...themeOptions.clock ? { clock: themeOptions.clock } : {},
parentScope: shell.scope
}) : null;
theme && themeByShell.set(shell, theme);
const appearance = appearancePreferences ? new import_reader_appearance_style_controller.ReaderAppearanceStyleController({
root: shell.view.root,
preferences: appearancePreferences,
readPreferences: context.readPreferences,
preferenceChanges: context.preferenceChanges,
environment: {
read: () => Object.freeze({
...workspaceOptions.readAppearance(),
...theme ? { theme: theme.snapshot.resolved } : {}
}),
subscribe: (listener, scope) => {
const publish = () => listener(
Object.freeze({
...workspaceOptions.readAppearance(),
...theme ? {
theme: theme.snapshot.resolved
} : {}
})
);
return workspaceOptions.appearanceChanges?.subscribe(
publish,
scope
), theme?.changes.subscribe(publish, scope), () => {
};
}
},
parentScope: shell.scope
}) : null;
appearance && appearanceByShell.set(shell, appearance);
const font = fontPreferences ? new import_reader_font_style_controller.ReaderFontStyleController({
root: shell.view.root,
pageRoot: workspaceOptions.elements.pageRoot,
resizeTarget: shell.view.modal,
preferences: fontPreferences,
readPreferences: context.readPreferences,
preferenceChanges: context.preferenceChanges,
readReaderWidth: () => shell.view.modal.clientWidth || 1080,
readSiteFontFamily: () => {
const target = options.runtime.document.body ?? workspaceOptions.elements.pageRoot;
return options.runtime.document.defaultView?.getComputedStyle(target).fontFamily || "inherit";
},
readExternalFontRendering: () => {
const pageRoot = workspaceOptions.elements.pageRoot;
return pageRoot.hasAttribute("fr-init-once") ? (options.runtime.document.defaultView?.getComputedStyle(pageRoot).getPropertyValue("--fr-render-text").trim() ?? "") !== "" : !1;
},
userAgent: options.runtime.document.defaultView?.navigator.userAgent ?? "",
platform: options.runtime.document.defaultView?.navigator.platform ?? "",
...workspaceOptions.createMutationObserver ? {
createMutationObserver: workspaceOptions.createMutationObserver
} : {},
...workspaceOptions.createResizeObserver ? {
createResizeObserver: workspaceOptions.createResizeObserver
} : {},
parentScope: shell.scope
}) : null;
return font && fontByShell.set(shell, font), Object.freeze({
...workspaceOptions,
...appearance ? {
readAppearance: () => appearance.snapshot.embedded,
appearanceChanges: appearance.embeddedChanges
} : {}
});
}
}) : options.shell;
return (0, import_reader_workspace_coordinator.createReaderShellWorkspaceStage)({
...shellOptions,
onReady(shell, workspace, context) {
const theme = themeByShell.get(shell) ?? null, appearance = appearanceByShell.get(shell) ?? null, font = fontByShell.get(shell) ?? null, queryLocalFonts = createReaderLocalFontQuery(
options.runtime.document
), sharedFontCatalog = new import_reader_font_catalog.ReaderFontCatalog({
document: options.runtime.document,
indexedDb: options.runtime.indexedDb ?? null,
...queryLocalFonts ? { queryLocalFonts } : {},
...options.runtime.fontStylesheet ? { appendStylesheet: options.runtime.fontStylesheet } : {}
});
shell.scope.add(() => {
sharedFontCatalog.destroy();
});
const rawNavigation = options.runtime.navigation;
if (boostCopyPreferences && options.runtime.boostCopy)
throw new Error(
"Boost 复制偏好投影与自定义 runtime 读取器只能配置一个"
);
const boostCopy = boostCopyPreferences ? Object.freeze({
readSettings: () => boostCopyPreferences.read(
context.readPreferences()
)
}) : options.runtime.boostCopy, topicActionRailPreferences = options.topicActionRail;
if (topicActionRailPreferences && options.runtime.topicActionRail !== void 0)
throw new Error(
"主帖操作列偏好投影与自定义 runtime 端口只能配置一个"
);
if (topicActionRailPreferences && !context.updatePreferences)
throw new Error(
"主帖操作列拖动与收纳需要 application 唯一偏好写端口"
);
const topicActionRail = topicActionRailPreferences === !1 ? !1 : topicActionRailPreferences ? Object.freeze({
read: () => topicActionRailPreferences.read(
context.readPreferences()
),
subscribe: (listener, scope) => context.preferenceChanges.subscribe(
(preferences) => listener(
topicActionRailPreferences.read(preferences)
),
scope
),
update: (patch) => {
const current = topicActionRailPreferences.read(
context.readPreferences()
);
context.updatePreferences(
topicActionRailPreferences.createPatch(
Object.freeze({
...current,
...patch
})
)
);
}
}) : options.runtime.topicActionRail;
if (options.selectPerformancePreferences && options.runtime.performance)
throw new Error(
"性能偏好投影与自定义 runtime performance 只能配置一个"
);
const performancePolicy = options.selectPerformancePreferences ? new import_reader_performance_policy.ReaderPerformancePolicy({
preferences: options.selectPerformancePreferences(
context.readPreferences()
),
shortBudgetCeiling: options.performanceBudgetCeilings?.short ?? options.runtime.permit.shortBudget,
longBudgetCeiling: options.performanceBudgetCeilings?.long ?? options.runtime.permit.longBudget,
capabilities: (0, import_reader_performance_policy.readBrowserPerformanceCapabilities)(
options.runtime.document.defaultView?.navigator
)
}) : null;
if (selectNavigationPreferences && (rawNavigation?.readOverscan || rawNavigation?.readMaxMountedPostCount || rawNavigation?.readLifetimeMs))
throw new Error(
"导航偏好投影与自定义虚拟窗口/高亮读取器只能配置一个"
);
const navigationPreferences = selectNavigationPreferences ? new import_reader_topic_navigation_preferences.ReaderTopicNavigationPreferenceProjection({
root: shell.view.root,
preferences: selectNavigationPreferences(
context.readPreferences()
),
...performancePolicy === null ? {} : {
readPerformance: () => performancePolicy.value
},
parentScope: shell.scope
}) : null, navigation = navigationPreferences ? Object.freeze({
...rawNavigation,
readOverscan: () => navigationPreferences.readOverscan(),
readMaxMountedPostCount: () => navigationPreferences.readMaxMountedPostCount(),
readLifetimeMs: () => navigationPreferences.readHighlightLifetimeMs()
}) : rawNavigation, rawHistory = options.runtime.history;
if (options.selectHistoryNavigationPreferences && rawHistory?.navigationView)
throw new Error(
"历史导航偏好投影与自定义 navigationView 只能配置一个"
);
const navigationHistory = options.selectHistoryNavigationPreferences ? Object.freeze({
...rawHistory,
navigationView: Object.freeze({
preferences: options.selectHistoryNavigationPreferences(
context.readPreferences()
)
})
}) : rawHistory;
if (options.selectHistoryPanelPreferences && !navigationHistory?.panelView)
throw new Error(
"历史列表偏好投影需要先配置唯一 panelView 端口"
);
const history = options.selectHistoryPanelPreferences ? Object.freeze({
...navigationHistory,
readSortMode: () => options.selectHistoryPanelPreferences(
context.readPreferences()
).sortMode,
panelView: Object.freeze({
...navigationHistory.panelView,
preferences: options.selectHistoryPanelPreferences(
context.readPreferences()
)
})
}) : navigationHistory, rawBookmarks = options.runtime.bookmarks;
if (options.selectBookmarkPreferences && rawBookmarks === !1)
throw new Error(
"收藏偏好投影与禁用收藏中心不能同时配置"
);
const bookmarks = options.selectBookmarkPreferences ? Object.freeze({
...rawBookmarks ?? {},
tabOrder: options.selectBookmarkPreferences(
context.readPreferences()
).tabOrder
}) : rawBookmarks, rawTimelineView = options.runtime.timelineView;
if (options.selectTimelineViewPreferences && (!rawTimelineView || rawTimelineView.readPreferences))
throw new Error(
"时间轴偏好投影需要唯一 timelineView,且不能同时注入自定义读取器"
);
let timelinePreferences = options.selectTimelineViewPreferences?.(
context.readPreferences()
) ?? null;
const timelineView = options.selectTimelineViewPreferences && rawTimelineView ? Object.freeze({
...rawTimelineView,
preferences: timelinePreferences,
readPreferences: () => timelinePreferences
}) : rawTimelineView;
if (options.selectTranslationMode === void 0 != (options.persistTranslationMode === void 0))
throw new Error(
"翻译偏好投影必须同时提供 selectTranslationMode 与 persistTranslationMode"
);
const rawTranslationView = options.runtime.translationView;
if (options.selectTranslationMode && rawTranslationView === !1)
throw new Error(
"翻译偏好投影与禁用翻译 View 不能同时配置"
);
if (options.selectTranslationMode && rawTranslationView && (rawTranslationView.initialMode !== void 0 || rawTranslationView.persistMode !== void 0))
throw new Error(
"翻译偏好投影与自定义 initialMode/persistMode 只能配置一个"
);
const translationView = options.selectTranslationMode ? Object.freeze({
...rawTranslationView || {},
initialMode: options.selectTranslationMode(
context.readPreferences()
),
persistMode: options.persistTranslationMode
}) : rawTranslationView, imagePreferences = options.image || null, rawLightbox = options.runtime.lightbox;
if (imagePreferences && rawLightbox?.preferences)
throw new Error(
"图片偏好投影与自定义 Lightbox 偏好端口只能配置一个"
);
if (imagePreferences && rawLightbox && !context.updatePreferences)
throw new Error(
"Lightbox 几何持久化需要 application 唯一偏好写端口"
);
const lightbox = imagePreferences && rawLightbox ? Object.freeze({
...rawLightbox,
preferences: Object.freeze({
read: () => {
const current = imagePreferences.read(
context.readPreferences()
);
return Object.freeze({
originalByDefault: current.lightboxOriginalByDefault,
commentsExpanded: current.lightboxCommentsExpandedByDefault,
descriptionExpanded: current.lightboxDescriptionExpanded,
lightboxDescriptionHeight: current.lightboxDescriptionHeight,
lightboxCommentsWidthPercent: current.lightboxCommentsWidthPercent
});
},
update: (patch) => {
const current = imagePreferences.read(
context.readPreferences()
), next = (0, import_reader_image_preferences.normalizeReaderImagePreferences)({
...current,
...patch.originalByDefault === void 0 ? {} : {
lightboxOriginalByDefault: patch.originalByDefault
},
...patch.commentsExpanded === void 0 ? {} : {
lightboxCommentsExpandedByDefault: patch.commentsExpanded
},
...patch.descriptionExpanded === void 0 ? {} : {
lightboxDescriptionExpanded: patch.descriptionExpanded
},
...patch.lightboxDescriptionHeight === void 0 ? {} : {
lightboxDescriptionHeight: patch.lightboxDescriptionHeight
},
...patch.lightboxCommentsWidthPercent === void 0 ? {} : {
lightboxCommentsWidthPercent: patch.lightboxCommentsWidthPercent
}
});
context.updatePreferences(
imagePreferences.createPatch(next)
);
}
})
}) : rawLightbox, loadingAnimation = motionPreferences ? new import_reader_loading_animation_view.ReaderLoadingAnimationView({
document: options.runtime.document,
host: shell.view.body,
shell,
preference: motionPreferences.read(
context.readPreferences()
).loadingAnimation,
siteName: motionPreferences.siteName,
parentScope: shell.scope
}) : null;
let downloadCurrentTopic = null;
const runtime = new ReaderBrowserRuntime({
...options.runtime,
...performancePolicy === null ? {} : { performance: performancePolicy.value },
...navigation === void 0 ? {} : { navigation },
...history === void 0 ? {} : { history },
...bookmarks === void 0 ? {} : { bookmarks },
...timelineView === void 0 ? {} : { timelineView },
...translationView === void 0 ? {} : { translationView },
...lightbox === void 0 ? {} : { lightbox },
...boostCopy === void 0 ? {} : { boostCopy },
...topicActionRail === void 0 ? {} : { topicActionRail },
topicSummaryFonts: Object.freeze({
readCurrentFamily: () => options.runtime.document.defaultView?.getComputedStyle(shell.view.root).getPropertyValue("--ldp-post-font-family").trim() || "system-ui,sans-serif",
...queryLocalFonts ? { queryLocalFonts } : {},
entries: () => sharedFontCatalog.entries(),
entry: (id) => sharedFontCatalog.entry(id),
ensureLoaded: (id) => sharedFontCatalog.ensureLoaded(id),
subscribe: (listener) => sharedFontCatalog.subscribe(listener)
}),
...options.openQueue && options.runtime.resources ? {
downloadCurrentTopic: () => downloadCurrentTopic?.()
} : {},
shell,
workspace,
...loadingAnimation ? { loadingProgress: loadingAnimation } : {},
parentScope: shell.scope
}), reportCacheError = (cause) => {
try {
options.runtime.onTopicFeatureError?.(Object.freeze({
topicId: runtime.shell.activeTopicId ?? 0,
feature: "cache",
cause
}));
} catch {
}
};
new import_browser_request_observation.DiscourseNativeAjaxObservationAdapter({
observer: runtime.data.requests,
jqueryModule: (0, import_native_host_api.discourseNativeJqueryModule)(options.runtime.host),
document: options.runtime.document,
hostRequestBudget: runtime.permit
}).install(runtime.scope);
const browserPerformance = options.runtime.document.defaultView?.performance ?? null;
browserPerformance && new import_browser_request_observation.BrowserResourceObservationAdapter({
observer: runtime.data.requests,
performance: browserPerformance,
hostRequestBudget: runtime.permit,
dynamicOnly: !0
}).install(runtime.scope);
const mobileReaderBackButton = shell.view.root.querySelector(
".ldp-mobile-reader-back"
), refreshTopicButton = shell.view.root.querySelector(
".ldp-reader-refresh"
), closeReaderButton = shell.view.root.querySelector(
".ldp-close"
), layoutToggleButton = shell.view.root.querySelector(
".ldp-layout-toggle"
);
if (!mobileReaderBackButton || !refreshTopicButton || !closeReaderButton || !layoutToggleButton)
throw runtime.destroy(), new Error("Reader Shell 缺少移动返回、布局、刷新或关闭入口");
new import_reader_mobile_return_controller.ReaderMobileReturnController({
document: options.runtime.document,
root: shell.view.root,
button: mobileReaderBackButton,
window: options.runtime.document.defaultView,
readReaderState: () => runtime.shell.state,
readerChanges: runtime.shell.changes,
parentScope: runtime.scope,
onError: (cause) => {
console.error("[main-lite:mobile-return]", cause);
}
});
let currentTopicRefresh = null;
const syncHeaderTopicActions = () => {
refreshTopicButton.disabled = currentTopicRefresh !== null || runtime.shell.activeValue === null;
const presentation = workspace.workspace.snapshot.presentation, fullPage = presentation.fullPage;
layoutToggleButton.hidden = presentation.embedded, layoutToggleButton.setAttribute(
"aria-pressed",
String(fullPage)
), layoutToggleButton.setAttribute(
"aria-label",
fullPage ? "切换为浮窗阅读器" : "切换为全屏阅读器"
), layoutToggleButton.title = fullPage ? "切换为浮窗阅读器" : "切换为全屏阅读器", layoutToggleButton.replaceChildren((0, import_reader_icon.renderReaderIcon)(
options.runtime.document,
fullPage ? "minimize-2" : "maximize-2",
options.runtime.renderIcon
));
}, refreshCurrentTopic = (refreshOptions = {}) => {
if (currentTopicRefresh) return currentTopicRefresh;
const topicId = runtime.shell.activeTopicId, active = runtime.shell.activeValue;
if (!topicId || !active)
return Promise.reject(new Error("当前没有可重建的主题"));
const transaction = (async () => {
const anchor = runtime.historyNavigation.captureCurrent(), exactAnchor = anchor === null ? null : Object.freeze({
...anchor,
viewport: Object.freeze({
postNumber: anchor.viewport.postNumber,
postOffset: anchor.viewport.postOffset,
scrollTop: anchor.viewport.scrollTop
})
}), onlyOpEnabled = active.topicOnlyOp.snapshot.enabled, sources = active.topicImages.snapshot().items.flatMap((item) => [
item.previewSrc,
item.originalSrc
]);
if (runtime.shell.activeTopicId !== topicId || runtime.shell.activeValue !== active)
throw new Error("当前主题已切换,已取消缓存重建");
if (!await runtime.shell.closeTopic())
throw new Error("当前主题关闭事务失败");
const cleanupFailures = [];
try {
const report = await runtime.data.responses.invalidateWithReport({ tags: [`topic:${topicId}`] });
cleanupFailures.push(...report.failures.map(
(failure) => failure.cause
));
} catch (cause) {
cleanupFailures.push(cause);
}
if (refreshOptions.clearImages !== !1 && runtime.imageResources && sources.length)
try {
const report = await runtime.imageResources.invalidateSources(sources);
cleanupFailures.push(...report.failures.map(
(failure) => failure.cause
));
} catch (cause) {
cleanupFailures.push(cause);
}
const reopened = await runtime.openTarget({
topicId,
...exactAnchor ? { postNumber: exactAnchor.viewport.postNumber } : {},
source: "restore"
});
if (reopened.topic.status === "failed") {
const refreshCause = reopened.topic.cause, recovered = await (async () => {
try {
return await active.services.snapshots.persistCurrentSnapshot(), await runtime.openTarget({
topicId,
...exactAnchor ? { postNumber: exactAnchor.viewport.postNumber } : {},
source: "restore"
});
} catch (recoveryCause) {
throw new AggregateError(
[refreshCause, recoveryCause],
"刷新当前帖子失败,且刷新前内容未能恢复"
);
}
})();
if (recovered.topic.status === "failed")
throw new AggregateError(
[refreshCause, recovered.topic.cause],
"刷新当前帖子失败,且刷新前内容未能恢复"
);
if (recovered.topic.status === "superseded")
throw new Error("刷新前内容恢复已被新的打开事务取代");
onlyOpEnabled && recovered.topic.value.topicOnlyOp.setEnabled(!0);
let recoveryMessage = "刷新当前帖子失败,已恢复刷新前内容;可稍后再试。";
if (exactAnchor)
try {
await runtime.historyNavigation.restore(
topicId,
exactAnchor
);
} catch {
recoveryMessage = "刷新当前帖子失败,已恢复刷新前内容,但之前的阅读位置未能完整还原。";
}
return Object.freeze({
complete: !1,
restored: !0,
message: recoveryMessage
});
}
if (reopened.topic.status === "superseded")
throw new Error("当前主题重建已被新的打开事务取代");
return onlyOpEnabled && reopened.topic.value.topicOnlyOp.setEnabled(!0), exactAnchor && await runtime.historyNavigation.restore(topicId, exactAnchor), Object.freeze({
complete: cleanupFailures.length === 0,
...cleanupFailures.length ? {
message: "当前主题已从原站重新获取,但部分旧缓存未能清理;可再次重试。"
} : {}
});
})();
return currentTopicRefresh = transaction, refreshTopicButton.classList.add("is-refreshing"), refreshTopicButton.setAttribute("aria-busy", "true"), syncHeaderTopicActions(), transaction.finally(() => {
currentTopicRefresh === transaction && (currentTopicRefresh = null, refreshTopicButton.classList.remove("is-refreshing"), refreshTopicButton.removeAttribute("aria-busy"), syncHeaderTopicActions());
}).catch(() => {
}), transaction;
};
runtime.scope.listen(closeReaderButton, "click", () => {
runtime.close();
}), runtime.scope.listen(refreshTopicButton, "click", (event) => {
event.preventDefault(), event.stopPropagation(), !refreshTopicButton.disabled && refreshCurrentTopic().then((result) => {
!result.complete && result.message && runtime.feedback.show(result.message);
}).catch((cause) => {
runtime.feedback.show(
cause instanceof Error ? cause.message : "刷新当前帖子失败"
);
});
}), runtime.scope.listen(layoutToggleButton, "click", () => {
const mode = workspace.workspace.snapshot.presentation.mode;
workspace.setMode(
mode === "fullpage" ? "floating" : "fullpage"
);
}), runtime.shell.changes.subscribe(
syncHeaderTopicActions,
runtime.scope
), workspace.workspace.changes.subscribe(
syncHeaderTopicActions,
runtime.scope
), syncHeaderTopicActions();
const imageProjection = imagePreferences ? new import_reader_image_preferences.ReaderImagePreferencesProjection({
contentRoot: shell.view.root,
lightboxRoot: options.runtime.document.documentElement,
parentScope: runtime.scope
}) : null;
if (imageProjection && imagePreferences) {
const readImageMode = () => (0, import_reader_image_preferences.readerImagePresentationMode)(
workspace.workspace.snapshot
), applyImagePreferences = () => {
imageProjection.applyMode(
imagePreferences.read(
context.readPreferences()
),
readImageMode()
);
};
applyImagePreferences(), context.preferenceChanges.subscribe((preferences) => {
imageProjection.applyMode(
imagePreferences.read(preferences),
readImageMode()
);
}, runtime.scope), workspace.workspace.changes.subscribe(
applyImagePreferences,
runtime.scope
);
}
appearance?.changes.subscribe(() => {
runtime.shell.activeValue?.dom.notifyScroll();
}, runtime.scope);
const readLayoutMode = () => workspace.workspace.snapshot.presentation.mode === "fullpage" ? "fullpage" : "standard", layout = options.layout ? new import_reader_layout_style_controller.ReaderLayoutStyleController({
root: shell.view.root,
preferences: options.layout,
readPreferences: context.readPreferences,
preferenceChanges: context.preferenceChanges,
mode: {
read: readLayoutMode,
subscribe: (listener, scope) => workspace.workspace.changes.subscribe(
() => listener(readLayoutMode()),
scope
)
},
parentScope: runtime.scope
}) : null;
if (options.settings !== !1 && options.settings !== void 0 && !context.updatePreferences)
throw runtime.destroy(), new Error(
"设置 controller 需要 application 唯一偏好写端口"
);
const settings = options.settings === !1 || !context.updatePreferences ? null : new import_reader_settings_controller.ReaderSettingsController({
preferences: {
read: context.readPreferences,
update: context.updatePreferences
},
...options.settings?.initialPanelId === void 0 ? {} : {
initialPanelId: options.settings.initialPanelId
}
});
settings && runtime.scope.add(() => settings.destroy());
const settingsViewOptions = options.settings ? options.settings.view : void 0, settingsView = settings && settingsViewOptions !== !1 ? new import_reader_settings_view.ReaderSettingsView({
document: options.runtime.document,
controller: settings,
feedback: runtime.feedback,
toggleHost: shell.view.root.querySelector(
".ldp-head-btns"
) ?? shell.view.root,
surfaceHost: shell.view.surfaceHost,
...options.runtime.renderIcon ? { renderIcon: options.runtime.renderIcon } : {},
...settingsViewOptions?.brandName === void 0 ? {} : { brandName: settingsViewOptions.brandName },
...(settingsViewOptions?.logoUrl ?? shell.view.root.querySelector(
"[data-ldp-site-logo]"
)?.src) === void 0 ? {} : {
logoUrl: settingsViewOptions?.logoUrl ?? shell.view.root.querySelector(
"[data-ldp-site-logo]"
).src
},
parentScope: runtime.scope
}) : null;
settingsView && theme && context.updatePreferences && new import_reader_theme_settings_control.ReaderThemeSettingsControl({
document: options.runtime.document,
host: settingsView.themeHost(),
theme,
persist: context.updatePreferences,
...themeOptions?.hostTheme ? { hostTheme: themeOptions.hostTheme } : {},
feedback: runtime.feedback,
...options.runtime.renderIcon ? { renderIcon: options.runtime.renderIcon } : {},
parentScope: runtime.scope
}), settingsView && new import_reader_window_settings_form.ReaderWindowSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("window"),
workspace,
parentScope: runtime.scope
});
const sitesFormOptions = options.settings ? options.settings.sitesForm : void 0;
if (sitesFormOptions && !settingsView)
throw runtime.destroy(), new Error(
"适用站点 form 需要启用唯一 Settings View"
);
if (settingsView && sitesFormOptions) {
const coordinatedProbe = sitesFormOptions.probe ? new import_browser_discourse_site_probe.CoordinatedDiscourseSiteProbe({
gateway: runtime.data.gateway,
transport: sitesFormOptions.probe
}) : null;
new import_reader_custom_site_settings_form.ReaderCustomSiteSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("sites"),
repository: sitesFormOptions.repository,
probe: coordinatedProbe,
parentScope: runtime.scope
});
}
const aboutContentOptions = options.settings ? options.settings.aboutContent : void 0;
if (aboutContentOptions && !settingsView)
throw runtime.destroy(), new Error(
"关于内容需要启用唯一 Settings View"
);
if (settingsView && aboutContentOptions) {
const siteLogo = shell.view.root.querySelector(
"[data-ldp-site-logo]"
)?.src;
new import_reader_about_settings_content.ReaderAboutSettingsContent({
document: options.runtime.document,
host: settingsView.panelHost("about"),
version: aboutContentOptions.version,
brandName: (settingsViewOptions ? settingsViewOptions.brandName : void 0) ?? "Awesome LinuxDo Reader",
...siteLogo ? { logoUrl: siteLogo } : {},
...aboutContentOptions.manualUrl ? { manualUrl: aboutContentOptions.manualUrl } : {},
parentScope: runtime.scope
});
}
const imageFormOptions = options.settings ? options.settings.imageForm : void 0;
if (imageFormOptions && !settingsView)
throw runtime.destroy(), new Error(
"图片设置 form 需要启用唯一 Settings View"
);
if (settingsView && imageFormOptions && new import_reader_image_settings_form.ReaderImageSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("image"),
controller: settings,
preferences: imageFormOptions,
readPreferences: context.readPreferences,
preferenceChanges: context.preferenceChanges,
parentScope: runtime.scope
}), settingsView) {
let settingsUserView = null, settingsUsername = "";
const mountSettingsUser = () => {
const currentUsername = (0, import_native_host_api.discourseNativeCurrentUsername)(
options.runtime.host
).toLocaleLowerCase();
settingsUserView && currentUsername === settingsUsername || (settingsUserView?.destroy(), settingsUsername = currentUsername, settingsUserView = new import_reader_settings_user_view.ReaderSettingsUserView({
document: options.runtime.document,
host: settingsView.panelHost("user"),
session: runtime.users,
username: currentUsername,
avatarSource: (template, size) => runtime.userNative.avatarSource(template, size),
connectEnabled: !!options.runtime.connect && options.runtime.document.location?.hostname === "linux.do",
history: runtime.connectHistory,
creditEnabled: options.runtime.document.location?.hostname === "linux.do",
communityScoreEnabled: !!options.runtime.communityScore && options.runtime.document.location?.hostname === "linux.do",
...options.runtime.renderIcon ? { renderIcon: options.runtime.renderIcon } : {},
parentScope: runtime.scope,
onError: (cause) => {
try {
options.runtime.onTopicFeatureError?.(Object.freeze({
topicId: runtime.shell.activeTopicId ?? 0,
feature: "user",
cause
}));
} catch {
}
}
}));
};
settingsView.changes.subscribe((snapshot) => {
snapshot.open && snapshot.activePanelId === "user" && (mountSettingsUser(), settingsUserView?.focusConnect());
}, runtime.scope);
}
const performanceFormOptions = options.settings ? options.settings.performanceForm : void 0;
if (performanceFormOptions && !settingsView)
throw runtime.destroy(), new Error(
"性能设置 form 需要启用唯一 Settings View"
);
settingsView && performanceFormOptions && new import_reader_performance_settings_form.ReaderPerformanceSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("performance"),
controller: settings,
preferences: performanceFormOptions,
readPreferences: context.readPreferences,
preferenceChanges: context.preferenceChanges,
parentScope: runtime.scope
});
const readingFormOptions = options.settings ? options.settings.readingForm : void 0;
if (readingFormOptions && !settingsView)
throw runtime.destroy(), new Error(
"阅读与导航设置 form 需要启用唯一 Settings View"
);
settingsView && readingFormOptions && new import_reader_reading_settings_form.ReaderReadingSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("reading"),
controller: settings,
preferences: readingFormOptions,
readPreferences: context.readPreferences,
preferenceChanges: context.preferenceChanges,
parentScope: runtime.scope
});
const translationFormOptions = options.settings ? options.settings.translationForm : void 0;
if (translationFormOptions && (!settingsView || !runtime.translationRequests))
throw runtime.destroy(), new Error(
"翻译与 AI 服务设置 form 需要启用 Settings View 与 TranslationRequestAdapter"
);
translationFormOptions && runtime.translationRequests && (runtime.scope.add(
translationFormOptions.repository.attachCacheObserver(
runtime.data.cacheEvents
)
), translationFormOptions.repository.metadataChanges.subscribe((cache) => {
cache || runtime.translationRequests?.clearPublicModelMetadataCache();
}, runtime.scope)), settingsView && translationFormOptions && runtime.translationRequests && (new import_reader_translation_settings_form.ReaderTranslationSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("translation"),
repository: translationFormOptions.repository,
presentation: translationFormOptions.presentation,
parentScope: runtime.scope
}), new import_reader_ai_service_settings_form.ReaderAiServiceSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("ai-service"),
surfaceHost: shell.view.surfaceHost,
repository: translationFormOptions.repository,
access: runtime.translationRequests,
parentScope: runtime.scope
}));
const interactionFormOptions = options.settings ? options.settings.interactionForm : void 0;
if (interactionFormOptions && !settingsView)
throw runtime.destroy(), new Error(
"帖子与回复设置 form 需要启用唯一 Settings View"
);
if (settingsView && interactionFormOptions) {
const interactionForm = new import_reader_interaction_settings_form.ReaderInteractionSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("interaction"),
controller: settings,
boostCopy: interactionFormOptions.boostCopy,
topicActionRail: interactionFormOptions.topicActionRail,
replyTree: interactionFormOptions.replyTree,
...interactionFormOptions.replyTreePreview === void 0 ? {} : {
replyTreePreview: interactionFormOptions.replyTreePreview
},
...interactionFormOptions.boostsAvailable === void 0 ? {} : {
boostsAvailable: interactionFormOptions.boostsAvailable
},
readPreferences: context.readPreferences,
preferenceChanges: context.preferenceChanges,
parentScope: runtime.scope
});
settingsView.changes.subscribe((snapshot) => {
snapshot.open && snapshot.activePanelId === "interaction" && interactionForm.refreshCapabilities();
}, runtime.scope);
}
settingsView && layout && new import_reader_layout_settings_form.ReaderLayoutSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("layout"),
controller: settings,
layout,
parentScope: runtime.scope
}), settingsView && appearance && new import_reader_appearance_settings_form.ReaderAppearanceSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("appearance"),
controller: settings,
appearance,
parentScope: runtime.scope
}), settingsView && font && new import_reader_font_settings_form.ReaderFontSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("font"),
controller: settings,
font,
fontCatalog: sharedFontCatalog,
parentScope: runtime.scope
}), settingsView && motionPreferences && navigationPreferences && new import_reader_motion_settings_form.ReaderMotionSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("flash"),
controller: settings,
navigation: navigationPreferences,
preferences: motionPreferences,
readPreferences: context.readPreferences,
preferenceChanges: context.preferenceChanges,
parentScope: runtime.scope
});
const webDavOptions = options.settings ? options.settings.webDav : void 0;
let webDavCoordinator = null;
const configuration = options.settings && options.settings.configuration ? options.settings.configuration : null, configurationManager = configuration ? new import_reader_settings_config_manager.ReaderSettingsConfigManager({
codec: new import_reader_settings_config_manager.ReaderSettingsConfigCodec(configuration.codec),
defaults: configuration.defaults,
preferences: {
read: context.readPreferences,
update: (preferences) => {
context.updatePreferences(preferences);
}
},
customSites: configuration.customSites,
translation: configuration.translation,
webDav: configuration.webDav,
...configuration.prepareResetPreferences ? {
prepareResetPreferences: configuration.prepareResetPreferences
} : {}
}) : null, cacheSurface = settingsView ? new import_reader_cache_management_surface.ReaderCacheManagementSurface({
document: options.runtime.document,
host: settingsView.panelHost("cache"),
...configurationManager ? {
configuration: {
export: () => configurationManager.export(),
prepare: (payload) => configurationManager.prepare(payload),
apply: (prepared) => configurationManager.apply(prepared),
reset: async () => {
const result = await configurationManager.reset();
return (0, import_reader_open_queue_session.requestReaderQueueSurfacePositionsReset)(
options.runtime.document
), result;
},
confirm: (request) => runtime.feedback.confirm(request),
saveTextFile: (content, filename) => {
if (!runtime.blobDownloads)
throw new Error(
"浏览器文件下载 capability 不可用"
);
runtime.blobDownloads.save(
new Blob(
[content],
{ type: "application/json" }
),
filename
);
}
}
} : {},
history: runtime.history,
chronicle: runtime.chronicle,
responses: runtime.data.responses,
...runtime.assetCaches ? { assetCaches: runtime.assetCaches } : {},
...webDavOptions ? {
prepareClear: async (categories) => {
const {
webDavCategories,
protectedCategories
} = readerWebDavCacheClearPlan(categories);
if (!webDavCategories.length)
return Object.freeze({
failed: Object.freeze([])
});
try {
if (!webDavCoordinator)
throw new Error("WebDAV 同步协调器尚未就绪");
const release = await webDavCoordinator.acquireLocalCacheClear(webDavCategories);
return Object.freeze({
failed: Object.freeze([]),
release
});
} catch (cause) {
return reportCacheError(cause), Object.freeze({
failed: Object.freeze(protectedCategories)
});
}
}
} : {},
applicationCaches: {
stats: async () => {
const users = runtime.users.cacheStats(), userObservations = runtime.userObservations.cacheStats(), notifications = runtime.notificationController?.cacheStats() ?? { pages: 0, records: 0 }, bookmarks2 = runtime.bookmarkController?.cacheStats() ?? {
bookmarks: 0,
reactions: 0,
boosts: 0,
replies: 0
}, publicModelMetadata = translationFormOptions ? await translationFormOptions.repository.loadModelMetadataCache() : null, creditBridge = await runtime.creditAccount?.cacheStats() ?? {
records: 0,
bytes: 0,
cachedAt: null,
expired: !1
}, imageObjects = runtime.imageResources?.diagnostics() ?? {
objectUrls: 0,
objectUrlLimit: 0
}, hostIdentity = (0, import_reader_topic_header.readerTopicHostIdentityCacheStats)(
options.runtime.document
);
return Object.freeze({
categories: Object.freeze({
topics: Object.freeze({
records: (runtime.shell.activeValue ? 1 : 0) + hostIdentity.categoryEntries + hostIdentity.tagEntries,
detail: runtime.shell.activeTopicId === null ? `当前会话:未打开主题;宿主身份派生:${hostIdentity.categoryEntries} 个分类键 · ${hostIdentity.tagEntries} 个标签` : `当前会话:主题 ${runtime.shell.activeTopicId}(清理时联网重建);宿主身份派生:${hostIdentity.categoryEntries} 个分类键 · ${hostIdentity.tagEntries} 个标签`
}),
users: Object.freeze({
records: users.profiles + users.followLists + users.externalSnapshots + creditBridge.records + userObservations.storedRecords,
detail: `内存热缓存:${users.profiles} 个资料 · ${users.followLists} 份关注列表 · ${users.externalSnapshots} 份账户摘要;用户观察:${userObservations.users} 人 · ${userObservations.memoryRecords} 条内存 / ${userObservations.storedRecords} 条持久投影;LDC bridge ${creditBridge.records} 条(${creditBridge.bytes} B)`
}),
notifications: Object.freeze({
records: notifications.records,
detail: `内存热缓存:${notifications.pages} 页 · ${notifications.records} 条消息`
}),
responses: Object.freeze({
records: bookmarks2.bookmarks + bookmarks2.reactions + bookmarks2.boosts + bookmarks2.replies + (publicModelMetadata ? 1 : 0),
detail: `内存热缓存:${bookmarks2.bookmarks} 条收藏 · ${bookmarks2.reactions} 条回应 · ${bookmarks2.boosts} 条 Boost · ${bookmarks2.replies} 条回复;公共模型元数据:${publicModelMetadata?.catalog.length ?? 0} 个模型`
}),
assets: Object.freeze({
records: imageObjects.objectUrls,
detail: `内存对象 URL:${imageObjects.objectUrls} / ${imageObjects.objectUrlLimit}`
})
})
});
},
clear: async (categories) => {
const selected = new Set(categories), failed = [], resumes = [];
if (selected.has("users"))
try {
runtime.users.clearCache(), runtime.userObservations.clearCache(), await runtime.creditAccount?.clearCache();
} catch (cause) {
failed.push("users"), reportCacheError(cause);
}
if (selected.has("notifications"))
try {
runtime.notificationController && (runtime.notificationController.clearCache({ resume: !1 }), resumes.push(() => runtime.notificationController?.startBackgroundCache()));
} catch (cause) {
failed.push("notifications"), reportCacheError(cause);
}
if (selected.has("responses"))
try {
runtime.bookmarkController && (runtime.bookmarkController.clearCache({ resume: !1 }), resumes.push(() => runtime.bookmarkController?.startBackgroundCache())), translationFormOptions && await translationFormOptions.repository.clearModelMetadataCache();
} catch (cause) {
failed.push("responses"), reportCacheError(cause);
}
if (selected.has("topics"))
try {
runtime.shell.activeValue && ((await refreshCurrentTopic({
clearImages: !1
})).complete || failed.push("topics"));
} catch (cause) {
failed.push("topics"), reportCacheError(cause);
} finally {
(0, import_reader_topic_header.clearReaderTopicHostIdentityCache)(
options.runtime.document
);
}
return Object.freeze({
failed: Object.freeze(failed),
...resumes.length ? {
resume: () => {
for (const resume of resumes) resume();
}
} : {}
});
}
},
clearImageObjectUrls: () => runtime.imageResources?.clearObjectUrls(),
cacheLog: runtime.data.cacheEvents,
...runtime.blobDownloads ? {
saveCacheLog: (content, filename) => {
runtime.blobDownloads.save(
new Blob([content], {
type: "application/x-ndjson;charset=utf-8"
}),
filename
);
}
} : {},
currentTopicAvailable: () => runtime.shell.activeTopicId !== null,
clearCurrentTopic: () => refreshCurrentTopic(),
notify: (message) => runtime.feedback.show(message),
onError: reportCacheError,
parentScope: runtime.scope
}) : null;
if (cacheSurface) {
runtime.shell.changes.subscribe(
() => cacheSurface.sync(),
runtime.scope
);
let cachePanelVisible = !1;
settingsView?.changes.subscribe((snapshot) => {
const visible = snapshot.open && snapshot.activePanelId === "cache";
visible && !cachePanelVisible && cacheSurface.refresh(), cachePanelVisible = visible;
}, runtime.scope);
}
const resourceMonitor = settingsView ? new import_reader_resource_monitor.ReaderResourceMonitor({
document: options.runtime.document,
host: settingsView.panelHost("logs"),
readerRoot: shell.view.root,
requests: runtime.data.requests,
cacheEvents: runtime.data.cacheEvents,
pipeline: runtime.pipeline,
schedulerSnapshot: () => runtime.data.client.scheduler.snapshot(),
permitSnapshot: () => runtime.permit.snapshot(),
performancePolicySnapshot: () => runtime.performance,
topicSnapshot: () => {
const active = runtime.shell.activeValue, session = active?.services.session, coverage = session?.postStreamCoverage(), unavailableFloors = session?.unavailablePostNumbers() ?? Object.freeze([]), unavailableSet = new Set(
unavailableFloors
), relations = active?.replies.topology.snapshot().relations ?? [], views = active?.dom.domOwner.views() ?? [], mountedViews = views.filter(
(view) => view.slots.root.isConnected
), roots = mountedViews.map(
(view) => view.slots.root
), imageCatalog = active?.topicImages.snapshot(), imageRetry = active?.topicMedia.images.diagnostics(), mediaRuntime = active?.topicMedia.media.diagnostics(), imageResources = runtime.imageResources?.diagnostics(), hlsSources = roots.reduce(
(total, root) => total + [
...root.querySelectorAll(
"video"
)
].filter(
(video) => (0, import_reader_media_controller.readerHlsSource)(
video,
options.runtime.document.baseURI
)
).length,
0
), nativeHlsSources = roots.reduce(
(total, root) => total + [
...root.querySelectorAll(
"video"
)
].filter((video) => {
if (!(0, import_reader_media_controller.readerHlsSource)(
video,
options.runtime.document.baseURI
)) return !1;
try {
return !!video.canPlayType(
"application/vnd.apple.mpegurl"
) && mediaRuntime?.nativeManagedMediaSource === !0;
} catch {
return !1;
}
}).length,
0
);
return Object.freeze({
topicId: runtime.shell.activeTopicId,
mountedFloors: mountedViews.length,
preparedFloors: active?.dom.preparedPostViewCount ?? views.length,
retainedFloors: session?.cachedPosts().length ?? 0,
nestedFloors: relations.filter(
(relation) => relation.parentPostNumber !== null
).length,
media: roots.reduce(
(total, root) => total + root.querySelectorAll(
"img,video,audio,iframe"
).length,
0
),
initializedFromCache: session?.initializedFromCache ?? !1,
expectedFloors: coverage?.expectedPostCount ?? 0,
streamFloors: coverage?.streamPostCount ?? 0,
missingFloors: coverage?.missingPostCount ?? 0,
unavailableFloors,
mediaDiagnostics: Object.freeze({
catalogImages: imageCatalog?.items.length ?? 0,
catalogComplete: imageCatalog?.complete ?? !1,
catalogPending: imageCatalog?.pending ?? !1,
catalogFailedBatches: imageCatalog?.failedBatchCount ?? 0,
persistentCacheEnabled: runtime.imageResources !== null,
objectUrls: imageResources?.objectUrls ?? 0,
objectUrlLimit: imageResources?.objectUrlLimit ?? 0,
boundImages: imageRetry?.boundImages ?? 0,
failedImages: imageRetry?.failedImages ?? 0,
retryingImages: imageRetry?.retryingImages ?? 0,
crossOriginFailures: imageRetry?.crossOriginFailures ?? 0,
failedPostNumbers: imageRetry?.failedPostNumbers ?? Object.freeze([]),
unavailableSourcePostNumbers: Object.freeze([
...new Set(
(imageCatalog?.items ?? []).map(
(item) => item.sourcePostNumber
).filter(
(postNumber) => unavailableSet.has(
postNumber
)
)
)
].sort((left, right) => left - right)),
hlsSources,
nativeHlsSources,
activeHlsPlayers: mediaRuntime?.activeHlsPlayers ?? 0,
hlsLibraryAvailable: mediaRuntime?.hlsLibraryAvailable ?? !1,
hlsLibrarySupported: mediaRuntime?.hlsLibrarySupported ?? !1,
nativeManagedMediaSource: mediaRuntime?.nativeManagedMediaSource ?? !1
})
});
},
performance: options.runtime.document.defaultView?.performance ?? null,
parentScope: runtime.scope
}) : null;
if (settingsView && resourceMonitor) {
const syncResourceMonitor = () => {
const snapshot = settingsView.snapshot;
snapshot.open && snapshot.activePanelId === "logs" ? resourceMonitor.start() : resourceMonitor.stop();
};
settingsView.changes.subscribe(
syncResourceMonitor,
runtime.scope
), syncResourceMonitor();
}
const queuePreferences = options.openQueue || null;
if (queuePreferences && !context.updatePreferences)
throw runtime.destroy(), new Error(
"阅读队列与退出策略需要 application 唯一偏好写端口"
);
queuePreferences && runtime.composer.installCloseGuard({
document: options.runtime.document,
enabled: () => queuePreferences.read(
context.readPreferences()
).confirmNativeComposerClose,
ownsEscape: () => (0, import_reader_escape_surface.readerFrontmostEscapeSurface)(
options.runtime.document
) === null,
notify: (message) => runtime.feedback.show(message),
parentScope: runtime.scope
});
const queuePrefetchForegroundBusy = () => {
if (options.runtime.document.visibilityState !== "visible" || runtime.shell.state === "opening" || runtime.shell.state === "switching") return !0;
const lastScrollAt = runtime.shell.activeValue?.dom.lastUserScrollAt() ?? 0, now = options.runtime.document.defaultView?.performance.now() ?? performance.now();
return lastScrollAt > 0 && now - lastScrollAt < 700;
}, waitForQueuePrefetchIdle = async (signal) => {
for (; !signal.aborted && queuePrefetchForegroundBusy(); )
await (0, import_coordinated_request_client.abortableDelay)(180, signal);
if (signal.aborted) throw signal.reason;
}, waitForQueuePrefetchRequestHeadroom = async (signal) => {
for (; !signal.aborted; ) {
await waitForQueuePrefetchIdle(signal);
const snapshot = await runtime.permit.snapshot();
if (snapshot.challengeState === "idle" && (0, import_reader_performance_policy.readerQueuePrefetchRequestHasHeadroom)(
snapshot,
runtime.performance.requestFlowSettings
)) return;
const delayMs = snapshot.nextPermitDelay > 0 ? Math.max(500, Math.min(2e3, snapshot.nextPermitDelay)) : 1e3;
await (0, import_coordinated_request_client.abortableDelay)(delayMs, signal);
}
throw signal.reason;
}, waitForTopicDownloadIdle = async (signal) => {
for (; !signal.aborted && options.runtime.document.visibilityState === "visible" && queuePrefetchForegroundBusy(); )
await (0, import_coordinated_request_client.abortableDelay)(180, signal);
if (signal.aborted) throw signal.reason;
}, waitForTopicDownloadRequestHeadroom = async (signal, nestedReplies = !1) => {
for (; !signal.aborted; ) {
await waitForTopicDownloadIdle(signal);
const snapshot = await runtime.permit.snapshot();
if (snapshot.challengeState === "idle" && (0, import_reader_performance_policy.readerBulkBackgroundRequestHasHeadroom)(
snapshot,
nestedReplies,
runtime.performance.requestFlowSettings
)) return;
const delayMs = snapshot.nextPermitDelay > 0 ? Math.max(180, Math.min(1e3, snapshot.nextPermitDelay)) : 360;
await (0, import_coordinated_request_client.abortableDelay)(delayMs, signal);
}
throw signal.reason;
}, topicDownloadRequestMustPause = (error) => runtime.data.client.requestResume(error) !== null, queueTopicPresentation = queuePreferences ? (0, import_native_host_api.discourseNativeTopicPresentation)(options.runtime.host) : null, topicOfflineArtifacts = runtime.blobDownloads ? new import_reader_topic_offline_artifact_repository.ReaderTopicOfflineArtifactRepository(
runtime.data.responses,
options.runtime.topic.authScope
) : null, topicDownloadQuoteEndpointPreferences = /* @__PURE__ */ new Map(), topicDownloadUnavailableQuoteTargets = /* @__PURE__ */ new Set(), openQueue = queuePreferences ? new import_reader_open_queue_session.ReaderOpenQueueSession({
document: options.runtime.document,
root: shell.view.modal,
workspaceRoot: shell.view.root,
storage: options.runtime.storage,
authScope: options.runtime.topic.authScope,
target: runtime,
currentTopicId: () => runtime.shell.activeTopicId,
readerOpen: () => [
"opening",
"switching",
"running",
"failed"
].includes(runtime.shell.state),
historyEntry: (topicId) => topicId ? runtime.history.entry(topicId) : runtime.history.ordered("recent-viewed")[0] ?? null,
avatarSource: (template, size) => queueTopicPresentation?.avatarSource(template, size) ?? "",
emojiSource: (id) => queueTopicPresentation?.emojiSource?.(id) ?? "",
historyAnchor: (topicId) => {
const activeAnchor = runtime.historyNavigation.snapshot.states[String(topicId)];
if (activeAnchor) return activeAnchor;
const viewport = runtime.history.entry(topicId)?.viewport ?? null;
return viewport === null ? null : (0, import_reader_history_model.normalizeReaderHistoryAnchorState)({ viewport });
},
restoreHistoryAnchor: async (topicId, anchor) => {
await runtime.historyNavigation.restore(topicId, anchor, {
highlight: !1,
restoreSemanticState: !1
});
},
prefetch: async (topicId, postNumber, signal, report) => {
const scope = runtime.scope.child(), abort = scope.abortController(
new DOMException("阅读队列预加载已释放", "AbortError"),
signal
), bundle = runtime.data.createTopicBundle({
topicId,
scope,
signal: abort.signal,
mount: () => () => {
}
}, {
...options.runtime.topic,
host: options.runtime.host,
nativeAjax: runtime.nativeAjax
});
try {
await (0, import_coordinated_request_client.abortableDelay)(900, abort.signal), await waitForQueuePrefetchRequestHeadroom(abort.signal), await bundle.services.session.init({ background: !0 });
const session = bundle.services.session, stream = [...session.streamPostIds()], targetPost = postNumber ? session.postByNumber(postNumber) : void 0, targetPostId = Number(
targetPost?.id
), foundIndex = targetPostId ? stream.findIndex((postId) => Number(postId) === targetPostId) : -1, targetIndex = foundIndex >= 0 ? foundIndex : Math.max(
0,
Math.min(stream.length - 1, Number(postNumber ?? 1) - 1)
);
let ids = stream;
if (stream.length > 200 && Number(postNumber ?? 1) > 1) {
const before = Math.min(targetIndex, 100);
let start = Math.max(0, targetIndex - before), end = Math.min(stream.length, targetIndex + 200 - before);
start = Math.max(0, end - 200), end = Math.min(stream.length, start + 200), ids = stream.slice(start, end);
} else if (stream.length > 200 && targetIndex > 0) {
const before = Math.min(targetIndex, 80);
let localStart = Math.max(0, targetIndex - before), localEnd = Math.min(
stream.length,
localStart + 160
);
localStart = Math.max(0, localEnd - 160);
const selected = new Set(stream.slice(0, 40));
stream.slice(localStart, localEnd).forEach((id) => selected.add(id)), selected.size < 200 && stream.slice(localEnd, localEnd + 200 - selected.size).forEach((id) => selected.add(id)), ids = [...selected].slice(0, 200);
} else stream.length > 200 && (ids = stream.slice(0, 200));
let loadedCount = stream.reduce(
(count, postId) => count + +!!session.postById(Number(postId)),
0
);
for (let offset = 0; offset < ids.length; offset += session.pageSize) {
const batch = ids.slice(offset, offset + session.pageSize), loadedBefore = batch.reduce(
(count, postId) => count + +!!session.postById(Number(postId)),
0
);
await session.loadPostsByIds(
batch,
{
background: !0,
maxAttempts: 1,
/*
* TopicSession 只在 canonical 缓存与在飞单飞
* 都未命中时调用 beforeNetwork。额度等待必须
* 放在这个真实缺口边界,不能阻塞纯缓存
* 队列或重复消费者加入已有请求。
*/
beforeNetwork: () => waitForQueuePrefetchRequestHeadroom(abort.signal)
}
);
const loadedAfter = batch.reduce(
(count, postId) => count + +!!session.postById(Number(postId)),
0
);
loadedCount += loadedAfter - loadedBefore, report({
loadedCount,
totalCount: stream.length
});
}
const nested = await session.loadReplyBranches(
ids.map((postId) => Number(
session.postById(Number(postId))?.post_number ?? 0
)).filter((postNumber2) => Number.isSafeInteger(postNumber2) && postNumber2 > 0),
{
background: !0,
maxPages: 32,
maxAttempts: 2,
beforePage: () => waitForQueuePrefetchRequestHeadroom(abort.signal)
}
);
report({
nestedLoadedCount: nested.loadedReplyCount,
nestedTotalCount: nested.expectedReplyCount
});
const topic = session.topic, mediaPosts = nested.postNumbers.map((childPostNumber) => session.postByNumber(childPostNumber)).filter((post) => post !== void 0), media = runtime.mediaPrefetch ? await runtime.mediaPrefetch.prefetch({
posts: mediaPosts,
signal: abort.signal,
waitUntilIdle: waitForQueuePrefetchRequestHeadroom,
reactionSources: (post) => topic ? runtime.postReactions.options(topic, post).flatMap((option) => option.imageUrl ? [option.imageUrl] : []) : [],
onProgress: (progress) => report({
mediaLoadedCount: progress.loadedCount,
mediaTotalCount: progress.totalCount
})
}) : Object.freeze({
loadedCount: 0,
totalCount: 0,
failedCount: 0,
complete: !0
});
return await session.flush(), loadedCount = stream.reduce(
(count, postId) => count + +!!session.postById(Number(postId)),
0
), Object.freeze({
loadedCount,
totalCount: stream.length,
nestedLoadedCount: nested.loadedReplyCount,
nestedTotalCount: nested.expectedReplyCount,
mediaLoadedCount: media.loadedCount,
mediaTotalCount: media.totalCount,
complete: loadedCount >= stream.length && nested.complete && media.complete
});
} finally {
await bundle.prepareClose?.("close"), scope.destroy();
}
},
...runtime.blobDownloads ? {
topicDownloads: {
downloads: runtime.blobDownloads,
requestResume: (error) => runtime.data.client.requestResume(error),
mount: shell.view.surfaceHost,
floating: !0,
confirmRemoval: async (context2, host) => {
const requestedAt = Number(
context2.localDownloadRequestedAt
), localDownload = requestedAt > 0 ? `曾于 ${new Date(requestedAt).toLocaleString(
"zh-CN"
)} 触发浏览器下载` : "Reader 未记录曾触发本地下载", choice = await runtime.feedback.choose({
title: "移除 Topic 下载记录?",
message: context2.hasCachedHtml ? "请选择是否同时删除 Reader 缓存中的离线 HTML。" : "该记录没有可清理的 Reader 缓存 HTML。",
details: Object.freeze([
Object.freeze({
label: "Reader 缓存 HTML",
value: context2.hasCachedHtml ? "已保存" : "未找到"
}),
Object.freeze({
label: "本地下载",
value: localDownload
}),
...context2.filename ? [Object.freeze({
label: "文件名",
value: context2.filename
})] : []
]),
note: "受浏览器安全限制,Reader 无法检查本地文件是否仍存在,也无法删除下载目录中的文件;如需删除请手动处理。",
cancelLabel: "取消",
...context2.hasCachedHtml ? { secondaryLabel: "仅移除记录" } : {},
confirmLabel: context2.hasCachedHtml ? "记录和缓存都删除" : "移除记录",
icon: "trash"
}, host);
return choice === "confirm" ? "remove-record-and-cache" : choice === "secondary" ? "remove-record" : "cancel";
},
confirmBulkRemoval: async (contexts, host) => {
const cachedCount = contexts.filter((context2) => context2.hasCachedHtml).length, downloadedCount = contexts.filter((context2) => context2.localDownloadRequestedAt > 0).length, choice = await runtime.feedback.choose({
title: `移除 ${contexts.length} 条 Topic 下载记录?`,
message: cachedCount ? "请选择是否同时删除这些记录在 Reader 中缓存的离线 HTML。" : "所选记录没有可清理的 Reader 缓存 HTML。",
details: Object.freeze([
Object.freeze({
label: "下载记录",
value: `${contexts.length} 条`
}),
Object.freeze({
label: "Reader 缓存 HTML",
value: `${cachedCount} 份`
}),
Object.freeze({
label: "曾触发本地下载",
value: `${downloadedCount} 条`
})
]),
note: "Reader 无法检查或删除下载目录中的本地文件;如需删除,请在文件管理器中手动处理。",
cancelLabel: "取消",
...cachedCount > 0 ? { secondaryLabel: "仅移除记录" } : {},
confirmLabel: cachedCount > 0 ? "记录和缓存都删除" : "移除记录",
icon: "trash"
}, host);
return choice === "confirm" ? "remove-record-and-cache" : choice === "secondary" ? "remove-record" : "cancel";
},
hydrateHtmlWindow: import_reader_topic_offline_document.hydrateReaderTopicOfflineDocumentWindow,
...topicOfflineArtifacts ? { artifacts: topicOfflineArtifacts } : {},
worker: async (topicId, fallbackTitle, signal, report, selection) => {
const scope = runtime.scope.child(), abort = scope.abortController(
new DOMException("Topic 后台下载已释放", "AbortError"),
signal
);
let backgroundNetworkRequestCount = 0;
async function beforeDownloadNetwork(networkSignal, nestedReplies = !1) {
await waitForTopicDownloadRequestHeadroom(
networkSignal,
nestedReplies
), backgroundNetworkRequestCount += 1;
}
const bundle = runtime.data.createTopicBundle({
topicId,
scope,
signal: abort.signal,
mount: () => () => {
}
}, {
...options.runtime.topic,
host: options.runtime.host,
nativeAjax: runtime.nativeAjax
});
try {
report({
phase: "loading-topic",
detail: "正在读取 Topic 与本地存档"
}), await waitForTopicDownloadIdle(abort.signal);
const topic = await bundle.services.session.init({
background: !0,
business: import_reader_business_request_policy.READER_TOPIC_DOWNLOAD_REQUEST_POLICY.kind,
beforeNetwork: beforeDownloadNetwork
}), session = bundle.services.session, cachedAtStart = session.cachedPosts(), streamCoverageAtStart = session.postStreamCoverage(), archiveAtStart = session.localArchiveState(), localArchivePlan = (0, import_reader_topic_download_manager.readerTopicDownloadLocalArchivePlan)({
topicStatus: archiveAtStart.topic?.status,
cachedPostCount: cachedAtStart.length,
expectedPostCount: streamCoverageAtStart.expectedPostCount,
streamPostCount: streamCoverageAtStart.streamPostCount,
missingStreamPostCount: streamCoverageAtStart.missingPostCount,
streamComplete: streamCoverageAtStart.complete
});
let streamComplete = !1, missingCanonicalPostCount = 0, repliesComplete = !1;
if (localArchivePlan)
streamComplete = localArchivePlan.streamComplete, missingCanonicalPostCount = localArchivePlan.missingCanonicalPostCount, report({
phase: "loading-replies",
completed: localArchivePlan.completed,
total: localArchivePlan.total,
detail: `正在整理 ${archiveAtStart.topic?.status ?? 404} 本地缓存 ${localArchivePlan.completed}/${localArchivePlan.total}`
});
else {
const stream = await session.ensurePostStream({
background: !0,
business: import_reader_business_request_policy.READER_TOPIC_DOWNLOAD_REQUEST_POLICY.kind,
maxAttempts: 2,
beforeNetwork: beforeDownloadNetwork,
beforeBatch: () => waitForTopicDownloadIdle(abort.signal),
onProgress: (progress) => report({
phase: "loading-posts",
completed: progress.loadedCount,
total: progress.totalCount,
detail: `正文已就绪 ${progress.loadedCount}/${progress.totalCount || "?"} · 本轮复用缓存 ${Math.min(cachedAtStart.length, progress.loadedCount)} · 后台联网 ${backgroundNetworkRequestCount}`
})
});
streamComplete = stream.complete, missingCanonicalPostCount = stream.missingPostIds.length;
const canonicalPosts = session.cachedPosts();
report({
phase: "loading-replies",
completed: canonicalPosts.length,
total: canonicalPosts.length,
detail: `已从 canonical 正文整理回复关系 ${canonicalPosts.length}/${canonicalPosts.length} · 后台联网 ${backgroundNetworkRequestCount}`
}), repliesComplete = stream.complete;
}
const topicRecord = topic, specialContentWarnings = [];
if (topicRecord.is_post_voting === !0 && !localArchivePlan) {
const record = (value) => value !== null && typeof value == "object" && !Array.isArray(value) ? value : null, votingPosts = session.cachedPosts().filter((post) => {
const source = post, loaded = Array.isArray(source.post_voting_comments) ? source.post_voting_comments.length : Array.isArray(source.comments) ? source.comments.length : 0;
return Math.max(0, Number(source.comments_count) || 0) > loaded;
});
for (const [postIndex, post] of votingPosts.entries()) {
const source = post, postId = Number(source.id), postNumber = Number(source.post_number), expectedComments = Math.max(
0,
Number(source.comments_count) || 0
);
if (!Number.isSafeInteger(postId) || postId < 1) continue;
const current = Array.isArray(source.post_voting_comments) ? source.post_voting_comments : Array.isArray(source.comments) ? source.comments : [], merged = /* @__PURE__ */ new Map();
for (const value of current) {
const candidate = record(value), id = Number(candidate?.id);
candidate && Number.isSafeInteger(id) && id > 0 && merged.set(id, Object.freeze({ ...candidate }));
}
try {
let pages = 0;
for (; merged.size < expectedComments && pages < 100; ) {
pages += 1, await waitForTopicDownloadIdle(abort.signal), report({
phase: "loading-replies",
completed: postIndex,
total: votingPosts.length,
detail: `正在补齐楼层 #${postNumber} 的投票评论 · 后台联网 ${backgroundNetworkRequestCount}`
});
const afterCommentId = Math.max(0, ...merged.keys()), payload = await bundle.services.requests.loadPostVotingComments(postId, {
afterCommentId,
refresh: !0,
background: !0,
business: import_reader_business_request_policy.READER_TOPIC_DOWNLOAD_REQUEST_POLICY.kind,
beforeNetwork: beforeDownloadNetwork
}), payloadRecord = record(payload), incoming = Array.isArray(payload) ? payload : Array.isArray(payloadRecord?.comments) ? payloadRecord.comments : [];
let added = 0;
for (const value of incoming) {
const candidate = record(value), id = Number(candidate?.id);
candidate && Number.isSafeInteger(id) && id > 0 && !merged.has(id) && (added += 1), candidate && Number.isSafeInteger(id) && id > 0 && merged.set(id, Object.freeze({ ...candidate }));
}
if (!added) break;
}
session.ingestPosts([Object.freeze({
...source,
post_voting_comments: Object.freeze([...merged.values()])
})], "action-response"), merged.size < expectedComments && specialContentWarnings.push(
`楼层 #${postNumber} 投票评论仅保存 ${merged.size}/${expectedComments}`
);
} catch (error) {
if (topicDownloadRequestMustPause(error)) throw error;
specialContentWarnings.push(
`楼层 #${postNumber} 投票评论未能补齐`
);
}
}
}
const archive = session.localArchiveState(), archived = archive.topic !== null || archive.posts.length > 0, coverage = (0, import_reader_topic_download_manager.readerTopicDownloadCoverage)({
selectionMode: selection.mode,
streamComplete,
missingCanonicalPostCount,
repliesComplete,
archived
});
await session.flush();
const availablePosts = session.cachedPosts(), selected = (0, import_reader_topic_download_manager.selectReaderTopicDownloadPosts)(
availablePosts,
selection,
selection.mode === "op" ? (0, import_reader_topic_header.readerTopicOwnerUsername)(topic, availablePosts) : ""
), contextPosts = selected.posts, contextPostNumbers = new Set(contextPosts.map((post) => Number(post.post_number))), availablePostByNumber = new Map(availablePosts.map((post) => [Number(post.post_number), post])), quotedPosts = /* @__PURE__ */ new Map(), quotePayloadPosts = /* @__PURE__ */ new Map(), quoteTargets = (0, import_reader_topic_offline_document.readerTopicOfflineQuoteTargets)(
options.runtime.document,
Number(topicId),
contextPosts
);
let missingQuoteTargetCount = 0;
for (const [quoteIndex, target] of quoteTargets.entries()) {
if (target.topicId === Number(topicId) && contextPostNumbers.has(target.postNumber)) continue;
const quoteKey = `${target.topicId}:${target.postNumber}`;
if (target.topicId === Number(topicId) && streamComplete && missingCanonicalPostCount === 0 && !availablePostByNumber.has(target.postNumber)) {
missingQuoteTargetCount += 1;
continue;
}
report({
phase: "loading-replies",
completed: quoteIndex,
total: quoteTargets.length,
detail: `正在补齐引用正文 ${quoteIndex + 1}/${quoteTargets.length} · 后台联网 ${backgroundNetworkRequestCount}`
});
let quotedPost = target.topicId === Number(topicId) ? availablePostByNumber.get(target.postNumber) ?? null : quotePayloadPosts.get(quoteKey) ?? null;
if (!quotedPost && !localArchivePlan && !topicDownloadUnavailableQuoteTargets.has(quoteKey)) {
await waitForTopicDownloadIdle(abort.signal);
const targetOptions = {
scope: "single",
background: !0,
business: import_reader_business_request_policy.READER_TOPIC_DOWNLOAD_REQUEST_POLICY.kind,
beforeNetwork: beforeDownloadNetwork
}, candidates = (0, import_reader_topic_offline_document.prioritizeReaderTopicOfflineTargetCandidates)(
bundle.services.requests.targetCandidates(
target.postNumber,
targetOptions,
target.topicId
),
topicDownloadQuoteEndpointPreferences.get(target.topicId)
);
for (const candidate of candidates)
try {
const payload = await bundle.services.requests.loadTargetCandidate(
candidate,
target.postNumber,
targetOptions,
target.topicId
);
for (const post of (0, import_topic_session.discoursePostsFromPayload)(payload)) {
const postNumber = Number(post.post_number);
!Number.isSafeInteger(postNumber) || postNumber < 1 || quotePayloadPosts.set(
`${target.topicId}:${postNumber}`,
post
);
}
if (quotedPost = quotePayloadPosts.get(quoteKey) ?? null, quotedPost) {
topicDownloadQuoteEndpointPreferences.set(
target.topicId,
candidate.endpoint
);
break;
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError" || topicDownloadRequestMustPause(error)) throw error;
const status = Number(
error?.status
);
if ((0, import_native_request_descriptors.discourseNativeTargetFailureIsDefinitive)({
endpoint: candidate.endpoint,
scope: targetOptions.scope,
status
})) {
topicDownloadUnavailableQuoteTargets.add(quoteKey);
break;
}
if ([401, 403].includes(status)) break;
}
}
quotedPost ? (topicDownloadUnavailableQuoteTargets.delete(quoteKey), quotedPosts.set(
quoteKey,
Object.freeze({ topicId: target.topicId, post: quotedPost })
)) : missingQuoteTargetCount += 1;
}
missingQuoteTargetCount > 0 && specialContentWarnings.push(
`${missingQuoteTargetCount} 个引用正文未能补齐`
);
const offlineTranslationController = runtime.translationFeature?.controller ?? null, offlineTranslationMode = offlineTranslationController?.mode ?? "original", offlineTranslationTheme = offlineTranslationController?.theme;
let offlineTranslations = null;
offlineTranslationController && offlineTranslationMode !== "original" && (offlineTranslations = await offlineTranslationController.prepareOfflineTranslations(
options.runtime.document,
Object.freeze([
...contextPosts,
...[...quotedPosts.values()].map((entry) => entry.post)
]),
abort.signal,
{
onProgress: (completed, total) => report({
phase: "serializing",
completed,
total,
detail: `正在补齐离线译文 ${completed}/${total}`
})
}
));
let selectedExpectedPostCount = session.postStreamCoverage().expectedPostCount;
const selectedComplete = coverage.complete && specialContentWarnings.length === 0, filenameScope = selected.filenameScope;
selection.mode !== "all" && (selectedExpectedPostCount = selected.expectedPostCount), report({
phase: "serializing",
completed: selection.mode === "all" ? contextPosts.length : selected.expectedPostCount,
total: selectedExpectedPostCount,
detail: [
selection.mode === "all" ? "正在生成单文件离线 HTML" : `正在生成离线 HTML · 已准备 ${contextPosts.length} 楼讨论上下文`,
coverage.warning,
...specialContentWarnings
].filter(Boolean).join(" · ")
});
const title = String(
topicRecord.fancy_title ?? topicRecord.title ?? fallbackTitle
).replace(/<[^>]+>/g, "").trim() || fallbackTitle, rootNode = runtime.shell.view.root.getRootNode(), readerRoot = runtime.shell.view.root, readerStyleProperties = {};
for (let index = 0; index < readerRoot.style.length; index += 1) {
const name = readerRoot.style.item(index);
name.startsWith("--") && (readerStyleProperties[name] = readerRoot.style.getPropertyValue(name));
}
const stylesheet = rootNode.querySelector(
"style[data-ldp-reader-shadow]"
)?.textContent ?? options.runtime.document.getElementById("ldp-mian-lite-styles")?.textContent ?? "", replyTreePreferences = interactionFormOptions ? interactionFormOptions.replyTree.read(
context.readPreferences()
) : import_reader_reply_tree_preferences.DEFAULT_READER_REPLY_TREE_PREFERENCES, header = (0, import_reader_topic_header.normalizeReaderTopicHeader)(
topic,
contextPosts,
queueTopicPresentation,
topicId
), logo = runtime.shell.view.root.querySelector(".ldp-logo"), offlineKatex = options.runtime.media?.katex ? new import_reader_katex_controller.ReaderKatexController({
document: options.runtime.document,
katex: options.runtime.media.katex
}) : null, prepareCooked = (cooked) => {
const host = options.runtime.document.createElement("div");
return host.className = "ldp-content cooked", host.innerHTML = cooked, (0, import_reader_cooked_content_feature2.prepareReaderCookedCallouts)(options.runtime.document, host), offlineKatex?.render(host), offlineTranslations && offlineTranslationController && offlineTranslationController.projectOfflineTranslations(
host,
offlineTranslations
), host.innerHTML;
};
let artifact;
try {
artifact = (0, import_reader_topic_offline_document.createReaderTopicOfflineDocument)({
topicId,
title,
sourceUrl: new URL(
`/t/${topicId}`,
options.runtime.document.baseURI
).href,
topic,
posts: contextPosts,
quotedPosts: Object.freeze([...quotedPosts.values()]),
...selection.mode !== "all" && selected.mainPostNumbers ? {
mainPostNumbers: selected.mainPostNumbers,
projectionMode: selection.mode
} : {},
expectedPostCount: selectedExpectedPostCount,
complete: selectedComplete,
archive,
inlineReplyTreeMaxDepth: replyTreePreferences.inlineReplyTreeMaxDepth,
header,
siteLogoUrl: logo?.currentSrc || logo?.src || "",
reactionEmojiUrl: (reactionId) => (0, import_native_host_api.discourseNativeEmojiUrl)(options.runtime.host, reactionId),
inlineEmojiUrl: (emojiId) => (0, import_native_host_api.discourseNativeEmojiUrl)(options.runtime.host, emojiId),
presentation: Object.freeze({
theme: readerRoot.dataset.ldpTheme === "dark" ? "dark" : "light",
translationMode: offlineTranslationMode,
...offlineTranslationTheme ? {
translationTheme: offlineTranslationTheme
} : {},
styleProperties: Object.freeze(readerStyleProperties),
structureColorsDisabled: readerRoot.classList.contains(
"ldp-structure-colors-disabled"
)
}),
stylesheet,
prepareCooked
});
} finally {
offlineKatex?.destroy();
}
const downloadArtifact = Object.freeze({
...artifact,
archiveStatus: archive.topic?.status ?? archive.posts[0]?.status ?? null
});
return filenameScope ? Object.freeze({
...downloadArtifact,
filename: downloadArtifact.filename.replace(
/-lite-offline\.html$/,
`-${filenameScope}-lite-offline.html`
)
}) : downloadArtifact;
} finally {
await bundle.prepareClose?.("close"), scope.destroy();
}
}
}
} : {},
closeReader: () => runtime.close(),
composerOpen: () => runtime.composer.isOpen(),
readerLightboxOpen: () => !!(0, import_reader_escape_surface.readerSurfaceQuery)(
options.runtime.document,
".ldp-lightbox"
),
readerSurfaceOpen: () => runtime.readerSurfaceOpen(),
closeExpandedReply: () => runtime.closeExpandedReply(),
readPreferences: () => queuePreferences.read(
context.readPreferences()
),
updatePreferences: (patch) => {
const current = queuePreferences.read(
context.readPreferences()
);
context.updatePreferences(
queuePreferences.createPatch(Object.freeze({
...current,
...patch
}))
);
},
notify: (message) => runtime.feedback.show(message),
parentScope: runtime.scope
}) : null;
if (openQueue) {
downloadCurrentTopic = () => {
openQueue.downloadCurrentTopic();
}, runtime.scope.add(() => {
downloadCurrentTopic = null;
});
const syncOpenQueue = () => openQueue.sync();
runtime.shell.changes.subscribe(syncOpenQueue, runtime.scope), runtime.history.changes.subscribe(syncOpenQueue, runtime.scope), context.preferenceChanges.subscribe(syncOpenQueue, runtime.scope), workspace.workspace.changes.subscribe(
() => openQueue.refreshSurface(),
runtime.scope
);
}
const informationFlow = options.informationFlow ?? new import_reader_information_flow_coordinator.ReaderInformationFlowCoordinator({
storageEvents: options.runtime.storageEvents ?? null,
parentScope: runtime.scope,
onDiagnostic: ({ domain, source, cause }) => {
console.error(
`[main-lite:information-flow:${domain}:${source}]`,
cause
);
}
});
runtime.scope.add(informationFlow.connectCache(
runtime.data.cacheCoordination
));
const registerInformationFlow = (registration) => {
runtime.scope.add(informationFlow.register(registration));
};
registerInformationFlow({
domain: "reading-history",
storageKeys: [runtime.history.storageKey],
refresh: () => runtime.topicStates.reloadExternal()
}), registerInformationFlow({
domain: "chronicle",
storageKeys: [runtime.chronicle.storageKey],
refresh: () => runtime.chronicle.reloadExternal()
}), registerInformationFlow({
domain: "unwanted-topics",
storageKeys: [runtime.unwantedTopics.storageKey],
refresh: () => runtime.topicStates.reloadExternal()
}), registerInformationFlow({
domain: "user-observations",
storageKeys: [runtime.userObservations.storageKey],
refresh: () => runtime.userObservations.reloadExternal()
}), registerInformationFlow({
domain: "topic-context",
storageKeys: [runtime.threadContextState.storageKey],
subscriptions: [{
source: "userscript-value",
subscribe: (notify) => runtime.threadContextState.subscribeExternal(notify)
}],
refresh: () => runtime.threadContextState.reloadExternal()
}), registerInformationFlow({
domain: "topic-summary-state",
storageKeys: [
import_reader_topic_summary_surface.READER_TOPIC_SUMMARY_RESULTS_STORAGE_KEY,
import_reader_topic_summary_surface.READER_TOPIC_SUMMARY_SHARE_SETTINGS_KEY
],
storageKeyPrefixes: [
`${import_reader_topic_summary_surface.READER_TOPIC_SUMMARY_WINDOW_GEOMETRY_STORAGE_KEY_PREFIX}:`
],
refresh: () => runtime.reloadExternalTopicSummaryState()
}), registerInformationFlow({
domain: "surface-layout",
storageKeys: [import_reader_collection_floating_window.READER_COLLECTION_FLOATING_WINDOW_GEOMETRY_KEY],
refresh: () => (0, import_reader_floating_window_frame.reloadReaderFloatingWindowTabGeometry)(
runtime.shell.view.surfaceHost
)
}), runtime.connectHistory && registerInformationFlow({
domain: "connect-trust-history",
storageKeys: [runtime.connectHistory.storageKey],
refresh: () => runtime.connectHistory?.reloadExternal()
}), runtime.creditAccount && registerInformationFlow({
domain: "credit-account",
subscriptions: [{
source: "userscript-value",
subscribe: (notify) => runtime.creditAccount.subscribeExternal(notify)
}],
refresh: () => runtime.users.reloadExternalCredit()
});
const projectionScope = encodeURIComponent(
String(options.runtime.topic.authScope).trim()
);
runtime.notificationController && registerInformationFlow({
domain: "notifications",
cacheIdPrefixes: [
`reader-collection-projection:notifications:manifest:v1:${projectionScope}:`
],
refresh: () => runtime.notificationController?.reloadExternalProjection()
}), runtime.bookmarkController && registerInformationFlow({
domain: "bookmarks",
cacheIdPrefixes: [
`reader-collection-projection:bookmarks:manifest:v1:${projectionScope}:`
],
refresh: () => runtime.bookmarkController?.reloadExternalProjection()
}), openQueue && (registerInformationFlow({
domain: "reader-queue",
storageKeys: [openQueue.storageKey],
refresh: () => openQueue.reloadExternal()
}), topicOfflineArtifacts && registerInformationFlow({
domain: "download-history",
cacheIds: [topicOfflineArtifacts.manifestCacheId],
refresh: () => openQueue.reloadExternalDownloads()
}));
const translationOptions = options.settings ? options.settings.translationForm : void 0;
if (webDavOptions && !settingsView)
throw runtime.destroy(), new Error("WebDAV 设置需要启用唯一 Settings View");
if (settingsView && webDavOptions) {
const coordinator = new import_reader_webdav_coordinator.ReaderWebDavCoordinator({
client: webDavOptions.client,
repository: webDavOptions.repository,
categories: (0, import_reader_webdav_category_ports.createReaderWebDavCategoryPorts)({
history: runtime.history,
notifications: runtime.notificationController,
bookmarks: runtime.bookmarkController,
queue: openQueue,
preferences: {
read: context.readPreferences,
validate: (id, value, records) => {
const preferences = context.readPreferences();
return (0, import_reader_webdav_category_ports.readerWebDavPreferenceRecordMatchesSchema)(
preferences,
id,
value,
(candidate) => webDavOptions.preferencesCodec.export(candidate).settings,
records
);
},
update: (patch) => {
context.updatePreferences(patch);
}
},
topicContext: runtime.threadContextState,
customSites: webDavOptions.customSites,
connectHistory: runtime.connectHistory,
translation: translationOptions ? translationOptions.repository : null,
translationCache: options.runtime.translation ? {
responses: runtime.data.responses,
cache: options.runtime.translation.translationCache
} : null,
offlineTopics: topicOfflineArtifacts
}),
hostname: () => options.runtime.document.location.hostname,
username: () => (0, import_native_host_api.discourseNativeCurrentUsername)(
options.runtime.host
)
});
webDavCoordinator = coordinator, runtime.scope.add(() => {
webDavCoordinator === coordinator && (webDavCoordinator = null);
});
const webDavSettingsForm = new import_reader_webdav_settings_form.ReaderWebDavSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("sync"),
repository: webDavOptions.repository,
coordinator,
unavailableReason: () => (0, import_native_host_api.discourseNativeCurrentUsername)(options.runtime.host) ? "" : "当前未登录 Discourse,WebDAV 同步不可用。请先登录并刷新页面。",
parentScope: runtime.scope
});
settingsView.changes.subscribe((snapshot) => {
snapshot.open && snapshot.activePanelId === "sync" && webDavSettingsForm.refreshAvailability();
}, runtime.scope), new import_reader_webdav_coordinator.ReaderWebDavAutoSync({
repository: webDavOptions.repository,
coordinator,
visibilityState: () => options.runtime.document.visibilityState,
parentScope: runtime.scope
});
}
const notificationTrigger = shell.view.root.querySelector(".ldp-notifications-toggle"), historyTrigger = shell.view.root.querySelector(".ldp-history-toggle"), bookmarkTrigger = shell.view.root.querySelector(".ldp-bookmarks-toggle"), settingsTrigger = shell.view.root.querySelector(".ldp-settings-toggle"), exclusivePanels = [
...runtime.notificationController && notificationTrigger ? [{
id: "notifications",
coexistGroup: "floating-tools",
trigger: notificationTrigger,
isOpen: () => runtime.notificationController.snapshot.open,
open: () => runtime.notificationController.open(),
close: () => runtime.notificationController.close()
}] : [],
...runtime.historyPanelView && historyTrigger ? [{
id: "history",
coexistGroup: "floating-tools",
trigger: historyTrigger,
isOpen: () => runtime.historyPanelView.snapshot.open,
open: () => runtime.historyPanelView.open(),
close: () => runtime.historyPanelView.close()
}] : [],
...runtime.bookmarkController && bookmarkTrigger ? [{
id: "bookmarks",
coexistGroup: "floating-tools",
trigger: bookmarkTrigger,
isOpen: () => runtime.bookmarkController.snapshot.open,
open: () => runtime.bookmarkController.open(),
close: () => runtime.bookmarkController.close()
}] : [],
...settingsView && settingsTrigger ? [{
id: "settings",
trigger: settingsTrigger,
isOpen: () => settingsView.snapshot.open,
open: () => settingsView.open(),
close: () => settingsView.requestClose()
}] : []
];
exclusivePanels.length > 1 && new import_reader_exclusive_panel_coordinator.ReaderExclusivePanelCoordinator({
entries: exclusivePanels,
beforeOpen: (target) => {
target.id === "settings" && runtime.unwantedTopicView.window.isOpen && runtime.unwantedTopicView.close();
},
parentScope: runtime.scope,
onError: (cause) => {
runtime.feedback.show(
cause instanceof Error ? cause.message : "面板切换失败"
);
}
});
const shortcutPreferences = options.shortcuts || null;
if (shortcutPreferences && !context.updatePreferences)
throw runtime.destroy(), new Error(
"快捷键设置需要 application 唯一偏好写端口"
);
let fullscreenReturnMode = workspace.workspace.snapshot.presentation.mode === "fullpage" ? "floating" : workspace.workspace.snapshot.presentation.mode;
const triggerTopicAction = (selector) => {
const button = runtime.shell.activeValue?.topicActionRail?.view?.slots.actions.querySelector(selector);
return !button || button.disabled || button.hidden ? !1 : (button.click(), !0);
}, triggerHeaderPanel = (selector) => {
const trigger = shell.view.root.querySelector(selector);
return !trigger || trigger.hidden || trigger.getAttribute("aria-disabled") === "true" || "disabled" in trigger && trigger.disabled ? !1 : (trigger.click(), !0);
}, triggerFloatingPanelShortcut = (selector, tabId) => (0, import_reader_floating_window_frame.restoreReaderFloatingWindowTabSession)(
shell.view.surfaceHost,
tabId
) || triggerHeaderPanel(selector), shortcuts = shortcutPreferences ? new import_reader_shortcut_controller.ReaderShortcutController({
target: options.runtime.document,
preferences: shortcutPreferences,
readPreferences: context.readPreferences,
preferenceChanges: context.preferenceChanges,
persist: context.updatePreferences,
canExecute: (action, event) => readerSurfaceOnlyCloseEvents.has(event) || action === "refreshHost" && !workspace.workspace.snapshot.presentation.embedded || runtime.readerShortcutContextBlocked() ? !1 : action !== "closeReader" ? !0 : (0, import_reader_shortcut_controller.readerShortcutBindingFromEvent)(event) === "Escape" ? !runtime.readerExitBlocked() : !0,
onUnavailable: (_action, label) => {
runtime.feedback.show(`“${label}”当前不可用`);
},
execute: (action, event) => {
const active = runtime.shell.activeValue;
switch (action) {
case "historyBack":
return runtime.historyNavigation.navigate("back");
case "historyForward":
return runtime.historyNavigation.navigate("forward");
case "topicTop":
return active ? active.topicTimeline.jumpTo(1, {
alignment: "start",
highlight: !0
}) : !1;
case "topicBottom":
return active ? active.topicTimeline.jumpTo(
active.topicTimeline.snapshot.totalPostCount,
{
alignment: "start",
highlight: !0
}
) : !1;
case "floorJump":
return active?.topicTimelineView ? (active.topicTimelineView.focusJump(), !0) : !1;
case "discussionHorizontalScroll":
return active?.topicContextSurface.scrollDiscussionHorizontal(
event.type === "wheel" ? event.deltaY || event.deltaX : 0
) ?? !1;
case "onlyAuthor":
return active ? (active.topicOnlyOp.toggle(), !0) : !1;
case "translate":
return runtime.translationFeature ? (runtime.translationFeature.controller.cycleMode(), !0) : !1;
case "refreshTopic":
return !active || refreshTopicButton.disabled ? !1 : (refreshTopicButton.click(), !0);
case "refreshHost":
return (0, import_native_host_api.discourseNativeHostRouteRefresh)(
options.runtime.host
);
case "openOriginal": {
const link = shell.view.root.querySelector("a.ldp-open");
return !link?.href || link.hidden ? !1 : (link.click(), !0);
}
case "settings":
return triggerHeaderPanel(".ldp-settings-toggle");
case "notifications":
return triggerFloatingPanelShortcut(
".ldp-notifications-toggle",
"notifications"
);
case "historyPanel":
return triggerFloatingPanelShortcut(
".ldp-history-toggle",
"history"
);
case "bookmarksPanel":
return triggerFloatingPanelShortcut(
".ldp-bookmarks-toggle",
"bookmarks"
);
case "likeTopic":
return triggerTopicAction("button[data-post-like]");
case "replyTopic":
return triggerTopicAction("button[data-post-reply]");
case "bookmarkTopic":
return triggerTopicAction("button[data-post-bookmark]");
case "toggleFullscreen": {
const mode = workspace.workspace.snapshot.presentation.mode;
return mode === "fullpage" ? workspace.setMode(fullscreenReturnMode) : (fullscreenReturnMode = mode, workspace.setMode("fullpage"));
}
case "toggleQueue":
return openQueue ? (openQueue.toggle(), !0) : !1;
case "closeReader":
return runtime.handleCloseReaderShortcut(event);
}
},
onError: (cause) => {
runtime.feedback.show(
cause instanceof Error ? cause.message : "快捷操作执行失败"
);
},
parentScope: runtime.scope
}) : null;
settingsView && shortcuts && new import_reader_shortcut_settings_form.ReaderShortcutSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("shortcuts"),
shortcuts,
parentScope: runtime.scope
}), (performancePolicy || navigationPreferences || loadingAnimation) && context.preferenceChanges.subscribe((preferences) => {
let performanceSnapshot = null;
performancePolicy && options.selectPerformancePreferences && (performanceSnapshot = performancePolicy.apply(
options.selectPerformancePreferences(preferences)
)), navigationPreferences && selectNavigationPreferences && (navigationPreferences.apply(
selectNavigationPreferences(preferences)
), navigationPreferences.refreshPerformance()), loadingAnimation && motionPreferences && loadingAnimation.apply(
motionPreferences.read(preferences).loadingAnimation
), performanceSnapshot && runtime.applyPerformance(performanceSnapshot);
}, runtime.scope), runtime.historyNavigationView && options.selectHistoryNavigationPreferences && context.preferenceChanges.subscribe((preferences) => {
runtime.historyNavigationView?.applyPreferences(
options.selectHistoryNavigationPreferences(
preferences
)
);
}, runtime.scope), runtime.historyPanelView && options.selectHistoryPanelPreferences && context.preferenceChanges.subscribe((preferences) => {
const projection = options.selectHistoryPanelPreferences(preferences);
runtime.historyPanelView?.applyPreferences(projection), runtime.historyNavigation.refreshOrder();
}, runtime.scope), runtime.bookmarkController && options.selectBookmarkPreferences && context.preferenceChanges.subscribe((preferences) => {
runtime.bookmarkController?.applyTabOrder(
options.selectBookmarkPreferences(preferences).tabOrder
);
}, runtime.scope), options.selectTimelineViewPreferences && context.preferenceChanges.subscribe((preferences) => {
timelinePreferences = options.selectTimelineViewPreferences(preferences), runtime.shell.activeValue?.topicTimelineView?.applyPreferences(timelinePreferences);
}, runtime.scope), runtime.translationFeature && options.selectTranslationMode && context.preferenceChanges.subscribe((preferences) => {
runtime.translationFeature?.applyMode(
options.selectTranslationMode(preferences)
), runtime.translationFeature?.syncMountedPosts();
}, runtime.scope);
let readyCleanup;
try {
readyCleanup = options.onReady?.(
runtime,
context,
settings,
settingsView,
layout,
appearance,
font
) || void 0;
} catch (error) {
throw runtime.destroy(), error;
}
return () => {
try {
readyCleanup?.();
} finally {
runtime.destroy();
}
};
}
});
}
}, "349a3d88ef1b52be26fe6f0131096bf184cec80d8106b2a9c8e99c5443bf5398");
/* Source: lite/src/app/reader-data-runtime.ts */
runtime.register("src/app/reader-data-runtime.js", function(module, exports, require) {
var reader_data_runtime_exports = {};
__export(reader_data_runtime_exports, {
READER_CACHE_COORDINATION_CHANNEL: () => READER_CACHE_COORDINATION_CHANNEL,
READER_CACHE_COORDINATION_LOCK: () => READER_CACHE_COORDINATION_LOCK,
READER_CACHE_COORDINATION_STORAGE_KEY: () => READER_CACHE_COORDINATION_STORAGE_KEY,
READER_RESPONSE_CACHE_DATABASE: () => READER_RESPONSE_CACHE_DATABASE,
READER_RESPONSE_CACHE_LEGACY_DATABASES: () => READER_RESPONSE_CACHE_LEGACY_DATABASES,
READER_RESPONSE_CACHE_STORE: () => READER_RESPONSE_CACHE_STORE,
ReaderDataRuntime: () => ReaderDataRuntime
});
module.exports = __toCommonJS(reader_data_runtime_exports);
var import_cache_coordination = require("../cache/cache-coordination.js"), import_indexeddb_response_cache_store = require("../cache/indexeddb-response-cache-store.js"), import_cache_observer = require("../cache/cache-observer.js"), import_response_repository = require("../cache/response-repository.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_coordinated_request_client = require("../network/coordinated-request-client.js"), import_domain_request_gateway = require("../network/domain-request-gateway.js"), import_request_rate_limit_policy = require("../network/request-rate-limit-policy.js"), import_request_observer = require("../network/request-observer.js"), import_read_state_coordination = require("../reading/read-state-coordination.js"), import_reader_topic_core_bundle = require("../topic/reader-topic-core-bundle.js");
const READER_RESPONSE_CACHE_DATABASE = "awesome linuxdo reader", READER_RESPONSE_CACHE_LEGACY_DATABASES = Object.freeze([
"linuxdo-enhanced-reader:responses:v1"
]), READER_RESPONSE_CACHE_STORE = "responses", READER_CACHE_COORDINATION_STORAGE_KEY = "linuxdo-enhanced-reader:cache-coordination:v1", READER_CACHE_COORDINATION_LOCK = "linuxdo-enhanced-reader:cache-coordination-lock:v1", READER_CACHE_COORDINATION_CHANNEL = "linuxdo-enhanced-reader:cache-coordination-channel:v1";
function sourceId(value) {
const normalized = String(value).trim();
if (!normalized) throw new Error("Reader data runtime sourceId 不能为空");
return normalized;
}
function browserChannelFactory(value) {
return value !== void 0 ? value : typeof BroadcastChannel > "u" ? null : (name) => new BroadcastChannel(name);
}
class ReaderDataRuntime {
scope;
rateLimit;
requests;
cacheEvents;
client;
responses;
gateway;
cacheCoordination;
readCoordination;
#destroyed = !1;
constructor(options) {
const id = sourceId(options.sourceId);
this.cacheEvents = new import_cache_observer.ReaderCacheObserver({
...options.now === void 0 ? {} : { now: options.now }
});
const report = (phase, cause) => {
(phase === "indexeddb" || phase === "cache-coordination") && this.cacheEvents.record({
operation: phase === "indexeddb" ? "read" : "invalidate",
outcome: "failure",
source: phase === "indexeddb" ? "indexeddb" : "cross-tab",
key: phase,
error: cause
}), options.onDiagnostic?.(Object.freeze({ phase, cause }));
};
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
try {
const channelFactory = browserChannelFactory(
options.broadcastChannelFactory
), cacheChannel = new import_cache_coordination.BroadcastCacheCoordinationChannel({
name: READER_CACHE_COORDINATION_CHANNEL,
factory: channelFactory,
onError: (cause) => report("cache-coordination", cause)
}), cacheState = new import_cache_coordination.BrowserCacheCoordinationStatePort({
storage: options.storage,
storageKey: READER_CACHE_COORDINATION_STORAGE_KEY,
lockName: READER_CACHE_COORDINATION_LOCK,
locks: options.locks ?? null,
onError: (cause) => report("cache-coordination", cause)
});
this.cacheCoordination = new import_cache_coordination.CrossTabCacheCoordinator({
sourceId: id,
channel: cacheChannel,
state: cacheState,
flightTtlMs: options.cacheFlightTtlMs,
flightStaleMs: options.cacheFlightStaleMs,
...options.now === void 0 ? {} : { now: options.now },
onError: (cause) => report("cache-coordination", cause)
}), this.scope.add(() => this.cacheCoordination.close());
const store = new import_indexeddb_response_cache_store.IndexedDbResponseCacheStore({
databaseName: READER_RESPONSE_CACHE_DATABASE,
legacyDatabaseNames: READER_RESPONSE_CACHE_LEGACY_DATABASES,
storeName: READER_RESPONSE_CACHE_STORE,
operationTimeoutMs: options.responseOperationTimeoutMs,
maxEntries: options.responsePersistentMaxEntries,
maxBytes: options.responsePersistentMaxBytes,
factory: options.indexedDb ?? null,
...options.now === void 0 ? {} : { now: options.now },
onError: (cause) => report("indexeddb", cause),
observer: this.cacheEvents
});
this.scope.add(() => {
store.close();
}), this.responses = new import_response_repository.ResponseRepository({
store,
maxMemoryEntries: options.responseMemoryMaxEntries,
maxMemoryBytes: options.responseMemoryMaxBytes,
mutationPort: this.cacheCoordination,
flightPort: this.cacheCoordination,
observer: this.cacheEvents,
...options.cacheFlightHeartbeatMs === void 0 ? {} : { flightHeartbeatMs: options.cacheFlightHeartbeatMs },
...options.cacheFlightWaitTimeoutMs === void 0 ? {} : { flightWaitTimeoutMs: options.cacheFlightWaitTimeoutMs },
...options.now === void 0 ? {} : { now: options.now },
onPersistenceError: (cause) => report("indexeddb", cause)
}), this.scope.add(this.cacheCoordination.subscribeInvalidation((query) => {
this.responses.applyExternalInvalidation(query);
}));
const readChannel = channelFactory ? new import_read_state_coordination.BroadcastReadStateChannel({
createChannel: channelFactory,
onListenerError: (cause) => report("read-coordination", cause)
}) : void 0, lock = options.locks ? (name, task) => options.locks.request(name, { mode: "exclusive" }, task) : void 0;
this.readCoordination = new import_read_state_coordination.BrowserReadStateCoordinator({
storage: options.storage,
...readChannel === void 0 ? {} : { channel: readChannel },
...lock === void 0 ? {} : { lock },
...options.now === void 0 ? {} : { now: options.now },
...options.readCoordinationTtlMs === void 0 ? {} : { ttlMs: options.readCoordinationTtlMs },
...options.readCoordinationMaxRecords === void 0 ? {} : { maxRecords: options.readCoordinationMaxRecords },
...options.readStateRequestsPerMinute === void 0 ? {} : { readRequestsPerMinute: options.readStateRequestsPerMinute },
...options.readStateTimingsPerMinute === void 0 ? {} : { readTimingsPerMinute: options.readStateTimingsPerMinute },
onCoordinationError: (cause) => report("read-coordination", cause)
}), this.scope.add(() => this.readCoordination.close()), this.rateLimit = new import_request_rate_limit_policy.RequestRateLimitPolicy(options.rateLimit), this.requests = new import_request_observer.RequestObserver({
baseHref: options.rateLimit.baseUrl ?? "https://invalid.local/",
retentionMs: 15 * 6e4,
maxEntries: 1200,
...options.now === void 0 ? {} : { now: options.now }
}), this.client = new import_coordinated_request_client.CoordinatedRequestClient({
scheduler: options.scheduler,
rateLimitPolicy: this.rateLimit,
permitPort: options.permit,
observer: this.requests,
...options.now === void 0 ? {} : { now: options.now },
...options.defaultMax429Retries === void 0 ? {} : { defaultMax429Retries: options.defaultMax429Retries },
onCoordinationError: (cause) => report("request-coordination", cause)
}), this.scope.add(() => this.client.destroy()), this.gateway = new import_domain_request_gateway.DomainRequestGateway(
this.client,
this.responses,
options.trace
);
} catch (error) {
throw this.scope.destroy(), error;
}
}
createTopicBundle(context, options) {
if (this.#destroyed || this.scope.destroyed)
throw new Error("ReaderDataRuntime 已销毁");
return (0, import_reader_topic_core_bundle.createReaderTopicCoreBundle)(context, {
...options,
gateway: this.gateway,
responses: this.responses,
readCoordination: this.readCoordination
});
}
applyRequestRuntimePolicy(policy) {
this.#destroyed || this.scope.destroyed || (this.client.applyRuntimePolicy(policy), policy.responseMemoryMaxEntries !== void 0 && policy.responseMemoryMaxBytes !== void 0 && this.responses.applyMemoryPolicy({
maxEntries: policy.responseMemoryMaxEntries,
maxBytes: policy.responseMemoryMaxBytes
}), policy.readStateRequestsPerMinute !== void 0 && policy.readStateTimingsPerMinute !== void 0 && this.readCoordination.applyRuntimePolicy({
readRequestsPerMinute: policy.readStateRequestsPerMinute,
readTimingsPerMinute: policy.readStateTimingsPerMinute
}));
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
}
}, "97acf86a2b0707c98c7b5813443a613b3188193222e1f5b339a172a24e09856a");
/* Source: lite/src/app/reader-performance-policy.ts */
runtime.register("src/app/reader-performance-policy.js", function(module, exports, require) {
var reader_performance_policy_exports = {};
__export(reader_performance_policy_exports, {
ReaderPerformancePolicy: () => ReaderPerformancePolicy,
readBrowserPerformanceCapabilities: () => readBrowserPerformanceCapabilities,
readerBulkBackgroundRequestHasHeadroom: () => readerBulkBackgroundRequestHasHeadroom,
readerQueuePrefetchRequestHasHeadroom: () => readerQueuePrefetchRequestHasHeadroom
});
module.exports = __toCommonJS(reader_performance_policy_exports);
var import_reader_business_request_config = require("../network/reader-business-request-config.js"), import_reader_request_flow_config = require("../network/reader-request-flow-config.js");
const DEFAULTS = Object.freeze({
pageSize: 32,
streamOverscanScreens: 1,
streamMaxMountedPostCount: 64,
nestedPrefetchScreens: 2,
requestMaxConcurrent: 3,
requestMinIntervalMs: 100,
requestRateTargetPercent: 85,
readStateRequestsPerMinute: 10,
readStateTimingsPerMinute: 240
});
function finiteRange(value, fallback, minimum, maximum) {
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value)) : fallback;
}
function integerRange(value, fallback, minimum, maximum) {
return Math.round(finiteRange(value, fallback, minimum, maximum));
}
function positiveInteger(value, name) {
const normalized = Number(value);
if (!Number.isSafeInteger(normalized) || normalized < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return normalized;
}
function readerBulkBackgroundRequestHasHeadroom(input, nestedReplies = !1, flowValue) {
const flow = (0, import_reader_request_flow_config.normalizeReaderRequestFlowSettings)(flowValue), budgetShare = flow.bulkBackgroundBudgetPercent / 100, shortLimit = Math.max(
1,
Math.min(
Math.floor(
positiveInteger(input.shortBudget, "shortBudget") * budgetShare
),
nestedReplies ? flow.nestedBackgroundShortLimit : Number.MAX_SAFE_INTEGER
)
), longLimit = Math.max(
1,
Math.min(
Math.floor(
positiveInteger(input.longBudget, "longBudget") * budgetShare
),
nestedReplies ? flow.nestedBackgroundLongLimit : Number.MAX_SAFE_INTEGER
)
), normalizedCount = (value) => Number.isFinite(value) && value >= 0 ? Math.floor(value) : Number.MAX_SAFE_INTEGER;
return normalizedCount(input.shortCount) < shortLimit && normalizedCount(input.longCount) < longLimit;
}
function readerQueuePrefetchRequestHasHeadroom(input, flowValue) {
const flow = (0, import_reader_request_flow_config.normalizeReaderRequestFlowSettings)(flowValue), shortLimit = Math.max(
1,
Math.min(
positiveInteger(input.shortBudget, "shortBudget"),
flow.queuePrefetchShortLimit
)
), longLimit = Math.max(
1,
Math.min(
positiveInteger(input.longBudget, "longBudget"),
flow.queuePrefetchLongLimit
)
), normalizedCount = (value) => Number.isFinite(value) && value >= 0 ? Math.floor(value) : Number.MAX_SAFE_INTEGER;
return normalizedCount(input.shortCount) < shortLimit && normalizedCount(input.longCount) < longLimit;
}
function positiveFinite(value) {
const normalized = Number(value);
return Number.isFinite(normalized) && normalized > 0 ? normalized : void 0;
}
function readBrowserPerformanceCapabilities(navigatorValue) {
const source = navigatorValue, logicalProcessors = positiveFinite(source?.hardwareConcurrency), memoryGiB = positiveFinite(source?.deviceMemory), effectiveType = String(
source?.connection?.effectiveType ?? ""
).trim().toLowerCase();
return Object.freeze({
...logicalProcessors === void 0 ? {} : { logicalProcessors },
...memoryGiB === void 0 ? {} : { memoryGiB },
...source?.connection?.saveData === !0 ? { saveData: !0 } : {},
...effectiveType ? { effectiveType } : {}
});
}
function runtimeScales(capabilities) {
const cores = positiveFinite(capabilities.logicalProcessors), memory = positiveFinite(capabilities.memoryGiB), coreScale = cores === void 0 ? 1 : cores <= 2 ? 0.55 : cores <= 4 ? 0.75 : cores <= 6 ? 0.9 : 1, memoryScale = memory === void 0 ? 1 : memory <= 2 ? 0.55 : memory <= 4 ? 0.75 : memory <= 6 ? 0.9 : 1, connectionScale = capabilities.saveData === !0 ? 0.55 : capabilities.effectiveType === "slow-2g" ? 0.45 : capabilities.effectiveType === "2g" ? 0.6 : capabilities.effectiveType === "3g" ? 0.8 : 1;
return Object.freeze({
render: Math.min(coreScale, memoryScale),
network: connectionScale
});
}
function snapshot(preferences, shortBudgetCeiling, longBudgetCeiling, capabilities) {
const scales = runtimeScales(capabilities), batchScale = Math.min(scales.render, scales.network), pageSize = integerRange(
preferences.performancePageSize,
DEFAULTS.pageSize,
12,
64
), streamMaxMountedPostCount = integerRange(
preferences.performanceStreamMaxItems,
DEFAULTS.streamMaxMountedPostCount,
24,
128
), nestedPrefetchScreens = finiteRange(
preferences.performanceNestedPrefetch,
DEFAULTS.nestedPrefetchScreens,
1,
3
), requestMaxConcurrent = integerRange(
preferences.performanceRequestConcurrency,
DEFAULTS.requestMaxConcurrent,
1,
4
), requestMinIntervalMs = integerRange(
preferences.performanceRequestInterval,
DEFAULTS.requestMinIntervalMs,
80,
500
), requestRateTargetPercent = integerRange(
preferences.performanceRequestRateTarget,
DEFAULTS.requestRateTargetPercent,
50,
95
), readStateRequestsPerMinute = integerRange(
preferences.performanceReadStateRequestsPerMinute,
DEFAULTS.readStateRequestsPerMinute,
1,
60
), readStateTimingsPerMinute = integerRange(
preferences.performanceReadStateTimingsPerMinute,
DEFAULTS.readStateTimingsPerMinute,
20,
1200
), requestFlowSettings = (0, import_reader_request_flow_config.normalizeReaderRequestFlowSettings)(
preferences.requestFlowSettings
), target = requestRateTargetPercent / 100, scaledPageSize = integerRange(
Math.floor(pageSize * batchScale),
pageSize,
12,
64
), scaledMountedPostCount = integerRange(
Math.floor(streamMaxMountedPostCount * scales.render),
streamMaxMountedPostCount,
24,
128
), scaledRequestConcurrency = integerRange(
Math.round(requestMaxConcurrent * batchScale),
requestMaxConcurrent,
1,
4
), resourceTier = scaledRequestConcurrency <= 2 || scaledMountedPostCount <= 48 ? "low" : scaledRequestConcurrency >= 4 && scaledMountedPostCount >= 90 ? "high" : "balanced", overscanScreens = finiteRange(
preferences.performanceStreamOverscan,
DEFAULTS.streamOverscanScreens,
0.25,
3
);
return Object.freeze({
pageSize: scaledPageSize,
streamOverscanScreens: finiteRange(
overscanScreens * scales.render,
overscanScreens,
0.25,
3
),
streamMaxMountedPostCount: scaledMountedPostCount,
nestedPrefetchScreens: finiteRange(
nestedPrefetchScreens * scales.network,
nestedPrefetchScreens,
1,
3
),
requestMaxConcurrent: scaledRequestConcurrency,
requestMinIntervalMs: integerRange(
Math.ceil(requestMinIntervalMs / Math.max(0.35, batchScale)),
requestMinIntervalMs,
80,
500
),
requestRateTargetPercent,
readStateRequestsPerMinute,
readStateTimingsPerMinute,
businessRequestSettings: (0, import_reader_business_request_config.normalizeReaderBusinessRequestSettings)(
preferences.businessRequestSettings
),
requestFlowSettings,
requestShortBudget: Math.max(
1,
Math.floor(shortBudgetCeiling * target)
),
requestLongBudget: Math.max(
1,
Math.floor(longBudgetCeiling * target)
),
preheatMaxConcurrent: Math.min(
requestFlowSettings.hostPreheatMaxConcurrent,
resourceTier === "low" ? 1 : resourceTier === "high" ? 3 : 2
),
preheatHandoffMaxEntries: resourceTier === "low" ? 1 : resourceTier === "high" ? 6 : 3,
preheatHandoffMaxBytes: (resourceTier === "low" ? 2 : resourceTier === "high" ? 16 : 8) * 1024 * 1024,
preheatHandoffTtlMs: 6e4,
responseMemoryMaxEntries: resourceTier === "low" ? 48 : resourceTier === "high" ? 96 : 72,
responseMemoryMaxBytes: (resourceTier === "low" ? 8 : resourceTier === "high" ? 24 : 16) * 1024 * 1024,
projectionHydrationBatchSize: resourceTier === "high" ? 2 : 1
});
}
class ReaderPerformancePolicy {
#shortBudgetCeiling;
#longBudgetCeiling;
#capabilities;
#snapshot;
constructor(options) {
this.#shortBudgetCeiling = positiveInteger(
options.shortBudgetCeiling,
"shortBudgetCeiling"
), this.#longBudgetCeiling = positiveInteger(
options.longBudgetCeiling,
"longBudgetCeiling"
), this.#capabilities = Object.freeze({
...options.capabilities ?? {}
}), this.#snapshot = snapshot(
options.preferences,
this.#shortBudgetCeiling,
this.#longBudgetCeiling,
this.#capabilities
);
}
get value() {
return this.#snapshot;
}
apply(preferences) {
const next = snapshot(
preferences,
this.#shortBudgetCeiling,
this.#longBudgetCeiling,
this.#capabilities
);
return Object.entries(next).every(([key, value]) => key === "businessRequestSettings" ? (0, import_reader_business_request_config.readerBusinessRequestSettingsEqual)(
(0, import_reader_business_request_config.normalizeReaderBusinessRequestSettings)(
this.#snapshot.businessRequestSettings
),
(0, import_reader_business_request_config.normalizeReaderBusinessRequestSettings)(
next.businessRequestSettings
)
) : key === "requestFlowSettings" ? (0, import_reader_request_flow_config.readerRequestFlowSettingsEqual)(
(0, import_reader_request_flow_config.normalizeReaderRequestFlowSettings)(
this.#snapshot.requestFlowSettings
),
(0, import_reader_request_flow_config.normalizeReaderRequestFlowSettings)(
next.requestFlowSettings
)
) : this.#snapshot[key] === value) ? this.#snapshot : (this.#snapshot = next, next);
}
}
}, "f937af7b3bab664298a46aae0e11f0ad2404d17d187c08e9a22137881d53a8b7");
/* Source: lite/src/components/reader-control-tooltip.ts */
runtime.register("src/components/reader-control-tooltip.js", function(module, exports, require) {
var reader_control_tooltip_exports = {};
__export(reader_control_tooltip_exports, {
ReaderControlTooltip: () => ReaderControlTooltip
});
module.exports = __toCommonJS(reader_control_tooltip_exports);
var import_event_target = require("../dom/event-target.js"), import_html_element = require("../dom/html-element.js"), import_lifecycle = require("../kernel/lifecycle.js");
const TIME_TOOLTIP_SELECTOR = ".ldp-time[data-exact-time]", TOOLTIP_CONTROL_SELECTOR = [
"button",
"a",
'[role="button"]',
"[data-ldp-tooltip-label]",
TIME_TOOLTIP_SELECTOR,
".ldp-nested-branch-toggle",
".ldp-avatar-flair",
".ldp-user-card-badge"
].join(","), READER_SURFACE_SELECTOR = [
".ldp-overlay",
".ldp-lightbox",
".ldp-user-card-fallback",
".ldp-avatar-viewer"
].join(","), HOST_TOPIC_CARD_SELECTOR = ":is(.topic-list-item,.latest-topic-list-item)";
function domNode(value) {
return value !== null && typeof value == "object" && typeof value.nodeType == "number";
}
class ReaderControlTooltip {
scope;
element;
#document;
#copyText;
#schedule;
#cancelSchedule;
#activeControl = null;
#clickPinnedControl = null;
#touchSuppressedControl = null;
#copyResetTimer = 0;
constructor(options) {
this.#document = options.document, this.#copyText = options.copyText ?? null;
const viewport = this.#document.defaultView;
this.#schedule = options.schedule ?? ((callback, delayMs) => viewport ? viewport.setTimeout(callback, delayMs) : globalThis.setTimeout(callback, delayMs)), this.#cancelSchedule = options.cancelSchedule ?? ((handle) => {
viewport ? viewport.clearTimeout(handle) : globalThis.clearTimeout(handle);
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.element = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-reader-icon-tooltip ldp-transient-surface"
), this.element.role = "tooltip", this.element.hidden = !0, options.surfaceHost.append(this.element);
const interactionRoot = options.surfaceHost.getRootNode(), roots = [interactionRoot];
interactionRoot !== this.#document && roots.push(this.#document);
for (const root of roots) this.#listen(root);
viewport && this.scope.listen(viewport, "resize", () => this.close());
for (const type of [
"ldp-reader-window-change",
"ldp-reader-workspace-change"
])
this.scope.listen(options.surfaceHost, type, () => this.close());
this.scope.add(() => {
this.#clearCopyReset(), this.close(), this.element.remove();
});
}
refresh(control) {
this.#activeControl && !this.#activeControl.isConnected && this.close();
const match = this.#match(control);
if (!match || control.hidden || control === this.#touchSuppressedControl || !this.#keepOpen(control)) {
control === this.#activeControl && this.close();
return;
}
this.#show(match);
}
close() {
!this.#activeControl && this.element.hidden && !this.element.textContent || (this.#activeControl = null, this.#clickPinnedControl = null, this.element.hidden = !0, this.element.textContent = "", this.element.classList.remove(
"ldp-reader-history-tooltip",
"ldp-connect-help-tooltip",
"ldp-reader-time-tooltip"
));
}
destroy() {
this.scope.destroy();
}
#listen(root) {
this.scope.listen(root, "ldp-tooltip-refresh", (event) => {
const control = (0, import_event_target.eventElement)(event);
control && this.refresh(control);
}), this.scope.listen(root, "click", (event) => {
this.#copyNamedTarget(event);
const control = (0, import_event_target.eventElement)(event)?.closest(
TOOLTIP_CONTROL_SELECTOR
) ?? null;
if (control?.matches(TIME_TOOLTIP_SELECTOR)) {
const match = this.#match(control);
match && (this.#touchSuppressedControl = null, this.#show(match, null, !0));
return;
}
this.#clickPinnedControl && this.close(), control && (control === this.#touchSuppressedControl || this.#usesCompactInteraction()) && (this.#touchSuppressedControl = control, this.close());
}, !0), this.scope.listen(root, "pointerover", (event) => {
const pointer = event;
if (pointer.pointerType === "touch") {
this.close();
return;
}
this.#touchSuppressedControl = null;
const match = this.#match((0, import_event_target.eventElement)(event));
!match || domNode(pointer.relatedTarget) && match.control.contains(pointer.relatedTarget) || this.#show(match, pointer);
}), this.scope.listen(root, "pointerdown", (event) => {
if (event.pointerType === "touch") {
this.#touchSuppressedControl = (0, import_event_target.eventElement)(event)?.closest(
TOOLTIP_CONTROL_SELECTOR
) ?? null, this.close();
return;
}
this.#touchSuppressedControl = null, (0, import_event_target.eventElement)(event)?.closest(
".ldp-header[data-ldp-reader-drag-surface]"
) && this.close();
}, !0), this.scope.listen(root, "pointermove", (event) => {
const active = this.#activeControl;
!active?.matches(
".ldp-nested-rail-toggle,.ldp-nested-branch-toggle"
) || !domNode(event.target) || !active.contains(event.target) || this.#position(active, event);
}, { passive: !0 }), this.scope.listen(root, "pointerout", (event) => {
const pointer = event, active = this.#activeControl;
!active || pointer.pointerType === "touch" || active === this.#clickPinnedControl || domNode(pointer.relatedTarget) && active.contains(pointer.relatedTarget) || active.matches(":focus-visible") || this.close();
}), this.scope.listen(root, "focusin", (event) => {
const match = this.#match((0, import_event_target.eventElement)(event));
if (match?.control === this.#touchSuppressedControl) {
this.close();
return;
}
match && this.#show(match);
}), this.scope.listen(root, "focusout", () => {
queueMicrotask(() => {
this.#keepOpen(this.#activeControl) || this.close();
});
}), this.scope.listen(root, "scroll", () => {
const active = this.#activeControl;
if (!active) return;
if (!active.matches(".ldp-reader-history-nav")) {
this.close();
return;
}
const hovered = this.#queryHoveredHistoryControl(root), match = this.#match(hovered);
match ? this.#show(match) : this.close();
}, !0);
}
#queryHoveredHistoryControl(root) {
if (!("querySelector" in root)) return null;
const query = root.querySelector;
return typeof query == "function" ? query.call(root, ".ldp-reader-history-nav:hover") : null;
}
#match(target) {
const control = target?.closest(TOOLTIP_CONTROL_SELECTOR) ?? null;
if (!control || control.hasAttribute("data-ldp-native-dnd")) return null;
const namedHostTopicControl = control.hasAttribute("data-ldp-tooltip-label") && this.#document.documentElement.classList.contains(
"ldp-reader-workspace"
) && !!control.closest(HOST_TOPIC_CARD_SELECTOR);
if (!!!(control.closest(READER_SURFACE_SELECTOR) || control.matches(".ldp-native-reader-trigger") || namedHostTopicControl) || control.hasAttribute("data-tooltip") || control.matches(
'.ldp-settings-tab,.ldp-topic-timeline-track,[data-reaction-picker][aria-expanded="true"]'
)) return null;
const functional = control.matches(
'button,[role="button"],.ldp-nested-branch-toggle'
), timeTarget = control.matches(TIME_TOOLTIP_SELECTOR), iconOnlyLink = control.matches("a") && !!control.querySelector(".ldp-icon,.ldp-logo,img") && !this.#hasVisibleText(control), namedCopyTarget = control.matches(
".ldp-avatar-flair,.ldp-user-card-badge"
), namedTarget = control.hasAttribute("data-ldp-tooltip-label");
if (!functional && !timeTarget && !iconOnlyLink && !namedCopyTarget && !namedTarget)
return null;
const narrowTitle = control.matches(".ldp-title-jump") && (control.closest(".ldp-modal")?.getBoundingClientRect().width ?? 0) <= 480 && control.scrollWidth > control.clientWidth + 1, label = String(
narrowTitle ? control.textContent : timeTarget ? control.dataset.exactTime ?? "" : control.getAttribute("aria-label") ?? control.dataset.ldpTooltipLabel ?? ""
).trim();
return label ? Object.freeze({ control, label }) : null;
}
#hasVisibleText(control) {
const viewport = this.#document.defaultView;
return [...control.childNodes].some((node) => {
if (node.nodeType === 3) return !!node.textContent?.trim();
if (node.nodeType !== 1) return !1;
const child = node;
if (child.matches(".ldp-icon,.ldp-logo,.ldp-notification-unread-badge"))
return !1;
if (viewport?.getComputedStyle) {
const computed = viewport.getComputedStyle(child);
if (computed.display === "none" || computed.visibility === "hidden")
return !1;
}
return !!(child.innerText?.trim() || child.textContent?.trim());
});
}
#show(match, pointer = null, clickPinned = !1) {
match.control.removeAttribute("title"), this.#activeControl = match.control, this.#clickPinnedControl = clickPinned ? match.control : null, this.element.textContent = match.label, this.element.classList.toggle(
"ldp-reader-history-tooltip",
match.control.matches(".ldp-reader-history-nav")
), this.element.classList.toggle(
"ldp-connect-help-tooltip",
match.control.matches(".ldp-connect-metric")
), this.element.classList.toggle(
"ldp-reader-time-tooltip",
match.control.matches(TIME_TOOLTIP_SELECTOR)
), this.element.hidden = !1, this.#position(match.control, pointer);
}
#position(control, pointer) {
const viewport = this.#document.defaultView;
if (!viewport) return;
const rect = control.getBoundingClientRect(), tooltipRect = this.element.getBoundingClientRect(), edge = 8;
if (control.matches(".ldp-nested-rail-toggle,.ldp-nested-branch-toggle") && pointer && Number.isFinite(pointer.clientX) && Number.isFinite(pointer.clientY)) {
let left2 = pointer.clientX + 12;
left2 + tooltipRect.width > viewport.innerWidth - edge && (left2 = pointer.clientX - tooltipRect.width - 12);
let top2 = pointer.clientY + 12;
top2 + tooltipRect.height > viewport.innerHeight - edge && (top2 = pointer.clientY - tooltipRect.height - 12), this.#place(left2, top2, tooltipRect, edge);
return;
}
let left = rect.left + (rect.width - tooltipRect.width) / 2;
left = Math.max(
edge,
Math.min(left, viewport.innerWidth - tooltipRect.width - edge)
);
let top = rect.top - tooltipRect.height - 6;
top < edge && (top = Math.min(
viewport.innerHeight - tooltipRect.height - edge,
rect.bottom + 6
)), this.#place(left, top, tooltipRect, edge);
}
#place(left, top, rect, edge) {
const viewport = this.#document.defaultView;
this.element.style.left = `${Math.round(Math.max(
edge,
Math.min(left, viewport.innerWidth - rect.width - edge)
))}px`, this.element.style.top = `${Math.round(Math.max(
edge,
Math.min(top, viewport.innerHeight - rect.height - edge)
))}px`;
}
#keepOpen(control) {
if (!control) return !1;
if (control === this.#clickPinnedControl) return !0;
try {
return control.matches(":hover") || control.matches(":focus-visible");
} catch {
return !1;
}
}
#usesCompactInteraction() {
const viewport = this.#document.defaultView;
return viewport ? viewport.innerWidth <= 760 || viewport.matchMedia?.("(hover: none) and (pointer: coarse)").matches === !0 : !1;
}
#copyNamedTarget(event) {
if (!this.#copyText) return;
const target = (0, import_event_target.eventElement)(event)?.closest(
".ldp-avatar-flair,.ldp-user-card-badge"
) ?? null;
if (!target || !target.closest(READER_SURFACE_SELECTOR)) return;
const original = String(
target.dataset.ldpTooltipLabel ?? target.getAttribute("aria-label") ?? ""
).trim();
original && (event.preventDefault(), event.stopPropagation(), Promise.resolve(this.#copyText(original)).then(() => this.#showCopyState(target, original, "已复制")).catch(() => this.#showCopyState(target, original, "复制失败")));
}
#showCopyState(target, original, message) {
this.#clearCopyReset(), target.dataset.ldpTooltipLabel = message, target.setAttribute("aria-label", message), this.refresh(target), this.#copyResetTimer = this.#schedule(() => {
this.#copyResetTimer = 0, !(!target.isConnected || target.dataset.ldpTooltipLabel !== message) && (target.dataset.ldpTooltipLabel = original, target.setAttribute("aria-label", original), this.refresh(target));
}, 900);
}
#clearCopyReset() {
this.#copyResetTimer && (this.#cancelSchedule(this.#copyResetTimer), this.#copyResetTimer = 0);
}
}
}, "d792038a86884da7def88414340c1b323e2d2a112ef569ac70797e983dba0598");
/* Source: lite/src/components/reader-icon.ts */
runtime.register("src/components/reader-icon.js", function(module, exports, require) {
var reader_icon_exports = {};
__export(reader_icon_exports, {
createReaderIcon: () => createReaderIcon,
hasReaderIcon: () => hasReaderIcon,
readerIconSvgMarkup: () => readerIconSvgMarkup,
renderReaderIcon: () => renderReaderIcon,
resolveReaderIcon: () => resolveReaderIcon
});
module.exports = __toCommonJS(reader_icon_exports);
const SVG_NAMESPACE = "http://www.w3.org/2000/svg", ICON_PATHS = Object.freeze({
activity: "M3 12h4l2-7 4 14 2-7h6",
"alert-triangle": "M10.3 2.9 1.8 17a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 2.9a2 2 0 0 0-3.4 0ZM12 9v4m0 4h.01",
"arrow-up": "M12 19V5m-7 7 7-7 7 7",
award: "M18 8a6 6 0 1 1-12 0 6 6 0 0 1 12 0Zm-2.5 5L17 22l-5-3-5 3 1.5-9",
bookmark: "m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16Z",
"book-open": "M2 4h6a4 4 0 0 1 4 4v12a4 4 0 0 0-4-4H2ZM22 4h-6a4 4 0 0 0-4 4v12a4 4 0 0 1 4-4h6Z",
bell: "M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4",
"bell-off": "M13.7 21h-3.4M18 8a6 6 0 0 0-9.3-5M6.3 6.3A6 6 0 0 0 6 8c0 7-3 7-3 9h14M3 3l18 18",
check: "m5 12 4 4L19 6",
"chevron-down": "m6 9 6 6 6-6",
"chevron-right": "m9 18 6-6-6-6",
"chevron-up": "m18 15-6-6-6 6",
"circle-x": "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM15 9l-6 6m0-6 6 6",
"circle-help": "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM9.1 9a3 3 0 1 1 5.4 1.8c-.8 1-2.5 1.4-2.5 3.2m0 4h.01",
clock: "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM12 6v6l4 2",
"clock-check": "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0Zm-14 0 3 3 5-6",
"check-square": "M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
"chevron-left": "m15 18-6-6 6-6",
copy: "M9 9h11v11H9zM4 15H3V4h11v1",
code: "m16 18 6-6-6-6M8 6l-6 6 6 6",
download: "M12 3v12m-5-5 5 5 5-5M5 21h14",
droplet: "M12 2.69 5.66 9a9 9 0 1 0 12.68 0Z",
"external-link": "M15 3h6v6m0-6-9 9M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",
"eye-off": "M3 3l18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.9 4.2A10.5 10.5 0 0 1 21 12a12 12 0 0 1-2.1 3M6.6 6.6A12 12 0 0 0 3 12a10.5 10.5 0 0 0 9 5.2",
info: "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM12 11v6m0-10h.01",
heart: "M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z",
hand: "M18 11V6a2 2 0 0 0-4 0v5M14 10V4a2 2 0 0 0-4 0v6M10 9.5V6a2 2 0 0 0-4 0v8M6 14v-2a2 2 0 0 0-4 0v2a8 8 0 0 0 8 8h2c5.5 0 10-4.5 10-10V8a2 2 0 0 0-4 0v3",
link: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
languages: "M5 8l6 6M4 14l6-6 2-3M2 5h12M7 2h1m14 20-5-10-5 10M14 18h6",
layers: "m12 2 9 5-9 5-9-5 9-5Zm-9 10 9 5 9-5M3 17l9 5 9-5",
lightbulb: "M9 18h6m-5 4h4m4-10a6 6 0 1 0-10 5c.7.5 1 1.3 1 2h6c0-1 .3-1.5 1-2a6 6 0 0 0 2-5Z",
list: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
"list-checks": "m3 6 2 2 4-4M3 12l2 2 4-4M3 18l2 2 4-4M13 6h8M13 12h8M13 18h8",
loader: "M21 12a9 9 0 1 1-6.219-8.56",
"maximize-2": "M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7",
"minimize-2": "M4 14h6v6M10 14l-7 7M20 10h-6V4m0 6 7-7",
minus: "M5 12h14",
maximize: "M8 3H3v5m18 0V3h-5M3 16v5h5m8 0h5v-5",
"message-square": "M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z",
pencil: "m12 20 9-9-4-4-9 9-1 5 5-1ZM15 9l4 4",
plus: "M12 5v14M5 12h14",
"rotate-ccw": "M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5",
reply: "m9 17-5-5 5-5M20 18v-2a4 4 0 0 0-4-4H4",
rocket: "M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09ZM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2ZM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5",
settings: "M9.7 4.1a2.34 2.34 0 0 1 4.6 0 2.34 2.34 0 0 0 3.3 1.9 2.34 2.34 0 0 1 2.4 4.1 2.34 2.34 0 0 0 0 3.8 2.34 2.34 0 0 1-2.4 4.1 2.34 2.34 0 0 0-3.3 1.9 2.34 2.34 0 0 1-4.6 0A2.34 2.34 0 0 0 6.4 18 2.34 2.34 0 0 1 4 13.9a2.34 2.34 0 0 0 0-3.8A2.34 2.34 0 0 1 6.4 6a2.34 2.34 0 0 0 3.3-1.9ZM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z",
search: "M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-2.3 5.7L21 21",
shield: "M12 2 21 5v6c0 5.7-3.7 9.4-9 11-5.3-1.6-9-5.3-9-11V5l9-3Zm0 3L6 7v4c0 3.9 2.2 6.5 6 8 3.8-1.5 6-4.1 6-8V7l-6-2Z",
sparkles: "M12 3l1.2 3.8L17 8l-3.8 1.2L12 13l-1.2-3.8L7 8l3.8-1.2L12 3ZM19 14l.8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14ZM5 14l.8 2.2L8 17l-2.2.8L5 20l-.8-2.2L2 17l2.2-.8L5 14Z",
square: "M3 3h18v18H3z",
"user-plus": "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm10-3v6m3-3h-6",
upload: "M12 15V3m-5 5 5-5 5 5M5 21h14",
x: "M18 6 6 18M6 6l12 12"
}), ICON_MARKUP = Object.freeze({
at: '<path d="M12 2a10 10 0 1 0 5.8 18.2l-1.3-1.7A7.8 7.8 0 1 1 19.8 12v1.2c0 1.2-.5 1.8-1.4 1.8-.8 0-1.3-.5-1.3-1.5V8h-2v1A5 5 0 1 0 16 16c.7.8 1.6 1.2 2.7 1.2 2.1 0 3.3-1.5 3.3-4V12c0-5.5-4.5-10-10-10Zm0 12.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5Z" fill="currentColor" stroke="none" fill-rule="evenodd"/>',
boost: '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2Z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
"calendar-clock": '<rect x="3" y="4" width="18" height="17" rx="2"/><path d="M8 2v4M16 2v4M3 9h18"/><circle cx="14.5" cy="15" r="3.5"/><path d="M14.5 13v2.2l1.5 1"/>',
database: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1Z"/><path d="M4 22v-7"/>',
"followed-topic": '<circle cx="7" cy="8" r="3"/><path d="M2 21v-1a5 5 0 0 1 10 0v1M13 4h8v7h-4l-4 3V4Z"/><path d="M15.5 7.5h3M17 6v3"/>',
"floating-window": '<rect x="4" y="5" width="16" height="14" rx="2"/><path d="M4 9h16M7 7h.01M10 7h.01"/>',
flask: '<path d="M9 3h6M10 3v6l-5 9a2 2 0 0 0 1.7 3h10.6a2 2 0 0 0 1.7-3l-5-9V3M7.5 14h9"/>',
"git-branch": '<path d="M6 3v12"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
"header-settings": '<path d="M4 6h5M13 6h7"/><circle cx="11" cy="6" r="2"/><path d="M4 12h10M18 12h2"/><circle cx="16" cy="12" r="2"/><path d="M4 18h2M10 18h10"/><circle cx="8" cy="18" r="2"/>',
history: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
"layout-grid": '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16M3 12h18"/>',
lock: '<rect width="14" height="10" x="5" y="11" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
"menu-box": '<rect x="3" y="3" width="18" height="18" rx="3"/><path d="M3 12h18M9 8h6M9 16h6"/>',
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>',
moon: '<path d="M20.99 12.8A9 9 0 1 1 11.2 3.01 7 7 0 0 0 20.99 12.8Z"/>',
"panel-left": '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>',
"panel-right": '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/>',
palette: '<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" stroke="none"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor" stroke="none"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor" stroke="none"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor" stroke="none"/><path d="M12 2a10 10 0 0 0 0 20c1.1 0 2-.9 2-2 0-.5-.2-1-.6-1.4-.4-.4-.6-.9-.6-1.4a2 2 0 0 1 2-2H17a5 5 0 0 0 5-5C22 5.7 17.5 2 12 2Z"/>',
pin: '<path d="M12 17v5M5 17h14m-13-14 1 7-3 3h16l-3-3 1-7Z"/>',
"post-approved": '<path d="M6 2h8l4 4v16H6V2Z"/><path d="M14 2v6h4M9 15l2 2 4-4"/>',
share: '<path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7"/><path d="m16 6-4-4-4 4M12 2v13"/>',
"select-items": '<rect x="7" y="7" width="14" height="14" rx="3"/><path d="M3 15V6a3 3 0 0 1 3-3h9"/>',
"select-items-check": '<rect x="7" y="7" width="14" height="14" rx="3"/><path d="M3 15V6a3 3 0 0 1 3-3h9M10.5 14l2 2 4-4"/>',
"shield-halved": '<path d="M12 2 4 5v6c0 5 3.3 9.4 8 11 4.7-1.6 8-6 8-11V5l-8-3Z"/><path d="M12 2 4 5v6c0 5 3.3 9.4 8 11V2Z" fill="currentColor" stroke="none"/>',
smile: '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/>',
"solution-badge": '<circle cx="12" cy="10" r="7"/><path d="m9 10 2 2 4-4M8.5 16.5 7 22l5-3 5 3-1.5-5.5"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor" stroke="none"/>',
trash: '<path d="M3 6h18M8 6V4h8v2m3 0-1 14H6L5 6M10 11v5M14 11v5"/>',
"trash-2": '<path d="M4 6h16M9 6V4h6v2"/><path d="m7 6 .8 14h8.4L17 6M10 10v6M14 10v6"/>',
type: '<path d="M4 7V4h16v3M9 20h6M12 4v16"/>',
unlock: '<rect width="14" height="10" x="5" y="11" rx="2"/><path d="M8 11V7a4 4 0 0 1 7.9-1"/>',
"user-round": '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
wrench: '<path d="M14.7 6.3a4 4 0 0 0-5-5L7.4 3.6l3 3-3.8 3.8-3-3-2.3 2.3a4 4 0 0 0 5 5L15.6 24l4-4-8.7-9.3 3.8-4.4Z"/>'
}), ICON_TEMPLATES = /* @__PURE__ */ new WeakMap();
function hasReaderIcon(name) {
return !!(ICON_PATHS[name] || ICON_MARKUP[name]);
}
function readerIconSvgMarkup(name) {
const pathData = ICON_PATHS[name], markup = ICON_MARKUP[name];
if (!pathData && !markup) throw new Error(`未知 Reader 图标:${name}`);
const content = pathData ? `<path d="${pathData}"></path>` : markup;
return `<svg class="ldp-icon ldp-icon-${name}" data-icon="${name}" data-ldp-reader-icon="" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${content}</svg>`;
}
function selfContainedNativeIcon(node) {
if (node.nodeType !== 1) return !0;
const element = node;
return element.querySelector("use") ? element.querySelector(
"path,circle,ellipse,line,polyline,polygon,rect,g"
) !== null : !0;
}
function createReaderIcon(document, name, extraClass = "") {
const pathData = ICON_PATHS[name], markup = ICON_MARKUP[name];
if (!pathData && !markup) throw new Error(`未知 Reader 图标:${name}`);
let templates = ICON_TEMPLATES.get(document);
templates || (templates = /* @__PURE__ */ new Map(), ICON_TEMPLATES.set(document, templates));
let template = templates.get(name);
if (!template) {
if (template = document.createElementNS(
SVG_NAMESPACE,
"svg"
), template.classList.add("ldp-icon"), template.classList.add(`ldp-icon-${name}`), template.dataset.icon = name, template.dataset.ldpReaderIcon = "", template.setAttribute("viewBox", "0 0 24 24"), template.setAttribute("aria-hidden", "true"), template.setAttribute("focusable", "false"), pathData) {
const path = document.createElementNS(SVG_NAMESPACE, "path");
path.setAttribute("d", pathData), template.append(path);
} else
template.innerHTML = markup;
templates.set(name, template);
}
const svg = template.cloneNode(!0);
for (const className of extraClass.split(/\s+/).filter(Boolean))
svg.classList.add(className);
return svg;
}
function resolveReaderIcon(document, name, nativeIcon = null) {
if (hasReaderIcon(name)) return createReaderIcon(document, name);
if (nativeIcon && selfContainedNativeIcon(nativeIcon)) return nativeIcon;
const fallback = createReaderIcon(document, "circle-help");
return fallback.dataset.readerIconFallbackFor = name, fallback;
}
function renderReaderIcon(document, name, renderer) {
let rendered = null;
try {
rendered = renderer?.(name, document) ?? null;
} catch {
}
return resolveReaderIcon(document, name, rendered);
}
}, "493997557cd9e65f0e6a5e571fc949b187d4d59342ef97cf43ae7019cc7b9a77");
/* Source: lite/src/components/reader-image-fallback.ts */
runtime.register("src/components/reader-image-fallback.js", function(module, exports, require) {
var reader_image_fallback_exports = {};
__export(reader_image_fallback_exports, {
installReaderImageSourceFallback: () => installReaderImageSourceFallback,
installReaderSiteLogoFallback: () => installReaderSiteLogoFallback,
replaceImageWithFallbackOnError: () => replaceImageWithFallbackOnError
});
module.exports = __toCommonJS(reader_image_fallback_exports);
function replaceImageWithFallbackOnError(image, createFallback) {
image.addEventListener("error", () => {
image.parentNode && image.replaceWith(createFallback());
}, { once: !0 });
}
const readerImageRecoveryRegistries = /* @__PURE__ */ new WeakMap();
function registerReaderImageRecovery(node, registration) {
const root = node.getRootNode(), Observer = node.ownerDocument.defaultView?.MutationObserver;
if (!Observer) return () => {
};
let registry = readerImageRecoveryRegistries.get(root);
if (!registry) {
const entries = /* @__PURE__ */ new Set(), observer = new Observer(() => {
for (const entry of [...entries])
entry.currentNode()?.isConnected || entry.abort();
});
observer.observe(root, { childList: !0, subtree: !0 }), registry = { entries, observer }, readerImageRecoveryRegistries.set(root, registry);
}
registry.entries.add(registration);
let released = !1;
return () => {
released || (released = !0, registry?.entries.delete(registration), !registry?.entries.size && (registry?.observer.disconnect(), readerImageRecoveryRegistries.get(root) === registry && readerImageRecoveryRegistries.delete(root)));
};
}
function installReaderImageSourceFallback(image, sources, createFallback, recoverSource, visibleSource) {
const imageRef = new WeakRef(image), candidates = [...new Set(
sources.map((source) => String(source).trim()).filter(Boolean)
)];
let directIndex = 0, recoveryIndex = 0, fallbackRef = null, recovering = !1, activeRecovery = null;
const currentFallback = () => fallbackRef?.deref() ?? null, currentNode = () => {
const fallback = currentFallback();
if (fallback?.isConnected) return fallback;
const currentImage = imageRef.deref();
return currentImage?.isConnected ? currentImage : null;
}, showFallback = () => {
const fallback = currentFallback();
if (fallback?.parentNode) return fallback;
const next = createFallback(), currentImage = imageRef.deref();
return currentImage?.parentNode && currentImage.replaceWith(next), fallbackRef = new WeakRef(next), next.parentNode ? next : null;
}, recoverNext = async () => {
if (recovering) return;
recovering = !0;
const controller = new AbortController();
activeRecovery = controller;
let releaseLifecycle = () => {
};
Promise.resolve().then(() => {
if (!recovering || activeRecovery !== controller || controller.signal.aborted)
return;
const mounted = currentNode();
if (!mounted) {
controller.abort(new Error("图片恢复宿主已离开文档"));
return;
}
releaseLifecycle = registerReaderImageRecovery(mounted, {
currentNode,
abort: () => controller.abort(new Error("图片恢复宿主已离开文档"))
});
});
try {
for (; recoverSource && recoveryIndex < candidates.length; ) {
const candidate = candidates[recoveryIndex];
recoveryIndex += 1;
try {
const recovered = String(
await recoverSource(candidate, controller.signal)
).trim();
if (controller.signal.aborted) return;
const currentImage2 = imageRef.deref(), fallback2 = currentFallback();
if (!fallback2?.parentNode && !currentImage2?.parentNode) return;
if (!recovered) continue;
if (fallback2?.parentNode && currentImage2 && (fallback2.replaceWith(currentImage2), fallbackRef = null), !currentImage2) return;
currentImage2.src = recovered;
return;
} catch {
if (controller.signal.aborted) return;
}
}
const fallback = currentFallback(), currentImage = imageRef.deref();
fallback?.parentNode && currentImage && currentImage.removeEventListener("error", advance);
} finally {
releaseLifecycle(), activeRecovery === controller && (activeRecovery = null), recovering = !1;
}
};
function advance() {
if (recoverSource) {
if (!showFallback()) return;
recoverNext();
return;
}
const direct = candidates[directIndex];
directIndex += 1;
const currentImage = imageRef.deref();
if (currentImage) {
if (direct) {
currentImage.src = direct;
return;
}
currentImage.removeEventListener("error", advance), showFallback();
}
}
if (image.addEventListener("error", advance), recoverSource) {
const visible = String(visibleSource ?? "").trim();
if (visible) image.src = visible;
else if (!showFallback()) return;
recoverNext();
} else
advance();
}
const READER_SITE_LOGO_PLACEHOLDER = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#e9eef3"/><path d="M18 33a14 14 0 1 1 28 0v13H18V33Z" fill="#748392"/><circle cx="27" cy="31" r="3" fill="#fff"/><circle cx="37" cy="31" r="3" fill="#fff"/></svg>'
)}`;
function siteFaviconSource(image, primarySource) {
const documentOrigin = String(
image.ownerDocument.location?.origin ?? ""
).trim();
for (const base of [documentOrigin, primarySource])
if (base)
try {
const url = new URL("/favicon.ico", base);
if (url.protocol === "https:" || url.protocol === "http:")
return url.href;
} catch {
}
return "";
}
function installReaderSiteLogoFallback(image, primarySource) {
const primary = String(primarySource).trim(), sources = [...new Set([
primary,
siteFaviconSource(image, primary),
READER_SITE_LOGO_PLACEHOLDER
].filter(Boolean))];
let index = 0;
const advance = () => {
index += 1;
const next = sources[index];
if (!next) {
image.removeEventListener("error", advance);
return;
}
image.src = next;
};
image.addEventListener("error", advance), image.src = sources[0] ?? READER_SITE_LOGO_PLACEHOLDER;
}
}, "c6156c9f1e5479ef3274e60147f327c53d73acf2b874bbd1557f675ea6264ff2");
/* Source: lite/src/components/reader-inline-emoji.ts */
runtime.register("src/components/reader-inline-emoji.js", function(module, exports, require) {
var reader_inline_emoji_exports = {};
__export(reader_inline_emoji_exports, {
clearReaderInlineEmoji: () => clearReaderInlineEmoji,
renderReaderInlineEmoji: () => renderReaderInlineEmoji
});
module.exports = __toCommonJS(reader_inline_emoji_exports);
const INLINE_EMOJI_ATTRIBUTE = "data-ldp-inline-emoji", INLINE_EMOJI_SIGNATURE_ATTRIBUTE = "data-ldp-inline-emoji-signature", SHORTCODE = /:([a-z0-9_+\-]+):/giu;
function emojiSource(source, id) {
try {
return String(source(id) ?? "").trim();
} catch {
return "";
}
}
function renderReaderInlineEmoji(target, value, source) {
const text = String(value ?? ""), matches = [...text.matchAll(SHORTCODE)];
if (!matches.length)
return (target.textContent !== text || target.hasAttribute(INLINE_EMOJI_SIGNATURE_ATTRIBUTE)) && (target.textContent = text), target.removeAttribute(INLINE_EMOJI_SIGNATURE_ATTRIBUTE), Object.freeze({ rendered: 0, unresolved: 0 });
const resolved = matches.map((match) => Object.freeze({
raw: match[0],
id: match[1] ?? "",
index: match.index ?? 0,
source: emojiSource(source, match[1] ?? "")
})), signature = JSON.stringify([
text,
...resolved.map((entry) => entry.source)
]), rendered = resolved.filter((entry) => !!entry.source).length, unresolved = resolved.length - rendered;
if (target.getAttribute(INLINE_EMOJI_SIGNATURE_ATTRIBUTE) === signature && target.querySelectorAll(`:scope > img[${INLINE_EMOJI_ATTRIBUTE}]`).length === rendered) return Object.freeze({ rendered, unresolved });
const document = target.ownerDocument, fragment = document.createDocumentFragment();
let cursor = 0;
for (const entry of resolved) {
if (entry.index > cursor && fragment.append(document.createTextNode(
text.slice(cursor, entry.index)
)), !entry.source)
fragment.append(document.createTextNode(entry.raw));
else {
const image = document.createElement("img");
image.className = "emoji", image.setAttribute(INLINE_EMOJI_ATTRIBUTE, entry.id), image.src = entry.source, image.alt = entry.raw, image.loading = "lazy", image.decoding = "async", image.addEventListener("error", () => {
target.removeAttribute(INLINE_EMOJI_SIGNATURE_ATTRIBUTE), image.replaceWith(document.createTextNode(entry.raw));
}, { once: !0 }), fragment.append(image);
}
cursor = entry.index + entry.raw.length;
}
return cursor < text.length && fragment.append(document.createTextNode(text.slice(cursor))), target.replaceChildren(fragment), target.setAttribute(INLINE_EMOJI_SIGNATURE_ATTRIBUTE, signature), Object.freeze({ rendered, unresolved });
}
function clearReaderInlineEmoji(target) {
if (!target.hasAttribute(INLINE_EMOJI_SIGNATURE_ATTRIBUTE)) return !1;
const text = [...target.childNodes].map((child) => child.nodeType === 1 && child.matches(`img[${INLINE_EMOJI_ATTRIBUTE}]`) ? child.alt : child.textContent ?? "").join("");
return target.textContent = text, target.removeAttribute(INLINE_EMOJI_SIGNATURE_ATTRIBUTE), !0;
}
}, "33a95f0994cd76d822f37945e66d4710b69fc8e9ba8dbf975a822bab9b3e7a60");
/* Source: lite/src/dom/event-target.ts */
runtime.register("src/dom/event-target.js", function(module, exports, require) {
var event_target_exports = {};
__export(event_target_exports, {
deepActiveElement: () => deepActiveElement,
eventElement: () => eventElement,
eventPath: () => eventPath,
eventPathIncludes: () => eventPathIncludes,
usesNativeLinkNavigation: () => usesNativeLinkNavigation
});
module.exports = __toCommonJS(event_target_exports);
function eventPath(event) {
try {
const path = event.composedPath?.();
if (path?.length) return path;
} catch {
}
return event.target ? Object.freeze([event.target]) : Object.freeze([]);
}
function eventElement(event) {
for (const target of eventPath(event))
if (target !== null && typeof target == "object" && target.nodeType === 1) return target;
return null;
}
function eventPathIncludes(event, node) {
if (!node) return !1;
if (eventPath(event).includes(node)) return !0;
const target = event.target;
return target !== null && typeof target == "object" && typeof target.nodeType == "number" && node.contains(target);
}
function usesNativeLinkNavigation(event) {
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
}
function deepActiveElement(document) {
let active = document.activeElement;
for (; active?.shadowRoot?.activeElement; )
active = active.shadowRoot.activeElement;
return active;
}
}, "7e486dde4a1615d7509486f91752765d59762d7ee30e373d3a730e51ec3f997d");
/* Source: lite/src/dom/floating-surface-wheel.ts */
runtime.register("src/dom/floating-surface-wheel.js", function(module, exports, require) {
var floating_surface_wheel_exports = {};
__export(floating_surface_wheel_exports, {
bindFloatingSurfaceWheel: () => bindFloatingSurfaceWheel,
containFloatingSurfaceWheel: () => containFloatingSurfaceWheel
});
module.exports = __toCommonJS(floating_surface_wheel_exports);
var import_event_target = require("./event-target.js");
const DOCUMENT_WHEEL_OPTIONS = {
capture: !0,
passive: !1
}, boundFloatingSurfaces = /* @__PURE__ */ new WeakMap();
function defaultStyle(element) {
const view = element.ownerDocument.defaultView;
return typeof view?.getComputedStyle == "function" ? view.getComputedStyle(element) : { overflowX: "", overflowY: "" };
}
function wheelDelta(event, surface) {
return event.deltaMode === 1 ? { x: event.deltaX * 40, y: event.deltaY * 40 } : event.deltaMode === 2 ? {
x: event.deltaX * surface.clientWidth,
y: event.deltaY * surface.clientHeight
} : { x: event.deltaX, y: event.deltaY };
}
function containFloatingSurfaceWheel(surface, event, environment = {}) {
event.stopPropagation();
const target = (0, import_event_target.eventElement)(event), { x: deltaX, y: deltaY } = wheelDelta(event, surface);
if (!target || !surface.contains(target)) {
(deltaX || deltaY) && event.preventDefault();
return;
}
const style = environment.style ?? defaultStyle;
let scrollTarget = target;
for (; scrollTarget && surface.contains(scrollTarget); ) {
const computed = style(scrollTarget), maxScrollLeft = scrollTarget.scrollWidth - scrollTarget.clientWidth, maxScrollTop = scrollTarget.scrollHeight - scrollTarget.clientHeight, canScrollX = !!(deltaX && maxScrollLeft > 1 && /(auto|scroll|overlay)/.test(computed.overflowX) && (deltaX < 0 ? scrollTarget.scrollLeft > 0 : scrollTarget.scrollLeft < maxScrollLeft - 1)), canScrollY = !!(deltaY && maxScrollTop > 1 && /(auto|scroll|overlay)/.test(computed.overflowY) && (deltaY < 0 ? scrollTarget.scrollTop > 0 : scrollTarget.scrollTop < maxScrollTop - 1));
if (canScrollX || canScrollY) return;
if (scrollTarget === surface) break;
scrollTarget = scrollTarget.parentElement;
}
(deltaX || deltaY) && event.preventDefault();
}
function floatingSurfaceAtWheelPoint(binding, event) {
const x = Number(event.clientX), y = Number(event.clientY);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
const candidates = [...binding.surfaces].filter(([surface, count]) => count.latchedTarget > 0 && !surface.hidden && surface.isConnected).reverse(), hits = /* @__PURE__ */ new Map();
let canHitTest = !1;
for (const [surface] of candidates) {
const root = surface.getRootNode(), tester = typeof root.elementFromPoint == "function" ? root.elementFromPoint.bind(root) : typeof binding.document.elementFromPoint == "function" ? binding.document.elementFromPoint.bind(binding.document) : null;
if (!tester) continue;
canHitTest = !0;
let hit = hits.get(root);
if (hits.has(root) || (hit = tester(x, y), hits.set(root, hit ?? null)), hit && (hit === surface || surface.contains(hit))) return surface;
}
if (canHitTest) return null;
for (const [surface] of candidates) {
const rect = surface.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0 && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return surface;
}
return null;
}
function installDocumentBinding(document) {
let binding;
const onWheel = (eventValue) => {
const event = eventValue;
for (const surface2 of binding.surfaces.keys())
if ((0, import_event_target.eventPathIncludes)(event, surface2)) return;
const surface = floatingSurfaceAtWheelPoint(binding, event);
surface && (containFloatingSurfaceWheel(surface, event), event.stopImmediatePropagation());
};
return binding = {
document,
surfaces: /* @__PURE__ */ new Map(),
onWheel
}, document.addEventListener("wheel", onWheel, DOCUMENT_WHEEL_OPTIONS), boundFloatingSurfaces.set(document, binding), binding;
}
function bindFloatingSurfaceWheel(surface, options = {}) {
const surfaceOptions = {
capture: options.capture === !0,
passive: !1
}, onSurfaceWheel = (event) => {
containFloatingSurfaceWheel(surface, event);
};
surface.addEventListener("wheel", onSurfaceWheel, surfaceOptions);
const document = surface.ownerDocument, binding = boundFloatingSurfaces.get(document) ?? installDocumentBinding(document), count = binding.surfaces.get(surface) ?? {
total: 0,
latchedTarget: 0
};
count.total += 1, options.captureLatchedTarget !== !1 && (count.latchedTarget += 1), binding.surfaces.set(surface, count);
let active = !0;
return () => {
if (!active) return;
active = !1, surface.removeEventListener("wheel", onSurfaceWheel, surfaceOptions);
const current = binding.surfaces.get(surface);
current && (current.total -= 1, options.captureLatchedTarget !== !1 && (current.latchedTarget -= 1), current.total <= 0 && binding.surfaces.delete(surface)), binding.surfaces.size || (document.removeEventListener(
"wheel",
binding.onWheel,
DOCUMENT_WHEEL_OPTIONS
), boundFloatingSurfaces.delete(document));
};
}
}, "cb4847a740a6e3124810384f7963c536d57456e9834e71bfdde2e590260fca8d");
/* Source: lite/src/dom/html-element.ts */
runtime.register("src/dom/html-element.js", function(module, exports, require) {
var html_element_exports = {};
__export(html_element_exports, {
htmlElement: () => htmlElement
});
module.exports = __toCommonJS(html_element_exports);
function htmlElement(document, tagName, className = "", textContent) {
const node = document.createElement(tagName);
return node.className = className, textContent !== void 0 && (node.textContent = textContent), node;
}
}, "67cadf2903ac7d3c55b0e65cd9828d3b33fcc50916b623b29b38b675f72b3d09");
/* Source: lite/src/dom/post-view.ts */
runtime.register("src/dom/post-view.js", function(module, exports, require) {
var post_view_exports = {};
__export(post_view_exports, {
PostView: () => PostView
});
module.exports = __toCommonJS(post_view_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_identifiers = require("../discourse/identifiers.js"), import_html_element = require("./html-element.js");
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
class PostView {
identity;
scope;
slots;
#unbindActionManifest = null;
constructor(document, identity, parentScope) {
const postId = (0, import_identifiers.discoursePostId)(identity.postId), postNumber = (0, import_identifiers.discoursePostNumber)(identity.postNumber);
this.identity = Object.freeze({ ...identity, postId, postNumber }), this.scope = import_lifecycle.LifecycleScope.ownedBy(parentScope);
const root = (0, import_html_element.htmlElement)(document, "article", "ldp-post");
root.dataset.postId = String(postId), root.dataset.postNumber = String(postNumber), root.dataset.username = identity.username, identity.createdAt && (root.dataset.createdAt = identity.createdAt);
const header = (0, import_html_element.htmlElement)(document, "header", "ldp-post-head"), body = (0, import_html_element.htmlElement)(document, "div", "ldp-post-body"), content = (0, import_html_element.htmlElement)(document, "div", "ldp-content cooked"), bodyLayer = (0, import_html_element.htmlElement)(document, "div", "ldp-post-body-layer"), boost = (0, import_html_element.htmlElement)(document, "div", "ldp-boost-list");
boost.hidden = !0;
const actions = (0, import_html_element.htmlElement)(document, "div", "ldp-reactions ldp-post-actions"), topicFooter = (0, import_html_element.htmlElement)(document, "footer", "ldp-topic-footer-slot");
topicFooter.hidden = !0, body.append(content, bodyLayer, boost, actions, topicFooter);
const replyTree = (0, import_html_element.htmlElement)(document, "section", "ldp-children ldp-reply-tree"), branchOverlay = document.createElementNS(SVG_NAMESPACE, "svg");
branchOverlay.classList.add("ldp-branch-overlay"), branchOverlay.setAttribute("hidden", ""), branchOverlay.setAttribute("aria-hidden", "true"), branchOverlay.setAttribute("focusable", "false");
const visiblePath = document.createElementNS(SVG_NAMESPACE, "path");
visiblePath.classList.add("ldp-branch-visible-path");
const hitPath = document.createElementNS(SVG_NAMESPACE, "path");
hitPath.classList.add("ldp-branch-hit-path"), branchOverlay.append(visiblePath, hitPath);
const replyControls = (0, import_html_element.htmlElement)(document, "div", "ldp-reply-controls ldp-sub-actions"), replyList = (0, import_html_element.htmlElement)(document, "div", "ldp-reply-list");
replyTree.append(branchOverlay, replyList, replyControls), root.append(header, body, replyTree), this.slots = Object.freeze({
root,
header,
body,
content,
bodyLayer,
boost,
actions,
topicFooter,
replyTree,
branchOverlay,
replyControls,
replyList
});
}
get postNumber() {
return this.identity.postNumber;
}
setTreePosition(parentPostNumber, depth) {
if (parentPostNumber !== null && (0, import_identifiers.discoursePostNumber)(parentPostNumber), depth !== void 0 && (!Number.isSafeInteger(depth) || depth < 0))
throw new RangeError("depth 必须是非负安全整数或 undefined");
const parentValue = parentPostNumber === null ? void 0 : String(parentPostNumber);
parentValue === void 0 ? this.slots.root.dataset.parentPostNumber !== void 0 && delete this.slots.root.dataset.parentPostNumber : this.slots.root.dataset.parentPostNumber !== parentValue && (this.slots.root.dataset.parentPostNumber = parentValue);
const depthValue = depth === void 0 ? void 0 : String(depth);
depthValue === void 0 ? this.slots.root.dataset.ldpNestDepth !== void 0 && delete this.slots.root.dataset.ldpNestDepth : this.slots.root.dataset.ldpNestDepth !== depthValue && (this.slots.root.dataset.ldpNestDepth = depthValue);
const nested = depth !== void 0 && depth > 0;
this.slots.root.classList.contains("ldp-nested-preview") !== nested && this.slots.root.classList.toggle("ldp-nested-preview", nested), nested && this.slots.root.classList.contains("ldp-zebra-alt") && this.slots.root.classList.remove("ldp-zebra-alt");
}
/**
* 将普通、嵌套、实时新增和回屏楼层接到同一份动作状态。
*
* PostView 只提供命名槽位与生命周期;具体按钮 DOM 仍由 renderer 组件拥有。
*/
bindActionManifest(source, renderer) {
this.#unbindActionManifest?.(), renderer(this.slots, source.snapshot());
const unsubscribe = source.subscribe((snapshot) => {
renderer(this.slots, snapshot);
}, this.scope);
let active = !0;
const cleanup = () => {
active && (active = !1, unsubscribe(), this.#unbindActionManifest === cleanup && (this.#unbindActionManifest = null));
};
return this.#unbindActionManifest = cleanup, this.scope.add(cleanup), cleanup;
}
destroy() {
try {
this.scope.destroy();
} finally {
this.slots.root.remove();
}
}
}
}, "8687ab8a5ffb375a62325deed0d15b34a1366e58f9157f99a6e9ba24c1a30edf");
/* Source: lite/src/dom/reply-tree-dom-owner.ts */
runtime.register("src/dom/reply-tree-dom-owner.js", function(module, exports, require) {
var reply_tree_dom_owner_exports = {};
__export(reply_tree_dom_owner_exports, {
ReplyTreeDomOwner: () => ReplyTreeDomOwner
});
module.exports = __toCommonJS(reply_tree_dom_owner_exports);
function directPostNumber(element) {
const raw = element.getAttribute("data-post-number");
if (raw === null) return null;
const postNumber = Number(raw);
return Number.isSafeInteger(postNumber) ? postNumber : null;
}
function insertPostInOrder(container, view) {
const root = view.slots.root;
if (root.parentElement === container) {
let previousPostNumber = null;
for (let previous = root.previousElementSibling; previous && (previousPostNumber = directPostNumber(previous), previousPostNumber === null); previous = previous.previousElementSibling)
;
let nextPostNumber = null;
for (let next2 = root.nextElementSibling; next2 && (nextPostNumber = directPostNumber(next2), nextPostNumber === null); next2 = next2.nextElementSibling)
;
if ((previousPostNumber === null || previousPostNumber < view.postNumber) && (nextPostNumber === null || nextPostNumber > view.postNumber)) return;
}
const next = Array.from(container.children).find((candidate) => {
const postNumber = directPostNumber(candidate);
return postNumber !== null && postNumber > view.postNumber;
});
container.insertBefore(root, next ?? null);
}
function virtualSpacer(document, blockSize) {
const spacer = document.createElement("div");
return spacer.className = "ldp-tree-virtual-spacer", spacer.setAttribute("aria-hidden", "true"), spacer.style.blockSize = `${Math.max(0, blockSize)}px`, spacer;
}
function removeVirtualSpacers(container) {
for (const child of Array.from(container.children))
child.classList.contains("ldp-tree-virtual-spacer") && child.remove();
}
function childLayoutKey(layout) {
return `${layout.postNumbers.join(".")}@${layout.beforeSizes.map((size) => size.toFixed(2)).join(".")}@` + layout.afterSize.toFixed(2);
}
class ReplyTreeDomOwner {
topology;
rootList;
#views = /* @__PURE__ */ new Map();
#viewRevision = 0;
#lastSyncKey = "";
#lastCommit = null;
#childLayoutKeys = /* @__PURE__ */ new Map();
constructor(topology, rootList) {
this.topology = topology, this.rootList = rootList;
}
register(view, sync = !0) {
const existing = this.#views.get(view.postNumber);
if (existing && existing !== view)
throw new Error(`楼层 #${view.postNumber} 已由另一个 PostView 持有`);
this.#views.set(view.postNumber, view);
const parentPostNumber = this.topology.parentOf(view.postNumber);
parentPostNumber != null && this.#childLayoutKeys.delete(parentPostNumber), this.#viewRevision += 1, this.#lastSyncKey = "", sync && this.sync();
}
view(postNumber) {
return this.#views.get(postNumber);
}
views() {
return Object.freeze([...this.#views.values()]);
}
willSyncChange(mountedRootPostNumbers, mountPlan) {
return !this.#lastCommit || this.#syncKey(mountedRootPostNumbers, mountPlan) !== this.#lastSyncKey;
}
unregister(postNumber, destroy = !0, resync = !0) {
const view = this.#views.get(postNumber);
if (!view) return;
this.#views.delete(postNumber), this.#childLayoutKeys.delete(postNumber);
const parentPostNumber = this.topology.parentOf(postNumber);
return parentPostNumber != null && this.#childLayoutKeys.delete(parentPostNumber), this.#viewRevision += 1, this.#lastSyncKey = "", destroy ? view.destroy() : view.slots.root.remove(), resync && this.sync(), view;
}
sync(mountedRootPostNumbers, mountPlan) {
const syncKey = this.#syncKey(mountedRootPostNumbers, mountPlan);
if (this.#lastCommit && syncKey === this.#lastSyncKey)
return Object.freeze({ ...this.#lastCommit, changed: !1 });
const mountedRoots = [], mountedReplies = [], parked = [], missingParents = [], viewEntries = [...this.#views.values()].map((view) => ({
view,
parentPostNumber: this.topology.parentOf(view.postNumber),
depth: this.topology.depthOf(view.postNumber),
rootPostNumber: this.topology.rootOf(view.postNumber)
})).sort(
(left, right) => (left.depth ?? Number.MAX_SAFE_INTEGER) - (right.depth ?? Number.MAX_SAFE_INTEGER) || left.view.postNumber - right.view.postNumber
);
for (const {
view,
parentPostNumber,
depth,
rootPostNumber
} of viewEntries) {
const selectedByNodeWindow = !mountPlan || mountPlan.mountedPostNumbers.has(view.postNumber), ancestorShell = !!mountPlan && mountPlan.shellPostNumbers.has(view.postNumber);
view.slots.root.classList.contains("ldp-virtual-ancestor-shell") !== ancestorShell && view.slots.root.classList.toggle(
"ldp-virtual-ancestor-shell",
ancestorShell
);
const ownSize = mountPlan?.ownSizes.get(view.postNumber);
if (ownSize === void 0)
view.slots.root.style.getPropertyValue("--ldp-virtual-own-size") && view.slots.root.style.removeProperty("--ldp-virtual-own-size");
else {
const ownSizeValue = `${Math.max(0, ownSize)}px`;
view.slots.root.style.getPropertyValue("--ldp-virtual-own-size") !== ownSizeValue && view.slots.root.style.setProperty(
"--ldp-virtual-own-size",
ownSizeValue
);
}
if (parentPostNumber === void 0) {
view.slots.root.remove(), this.#childLayoutKeys.delete(view.postNumber), view.setTreePosition(null, void 0), parked.push(view.postNumber);
continue;
}
if (!selectedByNodeWindow || mountedRootPostNumbers && (rootPostNumber === void 0 || !mountedRootPostNumbers.has(rootPostNumber))) {
view.slots.root.remove(), this.#childLayoutKeys.delete(view.postNumber), view.setTreePosition(parentPostNumber, depth), parked.push(view.postNumber);
continue;
}
if (parentPostNumber === null) {
view.setTreePosition(null, depth), insertPostInOrder(this.rootList, view), mountedRoots.push(view.postNumber);
continue;
}
const parent = this.#views.get(parentPostNumber);
if (view.setTreePosition(parentPostNumber, depth), !parent) {
view.slots.root.remove(), this.#childLayoutKeys.delete(view.postNumber), parked.push(view.postNumber), missingParents.push(parentPostNumber);
continue;
}
insertPostInOrder(parent.slots.replyList, view), mountedReplies.push(view.postNumber);
}
if (mountPlan)
for (const { view } of viewEntries) {
if (!mountPlan.mountedPostNumbers.has(view.postNumber)) continue;
const childLayout = mountPlan.childLayouts.get(view.postNumber);
if (!childLayout) {
this.#childLayoutKeys.has(view.postNumber) && (removeVirtualSpacers(view.slots.replyList), this.#childLayoutKeys.delete(view.postNumber));
continue;
}
const nextLayoutKey = childLayoutKey(childLayout);
if (this.#childLayoutKeys.get(view.postNumber) === nextLayoutKey)
continue;
removeVirtualSpacers(view.slots.replyList);
const fragment = view.slots.replyList.ownerDocument.createDocumentFragment();
for (let index = 0; index < childLayout.postNumbers.length; index += 1) {
const beforeSize = childLayout.beforeSizes[index] ?? 0;
beforeSize > 0 && fragment.append(virtualSpacer(
view.slots.replyList.ownerDocument,
beforeSize
));
const childPostNumber = childLayout.postNumbers[index], childView = this.#views.get(childPostNumber);
childView && mountPlan.mountedPostNumbers.has(childPostNumber) && fragment.append(childView.slots.root);
}
childLayout.afterSize > 0 && fragment.append(virtualSpacer(
view.slots.replyList.ownerDocument,
childLayout.afterSize
)), view.slots.replyList.append(fragment), this.#childLayoutKeys.set(view.postNumber, nextLayoutKey);
}
else if (this.#childLayoutKeys.size) {
for (const postNumber of this.#childLayoutKeys.keys()) {
const view = this.#views.get(postNumber);
view && removeVirtualSpacers(view.slots.replyList);
}
this.#childLayoutKeys.clear();
}
const commit = Object.freeze({
changed: !0,
mountedRoots: Object.freeze(mountedRoots),
mountedReplies: Object.freeze(mountedReplies),
parked: Object.freeze(parked),
missingParents: Object.freeze([...new Set(missingParents)].sort((a, b) => a - b))
});
return this.#lastSyncKey = syncKey, this.#lastCommit = commit, commit;
}
#syncKey(mountedRootPostNumbers, mountPlan) {
const roots = mountedRootPostNumbers ? [...mountedRootPostNumbers].sort((left, right) => left - right).join(",") : "*";
if (!mountPlan)
return `${String(this.topology.revision ?? "")}|${this.#viewRevision}|${roots}`;
const mounted = [...mountPlan.mountedPostNumbers].sort((left, right) => left - right).join(","), shells = [...mountPlan.shellPostNumbers].sort((left, right) => left - right).join(","), ownSizes = [...mountPlan.ownSizes].sort(([left], [right]) => left - right).map(([postNumber, size]) => `${postNumber}:${size.toFixed(2)}`).join(","), layouts = [...mountPlan.childLayouts].sort(([left], [right]) => left - right).map(
([postNumber, layout]) => `${postNumber}:${layout.postNumbers.join(".")}@${layout.beforeSizes.map((size) => size.toFixed(2)).join(".")}@` + layout.afterSize.toFixed(2)
).join("|");
return `${String(this.topology.revision ?? "")}|${this.#viewRevision}|${roots}|${mounted}|${shells}|${ownSizes}|${layouts}`;
}
destroy() {
const views = [...this.#views.values()].sort((left, right) => right.postNumber - left.postNumber);
this.#views.clear(), this.#childLayoutKeys.clear(), this.#lastCommit = null, this.#lastSyncKey = "";
for (const view of views) view.destroy();
}
}
}, "5cd439f571d7a08ffebc93392e187f70af6b95d33b71a229b088f1c3f74bb8d7");
/* Source: lite/src/dom/reply-tree-repository.ts */
runtime.register("src/dom/reply-tree-repository.js", function(module, exports, require) {
var reply_tree_repository_exports = {};
__export(reply_tree_repository_exports, {
ReplyTreeRepository: () => ReplyTreeRepository
});
module.exports = __toCommonJS(reply_tree_repository_exports);
var import_signal = require("../kernel/signal.js"), import_identifiers = require("../discourse/identifiers.js"), import_ingest_version = require("../discourse/ingest-version.js"), import_reply_tree = require("./reply-tree.js");
function relationFromPost(post) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(post.post_number);
if (postNumber === null) return null;
const parentPostNumber = (0, import_identifiers.tryDiscoursePostNumber)(post.reply_to_post_number);
return Object.freeze({ postNumber, parentPostNumber });
}
function assertExpectedPostCount(value) {
if (!Number.isSafeInteger(value) || value < 0)
throw new RangeError("expectedPostCount 必须是非负安全整数");
return value;
}
function validSource(value) {
return (0, import_ingest_version.normalizeDiscourseIngestSource)(value) !== null;
}
function shouldApplyRelation(current, observedAt, source) {
return (0, import_ingest_version.shouldReplaceDiscourseVersion)(current, { observedAt, source });
}
function assertStoredSnapshot(value, topicId) {
if (value === null) return null;
if (value.schemaVersion !== 2 || value.topicId !== topicId || !Number.isFinite(value.savedAt) || !Number.isSafeInteger(value.expectedPostCount) || value.expectedPostCount < 0 || !value.tree || !Array.isArray(value.tree.relations) || !Array.isArray(value.versions))
throw new Error(`Topic ${topicId} 的回复树快照无效`);
const relationPostNumbers = new Set(value.tree.relations.map((relation) => relation.postNumber)), versionPostNumbers = /* @__PURE__ */ new Set();
for (const version of value.versions) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(version?.postNumber);
if (postNumber === null || versionPostNumbers.has(postNumber) || !relationPostNumbers.has(postNumber) || !Number.isFinite(version.observedAt) || version.observedAt < 0 || !validSource(version.source))
throw new Error(`Topic ${topicId} 的回复树关系版本无效`);
versionPostNumbers.add(postNumber);
}
if (versionPostNumbers.size !== relationPostNumbers.size)
throw new Error(`Topic ${topicId} 的回复树关系版本不完整`);
const removedPostNumbers = /* @__PURE__ */ new Set();
for (const version of value.removedVersions ?? []) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(version?.postNumber);
if (postNumber === null || removedPostNumbers.has(postNumber) || relationPostNumbers.has(postNumber) || !Number.isFinite(version.observedAt) || version.observedAt < 0 || !validSource(version.source))
throw new Error(`Topic ${topicId} 的回复树删除版本无效`);
removedPostNumbers.add(postNumber);
}
return value;
}
class ReplyTreeRepository {
topicId;
topology;
changes = new import_signal.Signal();
#store;
#onPersistenceError;
#now;
#versions = /* @__PURE__ */ new Map();
#removedVersions = /* @__PURE__ */ new Map();
#expectedPostCount = 0;
#pendingSnapshot = null;
#persisting = null;
#lastPersistenceError = null;
#lastImplicitObservedAt = -1;
constructor(topicId, store, options = {}) {
const normalizedTopicId = String((0, import_identifiers.discourseTopicId)(topicId));
this.topicId = normalizedTopicId, this.#store = store, this.topology = options.topology ?? new import_reply_tree.ReplyTreeTopology(), this.#now = options.now ?? Date.now, this.#onPersistenceError = options.onPersistenceError ?? (() => {
});
for (const relation of this.topology.snapshot().relations)
this.#versions.set(relation.postNumber, Object.freeze({
postNumber: relation.postNumber,
observedAt: 0,
source: "loader-batch"
}));
}
coverage() {
const knownPostCount = this.topology.snapshot().relations.length;
return Object.freeze({
knownPostCount,
expectedPostCount: this.#expectedPostCount,
complete: this.#expectedPostCount > 0 && knownPostCount >= this.#expectedPostCount
});
}
setExpectedPostCount(expectedPostCount) {
const normalized = assertExpectedPostCount(expectedPostCount);
return normalized !== this.#expectedPostCount && (this.#expectedPostCount = normalized, this.#queuePersistence()), this.coverage();
}
ingest(posts, source, options = {}) {
const observedAt = options.observedAt === void 0 ? Math.max(
(0, import_ingest_version.discourseObservedAt)(this.#now()),
this.#lastImplicitObservedAt + 1
) : (0, import_ingest_version.discourseObservedAt)(options.observedAt);
options.observedAt === void 0 && (this.#lastImplicitObservedAt = observedAt);
const relationByPost = /* @__PURE__ */ new Map(), acceptedByPost = /* @__PURE__ */ new Map();
let ignored = 0;
for (const post of posts) {
const relation = relationFromPost(post);
relation ? (relationByPost.delete(relation.postNumber), relationByPost.set(relation.postNumber, relation), acceptedByPost.delete(relation.postNumber), acceptedByPost.set(
relation.postNumber,
Object.freeze({ postNumber: relation.postNumber, post })
)) : ignored += 1;
}
const relations = [...relationByPost.values()], acceptedPosts = [...acceptedByPost.values()];
if (!relations.length)
return Object.freeze({
accepted: 0,
appliedRelations: 0,
acceptedPosts: Object.freeze([]),
ignored,
event: null,
listenerErrors: Object.freeze([])
});
const applicable = relations.filter((relation) => {
const removed = this.#removedVersions.get(relation.postNumber);
return removed && !(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removed, { observedAt, source }) ? !1 : shouldApplyRelation(this.#versions.get(relation.postNumber), observedAt, source);
}), applicablePostNumbers = new Set(
applicable.map((relation) => relation.postNumber)
), applicablePosts = acceptedPosts.filter((entry) => applicablePostNumbers.has(entry.postNumber));
if (!applicable.length)
return Object.freeze({
accepted: 0,
appliedRelations: 0,
acceptedPosts: Object.freeze([]),
ignored,
event: null,
listenerErrors: Object.freeze([])
});
const change = this.topology.commit(applicable);
for (const relation of applicable)
this.#removedVersions.delete(relation.postNumber), this.#versions.set(relation.postNumber, Object.freeze({
postNumber: relation.postNumber,
observedAt,
source
}));
const event = this.#event(source, change), listenerErrors = this.changes.emit(event);
return this.#queuePersistence(), Object.freeze({
accepted: applicable.length,
appliedRelations: applicable.length,
acceptedPosts: Object.freeze(applicablePosts),
ignored,
event,
listenerErrors
});
}
remove(rawPostNumber, source, options = {}) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawPostNumber);
if (postNumber === null) throw new RangeError("postNumber 必须是正安全整数");
const observedAt = options.observedAt === void 0 ? Math.max(
(0, import_ingest_version.discourseObservedAt)(this.#now()),
this.#lastImplicitObservedAt + 1
) : (0, import_ingest_version.discourseObservedAt)(options.observedAt);
options.observedAt === void 0 && (this.#lastImplicitObservedAt = observedAt);
const current = this.#versions.get(postNumber), removed = this.#removedVersions.get(postNumber), nextVersion = Object.freeze({ postNumber, observedAt, source });
if (current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(current, nextVersion) || removed && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(removed, nextVersion))
return null;
const hadRelation = this.topology.has(postNumber), directChildren = this.topology.childrenOf(postNumber), change = this.topology.remove(postNumber);
this.#versions.delete(postNumber), this.#removedVersions.set(postNumber, nextVersion);
for (const childPostNumber of directChildren)
this.#versions.set(childPostNumber, Object.freeze({
postNumber: childPostNumber,
observedAt,
source
}));
(current || hadRelation) && (this.#expectedPostCount = Math.max(0, this.#expectedPostCount - 1));
const event = this.#event(source, change), listenerErrors = this.changes.emit(event);
for (const error of listenerErrors) this.#onPersistenceError(error);
return this.#queuePersistence(), event;
}
async restore() {
const revisionBeforeLoad = this.topology.revision;
let stored;
try {
stored = assertStoredSnapshot(await this.#store.load(this.topicId), this.topicId);
} catch (error) {
return this.#onPersistenceError(error), null;
}
if (!stored) return null;
const previousExpectedPostCount = this.#expectedPostCount, versionByPost = new Map(
stored.versions.map((version) => [version.postNumber, version])
);
let change, restoredRelations;
try {
if (this.topology.revision === revisionBeforeLoad && this.topology.snapshot().relations.length === 0)
change = this.topology.replace(stored.tree), restoredRelations = stored.tree.relations;
else {
const missingRelations = stored.tree.relations.filter(
(relation) => {
if (this.topology.has(relation.postNumber)) return !1;
const removed = this.#removedVersions.get(relation.postNumber), version = versionByPost.get(relation.postNumber);
return !removed || !version || (0, import_ingest_version.shouldReplaceDiscourseRemoval)(removed, version);
}
);
change = this.topology.commit(missingRelations), restoredRelations = missingRelations;
}
} catch (error) {
return this.#onPersistenceError(error), null;
}
for (const relation of restoredRelations) {
const version = versionByPost.get(relation.postNumber);
version && this.#versions.set(relation.postNumber, version);
}
const removalChanged = /* @__PURE__ */ new Set(), removalDetached = /* @__PURE__ */ new Set();
for (const removedVersion of stored.removedVersions ?? []) {
const current = this.#versions.get(removedVersion.postNumber), currentRemoval = this.#removedVersions.get(removedVersion.postNumber);
if (current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(current, removedVersion) || currentRemoval && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(currentRemoval, removedVersion))
continue;
const directChildren = this.topology.childrenOf(removedVersion.postNumber), removalChange = this.topology.remove(removedVersion.postNumber);
for (const postNumber of removalChange.changedPostNumbers)
removalChanged.add(postNumber);
for (const postNumber of removalChange.detachedPostNumbers)
removalDetached.add(postNumber);
this.#versions.delete(removedVersion.postNumber), this.#removedVersions.set(removedVersion.postNumber, removedVersion);
for (const childPostNumber of directChildren)
this.#versions.set(childPostNumber, Object.freeze({
postNumber: childPostNumber,
observedAt: removedVersion.observedAt,
source: removedVersion.source
}));
}
removalChanged.size && (change = Object.freeze({
revision: this.topology.revision,
changedPostNumbers: Object.freeze(
[.../* @__PURE__ */ new Set([
...change.changedPostNumbers,
...removalChanged
])].sort((left, right) => left - right)
),
detachedPostNumbers: Object.freeze(
[.../* @__PURE__ */ new Set([
...change.detachedPostNumbers,
...removalDetached
])].sort((left, right) => left - right)
)
}));
const blockedStoredCount = stored.tree.relations.filter((relation) => {
const removed = this.#removedVersions.get(relation.postNumber), version = versionByPost.get(relation.postNumber);
return !!removed && !!version && !(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removed, version);
}).length;
this.#expectedPostCount = Math.max(
this.#expectedPostCount,
Math.max(0, stored.expectedPostCount - blockedStoredCount)
);
const event = this.#event("cache-snapshot", change);
return this.changes.emit(event), (change.changedPostNumbers.length || this.#expectedPostCount !== previousExpectedPostCount) && this.#queuePersistence(), event;
}
snapshot(now = this.#now()) {
return Object.freeze({
schemaVersion: 2,
topicId: this.topicId,
savedAt: now,
expectedPostCount: this.#expectedPostCount,
tree: this.topology.snapshot(),
versions: Object.freeze(
[...this.#versions.values()].sort((left, right) => left.postNumber - right.postNumber)
),
removedVersions: Object.freeze(
[...this.#removedVersions.values()].sort((left, right) => left.postNumber - right.postNumber)
)
});
}
async flush() {
for (; this.#pendingSnapshot || this.#persisting; )
this.#persisting || this.#startPersistence(), await this.#persisting;
if (this.#lastPersistenceError !== null) {
const error = this.#lastPersistenceError;
throw this.#lastPersistenceError = null, error;
}
}
#event(source, change) {
return Object.freeze({
topicId: this.topicId,
source,
change,
coverage: this.coverage()
});
}
#queuePersistence() {
this.#pendingSnapshot = this.snapshot(), this.#persisting || this.#startPersistence();
}
#startPersistence() {
this.#persisting = Promise.resolve().then(async () => {
for (; this.#pendingSnapshot; ) {
const snapshot = this.#pendingSnapshot;
this.#pendingSnapshot = null;
try {
await this.#store.save(this.topicId, snapshot), this.#lastPersistenceError = null;
} catch (error) {
this.#lastPersistenceError = error, this.#onPersistenceError(error);
}
}
}).finally(() => {
this.#persisting = null, this.#pendingSnapshot && this.#startPersistence();
});
}
}
}, "23a6e27565f0b410969e9712da2c67fea0bfd06ccbdc7b01940a2aa4ad5dffb0");
/* Source: lite/src/dom/reply-tree.ts */
runtime.register("src/dom/reply-tree.js", function(module, exports, require) {
var reply_tree_exports = {};
__export(reply_tree_exports, {
ReplyTreeTopology: () => ReplyTreeTopology
});
module.exports = __toCommonJS(reply_tree_exports);
var import_identifiers = require("../discourse/identifiers.js");
function assertPostNumber(value, field) {
try {
(0, import_identifiers.discoursePostNumber)(value);
} catch {
throw new RangeError(`${field} 必须是正安全整数`);
}
}
function sorted(values) {
return [...values].sort((left, right) => left - right);
}
const EMPTY_POST_NUMBERS = Object.freeze([]);
class ReplyTreeTopology {
#revision = 0;
#parentByPost = /* @__PURE__ */ new Map();
#childrenByParent = /* @__PURE__ */ new Map();
#subtreePostCountByPost = /* @__PURE__ */ new Map();
#rootPostNumbers = /* @__PURE__ */ new Set();
#sortedChildrenByParent = /* @__PURE__ */ new Map();
#depthByPost = /* @__PURE__ */ new Map();
#rootByPost = /* @__PURE__ */ new Map();
#postNumbersCache = null;
#rootsCache = null;
#rootBranchesCache = null;
#snapshotCache = null;
#stateShared = !1;
get revision() {
return this.#revision;
}
parentOf(postNumber) {
return assertPostNumber(postNumber, "postNumber"), this.#parentByPost.get(postNumber);
}
childrenOf(parentPostNumber) {
assertPostNumber(parentPostNumber, "parentPostNumber");
const cached = this.#sortedChildrenByParent.get(parentPostNumber);
if (cached) return cached;
const children = this.#childrenByParent.get(parentPostNumber);
if (!children?.size) return EMPTY_POST_NUMBERS;
const result = Object.freeze(sorted(children));
return this.#sortedChildrenByParent.set(parentPostNumber, result), result;
}
postNumbers() {
return this.#postNumbersCache ? this.#postNumbersCache : (this.#postNumbersCache = Object.freeze(sorted(this.#parentByPost.keys())), this.#postNumbersCache);
}
roots() {
return this.#rootsCache ? this.#rootsCache : (this.#rootsCache = Object.freeze(sorted(this.#rootPostNumbers)), this.#rootsCache);
}
rootBranches() {
return this.#rootBranchesCache ? this.#rootBranchesCache : (this.#rootBranchesCache = Object.freeze(
this.roots().map((postNumber) => Object.freeze({
postNumber,
subtreePostCount: this.#subtreePostCountByPost.get(postNumber) ?? 1
}))
), this.#rootBranchesCache);
}
clone() {
const clone = new ReplyTreeTopology();
return clone.#revision = this.#revision, clone.#parentByPost = this.#parentByPost, clone.#childrenByParent = this.#childrenByParent, clone.#subtreePostCountByPost = this.#subtreePostCountByPost, clone.#rootPostNumbers = this.#rootPostNumbers, clone.#sortedChildrenByParent = this.#sortedChildrenByParent, clone.#depthByPost = this.#depthByPost, clone.#rootByPost = this.#rootByPost, clone.#postNumbersCache = this.#postNumbersCache, clone.#rootsCache = this.#rootsCache, clone.#rootBranchesCache = this.#rootBranchesCache, clone.#snapshotCache = this.#snapshotCache, this.#stateShared = !0, clone.#stateShared = !0, clone;
}
subtreePostCountOf(postNumber) {
return assertPostNumber(postNumber, "postNumber"), this.#subtreePostCountByPost.get(postNumber);
}
has(postNumber) {
return assertPostNumber(postNumber, "postNumber"), this.#parentByPost.has(postNumber);
}
depthOf(postNumber) {
if (assertPostNumber(postNumber, "postNumber"), !!this.#parentByPost.has(postNumber))
return this.#resolveAncestry(postNumber), this.#depthByPost.get(postNumber);
}
rootOf(postNumber) {
if (assertPostNumber(postNumber, "postNumber"), !!this.#parentByPost.has(postNumber))
return this.#resolveAncestry(postNumber), this.#rootByPost.get(postNumber);
}
commit(relations) {
if (!relations.length)
return Object.freeze({
revision: this.#revision,
changedPostNumbers: Object.freeze([]),
detachedPostNumbers: Object.freeze([])
});
const updates = /* @__PURE__ */ new Map(), touched = /* @__PURE__ */ new Set();
for (const relation of relations) {
if (assertPostNumber(relation.postNumber, "postNumber"), relation.parentPostNumber !== null && (assertPostNumber(relation.parentPostNumber, "parentPostNumber"), relation.parentPostNumber === relation.postNumber))
throw new Error(`楼层 #${relation.postNumber} 不能回复自身`);
updates.set(relation.postNumber, relation.parentPostNumber), touched.add(relation.postNumber);
}
this.#assertAcyclicWithUpdates(updates, touched);
const changed = /* @__PURE__ */ new Set(), detached = /* @__PURE__ */ new Set();
for (const postNumber of touched) {
const previousParent = this.#parentByPost.get(postNumber), nextParent = updates.get(postNumber);
previousParent === nextParent && this.#parentByPost.has(postNumber) || (changed.add(postNumber), previousParent != null && detached.add(postNumber));
}
if (!changed.size)
return Object.freeze({
revision: this.#revision,
changedPostNumbers: Object.freeze([]),
detachedPostNumbers: Object.freeze([])
});
this.#detachSharedState();
let membershipChanged = !1, rootsChanged = !1;
const addedPostCount = [...changed].filter(
(postNumber) => !this.#parentByPost.has(postNumber)
).length, projectedSize = this.#parentByPost.size + addedPostCount;
if (changed.size > 64 && changed.size * 4 >= projectedSize) {
const nextParents = this.#parentByPost.size === 0 ? updates : new Map(this.#parentByPost);
if (this.#parentByPost.size === 0)
membershipChanged = !0;
else
for (const postNumber of changed)
nextParents.has(postNumber) || (membershipChanged = !0), nextParents.set(postNumber, updates.get(postNumber));
this.#parentByPost = nextParents, this.#childrenByParent = this.#buildChildren(nextParents), this.#subtreePostCountByPost = this.#buildSubtreePostCounts(
nextParents,
this.#childrenByParent
), this.#rootPostNumbers = /* @__PURE__ */ new Set();
for (const [postNumber, parentPostNumber] of nextParents)
parentPostNumber === null && this.#rootPostNumbers.add(postNumber);
this.#sortedChildrenByParent.clear(), rootsChanged = !0;
} else {
const affectedCounts = /* @__PURE__ */ new Set();
for (const postNumber of changed)
this.#addKnownAncestorChain(postNumber, affectedCounts);
for (const postNumber of changed) {
const hadPost = this.#parentByPost.has(postNumber), previousParent = this.#parentByPost.get(postNumber), nextParent = updates.get(postNumber);
hadPost || (membershipChanged = !0), hadPost && previousParent === null && (this.#rootPostNumbers.delete(postNumber), rootsChanged = !0), previousParent != null && this.#detachChild(previousParent, postNumber), this.#parentByPost.set(postNumber, nextParent), nextParent === null ? (this.#rootPostNumbers.add(postNumber), rootsChanged = !0) : this.#attachChild(nextParent, postNumber);
}
for (const postNumber of changed)
this.#addKnownAncestorChain(postNumber, affectedCounts);
this.#recomputeSubtreePostCounts(affectedCounts);
}
return this.#revision += 1, this.#depthByPost = /* @__PURE__ */ new Map(), this.#rootByPost = /* @__PURE__ */ new Map(), membershipChanged && (this.#postNumbersCache = null), rootsChanged && (this.#rootsCache = null), this.#rootBranchesCache = null, this.#snapshotCache = null, Object.freeze({
revision: this.#revision,
changedPostNumbers: Object.freeze(sorted(changed)),
detachedPostNumbers: Object.freeze(sorted(detached))
});
}
/**
* 删除一个关系,并把直属子楼层提升到被删楼层原父级。
*
* 这避免子孙因父 DOM 消失而变成无根悬挂节点;返回的 changed/detached 同时包含被删
* 楼层与需要重新挂载的直属子楼层。
*/
remove(postNumber) {
if (assertPostNumber(postNumber, "postNumber"), !this.#parentByPost.has(postNumber))
return Object.freeze({
revision: this.#revision,
changedPostNumbers: Object.freeze([]),
detachedPostNumbers: Object.freeze([])
});
const previousParent = this.#parentByPost.get(postNumber) ?? null, directChildren = this.childrenOf(postNumber);
this.#detachSharedState();
const affectedCounts = /* @__PURE__ */ new Set();
this.#addKnownAncestorChain(postNumber, affectedCounts), previousParent === null ? this.#rootPostNumbers.delete(postNumber) : this.#detachChild(previousParent, postNumber), this.#parentByPost.delete(postNumber), this.#childrenByParent.delete(postNumber), this.#sortedChildrenByParent.delete(postNumber), this.#subtreePostCountByPost.delete(postNumber);
for (const childPostNumber of directChildren)
this.#parentByPost.set(childPostNumber, previousParent), previousParent === null ? this.#rootPostNumbers.add(childPostNumber) : this.#attachChild(previousParent, childPostNumber), this.#addKnownAncestorChain(childPostNumber, affectedCounts);
return this.#recomputeSubtreePostCounts(affectedCounts), this.#revision += 1, this.#depthByPost = /* @__PURE__ */ new Map(), this.#rootByPost = /* @__PURE__ */ new Map(), this.#postNumbersCache = null, this.#rootsCache = null, this.#rootBranchesCache = null, this.#snapshotCache = null, Object.freeze({
revision: this.#revision,
changedPostNumbers: Object.freeze(sorted([postNumber, ...directChildren])),
detachedPostNumbers: Object.freeze(sorted([postNumber, ...directChildren]))
});
}
replace(snapshot) {
if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0)
throw new RangeError("回复树快照 revision 必须是非负安全整数");
const nextParents = /* @__PURE__ */ new Map();
for (const relation of snapshot.relations) {
if (assertPostNumber(relation.postNumber, "postNumber"), nextParents.has(relation.postNumber))
throw new Error(`快照重复定义楼层 #${relation.postNumber}`);
relation.parentPostNumber !== null && assertPostNumber(relation.parentPostNumber, "parentPostNumber"), nextParents.set(relation.postNumber, relation.parentPostNumber);
}
this.#assertAcyclic(nextParents, new Set(nextParents.keys()));
const changed = /* @__PURE__ */ new Set(), detached = /* @__PURE__ */ new Set();
for (const postNumber of /* @__PURE__ */ new Set([...this.#parentByPost.keys(), ...nextParents.keys()])) {
const previousParent = this.#parentByPost.get(postNumber), nextParent = nextParents.get(postNumber);
previousParent === nextParent && this.#parentByPost.has(postNumber) === nextParents.has(postNumber) || (changed.add(postNumber), nextParents.has(postNumber) || detached.add(postNumber));
}
return this.#parentByPost = nextParents, this.#childrenByParent = this.#buildChildren(nextParents), this.#subtreePostCountByPost = this.#buildSubtreePostCounts(
nextParents,
this.#childrenByParent
), this.#rootPostNumbers = new Set(
[...nextParents].filter(([, parentPostNumber]) => parentPostNumber === null).map(([postNumber]) => postNumber)
), this.#revision = Math.max(this.#revision + 1, snapshot.revision), this.#sortedChildrenByParent = /* @__PURE__ */ new Map(), this.#depthByPost = /* @__PURE__ */ new Map(), this.#rootByPost = /* @__PURE__ */ new Map(), this.#postNumbersCache = null, this.#rootsCache = null, this.#rootBranchesCache = null, this.#snapshotCache = null, this.#stateShared = !1, Object.freeze({
revision: this.#revision,
changedPostNumbers: Object.freeze(sorted(changed)),
detachedPostNumbers: Object.freeze(sorted(detached))
});
}
snapshot() {
if (this.#snapshotCache) return this.#snapshotCache;
const relations = this.postNumbers().map(
(postNumber) => Object.freeze({
postNumber,
parentPostNumber: this.#parentByPost.get(postNumber) ?? null
})
);
return this.#snapshotCache = Object.freeze({
revision: this.#revision,
relations: Object.freeze(relations)
}), this.#snapshotCache;
}
#resolveAncestry(postNumber) {
if (this.#depthByPost.has(postNumber)) return;
const path = [];
let current = postNumber, depth, rootPostNumber;
for (; ; ) {
if (this.#depthByPost.has(current)) {
depth = this.#depthByPost.get(current), rootPostNumber = this.#rootByPost.get(current);
break;
}
if (!this.#parentByPost.has(current)) {
depth = void 0, rootPostNumber = void 0;
break;
}
const parentPostNumber = this.#parentByPost.get(current);
if (parentPostNumber === null) {
depth = 0, rootPostNumber = current, this.#depthByPost.set(current, depth), this.#rootByPost.set(current, rootPostNumber);
break;
}
path.push(current), current = parentPostNumber;
}
for (; path.length; ) {
const descendantPostNumber = path.pop();
depth !== void 0 && (depth += 1), this.#depthByPost.set(descendantPostNumber, depth), this.#rootByPost.set(descendantPostNumber, rootPostNumber);
}
}
#detachSharedState() {
this.#stateShared && (this.#parentByPost = new Map(this.#parentByPost), this.#childrenByParent = new Map(
[...this.#childrenByParent].map(([postNumber, children]) => [
postNumber,
new Set(children)
])
), this.#subtreePostCountByPost = new Map(this.#subtreePostCountByPost), this.#rootPostNumbers = new Set(this.#rootPostNumbers), this.#sortedChildrenByParent = new Map(this.#sortedChildrenByParent), this.#depthByPost = new Map(this.#depthByPost), this.#rootByPost = new Map(this.#rootByPost), this.#stateShared = !1);
}
#attachChild(parentPostNumber, postNumber) {
const children = this.#childrenByParent.get(parentPostNumber) ?? /* @__PURE__ */ new Set();
children.add(postNumber), this.#childrenByParent.set(parentPostNumber, children), this.#sortedChildrenByParent.delete(parentPostNumber);
}
#detachChild(parentPostNumber, postNumber) {
const children = this.#childrenByParent.get(parentPostNumber);
children && (children.delete(postNumber), children.size || this.#childrenByParent.delete(parentPostNumber), this.#sortedChildrenByParent.delete(parentPostNumber));
}
#addKnownAncestorChain(postNumber, target) {
const seen = /* @__PURE__ */ new Set();
let current = postNumber;
for (; current != null && this.#parentByPost.has(current) && !seen.has(current); )
seen.add(current), target.add(current), current = this.#parentByPost.get(current);
}
#knownDepth(postNumber, depthByPost) {
const cached = depthByPost.get(postNumber);
if (cached !== void 0) return cached;
const path = [];
let current = postNumber, depth = -1;
for (; ; ) {
const currentDepth = depthByPost.get(current);
if (currentDepth !== void 0) {
depth = currentDepth;
break;
}
if (!this.#parentByPost.has(current)) break;
path.push(current);
const parentPostNumber = this.#parentByPost.get(current);
if (parentPostNumber == null || !this.#parentByPost.has(parentPostNumber)) break;
current = parentPostNumber;
}
for (; path.length; )
depth += 1, depthByPost.set(path.pop(), depth);
return depthByPost.get(postNumber) ?? 0;
}
#recomputeSubtreePostCounts(affected) {
const depthByPost = /* @__PURE__ */ new Map(), postNumbers = [...affected].filter((postNumber) => this.#parentByPost.has(postNumber)).sort(
(left, right) => this.#knownDepth(right, depthByPost) - this.#knownDepth(left, depthByPost) || right - left
);
for (const postNumber of postNumbers) {
let subtreePostCount = 1;
for (const childPostNumber of this.#childrenByParent.get(postNumber) ?? [])
subtreePostCount += this.#subtreePostCountByPost.get(childPostNumber) ?? 1;
this.#subtreePostCountByPost.set(postNumber, subtreePostCount);
}
}
#buildChildren(parents) {
const children = /* @__PURE__ */ new Map();
for (const [postNumber, parentPostNumber] of parents) {
if (parentPostNumber === null) continue;
const siblings = children.get(parentPostNumber) ?? /* @__PURE__ */ new Set();
siblings.add(postNumber), children.set(parentPostNumber, siblings);
}
return children;
}
#buildSubtreePostCounts(parents, children) {
const counts = /* @__PURE__ */ new Map(), remainingChildren = /* @__PURE__ */ new Map(), queue = [];
for (const postNumber of parents.keys()) {
const count = children.get(postNumber)?.size ?? 0;
remainingChildren.set(postNumber, count), count === 0 && queue.push(postNumber);
}
for (; queue.length; ) {
const postNumber = queue.pop(), subtreePostCount = counts.get(postNumber) ?? 1;
counts.set(postNumber, subtreePostCount);
const parentPostNumber = parents.get(postNumber);
if (parentPostNumber == null || !parents.has(parentPostNumber)) continue;
counts.set(
parentPostNumber,
(counts.get(parentPostNumber) ?? 1) + subtreePostCount
);
const remaining = (remainingChildren.get(parentPostNumber) ?? 1) - 1;
remainingChildren.set(parentPostNumber, remaining), remaining === 0 && queue.push(parentPostNumber);
}
return counts;
}
#assertAcyclic(parents, starts) {
this.#assertAcyclicFrom(
(postNumber) => parents.get(postNumber),
starts
);
}
#assertAcyclicWithUpdates(updates, starts) {
this.#assertAcyclicFrom(
(postNumber) => updates.has(postNumber) ? updates.get(postNumber) : this.#parentByPost.get(postNumber),
starts
);
}
#assertAcyclicFrom(parentOf, starts) {
const complete = /* @__PURE__ */ new Set();
for (const start of starts) {
if (complete.has(start)) continue;
const path = /* @__PURE__ */ new Set(), traversed = [];
let current = start;
for (; current != null && !complete.has(current); ) {
if (path.has(current))
throw new Error(`楼层关系存在环,经过 #${current}`);
path.add(current), traversed.push(current), current = parentOf(current);
}
for (const postNumber of traversed) complete.add(postNumber);
}
}
}
}, "fe7b97571ff64d5f27c7fbf4ec07a3b417a3b9b00e0e8de55e6ee1f6d38af0d5");
/* Source: lite/src/dom/required-element.ts */
runtime.register("src/dom/required-element.js", function(module, exports, require) {
var required_element_exports = {};
__export(required_element_exports, {
requiredElementQuery: () => requiredElementQuery
});
module.exports = __toCommonJS(required_element_exports);
function requiredElementQuery(owner) {
return function(root, selector) {
const node = root.querySelector(selector);
if (!node) throw new Error(`${owner}缺少 ${selector}`);
return node;
};
}
}, "7f4374644d667871f13c79db93a71b41820a21560f1fddeeb6c081f26c783f8e");
/* Source: lite/src/kernel/lifecycle.ts */
runtime.register("src/kernel/lifecycle.js", function(module, exports, require) {
var lifecycle_exports = {};
__export(lifecycle_exports, {
LifecycleScope: () => LifecycleScope
});
module.exports = __toCommonJS(lifecycle_exports);
class LifecycleScope {
#cleanups = [];
#destroyed = !1;
/**
* 为业务 owner 创建唯一作用域:有父级时建立受控 child,否则建立独立 root。
*/
static ownedBy(parent) {
return parent ? parent.child() : new LifecycleScope();
}
get destroyed() {
return this.#destroyed;
}
add(cleanup) {
let active = !0;
const runOnce = () => {
if (!active) return;
active = !1;
const index = this.#cleanups.indexOf(runOnce);
index >= 0 && this.#cleanups.splice(index, 1), cleanup();
};
return this.#destroyed ? (runOnce(), runOnce) : (this.#cleanups.push(runOnce), runOnce);
}
child() {
const child = new LifecycleScope(), detach = this.add(() => child.destroy());
return child.add(detach), child;
}
/** 创建由本 scope 独占、可选转发上游取消原因的 AbortController。 */
abortController(destroyReason, upstream) {
const controller = new AbortController(), abort = (reason) => {
controller.signal.aborted || controller.abort(reason);
}, forwardAbort = () => abort(upstream?.reason);
return upstream?.aborted ? forwardAbort() : upstream?.addEventListener("abort", forwardAbort, { once: !0 }), this.add(() => {
upstream?.removeEventListener("abort", forwardAbort), abort(destroyReason);
}), controller;
}
listen(target, type, listener, options) {
return target.addEventListener(type, listener, options), this.add(() => target.removeEventListener(type, listener, options));
}
observe(observer, target, options) {
return target && "observe" in observer && typeof observer.observe == "function" && observer.observe(target, options), this.add(() => observer.disconnect());
}
timer(timerId, clear = clearTimeout) {
return this.add(() => clear(timerId));
}
destroy() {
if (this.#destroyed) return;
this.#destroyed = !0;
const errors = [], cleanups = this.#cleanups.splice(0);
for (let index = cleanups.length - 1; index >= 0; index -= 1)
try {
cleanups[index]();
} catch (error) {
errors.push(error);
}
if (errors.length) throw new AggregateError(errors, "LifecycleScope cleanup failed");
}
}
}, "8b8b2ccae7437d794799c0a26fd4258ce04e126181396d2ee18e7dea48d2cd62");
/* Source: lite/src/kernel/repeat-action-gate.ts */
runtime.register("src/kernel/repeat-action-gate.js", function(module, exports, require) {
var repeat_action_gate_exports = {};
__export(repeat_action_gate_exports, {
RepeatActionGate: () => RepeatActionGate
});
module.exports = __toCommonJS(repeat_action_gate_exports);
class RepeatActionGate {
#deadlines = /* @__PURE__ */ new Map();
#windowMs;
#now;
constructor(options = {}) {
const windowMs = Number(options.windowMs ?? 1500);
if (!Number.isFinite(windowMs) || windowMs <= 0)
throw new RangeError("重复动作确认期限必须为正数");
this.#windowMs = windowMs, this.#now = options.now ?? Date.now;
}
confirm(rawKey) {
const key = String(rawKey).trim();
if (!key) throw new TypeError("重复动作确认 key 不能为空");
const now = this.#now(), confirmed = (this.#deadlines.get(key) ?? 0) >= now;
return this.#deadlines.clear(), confirmed || this.#deadlines.set(key, now + this.#windowMs), confirmed;
}
clear() {
this.#deadlines.clear();
}
}
}, "405ed67abebe6114e3bf920269d0e06acd24f7aa7089e329bd65a7eefffa467a");
/* Source: lite/src/kernel/signal.ts */
runtime.register("src/kernel/signal.js", function(module, exports, require) {
var signal_exports = {};
__export(signal_exports, {
Signal: () => Signal
});
module.exports = __toCommonJS(signal_exports);
class Signal {
#listeners = /* @__PURE__ */ new Set();
get size() {
return this.#listeners.size;
}
subscribe(listener, scope) {
this.#listeners.add(listener);
const unsubscribe = () => {
this.#listeners.delete(listener);
};
return scope && scope.add(unsubscribe), unsubscribe;
}
emit(value) {
const errors = [];
for (const listener of [...this.#listeners])
try {
listener(value);
} catch (error) {
errors.push(error);
}
return Object.freeze(errors);
}
clear() {
this.#listeners.clear();
}
}
}, "9e0cff36c2177073a516fade2a21fee65bb7ac09607649e2c719742a2c129ae4");
/* Source: lite/src/kernel/value-record.ts */
runtime.register("src/kernel/value-record.js", function(module, exports, require) {
var value_record_exports = {};
__export(value_record_exports, {
objectRecord: () => objectRecord,
valueRecord: () => valueRecord
});
module.exports = __toCommonJS(value_record_exports);
function valueRecord(value) {
return value !== null && (typeof value == "object" || typeof value == "function") ? value : null;
}
function objectRecord(value) {
return value !== null && typeof value == "object" ? value : null;
}
}, "e86289a6fd7f06e9a422bff290f94ee01c33fd53a37836fa46491175d6dd1356");
/* Source: lite/src/layout/branch-overlay.ts */
runtime.register("src/layout/branch-overlay.js", function(module, exports, require) {
var branch_overlay_exports = {};
__export(branch_overlay_exports, {
ReaderBranchOverlayController: () => ReaderBranchOverlayController,
deriveBranchGeometry: () => deriveBranchGeometry
});
module.exports = __toCommonJS(branch_overlay_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_reader_icon = require("../components/reader-icon.js");
const CHILD_AVATAR_GAP = 5, PARENT_AVATAR_DROP_GAP = CHILD_AVATAR_GAP, MAX_CONTINUOUS_BRANCH_SPAN_PX = 2048, BRANCH_PAINT_PADDING_PX = 8;
function finite(value, field) {
if (!Number.isFinite(value)) throw new RangeError(`${field} 必须是有限数值`);
return value;
}
function deriveBranchGeometry(input) {
const parentAxisX = finite(input.parentAxisX, "parentAxisX"), parentStartY = finite(input.parentStartY, "parentStartY"), childAxisX = finite(input.childAxisX, "childAxisX"), childCenterY = finite(input.childCenterY, "childCenterY"), requestedRadius = Math.max(0, finite(input.cornerRadius, "cornerRadius")), horizontalDistance = Math.abs(childAxisX - parentAxisX), verticalDistance = Math.abs(childCenterY - parentStartY), radius = Math.min(requestedRadius, horizontalDistance, verticalDistance), turnY = childCenterY - Math.sign(childCenterY - parentStartY || 1) * radius, turnX = parentAxisX + Math.sign(childAxisX - parentAxisX || 1) * radius, path = [
`M ${parentAxisX} ${parentStartY}`,
`L ${parentAxisX} ${turnY}`,
`Q ${parentAxisX} ${childCenterY} ${turnX} ${childCenterY}`,
`L ${childAxisX} ${childCenterY}`
].join(" ");
return Object.freeze({
path
});
}
function deriveSharedBranchGeometry(input) {
const parentAxisX = finite(input.parentAxisX, "parentAxisX"), parentStartY = finite(input.parentStartY, "parentStartY"), requestedRadius = Math.max(
0,
finite(input.cornerRadius, "cornerRadius")
), branches = input.children.map((child) => {
const childAxisX = finite(child.childAxisX, "childAxisX"), childCenterY = finite(child.childCenterY, "childCenterY"), horizontalDirection = Math.sign(childAxisX - parentAxisX || 1), verticalDirection = Math.sign(childCenterY - parentStartY || 1), radius = Math.min(
requestedRadius,
Math.abs(childAxisX - parentAxisX),
Math.abs(childCenterY - parentStartY)
);
return Object.freeze({
childAxisX,
childCenterY,
turnX: parentAxisX + horizontalDirection * radius,
turnY: childCenterY - verticalDirection * radius
});
});
if (!branches.length) return Object.freeze({ path: "" });
const railEnd = branches.reduce(
(farthest, branch) => Math.abs(branch.turnY - parentStartY) > Math.abs(farthest - parentStartY) ? branch.turnY : farthest,
branches[0].turnY
), parts = [
`M ${parentAxisX} ${parentStartY}`,
`L ${parentAxisX} ${railEnd}`
];
for (const branch of branches)
parts.push(
`M ${parentAxisX} ${branch.turnY}`,
`Q ${parentAxisX} ${branch.childCenterY} ${branch.turnX} ${branch.childCenterY}`,
`L ${branch.childAxisX} ${branch.childCenterY}`
);
return Object.freeze({ path: parts.join(" ") });
}
function branchScrollContainer(element) {
const view = element.ownerDocument.defaultView;
for (let candidate = element.parentElement; candidate; candidate = candidate.parentElement) {
const overflowY = view?.getComputedStyle(candidate).overflowY ?? "";
if (/^(auto|scroll|overlay)$/.test(overflowY) && candidate.scrollHeight > candidate.clientHeight) return candidate;
}
return null;
}
function pointerAnchor(event, root, wasCollapsed) {
const pointer = event;
return wasCollapsed || pointer.detail <= 0 || !Number.isFinite(pointer.clientY) ? null : Object.freeze({
clientY: pointer.clientY,
scrollContainer: branchScrollContainer(root)
});
}
function restorePointerAnchor(anchor, toggle) {
if (!anchor || !toggle?.isConnected) return;
const rect = toggle.getBoundingClientRect(), delta = rect.top + rect.height / 2 - anchor.clientY;
if (!(!Number.isFinite(delta) || Math.abs(delta) < 0.5)) {
if (anchor.scrollContainer?.isConnected) {
anchor.scrollContainer.scrollTop += delta;
return;
}
toggle.ownerDocument.defaultView?.scrollBy({
top: delta,
behavior: "auto"
});
}
}
function defaultReadAvatar(slots) {
const collapsedToggle = slots.root.querySelector(
':scope > .ldp-reader-branch-toggle[aria-expanded="false"]'
);
return collapsedToggle || slots.header.querySelector(
"[data-reader-avatar],.ldp-avatar-link,.ldp-avatar,img.avatar"
);
}
function finiteRect(rect) {
return Number.isFinite(rect.left) && Number.isFinite(rect.right) && Number.isFinite(rect.top) && Number.isFinite(rect.bottom) && Number.isFinite(rect.width) && Number.isFinite(rect.height);
}
function visiblePath(slots) {
return slots.branchOverlay.querySelector(
".ldp-branch-visible-path"
);
}
function hitPath(slots) {
return slots.branchOverlay.querySelector(
".ldp-branch-hit-path"
);
}
class ReaderBranchOverlayController {
scope;
#domOwner;
#renderMode;
#allowLongBranchSpans;
#readAvatar;
#onLayoutChange;
#onToggleBranch;
#readCollapsed;
#owned = /* @__PURE__ */ new Map();
#collapsed = /* @__PURE__ */ new Set();
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#domOwner = options.domOwner, this.#renderMode = options.renderMode ?? "svg", this.#allowLongBranchSpans = options.allowLongBranchSpans ?? !1, this.#readAvatar = options.readAvatar ?? defaultReadAvatar, this.#onLayoutChange = options.onLayoutChange ?? (() => {
}), this.#onToggleBranch = options.onToggleBranch ?? null, this.#readCollapsed = options.readCollapsed ?? null, this.#renderMode === "segmented-css" && (this.#domOwner.rootList.classList.add("ldp-segmented-branches"), this.scope.add(() => {
this.#domOwner.rootList.classList.remove("ldp-segmented-branches");
})), this.scope.listen(this.#domOwner.rootList, "click", (event) => {
const target = event.target;
if (!target || target.nodeType !== 1) return;
const element = target, toggle = element.closest(
"[data-reader-branch-toggle]"
);
if (toggle?.disabled) return;
const hit = element.closest(
".ldp-branch-hit-path"
), root = (toggle ?? hit)?.closest(
".ldp-post[data-post-number]"
);
if (!root) return;
const postNumber = Number(
toggle?.dataset.readerBranchToggle ?? root.dataset.postNumber
);
if (!Number.isSafeInteger(postNumber) || postNumber <= 0) return;
const wasCollapsed = this.#isCollapsed(postNumber), anchor = !wasCollapsed && (options.preserveCollapseAnchor?.(root) ?? !1) ? null : pointerAnchor(event, root, wasCollapsed);
event.preventDefault(), event.stopPropagation(), this.toggle(postNumber), restorePointerAnchor(
anchor,
this.#owned.get(postNumber)?.toggle ?? null
);
});
const resizeObserver = options.createResizeObserver?.(() => {
if (!this.scope.destroyed) {
if (options.onObservedResize) {
options.onObservedResize();
return;
}
this.paint(), this.#onLayoutChange();
}
}) ?? null;
resizeObserver && (resizeObserver.observe(this.#domOwner.rootList), this.scope.add(() => resizeObserver.disconnect())), this.scope.add(() => this.#restore());
}
paint() {
if (this.scope.destroyed)
return Object.freeze({
paintedBranches: 0
});
if (this.#sweep(), this.#renderMode === "segmented-css")
return this.#paintSegmentedBranches();
const { measurements, invalidViews } = this.#measure();
let paintedBranches = 0;
for (const view of invalidViews) this.#clear(view);
for (const measurement of measurements) {
const { view } = measurement, slots = view.slots, hasChildren = measurement.hasChildren;
if (this.#setClass(slots.root, "ldp-has-child-branches", hasChildren), !hasChildren) {
this.#clear(view);
continue;
}
const owned = this.#own(view), projectedOverlayLeft = measurement.overlayRect.left, projectedOverlayWidth = Math.max(1, measurement.overlayRect.width);
owned.toggle.hidden === hasChildren && (owned.toggle.hidden = !hasChildren);
const parentAxisX = measurement.avatarRect.left + measurement.avatarRect.width / 2 - projectedOverlayLeft, toggleLeft = `${measurement.avatarRect.left + measurement.avatarRect.width / 2 - measurement.rootRect.left}px`;
owned.toggle.style.left !== toggleLeft && (owned.toggle.style.left = toggleLeft);
const toggleTop = `${measurement.toggleCenterY}px`;
if (owned.toggle.style.top !== toggleTop && (owned.toggle.style.top = toggleTop), this.#syncCollapsed(view, owned.toggle), this.#isCollapsed(view.postNumber)) {
this.#clearPaths(slots);
continue;
}
const parentStartY = measurement.avatarRect.bottom - measurement.overlayRect.top + PARENT_AVATAR_DROP_GAP;
if (!measurement.childAvatarRects.length) {
this.#clearPaths(slots);
continue;
}
const childGeometry = measurement.childAvatarRects.map(
(childAvatarRect) => Object.freeze({
childAxisX: childAvatarRect.left - measurement.overlayRect.left - CHILD_AVATAR_GAP,
childCenterY: childAvatarRect.top + childAvatarRect.height / 2 - measurement.overlayRect.top
})
), geometry = deriveSharedBranchGeometry({
parentAxisX,
parentStartY,
children: childGeometry,
cornerRadius: measurement.cornerRadius
});
paintedBranches += measurement.childAvatarRects.length;
const path = geometry.path, visible = visiblePath(slots), hit = hitPath(slots);
slots.branchOverlay.hasAttribute("hidden") && slots.branchOverlay.removeAttribute("hidden"), this.#setAttribute(visible, "d", path), this.#setAttribute(hit, "d", path), this.#setStyle(
visible,
"stroke-width",
"var(--ldp-reply-line-width,1px)"
), this.#setStyle(
hit,
"stroke-width",
"var(--ldp-reply-line-hit-width,8px)"
);
const paintBlockSize = Math.max(
1,
Math.ceil(
Math.max(
parentStartY,
...childGeometry.map((child) => child.childCenterY)
) + measurement.cornerRadius + BRANCH_PAINT_PADDING_PX
)
);
this.#setStyle(
slots.branchOverlay,
"height",
`${paintBlockSize}px`
), this.#setAttribute(
slots.branchOverlay,
"viewBox",
`0 0 ${projectedOverlayWidth} ${paintBlockSize}`
);
}
return Object.freeze({ paintedBranches });
}
#paintSegmentedBranches() {
const views = this.#domOwner.views(), childrenByParent = /* @__PURE__ */ new Map();
for (const child of views) {
if (!child.slots.root.isConnected) continue;
const parentPostNumber = this.#domOwner.topology.parentOf(
child.postNumber
);
if (parentPostNumber == null) continue;
const children = childrenByParent.get(parentPostNumber) ?? [];
children.push(child), childrenByParent.set(parentPostNumber, children);
}
const lastChildByParent = /* @__PURE__ */ new Map();
for (const [parentPostNumber, mountedChildren] of childrenByParent) {
const lastChildPostNumber = this.#domOwner.topology.childrenOf?.(parentPostNumber)?.at(-1) ?? [...mountedChildren].sort((left, right) => left.postNumber - right.postNumber).at(-1)?.postNumber;
lastChildPostNumber !== void 0 && lastChildByParent.set(parentPostNumber, lastChildPostNumber);
}
const segmentedRootToggleTop = this.#measureSegmentedRootToggleTop(
views,
childrenByParent
);
let paintedBranches = 0;
for (const view of views) {
const parentPostNumber = this.#domOwner.topology.parentOf(
view.postNumber
);
this.#setClass(
view.slots.root,
"ldp-segmented-branch-last",
view.slots.root.isConnected && parentPostNumber !== null && parentPostNumber !== void 0 && lastChildByParent.get(parentPostNumber) === view.postNumber
);
const children = childrenByParent.get(view.postNumber) ?? [], hasChildren = view.slots.root.isConnected && children.length > 0;
if (this.#setClass(
view.slots.root,
"ldp-has-child-branches",
hasChildren
), this.#clearPaths(view.slots), !hasChildren) {
this.#clear(view);
continue;
}
const owned = this.#own(view);
this.#syncSegmentedRailToggles(owned, children), owned.toggle.style.left && owned.toggle.style.removeProperty("left"), view.postNumber !== 1 && owned.toggle.style.top && owned.toggle.style.removeProperty("top"), view.postNumber === 1 && segmentedRootToggleTop !== null && this.#setStyle(
owned.toggle,
"top",
`${segmentedRootToggleTop}px`
), owned.toggle.hidden && (owned.toggle.hidden = !1), this.#syncCollapsed(view, owned.toggle), !this.#isCollapsed(view.postNumber) && (paintedBranches += children.filter(
(child) => !child.slots.root.classList.contains("ldp-virtual-ancestor-shell")
).length);
}
return Object.freeze({ paintedBranches });
}
#measureSegmentedRootToggleTop(views, childrenByParent) {
const rootView = views.find((view) => view.postNumber === 1);
if (!rootView?.slots.root.isConnected || this.#isCollapsed(rootView.postNumber)) return null;
const childByRoot = new Map(
(childrenByParent.get(rootView.postNumber) ?? []).map((child) => [
child.slots.root,
child
])
);
let firstChild = null;
for (const element of rootView.slots.replyList.children) {
const child = childByRoot.get(element);
if (child?.slots.root.isConnected && !child.slots.root.classList.contains("ldp-virtual-ancestor-shell")) {
firstChild = child;
break;
}
}
if (!firstChild) return null;
const rootReplyTreeRect = rootView.slots.replyTree.getBoundingClientRect(), childRootRect = firstChild.slots.root.getBoundingClientRect(), childActionsRect = firstChild.slots.actions.getBoundingClientRect(), childReplyTreeRect = firstChild.slots.replyTree.getBoundingClientRect();
if (!finiteRect(rootReplyTreeRect) || !finiteRect(childRootRect) || !finiteRect(childActionsRect) || !finiteRect(childReplyTreeRect) || childActionsRect.height <= 0) return null;
const ordinaryToggleCenterY = childActionsRect.top + childActionsRect.height / 2, ordinaryTailDistance = childReplyTreeRect.top - ordinaryToggleCenterY;
if (!Number.isFinite(ordinaryTailDistance) || ordinaryTailDistance < 0)
return null;
const top = childRootRect.top - rootReplyTreeRect.top - ordinaryTailDistance;
return Number.isFinite(top) ? top : null;
}
toggle(postNumber) {
if (this.scope.destroyed) return;
const view = this.#domOwner.view(postNumber);
!view || !this.#hasChildren(postNumber) || (this.#onToggleBranch ? this.#onToggleBranch(postNumber) : this.#collapsed.has(postNumber) ? this.#collapsed.delete(postNumber) : this.#collapsed.add(postNumber), this.#syncCollapsed(view, this.#own(view).toggle), this.#onLayoutChange());
}
destroy() {
this.scope.destroy();
}
/**
* 释放当前树投影,但保留 controller 供同一 surface 下次重新挂载。
*
* 完整讨论关闭时不会再等待下一次 paint 才 sweep 已销毁的 PostView;主流切帖也可
* 用同一路径立即恢复接管前 margin/折叠/按钮状态并丢弃旧 View 引用。
*/
releaseProjection() {
this.scope.destroyed || this.#restore();
}
#measure() {
const measurements = [], invalidViews = [], views = this.#domOwner.views(), childrenByParent = /* @__PURE__ */ new Map(), geometryByPost = /* @__PURE__ */ new Map(), avatarByPost = /* @__PURE__ */ new Map(), transientPostNumbers = /* @__PURE__ */ new Set(), invalidPostNumbers = /* @__PURE__ */ new Set(), styleGeometryByVariant = /* @__PURE__ */ new Map(), invalidate = (view) => {
invalidPostNumbers.add(view.postNumber), invalidViews.includes(view) || invalidViews.push(view);
};
for (const child of views) {
const parentPostNumber = this.#domOwner.topology.parentOf(
child.postNumber
);
if (parentPostNumber == null)
continue;
const children = childrenByParent.get(parentPostNumber) ?? [];
children.push(child), childrenByParent.set(parentPostNumber, children);
}
for (const view of views) {
const slots = view.slots;
if (!slots.root.isConnected) {
invalidate(view);
continue;
}
const avatar = this.#readAvatar(slots);
if (!avatar) {
invalidate(view);
continue;
}
const avatarRect = avatar.getBoundingClientRect(), rootRect = slots.root.getBoundingClientRect(), overlayRect = slots.replyTree.getBoundingClientRect();
if (!finiteRect(avatarRect) || !finiteRect(rootRect) || !finiteRect(overlayRect)) {
invalidate(view);
continue;
}
if (avatarRect.width > 0 && avatarRect.height > 0 ? avatarByPost.set(view.postNumber, avatarRect) : transientPostNumbers.add(view.postNumber), avatarRect.width <= 0 || avatarRect.height <= 0 || rootRect.width <= 0 || rootRect.height <= 0 || overlayRect.width <= 0 || overlayRect.height <= 0) {
transientPostNumbers.add(view.postNumber);
continue;
}
geometryByPost.set(view.postNumber, Object.freeze({
view,
avatarRect,
rootRect,
overlayRect
}));
}
for (const snapshot of geometryByPost.values()) {
const { view, avatarRect, rootRect, overlayRect } = snapshot, slots = view.slots, collapsed = this.#isCollapsed(view.postNumber), styleKey = this.#styleGeometryKey(slots.root);
let styleGeometry = styleGeometryByVariant.get(styleKey);
if (!styleGeometry) {
const rootStyle = slots.root.ownerDocument.defaultView?.getComputedStyle?.(slots.root);
styleGeometry = Object.freeze({
cornerRadius: this.#cornerRadius(rootStyle)
}), styleGeometryByVariant.set(styleKey, styleGeometry);
}
const childAvatarRects = [], actionsRect = slots.actions.getBoundingClientRect(), retainedToggleTop = Number.parseFloat(
this.#owned.get(view.postNumber)?.toggle.style.top ?? ""
);
let toggleCenterY = collapsed && Number.isFinite(retainedToggleTop) ? retainedToggleTop : finiteRect(actionsRect) && actionsRect.height > 0 ? actionsRect.top + actionsRect.height / 2 - rootRect.top : 0;
const children = [...childrenByParent.get(view.postNumber) ?? []].sort((left, right) => left.postNumber - right.postNumber);
if (!collapsed) {
const drawableChildren = children.filter(
(child) => child.slots.root.isConnected && !child.slots.root.classList.contains(
"ldp-virtual-ancestor-shell"
)
);
if (drawableChildren.filter(
(child) => !avatarByPost.has(child.postNumber)
).some(
(child) => transientPostNumbers.has(child.postNumber) && !invalidPostNumbers.has(child.postNumber)
))
continue;
for (const child of drawableChildren) {
const childAvatarRect = avatarByPost.get(child.postNumber);
childAvatarRect && (this.#allowLongBranchSpans || Math.abs(
childAvatarRect.top + childAvatarRect.height / 2 - (avatarRect.bottom + PARENT_AVATAR_DROP_GAP)
) <= MAX_CONTINUOUS_BRANCH_SPAN_PX) && childAvatarRects.push(childAvatarRect);
}
childAvatarRects.sort((left, right) => left.top - right.top);
}
!collapsed && view.postNumber === 1 && childAvatarRects[0] && (toggleCenterY = childAvatarRects[0].top - rootRect.top - 10), measurements.push(Object.freeze({
view,
avatarRect,
rootRect,
overlayRect,
toggleCenterY,
hasChildren: children.length > 0,
childAvatarRects: Object.freeze(childAvatarRects),
cornerRadius: styleGeometry.cornerRadius
}));
}
return Object.freeze({
measurements: Object.freeze(measurements),
invalidViews: Object.freeze(invalidViews)
});
}
#own(view) {
const existing = this.#owned.get(view.postNumber);
if (existing?.view === view) return existing;
existing && this.#releaseOwned(existing);
const toggle = view.slots.root.ownerDocument.createElement("button");
toggle.type = "button", toggle.className = "ldp-collapse-replies ldp-reply-rail-control ldp-reader-branch-toggle show", toggle.dataset.readerBranchToggle = String(view.postNumber);
const count = view.slots.root.ownerDocument.createElement("span");
count.className = "ldp-collapsed-branch-count", count.hidden = !0, view.slots.replyControls.classList.add("ldp-branch-controls"), view.slots.root.insertBefore(toggle, view.slots.header), view.slots.header.after(count);
const trunkToggle = this.#renderMode === "segmented-css" ? view.slots.root.ownerDocument.createElement("button") : null;
trunkToggle && (trunkToggle.type = "button", trunkToggle.className = "ldp-reader-branch-trunk-toggle", trunkToggle.dataset.readerBranchToggle = String(view.postNumber), trunkToggle.tabIndex = -1, trunkToggle.setAttribute("aria-hidden", "true"), view.slots.body.append(trunkToggle));
const owned = Object.freeze({
view,
toggle,
count,
trunkToggle,
railToggles: /* @__PURE__ */ new Map()
});
return this.#owned.set(view.postNumber, owned), owned;
}
#syncSegmentedRailToggles(owned, children) {
const retained = /* @__PURE__ */ new Set();
for (const child of children) {
if (child.slots.root.classList.contains("ldp-virtual-ancestor-shell"))
continue;
retained.add(child.postNumber);
let railToggle = owned.railToggles.get(child.postNumber);
railToggle?.parentElement !== child.slots.root && (railToggle?.remove(), railToggle = child.slots.root.ownerDocument.createElement("button"), railToggle.type = "button", railToggle.className = "ldp-reader-branch-rail-toggle", railToggle.dataset.readerBranchToggle = String(
owned.view.postNumber
), railToggle.tabIndex = -1, railToggle.setAttribute("aria-hidden", "true"), child.slots.root.prepend(railToggle), owned.railToggles.set(child.postNumber, railToggle));
}
for (const [postNumber, railToggle] of owned.railToggles)
retained.has(postNumber) || (railToggle.remove(), owned.railToggles.delete(postNumber));
}
#clearRailToggles(owned) {
for (const railToggle of owned.railToggles.values())
railToggle.remove();
owned.railToggles.clear();
}
#syncCollapsed(view, toggle) {
const collapsed = this.#isCollapsed(view.postNumber);
if (this.#renderMode === "segmented-css") {
const owner = collapsed ? view.slots.root : view.postNumber === 1 ? view.slots.replyTree : view.slots.actions.hidden ? view.slots.root : view.slots.actions;
toggle.parentElement !== owner && (owner === view.slots.root ? view.slots.root.insertBefore(toggle, view.slots.header) : owner === view.slots.replyTree ? view.slots.replyTree.prepend(toggle) : view.slots.actions.prepend(toggle));
}
this.#setClass(
view.slots.root,
"ldp-branch-parent-collapsed",
collapsed
), view.slots.replyList.hidden !== collapsed && (view.slots.replyList.hidden = collapsed), collapsed && this.#clearPaths(view.slots);
const owned = this.#owned.get(view.postNumber), descendantCount = Math.max(
this.#subtreePostCount(view.postNumber) - 1,
1
);
owned && (owned.count.textContent = `(${descendantCount})`, owned.count.hidden = !collapsed, owned.trunkToggle && owned.trunkToggle.disabled !== collapsed && (owned.trunkToggle.disabled = collapsed));
for (const railToggle of owned?.railToggles.values() ?? [])
railToggle.disabled !== collapsed && (railToggle.disabled = collapsed);
const iconName = collapsed ? "plus" : "minus";
toggle.querySelector(":scope > .ldp-icon")?.classList.contains(`ldp-icon-${iconName}`) || toggle.replaceChildren((0, import_reader_icon.createReaderIcon)(
toggle.ownerDocument,
iconName
)), this.#setAttribute(toggle, "aria-expanded", String(!collapsed)), this.#setAttribute(
toggle,
"aria-label",
collapsed ? `展开 ${descendantCount} 条回复` : `收起 ${descendantCount} 条回复`
);
}
#subtreePostCount(parentPostNumber) {
const cached = this.#domOwner.topology.subtreePostCountOf?.(
parentPostNumber
);
if (cached !== void 0) return Math.max(1, cached);
const visited = /* @__PURE__ */ new Set(), pending = [parentPostNumber];
for (; pending.length; ) {
const postNumber = pending.pop();
if (visited.has(postNumber)) continue;
visited.add(postNumber);
const children = this.#domOwner.topology.childrenOf?.(postNumber) ?? this.#domOwner.views().filter(
(view) => this.#domOwner.topology.parentOf(view.postNumber) === postNumber
).map((view) => view.postNumber);
pending.push(...children);
}
return Math.max(1, visited.size);
}
#isCollapsed(postNumber) {
return this.#readCollapsed?.(postNumber) ?? this.#collapsed.has(postNumber);
}
#setAttribute(element, name, value) {
element.getAttribute(name) !== value && element.setAttribute(name, value);
}
#setStyle(element, name, value) {
element.style.getPropertyValue(name) !== value && element.style.setProperty(name, value);
}
#setClass(element, name, enabled) {
element.classList.contains(name) !== enabled && element.classList.toggle(name, enabled);
}
#styleGeometryKey(root) {
return `${root.classList.contains("ldp-nested-preview") ? "nested" : "root"}|${root.getAttribute("style") ?? ""}`;
}
#cornerRadius(style) {
const value = style?.getPropertyValue("--ldp-reply-line-radius"), parsed = Number.parseFloat(value ?? "");
return Number.isFinite(parsed) ? Math.max(0, parsed) : 15;
}
#clear(view) {
this.#setClass(view.slots.root, "ldp-has-child-branches", !1), this.#setClass(view.slots.root, "ldp-branch-parent-collapsed", !1), view.slots.replyList.hidden && (view.slots.replyList.hidden = !1), this.#collapsed.delete(view.postNumber);
const owned = this.#owned.get(view.postNumber);
owned && (owned.toggle.hidden || (owned.toggle.hidden = !0), owned.count.hidden = !0, this.#clearRailToggles(owned)), this.#clearPaths(view.slots);
}
#clearPaths(slots) {
const visible = visiblePath(slots), hit = hitPath(slots);
visible.hasAttribute("d") && visible.removeAttribute("d"), hit.hasAttribute("d") && hit.removeAttribute("d"), slots.branchOverlay.hasAttribute("hidden") || slots.branchOverlay.setAttribute("hidden", ""), slots.branchOverlay.style.height && slots.branchOverlay.style.removeProperty("height"), slots.branchOverlay.hasAttribute("viewBox") && slots.branchOverlay.removeAttribute("viewBox");
}
#sweep() {
for (const [postNumber, owned] of this.#owned)
this.#domOwner.view(postNumber) !== owned.view && (this.#releaseOwned(owned), this.#owned.delete(postNumber));
}
#hasChildren(postNumber) {
return this.#domOwner.views().some(
(view) => this.#domOwner.topology.parentOf(view.postNumber) === postNumber
);
}
#restore() {
for (const view of this.#domOwner.views())
this.#setClass(
view.slots.root,
"ldp-segmented-branch-last",
!1
);
for (const owned of this.#owned.values())
this.#releaseOwned(owned);
this.#owned.clear(), this.#collapsed.clear();
}
#releaseOwned(owned) {
const slots = owned.view.slots;
slots.replyList.hidden = !1, this.#setClass(slots.root, "ldp-has-child-branches", !1), this.#setClass(slots.root, "ldp-branch-parent-collapsed", !1), this.#setClass(slots.root, "ldp-segmented-branch-last", !1), this.#clearPaths(slots), owned.toggle.remove(), owned.count.remove(), owned.trunkToggle?.remove(), this.#clearRailToggles(owned), slots.replyControls.classList.remove("ldp-branch-controls");
}
}
}, "66dfd742354962c70761489b6ef31621512979cf03a30c3dca4b4260c023b364");
/* Source: lite/src/layout/reader-layout-style-controller.ts */
runtime.register("src/layout/reader-layout-style-controller.js", function(module, exports, require) {
var reader_layout_style_controller_exports = {};
__export(reader_layout_style_controller_exports, {
ReaderLayoutStyleController: () => ReaderLayoutStyleController,
readerPreferencesLayoutAdapter: () => readerPreferencesLayoutAdapter
});
module.exports = __toCommonJS(reader_layout_style_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js"), import_reader_preferences_schema = require("../state/reader-preferences-schema.js");
const readerPreferencesLayoutAdapter = Object.freeze({
readProfile: (preferences, mode) => mode === "fullpage" ? preferences.fullpageLayoutProfile : preferences.layoutProfile,
createPatch: (profile, mode) => mode === "fullpage" ? { fullpageLayoutProfile: profile } : { layoutProfile: profile }
});
function sameProfile(left, right) {
return import_reader_preferences_schema.READER_LAYOUT_REGIONS.every(
(region) => left[region] === right[region]
);
}
class ReaderLayoutStyleController {
scope;
changes = new import_signal.Signal();
#root;
#adapter;
#modePort;
#original = /* @__PURE__ */ new Map();
#previews = /* @__PURE__ */ new Map();
#preferences;
#mode;
#snapshot;
constructor(options) {
this.#root = options.root, this.#adapter = options.preferences, this.#modePort = options.mode, this.#preferences = options.readPreferences(), this.#mode = this.#modePort.read(), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
for (const region of import_reader_preferences_schema.READER_LAYOUT_REGIONS) {
const property = `--ldp-layout-${region}`;
this.#original.set(property, Object.freeze({
value: this.#root.style.getPropertyValue(property),
priority: typeof this.#root.style.getPropertyPriority == "function" ? this.#root.style.getPropertyPriority(property) : ""
}));
}
this.#snapshot = this.#commit(), options.preferenceChanges.subscribe((preferences) => {
this.#preferences = preferences, this.#publish();
}, this.scope), this.#modePort.subscribe((mode) => {
mode !== this.#mode && (this.#mode = mode, this.#publish());
}, this.scope), this.scope.add(() => {
this.changes.clear(), this.#previews.clear();
for (const [property, previous] of this.#original)
previous.value ? this.#root.style.setProperty(
property,
previous.value,
previous.priority
) : this.#root.style.removeProperty(property);
});
}
get snapshot() {
return this.#snapshot;
}
get mode() {
return this.#mode;
}
profile(mode) {
return this.#adapter.readProfile(this.#preferences, mode);
}
readProfile(preferences, mode) {
return this.#adapter.readProfile(preferences, mode);
}
createPatch(profile, mode) {
return this.#adapter.createPatch(profile, mode);
}
preview(profile, mode = this.#mode) {
if (this.scope.destroyed) return;
const previous = this.#previews.get(mode);
previous && sameProfile(previous, profile) || (this.#previews.set(mode, Object.freeze({ ...profile })), mode === this.#mode && this.#publish());
}
clearPreview(mode) {
if (this.scope.destroyed) return;
if (mode) {
if (!this.#previews.delete(mode)) return;
mode === this.#mode && this.#publish();
return;
}
if (this.#previews.size === 0) return;
const currentChanged = this.#previews.has(this.#mode);
this.#previews.clear(), currentChanged && this.#publish();
}
destroy() {
this.scope.destroy();
}
#publish() {
this.#snapshot = this.#commit(), this.changes.emit(this.#snapshot);
}
#commit() {
const preview = this.#previews.get(this.#mode), profile = preview ?? this.profile(this.#mode);
for (const region of import_reader_preferences_schema.READER_LAYOUT_REGIONS)
this.#root.style.setProperty(
`--ldp-layout-${region}`,
`${profile[region]}%`
);
return Object.freeze({
mode: this.#mode,
profile,
previewing: !!preview
});
}
}
}, "4feaeadcb3666626a6f01d91293edf33547d4b33c8ece5408edcd3840f98e1ee");
/* Source: lite/src/live/reader-topic-live-navigation-controller.ts */
runtime.register("src/live/reader-topic-live-navigation-controller.js", function(module, exports, require) {
var reader_topic_live_navigation_controller_exports = {};
__export(reader_topic_live_navigation_controller_exports, {
ReaderTopicLiveNavigationController: () => ReaderTopicLiveNavigationController
});
module.exports = __toCommonJS(reader_topic_live_navigation_controller_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js");
function postNumberFromChange(change) {
return change.kind !== "post" || change.created !== !0 || change.wasKnown === !0 ? null : (0, import_identifiers.tryDiscoursePostNumber)(
change.post.post_number
);
}
class ReaderTopicLiveNavigationController {
scope;
changes = new import_signal.Signal();
#navigation;
#onError;
#snapshot = Object.freeze({
nearEnd: !1,
pendingPostNumbers: Object.freeze([]),
targetPostNumber: null,
pendingCount: 0,
dismissed: !1,
jumping: !1
});
#jumpEpoch = 0;
constructor(options) {
this.#navigation = options.navigation, this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), options.live.changes.subscribe((change) => {
const postNumber = postNumberFromChange(change);
if (postNumber !== null) {
if (this.#snapshot.nearEnd) {
this.#jump(postNumber, !0);
return;
}
this.#queue(postNumber);
}
}, this.scope), this.scope.add(() => {
this.#jumpEpoch += 1, this.changes.clear();
});
}
get snapshot() {
return this.#snapshot;
}
syncViewport(input) {
const nearEnd = input.atEnd === !0;
return this.#snapshot.nearEnd === nearEnd ? this.#snapshot : this.#commit({
...this.#snapshot,
nearEnd
});
}
jumpPending() {
this.#assertActive();
const target = this.#snapshot.targetPostNumber;
return target === null ? Promise.resolve(null) : this.#jump(target, !1);
}
dismiss() {
return this.#assertActive(), this.#snapshot.pendingCount ? this.#commit({
...this.#snapshot,
dismissed: !0
}) : this.#snapshot;
}
clear() {
return this.#assertActive(), this.#jumpEpoch += 1, this.#commit({
...this.#snapshot,
pendingPostNumbers: Object.freeze([]),
targetPostNumber: null,
pendingCount: 0,
dismissed: !1,
jumping: !1
});
}
destroy() {
this.scope.destroy();
}
#queue(postNumber) {
const pending = new Set(this.#snapshot.pendingPostNumbers);
pending.add(postNumber);
const pendingPostNumbers = Object.freeze(
[...pending].sort((left, right) => left - right)
);
this.#commit({
...this.#snapshot,
pendingPostNumbers,
targetPostNumber: pendingPostNumbers[0] ?? null,
pendingCount: pendingPostNumbers.length,
dismissed: !1
});
}
async #jump(postNumber, automatic) {
this.#assertActive();
const epoch = ++this.#jumpEpoch, jumpedBatch = /* @__PURE__ */ new Set([
...this.#snapshot.pendingPostNumbers,
postNumber
]);
this.#commit({
...this.#snapshot,
jumping: !0
});
try {
const result = await this.#navigation.navigate({
postNumber,
source: "message",
alignment: automatic ? "nearest" : "center",
highlight: !0
});
if (epoch !== this.#jumpEpoch || this.scope.destroyed) return result;
if (result.status === "revealed") {
const pendingPostNumbers = Object.freeze(
this.#snapshot.pendingPostNumbers.filter(
(pendingPostNumber) => !jumpedBatch.has(pendingPostNumber)
)
);
this.#commit({
...this.#snapshot,
pendingPostNumbers,
targetPostNumber: pendingPostNumbers[0] ?? null,
pendingCount: pendingPostNumbers.length,
dismissed: pendingPostNumbers.length ? this.#snapshot.dismissed : !1,
jumping: !1
});
} else
this.#queue(postNumber), this.#commit({
...this.#snapshot,
jumping: !1
});
return result;
} catch (cause) {
throw epoch === this.#jumpEpoch && !this.scope.destroyed && (this.#queue(postNumber), this.#commit({
...this.#snapshot,
jumping: !1
}), this.#onError(cause)), cause;
}
}
#commit(input) {
const snapshot = Object.freeze({
nearEnd: input.nearEnd,
pendingPostNumbers: Object.freeze([...input.pendingPostNumbers]),
targetPostNumber: input.targetPostNumber,
pendingCount: input.pendingCount,
dismissed: input.dismissed,
jumping: input.jumping
});
this.#snapshot = snapshot;
for (const cause of this.changes.emit(snapshot)) this.#onError(cause);
return snapshot;
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("ReaderTopicLiveNavigationController 已销毁");
}
}
}, "7f09e77612805e96f6be1113157d49f8b6fa5b96495168f9146e0245932a5067");
/* Source: lite/src/live/reader-topic-live-navigation-view.ts */
runtime.register("src/live/reader-topic-live-navigation-view.js", function(module, exports, require) {
var reader_topic_live_navigation_view_exports = {};
__export(reader_topic_live_navigation_view_exports, {
ReaderTopicLiveNavigationView: () => ReaderTopicLiveNavigationView
});
module.exports = __toCommonJS(reader_topic_live_navigation_view_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
class ReaderTopicLiveNavigationView {
scope;
#navigation;
#elements;
#notify;
constructor(options) {
this.#navigation = options.navigation, this.#elements = options.elements, this.#notify = options.notify ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.listen(this.#elements.jump, "click", () => {
this.#jump();
}), this.scope.listen(this.#elements.dismiss, "click", () => {
this.#navigation.dismiss();
}), this.#navigation.changes.subscribe((snapshot) => {
this.#sync(snapshot);
}, this.scope), this.scope.add(() => {
this.#elements.root.hidden = !0, this.#elements.root.removeAttribute("aria-busy"), this.#elements.label.textContent = "";
}), this.#sync(this.#navigation.snapshot);
}
destroy() {
this.scope.destroy();
}
#sync(snapshot) {
const hidden = snapshot.pendingCount <= 0 || snapshot.dismissed || snapshot.targetPostNumber === null;
this.#elements.root.hidden !== hidden && (this.#elements.root.hidden = hidden);
const busy = String(snapshot.jumping);
this.#elements.root.getAttribute("aria-busy") !== busy && this.#elements.root.setAttribute("aria-busy", busy);
const disabled = snapshot.jumping || hidden;
this.#elements.jump.disabled !== disabled && (this.#elements.jump.disabled = disabled), this.#elements.dismiss.disabled !== disabled && (this.#elements.dismiss.disabled = disabled);
const label = snapshot.pendingCount === 1 ? "查看 1 个新回复" : `查看 ${snapshot.pendingCount} 个新回复`, labelText = hidden ? "" : label;
this.#elements.label.textContent !== labelText && (this.#elements.label.textContent = labelText);
const ariaLabel = snapshot.targetPostNumber === null ? "查看新回复" : `${label},从楼层 #${snapshot.targetPostNumber} 开始`;
this.#elements.jump.getAttribute("aria-label") !== ariaLabel && this.#elements.jump.setAttribute("aria-label", ariaLabel);
}
#jump() {
this.#navigation.snapshot.jumping || this.#navigation.jumpPending().then((result) => {
this.scope.destroyed || result && result.status !== "revealed" && result.status !== "superseded" && this.#notify("新回复暂时无法定位,请重试");
}).catch(() => {
this.scope.destroyed || this.#notify("新回复加载失败,请重试");
});
}
}
}, "169a4f1c1c301e73f26e1abb3ebb6237cfe32f43074316d856f0f023a5cb4516");
/* Source: lite/src/live/topic-live-controller.ts */
runtime.register("src/live/topic-live-controller.js", function(module, exports, require) {
var topic_live_controller_exports = {};
__export(topic_live_controller_exports, {
TopicLiveController: () => TopicLiveController,
normalizeTopicLiveMessage: () => normalizeTopicLiveMessage
});
module.exports = __toCommonJS(topic_live_controller_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js");
const POST_REFRESH_MESSAGE_TYPES = /* @__PURE__ */ new Set([
"created",
"rebaked",
"recovered",
"revised"
]), DELETION_MESSAGE_TYPES = /* @__PURE__ */ new Set(["deleted", "destroyed"]), TOPIC_STATS_MESSAGE_TYPE = "stats", BOOST_ADDED_MESSAGE_TYPE = "boost_added", BOOST_REMOVED_MESSAGE_TYPE = "boost_removed";
function delay(value, fallback, name) {
const numeric = Number(value ?? fallback);
if (!Number.isFinite(numeric) || numeric < 0)
throw new RangeError(`${name} 必须是非负有限毫秒`);
return numeric;
}
function payloadFromMessage(message) {
if (!message || typeof message != "object") return {};
const record = message;
return record.payload && typeof record.payload == "object" ? record.payload : record;
}
function objectRecord(value) {
return value && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function positiveInteger(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : null;
}
function requestPressureFailure(error) {
const record = objectRecord(error), status = Number(record?.status ?? 0), code = String(record?.code ?? "").trim(), name = String(record?.name ?? "").trim();
return status === 429 || record?.cloudflareMitigated === !0 || name === "AbortError" || ["cancelled", "queue-limit"].includes(code);
}
function username(value) {
return String(value ?? "").trim().replace(/^@+/, "").toLocaleLowerCase();
}
function boostUser(boost) {
const user = objectRecord(boost.user);
return Object.freeze({
id: positiveInteger(user?.id ?? boost.user_id),
username: username(user?.username ?? boost.username)
});
}
function normalizeTopicLiveMessage(message, rawTopicId) {
const topicId = (0, import_identifiers.discourseTopicId)(rawTopicId), envelope = message && typeof message == "object" ? message : {}, payload = payloadFromMessage(message), messageTopicId = (0, import_identifiers.tryDiscourseTopicId)(
envelope.topic_id ?? payload.topic_id
);
if (messageTopicId !== null && messageTopicId !== topicId)
return Object.freeze({ kind: "ignore" });
const messageType = String(payload.type ?? "").trim(), reactionPostId = Array.isArray(payload.reactions) || messageType === "acted" ? (0, import_identifiers.tryDiscoursePostId)(payload.post_id ?? payload.id) : null, typedPostId = POST_REFRESH_MESSAGE_TYPES.has(messageType) || DELETION_MESSAGE_TYPES.has(messageType) ? (0, import_identifiers.tryDiscoursePostId)(payload.post_id) : null, postId = reactionPostId ?? typedPostId ?? (0, import_identifiers.tryDiscoursePostId)(payload.id);
if (messageType === "read") return Object.freeze({ kind: "ignore" });
if (reactionPostId !== null)
return Object.freeze({ kind: "reaction", postId: reactionPostId });
if (postId !== null && messageType === BOOST_ADDED_MESSAGE_TYPE) {
const boost = objectRecord(payload.boost);
if (boost && positiveInteger(boost.id) !== null)
return Object.freeze({ kind: "boost-added", postId, boost });
}
if (postId !== null && messageType === BOOST_REMOVED_MESSAGE_TYPE) {
const boostId = positiveInteger(payload.boost_id);
if (boostId !== null)
return Object.freeze({ kind: "boost-removed", postId, boostId });
}
return postId !== null && DELETION_MESSAGE_TYPES.has(messageType) ? Object.freeze({
kind: "post-delete",
postId
}) : postId !== null && POST_REFRESH_MESSAGE_TYPES.has(messageType) ? Object.freeze({
kind: "post",
postId,
created: messageType === "created"
}) : Object.freeze(messageType === TOPIC_STATS_MESSAGE_TYPE ? {
kind: "topic-stats",
postsCount: positiveInteger(payload.posts_count)
} : { kind: "ignore" });
}
class TopicLiveController {
topicId;
scope;
changes = new import_signal.Signal();
#messageBus;
#session;
#cache;
#currentUsername;
#postDelayMs;
#reactionDelayMs;
#reactionBatchSize;
#topicDelayMs;
#setTimer;
#clearTimer;
#onError;
#subscriptions = [];
#pendingPosts = /* @__PURE__ */ new Map();
#pendingReactionPostIds = /* @__PURE__ */ new Set();
#fullRefreshReasons = /* @__PURE__ */ new Set();
#tasks = /* @__PURE__ */ new Set();
#reactionRefreshTimer = 0;
#reactionRefreshRunning = !1;
#reactionRefreshSuppressed = !1;
#fullRefreshTimer = 0;
#fullRefreshRunning = !1;
#activationEpoch = 0;
#active = !1;
#closed = !1;
constructor(options) {
if (this.topicId = (0, import_identifiers.discourseTopicId)(options.topicId), options.session.topicId !== this.topicId)
throw new Error("TopicLiveController 与 TopicSession topicId 不一致");
this.#messageBus = options.messageBus, this.#session = options.session, this.#cache = options.cache ?? null, this.#currentUsername = username(options.currentUsername), this.#postDelayMs = delay(options.postDelayMs, 120, "postDelayMs"), this.#reactionDelayMs = delay(
options.reactionDelayMs,
1500,
"reactionDelayMs"
);
const reactionBatchSize = positiveInteger(options.reactionBatchSize ?? 20);
if (reactionBatchSize === null)
throw new RangeError("reactionBatchSize 必须是正安全整数");
this.#reactionBatchSize = reactionBatchSize, this.#topicDelayMs = delay(options.topicDelayMs, 350, "topicDelayMs"), this.#setTimer = options.setTimer ?? ((callback, milliseconds) => setTimeout(callback, milliseconds)), this.#clearTimer = options.clearTimer ?? clearTimeout, this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.scope), this.scope.add(() => {
this.#closed = !0, this.#deactivate(), this.changes.clear();
});
}
get active() {
return this.#active;
}
start() {
if (this.#assertOpen(), this.#active) return !0;
const handler = (message) => {
this.#handleMessage(message);
}, topicSubscription = {
channel: `/topic/${this.topicId}`,
handler
};
try {
this.#messageBus.subscribe(
topicSubscription.channel,
topicSubscription.handler
);
} catch (error) {
return this.#onError(error), !1;
}
this.#subscriptions.push(topicSubscription);
const reactionSubscription = {
channel: `/topic/${this.topicId}/reactions`,
handler
};
try {
this.#messageBus.subscribe(
reactionSubscription.channel,
reactionSubscription.handler
), this.#subscriptions.push(reactionSubscription);
} catch (error) {
this.#onError(error);
}
return this.#activationEpoch += 1, this.#active = !0, !0;
}
setActive(active, options = {}) {
if (this.#assertOpen(), !active)
return this.#deactivate(), !1;
const started = this.start();
return started && options.refresh === !0 && this.scheduleTopicRefresh("resume"), started;
}
scheduleTopicRefresh(reason = "explicit") {
if (!this.#active || this.#closed || (this.#fullRefreshReasons.add(String(reason || "explicit")), this.#fullRefreshTimer || this.#fullRefreshRunning)) return;
const epoch = this.#activationEpoch;
this.#fullRefreshTimer = this.#setTimer(() => {
this.#fullRefreshTimer = 0, this.#track(this.#refreshTopic(epoch));
}, this.#topicDelayMs);
}
/**
* 宿主 app-event 已携带完整 Post model 时直接提交当前 canonical。
* 这里只消费已加载楼层,不创建 stream、不发请求;缺少权威字段的 MessageBus
* 事件继续沿既有单帖刷新路径处理。
*/
ingestPostDelta(value) {
if (!this.#active || this.#closed) return !1;
const ingest = this.#session.ingestPosts, delta = objectRecord(value), postId = (0, import_identifiers.tryDiscoursePostId)(delta?.id);
if (!ingest || !delta || postId === null) return !1;
const current = this.#session.postById(postId);
if (!current) return !1;
const currentRecord = current;
if ((0, import_identifiers.tryDiscourseTopicId)(
delta.topic_id ?? currentRecord.topic_id
) !== this.topicId) return !1;
const next = Object.freeze({
...currentRecord,
...delta
});
try {
ingest.call(this.#session, [next], "message-bus");
const canonical = this.#session.postById(postId) ?? next;
return this.#emit(Object.freeze({
kind: "post",
postId,
post: canonical,
created: !1,
wasKnown: !0
})), this.#track(this.#invalidate([`post:${postId}`])), !0;
} catch (error) {
return this.#onError(error), !1;
}
}
async flush() {
for (; this.#tasks.size; )
await Promise.allSettled([...this.#tasks]);
}
destroy() {
this.scope.destroy();
}
#handleMessage(message) {
if (!this.#active || this.#closed) return;
let normalized;
try {
normalized = normalizeTopicLiveMessage(message, this.topicId);
} catch (error) {
this.#onError(error);
return;
}
if (normalized.kind === "ignore") return;
if (normalized.kind === "reaction") {
this.#queueReactionRefresh(normalized.postId);
return;
}
if (normalized.kind === "topic-stats") {
this.#pendingPosts.size === 0 && this.#statsRequireTopicRefresh(normalized.postsCount) && this.scheduleTopicRefresh("stats");
return;
}
if (normalized.kind === "boost-added" || normalized.kind === "boost-removed") {
this.#commitBoostDelta(normalized);
return;
}
const postChange = normalized, known = this.#session.postById(postChange.postId) !== void 0;
if (postChange.kind === "post-delete" && !known || postChange.kind === "post" && !postChange.created && !known) return;
postChange.kind === "post" && postChange.created && this.#cancelStatsRefresh();
const current = this.#pendingPosts.get(postChange.postId);
if (current) {
if (postChange.kind === "post-delete")
current.operation = "delete", current.created = !1;
else {
const recoveringFromDelete = current.operation === "delete";
current.operation = "refresh", current.created = recoveringFromDelete ? postChange.created : current.created || postChange.created;
}
return;
}
const epoch = this.#activationEpoch, pending = {
postId: postChange.postId,
operation: postChange.kind === "post-delete" ? "delete" : "refresh",
created: postChange.kind === "post" && postChange.created,
timerId: this.#setTimer(() => {
this.#pendingPosts.delete(postChange.postId), this.#track(
pending.operation === "delete" ? this.#deletePost(postChange.postId, epoch) : this.#refreshPost(postChange.postId, pending.created, epoch)
);
}, this.#postDelayMs)
};
this.#pendingPosts.set(postChange.postId, pending);
}
#commitBoostDelta(message) {
const ingest = this.#session.ingestPosts, current = this.#session.postById(message.postId);
if (!ingest || !current) return !1;
const currentRecord = current, currentBoosts = Array.isArray(currentRecord.boosts) ? currentRecord.boosts.map((value) => objectRecord(value)).filter((value) => value !== null) : [];
let nextBoosts, ownBoostChanged = !1;
if (message.kind === "boost-added") {
const incomingIdentity = boostUser(message.boost);
let replaced = !1;
nextBoosts = Object.freeze(currentBoosts.map((boost) => {
const identity = boostUser(boost), sameBoost = positiveInteger(boost.id) === positiveInteger(message.boost.id), sameUser = incomingIdentity.id !== null && identity.id === incomingIdentity.id;
return !sameBoost && !sameUser ? boost : (replaced = !0, Object.freeze({ ...boost, ...message.boost }));
})), replaced || (nextBoosts = Object.freeze([
...nextBoosts,
Object.freeze({ ...message.boost })
])), ownBoostChanged = !!(this.#currentUsername && incomingIdentity.username === this.#currentUsername);
} else {
const removed = currentBoosts.find((boost) => positiveInteger(boost.id) === message.boostId);
nextBoosts = Object.freeze(currentBoosts.filter((boost) => positiveInteger(boost.id) !== message.boostId)), ownBoostChanged = !!(removed && this.#currentUsername && boostUser(removed).username === this.#currentUsername);
}
const next = Object.freeze({
...currentRecord,
boosts: nextBoosts,
...ownBoostChanged ? { can_boost: message.kind === "boost-removed" } : {}
});
try {
ingest.call(this.#session, [next], "message-bus");
const canonical = this.#session.postById(message.postId) ?? next;
return this.#emit(Object.freeze({
kind: "post",
postId: message.postId,
post: canonical,
created: !1,
wasKnown: !0
})), this.#track(this.#invalidate([`post:${message.postId}`])), !0;
} catch (error) {
return this.#onError(error), !1;
}
}
async #deletePost(postId, epoch) {
if (this.#acceptsWork(epoch) && this.#session.postById(postId))
try {
const preserved = this.#session.preserveDeletedPostById(postId);
if (!this.#acceptsWork(epoch)) return;
this.#emit(Object.freeze({
kind: "deleted",
postId,
postNumber: preserved.postNumber
})), preserved.topicArchived && this.#deactivate();
} catch (error) {
this.#onError(error);
}
}
async #refreshPost(postId, created, epoch) {
if (!this.#acceptsWork(epoch)) return;
const wasKnown = this.#session.postById(postId) !== void 0, createdPostMissing = created && !wasKnown;
if (!(!createdPostMissing && !wasKnown)) {
await this.#invalidate([`post:${postId}`]);
try {
const post = await this.#session.loadPostById(postId, {
background: !0,
created: createdPostMissing
});
if (!post || !this.#acceptsWork(epoch)) {
!post && createdPostMissing && this.#acceptsWork(epoch) && this.scheduleTopicRefresh("created-post-missing");
return;
}
this.#emit(Object.freeze({
kind: "post",
postId: (0, import_identifiers.discoursePostId)(postId),
post,
created: createdPostMissing,
wasKnown
}));
} catch (error) {
this.#onError(error), createdPostMissing && this.#acceptsWork(epoch) && !requestPressureFailure(error) && this.scheduleTopicRefresh("created-post-refresh-failed");
}
}
}
#queueReactionRefresh(postId) {
if (this.#reactionRefreshSuppressed || !this.#session.loadPostsByIds || !this.#session.postById(postId) || (this.#pendingReactionPostIds.delete(postId), this.#pendingReactionPostIds.add(postId), this.#reactionRefreshRunning)) return;
this.#reactionRefreshTimer && this.#clearTimer(this.#reactionRefreshTimer);
const epoch = this.#activationEpoch;
this.#reactionRefreshTimer = this.#setTimer(() => {
this.#reactionRefreshTimer = 0, this.#track(this.#refreshReactions(epoch));
}, this.#reactionDelayMs);
}
async #refreshReactions(epoch) {
const loadPostsByIds = this.#session.loadPostsByIds;
if (!this.#acceptsWork(epoch) || this.#reactionRefreshRunning || this.#reactionRefreshSuppressed || !loadPostsByIds) return;
const pending = [...this.#pendingReactionPostIds];
this.#pendingReactionPostIds.clear();
const postIds = pending.slice(-this.#reactionBatchSize).filter((postId) => this.#session.postById(postId) !== void 0);
if (postIds.length) {
this.#reactionRefreshRunning = !0;
try {
await this.#invalidate(postIds.map((postId) => `post:${postId}`));
const result = await loadPostsByIds.call(this.#session, postIds, {
background: !0,
refresh: !0,
maxAttempts: 1,
ingestSource: "target-refresh"
});
if (!this.#acceptsWork(epoch)) return;
const requested = new Set(postIds);
for (const post of result.posts) {
const postId = (0, import_identifiers.tryDiscoursePostId)(post.id);
postId === null || !requested.has(postId) || this.#emit(Object.freeze({
kind: "post",
postId,
post,
created: !1,
wasKnown: !0
}));
}
} catch (error) {
this.#acceptsWork(epoch) && (this.#reactionRefreshSuppressed = !0, this.#pendingReactionPostIds.clear(), this.#onError(error));
} finally {
if (this.#reactionRefreshRunning = !1, this.#active && !this.#closed && !this.#reactionRefreshSuppressed && this.#pendingReactionPostIds.size) {
const postId = [...this.#pendingReactionPostIds].at(-1);
postId !== void 0 && this.#queueReactionRefresh(postId);
}
}
}
}
#statsRequireTopicRefresh(postsCount) {
if (postsCount === null || !this.#session.streamPostIds) return !1;
try {
return postsCount > this.#session.streamPostIds().length;
} catch (error) {
return this.#onError(error), !1;
}
}
async #refreshTopic(epoch) {
if (!this.#acceptsWork(epoch) || this.#fullRefreshRunning) return;
this.#fullRefreshRunning = !0;
const reasons = Object.freeze([...this.#fullRefreshReasons].sort());
this.#fullRefreshReasons.clear();
try {
const topic = await this.#session.refresh({ background: !0 });
this.#acceptsWork(epoch) && this.#emit(Object.freeze({ kind: "topic", topic, reasons }));
} catch (error) {
this.#onError(error);
} finally {
this.#fullRefreshRunning = !1, this.#active && !this.#closed && this.#fullRefreshReasons.size && !this.#fullRefreshTimer && this.scheduleTopicRefresh("coalesced");
}
}
async #invalidate(tags) {
if (this.#cache)
try {
await this.#cache.invalidate({ tags });
} catch (error) {
this.#onError(error);
}
}
#emit(change) {
for (const error of this.changes.emit(change)) this.#onError(error);
}
#track(task) {
this.#tasks.add(task), task.finally(() => {
this.#tasks.delete(task);
}).catch(() => {
});
}
#cancelStatsRefresh() {
this.#fullRefreshReasons.delete("stats") && (this.#fullRefreshReasons.size > 0 || !this.#fullRefreshTimer || (this.#clearTimer(this.#fullRefreshTimer), this.#fullRefreshTimer = 0));
}
#deactivate() {
this.#activationEpoch += 1, this.#active = !1, this.#fullRefreshTimer && this.#clearTimer(this.#fullRefreshTimer), this.#fullRefreshTimer = 0, this.#fullRefreshReasons.clear(), this.#reactionRefreshTimer && this.#clearTimer(this.#reactionRefreshTimer), this.#reactionRefreshTimer = 0, this.#pendingReactionPostIds.clear();
for (const pending of this.#pendingPosts.values())
this.#clearTimer(pending.timerId);
this.#pendingPosts.clear();
for (const subscription of this.#subscriptions.splice(0).reverse())
try {
this.#messageBus.unsubscribe(subscription.channel, subscription.handler);
} catch (error) {
this.#onError(error);
}
}
#assertOpen() {
if (this.#closed) throw new Error("TopicLiveController 已销毁");
}
#acceptsWork(epoch) {
return this.#active && !this.#closed && epoch === this.#activationEpoch;
}
}
}, "8aa0c2a80e1a824f1e231b439090c363dd90e40478ed5733ff9cacedbd6b1bb9");
/* Source: lite/src/shell/embedded-host-appearance.ts */
runtime.register("src/shell/embedded-host-appearance.js", function(module, exports, require) {
var embedded_host_appearance_exports = {};
__export(embedded_host_appearance_exports, {
EmbeddedHostAppearanceController: () => EmbeddedHostAppearanceController,
measureEmbeddedHostTopicRowHeight: () => measureEmbeddedHostTopicRowHeight
});
module.exports = __toCommonJS(embedded_host_appearance_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_reader_workspace = require("./reader-workspace.js");
const STYLE_PROPERTIES = Object.freeze([
"--ldp-reader-host-min-width",
"--ldp-reader-native-row-height"
]), ROOT_STYLE_PROPERTIES = Object.freeze([
"--ldp-reader-list-zebra-color",
"--ldp-divider-line-color",
"--ldp-divider-line-width"
]);
function measureEmbeddedHostTopicRowHeight(documentPort, overlay) {
const heights = [...documentPort.querySelectorAll(
"tr.topic-list-item,.topic-list-item,.latest-topic-list-item"
)].filter((node) => !node.closest(".ldp-overlay") && !overlay.contains(node)).map((node) => {
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height >= 48 && rect.height <= 320 ? Math.round(rect.height) : 0;
}).filter((height) => height > 0).sort((left, right) => left - right);
return heights.length ? heights[Math.floor((heights.length - 1) / 2)] : 0;
}
class EmbeddedHostAppearanceController {
scope;
#workspace;
#pageRoot;
#overlay;
#readAppearance;
#measureRowHeight;
#hostMinWidth;
#active = !1;
#rowHeight = 0;
#destroyed = !1;
constructor(options) {
this.#workspace = options.workspace, this.#pageRoot = options.pageRoot, this.#overlay = options.overlay, this.#readAppearance = options.readAppearance, this.#measureRowHeight = options.measureRowHeight ?? (() => measureEmbeddedHostTopicRowHeight(
this.#pageRoot.ownerDocument,
this.#overlay
)), this.#hostMinWidth = Math.max(
1,
Math.round(options.hostMinWidth ?? import_reader_workspace.READER_HOST_MIN_WIDTH)
), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#workspace.changes.subscribe(() => this.#syncActivation(), this.scope), options.appearanceChanges?.subscribe(() => {
this.#active && this.#applyAppearance();
}, this.scope), this.scope.add(() => this.#clear()), this.#syncActivation();
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#syncActivation() {
if (this.#destroyed) return;
const active = this.#workspace.snapshot.presentation.embedded;
active && !this.#active ? (this.#active = !0, this.#rowHeight = Math.max(0, Math.round(this.#measureRowHeight())), this.#applyAppearance()) : !active && this.#active && (this.#active = !1, this.#clear());
}
#applyAppearance() {
const appearance = this.#readAppearance(), profile = appearance.profile, dark = appearance.theme === "dark", zebraColor = dark ? profile.listZebraColorDark : profile.listZebraColor, dividerColor = dark ? profile.dividerLineColorDark : profile.dividerLineColor;
for (const target of [this.#pageRoot, this.#overlay])
target.style.setProperty(
"--ldp-reader-host-min-width",
`${this.#hostMinWidth}px`
), this.#rowHeight ? target.style.setProperty(
"--ldp-reader-native-row-height",
`${this.#rowHeight}px`
) : target.style.removeProperty("--ldp-reader-native-row-height");
this.#pageRoot.style.setProperty("--ldp-reader-list-zebra-color", zebraColor), this.#pageRoot.classList.toggle(
"ldp-structure-colors-disabled",
!profile.structureColorsEnabled
), !profile.structureColorsEnabled || dividerColor === appearance.defaultDividerLineColor ? this.#pageRoot.style.removeProperty("--ldp-divider-line-color") : this.#pageRoot.style.setProperty("--ldp-divider-line-color", dividerColor), profile.dividerLineWidth === appearance.defaultDividerLineWidth ? this.#pageRoot.style.removeProperty("--ldp-divider-line-width") : this.#pageRoot.style.setProperty(
"--ldp-divider-line-width",
`${profile.dividerLineWidth}px`
);
}
#clear() {
for (const target of [this.#pageRoot, this.#overlay])
for (const property of STYLE_PROPERTIES) target.style.removeProperty(property);
for (const property of ROOT_STYLE_PROPERTIES)
this.#pageRoot.style.removeProperty(property);
this.#pageRoot.classList.remove("ldp-structure-colors-disabled"), this.#rowHeight = 0;
}
}
}, "551dfb96890751093acf088ff55afc564c71121e94330d07eb3be899bbdb95f4");
/* Source: lite/src/shell/embedded-host-header-controller.ts */
runtime.register("src/shell/embedded-host-header-controller.js", function(module, exports, require) {
var embedded_host_header_controller_exports = {};
__export(embedded_host_header_controller_exports, {
EmbeddedHostHeaderController: () => EmbeddedHostHeaderController
});
module.exports = __toCommonJS(embedded_host_header_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_value_record = require("../kernel/value-record.js"), import_native_host_api = require("../discourse/native-host-api.js");
const MOBILE_HEADER_MAX_WIDTH = 700, LANGUAGE_ITEM_SELECTOR = ".d-header-icons > .language-switcher", LANGUAGE_TRIGGER_SELECTOR = ".language-switcher-trigger,[data-ldp-host-language-toggle]", SEARCH_TRIGGER_SELECTOR = ".d-header-icons > .search-dropdown", SEARCH_SURFACE_SELECTOR = ".search-menu.glimmer-search-menu,.d-header .panel > .search-menu", SEARCH_FILTER_TRIGGER_SELECTOR = ".show-advanced-search", SEARCH_MORE_RESULTS_SELECTOR = '.filter.search-link[href^="/search"]', SEARCH_ADVANCED_PANEL_SELECTOR = "[data-ldp-host-advanced-search-panel]", SEARCH_ADVANCED_FRAME_ATTRIBUTE = "data-ldp-host-advanced-search-frame", SEARCH_HISTORY_STATE_KEY = "__awesomeLinuxDoReaderHostSearch", READER_STYLE_ELEMENT_ID = "ldp-mian-lite-styles", USER_MENU_SELECTOR = ".user-menu,.menu-panel.user-menu,.fk-d-menu.user-menu", USER_MENU_RAIL_SELECTORS = Object.freeze([
".user-menu__tabs-list",
".user-menu__tabs",
".tabs-list",
'[role="tablist"]'
]), LANGUAGE_OPTION_SELECTOR = '[data-locale],[data-value],[lang],[hreflang],button,a,li,span,div,[role="menuitem"],[role="option"]', LANGUAGE_OPTION_OWNER_SELECTOR = 'button,a,li,[data-locale],[data-value],[role="menuitem"],[role="option"],.select-kit-row,.dropdown-menu__item', LANGUAGE_ROOT_SURFACE_SELECTOR = ".language-switcher-content,.language-switcher-menu,.fk-d-menu", LANGUAGE_SURFACE_SELECTOR = `${LANGUAGE_ROOT_SURFACE_SELECTOR},.menu-panel,.select-kit-body,.select-kit-collection,.select-kit,.dropdown-menu,.popover-content,[role="menu"],[role="listbox"]`, LANGUAGE_SCAN_LIMIT = 10, LATEST_NAVIGATION_LABEL_SELECTOR = [
'[data-value="latest"]',
'[data-value="/latest"]',
'[data-name="latest"]',
'a[href="/latest"]',
'a[href$="/latest"]',
".list-control-toggle-link-trigger",
".list-control-toggle-link__text",
".navigation-container .select-kit-header",
'.navigation-container [role="option"]',
'.navigation-container [role="menuitem"]',
".category-navigation .select-kit-header",
'.category-navigation [role="option"]',
".category-breadcrumb .select-kit-header",
'.select-kit-body [role="option"]',
".select-kit-body .select-kit-row",
'.select-kit-collection [role="option"]',
".select-kit-collection .select-kit-row"
].join(","), LATEST_LABEL_RE = /^最新(?:\s*(?:[((]\s*\d+\s*[))]|\d+))?$/u, LATEST_COUNT_SUFFIX_RE = /\s*(?:[((]\s*\d+\s*[))]|\d+)\s*$/u, COUNT_ONLY_RE = /^\s*(?:[((]\s*)?\d+(?:\s*[))])?\s*$/u, LATEST_REFRESH_SELECTOR = ".show-more.has-topics", LATEST_REFRESH_COUNT_RE = /查看\s*(\d+)\s*个(?=[^话题]*(?:新|更新))[^话题]+话题/u;
function eventElement(event) {
const target = event.target;
return target && target.nodeType === 1 ? target : null;
}
function normalizedLanguageKey(value) {
const normalized = String(value ?? "").trim().toLowerCase().replace(/[-_\s]/g, "");
return normalized ? normalized === "zh" || normalized.startsWith("zhcn") || normalized.includes("中文") || normalized.includes("简体") || normalized.includes("chinese") ? "zh" : normalized === "en" || normalized.startsWith("enus") || normalized.includes("english") || normalized.includes("英文") ? "en" : null : null;
}
function languageKey(element) {
for (const value of [
element.getAttribute("data-locale"),
element.getAttribute("data-value"),
element.getAttribute("lang"),
element.getAttribute("hreflang"),
element.getAttribute("aria-label"),
element.getAttribute("title"),
element.textContent
]) {
const key = normalizedLanguageKey(value);
if (key) return key;
}
return null;
}
function explicitLanguageOptionKey(element) {
for (const name of ["data-locale", "data-value", "lang", "hreflang"]) {
const key = normalizedLanguageKey(element.getAttribute(name));
if (key) return key;
}
const text = String(element.textContent ?? "").trim().replace(/\s+/g, " ");
return /^(?:英语\s*[((]English[))]|English(?:\s*\([^)]*\))?|EN)$/i.test(text) ? "en" : /^(?:简体中文|Chinese Simplified\s*[((]简体中文[))]|中文(?:(简体)|\s*\(Simplified\))?|ZH)$/i.test(text) ? "zh" : null;
}
function hidden(element) {
return element.hasAttribute("hidden") || element.getAttribute("aria-hidden") === "true";
}
function normalizedText(value) {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
function latestRefreshCount(document) {
for (const refresh of document.querySelectorAll(
LATEST_REFRESH_SELECTOR
)) {
if (hidden(refresh)) continue;
const match = normalizedText(refresh.textContent).match(LATEST_REFRESH_COUNT_RE);
if (match) return Number.parseInt(match[1] ?? "0", 10) || 0;
}
return 0;
}
function syncLatestLabelCount(element, count) {
if (!LATEST_LABEL_RE.test(normalizedText(element.textContent))) return;
const rendered = count > 0 ? `最新 (${count})` : "最新";
for (const attribute of ["aria-label", "title"]) {
const value = element.getAttribute(attribute);
value && LATEST_LABEL_RE.test(normalizedText(value)) && element.setAttribute(attribute, rendered);
}
const explicitOwner = element.matches(".list-control-toggle-link__text") ? element : element.querySelector(".list-control-toggle-link__text");
if (explicitOwner) {
normalizedText(explicitOwner.textContent) !== rendered && (explicitOwner.textContent = rendered);
return;
}
const textNodes = [], collectTextNodes = (node) => {
for (const child of [...node.childNodes])
child.nodeType === 3 ? textNodes.push(child) : collectTextNodes(child);
};
collectTextNodes(element);
const latestNode = textNodes.find((node) => /最新/u.test(node.nodeValue ?? ""));
for (const node of textNodes)
node !== latestNode && COUNT_ONLY_RE.test(node.nodeValue ?? "") && (node.nodeValue = "");
latestNode && (latestNode.nodeValue = (latestNode.nodeValue ?? "").replace(LATEST_COUNT_SUFFIX_RE, "").replace(/最新/u, rendered));
}
class EmbeddedHostHeaderController {
scope;
#model;
#routeKind;
#document;
#host;
#mutations;
#requestFrame;
#cancelFrame;
#activeScope = null;
#syncFrame = 0;
#languageFrame = 0;
#languageScanCount = 0;
#languageBypass = !1;
#languageState = "zh";
#pendingLanguage = null;
#nativeLanguageItem = null;
#nativeLanguageTrigger = null;
#languageItem = null;
#languageToggle = null;
#searchBackdrop = null;
#searchSurface = null;
#searchTrigger = null;
#allowSearchRoute = !1;
#searchHistoryEntry = !1;
#searchHistoryClosePending = !1;
#destroyed = !1;
constructor(options) {
this.#model = options.model, this.#routeKind = options.routeKind, this.#document = options.document, this.#host = options.host ?? null, this.#mutations = options.mutations, this.#requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)), this.#cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id)), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#model.changes.subscribe(() => this.#syncActivation(), this.scope), this.scope.add(() => this.#deactivate()), this.#syncActivation();
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#shouldActivate() {
return this.#routeKind === "list" && (this.#document.documentElement.classList.contains("mobile-view") || this.#model.snapshot.viewportWidth <= MOBILE_HEADER_MAX_WIDTH);
}
#syncActivation() {
if (this.#destroyed) return;
const shouldActivate = this.#shouldActivate();
if (!shouldActivate && this.#activeScope && this.#deactivate(), !shouldActivate || this.#activeScope) return;
const activeScope = this.scope.child();
this.#activeScope = activeScope, this.#document.documentElement.setAttribute(
"data-ldp-host-header-condensed",
""
), activeScope.add((0, import_native_host_api.discourseDeferredSubscription)(
() => this.#attachSearchRouteGuard()
)), this.#mutations.subscribe(() => {
this.#scheduleSync(), this.#languageScanCount && this.#scheduleLanguageScan();
}, activeScope), activeScope.listen(this.#document, "click", (event) => {
this.#onDocumentClick(event);
}, !0), activeScope.listen(this.#document, "keydown", (event) => {
const keyboardEvent = event;
if (keyboardEvent.key === "Escape") {
this.#dismissSearch(!0);
return;
}
keyboardEvent.key === "Enter" && (keyboardEvent.ctrlKey || keyboardEvent.metaKey) && eventElement(event)?.closest("[data-ldp-host-search-drawer]") && (this.#allowSearchRoute = !0);
});
const pageWindow = this.#document.defaultView;
pageWindow && activeScope.listen(pageWindow, "popstate", () => {
this.#onSearchHistoryPop();
}), this.#scheduleSync();
}
#deactivate() {
const activeScope = this.#activeScope;
this.#activeScope = null, activeScope?.destroy(), this.#syncFrame && this.#cancelFrame(this.#syncFrame), this.#languageFrame && this.#cancelFrame(this.#languageFrame), this.#syncFrame = 0, this.#languageFrame = 0, this.#languageScanCount = 0, this.#closeSearch(!0), this.#document.documentElement.removeAttribute("data-ldp-host-header-condensed"), this.#document.documentElement.removeAttribute("data-ldp-host-language-switching"), this.#restoreLanguageItem(), this.#searchBackdrop?.remove(), this.#searchTrigger?.removeAttribute("data-ldp-host-search-trigger"), this.#searchBackdrop = null, this.#searchSurface = null, this.#searchTrigger = null, this.#allowSearchRoute = !1, this.#searchHistoryClosePending = !1;
}
#scheduleSync() {
!this.#activeScope || this.#syncFrame || (this.#syncFrame = this.#requestFrame(() => {
this.#syncFrame = 0, this.#syncHostHeader();
}));
}
#syncHostHeader() {
if (!this.#activeScope) return;
const searchTrigger = this.#document.querySelector(
SEARCH_TRIGGER_SELECTOR
);
searchTrigger && (this.#searchTrigger = searchTrigger, searchTrigger.setAttribute("data-ldp-host-search-trigger", "")), this.#syncSearchSurface(), this.#syncLatestCount(), this.#syncLanguageItem();
}
#syncSearchSurface() {
if (!this.#document.documentElement.hasAttribute("data-ldp-host-search-open")) {
this.#searchSurface && this.#restoreSearchSurface(this.#searchSurface), this.#searchSurface = null;
return;
}
const surface = [...this.#document.querySelectorAll(
SEARCH_SURFACE_SELECTOR
)].filter((element) => !element.closest(".ldp-overlay") && !hidden(element)).at(-1) ?? null;
if (!surface) {
this.#searchSurface && !this.#searchSurface.isConnected && this.#closeSearch(!1);
return;
}
this.#searchSurface && this.#searchSurface !== surface && this.#restoreSearchSurface(this.#searchSurface), this.#searchSurface = surface, surface.setAttribute("data-ldp-host-search-drawer", ""), this.#ensureSearchAdvancedPanel(surface);
}
#ensureSearchAdvancedPanel(surface) {
const trigger = surface.querySelector(
SEARCH_FILTER_TRIGGER_SELECTOR
);
trigger && (trigger.hasAttribute("data-ldp-host-search-filter-trigger") || (trigger.setAttribute(
"data-ldp-host-search-filter-title",
trigger.getAttribute("title") ?? ""
), trigger.setAttribute("data-ldp-host-search-filter-trigger", ""), trigger.setAttribute("aria-controls", "ldp-host-advanced-search-panel"), trigger.setAttribute("aria-expanded", "false"), trigger.setAttribute("title", this.#languageState === "en" ? "Advanced search filters" : "高级搜索筛选器")));
}
#createSearchAdvancedPanel(surface, mode) {
const english = this.#languageState === "en", panel = this.#document.createElement("section");
panel.id = "ldp-host-advanced-search-panel", panel.setAttribute("data-ldp-host-advanced-search-panel", ""), panel.setAttribute("aria-label", english ? "Advanced search filters" : "高级搜索筛选器"), panel.setAttribute("hidden", "");
const header = this.#document.createElement("header");
header.className = "ldp-host-advanced-search-header";
const title = this.#document.createElement("span");
title.className = "ldp-host-advanced-search-title";
const close = this.#document.createElement("button");
close.type = "button", close.setAttribute("data-ldp-host-advanced-search-close", ""), close.setAttribute("aria-label", english ? "Return to quick search" : "返回快捷搜索"), close.textContent = english ? "Quick search" : "快捷搜索", header.append(title, close);
const status = this.#document.createElement("div");
status.className = "ldp-host-advanced-search-status", status.setAttribute("role", "status"), status.textContent = english ? "Loading advanced filters…" : "正在加载高级筛选器…";
const frame = this.#document.createElement("iframe");
return frame.name = "ldp-host-advanced-search", frame.title = english ? "Advanced search" : "高级搜索", frame.setAttribute(SEARCH_ADVANCED_FRAME_ATTRIBUTE, ""), frame.setAttribute("aria-busy", "true"), frame.setAttribute("loading", "eager"), frame.setAttribute("referrerpolicy", "same-origin"), frame.setAttribute(
"sandbox",
"allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
), frame.dataset.ldpHostSearchMode = mode, frame.src = this.#advancedSearchUrl(surface, mode).href, this.#activeScope?.listen(frame, "load", () => {
this.#prepareSearchAdvancedFrame(frame, panel, status);
}), panel.append(header, status, frame), this.#syncSearchAdvancedPanelTitle(panel, mode), panel;
}
#advancedSearchUrl(surface, mode) {
const url = new URL(
"/search",
this.#document.location?.href ?? "https://linux.do/"
);
mode === "advanced" && url.searchParams.set("expanded", "true"), url.searchParams.set("ldp_embedded_search", "1");
const query = surface.querySelector(
'input[type="search"]'
)?.value.trim();
return query && url.searchParams.set("q", query), url;
}
#syncSearchAdvancedPanelTitle(panel, mode) {
const title = panel.querySelector(
".ldp-host-advanced-search-title"
);
if (!title) return;
const english = this.#languageState === "en";
title.textContent = mode === "results" ? english ? "More results" : "更多搜索结果" : english ? "Advanced search" : "高级搜索";
}
#prepareSearchAdvancedFrame(frame, panel, status) {
try {
const frameDocument = frame.contentDocument;
if (!frameDocument) throw new Error("高级搜索文档不可用");
frameDocument.documentElement.setAttribute(
SEARCH_ADVANCED_FRAME_ATTRIBUTE,
""
);
const sourceStyle = this.#document.getElementById(
READER_STYLE_ELEMENT_ID
);
if (sourceStyle?.textContent) {
const style = frameDocument.createElement("style");
style.id = `${READER_STYLE_ELEMENT_ID}-advanced-search`, style.textContent = sourceStyle.textContent, frameDocument.getElementById(style.id)?.remove(), (frameDocument.head ?? frameDocument.documentElement).append(style);
}
frame.removeAttribute("aria-busy"), panel.setAttribute("data-ldp-host-advanced-search-ready", ""), status.setAttribute("hidden", "");
} catch {
panel.setAttribute("data-ldp-host-advanced-search-error", ""), status.textContent = this.#languageState === "en" ? "Advanced filters could not be loaded." : "高级筛选器加载失败。";
}
}
#restoreSearchSurface(surface) {
const trigger = surface.querySelector(
"[data-ldp-host-search-filter-trigger]"
);
if (trigger) {
const originalTitle = trigger.getAttribute(
"data-ldp-host-search-filter-title"
) ?? "";
originalTitle ? trigger.setAttribute("title", originalTitle) : trigger.removeAttribute("title"), trigger.removeAttribute("data-ldp-host-search-filter-title"), trigger.removeAttribute("data-ldp-host-search-filter-trigger"), trigger.removeAttribute("aria-controls"), trigger.removeAttribute("aria-expanded");
}
surface.querySelector(SEARCH_ADVANCED_PANEL_SELECTOR)?.remove(), surface.removeAttribute("data-ldp-host-advanced-search-open"), surface.removeAttribute("data-ldp-host-search-drawer");
}
#toggleSearchAdvancedPanel(surface) {
const panel = surface.querySelector(
SEARCH_ADVANCED_PANEL_SELECTOR
);
if (panel && !panel.hasAttribute("hidden")) {
this.#closeSearchAdvancedPanel(surface, panel);
return;
}
this.#openSearchAdvancedPanel(surface, "advanced");
}
#openSearchAdvancedPanel(surface, mode) {
let panel = surface.querySelector(SEARCH_ADVANCED_PANEL_SELECTOR);
panel || (panel = this.#createSearchAdvancedPanel(surface, mode), surface.querySelector(".panel-body-contents")?.append(panel));
const trigger = surface.querySelector(
"[data-ldp-host-search-filter-trigger]"
);
!panel || !trigger || (this.#syncSearchAdvancedPanelTitle(panel, mode), this.#syncSearchAdvancedFrame(surface, panel, mode), panel.removeAttribute("hidden"), surface.setAttribute("data-ldp-host-advanced-search-open", ""), trigger.setAttribute("aria-expanded", "true"));
}
#closeSearchAdvancedPanel(surface, panel) {
panel.setAttribute("hidden", ""), surface.removeAttribute("data-ldp-host-advanced-search-open"), surface.querySelector(
"[data-ldp-host-search-filter-trigger]"
)?.setAttribute("aria-expanded", "false");
}
#syncSearchAdvancedFrame(surface, panel, mode) {
const frame = panel.querySelector(
`[${SEARCH_ADVANCED_FRAME_ATTRIBUTE}]`
);
if (!frame) return;
const currentMode = frame.dataset.ldpHostSearchMode;
if (mode === "advanced" && currentMode === mode) return;
const nextUrl = this.#advancedSearchUrl(surface, mode), currentUrl = new URL(
frame.src,
this.#document.location?.href ?? "https://linux.do/"
);
if (currentMode === mode && currentUrl.searchParams.get("q") === nextUrl.searchParams.get("q")) return;
frame.dataset.ldpHostSearchMode = mode, frame.setAttribute("aria-busy", "true"), panel.removeAttribute("data-ldp-host-advanced-search-ready"), panel.removeAttribute("data-ldp-host-advanced-search-error");
const status = panel.querySelector(
".ldp-host-advanced-search-status"
);
status && (status.removeAttribute("hidden"), status.textContent = mode === "results" ? this.#languageState === "en" ? "Loading more results…" : "正在加载更多结果…" : this.#languageState === "en" ? "Loading advanced filters…" : "正在加载高级筛选器…"), frame.src = nextUrl.href;
}
#syncLatestCount() {
const count = latestRefreshCount(this.#document);
for (const label of this.#document.querySelectorAll(
LATEST_NAVIGATION_LABEL_SELECTOR
)) syncLatestLabelCount(label, count);
}
#syncLanguageItem() {
const nativeItem = this.#document.querySelector(
LANGUAGE_ITEM_SELECTOR
), nativeTrigger = nativeItem?.querySelector(
".language-switcher-trigger"
);
nativeItem && nativeTrigger && (this.#nativeLanguageItem = nativeItem, this.#nativeLanguageTrigger = nativeTrigger, nativeItem.setAttribute("data-ldp-host-language-source", ""), this.#pendingLanguage || this.#setLanguageState(languageKey(nativeTrigger) ?? "zh"));
const rail = this.#findUserMenuRail();
if (!rail || !this.#nativeLanguageTrigger) return;
let item = this.#languageItem;
item?.isConnected || (item = this.#createLanguageItem(), this.#languageItem = item), (item.parentElement !== rail || rail.lastElementChild !== item) && rail.append(item);
}
#createLanguageItem() {
const item = this.#document.createElement("li");
item.setAttribute("data-ldp-host-language-item", "");
const trigger = this.#document.createElement("button");
trigger.type = "button", trigger.setAttribute("data-ldp-host-language-toggle", "");
const chinese = this.#document.createElement("span");
chinese.className = "ldp-host-language-option", chinese.dataset.language = "zh", chinese.textContent = "中";
const english = this.#document.createElement("span");
return english.className = "ldp-host-language-option", english.dataset.language = "en", english.textContent = "EN", trigger.append(chinese, english), item.append(trigger), this.#languageToggle = trigger, this.#setLanguageState(this.#languageState), item;
}
#setLanguageState(state) {
this.#languageState = state;
const toggle = this.#languageToggle;
if (!toggle) return;
toggle.dataset.languageState = state;
const chinese = state === "zh";
toggle.setAttribute("aria-label", chinese ? "当前为中文,点击切换到 English" : "Current language is English, switch to Chinese"), toggle.setAttribute("title", chinese ? "中文 / English" : "English / 中文");
}
#findUserMenuRail() {
for (const surface of this.#document.querySelectorAll(
USER_MENU_SELECTOR
)) {
if (surface.closest(".ldp-overlay") || hidden(surface)) continue;
for (const selector of USER_MENU_RAIL_SELECTORS) {
const rail = surface.querySelector(selector);
if (rail) return rail;
}
const lists = surface.querySelectorAll("ul,ol");
for (const list of lists)
if (list.querySelector('[data-tab-id],[role="tab"],.user-menu-tab'))
return list;
}
return null;
}
#restoreLanguageItem() {
this.#languageItem?.remove(), this.#nativeLanguageItem?.removeAttribute("data-ldp-host-language-source"), this.#nativeLanguageItem = null, this.#nativeLanguageTrigger = null, this.#languageItem = null, this.#languageToggle = null;
}
#onDocumentClick(event) {
const target = eventElement(event);
if (!target) return;
const searchSurface = target.closest(
"[data-ldp-host-search-drawer]"
);
if (searchSurface) {
if (target.closest("[data-ldp-host-advanced-search-close]")) {
event.preventDefault(), event.stopImmediatePropagation();
const panel = searchSurface.querySelector(
SEARCH_ADVANCED_PANEL_SELECTOR
);
panel && this.#closeSearchAdvancedPanel(searchSurface, panel);
return;
}
if (target.closest(SEARCH_FILTER_TRIGGER_SELECTOR)) {
event.preventDefault(), event.stopImmediatePropagation(), this.#toggleSearchAdvancedPanel(searchSurface);
return;
}
if (target.closest(SEARCH_MORE_RESULTS_SELECTOR)) {
event.preventDefault(), event.stopImmediatePropagation(), this.#openSearchAdvancedPanel(searchSurface, "results");
return;
}
}
const searchTrigger = target.closest(SEARCH_TRIGGER_SELECTOR);
if (searchTrigger) {
event.preventDefault(), event.stopImmediatePropagation(), this.#document.documentElement.hasAttribute("data-ldp-host-search-open") ? this.#dismissSearch(!0) : this.#openSearch(searchTrigger);
return;
}
!target.closest(LANGUAGE_TRIGGER_SELECTOR) || this.#languageBypass || (event.preventDefault(), event.stopImmediatePropagation(), this.#beginLanguageCycle());
}
#openSearch(trigger) {
if (this.#allowSearchRoute = !1, this.#ensureSearchBackdrop().removeAttribute("hidden"), this.#document.documentElement.setAttribute("data-ldp-host-search-open", ""), this.#searchTrigger = trigger, trigger.setAttribute("aria-expanded", "true"), !this.#setHostSearchVisible(!0)) {
this.#closeSearch(!1);
return;
}
this.#pushSearchHistoryEntry(), this.#scheduleSync();
}
#ensureSearchBackdrop() {
if (this.#searchBackdrop?.isConnected) return this.#searchBackdrop;
const backdrop = this.#document.createElement("div");
return backdrop.className = "ldp-host-search-backdrop", backdrop.setAttribute("aria-hidden", "true"), backdrop.setAttribute("hidden", ""), this.#activeScope?.listen(backdrop, "click", (event) => {
event.target === backdrop && this.#dismissSearch(!0);
}), (this.#searchTrigger?.closest(".d-header-wrap") ?? this.#document.body)?.append(backdrop), this.#searchBackdrop = backdrop, backdrop;
}
#history() {
try {
return this.#document.defaultView?.history ?? null;
} catch {
return null;
}
}
#pushSearchHistoryEntry() {
if (this.#searchHistoryEntry) return;
const history = this.#history();
if (history)
try {
const current = (0, import_value_record.valueRecord)(history.state), state = current ? { ...current } : {};
state[SEARCH_HISTORY_STATE_KEY] = !0, history.pushState(state, ""), this.#searchHistoryEntry = !0;
} catch {
this.#searchHistoryEntry = !1;
}
}
#dismissSearch(invokeHost) {
if (this.#document.documentElement.hasAttribute("data-ldp-host-search-open")) {
if (this.#searchHistoryEntry && !this.#searchHistoryClosePending) {
const history = this.#history();
if (history) {
this.#searchHistoryClosePending = !0;
try {
history.back();
return;
} catch {
this.#searchHistoryClosePending = !1;
}
}
}
this.#closeSearch(invokeHost);
}
}
#onSearchHistoryPop() {
const ownedEntry = this.#searchHistoryEntry;
if (this.#searchHistoryEntry = !1, this.#searchHistoryClosePending = !1, ownedEntry && this.#document.documentElement.hasAttribute("data-ldp-host-search-open")) {
this.#closeSearch(!0);
return;
}
this.#removeSearchHistoryMarker();
}
#removeSearchHistoryMarker() {
const history = this.#history();
if (history)
try {
const current = (0, import_value_record.valueRecord)(history.state);
if (!current || current[SEARCH_HISTORY_STATE_KEY] !== !0) return;
const state = { ...current };
delete state[SEARCH_HISTORY_STATE_KEY], history.replaceState(state, "");
} catch {
}
}
#closeSearch(invokeHost) {
this.#searchBackdrop?.setAttribute("hidden", ""), this.#document.documentElement.removeAttribute("data-ldp-host-search-open"), this.#searchSurface && this.#restoreSearchSurface(this.#searchSurface), this.#searchSurface = null, this.#allowSearchRoute = !1, this.#searchHistoryEntry && (this.#searchHistoryEntry = !1, this.#removeSearchHistoryMarker()), this.#searchHistoryClosePending = !1, this.#searchTrigger?.setAttribute("aria-expanded", "false"), invokeHost && this.#setHostSearchVisible(!1);
}
#setHostSearchVisible(visible) {
if (!this.#host) return !1;
const search = (0, import_value_record.valueRecord)(this.#host.lookup("service:search"));
if (!search) return !1;
try {
return search.visible = visible, !0;
} catch {
return !1;
}
}
#attachSearchRouteGuard() {
if (!this.#host) return () => {
};
const router = (0, import_value_record.valueRecord)(this.#host.lookup("service:router")), on = router?.on, off = router?.off;
if (typeof on != "function" || typeof off != "function") return null;
const guard = (transition) => {
if (!this.#document.documentElement.hasAttribute("data-ldp-host-search-open"))
return;
const transitionRecord = (0, import_value_record.valueRecord)(transition), destination = (0, import_value_record.valueRecord)(transitionRecord?.to);
if (String(destination?.name ?? "") !== "full-page-search") return;
if (this.#allowSearchRoute) {
this.#allowSearchRoute = !1;
return;
}
const abort = transitionRecord?.abort;
typeof abort == "function" && abort.call(transitionRecord);
};
try {
on.call(router, "routeWillChange", guard);
} catch {
return null;
}
return () => {
try {
off.call(router, "routeWillChange", guard);
} catch {
}
};
}
#beginLanguageCycle() {
if (this.#languageScanCount) return;
const trigger = this.#nativeLanguageTrigger;
if (!trigger) return;
const current = languageKey(trigger) ?? this.#languageState;
this.#pendingLanguage = current === "zh" ? "en" : "zh", this.#languageScanCount = 1, this.#document.documentElement.setAttribute(
"data-ldp-host-language-switching",
""
), this.#languageBypass = !0, trigger.click(), this.#languageBypass = !1, this.#scheduleLanguageScan();
}
#scheduleLanguageScan() {
!this.#activeScope || this.#languageFrame || !this.#languageScanCount || (this.#languageFrame = this.#requestFrame(() => {
this.#languageFrame = 0, this.#scanLanguageOptions();
}));
}
#scanLanguageOptions() {
const trigger = this.#nativeLanguageTrigger;
if (!trigger || !this.#activeScope) {
this.#finishLanguageCycle();
return;
}
if (!this.#pendingLanguage) {
const surface = this.#document.querySelector(
"[data-ldp-host-language-menu]"
);
if (!surface?.isConnected) {
this.#finishLanguageCycle();
return;
}
if (this.#languageScanCount += 1, this.#languageScanCount <= LANGUAGE_SCAN_LIMIT) {
this.#scheduleLanguageScan();
return;
}
trigger.getAttribute("aria-expanded") === "true" && (this.#languageBypass = !0, trigger.click(), this.#languageBypass = !1), surface.isConnected && surface.remove(), this.#finishLanguageCycle();
return;
}
const desired = this.#pendingLanguage, candidates = this.#languageOptions(), alternative = new Set(candidates.map((entry) => entry.key)).size >= 2 ? candidates.find((entry) => entry.key === desired) : null;
if (alternative) {
const surface = alternative.element.closest(
LANGUAGE_ROOT_SURFACE_SELECTOR
) ?? alternative.element.closest(
LANGUAGE_SURFACE_SELECTOR
) ?? alternative.element.parentElement;
surface && surface !== this.#document.body && surface.setAttribute("data-ldp-host-language-menu", ""), alternative.element.click(), this.#setLanguageState(alternative.key), this.#pendingLanguage = null, this.#languageScanCount = 1, this.#scheduleLanguageScan();
return;
}
if (this.#languageScanCount += 1, this.#languageScanCount <= LANGUAGE_SCAN_LIMIT) {
this.#scheduleLanguageScan();
return;
}
trigger.getAttribute("aria-expanded") === "true" && (this.#languageBypass = !0, trigger.click(), this.#languageBypass = !1), this.#finishLanguageCycle();
}
#languageOptions() {
const options = /* @__PURE__ */ new Map();
for (const candidate of this.#document.querySelectorAll(
LANGUAGE_OPTION_SELECTOR
)) {
if (candidate.closest(".ldp-overlay,[data-ldp-host-language-item]") || candidate.closest("[data-ldp-host-language-source]")) continue;
const key = explicitLanguageOptionKey(candidate);
if (!key) continue;
const owner = candidate.matches('button,a,[role="menuitem"],[role="option"]') ? candidate : candidate.querySelector(
'button,a,[role="menuitem"],[role="option"]'
) ?? candidate.closest(LANGUAGE_OPTION_OWNER_SELECTOR) ?? candidate;
options.has(owner) || options.set(owner, key);
}
return Object.freeze([...options].map(([element, key]) => Object.freeze({ element, key })));
}
#finishLanguageCycle() {
this.#languageFrame && this.#cancelFrame(this.#languageFrame), this.#languageFrame = 0, this.#languageScanCount = 0, this.#pendingLanguage = null, this.#document.documentElement.removeAttribute("data-ldp-host-language-switching");
for (const surface of this.#document.querySelectorAll(
"[data-ldp-host-language-menu]"
)) surface.removeAttribute("data-ldp-host-language-menu");
}
}
}, "6711a1a14015c5644e4aac79aefb7b6d87138c18a292d30475aa62aadd5b70bb");
/* Source: lite/src/shell/embedded-host-root-controller.ts */
runtime.register("src/shell/embedded-host-root-controller.js", function(module, exports, require) {
var embedded_host_root_controller_exports = {};
__export(embedded_host_root_controller_exports, {
EmbeddedHostRootController: () => EmbeddedHostRootController
});
module.exports = __toCommonJS(embedded_host_root_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
const CARD_SELECTOR = "tr.topic-list-item,.topic-list-item,.latest-topic-list-item", ROOT_QUERIES = Object.freeze({
shell: "#ember-app,#main-outlet-wrapper,#main-outlet,.list-container,.topic-list,.navigation-container",
header: ".d-header,body > header",
sidebar: "#d-sidebar,.sidebar-wrapper,.sidebar-container"
}), ROOT_RANK = Object.freeze({
sidebar: 1,
header: 2,
shell: 3
});
function elementFromNode(node) {
return node ? node.nodeType === 1 ? node : node.parentElement : null;
}
function topLevelBodyChild(documentPort, node) {
let current = elementFromNode(node);
for (; current?.parentElement && current.parentElement !== documentPort.body; )
current = current.parentElement;
return current?.parentElement === documentPort.body ? current : null;
}
class EmbeddedHostRootController {
scope;
#model;
#routeKind;
#document;
#overlay;
#mutations;
#enhancements;
#requestFrame;
#cancelFrame;
#activeScope = null;
#roots = /* @__PURE__ */ new Map();
#changedCards = /* @__PURE__ */ new Set();
#activityCards = /* @__PURE__ */ new Set();
#rootFrame = 0;
#cardFrame = 0;
#projectionMode = "actions-only";
#destroyed = !1;
constructor(options) {
this.#model = options.model, this.#routeKind = options.routeKind, this.#document = options.document, this.#overlay = options.overlay, this.#mutations = options.mutations, this.#enhancements = options.enhancements, this.#requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)), this.#cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id)), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#model.changes.subscribe(() => this.#syncActivation(), this.scope), options.topicFilterChanges?.subscribe(() => {
this.#scheduleRootSync();
}, this.scope), this.scope.add(() => this.#deactivate()), this.#syncActivation();
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#syncActivation() {
if (this.#destroyed) return;
const embedded = this.#model.snapshot.presentation.embedded, shouldActivate = this.#routeKind === "list", projectionMode = embedded ? "embedded" : "actions-only";
if (this.#activeScope && (!shouldActivate || projectionMode !== this.#projectionMode) && this.#deactivate(), shouldActivate && !this.#activeScope) {
this.#projectionMode = projectionMode;
const activeScope = this.scope.child();
this.#activeScope = activeScope, this.#mutations.subscribe((batch) => this.#onMutations(batch), activeScope), this.#scheduleRootSync();
} else this.#activeScope && this.#scheduleRootSync();
}
#deactivate() {
const activeScope = this.#activeScope;
this.#activeScope = null, activeScope?.destroy(), this.#rootFrame && this.#cancelFrame(this.#rootFrame), this.#cardFrame && this.#cancelFrame(this.#cardFrame), this.#rootFrame = 0, this.#cardFrame = 0, this.#changedCards.clear(), this.#activityCards.clear();
for (const root of this.#roots.keys())
root.removeAttribute("data-ldp-reader-host-root");
this.#roots.clear(), this.#enhancements.clear();
}
#onMutations(batch) {
if (!this.#activeScope) return;
const hostRootsMayHaveChanged = batch.records.some(
(record) => record.type === "childList" && (record.target === this.#document.body || !batch.root?.contains(record.target))
);
(batch.rootChanged || hostRootsMayHaveChanged) && this.#scheduleRootSync();
for (const record of batch.records) this.#collectRecord(record);
this.#scheduleCardSync();
}
#collectRecord(record) {
if (record.type === "childList") {
const target = elementFromNode(record.target), activityTarget = target?.matches(".relative-date") && target.closest("td.activity"), changedNodes = [...record.addedNodes, ...record.removedNodes];
if (activityTarget && record.addedNodes.length > 0 && record.removedNodes.length > 0 && changedNodes.every((node) => node.nodeType === 3)) {
const card = activityTarget.closest(CARD_SELECTOR);
card && this.#activityCards.add(card);
return;
}
changedNodes.length > 0 && changedNodes.every((node) => {
const element = elementFromNode(node);
return !!(element?.matches(".ldp-topic-stats-component") || element?.closest(".ldp-topic-stats-component"));
}) || this.#collectNearestCard(record.target);
for (const node of record.addedNodes) this.#collectAddedCards(node);
} else
this.#collectNearestCard(record.target);
}
#collectNearestCard(node) {
const element = elementFromNode(node);
if (!element || element.closest(".ldp-overlay,.ldp-topic-stats-component")) return;
const card = element.matches(CARD_SELECTOR) ? element : element.closest(CARD_SELECTOR);
card && this.#changedCards.add(card);
}
#collectAddedCards(node) {
const element = elementFromNode(node);
if (!element || element.closest(".ldp-overlay,.ldp-topic-stats-component")) return;
const nearest = element.matches(CARD_SELECTOR) ? element : element.closest(CARD_SELECTOR);
if (nearest) {
this.#changedCards.add(nearest);
return;
}
for (const card of element.querySelectorAll(CARD_SELECTOR))
this.#changedCards.add(card);
}
#scheduleRootSync() {
this.#rootFrame || !this.#activeScope || (this.#rootFrame = this.#requestFrame(() => this.#syncRoots()));
}
#syncRoots() {
if (this.#rootFrame = 0, !this.#activeScope || !this.#document.body) return;
const next = /* @__PURE__ */ new Map(), mark = (node, role) => {
const root = topLevelBodyChild(this.#document, node);
if (!root || root === this.#overlay || root.contains(this.#overlay)) return;
const previous = next.get(root);
(!previous || ROOT_RANK[role] > ROOT_RANK[previous]) && next.set(root, role);
};
for (const role of ["shell", "header", "sidebar"])
for (const node of this.#document.querySelectorAll(ROOT_QUERIES[role]))
mark(node, role);
for (const [root, previousRole] of this.#roots) {
const nextRole = next.get(root);
previousRole === "shell" && nextRole !== "shell" && this.#enhancements.releaseRoot(root), (!nextRole || this.#projectionMode !== "embedded") && root.removeAttribute("data-ldp-reader-host-root");
}
for (const [root, role] of next)
this.#projectionMode === "embedded" ? root.setAttribute("data-ldp-reader-host-root", role) : root.removeAttribute("data-ldp-reader-host-root"), role === "shell" && this.#enhancements.syncRoot(root, this.#projectionMode);
this.#roots = next;
}
#scheduleCardSync() {
this.#cardFrame || !this.#activeScope || !this.#changedCards.size && !this.#activityCards.size || (this.#cardFrame = this.#requestFrame(() => this.#flushCards()));
}
#flushCards() {
if (this.#cardFrame = 0, !this.#activeScope) return;
for (const card of this.#activityCards) {
if (this.#projectionMode !== "embedded") {
this.#changedCards.add(card);
continue;
}
card.isConnected && !this.#changedCards.has(card) && !this.#enhancements.syncActivity(card) && this.#changedCards.add(card);
}
this.#activityCards.clear();
const cards = [...this.#changedCards].filter((card) => card.isConnected);
this.#changedCards.clear(), cards.length && this.#enhancements.syncCards(
Object.freeze(cards),
this.#projectionMode
);
}
}
}, "ae436bc0bdb5a5548cdb320f289d72a5869aab60509f2decc3f5053ef275c4b5");
/* Source: lite/src/shell/embedded-host-scrollbar.ts */
runtime.register("src/shell/embedded-host-scrollbar.js", function(module, exports, require) {
var embedded_host_scrollbar_exports = {};
__export(embedded_host_scrollbar_exports, {
EmbeddedHostScrollbarController: () => EmbeddedHostScrollbarController,
EmbeddedHostScrollbarModel: () => EmbeddedHostScrollbarModel
});
module.exports = __toCommonJS(embedded_host_scrollbar_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js");
const MIN_THUMB_HEIGHT = 24, KEY_LINE_STEP = 40, KEY_PAGE_RATIO = 0.9;
function finite(value, fallback) {
return Number.isFinite(value) ? value : fallback;
}
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function roundedCssPixels(value) {
return `${Math.round(value * 100) / 100}px`;
}
function emptySnapshot() {
return Object.freeze({
viewportHeight: 1,
scrollHeight: 1,
scrollTop: 0,
maxScroll: 0,
trackHeight: 1,
thumbHeight: 1,
maxThumbTop: 0,
thumbTop: 0,
inactive: !0
});
}
class EmbeddedHostScrollbarModel {
changes = new import_signal.Signal();
#snapshot = emptySnapshot();
get snapshot() {
return this.#snapshot;
}
update(rawMetrics, rawTrackHeight) {
const viewportHeight = Math.max(1, finite(rawMetrics.viewportHeight, 1)), scrollHeight = Math.max(
viewportHeight,
finite(rawMetrics.scrollHeight, viewportHeight)
), maxScroll = Math.max(0, scrollHeight - viewportHeight), scrollTop = clamp(finite(rawMetrics.scrollTop, 0), 0, maxScroll), trackHeight = Math.max(1, finite(rawTrackHeight, 1)), thumbHeight = maxScroll > 0 ? Math.min(
trackHeight,
Math.max(MIN_THUMB_HEIGHT, trackHeight * viewportHeight / scrollHeight)
) : trackHeight, maxThumbTop = Math.max(0, trackHeight - thumbHeight), thumbTop = maxScroll > 0 ? maxThumbTop * scrollTop / maxScroll : 0, next = Object.freeze({
viewportHeight,
scrollHeight,
scrollTop,
maxScroll,
trackHeight,
thumbHeight,
maxThumbTop,
thumbTop,
inactive: maxScroll <= 0
}), previous = this.#snapshot;
return next.viewportHeight === previous.viewportHeight && next.scrollHeight === previous.scrollHeight && next.scrollTop === previous.scrollTop && next.trackHeight === previous.trackHeight ? previous : (this.#snapshot = next, this.changes.emit(next), next);
}
scrollTopForPointer(clientY, trackTop, pointerOffsetY) {
const snapshot = this.#snapshot;
if (snapshot.maxScroll <= 0 || snapshot.maxThumbTop <= 0) return 0;
const thumbTop = clamp(
clientY - trackTop - pointerOffsetY,
0,
snapshot.maxThumbTop
);
return snapshot.maxScroll * thumbTop / snapshot.maxThumbTop;
}
scrollTopForKey(key) {
const snapshot = this.#snapshot;
let next = snapshot.scrollTop;
if (key === "ArrowUp") next -= KEY_LINE_STEP;
else if (key === "ArrowDown") next += KEY_LINE_STEP;
else if (key === "PageUp") next -= snapshot.viewportHeight * KEY_PAGE_RATIO;
else if (key === "PageDown") next += snapshot.viewportHeight * KEY_PAGE_RATIO;
else if (key === "Home") next = 0;
else if (key === "End") next = snapshot.maxScroll;
else return null;
return clamp(next, 0, snapshot.maxScroll);
}
reset() {
const next = emptySnapshot();
return this.#snapshot = next, this.changes.emit(next), next;
}
}
function pointerEventTarget(event) {
return event.target;
}
class EmbeddedHostScrollbarController {
scope;
model = new EmbeddedHostScrollbarModel();
#workspace;
#track;
#thumb;
#scroll;
#readScrollTop;
#readTrack;
#requestFrame;
#cancelFrame;
#createResizeObserver;
#resizeTargets;
#resizeObserver = null;
#frame = 0;
#pointer = null;
#active = !1;
#geometryDirty = !0;
#trackGeometry = { top: 0, height: 1 };
#scrollMetrics = null;
#destroyed = !1;
constructor(options) {
this.#workspace = options.workspace, this.#track = options.track, this.#thumb = options.thumb, this.#scroll = options.scroll, this.#readScrollTop = options.scroll.readScrollTop ?? (() => options.scroll.read().scrollTop), this.#resizeTargets = Object.freeze([
...options.resizeTargets ?? [],
options.track
]), this.#createResizeObserver = options.createResizeObserver, this.#readTrack = options.readTrack ?? (() => ({ top: this.#track.getBoundingClientRect().top, height: Math.max(1, this.#track.clientHeight) })), this.#requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)), this.#cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id)), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#workspace.changes.subscribe(() => this.#syncActivation(), this.scope), this.scope.listen(options.scrollTarget, "scroll", () => this.#schedule(), {
passive: !0
}), this.scope.listen(this.#track, "pointerdown", (event) => {
this.#onPointerDown(event);
}), this.scope.listen(this.#track, "pointermove", (event) => {
this.#onPointerMove(event);
});
for (const type of ["pointerup", "pointercancel", "lostpointercapture"])
this.scope.listen(this.#track, type, (event) => {
this.#stopPointer(event);
});
this.scope.listen(this.#track, "keydown", (event) => {
this.#onKeyDown(event);
}), this.scope.add(() => this.#deactivate()), this.#syncActivation();
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
invalidateGeometry() {
this.#geometryDirty = !0, this.#schedule();
}
#syncActivation() {
if (this.#destroyed) return;
const active = this.#workspace.snapshot.presentation.embedded;
active !== this.#active && (active ? this.#activate() : this.#deactivate());
}
#activate() {
if (this.#active = !0, this.#geometryDirty = !0, !this.#resizeObserver && this.#createResizeObserver) {
this.#resizeObserver = this.#createResizeObserver(() => this.invalidateGeometry());
for (const target of this.#resizeTargets) this.#resizeObserver.observe(target);
}
this.#schedule();
}
#deactivate() {
this.#active = !1, this.#frame && this.#cancelFrame(this.#frame), this.#frame = 0, this.#resizeObserver?.disconnect(), this.#resizeObserver = null, this.#stopPointer(), this.#geometryDirty = !0, this.#trackGeometry = { top: 0, height: 1 }, this.#scrollMetrics = null, this.model.reset(), this.#clearDom();
}
#schedule() {
!this.#active || this.#frame || (this.#frame = this.#requestFrame(() => this.#sync()));
}
#sync() {
if (this.#frame = 0, !this.#active) return;
const geometryDirty = this.#geometryDirty;
if (geometryDirty) {
const geometry = this.#readTrack();
this.#trackGeometry = {
top: finite(geometry.top, 0),
height: Math.max(1, finite(geometry.height, 1))
}, this.#geometryDirty = !1;
}
const metrics = geometryDirty || this.#scrollMetrics === null ? this.#scroll.read() : {
...this.#scrollMetrics,
scrollTop: this.#readScrollTop()
};
this.#scrollMetrics = metrics;
const snapshot = this.model.update(metrics, this.#trackGeometry.height);
this.#apply(snapshot);
}
#apply(snapshot) {
this.#track.classList.toggle(
"ldp-reader-host-scrollbar-inactive",
snapshot.inactive
), this.#track.setAttribute("aria-disabled", String(snapshot.inactive)), this.#track.setAttribute("aria-valuemin", "0"), this.#track.setAttribute("aria-valuemax", String(Math.round(snapshot.maxScroll))), this.#track.setAttribute("aria-valuenow", String(Math.round(snapshot.scrollTop))), this.#thumb.style.height = roundedCssPixels(snapshot.thumbHeight), this.#thumb.style.transform = `translateY(${roundedCssPixels(snapshot.thumbTop)})`;
}
#clearDom() {
this.#track.classList.remove("ldp-reader-host-scrollbar-dragging"), this.#track.classList.add("ldp-reader-host-scrollbar-inactive"), this.#track.setAttribute("aria-disabled", "true"), this.#track.setAttribute("aria-valuemin", "0"), this.#track.setAttribute("aria-valuemax", "0"), this.#track.setAttribute("aria-valuenow", "0"), this.#thumb.style.removeProperty("height"), this.#thumb.style.removeProperty("transform");
}
#onPointerDown(event) {
if (event.button !== 0 || !this.#active) return;
this.#frame && this.#cancelFrame(this.#frame), this.#frame = 0, this.#geometryDirty = !0, this.#sync();
const snapshot = this.model.snapshot;
if (snapshot.inactive) return;
event.preventDefault(), event.stopPropagation();
const onThumb = pointerEventTarget(event) === this.#thumb, thumbRect = this.#thumb.getBoundingClientRect();
this.#pointer = {
pointerId: event.pointerId,
offsetY: onThumb ? event.clientY - thumbRect.top : snapshot.thumbHeight / 2
}, this.#track.classList.add("ldp-reader-host-scrollbar-dragging");
try {
this.#track.setPointerCapture(event.pointerId);
} catch {
}
try {
this.#track.focus({ preventScroll: !0 });
} catch {
this.#track.focus();
}
this.#scrollFromPointer(event.clientY);
}
#onPointerMove(event) {
!this.#pointer || event.pointerId !== this.#pointer.pointerId || (event.preventDefault(), this.#scrollFromPointer(event.clientY));
}
#scrollFromPointer(clientY) {
this.#pointer && this.#scroll.scrollTo(this.model.scrollTopForPointer(
clientY,
this.#trackGeometry.top,
this.#pointer.offsetY
));
}
#stopPointer(event) {
if (!this.#pointer || event && event.pointerId !== this.#pointer.pointerId)
return;
const pointerId = this.#pointer.pointerId;
this.#pointer = null, this.#track.classList.remove("ldp-reader-host-scrollbar-dragging");
try {
this.#track.hasPointerCapture(pointerId) && this.#track.releasePointerCapture(pointerId);
} catch {
}
}
#onKeyDown(event) {
if (!this.#active) return;
this.#sync();
const top = this.model.scrollTopForKey(event.key);
top !== null && (event.preventDefault(), event.stopPropagation(), this.#scroll.scrollTo(top));
}
}
}, "7cf7ab170b59d2e47120ded04d6003ac077b5a456666267e473b368e48f45a9b");
/* Source: lite/src/shell/embedded-host-top-shortcut.ts */
runtime.register("src/shell/embedded-host-top-shortcut.js", function(module, exports, require) {
var embedded_host_top_shortcut_exports = {};
__export(embedded_host_top_shortcut_exports, {
EmbeddedHostTopShortcutController: () => EmbeddedHostTopShortcutController
});
module.exports = __toCommonJS(embedded_host_top_shortcut_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
const TOP_EDGE = 8, BUTTON_SIZE = 30, POINTER_GAP = 12, UPWARD_DISTANCE = 240, UPWARD_WINDOW_MS = 450, UPWARD_BREAK_MS = 240, SHOW_DURATION_MS = 3e3;
class EmbeddedHostTopShortcutController {
scope;
#workspace;
#button;
#readScrollTop;
#readViewportHeight;
#scrollToTop;
#now;
#setTimeout;
#clearTimeout;
#setInterval;
#clearInterval;
#requestFrame;
#cancelFrame;
#hideTimer = 0;
#countdownTimer = 0;
#jumpFrame = 0;
#scrollTop = 0;
#upwardDistance = 0;
#upwardStartedAt = 0;
#lastScrollAt = 0;
#pointerX = Number.NaN;
#pointerY = Number.NaN;
#jumping = !1;
#active = !1;
#destroyed = !1;
constructor(options) {
this.#workspace = options.workspace, this.#button = options.button, this.#readScrollTop = options.readScrollTop, this.#readViewportHeight = options.readViewportHeight, this.#scrollToTop = options.scrollToTop, this.#now = options.now ?? (() => performance.now()), this.#setTimeout = options.setTimeout ?? ((callback, milliseconds) => window.setTimeout(callback, milliseconds)), this.#clearTimeout = options.clearTimeout ?? ((id) => window.clearTimeout(id)), this.#setInterval = options.setInterval ?? ((callback, milliseconds) => window.setInterval(callback, milliseconds)), this.#clearInterval = options.clearInterval ?? ((id) => window.clearInterval(id)), this.#requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)), this.#cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id)), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#button.hidden = !0, this.#workspace.changes.subscribe(() => this.#syncActivation(), this.scope);
for (const type of ["pointermove", "wheel"])
this.scope.listen(options.pointerTarget, type, (event) => {
this.#rememberPointer(event);
}, { passive: !0 });
this.scope.listen(options.scrollTarget, "scroll", () => this.#onScroll(), {
passive: !0
}), this.scope.listen(this.#button, "click", (event) => this.#onClick(event)), this.scope.add(() => this.#deactivate()), this.#syncActivation();
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#syncActivation() {
if (this.#destroyed) return;
const active = this.#workspace.snapshot.presentation.embedded;
active !== this.#active && (active ? (this.#active = !0, this.#resetUpward(this.#safeScrollTop())) : this.#deactivate());
}
#deactivate() {
this.#active = !1, this.#hide(), this.#jumpFrame && this.#cancelFrame(this.#jumpFrame), this.#jumpFrame = 0, this.#jumping = !1, this.#pointerX = Number.NaN, this.#pointerY = Number.NaN, this.#resetUpward(0);
}
#safeScrollTop() {
return Math.max(0, Number(this.#readScrollTop()) || 0);
}
#hostBounds() {
const snapshot = this.#workspace.snapshot;
return snapshot.presentation.side === "left" ? { left: snapshot.embedWidth, right: snapshot.viewportWidth } : { left: 0, right: snapshot.viewportWidth - snapshot.embedWidth };
}
#rememberPointer(event) {
if (!this.#active || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY))
return;
const bounds = this.#hostBounds();
event.clientX < bounds.left || event.clientX > bounds.right || (this.#pointerX = event.clientX, this.#pointerY = event.clientY);
}
#onScroll() {
if (!this.#active) return;
const scrollTop = this.#safeScrollTop();
if (this.#jumping) {
this.#resetUpward(scrollTop);
return;
}
const now = this.#now(), upwardDelta = this.#scrollTop - scrollTop;
if (this.#scrollTop = scrollTop, scrollTop <= TOP_EDGE && this.#hide(), upwardDelta <= 0) {
this.#upwardDistance = 0, this.#upwardStartedAt = 0, this.#lastScrollAt = now;
return;
}
(!this.#upwardStartedAt || now - this.#lastScrollAt > UPWARD_BREAK_MS) && (this.#upwardStartedAt = now, this.#upwardDistance = 0), this.#lastScrollAt = now, this.#upwardDistance += upwardDelta, this.#upwardDistance >= UPWARD_DISTANCE && now - this.#upwardStartedAt <= UPWARD_WINDOW_MS && (this.#show(), this.#upwardStartedAt = now, this.#upwardDistance = 0);
}
#show() {
if (!this.#active) return;
if (this.#safeScrollTop() <= TOP_EDGE) {
this.#hide();
return;
}
if (this.#button.hidden) {
const bounds = this.#hostBounds(), left = Number.isFinite(this.#pointerX) ? Math.max(
bounds.left + TOP_EDGE,
Math.min(
bounds.right - BUTTON_SIZE - TOP_EDGE,
this.#pointerX + POINTER_GAP
)
) : bounds.right - BUTTON_SIZE - TOP_EDGE, pointerY = Number.isFinite(this.#pointerY) ? this.#pointerY : this.#readViewportHeight() * 0.3, top = Math.max(
TOP_EDGE,
Math.min(
this.#readViewportHeight() - BUTTON_SIZE - TOP_EDGE,
pointerY - BUTTON_SIZE / 2
)
);
this.#button.style.left = `${Math.round(left)}px`, this.#button.style.top = `${Math.round(top)}px`, this.#button.hidden = !1;
}
this.#clearTimers();
const countdown = this.#button.querySelector(
".ldp-reader-host-top-countdown,[data-reader-host-top-countdown]"
), hideAt = this.#now() + SHOW_DURATION_MS, updateCountdown = () => {
countdown && (countdown.textContent = String(Math.max(
1,
Math.ceil((hideAt - this.#now()) / 1e3)
)));
};
updateCountdown(), this.#countdownTimer = this.#setInterval(updateCountdown, 200), this.#hideTimer = this.#setTimeout(() => this.#hide(), SHOW_DURATION_MS);
}
#hide() {
this.#clearTimers(), this.#button.hidden = !0;
}
#clearTimers() {
this.#hideTimer && this.#clearTimeout(this.#hideTimer), this.#countdownTimer && this.#clearInterval(this.#countdownTimer), this.#hideTimer = 0, this.#countdownTimer = 0;
}
#resetUpward(scrollTop) {
this.#scrollTop = Math.max(0, scrollTop), this.#upwardDistance = 0, this.#upwardStartedAt = 0, this.#lastScrollAt = 0;
}
#onClick(event) {
this.#active && (event.preventDefault(), event.stopPropagation(), this.#hide(), this.#jumping = !0, this.#scrollToTop(), this.#jumpFrame && this.#cancelFrame(this.#jumpFrame), this.#jumpFrame = this.#requestFrame(() => {
this.#jumpFrame = 0, this.#jumping = !1, this.#resetUpward(0);
}));
}
}
}, "3d39f12efa64fb4131d77592a9b46ee992f95886f784406a4086d4ea215f99b9");
/* Source: lite/src/shell/embedded-host-topic-card-enhancement.ts */
runtime.register("src/shell/embedded-host-topic-card-enhancement.js", function(module, exports, require) {
var embedded_host_topic_card_enhancement_exports = {};
__export(embedded_host_topic_card_enhancement_exports, {
EmbeddedHostTopicCardEnhancement: () => EmbeddedHostTopicCardEnhancement
});
module.exports = __toCommonJS(embedded_host_topic_card_enhancement_exports);
var import_native_host_api = require("../discourse/native-host-api.js"), import_reader_inline_emoji = require("../components/reader-inline-emoji.js"), import_reader_icon = require("../components/reader-icon.js"), import_value_record = require("../kernel/value-record.js");
const CARD_SELECTOR = "tr.topic-list-item,.topic-list-item,.latest-topic-list-item", TOPIC_LINK_SELECTOR = '.main-link a.raw-topic-link[href*="/t/"],.main-link a.title[href*="/t/"],.main-link a[href*="/t/"]', NEW_TOPIC_BADGE_SELECTOR = ".topic-post-badges,.badge-notification.new-topic", AUTOMATIC_FILTER_ATTRIBUTE = "data-ldp-unwanted-auto-filter", MANUAL_FILTER_ATTRIBUTE = "data-ldp-unwanted-manual-filter", HOST_OP_AVATAR_LONG_PRESS_ATTRIBUTE = "data-ldp-host-op-avatar-long-press", EXPOSURE_COUNT_CLASS = "ldp-host-topic-exposure-count", TOPIC_LIST_SELECTOR = ".topic-list,.latest-topic-list", OPENED_TOPIC_STORAGE_KEY = "linuxdo-enhanced-reader:opened-host-topics:v1";
function modelValue(value, key) {
const source = (0, import_value_record.valueRecord)(value);
if (!source) return;
const getter = source.get;
if (typeof getter == "function")
try {
const result = getter.call(value, key);
if (result !== void 0) return result;
} catch {
}
return source[key];
}
function modelArray(value) {
if (Array.isArray(value)) return value;
const toArray = (0, import_value_record.valueRecord)(value)?.toArray;
if (typeof toArray != "function") return Object.freeze([]);
try {
const result = toArray.call(value);
return Array.isArray(result) ? result : Object.freeze([]);
} catch {
return Object.freeze([]);
}
}
function reactionCountTotal(value) {
const reactions = modelValue(value, "reactions"), source = (0, import_value_record.valueRecord)(reactions);
return !Array.isArray(reactions) && typeof source?.toArray != "function" ? null : modelArray(reactions).reduce((total, reaction) => {
const count = Number(modelValue(reaction, "count"));
return Number.isFinite(count) && count > 0 ? total + Math.trunc(count) : total;
}, 0);
}
function markup(node) {
return node.nodeType === 1 ? node.outerHTML : String(node.textContent ?? "");
}
function sourceNodes(cell, owned) {
return Object.freeze(
[...cell.childNodes].filter((node) => node !== owned).map((node) => node.cloneNode(!0))
);
}
function directChild(line, node) {
let current = node;
for (; current && current.parentElement !== line; )
current = current.parentElement;
return current?.parentElement === line ? current : null;
}
function normalizedLabel(value) {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
function nativeDndLabel(node) {
return [
node.textContent,
node.getAttribute("aria-label"),
node.getAttribute("title"),
node.getAttribute("data-tooltip"),
node.getAttribute("data-tippy-content")
].map(normalizedLabel).filter(Boolean).some(
(label) => label.includes("免打扰") || label.includes("静音") || /\b(?:mute|muted|unmute)\b/i.test(label)
);
}
class EmbeddedHostTopicCardEnhancement {
#document;
#host;
#isTopicHidden;
#hideTopic;
#recordAutomaticTopic;
#automaticFilter;
#notify;
#onError;
#openedTopicStorage;
#openedTopicStorageKey;
#roots = /* @__PURE__ */ new Set();
#rootModes = /* @__PURE__ */ new Map();
#openedTopicIds = /* @__PURE__ */ new Set();
#exposureCounts = /* @__PURE__ */ new Map();
#countedExposureCards = /* @__PURE__ */ new WeakSet();
#observedExposureCards = /* @__PURE__ */ new Set();
#exposureObserver;
#firstTopicCardByList = /* @__PURE__ */ new WeakMap();
#rootClickHandlers = /* @__PURE__ */ new Map();
#pendingCards = /* @__PURE__ */ new WeakSet();
#automaticRecordSignatures = /* @__PURE__ */ new WeakMap();
#nativeDndTooltipAttributes = /* @__PURE__ */ new WeakMap();
constructor(document, host, options = {}) {
this.#document = document, this.#host = host, this.#isTopicHidden = options.isTopicHidden ?? (() => !1), this.#hideTopic = options.hideTopic ?? (() => {
throw new Error("不想看仓库尚未就绪");
}), this.#recordAutomaticTopic = options.recordAutomaticTopic ?? (() => {
}), this.#automaticFilter = options.automaticFilter ?? (() => null), this.#notify = options.notify ?? (() => {
}), this.#onError = options.onError ?? (() => {
}), this.#openedTopicStorage = options.openedTopicStorage ?? null;
const storageScope = normalizedLabel(
options.openedTopicStorageScope ?? this.#currentUsername()
).toLocaleLowerCase("en-US") || "anonymous";
this.#openedTopicStorageKey = `${OPENED_TOPIC_STORAGE_KEY}:${encodeURIComponent(storageScope)}`, this.#restoreTopicState();
const NativeIntersectionObserver = document.defaultView?.IntersectionObserver, createObserver = options.createIntersectionObserver ?? (NativeIntersectionObserver ? ((callback, init) => new NativeIntersectionObserver(callback, init)) : null);
this.#exposureObserver = createObserver?.(
(entries) => this.#onExposureIntersections(entries),
{ root: null, threshold: 0 }
) ?? null;
}
syncRoot(root, mode = "embedded") {
if (this.#roots.add(root), this.#rootModes.set(root, mode), !this.#rootClickHandlers.has(root)) {
const handler = (event) => this.#onRootClick(event);
root.addEventListener("click", handler, !0), this.#rootClickHandlers.set(root, handler);
}
this.syncCards(
Object.freeze([...root.querySelectorAll(CARD_SELECTOR)]),
mode
);
}
releaseRoot(root) {
this.#roots.has(root) && (this.#clearRoot(root), this.#roots.delete(root), this.#rootModes.delete(root));
}
syncActivity(card) {
const source = card.querySelector(":scope > td.activity .relative-date"), component = card.querySelector(
":scope > td.posts > .ldp-topic-stats-component"
), target = component?.querySelector(
".ldp-topic-stat--activity .relative-date"
);
if (!source || !component || !target) return !1;
target.textContent !== source.textContent && (target.textContent = source.textContent);
const parts = String(
component.dataset.ldpSourceSignature ?? ""
).split(""), activity = card.querySelector(":scope > td.activity");
return parts.length === 5 && activity && (parts[2] = [...activity.childNodes].map(markup).join(""), component.dataset.ldpSourceSignature = parts.join("")), !0;
}
syncCards(cards, mode = "embedded") {
this.#pruneDisconnectedExposureCards();
const topicModels = this.#topicModels(), reactions = this.#reactionCounts(topicModels);
for (const card of this.#deduplicateCards(cards)) {
if (!card.matches(CARD_SELECTOR) || card.closest(".ldp-overlay")) continue;
const topic = this.#topicInput(card, topicModels), projection = this.#topicProjection(card, topic), manuallyHidden = this.#isTopicHidden(projection.topicId);
if (card.toggleAttribute(MANUAL_FILTER_ATTRIBUTE, manuallyHidden), manuallyHidden) {
card.removeAttribute(AUTOMATIC_FILTER_ATTRIBUTE), this.#automaticRecordSignatures.delete(card), this.#stopObservingExposure(card);
continue;
}
const automatic = this.#automaticFilter(projection);
if (card.toggleAttribute(AUTOMATIC_FILTER_ATTRIBUTE, !!automatic), automatic) {
this.#recordAutomaticMatch(card, projection, automatic), this.#stopObservingExposure(card);
continue;
}
this.#automaticRecordSignatures.delete(card);
const titleLink = card.querySelector(TOPIC_LINK_SELECTOR);
titleLink && (0, import_reader_inline_emoji.renderReaderInlineEmoji)(
titleLink,
projection.title,
(id) => (0, import_native_host_api.discourseNativeEmojiUrl)(this.#host, id)
), this.#markNewTopic(card, topic), this.#syncExposure(card), this.#groupTitleTools(card, topic), this.#markMobileOpAvatar(card), mode === "embedded" ? (this.#markDateCells(card), this.#syncStats(card, reactions)) : this.#clearEmbeddedCard(card);
}
}
clear() {
for (const root of this.#roots)
this.#clearRoot(root);
this.#roots.clear(), this.#rootModes.clear(), this.#exposureObserver?.disconnect(), this.#observedExposureCards.clear();
}
get openedTopicStorageKey() {
return this.#openedTopicStorageKey;
}
refreshHiddenTopics() {
for (const root of this.#roots)
this.syncCards(
Object.freeze([...root.querySelectorAll(CARD_SELECTOR)]),
this.#rootModes.get(root) ?? "embedded"
);
}
reloadExternalOpenedTopics() {
this.#openedTopicIds.clear(), this.#exposureCounts.clear(), this.#restoreTopicState();
const topicModels = this.#topicModels();
for (const card of this.#document.querySelectorAll(
CARD_SELECTOR
))
this.#markNewTopic(card, this.#topicInput(card, topicModels)), this.#syncExposure(card);
}
markTopicOpened(topicId) {
if (!Number.isSafeInteger(topicId) || topicId < 1) return !1;
this.#restoreTopicState();
let stateChanged = !1;
this.#openedTopicIds.has(topicId) || (this.#openedTopicIds.add(topicId), stateChanged = !0), this.#exposureCounts.delete(topicId) && (stateChanged = !0), stateChanged && this.#persistTopicState();
let changed = !1;
for (const card of this.#document.querySelectorAll(
CARD_SELECTOR
)) {
if (Number(this.#cardTopicId(card)) !== topicId) continue;
this.#stopObservingExposure(card), card.querySelector(`.${EXPOSURE_COUNT_CLASS}`)?.remove();
const marker = card.querySelector(NEW_TOPIC_BADGE_SELECTOR);
marker && !marker.hasAttribute("data-ldp-native-new-topic-marker") && (marker.setAttribute("data-ldp-native-new-topic-marker", "true"), changed = !0), card.hasAttribute("data-ldp-native-new-topic") && (card.removeAttribute("data-ldp-native-new-topic"), changed = !0);
}
return changed;
}
#clearRoot(root) {
for (const card of root.querySelectorAll(CARD_SELECTOR))
this.#stopObservingExposure(card);
for (const list of root.querySelectorAll(TOPIC_LIST_SELECTOR))
this.#firstTopicCardByList.delete(list);
const handler = this.#rootClickHandlers.get(root);
handler && root.removeEventListener("click", handler, !0), this.#rootClickHandlers.delete(root);
for (const component of root.querySelectorAll(
".ldp-topic-stats-component"
)) component.remove();
for (const button of root.querySelectorAll(
"[data-ldp-owned-native-dnd]"
)) button.remove();
for (const group of root.querySelectorAll(
".ldp-native-topic-title-tools"
)) group.replaceWith(...group.childNodes);
for (const node of root.querySelectorAll(
"[data-ldp-native-dnd]"
))
this.#restoreNativeDndTooltip(node), node.removeAttribute("data-ldp-native-dnd");
for (const node of root.querySelectorAll(
"[data-ldp-native-topic-date]"
)) node.removeAttribute("data-ldp-native-topic-date");
for (const node of root.querySelectorAll(
"[data-ldp-native-old-topic]"
)) node.removeAttribute("data-ldp-native-old-topic");
for (const node of root.querySelectorAll(
`[${HOST_OP_AVATAR_LONG_PRESS_ATTRIBUTE}]`
))
node.removeAttribute("data-user-card-long-press"), node.removeAttribute(HOST_OP_AVATAR_LONG_PRESS_ATTRIBUTE);
for (const node of root.querySelectorAll(
"[data-ldp-native-topic-date-row],[data-ldp-native-dnd-ready],[data-ldp-native-new-topic],[data-ldp-native-new-topic-marker]"
))
node.removeAttribute("data-ldp-native-topic-date-row"), node.removeAttribute("data-ldp-native-dnd-ready"), node.removeAttribute("data-ldp-native-new-topic"), node.removeAttribute("data-ldp-native-new-topic-marker");
for (const node of root.querySelectorAll(
"[data-ldp-topic-stats]"
)) node.removeAttribute("data-ldp-topic-stats");
for (const node of root.querySelectorAll(
`[${AUTOMATIC_FILTER_ATTRIBUTE}],[${MANUAL_FILTER_ATTRIBUTE}]`
))
node.removeAttribute(AUTOMATIC_FILTER_ATTRIBUTE), node.removeAttribute(MANUAL_FILTER_ATTRIBUTE);
for (const node of root.querySelectorAll(`.${EXPOSURE_COUNT_CLASS}`))
node.remove();
for (const node of root.querySelectorAll(
"[data-ldp-inline-emoji-signature]"
)) (0, import_reader_inline_emoji.clearReaderInlineEmoji)(node);
}
#clearEmbeddedCard(card) {
card.querySelector(":scope > td.posts > .ldp-topic-stats-component")?.remove(), card.removeAttribute("data-ldp-topic-stats"), card.removeAttribute("data-ldp-native-topic-date-row");
for (const cell of card.querySelectorAll(
"[data-ldp-native-topic-date],[data-ldp-native-old-topic]"
))
cell.removeAttribute("data-ldp-native-topic-date"), cell.removeAttribute("data-ldp-native-old-topic");
}
#syncStats(card, reactions) {
const posts = card.querySelector(":scope > td.posts"), views = card.querySelector(":scope > td.views"), activity = card.querySelector(":scope > td.activity");
if (!posts || !views || !activity) {
card.removeAttribute("data-ldp-topic-stats"), card.querySelector(":scope > td.posts > .ldp-topic-stats-component")?.remove();
return;
}
card.setAttribute("data-ldp-topic-stats", "true");
let component = posts.querySelector(
":scope > .ldp-topic-stats-component"
);
const topicId = this.#cardTopicId(card), responseCount = topicId && reactions.has(topicId) ? reactions.get(topicId) : null, sourceSignature = [
[...posts.childNodes].filter((node) => node !== component).map(markup).join(""),
[...views.childNodes].map(markup).join(""),
[...activity.childNodes].map(markup).join(""),
views.dataset.ldpNativeOldTopic === "true" ? "old" : "recent",
responseCount ?? ""
].join("");
if (component?.dataset.ldpSourceSignature === sourceSignature && component.querySelectorAll(":scope > .ldp-topic-stats-row").length === 2) return;
component || (component = this.#document.createElement("div"), component.className = "ldp-topic-stats-component", posts.append(component));
const stat = (kind, label, nodes) => {
const item = this.#document.createElement("span");
item.className = `ldp-topic-stat ldp-topic-stat--${kind}`;
const caption = this.#document.createElement("span");
caption.className = "ldp-topic-stat-label", caption.textContent = label;
const value = this.#document.createElement("span");
return value.className = "ldp-topic-stat-value", value.append(...nodes), item.append(caption, value), item;
}, row = (...items) => {
const value = this.#document.createElement("span");
return value.className = "ldp-topic-stats-row", value.append(...items), value;
}, oldTopic = views.dataset.ldpNativeOldTopic === "true";
component.replaceChildren(
row(
stat("reply", "回复", sourceNodes(posts, component)),
stat(
oldTopic ? "old" : "date",
oldTopic ? "旧帖" : "最近回复",
sourceNodes(views, null)
)
),
row(
stat("activity", "活跃", sourceNodes(activity, null)),
stat(
"response",
"回应",
[this.#document.createTextNode(
responseCount == null ? "—" : String(responseCount)
)]
)
)
), component.dataset.ldpSourceSignature = sourceSignature;
}
#markDateCells(card) {
const cells = [...card.querySelectorAll(":scope > td")], dateCell = card.querySelector(":scope > td.views") ?? cells[3] ?? null;
let hasDate = !1;
for (const cell of cells) {
const text = cell === dateCell ? String(cell.textContent ?? "").replace(/\s+/g, " ").trim() : "", date = /\b(?:\d{4}[/-])?\d{1,2}[/-]\d{1,2}\s+\d{1,2}:\d{2}\b/.test(text), old = /\b\d{4}[/-]\d{1,2}[/-]\d{1,2}\s+\d{1,2}:\d{2}\b/.test(text);
hasDate = hasDate || date, cell.toggleAttribute("data-ldp-native-topic-date", date), cell.toggleAttribute("data-ldp-native-old-topic", old);
}
card.toggleAttribute("data-ldp-native-topic-date-row", hasDate);
}
#markNewTopic(card, topic) {
const previous = card.querySelector(
"[data-ldp-native-new-topic-marker]"
), legacyMarker = card.querySelector(
".badge-notification.new-topic"
), unseen = modelValue(topic, "unseen"), hostNew = unseen === !0 || unseen !== !1 && !!(legacyMarker ?? previous), topicId = Number(this.#cardTopicId(card)), marker = hostNew ? card.querySelector(NEW_TOPIC_BADGE_SELECTOR) : null;
previous && previous !== marker && previous.removeAttribute("data-ldp-native-new-topic-marker"), marker?.setAttribute("data-ldp-native-new-topic-marker", "true"), card.toggleAttribute(
"data-ldp-native-new-topic",
hostNew && !this.#openedTopicIds.has(topicId)
);
}
#restoreTopicState() {
if (!this.#openedTopicStorage) return;
let raw;
try {
raw = this.#openedTopicStorage.getItem(this.#openedTopicStorageKey);
} catch (cause) {
this.#onError(cause);
return;
}
if (raw)
try {
const parsed = JSON.parse(raw), state = (0, import_value_record.valueRecord)(parsed), opened = Array.isArray(parsed) ? parsed : state?.opened;
if (!Array.isArray(opened))
throw new TypeError("本机 Topic 曝光记录格式无效");
for (const value of opened) {
const topicId = Number(value);
Number.isSafeInteger(topicId) && topicId > 0 && this.#openedTopicIds.add(topicId);
}
const exposures = state?.exposures;
if (exposures !== void 0 && !Array.isArray(exposures))
throw new TypeError("本机 Topic 曝光次数格式无效");
for (const entry of Array.isArray(exposures) ? exposures : []) {
if (!Array.isArray(entry) || entry.length !== 2) continue;
const topicId = Number(entry[0]), count = Number(entry[1]);
Number.isSafeInteger(topicId) && topicId > 0 && Number.isSafeInteger(count) && count > 0 && !this.#openedTopicIds.has(topicId) && this.#exposureCounts.set(topicId, count);
}
} catch (cause) {
this.#onError(cause);
try {
this.#openedTopicStorage.removeItem(this.#openedTopicStorageKey);
} catch (removeCause) {
this.#onError(removeCause);
}
}
}
#persistTopicState() {
if (this.#openedTopicStorage)
try {
if (!this.#openedTopicIds.size && !this.#exposureCounts.size) {
this.#openedTopicStorage.removeItem(this.#openedTopicStorageKey);
return;
}
this.#openedTopicStorage.setItem(
this.#openedTopicStorageKey,
JSON.stringify({
version: 2,
opened: [...this.#openedTopicIds],
exposures: [...this.#exposureCounts]
})
);
} catch (cause) {
this.#onError(cause);
}
}
#deduplicateCards(cards) {
for (const card of cards) {
const container = card.closest(TOPIC_LIST_SELECTOR) ?? card.parentElement, topicId = this.#cardTopicId(card);
if (!container || !topicId || !card.isConnected) continue;
let firstCards = this.#firstTopicCardByList.get(container);
firstCards || (firstCards = /* @__PURE__ */ new Map(), this.#firstTopicCardByList.set(container, firstCards));
const existing = firstCards.get(topicId);
if (!existing || !existing.isConnected || !container.contains(existing)) {
firstCards.set(topicId, card);
continue;
}
if (existing === card) continue;
const existingComesFirst = !!(existing.compareDocumentPosition(card) & 4), duplicate = existingComesFirst ? card : existing;
this.#stopObservingExposure(duplicate), duplicate.remove(), existingComesFirst || firstCards.set(topicId, card);
}
return Object.freeze(cards.filter((card) => card.isConnected));
}
#syncExposure(card) {
const topicId = Number(this.#cardTopicId(card));
if (!(!Number.isSafeInteger(topicId) || topicId < 1)) {
if (this.#openedTopicIds.has(topicId)) {
this.#stopObservingExposure(card), card.querySelector(`.${EXPOSURE_COUNT_CLASS}`)?.remove();
return;
}
this.#renderExposureCount(card, this.#exposureCounts.get(topicId) ?? 0), this.#exposureObserver && !this.#countedExposureCards.has(card) && !this.#observedExposureCards.has(card) && (this.#observedExposureCards.add(card), this.#exposureObserver.observe(card));
}
}
#stopObservingExposure(card) {
this.#observedExposureCards.delete(card) && this.#exposureObserver?.unobserve(card);
}
#pruneDisconnectedExposureCards() {
for (const card of this.#observedExposureCards)
card.isConnected || this.#stopObservingExposure(card);
}
#onExposureIntersections(entries) {
for (const entry of entries) {
const card = entry.target;
if (!card.isConnected) {
this.#stopObservingExposure(card);
continue;
}
if (this.#document.visibilityState === "hidden" || !entry.isIntersecting || entry.intersectionRatio <= 0 || entry.intersectionRect.width <= 0 || entry.intersectionRect.height <= 0 || this.#countedExposureCards.has(card)) continue;
this.#stopObservingExposure(card), this.#countedExposureCards.add(card), this.#restoreTopicState();
const topicId = Number(this.#cardTopicId(card));
if (!Number.isSafeInteger(topicId) || topicId < 1 || this.#openedTopicIds.has(topicId)) {
this.#renderExposureCount(card, 0);
continue;
}
const count = Math.min(
Number.MAX_SAFE_INTEGER,
(this.#exposureCounts.get(topicId) ?? 0) + 1
);
this.#exposureCounts.set(topicId, count), this.#persistTopicState();
for (const current of this.#document.querySelectorAll(CARD_SELECTOR))
Number(this.#cardTopicId(current)) === topicId && this.#renderExposureCount(current, count);
}
}
#renderExposureCount(card, count) {
let badge = card.querySelector(`.${EXPOSURE_COUNT_CLASS}`);
if (!Number.isSafeInteger(count) || count < 1) {
badge?.remove();
return;
}
if (!badge) {
if (!card.querySelector(TOPIC_LINK_SELECTOR)) return;
badge = this.#document.createElement("span"), badge.className = EXPOSURE_COUNT_CLASS;
}
const readerMeta = card.querySelector(
".ldp-host-topic-reader-meta"
), bottomLine = card.querySelector(".link-bottom-line");
readerMeta ? readerMeta.after(badge) : bottomLine ? bottomLine.append(badge) : card.querySelector(TOPIC_LINK_SELECTOR)?.after(badge), badge.dataset.exposureCount = String(count), badge.textContent = `出现 ${count} 次`, badge.setAttribute(
"aria-label",
`此 Topic 已在宿主可见视野出现 ${count} 次`
), badge.title = "打开此 Topic 后将永久停止累计出现次数";
}
#groupTitleTools(card, topic) {
const titleLink = card.querySelector(TOPIC_LINK_SELECTOR), line = card.querySelector(".link-top-line") ?? titleLink?.parentElement;
if (!line) return;
const queue = card.querySelector(".ldp-reader-queue-add");
queue && titleLink && !line.contains(queue) && titleLink.after(queue);
const direct = [...line.querySelectorAll(
'a,button,[role="button"]'
)].find(
(node) => !node.matches(TOPIC_LINK_SELECTOR) && nativeDndLabel(node)
), fallback = direct ? null : [...line.querySelectorAll("span")].find(
(node) => !node.closest(TOPIC_LINK_SELECTOR) && nativeDndLabel(node)
);
let dnd = direct ?? fallback?.closest(
'a,button,[role="button"]'
) ?? fallback;
if (!dnd && topic && this.#currentUsername() && (dnd = this.#createDndButton(line, topic)), !dnd) return;
dnd.dataset.ldpNativeDnd = "true", line.dataset.ldpNativeDndReady = "true", this.#prepareDndTooltip(dnd);
const expert = [...line.querySelectorAll("*")].find((node) => normalizedLabel(node.textContent) === "专家回应");
if (!expert) return;
const startNode = directChild(line, expert), endNode = directChild(line, dnd);
if (!startNode || !endNode || startNode === endNode && startNode.classList.contains("ldp-native-topic-title-tools")) return;
const children = [...line.children], start = children.indexOf(startNode), end = children.indexOf(endNode);
if (start < 0 || end < start) return;
const group = this.#document.createElement("span");
group.className = "ldp-native-topic-title-tools", startNode.before(group), group.append(...children.slice(start, end + 1));
}
#markMobileOpAvatar(card) {
const avatar = card.querySelector(
":scope > .topic-list-data > .pull-left > [data-user-card]:first-child"
);
!avatar || avatar.hasAttribute("data-user-card-long-press") || (avatar.setAttribute("data-user-card-long-press", "true"), avatar.setAttribute(HOST_OP_AVATAR_LONG_PRESS_ATTRIBUTE, "true"));
}
#createDndButton(line, _topic) {
const button = this.#document.createElement("button");
button.type = "button", button.dataset.ldpOwnedNativeDnd = "true", button.dataset.ldpNativeDnd = "true", button.setAttribute("aria-label", "免打扰:加入不想看"), button.append((0, import_reader_icon.createReaderIcon)(this.#document, "bell-off"));
const queue = line.querySelector(".ldp-reader-queue-add"), title = line.querySelector(TOPIC_LINK_SELECTOR);
return queue ? queue.after(button) : title ? title.after(button) : line.append(button), button;
}
#onRootClick(event) {
const eventTarget = event.target, control = (eventTarget?.nodeType === 1 ? eventTarget : eventTarget?.parentElement ?? null)?.closest("[data-ldp-native-dnd]"), card = control?.closest(CARD_SELECTOR);
if (!control || !card || (event.preventDefault(), event.stopImmediatePropagation(), this.#pendingCards.has(card))) return;
const topic = this.#topicInput(card, this.#topicModels()), projection = this.#topicProjection(card, topic);
this.#hideCard(card, projection, control);
}
#recordAutomaticMatch(card, input, match) {
const record2 = Object.freeze({
topicId: input.topicId,
title: input.title,
href: input.href,
categoryId: input.categoryId,
categoryName: input.categoryName,
categorySlug: input.categorySlug,
source: "automatic",
matchedRule: match.label,
matchedCategory: match.matches.some((reason) => reason.kind === "category")
}), signature = JSON.stringify(record2);
if (this.#automaticRecordSignatures.get(card) !== signature)
try {
this.#recordAutomaticTopic(record2) !== !1 && this.#automaticRecordSignatures.set(card, signature);
} catch (cause) {
this.#onError(cause);
}
}
async #hideCard(card, input, control) {
if (!this.#pendingCards.has(card)) {
this.#pendingCards.add(card), control && (control.dataset.ldpNativeDndPending = "true", control.setAttribute("aria-busy", "true"), control.tagName === "BUTTON" && (control.disabled = !0));
try {
await this.#hideTopic(Object.freeze({
topicId: input.topicId,
title: input.title,
href: input.href,
categoryId: input.categoryId,
categoryName: input.categoryName,
categorySlug: input.categorySlug,
source: "manual",
matchedRule: "",
matchedCategory: !1
})), card.removeAttribute(AUTOMATIC_FILTER_ATTRIBUTE), card.setAttribute(MANUAL_FILTER_ATTRIBUTE, ""), this.#stopObservingExposure(card), this.#notify("已加入不想看");
} catch (cause) {
this.#onError(cause), this.#notify("加入不想看失败,请稍后重试");
} finally {
this.#pendingCards.delete(card), control?.isConnected && (delete control.dataset.ldpNativeDndPending, control.removeAttribute("aria-busy"), control.tagName === "BUTTON" && (control.disabled = !1));
}
}
}
#prepareDndTooltip(control) {
const attributes = [
"aria-label",
"title",
"data-tooltip",
"data-tippy-content",
"data-ldp-tooltip-label"
];
control.dataset.ldpOwnedNativeDnd !== "true" && !this.#nativeDndTooltipAttributes.has(control) && this.#nativeDndTooltipAttributes.set(control, new Map(
attributes.map((name) => [
name,
control.getAttribute(name)
])
)), control.setAttribute("aria-label", "免打扰:加入不想看");
for (const name of attributes.slice(1)) control.removeAttribute(name);
}
#restoreNativeDndTooltip(control) {
delete control.dataset.ldpTooltipLabel;
const attributes = this.#nativeDndTooltipAttributes.get(control);
if (attributes) {
for (const [name, value] of attributes)
value === null ? control.removeAttribute(name) : control.setAttribute(name, value);
this.#nativeDndTooltipAttributes.delete(control);
}
}
#currentUsername() {
return normalizedLabel(modelValue(
this.#host.lookup("service:current-user"),
"username"
));
}
#topicModels() {
const result = /* @__PURE__ */ new Map(), router = this.#host.lookup("service:router"), routeName = String(
modelValue(router, "currentRouteName") ?? ""
).trim(), controllerNames = [
routeName ? `controller:${routeName}` : "",
routeName ? `controller:${routeName.replaceAll(".", "/")}` : "",
"controller:discovery/list",
"controller:tag/show",
"controller:tags/intersection"
].filter(Boolean), seen = /* @__PURE__ */ new Set();
for (const name of controllerNames) {
const controller = this.#host.lookup(name);
if (!controller || seen.has(controller)) continue;
seen.add(controller);
const model = modelValue(controller, "model"), list = modelValue(model, "list") ?? modelValue(controller, "list") ?? model, topics = modelArray(
modelValue(list, "topics") ?? modelValue(list, "content")
);
for (const topic of topics) {
const topicId = String(modelValue(topic, "id") ?? "").trim();
!topicId || result.has(topicId) || result.set(topicId, topic);
}
}
return result;
}
#reactionCounts(topics) {
const counts = /* @__PURE__ */ new Map();
for (const [topicId, topic] of topics) {
const reactions = modelValue(topic, "op_reactions_data") ?? modelValue(topic, "opReactionsData"), total = reactionCountTotal(reactions), raw = reactions ? total ?? modelValue(reactions, "reaction_users_count") ?? modelValue(reactions, "reactionUsersCount") : modelValue(topic, "op_like_count") ?? modelValue(topic, "opLikeCount"), numeric = Number(raw);
counts.set(
topicId,
Number.isFinite(numeric) && numeric >= 0 ? Math.trunc(numeric) : null
);
}
return counts;
}
#topicInput(card, topics) {
const topicId = this.#cardTopicId(card);
if (!topicId) return null;
const model = topics.get(topicId);
if (model) return model;
const numericId = Number(topicId);
if (!Number.isSafeInteger(numericId) || numericId < 1) return null;
const link = card.querySelector(TOPIC_LINK_SELECTOR);
return Object.freeze({
id: numericId,
title: normalizedLabel(link?.textContent),
slug: "topic",
notification_level: 1
});
}
#topicProjection(card, topic) {
const topicId = Number(this.#cardTopicId(card));
if (!Number.isSafeInteger(topicId) || topicId < 1)
throw new Error("宿主 Topic 卡片缺少有效 topic.id");
const link = card.querySelector(TOPIC_LINK_SELECTOR), category = modelValue(topic, "category"), categoryIdRaw = Number(
modelValue(topic, "category_id") ?? modelValue(category, "id")
), categoryName = normalizedLabel(
modelValue(category, "name") ?? modelValue(topic, "category_name") ?? card.querySelector(".category-name,.badge-category__name")?.textContent
), categorySlug = normalizedLabel(
modelValue(category, "slug") ?? modelValue(topic, "category_slug")
), rawLabels = modelArray(
modelValue(topic, "tags") ?? modelValue(topic, "topic_tags")
), labels = /* @__PURE__ */ new Map(), rememberLabel = (value) => {
const label = normalizedLabel(
typeof value == "string" ? value : modelValue(value, "name") ?? modelValue(value, "id") ?? modelValue(value, "slug")
), key = label.toLocaleLowerCase("zh-CN");
key && !labels.has(key) && labels.set(key, label);
};
for (const label of rawLabels) rememberLabel(label);
for (const label of card.querySelectorAll(
".discourse-tag,[data-tag-name],.list-tags a"
))
rememberLabel(label.dataset.tagName ?? label.textContent);
const creator = modelValue(topic, "creator"), posters = modelArray(modelValue(topic, "posters")), opPoster = posters.find((poster) => {
const description = normalizedLabel(modelValue(poster, "description"));
return /\b(?:original poster|op)\b|楼主|发帖人/i.test(description) || modelValue(poster, "original_poster") === !0;
}) ?? posters[0], domPoster = card.querySelector(
".posters a[data-user-card].original-poster,.posters a[data-user-card]:first-child,.posters [data-user-card]:first-child"
), authorUsername = normalizedLabel(
modelValue(creator, "username") ?? modelValue(topic, "creator_username") ?? modelValue(opPoster, "username") ?? domPoster?.dataset.userCard
).replace(/^@+/, "");
return Object.freeze({
topicId,
title: normalizedLabel(
modelValue(topic, "title") ?? link?.textContent
) || `帖子 #${topicId}`,
href: link?.getAttribute("href") ?? `/t/${topicId}`,
categoryId: Number.isSafeInteger(categoryIdRaw) && categoryIdRaw > 0 ? categoryIdRaw : null,
categoryName,
categorySlug,
labels: Object.freeze([...labels.values()]),
authorUsername
});
}
#cardTopicId(card) {
return String(
card.dataset.topicId ?? card.getAttribute("data-topic-id") ?? this.#topicId(card.querySelector(TOPIC_LINK_SELECTOR))
).trim();
}
#topicId(link) {
return (link?.getAttribute("href") ?? "").match(/\/t\/(?:[^/]+\/)?(\d+)(?:\/|$)/)?.[1] ?? "";
}
}
}, "7d82758ab5f65987170f6e35a714a5c85fc6be173889ac896c37d5d168b1ae5e");
/* Source: lite/src/shell/main-outlet-mutation-hub.ts */
runtime.register("src/shell/main-outlet-mutation-hub.js", function(module, exports, require) {
var main_outlet_mutation_hub_exports = {};
__export(main_outlet_mutation_hub_exports, {
MainOutletMutationHub: () => MainOutletMutationHub
});
module.exports = __toCommonJS(main_outlet_mutation_hub_exports);
const MAIN_OUTLET_SELECTORS = Object.freeze([
"#main-outlet",
".list-container,.topic-list,.latest-topic-list",
"#ember-app"
]);
function mainOutlet(documentPort) {
for (const selector of MAIN_OUTLET_SELECTORS) {
const root = documentPort.querySelector(selector);
if (root) return root;
}
return null;
}
class MainOutletMutationHub {
#document;
#createObserver;
#onListenerError;
#listeners = /* @__PURE__ */ new Set();
#observer = null;
#root = null;
#destroyed = !1;
constructor(options) {
this.#document = options.document, this.#createObserver = options.createObserver ?? ((callback) => new MutationObserver(callback)), this.#onListenerError = options.onListenerError ?? (() => {
});
}
get currentRoot() {
return this.#root;
}
subscribe(listener, scope) {
if (this.#destroyed) throw new Error("MainOutletMutationHub 已销毁");
this.#listeners.add(listener), this.#listeners.size === 1 && this.#start(), this.#notifyOne(listener, Object.freeze({
root: this.#root,
rootChanged: !0,
records: Object.freeze([])
}));
let active = !0;
const unsubscribe = () => {
active && (active = !1, this.#listeners.delete(listener), this.#listeners.size || this.#stop());
};
return scope?.add(unsubscribe), unsubscribe;
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.#listeners.clear(), this.#stop());
}
#start() {
this.#observer || !this.#document.body || (this.#observer = this.#createObserver((records) => this.#receive(records)), this.#retarget());
}
#stop() {
this.#observer?.disconnect(), this.#observer = null, this.#root = null;
}
#retarget() {
const observer = this.#observer, body = this.#document.body;
if (!observer || !body) return !1;
const nextRoot = mainOutlet(this.#document);
if (this.#root === nextRoot && nextRoot?.isConnected) return !1;
if (observer.disconnect(), observer.observe(body, { childList: !0 }), nextRoot) {
let ancestor = nextRoot.parentElement;
for (; ancestor && ancestor !== body; )
observer.observe(ancestor, { childList: !0 }), ancestor = ancestor.parentElement;
observer.observe(nextRoot, {
childList: !0,
subtree: !0,
characterData: !0
});
}
return this.#root = nextRoot, !0;
}
#receive(records) {
if (this.#destroyed || !this.#listeners.size) return;
const body = this.#document.body, rootChanged = records.some(
(record) => record.target === body || !this.#root?.isConnected || !this.#root.contains(record.target)
) ? this.#retarget() : !1, batch = Object.freeze({
root: this.#root,
rootChanged,
records: Object.freeze([...records])
});
for (const listener of [...this.#listeners]) this.#notifyOne(listener, batch);
}
#notifyOne(listener, batch) {
try {
listener(batch);
} catch (error) {
this.#onListenerError(error);
}
}
}
}, "1b77cf81270ca6322e13b2672695a5a2fcb0a16f057ebe5dc6b8817a3c54763c");
/* Source: lite/src/shell/reader-action-form-support.ts */
runtime.register("src/shell/reader-action-form-support.js", function(module, exports, require) {
var reader_action_form_support_exports = {};
__export(reader_action_form_support_exports, {
ReaderActionFormSession: () => ReaderActionFormSession,
ReaderActionFormSurfaceHost: () => ReaderActionFormSurfaceHost,
ReaderActionFormTiming: () => ReaderActionFormTiming,
createReaderActionFormFrame: () => createReaderActionFormFrame,
handleReaderActionDialogKeydown: () => handleReaderActionDialogKeydown,
renderReaderActionIcon: () => renderReaderActionIcon,
setReaderActionFormBusy: () => setReaderActionFormBusy
});
module.exports = __toCommonJS(reader_action_form_support_exports);
var import_event_target = require("../dom/event-target.js"), import_floating_surface_wheel = require("../dom/floating-surface-wheel.js"), import_reader_icon = require("../components/reader-icon.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_action_surface_coordinator = require("./reader-action-surface-coordinator.js");
function renderReaderActionIcon(document, name, renderIcon) {
return (0, import_reader_icon.renderReaderIcon)(document, name, renderIcon);
}
function createReaderActionFormFrame(options) {
const { document } = options, layer = document.createElement("div");
layer.className = "ldp-reader-action-layer";
const dialog = document.createElement("section");
dialog.className = "ldp-reader-action-dialog", dialog.setAttribute("role", "dialog"), dialog.setAttribute("aria-modal", "true"), dialog.setAttribute("aria-labelledby", options.titleId);
const head = document.createElement("div");
head.className = "ldp-reader-action-head";
const title = document.createElement("strong");
title.id = options.titleId, title.textContent = options.title;
const close = document.createElement("button");
close.type = "button", close.className = "ldp-reader-action-close", close.setAttribute(options.closeDataAttribute, ""), close.setAttribute("aria-label", "关闭"), close.append(renderReaderActionIcon(document, "x", options.renderIcon)), head.append(title, close);
const form = document.createElement("form");
form.className = "ldp-reader-action-form";
const body = document.createElement("div");
if (body.className = "ldp-reader-action-body", options.intro) {
const intro = document.createElement("p");
intro.className = "ldp-reader-action-intro", intro.textContent = options.intro, body.append(intro);
}
const status = document.createElement("p");
status.className = "ldp-reader-action-status", status.setAttribute("role", "status"), status.setAttribute("aria-live", "polite"), body.append(status);
const footer = document.createElement("div");
footer.className = "ldp-reader-action-footer";
const cancel = document.createElement("button");
cancel.type = "button", cancel.className = "ldp-reader-action-cancel", cancel.setAttribute(options.cancelDataAttribute, ""), cancel.textContent = "取消";
const submit = document.createElement("button");
return submit.type = "submit", submit.className = "ldp-reader-action-submit", submit.textContent = options.submitLabel, footer.append(cancel, submit), form.append(body, footer), dialog.append(head, form), layer.append(dialog), { layer, dialog, form, body, status, cancel, submit };
}
class ReaderActionFormTiming {
#document;
#successDelayMs;
#schedule;
#cancel;
#focusSoon;
#closeTimer = null;
constructor(document, options) {
if (this.#document = document, this.#successDelayMs = Number(options.successDelayMs ?? 650), !Number.isFinite(this.#successDelayMs) || this.#successDelayMs < 0)
throw new RangeError("successDelayMs 必须是非负有限数值");
this.#schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs)), this.#cancel = options.cancel ?? ((handle) => clearTimeout(
handle
)), this.#focusSoon = options.focusSoon ?? ((callback) => {
const view = this.#document.defaultView;
view?.requestAnimationFrame ? view.requestAnimationFrame(callback) : queueMicrotask(callback);
});
}
focus(callback) {
this.#focusSoon(callback);
}
restore(previousFocus) {
this.focus(() => {
previousFocus?.isConnected && typeof previousFocus.focus == "function" && previousFocus.focus({ preventScroll: !0 });
});
}
scheduleClose(callback) {
this.clear(), this.#closeTimer = this.#schedule(callback, this.#successDelayMs);
}
clear() {
this.#closeTimer !== null && (this.#cancel(this.#closeTimer), this.#closeTimer = null);
}
}
function setReaderActionFormBusy(form, submit, busy, busyLabel, idleLabel) {
for (const control of form.querySelectorAll("input,textarea,select,button"))
control.disabled = busy;
submit.textContent = busy ? busyLabel : idleLabel;
}
function handleReaderActionDialogKeydown(event, document, dialog, busy, close) {
if (event.key === "Escape" && !busy) {
event.preventDefault(), close();
return;
}
if (event.key !== "Tab") return;
const controls = [
...dialog.querySelectorAll(
"input:not(:disabled),textarea:not(:disabled),select:not(:disabled),button:not(:disabled)"
)
];
if (!controls.length) return;
const first = controls[0], last = controls.at(-1), active = (0, import_event_target.deepActiveElement)(document);
event.shiftKey && active === first ? (event.preventDefault(), last.focus()) : !event.shiftKey && active === last && (event.preventDefault(), first.focus());
}
class ReaderActionFormSurfaceHost {
scope;
document;
renderIcon;
#root;
#label;
#timing;
#coordinator;
#active = null;
#id = 0;
constructor(options) {
this.document = options.document, this.#root = options.root, this.#label = options.label, this.renderIcon = options.renderIcon, this.#timing = new ReaderActionFormTiming(options.document, options), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#coordinator = options.coordinator ?? new import_reader_action_surface_coordinator.ReaderActionSurfaceCoordinator({ parentScope: this.scope }), this.scope.add(() => this.close(!1));
}
prepare() {
this.#assertActive();
const previousFocus = this.#active?.previousFocus ?? (0, import_event_target.deepActiveElement)(this.document);
return this.close(!1), Object.freeze({
id: ++this.#id,
previousFocus
});
}
start(options) {
this.#assertActive();
let session;
return session = new ReaderActionFormSession({
document: this.document,
root: this.#root,
frame: options.frame,
timing: this.#timing,
coordinator: this.#coordinator,
previousFocus: options.previousFocus,
closeSelector: options.closeSelector,
cancelSelector: options.cancelSelector,
signal: options.signal,
onSettled: () => {
this.#active === session && (this.#active = null), options.onSettled?.();
}
}), this.#active = session, session;
}
close(submitted = !1) {
this.#active?.settle(submitted);
}
destroy() {
this.scope.destroy();
}
#assertActive() {
if (this.scope.destroyed)
throw new Error(`${this.#label} 已销毁`);
}
}
class ReaderActionFormSession {
previousFocus;
result;
#options;
#resolve;
#releaseAction = () => {
};
#removeAbortListener = () => {
};
#busy = !1;
#mounted = !1;
#settled = !1;
constructor(options) {
this.#options = options, this.previousFocus = options.previousFocus, this.result = new Promise((resolve) => {
this.#resolve = resolve;
});
}
get active() {
return !this.#settled;
}
get busy() {
return this.#busy;
}
setBusy(busy) {
this.#settled || (this.#busy = busy);
}
mount(focus) {
if (this.#mounted || this.#settled) return;
this.#mounted = !0;
const { document, root, frame, coordinator, signal } = this.#options;
if (this.#releaseAction = coordinator.claim(() => this.settle(!1)), frame.layer.addEventListener("click", (event) => {
const target = event.target;
target?.closest(this.#options.closeSelector) ? this.settle(!1) : !this.#busy && (event.target === frame.layer || target?.closest(this.#options.cancelSelector)) && this.settle(!1);
}), frame.layer.addEventListener("keydown", (event) => {
handleReaderActionDialogKeydown(
event,
document,
frame.dialog,
this.#busy,
() => this.settle(!1)
);
}), frame.layer.addEventListener("wheel", (event) => (0, import_floating_surface_wheel.containFloatingSurfaceWheel)(frame.layer, event), {
passive: !1
}), root.append(frame.layer), signal) {
const onAbort = () => this.settle(!1);
signal.addEventListener("abort", onAbort, { once: !0 }), this.#removeAbortListener = () => signal.removeEventListener("abort", onAbort), signal.aborted && this.settle(!1);
}
this.#options.timing.focus(() => {
frame.layer.isConnected && focus();
});
}
resetStatus() {
this.#options.frame.status.classList.remove("success"), this.#options.frame.status.textContent = "";
}
submit(options) {
if (this.#busy || this.#settled) return;
this.setBusy(!0), options.setBusy(!0);
let pending;
try {
pending = options.execute();
} catch (cause) {
this.#rejectSubmission(cause, options);
return;
}
Promise.resolve(pending).then((value) => {
if (this.#settled) return;
const status = this.#options.frame.status;
status.classList.add("success"), status.textContent = options.successMessage(value), this.#options.timing.scheduleClose(() => this.settle(!0));
}).catch((cause) => this.#rejectSubmission(cause, options));
}
settle(submitted) {
this.#settled || (this.#settled = !0, this.#releaseAction(), this.#removeAbortListener(), this.#options.timing.clear(), this.#options.frame.layer.remove(), this.#options.onSettled?.(), this.#options.timing.restore(this.previousFocus), this.#resolve(submitted));
}
#rejectSubmission(cause, options) {
this.#settled || (this.#options.frame.status.textContent = options.failureMessage(cause), this.setBusy(!1), options.setBusy(!1));
}
}
}, "6d9b160295ad415aa13e84b6cf19abf62ff4ef8e5b7ba6e4f83396376b87290e");
/* Source: lite/src/shell/reader-action-surface-coordinator.ts */
runtime.register("src/shell/reader-action-surface-coordinator.js", function(module, exports, require) {
var reader_action_surface_coordinator_exports = {};
__export(reader_action_surface_coordinator_exports, {
ReaderActionSurfaceCoordinator: () => ReaderActionSurfaceCoordinator
});
module.exports = __toCommonJS(reader_action_surface_coordinator_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
class ReaderActionSurfaceCoordinator {
scope;
#active = null;
constructor(options = {}) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => {
this.closeActive();
});
}
get active() {
return this.#active !== null;
}
claim(close) {
if (this.scope.destroyed)
throw new Error("ReaderActionSurfaceCoordinator 已销毁");
const previous = this.#active;
this.#active = null, previous?.close();
const token = Symbol("reader-action-surface");
this.#active = { token, close };
let released = !1;
return () => {
released || (released = !0, this.#active?.token === token && (this.#active = null));
};
}
destroy() {
this.scope.destroy();
}
closeActive() {
const active = this.#active;
return this.#active = null, active?.close(), active !== null;
}
}
}, "fe11b3d151a62bd97b52adbcfe525c6e29c3e75aa7df95756ea3520b0442456c");
/* Source: lite/src/shell/reader-assignment-form-surface.ts */
runtime.register("src/shell/reader-assignment-form-surface.js", function(module, exports, require) {
var reader_assignment_form_surface_exports = {};
__export(reader_assignment_form_surface_exports, {
ReaderAssignmentFormSurface: () => ReaderAssignmentFormSurface
});
module.exports = __toCommonJS(reader_assignment_form_surface_exports);
var import_reader_action_form_support = require("./reader-action-form-support.js");
class ReaderAssignmentFormSurface {
scope;
#host;
#users;
#scheduleLookup;
#cancelLookup;
constructor(options) {
this.#host = new import_reader_action_form_support.ReaderActionFormSurfaceHost({
...options,
label: "ReaderAssignmentFormSurface"
}), this.scope = this.#host.scope, this.#users = options.users, this.#scheduleLookup = options.schedule ?? ((callback, delayMs) => globalThis.setTimeout(callback, delayMs)), this.#cancelLookup = options.cancel ?? ((handle) => globalThis.clearTimeout(
handle
));
}
open(request) {
if (request.signal?.aborted) return Promise.resolve(!1);
const { id, previousFocus } = this.#host.prepare(), document = this.#host.document, titleId = `ldp-reader-assignment-title-${id}`, frame = (0, import_reader_action_form_support.createReaderActionFormFrame)({
document,
titleId,
title: request.title,
intro: request.intro,
closeDataAttribute: "data-assignment-close",
cancelDataAttribute: "data-assignment-cancel",
submitLabel: "确认指定",
renderIcon: this.#host.renderIcon
});
frame.dialog.classList.add("ldp-reader-assignment-dialog");
const { form, status, submit } = frame, usernameField = document.createElement("label");
usernameField.className = "ldp-reader-action-field";
const usernameLookup = document.createElement("div");
usernameLookup.className = "ldp-reader-assignment-user-lookup";
const usernameLabel = document.createElement("span");
usernameLabel.textContent = "用户名";
const username = document.createElement("input");
username.name = "username", username.type = "text", username.maxLength = 100, username.autocomplete = "off", username.placeholder = "不含 @", username.required = !0, username.value = String(request.initialUsername ?? "").replace(/^@+/, ""), username.setAttribute("aria-describedby", `${titleId}-status`), username.setAttribute("aria-autocomplete", "list"), username.setAttribute("aria-controls", `${titleId}-candidates`), username.setAttribute("aria-expanded", "false"), status.id = `${titleId}-status`, usernameField.append(usernameLabel, username);
const candidates = document.createElement("div");
candidates.id = `${titleId}-candidates`, candidates.className = "ldp-reader-assignment-user-candidates", candidates.setAttribute("role", "listbox"), candidates.setAttribute("aria-label", "匹配的社区用户"), candidates.hidden = !0, usernameLookup.append(usernameField, candidates);
const noteField = document.createElement("label");
noteField.className = "ldp-reader-action-field";
const noteLabel = document.createElement("span");
noteLabel.textContent = "备注(选填)";
const note = document.createElement("textarea");
note.name = "note", note.maxLength = 1e3, note.placeholder = "说明这次指定的原因或任务", noteField.append(noteLabel, note), status.before(usernameLookup, noteField);
let lookupTimer = null, lookupSequence = 0, verifiedInput = "", verifiedUsername = "", candidateUsers = Object.freeze([]);
const cancelLookup = () => {
lookupSequence += 1, lookupTimer !== null && this.#cancelLookup(lookupTimer), lookupTimer = null;
}, session = this.#host.start({
frame,
previousFocus,
closeSelector: "[data-assignment-close]",
cancelSelector: "[data-assignment-cancel]",
signal: request.signal,
onSettled: cancelLookup
}), normalizeUsername = (value) => value.trim().replace(/^@+/, ""), usernameKey = (value) => normalizeUsername(value).toLocaleLowerCase("zh-CN"), showLookupStatus = (message, tone) => {
status.textContent = message, status.classList.toggle("success", tone === "success"), status.classList.toggle("is-neutral", tone === "neutral");
}, hideCandidates = () => {
candidates.hidden = !0, username.setAttribute("aria-expanded", "false");
}, clearCandidates = () => {
candidateUsers = Object.freeze([]), candidates.replaceChildren(), hideCandidates();
}, selectUser = (user) => {
const selectedUsername = normalizeUsername(user.username);
selectedUsername && (cancelLookup(), username.value = selectedUsername, verifiedInput = usernameKey(selectedUsername), verifiedUsername = selectedUsername, username.setAttribute("aria-invalid", "false"), submit.disabled = !1, clearCandidates(), showLookupStatus(`已选择 @${selectedUsername}。`, "success"), username.focus({ preventScroll: !0 }));
}, renderCandidates = (users) => {
candidateUsers = Object.freeze(users.filter((user) => normalizeUsername(user.username)).slice(0, 20)), candidates.replaceChildren(...candidateUsers.map((user, index) => {
const button = document.createElement("button");
button.type = "button", button.dataset.assignmentUserCandidate = String(index), button.setAttribute("role", "option"), button.setAttribute("aria-selected", String(
usernameKey(user.username) === usernameKey(verifiedUsername)
));
const name = document.createElement("strong");
name.textContent = String(user.name ?? "").trim() || `@${user.username}`;
const detail = document.createElement("small");
return detail.textContent = `@${user.username}${user.id ? ` · #${user.id}` : ""}`, button.append(name, detail), button;
})), candidates.hidden = candidateUsers.length === 0, username.setAttribute(
"aria-expanded",
String(candidateUsers.length > 0)
);
}, scheduleLookup = (revealCandidates = !0) => {
if (cancelLookup(), !session.active) return;
verifiedInput = "", verifiedUsername = "", submit.disabled = !0, username.removeAttribute("aria-invalid"), clearCandidates();
const query = normalizeUsername(username.value);
if (!query) {
showLookupStatus(
"输入后会检索社区用户,只有真实用户名可以指定。",
"neutral"
);
return;
}
const sequence = lookupSequence;
showLookupStatus("正在检索用户…", "neutral"), lookupTimer = this.#scheduleLookup(() => {
lookupTimer = null, this.#users.searchUsers(query).then((users) => {
if (!session.active || sequence !== lookupSequence) return;
const key = usernameKey(query), numericId = /^\d+$/.test(query) ? Number(query) : null, visibleUsers = users.filter((user) => normalizeUsername(user.username)), matched = visibleUsers.find((user) => usernameKey(user.username) === key || Number.isSafeInteger(numericId) && numericId > 0 && user.id === numericId);
if (revealCandidates && renderCandidates(visibleUsers), matched) {
verifiedInput = key, verifiedUsername = normalizeUsername(matched.username), username.setAttribute("aria-invalid", "false"), submit.disabled = !1, showLookupStatus(
`已找到 @${verifiedUsername}。`,
"success"
);
return;
}
if (!visibleUsers.length) {
username.setAttribute("aria-invalid", "true"), showLookupStatus(
`没有找到“${query}”对应的社区用户。`,
"error"
);
return;
}
showLookupStatus(
`找到 ${visibleUsers.length} 个候选,请选择具体用户。`,
"neutral"
);
}).catch(() => {
!session.active || sequence !== lookupSequence || (clearCandidates(), username.setAttribute("aria-invalid", "true"), showLookupStatus("用户检索失败,请稍后重试。", "error"));
});
}, 240);
};
return username.addEventListener("input", () => scheduleLookup()), username.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !candidates.hidden) {
event.preventDefault(), hideCandidates();
return;
}
if (event.key !== "ArrowDown" || candidates.hidden) return;
const first = candidates.querySelector("button");
first && (event.preventDefault(), first.focus({ preventScroll: !0 }));
}), candidates.addEventListener("click", (event) => {
const button = event.target?.closest(
"button[data-assignment-user-candidate]"
);
if (!button) return;
const user = candidateUsers[Number(button.dataset.assignmentUserCandidate)];
user && selectUser(user);
}), candidates.addEventListener("keydown", (event) => {
const buttons = [...candidates.querySelectorAll("button")], current = event.target?.closest(
"button[data-assignment-user-candidate]"
), index = current ? buttons.indexOf(current) : -1;
if (event.key === "Escape") {
event.preventDefault(), hideCandidates(), username.focus({ preventScroll: !0 });
return;
}
if (!buttons.length || !["ArrowDown", "ArrowUp"].includes(event.key)) return;
event.preventDefault();
const offset = event.key === "ArrowDown" ? 1 : -1;
buttons[(index + offset + buttons.length) % buttons.length]?.focus({ preventScroll: !0 });
}), form.addEventListener("focusin", (event) => {
usernameLookup.contains(event.target) || hideCandidates();
}), form.addEventListener("submit", (event) => {
if (event.preventDefault(), session.busy) return;
const normalizedUsername = normalizeUsername(username.value), normalizedNote = note.value.trim();
if (!normalizedUsername) {
scheduleLookup(), username.focus();
return;
}
if (!verifiedUsername || verifiedInput !== usernameKey(normalizedUsername)) {
scheduleLookup(), username.focus();
return;
}
status.classList.remove("is-neutral"), session.resetStatus(), session.submit({
execute: () => request.submit(Object.freeze({
username: verifiedUsername,
note: normalizedNote
})),
setBusy: (busy) => (0, import_reader_action_form_support.setReaderActionFormBusy)(
form,
submit,
busy,
"指定中…",
"确认指定"
),
successMessage: (message) => message || "指定已更新",
failureMessage: (cause) => cause instanceof Error ? cause.message : "指定失败,请重试"
});
}), session.mount(() => username.focus({ preventScroll: !0 })), scheduleLookup(!1), session.result;
}
destroy() {
this.#host.destroy();
}
}
}, "bafbf9aad17fff35ba421f5cd5887e74f855572affd55569a16854e4972f8dff");
/* Source: lite/src/shell/reader-browser-immersive-controller.ts */
runtime.register("src/shell/reader-browser-immersive-controller.js", function(module, exports, require) {
var reader_browser_immersive_controller_exports = {};
__export(reader_browser_immersive_controller_exports, {
ReaderBrowserImmersiveController: () => ReaderBrowserImmersiveController
});
module.exports = __toCommonJS(reader_browser_immersive_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
const MOBILE_IMMERSIVE_QUERY = "(max-width: 700px) and (hover: none) and (pointer: coarse)";
class ReaderBrowserImmersiveController {
#options;
#scope;
#target;
#owned = !1;
#pending = !1;
#exitAfterRequest = !1;
constructor(options) {
this.#options = options, this.#scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#target = options.document.documentElement;
const supported = this.#supported();
options.button.hidden = !supported, options.button.dataset.immersiveSupported = String(supported), this.#sync(), supported && (this.#scope.listen(options.button, "click", (event) => {
event.preventDefault(), event.stopPropagation(), this.#toggle();
}), this.#scope.listen(options.document, "fullscreenchange", () => {
options.document.fullscreenElement !== this.#target && (this.#owned = !1), this.#sync();
}), this.#scope.add(() => {
this.exit();
}));
}
get supported() {
return this.#supported();
}
exit() {
!this.#owned || typeof this.#options.document.exitFullscreen != "function" || (this.#exitAfterRequest = this.#pending, this.#options.document.fullscreenElement === this.#target && (this.#owned = !1, this.#options.document.exitFullscreen().catch(() => {
}), this.#sync()));
}
destroy() {
this.#scope.destroy();
}
#supported() {
return this.#options.document.defaultView?.matchMedia?.(
MOBILE_IMMERSIVE_QUERY
).matches === !0 && this.#options.document.fullscreenEnabled !== !1 && typeof this.#target.requestFullscreen == "function" && typeof this.#options.document.exitFullscreen == "function";
}
#toggle() {
if (this.#pending) return;
if (this.#options.document.fullscreenElement === this.#target) {
this.exit();
return;
}
if (this.#options.document.fullscreenElement !== null) {
this.#options.showFeedback?.("浏览器已有其他全屏内容,请先退出后重试。");
return;
}
this.#options.enterReaderFullpage(), this.#pending = !0, this.#owned = !0, this.#exitAfterRequest = !1, this.#sync();
let request;
try {
request = this.#target.requestFullscreen();
} catch {
this.#pending = !1, this.#owned = !1, this.#sync(), this.#options.showFeedback?.("浏览器未允许进入沉浸式阅读。");
return;
}
request.then(() => {
this.#exitAfterRequest && (this.#exitAfterRequest = !1, this.#owned = !1, this.#options.document.fullscreenElement === this.#target && typeof this.#options.document.exitFullscreen == "function" && this.#options.document.exitFullscreen().catch(() => {
}));
}).catch(() => {
this.#owned = !1, this.#exitAfterRequest = !1, this.#options.showFeedback?.("浏览器未允许进入沉浸式阅读。");
}).finally(() => {
this.#pending = !1, this.#sync();
});
}
#sync() {
const active = this.#options.document.fullscreenElement === this.#target, label = active ? "退出沉浸式阅读" : "进入沉浸式阅读";
this.#options.button.disabled = this.#pending, this.#options.button.classList.toggle("is-active", active), this.#options.button.setAttribute("aria-pressed", String(active)), this.#options.button.setAttribute("aria-label", label), this.#options.button.title = label;
}
}
}, "a14568bdd407605435e50db25d93cd156d7065067015b251b201dfdd41aa84d4");
/* Source: lite/src/shell/reader-choice-form-surface.ts */
runtime.register("src/shell/reader-choice-form-surface.js", function(module, exports, require) {
var reader_choice_form_surface_exports = {};
__export(reader_choice_form_surface_exports, {
ReaderChoiceFormSurface: () => ReaderChoiceFormSurface
});
module.exports = __toCommonJS(reader_choice_form_surface_exports);
var import_reader_action_form_support = require("./reader-action-form-support.js");
class ReaderChoiceFormSurface {
scope;
#host;
constructor(options) {
this.#host = new import_reader_action_form_support.ReaderActionFormSurfaceHost({
...options,
label: "ReaderChoiceFormSurface"
}), this.scope = this.#host.scope;
}
open(request) {
if (request.signal?.aborted) return Promise.resolve(!1);
const options = request.options.filter(
(option) => String(option.value).trim() && String(option.label).trim()
);
if (!options.length)
return Promise.reject(new Error("当前没有可用选项"));
const { id, previousFocus } = this.#host.prepare(), document = this.#host.document, titleId = `ldp-reader-choice-title-${id}`, submitLabel = request.submitLabel ?? "提交", frame = (0, import_reader_action_form_support.createReaderActionFormFrame)({
document,
titleId,
title: request.title,
intro: request.intro,
closeDataAttribute: "data-choice-close",
cancelDataAttribute: "data-choice-cancel",
submitLabel,
renderIcon: this.#host.renderIcon
}), { form, status, submit } = frame;
let firstChoice = null;
if (request.mode === "select") {
const field = document.createElement("label");
if (field.className = "ldp-reader-action-field", request.fieldLabel) {
const label = document.createElement("span");
label.textContent = request.fieldLabel, field.append(label);
}
const select = document.createElement("select");
select.className = "ldp-reader-select", select.name = `reader-choice-${id}`;
for (const option of options) {
const item = document.createElement("option");
item.value = option.value, item.textContent = option.label, item.selected = option.selected === !0, item.disabled = option.disabled === !0, select.append(item);
}
field.append(select), status.before(field), firstChoice = select;
} else {
const choices = document.createElement("div");
choices.className = "ldp-reader-action-options";
for (const option of options) {
const label = document.createElement("label");
label.className = "ldp-reader-action-option";
const input = document.createElement("input");
input.type = "checkbox", input.name = `reader-choice-${id}`, input.value = option.value, input.checked = option.selected === !0, input.disabled = option.disabled === !0;
const copy = document.createElement("span");
copy.className = "ldp-reader-action-option-copy";
const name = document.createElement("strong");
if (name.textContent = option.label, copy.append(name), option.description) {
const description = document.createElement("small");
description.textContent = option.description, copy.append(description);
}
label.append(input, copy), choices.append(label), !firstChoice && !input.disabled && (firstChoice = input);
}
status.before(choices);
}
const disabled = /* @__PURE__ */ new Map(), session = this.#host.start({
frame,
previousFocus,
closeSelector: "[data-choice-close]",
cancelSelector: "[data-choice-cancel]",
signal: request.signal
}), setBusy = (busy) => {
for (const control of form.querySelectorAll("input,select,button"))
busy && disabled.set(control, control.disabled), control.disabled = busy || disabled.get(control) === !0;
submit.textContent = busy ? request.busyLabel ?? "提交中…" : submitLabel;
};
return form.addEventListener("submit", (event) => {
if (event.preventDefault(), session.busy) return;
const values = [...form.querySelectorAll(
`[name="reader-choice-${id}"]`
)].flatMap((control) => {
if (control.tagName === "SELECT")
return control.value ? [control.value] : [];
const input = control;
return input.checked && !input.disabled ? [input.value] : [];
});
if (session.resetStatus(), !values.length) {
status.textContent = request.emptySelectionError ?? "请选择至少一个选项", firstChoice?.focus();
return;
}
session.submit({
execute: () => request.submit(Object.freeze(values)),
setBusy,
successMessage: (message) => message ?? request.successMessage ?? "操作已完成",
failureMessage: (cause) => cause instanceof Error ? cause.message : "操作失败,请重试"
});
}), session.mount(() => firstChoice?.focus({ preventScroll: !0 })), session.result;
}
destroy() {
this.#host.destroy();
}
}
}, "723b9450b79cc805e438b79f39d532588d39337494ea98895e5324ea4bf12ebb");
/* Source: lite/src/shell/reader-embed-resize-controller.ts */
runtime.register("src/shell/reader-embed-resize-controller.js", function(module, exports, require) {
var reader_embed_resize_controller_exports = {};
__export(reader_embed_resize_controller_exports, {
ReaderEmbedResizeController: () => ReaderEmbedResizeController
});
module.exports = __toCommonJS(reader_embed_resize_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
class ReaderEmbedResizeController {
scope;
#model;
#pageRoot;
#overlay;
#handle;
#readViewportWidth;
#onPersist;
#requestFrame;
#cancelFrame;
#pointer = null;
#frame = 0;
#destroyed = !1;
constructor(options) {
this.#model = options.model, this.#pageRoot = options.pageRoot, this.#overlay = options.overlay, this.#handle = options.handle, this.#readViewportWidth = options.readViewportWidth, this.#onPersist = options.onPersist, this.#requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)), this.#cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id)), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.listen(this.#handle, "pointerdown", (event) => {
this.#onPointerDown(event);
}), this.scope.listen(this.#handle, "pointermove", (event) => {
this.#onPointerMove(event);
});
for (const type of ["pointerup", "pointercancel", "lostpointercapture"])
this.scope.listen(this.#handle, type, (event) => {
this.#finish(event, !0);
});
this.scope.listen(options.viewportTarget, "resize", () => {
this.#finish(void 0, !1), this.#model.resizeViewport(this.#readViewportWidth());
}), this.#model.changes.subscribe((snapshot) => {
snapshot.presentation.embedded || this.#finish(void 0, !1);
}, this.scope), this.scope.add(() => this.#finish(void 0, !1));
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#onPointerDown(event) {
if (!(this.#destroyed || event.button !== 0 || !this.#model.snapshot.presentation.embedded)) {
event.preventDefault(), event.stopPropagation(), this.#pointer = {
pointerId: event.pointerId,
startWidth: this.#model.snapshot.embedWidth,
clientX: event.clientX
}, this.#setResizing(!0);
try {
this.#handle.setPointerCapture(event.pointerId);
} catch {
}
}
}
#onPointerMove(event) {
!this.#pointer || event.pointerId !== this.#pointer.pointerId || (this.#pointer.clientX = event.clientX, this.#frame || (this.#frame = this.#requestFrame(() => this.#render())));
}
#render() {
this.#frame = 0;
const pointer = this.#pointer, presentation = this.#model.snapshot.presentation;
if (!pointer || !presentation.embedded) return;
const viewportWidth = this.#readViewportWidth(), requestedWidth = presentation.side === "left" ? pointer.clientX : viewportWidth - pointer.clientX;
this.#model.setEmbedWidth(requestedWidth);
}
#finish(event, persist = !1) {
const pointer = this.#pointer;
if (!pointer || event && event.pointerId !== pointer.pointerId) {
pointer || this.#setResizing(!1);
return;
}
this.#frame && (this.#cancelFrame(this.#frame), this.#frame = 0, persist && this.#render()), this.#pointer = null, persist || this.#model.setEmbedWidth(pointer.startWidth), this.#setResizing(!1);
try {
this.#handle.hasPointerCapture(pointer.pointerId) && this.#handle.releasePointerCapture(pointer.pointerId);
} catch {
}
persist && this.#model.snapshot.presentation.embedded && this.#onPersist?.(this.#model.snapshot.embedWidth);
}
#setResizing(active) {
this.#pageRoot.classList.toggle("ldp-reader-embed-resizing", active), this.#overlay.classList.toggle("ldp-reader-embed-resizing", active);
}
}
}, "7667c373d4473cbe1d0054cde36715186d2fe45400e95cc5028541c45db701a2");
/* Source: lite/src/shell/reader-escape-surface.ts */
runtime.register("src/shell/reader-escape-surface.js", function(module, exports, require) {
var reader_escape_surface_exports = {};
__export(reader_escape_surface_exports, {
readerEscapeOwnedBy: () => readerEscapeOwnedBy,
readerFrontmostEscapeSurface: () => readerFrontmostEscapeSurface,
readerSurfaceQuery: () => readerSurfaceQuery,
readerSurfaceQueryAll: () => readerSurfaceQueryAll
});
module.exports = __toCommonJS(reader_escape_surface_exports);
const ESCAPE_SURFACE_SELECTOR = [
".ldp-reader-action-layer:not([hidden])",
".ldp-ai-service-model-metadata:not([hidden])",
".ldp-avatar-viewer",
".ldp-native-boost-menu:not([hidden])",
'[data-identifier="ldp-native-boost-emoji-picker"]',
".emoji-picker",
".ldp-select-menu:not([hidden])",
".ldp-settings-popover:not([hidden])",
".ldp-color-picker-popover:not([hidden])",
".ldp-notifications-popover:not([hidden])",
".ldp-history-popover:not([hidden])",
".ldp-bookmarks-popover:not([hidden])",
".ldp-lightbox",
".ldp-lb-batch-overlay:not([hidden])",
".ldp-descendant-replies-layer:not([hidden])",
".ldp-code-preview-layer",
".ldp-user-card-fallback.open",
".ldp-user-card-follow-panel:not([hidden])",
".ldp-user-card-follow-preview:not([hidden])",
".ldp-reader-floating-window:not([hidden])",
".ldp-reaction-picker:not([hidden])",
".ldp-selection-toolbar:not([hidden])"
].join(","), EMBEDDED_COLLECTION_SURFACE_SELECTOR = [
".ldp-notifications-popover",
".ldp-history-popover",
".ldp-bookmarks-popover"
].join(",");
function visible(document, element) {
const viewport = document.defaultView;
let current = element;
for (; current; ) {
if (!current.isConnected || current.hidden || current.getAttribute("aria-hidden") === "true") return !1;
if (viewport?.getComputedStyle) {
const style = viewport.getComputedStyle(current);
if (style.display === "none" || style.visibility === "hidden")
return !1;
}
current = current.parentElement;
}
return !0;
}
function embeddedCollectionSurface(element) {
return element.matches(EMBEDDED_COLLECTION_SURFACE_SELECTOR) && element.closest(".ldp-reader-floating-window") !== null;
}
function declaredSurfaceZIndex(element) {
for (const [selector, zIndex] of [
[".ldp-reader-action-layer", 2147483612],
[".ldp-ai-service-model-metadata", 2147483612],
[".ldp-user-card-follow-preview.is-above-user-observation-window", 2147483619],
[".ldp-user-card-follow-panel.is-above-user-observation-window", 2147483618],
[".ldp-user-card-fallback.is-above-user-observation-window", 2147483617],
[".ldp-user-card-follow-preview", 2147483611],
[".ldp-user-card-follow-panel", 2147483610],
[".ldp-color-picker-popover", 2147483610],
['[data-identifier="ldp-native-boost-emoji-picker"]', 2147483610],
[".ldp-native-boost-emoji-picker", 2147483610],
[".ldp-selection-toolbar", 2147483610],
[".ldp-avatar-viewer", 2147483609],
[".ldp-native-boost-menu", 2147483608],
[".ldp-user-card-fallback", 2147483608],
[".ldp-reader-floating-window.is-user-observation-list", 2147483584],
[".ldp-settings-popover", 2147483606],
[".ldp-notifications-popover", 2147483606],
[".ldp-history-popover", 2147483606],
[".ldp-bookmarks-popover", 2147483606],
[".ldp-lightbox", 2147483600],
[".ldp-descendant-replies-layer-centered", 2147483590],
[".ldp-select-menu", 30],
[".ldp-descendant-replies-layer", 30],
[".ldp-lb-batch-overlay", 12],
[".ldp-reaction-picker", 3]
])
if (element.matches(selector)) return zIndex;
return null;
}
function stackingVector(document, element) {
const values = [], viewport = document.defaultView;
let current = element;
for (; current; ) {
const raw = viewport?.getComputedStyle ? viewport.getComputedStyle(current).zIndex : current.style.zIndex, normalized = String(raw ?? "").trim(), computed = normalized && normalized !== "auto" ? Number(normalized) : Number.NaN, value = Number.isFinite(computed) ? computed : declaredSurfaceZIndex(current);
value !== null && values.unshift(value), current = current.parentElement;
}
return values;
}
function compareVector(left, right) {
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
const difference = (left[index] ?? 0) - (right[index] ?? 0);
if (difference) return difference;
}
return left.length - right.length;
}
function escapeSurfaceRoots(document) {
const roots = [document];
for (const host of document.querySelectorAll(
"[data-ldp-reader-portal]"
))
host.shadowRoot && roots.push(host.shadowRoot);
return roots;
}
function readerSurfaceQuery(document, selector) {
for (const root of escapeSurfaceRoots(document)) {
const surface = root.querySelector(selector);
if (surface) return surface;
}
return null;
}
function readerSurfaceQueryAll(document, selector) {
return Object.freeze(escapeSurfaceRoots(document).flatMap(
(root) => [...root.querySelectorAll(selector)]
));
}
function readerFrontmostEscapeSurface(document) {
const candidates = escapeSurfaceRoots(document).flatMap(
(root) => [...root.querySelectorAll(ESCAPE_SURFACE_SELECTOR)]
).filter(
(candidate) => visible(document, candidate) && !embeddedCollectionSurface(candidate)
);
let frontmost = null, frontmostVector = Object.freeze([]);
for (const candidate of candidates) {
const vector = stackingVector(document, candidate), comparison = compareVector(vector, frontmostVector), order = frontmost?.compareDocumentPosition(candidate) ?? 0;
(!frontmost || comparison > 0 || comparison === 0 && order & 4) && (frontmost = candidate, frontmostVector = vector);
}
return frontmost;
}
function readerEscapeOwnedBy(document, owners) {
const frontmost = readerFrontmostEscapeSurface(document);
return frontmost ? (Array.isArray(owners) ? owners : [owners]).some((owner) => owner === frontmost) : !0;
}
}, "ec95e882208eace62d4603fb5af704862a5bc0e18aafe823ba23bb5617539bed");
/* Source: lite/src/shell/reader-exclusive-panel-coordinator.ts */
runtime.register("src/shell/reader-exclusive-panel-coordinator.js", function(module, exports, require) {
var reader_exclusive_panel_coordinator_exports = {};
__export(reader_exclusive_panel_coordinator_exports, {
ReaderExclusivePanelCoordinator: () => ReaderExclusivePanelCoordinator
});
module.exports = __toCommonJS(reader_exclusive_panel_coordinator_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
class ReaderExclusivePanelCoordinator {
scope;
#entries;
#beforeOpen;
#onError;
#epoch = 0;
constructor(options) {
this.#entries = Object.freeze([...options.entries]), this.#beforeOpen = options.beforeOpen ?? (() => {
}), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const triggers = /* @__PURE__ */ new Set();
for (const entry of this.#entries) {
if (!entry.id || triggers.has(entry.trigger))
throw new Error("Header 面板互斥项必须拥有唯一 id 与 trigger");
triggers.add(entry.trigger), this.scope.listen(entry.trigger, "click", (event) => {
event.preventDefault(), event.stopImmediatePropagation(), this.#activate(entry);
}, !0);
}
this.scope.add(() => {
this.#epoch += 1;
for (const entry of this.#entries)
entry.trigger.removeAttribute("aria-busy");
});
}
destroy() {
this.scope.destroy();
}
async #activate(target) {
const epoch = ++this.#epoch;
target.trigger.setAttribute("aria-busy", "true");
try {
if (target.isOpen() && !target.coexistGroup) {
await target.close();
return;
}
for (const entry of this.#entries) {
if (entry === target || !entry.isOpen() || target.coexistGroup && entry.coexistGroup === target.coexistGroup) continue;
if (await entry.close() === !1 || epoch !== this.#epoch || this.scope.destroyed)
return;
}
if (epoch !== this.#epoch || this.scope.destroyed || await this.#beforeOpen(target) === !1 || epoch !== this.#epoch || this.scope.destroyed)
return;
await target.open();
} catch (cause) {
!this.scope.destroyed && epoch === this.#epoch && this.#onError(cause);
} finally {
epoch === this.#epoch && target.trigger.removeAttribute("aria-busy");
}
}
}
}, "ce50d509559082001f97cd7ab52fc9d5e6b60eb5098b0e656ff7f88b62f14b9d");
/* Source: lite/src/shell/reader-feedback-surface.ts */
runtime.register("src/shell/reader-feedback-surface.js", function(module, exports, require) {
var reader_feedback_surface_exports = {};
__export(reader_feedback_surface_exports, {
ReaderFeedbackSurface: () => ReaderFeedbackSurface
});
module.exports = __toCommonJS(reader_feedback_surface_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_event_target = require("../dom/event-target.js"), import_floating_surface_wheel = require("../dom/floating-surface-wheel.js"), import_reader_action_form_support = require("./reader-action-form-support.js"), import_reader_action_surface_coordinator = require("./reader-action-surface-coordinator.js");
class ReaderFeedbackSurface {
scope;
#document;
#root;
#toastLifetimeMs;
#schedule;
#cancel;
#focusSoon;
#renderIcon;
#coordinator;
#confirmation = null;
#toast = null;
#toastTimer = null;
#id = 0;
constructor(options) {
if (this.#document = options.document, this.#root = options.root, this.#toastLifetimeMs = Number(options.toastLifetimeMs ?? 1800), !Number.isFinite(this.#toastLifetimeMs) || this.#toastLifetimeMs < 0)
throw new RangeError("toastLifetimeMs 必须是非负有限数值");
this.#schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs)), this.#cancel = options.cancel ?? ((handle) => clearTimeout(
handle
)), this.#renderIcon = options.renderIcon, this.#focusSoon = options.focusSoon ?? ((callback) => {
const view = this.#document.defaultView;
view?.requestAnimationFrame ? view.requestAnimationFrame(callback) : queueMicrotask(callback);
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#coordinator = options.coordinator ?? new import_reader_action_surface_coordinator.ReaderActionSurfaceCoordinator({ parentScope: this.scope }), this.scope.add(() => {
this.#closeConfirmation("cancel"), this.#clearToast();
});
}
confirm(request) {
return this.choose(request).then((choice) => choice === "confirm");
}
choose(request, root = this.#root) {
this.#assertActive();
const previousFocus = this.#confirmation?.previousFocus ?? (0, import_event_target.deepActiveElement)(this.#document);
this.#closeConfirmation("cancel");
const id = ++this.#id, layer = this.#document.createElement("div");
layer.className = "ldp-reader-action-layer ldp-reader-confirm-layer";
const dialog = this.#document.createElement("section");
dialog.className = "ldp-reader-action-dialog ldp-reader-confirm-dialog" + (request.tone === "primary" ? " is-primary" : ""), dialog.setAttribute("role", "alertdialog"), dialog.setAttribute("aria-modal", "true");
const titleId = `ldp-reader-confirm-title-${id}`, descriptionId = `ldp-reader-confirm-description-${id}`, detailsId = `ldp-reader-confirm-details-${id}`, noteId = `ldp-reader-confirm-note-${id}`, details = (request.details ?? []).filter(
(detail) => String(detail.label).trim()
), noteText = String(request.note ?? "").trim();
dialog.setAttribute("aria-labelledby", titleId), dialog.setAttribute(
"aria-describedby",
[
descriptionId,
details.length ? detailsId : "",
noteText ? noteId : ""
].filter(Boolean).join(" ")
);
const content = this.#document.createElement("div");
content.className = "ldp-reader-confirm-content";
const icon = this.#document.createElement("span");
icon.className = "ldp-reader-confirm-icon", icon.setAttribute("aria-hidden", "true"), icon.append((0, import_reader_action_form_support.renderReaderActionIcon)(
this.#document,
request.icon ?? "alertTriangle",
this.#renderIcon
));
const copy = this.#document.createElement("div");
copy.className = "ldp-reader-confirm-copy";
const title = this.#document.createElement("strong");
title.id = titleId, title.textContent = String(request.title || "确认操作");
const message = this.#document.createElement("p");
if (message.id = descriptionId, message.textContent = String(request.message ?? ""), copy.append(title, message), details.length) {
const list = this.#document.createElement("ul");
list.className = "ldp-reader-confirm-details", list.id = detailsId;
for (const detail of details) {
const item = this.#document.createElement("li"), label = this.#document.createElement("span");
label.textContent = detail.label;
const value = this.#document.createElement("strong");
value.textContent = detail.value ?? "", item.append(label, value), list.append(item);
}
copy.append(list);
}
if (noteText) {
const note = this.#document.createElement("small");
note.id = noteId, note.textContent = noteText, copy.append(note);
}
content.append(icon, copy);
const footer = this.#document.createElement("div");
footer.className = "ldp-reader-action-footer";
const cancel = this.#document.createElement("button");
cancel.type = "button", cancel.className = "ldp-reader-action-cancel", cancel.textContent = request.cancelLabel ?? "取消";
const secondary = request.secondaryLabel ? this.#document.createElement("button") : null;
secondary && (secondary.type = "button", secondary.className = "ldp-reader-action-secondary", secondary.textContent = request.secondaryLabel ?? "");
const submit = this.#document.createElement("button");
return submit.type = "button", submit.className = "ldp-reader-action-submit", submit.textContent = request.confirmLabel ?? "确认", footer.append(cancel), secondary && footer.append(secondary), footer.append(submit), dialog.append(content, footer), layer.append(dialog), new Promise((resolve) => {
let settled = !1, releaseAction = () => {
};
const settle = (value) => {
settled || (settled = !0, releaseAction(), layer.remove(), this.#confirmation?.layer === layer && (this.#confirmation = null), this.#focusSoon(() => {
previousFocus?.isConnected && typeof previousFocus.focus == "function" && previousFocus.focus({ preventScroll: !0 });
}), resolve(value));
};
this.#confirmation = { layer, previousFocus, settle }, releaseAction = this.#coordinator.claim(() => settle("cancel")), layer.addEventListener("click", (event) => {
const target = event.target;
target?.closest(".ldp-reader-action-secondary") ? settle("secondary") : event.target === layer || target?.closest(".ldp-reader-action-cancel") ? settle("cancel") : target?.closest(".ldp-reader-action-submit") && settle("confirm");
}), layer.addEventListener("keydown", (event) => {
const keyboard = event;
if (keyboard.key === "Escape") {
keyboard.preventDefault(), settle("cancel");
return;
}
if (keyboard.key !== "Tab") return;
const buttons = [cancel, secondary, submit].filter(
(button) => !!button
).filter(
(button) => !button.disabled
);
if (!buttons.length) return;
const first = buttons[0], last = buttons.at(-1), active = (0, import_event_target.deepActiveElement)(this.#document);
keyboard.shiftKey && active === first ? (keyboard.preventDefault(), last.focus()) : !keyboard.shiftKey && active === last && (keyboard.preventDefault(), first.focus());
}), layer.addEventListener("wheel", (event) => (0, import_floating_surface_wheel.containFloatingSurfaceWheel)(layer, event), {
passive: !1
}), root.append(layer), this.#focusSoon(() => {
layer.isConnected && cancel.focus({ preventScroll: !0 });
});
});
}
show(message) {
this.#assertActive(), this.#clearToast();
const toast = this.#document.createElement("div");
toast.className = "ldp-selection-toast", toast.setAttribute("role", "status"), toast.setAttribute("aria-live", "polite"), toast.textContent = String(message), this.#root.append(toast), this.#toast = toast, this.#toastTimer = this.#schedule(() => {
this.#toast === toast && (this.#toast = null, this.#toastTimer = null), toast.remove();
}, this.#toastLifetimeMs);
}
destroy() {
this.scope.destroy();
}
#closeConfirmation(value) {
const active = this.#confirmation;
this.#confirmation = null, active?.settle(value);
}
#clearToast() {
this.#toastTimer !== null && (this.#cancel(this.#toastTimer), this.#toastTimer = null), this.#toast?.remove(), this.#toast = null;
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("ReaderFeedbackSurface 已销毁");
}
}
}, "294a7bce2ea83ebcf2c371e4ce45567a795ad0cc232d4c66ecbd9c08d19e0279");
/* Source: lite/src/shell/reader-floating-window-frame.ts */
runtime.register("src/shell/reader-floating-window-frame.js", function(module, exports, require) {
var reader_floating_window_frame_exports = {};
__export(reader_floating_window_frame_exports, {
ReaderFloatingWindowFrame: () => ReaderFloatingWindowFrame,
dismissReaderFloatingWindowTabSessionFromEscape: () => dismissReaderFloatingWindowTabSessionFromEscape,
reloadReaderFloatingWindowTabGeometry: () => reloadReaderFloatingWindowTabGeometry,
restoreReaderFloatingWindowTabSession: () => restoreReaderFloatingWindowTabSession
});
module.exports = __toCommonJS(reader_floating_window_frame_exports);
var import_reader_icon = require("../components/reader-icon.js"), import_floating_surface_wheel = require("../dom/floating-surface-wheel.js"), import_html_element = require("../dom/html-element.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_escape_surface = require("./reader-escape-surface.js"), import_reader_workspace = require("./reader-workspace.js");
const READER_FLOATING_WINDOW_LAUNCHERS = Object.freeze([
Object.freeze({ selector: ".ldp-notifications-toggle", id: "notifications" }),
Object.freeze({ selector: ".ldp-history-toggle", id: "history" }),
Object.freeze({ selector: ".ldp-bookmarks-toggle", id: "bookmarks" }),
Object.freeze({
selector: ".ldp-topic-action-rail-download",
id: "topic-downloads"
}),
Object.freeze({
selector: ".ldp-topic-action-rail-user-observation",
id: "user-observations"
}),
Object.freeze({
selector: ".ldp-topic-action-rail-chronicle",
id: "chronicle"
}),
Object.freeze({
selector: ".ldp-topic-action-rail-unwanted-topics",
id: "unwanted-topics"
})
]), READER_FLOATING_WINDOW_LAUNCHER_SELECTOR = READER_FLOATING_WINDOW_LAUNCHERS.map(({ selector }) => selector).join(","), READER_FLOATING_WINDOW_SCROLLBAR_GUARD_PX = 6, READER_FLOATING_WINDOW_MOBILE_MAX_WIDTH = 760, floatingWindowTabGroups = /* @__PURE__ */ new WeakMap();
function eventPathMatches(event, selector) {
const composed = typeof event.composedPath == "function" ? event.composedPath() : [event.target];
return (composed.length ? composed : [event.target]).some((target) => {
const matches = target?.matches;
return typeof matches == "function" && matches.call(target, selector);
});
}
function pointerHitsHorizontalScrollbar(event, element) {
if (element.scrollWidth <= element.clientWidth + 1) return !1;
const bounds = element.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) return !1;
const measuredHeight = Math.max(
0,
Number(element.offsetHeight) - Number(element.clientHeight)
), guardHeight = Math.max(
READER_FLOATING_WINDOW_SCROLLBAR_GUARD_PX,
measuredHeight
);
return event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.bottom - guardHeight && event.clientY <= bounds.bottom;
}
class ReaderFloatingWindowTabGroup {
#document;
#mount;
#frames = /* @__PURE__ */ new Map();
#opened = [];
#claimedOutsideEvents = /* @__PURE__ */ new WeakSet();
#onPointerDown;
#onClick;
#active = null;
#sharedGeometry = null;
#pinned = !1;
#tabScrollLeft = 0;
#visible = !1;
constructor(document, mount) {
this.#document = document, this.#mount = mount, this.#onPointerDown = (event) => {
eventPathMatches(
event,
".ldp-reader-floating-window-add-wrap,.ldp-reader-floating-window-mobile-menu-wrap"
) || this.#closeMenus();
}, this.#onClick = (event) => {
const launcher = READER_FLOATING_WINDOW_LAUNCHERS.find(({ selector }) => eventPathMatches(event, selector)), frame = launcher ? this.#frames.get(launcher.id) : null;
frame?.isOpen && (event.preventDefault(), event.stopImmediatePropagation(), frame.open());
}, this.#document.addEventListener("pointerdown", this.#onPointerDown, !0), this.#document.addEventListener("click", this.#onClick, !0);
}
register(frame) {
const existing = this.#frames.get(frame.tabId);
if (existing && existing !== frame)
throw new Error(`浮窗标签 id 重复:${frame.tabId}`);
this.#captureTabScroll(), this.#captureSharedGeometry(), this.#frames.size ? frame.syncSharedPinned(this.#pinned) : this.#pinned = frame.pinned, this.#frames.set(frame.tabId, frame), this.#sync();
}
unregister(frame) {
if (this.#frames.get(frame.tabId) !== frame) return;
this.#captureTabScroll(), this.#captureSharedGeometry(), this.#frames.delete(frame.tabId);
const index = this.#opened.indexOf(frame);
index >= 0 && this.#opened.splice(index, 1), this.#active === frame && (this.#active = this.#opened[Math.min(index, this.#opened.length - 1)] ?? this.#opened.at(-1) ?? null), this.#opened.length || (this.#visible = !1), this.#sync(), !this.#frames.size && (this.#document.removeEventListener(
"pointerdown",
this.#onPointerDown,
!0
), this.#document.removeEventListener("click", this.#onClick, !0), floatingWindowTabGroups.get(this.#mount) === this && floatingWindowTabGroups.delete(this.#mount));
}
open(frame) {
if (!this.#frames.has(frame.tabId)) return;
this.#captureTabScroll(), this.#visible ? this.#captureSharedGeometry() : frame.compactViewport || (this.#sharedGeometry = frame.geometry.snapshot.geometry);
const revealActive = !this.#opened.includes(frame);
revealActive && this.#opened.push(frame), this.#active = frame, this.#visible = !0, this.#sync(revealActive);
}
close(frame) {
const index = this.#opened.indexOf(frame);
index < 0 || (this.#captureTabScroll(), this.#captureSharedGeometry(), this.#opened.splice(index, 1), this.#active === frame && (this.#active = this.#opened[Math.min(index, this.#opened.length - 1)] ?? this.#opened.at(-1) ?? null), this.#opened.length || (this.#visible = !1), this.#sync());
}
dismiss(frame) {
return !this.#visible || this.#active !== frame ? !1 : (this.#dismiss(), !0);
}
activate(frame) {
!frame.isOpen || !this.#opened.includes(frame) || frame.open();
}
dismissFromPointerEvent(frame, event) {
return this.#active !== frame || this.#claimedOutsideEvents.has(event) ? !1 : (this.#claimedOutsideEvents.add(event), this.#dismiss(), !0);
}
dismissFromEscapeEvent(frame, event) {
return event.key !== "Escape" || this.#active !== frame || !(0, import_reader_escape_surface.readerEscapeOwnedBy)(this.#document, [frame.element]) ? !1 : (event.preventDefault(), event.stopImmediatePropagation(), frame.closeMobileMenu() || (this.#pinned ? this.#closeMenus() : this.#dismiss()), !0);
}
dismissActiveFromEarlyEscapeEvent(event) {
return !this.#visible || !this.#active?.active ? !1 : this.dismissFromEscapeEvent(this.#active, event);
}
isLauncherEvent(event) {
return eventPathMatches(event, READER_FLOATING_WINDOW_LAUNCHER_SELECTOR);
}
get pinned() {
return this.#pinned;
}
syncPinnedFrom(frame) {
if (this.#frames.get(frame.tabId) === frame) {
this.#pinned = frame.pinned;
for (const target of this.#frames.values())
target.syncSharedPinned(this.#pinned);
}
}
refresh() {
this.#captureTabScroll(), this.#captureSharedGeometry(), this.#sync();
}
reloadStoredGeometry() {
const target = this.#active ?? this.#frames.values().next().value;
target && (target.reloadStoredGeometry(), target.compactViewport || (this.#sharedGeometry = target.geometry.snapshot.geometry), this.#sync());
}
restore(tabId) {
if (this.#visible || !this.#opened.length) return !1;
if (this.#captureSharedGeometry(), tabId) {
const requested = this.#frames.get(tabId);
if (!requested || !this.#opened.includes(requested)) return !1;
this.#active = requested;
} else (!this.#active || !this.#opened.includes(this.#active)) && (this.#active = this.#opened.at(-1) ?? null);
return this.#visible = !!this.#active, this.#sync(), this.#visible;
}
#closeMenus() {
for (const frame of this.#frames.values()) frame.closeAddMenu();
}
#dismiss() {
this.#captureTabScroll(), this.#captureSharedGeometry(), this.#visible = !1, this.#sync();
}
#captureSharedGeometry() {
!this.#active || this.#active.compactViewport || (this.#sharedGeometry = this.#active.geometry.snapshot.geometry);
}
#captureTabScroll() {
if (!this.#active?.active) return;
const scrollLeft = Number(this.#active.tabList.scrollLeft);
Number.isFinite(scrollLeft) && (this.#tabScrollLeft = Math.max(0, scrollLeft));
}
#sync(revealActive = !1) {
const sharesDesktopGeometry = !!(this.#active && !this.#active.compactViewport);
!this.#sharedGeometry && this.#active && sharesDesktopGeometry && (this.#sharedGeometry = this.#active.geometry.snapshot.geometry), this.#active && this.#sharedGeometry && sharesDesktopGeometry && this.#active.applySharedGeometry(this.#sharedGeometry);
const remaining = [...this.#frames.values()].filter((frame) => !this.#opened.includes(frame)).sort((left, right) => left.tabOrder - right.tabOrder);
for (const frame of this.#frames.values())
frame.syncTabVisibility(this.#visible && frame === this.#active);
this.#active?.renderTabChrome(
this.#opened,
remaining,
this.#tabScrollLeft,
revealActive
), this.#captureTabScroll();
}
}
function tabGroup(document, mount) {
const existing = floatingWindowTabGroups.get(mount);
if (existing) return existing;
const created = new ReaderFloatingWindowTabGroup(document, mount);
return floatingWindowTabGroups.set(mount, created), created;
}
function restoreReaderFloatingWindowTabSession(mount, tabId) {
return floatingWindowTabGroups.get(mount)?.restore(tabId) ?? !1;
}
function dismissReaderFloatingWindowTabSessionFromEscape(mount, event) {
return floatingWindowTabGroups.get(mount)?.dismissActiveFromEarlyEscapeEvent(event) ?? !1;
}
function reloadReaderFloatingWindowTabGeometry(mount) {
floatingWindowTabGroups.get(mount)?.reloadStoredGeometry();
}
function storedPreferences(storage, key) {
try {
const raw = storage?.getItem(key);
if (!raw) return null;
const source = JSON.parse(raw), width = Number(source.readerWindowWidth), height = Number(source.readerWindowHeight), left = Number(source.readerWindowX), top = Number(source.readerWindowY);
return !Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0 || !Number.isFinite(left) || !Number.isFinite(top) ? null : Object.freeze({
readerWindowWidth: width,
readerWindowHeight: height,
readerWindowX: left,
readerWindowY: top,
readerWindowLocked: !1,
readerWindowPinned: source.readerWindowPinned === !0
});
} catch {
return null;
}
}
function viewport(document, mount) {
const view = document.defaultView;
return Object.freeze({
width: Math.max(
1,
Number(view?.innerWidth) || document.documentElement?.clientWidth || mount.clientWidth || 1024
),
height: Math.max(
1,
Number(view?.innerHeight) || document.documentElement?.clientHeight || mount.clientHeight || 768
)
});
}
function defaultPreferences(options, width, height) {
const margin = Number(options.policy.margin ?? 8), targetWidth = Math.min(
Math.max(1, width - margin * 2),
options.policy.defaultWidth
), targetHeight = Math.min(
Math.max(1, height - margin * 2),
options.policy.defaultHeight
), placement = options.placement ?? "center", left = placement === "right" ? width - targetWidth - Math.max(18, margin) : placement === "left" ? Math.max(18, margin) : Math.round((width - targetWidth) / 2);
return Object.freeze({
readerWindowWidth: targetWidth,
readerWindowHeight: targetHeight,
readerWindowX: left,
readerWindowY: placement === "center" ? Math.round((height - targetHeight) / 2) : Math.max(32, margin),
readerWindowLocked: !1,
readerWindowPinned: !1
});
}
class ReaderFloatingWindowFrame {
scope;
host;
element;
header;
title;
meta;
body;
actions;
tabRow;
toolbarRow;
tabList;
addWrap;
addButton;
addMenu;
mobileMenuWrap;
mobileMenuButton;
mobileMenuCurrent;
mobileMenu;
pinButton;
closeButton;
tabId;
tabOrder;
geometry;
pointer;
#onClose;
#baseZIndex;
#geometryStorage;
#geometryStorageKey;
#requestOpen;
#tabAction;
#notify;
#tabGroup;
#launcherSelector;
#tabLabel;
#iconName;
#open = !1;
#active = !1;
#desktopGeometryReady = !1;
#deferredMinimumWidth = null;
constructor(options) {
this.#onClose = options.onClose ?? (() => {
}), this.#baseZIndex = options.zIndex, this.#geometryStorage = options.geometryStorage, this.#geometryStorageKey = options.geometryStorageKey, this.#requestOpen = options.requestOpen, this.#tabAction = options.tabAction ?? null, this.#tabAction?.classList.add("ldp-reader-floating-window-tab-action");
const standalone = options.sessionMode === "standalone";
this.#launcherSelector = standalone ? options.launcherSelector ?? null : null, this.#notify = options.notify ?? (() => {
}), this.tabId = options.tabId, this.tabOrder = options.tabOrder, this.#tabLabel = options.title, this.#iconName = options.icon, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.host = (0, import_html_element.htmlElement)(
options.document,
"div",
`ldp-reader-floating-host is-${options.variant}`
), this.host.style.zIndex = String(options.zIndex), this.host.classList.toggle("is-standalone", standalone), options.mount.append(this.host), this.element = (0, import_html_element.htmlElement)(
options.document,
"section",
`ldp-reader-floating-window is-${options.variant}`
), this.element.hidden = !0, this.element.classList.toggle("is-standalone", standalone), this.element.setAttribute("role", "dialog"), this.element.setAttribute("aria-label", options.ariaLabel), this.header = (0, import_html_element.htmlElement)(
options.document,
"header",
"ldp-reader-floating-window-head"
), this.header.dataset.readerFloatingDragSurface = options.variant, this.title = (0, import_html_element.htmlElement)(
options.document,
"strong",
"ldp-reader-floating-window-title",
options.title
), this.title.hidden = !standalone, this.tabList = (0, import_html_element.htmlElement)(
options.document,
"nav",
"ldp-reader-floating-window-tabs"
), this.tabList.setAttribute("role", "tablist"), this.tabList.setAttribute("aria-label", "已打开的工具浮窗"), this.addWrap = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-reader-floating-window-add-wrap"
), this.addButton = options.document.createElement("button"), this.addButton.type = "button", this.addButton.className = "ldp-reader-floating-window-add", this.addButton.setAttribute("aria-label", "添加工具浮窗"), this.addButton.setAttribute("aria-haspopup", "menu"), this.addButton.setAttribute("aria-expanded", "false"), this.addButton.append((0, import_reader_icon.createReaderIcon)(options.document, "plus")), this.addMenu = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-reader-floating-window-add-menu"
), this.addMenu.hidden = !0, this.addMenu.setAttribute("role", "menu"), this.addMenu.setAttribute("aria-label", "添加剩余工具浮窗"), this.addWrap.append(this.addButton, this.addMenu), this.mobileMenuWrap = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-reader-floating-window-mobile-menu-wrap"
), this.mobileMenuButton = options.document.createElement("button"), this.mobileMenuButton.type = "button", this.mobileMenuButton.className = "ldp-reader-floating-window-mobile-menu-toggle", this.mobileMenuButton.setAttribute("aria-label", "打开工具分类"), this.mobileMenuButton.setAttribute("aria-haspopup", "menu"), this.mobileMenuButton.setAttribute("aria-expanded", "false"), this.mobileMenuButton.append((0, import_reader_icon.createReaderIcon)(options.document, "list")), this.mobileMenuCurrent = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-reader-floating-window-mobile-current",
options.title
), this.mobileMenu = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-reader-floating-window-mobile-menu"
), this.mobileMenu.hidden = !0, this.mobileMenu.setAttribute("role", "menu"), this.mobileMenu.setAttribute("aria-label", "工具分类"), this.mobileMenuWrap.append(
this.mobileMenuButton,
this.mobileMenuCurrent,
this.mobileMenu
), this.meta = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-reader-floating-window-meta"
), this.pinButton = options.document.createElement("button"), this.pinButton.type = "button", this.pinButton.className = "ldp-reader-floating-window-pin", this.pinButton.setAttribute("aria-label", "锁定置顶,点击外部保持显示"), this.pinButton.setAttribute("aria-pressed", "false"), this.pinButton.append((0, import_reader_icon.createReaderIcon)(options.document, "pin")), this.closeButton = options.document.createElement("button"), this.closeButton.type = "button", this.closeButton.className = "ldp-reader-floating-window-close", this.closeButton.setAttribute("aria-label", `关闭${options.title}`), this.closeButton.append((0, import_reader_icon.createReaderIcon)(options.document, "x")), this.actions = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-reader-floating-window-actions"
), this.tabRow = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-reader-floating-window-tab-row"
), this.tabRow.append(
this.mobileMenuWrap,
this.tabList,
this.addWrap,
this.pinButton
), this.toolbarRow = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-reader-floating-window-toolbar-row"
), this.toolbarRow.append(this.meta, this.actions), standalone ? this.header.append(
this.title,
this.meta,
this.pinButton,
...this.#tabAction ? [this.#tabAction] : [],
this.closeButton
) : this.header.append(
this.title,
this.tabRow,
this.toolbarRow
), this.body = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-reader-floating-window-body"
);
const handles = Object.freeze([
"n",
"s",
"e",
"w",
"ne",
"nw",
"se",
"sw"
]).map((direction) => {
const handle = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-reader-floating-window-resize"
);
return handle.dataset.readerResize = direction, handle.dataset.resize = direction, handle.setAttribute("aria-hidden", "true"), handle;
});
this.element.append(this.header, this.body, ...handles), this.host.append(this.element), this.scope.add((0, import_floating_surface_wheel.bindFloatingSurfaceWheel)(this.element));
const currentViewport = viewport(options.document, options.mount);
this.#desktopGeometryReady = currentViewport.width > READER_FLOATING_WINDOW_MOBILE_MAX_WIDTH;
const restored = storedPreferences(
options.geometryStorage,
options.geometryStorageKey
);
this.geometry = new import_reader_workspace.ReaderWindowGeometryModel({
preferences: restored ?? defaultPreferences(
options,
currentViewport.width,
currentViewport.height
),
viewportWidth: currentViewport.width,
viewportHeight: currentViewport.height,
mode: "floating",
policy: {
margin: 8,
minWidth: 420,
minHeight: 360,
defaultViewportWidth: 0.8,
defaultViewportHeight: 0.82,
...options.policy,
compactWidth: READER_FLOATING_WINDOW_MOBILE_MAX_WIDTH
}
}), this.geometry.changes.subscribe(
(snapshot) => this.#applyGeometry(snapshot),
this.scope
);
const view = options.document.defaultView, requestFrame = (callback) => typeof view?.requestAnimationFrame == "function" ? view.requestAnimationFrame(callback) : (callback(0), 0), cancelFrame = (id) => {
view?.cancelAnimationFrame?.(id);
};
this.pointer = new import_reader_workspace.ReaderWindowPointerController({
model: this.geometry,
overlay: this.element,
modal: this.element,
header: this.header,
pinButton: this.pinButton,
...view ? { viewportTarget: view } : {},
readViewport: () => viewport(options.document, options.mount),
onPersist: (preferences) => {
if (!this.compactViewport)
try {
options.geometryStorage?.setItem(
options.geometryStorageKey,
JSON.stringify(preferences)
);
} catch {
options.notify?.(`${options.title}浮窗位置保存失败`);
}
},
requestFrame,
cancelFrame,
dragSurfaceSelector: ".ldp-reader-floating-window-head[data-reader-floating-drag-surface]",
blockedSelector: 'button,input,select,textarea,label,a,[role="button"],[contenteditable="true"]',
isDragBlocked: (event, target) => {
const tabs = target.closest(
".ldp-reader-floating-window-tabs"
);
return tabs === this.tabList && pointerHitsHorizontalScrollbar(event, tabs);
},
interactingClassName: "ldp-reader-floating-window-interacting",
restingTransform: "none",
projectPlacement: () => {
},
parentScope: this.scope
}), this.#applyGeometry(this.geometry.snapshot), this.scope.listen(this.closeButton, "click", () => {
this.#document().defaultView?.matchMedia?.("(max-width: 760px)").matches === !0 && this.#tabGroup?.dismiss(this) || this.close();
}), this.scope.listen(this.addButton, "click", (event) => {
if (event.preventDefault(), event.stopPropagation(), this.addButton.disabled) return;
const open = this.addButton.getAttribute("aria-expanded") === "true";
this.addButton.setAttribute("aria-expanded", String(!open)), this.addMenu.hidden = open;
}), this.scope.listen(this.mobileMenuButton, "click", (event) => {
event.preventDefault(), event.stopPropagation();
const open = this.mobileMenuButton.getAttribute("aria-expanded") === "true";
this.mobileMenuButton.setAttribute("aria-expanded", String(!open)), this.mobileMenuButton.setAttribute(
"aria-label",
open ? "打开工具分类" : "收起工具分类"
), this.mobileMenu.hidden = open;
}), this.scope.listen(this.tabList, "wheel", (eventValue) => {
const event = eventValue;
if (this.tabList.scrollWidth <= this.tabList.clientWidth + 1) return;
const delta = Math.abs(event.deltaX) >= Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
delta && (this.tabList.scrollLeft += delta, event.preventDefault(), event.stopPropagation());
}, { passive: !1 });
let compactViewport = currentViewport.width <= READER_FLOATING_WINDOW_MOBILE_MAX_WIDTH;
view && this.scope.listen(view, "resize", () => {
const wasCompact = compactViewport, next = viewport(options.document, options.mount);
if (this.geometry.resizeViewport(next.width, next.height), compactViewport = next.width <= READER_FLOATING_WINDOW_MOBILE_MAX_WIDTH, !wasCompact && compactViewport && this.closeAddMenu(), wasCompact && !compactViewport && (this.closeMobileMenu(), this.#deferredMinimumWidth !== null && (this.geometry.setMinimumWidth(this.#deferredMinimumWidth), this.#deferredMinimumWidth = null), !this.#desktopGeometryReady)) {
const restored2 = storedPreferences(
this.#geometryStorage,
this.#geometryStorageKey
);
restored2 ? this.geometry.setGeometry(
restored2.readerWindowWidth,
restored2.readerWindowHeight,
restored2.readerWindowX,
restored2.readerWindowY
) : this.geometry.reset(), this.#desktopGeometryReady = !0;
}
this.#tabGroup?.refresh();
}), this.#tabGroup = standalone ? null : tabGroup(options.document, options.mount), this.#tabGroup?.register(this), this.#tabGroup && this.scope.listen(this.pinButton, "click", () => {
this.#tabGroup?.syncPinnedFrom(this);
}), this.scope.add(() => {
this.#tabGroup?.unregister(this), this.host.remove();
});
}
get isOpen() {
return this.#open;
}
get pinned() {
return this.geometry.snapshot.pinned;
}
get active() {
return this.#active;
}
get compactViewport() {
return this.geometry.snapshot.viewportWidth <= READER_FLOATING_WINDOW_MOBILE_MAX_WIDTH;
}
setIcon(name) {
this.#iconName = name, this.#tabGroup?.refresh();
}
setTitle(value) {
this.title.textContent = value, this.#tabGroup?.refresh();
}
setMinimumWidth(width) {
if (this.compactViewport) {
this.#deferredMinimumWidth = width;
return;
}
this.geometry.setMinimumWidth(width);
}
applySharedGeometry(geometry) {
this.compactViewport || this.geometry.setGeometry(
geometry.width,
geometry.height,
geometry.left,
geometry.top
);
}
reloadStoredGeometry() {
this.#syncSharedGeometry();
}
open() {
this.scope.destroyed || (this.#syncSharedGeometry(), this.#open = !0, this.element.classList.add("is-open"), this.#tabGroup ? this.#tabGroup.open(this) : this.syncTabVisibility(!0));
}
#syncSharedGeometry() {
const restored = storedPreferences(
this.#geometryStorage,
this.#geometryStorageKey
);
restored && this.geometry.setGeometry(
restored.readerWindowWidth,
restored.readerWindowHeight,
restored.readerWindowX,
restored.readerWindowY
), this.#tabGroup && this.syncSharedPinned(this.#tabGroup.pinned);
}
close() {
!this.#open || this.scope.destroyed || (this.#open = !1, this.element.classList.remove("is-open"), this.#tabGroup ? this.#tabGroup.close(this) : this.syncTabVisibility(!1), this.#onClose());
}
dismissFromPointerEvent(event) {
return !this.#open || !this.#active || this.pinned || this.contains(event.target) || eventPathMatches(event, ".ldp-reader-floating-window") || this.#launcherSelector && eventPathMatches(event, this.#launcherSelector) || this.#tabGroup?.isLauncherEvent(event) ? !1 : this.#tabGroup ? this.#tabGroup.dismissFromPointerEvent(this, event) : (this.close(), !0);
}
dismissFromEscapeEvent(event) {
return !this.#open || !this.#active ? !1 : this.#tabGroup ? this.#tabGroup.dismissFromEscapeEvent(this, event) : event.key !== "Escape" || !(0, import_reader_escape_surface.readerEscapeOwnedBy)(this.#document(), [this.element]) ? !1 : (event.preventDefault(), event.stopImmediatePropagation(), this.pinned || this.close(), !0);
}
syncSharedPinned(pinned) {
this.pinned !== pinned && this.geometry.setPinned(pinned);
}
syncTabVisibility(active) {
this.#active = active && this.#open, this.element.hidden = !this.#active, this.element.classList.toggle("is-active-tab", this.#active), this.#active || this.closeAddMenu();
}
renderTabChrome(opened, remaining, scrollLeft, revealActive) {
if (!this.#active || !this.#tabGroup) return;
this.mobileMenuCurrent.replaceChildren(
(0, import_reader_icon.createReaderIcon)(this.#document(), this.#iconName),
(0, import_html_element.htmlElement)(
this.#document(),
"span",
"ldp-reader-floating-window-mobile-current-title",
this.#tabLabel
)
);
const allFrames = [...opened, ...remaining].sort((left, right) => left.tabOrder - right.tabOrder);
this.mobileMenu.replaceChildren(...allFrames.map((frame) => {
const option = this.#document().createElement("button");
return option.type = "button", option.className = "ldp-reader-floating-window-mobile-menu-option", option.dataset.floatingMobileTab = frame.tabId, option.setAttribute("role", "menuitem"), option.setAttribute("aria-current", frame === this ? "page" : "false"), option.classList.toggle("is-active", frame === this), option.append(
(0, import_reader_icon.createReaderIcon)(this.#document(), frame.#iconName),
this.#document().createTextNode(frame.#tabLabel)
), option.addEventListener("click", () => {
this.closeMobileMenu(), frame.isOpen ? this.#tabGroup?.activate(frame) : frame.requestOpenFromTabs();
}), option;
})), this.tabList.replaceChildren(...opened.map((frame) => {
const item = (0, import_html_element.htmlElement)(
this.#document(),
"div",
"ldp-reader-floating-window-tab"
);
item.dataset.floatingTab = frame.tabId, item.setAttribute("role", "presentation");
const activate = this.#document().createElement("button");
activate.type = "button", activate.className = "ldp-reader-floating-window-tab-activate", activate.setAttribute("role", "tab"), activate.setAttribute("aria-selected", String(frame === this)), activate.setAttribute(
"aria-label",
`切换到${frame.#tabLabel}`
), activate.append(
(0, import_reader_icon.createReaderIcon)(this.#document(), frame.#iconName),
(0, import_html_element.htmlElement)(
this.#document(),
"span",
"ldp-reader-floating-window-tab-title",
frame.#tabLabel
)
), activate.addEventListener("click", () => {
this.#tabGroup?.activate(frame);
});
const close = frame === this ? this.closeButton : this.#document().createElement("button");
return frame !== this && (close.type = "button", close.className = "ldp-reader-floating-window-close", close.append((0, import_reader_icon.createReaderIcon)(this.#document(), "x")), close.addEventListener("click", () => frame.close())), close.dataset.floatingTabClose = frame.tabId, close.setAttribute(
"aria-label",
`关闭${frame.#tabLabel}`
), item.classList.toggle("is-active", frame === this), item.addEventListener("pointerdown", (event) => {
event.button === 1 && (event.preventDefault(), event.stopPropagation());
}), item.addEventListener("auxclick", (event) => {
event.button === 1 && (event.preventDefault(), event.stopPropagation(), frame.close());
}), item.append(
activate,
...frame === this && frame.#tabAction ? [frame.#tabAction] : [],
close
), item;
})), this.tabList.scrollLeft = Math.max(0, scrollLeft), revealActive && this.#revealActiveTab(), this.addButton.disabled = remaining.length === 0, this.addButton.setAttribute(
"aria-label",
remaining.length ? `添加工具浮窗,剩余 ${remaining.length} 个` : "所有工具浮窗均已打开"
), this.addMenu.replaceChildren(...remaining.map((frame) => {
const button = this.#document().createElement("button");
return button.type = "button", button.className = "ldp-reader-floating-window-add-option", button.dataset.floatingTabAdd = frame.tabId, button.setAttribute("role", "menuitem"), button.append(
(0, import_reader_icon.createReaderIcon)(this.#document(), frame.#iconName),
this.#document().createTextNode(frame.#tabLabel)
), button.addEventListener("click", () => {
this.closeAddMenu(), frame.requestOpenFromTabs();
}), button;
})), remaining.length || this.closeAddMenu();
}
#revealActiveTab() {
const activeTab = this.tabList.querySelector(
".ldp-reader-floating-window-tab.is-active"
);
if (!activeTab || this.tabList.clientWidth <= 0) return;
const viewportStart = this.tabList.scrollLeft, viewportEnd = viewportStart + this.tabList.clientWidth, tabStart = activeTab.offsetLeft, tabEnd = tabStart + activeTab.offsetWidth;
tabStart < viewportStart ? this.tabList.scrollLeft = tabStart : tabEnd > viewportEnd && (this.tabList.scrollLeft = tabEnd - this.tabList.clientWidth);
}
closeAddMenu() {
this.addButton.setAttribute("aria-expanded", "false"), this.addMenu.hidden = !0, this.closeMobileMenu();
}
closeMobileMenu() {
const open = this.mobileMenuButton.getAttribute("aria-expanded") === "true";
return this.mobileMenuButton.setAttribute("aria-expanded", "false"), this.mobileMenuButton.setAttribute("aria-label", "打开工具分类"), this.mobileMenu.hidden = !0, open;
}
async requestOpenFromTabs() {
try {
await this.#requestOpen();
} catch (cause) {
this.#notify(
`${this.title.textContent ?? "工具"}浮窗打开失败:${String(cause)}`
);
}
}
contains(target) {
return !!(target && typeof target.nodeType == "number" && this.element.contains(target));
}
destroy() {
this.scope.destroy();
}
#applyGeometry(snapshot) {
if (!snapshot.managed) return;
this.host.classList.toggle("is-pinned", snapshot.pinned), this.element.classList.toggle("is-pinned", snapshot.pinned), this.host.style.zIndex = String(snapshot.pinned ? Math.min(2147483647, this.#baseZIndex + 32) : this.#baseZIndex), this.pinButton.classList.toggle("is-active", snapshot.pinned), this.pinButton.setAttribute("aria-pressed", String(snapshot.pinned));
const pinLabel = snapshot.pinned ? "取消锁定置顶" : "锁定置顶,点击外部保持显示", pinLabelChanged = this.pinButton.getAttribute("aria-label") !== pinLabel;
if (this.pinButton.setAttribute("aria-label", pinLabel), pinLabelChanged) {
const EventConstructor = this.#document().defaultView?.Event ?? Event;
this.pinButton.dispatchEvent(new EventConstructor(
"ldp-tooltip-refresh",
{ bubbles: !0 }
));
}
const geometry = snapshot.geometry;
this.element.style.left = `${geometry.left}px`, this.element.style.top = `${geometry.top}px`, this.element.style.width = `${geometry.width}px`, this.element.style.height = `${geometry.height}px`;
}
#document() {
return this.element.ownerDocument;
}
}
}, "bee4971965f37627b252051c9b7dc1f545bf1dd8f755e1dd72f00a45da68e741");
/* Source: lite/src/shell/reader-mobile-return-controller.ts */
runtime.register("src/shell/reader-mobile-return-controller.js", function(module, exports, require) {
var reader_mobile_return_controller_exports = {};
__export(reader_mobile_return_controller_exports, {
ReaderMobileReturnController: () => ReaderMobileReturnController,
dispatchReaderEscape: () => dispatchReaderEscape,
isReaderAppleMobilePlatform: () => isReaderAppleMobilePlatform
});
module.exports = __toCommonJS(reader_mobile_return_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_reader_escape_surface = require("./reader-escape-surface.js");
const READER_MOBILE_RETURN_QUERY = "(max-width: 700px) and (hover: none) and (pointer: coarse)", READER_MOBILE_RETURN_STATE_KEY = "ldpReaderMobileReturn";
let readerMobileReturnSequence = 0;
function valueRecord(value) {
return value !== null && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function readerVisibleState(state) {
return state !== "idle" && state !== "closed" && state !== "destroyed";
}
function isReaderAppleMobilePlatform(navigator) {
const userAgent = String(navigator?.userAgent ?? ""), platform = String(navigator?.platform ?? ""), touchPoints = Math.max(0, Number(navigator?.maxTouchPoints) || 0);
return /(?:iPad|iPhone|iPod)/i.test(userAgent) || /^Mac/i.test(platform) && touchPoints > 1;
}
function dispatchReaderEscape(document) {
const window = document.defaultView;
if (!window) return;
const target = (0, import_reader_escape_surface.readerFrontmostEscapeSurface)(document) ?? document;
let event;
typeof window.KeyboardEvent == "function" ? event = new window.KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
bubbles: !0,
cancelable: !0,
composed: !0
}) : (event = new window.Event("keydown", {
bubbles: !0,
cancelable: !0,
composed: !0
}), Object.defineProperties(event, {
key: { value: "Escape", configurable: !0 },
code: { value: "Escape", configurable: !0 },
composed: { value: !0, configurable: !0 }
})), target.dispatchEvent(event);
}
class ReaderMobileReturnController {
scope;
#document;
#root;
#button;
#window;
#media;
#readReaderState;
#dispatchEscape;
#onError;
#appleMobile;
#token;
#entryActive = !1;
#entryPushed = !1;
#historyClosePending = !1;
#historyRestorePending = !1;
#escapeDispatching = !1;
#entryHref = "";
constructor(options) {
if (this.#document = options.document, this.#root = options.root, this.#button = options.button, this.#window = options.window === void 0 ? options.document.defaultView : options.window, this.#readReaderState = options.readReaderState, this.#dispatchEscape = options.dispatchEscape ?? (() => {
dispatchReaderEscape(options.document);
}), this.#onError = options.onError ?? (() => {
}), this.#appleMobile = isReaderAppleMobilePlatform(
this.#window?.navigator
), this.#token = `reader-mobile-return:${++readerMobileReturnSequence}`, this.#media = this.#window?.matchMedia?.(
READER_MOBILE_RETURN_QUERY
) ?? null, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.listen(this.#button, "click", (event) => {
event.preventDefault(), event.stopPropagation(), this.#escapeOnce();
}), this.#window && this.scope.listen(this.#window, "popstate", (event) => {
this.#onHistoryPop(event);
}, !0), this.#media) {
const syncMedia = () => this.#sync();
typeof this.#media.addEventListener == "function" ? this.scope.listen(this.#media, "change", syncMedia) : typeof this.#media.addListener == "function" && (this.#media.addListener(syncMedia), this.scope.add(() => this.#media?.removeListener(syncMedia)));
}
options.readerChanges.subscribe(() => this.#sync(), this.scope), this.scope.add(() => {
this.#root.classList.remove("ldp-apple-mobile-return"), this.#button.hidden = !0, this.#removeCurrentMarker();
}), this.#sync();
}
destroy() {
this.scope.destroy();
}
#sync() {
if (this.scope.destroyed) return;
const visible = readerVisibleState(this.#readReaderState()), mobile = this.#mobileViewport(), appleEntryVisible = visible && mobile && this.#appleMobile;
if (this.#button.hidden = !appleEntryVisible, this.#root.classList.toggle(
"ldp-apple-mobile-return",
appleEntryVisible
), !visible || !mobile) {
this.#releaseHistoryEntry();
return;
}
this.#ensureHistoryEntry();
}
#mobileViewport() {
return this.#media?.matches === !0 || this.#document.documentElement.classList.contains("mobile-view");
}
#history() {
try {
return this.#window?.history ?? null;
} catch {
return null;
}
}
#ownsCurrentEntry(history) {
return valueRecord(history.state)?.[READER_MOBILE_RETURN_STATE_KEY] === this.#token;
}
#ensureHistoryEntry() {
if (this.#historyClosePending || this.#historyRestorePending) return;
const history = this.#history();
if (history)
try {
if (this.#ownsCurrentEntry(history)) {
this.#entryActive = !0, this.#entryHref = this.#href();
return;
}
if (this.#entryActive && this.#href() === this.#entryHref) {
this.#markCurrentEntry(history, this.#entryPushed);
return;
}
const current = valueRecord(history.state);
history.pushState({
...current ?? {},
[READER_MOBILE_RETURN_STATE_KEY]: this.#token
}, ""), this.#entryActive = !0, this.#entryPushed = !0, this.#entryHref = this.#href();
} catch (cause) {
this.#report(cause);
}
}
#releaseHistoryEntry() {
if (!this.#entryActive || this.#historyClosePending) return;
const history = this.#history();
if (this.#entryActive = !1, !(!history || !this.#ownsCurrentEntry(history))) {
if (!this.#entryPushed) {
this.#removeCurrentMarker();
return;
}
this.#entryPushed = !1, this.#historyClosePending = !0;
try {
history.back();
} catch (cause) {
this.#historyClosePending = !1, this.#removeCurrentMarker(), this.#report(cause);
}
}
}
#onHistoryPop(event) {
if (this.#historyClosePending) {
this.#consumeHistoryPop(event), this.#historyClosePending = !1, this.#sync();
return;
}
if (this.#historyRestorePending) {
this.#consumeHistoryPop(event), this.#historyRestorePending = !1;
const history2 = this.#history();
history2 && this.#markCurrentEntry(history2, !1), this.#entryActive = !0, readerVisibleState(this.#readReaderState()) ? this.#escapeOnce() : this.#sync();
return;
}
if (!this.#entryActive) return;
const history = this.#history();
if (history && this.#ownsCurrentEntry(history)) {
this.#consumeHistoryPop(event), this.#historyRestorePending = !0;
try {
history.forward();
} catch (cause) {
this.#historyRestorePending = !1, this.#report(cause), readerVisibleState(this.#readReaderState()) && this.#escapeOnce();
}
return;
}
if (this.#consumeHistoryPop(event), history && this.#entryHref && this.#href() && this.#href() !== this.#entryHref) {
this.#historyRestorePending = !0;
try {
history.forward();
return;
} catch (cause) {
this.#historyRestorePending = !1, this.#report(cause);
}
}
this.#entryActive = !1, this.#entryPushed = !1, readerVisibleState(this.#readReaderState()) && this.#escapeOnce();
}
#consumeHistoryPop(event) {
event.preventDefault(), event.stopImmediatePropagation();
}
#escapeOnce() {
if (!(this.#escapeDispatching || !readerVisibleState(this.#readReaderState()))) {
this.#escapeDispatching = !0;
try {
this.#dispatchEscape();
} catch (cause) {
this.#report(cause);
} finally {
this.#escapeDispatching = !1, this.#sync();
}
}
}
#removeCurrentMarker() {
const history = this.#history();
if (!(!history || !this.#ownsCurrentEntry(history)))
try {
const state = { ...valueRecord(history.state) ?? {} };
delete state[READER_MOBILE_RETURN_STATE_KEY], history.replaceState(state, "");
} catch (cause) {
this.#report(cause);
} finally {
this.#entryActive = !1, this.#entryPushed = !1, this.#historyClosePending = !1, this.#historyRestorePending = !1, this.#entryHref = "";
}
}
#href() {
try {
return String(this.#window?.location?.href ?? "");
} catch {
return "";
}
}
#markCurrentEntry(history, pushed) {
const current = valueRecord(history.state);
history.replaceState({
...current ?? {},
[READER_MOBILE_RETURN_STATE_KEY]: this.#token
}, ""), this.#entryPushed = pushed, this.#entryHref = this.#href();
}
#report(cause) {
try {
this.#onError(cause);
} catch {
}
}
}
}, "b10bcda3a5ec7a7529cae7ffbbf6599a76a0335e4bba23d048b57f7670f9b7b3");
/* Source: lite/src/shell/reader-rate-limit-notice.ts */
runtime.register("src/shell/reader-rate-limit-notice.js", function(module, exports, require) {
var reader_rate_limit_notice_exports = {};
__export(reader_rate_limit_notice_exports, {
ReaderRateLimitNotice: () => ReaderRateLimitNotice
});
module.exports = __toCommonJS(reader_rate_limit_notice_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
class ReaderRateLimitNotice {
scope;
#document;
#elements;
#snapshot;
#challengeAvailable;
#intervalMs;
#timer = null;
#epoch = 0;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#document = options.document, this.#elements = options.elements, this.#snapshot = options.snapshot, this.#challengeAvailable = !!options.challengeHref, this.#intervalMs = Math.max(250, options.intervalMs ?? 1e3), options.challengeHref ? (this.#elements.challenge.href = options.challengeHref, this.#elements.challenge.hidden = !1) : (this.#elements.challenge.removeAttribute("href"), this.#elements.challenge.hidden = !0), this.scope.listen(this.#document, "visibilitychange", () => {
this.#syncPolling();
});
const unsubscribe = options.subscribe?.(() => {
this.refresh();
});
unsubscribe && this.scope.add(unsubscribe), this.scope.add(() => {
this.#epoch += 1, this.#stopPolling(), this.#hide();
}), this.#syncPolling();
}
async refresh() {
if (this.scope.destroyed) return;
const epoch = ++this.#epoch;
try {
const snapshot = await this.#snapshot();
if (this.scope.destroyed || epoch !== this.#epoch) return;
if (snapshot.challengeState === "required") {
this.#showChallenge(), this.#elements.detail.textContent = "关键 Reader 请求遇到 Cloudflare 验证,已暂停后续请求;Reader 会先自动探测,确有需要时只打开一个过盾页。若浏览器拦截,请点击右侧按钮。", this.#show();
return;
}
if (snapshot.challengeState === "active") {
this.#showChallenge(), this.#elements.detail.textContent = snapshot.challengeOwned ? "本页过盾浮窗已打开;若未显示,点击右侧按钮唤起。验证完成后请求自动恢复。" : "其他标签页已有唯一过盾浮窗;点击右侧按钮可唤起。验证完成后请求自动恢复。", this.#show();
return;
}
if (snapshot.blockingReason === "rate-limit" && snapshot.nextPermitDelay > 0) {
this.#elements.detail.textContent = "站点返回 429,所有 Reader 标签页已共享暂停;收到恢复状态后会立即解除。", this.#elements.challenge.hidden = !0, this.#show();
return;
}
this.#hide();
} catch {
!this.scope.destroyed && epoch === this.#epoch && this.#hide();
}
}
destroy() {
this.scope.destroy();
}
#show() {
delete this.#elements.root.dataset.cooldownSeconds, this.#elements.root.hidden = !1;
}
#showChallenge() {
this.#elements.challenge.hidden = !this.#challengeAvailable;
}
#hide() {
this.#elements.root.hidden = !0, delete this.#elements.root.dataset.cooldownSeconds;
}
#syncPolling() {
this.#stopPolling(), !(this.scope.destroyed || this.#document.visibilityState === "hidden") && (this.refresh(), this.#timer = setInterval(() => {
this.refresh();
}, this.#intervalMs));
}
#stopPolling() {
this.#timer !== null && (clearInterval(this.#timer), this.#timer = null);
}
}
}, "ffc689757ef08d03e359509dc5638fda944c2e241c88586d62a685b575595b68");
/* Source: lite/src/shell/reader-report-form-surface.ts */
runtime.register("src/shell/reader-report-form-surface.js", function(module, exports, require) {
var reader_report_form_surface_exports = {};
__export(reader_report_form_surface_exports, {
ReaderReportFormSurface: () => ReaderReportFormSurface
});
module.exports = __toCommonJS(reader_report_form_surface_exports);
var import_reader_action_form_support = require("./reader-action-form-support.js");
class ReaderReportFormSurface {
scope;
#host;
constructor(options) {
this.#host = new import_reader_action_form_support.ReaderActionFormSurfaceHost({
...options,
label: "ReaderReportFormSurface"
}), this.scope = this.#host.scope;
}
open(request) {
const options = request.options.filter(
(option) => Number.isSafeInteger(option.id) && option.id > 0 && String(option.label).trim()
);
if (!options.length)
return Promise.reject(new Error("当前没有可用的举报类型"));
const messageMaxLength = Number(request.messageMaxLength);
if (!Number.isSafeInteger(messageMaxLength) || messageMaxLength <= 0)
return Promise.reject(new Error("举报说明长度限制无效"));
const { id, previousFocus } = this.#host.prepare(), document = this.#host.document, titleId = `ldp-reader-report-title-${id}`, frame = (0, import_reader_action_form_support.createReaderActionFormFrame)({
document,
titleId,
title: request.title,
intro: request.intro,
closeDataAttribute: "data-report-close",
cancelDataAttribute: "data-report-cancel",
submitLabel: "提交举报",
renderIcon: this.#host.renderIcon
}), { form, status, submit } = frame, optionList = document.createElement("div");
optionList.className = "ldp-reader-action-options";
for (const [index, option] of options.entries()) {
const label = document.createElement("label");
label.className = "ldp-reader-action-option";
const radio = document.createElement("input");
radio.type = "radio", radio.name = `reader-report-type-${id}`, radio.value = String(option.id), radio.checked = index === 0;
const copy = document.createElement("span");
copy.className = "ldp-reader-action-option-copy";
const name = document.createElement("strong");
name.textContent = option.label;
const description = document.createElement("small");
description.textContent = option.description, copy.append(name, description), label.append(radio, copy), optionList.append(label);
}
const field = document.createElement("label");
field.className = "ldp-reader-action-field";
const fieldLabel = document.createElement("span");
fieldLabel.textContent = "补充说明";
const message = document.createElement("textarea");
message.maxLength = messageMaxLength, message.placeholder = request.placeholder ?? "选填;所选类型要求说明时必须填写", field.append(fieldLabel, message), status.before(optionList, field);
const session = this.#host.start({
frame,
previousFocus,
closeSelector: "[data-report-close]",
cancelSelector: "[data-report-cancel]"
});
return form.addEventListener("submit", (event) => {
if (event.preventDefault(), session.busy) return;
const selected = form.querySelector(
`input[name="reader-report-type-${id}"]:checked`
), optionId = Number(selected?.value), option = options.find((entry) => entry.id === optionId), normalizedMessage = message.value.trim();
if (session.resetStatus(), !option) {
status.textContent = "请选择举报类型";
return;
}
if (option.requireMessage && !normalizedMessage) {
status.textContent = request.requiredMessageError ?? `${option.label}需要填写具体原因`, message.focus();
return;
}
session.submit({
execute: () => request.submit(Object.freeze({
optionId,
message: normalizedMessage
})),
setBusy: (busy) => (0, import_reader_action_form_support.setReaderActionFormBusy)(
form,
submit,
busy,
"提交中…",
"提交举报"
),
successMessage: (message2) => message2 || "举报已提交",
failureMessage: (cause) => cause instanceof Error ? cause.message : "举报失败,请重试"
});
}), session.mount(() => {
optionList.querySelector(
'input[type="radio"]'
)?.focus({ preventScroll: !0 });
}), session.result;
}
destroy() {
this.#host.destroy();
}
}
}, "f720bdf9c906620c34ce48021bcaadad9788f96c026a8fccd340dd307d3e51b3");
/* Source: lite/src/shell/reader-select-surface.ts */
runtime.register("src/shell/reader-select-surface.js", function(module, exports, require) {
var reader_select_surface_exports = {};
__export(reader_select_surface_exports, {
READER_SELECT_DISMISS_EVENT: () => READER_SELECT_DISMISS_EVENT,
READER_SELECT_OPEN_EVENT: () => READER_SELECT_OPEN_EVENT,
READER_SELECT_OPTIONS_CHANGE_EVENT: () => READER_SELECT_OPTIONS_CHANGE_EVENT,
READER_SELECT_RESELECT_EVENT: () => READER_SELECT_RESELECT_EVENT,
ReaderSelectSurface: () => ReaderSelectSurface
});
module.exports = __toCommonJS(reader_select_surface_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_event_target = require("../dom/event-target.js");
const SELECTOR = [
"select.ldp-reader-select",
"select.ldp-cache-select",
"select.ldp-collection-scope",
"select.ldp-font-weight-select"
].join(","), READER_SELECT_DISMISS_EVENT = "ldp-reader-select-dismiss", READER_SELECT_OPEN_EVENT = "ldp-reader-select-open", READER_SELECT_OPTIONS_CHANGE_EVENT = "ldp-reader-select-options-change", READER_SELECT_RESELECT_EVENT = "ldp-reader-select-reselect";
function eventElement(event) {
const target = event.target;
return target && typeof target.closest == "function" ? target : null;
}
class ReaderSelectSurface {
scope;
#document;
#root;
#states = /* @__PURE__ */ new Map();
#openSelect = null;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#document = options.document, this.#root = options.root, this.#scan(options.root);
const Observer = options.document.defaultView?.MutationObserver ?? globalThis.MutationObserver;
if (typeof Observer == "function") {
const observer = new Observer((mutations) => {
for (const mutation of mutations)
for (const added of mutation.addedNodes) {
const element = added;
added.nodeType === 1 && this.#scan(element);
}
this.#prune();
});
this.scope.observe(observer, options.root, {
childList: !0,
subtree: !0
});
}
this.scope.listen(options.root, "pointerdown", (event) => this.#pointerDown(event)), this.scope.listen(options.root, "mousedown", (event) => this.#mouseDown(event)), this.scope.listen(options.root, "click", (event) => this.#click(event)), this.scope.listen(options.root, "keydown", (event) => this.#keydown(event)), this.scope.listen(options.root, "change", (event) => {
const select = eventElement(event)?.closest(SELECTOR);
select && this.#sync(select);
}), this.scope.listen(options.root, READER_SELECT_DISMISS_EVENT, () => {
this.#close();
}), this.scope.listen(options.root, READER_SELECT_OPTIONS_CHANGE_EVENT, (event) => {
const select = eventElement(event)?.closest(SELECTOR);
select && this.#openSelect === select && this.#open(select);
}), this.scope.listen(options.document, "pointerdown", (event) => {
const select = this.#openSelect;
if (!select) return;
const wrapper = this.#states.get(select);
(0, import_event_target.eventPathIncludes)(event, wrapper ?? null) || this.#close();
}, !0);
const viewport = options.document.defaultView;
viewport && this.scope.listen(viewport, "resize", () => this.#positionOpenMenu());
for (const type of [
"ldp-reader-window-change",
"ldp-reader-workspace-change"
])
this.scope.listen(options.root, type, () => this.#positionOpenMenu());
this.scope.add(() => {
this.#close();
for (const [select, wrapper] of this.#states)
select.removeAttribute("aria-expanded"), select.removeAttribute("aria-haspopup"), select.removeAttribute("data-reader-select-enhanced"), wrapper.replaceWith(select);
this.#states.clear();
});
}
destroy() {
this.scope.destroy();
}
#scan(root) {
root.matches(SELECTOR) && this.#enhance(root);
for (const select of root.querySelectorAll(SELECTOR))
this.#enhance(select);
}
#enhance(select) {
if (select.multiple || select.dataset.readerSelectEnhanced || !select.parentNode) return;
const wrapper = this.#document.createElement("span");
wrapper.className = "ldp-select-surface";
const indicator = this.#document.createElement("span");
indicator.className = "ldp-select-indicator", indicator.setAttribute("aria-hidden", "true");
const menu = this.#document.createElement("span");
menu.className = "ldp-select-menu ldp-picker-options", menu.setAttribute("role", "presentation"), menu.hidden = !0, select.parentNode.insertBefore(wrapper, select), wrapper.append(select, indicator, menu), select.dataset.readerSelectEnhanced = "1", select.setAttribute("aria-haspopup", "listbox"), select.setAttribute("aria-expanded", "false"), this.#states.set(select, wrapper);
}
#prune() {
for (const [select] of this.#states)
select.isConnected || (this.#openSelect === select && (this.#openSelect = null), this.#states.delete(select));
}
#pointerDown(event) {
if (event.button !== 0) return;
const select = eventElement(event)?.closest(SELECTOR);
!select || !this.#root.contains(select) || select.disabled || (event.preventDefault(), select.focus({ preventScroll: !0 }), this.#openSelect === select ? this.#close() : this.#open(select));
}
#mouseDown(event) {
const select = eventElement(event)?.closest(SELECTOR);
select && this.#states.has(select) && event.preventDefault();
}
#click(event) {
const target = eventElement(event), option = target?.closest("[data-reader-select-value]");
if (option) {
const select2 = this.#openSelect;
if (!select2 || !this.#states.get(select2)?.contains(option) || (event.preventDefault(), option.disabled)) return;
const value = option.dataset.readerSelectValue ?? "", changed = select2.value !== value;
let matched = !1;
for (const nativeOption of select2.options) {
const selected = !matched && nativeOption.value === value;
nativeOption.selected = selected, selected && (matched = !0);
}
if (this.#sync(select2), this.#close(), select2.focus({ preventScroll: !0 }), changed) {
const EventConstructor = this.#document.defaultView?.Event ?? Event;
select2.dispatchEvent(new EventConstructor("input", { bubbles: !0 })), select2.dispatchEvent(new EventConstructor("change", { bubbles: !0 }));
} else {
const EventConstructor = this.#document.defaultView?.Event ?? Event;
select2.dispatchEvent(new EventConstructor(
READER_SELECT_RESELECT_EVENT,
{ bubbles: !0 }
));
}
return;
}
const select = target?.closest(SELECTOR);
select && this.#states.has(select) && event.preventDefault();
}
#keydown(event) {
const target = eventElement(event);
if (target?.closest(".ldp-select-search") && this.#openSelect) {
if (event.key === "Escape") {
event.preventDefault(), this.#close(!0);
return;
}
event.key === "ArrowDown" && (event.preventDefault(), this.#enabledOptions(this.#openSelect)[0]?.focus());
return;
}
const option = target?.closest("[data-reader-select-value]");
if (option && this.#openSelect) {
const buttons = this.#enabledOptions(this.#openSelect), index = buttons.indexOf(option);
if (event.key === "Escape") {
event.preventDefault(), this.#close(!0);
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
buttons[(index + delta + buttons.length) % buttons.length]?.focus();
}
return;
}
const select = target?.closest(SELECTOR);
if (!(!select || !this.#states.has(select) || select.disabled)) {
if (event.key === "Escape" && this.#openSelect === select) {
event.preventDefault(), this.#close(!0);
return;
}
(["Enter", " ", "F4"].includes(event.key) || event.altKey && ["ArrowDown", "ArrowUp"].includes(event.key)) && (event.preventDefault(), this.#openSelect === select ? this.#close() : this.#open(select, !0));
}
}
#open(select, focusSelected = !1) {
this.#close();
const EventConstructor = this.#document.defaultView?.Event ?? Event;
select.dispatchEvent(new EventConstructor(
READER_SELECT_OPEN_EVENT,
{ bubbles: !0 }
));
const wrapper = this.#states.get(select), menu = wrapper?.querySelector(".ldp-select-menu");
if (!wrapper || !menu) return;
const options = this.#document.createElement("span");
options.className = "ldp-select-options", options.setAttribute("role", "listbox"), options.setAttribute(
"aria-label",
select.getAttribute("aria-label") || "下拉选项"
);
const optionNodes = [], groups = /* @__PURE__ */ new Map();
let groupSerial = 0;
for (const nativeOption of select.options) {
const parent = nativeOption.parentElement;
let group = "";
if (parent?.tagName === "OPTGROUP" && (group = groups.get(parent) ?? `group-${groupSerial += 1}`, !groups.has(parent))) {
groups.set(parent, group);
const heading = this.#document.createElement("span");
heading.className = "ldp-select-group", heading.dataset.readerSelectGroup = group, heading.textContent = parent.label, heading.setAttribute("aria-hidden", "true"), optionNodes.push(heading);
}
const option = this.#document.createElement("button");
option.type = "button", option.className = "ldp-select-option ldp-picker-option", option.dataset.readerSelectValue = nativeOption.value, option.dataset.readerSelectGroup = group, option.dataset.readerSelectNativeHidden = String(nativeOption.hidden);
const labelText = nativeOption.label || nativeOption.textContent || "";
option.dataset.readerSelectSearchText = [
labelText,
nativeOption.dataset.readerSelectSearchText ?? ""
].join(" ").trim();
const previewText = nativeOption.dataset.readerSelectPreview;
if (previewText) {
option.classList.add("has-preview");
const label = this.#document.createElement("span");
label.className = "ldp-select-option-label", label.textContent = labelText;
const preview = this.#document.createElement("span");
preview.className = "ldp-select-option-preview", preview.textContent = previewText;
const fontFamily = nativeOption.dataset.readerSelectFontFamily;
fontFamily && (preview.style.fontFamily = fontFamily), option.append(label, preview);
} else
option.textContent = labelText;
option.disabled = nativeOption.disabled, option.hidden = nativeOption.hidden, option.setAttribute("role", "option"), option.setAttribute("aria-selected", String(nativeOption.selected)), optionNodes.push(option);
}
if (options.replaceChildren(...optionNodes), menu.classList.toggle(
"has-font-previews",
!!options.querySelector(".ldp-select-option-preview")
), menu.classList.toggle(
"has-long-list",
options.querySelectorAll("[data-reader-select-value]").length > 8
), select.dataset.readerSelectSearchable === "true") {
const search2 = this.#document.createElement("input");
search2.type = "search", search2.className = "ldp-select-search";
const searchLabel = select.dataset.readerSelectSearchLabel ?? "搜索字体";
search2.placeholder = searchLabel, search2.setAttribute("aria-label", searchLabel);
const empty = this.#document.createElement("span");
empty.className = "ldp-select-empty", empty.textContent = select.dataset.readerSelectEmptyLabel ?? "没有匹配的字体", empty.hidden = !0, search2.addEventListener("input", () => {
const query = search2.value.trim().toLocaleLowerCase();
let visible = 0;
for (const option of options.querySelectorAll(
"[data-reader-select-value]"
)) {
const matches = option.dataset.readerSelectNativeHidden !== "true" && (!query || (option.dataset.readerSelectSearchText ?? "").toLocaleLowerCase().includes(query));
option.hidden = !matches, matches && (visible += 1);
}
for (const heading of options.querySelectorAll(
".ldp-select-group"
)) {
const group = heading.dataset.readerSelectGroup;
heading.hidden = ![...options.querySelectorAll(
"[data-reader-select-value]"
)].some(
(option) => !option.hidden && option.dataset.readerSelectGroup === group
);
}
empty.hidden = visible > 0;
}), menu.replaceChildren(search2, options, empty);
} else
menu.replaceChildren(options);
this.#openSelect = select, select.setAttribute("aria-expanded", "true"), menu.hidden = !1, this.#positionOpenMenu();
const search = menu.querySelector(".ldp-select-search");
search ? search.focus({ preventScroll: !0 }) : focusSelected && this.#enabledOptions(select).find((option) => option.dataset.readerSelectValue === select.value)?.focus();
}
#positionOpenMenu() {
const select = this.#openSelect, wrapper = select ? this.#states.get(select) : null, menu = wrapper?.querySelector(".ldp-select-menu"), viewport = this.#document.defaultView;
if (!select || !wrapper || !menu || menu.hidden || !viewport) return;
menu.style.removeProperty("left"), menu.style.removeProperty("top"), menu.style.removeProperty("height"), menu.style.removeProperty("max-height"), menu.style.removeProperty("max-width"), wrapper.classList.remove("is-menu-above");
const selectRect = select.getBoundingClientRect(), menuRect = menu.getBoundingClientRect(), menuWidth = menuRect.width || menu.offsetWidth, menuHeight = menuRect.height || menu.offsetHeight, margin = 12, gap = 6, settingsPanel = select.closest(".ldp-settings-panel"), collisionRect = (settingsPanel ?? select.closest(
".ldp-unwanted-topic-filter-content,.ldp-settings-popover,.ldp-reader-floating-window,.ldp-notifications-popover,.ldp-history-popover,.ldp-bookmarks-popover,.ldp-topic-summary-surface"
))?.getBoundingClientRect(), frozenIntroRect = (settingsPanel ? select.closest(".ldp-settings-section") : null)?.querySelector(".ldp-settings-intro")?.getBoundingClientRect(), bounds = Object.freeze({
left: Math.max(margin, (collisionRect?.left ?? 0) + margin),
right: Math.min(
viewport.innerWidth - margin,
(collisionRect?.right ?? viewport.innerWidth) - margin
),
top: Math.max(
margin,
(collisionRect?.top ?? 0) + margin,
frozenIntroRect ? frozenIntroRect.bottom + margin : 0
),
bottom: Math.min(
viewport.innerHeight - margin,
(collisionRect?.bottom ?? viewport.innerHeight) - margin
)
});
if (menuWidth > 0) {
const availableWidth = Math.max(1, bounds.right - bounds.left), positionedWidth = Math.min(menuWidth, availableWidth);
menuWidth > availableWidth && (menu.style.maxWidth = `${Math.floor(availableWidth)}px`);
const maxLeft = Math.max(bounds.left, bounds.right - positionedWidth), left = Math.max(
bounds.left,
Math.min(maxLeft, menuRect.left)
);
menu.style.left = `${Math.round(left - menuRect.left)}px`;
}
const isLongFontMenu = !!(menu.classList.contains("has-font-previews") && menu.classList.contains("has-long-list")), preferredMenuHeight = isLongFontMenu ? 300 : menuHeight, spaceBelow = Math.max(
0,
bounds.bottom - selectRect.bottom - gap
), spaceAbove = Math.max(
0,
selectRect.top - bounds.top - gap
), menuAbove = preferredMenuHeight > spaceBelow && spaceAbove > spaceBelow;
wrapper.classList.toggle("is-menu-above", menuAbove);
const availableHeight = menuAbove ? spaceAbove : spaceBelow;
if (isLongFontMenu) {
const height = Math.max(
1,
Math.floor(Math.min(preferredMenuHeight, availableHeight))
);
menu.style.height = `${height}px`, menu.style.maxHeight = `${height}px`;
} else menuHeight > availableHeight && (menu.style.maxHeight = `${Math.max(1, Math.floor(availableHeight))}px`);
}
#sync(select) {
const wrapper = this.#states.get(select);
wrapper?.classList.toggle("is-disabled", select.disabled);
for (const option of wrapper?.querySelectorAll(
"[data-reader-select-value]"
) ?? [])
option.setAttribute(
"aria-selected",
String(option.dataset.readerSelectValue === select.value)
);
}
#enabledOptions(select) {
return [...this.#states.get(select)?.querySelectorAll(
"[data-reader-select-value]:not(:disabled)"
) ?? []].filter((option) => !option.hidden);
}
#close(restoreFocus = !1) {
const select = this.#openSelect;
if (!select) return;
this.#openSelect = null, select.setAttribute("aria-expanded", "false");
const wrapper = this.#states.get(select);
wrapper?.classList.remove("is-menu-above");
const menu = wrapper?.querySelector(".ldp-select-menu");
menu && (menu.hidden = !0, menu.style.removeProperty("left"), menu.style.removeProperty("top"), menu.style.removeProperty("height"), menu.style.removeProperty("max-height"), menu.style.removeProperty("max-width")), restoreFocus && select.focus({ preventScroll: !0 });
}
}
}, "d9a84765b4dcf6fab36c19a37ab3c3e522f8abdf333967d898d4461e58f515f9");
/* Source: lite/src/shell/reader-shell-recovery-view.ts */
runtime.register("src/shell/reader-shell-recovery-view.js", function(module, exports, require) {
var reader_shell_recovery_view_exports = {};
__export(reader_shell_recovery_view_exports, {
ReaderShellRecoveryView: () => ReaderShellRecoveryView
});
module.exports = __toCommonJS(reader_shell_recovery_view_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
class ReaderShellRecoveryView {
scope;
#host;
#root;
#message;
#detail;
#retry;
#challenge;
#onRetry;
#onClose;
#busy = !1;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#host = options.host, this.#onRetry = options.onRetry, this.#onClose = options.onClose;
const document = options.document;
this.#root = document.createElement("div"), this.#root.className = "ldp-error", this.#root.role = "alert", this.#root.hidden = !0, this.#message = document.createElement("div"), this.#message.className = "ldp-error-message", this.#detail = document.createElement("small"), this.#detail.className = "ldp-error-message ldp-error-detail";
const actions = document.createElement("div");
actions.className = "ldp-error-actions", this.#retry = document.createElement("button"), this.#retry.className = "ldp-error-retry", this.#retry.type = "button", this.#retry.textContent = "重新加载", this.#challenge = document.createElement("a"), this.#challenge.className = "ldp-error-challenge", this.#challenge.target = "_blank", this.#challenge.rel = "noopener noreferrer", this.#challenge.textContent = "手动完成 Cloudflare 验证", this.#challenge.hidden = !0;
const close = document.createElement("button");
close.className = "ldp-error-close", close.type = "button", close.textContent = "关闭阅读器", actions.append(this.#retry, this.#challenge, close), this.#root.append(this.#message, this.#detail, actions), this.scope.listen(this.#retry, "click", () => void this.#retryNow()), this.scope.listen(close, "click", () => void this.#close()), this.scope.add(() => this.#root.remove());
}
get visible() {
return !this.#root.hidden && this.#root.isConnected;
}
show(failure) {
this.scope.destroyed || (this.#message.textContent = failure.message, this.#detail.textContent = failure.detail, this.#root.dataset.failureKind = failure.kind, failure.kind === "cloudflare" && failure.challengeHref ? (this.#challenge.href = failure.challengeHref, this.#challenge.hidden = !1) : (this.#challenge.removeAttribute("href"), this.#challenge.hidden = !0), this.#busy = !1, this.#retry.disabled = !1, this.#retry.textContent = "重新加载", this.#root.hidden = !1, this.#root.parentNode !== this.#host && this.#host.append(this.#root));
}
clear() {
this.#busy = !1, this.#retry.disabled = !1, this.#retry.textContent = "重新加载", this.#root.hidden = !0, this.#root.remove();
}
destroy() {
this.scope.destroy();
}
async #retryNow() {
if (!(this.#busy || this.scope.destroyed)) {
this.#busy = !0, this.#retry.disabled = !0, this.#retry.textContent = "正在重新加载…";
try {
await this.#onRetry() && this.clear();
} catch {
this.visible && (this.#detail.textContent = "重新加载仍然失败;当前状态已保留,请稍后再试。");
} finally {
this.#busy = !1, this.visible && (this.#retry.disabled = !1, this.#retry.textContent = "重新加载");
}
}
}
async #close() {
this.scope.destroyed || (this.clear(), await this.#onClose());
}
}
}, "3cdf02d3208fe62efdafc48ed1193199225cf205f36b1a83022857526825921c");
/* Source: lite/src/shell/reader-shell-template.ts */
runtime.register("src/shell/reader-shell-template.js", function(module, exports, require) {
var reader_shell_template_exports = {};
__export(reader_shell_template_exports, {
createReaderShellTemplate: () => createReaderShellTemplate
});
module.exports = __toCommonJS(reader_shell_template_exports);
var import_reader_notification_model = require("../notification/reader-notification-model.js"), import_reader_icon = require("../components/reader-icon.js"), import_reader_image_fallback = require("../components/reader-image-fallback.js"), import_html_element = require("../dom/html-element.js"), import_reader_popover_filter_controls = require("../collection/reader-popover-filter-controls.js");
function nonEmpty(value, name) {
const normalized = String(value).trim();
if (!normalized) throw new Error(`${name} 不能为空`);
return normalized;
}
function icon(options, name) {
return (0, import_reader_icon.renderReaderIcon)(options.document, name, options.renderIcon);
}
function button(options, className, label, iconName) {
const node = (0, import_html_element.htmlElement)(options.document, "button", className);
return node.type = "button", node.setAttribute("aria-label", label), node.append(icon(options, iconName)), node;
}
function popoverPager(options, name, infoText) {
const root = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-notification-pager"
), prefix = name === "notification" ? "" : `ldp-${name}-page-`, previous = button(
options,
`${prefix ? `${prefix}prev ` : ""}ldp-notification-page-prev`,
"上一页",
"chevron-left"
);
previous.disabled = !0;
const info = (0, import_html_element.htmlElement)(
options.document,
"span",
`${prefix ? `${prefix}info ` : ""}ldp-notification-page-info`
);
info.textContent = infoText;
const next = button(
options,
`${prefix ? `${prefix}next ` : ""}ldp-notification-page-next`,
"下一页",
"chevron-right"
);
return next.disabled = !0, root.append(previous, info, next), { root, previous, info, next };
}
function collectionBulkActions(options, name, noun, deleteLabel) {
const root = (0, import_html_element.htmlElement)(
options.document,
"div",
`ldp-collection-title-actions ldp-${name}-bulk-actions`
);
root.hidden = !0;
const scope = (0, import_html_element.htmlElement)(
options.document,
"select",
`ldp-reader-select ldp-collection-scope ldp-${name}-select-scope`
);
scope.setAttribute(
"aria-label",
name === "history" ? "历史全选范围" : "收藏全选范围"
);
for (const [value, label] of [
["page", "已加载"],
["all", "全部记录"]
]) {
const option = (0, import_html_element.htmlElement)(options.document, "option", "");
option.value = value, option.textContent = label, scope.append(option);
}
const select = button(
options,
`ldp-collection-action ldp-${name}-select-toggle`,
`全选已加载${noun}`,
name === "bookmarks" ? "select-items" : "square"
);
select.setAttribute("aria-pressed", "false");
const remove = button(
options,
`ldp-collection-action danger ldp-${name}-delete-selected`,
deleteLabel,
name === "bookmarks" ? "trash-2" : "trash"
);
remove.disabled = !0;
const count = (0, import_html_element.htmlElement)(
options.document,
"b",
`ldp-collection-count ldp-${name}-delete-selected-label`
);
count.hidden = !0, count.textContent = "0", remove.append(count);
const done = button(
options,
`ldp-collection-action ldp-${name}-multi-done`,
"退出多选",
name === "bookmarks" ? "check" : "x"
);
return root.append(scope, select, remove, done), { root, scope, select, remove, count, done };
}
function popoverList(document, className, emptyText) {
const list = (0, import_html_element.htmlElement)(document, "div", className), empty = (0, import_html_element.htmlElement)(document, "div", "ldp-notification-empty");
return empty.textContent = emptyText, list.append(empty), list;
}
function createReaderShellTemplate(options) {
const { document } = options, siteName = nonEmpty(options.siteName, "siteName"), homeUrl = nonEmpty(options.homeUrl, "homeUrl"), root = (0, import_html_element.htmlElement)(document, "div", "ldp-overlay");
root.dataset.readerWorkspaceMode = "floating", root.dataset.readerListModeAllowed = String(options.listModeAllowed);
const capsule = (0, import_html_element.htmlElement)(document, "div", "ldp-reader-window-capsule"), pinButton = button(
options,
"ldp-reader-pin-button",
"点击外部时保持显示",
"pin"
);
pinButton.setAttribute("aria-pressed", "false");
const lockButton = (0, import_html_element.htmlElement)(document, "button", "ldp-reader-lock-button");
lockButton.type = "button", lockButton.setAttribute("aria-label", "锁定浮窗");
const unlockedIcon = (0, import_html_element.htmlElement)(document, "span", "ldp-reader-lock-icon");
unlockedIcon.dataset.readerLockIcon = "unlocked", unlockedIcon.append(icon(options, "unlock"));
const lockedIcon = (0, import_html_element.htmlElement)(document, "span", "ldp-reader-lock-icon");
lockedIcon.dataset.readerLockIcon = "locked", lockedIcon.hidden = !0, lockedIcon.append(icon(options, "lock")), lockButton.append(unlockedIcon, lockedIcon), lockButton.setAttribute("aria-pressed", "false");
const placementControl = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-reader-placement-control"
), placementDivider = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-reader-placement-divider"
);
placementDivider.setAttribute("aria-hidden", "true");
const placementStrip = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-reader-placement-strip"
);
placementStrip.setAttribute("role", "group"), placementStrip.setAttribute("aria-label", "切换阅读器显示方式");
const placementOptions = Object.freeze([
["embed-left", "嵌入左侧", "panel-left"],
["embed-right", "嵌入右侧", "panel-right"],
["floating", "浮窗阅读器", "floating-window"],
["fullpage", "全屏阅读器", "maximize-2"]
].map(([mode, label, iconName]) => {
const option = button(
options,
"ldp-reader-placement-option",
label,
iconName
);
return option.dataset.readerPlacement = mode, option.dataset.tooltip = "", option.setAttribute("aria-pressed", "false"), placementStrip.append(option), option;
}));
placementControl.append(placementDivider, placementStrip), capsule.append(pinButton, lockButton, placementControl);
const hostScrollbar = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-reader-host-scrollbar ldp-reader-host-scrollbar-inactive"
);
hostScrollbar.setAttribute("role", "scrollbar"), hostScrollbar.setAttribute("aria-label", "原站主题列表滚动条"), hostScrollbar.setAttribute("aria-orientation", "vertical"), hostScrollbar.setAttribute("aria-valuemin", "0"), hostScrollbar.setAttribute("aria-valuemax", "0"), hostScrollbar.setAttribute("aria-valuenow", "0"), hostScrollbar.setAttribute("aria-disabled", "true"), hostScrollbar.tabIndex = 0;
const hostScrollbarThumb = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-reader-host-scrollbar-thumb"
);
hostScrollbarThumb.setAttribute("aria-hidden", "true"), hostScrollbar.append(hostScrollbarThumb);
const hostTopButton = button(
options,
"ldp-reader-host-top",
"回到原站页面顶部",
"arrow-up"
);
hostTopButton.title = "回到原站页面顶部", hostTopButton.hidden = !0;
const countdown = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-reader-host-top-countdown"
);
countdown.textContent = "3", countdown.setAttribute("aria-hidden", "true"), hostTopButton.append(countdown);
const modal = (0, import_html_element.htmlElement)(document, "div", "ldp-modal"), embedResizeHandle = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-reader-embed-resize"
);
embedResizeHandle.setAttribute("aria-hidden", "true"), modal.append(embedResizeHandle);
for (const direction of ["n", "s", "e", "w", "nw", "ne", "sw", "se"]) {
const handle = (0, import_html_element.htmlElement)(document, "span", "ldp-reader-resize-handle");
handle.dataset.readerResize = direction, handle.setAttribute("aria-hidden", "true"), modal.append(handle);
}
const header = (0, import_html_element.htmlElement)(document, "div", "ldp-header"), mobileReaderBack = button(
options,
"ldp-mobile-reader-back",
"返回上一层",
"chevron-left"
);
mobileReaderBack.title = "返回上一层", mobileReaderBack.hidden = !0;
const home = (0, import_html_element.htmlElement)(document, "a", "ldp-home-logo");
if (home.href = homeUrl, home.setAttribute("aria-label", `回到 ${siteName} 首页`), options.logoUrl) {
const logo = (0, import_html_element.htmlElement)(document, "img", "ldp-logo");
(0, import_reader_image_fallback.installReaderSiteLogoFallback)(logo, options.logoUrl), logo.alt = "", logo.loading = "lazy", logo.decoding = "async", logo.dataset.ldpSiteLogo = "", home.append(logo);
}
const titleWrap = (0, import_html_element.htmlElement)(document, "div", "ldp-title-wrap"), title = (0, import_html_element.htmlElement)(document, "h2", "ldp-title"), titleJump = (0, import_html_element.htmlElement)(document, "span", "ldp-title-jump");
titleJump.textContent = options.loadingTitle ?? "正在载入主题…", titleJump.setAttribute("role", "button"), titleJump.tabIndex = 0, titleJump.setAttribute("aria-label", "跳到 #1");
const topicEditTrigger = button(
options,
"ldp-topic-edit-trigger",
"编辑帖子标题、类别和 label",
"pencil"
);
topicEditTrigger.hidden = !0, topicEditTrigger.setAttribute("aria-haspopup", "dialog"), topicEditTrigger.setAttribute("aria-expanded", "false"), title.append(titleJump, topicEditTrigger);
const titleSubline = (0, import_html_element.htmlElement)(document, "div", "ldp-title-subline"), metaRow = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-meta-row ldp-topic-details"
), meta = (0, import_html_element.htmlElement)(document, "div", "ldp-meta"), metaStats = (0, import_html_element.htmlElement)(document, "span", "ldp-meta-stats");
metaStats.textContent = "正在读取主题信息…";
const metaOwner = (0, import_html_element.htmlElement)(document, "span", "ldp-meta-owner");
metaOwner.hidden = !0;
const metaOwnerCopy = (0, import_html_element.htmlElement)(document, "span", "ldp-meta-owner-copy");
metaOwnerCopy.append("楼主 ");
const metaOwnerValue = (0, import_html_element.htmlElement)(
document,
"a",
"ldp-user-link ldp-topic-owner-link ldp-meta-owner-value"
);
metaOwnerCopy.append(metaOwnerValue);
const onlyOpToggle = (0, import_html_element.htmlElement)(
document,
"button",
"ldp-only-op-toggle"
);
onlyOpToggle.type = "button", onlyOpToggle.disabled = !0, onlyOpToggle.setAttribute("aria-label", "只看楼主"), onlyOpToggle.setAttribute("aria-pressed", "false"), onlyOpToggle.append(icon(options, "user-round")), metaOwner.append(metaOwnerCopy);
const topicControlsParking = (0, import_html_element.htmlElement)(document, "span", "");
topicControlsParking.hidden = !0, topicControlsParking.dataset.ldpTopicControlsParking = "", topicControlsParking.append(onlyOpToggle);
const onlyOpProgress = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-only-op-progress"
);
onlyOpProgress.hidden = !0, onlyOpProgress.setAttribute("role", "status"), onlyOpProgress.setAttribute("aria-live", "polite");
const onlyOpProgressTrack = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-only-op-progress-track"
), onlyOpProgressFill = (0, import_html_element.htmlElement)(
document,
"i",
"ldp-only-op-progress-fill"
);
onlyOpProgressTrack.append(onlyOpProgressFill);
const onlyOpProgressValue = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-only-op-progress-value"
);
onlyOpProgress.append(onlyOpProgressTrack, onlyOpProgressValue), meta.append(metaStats, metaOwner, onlyOpProgress), metaRow.append(meta);
const topicIdentityHost = (0, import_html_element.htmlElement)(document, "div", "ldp-title-topic-row");
titleSubline.append(metaRow, topicIdentityHost), titleWrap.append(title, titleSubline);
const headerActions = (0, import_html_element.htmlElement)(document, "div", "ldp-head-btns"), layoutToggle = button(
options,
"ldp-layout-toggle",
"切换阅读器布局",
"maximize-2"
), notificationsToggle = button(
options,
"ldp-notifications-toggle",
"消息",
"bell"
);
notificationsToggle.setAttribute("aria-expanded", "false");
const notificationsToggleLabel = (0, import_html_element.htmlElement)(document, "span", "");
notificationsToggleLabel.textContent = "消息";
const notificationUnreadBadge = (0, import_html_element.htmlElement)(
document,
"b",
"ldp-notification-unread-badge"
);
notificationUnreadBadge.hidden = !0, notificationsToggle.append(
notificationsToggleLabel,
notificationUnreadBadge
);
const notificationsPopover = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-notifications-popover"
);
notificationsPopover.hidden = !0;
const notificationModeTabsHost = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-notification-mode-tabs"
);
notificationModeTabsHost.setAttribute("role", "tablist"), notificationModeTabsHost.setAttribute("aria-label", "消息来源");
const notificationModeTabs = [];
for (const [mode, label, iconName] of [
["notifications", "通知", "bell"],
["messages", "私信", "mail"]
]) {
const tab = button(
options,
"ldp-notification-mode-tab",
label,
iconName
);
tab.dataset.notificationMode = mode, tab.setAttribute("role", "tab");
const active = mode === "notifications";
tab.classList.toggle("active", active), tab.setAttribute("aria-selected", String(active));
const copy = (0, import_html_element.htmlElement)(document, "span", "");
copy.textContent = label, tab.append(copy), notificationModeTabs.push(tab), notificationModeTabsHost.append(tab);
}
const notificationGroupPanels = [], notificationGroupTabs = [];
for (const mode of ["notifications", "messages"]) {
const keys = import_reader_notification_model.READER_NOTIFICATION_PANEL_GROUP_ORDER.filter((key) => import_reader_notification_model.READER_NOTIFICATION_GROUPS[key].mode === mode), panel = (0, import_html_element.htmlElement)(document, "div", "ldp-notification-tabs");
panel.dataset.notificationModePanel = mode, panel.setAttribute("role", "tablist"), panel.setAttribute("aria-label", mode === "notifications" ? "通知分类" : "私信分类"), panel.style.setProperty("--ldp-notification-tab-count", String(keys.length)), panel.hidden = mode === "messages";
for (const key of keys) {
const group = import_reader_notification_model.READER_NOTIFICATION_GROUPS[key], tab = button(
options,
"ldp-notification-tab",
group.label,
group.icon
);
tab.dataset.notificationGroup = key, tab.setAttribute("role", "tab");
const active = key === "all";
tab.classList.toggle("active", active), tab.setAttribute("aria-selected", String(active));
const copy = (0, import_html_element.htmlElement)(document, "span", "");
copy.textContent = group.label, tab.append(copy), notificationGroupTabs.push(tab), panel.append(tab);
}
notificationGroupPanels.push(panel);
}
const notificationToolbar = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-notification-toolbar"
), notificationUnreadStatus = (0, import_html_element.htmlElement)(
document,
"button",
"ldp-notification-unread-status"
);
notificationUnreadStatus.type = "button", notificationUnreadStatus.setAttribute("aria-label", "没有未读通知"), notificationUnreadStatus.textContent = "没有未读消息";
const notificationMarkAll = button(
options,
"ldp-notification-mark-all",
"全部已读",
"check-square"
);
notificationMarkAll.disabled = !0;
const notificationMarkAllLabel = (0, import_html_element.htmlElement)(document, "span", "");
notificationMarkAllLabel.textContent = "全部已读", notificationMarkAll.append(notificationMarkAllLabel);
const notificationNewMessage = (0, import_html_element.htmlElement)(
document,
"a",
"ldp-notification-new-message"
);
notificationNewMessage.href = `${homeUrl.replace(/\/+$/, "")}/new-message` || "/new-message", notificationNewMessage.hidden = !0, notificationNewMessage.append(icon(options, "mail"));
const notificationNewMessageLabel = (0, import_html_element.htmlElement)(document, "span", "");
notificationNewMessageLabel.textContent = "新消息", notificationNewMessage.append(notificationNewMessageLabel), notificationToolbar.append(
notificationUnreadStatus,
notificationMarkAll,
notificationNewMessage
);
const notificationSearchTools = (0, import_reader_popover_filter_controls.createReaderPopoverSearchTools)(
document,
"notification",
"搜索用户、标题、内容或拼音",
"搜索消息",
"清空消息搜索",
options.renderIcon
), notificationSearch = notificationSearchTools.search.input, notificationSearchClear = notificationSearchTools.search.clear, notificationCategoryFilter = notificationSearchTools.category, notificationTagFilter = notificationSearchTools.tag, notificationList = popoverList(
document,
"ldp-notification-list",
"正在加载消息…"
), {
root: notificationPager,
previous: notificationPagePrevious,
info: notificationPageInfo,
next: notificationPageNext
} = popoverPager(
options,
"notification",
"第 1 页"
);
notificationsPopover.append(
notificationModeTabsHost,
...notificationGroupPanels,
notificationToolbar,
notificationSearchTools.root,
notificationList,
notificationPager
);
const historyToggle = button(
options,
"ldp-history-toggle",
"浏览历史",
"history"
);
historyToggle.setAttribute("aria-expanded", "false");
const historyPopover = (0, import_html_element.htmlElement)(document, "div", "ldp-history-popover");
historyPopover.hidden = !0;
const historyTitle = (0, import_html_element.htmlElement)(document, "div", "ldp-collection-title"), historyTitleLabel = (0, import_html_element.htmlElement)(document, "span", "");
historyTitleLabel.textContent = "浏览历史";
const historyDefaultActions = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-collection-title-actions ldp-history-default-actions"
), historySortToggle = button(
options,
"ldp-collection-action ldp-history-sort-toggle",
"切换浏览历史排序",
"history"
), historyMultiButton = button(
options,
"ldp-collection-action ldp-history-multi",
"多选浏览历史",
"list-checks"
), historyClearButton = button(
options,
"ldp-collection-action danger ldp-history-clear",
"清空全部浏览历史",
"trash"
);
historyClearButton.disabled = !0, historyDefaultActions.append(
historySortToggle,
historyMultiButton,
historyClearButton
);
const {
root: historyBulkActions,
scope: historySelectScope,
select: historySelectToggle,
remove: historyDeleteSelected,
count: historyDeleteSelectedLabel,
done: historyMultiDone
} = collectionBulkActions(
options,
"history",
"浏览历史",
"删除所选浏览历史"
);
historyTitle.append(
historyTitleLabel,
historyDefaultActions,
historyBulkActions
);
const historySearchTools = (0, import_reader_popover_filter_controls.createReaderPopoverSearchTools)(
document,
"history",
"搜索标题或拼音",
"搜索浏览历史",
"清空历史搜索",
options.renderIcon
), historySearch = historySearchTools.search.input, historySearchClear = historySearchTools.search.clear, historyCategoryFilter = historySearchTools.category, historyTagFilter = historySearchTools.tag, historyList = popoverList(
document,
"ldp-history-list ldp-notification-list",
"暂无浏览历史"
), {
root: historyPager,
previous: historyPagePrevious,
info: historyPageInfo,
next: historyPageNext
} = popoverPager(
options,
"history",
"暂无记录"
);
historyPopover.append(
historyTitle,
historySearchTools.root,
historyList,
historyPager
);
const bookmarksToggle = button(
options,
"ldp-bookmarks-toggle",
"收藏与回应",
"bookmark"
);
bookmarksToggle.setAttribute("aria-expanded", "false");
const bookmarksToggleLabel = (0, import_html_element.htmlElement)(document, "span", "");
bookmarksToggleLabel.textContent = "收藏", bookmarksToggle.append(bookmarksToggleLabel);
const bookmarksPopover = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-bookmarks-popover"
);
bookmarksPopover.hidden = !0;
const bookmarksTitle = (0, import_html_element.htmlElement)(document, "div", "ldp-collection-title"), bookmarksTitleLabel = (0, import_html_element.htmlElement)(document, "span", "");
bookmarksTitleLabel.textContent = "收藏与回应";
const bookmarksDefaultActions = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-collection-title-actions ldp-bookmarks-default-actions"
), bookmarksMultiButton = button(
options,
"ldp-collection-action ldp-bookmarks-multi",
"多选收藏",
"list-checks"
);
bookmarksDefaultActions.append(bookmarksMultiButton);
const {
root: bookmarksBulkActions,
scope: bookmarksSelectScope,
select: bookmarksSelectToggle,
remove: bookmarksDeleteSelected,
count: bookmarksDeleteSelectedLabel,
done: bookmarksMultiDone
} = collectionBulkActions(
options,
"bookmarks",
"收藏",
"取消所选收藏"
);
bookmarksTitle.append(
bookmarksTitleLabel,
bookmarksDefaultActions,
bookmarksBulkActions
);
const bookmarkTabsHost = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-bookmark-tabs"
);
bookmarkTabsHost.setAttribute("role", "tablist"), bookmarkTabsHost.setAttribute("aria-label", "收藏与回应类型");
const bookmarkTabs = [];
for (const [type, label] of [
["Reply", "回复"],
["Boost", "Boost"],
["Reaction", "表情回应"],
["Topic", "收藏帖子"],
["Post", "收藏楼层"]
]) {
const tab = button(
options,
"ldp-bookmark-tab",
`${label};拖动排序,首项默认`,
"bookmark"
);
tab.dataset.bookmarkType = type, tab.setAttribute("role", "tab"), tab.setAttribute("aria-selected", String(type === "Reply")), tab.classList.toggle("active", type === "Reply"), tab.replaceChildren(), tab.textContent = label, bookmarkTabs.push(tab), bookmarkTabsHost.append(tab);
}
const bookmarksSearchTools = (0, import_reader_popover_filter_controls.createReaderPopoverSearchTools)(
document,
"bookmarks",
"搜索收藏标题、内容或拼音",
"搜索收藏",
"清空收藏搜索",
options.renderIcon
), bookmarksSearch = bookmarksSearchTools.search.input, bookmarksSearchClear = bookmarksSearchTools.search.clear, bookmarkCategoryFilter = bookmarksSearchTools.category, bookmarkTagFilter = bookmarksSearchTools.tag, bookmarkReactionFilters = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-reaction-filters"
);
bookmarkReactionFilters.setAttribute("role", "group"), bookmarkReactionFilters.setAttribute("aria-label", "按回应表情筛选"), bookmarkReactionFilters.hidden = !0;
const bookmarksList = popoverList(
document,
"ldp-bookmarks-list ldp-notification-list",
"正在加载收藏…"
), {
root: bookmarksPager,
previous: bookmarksPagePrevious,
info: bookmarksPageInfo,
next: bookmarksPageNext
} = popoverPager(
options,
"bookmarks",
"暂无记录"
);
bookmarksPopover.append(
bookmarksTitle,
bookmarkTabsHost,
bookmarksSearchTools.root,
bookmarkReactionFilters,
bookmarksList,
bookmarksPager
);
const openNative = (0, import_html_element.htmlElement)(document, "a", "ldp-open ldp-icon-btn");
openNative.target = "_blank", openNative.rel = "noopener noreferrer", openNative.setAttribute("aria-label", "打开原生主题页面"), openNative.title = "打开原生主题页面", openNative.hidden = !0, openNative.append(icon(options, "external-link")), headerActions.append(
layoutToggle,
notificationsToggle,
notificationsPopover,
historyToggle,
historyPopover,
bookmarksToggle,
bookmarksPopover
);
const titleActions = (0, import_html_element.htmlElement)(document, "div", "ldp-title-actions"), headerActionsToggle = button(
options,
"ldp-header-actions-toggle",
"展开其余标题栏操作",
"chevron-left"
);
headerActionsToggle.setAttribute("aria-expanded", "false");
const refreshTopic = button(
options,
"ldp-reader-refresh ldp-icon-btn",
"清除当前帖子缓存并刷新",
"rotate-ccw"
), immersiveToggle = button(
options,
"ldp-browser-immersive-toggle ldp-icon-btn",
"进入沉浸式阅读",
"maximize-2"
);
immersiveToggle.hidden = !0, refreshTopic.disabled = !0;
const closeReader = button(
options,
"ldp-close ldp-icon-btn",
"关闭阅读器",
"x"
), usesMobileTitleActionToggle = () => document.defaultView?.matchMedia?.(
"(max-width: 700px) and (hover: none) and (pointer: coarse)"
).matches === !0, setHeaderActionsExpanded = (expanded) => {
titleActions.classList.toggle("is-expanded", expanded), header.classList.toggle("ldp-title-actions-expanded", expanded), headerActionsToggle.setAttribute("aria-expanded", String(expanded));
const label = expanded ? "收起原右上角操作" : "展开原右上角操作";
headerActionsToggle.setAttribute("aria-label", label), headerActionsToggle.title = label, headerActionsToggle.replaceChildren(
icon(options, expanded ? "chevron-right" : "chevron-left")
);
};
titleActions.addEventListener("pointerenter", () => {
usesMobileTitleActionToggle() || setHeaderActionsExpanded(!0);
}), titleActions.addEventListener("pointerleave", () => {
usesMobileTitleActionToggle() || setHeaderActionsExpanded(!1);
}), titleActions.addEventListener("focusin", () => {
usesMobileTitleActionToggle() || setHeaderActionsExpanded(!0);
}), titleActions.addEventListener("focusout", (event) => {
const next = event.relatedTarget;
(!next || !titleActions.contains(next)) && setHeaderActionsExpanded(!1);
}), headerActionsToggle.addEventListener("click", () => {
setHeaderActionsExpanded(
usesMobileTitleActionToggle() ? !titleActions.classList.contains("is-expanded") : !0
);
}), setHeaderActionsExpanded(!1), titleActions.append(
headerActionsToggle,
immersiveToggle,
refreshTopic,
openNative,
closeReader
), header.append(
mobileReaderBack,
home,
titleWrap,
topicControlsParking,
headerActions,
titleActions
);
const readerMain = (0, import_html_element.htmlElement)(document, "div", "ldp-reader-main"), rateLimitNotice = (0, import_html_element.htmlElement)(document, "div", "ldp-rate-limit-notice");
rateLimitNotice.hidden = !0, rateLimitNotice.setAttribute("role", "status"), rateLimitNotice.setAttribute("aria-live", "polite"), rateLimitNotice.setAttribute("aria-atomic", "true");
const rateLimitIcon = (0, import_html_element.htmlElement)(document, "span", "ldp-rate-limit-icon");
rateLimitIcon.setAttribute("aria-hidden", "true"), rateLimitIcon.append(icon(options, "alert-triangle"));
const rateLimitCopy = (0, import_html_element.htmlElement)(document, "span", "ldp-rate-limit-copy"), rateLimitTitle = (0, import_html_element.htmlElement)(document, "strong", "");
rateLimitTitle.textContent = "Cloudflare 验证";
const rateLimitDetail = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-rate-limit-detail"
);
rateLimitDetail.textContent = "关键 Reader 请求遇到 Cloudflare 验证时暂停新请求,最多只打开一个独立过盾浮窗。", rateLimitCopy.append(rateLimitTitle, rateLimitDetail);
const rateLimitChallenge = (0, import_html_element.htmlElement)(
document,
"a",
"ldp-rate-limit-challenge"
);
rateLimitChallenge.target = "_blank", rateLimitChallenge.rel = "noopener", rateLimitChallenge.setAttribute(
"aria-label",
"在独立浮窗打开 LINUX DO 官网完成 Cloudflare 验证"
), rateLimitChallenge.append(icon(options, "external-link"));
const rateLimitChallengeLabel = (0, import_html_element.htmlElement)(document, "span", "");
rateLimitChallengeLabel.textContent = "打开过盾浮窗", rateLimitChallenge.append(rateLimitChallengeLabel), rateLimitNotice.append(
rateLimitIcon,
rateLimitCopy,
rateLimitChallenge
);
const body = (0, import_html_element.htmlElement)(document, "div", "ldp-body"), topicTimeline = (0, import_html_element.htmlElement)(document, "aside", "ldp-topic-timeline");
topicTimeline.hidden = !0, topicTimeline.setAttribute("aria-label", "帖子时间轴");
const topicTimelineDate = (0, import_html_element.htmlElement)(
document,
"button",
"ldp-topic-timeline-date"
);
topicTimelineDate.type = "button", topicTimelineDate.setAttribute("aria-label", "跳到首帖");
const topicTimelineTrack = (0, import_html_element.htmlElement)(
document,
"button",
"ldp-topic-timeline-track"
);
topicTimelineTrack.type = "button", topicTimelineTrack.setAttribute("role", "slider"), topicTimelineTrack.setAttribute("aria-label", "跳转楼层"), topicTimelineTrack.setAttribute("aria-orientation", "vertical");
const topicTimelineCursor = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-topic-timeline-cursor ldp-timeline-lens-composited"
);
topicTimelineCursor.setAttribute("aria-hidden", "true");
const topicTimelineThumb = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-topic-timeline-thumb"
), topicTimelineCount = (0, import_html_element.htmlElement)(
document,
"strong",
"ldp-topic-timeline-count"
), topicTimelineCurrent = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-topic-timeline-current"
);
topicTimelineCurrent.textContent = "1";
const topicTimelineDivider = (0, import_html_element.htmlElement)(document, "span", "");
topicTimelineDivider.textContent = "/";
const topicTimelineTotal = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-topic-timeline-total"
);
topicTimelineTotal.textContent = "1", topicTimelineCount.append(
topicTimelineCurrent,
topicTimelineDivider,
topicTimelineTotal
);
const topicTimelinePreview = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-topic-timeline-preview"
);
topicTimelinePreview.setAttribute("aria-hidden", "true"), topicTimelineTrack.append(
topicTimelineCursor,
topicTimelineThumb,
topicTimelineCount,
topicTimelinePreview
);
const topicTimelineFooter = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-topic-timeline-footer"
), topicTimelineRelative = (0, import_html_element.htmlElement)(
document,
"button",
"ldp-topic-timeline-relative"
);
topicTimelineRelative.type = "button", topicTimelineRelative.setAttribute("aria-label", "拉到帖子底部");
const topicTimelineJump = (0, import_html_element.htmlElement)(
document,
"button",
"ldp-topic-timeline-jump"
);
topicTimelineJump.type = "button", topicTimelineJump.textContent = "#", topicTimelineJump.setAttribute("aria-label", "跳到指定楼层"), topicTimelineJump.setAttribute("aria-haspopup", "dialog"), topicTimelineJump.setAttribute("aria-expanded", "false");
const topicTimelineTop = button(
options,
"ldp-topic-timeline-top",
"回到顶部,第 1 楼",
"arrow-up"
);
topicTimelineFooter.append(
topicTimelineRelative,
topicTimelineJump,
topicTimelineTop
);
const topicTimelineJumpForm = (0, import_html_element.htmlElement)(
document,
"form",
"ldp-topic-timeline-jump-form"
);
topicTimelineJumpForm.hidden = !0, topicTimelineJumpForm.setAttribute("role", "dialog"), topicTimelineJumpForm.setAttribute("aria-label", "跳到指定楼层");
const topicTimelineJumpField = (0, import_html_element.htmlElement)(
document,
"label",
"ldp-topic-timeline-jump-field"
), topicTimelineJumpPrefix = (0, import_html_element.htmlElement)(document, "span", "");
topicTimelineJumpPrefix.textContent = "#", topicTimelineJumpPrefix.setAttribute("aria-hidden", "true");
const topicTimelineJumpInput = (0, import_html_element.htmlElement)(
document,
"input",
"ldp-topic-timeline-jump-input"
);
topicTimelineJumpInput.type = "text", topicTimelineJumpInput.inputMode = "numeric", topicTimelineJumpInput.maxLength = 2, topicTimelineJumpInput.autocomplete = "off", topicTimelineJumpInput.spellcheck = !1, topicTimelineJumpInput.setAttribute("enterkeyhint", "go"), topicTimelineJumpInput.setAttribute("aria-label", "楼层号"), topicTimelineJumpInput.setAttribute("aria-invalid", "false");
const topicTimelineJumpHint = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-topic-timeline-jump-hint"
);
topicTimelineJumpHint.id = "ldp-topic-timeline-jump-hint", topicTimelineJumpHint.setAttribute("role", "status"), topicTimelineJumpHint.setAttribute("aria-live", "polite"), topicTimelineJumpHint.setAttribute("aria-atomic", "true"), topicTimelineJumpInput.setAttribute(
"aria-describedby",
topicTimelineJumpHint.id
), topicTimelineJumpField.append(
topicTimelineJumpPrefix,
topicTimelineJumpInput
);
const topicTimelineJumpSubmit = button(
options,
"ldp-topic-timeline-jump-submit",
"跳转",
"chevron-right"
);
topicTimelineJumpSubmit.type = "submit", topicTimelineJumpSubmit.disabled = !0, topicTimelineJumpForm.append(
topicTimelineJumpField,
topicTimelineJumpSubmit,
topicTimelineJumpHint
), topicTimeline.append(
topicTimelineDate,
topicTimelineTrack,
topicTimelineFooter,
topicTimelineJumpForm
), readerMain.append(rateLimitNotice, body, topicTimeline);
const historyBackEdge = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-reader-history-edge ldp-reader-history-edge-back"
);
historyBackEdge.hidden = !0;
const historyBackButton = button(
options,
"ldp-reader-history-nav ldp-reader-history-back",
"上一条阅读历史",
"chevron-left"
);
historyBackButton.hidden = !0, historyBackEdge.append(historyBackButton);
const historyForwardEdge = (0, import_html_element.htmlElement)(
document,
"div",
"ldp-reader-history-edge ldp-reader-history-edge-forward"
);
historyForwardEdge.hidden = !0;
const historyForwardButton = button(
options,
"ldp-reader-history-nav ldp-reader-history-forward",
"下一条阅读历史",
"chevron-right"
);
historyForwardButton.hidden = !0, historyForwardEdge.append(historyForwardButton);
const liveUpdate = (0, import_html_element.htmlElement)(document, "div", "ldp-live-update");
liveUpdate.hidden = !0, liveUpdate.setAttribute("aria-live", "polite");
const liveUpdateJump = button(
options,
"ldp-live-update-jump",
"查看新回复",
"message-square"
), liveUpdateLabel = (0, import_html_element.htmlElement)(document, "span", "");
liveUpdateJump.append(liveUpdateLabel);
const liveUpdateDismiss = button(
options,
"ldp-live-update-dismiss",
"暂时关闭新消息提示",
"x"
);
liveUpdate.append(liveUpdateJump, liveUpdateDismiss), modal.append(
header,
historyBackEdge,
readerMain,
historyForwardEdge,
liveUpdate
), root.append(capsule, hostScrollbar, hostTopButton, modal), options.mount.append(root);
const view = Object.freeze({
root,
modal,
body,
topicHost: body,
surfaceHost: root
}), workspaceElements = Object.freeze({
pageRoot: document.documentElement,
overlay: root,
modal,
header,
titleActions,
headButtons: headerActions,
browserImmersiveToggle: immersiveToggle,
windowCapsule: capsule,
windowLockButton: lockButton,
windowPinButton: pinButton,
windowPlacementControl: placementControl,
windowPlacementStrip: placementStrip,
windowPlacementOptions: placementOptions,
embedResizeHandle,
hostScrollbar,
hostScrollbarThumb,
hostTopButton
});
return Object.freeze({
view,
workspaceElements,
titleJump,
metaHost: meta,
metaStats,
metaOwner,
metaOwnerValue,
onlyOpToggle,
onlyOpProgress,
onlyOpProgressValue,
topicIdentityHost,
headerActions,
headerActionsToggle,
layoutToggle,
immersiveToggle,
titleActions,
topicEditTrigger,
refreshTopic,
openNative,
mobileReaderBack,
closeReader,
rateLimitNotice,
rateLimitDetail,
rateLimitChallenge,
notificationsToggle,
notificationUnreadBadge,
notificationsPopover,
notificationModeTabs: Object.freeze(notificationModeTabs),
notificationGroupPanels: Object.freeze(notificationGroupPanels),
notificationGroupTabs: Object.freeze(notificationGroupTabs),
notificationToolbar,
notificationUnreadStatus,
notificationMarkAll,
notificationNewMessage,
notificationSearch,
notificationSearchClear,
notificationCategoryFilter,
notificationTagFilter,
notificationList,
notificationPagePrevious,
notificationPageInfo,
notificationPageNext,
historyBackEdge,
historyForwardEdge,
historyBackButton,
historyForwardButton,
historyToggle,
historyPopover,
historySortToggle,
historyMultiButton,
historyClearButton,
historyDefaultActions,
historyBulkActions,
historySelectScope,
historySelectToggle,
historyDeleteSelected,
historyDeleteSelectedLabel,
historyMultiDone,
historySearch,
historySearchClear,
historyCategoryFilter,
historyTagFilter,
historyList,
historyPagePrevious,
historyPageInfo,
historyPageNext,
bookmarksToggle,
bookmarksPopover,
bookmarkTabs: Object.freeze(bookmarkTabs),
bookmarksDefaultActions,
bookmarksMultiButton,
bookmarksBulkActions,
bookmarksSelectScope,
bookmarksSelectToggle,
bookmarksDeleteSelected,
bookmarksDeleteSelectedLabel,
bookmarksMultiDone,
bookmarksSearch,
bookmarksSearchClear,
bookmarkCategoryFilter,
bookmarkTagFilter,
bookmarkReactionFilters,
bookmarksList,
bookmarksPagePrevious,
bookmarksPageInfo,
bookmarksPageNext,
topicTimeline,
topicTimelineDate,
topicTimelineTrack,
topicTimelineCursor,
topicTimelineCurrent,
topicTimelineTotal,
topicTimelinePreview,
topicTimelineRelative,
topicTimelineJump,
topicTimelineTop,
topicTimelineJumpForm,
topicTimelineJumpInput,
topicTimelineJumpSubmit,
topicTimelineJumpHint,
liveUpdate,
liveUpdateJump,
liveUpdateLabel,
liveUpdateDismiss
});
}
}, "ded59b6f4ec09e8834723217cc89e0c4c9d201c70f9890a97b2b7c99c65f0552");
/* Source: lite/src/shell/reader-shell.ts */
runtime.register("src/shell/reader-shell.js", function(module, exports, require) {
var reader_shell_exports = {};
__export(reader_shell_exports, {
ReaderShell: () => ReaderShell,
ReaderSurfaceManager: () => ReaderSurfaceManager,
createReaderShellStage: () => createReaderShellStage
});
module.exports = __toCommonJS(reader_shell_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js");
function compatibilityKey(value) {
const key = String(value).trim();
if (!key) throw new Error("Reader Shell compatibilityKey 不能为空");
return key;
}
function abortReason(topicId) {
return new DOMException(`Topic ${topicId} 打开已被替代`, "AbortError");
}
function validateView(view) {
for (const [name, element] of Object.entries(view)) {
if (!element || element.nodeType !== 1)
throw new TypeError(`Reader Shell ${name} 必须是 Element`);
if (name !== "root" && element !== view.root && !view.root.contains(element))
throw new Error(`Reader Shell ${name} 必须属于 root`);
}
return Object.freeze({ ...view });
}
class ReaderSurfaceManager {
parking;
#root;
#defaultHost;
#surfaces = /* @__PURE__ */ new Set();
#destroyed = !1;
constructor(root, defaultHost, parentScope) {
if (defaultHost !== root && !root.contains(defaultHost))
throw new Error("surface defaultHost 必须属于 Shell root");
this.#root = root, this.#defaultHost = defaultHost, this.parking = root.ownerDocument.createDocumentFragment(), parentScope?.add(() => this.destroy());
}
get size() {
return this.#surfaces.size;
}
mount(surface, host = this.#defaultHost) {
if (this.#assertActive(), this.#assertHost(host), surface === this.#root) throw new Error("Shell root 不能登记为 surface");
return this.#surfaces.add(surface), surface.parentNode !== host && host.append(surface), surface;
}
park(surface) {
if (this.#assertActive(), !this.#surfaces.has(surface))
throw new Error("未登记 surface 不能进入 Shell parking");
return surface.parentNode !== this.parking && this.parking.append(surface), surface;
}
destroy() {
if (!this.#destroyed) {
this.#destroyed = !0;
for (const surface of this.#surfaces) surface.remove();
this.#surfaces.clear(), this.parking.replaceChildren();
}
}
#assertHost(host) {
if (host !== this.#root && !this.#root.contains(host))
throw new Error("surface host 必须属于当前 Shell root");
}
#assertActive() {
if (this.#destroyed) throw new Error("ReaderSurfaceManager 已销毁");
}
}
class ReaderShell {
changes = new import_signal.Signal();
diagnostics = new import_signal.Signal();
compatibilityKey;
view;
scope;
surfaces;
#state = "idle";
#epoch = 0;
#active = null;
#opening = null;
#deactivation = null;
constructor(compatibility, view, parentScope) {
this.compatibilityKey = compatibilityKey(compatibility), this.view = validateView(view), this.scope = import_lifecycle.LifecycleScope.ownedBy(parentScope), this.surfaces = new ReaderSurfaceManager(
this.view.root,
this.view.surfaceHost,
this.scope
), this.#syncRootVisibility();
}
get state() {
return this.#state;
}
get activeTopicId() {
return this.#active?.topicId ?? null;
}
get activeValue() {
return this.#active?.value ?? null;
}
canReuse(compatibility) {
return this.#state !== "destroyed" && this.compatibilityKey === compatibilityKey(compatibility);
}
open(topicIdValue, factory) {
if (this.#state === "destroyed")
return Promise.resolve({
status: "failed",
topicId: (0, import_identifiers.discourseTopicId)(topicIdValue),
cause: new Error("Reader Shell 已销毁")
});
const topicId = (0, import_identifiers.discourseTopicId)(topicIdValue);
if (this.#opening?.topicId === topicId) return this.#opening.promise;
if (!this.#opening && this.#active?.topicId === topicId)
return Promise.resolve(Object.freeze({
status: "reused",
topicId,
value: this.#active.value
}));
this.#epoch += 1, this.#cancelOpening();
const controller = new AbortController(), scope = this.scope.child();
scope.add(() => {
controller.signal.aborted || controller.abort(abortReason(topicId));
});
const opening = {
topicId,
epoch: this.#epoch,
controller,
scope,
promise: Promise.resolve({ status: "superseded", topicId })
};
return this.#opening = opening, this.#setState(this.#active ? "switching" : "opening"), opening.promise = this.#runOpen(opening, factory), opening.promise;
}
async closeTopic() {
if (this.#isDestroyed()) return !0;
const epoch = ++this.#epoch;
this.#cancelOpening(), this.#setState("closed");
const closed = await this.#deactivateActive("close");
return this.#isDestroyed() ? !0 : (this.#epoch === epoch && this.#setState("closed"), closed);
}
destroy() {
if (this.#state !== "destroyed") {
this.#epoch += 1, this.#cancelOpening(), this.#active = null, this.#setState("destroyed");
try {
this.scope.destroy();
} catch (cause) {
this.diagnostics.emit(Object.freeze({
phase: "shell-cleanup",
topicId: null,
cause
}));
} finally {
this.view.root.remove();
}
}
}
async #runOpen(opening, factory) {
if (!await this.#deactivateActive("switch")) {
const cause = new Error("当前 Topic prepareClose 失败"), current = this.#isCurrent(opening);
return this.#destroyTopicScope(opening.scope, opening.topicId), current ? (this.#opening = null, this.#setState("running"), Object.freeze({ status: "failed", topicId: opening.topicId, cause })) : Object.freeze({ status: "superseded", topicId: opening.topicId });
}
if (!this.#isCurrent(opening))
return this.#destroyTopicScope(opening.scope, opening.topicId), Object.freeze({ status: "superseded", topicId: opening.topicId });
try {
const result = await factory(Object.freeze({
topicId: opening.topicId,
scope: opening.scope,
signal: opening.controller.signal,
mount: (node) => (this.view.topicHost.append(node), opening.scope.add(() => node.parentNode?.removeChild(node)))
}));
return typeof result.cleanup == "function" && opening.scope.add(result.cleanup), this.#isCurrent(opening) ? (this.#active = Object.freeze({
topicId: opening.topicId,
value: result.value,
scope: opening.scope,
...result.prepareClose ? { prepareClose: result.prepareClose } : {}
}), this.#opening = null, this.#setState("running"), Object.freeze({
status: "opened",
topicId: opening.topicId,
value: result.value
})) : (this.#destroyTopicScope(opening.scope, opening.topicId), Object.freeze({ status: "superseded", topicId: opening.topicId }));
} catch (cause) {
const current = this.#isCurrent(opening);
return this.#destroyTopicScope(opening.scope, opening.topicId), current ? (this.#opening = null, this.#setState("failed"), this.diagnostics.emit(Object.freeze({
phase: "topic-open",
topicId: opening.topicId,
cause
})), Object.freeze({ status: "failed", topicId: opening.topicId, cause })) : Object.freeze({ status: "superseded", topicId: opening.topicId });
}
}
#cancelOpening() {
const opening = this.#opening;
opening && (this.#opening = null, opening.controller.signal.aborted || opening.controller.abort(abortReason(opening.topicId)), this.#destroyTopicScope(opening.scope, opening.topicId));
}
#isCurrent(opening) {
return this.#state !== "destroyed" && this.#opening === opening && this.#epoch === opening.epoch && !opening.controller.signal.aborted;
}
#deactivateActive(reason) {
if (!this.#active) return Promise.resolve(!0);
if (this.#deactivation) {
const deactivation2 = this.#deactivation;
return reason === "switch" ? deactivation2 : deactivation2.then((closed) => closed || !this.#active ? closed : this.#deactivateActive("close"));
}
const active = this.#active, deactivation = (async () => {
let prepared = !0;
try {
await active.prepareClose?.(reason);
} catch (cause) {
if (prepared = !1, this.diagnostics.emit(Object.freeze({
phase: "prepare-close",
topicId: active.topicId,
cause
})), reason === "switch") return !1;
}
return this.#active === active && (this.#active = null), this.#destroyTopicScope(active.scope, active.topicId), prepared;
})().finally(() => {
this.#deactivation === deactivation && (this.#deactivation = null);
});
return this.#deactivation = deactivation, this.#deactivation;
}
#destroyTopicScope(scope, topicId) {
try {
scope.destroy();
} catch (cause) {
this.diagnostics.emit(Object.freeze({
phase: "topic-cleanup",
topicId,
cause
}));
}
}
#setState(state) {
this.#state !== state && (this.#state = state, this.#syncRootVisibility(), this.changes.emit(state));
}
#syncRootVisibility() {
this.view.root.hidden = this.#state === "idle" || this.#state === "closed" || this.#state === "destroyed";
}
#isDestroyed() {
return this.#state === "destroyed";
}
}
function createReaderShellStage(options) {
return Object.freeze({
name: String(options.name ?? "reader-shell").trim() || "reader-shell",
required: !0,
setup: (scope, context) => {
const shell = new ReaderShell(
options.compatibilityKey(context),
options.createView(context),
scope
);
let readyCleanup;
try {
readyCleanup = options.onReady?.(shell, context) || void 0;
} catch (cause) {
throw shell.destroy(), cause;
}
return () => {
try {
readyCleanup?.();
} finally {
shell.destroy();
}
};
}
});
}
}, "aa4bb2cf57a3e3ac5ee0cf9690c2dd7c56caf9ae6d71e1f8e454ea58f8836d20");
/* Source: lite/src/shell/reader-shortcut-controller.ts */
runtime.register("src/shell/reader-shortcut-controller.js", function(module, exports, require) {
var reader_shortcut_controller_exports = {};
__export(reader_shortcut_controller_exports, {
READER_SHORTCUT_GROUPS: () => READER_SHORTCUT_GROUPS,
ReaderShortcutController: () => ReaderShortcutController,
readerPreferencesShortcutAdapter: () => readerPreferencesShortcutAdapter,
readerShortcutBindingFromEvent: () => readerShortcutBindingFromEvent,
readerShortcutBindingIssue: () => readerShortcutBindingIssue,
readerShortcutBindingLabel: () => readerShortcutBindingLabel
});
module.exports = __toCommonJS(reader_shortcut_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_event_target = require("../dom/event-target.js"), import_signal = require("../kernel/signal.js"), import_reader_preferences_schema = require("../state/reader-preferences-schema.js");
const readerPreferencesShortcutAdapter = Object.freeze({
read: (preferences) => preferences.readerShortcutBindings,
createPatch: (readerShortcutBindings) => ({
readerShortcutBindings
})
}), definitions = Object.freeze({
historyBack: Object.freeze({
group: "navigation",
label: "上一条阅读历史",
description: "切换到上一条帖子并保存当前位置。"
}),
historyForward: Object.freeze({
group: "navigation",
label: "下一条阅读历史",
description: "切换到下一条帖子并恢复阅读位置。"
}),
topicTop: Object.freeze({
group: "navigation",
label: "回到帖子开头",
description: "跳到主楼。"
}),
topicBottom: Object.freeze({
group: "navigation",
label: "跳到帖子末尾",
description: "跳到当前帖子最后可见楼层。"
}),
floorJump: Object.freeze({
group: "navigation",
label: "跳到指定楼层",
description: "打开楼层号输入框。"
}),
discussionHorizontalScroll: Object.freeze({
group: "navigation",
label: "完整讨论横向滚动",
description: "树状层级超出宽度时,按滚轮方向左右查看。"
}),
onlyAuthor: Object.freeze({
group: "reading",
label: "只看楼主",
description: "切换只看楼主模式。"
}),
translate: Object.freeze({
group: "reading",
label: "切换正文翻译",
description: "在原文、双语和译文之间切换。"
}),
refreshTopic: Object.freeze({
group: "reading",
label: "刷新当前帖子",
description: "清除当前帖子缓存并重新加载。"
}),
refreshHost: Object.freeze({
group: "reading",
label: "刷新嵌入原站",
description: "嵌入阅读时刷新后方的原站列表。"
}),
openOriginal: Object.freeze({
group: "reading",
label: "打开原帖",
description: "在新标签页打开原站帖子。"
}),
settings: Object.freeze({
group: "panels",
label: "设置",
description: "打开设置面板。"
}),
notifications: Object.freeze({
group: "panels",
label: "消息",
description: "打开或关闭通知与私信面板。"
}),
historyPanel: Object.freeze({
group: "panels",
label: "浏览历史面板",
description: "打开或关闭浏览历史列表。"
}),
bookmarksPanel: Object.freeze({
group: "panels",
label: "收藏与回应面板",
description: "打开或关闭收藏与回应列表。"
}),
likeTopic: Object.freeze({
group: "topicActions",
label: "点赞主帖",
description: "点赞或取消点赞当前主帖。"
}),
replyTopic: Object.freeze({
group: "topicActions",
label: "回复主题",
description: "打开当前主题的回复编辑器。"
}),
bookmarkTopic: Object.freeze({
group: "topicActions",
label: "收藏主题",
description: "收藏或编辑当前主题收藏。"
}),
toggleFullscreen: Object.freeze({
group: "window",
label: "全屏/浮窗切换",
description: "在全屏阅读与浮窗阅读之间切换。"
}),
toggleQueue: Object.freeze({
group: "window",
label: "阅读队列",
description: "展开或收起阅读队列。"
}),
closeReader: Object.freeze({
group: "window",
label: "关闭阅读器",
description: "遵循“连续两次关闭”安全设置退出阅读器。"
})
}), groupCopy = Object.freeze({
navigation: Object.freeze({
title: "浏览导航",
description: "在阅读历史和当前帖子的关键位置之间移动。"
}),
reading: Object.freeze({
title: "阅读工具",
description: "切换阅读过滤、翻译和内容刷新。"
}),
panels: Object.freeze({
title: "界面面板",
description: "快速打开阅读器里的常用信息面板。"
}),
topicActions: Object.freeze({
title: "帖子操作",
description: "复用主帖操作列,不复制点赞、回复或收藏逻辑。"
}),
window: Object.freeze({
title: "窗口与队列",
description: "控制阅读器窗口、阅读队列与退出。"
})
}), READER_SHORTCUT_GROUPS = Object.freeze(
Object.keys(groupCopy).map(
(group) => Object.freeze({
id: group,
...groupCopy[group],
actions: Object.freeze(import_reader_preferences_schema.READER_SHORTCUT_ACTIONS.filter((id) => definitions[id].group === group).map((id) => Object.freeze({
id,
label: definitions[id].label,
description: definitions[id].description
})))
})
)
);
function readerShortcutBindingLabel(binding) {
const labels = Object.freeze({
Ctrl: "Ctrl",
Alt: "Alt",
Shift: "Shift",
Meta: "Meta",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
Home: "Home",
End: "End",
PageUp: "Page Up",
PageDown: "Page Down",
Space: "空格",
Escape: "Esc",
Enter: "Enter",
Tab: "Tab",
Comma: ",",
Period: ".",
Slash: "/",
Semicolon: ";",
Quote: "'",
BracketLeft: "[",
BracketRight: "]",
Backslash: "\\",
Minus: "-",
Equal: "=",
Backquote: "`",
Mouse1: "鼠标中键",
Mouse3: "鼠标后退键",
Mouse4: "鼠标前进键",
Wheel: "滚轮"
});
return binding.split("+").map(
(part) => labels[part] ?? (/^Key[A-Z]$/.test(part) ? part.slice(3) : /^Digit\d$/.test(part) ? part.slice(5) : /^Numpad\d$/.test(part) ? `数字键盘 ${part.slice(6)}` : /^Mouse\d+$/.test(part) ? `鼠标键 ${Number(part.slice(5)) + 1}` : part)
).join(" + ");
}
function readerShortcutBindingFromEvent(event) {
const source = event, wheel = event.type === "wheel", mouse = /^(?:mouse|auxclick)/.test(event.type);
if (wheel && !source.ctrlKey && !source.altKey && !source.shiftKey && !source.metaKey) return "";
const code = wheel ? "Wheel" : mouse ? source.button === 1 || Number(source.button) >= 3 ? `Mouse${source.button}` : "" : String(source.code ?? "");
return !code || !mouse && !wheel && /^(?:Control|Alt|Shift|Meta)(?:Left|Right)?$/.test(code) ? "" : (0, import_reader_preferences_schema.normalizeReaderShortcutBinding)([
source.ctrlKey && "Ctrl",
source.altKey && "Alt",
source.shiftKey && "Shift",
source.metaKey && "Meta",
code
].filter(Boolean).join("+"));
}
function readerShortcutBindingIssue(bindings, binding, exceptAction) {
const normalized = (0, import_reader_preferences_schema.normalizeReaderShortcutBinding)(binding);
if (!normalized) return "这个按键组合无法识别,请换一个。";
const owner = import_reader_preferences_schema.READER_SHORTCUT_ACTIONS.find(
(action) => action !== exceptAction && bindings[action].includes(normalized)
);
if (owner)
return `${readerShortcutBindingLabel(normalized)} 已绑定“${definitions[owner].label}”,请先移除或改用其他组合。`;
const policyIssue = (0, import_reader_preferences_schema.readerShortcutBindingPolicyIssue)(normalized);
return policyIssue === "reserved" ? `${readerShortcutBindingLabel(normalized)} 通常由浏览器占用,无法保证生效,请换一个组合。` : policyIssue === "bare-alphanumeric" ? "单个字母或数字容易与论坛快捷键冲突,请至少加入 Ctrl、Alt、Shift 或 Meta。" : "";
}
function editableTarget(event) {
const target = (0, import_event_target.eventElement)(event);
if (!target) return !1;
const element = target;
return element.closest(
'input,textarea,select,[contenteditable="true"],[contenteditable=""]'
) ? !0 : event.type !== "keydown" ? !1 : !!element.closest(
'button,a[href],[role="button"],[role="slider"],[role="tab"],[role="menuitem"],[role="dialog"]'
);
}
class ReaderShortcutController {
scope;
changes = new import_signal.Signal();
captures = new import_signal.Signal();
#adapter;
#persist;
#execute;
#canExecute;
#onUnavailable;
#onError;
#bindings;
#recording = null;
#mouseGuard = null;
constructor(options) {
this.#adapter = options.preferences, this.#persist = options.persist, this.#execute = options.execute, this.#canExecute = options.canExecute ?? (() => !0), this.#onUnavailable = options.onUnavailable ?? (() => {
}), this.#onError = options.onError ?? (() => {
}), this.#bindings = (0, import_reader_preferences_schema.normalizeReaderShortcutBindings)(
this.#adapter.read(options.readPreferences())
), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), options.preferenceChanges.subscribe((preferences) => {
this.#bindings = (0, import_reader_preferences_schema.normalizeReaderShortcutBindings)(
this.#adapter.read(preferences)
), this.#publish();
}, this.scope), this.scope.listen(options.target, "keydown", (event) => {
this.#handle(event);
}, !1), this.scope.listen(options.target, "wheel", (event) => {
this.#handle(event);
}, { capture: !0, passive: !1 }), this.scope.listen(options.target, "mousedown", (event) => {
this.#handle(event);
}, !0), this.scope.listen(options.target, "mouseup", (event) => {
this.#handle(event);
}, !0), this.scope.listen(options.target, "auxclick", (event) => {
this.#handle(event);
}, !0), this.scope.add(() => {
this.changes.clear(), this.captures.clear();
});
}
get snapshot() {
return Object.freeze({
bindings: this.#bindings,
recording: this.#recording
});
}
startRecording(action) {
if (!import_reader_preferences_schema.READER_SHORTCUT_ACTIONS.includes(action))
throw new RangeError(`未知快捷动作:${action}`);
this.#recording = this.#recording === action ? null : action, this.#publish();
}
cancelRecording() {
this.#recording !== null && (this.#recording = null, this.#publish());
}
remove(action, binding) {
this.#replace(action, this.#bindings[action].filter(
(candidate) => candidate !== binding
));
}
clear(action) {
this.#replace(action, Object.freeze([]));
}
reset(action) {
const defaults = import_reader_preferences_schema.READER_SHORTCUT_DEFAULTS[action], issue = defaults.map((binding) => readerShortcutBindingIssue(this.#bindings, binding, action)).find(Boolean);
return issue || (this.#replace(action, defaults), "");
}
resetAll() {
this.#write((0, import_reader_preferences_schema.normalizeReaderShortcutBindings)(import_reader_preferences_schema.READER_SHORTCUT_DEFAULTS));
}
destroy() {
this.scope.destroy();
}
#handle(event) {
const keyboard = event, mouse = event;
if (event.type !== "mousedown" && this.#mouseGuard && mouse.button === this.#mouseGuard.button && Date.now() < this.#mouseGuard.until) {
event.preventDefault(), event.stopImmediatePropagation();
return;
}
if (event.defaultPrevented || keyboard.isComposing || keyboard.repeat) return;
const binding = readerShortcutBindingFromEvent(event);
if (!binding) return;
if (this.#recording) {
this.#consume(event, mouse);
const action2 = this.#recording;
if (this.#bindings[action2].includes(binding)) {
this.captures.emit(Object.freeze({
action: action2,
binding,
accepted: !1,
message: `${readerShortcutBindingLabel(binding)} 已在该动作中。`
}));
return;
}
const issue = readerShortcutBindingIssue(
this.#bindings,
binding,
action2
);
if (!issue && this.#bindings[action2].length >= 3) {
this.captures.emit(Object.freeze({
action: action2,
binding,
accepted: !1,
message: "每项最多保留 3 个快捷方式。"
}));
return;
}
if (issue) {
this.captures.emit(Object.freeze({
action: action2,
binding,
accepted: !1,
message: issue
}));
return;
}
this.#replace(action2, Object.freeze([
...this.#bindings[action2],
binding
])), this.#recording = null, this.captures.emit(Object.freeze({
action: action2,
binding,
accepted: !0,
message: `已绑定 ${readerShortcutBindingLabel(binding)}`
})), this.#publish();
return;
}
const targetBlocked = editableTarget(event), action = import_reader_preferences_schema.READER_SHORTCUT_ACTIONS.find(
(candidate) => this.#bindings[candidate].includes(binding)
);
if (action) {
if (targetBlocked || !this.#canExecute(action, event)) {
event.type !== "keydown" && this.#consume(event, mouse);
return;
}
try {
const result = this.#execute(action, event);
if (result === !1 && action === "discussionHorizontalScroll")
return;
if (this.#consume(event, mouse), result === !1) {
this.#onUnavailable(action, definitions[action].label);
return;
}
result && typeof result.then == "function" && Promise.resolve(result).then((value) => {
value === !1 && this.#onUnavailable(action, definitions[action].label);
}).catch(this.#onError);
} catch (cause) {
this.#onError(cause);
}
}
}
#consume(event, mouse) {
event.preventDefault(), event.stopImmediatePropagation(), event.type === "mousedown" && (this.#mouseGuard = Object.freeze({
button: Number(mouse.button),
until: Date.now() + 800
}));
}
#replace(action, bindings) {
this.#write((0, import_reader_preferences_schema.normalizeReaderShortcutBindings)({
...this.#bindings,
[action]: bindings
}));
}
#write(bindings) {
const persisted = this.#persist(this.#adapter.createPatch(bindings));
this.#bindings = (0, import_reader_preferences_schema.normalizeReaderShortcutBindings)(
this.#adapter.read(persisted)
), this.#publish();
}
#publish() {
this.scope.destroyed || this.changes.emit(this.snapshot);
}
}
}, "56c75eeed42c96ccea449ca0f6c743739d6da190d504b4d0429202d9e20ef593");
/* Source: lite/src/shell/reader-surface-portal.ts */
runtime.register("src/shell/reader-surface-portal.js", function(module, exports, require) {
var reader_surface_portal_exports = {};
__export(reader_surface_portal_exports, {
ReaderSurfacePortal: () => ReaderSurfacePortal
});
module.exports = __toCommonJS(reader_surface_portal_exports);
const PORTAL_ID = "ldp-mian-lite-portal";
class ReaderSurfacePortal {
host;
root;
style;
#destroyed = !1;
constructor(document, stylesheet) {
if (!document.documentElement)
throw new Error("Reader Surface Portal 缺少 documentElement");
if (document.getElementById(PORTAL_ID))
throw new Error("Reader Surface Portal 已存在");
const host = document.createElement("div");
host.id = PORTAL_ID, host.className = "ldp-reader-portal-host sciapp-ldp-owned", host.dataset.ldpReaderPortal = "mian-lite";
const root = host.attachShadow({ mode: "open" }), style = document.createElement("style");
style.dataset.ldpReaderShadow = "mian-lite", style.textContent = stylesheet, root.append(style), document.documentElement.append(host), this.host = host, this.root = root, this.style = style;
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.host.remove());
}
}
}, "fa2ac6ce26a1fa3db363281f1f16bf64a67328c388b03d2ee73e2b0fa3ccc621");
/* Source: lite/src/shell/reader-topic-edit-form-surface.ts */
runtime.register("src/shell/reader-topic-edit-form-surface.js", function(module, exports, require) {
var reader_topic_edit_form_surface_exports = {};
__export(reader_topic_edit_form_surface_exports, {
ReaderTopicEditFormSurface: () => ReaderTopicEditFormSurface
});
module.exports = __toCommonJS(reader_topic_edit_form_surface_exports);
var import_html_element = require("../dom/html-element.js"), import_reader_action_form_support = require("./reader-action-form-support.js");
function tagKey(value) {
return String(value ?? "").trim().toLocaleLowerCase();
}
function categorySearchText(category, byId) {
return `${(category.parentCategoryId ? byId.get(category.parentCategoryId) : null)?.name ?? ""} ${category.name}`.trim().toLocaleLowerCase();
}
class ReaderTopicEditFormSurface {
scope;
#host;
constructor(options) {
this.#host = new import_reader_action_form_support.ReaderActionFormSurfaceHost({
...options,
label: "ReaderTopicEditFormSurface"
}), this.scope = this.#host.scope;
}
get #document() {
return this.#host.document;
}
open(request) {
if (request.signal?.aborted) return Promise.resolve(!1);
const categories = request.categories.filter((category) => Number.isSafeInteger(category.id) && category.id > 0 && String(category.name).trim());
if (!categories.length)
return Promise.reject(new Error("原站分类数据尚未就绪,请稍后重试"));
const categoryById = new Map(categories.map((category) => [
category.id,
category
]));
let selectedCategoryId = categoryById.has(request.categoryId) ? request.categoryId : categories[0].id;
const availableTags = /* @__PURE__ */ new Map(), selectedTagKeys = /* @__PURE__ */ new Set();
for (const tag of request.tags) {
const key = tagKey(tag.name);
key && (availableTags.set(key, tag), selectedTagKeys.add(key));
}
const { id, previousFocus } = this.#host.prepare(), layer = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-reader-action-layer ldp-topic-edit-layer"
), dialog = (0, import_html_element.htmlElement)(
this.#document,
"section",
"ldp-reader-action-dialog ldp-topic-edit-dialog"
);
dialog.setAttribute("role", "dialog"), dialog.setAttribute("aria-modal", "true");
const titleId = `ldp-topic-edit-dialog-title-${id}`;
dialog.setAttribute("aria-labelledby", titleId);
const head = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-reader-action-head"), heading = (0, import_html_element.htmlElement)(this.#document, "strong", "", "编辑帖子信息");
heading.id = titleId;
const close = this.#iconButton(
"ldp-reader-action-close ldp-topic-edit-close",
"关闭编辑弹层",
"x"
);
close.dataset.topicEditClose = "", head.append(heading, close);
const form = (0, import_html_element.htmlElement)(
this.#document,
"form",
"ldp-reader-action-form ldp-topic-edit-form"
), body = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-reader-action-body ldp-topic-edit-body"
), titleField = (0, import_html_element.htmlElement)(
this.#document,
"label",
"ldp-topic-edit-field ldp-topic-edit-title"
), titleLabel = (0, import_html_element.htmlElement)(this.#document, "span", "", "标题"), titleInput = this.#document.createElement("input");
titleInput.name = "title", titleInput.type = "text", titleInput.maxLength = 255, titleInput.required = !0, titleInput.value = String(request.title ?? "").trim(), titleField.append(titleLabel, titleInput);
const categoryField = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-topic-edit-field"
), categoryLabel = (0, import_html_element.htmlElement)(this.#document, "span", "", "类别");
categoryLabel.id = `ldp-topic-edit-category-label-${id}`;
const categoryRoot = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-topic-edit-category"
), categoryTrigger = (0, import_html_element.htmlElement)(
this.#document,
"button",
"ldp-topic-edit-category-trigger ldp-picker-trigger"
);
categoryTrigger.type = "button", categoryTrigger.setAttribute("aria-haspopup", "listbox"), categoryTrigger.setAttribute("aria-expanded", "false"), categoryTrigger.setAttribute("aria-labelledby", categoryLabel.id);
const categoryValue = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-topic-edit-category-value"
);
categoryTrigger.append(categoryValue, this.#icon("chevron-right"));
const categoryMenu = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-topic-edit-category-menu"
);
categoryMenu.hidden = !0;
const categorySearchLabel = (0, import_html_element.htmlElement)(
this.#document,
"label",
"ldp-topic-edit-category-search ldp-picker-search"
);
categorySearchLabel.append(this.#icon("search"));
const categorySearch = this.#document.createElement("input");
categorySearch.name = "category-search", categorySearch.type = "search", categorySearch.autocomplete = "off", categorySearch.placeholder = "搜索类别", categorySearch.setAttribute("aria-label", "搜索类别"), categorySearchLabel.append(categorySearch);
const categoryOptions = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-topic-edit-category-options ldp-picker-options"
);
categoryOptions.setAttribute("role", "listbox"), categoryOptions.setAttribute("aria-labelledby", categoryLabel.id), categoryMenu.append(categorySearchLabel, categoryOptions), categoryRoot.append(categoryTrigger, categoryMenu), categoryField.append(categoryLabel, categoryRoot);
const tagField = (0, import_html_element.htmlElement)(this.#document, "label", "ldp-topic-edit-field"), tagLabel = (0, import_html_element.htmlElement)(this.#document, "span", "", "label"), tagControl = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-topic-edit-label-control"
), tagValues = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-topic-edit-label-values"
), tagInput = (0, import_html_element.htmlElement)(
this.#document,
"input",
"ldp-topic-edit-label-input"
);
tagInput.name = "tag-search", tagInput.type = "text", tagInput.autocomplete = "off", tagInput.placeholder = "搜索 label", tagInput.setAttribute("aria-label", "添加 label");
const tagOptionsId = `ldp-topic-edit-label-options-${id}`;
tagInput.setAttribute("list", tagOptionsId);
const tagOptions = (0, import_html_element.htmlElement)(
this.#document,
"datalist",
"ldp-topic-edit-label-options"
);
tagOptions.id = tagOptionsId, tagControl.append(tagValues, tagInput), tagField.append(tagLabel, tagControl, tagOptions);
const status = (0, import_html_element.htmlElement)(this.#document, "p", "ldp-topic-edit-status");
status.setAttribute("role", "status"), status.setAttribute("aria-live", "polite"), body.append(titleField, categoryField, tagField, status);
const footer = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-reader-action-footer ldp-topic-edit-actions"
), submit = this.#iconButton(
"ldp-topic-edit-save",
"保存帖子信息",
"check"
);
submit.type = "submit";
const cancel = this.#iconButton(
"ldp-topic-edit-cancel",
"取消编辑",
"x"
);
cancel.dataset.topicEditCancel = "", footer.append(submit, cancel), form.append(body, footer), dialog.append(head, form), layer.append(dialog);
{
let busy = !1, searchTimer = null, searchSequence = 0;
const selectedTags = () => Object.freeze([...selectedTagKeys].map((key) => availableTags.get(key)).filter((tag) => !!tag)), clearSearch = () => {
searchSequence += 1, searchTimer !== null && clearTimeout(searchTimer), searchTimer = null;
}, frame = {
layer,
dialog,
form,
body,
status,
cancel,
submit
}, session = this.#host.start({
frame,
previousFocus,
closeSelector: "[data-topic-edit-close]",
cancelSelector: "[data-topic-edit-cancel]",
signal: request.signal,
onSettled: clearSearch
}), closeCategoryMenu = (restoreFocus = !1) => {
categoryMenu.hidden = !0, categoryTrigger.setAttribute("aria-expanded", "false"), restoreFocus && !categoryTrigger.disabled && categoryTrigger.focus({ preventScroll: !0 });
}, setBusy = (value) => {
busy = value, session.setBusy(value), titleInput.disabled = value, categoryTrigger.disabled = value, categorySearch.disabled = value, tagInput.disabled = value, submit.disabled = value, cancel.disabled = value, close.disabled = value, form.setAttribute("aria-busy", String(value)), value && closeCategoryMenu();
}, syncCategory = () => {
const selected = categoryById.get(selectedCategoryId), parent = selected?.parentCategoryId ? categoryById.get(selected.parentCategoryId) : null;
categoryValue.textContent = selected ? `${parent ? `${parent.name} / ` : ""}${selected.name}` : "请选择类别";
for (const option of categoryOptions.querySelectorAll(
".ldp-topic-edit-category-option"
))
option.setAttribute(
"aria-selected",
String(Number(option.dataset.categoryId) === selectedCategoryId)
);
}, renderCategories = () => {
const fragment = this.#document.createDocumentFragment();
for (const category of categories) {
const parent = category.parentCategoryId ? categoryById.get(category.parentCategoryId) : null, option = (0, import_html_element.htmlElement)(
this.#document,
"button",
"ldp-topic-edit-category-option ldp-picker-option"
);
option.type = "button", option.dataset.categoryId = String(category.id), option.dataset.searchText = categorySearchText(
category,
categoryById
), option.setAttribute("role", "option");
const dot = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-topic-edit-category-dot"
);
/^[0-9a-f]{6}$/i.test(category.color) && dot.style.setProperty(
"--ldp-category-color",
`#${category.color}`
);
const copy = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-topic-edit-category-copy ldp-picker-option-copy"
), name = (0, import_html_element.htmlElement)(
this.#document,
"strong",
"",
category.name
), level = (0, import_html_element.htmlElement)(
this.#document,
"small",
"",
parent?.name ?? "一级类别"
);
copy.append(name, level), option.append(dot, copy), fragment.append(option);
}
categoryOptions.replaceChildren(fragment), syncCategory();
}, scheduleTagSearch = () => {
clearSearch();
const sequence = searchSequence;
searchTimer = setTimeout(() => {
searchTimer = null, request.searchTags({
query: tagInput.value.trim(),
categoryId: selectedCategoryId,
selected: selectedTags()
}).then((tags) => {
if (!(!session.active || sequence !== searchSequence)) {
for (const tag of tags) {
const key = tagKey(tag.name);
key && availableTags.set(key, tag);
}
tagOptions.replaceChildren(...tags.map((tag) => {
const option = (0, import_html_element.htmlElement)(this.#document, "option");
return option.value = tag.name, option;
})), status.textContent = "";
}
}).catch((cause) => {
!session.active || sequence !== searchSequence || (status.textContent = cause instanceof Error ? cause.message : "标签搜索失败,请重试");
});
}, 300);
}, renderTags = () => {
tagValues.replaceChildren(...selectedTags().map((tag) => {
const chip = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-topic-edit-label-chip"
), copy = (0, import_html_element.htmlElement)(this.#document, "span", "", tag.name), remove = this.#iconButton(
"ldp-topic-edit-label-remove",
`移除 label ${tag.name}`,
"x"
);
return remove.dataset.labelKey = tagKey(tag.name), chip.append(copy, remove), chip;
}));
}, commitTagInput = (showError = !0) => {
const raw = tagInput.value.replace(/[,,]\s*$/, "").trim();
if (!raw)
return tagInput.value = "", !0;
const key = tagKey(raw);
return availableTags.get(key) ? (selectedTagKeys.add(key), tagInput.value = "", status.textContent = "", renderTags(), scheduleTagSearch(), !0) : (showError && (status.textContent = `没有找到 label“${raw}”`), !1);
};
return layer.addEventListener("click", (event) => {
const target = event.target;
target && (target.closest(".ldp-topic-edit-category") || closeCategoryMenu());
}), layer.addEventListener("keydown", (event) => {
const keyboard = event;
if (keyboard.key === "Escape" && !categoryMenu.hidden && !busy) {
keyboard.preventDefault(), keyboard.stopImmediatePropagation(), closeCategoryMenu(!0);
return;
}
keyboard.key === "Escape" && !busy && keyboard.stopPropagation();
}), categoryTrigger.addEventListener("click", () => {
if (!busy) {
if (!categoryMenu.hidden) {
closeCategoryMenu(!0);
return;
}
categoryMenu.hidden = !1, categoryTrigger.setAttribute("aria-expanded", "true"), categorySearch.value = "";
for (const option of categoryOptions.children)
option.hidden = !1;
categorySearch.focus({ preventScroll: !0 });
}
}), categoryTrigger.addEventListener("keydown", (event) => {
event.key === "ArrowDown" && (event.preventDefault(), categoryTrigger.click());
}), categorySearch.addEventListener("input", () => {
const query = categorySearch.value.trim().toLocaleLowerCase();
for (const option of categoryOptions.querySelectorAll(
".ldp-topic-edit-category-option"
))
option.hidden = !!(query && !String(option.dataset.searchText).includes(query));
}), categoryOptions.addEventListener("click", (event) => {
const option = event.target?.closest(
".ldp-topic-edit-category-option"
);
!option || busy || (selectedCategoryId = Number(option.dataset.categoryId), syncCategory(), closeCategoryMenu(!0), scheduleTagSearch());
}), tagInput.addEventListener("input", scheduleTagSearch), tagInput.addEventListener("change", () => commitTagInput(!1)), tagInput.addEventListener("keydown", (event) => {
const key = event.key;
["Enter", ",", ","].includes(key) && (event.preventDefault(), commitTagInput());
}), tagValues.addEventListener("click", (event) => {
const remove = event.target?.closest(
".ldp-topic-edit-label-remove"
);
!remove || busy || (selectedTagKeys.delete(String(remove.dataset.labelKey)), renderTags(), scheduleTagSearch(), tagInput.focus({ preventScroll: !0 }));
}), form.addEventListener("submit", (event) => {
if (event.preventDefault(), busy || !commitTagInput()) return;
const nextTitle = titleInput.value.trim(), category = categoryById.get(selectedCategoryId);
if (!nextTitle) {
status.textContent = "标题不能为空", titleInput.focus({ preventScroll: !0 });
return;
}
if (!category) {
status.textContent = "请选择类别", categoryTrigger.focus({ preventScroll: !0 });
return;
}
setBusy(!0), status.textContent = "正在保存…", new Promise((resolve) => {
resolve(request.submit(Object.freeze({
title: nextTitle,
category,
tags: selectedTags()
})));
}).then(() => {
session.active && session.settle(!0);
}).catch((cause) => {
session.active && (status.textContent = cause instanceof Error ? cause.message : "保存失败,请重试", setBusy(!1));
});
}), renderCategories(), renderTags(), session.mount(() => {
titleInput.focus({ preventScroll: !0 }), titleInput.select();
}), scheduleTagSearch(), session.result;
}
}
destroy() {
this.#host.destroy();
}
#icon(name) {
return (0, import_reader_action_form_support.renderReaderActionIcon)(
this.#document,
name,
this.#host.renderIcon
);
}
#iconButton(className, label, icon) {
const button = (0, import_html_element.htmlElement)(this.#document, "button", className);
return button.type = "button", button.setAttribute("aria-label", label), button.append(this.#icon(icon)), button;
}
}
}, "964d8ccccee41bb4adf4de61bc618d25ece0686372c1447f235cf320c47e7195");
/* Source: lite/src/shell/reader-workspace-coordinator.ts */
runtime.register("src/shell/reader-workspace-coordinator.js", function(module, exports, require) {
var reader_workspace_coordinator_exports = {};
__export(reader_workspace_coordinator_exports, {
ReaderHeaderAlignmentController: () => ReaderHeaderAlignmentController,
ReaderHostTakeoverController: () => ReaderHostTakeoverController,
ReaderWorkspaceCoordinator: () => ReaderWorkspaceCoordinator,
createReaderShellWorkspaceStage: () => createReaderShellWorkspaceStage
});
module.exports = __toCommonJS(reader_workspace_coordinator_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_embedded_host_appearance = require("./embedded-host-appearance.js"), import_embedded_host_header_controller = require("./embedded-host-header-controller.js"), import_embedded_host_root_controller = require("./embedded-host-root-controller.js"), import_embedded_host_scrollbar = require("./embedded-host-scrollbar.js"), import_embedded_host_top_shortcut = require("./embedded-host-top-shortcut.js"), import_main_outlet_mutation_hub = require("./main-outlet-mutation-hub.js"), import_reader_embed_resize_controller = require("./reader-embed-resize-controller.js"), import_reader_browser_immersive_controller = require("./reader-browser-immersive-controller.js"), import_reader_shell = require("./reader-shell.js"), import_reader_workspace = require("./reader-workspace.js");
const READER_VISIBLE_STATES = /* @__PURE__ */ new Set([
"opening",
"switching",
"running",
"failed"
]);
class ReaderHostTakeoverController {
scope;
#shell;
#workspace;
#pageRoot;
#routeKind;
#destroyed = !1;
constructor(options) {
this.#shell = options.shell, this.#workspace = options.workspace, this.#pageRoot = options.pageRoot, this.#routeKind = options.routeKind, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#shell.changes.subscribe(() => this.#sync(), this.scope), this.#workspace.changes.subscribe(() => this.#sync(), this.scope), this.scope.add(() => this.#clear()), this.#sync();
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#sync() {
if (this.#destroyed) return;
const visible = READER_VISIBLE_STATES.has(this.#shell.state), presentation = this.#workspace.snapshot.presentation;
this.#pageRoot.classList.toggle("ldp-reader-open", visible), this.#pageRoot.classList.toggle(
"ldp-scroll-lock",
visible && presentation.fullPage
), this.#pageRoot.classList.toggle(
"ldp-route-takeover",
visible && this.#routeKind === "direct-topic"
);
}
#clear() {
this.#pageRoot.classList.remove(
"ldp-reader-open",
"ldp-scroll-lock",
"ldp-route-takeover"
);
}
}
class ReaderHeaderAlignmentController {
scope;
#shell;
#workspace;
#elements;
#requestFrame;
#cancelFrame;
#resizeObserver;
#mutationObserver;
#observedSizes = /* @__PURE__ */ new WeakMap();
#content = null;
#frame = 0;
#titleFrame = 0;
#titleDirty = !0;
#lastAlignment = null;
#destroyed = !1;
constructor(options) {
this.#shell = options.shell, this.#workspace = options.workspace, this.#elements = options.elements, this.#requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)), this.#cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id)), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#resizeObserver = options.createResizeObserver?.(
(entries) => this.#onResize(entries)
) ?? null, this.#resizeObserver?.observe(this.#elements.modal), this.#resizeObserver?.observe(this.#elements.header), this.#mutationObserver = options.createMutationObserver?.((records) => {
records.some(
(record) => record.target === this.#shell.view.root || this.#elements.header.contains(record.target)
) && this.#schedule(!0);
}) ?? null, this.#mutationObserver?.observe(this.#shell.view.root, {
attributes: !0,
attributeFilter: ["style"]
}), this.#mutationObserver?.observe(this.#elements.header, {
attributes: !0,
attributeFilter: ["hidden"],
childList: !0,
characterData: !0,
subtree: !0
}), this.#shell.changes.subscribe(() => this.#schedule(!0), this.scope), this.#workspace.changes.subscribe(() => this.#schedule(!0), this.scope), this.scope.add(() => {
this.#resizeObserver?.disconnect(), this.#mutationObserver?.disconnect(), this.#frame && this.#cancelFrame(this.#frame), this.#titleFrame && this.#cancelFrame(this.#titleFrame), this.#clear();
}), this.#schedule(!0);
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#schedule(titleDirty = !0) {
titleDirty && (this.#titleDirty = !0), !(this.#destroyed || this.#frame) && (this.#frame = this.#requestFrame(() => {
this.#frame = 0, this.#sync();
}));
}
#onResize(entries) {
if (!entries.length) {
this.#schedule(!1);
return;
}
let sizeChanged = !1;
for (const entry of entries) {
const borderSize = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize, width = borderSize?.inlineSize ?? entry.contentRect.width, height = borderSize?.blockSize ?? entry.contentRect.height;
if (!Number.isFinite(width) || width < 0 || !Number.isFinite(height) || height < 0) continue;
const size = entry.target === this.#elements.modal || entry.target === this.#elements.header ? `${Math.round(width * 2)}:${Math.round(height * 2)}` : `${Math.round(width * 2)}`, previous = this.#observedSizes.get(entry.target);
this.#observedSizes.set(entry.target, size), previous !== size && (entry.target === this.#elements.header && (this.#titleDirty = !0), sizeChanged = !0);
}
sizeChanged && this.#schedule(!1);
}
#sync() {
if (this.#destroyed) return;
const headerBlockSize = Math.max(
0,
Math.round(
this.#elements.header.getBoundingClientRect().height * 2
) / 2
);
if (headerBlockSize > 0) {
const value = `${headerBlockSize}px`;
this.#elements.modal.style.getPropertyValue(
"--ldp-reader-header-block-size"
) !== value && this.#elements.modal.style.setProperty(
"--ldp-reader-header-block-size",
value
);
}
this.#elements.modal.classList.toggle(
"ldp-reader-surface-short",
this.#elements.modal.clientHeight <= 560
);
const content = this.#elements.overlay.querySelector(".ldp-topic-runtime") ?? Array.from(
this.#elements.overlay.querySelectorAll(".ldp-comments")
).find((candidate) => candidate.getBoundingClientRect().width > 0) ?? null;
if (content !== this.#content && (this.#resizeObserver?.disconnect(), this.#resizeObserver?.observe(this.#elements.modal), this.#resizeObserver?.observe(this.#elements.header), content && this.#resizeObserver?.observe(content), this.#content = content, this.#lastAlignment = null, this.#titleDirty = !0), !this.#workspace.snapshot.presentation.fullPage || !content) {
this.#clearGeometry(), this.#scheduleTitleActions();
return;
}
const modalRect = this.#elements.modal.getBoundingClientRect(), contentRect = content.getBoundingClientRect();
if (!(modalRect.width > 0) || !(contentRect.width > 0)) return;
const left = Math.max(0, Math.round(contentRect.left - modalRect.left)), right = Math.max(0, Math.round(modalRect.right - contentRect.right)), alignment = `${left}:${right}`;
if (alignment !== this.#lastAlignment) {
this.#lastAlignment = alignment, this.#titleDirty = !0;
const header = this.#elements.header;
header.style.setProperty("--ldp-header-logo-inset", `${left}px`), header.style.paddingLeft = `${left}px`, header.style.paddingRight = `${right}px`, this.#elements.titleActions.style.right = `${right}px`, this.#elements.headButtons.classList.add("is-content-aligned");
}
this.#scheduleTitleActions();
}
#scheduleTitleActions() {
this.#destroyed || !this.#titleDirty || this.#titleFrame || (this.#titleFrame = this.#requestFrame(() => {
this.#titleFrame = 0, !(this.#destroyed || !this.#titleDirty) && (this.#titleDirty = !1, this.#syncTitleActions());
}));
}
#syncTitleActions() {
const { header, titleActions } = this.#elements, titleJump = header.querySelector(".ldp-title-jump");
if (!titleJump) return;
const titleRange = titleActions.ownerDocument.createRange();
titleRange.selectNodeContents(titleJump);
const titleRects = Array.from(titleRange.getClientRects()).filter(
(rect) => rect.width > 0 && rect.height > 0
), firstTop = titleRects[0]?.top, singleLine = firstTop !== void 0 && titleRects.every((rect) => Math.abs(rect.top - firstTop) < 1), singleLineChanged = header.classList.contains("ldp-title-single-line") !== singleLine, view = titleActions.ownerDocument.defaultView, actionIcon = Array.from(titleActions.children).find(
(control) => control instanceof HTMLElement && !control.hidden && view?.getComputedStyle(control).display !== "none"
)?.querySelector(".ldp-icon");
if (!actionIcon) {
header.classList.toggle("ldp-title-single-line", singleLine), titleActions.style.setProperty("--ldp-title-actions-align-y", "0px"), singleLineChanged && (this.#titleDirty = !0, this.#scheduleTitleActions());
return;
}
const titleTextRect = titleRects[0], graphicTop = Array.from(actionIcon.querySelectorAll(
":is(path,circle,ellipse,line,polyline,polygon,rect,use)"
)).reduce((top, graphic) => {
const rect = graphic.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 ? Math.min(top, rect.top) : top;
}, 1 / 0), titleTop = titleTextRect?.top ?? titleJump.getBoundingClientRect().top, actionTop = Number.isFinite(graphicTop) ? graphicTop : actionIcon.getBoundingClientRect().top, shift = (Number.parseFloat(
titleActions.style.getPropertyValue("--ldp-title-actions-align-y")
) || 0) + titleTop - actionTop;
header.classList.toggle("ldp-title-single-line", singleLine), titleActions.style.setProperty(
"--ldp-title-actions-align-y",
`${Math.round(shift * 2) / 2}px`
), singleLineChanged && (this.#titleDirty = !0, this.#scheduleTitleActions());
}
#clearGeometry() {
if (this.#lastAlignment === null) return;
this.#lastAlignment = null, this.#titleDirty = !0;
const { header, titleActions, headButtons } = this.#elements;
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");
}
#clear() {
this.#clearGeometry(), this.#elements.modal.classList.remove("ldp-reader-surface-short"), this.#elements.modal.style.removeProperty(
"--ldp-reader-header-block-size"
), this.#elements.titleActions.style.removeProperty(
"--ldp-title-actions-align-y"
);
}
}
class ReaderWorkspaceCoordinator {
scope;
workspace;
window;
mutations;
#onPersistMode;
#onPersistWindow;
#destroyed = !1;
constructor(options) {
const viewport = options.readViewport(), requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)), cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id));
this.#onPersistMode = options.onPersistMode, this.#onPersistWindow = options.onPersistWindow, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
try {
this.workspace = new import_reader_workspace.ReaderWorkspaceModel({
routeKind: options.routeKind,
requestedMode: options.requestedMode,
embedWidth: options.embedWidth,
viewportWidth: viewport.width,
...options.active === void 0 ? {} : { active: options.active }
}), this.window = new import_reader_workspace.ReaderWindowGeometryModel({
preferences: options.windowPreferences,
viewportWidth: viewport.width,
viewportHeight: viewport.height,
mode: this.workspace.snapshot.presentation.mode
}), this.workspace.changes.subscribe((snapshot) => {
this.window.setMode(snapshot.presentation.mode);
}, this.scope), new import_reader_workspace.ReaderWorkspaceDomAdapter({
model: this.workspace,
pageRoot: options.elements.pageRoot,
overlay: options.elements.overlay,
parentScope: this.scope
}), options.elements.windowCapsule && options.elements.windowPlacementControl && options.elements.windowPlacementStrip && options.elements.windowPlacementOptions && new import_reader_workspace.ReaderWorkspacePlacementController({
model: this.workspace,
routeKind: options.routeKind,
capsule: options.elements.windowCapsule,
control: options.elements.windowPlacementControl,
strip: options.elements.windowPlacementStrip,
options: options.elements.windowPlacementOptions,
onSelect: (mode) => this.setMode(mode),
parentScope: this.scope
}), new import_reader_workspace.ReaderWindowDomAdapter({
model: this.window,
overlay: options.elements.overlay,
modal: options.elements.modal,
header: options.elements.header,
...options.elements.windowLockButton ? { lockButton: options.elements.windowLockButton } : {},
...options.elements.windowPinButton ? { pinButton: options.elements.windowPinButton } : {},
parentScope: this.scope
}), new import_reader_workspace.ReaderWindowPointerController({
model: this.window,
overlay: options.elements.overlay,
modal: options.elements.modal,
header: options.elements.header,
...options.elements.windowLockButton ? { lockButton: options.elements.windowLockButton } : {},
...options.elements.windowPinButton ? { pinButton: options.elements.windowPinButton } : {},
viewportTarget: options.viewportTarget,
readViewport: options.readViewport,
...options.onPersistWindow ? { onPersist: options.onPersistWindow } : {},
requestFrame,
cancelFrame,
parentScope: this.scope
}), new import_reader_embed_resize_controller.ReaderEmbedResizeController({
model: this.workspace,
pageRoot: options.elements.pageRoot,
overlay: options.elements.overlay,
handle: options.elements.embedResizeHandle,
viewportTarget: options.viewportTarget,
readViewportWidth: () => options.readViewport().width,
...options.onPersistEmbedWidth ? { onPersist: options.onPersistEmbedWidth } : {},
requestFrame,
cancelFrame,
parentScope: this.scope
}), this.mutations = new import_main_outlet_mutation_hub.MainOutletMutationHub({
document: options.document,
...options.createMutationObserver ? { createObserver: options.createMutationObserver } : {}
}), this.scope.add(() => this.mutations.destroy()), new import_embedded_host_header_controller.EmbeddedHostHeaderController({
model: this.workspace,
routeKind: options.routeKind,
document: options.document,
...options.host ? { host: options.host } : {},
mutations: this.mutations,
requestFrame,
cancelFrame,
parentScope: this.scope
}), new import_embedded_host_root_controller.EmbeddedHostRootController({
model: this.workspace,
routeKind: options.routeKind,
document: options.document,
overlay: options.elements.overlay,
mutations: this.mutations,
enhancements: options.enhancements,
...options.topicFilterChanges ? { topicFilterChanges: options.topicFilterChanges } : {},
requestFrame,
cancelFrame,
parentScope: this.scope
}), new import_embedded_host_scrollbar.EmbeddedHostScrollbarController({
workspace: this.workspace,
track: options.elements.hostScrollbar,
thumb: options.elements.hostScrollbarThumb,
scrollTarget: options.scrollTarget,
scroll: options.hostScroll,
resizeTargets: Object.freeze([
options.elements.pageRoot,
...options.document.body ? [options.document.body] : []
]),
...options.createResizeObserver ? { createResizeObserver: options.createResizeObserver } : {},
...options.readHostScrollbarTrack ? { readTrack: options.readHostScrollbarTrack } : {},
requestFrame,
cancelFrame,
parentScope: this.scope
}), new import_embedded_host_top_shortcut.EmbeddedHostTopShortcutController({
workspace: this.workspace,
button: options.elements.hostTopButton,
pointerTarget: options.pointerTarget,
scrollTarget: options.scrollTarget,
readScrollTop: () => options.hostScroll.readScrollTop?.() ?? options.hostScroll.read().scrollTop,
readViewportHeight: () => options.readViewport().height,
scrollToTop: () => options.hostScroll.scrollTo(0),
requestFrame,
cancelFrame,
parentScope: this.scope
}), new import_embedded_host_appearance.EmbeddedHostAppearanceController({
workspace: this.workspace,
pageRoot: options.elements.pageRoot,
overlay: options.elements.overlay,
readAppearance: options.readAppearance,
...options.appearanceChanges ? { appearanceChanges: options.appearanceChanges } : {},
...options.measureHostRowHeight ? { measureRowHeight: options.measureHostRowHeight } : {},
parentScope: this.scope
});
} catch (error) {
throw this.scope.destroy(), error;
}
}
setMode(mode) {
const accepted = this.workspace.setRequestedMode(mode);
return accepted && this.#onPersistMode?.(mode), accepted;
}
setEmbedWidth(width) {
return this.workspace.setEmbedWidth(width);
}
setWindowGeometry(width, height, left, top) {
const previous = this.window.snapshot;
this.window.setGeometry(width, height, left, top) !== previous && this.#persistWindow();
}
setWindowLocked(locked) {
const previous = this.window.snapshot;
this.window.setLocked(locked) !== previous && this.#persistWindow();
}
setWindowPinned(pinned) {
const previous = this.window.snapshot;
this.window.setPinned(pinned) !== previous && this.#persistWindow();
}
resetWindow() {
const previous = this.window.snapshot;
this.window.reset() !== previous && this.#persistWindow();
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#persistWindow() {
this.#onPersistWindow?.(this.window.preferencePatch());
}
}
function createReaderShellWorkspaceStage(options) {
return (0, import_reader_shell.createReaderShellStage)({
name: options.name ?? "reader-shell-workspace",
compatibilityKey: options.compatibilityKey,
createView: options.createView,
onReady(shell, context) {
const workspaceOptions = options.createWorkspaceOptions(shell, context), workspace = new ReaderWorkspaceCoordinator({
...workspaceOptions,
active: READER_VISIBLE_STATES.has(shell.state),
parentScope: shell.scope
}), immersive = workspaceOptions.elements.browserImmersiveToggle ? new import_reader_browser_immersive_controller.ReaderBrowserImmersiveController({
document: workspaceOptions.document,
button: workspaceOptions.elements.browserImmersiveToggle,
enterReaderFullpage: () => {
workspace.setMode("fullpage");
},
parentScope: shell.scope
}) : null;
shell.changes.subscribe((state) => {
const visible = READER_VISIBLE_STATES.has(state);
workspace.workspace.setActive(visible), visible || immersive?.exit();
}, workspace.scope);
const takeover = new ReaderHostTakeoverController({
shell,
workspace: workspace.workspace,
pageRoot: workspaceOptions.elements.pageRoot,
routeKind: workspaceOptions.routeKind,
parentScope: shell.scope
}), headerAlignment = new ReaderHeaderAlignmentController({
shell,
workspace: workspace.workspace,
elements: workspaceOptions.elements,
...workspaceOptions.createMutationObserver ? {
createMutationObserver: workspaceOptions.createMutationObserver
} : {},
...workspaceOptions.createResizeObserver ? {
createResizeObserver: workspaceOptions.createResizeObserver
} : {},
...workspaceOptions.requestFrame ? { requestFrame: workspaceOptions.requestFrame } : {},
...workspaceOptions.cancelFrame ? { cancelFrame: workspaceOptions.cancelFrame } : {},
parentScope: shell.scope
});
let readyCleanup;
try {
readyCleanup = options.onReady?.(shell, workspace, context) || void 0;
} catch (error) {
throw headerAlignment.destroy(), takeover.destroy(), workspace.destroy(), error;
}
return () => {
try {
readyCleanup?.();
} finally {
try {
headerAlignment.destroy();
} finally {
try {
takeover.destroy();
} finally {
workspace.destroy();
}
}
}
};
}
});
}
}, "48db56e5e4472a75f62a2b030820b3a92a2b4eb8a5e2b68cd9aa552ffdb1579f");
/* Source: lite/src/shell/reader-workspace.ts */
runtime.register("src/shell/reader-workspace.js", function(module, exports, require) {
var reader_workspace_exports = {};
__export(reader_workspace_exports, {
READER_COMPACT_MAX_WIDTH: () => READER_COMPACT_MAX_WIDTH,
READER_EMBED_MIN_WIDTH: () => READER_EMBED_MIN_WIDTH,
READER_HOST_MIN_WIDTH: () => READER_HOST_MIN_WIDTH,
READER_WINDOW_MARGIN: () => READER_WINDOW_MARGIN,
READER_WINDOW_MIN_HEIGHT: () => READER_WINDOW_MIN_HEIGHT,
READER_WINDOW_MIN_WIDTH: () => READER_WINDOW_MIN_WIDTH,
ReaderWindowDomAdapter: () => ReaderWindowDomAdapter,
ReaderWindowGeometryModel: () => ReaderWindowGeometryModel,
ReaderWindowPointerController: () => ReaderWindowPointerController,
ReaderWorkspaceDomAdapter: () => ReaderWorkspaceDomAdapter,
ReaderWorkspaceModel: () => ReaderWorkspaceModel,
ReaderWorkspacePlacementController: () => ReaderWorkspacePlacementController,
readerWorkspacePositionMode: () => readerWorkspacePositionMode
});
module.exports = __toCommonJS(reader_workspace_exports);
var import_event_target = require("../dom/event-target.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js");
function readerWorkspacePositionMode(mode) {
return mode === "fullpage" ? "fullpage" : mode === "embed-left" || mode === "embed-right" ? "embedded" : "floating";
}
const READER_EMBED_MIN_WIDTH = 360, READER_HOST_MIN_WIDTH = 680, READER_WINDOW_MARGIN = 8, READER_WINDOW_MIN_WIDTH = 360, READER_WINDOW_MIN_HEIGHT = 320, READER_COMPACT_MAX_WIDTH = 700, WINDOW_DEFAULT_WIDTH = 1080, WINDOW_DEFAULT_VIEWPORT_WIDTH = 0.94, WINDOW_DEFAULT_VIEWPORT_HEIGHT = 0.86, WINDOW_HANDLE_DOCK_THRESHOLD_PX = 2, DEFAULT_WINDOW_GEOMETRY_POLICY = Object.freeze({
margin: READER_WINDOW_MARGIN,
minWidth: READER_WINDOW_MIN_WIDTH,
minHeight: READER_WINDOW_MIN_HEIGHT,
compactWidth: READER_COMPACT_MAX_WIDTH,
defaultWidth: WINDOW_DEFAULT_WIDTH,
defaultHeight: null,
defaultViewportWidth: WINDOW_DEFAULT_VIEWPORT_WIDTH,
defaultViewportHeight: WINDOW_DEFAULT_VIEWPORT_HEIGHT
});
function projectReaderWindowPlacement(overlay, geometry) {
if (overlay.classList.toggle(
"ldp-window-handle-docked",
!!(geometry && geometry.top <= WINDOW_HANDLE_DOCK_THRESHOLD_PX)
), !geometry) {
overlay.style.removeProperty("--ldp-reader-window-center-x"), overlay.style.removeProperty("--ldp-reader-window-top");
return;
}
overlay.style.setProperty(
"--ldp-reader-window-center-x",
`${geometry.left + geometry.width / 2}px`
), overlay.style.setProperty("--ldp-reader-window-top", `${geometry.top}px`);
}
const PRESENTATIONS = Object.freeze({
floating: Object.freeze({
mode: "floating",
floating: !0,
fullPage: !1,
embedded: !1,
side: ""
}),
fullpage: Object.freeze({
mode: "fullpage",
floating: !1,
fullPage: !0,
embedded: !1,
side: ""
}),
"embed-left": Object.freeze({
mode: "embed-left",
floating: !1,
fullPage: !1,
embedded: !0,
side: "left"
}),
"embed-right": Object.freeze({
mode: "embed-right",
floating: !1,
fullPage: !1,
embedded: !0,
side: "right"
})
}), LIST_MODES = /* @__PURE__ */ new Set([
"floating",
"fullpage",
"embed-left",
"embed-right"
]), DIRECT_TOPIC_MODES = /* @__PURE__ */ new Set(["floating", "fullpage"]);
function finiteViewport(value, name) {
if (!Number.isFinite(value) || value < 1)
throw new RangeError(`${name} 必须是正有限数`);
return value;
}
function finiteNumber(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function windowGeometryPolicy(value) {
const source = {
...DEFAULT_WINDOW_GEOMETRY_POLICY,
...value
}, positive = (candidate, name) => {
const numeric = Number(candidate);
if (!Number.isFinite(numeric) || numeric <= 0)
throw new RangeError(`${name} 必须是正有限数`);
return numeric;
}, compactWidth = Number(source.compactWidth);
if (!Number.isFinite(compactWidth) || compactWidth < 0)
throw new RangeError("compactWidth 必须是非负有限数");
const defaultHeight = source.defaultHeight === null ? null : positive(source.defaultHeight, "defaultHeight");
return Object.freeze({
margin: positive(source.margin, "margin"),
minWidth: positive(source.minWidth, "minWidth"),
minHeight: positive(source.minHeight, "minHeight"),
compactWidth,
defaultWidth: positive(source.defaultWidth, "defaultWidth"),
defaultHeight,
defaultViewportWidth: positive(
source.defaultViewportWidth,
"defaultViewportWidth"
),
defaultViewportHeight: positive(
source.defaultViewportHeight,
"defaultViewportHeight"
)
});
}
function workspacePresentation(mode) {
return PRESENTATIONS[mode];
}
function workspaceModeAllowed(mode, routeKind) {
return (routeKind === "direct-topic" ? DIRECT_TOPIC_MODES : LIST_MODES).has(mode);
}
function dispatchCompatibilityEvent(target, name) {
const event = target.ownerDocument.createEvent("Event");
event.initEvent(name, !1, !1), target.dispatchEvent(event);
}
class ReaderWorkspaceModel {
changes = new import_signal.Signal();
#routeKind;
#requestedMode;
#viewportWidth;
#requestedEmbedWidth;
#active;
#snapshot;
constructor(options) {
this.#routeKind = options.routeKind, this.#viewportWidth = finiteViewport(options.viewportWidth, "viewportWidth"), this.#active = options.active !== !1, this.#requestedMode = workspaceModeAllowed(options.requestedMode, this.#routeKind) ? options.requestedMode : this.#fallbackMode(), this.#requestedEmbedWidth = Math.max(
READER_EMBED_MIN_WIDTH,
Math.round(finiteNumber(options.embedWidth, READER_EMBED_MIN_WIDTH))
), this.#snapshot = this.#derive();
}
get snapshot() {
return this.#snapshot;
}
setRequestedMode(mode) {
return workspaceModeAllowed(mode, this.#routeKind) ? (this.#requestedMode = mode, this.#commit(), !0) : !1;
}
/**
* Shell 关闭时只撤销 presentation,requestedMode 仍作为下次打开的偏好。
*/
setActive(active) {
return this.#active = active, this.#commit(), this.#snapshot;
}
setEmbedWidth(width) {
return this.#requestedEmbedWidth = this.#clampEmbedWidth(width), this.#commit(), this.#snapshot.embedWidth;
}
resizeViewport(width) {
return this.#viewportWidth = finiteViewport(width, "viewportWidth"), this.#commit(), this.#snapshot;
}
#fallbackMode() {
return this.#routeKind === "direct-topic" ? "fullpage" : "floating";
}
#canEmbed() {
return this.#viewportWidth >= READER_EMBED_MIN_WIDTH + READER_HOST_MIN_WIDTH;
}
#maximumEmbedWidth() {
return Math.max(
READER_EMBED_MIN_WIDTH,
Math.floor(this.#viewportWidth - READER_HOST_MIN_WIDTH)
);
}
#clampEmbedWidth(value) {
return Math.min(
this.#maximumEmbedWidth(),
Math.max(
READER_EMBED_MIN_WIDTH,
Math.round(finiteNumber(value, READER_EMBED_MIN_WIDTH))
)
);
}
#derive() {
const requested = workspaceModeAllowed(this.#requestedMode, this.#routeKind) ? this.#requestedMode : this.#fallbackMode(), effective = this.#active ? workspacePresentation(requested).embedded && !this.#canEmbed() ? "floating" : requested : this.#fallbackMode();
return Object.freeze({
requestedMode: requested,
presentation: workspacePresentation(effective),
viewportWidth: this.#viewportWidth,
canEmbed: this.#canEmbed(),
embedWidth: this.#clampEmbedWidth(this.#requestedEmbedWidth)
});
}
#commit() {
const next = this.#derive();
next.requestedMode === this.#snapshot.requestedMode && next.presentation.mode === this.#snapshot.presentation.mode && next.viewportWidth === this.#snapshot.viewportWidth && next.embedWidth === this.#snapshot.embedWidth || (this.#snapshot = next, this.changes.emit(next));
}
}
class ReaderWorkspaceDomAdapter {
scope;
#model;
#pageRoot;
#overlay;
#dispatchEvents;
#destroyed = !1;
constructor(options) {
this.#model = options.model, this.#pageRoot = options.pageRoot, this.#overlay = options.overlay, this.#dispatchEvents = options.dispatchEvents !== !1, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#model.changes.subscribe((snapshot) => this.#apply(snapshot, !0), this.scope), this.scope.add(() => this.#clear()), this.#apply(this.#model.snapshot, !1);
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#apply(snapshot, changed) {
if (this.#destroyed) return;
const presentation = snapshot.presentation;
if (this.#overlay.dataset.readerWorkspaceMode = presentation.mode, this.#overlay.classList.toggle("ldp-fullpage", presentation.fullPage), this.#overlay.classList.toggle("ldp-reader-embedded", presentation.embedded), this.#overlay.classList.toggle(
"ldp-reader-embedded-left",
presentation.side === "left"
), this.#overlay.classList.toggle(
"ldp-reader-embedded-right",
presentation.side === "right"
), this.#pageRoot.classList.toggle("ldp-reader-workspace", presentation.embedded), this.#pageRoot.classList.toggle(
"ldp-reader-embedded-left",
presentation.side === "left"
), this.#pageRoot.classList.toggle(
"ldp-reader-embedded-right",
presentation.side === "right"
), presentation.embedded) {
const width = `${snapshot.embedWidth}px`;
this.#overlay.dataset.readerWorkspaceWidth = String(snapshot.embedWidth), this.#overlay.style.setProperty("--ldp-reader-workspace-width", width), this.#pageRoot.style.setProperty("--ldp-reader-workspace-width", width);
} else {
delete this.#overlay.dataset.readerWorkspaceWidth;
for (const target of [this.#overlay, this.#pageRoot])
target.style.removeProperty("--ldp-reader-workspace-width");
}
changed && this.#dispatchEvents && dispatchCompatibilityEvent(this.#overlay, "ldp-reader-workspace-change");
}
#clear() {
for (const className of [
"ldp-fullpage",
"ldp-reader-embedded",
"ldp-reader-embedded-left",
"ldp-reader-embedded-right"
])
this.#overlay.classList.remove(className);
this.#pageRoot.classList.remove(
"ldp-reader-workspace",
"ldp-reader-embedded-left",
"ldp-reader-embedded-right"
), delete this.#overlay.dataset.readerWorkspaceMode, delete this.#overlay.dataset.readerWorkspaceWidth;
for (const target of [this.#overlay, this.#pageRoot])
target.style.removeProperty("--ldp-reader-workspace-width");
}
}
class ReaderWorkspacePlacementController {
scope;
#model;
#capsule;
#control;
#strip;
#options;
#allowedModes;
#onSelect;
#destroyed = !1;
constructor(options) {
this.#model = options.model, this.#capsule = options.capsule, this.#control = options.control, this.#strip = options.strip, this.#options = options.options, this.#allowedModes = options.routeKind === "direct-topic" ? DIRECT_TOPIC_MODES : LIST_MODES, this.#onSelect = options.onSelect, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
for (const option of this.#options)
this.scope.listen(option, "click", (event) => {
event.preventDefault(), event.stopPropagation();
const mode = option.dataset.readerPlacement;
!mode || !this.#allowedModes.has(mode) || this.#onSelect(mode);
});
this.#model.changes.subscribe(() => this.#sync(), this.scope), this.scope.add(() => this.#clear()), this.#sync();
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#sync() {
if (this.#destroyed) return;
const available = this.#allowedModes.size > 1;
this.#control.hidden = !available, this.#strip.hidden = !available, this.#capsule.classList.toggle("ldp-reader-placement-available", available);
const activeMode = this.#model.snapshot.presentation.mode;
for (const option of this.#options) {
const mode = option.dataset.readerPlacement, allowed = !!(mode && this.#allowedModes.has(mode)), active = allowed && mode === activeMode;
option.hidden = !allowed, option.disabled = !allowed, option.classList.toggle("active", active), option.setAttribute("aria-pressed", String(active));
}
}
#clear() {
this.#capsule.classList.remove("ldp-reader-placement-available"), this.#control.hidden = !0, this.#strip.hidden = !0;
for (const option of this.#options)
option.classList.remove("active"), option.removeAttribute("aria-pressed");
}
}
class ReaderWindowGeometryModel {
changes = new import_signal.Signal();
#policy;
#viewportWidth;
#viewportHeight;
#presentation;
#geometry;
#geometryPersisted;
#locked;
#pinned;
#snapshot;
constructor(options) {
this.#policy = windowGeometryPolicy(options.policy), this.#viewportWidth = finiteViewport(options.viewportWidth, "viewportWidth"), this.#viewportHeight = finiteViewport(options.viewportHeight, "viewportHeight"), this.#presentation = workspacePresentation(options.mode), this.#locked = options.preferences.readerWindowLocked, this.#pinned = options.preferences.readerWindowPinned, this.#geometryPersisted = !!(options.preferences.readerWindowWidth || options.preferences.readerWindowHeight || options.preferences.readerWindowX || options.preferences.readerWindowY), this.#geometry = this.#preferredGeometry(options.preferences), this.#snapshot = this.#derive();
}
get snapshot() {
return this.#snapshot;
}
setMode(mode) {
return this.#presentation = workspacePresentation(mode), this.#commit(), this.#snapshot;
}
resizeViewport(width, height) {
return this.#viewportWidth = finiteViewport(width, "viewportWidth"), this.#viewportHeight = finiteViewport(height, "viewportHeight"), this.#geometryPersisted || (this.#geometry = this.#centeredGeometry()), this.#commit(), this.#snapshot;
}
/** 内容 owner 可按可见组件的固有宽度更新缩放下限。 */
setMinimumWidth(width) {
const minWidth = finiteViewport(width, "minWidth");
return minWidth === this.#policy.minWidth ? this.#snapshot : (this.#policy = Object.freeze({ ...this.#policy, minWidth }), this.#geometry = this.#clampGeometry(this.#geometry), this.#commit(), this.#snapshot);
}
setGeometry(width, height, left, top) {
if (this.#viewportWidth <= this.#policy.compactWidth) return this.#snapshot;
const current = this.#geometry;
return this.#geometry = this.#clampGeometry({
width: finiteNumber(width, current.width),
height: finiteNumber(height, current.height),
left: finiteNumber(left, current.left),
top: finiteNumber(top, current.top)
}), this.#geometryPersisted = !0, this.#commit(), this.#snapshot;
}
setLocked(locked) {
return this.#viewportWidth <= this.#policy.compactWidth ? this.#snapshot : (this.#locked = !!locked, this.#commit(), this.#snapshot);
}
setPinned(pinned) {
return this.#viewportWidth <= this.#policy.compactWidth ? this.#snapshot : (this.#pinned = !!pinned, this.#commit(), this.#snapshot);
}
reset() {
return this.#viewportWidth <= this.#policy.compactWidth ? this.#snapshot : (this.#locked = !1, this.#pinned = !1, this.#geometryPersisted = !1, this.#geometry = this.#centeredGeometry(), this.#commit(), this.#snapshot);
}
preferencePatch() {
return Object.freeze({
readerWindowWidth: this.#geometryPersisted ? Math.round(this.#geometry.width) : 0,
readerWindowHeight: this.#geometryPersisted ? Math.round(this.#geometry.height) : 0,
readerWindowX: this.#geometryPersisted ? Math.round(this.#geometry.left) : 0,
readerWindowY: this.#geometryPersisted ? Math.round(this.#geometry.top) : 0,
readerWindowLocked: this.#locked,
readerWindowPinned: this.#pinned
});
}
previewGeometry(width, height, left, top) {
return this.#clampGeometry({ width, height, left, top });
}
previewResize(start, direction, deltaX, deltaY) {
let left = start.left, top = start.top, right = start.left + start.width, bottom = start.top + start.height;
const bounds = this.#viewportBounds(), minWidth = Math.min(this.#policy.minWidth, bounds.maxWidth), minHeight = Math.min(this.#policy.minHeight, bounds.maxHeight);
return direction.includes("w") && (left = Math.min(
right - minWidth,
Math.max(this.#policy.margin, start.left + deltaX)
)), direction.includes("e") && (right = Math.max(
left + minWidth,
Math.min(
this.#viewportWidth - this.#policy.margin,
right + deltaX
)
)), direction.includes("n") && (top = Math.min(
bottom - minHeight,
Math.max(this.#policy.margin, start.top + deltaY)
)), direction.includes("s") && (bottom = Math.max(
top + minHeight,
Math.min(
this.#viewportHeight - this.#policy.margin,
bottom + deltaY
)
)), this.#clampGeometry({
left,
top,
width: right - left,
height: bottom - top
});
}
#managed() {
return this.#presentation.floating && this.#viewportWidth > this.#policy.compactWidth;
}
#viewportBounds() {
return {
maxWidth: Math.max(
1,
this.#viewportWidth - this.#policy.margin * 2
),
maxHeight: Math.max(
1,
this.#viewportHeight - this.#policy.margin * 2
)
};
}
#clampSize(width, height) {
const bounds = this.#viewportBounds();
return {
width: Math.min(
bounds.maxWidth,
Math.max(
Math.min(this.#policy.minWidth, bounds.maxWidth),
Math.round(width)
)
),
height: Math.min(
bounds.maxHeight,
Math.max(
Math.min(this.#policy.minHeight, bounds.maxHeight),
Math.round(height)
)
)
};
}
#centeredGeometry() {
const size = this.#clampSize(
Math.min(
this.#policy.defaultWidth,
this.#viewportWidth * this.#policy.defaultViewportWidth
),
Math.min(
this.#policy.defaultHeight ?? Number.POSITIVE_INFINITY,
this.#viewportHeight * this.#policy.defaultViewportHeight
)
);
return this.#clampGeometry({
...size,
left: Math.round((this.#viewportWidth - size.width) / 2),
top: Math.round((this.#viewportHeight - size.height) / 2)
});
}
#preferredGeometry(preferences) {
const fallback = this.#centeredGeometry(), size = this.#clampSize(
preferences.readerWindowWidth || fallback.width,
preferences.readerWindowHeight || fallback.height
);
return this.#clampGeometry({
...size,
left: preferences.readerWindowX || Math.round(
(this.#viewportWidth - size.width) / 2
),
top: preferences.readerWindowY || Math.round(
(this.#viewportHeight - size.height) / 2
)
});
}
#clampGeometry(value) {
const size = this.#clampSize(value.width, value.height);
return Object.freeze({
...size,
left: Math.min(
this.#viewportWidth - this.#policy.margin - size.width,
Math.max(this.#policy.margin, Math.round(value.left))
),
top: Math.min(
this.#viewportHeight - this.#policy.margin - size.height,
Math.max(this.#policy.margin, Math.round(value.top))
)
});
}
#derive() {
return Object.freeze({
geometry: this.#clampGeometry(this.#geometry),
viewportWidth: this.#viewportWidth,
viewportHeight: this.#viewportHeight,
presentation: this.#presentation,
managed: this.#managed(),
locked: this.#locked,
pinned: this.#pinned,
isDefault: !this.#geometryPersisted && !this.#locked && !this.#pinned
});
}
#commit() {
const next = this.#derive(), previous = this.#snapshot;
next.geometry.left === previous.geometry.left && next.geometry.top === previous.geometry.top && next.geometry.width === previous.geometry.width && next.geometry.height === previous.geometry.height && next.viewportWidth === previous.viewportWidth && next.viewportHeight === previous.viewportHeight && next.presentation.mode === previous.presentation.mode && next.locked === previous.locked && next.pinned === previous.pinned && next.isDefault === previous.isDefault || (this.#snapshot = next, this.changes.emit(next));
}
}
class ReaderWindowDomAdapter {
scope;
#model;
#overlay;
#modal;
#header;
#lockButton;
#pinButton;
#dispatchEvents;
#previousLocked = null;
#destroyed = !1;
constructor(options) {
this.#model = options.model, this.#overlay = options.overlay, this.#modal = options.modal, this.#header = options.header, this.#lockButton = options.lockButton, this.#pinButton = options.pinButton, this.#dispatchEvents = options.dispatchEvents !== !1, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#lockButton && this.scope.listen(this.#lockButton, "animationend", () => {
this.#lockButton?.classList.remove("ldp-lock-state-changing");
}), this.#model.changes.subscribe((snapshot) => this.#apply(snapshot, !0), this.scope), this.scope.add(() => this.#clear()), this.#apply(this.#model.snapshot, !1);
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#apply(snapshot, changed) {
if (!this.#destroyed) {
if (this.#overlay.classList.toggle(
"ldp-window-locked",
snapshot.managed && snapshot.locked
), this.#overlay.classList.toggle(
"ldp-window-pinned",
snapshot.managed && snapshot.pinned
), this.#overlay.classList.toggle("ldp-window-managed", snapshot.managed), this.#header?.toggleAttribute(
"data-ldp-reader-drag-surface",
snapshot.managed && !snapshot.locked
), this.#header && (snapshot.managed && !snapshot.locked ? this.#header.dataset.ldpTooltipLabel = "按住空白处可拖动" : delete this.#header.dataset.ldpTooltipLabel), this.#syncWindowButtons(snapshot), snapshot.managed) {
const geometry = snapshot.geometry;
this.#modal.style.setProperty("left", `${geometry.left}px`), this.#modal.style.setProperty("top", `${geometry.top}px`), this.#modal.style.setProperty("width", `${geometry.width}px`), this.#modal.style.setProperty("height", `${geometry.height}px`), projectReaderWindowPlacement(this.#overlay, geometry);
} else
this.#clearGeometry();
changed && this.#dispatchEvents && dispatchCompatibilityEvent(this.#overlay, "ldp-reader-window-change");
}
}
#syncWindowButtons(snapshot) {
const locked = snapshot.managed && snapshot.locked, pinned = snapshot.managed && snapshot.pinned;
if (this.#lockButton) {
const label = locked ? "解锁浮窗" : "锁定浮窗";
this.#lockButton.classList.toggle("active", locked), this.#lockButton.setAttribute("aria-pressed", String(locked)), this.#lockButton.setAttribute("aria-label", label), this.#lockButton.title = label, this.#lockButton.dataset.locked = String(locked);
for (const icon of this.#lockButton.querySelectorAll(
"[data-reader-lock-icon]"
))
icon.hidden = icon.dataset.readerLockIcon !== (locked ? "locked" : "unlocked");
this.#previousLocked !== null && this.#previousLocked !== locked && (this.#lockButton.classList.remove("ldp-lock-state-changing"), this.#lockButton.offsetWidth, this.#lockButton.classList.add("ldp-lock-state-changing"));
}
if (this.#previousLocked = locked, this.#pinButton) {
const label = pinned ? "恢复点击外部关闭" : "点击外部时保持显示";
this.#pinButton.classList.toggle("active", pinned), this.#pinButton.setAttribute("aria-pressed", String(pinned)), this.#pinButton.setAttribute("aria-label", label), this.#pinButton.title = label;
}
}
#clearGeometry() {
projectReaderWindowPlacement(this.#overlay, null);
for (const property of ["left", "top", "width", "height"])
this.#modal.style.removeProperty(property);
}
#clear() {
this.#overlay.classList.remove(
"ldp-window-locked",
"ldp-window-pinned",
"ldp-window-managed"
), this.#header?.removeAttribute("data-ldp-reader-drag-surface"), this.#header && delete this.#header.dataset.ldpTooltipLabel, this.#lockButton?.classList.remove("active", "ldp-lock-state-changing"), this.#pinButton?.classList.remove("active"), this.#clearGeometry();
}
}
const WINDOW_DRAG_BLOCKED_SELECTOR = [
"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(",");
class ReaderWindowPointerController {
scope;
#model;
#overlay;
#modal;
#header;
#onPersist;
#requestFrame;
#cancelFrame;
#dragSurfaceSelector;
#blockedSelector;
#isDragBlocked;
#interactingClassName;
#restingTransform;
#projectPlacement;
#interaction = null;
#interactionScope = null;
#frame = 0;
#destroyed = !1;
constructor(options) {
this.#model = options.model, this.#overlay = options.overlay, this.#modal = options.modal, this.#header = options.header, this.#onPersist = options.onPersist, this.#requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)), this.#cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id)), this.#dragSurfaceSelector = options.dragSurfaceSelector ?? ".ldp-header[data-ldp-reader-drag-surface]", this.#blockedSelector = options.blockedSelector ?? WINDOW_DRAG_BLOCKED_SELECTOR, this.#isDragBlocked = options.isDragBlocked, this.#interactingClassName = options.interactingClassName ?? "ldp-window-interacting", this.#restingTransform = options.restingTransform, this.#projectPlacement = options.projectPlacement ?? projectReaderWindowPlacement, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.listen(this.#overlay, "pointerdown", (event) => this.#onPointerDown(
event
)), options.lockButton && this.scope.listen(options.lockButton, "click", (event) => {
event.preventDefault(), event.stopPropagation(), this.#stopInteraction(), this.#model.setLocked(!this.#model.snapshot.locked), this.#persist();
}), options.pinButton && this.scope.listen(options.pinButton, "click", (event) => {
event.preventDefault(), event.stopPropagation(), this.#model.setPinned(!this.#model.snapshot.pinned), this.#persist();
}), options.viewportTarget && options.readViewport && this.scope.listen(options.viewportTarget, "resize", () => {
this.#stopInteraction();
const viewport = options.readViewport();
this.#model.resizeViewport(viewport.width, viewport.height);
}), this.scope.add(() => this.#stopInteraction());
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#onPointerDown(event) {
if (this.#destroyed || event.button !== 0 || !this.#model.snapshot.managed || this.#model.snapshot.locked)
return;
const target = (0, import_event_target.eventElement)(event);
if (!target) return;
const resizeHandle = target.closest("[data-reader-resize]");
let mode = null, handle = null;
if (resizeHandle && this.#overlay.contains(resizeHandle)) {
const direction = resizeHandle.dataset.readerResize;
(direction === "n" || direction === "s" || direction === "e" || direction === "w" || direction === "nw" || direction === "ne" || direction === "sw" || direction === "se") && (mode = direction, handle = resizeHandle);
} else {
const dragSurface = target.closest(
this.#dragSurfaceSelector
);
dragSurface === this.#header && !target.closest(this.#blockedSelector) && !this.#isDragBlocked?.(event, target) && (mode = "drag", handle = dragSurface);
}
if (!mode || !handle) return;
event.preventDefault(), event.stopPropagation();
const start = this.#model.snapshot.geometry;
this.#interaction = {
pointerId: event.pointerId,
target: handle,
mode,
startX: event.clientX,
startY: event.clientY,
x: event.clientX,
y: event.clientY,
start,
preview: null
}, this.#bindInteractionPointer(), this.#overlay.classList.add(this.#interactingClassName), this.#overlay.dataset.readerWindowInteraction = mode, dispatchCompatibilityEvent(
this.#overlay,
"ldp-reader-window-interaction-start"
);
const pointerTarget = handle;
try {
pointerTarget.setPointerCapture?.(event.pointerId);
} catch {
}
}
#onPointerMove(event) {
!this.#interaction || event.pointerId !== this.#interaction.pointerId || (this.#interaction.x = event.clientX, this.#interaction.y = event.clientY, this.#frame || (this.#frame = this.#requestFrame(() => {
this.#frame = 0, this.#renderInteraction();
})));
}
#bindInteractionPointer() {
this.#interactionScope?.destroy();
const interactionScope = this.scope.child();
this.#interactionScope = interactionScope, interactionScope.listen(this.#overlay, "pointermove", (event) => {
this.#onPointerMove(event);
});
for (const type of ["pointerup", "pointercancel"])
interactionScope.listen(this.#overlay, type, (event) => {
this.#onPointerEnd(event);
});
}
#onPointerEnd(event) {
if (!this.#interaction || event.pointerId !== this.#interaction.pointerId) return;
this.#interaction.x = event.clientX, this.#interaction.y = event.clientY, this.#frame && (this.#cancelFrame(this.#frame), this.#frame = 0), this.#renderInteraction();
const interaction = this.#interaction;
if (interaction?.mode === "drag" && interaction.preview) {
const preview = interaction.preview;
this.#model.setGeometry(
preview.width,
preview.height,
preview.left,
preview.top
);
}
this.#stopInteraction(), this.#persist();
}
#renderInteraction() {
const interaction = this.#interaction;
if (!interaction || !this.#model.snapshot.managed) return;
const deltaX = interaction.x - interaction.startX, deltaY = interaction.y - interaction.startY;
if (interaction.mode === "drag") {
const preview = this.#model.previewGeometry(
interaction.start.width,
interaction.start.height,
interaction.start.left + deltaX,
interaction.start.top + deltaY
);
interaction.preview = preview, this.#modal.style.transform = `translate3d(${preview.left - interaction.start.left}px,${preview.top - interaction.start.top}px,0)`, this.#projectPlacement(this.#overlay, preview);
return;
}
const raw = this.#model.previewResize(
interaction.start,
interaction.mode,
deltaX,
deltaY
);
this.#model.setGeometry(raw.width, raw.height, raw.left, raw.top);
}
#stopInteraction() {
this.#frame && this.#cancelFrame(this.#frame), this.#frame = 0;
const interactionScope = this.#interactionScope;
this.#interactionScope = null, interactionScope?.destroy();
const interaction = this.#interaction;
if (this.#interaction = null, this.#overlay.classList.remove(this.#interactingClassName), this.#restingTransform === void 0 ? this.#modal.style.removeProperty("transform") : this.#modal.style.setProperty("transform", this.#restingTransform), !interaction) return;
const snapshot = this.#model.snapshot;
this.#projectPlacement(
this.#overlay,
snapshot.managed ? snapshot.geometry : null
);
const pointerTarget = interaction.target;
try {
pointerTarget.hasPointerCapture?.(interaction.pointerId) && pointerTarget.releasePointerCapture?.(interaction.pointerId);
} catch {
}
dispatchCompatibilityEvent(
this.#overlay,
"ldp-reader-window-interaction-end"
), delete this.#overlay.dataset.readerWindowInteraction;
}
#persist() {
this.#onPersist?.(this.#model.preferencePatch());
}
}
}, "274eb11f15056c4b85bf5565fa32a6caca5658716121b763f58c7aa614f87195");
/* Source: lite/src/state/preferences-config-codec.ts */
runtime.register("src/state/preferences-config-codec.js", function(module, exports, require) {
var preferences_config_codec_exports = {};
__export(preferences_config_codec_exports, {
PreferencesConfigCodec: () => PreferencesConfigCodec
});
module.exports = __toCommonJS(preferences_config_codec_exports);
function nonEmpty(value, name) {
const normalized = String(value ?? "").trim();
if (!normalized) throw new Error(`${name} 不能为空`);
return normalized;
}
function plainRecord(value, name) {
if (!value || typeof value != "object" || Array.isArray(value))
throw new Error(`${name} 必须是对象`);
return value;
}
function exactKeys(value, expected) {
const actual = Object.keys(value).sort(), canonical = [...expected].sort();
return actual.length === canonical.length && actual.every((key, index) => key === canonical[index]);
}
class PreferencesConfigCodec {
#format;
#schemaVersion;
#scriptVersion;
#defaults;
#normalize;
#legacyRules;
#settingKeys;
#settingKeySet;
#now;
constructor(options) {
if (this.#format = nonEmpty(options.format, "config format"), !Number.isSafeInteger(options.schemaVersion) || options.schemaVersion < 1)
throw new RangeError("config schemaVersion 必须是正安全整数");
if (this.#schemaVersion = options.schemaVersion, this.#scriptVersion = nonEmpty(options.scriptVersion, "scriptVersion"), this.#defaults = options.defaults, this.#normalize = options.normalize, this.#settingKeys = Object.freeze(Object.keys(options.defaults)), !this.#settingKeys.length) throw new Error("preferences defaults 不能为空");
this.#settingKeySet = new Set(this.#settingKeys), this.#legacyRules = Object.freeze((options.legacyImportRules ?? []).map((rule) => {
const missingDefaults = plainRecord(rule.missingDefaults, "legacy missingDefaults"), keys = Object.keys(missingDefaults);
if (!keys.length || keys.some((key) => !this.#settingKeySet.has(key)))
throw new Error("legacy missingDefaults 必须只包含已知偏好字段");
return Object.freeze({ missingDefaults: Object.freeze({ ...missingDefaults }) });
})), this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
}
get settingKeys() {
return this.#settingKeys;
}
export(value) {
const normalized = this.#normalize({
...this.#defaults,
...value
}), settings = Object.freeze(Object.fromEntries(
this.#settingKeys.map((key) => [key, normalized[key]])
));
return Object.freeze({
format: this.#format,
schemaVersion: this.#schemaVersion,
scriptVersion: this.#scriptVersion,
exportedAt: this.#now().toISOString(),
settingsCount: this.#settingKeys.length,
settings
});
}
import(payload) {
try {
return this.#import(payload);
} catch (cause) {
throw cause instanceof Error && cause.message === "invalid_config" ? cause : new Error("invalid_config", { cause });
}
}
#import(payload) {
const record = plainRecord(payload, "config payload");
if (!exactKeys(record, [
"format",
"schemaVersion",
"scriptVersion",
"exportedAt",
"settingsCount",
"settings"
]) || record.format !== this.#format || record.schemaVersion !== this.#schemaVersion || typeof record.scriptVersion != "string" || !record.scriptVersion.trim() || typeof record.exportedAt != "string" || !record.exportedAt.trim())
throw new Error("invalid_config");
const settingsRecord = plainRecord(record.settings, "config settings"), originalKeys = Object.keys(settingsRecord);
if (record.settingsCount !== originalKeys.length || originalKeys.some((key) => !this.#settingKeySet.has(key)))
throw new Error("invalid_config");
const migrated = { ...settingsRecord };
for (const rule of this.#legacyRules) {
const allowedMissing = new Set(Object.keys(rule.missingDefaults));
if (this.#settingKeys.every((key) => Object.hasOwn(migrated, key) || allowedMissing.has(key)))
for (const [key, value] of Object.entries(rule.missingDefaults))
Object.hasOwn(migrated, key) || (migrated[key] = value);
}
if (this.#settingKeys.some((key) => !Object.hasOwn(migrated, key)))
throw new Error("invalid_config");
const selected = Object.fromEntries(
this.#settingKeys.map((key) => [key, migrated[key]])
);
return this.#normalize({
...this.#defaults,
...selected
});
}
}
}, "11cea2ff96a9bf9261c17b5eba9846e468cb5fd87580df681664e704f1b7191b");
/* Source: lite/src/state/preferences-repository.ts */
runtime.register("src/state/preferences-repository.js", function(module, exports, require) {
var preferences_repository_exports = {};
__export(preferences_repository_exports, {
PreferencesRepository: () => PreferencesRepository
});
module.exports = __toCommonJS(preferences_repository_exports);
var import_signal = require("../kernel/signal.js");
function plainRecord(value) {
if (!value || typeof value != "object" || Array.isArray(value))
throw new TypeError("偏好存储值必须是对象");
return value;
}
function nonEmptyKey(value) {
const key = String(value).trim();
if (!key) throw new Error("preferences storage key 不能为空");
return key;
}
function freezePreferenceValue(value, seen = /* @__PURE__ */ new WeakSet()) {
if (!value || typeof value != "object") return value;
const object = value;
if (seen.has(object)) return value;
if (seen.add(object), Array.isArray(value)) {
for (const entry of value) freezePreferenceValue(entry, seen);
return Object.freeze(value);
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) return value;
for (const entry of Object.values(value)) freezePreferenceValue(entry, seen);
return Object.freeze(value);
}
class PreferencesRepository {
changes = new import_signal.Signal();
diagnostics = new import_signal.Signal();
#key;
#storage;
#defaults;
#normalize;
#prepareStored;
#serialize;
#parse;
#snapshot;
constructor(options) {
this.#key = nonEmptyKey(options.key), this.#storage = options.storage, this.#normalize = options.normalize, this.#prepareStored = options.prepareStored ?? ((input) => input), this.#serialize = options.serialize ?? JSON.stringify, this.#parse = options.parse ?? JSON.parse, this.#defaults = freezePreferenceValue({
...this.#normalize({ ...options.defaults })
}), this.#snapshot = Object.freeze({
value: this.#defaults,
revision: 0,
source: "fallback"
});
}
get snapshot() {
return this.#snapshot;
}
load() {
return this.#readAndCommit("initial");
}
reloadExternal() {
return this.#readAndCommit("external-reload");
}
update(patch) {
return this.#persistAndCommit(
this.#normalize({
...this.#snapshot.value,
...patch
}),
"local-update"
);
}
replace(value) {
return this.#persistAndCommit(
this.#normalize({
...this.#defaults,
...value
}),
"replace"
);
}
#readAndCommit(source) {
let value = this.#defaults, commitSource = source;
try {
const stored = this.#storage.getItem(this.#key);
if (stored !== null) {
const parsed = plainRecord(this.#parse(stored)), prepared = plainRecord(this.#prepareStored(parsed));
value = this.#normalize({
...this.#defaults,
...prepared
});
}
} catch (cause) {
commitSource = "fallback", this.diagnostics.emit(Object.freeze({
code: cause instanceof TypeError ? "invalid-stored-value" : "read-failed",
cause
}));
}
return this.#commit(value, commitSource);
}
#persistAndCommit(value, source) {
const frozen = freezePreferenceValue({ ...value });
try {
this.#storage.setItem(this.#key, this.#serialize(frozen));
} catch (cause) {
throw this.diagnostics.emit(Object.freeze({ code: "write-failed", cause })), cause;
}
return this.#commit(frozen, source);
}
#commit(value, source) {
const snapshot = Object.freeze({
value: freezePreferenceValue({ ...value }),
revision: this.#snapshot.revision + 1,
source
});
return this.#snapshot = snapshot, this.changes.emit(snapshot), snapshot;
}
}
}, "7dbda24beac5a9c311c3653615cadbc783aa23e655c1baf2fd5fa146fefbf7b4");
/* Source: lite/src/state/reader-account-scoped-storage.ts */
runtime.register("src/state/reader-account-scoped-storage.js", function(module, exports, require) {
var reader_account_scoped_storage_exports = {};
__export(reader_account_scoped_storage_exports, {
readReaderAccountScopedString: () => readReaderAccountScopedString,
readReaderAccountScopedValue: () => readReaderAccountScopedValue,
readerAccountScopedStorageIdentity: () => readerAccountScopedStorageIdentity
});
module.exports = __toCommonJS(reader_account_scoped_storage_exports);
var import_identifiers = require("../discourse/identifiers.js");
function baseStorageKey(value) {
const normalized = String(value ?? "").trim();
if (!normalized) throw new Error("account scoped storage base key 不能为空");
return normalized;
}
function missing(value) {
return value == null;
}
function readerAccountScopedStorageIdentity(legacyKeyValue, authScopeValue) {
const legacyKey = baseStorageKey(legacyKeyValue), authScope = (0, import_identifiers.discourseAuthScope)(authScopeValue);
return Object.freeze({
authScope,
key: `${legacyKey}:scope:v2:${encodeURIComponent(authScope)}`,
legacyKey,
legacyOwnerKey: `${legacyKey}:legacy-owner:v2`,
canClaimLegacy: authScope.startsWith("account:")
});
}
function readReaderAccountScopedString(storage, identity) {
const scoped = storage.getItem(identity.key);
if (scoped !== null) return scoped;
if (!identity.canClaimLegacy) return null;
const legacy = storage.getItem(identity.legacyKey);
if (legacy === null) return null;
const owner = storage.getItem(identity.legacyOwnerKey);
return owner !== null && owner !== identity.authScope || owner === null && (storage.setItem(identity.legacyOwnerKey, identity.authScope), storage.getItem(identity.legacyOwnerKey) !== identity.authScope) ? null : (storage.setItem(identity.key, legacy), storage.getItem(identity.key));
}
async function readReaderAccountScopedValue(storage, identity) {
const scoped = await storage.getValue(identity.key);
if (!missing(scoped)) return scoped;
if (!identity.canClaimLegacy) return null;
const legacy = await storage.getValue(identity.legacyKey);
if (missing(legacy)) return null;
const owner = await storage.getValue(identity.legacyOwnerKey);
if (!missing(owner) && String(owner) !== identity.authScope || missing(owner) && (await storage.setValue(identity.legacyOwnerKey, identity.authScope), String(await storage.getValue(identity.legacyOwnerKey)) !== identity.authScope))
return null;
await storage.setValue(identity.key, legacy);
const migrated = await storage.getValue(identity.key);
return missing(migrated) ? null : migrated;
}
}, "4ef3eb13ce397fec240b7b3fa2c8798d30a38f2ec4b11956c44131fd85e18f23");
/* Source: lite/src/state/reader-boost-copy-settings.ts */
runtime.register("src/state/reader-boost-copy-settings.js", function(module, exports, require) {
var reader_boost_copy_settings_exports = {};
__export(reader_boost_copy_settings_exports, {
BOOST_COPY_MAX_LENGTH: () => BOOST_COPY_MAX_LENGTH,
DEFAULT_BOOST_COPY_SETTINGS: () => DEFAULT_BOOST_COPY_SETTINGS,
normalizeBoostCopySettings: () => normalizeBoostCopySettings
});
module.exports = __toCommonJS(reader_boost_copy_settings_exports);
const BOOST_COPY_MAX_LENGTH = 16, DEFAULT_BOOST_COPY_SETTINGS = Object.freeze({
mode: "counter",
prefix: "",
counterMarker: "+",
counterStep: 1,
fixedSuffix: ""
});
function boundedText(value, fallback = "") {
return [...String(value ?? fallback).replace(/\s+/g, " ")].slice(0, 16).join("");
}
function normalizeBoostCopySettings(input) {
const source = input ?? {}, markerInput = boundedText(
source.counterMarker,
DEFAULT_BOOST_COPY_SETTINGS.counterMarker
).trim(), marker = markerInput && !/\d$/.test(markerInput) ? markerInput : DEFAULT_BOOST_COPY_SETTINGS.counterMarker, step = Number(source.counterStep);
return Object.freeze({
mode: source.mode === "text" ? "text" : "counter",
prefix: boundedText(source.prefix),
counterMarker: marker,
counterStep: Number.isFinite(step) ? Math.min(99, Math.max(1, Math.round(step))) : DEFAULT_BOOST_COPY_SETTINGS.counterStep,
fixedSuffix: boundedText(source.fixedSuffix)
});
}
}, "b53c781301e282e8c6d15cf16425640164ea8d4d6fd06570f325d605596228d3");
/* Source: lite/src/state/reader-information-flow-coordinator.ts */
runtime.register("src/state/reader-information-flow-coordinator.js", function(module, exports, require) {
var reader_information_flow_coordinator_exports = {};
__export(reader_information_flow_coordinator_exports, {
READER_INFORMATION_FLOW_EXCLUSIONS: () => READER_INFORMATION_FLOW_EXCLUSIONS,
READER_INFORMATION_FLOW_INVENTORY: () => READER_INFORMATION_FLOW_INVENTORY,
ReaderInformationFlowCoordinator: () => ReaderInformationFlowCoordinator
});
module.exports = __toCommonJS(reader_information_flow_coordinator_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
const READER_INFORMATION_FLOW_INVENTORY = Object.freeze([
Object.freeze({ domain: "preferences", transport: "web-storage" }),
Object.freeze({ domain: "reading-history", transport: "web-storage" }),
Object.freeze({ domain: "chronicle", transport: "web-storage" }),
Object.freeze({ domain: "unwanted-topics", transport: "web-storage" }),
Object.freeze({ domain: "reader-queue", transport: "web-storage" }),
Object.freeze({ domain: "user-observations", transport: "web-storage+cache" }),
Object.freeze({ domain: "host-opened-topics", transport: "web-storage" }),
Object.freeze({ domain: "topic-context", transport: "userscript-value" }),
Object.freeze({ domain: "topic-summary-state", transport: "web-storage" }),
Object.freeze({ domain: "surface-layout", transport: "web-storage" }),
Object.freeze({ domain: "connect-trust-history", transport: "web-storage" }),
Object.freeze({ domain: "credit-account", transport: "userscript-value" }),
Object.freeze({ domain: "notifications", transport: "cache-broadcast" }),
Object.freeze({ domain: "bookmarks", transport: "cache-broadcast" }),
Object.freeze({ domain: "download-history", transport: "cache-broadcast" }),
Object.freeze({ domain: "custom-sites", transport: "userscript-value" }),
Object.freeze({ domain: "translation-config", transport: "userscript-value" }),
Object.freeze({ domain: "webdav-config", transport: "userscript-value" }),
Object.freeze({ domain: "response-cache", transport: "cache-broadcast" }),
Object.freeze({ domain: "read-confirmations", transport: "read-broadcast" })
]), READER_INFORMATION_FLOW_EXCLUSIONS = Object.freeze([
Object.freeze({
domain: "request-permit",
reason: "跨标签请求许可协议已自行协调,不是用户可见信息"
}),
Object.freeze({
domain: "cache-flight-lock",
reason: "缓存单飞租约只属于并发协议,不得投影到业务界面"
}),
Object.freeze({
domain: "embedded-reload-transaction",
reason: "仅供当前标签真实 reload 一次性消费,广播会串用导航事务"
}),
Object.freeze({
domain: "native-tab-bypass",
reason: "新标签原生打开绕过标记只能由目标标签一次性消费"
}),
Object.freeze({
domain: "account-scope-migration-metadata",
reason: "旧 key 归属标记只服务迁移判定,对应业务数据域已独立接线"
}),
Object.freeze({
domain: "asset-cache",
reason: "CacheStorage 二进制资源没有存活期业务投影,按 URL 命中即可"
}),
Object.freeze({
domain: "settings-reset-reminder",
reason: "升级提醒只在启动时判定,没有存活期数据消费者"
})
]);
function tokens(values) {
return Object.freeze([
...new Set((values ?? []).map(String).map((value) => value.trim()).filter(Boolean))
]);
}
function cacheMatches(registration, query) {
if (query.all) return !0;
const ids = query.ids ?? [];
return registration.cacheIds?.some((id) => ids.includes(id)) || registration.cacheIdPrefixes?.some((prefix) => ids.some((id) => id.startsWith(prefix))) || registration.cacheKinds?.some((kind) => query.kinds?.includes(kind)) ? !0 : registration.cacheTags?.some((tag) => query.tags?.includes(tag)) === !0;
}
class ReaderInformationFlowCoordinator {
scope;
#registrations = /* @__PURE__ */ new Map();
#states = /* @__PURE__ */ new Map();
#schedule;
#onDiagnostic;
constructor(options = {}) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#schedule = options.schedule ?? queueMicrotask, this.#onDiagnostic = options.onDiagnostic ?? (() => {
}), options.storageEvents && this.scope.listen(options.storageEvents, "storage", (rawEvent) => {
const event = rawEvent;
if (!(event.key !== null && event.oldValue === event.newValue))
for (const registration of this.#registrations.values())
(registration.storageKeys?.length || registration.storageKeyPrefixes?.length) && (event.key === null || registration.storageKeys?.includes(event.key) === !0 || registration.storageKeyPrefixes?.some((prefix) => event.key?.startsWith(prefix)) === !0) && this.#enqueue(registration.domain, "storage");
}), options.cache && this.connectCache(options.cache), this.scope.add(() => {
this.#registrations.clear(), this.#states.clear();
});
}
register(input) {
if (this.scope.destroyed) return () => {
};
if (this.#registrations.has(input.domain))
throw new Error(`信息流领域 ${input.domain} 已注册`);
const registration = Object.freeze({
...input,
storageKeys: tokens(input.storageKeys),
storageKeyPrefixes: tokens(input.storageKeyPrefixes),
cacheIds: tokens(input.cacheIds),
cacheIdPrefixes: tokens(input.cacheIdPrefixes),
cacheKinds: tokens(input.cacheKinds),
cacheTags: tokens(input.cacheTags)
});
if (!registration.storageKeys.length && !registration.storageKeyPrefixes.length && !registration.cacheIds.length && !registration.cacheIdPrefixes.length && !registration.cacheKinds.length && !registration.cacheTags.length && !registration.subscriptions?.length) throw new Error(`信息流领域 ${input.domain} 缺少事件入口`);
this.#registrations.set(input.domain, registration), this.#states.set(input.domain, {
scheduled: !1,
running: !1,
rerun: !1,
source: "storage"
});
const subscriptionCleanups = registration.subscriptions?.map((binding) => binding.subscribe(() => this.#enqueue(input.domain, binding.source))) ?? [];
let active = !0;
const cleanup = () => {
if (active) {
active = !1;
for (const release of subscriptionCleanups) release();
this.#registrations.get(input.domain) === registration && (this.#registrations.delete(input.domain), this.#states.delete(input.domain));
}
};
return this.scope.add(cleanup), cleanup;
}
connectCache(cache) {
if (this.scope.destroyed) return () => {
};
let active = !0;
const release = cache.subscribeInvalidation((query) => {
if (active)
for (const registration of this.#registrations.values())
cacheMatches(registration, query) && this.#enqueue(registration.domain, "cache");
}), cleanup = () => {
active && (active = !1, release());
};
return this.scope.add(cleanup), cleanup;
}
registeredDomains() {
return Object.freeze([...this.#registrations.keys()]);
}
destroy() {
this.scope.destroy();
}
#enqueue(domain, source) {
const state = this.#states.get(domain);
if (!(!state || this.scope.destroyed)) {
if (state.source = source, state.running) {
state.rerun = !0;
return;
}
state.scheduled || (state.scheduled = !0, this.#schedule(() => {
this.#drain(domain);
}));
}
}
async #drain(domain) {
const state = this.#states.get(domain), registration = this.#registrations.get(domain);
if (!(!state || !registration || this.scope.destroyed)) {
if (state.scheduled = !1, state.running) {
state.rerun = !0;
return;
}
state.running = !0;
try {
do {
state.rerun = !1;
const source = state.source;
try {
await registration.refresh(source);
} catch (cause) {
this.#onDiagnostic(Object.freeze({ domain, source, cause }));
}
} while (state.rerun && !this.scope.destroyed && this.#registrations.get(domain) === registration);
} finally {
state.running = !1;
}
}
}
}
}, "56406f1a54354f9da2b34c410b426ed7b33b43737174d765d56d65a52951d2e3");
/* Source: lite/src/state/reader-preferences-schema.ts */
runtime.register("src/state/reader-preferences-schema.js", function(module, exports, require) {
var reader_preferences_schema_exports = {};
__export(reader_preferences_schema_exports, {
HOST_TOPIC_PREHEAT_POST_COUNT_DEFAULT: () => HOST_TOPIC_PREHEAT_POST_COUNT_DEFAULT,
HOST_TOPIC_PREHEAT_POST_COUNT_MAX: () => HOST_TOPIC_PREHEAT_POST_COUNT_MAX,
HOST_TOPIC_PREHEAT_POST_COUNT_MIN: () => HOST_TOPIC_PREHEAT_POST_COUNT_MIN,
IMAGE_PROFILE_DEFAULT: () => IMAGE_PROFILE_DEFAULT,
LIGHTBOX_COMMENTS_WIDTH_DEFAULT: () => LIGHTBOX_COMMENTS_WIDTH_DEFAULT,
LIGHTBOX_COMMENTS_WIDTH_MAX: () => LIGHTBOX_COMMENTS_WIDTH_MAX,
LIGHTBOX_COMMENTS_WIDTH_MIN: () => LIGHTBOX_COMMENTS_WIDTH_MIN,
LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT: () => LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT,
LIGHTBOX_DESCRIPTION_HEIGHT_MIN: () => LIGHTBOX_DESCRIPTION_HEIGHT_MIN,
READER_APPEARANCE_COLOR_NAMES: () => READER_APPEARANCE_COLOR_NAMES,
READER_APPEARANCE_DEFAULT: () => READER_APPEARANCE_DEFAULT,
READER_APPEARANCE_NUMERIC_LIMITS: () => READER_APPEARANCE_NUMERIC_LIMITS,
READER_APPEARANCE_SETTING_NAMES: () => READER_APPEARANCE_SETTING_NAMES,
READER_CONFIG_EXPORT_FORMAT: () => READER_CONFIG_EXPORT_FORMAT,
READER_CONFIG_EXPORT_VERSION: () => READER_CONFIG_EXPORT_VERSION,
READER_FONT_DEFAULT: () => READER_FONT_DEFAULT,
READER_FONT_FAMILIES: () => READER_FONT_FAMILIES,
READER_FONT_SCALE_LIMITS: () => READER_FONT_SCALE_LIMITS,
READER_FONT_WEIGHTS: () => READER_FONT_WEIGHTS,
READER_FULLPAGE_LAYOUT_DEFAULT: () => READER_FULLPAGE_LAYOUT_DEFAULT,
READER_HOST_FONT_SCALE_DEFAULTS: () => READER_HOST_FONT_SCALE_DEFAULTS,
READER_HOST_FONT_SCALE_LIMITS: () => READER_HOST_FONT_SCALE_LIMITS,
READER_JUMP_HIGHLIGHT_DEFAULTS: () => READER_JUMP_HIGHLIGHT_DEFAULTS,
READER_JUMP_HIGHLIGHT_LIMITS: () => READER_JUMP_HIGHLIGHT_LIMITS,
READER_LAYOUT_DEFAULT: () => READER_LAYOUT_DEFAULT,
READER_LAYOUT_MINIMUM_RATIOS: () => READER_LAYOUT_MINIMUM_RATIOS,
READER_LAYOUT_REGIONS: () => READER_LAYOUT_REGIONS,
READER_LOADING_ANIMATION_KEYS: () => READER_LOADING_ANIMATION_KEYS,
READER_PERFORMANCE_DEFAULT_CONFIG: () => READER_PERFORMANCE_DEFAULT_CONFIG,
READER_PERFORMANCE_LIMITS: () => READER_PERFORMANCE_LIMITS,
READER_PERFORMANCE_PRESETS: () => READER_PERFORMANCE_PRESETS,
READER_PREFERENCES_STORAGE_KEY: () => READER_PREFERENCES_STORAGE_KEY,
READER_SHORTCUT_ACTIONS: () => READER_SHORTCUT_ACTIONS,
READER_SHORTCUT_DEFAULTS: () => READER_SHORTCUT_DEFAULTS,
createReaderBusinessRequestSettingsPatch: () => createReaderBusinessRequestSettingsPatch,
createReaderPerformancePreferencesPatch: () => createReaderPerformancePreferencesPatch,
createReaderPreferencesConfigCodec: () => createReaderPreferencesConfigCodec,
createReaderPreferencesDefaults: () => createReaderPreferencesDefaults,
createReaderPreferencesRepository: () => createReaderPreferencesRepository,
createReaderPreferencesResetValue: () => createReaderPreferencesResetValue,
createReaderRequestFlowSettingsPatch: () => createReaderRequestFlowSettingsPatch,
normalizeImageProfile: () => normalizeImageProfile,
normalizeReaderAppearanceProfile: () => normalizeReaderAppearanceProfile,
normalizeReaderFontProfile: () => normalizeReaderFontProfile,
normalizeReaderPreferences: () => normalizeReaderPreferences,
normalizeReaderShortcutBinding: () => normalizeReaderShortcutBinding,
normalizeReaderShortcutBindings: () => normalizeReaderShortcutBindings,
prepareStoredReaderPreferences: () => prepareStoredReaderPreferences,
readReaderBusinessRequestSettings: () => readReaderBusinessRequestSettings,
readReaderPerformanceConfig: () => readReaderPerformanceConfig,
readReaderRequestFlowSettings: () => readReaderRequestFlowSettings,
readerAppearanceEditableProfile: () => readerAppearanceEditableProfile,
readerLayoutProfileTotal: () => readerLayoutProfileTotal,
readerLayoutRegionMaximum: () => readerLayoutRegionMaximum,
readerPerformanceConfigIsDefault: () => readerPerformanceConfigIsDefault,
readerPerformancePresetForConfig: () => readerPerformancePresetForConfig,
readerShortcutBindingPolicyIssue: () => readerShortcutBindingPolicyIssue,
rebalanceReaderLayoutProfile: () => rebalanceReaderLayoutProfile,
resolveReaderAppearanceColor: () => resolveReaderAppearanceColor
});
module.exports = __toCommonJS(reader_preferences_schema_exports);
var import_preferences_config_codec = require("./preferences-config-codec.js"), import_preferences_repository = require("./preferences-repository.js"), import_reader_boost_copy_settings = require("./reader-boost-copy-settings.js"), import_reader_translation_presentation = require("../translation/reader-translation-presentation.js"), import_reader_unwanted_topic_filter = require("../collection/reader-unwanted-topic-filter.js"), import_reader_business_request_config = require("../network/reader-business-request-config.js"), import_reader_request_flow_config = require("../network/reader-request-flow-config.js");
const READER_PREFERENCES_STORAGE_KEY = "linuxdo-enhanced-reader:prefs", READER_CONFIG_EXPORT_FORMAT = "awesome-linuxdo-reader-settings", READER_CONFIG_EXPORT_VERSION = 5, IMAGE_SCALE_OPTIONS = Object.freeze([25, 50, 100, 125, 150, 200]), LIGHTBOX_DESCRIPTION_HEIGHT_MIN = 56, LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT = 120, LIGHTBOX_COMMENTS_WIDTH_DEFAULT = 25, LIGHTBOX_COMMENTS_WIDTH_MIN = 18, LIGHTBOX_COMMENTS_WIDTH_MAX = 50, IMAGE_SCALE_MIN = 50, IMAGE_SCALE_MAX = 200, FONT_SCALE_MIN = 50, FONT_SCALE_MAX = 250, COMPOSER_FONT_SCALE_DEFAULT = 80, HOST_EMBED_SIZE_MIN = 50, HOST_EMBED_SIZE_MAX = 200, READER_WINDOW_MIN_WIDTH = 360, READER_WINDOW_MIN_HEIGHT = 320, READER_EMBED_MIN_WIDTH = 360, HISTORY_EDGE_TRIGGER_MIN = 0, HISTORY_EDGE_TRIGGER_MAX = 15, HISTORY_EDGE_TRIGGER_DEFAULT = 15, INLINE_REPLY_TREE_DEFAULT_DEPTH = 3, INLINE_REPLY_TREE_MAX_DEPTH = 5, HOST_TOPIC_PREHEAT_POST_COUNT_DEFAULT = 24, HOST_TOPIC_PREHEAT_POST_COUNT_MIN = 1, HOST_TOPIC_PREHEAT_POST_COUNT_MAX = 128, FONT_FAMILIES = /* @__PURE__ */ new Set([
"site",
"system",
"cjkSans",
"serif",
"monospace",
"custom"
]), FONT_WEIGHTS = /* @__PURE__ */ new Set([300, 400, 500, 600]), BOOKMARK_TAB_TYPES = Object.freeze([
"Reply",
"Boost",
"Reaction",
"Topic",
"Post"
]), READER_LOADING_ANIMATION_KEYS = Object.freeze([
"portal",
"constellation",
"corridor",
"typewave",
"crystal",
"marginalia",
"chapters",
"quoteecho",
"footnotes",
"inkverse"
]), READER_LOADING_ANIMATIONS = new Set(
READER_LOADING_ANIMATION_KEYS
), LAYOUT_KEYS = Object.freeze([
"left",
"main",
"gap",
"timeline",
"right"
]), READER_LAYOUT_DEFAULT = Object.freeze({
left: 0,
main: 88,
gap: 0,
timeline: 8,
right: 4
}), READER_FULLPAGE_LAYOUT_DEFAULT = Object.freeze({
left: 15,
main: 70,
gap: 5,
timeline: 8,
right: 2
}), LAYOUT_MIN_RATIOS = Object.freeze({
left: 0,
main: 40,
gap: 0,
timeline: 6,
right: 0
}), READER_LAYOUT_REGIONS = LAYOUT_KEYS, READER_LAYOUT_MINIMUM_RATIOS = LAYOUT_MIN_RATIOS, READER_FONT_DEFAULT = Object.freeze({
family: "system",
customFamily: "",
weight: 400,
interfaceColor: "",
interface: 92,
postFamily: "system",
postCustomFamily: "",
postWeight: 400,
postColor: "",
post: 95,
composerFamily: "system",
composerCustomFamily: "",
composerWeight: 400,
composerColor: "",
composer: COMPOSER_FONT_SCALE_DEFAULT
}), READER_FONT_FAMILIES = Object.freeze([...FONT_FAMILIES]), READER_FONT_WEIGHTS = Object.freeze([...FONT_WEIGHTS]), READER_FONT_SCALE_LIMITS = Object.freeze({
min: FONT_SCALE_MIN,
max: FONT_SCALE_MAX,
step: 1
}), READER_HOST_FONT_SCALE_LIMITS = Object.freeze({
min: HOST_EMBED_SIZE_MIN,
max: HOST_EMBED_SIZE_MAX,
step: 1
}), READER_HOST_FONT_SCALE_DEFAULTS = Object.freeze({
title: 110,
avatar: 80,
stats: 120,
labelCard: 100
}), IMAGE_PROFILE_DEFAULT = Object.freeze({
preset: "100",
custom: 100
}), APPEARANCE_PROFILE_DEFAULT = Object.freeze({
accentColor: "#47855f",
accentColorDark: "#78c295",
linkColor: "#2870b8",
linkColorDark: "#71b7ff",
zebraColor: "#f7f7f7",
zebraColorDark: "#1b2b21",
zebraRadius: 10,
listZebraColor: "#f7f7f7",
listZebraColorDark: "#242a31",
structureColorsEnabled: !0,
replyLineColor: "#6dab85",
replyLineColorDark: "#78c295",
replyLineWidth: 1,
replyLineRadius: 15,
quoteLineColor: "#d7d7d7",
quoteLineColorDark: "#46505a",
quoteLineWidth: 0.5,
dividerLineColor: "#e5e5e5",
dividerLineColorDark: "#343b44",
dividerLineWidth: 0.5
}), READER_APPEARANCE_DEFAULT = APPEARANCE_PROFILE_DEFAULT, READER_APPEARANCE_COLOR_NAMES = Object.freeze([
"accentColor",
"linkColor",
"zebraColor",
"listZebraColor",
"replyLineColor",
"quoteLineColor",
"dividerLineColor"
]), READER_APPEARANCE_SETTING_NAMES = Object.freeze([
"accentColor",
"linkColor",
"zebraColor",
"zebraRadius",
"listZebraColor",
"structureColorsEnabled",
"replyLineColor",
"replyLineWidth",
"replyLineRadius",
"quoteLineColor",
"quoteLineWidth",
"dividerLineColor",
"dividerLineWidth"
]), READER_APPEARANCE_NUMERIC_LIMITS = Object.freeze({
zebraRadius: Object.freeze({ min: 0, max: 16, step: 1 }),
replyLineWidth: Object.freeze({ min: 0.5, max: 4, step: 0.5 }),
replyLineRadius: Object.freeze({ min: 0, max: 16, step: 1 }),
quoteLineWidth: Object.freeze({ min: 0.5, max: 4, step: 0.5 }),
dividerLineWidth: Object.freeze({ min: 0.5, max: 4, step: 0.5 })
}), READER_APPEARANCE_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] })
}), PERFORMANCE_PRESETS = Object.freeze({
low: Object.freeze({
pageSize: 24,
streamOverscanViewports: 1,
streamMaxItems: 48,
nestedPrefetchViewports: 1.25,
requestMaxConcurrent: 2,
requestMinInterval: 180,
requestRateTarget: 75,
readStateRequestsPerMinute: 6,
readStateTimingsPerMinute: 120
}),
balanced: Object.freeze({
pageSize: 32,
streamOverscanViewports: 1,
streamMaxItems: 64,
nestedPrefetchViewports: 2,
requestMaxConcurrent: 3,
requestMinInterval: 100,
requestRateTarget: 85,
readStateRequestsPerMinute: 10,
readStateTimingsPerMinute: 240
}),
high: Object.freeze({
pageSize: 64,
streamOverscanViewports: 2,
streamMaxItems: 96,
nestedPrefetchViewports: 3,
requestMaxConcurrent: 4,
requestMinInterval: 80,
requestRateTarget: 90,
readStateRequestsPerMinute: 20,
readStateTimingsPerMinute: 400
})
}), PERFORMANCE_NAMES = Object.freeze([
"pageSize",
"streamOverscanViewports",
"streamMaxItems",
"nestedPrefetchViewports",
"requestMaxConcurrent",
"requestMinInterval",
"requestRateTarget",
"readStateRequestsPerMinute",
"readStateTimingsPerMinute"
]), PERFORMANCE_LIMITS = Object.freeze({
pageSize: Object.freeze({ min: 12, max: 64, integer: !0 }),
streamOverscanViewports: Object.freeze({ min: 0.25, max: 3 }),
streamMaxItems: Object.freeze({ min: 24, max: 128, integer: !0 }),
nestedPrefetchViewports: Object.freeze({ min: 1, max: 3 }),
requestMaxConcurrent: Object.freeze({ min: 1, max: 4, integer: !0 }),
requestMinInterval: Object.freeze({ min: 80, max: 500, integer: !0 }),
requestRateTarget: Object.freeze({ min: 50, max: 95, integer: !0 }),
readStateRequestsPerMinute: Object.freeze({ min: 1, max: 60, integer: !0 }),
readStateTimingsPerMinute: Object.freeze({ min: 20, max: 1200, integer: !0 })
}), READER_PERFORMANCE_PRESETS = PERFORMANCE_PRESETS, READER_PERFORMANCE_DEFAULT_CONFIG = PERFORMANCE_PRESETS.balanced, READER_PERFORMANCE_LIMITS = PERFORMANCE_LIMITS, READER_SHORTCUT_DEFAULTS = Object.freeze({
historyBack: Object.freeze(["ArrowLeft", "Mouse3"]),
historyForward: Object.freeze(["ArrowRight", "Mouse4"]),
topicTop: Object.freeze(["Home"]),
topicBottom: Object.freeze(["End"]),
floorJump: Object.freeze([]),
discussionHorizontalScroll: Object.freeze(["Shift+Wheel"]),
onlyAuthor: Object.freeze([]),
translate: Object.freeze([]),
refreshTopic: Object.freeze([]),
refreshHost: Object.freeze(["F5"]),
openOriginal: Object.freeze([]),
settings: Object.freeze(["Ctrl+Comma"]),
notifications: Object.freeze([]),
historyPanel: Object.freeze([]),
bookmarksPanel: Object.freeze([]),
likeTopic: Object.freeze([]),
replyTopic: Object.freeze([]),
bookmarkTopic: Object.freeze([]),
toggleFullscreen: Object.freeze([]),
toggleQueue: Object.freeze([]),
closeReader: Object.freeze(["Escape"])
}), READER_SHORTCUT_ACTIONS = Object.freeze(
Object.keys(READER_SHORTCUT_DEFAULTS)
), SHORTCUT_MODIFIERS = Object.freeze(["Ctrl", "Alt", "Shift", "Meta"]), READER_SHORTCUT_RESERVED_BINDINGS = /* @__PURE__ */ new Set([
"Ctrl+KeyD",
"Ctrl+KeyF",
"Ctrl+KeyH",
"Ctrl+KeyJ",
"Ctrl+KeyL",
"Ctrl+KeyN",
"Ctrl+KeyO",
"Ctrl+KeyP",
"Ctrl+KeyR",
"Ctrl+KeyS",
"Ctrl+KeyT",
"Ctrl+KeyW",
"Ctrl+Tab",
"Ctrl+Shift+KeyN",
"Ctrl+Shift+KeyB",
"Ctrl+Shift+KeyD",
"Ctrl+Shift+KeyI",
"Ctrl+Shift+KeyJ",
"Ctrl+Shift+KeyO",
"Ctrl+Shift+KeyP",
"Ctrl+Shift+KeyT",
"Ctrl+Shift+KeyW",
"Ctrl+Shift+Delete",
"Ctrl+Shift+Tab",
"Ctrl+KeyU",
"Alt+ArrowLeft",
"Alt+ArrowRight",
"Alt+F4",
"Alt+Home",
"Meta+Comma",
"Meta+KeyF",
"Meta+KeyL",
"Meta+KeyN",
"Meta+KeyP",
"Meta+KeyQ",
"Meta+KeyR",
"Meta+KeyS",
"Meta+KeyT",
"Meta+KeyW",
"Meta+BracketLeft",
"Meta+BracketRight",
"Alt+Meta+KeyC",
"Alt+Meta+KeyI",
"Alt+Meta+KeyJ",
"Alt+Meta+ArrowLeft",
"Alt+Meta+ArrowRight",
"Shift+Meta+BracketLeft",
"Shift+Meta+BracketRight",
"Shift+Meta+KeyN",
"Shift+Meta+KeyT",
"Shift+Meta+KeyW",
"F11",
"F12"
]), READER_SHORTCUT_KEYBOARD_CODE = new RegExp([
"^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-9]|2[0-4])|",
"Arrow(?:Down|Left|Right|Up)|",
"(?:Backquote|Backslash|BracketLeft|BracketRight|Comma|Equal|Minus|",
"Period|Quote|Semicolon|Slash)|",
"(?:Backspace|CapsLock|ContextMenu|Delete|End|Enter|Escape|Help|Home|",
"Insert|PageDown|PageUp|Pause|PrintScreen|ScrollLock|Space|Tab)|",
"Intl(?:Backslash|Ro|Yen)|Lang[1-5]|(?:Convert|KanaMode|NonConvert)|",
"Numpad(?:[0-9]|Add|Backspace|Clear|ClearEntry|Comma|Decimal|Divide|",
"Enter|Equal|Hash|MemoryAdd|MemoryClear|MemoryRecall|MemoryStore|",
"MemorySubtract|Multiply|ParenLeft|ParenRight|Star|Subtract)|",
"Browser(?:Back|Favorites|Forward|Home|Refresh|Search|Stop)|",
"Media(?:PlayPause|Select|Stop|TrackNext|TrackPrevious)|",
"AudioVolume(?:Down|Mute|Up)|Launch(?:App1|App2|Mail)|",
"(?:Abort|Again|Copy|Cut|Eject|Find|Fn|FnLock|Hyper|Open|Paste|Power|",
"Props|Select|Sleep|Super|Turbo|Undo|WakeUp))$"
].join("")), JUMP_HIGHLIGHT_DEFAULTS = Object.freeze({
color: "#0888cc",
radius: 10,
borderWidth: 1,
rate: 0.8,
count: 1
}), JUMP_HIGHLIGHT_LIMITS = Object.freeze({
radius: Object.freeze({ min: 0, max: 24, step: 1, integer: !0 }),
borderWidth: Object.freeze({ min: 0, max: 4, step: 1, integer: !0 }),
rate: Object.freeze({ min: 0.5, max: 2, step: 0.1, integer: !1 }),
count: Object.freeze({ min: 1, max: 6, step: 1, integer: !0 })
}), READER_JUMP_HIGHLIGHT_DEFAULTS = JUMP_HIGHLIGHT_DEFAULTS, READER_JUMP_HIGHLIGHT_LIMITS = JUMP_HIGHLIGHT_LIMITS;
function finiteViewport(value, name) {
if (!Number.isFinite(value) || value < 0)
throw new RangeError(`${name} 必须是非负有限数`);
return value;
}
function plainRecord(value) {
return value && typeof value == "object" && !Array.isArray(value) ? value : {};
}
function normalizeTopicActionRailPosition(value) {
const source = plainRecord(value), rawX = source.x, numericX = Number(rawX), x = rawX === "left" || rawX === "right" ? rawX : Number.isFinite(numericX) ? Math.max(0, Math.min(1, numericX)) : "left", numericY = Number(source.y);
return Object.freeze({
x,
y: Number.isFinite(numericY) ? Math.max(0, Math.min(1, numericY)) : 0.95
});
}
function normalizeTopicActionRailPositions(value, legacyValue) {
const source = plainRecord(value), legacy = normalizeTopicActionRailPosition(legacyValue);
return Object.freeze({
floating: Object.hasOwn(source, "floating") ? normalizeTopicActionRailPosition(source.floating) : legacy,
fullpage: Object.hasOwn(source, "fullpage") ? normalizeTopicActionRailPosition(source.fullpage) : legacy,
embedded: Object.hasOwn(source, "embedded") ? normalizeTopicActionRailPosition(source.embedded) : legacy
});
}
function roundedRange(value, fallback, minimum, maximum) {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.min(maximum, Math.max(minimum, Math.round(numeric))) : fallback;
}
function steppedRange(value, fallback, minimum, maximum, step) {
const numeric = Number(value), safe = Number.isFinite(numeric) ? numeric : fallback;
return Math.min(maximum, Math.max(minimum, Math.round(safe / step) * step));
}
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 normalizeFontFamily(value, fallback) {
return FONT_FAMILIES.has(value) ? value : fallback;
}
function normalizeCustomFontFamily(value, fallback = "") {
return [...String(value ?? fallback).replace(/[\u0000-\u001f\u007f"'`,;{}<>\\]/g, "").replace(/\s+/g, " ").trim()].slice(0, 64).join("");
}
function normalizeFontWeight(value, fallback) {
const numeric = Number(value);
return FONT_WEIGHTS.has(numeric) ? numeric : fallback;
}
function normalizeImageProfile(value) {
const source = plainRecord(value), sourcePreset = source.preset == null ? IMAGE_PROFILE_DEFAULT.preset : source.preset, preset = sourcePreset === "custom" ? "custom" : IMAGE_SCALE_OPTIONS.includes(Number(sourcePreset)) ? String(Number(sourcePreset)) : "100";
return Object.freeze({
preset,
custom: roundedRange(
source.custom,
IMAGE_PROFILE_DEFAULT.custom,
IMAGE_SCALE_MIN,
IMAGE_SCALE_MAX
)
});
}
function normalizeFontProfile(value) {
const source = plainRecord(value), family = normalizeFontFamily(source.family, READER_FONT_DEFAULT.family), customFamily = normalizeCustomFontFamily(
source.customFamily,
READER_FONT_DEFAULT.customFamily
), weight = normalizeFontWeight(source.weight, READER_FONT_DEFAULT.weight);
return Object.freeze({
family,
customFamily,
weight,
interfaceColor: normalizeHexColor(
source.interfaceColor,
READER_FONT_DEFAULT.interfaceColor
),
interface: roundedRange(
source.interface,
READER_FONT_DEFAULT.interface,
FONT_SCALE_MIN,
FONT_SCALE_MAX
),
postFamily: normalizeFontFamily(source.postFamily, family),
postCustomFamily: normalizeCustomFontFamily(source.postCustomFamily, customFamily),
postWeight: normalizeFontWeight(source.postWeight, weight),
postColor: normalizeHexColor(source.postColor, READER_FONT_DEFAULT.postColor),
post: roundedRange(
source.post,
READER_FONT_DEFAULT.post,
FONT_SCALE_MIN,
FONT_SCALE_MAX
),
composerFamily: normalizeFontFamily(source.composerFamily, family),
composerCustomFamily: normalizeCustomFontFamily(
source.composerCustomFamily,
customFamily
),
composerWeight: normalizeFontWeight(source.composerWeight, weight),
composerColor: normalizeHexColor(
source.composerColor,
READER_FONT_DEFAULT.composerColor
),
composer: roundedRange(
source.composer,
READER_FONT_DEFAULT.composer,
FONT_SCALE_MIN,
FONT_SCALE_MAX
)
});
}
function normalizeReaderFontProfile(value) {
return normalizeFontProfile(value);
}
function appearanceColorPair(source, key) {
const darkKey = `${key}Dark`, lightFallback = normalizeHexColor(
APPEARANCE_PROFILE_DEFAULT[key],
APPEARANCE_PROFILE_DEFAULT[key]
), sourceColor = normalizeHexColor(source[key]) || normalizeHexColor(source[darkKey]) || lightFallback;
return [sourceColor, sourceColor];
}
function normalizeAppearanceProfile(value) {
const source = plainRecord(value), [accentColor, accentColorDark] = appearanceColorPair(source, "accentColor"), [linkColor, linkColorDark] = appearanceColorPair(source, "linkColor"), [zebraColor, zebraColorDark] = appearanceColorPair(source, "zebraColor"), [listZebraColor, listZebraColorDark] = appearanceColorPair(
source,
"listZebraColor"
), [replyLineColor, replyLineColorDark] = appearanceColorPair(
source,
"replyLineColor"
), [quoteLineColor, quoteLineColorDark] = appearanceColorPair(
source,
"quoteLineColor"
), [dividerLineColor, dividerLineColorDark] = appearanceColorPair(
source,
"dividerLineColor"
);
return Object.freeze({
accentColor,
accentColorDark,
linkColor,
linkColorDark,
zebraColor,
zebraColorDark,
zebraRadius: steppedRange(source.zebraRadius, 10, 0, 16, 1),
listZebraColor,
listZebraColorDark,
structureColorsEnabled: typeof source.structureColorsEnabled == "boolean" ? source.structureColorsEnabled : !0,
replyLineColor,
replyLineColorDark,
replyLineWidth: steppedRange(source.replyLineWidth, 1, 0.5, 4, 0.5),
replyLineRadius: steppedRange(source.replyLineRadius, 15, 0, 16, 1),
quoteLineColor,
quoteLineColorDark,
quoteLineWidth: steppedRange(source.quoteLineWidth, 0.5, 0.5, 4, 0.5),
dividerLineColor,
dividerLineColorDark,
dividerLineWidth: steppedRange(source.dividerLineWidth, 0.5, 0.5, 4, 0.5)
});
}
function hexColorToHsl(value) {
const color = normalizeHexColor(value);
if (!color) return null;
const [red, green, blue] = [1, 3, 5].map(
(index) => Number.parseInt(color.slice(index, index + 2), 16) / 255
), maximum = Math.max(red, green, blue), minimum = Math.min(red, green, blue), delta = maximum - minimum, lightness = (maximum + minimum) / 2, saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));
let hue = 0;
return delta !== 0 && (maximum === red ? hue = 60 * ((green - blue) / delta % 6) : maximum === green ? hue = 60 * ((blue - red) / delta + 2) : hue = 60 * ((red - green) / delta + 4)), Object.freeze({
hue: hue < 0 ? hue + 360 : hue,
saturation: saturation * 100,
lightness: lightness * 100
});
}
function hslColorToHex(hue, saturation, lightness) {
const normalizedHue = (hue % 360 + 360) % 360, normalizedSaturation = Math.min(100, Math.max(0, saturation)) / 100, normalizedLightness = Math.min(100, Math.max(0, lightness)) / 100, chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation, intermediate = chroma * (1 - Math.abs(normalizedHue / 60 % 2 - 1)), offset = normalizedLightness - chroma / 2, sector = Math.floor(normalizedHue / 60);
return `#${([
[chroma, intermediate, 0],
[intermediate, chroma, 0],
[0, chroma, intermediate],
[0, intermediate, chroma],
[intermediate, 0, chroma],
[chroma, 0, intermediate]
][sector] ?? [0, 0, 0]).map(
(channel) => Math.round((channel + offset) * 255).toString(16).padStart(2, "0")
).join("")}`;
}
function normalizeReaderAppearanceProfile(value) {
return normalizeAppearanceProfile(value);
}
function readerAppearanceEditableProfile(profile) {
return Object.freeze(Object.fromEntries(
READER_APPEARANCE_SETTING_NAMES.map((name) => [name, profile[name]])
));
}
function resolveReaderAppearanceColor(profile, name, theme) {
const darkName = `${name}Dark`, defaultColor = String(theme === "dark" ? READER_APPEARANCE_DEFAULT[darkName] : READER_APPEARANCE_DEFAULT[name]), sourceColor = normalizeHexColor(
profile[name],
READER_APPEARANCE_DEFAULT[name]
);
if (sourceColor === READER_APPEARANCE_DEFAULT[name]) return defaultColor;
const hsl = hexColorToHsl(sourceColor);
if (!hsl) return defaultColor;
const limit = READER_APPEARANCE_THEME_LIMITS[name], [minimumLightness, maximumLightness] = limit[theme], saturation = Math.min(hsl.saturation, limit.saturationMax), lightness = Math.min(
maximumLightness,
Math.max(minimumLightness, hsl.lightness)
);
return Math.abs(saturation - hsl.saturation) < 0.01 && Math.abs(lightness - hsl.lightness) < 0.01 ? sourceColor : hslColorToHex(hsl.hue, saturation, lightness);
}
function normalizePerformanceValue(name, value, fallback) {
const limit = PERFORMANCE_LIMITS[name], numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
const clamped = Math.min(limit.max, Math.max(limit.min, numeric));
return limit.integer ? Math.round(clamped) : Math.round(clamped * 100) / 100;
}
function performanceConfigFromInput(input) {
const fallback = PERFORMANCE_PRESETS.balanced;
return Object.freeze({
pageSize: normalizePerformanceValue(
"pageSize",
input.performancePageSize,
fallback.pageSize
),
streamOverscanViewports: normalizePerformanceValue(
"streamOverscanViewports",
input.performanceStreamOverscan,
fallback.streamOverscanViewports
),
streamMaxItems: normalizePerformanceValue(
"streamMaxItems",
input.performanceStreamMaxItems,
fallback.streamMaxItems
),
nestedPrefetchViewports: normalizePerformanceValue(
"nestedPrefetchViewports",
input.performanceNestedPrefetch,
fallback.nestedPrefetchViewports
),
requestMaxConcurrent: normalizePerformanceValue(
"requestMaxConcurrent",
input.performanceRequestConcurrency,
fallback.requestMaxConcurrent
),
requestMinInterval: normalizePerformanceValue(
"requestMinInterval",
input.performanceRequestInterval,
fallback.requestMinInterval
),
requestRateTarget: normalizePerformanceValue(
"requestRateTarget",
input.performanceRequestRateTarget,
fallback.requestRateTarget
),
readStateRequestsPerMinute: normalizePerformanceValue(
"readStateRequestsPerMinute",
input.performanceReadStateRequestsPerMinute,
fallback.readStateRequestsPerMinute
),
readStateTimingsPerMinute: normalizePerformanceValue(
"readStateTimingsPerMinute",
input.performanceReadStateTimingsPerMinute,
fallback.readStateTimingsPerMinute
)
});
}
function performancePresetForConfig(config) {
for (const preset of ["low", "balanced", "high"])
if (PERFORMANCE_NAMES.every((name) => config[name] === PERFORMANCE_PRESETS[preset][name]))
return preset;
return "custom";
}
function performancePreferencesPatch(config, preset = performancePresetForConfig(config)) {
return {
performancePreset: preset,
performancePageSize: config.pageSize,
performanceStreamOverscan: config.streamOverscanViewports,
performanceStreamMaxItems: config.streamMaxItems,
performanceNestedPrefetch: config.nestedPrefetchViewports,
performanceRequestConcurrency: config.requestMaxConcurrent,
performanceRequestInterval: config.requestMinInterval,
performanceRequestRateTarget: config.requestRateTarget,
performanceReadStateRequestsPerMinute: config.readStateRequestsPerMinute,
performanceReadStateTimingsPerMinute: config.readStateTimingsPerMinute
};
}
function readReaderPerformanceConfig(input) {
return performanceConfigFromInput(
input
);
}
function readerPerformanceConfigIsDefault(config) {
return PERFORMANCE_NAMES.every((name) => config[name] === READER_PERFORMANCE_DEFAULT_CONFIG[name]);
}
function createReaderPerformancePreferencesPatch(config, preset) {
return performancePreferencesPatch(config, preset);
}
function readReaderBusinessRequestSettings(input) {
return (0, import_reader_business_request_config.normalizeReaderBusinessRequestSettings)(input.businessRequestSettings);
}
function createReaderBusinessRequestSettingsPatch(settings) {
return {
businessRequestSettings: (0, import_reader_business_request_config.normalizeReaderBusinessRequestSettings)(settings)
};
}
function readReaderRequestFlowSettings(input) {
return (0, import_reader_request_flow_config.normalizeReaderRequestFlowSettings)(input.requestFlowSettings);
}
function createReaderRequestFlowSettingsPatch(settings) {
return {
requestFlowSettings: (0, import_reader_request_flow_config.normalizeReaderRequestFlowSettings)(settings)
};
}
function readerPerformancePresetForConfig(config) {
return performancePresetForConfig(config);
}
function roundLayoutRatio(value) {
return Math.round(Number(value) * 100) / 100;
}
function layoutTotal(value) {
return roundLayoutRatio(LAYOUT_KEYS.reduce((sum, key) => sum + value[key], 0));
}
function normalizeLayoutProfile(value, fallback) {
const source = plainRecord(value), result = Object.fromEntries(LAYOUT_KEYS.map((key) => {
const numeric = Number(source[key]), safe = Number.isFinite(numeric) ? numeric : fallback[key];
return [
key,
roundLayoutRatio(Math.min(100, Math.max(LAYOUT_MIN_RATIOS[key], safe)))
];
}));
if (layoutTotal(result) > 100) {
const extraBudget = 100 - LAYOUT_KEYS.reduce(
(sum, key) => sum + LAYOUT_MIN_RATIOS[key],
0
), extras = Object.fromEntries(LAYOUT_KEYS.map((key) => [
key,
Math.max(0, result[key] - LAYOUT_MIN_RATIOS[key])
])), extraTotal = LAYOUT_KEYS.reduce((sum, key) => sum + extras[key], 0) || 1;
for (const key of LAYOUT_KEYS)
result[key] = roundLayoutRatio(
LAYOUT_MIN_RATIOS[key] + extraBudget * extras[key] / extraTotal
);
let overflow = roundLayoutRatio(layoutTotal(result) - 100);
if (overflow > 0) {
const correctionKey = LAYOUT_KEYS.find(
(key) => result[key] - overflow >= LAYOUT_MIN_RATIOS[key]
);
correctionKey && (result[correctionKey] = roundLayoutRatio(result[correctionKey] - overflow)), overflow = roundLayoutRatio(layoutTotal(result) - 100), overflow > 0 && (result.main = roundLayoutRatio(result.main - overflow));
}
}
return Object.freeze({ ...result });
}
function readerLayoutProfileTotal(profile) {
return layoutTotal(profile);
}
function readerLayoutRegionMaximum(region) {
return roundLayoutRatio(
100 - LAYOUT_KEYS.reduce(
(total, name) => total + (name === region ? 0 : LAYOUT_MIN_RATIOS[name]),
0
)
);
}
function rebalanceReaderLayoutProfile(profile, editedRegion) {
const result = { ...profile }, difference = roundLayoutRatio(100 - layoutTotal(result)), direction = difference > 0 ? 1 : -1;
let remaining = Math.abs(difference);
for (const name of [
"main",
"left",
"right",
"gap",
"timeline"
]) {
if (name === editedRegion || remaining <= 0) continue;
const capacity = roundLayoutRatio(
direction > 0 ? 100 - result[name] : Math.max(0, result[name] - LAYOUT_MIN_RATIOS[name])
), change = Math.min(capacity, remaining);
result[name] = roundLayoutRatio(
result[name] + direction * change
), remaining = roundLayoutRatio(remaining - change);
}
return normalizeLayoutProfile(result, profile);
}
function normalizeReaderWindowGroup(source) {
const width = Number(source.readerWindowWidth), height = Number(source.readerWindowHeight), x = Number(source.readerWindowX), y = Number(source.readerWindowY);
return {
readerWindowWidth: Number.isFinite(width) && width > 0 ? Math.max(READER_WINDOW_MIN_WIDTH, Math.round(width)) : 0,
readerWindowHeight: Number.isFinite(height) && height > 0 ? Math.max(READER_WINDOW_MIN_HEIGHT, Math.round(height)) : 0,
readerWindowX: Number.isFinite(x) && x > 0 ? Math.round(x) : 0,
readerWindowY: Number.isFinite(y) && y > 0 ? Math.round(y) : 0,
readerWindowLocked: !!source.readerWindowLocked,
readerWindowPinned: !!source.readerWindowPinned
};
}
function normalizeComposerWindowGroup(source) {
const result = {};
for (const key of [
"composerWindowWidth",
"composerWindowHeight",
"composerWindowX",
"composerWindowY"
]) {
const value = Number(source[key]);
result[key] = Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
}
return result;
}
function normalizeReaderShortcutBinding(value) {
const parts = String(value || "").split("+").map((part) => part.trim()).filter(Boolean), code = parts.pop() || "";
if (!READER_SHORTCUT_KEYBOARD_CODE.test(code) && !/^Mouse(?:1|3|4|[5-9])$/.test(code) && code !== "Wheel" || /^(?:Control|Alt|Shift|Meta)(?:Left|Right)?$/.test(code))
return "";
const modifiers = SHORTCUT_MODIFIERS.filter((modifier) => parts.includes(modifier));
return parts.some((part) => !SHORTCUT_MODIFIERS.includes(part)) ? "" : [...modifiers, code].join("+");
}
function readerShortcutBindingPolicyIssue(value) {
const binding = normalizeReaderShortcutBinding(value);
if (!binding) return "invalid";
if (READER_SHORTCUT_RESERVED_BINDINGS.has(binding)) return "reserved";
const parts = binding.split("+"), code = parts.at(-1) ?? "";
return parts.length === 1 && /^(?:Key[A-Z]|Digit\d|Numpad\d)$/.test(code) ? "bare-alphanumeric" : null;
}
function normalizeReaderShortcutBindings(value) {
const source = plainRecord(value), used = /* @__PURE__ */ new Set(), result = {};
for (const action of READER_SHORTCUT_ACTIONS) {
const selected = Object.hasOwn(source, action) && Array.isArray(source[action]) ? source[action] : READER_SHORTCUT_DEFAULTS[action], bindings = [...new Set(
selected.map(normalizeReaderShortcutBinding).filter((binding) => binding && readerShortcutBindingPolicyIssue(binding) === null)
)].filter((binding) => used.has(binding) ? !1 : (used.add(binding), !0)).slice(0, 3);
result[action] = Object.freeze(bindings);
}
return Object.freeze(result);
}
function normalizeBookmarkTabOrder(value) {
const stored = Array.isArray(value) ? [...new Set(value.filter(
(type) => BOOKMARK_TAB_TYPES.includes(type)
))] : [];
return Object.freeze([
...stored,
...BOOKMARK_TAB_TYPES.filter((type) => !stored.includes(type))
]);
}
function normalizeJumpValue(name, value) {
const limit = JUMP_HIGHLIGHT_LIMITS[name], fallback = JUMP_HIGHLIGHT_DEFAULTS[name], numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
const clamped = Math.min(limit.max, Math.max(limit.min, numeric));
return limit.integer ? Math.round(clamped) : Math.round(Math.round(clamped / limit.step) * limit.step * 100) / 100;
}
function normalizeBoostPreferences(source) {
const normalized = (0, import_reader_boost_copy_settings.normalizeBoostCopySettings)({
mode: source.boostCopyMode === "text" ? "text" : "counter",
prefix: String(source.boostCopyPrefix ?? ""),
counterMarker: String(source.boostCopyCounterMarker ?? ""),
counterStep: Number(source.boostCopyCounterStep),
fixedSuffix: String(source.boostCopyFixedSuffix ?? "")
});
return {
boostCopyMode: normalized.mode,
boostCopyPrefix: normalized.prefix,
boostCopyCounterMarker: normalized.counterMarker,
boostCopyCounterStep: normalized.counterStep,
boostCopyFixedSuffix: normalized.fixedSuffix
};
}
function normalizeEnvironment(environment) {
return Object.freeze({
viewportWidth: finiteViewport(environment.viewportWidth, "viewportWidth"),
viewportHeight: finiteViewport(environment.viewportHeight, "viewportHeight")
});
}
function createReaderPreferencesDefaults(environment) {
const viewport = normalizeEnvironment(environment), performance = performancePreferencesPatch(
PERFORMANCE_PRESETS.balanced,
"balanced"
);
return Object.freeze({
topicReaderMode: "fullpage",
imageProfile: IMAGE_PROFILE_DEFAULT,
imageProfilesShared: !0,
floatingImageProfile: IMAGE_PROFILE_DEFAULT,
fullpageImageProfile: IMAGE_PROFILE_DEFAULT,
mobileImageProfile: IMAGE_PROFILE_DEFAULT,
lightboxOriginalByDefault: !0,
lightboxCommentsExpandedByDefault: !0,
lightboxDescriptionExpanded: !1,
lightboxDescriptionHeight: LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT,
lightboxCommentsWidthPercent: LIGHTBOX_COMMENTS_WIDTH_DEFAULT,
themeMode: "system",
autoDarkModeEnabled: !1,
autoDarkModeStartTime: "sunset",
fontRenderingEnabled: !0,
fontRenderingOnHost: !0,
hostFontFamily: "system",
hostFontCustomFamily: "",
hostFontWeight: 400,
hostFontColor: "",
hostEmbeddedTitleScale: READER_HOST_FONT_SCALE_DEFAULTS.title,
hostEmbeddedAvatarScale: READER_HOST_FONT_SCALE_DEFAULTS.avatar,
hostEmbeddedStatsScale: READER_HOST_FONT_SCALE_DEFAULTS.stats,
hostEmbeddedLabelCardScale: READER_HOST_FONT_SCALE_DEFAULTS.labelCard,
fontProfile: READER_FONT_DEFAULT,
appearanceProfile: APPEARANCE_PROFILE_DEFAULT,
...performance,
requestFlowSettings: import_reader_request_flow_config.READER_REQUEST_FLOW_DEFAULTS,
businessRequestSettings: import_reader_business_request_config.READER_BUSINESS_REQUEST_DEFAULTS,
hostTopicPreheatEnabled: !1,
hostTopicPreheatPostCount: HOST_TOPIC_PREHEAT_POST_COUNT_DEFAULT,
performanceSuspendHostTurnstileInBackground: !1,
layoutProfile: READER_LAYOUT_DEFAULT,
fullpageLayoutProfile: READER_FULLPAGE_LAYOUT_DEFAULT,
readerWindowWidth: 0,
readerWindowHeight: 0,
readerWindowX: 0,
readerWindowY: 0,
readerWindowLocked: !1,
readerWindowPinned: !1,
listReaderMode: "embed-right",
listReaderEmbedWidth: Math.round(viewport.viewportWidth * 0.45),
composerWindowWidth: 0,
composerWindowHeight: 0,
composerWindowX: 0,
composerWindowY: 0,
historySortMode: "recent-viewed",
bookmarkTabOrder: BOOKMARK_TAB_TYPES,
historyButtonsAlwaysVisible: !0,
readerQueueAlwaysVisibleWhenEmpty: !0,
historyEdgeTriggerPercent: HISTORY_EDGE_TRIGGER_DEFAULT,
loadingAnimation: "quoteecho",
translationMode: "original",
translationTheme: import_reader_translation_presentation.DEFAULT_READER_TRANSLATION_THEME,
openTopicsAtFirstPost: !0,
doubleEscapeToCloseReader: !1,
confirmNativeComposerClose: !1,
readerShortcutBindings: normalizeReaderShortcutBindings(READER_SHORTCUT_DEFAULTS),
topicActionRailVisible: !0,
topicActionRailFixed: !1,
topicActionRailMode: "compact",
topicActionRailPositions: Object.freeze({
floating: Object.freeze({ x: "left", y: 0.95 }),
fullpage: Object.freeze({ x: "left", y: 0.95 }),
embedded: Object.freeze({ x: "left", y: 0.95 })
}),
unwantedTopicFilterEnabled: !1,
unwantedTopicFilterCategories: Object.freeze([]),
unwantedTopicFilterLabels: Object.freeze([]),
unwantedTopicFilterTopicAuthors: Object.freeze([]),
unwantedTopicFilterTopicFields: Object.freeze([]),
unwantedTopicFilterPostAuthors: Object.freeze([]),
expandNestedRepliesByDefault: !0,
expandLeafNestedReplies: !1,
aggregateDescendantReplies: !0,
inlineReplyTreeMaxDepth: INLINE_REPLY_TREE_DEFAULT_DEPTH,
hideNestedReplyFloors: !0,
jumpHighlightColor: JUMP_HIGHLIGHT_DEFAULTS.color,
jumpHighlightRadius: JUMP_HIGHLIGHT_DEFAULTS.radius,
jumpHighlightBorderWidth: JUMP_HIGHLIGHT_DEFAULTS.borderWidth,
jumpHighlightRate: JUMP_HIGHLIGHT_DEFAULTS.rate,
jumpHighlightCount: JUMP_HIGHLIGHT_DEFAULTS.count,
boostCopyMode: import_reader_boost_copy_settings.DEFAULT_BOOST_COPY_SETTINGS.mode,
boostCopyPrefix: import_reader_boost_copy_settings.DEFAULT_BOOST_COPY_SETTINGS.prefix,
boostCopyCounterMarker: import_reader_boost_copy_settings.DEFAULT_BOOST_COPY_SETTINGS.counterMarker,
boostCopyCounterStep: import_reader_boost_copy_settings.DEFAULT_BOOST_COPY_SETTINGS.counterStep,
boostCopyFixedSuffix: import_reader_boost_copy_settings.DEFAULT_BOOST_COPY_SETTINGS.fixedSuffix
});
}
function createReaderPreferencesResetValue(defaults, current) {
return Object.freeze({
...defaults,
...import_reader_unwanted_topic_filter.readerPreferencesUnwantedTopicFilterAdapter.createPatch(
import_reader_unwanted_topic_filter.readerPreferencesUnwantedTopicFilterAdapter.read(current)
)
});
}
function prepareStoredReaderPreferences(value) {
const source = { ...plainRecord(value) }, preset = source.performancePreset;
if (preset === "low" || preset === "balanced" || preset === "high") {
const patch = performancePreferencesPatch(PERFORMANCE_PRESETS[preset], preset);
return Object.freeze({ ...source, ...patch });
}
return Object.freeze(source);
}
function normalizeReaderPreferences(value, environment) {
const viewport = normalizeEnvironment(environment), defaults = createReaderPreferencesDefaults(viewport), known = Object.fromEntries(
Object.keys(defaults).filter((key) => Object.hasOwn(value, key)).map((key) => [key, value[key]])
), source = { ...defaults, ...known }, performance = performancePreferencesPatch(performanceConfigFromInput(source)), expandLeafNestedReplies = source.expandLeafNestedReplies === !0, aggregateDescendantReplies = source.aggregateDescendantReplies === !0, expandNestedRepliesByDefault = aggregateDescendantReplies || source.expandNestedRepliesByDefault !== !1 || !expandLeafNestedReplies, windowPreferences = normalizeReaderWindowGroup(source), composerPreferences = normalizeComposerWindowGroup(source), boostPreferences = normalizeBoostPreferences(source), lightboxMaximum = Math.max(
LIGHTBOX_DESCRIPTION_HEIGHT_MIN,
Math.floor(viewport.viewportHeight * 0.4)
), lightboxHeight = Math.round(Number(source.lightboxDescriptionHeight)), listWidth = Number(source.listReaderEmbedWidth), historyEdge = Number(source.historyEdgeTriggerPercent), depth = Number.parseInt(String(source.inlineReplyTreeMaxDepth), 10), loadingAnimation = source.loadingAnimation === "random" || READER_LOADING_ANIMATIONS.has(source.loadingAnimation) ? source.loadingAnimation : "quoteecho", topicReaderMode = source.topicReaderMode === "floating" ? "floating" : "fullpage", imageProfile = normalizeImageProfile(source.imageProfile), imageProfilesShared = source.imageProfilesShared !== !1, floatingImageProfile = imageProfilesShared ? imageProfile : normalizeImageProfile(
source.floatingImageProfile ?? imageProfile
), fullpageImageProfile = imageProfilesShared ? imageProfile : normalizeImageProfile(
source.fullpageImageProfile ?? imageProfile
), mobileImageProfile = imageProfilesShared ? imageProfile : normalizeImageProfile(
source.mobileImageProfile ?? imageProfile
), listReaderMode = [
"floating",
"fullpage",
"embed-left",
"embed-right"
].includes(String(source.listReaderMode)) ? source.listReaderMode : defaults.listReaderMode, themeMode = ["light", "dark", "system"].includes(String(source.themeMode)) ? source.themeMode : "system", autoDarkModeStartTime = /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(
String(source.autoDarkModeStartTime)
) ? String(source.autoDarkModeStartTime) : "sunset", translationMode = ["bilingual", "translation"].includes(
String(source.translationMode)
) ? source.translationMode : "original", translationTheme = (0, import_reader_translation_presentation.normalizeReaderTranslationTheme)(
source.translationTheme
), jumpColor = String(source.jumpHighlightColor || "").trim().toLowerCase(), unwantedTopicFilter = (0, import_reader_unwanted_topic_filter.normalizeReaderUnwantedTopicFilterPreferences)({
enabled: source.unwantedTopicFilterEnabled === !0,
categories: source.unwantedTopicFilterCategories,
labels: source.unwantedTopicFilterLabels,
topicAuthors: source.unwantedTopicFilterTopicAuthors,
topicFields: source.unwantedTopicFilterTopicFields,
postAuthors: source.unwantedTopicFilterPostAuthors
});
return Object.freeze({
topicReaderMode,
imageProfile,
imageProfilesShared,
floatingImageProfile,
fullpageImageProfile,
mobileImageProfile,
lightboxOriginalByDefault: source.lightboxOriginalByDefault === !0,
lightboxCommentsExpandedByDefault: source.lightboxCommentsExpandedByDefault === !0,
lightboxDescriptionExpanded: source.lightboxDescriptionExpanded === !0,
lightboxDescriptionHeight: Math.min(
lightboxMaximum,
Math.max(
LIGHTBOX_DESCRIPTION_HEIGHT_MIN,
Number.isFinite(lightboxHeight) ? lightboxHeight : LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT
)
),
lightboxCommentsWidthPercent: Math.min(
LIGHTBOX_COMMENTS_WIDTH_MAX,
Math.max(
LIGHTBOX_COMMENTS_WIDTH_MIN,
Number.isFinite(Number(source.lightboxCommentsWidthPercent)) ? Number(source.lightboxCommentsWidthPercent) : LIGHTBOX_COMMENTS_WIDTH_DEFAULT
)
),
themeMode,
autoDarkModeEnabled: source.autoDarkModeEnabled === !0,
autoDarkModeStartTime,
fontRenderingEnabled: source.fontRenderingEnabled !== !1,
fontRenderingOnHost: source.fontRenderingOnHost === !0,
hostFontFamily: normalizeFontFamily(source.hostFontFamily, "system"),
hostFontCustomFamily: normalizeCustomFontFamily(source.hostFontCustomFamily),
hostFontWeight: normalizeFontWeight(source.hostFontWeight, 400),
hostFontColor: normalizeHexColor(source.hostFontColor),
hostEmbeddedTitleScale: roundedRange(
source.hostEmbeddedTitleScale,
READER_HOST_FONT_SCALE_DEFAULTS.title,
HOST_EMBED_SIZE_MIN,
HOST_EMBED_SIZE_MAX
),
hostEmbeddedAvatarScale: roundedRange(
source.hostEmbeddedAvatarScale,
READER_HOST_FONT_SCALE_DEFAULTS.avatar,
HOST_EMBED_SIZE_MIN,
HOST_EMBED_SIZE_MAX
),
hostEmbeddedStatsScale: roundedRange(
source.hostEmbeddedStatsScale,
READER_HOST_FONT_SCALE_DEFAULTS.stats,
HOST_EMBED_SIZE_MIN,
HOST_EMBED_SIZE_MAX
),
hostEmbeddedLabelCardScale: roundedRange(
source.hostEmbeddedLabelCardScale,
READER_HOST_FONT_SCALE_DEFAULTS.labelCard,
HOST_EMBED_SIZE_MIN,
HOST_EMBED_SIZE_MAX
),
fontProfile: normalizeFontProfile(source.fontProfile),
appearanceProfile: normalizeAppearanceProfile(source.appearanceProfile),
...performance,
requestFlowSettings: (0, import_reader_request_flow_config.normalizeReaderRequestFlowSettings)(
source.requestFlowSettings
),
businessRequestSettings: (0, import_reader_business_request_config.normalizeReaderBusinessRequestSettings)(
source.businessRequestSettings
),
hostTopicPreheatEnabled: source.hostTopicPreheatEnabled === !0,
hostTopicPreheatPostCount: roundedRange(
source.hostTopicPreheatPostCount,
HOST_TOPIC_PREHEAT_POST_COUNT_DEFAULT,
HOST_TOPIC_PREHEAT_POST_COUNT_MIN,
HOST_TOPIC_PREHEAT_POST_COUNT_MAX
),
performanceSuspendHostTurnstileInBackground: source.performanceSuspendHostTurnstileInBackground === !0,
layoutProfile: normalizeLayoutProfile(source.layoutProfile, READER_LAYOUT_DEFAULT),
fullpageLayoutProfile: normalizeLayoutProfile(
source.fullpageLayoutProfile,
READER_FULLPAGE_LAYOUT_DEFAULT
),
...windowPreferences,
listReaderMode,
listReaderEmbedWidth: Number.isFinite(listWidth) ? Math.max(READER_EMBED_MIN_WIDTH, Math.round(listWidth)) : defaults.listReaderEmbedWidth,
...composerPreferences,
historySortMode: source.historySortMode === "first-viewed" ? "first-viewed" : "recent-viewed",
bookmarkTabOrder: normalizeBookmarkTabOrder(source.bookmarkTabOrder),
historyButtonsAlwaysVisible: source.historyButtonsAlwaysVisible === !0,
readerQueueAlwaysVisibleWhenEmpty: source.readerQueueAlwaysVisibleWhenEmpty !== !1,
historyEdgeTriggerPercent: Number.isFinite(historyEdge) ? Math.min(
HISTORY_EDGE_TRIGGER_MAX,
Math.max(HISTORY_EDGE_TRIGGER_MIN, Math.round(historyEdge))
) : HISTORY_EDGE_TRIGGER_DEFAULT,
loadingAnimation,
translationMode,
translationTheme,
openTopicsAtFirstPost: source.openTopicsAtFirstPost !== !1,
doubleEscapeToCloseReader: source.doubleEscapeToCloseReader === !0,
confirmNativeComposerClose: source.confirmNativeComposerClose === !0,
readerShortcutBindings: normalizeReaderShortcutBindings(source.readerShortcutBindings),
topicActionRailVisible: source.topicActionRailVisible !== !1,
topicActionRailFixed: source.topicActionRailFixed === !0,
topicActionRailMode: source.topicActionRailMode === "collapsed" ? "collapsed" : "compact",
topicActionRailPositions: normalizeTopicActionRailPositions(
Object.hasOwn(value, "topicActionRailPositions") ? source.topicActionRailPositions : null,
value.topicActionRailPosition
),
unwantedTopicFilterEnabled: unwantedTopicFilter.enabled,
unwantedTopicFilterCategories: unwantedTopicFilter.categories,
unwantedTopicFilterLabels: unwantedTopicFilter.labels,
unwantedTopicFilterTopicAuthors: unwantedTopicFilter.topicAuthors,
unwantedTopicFilterTopicFields: unwantedTopicFilter.topicFields,
unwantedTopicFilterPostAuthors: unwantedTopicFilter.postAuthors,
expandNestedRepliesByDefault,
expandLeafNestedReplies,
aggregateDescendantReplies,
inlineReplyTreeMaxDepth: Math.min(
INLINE_REPLY_TREE_MAX_DEPTH,
Math.max(1, depth || INLINE_REPLY_TREE_DEFAULT_DEPTH)
),
hideNestedReplyFloors: source.hideNestedReplyFloors === !0,
jumpHighlightColor: /^#[0-9a-f]{6}$/.test(jumpColor) ? jumpColor : JUMP_HIGHLIGHT_DEFAULTS.color,
jumpHighlightRadius: normalizeJumpValue("radius", source.jumpHighlightRadius),
jumpHighlightBorderWidth: normalizeJumpValue(
"borderWidth",
source.jumpHighlightBorderWidth
),
jumpHighlightRate: normalizeJumpValue("rate", source.jumpHighlightRate),
jumpHighlightCount: normalizeJumpValue("count", source.jumpHighlightCount),
...boostPreferences
});
}
function createReaderPreferencesConfigCodec(options) {
const defaults = createReaderPreferencesDefaults(options.environment), normalize = (value) => normalizeReaderPreferences(value, options.environment), unwantedTopicFilterDefaults = Object.freeze({
unwantedTopicFilterEnabled: !1,
unwantedTopicFilterCategories: Object.freeze([]),
unwantedTopicFilterLabels: Object.freeze([]),
unwantedTopicFilterTopicAuthors: Object.freeze([]),
unwantedTopicFilterTopicFields: Object.freeze([]),
unwantedTopicFilterPostAuthors: Object.freeze([])
}), performanceRuntimeDefaults = Object.freeze({
performanceReadStateRequestsPerMinute: PERFORMANCE_PRESETS.balanced.readStateRequestsPerMinute,
performanceReadStateTimingsPerMinute: PERFORMANCE_PRESETS.balanced.readStateTimingsPerMinute,
requestFlowSettings: import_reader_request_flow_config.READER_REQUEST_FLOW_DEFAULTS,
businessRequestSettings: import_reader_business_request_config.READER_BUSINESS_REQUEST_DEFAULTS,
hostTopicPreheatEnabled: !1,
hostTopicPreheatPostCount: HOST_TOPIC_PREHEAT_POST_COUNT_DEFAULT,
performanceSuspendHostTurnstileInBackground: !1
});
return new import_preferences_config_codec.PreferencesConfigCodec({
format: READER_CONFIG_EXPORT_FORMAT,
schemaVersion: READER_CONFIG_EXPORT_VERSION,
scriptVersion: options.scriptVersion,
defaults,
normalize,
legacyImportRules: [
{
missingDefaults: performanceRuntimeDefaults
},
{
missingDefaults: {
...performanceRuntimeDefaults,
...unwantedTopicFilterDefaults
}
},
{
missingDefaults: {
...performanceRuntimeDefaults,
...unwantedTopicFilterDefaults,
autoDarkModeEnabled: !1,
autoDarkModeStartTime: "sunset"
}
},
{
missingDefaults: {
...performanceRuntimeDefaults,
...unwantedTopicFilterDefaults,
autoDarkModeEnabled: !1,
autoDarkModeStartTime: "sunset",
fullpageLayoutProfile: READER_FULLPAGE_LAYOUT_DEFAULT
}
},
{
missingDefaults: {
...performanceRuntimeDefaults,
...unwantedTopicFilterDefaults,
autoDarkModeEnabled: !1,
autoDarkModeStartTime: "sunset",
fullpageLayoutProfile: READER_FULLPAGE_LAYOUT_DEFAULT,
confirmNativeComposerClose: !0
}
},
{
missingDefaults: {
...performanceRuntimeDefaults,
...unwantedTopicFilterDefaults,
autoDarkModeEnabled: !1,
autoDarkModeStartTime: "sunset",
readerShortcutBindings: defaults.readerShortcutBindings,
topicActionRailMode: "compact",
inlineReplyTreeMaxDepth: 1
}
}
],
...options.now ? { now: options.now } : {}
});
}
function createReaderPreferencesRepository(options) {
const defaults = createReaderPreferencesDefaults(options.environment);
return new import_preferences_repository.PreferencesRepository({
key: READER_PREFERENCES_STORAGE_KEY,
storage: options.storage,
defaults,
normalize: (value) => normalizeReaderPreferences(value, options.environment),
prepareStored: prepareStoredReaderPreferences
});
}
}, "fee25a799df28ae7f4f28adc7e6d2097205276eb61764361b1731670931a310e");
/* Source: lite/src/state/reader-settings-config-manager.ts */
runtime.register("src/state/reader-settings-config-manager.js", function(module, exports, require) {
var reader_settings_config_manager_exports = {};
__export(reader_settings_config_manager_exports, {
READER_SETTINGS_CONFIG_EXPORT_VERSION: () => READER_SETTINGS_CONFIG_EXPORT_VERSION,
READER_SETTINGS_CONFIG_OMITTED_SECRETS: () => READER_SETTINGS_CONFIG_OMITTED_SECRETS,
ReaderSettingsConfigCodec: () => ReaderSettingsConfigCodec,
ReaderSettingsConfigManager: () => ReaderSettingsConfigManager
});
module.exports = __toCommonJS(reader_settings_config_manager_exports);
var import_reader_custom_site_repository = require("../site/reader-custom-site-repository.js"), import_reader_webdav_model = require("../sync/reader-webdav-model.js"), import_reader_translation_config = require("../translation/reader-translation-config.js"), import_reader_preferences_schema = require("./reader-preferences-schema.js");
const READER_SETTINGS_CONFIG_EXPORT_VERSION = 9, READER_SETTINGS_CONFIG_LEGACY_PORTABLE_VERSIONS = Object.freeze([6, 7, 8]), READER_SETTINGS_CONFIG_OMITTED_SECRETS = Object.freeze([
"translation.apiKey",
"webDav.username",
"webDav.password"
]);
function invalidConfig(cause) {
return cause === void 0 ? new Error("invalid_config") : new Error("invalid_config", { cause });
}
function record(value) {
if (!value || typeof value != "object" || Array.isArray(value))
throw invalidConfig();
return value;
}
function exactKeys(value, expected) {
const actual = Object.keys(value).sort(), canonical = [...expected].sort();
if (actual.length !== canonical.length || actual.some((key, index) => key !== canonical[index])) throw invalidConfig();
}
function portableTranslationProfile(profile) {
return Object.freeze({
baseUrl: profile.baseUrl,
models: profile.models,
modelCatalog: profile.modelCatalog,
model: profile.model,
prompt: profile.prompt,
temperature: profile.temperature,
reasoningEffort: profile.reasoningEffort,
requestsPerMinute: profile.requestsPerMinute,
tokensPerMinute: profile.tokensPerMinute,
animation: profile.animation
});
}
function portableTranslationConfig(value) {
const normalized = (0, import_reader_translation_config.normalizeReaderTranslationConfig)(value);
return Object.freeze({
profiles: Object.freeze(normalized.profiles.map(
portableTranslationProfile
)),
activeBaseUrl: normalized.activeBaseUrl
});
}
function parsePortableTranslationConfig(value, sourceVersion = READER_SETTINGS_CONFIG_EXPORT_VERSION) {
if (value === null) return null;
const source = record(value);
if (exactKeys(source, ["profiles", "activeBaseUrl"]), !Array.isArray(source.profiles) || !source.profiles.length)
throw invalidConfig();
const profiles = source.profiles.map((value2) => {
const profile = record(value2), hasModels = sourceVersion >= 8, hasModelCatalog = sourceVersion >= 9;
exactKeys(profile, [
"baseUrl",
...hasModels ? ["models"] : [],
...hasModelCatalog ? ["modelCatalog"] : [],
"model",
"prompt",
"temperature",
"reasoningEffort",
"requestsPerMinute",
"tokensPerMinute",
"animation"
]);
const parsed = (0, import_reader_translation_config.normalizeReaderTranslationConfig)({
profiles: [{ ...profile, apiKey: "" }],
activeBaseUrl: profile.baseUrl
}).profiles[0];
if (!parsed) throw invalidConfig();
const portable = portableTranslationProfile(parsed);
if (Object.entries(profile).some(([key, sourceValue]) => key !== "models" && key !== "modelCatalog" && sourceValue !== portable[key]) || hasModels && JSON.stringify(profile.models) !== JSON.stringify(portable.models) || hasModelCatalog && JSON.stringify(profile.modelCatalog) !== JSON.stringify(portable.modelCatalog))
throw invalidConfig();
return parsed;
});
if (new Set(profiles.map((profile) => profile.baseUrl)).size !== profiles.length)
throw invalidConfig();
const activeBaseUrl = (0, import_reader_translation_config.normalizeReaderTranslationBaseUrl)(source.activeBaseUrl);
if (activeBaseUrl !== source.activeBaseUrl || !profiles.some((profile) => profile.baseUrl === activeBaseUrl)) throw invalidConfig();
return (0, import_reader_translation_config.normalizeReaderTranslationConfig)({
profiles: Object.freeze(profiles),
activeBaseUrl
});
}
function portableWebDavConfig(value) {
const normalized = (0, import_reader_webdav_model.normalizeReaderWebDavConfig)(value);
return Object.freeze({
endpoint: normalized.endpoint,
remotePath: normalized.remotePath,
categories: Object.freeze({ ...normalized.categories }),
autoSyncEnabled: normalized.autoSyncEnabled,
autoSyncIntervalMinutes: normalized.autoSyncIntervalMinutes
});
}
function parsePortableWebDavConfig(value, sourceVersion = READER_SETTINGS_CONFIG_EXPORT_VERSION) {
if (value === null) return null;
const source = record(value);
exactKeys(source, [
"endpoint",
"remotePath",
"categories",
"autoSyncEnabled",
"autoSyncIntervalMinutes"
]);
const categories = record(source.categories), historyCategories = /* @__PURE__ */ new Set([
"notification-history",
"activity-history"
]), missesBothHistoryCategories = [...historyCategories].every(
(category) => !Object.hasOwn(categories, category)
), expectedCategories = sourceVersion === 6 ? import_reader_webdav_model.READER_WEBDAV_CATEGORIES.filter((category) => category !== "offline-topics" && !historyCategories.has(category)) : sourceVersion < READER_SETTINGS_CONFIG_EXPORT_VERSION && missesBothHistoryCategories ? import_reader_webdav_model.READER_WEBDAV_CATEGORIES.filter((category) => !historyCategories.has(category)) : import_reader_webdav_model.READER_WEBDAV_CATEGORIES;
if (exactKeys(categories, expectedCategories), Object.values(categories).some((selected) => typeof selected != "boolean"))
throw invalidConfig();
const normalizedCategories = (0, import_reader_webdav_model.createReaderWebDavCategorySelection)(categories), normalized = (0, import_reader_webdav_model.normalizeReaderWebDavConfig)({
...source,
categories: normalizedCategories,
username: "",
password: ""
});
if ((0, import_reader_webdav_model.validateReaderWebDavConfig)(normalized, {
requireCredentials: !1
}).length) throw invalidConfig();
const portable = portableWebDavConfig(normalized);
if (portable.endpoint !== source.endpoint || portable.remotePath !== source.remotePath || portable.autoSyncEnabled !== source.autoSyncEnabled || portable.autoSyncIntervalMinutes !== source.autoSyncIntervalMinutes || import_reader_webdav_model.READER_WEBDAV_CATEGORIES.some((category) => portable.categories[category] !== normalizedCategories[category])) throw invalidConfig();
return normalized;
}
function parseCustomSites(value) {
if (!Array.isArray(value)) throw invalidConfig();
const sites = value.map((entry) => {
if (typeof entry != "string") throw invalidConfig();
const normalized = (0, import_reader_custom_site_repository.normalizeReaderCustomSiteHost)(entry);
if (!normalized || normalized !== entry || (0, import_reader_custom_site_repository.readerBuiltinDiscourseHost)(normalized)) throw invalidConfig();
return normalized;
});
if (new Set(sites).size !== sites.length) throw invalidConfig();
return Object.freeze([...sites].sort());
}
class ReaderSettingsConfigCodec {
#preferences;
constructor(preferences) {
this.#preferences = preferences;
}
export(input) {
const preferences = this.#preferences.export(input.preferences);
return Object.freeze({
...preferences,
schemaVersion: READER_SETTINGS_CONFIG_EXPORT_VERSION,
customSites: parseCustomSites(input.customSites),
translation: input.translation ? portableTranslationConfig(input.translation) : null,
webDav: input.webDav ? portableWebDavConfig(input.webDav) : null,
omittedSecrets: READER_SETTINGS_CONFIG_OMITTED_SECRETS
});
}
import(payload) {
try {
return this.#import(payload);
} catch (cause) {
throw cause instanceof Error && cause.message === "invalid_config" ? cause : invalidConfig(cause);
}
}
#import(payload) {
const source = record(payload), schemaVersion = source.schemaVersion;
if (typeof schemaVersion != "number" || !Number.isSafeInteger(schemaVersion)) throw invalidConfig();
if (schemaVersion === import_reader_preferences_schema.READER_CONFIG_EXPORT_VERSION) {
const preferences2 = this.#preferences.import(payload);
return Object.freeze({
sourceVersion: schemaVersion,
settingsCount: source.settingsCount,
preferences: preferences2,
includesPortableSections: !1,
customSites: null,
translation: null,
webDav: null
});
}
if (schemaVersion !== READER_SETTINGS_CONFIG_EXPORT_VERSION && !READER_SETTINGS_CONFIG_LEGACY_PORTABLE_VERSIONS.includes(schemaVersion) || (exactKeys(source, [
"format",
"schemaVersion",
"scriptVersion",
"exportedAt",
"settingsCount",
"settings",
"customSites",
"translation",
"webDav",
"omittedSecrets"
]), source.format !== import_reader_preferences_schema.READER_CONFIG_EXPORT_FORMAT || !Array.isArray(source.omittedSecrets) || source.omittedSecrets.length !== READER_SETTINGS_CONFIG_OMITTED_SECRETS.length || source.omittedSecrets.some((key, index) => key !== READER_SETTINGS_CONFIG_OMITTED_SECRETS[index]))) throw invalidConfig();
const preferences = this.#preferences.import({
format: source.format,
schemaVersion: import_reader_preferences_schema.READER_CONFIG_EXPORT_VERSION,
scriptVersion: source.scriptVersion,
exportedAt: source.exportedAt,
settingsCount: source.settingsCount,
settings: source.settings
});
return Object.freeze({
sourceVersion: schemaVersion,
settingsCount: source.settingsCount,
preferences,
includesPortableSections: !0,
customSites: parseCustomSites(source.customSites),
translation: parsePortableTranslationConfig(
source.translation,
schemaVersion
),
webDav: parsePortableWebDavConfig(
source.webDav,
schemaVersion
)
});
}
}
class ReaderSettingsConfigManager {
#codec;
#defaults;
#preferences;
#customSites;
#translation;
#webDav;
#prepareResetPreferences;
constructor(options) {
this.#codec = options.codec, this.#defaults = options.defaults, this.#preferences = options.preferences, this.#customSites = options.customSites, this.#translation = options.translation, this.#webDav = options.webDav, this.#prepareResetPreferences = options.prepareResetPreferences ?? null;
}
async export() {
return await Promise.all([
this.#customSites.load(),
this.#translation?.load(),
this.#webDav?.load()
]), this.#codec.export({
preferences: this.#preferences.read(),
customSites: this.#customSites.snapshot,
translation: this.#translation?.snapshot.config ?? null,
webDav: this.#webDav?.snapshot.config ?? null
});
}
prepare(payload) {
return this.#codec.import(payload);
}
apply(prepared) {
return this.#apply(prepared, !0);
}
reset() {
const preferences = this.#prepareResetPreferences?.(
this.#defaults,
this.#preferences.read()
) ?? this.#defaults;
return this.#apply(Object.freeze({
sourceVersion: READER_SETTINGS_CONFIG_EXPORT_VERSION,
settingsCount: Object.keys(this.#defaults).length,
preferences,
includesPortableSections: !0,
customSites: Object.freeze([]),
translation: (0, import_reader_translation_config.createReaderTranslationDefaultConfig)(),
webDav: (0, import_reader_webdav_model.createReaderWebDavDefaultConfig)()
}), !1);
}
async #apply(prepared, preserveLocalSecrets) {
const rollbacks = [], skippedSections = [];
let customSitesApplied = !1, translationApplied = !1, webDavApplied = !1, preservedTranslationApiKeys = 0, preservedWebDavCredentials = !1, webDavAutoSyncDisabled = !1;
try {
if (prepared.includesPortableSections && prepared.customSites)
if (this.#customSites.writable) {
const previous = await this.#customSites.load();
await this.#customSites.replaceExternal(prepared.customSites), rollbacks.unshift(async () => {
await this.#customSites.replaceExternal(previous);
}), customSitesApplied = !0;
} else
skippedSections.push("customSites");
if (prepared.includesPortableSections && prepared.translation)
if (this.#translation) {
const previous = (await this.#translation.load()).config, apiKeys = new Map(previous.profiles.map((profile) => [
profile.baseUrl,
profile.apiKey
])), next = Object.freeze({
...prepared.translation,
profiles: Object.freeze(prepared.translation.profiles.map(
(profile) => {
const apiKey = preserveLocalSecrets ? apiKeys.get(profile.baseUrl) ?? "" : "";
return apiKey && (preservedTranslationApiKeys += 1), Object.freeze({ ...profile, apiKey });
}
))
});
await this.#translation.saveConfig(next), rollbacks.unshift(async () => {
await this.#translation.saveConfig(previous);
}), translationApplied = !0;
} else
skippedSections.push("translation");
if (prepared.includesPortableSections && prepared.webDav)
if (this.#webDav) {
const previous = (await this.#webDav.load()).config, sameEndpoint = previous.endpoint === prepared.webDav.endpoint, username = preserveLocalSecrets && sameEndpoint ? previous.username : "", password = preserveLocalSecrets && sameEndpoint ? previous.password : "";
preservedWebDavCredentials = !!(username && password), webDavAutoSyncDisabled = prepared.webDav.autoSyncEnabled && !preservedWebDavCredentials;
const next = Object.freeze({
...prepared.webDav,
username,
password,
autoSyncEnabled: prepared.webDav.autoSyncEnabled && preservedWebDavCredentials
});
await this.#webDav.saveConfig(next), rollbacks.unshift(async () => {
await this.#webDav.saveConfig(previous);
}), webDavApplied = !0;
} else
skippedSections.push("webDav");
const previousPreferences = this.#preferences.read();
return await this.#preferences.update(prepared.preferences), rollbacks.unshift(async () => {
await this.#preferences.update(previousPreferences);
}), Object.freeze({
sourceVersion: prepared.sourceVersion,
settingsCount: prepared.settingsCount,
customSitesApplied,
translationApplied,
webDavApplied,
preservedTranslationApiKeys,
preservedWebDavCredentials,
webDavAutoSyncDisabled,
skippedSections: Object.freeze(skippedSections)
});
} catch (cause) {
const rollbackFailures = [];
for (const rollback of rollbacks)
try {
await rollback();
} catch (rollbackCause) {
rollbackFailures.push(rollbackCause);
}
throw rollbackFailures.length ? new Error("设置配置写入失败且回滚不完整", {
cause: new AggregateError([cause, ...rollbackFailures])
}) : cause;
}
}
}
}, "2a58b4ce6474ec760923ac4782e2ed1c0e922b62c91bd8bbd6ca15335d153d4d");
/* Source: lite/src/state/reader-topic-state-projection.ts */
runtime.register("src/state/reader-topic-state-projection.js", function(module, exports, require) {
var reader_topic_state_projection_exports = {};
__export(reader_topic_state_projection_exports, {
ReaderTopicStateProjection: () => ReaderTopicStateProjection
});
module.exports = __toCommonJS(reader_topic_state_projection_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js");
class ReaderTopicStateProjection {
scope;
changes = new import_signal.Signal();
#history;
#unwanted;
#schedule;
#onError;
#snapshot = Object.freeze({
records: Object.freeze([]),
historyRevision: 0,
unwantedRevision: 0,
revision: 0
});
#scheduled = !1;
#externalReload = null;
constructor(options) {
this.#history = options.history, this.#unwanted = options.unwanted, this.#schedule = options.schedule ?? queueMicrotask, this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const enqueue = () => this.#enqueue();
this.#history.changes.subscribe(enqueue, this.scope), this.#unwanted.changes.subscribe(enqueue, this.scope), this.#commit(), this.scope.add(() => {
this.#scheduled = !1, this.#externalReload = null, this.changes.clear();
});
}
get snapshot() {
return this.#snapshot;
}
state(topicIdValue) {
const topicId = (0, import_identifiers.discourseTopicId)(topicIdValue);
return this.#snapshot.records.find((entry) => entry.topicId === topicId) ?? null;
}
isManuallyHidden(topicIdValue) {
return this.state(topicIdValue)?.manuallyHidden === !0;
}
reloadExternal() {
return this.#externalReload ? this.#externalReload : (this.#externalReload = new Promise((resolve) => {
this.#schedule(() => {
if (this.scope.destroyed) {
resolve(this.#snapshot);
return;
}
this.#history.reloadExternal(), this.#unwanted.reloadExternal(), this.#scheduled = !1;
const snapshot = this.#commit();
this.#externalReload = null, resolve(snapshot);
});
}), this.#externalReload);
}
destroy() {
this.scope.destroy();
}
#enqueue() {
this.scope.destroyed || this.#scheduled || this.#externalReload || (this.#scheduled = !0, this.#schedule(() => {
this.#scheduled = !1, this.scope.destroyed || this.#commit();
}));
}
#commit() {
const records = /* @__PURE__ */ new Map();
for (const history of this.#history.snapshot.entries)
records.set(history.topicId, Object.freeze({
topicId: history.topicId,
history,
unwanted: null,
manuallyHidden: !1,
automaticallyMatched: !1
}));
for (const unwanted of this.#unwanted.snapshot.records) {
const current = records.get(unwanted.topicId);
records.set(unwanted.topicId, Object.freeze({
topicId: unwanted.topicId,
history: current?.history ?? null,
unwanted,
manuallyHidden: unwanted.source === "manual",
automaticallyMatched: unwanted.source === "automatic"
}));
}
const snapshot = Object.freeze({
records: Object.freeze([...records.values()].sort(
(left, right) => Number(left.topicId) - Number(right.topicId)
)),
historyRevision: this.#history.snapshot.revision,
unwantedRevision: this.#unwanted.snapshot.revision,
revision: this.#snapshot.revision + 1
});
this.#snapshot = snapshot;
for (const cause of this.changes.emit(snapshot))
this.#onError(cause);
return snapshot;
}
}
}, "a6eb09114c5c3372cce192544d24a08d54e8370fa5a5f1b20689df3ea6a585b8");
/* Source: lite/src/stream/reply-tree-viewport-layout.ts */
runtime.register("src/stream/reply-tree-viewport-layout.js", function(module, exports, require) {
var reply_tree_viewport_layout_exports = {};
__export(reply_tree_viewport_layout_exports, {
ReplyTreeViewportLayout: () => ReplyTreeViewportLayout
});
module.exports = __toCommonJS(reply_tree_viewport_layout_exports);
function lowerBoundEndingAfter(count, endAt, offset) {
let low = 0, high = count;
for (; low < high; ) {
const middle = Math.floor((low + high) / 2);
endAt(middle) <= offset ? low = middle + 1 : high = middle;
}
return low;
}
function lowerBoundStartingAtOrAfter(count, startAt, offset) {
let low = 0, high = count;
for (; low < high; ) {
const middle = Math.floor((low + high) / 2);
startAt(middle) < offset ? low = middle + 1 : high = middle;
}
return low;
}
class ReplyTreeViewportLayout {
topology;
rootLayout;
#estimatedPostSize;
#measuredOwnSizes = /* @__PURE__ */ new Map();
#revision = "";
#branches = /* @__PURE__ */ new Map();
#visiblePostNumbers = Object.freeze([]);
constructor(topology, rootLayout, estimatedPostSize) {
if (!Number.isFinite(estimatedPostSize) || estimatedPostSize <= 0)
throw new RangeError("estimatedPostSize 必须是正有限数值");
this.topology = topology, this.rootLayout = rootLayout, this.#estimatedPostSize = estimatedPostSize;
}
/**
* 记录已完整投影节点自身的真实高度,不包含它的 replyList 子树。
*
* 节点退出正文窗口后,祖先壳和虚拟 spacer 必须继续占用相同高度;否则大正文会
* 在真实高度与固定估算之间反复切换,浏览器锚定补偿又会被误当成新的窗口输入。
*/
measureOwnSize(postNumber, blockSize) {
this.#syncRevision();
const normalized = Math.round(Number(blockSize));
if (!Number.isFinite(normalized) || normalized <= 0 || (this.#measuredOwnSizes.get(postNumber) ?? this.#estimatedPostSize) === normalized) return !1;
this.#measuredOwnSizes.set(postNumber, normalized);
const rootPostNumber = this.topology.rootOf(postNumber), branch = rootPostNumber === void 0 ? void 0 : this.#branches.get(rootPostNumber), index = branch?.indexByPost.get(postNumber);
return branch && index !== void 0 && (branch.prefixDirtyFrom = Math.min(branch.prefixDirtyFrom, index)), !0;
}
/** 当前计划中真正与物理视口相交的 DFS 节点,供锚点读取缩小几何查询范围。 */
get visiblePostNumbers() {
return this.#visiblePostNumbers;
}
plan(rootWindow, input) {
this.#syncRevision();
const materializationStep = input.viewportSize * Math.max(0, input.materializationStepScreens ?? 0), materializationStart = materializationStep > 0 ? Math.floor(input.scrollOffset / materializationStep) * materializationStep : input.scrollOffset, overscanStart = Math.max(
0,
materializationStart - input.viewportSize * (input.overscanBeforeScreens ?? 1)
), overscanEnd = materializationStart + materializationStep + input.viewportSize * (1 + (input.overscanAfterScreens ?? 1)), visibleStart = input.scrollOffset, visibleEnd = input.scrollOffset + input.viewportSize, candidates = [];
for (const rootPostNumber of rootWindow.postNumbers) {
const branch = this.#branch(rootPostNumber), branchPrefix = this.#branchPrefix(branch), rootStart = this.rootLayout.offsetOf(rootPostNumber), rootSize = this.rootLayout.blockSizeOf(rootPostNumber);
if (rootStart === void 0 || rootSize === void 0 || branch.entries.length === 0) continue;
const branchSize = branchPrefix.at(-1) ?? 0;
if (overscanEnd <= rootStart || overscanStart >= rootStart + rootSize) continue;
const localStart = Math.min(
Math.max(0, branchSize - 1),
Math.max(0, overscanStart - rootStart)
), localEnd = Math.min(
branchSize,
Math.max(localStart + 1, overscanEnd - rootStart)
), startIndex = lowerBoundEndingAfter(
branch.entries.length,
(index) => branchPrefix[index + 1] ?? 0,
localStart
), endIndex = lowerBoundStartingAtOrAfter(
branch.entries.length,
(index) => branchPrefix[index] ?? 0,
localEnd
);
for (let index = startIndex; index < Math.max(startIndex + 1, endIndex) && index < branch.entries.length; index += 1)
candidates.push(Object.freeze({
branch,
entryIndex: index,
absoluteStart: rootStart + (branchPrefix[index] ?? 0),
absoluteEnd: rootStart + (branchPrefix[index + 1] ?? 0)
}));
}
this.#visiblePostNumbers = Object.freeze([
...new Set(candidates.filter(
(candidate) => candidate.absoluteEnd > visibleStart && candidate.absoluteStart < visibleEnd
).map(
(candidate) => candidate.branch.entries[candidate.entryIndex].postNumber
))
]);
const content = new Set(this.#budget(
candidates,
visibleStart,
visibleEnd,
input.maxMountedPostCount
)), preservePostNumber = input.preservePostNumber;
if (preservePostNumber !== void 0) {
const preserveRootPostNumber = this.topology.rootOf(preservePostNumber);
preserveRootPostNumber !== void 0 && rootWindow.postNumbers.includes(preserveRootPostNumber) && content.add(preservePostNumber);
}
const mounted = new Set(content);
for (const postNumber of content) {
let parentPostNumber = this.topology.parentOf(postNumber);
for (; parentPostNumber != null && !mounted.has(parentPostNumber); )
mounted.add(parentPostNumber), parentPostNumber = this.topology.parentOf(parentPostNumber);
}
const shells = new Set(
[...mounted].filter((postNumber) => !content.has(postNumber))
), ownSizes = /* @__PURE__ */ new Map(), mountedChildrenByParent = /* @__PURE__ */ new Map();
for (const postNumber of mounted) {
const parentPostNumber = this.topology.parentOf(postNumber);
if (parentPostNumber == null || !mounted.has(parentPostNumber)) continue;
const children = mountedChildrenByParent.get(parentPostNumber) ?? [];
children.push(postNumber), mountedChildrenByParent.set(parentPostNumber, children);
}
const childLayouts = /* @__PURE__ */ new Map();
for (const postNumber of mounted) {
const rootPostNumber = this.topology.rootOf(postNumber);
if (rootPostNumber === void 0) continue;
const branch = this.#branch(rootPostNumber), branchPrefix = this.#branchPrefix(branch), index = branch.indexByPost.get(postNumber), rootSize = this.rootLayout.blockSizeOf(rootPostNumber);
if (index === void 0 || rootSize === void 0) continue;
ownSizes.set(postNumber, this.#ownSize(postNumber));
const parentEntry = branch.entries[index], mountedChildren = mountedChildrenByParent.get(postNumber) ?? [];
mountedChildren.sort(
(left, right) => (branch.indexByPost.get(left) ?? 0) - (branch.indexByPost.get(right) ?? 0)
);
const beforeSizes = [];
let cursor = index + 1;
for (const childPostNumber of mountedChildren) {
const childIndex = branch.indexByPost.get(childPostNumber);
childIndex !== void 0 && (beforeSizes.push(Math.max(
0,
(branchPrefix[childIndex] ?? 0) - (branchPrefix[cursor] ?? 0)
)), cursor = branch.entries[childIndex].subtreeEndIndex);
}
childLayouts.set(postNumber, Object.freeze({
postNumbers: Object.freeze([...mountedChildren]),
beforeSizes: Object.freeze(beforeSizes),
afterSize: Math.max(
0,
(branchPrefix[parentEntry.subtreeEndIndex] ?? 0) - (branchPrefix[cursor] ?? 0)
)
}));
}
const rootVirtualInsets = this.#externalizeWindowEdgeSpacers(
rootWindow,
content,
shells,
ownSizes,
childLayouts
);
return Object.freeze({
mountedPostNumbers: mounted,
contentPostNumbers: content,
shellPostNumbers: shells,
ownSizes,
rootVirtualInsets,
childLayouts
});
}
/**
* 窗口首尾之外的 DFS 高度属于流级占位,不应留在递归 replyList 内穿过视口。
* 这里只改占位的归属;正文集合、祖先闭包和半屏物化分段均保持不变。
*/
#externalizeWindowEdgeSpacers(rootWindow, content, shells, ownSizes, childLayouts) {
const firstRoot = rootWindow.postNumbers[0], lastRoot = rootWindow.postNumbers.at(-1), edgeRoots = new Set(
[firstRoot, lastRoot].filter(
(postNumber) => postNumber !== void 0
)
), insets = /* @__PURE__ */ new Map();
for (const rootPostNumber of edgeRoots) {
const branch = this.#branch(rootPostNumber);
let firstIndex = Number.POSITIVE_INFINITY, lastIndex = -1;
for (const postNumber of content) {
const index = branch.indexByPost.get(postNumber);
index !== void 0 && (firstIndex = Math.min(firstIndex, index), lastIndex = Math.max(lastIndex, index));
}
if (lastIndex < 0) continue;
const prefix = this.#branchPrefix(branch), beforeSize = rootPostNumber === firstRoot ? prefix[firstIndex] ?? 0 : 0, afterSize = rootPostNumber === lastRoot ? Math.max(
0,
(prefix.at(-1) ?? 0) - (prefix[lastIndex + 1] ?? 0)
) : 0;
beforeSize > 0 && this.#trimLeadingPath(
branch,
firstIndex,
shells,
ownSizes,
childLayouts
), afterSize > 0 && this.#trimTrailingPath(branch, lastIndex, childLayouts), (beforeSize > 0 || afterSize > 0) && insets.set(rootPostNumber, Object.freeze({ beforeSize, afterSize }));
}
return insets;
}
#trimLeadingPath(branch, entryIndex, shells, ownSizes, childLayouts) {
const path = this.#entryPath(branch, entryIndex);
for (let index = 0; index + 1 < path.length; index += 1) {
const parentPostNumber = path[index], childPostNumber = path[index + 1];
shells.has(parentPostNumber) && ownSizes.set(parentPostNumber, 0);
const layout = childLayouts.get(parentPostNumber), childIndex = layout?.postNumbers.indexOf(childPostNumber) ?? -1;
if (!layout || childIndex < 0 || layout.beforeSizes[childIndex] === 0)
continue;
const beforeSizes = [...layout.beforeSizes];
beforeSizes[childIndex] = 0, childLayouts.set(parentPostNumber, Object.freeze({
...layout,
beforeSizes: Object.freeze(beforeSizes)
}));
}
}
#trimTrailingPath(branch, entryIndex, childLayouts) {
for (const postNumber of this.#entryPath(branch, entryIndex)) {
const layout = childLayouts.get(postNumber);
!layout || layout.afterSize === 0 || childLayouts.set(postNumber, Object.freeze({
...layout,
afterSize: 0
}));
}
}
#entryPath(branch, entryIndex) {
const path = [];
let entry = branch.entries[entryIndex];
for (; entry && (path.push(entry.postNumber), entry.parentPostNumber !== null); ) {
const parentIndex = branch.indexByPost.get(entry.parentPostNumber);
entry = parentIndex === void 0 ? void 0 : branch.entries[parentIndex];
}
return Object.freeze(path.reverse());
}
offsetOf(postNumber) {
this.#syncRevision();
const rootPostNumber = this.topology.rootOf(postNumber);
if (rootPostNumber === void 0) return;
const rootOffset = this.rootLayout.offsetOf(rootPostNumber), rootSize = this.rootLayout.blockSizeOf(rootPostNumber);
if (rootOffset === void 0 || rootSize === void 0) return;
const branch = this.#branch(rootPostNumber), branchPrefix = this.#branchPrefix(branch), index = branch.indexByPost.get(postNumber);
if (!(index === void 0 || !branch.entries.length))
return rootOffset + Math.min(
branchPrefix[index] ?? 0,
Math.max(0, rootSize - 1)
);
}
#budget(candidates, visibleStart, visibleEnd, rawBudget) {
if (!candidates.length) return /* @__PURE__ */ new Set();
const visibleIndexes = [];
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
candidate.absoluteEnd > visibleStart && candidate.absoluteStart < visibleEnd && visibleIndexes.push(index);
}
const firstVisible = visibleIndexes[0] ?? 0, lastVisible = visibleIndexes.at(-1) ?? firstVisible, budget = rawBudget === void 0 ? candidates.length : Math.max(rawBudget, lastVisible - firstVisible + 1);
let start = firstVisible, end = lastVisible + 1;
for (; end - start < budget && (start > 0 || end < candidates.length); ) {
const beforeDistance = start > 0 ? Math.max(0, visibleStart - candidates[start - 1].absoluteEnd) : Number.POSITIVE_INFINITY, afterDistance = end < candidates.length ? Math.max(0, candidates[end].absoluteStart - visibleEnd) : Number.POSITIVE_INFINITY;
if (beforeDistance <= afterDistance && start > 0) start -= 1;
else if (end < candidates.length) end += 1;
else break;
}
return new Set(
candidates.slice(start, end).map(
(candidate) => candidate.branch.entries[candidate.entryIndex].postNumber
)
);
}
#syncRevision() {
this.#revision !== this.topology.revision && (this.#revision = this.topology.revision, this.#branches.clear(), this.#visiblePostNumbers = Object.freeze([]));
}
#branch(rootPostNumber) {
const cached = this.#branches.get(rootPostNumber);
if (cached) return cached;
const entries = [], indexByPost = /* @__PURE__ */ new Map(), stack = [{
postNumber: rootPostNumber,
parentPostNumber: null,
depth: 0,
closing: !1
}];
for (; stack.length; ) {
const current = stack.pop();
if (current.closing) {
const index2 = indexByPost.get(current.postNumber);
index2 !== void 0 && (entries[index2].subtreeEndIndex = entries.length);
continue;
}
const index = entries.length;
indexByPost.set(current.postNumber, index), entries.push({
postNumber: current.postNumber,
parentPostNumber: current.parentPostNumber,
depth: current.depth,
subtreeEndIndex: index + 1
}), stack.push({ ...current, closing: !0 });
const children = this.topology.childrenOf(current.postNumber);
for (let childIndex = children.length - 1; childIndex >= 0; childIndex -= 1)
stack.push({
postNumber: children[childIndex],
parentPostNumber: current.postNumber,
depth: current.depth + 1,
closing: !1
});
}
const projection = {
entries: Object.freeze(entries),
indexByPost,
prefix: new Array(entries.length + 1).fill(0),
prefixDirtyFrom: 0
};
return this.#branches.set(rootPostNumber, projection), projection;
}
#ownSize(postNumber) {
return this.#measuredOwnSizes.get(postNumber) ?? this.#estimatedPostSize;
}
#branchPrefix(branch) {
for (let index = branch.prefixDirtyFrom; index < branch.entries.length; index += 1)
branch.prefix[index + 1] = (branch.prefix[index] ?? 0) + this.#ownSize(branch.entries[index].postNumber);
return branch.prefixDirtyFrom = branch.entries.length, branch.prefix;
}
}
}, "a5c984546f0ef4ebd63fd285cb274678fb101407c6aa63f87f9104deff2e0efc");
/* Source: lite/src/stream/reply-tree-virtual-layout-controller.ts */
runtime.register("src/stream/reply-tree-virtual-layout-controller.js", function(module, exports, require) {
var reply_tree_virtual_layout_controller_exports = {};
__export(reply_tree_virtual_layout_controller_exports, {
ReplyTreeVirtualLayoutController: () => ReplyTreeVirtualLayoutController
});
module.exports = __toCommonJS(reply_tree_virtual_layout_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
class ReplyTreeVirtualLayoutController {
repository;
topology;
layout;
scope;
#rootBranches = null;
constructor(repository, layout, parentScope, topology = repository.topology) {
this.repository = repository, this.topology = topology, this.layout = layout, this.scope = import_lifecycle.LifecycleScope.ownedBy(parentScope), this.syncRoots(), repository.changes.subscribe(() => this.syncRoots(), this.scope);
}
syncRoots() {
const rootBranches = this.topology.rootBranches();
rootBranches !== this.#rootBranches && (this.#rootBranches = rootBranches, this.layout.setRoots(rootBranches));
}
destroy() {
this.scope.destroy();
}
}
}, "02fe389ab682c83811aa4b0ed77520be73670dd237cf833edf11c9c2f25ac4e7");
/* Source: lite/src/stream/virtual-root-layout.ts */
runtime.register("src/stream/virtual-root-layout.js", function(module, exports, require) {
var virtual_root_layout_exports = {};
__export(virtual_root_layout_exports, {
VirtualRootLayout: () => VirtualRootLayout
});
module.exports = __toCommonJS(virtual_root_layout_exports);
var import_identifiers = require("../discourse/identifiers.js");
function finiteNonNegative(value, name) {
if (!Number.isFinite(value) || value < 0)
throw new RangeError(`${name} 必须是非负有限数值`);
return value;
}
function positiveSize(value, name) {
if (!Number.isFinite(value) || value <= 0)
throw new RangeError(`${name} 必须是正有限数值`);
return value;
}
function assertPostNumber(value) {
try {
(0, import_identifiers.discoursePostNumber)(value);
} catch {
throw new RangeError("根楼层号必须是正安全整数");
}
}
function positiveSafeInteger(value, name) {
if (!Number.isSafeInteger(value) || value <= 0)
throw new RangeError(`${name} 必须是正安全整数`);
return value;
}
function nonNegativeSafeInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 0)
throw new RangeError(`${name} 必须是非负安全整数`);
return value;
}
class VirtualRootLayout {
#estimatedSize;
#estimateSubtreeSize;
#measuredSizes = /* @__PURE__ */ new Map();
#postNumbers = [];
#subtreePostCounts = [];
#unloadedPostCountsBefore = [];
#segmentStartByIndex = [];
#segmentEndByIndex = [];
#indexByPost = /* @__PURE__ */ new Map();
#prefix = [0];
#subtreeCountPrefix = [0];
#dirtyFrom = 0;
constructor(estimatedSize, estimateSubtreeSize = !1) {
this.#estimatedSize = positiveSize(estimatedSize, "estimatedSize"), this.#estimateSubtreeSize = estimateSubtreeSize;
}
setRoots(roots) {
const previousSubtreePostCountByPost = new Map(
this.#postNumbers.map((postNumber, index) => [
postNumber,
this.#subtreePostCounts[index] ?? 1
])
), unique = /* @__PURE__ */ new Set(), subtreePostCountByPost = /* @__PURE__ */ new Map(), unloadedPostCountBeforeByPost = /* @__PURE__ */ new Map();
for (const root of roots) {
const postNumber = typeof root == "number" ? root : root.postNumber;
if (assertPostNumber(postNumber), unique.has(postNumber)) throw new Error(`根楼层 #${postNumber} 重复`);
unique.add(postNumber), subtreePostCountByPost.set(
postNumber,
typeof root == "number" ? 1 : positiveSafeInteger(root.subtreePostCount, "subtreePostCount")
);
const unloadedPostCountBefore = typeof root == "number" ? 0 : nonNegativeSafeInteger(
root.unloadedPostCountBefore ?? 0,
"unloadedPostCountBefore"
);
if (unloadedPostCountBefore >= postNumber)
throw new RangeError(
"unloadedPostCountBefore 必须小于当前根楼层号"
);
unloadedPostCountBeforeByPost.set(
postNumber,
unloadedPostCountBefore
);
}
this.#postNumbers = [...unique].sort((left, right) => left - right), this.#subtreePostCounts = this.#postNumbers.map(
(postNumber) => subtreePostCountByPost.get(postNumber) ?? 1
), this.#unloadedPostCountsBefore = this.#postNumbers.map(
(postNumber) => unloadedPostCountBeforeByPost.get(postNumber) ?? 0
), this.#segmentStartByIndex = new Array(this.#postNumbers.length);
let segmentStart = 0;
for (let index = 0; index < this.#postNumbers.length; index += 1)
(this.#unloadedPostCountsBefore[index] ?? 0) > 0 && (segmentStart = index), this.#segmentStartByIndex[index] = segmentStart;
this.#segmentEndByIndex = new Array(this.#postNumbers.length);
let segmentEnd = this.#postNumbers.length;
for (let index = this.#postNumbers.length - 1; index >= 0; index -= 1)
index + 1 < this.#postNumbers.length && (this.#unloadedPostCountsBefore[index + 1] ?? 0) > 0 && (segmentEnd = index + 1), this.#segmentEndByIndex[index] = segmentEnd;
for (const postNumber of this.#measuredSizes.keys()) {
const subtreePostCountChanged = this.#estimateSubtreeSize && previousSubtreePostCountByPost.get(postNumber) !== subtreePostCountByPost.get(postNumber);
(!unique.has(postNumber) || subtreePostCountChanged) && this.#measuredSizes.delete(postNumber);
}
this.#indexByPost = new Map(
this.#postNumbers.map((postNumber, index) => [postNumber, index])
), this.#prefix = new Array(this.#postNumbers.length + 1).fill(0), this.#subtreeCountPrefix = new Array(this.#postNumbers.length + 1).fill(0);
for (let index = 0; index < this.#postNumbers.length; index += 1)
this.#subtreeCountPrefix[index + 1] = (this.#subtreeCountPrefix[index] ?? 0) + (this.#subtreePostCounts[index] ?? 1);
this.#dirtyFrom = 0;
}
roots() {
return Object.freeze([...this.#postNumbers]);
}
offsetOf(postNumber) {
assertPostNumber(postNumber);
const index = this.#indexByPost.get(postNumber);
if (index !== void 0)
return this.#ensurePrefix(), this.#prefix[index] ?? 0;
}
blockSizeOf(postNumber) {
assertPostNumber(postNumber);
const index = this.#indexByPost.get(postNumber);
return index === void 0 ? void 0 : this.#sizeAt(index);
}
measure(postNumber, blockSize, anchorPostNumber) {
assertPostNumber(postNumber);
const normalizedSize = positiveSize(blockSize, "blockSize"), index = this.#indexByPost.get(postNumber);
if (index === void 0)
return Object.freeze({ changed: !1, sizeDelta: 0, scrollCompensation: 0 });
const previousSize = this.#sizeAt(index);
if (previousSize === normalizedSize)
return Object.freeze({ changed: !1, sizeDelta: 0, scrollCompensation: 0 });
this.#measuredSizes.set(postNumber, normalizedSize), this.#dirtyFrom = Math.min(this.#dirtyFrom, index);
const sizeDelta = normalizedSize - previousSize, anchorIndex = anchorPostNumber === void 0 ? void 0 : this.#indexByPost.get(anchorPostNumber);
return Object.freeze({
changed: !0,
sizeDelta,
scrollCompensation: anchorIndex !== void 0 && index < anchorIndex ? sizeDelta : 0
});
}
window(input) {
const scrollOffset = finiteNonNegative(input.scrollOffset, "scrollOffset"), viewportSize = positiveSize(input.viewportSize, "viewportSize"), beforeScreens = finiteNonNegative(
input.overscanBeforeScreens ?? 1,
"overscanBeforeScreens"
), afterScreens = finiteNonNegative(
input.overscanAfterScreens ?? 1,
"overscanAfterScreens"
), materializationStepScreens = finiteNonNegative(
input.materializationStepScreens ?? 0,
"materializationStepScreens"
), maxMountedPostCount = input.maxMountedPostCount === void 0 ? void 0 : positiveSafeInteger(
input.maxMountedPostCount,
"maxMountedPostCount"
);
this.#ensurePrefix();
const totalSize = this.#prefix.at(-1) ?? 0;
if (!this.#postNumbers.length)
return Object.freeze({
startIndex: 0,
endIndex: 0,
postNumbers: Object.freeze([]),
visiblePostNumbers: Object.freeze([]),
atStart: !0,
atEnd: !0,
beforeSpacer: 0,
afterSpacer: 0,
hasUnloadedGapBefore: !1,
hasUnloadedGapAfter: !1,
distanceToSegmentStart: 0,
distanceToSegmentEnd: 0,
afterSegmentSpacer: 0,
totalSize: 0
});
const materializationStep = viewportSize * materializationStepScreens, materializationStart = materializationStep > 0 ? Math.floor(scrollOffset / materializationStep) * materializationStep : scrollOffset, rangeStart = Math.max(
0,
materializationStart - viewportSize * beforeScreens
), rangeEnd = Math.min(
totalSize,
materializationStart + materializationStep + viewportSize * (1 + afterScreens)
);
let visibleStartIndex = this.#firstBlockEndingAfter(
Math.min(scrollOffset, Math.max(0, totalSize - 1))
);
const visibleRootEnd = (this.#prefix[visibleStartIndex] ?? 0) + this.#sizeAt(visibleStartIndex), nextVisibleIndex = visibleStartIndex + 1;
visibleRootEnd <= scrollOffset && nextVisibleIndex < this.#postNumbers.length && (this.#prefix[nextVisibleIndex] ?? 0) < Math.min(totalSize, scrollOffset + viewportSize) && (visibleStartIndex = nextVisibleIndex);
const segmentStartIndex = this.#segmentStartByIndex[visibleStartIndex] ?? 0, segmentEndIndex = this.#segmentEndByIndex[visibleStartIndex] ?? this.#postNumbers.length, unloadedGap = this.#unloadedGapAt(
scrollOffset + viewportSize / 2
), overscanStartIndex = Math.max(
segmentStartIndex,
this.#firstBlockEndingAfter(rangeStart)
), overscanEndIndex = Math.min(
segmentEndIndex,
this.#postNumbers.length,
Math.max(
overscanStartIndex + 1,
this.#firstBlockStartingAtOrAfter(rangeEnd)
)
), visibleEndIndex = Math.min(
segmentEndIndex,
Math.max(
visibleStartIndex + 1,
this.#firstBlockStartingAtOrAfter(
Math.min(totalSize, scrollOffset + viewportSize)
)
)
);
let { startIndex, endIndex } = maxMountedPostCount === void 0 ? {
startIndex: overscanStartIndex,
endIndex: overscanEndIndex
} : this.#budgetedRange({
overscanStartIndex,
overscanEndIndex,
visibleStartIndex,
visibleEndIndex,
maxMountedPostCount,
scrollOffset,
viewportSize
});
const preserveRootIndex = input.preserveRootPostNumber === void 0 ? void 0 : this.#indexByPost.get(input.preserveRootPostNumber);
preserveRootIndex !== void 0 && preserveRootIndex >= segmentStartIndex && preserveRootIndex < segmentEndIndex && (startIndex = Math.min(startIndex, preserveRootIndex), endIndex = Math.max(endIndex, preserveRootIndex + 1));
const boundedEnd = Math.min(this.#postNumbers.length, endIndex), visiblePostNumbers = [];
for (let index = visibleStartIndex; index < visibleEndIndex; index += 1) {
const rootStart = this.#prefix[index] ?? 0;
rootStart + this.#sizeAt(index) > scrollOffset && rootStart < scrollOffset + viewportSize && visiblePostNumbers.push(this.#postNumbers[index]);
}
const mountedEnd = boundedEnd > startIndex ? (this.#prefix[boundedEnd - 1] ?? 0) + this.#sizeAt(boundedEnd - 1) : this.#prefix[startIndex] ?? 0, segmentContentEnd = segmentEndIndex > segmentStartIndex ? (this.#prefix[segmentEndIndex - 1] ?? 0) + this.#sizeAt(segmentEndIndex - 1) : mountedEnd, segmentContentStart = this.#prefix[segmentStartIndex] ?? 0, hasUnloadedGapBefore = (this.#unloadedPostCountsBefore[segmentStartIndex] ?? 0) > 0, hasUnloadedGapAfter = segmentEndIndex < this.#postNumbers.length && (this.#unloadedPostCountsBefore[segmentEndIndex] ?? 0) > 0, segmentStartPostNumber = this.#postNumbers[segmentStartIndex], segmentEndPostNumber = this.#postNumbers[segmentEndIndex - 1], unloadedGapAfterAnchorPostNumber = hasUnloadedGapAfter ? (0, import_identifiers.discoursePostNumber)(
this.#postNumbers[segmentEndIndex] - (this.#unloadedPostCountsBefore[segmentEndIndex] ?? 0) - 1
) : void 0;
return Object.freeze({
startIndex,
endIndex: boundedEnd,
postNumbers: Object.freeze(this.#postNumbers.slice(startIndex, boundedEnd)),
visiblePostNumbers: Object.freeze(visiblePostNumbers),
atStart: scrollOffset <= 10,
atEnd: scrollOffset + viewportSize >= Math.max(0, totalSize - 16),
beforeSpacer: this.#prefix[startIndex] ?? 0,
afterSpacer: Math.max(0, totalSize - mountedEnd),
hasUnloadedGapBefore,
hasUnloadedGapAfter,
segmentStartPostNumber,
segmentEndPostNumber,
...hasUnloadedGapBefore ? { unloadedGapBeforeAnchorPostNumber: segmentStartPostNumber } : {},
...unloadedGapAfterAnchorPostNumber === void 0 ? {} : { unloadedGapAfterAnchorPostNumber },
distanceToSegmentStart: Math.max(
0,
scrollOffset - segmentContentStart
),
distanceToSegmentEnd: Math.max(
0,
segmentContentEnd - (scrollOffset + viewportSize)
),
afterSegmentSpacer: Math.max(0, segmentContentEnd - mountedEnd),
...unloadedGap === void 0 || visiblePostNumbers.length > 0 && unloadedGap.nextIndex <= visibleStartIndex ? {} : {
unloadedGapTargetPostNumber: unloadedGap.targetPostNumber,
unloadedGapSide: unloadedGap.nextIndex <= visibleStartIndex ? "before" : "after"
},
totalSize
});
}
#budgetedRange(input) {
let startIndex = input.visibleStartIndex, endIndex = input.visibleEndIndex, mountedPostCount = this.#subtreePostCountBetween(startIndex, endIndex), beforeBlocked = !1, afterBlocked = !1;
for (; !beforeBlocked && startIndex > input.overscanStartIndex || !afterBlocked && endIndex < input.overscanEndIndex; ) {
const beforeIndex = startIndex - 1, afterIndex = endIndex, beforeDistance = !beforeBlocked && beforeIndex >= input.overscanStartIndex ? Math.max(
0,
input.scrollOffset - (this.#prefix[beforeIndex + 1] ?? 0)
) : Number.POSITIVE_INFINITY, afterDistance = !afterBlocked && afterIndex < input.overscanEndIndex ? Math.max(
0,
(this.#prefix[afterIndex] ?? 0) - (input.scrollOffset + input.viewportSize)
) : Number.POSITIVE_INFINITY;
if (beforeDistance === Number.POSITIVE_INFINITY && afterDistance === Number.POSITIVE_INFINITY)
break;
const addBefore = beforeDistance <= afterDistance, candidateIndex = addBefore ? beforeIndex : afterIndex, candidateWeight = this.#subtreePostCounts[candidateIndex] ?? 1;
if (mountedPostCount + candidateWeight > input.maxMountedPostCount) {
addBefore ? beforeBlocked = !0 : afterBlocked = !0;
continue;
}
mountedPostCount += candidateWeight, addBefore ? startIndex = candidateIndex : endIndex = candidateIndex + 1;
}
return Object.freeze({ startIndex, endIndex });
}
#subtreePostCountBetween(startIndex, endIndex) {
return (this.#subtreeCountPrefix[endIndex] ?? 0) - (this.#subtreeCountPrefix[startIndex] ?? 0);
}
#sizeAt(index) {
const postNumber = this.#postNumbers[index];
return this.#measuredSizes.get(postNumber) ?? this.#estimatedSize * (this.#estimateSubtreeSize ? this.#subtreePostCounts[index] ?? 1 : 1);
}
#ensurePrefix() {
const start = Math.min(this.#dirtyFrom, this.#postNumbers.length);
start === 0 && (this.#prefix[0] = this.#estimatedSize * (this.#unloadedPostCountsBefore[0] ?? 0));
for (let index = start; index < this.#postNumbers.length; index += 1)
this.#prefix[index + 1] = (this.#prefix[index] ?? 0) + this.#sizeAt(index) + this.#estimatedSize * (this.#unloadedPostCountsBefore[index + 1] ?? 0);
this.#prefix.length = this.#postNumbers.length + 1, this.#dirtyFrom = this.#postNumbers.length;
}
#firstBlockEndingAfter(offset) {
let low = 0, high = this.#postNumbers.length;
for (; low < high; ) {
const middle = Math.floor((low + high) / 2);
(this.#prefix[middle + 1] ?? 0) <= offset ? low = middle + 1 : high = middle;
}
return Math.min(low, this.#postNumbers.length - 1);
}
#firstBlockStartingAtOrAfter(offset) {
let low = 0, high = this.#postNumbers.length;
for (; low < high; ) {
const middle = Math.floor((low + high) / 2);
(this.#prefix[middle] ?? 0) < offset ? low = middle + 1 : high = middle;
}
return low;
}
#unloadedGapAt(offset) {
if (!Number.isFinite(offset) || offset < 0 || !this.#postNumbers.length)
return;
const nextIndex = this.#firstBlockStartingAtOrAfter(offset);
if (nextIndex >= this.#postNumbers.length) return;
const unloadedPostCount = this.#unloadedPostCountsBefore[nextIndex] ?? 0;
if (unloadedPostCount <= 0) return;
const gapEnd = this.#prefix[nextIndex] ?? 0, gapStart = gapEnd - this.#estimatedSize * unloadedPostCount;
if (offset < gapStart || offset >= gapEnd) return;
const nextPostNumber = this.#postNumbers[nextIndex], missingOffset = Math.min(
unloadedPostCount - 1,
Math.max(0, Math.floor((offset - gapStart) / this.#estimatedSize))
);
return Object.freeze({
nextIndex,
targetPostNumber: (0, import_identifiers.discoursePostNumber)(
nextPostNumber - unloadedPostCount + missingOffset
)
});
}
}
}, "06ff523dcdcc3f801208c43af97b4dc459d31a1b529dd532e10b1743058901e9");
/* Source: lite/src/stream/virtual-stream-dom-controller.ts */
runtime.register("src/stream/virtual-stream-dom-controller.js", function(module, exports, require) {
var virtual_stream_dom_controller_exports = {};
__export(virtual_stream_dom_controller_exports, {
VirtualStreamDomController: () => VirtualStreamDomController
});
module.exports = __toCommonJS(virtual_stream_dom_controller_exports);
class VirtualStreamDomController {
repository;
layout;
streamView;
domOwner;
#prepareRoots;
#roots;
#resolveGapPlaceholder;
#connectedRoots = /* @__PURE__ */ new Set();
#committed = !1;
constructor(repository, layout, streamView, domOwner, options = {}) {
if (streamView.slots.rootList !== domOwner.rootList)
throw new Error("ReplyTreeDomOwner 必须挂载到 VirtualStreamView.rootList");
this.repository = repository, this.layout = layout, this.streamView = streamView, this.domOwner = domOwner, this.#prepareRoots = options.prepareRoots ?? (() => {
}), this.#roots = options.roots ?? (() => repository.topology.roots()), this.#resolveGapPlaceholder = options.resolveGapPlaceholder ?? ((window) => window.unloadedGapTargetPostNumber === void 0 || window.unloadedGapSide === void 0 ? null : Object.freeze({
side: window.unloadedGapSide,
targetPostNumber: window.unloadedGapTargetPostNumber
}));
}
commit(input) {
const window = this.layout.window(input), mountPlan = this.#prepareRoots(window.postNumbers, input, window), desired = new Set(window.postNumbers), connectedBefore = this.#committed ? this.#connectedRoots : new Set(this.#roots().filter((postNumber) => {
const root = this.domOwner.view(postNumber)?.slots.root;
return !!root && this.streamView.slots.rootList.contains(root);
})), tree = this.domOwner.sync(desired, mountPlan);
for (let index = 0; index < window.postNumbers.length; index += 1) {
const postNumber = window.postNumbers[index], root = this.domOwner.view(postNumber)?.slots.root, zebra = (window.startIndex + index) % 2 === 1;
root?.classList.contains("ldp-zebra-alt") !== zebra && root?.classList.toggle("ldp-zebra-alt", zebra);
const beforeZebra = index + 1 < window.postNumbers.length && (window.startIndex + index + 1) % 2 === 1;
root?.classList.contains("ldp-before-zebra") !== beforeZebra && root?.classList.toggle("ldp-before-zebra", beforeZebra);
}
const connectedAfter = new Set(
[...desired].filter(
(postNumber) => {
const root = this.domOwner.view(postNumber)?.slots.root;
return !!root && this.streamView.slots.rootList.contains(root);
}
)
), attachedRoots = [...connectedAfter].filter((postNumber) => !connectedBefore.has(postNumber)), detachedRoots = [...connectedBefore].filter((postNumber) => !connectedAfter.has(postNumber));
this.#connectedRoots = connectedAfter, this.#committed = !0;
const firstRootInset = window.postNumbers[0] === void 0 ? void 0 : mountPlan?.rootVirtualInsets?.get(window.postNumbers[0]), lastRootPostNumber = window.postNumbers.at(-1), lastRootInset = lastRootPostNumber === void 0 ? void 0 : mountPlan?.rootVirtualInsets?.get(lastRootPostNumber);
return this.streamView.setSpacerSizes(
window.beforeSpacer + (firstRootInset?.beforeSize ?? 0),
window.afterSpacer + (lastRootInset?.afterSize ?? 0)
), this.streamView.setGapPlaceholder(
this.#resolveGapPlaceholder(window, input)
), Object.freeze({
window,
tree,
attachedRoots: Object.freeze(
attachedRoots.sort((left, right) => left - right)
),
detachedRoots: Object.freeze(
detachedRoots.sort((left, right) => left - right)
)
});
}
}
}, "36cc128afd46bfe67839055dbae9fc260fa9519e584dd6eec0ea10ee2dcaa4ce");
/* Source: lite/src/stream/virtual-stream-frame-controller.ts */
runtime.register("src/stream/virtual-stream-frame-controller.js", function(module, exports, require) {
var virtual_stream_frame_controller_exports = {};
__export(virtual_stream_frame_controller_exports, {
VirtualStreamFrameController: () => VirtualStreamFrameController
});
module.exports = __toCommonJS(virtual_stream_frame_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_identifiers = require("../discourse/identifiers.js");
function browserObserverFactory(callback) {
return new ResizeObserver((entries) => {
callback(
entries.map((entry) => {
const borderBox = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize;
return Object.freeze({
target: entry.target,
blockSize: borderBox?.blockSize ?? entry.contentRect.height
});
})
);
});
}
const browserFrameScheduler = Object.freeze({
request: (callback) => requestAnimationFrame(callback),
cancel: (handle) => cancelAnimationFrame(handle)
}), SCROLL_OFFSET_EPSILON = 0.5;
function postNumberFromElement(target) {
return (0, import_identifiers.tryDiscoursePostNumber)(target.getAttribute("data-post-number"));
}
class VirtualStreamFrameController {
domController;
scope;
#readWindowInput;
#applyScrollCompensation;
#shouldApplyScrollCompensation;
#shouldDeferMeasurements;
#onMeasurementsDeferred;
#resolveRootBlockSize;
#onCommit;
#observer;
#frames;
#observedRoots = /* @__PURE__ */ new Map();
#deferredMeasurements = /* @__PURE__ */ new Map();
#frameHandle = null;
#pendingCompensation = 0;
#measurementScrollOffset = null;
#lastCommit = null;
constructor(domController, options) {
this.domController = domController, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.scope), this.#readWindowInput = options.readWindowInput, this.#applyScrollCompensation = options.applyScrollCompensation, this.#shouldApplyScrollCompensation = options.shouldApplyScrollCompensation ?? (() => !0), this.#shouldDeferMeasurements = options.shouldDeferMeasurements ?? (() => !1), this.#onMeasurementsDeferred = options.onMeasurementsDeferred ?? (() => {
}), this.#resolveRootBlockSize = options.resolveRootBlockSize ?? ((_target, blockSize) => blockSize), this.#onCommit = options.onCommit, this.#frames = options.frameScheduler ?? browserFrameScheduler, this.#observer = (options.observerFactory ?? browserObserverFactory)((entries) => {
this.#recordMeasurements(entries);
}), this.scope.add(() => {
this.#frameHandle !== null && this.#frames.cancel(this.#frameHandle), this.#frameHandle = null, this.#clearMeasurementTransaction(), this.#observedRoots.clear(), this.#deferredMeasurements.clear(), this.#observer.disconnect();
});
}
get lastCommit() {
return this.#lastCommit;
}
observeRoot(target) {
this.#observedRoots.set(target, postNumberFromElement(target)), this.#observer.observe(target);
const cleanup = () => {
this.#observedRoots.delete(target), this.#deferredMeasurements.delete(target), this.#observer.unobserve(target);
};
return this.scope.add(cleanup);
}
notifyScroll() {
this.#schedule();
}
flushDeferredMeasurements() {
if (!this.#deferredMeasurements.size || this.scope.destroyed) return;
const entries = [...this.#deferredMeasurements.values()];
this.#deferredMeasurements.clear(), this.#recordMeasurements(entries, !0);
}
/** 同级投影(例如隐藏楼层分隔条)变化后主动刷新该根的物理占位。 */
refreshRootMeasurement(target) {
this.#observedRoots.has(target) && this.#recordMeasurements([Object.freeze({
target,
blockSize: target.getBoundingClientRect().height
})]);
}
flushNow() {
this.#frameHandle !== null && (this.#frames.cancel(this.#frameHandle), this.#frameHandle = null);
const inputBeforeCompensation = this.#readWindowInput();
if (this.#pendingCompensation !== 0) {
const compensation = this.#pendingCompensation, measurementScrollOffset = this.#measurementScrollOffset;
this.#clearMeasurementTransaction(), this.#shouldApplyScrollCompensation() && measurementScrollOffset !== null && Math.abs(inputBeforeCompensation.scrollOffset - measurementScrollOffset) <= SCROLL_OFFSET_EPSILON && this.#applyScrollCompensation(compensation);
} else
this.#measurementScrollOffset = null;
return this.#lastCommit = this.domController.commit(this.#readWindowInput()), this.#onCommit?.(this.#lastCommit), this.#lastCommit;
}
destroy() {
this.scope.destroy();
}
#recordMeasurements(entries, force = !1) {
if (!force && this.#shouldDeferMeasurements()) {
for (const entry of entries)
this.#deferredMeasurements.set(entry.target, entry);
this.#onMeasurementsDeferred();
return;
}
const input = this.#readWindowInput();
this.#measurementScrollOffset !== null && Math.abs(input.scrollOffset - this.#measurementScrollOffset) > SCROLL_OFFSET_EPSILON && this.#clearMeasurementTransaction();
const anchorPostNumber = this.domController.layout.window({
...input,
overscanBeforeScreens: 0,
overscanAfterScreens: 0
}).postNumbers[0];
let changed = !1;
for (const entry of entries) {
const postNumber = postNumberFromElement(entry.target), blockSize = Math.round(
this.#resolveRootBlockSize(entry.target, entry.blockSize)
);
if (!this.#observedRoots.has(entry.target) || this.#observedRoots.get(entry.target) !== postNumber || postNumber === null || !Number.isFinite(blockSize) || blockSize <= 0) continue;
const result = this.domController.layout.measure(
postNumber,
blockSize,
anchorPostNumber
);
result.changed && (changed = !0, this.#measurementScrollOffset ??= input.scrollOffset, this.#pendingCompensation += result.scrollCompensation);
}
changed && this.#schedule();
}
#clearMeasurementTransaction() {
this.#pendingCompensation = 0, this.#measurementScrollOffset = null;
}
#schedule() {
this.scope.destroyed || this.#frameHandle !== null || (this.#frameHandle = this.#frames.request(() => {
this.#frameHandle = null, this.flushNow();
}));
}
}
}, "cea160b0950ac7ebd97e7f8622aeb5e47f2fe9283617fce84143ff94c418a1d0");
/* Source: lite/src/stream/virtual-stream-view.ts */
runtime.register("src/stream/virtual-stream-view.js", function(module, exports, require) {
var virtual_stream_view_exports = {};
__export(virtual_stream_view_exports, {
VirtualStreamView: () => VirtualStreamView
});
module.exports = __toCommonJS(virtual_stream_view_exports);
var import_html_element = require("../dom/html-element.js");
function gapPlaceholder(document, side) {
const root = (0, import_html_element.htmlElement)(document, "div", "ldp-virtual-gap-placeholder"), avatar = (0, import_html_element.htmlElement)(document, "span", "ldp-virtual-gap-skeleton-avatar"), body = (0, import_html_element.htmlElement)(document, "span", "ldp-virtual-gap-skeleton-body"), line = (0, import_html_element.htmlElement)(document, "span", "ldp-virtual-gap-skeleton-line"), shortLine = (0, import_html_element.htmlElement)(
document,
"span",
"ldp-virtual-gap-skeleton-line ldp-virtual-gap-skeleton-line-short"
), label = (0, import_html_element.htmlElement)(document, "span", "ldp-virtual-gap-label");
return avatar.setAttribute("aria-hidden", "true"), line.setAttribute("aria-hidden", "true"), shortLine.setAttribute("aria-hidden", "true"), label.textContent = "正在加载附近楼层…", body.append(line, shortLine, label), root.append(avatar, body), root.hidden = !0, root.setAttribute("data-gap-side", side), root.setAttribute("role", "status"), root.setAttribute("aria-live", "polite"), root;
}
class VirtualStreamView {
slots;
#endTipRevealed = !1;
constructor(document) {
const root = (0, import_html_element.htmlElement)(document, "div", "ldp-virtual-stream"), beforeSpacer = (0, import_html_element.htmlElement)(document, "div", "ldp-virtual-spacer ldp-virtual-spacer-before"), beforeGapPlaceholder = gapPlaceholder(document, "before"), rootList = (0, import_html_element.htmlElement)(document, "div", "ldp-virtual-root-list"), afterSpacer = (0, import_html_element.htmlElement)(document, "div", "ldp-virtual-spacer ldp-virtual-spacer-after"), afterGapPlaceholder = gapPlaceholder(document, "after"), empty = (0, import_html_element.htmlElement)(document, "div", "ldp-comments-empty"), loadingTip = (0, import_html_element.htmlElement)(document, "div", "ldp-loading-tip"), loadingCopy = document.createElement("span"), endTip = (0, import_html_element.htmlElement)(document, "div", "ldp-end-tip");
beforeSpacer.setAttribute("aria-hidden", "true"), afterSpacer.setAttribute("aria-hidden", "true"), empty.textContent = "暂无评论", empty.hidden = !0, loadingCopy.textContent = "正在加载楼层…", loadingTip.append(loadingCopy), loadingTip.hidden = !0, loadingTip.setAttribute("role", "status"), loadingTip.setAttribute("aria-live", "polite"), endTip.textContent = "已经见底了~", endTip.hidden = !0, endTip.setAttribute("role", "status"), endTip.setAttribute("aria-live", "polite"), beforeSpacer.append(beforeGapPlaceholder), afterSpacer.append(afterGapPlaceholder), root.append(beforeSpacer, rootList, afterSpacer, empty, loadingTip, endTip), this.slots = Object.freeze({
root,
beforeSpacer,
beforeGapPlaceholder,
rootList,
afterSpacer,
afterGapPlaceholder,
empty,
loadingTip,
endTip
});
}
setGapPlaceholder(state) {
for (const [side, spacer, placeholder] of [
["before", this.slots.beforeSpacer, this.slots.beforeGapPlaceholder],
["after", this.slots.afterSpacer, this.slots.afterGapPlaceholder]
]) {
const visible = state?.side === side;
if (placeholder.hidden = !visible, spacer.classList.toggle("has-gap-placeholder", visible), !visible) {
spacer.setAttribute("aria-hidden", "true"), placeholder.removeAttribute("data-target-post-number");
continue;
}
spacer.removeAttribute("aria-hidden"), placeholder.setAttribute(
"data-target-post-number",
String(state.targetPostNumber)
);
}
}
setSpacerSizes(before, after) {
const beforeSize = `${Math.max(0, before)}px`, afterSize = `${Math.max(0, after)}px`;
this.slots.beforeSpacer.style.blockSize !== beforeSize && (this.slots.beforeSpacer.style.blockSize = beforeSize), this.slots.afterSpacer.style.blockSize !== afterSize && (this.slots.afterSpacer.style.blockSize = afterSize);
}
setFlowState(state) {
this.slots.root.setAttribute("aria-busy", String(state.loading)), this.slots.loadingTip.hidden = !state.loading, this.slots.loadingTip.classList.toggle("show", state.loading);
const showEndTip = state.done || this.#endTipRevealed;
this.slots.endTip.hidden = !showEndTip, this.slots.endTip.classList.toggle("show", showEndTip), this.slots.empty.hidden = !state.empty;
}
revealEndTip() {
this.#endTipRevealed = !0, this.slots.endTip.hidden = !1, this.slots.endTip.classList.add("show");
}
destroy() {
this.slots.root.remove();
}
}
}, "6d1b30320fa860de4d268fe5229348ef703b26fc05216a629aa1db8322446686");
/* Source: lite/src/topic/reader-native-topic-route.ts */
runtime.register("src/topic/reader-native-topic-route.js", function(module, exports, require) {
var reader_native_topic_route_exports = {};
__export(reader_native_topic_route_exports, {
READER_NATIVE_BYPASS_PARAMETER: () => READER_NATIVE_BYPASS_PARAMETER,
READER_NATIVE_BYPASS_TAB_KEY: () => READER_NATIVE_BYPASS_TAB_KEY,
consumeReaderNativeBypass: () => consumeReaderNativeBypass,
consumeReaderNativeTabBypass: () => consumeReaderNativeTabBypass,
openReaderNativeTopicTab: () => openReaderNativeTopicTab,
readerNativeBypassCleanHref: () => readerNativeBypassCleanHref,
readerNativeTopicHref: () => readerNativeTopicHref
});
module.exports = __toCommonJS(reader_native_topic_route_exports);
const READER_NATIVE_BYPASS_PARAMETER = "ldp_native", READER_NATIVE_BYPASS_TAB_KEY = "linuxdo-enhanced-reader:native-tab";
function httpUrl(value, baseValue) {
try {
const base = new URL(baseValue), url = new URL(value, base);
return /^https?:$/i.test(url.protocol) && url.origin === base.origin ? url : null;
} catch {
return null;
}
}
function readerNativeTopicHref(value, baseValue) {
const url = httpUrl(value, baseValue);
return url ? (url.searchParams.set(READER_NATIVE_BYPASS_PARAMETER, "1"), url.href) : "";
}
function readerNativeBypassCleanHref(value, baseValue) {
const url = httpUrl(value, baseValue);
return url?.searchParams.has(READER_NATIVE_BYPASS_PARAMETER) ? (url.searchParams.delete(READER_NATIVE_BYPASS_PARAMETER), url.href) : null;
}
function consumeReaderNativeBypass(value, baseValue, replace) {
const cleanHref = readerNativeBypassCleanHref(value, baseValue);
return cleanHref ? (replace(cleanHref), !0) : !1;
}
function consumeReaderNativeTabBypass(window) {
try {
const bypass = window.sessionStorage.getItem(
READER_NATIVE_BYPASS_TAB_KEY
) === "1";
return bypass && window.sessionStorage.removeItem(READER_NATIVE_BYPASS_TAB_KEY), bypass;
} catch {
return !1;
}
}
function openReaderNativeTopicTab(window, value) {
let tab = null;
try {
tab = window.open("about:blank", "_blank");
} catch {
return !1;
}
if (!tab) return !1;
try {
tab.sessionStorage.setItem(READER_NATIVE_BYPASS_TAB_KEY, "1");
} catch {
}
try {
tab.opener = null;
} catch {
}
try {
tab.location.replace(value);
} catch {
tab.location.href = value;
}
return !0;
}
}, "0f9505808f65e295e5a2963229477e4ad9daae1d1aa9a282907d558a769c35aa");
/* Source: lite/src/topic/reader-post-author-filter-feature.ts */
runtime.register("src/topic/reader-post-author-filter-feature.js", function(module, exports, require) {
var reader_post_author_filter_feature_exports = {};
__export(reader_post_author_filter_feature_exports, {
ReaderPostAuthorFilterFeature: () => ReaderPostAuthorFilterFeature
});
module.exports = __toCommonJS(reader_post_author_filter_feature_exports);
var import_reader_unwanted_topic_filter = require("../collection/reader-unwanted-topic-filter.js");
class ReaderPostAuthorFilterFeature {
activationScope = "node";
#views = /* @__PURE__ */ new Map();
#boundViews = /* @__PURE__ */ new WeakSet();
#recordedUsernames = /* @__PURE__ */ new Set();
#recordHiddenPostAuthor;
#onError;
#preferences;
constructor(options) {
this.#recordHiddenPostAuthor = options.recordHiddenPostAuthor ?? (() => {
}), this.#onError = options.onError ?? (() => {
}), this.#preferences = options.preferences.read(), options.preferences.subscribe((preferences) => {
this.#preferences = preferences;
for (const [view, username] of this.#views)
this.#project(view, username);
}, options.parentScope), options.parentScope.add(() => {
this.#views.clear(), this.#recordedUsernames.clear();
});
}
afterRender(_post, view) {
const username = view.identity.username;
this.#views.set(view, username), this.#boundViews.has(view) || (this.#boundViews.add(view), view.scope.add(() => this.#views.delete(view))), this.#project(view, username);
}
#project(view, username) {
const hidden = (0, import_reader_unwanted_topic_filter.readerUnwantedPostAuthorMatches)(
this.#preferences,
username
);
view.slots.root.classList.toggle("ldp-post-unwanted-author", hidden);
const normalizedUsername = username.replace(/^@+/, "").trim(), usernameKey = normalizedUsername.toLocaleLowerCase("en-US");
if (hidden) {
if (view.slots.root.dataset.unwantedPostAuthor = normalizedUsername, usernameKey && !this.#recordedUsernames.has(usernameKey))
try {
this.#recordHiddenPostAuthor(normalizedUsername), this.#recordedUsernames.add(usernameKey);
} catch (cause) {
this.#onError(cause);
}
} else
delete view.slots.root.dataset.unwantedPostAuthor, this.#recordedUsernames.delete(usernameKey);
}
}
}, "d63b3510337b93c8efe598f55bde4791468d30ee6212679aa02a1f8a1074f240");
/* Source: lite/src/topic/reader-post-presentation.ts */
runtime.register("src/topic/reader-post-presentation.js", function(module, exports, require) {
var reader_post_presentation_exports = {};
__export(reader_post_presentation_exports, {
createReaderPostPresentation: () => createReaderPostPresentation,
createReaderPostReadStateFeature: () => createReaderPostReadStateFeature
});
module.exports = __toCommonJS(reader_post_presentation_exports);
var import_reader_image_fallback = require("../components/reader-image-fallback.js"), import_reader_icon = require("../components/reader-icon.js"), import_reader_topic_header = require("./reader-topic-header.js");
const EMPTY_RECORD = Object.freeze({});
function record(value) {
return value !== null && (typeof value == "object" || typeof value == "function") ? value : EMPTY_RECORD;
}
function text(value) {
return String(value ?? "").trim();
}
function positiveInteger(value, name) {
const numeric = Number(value);
if (!Number.isSafeInteger(numeric) || numeric < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return numeric;
}
function appendUserLink(document, parent, className, label, href, username) {
const link = document.createElement("a");
link.className = `ldp-user-link ${className}`, link.textContent = label, link.href = href || "#", link.target = "_blank", link.rel = "noopener", link.dataset.userCard = username, parent.append(link);
}
function appendAvatar(options, parent, post, username, displayName) {
const avatarTemplate = text(post.avatar_template), source = options.presentation.avatarSource(avatarTemplate, 48), exactSource = avatarTemplate.replace(/\{size\}/g, "48"), trigger = options.document.createElement("button");
if (trigger.type = "button", trigger.className = "ldp-user-link ldp-avatar-link", trigger.dataset.readerAvatar = "", trigger.dataset.userAvatarPreview = "", avatarTemplate && (trigger.dataset.userAvatarTemplate = avatarTemplate), trigger.dataset.userCard = username, trigger.setAttribute("aria-label", `查看 ${displayName} 的头像原图`), source) {
const avatar = options.document.createElement("img");
avatar.className = "ldp-avatar", avatar.alt = "", avatar.loading = "lazy", avatar.decoding = "async", trigger.append(avatar), (0, import_reader_image_fallback.installReaderImageSourceFallback)(avatar, [source, exactSource], () => {
const fallback = options.document.createElement("span");
return fallback.className = "ldp-avatar ldp-persistent-avatar-fallback", fallback.textContent = [...displayName || username || "?"][0] ?? "?", fallback.setAttribute("aria-hidden", "true"), fallback;
}, options.recoverAvatarSource, exactSource);
} else {
const fallback = options.document.createElement("span");
fallback.className = "ldp-avatar ldp-persistent-avatar-fallback", fallback.textContent = [...displayName || username || "?"][0] ?? "?", fallback.setAttribute("aria-hidden", "true"), trigger.append(fallback);
}
parent.append(trigger);
}
function appendBadge(document, parent, className, label) {
const badge = document.createElement("span");
badge.className = className, badge.textContent = label, parent.append(badge);
}
function appendIdentityBadge(document, parent, className, label, tooltip, iconName, renderIcon) {
const badge = document.createElement("span");
badge.className = className, badge.dataset.ldpTooltipLabel = tooltip, badge.setAttribute("role", "img"), badge.setAttribute("aria-label", tooltip);
const icon = (0, import_reader_icon.renderReaderIcon)(document, iconName, renderIcon);
icon.nodeType === 1 && icon.classList.add("ldp-post-identity-compact-icon");
const text2 = document.createElement("span");
text2.className = "ldp-post-identity-label", text2.textContent = label, badge.append(icon, text2), parent.append(badge);
}
function appendFloor(document, parent, postNumber, _replyToPostNumber) {
const floor = document.createElement("span");
floor.className = "ldp-floor ldp-body-floor", floor.textContent = `#${postNumber}`, parent.append(floor);
}
function readStateIcon(document, read, renderIcon) {
let icon = read ? (0, import_reader_icon.renderReaderIcon)(document, "check", renderIcon) : null;
if (!icon) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
if (svg.setAttribute("viewBox", "0 0 24 24"), svg.setAttribute("aria-hidden", "true"), read) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M5 12.5 9.5 17 19 7.5"), path.setAttribute("fill", "none"), path.setAttribute("stroke", "currentColor"), path.setAttribute("stroke-linecap", "round"), path.setAttribute("stroke-linejoin", "round"), svg.append(path);
} else {
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", "12"), circle.setAttribute("cy", "12"), circle.setAttribute("r", "5.5"), svg.append(circle);
}
icon = svg;
}
return icon.nodeType === 1 && (icon.classList.add("ldp-icon", "ldp-post-read-state-icon"), icon.setAttribute("aria-hidden", "true")), icon;
}
function appendReadState(document, parent, read, renderIcon) {
const state = document.createElement("span");
state.className = "ldp-post-read-state", projectReadStateMarker(state, read, renderIcon), parent.append(state);
}
function projectReadStateMarker(state, read, renderIcon) {
const label = read ? "该楼层已读" : "该楼层未读";
state.classList.toggle("is-read", read), state.classList.toggle("is-unread", !read), state.dataset.readState = read ? "read" : "unread", state.dataset.ldpTooltipLabel = label, state.setAttribute("role", "img"), state.setAttribute("aria-label", label), state.replaceChildren(readStateIcon(state.ownerDocument, read, renderIcon));
}
function createReaderPostReadStateFeature(options) {
const scope = options.parentScope.child(), views = /* @__PURE__ */ new Map(), boundViews = /* @__PURE__ */ new WeakSet(), viewMarkers = /* @__PURE__ */ new WeakMap(), transitions = /* @__PURE__ */ new Map(), setTimer = options.setTimer ?? ((callback, milliseconds) => setTimeout(callback, milliseconds)), clearTimer = options.clearTimer ?? clearTimeout, isVisible = options.isVisible ?? ((view) => view.slots.root.isConnected && typeof view.slots.root.getClientRects == "function" && view.slots.root.getClientRects().length > 0), prefersReducedMotion = options.prefersReducedMotion ?? (() => !1), clearTransition = (marker) => {
const transition = transitions.get(marker);
transition && (clearTimer(transition.swapTimer), clearTimer(transition.finishTimer), marker.removeEventListener("animationend", transition.finish), transitions.delete(marker)), marker.classList.remove("is-confirming"), delete marker.dataset.readTransitionState;
}, syncViewReadState = (view, read, animate) => {
const marker = view.slots.header.querySelector(
":scope > .ldp-post-read-state"
);
if (!marker) return;
const previousMarker = viewMarkers.get(view);
previousMarker && previousMarker !== marker && clearTransition(previousMarker), viewMarkers.set(view, marker);
const state = read ? "read" : "unread";
if (marker.dataset.readState === state || marker.dataset.readTransitionState === state) return;
if (clearTransition(marker), !animate || !read || !isVisible(view)) {
projectReadStateMarker(marker, read, options.renderIcon);
return;
}
marker.dataset.readTransitionState = state, marker.classList.add("is-confirming");
const finish = () => {
const transition = transitions.get(marker);
!transition || transition.finish !== finish || (clearTransition(marker), marker.dataset.readState !== state && projectReadStateMarker(marker, read, options.renderIcon));
};
marker.addEventListener("animationend", finish, { once: !0 });
const swapTimer = setTimer(() => {
const transition = transitions.get(marker);
!transition || transition.finish !== finish || projectReadStateMarker(marker, read, options.renderIcon);
}, 625), finishTimer = setTimer(finish, 1450);
transitions.set(marker, Object.freeze({
swapTimer,
finishTimer,
finish
}));
};
return options.readState.changes.subscribe((change) => {
for (const postNumber of change.postNumbers) {
const bindings = views.get(postNumber);
if (bindings)
for (const [view, sourceRead] of bindings)
syncViewReadState(
view,
sourceRead || options.readState.isOptimistic(postNumber),
change.kind === "optimistic" && !prefersReducedMotion()
);
}
}, scope), scope.add(() => {
for (const marker of transitions.keys()) clearTransition(marker);
views.clear();
}), Object.freeze({
afterRender(postValue, view) {
const post = record(postValue), postNumber = positiveInteger(
post.post_number,
"post.post_number"
), bindings = views.get(postNumber) ?? /* @__PURE__ */ new Map();
bindings.set(view, post.read === !0), views.set(postNumber, bindings), boundViews.has(view) || (boundViews.add(view), view.scope.add(() => {
const marker = viewMarkers.get(view);
marker && clearTransition(marker), viewMarkers.delete(view);
const current = views.get(postNumber);
current?.delete(view), current?.size === 0 && views.delete(postNumber);
})), syncViewReadState(
view,
post.read === !0 || options.readState.isOptimistic(postNumber),
!1
);
}
});
}
function createReaderPostPresentation(options) {
const currentUsername = text(options.currentUsername), exactTimeByView = /* @__PURE__ */ new WeakMap(), cookedByView = /* @__PURE__ */ new WeakMap();
return Object.freeze({
identity(postValue) {
const post = record(postValue);
return Object.freeze({
postId: positiveInteger(post.id, "post.id"),
postNumber: positiveInteger(
post.post_number,
"post.post_number"
),
username: text(post.username),
createdAt: text(post.created_at)
});
},
render(postValue, view) {
const post = record(postValue), postNumber = positiveInteger(
post.post_number,
"post.post_number"
), replyNumber = Number(post.reply_to_post_number), replyToPostNumber = Number.isSafeInteger(replyNumber) && replyNumber > 0 ? replyNumber : null, username = text(post.username), displayName = text(post.name) || username || "未知用户", profileHref = options.presentation.userHref(username), header = view.slots.header;
header.replaceChildren(), appendAvatar(options, header, post, username, displayName), appendUserLink(
options.document,
header,
"ldp-author",
displayName,
profileHref,
username
), appendUserLink(
options.document,
header,
"ldp-user",
`@${username}`,
profileHref,
username
);
const identityBadges = options.document.createElement("span");
identityBadges.className = "ldp-post-identity-badges", header.append(identityBadges), currentUsername && username === currentUsername && appendIdentityBadge(
options.document,
identityBadges,
"ldp-me",
"ME",
"当前用户",
"user-round",
options.renderIcon
), username && username === (0, import_reader_topic_header.readerTopicOwnerUsername)(options.readTopic()) && appendIdentityBadge(
options.document,
identityBadges,
"ldp-op",
"OP",
"主题作者(OP)",
"pencil",
options.renderIcon
);
const createdAt = text(post.created_at) || text(view.identity.createdAt), relative = options.relativeTime(createdAt);
if (relative) {
const time = options.document.createElement("span");
time.className = "ldp-time";
const cachedExact = exactTimeByView.get(view), exact = cachedExact?.timestamp === createdAt ? cachedExact.value : options.exactTime(createdAt);
exact && cachedExact?.timestamp !== createdAt && exactTimeByView.set(view, Object.freeze({
timestamp: createdAt,
value: exact
})), exact && (time.dataset.exactTime = exact);
const label = options.document.createElement("span");
label.className = "ldp-time-relative", label.textContent = `· ${relative}`, time.append(label), header.append(time);
}
post.hidden === !0 && appendBadge(
options.document,
header,
"ldp-special-badge ldp-hidden-badge warn",
"已隐藏"
), appendFloor(
options.document,
header,
postNumber,
replyToPostNumber
), appendReadState(
options.document,
header,
post.read === !0,
options.renderIcon
);
const cooked = text(post.cooked), detailsOpenState = cookedByView.get(view) === cooked ? [...view.slots.content.querySelectorAll("details")].map((details) => details.hasAttribute("open")) : null;
if (view.slots.content.innerHTML = cooked, cookedByView.set(view, cooked), detailsOpenState)
for (const [index, details] of [
...view.slots.content.querySelectorAll("details")
].entries())
details.toggleAttribute(
"open",
detailsOpenState[index] ?? details.hasAttribute("open")
);
}
});
}
}, "d4c7665f12afae5d6d1c6b43b39c866584a9b720a742fa8a7ba783eb07d5aa28");
/* Source: lite/src/topic/reader-post-view-projector.ts */
runtime.register("src/topic/reader-post-view-projector.js", function(module, exports, require) {
var reader_post_view_projector_exports = {};
__export(reader_post_view_projector_exports, {
ReaderPostViewProjector: () => ReaderPostViewProjector
});
module.exports = __toCommonJS(reader_post_view_projector_exports);
var import_post_view = require("../dom/post-view.js");
class ReaderPostViewProjector {
#document;
#identity;
#render;
#features;
#onError;
constructor(options) {
this.#document = options.document, this.#identity = options.identity, this.#render = options.render, this.#features = Object.freeze([...options.features ?? []]), this.#onError = options.onError ?? (() => {
});
}
identity(post) {
return this.#identity(post);
}
create(post, parentScope, expectedPostNumber) {
const view = this.createShell(post, parentScope, expectedPostNumber);
try {
return this.render(post, view), view;
} catch (error) {
throw view.destroy(), error;
}
}
/** 创建 canonical 固定槽位,但暂不解析 cooked 或投影 feature。 */
createShell(post, parentScope, expectedPostNumber) {
const view = new import_post_view.PostView(
this.#document,
this.#identity(post),
parentScope
);
if (expectedPostNumber !== void 0 && view.postNumber !== expectedPostNumber)
throw view.destroy(), new Error(
`PostView identity #${view.postNumber} 与 canonical 楼层 #${expectedPostNumber} 不一致`
);
return view;
}
render(post, view) {
for (const feature of this.#features)
try {
feature.beforeRender?.(post, view);
} catch (error) {
this.#onError(error);
}
try {
this.#render(post, view);
} finally {
for (const feature of this.#features)
try {
feature.afterRender?.(post, view);
} catch (error) {
this.#onError(error);
}
}
}
attach(root, postNumber, target = "all") {
this.#visitFeatures(target, (feature) => {
feature.attachRoot?.(root, postNumber);
});
}
detach(root, postNumber, target = "all") {
this.#visitFeatures(target, (feature) => {
feature.detachRoot?.(root, postNumber);
});
}
syncProjection() {
for (const feature of this.#features)
try {
feature.syncProjection?.();
} catch (error) {
this.#onError(error);
}
}
#visitFeatures(target, visit) {
for (const feature of this.#features) {
const scope = feature.activationScope ?? "branch";
if (!(target !== "all" && scope !== target))
try {
visit(feature);
} catch (error) {
this.#onError(error);
}
}
}
}
}, "973fb472a06fa9ea020a5c3755fc8a12cb7682b791ffbb4bc0121490d7d6a10e");
/* Source: lite/src/topic/reader-reply-ancestor-resolver.ts */
runtime.register("src/topic/reader-reply-ancestor-resolver.js", function(module, exports, require) {
var reader_reply_ancestor_resolver_exports = {};
__export(reader_reply_ancestor_resolver_exports, {
resolveReaderReplyAncestors: () => resolveReaderReplyAncestors
});
module.exports = __toCommonJS(reader_reply_ancestor_resolver_exports);
var import_identifiers = require("../discourse/identifiers.js");
async function resolveReaderReplyAncestors(session, targetPostNumberValue, options = {}) {
const targetPostNumber = (0, import_identifiers.discoursePostNumber)(targetPostNumberValue), stopBeforePostNumber = options.stopBeforePostNumber === void 0 ? null : (0, import_identifiers.discoursePostNumber)(options.stopBeforePostNumber), maxDepth = Math.max(1, Math.floor(options.maxDepth ?? 128)), isActive = options.isActive ?? (() => !0), seen = /* @__PURE__ */ new Set(), loadedPostNumbers = [];
let current = targetPostNumber, availableRoot = targetPostNumber;
for (let depth = 0; depth < maxDepth; depth += 1) {
if (!isActive())
return Object.freeze({
rootPostNumber: availableRoot,
complete: !1,
loadedPostNumbers: Object.freeze(loadedPostNumbers)
});
if (seen.has(current))
throw new Error(`楼层祖先链存在环,经过 #${current}`);
seen.add(current);
let post = session.postByNumber(current);
if (!post) {
try {
await session.loadTarget(current, {
scope: "single",
advanceCursor: !1
});
} catch (error) {
return Object.freeze({
rootPostNumber: availableRoot,
complete: !1,
loadedPostNumbers: Object.freeze(loadedPostNumbers),
...isActive() ? { error } : {}
});
}
if (!isActive()) continue;
if (post = session.postByNumber(current), !post)
return Object.freeze({
rootPostNumber: availableRoot,
complete: !1,
loadedPostNumbers: Object.freeze(loadedPostNumbers)
});
loadedPostNumbers.push(current);
}
availableRoot = current;
const parent = (0, import_identifiers.discoursePostReference)(post).replyToPostNumber;
if (parent === null || parent === stopBeforePostNumber)
return Object.freeze({
rootPostNumber: current,
complete: !0,
loadedPostNumbers: Object.freeze(loadedPostNumbers)
});
current = (0, import_identifiers.discoursePostNumber)(parent);
}
return Object.freeze({
rootPostNumber: availableRoot,
complete: !1,
loadedPostNumbers: Object.freeze(loadedPostNumbers)
});
}
}, "0e9a00d23cc3baf9951c000c767973a28696998cd9581061a576aa3590528d4e");
/* Source: lite/src/topic/reader-reply-tree-preferences.ts */
runtime.register("src/topic/reader-reply-tree-preferences.js", function(module, exports, require) {
var reader_reply_tree_preferences_exports = {};
__export(reader_reply_tree_preferences_exports, {
DEFAULT_READER_REPLY_TREE_PREFERENCES: () => DEFAULT_READER_REPLY_TREE_PREFERENCES,
ReaderReplyTreePreferencesPreview: () => ReaderReplyTreePreferencesPreview,
ReaderReplyTreePresentation: () => ReaderReplyTreePresentation,
normalizeReaderReplyTreePreferences: () => normalizeReaderReplyTreePreferences,
readerPreferencesReplyTreeAdapter: () => readerPreferencesReplyTreeAdapter
});
module.exports = __toCommonJS(reader_reply_tree_preferences_exports);
var import_signal = require("../kernel/signal.js");
const DEFAULT_READER_REPLY_TREE_PREFERENCES = Object.freeze({
expandNestedRepliesByDefault: !0,
expandLeafNestedReplies: !1,
aggregateDescendantReplies: !0,
inlineReplyTreeMaxDepth: 3,
hideNestedReplyFloors: !0
});
function normalizeReaderReplyTreePreferences(value) {
const expandLeafNestedReplies = value.expandLeafNestedReplies === !0, aggregateDescendantReplies = value.aggregateDescendantReplies === !0, expandNestedRepliesByDefault = aggregateDescendantReplies || value.expandNestedRepliesByDefault !== !1 || !expandLeafNestedReplies, rawDepth = Number(value.inlineReplyTreeMaxDepth);
return Object.freeze({
expandNestedRepliesByDefault,
expandLeafNestedReplies,
aggregateDescendantReplies: expandNestedRepliesByDefault && aggregateDescendantReplies,
inlineReplyTreeMaxDepth: Number.isFinite(rawDepth) ? Math.min(5, Math.max(1, Math.trunc(rawDepth))) : DEFAULT_READER_REPLY_TREE_PREFERENCES.inlineReplyTreeMaxDepth,
hideNestedReplyFloors: value.hideNestedReplyFloors === !0
});
}
class ReaderReplyTreePreferencesPreview {
changes = new import_signal.Signal();
#onError;
#value;
constructor(value, onError = () => {
}) {
this.#value = normalizeReaderReplyTreePreferences(value), this.#onError = onError;
}
read() {
return this.#value;
}
update(value) {
const next = normalizeReaderReplyTreePreferences(value);
if (!Object.keys(next).some((name) => !Object.is(next[name], this.#value[name]))) return !1;
this.#value = next;
for (const error of this.changes.emit(next)) this.#onError(error);
return !0;
}
subscribe(listener, scope) {
return this.changes.subscribe(listener, scope);
}
destroy() {
this.changes.clear();
}
}
const readerPreferencesReplyTreeAdapter = Object.freeze({
read: (preferences) => normalizeReaderReplyTreePreferences(preferences),
createPatch: (value) => normalizeReaderReplyTreePreferences(value)
});
function inlineDepth(preferences) {
return preferences.expandNestedRepliesByDefault ? preferences.aggregateDescendantReplies ? preferences.inlineReplyTreeMaxDepth : 1 : 0;
}
class ReaderReplyTreePresentation {
canonical;
#frozenCanonical = null;
#frozenCanonicalSourceRevision = -1;
#frozenCanonicalCoverageComplete = !0;
#frozenCanonicalPostStreamRevision = 0;
#preferences;
#canonicalCoverageComplete;
#canonicalPostStreamRevision;
#canonicalPostStreamGapCount;
#revealedFloors = /* @__PURE__ */ new Set();
#revealedParents = /* @__PURE__ */ new Set();
#degradedFloorRoots = /* @__PURE__ */ new Set();
#degradedFloorCanonicalRevision = -1;
#postFilter = null;
#projectionRevision = 0;
#cachedCanonicalRevision = -1;
#cachedCanonicalCoverageComplete = null;
#cachedCanonicalPostStreamRevision = -1;
#cachedProjectionRevision = -1;
#trustedCanonicalCoverageThroughPostNumber = 0;
#cachedRoots = Object.freeze([]);
#cachedRootBranches = Object.freeze([]);
#cachedSubtreePostCounts = /* @__PURE__ */ new Map();
#cachedHiddenFloorRunsAfter = /* @__PURE__ */ new Map();
constructor(canonical, preferences = DEFAULT_READER_REPLY_TREE_PREFERENCES, options = {}) {
this.canonical = canonical, this.#preferences = normalizeReaderReplyTreePreferences(preferences), this.#canonicalCoverageComplete = options.canonicalCoverageComplete ?? (() => !0), this.#canonicalPostStreamRevision = options.canonicalPostStreamRevision ?? (() => 0), this.#canonicalPostStreamGapCount = options.canonicalPostStreamGapCount;
}
get preferences() {
return this.#preferences;
}
get revision() {
return `${this.#activeCanonicalRevision()}:${Number(this.#activeCanonicalCoverageComplete())}:${this.#activeCanonicalPostStreamRevision()}:${this.#projectionRevision}`;
}
get postFilterKey() {
return this.#postFilter?.key ?? null;
}
postFilterMatches(postNumber) {
return this.#postFilter?.matches(postNumber) ?? !0;
}
/**
* 用户连续滚动期间固定一次只读关系快照。
*
* canonical 仍可被 MessageBus 即时更新并持久化;虚拟窗口、DOM owner 和回复线只读
* 这个短生命周期投影,避免同一个滚轮手势中途改变根高或父子挂载。重复调用不会复制。
*/
freezeCanonical() {
return this.#frozenCanonical ? !1 : (this.#frozenCanonical = this.canonical.clone(), this.#frozenCanonicalSourceRevision = this.canonical.revision, this.#frozenCanonicalCoverageComplete = this.#canonicalCoverageComplete(), this.#frozenCanonicalPostStreamRevision = this.#canonicalPostStreamRevision(), !0);
}
/** 停滚后切回最新 canonical;返回冻结期间关系是否发生变化。 */
thawCanonical() {
if (!this.#frozenCanonical) return !1;
const changed = this.#frozenCanonicalSourceRevision !== this.canonical.revision || this.#frozenCanonicalCoverageComplete !== this.#canonicalCoverageComplete() || this.#frozenCanonicalPostStreamRevision !== this.#canonicalPostStreamRevision();
return this.#frozenCanonical = null, this.#frozenCanonicalSourceRevision = -1, this.#frozenCanonicalCoverageComplete = !0, this.#frozenCanonicalPostStreamRevision = 0, changed;
}
get canonicalFrozen() {
return this.#frozenCanonical !== null;
}
/** 与当前活动投影同源;滚动冻结期间返回冻结时的覆盖状态。 */
get coverageComplete() {
return this.#activeCanonicalCoverageComplete();
}
update(preferences) {
const next = normalizeReaderReplyTreePreferences(preferences), changed = Object.keys(next).some((name) => !Object.is(next[name], this.#preferences[name]));
return changed && (this.#preferences = next, this.#revealedFloors.clear(), this.#revealedParents.clear(), this.#degradedFloorRoots.clear(), this.#degradedFloorCanonicalRevision = -1, this.#projectionRevision += 1), changed;
}
setPostFilter(filter) {
const nextKey = filter?.key ?? "", currentKey = this.#postFilter?.key ?? "";
return nextKey === currentKey ? !1 : (this.#postFilter = filter, this.#revealedFloors.clear(), this.#revealedParents.clear(), this.#degradedFloorRoots.clear(), this.#degradedFloorCanonicalRevision = -1, this.#projectionRevision += 1, !0);
}
invalidatePostFilter() {
return this.#postFilter ? (this.#projectionRevision += 1, !0) : !1;
}
revealAsFloor(postNumber) {
return !this.#activeCanonical().has(postNumber) || this.rootOf(postNumber) !== void 0 ? !1 : (this.#postFilter && this.#revealedFloors.clear(), this.#revealedFloors.add(postNumber), this.#projectionRevision += 1, !0);
}
/**
* canonical 父链在某个不可读楼层处断开时,把最高可用祖先投影成临时根,并沿已确认
* 的真实 parent 关系揭示到目标。只修改短生命周期显示投影,不伪造 canonical 关系。
*/
revealDegradedBranch(rootPostNumber, targetPostNumber) {
const canonical = this.#activeCanonical();
if (!canonical.has(rootPostNumber) || !canonical.has(targetPostNumber) || canonical.depthOf(rootPostNumber) !== void 0)
return !1;
const parents = [], seen = /* @__PURE__ */ new Set([targetPostNumber]);
let current = targetPostNumber;
for (; current !== rootPostNumber; ) {
const parent = canonical.parentOf(current);
if (parent == null || seen.has(parent))
return !1;
parents.push(parent), seen.add(parent), current = parent;
}
let changed = !1;
this.#degradedFloorRoots.has(rootPostNumber) || (this.#degradedFloorRoots.add(rootPostNumber), changed = !0), this.#revealedFloors.has(rootPostNumber) || (this.#revealedFloors.add(rootPostNumber), changed = !0);
for (const parent of parents)
parent === targetPostNumber || this.#revealedParents.has(parent) || (this.#revealedParents.add(parent), changed = !0);
return this.#degradedFloorCanonicalRevision = this.#activeCanonicalRevision(), changed && (this.#projectionRevision += 1), changed;
}
parentOf(postNumber) {
const canonical = this.#activeCanonical(), canonicalDepth = canonical.depthOf(postNumber), degradedPath = this.#degradedFloorPath(postNumber, canonical), projectedDepth = degradedPath?.depth ?? canonicalDepth;
if (projectedDepth === void 0)
return this.#postFilter && this.#matchesPostFilter(postNumber) ? null : void 0;
if (degradedPath?.depth === 0 || this.#isRevealedFloor(postNumber, canonicalDepth)) return null;
if (this.#matchesPostFilter(postNumber))
return this.#postFilter || projectedDepth === 0 ? null : this.#isInlineVisible(postNumber, projectedDepth) ? canonical.parentOf(postNumber) : this.#showAsFloor() ? null : void 0;
}
childrenOf(postNumber) {
const canonical = this.#activeCanonical();
if (this.#postFilter) return Object.freeze([]);
const parentDepth = this.#projectedDepth(postNumber, canonical);
return parentDepth === void 0 || !this.#isInlineVisible(postNumber, parentDepth) || parentDepth >= inlineDepth(this.#preferences) && !this.#revealedParents.has(postNumber) ? Object.freeze([]) : canonical.childrenOf(postNumber);
}
subtreePostCountOf(postNumber) {
this.#syncCache();
const cached = this.#cachedSubtreePostCounts.get(postNumber);
if (cached !== void 0) return cached;
if (this.parentOf(postNumber) === void 0) return;
const childrenByPost = /* @__PURE__ */ new Map(), order = [], pending = [postNumber], visited = /* @__PURE__ */ new Set();
for (; pending.length; ) {
const current = pending.pop();
if (visited.has(current)) continue;
visited.add(current), order.push(current);
const children = this.childrenOf(current);
childrenByPost.set(current, children), pending.push(...children);
}
const subtreePostCounts = /* @__PURE__ */ new Map();
for (let index = order.length - 1; index >= 0; index -= 1) {
const current = order[index], subtreePostCount = 1 + (childrenByPost.get(current) ?? []).reduce(
(count, childPostNumber) => count + (subtreePostCounts.get(childPostNumber) ?? 1),
0
);
subtreePostCounts.set(current, subtreePostCount), subtreePostCount > 1 && this.#cachedSubtreePostCounts.set(current, subtreePostCount);
}
return subtreePostCounts.get(postNumber) ?? 1;
}
hiddenDirectChildrenOf(postNumber) {
if (this.#postFilter) return Object.freeze([]);
const canonical = this.#activeCanonical(), visibleChildren = new Set(this.childrenOf(postNumber));
return Object.freeze(
canonical.childrenOf(postNumber).filter((childPostNumber) => !visibleChildren.has(childPostNumber) && this.rootOf(childPostNumber) === void 0)
);
}
/** 用户只揭示当前父节点的直属下一层;更深后代仍停在各自的“+”之后。 */
revealNextLevel(postNumber) {
if (this.#postFilter) return !1;
const canonical = this.#activeCanonical(), projectedDepth = this.#projectedDepth(postNumber, canonical);
return projectedDepth === void 0 || !this.#isInlineVisible(postNumber, projectedDepth) || this.#revealedParents.has(postNumber) || this.hiddenDirectChildrenOf(postNumber).length === 0 ? !1 : (this.#revealedParents.add(postNumber), this.#projectionRevision += 1, !0);
}
hiddenFloorRunAfter(postNumber) {
return this.#syncCache(), this.#cachedHiddenFloorRunsAfter.get(postNumber) ?? Object.freeze([]);
}
depthOf(postNumber) {
const canonical = this.#activeCanonical(), canonicalDepth = canonical.depthOf(postNumber), degradedPath = this.#degradedFloorPath(postNumber, canonical), projectedDepth = degradedPath?.depth ?? canonicalDepth;
if (projectedDepth === void 0)
return this.#postFilter && this.#matchesPostFilter(postNumber) ? 0 : void 0;
if (degradedPath?.depth === 0 || this.#isRevealedFloor(postNumber, canonicalDepth)) return 0;
if (this.#matchesPostFilter(postNumber))
return this.#postFilter ? 0 : this.#isInlineVisible(postNumber, projectedDepth) ? projectedDepth : this.#showAsFloor() ? 0 : void 0;
}
rootOf(postNumber) {
const canonical = this.#activeCanonical(), canonicalDepth = canonical.depthOf(postNumber), degradedPath = this.#degradedFloorPath(postNumber, canonical), projectedDepth = degradedPath?.depth ?? canonicalDepth;
if (projectedDepth === void 0)
return this.#postFilter && this.#matchesPostFilter(postNumber) ? postNumber : void 0;
if (degradedPath?.depth === 0 || this.#isRevealedFloor(postNumber, canonicalDepth)) return postNumber;
if (this.#matchesPostFilter(postNumber))
return this.#postFilter ? postNumber : this.#isInlineVisible(postNumber, projectedDepth) ? degradedPath?.rootPostNumber ?? canonical.rootOf(postNumber) : this.#showAsFloor() ? postNumber : void 0;
}
roots() {
return this.#syncCache(), this.#cachedRoots;
}
rootBranches() {
return this.#syncCache(), this.#cachedRootBranches;
}
#syncCache() {
const canonical = this.#activeCanonical(), canonicalRevision = this.#activeCanonicalRevision(), canonicalCoverageComplete = this.#activeCanonicalCoverageComplete(), canonicalPostStreamRevision = this.#activeCanonicalPostStreamRevision();
if (this.#cachedCanonicalRevision === canonicalRevision && this.#cachedCanonicalCoverageComplete === canonicalCoverageComplete && this.#cachedCanonicalPostStreamRevision === canonicalPostStreamRevision && this.#cachedProjectionRevision === this.#projectionRevision) return;
const postNumbers = canonical.postNumbers();
canonicalCoverageComplete && (this.#trustedCanonicalCoverageThroughPostNumber = Math.max(
this.#trustedCanonicalCoverageThroughPostNumber,
postNumbers.at(-1) ?? 0
));
const trustedCoverageThroughPostNumber = this.#trustedCanonicalCoverageThroughPostNumber, roots = Object.freeze(postNumbers.filter((postNumber) => this.parentOf(postNumber) === null).sort((left, right) => left - right)), subtreePostCounts = /* @__PURE__ */ new Map();
let canonicalIndex = 0, previousRootCanonicalIndex = -1, previousRootPostNumber = 0;
const rootBranches = Object.freeze(roots.map((postNumber) => {
for (; canonicalIndex < postNumbers.length && postNumbers[canonicalIndex] < postNumber; ) canonicalIndex += 1;
const previousKnownPostNumber = canonicalIndex > 0 ? postNumbers[canonicalIndex - 1] ?? 0 : 0, canonicalPostStreamGapCount = this.#canonicalPostStreamGapCount?.(
postNumber,
previousRootPostNumber
), knownCanonicalPostCountBetween = Math.max(
0,
canonicalIndex - previousRootCanonicalIndex - 1
), exactUnloadedPostCountBefore = canonicalPostStreamGapCount === void 0 ? void 0 : Number.isSafeInteger(canonicalPostStreamGapCount) && canonicalPostStreamGapCount >= 0 ? Math.max(
0,
canonicalPostStreamGapCount - knownCanonicalPostCountBetween
) : 0, unloadedPostCountBefore = !canonicalCoverageComplete && !this.#postFilter ? postNumber <= trustedCoverageThroughPostNumber ? 0 : exactUnloadedPostCountBefore === void 0 ? Math.max(
0,
postNumber - Math.max(
previousKnownPostNumber,
trustedCoverageThroughPostNumber
) - 1
) : exactUnloadedPostCountBefore : 0;
previousRootCanonicalIndex = canonicalIndex, previousRootPostNumber = postNumber;
const pending = [postNumber], visited = /* @__PURE__ */ new Set();
let subtreePostCount = 0;
for (; pending.length; ) {
const current = pending.pop();
visited.has(current) || (visited.add(current), subtreePostCount += 1, pending.push(...this.childrenOf(current)));
}
return subtreePostCount > 1 && subtreePostCounts.set(postNumber, subtreePostCount), Object.freeze({
postNumber,
subtreePostCount,
...unloadedPostCountBefore > 0 ? { unloadedPostCountBefore } : {}
});
})), hiddenFloorRunsAfter = /* @__PURE__ */ new Map();
if (!this.#postFilter && this.#preferences.hideNestedReplyFloors) {
let relationIndex = 0;
for (let index = 0; index < roots.length; index += 1) {
const rootPostNumber = roots[index], nextRootPostNumber = roots[index + 1] ?? Number.POSITIVE_INFINITY;
for (; relationIndex < postNumbers.length && postNumbers[relationIndex] <= rootPostNumber; )
relationIndex += 1;
const run = [];
let previousPostNumber = rootPostNumber;
for (; relationIndex < postNumbers.length && postNumbers[relationIndex] < nextRootPostNumber; ) {
const hiddenPostNumber = postNumbers[relationIndex];
if (!canonicalCoverageComplete && hiddenPostNumber > trustedCoverageThroughPostNumber && hiddenPostNumber !== previousPostNumber + 1) break;
run.push(hiddenPostNumber), previousPostNumber = hiddenPostNumber, relationIndex += 1;
}
run.length > 0 && hiddenFloorRunsAfter.set(
rootPostNumber,
Object.freeze(run)
);
}
}
this.#cachedRoots = roots, this.#cachedRootBranches = rootBranches, this.#cachedSubtreePostCounts = subtreePostCounts, this.#cachedHiddenFloorRunsAfter = hiddenFloorRunsAfter, this.#cachedCanonicalRevision = canonicalRevision, this.#cachedCanonicalCoverageComplete = canonicalCoverageComplete, this.#cachedCanonicalPostStreamRevision = canonicalPostStreamRevision, this.#cachedProjectionRevision = this.#projectionRevision;
}
#activeCanonical() {
return this.#frozenCanonical ?? this.canonical;
}
#activeCanonicalRevision() {
return this.#frozenCanonical ? this.#frozenCanonicalSourceRevision : this.canonical.revision;
}
#activeCanonicalCoverageComplete() {
return this.#frozenCanonical ? this.#frozenCanonicalCoverageComplete : this.#canonicalCoverageComplete();
}
#activeCanonicalPostStreamRevision() {
return this.#frozenCanonical ? this.#frozenCanonicalPostStreamRevision : this.#canonicalPostStreamRevision();
}
#showAsFloor() {
return this.#preferences.expandLeafNestedReplies || !this.#preferences.hideNestedReplyFloors;
}
#syncDegradedFloorRoots(canonical) {
const revision = this.#activeCanonicalRevision();
if (revision !== this.#degradedFloorCanonicalRevision) {
for (const rootPostNumber of [...this.#degradedFloorRoots])
canonical.depthOf(rootPostNumber) !== void 0 && (this.#degradedFloorRoots.delete(rootPostNumber), this.#revealedFloors.delete(rootPostNumber));
this.#degradedFloorCanonicalRevision = revision;
}
}
#degradedFloorPath(postNumber, canonical) {
if (this.#syncDegradedFloorRoots(canonical), !this.#degradedFloorRoots.size || !canonical.has(postNumber))
return;
const seen = /* @__PURE__ */ new Set();
let current = postNumber, depth = 0;
for (; !seen.has(current); ) {
if (this.#degradedFloorRoots.has(current))
return Object.freeze({ rootPostNumber: current, depth });
seen.add(current);
const parent = canonical.parentOf(current);
if (parent == null) return;
current = parent, depth += 1;
}
}
#projectedDepth(postNumber, canonical) {
return this.#degradedFloorPath(postNumber, canonical)?.depth ?? canonical.depthOf(postNumber);
}
#isRevealedFloor(postNumber, canonicalDepth) {
return this.#degradedFloorRoots.has(postNumber) ? !0 : !this.#revealedFloors.has(postNumber) || canonicalDepth === void 0 ? !1 : this.#postFilter && !this.#matchesPostFilter(postNumber) || canonicalDepth > inlineDepth(this.#preferences) ? !0 : (this.#revealedFloors.delete(postNumber), !1);
}
#isInlineVisible(postNumber, projectedDepth) {
if (projectedDepth <= inlineDepth(this.#preferences)) return !0;
const canonical = this.#activeCanonical(), parentPostNumber = canonical.parentOf(postNumber);
if (parentPostNumber == null || !this.#revealedParents.has(parentPostNumber)) return !1;
const parentDepth = this.#projectedDepth(parentPostNumber, canonical);
return parentDepth !== void 0 && this.#isInlineVisible(parentPostNumber, parentDepth);
}
#matchesPostFilter(postNumber) {
const filter = this.#postFilter;
if (!filter) return !0;
if (!filter.matches(postNumber)) return !1;
if (!filter.hideDescendantMatches) return !0;
const canonical = this.#activeCanonical(), seen = /* @__PURE__ */ new Set([postNumber]);
let parent = canonical.parentOf(postNumber);
for (; parent != null && !seen.has(parent); ) {
if (parent === filter.ancestorBoundaryPostNumber) return !0;
if (filter.matches(parent)) return !1;
seen.add(parent), parent = canonical.parentOf(parent);
}
return !0;
}
}
}, "3a7c25e5877aee489ff7dab29ad1d03825ba400a54f95167c3a96a5b026049c0");
/* Source: lite/src/topic/reader-topic-comments-header.ts */
runtime.register("src/topic/reader-topic-comments-header.js", function(module, exports, require) {
var reader_topic_comments_header_exports = {};
__export(reader_topic_comments_header_exports, {
ReaderTopicCommentsHeader: () => ReaderTopicCommentsHeader
});
module.exports = __toCommonJS(reader_topic_comments_header_exports);
var import_reader_icon = require("../components/reader-icon.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_value_record = require("../kernel/value-record.js");
function positiveCount(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : 0;
}
function commentCount(topicValue, posts) {
const topic = (0, import_value_record.objectRecord)(topicValue), total = Math.max(
positiveCount(topic?.posts_count),
positiveCount(topic?.highest_post_number),
posts.reduce(
(maximum, post) => Math.max(maximum, positiveCount(post.post_number)),
0
)
);
return Math.max(0, total - 1);
}
class ReaderTopicCommentsHeader {
scope;
#document;
#topicRoot;
#topicDetails;
#topicDetailsHome;
#topicDetailsNext;
#session;
#presentation;
#currentUsername;
#renderIcon;
#onError;
#count = null;
#presence = null;
#presenceUsers = Object.freeze([]);
constructor(options) {
this.#document = options.document, this.#topicRoot = options.topicRoot, this.#topicDetails = options.topicDetails;
const topicDetailsHome = options.topicDetails.parentNode;
if (!topicDetailsHome)
throw new Error("帖子详情必须具有稳定 Shell parking");
this.#topicDetailsHome = topicDetailsHome, this.#topicDetailsNext = options.topicDetails.nextSibling, this.#session = options.session, this.#presentation = options.presentation, this.#currentUsername = String(options.currentUsername ?? "").trim(), this.#renderIcon = options.renderIcon, this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#session.changes.subscribe(() => this.#refreshCount(), this.scope), this.scope.add(options.presence.watchReplying(
options.topicId,
(users) => {
this.#presenceUsers = Object.freeze(users.filter(
(user) => user.username !== this.#currentUsername
)), this.#renderPresence();
},
this.#onError
)), this.scope.add(() => {
this.#topicDetailsHome.insertBefore(
this.#topicDetails,
this.#topicDetailsNext
), this.#count = null, this.#presence = null;
});
}
afterRender(post, view) {
if (positiveCount(post.post_number) !== 1) return;
const root = view.slots.root;
let header = root.querySelector(
":scope > .ldp-comments-header"
);
header || (header = this.#createHeader(), view.slots.replyTree.after(header)), this.#count = header.querySelector(
":scope > .ldp-comments-count"
), this.#presence = header.querySelector(
":scope > .ldp-topic-presence"
), this.#refreshCount(), this.#renderPresence(), this.#mountTopicDetails(root);
}
attachRoot(root, postNumber) {
postNumber === 1 && this.#mountTopicDetails(root);
}
destroy() {
this.scope.destroy();
}
#createHeader() {
const header = this.#document.createElement("div");
header.className = "ldp-comments-header", header.append((0, import_reader_icon.renderReaderIcon)(
this.#document,
"message-square",
this.#renderIcon
));
const label = this.#document.createElement("span");
label.className = "ldp-comments-label", label.textContent = "评论";
const count = this.#document.createElement("span");
count.className = "ldp-comments-count", count.setAttribute("aria-live", "polite");
const presence = this.#document.createElement("span");
return presence.className = "ldp-topic-presence", presence.hidden = !0, presence.setAttribute("aria-live", "polite"), header.append(label, count, presence), header;
}
#mountTopicDetails(root) {
if (!this.#topicRoot.contains(root)) return;
const header = root.querySelector(
":scope > .ldp-comments-header"
);
header && header.before(this.#topicDetails);
}
#refreshCount() {
if (!this.#count) return;
const count = commentCount(
this.#session.topic,
this.#session.cachedPosts()
);
this.#count.textContent = `(${count})`;
}
#renderPresence() {
const root = this.#presence;
if (!root?.isConnected) return;
root.replaceChildren();
const users = this.#presenceUsers;
if (!users.length) {
root.hidden = !0;
return;
}
const avatars = this.#document.createElement("span");
avatars.className = "ldp-topic-presence-avatars";
for (const user of users.slice(0, 3)) {
const link = this.#document.createElement("a");
link.className = "ldp-topic-presence-user ldp-user-link", link.href = this.#presentation.userHref(user.username), link.target = "_blank", link.rel = "noopener noreferrer", link.dataset.userCard = user.username, link.setAttribute("aria-label", user.name);
const source = user.avatarTemplate ? this.#presentation.avatarSource(user.avatarTemplate, 24) : "";
if (source) {
const image = this.#document.createElement("img");
image.className = "ldp-topic-presence-avatar", image.src = source, image.alt = "", image.loading = "lazy", image.decoding = "async", link.append(image);
} else {
const fallback = this.#document.createElement("span");
fallback.className = "ldp-topic-presence-avatar ldp-avatar-fallback", fallback.textContent = user.name.slice(0, 1).toUpperCase(), link.append(fallback);
}
avatars.append(link);
}
const label = this.#document.createElement("span");
label.className = "ldp-topic-presence-text", label.textContent = users.length === 1 ? `${users[0].name} 正在回复…` : `${users.length} 人正在回复…`, root.append(avatars, label), root.hidden = !1;
}
}
}, "bafc410393896caadf2cb3b45d7166295720b29958e2110c5b725426652c632e");
/* Source: lite/src/topic/reader-topic-context-controller.ts */
runtime.register("src/topic/reader-topic-context-controller.js", function(module, exports, require) {
var reader_topic_context_controller_exports = {};
__export(reader_topic_context_controller_exports, {
ReaderTopicContextController: () => ReaderTopicContextController
});
module.exports = __toCommonJS(reader_topic_context_controller_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js"), import_reader_reply_ancestor_resolver = require("./reader-reply-ancestor-resolver.js");
function frozenPostNumbers(values) {
return Object.freeze([...values].sort((left, right) => left - right));
}
class ReaderTopicContextController {
scope;
changes = new import_signal.Signal();
#session;
#replies;
#loadCrossTopicQuotedPost;
#onError;
#quotedPosts = /* @__PURE__ */ new Map();
#unavailableQuotedPostKeys = /* @__PURE__ */ new Set();
#collapsedPostNumbers = /* @__PURE__ */ new Set();
#discussionContextualParents = /* @__PURE__ */ new Map();
#discussionRootPostNumber = null;
#discussionDescendantRootPostNumber = null;
#discussionTargetPostNumber = null;
#discussionLoading = !1;
#discussionBranchPartial = !1;
#discussionUsesGlobalCoverage = !1;
#revision = 0;
#discussionEpoch = 0;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#session = options.session, this.#replies = options.replies, this.#loadCrossTopicQuotedPost = options.loadCrossTopicQuotedPost, this.#onError = options.onError ?? (() => {
}), this.#session.changes.subscribe(() => this.#emit(), this.scope), this.#replies.changes.subscribe(() => this.#emit(), this.scope), this.scope.add(() => {
this.#discussionEpoch += 1, this.#collapsedPostNumbers.clear(), this.#discussionContextualParents.clear(), this.#quotedPosts.clear(), this.#unavailableQuotedPostKeys.clear(), this.changes.clear();
});
}
get topicId() {
return (0, import_identifiers.discourseTopicId)(this.#session.topicId);
}
postByNumber(postNumberValue) {
return this.#session.postByNumber(
(0, import_identifiers.discoursePostNumber)(postNumberValue)
);
}
quotedPost(topicIdValue, postNumberValue) {
const topicId = (0, import_identifiers.discourseTopicId)(topicIdValue), postNumber = (0, import_identifiers.discoursePostNumber)(postNumberValue);
return topicId === this.topicId ? this.#session.postByNumber(postNumber) : this.#quotedPosts.get(`${topicId}:${postNumber}`);
}
snapshot() {
return Object.freeze({
discussion: this.#discussionSnapshot(),
revision: this.#revision
});
}
/** 同 Topic 复用 canonical session;跨 Topic 通过组合根注入的统一请求网关读取。 */
async loadQuotedPost(topicIdValue, postNumberValue) {
this.#assertActive();
const topicId = (0, import_identifiers.discourseTopicId)(topicIdValue), postNumber = (0, import_identifiers.discoursePostNumber)(postNumberValue), key = `${topicId}:${postNumber}`;
if (topicId !== this.topicId) {
const cached2 = this.#quotedPosts.get(key);
if (cached2) return cached2;
if (this.#unavailableQuotedPostKeys.has(key) || !this.#loadCrossTopicQuotedPost) return null;
try {
const post = await this.#loadCrossTopicQuotedPost(
topicId,
postNumber
);
return this.scope.destroyed ? null : post ? (this.#unavailableQuotedPostKeys.delete(key), this.#quotedPosts.set(key, post), post) : (this.#unavailableQuotedPostKeys.add(key), null);
} catch (error) {
return this.scope.destroyed || this.#onError(error), null;
}
}
const cached = this.#session.postByNumber(postNumber);
if (cached) return cached;
try {
await this.#session.loadTarget(postNumber, {
scope: "single",
advanceCursor: !1
});
} catch (error) {
return this.scope.destroyed || this.#onError(error), null;
}
return this.scope.destroyed ? null : this.#session.postByNumber(postNumber) ?? null;
}
async openDiscussion(postNumberValue, options = {}) {
this.#assertActive();
const requestedPostNumber = (0, import_identifiers.discoursePostNumber)(postNumberValue), targetPostNumber = options.targetPostNumber === null ? null : (0, import_identifiers.discoursePostNumber)(
options.targetPostNumber ?? requestedPostNumber
), requestedDescendantRootPostNumber = options.descendantRootPostNumber === void 0 ? null : (0, import_identifiers.discoursePostNumber)(options.descendantRootPostNumber), epoch = ++this.#discussionEpoch;
this.#discussionLoading = !0, this.#discussionBranchPartial = !1, this.#discussionUsesGlobalCoverage = !1, this.#collapsedPostNumbers.clear(), this.#discussionContextualParents.clear();
let rootPostNumber = requestedPostNumber;
try {
await this.#ensurePost(requestedPostNumber), requestedDescendantRootPostNumber !== null && requestedDescendantRootPostNumber !== requestedPostNumber && await this.#ensurePost(requestedDescendantRootPostNumber), options.explicitRoot || (rootPostNumber = await this.#branchRootOf(
requestedPostNumber,
epoch
));
} catch (error) {
epoch === this.#discussionEpoch && !this.scope.destroyed && (this.#discussionBranchPartial = !0, this.#onError(error));
}
if (epoch !== this.#discussionEpoch || this.scope.destroyed)
return this.snapshot();
if (!this.#session.postByNumber(rootPostNumber))
return this.#discussionLoading = !1, this.#discussionRootPostNumber = null, this.#discussionDescendantRootPostNumber = null, this.#discussionTargetPostNumber = null, this.#emit();
const descendantRootPostNumber = requestedDescendantRootPostNumber ?? rootPostNumber;
this.#discussionRootPostNumber = rootPostNumber, this.#discussionDescendantRootPostNumber = descendantRootPostNumber, this.#discussionTargetPostNumber = targetPostNumber, this.#emit();
try {
if (this.#session.loadReplyBranches) {
const result = await this.#session.loadReplyBranches(
[descendantRootPostNumber],
{ background: !1, maxPages: 32 }
);
if (epoch !== this.#discussionEpoch || this.scope.destroyed)
return this.snapshot();
this.#discussionBranchPartial ||= !result.complete;
for (const relation of result.contextualReplyRelations) {
const parentPostNumber = (0, import_identifiers.discoursePostNumber)(
relation.parentPostNumber
), postNumber = (0, import_identifiers.discoursePostNumber)(relation.postNumber);
postNumber === parentPostNumber || !this.#session.postByNumber(parentPostNumber) || !this.#session.postByNumber(postNumber) || this.#discussionContextualParents.has(postNumber) || this.#discussionContextualParents.set(
postNumber,
parentPostNumber
);
}
for (const error of result.errors) this.#onError(error);
} else
this.#discussionUsesGlobalCoverage = !0, await this.#session.ensurePostStream({
background: !1,
maxAttempts: 2
});
} catch (error) {
epoch === this.#discussionEpoch && !this.scope.destroyed && (this.#discussionBranchPartial = !0, this.#onError(error));
}
return epoch !== this.#discussionEpoch || this.scope.destroyed ? this.snapshot() : (this.#discussionLoading = !1, this.#emit());
}
closeDiscussion() {
return this.#assertActive(), !this.#discussionRootPostNumber && !this.#discussionLoading ? this.snapshot() : (this.#discussionEpoch += 1, this.#discussionRootPostNumber = null, this.#discussionDescendantRootPostNumber = null, this.#discussionTargetPostNumber = null, this.#discussionLoading = !1, this.#discussionBranchPartial = !1, this.#discussionUsesGlobalCoverage = !1, this.#collapsedPostNumbers.clear(), this.#discussionContextualParents.clear(), this.#emit());
}
toggleDiscussionBranch(postNumberValue) {
this.#assertActive();
const postNumber = (0, import_identifiers.discoursePostNumber)(postNumberValue);
return this.#collapsedPostNumbers.has(postNumber) ? this.#collapsedPostNumbers.delete(postNumber) : this.#collapsedPostNumbers.add(postNumber), this.#emit();
}
clearDiscussionTarget() {
return this.#assertActive(), this.#discussionTargetPostNumber === null ? this.snapshot() : (this.#discussionTargetPostNumber = null, this.#emit());
}
destroy() {
this.scope.destroy();
}
async #ensurePost(postNumber) {
const cached = this.#session.postByNumber(postNumber);
return cached || (await this.#session.loadTarget(postNumber, {
scope: "single",
advanceCursor: !1
}), this.#session.postByNumber(postNumber) ?? null);
}
async #branchRootOf(targetPostNumber, epoch) {
const resolution = await (0, import_reader_reply_ancestor_resolver.resolveReaderReplyAncestors)(
this.#session,
targetPostNumber,
{
stopBeforePostNumber: 1,
isActive: () => epoch === this.#discussionEpoch && !this.scope.destroyed
}
);
return !resolution.complete && epoch === this.#discussionEpoch && !this.scope.destroyed && (this.#discussionBranchPartial = !0), resolution.error !== void 0 && this.#onError(resolution.error), resolution.rootPostNumber;
}
#replyCount(post) {
const count = Number(post.reply_count ?? 0);
return Number.isSafeInteger(count) && count > 0 ? count : 0;
}
#discussionSnapshot() {
const rootPostNumber = this.#discussionRootPostNumber, descendantRootPostNumber = this.#discussionDescendantRootPostNumber;
if (rootPostNumber === null || descendantRootPostNumber === null)
return null;
const lineage = this.#discussionLineage(
rootPostNumber,
descendantRootPostNumber
), lineageNext = /* @__PURE__ */ new Map();
for (let index = 0; index < lineage.length - 1; index += 1)
lineageNext.set(lineage[index], lineage[index + 1]);
const entries = [], pending = [{
postNumber: rootPostNumber,
parentPostNumber: null,
depth: 0
}], seen = /* @__PURE__ */ new Set();
let missingCanonicalPost = !1;
for (let index = 0; index < pending.length; index += 1) {
const entry = pending[index];
if (seen.has(entry.postNumber)) continue;
seen.add(entry.postNumber);
const post = this.#session.postByNumber(entry.postNumber);
post ? entries.push(Object.freeze({ ...entry, post })) : missingCanonicalPost = !0;
const nextLineagePostNumber = lineageNext.get(entry.postNumber);
for (const child of this.#discussionChildrenOf(entry.postNumber))
nextLineagePostNumber !== void 0 && child !== nextLineagePostNumber || pending.push(Object.freeze({
postNumber: (0, import_identifiers.discoursePostNumber)(child),
parentPostNumber: entry.postNumber,
depth: entry.depth + 1
}));
}
const postCoverage = this.#session.postStreamCoverage(), treeCoverage = this.#replies.coverage(), descendantEntry = entries.find((entry) => entry.postNumber === descendantRootPostNumber), branchHasMissingReplies = entries.some((entry) => entry.depth >= (descendantEntry?.depth ?? 0) && this.#replyCount(entry.post) > this.#discussionChildrenOf(entry.postNumber).length);
return Object.freeze({
rootPostNumber,
descendantRootPostNumber,
targetPostNumber: this.#discussionTargetPostNumber,
entries: Object.freeze(entries),
collapsedPostNumbers: frozenPostNumbers(
this.#collapsedPostNumbers
),
loading: this.#discussionLoading,
partial: this.#discussionLoading || missingCanonicalPost || this.#discussionBranchPartial || branchHasMissingReplies || this.#discussionUsesGlobalCoverage && (!postCoverage.complete || !treeCoverage.complete)
});
}
#discussionChildrenOf(parentPostNumber) {
const children = /* @__PURE__ */ new Set();
for (const child of this.#replies.topology.childrenOf(parentPostNumber)) {
const postNumber = (0, import_identifiers.discoursePostNumber)(child), contextualParent = this.#discussionContextualParents.get(postNumber);
contextualParent !== void 0 && contextualParent !== parentPostNumber || children.add(postNumber);
}
for (const [postNumber, contextualParent] of this.#discussionContextualParents)
contextualParent === parentPostNumber && children.add(postNumber);
return Object.freeze([...children].sort((left, right) => left - right));
}
#discussionLineage(rootPostNumber, descendantRootPostNumber) {
const reversed = [], seen = /* @__PURE__ */ new Set();
let current = descendantRootPostNumber;
for (; !seen.has(current); ) {
if (seen.add(current), reversed.push(current), current === rootPostNumber)
return Object.freeze(reversed.reverse());
const parent = this.#replies.topology.parentOf(current);
if (parent == null) break;
current = (0, import_identifiers.discoursePostNumber)(parent);
}
return Object.freeze([rootPostNumber]);
}
#emit() {
if (this.scope.destroyed) return this.snapshot();
this.#revision += 1;
const snapshot = this.snapshot();
for (const error of this.changes.emit(snapshot)) this.#onError(error);
return snapshot;
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("ReaderTopicContextController 已销毁");
}
}
}, "45e132944170204a8aebb5e07d6d70f747a0b720cadf4b3c37206bee7f165552");
/* Source: lite/src/topic/reader-topic-context-state.ts */
runtime.register("src/topic/reader-topic-context-state.js", function(module, exports, require) {
var reader_topic_context_state_exports = {};
__export(reader_topic_context_state_exports, {
READER_TOPIC_CONTEXT_STATE_KEY: () => READER_TOPIC_CONTEXT_STATE_KEY,
ReaderTopicContextStateRepository: () => ReaderTopicContextStateRepository,
readerTopicContextWebStorage: () => readerTopicContextWebStorage
});
module.exports = __toCommonJS(reader_topic_context_state_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_signal = require("../kernel/signal.js"), import_reader_account_scoped_storage = require("../state/reader-account-scoped-storage.js");
const READER_TOPIC_CONTEXT_STATE_KEY = "linuxdo-enhanced-reader:reply-window:v1";
function record(value) {
if (typeof value == "string")
try {
return record(JSON.parse(value));
} catch {
return null;
}
return value && typeof value == "object" && !Array.isArray(value) ? value : null;
}
function finite(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function geometry(value) {
const source = record(value);
if (!source) return null;
const width = finite(source.width), height = finite(source.height);
return !(width > 0) || !(height > 0) ? null : Object.freeze({
left: finite(source.left),
top: finite(source.top),
width,
height
});
}
function anchorPoint(value) {
const source = record(value);
if (!source) return null;
try {
return Object.freeze({
number: (0, import_identifiers.discoursePostNumber)(source.number),
scrollTop: Math.max(0, finite(source.scrollTop)),
scrollLeft: Math.max(0, finite(source.scrollLeft)),
offset: finite(source.offset, 12)
});
} catch {
return null;
}
}
function viewKey(host, topicId, rootPostNumber) {
return `${String(host).trim()}:${topicId}:${rootPostNumber}:0`;
}
function normalizedState(value, maxViews) {
const source = record(value), entries = [];
for (const [key, rawPoint] of Object.entries(record(source?.views) ?? {})) {
const point = anchorPoint(rawPoint);
point && entries.push(Object.freeze([
key,
Object.freeze({
...point,
at: Math.max(0, finite(record(rawPoint)?.at))
})
]));
}
return entries.sort((left, right) => left[1].at - right[1].at), Object.freeze({
fullPageGeometry: geometry(source?.fullPageGeometry),
views: Object.freeze(Object.fromEntries(entries.slice(-maxViews)))
});
}
class ReaderTopicContextStateRepository {
changes = new import_signal.Signal();
#storage;
#key;
#accountStorage;
#maxViews;
#onError;
#state;
#write = Promise.resolve();
#loadPromise = null;
constructor(options) {
if (this.#storage = options.storage, this.#accountStorage = options.key === void 0 && options.authScope !== void 0 ? (0, import_reader_account_scoped_storage.readerAccountScopedStorageIdentity)(
READER_TOPIC_CONTEXT_STATE_KEY,
options.authScope
) : null, this.#key = String(options.key ?? this.#accountStorage?.key ?? READER_TOPIC_CONTEXT_STATE_KEY).trim(), !this.#key) throw new Error("完整讨论状态 storage key 不能为空");
const maxViews = Math.floor(Number(options.maxViews ?? 128));
if (!Number.isSafeInteger(maxViews) || maxViews < 1)
throw new RangeError("完整讨论持久锚点上限必须是正整数");
this.#maxViews = maxViews, this.#onError = options.onError ?? (() => {
}), this.#state = normalizedState(null, maxViews);
}
get snapshot() {
return this.#state;
}
get storageKey() {
return this.#key;
}
subscribeExternal(listener) {
return this.#storage.subscribe?.(this.#key, listener) ?? (() => {
});
}
load() {
return this.#loadPromise ? this.#loadPromise : (this.#loadPromise = (async () => {
try {
const loaded = normalizedState(
this.#accountStorage ? await (0, import_reader_account_scoped_storage.readReaderAccountScopedValue)(
this.#storage,
this.#accountStorage
) : await this.#storage.getValue(this.#key),
this.#maxViews
), current = this.#state, hasLocalState = current.fullPageGeometry !== null || Object.keys(current.views).length > 0;
this.#state = normalizedState({
fullPageGeometry: current.fullPageGeometry ?? loaded.fullPageGeometry,
views: {
...loaded.views,
...current.views
}
}, this.#maxViews), hasLocalState && this.#persist(), this.changes.emit(this.#state);
} catch (error) {
this.#onError(error);
}
return this.#state;
})(), this.#loadPromise);
}
async reloadExternal() {
await this.#write;
try {
this.#state = normalizedState(
this.#accountStorage ? await (0, import_reader_account_scoped_storage.readReaderAccountScopedValue)(
this.#storage,
this.#accountStorage
) : await this.#storage.getValue(this.#key),
this.#maxViews
), this.changes.emit(this.#state);
} catch (error) {
this.#onError(error);
}
return this.#state;
}
point(host, topicIdValue, rootPostNumberValue) {
const topicId = (0, import_identifiers.discourseTopicId)(topicIdValue), rootPostNumber = (0, import_identifiers.discoursePostNumber)(rootPostNumberValue), value = this.#state.views[viewKey(host, topicId, rootPostNumber)];
return value ? Object.freeze({
number: value.number,
scrollTop: value.scrollTop,
scrollLeft: value.scrollLeft,
offset: value.offset
}) : null;
}
rememberGeometry(value) {
const nextGeometry = geometry(value);
nextGeometry && (this.#state = Object.freeze({
...this.#state,
fullPageGeometry: nextGeometry
}), this.#persist());
}
rememberPoint(host, topicIdValue, rootPostNumberValue, point, now = Date.now()) {
if (!point) return;
const topicId = (0, import_identifiers.discourseTopicId)(topicIdValue), rootPostNumber = (0, import_identifiers.discoursePostNumber)(rootPostNumberValue), key = viewKey(host, topicId, rootPostNumber), entries = Object.entries(this.#state.views).filter(
([entryKey]) => entryKey !== key
);
entries.push([
key,
Object.freeze({
...point,
at: Math.max(0, finite(now))
})
]);
const views = Object.fromEntries(entries);
this.#state = normalizedState({
fullPageGeometry: this.#state.fullPageGeometry,
views
}, this.#maxViews), this.#persist();
}
replaceExternal(value) {
return this.#state = normalizedState(value, this.#maxViews), this.#persist(), this.changes.emit(this.#state), this.#state;
}
async flush() {
await this.#write;
}
#persist() {
const state = this.#state;
this.#write = this.#write.catch(() => {
}).then(async () => {
try {
await this.#storage.setValue(
this.#key,
state
);
} catch (error) {
this.#onError(error);
}
});
}
}
function readerTopicContextWebStorage(storage) {
return Object.freeze({
getValue: (key) => storage.getItem(key),
setValue: (key, value) => {
storage.setItem(key, JSON.stringify(value));
}
});
}
}, "a0d566455ec3d3e0c3dad7d45e48b92fd0d6266415feb5804dbdefc4ce3e73fb");
/* Source: lite/src/topic/reader-topic-context-surface.ts */
runtime.register("src/topic/reader-topic-context-surface.js", function(module, exports, require) {
var reader_topic_context_surface_exports = {};
__export(reader_topic_context_surface_exports, {
ReaderTopicContextFeature: () => ReaderTopicContextFeature,
ReaderTopicContextSurface: () => ReaderTopicContextSurface
});
module.exports = __toCommonJS(reader_topic_context_surface_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_event_target = require("../dom/event-target.js"), import_floating_surface_wheel = require("../dom/floating-surface-wheel.js"), import_html_element = require("../dom/html-element.js"), import_reply_tree_dom_owner = require("../dom/reply-tree-dom-owner.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_icon = require("../components/reader-icon.js"), import_branch_overlay = require("../layout/branch-overlay.js"), import_reader_workspace = require("../shell/reader-workspace.js"), import_reader_escape_surface = require("../shell/reader-escape-surface.js"), import_reader_post_view_projector = require("./reader-post-view-projector.js"), import_reader_topic_context_state = require("./reader-topic-context-state.js");
function button(document, className, label, text = "") {
const value = (0, import_html_element.htmlElement)(document, "button", className);
return value.type = "button", value.setAttribute("aria-label", label), value.textContent = text, value;
}
const HIDDEN_REPLY_MATERIALIZE_BATCH_SIZE = 100, QUOTE_HINT_POINTER_GAP_PX = 4, QUOTE_HINT_HIDE_GRACE_MS = 480;
function postCooked(post) {
return String(
post.cooked ?? ""
);
}
function postReplyCount(post) {
const value = Number(
post.reply_count ?? 0
);
return Number.isSafeInteger(value) && value > 0 ? value : 0;
}
function comparableQuoteText(value) {
return value.replace(/[ \t\r\n]/g, "");
}
function quoteExcerptText(document, html) {
const container = document.createElement("div");
return container.innerHTML = html, String(container.textContent ?? "").trim();
}
function usableQuoteHintRect(rect) {
return [rect.left, rect.top, rect.right, rect.bottom, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0;
}
function quoteHintElementRects(element2) {
const clientRects = typeof element2.getClientRects == "function" ? Array.from(element2.getClientRects()).filter(usableQuoteHintRect) : [];
if (clientRects.length) return clientRects;
const bounds = element2.getBoundingClientRect();
return usableQuoteHintRect(bounds) ? Object.freeze([bounds]) : Object.freeze([]);
}
function markQuoteTextMatch(document, root, text) {
const comparable = comparableQuoteText(text);
if (!comparable) return Object.freeze([]);
const characters = [], positions = [], showText = document.defaultView?.NodeFilter?.SHOW_TEXT ?? 4, walker = document.createTreeWalker(root, showText);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const textNode = node, parent = textNode.parentElement;
if (!parent || parent.closest(
"button,script,style,noscript,.ldp-quote-highlight-close"
))
continue;
const value = textNode.nodeValue ?? "";
for (let offset = 0; offset < value.length; offset += 1) {
const character = value[offset];
/[ \t\r\n]/.test(character) || (characters.push(character), positions.push(Object.freeze({ node: textNode, offset })));
}
}
const matchStart = characters.join("").indexOf(comparable);
if (matchStart < 0) return Object.freeze([]);
const groups = [];
for (let index = matchStart; index < matchStart + comparable.length; index += 1) {
const position = positions[index];
if (!position) continue;
const previous = groups.at(-1);
previous?.node === position.node ? previous.end = position.offset + 1 : groups.push({
node: position.node,
start: position.offset,
end: position.offset + 1
});
}
const marks = [];
for (let index = groups.length - 1; index >= 0; index -= 1) {
const group = groups[index], parent = group.node.parentNode;
if (!parent) continue;
const value = group.node.nodeValue ?? "", fragment = document.createDocumentFragment();
group.start > 0 && fragment.append(document.createTextNode(value.slice(0, group.start)));
const mark = document.createElement("mark");
mark.className = "ldp-quote-match", mark.textContent = value.slice(group.start, group.end), fragment.append(mark), group.end < value.length && fragment.append(document.createTextNode(value.slice(group.end))), parent.replaceChild(fragment, group.node), marks.unshift(mark);
}
return Object.freeze(marks);
}
class ReaderTopicContextFeature {
activationScope = "branch";
scope;
#document;
#controller;
#replies;
#presentation;
#scrollRoot;
#navigate;
#target;
#avatarSource;
#renderIcon;
#onQuoteBodyChanged;
#onRevealNextReplyLevel;
#revealQuoteTarget;
#navigationRetryDelay;
#quoteHintHost;
#notify;
#onError;
#rootCleanups = /* @__PURE__ */ new WeakMap();
#viewByRoot = /* @__PURE__ */ new WeakMap();
#hiddenReplyMarkers = /* @__PURE__ */ new Map();
#hiddenReplyPostNumbers = /* @__PURE__ */ new WeakMap();
#rootsByPostNumber = /* @__PURE__ */ new Map();
#collapsedRootReplies = /* @__PURE__ */ new Set();
#quoteJumpExcerptByElement = /* @__PURE__ */ new WeakMap();
#quotePostLoads = /* @__PURE__ */ new Map();
#expandedQuoteKeys = /* @__PURE__ */ new Set();
#quoteSourcePort = null;
#quoteHighlight = null;
#quoteHighlightMarks = Object.freeze([]);
#quoteHighlightHint = null;
#quoteReturnEntries = /* @__PURE__ */ new Set();
#quoteHintHideTimer = null;
#quotePositionEpoch = 0;
#quoteJumpEpoch = 0;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#document = options.document, this.#controller = options.controller, this.#replies = options.replies, this.#presentation = options.presentation, this.#scrollRoot = options.scrollRoot, this.#navigate = options.navigate, this.#target = options.target, this.#avatarSource = options.avatarSource, this.#renderIcon = options.renderIcon, this.#onQuoteBodyChanged = options.onQuoteBodyChanged, this.#onRevealNextReplyLevel = options.onRevealNextReplyLevel, this.#revealQuoteTarget = options.revealQuoteTarget, this.#navigationRetryDelay = options.navigationRetryDelay ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))), this.#quoteHintHost = options.quoteHintHost ?? options.document.body ?? options.document.documentElement, this.#notify = options.notify ?? (() => {
}), this.#onError = options.onError ?? (() => {
});
for (const type of [
"ldp-reader-window-change",
"ldp-reader-workspace-change"
])
this.scope.listen(this.#quoteHintHost, type, () => {
this.#quoteHighlightHint?.classList.remove(
"ldp-quote-hint-visible"
);
});
(options.presentationChanges ?? this.#replies.changes).subscribe(() => {
for (const roots of this.#rootsByPostNumber.values())
for (const root of roots) this.#syncMountedTree(root);
}, this.scope), this.scope.add(() => {
this.#quoteJumpEpoch += 1, this.#quotePositionEpoch += 1, this.#scrollRoot.classList.remove("ldp-quote-positioning");
for (const roots of this.#rootsByPostNumber.values())
for (const root of roots) this.#rootCleanups.get(root)?.();
this.#rootsByPostNumber.clear(), this.#collapsedRootReplies.clear(), this.#quotePostLoads.clear(), this.#expandedQuoteKeys.clear(), this.#clearQuoteHighlight();
});
}
afterRender(post, view) {
this.#viewByRoot.set(view.slots.root, view), this.#prepareQuotes(post, view), this.#syncRelationshipControls(
view.slots.root,
(0, import_identifiers.discoursePostReference)(post).postNumber,
post
);
const highlight = this.#quoteHighlight;
highlight?.postNumber === view.postNumber && highlight.source && this.#mountQuoteReturnEntry(view.slots.root, highlight.source), highlight?.postNumber === view.postNumber && this.#quoteHighlightMarks.every((mark) => !mark.parentNode) && this.#applyQuoteHighlight(
view.slots.root,
highlight.text,
highlight.active,
highlight.source
);
}
attachRoot(root, postNumberValue) {
const postNumber = (0, import_identifiers.discoursePostNumber)(postNumberValue);
if (this.#rootCleanups.has(root)) return;
const roots = this.#rootsByPostNumber.get(postNumber) ?? /* @__PURE__ */ new Set();
roots.add(root), this.#rootsByPostNumber.set(postNumber, roots);
const onClick = (event) => {
this.#handleClick(event, root, postNumber).catch((error) => {
!this.scope.destroyed && this.#rootCleanups.has(root) && this.#onError(error);
});
};
root.addEventListener("click", onClick);
const cleanup = () => {
for (const postRoot of [
root,
...root.querySelectorAll(".ldp-post")
])
this.#removeHiddenReplyMarker(postRoot), postRoot.classList.remove("ldp-has-hidden-child-branches"), postRoot.querySelectorAll(
":scope > .ldp-reply-tree > .ldp-reply-controls > [data-reader-context-hidden-branch-controls]"
).forEach((control) => control.remove());
root.removeEventListener("click", onClick);
for (const entry of root.querySelectorAll(
"[data-reader-context-quote-return]"
))
this.#quoteReturnEntries.delete(entry), entry.remove();
this.#rootCleanups.delete(root);
const current = this.#rootsByPostNumber.get(postNumber);
current?.delete(root), current?.size || this.#rootsByPostNumber.delete(postNumber);
};
this.#rootCleanups.set(root, cleanup), this.#syncMountedTree(root);
const highlight = this.#quoteHighlight;
highlight?.postNumber === postNumber && highlight.source && this.#mountQuoteReturnEntry(root, highlight.source);
}
detachRoot(root) {
this.#rootCleanups.get(root)?.();
}
destroy() {
this.scope.destroy();
}
syncProjection() {
for (const roots of this.#rootsByPostNumber.values())
for (const root of roots) this.#syncMountedTree(root);
}
/** 收起当前视口内一个作为普通楼层显示的叶子回复;优先鼠标所在楼层。 */
collapseExpandedDefaultPost() {
const viewport = this.#scrollRoot.getBoundingClientRect(), candidates = [...this.#rootsByPostNumber.entries()].flatMap(([postNumber, roots]) => [...roots].map((root) => ({
postNumber,
root,
rect: root.getBoundingClientRect()
}))).filter(
({ root, rect }) => root.isConnected && !root.closest(".ldp-descendant-replies-layer") && root.classList.contains("ldp-reply-collapsible") && !root.classList.contains("ldp-nested-collapsed") && !root.classList.contains("ldp-nested-preview") && rect.bottom > viewport.top && rect.top < viewport.bottom && rect.right > viewport.left && rect.left < viewport.right
).sort((left, right) => left.rect.top - right.rect.top), candidate = candidates.find(({ root }) => {
try {
return root.matches(":hover");
} catch {
return !1;
}
}) ?? candidates[0];
return candidate ? (this.#setRootReplyCollapsed(
candidate.root,
candidate.postNumber,
!0
), candidate.postNumber) : null;
}
connectQuoteSource(port) {
if (this.scope.destroyed)
throw new Error("线程上下文 feature 已销毁");
if (this.#quoteSourcePort && this.#quoteSourcePort !== port)
throw new Error("引用来源端口只能连接一次");
this.#quoteSourcePort = port;
let active = !0;
const cleanup = () => {
active && (active = !1, this.#quoteSourcePort === port && (this.#quoteSourcePort = null));
};
return this.scope.add(cleanup), cleanup;
}
captureQuoteHighlightState() {
return this.#quoteHighlight;
}
applyRevealedQuoteHighlight(state, postRoot = null) {
const targetRoot = this.#resolveQuoteTargetRoot(state.postNumber, postRoot);
if (!targetRoot)
return this.#clearQuoteHighlight(), this.#quoteHighlight = state, !1;
const matched = this.#applyQuoteHighlight(
targetRoot,
state.text,
state.active,
state.source,
!0
);
return matched || this.#revealQuoteTarget?.(targetRoot, "floor"), matched;
}
#resolveQuoteTargetRoot(postNumber, preferred) {
const expected = String(postNumber);
return preferred?.dataset.postNumber === expected && preferred.isConnected ? preferred : [...this.#rootsByPostNumber.get(postNumber) ?? []].find((root) => root.isConnected && root.dataset.postNumber === expected) ?? null;
}
async restoreQuoteHighlightState(state) {
if (!state)
return this.#clearQuoteHighlight(), !0;
const navigation = this.#navigate();
if (!navigation) return !1;
const result = await navigation.navigate({
postNumber: state.postNumber,
source: "quote",
alignment: "nearest",
highlight: !1
});
return this.scope.destroyed || result.status !== "revealed" || !result.element ? !1 : this.applyRevealedQuoteHighlight(state, result.element);
}
#icon(name) {
return (0, import_reader_icon.renderReaderIcon)(this.#document, name, this.#renderIcon);
}
#prepareQuotes(post, view) {
const sourcePostNumber = (0, import_identifiers.discoursePostReference)(post).postNumber;
for (const quote of view.slots.content.querySelectorAll(
"aside.quote"
)) {
const title = quote.querySelector(":scope > .title"), body = quote.querySelector(":scope > blockquote");
if (!title || !body) continue;
quote.classList.add("ldp-post-quote"), title.classList.add("ldp-quote-title");
const targetPostNumber = Number(quote.dataset.post ?? 0), targetTopicId = Number(
quote.dataset.topic ?? this.#controller.topicId
);
if (!Number.isSafeInteger(targetPostNumber) || targetPostNumber < 1 || !Number.isSafeInteger(targetTopicId) || targetTopicId < 1)
continue;
this.#quoteJumpExcerptByElement.has(quote) || this.#quoteJumpExcerptByElement.set(quote, body.innerHTML);
let controls = title.querySelector(
":scope > .quote-controls"
);
controls || (controls = (0, import_html_element.htmlElement)(this.#document, "span", "quote-controls"), title.append(controls)), controls.classList.add("ldp-quote-controls"), controls.querySelectorAll("[data-reader-context-quote]").forEach((control) => control.remove());
const key = `${sourcePostNumber}:${targetTopicId}:${targetPostNumber}`, expanded = this.#expandedQuoteKeys.has(key);
quote.classList.toggle("ldp-quote-expanded", expanded), quote.dataset.ldpQuoteExpanded = expanded ? "1" : "0", expanded || this.#restoreQuoteExcerpt(quote, body);
const toggle = button(
this.#document,
"ldp-quote-toggle",
expanded ? "收起引用" : "展开完整引用"
);
toggle.dataset.readerContextQuote = "toggle", toggle.dataset.quoteKey = key, toggle.dataset.targetPostNumber = String(targetPostNumber), toggle.dataset.targetTopicId = String(targetTopicId), toggle.setAttribute("aria-expanded", String(expanded)), toggle.append(this.#icon(
expanded ? "chevron-up" : "chevron-down"
)), controls.append(toggle);
const jump = button(
this.#document,
"ldp-quote-jump",
`跳到被引用楼层 #${targetPostNumber}`
);
if (jump.dataset.readerContextQuote = "jump", jump.dataset.targetPostNumber = String(targetPostNumber), jump.dataset.targetTopicId = String(targetTopicId), jump.append(this.#icon("arrow-up")), controls.append(jump), !expanded) continue;
const fullPost = this.#controller.quotedPost(
targetTopicId,
targetPostNumber
);
if (fullPost) {
this.#applyQuotePost(quote, body, fullPost);
continue;
}
this.#hydrateExpandedQuoteBody(
view,
quote,
body,
key,
targetTopicId,
targetPostNumber
).catch((error) => {
this.scope.destroyed || this.#onError(error);
});
}
}
#restoreQuoteExcerpt(quote, body) {
const excerpt = this.#quoteJumpExcerptByElement.get(quote);
return delete quote.dataset.ldpQuoteHydrated, excerpt === void 0 || body.innerHTML === excerpt ? !1 : (body.innerHTML = excerpt, !0);
}
#applyQuotePost(quote, body, fullPost) {
const cooked = postCooked(fullPost);
return quote.dataset.ldpQuoteHydrated = "1", body.innerHTML === cooked ? !1 : (body.innerHTML = cooked, !0);
}
async #hydrateExpandedQuoteBody(view, quote, body, key, targetTopicId, targetPostNumber) {
const fullPost = await this.#loadQuotePost(
targetTopicId,
targetPostNumber
);
!fullPost || !this.#expandedQuoteKeys.has(key) || this.scope.destroyed || this.#viewByRoot.get(view.slots.root) !== view || !view.slots.content.contains(quote) || quote.querySelector(":scope > blockquote") !== body || this.#applyQuotePost(quote, body, fullPost) && this.#notifyQuoteBodyChanged(view.slots.root, "expanded");
}
async #loadQuotePost(targetTopicId, targetPostNumber) {
const cached = this.#controller.quotedPost(
targetTopicId,
targetPostNumber
);
if (cached) return cached;
const key = `${targetTopicId}:${targetPostNumber}`, pending = this.#quotePostLoads.get(key);
if (pending) return pending;
const request = this.#requestQuotePost(
key,
targetTopicId,
targetPostNumber
);
return this.#quotePostLoads.set(key, request), request;
}
async #requestQuotePost(key, targetTopicId, targetPostNumber) {
try {
return await this.#controller.loadQuotedPost(
targetTopicId,
targetPostNumber
);
} finally {
this.#quotePostLoads.delete(key);
}
}
#syncRelationshipControls(root, postNumber, post = null) {
const header = root.querySelector(
":scope > .ldp-post-head"
), replyControls = root.querySelector(
":scope > .ldp-reply-tree > .ldp-reply-controls"
);
header?.querySelectorAll(":scope > [data-reader-context-parent]").forEach((control) => control.remove()), header?.querySelectorAll(":scope > .ldp-jump-parent").forEach((control) => control.remove()), replyControls?.querySelectorAll(":scope > [data-reader-context-discussion]").forEach((control) => control.remove()), replyControls?.querySelectorAll(
":scope > [data-reader-context-hidden-branch-controls]"
).forEach((control) => control.remove()), root.classList.remove("ldp-has-hidden-child-branches");
const parent = this.#replies.topology.parentOf(postNumber), onlyOpPost = this.#isOnlyOpPost(postNumber), currentFloor = header?.querySelector(
":scope > :is(.ldp-body-floor,[data-reader-context-self])"
);
if (header && parent !== void 0 && parent !== null)
if (currentFloor?.dataset.readerContextSelf)
currentFloor.classList.add("ldp-current-floor"), currentFloor.textContent = `#${postNumber}`, currentFloor.dataset.targetPostNumber = String(postNumber), currentFloor.setAttribute("aria-label", `跳到楼层 #${postNumber}`);
else {
const selfButton = button(
this.#document,
"ldp-floor ldp-jump-self ldp-current-floor",
`跳到楼层 #${postNumber}`,
`#${postNumber}`
);
selfButton.dataset.readerContextSelf = "1", selfButton.dataset.targetPostNumber = String(postNumber), currentFloor ? currentFloor.replaceWith(selfButton) : header.insertBefore(
selfButton,
header.querySelector(":scope > .ldp-post-read-state")
);
}
else if (header && currentFloor?.dataset.readerContextSelf) {
const floor = (0, import_html_element.htmlElement)(this.#document, "span", "ldp-floor ldp-body-floor");
floor.textContent = `#${postNumber}`, currentFloor.replaceWith(floor);
}
const insideDiscussion = !!root.closest(".ldp-descendant-replies-layer"), hiddenDirectChildren = insideDiscussion ? Object.freeze([]) : this.#presentation?.hiddenDirectChildrenOf(postNumber) ?? Object.freeze([]);
if (replyControls && hiddenDirectChildren.length > 0) {
root.classList.add("ldp-has-hidden-child-branches");
const controls = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-hidden-branch-controls"
);
controls.dataset.readerContextHiddenBranchControls = "1";
const revealButton = button(
this.#document,
"ldp-collapse-replies ldp-reply-rail-control ldp-hidden-branch-reveal",
`展开楼层 #${postNumber} 的下一层回复`
);
revealButton.append(this.#icon("plus")), revealButton.dataset.readerContextRevealBranch = "1", revealButton.setAttribute("aria-expanded", "false");
const branchButton = button(
this.#document,
"ldp-btn ldp-sub-page-btn ldp-hidden-branch-discussion",
`查看楼层 #${postNumber} 以下的完整分支`
);
branchButton.dataset.readerContextBranchDiscussion = "1", branchButton.dataset.targetPostNumber = String(
hiddenDirectChildren[0]
), branchButton.append(this.#icon("layers"));
const label = (0, import_html_element.htmlElement)(this.#document, "span", "");
label.textContent = "查看完整分支", branchButton.append(label), controls.append(revealButton, branchButton), replyControls.append(controls);
}
const discussionOwner = this.#discussionBranchOwner(postNumber), onlyOpDiscussion = onlyOpPost && parent !== void 0 && parent !== null && !insideDiscussion, hasParkedDiscussion = postNumber > 1 && !insideDiscussion && discussionOwner === postNumber && this.#branchHasParkedDiscussion(postNumber, post);
if (replyControls && (onlyOpDiscussion || hasParkedDiscussion)) {
const discussionButton = button(
this.#document,
"ldp-btn ldp-sub-page-btn ldp-descendant-replies-open",
`查看楼层 #${postNumber} 的完整讨论`
);
discussionButton.dataset.readerContextDiscussion = "1", discussionButton.append(this.#icon("layers"));
const label = (0, import_html_element.htmlElement)(this.#document, "span", "");
label.textContent = "查看完整讨论", discussionButton.append(label), replyControls.append(discussionButton);
}
}
#syncRootReplyCollapse(root, postNumber, post) {
const canonicalParent = this.#replies.topology.parentOf(postNumber), projectedParent = this.#presentation?.parentOf(postNumber), canonicalReply = post !== null && canonicalParent !== void 0 && canonicalParent !== null, canonicalLeafReply = canonicalReply && postReplyCount(post) === 0 && this.#replies.topology.childrenOf(postNumber).length === 0, nestedPreview = root.classList.contains("ldp-nested-preview"), streamReply = canonicalReply && !this.#isOnlyOpPost(postNumber) && !root.closest(".ldp-descendant-replies-layer") && (projectedParent === null || nestedPreview), collapseControlVisible = streamReply && projectedParent === null && !nestedPreview, previousToggle = root.querySelector(
":scope > [data-reader-context-collapse-reply]"
), previousHint = root.querySelector(
":scope > .ldp-nested-esc-hint"
);
if (!streamReply) {
canonicalLeafReply || this.#collapsedRootReplies.delete(postNumber), root.classList.remove(
"ldp-reply",
"ldp-reply-collapsible",
"ldp-nested-collapsed"
), delete root.dataset.replyToPostNumber, previousToggle?.remove(), previousHint?.remove();
return;
}
if (root.classList.add("ldp-reply", "ldp-reply-collapsible"), root.dataset.replyToPostNumber = String(canonicalParent), !collapseControlVisible) {
root.classList.remove("ldp-nested-collapsed"), previousToggle?.remove(), previousHint?.remove();
return;
}
let toggle = previousToggle;
toggle || (toggle = button(
this.#document,
"ldp-btn ldp-nested-toggle",
""
), toggle.dataset.readerContextCollapseReply = "1", root.insertBefore(toggle, root.firstChild)), this.#setRootReplyCollapsed(
root,
postNumber,
this.#collapsedRootReplies.has(postNumber)
);
}
#setRootReplyCollapsed(root, postNumber, collapsed) {
collapsed ? this.#collapsedRootReplies.add(postNumber) : this.#collapsedRootReplies.delete(postNumber), root.classList.toggle("ldp-nested-collapsed", collapsed);
const toggle = root.querySelector(
":scope > [data-reader-context-collapse-reply]"
);
toggle && (toggle.setAttribute("aria-expanded", String(!collapsed)), toggle.setAttribute(
"aria-label",
`${collapsed ? "展开" : "收起"}楼层 #${postNumber}`
), toggle.replaceChildren(this.#icon(
collapsed ? "chevron-down" : "chevron-up"
)));
let hint = root.querySelector(
":scope > .ldp-nested-esc-hint"
);
if (collapsed) {
hint?.remove();
return;
}
!hint && toggle && (hint = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-nested-esc-hint"
), toggle.insertAdjacentElement("afterend", hint)), hint && (hint.textContent = "Esc 收起");
}
#isOnlyOpPost(postNumber) {
return this.#presentation?.postFilterKey?.startsWith("only-op:") === !0 && this.#presentation.postFilterMatches(postNumber);
}
/**
* 一条连续可见树只允许一个完整讨论入口。
*
* 普通树由 projection root 持有;楼主 #1 下的每条直属回复分支分别由该直属回复
* 持有,避免把所有评论误合并为楼主的一条讨论。入口放在 owner 的 replyControls,
* 该槽位在内联 replyList 之后,因此视觉上天然位于整条可见分支末尾。
*/
#discussionBranchOwner(postNumber) {
const presentation = this.#presentation;
if (!presentation) return postNumber;
const rootPostNumber = presentation.rootOf(postNumber);
if (rootPostNumber === void 0 || rootPostNumber !== 1)
return (0, import_identifiers.discoursePostNumber)(rootPostNumber ?? postNumber);
let owner = postNumber, parent = presentation.parentOf(owner);
for (; parent != null && parent !== 1; )
owner = (0, import_identifiers.discoursePostNumber)(parent), parent = presentation.parentOf(owner);
return (0, import_identifiers.discoursePostNumber)(owner);
}
#branchHasParkedDiscussion(rootPostNumber, rootPost) {
const presentation = this.#presentation, pending = [{ postNumber: rootPostNumber, post: rootPost }], visited = /* @__PURE__ */ new Set();
for (; pending.length; ) {
const current = pending.pop();
if (visited.has(current.postNumber)) continue;
visited.add(current.postNumber);
const knownChildren = this.#replies.topology.childrenOf(
current.postNumber
), visibleChildren = presentation?.childrenOf(current.postNumber) ?? knownChildren, hiddenChildren = presentation?.hiddenDirectChildrenOf(
current.postNumber
).length ?? 0, currentPost = current.post ?? this.#controller.postByNumber(current.postNumber), unresolvedChildren = Math.max(
0,
(currentPost ? postReplyCount(currentPost) : 0) - knownChildren.length
);
if (hiddenChildren > 0 || unresolvedChildren > 0 && visibleChildren.length === 0) return !0;
for (const childPostNumber of visibleChildren) {
const normalizedChild = (0, import_identifiers.discoursePostNumber)(childPostNumber);
pending.push({
postNumber: normalizedChild,
post: this.#controller.postByNumber(normalizedChild) ?? null
});
}
}
return !1;
}
#syncMountedTree(root) {
const postRoots = [
root,
...root.querySelectorAll(".ldp-post")
];
for (const postRoot of postRoots) {
const postNumber = (0, import_identifiers.discoursePostNumber)(
postRoot.dataset.postNumber
), post = this.#controller.postByNumber(postNumber) ?? null;
this.#syncRelationshipControls(
postRoot,
postNumber,
post
), this.#syncRootReplyCollapse(
postRoot,
postNumber,
post
), !postRoot.closest(".ldp-descendant-replies-layer") && this.#syncHiddenReplyMarker(postRoot, postNumber);
}
}
#syncHiddenReplyMarker(root, postNumber) {
if (this.#presentation && this.#presentation.rootOf(postNumber) !== postNumber) {
this.#removeHiddenReplyMarker(root);
return;
}
const previous = this.#hiddenReplyMarkers.get(root) ?? null, hiddenPostNumbers = this.#presentation?.hiddenFloorRunAfter(postNumber) ?? Object.freeze([]);
if (hiddenPostNumbers.length === 0) {
this.#removeHiddenReplyMarker(root);
return;
}
if (previous && this.#hiddenReplyPostNumbers.get(previous) === hiddenPostNumbers) {
root.classList.add("ldp-before-hidden-reply-marker"), previous.previousElementSibling !== root && root.after(previous);
return;
}
const expanded = previous?.querySelector(
"[data-reader-context-hidden-list]"
)?.hidden === !1;
this.#removeHiddenReplyMarker(root);
const marker = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-hidden-reply-marker"
);
marker.dataset.readerContextHiddenReplies = "1", marker.dataset.readerContextHiddenPostNumbers = [
hiddenPostNumbers.length,
hiddenPostNumbers[0],
hiddenPostNumbers.at(-1)
].join(":"), this.#hiddenReplyPostNumbers.set(marker, hiddenPostNumbers);
const toggle = button(
this.#document,
"ldp-btn ldp-hidden-reply-toggle",
`${expanded ? "收起" : "查看"} ${hiddenPostNumbers.length} 个隐藏回复`
);
toggle.dataset.readerContextHiddenToggle = "1", toggle.setAttribute("aria-expanded", String(expanded));
const list = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-hidden-reply-list"
);
list.dataset.readerContextHiddenList = "1", list.dataset.readerContextHiddenRendered = "0", list.setAttribute("role", "list"), list.hidden = !expanded, expanded && this.#appendHiddenReplyBatch(marker, list), marker.append(toggle, list), this.#hiddenReplyMarkers.set(root, marker), root.classList.add("ldp-before-hidden-reply-marker"), marker.addEventListener("click", (event) => {
this.#handleClick(event, root, postNumber).catch((error) => {
!this.scope.destroyed && marker.isConnected && this.#onError(error);
});
}), root.after(marker);
}
#appendHiddenReplyBatch(marker, list) {
list.querySelector("[data-reader-context-hidden-more]")?.remove();
const postNumbers = this.#hiddenReplyPostNumbers.get(marker) ?? [], rendered = Math.min(
postNumbers.length,
Math.max(
0,
Number(list.dataset.readerContextHiddenRendered) || 0
)
), end = Math.min(
postNumbers.length,
rendered + HIDDEN_REPLY_MATERIALIZE_BATCH_SIZE
);
for (let index = rendered; index < end; index += 1) {
const hiddenPostNumber = postNumbers[index], identity = this.#controller.postByNumber(hiddenPostNumber), username = String(identity?.username ?? "").trim(), displayName = String(identity?.name ?? "").trim() || username || `#${hiddenPostNumber}`, avatar = button(
this.#document,
"ldp-hidden-reply-avatar",
`跳到 ${displayName} 的回复 #${hiddenPostNumber}`
);
avatar.dataset.readerContextHiddenPost = String(hiddenPostNumber), avatar.setAttribute("role", "listitem");
const source = this.#avatarSource?.(
String(identity?.avatar_template ?? ""),
24
) ?? "";
if (source) {
const image = this.#document.createElement("img");
image.src = source, image.alt = "", image.loading = "lazy", image.decoding = "async", avatar.append(image);
} else
avatar.textContent = [...displayName][0] ?? "?";
list.append(avatar);
}
if (list.dataset.readerContextHiddenRendered = String(end), end >= postNumbers.length) return;
const remaining = postNumbers.length - end, nextBatchSize = Math.min(
remaining,
HIDDEN_REPLY_MATERIALIZE_BATCH_SIZE
), more = button(
this.#document,
"ldp-btn ldp-hidden-reply-more",
`再显示 ${nextBatchSize} 个隐藏回复,剩余 ${remaining} 个`,
`+${nextBatchSize}`
);
more.dataset.readerContextHiddenMore = "1", more.setAttribute("role", "listitem"), list.append(more);
}
#removeHiddenReplyMarker(root) {
this.#hiddenReplyMarkers.get(root)?.remove(), this.#hiddenReplyMarkers.delete(root), root.classList.remove("ldp-before-hidden-reply-marker");
}
async #handleClick(event, root, rootPostNumber) {
const target = (0, import_event_target.eventElement)(event);
if (!target) return;
const postRoot = target.closest(".ldp-post"), sourcePostNumber = postRoot && root.contains(postRoot) ? (0, import_identifiers.discoursePostNumber)(postRoot.dataset.postNumber) : rootPostNumber, collapseReply = target.closest(
"[data-reader-context-collapse-reply]"
);
if (collapseReply && root.contains(collapseReply) && postRoot) {
event.preventDefault(), this.#setRootReplyCollapsed(
postRoot,
sourcePostNumber,
!postRoot.classList.contains("ldp-nested-collapsed")
);
return;
}
const hiddenToggle = target.closest(
"[data-reader-context-hidden-toggle]"
), hiddenMarker = this.#hiddenReplyMarkers.get(root);
if (hiddenToggle && hiddenMarker?.contains(hiddenToggle)) {
event.preventDefault();
const list = hiddenToggle.closest(
"[data-reader-context-hidden-replies]"
)?.querySelector(
"[data-reader-context-hidden-list]"
);
if (!list) return;
const expanded2 = list.hidden;
expanded2 && list.dataset.readerContextHiddenRendered === "0" && this.#appendHiddenReplyBatch(hiddenMarker, list), list.hidden = !expanded2, hiddenToggle.setAttribute("aria-expanded", String(expanded2));
const hiddenPostCount = this.#hiddenReplyPostNumbers.get(hiddenMarker)?.length ?? 0;
hiddenToggle.setAttribute(
"aria-label",
`${expanded2 ? "收起" : "查看"} ${hiddenPostCount} 个隐藏回复`
);
return;
}
const hiddenMore = target.closest(
"[data-reader-context-hidden-more]"
);
if (hiddenMore && hiddenMarker?.contains(hiddenMore)) {
event.preventDefault();
const list = hiddenMore.closest(
"[data-reader-context-hidden-list]"
);
list && this.#appendHiddenReplyBatch(hiddenMarker, list);
return;
}
const hiddenReply = target.closest(
"[data-reader-context-hidden-post]"
);
if (hiddenReply && hiddenMarker?.contains(hiddenReply)) {
event.preventDefault();
const navigation = this.#navigate();
if (!navigation) throw new Error("隐藏楼层跳转时 navigation 尚未就绪");
await navigation.navigate({
postNumber: (0, import_identifiers.discoursePostNumber)(
hiddenReply.dataset.readerContextHiddenPost
),
source: "timeline",
alignment: "nearest",
highlight: !0
});
return;
}
const selfButton = target.closest(
"[data-reader-context-self]"
);
if (selfButton && root.contains(selfButton)) {
event.preventDefault();
const navigation = this.#navigate();
if (!navigation) throw new Error("楼层跳转时 navigation 尚未就绪");
await navigation.navigate({
postNumber: (0, import_identifiers.discoursePostNumber)(
selfButton.dataset.targetPostNumber
),
source: "link",
alignment: "start",
highlight: !0
});
return;
}
const discussionButton = target.closest(
"[data-reader-context-discussion]"
);
if (discussionButton && root.contains(discussionButton)) {
event.preventDefault(), await this.#controller.openDiscussion(sourcePostNumber);
return;
}
const revealBranchButton = target.closest(
"[data-reader-context-reveal-branch]"
);
if (revealBranchButton && root.contains(revealBranchButton)) {
event.preventDefault(), this.#onRevealNextReplyLevel?.(sourcePostNumber);
return;
}
const branchDiscussionButton = target.closest(
"[data-reader-context-branch-discussion]"
);
if (branchDiscussionButton && root.contains(branchDiscussionButton)) {
event.preventDefault(), await this.#controller.openDiscussion(sourcePostNumber, {
descendantRootPostNumber: sourcePostNumber,
targetPostNumber: (0, import_identifiers.discoursePostNumber)(
branchDiscussionButton.dataset.targetPostNumber ?? sourcePostNumber
)
});
return;
}
const quoteAction = target.closest(
"[data-reader-context-quote]"
);
if (!quoteAction || !root.contains(quoteAction)) return;
event.preventDefault(), event.stopPropagation();
const targetPostNumber = (0, import_identifiers.discoursePostNumber)(
quoteAction.dataset.targetPostNumber
);
if (quoteAction.dataset.readerContextQuote === "jump") {
const targetTopicId = Number(
quoteAction.dataset.targetTopicId ?? this.#controller.topicId
), quote2 = quoteAction.closest(".ldp-post-quote"), excerptHtml = quote2 ? this.#quoteJumpExcerptByElement.get(quote2) ?? "" : "", excerpt = quoteExcerptText(this.#document, excerptHtml), rawParentPostNumber = this.#replies.topology.parentOf(sourcePostNumber), parentPostNumber = rawParentPostNumber == null ? null : (0, import_identifiers.discoursePostNumber)(rawParentPostNumber), source = Object.freeze({
topicId: this.#controller.topicId,
postNumber: sourcePostNumber,
parentPostNumber,
nested: parentPostNumber !== null && parentPostNumber > 1,
anchor: this.#quoteSourcePort?.captureAnchor() ?? null
}), quoteHighlight = Object.freeze({
postNumber: targetPostNumber,
text: excerpt,
active: !0,
source
});
if (targetTopicId !== this.#controller.topicId) {
if (!this.#target)
throw new Error("跨主题引用跳转时 target 尚未就绪");
await this.#target.open({
topicId: targetTopicId,
postNumber: targetPostNumber,
source: "quote",
alignment: "nearest",
highlight: !1,
forceRefresh: !0,
quoteHighlight
});
return;
}
const navigation = this.#navigate();
if (!navigation) throw new Error("引用跳转时 navigation 尚未就绪");
const jumpEpoch = ++this.#quoteJumpEpoch, result = await this.#navigateQuoteWithRetry(navigation, {
postNumber: targetPostNumber,
source: "quote",
alignment: "nearest",
highlight: !1
}, jumpEpoch);
if (!result || !this.#isQuoteJumpCurrent(jumpEpoch)) return;
if (result.status !== "revealed") {
result.status === "unavailable" ? this.#notify(
`目的地楼层 #${targetPostNumber} 不存在或当前不可访问`
) : result.status === "superseded" ? this.#notify("楼层跳转已取消;检测到新的定位或滚动操作") : result.status === "unresolved-tree" ? this.#notify(
`目的地楼层 #${targetPostNumber} 的回复树暂未完成挂载;请稍后重试`
) : this.#notify(`目的地楼层 #${targetPostNumber} 定位失败`);
return;
}
this.applyRevealedQuoteHighlight(
quoteHighlight,
result.element ?? null
) || this.#notify(
`目的地内容已修改;已定位到楼层 #${targetPostNumber}`
);
return;
}
const quote = quoteAction.closest(".ldp-post-quote"), body = quote?.querySelector(":scope > blockquote");
if (!quote || !body || quoteAction.getAttribute("aria-busy") === "true") return;
const key = String(quoteAction.dataset.quoteKey ?? ""), expanded = this.#expandedQuoteKeys.has(key), anchorRoot = postRoot ?? root;
if (expanded)
this.#expandedQuoteKeys.delete(key), this.#restoreQuoteExcerpt(quote, body), quote.dataset.ldpQuoteExpanded = "0", quote.classList.remove("ldp-quote-expanded"), quoteAction.setAttribute("aria-expanded", "false"), quoteAction.setAttribute("aria-label", "展开完整引用"), quoteAction.replaceChildren(this.#icon("chevron-down")), this.#notifyQuoteBodyChanged(anchorRoot, "collapsed");
else {
quoteAction.setAttribute("aria-busy", "true");
const targetTopicId = Number(
quoteAction.dataset.targetTopicId ?? this.#controller.topicId
);
try {
const fullPost = await this.#loadQuotePost(
targetTopicId,
targetPostNumber
);
if (!this.#isActiveRoot(root) || !quoteAction.isConnected || !body.isConnected)
return;
if (!fullPost) {
quoteAction.setAttribute(
"aria-label",
"完整引用不可用;可跳到被引用楼层"
), this.#notify(
`被引用楼层 #${targetPostNumber} 的完整正文暂不可用`
);
return;
}
this.#applyQuotePost(quote, body, fullPost), this.#expandedQuoteKeys.add(key), quote.dataset.ldpQuoteExpanded = "1", quote.classList.add("ldp-quote-expanded"), quoteAction.setAttribute("aria-expanded", "true"), quoteAction.setAttribute("aria-label", "收起引用"), quoteAction.replaceChildren(this.#icon("chevron-up")), this.#notifyQuoteBodyChanged(anchorRoot, "expanded");
} finally {
quoteAction.removeAttribute("aria-busy");
}
}
}
#isActiveRoot(root) {
return !this.scope.destroyed && this.#rootCleanups.has(root) && root.isConnected;
}
async #navigateQuoteWithRetry(navigation, request, jumpEpoch) {
for (let attempt = 0; ; attempt += 1) {
let result;
try {
result = await navigation.navigate(request);
} catch (error) {
throw this.#isQuoteJumpCurrent(jumpEpoch) && this.#notify(
`目的地楼层 #${request.postNumber} 定位失败;请稍后重试`
), error;
}
if (!this.#isQuoteJumpCurrent(jumpEpoch))
return null;
if (result.status !== "unresolved-tree" || attempt >= 2)
return result;
this.#notify(
`目标楼层定位暂时失败,${attempt + 1} 秒后自动重试一次`
);
const navigationRevision = navigation.revision;
if (await this.#navigationRetryDelay((attempt + 1) * 1e3), !this.#isQuoteJumpCurrent(jumpEpoch))
return null;
if (navigationRevision !== void 0 && navigation.isCurrent && !navigation.isCurrent(navigationRevision))
return Object.freeze({
...result,
status: "superseded"
});
}
}
#isQuoteJumpCurrent(jumpEpoch) {
return !this.scope.destroyed && jumpEpoch === this.#quoteJumpEpoch;
}
#notifyQuoteBodyChanged(root, state) {
if (!this.#onQuoteBodyChanged) return;
const view = this.#viewByRoot.get(root);
view && this.#onQuoteBodyChanged(view, state);
}
#applyQuoteHighlight(postRoot, text, active, source = null, revealMatch = !1) {
this.#clearQuoteHighlight();
const postNumber = (0, import_identifiers.discoursePostNumber)(postRoot.dataset.postNumber);
this.#quoteHighlight = Object.freeze({
postNumber,
text,
source,
active
}), source && this.#syncQuoteReturnEntries(postRoot, postNumber, source);
const content = postRoot.querySelector(
":scope > .ldp-post-body > .ldp-content"
);
if (!content) return !1;
const releasePositioning = this.#beginQuotePositioning(), marks = markQuoteTextMatch(this.#document, content, text);
if (releasePositioning(), !marks.length) return !1;
this.#quoteHighlightMarks = marks;
const lastMark = marks.at(-1);
lastMark?.classList.add("ldp-quote-match-end"), lastMark && (lastMark.tabIndex = 0, lastMark.setAttribute("role", "button"));
const hint = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-quote-highlight-hint ldp-action-surface"
), toggle = button(
this.#document,
"ldp-quote-highlight-toggle",
active ? "关闭引用高亮" : "继续引用高亮",
active ? "关闭高亮" : "继续高亮"
);
hint.append(toggle);
let returnButton = null;
source && this.#quoteSourcePort && (returnButton = button(
this.#document,
"ldp-quote-highlight-return",
source.nested ? "返回二级回复" : "返回引用楼层",
source.nested ? "返回二级回复" : "返回引用楼层"
), hint.append(returnButton), this.#bindQuoteReturn(returnButton, source, () => {
hint.classList.remove("ldp-quote-hint-visible");
})), this.#quoteHintHost.append(hint), this.#quoteHighlightHint = hint;
const setActive = (nextActive) => {
const current = this.#quoteHighlight;
if (current) {
this.#quoteHighlight = Object.freeze({
...current,
active: nextActive
});
for (const currentMark of this.#quoteHighlightMarks)
currentMark.classList.toggle(
"ldp-quote-match-muted",
!nextActive
);
toggle.textContent = nextActive ? "关闭高亮" : "继续高亮", toggle.setAttribute(
"aria-label",
nextActive ? "关闭引用高亮" : "继续引用高亮"
), lastMark?.setAttribute("aria-pressed", String(nextActive));
}
}, toggleActive = (event) => {
event.preventDefault(), event.stopPropagation(), setActive(!(this.#quoteHighlight?.active ?? !1));
}, cancelHintHide = () => {
this.#quoteHintHideTimer !== null && (clearTimeout(this.#quoteHintHideTimer), this.#quoteHintHideTimer = null);
}, showHint = (event) => {
if (!lastMark || this.#quoteHighlightHint !== hint) return;
cancelHintHide(), hint.classList.add("ldp-quote-hint-visible");
const markRects = this.#quoteHighlightMarks.flatMap(
quoteHintElementRects
), markRect = markRects.length ? Object.freeze({
left: Math.min(...markRects.map((rect) => rect.left)),
top: Math.min(...markRects.map((rect) => rect.top)),
right: Math.max(...markRects.map((rect) => rect.right)),
bottom: Math.max(...markRects.map((rect) => rect.bottom)),
width: 0,
height: 0
}) : lastMark.getBoundingClientRect(), rootRect = this.#scrollRoot.getBoundingClientRect(), hintRect = hint.getBoundingClientRect(), view = this.#document.defaultView, measuredViewportWidth = view?.innerWidth ?? this.#document.documentElement.clientWidth, measuredViewportHeight = view?.innerHeight ?? this.#document.documentElement.clientHeight, viewportWidth = Number.isFinite(measuredViewportWidth) && measuredViewportWidth > 0 ? measuredViewportWidth : Math.max(rootRect.right, hintRect.width + 16), viewportHeight = Number.isFinite(measuredViewportHeight) && measuredViewportHeight > 0 ? measuredViewportHeight : Math.max(rootRect.bottom, hintRect.height + 16), anchorX = event && Number.isFinite(event.clientX) ? event.clientX : (markRect.left + markRect.right) / 2, edge = 8, gap = QUOTE_HINT_POINTER_GAP_PX, minLeft = edge, maxLeft = Math.max(edge, viewportWidth - hintRect.width - edge), minTop = Math.max(edge, rootRect.top + edge), maxTop = Math.max(
minTop,
Math.min(viewportHeight - edge, rootRect.bottom - edge) - hintRect.height
), clampLeft = (value) => Math.max(minLeft, Math.min(value, maxLeft)), clampTop = (value) => Math.max(minTop, Math.min(value, maxTop)), above = markRect.top - hintRect.height - gap, below = markRect.bottom + gap, fitsAbove = above >= minTop, fitsBelow = below <= maxTop, aboveSpace = markRect.top - minTop, belowSpace = maxTop + hintRect.height - markRect.bottom, top = fitsAbove || !fitsBelow && aboveSpace >= belowSpace ? above : below;
hint.style.left = `${Math.round(clampLeft(
anchorX - hintRect.width / 2
))}px`, hint.style.top = `${Math.round(clampTop(top))}px`;
}, scheduleHintHide = () => {
cancelHintHide(), this.#quoteHintHideTimer = setTimeout(() => {
this.#quoteHintHideTimer = null, !(hint.matches(":hover,:focus-within") || this.#quoteHighlightMarks.some((mark) => mark.matches(":hover"))) && hint.classList.remove("ldp-quote-hint-visible");
}, QUOTE_HINT_HIDE_GRACE_MS);
};
toggle.addEventListener("click", toggleActive), hint.addEventListener("mouseenter", cancelHintHide), hint.addEventListener("mouseleave", scheduleHintHide);
for (const mark of marks)
mark.addEventListener("mouseenter", (event) => showHint(event)), mark.addEventListener("mouseleave", scheduleHintHide);
lastMark?.addEventListener("focus", () => showHint()), lastMark?.addEventListener("blur", scheduleHintHide), lastMark?.addEventListener("keydown", (event) => {
(event.key === "Enter" || event.key === " ") && toggleActive(event);
});
for (const mark of marks)
mark.classList.toggle("ldp-quote-match-muted", !active), mark.addEventListener("click", toggleActive);
if (setActive(active), revealMatch) {
const firstMark = marks[0];
firstMark && this.#revealQuoteTarget?.(firstMark, "match");
}
return !0;
}
#syncQuoteReturnEntries(postRoot, postNumber, source) {
const roots = /* @__PURE__ */ new Set([
postRoot,
...this.#rootsByPostNumber.get(postNumber) ?? []
]);
for (const root of roots) this.#mountQuoteReturnEntry(root, source);
}
#mountQuoteReturnEntry(postRoot, source) {
if (!this.#quoteSourcePort) return;
const header = postRoot.querySelector(
":scope > .ldp-post-head"
);
if (!header) return;
const existing = header.querySelector(
":scope > [data-reader-context-quote-return]"
);
if (existing) {
this.#quoteReturnEntries.add(existing);
return;
}
const label = source.nested ? "返回二级回复" : "返回引用楼层", entry = button(
this.#document,
"ldp-btn ldp-quote-return-entry",
label,
"← 返回引用处"
);
entry.dataset.readerContextQuoteReturn = "1";
const floor = header.querySelector(
":scope > :is(.ldp-body-floor,[data-reader-context-self])"
);
floor ? floor.insertAdjacentElement("afterend", entry) : header.append(entry), this.#quoteReturnEntries.add(entry), this.#bindQuoteReturn(entry, source);
}
#bindQuoteReturn(control, source, onRestored = () => {
}) {
control.addEventListener("click", (event) => {
event.preventDefault(), event.stopPropagation();
const sourcePort = this.#quoteSourcePort;
if (control.disabled || !sourcePort) return;
control.disabled = !0;
const original = control.textContent;
control.textContent = "返回中…", sourcePort.restore(source).then((restored) => {
if (!this.scope.destroyed) {
if (!restored) throw new Error("引用来源暂不可用");
onRestored();
}
}).catch((error) => {
this.scope.destroyed || this.#onError(error);
}).finally(() => {
control.isConnected && (control.disabled = !1, control.textContent = original);
});
});
}
#beginQuotePositioning() {
const epoch = ++this.#quotePositionEpoch;
return this.#scrollRoot.classList.add("ldp-quote-positioning"), () => {
const release = () => {
this.scope.destroyed || epoch !== this.#quotePositionEpoch || this.#scrollRoot.classList.remove("ldp-quote-positioning");
}, view = this.#document.defaultView;
typeof view?.requestAnimationFrame == "function" ? view.requestAnimationFrame(release) : queueMicrotask(release);
};
}
#clearQuoteHighlight() {
this.#quoteHintHideTimer !== null && (clearTimeout(this.#quoteHintHideTimer), this.#quoteHintHideTimer = null), this.#quoteHighlightHint?.remove(), this.#quoteHighlightHint = null;
for (const entry of this.#quoteReturnEntries) entry.remove();
this.#quoteReturnEntries.clear();
for (const mark of this.#quoteHighlightMarks) {
if (!mark.parentNode) continue;
const parent = mark.parentNode;
mark.replaceWith(this.#document.createTextNode(mark.textContent ?? "")), parent.normalize();
}
this.#quoteHighlightMarks = Object.freeze([]), this.#quoteHighlight = null;
}
}
class ReaderTopicDiscussionTopology {
#snapshot = null;
#revision = 0;
#entries = /* @__PURE__ */ new Map();
get revision() {
return this.#revision;
}
update(snapshot) {
const nextEntries = snapshot?.entries ?? [];
let structureChanged = snapshot === null != (this.#snapshot === null) || snapshot?.rootPostNumber !== this.#snapshot?.rootPostNumber || nextEntries.length !== this.#entries.size;
structureChanged || (structureChanged = nextEntries.some((entry) => {
const previous = this.#entries.get(entry.postNumber);
return !previous || previous.parentPostNumber !== entry.parentPostNumber || previous.depth !== entry.depth;
})), structureChanged && (this.#revision += 1), this.#snapshot = snapshot, this.#entries.clear();
for (const entry of nextEntries)
this.#entries.set(entry.postNumber, entry);
}
parentOf(postNumber) {
return this.#entry(postNumber)?.parentPostNumber;
}
depthOf(postNumber) {
return this.#entry(postNumber)?.depth;
}
rootOf(postNumber) {
return this.#entry(postNumber) ? this.#snapshot?.rootPostNumber : void 0;
}
#entry(postNumber) {
return this.#entries.get(postNumber);
}
}
class ReaderTopicContextSurface {
scope;
discussionDomOwner;
discussionGeometry;
discussionPointer;
discussionBranchOverlay;
#document;
#controller;
#state;
#workspace;
#discussionHost;
#postProjector;
#highlight;
#requestFrame;
#cancelFrame;
#readComputedStyle;
#onError;
#discussionTopology = new ReaderTopicDiscussionTopology();
#discussionLayer;
#discussionPanel;
#discussionTitle;
#discussionList;
#discussionResizeHandles;
#mountedDiscussionPostNumbers = /* @__PURE__ */ new Set();
#discussionBranchPostNumbers = /* @__PURE__ */ new Set();
#discussionCollapsedPostNumbers = /* @__PURE__ */ new Set();
#observedDiscussionContent = /* @__PURE__ */ new Map();
#activeDiscussionContent = /* @__PURE__ */ new Set();
#renderedDiscussionPostNumbers = /* @__PURE__ */ new Set();
#discussionMaterializedLru = /* @__PURE__ */ new Map();
#discussionPostsByNumber = /* @__PURE__ */ new Map();
#discussionEagerPostLimit;
#readDiscussionMaterializedPostLimit;
#discussionContentObserver;
#layoutResizeObserver;
#returnFocus = null;
#pendingRestorePoint = null;
#activeDiscussionRoot = null;
#stateLoaded = !1;
#treeWidthFrame = 0;
#discussionBranchFrame = 0;
#discussionContentWidth = 0;
#observesDiscussionSize = !1;
#treePan = null;
#suppressTreeClick = !1;
#lastWorkspaceFullPage;
get discussionContentHost() {
return this.#discussionLayer;
}
scrollDiscussionHorizontal(delta) {
return this.scope.destroyed || this.#discussionLayer.hidden || !Number.isFinite(delta) || delta === 0 ? !1 : (this.#discussionList.scrollLeft += delta, !0);
}
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#document = options.document, this.#controller = options.controller, this.#state = options.stateRepository ?? new import_reader_topic_context_state.ReaderTopicContextStateRepository({
storage: options.stateStorage ?? Object.freeze({
getValue: () => null,
setValue: () => {
}
}),
...options.onError ? { onError: options.onError } : {}
}), this.#workspace = options.workspace, this.#discussionHost = options.discussionHost, this.#postProjector = options.postProjector ?? new import_reader_post_view_projector.ReaderPostViewProjector({
document: options.document,
identity: options.identity,
render: options.renderPost,
...options.postFeatures ? { features: options.postFeatures } : {},
...options.onError ? { onError: options.onError } : {}
}), this.#highlight = options.highlight ?? (() => {
}), this.#requestFrame = options.requestFrame ?? (typeof requestAnimationFrame == "function" ? (callback) => requestAnimationFrame(callback) : (callback) => (callback(0), 0)), this.#cancelFrame = options.cancelFrame ?? (typeof cancelAnimationFrame == "function" ? (id) => cancelAnimationFrame(id) : () => {
}), this.#readComputedStyle = options.readComputedStyle, this.#onError = options.onError ?? (() => {
}), this.#discussionEagerPostLimit = Number.isFinite(
options.discussionEagerPostLimit
) ? Math.max(1, Math.floor(Number(options.discussionEagerPostLimit))) : 12, this.#readDiscussionMaterializedPostLimit = options.readDiscussionMaterializedPostLimit ?? (() => Math.max(24, this.#discussionEagerPostLimit * 3)), this.#discussionLayer = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-descendant-replies-layer"
), this.#discussionLayer.hidden = !0, this.#discussionPanel = (0, import_html_element.htmlElement)(
options.document,
"section",
"ldp-descendant-replies-window"
);
const header = (0, import_html_element.htmlElement)(
options.document,
"header",
"ldp-descendant-replies-header"
);
header.dataset.readerContextDragSurface = "1";
const close = button(
options.document,
"ldp-btn ldp-author ldp-descendant-replies-close",
"关闭完整讨论(Esc)",
"<"
);
this.#discussionTitle = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-author ldp-descendant-replies-title"
);
const top = button(
options.document,
"ldp-btn ldp-descendant-replies-top",
"回到完整讨论顶部",
"↑"
);
header.append(close, this.#discussionTitle, top), this.#discussionList = (0, import_html_element.htmlElement)(
options.document,
"div",
"ldp-descendant-replies-list ldp-comments ldp-descendant-replies-tree"
), this.#discussionList.scrollTop = 0, this.#discussionList.scrollLeft = 0;
const resizeDirections = Object.freeze([
"n",
"s",
"e",
"w",
"ne",
"nw",
"se",
"sw"
]);
this.#discussionResizeHandles = Object.freeze(
resizeDirections.map((direction) => {
const handle = (0, import_html_element.htmlElement)(
options.document,
"span",
"ldp-descendant-replies-resize-handle"
);
return handle.dataset.readerResize = direction, handle.dataset.resize = direction, handle.setAttribute("aria-hidden", "true"), handle;
})
), this.#discussionPanel.append(
header,
this.#discussionList,
...this.#discussionResizeHandles
), this.#discussionLayer.append(this.#discussionPanel), options.discussionHost.append(this.#discussionLayer), this.scope.add((0, import_floating_surface_wheel.bindFloatingSurfaceWheel)(this.#discussionLayer)), this.#discussionTopology.update(null), this.discussionDomOwner = new import_reply_tree_dom_owner.ReplyTreeDomOwner(
this.#discussionTopology,
this.#discussionList
), this.discussionBranchOverlay = new import_branch_overlay.ReaderBranchOverlayController({
domOwner: this.discussionDomOwner,
renderMode: "segmented-css",
onToggleBranch: (postNumber) => {
this.#controller.toggleDiscussionBranch(postNumber);
},
readCollapsed: (postNumber) => this.#discussionCollapsedPostNumbers.has(postNumber),
onLayoutChange: () => this.#scheduleDiscussionBranchPaint(),
parentScope: this.scope
});
const createContentObserver = options.createContentObserver ?? (options.document.defaultView?.IntersectionObserver ? (callback, init) => new options.document.defaultView.IntersectionObserver(callback, init) : null);
this.#discussionContentObserver = createContentObserver ? createContentObserver(
(entries) => this.#onDiscussionContentIntersection(entries),
{
root: this.#discussionList,
rootMargin: "100% 0px 100% 0px",
threshold: 0
}
) : null, this.scope.add(() => this.#discussionContentObserver?.disconnect()), this.#lastWorkspaceFullPage = options.workspace.snapshot.presentation.fullPage;
const defaultView = options.document.defaultView, readViewport = options.readViewport ?? (() => Object.freeze({
width: Math.max(
1,
Number(defaultView?.innerWidth) || options.discussionHost.clientWidth || 1
),
height: Math.max(
1,
Number(defaultView?.innerHeight) || options.discussionHost.clientHeight || 1
)
})), viewport = readViewport(), viewportTarget = options.viewportTarget ?? defaultView;
this.discussionGeometry = new import_reader_workspace.ReaderWindowGeometryModel({
preferences: {
readerWindowWidth: 0,
readerWindowHeight: 0,
readerWindowX: 0,
readerWindowY: 0,
readerWindowLocked: !1,
readerWindowPinned: !1
},
viewportWidth: viewport.width,
viewportHeight: viewport.height,
mode: this.#lastWorkspaceFullPage ? "floating" : "fullpage",
policy: {
margin: 16,
minWidth: 320,
minHeight: 240,
compactWidth: 0,
defaultWidth: Math.max(1, Math.min(960, viewport.width - 48)),
defaultHeight: 720,
defaultViewportWidth: 1,
defaultViewportHeight: 0.78
}
}), this.discussionGeometry.changes.subscribe(
(snapshot) => this.#applyDiscussionGeometry(snapshot),
this.scope
), this.discussionPointer = new import_reader_workspace.ReaderWindowPointerController({
model: this.discussionGeometry,
overlay: this.#discussionLayer,
modal: this.#discussionPanel,
header,
...viewportTarget ? { viewportTarget } : {},
readViewport,
onPersist: () => {
this.discussionGeometry.snapshot.managed && this.#state.rememberGeometry(
this.discussionGeometry.snapshot.geometry
);
},
requestFrame: this.#requestFrame,
cancelFrame: this.#cancelFrame,
dragSurfaceSelector: ".ldp-descendant-replies-header[data-reader-context-drag-surface]",
interactingClassName: "ldp-descendant-replies-interacting",
restingTransform: "none",
projectPlacement: () => {
},
parentScope: this.scope
}), this.#syncWorkspaceMode(this.#lastWorkspaceFullPage), this.#applyDiscussionGeometry(this.discussionGeometry.snapshot), this.#discussionLayer.addEventListener("click", (event) => {
const target = (0, import_event_target.eventElement)(event);
if (target) {
if (target === this.#discussionLayer || target.closest(".ldp-descendant-replies-close")) {
event.preventDefault(), this.#controller.closeDiscussion();
return;
}
if (target.closest(".ldp-descendant-replies-top")) {
event.preventDefault(), this.#discussionList.scrollTop = 0, this.#discussionList.scrollLeft = 0;
return;
}
}
}), this.scope.listen(this.#discussionList, "pointerdown", (event) => {
this.#onTreePanPointerDown(event);
}), this.scope.listen(this.#discussionList, "pointermove", (event) => {
this.#onTreePanPointerMove(event);
});
for (const type of ["pointerup", "pointercancel", "lostpointercapture"])
this.scope.listen(this.#discussionList, type, (event) => {
this.#stopTreePan(event);
});
this.scope.listen(this.#discussionList, "click", (event) => {
this.#suppressTreeClick && (event.preventDefault(), event.stopImmediatePropagation());
}, !0);
const NativeResizeObserver = defaultView?.ResizeObserver, createResizeObserver = options.createResizeObserver ?? (NativeResizeObserver ? (callback) => new NativeResizeObserver(callback) : null);
this.#layoutResizeObserver = createResizeObserver ? createResizeObserver((entries) => {
const entry = entries.find((candidate) => candidate.target === this.#discussionList);
if (entry) {
const width = Number(entry.contentRect.width);
Number.isFinite(width) && width > 0 && (this.#discussionContentWidth = width), this.#applyPendingRestorePoint(), this.#scheduleDiscussionTreeWidth(), this.#scheduleDiscussionBranchPaint();
}
}) : null, this.#layoutResizeObserver && (this.#observesDiscussionSize = !0, this.#layoutResizeObserver.observe(this.#discussionList), this.scope.add(() => this.#layoutResizeObserver?.disconnect()));
const keydownTargets = /* @__PURE__ */ new Set([
options.document,
options.discussionHost.getRootNode()
]);
for (const target of keydownTargets)
this.scope.listen(target, "keydown", (event) => {
this.handleEscape(event);
}, !0);
this.#controller.changes.subscribe((snapshot) => {
this.#project(snapshot);
}, this.scope), this.#workspace.changes.subscribe((snapshot) => {
const fullPage = snapshot.presentation.fullPage;
fullPage !== this.#lastWorkspaceFullPage && (this.#lastWorkspaceFullPage = fullPage, this.#controller.closeDiscussion()), this.discussionGeometry.setMode(
fullPage ? "floating" : "fullpage"
), this.#syncWorkspaceMode(fullPage), this.#applyDiscussionGeometry(this.discussionGeometry.snapshot);
}, this.scope), this.#state.changes.subscribe((state) => {
if (!state.fullPageGeometry) return;
const stored = state.fullPageGeometry;
this.discussionGeometry.setGeometry(
stored.width,
stored.height,
stored.left,
stored.top
);
}, this.scope), this.scope.add(() => {
this.#persistActiveDiscussionPoint(), this.#treeWidthFrame && (this.#cancelFrame(this.#treeWidthFrame), this.#treeWidthFrame = 0), this.#discussionBranchFrame && (this.#cancelFrame(this.#discussionBranchFrame), this.#discussionBranchFrame = 0), this.#stopTreePan();
for (const postNumber of this.#mountedDiscussionPostNumbers) {
const root = this.discussionDomOwner.view(postNumber)?.slots.root;
root && this.#detachDiscussionFeatures(root, postNumber);
}
this.#mountedDiscussionPostNumbers.clear(), this.#discussionBranchPostNumbers.clear(), this.#discussionCollapsedPostNumbers.clear(), this.#observedDiscussionContent.clear(), this.#activeDiscussionContent.clear(), this.discussionDomOwner.destroy(), this.#discussionLayer.remove();
}), this.#state.load().then((state) => {
if (!this.scope.destroyed) {
if (this.#stateLoaded = !0, state.fullPageGeometry) {
const stored = state.fullPageGeometry;
this.discussionGeometry.setGeometry(
stored.width,
stored.height,
stored.left,
stored.top
);
}
this.#activeDiscussionRoot !== null && this.#pendingRestorePoint === null && (this.#pendingRestorePoint = this.#storedDiscussionPoint(
this.#activeDiscussionRoot
), this.#applyPendingRestorePoint());
}
}).catch(this.#onError), this.#project(this.#controller.snapshot());
}
/**
* 消费当前完整讨论拥有的 Esc。
*
* 除了 Document/ShadowRoot 的局部监听,userscript entry 还会在 Window
* capture 阶段调用它,避免宿主更早的监听器截断事件传播。
*/
handleEscape(event) {
return event.key !== "Escape" || !this.#controller.snapshot().discussion || !(0, import_reader_escape_surface.readerEscapeOwnedBy)(this.#document, this.#discussionLayer) ? !1 : (event.preventDefault(), event.stopImmediatePropagation(), this.#controller.closeDiscussion(), !0);
}
captureDiscussionState() {
const discussion = this.#controller.snapshot().discussion;
return !discussion || this.#discussionLayer.hidden ? null : Object.freeze({
rootPostNumber: discussion.rootPostNumber,
...discussion.descendantRootPostNumber !== discussion.rootPostNumber ? {
descendantRootPostNumber: discussion.descendantRootPostNumber
} : {},
point: this.#captureDiscussionPoint()
});
}
async restoreDiscussionState(state) {
this.#pendingRestorePoint = state.point, await this.#controller.openDiscussion(state.rootPostNumber, {
explicitRoot: !0,
targetPostNumber: null,
...state.descendantRootPostNumber === void 0 ? {} : {
descendantRootPostNumber: state.descendantRootPostNumber
}
}), this.#applyPendingRestorePoint();
}
/**
* 在完整讨论局部投影中揭示目标;不会把停放楼层提升进主信息流。
*/
async revealDiscussionPost(postNumberValue) {
if (this.scope.destroyed)
throw new Error("完整讨论 surface 已销毁");
const postNumber = (0, import_identifiers.discoursePostNumber)(postNumberValue), wasMounted = this.discussionDomOwner.view(postNumber)?.slots.root?.isConnected === !0, snapshot = await this.#controller.openDiscussion(postNumber, {
targetPostNumber: postNumber
});
if (this.scope.destroyed) return null;
const discussion = snapshot.discussion, target = this.discussionDomOwner.view(postNumber)?.slots.root;
return !discussion || !target?.isConnected || target.classList.contains("ldp-post-projection-pending") ? null : Object.freeze({
postNumber,
rootPostNumber: discussion.rootPostNumber,
element: target,
mounted: !wasMounted
});
}
/** 只闪烁完整讨论中已挂载的楼层,不移动浮窗滚动位置。 */
highlightDiscussionPost(postNumberValue) {
if (this.scope.destroyed) return !1;
const postNumber = (0, import_identifiers.discoursePostNumber)(postNumberValue), target = this.discussionDomOwner.view(postNumber)?.slots.root;
return !target?.isConnected || target.classList.contains("ldp-post-projection-pending") ? !1 : (this.#highlight(target), !0);
}
destroy() {
this.scope.destroy();
}
#project(snapshot) {
this.scope.destroyed || this.#projectDiscussion(snapshot.discussion);
}
#projectDiscussion(snapshot) {
if (this.#discussionTopology.update(snapshot), !snapshot) {
this.#persistActiveDiscussionPoint(), this.#activeDiscussionRoot = null, this.#discussionLayer.hidden = !0, this.#discussionHost.classList.remove(
"ldp-descendant-replies-host-open"
), this.#clearDiscussionViews(), this.#returnFocus?.focus?.(), this.#returnFocus = null;
return;
}
if (this.#discussionLayer.hidden) {
const active = (0, import_event_target.deepActiveElement)(this.#document);
this.#returnFocus = active && active.nodeType === 1 ? active : null;
}
this.#activeDiscussionRoot !== snapshot.rootPostNumber && (this.#persistActiveDiscussionPoint(), this.#activeDiscussionRoot = snapshot.rootPostNumber, this.#pendingRestorePoint === null && this.#stateLoaded && (this.#pendingRestorePoint = this.#storedDiscussionPoint(
snapshot.rootPostNumber
))), this.#discussionLayer.hidden = !1, this.#discussionHost.classList.toggle(
"ldp-descendant-replies-host-open",
this.discussionGeometry.snapshot.managed
);
const titleEntry = snapshot.entries.find((entry) => entry.postNumber === snapshot.descendantRootPostNumber), titleIdentity = titleEntry ? this.#postProjector.identity(titleEntry.post) : null, branchMode = snapshot.descendantRootPostNumber !== snapshot.rootPostNumber, titlePostNumber = snapshot.descendantRootPostNumber, titleAction = branchMode ? "查看完整分支" : "查看完整讨论";
this.#discussionTitle.textContent = titleIdentity ? `#${titlePostNumber} · ${titleIdentity.username} · ${titleAction}(${snapshot.entries.length})` : `#${titlePostNumber} · ${titleAction}(${snapshot.entries.length})`, this.#discussionTitle.dataset.partial = snapshot.partial ? "1" : "0";
const nextPostNumbers = new Set(
snapshot.entries.map((entry) => entry.postNumber)
);
this.#discussionPostsByNumber.clear();
for (const entry of snapshot.entries)
this.#discussionPostsByNumber.set(entry.postNumber, entry.post);
const eagerPostNumbers = this.#discussionEagerPostNumbers(snapshot);
for (const postNumber of [...this.#mountedDiscussionPostNumbers]) {
if (nextPostNumbers.has(postNumber)) continue;
const view = this.discussionDomOwner.view(postNumber);
view && this.#detachDiscussionFeatures(view.slots.root, postNumber), this.discussionDomOwner.unregister(postNumber, !0, !1), this.#mountedDiscussionPostNumbers.delete(postNumber), this.#renderedDiscussionPostNumbers.delete(postNumber), this.#discussionMaterializedLru.delete(postNumber);
}
const attachAfterSync = /* @__PURE__ */ new Set();
for (const entry of snapshot.entries) {
let view = this.discussionDomOwner.view(
entry.postNumber
), created = !1;
const shouldRender = eagerPostNumbers.has(entry.postNumber);
if (!view)
try {
view = shouldRender ? this.#postProjector.create(
entry.post,
this.scope,
entry.postNumber
) : this.#postProjector.createShell(
entry.post,
this.scope,
entry.postNumber
), created = !0, shouldRender ? (this.#renderedDiscussionPostNumbers.add(entry.postNumber), this.#touchDiscussionMaterialized(entry.postNumber)) : (view.slots.root.classList.add("ldp-post-projection-pending"), view.slots.root.setAttribute("aria-busy", "true")), this.discussionDomOwner.register(view, !1), this.#mountedDiscussionPostNumbers.add(entry.postNumber), attachAfterSync.add(entry.postNumber);
} catch (error) {
view?.destroy(), this.#onError(error);
continue;
}
if (!created && shouldRender && !this.#renderedDiscussionPostNumbers.has(entry.postNumber))
this.#materializeDiscussionView(entry.postNumber);
else if (!created && this.#renderedDiscussionPostNumbers.has(entry.postNumber))
try {
this.#postProjector.render(entry.post, view);
} catch (error) {
this.#onError(error);
}
view.slots.root.classList.remove("ldp-nested-collapsed"), view.slots.root.classList.add(
entry.depth > 0 ? "ldp-nested-preview" : "ldp-discussion-root"
), view.slots.root.isConnected || attachAfterSync.add(entry.postNumber);
}
this.discussionDomOwner.sync();
for (const postNumber of attachAfterSync) {
const rootElement = this.discussionDomOwner.view(postNumber)?.slots.root;
rootElement?.isConnected && this.#attachDiscussionFeatures(rootElement, postNumber);
}
this.#syncDiscussionBranches(snapshot), this.#scheduleDiscussionTreeWidth(), this.#scheduleDiscussionBranchPaint(), this.#applyPendingRestorePoint();
const targetPostNumber = snapshot.targetPostNumber;
if (targetPostNumber !== null) {
const target = this.discussionDomOwner.view(targetPostNumber)?.slots.root;
target?.isConnected && (this.#highlight(target), target.scrollIntoView?.({ block: "center", inline: "nearest" }), this.#requestFrame(() => {
this.scope.destroyed || this.#controller.clearDiscussionTarget();
}));
}
}
#syncDiscussionBranches(snapshot) {
const collapsed = new Set(snapshot.collapsedPostNumbers);
this.#discussionCollapsedPostNumbers.clear();
for (const postNumber of collapsed)
this.#discussionCollapsedPostNumbers.add(postNumber);
for (const entry of snapshot.entries) {
const view = this.discussionDomOwner.view(entry.postNumber);
view && (view.slots.replyList.hidden = collapsed.has(entry.postNumber), view.slots.replyControls.querySelectorAll(":scope > [data-reader-context-discussion]").forEach((control) => control.remove()));
}
}
#attachDiscussionFeatures(root, postNumber) {
this.#discussionContentObserver ? this.#observedDiscussionContent.get(postNumber) !== root && (this.#observedDiscussionContent.set(postNumber, root), this.#discussionContentObserver.observe(root)) : this.#activateDiscussionContent(root, postNumber), this.#renderedDiscussionPostNumbers.has(postNumber) && this.#attachDiscussionBranchFeatures(root, postNumber);
}
#attachDiscussionBranchFeatures(root, postNumber) {
this.#discussionTopology.parentOf(postNumber) === null && (this.#discussionBranchPostNumbers.has(postNumber) || (this.#discussionBranchPostNumbers.add(postNumber), this.#postProjector.attach(root, postNumber, "branch")));
}
#detachDiscussionFeatures(root, postNumber) {
this.#observedDiscussionContent.get(postNumber) === root && (this.#discussionContentObserver?.unobserve(root), this.#observedDiscussionContent.delete(postNumber)), this.#deactivateDiscussionContent(root, postNumber), this.#discussionBranchPostNumbers.delete(postNumber) && this.#postProjector.detach(root, postNumber, "branch");
}
#onDiscussionContentIntersection(entries) {
for (const entry of entries) {
const root = entry.target, postNumber = Number(root.dataset.postNumber);
!Number.isSafeInteger(postNumber) || postNumber <= 0 || this.#observedDiscussionContent.get(postNumber) === root && (entry.isIntersecting || entry.intersectionRatio > 0 ? this.#activateDiscussionContent(root, postNumber) : this.#deactivateDiscussionContent(root, postNumber));
}
this.#scheduleDiscussionBranchPaint();
}
#scheduleDiscussionBranchPaint() {
if (this.scope.destroyed || this.#discussionBranchFrame) return;
this.#discussionList.classList.add("ldp-branch-paint-pending");
let completed = !1;
const handle = this.#requestFrame(() => {
if (completed = !0, this.#discussionBranchFrame = 0, this.scope.destroyed || this.#discussionLayer.hidden) {
this.#discussionList.classList.remove(
"ldp-branch-paint-pending"
);
return;
}
this.discussionBranchOverlay.paint(), this.#discussionList.classList.remove("ldp-branch-paint-pending");
});
completed || (this.#discussionBranchFrame = handle);
}
#activateDiscussionContent(root, postNumber) {
this.#activeDiscussionContent.has(postNumber) || this.#materializeDiscussionView(postNumber) && (this.#activeDiscussionContent.add(postNumber), this.#postProjector.attach(root, postNumber, "node"));
}
#deactivateDiscussionContent(root, postNumber) {
this.#activeDiscussionContent.delete(postNumber) && (this.#postProjector.detach(root, postNumber, "node"), this.#touchDiscussionMaterialized(postNumber), this.#evictDiscussionMaterializedViews());
}
#clearDiscussionViews() {
this.#treeWidthFrame && (this.#cancelFrame(this.#treeWidthFrame), this.#treeWidthFrame = 0), this.#discussionBranchFrame && (this.#cancelFrame(this.#discussionBranchFrame), this.#discussionBranchFrame = 0), this.#discussionList.classList.remove("ldp-branch-paint-pending"), this.discussionBranchOverlay.releaseProjection();
for (const postNumber of [...this.#mountedDiscussionPostNumbers]) {
const view = this.discussionDomOwner.view(postNumber);
view && this.#detachDiscussionFeatures(view.slots.root, postNumber), this.discussionDomOwner.unregister(postNumber, !0, !1);
}
this.#mountedDiscussionPostNumbers.clear(), this.#discussionBranchPostNumbers.clear(), this.#discussionCollapsedPostNumbers.clear(), this.#observedDiscussionContent.clear(), this.#activeDiscussionContent.clear(), this.#renderedDiscussionPostNumbers.clear(), this.#discussionMaterializedLru.clear(), this.#discussionPostsByNumber.clear(), this.#discussionList.replaceChildren();
}
#discussionEagerPostNumbers(snapshot) {
if (!this.#discussionContentObserver)
return new Set(snapshot.entries.map((entry) => entry.postNumber));
const eager = new Set(
snapshot.entries.slice(0, this.#discussionEagerPostLimit).map((entry) => entry.postNumber)
);
eager.add(snapshot.rootPostNumber);
for (const target of [
snapshot.targetPostNumber,
this.#pendingRestorePoint?.number ?? null
]) {
let cursor = target;
const visited = /* @__PURE__ */ new Set();
for (; cursor != null && !visited.has(cursor); ) {
visited.add(cursor), eager.add(cursor);
const parent = this.#discussionTopology.parentOf(cursor);
cursor = parent === void 0 ? null : parent;
}
}
return eager;
}
#materializeDiscussionView(postNumber) {
if (this.#renderedDiscussionPostNumbers.has(postNumber))
return this.#touchDiscussionMaterialized(postNumber), !0;
const post = this.#discussionPostsByNumber.get(postNumber), view = this.discussionDomOwner.view(postNumber);
if (!post || !view) return !1;
try {
this.#postProjector.render(post, view);
} catch (error) {
return this.#onError(error), !1;
}
return this.#renderedDiscussionPostNumbers.add(postNumber), this.#touchDiscussionMaterialized(postNumber), view.slots.root.classList.remove("ldp-post-projection-pending"), view.slots.root.removeAttribute("aria-busy"), view.slots.root.isConnected && this.#attachDiscussionBranchFeatures(view.slots.root, postNumber), this.#evictDiscussionMaterializedViews(postNumber), !0;
}
#touchDiscussionMaterialized(postNumber) {
this.#renderedDiscussionPostNumbers.has(postNumber) && (this.#discussionMaterializedLru.delete(postNumber), this.#discussionMaterializedLru.set(postNumber, !0));
}
#discussionMaterializedPostLimit() {
let configured = Number.NaN;
try {
configured = Number(this.#readDiscussionMaterializedPostLimit());
} catch (error) {
this.#onError(error);
}
return Math.max(
this.#discussionEagerPostLimit,
Number.isFinite(configured) ? Math.floor(configured) : 24
);
}
#evictDiscussionMaterializedViews(protectedPostNumber) {
const limit = this.#discussionMaterializedPostLimit();
if (!(this.#renderedDiscussionPostNumbers.size <= limit))
for (const postNumber of [...this.#discussionMaterializedLru.keys()]) {
if (this.#renderedDiscussionPostNumbers.size <= limit) break;
postNumber === protectedPostNumber || postNumber === this.#activeDiscussionRoot || this.#activeDiscussionContent.has(postNumber) || this.#replaceDiscussionViewWithShell(postNumber);
}
}
#replaceDiscussionViewWithShell(postNumber) {
if (!this.#renderedDiscussionPostNumbers.has(postNumber) || this.#activeDiscussionContent.has(postNumber))
return !1;
const post = this.#discussionPostsByNumber.get(postNumber), current = this.discussionDomOwner.view(postNumber);
if (!post || !current) return !1;
let shell;
try {
shell = this.#postProjector.createShell(
post,
this.scope,
postNumber
);
} catch (error) {
return this.#onError(error), !1;
}
shell.slots.root.classList.add("ldp-post-projection-pending"), shell.slots.root.setAttribute("aria-busy", "true");
const depth = this.#discussionTopology.depthOf(postNumber);
shell.slots.root.classList.add(
depth !== void 0 && depth > 0 ? "ldp-nested-preview" : "ldp-discussion-root"
), shell.slots.root.classList.toggle(
"ldp-nested-collapsed",
current.slots.root.classList.contains("ldp-nested-collapsed")
), shell.slots.replyList.hidden = current.slots.replyList.hidden, this.#detachDiscussionFeatures(current.slots.root, postNumber);
try {
this.discussionDomOwner.unregister(postNumber, !1, !1), this.discussionDomOwner.register(shell, !1), this.discussionDomOwner.sync();
} catch (error) {
this.discussionDomOwner.unregister(postNumber, !1, !1), shell.destroy();
try {
this.discussionDomOwner.register(current, !1), this.discussionDomOwner.sync(), current.slots.root.isConnected && this.#attachDiscussionFeatures(current.slots.root, postNumber);
} catch (rollbackError) {
this.#onError(rollbackError);
}
return this.#onError(error), !1;
}
return current.destroy(), this.#renderedDiscussionPostNumbers.delete(postNumber), this.#discussionMaterializedLru.delete(postNumber), shell.slots.root.isConnected && this.#attachDiscussionFeatures(shell.slots.root, postNumber), this.#scheduleDiscussionBranchPaint(), !0;
}
#captureDiscussionPoint() {
const listRect = this.#discussionList.getBoundingClientRect(), anchor = [...this.#discussionList.querySelectorAll(
".ldp-post"
)].find((post) => post.getBoundingClientRect().bottom > listRect.top);
return anchor ? Object.freeze({
number: (0, import_identifiers.discoursePostNumber)(anchor.dataset.postNumber),
scrollTop: Math.max(0, this.#discussionList.scrollTop),
scrollLeft: Math.max(0, this.#discussionList.scrollLeft),
offset: anchor.getBoundingClientRect().top - listRect.top
}) : null;
}
#applyPendingRestorePoint() {
const point = this.#pendingRestorePoint;
if (!point) return;
const target = this.discussionDomOwner.view(point.number)?.slots.root;
if (!target?.isConnected) return;
const currentScrollTop = Math.max(0, this.#discussionList.scrollTop), targetRect = target.getBoundingClientRect(), listRect = this.#discussionList.getBoundingClientRect(), anchoredScrollTop = currentScrollTop + targetRect.top - listRect.top - point.offset;
this.#pendingRestorePoint = null, this.#discussionList.scrollLeft = point.scrollLeft, this.#discussionList.scrollTop = Math.max(
0,
Number.isFinite(anchoredScrollTop) ? anchoredScrollTop : point.scrollTop
);
}
#storedDiscussionPoint(rootPostNumber) {
return this.#state.point(
this.#document.location?.host ?? "",
this.#controller.topicId,
rootPostNumber
);
}
#persistActiveDiscussionPoint() {
this.#activeDiscussionRoot === null || this.#discussionLayer.hidden || this.#state.rememberPoint(
this.#document.location?.host ?? "",
this.#controller.topicId,
this.#activeDiscussionRoot,
this.#captureDiscussionPoint()
);
}
#applyDiscussionGeometry(snapshot) {
const managed = snapshot.managed;
for (const handle of this.#discussionResizeHandles)
handle.hidden = !managed;
const header = this.#discussionPanel.querySelector(
":scope > .ldp-descendant-replies-header"
);
if (header && (header.style.cursor = managed ? "move" : "", header.style.touchAction = managed ? "none" : ""), managed) {
const value = snapshot.geometry;
this.#discussionPanel.style.left = `${value.left}px`, this.#discussionPanel.style.top = `${value.top}px`, this.#discussionPanel.style.width = `${value.width}px`, this.#discussionPanel.style.height = `${value.height}px`, this.#discussionPanel.style.transform = "none";
} else
for (const property of [
"left",
"top",
"width",
"height",
"transform"
])
this.#discussionPanel.style.removeProperty(property);
this.#discussionHost.classList.toggle(
"ldp-descendant-replies-host-open",
managed && !this.#discussionLayer.hidden
), this.#scheduleDiscussionTreeWidth();
}
#scheduleDiscussionTreeWidth() {
if (this.#treeWidthFrame) return;
let completed = !1;
const handle = this.#requestFrame(() => {
completed = !0, this.#treeWidthFrame = 0, this.#syncDiscussionTreeWidth();
});
completed || (this.#treeWidthFrame = handle);
}
#syncDiscussionTreeWidth() {
const snapshot = this.#controller.snapshot().discussion;
if (!snapshot || this.#discussionLayer.hidden) return;
let contentWidth = this.#discussionContentWidth;
if (contentWidth <= 0) {
if (this.#observesDiscussionSize) return;
const style = this.#readComputedStyle?.(this.#discussionList) ?? this.#document.defaultView?.getComputedStyle?.(
this.#discussionList
), padding = Number.parseFloat(style?.paddingLeft ?? "") + Number.parseFloat(style?.paddingRight ?? ""), horizontalPadding = Number.isFinite(padding) ? padding : 0;
contentWidth = this.#discussionList.clientWidth - horizontalPadding;
}
const baseWidth = Math.max(320, contentWidth), maxDepth = snapshot.entries.reduce(
(depth, entry) => Math.max(depth, entry.depth),
0
), visibleDepth = Math.max(
0,
Math.floor((baseWidth - 320) / 28)
), overflowDepth = Math.max(0, maxDepth - visibleDepth);
this.#discussionList.style.setProperty(
"--ldp-descendant-tree-width",
`${baseWidth + overflowDepth * 28}px`
), this.#discussionList.style.setProperty(
"--ldp-descendant-tree-pan-width",
`${baseWidth + maxDepth * 28}px`
);
const hasHorizontalOverflow = overflowDepth > 0 || this.#discussionList.scrollWidth > this.#discussionList.clientWidth + 1;
this.#discussionList.classList.toggle(
"ldp-descendant-tree-pannable",
hasHorizontalOverflow
), hasHorizontalOverflow || (this.#discussionList.scrollLeft = 0);
}
#onTreePanPointerDown(event) {
const target = (0, import_event_target.eventElement)(event), hasHorizontalOverflow = this.#discussionList.classList.contains(
"ldp-descendant-tree-pannable"
) || this.#discussionList.scrollWidth > this.#discussionList.clientWidth + 1;
if (!(event.button !== 0 || event.pointerType && event.pointerType !== "mouse" || !hasHorizontalOverflow || !target || target.closest([
"button",
"a",
"input",
"select",
"textarea",
'[contenteditable="true"]',
'[role="button"]',
".ldp-post-head",
".ldp-content",
".ldp-reactions",
".ldp-boost-list",
".ldp-sub-actions",
".ldp-topic-footer-actions",
"img",
"video",
"audio",
"canvas",
"pre",
"code"
].join(",")))) {
this.#treePan = {
pointerId: event.pointerId,
startX: event.clientX,
scrollLeft: this.#discussionList.scrollLeft,
moved: !1
};
try {
this.#discussionList.setPointerCapture?.(event.pointerId);
} catch {
}
}
}
#onTreePanPointerMove(event) {
const pan = this.#treePan;
if (!pan || event.pointerId !== pan.pointerId) return;
const deltaX = event.clientX - pan.startX;
!pan.moved && Math.abs(deltaX) < 3 || (pan.moved = !0, this.#discussionList.classList.add(
"ldp-descendant-tree-panning"
), this.#discussionList.scrollLeft = pan.scrollLeft - deltaX, event.preventDefault());
}
#stopTreePan(event) {
const pan = this.#treePan;
if (!(!pan || event && event.pointerId !== pan.pointerId)) {
this.#treePan = null, this.#discussionList.classList.remove(
"ldp-descendant-tree-panning"
);
try {
this.#discussionList.hasPointerCapture?.(pan.pointerId) && this.#discussionList.releasePointerCapture?.(pan.pointerId);
} catch {
}
pan.moved && (this.#suppressTreeClick = !0, this.#requestFrame(() => {
this.#suppressTreeClick = !1;
}));
}
}
#syncWorkspaceMode(fullPage) {
this.#discussionLayer.classList.toggle(
"ldp-descendant-replies-layer-centered",
fullPage
), this.#discussionLayer.classList.toggle(
"ldp-descendant-replies-layer-inline",
!fullPage
), this.#discussionPanel.classList.toggle(
"ldp-descendant-replies-centered",
fullPage
), this.#discussionPanel.classList.toggle(
"ldp-descendant-replies-inline",
!fullPage
);
}
}
}, "f778f1c9bb14e3043cb100800c52942cca995836d0165af39077c0a1c63bdbbb");
/* Source: lite/src/topic/reader-topic-core-bundle.ts */
runtime.register("src/topic/reader-topic-core-bundle.js", function(module, exports, require) {
var reader_topic_core_bundle_exports = {};
__export(reader_topic_core_bundle_exports, {
createReaderTopicCoreBundle: () => createReaderTopicCoreBundle
});
module.exports = __toCommonJS(reader_topic_core_bundle_exports);
var import_topic_snapshot_repository = require("../cache/topic-snapshot-repository.js"), import_native_message_bus = require("../discourse/native-message-bus.js"), import_native_host_api = require("../discourse/native-host-api.js"), import_native_composer = require("../discourse/native-composer.js"), import_reply_tree_repository = require("../dom/reply-tree-repository.js"), import_topic_live_controller = require("../live/topic-live-controller.js"), import_discourse_native_read_transport = require("../network/discourse-native-read-transport.js"), import_action_request_adapter = require("../post/action-request-adapter.js"), import_boost_report_access_adapter = require("../post/boost-report-access-adapter.js"), import_discourse_action_transport = require("../post/discourse-action-transport.js"), import_post_action_controller = require("../post/post-action-controller.js"), import_topic_post_action_adapter = require("../post/topic-post-action-adapter.js"), import_read_state_controller = require("../reading/read-state-controller.js"), import_read_state_request_adapter = require("../reading/read-state-request-adapter.js"), import_topic_session = require("./topic-session.js"), import_topic_read_request_adapter = require("./topic-read-request-adapter.js");
const TOPIC_SNAPSHOT_PERSISTENCE_IDLE_MS = 1500;
function cacheSettings(kind, topicId, lifetime) {
return Object.freeze({
kind,
tags: Object.freeze([`topic:${topicId}`]),
freshForMs: lifetime.freshForMs,
retainForMs: lifetime.retainForMs,
persist: lifetime.persist
});
}
function topicReadCaches(topicId, options) {
return Object.freeze({
topic: cacheSettings("discourse-topic-json", topicId, options.topic),
posts: cacheSettings("discourse-topic-posts", topicId, options.posts),
nested: cacheSettings("discourse-topic-replies", topicId, options.nested)
});
}
function readCandidate(post) {
const postNumber = Number(post.post_number);
if (!Number.isSafeInteger(postNumber) || postNumber < 1) return null;
const read = post.read;
return Object.freeze({
postNumber,
...read === !0 ? { read: !0 } : {}
});
}
function createReaderTopicCoreBundle(context, options) {
const topicId = Number(context.topicId), report = (phase, cause) => {
options.onDiagnostic?.(Object.freeze({ phase, topicId, cause }));
}, nativeOptions = options.origin === void 0 ? {} : { origin: options.origin }, nativeAjax = options.nativeAjax ?? new import_discourse_native_read_transport.BrowserDiscourseNativeAjaxPort(
options.host,
nativeOptions
), readTransport = new import_discourse_native_read_transport.BrowserDiscourseNativeReadTransport(nativeAjax), mutationTransport = new import_discourse_native_read_transport.BrowserDiscourseNativeMutationTransport(nativeAjax), topicReadController = context.scope.abortController(
new DOMException(`Topic ${topicId} 读取链已结束`, "AbortError"),
context.signal
), requests = new import_topic_read_request_adapter.TopicReadRequestAdapter({
gateway: options.gateway,
transport: readTransport,
authScope: options.authScope,
topicId,
signal: topicReadController.signal,
caches: topicReadCaches(topicId, options.caches),
...options.basePath === void 0 ? {} : { basePath: options.basePath }
}), snapshots = new import_topic_snapshot_repository.TopicSnapshotRepository({
responseRepository: options.responses,
topicId,
authScope: options.authScope,
freshForMs: options.caches.snapshot.freshForMs,
retainForMs: options.caches.snapshot.retainForMs,
persistenceIdleMs: TOPIC_SNAPSHOT_PERSISTENCE_IDLE_MS,
...options.initialSnapshot === void 0 ? {} : { initialSnapshot: options.initialSnapshot },
...options.now === void 0 ? {} : { now: options.now },
onInvalidSnapshot: (cause) => report("snapshot", cause),
onInvalidTreeSnapshot: (cause) => report("reply-tree", cause)
}), replies = new import_reply_tree_repository.ReplyTreeRepository(
topicId,
snapshots.replyTreeSnapshotStore(),
{
...options.now === void 0 ? {} : { now: options.now },
onPersistenceError: (cause) => report("reply-tree", cause)
}
), session = new import_topic_session.TopicSession({
topicId,
requests,
snapshots,
replies,
pageSize: options.pageSize,
signal: topicReadController.signal,
...options.refreshCachedInBackground === void 0 ? {} : { refreshCachedInBackground: options.refreshCachedInBackground },
...options.now === void 0 ? {} : { now: options.now },
scope: context.scope,
onError: (cause) => report("session", cause),
...options.onLoadingSource === void 0 ? {} : { onInitializeSource: options.onLoadingSource }
}), messageBus = new import_native_message_bus.BrowserDiscourseMessageBusPort(options.host), live = new import_topic_live_controller.TopicLiveController({
topicId,
messageBus,
session,
cache: options.responses,
currentUsername: (0, import_native_host_api.discourseNativeCurrentUsername)(options.host),
...options.livePostDelayMs === void 0 ? {} : { postDelayMs: options.livePostDelayMs },
...options.liveTopicDelayMs === void 0 ? {} : { topicDelayMs: options.liveTopicDelayMs },
scope: context.scope,
onError: (cause) => report("message-bus", cause)
}), composerEvents = new import_native_composer.DiscourseComposerTopicSyncController({
topicId,
events: options.composerEvents ?? new import_native_composer.DiscourseComposerEventPort(options.host),
session,
parentScope: context.scope,
...options.now === void 0 ? {} : { now: options.now },
onError: (cause) => report("composer-events", cause)
}), readRequests = new import_read_state_request_adapter.ReadStateRequestAdapter({
gateway: options.gateway,
transport: mutationTransport,
authScope: options.authScope,
topicId,
signal: context.signal,
...options.basePath === void 0 ? {} : { basePath: options.basePath }
}), read = new import_read_state_controller.ReadStateController({
authScope: options.authScope,
topicId,
submitter: readRequests,
...options.readCoordination === void 0 ? {} : { coordination: options.readCoordination },
...options.readBatchSize === void 0 ? {} : { batchSize: options.readBatchSize },
...options.readMinimumDwellMs === void 0 ? {} : { minimumDwellMs: options.readMinimumDwellMs },
...options.readRetryDelayMs === void 0 ? {} : { retryDelayMs: options.readRetryDelayMs },
...options.readMaxAutomaticRetries === void 0 ? {} : { maxAutomaticRetries: options.readMaxAutomaticRetries },
scope: context.scope,
onError: (cause) => report("read-state", cause)
}), actionRequests = new import_action_request_adapter.ActionRequestAdapter({
gateway: options.gateway,
nativeActions: options.nativeActions ?? new import_discourse_action_transport.BrowserDiscourseNativeActionPort(options.host, nativeAjax),
authScope: options.authScope,
signal: context.signal
}), boostReportAccess = new import_boost_report_access_adapter.BoostReportAccessAdapter({
gateway: options.gateway,
transport: readTransport,
authScope: options.authScope,
signal: context.signal,
...options.basePath === void 0 ? {} : { basePath: options.basePath }
}), actions = new import_post_action_controller.PostActionController({
mutation: actionRequests,
cache: options.responses,
scope: context.scope,
onError: (cause) => report("action", cause)
}), postActions = new import_topic_post_action_adapter.TopicPostActionAdapter({
session,
...options.now === void 0 ? {} : { now: options.now }
}), preload = (posts) => {
const candidates = posts.map(readCandidate).filter((candidate) => candidate !== null);
candidates.length && read.preload(candidates);
};
session.changes.subscribe((commit) => {
preload(
commit.changedPostNumbers.map((postNumber) => session.postByNumber(postNumber)).filter((post) => post !== void 0)
);
}, context.scope);
const services = Object.freeze({
requests,
snapshots,
replies,
session,
live,
composerEvents,
readRequests,
read,
actionRequests,
boostReportAccess,
actions,
postActions
});
let closePromise = null;
return Object.freeze({
session,
replies,
services,
activate: () => {
preload(session.cachedPosts()), live.setActive(!0, {
refresh: session.initializedFromCache && session.localArchiveState().topic === null
});
try {
composerEvents.start();
} catch (cause) {
report("composer-events", cause);
}
return read.start(), () => {
read.stop(), live.active && live.setActive(!1), composerEvents.stop();
};
},
prepareClose: (reason) => closePromise || (topicReadController.signal.aborted || topicReadController.abort(
new DOMException(
`Topic ${topicId} 已${reason === "switch" ? "切换" : "关闭"}`,
"AbortError"
)
), read.stop(), live.setActive(!1), composerEvents.stop(), closePromise = (async () => {
const activeResults = await Promise.allSettled([
read.flush({ force: !0 }),
live.flush()
]);
for (const result of activeResults)
result.status === "rejected" && report("prepare-close", result.reason);
try {
await session.flush();
} catch (cause) {
report("prepare-close", cause);
}
})(), closePromise)
});
}
}, "3fc1feb786017810800731d62f45f626415558beec039317d860933d69b115ef");
/* Source: lite/src/topic/reader-topic-dom-coordinator.ts */
runtime.register("src/topic/reader-topic-dom-coordinator.js", function(module, exports, require) {
var reader_topic_dom_coordinator_exports = {};
__export(reader_topic_dom_coordinator_exports, {
ReaderTopicDomCoordinator: () => ReaderTopicDomCoordinator
});
module.exports = __toCommonJS(reader_topic_dom_coordinator_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_reply_tree_dom_owner = require("../dom/reply-tree-dom-owner.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js"), import_branch_overlay = require("../layout/branch-overlay.js"), import_reader_reply_tree_preferences = require("./reader-reply-tree-preferences.js"), import_reply_tree_virtual_layout_controller = require("../stream/reply-tree-virtual-layout-controller.js"), import_reply_tree_viewport_layout = require("../stream/reply-tree-viewport-layout.js"), import_virtual_root_layout = require("../stream/virtual-root-layout.js"), import_virtual_stream_dom_controller = require("../stream/virtual-stream-dom-controller.js"), import_virtual_stream_frame_controller = require("../stream/virtual-stream-frame-controller.js"), import_virtual_stream_view = require("../stream/virtual-stream-view.js"), import_reader_post_view_projector = require("./reader-post-view-projector.js"), import_reader_topic_scroll_lifecycle = require("./reader-topic-scroll-lifecycle.js");
const DIRECT_REPLY_VISIBLE_PIPELINE_DEPTH = 3, DIRECT_REPLY_NEARBY_PIPELINE_DEPTH = 2, DIRECT_REPLY_PREFETCH_MAX_PAGES = 5, MATERIALIZATION_STEP_SCREENS = 0.5, HIDDEN_REPLY_MARKER_BLOCK_SIZE = 18, PROJECTION_HYDRATION_MIN_IDLE_MS = 120, PROJECTION_HYDRATION_BATCH_DELAY_MS = 16, DEFAULT_PROJECTION_HYDRATION_BATCH_SIZE = 1;
function isAbortFailure(error) {
return error instanceof DOMException && error.name === "AbortError" || String(error?.name ?? "") === "AbortError";
}
class ReaderTopicDomCoordinator {
scope;
streamView;
domOwner;
layout;
domController;
frame;
branchOverlay;
replyTreePresentation;
postProjector;
visibleRootChanges = new import_signal.Signal();
windowChanges = new import_signal.Signal();
presentationChanges;
#session;
#scroll;
#onError;
#branchFrames;
#directReplyPrefetchScheduler;
#projectionHydrationScheduler;
#readDirectReplyPrefetchScreens;
#readDirectReplyPrefetchIdleMs;
#readDirectReplyPrefetchConcurrency;
#readProjectionHydrationBatchSize;
#now;
#scrollLifecycle;
#ownsPresentationChanges;
#rootObservers = /* @__PURE__ */ new Map();
#retainedViews = /* @__PURE__ */ new Map();
#topicDirtyRetainedPostNumbers = /* @__PURE__ */ new Set();
#directReplyPrefetches = /* @__PURE__ */ new Map();
#queuedDirectReplyPrefetches = /* @__PURE__ */ new Set();
#directReplyPrefetchedExpectedCounts = /* @__PURE__ */ new Map();
#directReplyPrefetchAttemptedExpectedCounts = /* @__PURE__ */ new Map();
#rootProjection;
#treeViewport;
#ownSizeObserver;
#ownSizeTargets = /* @__PURE__ */ new Map();
#ownSizeSamples = /* @__PURE__ */ new Map();
#pendingOwnSizePostNumbers = /* @__PURE__ */ new Set();
#activeBranchPostNumbers = /* @__PURE__ */ new Set();
#projectionHydrationFailedPostNumbers = /* @__PURE__ */ new Set();
#rootVirtualInsets = /* @__PURE__ */ new Map();
#mountedPostNumbers = /* @__PURE__ */ new Set();
#activeContentPostNumbers = /* @__PURE__ */ new Set();
#nextContentPostNumbers = /* @__PURE__ */ new Set();
#desiredContentPostNumbers = /* @__PURE__ */ new Set();
#directReplyPrefetchCandidatePostNumbers = /* @__PURE__ */ new Set();
#directReplyPrefetchOrderedCandidates = Object.freeze([]);
#directReplyVisiblePostNumbers = /* @__PURE__ */ new Set();
#directReplyVisibleKey = "";
#directReplyPrefetchCandidateKey = "";
#branchPaintHandle = null;
#branchPaintGeneration = 0;
#canonicalFreezeEpoch = 0;
#pendingBranchCollapseAnchor = null;
#directReplyPrefetchHandle = null;
#projectionHydrationHandle = null;
#projectionHydrationHandleUrgent = !1;
#projectionHydrationPostNumbers = Object.freeze([]);
#projectionHydrationUrgentPostNumbers = /* @__PURE__ */ new Set();
#deferredMeasurementFlushPending = !1;
#retainedViewLimit = 0;
#lastVisibleRootChangeKey = "";
#lastPostStreamRevision;
#pendingViewportMutation = null;
#anchorSettlementController = null;
#destroyed = !1;
constructor(options) {
if (this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#session = options.session, this.#scroll = options.scroll, this.#lastPostStreamRevision = options.session.postStreamRevision ?? 0, this.#onError = options.onError ?? (() => {
}), this.presentationChanges = options.presentationChanges ?? new import_signal.Signal(), this.#ownsPresentationChanges = options.presentationChanges === void 0, this.postProjector = new import_reader_post_view_projector.ReaderPostViewProjector({
document: options.document,
identity: options.identity,
render: options.render,
...options.postFeatures ? { features: options.postFeatures } : {},
onError: this.#onError
}), this.#branchFrames = options.frameScheduler ?? Object.freeze({
request: (callback) => requestAnimationFrame(callback),
cancel: (handle) => cancelAnimationFrame(handle)
}), this.#directReplyPrefetchScheduler = options.directReplyPrefetchScheduler ?? Object.freeze({
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
cancel: (handle) => clearTimeout(handle)
}), this.#projectionHydrationScheduler = options.projectionHydrationScheduler ?? Object.freeze({
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
cancel: (handle) => clearTimeout(handle)
}), this.#readDirectReplyPrefetchScreens = options.readDirectReplyPrefetchScreens ?? (() => 0), this.#readDirectReplyPrefetchIdleMs = options.readDirectReplyPrefetchIdleMs ?? (() => 180), this.#readDirectReplyPrefetchConcurrency = options.readDirectReplyPrefetchConcurrency ?? (() => 1), this.#readProjectionHydrationBatchSize = options.readProjectionHydrationBatchSize ?? (() => DEFAULT_PROJECTION_HYDRATION_BATCH_SIZE), this.#now = options.now ?? (() => performance.now()), this.#scrollLifecycle = new import_reader_topic_scroll_lifecycle.ReaderTopicScrollLifecycle({
readLastUserScrollAt: () => options.scroll.lastUserScrollAt?.() ?? 0,
readIdleMs: this.#readDirectReplyPrefetchIdleMs,
scheduler: this.#directReplyPrefetchScheduler,
now: this.#now,
parentScope: this.scope
}), this.streamView = new import_virtual_stream_view.VirtualStreamView(options.document), options.topicHost.append(this.streamView.slots.root), this.scope.add(() => this.streamView.destroy()), options.replyTreePresentation && options.replyTreePresentation.canonical !== options.replies.topology)
throw new Error(
"外部 ReplyTree presentation 必须投影当前 canonical topology"
);
this.replyTreePresentation = options.replyTreePresentation ?? new import_reader_reply_tree_preferences.ReaderReplyTreePresentation(
options.replies.topology,
options.replyTreePreferences?.read(),
{
canonicalCoverageComplete: () => {
const replyCoverage = options.replies.coverage();
if (!replyCoverage.complete) return !1;
const topicExpectedPostCount = options.session.postStreamCoverage?.().expectedPostCount;
return topicExpectedPostCount === void 0 || replyCoverage.expectedPostCount >= topicExpectedPostCount;
},
canonicalPostStreamRevision: () => options.session.postStreamRevision ?? 0,
canonicalPostStreamGapCount: (postNumber, previousRootPostNumber) => options.session.postStreamGapCount?.(
previousRootPostNumber,
postNumber
)
}
), this.domOwner = new import_reply_tree_dom_owner.ReplyTreeDomOwner(
this.replyTreePresentation,
this.streamView.slots.rootList
), this.scope.add(() => this.domOwner.destroy()), this.scope.add(() => {
for (const view of this.#retainedViews.values()) view.destroy();
this.#retainedViews.clear(), this.#topicDirtyRetainedPostNumbers.clear();
}), this.layout = new import_virtual_root_layout.VirtualRootLayout(options.estimatedRootSize, !0), this.#rootProjection = new import_reply_tree_virtual_layout_controller.ReplyTreeVirtualLayoutController(
options.replies,
this.layout,
this.scope,
this.replyTreePresentation
), this.#treeViewport = new import_reply_tree_viewport_layout.ReplyTreeViewportLayout(
this.replyTreePresentation,
this.layout,
options.estimatedRootSize
), this.domController = new import_virtual_stream_dom_controller.VirtualStreamDomController(
options.replies,
this.layout,
this.streamView,
this.domOwner,
{
prepareRoots: (postNumbers, input, window) => this.#prepareRootViews(postNumbers, input, window),
roots: () => this.replyTreePresentation.roots(),
resolveGapPlaceholder: (window, input) => {
if (window.unloadedGapTargetPostNumber !== void 0 && window.unloadedGapSide !== void 0)
return Object.freeze({
side: window.unloadedGapSide,
targetPostNumber: window.unloadedGapTargetPostNumber
});
const targetPostNumber = this.visibleDataGapPostNumber();
if (targetPostNumber === void 0) return null;
const targetOffset = this.#treeViewport.offsetOf(targetPostNumber);
return Object.freeze({
side: targetOffset !== void 0 && targetOffset > input.scrollOffset + input.viewportSize / 2 ? "after" : "before",
targetPostNumber
});
}
}
), this.frame = new import_virtual_stream_frame_controller.VirtualStreamFrameController(this.domController, {
readWindowInput: () => this.#readVirtualWindowInput(),
applyScrollCompensation: (delta) => options.scroll.applyScrollCompensation(delta),
/*
* 根高测量会改写 overflow-anchor:none 的虚拟 spacer,Chromium 不会
* 替它补偿。没有显式 DOM 高度事务时使用 frame 自带的单帧补偿;已有
* 事务则由物理楼层锚点结算,二者不能同时写 scrollTop。
*/
shouldApplyScrollCompensation: () => this.#pendingViewportMutation === null,
shouldDeferMeasurements: () => !this.#scrollLifecycle.isIdle(PROJECTION_HYDRATION_MIN_IDLE_MS),
onMeasurementsDeferred: () => this.#scheduleDeferredMeasurementFlush(),
resolveRootBlockSize: (target, observedBlockSize) => {
const postNumber = Number(target.getAttribute("data-post-number")), inset = Number.isSafeInteger(postNumber) ? this.#rootVirtualInsets.get(postNumber) : void 0, virtualBlockSize = observedBlockSize + (inset?.beforeSize ?? 0) + (inset?.afterSize ?? 0), marker = target.nextElementSibling;
return marker?.nodeType === 1 && marker.classList.contains("ldp-hidden-reply-marker") ? virtualBlockSize + HIDDEN_REPLY_MARKER_BLOCK_SIZE : virtualBlockSize;
},
onCommit: (commit) => {
for (const error of this.windowChanges.emit(commit))
this.#onError(error);
this.#syncRootObservers(
commit.attachedRoots,
commit.detachedRoots
);
const projectionChanged = commit.tree.changed !== !1;
projectionChanged && this.#syncProjectionFeatures(), this.#syncContentFeatures(), this.#syncDirectReplyPrefetchCandidates(), this.#releaseParkedViews(commit.tree.parked), projectionChanged && this.#scheduleBranchPaint();
const mutationPostNumber = this.#pendingViewportMutation?.postNumber, visiblePostNumber = (mutationPostNumber === void 0 ? void 0 : this.domOwner.view(mutationPostNumber)?.slots.root)?.isConnected ? mutationPostNumber : this.#treeViewport.visiblePostNumbers[0] ?? commit.window.visiblePostNumbers[0], visibleRootPostNumber = visiblePostNumber === void 0 ? void 0 : this.replyTreePresentation.rootOf(visiblePostNumber) ?? visiblePostNumber;
if (visibleRootPostNumber !== void 0) {
const changeKey = `${visibleRootPostNumber}|${Number(commit.window.atStart)}|${Number(commit.window.atEnd)}`;
if (changeKey !== this.#lastVisibleRootChangeKey) {
this.#lastVisibleRootChangeKey = changeKey;
for (const error of this.visibleRootChanges.emit(Object.freeze({
postNumber: visibleRootPostNumber,
atStart: commit.window.atStart,
atEnd: commit.window.atEnd
})))
this.#onError(error);
}
}
this.#restorePendingViewportMutation(), this.#scroll.notifyVirtualWindowCommit?.();
},
...options.observerFactory ? { observerFactory: options.observerFactory } : {},
...options.frameScheduler ? { frameScheduler: options.frameScheduler } : {},
scope: this.scope
});
const NativeResizeObserver = options.document.defaultView?.ResizeObserver;
this.#ownSizeObserver = NativeResizeObserver ? new NativeResizeObserver((entries) => {
this.#recordOwnSizeMeasurements(entries);
}) : null, this.scope.add(() => {
this.#ownSizeObserver?.disconnect(), this.#ownSizeTargets.clear(), this.#ownSizeSamples.clear(), this.#pendingOwnSizePostNumbers.clear(), this.#deferredMeasurementFlushPending = !1;
});
const createBranchResizeObserver = options.branchResizeObserverFactory ?? (NativeResizeObserver ? (callback) => new NativeResizeObserver(callback) : void 0);
this.branchOverlay = new import_branch_overlay.ReaderBranchOverlayController({
domOwner: this.domOwner,
renderMode: "segmented-css",
preserveCollapseAnchor: (root) => {
const postNumber = Number(root.dataset.postNumber);
if (!Number.isSafeInteger(postNumber) || postNumber <= 0)
return this.#pendingBranchCollapseAnchor = null, !1;
const scrollRoot = root.closest(".ldp-body"), header = root.querySelector(
":scope > .ldp-post-head"
), viewport = scrollRoot?.getBoundingClientRect(), frozenBottom = scrollRoot?.closest(".ldp-modal")?.querySelector(":scope > .ldp-header")?.getBoundingClientRect().bottom ?? viewport?.top, headerRect = header?.getBoundingClientRect(), rootRect = root.getBoundingClientRect(), visibleTop = Math.max(
viewport?.top ?? 0,
frozenBottom ?? 0
), parentVisible = !!(viewport && headerRect && headerRect.bottom > visibleTop && headerRect.top < viewport.bottom);
return this.#pendingBranchCollapseAnchor = Object.freeze({
postNumber,
viewportOffset: parentVisible ? Math.max(0, rootRect.top - visibleTop) : 10
}), !0;
},
onLayoutChange: () => {
const anchor = this.#pendingBranchCollapseAnchor;
this.#pendingBranchCollapseAnchor = null;
const postNumber = anchor?.postNumber ?? null, rootPostNumber = postNumber === null ? void 0 : this.replyTreePresentation.rootOf(postNumber) ?? postNumber, offset = postNumber === null ? void 0 : this.#treeViewport.offsetOf(postNumber) ?? this.layout.offsetOf(rootPostNumber);
if (postNumber !== null && offset !== void 0) {
this.#scroll.writeScrollOffset(offset), this.frame.flushNow();
const target = this.domOwner.view(postNumber)?.slots.root;
if (target?.isConnected) {
const alignCollapsedBranch = (element) => this.#scroll.alignPost(element, {
source: "branch-collapse",
alignment: "start",
viewportOffset: anchor?.viewportOffset ?? 10,
highlight: !1
});
alignCollapsedBranch(target), this.frame.flushNow();
const committedTarget = this.domOwner.view(postNumber)?.slots.root;
committedTarget?.isConnected && alignCollapsedBranch(committedTarget);
}
} else
this.frame.notifyScroll();
this.#scheduleBranchPaint();
},
onObservedResize: () => this.#scheduleBranchPaint(),
...createBranchResizeObserver ? { createResizeObserver: createBranchResizeObserver } : {},
parentScope: this.scope
}), this.scope.add(options.scroll.listenScroll(() => {
this.frame.notifyScroll();
})), this.scope.add(options.scroll.listenUserScrollIntent?.(() => {
this.#freezeCanonicalUntilScrollIdle();
}) ?? (() => {
})), this.scope.add(() => {
this.#anchorSettlementController?.abort(new DOMException(
"Topic 锚点结算已释放",
"AbortError"
)), this.#anchorSettlementController = null, this.#cancelProjectionHydration(), this.#projectionHydrationFailedPostNumbers.clear(), this.#directReplyPrefetchHandle !== null && this.#directReplyPrefetchScheduler.cancel(
this.#directReplyPrefetchHandle
), this.#directReplyPrefetchHandle = null;
for (const [postNumber, request] of this.#directReplyPrefetches)
request.controller.signal.aborted || request.controller.abort(new DOMException(
`树状回复 #${postNumber} 已离开 Topic`,
"AbortError"
));
this.#directReplyPrefetches.clear(), this.#queuedDirectReplyPrefetches.clear(), this.#syncDirectReplyLoadingIndicators(), this.#directReplyPrefetchedExpectedCounts.clear(), this.#directReplyPrefetchAttemptedExpectedCounts.clear(), this.#directReplyPrefetchCandidatePostNumbers = /* @__PURE__ */ new Set(), this.#directReplyPrefetchOrderedCandidates = Object.freeze([]), this.#directReplyVisiblePostNumbers = /* @__PURE__ */ new Set(), this.#directReplyVisibleKey = "";
for (const postNumber of this.#activeContentPostNumbers) {
const root = this.domOwner.view(postNumber)?.slots.root;
root && this.#deactivateNodeContent(root, postNumber);
}
for (const postNumber of this.#activeBranchPostNumbers) {
const root = this.domOwner.view(postNumber)?.slots.root;
root && this.#deactivateBranch(root, postNumber);
}
this.#activeBranchPostNumbers.clear(), this.#activeContentPostNumbers = /* @__PURE__ */ new Set(), this.#nextContentPostNumbers = /* @__PURE__ */ new Set(), this.#desiredContentPostNumbers = /* @__PURE__ */ new Set(), this.#branchPaintHandle !== null && this.#branchFrames.cancel(this.#branchPaintHandle), this.#branchPaintHandle = null, this.streamView.slots.rootList.classList.remove(
"ldp-branch-paint-pending"
), this.visibleRootChanges.clear(), this.windowChanges.clear(), this.#cancelPendingViewportMutation(), this.#ownsPresentationChanges && this.presentationChanges.clear();
for (const cleanup of this.#rootObservers.values()) cleanup();
this.#rootObservers.clear();
}), options.session.changes.subscribe((commit) => {
this.#queueSessionCommit(commit);
}, this.scope), options.replyTreePreferences?.subscribe((preferences) => {
this.replyTreePresentation.update(preferences) && this.refreshRootProjection();
}, this.scope);
}
async initialize() {
this.#assertActive();
const topic = await this.#session.init();
return this.#assertActive(), this.#scroll.writeScrollOffset(0), this.frame.flushNow(), topic;
}
get preparedPostViewCount() {
return this.domOwner.views().length + this.#retainedViews.size;
}
async loadNext(options = {}) {
this.#assertActive();
const result = await this.#session.next(options);
return this.#assertActive(), this.frame.notifyScroll(), result;
}
async hydrateUnloadedRange(request, options = {}) {
this.#assertActive();
let postNumber = (0, import_identifiers.discoursePostNumber)(request.postNumber);
if (this.replyTreePresentation.canonicalFrozen) {
this.#adoptLatestCanonicalProjection();
const window = this.frame.lastCommit?.window;
if (request.direction === "around") {
if (!(this.visibleDataGapPostNumber() === postNumber) && window?.unloadedGapTargetPostNumber === void 0) return 0;
} else if (request.direction === "before") {
if (window?.hasUnloadedGapBefore !== !0) return 0;
postNumber = window.unloadedGapBeforeAnchorPostNumber ?? window.segmentStartPostNumber ?? postNumber;
} else {
if (window?.hasUnloadedGapAfter !== !0) return 0;
postNumber = window.unloadedGapAfterAnchorPostNumber ?? window.segmentEndPostNumber ?? postNumber;
}
}
if (request.direction === "around" && this.#session.postByNumber(postNumber) !== void 0)
return this.refreshRootProjection(), 0;
let posts;
return request.direction === "before" ? posts = this.#session.loadBeforePost ? await this.#session.loadBeforePost(postNumber, options) : Object.freeze([]) : request.direction === "after" ? posts = this.#session.loadAfterPost ? await this.#session.loadAfterPost(postNumber, options) : Object.freeze([]) : posts = this.#session.loadAroundPost ? await this.#session.loadAroundPost(postNumber, options) : Object.freeze([]), this.#assertActive(), this.#adoptLatestCanonicalProjection() || this.frame.notifyScroll(), posts.length;
}
async prefetchAhead(batchCount) {
this.#assertActive(), this.#session.prefetchAhead && (await this.#session.prefetchAhead(batchCount, {
background: !0,
maxAttempts: 1
}), this.#assertActive(), this.frame.notifyScroll());
}
setFlowStatus(state) {
this.#assertActive(), this.streamView.setFlowState(Object.freeze({
...state,
empty: state.done && this.replyTreePresentation.roots().length === 0
}));
}
notifyScroll() {
this.#assertActive(), this.frame.notifyScroll();
}
/**
* cooked、图片、Onebox、公式等迟到内容只报告“几何已经变化”。
*
* 根高度由 VirtualStreamFrameController 的唯一 ResizeObserver 读取并提交;
* 这里不得再伪造一次 scroll 帧,否则同一次资源 load 会同时走手工通知与
* ResizeObserver 两条路径。回复线只需在下一绘制帧读取最终锚点。
*/
notifyContentLayoutChanged() {
this.#assertActive(), this.#scheduleBranchPaint();
}
flushNow() {
this.#assertActive(), this.frame.flushNow();
}
refreshRootProjection(resetScroll = !1) {
this.#assertActive(), this.#rootProjection.syncRoots(), resetScroll && this.#scroll.writeScrollOffset(0), this.frame.flushNow(), this.#syncProjectionFeatures();
}
revealNextReplyLevel(postNumberValue) {
this.#assertActive();
const postNumber = (0, import_identifiers.discoursePostNumber)(postNumberValue);
return this.replyTreePresentation.revealNextLevel(postNumber) ? (this.refreshRootProjection(), !0) : !1;
}
readWindowInput() {
return this.#assertActive(), this.#readVirtualWindowInput();
}
#readVirtualWindowInput() {
const input = this.#scroll.readWindowInput(), preservePostNumber = input.preservePostNumber, preserveRootPostNumber = preservePostNumber === void 0 ? void 0 : this.replyTreePresentation.rootOf(preservePostNumber) ?? preservePostNumber;
return Object.freeze({
...input,
materializationStepScreens: MATERIALIZATION_STEP_SCREENS,
...preserveRootPostNumber === void 0 ? {} : { preserveRootPostNumber }
});
}
hasVisibleDataGap() {
return this.#assertActive(), this.visibleDataGapPostNumber() !== void 0;
}
visibleDataGapPostNumber() {
this.#assertActive();
for (const visiblePostNumber of this.#treeViewport.visiblePostNumbers) {
let postNumber = visiblePostNumber, rootmostMissingPostNumber;
const visited = /* @__PURE__ */ new Set();
for (; postNumber != null && !visited.has(postNumber); )
visited.add(postNumber), this.#session.postByNumber(postNumber) || (rootmostMissingPostNumber = postNumber), postNumber = this.replyTreePresentation.parentOf(postNumber);
if (rootmostMissingPostNumber !== void 0)
return rootmostMissingPostNumber;
}
}
lastUserScrollAt() {
return this.#assertActive(), this.#scrollLifecycle.lastUserScrollAt();
}
lastUserScrollDirection() {
this.#assertActive();
const direction = this.#scroll.lastUserScrollDirection?.() ?? 0;
return direction === -1 || direction === 1 ? direction : 0;
}
listenUserScrollIntent(listener) {
return this.#assertActive(), this.#scroll.listenUserScrollIntent?.(listener) ?? (() => {
});
}
listenDirectUserScrollIntent(listener) {
return this.#assertActive(), this.#scroll.listenDirectUserScrollIntent?.(listener) ?? (() => {
});
}
/** 主滚动坐标已在统一 scroll owner 中提交;早于可能发生的虚拟 DOM 换窗。 */
listenScrollFrameCommit(listener) {
return this.#assertActive(), this.#scroll.listenScroll(listener);
}
waitForScrollIdle() {
return this.#assertActive(), this.#scrollLifecycle.waitForIdle();
}
captureViewportAnchor() {
this.#assertActive();
const physicalAnchor = this.#scroll.readVisibleViewportAnchor?.(
this.#visiblePostElements()
), input = this.#scroll.readWindowInput(), scrollRange = this.#scrollRange(input), scrollRatio = scrollRange > 0 ? Math.min(1, Math.max(0, input.scrollOffset / scrollRange)) : 0;
if (physicalAnchor) {
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: physicalAnchor.postNumber
}).postNumber, rootPostNumber = this.domOwner.topology.rootOf(postNumber), postLayoutOffset = this.#treeViewport.offsetOf(postNumber) ?? (rootPostNumber === void 0 ? void 0 : this.layout.offsetOf(rootPostNumber));
if (postLayoutOffset !== void 0)
return Object.freeze({
postNumber,
postOffset: input.scrollOffset - postLayoutOffset,
scrollTop: input.scrollOffset,
scrollRange,
scrollRatio
});
}
const visibleRootPostNumber = this.layout.window({
...input,
overscanBeforeScreens: 0,
overscanAfterScreens: 0
}).visiblePostNumbers[0];
if (visibleRootPostNumber === void 0) return null;
const rootOffset = this.layout.offsetOf(visibleRootPostNumber);
return rootOffset === void 0 ? null : Object.freeze({
postNumber: visibleRootPostNumber,
postOffset: input.scrollOffset - rootOffset,
scrollTop: input.scrollOffset,
scrollRange,
scrollRatio
});
}
#scrollRange(input) {
const physicalRange = Number(this.#scroll.readScrollRange?.());
if (Number.isFinite(physicalRange) && physicalRange > 0)
return physicalRange;
const virtualRange = Math.max(
0,
this.layout.window(input).totalSize - input.viewportSize
);
return virtualRange > 0 ? virtualRange : Math.max(0, Number.isFinite(physicalRange) ? physicalRange : 0);
}
#connectedPostElements() {
return this.domOwner.views().map((view) => view.slots.root).filter(
(root) => root.isConnected && this.streamView.slots.root.contains(root)
);
}
#visiblePostElements() {
const visible = this.#treeViewport.visiblePostNumbers.map((postNumber) => this.domOwner.view(postNumber)?.slots.root).filter(
(root) => !!root && root.isConnected && this.streamView.slots.root.contains(root)
);
return visible.length ? Object.freeze(visible) : this.#connectedPostElements();
}
restoreViewportAnchor(anchor) {
this.#assertActive();
const scrollRatio = Number(anchor.scrollRatio);
if (Number.isFinite(scrollRatio) && scrollRatio >= 0)
return this.#writeVirtualOffset(() => {
const input = this.#scroll.readWindowInput();
return this.#scrollRange(input) * Math.min(1, scrollRatio);
});
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: anchor.postNumber
}).postNumber, rootPostNumber = this.domOwner.topology.rootOf(postNumber);
if (rootPostNumber === void 0) return !1;
const postOffset = Number(anchor.postOffset);
return this.#writeVirtualOffset(() => {
const postLayoutOffset = this.#treeViewport.offsetOf(postNumber) ?? this.layout.offsetOf(rootPostNumber);
return postLayoutOffset === void 0 ? void 0 : postLayoutOffset + (Number.isFinite(postOffset) ? postOffset : 0);
});
}
/** 只闪烁已挂载楼层,不改变当前视口几何。 */
highlightPost(rawPostNumber) {
this.#assertActive();
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: rawPostNumber
}).postNumber, element = this.domOwner.view(postNumber)?.slots.root;
return !element?.isConnected || element.classList.contains("ldp-virtual-ancestor-shell") || element.classList.contains("ldp-post-projection-pending") || !this.#scroll.highlightPost ? !1 : (this.#scroll.highlightPost(element), !0);
}
/** 当前 canonical 楼层被主信息流投影停放时,交由完整讨论 surface 揭示。 */
isPostHidden(rawPostNumber) {
this.#assertActive();
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: rawPostNumber
}).postNumber;
return this.replyTreePresentation.canonical.has(postNumber) && this.replyTreePresentation.rootOf(postNumber) === void 0;
}
/** 目的性导航取代当前滚动手势,并用最新回复关系判定目标属于正文还是隐藏子树。 */
prepareRevealPost(rawPostNumber) {
this.#assertActive(), (0, import_identifiers.discoursePostReference)({ post_number: rawPostNumber }), this.#anchorSettlementController?.abort(new DOMException(
"新导航已取代旧锚点结算",
"AbortError"
)), this.#anchorSettlementController = null, this.#adoptLatestCanonicalProjection();
}
revealPost(rawPostNumber, options) {
this.#assertActive();
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: rawPostNumber
}).postNumber;
options.revealAsFloor === !0 && this.replyTreePresentation.revealAsFloor(postNumber) && (this.#rootProjection.syncRoots(), this.#syncProjectionFeatures());
let rootPostNumber = this.domOwner.topology.rootOf(postNumber);
if (rootPostNumber === void 0 && options.degradedRootPostNumber !== void 0 && (this.replyTreePresentation.revealDegradedBranch(
options.degradedRootPostNumber,
postNumber
), this.#rootProjection.syncRoots(), this.#syncProjectionFeatures(), rootPostNumber = this.domOwner.topology.rootOf(postNumber)), rootPostNumber === void 0 && this.replyTreePresentation.revealAsFloor(postNumber) && (this.#rootProjection.syncRoots(), rootPostNumber = postNumber, this.#syncProjectionFeatures()), rootPostNumber === void 0) return null;
const currentRoot = this.domOwner.view(postNumber)?.slots.root, wasMaterialized = currentRoot?.isConnected === !0 && !currentRoot.classList.contains("ldp-virtual-ancestor-shell") && !currentRoot.classList.contains("ldp-post-projection-pending");
if (!wasMaterialized && !this.#writeVirtualOffset(
() => this.#treeViewport.offsetOf(postNumber) ?? this.layout.offsetOf(rootPostNumber)
) || this.domOwner.view(postNumber)?.slots.root?.classList.contains("ldp-post-projection-pending") && !this.#materializeProjection(postNumber)) return null;
const element = this.domOwner.view(postNumber)?.slots.root;
return !element?.isConnected || element.classList.contains("ldp-virtual-ancestor-shell") || element.classList.contains("ldp-post-projection-pending") ? null : (this.#scroll.alignPost(element, options), Object.freeze({
postNumber,
rootPostNumber,
element,
mounted: !wasMaterialized
}));
}
/**
* 在 canonical/DOM 提交静稳后结算一次目标锚点。任何新布局提交都重启
* quiet window;新导航、直接用户滚动或 Topic 销毁立即取消。
*/
settleRevealedPost(rawPostNumber, options, settlement = {}) {
this.#assertActive();
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: rawPostNumber
}).postNumber;
this.#anchorSettlementController?.abort(new DOMException(
"新锚点结算已取代旧交易",
"AbortError"
));
const controller = new AbortController();
this.#anchorSettlementController = controller;
const tolerancePx = Math.max(0, Number.isFinite(settlement.tolerancePx) ? Number(settlement.tolerancePx) : 2), quietMs = Math.max(0, Number.isFinite(settlement.quietMs) ? Number(settlement.quietMs) : 120), maxWaitMs = Math.max(quietMs, Number.isFinite(settlement.maxWaitMs) ? Number(settlement.maxWaitMs) : 2e3), startedAt = this.#now();
return new Promise((resolve) => {
let quietHandle = null, deadlineHandle = null, attempts = 0, stablePasses = 0, lastError = null, completed = !1, attempting = !1, releaseWindowChanges = () => {
}, releaseUserIntent = () => {
};
const cancelHandle = (handle) => {
handle !== null && this.#projectionHydrationScheduler.cancel(handle);
}, finish = (status) => {
completed || (completed = !0, cancelHandle(quietHandle), cancelHandle(deadlineHandle), releaseWindowChanges(), releaseUserIntent(), controller.signal.removeEventListener("abort", abortSettlement), this.#anchorSettlementController === controller && (this.#anchorSettlementController = null), resolve(Object.freeze({
status,
errorPx: lastError,
attempts,
durationMs: Math.max(0, this.#now() - startedAt)
})));
}, abortSettlement = () => finish("cancelled"), scheduleQuiet = () => {
cancelHandle(quietHandle), quietHandle = this.#projectionHydrationScheduler.schedule(
attemptSettlement,
quietMs
);
}, attemptSettlement = () => {
if (quietHandle = null, completed || controller.signal.aborted) return;
attempts += 1;
let revealed = null;
attempting = !0;
try {
this.frame.flushNow(), revealed = this.revealPost(postNumber, options), this.frame.flushNow();
} catch (error) {
this.#onError(error), finish("unavailable");
return;
} finally {
attempting = !1;
}
if (!revealed || !this.#scroll.alignmentError) {
finish("unavailable");
return;
}
if (lastError = Math.abs(this.#scroll.alignmentError(
revealed.element,
options
)), stablePasses = lastError <= tolerancePx ? stablePasses + 1 : 0, stablePasses >= 2) {
finish("settled");
return;
}
scheduleQuiet();
};
releaseWindowChanges = this.windowChanges.subscribe(() => {
attempting || (stablePasses = 0, scheduleQuiet());
}), releaseUserIntent = this.listenDirectUserScrollIntent(() => {
finish("cancelled");
}), controller.signal.addEventListener("abort", abortSettlement, { once: !0 }), scheduleQuiet(), deadlineHandle = this.#projectionHydrationScheduler.schedule(
() => {
if (deadlineHandle = null, completed) return;
const pendingQuiet = quietHandle;
quietHandle = null, cancelHandle(pendingQuiet), stablePasses > 0 && lastError !== null && attemptSettlement(), completed || finish("timeout");
},
maxWaitMs
);
});
}
/**
* 目的性末端跳转不等待顺序 cursor 扫完整帖:直接显示唯一流尾标记,
* 只连续写入物理最大位置。结算不再依赖最后正文 DOM:该卡片在进入
* 虚拟 gap 后可能正常卸载,若再用它做存活校验就会在正文与 gap 之间往返。
*/
reachStreamEnd(settlement = {}) {
this.#assertActive(), this.#anchorSettlementController?.abort(new DOMException(
"新流尾结算已取代旧交易",
"AbortError"
));
const controller = new AbortController();
this.#anchorSettlementController = controller;
const tolerancePx = Math.max(0, Number.isFinite(settlement.tolerancePx) ? Number(settlement.tolerancePx) : 2), quietMs = Math.max(0, Number.isFinite(settlement.quietMs) ? Number(settlement.quietMs) : 120), maxWaitMs = Math.max(quietMs, Number.isFinite(settlement.maxWaitMs) ? Number(settlement.maxWaitMs) : 2e3), startedAt = this.#now();
return this.streamView.revealEndTip(), new Promise((resolve) => {
let quietHandle = null, deadlineHandle = null, attempts = 0, stablePasses = 0, lastError = null, completed = !1, aligning = !1, releaseWindowChanges = () => {
}, releaseUserIntent = () => {
};
const cancelHandle = (handle) => {
handle !== null && this.#projectionHydrationScheduler.cancel(handle);
}, finish = (status) => {
completed || (completed = !0, cancelHandle(quietHandle), cancelHandle(deadlineHandle), releaseWindowChanges(), releaseUserIntent(), controller.signal.removeEventListener("abort", abortSettlement), this.#anchorSettlementController === controller && (this.#anchorSettlementController = null), resolve(Object.freeze({
status,
errorPx: lastError,
attempts,
durationMs: Math.max(0, this.#now() - startedAt)
})));
}, abortSettlement = () => finish("cancelled"), scheduleQuiet = () => {
cancelHandle(quietHandle), quietHandle = this.#projectionHydrationScheduler.schedule(
alignEnd,
quietMs
);
}, alignEnd = () => {
if (quietHandle = null, !(completed || controller.signal.aborted)) {
attempts += 1, aligning = !0;
try {
const commit = () => {
this.frame.flushNow();
const range = this.#scrollRange(this.#scroll.readWindowInput());
this.#scroll.writeScrollOffset(range), this.frame.flushNow();
};
this.#scroll.withProgrammaticScrollTransaction ? this.#scroll.withProgrammaticScrollTransaction(commit) : commit();
const input = this.#scroll.readWindowInput();
lastError = Math.abs(this.#scrollRange(input) - input.scrollOffset), stablePasses = lastError <= tolerancePx ? stablePasses + 1 : 0;
} catch (error) {
this.#onError(error), finish("unavailable");
return;
} finally {
aligning = !1;
}
if (stablePasses >= 2) {
finish("settled");
return;
}
scheduleQuiet();
}
};
releaseWindowChanges = this.windowChanges.subscribe(() => {
aligning || (stablePasses = 0, scheduleQuiet());
}), releaseUserIntent = this.listenDirectUserScrollIntent(() => {
finish("cancelled");
}), controller.signal.addEventListener("abort", abortSettlement, { once: !0 }), alignEnd(), deadlineHandle = this.#projectionHydrationScheduler.schedule(
() => {
if (deadlineHandle = null, completed) return;
const pendingQuiet = quietHandle;
quietHandle = null, cancelHandle(pendingQuiet), stablePasses > 0 && lastError !== null && alignEnd(), completed || finish("timeout");
},
maxWaitMs
);
});
}
#writeVirtualOffset(readOffset) {
let written = !1;
const commit = () => {
this.frame.flushNow();
const offset = readOffset();
offset !== void 0 && (this.#scroll.writeScrollOffset(offset), this.frame.flushNow(), written = !0);
};
return this.#scroll.withProgrammaticScrollTransaction ? this.#scroll.withProgrammaticScrollTransaction(commit) : commit(), written;
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#queueSessionCommit(commit) {
const postStreamRevision = this.#session.postStreamRevision ?? 0, postStreamGeometryChanged = postStreamRevision !== this.#lastPostStreamRevision;
this.#lastPostStreamRevision = postStreamRevision, this.#beginViewportMutation(commit, postStreamGeometryChanged);
try {
this.#rootProjection.syncRoots(), this.#applySessionCommit(commit), this.#emitPresentationCommit(commit);
} catch (error) {
throw this.#cancelPendingViewportMutation(), error;
}
}
#beginViewportMutation(commit, postStreamGeometryChanged = !1) {
this.#commitMayChangeConnectedGeometry(
commit,
postStreamGeometryChanged
) && this.#beginConnectedViewportMutation();
}
#commitMayChangeConnectedGeometry(commit, postStreamGeometryChanged) {
if (postStreamGeometryChanged && this.#connectedPostElements().length > 0) return !0;
if (commit.topicChanged || commit.streamChanged)
return this.#connectedPostElements().length > 0;
for (const postNumber of [
...commit.changedPostNumbers,
...commit.removedPostNumbers ?? []
]) {
const root = this.domOwner.view(postNumber)?.slots.root;
if (root?.isConnected && this.streamView.slots.root.contains(root)) return !0;
const rootPostNumber = this.replyTreePresentation.rootOf(postNumber);
if (rootPostNumber === void 0 || rootPostNumber === postNumber)
continue;
const connectedRoot = this.domOwner.view(rootPostNumber)?.slots.root;
if (connectedRoot?.isConnected && this.streamView.slots.root.contains(connectedRoot)) return !0;
}
return !1;
}
#beginConnectedViewportMutation() {
if (this.#pendingViewportMutation || !this.#scroll.beginViewportMutation) return null;
const elements = this.#connectedPostElements();
if (!elements.length) return null;
const mutation = this.#scroll.beginViewportMutation(elements);
return this.#pendingViewportMutation = mutation, mutation;
}
#freezeCanonicalUntilScrollIdle() {
if (!this.replyTreePresentation.freezeCanonical()) return;
const epoch = ++this.#canonicalFreezeEpoch;
this.#scrollLifecycle.waitForIdle().then(() => {
if (!(this.#destroyed || epoch !== this.#canonicalFreezeEpoch))
try {
this.#adoptLatestCanonicalProjection();
} catch (error) {
this.#onError(error);
}
}).catch(this.#onError);
}
#adoptLatestCanonicalProjection() {
if (!this.replyTreePresentation.canonicalFrozen) return !1;
if (this.#canonicalFreezeEpoch += 1, !this.replyTreePresentation.thawCanonical()) return !0;
const mutation = this.#beginConnectedViewportMutation();
try {
this.refreshRootProjection(), mutation && this.#pendingViewportMutation === mutation && this.#restorePendingViewportMutation();
} catch (error) {
throw mutation && this.#pendingViewportMutation === mutation && this.#cancelPendingViewportMutation(), error;
}
return !0;
}
#restorePendingViewportMutation() {
const mutation = this.#pendingViewportMutation;
mutation && (this.#pendingViewportMutation = null, mutation.restore());
}
#cancelPendingViewportMutation() {
const mutation = this.#pendingViewportMutation;
mutation && (this.#pendingViewportMutation = null, mutation.cancel());
}
#emitPresentationCommit(commit) {
for (const error of this.presentationChanges.emit(commit))
this.#onError(error);
}
#applySessionCommit(commit, notify = !0) {
this.#directReplyPrefetchCandidateKey = "";
for (const postNumber of commit.removedPostNumbers ?? []) {
this.#projectionHydrationFailedPostNumbers.delete(postNumber), this.#directReplyPrefetchedExpectedCounts.delete(postNumber), this.#directReplyPrefetchAttemptedExpectedCounts.delete(postNumber);
const root = this.domOwner.view(postNumber)?.slots.root;
this.#activeContentPostNumbers.has(postNumber) && (root && this.#deactivateNodeContent(root, postNumber), this.#activeContentPostNumbers = new Set(
[...this.#activeContentPostNumbers].filter(
(candidate) => candidate !== postNumber
)
)), root && this.#rootObservers.has(postNumber) && this.#deactivateBranch(root, postNumber), this.#rootObservers.get(postNumber)?.(), this.#rootObservers.delete(postNumber), this.domOwner.unregister(postNumber, !0, !1), this.#retainedViews.get(postNumber)?.destroy(), this.#retainedViews.delete(postNumber), this.#topicDirtyRetainedPostNumbers.delete(postNumber);
}
const refreshPostNumbers = new Set(commit.changedPostNumbers);
for (const postNumber of refreshPostNumbers)
this.#projectionHydrationFailedPostNumbers.delete(postNumber);
if (commit.topicChanged) {
for (const postNumber of this.#mountedPostNumbers)
refreshPostNumbers.add(postNumber);
for (const postNumber of this.#retainedViews.keys())
this.#topicDirtyRetainedPostNumbers.add(postNumber);
}
const posts = [...refreshPostNumbers].map((postNumber) => this.#session.postByNumber(postNumber)).filter((post) => post !== void 0);
this.#refreshExistingPosts(posts);
for (const postNumber of refreshPostNumbers)
this.#directReplyPrefetchCandidatePostNumbers.has(postNumber) && this.#prefetchDirectReplies(postNumber);
notify && this.frame.notifyScroll();
}
#refreshExistingPosts(posts) {
for (const post of posts) {
let postNumber;
try {
postNumber = (0, import_identifiers.discoursePostReference)(post).postNumber;
} catch (error) {
this.#onError(error);
continue;
}
const existing = this.domOwner.view(postNumber) ?? this.#retainedViews.get(postNumber);
if (existing && !existing.slots.root.classList.contains(
"ldp-post-projection-pending"
))
try {
this.postProjector.render(post, existing), this.#topicDirtyRetainedPostNumbers.delete(postNumber);
} catch (error) {
this.#onError(error);
}
}
}
#prepareRootViews(rootPostNumbers, input, window) {
const connectedRootPostNumbers = [...this.streamView.slots.rootList.children].flatMap((element) => {
if (!element.classList.contains("ldp-post")) return [];
const postNumber = Number(element.getAttribute("data-post-number"));
return Number.isSafeInteger(postNumber) && postNumber > 0 ? [postNumber] : [];
});
(connectedRootPostNumbers.length !== rootPostNumbers.length || connectedRootPostNumbers.some(
(postNumber, index) => postNumber !== rootPostNumbers[index]
)) && this.#beginConnectedViewportMutation(), this.#updateRetainedViewLimit(input.maxMountedPostCount);
let plan = this.#treeViewport.plan(window, input);
if (!this.#ownSizeObserver)
for (let pass = 0; pass < 2; pass += 1) {
let measured = !1;
for (const postNumber of this.#activeContentPostNumbers)
plan.contentPostNumbers.has(postNumber) || (measured = this.#measureOwnPostSize(postNumber) || measured);
if (!measured) break;
plan = this.#treeViewport.plan(window, input);
}
this.#rootVirtualInsets = plan.rootVirtualInsets ?? /* @__PURE__ */ new Map(), this.#mountedPostNumbers = plan.mountedPostNumbers, this.#desiredContentPostNumbers = new Set(plan.contentPostNumbers);
const visiblePostNumbers = new Set(
this.#treeViewport.visiblePostNumbers
);
let eagerProjectionBudget = this.#scrollLifecycle.isIdle(
PROJECTION_HYDRATION_MIN_IDLE_MS
) ? plan.contentPostNumbers.size : 0;
for (const postNumber of plan.mountedPostNumbers) {
if (this.domOwner.view(postNumber)) continue;
const retained = this.#retainedViews.get(postNumber);
if (retained) {
if (this.#retainedViews.delete(postNumber), this.domOwner.register(retained, !1), !retained.slots.root.classList.contains(
"ldp-post-projection-pending"
) && this.#topicDirtyRetainedPostNumbers.delete(postNumber)) {
const post2 = this.#session.postByNumber(postNumber);
if (post2)
try {
this.postProjector.render(post2, retained);
} catch (error) {
this.#onError(error);
}
}
continue;
}
const post = this.#session.postByNumber(postNumber);
if (post)
try {
const renderImmediately = visiblePostNumbers.has(postNumber) || eagerProjectionBudget > 0 && plan.contentPostNumbers.has(postNumber), created = renderImmediately ? this.postProjector.create(
post,
this.scope,
postNumber
) : this.postProjector.createShell(
post,
this.scope,
postNumber
);
renderImmediately ? eagerProjectionBudget > 0 && (eagerProjectionBudget -= 1) : this.#markProjectionPending(
created,
plan.ownSizes.get(postNumber)
), this.domOwner.register(created, !1);
} catch (error) {
this.#onError(error);
}
}
[...visiblePostNumbers].some(
(postNumber) => {
const root = this.domOwner.view(postNumber)?.slots.root;
return root?.isConnected === !0 && root.classList.contains(
"ldp-post-projection-pending"
);
}
) && this.#beginConnectedViewportMutation();
let visibleProjectionMaterialized = !1;
for (const postNumber of visiblePostNumbers)
this.domOwner.view(postNumber)?.slots.root?.classList.contains("ldp-post-projection-pending") && (visibleProjectionMaterialized = this.#materializeProjection(
postNumber,
{ commitEffects: !1 }
) || visibleProjectionMaterialized);
visibleProjectionMaterialized && this.#commitProjectionMaterialization(!1), this.#nextContentPostNumbers = new Set(
[...plan.contentPostNumbers].filter((postNumber) => {
const root = this.domOwner.view(postNumber)?.slots.root;
return !!root && !root.classList.contains(
"ldp-post-projection-pending"
);
})
);
const scrollDirection = this.#scroll.lastUserScrollDirection?.() ?? 0, orderedContentPostNumbers = [...plan.contentPostNumbers], visibleContentIndices = orderedContentPostNumbers.flatMap(
(postNumber, index) => visiblePostNumbers.has(postNumber) ? [index] : []
), firstVisibleContentIndex = visibleContentIndices.length ? Math.min(...visibleContentIndices) : -1, lastVisibleContentIndex = visibleContentIndices.length ? Math.max(...visibleContentIndices) : -1, directionalProjectionHydrationPostNumbers = orderedContentPostNumbers.filter(
(_postNumber, index) => scrollDirection === -1 ? firstVisibleContentIndex >= 0 && index < firstVisibleContentIndex : scrollDirection === 1 ? lastVisibleContentIndex >= 0 && index > lastVisibleContentIndex : !1
);
scrollDirection === -1 && directionalProjectionHydrationPostNumbers.reverse();
const projectionHydrationPostNumbers = [
...visiblePostNumbers,
...directionalProjectionHydrationPostNumbers,
...plan.contentPostNumbers
];
return this.#setProjectionHydrationCandidates(
projectionHydrationPostNumbers,
directionalProjectionHydrationPostNumbers
), plan;
}
#markProjectionPending(view, ownSize) {
const root = view.slots.root;
root.classList.add("ldp-post-projection-pending"), root.setAttribute("aria-busy", "true"), ownSize !== void 0 && root.style.setProperty(
"--ldp-virtual-own-size",
`${Math.max(0, ownSize)}px`
);
}
#setProjectionHydrationCandidates(postNumbers, urgentPostNumbers = []) {
const unique = /* @__PURE__ */ new Set();
for (const postNumber of postNumbers)
unique.has(postNumber) || !this.#mountedPostNumbers.has(postNumber) || this.#projectionHydrationFailedPostNumbers.has(postNumber) || !this.domOwner.view(postNumber)?.slots.root?.classList.contains("ldp-post-projection-pending") || unique.add(postNumber);
this.#projectionHydrationPostNumbers = Object.freeze([...unique]);
const urgent = new Set(
urgentPostNumbers.filter((postNumber) => unique.has(postNumber))
);
if (this.#projectionHydrationUrgentPostNumbers = urgent, !this.#projectionHydrationPostNumbers.length) {
this.#projectionHydrationHandle !== null && this.#projectionHydrationScheduler.cancel(
this.#projectionHydrationHandle
), this.#projectionHydrationHandle = null, this.#projectionHydrationHandleUrgent = !1;
return;
}
const needsUrgentHandle = urgent.size > 0;
this.#projectionHydrationHandle !== null && this.#projectionHydrationHandleUrgent !== needsUrgentHandle && (this.#projectionHydrationScheduler.cancel(
this.#projectionHydrationHandle
), this.#projectionHydrationHandle = null, this.#projectionHydrationHandleUrgent = !1), this.#scheduleProjectionHydration();
}
#scheduleProjectionHydration(delayMs) {
if (this.scope.destroyed || this.#projectionHydrationHandle !== null || !this.#projectionHydrationPostNumbers.length) return;
const urgent = this.#projectionHydrationUrgentPostNumbers.size > 0, delay = delayMs ?? (urgent ? PROJECTION_HYDRATION_BATCH_DELAY_MS : Math.max(
PROJECTION_HYDRATION_BATCH_DELAY_MS,
this.#scrollLifecycle.remainingIdleMs(
PROJECTION_HYDRATION_MIN_IDLE_MS
)
));
this.#projectionHydrationHandleUrgent = urgent, this.#projectionHydrationHandle = this.#projectionHydrationScheduler.schedule(() => {
if (this.#projectionHydrationHandle = null, this.#projectionHydrationHandleUrgent = !1, !this.scope.destroyed) {
if (!urgent) {
const remainingIdleMs = this.#scrollLifecycle.remainingIdleMs(
PROJECTION_HYDRATION_MIN_IDLE_MS
);
if (remainingIdleMs > 0) {
this.#scheduleProjectionHydration(remainingIdleMs);
return;
}
}
this.#hydrateProjectionBatch(urgent);
}
}, Math.max(0, delay));
}
#hydrateProjectionBatch(urgentOnly = !1) {
const remaining = [];
let hydrated = 0;
const batchSize = Math.max(
1,
Math.min(4, Math.floor(this.#readProjectionHydrationBatchSize()) || 1)
);
for (const postNumber of this.#projectionHydrationPostNumbers) {
if (urgentOnly && !this.#projectionHydrationUrgentPostNumbers.has(postNumber)) {
remaining.push(postNumber);
continue;
}
if (hydrated < batchSize && this.#materializeProjection(postNumber, { commitEffects: !1 })) {
hydrated += 1;
continue;
}
const root = this.domOwner.view(postNumber)?.slots.root;
this.#mountedPostNumbers.has(postNumber) && !this.#projectionHydrationFailedPostNumbers.has(postNumber) && root?.classList.contains("ldp-post-projection-pending") && remaining.push(postNumber);
}
this.#projectionHydrationPostNumbers = Object.freeze(remaining), this.#projectionHydrationUrgentPostNumbers = new Set(
remaining.filter((postNumber) => this.#projectionHydrationUrgentPostNumbers.has(postNumber))
), hydrated > 0 && this.#commitProjectionMaterialization(!0), remaining.length && this.#scheduleProjectionHydration();
}
#materializeProjection(postNumber, options = {}) {
if (!this.#mountedPostNumbers.has(postNumber) || this.#projectionHydrationFailedPostNumbers.has(postNumber)) return !1;
const post = this.#session.postByNumber(postNumber), view = this.domOwner.view(postNumber), root = view?.slots.root;
if (!post || !view || !root?.classList.contains("ldp-post-projection-pending")) return !1;
const mutation = root.isConnected ? this.#beginConnectedViewportMutation() : null;
try {
this.postProjector.render(post, view);
} catch (error) {
return this.#projectionHydrationFailedPostNumbers.add(postNumber), mutation && this.#pendingViewportMutation === mutation && this.#cancelPendingViewportMutation(), this.#onError(error), !1;
}
return this.#topicDirtyRetainedPostNumbers.delete(postNumber), root.classList.remove("ldp-post-projection-pending"), root.removeAttribute("aria-busy"), root.isConnected && this.#rootObservers.has(postNumber) && this.#activateBranch(root, postNumber), root.isConnected && this.#desiredContentPostNumbers.has(postNumber) && !this.#activeContentPostNumbers.has(postNumber) && (this.#activeContentPostNumbers = /* @__PURE__ */ new Set([
...this.#activeContentPostNumbers,
postNumber
]), this.#activateNodeContent(root, postNumber)), options.commitEffects !== !1 && this.#commitProjectionMaterialization(!1), !0;
}
#commitProjectionMaterialization(notify) {
this.#syncProjectionFeatures(), notify && this.frame.notifyScroll(), this.#scheduleBranchPaint();
}
#cancelProjectionHydration() {
this.#projectionHydrationHandle !== null && this.#projectionHydrationScheduler.cancel(
this.#projectionHydrationHandle
), this.#projectionHydrationHandle = null, this.#projectionHydrationHandleUrgent = !1, this.#projectionHydrationPostNumbers = Object.freeze([]), this.#projectionHydrationUrgentPostNumbers = /* @__PURE__ */ new Set();
}
#measureOwnPostSize(postNumber) {
const view = this.domOwner.view(postNumber), root = view?.slots.root;
if (!view || !root?.isConnected || root.classList.contains("ldp-virtual-ancestor-shell")) return !1;
const rootSize = root.getBoundingClientRect().height, replyTreeSize = view.slots.replyTree.getBoundingClientRect().height, ownSize = rootSize - replyTreeSize;
return this.#treeViewport.measureOwnSize(postNumber, ownSize);
}
#releaseParkedViews(parkedPostNumbers) {
for (const postNumber of parkedPostNumbers) {
if (this.#mountedPostNumbers.has(postNumber)) continue;
const view = this.domOwner.unregister(postNumber, !1, !1);
view && this.#retainView(view);
}
}
#updateRetainedViewLimit(maxMountedPostCount) {
this.#retainedViewLimit = maxMountedPostCount === void 0 ? 0 : Math.max(0, Math.floor(maxMountedPostCount / 4)), this.#trimRetainedViews();
}
#retainView(view) {
this.#retainedViews.delete(view.postNumber), this.#retainedViews.set(view.postNumber, view), this.#trimRetainedViews();
}
#trimRetainedViews() {
for (; this.#retainedViews.size > this.#retainedViewLimit; ) {
const postNumber = this.#retainedViews.keys().next().value;
if (postNumber === void 0) break;
const view = this.#retainedViews.get(postNumber);
this.#retainedViews.delete(postNumber), this.#topicDirtyRetainedPostNumbers.delete(postNumber), this.#projectionHydrationFailedPostNumbers.delete(postNumber), view?.destroy();
}
}
#syncContentFeatures() {
const previousPostNumbers = this.#activeContentPostNumbers, nextPostNumbers = new Set(this.#nextContentPostNumbers);
for (const postNumber of previousPostNumbers) {
if (nextPostNumbers.has(postNumber)) continue;
const root = this.domOwner.view(postNumber)?.slots.root;
root && this.#deactivateNodeContent(root, postNumber);
}
this.#activeContentPostNumbers = nextPostNumbers;
for (const postNumber of nextPostNumbers) {
if (previousPostNumbers.has(postNumber)) continue;
const root = this.domOwner.view(postNumber)?.slots.root;
root && this.#activateNodeContent(root, postNumber);
}
}
#activateNodeContent(root, postNumber) {
this.postProjector.attach(root, postNumber, "node");
const view = this.domOwner.view(postNumber);
view && this.#observeOwnSize(view);
}
#deactivateNodeContent(root, postNumber) {
const view = this.domOwner.view(postNumber);
view && this.#unobserveOwnSize(view), this.postProjector.detach(root, postNumber, "node");
}
#observeOwnSize(view) {
const observer = this.#ownSizeObserver;
if (!observer) return;
const postNumber = view.postNumber;
for (const [target, kind] of [
[view.slots.root, "root"],
[view.slots.replyTree, "reply-tree"]
])
this.#ownSizeTargets.set(target, Object.freeze({ postNumber, kind })), observer.observe(target);
}
#unobserveOwnSize(view) {
const observer = this.#ownSizeObserver;
if (observer) {
for (const target of [view.slots.root, view.slots.replyTree])
observer.unobserve(target), this.#ownSizeTargets.delete(target);
this.#ownSizeSamples.delete(view.postNumber), this.#pendingOwnSizePostNumbers.delete(view.postNumber);
}
}
#recordOwnSizeMeasurements(entries) {
const touched = /* @__PURE__ */ new Set();
for (const entry of entries) {
const target = this.#ownSizeTargets.get(entry.target);
if (!target) continue;
const borderBox = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize, blockSize = Math.round(
borderBox?.blockSize ?? entry.contentRect.height
);
if (!Number.isFinite(blockSize) || blockSize < 0) continue;
const sample = this.#ownSizeSamples.get(target.postNumber) ?? {};
target.kind === "root" ? sample.root = blockSize : sample.replyTree = blockSize, this.#ownSizeSamples.set(target.postNumber, sample), touched.add(target.postNumber);
}
for (const postNumber of touched)
this.#pendingOwnSizePostNumbers.add(postNumber);
if (!this.#scrollLifecycle.isIdle(PROJECTION_HYDRATION_MIN_IDLE_MS)) {
this.#scheduleDeferredMeasurementFlush();
return;
}
this.#commitPendingOwnSizeMeasurements();
}
#scheduleDeferredMeasurementFlush() {
this.scope.destroyed || this.#deferredMeasurementFlushPending || (this.#deferredMeasurementFlushPending = !0, this.#scrollLifecycle.waitForIdle(
PROJECTION_HYDRATION_MIN_IDLE_MS
).then(() => {
this.#deferredMeasurementFlushPending = !1, !this.scope.destroyed && (this.frame.flushDeferredMeasurements(), this.#commitPendingOwnSizeMeasurements());
}).catch(this.#onError));
}
#commitPendingOwnSizeMeasurements() {
if (!this.#pendingOwnSizePostNumbers.size) return;
const pending = [...this.#pendingOwnSizePostNumbers];
this.#pendingOwnSizePostNumbers.clear();
let changed = !1, mutation = null;
for (const postNumber of pending) {
const view = this.domOwner.view(postNumber), sample = this.#ownSizeSamples.get(postNumber);
!view?.slots.root.isConnected || view.slots.root.classList.contains("ldp-virtual-ancestor-shell") || sample?.root === void 0 || sample.replyTree === void 0 || (mutation ??= this.#beginConnectedViewportMutation(), changed = this.#treeViewport.measureOwnSize(
postNumber,
Math.max(1, sample.root - sample.replyTree)
) || changed);
}
changed ? this.frame.notifyScroll() : mutation && this.#pendingViewportMutation === mutation && this.#cancelPendingViewportMutation();
}
#syncDirectReplyPrefetchCandidates() {
const input = this.#scroll.readWindowInput(), rawScreens = Number(this.#readDirectReplyPrefetchScreens()), prefetchScreens = Number.isFinite(rawScreens) ? Math.min(3, Math.max(0, rawScreens)) : 0, beforeScreens = Math.max(
prefetchScreens,
Number(input.overscanBeforeScreens) || 0
), afterScreens = Math.max(
prefetchScreens,
Number(input.overscanAfterScreens) || 0
), scrollBucketSize = Math.max(1, input.viewportSize / 2), candidateKey = `${this.replyTreePresentation.revision}|${Math.floor(input.scrollOffset / scrollBucketSize)}|${Math.round(input.viewportSize)}|${beforeScreens}|${afterScreens}`, candidatesChanged = candidateKey !== this.#directReplyPrefetchCandidateKey;
if (this.#directReplyPrefetchCandidateKey = candidateKey, candidatesChanged) {
const prefetchInput = Object.freeze({
scrollOffset: input.scrollOffset,
viewportSize: input.viewportSize,
overscanBeforeScreens: beforeScreens,
overscanAfterScreens: afterScreens
}), rootWindow = this.layout.window(prefetchInput), plan = this.#treeViewport.plan(rootWindow, prefetchInput), candidateOffsets = new Map(
[...plan.contentPostNumbers].map((postNumber) => [
postNumber,
this.#treeViewport.offsetOf(postNumber) ?? 0
])
);
this.#directReplyPrefetchOrderedCandidates = Object.freeze(
[...plan.contentPostNumbers].sort((left, right) => (candidateOffsets.get(left) ?? 0) - (candidateOffsets.get(right) ?? 0) || left - right)
), this.#directReplyPrefetchCandidatePostNumbers = new Set(
this.#directReplyPrefetchOrderedCandidates
);
}
const visiblePostNumbers = new Set(
this.#treeViewport.visiblePostNumbers.filter(
(postNumber) => this.#directReplyPrefetchCandidatePostNumbers.has(postNumber)
)
), visibleKey = [...visiblePostNumbers].join(","), visibleChanged = visibleKey !== this.#directReplyVisibleKey;
visibleChanged && (this.#directReplyVisibleKey = visibleKey), (candidatesChanged || visibleChanged) && this.#abortDirectReplyRequestsOutside(
this.#directReplyPrefetchCandidatePostNumbers,
visiblePostNumbers
), this.#directReplyVisiblePostNumbers = visiblePostNumbers;
const orderedCandidates = [
...this.#directReplyPrefetchOrderedCandidates.filter((postNumber) => visiblePostNumbers.has(postNumber)),
...this.#directReplyPrefetchOrderedCandidates.filter((postNumber) => !visiblePostNumbers.has(postNumber))
], stillQueued = orderedCandidates.filter(
(postNumber) => this.#queuedDirectReplyPrefetches.has(postNumber)
);
this.#queuedDirectReplyPrefetches.clear();
for (const postNumber of stillQueued)
this.#queuedDirectReplyPrefetches.add(postNumber);
if (candidatesChanged || visiblePostNumbers.size > 0)
for (const postNumber of orderedCandidates)
this.#prefetchDirectReplies(postNumber, !1);
this.#queuedDirectReplyPrefetches.size > 0 ? this.#scheduleDirectReplyPrefetchFlush() : this.#directReplyPrefetchHandle !== null && (this.#directReplyPrefetchScheduler.cancel(
this.#directReplyPrefetchHandle
), this.#directReplyPrefetchHandle = null), this.#syncDirectReplyLoadingIndicators();
}
#prefetchDirectReplies(postNumber, schedule = !0) {
this.#directReplyPrefetches.has(postNumber) || this.#queuedDirectReplyPrefetches.has(postNumber) || this.scope.destroyed || !this.#directReplyPrefetchRequired(postNumber) || (this.#queuedDirectReplyPrefetches.add(postNumber), this.#syncDirectReplyLoadingIndicators(), schedule && this.#scheduleDirectReplyPrefetchFlush());
}
#directReplyPrefetchRequired(postNumber) {
if (!this.#session.loadDirectReplies) return !1;
const expectedCount = Number(
this.#session.postByNumber(postNumber)?.reply_count ?? 0
);
return Number.isSafeInteger(expectedCount) && expectedCount > 0 && Math.max(
this.#directReplyPrefetchedExpectedCounts.get(postNumber) ?? 0,
this.#directReplyPrefetchAttemptedExpectedCounts.get(postNumber) ?? 0
) < expectedCount;
}
#scheduleDirectReplyPrefetchFlush(minimumDelayMs = 0) {
this.scope.destroyed || this.#queuedDirectReplyPrefetches.size === 0 || (this.#directReplyPrefetchHandle !== null && this.#directReplyPrefetchScheduler.cancel(
this.#directReplyPrefetchHandle
), this.#directReplyPrefetchHandle = this.#directReplyPrefetchScheduler.schedule(() => {
this.#directReplyPrefetchHandle = null, this.#flushDirectReplyPrefetch();
}, Math.max(0, minimumDelayMs)));
}
#flushDirectReplyPrefetch() {
if (this.scope.destroyed) return;
for (const candidate of this.#queuedDirectReplyPrefetches)
this.#directReplyPrefetchCandidatePostNumbers.has(candidate) || this.#queuedDirectReplyPrefetches.delete(candidate);
let visiblePostNumber = [...this.#queuedDirectReplyPrefetches].find(
(candidate) => this.#directReplyVisiblePostNumbers.has(candidate)
);
for (; visiblePostNumber !== void 0 && this.#directReplyPrefetches.size < DIRECT_REPLY_VISIBLE_PIPELINE_DEPTH; )
this.#startDirectReplyRequest(visiblePostNumber, "visible"), visiblePostNumber = [...this.#queuedDirectReplyPrefetches].find(
(candidate) => this.#directReplyVisiblePostNumbers.has(candidate)
);
if (visiblePostNumber !== void 0 || [...this.#directReplyPrefetches.values()].some(
(request) => request.lane === "visible"
)) {
this.#syncDirectReplyLoadingIndicators();
return;
}
const configuredConcurrency = Number(
this.#readDirectReplyPrefetchConcurrency()
), nearbyDepth = Math.min(
DIRECT_REPLY_NEARBY_PIPELINE_DEPTH,
Math.max(
1,
Number.isFinite(configuredConcurrency) ? Math.round(configuredConcurrency) : 1
)
);
let nearbyCount = [...this.#directReplyPrefetches.values()].filter(
(request) => request.lane === "nearby"
).length, postNumber = [...this.#queuedDirectReplyPrefetches].find(
(candidate) => this.#directReplyPrefetchCandidatePostNumbers.has(candidate)
);
for (; postNumber !== void 0 && nearbyCount < nearbyDepth; )
this.#startDirectReplyRequest(postNumber, "nearby"), nearbyCount += 1, postNumber = [...this.#queuedDirectReplyPrefetches].find(
(candidate) => this.#directReplyPrefetchCandidatePostNumbers.has(candidate)
);
nearbyCount === 0 && this.#syncDirectReplyLoadingIndicators();
}
#startDirectReplyRequest(postNumber, lane) {
this.#queuedDirectReplyPrefetches.delete(postNumber);
const loadDirectReplies = this.#session.loadDirectReplies, post = this.#session.postByNumber(postNumber), expectedCount = Number(post?.reply_count ?? 0);
if (!loadDirectReplies || !Number.isSafeInteger(expectedCount) || expectedCount <= 0) {
this.#syncDirectReplyLoadingIndicators(), this.#scheduleDirectReplyPrefetchFlush();
return;
}
const request = Object.freeze({
lane,
controller: new AbortController()
});
this.#directReplyPrefetches.set(postNumber, request), this.#syncDirectReplyLoadingIndicators(), loadDirectReplies.call(this.#session, postNumber, {
background: lane === "nearby",
expectedCount,
maxPages: DIRECT_REPLY_PREFETCH_MAX_PAGES,
signal: request.controller.signal
}).then((result) => {
if (this.scope.destroyed || this.#directReplyPrefetches.get(postNumber) !== request) return;
this.#directReplyPrefetches.delete(postNumber), this.#syncDirectReplyLoadingIndicators();
const observedExpectedCount = Math.max(
expectedCount,
result.expectedCount
);
this.#directReplyPrefetchAttemptedExpectedCounts.set(
postNumber,
observedExpectedCount
), result.complete && this.#directReplyPrefetchedExpectedCounts.set(
postNumber,
observedExpectedCount
), this.frame.notifyScroll();
}).catch((error) => {
!isAbortFailure(error) && !this.scope.destroyed && this.#directReplyPrefetches.get(postNumber) === request && (this.#directReplyPrefetchAttemptedExpectedCounts.set(
postNumber,
expectedCount
), this.#onError(error));
}).finally(() => {
this.#directReplyPrefetches.get(postNumber) === request && this.#directReplyPrefetches.delete(postNumber), this.#syncDirectReplyLoadingIndicators(), this.#scheduleDirectReplyPrefetchFlush();
});
}
#abortDirectReplyRequestsOutside(candidatePostNumbers, visiblePostNumbers) {
const visibleRequestPending = [...visiblePostNumbers].some(
(postNumber) => this.#directReplyPrefetchRequired(postNumber)
);
for (const [postNumber, request] of this.#directReplyPrefetches) {
if (visibleRequestPending) {
if (visiblePostNumbers.has(postNumber) && request.lane === "visible") continue;
} else if (candidatePostNumbers.has(postNumber))
continue;
this.#directReplyPrefetches.delete(postNumber), request.controller.signal.aborted || request.controller.abort(new DOMException(
visiblePostNumbers.has(postNumber) ? `树状回复 #${postNumber} 升级为可见快车道` : candidatePostNumbers.has(postNumber) ? `树状回复 #${postNumber} 为可见快车道让位` : `树状回复 #${postNumber} 已滚出当前视口`,
"AbortError"
));
}
}
#syncDirectReplyLoadingIndicators() {
for (const view of this.domOwner.views()) {
const active = this.#directReplyPrefetches.has(view.postNumber), queued = this.#queuedDirectReplyPrefetches.has(view.postNumber), existing = Array.from(view.slots.replyControls.children).find(
(child) => child.classList.contains("ldp-direct-reply-loading")
);
if (!active && !queued) {
existing?.remove(), view.slots.replyTree.removeAttribute("aria-busy");
continue;
}
const indicator = existing ?? view.slots.root.ownerDocument.createElement("div");
existing || (indicator.className = "ldp-direct-reply-loading", indicator.setAttribute("role", "status"), indicator.setAttribute("aria-live", "polite"), view.slots.replyControls.append(indicator)), indicator.dataset.state = active ? "loading" : "queued", indicator.textContent = active ? "正在优先加载此处树状回复…" : "此处还有树状回复,等待加载…", view.slots.replyTree.setAttribute("aria-busy", "true");
}
}
#activateBranch(root, postNumber) {
this.#activeBranchPostNumbers.has(postNumber) || root.classList.contains("ldp-post-projection-pending") || (this.#activeBranchPostNumbers.add(postNumber), this.postProjector.attach(root, postNumber, "branch"));
}
#deactivateBranch(root, postNumber) {
this.#activeBranchPostNumbers.delete(postNumber) && this.postProjector.detach(root, postNumber, "branch");
}
#scheduleBranchPaint() {
this.scope.destroyed || (this.#branchPaintGeneration += 1, this.streamView.slots.rootList.classList.add(
"ldp-branch-paint-pending"
), this.#branchPaintHandle === null && this.#queueBranchPaintStabilityCheck());
}
#queueBranchPaintStabilityCheck() {
this.#branchPaintHandle = this.#branchFrames.request(() => {
if (this.#branchPaintHandle = null, this.scope.destroyed) return;
const generation = this.#branchPaintGeneration;
this.branchOverlay.paint(), this.streamView.slots.rootList.classList.remove(
"ldp-branch-paint-pending"
), generation !== this.#branchPaintGeneration && (this.streamView.slots.rootList.classList.add(
"ldp-branch-paint-pending"
), this.#queueBranchPaintStabilityCheck());
});
}
#syncProjectionFeatures() {
this.postProjector.syncProjection();
}
#syncRootObservers(attachedRoots, detachedRoots) {
for (const postNumber of detachedRoots) {
const root = this.domOwner.view(postNumber)?.slots.root;
root && this.#deactivateBranch(root, postNumber), this.#rootObservers.get(postNumber)?.(), this.#rootObservers.delete(postNumber);
}
for (const postNumber of attachedRoots) {
if (this.#rootObservers.has(postNumber)) continue;
const root = this.domOwner.view(postNumber)?.slots.root;
root && (this.#rootObservers.set(postNumber, this.frame.observeRoot(root)), this.#activateBranch(root, postNumber));
}
}
#assertActive() {
if (this.#destroyed || this.scope.destroyed)
throw new Error("ReaderTopicDomCoordinator 已销毁");
}
}
}, "748c902018c027b6898ae49986ef8f5f82dd3b68308d1a71c1415ffff6635aac");
/* Source: lite/src/topic/reader-topic-edit-controller.ts */
runtime.register("src/topic/reader-topic-edit-controller.js", function(module, exports, require) {
var reader_topic_edit_controller_exports = {};
__export(reader_topic_edit_controller_exports, {
ReaderTopicEditController: () => ReaderTopicEditController
});
module.exports = __toCommonJS(reader_topic_edit_controller_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_value_record = require("../kernel/value-record.js"), import_topic_action_feature_commands = require("../post/topic-action-feature-commands.js");
function text(value) {
return String(value ?? "").trim();
}
function topicCanEdit(value) {
return (0, import_value_record.valueRecord)((0, import_value_record.valueRecord)(value)?.details)?.can_edit === !0;
}
function categoryName(value) {
return text(value).replace(/\s*[,,]\s*Lv\s*\d+\s*$/i, "").trim();
}
function categoryLevel(value) {
const match = text(value).match(/(?:^|[,,]\s*)Lv\s*(\d+)\s*$/i);
return match?.[1] ? `Lv${match[1]}` : "";
}
function topicTags(value) {
const tags = (0, import_value_record.valueRecord)(value)?.tags;
if (!Array.isArray(tags)) return Object.freeze([]);
const byName = /* @__PURE__ */ new Map();
for (const value2 of tags) {
const source = (0, import_value_record.valueRecord)(value2), name = text(source?.name ?? source?.text ?? value2);
if (!name) continue;
const id = Number(source?.id);
byName.set(name.toLocaleLowerCase(), Object.freeze({
id: Number.isSafeInteger(id) && id > 0 ? id : null,
name
}));
}
return Object.freeze([...byName.values()]);
}
class ReaderTopicEditController {
topicId;
scope;
#session;
#trigger;
#form;
#catalog;
#actions;
#commands;
#descriptors;
#models;
#onError;
#notify;
#abort = new AbortController();
#opening = null;
constructor(options) {
this.topicId = (0, import_identifiers.discourseTopicId)(options.topicId), this.#session = options.session, this.#trigger = options.trigger, this.#form = options.form, this.#catalog = options.catalog, this.#actions = options.actions, this.#commands = new import_topic_action_feature_commands.TopicActionFeatureCommands({
topicId: this.topicId,
session: this.#session
}), this.#descriptors = options.descriptors, this.#models = options.models, this.#onError = options.onError ?? (() => {
}), this.#notify = options.notify ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => {
this.#abort.abort(new Error("Reader Topic 编辑生命周期已结束")), this.#trigger.hidden = !0, this.#trigger.setAttribute("aria-expanded", "false");
}), this.scope.listen(this.#trigger, "click", () => {
this.open().catch((error) => this.#report(error));
}), this.#session.changes.subscribe(() => this.sync(), this.scope), this.sync();
}
sync() {
const canEdit = topicCanEdit(this.#session.topic);
this.#trigger.hidden = !canEdit, this.#trigger.disabled = !canEdit, canEdit || this.#trigger.setAttribute("aria-expanded", "false");
}
open() {
if (this.#opening) return this.#opening;
const topic = this.#topic();
if (!topicCanEdit(topic))
return Promise.reject(new Error("当前账号没有编辑该帖子的权限"));
const categories = [...this.#catalog.categories()], categoryId = Number(topic.category_id ?? topic.categoryId);
Number.isSafeInteger(categoryId) && categoryId > 0 && !categories.some((category) => category.id === categoryId) && categories.unshift(Object.freeze({
id: categoryId,
name: categoryName(
topic.category_name ?? topic.categoryName ?? "当前类别"
) || "当前类别",
slug: text(topic.category_slug ?? topic.categorySlug),
color: "",
parentCategoryId: null
})), this.#trigger.setAttribute("aria-expanded", "true");
const opening = this.#form.open({
title: text(topic.title ?? topic.fancy_title),
categoryId: Number.isSafeInteger(categoryId) && categoryId > 0 ? categoryId : 0,
tags: topicTags(topic),
categories: Object.freeze(categories),
signal: this.#abort.signal,
searchTags: (input) => this.#catalog.searchTags(input),
submit: (submission) => this.#submit(submission)
}).finally(() => {
this.#opening === opening && (this.#opening = null), this.#trigger.setAttribute("aria-expanded", "false");
});
return this.#opening = opening, opening;
}
destroy() {
this.scope.destroy();
}
async #submit(submission) {
const topic = this.#topic();
if (!topicCanEdit(topic))
throw new Error("当前账号没有编辑该帖子的权限");
const nativeFields = Object.freeze({
title: submission.title,
category_id: submission.category.id,
tags: Object.freeze(submission.tags.map((tag) => Object.freeze({
name: tag.name,
...tag.id ? { id: tag.id } : {}
})))
}), canonicalFields = Object.freeze({
title: submission.title,
category_id: submission.category.id,
category_name: categoryName(submission.category.name),
category_level: categoryLevel(submission.category.name),
category_slug: submission.category.slug,
tags: Object.freeze(submission.tags.map((tag) => tag.name))
});
await this.#actions.dispatch(this.#commands.edit(
canonicalFields,
this.#descriptors.topicEdit({
topicId: this.topicId,
topic: this.#models.createTopic(topic),
changedFields: nativeFields
})
)), this.#notify("帖子信息已更新");
}
#topic() {
const topic = this.#session.topic;
if (!topic) throw new Error("canonical Topic 尚未加载");
if ((0, import_identifiers.discourseTopicId)(topic.id) !== this.topicId)
throw new Error("Topic 编辑目标与当前会话不一致");
return topic;
}
#report(error) {
try {
this.#onError(error);
} catch {
}
}
}
}, "f4a30971ecaa7f26c1382c1cc09a7d2ed29c1e375c2dc45e58bfcda8ea5d5547");
/* Source: lite/src/topic/reader-topic-factory.ts */
runtime.register("src/topic/reader-topic-factory.js", function(module, exports, require) {
var reader_topic_factory_exports = {};
__export(reader_topic_factory_exports, {
createReaderTopicFactory: () => createReaderTopicFactory
});
module.exports = __toCommonJS(reader_topic_factory_exports);
var import_reader_topic_dom_coordinator = require("./reader-topic-dom-coordinator.js");
function throwIfAborted(signal) {
if (signal.aborted) throw signal.reason;
}
function createTopicLifetimeContext(context) {
const controller = context.scope.abortController(
new DOMException(
`Topic ${context.topicId} 生命周期已结束`,
"AbortError"
),
context.signal
);
return Object.freeze({
...context,
signal: controller.signal
});
}
function createReaderTopicFactory(options) {
return async (context) => {
throwIfAborted(context.signal);
const topicContext = createTopicLifetimeContext(context);
options.onPhase?.("prepare", topicContext);
const bundle = await options.createBundle(topicContext);
bundle.cleanup && topicContext.scope.add(bundle.cleanup), throwIfAborted(topicContext.signal);
const root = options.createRoot?.(topicContext, options.document) ?? options.document.createElement("section");
if (root.isConnected || root.parentNode)
throw new Error("Topic root 在 mount 前必须是 detached");
root.classList.contains("ldp-topic-runtime") || root.classList.add("ldp-topic-runtime"), root.dataset.topicId = String(topicContext.topicId), topicContext.mount(root);
const dom = new import_reader_topic_dom_coordinator.ReaderTopicDomCoordinator({
...options.createDomOptions(bundle, topicContext, root),
document: options.document,
topicHost: root,
session: bundle.session,
replies: bundle.replies,
parentScope: topicContext.scope
}), topic = await dom.initialize();
throwIfAborted(topicContext.signal), options.onPhase?.("render", topicContext);
const value = Object.freeze({
topic,
root,
session: bundle.session,
replies: bundle.replies,
dom,
services: bundle.services
}), assembledCleanup = options.onAssembled?.(value, topicContext);
typeof assembledCleanup == "function" && topicContext.scope.add(assembledCleanup), throwIfAborted(topicContext.signal);
const activationCleanup = bundle.activate?.();
typeof activationCleanup == "function" && topicContext.scope.add(activationCleanup), throwIfAborted(topicContext.signal);
const readyCleanup = options.onReady?.(value, topicContext);
return typeof readyCleanup == "function" && topicContext.scope.add(readyCleanup), Object.freeze({
value,
...bundle.prepareClose ? { prepareClose: bundle.prepareClose } : {}
});
};
}
}, "6bfc20f304a3e8ed6007bc10d14ac7cf18298b671f8063a34860556fc2dbc103");
/* Source: lite/src/topic/reader-topic-flow-controller.ts */
runtime.register("src/topic/reader-topic-flow-controller.js", function(module, exports, require) {
var reader_topic_flow_controller_exports = {};
__export(reader_topic_flow_controller_exports, {
ReaderTopicFlowController: () => ReaderTopicFlowController
});
module.exports = __toCommonJS(reader_topic_flow_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
const browserScheduler = Object.freeze({
schedule(callback, urgency, delayMs = urgency === "near-window" ? 0 : 600) {
return window.setTimeout(callback, delayMs);
},
cancel(handle) {
window.clearTimeout(handle);
}
}), BACKGROUND_PREFETCH_DELAY_MS = 360, GAP_TARGET_SETTLE_MS = 180;
class ReaderTopicFlowController {
scope;
#dom;
#readPerformance;
#scheduler;
#onError;
#readLoadDone;
#scheduledHandle = null;
#scheduledUrgency = null;
#running = !1;
#rerun = !1;
#done = !1;
#projectionPriority = !1;
#lastUserDrivenLoadAt = 0;
#aroundSettleHandle = null;
#aroundSettleKey = "";
#settledAroundKey = "";
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#dom = options.dom, this.#readPerformance = options.readPerformance, this.#scheduler = options.scheduler ?? browserScheduler, this.#onError = options.onError ?? (() => {
}), this.#readLoadDone = options.readLoadDone ?? (() => {
}), this.#done = this.#readLoadDone() === !0, this.#syncStatus(!1), this.#dom.windowChanges.subscribe(() => {
this.#scheduleCurrentWork();
}, this.scope), options.sessionChanges?.subscribe((commit) => {
commit.streamChanged && (this.#readLoadDone() !== !0 && (this.#done = !1), this.#syncStatus(!1), this.#scheduleCurrentWork());
}, this.scope), this.scope.add(() => {
this.#cancelAroundSettle(), this.#scheduledHandle !== null && this.#scheduler.cancel(this.#scheduledHandle), this.#scheduledHandle = null, this.#scheduledUrgency = null;
}), this.#scheduleCurrentWork();
}
refreshPerformance() {
this.scope.destroyed || (this.#dom.flushNow(), this.#hasWork() && this.#queue(this.#urgency(), !0));
}
/**
* 需要完整 Topic 投影的功能(目前为“只看楼主”)只提升 canonical Flow,
* 不创建第二个 cursor、帖子缓存或请求循环。
*/
setProjectionPriority(enabled) {
this.scope.destroyed || this.#projectionPriority === enabled || (this.#projectionPriority = enabled, this.#dom.flushNow(), this.#hasWork() && this.#queue(this.#urgency(), !0));
}
destroy() {
this.scope.destroy();
}
#rangeHydrationPlan(allowUnsettledAround = !1) {
if (!this.#dom.hydrateUnloadedRange) return null;
const commit = this.#dom.frame.lastCommit;
if (!commit) return null;
const { window: window2 } = commit, userScrollAt = this.#userScrollAt();
if (userScrollAt <= this.#lastUserDrivenLoadAt) return null;
const visibleDataGapPostNumber = this.#dom.visibleDataGapPostNumber?.();
if (visibleDataGapPostNumber !== void 0)
return Object.freeze({
distance: 0,
request: Object.freeze({
direction: "around",
postNumber: visibleDataGapPostNumber
}),
userScrollAt
});
const gapTargetPostNumber = window2.unloadedGapTargetPostNumber;
if (gapTargetPostNumber !== void 0) {
const key = `${userScrollAt}|${gapTargetPostNumber}`, plan = Object.freeze({
distance: 0,
request: Object.freeze({
direction: "around",
postNumber: gapTargetPostNumber
}),
requiresSettle: !0,
userScrollAt
});
return allowUnsettledAround || this.#settledAroundKey === key ? plan : null;
}
const input = this.#dom.readWindowInput(), performance = this.#readPerformance(), scrollDirection = this.#dom.lastUserScrollDirection?.() ?? 0, beforeHorizon = input.viewportSize * Math.max(
performance.nestedPrefetchScreens,
Number(input.overscanBeforeScreens) || 0
), afterHorizon = input.viewportSize * Math.max(
performance.nestedPrefetchScreens,
Number(input.overscanAfterScreens) || 0
), candidates = [], beforeAnchor = window2.unloadedGapBeforeAnchorPostNumber ?? window2.segmentStartPostNumber, beforeDistance = window2.distanceToSegmentStart ?? Number.POSITIVE_INFINITY;
window2.hasUnloadedGapBefore === !0 && beforeAnchor !== void 0 && beforeDistance <= beforeHorizon && candidates.push({
distance: beforeDistance,
request: Object.freeze({
direction: "before",
postNumber: beforeAnchor
}),
userScrollAt
});
const afterAnchor = window2.unloadedGapAfterAnchorPostNumber ?? window2.segmentEndPostNumber, afterDistance = window2.distanceToSegmentEnd ?? Number.POSITIVE_INFINITY;
return window2.hasUnloadedGapAfter === !0 && afterAnchor !== void 0 && afterDistance <= afterHorizon && candidates.push({
distance: afterDistance,
request: Object.freeze({
direction: "after",
postNumber: afterAnchor
}),
userScrollAt
}), candidates.filter(
(candidate) => scrollDirection === 0 || scrollDirection < 0 && candidate.request.direction === "before" || scrollDirection > 0 && candidate.request.direction === "after"
).sort((left, right) => left.distance - right.distance)[0] ?? null;
}
#sequentialLoadAllowed() {
if (this.#done || this.#dom.visibleDataGapPostNumber?.() !== void 0) return !1;
const window2 = this.#dom.frame.lastCommit?.window;
return window2?.unloadedGapTargetPostNumber !== void 0 || window2?.hasUnloadedGapBefore === !0 ? !1 : this.#projectionPriority ? !0 : this.#userScrollAt() > this.#lastUserDrivenLoadAt;
}
#userScrollAt() {
return Math.max(0, Number(this.#dom.lastUserScrollAt?.()) || 0);
}
#consumeUserScrollIntent(observedAt = this.#userScrollAt()) {
this.#lastUserDrivenLoadAt = Math.max(
this.#lastUserDrivenLoadAt,
observedAt
);
}
#scheduleCurrentWork() {
if (this.scope.destroyed) return;
const rawRangePlan = this.#rangeHydrationPlan(!0);
if (rawRangePlan?.requiresSettle === !0 && this.#settledAroundKey !== `${rawRangePlan.userScrollAt}|${rawRangePlan.request.postNumber}`) {
this.#scheduleAroundSettle(rawRangePlan);
return;
}
this.#cancelAroundSettle(), this.#hasWork() && this.#queue(this.#urgency());
}
#scheduleAroundSettle(plan) {
const key = `${plan.userScrollAt}|${plan.request.postNumber}`;
this.#aroundSettleHandle !== null && key === this.#aroundSettleKey || (this.#cancelAroundSettle(), this.#scheduledHandle !== null && (this.#scheduler.cancel(this.#scheduledHandle), this.#scheduledHandle = null, this.#scheduledUrgency = null), this.#aroundSettleKey = key, this.#aroundSettleHandle = this.#scheduler.schedule(() => {
this.#aroundSettleHandle = null;
const current = this.#rangeHydrationPlan(!0), currentKey = current?.requiresSettle === !0 ? `${current.userScrollAt}|${current.request.postNumber}` : "";
if (!current || currentKey !== this.#aroundSettleKey) {
this.#aroundSettleKey = "", this.#scheduleCurrentWork();
return;
}
this.#aroundSettleKey = "", this.#settledAroundKey = currentKey, this.#hasWork() && this.#queue("near-window", !0);
}, "near-window", GAP_TARGET_SETTLE_MS));
}
#cancelAroundSettle() {
this.#aroundSettleHandle !== null && this.#scheduler.cancel(this.#aroundSettleHandle), this.#aroundSettleHandle = null, this.#aroundSettleKey = "";
}
#hasWork() {
return this.#rangeHydrationPlan() !== null || this.#sequentialLoadAllowed();
}
#urgency() {
if (this.#rangeHydrationPlan() || this.#projectionPriority || this.#dom.hasVisibleDataGap?.() === !0) return "near-window";
const commit = this.#dom.frame.lastCommit;
if (!commit) return "near-window";
if (commit.window.unloadedGapTargetPostNumber !== void 0 || commit.window.hasUnloadedGapBefore === !0) return "background";
const input = this.#dom.readWindowInput(), forwardScreens = Math.max(
this.#readPerformance().nestedPrefetchScreens,
Number(input.overscanAfterScreens) || 0
), horizon = input.viewportSize * forwardScreens;
return (commit.window.afterSegmentSpacer ?? commit.window.afterSpacer) <= horizon ? "near-window" : "background";
}
#queue(urgency, replace = !1, delayOverrideMs) {
if (this.scope.destroyed || !this.#hasWork()) return;
if (this.#running) {
this.#rerun = !0;
return;
}
if (this.#scheduledHandle !== null) {
const promote = this.#scheduledUrgency === "background" && urgency === "near-window";
if (!replace && !promote) return;
this.#scheduler.cancel(this.#scheduledHandle);
}
this.#scheduledUrgency = urgency;
const delayMs = delayOverrideMs ?? (urgency === "near-window" ? 0 : Math.max(
BACKGROUND_PREFETCH_DELAY_MS,
this.#readPerformance().requestMinIntervalMs * 3
));
this.#scheduledHandle = this.#scheduler.schedule(() => {
this.#scheduledHandle = null, this.#scheduledUrgency = null, this.#run();
}, urgency, delayMs);
}
async #run() {
if (this.scope.destroyed || this.#running || !this.#hasWork()) return;
this.#running = !0, this.#rerun = !1;
const rangePlan = this.#rangeHydrationPlan(), urgency = rangePlan ? "near-window" : this.#urgency();
let activeRange = !1, loadingPublished = !1, failed = !1, sequentialUserScrollAt = null;
try {
if (rangePlan && this.#dom.hydrateUnloadedRange) {
if (activeRange = !0, this.#consumeUserScrollIntent(rangePlan.userScrollAt), await this.#dom.hydrateUnloadedRange(rangePlan.request, {
background: !1,
priority: "visible",
maxAttempts: 1
}), this.scope.destroyed) return;
this.#dom.flushNow();
return;
}
if (!this.#sequentialLoadAllowed())
return;
this.#projectionPriority || (sequentialUserScrollAt = this.#userScrollAt(), this.#consumeUserScrollIntent(sequentialUserScrollAt)), loadingPublished = urgency === "near-window", loadingPublished && this.#syncStatus(!0);
const visibleDataGap = this.#dom.hasVisibleDataGap?.() === !0;
let source = null;
const load = this.#dom.loadNext({
background: urgency === "background",
priority: visibleDataGap ? "nested" : "visible",
maxAttempts: 1,
onSource(nextSource) {
source = nextSource;
}
}), aheadBatchCount = urgency === "near-window" ? Math.min(
2,
Math.max(0, this.#readPerformance().requestMaxConcurrent - 1)
) : 0;
source !== null && aheadBatchCount > 0 && this.#dom.prefetchAhead && this.#dom.prefetchAhead(aheadBatchCount).catch(() => {
});
const result = await load;
if (this.scope.destroyed) return;
if (this.#done = this.#readLoadDone() ?? result.done, this.#dom.flushNow(), loadingPublished && this.#syncStatus(!1), result.fatal) {
failed = !0;
return;
}
if (result.retry) {
failed = !0;
return;
}
if (!this.#done) {
const nextUrgency = this.#urgency();
nextUrgency === "near-window" && this.#queue(nextUrgency);
}
} catch (error) {
this.scope.destroyed || (failed = !0, activeRange ? this.#consumeUserScrollIntent() : sequentialUserScrollAt !== null && this.#consumeUserScrollIntent(), !activeRange && loadingPublished && this.#syncStatus(!1), this.#onError(error));
} finally {
if (this.#running = !1, !failed && this.#rerun && !this.scope.destroyed && this.#hasWork()) {
const nextUrgency = this.#urgency();
nextUrgency === "near-window" && this.#queue(nextUrgency, !0);
}
}
}
#syncStatus(loading) {
this.#dom.setFlowStatus?.(Object.freeze({
loading,
done: !loading && this.#done
}));
}
}
}, "b7e12fd69dbaf85e3408e260697e3b25c10dfb8fb685bd3e8eac31144ba2a376");
/* Source: lite/src/topic/reader-topic-header.ts */
runtime.register("src/topic/reader-topic-header.js", function(module, exports, require) {
var reader_topic_header_exports = {};
__export(reader_topic_header_exports, {
ReaderTopicHeaderController: () => ReaderTopicHeaderController,
ReaderTopicHeaderView: () => ReaderTopicHeaderView,
clearReaderTopicHostIdentityCache: () => clearReaderTopicHostIdentityCache,
normalizeReaderTopicHeader: () => normalizeReaderTopicHeader,
readReaderTopicHostIconMetadata: () => readReaderTopicHostIconMetadata,
readerTopicHostIdentityCacheStats: () => readerTopicHostIdentityCacheStats,
readerTopicOwnerUsername: () => readerTopicOwnerUsername
});
module.exports = __toCommonJS(reader_topic_header_exports);
var import_reader_icon = require("../components/reader-icon.js"), import_reader_inline_emoji = require("../components/reader-inline-emoji.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js"), import_value_record = require("../kernel/value-record.js");
const EMPTY_RECORD = Object.freeze({});
function text(value) {
return String(value ?? "").trim();
}
function count(value) {
if (value == null || value === "") return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : null;
}
function categoryLevel(value) {
const match = text(value).match(/(?:^|[,,]\s*)Lv\s*(\d+)\s*$/i);
return match?.[1] ? `Lv${match[1]}` : "";
}
function categoryName(value) {
return text(value).replace(/\s*[,,]\s*Lv\s*\d+\s*$/i, "").trim();
}
function safeIconName(value) {
const normalized = text(value).toLowerCase().replace(/^fa-/, "");
return normalized.length <= 80 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalized) ? normalized : "";
}
function topicTags(topic) {
if (!Array.isArray(topic.tags)) return Object.freeze([]);
const tags = topic.tags.map((value) => {
const source = (0, import_value_record.valueRecord)(value);
return Object.freeze({
name: text(source?.name ?? source?.id ?? value),
icon: safeIconName(
source?.icon ?? source?.icon_name ?? source?.iconName
)
});
}).filter((tag) => !!tag.name), byName = /* @__PURE__ */ new Map();
for (const tag of tags) {
const previous = byName.get(tag.name);
(!previous || !previous.icon && tag.icon) && byName.set(tag.name, tag);
}
return Object.freeze([...byName.values()]);
}
function readerTopicOwnerUsername(topicValue, posts = []) {
const topic = (0, import_value_record.valueRecord)(topicValue) ?? EMPTY_RECORD, details = (0, import_value_record.valueRecord)(topic.details), createdBy = (0, import_value_record.valueRecord)(details?.created_by), firstPost = posts.map((post) => (0, import_value_record.valueRecord)(post)).find((post) => Number(post?.post_number) === 1);
return text(
topic._opUsername || createdBy?.username || firstPost?.username || topic.original_poster_username
);
}
function normalizeReaderTopicHeader(topicValue, posts, presentation, fallbackTopicId = 0) {
const topic = (0, import_value_record.valueRecord)(topicValue) ?? EMPTY_RECORD, category = (0, import_value_record.valueRecord)(topic.category) ?? EMPTY_RECORD, owner = readerTopicOwnerUsername(topic, posts), categoryId = count(
topic.category_id ?? topic.categoryId ?? category.id
) ?? 0, rawCategoryName = topic.category_name ?? topic.categoryName ?? category.name ?? topic.category_slug ?? category.slug ?? presentation.categoryName?.(categoryId), normalizedCategoryName = categoryName(rawCategoryName), level = [
topic.category_level,
topic.categoryLevel,
rawCategoryName
].map(categoryLevel).find(Boolean) ?? "", tags = topicTags(topic).map((tag) => Object.freeze({
name: tag.name,
icon: tag.icon,
href: presentation.tagHref(tag.name)
})), voteCount = count(topic.vote_count) ?? 0, voted = topic.user_voted === !0, canVote = topic.can_vote === !0 || voted, hasVoteCapability = ["can_vote", "user_voted", "vote_count"].some((key) => Object.hasOwn(topic, key)), stats = [], postsCount = count(topic.posts_count), views = count(topic.views), likes = count(topic.like_count), participants = count(topic.participant_count);
return postsCount !== null && stats.push(`${postsCount} 帖`), views !== null && stats.push(`${views} 浏览`), likes !== null && stats.push(`${likes} 赞`), participants !== null && stats.push(`${participants} 用户`), Object.freeze({
topicId: count(topic.id) ?? count(fallbackTopicId) ?? 0,
categoryId,
title: text(topic.title ?? topic.fancy_title) || "未命名主题",
ownerUsername: owner,
ownerHref: presentation.userHref(owner),
statsText: stats.join(" · ") || "主题信息暂不可用",
category: normalizedCategoryName ? Object.freeze({
id: categoryId,
name: normalizedCategoryName,
level,
icon: safeIconName(
topic.category_icon ?? topic.categoryIcon ?? category.icon ?? category.icon_name ?? category.iconName ?? presentation.categoryIcon?.(categoryId)
),
href: presentation.categoryHref(categoryId)
}) : null,
tags: Object.freeze(tags),
vote: hasVoteCapability && (canVote || voteCount > 0) ? Object.freeze({ count: voteCount, voted, canVote }) : null
});
}
class ReaderTopicHeaderController {
scope;
changes = new import_signal.Signal();
#session;
#presentation;
#topicId;
#onError;
#snapshot;
constructor(options) {
this.#session = options.session, this.#presentation = options.presentation, this.#topicId = count(options.session.topicId) ?? 0, this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#snapshot = normalizeReaderTopicHeader(
this.#session.topic,
this.#session.cachedPosts(),
this.#presentation,
this.#topicId
), this.#session.changes.subscribe(() => this.refresh(), this.scope), this.scope.add(() => this.changes.clear());
}
get snapshot() {
return this.#snapshot;
}
emojiSource(id) {
return this.#presentation.emojiSource?.(id) ?? "";
}
refresh() {
const next = normalizeReaderTopicHeader(
this.#session.topic,
this.#session.cachedPosts(),
this.#presentation,
this.#topicId
);
if (JSON.stringify(next) === JSON.stringify(this.#snapshot))
return this.#snapshot;
this.#snapshot = next;
for (const error of this.changes.emit(next)) this.#onError(error);
return next;
}
destroy() {
this.scope.destroy();
}
}
function appendLinkedIdentity(document, parent, className, label, href, icon, renderIcon, hostIcon = null) {
const node = document.createElement(href ? "a" : "span");
node.className = className, node.tagName === "A" && href && node.setAttribute("href", href);
const fallbackIcon = className.includes("ldp-topic-category") ? "code" : "tag", hostIconName = safeIconName(
hostIcon?.querySelector("use")?.getAttribute("href")?.replace(/^#/, "")
), requestedIcon = icon || hostIconName;
if (requestedIcon) {
const rendered = (0, import_reader_icon.renderReaderIcon)(document, requestedIcon, renderIcon), renderedElement = rendered.nodeType === 1 ? rendered : null, unresolved = renderedElement?.matches(
"[data-reader-icon-fallback-for]"
) || renderedElement?.querySelector(
"[data-reader-icon-fallback-for]"
);
node.append(
unresolved ? (0, import_reader_icon.createReaderIcon)(document, fallbackIcon) : rendered
);
} else hostIcon && hostIcon.tagName.toLowerCase() === "img" ? node.append(hostIcon) : node.append((0, import_reader_icon.createReaderIcon)(document, fallbackIcon));
const textNode = document.createElement("span");
return textNode.className = "ldp-topic-tag-text", textNode.textContent = label, node.append(textNode), parent.append(node), node;
}
const SAFE_ICON_FRAGMENT = /^#[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/, HOST_IDENTITY_CACHE = /* @__PURE__ */ new WeakMap(), HOST_IDENTITY_INDEX_LIMIT = 128;
function readerTopicHostIdentityCacheStats(document) {
const cache = HOST_IDENTITY_CACHE.get(document);
return Object.freeze({
categoryEntries: cache ? (/* @__PURE__ */ new Set([
...cache.categoryIcons.keys(),
...cache.categoryHrefs.keys()
])).size : 0,
tagEntries: cache ? (/* @__PURE__ */ new Set([
...cache.tagIcons.keys(),
...cache.tagHrefs.keys()
])).size : 0,
indexLimit: HOST_IDENTITY_INDEX_LIMIT
});
}
function clearReaderTopicHostIdentityCache(document) {
HOST_IDENTITY_CACHE.delete(document);
}
function rememberHostIdentity(map, key, value) {
for (map.delete(key), map.set(key, value); map.size > HOST_IDENTITY_INDEX_LIMIT; ) {
const oldest = map.keys().next().value;
if (oldest === void 0) break;
map.delete(oldest);
}
}
function cachedHostIdentity(map, key) {
const value = map.get(key);
if (value !== void 0)
return map.delete(key), map.set(key, value), value;
}
function hostIdentityCache(document) {
let cache = HOST_IDENTITY_CACHE.get(document);
return cache || (cache = {
categoryIcons: /* @__PURE__ */ new Map(),
categoryHrefs: /* @__PURE__ */ new Map(),
tagIcons: /* @__PURE__ */ new Map(),
tagHrefs: /* @__PURE__ */ new Map()
}, HOST_IDENTITY_CACHE.set(document, cache)), cache;
}
function categoryCacheKeys(id, name) {
return Object.freeze([
...id > 0 ? [`id:${id}`] : [],
...name ? [`name:${name}`] : []
]);
}
function cachedCategoryValue(values, id, name) {
for (const key of categoryCacheKeys(id, name)) {
const value = cachedHostIdentity(values, key);
if (value !== void 0) return value;
}
return null;
}
function cloneHostIdentityIcon(link) {
if (!link) return null;
const source = [...link.querySelectorAll("svg,img")].find((candidate) => candidate.closest("a") === link);
if (!source) return null;
const clone = source.cloneNode(!0);
clone.querySelectorAll("script,foreignObject").forEach((node) => node.remove());
for (const node of [clone, ...clone.querySelectorAll("*")])
for (const attribute of [...node.attributes])
/^on/i.test(attribute.name) && node.removeAttribute(attribute.name);
if (clone.tagName.toLowerCase() === "svg")
for (const use of clone.querySelectorAll("use")) {
const href = use.getAttribute("href") ?? use.getAttribute("xlink:href") ?? "";
if (!SAFE_ICON_FRAGMENT.test(href)) return null;
}
else {
const sourceUrl = clone.getAttribute("src") ?? "";
if (/^\s*javascript:/i.test(sourceUrl)) return null;
clone.removeAttribute("srcset"), clone.setAttribute("alt", "");
}
return clone.setAttribute("aria-hidden", "true"), clone;
}
function hostTopicRouteId(document) {
const pathname = document.defaultView?.location?.pathname ?? "";
return Number(
pathname.match(/\/t\/(?:[^/]+\/)?(\d+)(?:\/|$)/)?.[1] ?? 0
);
}
const HOST_TOPIC_SOURCE_ROOT_SELECTOR = "tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result,.category-topic-link";
function hostSourceTopicId(root) {
const direct = Number(
root.getAttribute("data-topic-id") ?? root.dataset.topicId ?? 0
);
if (Number.isSafeInteger(direct) && direct > 0) return direct;
const href = root.querySelector('a[href*="/t/"]')?.getAttribute("href") ?? "";
return Number(
href.match(/\/t\/(?:[^/]+\/)?(\d+)(?:\/|$)/)?.[1] ?? 0
);
}
function readReaderTopicHostIconMetadata(document, snapshot) {
const cache = hostIdentityCache(document), tagIcons = /* @__PURE__ */ new Map(), tagHrefs = /* @__PURE__ */ new Map(), empty = () => Object.freeze({
categoryIcon: null,
categoryId: 0,
categoryName: "",
categoryLevel: "",
categoryHref: "",
tagIcons,
tagHrefs
}), routeTopicId = hostTopicRouteId(document);
let root = routeTopicId > 0 && (!snapshot.topicId || routeTopicId === snapshot.topicId) ? [...document.querySelectorAll(
"#topic-title,.topic-title,.title-wrapper"
)].find((candidate) => !candidate.closest(".ldp-overlay")) ?? null : null;
root || (root = [...document.querySelectorAll(
HOST_TOPIC_SOURCE_ROOT_SELECTOR
)].find(
(candidate) => !candidate.closest(".ldp-overlay") && hostSourceTopicId(candidate) === snapshot.topicId
) ?? null);
const categorySelector = 'a.badge-category__wrapper,a.badge-category,a.topic-category,a[href^="/c/"],a[href*="/c/"]', categoryLink = root?.querySelector(categorySelector) ?? (snapshot.categoryId > 0 ? [...document.querySelectorAll(categorySelector)].find((candidate) => {
const href = candidate.getAttribute("href") ?? "";
return (Number(
candidate.querySelector(
"[data-category-id]"
)?.dataset.categoryId
) || Number(
href.match(/\/c\/(?:[^/]+\/)*(\d+)(?:\/|$)/)?.[1] ?? 0
)) === snapshot.categoryId;
}) ?? null : null), tagNames = new Set(snapshot.tags.map((tag) => tag.name));
for (const link of (root ?? document).querySelectorAll(
"a.discourse-tag,.discourse-tags a,.topic-tags a"
)) {
const name = text(link.dataset.tagName ?? link.textContent);
if (!root && !tagNames.has(name)) continue;
const icon = cloneHostIdentityIcon(link), href = link.getAttribute("href") ?? "";
name && icon && tagIcons.set(name, icon), name && href && tagHrefs.set(name, href);
}
for (const [name, icon] of tagIcons)
rememberHostIdentity(
cache.tagIcons,
name,
icon.cloneNode(!0)
);
for (const [name, href] of tagHrefs)
rememberHostIdentity(cache.tagHrefs, name, href);
for (const tag of snapshot.tags) {
if (!tagIcons.has(tag.name)) {
const cachedIcon = cachedHostIdentity(cache.tagIcons, tag.name);
cachedIcon && tagIcons.set(tag.name, cachedIcon.cloneNode(!0));
}
if (!tagHrefs.has(tag.name)) {
const cachedHref = cachedHostIdentity(cache.tagHrefs, tag.name);
cachedHref && tagHrefs.set(tag.name, cachedHref);
}
}
const categoryText = text(
categoryLink?.dataset.categoryName ?? categoryLink?.getAttribute("title") ?? categoryLink?.textContent
), liveCategoryHref = categoryLink?.getAttribute("href") ?? "", hrefCategoryId = Number(
liveCategoryHref.match(/\/c\/(?:[^/]+\/)*(\d+)(?:\/|$)/)?.[1] ?? 0
), categoryId = Number(categoryLink?.dataset.categoryId) || hrefCategoryId || snapshot.categoryId, normalizedCategoryName = categoryName(categoryText) || snapshot.category?.name || "", liveCategoryIcon = cloneHostIdentityIcon(categoryLink);
for (const key of categoryCacheKeys(categoryId, normalizedCategoryName))
liveCategoryIcon && rememberHostIdentity(
cache.categoryIcons,
key,
liveCategoryIcon.cloneNode(!0)
), liveCategoryHref && rememberHostIdentity(cache.categoryHrefs, key, liveCategoryHref);
const cachedCategoryIcon = cachedCategoryValue(
cache.categoryIcons,
categoryId,
normalizedCategoryName
), categoryIcon = liveCategoryIcon ?? cachedCategoryIcon?.cloneNode(!0) ?? null, categoryHref = liveCategoryHref || cachedCategoryValue(
cache.categoryHrefs,
categoryId,
normalizedCategoryName
) || "";
return !root && !categoryLink && !categoryIcon && !categoryHref && !tagIcons.size && !tagHrefs.size ? empty() : Object.freeze({
categoryIcon,
categoryId,
categoryName: normalizedCategoryName,
categoryLevel: categoryLevel(categoryText),
categoryHref,
tagIcons,
tagHrefs
});
}
function hasCompleteHostIdentityIcons(metadata, snapshot) {
return (snapshot.category ? !!(snapshot.category.icon || metadata.categoryIcon) : snapshot.categoryId <= 0 || !!(metadata.categoryName && metadata.categoryIcon)) && snapshot.tags.every(
(tag) => !!(tag.icon || metadata.tagIcons.get(tag.name))
);
}
class ReaderTopicHeaderView {
scope;
#controller;
#elements;
#onJumpFirst;
#onlyOp;
#onToggleTopicVote;
#renderIcon;
#onError;
#hostDocument;
#topicScroller;
#topicTags;
#topicVote;
#requestFrame;
#cancelFrame;
#hostMetadataObserver = null;
#topicHintFrame = 0;
#hostMetadataFrame = 0;
#hostMetadataStopTimer = 0;
#hostMetadataRetryTimer = 0;
#hostMetadataRetryDelay = 80;
#topicVotePending = !1;
#titleEmojiPending = !1;
constructor(options) {
this.#controller = options.controller, this.#elements = options.elements, this.#onJumpFirst = options.onJumpFirst, this.#onlyOp = options.onlyOp ?? null, this.#onToggleTopicVote = options.onToggleTopicVote ?? null, this.#renderIcon = options.renderIcon ?? null, this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const document = this.#elements.topicIdentityHost.ownerDocument, rootHost = this.#elements.topicIdentityHost.getRootNode().host;
this.#hostDocument = options.hostDocument ?? rootHost?.ownerDocument ?? document;
const defaultView = this.#hostDocument.defaultView ?? document.defaultView;
this.#requestFrame = options.requestFrame ?? ((callback) => defaultView?.requestAnimationFrame ? defaultView.requestAnimationFrame(callback) : setTimeout(() => callback(Date.now()), 0)), this.#cancelFrame = options.cancelFrame ?? ((id) => {
if (defaultView?.cancelAnimationFrame) {
defaultView.cancelAnimationFrame(id);
return;
}
clearTimeout(id);
});
const leftHint = document.createElement("span");
leftHint.className = "ldp-title-topic-scroll-hint ldp-title-topic-scroll-hint-left", leftHint.setAttribute("aria-hidden", "true");
const scroller = document.createElement("div");
scroller.className = "ldp-title-topic-scroller", scroller.setAttribute("role", "group"), scroller.tabIndex = 0, scroller.setAttribute(
"aria-label",
"主题分类和标签,滚轮可横向浏览"
);
const tags = document.createElement("div");
tags.className = "ldp-topic-tags";
const vote = document.createElement("div");
vote.className = "ldp-topic-vote-slot", vote.hidden = !0, scroller.append(tags, vote);
const rightHint = document.createElement("span");
rightHint.className = "ldp-title-topic-scroll-hint ldp-title-topic-scroll-hint-right", rightHint.setAttribute("aria-hidden", "true"), this.#elements.topicIdentityHost.replaceChildren(
leftHint,
scroller,
rightHint
), this.#topicScroller = scroller, this.#topicTags = tags, this.#topicVote = vote, this.scope.listen(vote, "click", (event) => {
const button = event.target?.closest("[data-topic-vote]");
!button || button.disabled || (event.preventDefault(), this.#toggleTopicVote());
}), this.scope.listen(scroller, "scroll", () => this.#queueTopicHints(), {
passive: !0
}), this.scope.listen(
this.#elements.topicIdentityHost,
"wheel",
(event) => this.#onTopicWheel(event),
{ passive: !1 }
);
const NativeResizeObserver = defaultView?.ResizeObserver, resizeObserver = options.createResizeObserver?.(
() => this.#queueTopicHints()
) ?? (NativeResizeObserver ? new NativeResizeObserver(() => this.#queueTopicHints()) : null);
resizeObserver && (resizeObserver.observe(this.#elements.topicIdentityHost), resizeObserver.observe(scroller), resizeObserver.observe(tags), this.scope.add(() => resizeObserver.disconnect())), this.scope.add(() => {
this.#topicHintFrame && (this.#cancelFrame(this.#topicHintFrame), this.#topicHintFrame = 0), this.#stopHostMetadataHydration();
}), this.scope.listen(this.#elements.titleJump, "click", () => {
this.#jumpFirst();
}), this.scope.listen(this.#elements.titleJump, "keydown", (event) => {
const key = event.key;
key !== "Enter" && key !== " " || (event.preventDefault(), this.#jumpFirst());
}), this.scope.listen(this.#elements.onlyOpToggle, "click", () => {
try {
this.#onlyOp?.toggle();
} catch (error) {
this.#onError(error);
}
}), this.#controller.changes.subscribe(
(snapshot) => this.render(snapshot),
this.scope
), this.#onlyOp?.changes.subscribe(
(snapshot) => this.#renderOnlyOp(snapshot),
this.scope
), this.render(this.#controller.snapshot), this.#renderOnlyOp(this.#onlyOp?.snapshot ?? null), this.#startHostMetadataHydration(options.createMutationObserver);
}
render(snapshot) {
const document = this.#elements.titleJump.ownerDocument, hostIcons = readReaderTopicHostIconMetadata(
this.#hostDocument,
snapshot
);
this.#titleEmojiPending = (0, import_reader_inline_emoji.renderReaderInlineEmoji)(
this.#elements.titleJump,
snapshot.title,
(id) => this.#controller.emojiSource(id)
).unresolved > 0, this.#elements.metaStats.textContent = snapshot.statsText, this.#elements.metaOwner.hidden = !snapshot.ownerUsername, this.#elements.metaOwnerValue.textContent = snapshot.ownerUsername ? `@${snapshot.ownerUsername}` : "", snapshot.ownerUsername && snapshot.ownerHref ? (this.#elements.metaOwnerValue.href = snapshot.ownerHref, this.#elements.metaOwnerValue.dataset.userCard = snapshot.ownerUsername, this.#elements.metaOwnerValue.target = "_blank", this.#elements.metaOwnerValue.rel = "noopener") : (this.#elements.metaOwnerValue.removeAttribute("href"), this.#elements.metaOwnerValue.removeAttribute("data-user-card"), this.#elements.metaOwnerValue.removeAttribute("target"), this.#elements.metaOwnerValue.removeAttribute("rel")), this.#topicTags.replaceChildren();
const renderedCategory = snapshot.category ? Object.freeze({
...snapshot.category,
href: snapshot.category.href || hostIcons.categoryHref
}) : hostIcons.categoryName ? Object.freeze({
id: hostIcons.categoryId,
name: hostIcons.categoryName,
level: hostIcons.categoryLevel,
icon: "",
href: hostIcons.categoryHref
}) : null;
if (renderedCategory) {
const label = renderedCategory.level ? `${renderedCategory.name}, ${renderedCategory.level}` : renderedCategory.name;
appendLinkedIdentity(
document,
this.#topicTags,
"ldp-topic-tag ldp-topic-category",
label,
renderedCategory.href,
renderedCategory.icon,
this.#renderIcon,
hostIcons.categoryIcon
);
}
for (const tag of snapshot.tags)
appendLinkedIdentity(
document,
this.#topicTags,
"ldp-topic-tag ldp-topic-label",
tag.name,
tag.href || hostIcons.tagHrefs.get(tag.name) || "",
tag.icon,
this.#renderIcon,
hostIcons.tagIcons.get(tag.name) ?? null
);
this.#topicTags.hidden = this.#topicTags.childElementCount === 0, this.#renderTopicVote(snapshot.vote), this.#queueTopicHints(), !this.#titleEmojiPending && hasCompleteHostIdentityIcons(hostIcons, snapshot) && !(snapshot.categoryId > 0 && !snapshot.category && !hostIcons.categoryName) && this.#stopHostMetadataHydration();
}
destroy() {
this.scope.destroy();
}
#startHostMetadataHydration(createMutationObserver) {
const refreshed = this.#controller.refresh(), metadata = readReaderTopicHostIconMetadata(
this.#hostDocument,
refreshed
);
if (!this.#titleEmojiPending && !(refreshed.categoryId > 0 && !refreshed.category && !metadata.categoryName) && hasCompleteHostIdentityIcons(metadata, refreshed)) {
this.render(refreshed);
return;
}
const document = this.#hostDocument, root = document.querySelector("#main-outlet,#ember-app") ?? document.body ?? document.documentElement, NativeMutationObserver = document.defaultView?.MutationObserver;
if (!root || !createMutationObserver && !NativeMutationObserver) return;
const onMutation = (records) => {
records.some((record2) => {
const target = record2.target;
return typeof target.closest != "function" || !target.closest(".ldp-overlay");
}) && this.#queueHostMetadataSync();
};
this.#hostMetadataObserver = createMutationObserver?.(onMutation) ?? (NativeMutationObserver ? new NativeMutationObserver(onMutation) : null), this.#hostMetadataObserver?.observe(root, {
childList: !0,
subtree: !0
}), this.#hostMetadataRetryDelay = 80, this.#queueHostMetadataSync(), this.#hostMetadataStopTimer = document.defaultView?.setTimeout(
() => this.#stopHostMetadataHydration(),
6e4
) ?? 0;
}
#queueHostMetadataSync() {
this.#hostMetadataFrame || this.scope.destroyed || (this.#hostMetadataFrame = this.#requestFrame(() => {
if (this.#hostMetadataFrame = 0, this.scope.destroyed) return;
const previous = this.#controller.snapshot, next = this.#controller.refresh();
next === previous && this.render(next), this.#hostMetadataObserver && this.#scheduleHostMetadataRetry();
}));
}
#scheduleHostMetadataRetry() {
if (this.#hostMetadataRetryTimer || !this.#hostMetadataObserver) return;
const window = this.#hostDocument.defaultView;
if (!window) return;
const delay = this.#hostMetadataRetryDelay;
this.#hostMetadataRetryDelay = Math.min(delay * 2, 4e3), this.#hostMetadataRetryTimer = window.setTimeout(() => {
this.#hostMetadataRetryTimer = 0, this.#queueHostMetadataSync();
}, delay);
}
#stopHostMetadataHydration() {
this.#hostMetadataObserver?.disconnect(), this.#hostMetadataObserver = null, this.#hostMetadataFrame && (this.#cancelFrame(this.#hostMetadataFrame), this.#hostMetadataFrame = 0), this.#hostMetadataStopTimer && (this.#hostDocument.defaultView?.clearTimeout(
this.#hostMetadataStopTimer
), this.#hostMetadataStopTimer = 0), this.#hostMetadataRetryTimer && (this.#hostDocument.defaultView?.clearTimeout(
this.#hostMetadataRetryTimer
), this.#hostMetadataRetryTimer = 0);
}
async #jumpFirst() {
try {
await this.#onJumpFirst();
} catch (error) {
this.scope.destroyed || this.#onError(error);
}
}
async #toggleTopicVote() {
const vote = this.#controller.snapshot.vote;
if (!(!vote || !this.#onToggleTopicVote || this.#topicVotePending)) {
this.#topicVotePending = !0, this.#renderTopicVote(vote);
try {
await this.#onToggleTopicVote(vote.voted);
} catch (error) {
this.scope.destroyed || this.#onError(error);
} finally {
this.#topicVotePending = !1, this.scope.destroyed || this.#renderTopicVote(this.#controller.snapshot.vote);
}
}
}
#renderTopicVote(vote) {
if (!vote) {
this.#topicVote.replaceChildren(), this.#topicVote.hidden = !0;
return;
}
this.#topicVote.hidden = !1;
let button = this.#topicVote.querySelector(
":scope > .ldp-topic-vote"
);
if (!button) {
button = this.#topicVote.ownerDocument.createElement("button"), button.type = "button", button.className = "ldp-topic-vote", button.dataset.topicVote = "";
const countNode = this.#topicVote.ownerDocument.createElement("span");
button.append("▲ ", countNode, " 票"), this.#topicVote.append(button);
}
button.classList.toggle("on", vote.voted), button.setAttribute("aria-pressed", String(vote.voted)), button.setAttribute(
"aria-label",
`${vote.voted ? "取消主题投票" : "为主题投票"},当前 ${vote.count} 票`
), button.disabled = this.#topicVotePending || !vote.canVote && !vote.voted, button.toggleAttribute("aria-busy", this.#topicVotePending), button.querySelector("span").textContent = String(vote.count);
}
#queueTopicHints() {
this.scope.destroyed || this.#topicHintFrame || (this.#topicHintFrame = this.#requestFrame(() => {
if (this.#topicHintFrame = 0, this.scope.destroyed) return;
const row = this.#elements.topicIdentityHost, scroller = this.#topicScroller, hasOverflow = (!this.#topicTags.hidden || !this.#topicVote.hidden) && scroller.scrollWidth > row.clientWidth + 1, maxScrollLeft = Math.max(
0,
scroller.scrollWidth - scroller.clientWidth
);
row.classList.toggle("has-overflow", hasOverflow), row.classList.toggle(
"can-scroll-left",
hasOverflow && scroller.scrollLeft > 1
), row.classList.toggle(
"can-scroll-right",
hasOverflow && scroller.scrollLeft < maxScrollLeft - 1
);
}));
}
#onTopicWheel(event) {
const rawDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (!rawDelta) return;
const maxScrollLeft = Math.max(
0,
this.#topicScroller.scrollWidth - this.#topicScroller.clientWidth
);
if (maxScrollLeft <= 1) return;
const delta = event.deltaMode === 1 ? rawDelta * 40 : event.deltaMode === 2 ? rawDelta * this.#topicScroller.clientWidth : rawDelta, nextScrollLeft = Math.min(
maxScrollLeft,
Math.max(0, this.#topicScroller.scrollLeft + delta)
);
nextScrollLeft !== this.#topicScroller.scrollLeft && (event.preventDefault(), event.stopPropagation(), this.#topicScroller.scrollLeft = nextScrollLeft, this.#queueTopicHints());
}
#renderOnlyOp(snapshot) {
const enabled = snapshot?.enabled === !0, available = snapshot?.available === !0;
this.#elements.onlyOpToggle.disabled = !available, this.#elements.onlyOpToggle.classList.toggle("active", enabled), this.#elements.onlyOpToggle.setAttribute(
"aria-pressed",
String(enabled)
), this.#elements.onlyOpToggle.setAttribute(
"aria-label",
enabled ? "显示全部楼层" : "只看楼主"
);
const showProgress = !!(enabled && snapshot && !snapshot.complete && snapshot.totalPostCount > 0);
if (this.#elements.onlyOpProgress.hidden = !showProgress, !showProgress || !snapshot) {
this.#elements.onlyOpProgressValue.textContent = "", this.#elements.onlyOpProgress.style.removeProperty(
"--ldp-only-op-progress"
);
return;
}
const percent = Math.min(
100,
snapshot.loadedPostCount / snapshot.totalPostCount * 100
);
this.#elements.onlyOpProgress.style.setProperty(
"--ldp-only-op-progress",
`${percent.toFixed(1)}%`
), this.#elements.onlyOpProgressValue.textContent = `已载入 ${snapshot.loadedPostCount}/${snapshot.totalPostCount} · 楼主 ${snapshot.ownerPostCount}`;
}
}
}, "db006541a3e0ae1b410214d6a35273e74a9051012580b9dca99cc0542b071629");
/* Source: lite/src/topic/reader-topic-local-archive-feature.ts */
runtime.register("src/topic/reader-topic-local-archive-feature.js", function(module, exports, require) {
var reader_topic_local_archive_feature_exports = {};
__export(reader_topic_local_archive_feature_exports, {
ReaderTopicLocalArchiveFeature: () => ReaderTopicLocalArchiveFeature
});
module.exports = __toCommonJS(reader_topic_local_archive_feature_exports);
var import_html_element = require("../dom/html-element.js"), import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js");
function archiveLabel(status) {
return status === 403 ? "已隐藏或无权访问" : `服务器返回 ${status}`;
}
function archivedPostLabel(status) {
return status === 403 ? "隐藏前正文" : `${status} 前正文`;
}
class ReaderTopicLocalArchiveFeature {
activationScope = "node";
scope;
#document;
#topicRoot;
#session;
#nowLabel;
#notice;
#roots = /* @__PURE__ */ new Map();
constructor(options) {
this.#document = options.document, this.#topicRoot = options.topicRoot, this.#session = options.session, this.#nowLabel = options.nowLabel ?? ((timestamp) => new Date(timestamp).toLocaleString()), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#notice = (0, import_html_element.htmlElement)(options.document, "aside", "ldp-topic-local-archive-notice"), this.#notice.setAttribute("role", "note"), this.#notice.hidden = !0, this.#topicRoot.prepend(this.#notice), this.scope.add(() => {
this.#roots.clear(), this.#notice.remove(), this.#topicRoot.classList.remove("is-local-archive-topic"), delete this.#topicRoot.dataset.localArchiveStatus;
}), this.#session.archiveChanges.subscribe(() => this.syncProjection(), this.scope), this.syncProjection();
}
afterRender(post, view) {
const postNumber = (0, import_identifiers.discoursePostReference)(post).postNumber;
this.#roots.set(postNumber, view.slots.root), this.#projectPost(view.slots.root, postNumber);
}
attachRoot(root, postNumber) {
this.#roots.set(postNumber, root), this.#projectPost(root, postNumber);
}
detachRoot(root, postNumber) {
this.#roots.get(postNumber) === root && this.#roots.delete(postNumber);
}
syncProjection() {
const state = this.#session.localArchiveState(), topicUnavailable = state.topic;
this.#topicRoot.classList.toggle(
"is-local-archive-topic",
topicUnavailable !== null
), topicUnavailable ? (this.#topicRoot.dataset.localArchiveStatus = String(topicUnavailable.status), this.#notice.hidden = !1, this.#notice.textContent = `本地存档 · ${archiveLabel(topicUnavailable.status)}。以下内容来自 ${this.#nowLabel(topicUnavailable.confirmedAt)} 前保留的正文,不会再按普通缓存期限自动清理,也不代表服务器当前版本。`) : (delete this.#topicRoot.dataset.localArchiveStatus, this.#notice.hidden = !0, this.#notice.textContent = "");
for (const [postNumber, root] of this.#roots)
this.#projectPost(root, postNumber, state);
}
#projectPost(root, postNumber, state = this.#session.localArchiveState()) {
const unavailable = state.posts.find((entry) => entry.postNumber === postNumber) ?? null;
root.classList.toggle("is-local-archive-post", unavailable !== null);
let note = root.querySelector(
":scope > .ldp-post-body > .ldp-post-body-layer > .ldp-post-local-archive-note"
);
if (!unavailable) {
note?.remove(), delete root.dataset.localArchiveStatus;
return;
}
root.dataset.localArchiveStatus = String(unavailable.status), note || (note = (0, import_html_element.htmlElement)(
this.#document,
"aside",
"ldp-post-local-archive-note"
), note.setAttribute("role", "note"), root.querySelector(
":scope > .ldp-post-body > .ldp-post-body-layer"
)?.prepend(note)), note.textContent = `本地缓存 · ${archivedPostLabel(unavailable.status)} · ${this.#nowLabel(unavailable.confirmedAt)} 确认`, root.querySelector(":scope > .ldp-post-head .ldp-hidden-badge") && note.append((0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-post-local-archive-subtext",
"(已隐藏)"
));
}
}
}, "76b82c13cc71f53ac13e1cbb4b1cd8b9bf651f2c2e90549ef3575fa97e38f275");
/* Source: lite/src/topic/reader-topic-navigation-controller.ts */
runtime.register("src/topic/reader-topic-navigation-controller.js", function(module, exports, require) {
var reader_topic_navigation_controller_exports = {};
__export(reader_topic_navigation_controller_exports, {
ReaderTopicNavigationController: () => ReaderTopicNavigationController
});
module.exports = __toCommonJS(reader_topic_navigation_controller_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js"), import_reader_reply_ancestor_resolver = require("./reader-reply-ancestor-resolver.js");
class ReaderTopicNavigationController {
scope;
changes = new import_signal.Signal();
#session;
#dom;
#hidden;
#onError;
#onMilestone;
#now;
#epoch = 0;
constructor(options) {
this.#session = options.session, this.#dom = options.dom, this.#hidden = options.hidden ?? null, this.#onError = options.onError ?? (() => {
}), this.#onMilestone = options.onMilestone ?? (() => {
}), this.#now = options.now ?? (() => performance.now()), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), options.listenUserScrollIntent && this.scope.add(options.listenUserScrollIntent(() => {
this.scope.destroyed || (this.#epoch += 1);
})), this.scope.add(() => {
this.#epoch += 1, this.changes.clear();
});
}
get revision() {
return this.#epoch;
}
isCurrent(revision) {
return !this.scope.destroyed && revision === this.#epoch;
}
async navigate(request) {
this.#assertActive();
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: request.postNumber
}).postNumber, epoch = ++this.#epoch, startedAt = this.#now();
try {
if (request.cachedOnly !== !0 && (request.forceRefresh === !0 || !this.#session.postByNumber(postNumber)) && await this.#session.loadTarget(postNumber, {
scope: "around",
/* 远距目的性水合不能跳过尚未读取的顺序流中段。 */
advanceCursor: !1,
...request.forceRefresh === !0 ? { forceRefresh: !0 } : {}
}), epoch !== this.#epoch || this.scope.destroyed)
return this.#result(request, postNumber, "superseded");
if (!this.#session.postByNumber(postNumber))
return this.#emit(this.#result(
request,
postNumber,
"unavailable"
));
this.#milestone("target-data-ready", postNumber, request, startedAt);
const ancestorResolution = request.cachedOnly === !0 ? null : await (0, import_reader_reply_ancestor_resolver.resolveReaderReplyAncestors)(
this.#session,
postNumber,
{
isActive: () => epoch === this.#epoch && !this.scope.destroyed
}
);
if (epoch !== this.#epoch || this.scope.destroyed)
return this.#result(request, postNumber, "superseded");
ancestorResolution?.error !== void 0 && this.#onError(ancestorResolution.error);
const revealOptions = {
source: request.source,
...request.cachedOnly === !0 && request.revealAsFloor === !0 ? { degradedRootPostNumber: postNumber } : ancestorResolution && !ancestorResolution.complete ? {
degradedRootPostNumber: ancestorResolution.rootPostNumber
} : {},
...request.revealAsFloor === !0 ? { revealAsFloor: !0 } : {},
...request.alignment === void 0 ? {} : { alignment: request.alignment },
...request.focus === void 0 ? {} : { focus: request.focus },
...request.highlight === void 0 ? {} : { highlight: request.highlight }
};
this.#dom.prepareRevealPost?.(postNumber);
const reveal = request.revealAsFloor !== !0 && this.#hidden?.isHidden(postNumber) ? await this.#hidden.revealPost(postNumber, revealOptions) : this.#dom.revealPost(postNumber, revealOptions);
return epoch !== this.#epoch || this.scope.destroyed ? this.#result(request, postNumber, "superseded") : reveal ? (this.#milestone("target-dom-ready", postNumber, request, startedAt), this.#milestone("target-aligned", postNumber, request, startedAt), this.#emit(Object.freeze({
postNumber,
source: request.source,
status: "revealed",
rootPostNumber: (0, import_identifiers.discoursePostReference)({
post_number: reveal.rootPostNumber
}).postNumber,
mounted: reveal.mounted,
element: reveal.element
}))) : this.#emit(this.#result(
request,
postNumber,
"unresolved-tree"
));
} catch (error) {
if (epoch !== this.#epoch || this.scope.destroyed)
return this.#result(request, postNumber, "superseded");
throw this.#onError(error), error;
}
}
cancel() {
this.#assertActive(), this.#epoch += 1;
}
destroy() {
this.scope.destroy();
}
#result(request, postNumber, status) {
return Object.freeze({
postNumber,
source: request.source,
status,
rootPostNumber: null,
mounted: !1
});
}
#emit(result) {
for (const error of this.changes.emit(result)) this.#onError(error);
return result;
}
#milestone(stage, postNumber, request, startedAt) {
try {
this.#onMilestone(Object.freeze({
stage,
postNumber,
source: request.source,
durationMs: Math.max(0, this.#now() - startedAt)
}));
} catch (error) {
this.#onError(error);
}
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("ReaderTopicNavigationController 已销毁");
}
}
}, "37fa390e0906a98ee8c2b0c41289ea8600c6272bf0af01201a3c5406862ccc46");
/* Source: lite/src/topic/reader-topic-navigation-preferences.ts */
runtime.register("src/topic/reader-topic-navigation-preferences.js", function(module, exports, require) {
var reader_topic_navigation_preferences_exports = {};
__export(reader_topic_navigation_preferences_exports, {
ReaderTopicNavigationPreferenceProjection: () => ReaderTopicNavigationPreferenceProjection
});
module.exports = __toCommonJS(reader_topic_navigation_preferences_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
const VARIABLES = Object.freeze([
"--ldp-jump-highlight-color",
"--ldp-jump-highlight-radius",
"--ldp-jump-highlight-border-width",
"--ldp-jump-highlight-duration",
"--ldp-jump-highlight-count"
]), DEFAULTS = Object.freeze({
overscanScreens: 1.5,
maxMountedPostCount: 80,
highlightColor: "#0888cc",
highlightRadius: 10,
highlightBorderWidth: 1,
highlightRate: 0.8,
highlightCount: 2
});
function finiteRange(value, fallback, minimum, maximum) {
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value)) : fallback;
}
function normalize(preferences, performance) {
const overscanScreens = finiteRange(
performance?.streamOverscanScreens ?? DEFAULTS.overscanScreens,
DEFAULTS.overscanScreens,
0.25,
3
), maxMountedPostCount = Math.round(finiteRange(
performance?.streamMaxMountedPostCount ?? DEFAULTS.maxMountedPostCount,
DEFAULTS.maxMountedPostCount,
24,
128
)), highlightColor = /^#[0-9a-f]{6}$/i.test(preferences.jumpHighlightColor) ? preferences.jumpHighlightColor.toLowerCase() : DEFAULTS.highlightColor, highlightRadius = Math.round(finiteRange(
preferences.jumpHighlightRadius,
DEFAULTS.highlightRadius,
0,
24
)), highlightBorderWidth = Math.round(finiteRange(
preferences.jumpHighlightBorderWidth,
DEFAULTS.highlightBorderWidth,
0,
4
)), highlightRate = Math.round(
finiteRange(
preferences.jumpHighlightRate,
DEFAULTS.highlightRate,
0.5,
2
) * 10
) / 10, highlightCount = Math.round(finiteRange(
preferences.jumpHighlightCount,
DEFAULTS.highlightCount,
1,
6
)), highlightStepDurationMs = Math.round(1e3 / highlightRate);
return Object.freeze({
overscanScreens,
maxMountedPostCount,
highlightColor,
highlightRadius,
highlightBorderWidth,
highlightRate,
highlightCount,
highlightStepDurationMs,
highlightLifetimeMs: highlightStepDurationMs * highlightCount
});
}
function snapshotsEqual(left, right) {
return left.overscanScreens === right.overscanScreens && left.maxMountedPostCount === right.maxMountedPostCount && left.highlightColor === right.highlightColor && left.highlightRadius === right.highlightRadius && left.highlightBorderWidth === right.highlightBorderWidth && left.highlightRate === right.highlightRate && left.highlightCount === right.highlightCount && left.highlightStepDurationMs === right.highlightStepDurationMs && left.highlightLifetimeMs === right.highlightLifetimeMs;
}
class ReaderTopicNavigationPreferenceProjection {
scope;
#root;
#readPerformance;
#previous = /* @__PURE__ */ new Map();
#preferences;
#preview = null;
#snapshot;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#root = options.root, this.#readPerformance = options.readPerformance ?? (() => null), this.#preferences = options.preferences;
for (const name of VARIABLES) {
const readPriority = this.#root.style.getPropertyPriority;
this.#previous.set(name, Object.freeze({
value: this.#root.style.getPropertyValue(name),
priority: typeof readPriority == "function" ? readPriority.call(this.#root.style, name) : ""
}));
}
this.#snapshot = normalize(
options.preferences,
this.#readPerformance()
), this.#applyCss(), this.scope.add(() => this.#restoreCss());
}
get snapshot() {
return this.#snapshot;
}
apply(preferences) {
this.scope.destroyed || (this.#preferences = preferences, this.#commit(normalize(
this.#preview ?? this.#preferences,
this.#readPerformance()
)));
}
preview(preferences) {
if (this.scope.destroyed) return;
const performance = this.#readPerformance(), snapshot = normalize(preferences, performance);
this.#preview = snapshotsEqual(
snapshot,
normalize(this.#preferences, performance)
) ? null : preferences, this.#commit(snapshot);
}
clearPreview() {
this.scope.destroyed || !this.#preview || (this.#preview = null, this.#commit(normalize(
this.#preferences,
this.#readPerformance()
)));
}
refreshPerformance() {
if (this.scope.destroyed) return;
const performance = this.#readPerformance();
this.#commit(Object.freeze({
...this.#snapshot,
overscanScreens: finiteRange(
performance?.streamOverscanScreens ?? DEFAULTS.overscanScreens,
DEFAULTS.overscanScreens,
0.25,
3
),
maxMountedPostCount: Math.round(finiteRange(
performance?.streamMaxMountedPostCount ?? DEFAULTS.maxMountedPostCount,
DEFAULTS.maxMountedPostCount,
24,
128
))
}));
}
readOverscan() {
const overscanScreens = this.#snapshot.overscanScreens;
return Object.freeze({
beforeScreens: overscanScreens,
afterScreens: overscanScreens
});
}
readHighlightLifetimeMs() {
return this.#snapshot.highlightLifetimeMs;
}
readMaxMountedPostCount() {
return this.#snapshot.maxMountedPostCount;
}
destroy() {
this.scope.destroy();
}
#commit(snapshot) {
snapshotsEqual(snapshot, this.#snapshot) || (this.#snapshot = snapshot, this.#applyCss());
}
#applyCss() {
const snapshot = this.#snapshot;
this.#root.style.setProperty(
"--ldp-jump-highlight-color",
snapshot.highlightColor
), this.#root.style.setProperty(
"--ldp-jump-highlight-radius",
`${snapshot.highlightRadius}px`
), this.#root.style.setProperty(
"--ldp-jump-highlight-border-width",
`${snapshot.highlightBorderWidth}px`
), this.#root.style.setProperty(
"--ldp-jump-highlight-duration",
`${snapshot.highlightStepDurationMs}ms`
), this.#root.style.setProperty(
"--ldp-jump-highlight-count",
String(snapshot.highlightCount)
);
}
#restoreCss() {
for (const name of VARIABLES) {
const previous = this.#previous.get(name);
previous?.value ? this.#root.style.setProperty(
name,
previous.value,
previous.priority
) : this.#root.style.removeProperty(name);
}
}
}
}, "021614753d8d9c7366451df05ef36e606d15b2101d07f9b3bf45efa4ec826017");
/* Source: lite/src/topic/reader-topic-only-op-controller.ts */
runtime.register("src/topic/reader-topic-only-op-controller.js", function(module, exports, require) {
var reader_topic_only_op_controller_exports = {};
__export(reader_topic_only_op_controller_exports, {
ReaderTopicOnlyOpController: () => ReaderTopicOnlyOpController
});
module.exports = __toCommonJS(reader_topic_only_op_controller_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js"), import_value_record = require("../kernel/value-record.js"), import_reader_topic_header = require("./reader-topic-header.js");
function text(value) {
return String(value ?? "").trim();
}
function positiveCount(value) {
const numeric = Number(value);
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : 0;
}
class ReaderTopicOnlyOpController {
scope;
changes = new import_signal.Signal();
#session;
#presentation;
#onProjectionChanged;
#onEnabledChanged;
#onError;
#enabled = !1;
#snapshot;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#session = options.session, this.#presentation = options.presentation, this.#onProjectionChanged = options.onProjectionChanged, this.#onEnabledChanged = options.onEnabledChanged ?? (() => {
}), this.#onError = options.onError ?? (() => {
}), this.#snapshot = this.#readSnapshot(), (options.presentationChanges ?? this.#session.changes).subscribe((commit) => {
this.refresh(commit.changedPostNumbers.length > 0);
}, this.scope), this.scope.add(() => {
this.#enabled && this.#notifyEnabledChanged(!1), this.#presentation.setPostFilter(null), this.changes.clear();
});
}
get snapshot() {
return this.#snapshot;
}
setEnabled(enabled) {
const next = enabled === !0 && this.#snapshot.available;
return next === this.#enabled ? !1 : (this.#enabled = next, this.#notifyEnabledChanged(next), this.#applyProjection(), this.refresh(), !0);
}
toggle() {
return this.setEnabled(!this.#enabled);
}
refresh(invalidateFilter = !1) {
const next = this.#readSnapshot();
if (this.#enabled && !next.available)
return this.#enabled = !1, this.#notifyEnabledChanged(!1), this.#applyProjection(), this.refresh();
if (this.#enabled && next.ownerUsername !== this.#snapshot.ownerUsername && this.#applyProjection(next.ownerUsername), invalidateFilter && this.#enabled && this.#presentation.invalidatePostFilter() && this.#notifyProjectionChanged(!1), next.enabled === this.#snapshot.enabled && next.available === this.#snapshot.available && next.ownerUsername === this.#snapshot.ownerUsername && next.loadedPostCount === this.#snapshot.loadedPostCount && next.totalPostCount === this.#snapshot.totalPostCount && next.ownerPostCount === this.#snapshot.ownerPostCount && next.complete === this.#snapshot.complete) return this.#snapshot;
this.#snapshot = next;
for (const error of this.changes.emit(next)) this.#onError(error);
return next;
}
destroy() {
this.scope.destroy();
}
#applyProjection(owner = this.#snapshot.ownerUsername) {
this.#presentation.setPostFilter(
this.#enabled && owner ? Object.freeze({
key: `only-op:${owner}`,
hideDescendantMatches: !0,
ancestorBoundaryPostNumber: 1,
matches: (postNumber) => text(this.#session.postByNumber(postNumber)?.username) === owner
}) : null
) && this.#notifyProjectionChanged(!0);
}
#notifyProjectionChanged(resetScroll) {
try {
this.#onProjectionChanged(resetScroll);
} catch (error) {
this.#onError(error);
}
}
#notifyEnabledChanged(enabled) {
try {
this.#onEnabledChanged(enabled);
} catch (error) {
this.#onError(error);
}
}
#readSnapshot() {
const posts = this.#session.cachedPosts(), owner = (0, import_reader_topic_header.readerTopicOwnerUsername)(this.#session.topic, posts), topic = (0, import_value_record.valueRecord)(this.#session.topic), totalPostCount = Math.max(
positiveCount(topic?.highest_post_number),
positiveCount(topic?.posts_count),
posts.reduce(
(maximum, post) => Math.max(maximum, positiveCount(post.post_number)),
0
)
);
return Object.freeze({
enabled: this.#enabled,
available: !!owner,
ownerUsername: owner,
loadedPostCount: posts.length,
totalPostCount,
ownerPostCount: owner ? posts.filter((post) => text(post.username) === owner).length : 0,
complete: this.#session.loadDone === !0 || totalPostCount > 0 && posts.length >= totalPostCount
});
}
}
}, "26786da74701ab1ec96b5181a0950b1463e676432c25bc31fdd17eb8d88e0bbd");
/* Source: lite/src/topic/reader-topic-scroll-adapter.ts */
runtime.register("src/topic/reader-topic-scroll-adapter.js", function(module, exports, require) {
var reader_topic_scroll_adapter_exports = {};
__export(reader_topic_scroll_adapter_exports, {
ReaderBoostTargetHighlightController: () => ReaderBoostTargetHighlightController,
ReaderTopicJumpHighlightController: () => ReaderTopicJumpHighlightController,
ReaderTopicScrollAdapter: () => ReaderTopicScrollAdapter
});
module.exports = __toCommonJS(reader_topic_scroll_adapter_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_event_target = require("../dom/event-target.js");
function finiteNonNegative(value, fallback = 0) {
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function wheelBlockDelta(event, scrollRoot) {
return event.deltaMode === 1 ? event.deltaY * 40 : event.deltaMode === 2 ? event.deltaY * scrollRoot.clientHeight : event.deltaY;
}
function nestedScrollTargetCanConsume(event, scrollRoot, delta) {
const target = (0, import_event_target.eventElement)(event);
if (!target || !scrollRoot.contains(target)) return !1;
let candidate = target;
for (; candidate && candidate !== scrollRoot; ) {
const maxScrollTop = candidate.scrollHeight - candidate.clientHeight, view = candidate.ownerDocument.defaultView, overflowY = (typeof view?.getComputedStyle == "function" ? view.getComputedStyle(candidate).overflowY : "") || candidate.style.overflowY;
if (maxScrollTop > 1 && /(auto|scroll|overlay)/.test(overflowY) && (delta < 0 ? candidate.scrollTop > 0 : candidate.scrollTop < maxScrollTop - 1)) return !0;
candidate = candidate.parentElement;
}
return !1;
}
function defaultResizeObserverFactory(callback) {
return typeof ResizeObserver != "function" ? null : new ResizeObserver((entries) => callback(entries.map((entry) => {
const borderBox = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize;
return Object.freeze({
target: entry.target,
blockSize: borderBox?.blockSize ?? entry.contentRect.height
});
})));
}
class ReaderTopicJumpHighlightController {
scope;
#readLifetimeMs;
#prefersReducedMotion;
#createResizeObserver;
#schedule;
#cancel;
#active = null;
#token = 0;
constructor(options = {}) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#readLifetimeMs = options.readLifetimeMs ?? (() => 2500), this.#prefersReducedMotion = options.prefersReducedMotion ?? (() => !1), this.#createResizeObserver = options.createResizeObserver ?? defaultResizeObserverFactory, this.#schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs)), this.#cancel = options.cancel ?? ((handle) => clearTimeout(handle)), this.scope.add(() => this.clear());
}
highlight(target) {
if (this.scope.destroyed || !target.isConnected) return;
this.clear();
const token = String(++this.#token);
target.classList.remove("ldp-jump-highlight"), target.style.removeProperty("--ldp-first-post-jump-highlight-height");
const anchor = this.#syncFirstPostRegion(target);
target.offsetWidth, target.dataset.jumpHighlightToken = token, target.classList.add("ldp-jump-highlight");
const observer = anchor ? this.#createResizeObserver(() => {
target.isConnected && target.dataset.jumpHighlightToken === token && this.#syncFirstPostRegion(target);
}) : null;
observer?.observe(target), anchor && observer?.observe(anchor);
const lifetime = this.#prefersReducedMotion() ? 1e3 : Math.max(1, finiteNonNegative(this.#readLifetimeMs(), 2500)), timer = this.#schedule(() => {
target.dataset.jumpHighlightToken === token && this.#clearTarget(target), observer?.disconnect(), this.#active?.token === token && (this.#active = null);
}, lifetime);
this.#active = Object.freeze({ target, token, timer, observer });
}
clear() {
const active = this.#active;
active && (this.#active = null, this.#cancel(active.timer), active.observer?.disconnect(), active.target.dataset.jumpHighlightToken === active.token && this.#clearTarget(active.target));
}
destroy() {
this.scope.destroy();
}
#syncFirstPostRegion(target) {
if (target.dataset.postNumber !== "1") return null;
const actions = target.querySelector(
":scope > .ldp-post-body .ldp-reactions, :scope > .ldp-reactions"
), targetRect = target.getBoundingClientRect();
if (actions) {
const actionsRect = actions.getBoundingClientRect();
if (actionsRect.height > 0 && actionsRect.bottom > targetRect.top)
return this.#setFirstPostRegion(target, targetRect, actionsRect), actions;
}
const body = target.querySelector(
":scope > .ldp-post-body"
);
return body ? (this.#setFirstPostRegion(
target,
targetRect,
body.getBoundingClientRect()
), body) : null;
}
#setFirstPostRegion(target, targetRect, boundaryRect) {
const height = Math.min(
targetRect.height,
Math.max(0, boundaryRect.bottom - targetRect.top)
);
target.style.setProperty(
"--ldp-first-post-jump-highlight-height",
`${Math.ceil(height)}px`
);
}
#clearTarget(target) {
target.classList.remove("ldp-jump-highlight"), target.style.removeProperty("--ldp-first-post-jump-highlight-height"), delete target.dataset.jumpHighlightToken;
}
}
class ReaderBoostTargetHighlightController {
scope;
#readLifetimeMs;
#prefersReducedMotion;
#schedule;
#cancel;
#active = null;
#token = 0;
constructor(options = {}) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#readLifetimeMs = options.readLifetimeMs ?? (() => 2500), this.#prefersReducedMotion = options.prefersReducedMotion ?? (() => !1), this.#schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs)), this.#cancel = options.cancel ?? ((handle) => clearTimeout(handle)), this.scope.add(() => this.clear());
}
highlight(target) {
if (this.scope.destroyed || !target.isConnected) return;
this.clear();
const token = String(++this.#token);
target.classList.remove("ldp-boost-target-highlight"), target.offsetWidth, target.dataset.boostTargetHighlightToken = token, target.classList.add("ldp-boost-target-highlight");
const lifetime = this.#prefersReducedMotion() ? 1e3 : Math.max(1, finiteNonNegative(this.#readLifetimeMs(), 2500)), timer = this.#schedule(() => {
target.dataset.boostTargetHighlightToken === token && this.#clearTarget(target), this.#active?.token === token && (this.#active = null);
}, lifetime);
this.#active = Object.freeze({ target, token, timer });
}
clear() {
const active = this.#active;
active && (this.#active = null, this.#cancel(active.timer), active.target.dataset.boostTargetHighlightToken === active.token && this.#clearTarget(active.target));
}
destroy() {
this.scope.destroy();
}
#clearTarget(target) {
target.classList.remove("ldp-boost-target-highlight"), delete target.dataset.boostTargetHighlightToken;
}
}
const SCROLLING_KEYS = /* @__PURE__ */ new Set([
"ArrowDown",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
" "
]), USER_SCROLL_SESSION_GAP_MS = 500, STATIONARY_LOCK_FALLBACK_FRAMES = 3, STATIONARY_LOCK_AFTER_COMMIT_FRAMES = 2;
function isEditableScrollTarget(target) {
const candidate = target;
return !candidate || typeof candidate.closest != "function" ? !1 : !!candidate.closest(
'input, textarea, select, button, [role="button"], [contenteditable=""], [contenteditable="true"]'
);
}
class ReaderTopicScrollAdapter {
scope;
highlight;
#scrollRoot;
#readOverscan;
#readMaxMountedPostCount;
#readTopInset;
#requestFrame;
#cancelFrame;
#now;
#observesViewportSize;
#windowChangeListeners = /* @__PURE__ */ new Set();
#userScrollIntentListeners = /* @__PURE__ */ new Set();
#directUserScrollIntentListeners = /* @__PURE__ */ new Set();
#viewportSize = 1;
#viewportSizeDirty = !1;
#scrollOffset = 0;
#pendingScrollOffset = null;
#scrollOffsetDirty = !1;
#scrollFrame = 0;
#scrollFrameHinted = !1;
#lastUserScrollAt = 0;
#lastUserScrollDirection = 0;
#userScrollSessionActive = !1;
#pendingExternalKeyboardScroll = null;
#lastTouchClientY = null;
#stationaryAnchor = null;
#stationaryLockPending = !1;
#stationaryLockFrame = 0;
#stationaryLockSettleFrames = 0;
#viewportMutationAnchor = null;
#viewportMutationToken = 0;
#internalScrollWriteOffset = null;
#programmaticScrollTransactionDepth = 0;
#stationaryMutationObserver = null;
#stationaryResizeObserver = null;
#stationaryRestoreFrame = 0;
#stationaryContentObservationDirty = !1;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#scrollRoot = options.scrollRoot, this.#readOverscan = options.readOverscan ?? (() => ({})), this.#readMaxMountedPostCount = options.readMaxMountedPostCount ?? (() => {
}), this.#readTopInset = options.readTopInset ?? (() => 0), this.#requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)), this.#cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id)), this.#now = options.now ?? (() => performance.now()), this.#scrollRoot.classList.remove("ldp-stream-viewport-anchor");
const createMutationObserver = options.createMutationObserver ?? ((callback) => {
const NativeMutationObserver = this.#scrollRoot.ownerDocument.defaultView?.MutationObserver;
return NativeMutationObserver ? new NativeMutationObserver(callback) : null;
});
this.#stationaryMutationObserver = createMutationObserver(() => {
this.#stationaryContentObservationDirty = !0, this.#scheduleStationaryViewportRestore();
});
const NativeResizeObserver = this.#scrollRoot.ownerDocument.defaultView?.ResizeObserver;
NativeResizeObserver && (this.#stationaryResizeObserver = new NativeResizeObserver(() => {
this.#scheduleStationaryViewportRestore();
})), this.scope.add(() => this.#releaseStationaryViewport()), this.scope.add(() => this.#cancelPendingStationaryViewportLock()), this.scope.add(() => this.#cancelViewportMutation(void 0, !1));
const createResizeObserver = options.createResizeObserver ?? defaultResizeObserverFactory, viewportObserver = createResizeObserver((entries) => {
const entry = entries.find(({ target }) => target === this.#scrollRoot), viewportSize = Math.max(
1,
finiteNonNegative(entry?.blockSize ?? 0) || this.#measureViewportSize()
), scrollOffset = finiteNonNegative(this.#scrollRoot.scrollTop);
viewportSize === this.#viewportSize && scrollOffset === this.#scrollOffset || (this.#viewportSize = viewportSize, this.#scrollOffset = scrollOffset, this.#pendingScrollOffset = scrollOffset, this.#scrollOffsetDirty = !1, this.#notifyWindowChange());
});
this.#observesViewportSize = viewportObserver !== null, viewportObserver || (this.#viewportSize = this.#measureViewportSize(), this.#scrollOffset = finiteNonNegative(this.#scrollRoot.scrollTop)), viewportObserver?.observe(this.#scrollRoot), viewportObserver && this.scope.add(() => viewportObserver.disconnect()), options.viewportChangeTarget && this.scope.listen(
options.viewportChangeTarget,
"ldp-reader-window-change",
() => {
this.#viewportSizeDirty = !0, this.#notifyWindowChange();
}
), this.highlight = new ReaderTopicJumpHighlightController({
...options.readLifetimeMs ? { readLifetimeMs: options.readLifetimeMs } : {},
...options.prefersReducedMotion ? { prefersReducedMotion: options.prefersReducedMotion } : {},
createResizeObserver,
...options.schedule ? { schedule: options.schedule } : {},
...options.cancel ? { cancel: options.cancel } : {},
parentScope: this.scope
}), this.scope.listen(
this.#scrollRoot,
"scroll",
() => {
const actualOffset = finiteNonNegative(this.#scrollRoot.scrollTop);
this.#claimScrollOnlyUserInput(actualOffset) && this.#markUserScrollProgress(), !(Math.abs(actualOffset - this.#scrollOffset) < 0.5) && (this.#scrollOffsetDirty = !0, !this.#scrollFrame && !this.#scrollFrameHinted && (this.#scrollFrameHinted = !0, this.#notifyWindowChange()), this.#scheduleScrollCommit());
},
{ passive: !0 }
), this.scope.listen(this.#scrollRoot, "scrollend", () => {
this.#finishUserScrollSession();
}, { passive: !0 }), this.scope.listen(this.#scrollRoot, "wheel", (event) => {
const wheel = event;
if (wheel.ctrlKey) return;
const delta = wheelBlockDelta(wheel, this.#scrollRoot), direction = this.#directionOf(delta);
if (direction === 0) {
this.#markUserScrollIntent();
return;
}
if (direction < 0 && !nestedScrollTargetCanConsume(wheel, this.#scrollRoot, delta)) {
wheel.preventDefault(), this.#markUserScrollIntent(direction);
const requestedOffset = Math.max(
0,
finiteNonNegative(this.#scrollRoot.scrollTop) + delta
), actualOffset = this.#writeScrollRootOffset(requestedOffset);
this.#scrollOffset = actualOffset, this.#pendingScrollOffset = actualOffset, this.#scrollOffsetDirty = !1, this.#notifyWindowChange();
return;
}
this.#markUserScrollIntent(direction);
}, { passive: !1 }), this.scope.listen(this.#scrollRoot, "touchstart", (event) => {
this.#lastTouchClientY = event.touches?.[0]?.clientY ?? null, this.#markUserScrollIntent();
}, { passive: !0 }), this.scope.listen(this.#scrollRoot, "touchmove", (event) => {
const clientY = event.touches?.[0]?.clientY, direction = clientY === void 0 || this.#lastTouchClientY === null ? 0 : this.#directionOf(this.#lastTouchClientY - clientY);
this.#lastTouchClientY = clientY ?? null, this.#markUserScrollIntent(direction);
}, { passive: !0 });
for (const type of ["touchend", "touchcancel"])
this.scope.listen(this.#scrollRoot, type, () => {
this.#lastTouchClientY = null;
}, { passive: !0 });
this.scope.listen(this.#scrollRoot, "keydown", (event) => {
const keyboard = event;
if (keyboard.defaultPrevented || keyboard.altKey || keyboard.ctrlKey || keyboard.metaKey || !SCROLLING_KEYS.has(keyboard.key) || isEditableScrollTarget(keyboard.target)) return;
keyboard.preventDefault(), this.#markUserScrollIntent(this.#keyboardDirection(keyboard));
const actualOffset = this.#writeScrollRootOffset(
this.#keyboardScrollOffset(keyboard)
);
this.#scrollOffset = actualOffset, this.#pendingScrollOffset = actualOffset, this.#scrollOffsetDirty = !1, this.#notifyWindowChange();
}), this.scope.listen(this.#scrollRoot.ownerDocument, "keydown", (event) => {
const keyboard = event;
(typeof keyboard.composedPath == "function" ? keyboard.composedPath() : []).includes(this.#scrollRoot) || this.#scrollRoot.contains(keyboard.target) || keyboard.defaultPrevented || keyboard.altKey || keyboard.ctrlKey || keyboard.metaKey || !SCROLLING_KEYS.has(keyboard.key) || isEditableScrollTarget(keyboard.target) || (this.#pendingExternalKeyboardScroll = Object.freeze({
at: finiteNonNegative(this.#now()),
direction: this.#keyboardDirection(keyboard)
}));
}), this.scope.add(() => {
this.#scrollFrame && this.#cancelFrame(this.#scrollFrame), this.#scrollFrame = 0, this.#scrollFrameHinted = !1, this.#pendingScrollOffset = null, this.#scrollOffsetDirty = !1;
}), this.scope.add(() => this.#windowChangeListeners.clear()), this.scope.add(() => this.#userScrollIntentListeners.clear()), this.scope.add(() => this.#directUserScrollIntentListeners.clear());
}
readWindowInput() {
const overscan = this.#readOverscan(), maxMountedPostCount = this.#readMaxMountedPostCount(), scrollOffset = this.#refreshPendingScrollOffset(), bootstrapViewportSize = this.#observesViewportSize && this.#viewportSize <= 1, viewportSize = !this.#observesViewportSize || this.#viewportSizeDirty || bootstrapViewportSize ? this.#measureViewportSize() : this.#viewportSize;
(this.#viewportSizeDirty || bootstrapViewportSize) && (this.#viewportSize = viewportSize, this.#viewportSizeDirty = !1);
const preservedPostNumber = this.#stationaryAnchor?.postNumber ?? this.#viewportMutationAnchor?.postNumber;
return Object.freeze({
scrollOffset,
viewportSize,
...preservedPostNumber === void 0 ? {} : { preservePostNumber: preservedPostNumber },
/*
* overscan 是用户/性能策略的稳定窗口契约。滚动方向只改变 offset;
* 若在反向瞬间翻转前后边界,会额外整批卸载/挂载树节点并制造尖峰。
*/
overscanBeforeScreens: finiteNonNegative(
Number(overscan.beforeScreens),
1
),
overscanAfterScreens: finiteNonNegative(
Number(overscan.afterScreens),
1
),
...maxMountedPostCount === void 0 ? {} : { maxMountedPostCount }
});
}
lastUserScrollAt() {
return this.#lastUserScrollAt;
}
lastUserScrollDirection() {
return this.#lastUserScrollDirection;
}
remainingUserIdleMs(minimumIdleMs) {
const idleMs = finiteNonNegative(Number(minimumIdleMs));
return this.#lastUserScrollAt <= 0 ? 0 : Math.max(0, idleMs - (this.#now() - this.#lastUserScrollAt));
}
readVisibleViewportAnchor(elements) {
const visible = this.#findTopVisiblePost(elements);
return visible ? Object.freeze({
postNumber: visible.postNumber,
postOffset: visible.ownerOffset,
scrollTop: finiteNonNegative(this.#scrollRoot.scrollTop)
}) : null;
}
beginViewportMutation(elements) {
if (this.#stationaryAnchor || this.#viewportMutationAnchor) return null;
const visible = this.#findTopVisiblePost(elements);
if (!visible) return null;
const token = ++this.#viewportMutationToken;
return this.#viewportMutationAnchor = Object.freeze({
token,
postNumber: visible.postNumber,
markerRole: visible.marker === visible.owner ? "owner" : "header",
markerOffset: visible.markerOffset,
ownerOffset: visible.ownerOffset,
scrollTop: finiteNonNegative(this.#scrollRoot.scrollTop)
}), this.#syncViewportAnchorClass(), Object.freeze({
postNumber: visible.postNumber,
restore: () => this.#restoreViewportMutation(token),
cancel: () => this.#cancelViewportMutation(token, !1)
});
}
/** 虚拟窗口及其同步投影已经提交;停稳锁可以开始等待干净的布局边界。 */
notifyVirtualWindowCommit() {
!this.#stationaryLockPending || this.#userScrollSessionActive || this.#viewportMutationAnchor || (this.#stationaryLockSettleFrames = STATIONARY_LOCK_AFTER_COMMIT_FRAMES, this.#stationaryLockFrame && (this.#cancelFrame(this.#stationaryLockFrame), this.#stationaryLockFrame = 0), this.#scheduleStationaryViewportLock());
}
#measureViewportSize() {
const clientHeight = finiteNonNegative(this.#scrollRoot.clientHeight), rectHeight = clientHeight > 0 ? 0 : finiteNonNegative(
this.#scrollRoot.getBoundingClientRect().height
);
return Math.max(1, clientHeight || rectHeight || 1);
}
applyScrollCompensation(delta) {
if (!Number.isFinite(delta) || delta === 0) return;
this.#refreshPendingScrollOffset();
const requestedOffset = Math.max(
0,
(this.#pendingScrollOffset ?? this.#scrollOffset) + delta
), actualOffset = this.#writeScrollRootOffset(requestedOffset);
this.#scrollOffset = actualOffset, this.#pendingScrollOffset !== null && (this.#pendingScrollOffset = actualOffset), this.#restoreStationaryViewport();
}
listenScroll(listener) {
return this.scope.destroyed ? () => {
} : (this.#windowChangeListeners.add(listener), this.scope.add(() => {
this.#windowChangeListeners.delete(listener);
}));
}
listenUserScrollIntent(listener) {
return this.scope.destroyed ? () => {
} : (this.#userScrollIntentListeners.add(listener), this.scope.add(() => {
this.#userScrollIntentListeners.delete(listener);
}));
}
listenDirectUserScrollIntent(listener) {
return this.scope.destroyed ? () => {
} : (this.#directUserScrollIntentListeners.add(listener), this.scope.add(() => {
this.#directUserScrollIntentListeners.delete(listener);
}));
}
writeScrollOffset(offset) {
const normalized = finiteNonNegative(offset), maxOffset = Math.max(
0,
finiteNonNegative(this.#scrollRoot.scrollHeight) - finiteNonNegative(this.#scrollRoot.clientHeight)
);
this.#setScrollOffset(maxOffset > 0 ? Math.min(normalized, maxOffset) : normalized);
}
readScrollRange() {
return Math.max(
0,
finiteNonNegative(this.#scrollRoot.scrollHeight) - finiteNonNegative(this.#scrollRoot.clientHeight)
);
}
withProgrammaticScrollTransaction(commit) {
if (this.scope.destroyed) return;
const outermost = this.#programmaticScrollTransactionDepth === 0;
this.#programmaticScrollTransactionDepth += 1, outermost && (this.#cancelViewportMutation(void 0, !1), this.#releaseStationaryViewport(), this.#scrollRoot.classList.add("ldp-stream-programmatic-scroll"));
try {
if (commit(), outermost) {
this.#scrollRoot.getBoundingClientRect();
const actualOffset = finiteNonNegative(this.#scrollRoot.scrollTop);
this.#scrollOffset = actualOffset, this.#pendingScrollOffset = actualOffset, this.#scrollOffsetDirty = !1;
}
} finally {
this.#programmaticScrollTransactionDepth = Math.max(
0,
this.#programmaticScrollTransactionDepth - 1
), outermost && this.#scrollRoot.classList.remove("ldp-stream-programmatic-scroll");
}
}
alignPost(target, options) {
if (target.isConnected) {
if (options.alignment === "end")
this.writeScrollOffset(this.readScrollRange());
else {
const correction = this.alignmentError(target, options);
Math.abs(correction) >= 1 && this.writeScrollOffset(this.#scrollOffset + correction);
}
options.focus && this.#focus(target), options.highlight !== !1 && this.highlight.highlight(target);
}
}
alignmentError(target, options) {
if (!target.isConnected) return Number.POSITIVE_INFINITY;
if (options.alignment === "end")
return this.readScrollRange() - finiteNonNegative(this.#scrollRoot.scrollTop);
const rootRect = this.#scrollRoot.getBoundingClientRect(), targetRect = target.getBoundingClientRect(), topInset = Math.min(
rootRect.height,
finiteNonNegative(this.#readTopInset())
), configuredVisibleTop = rootRect.top + topInset, frozenHeaderBottom = options.viewportOffset === void 0 ? configuredVisibleTop : this.#scrollRoot.closest(".ldp-modal")?.querySelector(":scope > .ldp-header")?.getBoundingClientRect().bottom ?? configuredVisibleTop, visibleTop = Math.min(
rootRect.bottom,
Math.max(configuredVisibleTop, frozenHeaderBottom) + finiteNonNegative(options.viewportOffset ?? 0)
), visibleBottom = rootRect.bottom, visibleHeight = Math.max(1, visibleBottom - visibleTop), fits = targetRect.height <= visibleHeight, alignment = options.alignment ?? (target.dataset.postNumber === "1" ? "start" : "center");
let correction = 0;
return alignment === "nearest" ? targetRect.top < visibleTop && targetRect.bottom > visibleBottom ? correction = 0 : targetRect.top < visibleTop ? correction = targetRect.top - visibleTop : targetRect.bottom > visibleBottom && (correction = targetRect.bottom - visibleBottom) : alignment === "center" && fits ? correction = targetRect.top + targetRect.height / 2 - (visibleTop + visibleHeight / 2) : correction = targetRect.top - visibleTop, correction;
}
highlightPost(target) {
target.isConnected && this.highlight.highlight(target);
}
destroy() {
this.scope.destroy();
}
#setScrollOffset(offset) {
this.#cancelPendingStationaryViewportLock(), this.#cancelViewportMutation(void 0, !1), this.#releaseStationaryViewport();
const requestedOffset = Math.max(0, finiteNonNegative(offset));
this.#scrollOffset = requestedOffset, this.#pendingScrollOffset = requestedOffset, this.#scrollOffsetDirty = !1, this.#lastUserScrollAt = 0, this.#lastUserScrollDirection = 0, this.#userScrollSessionActive = !1;
const actualOffset = this.#writeScrollRootOffset(requestedOffset);
this.#scrollOffset = actualOffset, this.#pendingScrollOffset = actualOffset;
}
#focus(target) {
const previousTabIndex = target.getAttribute("tabindex");
previousTabIndex === null && target.setAttribute("tabindex", "-1");
try {
target.focus({ preventScroll: !0 });
} catch {
target.focus();
}
previousTabIndex === null ? target.removeAttribute("tabindex") : target.setAttribute("tabindex", previousTabIndex);
}
#findTopVisiblePost(elements) {
const rootRect = this.#scrollRoot.getBoundingClientRect(), visibleTop = rootRect.top + Math.min(
rootRect.height,
finiteNonNegative(this.#readTopInset())
), visibleBottom = rootRect.bottom;
let visibleHeader = null;
const candidates = [];
for (const owner of elements) {
const postNumber = Number(owner.dataset.postNumber);
if (!Number.isSafeInteger(postNumber) || postNumber <= 0 || !owner.isConnected || !this.#scrollRoot.contains(owner) || owner.hidden) continue;
const marker2 = owner.querySelector(":scope > .ldp-post-head") ?? owner, markerRect = marker2.getBoundingClientRect(), candidate = Object.freeze({ postNumber, marker: marker2, markerRect, owner });
if (candidates.push(candidate), markerRect.bottom <= visibleTop + 1 || markerRect.top >= visibleBottom - 1) continue;
const primary = Math.max(0, markerRect.top - visibleTop), secondary = markerRect.top;
(!visibleHeader || primary < visibleHeader.primary || primary === visibleHeader.primary && secondary < visibleHeader.secondary) && (visibleHeader = Object.freeze({ candidate, primary, secondary }));
}
let best = visibleHeader?.candidate ?? null, ownerRect = best ? best.marker === best.owner ? best.markerRect : best.owner.getBoundingClientRect() : null;
if (!best) {
let fallbackPrimary = Number.POSITIVE_INFINITY, fallbackSecondary = Number.POSITIVE_INFINITY;
for (const candidate of candidates) {
const candidateOwnerRect = candidate.marker === candidate.owner ? candidate.markerRect : candidate.owner.getBoundingClientRect();
if (candidateOwnerRect.bottom <= visibleTop + 1 || candidateOwnerRect.top >= visibleBottom - 1) continue;
const primary = Math.abs(candidateOwnerRect.top - visibleTop), secondary = candidateOwnerRect.height;
primary > fallbackPrimary || primary === fallbackPrimary && secondary >= fallbackSecondary || (best = candidate, ownerRect = candidateOwnerRect, fallbackPrimary = primary, fallbackSecondary = secondary);
}
}
if (!best || !ownerRect) return null;
const marker = visibleHeader ? best.marker : best.owner, markerOffset = visibleHeader ? best.markerRect.top - visibleTop : ownerRect.top - visibleTop;
return Object.freeze({
postNumber: best.postNumber,
marker,
markerOffset,
owner: best.owner,
ownerOffset: ownerRect.top - visibleTop
});
}
#notifyWindowChange() {
for (const listener of [...this.#windowChangeListeners]) listener();
}
#markUserScrollIntent(direction = 0, direct = !0) {
const now = finiteNonNegative(this.#now()), startsSession = !this.#userScrollSessionActive || this.#lastUserScrollAt <= 0 || now - this.#lastUserScrollAt > USER_SCROLL_SESSION_GAP_MS;
if (this.#cancelPendingStationaryViewportLock(), this.#releaseStationaryViewport(), direction !== 0 && (this.#lastUserScrollDirection = direction), this.#userScrollSessionActive = !0, this.#lastUserScrollAt = Math.max(
Number.EPSILON,
now
), !!startsSession) {
for (const listener of [...this.#userScrollIntentListeners]) listener();
if (direct)
for (const listener of [...this.#directUserScrollIntentListeners])
listener();
}
}
#markUserScrollProgress() {
if (!this.#userScrollSessionActive) return;
const now = finiteNonNegative(this.#now());
if (this.#lastUserScrollAt <= 0 || now - this.#lastUserScrollAt > USER_SCROLL_SESSION_GAP_MS) {
this.#userScrollSessionActive = !1;
return;
}
this.#lastUserScrollAt = Math.max(Number.EPSILON, now);
}
#finishUserScrollSession() {
this.#userScrollSessionActive && (this.#userScrollSessionActive = !1, this.#lastUserScrollAt = Math.max(
Number.EPSILON,
finiteNonNegative(this.#now())
), this.#requestStationaryViewportLock());
}
#requestStationaryViewportLock() {
this.#stationaryLockPending = !0, this.#stationaryLockSettleFrames = STATIONARY_LOCK_FALLBACK_FRAMES, !this.#viewportMutationAnchor && this.#scheduleStationaryViewportLock();
}
#scheduleStationaryViewportLock() {
if (this.#stationaryLockFrame || this.scope.destroyed || !this.#stationaryLockPending || this.#userScrollSessionActive || this.#viewportMutationAnchor) return;
let completed = !1;
const handle = this.#requestFrame(() => {
if (completed = !0, this.#stationaryLockFrame = 0, !(this.scope.destroyed || !this.#stationaryLockPending || this.#userScrollSessionActive || this.#viewportMutationAnchor)) {
if (this.#scrollFrame) {
this.#stationaryLockSettleFrames = STATIONARY_LOCK_FALLBACK_FRAMES, this.#scheduleStationaryViewportLock();
return;
}
if (this.#stationaryLockSettleFrames = Math.max(
0,
this.#stationaryLockSettleFrames - 1
), this.#stationaryLockSettleFrames > 0) {
this.#scheduleStationaryViewportLock();
return;
}
this.#stationaryLockPending = !1, this.#lockStationaryViewport();
}
});
completed || (this.#stationaryLockFrame = handle);
}
#cancelPendingStationaryViewportLock() {
this.#stationaryLockPending = !1, this.#stationaryLockSettleFrames = 0, this.#stationaryLockFrame && (this.#cancelFrame(this.#stationaryLockFrame), this.#stationaryLockFrame = 0);
}
#lockStationaryViewport() {
this.#releaseStationaryViewport();
const visible = this.#findTopVisiblePost(
Array.from(
this.#scrollRoot.querySelectorAll(
".ldp-post[data-post-number]"
)
)
);
visible && (this.#stationaryAnchor = Object.freeze({
postNumber: visible.postNumber,
markerRole: visible.marker === visible.owner ? "owner" : "header",
markerOffset: visible.markerOffset,
ownerOffset: visible.ownerOffset
}), this.#syncViewportAnchorClass(), this.#stationaryMutationObserver?.observe(this.#scrollRoot, {
attributes: !0,
attributeFilter: ["class", "hidden", "style"],
characterData: !0,
childList: !0,
subtree: !0
}), this.#observeStationaryContentSize());
}
#releaseStationaryViewport() {
this.#stationaryAnchor = null, this.#stationaryContentObservationDirty = !1, this.#stationaryRestoreFrame && (this.#cancelFrame(this.#stationaryRestoreFrame), this.#stationaryRestoreFrame = 0), this.#stationaryMutationObserver?.disconnect(), this.#stationaryResizeObserver?.disconnect(), this.#syncViewportAnchorClass();
}
#restoreViewportMutation(token) {
const anchor = this.#viewportMutationAnchor;
if (!(!anchor || anchor.token !== token))
try {
const owner = this.#scrollRoot.querySelector(
`.ldp-post[data-post-number="${anchor.postNumber}"]`
);
if (!owner || owner.hidden) return;
const header = anchor.markerRole === "header" ? owner.querySelector(":scope > .ldp-post-head") : null, marker = header ?? owner, initialMarkerOffset = header ? anchor.markerOffset : anchor.ownerOffset, currentScrollTop = finiteNonNegative(this.#scrollRoot.scrollTop), expectedMarkerOffset = initialMarkerOffset - (currentScrollTop - anchor.scrollTop), rootRect = this.#scrollRoot.getBoundingClientRect(), visibleTop = rootRect.top + Math.min(
rootRect.height,
finiteNonNegative(this.#readTopInset())
), correction = marker.getBoundingClientRect().top - visibleTop - expectedMarkerOffset;
if (Math.abs(correction) < 0.5) return;
const nextOffset = Math.max(0, currentScrollTop + correction), actualOffset = this.#writeScrollRootOffset(nextOffset);
this.#scrollOffset = actualOffset, this.#pendingScrollOffset = actualOffset, this.#scrollOffsetDirty = !1;
} finally {
this.#cancelViewportMutation(token, !0);
}
}
#cancelViewportMutation(token, settleStationary = !1) {
if (!(token !== void 0 && this.#viewportMutationAnchor?.token !== token)) {
if (this.#viewportMutationAnchor = null, settleStationary && this.#stationaryLockPending && !this.#userScrollSessionActive) {
this.#requestStationaryViewportLock(), this.#syncViewportAnchorClass();
return;
}
settleStationary || this.#cancelPendingStationaryViewportLock(), this.#syncViewportAnchorClass();
}
}
#syncViewportAnchorClass() {
this.#scrollRoot.classList.toggle(
"ldp-stream-viewport-anchor",
this.#stationaryAnchor !== null || this.#viewportMutationAnchor !== null
), this.#scrollRoot.classList.toggle(
"ldp-stream-viewport-mutation",
this.#viewportMutationAnchor !== null
);
}
#observeStationaryContentSize() {
const observer = this.#stationaryResizeObserver;
if (!observer || !this.#stationaryAnchor) return;
observer.disconnect();
const stream = this.#scrollRoot.querySelector(
".ldp-virtual-stream"
);
stream && observer.observe(stream);
}
#scheduleStationaryViewportRestore() {
if (this.#stationaryRestoreFrame || this.scope.destroyed || !this.#stationaryAnchor || this.#userScrollSessionActive) return;
let completed = !1;
const handle = this.#requestFrame(() => {
completed = !0, this.#stationaryRestoreFrame = 0, !(this.scope.destroyed || !this.#stationaryAnchor || this.#userScrollSessionActive) && (this.#stationaryContentObservationDirty && (this.#stationaryContentObservationDirty = !1, this.#observeStationaryContentSize()), this.#restoreStationaryViewport());
});
completed || (this.#stationaryRestoreFrame = handle);
}
#restoreStationaryViewport() {
const anchor = this.#stationaryAnchor;
if (!anchor || this.#userScrollSessionActive) return;
const owner = this.#scrollRoot.querySelector(
`.ldp-post[data-post-number="${anchor.postNumber}"]`
);
if (!owner || owner.hidden) return;
const header = anchor.markerRole === "header" ? owner.querySelector(":scope > .ldp-post-head") : null, marker = header ?? owner, expectedOffset = header ? anchor.markerOffset : anchor.ownerOffset, rootRect = this.#scrollRoot.getBoundingClientRect(), visibleTop = rootRect.top + Math.min(
rootRect.height,
finiteNonNegative(this.#readTopInset())
), correction = marker.getBoundingClientRect().top - visibleTop - expectedOffset;
if (Math.abs(correction) < 0.5) return;
const nextOffset = Math.max(
0,
finiteNonNegative(this.#scrollRoot.scrollTop) + correction
), actualOffset = this.#writeScrollRootOffset(nextOffset);
this.#scrollOffset = actualOffset, this.#pendingScrollOffset = actualOffset, this.#scrollOffsetDirty = !1;
}
#writeScrollRootOffset(offset) {
this.#internalScrollWriteOffset = offset;
let written = !1;
if (typeof this.#scrollRoot.scrollTo == "function")
try {
this.#scrollRoot.scrollTo({ top: offset, behavior: "instant" }), written = !0;
} catch {
}
written || (this.#scrollRoot.scrollTop = offset);
const actualOffset = finiteNonNegative(
this.#scrollRoot.scrollTop
);
return this.#internalScrollWriteOffset = actualOffset, actualOffset;
}
#claimScrollOnlyUserInput(actualOffset) {
const internalOffset = this.#internalScrollWriteOffset;
if (this.#internalScrollWriteOffset = null, internalOffset !== null && Math.abs(actualOffset - internalOffset) < 0.5)
return this.#pendingExternalKeyboardScroll = null, !1;
const direction = this.#directionOf(actualOffset - this.#scrollOffset);
if (this.#userScrollSessionActive)
return this.#pendingExternalKeyboardScroll = null, !0;
const pendingKeyboard = this.#pendingExternalKeyboardScroll;
return pendingKeyboard && finiteNonNegative(this.#now()) - pendingKeyboard.at <= USER_SCROLL_SESSION_GAP_MS && direction !== 0 && (pendingKeyboard.direction === 0 || pendingKeyboard.direction === direction) ? (this.#pendingExternalKeyboardScroll = null, this.#markUserScrollIntent(direction, !1), !0) : (pendingKeyboard && (this.#pendingExternalKeyboardScroll = null), !this.#stationaryAnchor && !this.#stationaryLockPending ? !1 : (this.#markUserScrollIntent(direction, !1), !0));
}
#directionOf(delta) {
return !Number.isFinite(delta) || Math.abs(delta) < 0.5 ? 0 : delta < 0 ? -1 : 1;
}
#keyboardDirection(event) {
return event.key === "ArrowUp" || event.key === "PageUp" || event.key === "Home" || event.key === " " && event.shiftKey ? -1 : event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || event.key === " " ? 1 : 0;
}
#keyboardScrollOffset(event) {
const current = finiteNonNegative(this.#scrollRoot.scrollTop), maximum = Math.max(
0,
finiteNonNegative(this.#scrollRoot.scrollHeight) - finiteNonNegative(this.#scrollRoot.clientHeight)
);
if (event.key === "Home") return 0;
if (event.key === "End") return maximum;
const direction = this.#keyboardDirection(event), distance = event.key === "ArrowUp" || event.key === "ArrowDown" ? 40 : Math.max(1, finiteNonNegative(this.#scrollRoot.clientHeight) * 0.875);
return Math.min(maximum, Math.max(0, current + direction * distance));
}
#scheduleScrollCommit() {
if (this.#scrollFrame || this.scope.destroyed) return;
let completed = !1;
const handle = this.#requestFrame(() => {
completed = !0, this.#scrollFrame = 0;
const hinted = this.#scrollFrameHinted;
if (this.#scrollFrameHinted = !1, this.scope.destroyed) return;
const scrollOffset = this.#refreshPendingScrollOffset();
this.#pendingScrollOffset = null, scrollOffset !== this.#scrollOffset && (this.#scrollOffset = scrollOffset, hinted || this.#notifyWindowChange());
});
completed || (this.#scrollFrame = handle);
}
#refreshPendingScrollOffset() {
return this.#scrollOffsetDirty && (this.#pendingScrollOffset = finiteNonNegative(
this.#scrollRoot.scrollTop
), this.#scrollOffsetDirty = !1), this.#pendingScrollOffset ?? this.#scrollOffset;
}
}
}, "29424c45778c4881c8f3c49d097f566616dff32b7247425eb74f2c2e7f9ece82");
/* Source: lite/src/topic/reader-topic-scroll-lifecycle.ts */
runtime.register("src/topic/reader-topic-scroll-lifecycle.js", function(module, exports, require) {
var reader_topic_scroll_lifecycle_exports = {};
__export(reader_topic_scroll_lifecycle_exports, {
ReaderTopicScrollLifecycle: () => ReaderTopicScrollLifecycle
});
module.exports = __toCommonJS(reader_topic_scroll_lifecycle_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
class ReaderTopicScrollLifecycle {
scope;
#readLastUserScrollAt;
#readIdleMs;
#scheduler;
#now;
#idleHandle = null;
#idlePromise = null;
#idleResolve = null;
#minimumIdleMs = 0;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#readLastUserScrollAt = options.readLastUserScrollAt, this.#readIdleMs = options.readIdleMs, this.#scheduler = options.scheduler, this.#now = options.now ?? (() => performance.now()), this.scope.add(() => {
this.#idleHandle !== null && this.#scheduler.cancel(this.#idleHandle), this.#idleHandle = null;
const resolve = this.#idleResolve;
this.#idleResolve = null, this.#idlePromise = null, this.#minimumIdleMs = 0, resolve?.();
});
}
lastUserScrollAt() {
const value = Number(this.#readLastUserScrollAt());
return Number.isFinite(value) && value > 0 ? value : 0;
}
remainingIdleMs(minimumIdleMs = 120) {
const configuredIdleMs = Number(this.#readIdleMs()), idleMs = Math.max(
Math.max(0, Number(minimumIdleMs) || 0),
Number.isFinite(configuredIdleMs) ? configuredIdleMs : 0
), lastUserScrollAt = this.lastUserScrollAt();
return lastUserScrollAt <= 0 ? 0 : Math.max(0, idleMs - (this.#now() - lastUserScrollAt));
}
isIdle(minimumIdleMs = 120) {
return this.remainingIdleMs(minimumIdleMs) <= 0;
}
waitForIdle(minimumIdleMs = 120) {
if (this.scope.destroyed) return Promise.resolve();
this.#minimumIdleMs = Math.max(
this.#minimumIdleMs,
Math.max(0, Number(minimumIdleMs) || 0)
);
const remainingMs = this.remainingIdleMs(this.#minimumIdleMs);
return remainingMs <= 0 ? (this.#minimumIdleMs = 0, Promise.resolve()) : (this.#idlePromise || (this.#idlePromise = new Promise((resolve) => {
this.#idleResolve = resolve;
})), this.#scheduleIdleCheck(remainingMs), this.#idlePromise);
}
destroy() {
this.scope.destroy();
}
#scheduleIdleCheck(delayMs) {
this.#idleHandle !== null && this.#scheduler.cancel(this.#idleHandle), this.#idleHandle = this.#scheduler.schedule(() => {
this.#idleHandle = null;
const remainingMs = this.remainingIdleMs(this.#minimumIdleMs);
if (!this.scope.destroyed && remainingMs > 0) {
this.#scheduleIdleCheck(remainingMs);
return;
}
const resolve = this.#idleResolve;
this.#idleResolve = null, this.#idlePromise = null, this.#minimumIdleMs = 0, resolve?.();
}, Math.max(0, delayMs));
}
}
}, "78b9e8af68ce9f98c3d2d31a24b01cdee20e2bc67ef8989adee2ad6c4cf6ca2d");
/* Source: lite/src/topic/reader-topic-special-content-feature.ts */
runtime.register("src/topic/reader-topic-special-content-feature.js", function(module, exports, require) {
var reader_topic_special_content_feature_exports = {};
__export(reader_topic_special_content_feature_exports, {
ReaderTopicSpecialContentFeature: () => ReaderTopicSpecialContentFeature,
normalizeReaderSolvedAnswers: () => normalizeReaderSolvedAnswers
});
module.exports = __toCommonJS(reader_topic_special_content_feature_exports);
var import_html_element = require("../dom/html-element.js"), import_reader_icon = require("../components/reader-icon.js"), import_reader_inline_emoji = require("../components/reader-inline-emoji.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_value_record = require("../kernel/value-record.js");
const EMPTY_RECORD = Object.freeze({});
function text(value) {
return String(value ?? "").trim();
}
function postNumber(value) {
const numeric = Number((0, import_value_record.valueRecord)(value)?.post_number ?? value);
return Number.isSafeInteger(numeric) && numeric > 1 ? numeric : null;
}
function acceptedCandidates(topic, posts) {
const byPostNumber = /* @__PURE__ */ new Map(), add = (value) => {
const number = postNumber(value);
number !== null && byPostNumber.set(number, Object.freeze({
...byPostNumber.get(number) ?? {},
...(0, import_value_record.valueRecord)(value) ?? {},
post_number: number
}));
};
if (Array.isArray(topic.accepted_answers))
for (const answer of topic.accepted_answers) add(answer);
add(topic.accepted_answer);
for (const postValue of posts) {
const post = (0, import_value_record.valueRecord)(postValue);
post?.accepted_answer === !0 && add(post);
}
return Object.freeze([...byPostNumber.values()].sort((left, right) => Number(left.post_number) - Number(right.post_number)));
}
function normalizeReaderSolvedAnswers(topicValue, posts, presentation) {
const topic = (0, import_value_record.valueRecord)(topicValue) ?? EMPTY_RECORD, postsByNumber = /* @__PURE__ */ new Map();
for (const postValue of posts) {
const post = (0, import_value_record.valueRecord)(postValue), number = Number(post?.post_number);
post && Number.isSafeInteger(number) && number > 0 && postsByNumber.set(number, post);
}
return Object.freeze(
acceptedCandidates(topic, posts).map((candidate) => {
const number = Number(candidate.post_number), canonical = postsByNumber.get(number) ?? EMPTY_RECORD, username = text(candidate.username ?? canonical.username), name = text(
candidate.name ?? canonical.name ?? username
) || "已解决回复", avatarTemplate = text(
candidate.avatar_template ?? canonical.avatar_template
);
return Object.freeze({
postNumber: number,
username,
name,
avatarSource: presentation.avatarSource(avatarTemplate, 32),
createdAt: text(
candidate.created_at ?? canonical.created_at
),
cooked: text(candidate.cooked ?? canonical.cooked),
excerpt: text(candidate.excerpt ?? canonical.excerpt)
});
})
);
}
function specialBadges(post) {
const badges = [], add = (label, tone = "", title = "", icon = "") => {
badges.push(Object.freeze({ label, tone, title, icon }));
}, postType = Number(post.post_type);
return postType === 4 && add("私信回复"), postType === 2 && add("管理操作", "warn"), post.wiki === !0 && add("Wiki"), post.deleted_at && add("已删除", "danger"), post.locked === !0 && add("已锁定", "warn"), Object.freeze(badges);
}
function postIdentityBadge(post) {
const username = text(post.username).toLocaleLowerCase();
if (Number(post.post_type) === 3 || username === "system")
return Object.freeze({
label: "系统",
title: "系统账户",
icon: "settings"
});
if (post.moderator === !0 || post.group_moderator === !0)
return Object.freeze({
label: "版主",
title: "版主",
icon: "shield-halved"
});
const notice = (0, import_value_record.valueRecord)(post.notice), noticeType = text(notice?.type);
if (noticeType === "new_user")
return Object.freeze({
label: "新用户",
title: "新用户,首次发帖",
icon: "user-plus"
});
if (noticeType === "returning_user")
return Object.freeze({
label: "回归用户",
title: "回归用户,久未发帖",
icon: "rotate-ccw"
});
if (noticeType !== "custom") return null;
const source = `${text(notice?.raw)} ${text(notice?.cooked)}`;
return /\bpremium\b/i.test(source) ? Object.freeze({ label: "Premium", title: "Premium", icon: "check" }) : Object.freeze({ label: "富可敌国", title: "富可敌国", icon: "tag" });
}
const SYSTEM_ACTION_LABELS = Object.freeze({
closed: "主题已关闭",
opened: "主题已重新开放",
archived: "主题已归档",
unarchived: "主题已取消归档",
pinned: "主题已置顶",
unpinned: "主题已取消置顶",
autoclosed: "主题已自动关闭",
"autoclosed.enabled": "主题已自动关闭",
"autoclosed.disabled": "主题已自动重新开放",
split_topic: "帖子已拆分到新主题",
merged: "主题已合并",
moved: "帖子已移动",
visible: "主题已公开",
invisible: "主题已隐藏",
"visible.enabled": "主题已公开",
"visible.disabled": "主题已取消公开",
renamed: "主题标题已修改",
assigned: "已指定"
}), SYSTEM_ACTION_ICONS = Object.freeze({
visible: "check",
invisible: "eye-off",
"visible.enabled": "check",
"visible.disabled": "eye-off",
assigned: "user-plus"
});
class ReaderTopicSpecialContentFeature {
scope;
#document;
#session;
#presentation;
#relativeTime;
#navigate;
#renderIcon;
#actions;
#commands;
#descriptors;
#models;
#loadPostVotingComments;
#onBodyLayerChanged;
#onError;
#boundViews = /* @__PURE__ */ new WeakSet();
#starterViews = /* @__PURE__ */ new Set();
#views = /* @__PURE__ */ new Map();
#expandedComments = /* @__PURE__ */ new WeakSet();
#pendingPostIds = /* @__PURE__ */ new Map();
#loadingCommentPostIds = /* @__PURE__ */ new Set();
constructor(options) {
this.#document = options.document, this.#session = options.session, this.#presentation = options.presentation, this.#relativeTime = options.relativeTime, this.#navigate = options.navigate, this.#renderIcon = options.renderIcon ?? null, this.#actions = options.actions ?? null, this.#commands = options.commands ?? null, this.#descriptors = options.descriptors ?? null, this.#models = options.models ?? null, this.#loadPostVotingComments = options.loadPostVotingComments ?? null, this.#onBodyLayerChanged = options.onBodyLayerChanged ?? null, this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), (options.presentationChanges ?? this.#session.changes).subscribe(() => {
for (const view of [...this.#starterViews]) {
if (view.scope.destroyed) {
this.#starterViews.delete(view);
continue;
}
this.#renderSolved(view) && this.#notifyBodyLayerChanged(view);
}
}, this.scope), this.#actions?.events.subscribe((event) => {
const postIds = event.presentation?.postIds ?? [];
if (event.phase === "pending")
for (const postId of postIds)
this.#pendingPostIds.set(
postId,
(this.#pendingPostIds.get(postId) ?? 0) + 1
);
else if (event.phase === "settled")
for (const postId of postIds) {
const next = (this.#pendingPostIds.get(postId) ?? 1) - 1;
next > 0 ? this.#pendingPostIds.set(postId, next) : this.#pendingPostIds.delete(postId);
}
else
return;
this.#refreshInteractiveViews(new Set(postIds));
}, this.scope), this.scope.add(() => {
this.#starterViews.clear(), this.#views.clear(), this.#pendingPostIds.clear(), this.#loadingCommentPostIds.clear();
});
}
afterRender(postValue, view) {
const post = (0, import_value_record.valueRecord)(postValue) ?? EMPTY_RECORD;
this.#views.set(view, Number(post.post_number)), this.#bindView(view), this.#renderRootState(post, view), this.#renderSpecialBadges(post, view), this.#renderNotice(post, view), this.#renderSystemAction(post, view), this.#renderPostEvent(post, view), Number(post.post_number) === 1 && (this.#starterViews.add(view), this.#renderSolved(view)), this.#renderPostVoting(post, view);
}
destroy() {
this.scope.destroy();
}
#bindView(view) {
this.#boundViews.has(view) || (this.#boundViews.add(view), view.scope.listen(view.slots.root, "click", (event) => {
this.#onClick(view, event);
}), view.scope.listen(view.slots.root, "submit", (event) => {
this.#onSubmit(view, event);
}), view.scope.add(() => {
this.#starterViews.delete(view), this.#views.delete(view);
}));
}
#refreshInteractiveViews(postIds) {
for (const [view, number] of [...this.#views]) {
if (view.scope.destroyed) {
this.#views.delete(view);
continue;
}
const post = this.#session.postByNumber(number);
if (!post) continue;
const postId = Number(post.id);
if (postIds && !postIds.has(postId)) continue;
const source = (0, import_value_record.valueRecord)(post) ?? EMPTY_RECORD;
this.#renderInteractiveBody(source, view) && this.#notifyBodyLayerChanged(view);
}
}
#currentPost(view) {
const number = this.#views.get(view) ?? view.postNumber;
return this.#session.postByNumber(number) ?? null;
}
#onClick(view, event) {
const target = event.target, solved = target?.closest(
"[data-reader-solved-post-number]"
);
if (solved && view.slots.bodyLayer.contains(solved)) {
const number = Number(solved.dataset.readerSolvedPostNumber);
if (!Number.isSafeInteger(number) || number < 2) return;
event.preventDefault(), new Promise((resolve) => {
resolve(this.#navigate(number));
}).catch((error) => {
!this.scope.destroyed && !view.scope.destroyed && this.#onError(error);
});
return;
}
const post = this.#currentPost(view);
if (!post) return;
const source = (0, import_value_record.valueRecord)(post) ?? EMPTY_RECORD, postId = Number(source.id);
if (!Number.isSafeInteger(postId) || postId < 1) return;
if (target?.closest(
"[data-pv-comments-toggle]"
)) {
event.preventDefault(), this.#expandedComments.has(view) ? this.#expandedComments.delete(view) : this.#expandedComments.add(view), this.#renderPostVoting(source, view), this.#notifyBodyLayerChanged(view);
return;
}
if (target?.closest(
"[data-pv-comments-more]"
)) {
event.preventDefault(), this.#loadMorePostVotingComments(post, view);
return;
}
const vote = target?.closest("[data-pv-vote]");
if (vote) {
event.preventDefault();
const direction = text(vote.dataset.pvVote);
if (direction !== "up" && direction !== "down") return;
const current = text(source.post_voting_user_voted_direction);
this.#runAction(() => {
if (!this.#descriptors || !this.#commands)
throw new Error("Post Voting 动作端口尚未就绪");
const mutation = this.#descriptors.postVotingVote({
postId,
direction,
remove: current === direction
});
return this.#commands.postVotingVote(postId, mutation);
});
return;
}
const commentVote = target?.closest(
"[data-pv-comment-vote]"
);
if (commentVote) {
event.preventDefault();
const commentNode = commentVote.closest(
"[data-pv-comment-id]"
), commentId = Number(commentNode?.dataset.pvCommentId);
if (!Number.isSafeInteger(commentId) || commentId < 1) return;
const comment = this.#postVotingComments(source).find((entry) => Number(entry.id) === commentId);
this.#runAction(() => {
if (!this.#descriptors || !this.#commands)
throw new Error("Post Voting 评论动作端口尚未就绪");
const remove = comment?.user_voted === !0, mutation = this.#descriptors.postVotingCommentVote({
commentId,
remove
});
return this.#commands.postVotingCommentVote(
postId,
commentId,
remove,
mutation
);
});
return;
}
const attendance = target?.closest(
"[data-event-status]"
);
if (!attendance) return;
event.preventDefault();
const status = text(attendance.dataset.eventStatus), eventData = (0, import_value_record.valueRecord)(source.event), eventId = Number(eventData?.id) || postId;
!eventData || !Number.isSafeInteger(eventId) || eventId < 1 || this.#runAction(() => {
if (!this.#descriptors || !this.#commands || !this.#models)
throw new Error("Discourse 活动动作端口尚未就绪");
const invitee = (0, import_value_record.valueRecord)(eventData.watching_invitee), eventModel = this.#models.createPostEvent(eventData, postId), mutation = this.#descriptors.eventAttendance({
eventId,
event: eventModel,
status,
alreadyInvited: Number(invitee?.id) > 0
});
return this.#commands.eventAttendance(postId, mutation);
});
}
#onSubmit(view, event) {
const form = event.target?.closest(
".ldp-pv-comment-form"
);
if (!form || !view.slots.root.contains(form)) return;
event.preventDefault();
const post = this.#currentPost(view), source = (0, import_value_record.valueRecord)(post), postId = Number(source?.id);
if (!post || !Number.isSafeInteger(postId) || postId < 1) return;
const input = form.querySelector(
".ldp-pv-comment-input"
), raw = text(input?.value);
raw && this.#runAction(() => {
if (!this.#descriptors || !this.#commands)
throw new Error("Post Voting 评论动作端口尚未就绪");
const mutation = this.#descriptors.postVotingCommentCreate({
postId,
raw
});
return this.#commands.postVotingCommentCreate(postId, mutation);
}).then(() => {
!this.scope.destroyed && !view.scope.destroyed && input && (input.value = "");
});
}
async #runAction(create) {
try {
if (!this.#actions) throw new Error("楼层动作控制器尚未就绪");
await this.#actions.dispatch(create());
} catch (error) {
this.scope.destroyed || this.#onError(error);
}
}
async #loadMorePostVotingComments(post, view) {
const source = (0, import_value_record.valueRecord)(post) ?? EMPTY_RECORD, postId = Number(source.id);
if (!this.#loadPostVotingComments || !Number.isSafeInteger(postId) || postId < 1 || this.#loadingCommentPostIds.has(postId)) return;
const loaded = this.#postVotingComments(source), afterCommentId = Number(loaded.at(-1)?.id) || 0;
this.#loadingCommentPostIds.add(postId), this.#renderPostVoting(source, view), this.#notifyBodyLayerChanged(view);
try {
const payload = await this.#loadPostVotingComments(
postId,
afterCommentId
), payloadRecord = (0, import_value_record.valueRecord)(payload), incomingSource = Array.isArray(payload) ? payload : Array.isArray(payloadRecord?.comments) ? payloadRecord.comments : [], merged = /* @__PURE__ */ new Map();
for (const comment of [...loaded, ...incomingSource]) {
const candidate = (0, import_value_record.valueRecord)(comment), id = Number(candidate?.id);
candidate && Number.isSafeInteger(id) && id > 0 && merged.set(id, Object.freeze({ ...candidate }));
}
this.#session.ingestPosts([
{
...source,
post_voting_comments: Object.freeze([...merged.values()])
}
], "action-response");
} catch (error) {
!this.scope.destroyed && !view.scope.destroyed && this.#onError(error);
} finally {
this.#loadingCommentPostIds.delete(postId);
const current = this.#session.postByNumber(Number(source.post_number));
current && !view.scope.destroyed && (this.#renderPostVoting((0, import_value_record.valueRecord)(current) ?? EMPTY_RECORD, view), this.#notifyBodyLayerChanged(view));
}
}
#renderInteractiveBody(post, view) {
const selector = ":scope > :is(.ldp-pv-votes,.ldp-pv-comments,.ldp-event-card)", hadInteractiveBody = !!view.slots.bodyLayer.querySelector(selector);
return this.#renderPostEvent(post, view), this.#renderPostVoting(post, view), hadInteractiveBody || !!view.slots.bodyLayer.querySelector(selector);
}
#notifyBodyLayerChanged(view) {
if (!(this.scope.destroyed || view.scope.destroyed))
try {
this.#onBodyLayerChanged?.(view);
} catch (error) {
this.#onError(error);
}
}
#postVotingComments(post) {
const source = Array.isArray(post.post_voting_comments) ? post.post_voting_comments : Array.isArray(post.comments) ? post.comments : [];
return Object.freeze(source.map((value) => (0, import_value_record.valueRecord)(value)).filter((value) => value !== null));
}
#renderSolved(view) {
if (view.scope.destroyed) return !1;
const answers = normalizeReaderSolvedAnswers(
this.#session.topic,
this.#session.cachedPosts(),
this.#presentation
), previous = view.slots.bodyLayer.querySelector(
":scope > .ldp-solved-card"
);
if (!answers.length)
return previous?.remove(), previous !== null;
const card = (0, import_html_element.htmlElement)(this.#document, "section", "ldp-solved-card"), heading = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-solved-head"), icon = this.#icon("check");
icon && heading.append(icon);
const headingLabel = answers.length > 1 ? `✓ 已解决 · ${answers.length} 个答案` : "✓ 已解决", headingText = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-solved-label",
icon ? headingLabel.replace(/^✓\s*/, "") : headingLabel
);
heading.append(headingText), card.append(heading);
for (const answer of answers)
card.append(this.#answerNode(answer));
return previous?.isEqualNode(card) ? !1 : (previous ? previous.replaceWith(card) : view.slots.bodyLayer.append(card), !0);
}
#answerNode(answer) {
const body = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-solved-body");
body.dataset.solvedPostNumber = String(answer.postNumber);
const authorRow = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-solved-author-row"
), profileHref = this.#presentation.userHref(answer.username);
if (answer.avatarSource) {
const avatarLink = this.#document.createElement(
profileHref ? "a" : "span"
);
avatarLink.className = "ldp-user-link", profileHref && avatarLink.setAttribute("href", profileHref), answer.username && (avatarLink.dataset.userCard = answer.username);
const avatar = (0, import_html_element.htmlElement)(this.#document, "img", "ldp-solved-avatar");
avatar.src = answer.avatarSource, avatar.alt = "", avatar.loading = "lazy", avatar.decoding = "async", avatarLink.append(avatar), authorRow.append(avatarLink);
}
const author = this.#document.createElement(
profileHref ? "a" : "span"
);
if (author.className = "ldp-user-link ldp-solved-author", author.textContent = answer.name, profileHref && author.setAttribute("href", profileHref), answer.username && (author.dataset.userCard = answer.username), authorRow.append(author), answer.username) {
const username = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-solved-username",
`@${answer.username}`
);
authorRow.append(username);
}
const relative = answer.createdAt ? this.#relativeTime(answer.createdAt) : "";
if (relative) {
const time = (0, import_html_element.htmlElement)(this.#document, "span", "", `· ${relative}`);
authorRow.append(time);
}
const floor = (0, import_html_element.htmlElement)(
this.#document,
"button",
"ldp-solved-floor ldp-jump-self",
`#${answer.postNumber}`
);
floor.type = "button", floor.dataset.readerSolvedPostNumber = String(answer.postNumber), floor.setAttribute("aria-label", `跳到楼层 #${answer.postNumber}`), authorRow.append(floor), body.append(authorRow);
const excerpt = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-solved-excerpt ldp-content cooked"
);
answer.cooked ? excerpt.innerHTML = answer.cooked : (0, import_reader_inline_emoji.renderReaderInlineEmoji)(
excerpt,
answer.excerpt || "查看被采纳的完整回复。",
(id) => this.#presentation.emojiSource?.(id) ?? ""
), body.append(excerpt);
const jump = (0, import_html_element.htmlElement)(
this.#document,
"button",
"ldp-solved-jump ldp-jump-self",
"阅读更多"
);
return jump.type = "button", jump.dataset.readerSolvedPostNumber = String(answer.postNumber), body.append(jump), body;
}
#renderPostVoting(post, view) {
view.slots.bodyLayer.querySelector(":scope > .ldp-pv-votes")?.remove(), view.slots.bodyLayer.querySelector(":scope > .ldp-pv-comments")?.remove();
const isAnswer = ((0, import_value_record.valueRecord)(this.#session.topic) ?? EMPTY_RECORD).is_post_voting === !0 && Number(post.post_number) !== 1;
if (view.slots.root.classList.toggle("ldp-post-voting-answer", isAnswer), !isAnswer) return;
const postId = Number(post.id), pending = this.#pendingPostIds.has(postId), votes = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-pv-votes"), direction = text(post.post_voting_user_voted_direction);
for (const [value, label, iconName] of [
["up", "赞同", "chevron-up"],
["down", "反对", "chevron-down"]
]) {
const button = this.#document.createElement("button");
button.type = "button", button.className = `ldp-pv-vote${direction === value ? " on" : ""}`, button.dataset.pvVote = value, button.setAttribute("aria-label", label), button.disabled = pending || !this.#actions;
const icon = this.#icon(iconName);
if (icon ? button.append(icon) : button.textContent = value === "up" ? "↑" : "↓", votes.append(button), value === "up") {
const score = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-pv-score",
String(Math.max(0, Number(post.post_voting_vote_count) || 0))
);
votes.append(score);
}
}
view.slots.bodyLayer.prepend(votes);
const comments = this.#postVotingComments(post), count = Math.max(
0,
Number(post.comments_count) || comments.length
), host = (0, import_html_element.htmlElement)(this.#document, "section", "ldp-pv-comments"), toggle = (0, import_html_element.htmlElement)(
this.#document,
"button",
"ldp-pv-comments-toggle"
);
toggle.type = "button", toggle.dataset.pvCommentsToggle = "";
const expanded = this.#expandedComments.has(view);
toggle.setAttribute("aria-expanded", String(expanded)), toggle.setAttribute(
"aria-label",
expanded ? "收起评论" : "展开评论"
);
const toggleIcon = this.#icon("message-square");
toggleIcon && toggle.append(toggleIcon);
const toggleCount = (0, import_html_element.htmlElement)(this.#document, "span", "", String(count));
toggle.append(toggleCount), host.append(toggle);
const body = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-pv-comments-body");
body.hidden = !expanded;
for (const comment of comments)
body.append(this.#postVotingCommentNode(comment, pending));
if (comments.length < count && this.#loadPostVotingComments) {
const more = (0, import_html_element.htmlElement)(
this.#document,
"button",
"ldp-pv-comments-more"
);
more.type = "button", more.dataset.pvCommentsMore = "", more.disabled = this.#loadingCommentPostIds.has(postId), more.textContent = more.disabled ? "正在加载…" : "加载更多评论", body.append(more);
}
if (this.#models?.currentUser()) {
const form = (0, import_html_element.htmlElement)(this.#document, "form", "ldp-pv-comment-form"), input = (0, import_html_element.htmlElement)(this.#document, "input", "ldp-pv-comment-input");
input.name = "raw", input.autocomplete = "off", input.placeholder = "写评论…", input.required = !0, input.disabled = pending;
const submit = (0, import_html_element.htmlElement)(
this.#document,
"button",
"ldp-pv-comment-submit",
"发送"
);
submit.type = "submit", submit.disabled = pending, form.append(input, submit), body.append(form);
}
host.append(body), view.slots.bodyLayer.append(host);
}
#postVotingCommentNode(comment, pending) {
const user = (0, import_value_record.valueRecord)(comment.user) ?? EMPTY_RECORD, username = text(comment.username ?? user.username), name = text(comment.name ?? user.name ?? username) || "用户", node = (0, import_html_element.htmlElement)(this.#document, "article", "ldp-pv-comment"), commentId = Number(comment.id);
Number.isSafeInteger(commentId) && commentId > 0 && (node.dataset.pvCommentId = String(commentId));
const avatarTemplate = text(
user.avatar_template ?? comment.avatar_template
), avatarSource = this.#presentation.avatarSource(avatarTemplate, 28), avatarHost = this.#document.createElement(
username ? "a" : "span"
);
if (avatarHost.className = "ldp-user-link", username) {
avatarHost.dataset.userCard = username;
const href = this.#presentation.userHref(username);
href && avatarHost.setAttribute("href", href);
}
if (avatarSource) {
const avatar = (0, import_html_element.htmlElement)(
this.#document,
"img",
"ldp-pv-comment-avatar"
);
avatar.src = avatarSource, avatar.alt = "", avatar.loading = "lazy", avatar.decoding = "async", avatarHost.append(avatar);
}
node.append(avatarHost);
const content = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-pv-comment-body"), meta = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-pv-comment-meta",
[
name,
username ? `@${username}` : "",
text(comment.created_at) ? this.#relativeTime(text(comment.created_at)) : ""
].filter(Boolean).join(" · ")
), cooked = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-content cooked"), cookedValue = text(comment.cooked);
cookedValue ? cooked.innerHTML = cookedValue : cooked.textContent = text(comment.raw), content.append(meta, cooked), node.append(content);
const vote = this.#document.createElement("button");
vote.type = "button", vote.className = `ldp-pv-comment-vote${comment.user_voted === !0 ? " on" : ""}`, vote.dataset.pvCommentVote = "", vote.setAttribute("aria-label", "赞同评论"), vote.disabled = pending || !this.#actions || !Number.isSafeInteger(commentId) || commentId < 1;
const icon = this.#icon("chevron-up");
icon && vote.append(icon);
const count = (0, import_html_element.htmlElement)(
this.#document,
"span",
"",
String(Math.max(0, Number(comment.post_voting_vote_count) || 0))
);
return vote.append(count), node.append(vote), node;
}
#renderPostEvent(post, view) {
view.slots.bodyLayer.querySelector(":scope > .ldp-event-card")?.remove();
const event = (0, import_value_record.valueRecord)(post.event);
if (!event) return;
const postId = Number(post.id), pending = this.#pendingPostIds.has(postId), card = (0, import_html_element.htmlElement)(this.#document, "section", "ldp-event-card"), title = (0, import_html_element.htmlElement)(
this.#document,
"h3",
"ldp-event-title",
text(event.name) || "活动"
);
card.append(title);
const grid = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-event-grid"), dateLabel = this.#eventDateLabel(event);
if (dateLabel) {
const date = this.#document.createElement("div"), strong = (0, import_html_element.htmlElement)(this.#document, "b", "", dateLabel);
date.append(strong);
const timezone = text(event.timezone);
if (timezone) {
const zone = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-event-meta",
` ${timezone}`
);
date.append(zone);
}
grid.append(date);
}
const locationRecord = (0, import_value_record.valueRecord)(event.location), location = typeof event.location == "string" ? text(event.location) : text(
locationRecord?.name ?? locationRecord?.address ?? locationRecord?.display
);
if (location) {
const place = (0, import_html_element.htmlElement)(
this.#document,
"div",
"",
`地点:${location}`
);
grid.append(place);
}
const description = text(event.description_html);
if (description) {
const detail = (0, import_html_element.htmlElement)(this.#document, "div", "cooked");
detail.innerHTML = description, grid.append(detail);
}
const stats = (0, import_value_record.valueRecord)(event.stats) ?? EMPTY_RECORD, statsNode = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-event-meta"), going = Math.max(0, Number(stats.going) || 0), interested = Math.max(0, Number(stats.interested) || 0), maxAttendees = Math.max(0, Number(event.max_attendees) || 0);
statsNode.textContent = [
`参加 ${going}`,
`感兴趣 ${interested}`,
maxAttendees ? `名额 ${going}/${maxAttendees}` : "",
event.is_ongoing === !0 ? "进行中" : "",
event.is_expired === !0 ? "已结束" : ""
].filter(Boolean).join(" · "), grid.append(statsNode), card.append(grid);
const actions = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-event-actions"), watching = (0, import_value_record.valueRecord)(event.watching_invitee) ?? EMPTY_RECORD, currentStatus = text(watching.status), closed = event.is_closed === !0 || event.is_expired === !0 || event.can_update_attendance === !1;
for (const [status, label] of [
["going", "参加"],
["interested", "感兴趣"],
["not_going", "不参加"]
]) {
const button = (0, import_html_element.htmlElement)(
this.#document,
"button",
`ldp-event-action${currentStatus === status ? " on" : ""}`,
label
);
button.type = "button", button.dataset.eventStatus = status, button.disabled = closed || pending || !this.#actions || !this.#models, actions.append(button);
}
const calendarUrl = text(event.ics_url ?? event.calendar_url);
if (/^(?:https?:\/\/|\/)/i.test(calendarUrl)) {
const calendar = (0, import_html_element.htmlElement)(
this.#document,
"a",
"ldp-event-ics",
"下载日历"
);
calendar.href = calendarUrl, calendar.download = "", actions.append(calendar);
}
card.append(actions), view.slots.bodyLayer.append(card);
}
#eventDateLabel(event) {
const options = {
year: "numeric",
month: "short",
day: "numeric",
...event.all_day === !0 ? {} : { hour: "2-digit", minute: "2-digit" }
}, format = (value) => {
const date = new Date(text(value));
return Number.isFinite(date.getTime()) ? date.toLocaleString("zh-CN", options) : "";
};
return [format(event.starts_at), format(event.ends_at)].filter(Boolean).join(" — ");
}
#icon(name) {
return (0, import_reader_icon.renderReaderIcon)(this.#document, name, this.#renderIcon);
}
#renderRootState(post, view) {
const postType = Number(post.post_type), actionCode = text(post.action_code);
view.slots.root.classList.toggle("ldp-whisper", postType === 4), view.slots.root.classList.toggle(
"ldp-system-post",
postType === 2 || postType === 3
), view.slots.root.classList.toggle(
"ldp-system-action-compact",
(postType === 2 || postType === 3) && actionCode === "assigned"
), this.#renderIdentityBadge(post, view);
}
#renderIdentityBadge(post, view) {
view.slots.header.querySelector(".ldp-new-user-badge")?.remove();
const noticeType = text((0, import_value_record.valueRecord)(post.notice)?.type);
view.slots.root.classList.toggle(
"ldp-new-user",
noticeType === "new_user"
);
const identity = postIdentityBadge(post);
if (!identity) return;
const badge = (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-new-user-badge"
);
badge.dataset.ldpTooltipLabel = identity.title, badge.setAttribute("role", "img"), badge.setAttribute("aria-label", identity.title), badge.append(
this.#icon(identity.icon),
(0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-post-identity-label",
identity.label
)
);
const existingHost = view.slots.header.querySelector(
":scope > .ldp-post-identity-badges"
), host = existingHost ?? (0, import_html_element.htmlElement)(
this.#document,
"span",
"ldp-post-identity-badges"
);
existingHost || view.slots.header.append(host), host.append(badge);
}
#renderSpecialBadges(post, view) {
view.slots.bodyLayer.querySelector(":scope > .ldp-special-badges")?.remove();
const badges = specialBadges(post);
if (!badges.length) return;
const host = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-special-badges");
for (const badge of badges) {
const node = this.#document.createElement("span");
node.className = `ldp-special-badge ${badge.tone}`.trim(), badge.title && (node.dataset.ldpTooltipLabel = badge.title), badge.icon && node.append(this.#icon(badge.icon));
const label = (0, import_html_element.htmlElement)(this.#document, "span", "", badge.label);
node.append(label), host.append(node);
}
view.slots.bodyLayer.prepend(host);
}
#renderNotice(post, view) {
view.slots.bodyLayer.querySelector(":scope > .ldp-special-notice")?.remove();
const notice = (0, import_value_record.valueRecord)(post.notice), noticeType = text(notice?.type);
if (["new_user", "returning_user", "custom"].includes(noticeType))
return;
const label = typeof post.notice == "string" ? text(post.notice) : text(notice?.text ?? noticeType);
if (!label) return;
const node = (0, import_html_element.htmlElement)(
this.#document,
"div",
"ldp-special-notice",
label
);
view.slots.bodyLayer.prepend(node);
}
#renderSystemAction(post, view) {
view.slots.bodyLayer.querySelector(":scope > .ldp-system-action")?.remove();
const postType = Number(post.post_type), actionCode = text(post.action_code);
if (postType !== 2 && postType !== 3 || !actionCode) return;
const node = (0, import_html_element.htmlElement)(this.#document, "div", "ldp-system-action");
node.append(this.#icon(
SYSTEM_ACTION_ICONS[actionCode] ?? "settings"
));
const label = SYSTEM_ACTION_LABELS[actionCode] ?? `系统操作:${actionCode.replace(/[._]/g, " ")}`, content = this.#document.createElement("span");
if (content.append(this.#document.createTextNode(label)), actionCode === "assigned") {
const who = (0, import_value_record.valueRecord)(post.action_code_who), username = text(who?.username ?? post.action_code_who).replace(/^@/, "");
if (username) {
content.append(this.#document.createTextNode("给 "));
const user = (0, import_html_element.htmlElement)(
this.#document,
"a",
"ldp-user-link ldp-system-action-user",
`@${username}`
);
user.dataset.userCard = username;
const href = this.#presentation.userHref(username);
href && (user.href = href), content.append(user);
}
}
node.append(content), view.slots.bodyLayer.prepend(node);
}
}
}, "2ec0ea5494934f6c9dfb883429f8511b96069ca94bc846fa62795d4ebec1e54f");
/* Source: lite/src/topic/reader-topic-timeline-controller.ts */
runtime.register("src/topic/reader-topic-timeline-controller.js", function(module, exports, require) {
var reader_topic_timeline_controller_exports = {};
__export(reader_topic_timeline_controller_exports, {
ReaderTopicTimelineController: () => ReaderTopicTimelineController
});
module.exports = __toCommonJS(reader_topic_timeline_controller_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js");
function normalizedTotal(value) {
const numeric = Math.floor(Number(value));
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : 1;
}
function normalizedNavigablePosts(values, totalPostCount) {
if (!values?.length) return null;
const postNumbers = /* @__PURE__ */ new Set();
for (const value of values) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(value);
postNumber !== null && postNumber <= totalPostCount && postNumbers.add(postNumber);
}
return postNumbers.size ? Object.freeze([...postNumbers].sort((left, right) => left - right)) : null;
}
function clampedPostNumber(value, totalPostCount) {
return (0, import_identifiers.discoursePostReference)({
post_number: Math.max(1, Math.min(totalPostCount, Math.floor(value) || 1))
}).postNumber;
}
function snapshotsEqual(left, right) {
if (left.currentPostNumber !== right.currentPostNumber || left.totalPostCount !== right.totalPostCount || left.progress !== right.progress || left.pendingPostNumber !== right.pendingPostNumber)
return !1;
const leftPosts = left.navigablePostNumbers, rightPosts = right.navigablePostNumbers;
return leftPosts === rightPosts || leftPosts !== null && rightPosts !== null && leftPosts.length === rightPosts.length && leftPosts.every((postNumber, index) => postNumber === rightPosts[index]);
}
class ReaderTopicTimelineController {
scope;
changes = new import_signal.Signal();
#navigation;
#readTotalPostCount;
#readNavigablePostNumbers;
#readNavigablePostNumbersComplete;
#resolveEndPostNumber;
#onError;
#snapshot;
#jumpEpoch = 0;
#resolvedEndPostNumber = null;
#resolvedEndTotalPostCount = 0;
#heldVisiblePostNumber = null;
#visiblePostHoldGeneration = 0;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#navigation = options.navigation, this.#readTotalPostCount = options.readTotalPostCount, this.#readNavigablePostNumbers = options.readNavigablePostNumbers ?? (() => null), this.#resolveEndPostNumber = options.resolveEndPostNumber ?? null, this.#readNavigablePostNumbersComplete = options.readNavigablePostNumbersComplete ?? (() => !0), this.#onError = options.onError ?? (() => {
}), this.#snapshot = this.#derive(
options.initialPostNumber ?? 1,
null
), this.#navigation.changes.subscribe((result) => {
if (result.status === "revealed") {
if (this.#heldVisiblePostNumber !== null) {
this.#commitCachedSources(
this.#heldVisiblePostNumber,
this.#snapshot.pendingPostNumber
);
return;
}
this.#commit(
result.rootPostNumber ?? result.postNumber,
this.#snapshot.pendingPostNumber
);
}
}, this.scope), this.scope.add(() => {
this.#jumpEpoch += 1, this.#clearVisiblePostHold(), this.changes.clear();
});
}
get snapshot() {
return this.#snapshot;
}
refresh() {
return this.#commit(
this.#snapshot.currentPostNumber,
this.#snapshot.pendingPostNumber
);
}
/**
* 历史恢复会在虚拟窗口补齐、测量和锚点补偿期间连续产生程序化 scroll。
* 在用户真正发出滚动意图前固定历史楼层,避免这些布局事件把时间线和宿主
* Topic 列表的“定位”字段改写成相邻物理窗口。返回值只释放本次 generation,
* 旧恢复任务不得误解锁同楼层的新恢复。
*/
holdVisiblePost(postNumber) {
if (this.scope.destroyed) return () => {
};
const target = clampedPostNumber(
postNumber,
this.#snapshot.totalPostCount
), generation = ++this.#visiblePostHoldGeneration;
this.#heldVisiblePostNumber = target, this.#commitCachedSources(target, this.#snapshot.pendingPostNumber);
let active = !0;
return () => {
active && (active = !1, generation === this.#visiblePostHoldGeneration && this.#clearVisiblePostHold());
};
}
syncVisiblePost(postNumber, options = {}) {
if (this.#heldVisiblePostNumber !== null)
return this.#commitCachedSources(
this.#heldVisiblePostNumber,
this.#snapshot.pendingPostNumber
);
const navigable = this.#snapshot.navigablePostNumbers, boundaryPostNumber = options.atStart ? navigable?.[0] ?? 1 : options.atEnd ? navigable?.at(-1) ?? this.#resolvedEndForCurrentTotal() ?? this.#snapshot.totalPostCount : postNumber;
return this.#commitCachedSources(
boundaryPostNumber,
this.#snapshot.pendingPostNumber
);
}
progressFor(postNumber) {
const target = clampedPostNumber(postNumber, this.#snapshot.totalPostCount), posts = this.#snapshot.navigablePostNumbers;
if (posts) {
const index = posts.indexOf(target);
if (index >= 0) return posts.length > 1 ? index / (posts.length - 1) : 0;
}
return this.#snapshot.totalPostCount > 1 ? (target - 1) / (this.#snapshot.totalPostCount - 1) : 0;
}
targetAtRatio(ratio) {
const normalizedRatio = Number.isFinite(ratio) ? Math.max(0, Math.min(1, ratio)) : 0, posts = this.#snapshot.navigablePostNumbers;
return posts ? posts[Math.round(normalizedRatio * (posts.length - 1))] : clampedPostNumber(
Math.round(1 + normalizedRatio * (this.#snapshot.totalPostCount - 1)),
this.#snapshot.totalPostCount
);
}
targetAtEnd() {
const navigable = this.#snapshot.navigablePostNumbers;
if (navigable?.length) return navigable.at(-1);
const resolved = this.#resolvedEndForCurrentTotal();
return resolved !== null ? resolved : clampedPostNumber(
this.#snapshot.totalPostCount,
this.#snapshot.totalPostCount
);
}
targetByStep(currentPostNumber, delta) {
const posts = this.#snapshot.navigablePostNumbers;
if (posts) {
const current = (0, import_identifiers.tryDiscoursePostNumber)(currentPostNumber), currentIndex = current === null ? -1 : posts.indexOf(current), baseIndex = currentIndex >= 0 ? currentIndex : 0, nextIndex = Number.isFinite(delta) ? Math.max(
0,
Math.min(posts.length - 1, baseIndex + Math.trunc(delta))
) : delta < 0 ? 0 : posts.length - 1;
return posts[nextIndex];
}
const numericDelta = Number.isFinite(delta) ? Math.trunc(delta) : delta < 0 ? -this.#snapshot.totalPostCount : this.#snapshot.totalPostCount;
return clampedPostNumber(
currentPostNumber + numericDelta,
this.#snapshot.totalPostCount
);
}
validateInput(rawValue) {
const total = this.#snapshot.totalPostCount;
if (!rawValue)
return Object.freeze({
postNumber: null,
message: `请输入楼层号(1–${total})`
});
if (!/^\d+$/.test(rawValue))
return Object.freeze({
postNumber: null,
message: "仅支持十进制整数"
});
const value = Number(rawValue);
return Number.isSafeInteger(value) ? value < 1 || value > total ? Object.freeze({
postNumber: null,
message: `超出范围,请输入 1–${total}`
}) : Object.freeze({
postNumber: (0, import_identifiers.discoursePostReference)({ post_number: value }).postNumber,
message: ""
}) : Object.freeze({
postNumber: null,
message: "楼层号数值过大"
});
}
async jumpTo(postNumber, options = {}) {
if (this.scope.destroyed)
throw new Error("ReaderTopicTimelineController 已销毁");
this.#clearVisiblePostHold();
const target = (0, import_identifiers.discoursePostReference)({ post_number: postNumber }).postNumber;
if (target > this.#snapshot.totalPostCount)
throw new RangeError(`目标楼层超出范围:1–${this.#snapshot.totalPostCount}`);
const epoch = ++this.#jumpEpoch;
return this.#commit(this.#snapshot.currentPostNumber, target), this.#navigate(target, options, epoch);
}
async jumpToEnd(options = {}) {
const knownEnd = this.#snapshot.navigablePostNumbers?.at(-1) ?? this.#resolvedEndForCurrentTotal() ?? void 0;
if (knownEnd !== void 0 || this.#resolveEndPostNumber === null)
return this.jumpTo(knownEnd ?? this.targetAtEnd(), options);
if (this.scope.destroyed)
throw new Error("ReaderTopicTimelineController 已销毁");
this.#clearVisiblePostHold();
const fallback = this.targetAtEnd(), epoch = ++this.#jumpEpoch;
this.#commit(this.#snapshot.currentPostNumber, fallback);
try {
const resolved = (0, import_identifiers.tryDiscoursePostNumber)(
await this.#resolveEndPostNumber()
);
if (epoch !== this.#jumpEpoch || this.scope.destroyed)
return Object.freeze({
postNumber: fallback,
source: "timeline",
status: "superseded",
rootPostNumber: null,
mounted: !1
});
if (resolved === null || resolved > this.#snapshot.totalPostCount)
throw new Error("无法确认 Topic 末尾的正文回复");
return this.#resolvedEndPostNumber = resolved, this.#resolvedEndTotalPostCount = this.#snapshot.totalPostCount, this.#commit(this.#snapshot.currentPostNumber, resolved), await this.#navigate(resolved, options, epoch);
} catch (error) {
throw epoch === this.#jumpEpoch && !this.scope.destroyed && this.#commit(this.#snapshot.currentPostNumber, null), error;
}
}
destroy() {
this.scope.destroy();
}
async #navigate(target, options, epoch) {
try {
return await this.#navigation.navigate({
postNumber: target,
source: "timeline",
...options.alignment === void 0 ? {} : { alignment: options.alignment },
...options.focus === void 0 ? {} : { focus: options.focus },
...options.highlight === void 0 ? {} : { highlight: options.highlight }
});
} finally {
epoch === this.#jumpEpoch && !this.scope.destroyed && this.#commit(this.#snapshot.currentPostNumber, null);
}
}
#derive(currentPostNumber, pendingPostNumber) {
const totalPostCount = normalizedTotal(this.#readTotalPostCount()), navigablePostNumbers = this.#readNavigablePostNumbersComplete() ? normalizedNavigablePosts(
this.#readNavigablePostNumbers(),
totalPostCount
) : null;
return this.#deriveFromSources(
currentPostNumber,
pendingPostNumber,
totalPostCount,
navigablePostNumbers
);
}
#deriveFromSources(currentPostNumber, pendingPostNumber, totalPostCount, navigablePostNumbers) {
const current = clampedPostNumber(currentPostNumber, totalPostCount), progress = navigablePostNumbers?.includes(current) ? navigablePostNumbers.length > 1 ? navigablePostNumbers.indexOf(current) / (navigablePostNumbers.length - 1) : 0 : totalPostCount > 1 ? (current - 1) / (totalPostCount - 1) : 0;
return Object.freeze({
currentPostNumber: current,
totalPostCount,
progress,
pendingPostNumber: pendingPostNumber !== null && pendingPostNumber <= totalPostCount ? pendingPostNumber : null,
navigablePostNumbers
});
}
#commitCachedSources(currentPostNumber, pendingPostNumber) {
return this.#accept(this.#deriveFromSources(
currentPostNumber,
pendingPostNumber,
this.#snapshot.totalPostCount,
this.#snapshot.navigablePostNumbers
));
}
#commit(currentPostNumber, pendingPostNumber) {
return this.#accept(this.#derive(currentPostNumber, pendingPostNumber));
}
#accept(next) {
if (snapshotsEqual(this.#snapshot, next)) return this.#snapshot;
this.#snapshot = next;
for (const error of this.changes.emit(next)) this.#onError(error);
return next;
}
#clearVisiblePostHold() {
this.#heldVisiblePostNumber = null, this.#visiblePostHoldGeneration += 1;
}
#resolvedEndForCurrentTotal() {
return this.#resolvedEndTotalPostCount === this.#snapshot.totalPostCount ? this.#resolvedEndPostNumber : null;
}
}
}, "f1862ce535decde4a07b4d9194e76ce51180e975965729eccd53b1a5f28d950b");
/* Source: lite/src/topic/reader-topic-timeline-end-resolver.ts */
runtime.register("src/topic/reader-topic-timeline-end-resolver.js", function(module, exports, require) {
var reader_topic_timeline_end_resolver_exports = {};
__export(reader_topic_timeline_end_resolver_exports, {
resolveReaderTopicTimelineEndPostNumber: () => resolveReaderTopicTimelineEndPostNumber
});
module.exports = __toCommonJS(reader_topic_timeline_end_resolver_exports);
var import_identifiers = require("../discourse/identifiers.js");
function latestRootInContiguousTail(posts, total) {
const references = posts.flatMap((post) => {
try {
return [(0, import_identifiers.discoursePostReference)(post)];
} catch {
return [];
}
}).filter(({ postNumber }) => postNumber <= total).sort((left, right) => right.postNumber - left.postNumber);
let expected = Number(total);
for (const reference of references) {
if (reference.postNumber !== expected) return null;
if (reference.replyToPostNumber === null) return reference.postNumber;
expected -= 1;
}
return null;
}
async function resolveReaderTopicTimelineEndPostNumber(port) {
const total = (0, import_identifiers.tryDiscoursePostNumber)(port.readTotalPostCount());
if (total === null) return null;
const cachedRoot = latestRootInContiguousTail(
port.readCachedPosts?.() ?? Object.freeze([]),
total
);
if (cachedRoot !== null) return cachedRoot;
let boundary = total, firstBatch = !0;
for (; ; ) {
const references = (firstBatch ? await port.loadAround(boundary) : await port.loadBefore(boundary)).flatMap((post) => {
try {
return [(0, import_identifiers.discoursePostReference)(post)];
} catch {
return [];
}
});
if (!references.length) return null;
const earliest = Math.min(...references.map(({ postNumber }) => postNumber)), latestRoot = references.filter(
({ postNumber, replyToPostNumber }) => postNumber <= boundary && replyToPostNumber === null
).sort((left, right) => right.postNumber - left.postNumber).at(0)?.postNumber;
if (latestRoot !== void 0) return latestRoot;
if (!firstBatch && earliest >= boundary || earliest <= 1) return null;
boundary = (0, import_identifiers.discoursePostReference)({
post_number: earliest
}).postNumber, firstBatch = !1;
}
}
}, "3df6c890eda730ec309632d4b2647ca95cc821aa692fad53276dd236a27c1040");
/* Source: lite/src/topic/reader-topic-timeline-view.ts */
runtime.register("src/topic/reader-topic-timeline-view.js", function(module, exports, require) {
var reader_topic_timeline_view_exports = {};
__export(reader_topic_timeline_view_exports, {
ReaderTopicTimelineView: () => ReaderTopicTimelineView,
readerTimelineDateLabel: () => readerTimelineDateLabel
});
module.exports = __toCommonJS(reader_topic_timeline_view_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
function normalizedPreferences(value) {
const numeric = Math.floor(Number(value.pageStep));
return Object.freeze({
pageStep: Number.isSafeInteger(numeric) && numeric > 0 ? Math.min(64, numeric) : 16
});
}
function normalizedInset(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric >= 0 ? numeric : 10;
}
function readerTimelineDateLabel(timestamp) {
if (!timestamp) return "";
const date = new Date(timestamp);
return Number.isFinite(date.getTime()) ? `${date.getFullYear()} 年
${date.getMonth() + 1} 月 ${date.getDate()} 日` : "";
}
function pointerCoordinate(event) {
const value = Number(event.clientY);
return Number.isFinite(value) ? value : 0;
}
function pointerIdentifier(event) {
const value = Number(event.pointerId);
return Number.isFinite(value) ? value : 0;
}
function eventPathIncludes(event, node) {
const composedPath = event.composedPath;
if (typeof composedPath == "function")
return composedPath.call(event).includes(node);
const target = event.target;
return target !== null && typeof target == "object" && "nodeType" in target && node.contains(target);
}
class ReaderTopicTimelineView {
scope;
#controller;
#elements;
#readCreatedAt;
#readLatestReplyAt;
#formatRelative;
#reachEnd;
#frameScheduler;
#animationFrameScheduler;
#scheduleTimer;
#cancelTimer;
#now;
#prefersReducedMotion;
#trackTopInset;
#trackBottomInset;
#notify;
#onError;
#lensElements = /* @__PURE__ */ new Map();
#spareLensElements = [];
#preferences;
#previewFrame = 0;
#jumpAnimationFrame = 0;
#jumpAnimationTimer = 0;
#jumpAnimationEpoch = 0;
#previewClientY = 0;
#previewVisible = !1;
#pointerInside = !1;
#dragging = !1;
#activePointerId = 0;
#trackRect = null;
constructor(options) {
this.#controller = options.controller, this.#elements = options.elements, this.#readCreatedAt = options.readCreatedAt, this.#readLatestReplyAt = options.readLatestReplyAt, this.#formatRelative = options.formatRelative, this.#reachEnd = options.reachEnd ?? (async () => {
}), this.#trackTopInset = normalizedInset(options.trackTopInset), this.#trackBottomInset = normalizedInset(options.trackBottomInset), this.#notify = options.notify ?? (() => {
}), this.#onError = options.onError ?? (() => {
}), this.#preferences = normalizedPreferences(options.preferences);
const view = this.#elements.timeline.ownerDocument.defaultView;
this.#frameScheduler = options.frameScheduler ?? Object.freeze({
request(callback) {
return typeof view?.requestAnimationFrame == "function" ? view.requestAnimationFrame(callback) : (callback(0), 0);
},
cancel(frameId) {
view?.cancelAnimationFrame?.(frameId);
}
}), this.#animationFrameScheduler = options.animationFrameScheduler ?? Object.freeze({
request(callback) {
return typeof view?.requestAnimationFrame == "function" ? view.requestAnimationFrame(callback) : view?.setTimeout(
() => callback(Date.now()),
16
) ?? globalThis.setTimeout(
() => callback(Date.now()),
16
);
},
cancel(frameId) {
typeof view?.cancelAnimationFrame == "function" ? view.cancelAnimationFrame(frameId) : view ? view.clearTimeout(frameId) : globalThis.clearTimeout(frameId);
}
}), this.#scheduleTimer = options.scheduleTimer ?? ((callback, delayMs) => view?.setTimeout(callback, delayMs) ?? globalThis.setTimeout(callback, delayMs)), this.#cancelTimer = options.cancelTimer ?? ((timerId) => {
view ? view.clearTimeout(timerId) : globalThis.clearTimeout(timerId);
}), this.#now = options.now ?? Date.now, this.#prefersReducedMotion = options.prefersReducedMotion ?? (() => view?.matchMedia?.("(prefers-reduced-motion: reduce)").matches === !0), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const timelineDocument = this.#elements.timeline.ownerDocument, timelineWindow = timelineDocument.defaultView;
let relativeTimer = null;
const stopRelativeTimer = () => {
relativeTimer !== null && (timelineWindow?.clearInterval(relativeTimer), relativeTimer = null);
}, syncRelativeTimer = () => {
stopRelativeTimer(), !(!timelineWindow || timelineDocument.visibilityState === "hidden") && (relativeTimer = timelineWindow.setInterval(() => {
this.scope.destroyed || this.#syncRelativeTime();
}, 3e4));
};
this.#listen(
timelineDocument,
"visibilitychange",
syncRelativeTimer
), this.scope.add(stopRelativeTimer), syncRelativeTimer();
const {
root,
date,
track,
relative,
jump,
top,
jumpForm,
jumpInput
} = this.#elements;
this.#listen(date, "click", () => {
this.#submitJump(1);
}), this.#listen(top, "click", () => {
this.#submitJump(1);
}), this.#listen(relative, "click", () => {
this.#submitEndJump();
}), this.#listen(jump, "click", () => {
jumpForm.hidden ? this.#openJumpForm() : this.#closeJumpForm(!0);
}), this.#listen(jumpInput, "input", () => {
this.#validateJumpInput();
}), this.#listen(jumpForm, "submit", (event) => {
event.preventDefault();
const validation = this.#validateJumpInput();
validation !== null && (this.#closeJumpForm(), this.#submitJump(validation));
}), this.#listen(jumpForm, "keydown", (event) => {
const key = event.key;
key === "Enter" && this.#elements.jumpSubmit.disabled ? (event.preventDefault(), event.stopPropagation()) : key === "Escape" && (event.preventDefault(), event.stopPropagation(), this.#closeJumpForm(!0));
}), this.#listen(root.getRootNode(), "pointerdown", (event) => {
!jumpForm.hidden && !eventPathIncludes(event, jumpForm) && !eventPathIncludes(event, jump) && this.#closeJumpForm();
}, !0), this.#listen(track, "pointerenter", (event) => {
this.#pointerInside = !0, this.#refreshTrackRect(), this.#showPointerPreview(pointerCoordinate(event));
}), this.#listen(track, "pointerleave", () => {
this.#pointerInside = !1, this.#dragging || this.#hidePointerPreview();
}), this.#listen(track, "pointerdown", (event) => {
this.#dragging || (event.preventDefault(), this.#dragging = !0, this.#activePointerId = pointerIdentifier(event), this.#refreshTrackRect(), this.#capturePointer(this.#activePointerId), this.#showPointerPreview(pointerCoordinate(event)));
}), this.#listen(track, "pointermove", (event) => {
this.#previewVisible && this.#showPointerPreview(pointerCoordinate(event));
}), this.#listen(track, "pointerup", (event) => {
const pointerId = pointerIdentifier(event);
if (!this.#dragging || pointerId !== this.#activePointerId) return;
this.#dragging = !1, this.#releasePointer(pointerId), this.#activePointerId = 0;
const target = this.#controller.targetAtRatio(
this.#pointerPosition(pointerCoordinate(event)).ratio
);
this.#pointerInside || this.#hidePointerPreview(), this.#submitJump(target);
}), this.#listen(track, "pointercancel", (event) => {
!this.#dragging || pointerIdentifier(event) !== this.#activePointerId || (this.#dragging = !1, this.#releasePointer(this.#activePointerId), this.#activePointerId = 0, this.#hidePointerPreview(), this.#sync(this.#controller.snapshot));
}), this.#listen(track, "keydown", (event) => {
this.#onTrackKeyDown(event);
}), this.#controller.changes.subscribe((snapshot) => {
this.#sync(snapshot);
}, this.scope), this.scope.add(() => {
this.#finishJumpAnimation(!1), this.#elements.track.classList.remove("ldp-timeline-pending"), this.#previewFrame && (this.#frameScheduler.cancel(this.#previewFrame), this.#previewFrame = 0), this.#releasePointer(this.#activePointerId), this.#closeJumpForm(), this.#hidePointerPreview(), this.#elements.cursor.replaceChildren(), this.#lensElements.clear(), this.#spareLensElements.length = 0, this.#elements.timeline.hidden = !0;
}), this.#sync(this.#controller.snapshot);
}
applyPreferences(value) {
this.#assertActive(), this.#preferences = normalizedPreferences(value);
}
refresh() {
this.#assertActive(), this.#sync(this.#controller.snapshot);
}
focusJump() {
this.#assertActive(), this.#openJumpForm();
}
destroy() {
this.scope.destroy();
}
#sync(snapshot) {
const {
timeline,
date,
track,
current,
total,
jump,
top,
jumpInput
} = this.#elements, hidden = snapshot.totalPostCount <= 1;
timeline.hidden !== hidden && (timeline.hidden = hidden);
const progress = String(snapshot.progress);
timeline.style.getPropertyValue("--ldp-timeline-progress") !== progress && timeline.style.setProperty("--ldp-timeline-progress", progress), this.#setText(current, String(snapshot.currentPostNumber)), this.#setText(total, String(snapshot.totalPostCount)), this.#setAttribute(
track,
"data-timeline-total",
String(snapshot.totalPostCount)
), this.#setAttribute(track, "aria-valuemin", "1"), this.#setAttribute(track, "aria-valuemax", String(snapshot.totalPostCount)), this.#setAttribute(track, "aria-valuenow", String(snapshot.currentPostNumber)), this.#setAttribute(
track,
"aria-valuetext",
`第 ${snapshot.currentPostNumber} 楼,共 ${snapshot.totalPostCount} 楼`
), this.#setAttribute(
jump,
"aria-label",
`跳到指定楼层,当前第 ${snapshot.currentPostNumber} 楼,共 ${snapshot.totalPostCount} 楼`
);
const maxLength = String(snapshot.totalPostCount).length + 1;
jumpInput.maxLength !== maxLength && (jumpInput.maxLength = maxLength), this.#setAttribute(
jumpInput,
"aria-label",
`楼层号,范围 1 到 ${snapshot.totalPostCount}`
);
const createdAt = this.#readCreatedAt(snapshot.currentPostNumber), dateText = readerTimelineDateLabel(createdAt);
this.#setText(date, dateText);
const pending = snapshot.pendingPostNumber !== null;
this.#syncRelativeTime(pending), track.classList.contains("ldp-timeline-pending") !== pending && track.classList.toggle("ldp-timeline-pending", pending), this.#setAttribute(timeline, "aria-busy", String(pending)), this.#setDisabled(track, pending), this.#setDisabled(date, pending || !dateText), this.#setDisabled(jump, pending), this.#setDisabled(top, pending), this.#setDisabled(jumpInput, pending), this.#elements.jumpForm.hidden || this.#validateJumpInput();
}
#syncRelativeTime(pending = this.#controller.snapshot.pendingPostNumber !== null) {
const latestReplyAt = this.#readLatestReplyAt() ?? "";
let relativeText = "";
if (latestReplyAt)
try {
relativeText = this.#formatRelative(latestReplyAt);
} catch (error) {
this.#report(error);
}
this.#setText(this.#elements.relative, relativeText), this.#setDisabled(
this.#elements.relative,
pending || !relativeText
);
}
#setAttribute(element, name, value) {
element.getAttribute(name) !== value && element.setAttribute(name, value);
}
#setText(element, value) {
element.textContent !== value && (element.textContent = value);
}
#setDisabled(element, disabled) {
element.disabled !== disabled && (element.disabled = disabled);
}
#onTrackKeyDown(event) {
const current = this.#controller.snapshot.currentPostNumber;
let delta;
if (event.key === "ArrowUp" || event.key === "ArrowLeft") delta = -1;
else if (event.key === "ArrowDown" || event.key === "ArrowRight") delta = 1;
else if (event.key === "PageUp") delta = -this.#preferences.pageStep;
else if (event.key === "PageDown") delta = this.#preferences.pageStep;
else if (event.key === "Home") delta = Number.NEGATIVE_INFINITY;
else if (event.key === "End") delta = Number.POSITIVE_INFINITY;
else return;
event.preventDefault(), this.#submitJump(this.#controller.targetByStep(current, delta));
}
#openJumpForm() {
this.#hidePointerPreview();
const { jump, jumpForm, jumpInput } = this.#elements;
jumpInput.value = String(this.#controller.snapshot.currentPostNumber), jumpForm.hidden = !1, jump.setAttribute("aria-expanded", "true"), this.#validateJumpInput(), jumpInput.focus(), jumpInput.select?.();
}
#closeJumpForm(restoreFocus = !1) {
const { jump, jumpForm } = this.#elements;
jumpForm.hidden || (jumpForm.hidden = !0, jump.setAttribute("aria-expanded", "false"), restoreFocus && jump.focus());
}
#validateJumpInput() {
const {
jumpForm,
jumpInput,
jumpSubmit,
jumpHint
} = this.#elements, validation = this.#controller.validateInput(jumpInput.value), invalid = validation.postNumber === null;
return jumpInput.setAttribute("aria-invalid", String(invalid)), jumpForm.classList.toggle("is-invalid", invalid), jumpHint.textContent = invalid ? validation.message : `有效范围:1–${this.#controller.snapshot.totalPostCount}`, jumpSubmit.disabled = invalid || this.#controller.snapshot.pendingPostNumber !== null, validation.postNumber;
}
#submitJump(postNumber, animate = !0) {
this.scope.destroyed || this.#controller.snapshot.pendingPostNumber !== null || (animate ? this.#beginJumpAnimation(postNumber) : this.#finishJumpAnimation(!1), this.#controller.jumpTo(postNumber).then((result) => {
this.scope.destroyed || result.status !== "revealed" && result.status !== "superseded" && this.#notify(`暂时无法定位到楼层 #${postNumber},可重试`);
}).catch((error) => {
this.scope.destroyed || (this.#report(error), this.#notify(`楼层 #${postNumber} 加载失败,请重试`));
}));
}
#submitEndJump() {
this.scope.destroyed || this.#controller.snapshot.pendingPostNumber !== null || (this.#finishJumpAnimation(!1), this.#controller.jumpToEnd({
alignment: "end",
focus: !1,
highlight: !1
}).then(async (result) => {
this.scope.destroyed || result.status === "superseded" || await this.#reachEnd();
}).catch((error) => {
this.scope.destroyed || (this.#report(error), this.#notify("拉到帖子底部失败,请重试"));
}));
}
#beginJumpAnimation(postNumber) {
if (this.#finishJumpAnimation(!1), this.#prefersReducedMotion() || this.scope.destroyed) return;
const snapshot = this.#controller.snapshot, currentRatio = this.#ratioForPost(
snapshot.currentPostNumber,
snapshot
), targetRatio = this.#ratioForPost(postNumber, snapshot), startRatio = this.#previewVisible && this.#lensElements.get(postNumber)?.classList.contains(
"ldp-timeline-lens-selected"
) === !0 ? targetRatio : currentRatio;
this.#renderLens(startRatio, this.#topForRatio(startRatio));
const { track, cursor } = this.#elements;
track.classList.remove("ldp-timeline-jumping"), cursor.offsetWidth, track.classList.add("ldp-timeline-jumping");
const epoch = ++this.#jumpAnimationEpoch, durationMs = 360;
let startedAt = null;
const settle = () => {
this.scope.destroyed || epoch !== this.#jumpAnimationEpoch || (this.#jumpAnimationFrame = 0, this.#renderLens(targetRatio, this.#topForRatio(targetRatio)), this.#jumpAnimationTimer = this.#scheduleTimer(() => {
epoch === this.#jumpAnimationEpoch && this.#finishJumpAnimation(!0);
}, 460));
};
if (Math.abs(targetRatio - startRatio) <= 1e-4) {
this.#jumpAnimationFrame = this.#animationFrameScheduler.request(settle);
return;
}
const animate = (timestamp) => {
if (this.scope.destroyed || epoch !== this.#jumpAnimationEpoch) return;
const currentTime = Number.isFinite(timestamp) ? timestamp : this.#now();
startedAt === null && (startedAt = currentTime);
const elapsedRatio = Math.min(
1,
Math.max(0, (currentTime - startedAt) / durationMs)
), easedRatio = 1 - Math.pow(1 - elapsedRatio, 3), ratio = startRatio + (targetRatio - startRatio) * easedRatio;
if (this.#renderLens(ratio, this.#topForRatio(ratio)), elapsedRatio >= 1) {
settle();
return;
}
this.#jumpAnimationFrame = this.#animationFrameScheduler.request(animate);
};
this.#jumpAnimationFrame = this.#animationFrameScheduler.request(animate);
}
#finishJumpAnimation(resumePreview) {
this.#jumpAnimationEpoch += 1, this.#jumpAnimationFrame && (this.#animationFrameScheduler.cancel(this.#jumpAnimationFrame), this.#jumpAnimationFrame = 0), this.#jumpAnimationTimer && (this.#cancelTimer(this.#jumpAnimationTimer), this.#jumpAnimationTimer = 0), this.#elements.track.classList.remove("ldp-timeline-jumping"), resumePreview && this.#previewVisible && this.#showPointerPreview(this.#previewClientY);
}
#ratioForPost(postNumber, snapshot) {
const navigable = snapshot.navigablePostNumbers;
if (navigable?.length) {
const index = navigable.findIndex(
(candidate) => Number(candidate) === postNumber
);
if (index >= 0) return navigable.length === 1 ? 0 : index / (navigable.length - 1);
}
return snapshot.totalPostCount <= 1 ? 0 : Math.max(0, Math.min(
1,
(postNumber - 1) / (snapshot.totalPostCount - 1)
));
}
#topForRatio(ratio) {
const rect = this.#trackRect ?? this.#refreshTrackRect(), usableHeight = Math.max(
1,
rect.height - this.#trackTopInset - this.#trackBottomInset
);
return this.#trackTopInset + Math.max(0, Math.min(1, ratio)) * usableHeight;
}
#showPointerPreview(clientY) {
this.#previewVisible = !0, this.#previewClientY = clientY, !this.#previewFrame && (this.#previewFrame = this.#frameScheduler.request(() => {
if (this.#previewFrame = 0, !this.#previewVisible || this.scope.destroyed) return;
const position = this.#pointerPosition(this.#previewClientY), target = this.#controller.targetAtRatio(position.ratio);
this.#elements.timeline.style.setProperty(
"--ldp-timeline-preview-progress",
String(position.ratio)
), this.#elements.preview.textContent = `#${target}`, this.#elements.track.classList.toggle(
"ldp-timeline-previewing",
target !== this.#controller.snapshot.currentPostNumber
), this.#elements.track.classList.add("ldp-timeline-hovering"), this.#renderLens(position.ratio, position.top);
}));
}
#hidePointerPreview() {
this.#previewVisible = !1, this.#previewFrame && (this.#frameScheduler.cancel(this.#previewFrame), this.#previewFrame = 0), this.#trackRect = null, this.#elements.track.classList.remove(
"ldp-timeline-previewing",
"ldp-timeline-hovering"
);
}
#renderLens(ratio, top) {
const snapshot = this.#controller.snapshot, navigablePosts = snapshot.navigablePostNumbers, floorCount = navigablePosts?.length ?? snapshot.totalPostCount, floorAt = (index) => navigablePosts?.[index] ?? index + 1, continuousIndex = Math.max(
0,
Math.min(floorCount - 1, ratio * (floorCount - 1))
), nearestIndex = Math.round(continuousIndex), selectedFloor = floorAt(nearestIndex), firstIndex = Math.max(0, nearestIndex - 3), lastIndex = Math.min(floorCount - 1, nearestIndex + 3), desired = /* @__PURE__ */ new Map();
for (let index = firstIndex; index <= lastIndex; index += 1)
desired.set(floorAt(index), index - continuousIndex);
for (const [floor, element] of this.#lensElements)
desired.has(floor) || (element.hidden = !0, this.#lensElements.delete(floor), this.#spareLensElements.push(element));
for (const [floor, offset] of desired) {
let element = this.#lensElements.get(floor);
element || (element = this.#spareLensElements.pop() ?? this.#elements.timeline.ownerDocument.createElement("span"), element.textContent = `#${floor}`, element.hidden = !1, this.#lensElements.set(floor, element), element.isConnected || this.#elements.cursor.append(element));
const distance = Math.abs(offset), focus = Math.exp(-0.72 * distance * distance), opacity = Math.pow(Math.max(0, 1 - distance / 3), 1.25), scale = 0.46 + 0.54 * focus, shiftX = -Math.min(6, distance * 2);
element.style.setProperty("--ldp-lens-offset", String(offset)), element.style.setProperty("--ldp-lens-scale", scale.toFixed(4)), element.style.setProperty("--ldp-lens-opacity", opacity.toFixed(4)), element.style.setProperty(
"--ldp-lens-shift-x",
`${shiftX.toFixed(2)}px`
), element.classList.toggle(
"ldp-timeline-lens-selected",
floor === selectedFloor
), element.classList.toggle(
"ldp-timeline-lens-focus",
Math.abs(offset) < 1e-3
);
}
this.#elements.cursor.style.setProperty(
"--ldp-timeline-lens-top",
`${top.toFixed(2)}px`
);
}
#pointerPosition(clientY) {
const rect = this.#trackRect ?? this.#refreshTrackRect(), usableHeight = Math.max(
1,
rect.height - this.#trackTopInset - this.#trackBottomInset
), localTop = Math.max(
this.#trackTopInset,
Math.min(
Math.max(this.#trackTopInset, rect.height - this.#trackBottomInset),
clientY - rect.top
)
);
return Object.freeze({
top: localTop,
ratio: Math.max(
0,
Math.min(1, (localTop - this.#trackTopInset) / usableHeight)
)
});
}
#refreshTrackRect() {
return this.#trackRect = this.#elements.track.getBoundingClientRect(), this.#trackRect;
}
#capturePointer(pointerId) {
const track = this.#elements.track;
if (!(!pointerId || typeof track.setPointerCapture != "function"))
try {
track.setPointerCapture(pointerId);
} catch {
}
}
#releasePointer(pointerId) {
const track = this.#elements.track;
if (!(!pointerId || typeof track.releasePointerCapture != "function"))
try {
(typeof track.hasPointerCapture != "function" || track.hasPointerCapture(pointerId)) && track.releasePointerCapture(pointerId);
} catch {
}
}
#listen(target, type, listener, options) {
this.scope.listen(target, type, listener, options);
}
#report(error) {
try {
this.#onError(error);
} catch {
}
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("ReaderTopicTimelineView 已销毁");
}
}
}, "3006dff9e81cf6bac5222c8878f9f1f4c8e8dd3484f564920fd023c4138091d2");
/* Source: lite/src/topic/topic-read-request-adapter.ts */
runtime.register("src/topic/topic-read-request-adapter.js", function(module, exports, require) {
var topic_read_request_adapter_exports = {};
__export(topic_read_request_adapter_exports, {
TopicReadRequestAdapter: () => TopicReadRequestAdapter
});
module.exports = __toCommonJS(topic_read_request_adapter_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_native_request_descriptors = require("../discourse/native-request-descriptors.js"), import_reader_business_request_policy = require("../network/reader-business-request-policy.js");
function cacheWithPostIds(cache, postIds) {
return Object.freeze({
...cache,
tags: Object.freeze(
[.../* @__PURE__ */ new Set([
...cache.tags,
...postIds.map((postId) => `post:${(0, import_identifiers.discoursePostId)(postId)}`)
])].sort()
)
});
}
function cacheForTopic(cache, topicId) {
return Object.freeze({
...cache,
tags: Object.freeze([
...cache.tags.filter((tag) => !tag.startsWith("topic:")),
`topic:${topicId}`
])
});
}
function linkedRequestSignal(topicSignal, requestSignal) {
if (!requestSignal || requestSignal === topicSignal)
return Object.freeze({ signal: topicSignal, dispose() {
} });
const controller = new AbortController(), abortFromTopic = () => controller.abort(topicSignal.reason), abortFromRequest = () => controller.abort(requestSignal.reason);
return topicSignal.aborted ? abortFromTopic() : requestSignal.aborted ? abortFromRequest() : (topicSignal.addEventListener("abort", abortFromTopic, { once: !0 }), requestSignal.addEventListener("abort", abortFromRequest, { once: !0 })), Object.freeze({
signal: controller.signal,
dispose() {
topicSignal.removeEventListener("abort", abortFromTopic), requestSignal.removeEventListener("abort", abortFromRequest);
}
});
}
function topicBackgroundProfile(options) {
return options.prefetchTier === "nearby" ? "nearby-prefetch" : options.business === import_reader_business_request_policy.READER_TOPIC_DOWNLOAD_REQUEST_POLICY.kind ? import_reader_business_request_policy.READER_TOPIC_DOWNLOAD_REQUEST_POLICY.backgroundProfile : "background-prefetch";
}
function businessBackgroundProfile(options) {
return options.business === import_reader_business_request_policy.READER_TOPIC_DOWNLOAD_REQUEST_POLICY.kind ? import_reader_business_request_policy.READER_TOPIC_DOWNLOAD_REQUEST_POLICY.backgroundProfile : "background-prefetch";
}
class TopicReadRequestAdapter {
topicId;
authScope;
#gateway;
#transport;
#signal;
#caches;
#basePath;
constructor(options) {
this.#gateway = options.gateway, this.#transport = options.transport, this.authScope = (0, import_identifiers.discourseAuthScope)(options.authScope), this.topicId = (0, import_identifiers.discourseTopicId)(options.topicId), this.#signal = options.signal, this.#caches = options.caches, this.#basePath = (0, import_native_request_descriptors.discourseBasePath)(options.basePath);
}
loadTopic(options = {}) {
const descriptor = import_native_request_descriptors.DiscourseNativeRequests[options.background ? "topicPrefetch" : "topic"]({
basePath: this.#basePath,
topicId: this.topicId
});
return this.#gateway.loadTopicTarget({
...options.business === void 0 ? {} : { business: options.business },
authScope: this.authScope,
topicId: this.topicId,
operation: "topic-refresh",
profile: options.background ? topicBackgroundProfile(options) : "topic-visible",
input: descriptor.path,
signal: this.#signal,
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork },
cacheMode: options.refresh === !0 ? "refresh" : "default",
cache: this.#caches.topic,
transport: (input) => this.#transport.request({
descriptor,
signal: input.signal,
attempt: input.attempt
})
});
}
loadPostsByIds(rawPostIds, options = {}) {
const postIds = (0, import_identifiers.discoursePostIds)(rawPostIds), descriptor = import_native_request_descriptors.DiscourseNativeRequests.postsById({
basePath: this.#basePath,
topicId: this.topicId,
postIds
});
return this.#gateway.loadTopicPosts({
...options.business === void 0 ? {} : { business: options.business },
authScope: this.authScope,
topicId: this.topicId,
postIds,
profile: options.background ? topicBackgroundProfile(options) : options.priority === "nested" ? "nested-visible" : "topic-visible",
input: descriptor.path,
signal: this.#signal,
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork },
cacheMode: options.refresh ? "refresh" : "default",
cache: cacheWithPostIds(this.#caches.posts, postIds),
transport: (input) => this.#transport.request({
descriptor,
signal: input.signal,
attempt: input.attempt
})
});
}
promotePostsByIds(rawPostIds, options = {}) {
const postIds = (0, import_identifiers.discoursePostIds)(rawPostIds);
return this.#gateway.promoteTopicPosts?.({
authScope: this.authScope,
topicId: this.topicId,
postIds,
profile: options.background ? topicBackgroundProfile(options) : options.priority === "nested" ? "nested-visible" : "topic-visible",
cacheMode: options.refresh ? "refresh" : "default"
}) ?? !1;
}
loadPostById(rawPostId, options = {}) {
const postId = (0, import_identifiers.discoursePostId)(rawPostId), descriptor = import_native_request_descriptors.DiscourseNativeRequests.postById({
basePath: this.#basePath,
postId
});
return this.#gateway.loadTopicTarget({
...options.business === void 0 ? {} : { business: options.business },
authScope: this.authScope,
topicId: this.topicId,
operation: "post-by-id-refresh",
postId,
profile: options.background ? topicBackgroundProfile(options) : "topic-visible",
input: descriptor.path,
signal: this.#signal,
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork },
cacheMode: "refresh",
cache: cacheWithPostIds(this.#caches.posts, [postId]),
allowStaleOnError: !1,
transport: (input) => this.#transport.request({
descriptor,
signal: input.signal,
attempt: input.attempt
})
});
}
loadPostVotingComments(rawPostId, options = {}) {
const postId = (0, import_identifiers.discoursePostId)(rawPostId), afterCommentId = Number(options.afterCommentId ?? 0);
if (!Number.isSafeInteger(afterCommentId) || afterCommentId < 0)
throw new RangeError("afterCommentId 必须是非负安全整数");
const descriptor = import_native_request_descriptors.DiscourseNativeRequests.postVotingComments({
basePath: this.#basePath,
postId,
afterCommentId
});
return this.#gateway.loadCollectionPage({
...options.business === void 0 ? {} : { business: options.business },
authScope: this.authScope,
collection: `post-voting-comments:${postId}`,
page: afterCommentId,
cursor: afterCommentId,
profile: options.background ? businessBackgroundProfile(options) : "collection-visible",
input: descriptor.path,
signal: this.#signal,
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork },
cacheMode: options.refresh ? "refresh" : "default",
cache: cacheWithPostIds(this.#caches.posts, [postId]),
transport: (input) => this.#transport.request({
descriptor,
signal: input.signal,
attempt: input.attempt
})
});
}
targetCandidates(rawPostNumber, options, rawTopicId = this.topicId) {
const postNumber = (0, import_identifiers.discoursePostNumber)(rawPostNumber), topicId = (0, import_identifiers.discourseTopicId)(rawTopicId);
return Object.freeze(import_native_request_descriptors.DiscourseNativeRequests.targetCandidates({
basePath: this.#basePath,
topicId,
postNumber,
scope: options.scope,
...options.slug === void 0 ? {} : { slug: options.slug },
...options.refresh === void 0 ? {} : { refresh: options.refresh }
}).map(({ endpoint, url }) => Object.freeze({ endpoint, url })));
}
loadTargetCandidate(candidate, rawPostNumber, options, rawTopicId = this.topicId) {
const postNumber = (0, import_identifiers.discoursePostNumber)(rawPostNumber), topicId = (0, import_identifiers.discourseTopicId)(rawTopicId), refresh = options.refresh === !0, catalogCandidate = import_native_request_descriptors.DiscourseNativeRequests.targetCandidates({
basePath: this.#basePath,
topicId,
postNumber,
scope: options.scope,
...options.slug === void 0 ? {} : { slug: options.slug },
refresh
}).find((entry) => entry.endpoint === candidate.endpoint && entry.url === candidate.url);
if (!catalogCandidate)
throw new Error("目标楼层请求不属于 Discourse 原生目录");
const descriptor = catalogCandidate.descriptor;
return this.#gateway.loadTopicTarget({
...options.business === void 0 ? {} : { business: options.business },
authScope: this.authScope,
topicId,
operation: `target:${options.scope}:${candidate.endpoint}`,
postNumber,
profile: options.background ? topicBackgroundProfile(options) : "topic-visible",
input: candidate.url,
signal: this.#signal,
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork },
cacheMode: refresh ? "refresh" : "default",
cache: cacheForTopic(this.#caches.posts, topicId),
allowStaleOnError: !refresh,
transport: (input) => this.#transport.request({
descriptor,
signal: input.signal,
attempt: input.attempt
})
});
}
/** 只读中央响应缓存;不进入 client、Scheduler 或传输层。 */
cachedTargetCandidate(candidate, rawPostNumber, options, rawTopicId = this.topicId) {
if (!this.#gateway.cachedTopicTarget) return Promise.resolve(null);
const postNumber = (0, import_identifiers.discoursePostNumber)(rawPostNumber), topicId = (0, import_identifiers.discourseTopicId)(rawTopicId);
return import_native_request_descriptors.DiscourseNativeRequests.targetCandidates({
basePath: this.#basePath,
topicId,
postNumber,
scope: options.scope,
...options.slug === void 0 ? {} : { slug: options.slug },
refresh: options.refresh === !0
}).find((entry) => entry.endpoint === candidate.endpoint && entry.url === candidate.url) ? this.#gateway.cachedTopicTarget({
authScope: this.authScope,
topicId,
operation: `target:${options.scope}:${candidate.endpoint}`,
postNumber,
profile: options.background ? topicBackgroundProfile(options) : "topic-visible",
cache: cacheForTopic(this.#caches.posts, topicId)
}) : Promise.reject(new Error("目标楼层缓存查询不属于 Discourse 原生目录"));
}
loadNestedReplies(rawParentPostNumber, options = {}) {
const parentPostNumber = (0, import_identifiers.discoursePostNumber)(rawParentPostNumber), parentPostId = options.parentPostId === void 0 ? void 0 : (0, import_identifiers.discoursePostId)(options.parentPostId), after = (0, import_identifiers.discourseReplyCursor)(options.after);
if (parentPostId === void 0)
throw new Error("直属回复 endpoint 需要 parentPostId");
const descriptor = import_native_request_descriptors.DiscourseNativeRequests.directReplies({
basePath: this.#basePath,
parentPostId,
after
}), requestLifetime = linkedRequestSignal(
this.#signal,
options.signal
);
return this.#gateway.loadNestedReplies({
...options.business === void 0 ? {} : { business: options.business },
authScope: this.authScope,
topicId: this.topicId,
parentPostNumber,
parentPostId,
after,
profile: options.background ? businessBackgroundProfile(options) : "nested-visible",
input: descriptor.path,
signal: requestLifetime.signal,
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork },
cacheMode: options.refresh ? "refresh" : "default",
cache: cacheWithPostIds(this.#caches.nested, [parentPostId]),
transport: (input) => this.#transport.request({
descriptor,
signal: input.signal,
attempt: input.attempt
})
}).finally(() => requestLifetime.dispose());
}
promoteNestedReplies(rawParentPostNumber, options = {}) {
const parentPostNumber = (0, import_identifiers.discoursePostNumber)(rawParentPostNumber), parentPostId = options.parentPostId === void 0 ? void 0 : (0, import_identifiers.discoursePostId)(options.parentPostId);
return parentPostId === void 0 ? !1 : this.#gateway.promoteNestedReplies?.({
authScope: this.authScope,
topicId: this.topicId,
parentPostNumber,
parentPostId,
after: (0, import_identifiers.discourseReplyCursor)(options.after),
profile: options.background ? businessBackgroundProfile(options) : "nested-visible",
cacheMode: options.refresh ? "refresh" : "default"
}) ?? !1;
}
}
}, "9be3bc45ab7721ff55a482bd26dba43b3413f2bb68bf635644d9063e027f4b28");
/* Source: lite/src/topic/topic-session.ts */
runtime.register("src/topic/topic-session.js", function(module, exports, require) {
var topic_session_exports = {};
__export(topic_session_exports, {
TopicSession: () => TopicSession,
discoursePostsFromPayload: () => discoursePostsFromPayload
});
module.exports = __toCommonJS(topic_session_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_native_request_descriptors = require("../discourse/native-request-descriptors.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_signal = require("../kernel/signal.js");
function positiveInteger(value, name) {
const numeric = Number(value);
if (!Number.isSafeInteger(numeric) || numeric < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return numeric;
}
function nonNegativeInteger(value) {
const numeric = Number(value ?? 0);
return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : 0;
}
function directReplyPost(post, parentPostNumber) {
const rawParent = post.reply_to_post_number;
if (rawParent != null && rawParent !== "") {
let explicitParent;
try {
explicitParent = (0, import_identifiers.discoursePostReference)({
post_number: rawParent
}).postNumber;
} catch {
return null;
}
return explicitParent === parentPostNumber ? post : null;
}
return Object.freeze({
...post,
reply_to_post_number: parentPostNumber
});
}
function scopedReplyPost(post, parentPostNumber) {
try {
(0, import_identifiers.discoursePostReference)(post);
} catch {
return null;
}
const direct = directReplyPost(post, parentPostNumber);
if (direct) return direct;
try {
return (0, import_identifiers.discoursePostReference)({
post_number: post.reply_to_post_number
}), post;
} catch {
return null;
}
}
function replyPageCursor(posts) {
let cursor = 0;
for (const post of posts)
try {
cursor = Math.max(cursor, (0, import_identifiers.discoursePostReference)(post).postNumber);
} catch {
}
return cursor;
}
function errorStatus(error) {
return Number(error?.status ?? 0);
}
function comparableRequestPath(value) {
const source = String(value ?? "").trim();
if (!source) return "";
try {
const url = new URL(source, "https://reader.invalid");
return `${url.pathname}${url.search}`;
} catch {
return source;
}
}
function isAuthFailure(error) {
return [401, 403].includes(errorStatus(error));
}
function isThrottleFailure(error) {
return errorStatus(error) === 429 || error?.cloudflareMitigated === !0;
}
function isAbortFailure(error) {
return error instanceof DOMException && error.name === "AbortError" || String(error?.name ?? "") === "AbortError";
}
function throwIfAborted(signal) {
if (signal?.aborted)
throw signal.reason ?? new DOMException("Aborted", "AbortError");
}
function awaitWithSignal(operation, signal) {
return signal ? signal.aborted ? Promise.reject(
signal.reason ?? new DOMException("Aborted", "AbortError")
) : new Promise((resolve, reject) => {
let settled = !1;
const finish = (callback) => {
settled || (settled = !0, signal.removeEventListener("abort", onAbort), callback());
}, onAbort = () => finish(() => reject(
signal.reason ?? new DOMException("Aborted", "AbortError")
));
signal.addEventListener("abort", onAbort, { once: !0 }), Promise.resolve(operation).then(
(value) => finish(() => resolve(value)),
(error) => finish(() => reject(error))
);
}) : Promise.resolve(operation);
}
function linkedAbortSignals(first, second) {
if (!first || first === second) {
const signal = second ?? first;
return Object.freeze(signal ? { signal, dispose() {
} } : { dispose() {
} });
}
if (!second) return Object.freeze({ signal: first, dispose() {
} });
const controller = new AbortController(), abortFromFirst = () => controller.abort(first.reason), abortFromSecond = () => controller.abort(second.reason);
return first.aborted ? abortFromFirst() : second.aborted ? abortFromSecond() : (first.addEventListener("abort", abortFromFirst, { once: !0 }), second.addEventListener("abort", abortFromSecond, { once: !0 })), Object.freeze({
signal: controller.signal,
dispose() {
first.removeEventListener("abort", abortFromFirst), second.removeEventListener("abort", abortFromSecond);
}
});
}
function shouldSplit(error) {
return [400, 404, 413, 414, 422].includes(errorStatus(error));
}
function freezeNumbers(values) {
return Object.freeze([...values]);
}
function discoursePostsFromPayload(payload) {
if (Array.isArray(payload)) return Object.freeze(payload.filter(Boolean));
if (!payload || typeof payload != "object") return Object.freeze([]);
const candidate = payload;
return Array.isArray(candidate.post_stream?.posts) ? Object.freeze(candidate.post_stream.posts.filter(Boolean)) : candidate.post_number === void 0 ? Object.freeze([]) : Object.freeze([payload]);
}
function postRecord(value) {
return value && typeof value == "object" ? value : null;
}
function moderationHiddenPlaceholder(value) {
const source = postRecord(value);
if (source?.hidden !== !0) return !1;
const cooked = String(source.cooked ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
return /社区举报.*临时隐藏/.test(cooked) || /flagged by the community.*temporarily hidden/i.test(cooked);
}
function cachedOriginalCooked(value) {
const source = postRecord(value);
if (!source || source.reader_local_archive_placeholder === !0 || moderationHiddenPlaceholder(value))
return null;
const cooked = typeof source.cooked == "string" ? source.cooked : "";
return cooked.trim() ? cooked : null;
}
class TopicSession {
topicId;
scope;
changes = new import_signal.Signal();
archiveChanges = new import_signal.Signal();
#requests;
#snapshots;
#replies;
#pageSize;
#refreshCachedInBackground;
#now;
#wait;
#signal;
#onError;
#onInitializeSource;
#postById = /* @__PURE__ */ new Map();
#postByNumber = /* @__PURE__ */ new Map();
#streamIndexByPostId = /* @__PURE__ */ new Map();
#cachedPostsSnapshot = null;
#pendingByPostId = /* @__PURE__ */ new Map();
#pendingDirectReplies = /* @__PURE__ */ new Map();
#unavailablePostNumbers = /* @__PURE__ */ new Set();
#streamPostIds = Object.freeze([]);
#postStreamRevision = 0;
#topic = null;
#cursor = 0;
#sequentialLoadStarted = !1;
#initializedFromCache = !1;
#initPromise = null;
#refreshPromise = null;
#postStreamPromise = null;
#postStreamExecution = null;
#authoritativeTopicExpectedPostCount = 0;
#createdPostNumbersSinceAuthoritativeTopic = /* @__PURE__ */ new Set();
#closed = !1;
#archiveStateKey = "";
constructor(options) {
if (this.topicId = (0, import_identifiers.discourseTopicId)(options.topicId), options.snapshots.topicId !== String(this.topicId))
throw new Error("TopicSession 与 TopicSnapshotRepository topicId 不一致");
if (options.replies.topicId !== String(this.topicId))
throw new Error("TopicSession 与 ReplyTreeRepository topicId 不一致");
this.#requests = options.requests, this.#snapshots = options.snapshots, this.#replies = options.replies, this.#pageSize = positiveInteger(options.pageSize, "pageSize"), this.#refreshCachedInBackground = options.refreshCachedInBackground !== !1, this.#now = options.now ?? Date.now, this.#wait = options.wait ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))), this.#signal = options.signal, this.#onError = options.onError ?? (() => {
}), this.#onInitializeSource = options.onInitializeSource ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.scope), this.scope.add(() => {
this.#closed = !0, this.#cachedPostsSnapshot = null, this.#pendingByPostId.clear();
for (const pending of this.#pendingDirectReplies.values())
pending.controller.signal.aborted || pending.controller.abort(new DOMException(
"Topic 已关闭",
"AbortError"
));
this.#pendingDirectReplies.clear(), this.#postStreamPromise = null, this.#postStreamExecution = null;
});
}
get topic() {
return this.#topic;
}
get initializedFromCache() {
return this.#initializedFromCache;
}
get loadDone() {
return this.#cursor >= this.#streamPostIds.length;
}
streamPostIds() {
return this.#streamPostIds;
}
get postStreamRevision() {
return this.#postStreamRevision;
}
/**
* 两个已加载楼层之间实际存在的 canonical stream 项数。
*
* post_number 会因删除而留洞;只有 post_stream 的相对位置能够区分“已删除编号”
* 与“正文尚未水合”。索引随 stream 快照一次重建,根投影逐楼读取保持 O(1)。
*/
postStreamGapCount(rawPreviousPostNumber, rawPostNumber) {
const postNumber = (0, import_identifiers.discoursePostNumber)(rawPostNumber), post = this.#postByNumber.get(postNumber);
if (!post) return;
const postId = (0, import_identifiers.discoursePostReference)(post).postId;
if (postId === null) return;
const postIndex = this.#streamIndexByPostId.get(postId);
if (postIndex === void 0) return;
let previousIndex = -1;
if (rawPreviousPostNumber > 0) {
const previousPost = this.#postByNumber.get(
(0, import_identifiers.discoursePostNumber)(rawPreviousPostNumber)
);
if (!previousPost) return;
const previousPostId = (0, import_identifiers.discoursePostReference)(previousPost).postId;
if (previousPostId === null) return;
const resolvedPreviousIndex = this.#streamIndexByPostId.get(previousPostId);
if (resolvedPreviousIndex === void 0) return;
previousIndex = resolvedPreviousIndex;
}
if (!(postIndex <= previousIndex))
return postIndex - previousIndex - 1;
}
postStreamCoverage() {
const expectedPostCount = this.#snapshots.snapshot().expectedPostCount, missingPostCount = this.#streamPostIds.reduce(
(total, postId) => total + (this.#postById.has(postId) ? 0 : 1),
0
);
return Object.freeze({
complete: expectedPostCount > 0 && expectedPostCount <= this.#streamPostIds.length && missingPostCount === 0,
expectedPostCount,
streamPostCount: this.#streamPostIds.length,
missingPostCount
});
}
unavailablePostNumbers() {
return Object.freeze(
[...this.#unavailablePostNumbers].sort((left, right) => left - right)
);
}
localArchiveState() {
return this.#snapshots.localArchiveState();
}
get pageSize() {
return this.#pageSize;
}
/**
* 原地切换后续 loader 批次大小;不移动 cursor、不清 pending,也不重建 Topic。
*/
applyPageSize(pageSize) {
this.#assertActive(), this.#pageSize = positiveInteger(pageSize, "pageSize");
}
cachedPosts() {
return this.#cachedPostsSnapshot ??= Object.freeze([...this.#postByNumber.entries()].sort(([left], [right]) => left - right).map(([, post]) => post)), this.#cachedPostsSnapshot;
}
postById(rawPostId) {
return this.#postById.get((0, import_identifiers.discoursePostId)(rawPostId));
}
postByNumber(rawPostNumber) {
return this.#postByNumber.get((0, import_identifiers.discoursePostNumber)(rawPostNumber));
}
init(options = {}) {
if (this.#assertActive(), this.#topic) return Promise.resolve(this.#topic);
if (this.#initPromise) return this.#initPromise;
const promise = this.#initialize(options);
return this.#initPromise = promise, promise.finally(() => {
this.#initPromise === promise && (this.#initPromise = null);
}).catch(() => {
}), promise;
}
refresh(options = {}) {
if (this.#assertActive(), this.#isLocalArchiveTopic()) {
const cached = this.#topic ?? this.#snapshots.topic();
if (cached) return Promise.resolve(cached);
}
return this.#loadTopic(options, !0);
}
#loadTopic(options, refresh) {
if (this.#refreshPromise) return this.#refreshPromise;
const observedAt = this.#now(), promise = this.#requests.loadTopic({
...options,
refresh
}).then(async (topic) => {
this.#assertActive();
const posts = await this.#prepareModerationHiddenPosts(
discoursePostsFromPayload(topic)
);
return this.#assertActive(), this.#commitTopic(topic, "topic-json", observedAt, posts), topic;
}).catch((error) => {
this.#assertActive();
const status = errorStatus(error), cached = this.#snapshots.topic();
if (![403, 404, 410].includes(status) || cached === null || this.#snapshots.posts().length === 0) throw error;
return this.#snapshots.markTopicUnavailable(status, observedAt), this.#restoreIndexes(), this.#initializedFromCache = !0, this.#syncLocalArchiveState(), cached;
});
return this.#refreshPromise = promise, promise.finally(() => {
this.#refreshPromise === promise && (this.#refreshPromise = null);
}).catch(() => {
}), promise;
}
async next(options = {}) {
this.#assertActive(), this.#sequentialLoadStarted = !0;
const position = this.#cursor;
if (position >= this.#streamPostIds.length)
return this.#batchResult([], !0, [], !1, !1);
const ids = this.#streamPostIds.slice(position, position + this.#pageSize), missingBefore = ids.filter((postId) => !this.#postById.has(postId)), localArchive = this.#isLocalArchiveTopic();
if (options.onSource?.(localArchive || !missingBefore.length ? "cache" : "network", Object.freeze({
cachedCount: ids.length - missingBefore.length,
missingCount: missingBefore.length,
totalCount: ids.length
})), localArchive) {
const posts2 = ids.map((postId) => this.#postById.get(postId)).filter((post) => post !== void 0);
return this.#cursor += ids.length, this.#batchResult(
posts2,
this.#cursor >= this.#streamPostIds.length,
missingBefore,
!1,
!1
);
}
let loadError;
if (missingBefore.length)
try {
await this.loadPostsByIds(ids, {
...options.background === void 0 ? {} : { background: options.background },
...options.priority === void 0 ? {} : { priority: options.priority },
...options.business === void 0 ? {} : { business: options.business },
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork },
...options.maxAttempts === void 0 ? {} : { maxAttempts: options.maxAttempts },
...options.beforeCommit === void 0 ? {} : { beforeCommit: options.beforeCommit }
});
} catch (error) {
if (isThrottleFailure(error)) throw error;
loadError = error;
}
const missing = ids.filter((postId) => !this.#postById.has(postId));
if (missing.length) {
const fatal = loadError !== void 0 && isAuthFailure(loadError);
return this.#batchResult(
[],
!1,
missing,
!fatal,
fatal,
loadError
);
}
const posts = ids.map((postId) => this.#postById.get(postId)).filter((post) => post !== void 0);
return this.#cursor += ids.length, this.#batchResult(
posts,
this.#cursor >= this.#streamPostIds.length,
[],
!1,
!1
);
}
async loadPostsByIds(rawPostIds, options = {}) {
this.#assertActive();
const postIds = (0, import_identifiers.discoursePostIdStream)(rawPostIds);
if (this.#isLocalArchiveTopic()) {
const missingPostIds2 = postIds.filter((postId) => !this.#postById.has(postId));
return Object.freeze({
posts: Object.freeze(
postIds.map((postId) => this.#postById.get(postId)).filter((post) => post !== void 0)
),
missingPostIds: freezeNumbers(missingPostIds2)
});
}
const maxAttempts = positiveInteger(options.maxAttempts ?? 2, "maxAttempts");
let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const pending = attempt === 0 && options.refresh ? postIds : postIds.filter((postId) => !this.#postById.has(postId));
if (!pending.length) break;
try {
await this.#ensurePostIds(pending, options), lastError = void 0;
} catch (error) {
if (lastError = error, isAuthFailure(error) || isThrottleFailure(error)) throw error;
}
postIds.some((postId) => !this.#postById.has(postId)) && attempt + 1 < maxAttempts && await this.#wait(240);
}
const missingPostIds = postIds.filter((postId) => !this.#postById.has(postId));
if (missingPostIds.length && lastError !== void 0) throw lastError;
return Object.freeze({
posts: Object.freeze(
postIds.map((postId) => this.#postById.get(postId)).filter((post) => post !== void 0)
),
missingPostIds: freezeNumbers(missingPostIds)
});
}
/**
* 用 Discourse post_ids[] 批量端点预热当前顺序流之后最近仍缺正文的完整批次。
*
* 顺序流尚未启动时保留“当前批次之后”的显式预热语义;next() 已启动后则从当前
* cursor 向前扫描,跳过完整缓存和整批在途请求。这样缓存命中同步推进 cursor 时不会
* 留下最近批次冷缺口,网络批次又仍可通过 pendingByPostId 保持单飞。
*/
async prefetchAhead(rawBatchCount, options = {}) {
this.#assertActive();
const batchCount = Math.min(
2,
positiveInteger(rawBatchCount, "batchCount")
), firstOffset = this.#sequentialLoadStarted ? this.#cursor : this.#cursor + this.#pageSize, batches = [];
for (let offset = firstOffset; offset < this.#streamPostIds.length && batches.length < batchCount; offset += this.#pageSize) {
const batch = this.#streamPostIds.slice(offset, offset + this.#pageSize), missing = batch.filter((postId) => !this.#postById.has(postId));
!missing.length || missing.every((postId) => this.#pendingByPostId.has(postId)) || batches.push([...batch]);
}
return Object.freeze(await Promise.all(batches.map((batch) => this.loadPostsByIds(batch, options))));
}
/**
* 补齐当前 Topic 的 canonical post.id stream。
*
* 需要全帖投影的功能(例如图片关联评论、全帖媒体索引)共用本入口;调用方不得再复制
* stream 分批、single-flight、缺口判断或第二份 post Map。失败批次不会阻断其余批次,
* 最终以 missingPostIds/complete 明确报告覆盖率。
*/
ensurePostStream(options = {}) {
if (this.#assertActive(), this.#isLocalArchiveTopic()) {
const missingPostIds = this.#streamPostIds.filter(
(postId) => !this.#postById.has(postId)
);
return options.onProgress?.(Object.freeze({
loadedCount: this.#streamPostIds.length - missingPostIds.length,
totalCount: this.#streamPostIds.length,
missingCount: missingPostIds.length
})), Promise.resolve(Object.freeze({
posts: this.cachedPosts(),
missingPostIds: freezeNumbers(missingPostIds),
complete: this.postStreamCoverage().complete,
failedBatchCount: 0
}));
}
if (this.#postStreamPromise)
return this.#upgradePostStreamExecution(options), this.#postStreamPromise;
const execution = {
background: options.background === !0,
priority: options.priority,
maxAttempts: positiveInteger(options.maxAttempts ?? 2, "maxAttempts"),
refresh: options.refresh === !0
};
this.#postStreamExecution = execution;
const request = this.#loadPostStream(options, execution).finally(() => {
this.#postStreamPromise === request && (this.#postStreamPromise = null, this.#postStreamExecution === execution && (this.#postStreamExecution = null));
});
return this.#postStreamPromise = request, request;
}
#upgradePostStreamExecution(options) {
const execution = this.#postStreamExecution;
if (!execution) return;
const wasBackground = execution.background, previousPriority = execution.priority;
options.background !== !0 && (execution.background = !1), options.priority === "nested" ? execution.priority = "nested" : execution.priority === void 0 && options.priority === "visible" && (execution.priority = "visible"), options.maxAttempts !== void 0 && (execution.maxAttempts = Math.max(
execution.maxAttempts,
positiveInteger(options.maxAttempts, "maxAttempts")
)), !(wasBackground === execution.background && previousPriority === execution.priority) && this.#promotePendingPostBatches({
background: execution.background,
...execution.priority === void 0 ? {} : { priority: execution.priority },
...execution.refresh ? { refresh: !0 } : {}
});
}
async #loadPostStream(options, execution) {
let loadedCount = 0;
const report = () => {
options.onProgress?.(Object.freeze({
loadedCount,
totalCount: this.#streamPostIds.length,
missingCount: this.#streamPostIds.length - loadedCount
}));
};
let failedBatchCount = 0;
if (this.#snapshots.snapshot().expectedPostCount > this.#streamPostIds.length)
try {
await this.refresh({
background: execution.background,
...options.business === void 0 ? {} : { business: options.business },
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork }
});
} catch (error) {
if (this.#closed || this.scope.destroyed || isThrottleFailure(error)) throw error;
failedBatchCount += 1, this.#onError(error);
}
loadedCount = this.#streamPostIds.reduce(
(total, postId) => total + Number(this.#postById.has(postId)),
0
), report();
for (let offset = 0; offset < this.#streamPostIds.length; ) {
const batch = this.#streamPostIds.slice(
offset,
offset + this.#pageSize
);
if (!batch.length) break;
offset += batch.length;
const loadedBefore = batch.reduce(
(total, postId) => total + Number(this.#postById.has(postId)),
0
);
if (loadedBefore === batch.length) continue;
try {
await options.beforeBatch?.(), await this.loadPostsByIds(batch, {
...options,
background: execution.background,
maxAttempts: execution.maxAttempts,
...execution.priority === void 0 ? {} : { priority: execution.priority }
});
} catch (error) {
if (this.#closed || this.scope.destroyed || isThrottleFailure(error)) throw error;
failedBatchCount += 1, this.#onError(error);
}
const loadedAfter = batch.reduce(
(total, postId) => total + Number(this.#postById.has(postId)),
0
);
loadedAfter !== loadedBefore && (loadedCount += loadedAfter - loadedBefore, report());
}
const missingPostIds = this.#streamPostIds.filter(
(postId) => !this.#postById.has(postId)
), coverage = this.postStreamCoverage();
return Object.freeze({
posts: this.cachedPosts(),
missingPostIds: freezeNumbers(missingPostIds),
complete: coverage.complete,
failedBatchCount
});
}
async loadPostById(rawPostId, options = {}) {
this.#assertActive();
const postId = (0, import_identifiers.discoursePostId)(rawPostId);
if (this.#isLocalArchiveTopic()) return this.#postById.get(postId) ?? null;
const observedAt = this.#now(), payload = await this.#requests.loadPostById(postId, options), matchingPost = discoursePostsFromPayload(payload).find((post) => {
try {
return (0, import_identifiers.discoursePostReference)(post).postId === postId;
} catch {
return !1;
}
});
return matchingPost ? (options.created === !0 || !this.#streamPostIds.includes(postId) ? this.ingestCreatedPost(matchingPost, "target-refresh", observedAt) : this.#commit({ posts: [matchingPost] }, "target-refresh", observedAt), this.#postById.get(postId) ?? null) : null;
}
/**
* Discourse 直属回复 endpoint 的唯一 canonical 入口。
*
* endpoint 已由 parent post.id 限定;部分站点会省略 reply_to_post_number,
* 因而在提交快照/树之前必须补回父级提示。显式指向其他父级的帖子按自身 canonical
* 关系提交,但不混入当前直属集合;调用者可通过 scopedPosts 保留 endpoint 讨论语义。
*/
loadDirectReplies(rawParentPostNumber, options = {}) {
this.#assertActive(), throwIfAborted(options.signal);
const parentPostNumber = (0, import_identifiers.discoursePostReference)({
post_number: rawParentPostNumber
}).postNumber, refresh = options.refresh === !0, key = `${parentPostNumber}:${refresh ? "refresh" : "default"}`, pending = this.#pendingDirectReplies.get(key);
if (pending)
return options.background !== !0 && (pending.profile.background = !1, pending.profile.activeRequest && this.#requests.promoteNestedReplies?.(
parentPostNumber,
{
...pending.profile.activeRequest,
background: !1
}
)), this.#joinDirectReplies(pending, options.signal);
const controller = new AbortController(), requestLifetime = linkedAbortSignals(
this.#signal,
controller.signal
), profile = {
background: options.background === !0,
activeRequest: null
};
let created;
return created = {
task: this.#loadDirectReplies(parentPostNumber, {
...options,
signal: requestLifetime.signal ?? controller.signal
}, profile).finally(() => {
created.settled = !0, requestLifetime.dispose(), this.#pendingDirectReplies.get(key) === created && this.#pendingDirectReplies.delete(key);
}),
controller,
consumers: /* @__PURE__ */ new Set(),
profile,
unabortableConsumer: !1,
settled: !1
}, this.#pendingDirectReplies.set(key, created), this.#joinDirectReplies(created, options.signal);
}
#joinDirectReplies(pending, signal) {
if (throwIfAborted(signal), !signal)
return pending.unabortableConsumer = !0, pending.task;
const consumer = Symbol("direct-replies-consumer");
return pending.consumers.add(consumer), awaitWithSignal(pending.task, signal).finally(() => {
pending.consumers.delete(consumer), !(pending.settled || pending.unabortableConsumer || pending.consumers.size > 0 || pending.controller.signal.aborted) && pending.controller.abort(
signal.reason ?? new DOMException(
"直属回复已无消费者",
"AbortError"
)
);
});
}
/**
* 从一个或多个已加载根楼层递归补齐子孙分支。
*
* 完整讨论、阅读队列等上层只提交根集合;遍历、直属分页、partial 统计与错误保留均由
* TopicSession 统一完成,避免每个 surface 维护自己的树扫描和请求循环。
*/
async loadReplyBranches(rawRootPostNumbers, options = {}) {
this.#assertActive();
const rootPostNumbers = Object.freeze([...new Set(
rawRootPostNumbers.map((postNumber) => (0, import_identifiers.discoursePostReference)({
post_number: postNumber
}).postNumber)
)]), pending = [...rootPostNumbers], queued = new Set(pending), enqueue = (postNumber) => {
queued.has(postNumber) || (queued.add(postNumber), pending.push(postNumber));
}, seen = /* @__PURE__ */ new Set(), postNumbers = [], parentPostNumbers = [], contextualReplyRelations = [], contextualRelationKeys = /* @__PURE__ */ new Set(), errors = [];
let expectedReplyCount = 0, loadedReplyCount = 0, complete = !0;
const report = (processedCount) => {
options.onProgress?.(Object.freeze({
processedCount,
totalCount: pending.length,
loadedReplyCount,
expectedReplyCount
}));
};
report(0);
for (let index = 0; index < pending.length; index += 1) {
this.#assertActive();
const postNumber = pending[index];
if (seen.has(postNumber)) {
report(index + 1);
continue;
}
seen.add(postNumber);
const post = this.#postByNumber.get(postNumber);
if (!post) {
complete = !1, report(index + 1);
continue;
}
postNumbers.push(postNumber);
const expectedCount = Math.max(
nonNegativeInteger(post.reply_count),
this.#replies.topology.childrenOf(postNumber).length
);
if (expectedCount > 0) {
parentPostNumbers.push(postNumber);
let directPosts = this.#knownDirectReplies(postNumber), scopedPosts = directPosts;
if (directPosts.length < expectedCount)
try {
const result = await this.loadDirectReplies(postNumber, {
...options,
expectedCount
});
directPosts = result.posts, scopedPosts = result.scopedPosts, complete = complete && result.complete;
} catch (error) {
let finalError = error;
if (isAbortFailure(error) && !options.signal?.aborted && !this.scope.destroyed)
try {
const recovered = await this.loadDirectReplies(postNumber, {
...options,
expectedCount,
background: !1
});
directPosts = recovered.posts, scopedPosts = recovered.scopedPosts, complete = complete && recovered.complete, finalError = null;
} catch (recoveryError) {
finalError = recoveryError;
}
if (finalError !== null) {
if (this.scope.destroyed || isAbortFailure(finalError) || isThrottleFailure(finalError)) throw finalError;
errors.push(finalError), complete = !1;
}
}
const scopedPostNumbers = /* @__PURE__ */ new Set();
for (const scopedPost of scopedPosts) {
const childPostNumber = (0, import_identifiers.discoursePostReference)(scopedPost).postNumber;
if (childPostNumber === postNumber || (scopedPostNumbers.add(childPostNumber), enqueue(childPostNumber), this.#canonicalBranchContains(postNumber, childPostNumber))) continue;
const key = `${postNumber}:${childPostNumber}`;
contextualRelationKeys.has(key) || (contextualRelationKeys.add(key), contextualReplyRelations.push(Object.freeze({
parentPostNumber: postNumber,
postNumber: childPostNumber
})));
}
for (const directPost of directPosts)
scopedPostNumbers.add((0, import_identifiers.discoursePostReference)(directPost).postNumber);
expectedReplyCount += expectedCount, loadedReplyCount += scopedPostNumbers.size, complete = complete && scopedPostNumbers.size >= expectedCount;
}
for (const child of this.#replies.topology.childrenOf(postNumber))
enqueue((0, import_identifiers.discoursePostReference)({
post_number: child
}).postNumber);
report(index + 1);
}
return Object.freeze({
rootPostNumbers,
postNumbers: Object.freeze(postNumbers),
parentPostNumbers: Object.freeze(parentPostNumbers),
expectedReplyCount,
loadedReplyCount,
complete,
contextualReplyRelations: Object.freeze(contextualReplyRelations),
errors: Object.freeze(errors)
});
}
/**
* 用户 reply/create 与 MessageBus created 共用的原子 ingress。
*
* 新 post 在同一次 commit 中进入 post.id stream、正文索引、快照和回复拓扑;已知 post
* 只更新正文/关系,不重复增加 stream 或 expected count。
*/
ingestCreatedPost(post, source, observedAt = this.#now()) {
this.#assertActive();
const reference = (0, import_identifiers.discoursePostReference)(post);
if (reference.postId === null) throw new Error("created 楼层缺少 post.id");
const knownPostAtNumber = this.#postByNumber.get(reference.postNumber), knownPostIdAtNumber = knownPostAtNumber === void 0 ? null : (0, import_identifiers.discoursePostReference)(knownPostAtNumber).postId, isNewCreatedFloor = knownPostAtNumber === void 0, nextStream = this.#streamWithCreatedPost(
reference.postId,
reference.postNumber
);
isNewCreatedFloor && this.#createdPostNumbersSinceAuthoritativeTopic.add(
reference.postNumber
);
const expectedPostCount = nextStream === void 0 ? void 0 : this.#authoritativeTopicExpectedPostCount + this.#createdPostNumbersSinceAuthoritativeTopic.size, repairedChildren = [...this.#postByNumber.values()].filter((candidate) => {
try {
return (0, import_identifiers.discoursePostReference)(candidate).replyToPostNumber === reference.postNumber;
} catch {
return !1;
}
});
return this.#commit({
posts: [post, ...repairedChildren],
...nextStream === void 0 ? {} : {
streamPostIds: knownPostIdAtNumber === null ? nextStream : nextStream.filter((postId) => postId !== knownPostIdAtNumber)
},
...expectedPostCount === void 0 ? {} : { expectedPostCount }
}, source, observedAt);
}
removePostById(rawPostId, source = "action-response", observedAt = this.#now()) {
this.#assertActive();
const postId = (0, import_identifiers.discoursePostId)(rawPostId), post = this.#postById.get(postId);
if (!post) throw new Error(`canonical post.id ${postId} 尚未加载`);
const reference = (0, import_identifiers.discoursePostReference)(post), snapshotResult = this.#snapshots.removePost(
reference.postNumber,
postId,
source,
observedAt
), treeEvent = this.#replies.remove(
reference.postNumber,
source,
{ observedAt }
);
this.#restoreIndexes();
const changedPostNumbers = Object.freeze(
[.../* @__PURE__ */ new Set([
reference.postNumber,
...treeEvent?.change.changedPostNumbers ?? []
])].sort((left, right) => left - right)
), result = Object.freeze({
source,
observedAt,
acceptedPosts: 0,
ignoredPosts: snapshotResult.removed ? 0 : 1,
changedPostNumbers,
removedPostNumbers: snapshotResult.removed ? Object.freeze([reference.postNumber]) : Object.freeze([]),
topicChanged: !1,
streamChanged: snapshotResult.streamChanged
});
for (const error of this.changes.emit(result)) this.#onError(error);
return result;
}
preserveDeletedPostById(rawPostId, observedAt = this.#now()) {
this.#assertActive();
const postId = (0, import_identifiers.discoursePostId)(rawPostId), post = this.#postById.get(postId);
if (!post) throw new Error(`canonical post.id ${postId} 尚未加载`);
const postNumber = (0, import_identifiers.discoursePostReference)(post).postNumber, topicArchived = postNumber === 1 && this.#snapshots.markTopicUnavailable(404, observedAt), postArchived = postNumber !== 1 && this.#snapshots.markPostUnavailable(postNumber, 404, observedAt);
return postArchived && this.#unavailablePostNumbers.add(postNumber), (topicArchived || postArchived) && this.#syncLocalArchiveState(), Object.freeze({ postNumber, topicArchived });
}
/**
* 已确认失效的楼层只在 canonical 快照已有正文时转为只读存档。
*
* 岁月史书等入口传入的是已观测到的服务器状态;本方法不发请求、
* 不伪造正文,也不在缓存缺失时创建占位楼层。
*/
preserveUnavailablePost(rawPostNumber, status, confirmedAt = this.#now()) {
this.#assertActive();
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: rawPostNumber
}).postNumber;
if (!this.#postByNumber.has(postNumber)) return !1;
const archived = postNumber === 1 ? this.#snapshots.markTopicUnavailable(status, confirmedAt) : this.#snapshots.markPostUnavailable(
postNumber,
status,
confirmedAt
);
return postNumber !== 1 && this.#unavailablePostNumbers.add(postNumber), archived && this.#syncLocalArchiveState(), !0;
}
/**
* 当 Topic 快照尚无正文或只剩举报占位文案时,从中央响应缓存找回曾经成功
* 返回的目标楼层。只提交精确目标,不联网、不把整份旧 Topic payload 覆盖
* 当前 canonical 状态。
*/
async restoreUnavailablePostFromCache(rawPostNumber, status, confirmedAt = this.#now(), preferredRequestPath) {
this.#assertActive();
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: rawPostNumber
}).postNumber, current = this.#postByNumber.get(postNumber);
if (current && !moderationHiddenPlaceholder(current) && this.preserveUnavailablePost(postNumber, status, confirmedAt))
return !0;
const target = await this.#cachedOriginalPost(
postNumber,
preferredRequestPath
), originalCooked = cachedOriginalCooked(target);
if (target && originalCooked) {
const currentPost = this.#postByNumber.get(postNumber), restored = currentPost && moderationHiddenPlaceholder(currentPost) ? Object.freeze({
...currentPost,
cooked: originalCooked
}) : target;
if (this.ingestPosts(
[restored],
currentPost ? "target-refresh" : "loader-batch",
confirmedAt
), this.preserveUnavailablePost(postNumber, status, confirmedAt))
return !0;
}
return this.preserveUnavailablePost(postNumber, status, confirmedAt);
}
async #cachedOriginalPost(postNumber, preferredRequestPath) {
const readCached = this.#requests.cachedTargetCandidate;
if (!readCached) return null;
const slug = String(this.#topic?.slug ?? "topic"), plans = ["single", "around"].flatMap((scope) => {
const options = Object.freeze({
scope,
slug,
refresh: !0
});
return this.#requests.targetCandidates(postNumber, options).map(
(candidate) => Object.freeze({ candidate, options })
);
}), preferred = comparableRequestPath(preferredRequestPath), matching = preferred ? plans.filter(({ candidate }) => comparableRequestPath(candidate.url) === preferred) : [];
for (const { candidate, options } of matching.length ? matching : plans) {
let payload;
try {
payload = await readCached.call(
this.#requests,
candidate,
postNumber,
options
);
} catch (error) {
this.#onError(error);
continue;
}
if (payload === null) continue;
const target = discoursePostsFromPayload(payload).find((post) => {
try {
return (0, import_identifiers.discoursePostReference)(post).postNumber === postNumber;
} catch {
return !1;
}
});
if (cachedOriginalCooked(target)) return target ?? null;
}
return null;
}
async #prepareModerationHiddenPosts(posts) {
let changed = !1;
const prepared = [];
for (const post of posts) {
if (!moderationHiddenPlaceholder(post)) {
prepared.push(post);
continue;
}
let postNumber;
try {
postNumber = (0, import_identifiers.discoursePostReference)(post).postNumber;
} catch (error) {
this.#onError(error), prepared.push(post);
continue;
}
const currentCooked = cachedOriginalCooked(
this.#postByNumber.get(postNumber)
), cached = currentCooked ? null : await this.#cachedOriginalPost(postNumber), originalCooked = currentCooked ?? cachedOriginalCooked(cached);
if (!originalCooked) {
prepared.push(post);
continue;
}
changed = !0, prepared.push(Object.freeze({
...post,
cooked: originalCooked
}));
}
return changed ? Object.freeze(prepared) : posts;
}
async loadTarget(rawPostNumber, options = {}) {
this.#assertActive();
const postNumber = (0, import_identifiers.discoursePostReference)({ post_number: rawPostNumber }).postNumber, scope = options.scope ?? "single", shouldAdvance = options.advanceCursor ?? scope === "around", cachedBeforeRequest = this.#postByNumber.get(postNumber);
if (this.#isLocalArchiveTopic())
return cachedBeforeRequest && shouldAdvance && this.#advanceCursorPast([cachedBeforeRequest], postNumber), Object.freeze(cachedBeforeRequest ? [cachedBeforeRequest] : []);
if (!options.forceRefresh && this.#unavailablePostNumbers.has(postNumber))
return Object.freeze(cachedBeforeRequest ? [cachedBeforeRequest] : []);
const cached = scope === "single" && !options.forceRefresh ? this.#postByNumber.get(postNumber) : void 0;
if (cached)
return shouldAdvance && this.#advanceCursorPast([cached], postNumber), Object.freeze([cached]);
const targetOptions = {
scope,
slug: String(this.#topic?.slug ?? "topic"),
refresh: options.forceRefresh === !0,
...options.business === void 0 ? {} : { business: options.business },
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork }
};
let fallback = Object.freeze([]), definitiveStatus = null, definitiveRequestPath;
for (const candidate of this.#requests.targetCandidates(postNumber, targetOptions)) {
const observedAt = this.#now();
try {
const payload = await this.#requests.loadTargetCandidate(
candidate,
postNumber,
targetOptions
), posts = discoursePostsFromPayload(payload);
this.ingestPosts(posts, "target-refresh", observedAt);
const committedPosts = posts.map((post) => {
try {
return this.#postByNumber.get(
(0, import_identifiers.discoursePostReference)(post).postNumber
);
} catch {
return;
}
}).filter((post) => post !== void 0), found = posts.some((post) => {
try {
return (0, import_identifiers.discoursePostReference)(post).postNumber === postNumber;
} catch {
return !1;
}
}) ? this.#postByNumber.get(postNumber) : void 0;
if (found) {
const result = scope === "around" ? committedPosts : [found];
return shouldAdvance && this.#advanceCursorPast(result, postNumber), Object.freeze([...result]);
}
scope === "around" && committedPosts.length && !fallback.length && (fallback = committedPosts);
} catch (error) {
const status = errorStatus(error);
if ((0, import_native_request_descriptors.discourseNativeTargetFailureIsDefinitive)({
endpoint: candidate.endpoint,
scope,
status
})) {
definitiveStatus = status, definitiveRequestPath = candidate.url;
break;
}
if (scope === "single" && candidate.endpoint === "post-by-number" && status === 403 && cachedBeforeRequest !== void 0) {
definitiveStatus = 403, definitiveRequestPath = candidate.url;
continue;
}
if (isAuthFailure(error) || isThrottleFailure(error)) throw error;
}
}
if (scope === "around" && this.#streamPostIds.length) {
const posts = await this.loadAroundPost(postNumber, {
maxAttempts: 1,
refresh: options.forceRefresh === !0,
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork }
});
if (posts.length)
return shouldAdvance && this.#advanceCursorPast(posts, postNumber), posts;
}
if (scope === "single" && definitiveStatus !== null && (this.#unavailablePostNumbers.add(postNumber), cachedBeforeRequest)) {
const confirmedAt = this.#now();
await this.restoreUnavailablePostFromCache(
postNumber,
definitiveStatus,
confirmedAt,
definitiveRequestPath
);
const archivedPost = this.#postByNumber.get(postNumber) ?? cachedBeforeRequest;
return shouldAdvance && this.#advanceCursorPast([archivedPost], postNumber), Object.freeze([archivedPost]);
}
return fallback.length && shouldAdvance && this.#advanceCursorPast(fallback, postNumber), Object.freeze([...fallback]);
}
loadBeforePost(postNumber, options = {}) {
return this.#loadRelative(postNumber, "before", {
...options,
maxAttempts: options.maxAttempts ?? 1
});
}
loadAfterPost(postNumber, options = {}) {
return this.#loadRelative(postNumber, "after", {
...options,
maxAttempts: options.maxAttempts ?? 1
});
}
/**
* 只使用 Topic 已知的 canonical post_stream,在目标楼层附近读取一批正文。
*
* 虚拟 gap 的楼层号来自估算,不是可直接访问的 Discourse route。这里不得走
* targetCandidates,否则删除楼层或稀疏编号会把一次补窗放大成 topic/by-number
* 候选循环;批次仍完整复用 loadPostsByIds 的缓存、single-flight 与 429 终态。
*/
async loadAroundPost(rawPostNumber, options = {}) {
if (this.#assertActive(), !this.#streamPostIds.length) return Object.freeze([]);
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: rawPostNumber
}).postNumber, center = this.#streamIndexForPostNumber(postNumber), start = Math.max(
0,
Math.min(
Math.max(0, this.#streamPostIds.length - this.#pageSize),
center - Math.floor(this.#pageSize / 2)
)
);
return (await this.loadPostsByIds(
this.#streamPostIds.slice(start, start + this.#pageSize),
{
...options,
maxAttempts: options.maxAttempts ?? 1
}
)).posts;
}
/**
* 宿主 Topic 列表入口的标准正文预热窗口。
*
* 显式提供 maximumPostCount 时,以目标楼层为中心选取不超过该值的连续总量;
* 旧调用不提供上限时,#1 只取向下一个 pageSize,中间楼层仍取前后各一个
* pageSize。所有批次都走 loadPostsByIds 的缓存、single-flight 与中央后台调度;
* 完整缓存命中时不创建网络请求,也不挂载 Post DOM。
*/
async restorePreheatEntry(rawPostNumber, maximumPostCount) {
if (this.#assertActive(), !this.#topic && !this.#streamPostIds.length && !this.#snapshots.posts().length) {
const restored = await this.#snapshots.restore();
if (this.#assertActive(), !restored) return null;
this.#restoreIndexes();
}
if (!this.#streamPostIds.length) return null;
const { ids, totalCount } = this.#entryPreheatWindow(
rawPostNumber,
maximumPostCount
), warmedCount = ids.reduce(
(count, postId) => count + Number(this.#postById.has(postId)),
0
);
return Object.freeze({
warmedCount,
requestedCount: ids.length,
totalCount,
cacheHit: warmedCount >= ids.length,
complete: warmedCount >= ids.length
});
}
async preheatEntry(rawPostNumber, options = {}) {
this.#assertActive();
const {
onProgress,
minimumTotalCount: rawMinimumTotalCount,
maximumPostCount,
...loadOptions
} = options;
nonNegativeInteger(rawMinimumTotalCount) > Math.max(
this.#streamPostIds.length,
this.#snapshots.snapshot().expectedPostCount
) && await this.refresh({
...loadOptions.background === void 0 ? {} : { background: loadOptions.background },
...loadOptions.prefetchTier === void 0 ? {} : { prefetchTier: loadOptions.prefetchTier },
...loadOptions.beforeNetwork === void 0 ? {} : { beforeNetwork: loadOptions.beforeNetwork }
});
const { ids, totalCount } = this.#entryPreheatWindow(
rawPostNumber,
maximumPostCount
);
if (!this.#streamPostIds.length) {
const empty = Object.freeze({
posts: Object.freeze([]),
missingPostIds: Object.freeze([]),
warmedCount: 0,
requestedCount: 0,
totalCount,
cacheHit: !0,
complete: !0
});
return onProgress?.(empty), empty;
}
const cacheHit = ids.every((postId) => this.#postById.has(postId)), progress = () => {
const warmedCount = ids.reduce(
(count, postId) => count + Number(this.#postById.has(postId)),
0
);
return Object.freeze({
warmedCount,
requestedCount: ids.length,
totalCount,
cacheHit,
complete: warmedCount >= ids.length
});
};
for (let offset = 0; offset < ids.length; offset += this.#pageSize) {
const batch = ids.slice(offset, offset + this.#pageSize);
batch.some((postId) => !this.#postById.has(postId)) && await this.loadPostsByIds(batch, loadOptions), onProgress?.(progress());
}
const finalProgress = progress();
return ids.length || onProgress?.(finalProgress), Object.freeze({
posts: Object.freeze(ids.map((postId) => this.#postById.get(postId)).filter((post) => post !== void 0)),
missingPostIds: freezeNumbers(ids.filter(
(postId) => !this.#postById.has(postId)
)),
...finalProgress
});
}
#entryPreheatWindow(rawPostNumber, rawMaximumPostCount) {
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: rawPostNumber
}).postNumber, totalCount = Math.max(
this.#streamPostIds.length,
this.#snapshots.snapshot().expectedPostCount
);
if (!this.#streamPostIds.length)
return Object.freeze({ ids: Object.freeze([]), totalCount });
const targetIndex = this.#streamIndexForPostNumber(postNumber);
if (rawMaximumPostCount !== void 0) {
const maximumPostCount = Math.min(
this.#streamPostIds.length,
positiveInteger(rawMaximumPostCount, "maximumPostCount")
), start2 = Math.max(
0,
Math.min(
this.#streamPostIds.length - maximumPostCount,
targetIndex - Math.floor(maximumPostCount / 2)
)
);
return Object.freeze({
ids: this.#streamPostIds.slice(start2, start2 + maximumPostCount),
totalCount
});
}
const start = targetIndex <= 0 ? 0 : Math.max(0, targetIndex - this.#pageSize), end = Math.min(
this.#streamPostIds.length,
targetIndex <= 0 ? this.#pageSize : targetIndex + this.#pageSize
);
return Object.freeze({
ids: this.#streamPostIds.slice(start, end),
totalCount
});
}
loadLastPost(options = {}) {
return this.#loadRelative(1, "last", options);
}
ingestPosts(posts, source, observedAt = this.#now()) {
return this.#assertActive(), this.#commit({ posts }, source, observedAt);
}
ingestTopic(topic, source, observedAt = this.#now()) {
return this.#assertActive(), this.#commitTopic(topic, source, observedAt);
}
async flush() {
await this.#replies.flush(), await this.#snapshots.flush();
}
destroy() {
this.scope.destroy();
}
async #loadDirectReplies(parentPostNumber, options, profile) {
const parentPost = this.#postByNumber.get(parentPostNumber);
if (!parentPost) throw new Error(`父楼层 #${parentPostNumber} 尚未加载`);
const parentPostId = (0, import_identifiers.discoursePostReference)(parentPost).postId;
if (parentPostId === null)
throw new Error(`父楼层 #${parentPostNumber} 缺少 post.id`);
const configuredExpected = nonNegativeInteger(options.expectedCount), declaredExpected = nonNegativeInteger(parentPost.reply_count);
let posts = this.#knownDirectReplies(parentPostNumber);
const scopedPostsByNumber = new Map(
posts.map((post) => [(0, import_identifiers.discoursePostReference)(post).postNumber, post])
), knownRelationCount = this.#replies.topology.childrenOf(parentPostNumber).length, expectedCount = Math.max(
configuredExpected,
declaredExpected,
knownRelationCount,
posts.length
), localArchive = this.#isLocalArchiveTopic();
if (localArchive || !options.refresh && expectedCount <= posts.length)
return Object.freeze({
parentPostNumber,
posts,
scopedPosts: posts,
expectedCount,
complete: expectedCount <= posts.length,
endpointExhausted: localArchive,
pageCount: 0,
nextAfter: 0
});
const request = this.#requests.loadNestedReplies;
if (typeof request != "function")
throw new Error("TopicSession 请求端口未提供 Discourse 直属回复能力");
const maxPages = Math.max(
1,
Math.min(100, positiveInteger(options.maxPages ?? 32, "maxPages"))
), configuredMaxAttempts = options.maxAttempts === void 0 ? null : Math.max(
1,
Math.min(
4,
positiveInteger(options.maxAttempts, "maxAttempts")
)
), maxAttempts = configuredMaxAttempts ?? 2;
let after = 0, pageCount = 0, endpointExhausted = !1;
const seenCursors = /* @__PURE__ */ new Set();
for (; pageCount < maxPages; ) {
this.#assertActive(), throwIfAborted(options.signal);
let payload, observedAt = this.#now(), lastError;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
throwIfAborted(options.signal), await awaitWithSignal(options.beforePage?.(), options.signal), this.#assertActive(), throwIfAborted(options.signal), observedAt = this.#now();
const activeRequest = Object.freeze({
parentPostId,
after,
background: profile.background,
...options.business === void 0 ? {} : { business: options.business },
...options.refresh === void 0 ? {} : { refresh: options.refresh },
...options.signal === void 0 ? {} : { signal: options.signal },
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork }
});
profile.activeRequest = activeRequest;
try {
payload = await request.call(
this.#requests,
parentPostNumber,
activeRequest
), lastError = void 0;
break;
} catch (error) {
if (lastError = error, options.signal?.aborted || isAbortFailure(error) || isAuthFailure(error) || isThrottleFailure(error)) throw error;
const allowedAttempts = configuredMaxAttempts ?? (profile.background ? 1 : 2);
if (attempt + 1 < allowedAttempts)
await awaitWithSignal(this.#wait(240), options.signal);
else
break;
} finally {
profile.activeRequest === activeRequest && (profile.activeRequest = null);
}
}
if (lastError !== void 0) throw lastError;
this.#assertActive(), pageCount += 1;
const pagePosts = discoursePostsFromPayload(payload), scopedPagePosts = pagePosts.map((post) => scopedReplyPost(post, parentPostNumber)).filter((post) => post !== null);
if (scopedPagePosts.length) {
throwIfAborted(options.signal), await awaitWithSignal(options.beforeCommit?.(), options.signal), this.#assertActive(), throwIfAborted(options.signal), this.ingestPosts(scopedPagePosts, "loader-batch", observedAt);
for (const post of scopedPagePosts)
scopedPostsByNumber.set(
(0, import_identifiers.discoursePostReference)(post).postNumber,
post
);
posts = this.#knownDirectReplies(parentPostNumber);
}
const nextAfter = replyPageCursor(pagePosts);
if (endpointExhausted = pagePosts.length === 0 || pagePosts.length < import_native_request_descriptors.DISCOURSE_DIRECT_REPLIES_PAGE_SIZE || nextAfter <= after || seenCursors.has(nextAfter), expectedCount <= scopedPostsByNumber.size || endpointExhausted) {
after = Math.max(after, nextAfter);
break;
}
seenCursors.add(nextAfter), after = nextAfter;
}
return Object.freeze({
parentPostNumber,
posts,
scopedPosts: Object.freeze([...scopedPostsByNumber.values()]),
expectedCount,
complete: expectedCount <= scopedPostsByNumber.size,
endpointExhausted,
pageCount,
nextAfter: after
});
}
#canonicalBranchContains(parentPostNumber, childPostNumber) {
const seen = /* @__PURE__ */ new Set();
let current = childPostNumber;
for (; current !== null && !seen.has(current); ) {
if (current === parentPostNumber) return !0;
seen.add(current);
const parent = this.#replies.topology.parentOf(current);
current = parent == null ? null : (0, import_identifiers.discoursePostReference)({ post_number: parent }).postNumber;
}
return !1;
}
#knownDirectReplies(parentPostNumber) {
return Object.freeze(
this.#replies.topology.childrenOf(parentPostNumber).map((postNumber) => this.#postByNumber.get(
(0, import_identifiers.discoursePostReference)({ post_number: postNumber }).postNumber
)).filter((post) => post !== void 0)
);
}
async #initialize(options) {
this.#assertActive(), await Promise.all([
this.#snapshots.restore(),
this.#replies.restore()
]), this.#restoreIndexes(), await this.#prepareRestoredModerationHiddenPosts();
const snapshot = this.#snapshots.snapshot();
this.#authoritativeTopicExpectedPostCount = snapshot.expectedPostCount, this.#createdPostNumbersSinceAuthoritativeTopic.clear(), this.#replies.setExpectedPostCount(snapshot.expectedPostCount);
const cachedPosts = this.#snapshots.posts();
if (this.#replies.coverage().knownPostCount < cachedPosts.length) {
const repairedTree = this.#replies.ingest(
cachedPosts,
"loader-batch",
{ observedAt: snapshot.updatedAt }
);
for (const error of repairedTree.listenerErrors) this.#onError(error);
}
const restoredStreamCount = this.#streamPostIds.reduce(
(count, postId) => count + (this.#postById.has(postId) ? 1 : 0),
0
), archivedTopic = this.#snapshots.localArchiveState().topic !== null;
return this.#topic !== null && this.#streamPostIds.length > 0 && (archivedTopic || snapshot.expectedPostCount <= this.#streamPostIds.length) ? (this.#initializedFromCache = !0, this.#syncLocalArchiveState(), this.#onInitializeSource("cache", Object.freeze({
cachedCount: restoredStreamCount,
missingCount: 0,
totalCount: this.#streamPostIds.length
})), this.#refreshCachedInBackground && !this.#snapshots.isFresh() && this.refresh({ background: !0 }).then(() => this.#snapshots.localArchiveState().topic ? null : this.loadPostById(this.#streamPostIds.at(-1), {
background: !0
})).catch(this.#onError), this.#topic) : (this.#initializedFromCache = !1, this.#onInitializeSource("network", Object.freeze({
cachedCount: restoredStreamCount,
missingCount: Math.max(
1,
snapshot.expectedPostCount - restoredStreamCount
),
totalCount: Math.max(
1,
snapshot.expectedPostCount,
this.#streamPostIds.length
)
})), this.#loadTopic(options, !1));
}
async #prepareRestoredModerationHiddenPosts() {
const currentPosts = this.cachedPosts(), prepared = await this.#prepareModerationHiddenPosts(currentPosts);
if (prepared === currentPosts) return;
const snapshot = this.#snapshots.snapshot(), archived = this.#snapshots.localArchiveState();
for (let index = 0; index < prepared.length; index += 1) {
const post = prepared[index];
if (post === currentPosts[index]) continue;
const postNumber = (0, import_identifiers.discoursePostReference)(post).postNumber, stored = snapshot.posts.find((entry) => entry.postNumber === postNumber);
this.ingestPosts(
[post],
stored?.source ?? "loader-batch",
stored?.observedAt ?? snapshot.updatedAt
);
const marker = archived.posts.find((entry) => entry.postNumber === postNumber);
marker && (this.#snapshots.markPostUnavailable(
postNumber,
marker.status,
marker.confirmedAt
), this.#unavailablePostNumbers.add(postNumber));
}
this.#syncLocalArchiveState();
}
#commitTopic(topic, source, observedAt, preparedPosts) {
const payloadTopicId = topic.id === void 0 ? this.topicId : (0, import_identifiers.discourseTopicId)(topic.id);
if (payloadTopicId !== this.topicId)
throw new Error(`Topic 响应 ${payloadTopicId} 与会话 ${this.topicId} 不一致`);
const streamPostIds = this.#mergeStreamPostIds(
(0, import_identifiers.discoursePostIdStream)(topic.post_stream?.stream ?? [])
), posts = preparedPosts ?? discoursePostsFromPayload(topic), expectedPostCount = nonNegativeInteger(topic.posts_count);
return this.#commit(
{ topic, posts, streamPostIds, expectedPostCount },
source,
observedAt
);
}
#commit(input, source, observedAt) {
const normalized = [];
let ignoredPosts = 0;
const byId = /* @__PURE__ */ new Map(), byNumber = /* @__PURE__ */ new Map();
for (const post of input.posts)
try {
const reference = (0, import_identifiers.discoursePostReference)(post);
if (reference.postId === null) throw new Error("Topic 楼层缺少 post.id");
if (reference.topicId !== null && reference.topicId !== this.topicId)
throw new Error(`楼层 #${reference.postNumber} 属于其他 Topic`);
const entry = Object.freeze({
post,
postId: reference.postId,
postNumber: reference.postNumber
}), previousById = byId.get(entry.postId);
previousById && (previousById.postNumber !== entry.postNumber && this.#onError(new Error(
`post.id ${entry.postId} 同时映射楼层 #${previousById.postNumber} 与 #${entry.postNumber}`
)), byNumber.delete(previousById.postNumber));
const previousByNumber = byNumber.get(entry.postNumber);
previousByNumber && (previousByNumber.postId !== entry.postId && this.#onError(new Error(
`楼层 #${entry.postNumber} 同时映射 post.id ${previousByNumber.postId} 与 ${entry.postId}`
)), byId.delete(previousByNumber.postId)), byId.set(entry.postId, entry), byNumber.set(entry.postNumber, entry);
} catch (error) {
ignoredPosts += 1, this.#onError(error);
}
normalized.push(...byId.values());
const posts = normalized.map((entry) => entry.post), treeResult = this.#replies.ingest(posts, source, { observedAt }), snapshotResult = this.#snapshots.ingest({
source,
observedAt,
...input.topic === void 0 ? {} : { topic: input.topic },
...input.streamPostIds === void 0 ? {} : { streamPostIds: input.streamPostIds },
...input.expectedPostCount === void 0 ? {} : { expectedPostCount: input.expectedPostCount },
posts
});
input.expectedPostCount !== void 0 && this.#replies.setExpectedPostCount(
this.#snapshots.snapshot().expectedPostCount
), input.topic !== void 0 && (this.#authoritativeTopicExpectedPostCount = this.#snapshots.snapshot().expectedPostCount, this.#createdPostNumbersSinceAuthoritativeTopic.clear()), this.#applySnapshotChanges(snapshotResult.changedPostNumbers), this.#syncLocalArchiveState();
const result = Object.freeze({
source,
observedAt,
acceptedPosts: snapshotResult.acceptedPosts,
ignoredPosts: ignoredPosts + snapshotResult.ignoredPosts,
changedPostNumbers: snapshotResult.changedPostNumbers,
topicChanged: snapshotResult.topicChanged,
streamChanged: snapshotResult.streamChanged
});
for (const error of treeResult.listenerErrors) this.#onError(error);
for (const error of this.changes.emit(result)) this.#onError(error);
return result;
}
/**
* 常规网络批次只会新增或刷新少量楼层;按仓储裁决后的 winner 增量更新索引,
* 避免全帖补流时每批重扫此前所有帖子。身份漂移或冲突属于异常输入,回退到
* 完整重建以保留既有冲突诊断与确定性 winner 语义。
*/
#applySnapshotChanges(changedPostNumbers) {
if (this.#topic = this.#snapshots.topic(), this.#syncStreamPostIds(), !changedPostNumbers.length) return;
const entries = [], changedIds = /* @__PURE__ */ new Set();
let postStreamIndexChanged = !1;
for (const rawPostNumber of changedPostNumbers) {
const postNumber = (0, import_identifiers.discoursePostNumber)(rawPostNumber), post = this.#snapshots.post(postNumber);
if (!post) {
this.#restoreIndexes();
return;
}
try {
const reference = (0, import_identifiers.discoursePostReference)(post);
if (reference.postId === null || reference.postNumber !== postNumber || changedIds.has(reference.postId)) {
this.#restoreIndexes();
return;
}
const previousAtNumber = this.#postByNumber.get(postNumber);
if (previousAtNumber && (0, import_identifiers.discoursePostReference)(previousAtNumber).postId !== reference.postId) {
this.#restoreIndexes();
return;
}
const previousAtId = this.#postById.get(reference.postId);
if (previousAtId && (0, import_identifiers.discoursePostReference)(previousAtId).postNumber !== postNumber) {
this.#restoreIndexes();
return;
}
if (!!previousAtNumber != !!previousAtId) {
this.#restoreIndexes();
return;
}
previousAtNumber || (postStreamIndexChanged = !0), changedIds.add(reference.postId), entries.push(Object.freeze({
post,
postId: reference.postId,
postNumber
}));
} catch (error) {
this.#onError(error), this.#restoreIndexes();
return;
}
}
this.#cachedPostsSnapshot = null;
const archivedPostNumbers = new Set(
this.#snapshots.localArchiveState().posts.map((entry) => (0, import_identifiers.discoursePostNumber)(entry.postNumber))
);
for (const entry of entries)
this.#postById.set(entry.postId, entry.post), this.#postByNumber.set(entry.postNumber, entry.post), archivedPostNumbers.has(entry.postNumber) ? this.#unavailablePostNumbers.add(entry.postNumber) : this.#unavailablePostNumbers.delete(entry.postNumber);
postStreamIndexChanged && (this.#postStreamRevision += 1);
}
#restoreIndexes() {
this.#cachedPostsSnapshot = null, this.#topic = this.#snapshots.topic(), this.#syncStreamPostIds(), this.#postById.clear(), this.#postByNumber.clear();
const normalizedById = /* @__PURE__ */ new Map(), normalizedByNumber = /* @__PURE__ */ new Map();
for (const post of this.#snapshots.posts())
try {
const reference = (0, import_identifiers.discoursePostReference)(post);
if (reference.postId === null) continue;
const entry = Object.freeze({
post,
postId: reference.postId,
postNumber: reference.postNumber
}), previousById = normalizedById.get(entry.postId);
previousById && (previousById.postNumber !== entry.postNumber && this.#onError(new Error(
`快照 post.id ${entry.postId} 同时映射楼层 #${previousById.postNumber} 与 #${entry.postNumber}`
)), normalizedByNumber.delete(previousById.postNumber));
const previousByNumber = normalizedByNumber.get(entry.postNumber);
previousByNumber && (previousByNumber.postId !== entry.postId && this.#onError(new Error(
`快照楼层 #${entry.postNumber} 同时映射 post.id ${previousByNumber.postId} 与 ${entry.postId}`
)), normalizedById.delete(previousByNumber.postId)), normalizedById.set(entry.postId, entry), normalizedByNumber.set(entry.postNumber, entry);
} catch (error) {
this.#onError(error);
}
const archivedPostNumbers = new Set(
this.#snapshots.localArchiveState().posts.map((entry) => (0, import_identifiers.discoursePostReference)({ post_number: entry.postNumber }).postNumber)
);
for (const entry of normalizedById.values()) this.#postById.set(entry.postId, entry.post);
for (const entry of normalizedByNumber.values())
this.#postByNumber.set(entry.postNumber, entry.post);
for (const postNumber of this.#unavailablePostNumbers)
this.#postByNumber.has(postNumber) && !archivedPostNumbers.has(postNumber) && this.#unavailablePostNumbers.delete(postNumber);
for (const postNumber of archivedPostNumbers)
this.#unavailablePostNumbers.add(postNumber);
this.#postStreamRevision += 1;
}
#syncStreamPostIds() {
const next = this.#snapshots.streamPostIds();
if (next !== this.#streamPostIds) {
this.#streamPostIds = next, this.#postStreamRevision += 1, this.#streamIndexByPostId.clear();
for (let index = 0; index < next.length; index += 1)
this.#streamIndexByPostId.set(next[index], index);
}
}
#syncLocalArchiveState() {
const snapshot = this.#snapshots.localArchiveState(), key = JSON.stringify([
snapshot.topic?.status ?? 0,
snapshot.topic?.confirmedAt ?? 0,
snapshot.posts.map((entry) => [
entry.postNumber,
entry.status,
entry.confirmedAt
])
]);
if (key !== this.#archiveStateKey) {
this.#archiveStateKey = key;
for (const error of this.archiveChanges.emit(snapshot)) this.#onError(error);
}
}
#isLocalArchiveTopic() {
return this.#snapshots.localArchiveState().topic !== null;
}
#mergeStreamPostIds(incoming) {
if (!this.#streamPostIds.length) return incoming;
const merged = [...incoming], known = new Set(merged);
for (const postId of this.#streamPostIds) {
if (known.has(postId)) continue;
const post = this.#postById.get(postId);
if (!post) {
merged.push(postId), known.add(postId);
continue;
}
const postNumber = (0, import_identifiers.discoursePostReference)(post).postNumber;
let insertAt = merged.length;
for (let index = 0; index < merged.length; index += 1) {
const candidate = this.#postById.get(merged[index]);
if (candidate && (0, import_identifiers.discoursePostReference)(candidate).postNumber > postNumber) {
insertAt = index;
break;
}
}
merged.splice(insertAt, 0, postId), known.add(postId);
}
return Object.freeze(merged);
}
#streamWithCreatedPost(postId, postNumber) {
if (this.#streamPostIds.includes(postId)) return;
const mutable = [...this.#streamPostIds];
let insertAt = mutable.length;
for (; insertAt > 0; ) {
const previous = this.#postById.get(mutable[insertAt - 1]);
if (!previous || (0, import_identifiers.discoursePostReference)(previous).postNumber <= postNumber) break;
insertAt -= 1;
}
return mutable.splice(insertAt, 0, postId), Object.freeze(mutable);
}
async #ensurePostIds(missing, options) {
if (options.ingestSource === "target-refresh") {
await this.#fetchPostBatch(missing, options, 0);
return;
}
options.background !== !0 && this.#promotePendingPostBatches(options);
const unclaimed = missing.filter((postId) => !this.#pendingByPostId.has(postId));
if (unclaimed.length) {
const request = this.#fetchPostBatch(unclaimed, options, 0).finally(() => {
for (const postId of unclaimed)
this.#pendingByPostId.get(postId) === request && this.#pendingByPostId.delete(postId);
});
for (const postId of unclaimed) this.#pendingByPostId.set(postId, request);
}
await Promise.all([...new Set(
missing.map((postId) => this.#pendingByPostId.get(postId)).filter(
(request) => request !== void 0
)
)]);
}
#promotePendingPostBatches(options) {
if (!this.#requests.promotePostsByIds) return;
const claimed = new Set(this.#pendingByPostId.values());
for (const request of claimed) {
const batch = [...this.#pendingByPostId.entries()].filter(([, pending]) => pending === request).map(([postId]) => postId);
batch.length && this.#requests.promotePostsByIds(batch, options);
}
}
async #fetchPostBatch(postIds, options, splitDepth) {
const observedAt = this.#now();
try {
const payload = await this.#requests.loadPostsByIds(postIds, {
...options.background === void 0 ? {} : { background: options.background },
...options.refresh === void 0 ? {} : { refresh: options.refresh },
...options.priority === void 0 ? {} : { priority: options.priority },
...options.prefetchTier === void 0 ? {} : { prefetchTier: options.prefetchTier },
...options.business === void 0 ? {} : { business: options.business },
...options.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork }
}), posts = discoursePostsFromPayload(payload);
await options.beforeCommit?.(), this.#assertActive(), this.ingestPosts(
posts,
options.ingestSource ?? "loader-batch",
observedAt
);
} catch (error) {
if (shouldSplit(error) && postIds.length > 1 && splitDepth < 1) {
const middle = Math.ceil(postIds.length / 2), failure = (await Promise.allSettled([
this.#fetchPostBatch(postIds.slice(0, middle), options, splitDepth + 1),
this.#fetchPostBatch(postIds.slice(middle), options, splitDepth + 1)
])).find((result) => result.status === "rejected");
if (failure) throw failure.reason;
return;
}
throw error;
}
}
async #loadRelative(rawPostNumber, direction, options) {
if (this.#assertActive(), !this.#streamPostIds.length) return Object.freeze([]);
const postNumber = (0, import_identifiers.discoursePostReference)({ post_number: rawPostNumber }).postNumber, last = direction === "last", targetIndex = last ? this.#streamPostIds.length - 1 : this.#streamIndexForPostNumber(postNumber);
if (direction === "before" && targetIndex <= 0) return Object.freeze([]);
const start = last ? targetIndex : direction === "before" ? Math.max(0, targetIndex - this.#pageSize) : Math.min(this.#streamPostIds.length, targetIndex + 1), end = last ? this.#streamPostIds.length : direction === "before" ? targetIndex : start + this.#pageSize, result = await this.loadPostsByIds(this.#streamPostIds.slice(start, end), options);
return last && result.posts.length && this.#advanceCursorPast(result.posts), result.posts;
}
#streamIndexForPostNumber(postNumber) {
const post = this.#postByNumber.get(postNumber);
if (post) {
const reference = (0, import_identifiers.discoursePostReference)(post);
if (reference.postId !== null) {
const exact = this.#streamIndexByPostId.get(reference.postId);
if (exact !== void 0) return exact;
}
}
let bestIndex = -1, bestDistance = Number.POSITIVE_INFINITY;
for (const [knownPostNumber, knownPost] of this.#postByNumber) {
let knownIndex;
try {
const knownPostId = (0, import_identifiers.discoursePostReference)(knownPost).postId;
knownPostId !== null && (knownIndex = this.#streamIndexByPostId.get(knownPostId));
} catch {
continue;
}
if (knownIndex === void 0) continue;
const distance = Math.abs(knownPostNumber - postNumber);
(distance < bestDistance || distance === bestDistance && knownPostNumber <= postNumber) && (bestDistance = distance, bestIndex = knownIndex + (postNumber - knownPostNumber));
}
return Math.min(
this.#streamPostIds.length - 1,
Math.max(0, bestIndex >= 0 ? bestIndex : postNumber - 1)
);
}
#advanceCursorPast(posts, targetPostNumber) {
let maxIndex = -1;
for (const post of posts)
try {
const reference = (0, import_identifiers.discoursePostReference)(post);
reference.postId !== null && (maxIndex = Math.max(maxIndex, this.#streamPostIds.indexOf(reference.postId)));
} catch {
}
maxIndex < 0 && targetPostNumber !== void 0 && (maxIndex = Math.min(
this.#streamPostIds.length - 1,
Math.max(0, targetPostNumber - 1)
)), maxIndex >= this.#cursor && (this.#cursor = maxIndex + 1);
}
#batchResult(posts, done, missingPostIds, retry, fatal, error) {
return Object.freeze({
posts: Object.freeze([...posts]),
done,
retry,
fatal,
...error === void 0 ? {} : { error },
missingPostIds: freezeNumbers(missingPostIds)
});
}
#assertActive() {
if (this.#closed || this.scope.destroyed || this.#signal?.aborted)
throw this.#signal?.aborted ? this.#signal.reason ?? new DOMException("Topic session closed", "AbortError") : new DOMException("Topic session closed", "AbortError");
}
}
}, "d28b503a365349abf86e09081e675070b96063a6c1f88f3ede7db143e1ac5dbe");
/* Source: lite/src/userscript/browser-share-surface.ts */
runtime.register("src/userscript/browser-share-surface.js", function(module, exports, require) {
var browser_share_surface_exports = {};
__export(browser_share_surface_exports, {
BrowserReaderShareSurface: () => BrowserReaderShareSurface
});
module.exports = __toCommonJS(browser_share_surface_exports);
var import_value_record = require("../kernel/value-record.js");
class BrowserReaderShareSurface {
#navigator;
constructor(pageWindow) {
const page = (0, import_value_record.valueRecord)(pageWindow);
this.#navigator = (0, import_value_record.valueRecord)(page?.navigator) ?? Object.freeze({});
}
async share(input) {
const share = this.#navigator.share;
if (typeof share != "function") return "unsupported";
try {
return await share.call(this.#navigator, input), "shared";
} catch (cause) {
if ((0, import_value_record.valueRecord)(cause)?.name === "AbortError") return "cancelled";
throw cause;
}
}
async copyText(text) {
const clipboard = (0, import_value_record.valueRecord)(this.#navigator.clipboard), writeText = clipboard?.writeText;
if (typeof writeText != "function")
throw new Error("浏览器剪贴板不可用");
await writeText.call(clipboard, text);
}
}
}, "ea7818f3373ea5d34e4251de1fa84d0f8d0f136ac4484637bb53967e9486b9d8");
/* Source: lite/src/userscript/browser-userscript-environment.ts */
runtime.register("src/userscript/browser-userscript-environment.js", function(module, exports, require) {
var browser_userscript_environment_exports = {};
__export(browser_userscript_environment_exports, {
BrowserUserscriptEnvironment: () => BrowserUserscriptEnvironment
});
module.exports = __toCommonJS(browser_userscript_environment_exports);
var import_native_host_api = require("../discourse/native-host-api.js"), import_coordinated_request_client = require("../network/coordinated-request-client.js"), import_discourse_native_read_transport = require("../network/discourse-native-read-transport.js"), import_public_resource_request_adapter = require("../network/public-resource-request-adapter.js"), import_reader_search = require("../search/reader-search.js"), import_translation_request_adapter = require("../translation/translation-request-adapter.js"), import_browser_share_surface = require("./browser-share-surface.js"), import_browser_discourse_site_probe = require("../site/browser-discourse-site-probe.js"), import_value_record = require("../kernel/value-record.js"), import_reader_webdav_client = require("../sync/reader-webdav-client.js");
function runtimeReadinessTimeout(pageWindow) {
const page = (0, import_value_record.objectRecord)(pageWindow), navigatorValue = (0, import_value_record.objectRecord)(page?.navigator), connection = (0, import_value_record.objectRecord)(navigatorValue?.connection), documentValue = (0, import_value_record.objectRecord)(page?.document), cores = Number(navigatorValue?.hardwareConcurrency), memory = Number(navigatorValue?.deviceMemory), effectiveType = String(connection?.effectiveType ?? "").trim().toLowerCase();
return String(documentValue?.visibilityState ?? "") === "hidden" ? 6e4 : Number.isFinite(cores) && cores > 0 && cores <= 4 || Number.isFinite(memory) && memory > 0 && memory <= 4 || connection?.saveData === !0 || ["slow-2g", "2g", "3g"].includes(effectiveType) ? 45e3 : 25e3;
}
class BrowserUserscriptEnvironment {
#userscriptGlobal;
pageWindow;
discourseHost;
constructor(options) {
const userscriptGlobal = (0, import_value_record.objectRecord)(options.userscriptGlobal);
if (!userscriptGlobal) throw new Error("userscript global 不可用");
const pageWindow = userscriptGlobal.unsafeWindow ?? userscriptGlobal.window;
if (!(0, import_value_record.objectRecord)(pageWindow))
throw new Error("userscript page window 不可用");
this.#userscriptGlobal = userscriptGlobal, this.pageWindow = pageWindow, this.discourseHost = new import_native_host_api.BrowserDiscourseHostApiPort({ pageWindow });
}
async waitForDiscourseRuntime(signal, options = {}) {
const timeoutMs = Math.max(
1,
Math.floor(
options.timeoutMs ?? runtimeReadinessTimeout(this.pageWindow)
)
), pollIntervalMs = Math.max(
1,
Math.floor(options.pollIntervalMs ?? 50)
), now = options.now ?? Date.now, delay = options.delay ?? import_coordinated_request_client.abortableDelay, startedAt = now();
for (; !(0, import_discourse_native_read_transport.discourseNativeAjaxAvailable)(this.discourseHost) || !(0, import_native_host_api.discourseNativeCurrentUserBindingAvailable)(this.discourseHost); ) {
if (signal.aborted) throw signal.reason;
const remaining = timeoutMs - (now() - startedAt);
if (remaining <= 0)
throw new Error(
"Discourse 原生 Ajax/current-user 在启动期限内未就绪"
);
await delay(Math.min(pollIntervalMs, remaining), signal);
}
}
createExternalHttp(options = {}) {
const rawRequest = this.#userscriptGlobal.GM_xmlhttpRequest;
if (typeof rawRequest != "function")
throw new Error("GM_xmlhttpRequest 不可用,无法访问登记的外部服务");
const request = (requestOptions) => rawRequest.call(this.#userscriptGlobal, requestOptions);
return new import_translation_request_adapter.BrowserUserscriptExternalHttpPort({
...options,
request
});
}
createDiscourseSiteProbe() {
const rawRequest = this.#userscriptGlobal.GM_xmlhttpRequest;
if (typeof rawRequest != "function")
throw new Error("GM_xmlhttpRequest 不可用,无法检测自定义站点");
const request = (requestOptions) => rawRequest.call(this.#userscriptGlobal, requestOptions);
return new import_browser_discourse_site_probe.BrowserDiscourseSiteProbe({ request });
}
createWebDavClient() {
const rawRequest = this.#userscriptGlobal.GM_xmlhttpRequest;
if (typeof rawRequest != "function")
throw new Error("GM_xmlhttpRequest 不可用,无法访问 WebDAV");
const request = (requestOptions) => rawRequest.call(this.#userscriptGlobal, requestOptions);
return new import_reader_webdav_client.ReaderWebDavClient({ request });
}
createPublicResourceHttp() {
const rawRequest = (0, import_value_record.objectRecord)(this.pageWindow)?.fetch;
if (typeof rawRequest != "function")
throw new Error("page window fetch 不可用,无法访问公共图片资源");
const request = (input, init) => rawRequest.call(this.pageWindow, input, init);
return new import_public_resource_request_adapter.BrowserPublicResourceHttpPort({ request });
}
createExternalStylesheetAppender() {
const rawAddElement = this.#userscriptGlobal.GM_addElement;
if (typeof rawAddElement == "function")
return (href) => rawAddElement.call(
this.#userscriptGlobal,
"link",
Object.freeze({ rel: "stylesheet", href })
);
const document = (0, import_value_record.objectRecord)(this.pageWindow)?.document;
if (!document?.createElement)
throw new Error("页面无法创建外部字体样式");
return (href) => {
const link = document.createElement("link");
return link.rel = "stylesheet", link.href = href, (document.head ?? document.documentElement).append(link), link;
};
}
createCreditBridgeHttp() {
const rawRequest = (0, import_value_record.objectRecord)(this.pageWindow)?.fetch;
if (typeof rawRequest != "function")
throw new Error("page window fetch 不可用,无法读取 LDC 账户摘要");
return Object.freeze({
loadUserInfo: async (signal) => {
const response = await rawRequest.call(
this.pageWindow,
"/api/v1/oauth/user-info",
{
signal,
credentials: "include",
cache: "no-store",
headers: { Accept: "application/json" }
}
);
if (!response.ok) throw new Error(`LDC HTTP ${response.status}`);
return response.json();
}
});
}
createObjectUrlPort() {
const urlOwner = (0, import_value_record.objectRecord)(this.pageWindow)?.URL, create = urlOwner?.createObjectURL, revoke = urlOwner?.revokeObjectURL;
if (typeof create != "function" || typeof revoke != "function")
throw new Error("page window URL.createObjectURL/revokeObjectURL 不可用");
return Object.freeze({
createObjectURL: (blob) => String(create.call(urlOwner, blob)),
revokeObjectURL: (source) => {
revoke.call(urlOwner, source);
}
});
}
createAssetCacheStorage() {
const page = (0, import_value_record.objectRecord)(this.pageWindow), storage = (0, import_value_record.objectRecord)(page?.caches), keys = storage?.keys, open = storage?.open, remove = storage?.delete;
return typeof keys != "function" || typeof open != "function" || typeof remove != "function" ? null : Object.freeze({
keys: () => keys.call(storage),
open: (name) => open.call(storage, name),
delete: (name) => remove.call(storage, name)
});
}
createValueStorage() {
const subscription = (owner, add, remove) => (key, listener) => {
let active = !0, id;
try {
id = Promise.resolve(add.call(
owner,
String(key),
(_name, previous, value, remote) => {
active && remote === !0 && listener(value, previous);
}
));
} catch {
return () => {
};
}
return () => {
active && (active = !1, id.then((listenerId) => remove.call(owner, listenerId)).catch(() => {
}));
};
}, modern = (0, import_value_record.objectRecord)(this.#userscriptGlobal.GM), modernGet = modern?.getValue, modernSet = modern?.setValue, modernAdd = modern?.addValueChangeListener, modernRemove = modern?.removeValueChangeListener;
if (modern && typeof modernGet == "function" && typeof modernSet == "function")
return Object.freeze({
getValue: (key) => modernGet.call(modern, key, null),
setValue: (key, value) => modernSet.call(modern, key, value),
...typeof modernAdd == "function" && typeof modernRemove == "function" ? {
subscribe: subscription(
modern,
modernAdd,
modernRemove
)
} : {}
});
const legacyGet = this.#userscriptGlobal.GM_getValue, legacySet = this.#userscriptGlobal.GM_setValue, legacyAdd = this.#userscriptGlobal.GM_addValueChangeListener, legacyRemove = this.#userscriptGlobal.GM_removeValueChangeListener;
return typeof legacyGet != "function" || typeof legacySet != "function" ? null : Object.freeze({
getValue: (key) => legacyGet.call(this.#userscriptGlobal, key, null),
setValue: (key, value) => legacySet.call(this.#userscriptGlobal, key, value),
...typeof legacyAdd == "function" && typeof legacyRemove == "function" ? {
subscribe: subscription(
this.#userscriptGlobal,
legacyAdd,
legacyRemove
)
} : {}
});
}
createShareSurface() {
return new import_browser_share_surface.BrowserReaderShareSurface(this.pageWindow);
}
createKatexPort() {
const page = (0, import_value_record.objectRecord)(this.pageWindow), owner = (0, import_value_record.objectRecord)(this.#userscriptGlobal.katex) ?? (0, import_value_record.objectRecord)(page?.katex), render = owner?.render;
return typeof render != "function" ? null : Object.freeze({
render: (tex, target, options) => {
render.call(owner, tex, target, options);
}
});
}
createHlsPort() {
const page = (0, import_value_record.objectRecord)(this.pageWindow), candidate = this.#userscriptGlobal.Hls ?? page?.Hls, isSupported = candidate?.isSupported;
return typeof candidate != "function" || typeof isSupported != "function" ? null : Object.freeze({
isSupported: () => !!isSupported.call(candidate),
create: () => {
const player = new candidate(), loadSource = player.loadSource, attachMedia = player.attachMedia, destroy = player.destroy;
if (typeof loadSource != "function" || typeof attachMedia != "function" || typeof destroy != "function")
throw new Error("Hls player 缺少标准生命周期方法");
return Object.freeze({
loadSource: (source) => {
loadSource.call(player, source);
},
attachMedia: (video) => {
attachMedia.call(player, video);
},
destroy: () => {
destroy.call(player);
}
});
}
});
}
readScriptVersion() {
const modern = (0, import_value_record.objectRecord)(this.#userscriptGlobal.GM), info = (0, import_value_record.objectRecord)(modern?.info) ?? (0, import_value_record.objectRecord)(this.#userscriptGlobal.GM_info), script = (0, import_value_record.objectRecord)(info?.script);
return String(script?.version ?? "").trim() || null;
}
async readTextResource(name) {
const resourceName = String(name).trim();
if (!resourceName) throw new Error("userscript resource name 不能为空");
const modern = (0, import_value_record.objectRecord)(this.#userscriptGlobal.GM), modernRead = modern?.getResourceText, legacyRead = this.#userscriptGlobal.GM_getResourceText, value = typeof modernRead == "function" ? await modernRead.call(modern, resourceName) : typeof legacyRead == "function" ? await legacyRead.call(this.#userscriptGlobal, resourceName) : null;
if (typeof value != "string" || !value.trim())
throw new Error(
`userscript 文本资源 ${resourceName} 不可用`
);
return value;
}
createPinyinSearchForms(maxEntries = 512) {
const limit = Math.floor(Number(maxEntries));
if (!Number.isSafeInteger(limit) || limit <= 0)
throw new RangeError("拼音搜索缓存上限必须是正整数");
const page = (0, import_value_record.objectRecord)(this.pageWindow), pinyinOwner = (0, import_value_record.objectRecord)(this.#userscriptGlobal.pinyinPro) ?? (0, import_value_record.objectRecord)(page?.pinyinPro), pinyin = pinyinOwner?.pinyin, cache = /* @__PURE__ */ new Map();
return (value) => {
const source = String(value ?? "");
if (!source) return Object.freeze([]);
const cached = cache.get(source);
if (cached)
return cache.delete(source), cache.set(source, cached), cached;
const forms = [(0, import_reader_search.normalizeReaderSearchText)(source)];
if (typeof pinyin == "function")
try {
forms.push((0, import_reader_search.normalizeReaderSearchText)(pinyin.call(pinyinOwner, source, {
toneType: "none",
nonZh: "consecutive"
}))), forms.push((0, import_reader_search.normalizeReaderSearchText)(pinyin.call(pinyinOwner, source, {
pattern: "first",
toneType: "none",
nonZh: "consecutive"
})));
} catch {
}
const result = Object.freeze([...new Set(forms.filter(Boolean))]);
for (cache.set(source, result); cache.size > limit; ) {
const oldest = cache.keys().next().value;
if (oldest === void 0) break;
cache.delete(oldest);
}
return result;
};
}
}
}, "f7ae985b3af258589b78ad9bfe575b95dec7d094f424902dfffcd6ae2db45112");
/* Source: lite/src/userscript/main-lite-bootstrap.ts */
runtime.register("src/userscript/main-lite-bootstrap.js", function(module, exports, require) {
var main_lite_bootstrap_exports = {};
__export(main_lite_bootstrap_exports, {
startMainLiteUserscript: () => startMainLiteUserscript,
startMianLiteUserscript: () => startMianLiteUserscript
});
module.exports = __toCommonJS(main_lite_bootstrap_exports);
var import_native_host_api = require("../discourse/native-host-api.js"), import_reader_native_composer_window = require("../discourse/reader-native-composer-window.js"), import_reader_icon = require("../components/reader-icon.js"), import_reader_history_model = require("../history/reader-history-model.js"), import_reader_katex_controller = require("../media/reader-katex-controller.js"), import_reader_image_preferences = require("../media/reader-image-preferences.js"), import_reader_appearance_style_controller = require("../appearance/reader-appearance-style-controller.js"), import_reader_theme_controller = require("../appearance/reader-theme-controller.js"), import_reader_local_sun_clock = require("../appearance/reader-local-sun-clock.js"), import_reader_font_style_controller = require("../font/reader-font-style-controller.js"), import_reader_layout_style_controller = require("../layout/reader-layout-style-controller.js"), import_reader_shell_template = require("../shell/reader-shell-template.js"), import_reader_floating_window_frame = require("../shell/reader-floating-window-frame.js"), import_reader_surface_portal = require("../shell/reader-surface-portal.js"), import_reader_shortcut_controller = require("../shell/reader-shortcut-controller.js"), import_reader_workspace = require("../shell/reader-workspace.js"), import_embedded_host_topic_card_enhancement = require("../shell/embedded-host-topic-card-enhancement.js"), import_reader_motion_settings_form = require("../settings/reader-motion-settings-form.js"), import_boost_copy_rule = require("../post/boost-copy-rule.js"), import_reader_topic_action_rail = require("../post/reader-topic-action-rail.js"), import_reader_unwanted_topic_filter = require("../collection/reader-unwanted-topic-filter.js"), import_reader_performance_settings_form = require("../settings/reader-performance-settings-form.js"), import_reader_reading_settings_form = require("../settings/reader-reading-settings-form.js"), import_reader_open_queue_session = require("../queue/reader-open-queue-session.js"), import_reader_settings_reset_reminder = require("../settings/reader-settings-reset-reminder.js"), import_reader_browser_storage_management = require("../settings/reader-browser-storage-management.js"), import_read_viewport_adapter = require("../reading/read-viewport-adapter.js"), import_reader_preferences_schema = require("../state/reader-preferences-schema.js"), import_reader_information_flow_coordinator = require("../state/reader-information-flow-coordinator.js"), import_reader_post_presentation = require("../topic/reader-post-presentation.js"), import_translation_text = require("../translation/translation-text.js"), import_reader_translation_config = require("../translation/reader-translation-config.js"), import_reader_reply_tree_preferences = require("../topic/reader-reply-tree-preferences.js"), import_browser_userscript_environment = require("./browser-userscript-environment.js"), import_reader_userscript_application = require("./reader-userscript-application.js"), import_reader_userscript_target_adapter = require("./reader-userscript-target-adapter.js"), import_reader_native_topic_route = require("../topic/reader-native-topic-route.js"), import_reader_credit_account_bridge = require("../user/reader-credit-account-bridge.js"), import_reader_custom_site_repository = require("../site/reader-custom-site-repository.js"), import_reader_detected_site_onboarding = require("../site/reader-detected-site-onboarding.js"), import_reader_embedded_reload_coordinator = require("./reader-embedded-reload-coordinator.js"), import_browser_shared_request_permit = require("../network/browser-shared-request-permit.js"), import_reader_host_turnstile_background_controller = require("../network/reader-host-turnstile-background-controller.js"), import_reader_webdav_config_repository = require("../sync/reader-webdav-config-repository.js");
const DEBUG_HANDLE_KEY = "__LDP_MAIN_LITE__", LEGACY_DEBUG_HANDLE_KEY = "__LDP_MIAN_LITE__", CHALLENGE_MONITOR_KEY = "__LDP_CLOUDFLARE_CHALLENGE_MONITOR__", STYLE_ID = "ldp-mian-lite-styles", STYLE_RESOURCE = "ldpReaderStyles", KATEX_STYLE_RESOURCE = "ldpKatexStyles", KATEX_STYLESHEET_URL = "https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css";
function pageRecord(value) {
if (value === null || typeof value != "object" && typeof value != "function")
throw new Error("main-lite page window 不可用");
return value;
}
function theme(document, window) {
return document.documentElement.classList.contains("dark") || document.documentElement.dataset.colorScheme === "dark" || window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function sourceId(window) {
const value = window.crypto?.randomUUID?.();
return value ? `main-lite:${value}` : `main-lite:${Date.now()}`;
}
function unavailableLocalStorage(cause) {
const error = cause instanceof Error ? cause : new DOMException("当前页面无法访问 localStorage", "SecurityError");
return Object.freeze({
length: 0,
clear() {
throw error;
},
getItem() {
return null;
},
key() {
return null;
},
removeItem() {
throw error;
},
setItem() {
throw error;
}
});
}
function createStyleStage(environment, document, state) {
return Object.freeze({
name: "userscript-styles",
required: !0,
async setup() {
const [css, katexCss] = await Promise.all([
environment.readTextResource(STYLE_RESOURCE),
environment.readTextResource(KATEX_STYLE_RESOURCE)
]);
document.getElementById(STYLE_ID)?.remove();
const style = document.createElement("style");
style.id = STYLE_ID;
const stylesheet = `${css}
${(0, import_reader_katex_controller.readerKatexStylesheet)(
katexCss,
KATEX_STYLESHEET_URL
)}`;
style.textContent = stylesheet;
let portal = null;
try {
portal = new import_reader_surface_portal.ReaderSurfacePortal(document, stylesheet), (document.head ?? document.documentElement).append(style);
} catch (cause) {
throw portal?.destroy(), style.remove(), cause;
}
return state.portal = portal, () => {
portal.destroy(), state.portal === portal && (state.portal = null), style.remove();
};
}
});
}
function createInformationFlowStage(window, storage, preferences, state, bindings) {
return Object.freeze({
name: "information-flow",
required: !0,
setup(scope) {
const informationFlow = new import_reader_information_flow_coordinator.ReaderInformationFlowCoordinator({
storageEvents: window,
parentScope: scope,
onDiagnostic: ({ domain, source, cause }) => {
console.error(
`[main-lite:information-flow:${domain}:${source}]`,
cause
);
}
});
state.informationFlow = informationFlow, informationFlow.register({
domain: "preferences",
storageKeys: [import_reader_preferences_schema.READER_PREFERENCES_STORAGE_KEY],
refresh: () => preferences.reloadExternal()
});
for (const binding of bindings)
storage?.subscribe && informationFlow.register({
domain: binding.domain,
subscriptions: binding.keys.map((key) => ({
source: "userscript-value",
subscribe: (notify) => storage.subscribe(key, notify)
})),
refresh: binding.refresh
});
return () => {
state.informationFlow === informationFlow && (state.informationFlow = null);
};
}
});
}
function requestedMode(preferences, routeKind) {
return routeKind === "direct-topic" ? preferences.topicReaderMode : preferences.listReaderMode;
}
function createRuntimeStage(environment, document, window, localStorage, localStorageAccessError, state, serviceWorkerMessages, suppressInitialTopicOpen, customSites) {
let template = null;
const origin = document.location.origin, siteName = (0, import_reader_custom_site_repository.readerDiscourseSiteDisplayName)(document.location.hostname), routeKind = (0, import_reader_userscript_target_adapter.readerUserscriptRouteKind)(
document.location.href,
origin
);
return Object.freeze({
name: "reader-userscript-runtime",
required: !0,
async setup(scope, applicationContext) {
const readiness = scope.abortController(
new DOMException("main-lite runtime 启动已取消", "AbortError")
);
await environment.waitForDiscourseRuntime(readiness.signal);
const initialPreferences = applicationContext.readPreferences();
let defaultSiteThemeReloadRequested = !1;
const enforceEmbeddedDefaultSiteTheme = (mode) => {
if (!(routeKind !== "list" || (0, import_reader_workspace.readerWorkspacePositionMode)(mode) !== "embedded" || defaultSiteThemeReloadRequested || (0, import_native_host_api.discourseNativeDefaultSiteTheme)(
environment.discourseHost
) !== "updated")) {
defaultSiteThemeReloadRequested = !0;
try {
window.location.reload();
} catch (cause) {
console.error("[main-lite:default-site-theme]", cause);
}
}
};
enforceEmbeddedDefaultSiteTheme(
requestedMode(initialPreferences, routeKind)
);
const replyTreePreferences = new import_reader_reply_tree_preferences.ReaderReplyTreePreferencesPreview(
import_reader_reply_tree_preferences.readerPreferencesReplyTreeAdapter.read(initialPreferences),
(error) => {
console.error("[main-lite:reply-tree-preview]", error);
}
);
scope.add(() => replyTreePreferences.destroy());
const preferencesEnvironment = Object.freeze({
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
}), scriptVersion = environment.readScriptVersion() ?? "development", preferencesCodec = (0, import_reader_preferences_schema.createReaderPreferencesConfigCodec)({
environment: preferencesEnvironment,
scriptVersion
}), preferencesDefaults = (0, import_reader_preferences_schema.createReaderPreferencesDefaults)(
preferencesEnvironment
), currentUsername = (0, import_native_host_api.discourseNativeInitialCurrentUsername)(
environment.discourseHost
), renderNativeIcon = (0, import_native_host_api.discourseNativeIconRenderer)(
environment.discourseHost
), siteLogoCandidates = [
document.querySelector(
".d-header #site-logo,.d-header img.logo-big,.d-header img.logo-small,.custom-logo-link img"
)?.currentSrc,
document.querySelector(
'link[rel~="apple-touch-icon"]'
)?.href,
document.querySelector(
'link[rel~="icon"]'
)?.href
].filter((value) => !!value), siteLogoUrl = (0, import_native_host_api.discourseNativeSiteLogoUrl)(
environment.discourseHost,
origin,
siteLogoCandidates
), renderIcon = (name, iconDocument) => {
const nativeIcon = renderNativeIcon(name, iconDocument);
return (0, import_reader_icon.resolveReaderIcon)(iconDocument, name, nativeIcon);
}, katex = environment.createKatexPort(), hls = environment.createHlsPort(), bodyTranslationAllowed = (0, import_reader_custom_site_repository.readerDiscourseSiteAllowsBodyTranslation)(
document.location.hostname
);
let persistTranslationMode = null, hostTopicEnhancement = null, hostOpenedTopicsRegistered = !1;
const unwantedTopicFilter = Object.freeze({
read: () => import_reader_unwanted_topic_filter.readerPreferencesUnwantedTopicFilterAdapter.read(
applicationContext.readPreferences()
),
update: (preferences) => {
if (!applicationContext.updatePreferences)
throw new Error("当前环境不能保存自动过滤设置");
applicationContext.updatePreferences(
import_reader_unwanted_topic_filter.readerPreferencesUnwantedTopicFilterAdapter.createPatch(
preferences
)
);
},
subscribe: (listener, preferenceScope) => {
let previous = import_reader_unwanted_topic_filter.readerPreferencesUnwantedTopicFilterAdapter.read(
applicationContext.readPreferences()
);
return applicationContext.preferenceChanges.subscribe(
(preferences) => {
const next = import_reader_unwanted_topic_filter.readerPreferencesUnwantedTopicFilterAdapter.read(
preferences
);
(0, import_reader_unwanted_topic_filter.readerUnwantedTopicFilterPreferencesEqual)(
previous,
next
) || (previous = next, listener(next));
},
preferenceScope
);
}
}), pendingAutomaticTopics = /* @__PURE__ */ new Map();
scope.add(() => pendingAutomaticTopics.clear());
const recordAutomaticTopic = (input) => {
const topicId = Number(input.topicId), runtime = state.runtime;
return runtime ? (runtime.unwantedTopics.remember(input), pendingAutomaticTopics.delete(topicId), !0) : (pendingAutomaticTopics.set(topicId, input), !1);
}, informationFlow = state.informationFlow;
if (!informationFlow)
throw new Error("main-lite 统一信息流协调器尚未就绪");
return (0, import_reader_userscript_application.createReaderUserscriptRuntimeStage)({
environment,
informationFlow,
shell: {
compatibilityKey: () => `${origin}:mian-lite:v1`,
createView: () => {
const portal = state.portal;
if (!portal) throw new Error("main-lite Shadow Portal 未就绪");
return template = (0, import_reader_shell_template.createReaderShellTemplate)({
document,
mount: portal.root,
listModeAllowed: routeKind === "list",
siteName,
homeUrl: `${origin}/`,
logoUrl: siteLogoUrl,
renderIcon
}), template.view;
},
createWorkspaceOptions: (_shell, context) => {
if (!template) throw new Error("main-lite Shell template 未创建");
const readPreferences = context.readPreferences, scrolling = () => document.scrollingElement ?? document.documentElement;
return hostTopicEnhancement = new import_embedded_host_topic_card_enhancement.EmbeddedHostTopicCardEnhancement(
document,
environment.discourseHost,
{
openedTopicStorage: localStorage,
openedTopicStorageScope: currentUsername,
isTopicHidden: (topicId) => state.runtime?.topicStates.isManuallyHidden(topicId) === !0,
hideTopic: (input) => {
const runtime = state.runtime;
if (!runtime) throw new Error("不想看仓库尚未就绪");
runtime.unwantedTopics.remember(input);
},
recordAutomaticTopic,
automaticFilter: (input) => (0, import_reader_unwanted_topic_filter.readerUnwantedTopicFilterPortMatch)(
unwantedTopicFilter,
input
),
notify: (message) => state.runtime?.feedback.show(message),
onError: (cause) => {
console.error(
"[main-lite:host-topic-notification]",
cause
);
}
}
), hostOpenedTopicsRegistered || (hostOpenedTopicsRegistered = !0, scope.add(informationFlow.register({
domain: "host-opened-topics",
storageKeys: [
hostTopicEnhancement.openedTopicStorageKey
],
refresh: () => hostTopicEnhancement?.reloadExternalOpenedTopics()
}))), {
document,
host: environment.discourseHost,
routeKind,
requestedMode: requestedMode(
readPreferences(),
routeKind
),
embedWidth: readPreferences().listReaderEmbedWidth,
windowPreferences: readPreferences(),
topicFilterChanges: unwantedTopicFilter,
elements: template.workspaceElements,
viewportTarget: window,
pointerTarget: document,
scrollTarget: window,
readViewport: () => ({
width: window.innerWidth,
height: window.innerHeight
}),
hostScroll: {
read: () => ({
viewportHeight: window.innerHeight,
scrollHeight: scrolling().scrollHeight,
scrollTop: scrolling().scrollTop
}),
readScrollTop: () => scrolling().scrollTop,
scrollTo: (top) => window.scrollTo({
top,
behavior: "auto"
})
},
enhancements: hostTopicEnhancement,
readAppearance: () => {
const preferences = readPreferences(), activeTheme = theme(document, window);
return {
profile: preferences.appearanceProfile,
theme: activeTheme,
defaultDividerLineColor: activeTheme === "dark" ? "#343b44" : "#e5e5e5",
defaultDividerLineWidth: 0.5
};
},
onPersistMode: (mode) => {
context.updatePreferences?.(
routeKind === "direct-topic" ? {
topicReaderMode: mode === "fullpage" ? "fullpage" : "floating"
} : { listReaderMode: mode }
), enforceEmbeddedDefaultSiteTheme(mode);
},
onPersistEmbedWidth: (listReaderEmbedWidth) => {
context.updatePreferences?.({ listReaderEmbedWidth });
},
onPersistWindow: (preferences) => {
context.updatePreferences?.({
readerWindowWidth: preferences.readerWindowWidth,
readerWindowHeight: preferences.readerWindowHeight,
readerWindowX: preferences.readerWindowX,
readerWindowY: preferences.readerWindowY,
readerWindowLocked: preferences.readerWindowLocked,
readerWindowPinned: preferences.readerWindowPinned
});
},
createMutationObserver: (callback) => new MutationObserver(callback),
...typeof ResizeObserver == "function" ? {
createResizeObserver: (callback) => new ResizeObserver(callback)
} : {},
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (id) => window.cancelAnimationFrame(id)
};
}
},
runtime: {
document,
renderIcon,
translationView: bodyTranslationAllowed ? {
initialTheme: initialPreferences.translationTheme,
subscribeTheme: (listener, themeScope) => {
applicationContext.preferenceChanges.subscribe(
(preferences) => listener(preferences.translationTheme),
themeScope
);
},
...customSites.translation ? {
initialAnimation: customSites.translation.snapshot.config.animation,
subscribeAnimation: (listener, animationScope) => {
customSites.translation.changes.subscribe(
(snapshot) => listener(snapshot.config.animation),
animationScope
);
}
} : {}
} : !1,
storage: localStorage,
sourceId: sourceId(window),
locks: window.navigator.locks ?? null,
indexedDb: window.indexedDB ?? null,
storageEvents: window,
broadcastChannelFactory: typeof BroadcastChannel == "function" ? (name) => new BroadcastChannel(name) : null,
permit: {
shortWindowMs: 1e4,
longWindowMs: 6e4,
shortBudget: 50,
longBudget: 200,
minIntervalMs: initialPreferences.performanceRequestInterval,
maxConcurrent: initialPreferences.performanceRequestConcurrency,
backgroundIdleIntervalMs: initialPreferences.requestFlowSettings.backgroundIdleIntervalMs,
backgroundMaxDeferMs: initialPreferences.requestFlowSettings.backgroundMaxDeferMs
},
data: {
scheduler: {
maxConcurrent: initialPreferences.performanceRequestConcurrency,
queueLimit: 160,
defaultTimeoutMs: 15e3
},
rateLimit: {
evidenceWindowMs: 4e3,
maxEndpointEntries: 128,
retryAfterFallbackMs: 1500,
baseUrl: origin
},
responseMemoryMaxEntries: 96,
responseMemoryMaxBytes: 24 * 1024 * 1024,
responsePersistentMaxEntries: 600,
responsePersistentMaxBytes: 96 * 1024 * 1024,
responseOperationTimeoutMs: 5e3,
cacheFlightTtlMs: 3e4,
cacheFlightStaleMs: 45e3,
readStateRequestsPerMinute: initialPreferences.performanceReadStateRequestsPerMinute,
readStateTimingsPerMinute: initialPreferences.performanceReadStateTimingsPerMinute
},
topic: {
authScope: currentUsername ? `account:${currentUsername}` : `anonymous:${origin}`,
origin,
pageSize: initialPreferences.performancePageSize,
caches: {
topic: {
freshForMs: 30 * 6e4,
retainForMs: 10080 * 6e4,
persist: !0
},
posts: {
freshForMs: 30 * 6e4,
retainForMs: 10080 * 6e4,
persist: !0
},
nested: {
freshForMs: 30 * 6e4,
retainForMs: 10080 * 6e4,
persist: !0
},
snapshot: {
freshForMs: 30 * 6e4,
retainForMs: 720 * 60 * 6e4
}
}
},
timelineView: {
preferences: {
pageStep: initialPreferences.performancePageSize
}
},
history: {
panelView: {
preferences: {
sortMode: initialPreferences.historySortMode
},
topicHref: (entry) => `${origin}/t/${entry.topicId}`,
changeSortMode: (historySortMode) => {
applicationContext.updatePreferences?.({
historySortMode
});
}
}
},
media: {
...katex ? { katex } : {},
...hls ? { hls } : {},
hasManagedMediaSource: "ManagedMediaSource" in window
},
lightbox: {
mount: () => template?.view.surfaceHost ?? document.body
},
topicFactory: {
createDomOptions: (bundle, context, _root, services) => {
const presentation = (0, import_reader_post_presentation.createReaderPostPresentation)({
document,
presentation: services.presentation,
relativeTime: services.relativeTime,
exactTime: services.exactTime,
readTopic: () => bundle.services.session.topic,
currentUsername: services.currentUsername,
recoverAvatarSource: services.recoverAvatarSource,
renderIcon
}), readState = (0, import_reader_post_presentation.createReaderPostReadStateFeature)({
readState: bundle.services.read,
parentScope: context.scope,
renderIcon,
prefersReducedMotion: () => !!window.matchMedia?.(
"(prefers-reduced-motion: reduce)"
).matches,
isVisible: (view) => {
const postRoot = view.slots.root;
if (!postRoot.isConnected || !postRoot.getClientRects().length)
return !1;
const rect = postRoot.getBoundingClientRect(), viewport = (postRoot.closest(
".ldp-descendant-replies-list"
) ?? template?.view.body)?.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;
}
}), readViewport = new import_read_viewport_adapter.ReaderPostReadViewportFeature({
controller: bundle.services.read,
document,
focusTarget: window,
parentScope: context.scope,
rootFor: (postRoot) => {
const discussion = postRoot.closest(
".ldp-descendant-replies-list"
);
return discussion || (postRoot.closest(
".ldp-lb-comment-list,.ldp-topic-action-rail"
) ? !1 : template?.view.body ?? null);
}
});
return {
estimatedRootSize: 360,
identity: presentation.identity,
render: presentation.render,
postFeatures: Object.freeze([readViewport, readState]),
replyTreePreferences: {
read: () => replyTreePreferences.read(),
subscribe: (listener, preferenceScope) => replyTreePreferences.subscribe(
listener,
preferenceScope
)
}
};
}
},
unwantedTopicFilter,
onTopicFeatureError: (diagnostic) => {
console.error(
`[main-lite:${diagnostic.feature}]`,
diagnostic.cause
);
}
},
translation: {
...customSites.translation ? {
readConfig: async () => (await customSites.translation.load()).config
} : {},
fingerprint: (texts) => {
const subtle = window.crypto?.subtle;
return subtle ? (0, import_translation_text.translationTextFingerprint)(texts, subtle) : Promise.reject(new Error("浏览器缺少 SubtleCrypto"));
},
translationCache: {
kind: "translations",
tags: ["translation:zh-CN"],
freshForMs: 720 * 60 * 6e4,
retainForMs: 4320 * 60 * 6e4,
persist: !0
},
credentialCache: {
kind: "translation-credentials",
tags: ["translation:credential"],
freshForMs: 8 * 6e4,
retainForMs: 8 * 6e4,
persist: !1
}
},
resources: {
baseUrl: origin,
cache: {
kind: "images",
tags: ["images"],
freshForMs: 1440 * 6e4,
retainForMs: 720 * 60 * 6e4,
persist: !0
}
},
selectPerformancePreferences: (preferences) => preferences,
performanceBudgetCeilings: { short: 50, long: 200 },
layout: import_reader_layout_style_controller.readerPreferencesLayoutAdapter,
appearance: import_reader_appearance_style_controller.readerPreferencesAppearanceAdapter,
theme: {
preferences: import_reader_theme_controller.readerPreferencesThemeAdapter,
hostTheme: (0, import_native_host_api.discourseNativeTheme)(environment.discourseHost),
clock: (0, import_reader_local_sun_clock.createReaderBrowserThemeClock)({ window, document }),
system: {
readDark: () => !!window.matchMedia?.(
"(prefers-color-scheme: dark)"
).matches,
subscribe: (listener, scope2) => {
const query = window.matchMedia?.(
"(prefers-color-scheme: dark)"
);
if (!query) return () => {
};
const onChange = () => listener(query.matches);
query.addEventListener("change", onChange);
const cleanup = () => query.removeEventListener("change", onChange);
return scope2.add(cleanup), cleanup;
}
}
},
font: import_reader_font_style_controller.readerPreferencesFontAdapter,
motion: {
...import_reader_motion_settings_form.readerPreferencesMotionAdapter,
siteName
},
image: import_reader_image_preferences.readerPreferencesImageAdapter,
boostCopy: import_boost_copy_rule.readerPreferencesBoostCopyAdapter,
topicActionRail: import_reader_topic_action_rail.readerPreferencesTopicActionRailAdapter,
openQueue: {
read: (preferences) => Object.freeze({
openTopicsAtFirstPost: preferences.openTopicsAtFirstPost,
readerQueueAlwaysVisibleWhenEmpty: preferences.readerQueueAlwaysVisibleWhenEmpty,
doubleEscapeToCloseReader: preferences.doubleEscapeToCloseReader,
confirmNativeComposerClose: preferences.confirmNativeComposerClose
}),
createPatch: (preferences) => preferences
},
shortcuts: import_reader_shortcut_controller.readerPreferencesShortcutAdapter,
settings: {
view: { brandName: "Awesome LinuxDo Reader" },
sitesForm: customSites,
...customSites.webDav ? {
webDav: {
...customSites.webDav,
customSites: customSites.repository,
preferencesCodec
}
} : {},
aboutContent: {
version: scriptVersion
},
configuration: {
codec: preferencesCodec,
defaults: preferencesDefaults,
prepareResetPreferences: import_reader_preferences_schema.createReaderPreferencesResetValue,
customSites: customSites.repository,
translation: customSites.translation,
webDav: customSites.webDav?.repository ?? null
},
performanceForm: import_reader_performance_settings_form.readerPreferencesPerformanceSettingsAdapter,
...customSites.translation ? {
translationForm: {
repository: customSites.translation,
presentation: {
readTheme: () => applicationContext.readPreferences().translationTheme,
persistTheme: (translationTheme) => {
if (!applicationContext.updatePreferences)
throw new Error("当前环境不能保存译文样式");
applicationContext.updatePreferences({ translationTheme });
}
}
}
} : {},
imageForm: import_reader_image_preferences.readerPreferencesImageAdapter,
readingForm: import_reader_reading_settings_form.readerPreferencesReadingSettingsAdapter,
interactionForm: {
boostCopy: import_boost_copy_rule.readerPreferencesBoostCopyAdapter,
topicActionRail: import_reader_topic_action_rail.readerPreferencesTopicActionRailAdapter,
replyTree: import_reader_reply_tree_preferences.readerPreferencesReplyTreeAdapter,
replyTreePreview: replyTreePreferences,
boostsAvailable: () => (0, import_native_host_api.discourseNativeBoostsAvailable)(
environment.discourseHost
)
}
},
selectHistoryNavigationPreferences: (preferences) => ({
edgeTriggerPercent: preferences.historyEdgeTriggerPercent,
buttonsAlwaysVisible: preferences.historyButtonsAlwaysVisible
}),
selectHistoryPanelPreferences: (preferences) => ({
sortMode: preferences.historySortMode
}),
selectBookmarkPreferences: (preferences) => ({
tabOrder: preferences.bookmarkTabOrder
}),
selectTimelineViewPreferences: (preferences) => ({
pageStep: preferences.performancePageSize
}),
...bodyTranslationAllowed ? {
selectTranslationMode: (preferences) => preferences.translationMode,
persistTranslationMode: (translationMode) => persistTranslationMode?.(translationMode)
} : {},
targets: {
openInitialRoute: !suppressInitialTopicOpen,
serviceWorkerMessages,
selectOpenTopicsAtFirstPost: (preferences) => preferences.openTopicsAtFirstPost,
selectHostTopicPreheatEnabled: (preferences) => preferences.hostTopicPreheatEnabled,
selectHostTopicPreheatPostCount: (preferences) => preferences.hostTopicPreheatPostCount,
onError: (error) => {
console.error("[main-lite:target]", error);
}
},
onReady(runtime, context, _settings, settingsView, _layout, appearance, font) {
state.runtime = runtime, (0, import_reader_detected_site_onboarding.onboardDetectedDiscourseSite)({
hostname: document.location.hostname,
detection: applicationContext.host.detection,
repository: customSites.repository,
feedback: runtime.feedback
}).catch((cause) => {
console.error("[main-lite:detected-custom-site]", cause);
});
for (const [topicId, input] of pendingAutomaticTopics)
try {
runtime.unwantedTopics.remember(input), pendingAutomaticTopics.delete(topicId);
} catch (cause) {
console.error("[main-lite:automatic-unwanted-history]", cause);
}
let manualTopicSignature = runtime.topicStates.snapshot.records.filter((record) => record.manuallyHidden).map((record) => record.topicId).sort((left, right) => left - right).join(",");
if (hostTopicEnhancement?.refreshHiddenTopics(), runtime.topicStates.changes.subscribe((snapshot) => {
const nextSignature = snapshot.records.filter((record) => record.manuallyHidden).map((record) => record.topicId).sort((left, right) => left - right).join(",");
nextSignature !== manualTopicSignature && (manualTopicSignature = nextSignature, hostTopicEnhancement?.refreshHiddenTopics());
}, runtime.scope), document.location.hostname.toLowerCase() === "linux.do") {
const hostTurnstile = new import_reader_host_turnstile_background_controller.ReaderHostTurnstileBackgroundController({
document,
enabled: context.readPreferences().performanceSuspendHostTurnstileInBackground,
turnstile: () => {
const candidate = window.turnstile;
return candidate && typeof candidate.getResponse == "function" && typeof candidate.remove == "function" && typeof candidate.render == "function" ? candidate : null;
},
parentScope: runtime.scope,
onError: (cause) => {
console.warn(
"[main-lite:host-turnstile-background]",
cause
);
}
});
context.preferenceChanges.subscribe((preferences) => {
hostTurnstile.applyEnabled(
preferences.performanceSuspendHostTurnstileInBackground
);
}, runtime.scope);
}
persistTranslationMode = (translationMode) => {
context.updatePreferences?.({ translationMode });
};
const storageAccessDocument = document, browserStorage = window.navigator.storage, storageSurface = settingsView ? new import_reader_browser_storage_management.ReaderBrowserStorageManagementSurface({
document,
host: settingsView.panelHost("cache"),
storage: localStorage,
...localStorageAccessError === void 0 ? {} : { initialAccessError: localStorageAccessError },
...typeof storageAccessDocument.hasStorageAccess == "function" || typeof storageAccessDocument.requestStorageAccess == "function" ? {
storageAccess: {
...typeof storageAccessDocument.hasStorageAccess == "function" ? {
hasAccess: () => storageAccessDocument.hasStorageAccess()
} : {},
...typeof storageAccessDocument.requestStorageAccess == "function" ? {
requestAccess: () => storageAccessDocument.requestStorageAccess()
} : {}
}
} : {},
...browserStorage ? {
originStorage: {
estimate: () => browserStorage.estimate(),
persisted: () => browserStorage.persisted(),
persist: () => browserStorage.persist()
}
} : {},
confirm: (request) => runtime.feedback.confirm(request),
choose: (request) => runtime.feedback.choose(request),
notify: (message) => runtime.feedback.show(message),
openSettings: () => settingsView.open("cache"),
reload: () => window.location.reload(),
onError: (cause) => {
console.error("[main-lite:local-storage]", cause);
},
parentScope: runtime.scope
}) : null;
if (settingsView && storageSurface) {
let storagePanelVisible = !1;
settingsView.changes.subscribe((snapshot) => {
const visible = snapshot.open && snapshot.activePanelId === "cache";
visible && !storagePanelVisible && storageSurface.refresh({ measureRemaining: !0 }), storagePanelVisible = visible;
}, runtime.scope);
}
let storageWarningSettled = storageSurface === null, settingsResetReminderChecked = !1;
const checkSettingsResetReminder = () => {
!storageWarningSettled || settingsResetReminderChecked || !["opening", "running", "failed"].includes(runtime.shell.state) || (settingsResetReminderChecked = !0, (0, import_reader_settings_reset_reminder.showReaderSettingsResetReminder)({
storage: localStorage,
preferencesStorageKey: import_reader_preferences_schema.READER_PREFERENCES_STORAGE_KEY,
defaults: preferencesDefaults,
prepareResetPreferences: (preferences) => (0, import_reader_preferences_schema.createReaderPreferencesResetValue)(
preferences,
context.readPreferences()
),
update: (preferences) => {
if (!context.updatePreferences)
throw new Error("偏好写端口不可用");
context.updatePreferences(preferences), (0, import_reader_open_queue_session.requestReaderQueueSurfacePositionsReset)(document);
},
feedback: runtime.feedback,
isActive: () => !runtime.scope.destroyed,
onError: (error) => {
console.error("[main-lite:settings-reset-reminder]", error);
}
}));
};
storageSurface && storageSurface.warnAtStartup().catch((cause) => {
console.error("[main-lite:local-storage-warning]", cause);
}).finally(() => {
storageWarningSettled = !0, checkSettingsResetReminder();
});
const restoreOpenedHostTopicTitle = () => {
if (runtime.shell.state !== "running") return;
const topicId = runtime.shell.activeTopicId;
topicId !== null && hostTopicEnhancement?.markTopicOpened(topicId);
};
runtime.shell.changes.subscribe(
() => {
checkSettingsResetReminder(), restoreOpenedHostTopicTitle();
},
runtime.scope
), checkSettingsResetReminder(), restoreOpenedHostTopicTitle();
const portal = state.portal;
if (!portal) throw new Error("main-lite Shadow Portal 未就绪");
const embeddedReload = routeKind === "list" ? new import_reader_embedded_reload_coordinator.ReaderEmbeddedReloadCoordinator({
target: window,
storage: window.sessionStorage,
currentHostRoute: () => `${document.location.pathname}${document.location.search}${document.location.hash}`,
navigationType: () => {
const entry = window.performance.getEntriesByType?.("navigation")[0];
return entry?.type ? entry.type : window.performance.navigation?.type === 1 ? "reload" : null;
},
capture: () => {
const workspace = runtime.workspace.workspace.snapshot, topicId = runtime.shell.activeTopicId, active = runtime.shell.activeValue;
if (!workspace.presentation.embedded || topicId === null || !active) return null;
const anchor = runtime.historyNavigation.captureCurrent();
return anchor ? Object.freeze({
mode: workspace.presentation.mode,
topicId: Number(topicId),
anchor,
onlyOp: active.topicOnlyOp.snapshot.enabled
}) : null;
},
restore: async (reload) => {
if (!runtime.workspace.setMode(reload.mode)) return !1;
const opened = await runtime.openTarget({
topicId: reload.topicId,
...reload.anchor.viewport.scrollRatio === void 0 ? { postNumber: reload.anchor.viewport.postNumber } : {},
source: "restore",
alignment: "nearest"
});
return opened.topic.status !== "opened" && opened.topic.status !== "reused" ? !1 : (reload.onlyOp && opened.topic.value.topicOnlyOp.setEnabled(!0), await runtime.historyNavigation.restore(
reload.topicId,
reload.anchor
), !0);
},
parentScope: runtime.scope,
onError: (error) => {
console.error("[main-lite:embedded-reload]", error);
}
}) : null;
(async () => {
if (await embeddedReload?.restore() || runtime.shell.activeTopicId !== null || !["embed-left", "embed-right"].includes(
runtime.workspace.workspace.snapshot.requestedMode
) || !runtime.workspace.workspace.snapshot.canEmbed) return;
const recent = runtime.history.ordered("recent-viewed")[0] ?? null;
if (!recent) return;
const anchor = runtime.historyNavigation.snapshot.states[String(recent.topicId)] ?? (recent.viewport === null ? null : (0, import_reader_history_model.normalizeReaderHistoryAnchorState)({
viewport: recent.viewport
})), opened = await runtime.openTarget({
topicId: recent.topicId,
source: "restore",
alignment: "nearest"
});
anchor && (opened.topic.status === "opened" || opened.topic.status === "reused") && await runtime.historyNavigation.restore(recent.topicId, anchor, {
highlight: !1,
restoreSemanticState: !1
});
})().catch((error) => {
console.error("[main-lite:embedded-default-topic]", error);
});
const composerWindow = new import_reader_native_composer_window.ReaderNativeComposerWindowController({
document,
window,
mount: portal.root,
pageRoot: document.documentElement,
readPreferences: context.readPreferences,
preferenceChanges: context.preferenceChanges,
updatePreferences: (patch) => {
context.updatePreferences?.(patch);
},
readFontProfile: () => font?.snapshot.settings.fontProfile ?? context.readPreferences().fontProfile,
...font ? {
fontChanges: {
subscribe(listener, scope2) {
return font.changes.subscribe((snapshot) => {
listener(snapshot.settings.fontProfile);
}, scope2);
}
}
} : {},
...appearance ? {
readAppearance: () => appearance.snapshot.interaction,
appearanceChanges: {
subscribe(listener, scope2) {
return appearance.changes.subscribe((snapshot) => {
listener(snapshot.interaction);
}, scope2);
}
}
} : {},
createMutationObserver: (callback) => new MutationObserver(callback),
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frameId) => window.cancelAnimationFrame(frameId),
parentScope: runtime.scope,
onError: (error) => {
console.error("[main-lite:native-composer-window]", error);
}
}), unbindComposerWindow = runtime.composer.bindWindow(
composerWindow
);
return runtime.composer.warmReply(), () => {
unbindComposerWindow(), composerWindow.destroy(), embeddedReload?.destroy(), persistTranslationMode = null, state.runtime === runtime && (state.runtime = null);
};
}
}).setup(scope, applicationContext);
}
});
}
function startMainLiteUserscript(userscriptGlobal = globalThis) {
const environment = new import_browser_userscript_environment.BrowserUserscriptEnvironment({
userscriptGlobal
}), page = pageRecord(environment.pageWindow), existing = page[DEBUG_HANDLE_KEY] ?? page[LEGACY_DEBUG_HANDLE_KEY], document = page.document, window = environment.pageWindow;
if (!document) throw new Error("main-lite document 不可用");
if (window.frameElement?.hasAttribute(
"data-ldp-host-advanced-search-frame"
)) return null;
let localStorageAccessError, localStorage;
try {
localStorage = window.localStorage;
} catch (cause) {
localStorageAccessError = cause, localStorage = unavailableLocalStorage(cause);
}
if ((0, import_browser_shared_request_permit.isReaderCloudflareChallengeWindow)(window)) {
existing?.destroy?.(), page[CHALLENGE_MONITOR_KEY]?.();
const stopMonitor = (0, import_browser_shared_request_permit.monitorReaderCloudflareChallengeWindow)({
storage: localStorage,
storageEvents: window,
broadcastChannelFactory: typeof BroadcastChannel == "function" ? (name) => new BroadcastChannel(name) : null,
close: () => window.close(),
schedule: (callback, intervalMs) => window.setInterval(callback, intervalMs),
cancel: (handle2) => window.clearInterval(Number(handle2)),
onError: (error) => {
console.warn("[main-lite] Cloudflare 验证浮窗自动关闭失败", error);
}
});
return Object.defineProperty(page, CHALLENGE_MONITOR_KEY, {
configurable: !0,
enumerable: !1,
value: stopMonitor,
writable: !1
}), null;
}
if (document.location.hostname === "credit.linux.do")
return (0, import_reader_credit_account_bridge.scheduleReaderCreditAccountBridge)(
window,
document,
environment.createValueStorage(),
environment.createCreditBridgeHttp(),
(cause) => console.warn("[main-lite] LDC 账户桥同步失败", cause)
), null;
const bypassNativeTab = (0, import_reader_native_topic_route.consumeReaderNativeTabBypass)(window), bypassNativeUrl = (0, import_reader_native_topic_route.consumeReaderNativeBypass)(
document.location.href,
document.location.origin,
(cleanHref) => {
try {
window.history.replaceState(
window.history.state,
"",
cleanHref
);
} catch {
}
}
), suppressInitialTopicOpen = bypassNativeTab || bypassNativeUrl;
if (suppressInitialTopicOpen && existing?.destroy?.(), existing && !suppressInitialTopicOpen)
return existing;
const serviceWorkerMessages = (0, import_reader_userscript_target_adapter.createReaderUserscriptServiceWorkerMessageRelay)(
window.navigator.serviceWorker ?? null,
(error) => {
console.error("[main-lite:service-worker-target]", error);
}
), valueStorage = environment.createValueStorage(), customSiteRepository = new import_reader_custom_site_repository.ReaderCustomSiteRepository({
storage: valueStorage
}), translation = valueStorage ? new import_reader_translation_config.ReaderTranslationConfigRepository({ storage: valueStorage }) : null;
let customSiteProbe = null;
try {
customSiteProbe = environment.createDiscourseSiteProbe();
} catch {
}
let webDav = null;
if (valueStorage)
try {
webDav = Object.freeze({
client: environment.createWebDavClient(),
repository: new import_reader_webdav_config_repository.ReaderWebDavConfigRepository({
storage: valueStorage
})
});
} catch {
}
const preferences = (0, import_reader_preferences_schema.createReaderPreferencesRepository)({
environment: {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
},
storage: localStorage
}), state = {
runtime: null,
portal: null,
informationFlow: null,
diagnostics: []
}, onWindowKeyDown = (event) => {
const runtime = state.runtime;
runtime && ((0, import_reader_floating_window_frame.dismissReaderFloatingWindowTabSessionFromEscape)(
runtime.shell.view.surfaceHost,
event
) || runtime.shell.activeValue?.topicContextSurface.handleEscape(event));
};
window.addEventListener("keydown", onWindowKeyDown, !0);
const application = (0, import_reader_userscript_application.createReaderUserscriptApplication)({
environment,
document,
window,
preferences,
isVerifiedHost: async (hostname, signal) => {
if ((0, import_reader_custom_site_repository.readerBuiltinDiscourseHost)(hostname)) return !0;
if (signal.aborted) return !1;
try {
const verified = await customSiteRepository.allows(hostname);
return !signal.aborted && verified;
} catch {
return !1;
}
},
stages: [
createInformationFlowStage(window, valueStorage, preferences, state, [
{
domain: "custom-sites",
keys: [customSiteRepository.storageKey],
refresh: () => customSiteRepository.reloadExternal()
},
...translation ? [{
domain: "translation-config",
keys: [
translation.storageKey,
translation.metadataStorageKey
],
refresh: () => translation.reloadExternalState()
}] : [],
...webDav ? [{
domain: "webdav-config",
keys: [webDav.repository.storageKey],
refresh: () => webDav.repository.reloadExternal()
}] : []
]),
createStyleStage(environment, document, state),
createRuntimeStage(
environment,
document,
window,
localStorage,
localStorageAccessError,
state,
serviceWorkerMessages,
suppressInitialTopicOpen,
{
repository: customSiteRepository,
probe: customSiteProbe,
translation,
webDav
}
)
]
});
application.diagnostics.subscribe((diagnostic) => {
state.diagnostics.push(diagnostic), console.error(
`[main-lite:${diagnostic.stage}]`,
diagnostic.cause
);
});
const started = application.start(), handle = Object.freeze({
application,
started,
get diagnostics() {
return Object.freeze([...state.diagnostics]);
},
get runtime() {
return state.runtime;
},
destroy() {
window.removeEventListener("keydown", onWindowKeyDown, !0);
try {
application.destroy();
} finally {
serviceWorkerMessages?.destroy(), page[DEBUG_HANDLE_KEY] === handle && delete page[DEBUG_HANDLE_KEY], page[LEGACY_DEBUG_HANDLE_KEY] === handle && delete page[LEGACY_DEBUG_HANDLE_KEY];
}
}
});
return Object.defineProperty(page, DEBUG_HANDLE_KEY, {
configurable: !0,
enumerable: !1,
value: handle,
writable: !1
}), Object.defineProperty(page, LEGACY_DEBUG_HANDLE_KEY, {
configurable: !0,
enumerable: !1,
value: handle,
writable: !1
}), handle;
}
const startMianLiteUserscript = startMainLiteUserscript;
}, "e202bc006e9ffe685432ab2cc7a9f73e9feffcdda7b25a0941ca01b5cb2a9171");
/* Source: lite/src/userscript/main-lite-entry.ts */
runtime.register("src/userscript/main-lite-entry.js", function(module, exports, require) {
var import_main_lite_bootstrap = require("./main-lite-bootstrap.js");
(0, import_main_lite_bootstrap.startMainLiteUserscript)();
}, "9e50c00fd0262b242afcb1582b85bc77d9ea61386a6ca6dc6939dc68f814e1ff");
/* Source: lite/src/userscript/reader-embedded-reload-coordinator.ts */
runtime.register("src/userscript/reader-embedded-reload-coordinator.js", function(module, exports, require) {
var reader_embedded_reload_coordinator_exports = {};
__export(reader_embedded_reload_coordinator_exports, {
ReaderEmbeddedReloadCoordinator: () => ReaderEmbeddedReloadCoordinator
});
module.exports = __toCommonJS(reader_embedded_reload_coordinator_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_reader_history_model = require("../history/reader-history-model.js"), import_lifecycle = require("../kernel/lifecycle.js");
const DEFAULT_STORAGE_KEY = "ldp:mian-lite:embedded-reload:v1", DEFAULT_TTL_MS = 3e4;
function embeddedMode(value) {
return value === "embed-left" || value === "embed-right" ? value : null;
}
function stateRecord(value) {
return value !== null && typeof value == "object" && !Array.isArray(value) ? value : null;
}
class ReaderEmbeddedReloadCoordinator {
scope;
#options;
#storageKey;
#ttlMs;
#now;
#restorePromise = null;
constructor(options) {
this.#options = options, this.#storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY, this.#ttlMs = Math.max(0, options.ttlMs ?? DEFAULT_TTL_MS), this.#now = options.now ?? Date.now, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.listen(options.target, "pagehide", () => {
this.save();
});
}
save() {
if (this.scope.destroyed) return !1;
let capture;
try {
capture = this.#options.capture();
} catch (cause) {
return this.#report(cause), !1;
}
const mode = embeddedMode(capture?.mode);
if (!capture || !mode) return !1;
const anchor = (0, import_reader_history_model.normalizeReaderHistoryAnchorState)(capture.anchor);
if (!anchor) return !1;
let topicId;
try {
topicId = Number((0, import_identifiers.discourseTopicId)(capture.topicId));
} catch {
return !1;
}
try {
const state = Object.freeze({
savedAt: this.#now(),
hostRoute: String(this.#options.currentHostRoute()),
mode,
topicId,
anchor,
onlyOp: capture.onlyOp === !0
});
return this.#options.storage.setItem(
this.#storageKey,
JSON.stringify(state)
), !0;
} catch (cause) {
return this.#report(cause), !1;
}
}
restore() {
if (this.#restorePromise) return this.#restorePromise;
const transaction = this.#restore();
return this.#restorePromise = transaction, transaction.finally(() => {
this.#restorePromise === transaction && (this.#restorePromise = null);
}), transaction;
}
destroy() {
this.scope.destroy();
}
async #restore() {
if (this.scope.destroyed) return !1;
const state = this.#consume();
if (!state) return !1;
try {
return await this.#options.restore(state);
} catch (cause) {
return this.#report(cause), !1;
}
}
#consume() {
let raw = null;
try {
raw = this.#options.storage.getItem(this.#storageKey), this.#options.storage.removeItem(this.#storageKey);
} catch (cause) {
return this.#report(cause), null;
}
if (!raw || this.#options.navigationType() !== "reload") return null;
let source;
try {
source = stateRecord(JSON.parse(raw));
} catch {
return null;
}
const savedAt = Number(source?.savedAt), mode = embeddedMode(source?.mode), anchor = (0, import_reader_history_model.normalizeReaderHistoryAnchorState)(source?.anchor);
if (!source || !Number.isFinite(savedAt) || this.#now() - savedAt < 0 || this.#now() - savedAt > this.#ttlMs || String(source.hostRoute ?? "") !== String(this.#options.currentHostRoute()) || !mode || !anchor) return null;
let topicId;
try {
topicId = Number((0, import_identifiers.discourseTopicId)(source.topicId));
} catch {
return null;
}
return Object.freeze({
savedAt,
hostRoute: String(source.hostRoute),
mode,
topicId,
anchor,
onlyOp: source.onlyOp === !0
});
}
#report(cause) {
try {
this.#options.onError?.(cause);
} catch {
}
}
}
}, "3140edd91b8288950ef4bc855c11df2651f0fccda7a2e4fa0adeef29bbf49d1d");
/* Source: lite/src/userscript/reader-floating-host-target-controller.ts */
runtime.register("src/userscript/reader-floating-host-target-controller.js", function(module, exports, require) {
var reader_floating_host_target_controller_exports = {};
__export(reader_floating_host_target_controller_exports, {
ReaderFloatingHostTargetController: () => ReaderFloatingHostTargetController
});
module.exports = __toCommonJS(reader_floating_host_target_controller_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_workspace = require("../shell/reader-workspace.js"), import_reader_userscript_target_adapter = require("./reader-userscript-target-adapter.js");
class ReaderFloatingHostTargetController {
scope;
#options;
#requestFrame;
#cancelFrame;
#target = null;
#frame = 0;
#clientX = 0;
#clientY = 0;
#destroyed = !1;
constructor(options) {
this.#options = options, this.#requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback)), this.#cancelFrame = options.cancelFrame ?? ((id) => cancelAnimationFrame(id)), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.listen(options.overlay, "pointermove", (event) => this.#onPointerMove(event)), this.scope.listen(options.overlay, "click", (event) => this.#onClick(event)), this.scope.add(() => this.#clear());
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#canSwitch() {
return this.#options.workspace.snapshot.presentation.floating && !this.#options.window.snapshot.pinned && this.#options.window.snapshot.viewportWidth > import_reader_workspace.READER_COMPACT_MAX_WIDTH;
}
#onPointerMove(event) {
if (event.target !== this.#options.overlay || event.pointerType === "touch" || !this.#canSwitch()) {
this.#stop();
return;
}
this.#clientX = event.clientX, this.#clientY = event.clientY, this.#frame || (this.#frame = this.#requestFrame(() => this.#syncTarget()));
}
#onClick(event) {
if (event.target !== this.#options.overlay || !this.#canSwitch()) return;
const target = this.#targetAt(event.clientX, event.clientY);
if (this.#stop(), !target) {
this.#run(() => this.#options.closeReader());
return;
}
const openAtFirst = this.#readOpenAtFirstPost();
this.#run(() => this.#options.target.openTarget({
topicId: target.topicId,
...openAtFirst ? { postNumber: (0, import_identifiers.tryDiscoursePostNumber)(1) } : target.postNumber === null ? {} : { postNumber: target.postNumber },
source: "link"
}));
}
#syncTarget() {
this.#frame = 0, this.#setTarget(
this.#canSwitch() ? this.#targetAt(this.#clientX, this.#clientY)?.anchor ?? null : null
);
}
#targetAt(clientX, clientY) {
let source = null;
try {
this.#options.overlay.classList.add("ldp-reader-hit-test-hidden"), source = this.#options.document.elementFromPoint(clientX, clientY);
} finally {
this.#options.overlay.classList.remove("ldp-reader-hit-test-hidden");
}
const anchor = source?.closest("a[href]") ?? null;
if (!anchor || anchor.classList.contains("ldp-open")) return null;
let currentUrl;
try {
currentUrl = this.#options.currentUrl();
} catch (error) {
return this.#report(error), null;
}
const route = (0, import_reader_userscript_target_adapter.parseReaderUserscriptTopicRoute)(
anchor.getAttribute("href") ?? anchor.href,
currentUrl
);
return !route || route.bypassReader ? null : Object.freeze({
anchor,
topicId: route.topicId,
postNumber: route.postNumber
});
}
#setTarget(target) {
target !== this.#target && (this.#target?.classList.remove("ldp-reader-switch-target"), this.#target = target, this.#target?.classList.add("ldp-reader-switch-target"), this.#options.overlay.classList.toggle(
"ldp-reader-switch-ready",
this.#target !== null
));
}
#stop() {
this.#frame && this.#cancelFrame(this.#frame), this.#frame = 0, this.#setTarget(null);
}
#clear() {
this.#stop();
}
#readOpenAtFirstPost() {
try {
return this.#options.readOpenTopicsAtFirstPost?.() === !0;
} catch (error) {
return this.#report(error), !1;
}
}
#run(action) {
try {
Promise.resolve(action()).catch((error) => this.#report(error));
} catch (error) {
this.#report(error);
}
}
#report(error) {
try {
this.#options.onError?.(error);
} catch {
}
}
}
}, "3ed6bdf9714346b7cbaa0b69040534a7b798c0def2a9ec57ccf47683f55144ec");
/* Source: lite/src/userscript/reader-host-topic-preheat-controller.ts */
runtime.register("src/userscript/reader-host-topic-preheat-controller.js", function(module, exports, require) {
var reader_host_topic_preheat_controller_exports = {};
__export(reader_host_topic_preheat_controller_exports, {
ReaderHostTopicPreheatController: () => ReaderHostTopicPreheatController
});
module.exports = __toCommonJS(reader_host_topic_preheat_controller_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_preferences_schema = require("../state/reader-preferences-schema.js"), import_reader_userscript_target_adapter = require("./reader-userscript-target-adapter.js");
const CARD_SELECTOR = "tr.topic-list-item,.topic-list-item,.latest-topic-list-item", TOPIC_LINK_SELECTOR = 'a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]', META_SELECTOR = ".ldp-host-topic-reader-meta", META_ROW_CLASS = "ldp-host-topic-reader-meta-row", PERFORMANCE_CARD_CLASS = "ldp-host-topic-card-performance", DEFAULT_MAX_QUEUED_TOPICS = 6, DEFAULT_MAX_CONCURRENT_PREHEATS = 1, MAX_CONCURRENT_PREHEATS = 3, HOST_REPLY_COUNT_SELECTORS = Object.freeze([
".ldp-topic-stat--reply .ldp-topic-stat-value",
":scope > td.posts .number",
":scope > td.posts",
".topic-stats .posts .number",
".topic-stats .posts",
".topic-list-data.posts .number",
".topic-list-data.posts"
]);
function compactCount(value) {
const match = String(value ?? "").replaceAll(",", "").replaceAll(",", "").trim().match(/(\d+(?:\.\d+)?)\s*(k|m|万|亿)?/i);
if (!match) return null;
const unit = String(match[2] ?? "").toLocaleLowerCase("en-US"), multiplier = unit === "k" ? 1e3 : unit === "m" ? 1e6 : unit === "万" ? 1e4 : unit === "亿" ? 1e8 : 1, count = Math.floor(Number(match[1]) * multiplier);
return Number.isSafeInteger(count) && count >= 0 ? count : null;
}
function hostCardPostCount(card) {
for (const selector of HOST_REPLY_COUNT_SELECTORS) {
const source = card.querySelector(selector);
if (source)
for (const value of [
source.dataset.count,
source.dataset.value,
source.getAttribute("title"),
source.getAttribute("aria-label"),
source.textContent
]) {
const replies = compactCount(value);
if (replies !== null) return replies + 1;
}
}
return 0;
}
function elementFromNode(node) {
return node ? node.nodeType === 1 ? node : node.parentElement : null;
}
function historyPostNumber(entry) {
if (!entry) return null;
try {
return (0, import_identifiers.discoursePostNumber)(entry.viewport?.postNumber ?? entry.postNumber);
} catch {
return entry.postNumber;
}
}
function historyDate(timestamp) {
const date = new Date(timestamp);
return Number.isFinite(date.getTime()) ? new Intl.DateTimeFormat("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: !1
}).format(date) : "未知时间";
}
function hostTopicPreheatPostCount(value) {
const count = Number(value);
return Number.isSafeInteger(count) ? Math.min(
import_reader_preferences_schema.HOST_TOPIC_PREHEAT_POST_COUNT_MAX,
Math.max(import_reader_preferences_schema.HOST_TOPIC_PREHEAT_POST_COUNT_MIN, count)
) : import_reader_preferences_schema.HOST_TOPIC_PREHEAT_POST_COUNT_DEFAULT;
}
class ReaderHostTopicPreheatController {
scope;
#options;
#document;
#maxQueuedTopics;
#readMaxConcurrentPreheats;
#requestFrame;
#cancelFrame;
#cards = /* @__PURE__ */ new Map();
#topics = /* @__PURE__ */ new Map();
#pendingCards = /* @__PURE__ */ new Set();
#nearTopics = /* @__PURE__ */ new Map();
#liveReading = /* @__PURE__ */ new Map();
#queue = [];
#activeControllers = /* @__PURE__ */ new Map();
#networkActiveTopics = /* @__PURE__ */ new Set();
#observer;
#resumePromise = null;
#paused = !1;
#enabled;
#preheatPostCount;
#readerForeground = !1;
#interactiveOpenHolds = 0;
#frame = 0;
#destroyed = !1;
constructor(options) {
this.#options = options, this.#document = options.document, this.#enabled = options.enabled === !0, this.#preheatPostCount = hostTopicPreheatPostCount(
options.preheatPostCount
), this.#maxQueuedTopics = Math.max(
1,
Math.floor(options.maxQueuedTopics ?? DEFAULT_MAX_QUEUED_TOPICS)
), this.#readMaxConcurrentPreheats = options.readMaxConcurrentPreheats ?? (() => options.maxConcurrentPreheats ?? DEFAULT_MAX_CONCURRENT_PREHEATS);
const view = this.#document.defaultView;
this.#requestFrame = options.requestFrame ?? ((callback) => {
const request = view?.requestAnimationFrame;
return typeof request == "function" ? request.call(view, callback) : (callback(0), 0);
}), this.#cancelFrame = options.cancelFrame ?? ((id) => {
const cancel = view?.cancelAnimationFrame;
typeof cancel == "function" && cancel.call(view, id);
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const NativeIntersectionObserver = this.#document.defaultView?.IntersectionObserver, createObserver = options.createIntersectionObserver ?? (NativeIntersectionObserver ? ((callback, init) => new NativeIntersectionObserver(callback, init)) : null);
this.#observer = createObserver?.(
(entries) => this.#onIntersections(entries),
{ root: null, rootMargin: "125% 0px", threshold: 0.01 }
) ?? null, options.mutations.subscribe((batch) => this.#onMutations(batch), this.scope), options.activity && this.scope.add(options.activity.subscribe(() => {
this.#onActivityChanged();
})), this.scope.add(() => this.#clear());
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
refreshHistory() {
if (!(this.#destroyed || this.scope.destroyed))
for (const [card, cardState] of this.#cards) {
const topic = this.#topics.get(cardState.topicId);
if (!topic) continue;
const totalIncreased = this.#refreshTotalCount(card, topic), target = this.#targetPostNumber(
cardState.topicId,
cardState.routePostNumber
);
topic.status !== "loading" && (topic.targetPostNumber = target), this.#renderCard(card, topic), totalIncreased && cardState.near && this.#enqueue(topic);
}
}
refreshConfirmedReadCount(topicId) {
if (!(this.#destroyed || this.scope.destroyed))
for (const topic of this.#topics.values())
topicId !== void 0 && topic.topicId !== topicId || (topic.confirmedReadCount = this.#confirmedReadCount(topic.topicId), this.#renderTopic(topic));
}
applyEnabled(enabled) {
if (this.#destroyed || this.scope.destroyed) return;
const next = enabled !== !1;
if (next !== this.#enabled) {
if (this.#enabled = next, !next) {
this.#disablePreheat();
return;
}
for (const topic of this.#topics.values()) this.#renderTopic(topic);
this.#fillQueue(), this.#pump();
}
}
applyPreheatPostCount(postCount) {
if (this.#destroyed || this.scope.destroyed) return;
const next = hostTopicPreheatPostCount(postCount);
next !== this.#preheatPostCount && (this.#preheatPostCount = next, this.#enabled && this.#restartPreheatForPostCount());
}
setReaderForeground(active) {
this.#destroyed || this.scope.destroyed || this.#readerForeground !== active && (this.#readerForeground = active, this.#enforceForegroundNetworkBudget(), this.#fillQueue(), this.#pump());
}
/**
* 用户正在打开一个 Topic 时,暂停全部宿主预热联网与缓存恢复。
*
* 隐藏预热 bundle 与可见 Topic bundle 不是同一个 Session;保留同 Topic 的后台
* 请求会让可见打开等它完整落盘后才取得 handoff,无法在原地提升为 topic-visible。
* 因此这里也取消目标 Topic 的未完成预热,让可见 Session 立即以自身优先级读取。
* 引用计数让快速连续点击不会被较早事务的 finally 提前恢复后台流。
*/
holdInteractiveOpen(rawTopicId) {
if (this.#destroyed || this.scope.destroyed) return () => {
};
(0, import_identifiers.discourseTopicId)(rawTopicId), this.#interactiveOpenHolds += 1, this.#enforceInteractiveOpenBudget();
let released = !1;
return () => {
if (!released && (released = !0, this.#interactiveOpenHolds = Math.max(0, this.#interactiveOpenHolds - 1), !(this.#destroyed || this.scope.destroyed))) {
if (this.#interactiveOpenHolds > 0) {
this.#enforceInteractiveOpenBudget();
return;
}
this.#fillQueue(), this.#pump();
}
};
}
updateLiveReading(topicId, postNumber, confirmedReadCount, viewedAt = Date.now()) {
if (this.#destroyed || this.scope.destroyed) return;
const topic = this.#topics.get(topicId), timestamp = Number(viewedAt);
this.#liveReading.set(topicId, Object.freeze({
postNumber,
viewedAt: Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Date.now()
})), topic && (this.#stopPreheatForLiveTopic(topic), topic.confirmedReadCount = Math.max(
0,
Math.floor(Number(confirmedReadCount) || 0)
), this.#renderTopic(topic));
}
clearLiveReading(topicId) {
if (this.#destroyed || this.scope.destroyed) return;
const topicIds = topicId === void 0 ? [...this.#liveReading.keys()] : [topicId];
for (const currentTopicId of topicIds) {
if (!this.#liveReading.delete(currentTopicId)) continue;
const topic = this.#topics.get(currentTopicId);
topic && (topic.confirmedReadCount = this.#confirmedReadCount(currentTopicId), this.#renderTopic(topic), this.#nearTopics.has(currentTopicId) && this.#enqueue(topic));
}
}
#onMutations(batch) {
if (!this.#destroyed) {
if (batch.rootChanged) {
for (const card of [...this.#cards.keys()])
batch.root?.contains(card) || this.#releaseCard(card);
batch.root && this.#collectCards(batch.root);
}
for (const record of batch.records)
if (!elementFromNode(record.target)?.closest(META_SELECTOR)) {
record.type === "characterData" && this.#collectCards(record.target);
for (const node of record.removedNodes) this.#releaseCardsIn(node);
for (const node of record.addedNodes) this.#collectCards(node);
}
this.#scheduleCards();
}
}
#collectCards(node) {
const element = elementFromNode(node);
if (!element || element.closest(".ldp-overlay,.ldp-reader-portal-host") || element.matches(META_SELECTOR)) return;
const nearest = element.matches(CARD_SELECTOR) ? element : element.closest(CARD_SELECTOR);
if (nearest) {
this.#pendingCards.add(nearest);
return;
}
for (const card of element.querySelectorAll(CARD_SELECTOR))
this.#pendingCards.add(card);
}
#releaseCardsIn(node) {
const element = elementFromNode(node);
if (element) {
element.matches(CARD_SELECTOR) && this.#releaseCard(element);
for (const card of element.querySelectorAll(CARD_SELECTOR))
this.#releaseCard(card);
}
}
#scheduleCards() {
this.#frame || !this.#pendingCards.size || (this.#frame = this.#requestFrame(() => {
this.#frame = 0;
const cards = [...this.#pendingCards];
this.#pendingCards.clear();
for (const card of cards)
card.isConnected && this.#attachCard(card);
}));
}
#attachCard(card) {
const current = this.#cards.get(card);
if (current) {
const topic2 = this.#topics.get(current.topicId);
if (topic2) {
const totalIncreased = this.#refreshTotalCount(card, topic2);
this.#renderCard(card, topic2), totalIncreased && current.near && this.#enqueue(topic2);
}
return;
}
const href = card.querySelector(TOPIC_LINK_SELECTOR)?.getAttribute("href") ?? "", route = href ? (0, import_reader_userscript_target_adapter.parseReaderUserscriptTopicRoute)(href, this.#document.baseURI) : null;
let topicId;
try {
topicId = route?.topicId ?? (0, import_identifiers.discourseTopicId)(
card.dataset.topicId ?? card.getAttribute("data-topic-id")
);
} catch {
return;
}
const meta = this.#document.createElement("span");
meta.className = META_SELECTOR.slice(1);
const metaRow = this.#document.createElement("span");
metaRow.className = META_ROW_CLASS, metaRow.append(meta), card.classList.add(PERFORMANCE_CARD_CLASS), (card.querySelector(".link-bottom-line") ?? card.querySelector(".main-link") ?? card).append(metaRow);
const cardState = {
topicId,
routePostNumber: route?.postNumber ?? null,
meta,
metaRow,
near: !1
};
this.#cards.set(card, cardState);
let topic = this.#topics.get(topicId);
topic || (topic = {
topicId,
targetPostNumber: this.#targetPostNumber(
topicId,
cardState.routePostNumber
),
status: "idle",
warmedCount: 0,
totalCount: Math.max(
this.#options.historyEntry(topicId)?.postsCount ?? 0,
hostCardPostCount(card)
),
cacheHit: !1,
confirmedReadCount: this.#confirmedReadCount(topicId),
attempts: 0,
restoreAttempted: !1,
restorePending: !1,
restoreController: null,
cards: /* @__PURE__ */ new Set()
}, this.#topics.set(topicId, topic)), topic.cards.add(card), this.#renderCard(card, topic), this.#observer ? this.#observer.observe(card) : this.#enqueue(topic);
}
#releaseCard(card) {
this.#pendingCards.delete(card);
const current = this.#cards.get(card);
if (!current) return;
this.#observer?.unobserve(card), current.metaRow.remove(), card.classList.remove(PERFORMANCE_CARD_CLASS), this.#cards.delete(card);
const topic = this.#topics.get(current.topicId);
topic?.cards.delete(card), topic && ![...topic.cards].some(
(candidate) => this.#cards.get(candidate)?.near === !0
) && this.#leavePreheatArea(topic), topic && !topic.cards.size && (topic.status === "queued" && (this.#removeFromQueue(topic.topicId), topic.status = "idle"), topic.restoreController?.abort(
new DOMException("Topic 已离开宿主预热区", "AbortError")
), topic.restoreController = null, topic.restorePending = !1, this.#activeControllers.get(topic.topicId)?.abort(
new DOMException("Topic 已离开宿主预热区", "AbortError")
), topic.status !== "loading" && this.#topics.delete(topic.topicId));
}
#targetPostNumber(topicId, routePostNumber) {
const historical = historyPostNumber(this.#options.historyEntry(topicId));
if (historical !== null) return historical;
try {
if (this.#options.readOpenTopicsAtFirstPost()) return (0, import_identifiers.discoursePostNumber)(1);
} catch (error) {
this.#report(error);
}
return routePostNumber ?? (0, import_identifiers.discoursePostNumber)(1);
}
#onIntersections(entries) {
for (const entry of entries) {
const card = entry.target, cardState = this.#cards.get(card), topic = cardState ? this.#topics.get(cardState.topicId) : null;
if (!cardState || !topic) continue;
if (cardState.near = entry.isIntersecting, !entry.isIntersecting) {
[...topic.cards].some(
(candidate) => this.#cards.get(candidate)?.near === !0
) || this.#leavePreheatArea(topic);
continue;
}
const rect = entry.boundingClientRect, viewportCenter = entry.rootBounds ? (entry.rootBounds.top + entry.rootBounds.bottom) / 2 : Number(this.#document.defaultView?.innerHeight ?? 0) / 2, cardCenter = Number.isFinite(rect?.top) && Number.isFinite(rect?.bottom) ? (rect.top + rect.bottom) / 2 : viewportCenter;
this.#nearTopics.set(topic.topicId, Math.abs(cardCenter - viewportCenter));
}
if (!this.#activityVisible()) {
this.#pauseForInactivity();
return;
}
if (this.#dropStaleQueuedTopics(), this.#nearTopics.size)
for (const [topicId, controller] of this.#activeControllers)
this.#nearTopics.has(topicId) || controller.abort(
new DOMException("Topic 已离开宿主预热区", "AbortError")
);
if (this.#paused) {
this.#tryResume();
return;
}
this.#fillQueue();
}
#fillQueue() {
if (!(!this.#enabled || this.#paused || this.#interactiveOpenHolds > 0 || !this.#activityVisible())) {
this.#dropStaleQueuedTopics();
for (const [topicId] of [...this.#nearTopics.entries()].sort((left, right) => left[1] - right[1])) {
if (this.#queue.length >= this.#maxQueuedTopics) break;
const topic = this.#topics.get(topicId);
topic && this.#enqueue(topic);
}
}
}
#dropStaleQueuedTopics() {
const retained = this.#queue.filter((topicId) => {
const topic = this.#topics.get(topicId);
return !topic || topic.status !== "queued" ? !1 : this.#nearTopics.has(topicId) ? !0 : (topic.status = "idle", this.#renderTopic(topic), !1);
});
retained.sort((left, right) => (this.#nearTopics.get(left) ?? Number.POSITIVE_INFINITY) - (this.#nearTopics.get(right) ?? Number.POSITIVE_INFINITY)), this.#queue.splice(0, this.#queue.length, ...retained);
}
#enqueue(topic) {
!this.#enabled || !this.#activityVisible() || this.#liveReading.has(topic.topicId) || topic.status === "queued" || topic.status === "loading" || topic.status === "partial" || topic.status === "ready" || topic.attempts >= 2 || this.#queue.length >= this.#maxQueuedTopics || (topic.status = "queued", this.#queue.push(topic.topicId), this.#renderTopic(topic), this.#restorePreheat(topic), this.#pump());
}
#pump() {
if (this.#destroyed || this.scope.destroyed || !this.#enabled || this.#paused || this.#interactiveOpenHolds > 0 || !this.#activityVisible()) return;
for (let index = this.#queue.length - 1; index >= 0; index -= 1) {
const topicId = this.#queue[index], topic = this.#topics.get(topicId);
topic?.status === "queued" && !this.#liveReading.has(topicId) || (this.#queue.splice(index, 1), topic?.status === "queued" && (topic.status = "idle", this.#renderTopic(topic)));
}
const configuredMaxConcurrentPreheats = Math.min(
MAX_CONCURRENT_PREHEATS,
Math.max(
1,
Math.floor(Number(this.#readMaxConcurrentPreheats()) || 1)
)
), maxConcurrentPreheats = this.#readerForeground ? Math.min(1, configuredMaxConcurrentPreheats) : configuredMaxConcurrentPreheats;
for (; this.#networkActiveTopics.size < maxConcurrentPreheats && this.#activeControllers.size < this.#maxQueuedTopics + maxConcurrentPreheats; ) {
const queueIndex = this.#queue.findIndex((topicId2) => this.#topics.get(topicId2)?.restorePending !== !0);
if (queueIndex < 0) return;
const [topicId] = this.#queue.splice(queueIndex, 1);
if (topicId === void 0) return;
const topic = this.#topics.get(topicId);
if (!topic || topic.status !== "queued") continue;
const controller = new AbortController();
this.#activeControllers.set(topicId, controller), this.#networkActiveTopics.add(topicId), topic.status = "loading", topic.attempts += 1, this.#renderTopic(topic);
let task;
try {
task = this.#options.preheat(
topic.topicId,
topic.targetPostNumber,
controller.signal,
(progress) => {
controller.signal.aborted || this.#activeControllers.get(topicId) !== controller || this.#topics.get(topicId) !== topic || (this.#applyProgress(topic, progress, !1), progress.complete && !controller.signal.aborted && this.#releaseNetworkSlot(topicId, controller));
},
topic.totalCount,
this.#preheatPostCount
);
} catch (error) {
task = Promise.reject(error);
}
task.then((result) => {
controller.signal.aborted || this.#applyProgress(topic, result, !0);
}).catch((error) => {
if (controller.signal.aborted) {
topic.status = "idle", topic.attempts = Math.max(0, topic.attempts - 1), this.#renderTopic(topic);
return;
}
topic.status = "error", this.#renderTopic(topic), this.#shouldPause(error) && this.#pauseQueue(), this.#report(error), topic.cards.size || this.#topics.delete(topic.topicId);
}).finally(() => {
this.#networkActiveTopics.delete(topicId), this.#activeControllers.get(topicId) === controller && (this.#activeControllers.delete(topicId), controller.signal.aborted && topic.status === "loading" && (topic.status = "idle", topic.attempts = Math.max(0, topic.attempts - 1), this.#renderTopic(topic)), !this.#paused && this.#activityVisible() && (this.#fillQueue(), this.#pump()));
});
}
}
#restorePreheat(topic) {
if (!this.#enabled || this.#interactiveOpenHolds > 0 || topic.restoreAttempted || !this.#options.restorePreheat)
return;
topic.restoreAttempted = !0, topic.restorePending = !0;
const controller = new AbortController();
topic.restoreController = controller, this.#options.restorePreheat(
topic.topicId,
topic.targetPostNumber,
controller.signal,
this.#preheatPostCount
).then((progress) => {
if (progress === null || controller.signal.aborted || this.#destroyed || this.scope.destroyed || this.#topics.get(topic.topicId) !== topic) return;
const minimumTotalCount = topic.totalCount;
topic.warmedCount = Math.max(0, Math.floor(progress.warmedCount)), topic.totalCount = Math.max(
minimumTotalCount,
Math.max(0, Math.floor(progress.totalCount))
), topic.cacheHit = progress.cacheHit, progress.complete && progress.totalCount >= minimumTotalCount && (topic.status === "queued" || topic.status === "idle") && (this.#removeFromQueue(topic.topicId), topic.status = "ready"), this.#renderTopic(topic);
}).catch((error) => {
controller.signal.aborted || this.#report(error);
}).finally(() => {
topic.restoreController === controller && (topic.restoreController = null, topic.restorePending = !1, !this.#paused && this.#activityVisible() && (this.#fillQueue(), this.#pump()));
});
}
#releaseNetworkSlot(topicId, controller) {
this.#activeControllers.get(topicId) !== controller || !this.#networkActiveTopics.delete(topicId) || this.#paused || (this.#fillQueue(), this.#pump());
}
#removeFromQueue(topicId) {
for (let index = this.#queue.length - 1; index >= 0; index -= 1)
this.#queue[index] === topicId && this.#queue.splice(index, 1);
}
#shouldPause(error) {
try {
return this.#options.shouldPauseAfterError?.(error) === !0;
} catch (cause) {
return this.#report(cause), !0;
}
}
#pauseQueue() {
this.#paused = !0;
for (const topicId of this.#queue.splice(0)) {
const queued = this.#topics.get(topicId);
queued?.status === "queued" && (queued.status = "idle", this.#renderTopic(queued));
}
for (const controller of this.#activeControllers.values())
controller.abort(
new DOMException("宿主 Topic 预热因统一限流暂停", "AbortError")
);
}
#activityVisible() {
try {
return this.#options.activity?.visible() ?? !0;
} catch (error) {
return this.#report(error), !1;
}
}
#pauseForInactivity() {
for (const topicId of this.#queue.splice(0)) {
const queued = this.#topics.get(topicId);
queued?.status === "queued" && (queued.status = "idle", this.#renderTopic(queued));
}
for (const controller of this.#activeControllers.values())
controller.abort(
new DOMException("页面进入后台,暂停宿主 Topic 预热", "AbortError")
);
}
#disablePreheat() {
for (const queuedTopicId of this.#queue.splice(0)) {
const queued = this.#topics.get(queuedTopicId);
queued?.status === "queued" && (queued.status = "idle");
}
for (const controller of this.#activeControllers.values())
controller.abort(
new DOMException("宿主 Topic 预热已关闭", "AbortError")
);
for (const topic of this.#topics.values()) {
topic.restoreController?.abort(
new DOMException("宿主 Topic 预热已关闭", "AbortError")
);
try {
this.#options.forgetPreheat?.(topic.topicId);
} catch (error) {
this.#report(error);
}
topic.status = "idle", topic.attempts = 0, topic.restoreAttempted = !1, topic.restorePending = !1, this.#renderTopic(topic);
}
}
#restartPreheatForPostCount() {
this.#queue.length = 0;
for (const controller of this.#activeControllers.values())
controller.abort(
new DOMException("宿主 Topic 预热楼层数已更新", "AbortError")
);
for (const topic of this.#topics.values()) {
topic.restoreController?.abort(
new DOMException("宿主 Topic 预热楼层数已更新", "AbortError")
), topic.restoreController = null;
try {
this.#options.forgetPreheat?.(topic.topicId);
} catch (error) {
this.#report(error);
}
topic.status = "idle", topic.warmedCount = 0, topic.cacheHit = !1, topic.attempts = 0, topic.restoreAttempted = !1, topic.restorePending = !1, this.#renderTopic(topic);
}
this.#fillQueue(), this.#pump();
}
#enforceForegroundNetworkBudget() {
if (!this.#readerForeground) return;
let retained = 0;
for (const [topicId, controller] of this.#activeControllers)
this.#networkActiveTopics.has(topicId) && (retained += 1, !(retained <= 1) && controller.abort(new DOMException(
"Reader 前台阅读收紧宿主预热并发",
"AbortError"
)));
}
#enforceInteractiveOpenBudget() {
if (!(this.#interactiveOpenHolds <= 0)) {
for (const controller of this.#activeControllers.values())
controller.abort(new DOMException(
"Reader 正在打开可见 Topic,宿主预热让出请求槽",
"AbortError"
));
for (const topic of this.#topics.values())
topic.restoreController && topic.restoreController.abort(new DOMException(
"Reader 正在打开可见 Topic,宿主缓存恢复让出事务",
"AbortError"
));
}
}
#leavePreheatArea(topic) {
if (this.#nearTopics.delete(topic.topicId), topic.status === "ready") {
try {
this.#options.forgetPreheat?.(topic.topicId);
} catch (error) {
this.#report(error);
}
topic.status = "idle", topic.attempts = 0, topic.restoreAttempted = !1, topic.cacheHit = !1, this.#renderTopic(topic);
}
}
#onActivityChanged() {
if (!(this.#destroyed || this.scope.destroyed)) {
if (!this.#activityVisible()) {
this.#pauseForInactivity();
return;
}
if (this.#paused) {
this.#tryResume();
return;
}
this.#fillQueue(), this.#pump();
}
}
#stopPreheatForLiveTopic(topic) {
this.#removeFromQueue(topic.topicId), topic.restoreController?.abort(
new DOMException("Topic 已进入 Reader,停止宿主预热", "AbortError")
), this.#activeControllers.get(topic.topicId)?.abort(
new DOMException("Topic 已进入 Reader,停止宿主预热", "AbortError")
), topic.status !== "ready" && (topic.status = "idle"), this.#renderTopic(topic), this.#pump();
}
#tryResume() {
if (this.#resumePromise) return this.#resumePromise;
const promise = Promise.resolve(this.#options.canResume?.() ?? !0).then((ready) => {
!ready || this.#destroyed || this.scope.destroyed || !this.#enabled || !this.#activityVisible() || (this.#paused = !1, this.#fillQueue(), this.#pump());
}).catch((error) => this.#report(error)).finally(() => {
this.#resumePromise === promise && (this.#resumePromise = null);
});
return this.#resumePromise = promise, promise;
}
#applyProgress(topic, progress, settled) {
const minimumTotalCount = topic.totalCount;
topic.warmedCount = Math.max(0, Math.floor(progress.warmedCount)), topic.totalCount = Math.max(
minimumTotalCount,
Math.max(0, Math.floor(progress.totalCount))
), topic.cacheHit = progress.cacheHit, progress.complete && progress.totalCount >= minimumTotalCount ? topic.status = "ready" : settled && (topic.status = "partial"), this.#renderTopic(topic), settled && !topic.cards.size && this.#topics.delete(topic.topicId);
}
#refreshTotalCount(card, topic) {
const totalCount = Math.max(
this.#options.historyEntry(topic.topicId)?.postsCount ?? 0,
hostCardPostCount(card)
);
if (totalCount <= topic.totalCount) return !1;
if (topic.totalCount = totalCount, topic.status === "ready" || topic.status === "partial" || topic.status === "error") {
if (topic.status === "ready")
try {
this.#options.forgetPreheat?.(topic.topicId);
} catch (error) {
this.#report(error);
}
topic.status = "idle", topic.attempts = 0;
}
return !0;
}
#renderTopic(topic) {
for (const card of [...topic.cards])
card.isConnected ? this.#renderCard(card, topic) : this.#releaseCard(card);
}
#renderCard(card, topic) {
const cardState = this.#cards.get(card);
if (!cardState) return;
const history = this.#options.historyEntry(topic.topicId), live = this.#liveReading.get(topic.topicId), floor = live?.postNumber ?? historyPostNumber(history) ?? topic.targetPostNumber, viewedAt = live?.viewedAt ?? history?.viewedAt ?? 0, historyLabel = live || history ? `上次阅读 ${historyDate(viewedAt)} · 定位 #${floor}` : "", total = topic.totalCount || history?.postsCount || 0, suffix = topic.status === "queued" ? "(排队)" : topic.status === "loading" ? "(后台)" : topic.status === "error" ? "(失败)" : topic.status === "partial" ? "(部分)" : "", activityLabel = live ? "阅读中" : this.#enabled ? `预热 ${topic.warmedCount}/${total || "?"}${suffix}` : "", readLabel = `已读 ${topic.confirmedReadCount}`;
cardState.meta.textContent = [historyLabel, activityLabel, readLabel].filter(Boolean).join(" · "), cardState.meta.dataset.ldpPreheatState = live ? "reading" : topic.status, cardState.meta.title = [
...live || history ? [
`上次阅读:${new Date(viewedAt).toLocaleString("zh-CN")}`,
`定位:#${floor}`
] : [],
activityLabel,
readLabel
].filter(Boolean).join(";");
}
#confirmedReadCount(topicId) {
try {
const value = Number(this.#options.readConfirmedCount?.(topicId) ?? 0);
return Number.isSafeInteger(value) && value > 0 ? value : 0;
} catch (error) {
return this.#report(error), 0;
}
}
#clear() {
this.#frame && this.#cancelFrame(this.#frame), this.#frame = 0, this.#pendingCards.clear();
for (const controller of this.#activeControllers.values())
controller.abort(
new DOMException("宿主 Topic 预热已释放", "AbortError")
);
this.#activeControllers.clear(), this.#networkActiveTopics.clear(), this.#resumePromise = null, this.#paused = !1, this.#enabled = !1, this.#readerForeground = !1, this.#queue.length = 0, this.#nearTopics.clear(), this.#liveReading.clear();
for (const topic of this.#topics.values())
topic.restoreController?.abort(
new DOMException("宿主 Topic 预热已释放", "AbortError")
);
this.#observer?.disconnect(), this.#observer = null;
for (const [card, current] of this.#cards)
current.metaRow.remove(), card.classList.remove(PERFORMANCE_CARD_CLASS);
this.#cards.clear(), this.#topics.clear();
}
#report(error) {
try {
this.#options.onError?.(error);
} catch {
}
}
}
}, "2b5722f3f874727b7e11b0ec3d35c937c6669c6dbf281caffbe178b05c076142");
/* Source: lite/src/userscript/reader-host-topic-source-coordinator.ts */
runtime.register("src/userscript/reader-host-topic-source-coordinator.js", function(module, exports, require) {
var reader_host_topic_source_coordinator_exports = {};
__export(reader_host_topic_source_coordinator_exports, {
ReaderHostTopicSourceCoordinator: () => ReaderHostTopicSourceCoordinator
});
module.exports = __toCommonJS(reader_host_topic_source_coordinator_exports);
var import_native_host_api = require("../discourse/native-host-api.js"), import_lifecycle = require("../kernel/lifecycle.js");
const TOPIC_ROW_SELECTOR = "tr.topic-list-item,.topic-list-item,.latest-topic-list-item", SOURCE_SELECTOR = ".fk-d-menu,.menu-panel,.search-menu,.user-menu,.hamburger-panel,.sidebar-hamburger-dropdown,.chat-drawer", SURFACE_SELECTOR = ".fk-d-menu,.menu-panel,.chat-drawer", OWNED_SELECTOR = '.ldp-overlay,.ldp-reader-portal-host,[data-ldp-owned="true"]';
function nativeNotificationTarget(target) {
if (target.request.source !== "notification" && target.request.source !== "message") return !1;
const menu = target.anchor.closest(".user-menu[data-tab-id]");
return !!(menu && !menu.closest(OWNED_SELECTOR));
}
function acknowledgeNativeNotification(anchor) {
const view = anchor.ownerDocument.defaultView, EventConstructor = view?.MouseEvent;
let event;
typeof EventConstructor == "function" ? event = new EventConstructor("click", {
bubbles: !0,
button: 0,
cancelable: !0,
ctrlKey: !0
}) : (event = new (view?.Event ?? Event)("click", {
bubbles: !0,
cancelable: !0
}), Object.defineProperties(event, {
button: { configurable: !0, value: 0 },
ctrlKey: { configurable: !0, value: !0 }
})), event.preventDefault();
try {
return anchor.dispatchEvent(event), !0;
} catch {
return !1;
}
}
function topicIdFromCard(card) {
const direct = String(
card.getAttribute("data-topic-id") ?? card.dataset.topicId ?? ""
).trim();
return direct || ((card.querySelector(
'a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]'
)?.getAttribute("href") ?? "").match(/\/t\/(?:[^/]+\/)?(\d+)(?:\/|$)/)?.[1] ?? "");
}
function sourceElement(target) {
const anchorSource = target.anchor.closest(SOURCE_SELECTOR), markerSource = target.sourceElement?.closest(
SOURCE_SELECTOR
) ?? null;
return anchorSource ?? markerSource;
}
function sourceSurface(source) {
return !source || source.closest(OWNED_SELECTOR) ? null : source.matches(SURFACE_SELECTOR) ? source : source.closest(SURFACE_SELECTOR) ?? source.querySelector(SURFACE_SELECTOR) ?? source;
}
function surfaceClosed(surface) {
if (!surface.isConnected || surface.hidden === !0 || surface.getAttribute("aria-hidden") === "true") return !0;
const view = surface.ownerDocument.defaultView;
if (typeof view?.getComputedStyle != "function") return !1;
try {
const style = view.getComputedStyle(surface);
return style.display === "none" || style.visibility === "hidden";
} catch {
return !1;
}
}
function openSourceSurfaces(document) {
const seen = /* @__PURE__ */ new Set(), result = [];
for (const source of document.querySelectorAll(SOURCE_SELECTOR)) {
const surface = sourceSurface(source);
!surface || seen.has(surface) || surfaceClosed(surface) || (seen.add(surface), result.push(Object.freeze({ source, surface })));
}
return Object.freeze(result);
}
class ReaderHostTopicSourceCoordinator {
scope;
#document;
#host;
#anchors = /* @__PURE__ */ new WeakMap();
#restoringTargets = /* @__PURE__ */ new Set();
#nativeNotificationTargets = /* @__PURE__ */ new Set();
#closingOpenSurfaces = null;
constructor(options) {
this.#document = options.document, this.#host = options.host, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const readerRoot = options.readerRoot, isEmbedded = options.isEmbedded;
readerRoot && isEmbedded && this.scope.listen(readerRoot, "pointerdown", () => {
this.scope.destroyed || !isEmbedded() || this.closeOpenSurfaces();
}, !0), this.scope.add(() => {
this.#restoringTargets.clear(), this.#nativeNotificationTargets.clear(), this.#closingOpenSurfaces = null, this.#document.documentElement.classList.remove(
"ldp-reader-host-anchor-restoring"
);
});
}
async prepare(target) {
if (this.scope.destroyed) return;
const pointerAnchor = this.#capturePointerAnchor(target);
if (pointerAnchor) {
this.#anchors.set(target, pointerAnchor), this.#restoringTargets.add(target), this.#document.documentElement.classList.add(
"ldp-reader-host-anchor-restoring"
);
const blur = target.anchor.blur;
typeof blur == "function" && blur.call(target.anchor);
}
if (nativeNotificationTarget(target)) {
this.#nativeNotificationTargets.add(target);
return;
}
await this.#closeSourceSurface(target);
}
closeOpenSurfaces() {
if (this.scope.destroyed) return Promise.resolve();
if (this.#closingOpenSurfaces) return this.#closingOpenSurfaces;
const closing = this.#closeOpenSourceSurfaces().catch(() => {
});
return this.#closingOpenSurfaces = closing, closing.then(() => {
this.#closingOpenSurfaces === closing && (this.#closingOpenSurfaces = null);
}), closing;
}
async settle(target, opened) {
this.#nativeNotificationTargets.delete(target) && (opened && !this.scope.destroyed && acknowledgeNativeNotification(target.anchor), (opened || this.#nativeNotificationTargets.size === 0) && await this.#closeSourceSurface(target));
const anchor = this.#anchors.get(target) ?? null;
this.#anchors.delete(target);
let restored = !1;
return opened && anchor && !this.scope.destroyed && (await this.#nextFrame(), this.scope.destroyed || (restored = this.#restorePointerAnchor(anchor))), await this.#finishAnchorRestoration(target), restored;
}
destroy() {
this.scope.destroy();
}
#capturePointerAnchor(target) {
const clientY = target.pointer?.clientY;
if (target.pointer?.detail === 0 || !Number.isFinite(clientY))
return null;
const card = target.anchor.closest(TOPIC_ROW_SELECTOR);
if (!card || card.closest(OWNED_SELECTOR)) return null;
const rect = card.getBoundingClientRect();
return !(rect.width > 0) || !(rect.height > 0) ? null : Object.freeze({
card,
topicId: String(target.request.topicId),
clientY,
offsetY: Math.max(0, Math.min(rect.height, clientY - rect.top))
});
}
#restorePointerAnchor(anchor) {
let card = anchor.card.isConnected ? anchor.card : null;
if (card || (card = [...this.#document.querySelectorAll(
TOPIC_ROW_SELECTOR
)].find(
(candidate) => !candidate.closest(OWNED_SELECTOR) && topicIdFromCard(candidate) === anchor.topicId
) ?? null), !card) return !1;
for (const candidate of this.#document.querySelectorAll(
'[data-ldp-reader-active-topic="true"]'
))
candidate !== card && candidate.removeAttribute("data-ldp-reader-active-topic");
card.dataset.ldpReaderActiveTopic = "true";
const delta = card.getBoundingClientRect().top + anchor.offsetY - anchor.clientY, view = this.#document.defaultView;
return Math.abs(delta) >= 0.5 && typeof view?.scrollBy == "function" && view.scrollBy({ top: delta, behavior: "auto" }), !0;
}
async #finishAnchorRestoration(target) {
this.#restoringTargets.delete(target), !(this.#restoringTargets.size || this.scope.destroyed) && (await this.#nextFrame(), !(this.#restoringTargets.size || this.scope.destroyed) && this.#document.documentElement.classList.remove(
"ldp-reader-host-anchor-restoring"
));
}
async #closeSourceSurface(target) {
const source = sourceElement(target), surface = sourceSurface(source);
!surface || surfaceClosed(surface) || await this.#closeSurface(surface, source);
}
async #closeOpenSourceSurfaces() {
for (const { source, surface } of openSourceSurfaces(this.#document)) {
if (this.scope.destroyed) return;
await this.#closeSurface(surface, source);
}
}
async #closeSurface(surface, source) {
const identifier = surface.matches(".fk-d-menu") ? String(surface.dataset.identifier ?? "").trim() : "";
if (identifier) {
const close = (0, import_native_host_api.discourseNativeMenuCloser)(this.#host);
if (close) {
await this.#invokeAndWait(
surface,
() => close(identifier)
);
return;
}
}
const chatClose = surface.matches(".chat-drawer") ? surface.querySelector(
".chat-drawer-header__close-btn"
) : null;
if (chatClose && typeof chatClose.click == "function") {
await this.#invokeAndWait(surface, () => chatClose.click());
return;
}
const controlledIds = [surface.id].filter(Boolean);
let trigger = [...this.#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))
)
) ?? null;
if (!trigger) {
const selector = source?.classList.contains("search-menu") ? ".d-header-icons .search-dropdown" : source?.classList.contains("user-menu") ? ".d-header-icons .current-user" : source ? ".d-header-icons .hamburger-dropdown,.hamburger-dropdown" : "";
if (selector) {
const candidates = [
...this.#document.querySelectorAll(selector)
];
trigger = candidates.find(
(candidate) => candidate.getAttribute("aria-expanded") === "true"
) ?? candidates[0] ?? null;
}
}
trigger && typeof trigger.click == "function" && await this.#invokeAndWait(surface, () => trigger.click());
}
async #invokeAndWait(surface, action) {
try {
await Promise.resolve(action());
} catch {
return;
}
surfaceClosed(surface) || this.scope.destroyed || await new Promise((resolve) => {
const view = this.#document.defaultView;
let settled = !1;
const finish = () => {
settled || (settled = !0, resolve());
}, deadline = Date.now() + 180, check = () => {
if (this.scope.destroyed || surfaceClosed(surface) || Date.now() >= deadline) {
finish();
return;
}
typeof view?.requestAnimationFrame == "function" ? view.requestAnimationFrame(check) : view?.setTimeout(check, 16);
};
check();
});
}
async #nextFrame() {
const view = this.#document.defaultView;
typeof view?.requestAnimationFrame == "function" && await new Promise((resolve) => {
view.requestAnimationFrame(() => resolve());
});
}
}
}, "7f80358bbea58fd56863d806855971a64c9bcc0adddfe7b6c1ecc50db6d0c73e");
/* Source: lite/src/userscript/reader-userscript-application.ts */
runtime.register("src/userscript/reader-userscript-application.js", function(module, exports, require) {
var reader_userscript_application_exports = {};
__export(reader_userscript_application_exports, {
createReaderUserscriptApplication: () => createReaderUserscriptApplication,
createReaderUserscriptRouteChangePort: () => createReaderUserscriptRouteChangePort,
createReaderUserscriptRuntimeBindings: () => createReaderUserscriptRuntimeBindings,
createReaderUserscriptRuntimeStage: () => createReaderUserscriptRuntimeStage
});
module.exports = __toCommonJS(reader_userscript_application_exports);
var import_reader_application = require("../app/reader-application.js"), import_reader_browser_runtime = require("../app/reader-browser-runtime.js"), import_native_host_api = require("../discourse/native-host-api.js"), import_value_record = require("../kernel/value-record.js"), import_reader_preferences_schema = require("../state/reader-preferences-schema.js"), import_reader_userscript_target_adapter = require("./reader-userscript-target-adapter.js"), import_reader_host_topic_source_coordinator = require("./reader-host-topic-source-coordinator.js"), import_reader_host_topic_preheat_controller = require("./reader-host-topic-preheat-controller.js"), import_reader_floating_host_target_controller = require("./reader-floating-host-target-controller.js");
function createReaderUserscriptRouteChangePort(host) {
return Object.freeze({
subscribe(handler) {
if (typeof handler != "function")
throw new TypeError("Discourse page-change handler 必须是函数");
return (0, import_native_host_api.discourseDeferredSubscription)(() => {
const module2 = (0, import_value_record.valueRecord)(
host.lookupModule("discourse/lib/plugin-api")
), defaultExport = (0, import_value_record.valueRecord)(module2?.default), owner = typeof module2?.withPluginApi == "function" ? module2 : defaultExport, withPluginApi = owner?.withPluginApi;
if (typeof withPluginApi != "function") return null;
let bindingActive = !0, pageCleanup = null, pluginCleanup = null;
try {
const result = withPluginApi.call(
owner,
(apiValue) => {
if (!bindingActive) return;
const api = (0, import_value_record.valueRecord)(apiValue), onPageChange = api?.onPageChange;
if (typeof onPageChange != "function") return;
const cleanup = onPageChange.call(api, () => {
bindingActive && handler();
});
typeof cleanup == "function" && (pageCleanup = cleanup);
}
);
typeof result == "function" && (pluginCleanup = result);
} catch {
return () => {
};
}
return () => {
if (bindingActive) {
bindingActive = !1;
try {
pageCleanup?.();
} finally {
pluginCleanup?.();
}
}
};
});
}
});
}
function createReaderUserscriptApplication(options) {
const detectedHost = new import_reader_application.BrowserDiscourseHostPort({
moduleLookup: (name) => options.environment.discourseHost.lookupModule(name),
document: options.document,
window: options.window,
...options.hostTimeoutMs === void 0 ? {} : { timeoutMs: options.hostTimeoutMs },
...options.createHostObserver === void 0 ? {} : { createObserver: options.createHostObserver }
}), host = options.isVerifiedHost ? Object.freeze({
async waitForHost(signal) {
const detected = await detectedHost.waitForHost(signal);
if (detected || signal.aborted) return detected;
const hostname = String(
options.document.location?.hostname ?? options.window.location?.hostname ?? ""
).trim();
return !await options.isVerifiedHost(
hostname,
signal
) || signal.aborted ? null : Object.freeze({ detection: "verified-site" });
}
}) : detectedHost;
return new import_reader_application.ReaderApplication({
bodyReady: (signal) => (0, import_reader_application.browserBodyReady)(options.document, signal),
host,
preferences: options.preferences,
stages: options.stages
});
}
function createReaderUserscriptRuntimeBindings(environment, translationOptions, resourceOptions) {
let externalHttp;
try {
externalHttp = environment.createExternalHttp(
translationOptions?.http
);
} catch (cause) {
if (translationOptions) throw cause;
}
let translation;
if (translationOptions) {
if (!externalHttp) throw new Error("外部翻译 HTTP capability 不可用");
const { http: _httpOptions, ...adapterOptions } = translationOptions;
translation = Object.freeze({ ...adapterOptions, http: externalHttp });
}
let resources;
if (resourceOptions) {
const http = environment.createPublicResourceHttp();
resources = Object.freeze({
...resourceOptions,
http,
objectUrls: environment.createObjectUrlPort()
});
}
const valueStorage = environment.createValueStorage(), assetCacheStorage = environment.createAssetCacheStorage();
let fontStylesheet;
try {
fontStylesheet = environment.createExternalStylesheetAppender();
} catch {
}
return Object.freeze({
host: environment.discourseHost,
share: environment.createShareSurface(),
...externalHttp ? {
connect: Object.freeze({ http: externalHttp }),
credit: Object.freeze({
http: externalHttp,
...valueStorage ? { storage: valueStorage } : {}
}),
communityScore: Object.freeze({ http: externalHttp })
} : {},
...translation === void 0 ? {} : { translation },
...resources === void 0 ? {} : { resources },
...assetCacheStorage ? { assetCacheStorage } : {},
...fontStylesheet ? { fontStylesheet } : {}
});
}
function createReaderUserscriptRuntimeStage(options) {
const bindings = createReaderUserscriptRuntimeBindings(
options.environment,
options.translation,
options.resources
), valueStorage = options.environment.createValueStorage(), onReady = options.onReady, targetOptions = options.targets === !1 ? null : options.targets ?? Object.freeze({});
return (0, import_reader_browser_runtime.createReaderBrowserRuntimeStage)({
shell: options.shell,
...options.informationFlow ? { informationFlow: options.informationFlow } : {},
runtime: {
...options.runtime,
searchForms: options.runtime.searchForms ?? options.environment.createPinyinSearchForms(),
host: bindings.host,
share: bindings.share,
...bindings.connect ? { connect: bindings.connect } : {},
...bindings.credit ? { credit: bindings.credit } : {},
...bindings.communityScore ? { communityScore: bindings.communityScore } : {},
...bindings.translation === void 0 ? {} : { translation: bindings.translation },
...bindings.resources === void 0 ? {} : { resources: bindings.resources },
...bindings.assetCacheStorage === void 0 ? {} : { assetCacheStorage: bindings.assetCacheStorage },
...bindings.fontStylesheet === void 0 ? {} : { fontStylesheet: bindings.fontStylesheet },
...options.runtime.threadContextStorage !== void 0 || !valueStorage ? {} : { threadContextStorage: valueStorage }
},
...options.selectNavigationPreferences === void 0 ? {} : {
selectNavigationPreferences: options.selectNavigationPreferences
},
...options.selectPerformancePreferences === void 0 ? {} : {
selectPerformancePreferences: options.selectPerformancePreferences
},
...options.performanceBudgetCeilings === void 0 ? {} : {
performanceBudgetCeilings: options.performanceBudgetCeilings
},
...options.layout === void 0 ? {} : { layout: options.layout },
...options.appearance === void 0 ? {} : { appearance: options.appearance },
...options.theme === void 0 ? {} : { theme: options.theme },
...options.font === void 0 ? {} : { font: options.font },
...options.motion === void 0 ? {} : { motion: options.motion },
...options.image === void 0 ? {} : { image: options.image },
...options.boostCopy === void 0 ? {} : { boostCopy: options.boostCopy },
...options.topicActionRail === void 0 ? {} : { topicActionRail: options.topicActionRail },
...options.openQueue === void 0 ? {} : { openQueue: options.openQueue },
...options.shortcuts === void 0 ? {} : { shortcuts: options.shortcuts },
...options.settings === void 0 ? {} : { settings: options.settings },
...options.selectHistoryNavigationPreferences === void 0 ? {} : {
selectHistoryNavigationPreferences: options.selectHistoryNavigationPreferences
},
...options.selectHistoryPanelPreferences === void 0 ? {} : {
selectHistoryPanelPreferences: options.selectHistoryPanelPreferences
},
...options.selectBookmarkPreferences === void 0 ? {} : {
selectBookmarkPreferences: options.selectBookmarkPreferences
},
...options.selectTimelineViewPreferences === void 0 ? {} : {
selectTimelineViewPreferences: options.selectTimelineViewPreferences
},
...options.selectTranslationMode === void 0 ? {} : {
selectTranslationMode: options.selectTranslationMode
},
...options.persistTranslationMode === void 0 ? {} : {
persistTranslationMode: options.persistTranslationMode
},
onReady(runtime, context, settings, settingsView, layout, appearance, font) {
let targetAdapter = null, userObservationEntry = null, floatingHostTarget = null, hostSource = null, hostPreheat = null, readyCleanup;
try {
if (targetOptions) {
const routeChanges = createReaderUserscriptRouteChangePort(
options.environment.discourseHost
);
floatingHostTarget = new import_reader_floating_host_target_controller.ReaderFloatingHostTargetController({
document: options.runtime.document,
overlay: runtime.shell.view.root,
workspace: runtime.workspace.workspace,
window: runtime.workspace.window,
currentUrl: () => options.runtime.document.location.href,
target: {
openTarget: (request) => runtime.openTarget(request)
},
closeReader: () => runtime.close(),
readOpenTopicsAtFirstPost: () => targetOptions.selectOpenTopicsAtFirstPost?.(
context.readPreferences()
) === !0,
parentScope: runtime.scope,
...targetOptions.onError === void 0 ? {} : { onError: targetOptions.onError }
}), hostSource = new import_reader_host_topic_source_coordinator.ReaderHostTopicSourceCoordinator({
document: options.runtime.document,
host: options.environment.discourseHost,
readerRoot: runtime.shell.view.root,
isEmbedded: () => runtime.workspace.workspace.snapshot.presentation.embedded,
parentScope: runtime.scope
});
const readAuthScope = options.runtime.topic.authScope, confirmedReadPosts = /* @__PURE__ */ new Map(), rememberConfirmedReadPosts = (topicId, postNumbers) => {
let posts = confirmedReadPosts.get(topicId);
posts || (posts = /* @__PURE__ */ new Set(), confirmedReadPosts.set(topicId, posts));
for (const postNumber of postNumbers) posts.add(postNumber);
};
for (const confirmed of runtime.data.readCoordination.confirmedPosts(
readAuthScope
))
rememberConfirmedReadPosts(
confirmed.topicId,
[confirmed.postNumber]
);
const withPreheatTrace = async (topicId, source, signal, task) => {
const traceId = runtime.beginPreheatTrace(topicId, source);
try {
const result = await task(traceId);
return signal.throwIfAborted(), runtime.finishPreheatTrace(traceId), result;
} catch (cause) {
throw signal.aborted && runtime.pipeline.mark(traceId, "preheat-aborted"), runtime.finishPreheatTrace(
traceId,
signal.aborted ? "superseded" : "failed"
), cause;
}
};
hostPreheat = new import_reader_host_topic_preheat_controller.ReaderHostTopicPreheatController({
document: options.runtime.document,
mutations: runtime.workspace.mutations,
enabled: targetOptions.selectHostTopicPreheatEnabled?.(
context.readPreferences()
) === !0,
preheatPostCount: targetOptions.selectHostTopicPreheatPostCount?.(
context.readPreferences()
) ?? import_reader_preferences_schema.HOST_TOPIC_PREHEAT_POST_COUNT_DEFAULT,
activity: runtime.activity,
readMaxConcurrentPreheats: () => runtime.performance.preheatMaxConcurrent,
historyEntry: (topicId) => runtime.history.entry(topicId),
readConfirmedCount: (topicId) => confirmedReadPosts.get(topicId)?.size ?? 0,
readOpenTopicsAtFirstPost: () => targetOptions.selectOpenTopicsAtFirstPost?.(
context.readPreferences()
) === !0,
restorePreheat: async (topicId, postNumber, signal, maximumPostCount) => withPreheatTrace(
topicId,
"host-cache-restore",
signal,
async (traceId) => {
const active = runtime.shell.activeValue;
if (active?.services.session.topicId === topicId) {
signal.throwIfAborted(), active.services.session.applyPageSize(
runtime.performance.pageSize
);
const result = await active.services.session.restorePreheatEntry(
postNumber,
maximumPostCount
);
return runtime.pipeline.mark(traceId, "preheat-restored", {
detail: { complete: result?.complete === !0 }
}), result?.complete && runtime.pipeline.mark(traceId, "preheat-ready"), result;
}
const scope = runtime.scope.child(), abort = scope.abortController(
new DOMException("宿主 Topic 缓存恢复已释放", "AbortError"),
signal
), bundle = runtime.data.createTopicBundle({
topicId,
scope,
signal: abort.signal,
mount: () => () => {
}
}, {
...options.runtime.topic,
pageSize: runtime.performance.pageSize,
refreshCachedInBackground: !1,
host: options.environment.discourseHost,
nativeAjax: runtime.nativeAjax
});
try {
const result = await bundle.services.session.restorePreheatEntry(
postNumber,
maximumPostCount
);
return result?.complete && runtime.rememberPreheatedTopicSnapshot(
bundle.services.snapshots.snapshot(),
traceId
), runtime.pipeline.mark(traceId, "preheat-restored", {
detail: { complete: result?.complete === !0 }
}), result?.complete && runtime.pipeline.mark(traceId, "preheat-ready"), result;
} finally {
await bundle.prepareClose?.("close"), scope.destroy();
}
}
),
preheat: async (topicId, postNumber, signal, report, minimumTotalCount, maximumPostCount) => withPreheatTrace(
topicId,
"host-visible-preheat",
signal,
async (traceId) => {
const active = runtime.shell.activeValue;
if (active?.services.session.topicId === topicId) {
signal.throwIfAborted(), active.services.session.applyPageSize(
runtime.performance.pageSize
);
const result = await active.services.session.preheatEntry(
postNumber,
{
background: !0,
prefetchTier: "nearby",
maxAttempts: 1,
minimumTotalCount,
maximumPostCount,
onProgress: report,
beforeNetwork: (requestSignal) => {
signal.throwIfAborted(), requestSignal.throwIfAborted();
}
}
);
return signal.throwIfAborted(), await active.services.session.flush(), result.complete && runtime.pipeline.mark(traceId, "preheat-ready"), result;
}
const scope = runtime.scope.child(), abort = scope.abortController(
new DOMException("宿主 Topic 预热已释放", "AbortError"),
signal
), bundle = runtime.data.createTopicBundle({
topicId,
scope,
signal: abort.signal,
mount: () => () => {
}
}, {
...options.runtime.topic,
pageSize: runtime.performance.pageSize,
refreshCachedInBackground: !1,
host: options.environment.discourseHost,
nativeAjax: runtime.nativeAjax
});
try {
await bundle.services.session.init({
background: !0,
prefetchTier: "nearby"
});
const restoredMinimumTotalCount = bundle.services.session.initializedFromCache ? minimumTotalCount : 0, result = await bundle.services.session.preheatEntry(
postNumber,
{
background: !0,
prefetchTier: "nearby",
maxAttempts: 1,
minimumTotalCount: restoredMinimumTotalCount,
maximumPostCount,
onProgress: (progress) => {
progress.complete && runtime.rememberPreheatedTopicSnapshot(
bundle.services.snapshots.snapshot(),
traceId
), report(progress);
}
}
);
return result.complete && (runtime.rememberPreheatedTopicSnapshot(
bundle.services.snapshots.snapshot(),
traceId
), runtime.pipeline.mark(traceId, "preheat-ready")), await bundle.services.session.flush(), result;
} finally {
await bundle.prepareClose?.("close"), scope.destroy();
}
}
),
shouldPauseAfterError: (error) => runtime.data.client.requestResume(error) !== null,
canResume: async () => {
const snapshot = await runtime.permit.snapshot();
return snapshot.challengeState === "idle" && snapshot.nextPermitDelay <= 0;
},
forgetPreheat: (topicId) => {
runtime.forgetPreheatedTopicSnapshot(topicId);
},
parentScope: runtime.scope,
...targetOptions.onError === void 0 ? {} : { onError: targetOptions.onError }
}), context.preferenceChanges.subscribe((preferences) => {
hostPreheat?.applyPreheatPostCount(
targetOptions.selectHostTopicPreheatPostCount?.(
preferences
) ?? import_reader_preferences_schema.HOST_TOPIC_PREHEAT_POST_COUNT_DEFAULT
), hostPreheat?.applyEnabled(
targetOptions.selectHostTopicPreheatEnabled?.(preferences) !== !1
);
}, hostPreheat.scope), runtime.history.changes.subscribe(
() => hostPreheat?.refreshHistory(),
hostPreheat.scope
), hostPreheat.scope.add(
runtime.data.readCoordination.subscribeConfirmations(
(confirmation) => {
confirmation.authScope === readAuthScope && (rememberConfirmedReadPosts(
confirmation.topicId,
confirmation.postNumbers
), hostPreheat?.refreshConfirmedReadCount(
confirmation.topicId
));
}
)
);
let releaseActiveReadingProjection = () => {
};
const clearActiveReadingProjection = () => {
releaseActiveReadingProjection(), releaseActiveReadingProjection = () => {
}, hostPreheat?.clearLiveReading();
}, bindActiveReadingProjection = () => {
clearActiveReadingProjection();
const active = runtime.shell.activeValue;
if (!active) return;
const topicId = active.services.session.topicId, sync = (postNumber = active.topicTimeline.snapshot.currentPostNumber) => {
rememberConfirmedReadPosts(
topicId,
active.services.read.snapshot().confirmed
), hostPreheat?.updateLiveReading(
topicId,
postNumber,
confirmedReadPosts.get(topicId)?.size ?? 0
);
}, releaseTimeline = active.topicTimeline.changes.subscribe(
(snapshot) => sync(snapshot.currentPostNumber)
), releaseRead = active.services.read.changes.subscribe(
(change) => {
change.kind === "confirmed" && (rememberConfirmedReadPosts(topicId, change.postNumbers), sync());
}
);
releaseActiveReadingProjection = () => {
releaseTimeline(), releaseRead();
}, sync();
}, syncReaderPreheatState = (state) => {
hostPreheat?.setReaderForeground(
state === "opening" || state === "switching" || state === "running"
);
};
runtime.shell.changes.subscribe((state) => {
syncReaderPreheatState(state), state === "running" ? bindActiveReadingProjection() : (state === "switching" || state === "closed" || state === "failed") && clearActiveReadingProjection();
}, hostPreheat.scope), hostPreheat.scope.add(clearActiveReadingProjection), syncReaderPreheatState(runtime.shell.state), runtime.shell.state === "running" && bindActiveReadingProjection();
const interactivePreheatReleases = /* @__PURE__ */ new WeakMap();
targetAdapter = new import_reader_userscript_target_adapter.ReaderUserscriptTargetAdapter({
document: options.runtime.document,
currentUrl: () => options.runtime.document.location.href,
target: {
openTarget: (request) => runtime.openTarget(request),
openHistoricalTarget: async (request) => {
const entry = runtime.history.entry(request.topicId), anchor = entry?.viewport ? { viewport: entry.viewport } : runtime.historyNavigation.snapshot.states[String(request.topicId)] ?? null;
if (!anchor) return runtime.openTarget(request);
const exactFloorAnchor = Object.freeze({
viewport: Object.freeze({
postNumber: anchor.viewport.postNumber,
postOffset: anchor.viewport.postOffset,
scrollTop: anchor.viewport.scrollTop
}),
replyWindow: null,
quoteHighlight: null
}), opened = await runtime.openTarget({
topicId: request.topicId,
source: "restore"
});
if (opened.topic.status !== "opened" && opened.topic.status !== "reused") return opened;
await runtime.historyNavigation.restore(
request.topicId,
exactFloorAnchor,
{
highlight: !1,
restoreSemanticState: !0
}
);
const active = runtime.shell.activeValue;
active?.services.session.topicId === request.topicId && (await active.services.session.flush(), active.dom.flushNow());
const navigation = active?.services.session.topicId === request.topicId ? await active.topicNavigation.navigate({
postNumber: exactFloorAnchor.viewport.postNumber,
source: "history",
alignment: "center",
highlight: !1
}) : null;
if (navigation?.status === "revealed" && active) {
const releaseTimelineHold = active.topicTimeline.holdVisiblePost(
exactFloorAnchor.viewport.postNumber
), settlementTraceId = runtime.pipeline.begin({
kind: "topic-open",
topicId: request.topicId,
source: "history-anchor-settlement",
targetPostNumber: exactFloorAnchor.viewport.postNumber
});
try {
const settled = await active.dom.settleRevealedPost(
exactFloorAnchor.viewport.postNumber,
{
source: "history",
alignment: "center",
highlight: !1
},
{
tolerancePx: 2,
quietMs: 120,
maxWaitMs: 2e3
}
);
runtime.pipeline.mark(
settlementTraceId,
"anchor-settled",
{
durationMs: settled.durationMs,
detail: {
status: settled.status,
errorPx: settled.errorPx,
attempts: settled.attempts
}
}
);
} finally {
releaseTimelineHold(), runtime.pipeline.finish(settlementTraceId);
}
}
return Object.freeze({
topic: opened.topic,
navigation: Object.freeze({
status: navigation?.status ?? "superseded"
})
});
}
},
routeChanges,
serviceWorkerMessages: targetOptions.serviceWorkerMessages === void 0 ? options.runtime.document.defaultView?.navigator.serviceWorker ?? null : targetOptions.serviceWorkerMessages,
readHistoryPostNumber: (topicId) => {
const history = runtime.history.entry(topicId);
return history?.viewport?.postNumber ?? history?.postNumber ?? null;
},
readOpenTopicsAtFirstPost: () => targetOptions.selectOpenTopicsAtFirstPost?.(
context.readPreferences()
) === !0,
...targetOptions.openInitialRoute === void 0 ? {} : {
openInitialRoute: targetOptions.openInitialRoute
},
...targetOptions.interceptTopicLinks === void 0 ? {} : {
interceptTopicLinks: targetOptions.interceptTopicLinks
},
beforeOpenTarget: async (target) => {
const release = hostPreheat?.holdInteractiveOpen(
target.request.topicId
);
release && interactivePreheatReleases.set(target, release), await hostSource.prepare(target), await targetOptions.beforeOpenTarget?.(target);
},
afterOpenTarget: async (target, opened) => {
try {
runtime.shell.activeValue?.services.session.topicId === target.request.topicId && bindActiveReadingProjection(), await hostSource.settle(target, opened), syncReaderPreheatState(runtime.shell.state);
} finally {
interactivePreheatReleases.get(target)?.(), interactivePreheatReleases.delete(target);
}
},
parentScope: runtime.scope,
...targetOptions.onError === void 0 ? {} : { onError: targetOptions.onError }
}), userObservationEntry = new import_reader_userscript_target_adapter.ReaderUserscriptUserObservationEntry({
document: options.runtime.document,
currentUrl: () => options.runtime.document.location.href,
routeChanges,
hostMutations: {
subscribe: (handler) => runtime.workspace.mutations.subscribe(handler)
},
openObservation: (identity) => {
runtime.userObservationView.observeAndOpen(identity);
},
parentScope: runtime.scope,
...targetOptions.onError === void 0 ? {} : { onError: targetOptions.onError }
});
}
readyCleanup = onReady?.(
runtime,
context,
settings,
settingsView,
layout,
appearance,
font
) || void 0;
} catch (error) {
throw userObservationEntry?.destroy(), targetAdapter?.destroy(), floatingHostTarget?.destroy(), hostSource?.destroy(), hostPreheat?.destroy(), error;
}
return () => {
try {
readyCleanup?.();
} finally {
userObservationEntry?.destroy(), targetAdapter?.destroy(), floatingHostTarget?.destroy(), hostSource?.destroy(), hostPreheat?.destroy();
}
};
}
});
}
}, "5e619850e843b198db69a85d6116c3ab5a5d91db95a575090b1686499ae045e2");
/* Source: lite/src/userscript/reader-userscript-target-adapter.ts */
runtime.register("src/userscript/reader-userscript-target-adapter.js", function(module, exports, require) {
var reader_userscript_target_adapter_exports = {};
__export(reader_userscript_target_adapter_exports, {
ReaderUserscriptTargetAdapter: () => ReaderUserscriptTargetAdapter,
ReaderUserscriptUserObservationEntry: () => ReaderUserscriptUserObservationEntry,
createReaderUserscriptServiceWorkerMessageRelay: () => createReaderUserscriptServiceWorkerMessageRelay,
parseReaderUserscriptTopicRoute: () => parseReaderUserscriptTopicRoute,
parseReaderUserscriptUserRoute: () => parseReaderUserscriptUserRoute,
readerUserscriptRouteKind: () => readerUserscriptRouteKind
});
module.exports = __toCommonJS(reader_userscript_target_adapter_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_icon = require("../components/reader-icon.js"), import_reader_native_topic_route = require("../topic/reader-native-topic-route.js");
function createReaderUserscriptServiceWorkerMessageRelay(source, onError) {
if (!source) return null;
const listeners = /* @__PURE__ */ new Set();
let destroyed = !1;
const forward = (event) => {
for (const listener of [...listeners])
try {
listener.call(source, event);
} catch (error) {
try {
onError?.(error);
} catch {
}
}
};
try {
source.addEventListener("message", forward, !0);
} catch (error) {
try {
onError?.(error);
} catch {
}
return null;
}
return Object.freeze({
addEventListener(_type, listener) {
destroyed || listeners.add(listener);
},
removeEventListener(_type, listener) {
listeners.delete(listener);
},
destroy() {
destroyed || (destroyed = !0, listeners.clear(), source.removeEventListener("message", forward, !0));
}
});
}
const SOURCE_SELECTOR = "[data-reader-target-source],.ldp-notification-item", BYPASS_SELECTOR = '[data-reader-target-interception="off"],[data-reader-target-source="history"],.ldp-open,.ldp-history-link', HOST_TOPIC_CARD_SELECTOR = "tr.topic-list-item,.topic-list-item,.latest-topic-list-item", HOST_TOPIC_LINK_SELECTOR = 'a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]', HOST_TOPIC_CARD_CONTROL_SELECTOR = 'a[href],button,input,select,textarea,summary,[role="button"],[role="link"],[contenteditable="true"],[data-user-card],[data-ldp-native-dnd]', READER_OWNED_SELECTOR = '.ldp-overlay,.ldp-reader-portal-host,[data-ldp-owned="true"]', NATIVE_NOTIFICATION_TAB_IDS = /* @__PURE__ */ new Set([
"all-notifications",
"replies",
"likes",
"messages",
"other-notifications"
]), HOST_USER_OBSERVATION_ENTRY_SELECTOR = ".ldp-host-user-observation-entry", HOST_USER_PROFILE_NAME_SELECTORS = Object.freeze([
".user-main .user-profile-names > .user-profile-names__primary",
".user-main .user-profile-names > .full-name",
".user-main .primary-textual > .full-name",
".user-main .primary-textual > h1",
".user-main .user-profile-names > .username",
".user-main .primary-textual > .username"
]), HOST_USER_PROFILE_USERNAME_SELECTORS = Object.freeze([
".user-main .user-profile-names__secondary.username",
".user-main .user-profile-names > .username:not(.user-profile-names__primary)",
".user-main .primary-textual > .username"
]), HOST_USER_PROFILE_RETRY_DELAYS = Object.freeze([
50,
100,
250,
500,
1e3,
2e3
]);
function element(value) {
return value === null || typeof value != "object" || value.nodeType !== 1 || typeof value.matches != "function" ? null : value;
}
function eventAnchor(event) {
const path = typeof event.composedPath == "function" ? event.composedPath() : [];
for (const value of path) {
const candidate = element(value);
if (candidate?.matches("a[href]")) return candidate;
}
return element(event.target)?.closest("a[href]") ?? null;
}
function eventHostTopicCardAnchor(event) {
const target = element(event.target), card = target?.closest(HOST_TOPIC_CARD_SELECTOR) ?? null;
return !target || !card || card.closest(READER_OWNED_SELECTOR) || target.closest(HOST_TOPIC_CARD_CONTROL_SELECTOR) ? null : card.querySelector(HOST_TOPIC_LINK_SELECTOR);
}
function nativeHostNotificationSource(anchor) {
const menu = anchor.closest(".user-menu[data-tab-id]");
if (!menu || menu.closest(".ldp-overlay,.ldp-reader-portal-host")) return null;
const tabId = String(menu.dataset.tabId ?? "").trim();
return NATIVE_NOTIFICATION_TAB_IDS.has(tabId) ? tabId === "messages" ? "message" : "notification" : null;
}
function linkSource(marker) {
const explicit = marker?.getAttribute("data-reader-target-source");
return explicit === "link" || explicit === "message" || explicit === "notification" || explicit === "restore" ? explicit : marker?.classList.contains("ldp-notification-message-item") || marker?.getAttribute("data-notification-mode") === "messages" ? "message" : marker?.classList.contains("ldp-notification-item") ? "notification" : "link";
}
function markerRoute(marker, fallback) {
if (!marker) return fallback;
const topicId = (0, import_identifiers.tryDiscourseTopicId)(
marker.getAttribute("data-reader-topic-id") ?? marker.getAttribute("data-notification-topic-id")
);
if (!topicId) return fallback;
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(
marker.getAttribute("data-reader-post-number") ?? marker.getAttribute("data-notification-post-number")
) ?? (fallback?.topicId === topicId ? fallback.postNumber : null);
return Object.freeze({
topicId,
postNumber,
bypassReader: fallback?.bypassReader ?? !1,
href: fallback?.href ?? ""
});
}
function isPlainPrimaryClick(event) {
const pointer = event;
return event.type === "click" && !event.defaultPrevented && (pointer.button === void 0 || pointer.button === 0) && pointer.altKey !== !0 && pointer.ctrlKey !== !0 && pointer.metaKey !== !0 && pointer.shiftKey !== !0;
}
function truthyAttribute(node, name) {
const value = node?.getAttribute(name);
return value === "1" || value === "true";
}
function isSameOriginHttpTarget(value, baseValue) {
try {
const base = new URL(baseValue), url = new URL(value, base);
return /^https?:$/i.test(url.protocol) && url.origin === base.origin;
} catch {
return !1;
}
}
function parseReaderUserscriptTopicRoute(value, baseValue) {
let base, url;
try {
base = new URL(baseValue), url = new URL(value, base);
} catch {
return null;
}
if (!/^https?:$/i.test(url.protocol) || url.origin !== base.origin)
return null;
const segments = url.pathname.split("/").filter(Boolean);
if (segments[0] !== "t") return null;
const numericFirst = (0, import_identifiers.tryDiscourseTopicId)(segments[1]), topicId = numericFirst ?? (0, import_identifiers.tryDiscourseTopicId)(segments[2]);
if (!topicId) return null;
const postValue = segments[numericFirst ? 2 : 3], postNumber = postValue === void 0 ? null : (0, import_identifiers.tryDiscoursePostNumber)(postValue);
return postValue !== void 0 && !postNumber ? null : Object.freeze({
topicId,
postNumber,
bypassReader: url.searchParams.has(
import_reader_native_topic_route.READER_NATIVE_BYPASS_PARAMETER
),
href: url.href
});
}
function parseReaderUserscriptUserRoute(value, baseValue) {
let base, url;
try {
base = new URL(baseValue), url = new URL(value, base);
} catch {
return null;
}
if (!/^https?:$/i.test(url.protocol) || url.origin !== base.origin)
return null;
const segments = url.pathname.split("/").filter(Boolean);
if (segments[0] !== "u" || !segments[1]) return null;
try {
const username = decodeURIComponent(segments[1]).trim().replace(/^@+/, "");
return username ? Object.freeze({ username }) : null;
} catch {
return null;
}
}
function readerUserscriptRouteKind(value, baseValue) {
return parseReaderUserscriptTopicRoute(value, baseValue) ? "direct-topic" : "list";
}
function directText(element2) {
return element2 ? [...element2.childNodes].filter((node) => node.nodeType === 3).map((node) => String(node.textContent ?? "").trim()).filter(Boolean).join(" ").trim() : "";
}
function firstProfileElement(documentPort, selectors) {
for (const selector of selectors) {
const candidate = documentPort.querySelector(selector);
if (candidate) return candidate;
}
return null;
}
function userRouteFromUsername(value) {
const username = String(value ?? "").trim().replace(/^@+/, "");
return username && !/[\s/]/u.test(username) ? Object.freeze({ username }) : null;
}
function hostDocumentUserRoute(documentPort, currentUrl) {
const userMain = documentPort.querySelector(".user-main");
if (!userMain) return null;
const dataUsername = userMain.matches("[data-username]") ? userMain : userMain.querySelector("[data-username]"), dataRoute = userRouteFromUsername(
dataUsername?.getAttribute("data-username")
);
if (dataRoute) return dataRoute;
const username = firstProfileElement(
documentPort,
HOST_USER_PROFILE_USERNAME_SELECTORS
), textRoute = userRouteFromUsername(directText(username));
if (textRoute) return textRoute;
const profileLink = userMain.querySelector('a[href*="/u/"]'), baseUrl = currentUrl || documentPort.baseURI;
return profileLink && baseUrl ? parseReaderUserscriptUserRoute(profileLink.href, baseUrl) : null;
}
class ReaderUserscriptUserObservationEntry {
scope;
#options;
#retryTimers = /* @__PURE__ */ new Set();
#routeEpoch = 0;
constructor(options) {
this.#options = options, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const onClick = (event) => this.#handleClick(event);
if (options.document.addEventListener("click", onClick, !0), this.scope.add(() => {
options.document.removeEventListener("click", onClick, !0);
}), options.routeChanges)
try {
this.scope.add(options.routeChanges.subscribe(() => {
this.syncCurrentRoute();
}));
} catch (error) {
this.#report(error);
}
if (options.hostMutations)
try {
this.scope.add(options.hostMutations.subscribe(() => {
this.syncCurrentRoute();
}));
} catch (error) {
this.#report(error);
}
this.scope.add(() => {
this.#routeEpoch += 1, this.#cancelRetries(), this.#removeEntries();
}), this.syncCurrentRoute();
}
syncCurrentRoute() {
if (this.scope.destroyed) return !1;
const epoch = ++this.#routeEpoch;
this.#cancelRetries();
let currentUrl = "";
try {
currentUrl = String(this.#options.currentUrl()).trim();
} catch (error) {
this.#report(error);
}
const route = (currentUrl ? parseReaderUserscriptUserRoute(currentUrl, currentUrl) : null) ?? hostDocumentUserRoute(
this.#options.document,
currentUrl
);
if (!route)
return this.#removeEntries(), !1;
if (this.#mount(route)) return !0;
for (const delay of HOST_USER_PROFILE_RETRY_DELAYS) {
const timer = setTimeout(() => {
this.#retryTimers.delete(timer), !(this.scope.destroyed || epoch !== this.#routeEpoch) && this.#mount(route) && this.#cancelRetries();
}, delay);
this.#retryTimers.add(timer);
}
return !1;
}
destroy() {
this.scope.destroy();
}
#mount(route) {
const name = firstProfileElement(
this.#options.document,
HOST_USER_PROFILE_NAME_SELECTORS
);
if (!name) return !1;
if (name.querySelector(
HOST_USER_OBSERVATION_ENTRY_SELECTOR
)?.dataset.readerUserObservationUsername === route.username) return !0;
this.#removeEntries();
const secondary = name.closest(".user-profile-names")?.querySelector(
".user-profile-names__secondary"
) ?? null, primaryText = directText(name), secondaryText = directText(secondary), displayName = [primaryText, secondaryText].find((candidate) => candidate && candidate.replace(/^@+/, "").toLocaleLowerCase() !== route.username.toLocaleLowerCase()) ?? "", avatar = this.#options.document.querySelector(
".user-main .user-profile-avatar img,.user-main .avatar-wrapper img,.user-main img.avatar"
), button = this.#options.document.createElement("button");
button.type = "button", button.className = "ldp-host-user-observation-entry", button.dataset.readerUserObservationUsername = route.username, button.dataset.readerUserObservationName = displayName, button.dataset.readerUserObservationAvatar = avatar?.getAttribute("data-avatar-template") ?? avatar?.getAttribute("src") ?? "", button.setAttribute("aria-label", `用户观察:@${route.username}`), button.title = "用户观察", button.append((0, import_reader_icon.createReaderIcon)(this.#options.document, "activity"));
const label = this.#options.document.createElement("span");
return label.className = "ldp-host-user-observation-entry-label", label.textContent = "观察用户", button.append(label), directText(name) ? name.insertBefore(button, name.firstElementChild) : name.append(button), !0;
}
#handleClick(event) {
const target = element(event.target)?.closest(
HOST_USER_OBSERVATION_ENTRY_SELECTOR
) ?? null;
if (!target || target.disabled || this.scope.destroyed) return;
event.preventDefault(), event.stopPropagation(), event.stopImmediatePropagation();
const username = String(
target.dataset.readerUserObservationUsername ?? ""
).trim();
if (!username) return;
target.disabled = !0, target.setAttribute("aria-busy", "true");
const identity = Object.freeze({
username,
name: String(target.dataset.readerUserObservationName ?? "").trim(),
avatarTemplate: String(
target.dataset.readerUserObservationAvatar ?? ""
).trim()
});
Promise.resolve().then(() => this.#options.openObservation(identity)).catch((error) => {
this.#report(error);
}).finally(() => {
target.isConnected && (target.disabled = !1, target.removeAttribute("aria-busy"));
});
}
#removeEntries() {
for (const entry of this.#options.document.querySelectorAll(
HOST_USER_OBSERVATION_ENTRY_SELECTOR
)) entry.remove();
}
#cancelRetries() {
for (const timer of this.#retryTimers) clearTimeout(timer);
this.#retryTimers.clear();
}
#report(error) {
try {
this.#options.onError?.(error);
} catch {
}
}
}
class ReaderUserscriptTargetAdapter {
scope;
ready;
#options;
#onClick;
#routeEpoch = 0;
#targetEpoch = 0;
#lastRouteKey = "";
constructor(options) {
if (this.#options = options, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#onClick = (event) => {
this.#handleClick(event);
}, options.interceptTopicLinks !== !1 && (options.document.addEventListener("click", this.#onClick, !0), this.scope.add(() => {
options.document.removeEventListener("click", this.#onClick, !0);
})), options.routeChanges)
try {
this.scope.add(options.routeChanges.subscribe(() => {
this.syncCurrentRoute();
}));
} catch (error) {
this.#report(error);
}
if (options.serviceWorkerMessages) {
const listener = (event) => {
this.#handleServiceWorkerMessage(event);
};
options.serviceWorkerMessages.addEventListener(
"message",
listener,
!0
), this.scope.add(() => {
options.serviceWorkerMessages?.removeEventListener(
"message",
listener,
!0
);
});
}
this.scope.add(() => {
this.#routeEpoch += 1, this.#targetEpoch += 1, this.#lastRouteKey = "";
}), options.openInitialRoute === !1 ? (this.#rememberCurrentRoute(), this.ready = Promise.resolve(!1)) : this.ready = this.syncCurrentRoute({ force: !0 });
}
async syncCurrentRoute(options = {}) {
if (this.scope.destroyed) return !1;
const currentUrl = this.#currentUrl();
if (!currentUrl) return !1;
const route = parseReaderUserscriptTopicRoute(
currentUrl,
currentUrl
);
if (!route || route.bypassReader)
return this.#lastRouteKey = "", !1;
const ordinaryTarget = this.#ordinaryTarget(route), targetPostNumber = ordinaryTarget.postNumber, routeKey = `${route.topicId}:${targetPostNumber ?? 0}`;
if (options.force !== !0 && routeKey === this.#lastRouteKey)
return !1;
this.#lastRouteKey = routeKey;
const epoch = ++this.#routeEpoch, targetEpoch = ++this.#targetEpoch, opened = await this.#open({
topicId: route.topicId,
...targetPostNumber === null ? {} : { postNumber: targetPostNumber },
...ordinaryTarget.fromHistory ? { alignment: "start" } : {},
source: "restore"
}, ordinaryTarget.fromHistory);
return epoch !== this.#routeEpoch || targetEpoch !== this.#targetEpoch || this.scope.destroyed ? !1 : (opened || (this.#lastRouteKey = ""), opened);
}
destroy() {
this.scope.destroy();
}
#handleClick(event) {
if (this.scope.destroyed || !isPlainPrimaryClick(event)) return;
const anchor = eventAnchor(event) ?? eventHostTopicCardAnchor(event);
if (!anchor || anchor.closest(BYPASS_SELECTOR) || anchor.hasAttribute("download") || anchor.getAttribute("aria-disabled") === "true" || anchor.closest(".search-menu") && !anchor.closest(
".search-result-topic,.search-result-post," + SOURCE_SELECTOR
))
return;
const target = String(anchor.getAttribute("target") ?? "").toLowerCase();
if (target && target !== "_self") return;
const linkTarget = this.#linkTarget(anchor);
if (!linkTarget || linkTarget.route.bypassReader) return;
const nativeNotificationSource = nativeHostNotificationSource(anchor);
event.preventDefault(), event.stopPropagation(), event.stopImmediatePropagation();
const source = nativeNotificationSource ?? linkTarget.source, ordinaryTarget = source === "link" && !linkTarget.preservePostNumber ? this.#ordinaryTarget(linkTarget.route) : null, postNumber = ordinaryTarget?.postNumber ?? linkTarget.route.postNumber, request = {
topicId: linkTarget.route.topicId,
...postNumber === null ? {} : { postNumber },
...ordinaryTarget?.fromHistory === !0 ? { alignment: "start" } : {},
source
};
this.#openIntercepted({
request,
historical: ordinaryTarget?.fromHistory === !0,
anchor,
sourceElement: linkTarget.sourceElement,
pointer: Number.isFinite(event.clientY) ? Object.freeze({
clientY: event.clientY,
detail: Number(event.detail) || 0
}) : null
});
}
#handleServiceWorkerMessage(event) {
if (this.scope.destroyed) return;
const data = event.data;
if (data === null || typeof data != "object") return;
const targetUrl = String(
data.url ?? ""
).trim();
if (!targetUrl) return;
const currentUrl = this.#currentUrl();
if (!currentUrl) return;
const route = parseReaderUserscriptTopicRoute(targetUrl, currentUrl);
!route || route.bypassReader || (event.preventDefault(), event.stopPropagation(), event.stopImmediatePropagation(), this.#targetEpoch += 1, this.#open({
topicId: route.topicId,
...route.postNumber === null ? {} : { postNumber: route.postNumber },
source: "notification"
}));
}
#linkTarget(anchor) {
const currentUrl = this.#currentUrl();
if (!currentUrl) return null;
const href = anchor.getAttribute("href") ?? "";
if (!isSameOriginHttpTarget(href, currentUrl)) return null;
const fallback = parseReaderUserscriptTopicRoute(href, currentUrl), marker = anchor.closest(SOURCE_SELECTOR), route = markerRoute(marker, fallback);
if (!route) return null;
const source = linkSource(marker), preservePostNumber = source !== "link" || truthyAttribute(anchor, "data-reader-preserve-target") || truthyAttribute(marker, "data-reader-preserve-target") || truthyAttribute(anchor, "data-ldp-preserve-target-post") || truthyAttribute(marker, "data-ldp-preserve-target-post");
return Object.freeze({
route,
source,
preservePostNumber,
sourceElement: marker
});
}
#ordinaryPostNumber(route) {
return this.#ordinaryTarget(route).postNumber;
}
#ordinaryTarget(route) {
try {
const historical = this.#options.readHistoryPostNumber?.(route.topicId) ?? null;
return Object.freeze(historical !== null ? {
postNumber: historical,
fromHistory: !0
} : {
postNumber: this.#options.readOpenTopicsAtFirstPost?.() === !0 ? (0, import_identifiers.tryDiscoursePostNumber)(1) : route.postNumber,
fromHistory: !1
});
} catch (error) {
return this.#report(error), Object.freeze({
postNumber: route.postNumber,
fromHistory: !1
});
}
}
#rememberCurrentRoute() {
const currentUrl = this.#currentUrl();
if (!currentUrl) return;
const route = parseReaderUserscriptTopicRoute(currentUrl, currentUrl);
!route || route.bypassReader || (this.#lastRouteKey = `${route.topicId}:${this.#ordinaryPostNumber(route) ?? 0}`);
}
#currentUrl() {
try {
const value = String(this.#options.currentUrl()).trim();
if (!value) throw new Error("当前页面 URL 为空");
return value;
} catch (error) {
return this.#report(error), null;
}
}
#report(error) {
try {
this.#options.onError?.(error);
} catch {
}
}
async #openIntercepted(target) {
const epoch = ++this.#targetEpoch;
if (this.#options.beforeOpenTarget)
try {
await this.#options.beforeOpenTarget(target);
} catch (error) {
this.#report(error);
}
if (this.scope.destroyed || epoch !== this.#targetEpoch)
return await this.#settleIntercepted(target, !1), !1;
const opened = await this.#open(target.request, target.historical);
return this.scope.destroyed || epoch !== this.#targetEpoch ? (await this.#settleIntercepted(target, !1), !1) : (await this.#settleIntercepted(target, opened), opened);
}
async #settleIntercepted(target, opened) {
if (this.#options.afterOpenTarget)
try {
await this.#options.afterOpenTarget(target, opened);
} catch (error) {
this.#report(error);
}
}
async #open(request, historical = !1) {
try {
const result = historical && this.#options.target.openHistoricalTarget ? await this.#options.target.openHistoricalTarget(request) : await this.#options.target.openTarget(request);
if (result.topic.status === "opened" || result.topic.status === "reused")
return request.postNumber === void 0 || result.navigation?.status === "revealed";
if (result.topic.status === "failed")
throw result.topic.cause ?? new Error(`Reader 目标 Topic ${request.topicId} 打开失败`);
return !1;
} catch (error) {
return this.#report(error), !1;
}
}
}
}, "d0cfa770d3ccfb08d6cfadbd170481a856e6e17ee4452ad5ad98d70aeedddfce");
runtime.markLibrary("main-lite-core");
})();