名册每人一份 · 分组两层 · 拖动排序 · 表单合一 · 裁剪可缩放
按用户十点反馈逐条落地(v5,Schema::VERSION = 5): - 名册改成每人一份:新表 person_roster(owner_id, person_id, group_id, sort_order, source)。默认只看「我添加的 + 别人分享给我的」; source='seeded' 是升级时从老 persons.group_id 播进来的(先播数据再删列)。 - 排序不再手填:名册页直接拖动卡片(整版提交,一次 applyLayout 落库), 无 JS 时卡片上的「调整分组」表单照旧可用。 - 分组分两层:管理员在「系统设置」维护公共分组(owner_id=0), 用户在「个人设置」加自己的分组;显示顺序各人各定、互不影响。 - 名册可共享:把名片分享给别的用户(people/share);添加成员时若系统里 已有这个人,可直接复用(people/pick),不必重复录入。 - 「编辑成员」与「改我自己的资料」合并为同一张表(templates/people/form.php), 两个入口 href 完全相同,h1 按改谁切换文案;个人设置页只留 资料入口 + 称呼 + 我的分组三块,删除 templates/settings/_profile.php。 - 系统设置收进头像下拉,顶栏只剩 首页/家谱/名册。 - 裁剪器加缩放(滑块 1-4x、Ctrl+滚轮、还原):缩放只改视图大小, 落库的归一化矩形保持不变。 - 权限:管理员可以改任何人的权限(含自己),只拦「最后一位管理员」被降级。 - 去掉首页「我的一句话」后的链接、家谱说明段、以及「还没归到任何分组」提示。 迁移安全:数据回填(PersonRoster::seedFromPersons)先于删列执行、且不进 tryStep 容错;失败步骤记入 settings.schema_upgrade_warning 并在系统设置页提示。 线上已验证:schema_upgrade_warning 为空,person_roster/person_groups.owner_id 就位,persons.group_id / sort_order 已删。 验证:php -l 48 文件;本地 e2e 277/0、老库升级 81/0、桌面几何 108/0、 移动端几何 180/0、交互 96/0;反向验证 38 条注入 + 2 条缩放注入全部命中; 部署静态断言 53/0,站点自检 7/7(--resolve 锁本机走 443,探针真的进 PHP)。
This commit is contained in:
+71
-3
@@ -166,7 +166,7 @@ a:hover { text-decoration: underline; }
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
}
|
||||
.person {
|
||||
display: block; color: var(--ink);
|
||||
display: block; color: var(--ink); position: relative;
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
@@ -596,6 +596,65 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
.person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; }
|
||||
.person-move .btn { flex: 0 0 auto; }
|
||||
|
||||
/* ---------------- 名册:拖动排序 / 拖到别栏改分组 ----------------
|
||||
拖动是"锦上添花":没有它,卡片上的「⋯ → 调整分组」表单照样能改分组,
|
||||
手机上 HTML5 拖放本来也不生效,那套表单就是窄屏的正路。 */
|
||||
.roster.is-droppable .person { cursor: grab; }
|
||||
.roster.is-droppable .person.is-dragged { opacity: .45; cursor: grabbing; }
|
||||
.drag-handle {
|
||||
position: absolute; left: 8px; top: 8px; z-index: 2;
|
||||
width: 22px; height: 22px; line-height: 22px; text-align: center;
|
||||
border-radius: 6px; font-size: 14px;
|
||||
color: var(--muted); background: rgba(255, 255, 255, .82);
|
||||
border: 1px solid var(--line-2);
|
||||
pointer-events: none; /* 手柄只是个记号,抓取要落在整张卡上 */
|
||||
}
|
||||
.drag-handle-inline { color: var(--muted); }
|
||||
|
||||
/* 空栏平时收起来(七个预置分组可能只用到三个,摊开会显得名册破破烂烂)——
|
||||
但**拖动时必须露出来**,否则"想拖到一个现在没人的分组里"就没地方放。 */
|
||||
.group-block.is-empty { display: none; }
|
||||
.roster.is-dragging .group-block.is-empty {
|
||||
display: block; opacity: .9;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius); padding: 10px; margin-top: 18px;
|
||||
background: rgba(250, 247, 241, .6);
|
||||
}
|
||||
.group-drop-hint { display: none; margin-left: auto; font-weight: 400; font-size: 12px; color: var(--brand); }
|
||||
.roster.is-dragging .group-block .group-drop-hint { display: inline; }
|
||||
/* 拖动时把"有多少人"让出位置给落点提示,两者都挤在右上角会很乱 */
|
||||
.roster.is-dragging .group-block .group-n { display: none; }
|
||||
.roster.is-dragging .people-grid { min-height: 24px; }
|
||||
|
||||
/* 拖完/没拖成的轻提示(2 秒自己消失,不打断操作) */
|
||||
.roster-note {
|
||||
position: fixed; left: 50%; bottom: 28px; transform: translate(-50%, 12px);
|
||||
z-index: 200; padding: 8px 16px; border-radius: 999px;
|
||||
background: rgba(44, 35, 29, .88); color: #fff; font-size: 13px;
|
||||
opacity: 0; pointer-events: none; transition: opacity .18s ease, transform .18s ease;
|
||||
}
|
||||
.roster-note.is-show { opacity: 1; transform: translate(-50%, 0); }
|
||||
.roster-note.is-err { background: rgba(178, 45, 45, .92); }
|
||||
|
||||
/* ---------------- 分享名片(成员详情页) ---------------- */
|
||||
.share-wrap { display: inline-block; }
|
||||
.share-wrap > summary { list-style: none; cursor: pointer; display: inline-block; }
|
||||
.share-wrap > summary::-webkit-details-marker { display: none; }
|
||||
.share-wrap .inline-form { margin-top: 10px; }
|
||||
|
||||
/* ---------------- 「从家里已有的人里挑」 ---------------- */
|
||||
.pick-list { list-style: none; margin: 0 0 4px; padding: 0; display: grid; gap: 8px; }
|
||||
.pick-row { margin: 0; }
|
||||
.pick-item {
|
||||
display: flex; align-items: center; gap: 10px; cursor: pointer;
|
||||
padding: 8px 12px; border-radius: var(--radius-s);
|
||||
background: #FAF7F1; border: 1px solid var(--line-2);
|
||||
}
|
||||
.pick-item:hover { border-color: var(--brand); }
|
||||
.pick-item input[type="checkbox"] { width: 16px; height: 16px; flex: 0 0 auto; accent-color: var(--brand); }
|
||||
.pick-ph { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; background: #F1E7D6; }
|
||||
.pick-name { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* ---------------- 个人设置:分组顺序 ---------------- */
|
||||
.order-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.order-row {
|
||||
@@ -651,12 +710,21 @@ textarea.input { min-height: 76px; resize: vertical; }
|
||||
}
|
||||
.crop-head .crop-shape { font-size: 12px; font-weight: 400; color: var(--muted); }
|
||||
.crop-stage {
|
||||
position: relative; margin: 0 auto; overflow: hidden;
|
||||
position: relative; margin: 0 auto; overflow: auto;
|
||||
background: #F1E7D6; border-radius: var(--radius-s);
|
||||
touch-action: none; /* 手机上拖框时别把页面一起滚走 */
|
||||
user-select: none;
|
||||
}
|
||||
.crop-img { display: block; width: 100%; height: 100%; }
|
||||
/* 图片的宽高由 JS 按倍率写死(放大后比舞台大,靠舞台自身滚动看别处)——
|
||||
所以这里**不能**写 width/height:100%,否则缩放就永远没效果。 */
|
||||
.crop-img { display: block; max-width: none; }
|
||||
/* 缩放条 —— 用户明确要求"裁剪的时候要能缩放" */
|
||||
.crop-zoom-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-top: 12px; font-size: 13px; color: var(--muted);
|
||||
}
|
||||
.crop-zoom { flex: 1; min-width: 120px; accent-color: var(--brand); }
|
||||
.crop-zoom-val { font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; }
|
||||
/* 框外的部分压暗 —— 这是"你只留下框里这块"最直观的表达 */
|
||||
.crop-box {
|
||||
position: absolute; cursor: move;
|
||||
|
||||
+246
-12
@@ -30,6 +30,13 @@
|
||||
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
|
||||
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
|
||||
//
|
||||
// 缩放(用户明确要求「裁剪图片的时候,需要可以进行缩放」):
|
||||
// 舞台是个**固定大小的视口**(baseW×baseH),图片按 zoom 放大到 baseW*zoom,
|
||||
// 放不下就由舞台自己滚动。裁剪框的坐标一律相对**显示出来的图片**算,
|
||||
// 最后除以 dispW/dispH 换成 0..1 —— 所以放大多少倍都不影响落库的那个矩形。
|
||||
// 框的最小边长固定 32 显示像素,于是放大之后能选中原图上更小的区域,
|
||||
// 这正是"缩放"要解决的问题(合影里挑出某一个人的脸)。
|
||||
//
|
||||
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php):
|
||||
// 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。
|
||||
// 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。
|
||||
@@ -67,9 +74,15 @@
|
||||
'<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="">' +
|
||||
'<img class="crop-img" alt="" draggable="false">' +
|
||||
'<div class="crop-box"><i class="crop-handle" aria-hidden="true"></i></div>' +
|
||||
'</div>' +
|
||||
'<div class="crop-zoom-row">' +
|
||||
'<label for="cropZoom">缩放</label>' +
|
||||
'<input type="range" class="crop-zoom" id="cropZoom" min="1" max="4" step="0.05" value="1">' +
|
||||
'<span class="crop-zoom-val">1.0×</span>' +
|
||||
'<button type="button" class="btn btn-ghost btn-sm" data-act="zoom-reset">还原</button>' +
|
||||
'</div>' +
|
||||
'<p class="crop-tip">拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。</p>' +
|
||||
'<div class="crop-actions">' +
|
||||
'<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>' +
|
||||
@@ -86,10 +99,24 @@
|
||||
var act = e.target.getAttribute && e.target.getAttribute('data-act');
|
||||
if (act === 'cancel') { close(false); }
|
||||
if (act === 'ok') { close(true); }
|
||||
if (act === 'zoom-reset') { setZoom(1); }
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (overlay && overlay.parentNode && e.key === 'Escape') { close(false); }
|
||||
});
|
||||
|
||||
var zoomEl = overlay.querySelector('.crop-zoom');
|
||||
zoomEl.addEventListener('input', function () {
|
||||
var z = parseFloat(zoomEl.value);
|
||||
if (isFinite(z) && z > 0) { setZoom(z); }
|
||||
});
|
||||
// Ctrl/⌘ + 滚轮 缩放;**普通滚轮不拦**,留给舞台自己滚动(放大之后要能平移)
|
||||
stage.addEventListener('wheel', function (e) {
|
||||
if (!st || !(e.ctrlKey || e.metaKey)) { return; }
|
||||
e.preventDefault();
|
||||
setZoom(st.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
|
||||
}, { passive: false });
|
||||
|
||||
bindDrag(overlay.querySelector('.crop-handle'), 'resize');
|
||||
bindDrag(boxEl, 'move');
|
||||
}
|
||||
@@ -125,6 +152,7 @@
|
||||
}
|
||||
|
||||
var MIN = 32; // 裁剪框最小边长(显示像素),太小了没法拖
|
||||
var MAX_ZOOM = 4;
|
||||
|
||||
/** 把裁剪框放到指定位置,并夹在图片范围内 */
|
||||
function place(x, y, w, h) {
|
||||
@@ -140,6 +168,49 @@
|
||||
boxEl.style.height = h + 'px';
|
||||
}
|
||||
|
||||
/** 当前框的归一化矩形(相对**显示出来的图片**,与倍率无关) */
|
||||
function normOf() {
|
||||
if (!st || !st.dispW || !st.dispH) { return { x: 0, y: 0, w: 1, h: 1 }; }
|
||||
return {
|
||||
x: boxEl.offsetLeft / st.dispW,
|
||||
y: boxEl.offsetTop / st.dispH,
|
||||
w: boxEl.offsetWidth / st.dispW,
|
||||
h: boxEl.offsetHeight / st.dispH,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 改缩放。
|
||||
*
|
||||
* 关键:**框住的归一化区域保持不变**,只是把像素坐标整体重算 ——
|
||||
* 于是"放大"只让你看得更清楚、能框得更细,绝不会把已经选好的区域改掉。
|
||||
* 放大后把框滚回视野中间,不然用户会以为框不见了(框在滚动区外面)。
|
||||
*/
|
||||
function setZoom(z) {
|
||||
if (!st) { return; }
|
||||
z = Math.max(1, Math.min(MAX_ZOOM, z));
|
||||
var before = normOf();
|
||||
st.zoom = z;
|
||||
st.dispW = Math.max(1, Math.round(st.baseW * z));
|
||||
st.dispH = Math.max(1, Math.round(st.baseH * z));
|
||||
|
||||
imgEl.style.width = st.dispW + 'px';
|
||||
imgEl.style.height = st.dispH + 'px';
|
||||
// 舞台是**固定大小的视口**(不跟着图片变大),放不下就滚动
|
||||
stage.style.width = st.baseW + 'px';
|
||||
stage.style.height = st.baseH + 'px';
|
||||
|
||||
var zoomEl = overlay.querySelector('.crop-zoom');
|
||||
if (zoomEl) { zoomEl.value = String(z); }
|
||||
var valEl = overlay.querySelector('.crop-zoom-val');
|
||||
if (valEl) { valEl.textContent = z.toFixed(1) + '×'; }
|
||||
|
||||
place(before.x * st.dispW, before.y * st.dispH,
|
||||
before.w * st.dispW, before.h * st.dispH);
|
||||
stage.scrollLeft = Math.max(0, boxEl.offsetLeft - (st.baseW - boxEl.offsetWidth) / 2);
|
||||
stage.scrollTop = Math.max(0, boxEl.offsetTop - (st.baseH - boxEl.offsetHeight) / 2);
|
||||
}
|
||||
|
||||
function open(input, file) {
|
||||
if (!overlay) { build(); }
|
||||
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
|
||||
@@ -151,28 +222,41 @@
|
||||
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));
|
||||
var baseW = Math.max(1, Math.round(probe.naturalWidth * scale));
|
||||
var baseH = Math.max(1, Math.round(probe.naturalHeight * scale));
|
||||
|
||||
st = { input: input, ratio: ratio, dispW: dispW, dispH: dispH,
|
||||
st = { input: input, ratio: ratio,
|
||||
baseW: baseW, baseH: baseH, zoom: 1,
|
||||
dispW: baseW, dispH: baseH,
|
||||
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 };
|
||||
previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || baseW,
|
||||
outH: parseInt(input.getAttribute('data-crop-h'), 10) || baseH };
|
||||
|
||||
imgEl.src = url;
|
||||
stage.style.width = dispW + 'px';
|
||||
stage.style.height = dispH + 'px';
|
||||
imgEl.style.width = baseW + 'px';
|
||||
imgEl.style.height = baseH + 'px';
|
||||
stage.style.width = baseW + 'px';
|
||||
stage.style.height = baseH + 'px';
|
||||
stage.scrollLeft = 0;
|
||||
stage.scrollTop = 0;
|
||||
// ⚠️ 倍率每次打开都从 1× 开始:上一张图缩到 3× 不该带到这一张上,
|
||||
// 否则新图的框会莫名其妙是上一张的"放大版"。
|
||||
var zoomEl = overlay.querySelector('.crop-zoom');
|
||||
if (zoomEl) { zoomEl.value = '1'; }
|
||||
var valEl = overlay.querySelector('.crop-zoom-val');
|
||||
if (valEl) { valEl.textContent = '1.0×'; }
|
||||
|
||||
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);
|
||||
var w = baseW, h = w / ratio;
|
||||
if (h > baseH) { h = baseH; w = h * ratio; }
|
||||
place((baseW - w) / 2, (baseH - h) / 2, w, h);
|
||||
|
||||
// 记住预览图原来的 src:取消时恢复,否则预览会停在"没裁过的图"上
|
||||
var prev = preview(input);
|
||||
@@ -332,4 +416,154 @@
|
||||
idc.addEventListener('input', calc);
|
||||
calc();
|
||||
})();
|
||||
|
||||
// ===== 名册:拖动卡片改顺序 / 拖到别的一栏改分组 =====
|
||||
//
|
||||
// 落库的是**整版布局**(每栏的有序 id 列表),一次提交写完 ——
|
||||
// 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
|
||||
//
|
||||
// 没有这段 JS 也完全能用:卡片上的「⋯ → 调整分组」表单还在,
|
||||
// 而且手机上 HTML5 拖放本来就不生效,那套表单就是窄屏的正路。
|
||||
(function () {
|
||||
var roster = document.querySelector('.roster.is-droppable');
|
||||
if (!roster || !window.FormData || !window.fetch) { return; }
|
||||
var url = roster.getAttribute('data-reorder');
|
||||
var csrf = roster.getAttribute('data-csrf') || '';
|
||||
var dragging = null;
|
||||
var noteEl = null;
|
||||
|
||||
function each(list, fn) { Array.prototype.forEach.call(list, fn); }
|
||||
|
||||
/** 从事件目标往上找那张可拖的卡片(点在卡片里的链接/表单上时也算这张卡) */
|
||||
function cardFrom(e) {
|
||||
var t = e.target;
|
||||
while (t && t !== roster) {
|
||||
if (t.classList && t.classList.contains('person')
|
||||
&& t.getAttribute('draggable') === 'true') {
|
||||
return t;
|
||||
}
|
||||
t = t.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function blocks() {
|
||||
return roster.querySelectorAll('.group-block');
|
||||
}
|
||||
|
||||
/** 每栏人数与"空栏"标记跟着 DOM 走 —— 不然拖完之后标题上的「N 人」还是旧的 */
|
||||
function refreshCounts() {
|
||||
each(blocks(), function (b) {
|
||||
var n = b.querySelectorAll('.people-grid .person').length;
|
||||
var nEl = b.querySelector('.group-n .n');
|
||||
if (nEl) { nEl.textContent = String(n); }
|
||||
b.classList.toggle('is-empty', n === 0);
|
||||
});
|
||||
}
|
||||
|
||||
/** 右下角那个小提示(成功/失败都只活两秒,不打断操作) */
|
||||
function note(text, ok) {
|
||||
if (!noteEl) {
|
||||
noteEl = document.createElement('div');
|
||||
noteEl.className = 'roster-note';
|
||||
document.body.appendChild(noteEl);
|
||||
}
|
||||
noteEl.textContent = text;
|
||||
noteEl.className = 'roster-note is-show' + (ok ? '' : ' is-err');
|
||||
window.clearTimeout(note._t);
|
||||
note._t = window.setTimeout(function () {
|
||||
noteEl.className = 'roster-note';
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
function save() {
|
||||
var cols = [];
|
||||
each(blocks(), function (b) {
|
||||
var ids = [];
|
||||
each(b.querySelectorAll('.people-grid .person'), function (c) {
|
||||
var pid = parseInt(c.getAttribute('data-person'), 10);
|
||||
if (pid > 0) { ids.push(pid); }
|
||||
});
|
||||
cols.push({ g: parseInt(b.getAttribute('data-group'), 10) || 0, ids: ids });
|
||||
});
|
||||
|
||||
var body = new URLSearchParams();
|
||||
body.append('_csrf', csrf);
|
||||
body.append('layout', JSON.stringify(cols));
|
||||
|
||||
window.fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
// 让服务端知道这是 AJAX:它就回 JSON 而不是 302(不然 flash 会攒到下次刷新才冒出来)
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: body,
|
||||
}).then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
}).then(function (j) {
|
||||
if (j && j.ok) {
|
||||
note('已保存', true);
|
||||
} else {
|
||||
note('没保存上,刷新页面再试一次', false);
|
||||
}
|
||||
}).catch(function () {
|
||||
note('没保存上,刷新页面再试一次', false);
|
||||
});
|
||||
}
|
||||
|
||||
roster.addEventListener('dragstart', function (e) {
|
||||
var card = cardFrom(e);
|
||||
if (!card) { return; }
|
||||
// 「⋯」菜单里的下拉与按钮不参与拖动 —— 在那儿按下多半是想选项,不是想拖卡
|
||||
var t = e.target;
|
||||
while (t && t !== roster) {
|
||||
if (t.getAttribute && t.getAttribute('data-no-drag') !== null) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
t = t.parentNode;
|
||||
}
|
||||
dragging = card;
|
||||
roster.classList.add('is-dragging');
|
||||
card.classList.add('is-dragged');
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
try { e.dataTransfer.setData('text/plain', card.getAttribute('data-person')); } catch (err) { /* 老浏览器忽略 */ }
|
||||
}
|
||||
});
|
||||
|
||||
roster.addEventListener('dragover', function (e) {
|
||||
if (!dragging) { return; }
|
||||
var block = e.target && e.target.closest ? e.target.closest('.group-block') : null;
|
||||
if (!block) { return; }
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) { e.dataTransfer.dropEffect = 'move'; }
|
||||
var grid = block.querySelector('.people-grid');
|
||||
// 落点 = 指针在谁的上半部分,就插到谁前面(和列表拖拽的直觉一致)
|
||||
var after = null;
|
||||
each(grid.querySelectorAll('.person'), function (c) {
|
||||
if (after || c === dragging) { return; }
|
||||
var r = c.getBoundingClientRect();
|
||||
if (e.clientY < r.top + r.height / 2) { after = c; }
|
||||
});
|
||||
if (after) {
|
||||
grid.insertBefore(dragging, after);
|
||||
} else {
|
||||
grid.appendChild(dragging);
|
||||
}
|
||||
});
|
||||
|
||||
roster.addEventListener('drop', function (e) {
|
||||
// 不 preventDefault 的话浏览器会按"跳到某个地址"处理,整页就跳走了
|
||||
if (dragging) { e.preventDefault(); }
|
||||
});
|
||||
|
||||
roster.addEventListener('dragend', function () {
|
||||
if (!dragging) { return; }
|
||||
dragging.classList.remove('is-dragged');
|
||||
roster.classList.remove('is-dragging');
|
||||
dragging = null;
|
||||
refreshCounts();
|
||||
save();
|
||||
});
|
||||
})();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user