图片可裁剪 · 名册按关系分组 · 个人设置独立 · 顶栏下拉菜单
① 上传图片一律可裁剪,比例由图位决定
新增 app/ImageSlot.php 统一配置(头像 1:1/512×512;题图 16:9 只留注释,
将来启用只加一行)。前端只挑区域、写回归一化矩形 "x,y,w,h",真正裁剪在服务端
Upload::fitRegion():夹回图内 → 在框内居中收缩到目标比例(不外扩)→ 钉住 sx+sw ≤ w。
比例解析单独写 parseRatioAttr():parseFloat('16:9') 得 16,会把题图裁成细长条且不报错。
② 名册按关系分组,顺序每人可自定
分类全家共用(person_groups,另一个人可以自己加分类);「自己的」只体现在顺序上
(user_settings.group_order,每人一份)。persons.group_id 故意不加外键(老库只能
ADD COLUMN),完整性由 PersonGroup 保证:删分类把人置回未分类,人不会从名册消失。
③ 设置分两级:系统设置 / 个人设置
个人设置拆成 templates/settings/me.php + _profile.php + _nicknames.php,
取代 templates/people/me.php(旧路由与旧表单仍兼容)。
④ 右上角「姓名/头像 + 下拉菜单」
原来是两个裸链接,窄屏会被 CSS 藏掉导致改密码/退出不可达;改成 <details> 零 JS 菜单
(app.js 只补「点外面 / Esc」)。菜单项:个人设置 / 系统设置(管理员)/ 修改密码 / 退出登录。
Schema 版本 3 → 4;安装与老库升级共用 PersonGroup::backfillMissing() 一条回填路径。
验证:php -l 全量;e2e 202/0(新增「关系分组」与「图片裁剪」两节);老库升级 49/0;
桌面几何 108/0;移动端几何 192/0(360/375/390/414);交互 76/0;
反向验证 31/31(逐条把修复改回旧写法,指定的目标断言必须变红);
部署后本地↔线上 72 个文件 sha256 逐字节一致。
This commit is contained in:
+167
-4
@@ -64,12 +64,42 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
||||
.who { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 14px; }
|
||||
a.who:hover { text-decoration: none; }
|
||||
/* 头像/名字是个「编辑我自己」的入口 —— hover 时给点反馈,让人知道它可点 */
|
||||
a.who:hover .who-name { color: var(--brand); text-decoration: underline; }
|
||||
a.who:hover img { box-shadow: 0 0 0 2px var(--brand-2); }
|
||||
/* 姓名 + 头像是个下拉菜单的「把手」——hover 时给点反馈,让人知道它可点 */
|
||||
.who-menu { position: relative; }
|
||||
.who-menu > summary { list-style: none; cursor: pointer; padding: 4px 6px; border-radius: 999px; }
|
||||
.who-menu > summary::-webkit-details-marker { display: none; }
|
||||
.who-menu > summary:hover { background: var(--line-2); }
|
||||
.who-menu > summary:hover .who-name { color: var(--brand); }
|
||||
.who-menu[open] > summary { background: var(--line-2); }
|
||||
.who-menu[open] > summary .caret { transform: rotate(180deg); }
|
||||
.who img { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; }
|
||||
.who img { transition: box-shadow .15s ease; }
|
||||
.who-menu > summary:hover img { box-shadow: 0 0 0 2px var(--brand-2); }
|
||||
/* 那个小三角:没有它,用户不知道"名字能点开" */
|
||||
.caret {
|
||||
width: 0; height: 0; margin-left: -2px;
|
||||
border-left: 4px solid transparent; border-right: 4px solid transparent;
|
||||
border-top: 5px solid var(--faint);
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
.menu-list {
|
||||
position: absolute; right: 0; top: calc(100% + 8px); z-index: 30;
|
||||
min-width: 148px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: var(--radius-s); box-shadow: var(--shadow-l);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.menu-list a {
|
||||
padding: 8px 12px; border-radius: 8px; color: var(--ink);
|
||||
font-size: 14px; white-space: nowrap;
|
||||
}
|
||||
.menu-list a:hover { background: var(--line-2); text-decoration: none; color: var(--brand); }
|
||||
/* 退出放在最下面并隔一条线:它是"离开",和上面几个"改点什么"不是一类动作 */
|
||||
.menu-list .menu-exit {
|
||||
margin-top: 4px; padding-top: 9px; border-top: 1px solid var(--line-2);
|
||||
border-radius: 0 0 8px 8px; color: var(--muted);
|
||||
}
|
||||
.menu-list .menu-exit:hover { color: var(--brand); }
|
||||
.link-quiet { color: var(--muted); font-size: 14px; }
|
||||
.link-quiet:hover { color: var(--brand); }
|
||||
|
||||
@@ -518,3 +548,136 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
.oss-diag-table th:nth-child(3),
|
||||
.oss-diag-table td:nth-child(3) { display: none; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
以下都是 2026-09-30 那一批反馈加的东西
|
||||
============================================================ */
|
||||
|
||||
/* 只给读屏用的标签:视觉上不占地,但无障碍名还在(别用 display:none,那会一起藏掉) */
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
/* 区块之间的小间隔(比写 style="margin-top:20px" 好读) */
|
||||
.stack-gap { height: 20px; }
|
||||
|
||||
/* ---------------- 名册:按关系分组分栏 ---------------- */
|
||||
.group-block { margin-top: 24px; }
|
||||
.group-head {
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
margin: 0 0 10px; padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 15px; font-weight: 600;
|
||||
}
|
||||
.group-head .group-title { color: var(--ink); }
|
||||
/* 分组名前面那道砖红短线,让分栏在长页面上更容易一眼定位 */
|
||||
.group-head .group-title::before {
|
||||
content: ''; display: inline-block; vertical-align: middle;
|
||||
width: 3px; height: 13px; margin-right: 8px; border-radius: 2px;
|
||||
background: var(--brand);
|
||||
}
|
||||
.group-head .group-n { margin-left: auto; font-weight: 400; font-size: 12px; color: var(--faint); }
|
||||
|
||||
/* 卡片改成 div > a + details:表单不能套在链接里(非法 HTML,浏览器会把 DOM 拆开) */
|
||||
.person-main { display: block; color: var(--ink); }
|
||||
.person-main:hover { text-decoration: none; }
|
||||
.person-more {
|
||||
border-top: 1px solid var(--line-2); background: #FCFAF6;
|
||||
}
|
||||
.person-more > summary {
|
||||
list-style: none; cursor: pointer; padding: 7px 14px;
|
||||
font-size: 12px; color: var(--muted);
|
||||
}
|
||||
.person-more > summary::-webkit-details-marker { display: none; }
|
||||
.person-more > summary::after { content: ' ▾'; }
|
||||
.person-more[open] > summary::after { content: ' ▴'; }
|
||||
.person-more > summary:hover { color: var(--brand); }
|
||||
.person-move { display: flex; gap: 6px; padding: 0 14px 12px; }
|
||||
.person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; }
|
||||
.person-move .btn { flex: 0 0 auto; }
|
||||
|
||||
/* ---------------- 个人设置:分组顺序 ---------------- */
|
||||
.order-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.order-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px; border-radius: var(--radius-s);
|
||||
background: #FAF7F1; border: 1px solid var(--line-2);
|
||||
}
|
||||
.order-no {
|
||||
flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%;
|
||||
background: var(--brand); color: #fff;
|
||||
font-size: 12px; line-height: 22px; text-align: center;
|
||||
}
|
||||
.order-name { flex: 1 1 auto; min-width: 0; font-size: 14px; }
|
||||
.order-count { flex: 0 0 auto; font-size: 12px; color: var(--faint); }
|
||||
.order-move { flex: 0 0 auto; display: flex; gap: 4px; }
|
||||
.order-move .btn { padding: 2px 8px; line-height: 1.4; }
|
||||
.order-move .btn[disabled] { opacity: .35; cursor: default; }
|
||||
|
||||
/* ---------------- 个人设置:关系分类管理 ---------------- */
|
||||
.inline-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.inline-form .input { flex: 1 1 200px; max-width: 320px; }
|
||||
.group-manage { list-style: none; margin: 16px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.group-manage-row {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 8px 12px; border-radius: var(--radius-s);
|
||||
background: #FAF7F1; border: 1px solid var(--line-2);
|
||||
}
|
||||
.group-rename { display: flex; gap: 8px; align-items: center; flex: 1 1 240px; min-width: 0; }
|
||||
.group-rename .input { flex: 1 1 auto; min-width: 0; max-width: 220px; padding: 6px 10px; font-size: 13px; }
|
||||
.group-manage-row .group-count { flex: 0 0 auto; }
|
||||
.del-wrap > summary { list-style: none; cursor: pointer; }
|
||||
.del-wrap[open] { flex: 1 1 100%; }
|
||||
.del-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 8px 0 2px; }
|
||||
.btn.danger { color: var(--red); border-color: rgba(192, 83, 63, .35); }
|
||||
.btn.danger:hover { background: rgba(192, 83, 63, .08); }
|
||||
|
||||
/* ---------------- 图片裁剪浮层 ---------------- */
|
||||
/* 覆盖层默认不占空间;打开时铺满整屏,把底下的页面锁住(裁剪时不该能点到别处) */
|
||||
.crop-overlay { display: none; }
|
||||
.crop-overlay.is-open {
|
||||
display: flex; position: fixed; inset: 0; z-index: 100;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 20px; background: rgba(44, 35, 29, .55);
|
||||
}
|
||||
.crop-panel {
|
||||
background: var(--surface); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-l); padding: 16px;
|
||||
max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); overflow: auto;
|
||||
}
|
||||
.crop-head {
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
font-size: 15px; font-weight: 600; margin-bottom: 12px;
|
||||
}
|
||||
.crop-head .crop-shape { font-size: 12px; font-weight: 400; color: var(--muted); }
|
||||
.crop-stage {
|
||||
position: relative; margin: 0 auto; overflow: hidden;
|
||||
background: #F1E7D6; border-radius: var(--radius-s);
|
||||
touch-action: none; /* 手机上拖框时别把页面一起滚走 */
|
||||
user-select: none;
|
||||
}
|
||||
.crop-img { display: block; width: 100%; height: 100%; }
|
||||
/* 框外的部分压暗 —— 这是"你只留下框里这块"最直观的表达 */
|
||||
.crop-box {
|
||||
position: absolute; cursor: move;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 9999px rgba(44, 35, 29, .5);
|
||||
}
|
||||
.crop-box::before {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
border: 1px dashed rgba(255, 255, 255, .75); pointer-events: none;
|
||||
}
|
||||
.crop-handle {
|
||||
position: absolute; right: -8px; bottom: -8px;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: #fff; border: 2px solid var(--brand);
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.crop-tip { color: var(--muted); font-size: 12px; margin: 12px 0 0; }
|
||||
.crop-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 14px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.order-move .btn { padding: 4px 10px; }
|
||||
.group-manage-row { align-items: flex-start; }
|
||||
.crop-panel { padding: 12px; }
|
||||
}
|
||||
|
||||
+243
-2
@@ -26,9 +26,240 @@
|
||||
for (var i = 0; i < pickers.length; i++) { hook(pickers[i]); }
|
||||
})();
|
||||
|
||||
// 头像选好后即时预览,别等上传完才知道选错了
|
||||
// ===== 图片裁剪 =====
|
||||
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
|
||||
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
|
||||
//
|
||||
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php):
|
||||
// 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。
|
||||
// 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。
|
||||
(function () {
|
||||
var inputs = Array.prototype.slice.call(
|
||||
document.querySelectorAll('input[type="file"][data-crop-ratio]'));
|
||||
if (!inputs.length) { return; }
|
||||
|
||||
var overlay = null, stage = null, imgEl = null, boxEl = null;
|
||||
var st = null; // 当前编辑状态
|
||||
|
||||
/**
|
||||
* 解析 data-crop-ratio。
|
||||
*
|
||||
* ⚠️ 不能直接 parseFloat:'16:9' 会被读成 **16**(parseFloat 只吃开头那段数字),
|
||||
* 于是裁剪框变成 16:1 的细长条,而钩子本身不报错、只是裁出来的图没法看。
|
||||
* 头像的 '1:1' 恰好蒙对(=1),所以这个坑会潜伏到"启用题图"那天才炸。
|
||||
* 两种写法都支持:'16:9' / '16/9' 按分数算,纯数字按它本身算。
|
||||
*/
|
||||
function parseRatioAttr(v) {
|
||||
var s = String(v == null ? '' : v).trim();
|
||||
var m = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/.exec(s);
|
||||
if (m) {
|
||||
var a = parseFloat(m[1]), b = parseFloat(m[2]);
|
||||
return (isFinite(a) && isFinite(b) && b > 0 && a > 0) ? a / b : 1;
|
||||
}
|
||||
var f = parseFloat(s);
|
||||
return (isFinite(f) && f > 0) ? f : 1;
|
||||
}
|
||||
|
||||
function build() {
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'crop-overlay';
|
||||
overlay.innerHTML =
|
||||
'<div class="crop-panel" role="dialog" aria-modal="true" aria-label="裁剪图片">' +
|
||||
'<div class="crop-head"><b>裁剪图片</b><span class="crop-shape"></span></div>' +
|
||||
'<div class="crop-stage">' +
|
||||
'<img class="crop-img" alt="">' +
|
||||
'<div class="crop-box"><i class="crop-handle" aria-hidden="true"></i></div>' +
|
||||
'</div>' +
|
||||
'<p class="crop-tip">拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。</p>' +
|
||||
'<div class="crop-actions">' +
|
||||
'<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>' +
|
||||
'<button type="button" class="btn" data-act="ok">确定裁剪</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(overlay);
|
||||
stage = overlay.querySelector('.crop-stage');
|
||||
imgEl = overlay.querySelector('.crop-img');
|
||||
boxEl = overlay.querySelector('.crop-box');
|
||||
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) { close(false); return; }
|
||||
var act = e.target.getAttribute && e.target.getAttribute('data-act');
|
||||
if (act === 'cancel') { close(false); }
|
||||
if (act === 'ok') { close(true); }
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (overlay && overlay.parentNode && e.key === 'Escape') { close(false); }
|
||||
});
|
||||
bindDrag(overlay.querySelector('.crop-handle'), 'resize');
|
||||
bindDrag(boxEl, 'move');
|
||||
}
|
||||
|
||||
/** 让元素可拖动。pointer 事件同时覆盖鼠标与触摸,不用分别写两套。 */
|
||||
function bindDrag(el, mode) {
|
||||
el.addEventListener('pointerdown', function (e) {
|
||||
if (!st || e.button > 0) { return; }
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try { el.setPointerCapture(e.pointerId); } catch (err) { /* 老浏览器忽略 */ }
|
||||
var x0 = e.clientX, y0 = e.clientY;
|
||||
var b0 = { x: boxEl.offsetLeft, y: boxEl.offsetTop, w: boxEl.offsetWidth, h: boxEl.offsetHeight };
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - x0, dy = ev.clientY - y0;
|
||||
if (mode === 'move') {
|
||||
place(b0.x + dx, b0.y + dy, b0.w, b0.h);
|
||||
} else {
|
||||
// 缩放时**锁定比例**:以宽为准推高,宽被夹住时高也跟着被夹住
|
||||
var nw = b0.w + dx;
|
||||
place(b0.x, b0.y, nw, nw / st.ratio);
|
||||
}
|
||||
}
|
||||
function onUp() {
|
||||
el.removeEventListener('pointermove', onMove);
|
||||
el.removeEventListener('pointerup', onUp);
|
||||
el.removeEventListener('pointercancel', onUp);
|
||||
}
|
||||
el.addEventListener('pointermove', onMove);
|
||||
el.addEventListener('pointerup', onUp);
|
||||
el.addEventListener('pointercancel', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
var MIN = 32; // 裁剪框最小边长(显示像素),太小了没法拖
|
||||
|
||||
/** 把裁剪框放到指定位置,并夹在图片范围内 */
|
||||
function place(x, y, w, h) {
|
||||
w = Math.max(MIN, Math.min(w, st.dispW));
|
||||
h = Math.max(MIN * st.ratio, Math.min(h, st.dispH));
|
||||
// 比例锁定后再夹一次高度(宽高比可能因为上面的夹取而失配)
|
||||
if (w / h > st.ratio) { w = h * st.ratio; } else { h = w / st.ratio; }
|
||||
x = Math.max(0, Math.min(x, st.dispW - w));
|
||||
y = Math.max(0, Math.min(y, st.dispH - h));
|
||||
boxEl.style.left = x + 'px';
|
||||
boxEl.style.top = y + 'px';
|
||||
boxEl.style.width = w + 'px';
|
||||
boxEl.style.height = h + 'px';
|
||||
}
|
||||
|
||||
function open(input, file) {
|
||||
if (!overlay) { build(); }
|
||||
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
|
||||
var url = URL.createObjectURL(file);
|
||||
var probe = new Image();
|
||||
probe.onload = function () {
|
||||
// 图片按「等比缩放到可用区域」后显示;裁剪框的坐标都相对这块显示区域,
|
||||
// 最后再除以它换成 0..1 —— 所以原图多大都不影响结果。
|
||||
var availW = Math.min(window.innerWidth * 0.86, 620);
|
||||
var availH = Math.min(window.innerHeight * 0.52, 420);
|
||||
var scale = Math.min(availW / probe.naturalWidth, availH / probe.naturalHeight, 1);
|
||||
var dispW = Math.max(1, Math.round(probe.naturalWidth * scale));
|
||||
var dispH = Math.max(1, Math.round(probe.naturalHeight * scale));
|
||||
|
||||
st = { input: input, ratio: ratio, dispW: dispW, dispH: dispH,
|
||||
naturalW: probe.naturalWidth, naturalH: probe.naturalHeight,
|
||||
previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || dispW,
|
||||
outH: parseInt(input.getAttribute('data-crop-h'), 10) || dispH };
|
||||
|
||||
imgEl.src = url;
|
||||
stage.style.width = dispW + 'px';
|
||||
stage.style.height = dispH + 'px';
|
||||
var shape = overlay.querySelector('.crop-shape');
|
||||
shape.textContent = input.getAttribute('data-crop-label') || '';
|
||||
overlay.querySelector('.crop-tip').textContent =
|
||||
input.getAttribute('data-crop-hint') ||
|
||||
'拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。';
|
||||
|
||||
// 默认框 = 图里最大的、符合目标比例的那块(居中)—— 什么都不动直接确定,
|
||||
// 效果就等于老的「居中裁切」,所以默认值是安全的。
|
||||
var w = dispW, h = w / ratio;
|
||||
if (h > dispH) { h = dispH; w = h * ratio; }
|
||||
place((dispW - w) / 2, (dispH - h) / 2, w, h);
|
||||
|
||||
// 记住预览图原来的 src:取消时恢复,否则预览会停在"没裁过的图"上
|
||||
var prev = preview(input);
|
||||
st.previewSrc = prev ? prev.src : null;
|
||||
st._url = url;
|
||||
overlay.classList.add('is-open');
|
||||
};
|
||||
probe.onerror = function () {
|
||||
URL.revokeObjectURL(url);
|
||||
alert('这张图片读不出来,换一张试试。');
|
||||
input.value = '';
|
||||
};
|
||||
probe.src = url;
|
||||
}
|
||||
|
||||
function preview(input) {
|
||||
var sel = input.getAttribute('data-crop-preview');
|
||||
return sel ? document.querySelector(sel) : null;
|
||||
}
|
||||
|
||||
function close(ok) {
|
||||
if (!st) { return; }
|
||||
if (ok) {
|
||||
var nx = boxEl.offsetLeft / st.dispW;
|
||||
var ny = boxEl.offsetTop / st.dispH;
|
||||
var nw = boxEl.offsetWidth / st.dispW;
|
||||
var nh = boxEl.offsetHeight / st.dispH;
|
||||
// 写回归一化矩形 —— 服务端按它裁,与源图分辨率无关
|
||||
var hidden = hiddenFor(st.input);
|
||||
if (hidden) {
|
||||
hidden.value = [nx, ny, nw, nh].map(function (v) {
|
||||
return (Math.round(v * 1e6) / 1e6).toFixed(6);
|
||||
}).join(',');
|
||||
}
|
||||
drawPreview();
|
||||
} else {
|
||||
st.input.value = ''; // 取消 = 不换图,别让它悄悄传上去被居中裁
|
||||
var prev = preview(st.input);
|
||||
if (prev && st.previewSrc !== null) { prev.src = st.previewSrc; }
|
||||
var hid = hiddenFor(st.input);
|
||||
if (hid) { hid.value = ''; }
|
||||
}
|
||||
overlay.classList.remove('is-open');
|
||||
if (st._url) { URL.revokeObjectURL(st._url); }
|
||||
st = null;
|
||||
}
|
||||
|
||||
/** 按当前框把结果画出来当预览 —— 让用户看到"框里那块"长什么样 */
|
||||
function drawPreview() {
|
||||
var prev = preview(st.input);
|
||||
if (!prev) { return; }
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = st.outW;
|
||||
canvas.height = st.outH;
|
||||
var g = canvas.getContext('2d');
|
||||
var sx = boxEl.offsetLeft / st.dispW * st.naturalW;
|
||||
var sy = boxEl.offsetTop / st.dispH * st.naturalH;
|
||||
var sw = boxEl.offsetWidth / st.dispW * st.naturalW;
|
||||
var sh = boxEl.offsetHeight / st.dispH * st.naturalH;
|
||||
try {
|
||||
g.drawImage(imgEl, sx, sy, sw, sh, 0, 0, st.outW, st.outH);
|
||||
prev.src = canvas.toDataURL('image/jpeg', 0.9);
|
||||
} catch (err) {
|
||||
// 跨域图片会让 canvas 变"污染",这里都是本地 ObjectURL,正常不会发生
|
||||
}
|
||||
}
|
||||
|
||||
/** 同表单里那个存裁剪矩形的隐藏字段 */
|
||||
function hiddenFor(input) {
|
||||
var form = input.form;
|
||||
if (!form) { return null; }
|
||||
return form.querySelector('input[name="' + input.name + '_crop"]');
|
||||
}
|
||||
|
||||
inputs.forEach(function (input) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
|
||||
var fileInput = document.getElementById('avatar');
|
||||
if (fileInput && window.FileReader) {
|
||||
if (fileInput && !fileInput.hasAttribute('data-crop-ratio') && window.FileReader) {
|
||||
fileInput.addEventListener('change', function () {
|
||||
var f = this.files && this.files[0];
|
||||
if (!f) { return; }
|
||||
@@ -40,6 +271,16 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 下拉菜单:点外面 / 按 Esc 就收起来(用 <details> 实现,没这段也能用)
|
||||
Array.prototype.forEach.call(document.querySelectorAll('details.menu'), function (d) {
|
||||
document.addEventListener('click', function (e) {
|
||||
if (d.open && !d.contains(e.target)) { d.open = false; }
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (d.open && e.key === 'Escape') { d.open = false; }
|
||||
});
|
||||
});
|
||||
|
||||
// 头像加载失败时换回内置剪影 —— 保证任何时候都不会出现破图
|
||||
Array.prototype.forEach.call(document.querySelectorAll('img'), function (img) {
|
||||
img.addEventListener('error', function () {
|
||||
|
||||
Reference in New Issue
Block a user