PageSourceSearch

https://vip.xiaoxiaodong.ai/lightbox-page.js

js xiaoxiaodong.ai collected 2026-10-03 23:21:06 UTC 60,771 bytes, 1,377 lines download raw bytes

1// Generated from src/app/lightbox.js. Do not edit directly.
2
3// src/lib/site-icon-style.js
4var SITE_ICON_ATTRIBUTES = Object.freeze({
5  "stroke-width": 1.5,
6  "stroke-linecap": "round",
7  "stroke-linejoin": "miter"
8});
9var SITE_CHEVRON_LEFT_SVG = '<svg class="ui-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="miter" aria-hidden="true" focusable="false"><path d="m15 6-6 6 6 6"/></svg>';
10var SITE_CHEVRON_RIGHT_SVG = '<svg class="ui-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="miter" aria-hidden="true" focusable="false"><path d="m9 6 6 6-6 6"/></svg>';
11
12// src/lib/tag-vocabulary.js
13var TAG_DIMENSIONS = Object.freeze([
14  { id: "medium", label: "媒介手法", tags: ["摄影", "插画", "线描", "手绘", "书法", "水墨", "水彩", "版画", "剪纸", "拼贴", "涂鸦", "贴纸", "点阵", "半调网点", "几何", "立体", "浮雕", "剪影", "图形化"] },
15  { id: "layout", label: "构图版式", tags: ["留白", "大字", "框景", "居中", "极简", "网格", "浅景深", "错落", "特写", "分格", "微距", "阵列", "放射构图", "俯视", "仰视", "层叠"] },
16  { id: "type", label: "字体", tags: ["手写", "衬线", "无衬线", "明体"] },
17  { id: "light", label: "光线质感", tags: ["高明度", "暗场", "颗粒", "肌理", "渐变", "纸感", "发光", "柔光", "玻璃", "光束", "金属", "光斑", "织物", "侧光", "逆光", "雾感"] },
18  { id: "palette", label: "色调", tags: ["清透", "高饱和", "冷调", "暖调", "黑白", "浅灰", "中国红"] },
19  { id: "mood", label: "情绪气质", tags: ["东方", "静谧", "戏剧化", "雅致", "轻快", "仪式感", "童真", "庄重", "俏皮", "冷静", "治愈", "克制", "复古", "压迫感", "神秘", "动感", "冷峻", "粗粝"] },
20  { id: "subject", label: "题材内容", tags: ["人像", "荷花", "植物", "花卉", "雪景", "丰收", "山水", "云朵", "城市", "户外", "静物", "咖啡", "礼盒", "地图", "中医", "民俗", "文博"] },
21  { id: "use", label: "场景用途", tags: ["编辑感", "商品", "节令", "科技感", "档案感", "电影感", "促销", "节庆", "舞台感", "早安", "陈列", "科普图解", "纪实", "街头", "生活感", "通勤", "商务", "招聘", "卡片", "倒计时"] }
22]);
23var TAG_SYNONYMS = Object.freeze({
24  "巨字": "大字",
25  "巨字穿插": "大字",
26  "童趣": "童真",
27  "清新": "清透",
28  "清爽": "清透",
29  "洁净": "清透",
30  "清亮": "清透",
31  "明亮": "高明度",
32  "清雅": "雅致",
33  "明快": "轻快",
34  "清甜": "轻快",
35  "松弛": "轻快",
36  "轻盈": "轻快",
37  "元气": "轻快",
38  "甜美": "俏皮",
39  "温情": "治愈",
40  "浪漫": "治愈",
41  "温润": "治愈",
42  "悬疑": "神秘",
43  "梦幻": "神秘",
44  "动势": "动感",
45  "速度感": "动感",
46  "理性": "冷静",
47  "怀旧": "复古",
48  "科普海报": "科普图解",
49  "科普信息图": "科普图解",
50  "信息图": "科普图解",
51  "图表": "科普图解",
52  "图鉴": "科普图解",
53  "纪实摄影": "纪实",
54  "展陈感": "陈列",
55  "手作": "手绘",
56  "九宫格": "分格",
57  "拓印": "版画",
58  "飞白": "书法",
59  "宋体": "明体",
60  "描边": "线描",
61  "色块": "图形化",
62  "几何图形": "几何",
63  "圆角": "几何",
64  "斜切": "错落",
65  "放射": "放射构图",
66  "环形": "放射构图",
67  "长影": "侧光",
68  "露珠水珠": "肌理",
69  "水面质感": "肌理",
70  "印章": "东方",
71  "花窗": "东方",
72  "卷轴": "东方",
73  "荷元素": "荷花",
74  "霜雪": "雪景",
75  "稻浪": "丰收",
76  "麦穗": "丰收",
77  "自然": "植物",
78  "灰阶": "黑白",
79  "撞色": "高饱和",
80  "荧光": "高饱和",
81  "银灰": "浅灰",
82  "深灰": "浅灰",
83  "雾蓝": "冷调",
84  "粉蓝": "冷调",
85  "天空蓝": "冷调",
86  "薄荷绿": "冷调",
87  "青绿": "冷调",
88  "草绿": "冷调",
89  "靛蓝": "冷调",
90  "浅粉": "暖调",
91  "暖橙": "暖调",
92  "柠檬黄": "暖调",
93  "咖啡棕": "暖调",
94  "杏色": "暖调",
95  "青金": "暖调",
96  "浅金": "暖调",
97  "日光黄": "暖调",
98  "麦黄": "暖调",
99  "黄绿": "暖调",
100  "灰黄": "暖调",
101  "米白": "高明度",
102  "奶白": "高明度",
103  "纸白": "高明度",
104  "春意": "节令",
105  "夏意": "节令",
106  "冬意": "节令",
107  "雨意": "节令",
108  "新绿": "节令",
109  "霜红": "节令",
110  "晨雾": "节令",
111  "蝉鸣": "节令",
112  "桂影": "节令",
113  "watercolor": "水彩",
114  "水彩风": "水彩"
115});
116function normalizeTagKey(tag) {
117  return String(tag ?? "").trim().toLowerCase().replace(/\s+/g, "");
118}
119function buildTagIndex(dimensions = TAG_DIMENSIONS) {
120  const dimensionOrder = [];
121  const tagToDimension = /* @__PURE__ */ new Map();
122  const tagOrder = /* @__PURE__ */ new Map();
123  const labelByTag = /* @__PURE__ */ new Map();
124  for (const dim of Array.isArray(dimensions) ? dimensions : []) {
125    const id = String(dim?.id ?? "");
126    if (!id) continue;
127    const tags = (Array.isArray(dim?.tags) ? dim.tags : []).map(String).filter(Boolean);
128    dimensionOrder.push({ id, label: String(dim?.label ?? id) });
129    tagOrder.set(id, tags.map(normalizeTagKey));
130    for (const tag of tags) {
131      const key = normalizeTagKey(tag);
132      if (!tagToDimension.has(key)) {
133        tagToDimension.set(key, id);
134        labelByTag.set(key, tag);
135      }
136    }
137  }
138  return { dimensionOrder, tagToDimension, tagOrder, labelByTag };
139}
140
141// src/app/detail-tags.js
142function buildDetailTagEntries(tags, index = buildTagIndex()) {
143  const byKey = /* @__PURE__ */ new Map();
144  for (const raw of Array.isArray(tags) ? tags : []) {
145    const key = normalizeTagKey(raw);
146    if (!key || byKey.has(key)) continue;
147    byKey.set(key, String(raw));
148  }
149  const entries = [];
150  for (const { id, label } of index.dimensionOrder) {
151    for (const key of index.tagOrder.get(id) || []) {
152      if (!byKey.has(key)) continue;
153      entries.push({ tag: index.labelByTag.get(key) ?? byKey.get(key), dimensionId: id, dimensionLabel: label });
154      byKey.delete(key);
155    }
156  }
157  for (const tag of byKey.values()) entries.push({ tag, dimensionId: "", dimensionLabel: "" });
158  return entries;
159}
160
161// src/app/app-cdn-routing.js
162var IMAGE_CDN_BASE = "https://img.xiaoxiaodong.ai";
163var OBJECT_PATH_PREFIX = "/api/object/";
164var OBJECT_THUMB_SEGMENT = "data/thumbs/";
165var OBJECT_FULL_SEGMENT = "data/full/";
166var MEMBER_ORIGIN = "https://vip.xiaoxiaodong.ai";
167var PRIVATE_MEMBER_OBJECT_PREFIXES = [
168  `${OBJECT_PATH_PREFIX}${OBJECT_THUMB_SEGMENT}generated-history/`,
169  `${OBJECT_PATH_PREFIX}${OBJECT_FULL_SEGMENT}generated-history/`,
170  `${OBJECT_PATH_PREFIX}${OBJECT_THUMB_SEGMENT}media-library/`,
171  `${OBJECT_PATH_PREFIX}${OBJECT_FULL_SEGMENT}media-library/`
172];
173function encodedObjectPath(value = "") {
174  let decoded = "";
175  try {
176    decoded = decodeURIComponent(String(value || ""));
177  } catch {
178    return "";
179  }
180  const key = decoded.replace(/^\/+/, "");
181  if (!key.startsWith(OBJECT_THUMB_SEGMENT) && !key.startsWith(OBJECT_FULL_SEGMENT)) return "";
182  return `${OBJECT_PATH_PREFIX}${key.split("/").map(encodeURIComponent).join("/")}`;
183}
184function sameOriginObjectUrl(value, baseHref = globalThis.location?.href || MEMBER_ORIGIN) {
185  const raw = String(value || "").trim();
186  if (!raw || raw.startsWith("data:") || raw.startsWith("blob:")) return raw;
187  if (raw.startsWith(OBJECT_PATH_PREFIX)) return raw;
188  const directObjectPath = encodedObjectPath(raw);
189  if (directObjectPath) return directObjectPath;
190  try {
191    const base = new URL(baseHref || MEMBER_ORIGIN, MEMBER_ORIGIN);
192    const url = new URL(raw, base);
193    const isAbsolute = /^[a-z][a-z\d+.-]*:/i.test(raw) || raw.startsWith("//");
194    const trustedOrigins = /* @__PURE__ */ new Set([base.origin, MEMBER_ORIGIN, new URL(IMAGE_CDN_BASE).origin]);
195    if (isAbsolute && !trustedOrigins.has(url.origin)) return raw;
196    if (url.pathname.startsWith(OBJECT_PATH_PREFIX)) return `${url.pathname}${url.search}`;
197    const objectPath = encodedObjectPath(url.pathname);
198    return objectPath ? `${objectPath}${url.search}` : raw;
199  } catch {
200    return raw;
201  }
202}
203function cdnMediaUrl(value) {
204  const path = sameOriginObjectUrl(value);
205  if (!path.startsWith(OBJECT_PATH_PREFIX)) return value;
206  let decoded;
207  try {
208    decoded = decodeURIComponent(path.split("?")[0]);
209  } catch {
210    return value;
211  }
212  if (PRIVATE_MEMBER_OBJECT_PREFIXES.some((prefix) => decoded.startsWith(prefix))) return path;
213  const url = new URL(path, MEMBER_ORIGIN);
214  url.searchParams.set(decoded.startsWith(`${OBJECT_PATH_PREFIX}${OBJECT_THUMB_SEGMENT}`) ? "public_preview" : "member_image", "1");
215  return `${url.pathname}${url.search}`;
216}
217
218// src/lib/cover-version.js
219var COVER_KINDS = Object.freeze(["article", "skill"]);
220var COVER_LOCALES = Object.freeze(["zh-CN", "en"]);
221
222// src/app/responsive-image.js
223var DISPLAY_IMAGE_WIDTHS = Object.freeze([360, 540, 720, 1080, 1440]);
224var SLOW_NETWORK_MAX_WIDTH = 720;
225var LEGACY_KEY_RE = /^data\/(?:thumbs|full)\/(?:generated|generated-history|media-library|ingest|_display|en\/bundle)\//;
226function objectKeyAndSignature(path) {
227  const raw = String(path || "").trim();
228  if (!raw || raw.startsWith("data:") || raw.startsWith("blob:")) return null;
229  let url;
230  try {
231    url = new URL(raw, "https://vip.xiaoxiaodong.ai");
232  } catch {
233    return null;
234  }
235  if (!url.pathname.startsWith("/api/object/")) return null;
236  let key;
237  try {
238    key = decodeURIComponent(url.pathname.slice("/api/object/".length));
239  } catch {
240    return null;
241  }
242  if (!/^data\/(?:thumbs|full)\/./.test(key) || LEGACY_KEY_RE.test(key)) return null;
243  return { key: key.replace(/^data\/full\//, "data/thumbs/"), pub: url.searchParams.get("pub") || "" };
244}
245function naturalWidthFromRatio(ratio) {
246  const match = String(ratio || "").match(/^\s*(\d+(?:\.\d+)?)\s*[/:]\s*(\d+(?:\.\d+)?)\s*$/);
247  const width = Number(match?.[1]);
248  return width >= 200 ? Math.round(width) : 0;
249}
250function slowConnection(navigator = globalThis.navigator) {
251  const connection = navigator?.connection || navigator?.mozConnection || navigator?.webkitConnection;
252  if (connection?.saveData) return true;
253  return ["slow-2g", "2g", "3g"].includes(String(connection?.effectiveType || "").toLowerCase());
254}
255function displayImageSources(path, { ratio = "", memberGrant = false, slow = slowConnection() } = {}) {
256  const source = objectKeyAndSignature(path);
257  if (!source || !source.pub && !memberGrant) return null;
258  const encoded = source.key.split("/").map(encodeURIComponent).join("/");
259  const query = source.pub ? `?pub=${encodeURIComponent(source.pub)}` : "";
260  const natural = naturalWidthFromRatio(ratio);
261  const candidates = [];
262  for (const width of DISPLAY_IMAGE_WIDTHS) {
263    if (slow && width > SLOW_NETWORK_MAX_WIDTH) break;
264    const descriptor = natural && width >= natural ? natural : width;
265    candidates.push({ url: `/api/image/w${width}/${encoded}${query}`, descriptor });
266    if (natural && width >= natural) break;
267  }
268  if (!candidates.length) return null;
269  const fallback = candidates.filter((candidate) => candidate.descriptor <= 720).pop() || candidates[0];
270  return {
271    src: fallback.url,
272    srcset: candidates.map((candidate) => `${candidate.url} ${candidate.descriptor}w`).join(", ")
273  };
274}
275
276// src/app/lightbox.js
277var LIGHTBOX_WHEEL_ZOOM_INTENSITY = 22e-4;
278var LIGHTBOX_MIN_SCALE = 0.25;
279var LIGHTBOX_MAX_SCALE = 6;
280var LIGHTBOX_THUMB_WINDOW = 25;
281var LIGHTBOX_THUMB_THRESHOLD = 40;
282var LIGHTBOX_THUMB_HEIGHT = 44;
283var LIGHTBOX_THUMB_GAP = 8;
284function createLightboxModule({ document, state, els, routePathForTopic, onTopicSelect } = {}) {
285  const lb = { index: 0, scale: 1, x: 0, y: 0, dragging: false };
286  let transientGallery = null;
287  let transientPreviousGallery = null;
288  let returnFocusTarget = null;
289  let dragged = false;
290  let renderedThumbItems = null;
291  let thumbWidths = [];
292  let thumbOffsets = [0];
293  let activeThumb = null;
294  let thumbWindowStart = 0;
295  let thumbWindowEnd = 0;
296  let thumbScrollFrame = null;
297  let renderedTopicsKey = null;
298  let renderedTagsKey = null;
299  let topicCount = 0;
300  let trayMode = "closed";
301  let metaOpen = false;
302  let metaPeek = false;
303  let compactTray = false;
304  let detailHome = null;
305  let detailNext = null;
306  let detailScrollTop = 0;
307  let detailCloseWasHidden = false;
308  let detailExpanded = false;
309  let controlTimer = null;
310  let fittedSize = null;
311  let imageGeneration = 0;
312  let imageReady = false;
313  let previewReady = false;
314  let imageFailed = false;
315  let displayState = null;
316  let placeholderRatio = 1;
317  let transformFrame = null;
318  let stageObserver = null;
319  let frameGeometry = null;
320  let appliedX = 0;
321  let appliedY = 0;
322  let appliedScale = 1;
323  const pointers = /* @__PURE__ */ new Map();
324  let gesture = null;
325  let hadPinch = false;
326  let lastImagePointerType = "mouse";
327  const win = document.defaultView;
328  const coarsePointer = win?.matchMedia?.("(pointer: coarse)");
329  const english = () => document.documentElement.lang.startsWith("en");
330  const ui = (hans, hant, en) => english() ? en : document.documentElement.lang === "zh-Hant" ? hant : hans;
331  function isCompactTray() {
332    return win.innerWidth <= 800 || win.innerHeight <= 620 || win.innerWidth > win.innerHeight && win.innerHeight < 700;
333  }
334  function defaultTrayMode() {
335    return (state.galleryItems || []).length > 1 && !compactTray ? "thumbs" : "closed";
336  }
337  function setDetailExpanded(expanded) {
338    if (!detailHome) return;
339    detailExpanded = Boolean(expanded);
340    els.lightbox.classList.toggle("is-detail-expanded", detailExpanded);
341    els.lightboxDetailSlot.hidden = !detailExpanded;
342    els.lightboxDetailSlot.inert = !detailExpanded;
343    if (els.lightboxPanelToggle) {
344      els.lightboxPanelToggle.hidden = false;
345      const label = detailExpanded ? ui("收起右侧详情", "收起右側詳情", "Collapse details panel") : ui("展开右侧详情", "展開右側詳情", "Expand details panel");
346      els.lightboxPanelToggle.innerHTML = detailExpanded ? SITE_CHEVRON_RIGHT_SVG : SITE_CHEVRON_LEFT_SVG;
347      els.lightboxPanelToggle.setAttribute("aria-label", label);
348      els.lightboxPanelToggle.setAttribute("title", label);
349      els.lightboxPanelToggle.setAttribute("aria-expanded", String(detailExpanded));
350    }
351    metaOpen = false;
352    metaPeek = false;
353    syncTray();
354    frameGeometry = null;
355    resizePlaceholder();
356    lbApply();
357  }
358  function borrowDetailPanel() {
359    if (detailHome) {
360      setDetailExpanded(!compactTray);
361      return true;
362    }
363    const panel = els.detailPanel;
364    if (!panel || panel.hidden || !document.body.classList.contains("is-detail-open") || panel.classList.contains("style-share-embedded-panel")) return false;
365    detailHome = panel.parentNode;
366    detailNext = panel.nextSibling;
367    detailScrollTop = els.detailBody?.scrollTop || 0;
368    const close = document.querySelector("#closeDetailButton");
369    detailCloseWasHidden = Boolean(close?.hidden);
370    if (close) close.hidden = true;
371    els.lightboxDetailSlot.append(panel);
372    els.lightboxDetailSlot.inert = false;
373    setDetailExpanded(!compactTray);
374    return true;
375  }
376  function returnDetailPanel() {
377    if (!detailHome) return;
378    detailHome.insertBefore(els.detailPanel, detailNext);
379    const close = document.querySelector("#closeDetailButton");
380    if (close) close.hidden = detailCloseWasHidden;
381    if (els.detailBody) els.detailBody.scrollTop = detailScrollTop;
382    els.lightboxDetailSlot.hidden = true;
383    els.lightboxDetailSlot.inert = true;
384    els.lightbox.classList.remove("is-detail-expanded");
385    detailHome = null;
386    detailNext = null;
387    detailExpanded = false;
388  }
389  function syncTray() {
390    const multi = (state.galleryItems || []).length > 1;
391    if (!multi && trayMode === "thumbs") trayMode = "closed";
392    const hasTags = Boolean(els.lightboxTags?.children.length);
393    const hasMeta = hasTags || topicCount > 0;
394    const canvasAvailable = !compactTray || !detailExpanded;
395    els.lightbox.classList.toggle("is-compact-tray", compactTray);
396    if (els.lightboxThumbs) els.lightboxThumbs.setAttribute("aria-orientation", compactTray ? "horizontal" : "vertical");
397    if (els.lightboxTray) els.lightboxTray.hidden = !canvasAvailable || !(trayMode === "thumbs" || (metaOpen || metaPeek) && hasMeta);
398    if (els.lightboxThumbs) els.lightboxThumbs.hidden = !multi || trayMode !== "thumbs";
399    if (els.lightboxMeta) els.lightboxMeta.hidden = !hasMeta || !metaOpen && !metaPeek;
400    if (els.lightboxTagsHeading) els.lightboxTagsHeading.hidden = !hasTags;
401    if (els.lightboxTopicsHeading) els.lightboxTopicsHeading.hidden = !topicCount;
402    if (els.lightboxTopics) els.lightboxTopics.hidden = !topicCount;
403    if (els.lightboxThumbToggle) {
404      els.lightboxThumbToggle.hidden = !canvasAvailable || !multi;
405      els.lightboxThumbToggle.setAttribute("aria-expanded", String(trayMode === "thumbs"));
406      els.lightboxThumbToggle.setAttribute("aria-label", trayMode === "thumbs" ? ui("收起缩略图", "收起縮略圖", "Hide thumbnails") : ui("显示缩略图", "顯示縮略圖", "Show thumbnails"));
407    }
408    if (els.lightboxTopicsToggle) {
409      els.lightboxTopicsToggle.hidden = !canvasAvailable || !hasMeta;
410      els.lightboxTopicsToggle.textContent = win.innerWidth <= 600 ? ui("信息", "資訊", "Info") : ui("图片信息", "圖片資訊", "Image info");
411      els.lightboxTopicsToggle.setAttribute("aria-expanded", String(metaOpen));
412    }
413    if (els.lightboxPanelCount) {
414      els.lightboxPanelCount.hidden = true;
415      els.lightboxPanelCount.textContent = multi ? `${lb.index + 1} / ${(state.galleryItems || []).length}` : "";
416    }
417    if (els.lightboxLocate) els.lightboxLocate.hidden = true;
418    if (els.lightboxFit) els.lightboxFit.textContent = win.innerWidth <= 600 ? ui("适合", "適合", "Fit") : ui("适合屏幕", "適合螢幕", "Fit to screen");
419  }
420  function renderLightboxTags(item) {
421    const row = els.lightboxTags;
422    if (!row) return;
423    const tags = buildDetailTagEntries(item?.tags).map((entry) => entry.tag);
424    const key = JSON.stringify(tags);
425    row.hidden = tags.length === 0;
426    if (key === renderedTagsKey) {
427      syncTray();
428      return;
429    }
430    renderedTagsKey = key;
431    row.setAttribute("aria-label", ui(`当前样张标签:${tags.join("、")}`, `當前樣張標籤:${tags.join("、")}`, `Current image tags: ${tags.join(", ")}`));
432    row.replaceChildren(...tags.map((tag) => {
433      const chip = document.createElement("span");
434      chip.className = "lightbox-tag";
435      chip.setAttribute("role", "listitem");
436      chip.textContent = tag;
437      return chip;
438    }));
439    syncTray();
440  }
441  function renderLightboxTopics(item) {
442    const row = els.lightboxTopics;
443    if (!row) return;
444    const topics = [];
445    const seen = /* @__PURE__ */ new Set();
446    for (const topic of Array.isArray(item?.sampleTopics) ? item.sampleTopics : []) {
447      const id = String(topic?.id || "").trim();
448      const name = String(topic?.name || "").trim();
449      if (!id || !name || seen.has(id)) continue;
450      seen.add(id);
451      if (typeof routePathForTopic === "function") topics.push({ id, name, href: routePathForTopic(id) });
452    }
453    const key = JSON.stringify([english(), topics]);
454    topicCount = topics.length;
455    if (key === renderedTopicsKey) {
456      syncTray();
457      return;
458    }
459    renderedTopicsKey = key;
460    row.setAttribute("aria-label", ui("图片所属栏目", "圖片所屬欄目", "Image categories"));
461    const focusedId = row.contains(document.activeElement) ? document.activeElement.dataset.topic : null;
462    row.replaceChildren(...topics.map(({ id, name, href }) => {
463      const link = document.createElement("a");
464      link.href = href;
465      link.dataset.topic = id;
466      link.textContent = name;
467      link.setAttribute("aria-label", ui(`查看${name}栏目`, `查看${name}欄目`, `Browse ${name}`));
468      link.addEventListener("click", (event) => {
469        if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
470        if (typeof onTopicSelect !== "function") return;
471        event.preventDefault();
472        closeLightbox({ restoreFocus: false });
473        onTopicSelect(id);
474      });
475      return link;
476    }));
477    if (focusedId) {
478      const replacement = [...row.children].find((link) => link.dataset.topic === focusedId);
479      (replacement || (topics.length ? els.lightboxTopicsToggle : els.lightboxClose))?.focu
479s({ preventScroll: true });
480    }
481    syncTray();
482  }
483  function cancelTransformFrame() {
484    if (transformFrame !== null) win.cancelAnimationFrame(transformFrame);
485    transformFrame = null;
486    frameGeometry = null;
487  }
488  function geometry() {
489    if (!frameGeometry) {
490      const visibleImage = !imageReady && previewReady && !els.lightboxPreview?.hidden ? els.lightboxPreview : els.lightboxImage;
491      const rect = visibleImage.getBoundingClientRect();
492      frameGeometry = {
493        x: rect.left + rect.width / 2 - appliedX,
494        y: rect.top + rect.height / 2 - appliedY,
495        width: rect.width / appliedScale,
496        height: rect.height / appliedScale,
497        stage: els.lightboxStage.getBoundingClientRect()
498      };
499    }
500    return frameGeometry;
501  }
502  function updateZoomControls() {
503    for (const [button, disabled] of [[els.lightboxZoomOut, lb.scale <= LIGHTBOX_MIN_SCALE], [els.lightboxZoomIn, lb.scale >= LIGHTBOX_MAX_SCALE]]) {
504      if (button && button.getAttribute("aria-disabled") !== String(disabled)) button.setAttribute("aria-disabled", String(disabled));
505    }
506    if (els.lightboxHint) {
507      const count = (state.galleryItems || []).length;
508      const touch = coarsePointer?.matches;
509      const hint = touch ? count > 1 ? ui("单指拖动 · 双指缩放 · 点击箭头或缩略图翻页", "單指拖動 · 雙指縮放 · 點擊箭頭或縮略圖翻頁", "Drag to pan · Pinch to zoom · Use arrows or thumbnails to browse") : ui("单指拖动 · 双指缩放", "單指拖動 · 雙指縮放", "Drag to pan · Pinch to zoom") : ui("点击放大 · Alt 点击缩小 · 滚轮缩放 · 拖动平移 · Esc 退出", "點擊放大 · Alt 點擊縮小 · 滾輪縮放 · 拖動平移 · Esc 退出", "Click to zoom in · Alt-click to zoom out · Scroll to zoom · Drag to pan · Esc to close");
510      if (els.lightboxHint.textContent !== hint) els.lightboxHint.textContent = hint;
511    }
512  }
513  function lbApply() {
514    cancelTransformFrame();
515    const transform = `translate(${lb.x}px, ${lb.y}px) scale(${lb.scale})`;
516    for (const image of [els.lightboxImage, els.lightboxPreview].filter(Boolean)) {
517      image.style.transform = transform;
518      image.style.cursor = lb.dragging ? "grabbing" : "grab";
519      image.classList.toggle("is-zoomed", lb.scale > 1);
520    }
521    els.lightboxStage.style.cursor = lb.dragging ? "grabbing" : "grab";
522    if (els.lightboxFit) els.lightboxFit.textContent = lb.scale === 1 ? ui("适合屏幕", "適合屏幕", "Fit to screen") : `${Math.round(lb.scale * 100)}%`;
523    appliedX = lb.x;
524    appliedY = lb.y;
525    appliedScale = lb.scale;
526    updateZoomControls();
527    if (lb.scale > 1.05) sharpenForZoom();
528  }
529  function scheduleLbApply() {
530    if (transformFrame !== null) return;
531    if (!win?.requestAnimationFrame) {
532      lbApply();
533      return;
534    }
535    transformFrame = win.requestAnimationFrame(() => {
536      transformFrame = null;
537      lbApply();
538    });
539  }
540  function lbReset() {
541    clearGesture();
542    lb.dragging = false;
543    dragged = false;
544    lb.scale = 1;
545    lb.x = 0;
546    lb.y = 0;
547    lbApply();
548  }
549  function zoomTo(scale, point) {
550    const next = Math.min(LIGHTBOX_MAX_SCALE, Math.max(LIGHTBOX_MIN_SCALE, scale));
551    if (next === lb.scale) return;
552    const ratio = next / lb.scale;
553    if (point) {
554      const rect = geometry();
555      lb.x -= (point.x - rect.x - lb.x) * (ratio - 1);
556      lb.y -= (point.y - rect.y - lb.y) * (ratio - 1);
557    } else {
558      lb.x *= ratio;
559      lb.y *= ratio;
560    }
561    lb.scale = next;
562    scheduleLbApply();
563  }
564  function zoomStep(direction) {
565    let next = lb.scale * (direction > 0 ? 1.25 : 0.8);
566    if (lb.scale < 1 && next > 1 || lb.scale > 1 && next < 1) next = 1;
567    if (Math.abs(next - 1) < 1e-3) next = 1;
568    zoomTo(next);
569    lbApply();
570  }
571  function clearGesture() {
572    const ids = [...pointers.keys()];
573    pointers.clear();
574    gesture = null;
575    hadPinch = false;
576    lb.dragging = false;
577    for (const id of ids) {
578      if (els.lightboxStage.hasPointerCapture?.(id)) els.lightboxStage.releasePointerCapture(id);
579    }
580  }
581  function capturePointer(id) {
582    try {
583      els.lightboxStage.setPointerCapture?.(id);
584    } catch (error) {
585      if (error.name !== "NotFoundError") throw error;
586    }
587  }
588  function pinchPoints() {
589    const [a, b] = pointers.values();
590    return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, distance: Math.hypot(a.x - b.x, a.y - b.y) };
591  }
592  function beginGesture() {
593    if (pointers.size >= 2) {
594      hadPinch = true;
595      dragged = true;
596      gesture = { kind: "pinch", ...pinchPoints() };
597      for (const id of pointers.keys()) capturePointer(id);
598    } else if (pointers.size === 1) {
599      const [point] = pointers.values();
600      gesture = { kind: "pan", x: point.x, y: point.y, ox: lb.x, oy: lb.y };
601    } else gesture = null;
602    lb.dragging = gesture?.kind === "pan";
603  }
604  function thumbLabel(item, index) {
605    return ui(`查看第 ${index + 1} 张:${item.title || "图片"}`, `查看第 ${index + 1} 張:${item.title || "圖片"}`, `View image ${index + 1}: ${item.title || "Image"}`);
606  }
607  function thumbAt(index) {
608    return els.lightboxThumbs?.querySelector(`[data-index="${index}"]`) || null;
609  }
610  function thumbWidth(ratio) {
611    return Math.round(Math.max(44, Math.min(160, (LIGHTBOX_THUMB_HEIGHT - 4) * ratio + 4)));
612  }
613  function rebuildThumbOffsets() {
614    thumbOffsets = [0];
615    for (const width of thumbWidths) thumbOffsets.push(thumbOffsets.at(-1) + width + LIGHTBOX_THUMB_GAP);
616  }
617  function thumbOffset(index) {
618    return compactTray ? thumbOffsets[index] : index * (LIGHTBOX_THUMB_HEIGHT + LIGHTBOX_THUMB_GAP);
619  }
620  function updateThumbWidth(index, ratio) {
621    const width = thumbWidth(ratio);
622    if (thumbWidths[index] === width) return;
623    thumbWidths[index] = width;
624    rebuildThumbOffsets();
625    if (thumbWindowEnd > thumbWindowStart) {
626      renderThumbWindow(thumbWindowStart, thumbWindowEnd);
627      activeThumb = thumbAt(lb.index);
628      activeThumb?.setAttribute("aria-selected", "true");
629    }
630  }
631  function createThumb(item, index, total) {
632    const button = document.createElement("button");
633    button.type = "button";
634    button.className = "lightbox-thumb";
635    button.dataset.index = String(index);
636    button.setAttribute("role", "option");
637    button.setAttribute("aria-label", thumbLabel(item, index));
638    button.setAttribute("aria-posinset", String(index + 1));
639    button.setAttribute("aria-setsize", String(total));
640    button.setAttribute("aria-selected", "false");
641    button.tabIndex = -1;
642    button.style.width = `${thumbWidths[index]}px`;
643    const image = document.createElement("img");
644    image.addEventListener("load", () => {
645      if (renderedThumbItems !== state.galleryItems || !image.naturalWidth || !image.naturalHeight) return;
646      updateThumbWidth(index, image.naturalWidth / image.naturalHeight);
647    });
648    image.src = railSrc(item);
649    image.alt = "";
650    image.loading = "eager";
651    image.decoding = "async";
652    button.append(image);
653    button.addEventListener("click", () => {
654      if (lb.index === index) return;
655      lb.index = index;
656      lbShow();
657    });
658    return button;
659  }
660  function renderThumbWindow(start, end) {
661    const items = state.galleryItems || [];
662    const row = els.lightboxThumbs;
663    const focusedIndex = row.contains(document.activeElement) ? Number(document.activeElement.dataset.index) : -1;
664    activeThumb?.setAttribute("aria-selected", "false");
665    const existing = new Map([...row.querySelectorAll(".lightbox-thumb")].map((button) => [Number(button.dataset.index), button]));
666    const buttons = [];
667    for (let index = start; index < end; index++) {
668      const button = existing.get(index) || createThumb(items[index], index, items.length);
669      button.style.width = `${thumbWidths[index]}px`;
670      buttons.push(button);
671    }
672    const spacer = (from, to) => {
673      const node = document.createElement("span");
674      node.className = "lightbox-thumb-spacer";
675      node.setAttribute("aria-hidden", "true");
676      node.style.flex = `0 0 ${thumbOffset(to) - thumbOffset(from) - LIGHTBOX_THUMB_GAP}px`;
677      return node;
678    };
679    row.replaceChildren(
680      ...start ? [spacer(0, start)] : [],
681      ...buttons,
682      ...items.length > end ? [spacer(end, items.length)] : []
683    );
684    thumbWindowStart = start;
685    thumbWindowEnd = end;
686    activeThumb = null;
687    if (focusedIndex >= 0) (thumbAt(focusedIndex) || row)?.focus({ preventScroll: true });
688  }
689  function renderLightboxThumbs({ reveal = true } = {}) {
690    const items = state.galleryItems || [];
691    const row = els.lightboxThumbs;
692    if (!row) return;
693    if (items.length < 2) {
694      row.replaceChildren();
695      renderedThumbItems = null;
696      thumbWidths = [];
697      thumbOffsets = [0];
698      activeThumb = null;
699      thumbWindowStart = thumbWindowEnd = 0;
700      return;
701    }
702    const changed = renderedThumbItems !== items;
703    if (changed) {
704      renderedThumbItems = items;
705      thumbWidths = items.map((item) => thumbWidth(itemRatio(item)));
706      rebuildThumbOffsets();
707      row.scrollLeft = 0;
708      row.scrollTop = 0;
709      thumbWindowStart = thumbWindowEnd = 0;
710    }
711    const virtual = items.length > LIGHTBOX_THUMB_THRESHOLD;
712    const needsWindow = changed || !row.children.length || !virtual && row.children.length !== items.length || virtual && (lb.index < thumbWindowStart + 4 || lb.index >= thumbWindowEnd - 4);
713    if (needsWindow) {
714      const start = virtual ? Math.max(0, Math.min(items.length - LIGHTBOX_THUMB_WINDOW, lb.index - Math.floor(LIGHTBOX_THUMB_WINDOW / 2))) : 0;
715      renderThumbWindow(start, virtual ? start + LIGHTBOX_THUMB_WINDOW : items.length);
716    }
717    const active = thumbAt(lb.index);
718    if (activeThumb !== active) {
719      activeThumb?.setAttribute("aria-selected", "false");
720      active?.setAttribute("aria-selected", "true");
721      activeThumb = active;
722    }
723    if (reveal && !row.hidden && active) {
724      const start = compactTray ? active.offsetLeft - row.offsetLeft : active.offsetTop - row.offsetTop;
725      const position = compactTray ? row.scrollLeft : row.scrollTop;
726      const size = compactTray ? active.offsetWidth : active.offsetHeight;
727      const viewport = compactTray ? row.clientWidth : row.clientHeight;
728      if (start < position) row[compactTray ? "scrollLeft" : "scrollTop"] = start;
729      else if (start + size > position + viewport) row[compactTray ? "scrollLeft" : "scrollTop"] = start + size - viewport;
730    }
731  }
732  function itemRatio(item) {
733    const ratio = Number(item?.ratio);
734    if (ratio > 0 && Number.isFinite(ratio)) return ratio;
735    if (typeof item?.ratio === "string" && /[/:]/.test(item.ratio)) {
736      const [left, right] = item.ratio.split(/[/:]/).map(Number);
737      if (left > 0 && right > 0) return left / right;
738    }
739    const width = Number(item?.width || item?.imageWidth);
740    const height = Number(item?.height || item?.imageHeight);
741    return width > 0 && height > 0 ? width / height : 1;
742  }
743  function updateDockSpace() {
744    if (els.lightbox.hidden) return;
745    if (els.lightboxDock) els.lightbox.style.setProperty("--viewer-dock-height", `${Math.ceil(els.lightboxDock.getBoundingClientRect().height)}px`);
746    frameGeometry = null;
747    resizePlaceholder();
748  }
749  function resizePlaceholder() {
750    if (!els.lightboxPlaceholder || !els.lightboxStage) return;
751    const stage = els.lightboxStage;
752    const style = win.getComputedStyle?.(stage);
753    const width = Math.max(0, stage.clientWidth - (parseFloat(style?.paddingLeft) || 0) - (parseFloat(style?.paddingRight) || 0));
754    const height = Math.max(0, stage.clientHeight - (parseFloat(style?.paddingTop) || 0) - (parseFloat(style?.paddingBottom) || 0));
755    const fittedWidth = Math.min(width, height * placeholderRatio);
756    const fittedHeight = fittedWidth / placeholderRatio;
757    if (fittedSize && fittedSize.width && fittedSize.height) {
758      lb.x *= fittedWidth / fittedSize.width;
759      lb.y *= fittedHeight / fittedSize.height;
760    }
761    fittedSize = { width: fittedWidth, height: fittedHeight };
762    els.lightboxPlaceholder.style.width = `${Math.round(fittedWidth)}px`;
763    els.lightboxPlaceholder.style.height = `${Math.round(fittedHeight)}px`;
764    for (const image of [els.lightboxImage, els.lightboxPreview].filter(Boolean)) {
765      image.style.width = `${Math.round(fittedWidth)}px`;
766      image.style.height = `${Math.round(fittedHeight)}px`;
767    }
768    const full = els.lightboxImage;
769    if (full?.getAttribute("srcset")) {
770      const sizes = fittedSizes(lb.scale);
771      if (Number.parseInt(sizes, 10) > Number.parseInt(full.getAttribute("sizes") || "0", 10)) full.setAttribute("sizes", sizes);
772    }
773    frameGeometry = null;
774  }
775  function setImageStatus(message = "") {
776    if (!els.lightboxImageStatus) return;
777    els.lightboxImageStatus.hidden = !message;
778    if (els.lightboxImageStatusText) els.lightboxImageStatusText.textContent = message;
779  }
780  function srcsetUrls(image) {
781    return String(image.getAttribute("srcset") || "").split(",").map((entry) => entry.trim().split(/\s+/)[0]).filter(Boolean).map((url) => {
782      try {
783        return new URL(url, document.baseURI).href;
784      } catch {
785        return "";
786      }
787    });
788  }
789  function displaySourcesFor(item) {
790    return displayImageSources(item?.thumb || item?.src, { ratio: item?.ratio, memberGrant: Boolean(state?.me?.media_display) });
791  }
792  function railSrc(item) {
793    const display = displaySourcesFor(item);
794    return display ? display.srcset.split(",")[0].trim().split(/\s+/)[0] : item?.thumb || item?.src;
795  }
796  function naturalWidthOf(item) {
797    const match = String(item?.ratio || "").match(/^\s*(\d+(?:\.\d+)?)\s*[/:]/);
798    const width = Number(match?.[1]);
799    return width >= 200 ? width : 0;
800  }
801  function fittedSizes(scale = 1) {
802    const width = Math.max(20, Math.ceil((fittedSize?.width || 720) * Math.max(1, scale) / 20) * 20);
803    return `${Math.min(width, 4e3)}px`;
804  }
805  function sharpenForZoom() {
806    const current = displayState;
807    const full = els.lightboxImage;
808    if (!current || current.generation !== imageGeneration || !imageReady || !full) return;
809    if (full.getAttribute("srcset")) {
810      const sizes = fittedSizes(lb.scale);
811      if (Number.parseInt(sizes, 10) > Number.parseInt(full.getAttribute("sizes") || "0", 10)) full.setAttribute("sizes", sizes);
812    }
813    const maxTier = DISPLAY_IMAGE_WIDTHS[DISPLAY_IMAGE_WIDTHS.length - 1];
814    const neededPixels = (fittedSize?.width || 0) * lb.scale * (win?.devicePixelRatio || 1);
815    if (current.upgrading || !current.original || !/\/api\/object\/data\/full\//.test(current.original)) return;
816    if (current.naturalWidth && current.naturalWidth <= maxTier || neededPixels <= maxTier) return;
817    current.upgrading = true;
818    const loader = new win.Image();
819    loader.decoding = "async";
820    loader.onload = () => {
821      const decoded = typeof loader.decode === "function" ? loader.decode().catch(() => {
822      }) : Promise.resolve();
823      decoded.then(() => {
824        if (displayState !== current || current.generation !== imageGeneration) return;
825        full.removeAttribute("srcset");
826        full.removeAttribute("sizes");
827        full.src = current.original;
828      });
829    };
830    loader.onerror = () => {
831    };
832    loader.src = current.original;
833  }
834  function safePreviewSrc(item) {
835    const thumb = String(item?.thumb || "");
836    if (!thumb) return "";
837    try {
838      const url = new URL(thumb, document.baseURI);
839      return url.origin === win.location.origin || url.protocol === "blob:" ? thumb : "";
840    } catch {
841      return "";
842    }
843  }
844  function matchesImageSource(image, source) {
845    const assigned = image.getAttribute("src");
846    if (!assigned) return false;
847    try {
848      const assignedUrl = new URL(assigned, document.baseURI).href;
849      const sourceUrl = new URL(source, document.baseURI).href;
850      const routedUrl = new URL(cdnMediaUrl(source), document.baseURI).href;
851      if (assignedUrl !== sourceUrl && assignedUrl !== routedUrl) return false;
852      if (!image.currentSrc) return true;
853      const current = new URL(image.currentSrc, document.baseURI).href;
854      return current === assignedUrl || srcsetUrls(image).includes(current);
855    } catch {
856      return false;
857    }
858  }
859  function setLightboxImage(item, { preservePreview = false } = {}) {
860    const generation = ++imageGeneration;
861    frameGeometry = null;
862    const full = els.lightboxImage;
863    const preview = els.lightboxPreview;
864    const placeholder = els.lightboxPlaceholder;
865    const original = String(item?.src || "");
866    const display = displaySourcesFor(item);
867    const source = display ? display.src : original;
868    const thumb = String(item?.loadedSrc || "") || (display ? "" : safePreviewSrc(item));
869    const retainedPreview = preservePreview && previewReady && preview && !preview.hidden && matchesImageSource(preview, thumb);
870    imageReady = false;
871    previewReady = Boolean(retainedPreview);
872    imageFailed = false;
873    placeholderRatio = itemRatio(item);
874    fittedSize = null;
875    resizePlaceholder();
876    if (placeholder) placeholder.hidden = Boolean(retainedPreview);
877    setImageStatus();
878    full.classList.remove("is-ready");
879    full.onload = null;
880    full.onerror = null;
881    full.removeAttribute("src");
882    full.removeAttribute("srcset");
883    full.removeAttribute("sizes");
884    displayState = display ? { generation, original, naturalWidth: naturalWidthOf(item), upgrading: false } : null;
885    if (preview) {
886      preview.onload = null;
887      preview.onerror = null;
888      if (!retainedPreview) preview.removeAttribute("src");
889      preview.hidden = !thumb;
890      if (thumb && !retainedPreview) {
891        preview.onload = () => {
892          if (generation !== imageGeneration || !matchesImageSource(preview, thumb)) return;
893          previewReady = true;
894          if (preview.naturalWidth && preview.naturalHeight) placeholderRatio = preview.naturalWidth / preview.naturalHeight;
895          resizePlaceholder();
896          frameGeometry = null;
897          scheduleLbApply();
898          if (placeholder) placeholder.hidden = true;
899          if (imageFailed) setImageStatus(ui("高清图加载失败,已显示预览图", "高清圖載入失敗,已顯示預覽圖", "Full image unavailable. Preview shown."));
900        };
901        preview.onerror = () => {
902          if (generation !== imageGeneration || !matchesImageSource(preview, thumb)) return;
903          preview.hidden = true;
904          if (imageFailed) setImageStatus(ui("图片加载失败", "圖片載入失敗", "Image unavailable"));
905        };
906        preview.src = thumb;
907      }
908    }
909    if (!source) {
910      imageFailed = true;
911      setImageStatus(ui("图片加载失败", "圖片載入失敗", "Image unavailable"));
912      return;
913    }
914    let expected = source;
915    const finish = () => {
916      if (generation !== imageGeneration || els.lightbox.hidden || imageReady || !matchesImageSource(full, expected)) return;
917      imageReady = true;
918      if (full.naturalWidth && full.naturalHeight) {
919        placeholderRatio = full.naturalWidth / full.naturalHeight;
920        resizePlaceholder();
921      }
922      full.classList.add("is-ready");
923      if (preview) preview.hidden = true;
924      if (placeholder) placeholder.hidden = true;
925      setImageStatus();
926      frameGeometry = null;
927      scheduleLbApply();
928    };
929    full.onload = () => {
930      if (generation !== imageGeneration || !matchesImageSource(full, expected)) return;
931      const decoded = typeof full.decode === "function" ? full.decode().catch(() => {
932      }) : Promise.resolve();
933      decoded.then(finish);
934    };
935    full.onerror = () => {
936      if (generation !== imageGeneration || !matchesImageSource(full, expected)) return;
937      if (display && expected !== original && original) {
938        expected = original;
939        if (displayState?.generation === generation) displayState = null;
940        full.removeAttribute("srcset");
941        full.removeAttribute("sizes");
942        full.src = original;
943        return;
944      }
945      imageFailed = true;
946      setImageStatus(previewReady ? ui("高清图加载失败,已显示预览图", "高清圖載入失敗,已顯示預覽圖", "Full image unavailable. Preview shown.") : thumb ? ui("高清图加载失败,正在加载预览图", "高清圖載入失敗,正在載入預覽圖", "Full image unavailable. Loading preview…") : ui("图片加载失败", "圖片載入失敗", "Image unavailable"));
947    };
948    if (display) {
949      full.setAttribute("sizes", fittedSizes());
950      full.setAttribute("srcset", display.srcset);
951    }
952    full.src = source;
953    if (full.complete && full.naturalWidth > 0) full.onload();
954  }
955  function lbShow() {
956    const items = state.galleryItems || [];
957    const item = items[lb.index];
958    if (!item) return;
959    lbReset();
960    metaOpen = false;
961    metaPeek = false;
962    setLightboxImage(item);
963    els.lightboxImage.alt = item.title || "";
964    const multi = items.length > 1;
965    if (els.lightboxNavigation) els.lightboxNavigation.hidden = !multi;
966    if (els.lightboxPrev) els.lightboxPrev.hidden = !multi;
967    if (els.lightboxNext) els.lightboxNext.hidden = !multi;
968    if (els.lightboxCount) {
969      els.lightboxCount.hidden = !multi;
970      els.lightboxCount.textContent = multi ? `${lb.index + 1} / ${items.length}` : "";
971    }
972    renderLightboxTopics(item);
973    renderLightboxTags(item);
974    if (els.lightboxTopics) els.lightboxTopics.scrollLeft = 0;
975    renderLightboxThumbs();
976  }
977  function updateDetailGalleryItem(index, values) {
978    const gallery = transientGallery ? transientPreviousGallery : state.galleryItems;
979    const item = gallery?.[index];
980    if (!item) return;
981    const previousSrc = item.src;
982    Object.assign(item, values);
983    if (transientGallery || els.lightbox?.hidden) return;
984    if (lb.index === index) {
985      if (item.src !== previousSrc) setLightboxImage(item);
986      els.lightboxImage.alt = item.title || "";
987      if (Object.hasOwn(values, "sampleTopics")) renderLightboxTopics(item);
988      if (Object.hasOwn(values, "tags")) renderLightboxTags(item);
989    }
990    const button = thumbAt(index);
991    const thumb = button?.querySelector("img");
992    if (thumb && thumb.getAttribute("src") !== railSrc(item)) thumb.src = railSrc(item);
993    if (button && Object.hasOwn(values, "title")) button.setAttribute("aria-label", thumbLabel(item, index));
994    if (renderedThumbItems === gallery && (Object.hasOwn(values, "ratio") || Object.hasOwn(values, "width") || Object.hasOwn(values, "height"))) updateThumbWidth(index, itemRatio(item));
995  }
996  function restoreTransientGallery({ restoreFocus = false } = {}) {
997    const focusTarget = returnFocusTarget;
998    if (transientGallery && state.galleryItems === transientGallery) {
999      state.galleryItems = transientPreviousGallery || [];
1000    }
1001    transientGallery = null;
1002    transientPreviousGallery = null;
1003    returnFocusTarget = null;
1004    if (restoreFocus && focusTarget?.isConnected) focusTarget.focus?.({ preventScroll: true });
1005  }
1006  function openLightbox(index, options = {}) {
1007    const suppliedItems = Array.isArray(options?.items) ? options.items.filter(Boolean) : null;
1008    if (!els.lightbox || suppliedItems && !suppliedItems.length) return false;
1009    restoreTransientGallery();
1010    const items = suppliedItems || state.galleryItems || [];
1011    if (!items.length) return false;
1012    if (suppliedItems) {
1013      transientPreviousGallery = state.galleryItems || [];
1014      transientGallery = suppliedItems;
1015      state.galleryItems = suppliedItems;
1016    }
1017    returnFocusTarget = options?.returnFocus || document.activeElement;
1018    compactTray = isCompactTray();
1019    trayMode = defaultTrayMode();
1020    metaOpen = false;
1021    metaPeek = false;
1022    els.lightbox.setAttribute("aria-label", ui("大图预览", "大圖預覽", "Large image viewer"));
1023    els.lightboxClose?.setAttribute("aria-label", ui("关闭大图查看器 (Esc)", "關閉大圖檢視器 (Esc)", "Close image viewer (Esc)"));
1024    els.lightboxClose?.setAttribute("title", ui("关闭大图查看器", "關閉大圖檢視器", "Close image viewer"));
1025    els.lightboxPrev?.setAttribute("aria-label", ui("上一张", "上一張", "Previous image"));
1026    els.lightboxNext?.setAttribute("aria-label", ui("下一张", "下一張", "Next image"));
1027    els.lightboxThumbs?.setAttribute("aria-label", ui("同组图片缩略图", "同組圖片縮略圖", "Gallery thumbnails"));
1028    els.lightboxThumbToggle?.setAttribute("aria-label", ui("显示缩略图", "顯示縮略圖", "Show thumbnails"));
1029    if (els.lightboxThumbToggle) els.lightboxThumbToggle.textContent = ui("缩略图", "縮略圖", "Thumbnails");
1030    if (els.lightboxRetry) els.lightboxRetry.textContent = ui("重试", "重試", "Retry");
1031    els.lightboxZoomControls?.setAttribute("aria-label", ui("图片缩放", "圖片縮放", "Image zoom"));
1032    for (const [control, label] of [
1033      [els.lightboxZoomOut, ui("缩小 (−)", "縮小 (−)", "Zoom out (−)")],
1034      [els.lightboxZoomIn, ui("放大 (+)", "放大 (+)", "Zoom in (+)")],
1035      [els.lightboxFit, ui("适合屏幕 (0)", "適合屏幕 (0)", "Fit to screen (0)")]
1036    ]) {
1037      control?.setAttribute("aria-label", label);
1038      control?.setAttribute("title", label);
1039    }
1040    els.lightboxImage.setAttribute("title", ui("点击放大 · Alt 点击缩小 · 滚轮缩放", "點擊放大 · Alt 點擊縮小 · 滾輪縮放", "Click to zoom in · Alt-click to zoom out · Scroll to zoom"));
1041    if (els.lightboxTagsHeading) els.lightboxTagsHeading.textContent = ui("当前样张标签", "當前樣張標籤", "Current image tags");
1042    if (els.lightboxTopicsHeading) els.lightboxTopicsHeading.textContent = ui("图片所属栏目", "圖片所屬欄目", "Image categories");
1043    if (els.lightboxLocate) els.lightboxLocate.textContent = ui("定位当前图", "定位當前圖", "Find current image");
1044    els.lightboxPreview?.setAttribute("title", els.lightboxImage.getAttribute("title"));
1045    renderedThumbItems = null;
1046    lb.index = (index + items.length) % items.length;
1047    els.lightbox.hidden = false;
1048    document.body.classList.add("is-lightbox-open");
1049    if (!suppliedItems && borrowDetailPanel()) syncTray();
1050    else {
1051      if (detailHome) setDetailExpanded(false);
1052      els.lightbox.classList.remove("is-detail-expanded");
1053      els.lightboxDetailSlot.hidden = true;
1054      els.lightboxDetailSlot.inert = true;
1055      if (els.lightboxPanelToggle) els.lightboxPanelToggle.hidden = true;
1056    }
1057    stageObserver?.observe(els.lightboxStage);
1058    if (els.lightboxDock) stageObserver?.observe(els.lightboxDock);
1059    lbShow();
1060    updateDockSpace();
1061    revealControls();
1062    els.lightboxClose?.focus();
1063    return true;
1064  }
1065  function closeLightbox({ restoreFocus = true } = {}) {
1066    if (!els.lightbox || els.lightbox.hidden) return false;
1067    els.lightbox.hidden = true;
1068    if (controlTimer !== null) win.clearTimeout(controlTimer);
1069    controlTimer = null;
1070    els.lightbox.classList.remove("is-controls-visible");
1071    stageObserver?.disconnect();
1072    cancelTransformFrame();
1073    clearGesture();
1074    dragged = false;
1075    imageGeneration++;
1076    imageReady = false;
1077    previewReady = false;
1078    imageFailed = false;
1079    document.body.classList.remove("is-lightbox-open");
1080    returnDetailPanel();
1081    els.lightboxImage.onload = null;
1082    els.lightboxImage.onerror = null;
1083    els.lightboxImage.removeAttribute("src");
1084    els.lightboxImage.classList.remove("is-ready", "is-zoomed");
1085    if (els.lightboxPreview) {
1086      els.lightboxPreview.onload = null;
1087      els.lightboxPreview.onerror = null;
1088      els.lightboxPreview.removeAttribute("src");
1089    }
1090    setImageStatus();
1091    if (thumbScrollFrame !== null) win.cancelAnimationFrame(thumbScrollFrame);
1092    thumbScrollFrame = null;
1093    els.lightboxThumbs?.replaceChildren();
1094    els.lightboxTopics?.replaceChildren();
1095    els.lightboxTags?.replaceChildren();
1096    renderedThumbItems = null;
1097    thumbWidths = [];
1098    thumbOffsets = [0];
1099    activeThumb = null;
1100    thumbWindowStart = thumbWindowEnd = 0;
1101    renderedTopicsKey = null;
1102    renderedTagsKey = null;
1103    topicCount = 0;
1104    trayMode = "closed";
1105    metaOpen = false;
1106    metaPeek = false;
1107    fittedSize = null;
1108    if (els.lightboxTopics) els.lightboxTopics.hidden = true;
1109    if (els.lightboxTags) els.lightboxTags.hidden = true;
1110    restoreTransientGallery({ restoreFocus });
1111    return true;
1112  }
1113  function stepLightbox(dir) {
1114    const items = state.galleryItems || [];
1115    if (items.length < 2) return;
1116    lb.index = (lb.index + dir + items.length) % items.length;
1117    lbShow();
1118  }
1119  function revealControls() {
1120    if (els.lightbox.hidden) return;
1121    els.lightbox.classList.add("is-controls-visible");
1122    if (controlTimer !== null) win.clearTimeout(controlTimer);
1123    controlTimer = win.setTimeout(() => {
1124      controlTimer = null;
1125      if (!els.lightboxDock?.contains(document.activeElement) && !els.lightboxDock?.matches(":hover")) {
1126        els.lightbox.classList.remove("is-controls-visible");
1127      }
1128    }, 2e3);
1129  }
1130  function installLightboxListeners() {
1131    if (!els.lightbox) return;
1132    els.lightboxStage.addEventListener("wheel", (event) => {
1133      if (els.lightbox.hidden) return;
1134      revealControls();
1135      event.preventDefault();
1136      const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? els.lightboxStage.clientHeight : 1;
1137      const wheelDelta = Math.max(-120, Math.min(120, event.deltaY * unit));
1138      zoomTo(lb.scale * Math.exp(-wheelDelta * (event.ctrlKey ? 0.01 : LIGHTBOX_WHEEL_ZOOM_INTENSITY)), { x: event.clientX, y: event.clientY });
1139    }, { passive: false });
1140    const clickImage = (event) => {
1141      if (els.lightbox.hidden || dragged || hadPinch || event.detail > 1 || lastImagePointerType !== "mouse") return;
1142      const direction = event.altKey ? 1 / 1.6 : 1.6;
1143      zoomTo(lb.scale * direction, { x: event.clientX, y: event.clientY });
1144    };
1145    els.lightboxImage.addEventListener("click", clickImage);
1146    els.lightboxPreview?.addEventListener("click", clickImage);
1147    els.lightboxStage.addEventListener("pointerdown", (event) => {
1148      dragged = false;
1149      if (els.lightbox.hidden || event.button !== 0) return;
1150      revealControls();
1151      if (event.target === els.lightboxImage || event.target === els.lightboxPreview) lastImagePointerType = event.pointerType || "mouse";
1152      if (!pointers.size) hadPinch = false;
1153      pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, touch: event.pointerType === "touch" });
1154      beginGesture();
1155      scheduleLbApply();
1156    });
1157    els.lightboxStage.addEventListener("pointermove", (event) => {
1158      if (event.pointerType === "mouse" || event.pointerType === "pen") revealControls();
1159      if (!pointers.has(event.pointerId) || !gesture) return;
1160      const point = { x: event.clientX, y: event.clientY, touch: event.pointerType === "touch" };
1161      pointers.set(event.pointerId, point);
1162      if (gesture.kind === "pinch") {
1163        const next = pinchPoints();
1164        if (gesture.distance > 0) zoomTo(lb.scale * next.distance / gesture.distance, gesture);
1165        lb.x += next.x - gesture.x;
1166        lb.y += next.y - gesture.y;
1167        gesture = { kind: "pinch", ...next };
1168        scheduleLbApply();
1169        return;
1170      }
1171      if (!dragged && Math.hypot(point.x - gesture.x, point.y - gesture.y) > 3) {
1172        dragged = true;
1173        capturePointer(event.pointerId);
1174      }
1175      if (!dragged || gesture.kind !== "pan") return;
1176      lb.x = gesture.ox + (point.x - gesture.x);
1177      lb.y = gesture.oy + (point.y - gesture.y);
1178      scheduleLbApply();
1179    });
1180    const endDrag = (event) => {
1181      if (!pointers.has(event.pointerId)) return;
1182      pointers.delete(event.pointerId);
1183      beginGesture();
1184      if (els.lightboxStage.hasPointerCapture?.(event.pointerId)) els.lightboxStage.releasePointerCapture(event.pointerId);
1185      lbApply();
1186    };
1187    els.lightboxStage.addEventListener("pointerup", endDrag);
1188    els.lightboxStage.addEventListener("pointercancel", endDrag);
1189    els.lightboxStage.addEventListener("lostpointercapture", (event) => {
1190      if (event.target === els.lightboxStage && !els.lightboxStage.hasPointerCapture?.(event.pointerId)) endDrag(event);
1191    });
1192    els.lightboxStage.addEventListener("dblclick", (event) => {
1193      if (event.target.closest?.("button")) return;
1194      if (lb.scale >= 2.5) lbReset();
1195      else {
1196        zoomTo(2.5, { x: event.clientX, y: event.clientY });
1197        lbApply();
1198      }
1199    });
1200    els.lightboxStage.addEventListener("click", (event) => {
1201      if (event.pointerType !== "touch" || event.target !== els.lightboxStage || dragged) return;
1202      if (els.lightbox.classList.contains("is-controls-visible")) els.lightbox.classList.remove("is-controls-visible");
1203      else revealControls();
1204    });
1205    els.lightboxDock?.addEventListener("focusin", revealControls);
1206    els.lightboxDock?.addEventListener("pointermove", revealControls);
1207    els.lightboxClose?.addEventListener("click", closeLightbox);
1208    els.lightboxPanelToggle?.addEventListener("click", () => setDetailExpanded(!detailExpanded));
1209    els.lightboxLocate?.addEventListener("click", () => {
1210      els.lightboxTray?.scrollIntoView({ block: "start", behavior: "smooth" });
1211      renderLightboxThumbs();
1212    });
1213    els.lightboxZoomOut?.addEventListener("click", () => zoomStep(-1));
1214    els.lightboxZoomIn?.addEventListener("click", () => zoomStep(1));
1215    els.lightboxFit?.addEventListener("click", lbReset);
1216    els.lightboxRetry?.addEventListener("click", () => {
1217      const item = state.galleryItems?.[lb.index];
1218      if (item) setLightboxImage(item, { preservePreview: true });
1219    });
1220    els.lightboxThumbToggle?.addEventListener("click", () => {
1221      trayMode = trayMode === "thumbs" ? "closed" : "thumbs";
1222      syncTray();
1223      if (trayMode === "thumbs") renderLightboxThumbs();
1224    });
1225    els.lightboxTopicsToggle?.addEventListener("click", () => {
1226      metaOpen = !metaOpen;
1227      syncTray();
1228    });
1229    els.lightboxThumbs?.addEventListener("pointerover", (event) => {
1230      if (!event.target.closest?.('.lightbox-thumb[aria-selected="true"]')) return;
1231      metaPeek = true;
1232      syncTray();
1233    });
1234    els.lightboxTray?.addEventListener("pointerleave", () => {
1235      if (metaPeek) {
1236        metaPeek = false;
1237        syncTray();
1238      }
1239    });
1240    els.lightboxThumbs?.addEventListener("focusin", (event) => {
1241      if (!event.target.closest?.('.lightbox-thumb[aria-selected="true"]')) return;
1242      metaPeek = true;
1243      syncTray();
1244    });
1245    els.lightboxTray?.addEventListener("focusout", (event) => {
1246      if (metaPeek && !els.lightboxTray.contains(event.relatedTarget)) {
1247        metaPeek = false;
1248        syncTray();
1249      }
1250    });
1251    els.lightboxThumbs?.addEventListener("keydown", (event) => {
1252      const forward = compactTray ? "ArrowRight" : "ArrowDown";
1253      const backward = compactTray ? "ArrowLeft" : "ArrowUp";
1254      if (![forward, backward, "Home", "End"].includes(event.key)) return;
1255      event.preventDefault();
1256      event.stopPropagation();
1257      const items = state.galleryItems || [];
1258      if (items.length < 2) return;
1259      const focusIndex = Number(event.target.closest?.("[data-index]")?.dataset.index);
1260      const origin = Number.isFinite(focusIndex) ? focusIndex : lb.index;
1261      const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (origin + (event.key === forward ? 1 : -1) + items.length) % items.length;
1262      if (next !== lb.index) {
1263        lb.index = next;
1264        lbShow();
1265      }
1266      thumbAt(next)?.focus({ preventScroll: true });
1267    });
1268    els.lightboxThumbs?.addEventListener("scroll", () => {
1269      if (thumbScrollFrame !== null || renderedThumbItems?.length <= LIGHTBOX_THUMB_THRESHOLD) return;
1270      thumbScrollFrame = win.requestAnimationFrame(() => {
1271        thumbScrollFrame = null;
1272        const row = els.lightboxThumbs;
1273        if (els.lightbox.hidden || row.hidden) return;
1274        const position = compactTray ? row.scrollLeft + row.clientWidth / 2 : row.scrollTop + row.clientHeight / 2;
1275        let low = 0, high = renderedThumbItems.length;
1276        while (low < high) {
1277          const middle2 = low + high >>> 1;
1278          if (thumbOffset(middle2 + 1) <= position) low = middle2 + 1;
1279          else high = middle2;
1280        }
1281        const middle = Math.min(renderedThumbItems.length - 1, low);
1282        if (middle >= thumbWindowStart + 5 && middle < thumbWindowEnd - 5) return;
1283        const start = Math.max(0, Math.min(renderedThumbItems.length - LIGHTBOX_THUMB_WINDOW, middle - Math.floor(LIGHTBOX_THUMB_WINDOW / 2)));
1284        if (start === thumbWindowStart) return;
1285        renderThumbWindow(start, start + LIGHTBOX_THUMB_WINDOW);
1286        const current = thumbAt(lb.index);
1287        current?.setAttribute("aria-selected", "true");
1288        activeThumb = current;
1289      });
1290    }, { passive: true });
1291    const resized = (entries) => {
1292      if (els.lightbox.hidden) return;
1293      if (Array.isArray(entries) && entries.every((entry) => entry.target === els.lightboxDock)) {
1294        updateDockSpace();
1295        return;
1296      }
1297      frameGeometry = null;
1298      clearGesture();
1299      const nextCompact = isCompactTray();
1300      if (compactTray !== nextCompact) {
1301        compactTray = nextCompact;
1302        trayMode = defaultTrayMode();
1303        if (detailHome) setDetailExpanded(!compactTray);
1304        if (thumbWindowEnd > thumbWindowStart) renderThumbWindow(thumbWindowStart, thumbWindowEnd);
1305        els.lightboxThumbs.scrollLeft = 0;
1306        els.lightboxThumbs.scrollTop = 0;
1307      }
1308      syncTray();
1309      updateDockSpace();
1310      scheduleLbApply();
1311    };
1312    if (win?.ResizeObserver) stageObserver = new win.ResizeObserver(resized);
1313    win?.addEventListener("resize", resized);
1314    win?.addEventListener("blur", () => {
1315      if (els.lightbox.hidden) return;
1316      clearGesture();
1317      lbApply();
1318    });
1319    els.lightboxPrev?.addEventListener("click", () => stepLightbox(-1));
1320    els.lightboxNext?.addEventListener("click", () => stepLightbox(1));
1321    document.addEventListener("keydown", (event) => {
1322      if (els.lightbox.hidden || event.defaultPrevented) return;
1323      if (event.key === "Escape") {
1324        event.preventDefault();
1325        event.stopImmediatePropagation();
1326        if (metaOpen) {
1327          metaOpen = false;
1328          syncTray();
1329          els.lightboxTopicsToggle?.focus();
1330        } else if (trayMode === "thumbs") {
1331          trayMode = "closed";
1332          syncTray();
1333          els.lightboxThumbToggle?.focus();
1334        } else closeLightbox();
1335      } else if (event.key === "Tab") {
1336        const controls = [...els.lightbox.querySelectorAll('button, a[href], [tabindex="0"]')].filter((node) => !node.disabled && !node.closest("[hidden], [inert]") && win.getComputedStyle(node).visibility !== "hidden");
1337        const first = controls[0], last = controls.at(-1);
1338        const outside = !els.lightbox.contains(document.activeElement);
1339        if (event.shiftKey && (document.activeElement === first || outside)) {
1340          event.preventDefault();
1341          last?.focus();
1342        } else if (!event.shiftKey && (document.activeElement === last || outside)) {
1343          event.preventDefault();
1344          first?.focus();
1345        }
1346      } else if (event.ctrlKey || event.metaKey || event.altKey || event.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
1347      else if (event.key === "+" || event.key === "=") {
1348        event.preventDefault();
1349        zoomStep(1);
1350      } else if (event.key === "-" || event.key === "_") {
1351        event.preventDefault();
1352        zoomStep(-1);
1353      } else if (event.key === "0") {
1354        event.preventDefault();
1355        lbReset();
1356      } else if (els.lightboxTopics?.contains(event.target)) return;
1357      else if (event.key === "ArrowLeft") {
1358        event.preventDefault();
1359        stepLightbox(-1);
1360      } else if (event.key === "ArrowRight") {
1361        event.preventDefault();
1362        stepLightbox(1);
1363      }
1364    });
1365  }
1366  installLightboxListeners();
1367  return {
1368    openLightbox,
1369    closeLightbox,
1370    stepLightbox,
1371    updateDetailGalleryItem,
1372    renderLightboxThumbs
1373  };
1374}
1375export {
1376  createLightboxModule
1377};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.