Core runtime modules for Awesome LinuxDo Reader Lite.
Este script no debería instalarse directamente. Es una biblioteca que utilizan otros scripts mediante la meta-directiva de inclusión // @require https://update.greasyfork.org/scripts/590254/1899420/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.3.1
// @description Core runtime modules for Awesome LinuxDo Reader Lite.
// @description:zh-CN 应用、数据、Discourse、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.3.1 - main-lite-core
* 应用、数据、Discourse、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.3.1",
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.3.1") {
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
});
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_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_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_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_lifecycle = require("../kernel/lifecycle.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_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_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_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_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_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_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_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 readerReportOptions(document, flagTypes, availableNames, appliesTo) {
return Object.freeze(flagTypes.filter(
(flag) => flag.enabled && availableNames.has(flag.nameKey) && (!flag.appliesTo.length || flag.appliesTo.includes(appliesTo))
).map((flag) => {
const template = document.createElement("template");
template.innerHTML = flag.description;
const description = String(
template.content.textContent ?? ""
).replace(/\s+/g, " ").trim();
return Object.freeze({
id: flag.id,
label: flag.label,
description: description || "提交给社区管理人员审核。",
requireMessage: flag.requireMessage
});
}));
}
function readerNativeModelValue(value, key) {
if (!value || typeof value != "object" && typeof value != "function")
return;
const model = value;
return typeof model.get == "function" ? model.get(key) : model[key];
}
function readerPollViewer(currentUser, fallbackUsername) {
const id = Number(readerNativeModelValue(currentUser, "id")), username = String(
readerNativeModelValue(currentUser, "username") ?? fallbackUsername
).trim(), rawGroups = readerNativeModelValue(currentUser, "groups"), groups = Array.isArray(rawGroups) ? rawGroups.map((group) => String(
readerNativeModelValue(group, "name") ?? group
).trim()).filter(Boolean) : [];
return Object.freeze({
id: Number.isSafeInteger(id) && id > 0 ? id : null,
username: username || null,
staff: ["staff", "admin", "moderator"].some((key) => readerNativeModelValue(currentUser, key) === !0),
groups: Object.freeze(groups)
});
}
function readerShellElement(root, selector, owner) {
const value = root.querySelector(selector);
if (!value) throw new Error(`${owner} 缺少 Shell 命名控件:${selector}`);
return value;
}
function readerTopicHeaderElements(root) {
const query = (selector) => readerShellElement(root, selector, "主题 Header");
return Object.freeze({
titleJump: query(".ldp-title-jump"),
metaHost: query(".ldp-meta"),
metaStats: query(".ldp-meta-stats"),
metaOwner: query(".ldp-meta-owner"),
metaOwnerValue: query(".ldp-meta-owner-value"),
onlyOpToggle: query(".ldp-only-op-toggle"),
onlyOpProgress: query(".ldp-only-op-progress"),
onlyOpProgressValue: query(".ldp-only-op-progress-value"),
topicIdentityHost: query(".ldp-title-topic-row")
});
}
function readerTopicTimelineElements(root) {
const query = (selector) => readerShellElement(root, selector, "时间轴 View");
return Object.freeze({
root,
timeline: query(".ldp-topic-timeline"),
date: query(".ldp-topic-timeline-date"),
track: query(".ldp-topic-timeline-track"),
cursor: query(".ldp-topic-timeline-cursor"),
current: query(".ldp-topic-timeline-current"),
total: query(".ldp-topic-timeline-total"),
preview: query(".ldp-topic-timeline-preview"),
relative: query(".ldp-topic-timeline-relative"),
jump: query(".ldp-topic-timeline-jump"),
top: query(".ldp-topic-timeline-top"),
jumpForm: query(".ldp-topic-timeline-jump-form"),
jumpInput: query(".ldp-topic-timeline-jump-input"),
jumpSubmit: query(
".ldp-topic-timeline-jump-submit"
),
jumpHint: query(".ldp-topic-timeline-jump-hint")
});
}
function readerTopicLiveNavigationElements(root) {
const liveRoot = root.querySelector(".ldp-live-update"), jump = root.querySelector(".ldp-live-update-jump"), label = jump?.querySelector("span") ?? null, dismiss = root.querySelector(
".ldp-live-update-dismiss"
);
if (!liveRoot || !jump || !label || !dismiss)
throw new Error("实时新回复 View 缺少 Shell 命名控件");
return Object.freeze({
root: liveRoot,
jump,
label,
dismiss
});
}
function readerNotificationPanelElements(root) {
const query = (selector) => readerShellElement(root, selector, "消息面板"), modeTabs = [...root.querySelectorAll(
".ldp-notification-mode-tab"
)], groupPanels = [...root.querySelectorAll(
"[data-notification-mode-panel]"
)], groupTabs = [...root.querySelectorAll(
".ldp-notification-tab"
)];
if (modeTabs.length !== 2 || groupPanels.length !== 2 || groupTabs.length !== 14)
throw new Error("消息面板必须提供 2 个模式与完整 14 个分类锚点");
return Object.freeze({
root,
toggle: query(".ldp-notifications-toggle"),
badge: query(".ldp-notification-unread-badge"),
popover: query(".ldp-notifications-popover"),
modeTabs: Object.freeze(modeTabs),
groupPanels: Object.freeze(groupPanels),
groupTabs: Object.freeze(groupTabs),
toolbar: query(".ldp-notification-toolbar"),
unreadStatus: query(".ldp-notification-unread-status"),
markAll: query(".ldp-notification-mark-all"),
newMessage: query(".ldp-notification-new-message"),
search: query(".ldp-notification-search"),
searchClear: query(".ldp-notification-search-clear"),
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 !== 3)
throw new Error("收藏面板必须提供回应、帖子、楼层三个分类锚点");
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"),
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;
}
class ReaderBrowserRuntime {
scope;
shell;
workspace;
permit;
data;
nativeAjax;
userNative;
users;
connectHistory;
creditAccount;
userEndorsements;
userActions;
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;
historyNavigation;
historyNavigationView;
historyPanelView;
notificationNative;
notificationRequests;
notificationActions;
notificationController;
notificationPanelView;
bookmarkNative;
bookmarkRequests;
bookmarkActions;
bookmarkController;
bookmarkPanelView;
topicFactory;
#performance;
#openRecoveryController = null;
#lastFailedRequest = null;
#challengeHref;
#openRetryDelay;
#loadingProgress;
#manualChallengeController;
#manualChallengePromise = null;
#destroyed = !1;
constructor(options) {
this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), 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.#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 {
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,
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 }
), 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 response = await this.nativeAjax.request({
path: "/session/current.json",
method: "GET",
signal,
noStore: !0
});
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.permit.reconcileCloudflareChallenge().then(() => this.rateLimitNotice.refresh()).catch(() => {
}), 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
}), 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,
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
})
)
);
};
if (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
}), 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),
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);
},
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), 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), 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();
if (options.notifications === !1)
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
}
});
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,
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.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,
controller: this.notificationController,
elements: readerNotificationPanelElements(
this.shell.view.root
),
baseUrl: topicBaseUrl,
relativeTime: nativeRelativeTime,
notify: (message) => this.feedback.show(message),
parentScope: this.scope,
onError: (cause) => reportTopicFeature(
this.shell.activeTopicId ?? 0,
"notification",
cause
)
});
}
if (options.bookmarks === !1)
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"],
freshForMs: 30 * 6e4,
retainForMs: options.topic.caches.posts.retainForMs,
persist: !0
}
});
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,
native: this.bookmarkNative,
actions: this.bookmarkActions,
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
},
...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,
controller: this.bookmarkController,
elements: readerBookmarkPanelElements(
this.shell.view.root
),
baseUrl: topicBaseUrl,
relativeTime: nativeRelativeTime,
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
)
});
}
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) => {
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
})
), replyTreePresentation = domOptions.replyTreePresentation ?? new import_reader_reply_tree_preferences.ReaderReplyTreePresentation(
bundle.replies.topology,
domOptions.replyTreePreferences?.read(),
{
canonicalCoverageComplete: () => bundle.replies.coverage().complete
}
), 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
)
}), 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}`
);
},
...options.downloadCurrentTopic ? { downloadCurrentTopic: options.downloadCurrentTopic } : {},
...options.openTopicDownloadManager ? {
openTopicDownloadManager: options.openTopicDownloadManager
} : {},
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
});
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,
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);
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(),
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, 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.#rememberHistoryTopic(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.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.#rememberHistoryTopic(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 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 (anchor.replyWindow ? await value.topicContextSurface.restoreDiscussionState(anchor.replyWindow) : value.topicContext.closeDiscussion(), !!value.topicNavigation.isCurrent(navigationRevision)) {
if (!await value.topicContextFeature.restoreQuoteHighlightState(anchor.quoteHighlight))
throw new Error(
`历史引用高亮 #${anchor.quoteHighlight?.postNumber ?? 0} 恢复失败`
);
if (!(anchor.quoteHighlight === null && !value.topicNavigation.isCurrent(navigationRevision))) {
if (!value.dom.restoreViewportAnchor(anchor.viewport))
throw new Error(
`历史楼层 #${anchor.viewport.postNumber} 缺少 canonical 根布局`
);
value.topicTimeline.syncVisiblePost(
anchor.viewport.postNumber
), this.#rememberHistoryTopic(
value,
anchor.viewport.postNumber
);
}
}
}
},
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"
), 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 || !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,
history: this.history,
elements: {
root,
toggle,
popover,
sortToggle,
multiButton,
clearButton,
defaultActions,
bulkActions,
selectScope,
selectToggle,
deleteSelected,
deleteSelectedLabel,
multiDone,
search,
searchClear,
list,
pagePrevious,
pageInfo,
pageNext
},
openEntry: async (entry) => {
await this.openTarget({
topicId: entry.topicId,
postNumber: entry.postNumber,
source: "restore"
});
},
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;
}
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
});
const normalizedTopicId = (0, import_identifiers.discourseTopicId)(request.topicId);
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.#rememberHistoryTopic(result.value), this.historyNavigation.snapshot.activeTopicId !== result.topicId && this.historyNavigation.activate(result.topicId);
let navigation = null;
try {
if (request.postNumber !== void 0) {
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 }
});
} 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 (canonicalParent != null && 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}`
));
}
return Object.freeze({ topic: result, navigation });
} finally {
transactionIsCurrent() && (this.#rememberHistoryTopic(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 });
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.#openRecoveryController === recoveryController && (this.#openRecoveryController = null), releaseLoading?.();
}
}
async close() {
return 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.#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.#highlightQuoteSource(restored, source), !0;
}
#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) {
if (this.#manualChallengePromise || this.scope.destroyed) return;
const promise = this.permit.resolveCloudflareChallenge({
href,
signal: this.#manualChallengeController.signal,
focus: !0
}).then(async (passed) => (this.scope.destroyed || (passed && (await this.data.client.resetRateLimits(), await this.rateLimitNotice.refresh()), this.feedback.show(
passed ? "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
});
}
#captureAndRememberHistoryAnchor() {
const anchor = this.#captureHistoryAnchor(), value = this.shell.activeValue;
return anchor && value && this.#rememberHistoryTopic(
value,
anchor.viewport.postNumber
), 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;
}
#rememberHistoryTopic(value, postNumber = value.topicTimeline.snapshot.currentPostNumber) {
const topic = value.services.session.topic ?? value.topic, posts = value.services.session.cachedPosts(), firstPost = posts.find(
(post) => (0, import_identifiers.tryDiscoursePostNumber)(post.post_number) === 1
), readPostNumbers = posts.filter(
(post) => post.read === !0
).map((post) => post.post_number);
readPostNumbers.push(
...value.services.read.snapshot().confirmed
);
try {
this.history.remember({
topicId: value.services.session.topicId,
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,
postNumber,
readPostNumbers
});
} 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
});
}
}
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,
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, 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, openTopicDownloadManager = 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 },
...options.openQueue && options.runtime.resources ? {
downloadCurrentTopic: () => downloadCurrentTopic?.(),
openTopicDownloadManager: () => openTopicDownloadManager?.()
} : {},
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(), 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,
...anchor ? { postNumber: anchor.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,
...anchor ? { postNumber: anchor.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 (anchor)
try {
await runtime.historyNavigation.restore(topicId, anchor);
} 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), anchor && await runtime.historyNavigation.restore(topicId, anchor), 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 {
}
}
}));
};
mountSettingsUser(), settingsView.changes.subscribe((snapshot) => {
snapshot.open && snapshot.activePanelId === "user" && mountSettingsUser();
}, 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(
"翻译设置 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,
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 && 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 && 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) {
const localFontWindow = options.runtime.document.defaultView;
new import_reader_font_settings_form.ReaderFontSettingsForm({
document: options.runtime.document,
host: settingsView.panelHost("font"),
controller: settings,
font,
...localFontWindow?.queryLocalFonts ? {
queryLocalFonts: async () => (await localFontWindow.queryLocalFonts()).map((entry) => entry.family ?? "").filter(Boolean)
} : {},
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 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: () => configurationManager.reset(),
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,
responses: runtime.data.responses,
...runtime.assetCaches ? { assetCaches: runtime.assetCaches } : {},
applicationCaches: {
stats: async () => {
const users = runtime.users.cacheStats(), notifications = runtime.notificationController?.cacheStats() ?? { pages: 0, records: 0 }, bookmarks2 = runtime.bookmarkController?.cacheStats() ?? { bookmarks: 0, reactions: 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,
detail: `内存热缓存:${users.profiles} 个资料 · ${users.followLists} 份关注列表 · ${users.externalSnapshots} 份账户摘要;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,
detail: `内存热缓存:${bookmarks2.bookmarks} 条收藏 · ${bookmarks2.reactions} 条回应`
}),
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(), 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;
cacheSurface && runtime.shell.changes.subscribe(
() => cacheSurface.sync(),
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;
}, 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) : 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) => runtime.historyNavigation.snapshot.states[String(topicId)] ?? null,
restoreHistoryAnchor: async (topicId, anchor) => {
await runtime.historyNavigation.restore(topicId, anchor);
},
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 waitForQueuePrefetchIdle(abort.signal), await bundle.services.session.init({ background: !0 });
const session = bundle.services.session, stream = [...session.streamPostIds()], targetPost = postNumber ? session.postByNumber(postNumber) : void 0, targetPostId = Number(
targetPost?.id
), foundIndex = targetPostId ? stream.findIndex((postId) => Number(postId) === targetPostId) : -1, targetIndex = foundIndex >= 0 ? foundIndex : Math.max(
0,
Math.min(stream.length - 1, Number(postNumber ?? 1) - 1)
);
let ids = stream;
if (stream.length > 200 && Number(postNumber ?? 1) > 1) {
const before = Math.min(targetIndex, 100);
let start = Math.max(0, targetIndex - before), end = Math.min(stream.length, targetIndex + 200 - before);
start = Math.max(0, end - 200), end = Math.min(stream.length, start + 200), ids = stream.slice(start, end);
} else if (stream.length > 200 && targetIndex > 0) {
const before = Math.min(targetIndex, 80);
let localStart = Math.max(0, targetIndex - before), localEnd = Math.min(
stream.length,
localStart + 160
);
localStart = Math.max(0, localEnd - 160);
const selected = new Set(stream.slice(0, 40));
stream.slice(localStart, localEnd).forEach((id) => selected.add(id)), selected.size < 200 && stream.slice(localEnd, localEnd + 200 - selected.size).forEach((id) => selected.add(id)), ids = [...selected].slice(0, 200);
} else stream.length > 200 && (ids = stream.slice(0, 200));
let loadedCount = stream.reduce(
(count, postId) => count + +!!session.postById(Number(postId)),
0
);
for (let offset = 0; offset < ids.length; offset += session.pageSize) {
const batch = ids.slice(offset, offset + session.pageSize), loadedBefore = batch.reduce(
(count, postId) => count + +!!session.postById(Number(postId)),
0
);
await session.loadPostsByIds(
batch,
{ background: !0, maxAttempts: 1 }
);
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: () => waitForQueuePrefetchIdle(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: waitForQueuePrefetchIdle,
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.modal,
floating: !0,
positionAnchor: () => runtime.shell.activeValue?.topicActionRail?.downloadHistoryButton ?? null,
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;
const beforeDownloadNetwork = async (networkSignal, nestedReplies = !1) => {
await waitForTopicDownloadRequestHeadroom(
networkSignal,
nestedReplies
), backgroundNetworkRequestCount += 1;
}, 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} 个引用正文未能补齐`
);
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), 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),
presentation: Object.freeze({
theme: readerRoot.dataset.ldpTheme === "dark" ? "dark" : "light",
styleProperties: Object.freeze(readerStyleProperties),
structureColorsDisabled: readerRoot.classList.contains(
"ldp-structure-colors-disabled"
)
}),
stylesheet,
prepareCooked
});
} finally {
offlineKatex?.destroy();
}
return filenameScope ? Object.freeze({
...artifact,
filename: artifact.filename.replace(
/-lite-offline\.html$/,
`-${filenameScope}-lite-offline.html`
)
}) : artifact;
} 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();
}, openTopicDownloadManager = () => {
openQueue.openTopicDownloadManager();
}, runtime.scope.add(() => {
downloadCurrentTopic = null, openTopicDownloadManager = 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 webDavOptions = options.settings ? options.settings.webDav : void 0, 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,
bookmarks: runtime.bookmarkController,
queue: openQueue,
preferences: {
read: context.readPreferences,
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
)
}), 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",
trigger: notificationTrigger,
isOpen: () => runtime.notificationController.snapshot.open,
open: () => runtime.notificationController.open(),
close: () => runtime.notificationController.close()
}] : [],
...runtime.historyPanelView && historyTrigger ? [{
id: "history",
trigger: historyTrigger,
isOpen: () => !shell.view.root.querySelector(".ldp-history-popover")?.hidden,
open: () => runtime.historyPanelView.open(),
close: () => runtime.historyPanelView.close()
}] : [],
...runtime.bookmarkController && bookmarkTrigger ? [{
id: "bookmarks",
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,
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.click(), !0) : !1;
}, 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 triggerHeaderPanel(".ldp-notifications-toggle");
case "historyPanel":
return triggerHeaderPanel(".ldp-history-toggle");
case "bookmarksPanel":
return triggerHeaderPanel(".ldp-bookmarks-toggle");
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();
}
};
}
});
}
}, "c65c330d22a0d215701e8b9a38f78608dbd345baa290da79718ba6243eb4f3f5");
/* 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
});
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;
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 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);
}
}
}, "ead7b71ce6684b133ddaf00133eead7706b2fa9104aae06dbd69f3feb82f2f11");
/* Source: lite/src/cache/browser-asset-cache.ts */
runtime.register("src/cache/browser-asset-cache.js", function(module, exports, require) {
var browser_asset_cache_exports = {};
__export(browser_asset_cache_exports, {
READER_ASSET_CACHE_GROUPS: () => READER_ASSET_CACHE_GROUPS,
ReaderBrowserAssetCacheRepository: () => ReaderBrowserAssetCacheRepository
});
module.exports = __toCommonJS(browser_asset_cache_exports);
const READER_ASSET_CACHE_GROUPS = Object.freeze([
Object.freeze({
id: "avatar",
label: "头像",
cacheName: "linuxdo-enhanced-reader:avatars:v1"
}),
Object.freeze({
id: "emoji",
label: "表情",
cacheName: "linuxdo-enhanced-reader:emoji-images:v1"
}),
Object.freeze({
id: "original",
label: "原图",
cacheName: "linuxdo-enhanced-reader:lightbox-images:v1"
})
]), CACHE_STAT_BATCH_SIZE = 32;
function responseBytes(response) {
const header = response.headers.get("content-length"), declared = header === null ? Number.NaN : Number(header);
return Number.isFinite(declared) && declared >= 0 ? declared : response.blob().then((blob) => blob.size);
}
function emptyGroup(definition, state) {
return Object.freeze({
...definition,
count: 0,
bytes: 0,
state
});
}
class ReaderBrowserAssetCacheRepository {
#storage;
constructor(storage) {
this.#storage = storage;
}
async stats() {
let existing;
try {
existing = new Set(await this.#storage.keys());
} catch {
const errors2 = READER_ASSET_CACHE_GROUPS.map(({ label }) => `${label}缓存目录不可用`);
return Object.freeze({
count: 0,
bytes: 0,
groups: Object.freeze(
READER_ASSET_CACHE_GROUPS.map((group) => emptyGroup(group, "error"))
),
errors: Object.freeze(errors2)
});
}
const errors = [], groups = [];
for (const definition of READER_ASSET_CACHE_GROUPS) {
if (!existing.has(definition.cacheName)) {
groups.push(emptyGroup(definition, "missing"));
continue;
}
try {
const cache = await this.#storage.open(definition.cacheName), requests = await cache.keys();
let bytes = 0;
for (let offset = 0; offset < requests.length; offset += CACHE_STAT_BATCH_SIZE) {
const batch = requests.slice(
offset,
offset + CACHE_STAT_BATCH_SIZE
), sizes = await Promise.all(batch.map(async (request) => {
const response = await cache.match(request);
return response ? responseBytes(response) : 0;
}));
bytes += sizes.reduce((total, size) => total + size, 0);
}
groups.push(Object.freeze({
...definition,
count: requests.length,
bytes,
state: "available"
}));
} catch {
errors.push(`${definition.label}缓存统计失败`), groups.push(emptyGroup(definition, "error"));
}
}
return Object.freeze({
count: groups.reduce((total, group) => total + group.count, 0),
bytes: groups.reduce((total, group) => total + group.bytes, 0),
groups: Object.freeze(groups),
errors: Object.freeze(errors)
});
}
async clear() {
const deleted = [], missing = [], failed = [];
return await Promise.all(READER_ASSET_CACHE_GROUPS.map(async (definition) => {
try {
await this.#storage.delete(definition.cacheName) ? deleted.push(definition.id) : missing.push(definition.id);
} catch {
failed.push(definition.id);
}
})), Object.freeze({
deleted: Object.freeze(deleted),
missing: Object.freeze(missing),
failed: Object.freeze(failed)
});
}
}
}, "2d25f3dbe9f24884170a8d18afbc4fa6b627c60f06d2f902087cdd418d9a21c7");
/* Source: lite/src/cache/cache-coordination.ts */
runtime.register("src/cache/cache-coordination.js", function(module, exports, require) {
var cache_coordination_exports = {};
__export(cache_coordination_exports, {
BroadcastCacheCoordinationChannel: () => BroadcastCacheCoordinationChannel,
BrowserCacheCoordinationStatePort: () => BrowserCacheCoordinationStatePort,
CrossTabCacheCoordinator: () => CrossTabCacheCoordinator,
normalizeCacheInvalidation: () => normalizeCacheInvalidation
});
module.exports = __toCommonJS(cache_coordination_exports);
function positiveInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return value;
}
function uniqueStrings(values) {
if (!values) return;
const normalized = [...new Set(values.map(String).map((value) => value.trim()).filter(Boolean))].sort();
return normalized.length ? Object.freeze(normalized) : void 0;
}
function normalizeCacheInvalidation(query) {
if (query.all) return Object.freeze({ all: !0 });
const ids = uniqueStrings(query.ids), kinds = uniqueStrings(query.kinds), tags = uniqueStrings(query.tags);
return !ids && !kinds && !tags ? null : Object.freeze({
...ids ? { ids } : {},
...kinds ? { kinds } : {},
...tags ? { tags } : {}
});
}
function normalizeState(raw, now, staleAfterMs) {
const source = raw && typeof raw == "object" ? raw : {}, flights = Array.isArray(source.flights) ? source.flights.filter((flight) => !flight || typeof flight != "object" ? !1 : String(flight.token || "").length > 0 && String(flight.flightId || "").length > 0 && String(flight.ownerId || "").length > 0 && Number.isFinite(flight.epoch) && Number(flight.expiresAt) > now && Number(flight.heartbeatAt) > now - staleAfterMs) : [], failures = Array.isArray(source.failures) ? source.failures.filter((failure) => !failure || typeof failure != "object" ? !1 : String(failure.token || "").length > 0 && String(failure.flightId || "").length > 0 && Number(failure.expiresAt) > now && (failure.kind === "cloudflare" || failure.kind === "rate-limit") && Number.isSafeInteger(Number(failure.status)) && Number(failure.status) >= 400) : [];
return {
schemaVersion: 1,
epoch: Math.max(0, Math.floor(Number(source.epoch) || 0)),
updatedAt: Math.max(0, Number(source.updatedAt) || 0),
flights: flights.map((flight) => Object.freeze({
token: String(flight.token),
flightId: String(flight.flightId),
ownerId: String(flight.ownerId),
epoch: Math.max(0, Math.floor(Number(flight.epoch) || 0)),
heartbeatAt: Number(flight.heartbeatAt),
expiresAt: Number(flight.expiresAt)
})).sort((left, right) => left.expiresAt - right.expiresAt).slice(-128),
failures: failures.map((failure) => Object.freeze({
token: String(failure.token),
flightId: String(failure.flightId),
expiresAt: Number(failure.expiresAt),
status: Number(failure.status),
cloudflareMitigated: failure.cloudflareMitigated === !0,
kind: failure.kind
})).sort((left, right) => left.expiresAt - right.expiresAt).slice(-128)
};
}
function immutableState(state) {
return Object.freeze({
schemaVersion: 1,
epoch: state.epoch,
updatedAt: state.updatedAt,
flights: Object.freeze([...state.flights]),
failures: Object.freeze([...state.failures])
});
}
class BrowserCacheCoordinationStatePort {
atomic;
#storage;
#storageKey;
#lockName;
#locks;
#onError;
constructor(options) {
if (this.#storage = options.storage, this.#storageKey = String(options.storageKey).trim(), this.#lockName = String(options.lockName).trim(), !this.#storageKey || !this.#lockName)
throw new Error("cache coordination storageKey/lockName 不能为空");
this.#locks = options.locks ?? null, this.atomic = !!this.#locks, this.#onError = options.onError ?? (() => {
});
}
async read(now, staleAfterMs) {
return immutableState(this.#readMutable(now, staleAfterMs));
}
async transact(now, staleAfterMs, operation) {
const execute = async () => {
const state = this.#readMutable(now, staleAfterMs), result = await operation(state);
state.updatedAt = now;
try {
this.#storage.setItem(this.#storageKey, JSON.stringify(state));
} catch (error) {
throw this.#onError(error), error;
}
return result;
};
return this.#locks ? this.#locks.request(this.#lockName, { mode: "exclusive" }, execute) : execute();
}
#readMutable(now, staleAfterMs) {
try {
return normalizeState(
JSON.parse(this.#storage.getItem(this.#storageKey) || "null"),
now,
staleAfterMs
);
} catch (error) {
return this.#onError(error), normalizeState(null, now, staleAfterMs);
}
}
}
class BroadcastCacheCoordinationChannel {
#channel;
#listeners = /* @__PURE__ */ new Set();
#onError;
constructor(options) {
const name = String(options.name).trim();
if (!name) throw new Error("BroadcastChannel name 不能为空");
this.#onError = options.onError ?? (() => {
});
const factory = options.factory === void 0 ? typeof BroadcastChannel > "u" ? null : (channelName) => new BroadcastChannel(channelName) : options.factory;
let channel = null;
try {
channel = factory?.(name) ?? null, channel?.addEventListener("message", this.#onMessage);
} catch (error) {
this.#onError(error);
}
this.#channel = channel;
}
publish(message) {
try {
this.#channel?.postMessage(message);
} catch (error) {
this.#onError(error);
}
}
subscribe(listener) {
return this.#listeners.add(listener), () => this.#listeners.delete(listener);
}
close() {
this.#channel?.removeEventListener("message", this.#onMessage), this.#channel?.close(), this.#listeners.clear();
}
#onMessage = (event) => {
for (const listener of this.#listeners) listener(event.data);
};
}
class CrossTabCacheCoordinator {
coordinationMode;
#sourceId;
#channel;
#state;
#flightTtlMs;
#flightStaleMs;
#now;
#createId;
#onError;
#listeners = /* @__PURE__ */ new Set();
#waiters = /* @__PURE__ */ new Set();
#unsubscribe;
#closed = !1;
constructor(options) {
if (this.#sourceId = String(options.sourceId).trim(), !this.#sourceId) throw new Error("cache coordinator sourceId 不能为空");
this.#channel = options.channel, this.#state = options.state, this.#flightTtlMs = positiveInteger(options.flightTtlMs, "flightTtlMs"), this.#flightStaleMs = positiveInteger(options.flightStaleMs, "flightStaleMs"), this.#now = options.now ?? Date.now, this.#createId = options.createId ?? (() => `${this.#sourceId}:${this.#now().toString(36)}:${Math.random().toString(36).slice(2)}`), this.#onError = options.onError ?? (() => {
}), this.coordinationMode = options.state.atomic ? "atomic" : "mutation-only", this.#unsubscribe = this.#channel.subscribe((message) => this.#receive(message));
}
publish(query) {
const normalized = normalizeCacheInvalidation(query);
!normalized || this.#closed || this.#channel.publish(Object.freeze({
schemaVersion: 1,
sourceId: this.#sourceId,
type: "invalidate",
query: normalized
}));
}
subscribeInvalidation(listener) {
return this.#closed ? () => {
} : (this.#listeners.add(listener), () => this.#listeners.delete(listener));
}
async acquireFlight(rawToken) {
const token = String(rawToken).trim();
if (!token || !this.#state.atomic) return this.#uncoordinatedLease(token);
const now = this.#now();
try {
const lease = await this.#state.transact(now, this.#flightStaleMs, (state) => {
const existing = state.flights.find((flight2) => flight2.token === token);
if (existing)
return Object.freeze({
producer: !1,
token,
flightId: existing.flightId,
epoch: existing.epoch,
expiresAt: existing.expiresAt,
coordinated: !0
});
const flightId = this.#createId(), flight = Object.freeze({
token,
flightId,
ownerId: this.#sourceId,
epoch: state.epoch,
heartbeatAt: now,
expiresAt: now + this.#flightTtlMs
});
return state.flights.push(flight), Object.freeze({
producer: !0,
token,
flightId,
epoch: state.epoch,
expiresAt: flight.expiresAt,
coordinated: !0
});
});
return this.#publishState(), lease;
} catch (error) {
return this.#onError(error), this.#uncoordinatedLease(token);
}
}
async renewFlight(lease) {
if (!lease.coordinated || !lease.producer || !this.#state.atomic) return !1;
const now = this.#now();
try {
const renewed = await this.#state.transact(now, this.#flightStaleMs, (state) => {
const index = state.flights.findIndex((flight) => flight.token === lease.token && flight.flightId === lease.flightId && flight.ownerId === this.#sourceId);
if (index < 0) return !1;
const current = state.flights[index];
return current ? (state.flights[index] = Object.freeze({
...current,
heartbeatAt: now,
expiresAt: now + this.#flightTtlMs
}), !0) : !1;
});
return renewed && this.#publishState(), renewed;
} catch (error) {
return this.#onError(error), !1;
}
}
async releaseFlight(lease) {
if (!lease.coordinated || !lease.producer || !this.#state.atomic) return;
const now = this.#now();
try {
await this.#state.transact(now, this.#flightStaleMs, (state) => {
state.flights = state.flights.filter((flight) => flight.token !== lease.token || flight.flightId !== lease.flightId || flight.ownerId !== this.#sourceId);
}), this.#publishState();
} catch (error) {
this.#onError(error);
}
}
async waitForFlight(rawToken, signal, deadline = this.#now() + this.#flightTtlMs * 2) {
const token = String(rawToken).trim();
if (!token || !this.#state.atomic) return !0;
for (; !this.#closed && this.#now() < deadline; ) {
if (signal?.aborted) throw this.#abortError(signal);
let state;
try {
state = await this.#state.read(this.#now(), this.#flightStaleMs);
} catch (error) {
return this.#onError(error), !0;
}
const flight = state.flights.find((candidate) => candidate.token === token);
if (!flight) return !0;
await this.#wait(
Math.min(1e3, Math.max(25, flight.expiresAt - this.#now()), deadline - this.#now()),
signal
);
}
if (signal?.aborted) throw this.#abortError(signal);
return !1;
}
async failFlight(lease, failure) {
if (!lease.coordinated || !lease.producer || !this.#state.atomic) return !1;
const now = this.#now();
try {
const committed = await this.#state.transact(
now,
this.#flightStaleMs,
(state) => {
const index = state.flights.findIndex((flight) => flight.token === lease.token && flight.flightId === lease.flightId && flight.ownerId === this.#sourceId);
return index < 0 ? !1 : (state.flights.splice(index, 1), state.failures = state.failures.filter((candidate) => candidate.flightId !== lease.flightId), state.failures.push(Object.freeze({
token: lease.token,
flightId: lease.flightId,
expiresAt: now + this.#flightTtlMs,
status: failure.status,
cloudflareMitigated: failure.cloudflareMitigated,
kind: failure.kind
})), !0);
}
);
return committed && this.#publishState(), committed;
} catch (error) {
return this.#onError(error), !1;
}
}
async readFlightFailure(lease) {
if (!lease.coordinated || !this.#state.atomic) return null;
try {
const failure = (await this.#state.read(this.#now(), this.#flightStaleMs)).failures?.find((candidate) => candidate.token === lease.token && candidate.flightId === lease.flightId);
return failure ? Object.freeze({
status: failure.status,
cloudflareMitigated: failure.cloudflareMitigated,
kind: failure.kind
}) : null;
} catch (error) {
return this.#onError(error), null;
}
}
async invalidateWrites() {
if (!this.#state.atomic)
return this.#publishState(), 0;
const now = this.#now();
try {
const epoch = await this.#state.transact(now, this.#flightStaleMs, (state) => (state.epoch += 1, state.epoch));
return this.#publishState(), epoch;
} catch (error) {
return this.#onError(error), 0;
}
}
async commitFlight(lease, operation) {
if (!lease.coordinated || !this.#state.atomic)
return await operation(), !0;
try {
return await this.#state.transact(this.#now(), this.#flightStaleMs, async (state) => state.epoch === lease.epoch && state.flights.some((flight) => flight.token === lease.token && flight.flightId === lease.flightId && flight.ownerId === this.#sourceId) ? (await operation(), !0) : !1);
} catch (error) {
return this.#onError(error), !1;
}
}
close() {
this.#closed || (this.#closed = !0, this.#unsubscribe(), this.#channel.close(), this.#listeners.clear(), this.#notifyWaiters());
}
#receive(raw) {
if (!raw || typeof raw != "object") return;
const message = raw;
if (!(message.schemaVersion !== 1 || message.sourceId === this.#sourceId || message.type !== "invalidate" && message.type !== "state")) {
if (message.type === "invalidate") {
const query = normalizeCacheInvalidation(message.query ?? {});
if (query)
for (const listener of this.#listeners)
try {
listener(query);
} catch (error) {
this.#onError(error);
}
}
this.#notifyWaiters();
}
}
#publishState() {
this.#closed || (this.#channel.publish(Object.freeze({
schemaVersion: 1,
sourceId: this.#sourceId,
type: "state"
})), this.#notifyWaiters());
}
#notifyWaiters() {
for (const waiter of this.#waiters) waiter();
this.#waiters.clear();
}
#wait(milliseconds, signal) {
return new Promise((resolve, reject) => {
let settled = !1;
const finish = (error) => {
settled || (settled = !0, clearTimeout(timer), this.#waiters.delete(wake), signal?.removeEventListener("abort", abort), error !== void 0 ? reject(error) : resolve());
}, wake = () => finish(), abort = () => finish(this.#abortError(signal)), timer = setTimeout(wake, Math.max(0, milliseconds));
this.#waiters.add(wake), signal?.addEventListener("abort", abort, { once: !0 });
});
}
#abortError(signal) {
return signal?.reason ?? new DOMException("Aborted", "AbortError");
}
#uncoordinatedLease(token) {
return Object.freeze({
producer: !0,
token,
flightId: "",
epoch: 0,
expiresAt: 0,
coordinated: !1
});
}
}
}, "426097a26de19d79f96aa759e8005745ffdcb42c7015d0cc7750b2e276348878");
/* Source: lite/src/cache/cache-identity.ts */
runtime.register("src/cache/cache-identity.js", function(module, exports, require) {
var cache_identity_exports = {};
__export(cache_identity_exports, {
sharedCacheIdToken: () => sharedCacheIdToken
});
module.exports = __toCommonJS(cache_identity_exports);
function sharedCacheIdToken(value) {
const source = String(value);
let hash = 2166136261;
for (let index = 0; index < source.length; index += 1)
hash ^= source.charCodeAt(index), hash = Math.imul(hash, 16777619);
return (hash >>> 0).toString(36);
}
}, "fca985f86f39ba26872e25e42a855fbaff32da679df43591f16ca5e46a5e1157");
/* Source: lite/src/cache/discourse-application-cache-invalidation.ts */
runtime.register("src/cache/discourse-application-cache-invalidation.js", function(module, exports, require) {
var discourse_application_cache_invalidation_exports = {};
__export(discourse_application_cache_invalidation_exports, {
DiscourseApplicationCacheInvalidationCoordinator: () => DiscourseApplicationCacheInvalidationCoordinator
});
module.exports = __toCommonJS(discourse_application_cache_invalidation_exports);
var import_native_host_api = require("../discourse/native-host-api.js"), import_lifecycle = require("../kernel/lifecycle.js");
function record(value) {
if (!value || typeof value != "object" || Array.isArray(value)) return null;
const source = value, toJSON = source.toJSON;
if (typeof toJSON != "function") return source;
try {
return record(toJSON.call(value)) ?? source;
} catch {
return source;
}
}
function positiveId(value) {
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
function postIdentity(value) {
const envelope = record(value), post = record(envelope?.post) ?? envelope;
return Object.freeze({
postId: positiveId(post?.id),
topicId: positiveId(post?.topic_id) ?? positiveId(record(post?.topic)?.id)
});
}
function eventPost(value) {
const envelope = record(value);
return record(envelope?.post) ?? envelope;
}
class DiscourseApplicationCacheInvalidationCoordinator {
scope;
#options;
#pending = /* @__PURE__ */ new Set();
#scheduled = !1;
#flushPromise = null;
constructor(options) {
this.#options = options, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#bindReactionEvents(), this.scope.add(options.composerEvents.subscribe((event) => {
this.#onComposerEvent(event);
})), this.scope.add(() => {
this.#pending.clear(), this.#scheduled = !1;
});
}
flush() {
if (this.scope.destroyed) return this.#flushPromise ?? Promise.resolve();
if (this.#scheduled = !1, !this.#pending.size) return this.#flushPromise ?? Promise.resolve();
const tags = Object.freeze([...this.#pending].sort());
this.#pending.clear();
const transaction = (this.#flushPromise ?? Promise.resolve()).catch(() => {
}).then(async () => {
try {
await this.#options.cache.invalidate({ tags });
} catch (cause) {
this.#report(cause);
}
});
return this.#flushPromise = transaction, transaction.finally(() => {
this.#flushPromise === transaction && (this.#flushPromise = null), this.#pending.size && this.#schedule();
}), transaction;
}
destroy() {
this.scope.destroy();
}
#bindReactionEvents() {
const listener = (payload) => {
const post = eventPost(payload), identity = postIdentity(post ?? payload);
if (post)
try {
this.#options.onPostChanged?.(post);
} catch (cause) {
this.#report(cause);
}
this.#queue(["reactions-given"], identity);
};
this.scope.add((0, import_native_host_api.discourseNativeAppEventSubscription)(
this.#options.host,
"discourse-reactions:reaction-toggled",
listener,
(cause) => this.#report(cause)
));
}
#onComposerEvent(event) {
const identity = postIdentity(event.payload), fallbackTopicId = event.kind === "edited" ? positiveId(this.#options.currentTopicId()) : null;
this.#queue([], Object.freeze({
postId: identity.postId,
topicId: identity.topicId ?? fallbackTopicId
}));
}
#queue(baseTags, identity) {
if (!this.scope.destroyed) {
for (const tag of baseTags) this.#pending.add(tag);
identity.postId !== null && this.#pending.add(`post:${identity.postId}`), identity.topicId !== null && this.#pending.add(`topic:${identity.topicId}`), this.#schedule();
}
}
#schedule() {
this.#scheduled || this.scope.destroyed || !this.#pending.size || (this.#scheduled = !0, queueMicrotask(() => {
this.scope.destroyed || (this.#scheduled = !1, this.flush());
}));
}
#report(cause) {
try {
this.#options.onError?.(cause);
} catch {
}
}
}
}, "fe45e48ea5b25ee591dcd5cd1a9e7b072dbf709b3b0d371f04232238fac05d59");
/* Source: lite/src/cache/indexeddb-response-cache-store.ts */
runtime.register("src/cache/indexeddb-response-cache-store.js", function(module, exports, require) {
var indexeddb_response_cache_store_exports = {};
__export(indexeddb_response_cache_store_exports, {
IndexedDbResponseCacheStore: () => IndexedDbResponseCacheStore,
selectResponseCachePruneIds: () => selectResponseCachePruneIds
});
module.exports = __toCommonJS(indexeddb_response_cache_store_exports);
function positiveInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return value;
}
function matches(entry, query) {
return !!(query.all || query.ids?.includes(entry.id) || query.kinds?.includes(entry.kind) || query.tags?.some((tag) => entry.tags.includes(tag)));
}
function selectResponseCachePruneIds(entries, options) {
const sorted = [...entries].sort(
(left, right) => left.storedAt - right.storedAt || left.id.localeCompare(right.id)
), remove = /* @__PURE__ */ new Set();
let retainedEntries = 0, retainedBytes = 0;
for (const entry of sorted) {
if (entry.schemaVersion !== 1 || !Number.isFinite(entry.expiresAt) || entry.permanent !== !0 && entry.expiresAt <= options.now) {
remove.add(entry.id);
continue;
}
entry.permanent !== !0 && (retainedEntries += 1, retainedBytes += Math.max(0, Number(entry.bytes) || 0));
}
for (const entry of sorted)
if (!remove.has(entry.id)) {
if (retainedEntries <= options.maxEntries && retainedBytes <= options.maxBytes) break;
entry.permanent !== !0 && (remove.add(entry.id), retainedEntries -= 1, retainedBytes -= Math.max(0, Number(entry.bytes) || 0));
}
return Object.freeze([...remove]);
}
class IndexedDbResponseCacheStore {
#databaseName;
#storeName;
#operationTimeoutMs;
#maxEntries;
#maxBytes;
#factory;
#now;
#onError;
#databasePromise = null;
#writesSincePrune = 0;
#prunePromise = null;
constructor(options) {
if (this.#databaseName = String(options.databaseName).trim(), this.#storeName = String(options.storeName).trim(), !this.#databaseName || !this.#storeName)
throw new Error("IndexedDB databaseName/storeName 不能为空");
this.#operationTimeoutMs = positiveInteger(
options.operationTimeoutMs,
"operationTimeoutMs"
), this.#maxEntries = positiveInteger(options.maxEntries, "maxEntries"), this.#maxBytes = positiveInteger(options.maxBytes, "maxBytes"), this.#factory = options.factory === void 0 ? typeof indexedDB > "u" ? null : indexedDB : options.factory, this.#now = options.now ?? Date.now, this.#onError = options.onError ?? (() => {
});
}
async read(id) {
const result = await this.#transaction(
"readonly",
null,
(store, setValue) => {
const request = store.get(id);
request.onsuccess = () => {
const value = request.result;
setValue(value ?? null);
};
}
);
return result.ok || this.#report(result.error), result.ok ? result.value : null;
}
async write(entry) {
let result = await this.#put(entry);
!result.ok && this.#quotaError(result.error) && (await this.prune(!0), result = await this.#put(entry)), result.ok ? this.#recordSuccessfulWrite() : this.#report(result.error);
}
async invalidate(query) {
if (query.all) {
const result2 = await this.#transaction(
"readwrite",
!1,
(store) => {
store.clear();
}
);
return result2.ok || this.#report(result2.error), Object.freeze({ ok: result2.ok, error: result2.error });
}
const result = await this.#transaction(
"readwrite",
!1,
(store, setValue) => {
const request = store.openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
setValue(!0);
return;
}
const entry = cursor.value;
matches(entry, query) && cursor.delete(), cursor.continue();
};
}
);
return result.ok || this.#report(result.error), Object.freeze({ ok: result.ok, error: result.error });
}
async merge(id, update) {
let result = await this.#mergeEntry(id, update);
return !result.ok && this.#quotaError(result.error) && (await this.prune(!0), result = await this.#mergeEntry(id, update)), result.ok ? this.#recordSuccessfulWrite() : this.#report(result.error), result.ok ? result.value : null;
}
#mergeEntry(id, update) {
return this.#transaction(
"readwrite",
null,
(store, setValue) => {
const request = store.get(id);
request.onsuccess = () => {
try {
const next = update(
request.result ?? null
), write = store.put(next);
write.onsuccess = () => setValue(next);
} catch {
try {
store.transaction.abort();
} catch {
}
}
};
}
);
}
async records() {
const entries = await this.snapshotEntries();
return Object.freeze(entries.map((entry) => Object.freeze({
id: entry.id,
kind: entry.kind,
tags: Object.freeze([...entry.tags]),
storedAt: entry.storedAt,
expiresAt: entry.expiresAt,
bytes: Math.max(0, Number(entry.bytes) || 0),
...entry.permanent === !0 ? { permanent: !0 } : {}
})));
}
async snapshotEntries() {
const result = await this.#transaction(
"readonly",
[],
(store, setValue) => {
const request = store.getAll();
request.onsuccess = () => setValue(
Array.isArray(request.result) ? request.result : []
);
}
);
if (!result.ok) {
const error = result.error ?? new Error("IndexedDB cache directory unavailable");
throw this.#report(error), error;
}
return Object.freeze(result.value.map((entry) => Object.freeze({
...entry,
tags: Object.freeze([...entry.tags])
})));
}
async prune(forceQuotaRecovery = !1) {
const entries = await this.#allEntries(), ids = selectResponseCachePruneIds(entries, {
now: this.#now(),
maxEntries: this.#maxEntries,
maxBytes: this.#maxBytes
}), pruneIds = ids.length ? ids : forceQuotaRecovery ? [...entries].filter((entry) => entry.permanent !== !0).sort((left, right) => left.storedAt - right.storedAt).slice(0, Math.max(1, Math.ceil(entries.length / 4))).map((entry) => entry.id) : [];
if (!pruneIds.length) return;
const result = await this.#deleteIds(pruneIds);
result.ok || this.#report(result.error);
}
async close() {
await this.#prunePromise, (await this.#databasePromise)?.close(), this.#databasePromise = null;
}
#open() {
if (this.#databasePromise) return this.#databasePromise;
const factory = this.#factory;
if (!factory) return Promise.resolve(null);
let promise;
return promise = new Promise((resolve) => {
let settled = !1, request;
const finish = (database, error) => {
if (settled) {
database?.close();
return;
}
settled = !0, clearTimeout(timeoutId), error !== void 0 && this.#report(error), resolve(database);
}, timeoutId = setTimeout(
() => finish(null, new Error("IndexedDB open timeout")),
this.#operationTimeoutMs
);
try {
request = factory.open(this.#databaseName, 1);
} catch (error) {
finish(null, error);
return;
}
request.onupgradeneeded = () => {
const database = request.result, store = database.objectStoreNames.contains(this.#storeName) ? request.transaction?.objectStore(this.#storeName) : database.createObjectStore(this.#storeName, { keyPath: "id" });
store && (store.indexNames.contains("kind") || store.createIndex("kind", "kind"), store.indexNames.contains("tags") || store.createIndex("tags", "tags", { multiEntry: !0 }), store.indexNames.contains("storedAt") || store.createIndex("storedAt", "storedAt"));
}, request.onsuccess = () => {
const database = request.result;
database.onversionchange = () => {
database.close(), this.#databasePromise === promise && (this.#databasePromise = null);
}, finish(database);
}, request.onerror = () => finish(null, request.error), request.onblocked = () => finish(null, new Error("IndexedDB open blocked"));
}), this.#databasePromise = promise, promise.then((database) => {
!database && this.#databasePromise === promise && (this.#databasePromise = null);
}), promise;
}
async #transaction(mode, initialValue, operation) {
const database = await this.#open();
return database ? new Promise((resolve) => {
let settled = !1, value = initialValue, transaction = null;
const finish = (ok, error = null) => {
settled || (settled = !0, clearTimeout(timeoutId), resolve({ ok, value, error }));
}, timeoutId = setTimeout(() => {
try {
transaction?.abort();
} catch {
}
finish(!1, new Error("IndexedDB transaction timeout"));
}, this.#operationTimeoutMs);
try {
const activeTransaction = database.transaction(this.#storeName, mode);
transaction = activeTransaction, activeTransaction.oncomplete = () => finish(!0), activeTransaction.onerror = () => finish(!1, activeTransaction.error), activeTransaction.onabort = () => finish(!1, activeTransaction.error), operation(activeTransaction.objectStore(this.#storeName), (nextValue) => {
value = nextValue;
});
} catch (error) {
finish(!1, error);
}
}) : { ok: !1, value: initialValue, error: new Error("IndexedDB unavailable") };
}
#put(entry) {
return this.#transaction("readwrite", !1, (store, setValue) => {
const request = store.put(entry);
request.onsuccess = () => setValue(!0);
});
}
#recordSuccessfulWrite() {
if (this.#writesSincePrune += 1, this.#writesSincePrune < 32 || (this.#writesSincePrune = 0, this.#prunePromise)) return;
const pruning = this.prune().catch((error) => this.#report(error)).finally(() => {
this.#prunePromise === pruning && (this.#prunePromise = null);
});
this.#prunePromise = pruning;
}
async #allEntries() {
const result = await this.#transaction(
"readonly",
[],
(store, setValue) => {
const request = store.getAll();
request.onsuccess = () => setValue(
Array.isArray(request.result) ? request.result : []
);
}
);
return result.ok || this.#report(result.error), result.ok ? result.value : [];
}
#deleteIds(ids) {
return this.#transaction("readwrite", !1, (store, setValue) => {
for (const id of ids) store.delete(id);
setValue(!0);
});
}
#quotaError(error) {
return error instanceof DOMException ? error.name === "QuotaExceededError" : String(error?.name ?? "") === "QuotaExceededError";
}
#report(error) {
error != null && this.#onError(error);
}
}
}, "9e8743dbf8e5060e156abde4e4e0a5e5eb3815966802707f2a91a9c54243500b");
/* Source: lite/src/cache/reader-cache-management-surface.ts */
runtime.register("src/cache/reader-cache-management-surface.js", function(module, exports, require) {
var reader_cache_management_surface_exports = {};
__export(reader_cache_management_surface_exports, {
ReaderCacheManagementSurface: () => ReaderCacheManagementSurface
});
module.exports = __toCommonJS(reader_cache_management_surface_exports);
var import_lifecycle = require("../kernel/lifecycle.js"), import_reader_settings_dom = require("../settings/reader-settings-dom.js");
const CATEGORIES = Object.freeze([
{
id: "history",
title: "浏览历史",
help: "勾选“浏览历史”后再点下方清理,会删除阅读器保存的主题、最近阅读楼层和查看时间;不会删除浏览器本身的访问历史。",
retention: "最多 365 天"
},
{
id: "topics",
title: "帖子与楼层内容",
help: "勾选“帖子与楼层内容”后清理,会删除本机保存的帖子信息、楼层正文、回复关系、主题快照和相关接口数据;若当前正打开主题,会先安全结束旧会话并立即联网重建,避免旧快照在清理后写回。",
retention: "接口 7 天 · 快照 30 天"
},
{
id: "users",
title: "用户资料卡",
help: "勾选“用户资料卡”后清理,会删除用户名、简介、徽章、用户组、关注列表和账户摘要等可重新获取的资料快照;不会删除 Connect 近 400 天的本机信任观察历史,头像仍归“头像、表情与原图”单独管理。",
retention: "临时 5 分钟 · 持久 1 天"
},
{
id: "notifications",
title: "通知与消息",
help: "勾选“通知与消息”后清理,会删除通知分页、回复展开、已读状态和跳转位置等缓存;不会删除站点账号里的真实消息。",
retention: "最多 180 天"
},
{
id: "responses",
title: "收藏、回应与其他数据",
help: "勾选“收藏、回应与其他数据”后清理,会删除收藏列表、给出的回应与点赞、翻译和其他通用接口结果;不会撤销站点上的真实收藏或回应。",
retention: "按接口 8 分钟–180 天"
},
{
id: "assets",
title: "头像、表情与原图",
help: "勾选“头像、表情与原图”后清理,会删除统一图片响应和旧版头像、Boost 表情、实际查看过的原图资源;不会删除帖子中的线上图片。",
retention: "接口 30 天 · 兼容缓存 7–90 天"
}
]);
function categoryOf(record) {
return record.kind === "images" || record.tags.includes("images") ? "assets" : record.kind.includes("notification") || record.tags.includes("notifications") ? "notifications" : record.kind === "topics" || record.kind.startsWith("discourse-topic") || record.tags.some((tag) => tag.startsWith("topic:") || tag.startsWith("post:")) ? "topics" : record.kind === "users" || record.kind === "external-user-summary" || record.tags.includes("users") ? "users" : "responses";
}
function formatBytes(rawBytes) {
const bytes = Math.max(0, Number(rawBytes) || 0);
return bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
}
function assetCacheDetail(snapshot) {
return [...snapshot.groups.map((group) => {
const state = group.state === "error" ? "统计失败" : `${group.count} 条 · ${formatBytes(group.bytes)}`;
return `${group.label}:${state}`;
}), ...snapshot.errors].join(";");
}
function identityValue(record, key) {
const queryOffset = record.id.indexOf("?");
if (queryOffset < 0) return "";
try {
return new URLSearchParams(record.id.slice(queryOffset + 1)).get(key) ?? "";
} catch {
return "";
}
}
function taggedValues(records, prefix) {
const values = /* @__PURE__ */ new Set();
for (const record of records)
for (const tag of record.tags)
tag.startsWith(prefix) && tag.length > prefix.length && values.add(tag.slice(prefix.length));
return values;
}
function categoryBytes(records) {
return records.reduce(
(total, record) => total + Math.max(0, Number(record.bytes) || 0),
0
);
}
function withApplicationCacheDetail(detail, application) {
return application?.detail ? `${detail} · ${application.detail}` : detail;
}
function categoryStatText(id, records, history, assetCaches, application) {
const bytes = categoryBytes(records);
if (id === "history") {
const topicIds = new Set(history.map(
(entry) => Number(entry.topicId)
).filter((topicId) => Number.isSafeInteger(topicId) && topicId > 0));
return withApplicationCacheDetail(
`${topicIds.size} 个主题 · ${history.length} 条浏览记录 · ${formatBytes(new TextEncoder().encode(JSON.stringify(history)).byteLength)} · 本机保存`,
application
);
}
if (id === "topics") {
const snapshots = records.filter((record) => record.id.includes("|snapshot:topic:")).length;
return withApplicationCacheDetail(`${taggedValues(records, "topic:").size} 个主题 · 已缓存 ${taggedValues(records, "post:").size} 个楼层 · 快照 ${snapshots} / 接口 ${Math.max(0, records.length - snapshots)} · 共 ${records.length} 条记录 · ${formatBytes(bytes)} · 本机保存`, application);
}
if (id === "users") {
const users = new Set(taggedValues(records, "user:"));
for (const record of records) {
const username = identityValue(record, "username");
username && users.add(username.toLocaleLowerCase());
}
return withApplicationCacheDetail(
`${users.size} 个用户 · 共 ${records.length} 条记录 · ${formatBytes(bytes)} · 本机保存`,
application
);
}
if (id === "notifications") {
const pages = records.filter((record) => record.kind === "discourse-notification-page").length;
return withApplicationCacheDetail(
`分页 ${pages} / 回复展开 ${Math.max(0, records.length - pages)} · 共 ${records.length} 条记录 · ${formatBytes(bytes)} · 本机保存`,
application
);
}
if (id === "responses") {
let bookmarks = 0, reactions = 0;
for (const record of records) {
const collection = identityValue(record, "collection");
collection === "bookmarks" ? bookmarks += 1 : collection.includes("reaction") && (reactions += 1);
}
const other = Math.max(0, records.length - bookmarks - reactions);
return withApplicationCacheDetail(
`收藏 ${bookmarks} / 回应 ${reactions} / 其他 ${other} · 共 ${records.length} 条响应 · ${formatBytes(bytes)} · 本机保存`,
application
);
}
const groups = assetCaches?.groups.map((group) => `${group.label} ${group.count} 个(${formatBytes(group.bytes)})`) ?? [], legacyCount = assetCaches?.count ?? 0, legacyBytes = assetCaches?.bytes ?? 0;
return withApplicationCacheDetail([
...groups,
`接口图片 ${records.length} 个(${formatBytes(bytes)})`,
`共 ${legacyCount + records.length} 个资源 · ${formatBytes(legacyBytes + bytes)} · 本机保存`
].join(" · "), application);
}
class ReaderCacheManagementSurface {
scope;
#options;
#root;
#selects = /* @__PURE__ */ new Map();
#stats = /* @__PURE__ */ new Map();
#clear;
#refreshCurrent;
#configActions;
#configFile;
#configStatus;
#status;
#refreshToken = 0;
#busy = !1;
constructor(options) {
this.#options = options, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const document = options.document;
if (this.#root = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-settings-category-groups"
), options.configuration) {
const configSection = (0, import_reader_settings_dom.settingsSection)(
document,
"导入与导出设置",
"配置文件包含当前偏好(性能项仅保存目标值)、其他适用站点、翻译规则与 WebDAV 非敏感选项;导入后仍按当前设备、网络与 429 状态自适应。生效策略、请求与性能记录、阅读队列、浏览历史、帖子内容、缓存和账号数据不会写入文件;翻译 API Key、WebDAV 用户名和密码始终排除。",
!0
);
configSection.dataset.settingCategory = "config-management";
const actions = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-config-actions"
), exportButton = (0, import_reader_settings_dom.settingsButton)(
document,
"ldp-config-action ldp-config-export",
"",
"download",
"导出设置"
), importButton = (0, import_reader_settings_dom.settingsButton)(
document,
"ldp-config-action ldp-config-import",
"",
"upload",
"导入设置"
), resetButton = (0, import_reader_settings_dom.settingsButton)(
document,
"ldp-config-action ldp-config-reset danger",
"",
"rotate-ccw",
"恢复全部默认"
);
actions.append(exportButton, importButton, resetButton);
const file = (0, import_reader_settings_dom.settingsElement)(
document,
"input",
"ldp-config-file"
);
file.type = "file", file.accept = "application/json,.json", file.hidden = !0;
const status = (0, import_reader_settings_dom.settingsElement)(
document,
"small",
"ldp-cache-note ldp-config-status"
);
status.role = "status", status.setAttribute("aria-live", "polite");
const body = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-config-body"
);
body.append(actions, file, status), configSection.append(body), this.#root.append(configSection), this.#configActions = Object.freeze([
exportButton,
importButton,
resetButton
]), this.#configFile = file, this.#configStatus = status, this.scope.listen(
exportButton,
"click",
() => void this.#exportConfiguration()
), this.scope.listen(importButton, "click", () => {
file.value = "", file.click();
}), this.scope.listen(
file,
"change",
() => void this.#importConfiguration()
), this.scope.listen(
resetButton,
"click",
() => void this.#resetConfiguration()
);
} else
this.#configActions = Object.freeze([]), this.#configFile = null, this.#configStatus = null;
const section = (0, import_reader_settings_dom.settingsSection)(
document,
"本地缓存",
"阅读器会在本机保存浏览历史、帖子与楼层、用户资料、通知、收藏和图片等可复用数据。清理后不会影响站点账号内容,需要时会重新联网获取。",
!0
);
section.dataset.settingCategory = "local-cache";
const content = (0, import_reader_settings_dom.settingsElement)(
document,
"div",
"ldp-settings-category-content"
), list = (0, import_reader_settings_dom.settingsElement)(document, "div", "ldp-cache-list");
for (const definition of CATEGORIES) {
const row = (0, import_reader_settings_dom.settingsElement)(document, "label", "ldp-cache-row");
row.dataset.settingHelp = definition.help;
const input = (0, import_reader_settings_dom.settingsElement)(document, "input", "ldp-cache-select");
input.type = "checkbox", input.value = definition.id;
const copy = (0, import_reader_settings_dom.settingsCopy)(
document,
"",
definition.title
), stat = copy.querySelector("small") ?? (0, import_reader_settings_dom.settingsElement)(document, "small");
stat.dataset.cacheSize = definition.id, stat.parentElement || copy.append(stat);
const retention = (0, import_reader_settings_dom.settingsElement)(document, "em");
retention.dataset.cacheRetention = definition.id, retention.textContent = definition.retention, row.append(input, copy, retention), list.append(row), this.#selects.set(definition.id, input), this.#stats.set(definition.id, stat), this.scope.listen(input, "change", () => this.#syncButtons());
}
this.#clear = (0, import_reader_settings_dom.settingsButton)(
document,
"ldp-cache-clear",
"",
"trash",
"清理已选缓存"
), this.#clear.disabled = !0, this.#clear.dataset.settingHelp = "只清理上面已经勾选的缓存类型。不会退出登录,也不会删除站点上的帖子、消息或图片;清理后需要时会重新联网获取。", this.#status = (0, import_reader_settings_dom.settingsElement)(document, "small", "ldp-cache-note"), this.#status.role = "status", this.#status.setAttribute("aria-live", "polite"), content.append(list, this.#clear, this.#status), section.append(content), this.#root.append(section), options.host.append(this.#root), this.scope.add(() => this.#root.remove()), this.scope.listen(this.#clear, "click", () => void this.#clearSelected()), options.headerActions ? (this.#refreshCurrent = (0, import_reader_settings_dom.settingsButton)(
document,
"ldp-reader-refresh ldp-icon-btn",
"清除当前帖子缓存并刷新",
"rotate-ccw"
), options.headerActions.append(this.#refreshCurrent), this.scope.add(() => this.#refreshCurrent?.remove()), this.scope.listen(
this.#refreshCurrent,
"click",
() => void this.#clearCurrent()
)) : this.#refreshCurrent = null, this.sync(), this.refresh();
}
sync() {
this.#refreshCurrent && (this.#refreshCurrent.disabled = this.#busy || !this.#options.currentTopicAvailable());
}
async refresh() {
const token = ++this.#refreshToken;
let records, assetCaches, applicationCaches;
try {
[records, assetCaches, applicationCaches] = await Promise.all([
this.#options.responses.records(),
this.#options.assetCaches?.stats() ?? null,
this.#options.applicationCaches?.stats() ?? null
]);
} catch (cause) {
if (token !== this.#refreshToken || this.scope.destroyed) return !1;
this.#options.onError?.(cause);
for (const target of this.#stats.values())
target.textContent || (target.textContent = "统计失败");
return this.#status.textContent = "本地缓存统计读取失败;现有选择不受影响,可稍后重试。", !1;
}
if (token !== this.#refreshToken || this.scope.destroyed) return !1;
const grouped = new Map(
CATEGORIES.map(({ id }) => [id, []])
), history = this.#options.history.snapshot.entries;
for (const record of records)
grouped.get(categoryOf(record)).push(record);
if (assetCaches) {
const target = this.#stats.get("assets");
target && (target.title = assetCacheDetail(assetCaches));
}
for (const { id } of CATEGORIES) {
const target = this.#stats.get(id);
target && (target.textContent = categoryStatText(
id,
grouped.get(id) ?? [],
history,
assetCaches,
applicationCaches?.categories[id]
));
}
const total = history.length + records.length + (assetCaches?.count ?? 0) + Object.values(applicationCaches?.categories ?? {}).reduce(
(sum, category) => sum + Math.max(0, Number(category?.records) || 0),
0
);
return this.#status.textContent = assetCaches?.errors.length ? `共 ${total} 条本地记录;部分浏览器图片缓存统计失败` : `共 ${total} 条本地记录`, !0;
}
destroy() {
this.scope.destroy();
}
async #clearSelected() {
if (this.#busy) return;
const selected = new Set(
[...this.#selects].filter(([, input]) => input.checked).map(([id]) => id)
);
if (selected.size) {
this.#setBusy(!0, "正在清理已选缓存…");
try {
const failures = /* @__PURE__ */ new Map(), fail = (categories, message, cause) => {
for (const category of categories) {
const messages = failures.get(category) ?? [];
messages.push(message), failures.set(category, messages);
}
cause !== void 0 && this.#options.onError?.(cause);
};
if (selected.has("assets") && this.#options.assetCaches)
try {
const result = await this.#options.assetCaches.clear();
result.failed.length && fail(
["assets"],
`兼容图片缓存 ${result.failed.join(",")} 未清理`
);
} catch (cause) {
fail(["assets"], "兼容图片缓存未清理", cause);
}
const responseCategories = [...selected].filter(
(id) => id !== "history"
);
if (responseCategories.length)
try {
const query = responseCategories.length === CATEGORIES.length - 1 ? { all: !0 } : {
ids: (await this.#options.responses.records()).filter((record) => selected.has(categoryOf(record))).map((record) => record.id)
};
if (query.all || query.ids?.length) {
let report = null;
if (this.#options.responses.invalidateWithReport ? report = await this.#options.responses.invalidateWithReport(query) : await this.#options.responses.invalidate(query), report && !report.complete) {
for (const failure of report.failures)
this.#options.onError?.(failure.cause);
fail(responseCategories, "统一响应缓存未完整失效");
}
}
} catch (cause) {
fail(responseCategories, "统一响应缓存未清理", cause);
}
if (this.#options.applicationCaches)
try {
const result = await this.#options.applicationCaches.clear([...selected]);
for (const category of result.failed)
selected.has(category) && fail([category], "应用内存热缓存未清理");
} catch (cause) {
fail([...selected], "应用内存热缓存未清理", cause);
}
if (selected.has("history"))
try {
this.#options.history.clear();
} catch (cause) {
fail(["history"], "浏览历史未清理", cause);
}
if (selected.has("assets"))
try {
this.#options.clearImageObjectUrls?.();
} catch (cause) {
fail(["assets"], "当前图片对象未释放", cause);
}
for (const [id, input] of this.#selects)
selected.has(id) && !failures.has(id) && (input.checked = !1);
const refreshed = await this.refresh();
if (failures.size) {
const labels = CATEGORIES.filter(({ id }) => failures.has(id)).map(({ title }) => title);
this.#options.notify?.("部分本地缓存未能清理"), this.#status.textContent = `部分缓存已清理;未完成:${labels.join("、")}。已保留勾选,可重试。`;
} else
this.#options.notify?.("已清理所选本地缓存"), this.#status.textContent = refreshed ? "清理完成;需要的数据会按需重新获取。" : "清理已完成,但最新缓存统计读取失败;可稍后重新打开数据管理确认。";
} catch (cause) {
this.#options.onError?.(cause), this.#status.textContent = "清理失败,请稍后重试。";
} finally {
this.#setBusy(!1);
}
}
}
async #clearCurrent() {
if (!(this.#busy || !this.#options.currentTopicAvailable())) {
this.#setBusy(!0, "正在重建当前主题缓存…"), this.#refreshCurrent?.classList.add("is-refreshing");
try {
const result = await this.#options.clearCurrentTopic(), refreshed = await this.refresh();
if (result?.restored) {
const message = result.message ?? "当前主题刷新失败,已恢复刷新前内容;可稍后重试。";
this.#options.notify?.(message), this.#status.textContent = message;
return;
}
if (result && !result.complete) {
this.#options.notify?.(
"当前主题已重新获取,但部分旧缓存未能清理"
), this.#status.textContent = result.message ?? "当前主题已从原站重新获取,但部分旧缓存未能清理;可再次重试。";
return;
}
this.#options.notify?.("当前主题缓存已清理并重新获取"), this.#status.textContent = refreshed ? "当前主题已从原站重新获取。" : "当前主题已从原站重新获取,但最新缓存统计读取失败。";
} catch (cause) {
this.#options.onError?.(cause), this.#status.textContent = "当前主题重建失败,原缓存不会作为恢复前提。";
} finally {
this.#refreshCurrent?.classList.remove("is-refreshing"), this.#setBusy(!1);
}
}
}
async #exportConfiguration() {
const configuration = this.#options.configuration;
if (!(!configuration || this.#busy)) {
this.#setBusy(!0, "正在导出设置…"), this.#setConfigStatus("正在导出设置…");
try {
const payload = await configuration.export(), filename = configuration.filename?.(payload) ?? `awesome-linuxdo-reader-settings-${payload.exportedAt.slice(0, 10)}.json`;
await configuration.saveTextFile(
`${JSON.stringify(payload, null, 2)}
`,
filename
), this.#setConfigStatus(
`已导出 ${payload.settingsCount} 项偏好及安全扩展配置;性能项为目标值,不含运行时策略与日志。`
), this.#options.notify?.("设置配置已导出");
} catch (cause) {
this.#options.onError?.(cause), this.#setConfigStatus("导出失败,请稍后重试。");
} finally {
this.#setBusy(!1);
}
}
}
async #importConfiguration() {
const configuration = this.#options.configuration, file = this.#configFile?.files?.[0];
if (!(!configuration || !file || this.#busy)) {
this.#setBusy(!0);
try {
let imported;
try {
imported = configuration.prepare(
JSON.parse(await file.text())
);
} catch (cause) {
this.#options.onError?.(cause), this.#setConfigStatus(
"配置文件无效或版本不匹配,请选择本阅读器导出的 JSON 文件。"
);
return;
}
if (!await configuration.confirm({
title: "导入这份设置配置?",
message: `将使用“${file.name}”覆盖当前阅读器设置。`,
note: imported.includesPortableSections ? "性能目标会立即应用,并继续按当前设备、网络与 429 状态自适应;生效策略和请求/性能记录不会从文件导入。API Key、WebDAV 用户名和密码不会从文件导入;仅复用本机同地址已有凭据,新 WebDAV 地址会关闭定时同步。浏览历史、阅读队列和缓存不会改变。" : "这是旧版配置,只覆盖阅读器偏好(含性能目标);性能目标会立即应用并继续自适应,生效策略和请求/性能记录不会从文件导入。其他适用站点、翻译和 WebDAV 设置保持不变。浏览历史、阅读队列和缓存不会改变。",
confirmLabel: "导入设置",
tone: "primary",
icon: "upload"
}) || this.scope.destroyed) return;
this.#status.textContent = "正在导入设置…", this.#setConfigStatus("正在导入设置…");
try {
const result = await configuration.apply(imported), skipped = result.skippedSections.length ? `;当前环境跳过 ${result.skippedSections.join("、")}` : "", autoSync = result.webDavAutoSyncDisabled ? ";WebDAV 缺少本机同地址凭据,定时同步已关闭" : "";
this.#setConfigStatus(
`导入完成,已应用 ${result.settingsCount} 项偏好(性能项为目标值)${skipped}${autoSync}。`
), this.#options.notify?.("设置配置已导入");
} catch (cause) {
this.#options.onError?.(cause), this.#setConfigStatus(
cause instanceof Error && cause.message.includes("回滚不完整") ? "导入失败,部分设置可能未恢复;请重新打开设置逐项核对。" : "导入失败,当前设置保持不变。"
);
}
} catch (cause) {
this.#options.onError?.(cause), this.#setConfigStatus("无法确认导入,当前设置保持不变。");
} finally {
this.#setBusy(!1);
}
}
}
async #resetConfiguration() {
const configuration = this.#options.configuration;
if (!(!configuration || this.#busy)) {
this.#setBusy(!0);
try {
if (!await configuration.confirm({
title: "恢复全部默认设置?",
message: "当前偏好(含性能目标)、其他适用站点、翻译和 WebDAV 设置都会恢复默认。",
note: "性能项恢复推荐目标,运行时仍会自适应;请求/性能记录不会被删除。翻译 API Key、WebDAV 用户名和密码会从本机设置中清除;浏览历史、阅读队列、帖子缓存和账号数据不会被删除。",
confirmLabel: "恢复全部默认",
tone: "danger",
icon: "rotate-ccw"
}) || this.scope.destroyed) return;
this.#status.textContent = "正在恢复默认设置…", this.#setConfigStatus("正在恢复默认设置…"), await configuration.reset(), this.#setConfigStatus(
"全部设置已恢复默认;性能项将继续按运行环境自适应。"
), this.#options.notify?.("全部设置已恢复默认");
} catch (cause) {
this.#options.onError?.(cause), this.#setConfigStatus(
cause instanceof Error && cause.message.includes("回滚不完整") ? "恢复失败,部分设置可能未恢复;请重新打开设置逐项核对。" : "恢复失败,当前设置保持不变。"
);
} finally {
this.#setBusy(!1);
}
}
}
#setBusy(busy, status = "") {
this.#busy = busy;
for (const action of this.#configActions) action.disabled = busy;
this.#configFile && (this.#configFile.disabled = busy), this.#clear.disabled = busy || ![...this.#selects.values()].some((input) => input.checked), status && (this.#status.textContent = status), this.sync();
}
#syncButtons() {
this.#clear.disabled = this.#busy || ![...this.#selects.values()].some((input) => input.checked);
}
#setConfigStatus(message) {
this.#configStatus && (this.#configStatus.textContent = message);
}
}
}, "845798f70fff2618b6f72c99e285d61b48e64e02bce70730f0fafb3a2dbc8c31");
/* Source: lite/src/cache/response-repository.ts */
runtime.register("src/cache/response-repository.js", function(module, exports, require) {
var response_repository_exports = {};
__export(response_repository_exports, {
ResponseCacheFlightTimeoutError: () => ResponseCacheFlightTimeoutError,
ResponseCacheInvalidationError: () => ResponseCacheInvalidationError,
ResponseCacheSharedFlightFailureError: () => ResponseCacheSharedFlightFailureError,
ResponseRepository: () => ResponseRepository
});
module.exports = __toCommonJS(response_repository_exports);
var import_cache_identity = require("./cache-identity.js");
class ResponseCacheInvalidationError extends AggregateError {
report;
constructor(report) {
super(
report.failures.map((failure) => failure.cause),
"响应缓存失效未完整提交"
), this.name = "ResponseCacheInvalidationError", this.report = report;
}
}
class ResponseCacheSharedFlightFailureError extends Error {
status;
cloudflareMitigated;
kind;
sharedFlight = !0;
constructor(failure) {
super(
failure.kind === "cloudflare" ? "同一在途请求已遇到 Cloudflare 验证" : "同一在途请求已收到 429"
), this.name = "ResponseCacheSharedFlightFailureError", this.status = failure.status, this.cloudflareMitigated = failure.cloudflareMitigated, this.kind = failure.kind;
}
}
function sharedFlightFailure(error) {
if (!error || typeof error != "object") return null;
const candidate = error, status = Number(candidate.status ?? 0);
return candidate.cloudflareMitigated === !0 ? Object.freeze({
status: Number.isSafeInteger(status) && status >= 400 ? status : 403,
cloudflareMitigated: !0,
kind: "cloudflare"
}) : status === 429 ? Object.freeze({
status: 429,
cloudflareMitigated: !1,
kind: "rate-limit"
}) : null;
}
function nonNegativeFinite(value, name) {
if (!Number.isFinite(value) || value < 0)
throw new RangeError(`${name} 必须是非负有限数值`);
return value;
}
function positiveInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return value;
}
function normalizePolicy(policy) {
const id = String(policy.id).trim(), kind = String(policy.kind).trim();
if (!id) throw new Error("cache id 不能为空");
if (!kind) throw new Error("cache kind 不能为空");
const freshForMs = nonNegativeFinite(policy.freshForMs, "freshForMs"), retainForMs = nonNegativeFinite(policy.retainForMs, "retainForMs");
if (retainForMs < freshForMs) throw new RangeError("retainForMs 不能小于 freshForMs");
return Object.freeze({
id,
kind,
tags: Object.freeze(
[...new Set(policy.tags.map(String).map((tag) => tag.trim()).filter(Boolean))].sort()
),
freshForMs,
retainForMs,
persist: policy.persist,
...policy.permanent === !0 ? { permanent: !0 } : {}
});
}
function defaultEstimateBytes(value) {
if (value !== null && typeof value == "object" && (typeof Blob < "u" && value instanceof Blob || Object.prototype.toString.call(value) === "[object Blob]")) {
const size = Number(value.size);
if (Number.isFinite(size) && size >= 0) return size;
}
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value.byteLength;
try {
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
} catch {
return 0;
}
}
function matchesInvalidation(entry, query) {
return !!(query.all || query.ids?.includes(entry.id) || query.kinds?.includes(entry.kind) || query.tags?.some((tag) => entry.tags.includes(tag)));
}
class ResponseRepository {
#store;
#maxMemoryEntries;
#maxMemoryBytes;
#now;
#estimateBytes;
#mutationPort;
#flightPort;
#flightHeartbeatMs;
#flightWaitTimeoutMs;
#onPersistenceError;
#memory = /* @__PURE__ */ new Map();
#inflight = /* @__PURE__ */ new Map();
#writes = /* @__PURE__ */ new Map();
#epoch = 0;
constructor(options) {
this.#store = options.store, this.#maxMemoryEntries = positiveInteger(options.maxMemoryEntries, "maxMemoryEntries"), this.#maxMemoryBytes = positiveInteger(options.maxMemoryBytes, "maxMemoryBytes"), this.#now = options.now ?? Date.now, this.#estimateBytes = options.estimateBytes ?? defaultEstimateBytes, this.#mutationPort = options.mutationPort, this.#flightPort = options.flightPort, this.#flightHeartbeatMs = positiveInteger(
options.flightHeartbeatMs ?? 15e3,
"flightHeartbeatMs"
), this.#flightWaitTimeoutMs = positiveInteger(
options.flightWaitTimeoutMs ?? 65e3,
"flightWaitTimeoutMs"
), this.#onPersistenceError = options.onPersistenceError ?? (() => {
});
}
async read(rawPolicy) {
const policy = normalizePolicy(rawPolicy);
let entry = this.#memory.get(policy.id);
if (entry)
this.#memory.delete(policy.id), this.#memory.set(policy.id, entry);
else {
const epoch = this.#epoch;
try {
entry = await this.#store.read(policy.id) ?? void 0;
} catch (error) {
this.#onPersistenceError(error);
}
if (epoch !== this.#epoch)
entry = void 0;
else if (entry && this.#validEntry(entry, policy))
this.#remember(entry);
else {
if (entry)
try {
await this.#store.invalidate({ ids: [policy.id] });
} catch (error) {
this.#onPersistenceError(error);
}
entry = void 0;
}
}
if (!entry || !this.#validEntry(entry, policy))
return Object.freeze({ state: "miss" });
const age = Math.max(0, this.#now() - entry.storedAt);
return entry.permanent !== !0 && (age > policy.retainForMs || entry.expiresAt <= this.#now()) ? (await this.#invalidateWithReport({ ids: [policy.id] }, !0), Object.freeze({ state: "miss" })) : Object.freeze({
state: age <= policy.freshForMs ? "fresh" : "stale",
value: entry.value,
storedAt: entry.storedAt
});
}
/**
* 只从持久层读取,不接受当前标签页的 memory LRU 回退。
*
* 下载存档等用户明确保留的数据必须用它确认 IndexedDB 已真正提交;
* 普通响应仍使用 read() 的容错路径,持久层故障不会扩大成正文请求失败。
*/
async readPersistent(rawPolicy) {
const policy = normalizePolicy(rawPolicy), pending = this.#writes.get(policy.id);
pending && await pending;
let entry;
try {
entry = await this.#store.read(policy.id);
} catch (error) {
throw this.#onPersistenceError(error), error;
}
if (!entry || !this.#validEntry(entry, policy))
return Object.freeze({ state: "miss" });
const age = Math.max(0, this.#now() - entry.storedAt);
return entry.permanent !== !0 && (age > policy.retainForMs || entry.expiresAt <= this.#now()) ? Object.freeze({ state: "miss" }) : Object.freeze({
state: age <= policy.freshForMs ? "fresh" : "stale",
value: entry.value,
storedAt: entry.storedAt
});
}
getOrLoad(rawPolicy, loader, options = {}) {
const policy = normalizePolicy(rawPolicy);
options.signal?.throwIfAborted();
const cacheMode = options.cacheMode ?? "default", requestKey = `${cacheMode}:${policy.id}`, existing = this.#inflight.get(requestKey);
if (existing && !existing.controller.signal.aborted)
return this.#join(existing, options.signal);
const controller = new AbortController(), inflight = {
promise: Promise.resolve(void 0),
policy,
controller,
consumers: /* @__PURE__ */ new Set(),
unabortableConsumer: !1,
settled: !1
}, promise = this.#load(
policy,
loader,
{ ...options, signal: controller.signal },
cacheMode
).finally(() => {
inflight.settled = !0;
});
inflight.promise = promise, this.#inflight.set(requestKey, inflight);
const clearInflight = () => {
this.#inflight.get(requestKey)?.promise === promise && this.#inflight.delete(requestKey);
};
return promise.then(clearInflight, clearInflight), this.#join(inflight, options.signal);
}
#join(inflight, signal) {
if (!signal)
return inflight.unabortableConsumer = !0, inflight.promise;
if (signal.aborted) return Promise.reject(signal.reason);
const consumer = Symbol("cache-consumer");
return inflight.consumers.add(consumer), new Promise((resolve, reject) => {
let settled = !1;
const finish = (value) => {
settled || (settled = !0, signal.removeEventListener("abort", onAbort), inflight.consumers.delete(consumer), resolve(value));
}, fail = (cause) => {
settled || (settled = !0, signal.removeEventListener("abort", onAbort), inflight.consumers.delete(consumer), reject(cause));
}, onAbort = () => {
fail(signal.reason), !inflight.settled && !inflight.unabortableConsumer && inflight.consumers.size === 0 && !inflight.controller.signal.aborted && inflight.controller.abort(signal.reason);
};
signal.addEventListener("abort", onAbort, { once: !0 }), inflight.promise.then(finish, fail);
});
}
async write(rawPolicy, value) {
const policy = normalizePolicy(rawPolicy), storedAt = Math.max(
this.#now(),
(this.#memory.get(policy.id)?.storedAt ?? -1) + 1
), entry = Object.freeze({
schemaVersion: 1,
id: policy.id,
kind: policy.kind,
tags: policy.tags,
storedAt,
expiresAt: policy.permanent === !0 ? Number.MAX_SAFE_INTEGER : storedAt + policy.retainForMs,
bytes: Math.max(0, this.#estimateBytes(value)),
value,
...policy.permanent === !0 ? { permanent: !0 } : {}
});
if (this.#remember(entry), !policy.persist) return;
const write = (this.#writes.get(policy.id) ?? Promise.resolve()).catch(() => {
}).then(() => this.#store.write(entry)).catch((error) => {
this.#onPersistenceError(error);
});
this.#writes.set(policy.id, write), await write, this.#writes.get(policy.id) === write && this.#writes.delete(policy.id);
}
/** WebDAV 等受控迁移入口:仅在远端版本更新时保留原 storedAt 写回。 */
async restore(rawPolicy, value, storedAtValue) {
const policy = normalizePolicy(rawPolicy), storedAt = Math.max(0, Math.floor(storedAtValue));
if (!Number.isSafeInteger(storedAt) || policy.permanent !== !0 && storedAt + policy.retainForMs <= this.#now() || ((await this.read(policy)).storedAt ?? -1) >= storedAt) return;
const entry = Object.freeze({
schemaVersion: 1,
id: policy.id,
kind: policy.kind,
tags: policy.tags,
storedAt,
expiresAt: policy.permanent === !0 ? Number.MAX_SAFE_INTEGER : storedAt + policy.retainForMs,
bytes: Math.max(0, this.#estimateBytes(value)),
value,
...policy.permanent === !0 ? { permanent: !0 } : {}
});
if (this.#remember(entry), !policy.persist) return;
const write = (this.#writes.get(policy.id) ?? Promise.resolve()).catch(() => {
}).then(() => this.#store.write(entry)).catch((error) => {
this.#onPersistenceError(error);
});
this.#writes.set(policy.id, write), await write, this.#writes.get(policy.id) === write && this.#writes.delete(policy.id);
}
async merge(rawPolicy, incoming, mergeValues) {
const policy = normalizePolicy(rawPolicy), memory = this.#memory.get(policy.id), localValue = !this.#store.merge && memory && this.#validEntry(memory, policy) ? mergeValues(memory.value, incoming) : incoming;
let committed = Object.freeze({
schemaVersion: 1,
id: policy.id,
kind: policy.kind,
tags: policy.tags,
storedAt: Math.max(this.#now(), (memory?.storedAt ?? -1) + 1),
expiresAt: 0,
bytes: Math.max(0, this.#estimateBytes(localValue)),
value: localValue,
...policy.permanent === !0 ? { permanent: !0 } : {}
});
if (committed = Object.freeze({
...committed,
expiresAt: policy.permanent === !0 ? Number.MAX_SAFE_INTEGER : committed.storedAt + policy.retainForMs
}), this.#remember(committed), !policy.persist) return committed.value;
const previous = this.#writes.get(policy.id) ?? Promise.resolve();
let persisted = !1;
const write = previous.catch(() => {
}).then(async () => {
if (this.#store.merge) {
const merged = await this.#store.merge(
policy.id,
(current) => {
const value = current && this.#validEntry(current, policy) ? mergeValues(current.value, committed.value) : committed.value, storedAt = Math.max(
this.#now(),
(current?.storedAt ?? -1) + 1,
committed.storedAt
);
return Object.freeze({
schemaVersion: 1,
id: policy.id,
kind: policy.kind,
tags: policy.tags,
storedAt,
expiresAt: policy.permanent === !0 ? Number.MAX_SAFE_INTEGER : storedAt + policy.retainForMs,
bytes: Math.max(0, this.#estimateBytes(value)),
value,
...policy.permanent === !0 ? { permanent: !0 } : {}
});
}
);
if (!merged) return;
committed = merged;
} else
await this.#store.write(committed);
persisted = !0;
}).catch((error) => {
this.#onPersistenceError(error);
});
if (this.#writes.set(policy.id, write), await write, persisted)
try {
await this.#mutationPort?.publish({ ids: [policy.id] });
} catch (error) {
this.#onPersistenceError(error);
}
return this.#writes.get(policy.id) === write && (this.#writes.delete(policy.id), persisted && this.#remember(committed)), committed.value;
}
async invalidate(query, publish = !0) {
const report = await this.#invalidateWithReport(query, publish);
if (!report.complete) throw new ResponseCacheInvalidationError(report);
}
async invalidateWithReport(query, publish = !0) {
return this.#invalidateWithReport(query, publish);
}
async #invalidateWithReport(query, publish) {
this.#epoch += 1;
let memoryEntries = 0;
for (const [id, entry] of this.#memory)
matchesInvalidation(entry, query) && (this.#memory.delete(id), memoryEntries += 1);
this.#writes.size && await Promise.all(this.#writes.values());
const failures = [];
try {
await this.#flightPort?.invalidateWrites();
} catch (error) {
this.#onPersistenceError(error), failures.push(Object.freeze({ stage: "flight", cause: error }));
}
try {
const result = await this.#store.invalidate(query);
result && !result.ok && failures.push(Object.freeze({
stage: "store",
cause: result.error ?? new Error("持久响应缓存失效失败")
}));
} catch (error) {
this.#onPersistenceError(error), failures.push(Object.freeze({ stage: "store", cause: error }));
}
if (publish)
try {
await this.#mutationPort?.publish(query);
} catch (error) {
this.#onPersistenceError(error), failures.push(Object.freeze({ stage: "broadcast", cause: error }));
}
return Object.freeze({
memoryEntries,
failures: Object.freeze(failures),
complete: failures.length === 0
});
}
applyExternalInvalidation(query) {
this.#epoch += 1;
for (const [id, entry] of this.#memory)
matchesInvalidation(entry, query) && this.#memory.delete(id);
}
memoryStats() {
let bytes = 0;
for (const entry of this.#memory.values()) bytes += entry.bytes;
return Object.freeze({ entries: this.#memory.size, bytes });
}
async records() {
this.#writes.size && await Promise.all(this.#writes.values());
let persistent = [];
try {
persistent = await this.#store.records?.() ?? [];
} catch (error) {
throw this.#onPersistenceError(error), error;
}
const records = new Map(
persistent.map((entry) => [entry.id, entry])
);
for (const entry of this.#memory.values())
records.set(entry.id, Object.freeze({
id: entry.id,
kind: entry.kind,
tags: entry.tags,
storedAt: entry.storedAt,
expiresAt: entry.expiresAt,
bytes: entry.bytes,
...entry.permanent === !0 ? { permanent: !0 } : {}
}));
return Object.freeze([...records.values()]);
}
/**
* 仅供受控的数据迁移 owner 导出仍在保留期内的完整缓存记录。
* UI 目录继续使用 records(),避免普通调用方接触响应正文。
*/
async entries(query) {
this.#writes.size && await Promise.all(this.#writes.values());
let persistent = [];
try {
persistent = await this.#store.snapshotEntries?.() ?? [];
} catch (error) {
throw this.#onPersistenceError(error), error;
}
const now = this.#now(), entries = /* @__PURE__ */ new Map();
for (const entry of persistent)
entry.schemaVersion !== 1 || !matchesInvalidation(entry, query) || !Number.isFinite(entry.expiresAt) || entry.permanent !== !0 && entry.expiresAt <= now || entries.set(entry.id, entry);
for (const entry of this.#memory.values())
!matchesInvalidation(entry, query) || entry.permanent !== !0 && entry.expiresAt <= now || entries.set(entry.id, entry);
return Object.freeze([...entries.values()].sort((left, right) => right.storedAt - left.storedAt || left.id.localeCompare(right.id)).map((entry) => Object.freeze({
...entry,
tags: Object.freeze([...entry.tags])
})));
}
#validEntry(entry, policy) {
const tags = Array.isArray(entry.tags) && entry.tags.every((tag) => typeof tag == "string") ? [...new Set(entry.tags)].sort() : null;
return entry.schemaVersion === 1 && entry.id === policy.id && entry.kind === policy.kind && Number.isFinite(entry.storedAt) && entry.storedAt >= 0 && Number.isFinite(entry.expiresAt) && entry.expiresAt >= entry.storedAt && Number.isFinite(entry.bytes) && entry.bytes >= 0 && entry.permanent === !0 == (policy.permanent === !0) && tags !== null && tags.length === policy.tags.length && tags.every((tag, index) => tag === policy.tags[index]);
}
async #load(policy, loader, options, cacheMode) {
const throwIfAborted = () => options.signal?.throwIfAborted(), cached = cacheMode === "no-store" ? Object.freeze({ state: "miss" }) : await this.read(policy);
if (throwIfAborted(), cacheMode === "default" && cached.state === "fresh") return cached.value;
const cachedBeforeRequest = cached.storedAt ?? 0, flightToken = this.#flightPort && policy.persist && cacheMode !== "no-store" ? `v1:${(0, import_cache_identity.sharedCacheIdToken)(policy.id)}:${policy.id.length}` : "";
let lease = null, heartbeat = null;
try {
if (flightToken && this.#flightPort) {
const deadline = this.#now() + this.#flightWaitTimeoutMs;
for (; lease = await this.#flightPort.acquireFlight(flightToken), throwIfAborted(), !lease.producer; ) {
const released = await this.#flightPort.waitForFlight(
flightToken,
options.signal,
deadline
);
if (throwIfAborted(), !released) throw new ResponseCacheFlightTimeoutError();
const shared2 = await this.#readAfterFlight(
policy,
cacheMode,
cachedBeforeRequest
);
if (throwIfAborted(), shared2.state === "hit") return shared2.value;
const failure = await this.#flightPort.readFlightFailure(lease);
if (throwIfAborted(), failure) throw new ResponseCacheSharedFlightFailureError(failure);
}
const shared = await this.#readAfterFlight(
policy,
cacheMode,
cachedBeforeRequest
);
if (throwIfAborted(), shared.state === "hit") return shared.value;
if (lease.coordinated) {
const producerLease = lease;
heartbeat = setInterval(() => {
this.#flightPort?.renewFlight(producerLease).then((renewed) => {
!renewed && heartbeat !== null && (clearInterval(heartbeat), heartbeat = null);
}).catch((error) => {
this.#onPersistenceError(error), heartbeat !== null && (clearInterval(heartbeat), heartbeat = null);
});
}, this.#flightHeartbeatMs);
}
}
const epoch = this.#epoch, value = await loader(options.signal);
return throwIfAborted(), cacheMode !== "no-store" && epoch === this.#epoch && (lease && this.#flightPort ? await this.#flightPort.commitFlight(lease, () => this.write(policy, value)) : await this.write(policy, value)), value;
} catch (error) {
throwIfAborted();
const failure = lease?.producer ? sharedFlightFailure(error) : null;
if (failure && this.#flightPort)
try {
await this.#flightPort.failFlight(lease, failure);
} catch (coordinationError) {
this.#onPersistenceError(coordinationError);
}
const canFallback = options.canFallback?.(error) ?? !0;
if (options.allowStaleOnError !== !1 && cached.state === "stale" && canFallback) {
const value = cached.value;
return options.mapStaleFallback ? options.mapStaleFallback(value, error) : value;
}
throw error;
} finally {
if (heartbeat !== null && clearInterval(heartbeat), lease?.producer && this.#flightPort)
try {
await this.#flightPort.releaseFlight(lease);
} catch (error) {
this.#onPersistenceError(error);
}
}
}
async #readAfterFlight(policy, cacheMode, cachedBeforeRequest) {
this.#memory.delete(policy.id);
const latest = await this.read(policy);
return latest.state === "miss" ? Object.freeze({ state: "miss" }) : cacheMode === "refresh" ? (latest.storedAt ?? 0) > cachedBeforeRequest ? Object.freeze({ state: "hit", value: latest.value }) : Object.freeze({ state: "miss" }) : latest.state === "fresh" ? Object.freeze({ state: "hit", value: latest.value }) : Object.freeze({ state: "miss" });
}
#remember(entry) {
if (this.#memory.delete(entry.id), entry.permanent === !0 && entry.bytes > this.#maxMemoryBytes)
return;
this.#memory.set(entry.id, entry);
let bytes = 0;
for (const value of this.#memory.values()) bytes += value.bytes;
for (; this.#memory.size > this.#maxMemoryEntries || bytes > this.#maxMemoryBytes && this.#memory.size > 1; ) {
let evictedId;
for (const [id, value] of this.#memory)
if (value.permanent === !0) {
evictedId = id;
break;
}
if (evictedId ??= this.#memory.keys().next().value, evictedId === void 0) break;
const oldest = this.#memory.get(evictedId);
this.#memory.delete(evictedId), bytes -= oldest?.bytes ?? 0;
}
}
}
class ResponseCacheFlightTimeoutError extends Error {
constructor() {
super("等待共享缓存请求超时"), this.name = "TimeoutError";
}
}
}, "49141a0a146319e0a72079c4327b9d751bd5ed0cda3c5f9393adaaa4a5ddfa13");
/* Source: lite/src/cache/topic-snapshot-repository.ts */
runtime.register("src/cache/topic-snapshot-repository.js", function(module, exports, require) {
var topic_snapshot_repository_exports = {};
__export(topic_snapshot_repository_exports, {
TopicSnapshotRepository: () => TopicSnapshotRepository
});
module.exports = __toCommonJS(topic_snapshot_repository_exports);
var import_identifiers = require("../discourse/identifiers.js"), import_ingest_version = require("../discourse/ingest-version.js"), import_reply_tree = require("../dom/reply-tree.js");
function nonNegativeInteger(value, name) {
const numeric = Number(value);
if (!Number.isSafeInteger(numeric) || numeric < 0)
throw new RangeError(`${name} 必须是非负安全整数`);
return numeric;
}
function finiteTimestamp(value, name) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0)
throw new RangeError(`${name} 必须是非负有限时间戳`);
return numeric;
}
function localArchiveStatus(value) {
const status = Number(value);
if (status !== 403 && status !== 404 && status !== 410)
throw new RangeError("本地存档状态必须是 403、404 或 410");
return status;
}
function normalizeStreamPostIds(values) {
return (0, import_identifiers.discoursePostIdStream)(values ?? []);
}
function mergePostEntity(current, incoming) {
if (!current || current === incoming) return incoming;
const merged = {
...current
};
for (const [key, value] of Object.entries(
incoming
))
value !== void 0 && (merged[key] = value);
return Object.freeze(merged);
}
function validTreeSnapshot(value, topicId, posts, removedPostNumbers, onInvalid) {
if (value == null) return null;
const candidate = value;
let valid = candidate.schemaVersion === 2 && candidate.topicId === topicId && Number.isFinite(candidate.savedAt) && Number.isSafeInteger(candidate.expectedPostCount) && Number(candidate.expectedPostCount) >= 0 && !!candidate.tree && Number.isSafeInteger(candidate.tree.revision) && Number(candidate.tree.revision) >= 0 && Array.isArray(candidate.tree.relations) && Array.isArray(candidate.versions) && (candidate.removedVersions === void 0 || Array.isArray(candidate.removedVersions));
if (valid) {
const relations = candidate.tree.relations, relationNumbers = new Set(relations.map((relation) => relation.postNumber)), versionNumbers = /* @__PURE__ */ new Set();
for (const version of candidate.versions) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(version?.postNumber);
if (postNumber === null || versionNumbers.has(postNumber) || !relationNumbers.has(postNumber) || !Number.isFinite(version.observedAt) || version.observedAt < 0 || (0, import_ingest_version.normalizeDiscourseIngestSource)(version.source) === null) {
valid = !1;
break;
}
versionNumbers.add(postNumber);
}
versionNumbers.size !== relationNumbers.size && (valid = !1);
const removedNumbers = /* @__PURE__ */ new Set();
for (const version of candidate.removedVersions ?? []) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(version?.postNumber);
if (postNumber === null || relationNumbers.has(postNumber) || removedNumbers.has(postNumber) || !Number.isFinite(version.observedAt) || version.observedAt < 0 || (0, import_ingest_version.normalizeDiscourseIngestSource)(version.source) === null) {
valid = !1;
break;
}
removedNumbers.add(postNumber);
}
}
if (valid)
try {
const topology = new import_reply_tree.ReplyTreeTopology();
topology.replace(candidate.tree);
for (const entry of posts) {
const rawParentPostNumber = entry.value.reply_to_post_number, parentPostNumber = rawParentPostNumber == null || rawParentPostNumber === "" ? null : (0, import_identifiers.tryDiscoursePostNumber)(rawParentPostNumber);
if (parentPostNumber === null && !(rawParentPostNumber == null || rawParentPostNumber === ""))
throw new Error(
`Topic ${topicId} 楼层 #${entry.postNumber} 的缓存父楼层无效`
);
const storedParentPostNumber = topology.parentOf(entry.postNumber), parentWasRemoved = parentPostNumber !== null && removedPostNumbers.has(parentPostNumber);
if (storedParentPostNumber === void 0 || storedParentPostNumber !== parentPostNumber && !parentWasRemoved)
throw new Error(
`Topic ${topicId} 楼层 #${entry.postNumber} 的正文与回复树缓存关系不一致`
);
}
return value;
} catch (error) {
return onInvalid(error), null;
}
return onInvalid(new Error(`Topic ${topicId} 的内嵌回复树快照无效`)), null;
}
class TopicSnapshotRepository {
topicId;
authScope;
#responses;
#policy;
#archivePolicy;
#now;
#persistenceIdleMs;
#persistenceWait;
#onInvalidSnapshot;
#onInvalidTreeSnapshot;
#posts = /* @__PURE__ */ new Map();
#removedPosts = /* @__PURE__ */ new Map();
#unavailablePosts = /* @__PURE__ */ new Map();
#topic = null;
#unavailableTopic = null;
#topicObservedAt = 0;
#topicSource = null;
#streamPostIds = Object.freeze([]);
#streamObservedAt = 0;
#expectedPostCount = 0;
#tree = null;
#updatedAt = 0;
#restorePromise = null;
#pendingWrite = !1;
#persisting = null;
#persistenceReadyAt = 0;
#readPersistenceDelayMs = () => 0;
#archiveRecordKnown = !1;
constructor(options) {
const topicId = String((0, import_identifiers.discourseTopicId)(options.topicId)), authScope = (0, import_identifiers.discourseAuthScope)(options.authScope);
if (this.topicId = topicId, this.authScope = authScope, this.#responses = options.responseRepository, this.#now = options.now ?? Date.now, this.#persistenceIdleMs = finiteTimestamp(
options.persistenceIdleMs ?? 0,
"persistenceIdleMs"
), this.#persistenceWait = options.persistenceWait ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))), this.#onInvalidSnapshot = options.onInvalidSnapshot ?? (() => {
}), this.#onInvalidTreeSnapshot = options.onInvalidTreeSnapshot ?? (() => {
}), this.#policy = Object.freeze({
id: `${authScope}|snapshot:topic:${topicId}`,
kind: "topics",
tags: Object.freeze([`topic:${topicId}`]),
freshForMs: finiteTimestamp(options.freshForMs, "freshForMs"),
retainForMs: finiteTimestamp(options.retainForMs, "retainForMs"),
persist: !0
}), this.#archivePolicy = Object.freeze({
id: `${authScope}|snapshot:topic-archive:${topicId}`,
kind: "topics",
tags: Object.freeze([`topic:${topicId}`, "topic-local-archive"]),
freshForMs: finiteTimestamp(options.freshForMs, "freshForMs"),
retainForMs: finiteTimestamp(options.retainForMs, "retainForMs"),
persist: !0,
permanent: !0
}), this.#policy.retainForMs < this.#policy.freshForMs)
throw new RangeError("retainForMs 不能小于 freshForMs");
}
ingest(input) {
const observedAt = finiteTimestamp(input.observedAt ?? this.#now(), "observedAt");
let topicChanged = !1, streamChanged = !1, ignoredPosts = 0, acceptedPosts = 0;
const changedPostNumbers = [];
if (input.topic !== void 0 && (0, import_ingest_version.shouldReplaceDiscourseVersion)(
{ observedAt: this.#topicObservedAt, source: this.#topicSource },
{ observedAt, source: input.source }
) && (this.#topic = input.topic, this.#topicObservedAt = observedAt, this.#topicSource = input.source, topicChanged = !0, this.#unavailableTopic && observedAt >= this.#unavailableTopic.confirmedAt && (this.#unavailableTopic = null, topicChanged = !0)), input.streamPostIds !== void 0 && (0, import_ingest_version.shouldReplaceDiscourseVersion)(
{ observedAt: this.#streamObservedAt, source: null },
{ observedAt, source: input.source }
)) {
const blockedPostIds = new Set(
[...this.#removedPosts.values()].filter((removed) => !(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removed, {
observedAt,
source: input.source
})).map((removed) => removed.postId)
), stream = Object.freeze(
normalizeStreamPostIds(input.streamPostIds).filter((postId) => !blockedPostIds.has(postId))
);
(stream.length !== this.#streamPostIds.length || stream.some((postId, index) => postId !== this.#streamPostIds[index])) && (this.#streamPostIds = stream, streamChanged = !0), this.#streamObservedAt = observedAt;
}
if (input.expectedPostCount !== void 0) {
const blockedRemovalCount = [...this.#removedPosts.values()].filter((removed) => !(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removed, {
observedAt,
source: input.source
})).length;
this.#expectedPostCount = Math.max(
this.#expectedPostCount,
Math.max(
0,
nonNegativeInteger(input.expectedPostCount, "expectedPostCount") - blockedRemovalCount
)
);
}
for (const post of input.posts ?? []) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(post.post_number);
if (postNumber === null) {
ignoredPosts += 1;
continue;
}
const current = this.#posts.get(postNumber), removed = this.#removedPosts.get(postNumber);
if (removed && !(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removed, {
observedAt,
source: input.source
}) || current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(
current,
{ observedAt, source: input.source }
) || current && current.value === post && current.observedAt === observedAt) continue;
const value = mergePostEntity(current?.value, post);
this.#removedPosts.delete(postNumber);
const unavailable = this.#unavailablePosts.get(postNumber);
unavailable && observedAt >= unavailable.confirmedAt && this.#unavailablePosts.delete(postNumber), this.#posts.set(postNumber, Object.freeze({
postNumber,
observedAt,
source: input.source,
value
})), acceptedPosts += 1, changedPostNumbers.push(postNumber);
}
return (topicChanged || streamChanged || changedPostNumbers.length > 0) && (this.#updatedAt = Math.max(this.#updatedAt, observedAt), this.#queuePersistence()), Object.freeze({
acceptedPosts,
ignoredPosts,
changedPostNumbers: Object.freeze(changedPostNumbers.sort((left, right) => left - right)),
topicChanged,
streamChanged
});
}
markTopicUnavailable(rawStatus, confirmedAt = this.#now()) {
if (this.#topic === null && this.#posts.size === 0) return !1;
const next = Object.freeze({
status: localArchiveStatus(rawStatus),
confirmedAt: finiteTimestamp(confirmedAt, "confirmedAt")
});
return this.#unavailableTopic && this.#unavailableTopic.confirmedAt > next.confirmedAt ? !1 : (this.#unavailableTopic = next, this.#archiveRecordKnown = !0, this.#updatedAt = Math.max(this.#updatedAt, next.confirmedAt), this.#queuePersistence(), !0);
}
markPostUnavailable(rawPostNumber, rawStatus, confirmedAt = this.#now()) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawPostNumber);
if (postNumber === null || !this.#posts.has(postNumber)) return !1;
const next = Object.freeze({
postNumber,
status: localArchiveStatus(rawStatus),
confirmedAt: finiteTimestamp(confirmedAt, "confirmedAt")
}), current = this.#unavailablePosts.get(postNumber);
return current && current.confirmedAt > next.confirmedAt ? !1 : (this.#unavailablePosts.set(postNumber, next), this.#archiveRecordKnown = !0, this.#updatedAt = Math.max(this.#updatedAt, next.confirmedAt), this.#queuePersistence(), !0);
}
localArchiveState() {
return Object.freeze({
topic: this.#unavailableTopic,
posts: Object.freeze(
[...this.#unavailablePosts.values()].sort((left, right) => left.postNumber - right.postNumber)
)
});
}
hasLocalArchive() {
return this.#unavailableTopic !== null || this.#unavailablePosts.size > 0;
}
removePost(rawPostNumber, rawPostId, source, observedAt = this.#now()) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawPostNumber);
if (postNumber === null) throw new RangeError("postNumber 必须是正安全整数");
const postId = (0, import_identifiers.discoursePostId)(rawPostId), normalizedObservedAt = finiteTimestamp(observedAt, "observedAt"), current = this.#posts.get(postNumber), currentId = current?.value?.id;
if (currentId !== void 0 && (0, import_identifiers.discoursePostId)(currentId) !== postId)
throw new Error(`楼层 #${postNumber} 与 post.id ${postId} 不一致`);
const removed = this.#removedPosts.get(postNumber), next = Object.freeze({
postNumber,
postId,
observedAt: normalizedObservedAt,
source
});
if (current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(current, next) || removed && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(removed, next))
return Object.freeze({ removed: !1, postNumber, postId, streamChanged: !1 });
this.#posts.delete(postNumber), this.#unavailablePosts.delete(postNumber), this.#removedPosts.set(postNumber, next);
const nextStream = this.#streamPostIds.filter((candidate) => candidate !== postId), streamChanged = nextStream.length !== this.#streamPostIds.length;
return streamChanged && (this.#streamPostIds = Object.freeze(nextStream), this.#streamObservedAt = normalizedObservedAt), (current || streamChanged) && (this.#expectedPostCount = Math.max(0, this.#expectedPostCount - 1)), this.#updatedAt = Math.max(this.#updatedAt, normalizedObservedAt), this.#queuePersistence(), Object.freeze({ removed: !0, postNumber, postId, streamChanged });
}
restore() {
if (this.#restorePromise) return this.#restorePromise;
const promise = this.#restoreFromCache();
this.#restorePromise = promise;
const clearRestore = () => {
this.#restorePromise === promise && (this.#restorePromise = null);
};
return promise.then(clearRestore, clearRestore), promise;
}
topic() {
return this.#topic;
}
post(postNumber) {
const normalized = (0, import_identifiers.tryDiscoursePostNumber)(postNumber);
return normalized === null ? void 0 : this.#posts.get(normalized)?.value;
}
posts() {
return Object.freeze(
[...this.#posts.values()].sort((left, right) => left.postNumber - right.postNumber).map((entry) => entry.value)
);
}
streamPostIds() {
return this.#streamPostIds;
}
isFresh(now = this.#now()) {
return this.#unavailableTopic !== null ? !0 : this.#updatedAt > 0 ? Math.max(0, finiteTimestamp(now, "now") - this.#updatedAt) <= this.#policy.freshForMs : !1;
}
snapshot(now = this.#now()) {
return Object.freeze({
schemaVersion: 2,
topicId: this.topicId,
authScope: this.authScope,
savedAt: now,
updatedAt: this.#updatedAt,
expectedPostCount: this.#expectedPostCount,
topicObservedAt: this.#topicObservedAt,
topicSource: this.#topicSource,
topic: this.#topic,
streamObservedAt: this.#streamObservedAt,
streamPostIds: this.#streamPostIds,
posts: Object.freeze(
[...this.#posts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
removedPosts: Object.freeze(
[...this.#removedPosts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
unavailableTopic: this.#unavailableTopic,
unavailablePosts: Object.freeze(
[...this.#unavailablePosts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
tree: this.#tree
});
}
replyTreeSnapshotStore() {
return Object.freeze({
load: async (topicId) => String(topicId) !== this.topicId ? null : (await this.restore())?.snapshot.tree ?? null,
save: async (topicId, snapshot) => {
if (String(topicId) !== this.topicId || snapshot.topicId !== this.topicId)
throw new Error(`回复树快照 Topic ${topicId} 与仓储 ${this.topicId} 不匹配`);
this.#tree = snapshot, this.#expectedPostCount = Math.max(
this.#expectedPostCount,
snapshot.expectedPostCount
), this.#queuePersistence(), await this.flush();
}
});
}
async flush() {
for (; this.#pendingWrite || this.#persisting; )
this.#persisting || this.#startPersistence(), await this.#persisting;
}
/**
* 缓存重建失败时,把当前完整快照重新写回唯一 snapshot policy。
*
* 正常增量持久化仍走 merge/idle;这里仅用于缓存已失效、且必须立即恢复阅读器的
* rollback 边界,因此直接覆盖刚被清空的同 Topic 快照。
*/
persistCurrentSnapshot() {
return this.#responses.write(
this.hasLocalArchive() ? this.#archivePolicy : this.#policy,
this.snapshot()
);
}
setPersistenceDelayReader(reader) {
return this.#readPersistenceDelayMs = reader, () => {
this.#readPersistenceDelayMs === reader && (this.#readPersistenceDelayMs = () => 0);
};
}
async #restoreFromCache() {
const read = async (policy) => {
const cached2 = await this.#responses.read(
policy
);
if (cached2.state === "miss" || cached2.value === void 0) return null;
try {
const stored2 = this.#normalizeStoredSnapshot(cached2.value);
if (policy === this.#archivePolicy && !(stored2.unavailableTopic || stored2.unavailablePosts?.length))
throw new Error(`Topic ${this.topicId} 的永久存档缺少失效标记`);
return Object.freeze({
policy,
cached: cached2,
stored: stored2
});
} catch (error) {
return this.#onInvalidSnapshot(error), await this.#responses.invalidate({ ids: [policy.id] }), null;
}
}, [archived, regular] = await Promise.all([
read(this.#archivePolicy),
read(this.#policy)
]), restored = archived && regular ? archived.stored.updatedAt >= regular.stored.updatedAt ? archived : regular : archived ?? regular;
if (!restored) return null;
const { cached, policy: restoredPolicy, stored } = restored;
this.#archiveRecordKnown = archived !== null;
const staleArchive = archived !== null && restored === regular, discardedInvalidTree = cached.value.tree !== null && cached.value.tree !== void 0 && stored.tree === null, hadLocalState = this.#topic !== null || this.#posts.size > 0 || this.#removedPosts.size > 0 || this.#streamPostIds.length > 0 || this.#tree !== null || this.hasLocalArchive();
let topicFilled = !1, streamFilled = !1, removalFilled = !1, availabilityFilled = !1;
const addedPostNumbers = [];
this.#topic === null && stored.topic !== null && (this.#topic = stored.topic, this.#topicObservedAt = stored.topicObservedAt, this.#topicSource = stored.topicSource, topicFilled = !0), !this.#streamPostIds.length && stored.streamPostIds.length && (this.#streamPostIds = stored.streamPostIds, this.#streamObservedAt = stored.streamObservedAt, streamFilled = !0);
for (const removed of stored.removedPosts ?? []) {
const current = this.#posts.get(removed.postNumber);
if (current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(current, removed))
continue;
const existingRemoval = this.#removedPosts.get(removed.postNumber);
if (existingRemoval && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(existingRemoval, removed))
continue;
this.#posts.delete(removed.postNumber), this.#removedPosts.set(removed.postNumber, removed);
const nextStream = this.#streamPostIds.filter(
(postId) => postId !== removed.postId
);
nextStream.length !== this.#streamPostIds.length && (this.#streamPostIds = Object.freeze(nextStream), streamFilled = !0), removalFilled = !0;
}
for (const entry of stored.posts)
this.#removedPosts.has(entry.postNumber) || this.#posts.has(entry.postNumber) || (this.#posts.set(entry.postNumber, entry), addedPostNumbers.push(entry.postNumber));
stored.unavailableTopic && (!this.#unavailableTopic || stored.unavailableTopic.confirmedAt >= this.#unavailableTopic.confirmedAt) && (this.#unavailableTopic = stored.unavailableTopic, availabilityFilled = !0);
for (const unavailable of stored.unavailablePosts ?? []) {
if (!this.#posts.has(unavailable.postNumber)) continue;
const current = this.#unavailablePosts.get(unavailable.postNumber);
current && current.confirmedAt > unavailable.confirmedAt || (this.#unavailablePosts.set(unavailable.postNumber, unavailable), availabilityFilled = !0);
}
this.hasLocalArchive() && (this.#archiveRecordKnown = !0), this.#tree === null && stored.tree !== null && (this.#tree = stored.tree);
const blockedStoredPostCount = stored.posts.filter((entry) => this.#removedPosts.has(entry.postNumber)).length;
if (this.#expectedPostCount = Math.max(
this.#expectedPostCount,
Math.max(0, stored.expectedPostCount - blockedStoredPostCount)
), this.#updatedAt = Math.max(this.#updatedAt, stored.updatedAt), discardedInvalidTree && await this.#responses.invalidate({ ids: [restoredPolicy.id] }), (discardedInvalidTree || hadLocalState && (topicFilled || streamFilled || removalFilled || availabilityFilled || addedPostNumbers.length)) && this.#queuePersistence(), staleArchive) {
const cleanup = await this.#responses.invalidateWithReport({
ids: [this.#archivePolicy.id]
});
this.#archiveRecordKnown = !cleanup.complete, cleanup.complete || this.#queuePersistence();
}
return Object.freeze({
snapshot: stored,
addedPostNumbers: Object.freeze(addedPostNumbers.sort((left, right) => left - right)),
topicFilled,
streamFilled
});
}
#normalizeStoredSnapshot(value) {
if (!value || value.schemaVersion !== 2 || value.topicId !== this.topicId || value.authScope !== this.authScope || !Array.isArray(value.posts) || value.removedPosts !== void 0 && !Array.isArray(value.removedPosts) || value.unavailablePosts !== void 0 && !Array.isArray(value.unavailablePosts) || !Array.isArray(value.streamPostIds))
throw new Error(`Topic ${this.topicId} 的正文快照身份或 schema 无效`);
const removedPosts = /* @__PURE__ */ new Map();
for (const rawRemoval of value.removedPosts ?? [])
try {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawRemoval?.postNumber), source = (0, import_ingest_version.normalizeDiscourseIngestSource)(rawRemoval?.source);
if (postNumber === null || source === null || !Number.isFinite(rawRemoval.observedAt) || rawRemoval.observedAt < 0)
throw new Error("删除墓碑字段无效");
const removal = Object.freeze({
postNumber,
postId: (0, import_identifiers.discoursePostId)(rawRemoval.postId),
observedAt: rawRemoval.observedAt,
source
}), current = removedPosts.get(postNumber);
(!current || (0, import_ingest_version.shouldReplaceDiscourseVersion)(current, removal)) && removedPosts.set(postNumber, removal);
} catch (error) {
this.#onInvalidSnapshot(new Error(
`Topic ${this.topicId} 的楼层删除墓碑无效`,
{ cause: error }
));
}
const posts = /* @__PURE__ */ new Map(), blockedStreamPostIds = new Set(
[...removedPosts.values()].map((entry) => entry.postId)
);
for (const rawEntry of value.posts) {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawEntry?.postNumber), source = (0, import_ingest_version.normalizeDiscourseIngestSource)(rawEntry?.source), valuePostNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawEntry?.value?.post_number);
if (postNumber === null || valuePostNumber !== postNumber || source === null || !Number.isFinite(rawEntry.observedAt))
continue;
const postVersion = { observedAt: rawEntry.observedAt, source }, removal = removedPosts.get(postNumber);
if (removal) {
const rawPostId = rawEntry.value.id;
if (rawPostId !== void 0)
try {
const postId = (0, import_identifiers.discoursePostId)(rawPostId);
postId !== removal.postId && (blockedStreamPostIds.add(postId), this.#onInvalidSnapshot(new Error(
`Topic ${this.topicId} 楼层 #${postNumber} 的删除 post.id 不一致`
)));
} catch (error) {
this.#onInvalidSnapshot(error);
}
if (!(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removal, postVersion)) continue;
removedPosts.delete(postNumber), blockedStreamPostIds.delete(removal.postId);
}
const current = posts.get(postNumber);
(!current || (0, import_ingest_version.shouldReplaceDiscourseVersion)(
current,
postVersion
)) && posts.set(postNumber, Object.freeze({
postNumber,
observedAt: rawEntry.observedAt,
source,
value: rawEntry.value
}));
}
const topicSource = value.topicSource === null ? null : (0, import_ingest_version.normalizeDiscourseIngestSource)(value.topicSource);
if (value.topic !== null && topicSource === null)
throw new Error(`Topic ${this.topicId} 的正文来源无效`);
const normalizedPosts = Object.freeze(
[...posts.values()].sort((left, right) => left.postNumber - right.postNumber)
), unavailableTopic = value.unavailableTopic ? Object.freeze({
status: localArchiveStatus(value.unavailableTopic.status),
confirmedAt: finiteTimestamp(
value.unavailableTopic.confirmedAt,
"unavailableTopic.confirmedAt"
)
}) : null, unavailablePosts = /* @__PURE__ */ new Map();
for (const rawUnavailable of value.unavailablePosts ?? [])
try {
const postNumber = (0, import_identifiers.tryDiscoursePostNumber)(rawUnavailable?.postNumber);
if (postNumber === null || !posts.has(postNumber)) continue;
const unavailable = Object.freeze({
postNumber,
status: localArchiveStatus(rawUnavailable.status),
confirmedAt: finiteTimestamp(
rawUnavailable.confirmedAt,
"unavailablePost.confirmedAt"
)
});
if (posts.get(postNumber).observedAt > unavailable.confirmedAt) continue;
const current = unavailablePosts.get(postNumber);
(!current || current.confirmedAt <= unavailable.confirmedAt) && unavailablePosts.set(postNumber, unavailable);
} catch (error) {
this.#onInvalidSnapshot(error);
}
return Object.freeze({
schemaVersion: 2,
topicId: this.topicId,
authScope: this.authScope,
savedAt: finiteTimestamp(value.savedAt, "savedAt"),
updatedAt: finiteTimestamp(value.updatedAt, "updatedAt"),
expectedPostCount: nonNegativeInteger(value.expectedPostCount, "expectedPostCount"),
topicObservedAt: finiteTimestamp(value.topicObservedAt, "topicObservedAt"),
topicSource,
topic: value.topic,
streamObservedAt: finiteTimestamp(value.streamObservedAt, "streamObservedAt"),
streamPostIds: Object.freeze(
normalizeStreamPostIds(value.streamPostIds).filter((postId) => !blockedStreamPostIds.has(postId))
),
posts: normalizedPosts,
removedPosts: Object.freeze(
[...removedPosts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
unavailableTopic: unavailableTopic && unavailableTopic.confirmedAt >= value.topicObservedAt ? unavailableTopic : null,
unavailablePosts: Object.freeze(
[...unavailablePosts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
tree: validTreeSnapshot(
value.tree,
this.topicId,
normalizedPosts,
new Set(removedPosts.keys()),
this.#onInvalidTreeSnapshot
)
});
}
#mergeStoredSnapshots(storedValue, incomingValue) {
let stored;
try {
stored = this.#normalizeStoredSnapshot(storedValue);
} catch (error) {
return this.#onInvalidSnapshot(error), this.#normalizeStoredSnapshot(incomingValue);
}
const incoming = this.#normalizeStoredSnapshot(incomingValue), posts = /* @__PURE__ */ new Map(), removals = /* @__PURE__ */ new Map(), applyRemoval = (removal) => {
const post = posts.get(removal.postNumber);
if (post && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(post, removal)) return;
const current = removals.get(removal.postNumber);
current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(current, removal) || (posts.delete(removal.postNumber), removals.set(removal.postNumber, removal));
}, applyPost = (post) => {
const removal = removals.get(post.postNumber);
if (removal && !(0, import_ingest_version.shouldReplaceDiscourseRemoval)(removal, post)) return;
const current = posts.get(post.postNumber);
current && !(0, import_ingest_version.shouldReplaceDiscourseVersion)(current, post) || (removals.delete(post.postNumber), posts.set(post.postNumber, post));
};
for (const snapshot of [stored, incoming]) {
for (const removal of snapshot.removedPosts ?? []) applyRemoval(removal);
for (const post of snapshot.posts) applyPost(post);
}
let topic = stored.topic, topicObservedAt = stored.topicObservedAt, topicSource = stored.topicSource;
incoming.topic !== null && incoming.topicSource !== null && (0, import_ingest_version.shouldReplaceDiscourseVersion)(
{ observedAt: topicObservedAt, source: topicSource },
{
observedAt: incoming.topicObservedAt,
source: incoming.topicSource
}
) && (topic = incoming.topic, topicObservedAt = incoming.topicObservedAt, topicSource = incoming.topicSource);
const incomingStreamWins = incoming.streamObservedAt >= stored.streamObservedAt, streamPostIds = incomingStreamWins ? incoming.streamPostIds : stored.streamPostIds, blockedPostIds = new Set(
[...removals.values()].map((removal) => removal.postId)
), incomingStateWins = incoming.updatedAt >= stored.updatedAt, unavailableTopic = [stored.unavailableTopic, incoming.unavailableTopic].filter((value) => value != null).sort((left, right) => right.confirmedAt - left.confirmedAt)[0] ?? null, unavailablePosts = /* @__PURE__ */ new Map();
for (const snapshot of [stored, incoming])
for (const unavailable of snapshot.unavailablePosts ?? []) {
const current = unavailablePosts.get(unavailable.postNumber);
(!current || current.confirmedAt <= unavailable.confirmedAt) && unavailablePosts.set(unavailable.postNumber, unavailable);
}
for (const [postNumber, unavailable] of unavailablePosts) {
const post = posts.get(postNumber);
(!post || post.observedAt > unavailable.confirmedAt) && unavailablePosts.delete(postNumber);
}
const tree = stored.tree ? incoming.tree && incoming.tree.savedAt >= stored.tree.savedAt ? incoming.tree : stored.tree : incoming.tree;
return Object.freeze({
schemaVersion: 2,
topicId: this.topicId,
authScope: this.authScope,
savedAt: Math.max(stored.savedAt, incoming.savedAt),
updatedAt: Math.max(stored.updatedAt, incoming.updatedAt),
expectedPostCount: incomingStateWins && incoming.expectedPostCount > 0 ? incoming.expectedPostCount : stored.expectedPostCount,
topicObservedAt,
topicSource,
topic,
streamObservedAt: incomingStreamWins ? incoming.streamObservedAt : stored.streamObservedAt,
streamPostIds: Object.freeze(
streamPostIds.filter((postId) => !blockedPostIds.has(postId))
),
posts: Object.freeze(
[...posts.values()].sort(
(left, right) => left.postNumber - right.postNumber
)
),
removedPosts: Object.freeze(
[...removals.values()].sort(
(left, right) => left.postNumber - right.postNumber
)
),
unavailableTopic: unavailableTopic && unavailableTopic.confirmedAt >= topicObservedAt ? unavailableTopic : null,
unavailablePosts: Object.freeze(
[...unavailablePosts.values()].sort((left, right) => left.postNumber - right.postNumber)
),
tree
});
}
#queuePersistence() {
this.#pendingWrite = !0, this.#persistenceReadyAt = Math.max(
this.#persistenceReadyAt,
this.#now() + this.#persistenceIdleMs
), this.#persisting || this.#startPersistence();
}
async #waitForPersistenceWindow() {
for (; this.#pendingWrite; ) {
const repositoryDelay = Math.max(
0,
this.#persistenceReadyAt - this.#now()
), rawActivityDelay = Number(
this.#readPersistenceDelayMs(this.#persistenceIdleMs)
), activityDelay = Number.isFinite(rawActivityDelay) ? Math.max(0, rawActivityDelay) : 0, delayMs = Math.max(repositoryDelay, activityDelay);
if (delayMs <= 0) return;
await this.#persistenceWait(delayMs);
}
}
#startPersistence() {
this.#persisting = Promise.resolve().then(async () => {
for (; this.#pendingWrite; ) {
if (await this.#waitForPersistenceWindow(), !this.#pendingWrite) continue;
this.#pendingWrite = !1;
const snapshot = this.snapshot(), archived = !!(snapshot.unavailableTopic || snapshot.unavailablePosts?.length), policy = archived ? this.#archivePolicy : this.#policy;
await this.#responses.merge(
policy,
snapshot,
(stored, incoming) => this.#mergeStoredSnapshots(stored, incoming)
), archived ? this.#archiveRecordKnown = !0 : this.#archiveRecordKnown && (await this.#responses.invalidate({ ids: [this.#archivePolicy.id] }), this.#archiveRecordKnown = !1);
}
}).finally(() => {
this.#persisting = null, this.#pendingWrite || (this.#persistenceReadyAt = 0), this.#pendingWrite && this.#startPersistence();
});
}
}
}, "ad4eb8518b73dcfd075e62a076d04bdaa84fdbf16470212551d99eaae2394cb5");
/* Source: lite/src/discourse/identifiers.ts */
runtime.register("src/discourse/identifiers.js", function(module, exports, require) {
var identifiers_exports = {};
__export(identifiers_exports, {
discourseAuthScope: () => discourseAuthScope,
discoursePostId: () => discoursePostId,
discoursePostIdStream: () => discoursePostIdStream,
discoursePostIds: () => discoursePostIds,
discoursePostNumber: () => discoursePostNumber,
discoursePostNumbers: () => discoursePostNumbers,
discoursePostReference: () => discoursePostReference,
discourseReplyCursor: () => discourseReplyCursor,
discourseTopicId: () => discourseTopicId,
tryDiscoursePostId: () => tryDiscoursePostId,
tryDiscoursePostNumber: () => tryDiscoursePostNumber,
tryDiscourseTopicId: () => tryDiscourseTopicId
});
module.exports = __toCommonJS(identifiers_exports);
function positiveInteger(value, name) {
const numeric = Number(value);
if (!Number.isSafeInteger(numeric) || numeric < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return numeric;
}
function discourseTopicId(value) {
return positiveInteger(value, "topicId");
}
function discoursePostId(value) {
return positiveInteger(value, "postId");
}
function discoursePostNumber(value) {
return positiveInteger(value, "postNumber");
}
function discourseReplyCursor(value) {
const numeric = Number(value ?? 0);
if (!Number.isSafeInteger(numeric) || numeric < 0)
throw new RangeError("replyCursor 必须是非负安全整数");
return numeric;
}
function discourseAuthScope(value) {
const normalized = String(value ?? "").trim();
if (!normalized) throw new Error("authScope 不能为空");
return normalized;
}
function tryDiscourseTopicId(value) {
try {
return discourseTopicId(value);
} catch {
return null;
}
}
function tryDiscoursePostId(value) {
try {
return discoursePostId(value);
} catch {
return null;
}
}
function tryDiscoursePostNumber(value) {
try {
return discoursePostNumber(value);
} catch {
return null;
}
}
function discoursePostReference(input) {
const postNumber = discoursePostNumber(input.post_number), parent = tryDiscoursePostNumber(input.reply_to_post_number);
if (parent === postNumber)
throw new Error(`楼层 #${postNumber} 不能回复自身`);
return Object.freeze({
postId: tryDiscoursePostId(input.id),
topicId: tryDiscourseTopicId(input.topic_id),
postNumber,
replyToPostNumber: parent
});
}
function discoursePostIds(values) {
const normalized = [...new Set(values.map(discoursePostId))].sort((left, right) => left - right);
if (!normalized.length) throw new Error("postIds 不能为空");
return Object.freeze(normalized);
}
function discoursePostIdStream(values) {
const seen = /* @__PURE__ */ new Set(), stream = [];
for (const value of values) {
const postId = discoursePostId(value);
seen.has(postId) || (seen.add(postId), stream.push(postId));
}
return Object.freeze(stream);
}
function discoursePostNumbers(values) {
const normalized = [...new Set(values.map(discoursePostNumber))].sort((left, right) => left - right);
if (!normalized.length) throw new Error("postNumbers 不能为空");
return Object.freeze(normalized);
}
}, "df62eb641b1752e66e82842fdbbd0c8d27ee6ca3384dbfb60a23197a629599a9");
/* Source: lite/src/discourse/ingest-version.ts */
runtime.register("src/discourse/ingest-version.js", function(module, exports, require) {
var ingest_version_exports = {};
__export(ingest_version_exports, {
discourseObservedAt: () => discourseObservedAt,
normalizeDiscourseIngestSource: () => normalizeDiscourseIngestSource,
shouldReplaceDiscourseRemoval: () => shouldReplaceDiscourseRemoval,
shouldReplaceDiscourseVersion: () => shouldReplaceDiscourseVersion
});
module.exports = __toCommonJS(ingest_version_exports);
const SOURCE_RANK = Object.freeze({
"loader-batch": 1,
"topic-json": 2,
"target-refresh": 3,
"action-response": 4,
"message-bus": 5
});
function discourseObservedAt(value, name = "observedAt") {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric < 0)
throw new RangeError(`${name} 必须是非负有限时间戳`);
return numeric;
}
function normalizeDiscourseIngestSource(value) {
return typeof value == "string" && Object.hasOwn(SOURCE_RANK, value) ? value : null;
}
function shouldReplaceDiscourseVersion(current, next) {
return current ? next.source === "loader-batch" && current.source !== null && current.source !== "loader-batch" ? !1 : next.observedAt !== current.observedAt ? next.observedAt > current.observedAt : current.source === null ? !0 : SOURCE_RANK[next.source] >= SOURCE_RANK[current.source] : !0;
}
function shouldReplaceDiscourseRemoval(current, next) {
return current.source === "action-response" && (next.source === "loader-batch" || next.source === "topic-json") ? !1 : shouldReplaceDiscourseVersion(current, next);
}
}, "329717cc7f84a32026374e649cf6c00b22be5286b8e69dc5d2178a55f025dcd1");
/* Source: lite/src/discourse/native-composer.ts */
runtime.register("src/discourse/native-composer.js", function(module, exports, require) {
var native_composer_exports = {};
__export(native_composer_exports, {
DiscourseComposerCoordinator: () => DiscourseComposerCoordinator,
DiscourseComposerEventPort: () => DiscourseComposerEventPort,
DiscourseComposerHostIsolation: () => DiscourseComposerHostIsolation,
DiscourseComposerTopicSyncController: () => DiscourseComposerTopicSyncController
});
module.exports = __toCommonJS(native_composer_exports);
var import_identifiers = require("./identifiers.js"), import_native_post_model_factory = require("./native-post-model-factory.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_repeat_action_gate = require("../kernel/repeat-action-gate.js"), import_signal = require("../kernel/signal.js"), import_value_record = require("../kernel/value-record.js");
function modelValue(model, key) {
const value = (0, import_value_record.valueRecord)(model);
if (!value) return;
const getter = value.get;
return typeof getter == "function" ? getter.call(value, key) : value[key];
}
function composerModelTopicId(model) {
const post = modelValue(model, "post"), topic = modelValue(model, "topic") ?? modelValue(post, "topic");
return Number(
modelValue(topic, "id") ?? modelValue(model, "topic_id") ?? modelValue(model, "topicId") ?? modelValue(post, "topic_id") ?? modelValue(post, "topicId")
);
}
function setModelValue(model, key, value) {
const target = (0, import_value_record.valueRecord)(model);
if (!target) throw new Error("Discourse composer model 不可写");
const setter = target.set;
typeof setter == "function" ? setter.call(target, key, value) : target[key] = value;
}
function moduleDefault(host, name) {
const module2 = (0, import_value_record.valueRecord)(host.lookupModule(name)), value = (0, import_value_record.valueRecord)(module2?.default);
if (!value) throw new Error(`Discourse 原生模块未就绪:${name}`);
return value;
}
function normalizedDraftSequence(value) {
const numeric = Number(value);
if (!Number.isSafeInteger(numeric) || numeric < 0)
throw new Error("Topic 缺少合法 draft_sequence");
return numeric;
}
function normalizedDraftKey(value) {
const normalized = String(value ?? "").trim();
if (!normalized) throw new Error("Topic 缺少 draft_key");
return normalized;
}
function normalizedMentionUsername(value) {
return String(value ?? "").trim().replace(/^@+/, "");
}
function rawMentionsUsername(raw, usernameValue) {
const username = normalizedMentionUsername(usernameValue);
if (!raw || !username) return !1;
const escaped = username.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(
`(^|[^A-Za-z0-9_@-])@${escaped}(?=$|[^A-Za-z0-9_-])`,
"i"
).test(raw);
}
function composerTextInsertion(currentValue, blockValue, startValue, endValue) {
const current = String(currentValue ?? ""), start = Math.min(Math.max(0, startValue), current.length), end = Math.min(Math.max(start, endValue), current.length), before = current.slice(0, start), after = current.slice(end);
let inserted = String(blockValue ?? "").trim();
return before && !/\s$/.test(before) && (inserted = `
${inserted}`), after && !/^\s/.test(after) && (inserted += `
`), Object.freeze({
next: `${before}${inserted}${after}`,
inserted,
cursor: before.length + inserted.length
});
}
function composerRequestKey(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topic.id), post = (0, import_identifiers.discoursePostReference)(input.post);
return JSON.stringify([
"reply",
topicId,
post.postId,
post.postNumber,
String(input.initialRaw ?? "").trim(),
String(input.initialRichHtml ?? "").trim(),
normalizedMentionUsername(input.dedupeMention),
input.replaceRaw === !0
]);
}
function composerEditRequestKey(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topic.id), post = (0, import_identifiers.discoursePostReference)(input.post);
return JSON.stringify([
"edit",
topicId,
post.postId,
post.postNumber
]);
}
function privateMessageRequestKey(username) {
return JSON.stringify(["private-message", username]);
}
function parseDraft(value) {
if (value == null || value === "") return { reply: "" };
const parsed = JSON.parse(String(value));
if (!parsed || typeof parsed != "object" || Array.isArray(parsed))
throw new Error("Discourse composer draft 格式非法");
const source = parsed;
return Object.freeze({
reply: String(source.reply ?? ""),
...source.whisper === void 0 ? {} : { whisper: source.whisper }
});
}
class DiscourseComposerCoordinator {
scope;
#host;
#document;
#models;
#isolation;
#waitForDelay;
#composerOpenTimeoutMs;
#onError;
#requests = /* @__PURE__ */ new Map();
#queue = Promise.resolve();
#session = null;
#submitPromise = null;
#windowPort = null;
constructor(options) {
this.#host = options.host, this.#document = options.document ?? null, this.#models = new import_native_post_model_factory.DiscourseNativePostModelFactory(options.host), this.#isolation = options.isolation ?? null, this.#waitForDelay = options.waitForDelay ?? ((milliseconds) => new Promise((resolve) => globalThis.setTimeout(resolve, milliseconds))), this.#composerOpenTimeoutMs = Math.max(
0,
Number(options.composerOpenTimeoutMs) || 1600
), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => {
this.#requests.clear(), this.#queue = Promise.resolve(), this.#session = null, this.#submitPromise = null;
});
}
isOpen() {
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), model = modelValue(composer, "model");
return modelValue(model, "viewOpen") === !0 || String(modelValue(model, "composeState") ?? "").toLowerCase() === "open";
}
/**
* 在用户点击回复前只读解析宿主 Composer service、Draft 与原生 model。
*
* BrowserDiscourseHostApiPort 会缓存成功解析的 service/module,因此应用存续期间的
* 首次回复不再承担 module resolver 冷启动;这里不会调用 Draft.get、composer.open
* 或创建任何 Topic/Post model。
*/
warmReply() {
this.#assertActive();
try {
return this.#replyRuntime(), this.#models.prepareComposerBindings(), !0;
} catch {
return !1;
}
}
installCloseGuard(options) {
this.#assertActive();
const scope = options.parentScope ? options.parentScope.child() : this.scope.child(), gate = new import_repeat_action_gate.RepeatActionGate(), handledEvents = /* @__PURE__ */ new WeakSet(), consume = (event) => {
event.preventDefault(), event.stopImmediatePropagation();
}, requiresConfirmation = (key, message) => options.enabled() ? gate.confirm(key) ? !1 : (options.notify?.(message), !0) : (gate.clear(), !1), discardComposer = (event) => {
consume(event), this.discard().catch((error) => {
this.#onError(error), options.notify?.("舍弃回复失败,请重试");
});
}, ownsActiveComposer = () => {
const session = this.#session, composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer"));
return !!(session && modelValue(composer, "model") === session.model);
}, onClick = (event) => {
if (handledEvents.has(event)) return;
const control = event.target?.closest(
"#reply-control .toggle-save-and-close,#reply-control .discard-button"
);
if (!control) return;
handledEvents.add(event);
const shouldDiscard = control.classList.contains("discard-button");
if (requiresConfirmation(
shouldDiscard ? "composer:discard" : "composer:close",
shouldDiscard ? "再点一次舍弃回复" : "再点一次关闭回复窗口"
)) {
consume(event);
return;
}
shouldDiscard && ownsActiveComposer() && discardComposer(event);
}, onKeyDown = (event) => {
if (handledEvents.has(event)) return;
const keyboard = event;
if (!(keyboard.key !== "Escape" || keyboard.repeat || keyboard.defaultPrevented || !this.isOpen())) {
if (handledEvents.add(event), requiresConfirmation("composer:escape", "再按一次 Esc 舍弃回复")) {
consume(event);
return;
}
ownsActiveComposer() && discardComposer(event);
}
}, captureTarget = options.document.defaultView ?? options.document;
return scope.listen(captureTarget, "click", onClick, !0), scope.listen(captureTarget, "keydown", onKeyDown, !0), captureTarget !== options.document && (scope.listen(options.document, "click", onClick, !0), scope.listen(options.document, "keydown", onKeyDown, !0)), scope.add(() => gate.clear()), scope;
}
async discard() {
this.#assertActive();
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), model = modelValue(composer, "model");
if (!composer || !(0, import_value_record.valueRecord)(model)) {
this.#session = null;
return;
}
const destroyDraft = composer.destroyDraft;
if (typeof destroyDraft != "function")
throw new Error("Discourse composer.destroyDraft 尚未就绪");
const cleanupErrors = [];
try {
composer.skipAutoSave = !0;
try {
const runloop = (0, import_value_record.valueRecord)(this.#host.lookupModule("@ember/runloop")), cancel = runloop?.cancel;
typeof cancel == "function" && composer._saveDraftDebounce && cancel.call(runloop, composer._saveDraftDebounce);
} catch (error) {
cleanupErrors.push(error);
}
if (await destroyDraft.call(composer), modelValue(composer, "model") === model) {
const mutableModel = (0, import_value_record.valueRecord)(model);
try {
typeof mutableModel?.clearState == "function" && mutableModel.clearState.call(mutableModel);
} catch (error) {
cleanupErrors.push(error);
}
try {
typeof composer.close == "function" && composer.close.call(composer);
} catch (error) {
cleanupErrors.push(error);
}
try {
const appEvents = (0, import_value_record.valueRecord)(
composer.appEvents ?? this.#host.lookup("service:app-events")
);
typeof appEvents?.trigger == "function" && appEvents.trigger.call(appEvents, "composer:cancelled");
} catch (error) {
cleanupErrors.push(error);
}
}
this.#session = null;
} finally {
try {
composer.skipAutoSave = !1;
} catch (error) {
cleanupErrors.push(error);
}
}
if (cleanupErrors.length)
throw new AggregateError(
cleanupErrors,
"Discourse composer 舍弃后清理失败"
);
}
installSubmitGuard(options) {
if (this.#assertActive(), !this.#isolation)
throw new Error("Discourse Composer submit guard 缺少宿主隔离 owner");
const scope = options.parentScope ? options.parentScope.child() : this.scope.child(), submit = (event) => {
const button = event.target?.closest(
"#reply-control .save-or-cancel button.create"
) ?? null;
!button || button !== this.#submitButton(options.document) || (event.preventDefault(), event.stopImmediatePropagation(), this.#submit(button));
};
return scope.listen(options.document, "click", submit, !0), scope.listen(options.document, "keydown", (event) => {
const keyboard = event;
if (keyboard.isComposing || !(keyboard.ctrlKey || keyboard.metaKey) || keyboard.key !== "Enter" && keyboard.code !== "Enter") return;
const button = this.#submitButton(options.document);
button && (keyboard.preventDefault(), keyboard.stopImmediatePropagation(), this.#submit(button));
}, !0), scope;
}
openReply(input) {
return this.#enqueue(
composerRequestKey(input),
() => this.#openReply(input)
);
}
openEdit(input) {
return this.#enqueue(
composerEditRequestKey(input),
() => this.#openEdit(input)
);
}
openPrivateMessage(usernameValue) {
const username = String(usernameValue).trim().replace(/^@+/, "");
if (!username) throw new Error("私信 username 不能为空");
return this.#enqueue(
privateMessageRequestKey(username),
() => this.#openPrivateMessage(username)
);
}
bindWindow(port) {
return this.#assertActive(), this.#windowPort = port, this.#presentComposerWindow(), () => {
this.#windowPort === port && (this.#windowPort = null);
};
}
#enqueue(key, execute) {
this.#assertActive();
const existing = this.#requests.get(key);
if (existing) return existing;
const request = this.#queue.then(
() => (this.#assertActive(), execute()),
() => (this.#assertActive(), execute())
).catch((error) => {
throw this.#onError(error), error;
}).finally(() => {
this.#requests.get(key) === request && this.#requests.delete(key);
});
return this.#requests.set(key, request), this.#queue = request.then(
() => {
},
() => {
}
), request;
}
destroy() {
this.scope.destroy();
}
async #openPrivateMessage(username) {
if (this.isOpen())
throw new Error("Discourse 原生 composer 正在处理另一项编辑,请先完成或关闭");
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), openNewMessage = composer?.openNewMessage;
if (!composer || typeof openNewMessage != "function")
throw new Error("Discourse 原生 composer.openNewMessage 尚未就绪");
if (await openNewMessage.call(composer, { recipients: username }), this.#assertActive(), !await this.#waitForComposerPopup(this.#composerOpenTimeoutMs))
throw new Error("Discourse 原生私信浮窗未显示");
if (!this.#presentComposerWindow())
throw typeof composer.close == "function" && composer.close.call(composer), new Error("Reader 私信浮窗未接管");
return this.#focusComposerInput(), Object.freeze({ username });
}
async #openReply(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topic.id), postReference = (0, import_identifiers.discoursePostReference)(input.post);
if (input.post.topic_id !== void 0 && (0, import_identifiers.discourseTopicId)(input.post.topic_id) !== topicId)
throw new Error("composer 回复楼层不属于目标 Topic");
const {
composer,
appEvents,
replyAction,
draft: Draft
} = this.#replyRuntime(), topicModel = this.#models.createTopic(input.topic), postModel = this.#models.createPost(input.topic, input.post, topicModel), currentModel = modelValue(composer, "model"), currentTopicId = composerModelTopicId(currentModel), currentOpen = modelValue(currentModel, "viewOpen") === !0 || String(modelValue(currentModel, "composeState") ?? "").toLowerCase() === "open", currentAction = modelValue(currentModel, "action"), currentReply = String(modelValue(currentModel, "reply") ?? ""), initialRaw = String(input.initialRaw ?? "").trim(), initialRichHtml = String(input.initialRichHtml ?? "").trim(), dedupeMention = normalizedMentionUsername(input.dedupeMention);
if (currentOpen && currentTopicId === topicId && currentAction === replyAction && await this.#waitForComposerPopup(640)) {
setModelValue(
currentModel,
"post",
postReference.postNumber === 1 ? null : postModel
), this.#presentComposerWindow();
const duplicateMention = input.replaceRaw !== !0 && initialRaw && rawMentionsUsername(currentReply, dedupeMention);
if (!duplicateMention) {
if (initialRaw && input.replaceRaw === !0)
setModelValue(currentModel, "reply", initialRaw);
else if (initialRaw) {
const composerInput = await this.#waitForComposerInput(640);
if (this.#document && !composerInput)
throw new Error("Discourse 原生回复编辑器未就绪");
this.#insertComposerBlock(
appEvents,
currentModel,
initialRaw,
initialRichHtml,
composerInput
);
}
}
const session2 = Object.freeze({
topicId,
parentPostNumber: postReference.postNumber,
action: "reply",
reused: !0,
model: currentModel,
...duplicateMention ? { insertionSkipped: "duplicate-mention" } : {}
});
return this.#session = session2, this.#focusComposerInput(), session2;
}
if (currentOpen) {
const popupAvailable = this.#composerPopupAvailable();
if ((currentTopicId === topicId || !popupAvailable) && typeof composer.close == "function")
composer.close.call(composer), await this.#waitForDelay(0);
else
throw new Error(
"Discourse 原生 composer 正在处理另一项编辑,请先完成或关闭"
);
}
const key = normalizedDraftKey(input.topic.draft_key), sequence = normalizedDraftSequence(input.topic.draft_sequence);
let insertAfterOpen = !1, insertionSkipped;
const options = {
action: replyAction,
draftKey: key,
draftSequence: sequence,
skipJumpOnSave: !0,
...postReference.postNumber === 1 ? { topic: topicModel } : { post: postModel }
}, draftResult = (0, import_value_record.valueRecord)(await Draft.get.call(Draft, key));
if (draftResult?.draft) {
const draft = parseDraft(draftResult.draft);
options.draftSequence = normalizedDraftSequence(
draftResult.draft_sequence ?? sequence
), input.replaceRaw !== !0 && initialRaw && rawMentionsUsername(draft.reply, dedupeMention) ? (options.reply = draft.reply, insertionSkipped = "duplicate-mention") : input.replaceRaw === !0 ? options.reply = initialRaw : initialRaw && initialRichHtml ? (options.reply = draft.reply, insertAfterOpen = !0) : options.reply = `${draft.reply}${initialRaw ? `
${initialRaw}` : ""}`, draft.whisper !== void 0 && (options.whisper = draft.whisper);
} else initialRaw && (input.replaceRaw === !0 ? options.reply = initialRaw : initialRichHtml ? insertAfterOpen = !0 : options.quote = initialRaw);
if (currentReply && currentOpen && currentTopicId === topicId && (input.replaceRaw !== !0 && initialRaw && rawMentionsUsername(currentReply, dedupeMention) ? (options.reply = currentReply, insertAfterOpen = !1, insertionSkipped = "duplicate-mention") : input.replaceRaw === !0 ? (options.reply = initialRaw, insertAfterOpen = !1) : initialRaw && initialRichHtml ? (options.reply = currentReply, insertAfterOpen = !0) : options.reply = `${currentReply}${initialRaw ? `
${initialRaw}` : ""}`, delete options.quote), await composer.open.call(composer, options), this.#assertActive(), !await this.#waitForComposerPopup(this.#composerOpenTimeoutMs))
throw new Error("Discourse 原生回复浮窗未显示");
const model = modelValue(composer, "model");
if (!(0, import_value_record.valueRecord)(model)) throw new Error("Discourse composer.open 未生成 model");
if (this.#presentComposerWindow(), insertAfterOpen && initialRaw) {
const composerInput = await this.#waitForComposerInput(
this.#composerOpenTimeoutMs
);
if (this.#document && !composerInput)
throw new Error("Discourse 原生回复编辑器未就绪");
this.#insertComposerBlock(
appEvents,
model,
initialRaw,
initialRichHtml,
composerInput
);
}
const session = Object.freeze({
topicId,
parentPostNumber: postReference.postNumber,
action: "reply",
reused: !1,
model,
...insertionSkipped ? { insertionSkipped } : {}
});
return this.#session = session, this.#focusComposerInput(), session;
}
#replyRuntime() {
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), appEvents = (0, import_value_record.valueRecord)(
composer?.appEvents ?? this.#host.lookup("service:app-events")
);
if (!composer || typeof composer.open != "function" || !appEvents)
throw new Error("Discourse 原生 composer service 未就绪");
const replyAction = moduleDefault(this.#host, "discourse/models/composer").REPLY;
if (!replyAction) throw new Error("Discourse Composer.REPLY 未就绪");
const draft = moduleDefault(this.#host, "discourse/models/draft");
if (typeof draft.get != "function")
throw new Error("Discourse Draft.get 未就绪");
return Object.freeze({
composer,
appEvents,
replyAction,
draft
});
}
#presentComposerWindow() {
const popup = this.#document?.querySelector("#reply-control") ?? null;
return !popup || !this.#composerPopupAvailable() ? !1 : this.#windowPort?.open(popup) ?? !1;
}
#composerPopupAvailable() {
if (!this.#document) return !0;
const popup = this.#document.querySelector("#reply-control");
return !(!popup?.isConnected || popup.hidden || popup.classList.contains("closed") || popup.classList.contains("hidden") || popup.classList.contains("d-none") || popup.getAttribute("aria-hidden") === "true" || popup.closest('[hidden],[aria-hidden="true"]'));
}
async #waitForComposerPopup(timeoutMs) {
if (!this.#document) return !0;
const deadline = Date.now() + Math.max(0, timeoutMs);
do {
if (this.#composerPopupAvailable()) return !0;
if (Date.now() >= deadline) return !1;
await this.#waitForDelay(80), this.#assertActive();
} while (Date.now() <= deadline);
return !1;
}
#composerInput() {
const document = this.#document;
if (!document || !this.#composerPopupAvailable()) return null;
const selector = '#reply-control textarea.d-editor-input,#reply-control input.d-editor-input,#reply-control .ProseMirror.d-editor-input[contenteditable="true"],#reply-control [contenteditable="true"].d-editor-input,#reply-control [role="textbox"][contenteditable="true"],#reply-control .ProseMirror[contenteditable="true"],#reply-control textarea', available = (candidate) => {
if (!candidate.isConnected || candidate.hidden || candidate.style.display === "none" || candidate.style.visibility === "hidden" || candidate.matches(
'[disabled],[aria-disabled="true"],[aria-hidden="true"],.hidden,.d-none'
) || candidate.closest('[hidden],[aria-hidden="true"],.hidden,.d-none')) return !1;
const view = document.defaultView;
if (typeof view?.getComputedStyle == "function") {
const style = view.getComputedStyle(candidate);
if (style.display === "none" || style.visibility === "hidden" || typeof candidate.getClientRects == "function" && candidate.getClientRects().length === 0) return !1;
}
return !0;
}, active = document.activeElement;
return active?.matches(selector) && available(active) ? active : [...document.querySelectorAll(selector)].find(available) ?? null;
}
async #waitForComposerInput(timeoutMs) {
if (!this.#document) return null;
const deadline = Date.now() + Math.max(0, timeoutMs);
do {
const input = this.#composerInput();
if (input) return input;
if (Date.now() >= deadline) return null;
await this.#waitForDelay(80), this.#assertActive();
} while (Date.now() <= deadline);
return null;
}
#focusComposerInput() {
const document = this.#document;
if (!document) return;
const focus = () => {
if (!this.#composerPopupAvailable()) return;
const input = this.#composerInput();
if (input)
try {
input.focus({ preventScroll: !0 });
} catch {
input.focus();
}
}, viewport = document.defaultView;
viewport?.requestAnimationFrame ? viewport.requestAnimationFrame(focus) : queueMicrotask(focus);
}
#insertComposerBlock(appEvents, model, raw, richHtml, composerInput = this.#composerInput()) {
const richEditor = richHtml && composerInput?.matches(
'.ProseMirror[contenteditable="true"]'
) ? composerInput : null;
if (richEditor) {
if (this.#insertRichText(richEditor, richHtml, raw)) return;
throw new Error("Discourse 富文本 Composer 引用插入失败");
}
if (composerInput?.matches("textarea,input.d-editor-input")) {
this.#insertTextControlBlock(
composerInput,
model,
raw
);
return;
}
const trigger = appEvents.trigger;
if (typeof trigger != "function")
throw new Error("Discourse app-events 缺少 composer insert-block");
trigger.call(appEvents, "composer:insert-block", raw);
}
#insertTextControlBlock(editor, model, raw) {
const modelRaw = String(modelValue(model, "reply") ?? ""), current = editor.value || modelRaw, wasFocused = editor.ownerDocument.activeElement === editor;
let start = Number.isInteger(editor.selectionStart) ? Number(editor.selectionStart) : current.length, end = Number.isInteger(editor.selectionEnd) ? Number(editor.selectionEnd) : start;
!wasFocused && current && start === 0 && end === 0 && (start = current.length, end = current.length);
const insertion = composerTextInsertion(
current,
raw,
start,
end
);
editor.focus(), this.#setTextControlValue(editor, insertion.next), this.#dispatchTextControlInput(editor, insertion.inserted), setModelValue(model, "reply", insertion.next);
try {
editor.setSelectionRange(insertion.cursor, insertion.cursor);
} catch {
}
if (String(modelValue(model, "reply") ?? "") !== insertion.next || editor.value !== insertion.next)
throw new Error("Discourse Markdown Composer 引用插入失败");
}
#setTextControlValue(editor, value) {
try {
if (editor.value = value, editor.value === value) return;
} catch {
}
const view = editor.ownerDocument.defaultView;
for (const prototype of [
view?.HTMLTextAreaElement?.prototype,
view?.HTMLInputElement?.prototype,
Object.getPrototypeOf(editor)
]) {
if (!prototype) continue;
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
if (setter)
try {
if (setter.call(editor, value), editor.value === value) return;
} catch {
}
}
throw new Error("Discourse Markdown Composer value 不可写");
}
#dispatchTextControlInput(editor, inserted) {
const InputEventConstructor = editor.ownerDocument.defaultView?.InputEvent;
if (typeof InputEventConstructor == "function")
try {
editor.dispatchEvent(new InputEventConstructor("input", {
bubbles: !0,
inputType: "insertText",
data: inserted
}));
} catch {
this.#dispatchComposerInput(editor);
}
else
this.#dispatchComposerInput(editor);
const change = editor.ownerDocument.createEvent("Event");
change.initEvent("change", !0, !1), editor.dispatchEvent(change);
}
#insertRichText(editor, html, raw) {
const document = this.#document;
if (!document) return !1;
try {
editor.focus({ preventScroll: !0 });
} catch {
editor.focus();
}
const view = document.defaultView, DataTransferConstructor = view?.DataTransfer, ClipboardEventConstructor = view?.ClipboardEvent;
if (typeof DataTransferConstructor == "function" && typeof ClipboardEventConstructor == "function")
try {
const before = editor.innerHTML, data = new DataTransferConstructor();
data.setData("text/html", html), data.setData("text/plain", raw), data.setData("text/markdown", raw);
const event = new ClipboardEventConstructor("paste", {
bubbles: !0,
cancelable: !0,
clipboardData: data
});
if (editor.dispatchEvent(event), editor.innerHTML !== before) return !0;
} catch {
}
const execute = document.execCommand;
if (typeof execute == "function")
try {
const before = editor.innerHTML;
if (execute.call(document, "insertHTML", !1, html) && editor.innerHTML !== before)
return this.#dispatchComposerInput(editor), !0;
} catch {
}
const selection = document.getSelection?.() ?? view?.getSelection?.() ?? null;
if (!selection) return !1;
let range;
selection.rangeCount > 0 && editor.contains(selection.anchorNode) ? range = selection.getRangeAt(0) : (range = document.createRange(), range.selectNodeContents(editor), range.collapse(!1), selection.removeAllRanges(), selection.addRange(range));
try {
const before = editor.innerHTML;
range.deleteContents();
const fragment = range.createContextualFragment(html), last = fragment.lastChild;
return range.insertNode(fragment), last && (range.setStartAfter(last), range.collapse(!0), selection.removeAllRanges(), selection.addRange(range)), this.#dispatchComposerInput(editor), editor.innerHTML !== before;
} catch {
return !1;
}
}
#dispatchComposerInput(editor) {
const event = editor.ownerDocument.createEvent("Event");
event.initEvent("input", !0, !1), editor.dispatchEvent(event);
}
async #openEdit(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topic.id), postReference = (0, import_identifiers.discoursePostReference)(input.post);
if (input.post.topic_id !== void 0 && (0, import_identifiers.discourseTopicId)(input.post.topic_id) !== topicId)
throw new Error("composer 编辑楼层不属于目标 Topic");
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer"));
if (!composer || typeof composer.open != "function")
throw new Error("Discourse 原生 composer service 未就绪");
const editAction = moduleDefault(this.#host, "discourse/models/composer").EDIT;
if (!editAction) throw new Error("Discourse Composer.EDIT 未就绪");
const topicModel = this.#models.createTopic(input.topic), postModel = this.#models.createPost(
input.topic,
input.post,
topicModel
), currentModel = modelValue(composer, "model"), currentTopicId = Number(
modelValue(
modelValue(currentModel, "topic") ?? modelValue(modelValue(currentModel, "post"), "topic"),
"id"
)
), currentOpen = modelValue(currentModel, "viewOpen") === !0 || String(modelValue(currentModel, "composeState") ?? "").toLowerCase() === "open", currentAction = modelValue(currentModel, "action");
if (currentOpen) {
const currentPostId = Number(
modelValue(modelValue(currentModel, "post"), "id")
);
if (currentTopicId !== topicId || currentAction !== editAction || currentPostId !== postReference.postId)
throw new Error("Discourse 原生 composer 正在处理另一项编辑,请先完成或关闭");
const session2 = Object.freeze({
topicId,
parentPostNumber: postReference.postNumber,
action: "edit",
reused: !0,
model: currentModel
});
if (!await this.#waitForComposerPopup(this.#composerOpenTimeoutMs))
throw new Error("Discourse 原生编辑浮窗未显示");
return this.#session = session2, this.#presentComposerWindow(), this.#focusComposerInput(), session2;
}
if (await composer.open.call(composer, {
action: editAction,
post: postModel,
draftKey: normalizedDraftKey(input.topic.draft_key),
draftSequence: normalizedDraftSequence(input.topic.draft_sequence),
skipJumpOnSave: !0
}), this.#assertActive(), !await this.#waitForComposerPopup(this.#composerOpenTimeoutMs))
throw new Error("Discourse 原生编辑浮窗未显示");
const model = modelValue(composer, "model");
if (!(0, import_value_record.valueRecord)(model)) throw new Error("Discourse composer.open 未生成 edit model");
const session = Object.freeze({
topicId,
parentPostNumber: postReference.postNumber,
action: "edit",
reused: !1,
model
});
return this.#session = session, this.#presentComposerWindow(), this.#focusComposerInput(), session;
}
#submitButton(document) {
const session = this.#session, composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer"));
if (!session || modelValue(composer, "model") !== session.model || typeof composer?.save != "function") return null;
const button = document.querySelector(
"#reply-control .save-or-cancel button.create"
);
return button && !button.disabled ? button : null;
}
#submit(button) {
if (button.disabled || this.#submitPromise) return;
const session = this.#session, composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), save = composer?.save;
if (!session || !this.#isolation || modelValue(composer, "model") !== session.model || typeof save != "function") return;
const transaction = this.#isolation.run(
session.topicId,
session.action === "edit" ? "edited" : "created",
() => save.call(composer, !0, { jump: !1 })
);
this.#submitPromise = transaction, transaction.catch((error) => {
this.#onError(error);
}).finally(() => {
this.#submitPromise === transaction && (this.#submitPromise = null);
});
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("DiscourseComposerCoordinator 已销毁");
}
}
const COMPOSER_SAVE_EVENTS = Object.freeze([
Object.freeze({
eventName: "composer:created-post",
kind: "created"
}),
Object.freeze({
eventName: "composer:edited-post",
kind: "edited"
}),
Object.freeze({
eventName: "post:created",
kind: "created"
})
]);
function topicRouteTarget(value) {
let segments;
try {
segments = new URL(String(value ?? ""), "https://reader.invalid").pathname.split("/").filter(Boolean);
} catch {
return null;
}
const 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]), postNumber = Number(tail[topicOffset + 1]);
return Number.isSafeInteger(topicId) && topicId > 0 && Number.isSafeInteger(postNumber) && postNumber > 0 ? Object.freeze({ topicId, postNumber }) : null;
}
class DiscourseComposerHostIsolation {
scope;
#host;
#events = new import_signal.Signal();
#onError;
#releaseActive = null;
#suspended = null;
#running = !1;
constructor(options) {
this.#host = options.host, this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => {
this.#releaseActive?.(), this.#releaseActive = null, this.#resumeTopicController(), this.#events.clear();
});
}
subscribe(listener, scope) {
const owned = import_lifecycle.LifecycleScope.ownedBy(scope ?? this.scope);
return new DiscourseComposerEventPort(this.#host).subscribe(listener, owned), this.#events.subscribe(listener, owned), () => owned.destroy();
}
async run(topicIdValue, kind, execute) {
if (this.scope.destroyed)
throw new Error("Discourse Composer 宿主隔离 owner 已销毁");
if (this.#running)
throw new Error("Discourse Composer save 事务正在进行");
const topicId = Number((0, import_identifiers.discourseTopicId)(topicIdValue)), appEvents = appEventsPort(
(0, import_value_record.valueRecord)(this.#host.lookup("service:composer"))?.appEvents ?? this.#host.lookup("service:app-events")
), originalTrigger = appEvents.trigger;
if (typeof originalTrigger != "function")
throw new Error("Discourse app-events 缺少 trigger");
const expectedNames = new Set(
kind === "edited" ? ["composer:edited-post"] : ["composer:created-post", "post:created"]
), suppressedNames = /* @__PURE__ */ new Set([
"composer:created-post",
"composer:edited-post",
"post:created",
"post:highlight"
]);
let observed = !1;
const trigger = (eventName, payload) => {
const name = String(eventName ?? "");
return expectedNames.has(name) && (observed = !0, this.#emit(Object.freeze({
kind,
payload,
eventName: name
}))), suppressedNames.has(name) ? appEvents : originalTrigger.call(appEvents, eventName, payload);
}, releases = [], release = () => {
for (const cleanup of releases.splice(0).reverse())
try {
cleanup();
} catch (cause) {
this.#report(cause);
}
this.#releaseActive === release && (this.#releaseActive = null);
};
this.#running = !0;
try {
if (appEvents.trigger = trigger, appEvents.trigger !== trigger)
throw new Error("Discourse app-events trigger 无法建立有界隔离");
releases.push(() => {
appEvents.trigger === trigger && (appEvents.trigger = originalTrigger);
});
const routeModule = (0, import_value_record.valueRecord)(
this.#host.lookupModule("discourse/lib/url")
), routeOwner = (0, import_value_record.valueRecord)(routeModule?.default) ?? routeModule, originalRouteTo = routeOwner?.routeTo;
if (routeOwner && typeof originalRouteTo == "function") {
const guardedRouteTo = (value, ...args) => {
if (topicRouteTarget(value)?.topicId !== topicId)
return originalRouteTo.call(routeOwner, value, ...args);
};
routeOwner.routeTo = guardedRouteTo, routeOwner.routeTo === guardedRouteTo && releases.push(() => {
routeOwner.routeTo === guardedRouteTo && (routeOwner.routeTo = originalRouteTo);
});
}
this.#suspendTopicController(topicId), this.#releaseActive = release;
const result = await execute();
return observed || this.#emit(Object.freeze({
kind,
payload: result,
eventName: kind === "edited" ? "composer:edited-post" : "composer:created-post"
})), result;
} finally {
release(), this.#running = !1;
}
}
runActive(kind, execute) {
const composer = (0, import_value_record.valueRecord)(this.#host.lookup("service:composer")), model = modelValue(composer, "model"), topic = modelValue(model, "topic") ?? modelValue(modelValue(model, "post"), "topic");
return this.run(
Number((0, import_identifiers.discourseTopicId)(modelValue(topic, "id"))),
kind,
execute
);
}
destroy() {
this.scope.destroy();
}
#emit(event) {
for (const cause of this.#events.emit(event)) this.#report(cause);
}
#suspendTopicController(topicId) {
if (this.#suspended?.topicId === topicId) return;
this.#resumeTopicController();
const controller = (0, import_value_record.valueRecord)(this.#host.lookup("controller:topic")), hostTopicId = Number(modelValue(modelValue(controller, "model"), "id"));
!controller || hostTopicId !== topicId || typeof controller.unsubscribe != "function" || (controller.unsubscribe.call(controller), this.#suspended = Object.freeze({ controller, topicId }));
}
#resumeTopicController() {
const suspended = this.#suspended;
if (this.#suspended = null, !(!suspended || typeof suspended.controller.subscribe != "function" || Number(
modelValue(modelValue(suspended.controller, "model"), "id")
) !== suspended.topicId))
try {
suspended.controller.subscribe.call(suspended.controller);
} catch (cause) {
this.#report(cause);
}
}
#report(cause) {
try {
this.#onError(cause);
} catch {
}
}
}
function appEventsPort(value) {
const target = (0, import_value_record.valueRecord)(value);
if (!target || typeof target.on != "function" || typeof target.off != "function")
throw new Error("Discourse app-events 缺少 on/off");
return target;
}
function modelJson(value) {
const target = (0, import_value_record.valueRecord)(value);
if (!target || typeof target.toJSON != "function") return value;
try {
return target.toJSON.call(target);
} catch {
return value;
}
}
function eventPostCandidate(value) {
const normalized = modelJson(value), nested = modelValue(normalized, "post");
return nested === void 0 ? normalized : modelJson(nested);
}
function topicIdForPost(value) {
const direct = Number(modelValue(value, "topic_id"));
if (Number.isSafeInteger(direct) && direct > 0) return direct;
const topic = modelValue(value, "topic"), nested = Number(modelValue(topic, "id"));
return Number.isSafeInteger(nested) && nested > 0 ? nested : null;
}
class DiscourseComposerEventPort {
#host;
constructor(host) {
this.#host = host;
}
subscribe(listener, scope) {
let appEvents;
try {
appEvents = appEventsPort(
(0, import_value_record.valueRecord)(this.#host.lookup("service:composer"))?.appEvents ?? this.#host.lookup("service:app-events")
);
} catch {
return () => {
};
}
const bindings = [];
try {
for (const descriptor of COMPOSER_SAVE_EVENTS) {
const bound = (payload) => {
listener(Object.freeze({
kind: descriptor.kind,
payload,
eventName: descriptor.eventName
}));
};
appEvents.on(descriptor.eventName, bound), bindings.push(Object.freeze({
eventName: descriptor.eventName,
listener: bound
}));
}
} catch (error) {
for (const binding of bindings.reverse())
try {
appEvents.off(binding.eventName, binding.listener);
} catch {
}
throw error;
}
let active = !0;
const cleanup = () => {
if (active) {
active = !1;
for (const binding of bindings.reverse())
try {
appEvents.off(binding.eventName, binding.listener);
} catch {
}
}
};
return scope?.add(cleanup), cleanup;
}
}
class DiscourseComposerTopicSyncController {
scope;
changes = new import_signal.Signal();
#topicId;
#events;
#session;
#now;
#schedule;
#cancel;
#onError;
#pending = /* @__PURE__ */ new Map();
#unsubscribe = null;
constructor(options) {
this.#topicId = (0, import_identifiers.discourseTopicId)(options.topicId), this.#events = options.events, this.#session = options.session, this.#now = options.now ?? Date.now, this.#schedule = options.schedule ?? ((callback) => setTimeout(callback, 0)), this.#cancel = options.cancel ?? ((handle) => clearTimeout(handle)), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => {
this.stop(), this.changes.clear();
});
}
get active() {
return this.#unsubscribe !== null;
}
start() {
this.#assertActive(), !this.#unsubscribe && (this.#unsubscribe = this.#events.subscribe((event) => {
this.#handle(event);
}, this.scope));
}
stop() {
const unsubscribe = this.#unsubscribe;
this.#unsubscribe = null, unsubscribe?.();
for (const pending of this.#pending.values())
"handle" in pending && this.#cancel(pending.handle);
this.#pending.clear();
}
destroy() {
this.scope.destroy();
}
#handle(event) {
if (this.scope.destroyed) return;
const candidate = eventPostCandidate(event.payload), topicId = topicIdForPost(candidate);
if (topicId !== null && topicId !== this.#topicId) return;
let reference = null;
if (topicId === this.#topicId)
try {
reference = (0, import_identifiers.discoursePostReference)(
candidate
);
} catch {
reference = null;
}
if (reference && reference.postId !== null) {
this.#cancelPending(event.kind);
try {
event.kind === "created" ? this.#session.ingestCreatedPost(
candidate,
"action-response",
this.#now()
) : this.#session.ingestPosts(
Object.freeze([candidate]),
"action-response",
this.#now()
), this.#emit(Object.freeze({
kind: event.kind,
post: candidate,
postNumber: reference.postNumber,
source: "native-event"
}));
} catch (error) {
this.#onError(error);
}
return;
}
this.#scheduleFallback(event.kind);
}
#scheduleFallback(kind) {
if (this.#pending.has(kind)) return;
const pending = {};
this.#pending.set(kind, pending);
try {
pending.handle = this.#schedule(() => {
this.#pending.get(kind) === pending && (this.#pending.delete(kind), Promise.resolve().then(async () => {
await this.#session.refresh({ background: !1 }), kind === "created" && await this.#session.loadLastPost({ refresh: !0 });
}).then(() => {
this.scope.destroyed || this.#emit(Object.freeze({
kind,
post: null,
postNumber: null,
source: "canonical-refresh"
}));
}).catch(this.#onError));
});
} catch (error) {
this.#pending.get(kind) === pending && this.#pending.delete(kind), this.#onError(error);
}
}
#cancelPending(kind) {
const pending = this.#pending.get(kind);
pending && (this.#pending.delete(kind), "handle" in pending && this.#cancel(pending.handle));
}
#emit(commit) {
if (!this.scope.destroyed)
for (const error of this.changes.emit(commit)) this.#onError(error);
}
#assertActive() {
if (this.scope.destroyed)
throw new Error("DiscourseComposerTopicSyncController 已销毁");
}
}
}, "76776dc0758a7cd10d2e27ec465a8a7fb10760d27b6e27435e7e9771629576bf");
/* Source: lite/src/discourse/native-host-api.ts */
runtime.register("src/discourse/native-host-api.js", function(module, exports, require) {
var native_host_api_exports = {};
__export(native_host_api_exports, {
BrowserDiscourseBookmarkNativeState: () => BrowserDiscourseBookmarkNativeState,
BrowserDiscourseHostApiPort: () => BrowserDiscourseHostApiPort,
BrowserDiscourseNativeBookmarkForm: () => BrowserDiscourseNativeBookmarkForm,
BrowserDiscourseNotificationNativeState: () => BrowserDiscourseNotificationNativeState,
discourseAvatarTemplateUrl: () => discourseAvatarTemplateUrl,
discourseNativeAppEventSubscription: () => discourseNativeAppEventSubscription,
discourseNativeBoostsAvailable: () => discourseNativeBoostsAvailable,
discourseNativeCurrentUserBindingAvailable: () => discourseNativeCurrentUserBindingAvailable,
discourseNativeCurrentUsername: () => discourseNativeCurrentUsername,
discourseNativeEmojiMenu: () => discourseNativeEmojiMenu,
discourseNativeEmojiUrl: () => discourseNativeEmojiUrl,
discourseNativeExactTimeFormatter: () => discourseNativeExactTimeFormatter,
discourseNativeFlagCatalog: () => discourseNativeFlagCatalog,
discourseNativeFollowRoute: () => discourseNativeFollowRoute,
discourseNativeHostRouteRefresh: () => discourseNativeHostRouteRefresh,
discourseNativeIconRenderer: () => discourseNativeIconRenderer,
discourseNativeJqueryModule: () => discourseNativeJqueryModule,
discourseNativeMenuCloser: () => discourseNativeMenuCloser,
discourseNativePostAdminMenu: () => discourseNativePostAdminMenu,
discourseNativePostEventModel: () => discourseNativePostEventModel,
discourseNativePostRuntimeBindings: () => discourseNativePostRuntimeBindings,
discourseNativeRelativeTimeFormatter: () => discourseNativeRelativeTimeFormatter,
discourseNativeSiteLogoUrl: () => discourseNativeSiteLogoUrl,
discourseNativeTheme: () => discourseNativeTheme,
discourseNativeTopicEditCatalog: () => discourseNativeTopicEditCatalog,
discourseNativeTopicLinks: () => discourseNativeTopicLinks,
discourseNativeTopicPresentation: () => discourseNativeTopicPresentation,
discourseNativeUserActionBinding: () => discourseNativeUserActionBinding,
discourseNativeUserModel: () => discourseNativeUserModel
});
module.exports = __toCommonJS(native_host_api_exports);
var import_value_record = require("../kernel/value-record.js");
function discourseNativeAppEventSubscription(host, eventNameValue, listener, onError) {
const eventName = String(eventNameValue).trim();
if (!eventName) return () => {
};
const appEvents = (0, import_value_record.objectRecord)(host.lookup("service:app-events")), on = appEvents?.on, off = appEvents?.off;
if (typeof on != "function" || typeof off != "function") return () => {
};
const owner = Object.freeze({});
try {
on.call(appEvents, eventName, owner, listener);
} catch (cause) {
return onError?.(cause), () => {
};
}
let active = !0;
return () => {
if (active) {
active = !1;
try {
off.call(appEvents, eventName, owner, listener);
} catch (cause) {
onError?.(cause);
}
}
};
}
function discourseNativeUserModel(host) {
return host.lookupModule("discourse/models/user");
}
function discourseNativeFollowRoute(host, kind) {
return host.lookupModule(
`discourse/plugins/discourse-follow/discourse/routes/${kind}`
);
}
function discourseNativePostEventModel(host) {
return host.lookupModule(
"discourse/plugins/discourse-calendar/discourse/models/discourse-post-event-event"
);
}
function discourseNativeBoostsAvailable(host) {
const settings = (0, import_value_record.objectRecord)(host.lookup("service:site-settings"));
return settings && Object.hasOwn(settings, "discourse_boosts_enabled") ? settings.discourse_boosts_enabled === !0 : !!host.lookupModule(
"discourse/plugins/discourse-boosts/discourse/lib/create-boost"
);
}
function discourseNativeMenuCloser(host) {
const menu = (0, import_value_record.objectRecord)(host.lookup("service:menu")), close = menu?.close;
return typeof close != "function" ? null : (identifier) => {
const normalized = String(identifier).trim();
if (!normalized) throw new Error("Discourse menu identifier 不能为空");
return close.call(menu, normalized);
};
}
function discourseNativeHostRouteRefresh(host) {
const router = (0, import_value_record.objectRecord)(host.lookup("service:router")), refresh = router?.refresh;
if (typeof refresh != "function") return !1;
try {
const transition = refresh.call(router), rejection = (0, import_value_record.objectRecord)(transition)?.catch;
return typeof rejection == "function" && rejection.call(transition, () => {
}), !0;
} catch {
return !1;
}
}
function discourseNativeThemeMode(service) {
const colorMode = String(service?.colorMode ?? "").trim();
if (colorMode === "light" || colorMode === "dark") return colorMode;
if (colorMode === "auto") return "system";
if (service?.lightModeForced) return "light";
if (service?.darkModeForced) return "dark";
if (service?.selectorAvailable) return "system";
const session = (0, import_value_record.objectRecord)(service?.session);
return session ? session.defaultColorSchemeIsDark ? "dark" : "light" : null;
}
function discourseNativeTheme(host) {
return Object.freeze({
apply(mode) {
const service = (0, import_value_record.objectRecord)(
host.lookup("service:interface-color")
), action = service?.[mode === "light" ? "forceLightMode" : mode === "dark" ? "forceDarkMode" : "useAutoMode"];
if (typeof action != "function") return !1;
try {
return action.call(service), !0;
} catch {
return !1;
}
},
subscribe(listener, scope) {
const service = (0, import_value_record.objectRecord)(
host.lookup("service:interface-color")
), appEvents = (0, import_value_record.objectRecord)(service?.appEvents), on = appEvents?.on, off = appEvents?.off;
if (typeof on != "function" || typeof off != "function")
return () => {
};
const context = Object.freeze({}), onChanged = () => {
const mode = discourseNativeThemeMode(service);
mode && listener(mode);
};
try {
on.call(
appEvents,
"interface-color:changed",
context,
onChanged
);
} catch {
return () => {
};
}
let active = !0;
const cleanup = () => {
if (active) {
active = !1;
try {
off.call(
appEvents,
"interface-color:changed",
context,
onChanged
);
} catch {
}
}
};
return scope.add(cleanup), cleanup;
}
});
}
function discourseNativeTopicEditCatalog(host) {
return Object.freeze({
categories() {
const site = host.lookup("service:site"), source = nativeModelValue(site, "categories");
if (!Array.isArray(source)) return Object.freeze([]);
const categories = source.map((value) => {
const id = Number(nativeModelValue(value, "id")), name = String(nativeModelValue(value, "name") ?? "").trim();
if (!Number.isSafeInteger(id) || id < 1 || !name) return null;
const parentId = Number(
nativeModelValue(value, "parent_category_id")
);
return Object.freeze({
id,
name,
slug: String(nativeModelValue(value, "slug") ?? "").trim(),
color: String(nativeModelValue(value, "color") ?? "").trim().replace(/^#/, ""),
parentCategoryId: Number.isSafeInteger(parentId) && parentId > 0 ? parentId : null
});
}).filter((value) => value !== null);
return Object.freeze(categories);
},
async searchTags(request) {
const tagUtils = (0, import_value_record.objectRecord)(host.lookup("service:tag-utils")), search = tagUtils?.searchTags, settings = host.lookup("service:site-settings");
if (!tagUtils || typeof search != "function" || !(0, import_value_record.objectRecord)(settings))
throw new Error("Discourse 标签搜索服务尚未就绪");
const tagUtilsOwner = tagUtils, selectedIds = request.selected.map((tag) => Number(tag.id)).filter((id) => Number.isSafeInteger(id) && id > 0), selectedNames = request.selected.filter((tag) => !(Number(tag.id) > 0)).map((tag) => tag.name), result = await search.call(
tagUtils,
"/tags/filter/search",
Object.freeze({
q: String(request.query ?? "").trim(),
limit: Math.max(
1,
Number(nativeModelValue(
settings,
"max_tag_search_results"
)) || 20
),
categoryId: request.categoryId > 0 ? request.categoryId : void 0,
filterForInput: !0,
...selectedIds.length ? { selected_tag_ids: selectedIds.slice(0, 100) } : {},
...selectedNames.length ? { selected_tags: selectedNames.slice(0, 100) } : {}
}),
(json) => {
const payload = (0, import_value_record.objectRecord)(json), incoming = Array.isArray(payload?.results) ? payload.results : [], sort = tagUtilsOwner.sortSearchResults;
return typeof sort == "function" ? sort.call(tagUtilsOwner, incoming) : incoming;
}
), values = Array.isArray(result) ? result : [], byName = /* @__PURE__ */ new Map();
for (const value of values) {
const name = String(
nativeModelValue(value, "name") ?? nativeModelValue(value, "text") ?? ""
).trim();
if (!name) continue;
const id = Number(nativeModelValue(value, "id"));
byName.set(name.toLocaleLowerCase(), Object.freeze({
id: Number.isSafeInteger(id) && id > 0 ? id : null,
name
}));
}
return Object.freeze([...byName.values()]);
}
});
}
const DISCOURSE_ICON_ALIASES = Object.freeze({
"alert-triangle": "triangle-exclamation",
boost: "rocket",
"check-square": "square-check",
"circle-x": "circle-xmark",
"external-link": "arrow-up-right-from-square",
"eye-off": "eye-slash",
"header-bell": "far-bell",
"header-bookmark": "far-bookmark",
"header-settings": "sliders",
history: "clock-rotate-left",
languages: "language",
"list-checks": "list-check",
mail: "envelope",
"maximize-2": "up-right-and-down-left-from-center",
"minimize-2": "down-left-and-up-right-to-center",
"message-square": "message",
"panel-left": "table-columns",
"panel-right": "table-columns",
"floating-window": "window-maximize",
pin: "thumbtack",
"rotate-ccw": "arrow-rotate-left",
search: "magnifying-glass",
settings: "gear",
share: "share-nodes",
smile: "face-smile",
trash: "trash-can",
x: "xmark"
}), SVG_NAMESPACE = "http://www.w3.org/2000/svg", SAFE_SVG_FRAGMENT_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
function sanitizeInlinedIcon(root) {
const queryAll = root.querySelectorAll;
if (typeof queryAll != "function") return;
const elements = [root, ...Array.from(queryAll.call(root, "*"))];
for (const element of elements) {
const localName = String(element.localName ?? element.tagName).toLowerCase();
if (localName === "script" || localName === "foreignobject") {
element.remove();
continue;
}
for (const attribute of Array.from(element.attributes ?? [])) {
const name = attribute.name.toLowerCase();
if (name.startsWith("on")) {
element.removeAttribute(attribute.name);
continue;
}
(name === "href" || name === "xlink:href") && attribute.value && !attribute.value.startsWith("#") && element.removeAttribute(attribute.name);
}
}
}
function inlineDiscourseIconUse(value, document) {
const svg = value;
if (svg?.nodeType !== 1 || typeof svg.querySelector != "function" || typeof svg.replaceChildren != "function")
return;
const use = svg.querySelector("use"), href = use?.getAttribute("href") ?? use?.getAttribute("xlink:href") ?? "";
if (!href.startsWith("#")) return;
const fragmentId = href.slice(1);
if (!SAFE_SVG_FRAGMENT_ID.test(fragmentId)) return;
const symbol = document.getElementById(fragmentId);
if (!symbol || String(symbol.localName ?? symbol.tagName).toLowerCase() !== "symbol")
return;
const group = document.createElementNS(SVG_NAMESPACE, "g");
for (const child of Array.from(symbol.childNodes))
group.append(child.cloneNode(!0));
if (sanitizeInlinedIcon(group), !group.childNodes.length) return;
const viewBox = symbol.getAttribute("viewBox");
viewBox && !svg.getAttribute("viewBox") && svg.setAttribute("viewBox", viewBox), svg.replaceChildren(group);
}
function discourseNativeIconRenderer(host) {
return (nameValue, document) => {
const name = String(nameValue).trim();
if (!name) return null;
const candidates = Object.freeze([
DISCOURSE_ICON_ALIASES[name],
name
].filter((candidate2) => !!candidate2)), module2 = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/lib/icon-library")
), defaultExport = (0, import_value_record.objectRecord)(module2?.default), owner = typeof module2?.iconElement == "function" ? module2 : defaultExport, iconElement = owner?.iconElement;
if (typeof iconElement == "function")
for (const candidate2 of candidates)
try {
const rendered = iconElement.call(owner, candidate2), node = rendered;
if (node?.nodeType !== 1 || !node.classList) continue;
return node.classList.add("ldp-icon", `ldp-icon-${name}`), node.dataset && (node.dataset.icon = name), node.setAttribute?.("aria-hidden", "true"), inlineDiscourseIconUse(rendered, document), rendered;
} catch {
}
const candidate = candidates[0];
if (!candidate) return null;
const svg = document.createElementNS(
SVG_NAMESPACE,
"svg"
);
svg.classList.add(
"svg-icon",
"icon",
"d-icon",
`d-icon-${candidate}`,
"ldp-icon",
`ldp-icon-${name}`
), svg.dataset.icon = name, svg.setAttribute("aria-hidden", "true");
const use = document.createElementNS(
SVG_NAMESPACE,
"use"
);
return use.setAttribute("href", `#${candidate}`), svg.append(use), svg;
};
}
function discourseAvatarTemplateUrl(template, size, baseUrl) {
const source = String(template).replace(/\{size\}/g, String(size)).trim();
if (!source) return null;
try {
return new URL(source, baseUrl).href;
} catch {
return null;
}
}
function discourseNativeCurrentUser(host) {
const serviceUser = host.lookup("service:current-user");
if (serviceUser) return serviceUser;
const userModule = (0, import_value_record.valueRecord)(host.lookupModule("discourse/models/user"));
for (const owner of [(0, import_value_record.valueRecord)(userModule?.default), userModule]) {
const current = owner?.current;
if (typeof current == "function")
try {
const user = current.call(owner);
if (user) return user;
} catch {
}
}
return null;
}
function discourseNativeCurrentUserBindingAvailable(host) {
const currentUser = host.lookup("service:current-user");
if (currentUser != null) return !0;
const userModule = (0, import_value_record.valueRecord)(host.lookupModule("discourse/models/user"));
return [(0, import_value_record.valueRecord)(userModule?.default), userModule].some((owner) => typeof owner?.current == "function");
}
function discourseNativeCurrentUsername(host) {
return String(
nativeModelValue(
discourseNativeCurrentUser(host),
"username"
) ?? ""
).trim();
}
function discourseNativeSiteLogoUrl(host, baseUrl, fallbackCandidates = []) {
const settings = (0, import_value_record.objectRecord)(host.lookup("service:site-settings")), candidates = [
settings?.large_icon,
settings?.largeIcon,
settings?.apple_touch_icon,
settings?.appleTouchIcon,
settings?.favicon,
...fallbackCandidates
];
for (const candidate of candidates) {
const value = String(candidate ?? "").trim();
if (value)
try {
const url = new URL(value, baseUrl);
if (url.protocol === "https:" || url.protocol === "http:")
return url.href;
} catch {
}
}
return new URL("/favicon.ico", baseUrl).href;
}
function discourseNativeUserActionBinding(host, usernameValue) {
const username = String(usernameValue).trim().replace(/^@+/, "");
if (!username) throw new Error("User action username 不能为空");
const store = (0, import_value_record.objectRecord)(host.lookup("service:store")), createRecord = store?.createRecord;
if (typeof createRecord != "function")
throw new Error("Discourse 原生 store.createRecord 尚未就绪");
const user = createRecord.call(
store,
"user",
/* Ember Data 会在 createRecord 期间向属性袋补入 store 等内部字段。 */
{ username }
), actingUser = host.lookup("service:current-user");
if (!(0, import_value_record.objectRecord)(user) || !(0, import_value_record.objectRecord)(actingUser))
throw new Error("Discourse 原生用户动作 binding 尚未就绪");
return Object.freeze({
user,
actingUser
});
}
function discourseNativeJqueryModule(host) {
return host.lookupModule("jquery");
}
function discourseNativeFlagCatalog(host) {
const site = host.lookup("service:site"), postActionTypeModule = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/models/post-action-type")
), postActionTypeDefault = (0, import_value_record.objectRecord)(postActionTypeModule?.default), maxMessageLength = Number(
postActionTypeModule?.MAX_MESSAGE_LENGTH ?? postActionTypeDefault?.MAX_MESSAGE_LENGTH
), list = (value) => {
if (Array.isArray(value)) return value;
if (typeof (value !== null && (typeof value == "object" || typeof value == "function") ? value[Symbol.iterator] : void 0) != "function") return Object.freeze([]);
try {
return Object.freeze(Array.from(value));
} catch {
return Object.freeze([]);
}
};
return Object.freeze({
flagTypes() {
return Object.freeze(list(nativeModelValue(site, "flagTypes")).map((value) => {
const id = Number(nativeModelValue(value, "id")), nameKey = String(
nativeModelValue(value, "name_key") ?? ""
).trim();
if (!Number.isSafeInteger(id) || id <= 0 || !nameKey)
return null;
const appliesToValue = nativeModelValue(value, "applies_to"), appliesTo = Array.isArray(appliesToValue) ? appliesToValue.map(String).map((entry) => entry.trim()).filter(Boolean) : [];
return Object.freeze({
id,
nameKey,
label: String(
nativeModelValue(value, "name") ?? nameKey
).trim() || nameKey,
description: String(
nativeModelValue(value, "description") ?? nativeModelValue(value, "short_description") ?? ""
),
requireMessage: nativeModelValue(value, "require_message") === !0,
enabled: nativeModelValue(value, "enabled") !== !1,
appliesTo: Object.freeze(appliesTo)
});
}).filter(
(value) => value !== null
));
},
messageMaxLength() {
return Number.isSafeInteger(maxMessageLength) && maxMessageLength > 0 ? maxMessageLength : 500;
}
});
}
function discourseNativeEmojiMenu(host) {
return Object.freeze({
async show(anchor, request) {
const menu = (0, import_value_record.objectRecord)(host.lookup("service:menu")), component = (0, import_value_record.objectRecord)(host.lookupModule(
"discourse/components/emoji-picker/detached"
))?.default, show = menu?.show;
if (typeof show != "function" || !component)
throw new Error("Discourse 原生表情组件尚未就绪");
const identifier = String(request.identifier).trim(), context = String(request.context).trim();
if (!identifier || !context)
throw new Error("emoji menu identifier/context 不能为空");
await Promise.resolve(show.call(menu, anchor, {
identifier,
groupIdentifier: identifier,
component,
modalForMobile: !1,
strategy: "fixed",
fallbackPlacements: Object.freeze([
"bottom-start",
"top-start",
"bottom-end",
"top-end"
]),
...request.computePosition ? { computePosition: request.computePosition } : {},
data: Object.freeze({
term: "",
context,
didSelectEmoji: request.didSelectEmoji
})
}));
},
close(identifier) {
const menu = (0, import_value_record.objectRecord)(host.lookup("service:menu")), close = menu?.close, normalized = String(identifier).trim();
if (!(typeof close != "function" || !normalized))
try {
const result = close.call(menu, normalized);
result && typeof result.then == "function" && Promise.resolve(result).catch(() => {
});
} catch {
}
}
});
}
function discourseNativePostAdminMenu(host) {
return Object.freeze({
async show(anchor, post, scheduleRerender) {
const menu = (0, import_value_record.objectRecord)(host.lookup("service:menu")), component = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/components/admin-post-menu")
)?.default, topicController = (0, import_value_record.objectRecord)(host.lookup("controller:topic")), show = menu?.show, send = topicController?.send;
if (typeof show != "function" || !component || typeof send != "function")
throw new Error("Discourse 原生楼层管理菜单尚未就绪");
const topicAction = (name) => () => send.call(topicController, name, post);
await Promise.resolve(show.call(menu, anchor, {
identifier: "admin-post-menu",
component,
modalForMobile: !0,
autofocus: !0,
data: Object.freeze({
post,
changeNotice: topicAction("changeNotice"),
changePostOwner: topicAction("changePostOwner"),
grantBadge: topicAction("grantBadge"),
lockPost: topicAction("lockPost"),
permanentlyDeletePost: topicAction("permanentlyDeletePost"),
rebakePost: topicAction("rebakePost"),
showPagePublish: topicAction("showPagePublish"),
togglePostType: topicAction("togglePostType"),
toggleWiki: topicAction("toggleWiki"),
unhidePost: topicAction("unhidePost"),
unlockPost: topicAction("unlockPost"),
scheduleRerender
})
}));
}
});
}
class BrowserDiscourseNativeBookmarkForm {
#host;
constructor(host) {
this.#host = host;
}
build(subjectType, subjectIdValue) {
if (subjectType !== "Post" && subjectType !== "Topic")
throw new Error(`未知收藏目标:${String(subjectType)}`);
const subjectId = Number(subjectIdValue);
if (!Number.isSafeInteger(subjectId) || subjectId < 1)
throw new RangeError("bookmark subjectId 必须是正安全整数");
const api = (0, import_value_record.objectRecord)(this.#host.lookup("service:bookmark-api")), buildNewBookmark = api?.buildNewBookmark;
if (!api || typeof buildNewBookmark != "function")
throw new Error(
"Discourse 原生收藏依赖未就绪:service:bookmark-api"
);
const module2 = (0, import_value_record.objectRecord)(
this.#host.lookupModule("discourse/lib/bookmark-form-data")
), defaultExport = (0, import_value_record.objectRecord)(module2?.default), Constructor = module2?.BookmarkFormData ?? defaultExport?.BookmarkFormData ?? module2?.default;
if (typeof Constructor != "function")
throw new Error("Discourse BookmarkFormData 构造器未就绪");
const bookmark = (0, import_value_record.objectRecord)(
buildNewBookmark.call(api, subjectType, subjectId)
);
if (!bookmark)
throw new Error("bookmark-api.buildNewBookmark 未返回原生模型");
return new Constructor(bookmark);
}
}
function discourseNativePostRuntimeBindings(host) {
return Object.freeze({
topicModel: host.lookupModule("discourse/models/topic"),
topicDetailsModel: host.lookupModule("discourse/models/topic-details"),
postModel: host.lookupModule("discourse/models/post"),
appEvents: host.lookup("service:app-events"),
currentUser: host.lookup("service:current-user"),
siteSettings: host.lookup("service:site-settings")
});
}
function discourseNativeEmojiUrl(host, idValue) {
const id = String(idValue).trim().replace(/^:+|:+$/g, "");
if (!id) return "";
const module2 = (0, import_value_record.objectRecord)(host.lookupModule("discourse/lib/text")), defaultExport = (0, import_value_record.objectRecord)(module2?.default), owner = typeof module2?.emojiUrlFor == "function" ? module2 : defaultExport, emojiUrlFor = owner?.emojiUrlFor;
if (typeof emojiUrlFor != "function") return "";
try {
return String(emojiUrlFor.call(owner, id) ?? "").trim();
} catch {
return "";
}
}
function discourseNativeRelativeTimeFormatter(host) {
let owner = null;
return (timestamp) => {
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return "";
if (!owner || typeof owner.relativeAge != "function") {
const module2 = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/lib/formatter")
), defaultExport = (0, import_value_record.objectRecord)(module2?.default);
owner = typeof module2?.relativeAge == "function" ? module2 : defaultExport;
}
const relativeAge = owner?.relativeAge;
if (typeof relativeAge != "function") return "";
try {
return String(relativeAge.call(owner, date, {
format: "medium-with-ago",
wrapInSpan: !1
}) ?? "");
} catch {
return "";
}
};
}
function discourseNativeExactTimeFormatter(host) {
let owner = null;
return (timestamp) => {
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return "";
if (!owner || typeof owner.longDate != "function") {
const module2 = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/lib/formatter")
), defaultExport = (0, import_value_record.objectRecord)(module2?.default);
owner = typeof module2?.longDate == "function" ? module2 : defaultExport;
}
const longDate = owner?.longDate;
if (typeof longDate != "function") return "";
try {
return String(longDate.call(owner, date) ?? "");
} catch {
return "";
}
};
}
function discourseNativeTopicPresentation(host) {
const urlModule = (0, import_value_record.objectRecord)(host.lookupModule("discourse/lib/url")), urlDefault = (0, import_value_record.objectRecord)(urlModule?.default), urlOwner = typeof urlModule?.getCategoryAndTagUrl == "function" ? urlModule : urlDefault, avatarModule = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/lib/avatar-utils")
), avatarDefault = (0, import_value_record.objectRecord)(avatarModule?.default), avatarOwner = typeof avatarModule?.avatarUrl == "function" ? avatarModule : avatarDefault, categoryModel = (categoryId) => {
const categories = nativeModelValue(
host.lookup("service:site"),
"categories"
);
return Array.isArray(categories) ? categories.find((candidate) => Number(nativeModelValue(candidate, "id")) === categoryId) : void 0;
}, categoryAndTagUrl = (categoryId, tag) => {
const method = urlOwner?.getCategoryAndTagUrl, category = categoryId > 0 ? categoryModel(categoryId) : null;
if (categoryId > 0 && !category) return "";
if (typeof method == "function")
try {
const resolved = String(method.call(
urlOwner,
category,
!0,
tag
) ?? "");
if (resolved) return resolved;
} catch {
}
const normalizedTag = String(tag ?? "").trim();
if (normalizedTag) return `/tag/${encodeURIComponent(normalizedTag)}`;
const slug = String(nativeModelValue(category, "slug") ?? "").trim();
return categoryId > 0 && slug ? `/c/${encodeURIComponent(slug)}/${categoryId}` : "";
};
return Object.freeze({
avatarSource(template, size) {
const normalized = String(template ?? "").trim();
if (!normalized) return "";
const method = avatarOwner?.avatarUrl;
if (typeof method != "function")
return normalized.replace(/\{size\}/g, String(size));
try {
return (String(
method.call(avatarOwner, normalized, size) ?? ""
).trim() || normalized).replace(/\{size\}/g, String(size));
} catch {
return normalized.replace(/\{size\}/g, String(size));
}
},
categoryName(categoryId) {
return !Number.isSafeInteger(categoryId) || categoryId < 1 ? "" : String(
nativeModelValue(categoryModel(categoryId), "name") ?? ""
).trim();
},
categoryIcon(categoryId) {
return !Number.isSafeInteger(categoryId) || categoryId < 1 ? "" : String(
nativeModelValue(categoryModel(categoryId), "icon") ?? ""
).trim();
},
categoryHref(categoryId, tag) {
const normalizedId = Number.isSafeInteger(categoryId) && categoryId > 0 ? categoryId : 0, normalizedTag = String(tag ?? "").trim();
return categoryAndTagUrl(
normalizedId,
normalizedTag || void 0
);
},
tagHref(tag) {
const normalized = String(tag ?? "").trim();
return normalized ? categoryAndTagUrl(0, normalized) : "";
},
userHref(username) {
const normalized = String(username ?? "").trim();
if (!normalized) return "";
const userPath = urlOwner?.userPath;
if (typeof userPath == "function")
try {
return String(userPath.call(urlOwner, normalized) ?? "");
} catch {
return "";
}
return `/u/${encodeURIComponent(normalized)}`;
}
});
}
function discourseNativeTopicLinks(host, baseUrl) {
let normalizedBase = "";
try {
normalizedBase = new URL(baseUrl).href;
} catch {
normalizedBase = "";
}
return Object.freeze({
topicHref(topicIdValue, postNumberValue = 0) {
const topicId = Number(topicIdValue), postNumber = Number(postNumberValue);
if (!Number.isSafeInteger(topicId) || topicId < 1 || !Number.isSafeInteger(postNumber) || postNumber < 0 || !normalizedBase)
return "";
const path = `/t/${topicId}${postNumber ? `/${postNumber}` : ""}`;
try {
const module2 = (0, import_value_record.objectRecord)(
host.lookupModule("discourse/lib/get-url")
), defaultExport = module2?.default, getUrl = typeof defaultExport == "function" ? defaultExport : typeof module2?.getURL == "function" ? module2.getURL : null, nativeValue = getUrl ? String(getUrl.call(module2, path) ?? "").trim() : "", candidate = new URL(nativeValue || path, normalizedBase), segments = candidate.pathname.split("/").filter(Boolean), topicSegmentIndex = segments.indexOf("t"), candidateTopicId = Number(segments[topicSegmentIndex + 1]);
return candidate.origin === new URL(normalizedBase).origin && topicSegmentIndex >= 0 && candidateTopicId === topicId ? candidate.href : new URL(path, normalizedBase).href;
} catch {
return new URL(path, normalizedBase).href;
}
}
});
}
function nativeModelValue(value, key) {
const record = (0, import_value_record.objectRecord)(value), getter = record?.get;
if (typeof getter == "function")
try {
return getter.call(value, key);
} catch {
return;
}
return record?.[key];
}
function setNativeModelValues(value, values) {
const record = (0, import_value_record.objectRecord)(value), setProperties = record?.setProperties;
if (typeof setProperties == "function") {
setProperties.call(value, values);
return;
}
const set = record?.set;
if (typeof set == "function")
for (const [key, entry] of Object.entries(values))
set.call(value, key, entry);
}
function nativeCount(value) {
if (value == null || value === "") return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : null;
}
function currentUserUnreadCount(currentUser) {
const all = nativeCount(nativeModelValue(
currentUser,
"all_unread_notifications_count"
));
if (all !== null) return all;
const normal = nativeCount(nativeModelValue(
currentUser,
"unread_notifications"
)), high = nativeCount(nativeModelValue(
currentUser,
"unread_high_priority_notifications"
));
return normal !== null || high !== null ? (normal ?? 0) + (high ?? 0) : nativeCount(nativeModelValue(
currentUser,
"unread_notification_count"
)) ?? 0;
}
function nativeNotificationRecord(value) {
return (0, import_value_record.objectRecord)(value) ?? Object.freeze({});
}
function nativeNotificationData(value) {
const raw = nativeNotificationRecord(value).data;
if (raw !== null && typeof raw == "object") return raw;
if (typeof raw != "string" || !raw.trim()) return Object.freeze({});
try {
return nativeNotificationRecord(JSON.parse(raw));
} catch {
return Object.freeze({});
}
}
function nativePresentationText(value) {
const toHTML = (0, import_value_record.objectRecord)(value)?.toHTML;
let source = value;
if (typeof toHTML == "function")
try {
source = toHTML.call(value);
} catch {
return "";
}
return String(source ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
function fallbackNotificationPresentation(notification, typeName) {
const source = nativeNotificationRecord(notification), data = nativeNotificationData(source);
return Object.freeze({
actor: data.display_username ?? data.username ?? source.username,
typeName,
typeLabel: typeName || "通知",
summary: data.topic_title ?? typeName ?? "通知",
href: data.post_url ?? data.topic_url ?? data.url ?? "",
topicId: source.topic_id ?? data.topic_id,
postNumber: source.post_number ?? data.post_number
});
}
class BrowserDiscourseNotificationNativeState {
#host;
constructor(host) {
this.#host = host;
}
username() {
return String(nativeModelValue(
discourseNativeCurrentUser(this.#host),
"username"
) ?? "").trim().replace(/^@/, "");
}
unreadCount() {
return currentUserUnreadCount(
this.#host.lookup("service:current-user")
);
}
markAllRead() {
const currentUser = this.#host.lookup("service:current-user");
if (!currentUser) return;
const values = {
all_unread_notifications_count: 0,
unread_notifications: 0,
unread_high_priority_notifications: 0,
grouped_unread_notifications: Object.freeze({})
};
nativeModelValue(currentUser, "unread_notification_count") !== void 0 && (values.unread_notification_count = 0), setNativeModelValues(currentUser, Object.freeze(values));
}
markRead(input) {
const currentUser = this.#host.lookup("service:current-user");
if (!currentUser) return;
const normal = nativeCount(nativeModelValue(
currentUser,
"unread_notifications"
)) ?? 0, high = nativeCount(nativeModelValue(
currentUser,
"unread_high_priority_notifications"
)) ?? 0, nextNormal = input.highPriority ? normal : Math.max(0, normal - 1), nextHigh = input.highPriority ? Math.max(0, high - 1) : high, grouped = { ...(0, import_value_record.objectRecord)(nativeModelValue(
currentUser,
"grouped_unread_notifications"
)) ?? {} };
if (input.notificationTypeId !== null) {
const key = String(input.notificationTypeId), count = nativeCount(grouped[key]) ?? 0;
count > 0 && (grouped[key] = count - 1);
}
const values = {
grouped_unread_notifications: Object.freeze(grouped),
unread_notifications: nextNormal,
unread_high_priority_notifications: nextHigh,
all_unread_notifications_count: nextNormal + nextHigh
}, legacy = nativeCount(nativeModelValue(
currentUser,
"unread_notification_count"
));
legacy !== null && (values.unread_notification_count = Math.max(0, legacy - 1)), setNativeModelValues(currentUser, Object.freeze(values));
}
async present(notifications) {
const site = this.#host.lookup("service:site"), lookup = (0, import_value_record.objectRecord)(nativeModelValue(site, "notificationLookup")), typeName = (notification) => {
const source = nativeNotificationRecord(notification);
return String(lookup?.[String(Number(source.notification_type))] ?? "");
}, notificationModule = (0, import_value_record.objectRecord)(
this.#host.lookupModule("discourse/models/notification")
), Notification = (0, import_value_record.objectRecord)(notificationModule?.default), initialize = Notification?.initializeNotifications, managerModule = (0, import_value_record.objectRecord)(
this.#host.lookupModule("discourse/lib/notification-types-manager")
), manager = typeof managerModule?.getRenderDirector == "function" ? managerModule : (0, import_value_record.objectRecord)(managerModule?.default), getRenderDirector = manager?.getRenderDirector, currentUser = this.#host.lookup("service:current-user"), siteSettings = this.#host.lookup("service:site-settings");
if (typeof initialize != "function" || typeof getRenderDirector != "function" || !currentUser || !siteSettings || !site)
return Object.freeze(notifications.map((notification) => fallbackNotificationPresentation(
notification,
typeName(notification)
)));
let models;
try {
const source = notifications.map((notification) => Object.freeze({
...nativeNotificationRecord(notification),
data: nativeNotificationData(notification)
})), initialized = await initialize.call(Notification, source);
models = Array.isArray(initialized) ? initialized : source;
} catch {
return Object.freeze(notifications.map((notification) => fallbackNotificationPresentation(
notification,
typeName(notification)
)));
}
return Object.freeze(notifications.map((notification, index) => {
const model = models[index] ?? notification, modelData = nativeNotificationData(model), resolvedType = typeName(model) || typeName(notification);
try {
const director = (0, import_value_record.objectRecord)(getRenderDirector.call(
manager,
resolvedType,
model,
currentUser,
siteSettings,
site
));
if (!director)
return fallbackNotificationPresentation(
notification,
resolvedType
);
const label = nativePresentationText(director.label), description = nativePresentationText(director.description);
return Object.freeze({
actor: modelData.display_username ?? modelData.username ?? nativeModelValue(model, "username"),
typeName: resolvedType,
typeLabel: nativePresentationText(director.linkTitle) || resolvedType || "通知",
summary: [label, description].filter(Boolean).join(" · "),
href: String(director.linkHref ?? "").trim(),
topicId: nativeModelValue(model, "topic_id") ?? nativeModelValue(model, "topicId") ?? modelData.topic_id,
postNumber: nativeModelValue(model, "post_number") ?? nativeModelValue(model, "postNumber") ?? modelData.post_number
});
} catch {
return fallbackNotificationPresentation(
notification,
resolvedType
);
}
}));
}
subscribeChanged(listener) {
const appEvents = (0, import_value_record.objectRecord)(
this.#host.lookup("service:app-events")
), on = appEvents?.on, off = appEvents?.off;
if (typeof on != "function" || typeof off != "function") return () => {
};
const context = Object.freeze({});
try {
on.call(appEvents, "notifications:changed", context, listener);
} catch {
return () => {
};
}
let active = !0;
return () => {
if (active) {
active = !1;
try {
off.call(appEvents, "notifications:changed", context, listener);
} catch {
}
}
};
}
}
class BrowserDiscourseBookmarkNativeState {
#host;
constructor(host) {
this.#host = host;
}
username() {
return discourseNativeCurrentUsername(this.#host).replace(/^@/, "");
}
findGivenReactions(usernameValue, beforeReactionUserId) {
const username = String(usernameValue).trim().replace(/^@/, "");
if (!username)
return Promise.reject(new Error("回应记录需要当前登录用户名"));
const module2 = (0, import_value_record.objectRecord)(this.#host.lookupModule(
"discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction"
)), model = (0, import_value_record.valueRecord)(module2?.default), findReactions = model?.findReactions;
if (typeof findReactions != "function")
return Promise.reject(new Error("Discourse 回应记录接口尚未就绪"));
const cursor = Number(beforeReactionUserId);
return Promise.resolve(findReactions.call(
model,
"reactions",
username,
Object.freeze({
...Number.isSafeInteger(cursor) && cursor > 0 ? { beforeReactionUserId: cursor } : {}
})
));
}
subscribeChanged(listener) {
const appEvents = (0, import_value_record.objectRecord)(
this.#host.lookup("service:app-events")
), on = appEvents?.on, off = appEvents?.off;
if (typeof on != "function" || typeof off != "function") return () => {
};
const context = Object.freeze({}), subscriptions = Object.freeze([
["bookmarks:changed", "bookmarks"],
["discourse-reactions:reaction-toggled", "reactions"]
]), attached = [];
for (const [name, source] of subscriptions) {
const handler = () => listener(source);
try {
on.call(appEvents, name, context, handler), attached.push(Object.freeze({ name, handler }));
} catch {
}
}
let active = !0;
return () => {
if (active) {
active = !1;
for (const entry of attached)
try {
off.call(
appEvents,
entry.name,
context,
entry.handler
);
} catch {
}
}
};
}
}
function readyModule(name, value) {
if (typeof value == "function") return !0;
const module2 = (0, import_value_record.objectRecord)(value);
if (module2 === null || Reflect.ownKeys(module2).length === 0) return !1;
if (name !== "discourse/lib/text") return !0;
const defaultExport = (0, import_value_record.objectRecord)(module2.default);
return typeof module2.emojiUrlFor == "function" || typeof defaultExport?.emojiUrlFor == "function";
}
function nonEmptyName(value, kind) {
const normalized = String(value).trim();
if (!normalized) throw new Error(`Discourse ${kind} 名称不能为空`);
return normalized;
}
function moduleName(value) {
const normalized = nonEmptyName(value, "module");
if (normalized !== "jquery" && !normalized.startsWith("discourse/"))
throw new Error(`拒绝解析非 Discourse 原生 module:${normalized}`);
return normalized;
}
function callable(owner, name) {
const candidate = owner?.[name];
return typeof candidate == "function" ? candidate : null;
}
function moduleContainer(value) {
const module2 = (0, import_value_record.objectRecord)(value), defaultExport = (0, import_value_record.objectRecord)(module2?.default), candidate = (0, import_value_record.objectRecord)(defaultExport?.container ?? module2?.container);
return typeof candidate?.lookup == "function" ? candidate : null;
}
class BrowserDiscourseHostApiPort {
#pageWindow;
#modules = /* @__PURE__ */ new Map();
#lookups = /* @__PURE__ */ new Map();
#container = null;
constructor(options) {
const pageWindow = (0, import_value_record.objectRecord)(options.pageWindow);
if (!pageWindow) throw new Error("Discourse page window 不可用");
this.#pageWindow = pageWindow;
}
lookup(nameValue) {
const name = nonEmptyName(nameValue, "container lookup"), volatile = name === "service:current-user";
if (!volatile && this.#lookups.has(name)) return this.#lookups.get(name);
const container = this.#resolveContainer();
if (!container) return null;
try {
const value = container.lookup(name);
return !volatile && value !== null && value !== void 0 && this.#lookups.set(name, value), value ?? null;
} catch {
return null;
}
}
lookupModule(nameValue) {
const name = moduleName(nameValue);
if (this.#modules.has(name)) return this.#modules.get(name);
const broker = (0, import_value_record.objectRecord)(this.#pageWindow.moduleBroker), brokerLookup = callable(broker, "lookup"), requireModule = callable(this.#pageWindow, "require"), requireJsModule = callable(this.#pageWindow, "requirejs"), resolvers = Object.freeze([
() => brokerLookup?.call(broker, name, !0),
() => requireModule?.call(this.#pageWindow, name),
() => requireJsModule?.call(this.#pageWindow, name)
]);
for (const resolve of resolvers)
try {
const value = resolve();
if (readyModule(name, value))
return this.#modules.set(name, value), value;
} catch {
}
return null;
}
#resolveContainer() {
if (this.#container) return this.#container;
const urlContainer = moduleContainer(this.lookupModule("discourse/lib/url")), discourse = (0, import_value_record.objectRecord)(this.#pageWindow.Discourse), fallback = (0, import_value_record.objectRecord)(discourse?.__container__ ?? discourse?.container), fallbackContainer = typeof fallback?.lookup == "function" ? fallback : null;
return this.#container = urlContainer ?? fallbackContainer, this.#container;
}
}
}, "e604ebb94db5ea5c86eaa0715d6c79b7453fef8f284a2639ea0c32e9948659fe");
/* Source: lite/src/discourse/native-message-bus.ts */
runtime.register("src/discourse/native-message-bus.js", function(module, exports, require) {
var native_message_bus_exports = {};
__export(native_message_bus_exports, {
BrowserDiscourseMessageBusPort: () => BrowserDiscourseMessageBusPort
});
module.exports = __toCommonJS(native_message_bus_exports);
function channelName(value) {
const channel = String(value).trim();
if (!channel.startsWith("/") || channel.includes("://"))
throw new Error("Discourse MessageBus channel 必须是站内 /channel");
return channel;
}
function resolveMessageBus(host) {
const service = host.lookup("service:message-bus");
if (!service || typeof service != "object" || typeof service.subscribe != "function" || typeof service.unsubscribe != "function")
throw new Error("Discourse 原生 service:message-bus 不可用");
return {
owner: service,
subscribe: service.subscribe,
unsubscribe: service.unsubscribe
};
}
class BrowserDiscourseMessageBusPort {
nativeBinding = "service:message-bus";
#host;
constructor(host) {
this.#host = host;
}
subscribe(channelValue, handler) {
const channel = channelName(channelValue);
if (typeof handler != "function") throw new TypeError("MessageBus handler 必须是函数");
const resolved = resolveMessageBus(this.#host);
resolved.subscribe.call(resolved.owner, channel, handler);
}
unsubscribe(channelValue, handler) {
const channel = channelName(channelValue);
if (typeof handler != "function") throw new TypeError("MessageBus handler 必须是函数");
const resolved = resolveMessageBus(this.#host);
resolved.unsubscribe.call(resolved.owner, channel, handler);
}
}
}, "dfd0d9c34f350565f27200d1252e0f64980464c9690b96689242c7a3f21346ef");
/* Source: lite/src/discourse/native-post-model-factory.ts */
runtime.register("src/discourse/native-post-model-factory.js", function(module, exports, require) {
var native_post_model_factory_exports = {};
__export(native_post_model_factory_exports, {
DiscourseNativePostModelFactory: () => DiscourseNativePostModelFactory
});
module.exports = __toCommonJS(native_post_model_factory_exports);
var import_identifiers = require("./identifiers.js"), import_native_host_api = require("./native-host-api.js"), import_native_message_bus = require("./native-message-bus.js"), import_value_record = require("../kernel/value-record.js");
function moduleDefault(moduleValue, name) {
const module2 = (0, import_value_record.valueRecord)(moduleValue), value = (0, import_value_record.valueRecord)(module2?.default);
if (!value) throw new Error(`Discourse 原生模块未就绪:${name}`);
return value;
}
function modelValue(value, key) {
const target = (0, import_value_record.valueRecord)(value), getter = target?.get;
return typeof getter == "function" ? getter.call(target, key) : target?.[key];
}
function setModelValue(value, key, next) {
const target = (0, import_value_record.valueRecord)(value), set = target?.set;
if (typeof set == "function") {
set.call(target, key, next);
return;
}
target && (target[key] = next);
}
function reactionId(value) {
return String(value ?? "").trim().replace(/^:+|:+$/g, "");
}
function modelFactory(moduleValue, name) {
const model = moduleDefault(moduleValue, name);
if (typeof model.create != "function")
throw new Error(`Discourse 原生 model 缺少 create:${name}`);
return model;
}
class DiscourseNativePostModelFactory {
#host;
#bindings = null;
constructor(host) {
this.#host = host;
}
/**
* 只解析并校验回复 Composer 必需的 Topic/Post 原生 model。
*
* 该预热入口不创建 model、不读取 draft,也不触发任何宿主请求;实际回复仍由
* DiscourseComposerCoordinator 在用户操作后创建对应 Topic/Post 实例。
*/
prepareComposerBindings() {
const bindings = this.#runtimeBindings();
if (modelFactory(bindings.topicModel, "discourse/models/topic"), typeof modelFactory(bindings.postModel, "discourse/models/post").munge != "function")
throw new Error("Discourse Post.munge 未就绪");
}
createTopic(topic) {
const bindings = this.#runtimeBindings();
return this.#createTopic(topic, bindings.topicModel);
}
createTopicDetails(topic) {
const bindings = this.#runtimeBindings(), TopicDetails = modelFactory(
bindings.topicDetailsModel,
"discourse/models/topic-details"
), details = (0, import_value_record.valueRecord)(topic.details), topicRecord = topic;
return TopicDetails.create({
...details ?? {},
topic: this.#createTopic(topic, bindings.topicModel),
notification_level: topicRecord.notification_level ?? modelValue(details, "notification_level") ?? 1
});
}
createPost(topic, post, topicModel) {
const bindings = this.#runtimeBindings(), owner = topicModel ?? this.#createTopic(topic, bindings.topicModel);
return this.#createPost(topic, post, owner, bindings.postModel);
}
createContext(topic, post) {
const bindings = this.#runtimeBindings(), appEvents = (0, import_value_record.valueRecord)(bindings.appEvents);
if (!appEvents) throw new Error("Discourse app-events service 未就绪");
const topicModel = this.#createTopic(topic, bindings.topicModel);
return Object.freeze({
topic: topicModel,
post: this.#createPost(topic, post, topicModel, bindings.postModel),
appEvents
});
}
/**
* discourse-calendar 的活动报名必须携带插件原生 Event model。
*
* 特殊正文 feature 只提交 canonical event JSON;module lookup 与 model.create 继续
* 收口在同一原生 model 工厂,不能由 DOM 组件自行解析插件模块。
*/
createPostEvent(event, fallbackPostId) {
const EventModel = modelFactory(
(0, import_native_host_api.discourseNativePostEventModel)(this.#host),
"discourse-post-event-event"
), id = Number(event.id) || Number(fallbackPostId);
if (!Number.isSafeInteger(id) || id < 1)
throw new RangeError("活动必须具有正安全整数 ID");
return EventModel.create({
...event,
id
});
}
/**
* Discourse `/client_settings` 是回应目录热更新的唯一宿主事件源。
*
* 收到消息后先更新原生 site-settings service,再通知现有 PostView 重投;本方法
* 不请求 emoji 目录、不维护第二份设置快照,也不暴露 MessageBus 给 UI。
*/
subscribeClientSettings(listener) {
if (typeof listener != "function")
throw new TypeError("client settings listener 必须是函数");
const messageBus = new import_native_message_bus.BrowserDiscourseMessageBusPort(this.#host), handler = (message) => {
const input = (0, import_value_record.valueRecord)(message), name = String(input?.name ?? "").trim();
[
"discourse_reactions_enabled_reactions",
"discourse_reactions_reaction_for_like"
].includes(name) && (setModelValue(
this.#runtimeBindings().siteSettings,
name,
input?.value
), listener());
};
try {
messageBus.subscribe("/client_settings", handler);
} catch {
return () => {
};
}
return () => {
try {
messageBus.unsubscribe("/client_settings", handler);
} catch {
}
};
}
/**
* 需要原生 current-user model 作为参数的动作共用这一窄入口。
*
* 不把 service lookup 暴露给 UI;匿名态返回 null,由动作组件保持原按钮不可用。
*/
currentUser() {
return (0, import_value_record.valueRecord)(this.#runtimeBindings().currentUser);
}
sharedIssueAllowsMultipleSolutions() {
return modelValue(
this.#runtimeBindings().siteSettings,
"solved_allow_multiple_solutions"
) === !0;
}
minimumPostLength() {
const value = Number(modelValue(
this.#runtimeBindings().siteSettings,
"min_post_length"
));
return Number.isSafeInteger(value) && value > 0 ? value : 16;
}
reactionRegistry() {
const bindings = this.#runtimeBindings(), settings = (0, import_value_record.valueRecord)(bindings.siteSettings), configuredValue = modelValue(
settings,
"discourse_reactions_enabled_reactions"
), source = Array.isArray(configuredValue) ? configuredValue : String(configuredValue ?? "").split("|"), configuredIds = Object.freeze(
source.map(reactionId).filter(Boolean)
), mainReaction = reactionId(modelValue(
settings,
"discourse_reactions_reaction_for_like"
));
return Object.freeze({
configuredIds,
mainReaction,
emojiUrl: (id) => (0, import_native_host_api.discourseNativeEmojiUrl)(this.#host, reactionId(id))
});
}
reportContext(topic, post, nameKeys) {
const native = this.createContext(topic, post), actionByName = modelValue(native.post, "actionByName"), actions = [...new Set(nameKeys.map(String).map((name) => name.trim()).filter(Boolean))].map((nameKey) => {
const action = modelValue(actionByName, nameKey), actionRecord = (0, import_value_record.valueRecord)(action);
return modelValue(action, "can_act") !== !0 || !actionRecord ? null : (Number(post.post_number) === 1 && (actionRecord.flagTopic = native.post), Object.freeze({
nameKey,
action
}));
}).filter(
(value) => value !== null
);
return Object.freeze({
post: native.post,
actions: Object.freeze(actions)
});
}
#createTopic(topic, topicModule) {
const topicId = (0, import_identifiers.discourseTopicId)(topic.id), Topic = modelFactory(topicModule, "discourse/models/topic"), details = (0, import_value_record.valueRecord)(topic.details);
return Topic.create({
id: topicId,
title: String(topic.title ?? ""),
fancy_title: String(topic.fancy_title ?? topic.title ?? ""),
slug: String(topic.slug ?? "topic"),
category_id: topic.category_id,
archetype: String(topic.archetype ?? "regular"),
draft_key: topic.draft_key,
draft_sequence: topic.draft_sequence,
posts_count: topic.posts_count,
highest_post_number: topic.highest_post_number,
last_posted_at: topic.last_posted_at,
chunk_size: topic.chunk_size,
details: { can_create_post: !0, ...details ?? {} },
tags: Array.isArray(topic.tags) ? [...topic.tags] : []
});
}
#createPost(topic, post, topicModel, postModule) {
const topicId = (0, import_identifiers.discourseTopicId)(topic.id);
if (post.topic_id !== void 0 && (0, import_identifiers.discourseTopicId)(post.topic_id) !== topicId)
throw new Error("楼层不属于目标 Topic");
const Post = modelFactory(postModule, "discourse/models/post");
if (typeof Post.munge != "function")
throw new Error("Discourse Post.munge 未就绪");
const reference = (0, import_identifiers.discoursePostReference)(post), actions = Array.isArray(post.actions_summary) ? post.actions_summary.map((action) => action && typeof action == "object" ? { ...action } : action) : [], attributes = {
...post,
id: reference.postId,
topic_id: topicId,
post_number: reference.postNumber,
username: String(post.username ?? ""),
name: String(post.name ?? ""),
avatar_template: String(post.avatar_template ?? ""),
post_type: Number(post.post_type ?? 1),
reply_count: Number(post.reply_count ?? 0),
topic: topicModel,
actions_summary: actions
}, munge = Post.munge;
return Post.create(munge.call(Post, attributes));
}
#runtimeBindings() {
if (this.#bindings) return this.#bindings;
const bindings = (0, import_native_host_api.discourseNativePostRuntimeBindings)(this.#host);
return Object.values(bindings).every((value) => value != null) && (this.#bindings = bindings), bindings;
}
}
}, "c492019f71ca4690d179e142f690ae7cd181e8b77625f562c85b5bff3e4330fe");
/* Source: lite/src/discourse/native-presence.ts */
runtime.register("src/discourse/native-presence.js", function(module, exports, require) {
var native_presence_exports = {};
__export(native_presence_exports, {
BrowserDiscoursePresencePort: () => BrowserDiscoursePresencePort
});
module.exports = __toCommonJS(native_presence_exports);
var import_value_record = require("../kernel/value-record.js");
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 normalizeUsers(value) {
let source = [];
if (Array.isArray(value)) source = value;
else {
const toArray = (0, import_value_record.valueRecord)(value)?.toArray;
if (typeof toArray == "function")
try {
const result = toArray.call(value);
Array.isArray(result) && (source = result);
} catch {
source = [];
}
else value && typeof value[Symbol.iterator] == "function" && (source = [...value]);
}
const users = source.map((candidate) => {
const username = String(
modelValue(candidate, "username") ?? ""
).trim();
return username ? Object.freeze({
username,
name: String(
modelValue(candidate, "name") ?? username
).trim() || username,
avatarTemplate: String(
modelValue(candidate, "avatar_template") ?? modelValue(candidate, "avatarTemplate") ?? ""
).trim()
}) : null;
}).filter((user) => user !== null);
return Object.freeze(users);
}
function topicIdentifier(value) {
const topicId = Number(value);
if (!Number.isSafeInteger(topicId) || topicId < 1)
throw new RangeError("Presence topicId 必须是正安全整数");
return topicId;
}
class BrowserDiscoursePresencePort {
nativeBinding = "service:presence";
#host;
constructor(host) {
this.#host = host;
}
watchReplying(topicIdValue, listener, onError = () => {
}) {
const topicId = topicIdentifier(topicIdValue);
if (typeof listener != "function")
throw new TypeError("Presence listener 必须是函数");
const service = (0, import_value_record.valueRecord)(this.#host.lookup("service:presence")), getChannel = service?.getChannel;
if (typeof getChannel != "function")
return listener(Object.freeze([])), () => {
};
let channel = null;
try {
channel = (0, import_value_record.valueRecord)(
getChannel.call(
service,
`/discourse-presence/reply/${topicId}`
)
);
} catch (error) {
onError(error);
}
if (!channel || typeof channel.subscribe != "function" || typeof channel.unsubscribe != "function")
return listener(Object.freeze([])), () => {
};
const nativeChannel = channel, subscribe = nativeChannel.subscribe, unsubscribe = nativeChannel.unsubscribe;
let active = !0, subscriptionSettled = !1, unsubscribeIssued = !1;
const publish = () => {
if (active)
try {
listener(normalizeUsers(modelValue(nativeChannel, "users")));
} catch (error) {
onError(error);
}
}, change = () => publish();
if (typeof nativeChannel.on == "function")
try {
nativeChannel.on.call(nativeChannel, "change", change);
} catch (error) {
onError(error);
}
const issueUnsubscribe = () => {
if (!unsubscribeIssued) {
unsubscribeIssued = !0;
try {
Promise.resolve(
unsubscribe.call(nativeChannel)
).catch(onError);
} catch (error) {
onError(error);
}
}
};
try {
Promise.resolve(
subscribe.call(nativeChannel)
).then(() => {
subscriptionSettled = !0, active ? publish() : issueUnsubscribe();
}).catch((error) => {
subscriptionSettled = !0, active && (onError(error), listener(Object.freeze([]))), issueUnsubscribe();
});
} catch (error) {
subscriptionSettled = !0, onError(error), listener(Object.freeze([])), issueUnsubscribe();
}
return () => {
if (active) {
if (active = !1, typeof nativeChannel.off == "function")
try {
nativeChannel.off.call(nativeChannel, "change", change);
} catch (error) {
onError(error);
}
subscriptionSettled && issueUnsubscribe();
}
};
}
}
}, "785adf7cee061f235c01912b31517054fd9e90a0c2a16c7bc51fd86ae56433d1");
/* Source: lite/src/discourse/native-request-descriptors.ts */
runtime.register("src/discourse/native-request-descriptors.js", function(module, exports, require) {
var native_request_descriptors_exports = {};
__export(native_request_descriptors_exports, {
DISCOURSE_DIRECT_REPLIES_PAGE_SIZE: () => DISCOURSE_DIRECT_REPLIES_PAGE_SIZE,
DiscourseNativeRequests: () => DiscourseNativeRequests,
assertDiscourseNativeMutationDescriptor: () => assertDiscourseNativeMutationDescriptor,
assertDiscourseNativeReadDescriptor: () => assertDiscourseNativeReadDescriptor,
discourseBasePath: () => discourseBasePath,
discourseNativeTargetFailureIsDefinitive: () => discourseNativeTargetFailureIsDefinitive
});
module.exports = __toCommonJS(native_request_descriptors_exports);
var import_identifiers = require("./identifiers.js");
const nativeReadDescriptorBrand = Symbol("DiscourseNativeReadDescriptor"), nativeMutationDescriptorBrand = Symbol("DiscourseNativeMutationDescriptor"), nativeReadDescriptors = /* @__PURE__ */ new WeakSet(), nativeMutationDescriptors = /* @__PURE__ */ new WeakSet(), DISCOURSE_DIRECT_REPLIES_PAGE_SIZE = 20;
function discourseNativeTargetFailureIsDefinitive(input) {
return input.scope === "single" && input.endpoint === "post-by-number" && (input.status === 404 || input.status === 410);
}
function discourseBasePath(value) {
const normalized = String(value ?? "").trim().replace(/\/+$/, "");
if (normalized && !normalized.startsWith("/") && !/^https?:\/\//i.test(normalized))
throw new Error("basePath 必须是绝对 URL 或以 / 开头的路径");
return normalized;
}
function encodedSlug(value) {
const slug = String(value ?? "topic").trim();
if (!slug) throw new Error("slug 不能为空");
return encodeURIComponent(slug);
}
function readDescriptor(operation, path, options = {}) {
const descriptor = Object.freeze({
operation,
path,
headers: Object.freeze({ ...options.headers ?? {} }),
browserCache: options.browserCache ?? "default",
[nativeReadDescriptorBrand]: !0
});
return nativeReadDescriptors.add(descriptor), descriptor;
}
function assertDiscourseNativeReadDescriptor(value) {
if (value === null || typeof value != "object" || !nativeReadDescriptors.has(value))
throw new Error("读取请求必须来自 Discourse 原生请求目录");
}
function assertDiscourseNativeMutationDescriptor(value) {
if (value === null || typeof value != "object" || !nativeMutationDescriptors.has(value))
throw new Error("写请求必须来自 Discourse 原生请求目录");
}
function targetDescriptor(endpoint, url, refresh) {
return Object.freeze({
endpoint,
url,
descriptor: readDescriptor("target-post", url, {
headers: { Accept: "application/json" },
browserCache: refresh ? "no-store" : "default"
})
});
}
const DiscourseNativeRequests = Object.freeze({
topic(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), basePath = discourseBasePath(input.basePath);
return readDescriptor(
"topic",
`${basePath}/t/${topicId}.json?track_visit=true&forceLoad=true`,
{
headers: {
Accept: "application/json",
"Discourse-Track-View": "true",
"Discourse-Track-View-Topic-Id": String(topicId)
}
}
);
},
postsById(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), query = (0, import_identifiers.discoursePostIds)(input.postIds).map((postId) => `post_ids[]=${encodeURIComponent(postId)}`).join("&");
return readDescriptor(
"posts-by-id",
`${discourseBasePath(input.basePath)}/t/${topicId}/posts.json?${query}`,
{ headers: { Accept: "application/json" } }
);
},
postById(input) {
const postId = (0, import_identifiers.discoursePostId)(input.postId);
return readDescriptor(
"post-by-id",
`${discourseBasePath(input.basePath)}/posts/${postId}.json`,
{
headers: { Accept: "application/json" },
browserCache: "no-store"
}
);
},
targetCandidates(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), postNumber = (0, import_identifiers.discoursePostNumber)(input.postNumber), basePath = discourseBasePath(input.basePath), slug = encodedSlug(input.slug), refresh = input.refresh === !0, paths = Object.freeze({
"post-by-number": `${basePath}/posts/by_number/${topicId}/${postNumber}.json`,
"topic-floor": `${basePath}/t/${slug}/${topicId}/${postNumber}.json`,
"topic-query": `${basePath}/t/${slug}/${topicId}.json?post_number=${postNumber}`,
"topic-id-query": `${basePath}/t/${topicId}.json?post_number=${postNumber}`
}), order = input.scope === "around" ? ["topic-floor", "topic-query", "topic-id-query", "post-by-number"] : ["post-by-number", "topic-floor", "topic-id-query"];
return Object.freeze(order.map((endpoint) => targetDescriptor(endpoint, paths[endpoint], refresh)));
},
directReplies(input) {
const parentPostId = (0, import_identifiers.discoursePostId)(input.parentPostId), after = (0, import_identifiers.discourseReplyCursor)(input.after), suffix = after > 0 ? `?after=${after}` : "";
return readDescriptor(
"direct-replies",
`${discourseBasePath(input.basePath)}/posts/${parentPostId}/replies.json${suffix}`,
{
headers: { Accept: "application/json" },
browserCache: "no-store"
}
);
},
postVotingComments(input) {
const postId = (0, import_identifiers.discoursePostId)(input.postId), afterCommentId = Number(input.afterCommentId ?? 0);
if (!Number.isSafeInteger(afterCommentId) || afterCommentId < 0)
throw new RangeError("afterCommentId 必须是非负安全整数");
const query = new URLSearchParams({ post_id: String(postId) });
return afterCommentId > 0 && query.set("last_comment_id", String(afterCommentId)), readDescriptor(
"post-voting-comments",
`${discourseBasePath(input.basePath)}/post_voting/comments?${query}`,
{
headers: { Accept: "application/json" },
browserCache: "no-store"
}
);
},
boostReportAccess(input) {
const boostId = Number(input.boostId);
if (!Number.isSafeInteger(boostId) || boostId <= 0)
throw new RangeError("boostId 必须是正安全整数");
return readDescriptor(
"boost-report-access",
`${discourseBasePath(input.basePath)}/discourse-boosts/boosts/${boostId}.json`,
{
headers: { Accept: "application/json" },
browserCache: "no-store"
}
);
},
endorsableCategories(input) {
const username = String(input.username).trim().replace(/^@+/, "");
if (!username) throw new Error("username 不能为空");
return readDescriptor(
"endorsable-categories",
`${discourseBasePath(input.basePath)}/category-experts/endorsable-categories/${encodeURIComponent(username)}.json`,
{ headers: { Accept: "application/json" } }
);
},
userFollowList(input) {
const username = String(input.username).trim().replace(/^@+/, "");
if (!username) throw new Error("username 不能为空");
if (input.kind !== "following" && input.kind !== "followers")
throw new Error("关注列表类型无效");
return readDescriptor(
"user-follow-list",
`${discourseBasePath(input.basePath)}/u/${encodeURIComponent(username)}/follow/${input.kind}`,
{ headers: { Accept: "application/json" } }
);
},
userBadges(input) {
const username = String(input.username).trim().replace(/^@+/, "");
if (!username) throw new Error("username 不能为空");
return readDescriptor(
"user-badges",
`${discourseBasePath(input.basePath)}/user-badges/${encodeURIComponent(username)}.json`,
{ headers: { Accept: "application/json" } }
);
},
userSummary(input) {
const username = String(input.username).trim().replace(/^@+/, "");
if (!username) throw new Error("username 不能为空");
return readDescriptor(
"user-summary",
`${discourseBasePath(input.basePath)}/u/${encodeURIComponent(username)}/summary.json`,
{ headers: { Accept: "application/json" } }
);
},
userDirectoryStats(input) {
const username = String(input.username).trim().replace(/^@+/, "");
if (!username) throw new Error("username 不能为空");
const query = new URLSearchParams({
period: "all",
order: "likes_received",
username
});
return readDescriptor(
"user-directory-stats",
`${discourseBasePath(input.basePath)}/directory_items.json?${query}`,
{ headers: { Accept: "application/json" } }
);
},
topicTimings(input) {
const topicId = (0, import_identifiers.discourseTopicId)(input.topicId), postNumbers = (0, import_identifiers.discoursePostNumbers)(input.postNumbers), readTimeMs = Number(input.readTimeMs);
if (!Number.isSafeInteger(readTimeMs) || readTimeMs < 1 || readTimeMs > 6e4)
throw new RangeError("readTimeMs 必须是 1..60000 的安全整数");
const timings = Object.fromEntries(postNumbers.map((postNumber) => [
String(postNumber),
readTimeMs
])), data = Object.freeze({
topic_id: topicId,
topic_time: readTimeMs * postNumbers.length,
timings: Object.freeze(timings)
}), descriptor = Object.freeze({
operation: "topic-timings",
path: `${discourseBasePath(input.basePath)}/topics/timings`,
method: "POST",
headers: Object.freeze({
"Discourse-Background": "true",
"X-SILENCE-LOGGER": "true"
}),
data,
[nativeMutationDescriptorBrand]: !0
});
return nativeMutationDescriptors.add(descriptor), descriptor;
}
});
}, "921bba71979f25d4c7bcf7b1dfbaa8354e25d7c589db3d7addfadca0a3ec858d");
/* Source: lite/src/discourse/native-topic-notification-action.ts */
runtime.register("src/discourse/native-topic-notification-action.js", function(module, exports, require) {
var native_topic_notification_action_exports = {};
__export(native_topic_notification_action_exports, {
BrowserDiscourseTopicNotificationLevelMutationPort: () => BrowserDiscourseTopicNotificationLevelMutationPort
});
module.exports = __toCommonJS(native_topic_notification_action_exports);
var import_native_post_model_factory = require("./native-post-model-factory.js"), import_discourse_action_descriptors = require("../post/discourse-action-descriptors.js"), import_discourse_action_transport = require("../post/discourse-action-transport.js"), import_value_record = require("../kernel/value-record.js");
function modelValue(value, key) {
const source = (0, import_value_record.valueRecord)(value), getter = source?.get;
return typeof getter == "function" ? getter.call(value, key) : source?.[key];
}
class BrowserDiscourseTopicNotificationLevelMutationPort {
#models;
#descriptors = new import_discourse_action_descriptors.DiscourseActionDescriptors();
#actions;
constructor(host) {
this.#models = new import_native_post_model_factory.DiscourseNativePostModelFactory(host), this.#actions = new import_discourse_action_transport.BrowserDiscourseNativeActionPort(host);
}
async setLevel(topic, level) {
const topicId = Number(modelValue(topic, "id"));
if (!Number.isSafeInteger(topicId) || topicId < 1)
throw new RangeError("宿主话题通知动作缺少有效 Topic ID");
const topicDetails = this.#models.createTopicDetails(
topic
), mutation = this.#descriptors.topicNotificationLevel({
topicId,
topicDetails,
level
}), definition = (0, import_discourse_action_transport.discourseActionTransportDefinition)(
mutation.operation,
mutation.targetType
), response = await this.#actions.execute({
definition,
targetId: mutation.targetId,
variant: mutation.variant ?? null,
payload: mutation.payload,
signal: new AbortController().signal,
attempt: 0
});
if (!response.ok)
throw new Error(`宿主话题通知设置失败(HTTP ${response.status || 0})`);
}
}
}, "70e2eaa947da735f595616c706700d003b7deb8e12729a0093c9cc1f662f72f7");
/* Source: lite/src/discourse/reader-native-composer-window.ts */
runtime.register("src/discourse/reader-native-composer-window.js", function(module, exports, require) {
var reader_native_composer_window_exports = {};
__export(reader_native_composer_window_exports, {
DISCOURSE_NATIVE_FLOATING_SELECTOR: () => DISCOURSE_NATIVE_FLOATING_SELECTOR,
ReaderNativeComposerWindowController: () => ReaderNativeComposerWindowController,
discourseNativeFloatingSurfaceVisible: () => discourseNativeFloatingSurfaceVisible,
normalizeReaderNativeComposerGeometry: () => normalizeReaderNativeComposerGeometry,
readerNativeComposerFontPixels: () => readerNativeComposerFontPixels,
readerNativeTopLayerPort: () => readerNativeTopLayerPort,
visibleDiscourseNativeFloatingSurface: () => visibleDiscourseNativeFloatingSurface
});
module.exports = __toCommonJS(reader_native_composer_window_exports);
var import_floating_surface_wheel = require("../dom/floating-surface-wheel.js"), import_event_target = require("../dom/event-target.js"), import_lifecycle = require("../kernel/lifecycle.js"), import_reader_preferences_schema = require("../state/reader-preferences-schema.js");
const GEOMETRY_PROPERTIES = Object.freeze([
"--ldp-composer-left",
"--ldp-composer-top",
"--ldp-composer-width",
"--ldp-composer-height",
"--ldp-composer-transform"
]), APPEARANCE_PROPERTIES = Object.freeze([
"--tertiary",
"--tertiary-low",
"--d-link-color"
]), RESIZE_DIRECTIONS = Object.freeze([
"n",
"s",
"e",
"w",
"ne",
"nw",
"se",
"sw"
]), DISCOURSE_NATIVE_FLOATING_SELECTOR = ".fk-d-menu,.emoji-picker";
function discourseNativeFloatingSurfaceVisible(element, window = element.ownerDocument.defaultView) {
if (!element.isConnected || element.hidden || element.disabled || element.getAttribute("aria-hidden") === "true" || element.classList.contains("hidden") || element.classList.contains("d-none") || element.classList.contains("closed") || element.closest('[hidden],[aria-hidden="true"]')) return !1;
try {
const style = window?.getComputedStyle?.(element);
if (style?.display === "none" || style?.visibility === "hidden" || style?.contentVisibility === "hidden") return !1;
} catch {
}
const rect = element.getBoundingClientRect(), viewportWidth = Number(window?.innerWidth) || Number(element.ownerDocument.documentElement.clientWidth) || 0, viewportHeight = Number(window?.innerHeight) || Number(element.ownerDocument.documentElement.clientHeight) || 0;
return rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.bottom > 0 && (viewportWidth <= 0 || rect.left < viewportWidth) && (viewportHeight <= 0 || rect.top < viewportHeight);
}
function visibleDiscourseNativeFloatingSurface(document, window = document.defaultView) {
return [...document.querySelectorAll(
DISCOURSE_NATIVE_FLOATING_SELECTOR
)].find(
(element) => discourseNativeFloatingSurfaceVisible(element, window)
) ?? null;
}
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function finite(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function pixels(value) {
return `${Math.round(value * 100) / 100}px`;
}
function viewportLimits(viewport) {
const width = Math.max(1, finite(viewport.width, 1)), height = Math.max(1, finite(viewport.height, 1)), margin = Math.max(0, Math.min(
16,
Math.floor((width - 1) / 2),
Math.floor((height - 1) / 2)
)), maxWidth = Math.max(1, width - margin * 2), maxHeight = Math.max(1, height - margin * 2);
return Object.freeze({
left: margin,
top: margin,
right: width - margin,
bottom: height - margin,
maxWidth,
maxHeight,
minWidth: Math.min(520, maxWidth),
minHeight: Math.min(300, maxHeight)
});
}
function normalizeReaderNativeComposerGeometry(viewport, value) {
const bounds = viewportLimits(viewport), fallbackWidth = Math.min(1200, bounds.maxWidth), fallbackHeight = clamp(
bounds.maxHeight * 0.7,
bounds.minHeight,
bounds.maxHeight
), fallbackLeft = bounds.left + (bounds.maxWidth - fallbackWidth) / 2, fallbackTop = bounds.top + (bounds.maxHeight - fallbackHeight) / 2, width = clamp(
finite(value?.width, fallbackWidth),
bounds.minWidth,
bounds.maxWidth
), height = clamp(
finite(value?.height, fallbackHeight),
bounds.minHeight,
bounds.maxHeight
);
return Object.freeze({
left: clamp(
finite(value?.left, fallbackLeft),
bounds.left,
bounds.right - width
),
top: clamp(
finite(value?.top, fallbackTop),
bounds.top,
bounds.bottom - height
),
width,
height
});
}
function readerNativeComposerFontPixels(viewport, geometry, profile) {
const bounds = viewportLimits(viewport), widthTarget = Math.min(980, bounds.maxWidth), heightTarget = Math.min(720, bounds.maxHeight), widthProgress = clamp(
(geometry.width - bounds.minWidth) / Math.max(1, widthTarget - bounds.minWidth),
0,
1
), heightProgress = clamp(
(geometry.height - bounds.minHeight) / Math.max(1, heightTarget - bounds.minHeight),
0,
1
), automatic = 12 + Math.min(widthProgress, heightProgress) * 4, ratio = finite(profile.composer, import_reader_preferences_schema.READER_FONT_DEFAULT.composer) / import_reader_preferences_schema.READER_FONT_DEFAULT.composer;
return Math.round(clamp(automatic * ratio, 8, 40) * 100) / 100;
}
function readerNativeTopLayerPort() {
return Object.freeze({
isOpen: (element) => {
try {
return element.matches(":popover-open");
} catch {
return !1;
}
},
show: (element) => element.showPopover?.(),
hide: (element) => element.hidePopover?.()
});
}
class ReaderNativeComposerWindowController {
scope;
#document;
#window;
#mount;
#pageRoot;
#readPreferences;
#updatePreferences;
#readFontProfile;
#readAppearance;
#createMutationObserver;
#requestFrame;
#cancelFrame;
#setTimer;
#clearTimer;
#topLayer;
#onError;
#ownedTopLayers = /* @__PURE__ */ new Set();
#composer = null;
#composerRoot = null;
#composerAppearanceOriginal = /* @__PURE__ */ new Map();
#composerScope = null;
#hostObserver = null;
#floatingObserver = null;
#overflowLayers = [];
#chrome = null;
#geometry = null;
#pointer = null;
#pointerX = 0;
#pointerY = 0;
#pointerFrame = 0;
#chromeFrame = 0;
#overflowFrame = 0;
#floatingFrame = 0;
#persistTimer = 0;
#destroyed = !1;
constructor(options) {
this.#document = options.document, this.#window = options.window, this.#mount = options.mount, this.#pageRoot = options.pageRoot ?? options.document.documentElement, this.#readPreferences = options.readPreferences, this.#updatePreferences = options.updatePreferences ?? null, this.#readFontProfile = options.readFontProfile ?? (() => this.#readPreferences().fontProfile), this.#readAppearance = options.readAppearance ?? null, this.#createMutationObserver = options.createMutationObserver ?? ((callback) => new MutationObserver(callback)), this.#requestFrame = options.requestFrame ?? ((callback) => this.#window.requestAnimationFrame(callback)), this.#cancelFrame = options.cancelFrame ?? ((frameId) => this.#window.cancelAnimationFrame(frameId)), this.#setTimer = options.setTimer ?? ((callback, milliseconds) => this.#window.setTimeout(callback, milliseconds)), this.#clearTimer = options.clearTimer ?? ((timerId) => this.#window.clearTimeout(timerId)), this.#topLayer = options.topLayer ?? readerNativeTopLayerPort(), this.#onError = options.onError ?? (() => {
}), this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.scope.add(() => this.#teardown()), this.scope.listen(this.#window, "resize", () => {
this.#stopPointer(), this.#geometry ? this.#applyGeometry(this.#geometry) : this.#scheduleChrome(), this.#syncTopLayers(), this.#scheduleOverflow();
}), options.preferenceChanges.subscribe(() => {
this.#pointer || !this.#composerVisible() || (this.#geometry = this.#preferredGeometry(), this.#applyGeometry(this.#geometry));
}, this.scope), options.fontChanges?.subscribe((profile) => {
this.syncFont(profile);
}, this.scope), options.appearanceChanges?.subscribe((appearance) => {
this.#syncAppearance(appearance);
}, this.scope), this.#document.body && (this.#hostObserver = this.#createMutationObserver(() => {
this.sync(), this.#observeHostTargets();
}), this.#observeHostTargets(), this.scope.add(() => this.#hostObserver?.disconnect())), this.sync();
}
get geometry() {
return this.#geometry;
}
open(element = null) {
if (this.#destroyed) return !1;
const candidate = element ?? this.#document.querySelector("#reply-control");
return candidate !== this.#composer && this.#bindComposer(candidate), this.#composerAvailable() ? (this.#activate(), !0) : (this.#deactivate(), !1);
}
sync() {
if (this.#destroyed) return;
const candidate = this.#document.querySelector("#reply-control");
if (candidate !== this.#composer && this.#bindComposer(candidate), !this.#composerVisible()) {
this.#deactivate();
return;
}
this.#activate();
}
syncFont(profile = this.#readFontProfile()) {
!this.#composer || !this.#geometry || this.#composer.style.setProperty(
"--ldp-composer-font-size",
`${readerNativeComposerFontPixels(
this.#viewport(),
this.#geometry,
profile
)}px`
);
}
destroy() {
this.scope.destroy();
}
#viewport() {
return Object.freeze({
width: this.#window.innerWidth,
height: this.#window.innerHeight
});
}
#desktop() {
return this.#window.innerWidth > 760;
}
#preferredGeometry() {
const preferences = this.#readPreferences(), preferred = {
...preferences.composerWindowWidth ? { width: preferences.composerWindowWidth } : {},
...preferences.composerWindowHeight ? { height: preferences.composerWindowHeight } : {},
...preferences.composerWindowX ? { left: preferences.composerWindowX } : {},
...preferences.composerWindowY ? { top: preferences.composerWindowY } : {}
};
return normalizeReaderNativeComposerGeometry(
this.#viewport(),
preferred
);
}
#composerVisible() {
const composer = this.#composer;
if (!composer || !this.#composerAvailable()) return !1;
try {
const style = this.#window.getComputedStyle?.(composer), stagedByReader = style?.visibility === "hidden" && this.#pageRoot.classList.contains("ldp-reader-open") && !composer.dataset.ldpReaderComposerPositioned;
if (style?.display === "none" || style?.visibility === "hidden" && !stagedByReader)
return !1;
} catch {
}
return !0;
}
#composerAvailable() {
const composer = this.#composer;
return !!(composer?.isConnected && !composer.hidden && !composer.classList.contains("closed") && !composer.classList.contains("hidden") && !composer.classList.contains("d-none") && composer.getAttribute("aria-hidden") !== "true" && !composer.closest('[hidden],[aria-hidden="true"]'));
}
#bindComposer(composer) {
if (this.#composerScope?.destroy(), this.#composerScope = null, this.#composer && this.#deactivate(), this.#composer = composer, !composer) return;
const scope = this.scope.child();
this.#composerScope = scope;
const observer = this.#createMutationObserver(() => this.sync());
observer.observe(composer, {
attributes: !0,
attributeFilter: ["class", "hidden", "aria-hidden"],
childList: !0,
subtree: !0
}), scope.add(() => observer.disconnect()), scope.listen(composer, "wheel", (event) => {
(0, import_floating_surface_wheel.containFloatingSurfaceWheel)(composer, event);
}, { capture: !0, passive: !1 }), scope.add(() => {
this.#composer === composer && (this.#deactivate(), this.#composer = null);
});
}
#observeHostTargets() {
const observer = this.#hostObserver, body = this.#document.body;
if (!observer || !body) return;
if (observer.disconnect(), !this.#composer) {
const bootstrap = this.#document.querySelector("#ember-app") ?? body;
observer.observe(bootstrap, { childList: !0, subtree: !0 });
let ancestor2 = bootstrap.parentElement;
for (; ancestor2 && (observer.observe(ancestor2, { childList: !0 }), ancestor2 !== body); )
ancestor2 = ancestor2.parentElement;
return;
}
let ancestor = this.#composer.parentElement;
for (; ancestor && (observer.observe(ancestor, { childList: !0 }), ancestor !== body); )
ancestor = ancestor.parentElement;
}
#activate() {
const composer = this.#composer;
composer && (this.#ensureChrome(), this.#syncComposerRoot(), this.#applyGeometry(this.#geometry ?? this.#preferredGeometry()), composer.classList.contains("fullscreen") && (this.#stopPointer(), composer.style.removeProperty("translate")), this.#syncTopLayers(), this.#scheduleOverflow());
}
#deactivate() {
this.#stopPointer(), this.#persistGeometry(), this.#releaseTopLayers(), this.#chrome && (this.#chrome.hidden = !0), this.#geometry = null, this.#clearGeometry(), this.#clearOverflow(), this.#clearComposerRoot();
}
#applyGeometry(value) {
const composer = this.#composer;
if (!composer) return;
const previous = this.#geometry;
this.#geometry = normalizeReaderNativeComposerGeometry(
this.#viewport(),
value
);
const sizeChanged = !previous || previous.width !== this.#geometry.width || previous.height !== this.#geometry.height;
previous || (composer.style.setProperty("--ldp-composer-left", "0px"), composer.style.setProperty("--ldp-composer-top", "0px"), composer.style.setProperty("--ldp-composer-transform", "none")), sizeChanged && (composer.style.setProperty(
"--ldp-composer-width",
pixels(this.#geometry.width)
), composer.style.setProperty(
"--ldp-composer-height",
pixels(this.#geometry.height)
)), this.#desktop() && !composer.classList.contains("fullscreen") ? composer.style.setProperty(
"translate",
`${pixels(this.#geometry.left)} ${pixels(this.#geometry.top)}`
) : composer.style.removeProperty("translate"), composer.dataset.ldpReaderComposerPositioned = "1", sizeChanged && this.syncFont(), this.#syncChrome(sizeChanged);
}
#clearGeometry() {
const composer = this.#composer;
if (composer) {
delete composer.dataset.ldpReaderComposerPositioned;
for (const property of GEOMETRY_PROPERTIES)
composer.style.removeProperty(property);
composer.style.removeProperty("--ldp-composer-font-size"), composer.style.removeProperty("translate");
}
}
#ensureChrome() {
if (this.#chrome) return this.#chrome;
const chrome = this.#document.createElement("div");
chrome.className = "ldp-composer-window-chrome sciapp-ldp-owned", chrome.hidden = !0;
const drag = this.#document.createElement("button");
drag.type = "button", drag.className = "ldp-composer-drag-handle", drag.setAttribute(
"aria-label",
"拖动回复窗口;方向键可微调位置"
), chrome.append(drag);
for (const direction of RESIZE_DIRECTIONS) {
const handle = this.#document.createElement(
direction === "se" ? "button" : "span"
);
handle.className = "ldp-composer-resize-handle", handle.dataset.resize = direction, direction === "se" ? (handle.type = "button", handle.setAttribute(
"aria-label",
"拖动调整回复窗口大小;方向键可微调"
)) : handle.setAttribute("aria-hidden", "true"), chrome.append(handle);
}
this.#mount.append(chrome), this.scope.listen(chrome, "pointerdown", (event) => {
const pointer = event, eventTarget = (0, import_event_target.eventElement)(pointer), target = eventTarget ? eventTarget.closest(
".ldp-composer-drag-handle,[data-resize]"
) : null;
!target || !chrome.contains(target) || this.#startPointer(
pointer,
target.dataset.resize ?? "move",
target
);
}), this.scope.listen(chrome, "pointermove", (event) => {
this.#movePointer(event);
});
for (const type of ["pointerup", "pointercancel", "lostpointercapture"])
this.scope.listen(chrome, type, (event) => {
this.#stopPointer(event);
});
return this.scope.listen(chrome, "keydown", (event) => {
this.#handleChromeKey(event);
}), this.#chrome = chrome, chrome;
}
#syncChrome(syncSize = !1) {
const chrome = this.#chrome, composer = this.#composer, geometry = this.#geometry;
if (!chrome) return;
const available = !!(geometry && this.#desktop() && this.#composerVisible() && !composer?.classList.contains("fullscreen"));
chrome.hidden = !available, !(!available || !geometry) && (syncSize && (chrome.style.setProperty("--ldp-composer-chrome-width", pixels(geometry.width)), chrome.style.setProperty("--ldp-composer-chrome-height", pixels(geometry.height))), chrome.style.transform = `translate3d(${pixels(geometry.left)},${pixels(geometry.top)},0)`);
}
#scheduleChrome() {
this.#chromeFrame || (this.#chromeFrame = this.#requestFrame(() => {
this.#chromeFrame = 0, this.#syncChrome();
}));
}
#startPointer(event, mode, target) {
!this.#geometry || !this.#desktop() || !this.#composerVisible() || this.#composer?.classList.contains("fullscreen") || event.button !== 0 || (this.#pointer = Object.freeze({
id: event.pointerId,
mode,
startX: event.clientX,
startY: event.clientY,
geometry: this.#geometry,
target
}), this.#pointerX = event.clientX, this.#pointerY = event.clientY, target.setPointerCapture?.(event.pointerId), this.#chrome?.classList.add("is-interacting"), this.#pageRoot.classList.add("ldp-composer-window-interacting"), event.preventDefault(), event.stopPropagation());
}
#movePointer(event) {
!this.#pointer || event.pointerId !== this.#pointer.id || (this.#pointerX = event.clientX, this.#pointerY = event.clientY, this.#pointerFrame || (this.#pointerFrame = this.#requestFrame(() => {
this.#pointerFrame = 0, this.#applyPointer();
})), event.preventDefault());
}
#stopPointer(event) {
const pointer = this.#pointer;
if (!pointer) {
this.#chrome?.classList.remove("is-interacting"), this.#pageRoot.classList.remove("ldp-composer-window-interacting");
return;
}
if (!(event && event.pointerId !== pointer.id)) {
event && Number.isFinite(event.clientX) && Number.isFinite(event.clientY) && (this.#pointerX = event.clientX, this.#pointerY = event.clientY), this.#pointerFrame && this.#cancelFrame(this.#pointerFrame), this.#pointerFrame = 0, this.#applyPointer(), this.#pointer = null;
try {
pointer.target.hasPointerCapture?.(pointer.id) && pointer.target.releasePointerCapture?.(pointer.id);
} catch {
}
this.#chrome?.classList.remove("is-interacting"), this.#pageRoot.classList.remove("ldp-composer-window-interacting"), this.#scheduleOverflow(), this.#persistGeometry();
}
}
#applyPointer() {
const pointer = this.#pointer;
if (!pointer) return;
const deltaX = this.#pointerX - pointer.startX, deltaY = this.#pointerY - pointer.startY;
if (pointer.mode === "move") {
const bounds = viewportLimits(this.#viewport());
this.#applyGeometry({
...pointer.geometry,
left: clamp(
pointer.geometry.left + deltaX,
bounds.left,
bounds.right - pointer.geometry.width
),
top: clamp(
pointer.geometry.top + deltaY,
bounds.top,
bounds.bottom - pointer.geometry.height
)
});
return;
}
this.#applyGeometry(this.#resizeGeometry(
pointer.geometry,
pointer.mode,
deltaX,
deltaY
));
}
#resizeGeometry(start, direction, deltaX, deltaY) {
const bounds = viewportLimits(this.#viewport());
let left = start.left, top = start.top, right = start.left + start.width, bottom = start.top + start.height;
return direction.includes("w") && (left = clamp(start.left + deltaX, bounds.left, right - bounds.minWidth)), direction.includes("e") && (right = clamp(start.left + start.width + deltaX, left + bounds.minWidth, bounds.right)), direction.includes("n") && (top = clamp(start.top + deltaY, bounds.top, bottom - bounds.minHeight)), direction.includes("s") && (bottom = clamp(start.top + start.height + deltaY, top + bounds.minHeight, bounds.bottom)), Object.freeze({
left,
top,
width: right - left,
height: bottom - top
});
}
#handleChromeKey(event) {
if (!this.#geometry || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
const target = (0, import_event_target.eventElement)(event), step = event.shiftKey ? 40 : 12;
if (target?.matches(".ldp-composer-drag-handle")) {
const next = { ...this.#geometry };
event.key === "ArrowLeft" ? next.left -= step : event.key === "ArrowRight" ? next.left += step : event.key === "ArrowUp" ? next.top -= step : next.top += step, this.#applyGeometry(next);
} else if (target?.matches('[data-resize="se"]'))
this.#applyGeometry(this.#resizeGeometry(
this.#geometry,
"se",
event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0,
event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0
));
else
return;
this.#schedulePersist(), event.preventDefault();
}
#schedulePersist() {
this.#persistTimer && this.#clearTimer(this.#persistTimer), this.#persistTimer = this.#setTimer(() => {
this.#persistTimer = 0, this.#persistGeometry();
}, 160);
}
#persistGeometry() {
if (this.#persistTimer && this.#clearTimer(this.#persistTimer), this.#persistTimer = 0, !(!this.#geometry || !this.#desktop() || !this.#updatePreferences))
try {
this.#updatePreferences({
composerWindowWidth: Math.round(this.#geometry.width),
composerWindowHeight: Math.round(this.#geometry.height),
composerWindowX: Math.round(this.#geometry.left),
composerWindowY: Math.round(this.#geometry.top)
});
} catch (cause) {
this.#onError(cause);
}
}
#syncComposerRoot() {
let next = this.#composer;
for (; next?.parentElement && next.parentElement !== this.#document.body; )
next = next.parentElement;
if ((!next || next.parentElement !== this.#document.body) && (next = null), next !== this.#composerRoot && (this.#clearComposerRoot(), this.#composerRoot = next, next)) {
next.dataset.ldpReaderComposerRoot = "1";
for (const property of APPEARANCE_PROPERTIES) {
const priorityReader = next.style;
this.#composerAppearanceOriginal.set(property, Object.freeze({
value: next.style.getPropertyValue(property),
priority: typeof priorityReader.getPropertyPriority == "function" ? priorityReader.getPropertyPriority(property) : ""
}));
}
this.#syncAppearance();
}
}
#clearComposerRoot() {
if (this.#composerRoot) {
for (const [property, previous] of this.#composerAppearanceOriginal)
previous.value ? this.#composerRoot.style.setProperty(
property,
previous.value,
previous.priority
) : this.#composerRoot.style.removeProperty(property);
delete this.#composerRoot.dataset.ldpReaderComposerRoot;
}
this.#composerAppearanceOriginal.clear(), this.#composerRoot = null;
}
#syncAppearance(appearance = this.#readAppearance?.() ?? null) {
!this.#composerRoot || !appearance || (this.#composerRoot.style.setProperty(
"--tertiary",
appearance.accentColor
), this.#composerRoot.style.setProperty(
"--tertiary-low",
appearance.accentLowColor
), this.#composerRoot.style.setProperty(
"--d-link-color",
appearance.linkColor
));
}
#scheduleOverflow() {
this.#overflowFrame || (this.#overflowFrame = this.#requestFrame(() => {
this.#overflowFrame = 0, this.#syncOverflow();
}));
}
#syncOverflow() {
this.#clearOverflow(!1);
const composer = this.#composer;
if (!composer || !this.#composerVisible()) return;
const composerRect = composer.getBoundingClientRect?.();
if (!composerRect || composerRect.width <= 0 || composerRect.height <= 0) return;
const candidates = [composer, ...composer.querySelectorAll("*")].map((candidate) => {
if (candidate.clientWidth <= 0 || candidate.scrollWidth <= candidate.clientWidth + 1 || candidate.closest('.d-editor-button-bar,[role="toolbar"]')) return null;
let overflowX = "";
try {
overflowX = this.#window.getComputedStyle(candidate).overflowX;
} catch {
return null;
}
if (!/(auto|scroll)/.test(overflowX)) return null;
const rect = candidate.getBoundingClientRect(), coveredWidth = Math.max(0, Math.min(rect.right, composerRect.right) - Math.max(rect.left, composerRect.left)), coveredHeight = Math.max(0, Math.min(rect.bottom, composerRect.bottom) - Math.max(rect.top, composerRect.top)), widthCoverage = coveredWidth / composerRect.width, heightCoverage = coveredHeight / composerRect.height;
return widthCoverage >= 0.85 && heightCoverage >= 0.75 ? Object.freeze({
candidate,
coverage: widthCoverage * heightCoverage
}) : null;
}).filter((value) => value !== null).sort((left, right) => right.coverage - left.coverage);
this.#overflowLayers = candidates.length ? [candidates[0].candidate] : [];
for (const layer of this.#overflowLayers)
layer.dataset.ldpReaderComposerOverflow = "1";
}
#clearOverflow(cancelFrame = !0) {
cancelFrame && this.#overflowFrame && this.#cancelFrame(this.#overflowFrame), cancelFrame && (this.#overflowFrame = 0);
for (const layer of this.#overflowLayers)
delete layer.dataset.ldpReaderComposerOverflow;
this.#overflowLayers = [];
}
#promoteTopLayer(element, kind) {
if (element.hasAttribute("popover") && !this.#ownedTopLayers.has(element))
return this.#topLayer.isOpen(element);
this.#ownedTopLayers.has(element) || (element.setAttribute("popover", "manual"), element.dataset.ldpReaderTopLayer = kind, this.#ownedTopLayers.add(element));
try {
if (this.#topLayer.isOpen(element) || this.#topLayer.show(element), this.#topLayer.isOpen(element)) return !0;
} catch (cause) {
this.#onError(cause);
}
return this.#releaseTopLayer(element), !1;
}
#releaseTopLayer(element) {
if (this.#ownedTopLayers.delete(element)) {
try {
this.#topLayer.isOpen(element) && this.#topLayer.hide(element);
} catch (cause) {
this.#onError(cause);
}
element.removeAttribute("popover"), delete element.dataset.ldpReaderTopLayer;
}
}
#syncTopLayers() {
const composer = this.#composer;
if (!composer || !this.#composerVisible()) {
this.#releaseTopLayers();
return;
}
if (!this.#promoteTopLayer(composer, "composer")) {
this.#releaseFloatingTopLayers();
return;
}
this.#chrome && !this.#chrome.hidden && this.#promoteTopLayer(this.#chrome, "chrome"), this.#startFloatingObservation(), this.#scheduleFloating();
}
#startFloatingObservation() {
this.#floatingObserver || !this.#document.body || (this.#floatingObserver = this.#createMutationObserver((mutations) => {
mutations.some((mutation) => this.#floatingMutationRelevant(mutation)) && this.#scheduleFloating();
}), this.#floatingObserver.observe(this.#document.body, {
childList: !0,
subtree: !0,
attributes: !0,
attributeFilter: ["class", "style", "hidden", "aria-hidden", "open"]
}));
}
#floatingMutationRelevant(mutation) {
return (mutation.type === "attributes" ? [mutation.target] : [...mutation.addedNodes, ...mutation.removedNodes]).some((node) => {
const element = node.nodeType === 1 ? node : node.parentNode?.nodeType === 1 ? node.parentNode : null;
return !!(element?.matches(DISCOURSE_NATIVE_FLOATING_SELECTOR) || element?.closest(DISCOURSE_NATIVE_FLOATING_SELECTOR) || element?.querySelector(DISCOURSE_NATIVE_FLOATING_SELECTOR));
});
}
#scheduleFloating() {
this.#floatingFrame || (this.#floatingFrame = this.#requestFrame(() => {
this.#floatingFrame = 0, this.#syncFloatingTopLayers();
}));
}
#syncFloatingTopLayers() {
const composer = this.#composer;
if (!composer || !this.#topLayer.isOpen(composer)) return;
const visible = [...this.#document.querySelectorAll(
DISCOURSE_NATIVE_FLOATING_SELECTOR
)].filter((element) => !(element.closest("#reply-control") || element.contains(composer) || element.closest("dialog[open]") || !discourseNativeFloatingSurfaceVisible(element, this.#window))), surfaces = visible.filter((element) => !visible.some((parent) => parent !== element && parent.contains(element)));
for (const element of [...this.#ownedTopLayers])
element.dataset.ldpReaderTopLayer === "portal" && !surfaces.includes(element) && this.#releaseTopLayer(element);
for (const surface of surfaces) this.#promoteTopLayer(surface, "portal");
}
#releaseFloatingTopLayers() {
this.#floatingFrame && this.#cancelFrame(this.#floatingFrame), this.#floatingFrame = 0, this.#floatingObserver?.disconnect(), this.#floatingObserver = null;
for (const element of [...this.#ownedTopLayers])
element.dataset.ldpReaderTopLayer === "portal" && this.#releaseTopLayer(element);
}
#releaseTopLayers() {
this.#releaseFloatingTopLayers();
for (const element of [...this.#ownedTopLayers].reverse())
this.#releaseTopLayer(element);
}
#teardown() {
if (!this.#destroyed) {
this.#destroyed = !0, this.#stopPointer(), this.#persistGeometry(), this.#composerScope?.destroy(), this.#composerScope = null, this.#hostObserver?.disconnect(), this.#hostObserver = null, this.#releaseTopLayers();
for (const frame of [
this.#pointerFrame,
this.#chromeFrame,
this.#overflowFrame,
this.#floatingFrame
])
frame && this.#cancelFrame(frame);
this.#pointerFrame = 0, this.#chromeFrame = 0, this.#overflowFrame = 0, this.#floatingFrame = 0, this.#persistTimer && this.#clearTimer(this.#persistTimer), this.#persistTimer = 0, this.#pageRoot.classList.remove(
"ldp-composer-window-interacting"
), this.#clearGeometry(), this.#clearOverflow(), this.#clearComposerRoot(), this.#chrome?.remove(), this.#chrome = null, this.#composer = null, this.#geometry = null;
}
}
}
}, "d674a438db35fb347aa846080f5daf9be522cab21542eae7a11a226266457c9b");
/* 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, {
containFloatingSurfaceWheel: () => containFloatingSurfaceWheel
});
module.exports = __toCommonJS(floating_surface_wheel_exports);
var import_event_target = require("./event-target.js");
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();
}
}, "904b3651bafb4f8fb0ea30db869f3a91fd9147a3ff899ac88645019fb68fd66f");
/* 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();
}
}
}, "c512ca38a1e5d7b7eb8c1d4585a46169687310d97d0f98ab70912937ac736af5");
/* 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);
}
class ReplyTreeTopology {
#revision = 0;
#parentByPost = /* @__PURE__ */ new Map();
#childrenByParent = /* @__PURE__ */ new Map();
#subtreePostCountByPost = /* @__PURE__ */ new Map();
#snapshotCache = null;
get revision() {
return this.#revision;
}
parentOf(postNumber) {
return assertPostNumber(postNumber, "postNumber"), this.#parentByPost.get(postNumber);
}
childrenOf(parentPostNumber) {
return assertPostNumber(parentPostNumber, "parentPostNumber"), sorted(this.#childrenByParent.get(parentPostNumber) ?? []);
}
roots() {
return Object.freeze(
sorted(
[...this.#parentByPost].filter(([, parentPostNumber]) => parentPostNumber === null).map(([postNumber]) => postNumber)
)
);
}
rootBranches() {
return Object.freeze(
this.roots().map((postNumber) => Object.freeze({
postNumber,
subtreePostCount: this.#subtreePostCountByPost.get(postNumber) ?? 1
}))
);
}
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;
}
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;
let depth = 0, current = this.#parentByPost.get(postNumber);
for (; current !== null; ) {
if (current === void 0 || !this.#parentByPost.has(current)) return;
depth += 1, current = this.#parentByPost.get(current);
}
return depth;
}
rootOf(postNumber) {
if (assertPostNumber(postNumber, "postNumber"), !this.#parentByPost.has(postNumber)) return;
let current = postNumber, parent = this.#parentByPost.get(current);
for (; parent !== null; ) {
if (parent === void 0 || !this.#parentByPost.has(parent)) return;
current = parent, parent = this.#parentByPost.get(current);
}
return current;
}
commit(relations) {
if (!relations.length)
return Object.freeze({
revision: this.#revision,
changedPostNumbers: Object.freeze([]),
detachedPostNumbers: Object.freeze([])
});
const nextParents = new Map(this.#parentByPost), 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} 不能回复自身`);
nextParents.set(relation.postNumber, relation.parentPostNumber), touched.add(relation.postNumber);
}
this.#assertAcyclic(nextParents, touched);
const changed = /* @__PURE__ */ new Set(), detached = /* @__PURE__ */ new Set();
for (const postNumber of touched) {
const previousParent = this.#parentByPost.get(postNumber), nextParent = nextParents.get(postNumber);
previousParent === nextParent && this.#parentByPost.has(postNumber) || (changed.add(postNumber), previousParent != null && detached.add(postNumber));
}
return changed.size ? (this.#parentByPost = nextParents, this.#childrenByParent = this.#buildChildren(nextParents), this.#subtreePostCountByPost = this.#buildSubtreePostCounts(
nextParents,
this.#childrenByParent
), this.#revision += 1, this.#snapshotCache = null, Object.freeze({
revision: this.#revision,
changedPostNumbers: Object.freeze(sorted(changed)),
detachedPostNumbers: Object.freeze(sorted(detached))
})) : Object.freeze({
revision: this.#revision,
changedPostNumbers: Object.freeze([]),
detachedPostNumbers: Object.freeze([])
});
}
/**
* 删除一个关系,并把直属子楼层提升到被删楼层原父级。
*
* 这避免子孙因父 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), nextParents = new Map(this.#parentByPost);
nextParents.delete(postNumber);
for (const childPostNumber of directChildren)
nextParents.set(childPostNumber, previousParent);
return this.#assertAcyclic(nextParents, new Set(directChildren)), this.#parentByPost = nextParents, this.#childrenByParent = this.#buildChildren(nextParents), this.#subtreePostCountByPost = this.#buildSubtreePostCounts(
nextParents,
this.#childrenByParent
), this.#revision += 1, 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.#revision = Math.max(this.#revision + 1, snapshot.revision), 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 = sorted(this.#parentByPost.keys()).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;
}
#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) {
for (const start of starts) {
const path = /* @__PURE__ */ new Set();
let current = start;
for (; current != null; ) {
if (path.has(current))
throw new Error(`楼层关系存在环,经过 #${current}`);
path.add(current), current = parents.get(current);
}
}
}
}
}, "1895338830c0da6c624fff9b04da89cf664e9747700d1070194208cc79ac06b8");
/* 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 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");
}
}
}, "454d387511638ec7108df44be2bf8411859286b566e535375e9c50d63271ff2f");
/* 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 TARGETED_MESSAGE_TYPES = /* @__PURE__ */ new Set([
"acted",
"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 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 reactionPostId = Array.isArray(payload.reactions) ? (0, import_identifiers.tryDiscoursePostId)(payload.post_id) : null, messageType = reactionPostId === null ? String(payload.type ?? "").trim() : "acted", typedPostId = TARGETED_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 (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 && TARGETED_MESSAGE_TYPES.has(messageType) ? Object.freeze({
kind: "post",
postId,
created: messageType === "created"
}) : Object.freeze(messageType === TOPIC_STATS_MESSAGE_TYPE ? { kind: "topic-stats" } : {
kind: "refresh-topic",
reason: messageType || "unknown-message"
});
}
class TopicLiveController {
topicId;
scope;
changes = new import_signal.Signal();
#messageBus;
#session;
#cache;
#currentUsername;
#postDelayMs;
#topicDelayMs;
#setTimer;
#clearTimer;
#onError;
#subscriptions = [];
#pendingPosts = /* @__PURE__ */ new Map();
#fullRefreshReasons = /* @__PURE__ */ new Set();
#tasks = /* @__PURE__ */ new Set();
#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.#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([
`topic:${this.topicId}`,
`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), this.scheduleTopicRefresh("invalid-message");
return;
}
if (normalized.kind === "ignore") return;
if (normalized.kind === "refresh-topic") {
this.scheduleTopicRefresh(normalized.reason);
return;
}
if (normalized.kind === "topic-stats") {
this.#pendingPosts.size === 0 && this.scheduleTopicRefresh("stats");
return;
}
if (this.#cancelStatsRefresh(), (normalized.kind === "boost-added" || normalized.kind === "boost-removed") && this.#commitBoostDelta(normalized)) return;
const postChange = normalized.kind === "boost-added" || normalized.kind === "boost-removed" ? Object.freeze({
kind: "post",
postId: normalized.postId,
created: !1
}) : normalized, 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([
`topic:${this.topicId}`,
`post:${message.postId}`
])), !0;
} catch (error) {
return this.#onError(error), !1;
}
}
async #deletePost(postId, epoch) {
if (this.#acceptsWork(epoch) && (await this.#invalidate([`topic:${this.topicId}`, `post:${postId}`]), !!this.#session.postById(postId)))
try {
const commit = this.#session.removePostById(postId, "message-bus");
if (!this.#acceptsWork(epoch)) return;
const postNumber = Number(commit.removedPostNumbers?.[0] ?? 0);
Number.isSafeInteger(postNumber) && postNumber > 0 && this.#emit(Object.freeze({
kind: "deleted",
postId,
postNumber
}));
} catch (error) {
this.#onError(error), this.#acceptsWork(epoch) && this.scheduleTopicRefresh("post-delete-failed");
}
}
async #refreshPost(postId, created, epoch) {
if (!this.#acceptsWork(epoch)) return;
await this.#invalidate([`topic:${this.topicId}`, `post:${postId}`]);
const wasKnown = this.#session.postById(postId) !== void 0;
try {
const post = await this.#session.loadPostById(postId, {
created: created && !wasKnown
});
if (!post || !this.#acceptsWork(epoch)) {
!post && this.#acceptsWork(epoch) && this.scheduleTopicRefresh("post-missing");
return;
}
this.#emit(Object.freeze({
kind: "post",
postId: (0, import_identifiers.discoursePostId)(postId),
post,
created: created && !wasKnown,
wasKnown
}));
} catch (error) {
this.#onError(error), this.#acceptsWork(epoch) && this.scheduleTopicRefresh("post-refresh-failed");
}
}
async #refreshTopic(epoch) {
if (!this.#acceptsWork(epoch) || this.#fullRefreshRunning) return;
this.#fullRefreshRunning = !0;
const reasons = Object.freeze([...this.#fullRefreshReasons].sort());
this.#fullRefreshReasons.clear();
try {
await this.#invalidate([`topic:${this.topicId}`]);
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();
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;
}
}
}, "87237ccbaa9f3ffaeb3a0456dcd4c7991b588f66f5635a06971d99deded8dcd2");
/* Source: lite/src/network/browser-request-observation.ts */
runtime.register("src/network/browser-request-observation.js", function(module, exports, require) {
var browser_request_observation_exports = {};
__export(browser_request_observation_exports, {
BrowserResourceObservationAdapter: () => BrowserResourceObservationAdapter,
DiscourseNativeAjaxObservationAdapter: () => DiscourseNativeAjaxObservationAdapter
});
module.exports = __toCommonJS(browser_request_observation_exports);
function jqueryModule(value) {
const candidate = value && typeof value == "object" && "default" in value ? value.default : value;
return typeof candidate == "function" ? candidate : null;
}
function ajaxResponseHeader(response, name) {
try {
return String(response.getResponseHeader?.(name) ?? "");
} catch {
return "";
}
}
function ajaxFinish(response) {
const status = Number(response.status), cloudflareMitigated = ajaxResponseHeader(response, "cf-mitigated").trim().toLowerCase() === "challenge";
return Object.freeze({
...Number.isFinite(status) && status >= 0 ? { status } : {},
cloudflareMitigated,
rateLimitCode: ajaxResponseHeader(response, "Discourse-Rate-Limit-Error-Code") || ajaxResponseHeader(response, "X-Discourse-Rate-Limit-Error-Code"),
retryAfter: ajaxResponseHeader(response, "Retry-After"),
serverLimit: ajaxResponseHeader(response, "RateLimit-Limit") || ajaxResponseHeader(response, "X-RateLimit-Limit"),
serverRemaining: ajaxResponseHeader(response, "RateLimit-Remaining") || ajaxResponseHeader(response, "X-RateLimit-Remaining"),
serverReset: ajaxResponseHeader(response, "RateLimit-Reset") || ajaxResponseHeader(response, "X-RateLimit-Reset")
});
}
class DiscourseNativeAjaxObservationAdapter {
#observer;
#jqueryModule;
#document;
#namespace;
#hostRequestBudget;
constructor(options) {
this.#observer = options.observer, this.#jqueryModule = options.jqueryModule, this.#document = options.document ?? document, this.#hostRequestBudget = options.hostRequestBudget ?? null;
const namespace = String(options.namespace ?? "mianLiteRequestObserver").trim();
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(namespace))
throw new Error("jQuery ajax observation namespace 非法");
this.#namespace = namespace;
}
install(scope) {
let module2 = null;
try {
module2 = jqueryModule(this.#jqueryModule);
} catch {
return !1;
}
if (!module2) return !1;
let target;
try {
target = module2(this.#document);
} catch {
return !1;
}
if (!target || typeof target.on != "function" || typeof target.off != "function")
return !1;
const active = /* @__PURE__ */ new Map(), borrowedIds = /* @__PURE__ */ new Set(), send = (_event, rawResponse, rawSettings) => {
if (!rawResponse || typeof rawResponse != "object") return;
const settings = rawSettings ?? {}, href = String(settings.url ?? "");
if (!href) return;
const method = String(settings.type ?? settings.method ?? "GET"), readerId = this.#observer.matchActive({
href,
method,
source: "reader",
excludedIds: borrowedIds
});
readerId !== null && borrowedIds.add(readerId);
const id = readerId ?? this.#observer.begin({
href,
method,
transport: "xmlhttprequest",
source: "host"
}), event = this.#observer.snapshot.events.find(
(candidate) => candidate.id === id
), sharedApiResponse = !!(event?.sameOrigin && !["avatar", "media", "asset", "realtime", "presence"].includes(
event.type
)), countsAgainstSharedBudget = sharedApiResponse && event?.source === "host" && event.transport === "xmlhttprequest";
let hostLease = null;
if (countsAgainstSharedBudget && this.#hostRequestBudget && event)
try {
hostLease = this.#hostRequestBudget.recordHostStart({
startedAt: event.startedAt
});
} catch {
}
active.set(rawResponse, {
id,
borrowed: readerId !== null,
hostLease,
sharedResponse: sharedApiResponse && event ? Object.freeze({
source: event.source === "reader" ? "reader" : "host",
href: event.href,
recoveryProbe: event.recoveryProbe,
blockOnCloudflareChallenge: event.type !== "read"
}) : null
});
}, complete = (_event, rawResponse) => {
if (!rawResponse || typeof rawResponse != "object") return;
const current = active.get(rawResponse);
if (!current) return;
active.delete(rawResponse), current.borrowed && borrowedIds.delete(current.id), current.hostLease?.release();
const finish = ajaxFinish(rawResponse);
if (this.#observer.finish(
current.id,
finish
), current.sharedResponse && this.#hostRequestBudget)
try {
this.#hostRequestBudget.noteObservedResponse({
...current.sharedResponse,
status: finish.status ?? 0,
cloudflareMitigated: finish.cloudflareMitigated === !0,
retryAfter: finish.retryAfter ?? "",
rateLimitCode: finish.rateLimitCode ?? "",
serverLimit: finish.serverLimit ?? "",
serverRemaining: finish.serverRemaining ?? "",
serverReset: finish.serverReset ?? ""
});
} catch {
}
}, sendEvent = `ajaxSend.${this.#namespace}`, completeEvent = `ajaxComplete.${this.#namespace}`;
try {
target.on(sendEvent, send), target.on(completeEvent, complete);
} catch {
try {
target.off(sendEvent, send), target.off(completeEvent, complete);
} catch {
}
return !1;
}
return scope.add(() => {
target.off(sendEvent, send), target.off(completeEvent, complete);
for (const current of active.values())
current.hostLease?.release(), current.borrowed || this.#observer.finish(current.id, {
error: "observer-detached"
});
active.clear(), borrowedIds.clear();
}), !0;
}
}
class BrowserResourceObservationAdapter {
#observer;
#performance;
#createObserver;
constructor(options) {
this.#observer = options.observer, this.#performance = options.performance ?? performance, this.#createObserver = options.createObserver ?? ((callback) => new PerformanceObserver(callback));
}
install(scope) {
let nativeObserver = null;
try {
nativeObserver = this.#createObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType !== "resource") continue;
const resource = entry;
this.#observer.recordResource({
href: resource.name,
initiatorType: resource.initiatorType,
startedAt: this.#performance.timeOrigin + resource.startTime,
endedAt: this.#performance.timeOrigin + (resource.responseEnd || resource.startTime + resource.duration),
status: Number(resource.responseStatus) || 0,
size: Number(resource.transferSize || resource.encodedBodySize) || 0
});
}
}), nativeObserver.observe({ type: "resource", buffered: !0 });
} catch {
return nativeObserver?.disconnect(), !1;
}
const installedObserver = nativeObserver;
return scope.add(() => installedObserver.disconnect()), !0;
}
}
}, "1944a5973784dd16ee42e192d771c5de36760feb89733e82c12d01e8efce138a");
/* Source: lite/src/network/browser-shared-request-permit.ts */
runtime.register("src/network/browser-shared-request-permit.js", function(module, exports, require) {
var browser_shared_request_permit_exports = {};
__export(browser_shared_request_permit_exports, {
BrowserSharedRequestPermit: () => BrowserSharedRequestPermit,
READER_CLOUDFLARE_CHALLENGE_WINDOW_NAME: () => READER_CLOUDFLARE_CHALLENGE_WINDOW_NAME,
READER_REQUEST_PERMIT_CHANNEL: () => READER_REQUEST_PERMIT_CHANNEL,
READER_REQUEST_PERMIT_LOCK: () => READER_REQUEST_PERMIT_LOCK,
READER_REQUEST_PERMIT_STORAGE_KEY: () => READER_REQUEST_PERMIT_STORAGE_KEY,
browserCloudflareChallengeFeatures: () => browserCloudflareChallengeFeatures,
browserCloudflareChallengeHref: () => browserCloudflareChallengeHref,
isReaderCloudflareChallengeWindow: () => isReaderCloudflareChallengeWindow
});
module.exports = __toCommonJS(browser_shared_request_permit_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
const READER_REQUEST_PERMIT_STORAGE_KEY = "linuxdo-enhanced-reader:request-permit:v1", READER_REQUEST_PERMIT_LOCK = "linuxdo-enhanced-reader:request-permit-lock:v1", READER_REQUEST_PERMIT_CHANNEL = "linuxdo-enhanced-reader:request-permit-channel:v1", READER_CLOUDFLARE_CHALLENGE_WINDOW_NAME = "ldp-cloudflare-challenge";
function isReaderCloudflareChallengeWindow(window) {
return window.name === READER_CLOUDFLARE_CHALLENGE_WINDOW_NAME;
}
const PRIORITY_WEIGHT = Object.freeze({
critical: 0,
interactive: 1,
nested: 2,
visible: 3,
prefetch: 4,
background: 5
});
function positiveInteger(value, fallback, name) {
const normalized = Number(value ?? fallback);
if (!Number.isSafeInteger(normalized) || normalized < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return normalized;
}
function nonNegativeInteger(value, name) {
const normalized = Number(value);
if (!Number.isSafeInteger(normalized) || normalized < 0)
throw new RangeError(`${name} 必须是非负安全整数`);
return normalized;
}
function normalizedSourceId(value) {
const normalized = String(value).trim();
if (!normalized) throw new Error("request permit sourceId 不能为空");
return normalized;
}
function emptyState() {
return {
schemaVersion: 1,
updatedAt: 0,
events: [],
intents: [],
active: [],
policies: [],
challenge: null
};
}
function normalizeState(raw, now, longWindowMs) {
const source = raw && typeof raw == "object" ? raw : {}, events = Array.isArray(source.events) ? source.events.map(Number).filter((at) => Number.isFinite(at) && at > now - longWindowMs && at <= now).sort((left, right) => left - right).slice(-1e3) : [], intents = Array.isArray(source.intents) ? source.intents.filter((intent) => !!intent && typeof intent == "object" && typeof intent.id == "string" && typeof intent.ownerId == "string" && intent.priority in PRIORITY_WEIGHT && Number.isFinite(intent.queuedAt) && Number(intent.expiresAt) > now).slice(-256) : [], active = Array.isArray(source.active) ? source.active.filter((permit) => !!permit && typeof permit == "object" && typeof permit.id == "string" && typeof permit.ownerId == "string" && Number(permit.expiresAt) > now).slice(-128) : [], policies = Array.isArray(source.policies) ? source.policies.filter((policy) => !!policy && typeof policy == "object" && typeof policy.ownerId == "string" && Number(policy.expiresAt) > now && Number.isSafeInteger(Number(policy.shortBudget)) && Number(policy.shortBudget) > 0 && Number.isSafeInteger(Number(policy.longBudget)) && Number(policy.longBudget) > 0 && Number.isSafeInteger(Number(policy.minIntervalMs)) && Number(policy.minIntervalMs) >= 0 && Number.isSafeInteger(Number(policy.maxConcurrent)) && Number(policy.maxConcurrent) > 0).map((policy) => Object.freeze({
ownerId: policy.ownerId,
shortBudget: Number(policy.shortBudget),
longBudget: Number(policy.longBudget),
minIntervalMs: Number(policy.minIntervalMs),
maxConcurrent: Number(policy.maxConcurrent),
expiresAt: Number(policy.expiresAt)
})).slice(-64) : [], challengeSource = source.challenge && typeof source.challenge == "object" ? source.challenge : null, challenge = challengeSource && typeof challengeSource.ownerId == "string" && ["required", "active", "passed"].includes(String(challengeSource.state)) && Number(challengeSource.expiresAt) > now ? Object.freeze({
ownerId: challengeSource.ownerId,
state: String(challengeSource.state) === "passed" ? "passed" : "active",
required: String(challengeSource.state) === "required" || String(challengeSource.state) === "active" && (challengeSource.required === !0 || challengeSource.ownerId === ""),
automaticAttempted: challengeSource.automaticAttempted === !0 || challengeSource.ownerId === "",
recoveryProbeAttempted: challengeSource.recoveryProbeAttempted === !0,
updatedAt: Math.max(
0,
Number(challengeSource.updatedAt) || 0
),
expiresAt: Number(challengeSource.expiresAt)
}) : null;
return {
schemaVersion: 1,
updatedAt: Math.max(0, Number(source.updatedAt) || 0),
events,
intents,
active,
policies,
/* 旧 v1 的 cooldown/学习字段不会被复制,下一次写入即完成迁移。 */
challenge
};
}
function challengeOrigin(value) {
const url = new URL(String(value));
if (!["http:", "https:"].includes(url.protocol))
throw new Error("Cloudflare 验证 origin 必须是 HTTP(S)");
return url.origin;
}
function challengeHrefMatchesOrigin(href, origin) {
try {
return new URL(String(href), `${origin}/`).origin === origin;
} catch {
return !1;
}
}
function browserCloudflareChallengeHref(originValue, redirectHref) {
const origin = challengeOrigin(originValue);
let redirect = new URL("/", origin);
try {
const candidate = new URL(String(redirectHref ?? ""), `${origin}/`);
candidate.origin === origin && ["http:", "https:"].includes(candidate.protocol) && !/^\/challenge(?:\/|$)/i.test(candidate.pathname) && (candidate.username = "", candidate.password = "", redirect = candidate);
} catch {
}
const challenge = new URL(
new URL(origin).hostname.toLowerCase() === "linux.do" ? "/challenge" : "/",
origin
);
return challenge.pathname === "/challenge" && challenge.searchParams.set("redirect", redirect.href), challenge.href;
}
function inspectChallengeWindow(popup) {
try {
const document = popup.document;
if (!document || !!(document.querySelector(
'script[src*="/cdn-cgi/challenge-platform/"],#challenge-running,iframe[src*="challenges.cloudflare.com"],.cf-turnstile,input[name="cf-turnstile-response"]'
) || /^(?:Just a moment|请稍候)/i.test(String(document.title ?? "")))) return "pending";
if (document.querySelector(
'meta[name="discourse-base-uri"],meta[name="generator"],#main-outlet,.d-header'
)) return "passed";
const href = String(popup.location?.href ?? ""), location = new URL(href);
return ["http:", "https:"].includes(location.protocol) && !/^\/challenge(?:\/|$)/i.test(location.pathname) && !/^\/cdn-cgi\/challenge-platform(?:\/|$)/i.test(location.pathname) && document.readyState !== "loading" && document.body ? "passed" : "pending";
} catch {
return "pending";
}
}
function browserCloudflareChallengeFeatures(screen) {
const availableWidth = Math.max(0, Number(screen?.availWidth) || 760), availableHeight = Math.max(0, Number(screen?.availHeight) || 720), width = Math.min(760, Math.max(420, availableWidth)), height = Math.min(720, Math.max(520, availableHeight)), left = Math.max(
0,
Math.round(
(Number(screen?.availLeft) || 0) + (availableWidth - width) / 2
)
), top = Math.max(
0,
Math.round(
(Number(screen?.availTop) || 0) + (availableHeight - height) / 2
)
);
return `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
}
class BrowserSharedRequestPermit {
coordinationMode;
scope;
#storage;
#sourceId;
#locks;
#shortWindowMs;
#longWindowMs;
#shortBudget;
#longBudget;
#minIntervalMs;
#maxConcurrent;
#intentTtlMs;
#permitTtlMs;
#policyTtlMs;
#now;
#createId;
#onError;
#challenge;
#challengeOrigin;
#challengeHref;
#challengeLeaseTtlMs;
#challengePassedTtlMs;
#challengePollIntervalMs;
#challengeVerifyIntervalMs;
#challengeMaxWaitMs;
#inspectChallenge;
#verifyChallenge;
#waiters = /* @__PURE__ */ new Set();
#channel;
#fallbackState = emptyState();
#localTransactionTail = Promise.resolve();
#sequence = 0;
#closed = !1;
#challengePromise = null;
#challengeController = null;
#challengeFocusRequested = !1;
#challengeWindow = null;
#challengeReconcilePromise = null;
constructor(options) {
if (this.#storage = options.storage, this.#sourceId = normalizedSourceId(options.sourceId), this.#locks = options.locks ?? null, this.coordinationMode = this.#locks ? "atomic" : "best-effort", this.#shortWindowMs = positiveInteger(options.shortWindowMs, 1e4, "shortWindowMs"), this.#longWindowMs = positiveInteger(options.longWindowMs, 6e4, "longWindowMs"), this.#longWindowMs < this.#shortWindowMs)
throw new RangeError("longWindowMs 不能小于 shortWindowMs");
this.#shortBudget = positiveInteger(options.shortBudget, 40, "shortBudget"), this.#longBudget = positiveInteger(options.longBudget, 160, "longBudget"), this.#minIntervalMs = nonNegativeInteger(options.minIntervalMs, "minIntervalMs"), this.#maxConcurrent = positiveInteger(options.maxConcurrent, 3, "maxConcurrent"), this.#intentTtlMs = positiveInteger(options.intentTtlMs, 15e3, "intentTtlMs"), this.#permitTtlMs = positiveInteger(options.permitTtlMs, 35e3, "permitTtlMs"), this.#policyTtlMs = positiveInteger(options.policyTtlMs, 3e4, "policyTtlMs"), this.#now = options.now ?? Date.now, this.#createId = options.createId ?? (() => `${this.#sourceId}:${this.#now().toString(36)}:${++this.#sequence}`), this.#onError = options.onError ?? (() => {
}), this.#challenge = options.challenge ?? null, this.#challengeOrigin = this.#challenge ? challengeOrigin(this.#challenge.origin) : "", this.#challengeHref = this.#challenge ? browserCloudflareChallengeHref(
this.#challengeOrigin,
this.#challenge.redirectHref
) : "", this.#challengeLeaseTtlMs = positiveInteger(
this.#challenge?.leaseTtlMs,
15e3,
"challenge.leaseTtlMs"
), this.#challengePassedTtlMs = positiveInteger(
this.#challenge?.passedTtlMs,
1e4,
"challenge.passedTtlMs"
), this.#challengePollIntervalMs = positiveInteger(
this.#challenge?.pollIntervalMs,
250,
"challenge.pollIntervalMs"
), this.#challengeVerifyIntervalMs = positiveInteger(
this.#challenge?.verifyIntervalMs,
1e3,
"challenge.verifyIntervalMs"
), this.#challengeMaxWaitMs = positiveInteger(
this.#challenge?.maxWaitMs,
12e4,
"challenge.maxWaitMs"
), this.#inspectChallenge = this.#challenge?.inspect ?? inspectChallengeWindow, this.#verifyChallenge = this.#challenge?.verify ?? null, this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope);
const factory = options.broadcastChannelFactory === void 0 ? typeof BroadcastChannel > "u" ? null : (name) => new BroadcastChannel(name) : options.broadcastChannelFactory;
let channel = null;
try {
channel = factory?.(READER_REQUEST_PERMIT_CHANNEL) ?? null, channel?.addEventListener("message", this.#onChannelMessage);
} catch (error) {
this.#onError(error);
}
this.#channel = channel, options.storageEvents && this.scope.listen(
options.storageEvents,
"storage",
this.#onStorage
), this.scope.add(() => {
this.#channel?.removeEventListener("message", this.#onChannelMessage), this.#channel?.close();
}), this.scope.add(() => {
this.#closed = !0, this.#challengeController?.abort(
new DOMException("request permit 已销毁", "AbortError")
), this.#challengeController = null;
try {
this.#challengeWindow?.close?.();
} catch {
}
this.#challengeWindow = null, this.#wake(), this.#removeOwnedState();
});
}
async acquire(input) {
if (this.#assertOpen(), input.signal.aborted) throw this.#abortReason(input.signal);
const intentId = `${this.#sourceId}:intent:${this.#createId()}`, queuedAt = this.#now();
let granted = !1, waitReason = "";
try {
for (; !this.#closed; ) {
if (input.signal.aborted) throw this.#abortReason(input.signal);
const decision = await this.#transact((state, now) => this.#tryGrant(state, now, intentId, queuedAt, input.priority));
if (decision.granted && decision.permitId)
return granted = !0, this.#permit(
decision.permitId,
decision.recoveryProbe === !0,
waitReason
);
waitReason = decision.reason || waitReason, await this.#wait(decision.waitMs, input.signal);
}
throw new Error("BrowserSharedRequestPermit 已销毁");
} finally {
granted || this.#transact((state) => {
state.intents = state.intents.filter((intent) => intent.id !== intentId);
}).catch(this.#onError);
}
}
async noteRateLimit(decision) {
this.#assertOpen();
}
noteObservedResponse(input) {
input.source !== "host" || input.cloudflareMitigated !== !0 || input.blockOnCloudflareChallenge === !1 || !input.href || this.noteCloudflareChallenge({ href: input.href }).catch(this.#onError);
}
async noteCloudflareChallenge(input) {
this.#assertOpen(), !(!this.#challenge || !challengeHrefMatchesOrigin(input.href, this.#challengeOrigin)) && await this.#transact((state, now) => {
state.challenge?.state === "passed" && input.force !== !0 || state.challenge?.state === "active" && !state.challenge.required || (state.challenge = Object.freeze({
ownerId: "",
state: "active",
required: !0,
automaticAttempted: state.challenge?.automaticAttempted === !0,
recoveryProbeAttempted: state.challenge?.recoveryProbeAttempted === !0,
updatedAt: now,
expiresAt: now + this.#challengeMaxWaitMs
}));
});
}
/**
* 页面重载可能销毁原验证 owner,却留下已完成验证的命名窗口与 required 状态。
* 每个 challenge 世代只允许一个新 context 做一次原生 session 探针;成功即解闸,
* 失败仍保留人工按钮。它不打开窗口、不重放业务请求,也不形成请求 cooldown。
*/
reconcileCloudflareChallenge() {
if (this.#assertOpen(), !this.#verifyChallenge) return Promise.resolve(!1);
if (this.#challengeReconcilePromise) return this.#challengeReconcilePromise;
const controller = this.scope.abortController(
new DOMException("request permit 已销毁", "AbortError")
), promise = this.#reconcileRequiredChallenge(controller.signal).finally(() => {
this.#challengeReconcilePromise === promise && (this.#challengeReconcilePromise = null);
});
return this.#challengeReconcilePromise = promise, promise;
}
recordHostStart(input) {
this.#assertOpen();
const activeId = `${this.#sourceId}:host:${this.#createId()}`, registration = this.#transact((state, now) => {
this.#rememberPolicy(state, now);
const startedAt = Math.max(
now - 5e3,
Math.min(now, Number(input.startedAt) || now)
);
state.events.push(startedAt), state.events.sort((left, right) => left - right), state.active.push(Object.freeze({
id: activeId,
ownerId: this.#sourceId,
expiresAt: now + this.#permitTtlMs
}));
});
let released = !1;
return Object.freeze({
release: (input2) => {
released || (released = !0, input2 && this.noteObservedResponse(input2), registration.then(() => this.#transact((state) => {
state.active = state.active.filter(
(permit) => permit.id !== activeId
);
})).catch(this.#onError));
}
});
}
async resolveCloudflareChallenge(input) {
if (this.#assertOpen(), !this.#challenge || !challengeHrefMatchesOrigin(input.href, this.#challengeOrigin))
return !1;
if (input.signal.aborted)
throw this.#abortReason(input.signal);
if (input.focus !== !0 && !this.#locks && (await this.noteCloudflareChallenge({ href: input.href }), await this.#transact((state) => {
state.challenge?.state === "active" && state.challenge.required && (state.challenge = Object.freeze({
...state.challenge,
automaticAttempted: !0
}));
})), input.focus === !0 && (this.#challengeFocusRequested = !0, this.#focusChallengeWindow(), this.#postChannelMessage("challenge-focus"), this.#wake()), !this.#challengePromise) {
const controller = new AbortController();
this.#challengeController = controller;
const promise = this.#runChallenge(controller.signal).finally(() => {
this.#challengePromise === promise && (this.#challengePromise = null, this.#challengeController = null, this.#challengeFocusRequested = !1);
});
this.#challengePromise = promise;
}
const shared = this.#challengePromise;
let abort = () => {
};
const cancelled = new Promise((_resolve, reject) => {
abort = () => reject(this.#abortReason(input.signal)), input.signal.addEventListener("abort", abort, { once: !0 });
});
return Promise.race([shared, cancelled]).finally(() => {
input.signal.removeEventListener("abort", abort);
});
}
/** 兼容旧恢复入口;固定预防窗口必须原样保留,避免过盾后形成追赶突发。 */
resetRateLimits() {
return this.#assertOpen(), Promise.resolve();
}
async snapshot() {
const now = this.#now(), state = this.#read(now), policy = this.#effectivePolicy(state), blocking = this.#blockingState(state, now, policy), instances = /* @__PURE__ */ new Set([
this.#sourceId,
...state.policies.map((entry) => entry.ownerId),
...state.intents.map((entry) => entry.ownerId),
...state.active.map((entry) => entry.ownerId)
]);
return Object.freeze({
coordinationMode: this.coordinationMode,
shortBudget: policy.shortBudget,
longBudget: policy.longBudget,
minIntervalMs: policy.minIntervalMs,
maxConcurrent: policy.maxConcurrent,
instances: Math.max(1, instances.size),
queued: state.intents.length,
active: state.active.length,
shortCount: state.events.filter((at) => at > now - this.#shortWindowMs).length,
longCount: state.events.length,
challengeState: state.challenge ? state.challenge.state === "active" && state.challenge.required ? "required" : state.challenge.state : "idle",
challengeOwned: state.challenge?.state === "active" && !state.challenge.required && state.challenge.ownerId === this.#sourceId,
nextPermitDelay: blocking.waitMs,
blockingReason: blocking.reason || (state.intents.length ? "priority" : "")
});
}
/**
* 更新跨标签许可策略,但保留 intent、active lease 与固定窗口事件。
*
* 收紧后的策略只阻止后续 permit;不会中止已在执行的原站请求。
*/
applyRuntimePolicy(policy) {
this.#closed || (this.#shortBudget = positiveInteger(
policy.shortBudget,
this.#shortBudget,
"shortBudget"
), this.#longBudget = positiveInteger(
policy.longBudget,
this.#longBudget,
"longBudget"
), this.#minIntervalMs = nonNegativeInteger(
policy.minIntervalMs,
"minIntervalMs"
), this.#maxConcurrent = positiveInteger(
policy.maxConcurrent,
this.#maxConcurrent,
"maxConcurrent"
), this.#transact((state, now) => {
this.#rememberPolicy(state, now);
}).catch(this.#onError));
}
destroy() {
this.scope.destroy();
}
async #runChallenge(signal) {
const startedAt = this.#now();
for (; !this.#closed && this.#now() - startedAt < this.#challengeMaxWaitMs; ) {
if (signal.aborted) throw this.#abortReason(signal);
const ownership = await this.#transact((state, now) => state.challenge?.state === "passed" ? "passed" : state.challenge?.state === "active" && state.challenge.required && state.challenge.automaticAttempted && !this.#challengeFocusRequested || state.challenge?.state === "active" && !state.challenge.required && state.challenge.ownerId !== this.#sourceId ? "waiting" : (state.challenge = Object.freeze({
ownerId: this.#sourceId,
state: "active",
required: !1,
automaticAttempted: !0,
updatedAt: now,
expiresAt: now + this.#challengeLeaseTtlMs
}), "owner"));
if (ownership === "passed") return !0;
if (ownership === "waiting") {
await this.#wait(this.#challengePollIntervalMs, signal);
continue;
}
try {
return await this.#challengePassesProbe(signal) ? (await this.#completeChallengeLease(), !0) : await this.#ownChallengeWindow(signal, startedAt);
} catch (error) {
try {
this.#challengeWindow?.close?.();
} catch {
}
throw this.#challengeWindow = null, await this.#releaseChallengeLease(), error;
}
}
return !1;
}
async #reconcileRequiredChallenge(signal) {
if (!await this.#transact((state, now) => state.challenge?.state !== "active" || !state.challenge.required || !state.challenge.automaticAttempted || state.challenge.recoveryProbeAttempted === !0 ? !1 : (state.challenge = Object.freeze({
...state.challenge,
ownerId: this.#sourceId,
required: !1,
recoveryProbeAttempted: !0,
updatedAt: now,
expiresAt: now + this.#challengeLeaseTtlMs
}), !0))) return !1;
if (signal.aborted) throw this.#abortReason(signal);
let verified = !1;
try {
verified = await this.#verifyChallenge(signal);
} catch (error) {
if (signal.aborted) throw error;
this.#onError(error);
}
return verified ? (await this.#completeChallengeLease(), !0) : (await this.#releaseChallengeLease(), !1);
}
async #ownChallengeWindow(signal, startedAt) {
const challenge = this.#challenge;
if (!challenge) return !1;
if (!this.#challengeWindow || this.#challengeWindow.closed === !0)
try {
this.#challengeWindow = challenge.open(
this.#challengeHref,
READER_CLOUDFLARE_CHALLENGE_WINDOW_NAME,
browserCloudflareChallengeFeatures(challenge.screen)
);
} catch (error) {
this.#onError(error), this.#challengeWindow = null;
}
const popup = this.#challengeWindow;
if (!popup)
return await this.#releaseChallengeLease(), !1;
let popupLoaded = !1, verifyDelayMs = this.#challengeVerifyIntervalMs, nextVerifyAt = this.#now() + verifyDelayMs;
const onPopupLoad = () => {
popupLoaded = !0, this.#wake();
};
popup.addEventListener?.("load", onPopupLoad), this.#focusChallengeWindow();
try {
for (; !this.#closed && this.#now() - startedAt < this.#challengeMaxWaitMs; ) {
if (signal.aborted) throw this.#abortReason(signal);
if (popup.closed === !0)
return this.#challengeWindow = null, await this.#challengePassesProbe(signal) ? (await this.#completeChallengeLease(), !0) : (await this.#releaseChallengeLease(), !1);
const inspectedPassed = this.#inspectChallenge(popup) === "passed", probeDue = !!this.#verifyChallenge && (inspectedPassed || popupLoaded || this.#now() >= nextVerifyAt);
if (inspectedPassed && !this.#verifyChallenge) {
await this.#completeChallengeLease();
try {
popup.close?.();
} catch {
}
return this.#challengeWindow = null, !0;
}
if (probeDue) {
popupLoaded = !1;
const verified = await this.#challengePassesProbe(signal);
if (verifyDelayMs = Math.min(1e4, verifyDelayMs * 2), nextVerifyAt = this.#now() + verifyDelayMs, verified) {
await this.#completeChallengeLease();
try {
popup.close?.();
} catch {
}
return this.#challengeWindow = null, !0;
}
}
if (!await this.#transact((state, now) => state.challenge?.state !== "active" || state.challenge.ownerId !== this.#sourceId ? !1 : (state.challenge = Object.freeze({
...state.challenge,
updatedAt: now,
expiresAt: now + this.#challengeLeaseTtlMs
}), !0))) {
try {
popup.close?.();
} catch {
}
return this.#challengeWindow = null, !1;
}
await this.#wait(this.#challengePollIntervalMs, signal);
}
if (await this.#challengePassesProbe(signal)) {
await this.#completeChallengeLease();
try {
popup.close?.();
} catch {
}
return this.#challengeWindow = null, !0;
}
try {
popup.close?.();
} catch {
}
return this.#challengeWindow = null, await this.#releaseChallengeLease(), !1;
} finally {
popup.removeEventListener?.("load", onPopupLoad);
}
}
async #challengePassesProbe(signal) {
if (!this.#verifyChallenge) return !1;
try {
return await this.#verifyChallenge(signal);
} catch (error) {
if (signal.aborted) throw error;
return this.#onError(error), !1;
}
}
#focusChallengeWindow() {
if (!(!this.#challengeFocusRequested || !this.#challengeWindow)) {
this.#challengeFocusRequested = !1;
try {
this.#challengeWindow.focus?.();
} catch {
}
}
}
async #completeChallengeLease() {
await this.#transact((state, now) => {
state.challenge?.state !== "active" || state.challenge.ownerId !== this.#sourceId || (state.challenge = Object.freeze({
ownerId: this.#sourceId,
state: "passed",
required: !1,
automaticAttempted: !0,
recoveryProbeAttempted: state.challenge.recoveryProbeAttempted === !0,
updatedAt: now,
expiresAt: now + this.#challengePassedTtlMs
}));
});
}
async #releaseChallengeLease() {
await this.#transact((state, now) => {
state.challenge?.state === "active" && state.challenge.ownerId === this.#sourceId && (state.challenge = Object.freeze({
ownerId: "",
state: "active",
required: !0,
automaticAttempted: !0,
recoveryProbeAttempted: state.challenge.recoveryProbeAttempted === !0,
updatedAt: now,
expiresAt: now + this.#challengeMaxWaitMs
}));
});
}
#tryGrant(state, now, intentId, queuedAt, priority) {
this.#rememberPolicy(state, now);
const policy = this.#effectivePolicy(state);
if (state.intents.find((intent) => intent.id === intentId) ? state.intents = state.intents.map((intent) => intent.id === intentId ? Object.freeze({ ...intent, expiresAt: now + this.#intentTtlMs }) : intent) : state.intents.push(Object.freeze({
id: intentId,
ownerId: this.#sourceId,
priority,
queuedAt,
expiresAt: now + this.#intentTtlMs
})), state.intents.sort((left, right) => PRIORITY_WEIGHT[left.priority] - PRIORITY_WEIGHT[right.priority] || left.queuedAt - right.queuedAt || left.id.localeCompare(right.id)), state.intents[0]?.id !== intentId)
return Object.freeze({
granted: !1,
waitMs: 80,
reason: "priority"
});
const blocking = this.#blockingState(state, now, policy), { waitMs } = blocking;
if (waitMs > 0)
return Object.freeze({
granted: !1,
waitMs,
reason: blocking.reason
});
state.intents.shift(), state.events.push(now);
const permitId = `${this.#sourceId}:permit:${this.#createId()}`;
return state.active.push(Object.freeze({
id: permitId,
ownerId: this.#sourceId,
expiresAt: now + this.#permitTtlMs
})), Object.freeze({
granted: !0,
permitId,
waitMs: 0,
reason: "",
recoveryProbe: !1
});
}
#rememberPolicy(state, now) {
const policy = Object.freeze({
ownerId: this.#sourceId,
shortBudget: this.#shortBudget,
longBudget: this.#longBudget,
minIntervalMs: this.#minIntervalMs,
maxConcurrent: this.#maxConcurrent,
expiresAt: now + this.#policyTtlMs
}), index = state.policies.findIndex(
(candidate) => candidate.ownerId === this.#sourceId
);
index >= 0 ? state.policies[index] = policy : state.policies.push(policy);
}
#effectivePolicy(state) {
return Object.freeze(this.#configuredPolicy(state));
}
#configuredPolicy(state) {
const effective = {
shortBudget: this.#shortBudget,
longBudget: this.#longBudget,
minIntervalMs: this.#minIntervalMs,
maxConcurrent: this.#maxConcurrent
};
for (const policy of state.policies)
policy.ownerId !== this.#sourceId && (effective.shortBudget = Math.min(
effective.shortBudget,
policy.shortBudget
), effective.longBudget = Math.min(
effective.longBudget,
policy.longBudget
), effective.minIntervalMs = Math.max(
effective.minIntervalMs,
policy.minIntervalMs
), effective.maxConcurrent = Math.min(
effective.maxConcurrent,
policy.maxConcurrent
));
return effective;
}
#blockingState(state, now, policy) {
if (state.challenge?.state === "active")
return Object.freeze({
waitMs: Math.max(25, state.challenge.expiresAt - now),
reason: "challenge",
recoveryProbe: !1
});
const activeDelay = state.active.length >= policy.maxConcurrent ? Math.max(
25,
Math.min(...state.active.map((permit) => permit.expiresAt - now))
) : 0, shortEvents = state.events.filter(
(at) => at > now - this.#shortWindowMs
), shortWindowDelay = this.#windowDelay(
shortEvents,
policy.shortBudget,
this.#shortWindowMs,
now
), longWindowDelay = this.#windowDelay(
state.events,
policy.longBudget,
this.#longWindowMs,
now
), latest = state.events.at(-1) ?? 0, intervalDelay = Math.max(
0,
latest + policy.minIntervalMs - now
), candidates = [
["concurrency", activeDelay],
["interval", intervalDelay],
["10s", shortWindowDelay],
["60s", longWindowDelay]
];
let reason = "", waitMs = 0;
for (const [candidateReason, delay] of candidates)
delay > waitMs && (reason = candidateReason, waitMs = delay);
return Object.freeze({ waitMs, reason, recoveryProbe: !1 });
}
#windowDelay(events, budget, windowMs, now) {
if (events.length < budget) return 0;
const boundary = events[events.length - budget];
return boundary === void 0 ? 0 : Math.max(0, boundary + windowMs - now + 1);
}
#permit(permitId, recoveryProbe, waitReason) {
let released = !1;
return Object.freeze({
recoveryProbe,
waitReason,
release: () => {
released || (released = !0, this.#transact((state) => {
state.active = state.active.filter((permit) => permit.id !== permitId);
}).catch(this.#onError));
}
});
}
async #transact(operation) {
const execute = async () => {
const now = this.#now(), state = this.#read(now), result = await operation(state, now);
return state.updatedAt = now, this.#write(state), this.#publish(), this.#wake(), result;
};
if (this.#locks)
return this.#locks.request(
READER_REQUEST_PERMIT_LOCK,
{ mode: "exclusive" },
execute
);
const transaction = this.#localTransactionTail.catch(() => {
}).then(execute);
return this.#localTransactionTail = transaction.then(
() => {
},
() => {
}
), transaction;
}
#read(now) {
try {
const stored = this.#storage.getItem(READER_REQUEST_PERMIT_STORAGE_KEY);
if (stored)
return normalizeState(JSON.parse(stored), now, this.#longWindowMs);
} catch (error) {
if (this.#onError(error), this.#locks) throw error;
}
return normalizeState(
this.#locks ? null : this.#fallbackState,
now,
this.#longWindowMs
);
}
#write(state) {
this.#locks || (this.#fallbackState = normalizeState(state, this.#now(), this.#longWindowMs));
try {
this.#storage.setItem(
READER_REQUEST_PERMIT_STORAGE_KEY,
JSON.stringify(state)
);
} catch (error) {
if (this.#onError(error), this.#locks) throw error;
}
}
#publish() {
this.#postChannelMessage("updated");
}
#postChannelMessage(type) {
try {
this.#channel?.postMessage(Object.freeze({
schemaVersion: 1,
sourceId: this.#sourceId,
type
}));
} catch (error) {
this.#onError(error);
}
}
#wait(milliseconds, signal) {
return new Promise((resolve, reject) => {
let settled = !1;
const earliestWakeAt = this.#now() + 25, finish = (error) => {
settled || (settled = !0, clearTimeout(timer), this.#waiters.delete(wake), signal.removeEventListener("abort", abort), error !== void 0 ? reject(error) : resolve());
}, wake = () => {
const remaining = earliestWakeAt - this.#now();
if (remaining > 0) {
clearTimeout(timer), timer = setTimeout(wake, remaining);
return;
}
finish();
}, abort = () => finish(this.#abortReason(signal));
let timer = setTimeout(
wake,
Math.max(25, Math.min(1e3, milliseconds || 80))
);
this.#waiters.add(wake), signal.addEventListener("abort", abort, { once: !0 });
});
}
async #removeOwnedState() {
try {
await this.#transact((state, now) => {
state.intents = state.intents.filter(
(intent) => intent.ownerId !== this.#sourceId
), state.active = state.active.filter(
(permit) => permit.ownerId !== this.#sourceId
), state.policies = state.policies.filter(
(policy) => policy.ownerId !== this.#sourceId
), state.challenge?.state === "active" && state.challenge.ownerId === this.#sourceId && (state.challenge = Object.freeze({
ownerId: "",
state: "active",
required: !0,
automaticAttempted: !0,
recoveryProbeAttempted: state.challenge.recoveryProbeAttempted === !0,
updatedAt: now,
expiresAt: now + this.#challengeMaxWaitMs
}));
});
} catch (error) {
this.#onError(error);
}
}
#wake() {
for (const waiter of this.#waiters) waiter();
this.#waiters.clear();
}
#assertOpen() {
if (this.#closed || this.scope.destroyed)
throw new Error("BrowserSharedRequestPermit 已销毁");
}
#abortReason(signal) {
return signal.reason ?? new DOMException("Aborted", "AbortError");
}
#onChannelMessage = (event) => {
const message = event.data;
message?.schemaVersion === 1 && message.sourceId !== this.#sourceId && message.type === "challenge-focus" && this.#challengePromise && (this.#challengeFocusRequested = !0, this.#focusChallengeWindow()), this.#wake();
};
#onStorage = (event) => {
const key = event.key;
(key === null || key === READER_REQUEST_PERMIT_STORAGE_KEY) && this.#wake();
};
}
}, "3d5947f9ebe73382f777cc139f097b0ce36c31d6d0f112f64c375e6e843ef93b");
/* Source: lite/src/network/coordinated-request-client.ts */
runtime.register("src/network/coordinated-request-client.js", function(module, exports, require) {
var coordinated_request_client_exports = {};
__export(coordinated_request_client_exports, {
CoordinatedRequestClient: () => CoordinatedRequestClient,
RequestChallengeWaitSuppressedError: () => RequestChallengeWaitSuppressedError,
RequestCloudflareChallengeError: () => RequestCloudflareChallengeError,
RequestRateLimitError: () => RequestRateLimitError,
RequestStatusError: () => RequestStatusError,
abortableDelay: () => abortableDelay,
requestFailureKind: () => requestFailureKind
});
module.exports = __toCommonJS(coordinated_request_client_exports);
var import_request_scheduler = require("./request-scheduler.js");
const PRIORITY_WEIGHT = Object.freeze({
critical: 0,
interactive: 1,
nested: 2,
visible: 3,
prefetch: 4,
background: 5
});
function requestFailureKind(status) {
return status === 400 || status === 422 ? "validation" : status === 401 ? "authentication" : status === 403 ? "forbidden" : status === 404 || status === 410 ? "not-found" : status === 408 ? "timeout" : status === 409 || status === 412 ? "conflict" : status === 429 ? "rate-limit" : status === 425 || status >= 500 && status <= 599 ? "server" : status >= 400 && status <= 499 ? "client" : "unknown";
}
class RequestStatusError extends Error {
status;
cloudflareMitigated;
kind;
constructor(status, options = {}) {
super(`HTTP ${status}`), this.name = "RequestStatusError", this.status = status, this.cloudflareMitigated = options.cloudflareMitigated === !0, this.kind = options.kind ?? requestFailureKind(status);
}
}
class RequestCloudflareChallengeError extends RequestStatusError {
href;
constructor(status, href) {
super(status, {
cloudflareMitigated: !0,
kind: "cloudflare"
}), this.name = "RequestCloudflareChallengeError", this.href = String(href);
}
}
class RequestChallengeWaitSuppressedError extends Error {
code = "challenge-superseded";
cloudflareMitigated = !0;
constructor() {
super("排队期间已进入 Cloudflare 验证,本次写入不在过盾后自动追发"), this.name = "RequestChallengeWaitSuppressedError";
}
}
class RequestRateLimitError extends RequestStatusError {
decision;
constructor(decision) {
super(429), this.name = "RequestRateLimitError", this.decision = decision;
}
}
function nonNegativeInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 0)
throw new RangeError(`${name} 必须是非负安全整数`);
return value;
}
function abortableDelay(milliseconds, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
const onAbort = () => {
clearTimeout(timer), reject(signal.reason);
}, timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort), resolve();
}, milliseconds);
signal.addEventListener("abort", onAbort, { once: !0 });
});
}
class CoordinatedRequestClient {
scheduler;
rateLimitPolicy;
permitPort;
#delay;
#defaultMax429Retries;
#defaultMaxChallengeRetries;
#observer;
#now;
#onCoordinationError;
#requests = /* @__PURE__ */ new Map();
#disposed = !1;
constructor(options) {
this.rateLimitPolicy = options.rateLimitPolicy, this.permitPort = options.permitPort, this.scheduler = new import_request_scheduler.RequestScheduler({
...options.scheduler,
startGate: options.permitPort
}), this.#delay = options.delay ?? abortableDelay, this.#defaultMax429Retries = nonNegativeInteger(
options.defaultMax429Retries ?? 1,
"defaultMax429Retries"
), this.#defaultMaxChallengeRetries = nonNegativeInteger(
options.defaultMaxChallengeRetries ?? 1,
"defaultMaxChallengeRetries"
), this.#observer = options.observer ?? null, this.#now = options.now ?? Date.now, this.#onCoordinationError = options.onCoordinationError ?? (() => {
});
}
request(options, transport) {
const key = String(options.key).trim();
if (!key) return Promise.reject(new Error("request key 不能为空"));
const priority = options.priority ?? "visible";
if (this.#disposed)
return Promise.reject(new Error("request client 已销毁"));
if (options.signal?.aborted)
return Promise.reject(options.signal.reason);
const existing = this.#requests.get(key);
if (existing && !existing.controller.signal.aborted)
return this.#promoteLogical(existing, {
priority,
droppable: options.droppable === !0,
max429Retries: options.max429Retries ?? this.#defaultMax429Retries,
maxChallengeRetries: options.maxChallengeRetries ?? this.#defaultMaxChallengeRetries
}), this.#join(existing, options.signal);
const controller = new AbortController(), logical = {
promise: Promise.resolve(void 0),
controller,
consumers: /* @__PURE__ */ new Set(),
unabortableConsumer: !1,
settled: !1,
priority,
droppable: options.droppable === !0,
max429Retries: 0,
maxChallengeRetries: 0,
currentAttemptKey: ""
}, promise = this.#run(logical, options, transport).finally(() => {
logical.settled = !0, this.#requests.get(key) === logical && this.#requests.delete(key);
});
return logical.promise = promise, this.#requests.set(key, logical), this.#join(logical, options.signal);
}
promote(keyValue, promotion) {
const key = String(keyValue).trim();
if (!key || this.#disposed) return !1;
const logical = this.#requests.get(key);
return !logical || logical.controller.signal.aborted || logical.settled ? !1 : (this.#promoteLogical(logical, promotion), !0);
}
#join(logical, signal) {
if (!signal)
return logical.unabortableConsumer = !0, logical.promise;
if (signal.aborted) return Promise.reject(signal.reason);
const consumer = Symbol("request-consumer");
return logical.consumers.add(consumer), new Promise((resolve, reject) => {
let settled = !1;
const finish = (complete, value) => {
settled || (settled = !0, signal.removeEventListener("abort", onAbort), logical.consumers.delete(consumer), complete(value));
}, fail = (cause) => {
settled || (settled = !0, signal.removeEventListener("abort", onAbort), logical.consumers.delete(consumer), reject(cause));
}, onAbort = () => {
fail(signal.reason), !logical.settled && !logical.unabortableConsumer && logical.consumers.size === 0 && !logical.controller.signal.aborted && logical.controller.abort(signal.reason);
};
signal.addEventListener("abort", onAbort, { once: !0 }), logical.promise.then(
(value) => finish(resolve, value),
fail
);
});
}
applyRuntimePolicy(policy) {
this.scheduler.applyRuntimePolicy(policy);
}
/**
* 把中央 429 错误投影成长任务可等待的续传凭据。
*
* 使用 retryAt 计算剩余时间,避免错误沿调用栈返回后再次等待完整 Retry-After;
* 等待结束只允许长任务重新排队,绝不替代下一次请求的 scheduler/shared permit。
*/
rateLimitResume(error) {
if (this.#disposed || !(error instanceof RequestRateLimitError)) return null;
const waitMs = Math.max(
0,
Math.ceil(error.decision.retryAt - this.#now())
);
return Object.freeze({
decision: error.decision,
waitMs,
wait: (signal) => this.#delay(waitMs, signal)
});
}
/**
* 把长任务中断统一投影为可取消恢复凭据。
*
* 普通 429 复用 Retry-After;Cloudflare 只等待共享 challenge lease,不在长任务
* 内重放探针、打开第二个窗口或绕过 scheduler。
*/
requestResume(error) {
const rateLimit = this.rateLimitResume(error);
if (rateLimit)
return Object.freeze({
...rateLimit,
kind: "rate-limit"
});
if (this.#disposed || !(error instanceof RequestCloudflareChallengeError) || !this.permitPort.resolveCloudflareChallenge) return null;
const resolve = this.permitPort.resolveCloudflareChallenge.bind(
this.permitPort
);
return Object.freeze({
kind: "cloudflare-challenge",
waitMs: 0,
wait: async (signal) => {
if (signal.aborted) throw signal.reason;
if (!await resolve({
href: error.href,
signal
})) throw error;
}
});
}
/** 清除本实例的 429 范围判定证据;共享固定窗口与已记录启动次数原样保留。 */
async resetRateLimits() {
this.rateLimitPolicy.reset(), await this.permitPort.resetRateLimits?.();
}
destroy() {
if (!this.#disposed) {
this.#disposed = !0;
for (const request of this.#requests.values())
request.controller.abort(new Error("request client 已销毁"));
this.scheduler.destroy();
}
}
async #run(logical, options, transport) {
const method = String(options.method ?? "GET").toUpperCase();
logical.max429Retries = nonNegativeInteger(
options.max429Retries ?? this.#defaultMax429Retries,
"max429Retries"
), logical.maxChallengeRetries = nonNegativeInteger(
options.maxChallengeRetries ?? this.#defaultMaxChallengeRetries,
"maxChallengeRetries"
);
let rateLimitRetries = 0, challengeRetries = 0;
for (let attempt = 0; ; attempt += 1) {
logical.currentAttemptKey = `${options.key}:attempt:${attempt}`;
const queuedAt = this.#now();
let observationId = this.#beginQueuedObservation(
options,
method,
attempt,
logical.priority,
queuedAt
), observationStarted = !1, attemptWaitReason = "", response;
try {
response = await this.scheduler.schedule(
{
key: logical.currentAttemptKey,
priority: logical.priority,
lane: options.lane ?? "standard",
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
signal: logical.controller.signal,
droppable: logical.droppable,
onStart: (timing) => {
observationStarted = !0, attemptWaitReason = timing.waitReason, observationId = this.#markObservationStarted(
observationId,
options,
method,
attempt,
logical.priority,
timing
);
}
},
(signal) => {
if (options.suppressAfterChallengeWait === !0 && attemptWaitReason === "challenge")
throw new RequestChallengeWaitSuppressedError();
return transport({ signal, attempt });
}
);
} catch (error) {
logical.currentAttemptKey = "";
const reason = this.#errorCode(error);
throw observationId === null ? this.#recordControlled(
options,
method,
attempt,
reason,
queuedAt
) : observationStarted ? this.#finishObservation(observationId, {
error: reason,
cloudflareMitigated: !!error && typeof error == "object" && "cloudflareMitigated" in error && error.cloudflareMitigated === !0
}) : this.#cancelObservation(observationId, reason), error;
}
if (logical.currentAttemptKey = "", this.#finishObservation(observationId, {
status: response.status,
cloudflareMitigated: response.cloudflareMitigated === !0,
...response.retryAfter === void 0 ? {} : { retryAfter: String(response.retryAfter ?? "") },
...response.rateLimitCode === void 0 ? {} : { rateLimitCode: String(response.rateLimitCode ?? "") },
...response.serverLimit === void 0 ? {} : { serverLimit: String(response.serverLimit ?? "") },
...response.serverRemaining === void 0 ? {} : { serverRemaining: String(response.serverRemaining ?? "") },
...response.serverReset === void 0 ? {} : { serverReset: String(response.serverReset ?? "") }
}), !response.ok && response.cloudflareMitigated === !0) {
if (options.blockOnCloudflareChallenge === !1)
throw new RequestStatusError(response.status, {
cloudflareMitigated: !0
});
if (!this.permitPort.noteCloudflareChallenge && !this.permitPort.resolveCloudflareChallenge)
throw new RequestStatusError(response.status, {
cloudflareMitigated: !0,
kind: "cloudflare"
});
let passed = !1;
try {
this.permitPort.resolveCloudflareChallenge && challengeRetries < logical.maxChallengeRetries ? passed = await this.permitPort.resolveCloudflareChallenge({
href: String(options.input),
signal: logical.controller.signal
}) : await this.permitPort.noteCloudflareChallenge?.({
href: String(options.input),
force: challengeRetries > 0
});
} catch (error) {
if (logical.controller.signal.aborted) throw error;
this.#onCoordinationError(error);
try {
await this.permitPort.noteCloudflareChallenge?.({
href: String(options.input),
force: challengeRetries > 0
});
} catch (fallbackError) {
this.#onCoordinationError(fallbackError);
}
}
if (passed) {
this.rateLimitPolicy.reset(), challengeRetries += 1;
continue;
}
throw new RequestCloudflareChallengeError(
response.status,
String(options.input)
);
}
if (response.status === 429) {
const decision = this.rateLimitPolicy.noteRateLimit({
input: options.input,
method,
...response.retryAfter === void 0 ? {} : { retryAfter: response.retryAfter },
knownGlobalWindow: response.knownGlobalRateLimitWindow === !0,
...response.rateLimitWindow === void 0 ? {} : { globalWindow: response.rateLimitWindow }
});
if (decision.scope === "global")
try {
await this.permitPort.noteRateLimit(decision);
} catch (error) {
this.#onCoordinationError(error);
}
if (!([
"critical",
"interactive",
"nested",
"visible"
].includes(logical.priority) && rateLimitRetries < logical.max429Retries)) throw new RequestRateLimitError(decision);
rateLimitRetries += 1, await this.#delay(decision.waitMs, logical.controller.signal);
continue;
}
if (!response.ok)
throw new RequestStatusError(response.status, {
cloudflareMitigated: response.cloudflareMitigated === !0
});
return response.value;
}
}
#promoteLogical(logical, promotion) {
logical.droppable = logical.droppable && promotion.droppable, promotion.max429Retries !== void 0 && (logical.max429Retries = Math.max(
logical.max429Retries,
nonNegativeInteger(promotion.max429Retries, "max429Retries")
)), promotion.maxChallengeRetries !== void 0 && (logical.maxChallengeRetries = Math.max(
logical.maxChallengeRetries,
nonNegativeInteger(
promotion.maxChallengeRetries,
"maxChallengeRetries"
)
)), PRIORITY_WEIGHT[promotion.priority] < PRIORITY_WEIGHT[logical.priority] && (logical.priority = promotion.priority), logical.currentAttemptKey && this.scheduler.promoteQueued(
logical.currentAttemptKey,
logical.priority,
logical.droppable
);
}
#beginQueuedObservation(options, method, attempt, priority, queuedAt) {
if (!this.#observer) return null;
try {
return this.#observer.begin({
href: String(options.input),
method,
transport: "scheduler",
source: "reader",
phase: "queued",
queuedAt,
priority,
attempt: attempt + 1,
recoveryProbe: attempt > 0,
callSite: options.callSite ?? ""
});
} catch (error) {
return this.#onCoordinationError(error), null;
}
}
#markObservationStarted(id, options, method, attempt, priority, timing) {
if (!this.#observer) return null;
try {
return id !== null && this.#observer.markStarted({
id,
queuedAt: timing.queuedAt,
permittedAt: timing.permittedAt,
startedAt: timing.startedAt,
priority,
recoveryProbe: timing.recoveryProbe,
waitReason: timing.waitReason || (timing.permittedAt - timing.queuedAt > 0.5 ? "scheduler" : "")
}) ? id : this.#observer.begin({
href: String(options.input),
method,
transport: "scheduler",
source: "reader",
phase: "running",
queuedAt: timing.queuedAt,
permittedAt: timing.permittedAt,
startedAt: timing.startedAt,
priority,
attempt: attempt + 1,
recoveryProbe: timing.recoveryProbe,
waitReason: timing.waitReason || (timing.permittedAt - timing.queuedAt > 0.5 ? "scheduler" : ""),
callSite: options.callSite ?? ""
});
} catch (error) {
return this.#onCoordinationError(error), null;
}
}
#cancelObservation(id, reason) {
if (this.#observer)
try {
this.#observer.cancel(id, { reason }) || this.#observer.finish(id, { error: reason });
} catch (error) {
this.#onCoordinationError(error);
}
}
#finishObservation(id, input) {
if (!(id === null || !this.#observer))
try {
this.#observer.finish(id, input);
} catch (error) {
this.#onCoordinationError(error);
}
}
#recordControlled(options, method, attempt, reason, queuedAt = this.#now()) {
if (this.#observer)
try {
this.#observer.begin({
href: String(options.input),
method,
transport: "scheduler",
source: "reader",
queuedAt,
startedAt: this.#now(),
priority: options.priority ?? "visible",
attempt: attempt + 1,
recoveryProbe: attempt > 0,
waitReason: reason,
callSite: options.callSite ?? "",
controlReason: reason
});
} catch (error) {
this.#onCoordinationError(error);
}
}
#errorCode(error) {
if (error && typeof error == "object") {
const message = "message" in error ? String(error.message ?? "") : "";
if (message.includes("滚出当前视口")) return "viewport-change";
if (message.includes("升级为可见快车道")) return "priority-upgrade";
if (message.includes("已切换") || message.includes("打开已被替代") || message.includes("新的打开事务")) return "topic-switch";
if (message.includes("已关闭") || message.includes("离开 Topic") || message.includes("读取链已结束") || message.includes("Topic session closed")) return "topic-close";
if (message.includes("request client 已销毁") || message.includes("Reader runtime 已销毁") || message.includes("Reader application 已销毁")) return "context-close";
const code = "code" in error && typeof error.code == "string" ? error.code : "";
if (code) return code.slice(0, 80);
const name = "name" in error ? String(error.name ?? "") : "";
if (name) return name.slice(0, 80);
}
return "request-failed";
}
}
}, "885801a2627fe9cf4a9196ae4b18e4e26f79f102438e2983e9fad4c348da794a");
/* Source: lite/src/network/discourse-native-read-transport.ts */
runtime.register("src/network/discourse-native-read-transport.js", function(module, exports, require) {
var discourse_native_read_transport_exports = {};
__export(discourse_native_read_transport_exports, {
BrowserDiscourseNativeAjaxPort: () => BrowserDiscourseNativeAjaxPort,
BrowserDiscourseNativeMutationTransport: () => BrowserDiscourseNativeMutationTransport,
BrowserDiscourseNativeReadTransport: () => BrowserDiscourseNativeReadTransport,
discourseNativeAjaxAvailable: () => discourseNativeAjaxAvailable,
discourseNativeFailureResponse: () => discourseNativeFailureResponse
});
module.exports = __toCommonJS(discourse_native_read_transport_exports);
var import_native_request_descriptors = require("../discourse/native-request-descriptors.js"), import_request_rate_limit_policy = require("./request-rate-limit-policy.js"), import_value_record = require("../kernel/value-record.js");
const nativeReadTransportBrand = Symbol("DiscourseNativeReadTransport"), nativeMutationTransportBrand = Symbol("DiscourseNativeMutationTransport");
function statusFromError(error) {
const record = (0, import_value_record.objectRecord)(error), response = (0, import_value_record.objectRecord)(record?.response), xhr = (0, import_value_record.objectRecord)(record?.jqXHR);
for (const value of [
record?.status,
record?.statusCode,
response?.status,
xhr?.status
]) {
const status = Number(value);
if (Number.isSafeInteger(status) && status >= 100 && status <= 599)
return status;
}
return 0;
}
function responseHeader(error, name) {
const record = (0, import_value_record.objectRecord)(error), candidates = [error, record?.jqXHR, record?.response];
for (const candidate of candidates) {
const getResponseHeader = (0, import_value_record.objectRecord)(candidate)?.getResponseHeader;
if (typeof getResponseHeader == "function")
try {
const value = getResponseHeader.call(candidate, name);
if (value != null && String(value).trim())
return String(value);
} catch {
}
}
return null;
}
function retryAfterFromError(error) {
const record = (0, import_value_record.objectRecord)(error), response = (0, import_value_record.objectRecord)(record?.response);
for (const value of [
record?.retryAfter,
record?.retry_after,
response?.retryAfter,
response?.retry_after,
responseHeader(error, "Retry-After")
])
if (value != null && String(value).trim())
return String(value);
return null;
}
function responseTextFromError(error) {
const record = (0, import_value_record.objectRecord)(error), candidates = [error, record?.jqXHR, record?.response];
for (const candidate of candidates) {
const response = (0, import_value_record.objectRecord)(candidate);
for (const value of [
response?.responseText,
response?.body,
response?.data
])
if (typeof value == "string" && value.trim()) return value;
}
return "";
}
function cloudflareChallengeHtml(error) {
const body = responseTextFromError(error);
return body ? /<title>\s*(?:Just a moment(?:\.\.\.)?|请稍候(?:…|\.\.\.)?)\s*<\/title>/i.test(body) || /window\._cf_chl_opt\b/.test(body) && /\/cdn-cgi\/challenge-platform\//.test(body) : !1;
}
function cloudflareMitigatedFromError(error) {
const record = (0, import_value_record.objectRecord)(error), response = (0, import_value_record.objectRecord)(record?.response), xhr = (0, import_value_record.objectRecord)(record?.jqXHR);
return record?.cloudflareMitigated === !0 || response?.cloudflareMitigated === !0 || xhr?.cloudflareMitigated === !0 || responseHeader(error, "cf-mitigated")?.trim().toLowerCase() === "challenge" || cloudflareChallengeHtml(error);
}
function discourseNativeFailureResponse(error) {
const status = statusFromError(error);
if (!status) return null;
const rateLimitCode = responseHeader(error, "Discourse-Rate-Limit-Error-Code") ?? responseHeader(error, "X-Discourse-Rate-Limit-Error-Code") ?? "", rateLimitWindow = (0, import_request_rate_limit_policy.rateLimitWindowFromCode)(rateLimitCode);
return Object.freeze({
ok: !1,
status,
value: void 0,
retryAfter: retryAfterFromError(error),
rateLimitCode,
rateLimitWindow,
knownGlobalRateLimitWindow: rateLimitWindow !== "unknown",
cloudflareMitigated: cloudflareMitigatedFromError(error)
});
}
function normalizedOrigin(value) {
const normalized = String(value ?? "").trim();
return normalized ? new URL(normalized).origin : "";
}
function nativePath(value, origin) {
const normalized = String(value).trim();
if (!normalized) throw new Error("Discourse 原生读取 path 不能为空");
if (/^[\\/]{2}/.test(normalized))
throw new Error("Discourse 原生读取拒绝跨源 URL");
if (normalized.startsWith("/")) return normalized;
let parsed;
try {
parsed = new URL(normalized);
} catch {
throw new Error("Discourse 原生读取只接受站内绝对路径");
}
if (!origin || parsed.origin !== origin)
throw new Error("Discourse 原生读取拒绝跨源 URL");
return `${parsed.pathname}${parsed.search}`;
}
function resolveNativeAjax(host) {
const loaded = host.lookupModule("discourse/lib/ajax"), module2 = (0, import_value_record.objectRecord)(loaded), defaultExport = (0, import_value_record.objectRecord)(module2?.default), owner = module2 && typeof module2.ajax == "function" ? module2 : defaultExport && typeof defaultExport.ajax == "function" ? defaultExport : null;
if (!owner)
throw new Error("Discourse 原生模块 discourse/lib/ajax#ajax 不可用");
return {
owner,
ajax: owner.ajax
};
}
function discourseNativeAjaxAvailable(host) {
try {
return resolveNativeAjax(host), !0;
} catch {
return !1;
}
}
async function executeNativeAjax(resolved, origin, input) {
if (input.signal.aborted) throw input.signal.reason;
const path = nativePath(input.path, origin), options = {
type: input.method,
...input.headers && Object.keys(input.headers).length ? { headers: { ...input.headers } } : {},
...input.data === void 0 ? {} : { data: { ...input.data } },
...input.noStore ? { cache: !1 } : {}
};
let pending;
try {
pending = resolved.ajax.call(resolved.owner, path, options);
} catch (error) {
const failure = discourseNativeFailureResponse(error);
if (!failure) throw error;
return failure;
}
const abort = () => {
try {
pending.abort?.();
} catch {
}
};
input.signal.addEventListener("abort", abort, { once: !0 });
try {
const value = await pending;
if (input.signal.aborted) throw input.signal.reason;
return { ok: !0, status: 200, value };
} catch (error) {
if (input.signal.aborted) throw input.signal.reason;
const failure = discourseNativeFailureResponse(error);
if (!failure) throw error;
return failure;
} finally {
input.signal.removeEventListener("abort", abort);
}
}
class BrowserDiscourseNativeAjaxPort {
nativeBinding = "discourse/lib/ajax#ajax";
#host;
#origin;
#resolved = null;
constructor(host, options = {}) {
this.#host = host, this.#origin = normalizedOrigin(options.origin);
}
request(input) {
return this.#resolved ??= resolveNativeAjax(this.#host), executeNativeAjax(this.#resolved, this.#origin, input);
}
}
class BrowserDiscourseNativeReadTransport {
nativeBinding = "discourse/lib/ajax#ajax";
[nativeReadTransportBrand] = !0;
#ajax;
constructor(host, options = {}) {
this.#ajax = host instanceof BrowserDiscourseNativeAjaxPort ? host : new BrowserDiscourseNativeAjaxPort(host, options);
}
async request(input) {
return (0, import_native_request_descriptors.assertDiscourseNativeReadDescriptor)(input.descriptor), this.#ajax.request({
path: input.descriptor.path,
method: "GET",
signal: input.signal,
headers: input.descriptor.headers,
noStore: input.descriptor.browserCache === "no-store"
});
}
}
class BrowserDiscourseNativeMutationTransport {
nativeBinding = "discourse/lib/ajax#ajax";
[nativeMutationTransportBrand] = !0;
#ajax;
constructor(host, options = {}) {
this.#ajax = host instanceof BrowserDiscourseNativeAjaxPort ? host : new BrowserDiscourseNativeAjaxPort(host, options);
}
request(input) {
return (0, import_native_request_descriptors.assertDiscourseNativeMutationDescriptor)(input.descriptor), this.#ajax.request({
path: input.descriptor.path,
method: input.descriptor.method,
signal: input.signal,
headers: input.descriptor.headers,
data: input.descriptor.data,
noStore: !0
});
}
}
}, "a7a4d027801b7432b77f7a2d3f4f92ad75d122130f406d8ebd37d141d829b3d5");
/* Source: lite/src/network/domain-request-gateway.ts */
runtime.register("src/network/domain-request-gateway.js", function(module, exports, require) {
var domain_request_gateway_exports = {};
__export(domain_request_gateway_exports, {
DomainRequestGateway: () => DomainRequestGateway
});
module.exports = __toCommonJS(domain_request_gateway_exports);
var import_request_contract = require("./request-contract.js"), import_request_identities = require("./request-identities.js");
const PRIORITY_WEIGHT = Object.freeze({
critical: 0,
interactive: 1,
nested: 2,
visible: 3,
prefetch: 4,
background: 5
});
function cachePolicy(contract, settings) {
return Object.freeze({
id: contract.cacheKey,
kind: settings.kind,
tags: Object.freeze([...new Set(settings.tags.map(String))].sort()),
freshForMs: settings.freshForMs,
retainForMs: settings.retainForMs,
persist: settings.persist
});
}
class DomainRequestGateway {
#client;
#responses;
#executions = /* @__PURE__ */ new Map();
constructor(client, responses) {
this.#client = client, this.#responses = responses;
}
loadTopicPosts(input) {
return this.#execute({
...input,
profile: input.profile ?? "topic-visible",
lane: "topic-batch",
namespace: "topic-posts",
identity: (0, import_request_identities.topicPostsRequestIdentity)(input)
});
}
loadTopicTarget(input) {
return this.#execute({
...input,
profile: input.profile ?? "topic-visible",
lane: "topic-batch",
namespace: "topic-target",
identity: (0, import_request_identities.topicRequestIdentity)(input)
});
}
loadNestedReplies(input) {
return this.#execute({
...input,
profile: input.profile ?? "nested-visible",
lane: "nested-replies",
namespace: "topic-nested",
identity: (0, import_request_identities.nestedRequestIdentity)(input)
});
}
promoteTopicPosts(input) {
const contract = (0, import_request_contract.createRequestContract)(
input.profile ?? "topic-visible",
{
namespace: "topic-posts",
identity: (0, import_request_identities.topicPostsRequestIdentity)(input),
...input.cacheMode === void 0 ? {} : { cacheMode: input.cacheMode }
}
);
return this.#promoteExecution(contract);
}
promoteNestedReplies(input) {
const contract = (0, import_request_contract.createRequestContract)(
input.profile ?? "nested-visible",
{
namespace: "topic-nested",
identity: (0, import_request_identities.nestedRequestIdentity)(input),
...input.cacheMode === void 0 ? {} : { cacheMode: input.cacheMode }
}
);
return this.#promoteExecution(contract);
}
loadNotificationPage(input) {
return this.#execute({
...input,
profile: input.profile ?? "notification-visible",
lane: "standard",
namespace: "notifications",
identity: (0, import_request_identities.notificationRequestIdentity)(input)
});
}
loadCollectionPage(input) {
return this.#execute({
...input,
profile: input.profile ?? "collection-visible",
lane: "standard",
namespace: "reader-collection",
identity: (0, import_request_identities.collectionRequestIdentity)(input)
});
}
mutate(input) {
return this.#execute({
...input,
cacheMode: "no-store",
profile: "action-critical",
lane: "control",
namespace: "reader-action",
identity: (0, import_request_identities.actionRequestIdentity)(input)
});
}
loadActionPermission(input) {
return this.#execute({
...input,
cacheMode: "no-store",
profile: "action-permission",
lane: "control",
namespace: "reader-action-permission",
identity: (0, import_request_identities.actionRequestIdentity)(input)
});
}
submitReadState(input) {
return this.#execute({
...input,
cacheMode: "no-store",
profile: "read-critical",
lane: "control",
namespace: "topic-read-state",
identity: (0, import_request_identities.readRequestIdentity)(input)
});
}
translate(input) {
return this.#execute({
...input,
profile: input.profile ?? "translation-visible",
lane: "translation",
namespace: "reader-translation",
identity: (0, import_request_identities.translationRequestIdentity)(input)
});
}
loadResource(input) {
return this.#execute({
...input,
profile: input.profile ?? "resource-visible",
lane: "standard",
namespace: "reader-resource",
identity: (0, import_request_identities.resourceRequestIdentity)(input)
});
}
loadUserResource(input) {
return this.#execute({
...input,
profile: input.profile ?? "resource-visible",
lane: input.profile === "user-card-interactive" ? "user-card" : "standard",
namespace: "reader-user",
identity: (0, import_request_identities.userRequestIdentity)(input)
});
}
async cachedResource(input) {
const contract = this.#resourceContract(input), cached = await this.#responses.read(
cachePolicy(contract, input.cache)
);
return cached.state === "miss" ? null : cached.value;
}
async cachedTranslation(input) {
const contract = this.#translationCacheContract(input), cached = await this.#responses.read(
cachePolicy(contract, input.cache)
);
return cached.state === "miss" ? null : cached.value;
}
cacheTranslation(input, value) {
const contract = this.#translationCacheContract(input);
return this.#responses.write(cachePolicy(contract, input.cache), value);
}
invalidateResource(input) {
return this.#responses.invalidate({
ids: [this.#resourceContract(input).cacheKey]
});
}
invalidateResourceWithReport(input) {
return this.#responses.invalidateWithReport({
ids: [this.#resourceContract(input).cacheKey]
});
}
#resourceContract(input) {
return (0, import_request_contract.createRequestContract)("resource-visible", {
namespace: "reader-resource",
identity: (0, import_request_identities.resourceRequestIdentity)(input)
});
}
#translationCacheContract(input) {
return (0, import_request_contract.createRequestContract)("translation-visible", {
namespace: "reader-translation-section",
identity: (0, import_request_identities.translationRequestIdentity)(input)
});
}
#execute(input) {
if (input.signal.aborted) return Promise.reject(input.signal.reason);
const contract = (0, import_request_contract.createRequestContract)(input.profile, {
namespace: input.namespace,
identity: input.identity,
...input.cacheMode === void 0 ? {} : { cacheMode: input.cacheMode },
...input.timeoutMs === void 0 ? {} : { timeoutMs: input.timeoutMs }
});
if (contract.cacheMode !== "no-store" && !input.cache)
return Promise.reject(new Error(`${input.profile} 缺少 response cache settings`));
const execution = this.#acquireExecution(contract), network = async (signal) => {
if (input.beforeNetwork && execution.contract.priority === "background" && (await input.beforeNetwork(signal), signal.aborted))
throw signal.reason;
execution.started = !0;
const effective = execution.contract, requestOptions = {
key: effective.key,
input: input.input,
priority: effective.priority,
lane: input.lane,
timeoutMs: effective.timeoutMs,
droppable: effective.droppable,
max429Retries: effective.max429Retries,
maxChallengeRetries: effective.maxChallengeRetries,
blockOnCloudflareChallenge: effective.blockOnCloudflareChallenge !== !1,
suppressAfterChallengeWait: effective.suppressAfterChallengeWait === !0,
callSite: `${effective.profile} / ${input.namespace} / ${input.lane}`,
...input.method === void 0 ? {} : { method: input.method }
};
return this.#client.request(
{ ...requestOptions, signal },
input.transport
);
};
return (contract.cacheMode === "no-store" ? network(input.signal) : this.#responses.getOrLoad(
cachePolicy(contract, input.cache),
network,
{
cacheMode: contract.cacheMode,
signal: input.signal,
...input.allowStaleOnError === void 0 ? {} : { allowStaleOnError: input.allowStaleOnError },
...input.canFallback === void 0 ? {} : { canFallback: input.canFallback },
...input.mapStaleFallback === void 0 ? {} : { mapStaleFallback: input.mapStaleFallback }
}
)).finally(() => this.#releaseExecution(contract.key, execution));
}
#acquireExecution(contract) {
const existing = this.#executions.get(contract.key);
if (existing)
return existing.consumers += 1, this.#upgradeExecution(existing, contract), existing;
const created = {
contract,
consumers: 1,
started: !1
};
return this.#executions.set(contract.key, created), created;
}
#promoteExecution(contract) {
const execution = this.#executions.get(contract.key);
return execution ? (this.#upgradeExecution(execution, contract), !0) : this.#client.promote?.(
contract.key,
this.#requestPromotion(contract)
) ?? !1;
}
#upgradeExecution(execution, incoming) {
const current = execution.contract, winner = PRIORITY_WEIGHT[incoming.priority] < PRIORITY_WEIGHT[current.priority] ? incoming : current, merged = Object.freeze({
...winner,
droppable: current.droppable && incoming.droppable,
max429Retries: Math.max(
current.max429Retries,
incoming.max429Retries
),
maxChallengeRetries: Math.max(
current.maxChallengeRetries,
incoming.maxChallengeRetries
),
timeoutMs: Math.min(current.timeoutMs, incoming.timeoutMs)
});
(merged.priority !== current.priority || merged.droppable !== current.droppable || merged.max429Retries !== current.max429Retries || merged.maxChallengeRetries !== current.maxChallengeRetries || merged.timeoutMs !== current.timeoutMs) && (execution.contract = merged, execution.started && this.#client.promote?.(
merged.key,
this.#requestPromotion(merged)
));
}
#requestPromotion(contract) {
return Object.freeze({
priority: contract.priority,
droppable: contract.droppable,
max429Retries: contract.max429Retries,
maxChallengeRetries: contract.maxChallengeRetries
});
}
#releaseExecution(key, execution) {
execution.consumers = Math.max(0, execution.consumers - 1), execution.consumers === 0 && this.#executions.get(key) === execution && this.#executions.delete(key);
}
}
}, "883c4462da5da97fbb25a7a89cd605f8aba5885aa50badc31d58612c6f73b60d");
/* Source: lite/src/network/public-resource-request-adapter.ts */
runtime.register("src/network/public-resource-request-adapter.js", function(module, exports, require) {
var public_resource_request_adapter_exports = {};
__export(public_resource_request_adapter_exports, {
BrowserPublicResourceHttpPort: () => BrowserPublicResourceHttpPort,
PublicResourceRequestAdapter: () => PublicResourceRequestAdapter
});
module.exports = __toCommonJS(public_resource_request_adapter_exports);
var import_coordinated_request_client = require("./coordinated-request-client.js"), import_request_rate_limit_policy = require("./request-rate-limit-policy.js");
const publicResourceDescriptorBrand = Symbol("PublicResourceHttpDescriptor"), publicResourceDescriptors = /* @__PURE__ */ new WeakSet();
function normalizedSource(rawSource, baseUrl) {
const source = String(rawSource).trim();
if (!source) throw new Error("资源 URL 不能为空");
const url = new URL(source, baseUrl);
if (!["http:", "https:", "blob:", "data:"].includes(url.protocol))
throw new Error(`不支持的资源协议:${url.protocol}`);
return url.hash = "", url.href;
}
function descriptor(source) {
const value = Object.freeze({
url: source,
[publicResourceDescriptorBrand]: !0
});
return publicResourceDescriptors.add(value), value;
}
function assertDescriptor(input) {
if (!publicResourceDescriptors.has(input))
throw new Error("公共资源请求 descriptor 未登记");
const protocol = new URL(input.url).protocol;
if (!["http:", "https:", "blob:", "data:"].includes(protocol))
throw new Error(`公共资源请求拒绝协议 ${protocol}`);
}
class BrowserPublicResourceHttpPort {
#request;
constructor(options) {
this.#request = options.request;
}
async execute(input, request) {
if (assertDescriptor(input), request.signal.aborted) throw request.signal.reason;
const response = await this.#request(input.url, {
credentials: "omit",
cache: "force-cache",
signal: request.signal
}), contentType = String(response.headers.get("Content-Type") ?? "").trim().toLowerCase(), accepted = response.ok && (contentType === "" || contentType.startsWith("image/")), value = accepted ? await response.blob() : new Blob(), rateLimitCode = response.headers.get("Discourse-Rate-Limit-Error-Code") ?? response.headers.get("X-Discourse-Rate-Limit-Error-Code") ?? "", rateLimitWindow = (0, import_request_rate_limit_policy.rateLimitWindowFromCode)(rateLimitCode);
return {
ok: accepted,
status: response.ok && !accepted ? 415 : response.status,
value,
retryAfter: response.headers.get("Retry-After"),
rateLimitCode,
rateLimitWindow,
knownGlobalRateLimitWindow: rateLimitWindow !== "unknown",
serverLimit: response.headers.get("X-RateLimit-Limit"),
serverRemaining: response.headers.get("X-RateLimit-Remaining"),
serverReset: response.headers.get("X-RateLimit-Reset"),
cloudflareMitigated: response.headers.get("cf-mitigated")?.trim().toLowerCase() === "challenge"
};
}
}
class PublicResourceRequestAdapter {
#gateway;
#http;
#baseUrl;
#cache;
constructor(options) {
this.#gateway = options.gateway, this.#http = options.http, this.#baseUrl = options.baseUrl, this.#cache = options.cache;
}
async load(rawSource, options) {
const source = normalizedSource(rawSource, this.#baseUrl), requestDescriptor = descriptor(source);
if (source.startsWith("blob:") || source.startsWith("data:")) {
const response = await this.#http.execute(requestDescriptor, {
signal: options.signal,
attempt: 0
});
if (!response.ok) throw new import_coordinated_request_client.RequestStatusError(response.status);
return response.value;
}
return this.#gateway.loadResource({
resourceId: source,
variant: "blob",
input: source,
signal: options.signal,
cache: this.#cache,
...options.cacheMode === void 0 ? {} : { cacheMode: options.cacheMode },
...options.profile === void 0 ? {} : { profile: options.profile },
transport: (request) => this.#http.execute(requestDescriptor, request)
});
}
cached(rawSource) {
const source = normalizedSource(rawSource, this.#baseUrl);
return source.startsWith("blob:") || source.startsWith("data:") ? Promise.resolve(null) : this.#gateway.cachedResource({
resourceId: source,
variant: "blob",
cache: this.#cache
});
}
invalidate(rawSource) {
const source = normalizedSource(rawSource, this.#baseUrl);
return source.startsWith("blob:") || source.startsWith("data:") ? Promise.resolve() : this.#gateway.invalidateResource({
resourceId: source,
variant: "blob",
cache: this.#cache
});
}
invalidateWithReport(rawSource) {
const source = normalizedSource(rawSource, this.#baseUrl);
return source.startsWith("blob:") || source.startsWith("data:") ? Promise.resolve(Object.freeze({
memoryEntries: 0,
failures: Object.freeze([]),
complete: !0
})) : this.#gateway.invalidateResourceWithReport({
resourceId: source,
variant: "blob",
cache: this.#cache
});
}
normalize(rawSource) {
return normalizedSource(rawSource, this.#baseUrl);
}
}
}, "1fb17f5061cd8ac997fdb42032378c0487557407203e1180d5fdcaeb925afe76");
/* Source: lite/src/network/request-contract.ts */
runtime.register("src/network/request-contract.js", function(module, exports, require) {
var request_contract_exports = {};
__export(request_contract_exports, {
createRequestContract: () => createRequestContract,
requestProfileContract: () => requestProfileContract
});
module.exports = __toCommonJS(request_contract_exports);
function cacheModes(...values) {
return Object.freeze(values);
}
const PROFILES = Object.freeze({
"bootstrap-critical": Object.freeze({
priority: "critical",
lifecycle: "application",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 8e3,
max429Retries: 1,
maxChallengeRetries: 1
}),
"action-critical": Object.freeze({
priority: "critical",
lifecycle: "action",
droppable: !1,
defaultCacheMode: "no-store",
allowedCacheModes: cacheModes("no-store"),
defaultTimeoutMs: 2e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"action-permission": Object.freeze({
priority: "visible",
lifecycle: "action",
droppable: !1,
defaultCacheMode: "no-store",
allowedCacheModes: cacheModes("no-store"),
defaultTimeoutMs: 12e3,
max429Retries: 1,
maxChallengeRetries: 1
}),
"read-critical": Object.freeze({
priority: "critical",
lifecycle: "topic",
droppable: !1,
blockOnCloudflareChallenge: !1,
suppressAfterChallengeWait: !0,
defaultCacheMode: "no-store",
allowedCacheModes: cacheModes("no-store"),
defaultTimeoutMs: 2e4,
max429Retries: 1,
maxChallengeRetries: 0
}),
"topic-visible": Object.freeze({
priority: "visible",
lifecycle: "topic",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh", "no-store"),
defaultTimeoutMs: 2e4,
max429Retries: 1,
maxChallengeRetries: 1
}),
"nested-visible": Object.freeze({
priority: "nested",
lifecycle: "topic",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 12e3,
max429Retries: 1,
maxChallengeRetries: 1
}),
"user-card-interactive": Object.freeze({
priority: "interactive",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 12e3,
max429Retries: 1,
maxChallengeRetries: 1
}),
"translation-visible": Object.freeze({
priority: "visible",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 2e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"translation-access": Object.freeze({
priority: "interactive",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "no-store",
allowedCacheModes: cacheModes("no-store"),
defaultTimeoutMs: 2e4,
max429Retries: 1,
maxChallengeRetries: 1
}),
"translation-prefetch": Object.freeze({
priority: "prefetch",
lifecycle: "surface",
droppable: !0,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 3e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"notification-visible": Object.freeze({
priority: "visible",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 2e4,
max429Retries: 1,
maxChallengeRetries: 1
}),
"collection-visible": Object.freeze({
priority: "visible",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 2e4,
max429Retries: 1,
maxChallengeRetries: 1
}),
"resource-visible": Object.freeze({
priority: "visible",
lifecycle: "surface",
droppable: !1,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh", "no-store"),
defaultTimeoutMs: 3e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"surface-prefetch": Object.freeze({
priority: "prefetch",
lifecycle: "surface",
droppable: !0,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 2e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"user-prefetch": Object.freeze({
priority: "prefetch",
lifecycle: "surface",
droppable: !0,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 2e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"resource-prefetch": Object.freeze({
priority: "prefetch",
lifecycle: "surface",
droppable: !0,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 3e4,
max429Retries: 0,
maxChallengeRetries: 1
}),
"background-prefetch": Object.freeze({
priority: "background",
lifecycle: "topic",
droppable: !0,
defaultCacheMode: "default",
allowedCacheModes: cacheModes("default", "refresh"),
defaultTimeoutMs: 3e4,
max429Retries: 0,
maxChallengeRetries: 1
})
});
function nonEmptyToken(value, name) {
const token = String(value).trim();
if (!token) throw new Error(`${name} 不能为空`);
return token;
}
function encodedIdentity(identity) {
const entries = Object.entries(identity).sort(([left], [right]) => left.localeCompare(right));
if (!entries.length) throw new Error("request identity 不能为空");
return entries.map(([rawKey, rawValue]) => {
const key = nonEmptyToken(rawKey, "identity key"), value = typeof rawValue == "string" ? rawValue.trim() : String(rawValue);
if (!value) throw new Error(`identity ${key} 不能为空`);
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}).join("&");
}
function timeout(value, fallback) {
const resolved = value ?? fallback;
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > 12e4)
throw new RangeError("timeoutMs 必须是 1..120000 的安全整数");
return resolved;
}
function requestProfileContract(profile) {
return PROFILES[profile];
}
function createRequestContract(profile, input) {
const contract = requestProfileContract(profile), cacheMode = input.cacheMode ?? contract.defaultCacheMode;
if (!contract.allowedCacheModes.includes(cacheMode))
throw new Error(`${profile} 不允许 cache mode ${cacheMode}`);
const cacheKey = `${nonEmptyToken(input.namespace, "request namespace")}?${encodedIdentity(input.identity)}`;
return Object.freeze({
...contract,
profile,
cacheKey,
key: `${cacheKey}&cacheMode=${encodeURIComponent(cacheMode)}`,
cacheMode,
timeoutMs: timeout(input.timeoutMs, contract.defaultTimeoutMs)
});
}
}, "3ba6a4107d7a3521502bd3f307e439c751961044cd9a64cbe5b7fbfbe92906fa");
/* Source: lite/src/network/request-identities.ts */
runtime.register("src/network/request-identities.js", function(module, exports, require) {
var request_identities_exports = {};
__export(request_identities_exports, {
actionRequestIdentity: () => actionRequestIdentity,
collectionRequestIdentity: () => collectionRequestIdentity,
nestedRequestIdentity: () => nestedRequestIdentity,
notificationRequestIdentity: () => notificationRequestIdentity,
readRequestIdentity: () => readRequestIdentity,
resourceRequestIdentity: () => resourceRequestIdentity,
topicPostsRequestIdentity: () => topicPostsRequestIdentity,
topicRequestIdentity: () => topicRequestIdentity,
translationRequestIdentity: () => translationRequestIdentity,
userRequestIdentity: () => userRequestIdentity
});
module.exports = __toCommonJS(request_identities_exports);
var import_identifiers = require("../discourse/identifiers.js");
function token(value, name) {
const normalized = String(value).trim();
if (!normalized) throw new Error(`${name} 不能为空`);
return normalized;
}
function nonNegative(value, name) {
const numeric = Number(value);
if (!Number.isSafeInteger(numeric) || numeric < 0)
throw new RangeError(`${name} 必须是非负安全整数`);
return numeric;
}
function topicRequestIdentity(input) {
const identity = {
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
topicId: (0, import_identifiers.discourseTopicId)(input.topicId),
operation: token(input.operation, "operation")
};
return input.postId !== void 0 && (identity.postId = (0, import_identifiers.discoursePostId)(input.postId)), input.postNumber !== void 0 && (identity.postNumber = (0, import_identifiers.discoursePostNumber)(input.postNumber)), input.cursor !== void 0 && (identity.cursor = (0, import_identifiers.discourseReplyCursor)(input.cursor)), Object.freeze(identity);
}
function topicPostsRequestIdentity(input) {
return Object.freeze({
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
topicId: (0, import_identifiers.discourseTopicId)(input.topicId),
postIds: (0, import_identifiers.discoursePostIds)(input.postIds).join(",")
});
}
function nestedRequestIdentity(input) {
const identity = {
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
topicId: (0, import_identifiers.discourseTopicId)(input.topicId),
parentPostNumber: (0, import_identifiers.discoursePostNumber)(input.parentPostNumber),
after: (0, import_identifiers.discourseReplyCursor)(input.after)
};
return input.parentPostId !== void 0 && (identity.parentPostId = (0, import_identifiers.discoursePostId)(input.parentPostId)), Object.freeze(identity);
}
function notificationRequestIdentity(input) {
return Object.freeze({
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
group: token(input.group, "group"),
page: nonNegative(input.page, "page")
});
}
function collectionRequestIdentity(input) {
const identity = {
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
collection: token(input.collection, "collection"),
page: nonNegative(input.page, "page")
};
return input.cursor !== void 0 && (identity.cursor = token(input.cursor, "cursor")), input.variant !== void 0 && (identity.variant = token(input.variant, "variant")), Object.freeze(identity);
}
function userRequestIdentity(input) {
const identity = {
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
username: token(input.username, "username").replace(/^@/, "").toLocaleLowerCase(),
resource: token(input.resource, "resource")
};
return input.page !== void 0 && (identity.page = nonNegative(input.page, "page")), Object.freeze(identity);
}
function resourceRequestIdentity(input) {
return Object.freeze({
resourceId: token(input.resourceId, "resourceId"),
variant: token(input.variant, "variant")
});
}
function translationRequestIdentity(input) {
return Object.freeze({
provider: token(input.provider, "provider"),
textFingerprint: token(input.textFingerprint, "textFingerprint"),
sourceLanguage: token(input.sourceLanguage, "sourceLanguage"),
targetLanguage: token(input.targetLanguage, "targetLanguage")
});
}
function actionRequestIdentity(input) {
const identity = {
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
operation: token(input.operation, "operation"),
targetType: token(input.targetType, "targetType"),
targetId: token(input.targetId, "targetId")
};
return input.variant !== void 0 && (identity.variant = token(input.variant, "variant")), Object.freeze(identity);
}
function readRequestIdentity(input) {
return Object.freeze({
authScope: (0, import_identifiers.discourseAuthScope)(input.authScope),
topicId: (0, import_identifiers.discourseTopicId)(input.topicId),
postNumbers: (0, import_identifiers.discoursePostNumbers)(input.postNumbers).join(",")
});
}
}, "eae0dd4437edb39c183723fc1d72c9eb07ff1ad995fd92147c6f6fa6b474368c");
/* Source: lite/src/network/request-observer.ts */
runtime.register("src/network/request-observer.js", function(module, exports, require) {
var request_observer_exports = {};
__export(request_observer_exports, {
RequestObserver: () => RequestObserver,
requestObservationType: () => requestObservationType
});
module.exports = __toCommonJS(request_observer_exports);
var import_signal = require("../kernel/signal.js");
function nonNegative(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback;
}
function positiveInteger(value, fallback) {
const numeric = Math.trunc(Number(value));
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : fallback;
}
function requestUrl(href, baseHref) {
try {
return new URL(String(href), baseHref);
} catch {
return null;
}
}
function requestObservationHref(url) {
return url ? ["http:", "https:"].includes(url.protocol) ? `${url.origin}${url.pathname}`.slice(0, 512) : `${url.protocol.replace(":", "") || "本地"}资源` : "未知请求";
}
function requestObservationPath(url, baseOrigin) {
return url ? ["http:", "https:"].includes(url.protocol) ? `${url.origin === baseOrigin ? "" : url.host}${url.pathname || "/"}`.slice(0, 180) : `${url.protocol.replace(":", "") || "本地"}资源` : "未知请求";
}
function diagnosticText(value, maximum = 220) {
return String(value ?? "").replace(/[\r\n\t]+/g, " ").replace(/(?:https?|chrome-extension):\/\/[^\s)]+/g, (raw) => {
try {
const url = new URL(raw);
return `${url.origin}${url.pathname}`;
} catch {
return raw.split(/[?#]/, 1)[0] ?? "";
}
}).trim().slice(0, maximum);
}
function diagnosticCode(value, fallback = "") {
const normalized = diagnosticText(value, 80);
return /^[A-Za-z0-9_.:-]+$/.test(normalized) ? normalized : fallback;
}
function diagnosticError(value) {
const normalized = diagnosticText(value, 80);
return normalized ? /^[A-Za-z0-9_.:-]+$/.test(normalized) || /^(?:Failed to fetch|Load failed|NetworkError)$/i.test(normalized) ? normalized : "request-failed" : "";
}
const CANCELLATION_CODES = /* @__PURE__ */ new Set([
"AbortError",
"cancelled",
"context-close",
"context-closed",
"priority-upgrade",
"signal",
"topic-close",
"topic-switch",
"viewport-change"
]);
function isCancellationCode(value) {
return CANCELLATION_CODES.has(value);
}
function requestObservationType(href, options) {
const url = requestUrl(href, options.baseHref), path = String(url?.pathname ?? "").toLowerCase(), host = String(url?.hostname ?? "").toLowerCase(), initiator = String(options.initiatorType ?? "").toLowerCase(), method = String(options.method ?? "GET").toUpperCase();
return /\/posts\/\d+\/replies(?:\.json)?$/.test(path) ? "nested" : /\/user_avatar\/|\/letter_avatar\//.test(path) || /avatar/.test(host) ? "avatar" : /\/(?:uploads|optimized)\//.test(path) || /\.(?:avif|gif|jpe?g|png|webp|svg|mp4|webm|mp3|ogg)(?:$|\/)/.test(path) ? "media" : /\/bookmarks?(?:\/|\.|$)|remove_bookmarks/.test(path) ? "bookmark" : /\/notifications?(?:\/|\.|$)/.test(path) ? "notification" : /\/message-bus(?:\/|$)/.test(path) ? "realtime" : /\/presence\//.test(path) ? "presence" : /\/search(?:\/|\.|$)|\/filter(?:\/|\.|$)/.test(path) ? "search" : /topic-timings|\/timings(?:\/|\.|$)/.test(path) ? "read" : /\/u\/|\/directory_items|\/session\/current|\/category-experts\//.test(path) ? "user" : /post_actions|user_actions|discourse-reactions|\/boosts?(?:\/|\.|$)|\/emojis\.json$/.test(path) ? "reaction" : /\/t\/|\/posts(?:\/|\.|$)|\/posts\/by_number\//.test(path) ? "topic" : ["GET", "HEAD"].includes(method) ? ["img", "image", "video", "audio"].includes(initiator) ? "media" : ["css", "link", "script", "font"].includes(initiator) ? "asset" : "other" : "reaction";
}
class RequestObserver {
changes = new import_signal.Signal();
#baseHref;
#retentionMs;
#maxEntries;
#now;
#events = [];
#active = /* @__PURE__ */ new Map();
#sequence = 0;
#revision = 0;
constructor(options) {
const base = new URL(options.baseHref);
base.username = "", base.password = "", base.search = "", base.hash = "", this.#baseHref = base.href, this.#retentionMs = positiveInteger(options.retentionMs, 5 * 6e4), this.#maxEntries = positiveInteger(options.maxEntries, 500), this.#now = options.now ?? Date.now;
}
get snapshot() {
return this.#snapshot();
}
begin(input) {
const observedAt = this.#now(), requestedStart = nonNegative(input.startedAt, observedAt), requestedQueue = nonNegative(input.queuedAt, requestedStart), controlReason = diagnosticCode(input.controlReason), phase = controlReason ? "cancelled" : input.phase ?? "running", queuedAt = Math.min(requestedStart, requestedQueue), startedAt = phase === "queued" ? queuedAt : Math.max(queuedAt, requestedStart), permittedAt = phase === "queued" ? queuedAt : Math.min(
startedAt,
Math.max(queuedAt, nonNegative(input.permittedAt, startedAt))
), method = String(input.method ?? "GET").trim().toUpperCase().slice(0, 16) || "GET", url = requestUrl(input.href, this.#baseHref), normalizedHref = requestObservationHref(url), baseOrigin = new URL(this.#baseHref).origin, event = Object.freeze({
id: ++this.#sequence,
href: normalizedHref,
path: requestObservationPath(url, baseOrigin),
method,
transport: input.transport,
source: input.source,
phase,
type: input.type ?? requestObservationType(input.href, {
baseHref: this.#baseHref,
method,
initiatorType: input.transport
}),
sameOrigin: !!(url && ["http:", "https:"].includes(url.protocol) && url.origin === baseOrigin),
queuedAt,
permittedAt,
startedAt,
endedAt: controlReason ? startedAt : 0,
permitWait: permittedAt - queuedAt,
dispatchDuration: startedAt - permittedAt,
duration: 0,
priority: input.priority ?? null,
attempt: positiveInteger(input.attempt, 1),
recoveryProbe: input.recoveryProbe === !0,
waitReason: diagnosticCode(input.waitReason),
callSite: diagnosticText(input.callSite),
controlReason,
pending: !controlReason,
status: controlReason ? 0 : null,
cloudflareMitigated: !1,
size: 0,
error: "",
rateLimitCode: "",
retryAfter: "",
serverLimit: "",
serverRemaining: "",
serverReset: "",
resourceTimed: !1
});
return this.#insert(event), event.pending && this.#active.set(event.id, event), this.#prune(startedAt), this.#publish(), event.id;
}
markStarted(input) {
const current = this.#active.get(input.id);
if (!current || current.phase !== "queued") return !1;
const startedAt = Math.max(
current.queuedAt,
nonNegative(input.startedAt, this.#now())
), queuedAt = Math.min(
startedAt,
nonNegative(input.queuedAt, current.queuedAt)
), permittedAt = Math.min(
startedAt,
Math.max(queuedAt, nonNegative(input.permittedAt, startedAt))
), running = Object.freeze({
...current,
phase: "running",
queuedAt,
permittedAt,
startedAt,
permitWait: permittedAt - queuedAt,
dispatchDuration: startedAt - permittedAt,
priority: input.priority === void 0 ? current.priority : input.priority,
recoveryProbe: input.recoveryProbe === !0,
waitReason: diagnosticCode(input.waitReason)
});
return this.#replace(current, running), this.#active.set(input.id, running), this.#prune(startedAt), this.#publish(), !0;
}
cancel(id, input) {
const current = this.#active.get(id);
if (!current || current.phase !== "queued") return !1;
const endedAt = Math.max(
current.queuedAt,
nonNegative(input.endedAt, this.#now())
), reason = diagnosticCode(input.reason, "cancelled"), cancelled = Object.freeze({
...current,
phase: "cancelled",
permittedAt: endedAt,
startedAt: endedAt,
endedAt,
permitWait: endedAt - current.queuedAt,
dispatchDuration: 0,
duration: 0,
waitReason: reason,
controlReason: reason,
pending: !1,
status: 0
});
return this.#replace(current, cancelled), this.#active.delete(id), this.#prune(endedAt), this.#publish(), !0;
}
matchActive(input) {
const href = requestObservationHref(requestUrl(input.href, this.#baseHref)), method = String(input.method ?? "GET").trim().toUpperCase() || "GET", startedAt = nonNegative(input.startedAt, this.#now()), toleranceMs = nonNegative(input.toleranceMs, 250);
return [...this.#active.values()].filter(
(event) => event.phase === "running" && event.href === href && event.method === method && !input.excludedIds?.has(event.id) && (input.source === void 0 || event.source === input.source) && Math.abs(event.startedAt - startedAt) <= toleranceMs
).sort((left, right) => Math.abs(left.startedAt - startedAt) - Math.abs(right.startedAt - startedAt))[0]?.id ?? null;
}
finish(id, input = {}) {
const current = this.#active.get(id);
if (!current) return !1;
const endedAt = Math.max(current.startedAt, nonNegative(input.endedAt, this.#now())), error = diagnosticError(input.error), cancelled = isCancellationCode(error), controlledCancellation = cancelled && error !== "AbortError", completed = Object.freeze({
...current,
phase: cancelled ? "cancelled" : "finished",
endedAt,
duration: endedAt - current.startedAt,
pending: !1,
status: cancelled ? 0 : input.status === void 0 ? current.status : Math.trunc(nonNegative(input.status)),
cloudflareMitigated: input.cloudflareMitigated === !0,
size: nonNegative(input.size, current.size),
error,
controlReason: controlledCancellation ? error : current.controlReason,
rateLimitCode: diagnosticCode(input.rateLimitCode),
retryAfter: diagnosticText(input.retryAfter, 80),
serverLimit: diagnosticText(input.serverLimit, 80),
serverRemaining: diagnosticText(input.serverRemaining, 80),
serverReset: diagnosticText(input.serverReset, 80)
}), index = this.#events.findIndex((event) => event.id === id);
return index >= 0 && (this.#events[index] = completed), this.#active.delete(id), this.#prune(endedAt), this.#publish(), !0;
}
recordResource(input) {
const initiator = String(input.initiatorType ?? "").toLowerCase(), resourceUrl = requestUrl(input.href, this.#baseHref);
if (!resourceUrl || !["http:", "https:"].includes(resourceUrl.protocol)) return 0;
const normalizedHref = requestObservationHref(
resourceUrl
), resourceDuration = Math.max(0, input.endedAt - input.startedAt), repeated = this.#events.find(
(event) => event.resourceTimed && event.href === normalizedHref && Math.abs(event.startedAt - input.startedAt) <= 0.1 && Math.abs(event.duration - resourceDuration) <= 0.1
);
if (repeated) return repeated.id;
if (["fetch", "xmlhttprequest"].includes(initiator)) {
const match = this.#events.filter(
(event) => !event.resourceTimed && event.href === normalizedHref && (event.transport === initiator || initiator === "xmlhttprequest" && event.transport === "scheduler") && Math.abs(event.startedAt - input.startedAt) <= 100
).sort((left, right) => Math.abs(left.startedAt - input.startedAt) - Math.abs(right.startedAt - input.startedAt))[0];
if (match) {
const enriched = Object.freeze({
...match,
duration: resourceDuration,
size: nonNegative(input.size, match.size),
resourceTimed: !0
}), index2 = this.#events.findIndex((event) => event.id === match.id);
return index2 >= 0 && (this.#events[index2] = enriched), match.pending && this.#active.set(match.id, enriched), this.#publish(), match.id;
}
}
const id = this.begin({
href: input.href,
transport: "resource",
source: "browser",
startedAt: input.startedAt,
type: requestObservationType(input.href, {
baseHref: this.#baseHref,
...input.initiatorType === void 0 ? {} : { initiatorType: input.initiatorType }
}),
callSite: input.initiatorType ? `${input.initiatorType} 资源加载` : "浏览器资源加载"
});
this.finish(id, input);
const index = this.#events.findIndex((event) => event.id === id), completed = this.#events[index];
return index >= 0 && completed && (this.#events[index] = Object.freeze({ ...completed, resourceTimed: !0 }), this.#publish()), id;
}
clearCompleted() {
for (let index = this.#events.length - 1; index >= 0; index -= 1)
this.#events[index].pending || this.#events.splice(index, 1);
this.#publish();
}
#insert(event) {
let low = 0, high = this.#events.length;
for (; low < high; ) {
const middle = Math.floor((low + high) / 2), current = this.#events[middle];
current.queuedAt < event.queuedAt || current.queuedAt === event.queuedAt && current.id <= event.id ? low = middle + 1 : high = middle;
}
this.#events.splice(low, 0, event);
}
#replace(current, next) {
const index = this.#events.findIndex((event) => event.id === current.id);
index < 0 || (this.#events.splice(index, 1), this.#insert(next));
}
#prune(at) {
const cutoff = at - this.#retentionMs;
for (let index = this.#events.length - 1; index >= 0; index -= 1) {
const event = this.#events[index], retainedAt = event.endedAt || event.startedAt;
!event.pending && retainedAt < cutoff && this.#events.splice(index, 1);
}
if (this.#events.length <= this.#maxEntries) return;
let overflow = this.#events.length - this.#maxEntries;
for (let index = 0; index < this.#events.length && overflow > 0; ) {
if (this.#events[index].pending) {
index += 1;
continue;
}
this.#events.splice(index, 1), overflow -= 1;
}
}
#publish() {
this.#revision += 1, this.changes.emit(this.#snapshot());
}
#snapshot() {
const queued = [...this.#active.values()].filter(
(event) => event.phase === "queued"
).length, running = this.#active.size - queued;
return Object.freeze({
revision: this.#revision,
queued,
running,
active: running,
completed: this.#events.length - this.#active.size,
events: Object.freeze([...this.#events])
});
}
}
}, "20627e321881321d564ba13d33fff77df98e51c9ddcde52774e8ecd105824471");
/* Source: lite/src/network/request-rate-limit-policy.ts */
runtime.register("src/network/request-rate-limit-policy.js", function(module, exports, require) {
var request_rate_limit_policy_exports = {};
__export(request_rate_limit_policy_exports, {
RequestRateLimitPolicy: () => RequestRateLimitPolicy,
endpointRequestIdentity: () => endpointRequestIdentity,
parseRetryAfterMs: () => parseRetryAfterMs,
rateLimitWindowFromCode: () => rateLimitWindowFromCode
});
module.exports = __toCommonJS(request_rate_limit_policy_exports);
function rateLimitWindowFromCode(value) {
const short = /10[_-]?(?:secs?|seconds?)/i.test(String(value ?? "")), long = /60[_-]?(?:secs?|seconds?)|minute/i.test(String(value ?? ""));
return short && long ? "10s+60s" : short ? "10s" : long ? "60s" : "unknown";
}
function positiveFinite(value, name) {
if (!Number.isFinite(value) || value <= 0)
throw new RangeError(`${name} 必须是正有限数值`);
return value;
}
function positiveInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return value;
}
function parseRetryAfterMs(value, options) {
const minMs = positiveFinite(options.minMs ?? 1e3, "minMs"), maxMs = positiveFinite(options.maxMs ?? 6e4, "maxMs");
if (maxMs < minMs) throw new RangeError("maxMs 不能小于 minMs");
const fallbackMs = positiveFinite(options.fallbackMs, "fallbackMs"), raw = String(value ?? "").trim();
let waitMs = 0;
if (raw) {
const seconds = Number(raw);
waitMs = Number.isFinite(seconds) ? seconds * 1e3 : Math.max(0, Date.parse(raw) - options.now);
}
return waitMs || (waitMs = fallbackMs), Math.max(minMs, Math.min(maxMs, waitMs));
}
function endpointRequestIdentity(input, method = "GET", baseUrl) {
let url;
try {
url = input instanceof URL ? new URL(input.href) : new URL(input, baseUrl);
} catch {
const invalid = `${String(method || "GET").toUpperCase()}:invalid:${String(input).slice(0, 160)}`;
return Object.freeze({ fingerprint: invalid, route: invalid });
}
const normalizedMethod = String(method || "GET").toUpperCase(), params = [...url.searchParams.entries()].filter(([key]) => key !== "_ldp_retry"), identity = (routeOnly) => {
const path = routeOnly ? url.pathname.replace(/\b\d+\b/g, ":id").replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, ":uuid") : url.pathname, sorted = params.map(([key, value]) => routeOnly ? [key, ""] : [key, value]).sort(([leftKey, leftValue], [rightKey, rightValue]) => leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue)), query = routeOnly ? [...new Set(sorted.map(([key]) => encodeURIComponent(key)))].join("&") : sorted.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
return `${normalizedMethod}:${url.origin}${path}${query ? `?${query}` : ""}`;
};
return Object.freeze({ fingerprint: identity(!1), route: identity(!0) });
}
class RequestRateLimitPolicy {
#options;
#unknownEvidence = [];
constructor(options) {
const retryAfterMinMs = positiveFinite(options.retryAfterMinMs ?? 1e3, "retryAfterMinMs"), retryAfterMaxMs = positiveFinite(
options.retryAfterMaxMs ?? 6e4,
"retryAfterMaxMs"
);
if (retryAfterMaxMs < retryAfterMinMs)
throw new RangeError("retryAfterMaxMs 不能小于 retryAfterMinMs");
this.#options = {
evidenceWindowMs: positiveFinite(options.evidenceWindowMs, "evidenceWindowMs"),
maxEndpointEntries: positiveInteger(options.maxEndpointEntries, "maxEndpointEntries"),
retryAfterFallbackMs: positiveFinite(options.retryAfterFallbackMs, "retryAfterFallbackMs"),
retryAfterMinMs,
retryAfterMaxMs,
now: options.now ?? Date.now,
...options.baseUrl === void 0 ? {} : { baseUrl: options.baseUrl }
};
}
noteRateLimit(observation) {
const at = this.#options.now();
this.#prune(at);
const identity = endpointRequestIdentity(
observation.input,
observation.method,
this.#options.baseUrl
), retryAfterMs = parseRetryAfterMs(observation.retryAfter, {
now: at,
fallbackMs: this.#options.retryAfterFallbackMs,
minMs: this.#options.retryAfterMinMs,
maxMs: this.#options.retryAfterMaxMs
}), corroboratedGlobal = observation.knownGlobalWindow || this.#unknownEvidence.some(
(event) => event.route !== identity.route && event.at >= at - this.#options.evidenceWindowMs
);
return this.#unknownEvidence.push(Object.freeze({ at, route: identity.route })), this.#prune(at), this.#decision(
corroboratedGlobal ? "global" : "endpoint",
retryAfterMs,
at + retryAfterMs,
identity,
observation.globalWindow ?? "unknown"
);
}
reset() {
this.#unknownEvidence.length = 0;
}
#decision(scope, waitMs, retryAt, identity, window = "unknown") {
return Object.freeze({ scope, waitMs, retryAt, ...identity, window });
}
#prune(at) {
const evidenceCutoff = at - this.#options.evidenceWindowMs;
for (; this.#unknownEvidence.length && this.#unknownEvidence[0].at < evidenceCutoff; )
this.#unknownEvidence.shift();
this.#unknownEvidence.length > this.#options.maxEndpointEntries && this.#unknownEvidence.splice(
0,
this.#unknownEvidence.length - this.#options.maxEndpointEntries
);
}
}
}, "e030bd7e0a3669833a435fc466c7d1dd25284f0e27da7838c54ecf4176ff3f4f");
/* Source: lite/src/network/request-scheduler.ts */
runtime.register("src/network/request-scheduler.js", function(module, exports, require) {
var request_scheduler_exports = {};
__export(request_scheduler_exports, {
RequestControlError: () => RequestControlError,
RequestScheduler: () => RequestScheduler,
RequestTimeoutError: () => RequestTimeoutError
});
module.exports = __toCommonJS(request_scheduler_exports);
const REQUEST_LANES = Object.freeze([
"control",
"topic-batch",
"nested-replies",
"user-card",
"translation",
"standard"
]), LANE_CONCURRENCY_CAP = Object.freeze({
control: 1,
/* post_ids[] 当前批次与一批可丢弃的预知批次可以并行。 */
"topic-batch": 2,
/* replies.json 最多并行两个父楼;全局 permit 仍统一约束启动间隔与额度。 */
"nested-replies": 2,
"user-card": 2,
/* 五路预加载之外为滚动到眼前的正文预留一路;共享 permit 仍限制启动。 */
translation: 6,
standard: 1
}), PRIORITY_WEIGHT = Object.freeze({
critical: 0,
interactive: 1,
nested: 2,
visible: 3,
prefetch: 4,
background: 5
});
class RequestControlError extends Error {
code;
constructor(code) {
super(code), this.name = "AbortError", this.code = code;
}
}
class RequestTimeoutError extends Error {
constructor() {
super("请求超时"), this.name = "TimeoutError";
}
}
function positiveInteger(value, name) {
if (!Number.isSafeInteger(value) || value < 1)
throw new RangeError(`${name} 必须是正安全整数`);
return value;
}
function normalizedKey(value) {
const key = String(value).trim();
if (!key) throw new Error("request key 不能为空");
return key;
}
function abortReason(signal, fallback) {
return signal.reason ?? new RequestControlError(fallback);
}
class RequestScheduler {
#maxConcurrent;
#queueLimit;
#defaultTimeoutMs;
#now;
#startGate;
#onInternalError;
#queue = [];
#tasksByKey = /* @__PURE__ */ new Map();
#activeByLane = /* @__PURE__ */ new Map();
#activeCount = 0;
#sequence = 0;
#pumpQueued = !1;
#permitPending = !1;
#permitLane = null;
#permitTask = null;
#disposed = !1;
constructor(options) {
this.#maxConcurrent = positiveInteger(options.maxConcurrent, "maxConcurrent"), this.#queueLimit = positiveInteger(options.queueLimit, "queueLimit"), this.#defaultTimeoutMs = positiveInteger(options.defaultTimeoutMs, "defaultTimeoutMs"), this.#now = options.now ?? Date.now, this.#startGate = options.startGate, this.#onInternalError = options.onInternalError ?? (() => {
}), options.scope?.add(() => this.destroy());
}
schedule(options, operation) {
const key = normalizedKey(options.key), priority = options.priority ?? "visible", lane = options.lane ?? "standard";
if (this.#disposed)
return Promise.reject(new RequestControlError("context-closed"));
const existing = this.#tasksByKey.get(key);
if (existing && !existing.externalSignal?.aborted)
return options.droppable !== !0 && (existing.droppable = !1), this.#promoteTask(existing, priority), existing.promise;
if (options.signal?.aborted)
return Promise.reject(abortReason(options.signal, "signal"));
if (this.#waitingCount() >= this.#queueLimit) {
const evicted = options.droppable === !0 ? null : this.#evictLowerPriorityDroppable(priority);
if (!evicted && !this.#preemptDroppablePermit({
priority,
lane,
droppable: options.droppable === !0
}))
return Promise.reject(new RequestControlError("queue-limit"));
evicted && this.#finish(evicted, new RequestControlError("queue-limit"));
}
let resolve, reject;
const promise = new Promise((done, fail) => {
resolve = done, reject = fail;
}), task = {
key,
priority,
lane,
sequence: this.#sequence++,
queuedAt: this.#now(),
timeoutMs: positiveInteger(options.timeoutMs ?? this.#defaultTimeoutMs, "timeoutMs"),
externalSignal: options.signal,
operation,
onStart: options.onStart,
droppable: options.droppable === !0,
promise,
resolve,
reject,
state: "queued",
controller: null,
externalAbort: null,
permitRestart: !1
};
return task.externalSignal && (task.externalAbort = () => {
task.state === "queued" ? (this.#removeQueued(task), this.#finish(task, abortReason(task.externalSignal, "signal"))) : task.controller?.abort(abortReason(task.externalSignal, "signal"));
}, task.externalSignal.addEventListener("abort", task.externalAbort, { once: !0 })), this.#tasksByKey.set(key, task), this.#queue.push(task), this.#preemptDroppablePermit(task), this.#queuePump(), promise;
}
cancelQueued(key) {
const task = this.#tasksByKey.get(String(key));
return !task || task.state !== "queued" ? !1 : (this.#removeQueued(task), this.#finish(task, new RequestControlError("cancelled")), !0);
}
/**
* 原地更新只影响尚未启动的任务;已经取得 permit 的请求自然完成。
*
* 设置切换不得重建 scheduler,否则会丢失 single-flight、排队顺序和当前请求。
*/
applyRuntimePolicy(policy) {
this.#disposed || (this.#maxConcurrent = positiveInteger(
policy.maxConcurrent,
"maxConcurrent"
), this.#queuePump());
}
promoteQueued(key, priority, droppable) {
const task = this.#tasksByKey.get(String(key));
return task ? (droppable === !1 && (task.droppable = !1), this.#promoteTask(task, priority)) : !1;
}
snapshot() {
return this.#sortQueue(), Object.freeze({
active: this.#activeCount,
queued: this.#queue.length + (this.#permitPending ? 1 : 0),
maxConcurrent: this.#maxConcurrent,
queueLimit: this.#queueLimit,
disposed: this.#disposed,
queuedKeys: Object.freeze(this.#queue.map((task) => task.key)),
activeByLane: this.#laneCounts((lane) => this.#activeByLane.get(lane) ?? 0),
queuedByLane: this.#laneCounts((lane) => this.#queue.reduce(
(total, task) => total + (task.lane === lane ? 1 : 0),
this.#permitLane === lane ? 1 : 0
))
});
}
destroy() {
if (!this.#disposed) {
this.#disposed = !0;
for (const task of this.#queue.splice(0))
this.#finish(task, new RequestControlError("context-closed"));
for (const task of this.#tasksByKey.values())
(task.state === "active" || task.state === "permit") && task.controller?.abort(new RequestControlError("context-closed"));
}
}
#queuePump() {
this.#pumpQueued || this.#disposed || (this.#pumpQueued = !0, queueMicrotask(() => {
this.#pumpQueued = !1, this.#pump();
}));
}
#pump() {
if (!(this.#disposed || this.#permitPending))
for (this.#sortQueue(); this.#activeCount < this.#maxConcurrent && this.#queue.length; ) {
const nextIndex = this.#queue.findIndex((candidate) => this.#taskCanStart(candidate));
if (nextIndex < 0) return;
const [task] = this.#queue.splice(nextIndex, 1);
if (!task) return;
if (task.externalSignal?.aborted) {
this.#finish(task, abortReason(task.externalSignal, "signal"));
continue;
}
const controller = new AbortController();
if (task.controller = controller, !this.#startGate) {
this.#run(task, controller, null, task.queuedAt);
continue;
}
task.state = "permit", this.#permitPending = !0, this.#permitLane = task.lane, this.#permitTask = task;
const permitPromise = Promise.resolve().then(() => this.#startGate.acquire({
key: task.key,
priority: task.priority,
lane: task.lane,
signal: controller.signal
})).then((permit) => {
if (!permit || typeof permit.release != "function")
throw new Error("start gate 返回了无效 permit");
if (controller.signal.aborted)
throw this.#releasePermit(permit), abortReason(controller.signal, "signal");
return permit;
}), permitAbort = new Promise((_resolve, reject) => {
controller.signal.addEventListener(
"abort",
() => reject(abortReason(controller.signal, "signal")),
{ once: !0 }
);
});
Promise.race([permitPromise, permitAbort]).then((permit) => {
this.#clearPermitTask(task);
try {
if (this.#disposed || controller.signal.aborted)
throw this.#releasePermit(permit), abortReason(controller.signal, "context-closed");
this.#run(task, controller, permit, this.#now());
} catch (error) {
task.state !== "done" && this.#finish(task, error);
} finally {
this.#queuePump();
}
}, (error) => {
this.#clearPermitTask(task), task.permitRestart && !this.#disposed && !task.externalSignal?.aborted ? (task.permitRestart = !1, task.state = "queued", task.controller = null, this.#queue.push(task)) : task.state !== "done" && this.#finish(
task,
this.#disposed ? new RequestControlError("context-closed") : error
), this.#queuePump();
});
break;
}
}
#sortQueue() {
this.#queue.sort(
(left, right) => PRIORITY_WEIGHT[left.priority] - PRIORITY_WEIGHT[right.priority] || left.sequence - right.sequence
);
}
#promoteTask(task, priority) {
return task.state !== "queued" && task.state !== "permit" || PRIORITY_WEIGHT[priority] >= PRIORITY_WEIGHT[task.priority] ? !1 : (task.priority = priority, task.state === "permit" ? (task.permitRestart = !0, task.controller?.abort(new RequestControlError("cancelled"))) : this.#queuePump(), !0);
}
#preemptDroppablePermit(incoming) {
const waiting = this.#permitTask, controller = waiting?.controller;
return incoming.droppable || !waiting || !waiting.droppable || waiting.state !== "permit" || !controller || controller.signal.aborted || this.#activeCount >= this.#maxConcurrent || !this.#taskCanStart(incoming) || PRIORITY_WEIGHT[incoming.priority] >= PRIORITY_WEIGHT[waiting.priority] ? !1 : (waiting.permitRestart = !1, controller.abort(new RequestControlError("cancelled")), !0);
}
#clearPermitTask(task) {
this.#permitTask === task && (this.#permitPending = !1, this.#permitLane = null, this.#permitTask = null);
}
#waitingCount() {
return this.#queue.length + (this.#permitPending ? 1 : 0);
}
#taskCanStart(task) {
let cap = Math.min(
this.#maxConcurrent,
LANE_CONCURRENCY_CAP[task.lane]
);
return task.lane === "topic-batch" && task.priority === "background" && (cap = Math.min(cap, 1)), (this.#activeByLane.get(task.lane) ?? 0) < cap;
}
#laneCounts(read) {
return Object.freeze(Object.fromEntries(
REQUEST_LANES.map((lane) => [lane, read(lane)])
));
}
#evictLowerPriorityDroppable(incomingPriority) {
const incomingWeight = PRIORITY_WEIGHT[incomingPriority], candidate = this.#queue.filter((task) => task.droppable && PRIORITY_WEIGHT[task.priority] > incomingWeight).sort((left, right) => PRIORITY_WEIGHT[right.priority] - PRIORITY_WEIGHT[left.priority] || right.sequence - left.sequence)[0];
return candidate ? (this.#removeQueued(candidate), candidate) : null;
}
async #run(task, controller, permit, permittedAt) {
task.state = "active", task.controller = controller, task.permitRestart = !1, this.#activeCount += 1, this.#activeByLane.set(
task.lane,
(this.#activeByLane.get(task.lane) ?? 0) + 1
);
const startedAt = this.#now();
try {
task.onStart?.(Object.freeze({
queuedAt: task.queuedAt,
permittedAt: Math.max(task.queuedAt, permittedAt),
startedAt: Math.max(permittedAt, startedAt),
recoveryProbe: permit?.recoveryProbe === !0,
waitReason: String(permit?.waitReason ?? "")
}));
} catch (error) {
this.#onInternalError(error);
}
const timeoutError = new RequestTimeoutError();
let timedOut = !1;
const timeout = setTimeout(() => {
timedOut = !0, controller.abort(timeoutError);
}, task.timeoutMs), abortPromise = new Promise((_resolve, reject) => {
controller.signal.addEventListener(
"abort",
() => reject(abortReason(controller.signal, "signal")),
{ once: !0 }
);
});
try {
const value = await Promise.race([task.operation(controller.signal), abortPromise]);
task.resolve(value);
} catch (error) {
task.reject(timedOut ? timeoutError : error);
} finally {
clearTimeout(timeout), this.#releasePermit(permit), this.#activeCount = Math.max(0, this.#activeCount - 1);
const laneActive = Math.max(
0,
(this.#activeByLane.get(task.lane) ?? 0) - 1
);
laneActive ? this.#activeByLane.set(task.lane, laneActive) : this.#activeByLane.delete(task.lane), this.#finish(task), this.#queuePump();
}
}
#releasePermit(permit) {
if (permit)
try {
permit.release();
} catch (error) {
this.#onInternalError(error);
}
}
#removeQueued(task) {
const index = this.#queue.indexOf(task);
index >= 0 && this.#queue.splice(index, 1);
}
#finish(task, error) {
task.state !== "done" && (task.state = "done", task.externalSignal && task.externalAbort && task.externalSignal.removeEventListener("abort", task.externalAbort), task.externalAbort = null, task.controller = null, task.permitRestart = !1, this.#tasksByKey.get(task.key) === task && this.#tasksByKey.delete(task.key), error !== void 0 && task.reject(error));
}
}
}, "2355bf2f57a04b784116f47b363c09162e59f3c52542ecaa934150bd08a60c0a");
/* 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;
#document;
#overlay;
#mutations;
#enhancements;
#requestFrame;
#cancelFrame;
#activeScope = null;
#roots = /* @__PURE__ */ new Map();
#changedCards = /* @__PURE__ */ new Set();
#activityCards = /* @__PURE__ */ new Set();
#rootFrame = 0;
#cardFrame = 0;
#destroyed = !1;
constructor(options) {
this.#model = options.model, 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), 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;
if (embedded && !this.#activeScope) {
const activeScope = this.scope.child();
this.#activeScope = activeScope, this.#mutations.subscribe((batch) => this.#onMutations(batch), activeScope), this.#scheduleRootSync();
} else !embedded && this.#activeScope && this.#deactivate();
}
#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 || root.removeAttribute("data-ldp-reader-host-root");
}
for (const [root, role] of next)
root.setAttribute("data-ldp-reader-host-root", role), role === "shell" && this.#enhancements.syncRoot(root);
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)
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));
}
}
}, "a8b7bd4a80d4d85a55f76d0a2fdcf88a198fa8379f7e8fc6b9e4a88baf6d6a84");
/* 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;
#readTrack;
#requestFrame;
#cancelFrame;
#createResizeObserver;
#resizeTargets;
#resizeObserver = null;
#frame = 0;
#pointer = null;
#active = !1;
#geometryDirty = !0;
#trackGeometry = { top: 0, height: 1 };
#destroyed = !1;
constructor(options) {
this.#workspace = options.workspace, this.#track = options.track, this.#thumb = options.thumb, this.#scroll = options.scroll, 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.model.reset(), this.#clearDom();
}
#schedule() {
!this.#active || this.#frame || (this.#frame = this.#requestFrame(() => this.#sync()));
}
#sync() {
if (this.#frame = 0, !this.#active) return;
if (this.#geometryDirty) {
const geometry = this.#readTrack();
this.#trackGeometry = {
top: finite(geometry.top, 0),
height: Math.max(1, finite(geometry.height, 1))
}, this.#geometryDirty = !1;
}
const snapshot = this.model.update(this.#scroll.read(), 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));
}
}
}, "286dc64ee043a9481bac9e51bb448ef56ab04fc07dfc33ed8126ef39427e43d9");
/* 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_native_topic_notification_action = require("../discourse/native-topic-notification-action.js"), import_value_record = require("../kernel/value-record.js");
const CARD_SELECTOR = "tr.topic-list-item,.topic-list-item,.latest-topic-list-item", TOPIC_LINK_SELECTOR = 'a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]';
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 setModelValue(value, key, next) {
const source = (0, import_value_record.valueRecord)(value), setter = source?.set;
typeof setter == "function" ? setter.call(value, key, next) : source && (source[key] = next);
}
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;
#notifications;
#notify;
#onError;
#roots = /* @__PURE__ */ new Set();
constructor(document, host, options = {}) {
this.#document = document, this.#host = host, this.#notifications = options.notifications ?? new import_native_topic_notification_action.BrowserDiscourseTopicNotificationLevelMutationPort(host), this.#notify = options.notify ?? (() => {
}), this.#onError = options.onError ?? (() => {
});
}
syncRoot(root) {
this.#roots.add(root), this.syncCards(
Object.freeze([...root.querySelectorAll(CARD_SELECTOR)])
);
}
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) {
const topicModels = this.#topicModels(), reactions = this.#reactionCounts(topicModels);
for (const card of cards)
!card.matches(CARD_SELECTOR) || card.closest(".ldp-overlay") || (this.#markDateCells(card), this.#groupTitleTools(
card,
this.#topicInput(card, topicModels)
), this.#syncStats(card, reactions));
}
clear() {
for (const root of this.#roots)
this.#clearRoot(root);
this.#roots.clear();
}
#clearRoot(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]"
)) 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");
}
#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) return;
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;
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);
cell.toggleAttribute("data-ldp-native-topic-date", date), cell.toggleAttribute("data-ldp-native-old-topic", old);
}
}
#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";
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", "将此话题设为免打扰"), button.setAttribute("aria-pressed", "false"), button.title = "免打扰", button.addEventListener("click", (event) => {
event.preventDefault(), event.stopPropagation(), this.#muteTopic(button, topic);
}), line.append(button), button;
}
async #muteTopic(button, topic) {
if (!(button.dataset.ldpNativeDndPending === "true" || button.getAttribute("aria-pressed") === "true")) {
button.dataset.ldpNativeDndPending = "true", button.disabled = !0, button.setAttribute("aria-busy", "true");
try {
await this.#notifications.setLevel(topic, 0), setModelValue(topic, "notification_level", 0);
const details = modelValue(topic, "details");
details && setModelValue(details, "notification_level", 0), button.dataset.ldpNativeDndActive = "true", button.setAttribute("aria-pressed", "true"), button.setAttribute("aria-label", "此话题已设为免打扰"), button.title = "已设为免打扰", this.#notify("已将话题设为免打扰");
} catch (cause) {
this.#onError(cause), this.#notify("设置免打扰失败,请稍后重试");
} finally {
delete button.dataset.ldpNativeDndPending, button.disabled = !1, button.removeAttribute("aria-busy");
}
}
}
#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
});
}
#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] ?? "";
}
}
}, "78cf133c3be18786587ae7156e2ce9ddea07ef0fac934c049b8723f2f1f1728c");
/* 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;
constructor(options) {
this.#host = new import_reader_action_form_support.ReaderActionFormSurfaceHost({
...options,
label: "ReaderAssignmentFormSurface"
}), this.scope = this.#host.scope;
}
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
}), { form, status, submit } = frame, usernameField = document.createElement("label");
usernameField.className = "ldp-reader-action-field";
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(/^@+/, ""), usernameField.append(usernameLabel, username);
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(usernameField, noteField);
const session = this.#host.start({
frame,
previousFocus,
closeSelector: "[data-assignment-close]",
cancelSelector: "[data-assignment-cancel]",
signal: request.signal
});
return form.addEventListener("submit", (event) => {
if (event.preventDefault(), session.busy) return;
const normalizedUsername = username.value.trim().replace(/^@+/, ""), normalizedNote = note.value.trim();
if (session.resetStatus(), !normalizedUsername) {
status.textContent = "请输入要指定的用户名", username.focus();
return;
}
session.submit({
execute: () => request.submit(Object.freeze({
username: normalizedUsername,
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 })), session.result;
}
destroy() {
this.#host.destroy();
}
}
}, "d3cfc6ae2bfd3be19338f0fa6631051f17f122abad4b9b95e5a299b5d35f9d5e");
/* 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-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-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-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-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;
}
}, "8c4834e1dbdb5692dccee2a5d2291c648ef55f8e0143e47635aad83c06cbc64b");
/* 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;
#onError;
#epoch = 0;
constructor(options) {
this.#entries = Object.freeze([...options.entries]), 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()) {
await target.close();
return;
}
for (const entry of this.#entries) {
if (entry === target || !entry.isOpen()) continue;
if (await entry.close() === !1 || epoch !== this.#epoch || this.scope.destroyed)
return;
}
if (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");
}
}
}
}, "17b7aa46b869bc5efc5ec15ca4b95a016cc456893a13253ecdf8757e3ac560fc");
/* 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-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 验证,已暂停后续请求;点击右侧按钮只打开一个人工验证浮窗。", 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);
}
}
}, "105e5418a41d0b484798de92b32252448685ee89a0148d676fd36f049f53a13b");
/* 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, {
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(",");
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.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()), 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 }));
}
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.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", search2.placeholder = "搜索字体", search2.setAttribute("aria-label", "搜索字体");
const empty = this.#document.createElement("span");
empty.className = "ldp-select-empty", empty.textContent = "没有匹配的字体", 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, settingsRect = select.closest(
".ldp-settings-popover"
)?.getBoundingClientRect(), bounds = Object.freeze({
left: Math.max(margin, (settingsRect?.left ?? 0) + margin),
right: Math.min(
viewport.innerWidth - margin,
(settingsRect?.right ?? viewport.innerWidth) - margin
),
top: Math.max(margin, (settingsRect?.top ?? 0) + margin),
bottom: Math.min(
viewport.innerHeight - margin,
(settingsRect?.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 });
}
}
}, "d241bb583b736323434e178cff20af100ef6c08ba053be687ca9299c06217543");
/* 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");
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 popoverSearch(options, name, placeholder, label, clearLabel) {
const root = (0, import_html_element.htmlElement)(options.document, "label", "ldp-popover-search");
root.append(icon(options, "search"));
const input = (0, import_html_element.htmlElement)(
options.document,
"input",
`ldp-popover-search-input ldp-${name}-search`
);
input.type = "search", input.autocomplete = "off", input.spellcheck = !1, input.placeholder = placeholder, input.setAttribute("aria-label", label);
const clear = button(
options,
`ldp-popover-search-clear ldp-${name}-search-clear`,
clearLabel,
"x"
);
return clear.hidden = !0, root.append(input, clear), { root, input, clear };
}
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}`,
"square"
);
select.setAttribute("aria-pressed", "false");
const remove = button(
options,
`ldp-collection-action danger ldp-${name}-delete-selected`,
deleteLabel,
"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`,
"退出多选",
"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"), title.append(titleJump);
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_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 {
root: notificationSearchWrap,
input: notificationSearch,
clear: notificationSearchClear
} = popoverSearch(
options,
"notification",
"搜索用户、标题、内容或拼音",
"搜索消息",
"清空消息搜索"
), notificationList = popoverList(
document,
"ldp-notification-list",
"正在加载消息…"
), {
root: notificationPager,
previous: notificationPagePrevious,
info: notificationPageInfo,
next: notificationPageNext
} = popoverPager(
options,
"notification",
"第 1 页"
);
notificationsPopover.append(
notificationModeTabsHost,
...notificationGroupPanels,
notificationToolbar,
notificationSearchWrap,
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 {
root: historySearchWrap,
input: historySearch,
clear: historySearchClear
} = popoverSearch(
options,
"history",
"搜索标题或拼音",
"搜索浏览历史",
"清空历史搜索"
), historyList = popoverList(
document,
"ldp-history-list ldp-notification-list",
"暂无浏览历史"
), {
root: historyPager,
previous: historyPagePrevious,
info: historyPageInfo,
next: historyPageNext
} = popoverPager(
options,
"history",
"暂无记录"
);
historyPopover.append(
historyTitle,
historySearchWrap,
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 [
["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 === "Reaction")), tab.classList.toggle("active", type === "Reaction"), tab.replaceChildren(), tab.textContent = label, bookmarkTabs.push(tab), bookmarkTabsHost.append(tab);
}
const {
root: bookmarksSearchWrap,
input: bookmarksSearch,
clear: bookmarksSearchClear
} = popoverSearch(
options,
"bookmarks",
"搜索收藏标题、内容或拼音",
"搜索收藏",
"清空收藏搜索"
), 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,
bookmarksSearchWrap,
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"), topicEditTrigger = button(
options,
"ldp-topic-edit-trigger",
"编辑帖子标题、类别和 label",
"pencil"
);
topicEditTrigger.hidden = !0, topicEditTrigger.setAttribute("aria-haspopup", "dialog"), topicEditTrigger.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"
);
titleActions.append(
topicEditTrigger,
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,
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,
notificationList,
notificationPagePrevious,
notificationPageInfo,
notificationPageNext,
historyBackEdge,
historyForwardEdge,
historyBackButton,
historyForwardButton,
historyToggle,
historyPopover,
historySortToggle,
historyMultiButton,
historyClearButton,
historyDefaultActions,
historyBulkActions,
historySelectScope,
historySelectToggle,
historyDeleteSelected,
historyDeleteSelectedLabel,
historyMultiDone,
historySearch,
historySearchClear,
historyList,
historyPagePrevious,
historyPageInfo,
historyPageNext,
bookmarksToggle,
bookmarksPopover,
bookmarkTabs: Object.freeze(bookmarkTabs),
bookmarksDefaultActions,
bookmarksMultiButton,
bookmarksBulkActions,
bookmarksSelectScope,
bookmarksSelectToggle,
bookmarksDeleteSelected,
bookmarksDeleteSelectedLabel,
bookmarksMultiDone,
bookmarksSearch,
bookmarksSearchClear,
bookmarkReactionFilters,
bookmarksList,
bookmarksPagePrevious,
bookmarksPageInfo,
bookmarksPageNext,
topicTimeline,
topicTimelineDate,
topicTimelineTrack,
topicTimelineCursor,
topicTimelineCurrent,
topicTimelineTotal,
topicTimelinePreview,
topicTimelineRelative,
topicTimelineJump,
topicTimelineTop,
topicTimelineJumpForm,
topicTimelineJumpInput,
topicTimelineJumpSubmit,
topicTimelineJumpHint,
liveUpdate,
liveUpdateJump,
liveUpdateLabel,
liveUpdateDismiss
});
}
}, "4a7c2d5e47275aa24ac709fab31ecfa031acf3d9f40d4ffef041dc0245b0421f");
/* 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
})))
})
)
), reserved = /* @__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+KeyT",
"Ctrl+Shift+Tab",
"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",
"Shift+Meta+KeyT",
"F11",
"F12"
]);
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) : /^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}”,请先移除或改用其他组合。`;
if (reserved.has(normalized))
return `${readerShortcutBindingLabel(normalized)} 通常由浏览器占用,无法保证生效,请换一个组合。`;
const parts = normalized.split("+"), code = parts.at(-1) ?? "";
return parts.length === 1 && /^(?:Key[A-Z]|Digit\d)$/.test(code) ? "单个字母或数字容易与论坛快捷键冲突,请至少加入 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, 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);
}
}
}, "d1011e417cc7ef5f2c25a5891952a9160942452deac0ca4e31f5453b92853545");
/* 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,
document: options.document,
overlay: options.elements.overlay,
mutations: this.mutations,
enhancements: options.enhancements,
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.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();
}
}
}
};
}
});
}
}, "222333071d8542f27f46257f47efac93e24938eaacf9653f5d372881e18aae0e");
/* 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;
}
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;
#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.#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) && (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);
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 {
}
}
#persist() {
this.#onPersist?.(this.#model.preferencePatch());
}
}
}, "a917af8306e469bd60d9eca1ff0eeefd329c34961904fce6270a4b138a8290e8");
/* 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;
}
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 (record.format !== this.#format || Number(record.schemaVersion) !== this.#schemaVersion)
throw new Error("invalid_config");
const settingsRecord = plainRecord(record.settings, "config settings"), originalKeys = Object.keys(settingsRecord);
if (Number(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
});
}
}
}, "bab6d3c95c652c4b4ebcf85c43f2da0cdd299b92f3025e2aa5f36e2eed638d40");
/* 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-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,
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");
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([
"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: "50",
custom: 50
}), 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"]), 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 (!/^(?:[A-Za-z][A-Za-z0-9]*|Mouse(?:1|3|4|[5-9]))$/.test(code) || /^(?: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 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(Boolean)
)].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",
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: "floating",
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",
openTopicsAtFirstPost: !1,
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 })
}),
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 : "floating", themeMode = ["light", "dark", "system"].includes(String(source.themeMode)) ? source.themeMode : "system", translationMode = ["bilingual", "translation"].includes(
String(source.translationMode)
) ? source.translationMode : "original", jumpColor = String(source.jumpHighlightColor || "").trim().toLowerCase();
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,
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,
openTopicsAtFirstPost: source.openTopicsAtFirstPost === !0,
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
),
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);
return new import_preferences_config_codec.PreferencesConfigCodec({
format: READER_CONFIG_EXPORT_FORMAT,
schemaVersion: READER_CONFIG_EXPORT_VERSION,
scriptVersion: options.scriptVersion,
defaults,
normalize,
legacyImportRules: [
{
missingDefaults: {
fullpageLayoutProfile: READER_FULLPAGE_LAYOUT_DEFAULT
}
},
{
missingDefaults: {
fullpageLayoutProfile: READER_FULLPAGE_LAYOUT_DEFAULT,
confirmNativeComposerClose: !0
}
},
{
missingDefaults: {
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
});
}
}, "9d8e1169c95b9bcf6b6ec631233d1a08199383c23b738540ca209a03ae59a72e");
/* 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 = 7, READER_SETTINGS_CONFIG_PREVIOUS_PORTABLE_VERSION = 6, 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,
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) {
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);
exactKeys(profile, [
"baseUrl",
"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(portable).some(([key, normalizedValue]) => profile[key] !== normalizedValue))
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 Object.freeze({
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, legacyWithoutOfflineTopics = !1) {
if (value === null) return null;
const source = record(value);
exactKeys(source, [
"endpoint",
"remotePath",
"categories",
"autoSyncEnabled",
"autoSyncIntervalMinutes"
]);
const categories = record(source.categories), expectedCategories = legacyWithoutOfflineTopics ? import_reader_webdav_model.READER_WEBDAV_CATEGORIES.filter((category) => category !== "offline-topics") : 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: ""
}), 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 = Number(source.schemaVersion);
if (schemaVersion === import_reader_preferences_schema.READER_CONFIG_EXPORT_VERSION) {
const preferences2 = this.#preferences.import(payload);
return Object.freeze({
sourceVersion: schemaVersion,
settingsCount: Number(source.settingsCount),
preferences: preferences2,
includesPortableSections: !1,
customSites: null,
translation: null,
webDav: null
});
}
if (schemaVersion !== READER_SETTINGS_CONFIG_EXPORT_VERSION && schemaVersion !== READER_SETTINGS_CONFIG_PREVIOUS_PORTABLE_VERSION || (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: Number(source.settingsCount),
preferences,
includesPortableSections: !0,
customSites: parseCustomSites(source.customSites),
translation: parsePortableTranslationConfig(source.translation),
webDav: parsePortableWebDavConfig(
source.webDav,
schemaVersion === READER_SETTINGS_CONFIG_PREVIOUS_PORTABLE_VERSION
)
});
}
}
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;
}
}
}
}, "c6f5319872ac0a9877996fe8c70a6239ef32a10dba0e61bc2b93a7953c2d7e25");
/* 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;
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() {
this.layout.setRoots(this.topology.rootBranches());
}
destroy() {
this.scope.destroy();
}
}
}, "901b94a73439d93951b30737ec7953945db7993756d113c53b43e072d0eb568b");
/* 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;
}
class VirtualRootLayout {
#estimatedSize;
#estimateSubtreeSize;
#measuredSizes = /* @__PURE__ */ new Map();
#postNumbers = [];
#subtreePostCounts = [];
#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();
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")
);
}
this.#postNumbers = [...unique].sort((left, right) => left - right), this.#subtreePostCounts = this.#postNumbers.map(
(postNumber) => subtreePostCountByPost.get(postNumber) ?? 1
);
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,
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)
), overscanStartIndex = this.#firstBlockEndingAfter(rangeStart), overscanEndIndex = Math.min(
this.#postNumbers.length,
Math.max(
overscanStartIndex + 1,
this.#firstBlockStartingAtOrAfter(rangeEnd)
)
), visibleStartIndex = this.#firstBlockEndingAfter(
Math.min(scrollOffset, Math.max(0, totalSize - 1))
), visibleEndIndex = Math.min(
this.#postNumbers.length,
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 && (startIndex = Math.min(startIndex, preserveRootIndex), endIndex = Math.max(endIndex, preserveRootIndex + 1));
const boundedEnd = Math.min(this.#postNumbers.length, endIndex);
return Object.freeze({
startIndex,
endIndex: boundedEnd,
postNumbers: Object.freeze(this.#postNumbers.slice(startIndex, boundedEnd)),
visiblePostNumbers: Object.freeze(
this.#postNumbers.slice(visibleStartIndex, visibleEndIndex)
),
atStart: scrollOffset <= 10,
atEnd: scrollOffset + viewportSize >= Math.max(0, totalSize - 16),
beforeSpacer: this.#prefix[startIndex] ?? 0,
afterSpacer: Math.max(0, totalSize - (this.#prefix[boundedEnd] ?? totalSize)),
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] = 0);
for (let index = start; index < this.#postNumbers.length; index += 1)
this.#prefix[index + 1] = (this.#prefix[index] ?? 0) + this.#sizeAt(index);
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;
}
}
}, "a6d89feae412cca138ee496e05b3299ce4d8c5ca5526cf89aa46bc1024260320");
/* 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;
#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());
}
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)
), Object.freeze({
window,
tree,
attachedRoots: Object.freeze(
attachedRoots.sort((left, right) => left - right)
),
detachedRoots: Object.freeze(
detachedRoots.sort((left, right) => left - right)
)
});
}
}
}, "375b0521c824c0c6ea4e7f69612af9ed71a0f0f89992ed531b86a3166d207be0");
/* 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");
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"), 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"), 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"), root.append(beforeSpacer, rootList, afterSpacer, empty, loadingTip, endTip), this.slots = Object.freeze({
root,
beforeSpacer,
rootList,
afterSpacer,
empty,
loadingTip,
endTip
});
}
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();
}
}
}, "2174765b588dd5309fc62c1f1032549fb614488a294e6aa431a12fd4f437db2a");
/* 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-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 source = options.presentation.avatarSource(
text(post.avatar_template),
48
), trigger = options.document.createElement("button");
if (trigger.type = "button", trigger.className = "ldp-user-link ldp-avatar-link", trigger.dataset.readerAvatar = "", trigger.dataset.userAvatarPreview = "", trigger.dataset.userCard = username, trigger.setAttribute("aria-label", `查看 ${displayName} 的头像原图`), source) {
const avatar = options.document.createElement("img");
avatar.className = "ldp-avatar", (0, import_reader_image_fallback.replaceImageWithFallbackOnError)(avatar, () => {
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;
}), avatar.src = source, avatar.alt = "", avatar.loading = "lazy", avatar.decoding = "async", trigger.append(avatar);
} 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);
if (relative) {
const time = options.document.createElement("span");
time.className = "ldp-time";
const cachedExact = exactTimeByView.get(view), exact = cachedExact?.timestamp === createdAt ? cachedExact.value : options.exactTime(createdAt);
exact && cachedExact?.timestamp !== createdAt && exactTimeByView.set(view, Object.freeze({
timestamp: createdAt,
value: exact
})), exact && (time.dataset.exactTime = exact);
const label = options.document.createElement("span");
label.className = "ldp-time-relative", label.textContent = `· ${relative}`, time.append(label), header.append(time);
}
post.hidden === !0 && appendBadge(
options.document,
header,
"ldp-special-badge ldp-hidden-badge warn",
"已隐藏"
), appendFloor(
options.document,
header,
postNumber,
replyToPostNumber
), appendReadState(
options.document,
header,
post.read === !0,
options.renderIcon
), view.slots.content.innerHTML = text(post.cooked);
}
});
}
}, "5548e75bed3772379a527cab98c7b1714104915243a85f15901bb917c4c156cd");
/* 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;
#preferences;
#canonicalCoverageComplete;
#revealedFloors = /* @__PURE__ */ new Set();
#revealedParents = /* @__PURE__ */ new Set();
#degradedFloorRoots = /* @__PURE__ */ new Set();
#degradedFloorCanonicalRevision = -1;
#postFilter = null;
#projectionRevision = 0;
#cachedCanonicalRevision = -1;
#cachedCanonicalCoverageComplete = null;
#cachedProjectionRevision = -1;
#cachedRoots = Object.freeze([]);
#cachedRootBranches = Object.freeze([]);
#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);
}
get preferences() {
return this.#preferences;
}
get revision() {
return `${this.#activeCanonicalRevision()}:${Number(this.#activeCanonicalCoverageComplete())}:${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(), !0);
}
/** 停滚后切回最新 canonical;返回冻结期间关系是否发生变化。 */
thawCanonical() {
if (!this.#frozenCanonical) return !1;
const changed = this.#frozenCanonicalSourceRevision !== this.canonical.revision || this.#frozenCanonicalCoverageComplete !== this.#canonicalCoverageComplete();
return this.#frozenCanonical = null, this.#frozenCanonicalSourceRevision = -1, this.#frozenCanonicalCoverageComplete = !0, changed;
}
get canonicalFrozen() {
return this.#frozenCanonical !== null;
}
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);
}
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();
if (this.#cachedCanonicalRevision === canonicalRevision && this.#cachedCanonicalCoverageComplete === canonicalCoverageComplete && this.#cachedProjectionRevision === this.#projectionRevision) return;
const relations = canonical.snapshot().relations, roots = Object.freeze(relations.map((relation) => relation.postNumber).filter((postNumber) => this.parentOf(postNumber) === null).sort((left, right) => left - right)), rootBranches = Object.freeze(roots.map((postNumber) => {
let subtreePostCount = 0;
const pending = [postNumber];
for (; pending.length; ) {
const current = pending.pop();
subtreePostCount += 1, pending.push(...this.childrenOf(current));
}
return Object.freeze({ postNumber, subtreePostCount });
})), 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 < relations.length && relations[relationIndex].postNumber <= rootPostNumber; )
relationIndex += 1;
const run = [];
let previousPostNumber = rootPostNumber;
for (; relationIndex < relations.length && relations[relationIndex].postNumber < nextRootPostNumber; ) {
const hiddenPostNumber = relations[relationIndex].postNumber;
if (!canonicalCoverageComplete && 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.#cachedHiddenFloorRunsAfter = hiddenFloorRunsAfter, this.#cachedCanonicalRevision = canonicalRevision, this.#cachedCanonicalCoverageComplete = canonicalCoverageComplete, 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();
}
#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;
}
}
}, "7dd19bdf297410b6b32916d5b66cc3a12dd03557259efe7234ab302f909b6689");
/* 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_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 {
#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;
}
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();
} catch (error) {
this.#onError(error);
}
return this.#state;
})(), this.#loadPromise);
}
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.#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));
}
});
}
}, "9f024429af2d4fe1570b1e820d3a65516eb5108af753cae8ad821c881298be94");
/* 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_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 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 ?? (() => {
}), (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) {
if (postRoot.dataset.postNumber !== String(state.postNumber)) return !1;
const matched = this.#applyQuoteHighlight(
postRoot,
state.text,
state.active,
state.source,
!0
);
return matched || this.#revealQuoteTarget?.(postRoot, "floor"), matched;
}
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";
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}`
);
jump.dataset.readerContextQuote = "jump", jump.dataset.targetPostNumber = String(targetPostNumber), jump.dataset.targetTopicId = String(targetTopicId), jump.append(this.#icon("arrow-up")), controls.append(jump);
const fullPost = this.#controller.quotedPost(
targetTopicId,
targetPostNumber
);
if (fullPost) {
this.#applyQuotePost(quote, body, fullPost);
continue;
}
this.#hydrateQuoteBody(
view,
quote,
body,
key,
targetTopicId,
targetPostNumber
).catch((error) => {
this.scope.destroyed || this.#onError(error);
});
}
}
#applyQuotePost(quote, body, fullPost) {
const cooked = postCooked(fullPost);
return quote.dataset.ldpQuoteHydrated = "1", body.innerHTML === cooked ? !1 : (body.innerHTML = cooked, !0);
}
async #hydrateQuoteBody(view, quote, body, key, targetTopicId, targetPostNumber) {
const fullPost = await this.#loadQuotePost(
targetTopicId,
targetPostNumber
);
!fullPost || 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,
this.#expandedQuoteKeys.has(key) ? "expanded" : "collapsed"
);
}
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.#hiddenReplyTreeProjectionReady(
root,
postNumber,
hiddenPostNumbers
)) {
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);
}
#hiddenReplyTreeProjectionReady(root, postNumber, hiddenPostNumbers) {
const presentation = this.#presentation;
if (!presentation || !hiddenPostNumbers.some(
(hiddenPostNumber) => presentation.rootOf(hiddenPostNumber) === postNumber
) || presentation.childrenOf(postNumber).length === 0)
return !0;
const replyList = root.querySelector(
":scope > .ldp-children > .ldp-reply-list"
);
return replyList ? Array.from(replyList.children).some((child) => child.classList.contains("ldp-post") || child.classList.contains("ldp-tree-virtual-spacer")) : !1;
}
#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();
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
}, root, jumpEpoch);
if (!result || !this.#isActiveRoot(root)) 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;
}
result.element && !this.applyRevealedQuoteHighlight(quoteHighlight, result.element) && this.#notify(
`目的地内容已修改;已定位到楼层 #${targetPostNumber}`
);
return;
}
const quote = quoteAction.closest(".ldp-post-quote"), body = quote?.querySelector(":scope > blockquote");
if (!quote || !body) return;
const key = String(quoteAction.dataset.quoteKey ?? ""), expanded = this.#expandedQuoteKeys.has(key), anchorRoot = postRoot ?? root;
if (expanded)
this.#expandedQuoteKeys.delete(key), 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",
"完整引用不可用;可使用原生引用链接"
);
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, root, jumpEpoch) {
for (let attempt = 0; ; attempt += 1) {
let result;
try {
result = await navigation.navigate(request);
} catch (error) {
throw this.#isActiveRoot(root) && jumpEpoch === this.#quoteJumpEpoch && this.#notify(
`目的地楼层 #${request.postNumber} 定位失败;请稍后重试`
), error;
}
if (!this.#isActiveRoot(root) || jumpEpoch !== this.#quoteJumpEpoch)
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.#isActiveRoot(root) || jumpEpoch !== this.#quoteJumpEpoch)
return null;
if (navigationRevision !== void 0 && navigation.isCurrent && !navigation.isCurrent(navigationRevision))
return Object.freeze({
...result,
status: "superseded"
});
}
}
#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 markRect = lastMark.getBoundingClientRect(), rootRect = this.#scrollRoot.getBoundingClientRect(), hintRect = hint.getBoundingClientRect(), viewportWidth = this.#document.defaultView?.innerWidth ?? this.#document.documentElement.clientWidth, anchorX = event && Number.isFinite(event.clientX) ? event.clientX : markRect.left + markRect.width / 2, anchorY = event && Number.isFinite(event.clientY) ? event.clientY : markRect.top, edge = 8, gap = QUOTE_HINT_POINTER_GAP_PX, left = Math.max(
edge,
Math.min(
anchorX - hintRect.width / 2,
viewportWidth - hintRect.width - edge
)
), top = anchorY - hintRect.height - gap < rootRect.top + edge ? anchorY + gap : anchorY - hintRect.height - gap;
hint.style.left = `${Math.round(left)}px`, hint.style.top = `${Math.round(Math.max(edge, 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.#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.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
);
}
}
}, "a26b568140e3a7e97d1eb5e2b934b4207175b2205e7368e8767c3f2aa4bc9e3f");
/* 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;
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;
#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();
#rootVirtualInsets = /* @__PURE__ */ new Map();
#mountedPostNumbers = /* @__PURE__ */ new Set();
#activeContentPostNumbers = /* @__PURE__ */ new Set();
#nextContentPostNumbers = /* @__PURE__ */ new Set();
#directReplyPrefetchCandidatePostNumbers = /* @__PURE__ */ new Set();
#directReplyPrefetchOrderedCandidates = Object.freeze([]);
#directReplyVisiblePostNumbers = /* @__PURE__ */ new Set();
#directReplyVisibleKey = "";
#directReplyPrefetchCandidateKey = "";
#branchPaintHandle = null;
#branchPaintGeneration = 0;
#pendingBranchCollapseAnchor = null;
#directReplyPrefetchHandle = null;
#retainedViewLimit = 0;
#lastVisibleRootChangeKey = "";
#destroyed = !1;
constructor(options) {
if (this.scope = import_lifecycle.LifecycleScope.ownedBy(options.parentScope), this.#session = options.session, this.#scroll = options.scroll, 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.#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: () => options.replies.coverage().complete
}
), 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()
}
), 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.#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);
}
}
},
...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(() => {
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.#rootObservers.keys()) {
const root = this.domOwner.view(postNumber)?.slots.root;
root && this.#deactivateBranch(root, postNumber);
}
this.#activeContentPostNumbers = /* @__PURE__ */ new Set(), this.#nextContentPostNumbers = /* @__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.#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 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.#treeViewport.visiblePostNumbers.some(
(postNumber) => !this.#session.postByNumber(postNumber)
);
}
lastUserScrollAt() {
return this.#assertActive(), this.#scrollLifecycle.lastUserScrollAt();
}
listenUserScrollIntent(listener) {
return this.#assertActive(), this.#scroll.listenUserScrollIntent?.(listener) ?? (() => {
});
}
captureViewportAnchor() {
this.#assertActive();
const physicalAnchor = this.#scroll.readVisibleViewportAnchor?.(
this.#visiblePostElements()
), input = this.#scroll.readWindowInput();
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
});
}
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
});
}
#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 postNumber = (0, import_identifiers.discoursePostReference)({
post_number: anchor.postNumber
}).postNumber, rootPostNumber = this.domOwner.topology.rootOf(postNumber);
if (rootPostNumber === void 0) return !1;
const postLayoutOffset = this.#treeViewport.offsetOf(postNumber) ?? this.layout.offsetOf(rootPostNumber);
if (postLayoutOffset === void 0) return !1;
const postOffset = Number(anchor.postOffset);
return this.#scroll.writeScrollOffset(
postLayoutOffset + (Number.isFinite(postOffset) ? postOffset : 0)
), this.frame.flushNow(), !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;
}
revealPost(rawPostNumber, options) {
this.#assertActive();
const postNumber = (0, import_identifiers.discoursePostReference)({
post_number: rawPostNumber
}).postNumber;
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) {
const offset = this.#treeViewport.offsetOf(postNumber) ?? this.layout.offsetOf(rootPostNumber);
if (offset === void 0) return null;
this.#scroll.writeScrollOffset(offset), this.frame.flushNow();
}
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
}));
}
destroy() {
this.#destroyed || (this.#destroyed = !0, this.scope.destroy());
}
#queueSessionCommit(commit) {
this.#rootProjection.syncRoots(), this.#applySessionCommit(commit), this.#emitPresentationCommit(commit);
}
#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.#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);
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)
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.#nextContentPostNumbers = plan.contentPostNumbers;
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), 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 created = this.postProjector.create(
post,
this.scope,
postNumber
);
this.domOwner.register(created, !1);
} catch (error) {
this.#onError(error);
}
}
return plan;
}
#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), 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)
);
const previousCandidates = this.#directReplyPrefetchCandidatePostNumbers;
this.#directReplyPrefetchCandidatePostNumbers = new Set(
this.#directReplyPrefetchOrderedCandidates
);
for (const postNumber of previousCandidates)
this.#directReplyPrefetchCandidatePostNumbers.has(postNumber) || this.#directReplyPrefetchAttemptedExpectedCounts.delete(postNumber);
}
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.#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.postProjector.attach(root, postNumber, "branch");
}
#deactivateBranch(root, 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 已销毁");
}
}
}, "7e26da489b8db6407538fdca6762ab5a4583418dcdbea13558b8ac3395c7ae52");
/* 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;
class ReaderTopicFlowController {
scope;
#dom;
#readPerformance;
#scheduler;
#onError;
#readLoadDone;
#scheduledHandle = null;
#scheduledUrgency = null;
#running = !1;
#rerun = !1;
#done = !1;
#projectionPriority = !1;
#retryCount = 0;
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.#done || this.#queue(this.#urgency());
}, this.scope), options.sessionChanges?.subscribe((commit) => {
commit.streamChanged && this.#readLoadDone() !== !0 && (this.#done = !1, this.#retryCount = 0, this.#syncStatus(!1), this.#queue(this.#urgency()));
}, this.scope), this.scope.add(() => {
this.#scheduledHandle !== null && this.#scheduler.cancel(this.#scheduledHandle), this.#scheduledHandle = null, this.#scheduledUrgency = null;
}), this.#done || this.#queue(this.#urgency());
}
refreshPerformance() {
this.scope.destroyed || (this.#dom.flushNow(), !this.#done && this.#queue(this.#urgency(), !0));
}
/**
* 需要完整 Topic 投影的功能(目前为“只看楼主”)只提升 canonical Flow,
* 不创建第二个 cursor、帖子缓存或请求循环。
*/
setProjectionPriority(enabled) {
this.scope.destroyed || this.#projectionPriority === enabled || (this.#projectionPriority = enabled, this.#dom.flushNow(), this.#done || this.#queue(this.#urgency(), !0));
}
destroy() {
this.scope.destroy();
}
#urgency() {
if (this.#projectionPriority || this.#dom.hasVisibleDataGap?.() === !0) return "near-window";
const commit = this.#dom.frame.lastCommit;
if (!commit) return "near-window";
const input = this.#dom.readWindowInput(), forwardScreens = Math.max(
this.#readPerformance().nestedPrefetchScreens,
Number(input.overscanAfterScreens) || 0
), horizon = input.viewportSize * forwardScreens;
return commit.window.afterSpacer <= horizon ? "near-window" : "background";
}
#queue(urgency, replace = !1, delayOverrideMs) {
if (this.scope.destroyed || this.#done) 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.#done || this.#running) return;
this.#running = !0, this.#rerun = !1, this.#syncStatus(!0);
const urgency = this.#urgency(), visibleDataGap = this.#dom.hasVisibleDataGap?.() === !0;
let source = null, retryDelayMs = null;
try {
const load = this.#dom.loadNext({
background: urgency === "background",
priority: visibleDataGap ? "nested" : "visible",
maxAttempts: urgency === "near-window" ? 2 : 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 || (this.#done = this.#readLoadDone() ?? result.done, this.#dom.flushNow(), this.#syncStatus(!1), result.fatal)) return;
if (result.retry) {
this.#retryCount += 1;
const baseDelayMs = Math.max(
600,
this.#readPerformance().requestMinIntervalMs * 4
);
retryDelayMs = Math.min(
3e4,
baseDelayMs * 2 ** Math.min(5, this.#retryCount - 1)
);
return;
}
if (this.#retryCount = 0, !this.#done) {
const nextUrgency = this.#urgency();
nextUrgency === "near-window" && this.#queue(nextUrgency);
}
} catch (error) {
this.scope.destroyed || (this.#syncStatus(!1), this.#onError(error));
} finally {
if (this.#running = !1, retryDelayMs !== null && !this.#done && !this.scope.destroyed)
this.#queue(this.#urgency(), !0, retryDelayMs);
else if (this.#rerun && !this.#done && !this.scope.destroyed) {
const nextUrgency = this.#urgency();
nextUrgency === "near-window" && this.#queue(nextUrgency, !0);
}
}
}
#syncStatus(loading) {
this.#dom.setFlowStatus?.(Object.freeze({
loading,
done: !loading && this.#done
}));
}
}
}, "b021a1321ba88e7ef0d78bea31f51015e445b13a19ef352ab7896a8f6f03514a");
/* 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}`;
}
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-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")?.prepend(note)), note.textContent = `本地引用存档 · ${archiveLabel(unavailable.status)} · ${this.#nowLabel(unavailable.confirmedAt)} 前记录。`;
}
}
}, "b80daecb23a57fceca4a649d7a642d55e60abb6e5b5b107359368c972d066d39");
/* 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.forceRefresh === !0 || !this.#session.postByNumber(postNumber)) && await this.#session.loadTarget(postNumber, {
scope: "around",
advanceCursor: !0,
...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 = 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,
...ancestorResolution.complete ? {} : {
degradedRootPostNumber: ancestorResolution.rootPostNumber
},
...request.alignment === void 0 ? {} : { alignment: request.alignment },
...request.focus === void 0 ? {} : { focus: request.focus },
...request.highlight === void 0 ? {} : { highlight: request.highlight }
}, reveal = 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 已销毁");
}
}
}, "e52deaa34066ec53ddf69f0edf481930d7a2810b6330e9421c0dfe5f1c81a381");
/* 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, {
ReaderTopicJumpHighlightController: () => ReaderTopicJumpHighlightController,
ReaderTopicScrollAdapter: () => ReaderTopicScrollAdapter
});
module.exports = __toCommonJS(reader_topic_scroll_adapter_exports);
var import_lifecycle = require("../kernel/lifecycle.js");
function finiteNonNegative(value, fallback = 0) {
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
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;
}
}
const SCROLLING_KEYS = /* @__PURE__ */ new Set([
"ArrowDown",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
" "
]), USER_SCROLL_SESSION_GAP_MS = 500;
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();
#viewportSize = 1;
#viewportSizeDirty = !1;
#scrollOffset = 0;
#pendingScrollOffset = null;
#scrollOffsetDirty = !1;
#scrollFrame = 0;
#lastUserScrollAt = 0;
#userScrollSessionActive = !1;
#stationaryAnchor = null;
#stationaryScrollWriteOffset = null;
#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());
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) => {
event.ctrlKey || this.#markUserScrollIntent();
}, { passive: !0 });
for (const type of ["touchstart", "touchmove"])
this.scope.listen(this.#scrollRoot, type, () => {
this.#markUserScrollIntent();
}, { 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.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());
}
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;
return (this.#viewportSizeDirty || bootstrapViewportSize) && (this.#viewportSize = viewportSize, this.#viewportSizeDirty = !1), Object.freeze({
scrollOffset,
viewportSize,
...this.#stationaryAnchor === null ? {} : { preservePostNumber: this.#stationaryAnchor.postNumber },
/*
* overscan 是用户/性能策略的稳定窗口契约。滚动方向只改变 offset;
* 若在反向瞬间翻转前后边界,会额外整批卸载/挂载树节点并制造尖峰。
*/
overscanBeforeScreens: finiteNonNegative(
Number(overscan.beforeScreens),
1
),
overscanAfterScreens: finiteNonNegative(
Number(overscan.afterScreens),
1
),
...maxMountedPostCount === void 0 ? {} : { maxMountedPostCount }
});
}
lastUserScrollAt() {
return this.#lastUserScrollAt;
}
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;
}
#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) {
!Number.isFinite(delta) || delta === 0 || (this.#refreshPendingScrollOffset(), this.#scrollOffset = Math.max(0, this.#scrollOffset + delta), this.#pendingScrollOffset !== null && (this.#pendingScrollOffset = Math.max(
0,
this.#pendingScrollOffset + delta
)), this.#writeScrollRootOffset(
this.#pendingScrollOffset ?? this.#scrollOffset
), 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);
}));
}
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);
}
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.#releaseStationaryViewport(), this.#scrollOffset = Math.max(0, finiteNonNegative(offset)), this.#pendingScrollOffset = this.#scrollOffset, this.#scrollOffsetDirty = !1, this.#lastUserScrollAt = 0, this.#userScrollSessionActive = !1, this.#writeScrollRootOffset(this.#scrollOffset);
}
#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() {
this.#releaseStationaryViewport(), this.#userScrollSessionActive = !0, this.#lastUserScrollAt = Math.max(
Number.EPSILON,
finiteNonNegative(this.#now())
);
for (const listener of [...this.#userScrollIntentListeners]) 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.#lockStationaryViewport());
}
#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.#scrollRoot.classList.add("ldp-stream-viewport-anchor"), this.#stationaryMutationObserver?.observe(this.#scrollRoot, {
attributes: !0,
attributeFilter: ["class", "hidden", "style"],
characterData: !0,
childList: !0,
subtree: !0
}), this.#observeStationaryContentSize());
}
#releaseStationaryViewport() {
this.#stationaryAnchor = null, this.#stationaryScrollWriteOffset = null, this.#stationaryMutationObserver?.disconnect(), this.#stationaryResizeObserver?.disconnect(), this.#scrollRoot.classList.remove("ldp-stream-viewport-anchor");
}
#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
);
this.#scrollOffset = nextOffset, this.#pendingScrollOffset = nextOffset, this.#scrollOffsetDirty = !1, this.#writeScrollRootOffset(nextOffset);
}
#writeScrollRootOffset(offset) {
this.#stationaryAnchor && (this.#stationaryScrollWriteOffset = offset), this.#scrollRoot.scrollTop = offset;
}
#claimScrollOnlyUserInput() {
if (!this.#stationaryAnchor || this.#userScrollSessionActive) return;
const actualOffset = finiteNonNegative(this.#scrollRoot.scrollTop), internalOffset = this.#stationaryScrollWriteOffset;
this.#stationaryScrollWriteOffset = null, !(internalOffset !== null && Math.abs(actualOffset - internalOffset) < 0.5) && this.#markUserScrollIntent();
}
#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;
}
}
}, "62099ec9c2b6aec3830d06b1a57f6f8f888cb5129bef70fc5cda435212626ae5");
/* 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.hidden === !0 && add("已隐藏", "warn"), 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.title = 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.title = 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);
}
}
}, "a25a0e582f7c7356abc805a738c93b073a947f345738805c07ae16c7c486dfc4");
/* 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;
#onError;
#snapshot;
#jumpEpoch = 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.#onError = options.onError ?? (() => {
}), this.#snapshot = this.#derive(
options.initialPostNumber ?? 1,
null
), this.#navigation.changes.subscribe((result) => {
result.status === "revealed" && this.#commit(
result.rootPostNumber ?? result.postNumber,
this.#snapshot.pendingPostNumber
);
}, this.scope), this.scope.add(() => {
this.#jumpEpoch += 1, this.changes.clear();
});
}
get snapshot() {
return this.#snapshot;
}
refresh() {
return this.#commit(
this.#snapshot.currentPostNumber,
this.#snapshot.pendingPostNumber
);
}
syncVisiblePost(postNumber, options = {}) {
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
);
}
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 已销毁");
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 = normalizedNavigablePosts(
this.#readNavigablePostNumbers(),
totalPostCount
);
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;
}
}
}, "723fab465feaadd0631af235a22c01453df3f315f1af2dec57f07641496dca90");
/* 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.targetByStep(
this.#controller.snapshot.currentPostNumber,
Number.POSITIVE_INFINITY
);
this.#submitJump(target);
}), 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) {
this.scope.destroyed || this.#controller.snapshot.pendingPostNumber !== null || (this.#beginJumpAnimation(postNumber), 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 已销毁");
}
}
}, "b90805592fe59d521b7c704ace802709fdce614c1dbc4aba584fa114aeb8a2d3");
/* 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 ? "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 ? "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 ? "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
})
});
}
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;
}
}
}, "a4d4661b418d7855a2bfde8c5f5cb70167cfce87728bf5aa411ce9f80cf9abce");
/* 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 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]);
}
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();
#cachedPostsSnapshot = null;
#pendingByPostId = /* @__PURE__ */ new Map();
#pendingDirectReplies = /* @__PURE__ */ new Map();
#unavailablePostNumbers = /* @__PURE__ */ new Set();
#streamPostIds = Object.freeze([]);
#topic = null;
#cursor = 0;
#sequentialLoadStarted = !1;
#initializedFromCache = !1;
#initPromise = null;
#refreshPromise = null;
#postStreamPromise = null;
#postStreamExecution = null;
#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;
}
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 = {}) {
return this.#assertActive(), this.#loadTopic(options, !0);
}
#loadTopic(options, refresh) {
if (this.#refreshPromise) return this.#refreshPromise;
const observedAt = this.#now(), promise = this.#requests.loadTopic({
...options,
refresh
}).then((topic) => (this.#assertActive(), this.#commitTopic(topic, "topic-json", observedAt), 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));
options.onSource?.(missingBefore.length ? "network" : "cache", Object.freeze({
cachedCount: ids.length - missingBefore.length,
missingCount: missingBefore.length,
totalCount: ids.length
}));
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), 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.#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), 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 nextStream = this.#streamWithCreatedPost(
reference.postId,
reference.postNumber
), expectedPostCount = nextStream === void 0 ? void 0 : Math.max(
nextStream.length,
this.#snapshots.snapshot().expectedPostCount
), 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: nextStream },
...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;
}
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 (!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;
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;
break;
}
if (scope === "single" && candidate.endpoint === "post-by-number" && status === 403 && cachedBeforeRequest !== void 0) {
definitiveStatus = 403;
continue;
}
if (isAuthFailure(error) || isThrottleFailure(error)) throw error;
}
}
if (scope === "around" && this.#streamPostIds.length) {
const center = this.#streamIndexForPostNumber(postNumber), start = Math.max(
0,
Math.min(
Math.max(0, this.#streamPostIds.length - this.#pageSize),
center - Math.floor(this.#pageSize / 2)
)
), result = await this.loadPostsByIds(
this.#streamPostIds.slice(start, start + this.#pageSize)
);
if (result.posts.length)
return this.#advanceCursorPast(result.posts, postNumber), result.posts;
}
return scope === "single" && definitiveStatus !== null && (this.#unavailablePostNumbers.add(postNumber), cachedBeforeRequest) ? (this.#snapshots.markPostUnavailable(
postNumber,
definitiveStatus,
this.#now()
), this.#syncLocalArchiveState(), shouldAdvance && this.#advanceCursorPast([cachedBeforeRequest], postNumber), Object.freeze([cachedBeforeRequest])) : (fallback.length && shouldAdvance && this.#advanceCursorPast(fallback, postNumber), Object.freeze([...fallback]));
}
loadBeforePost(postNumber, options = {}) {
return this.#loadRelative(postNumber, "before", options);
}
loadAfterPost(postNumber, options = {}) {
return this.#loadRelative(postNumber, "after", options);
}
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 request = this.#requests.loadNestedReplies;
if (typeof request != "function")
throw new Error("TopicSession 请求端口未提供 Discourse 直属回复能力");
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
);
if (!options.refresh && expectedCount <= posts.length)
return Object.freeze({
parentPostNumber,
posts,
scopedPosts: posts,
expectedCount,
complete: !0,
endpointExhausted: !1,
pageCount: 0,
nextAfter: 0
});
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();
const snapshot = this.#snapshots.snapshot();
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));
}
#commitTopic(topic, source, observedAt) {
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 = 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 });
input.expectedPostCount !== void 0 && this.#replies.setExpectedPostCount(input.expectedPostCount);
const 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
});
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.#streamPostIds = this.#snapshots.streamPostIds(), !changedPostNumbers.length) return;
const entries = [], changedIds = /* @__PURE__ */ new Set();
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;
}
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);
}
#restoreIndexes() {
this.#cachedPostsSnapshot = null, this.#topic = this.#snapshots.topic(), this.#streamPostIds = this.#snapshots.streamPostIds(), 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);
}
#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);
}
}
#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) {
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.beforeNetwork === void 0 ? {} : { beforeNetwork: options.beforeNetwork }
}), posts = discoursePostsFromPayload(payload);
await options.beforeCommit?.(), this.#assertActive(), this.ingestPosts(posts, "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.#streamPostIds.indexOf(reference.postId);
if (exact >= 0) return exact;
}
}
return Math.min(this.#streamPostIds.length - 1, Math.max(0, 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");
}
}
}, "4cd7a5453578b0f1444c14df497571329e73b192e2c6f6f845219f925b02fdc2");
/* 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 modern = (0, import_value_record.objectRecord)(this.#userscriptGlobal.GM), modernGet = modern?.getValue, modernSet = modern?.setValue;
if (typeof modernGet == "function" && typeof modernSet == "function")
return Object.freeze({
getValue: (key) => modernGet.call(modern, key, null),
setValue: (key, value) => modernSet.call(modern, key, value)
});
const legacyGet = this.#userscriptGlobal.GM_getValue, legacySet = this.#userscriptGlobal.GM_setValue;
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)
});
}
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;
};
}
}
}, "6f3de165942d7bf43142e7f3b938030155c244dcf1c8514c80a5917e0fb3329a");
/* 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_reader_application_stages = require("../app/reader-application-stages.js"), 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_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_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_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_performance_settings_form = require("../settings/reader-performance-settings-form.js"), import_reader_reading_settings_form = require("../settings/reader-reading-settings-form.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_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__", 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 requestedMode(preferences, routeKind) {
return routeKind === "direct-topic" ? preferences.topicReaderMode : preferences.listReaderMode;
}
function createRuntimeStage(environment, document, window, state, 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(), 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.discourseNativeCurrentUsername)(
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;
return (0, import_reader_userscript_application.createReaderUserscriptRuntimeStage)({
environment,
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 {
document,
routeKind,
requestedMode: requestedMode(
readPreferences(),
routeKind
),
embedWidth: readPreferences().listReaderEmbedWidth,
windowPreferences: readPreferences(),
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
}),
scrollTo: (top) => window.scrollTo({
top,
behavior: "auto"
})
},
enhancements: new import_embedded_host_topic_card_enhancement.EmbeddedHostTopicCardEnhancement(
document,
environment.discourseHost,
{
notify: (message) => state.runtime?.feedback.show(message),
onError: (cause) => {
console.error(
"[main-lite:host-topic-notification]",
cause
);
}
}
),
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 }
);
},
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 ? customSites.translation ? {
initialAnimation: (0, import_reader_translation_config.readerTranslationActiveProfile)(
customSites.translation.snapshot.config
).animation,
subscribeAnimation: (listener, animationScope) => {
customSites.translation.changes.subscribe(
(snapshot) => listener((0, import_reader_translation_config.readerTranslationActiveProfile)(
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
},
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}/${entry.postNumber}`,
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,
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
)
}
};
}
},
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),
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
}
} : {},
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
}
} : {},
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,
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);
},
feedback: runtime.feedback,
isActive: () => !runtime.scope.destroyed,
onError: (error) => {
console.error("[main-lite:settings-reset-reminder]", error);
}
}));
};
runtime.shell.changes.subscribe(
checkSettingsResetReminder,
runtime.scope
), checkSettingsResetReminder();
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,
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)] ?? null, opened = await runtime.openTarget({
topicId: recent.topicId,
postNumber: anchor?.viewport.postNumber ?? recent.postNumber,
source: "restore",
alignment: "nearest"
});
anchor && (opened.topic.status === "opened" || opened.topic.status === "reused") && await runtime.historyNavigation.restore(recent.topicId, anchor);
})().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))
return existing?.destroy?.(), 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 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,
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: [
(0, import_reader_application_stages.createPreferencesStorageSyncStage)({
key: import_reader_preferences_schema.READER_PREFERENCES_STORAGE_KEY,
window,
repository: preferences,
onError: (cause) => {
console.error("[main-lite:preferences-sync]", cause);
}
}),
createStyleStage(environment, document, state),
createRuntimeStage(
environment,
document,
window,
state,
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), application.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;
}, "9df6ee0eaca4d58065c1fcabba906403e4b9c2b9204468e323fd9444a6ad407b");
/* 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-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 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();
#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.#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);
}
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) {
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());
});
}
}
}, "54da6eb46a64b56fbbc912bb38f329790d833b921800f746de735d514621f67a");
/* 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_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_floating_host_target_controller = require("./reader-floating-host-target-controller.js");
function createReaderUserscriptRouteChangePort(host) {
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;
return typeof withPluginApi != "function" ? null : Object.freeze({
subscribe(handler) {
if (typeof handler != "function")
throw new TypeError("Discourse page-change handler 必须是函数");
let active = !0, pageCleanup = null, pluginCleanup = null;
const result = withPluginApi.call(owner, (apiValue) => {
if (!active) return;
const api = (0, import_value_record.valueRecord)(apiValue), onPageChange = api?.onPageChange;
if (typeof onPageChange != "function")
throw new Error("Discourse plugin API 缺少 onPageChange");
const cleanup = onPageChange.call(api, () => {
active && handler();
});
typeof cleanup == "function" && (pageCleanup = cleanup);
});
return typeof result == "function" && (pluginCleanup = result), () => {
if (active) {
active = !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,
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, floatingHostTarget = null, hostSource = null, readyCleanup;
try {
targetOptions && (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
}), targetAdapter = new import_reader_userscript_target_adapter.ReaderUserscriptTargetAdapter({
document: options.runtime.document,
currentUrl: () => options.runtime.document.location.href,
target: {
openTarget: (request) => runtime.openTarget(request)
},
routeChanges: createReaderUserscriptRouteChangePort(
options.environment.discourseHost
),
serviceWorkerMessages: options.runtime.document.defaultView?.navigator.serviceWorker ?? null,
interceptServiceWorkerTopicTargets: () => runtime.workspace.workspace.snapshot.presentation.embedded,
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 }
})), readyCleanup = onReady?.(
runtime,
context,
settings,
settingsView,
layout,
appearance,
font
) || void 0;
} catch (error) {
throw targetAdapter?.destroy(), floatingHostTarget?.destroy(), hostSource?.destroy(), error;
}
return () => {
try {
readyCleanup?.();
} finally {
targetAdapter?.destroy(), floatingHostTarget?.destroy(), hostSource?.destroy();
}
};
}
});
}
}, "44997d4b9d0ed531b19ce5d8fff759494ecaf9577edd788c4b1d447252f6f2ff");
/* 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,
parseReaderUserscriptTopicRoute: () => parseReaderUserscriptTopicRoute,
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_native_topic_route = require("../topic/reader-native-topic-route.js");
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';
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 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 readerUserscriptRouteKind(value, baseValue) {
return parseReaderUserscriptTopicRoute(value, baseValue) ? "direct-topic" : "list";
}
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 targetPostNumber = this.#ordinaryPostNumber(route), 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 },
source: "restore"
});
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);
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;
event.preventDefault(), event.stopPropagation(), event.stopImmediatePropagation();
const postNumber = linkTarget.source === "link" && !linkTarget.preservePostNumber ? this.#ordinaryPostNumber(linkTarget.route) : linkTarget.route.postNumber, request = {
topicId: linkTarget.route.topicId,
...postNumber === null ? {} : { postNumber },
source: linkTarget.source
};
this.#openIntercepted({
request,
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;
try {
if (this.#options.interceptServiceWorkerTopicTargets?.() !== !0)
return;
} catch (error) {
this.#report(error);
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) {
try {
return this.#options.readOpenTopicsAtFirstPost?.() === !0 ? (0, import_identifiers.tryDiscoursePostNumber)(1) : route.postNumber;
} catch (error) {
return this.#report(error), route.postNumber;
}
}
#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);
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) {
try {
const result = 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;
}
}
}
}, "b223121844ede7bf645cc153e0e5e3cd21742c8a3bfbb6b56b6e292aa2715c48");
runtime.markLibrary("main-lite-core");
})();