Mail: swipe a message left to delete, right to archive. Books: page flip by tap and swipe works on touch screens
The page frame is wider than the screen (pages sit side by side inside it), so tap positions were measured against the wrong width and taps on touch devices never turned the page. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
This commit is contained in:
4 files changed
+56
-3
No files matched your search
+45
-1
@@ -243,7 +243,7 @@ async function mailLoadList(reset) {
|
|||||||
}
|
}
|
||||||
function mailMarkSel(id) { document.querySelectorAll('a.mrow').forEach(a => a.classList.toggle('sel', !!id && decodeURIComponent(a.getAttribute('href').slice(9)) === id)); }
|
function mailMarkSel(id) { document.querySelectorAll('a.mrow').forEach(a => a.classList.toggle('sel', !!id && decodeURIComponent(a.getAttribute('href').slice(9)) === id)); }
|
||||||
function mailDrawList() {
|
function mailDrawList() {
|
||||||
const rows = $('#mrows'); if (!rows) return;
|
const rows = $('#mrows'); if (!rows) return; mailSwipeInit(rows);
|
||||||
const sel = (location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1];
|
const sel = (location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1];
|
||||||
rows.innerHTML = mail.items.length ? mail.items.map(m => `<a class="row mrow${m.read ? '' : ' unread'}${sel && decodeURIComponent(sel) === m.id ? ' sel' : ''}" href="#/mail/m/${encodeURIComponent(m.id)}">
|
rows.innerHTML = mail.items.length ? mail.items.map(m => `<a class="row mrow${m.read ? '' : ' unread'}${sel && decodeURIComponent(sel) === m.id ? ' sel' : ''}" href="#/mail/m/${encodeURIComponent(m.id)}">
|
||||||
<span class="grow"><b>${esc(mailFrom(m.from))}</b><span class="msub">${esc(m.subject)}</span><small>${esc(m.preview)}</small></span>
|
<span class="grow"><b>${esc(mailFrom(m.from))}</b><span class="msub">${esc(m.subject)}</span><small>${esc(m.preview)}</small></span>
|
||||||
@@ -252,6 +252,50 @@ function mailDrawList() {
|
|||||||
if ($('#mmb')) $('#mmb').onclick = () => mailLoadList(false);
|
if ($('#mmb')) $('#mmb').onclick = () => mailLoadList(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Swipe a message in the list: left deletes it, right archives it (touch screens). The row follows your finger and the colour under it shows what will happen;
|
||||||
|
// let go past a third of the way (or flick it) and it leaves at once and finishes in the background, otherwise it springs back.
|
||||||
|
function mailSwipeInit(rows) {
|
||||||
|
if (rows.dataset.swipe) return; rows.dataset.swipe = '1';
|
||||||
|
let row = null, x0 = 0, y0 = 0, dx = 0, t0 = 0, mode = '', w = 0;
|
||||||
|
const reset = (anim) => { if (!row) return; const r = row; r.style.transition = anim ? 'transform .18s ease-out' : ''; r.style.transform = ''; setTimeout(() => { r.classList.remove('swl', 'swr'); r.style.transition = ''; r.style.removeProperty('--sww'); }, anim ? 200 : 0); };
|
||||||
|
rows.addEventListener('touchstart', e => {
|
||||||
|
const a = e.target.closest && e.target.closest('a.mrow'); if (!a || e.touches.length !== 1) { row = null; return; }
|
||||||
|
row = a; x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; dx = 0; mode = ''; t0 = Date.now(); w = a.offsetWidth;
|
||||||
|
}, { passive: true });
|
||||||
|
rows.addEventListener('touchmove', e => {
|
||||||
|
if (!row) return; const t = e.touches[0], mx = t.clientX - x0, my = t.clientY - y0;
|
||||||
|
if (!mode) { if (Math.abs(my) > 12 && Math.abs(my) > Math.abs(mx)) { row = null; return; } if (Math.abs(mx) > 12 && Math.abs(mx) > Math.abs(my) * 1.4) { mode = 'swipe'; row.style.setProperty('--sww', w + 'px'); } else return; }
|
||||||
|
dx = mx; row.classList.toggle('swl', dx < 0); row.classList.toggle('swr', dx > 0); row.style.transition = ''; row.style.transform = `translateX(${dx}px)`;
|
||||||
|
row.dataset.sw = dx < 0 ? 'Delete' : 'Archive'; row.classList.toggle('swgo', Math.abs(dx) > w / 3);
|
||||||
|
}, { passive: true });
|
||||||
|
const end = () => {
|
||||||
|
if (!row) return; const r = row; row = null;
|
||||||
|
if (mode !== 'swipe') return;
|
||||||
|
const fast = Math.abs(dx) > 60 && Date.now() - t0 < 250; r.dataset.swiped = '1'; setTimeout(() => delete r.dataset.swiped, 400);
|
||||||
|
if (Math.abs(dx) > w / 3 || fast) {
|
||||||
|
const id = decodeURIComponent(r.getAttribute('href').slice(9)), kind = dx < 0 ? 'delete' : 'archive';
|
||||||
|
if (kind === 'delete' && /(^|~)deleteditems$/.test(mail.folder)) { row = r; reset(true); row = null; const it = mail.items.find(x => x.id === id); return confirmSheet('Delete for good?', it ? it.subject : '', 'Delete', () => mailRowAway(id, kind)); }
|
||||||
|
r.style.transition = 'transform .16s ease-in, opacity .16s'; r.style.transform = `translateX(${dx < 0 ? -w : w}px)`; r.style.opacity = '0.2';
|
||||||
|
setTimeout(() => mailRowAway(id, kind), 160);
|
||||||
|
} else { row = r; reset(true); row = null; }
|
||||||
|
};
|
||||||
|
rows.addEventListener('touchend', end); rows.addEventListener('touchcancel', () => { if (row) { reset(true); row = null; } });
|
||||||
|
rows.addEventListener('click', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.dataset.swiped) { e.preventDefault(); e.stopPropagation(); } }, true); // the finger lifting after a swipe is not a tap
|
||||||
|
}
|
||||||
|
function mailRowAway(id, kind) {
|
||||||
|
const at = mail.items.findIndex(x => x.id === id), had = mail.items[at]; if (!had) return mailDrawList();
|
||||||
|
const permanent = kind === 'delete' && /(^|~)deleteditems$/.test(mail.folder);
|
||||||
|
mail.items.splice(at, 1);
|
||||||
|
const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => x.id !== id), more: sv.more, next: sv.next });
|
||||||
|
if (mail.msg && mail.msg.id === id) { mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); }
|
||||||
|
mailDrawList();
|
||||||
|
if (!had.read && mailInInbox() && mailUnread > 0) { mailUnread--; renderNav(); }
|
||||||
|
toast(kind === 'archive' ? 'Archived' : permanent ? 'Deleted' : 'Moved to Deleted');
|
||||||
|
const refresh = () => { mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {}); };
|
||||||
|
jfetch('/api/mail/' + (kind === 'archive' ? 'move' : 'delete'), { method: 'POST', body: JSON.stringify(kind === 'archive' ? { id, to: 'archive' } : { id, ...(permanent ? { permanent: true } : {}) }) })
|
||||||
|
.then(refresh, e => { toast(e.message); if (!had.read && mailInInbox()) { mailUnread++; renderNav(); } mail.items.splice(Math.min(at, mail.items.length), 0, had); mailDrawList(); refresh(); });
|
||||||
|
}
|
||||||
|
|
||||||
// Opened messages are also kept on this device (IndexedDB, the newest 80), so one you have read opens at once, even offline. A message's text never
|
// Opened messages are also kept on this device (IndexedDB, the newest 80), so one you have read opens at once, even offline. A message's text never
|
||||||
// changes, so only its read/flagged state is taken fresh (from the list). Cleared with the rest of the saved mail when you disconnect or sign out.
|
// changes, so only its read/flagged state is taken fresh (from the list). Cleared with the rest of the saved mail when you disconnect or sign out.
|
||||||
const mbDB = () => mbDB.p || (mbDB.p = new Promise(ok => { try { const r = indexedDB.open('hub-mail', 1); r.onupgradeneeded = () => r.result.createObjectStore('b'); r.onsuccess = () => ok(r.result); r.onerror = () => ok(null); } catch { ok(null); } }));
|
const mbDB = () => mbDB.p || (mbDB.p = new Promise(ok => { try { const r = indexedDB.open('hub-mail', 1); r.onupgradeneeded = () => r.result.createObjectStore('b'); r.onsuccess = () => ok(r.result); r.onerror = () => ok(null); } catch { ok(null); } }));
|
||||||
|
|||||||
+3
-1
@@ -356,13 +356,15 @@ class RdEpub {
|
|||||||
doc.addEventListener('keydown', e => R.keys(e));
|
doc.addEventListener('keydown', e => R.keys(e));
|
||||||
doc.addEventListener('touchstart', e => { x0 = e.changedTouches[0].screenX; y0 = e.changedTouches[0].screenY; t0 = Date.now(); }, { passive: true });
|
doc.addEventListener('touchstart', e => { x0 = e.changedTouches[0].screenX; y0 = e.changedTouches[0].screenY; t0 = Date.now(); }, { passive: true });
|
||||||
doc.addEventListener('touchend', e => { const dx = e.changedTouches[0].screenX - x0, dy = e.changedTouches[0].screenY - y0; if (this.R.P.flow === 'paged' && Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - t0 < 700) dx < 0 ? this.next() : this.prev(); });
|
doc.addEventListener('touchend', e => { const dx = e.changedTouches[0].screenX - x0, dy = e.changedTouches[0].screenY - y0; if (this.R.P.flow === 'paged' && Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - t0 < 700) dx < 0 ? this.next() : this.prev(); });
|
||||||
doc.addEventListener('click', e => { if (e.target.closest && e.target.closest('a[href]')) return; const sel = contents.window.getSelection(); if (sel && !sel.isCollapsed) return; const w = contents.window.innerWidth; if (this.R.P.flow !== 'paged') return R.toggleBars(); if (e.clientX < w * 0.22) this.prev(); else if (e.clientX > w * 0.78) this.next(); else R.toggleBars(); });
|
doc.addEventListener('click', e => { if (e.target.closest && e.target.closest('a[href]')) return; const sel = contents.window.getSelection(); if (sel && !sel.isCollapsed) return; if (this.R.P.flow !== 'paged') return R.toggleBars(); const x = this.tapX(contents, e.clientX); if (x < 0.22) this.prev(); else if (x > 0.78) this.next(); else R.toggleBars(); });
|
||||||
doc.addEventListener('mousedown', () => R.hideSel());
|
doc.addEventListener('mousedown', () => R.hideSel());
|
||||||
});
|
});
|
||||||
const start = loc && !/^\d+$/.test(loc) ? loc : undefined;
|
const start = loc && !/^\d+$/.test(loc) ? loc : undefined;
|
||||||
try { await this.rend.display(start); } catch { await this.rend.display(); }
|
try { await this.rend.display(start); } catch { await this.rend.display(); }
|
||||||
for (const n of R.notes) this.drawNote(n);
|
for (const n of R.notes) this.drawNote(n);
|
||||||
}
|
}
|
||||||
|
/** Where a tap landed as a fraction of the reading area. The page frame is wider than the screen (the pages sit side by side inside it), so its own coordinates say nothing about the screen. */
|
||||||
|
tapX(contents, clientX) { const f = contents.window.frameElement.getBoundingClientRect(), s = this.stage.getBoundingClientRect(); return (f.left + clientX - s.left) / s.width; }
|
||||||
restyle(k) {
|
restyle(k) {
|
||||||
const R = this.R, P = R.P, t = rdTheme(P);
|
const R = this.R, P = R.P, t = rdTheme(P);
|
||||||
if (k === 'flow') { const loc = this.at && this.at.start.cfi; return this.build(loc); }
|
if (k === 'flow') { const loc = this.at && this.at.start.cfi; return this.build(loc); }
|
||||||
|
|||||||
@@ -874,3 +874,10 @@ kbd { display: inline-block; padding: 1px 7px; border: 1px solid var(--line); bo
|
|||||||
.rdseg { display: flex; gap: 6px; flex-wrap: wrap; } .rdseg button { flex: 1; min-height: 40px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 0 10px; white-space: nowrap; }
|
.rdseg { display: flex; gap: 6px; flex-wrap: wrap; } .rdseg button { flex: 1; min-height: 40px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 0 10px; white-space: nowrap; }
|
||||||
.rdseg button.on { background: var(--accent); color: var(--accent-t); border-color: transparent; font-weight: 600; }
|
.rdseg button.on { background: var(--accent); color: var(--accent-t); border-color: transparent; font-weight: 600; }
|
||||||
.rdstep { display: flex; align-items: center; gap: 12px; } .rdstep span { min-width: 56px; text-align: center; font-weight: 600; } .rdstep .btn { min-width: 64px; justify-content: center; }
|
.rdstep { display: flex; align-items: center; gap: 12px; } .rdstep span { min-width: 56px; text-align: center; font-weight: 600; } .rdstep .btn { min-width: 64px; justify-content: center; }
|
||||||
|
|
||||||
|
/* Mail: swipe a message left to delete, right to archive */
|
||||||
|
.mrow { position: relative; touch-action: pan-y; will-change: transform; background: var(--panel); }
|
||||||
|
.mrow.swl { box-shadow: var(--sww, 100vw) 0 0 var(--danger); } .mrow.swr { box-shadow: calc(-1 * var(--sww, 100vw)) 0 0 #2f9e6e; }
|
||||||
|
.mrow.swl.swgo { box-shadow: var(--sww, 100vw) 0 0 #b3261e; }
|
||||||
|
.mrow.swl::after, .mrow.swr::after { content: attr(data-sw); position: absolute; top: 0; bottom: 0; display: flex; align-items: center; color: #fff; font-weight: 600; padding: 0 20px; white-space: nowrap; }
|
||||||
|
.mrow.swl::after { left: 100%; } .mrow.swr::after { right: 100%; }
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v34';
|
const CACHE = 'hub-v35';
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user