Core runtime modules for Awesome LinuxDo Reader Lite.
Цей скрипт не слід встановлювати безпосередньо. Це - бібліотека для інших скриптів для включення в мета директиву // @require https://update.greasyfork.org/scripts/590254/1904487/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.5.2
// @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.5.2 - 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.5.2",
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.5.2") {
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_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_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_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_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_self_observation_projection = require("../user/reader-self-observation-projection.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_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_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_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_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]";
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 !== 13)
throw new Error("消息面板必须提供 2 个模式与完整 13 个分类锚点");
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") {
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 ?? "").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;
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();
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.#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.5,
streamMaxMountedPostCount: 80,
nestedPrefetchScreens: 2.5,
requestMaxConcurrent: options.data.scheduler.maxConcurrent,
requestMinIntervalMs: options.permit.minIntervalMs,
requestRateTargetPercent: 85,
requestShortBudget: options.permit.shortBudget,
requestLongBudget: options.permit.longBudget
}), 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(),
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,
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
}), 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;
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 } : {},
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: 404,
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,
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
})]),
session: this.users,
userHref: (username) => this.userNative.requestIdentity(username),
avatarSource: (template, size) => this.userNative.avatarSource(template, size),
recoverAvatarSource: (source) => this.#recoverAvatarSource(source),
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,
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 = options.host.lookup("service:current-user"), retrySelfPrivateSources = () => {
this.notificationController?.startBackgroundCache(), this.notificationController?.retryBackgroundCache(), this.bookmarkController?.startBackgroundCache(), this.bookmarkController?.retryBackgroundCache();
};
this.userObservations.observeSelf({
username: selfObservationUsername,
name: String(readerNativeModelValue(currentUser, "name") ?? "").trim(),
avatarTemplate: String(
readerNativeModelValue(currentUser, "avatar_template") ?? ""
).trim()
}, retrySelfPrivateSources);
const publishSelfObservation = () => {
this.userObservations.updateSelfObservation(
(0, import_reader_self_observation_projection.readerSelfObservationProjection)({
...this.notificationController ? {
notifications: {
snapshot: this.notificationController.snapshot,
records: this.notificationController.syncHistoryRecords()
}
} : {},
...this.bookmarkController ? {
collections: {
snapshot: this.bookmarkController.snapshot,
records: this.bookmarkController.observationRecords()
}
} : {}
})
);
};
this.notificationController?.changes.subscribe(
publishSelfObservation,
this.scope
), this.bookmarkController?.changes.subscribe(
publishSelfObservation,
this.scope
), publishSelfObservation();
}
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) => this.#recoverAvatarSource(source)
})
), 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,
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,
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,
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,
parentScope: context.scope
}) : 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
)
),
...options.topicFlowScheduler === void 0 ? {} : {
directReplyPrefetchScheduler: {
schedule: (callback, delayMs) => options.topicFlowScheduler.schedule(
callback,
"near-window",
delayMs
),
cancel: (handle) => options.topicFlowScheduler.cancel(
handle
)
}
}
});
},
createBundle: (context) => this.data.createTopicBundle(
context,
{
...options.topic,
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);
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) => {
translationWindowPostNumbers = /* @__PURE__ */ new Set([
...commit.tree.mountedRoots,
...commit.tree.mountedReplies
]), updateTranslationWindow(), this.translationFeature?.syncMountedPosts();
}, context.scope), value.services.session.changes.subscribe((commit) => {
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.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);
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),
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 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(),
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,
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.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,
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);
},
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;
}
reloadExternalTopicSummaryState() {
for (const surface of this.#topicSummarySurfaces)
surface.reloadExternalState();
}
applyPerformance(snapshot) {
if (this.#destroyed || this.scope.destroyed) return;
this.#performance = snapshot, 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), 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 = () => 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.#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 (!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 });
} finally {
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) {
return this.imageResources?.resolveAvatarSource(source) ?? "";
}
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;
this.permit.applyRuntimePolicy({
shortBudget: snapshot.requestShortBudget,
longBudget: snapshot.requestLongBudget,
minIntervalMs: snapshot.requestMinIntervalMs,
maxConcurrent: snapshot.requestMaxConcurrent
}), this.data.applyRequestRuntimePolicy({
maxConcurrent: snapshot.requestMaxConcurrent
});
}
#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) {
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;
if (event.phase !== "finished" || event.status !== 404 || !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);
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) => this.chronicle.snapshot.records.some(
(record) => record.kind === kind && Number(record.topicId) === topicId && (kind === "topic" || Number(record.postNumber) === postNumber)
), remember = (kind, postNumber, confirmedAt) => {
if (alreadyRemembered(kind, postNumber)) 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: 404,
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?.status === 404 && remember("topic", null, archive.topic.confirmedAt);
for (const entry of archive.posts)
entry.status === 404 && remember("reply", Number(entry.postNumber), entry.confirmedAt);
}
#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) {
if (event.id <= requestFloor || event.phase !== "finished" || event.status !== 404 || !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: 404,
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) {
let targetKind = target.kind, topicId = target.topicId, postNumber = target.postNumber, postId = target.postId;
const boostId = target.boostId;
let topicTitle = "";
const active = 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: 404,
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
), 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 } : {}
}),
...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 refreshTopicButton = shell.view.root.querySelector(
".ldp-reader-refresh"
), closeReaderButton = shell.view.root.querySelector(
".ldp-close"
), layoutToggleButton = shell.view.root.querySelector(
".ldp-layout-toggle"
);
if (!refreshTopicButton || !closeReaderButton || !layoutToggleButton)
throw runtime.destroy(), new Error("Reader Shell 缺少布局、刷新或关闭入口");
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",
...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"
);
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,
...queryLocalFonts ? { queryLocalFonts } : {},
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
}) : 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
}, 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,
detail: `内存热缓存:${bookmarks2.bookmarks} 条收藏 · ${bookmarks2.reactions} 条回应 · ${bookmarks2.boosts} 条 Boost · ${bookmarks2.replies} 条回复`
}),
assets: Object.freeze({
records: imageObjects.objectUrls,
detail: `内存对象 URL:${imageObjects.objectUrls} / ${imageObjects.objectUrlLimit}`
})
})
});
},
clear: async (categories) => {
const selected = new Set(categories), failed = [];
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?.clearCache();
} catch (cause) {
failed.push("notifications"), reportCacheError(cause);
}
if (selected.has("responses"))
try {
runtime.bookmarkController?.clearCache();
} 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) });
}
},
clearImageObjectUrls: () => runtime.imageResources?.clearObjectUrls(),
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,
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,
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)) 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)) 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) ?? "",
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 waitForQueuePrefetchRequestHeadroom(abort.signal), await session.loadPostsByIds(
batch,
{ background: !0, maxAttempts: 1 }
);
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,
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,
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,
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,
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.history.reloadExternal()
}), registerInformationFlow({
domain: "chronicle",
storageKeys: [runtime.chronicle.storageKey],
refresh: () => runtime.chronicle.reloadExternal()
}), registerInformationFlow({
domain: "unwanted-topics",
storageKeys: [runtime.unwantedTopics.storageKey],
refresh: () => runtime.unwantedTopics.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();
}
};
}
});
}
}, "5f7683ee9507a3ded426b2f95b7e829e20b64b967294eb2f6fcf26ab4b3273a5");
/* 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_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_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 = "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;
client;
responses;
gateway;
cacheCoordination;
readCoordination;
#destroyed = !1;
constructor(options) {
const id = sourceId(options.sourceId), report = (phase, 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,
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)
});
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,
...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 },
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);
} 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);
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
}
}, "f9a9d054dc7203aa759bf367dfe44a0063643f22727925396089efc5825942b4");
/* 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);
const DEFAULTS = Object.freeze({
pageSize: 48,
streamOverscanScreens: 1.5,
streamMaxMountedPostCount: 80,
nestedPrefetchScreens: 2.5,
requestMaxConcurrent: 3,
requestMinIntervalMs: 100,
requestRateTargetPercent: 85
}), BULK_BACKGROUND_REQUEST_BUDGET_SHARE = 0.5, QUEUE_PREFETCH_REQUEST_BUDGET_SHARE = 0.25, QUEUE_PREFETCH_SHORT_REQUEST_LIMIT = 4, QUEUE_PREFETCH_LONG_REQUEST_LIMIT = 8;
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) {
const shortLimit = Math.max(
1,
Math.min(
Math.floor(
positiveInteger(input.shortBudget, "shortBudget") * BULK_BACKGROUND_REQUEST_BUDGET_SHARE
),
nestedReplies ? 8 : Number.MAX_SAFE_INTEGER
)
), longLimit = Math.max(
1,
Math.min(
Math.floor(
positiveInteger(input.longBudget, "longBudget") * BULK_BACKGROUND_REQUEST_BUDGET_SHARE
),
nestedReplies ? 24 : 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) {
const shortLimit = Math.max(
1,
Math.min(
Math.floor(
positiveInteger(input.shortBudget, "shortBudget") * QUEUE_PREFETCH_REQUEST_BUDGET_SHARE
),
QUEUE_PREFETCH_SHORT_REQUEST_LIMIT
)
), longLimit = Math.max(
1,
Math.min(
Math.floor(
positiveInteger(input.longBudget, "longBudget") * QUEUE_PREFETCH_REQUEST_BUDGET_SHARE
),
QUEUE_PREFETCH_LONG_REQUEST_LIMIT
)
), 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
), target = requestRateTargetPercent / 100;
return Object.freeze({
pageSize: integerRange(
Math.floor(pageSize * batchScale),
pageSize,
12,
64
),
streamOverscanScreens: finiteRange(
preferences.performanceStreamOverscan,
DEFAULTS.streamOverscanScreens,
0.25,
3
),
streamMaxMountedPostCount: integerRange(
Math.floor(streamMaxMountedPostCount * scales.render),
streamMaxMountedPostCount,
24,
128
),
nestedPrefetchScreens: finiteRange(
nestedPrefetchScreens * scales.network,
nestedPrefetchScreens,
1,
3
),
requestMaxConcurrent: integerRange(
Math.round(requestMaxConcurrent * batchScale),
requestMaxConcurrent,
1,
4
),
requestMinIntervalMs: integerRange(
Math.ceil(requestMinIntervalMs / Math.max(0.35, batchScale)),
requestMinIntervalMs,
80,
500
),
requestRateTargetPercent,
requestShortBudget: Math.max(
1,
Math.floor(shortBudgetCeiling * target)
),
requestLongBudget: Math.max(
1,
Math.floor(longBudgetCeiling * target)
)
});
}
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]) => this.#snapshot[key] === value
) ? this.#snapshot : (this.#snapshot = next, next);
}
}
}, "f4736053ffeb43148dd948d0c45db5e91fc3b35141e277b994abe0bf2b64f814");
/* 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 TOOLTIP_CONTROL_SELECTOR = [
"button",
"a",
'[role="button"]',
"[data-ldp-tooltip-label]",
".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;
#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 || !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.element.hidden = !0, this.element.textContent = "", this.element.classList.remove(
"ldp-reader-history-tooltip",
"ldp-connect-help-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);
}, !0), this.scope.listen(root, "pointerover", (event) => {
const pointer = event, 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) => {
(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);
}), this.scope.listen(root, "pointerout", (event) => {
const pointer = event, active = this.#activeControl;
!active || 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));
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'
), 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 && !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 : 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) {
match.control.removeAttribute("title"), this.#activeControl = match.control, 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.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;
try {
return control.matches(":hover") || control.matches(":focus-visible");
} catch {
return !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);
}
}
}, "8c6538bb3b9ca78569693905b692efac4491c47d8b3a091d5629adde434613bb");
/* 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"/>',
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"/>',
"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"/>',
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"/>',
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);
}
}, "44c88f76d9ed017c46ff4443d6d9668e460663d76eef84e2f284ca9d736d825b");
/* 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 });
}
function installReaderImageSourceFallback(image, sources, createFallback, recoverSource, visibleSource) {
const candidates = [...new Set(
sources.map((source) => String(source).trim()).filter(Boolean)
)];
let directIndex = 0, recoveryIndex = 0, fallback = null, recovering = !1;
const showFallback = () => {
if (fallback?.parentNode) return fallback;
const next = createFallback();
return image.parentNode && image.replaceWith(next), fallback = next, next.parentNode ? next : null;
}, recoverNext = async () => {
if (!recovering) {
for (recovering = !0; recoverSource && recoveryIndex < candidates.length; ) {
const candidate = candidates[recoveryIndex];
recoveryIndex += 1;
try {
const recovered = String(await recoverSource(candidate)).trim();
if (!fallback?.parentNode && !image.parentNode) return;
if (!recovered) continue;
fallback?.parentNode && (fallback.replaceWith(image), fallback = null), recovering = !1, image.src = recovered;
return;
} catch {
}
}
recovering = !1, fallback?.parentNode && image.removeEventListener("error", advance);
}
};
function advance() {
if (recoverSource) {
if (!showFallback()) return;
recoverNext();
return;
}
const direct = candidates[directIndex];
if (directIndex += 1, direct) {
image.src = direct;
return;
}
image.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;
}
}, "4da4b336ae2253b31010cb90068ad1856c50c2996fdea5cbefdb34781e12ee37");
/* 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;
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 = new Map(this.#parentByPost), clone.#childrenByParent = new Map(
[...this.#childrenByParent].map(([postNumber, children]) => [
postNumber,
new Set(children)
])
), clone.#subtreePostCountByPost = new Map(
this.#subtreePostCountByPost
), clone.#rootPostNumbers = new Set(this.#rootPostNumbers), clone.#sortedChildrenByParent = new Map(this.#sortedChildrenByParent), clone.#depthByPost = new Map(this.#depthByPost), clone.#rootByPost = new Map(this.#rootByPost), clone.#postNumbersCache = this.#postNumbersCache, clone.#rootsCache = this.#rootsCache, clone.#rootBranchesCache = this.#rootBranchesCache, clone.#snapshotCache = this.#snapshotCache, 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([])
});
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), 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.clear(), 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(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);
}
}
#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);
}
}
}
}, "cddb81ad8170f21ba3e8274c9e7c28173b0f74f8a7084202bb52d62bf8fd63d9");
/* 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;
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;
}
}
}, "0f86bfc921f880ef5e0c6c76f729d5161c57bf44a2700b4415cfbcd675b43d60");
/* 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-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 || !Number.isFinite(this.#pointerX) || !Number.isFinite(this.#pointerY))
return;
if (this.#safeScrollTop() <= TOP_EDGE) {
this.#hide();
return;
}
if (this.#button.hidden) {
const bounds = this.#hostBounds(), left = Math.max(
bounds.left + TOP_EDGE,
Math.min(
bounds.right - BUTTON_SIZE - TOP_EDGE,
this.#pointerX + POINTER_GAP
)
), top = Math.max(
TOP_EDGE,
Math.min(
this.#readViewportHeight() - BUTTON_SIZE - TOP_EDGE,
this.#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);
}));
}
}
}, "e5f43df781c7a5f30f18d8048664857a0badfe6b1f7a8e1121dbfcb17dd5c62f");
/* 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_value_record = require("../kernel/value-record.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/"]', NEW_TOPIC_BADGE_SELECTOR = ".topic-post-badges,.badge-notification.new-topic", AUTOMATIC_FILTER_ATTRIBUTE = "data-ldp-unwanted-auto-filter", OPENED_TOPIC_STORAGE_KEY = "linuxdo-enhanced-reader:opened-host-topics:v1", MAX_OPENED_TOPIC_IDS = 2048;
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;
#automaticFilter;
#notify;
#onError;
#openedTopicStorage;
#openedTopicStorageKey;
#roots = /* @__PURE__ */ new Set();
#openedTopicIds = /* @__PURE__ */ new Set();
#rootClickHandlers = /* @__PURE__ */ new Map();
#pendingCards = /* @__PURE__ */ new WeakSet();
#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.#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.#restoreOpenedTopicIds();
}
syncRoot(root, mode = "embedded") {
if (this.#roots.add(root), !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));
}
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") {
const topicModels = this.#topicModels(), reactions = this.#reactionCounts(topicModels);
for (const card of cards) {
if (!card.matches(CARD_SELECTOR) || card.closest(".ldp-overlay")) continue;
const topic = this.#topicInput(card, topicModels), projection = this.#topicProjection(card, topic);
if (this.#isTopicHidden(projection.topicId)) {
card.remove();
continue;
}
const automatic = this.#automaticFilter(projection);
card.toggleAttribute(AUTOMATIC_FILTER_ATTRIBUTE, !!automatic), !automatic && (this.#markNewTopic(card, topic), this.#groupTitleTools(card, topic), 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();
}
get openedTopicStorageKey() {
return this.#openedTopicStorageKey;
}
reloadExternalOpenedTopics() {
this.#openedTopicIds.clear(), this.#restoreOpenedTopicIds();
const topicModels = this.#topicModels();
for (const card of this.#document.querySelectorAll(
CARD_SELECTOR
))
this.#markNewTopic(card, this.#topicInput(card, topicModels));
}
markTopicOpened(topicId) {
if (!Number.isSafeInteger(topicId) || topicId < 1) return !1;
this.#restoreOpenedTopicIds(), this.#openedTopicIds.has(topicId) || (this.#openedTopicIds.add(topicId), this.#persistOpenedTopicIds());
let changed = !1;
for (const card of this.#document.querySelectorAll(
CARD_SELECTOR
)) {
if (Number(this.#cardTopicId(card)) !== topicId) continue;
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) {
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(
"[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}]`
)) node.removeAttribute(AUTOMATIC_FILTER_ATTRIBUTE);
}
#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));
!hostNew && Number.isSafeInteger(topicId) && this.#openedTopicIds.delete(topicId) && this.#persistOpenedTopicIds();
const 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)
);
}
#restoreOpenedTopicIds() {
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);
if (!Array.isArray(parsed)) throw new TypeError("本机已打开 Topic 记录格式无效");
for (const value of parsed.slice(-MAX_OPENED_TOPIC_IDS)) {
const topicId = Number(value);
Number.isSafeInteger(topicId) && topicId > 0 && this.#openedTopicIds.add(topicId);
}
} catch (cause) {
this.#onError(cause);
try {
this.#openedTopicStorage.removeItem(this.#openedTopicStorageKey);
} catch (removeCause) {
this.#onError(removeCause);
}
}
}
#persistOpenedTopicIds() {
if (this.#openedTopicStorage) {
for (; this.#openedTopicIds.size > MAX_OPENED_TOPIC_IDS; ) {
const oldest = this.#openedTopicIds.values().next().value;
if (oldest === void 0) break;
this.#openedTopicIds.delete(oldest);
}
try {
if (!this.#openedTopicIds.size) {
this.#openedTopicStorage.removeItem(this.#openedTopicStorageKey);
return;
}
this.#openedTopicStorage.setItem(
this.#openedTopicStorageKey,
JSON.stringify([...this.#openedTopicIds])
);
} catch (cause) {
this.#onError(cause);
}
}
}
#groupTitleTools(card, topic) {
const line = card.querySelector(".link-top-line");
if (!line) return;
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));
}
#createDndButton(line, _topic) {
const button = this.#document.createElement("button");
return button.type = "button", button.dataset.ldpOwnedNativeDnd = "true", button.dataset.ldpNativeDnd = "true", button.setAttribute("aria-label", "免打扰:加入不想看"), 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);
}
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.remove(), 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] ?? "";
}
}
}, "be577733df15f21236d0dbf9407e88843aa465f5073454044872059f5e405990");
/* 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-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-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(",");
function visible(document, element) {
if (!element.isConnected || element.hidden || element.getAttribute("aria-hidden") === "true") return !1;
const viewport = document.defaultView;
if (!viewport?.getComputedStyle) return !0;
const style = viewport.getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden";
}
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-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));
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;
}
}, "2c7a02acf9e893a104eef25e40506f26cad42004754ff606c5be4c7e3e345dfc");
/* 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,
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, 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") || 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() : 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());
}
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(), this.#pinned ? this.#closeMenus() : this.#dismiss(), !0);
}
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(), 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.#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) {
!this.#sharedGeometry && this.#active && (this.#sharedGeometry = this.#active.geometry.snapshot.geometry), this.#active && this.#sharedGeometry && 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 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;
pinButton;
closeButton;
tabId;
tabOrder;
geometry;
pointer;
#onClose;
#baseZIndex;
#geometryStorage;
#geometryStorageKey;
#requestOpen;
#tabAction;
#notify;
#tabGroup;
#launcherSelector;
#tabLabel;
#iconName;
#open = !1;
#active = !1;
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.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.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), 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,
compactWidth: 0,
defaultViewportWidth: 0.8,
defaultViewportHeight: 0.82,
...options.policy
}
}), 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) => {
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.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.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 }), view && this.scope.listen(view, "resize", () => {
const next = viewport(options.document, options.mount);
this.geometry.resizeViewport(next.width, next.height);
}), 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;
}
setIcon(name) {
this.#iconName = name, this.#tabGroup?.refresh();
}
setTitle(value) {
this.title.textContent = value, this.#tabGroup?.refresh();
}
setMinimumWidth(width) {
this.geometry.setMinimumWidth(width);
}
applySharedGeometry(geometry) {
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) {
!this.#active || !this.#tabGroup || (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;
}
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 ? "取消锁定置顶" : "锁定置顶,点击外部保持显示";
this.pinButton.setAttribute("aria-label", pinLabel), this.pinButton.title = pinLabel;
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;
}
}
}, "92d43d8123dccb8ec67a08ce31a2bec9ff664c3c83f58b00352388478109dea5");
/* 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;
#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.#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();
}), 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.#elements.detail.textContent = "关键 Reader 请求遇到 Cloudflare 验证,已暂停后续请求;Reader 会先自动探测,确有需要时只打开一个过盾页。若浏览器拦截,请点击右侧按钮。", this.#show();
return;
}
if (snapshot.challengeState === "active") {
this.#elements.detail.textContent = snapshot.challengeOwned ? "本页过盾浮窗已打开;若未显示,点击右侧按钮唤起。验证完成后请求自动恢复。" : "其他标签页已有唯一过盾浮窗;点击右侧按钮可唤起。验证完成后请求自动恢复。", 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;
}
#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);
}
}
}, "392d0bce3a596eb5e2e190f56ba98ac4c7628a402e761234d01dd73cc7f0cfd3");
/* 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_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_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.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 wrapper = this.#states.get(select), menu = wrapper?.querySelector(".ldp-select-menu");
if (!wrapper || !menu) return;
const options = this.#document.createElement("span");
if (options.className = "ldp-select-options", options.setAttribute("role", "listbox"), options.setAttribute(
"aria-label",
select.getAttribute("aria-label") || "下拉选项"
), options.replaceChildren(...[...select.options].map((nativeOption) => {
const option = this.#document.createElement("button");
return option.type = "button", option.className = "ldp-select-option ldp-picker-option", option.dataset.readerSelectValue = nativeOption.value, option.textContent = nativeOption.label || nativeOption.textContent || "", option.disabled = nativeOption.disabled, option.hidden = nativeOption.hidden, option.setAttribute("role", "option"), option.setAttribute("aria-selected", String(nativeOption.selected)), option;
})), 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 = !query || (option.textContent ?? "").toLocaleLowerCase().includes(query);
option.hidden = !matches, matches && (visible += 1);
}
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("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 spaceBelow = Math.max(
0,
bounds.bottom - selectRect.bottom - gap
), spaceAbove = Math.max(
0,
selectRect.top - bounds.top - gap
), menuAbove = menuHeight > spaceBelow && spaceAbove > spaceBelow;
wrapper.classList.toggle("is-menu-above", menuAbove);
const availableHeight = menuAbove ? spaceAbove : spaceBelow;
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("max-height"), menu.style.removeProperty("max-width")), restoreFocus && select.focus({ preventScroll: !0 });
}
}
}, "a316312b868e84deede49135f4c40fc6af6b415272cf9ab806c313e391ee3067");
/* 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"), 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"), 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, 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,
"span",
"ldp-notification-unread-status"
);
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"
);
refreshTopic.disabled = !0;
const closeReader = button(
options,
"ldp-close ldp-icon-btn",
"关闭阅读器",
"x"
), 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", () => {
setHeaderActionsExpanded(!0);
}), titleActions.addEventListener("pointerleave", () => {
setHeaderActionsExpanded(!1);
}), titleActions.addEventListener("focusin", () => {
setHeaderActionsExpanded(!0);
}), titleActions.addEventListener("focusout", (event) => {
const next = event.relatedTarget;
(!next || !titleActions.contains(next)) && setHeaderActionsExpanded(!1);
}), headerActionsToggle.addEventListener("click", () => {
setHeaderActionsExpanded(!0);
}), setHeaderActionsExpanded(!1), titleActions.append(headerActionsToggle, refreshTopic, openNative, closeReader), header.append(home, titleWrap, 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,
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,
titleActions,
topicEditTrigger,
refreshTopic,
openNative,
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
});
}
}, "8758d79a8f95f3609795ca6fe1b0511cac27d0e242a47fe0a98186c4d0b6cb94");
/* 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_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_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), embedded = this.#workspace.snapshot.presentation.embedded;
this.#pageRoot.classList.toggle("ldp-reader-open", visible), this.#pageRoot.classList.toggle(
"ldp-scroll-lock",
visible && !embedded
), 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;
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.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_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
});
shell.changes.subscribe((state) => {
workspace.workspace.setActive(READER_VISIBLE_STATES.has(state));
}, 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();
}
}
}
};
}
});
}
}, "b9f01618d579ebc56f8448623af7c76dd1325da88760aff35e13193d5d0b8157");
/* 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.locked), this.#overlay.classList.toggle("ldp-window-pinned", 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) {
if (this.#lockButton) {
const label = snapshot.locked ? "解锁浮窗" : "锁定浮窗";
this.#lockButton.classList.toggle("active", snapshot.locked), this.#lockButton.setAttribute("aria-pressed", String(snapshot.locked)), this.#lockButton.setAttribute("aria-label", label), this.#lockButton.title = label, this.#lockButton.dataset.locked = String(snapshot.locked);
for (const icon of this.#lockButton.querySelectorAll(
"[data-reader-lock-icon]"
))
icon.hidden = icon.dataset.readerLockIcon !== (snapshot.locked ? "locked" : "unlocked");
this.#previousLocked !== null && this.#previousLocked !== snapshot.locked && (this.#lockButton.classList.remove("ldp-lock-state-changing"), this.#lockButton.offsetWidth, this.#lockButton.classList.add("ldp-lock-state-changing"));
}
if (this.#previousLocked = snapshot.locked, this.#pinButton) {
const label = snapshot.pinned ? "恢复点击外部关闭" : "点击外部时保持显示";
this.#pinButton.classList.toggle("active", snapshot.pinned), this.#pinButton.setAttribute("aria-pressed", String(snapshot.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;
#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
)), this.scope.listen(this.#overlay, "pointermove", (event) => this.#onPointerMove(
event
));
for (const type of ["pointerup", "pointercancel"])
this.scope.listen(this.#overlay, type, (event) => this.#onPointerEnd(
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.#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();
})));
}
#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 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());
}
}
}, "48837eddefba6208c03437dfceea81ab115842737f6592b31ac086792edb76c1");
/* 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, {
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_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,
createReaderPerformancePreferencesPatch: () => createReaderPerformancePreferencesPatch,
createReaderPreferencesConfigCodec: () => createReaderPreferencesConfigCodec,
createReaderPreferencesDefaults: () => createReaderPreferencesDefaults,
createReaderPreferencesRepository: () => createReaderPreferencesRepository,
normalizeImageProfile: () => normalizeImageProfile,
normalizeReaderAppearanceProfile: () => normalizeReaderAppearanceProfile,
normalizeReaderFontProfile: () => normalizeReaderFontProfile,
normalizeReaderPreferences: () => normalizeReaderPreferences,
normalizeReaderShortcutBinding: () => normalizeReaderShortcutBinding,
normalizeReaderShortcutBindings: () => normalizeReaderShortcutBindings,
prepareStoredReaderPreferences: () => prepareStoredReaderPreferences,
readReaderPerformanceConfig: () => readReaderPerformanceConfig,
readerAppearanceEditableProfile: () => readerAppearanceEditableProfile,
readerLayoutProfileTotal: () => readerLayoutProfileTotal,
readerLayoutRegionMaximum: () => readerLayoutRegionMaximum,
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");
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, 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
}),
balanced: Object.freeze({
pageSize: 48,
streamOverscanViewports: 1.5,
streamMaxItems: 80,
nestedPrefetchViewports: 2.5,
requestMaxConcurrent: 3,
requestMinInterval: 100,
requestRateTarget: 85
}),
high: Object.freeze({
pageSize: 64,
streamOverscanViewports: 2,
streamMaxItems: 96,
nestedPrefetchViewports: 3,
requestMaxConcurrent: 4,
requestMinInterval: 80,
requestRateTarget: 90
})
}), PERFORMANCE_NAMES = Object.freeze([
"pageSize",
"streamOverscanViewports",
"streamMaxItems",
"nestedPrefetchViewports",
"requestMaxConcurrent",
"requestMinInterval",
"requestRateTarget"
]), 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 })
}), READER_PERFORMANCE_PRESETS = PERFORMANCE_PRESETS, 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
)
});
}
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
};
}
function readReaderPerformanceConfig(input) {
return performanceConfigFromInput(
input
);
}
function createReaderPerformancePreferencesPatch(config, preset) {
return performancePreferencesPatch(config, preset);
}
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,
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 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,
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([])
});
return new import_preferences_config_codec.PreferencesConfigCodec({
format: READER_CONFIG_EXPORT_FORMAT,
schemaVersion: READER_CONFIG_EXPORT_VERSION,
scriptVersion: options.scriptVersion,
defaults,
normalize,
legacyImportRules: [
{
missingDefaults: unwantedTopicFilterDefaults
},
{
missingDefaults: {
...unwantedTopicFilterDefaults,
autoDarkModeEnabled: !1,
autoDarkModeStartTime: "sunset"
}
},
{
missingDefaults: {
...unwantedTopicFilterDefaults,
autoDarkModeEnabled: !1,
autoDarkModeStartTime: "sunset",
fullpageLayoutProfile: READER_FULLPAGE_LAYOUT_DEFAULT
}
},
{
missingDefaults: {
...unwantedTopicFilterDefaults,
autoDarkModeEnabled: !1,
autoDarkModeStartTime: "sunset",
fullpageLayoutProfile: READER_FULLPAGE_LAYOUT_DEFAULT,
confirmNativeComposerClose: !0
}
},
{
missingDefaults: {
...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
});
}
}, "912c4eddb46b49e2508257294d02732ccf3c2b68e66442d6e7143837ea08df20");
/* 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;
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;
}
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() {
return this.#apply(Object.freeze({
sourceVersion: READER_SETTINGS_CONFIG_EXPORT_VERSION,
settingsCount: Object.keys(this.#defaults).length,
preferences: this.#defaults,
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;
}
}
}
}, "de1850d0832f75d8e437786b0c0f3eea5e4989cf60012e3d610c43333e715ccb");
/* 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;
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), this.slots.endTip.hidden = !state.done, this.slots.endTip.classList.toggle("show", state.done), this.slots.empty.hidden = !state.empty;
}
destroy() {
this.slots.root.remove();
}
}
}, "05ce613d0835acab8c277bfd8417391d40ad6795eb80edabc5f89676c3b19f5c");
/* 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();
#preferences;
constructor(options) {
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());
}
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), hidden ? view.slots.root.dataset.unwantedPostAuthor = username : delete view.slots.root.dataset.unwantedPostAuthor;
}
}
}, "201cc830587db6df721c9ddb84a7cf406ef8af913d07bb73161e5455f3d76c67");
/* 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 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();
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
), currentUsername && username === currentUsername && appendBadge(options.document, header, "ldp-me", "ME"), appendUserLink(
options.document,
header,
"ldp-user",
`@${username}`,
profileHref,
username
), username && username === (0, import_reader_topic_header.readerTopicOwnerUsername)(options.readTopic()) && appendBadge(options.document, header, "ldp-op", "OP");
const createdAt = text(post.created_at) || text(view.identity.createdAt), relative = options.relativeTime(createdAt);
let exactLabel = null;
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, exactLabel = options.document.createElement("span"), exactLabel.className = "ldp-time-exact", exactLabel.textContent = exact, exactLabel.setAttribute("aria-hidden", "true"));
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
), exactLabel && header.append(exactLabel), appendReadState(
options.document,
header,
post.read === !0,
options.renderIcon
), view.slots.content.innerHTML = text(post.cooked);
}
});
}
}, "c8980b6989edde77ec490a513dd15f64967d1f58f21bb5921700ee70c145fdae");
/* 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;
#session;
#presentation;
#currentUsername;
#renderIcon;
#onError;
#count = null;
#presence = null;
#presenceUsers = Object.freeze([]);
constructor(options) {
this.#document = options.document, 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.#count = null, this.#presence = null;
});
}
afterRender(post, view) {
if (positiveCount(post.post_number) !== 1) return;
let header = view.slots.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();
}
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;
}
#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;
}
}
}, "8b1bf40506b52a9ea96a53da13c90de849ed71a3a90e138944b1b6618dc1c90d");
/* 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;
#entries = /* @__PURE__ */ new Map();
update(snapshot) {
this.#snapshot = snapshot, this.#entries.clear();
for (const entry of snapshot?.entries ?? [])
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.classList.toggle(
"ldp-descendant-tree-pannable",
overflowDepth > 0
), overflowDepth || (this.#discussionList.scrollLeft = 0);
}
#onTreePanPointerDown(event) {
const target = (0, import_event_target.eventElement)(event);
if (!(event.button !== 0 || event.pointerType && event.pointerType !== "mouse" || !this.#discussionList.classList.contains(
"ldp-descendant-tree-pannable"
) || !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
);
}
}
}, "e45424c3aafd09efdca21b0462127c7afb219c5a6822057c5477bbfeea0ac664");
/* 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.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 },
...options.readTimeMs === void 0 ? {} : { readTimeMs: options.readTimeMs }
}), 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.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)
});
}
}, "8bf41c6e63221b26fef77731b5a672a978ff5e7bbb71e2854b269cbc3bd579e1");
/* 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, 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;
#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;
#projectionHydrationPostNumbers = Object.freeze([]);
#retainedViewLimit = 0;
#lastVisibleRootChangeKey = "";
#lastPostStreamRevision;
#pendingViewportMutation = 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.#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(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),
/*
* 根高测量只更新虚拟坐标。活跃滚动由 Chromium 独占;停稳后的物理
* 视野由 scroll adapter 的唯一视野锁持有,frame 不得再写第二份补偿。
*/
shouldApplyScrollCompensation: () => !1,
shouldDeferMeasurements: () => !1,
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 visiblePostNumber = this.#scroll.readVisibleViewportAnchor?.(
this.#visiblePostElements()
)?.postNumber ?? 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();
});
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.#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) ?? (() => {
});
}
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.#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, !1)) 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
}));
}
#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(input, window) {
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 = eagerProjectionBudget > 0 && plan.contentPostNumbers.has(postNumber), created = renderImmediately ? this.postProjector.create(
post,
this.scope,
postNumber
) : this.postProjector.createShell(
post,
this.scope,
postNumber
);
renderImmediately ? eagerProjectionBudget -= 1 : this.#markProjectionPending(
created,
plan.ownSizes.get(postNumber)
), this.domOwner.register(created, !1);
} catch (error) {
this.#onError(error);
}
}
return 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"
);
})
), this.#setProjectionHydrationCandidates(
[
...visiblePostNumbers,
...plan.contentPostNumbers
]
), 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) {
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);
if (this.#projectionHydrationPostNumbers = Object.freeze([...unique]), !this.#projectionHydrationPostNumbers.length) {
this.#projectionHydrationHandle !== null && this.#projectionHydrationScheduler.cancel(
this.#projectionHydrationHandle
), this.#projectionHydrationHandle = null;
return;
}
this.#scheduleProjectionHydration();
}
#scheduleProjectionHydration(delayMs) {
if (this.scope.destroyed || this.#projectionHydrationHandle !== null || !this.#projectionHydrationPostNumbers.length) return;
const delay = delayMs ?? Math.max(
PROJECTION_HYDRATION_BATCH_DELAY_MS,
this.#scrollLifecycle.remainingIdleMs(
PROJECTION_HYDRATION_MIN_IDLE_MS
)
);
this.#projectionHydrationHandle = this.#projectionHydrationScheduler.schedule(() => {
if (this.#projectionHydrationHandle = null, this.scope.destroyed) return;
const remainingIdleMs = this.#scrollLifecycle.remainingIdleMs(
PROJECTION_HYDRATION_MIN_IDLE_MS
);
if (remainingIdleMs > 0) {
this.#scheduleProjectionHydration(remainingIdleMs);
return;
}
this.#hydrateProjectionBatch();
}, Math.max(0, delay));
}
#hydrateProjectionBatch() {
const remaining = [];
let hydrated = 0;
for (const postNumber of this.#projectionHydrationPostNumbers) {
if (hydrated < PROJECTION_HYDRATION_BATCH_SIZE && this.#materializeProjection(postNumber)) {
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), remaining.length && this.#scheduleProjectionHydration(
PROJECTION_HYDRATION_BATCH_DELAY_MS
);
}
#materializeProjection(postNumber, notify = !0) {
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 = notify && 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)), this.#syncProjectionFeatures(), notify && this.frame.notifyScroll(), this.#scheduleBranchPaint(), !0;
}
#cancelProjectionHydration() {
this.#projectionHydrationHandle !== null && this.#projectionHydrationScheduler.cancel(
this.#projectionHydrationHandle
), this.#projectionHydrationHandle = null, this.#projectionHydrationPostNumbers = Object.freeze([]);
}
#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);
this.#commitPendingOwnSizeMeasurements();
}
#commitPendingOwnSizeMeasurements() {
if (!this.#pendingOwnSizePostNumbers.size) return;
const pending = [...this.#pendingOwnSizePostNumbers];
this.#pendingOwnSizePostNumbers.clear();
let changed = !1;
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 || (changed = this.#treeViewport.measureOwnSize(
postNumber,
Math.max(1, sample.root - sample.replyTree)
) || changed);
}
changed && this.frame.notifyScroll();
}
#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() {
const generation = this.#branchPaintGeneration;
this.#branchPaintHandle = this.#branchFrames.request(() => {
this.#branchPaintHandle = null, !this.scope.destroyed && (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 已销毁");
}
}
}, "117a5b405d2951b7f9a2d37c694a43cc601d45003dc43737ca729b5c17f227d1");
/* 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_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;
}
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;
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.#elements.titleJump.textContent = snapshot.title, 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(), 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 (!(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}`;
}
}
}, "51f322842b7ef4890ced9a0218e8fdceaf3743c94d30c0f262ad7d007e9e42b0");
/* 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;
#epoch = 0;
constructor(options) {
this.#session = options.session, this.#dom = options.dom, this.#hidden = options.hidden ?? null, this.#onError = options.onError ?? (() => {
}), 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;
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"
));
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.#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;
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("ReaderTopicNavigationController 已销毁");
}
}
}, "bda9dbdd4bf61753d4f769cdb94cf4e63e6b32548bfc17885f6dba793e94458c");
/* 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, [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;
#lastUserScrollAt = 0;
#lastUserScrollDirection = 0;
#userScrollSessionActive = !1;
#lastTouchClientY = null;
#stationaryAnchor = null;
#stationaryLockPending = !1;
#stationaryLockFrame = 0;
#stationaryLockSettleFrames = 0;
#viewportMutationAnchor = null;
#viewportMutationToken = 0;
#internalScrollWriteOffset = null;
#programmaticScrollTransactionDepth = 0;
#stationaryMutationObserver = null;
#stationaryResizeObserver = null;
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.#observeStationaryContentSize(), this.#restoreStationaryViewport();
});
const NativeResizeObserver = this.#scrollRoot.ownerDocument.defaultView?.ResizeObserver;
NativeResizeObserver && (this.#stationaryResizeObserver = new NativeResizeObserver(() => {
this.#restoreStationaryViewport();
})), 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",
() => {
this.#claimScrollOnlyUserInput() && this.#markUserScrollProgress(), this.#scrollOffsetDirty = !0, 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;
keyboard.defaultPrevented || keyboard.altKey || keyboard.ctrlKey || keyboard.metaKey || !SCROLLING_KEYS.has(keyboard.key) || isEditableScrollTarget(keyboard.target) || this.#markUserScrollIntent(this.#keyboardDirection(keyboard));
}), this.scope.add(() => {
this.#scrollFrame && (this.#cancelFrame(this.#scrollFrame), this.#scrollFrame = 0, 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({
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) return;
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;
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, Math.abs(correction) >= 1 && this.writeScrollOffset(this.#scrollOffset + correction), options.focus && this.#focus(target), options.highlight !== !1 && this.highlight.highlight(target);
}
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, candidates = elements.flatMap((owner) => {
const postNumber = Number(owner.dataset.postNumber);
if (!Number.isSafeInteger(postNumber) || postNumber <= 0 || !owner.isConnected || !this.#scrollRoot.contains(owner) || owner.hidden) return [];
const marker2 = owner.querySelector(":scope > .ldp-post-head") ?? owner, markerRect = marker2.getBoundingClientRect(), ownerRect = owner.getBoundingClientRect();
return [{ postNumber, marker: marker2, markerRect, owner, ownerRect }];
}), visibleHeaders = candidates.filter(
({ markerRect }) => markerRect.bottom > visibleTop + 1 && markerRect.top < visibleBottom - 1
).sort(
(left, right) => Math.max(0, left.markerRect.top - visibleTop) - Math.max(0, right.markerRect.top - visibleTop) || left.markerRect.top - right.markerRect.top
), fallback = candidates.filter(
({ ownerRect }) => ownerRect.bottom > visibleTop + 1 && ownerRect.top < visibleBottom - 1
).sort(
(left, right) => Math.abs(left.ownerRect.top - visibleTop) - Math.abs(right.ownerRect.top - visibleTop) || left.ownerRect.height - right.ownerRect.height
), visibleHeader = visibleHeaders[0], best = visibleHeader ?? fallback[0];
if (!best) return null;
const marker = visibleHeader ? best.marker : best.owner, markerOffset = visibleHeader ? best.markerRect.top - visibleTop : best.ownerRect.top - visibleTop;
return Object.freeze({
postNumber: best.postNumber,
marker,
markerOffset,
owner: best.owner,
ownerOffset: best.ownerRect.top - visibleTop
});
}
#notifyWindowChange() {
for (const listener of [...this.#windowChangeListeners]) listener();
}
#markUserScrollIntent(direction = 0, direct = !0) {
this.#cancelPendingStationaryViewportLock(), this.#releaseStationaryViewport(), direction !== 0 && (this.#lastUserScrollDirection = direction), this.#userScrollSessionActive = !0, this.#lastUserScrollAt = Math.max(
Number.EPSILON,
finiteNonNegative(this.#now())
);
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.#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
);
}
#observeStationaryContentSize() {
const observer = this.#stationaryResizeObserver;
if (!observer || !this.#stationaryAnchor) return;
observer.disconnect();
const stream = this.#scrollRoot.querySelector(
".ldp-virtual-stream"
);
stream && observer.observe(stream);
}
#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() {
const actualOffset = finiteNonNegative(this.#scrollRoot.scrollTop), internalOffset = this.#internalScrollWriteOffset;
return this.#internalScrollWriteOffset = null, internalOffset !== null && Math.abs(actualOffset - internalOffset) < 0.5 ? !1 : this.#userScrollSessionActive ? !0 : !this.#stationaryAnchor && !this.#stationaryLockPending ? !1 : (this.#markUserScrollIntent(
this.#directionOf(actualOffset - this.#scrollOffset),
!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;
}
#scheduleScrollCommit() {
if (this.#scrollFrame || this.scope.destroyed) return;
let completed = !1;
const handle = this.#requestFrame(() => {
if (completed = !0, this.#scrollFrame = 0, this.scope.destroyed) return;
const scrollOffset = this.#refreshPendingScrollOffset();
this.#pendingScrollOffset = null, scrollOffset !== this.#scrollOffset && (this.#scrollOffset = scrollOffset, this.#notifyWindowChange());
});
completed || (this.#scrollFrame = handle);
}
#refreshPendingScrollOffset() {
return this.#scrollOffsetDirty && (this.#pendingScrollOffset = finiteNonNegative(
this.#scrollRoot.scrollTop
), this.#scrollOffsetDirty = !1), this.#pendingScrollOffset ?? this.#scrollOffset;
}
}
}, "d7f3e33f99899893dfb0af8fbb9ad65f0c7a63102637490f38df267d5e1ba693");
/* 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_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 : excerpt.textContent = answer.excerpt || "查看被采纳的完整回复。", 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(":scope > .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", "", identity.label)
);
const username = view.slots.header.querySelector(":scope > .ldp-user");
username ? username.after(badge) : view.slots.header.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);
}
}
}, "b149c5c98ca216145de9530178a7afdece9da9bd44cdfb82c196ca70d50a678d");
/* 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;
#onError;
#snapshot;
#jumpEpoch = 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.#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.#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() {
return 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;
this.#commit(this.#snapshot.currentPostNumber, target);
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);
}
}
destroy() {
this.scope.destroy();
}
#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;
}
}
}, "b094b21e3324fe9a4286353886e51837cda01d1f786f4143fd5ff18db4b0d37e");
/* 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;
#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.#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", () => {
const target = this.#controller.targetAtEnd();
this.#submitJump(target, !1);
}), 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, "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} 加载失败,请重试`));
}));
}
#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 已销毁");
}
}
}, "d43608f0d25f4f22b459437dd779e1d7ecb93d83ec75eec95e5bffd966905cd2");
/* 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");
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);
}
});
}
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.topic({
basePath: this.#basePath,
topicId: this.topicId
});
return this.#gateway.loadTopicTarget({
authScope: this.authScope,
topicId: this.topicId,
operation: "topic-refresh",
profile: options.background ? options.prefetchTier === "nearby" ? "nearby-prefetch" : "background-prefetch" : "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({
authScope: this.authScope,
topicId: this.topicId,
postIds,
profile: options.background ? options.prefetchTier === "nearby" ? "nearby-prefetch" : "background-prefetch" : 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 ? options.prefetchTier === "nearby" ? "nearby-prefetch" : "background-prefetch" : 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({
authScope: this.authScope,
topicId: this.topicId,
operation: "post-by-id-refresh",
postId,
profile: options.background ? "background-prefetch" : "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({
authScope: this.authScope,
collection: `post-voting-comments:${postId}`,
page: afterCommentId,
cursor: afterCommentId,
profile: options.background ? "background-prefetch" : "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({
authScope: this.authScope,
topicId,
operation: `target:${options.scope}:${candidate.endpoint}`,
postNumber,
profile: options.background ? "background-prefetch" : "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 ? "background-prefetch" : "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({
authScope: this.authScope,
topicId: this.topicId,
parentPostNumber,
parentPostId,
after,
profile: options.background ? "background-prefetch" : "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 ? "background-prefetch" : "nested-visible",
cacheMode: options.refresh ? "refresh" : "default"
}) ?? !1;
}
}
}, "1aff93d89fa6c4282038878018b9f40d5b583b061e995ac8844a916308869742");
/* 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.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.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.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 列表入口的标准正文预热窗口。
*
* #1 只取向下一个 pageSize;中间楼层取前后各一个 pageSize,目标楼层归入
* 向下窗口。所有批次仍走 loadPostsByIds 的缓存、single-flight 与中央后台调度;
* 完整缓存命中时不创建网络请求。
*/
async restorePreheatEntry(rawPostNumber) {
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), 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, ...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);
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) {
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), 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.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.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");
}
}
}, "6b8e03f75992923fa40f092c611198e24931f6f8b82fc33a58e5261f90193f09");
/* 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");
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 ?? 15e3)), 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 });
}
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;
};
}
}
}, "d4fcc8337eb72a3405738b432121599fd953e479471df389d86dc85b8c3a8b23");
/* 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_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_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_embedded_reload_coordinator = require("./reader-embedded-reload-coordinator.js"), import_browser_shared_request_permit = require("../network/browser-shared-request-permit.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 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, 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
);
}
}), 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: window.localStorage,
openedTopicStorageScope: currentUsername,
isTopicHidden: (topicId) => state.runtime?.unwantedTopics.isManuallyHidden(topicId) === !0,
hideTopic: (input) => {
const runtime = state.runtime;
if (!runtime) throw new Error("不想看仓库尚未就绪");
runtime.unwantedTopics.remember(input);
},
automaticFilter: (input) => state.runtime ? (0, import_reader_unwanted_topic_filter.readerUnwantedTopicFilterMatch)(
import_reader_unwanted_topic_filter.readerPreferencesUnwantedTopicFilterAdapter.read(
applicationContext.readPreferences()
),
input
) : null,
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,
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: window.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: import_browser_shared_request_permit.READER_BACKGROUND_REQUEST_IDLE_INTERVAL_MS,
backgroundMaxDeferMs: import_browser_shared_request_permit.READER_BACKGROUND_REQUEST_MAX_DEFER_MS
},
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
},
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,
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,
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,
onError: (error) => {
console.error("[main-lite:target]", error);
}
},
onReady(runtime, context, _settings, _settingsView, _layout, appearance, font) {
state.runtime = runtime, persistTranslationMode = (translationMode) => {
context.updatePreferences?.({ translationMode });
};
let settingsResetReminderChecked = !1;
const checkSettingsResetReminder = () => {
settingsResetReminderChecked || !["opening", "running", "failed"].includes(runtime.shell.state) || (settingsResetReminderChecked = !0, (0, import_reader_settings_reset_reminder.showReaderSettingsResetReminder)({
storage: window.localStorage,
preferencesStorageKey: import_reader_preferences_schema.READER_PREFERENCES_STORAGE_KEY,
defaults: preferencesDefaults,
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);
}
}));
}, 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 ((0, import_browser_shared_request_permit.isReaderCloudflareChallengeWindow)(window)) {
existing?.destroy?.(), page[CHALLENGE_MONITOR_KEY]?.();
const stopMonitor = (0, import_browser_shared_request_permit.monitorReaderCloudflareChallengeWindow)({
storage: window.localStorage,
storageEvents: window,
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: window.localStorage
}), state = {
runtime: null,
portal: null,
informationFlow: null,
diagnostics: []
}, onWindowKeyDown = (event) => {
state.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,
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;
}, "2b45e65397efeecaf05ca13fca9c98ae8ffcb370bb9fbeddcc8e00548636f0a9");
/* 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_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", 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) : "未知时间";
}
class ReaderHostTopicPreheatController {
scope;
#options;
#document;
#maxQueuedTopics;
#maxConcurrentPreheats;
#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;
#frame = 0;
#destroyed = !1;
constructor(options) {
this.#options = options, this.#document = options.document, this.#maxQueuedTopics = Math.max(
1,
Math.floor(options.maxQueuedTopics ?? DEFAULT_MAX_QUEUED_TOPICS)
), this.#maxConcurrentPreheats = Math.min(
MAX_CONCURRENT_PREHEATS,
Math.max(
1,
Math.floor(
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));
}
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), card.classList.add(PERFORMANCE_CARD_CLASS), (card.querySelector(".link-bottom-line") ?? card.querySelector(".main-link") ?? card).append(meta);
const cardState = {
topicId,
routePostNumber: route?.postNumber ?? null,
meta,
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.meta.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.#nearTopics.delete(topic.topicId), 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.#nearTopics.delete(topic.topicId);
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.#paused || !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.#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.#paused || !this.#activityVisible())) {
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)));
}
for (; this.#networkActiveTopics.size < this.#maxConcurrentPreheats && this.#activeControllers.size < this.#maxQueuedTopics + this.#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) => {
this.#applyProgress(topic, progress, !1), progress.complete && !controller.signal.aborted && this.#releaseNetworkSlot(topicId, controller);
},
topic.totalCount
);
} 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 (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
).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")
);
}
#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.#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)
);
return totalCount <= topic.totalCount ? !1 : (topic.totalCount = totalCount, (topic.status === "ready" || topic.status === "partial" || topic.status === "error") && (topic.status = "idle", topic.attempts = 0), !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" ? "(部分)" : "", preheatLabel = `预热 ${topic.warmedCount}/${total || "?"}${suffix}`, activityLabel = live ? "阅读中" : preheatLabel, readLabel = `已读 ${topic.confirmedReadCount}`;
cardState.meta.textContent = historyLabel ? `${historyLabel} · ${activityLabel} · ${readLabel}` : `${activityLabel} · ${readLabel}`, cardState.meta.dataset.ldpPreheatState = live ? "reading" : topic.status, cardState.meta.title = live || history ? `上次阅读:${new Date(viewedAt).toLocaleString("zh-CN")};定位:#${floor};${activityLabel};${readLabel}` : `${activityLabel};${readLabel}`;
}
#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.#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.meta.remove(), card.classList.remove(PERFORMANCE_CARD_CLASS);
this.#cards.clear(), this.#topics.clear();
}
#report(error) {
try {
this.#options.onError?.(error);
} catch {
}
}
}
}, "e4da2fc2f0f73222474f71a40a683cf363f8a8be36f4d03acbbc78bd08c1f470");
/* 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_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();
return Object.freeze({
host: environment.discourseHost,
share: environment.createShareSurface(),
...externalHttp ? {
connect: Object.freeze({ http: externalHttp }),
credit: Object.freeze({
http: externalHttp,
...valueStorage ? { storage: valueStorage } : {}
})
} : {},
...translation === void 0 ? {} : { translation },
...resources === void 0 ? {} : { resources },
...assetCacheStorage ? { assetCacheStorage } : {}
});
}
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.translation === void 0 ? {} : { translation: bindings.translation },
...bindings.resources === void 0 ? {} : { resources: bindings.resources },
...bindings.assetCacheStorage === void 0 ? {} : { assetCacheStorage: bindings.assetCacheStorage },
...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]
);
hostPreheat = new import_reader_host_topic_preheat_controller.ReaderHostTopicPreheatController({
document: options.runtime.document,
mutations: runtime.workspace.mutations,
activity: runtime.activity,
maxConcurrentPreheats: 3,
historyEntry: (topicId) => runtime.history.entry(topicId),
readConfirmedCount: (topicId) => confirmedReadPosts.get(topicId)?.size ?? 0,
readOpenTopicsAtFirstPost: () => targetOptions.selectOpenTopicsAtFirstPost?.(
context.readPreferences()
) === !0,
restorePreheat: async (topicId, postNumber, signal) => {
const active = runtime.shell.activeValue;
if (active?.services.session.topicId === topicId)
return signal.throwIfAborted(), active.services.session.applyPageSize(
runtime.performance.pageSize
), active.services.session.restorePreheatEntry(
postNumber
);
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 {
return await bundle.services.session.restorePreheatEntry(
postNumber
);
} finally {
await bundle.prepareClose?.("close"), scope.destroy();
}
},
preheat: async (topicId, postNumber, signal, report, minimumTotalCount) => {
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,
onProgress: report,
beforeNetwork: (requestSignal) => {
signal.throwIfAborted(), requestSignal.throwIfAborted();
}
}
);
return signal.throwIfAborted(), await active.services.session.flush(), 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,
onProgress: report
}
);
return 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;
},
parentScope: runtime.scope,
...targetOptions.onError === void 0 ? {} : { onError: targetOptions.onError }
}), 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();
};
runtime.shell.changes.subscribe((state) => {
state === "running" ? bindActiveReadingProjection() : (state === "switching" || state === "closed" || state === "failed") && clearActiveReadingProjection();
}, hostPreheat.scope), hostPreheat.scope.add(clearActiveReadingProjection), runtime.shell.state === "running" && bindActiveReadingProjection(), 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) {
active.dom.flushNow();
const releaseTimelineHold = active.topicTimeline.holdVisiblePost(
exactFloorAnchor.viewport.postNumber
), settleTimers = /* @__PURE__ */ new Set(), clearSettleTimers = () => {
for (const timer of settleTimers)
options.runtime.document.defaultView?.clearTimeout(timer);
settleTimers.clear();
};
let releaseUserIntent = () => {
}, releaseReaderInteraction = () => {
};
const releaseHistorySettle = () => {
clearSettleTimers(), releaseTimelineHold(), releaseUserIntent(), releaseReaderInteraction();
};
releaseUserIntent = active.dom.listenDirectUserScrollIntent(
releaseHistorySettle
), releaseReaderInteraction = active.topicTimeline.scope.listen(
runtime.shell.view.root,
"click",
releaseHistorySettle
), active.topicTimeline.scope.add(clearSettleTimers);
for (const delayMs of [200, 800, 2e3]) {
const timerWindow = options.runtime.document.defaultView;
if (!timerWindow) break;
const timer = timerWindow.setTimeout(() => {
settleTimers.delete(timer), !(runtime.shell.activeValue !== active || active.services.session.topicId !== request.topicId) && active.topicNavigation.navigate({
postNumber: exactFloorAnchor.viewport.postNumber,
source: "history",
alignment: "center",
highlight: !1,
cachedOnly: !0
}).then((settled) => {
settled.status === "revealed" && runtime.shell.activeValue === active && active.dom.flushNow();
}).catch(() => {
});
}, delayMs);
settleTimers.add(timer);
}
}
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) => {
await hostSource.prepare(target), await targetOptions.beforeOpenTarget?.(target);
},
afterOpenTarget: async (target, opened) => {
await hostSource.settle(target, opened);
},
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();
}
};
}
});
}
}, "82bb38fb0fb79bd8c0cea799e8e0138380bfdf0bbf0669ae90693a72d003dd1f");
/* 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");
})();