Files
FamilyBook/assets/js/preview.js
T
laoyang 306f5a61cb 图片轮播切到视频后左右键失效 (#57)
用户 2026-11-05:「行程里面图片轮播的时候,切换到视频,上下左右键就失效了。」

根因:键盘处理里有 isImage() 判断,只有当前是图片才翻页:
  if (e.key === ArrowLeft && isImage()) { step(-1); }
切到视频后 isImage() 为 false,两个分支都进不去。

当初这么写是因为「视频/PDF 里方向键另有含义,不能抢」——
顾虑是对的(视频左右键是快退/快进),但解法错了:
一刀切禁掉,而不是看焦点在哪。

修法:改成判断事件目标
  function inMediaControl(t) { return !!t.closest("video, iframe"); }
  if (inMediaControl(e.target)) { return; }   // 放行给控件
  其余情况照常翻页,不再限制只有图片

行为矩阵:
  焦点在 video 里         → 视频快退/快进(不被抢)
  焦点在 PDF iframe 里    → PDF 自己翻页(不被抢)
  焦点在别处 / 视频未聚焦  → 上一张 / 下一张

上下键保持不处理:页面滚动、视频音量另有含义。
2026-11-05 13:19:05 +08:00

243 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 全局附件预览(v24 用户第 3 条;第 19 批 第 6 条加左右翻页)
*
* 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。
* · 图片 → <img>
* · 视频 → <video controls autoplay>(media.php 支持 Range,进度条能拖)
* · PDF → <iframe>(用浏览器自带的阅读器)
* · 其它(Word / Excel / …)→ 根本不进这里:那段链接指向的是下载地址,点了就下。
*
* 右上角恒有一个「下载」按钮(用户原话:所有的预览页面,右上角都有一个下载按钮)。
*
* 触发方式是数据属性,不用给每个地方写一遍初始化:
* <a href="…" data-prev="image|video|pdf" data-src="…" data-dl="…" data-name="…">
*
* ────────────────────────────────────────────────────────────────
* v24 第 19 批 用户第 6 条:「行程中的图片和视频,预览的时候,左右有箭头,
* 可以直接查看下一张。」
*
* 做法:翻页的候选 = **同一页里所有可预览的附件**(`[data-prev]`),按文档顺序。
* 为什么不做"只在本段行程内翻页":
* · 这个预览层是**全站一个**(图片/视频/PDF 共用),而 attachments 墙是按
* 宿主分块渲染的;要"按块"就得给每块再编一次组号,收益很小、复杂度不小;
* · 用户要的行为是"看完这张接着看下一张",同页顺序翻页完全满足,
* 并且跨段也不会跳去别的页面 —— 翻到下一个旅程节点时用户能立刻看出来。
* ⚠️ 只有**一个**可预览项时箭头不显示(点了没反应的元素比没有更糟)。
* ⚠️ 键盘 ← → 也翻页;视频/PDF 里方向键有自己的含义,所以**只在图片**上接管方向键。
*/
(function () {
'use strict';
var box = document.getElementById('pv');
if (!box) { return; }
var stage = box.querySelector('.pv-stage');
var titleEl = box.querySelector('.pv-name');
var metaEl = box.querySelector('.pv-meta');
var dlEl = box.querySelector('.pv-dl');
var closeEl = box.querySelector('.pv-close');
var prevEl = box.querySelector('.pv-prev');
var nextEl = box.querySelector('.pv-next');
var opener = null;
/** 当前这一层里能看到的所有可预览项(文档顺序),以及"正在看第几个" */
var items = [];
var cur = -1;
function clear() {
// ⚠️ 换内容要**整个重建**节点,不能只改 src:
// <video> 改 src 不会自动卸载上一支(声音会叠着放),<iframe> 同理。
stage.innerHTML = '';
}
/** 收一份当前页面里所有可预览的附件(每次打开都重收,页面内容可能被改过) */
function collect(trigger) {
items = Array.prototype.slice.call(document.querySelectorAll('[data-prev]'));
cur = items.indexOf(trigger);
if (cur < 0) { items = [trigger]; cur = 0; }
}
/** 第一张 / 最后一张时箭头要禁用(循环翻页会让人不知道自己在哪儿) */
function syncArrows() {
if (!prevEl || !nextEl) { return; }
var many = items.length > 1;
prevEl.hidden = !many;
nextEl.hidden = !many;
prevEl.disabled = !many || cur <= 0;
nextEl.disabled = !many || cur >= items.length - 1;
}
function paint(trigger) {
var kind = trigger.getAttribute('data-prev') || 'image';
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
var dl = trigger.getAttribute('data-dl') || src;
/* #53:文件名要在 error 回调里用到,提前取出来(原来 nm 在函数末尾才定义) */
var nm2 = trigger.getAttribute('data-name') || '';
if (!src) { return false; }
opener = trigger;
clear();
var el;
if (kind === 'video') {
el = document.createElement('video');
el.className = 'pv-video';
el.src = src;
el.controls = true;
el.autoplay = true;
el.playsInline = true;
/* 🔴🔴 #53(2026-11-05):**HEVC 解码失败时给明确提示,不要装死**。
实测用户的视频是 H.265/HEVC(`hvc1`),Chrome/Firefox 的根本解不了 ——
表现为一块黑屏,什么提示都没有,用户只会以为"文件坏了"。
这里监听 error:能取到 `el.error.code` 就说明是解码/加载失败。
把解释写进画面本身(而不是 alert),并给出下载入口 ——
下载下来用系统播放器是能看的(VLC/iPhone/Windows 都支持 HEVC)。
⚠️ 不做"静默回退到下载页":用户点的是"看",直接跳走会困惑。
要的是**在原地说明白**,同时给一条出路。 */
el.addEventListener('error', function () {
var wrap = document.createElement('div');
wrap.className = 'pv-fallback';
var h = document.createElement('p');
h.className = 'pv-fallback-h';
h.textContent = '这个视频浏览器解不了';
var p = document.createElement('p');
p.className = 'pv-fallback-p';
p.textContent = '它可能是 H.265(HEVC)编码。Chrome、Firefox 不支持这种格式,'
+ '但文件本身没坏 —— 下载后用系统播放器(iPhone / VLC / Windows 播放器)可以正常观看。';
var a = document.createElement('a');
a.className = 'pv-fallback-dl';
a.href = dl;
a.textContent = '下载这个视频';
if (nm2) { a.setAttribute('download', nm2); }
wrap.appendChild(h);
wrap.appendChild(p);
wrap.appendChild(a);
while (stage.firstChild) { stage.removeChild(stage.firstChild); }
stage.appendChild(wrap);
});
} else if (kind === 'pdf') {
el = document.createElement('iframe');
el.className = 'pv-pdf';
el.src = src;
// ⚠️ 这里**绝对不能**加 sandbox 属性。加上去 PDF 会直接打不开:
// 页面只剩一行「此页面已被 Chromium 屏蔽」,阅读器根本不起。
//
// 实测(Chromium 153,五组同源对照):
// · 无 sandbox → 正常渲染(多挂一个内置阅读器扩展 frame)
// · allow-same-origin allow-scripts → 屏蔽
// · allow-scripts → 屏蔽
// · + allow-popups-to-escape-sandbox → **仍然屏蔽**(网上很多答案说这个能救,本版本不行)
// · + allow-forms allow-popups → 仍然屏蔽
// ⇒ 只要属性存在就不给 PDF 插件权限。加白名单无效,只能不加。
//
// 不加也安全:PDF 由浏览器内置阅读器(mhjfbmdgcfjbbpaeojofohoefgiehjai)
// 在**独立扩展进程**里渲染,不是普通页面上下文,拿不到父页的 window / DOM / cookie。
// PDF 是原生文档、不是 HTML,本就没有能访问父页面的脚本环境。
// 服务端另有 `X-Content-Type-Options: nosniff` + MIME 按内容嗅探(media.php),
// PDF 不会被当 HTML 执行。若将来真需要禁止 PDF 内嵌 JS,正确做法是换 pdf.js 自渲染,
// 而不是给 iframe 挂 sandbox——那会把整个 PDF 预览一起废掉。
} else {
el = document.createElement('img');
el.className = 'pv-img';
el.src = src;
el.alt = '';
}
stage.appendChild(el);
titleEl.textContent = trigger.getAttribute('data-name') || '';
var sz = trigger.getAttribute('data-size') || '';
var counter = items.length > 1 ? (' ' + (cur + 1) + '/' + items.length) : '';
metaEl.textContent = sz + counter;
dlEl.setAttribute('href', dl);
// 下载要带上真实文件名:download 属性 + 服务端 Content-Disposition 双保险
var nm = trigger.getAttribute('data-name') || '';
if (nm) { dlEl.setAttribute('download', nm); } else { dlEl.removeAttribute('download'); }
syncArrows();
return true;
}
function open(trigger) {
collect(trigger);
if (!paint(trigger)) { return; }
box.hidden = false;
box.setAttribute('aria-hidden', 'false');
closeEl.focus();
}
/**
* 翻到上一张 / 下一张。
* @param {number} dir -1 = 上一张,+1 = 下一张
*/
function step(dir) {
if (items.length <= 1) { return; }
var n = cur + dir;
if (n < 0 || n >= items.length) { return; }
cur = n;
paint(items[cur]);
}
function close() {
box.hidden = true;
box.setAttribute('aria-hidden', 'true');
clear(); // 停掉视频 / 卸掉 PDF
if (opener && opener.focus) { opener.focus(); }
opener = null;
items = [];
cur = -1;
}
/**
* 按键是否发生在**媒体控件内部**(视频 / PDF iframe)。
*
* 🔴 #58(用户 2026-11-05):「行程里面图片轮播的时候,切换到视频,
* 上下左右键就失效了。」
*
* 原来的写法是 `if (e.key === 'ArrowLeft' && isImage())` ——
* 只有**当前是图片**才翻页,切到视频后左右键直接失效。
*
* 当年这么写的顾虑(注释原文)是「视频/PDF 里方向键另有含义,不能抢」,
* 这个顾虑对:视频的左右键是快退/快进,PDF 里是翻页。
*
* 但**一刀切禁掉**是错的解法。正确做法是看**事件目标**:
* · 按键发生在 video/iframe 内部 ⇒ 让它自己处理(快进、翻页)
* · 按键发生在别处(背景、标题栏)⇒ 照常翻页
*
* ⚠️ 视频**没聚焦**时 `e.target` 往往是 `<body>` ——
* 这时用户没在操作视频,就是想换下一张,应该翻页。
*/
function inMediaControl(t) {
if (!t || !t.closest) { return false; }
return !!t.closest('video, iframe');
}
document.addEventListener('click', function (e) {
var t = e.target && e.target.closest ? e.target.closest('[data-prev]') : null;
if (t) {
// ⚠️ 只拦"可预览"的:Word/Excel 那类链接没有 data-prev,
// 浏览器照常走 href(就是下载地址),不会被这里挡住。
e.preventDefault();
open(t);
return;
}
// 点遮罩(stage 本身或弹层空白处)关闭;点内容不关
if (!box.hidden && (e.target === box || e.target === stage)) { close(); }
});
if (prevEl) { prevEl.addEventListener('click', function () { step(-1); }); }
if (nextEl) { nextEl.addEventListener('click', function () { step(1); }); }
closeEl.addEventListener('click', close);
document.addEventListener('keydown', function (e) {
if (box.hidden) { return; }
if (e.key === 'Escape') { close(); return; }
/* #58:焦点在视频/PDF 控件里 ⇒ 放行给控件(快进、PDF 翻页)。
这样"视频里左右键是快进"和"轮播里左右键是翻页"**不冲突**。 */
if (inMediaControl(e.target)) { return; }
/* 其余情况:左右键翻页 —— **不再限制只有图片才能翻**。
⚠️ 上下键保持不处理:页面滚动/视频音量另有含义,抢了会别扭。 */
if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); return; }
if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
});
})();