Files
FamilyBook/assets/js/preview.js
T

217 lines
9.9 KiB
JavaScript
Raw Normal View History

/**
* 全局附件预览(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 里方向键另有含义,不能抢) */
function isImage() {
return items[cur] && (items[cur].getAttribute('data-prev') || '') === 'image';
}
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; }
if (e.key === 'ArrowLeft' && isImage()) { e.preventDefault(); step(-1); return; }
if (e.key === 'ArrowRight' && isImage()) { e.preventDefault(); step(1); }
});
})();