/* 家书 · 前端小助手(无框架,无构建步骤) */ (function () { 'use strict'; // ===== 时间框:点输入框任意一处都弹出选择器 ===== // 原生行为只在点右侧那个小日历图标时才弹(而且各浏览器还不一致), // 用户会以为框只能读、或者找不到那个图标在哪。 // showPicker() 是标准做法:在 click 里调它,等于把整块输入区都当成了那个图标。 // // 为什么能跟浏览器自带的那次弹出共存:若本次点击已经弹过,再调一次是空操作 // (规范里「show the picker」对已经显示的选择器直接返回),不会闪两下。 // 不支持的浏览器(老 Safari 等)静默跳过 —— 退化成原生行为,不会比现在更差。 (function () { var pickers = document.querySelectorAll('input[type="date"], input[type="datetime-local"]'); function hook(el) { el.addEventListener('click', function () { if (typeof el.showPicker !== 'function') { return; } try { el.showPicker(); } catch (err) { // NotAllowedError:不是用户手势触发的(或被跨域 iframe 挡住)。 // 忽略即可 —— 原生那套点击行为还在,用户仍有办法选时间。 } }); } for (var i = 0; i < pickers.length; i++) { hook(pickers[i]); } })(); // ===== 图片裁剪(v24 第 11 批 #5:改用 Cropper.js)===== // // 用户原话:「所有上传图片带有裁剪功能的地方,都需要重做,现在仍然不好用。 // 用 Cropper.js 这个库,弹框不要有任何滚动条,图片就无论多大, // 就缩放到这个框内,也不要缩放条,鼠标滚轮滚动后,缩放大小, // 裁剪框,要比外面的弹框小,让所有的裁剪触点都露出来。」 // // 逐条落地: // · **弹框不许有滚动条** → 面板 overflow:hidden;图片用 `img { max-width:100% }` // 交给 Cropper 的 viewMode:1 自动缩放进容器,而不是让容器去滚动。 // · **不要缩放条** → 去掉原来那条 ,改滚轮: // Cropper 的 wheelZoomRatio 直接支持(默认 0.1,这里调小一点更精细)。 // · **裁剪框比弹框小、触点全露出** → viewMode:1(框只能在画布内) // + 面板四周留出 padding(CSS 的 .crop-panel),保证四角四个手柄永远在框内可见。 // · **不同形状** → 比例仍由服务端 data-crop-ratio 决定(头像 1:1、题图 16:9…), // 前端不再自己定数字。可拖动的方向由 aspectRatio 允许的轴向自动决定。 // // ⚠️ 提交契约**完全不变**:确认时仍写回 `avatar_crop` = 归一化 "x,y,w,h"(0..1)。 // Cropper.getData() 给的是**原图像素**,所以要除以 naturalWidth/naturalHeight —— // 服务端(app/Upload.php)继续按归一化矩形裁,绕过前端也存不进比例不对的图。 // ⚠️ Cropper.js 是按需加载的:页面上真有 data-crop-ratio 的文件框才去拉这个库 // (与 Semi 那套同一思路:不能让没用到裁剪的页面白拉)。 // // 🔴🔴 v50(用户 2026-10-14 第 1 条)——**根因级修法:绑定可重复、可增量**。 // 用户原话:「旅程的详情页面,当我点击编辑的时候,里面的图片没有办法改变。 // 实际上当我把图片拖进去,或者选中一个图片的时候,应该弹出来一个让我 // 裁剪的那个框。但是现在却没有那个裁剪的框。」 // // 真根因:这段以前是**一次性**的 —— // `var inputs = querySelectorAll('[data-crop-ratio]'); if (!inputs.length) return;` // 在**脚本加载那一刻**扫一遍,扫到就绑、扫不到(或后来才出现的)**永远不绑**。 // 而旅程详情页的「编辑旅程」是 **AJAX 取片段、innerHTML 塞进弹框**的 // (见 templates/trips/show.php 的 loadInto): // 详情页首屏**没有**任何 `[data-crop-ratio]`(题图框在片段里)⇒ 这里当场 // `return` ⇒ 弹框里的题图框**从来没有 change 监听** ⇒ // 拖入/选中图片什么都不发生(`bindZone` 那条拖动逻辑会派发 change, // 但**没人听**)⇒ 「图片改不了」。 // // ⇒ 拆成两部分: // ① `bindOne(input)`:给**单个**文件框绑 change(幂等,带 `data-crop-bound` 标记); // ② `scan(scope)`:扫一遍并绑没绑过的;首屏调一次 + MutationObserver 补挂。 // ⚠️ 与「拖动上传」那段(bindInput/scan/MutationObserver)**同一手法** —— // 它早就解决了同样的问题("动态插进来的文件框也要接得住"), // 裁剪这段当年漏了这层。别再用"首屏一次性"的写法。 // ⚠️ 库(Cropper.js)的按需加载时机也要跟着改:**首次真要用时才拉** // (不能在"扫到 0 个框"时就 return 掉 —— 那样动态来的框再也不会拉库)。 (function () { var meta = document.querySelector('meta[name="fb-cropper"]'); if (!meta) { return; } // 页面没登记资源地址(不该发生,见 layout.php) var conf; try { conf = JSON.parse(meta.getAttribute('content')); } catch (e) { return; } if (!conf || !conf.css || !conf.js) { return; } var loading = null; function loadCss(href) { return new Promise(function (res) { var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; l.onload = function () { res(true); }; l.onerror = function () { res(false); }; document.head.appendChild(l); }); } /* ⚠️ 与 Semi 段那个 `loadJs` 用**同一个全局登记表** —— 两处都加载 React (Semi 无条件、antd 有条件),不共享去重就会执行两次 ⇒ React #321。 详见 Semi 段 `loadJs` 上方的完整说明。 */ function loadJs(src) { var reg = window.__fbJsLoaded; if (!reg) { reg = window.__fbJsLoaded = {}; } if (reg[src]) { return reg[src]; } reg[src] = new Promise(function (res, rej) { var el = document.createElement('script'); el.src = src; el.onload = function () { res(); }; el.onerror = function () { rej(new Error('加载失败: ' + src)); }; document.head.appendChild(el); }); return reg[src]; } function ensureCropper() { if (window.Cropper) { return Promise.resolve(); } if (loading) { return loading; } loading = loadCss(conf.css).then(function () { return loadJs(conf.js); }); return loading; } /** * 解析 data-crop-ratio;拿不到就退回 1(正方形)。 * * ⚠️ 必须支持 **"16:9" 这种带冒号的写法**:`parseFloat('16:9')` 会得到 **16** * (它遇到 ':' 就停),于是"题图"槽位会裁出一个 16:1 的细长条 —— * 页面不报错、图也存下去了,只有肉眼看才发现不对。 * (旧自研版正因为同一处栽过,交互测试里专门留了一条断言守它。) */ function parseRatioAttr(v) { var t = String(v == null ? '' : v).trim(); if (t === '') { return 1; } var m = t.match(/^(\d+(?:\.\d+)?)\s*[:/x×]\s*(\d+(?:\.\d+)?)$/); if (m) { var a = parseFloat(m[1]), b = parseFloat(m[2]); if (isFinite(a) && isFinite(b) && a > 0 && b > 0) { return a / b; } } var n = parseFloat(t); return (isFinite(n) && n > 0) ? n : 1; } // ---- 弹框骨架(全页一个,复用)---- var overlay = null, panel = null, imgEl = null, hintEl = null, cropper = null; var cur = null; // 当前编辑的 file input var objUrl = ''; function build() { overlay = document.createElement('div'); overlay.className = 'crop-overlay'; overlay.innerHTML = ''; document.body.appendChild(overlay); panel = overlay.querySelector('.crop-panel'); imgEl = overlay.querySelector('.crop-img'); hintEl = overlay.querySelector('.crop-tip'); overlay.querySelector('.crop-x').addEventListener('click', function () { close(true); }); overlay.querySelector('.crop-cancel').addEventListener('click', function () { close(true); }); overlay.addEventListener('click', function (e) { if (e.target === overlay) { close(true); } }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && overlay.classList.contains('is-open')) { close(true); } }); overlay.querySelector('.crop-ok').addEventListener('click', confirm); } /** * 关闭浮层。 * * @param {boolean} cancelled true = 用户**取消**(点 ✕ / 取消 / 遮罩 / Esc)。 * * ⚠️ 取消时必须把 file input 清空:否则用户以为"没选图",实际上表单里 * 仍挂着那张**没裁过的原图**,一提交就传上去了(旧自研版有这一步, * 换成 Cropper.js 时被我漏掉,靠交互测试的"取消后文件框被清空"抓回来)。 * 确认(confirm)走的是另一条路 —— 那时文件正是用户要传的,不能清。 */ function close(cancelled) { if (cropper) { cropper.destroy(); cropper = null; } if (overlay) { overlay.classList.remove('is-open'); } if (objUrl) { URL.revokeObjectURL(objUrl); objUrl = ''; } if (cancelled && cur) { try { cur.value = ''; } catch (e) { /* 某些浏览器对 file.value 只读,忽略 */ } // 顺带把已经写进去的裁剪矩形也清掉,避免"上次的框"跟着下一次提交 var f0 = cur.form || document; var h0 = f0.querySelector('input[type="hidden"][name$="_crop"]'); if (h0) { h0.value = ''; } } cur = null; } /** * 对外暴露当前「图片显示尺寸 + 裁剪框」,**只给测试用**。 * * ⚠️ 存在的理由:验证"滚轮真的缩放了"不能只看"有没有 wheel 事件"—— * 那证明不了库真的动了图片。有了这个探针,测试可以断言 * "滚轮前后 getBoundingClientRect 的宽度确实变了"。 */ window.__cropData = function () { if (!imgEl) { return null; } var d = cropper ? cropper.getData(true) : null; var im = cropper ? cropper.getImageData() : null; // ⚠️ 别用 imgEl.getBoundingClientRect() 量"显示尺寸":Cropper 把图片 // 放在 .cropper-canvas 里用 transform 缩放,img 自身的 rect 常常是 0。 // 真正代表"当前显示多大"的是 getImageData() 的 width/height(容器坐标)。 return {imgW: im ? Math.round(im.width) : 0, imgH: im ? Math.round(im.height) : 0, natW: im ? im.naturalWidth : 0, crop: d ? {x: d.x, y: d.y, w: d.width, h: d.height} : null}; }; function confirm() { if (!cropper || !cur) { close(); return; } var d = cropper.getData(true); // true = 取整 var img = cropper.getImageData(); var nw = img.naturalWidth || 1; var nh = img.naturalHeight || 1; // 归一化:服务端要的就是"相对原图的 0..1" function r3(v) { return Math.round(v * 1000) / 1000; } var x = r3(d.x / nw), y = r3(d.y / nh), w = r3(d.width / nw), h = r3(d.height / nh); var box = cur.closest ? cur.closest('.avatar-slot, .field, form') : null; var hidden = box ? box.querySelector('input[type="hidden"][name$="_crop"]') : null; if (!hidden) { // 兜底:找同一张表单里任何 *_crop 隐藏字段 var f = cur.form || document; hidden = f.querySelector('input[type="hidden"][name$="_crop"]'); } if (hidden) { hidden.value = [x, y, w, h].join(','); } // 选完给个即时反馈:预览图要显示**框里那块**,而不是原图。 // // ⚠️ 这里曾经只写了 `pv.src = objUrl`(原图)—— 于是"上车"的是整张原图, // 用户看到预览没有任何变化,会以为没裁上。用户当初的原话是 // 「确认后把框里那块画成预览(所见即所得)」,这是必须保住的行为。 // 做法:用 canvas 按 Cropper 给的**原图像素矩形**重新画一遍。 var prevSel = cur.getAttribute('data-crop-preview'); if (prevSel) { var pv = document.querySelector(prevSel); if (pv) { try { var cw = Math.max(1, Math.round(d.width)); var ch = Math.max(1, Math.round(d.height)); var cv = document.createElement('canvas'); cv.width = cw; cv.height = ch; cv.getContext('2d').drawImage( imgEl, Math.round(d.x), Math.round(d.y), cw, ch, // 源:框里那块(原图像素) 0, 0, cw, ch); // 目标:整张预览 pv.src = cv.toDataURL('image/png'); } catch (e) { // 画布被污染 / 内存不足等极端情况 → 退回原图,别让预览空着 if (objUrl) { pv.src = objUrl; } } } } close(); } function open(input, file) { cur = input; if (!overlay) { build(); } var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio')); var label = input.getAttribute('data-crop-label') || '图片'; var hint = input.getAttribute('data-crop-hint') || ''; overlay.querySelector('.crop-shape').textContent = label + (ratio === 1 ? '(正方形)' : '(' + ratio.toFixed(2) + ':1)'); hintEl.textContent = hint !== '' ? hint : '拖动图片挪位置,滚轮缩放;虚线框里就是最终保存的样子。'; if (objUrl) { URL.revokeObjectURL(objUrl); } objUrl = URL.createObjectURL(file); imgEl.src = objUrl; overlay.classList.add('is-open'); ensureCropper().then(function () { if (cropper) { cropper.destroy(); cropper = null; } cropper = new window.Cropper(imgEl, { // ★ 比例由槽位决定(头像 1:1、题图 16:9…),Cropper 会据此锁定缩放方向。 // ⚠️ 少了它,框能自由拉伸,"题图"就会被裁成任意比例。 aspectRatio: ratio, // ① 框不能超出画布,且图片自动缩放铺进容器(**不产生滚动条**) viewMode: 1, // ② 图片可以比"容器"小或大,但始终被约束在画布内 dragMode: 'move', // ③ 滚轮缩放(取代原来那根滑动条) zoomOnWheel: true, wheelZoomRatio: 0.06, // ④ 不许把图片缩到看不见 / 放到糊 minContainerWidth: 200, minContainerHeight: 200, minCropBoxWidth: 32, minCropBoxHeight: 32, // ⑤ 触点要**全露出来**:框四周始终留出可抓的边(见 CSS .cropper-* 手柄) autoCrop: true, autoCropArea: 0.86, center: true, responsive: true, restore: false, background: false, guides: true, highlight: false, movable: true, rotatable: false, scalable: false, zoomable: true, toggleDragModeOnDblclick: false, }); // 只给测试用:把实例露出去,便于用库 API 确定性地改变框尺寸 // (无头浏览器里 5×5 的触点很难稳定命中,靠鼠标拖会变成"测不稳")。 window.__cropperInst = cropper; }).catch(function (err) { // 拉不到库 → 退回"不裁剪":清空隐藏字段(服务端会**居中裁切**), // 并明确告诉用户,而不是默默地不裁。 if (window.console) { console.warn('Cropper.js 未加载,改为中心裁切:', err && err.message); } hintEl.textContent = '裁剪组件没加载出来 —— 保存时会自动居中裁切。'; var f = input.form || document; var hid = f.querySelector('input[type="hidden"][name$="_crop"]'); if (hid) { hid.value = ''; } }); } /** * 给**一个**文件框绑裁剪(幂等:已绑过的打标记跳过)。 * 只有带 `data-crop-ratio` 的框才需要裁剪 —— 其它 file input 不碰。 */ function bindOne(input) { if (!input || input.tagName !== 'INPUT' || input.type !== 'file') { return; } if (!input.hasAttribute('data-crop-ratio')) { return; } if (input.getAttribute('data-crop-bound') === '1') { return; } // 已绑,跳过 input.setAttribute('data-crop-bound', '1'); input.addEventListener('change', function () { var f = input.files && input.files[0]; if (!f) { return; } if (!/^image\//.test(f.type)) { alert('请选择图片文件。'); input.value = ''; return; } open(input, f); }); } /** 扫一遍(可限定 scope)并绑上所有还没绑的 `[data-crop-ratio]` 框 */ function scan(scope) { Array.prototype.forEach.call( (scope || document).querySelectorAll('input[type="file"][data-crop-ratio]'), bindOne); } scan(); // 首屏 /* 🔴 v50:**动态插进来的框也要接得住**(旅程「编辑旅程」是 AJAX 片段)。 ⚠️ 与上面「拖动上传」那段用同一招:MutationObserver 补挂。 子树监听:片段是塞进某个容器里的,框在子树深处。 */ if (window.MutationObserver) { new MutationObserver(function (muts) { muts.forEach(function (m) { Array.prototype.forEach.call(m.addedNodes || [], function (n) { if (!n.querySelectorAll) { return; } if (n.tagName === 'INPUT' && n.type === 'file') { bindOne(n); } scan(n); }); }); }).observe(document.body, { childList: true, subtree: true }); } })(); /** * 通用「点预览图 = 打开文件选择框」。 * * ⚠️ 为什么必须做成通用的(2026-10-05 第 15 批 用户第 3 条实测踩到): * 原来这句 `prev.addEventListener('click', () => file.click())` 只写在 * **templates/people/form.php 的内页脚本**里(那是"头像"专用的)。 * 后来加的「旅程题图」「图书封面」照抄了同一套 DOM(.cover-picker + * #cover-preview + 隐藏的 file input),却**没有对应的 JS** —— * 于是图块看着能点(CSS 给了 cursor:pointer 与 hover 高亮), * 点下去什么都不会发生,用户以为功能坏了(原话:"点击哪里都没反应")。 * * 做法:谁想"点图选文件",就在图上写 `data-pick-input="#文件框id"`, * 这里统一接管(一个页面有多个也各绑各的)。声明式,模板里不再各写一段 JS。 */ Array.prototype.forEach.call( document.querySelectorAll('[data-pick-input]'), function (pv) { var sel = pv.getAttribute('data-pick-input'); var file = sel ? document.querySelector(sel) : null; if (!file) { return; } // ⚠️ 用