| 1 |
|
| 2 |
* Balanced Breakfast - Mobile Interaction Wiring |
| 3 |
* Connects touch.js gesture utilities to list views using event delegation. |
| 4 |
* Handles swipe-to-action, pull-to-refresh, and long-press selection. |
| 5 |
* All no-ops on non-touch devices. |
| 6 |
|
| 7 |
|
| 8 |
(function() { |
| 9 |
'use strict'; |
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
if (!BB.touch?.isTouchDevice) return; |
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
* Add delegated swipe handling to a container. Tracks the swiped row |
| 20 |
* internally so DOM recycling doesn't cause issues. |
| 21 |
* |
| 22 |
* @param {HTMLElement} container - Scrollable container (e.g. #items-list) |
| 23 |
* @param {Object} config |
| 24 |
* @param {string} config.rowSelector - CSS selector for swipeable rows |
| 25 |
* @param {Function} config.getActions - (rowEl) => { left?: { action }, right?: { action } } | null |
| 26 |
* @param {number} [config.threshold=80] - Pixels to trigger action |
| 27 |
|
| 28 |
function addSwipeDelegate(container, config) { |
| 29 |
const threshold = config.threshold || 80; |
| 30 |
let activeRow = null; |
| 31 |
let startX = 0; |
| 32 |
let startY = 0; |
| 33 |
let currentX = 0; |
| 34 |
let isDragging = false; |
| 35 |
let isHorizontal = null; |
| 36 |
let actions = null; |
| 37 |
|
| 38 |
container.addEventListener('touchstart', function(e) { |
| 39 |
const row = e.target.closest(config.rowSelector); |
| 40 |
if (!row) return; |
| 41 |
|
| 42 |
actions = config.getActions(row); |
| 43 |
if (!actions) return; |
| 44 |
|
| 45 |
activeRow = row; |
| 46 |
const touch = e.touches[0]; |
| 47 |
startX = touch.clientX; |
| 48 |
startY = touch.clientY; |
| 49 |
currentX = 0; |
| 50 |
isDragging = true; |
| 51 |
isHorizontal = null; |
| 52 |
activeRow.style.transition = 'none'; |
| 53 |
}, { passive: true }); |
| 54 |
|
| 55 |
container.addEventListener('touchmove', function(e) { |
| 56 |
if (!isDragging || !activeRow) return; |
| 57 |
|
| 58 |
const touch = e.touches[0]; |
| 59 |
const dx = touch.clientX - startX; |
| 60 |
const dy = touch.clientY - startY; |
| 61 |
|
| 62 |
|
| 63 |
if (isHorizontal === null) { |
| 64 |
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) { |
| 65 |
isHorizontal = Math.abs(dx) > Math.abs(dy); |
| 66 |
} |
| 67 |
if (!isHorizontal) return; |
| 68 |
} |
| 69 |
if (!isHorizontal) return; |
| 70 |
|
| 71 |
e.preventDefault(); |
| 72 |
currentX = dx; |
| 73 |
|
| 74 |
|
| 75 |
if (dx < 0 && !actions.left) currentX = 0; |
| 76 |
if (dx > 0 && !actions.right) currentX = 0; |
| 77 |
|
| 78 |
|
| 79 |
const maxSwipe = threshold * 1.5; |
| 80 |
if (Math.abs(currentX) > threshold) { |
| 81 |
const overshoot = Math.abs(currentX) - threshold; |
| 82 |
const dampened = threshold + overshoot * 0.3; |
| 83 |
currentX = currentX > 0 ? Math.min(dampened, maxSwipe) : Math.max(-dampened, -maxSwipe); |
| 84 |
} |
| 85 |
|
| 86 |
activeRow.style.transform = `translateX(${currentX}px)`; |
| 87 |
}, { passive: false }); |
| 88 |
|
| 89 |
function onEnd() { |
| 90 |
if (!isDragging || !activeRow) return; |
| 91 |
isDragging = false; |
| 92 |
|
| 93 |
activeRow.style.transition = 'transform 0.2s ease'; |
| 94 |
|
| 95 |
if (Math.abs(currentX) >= threshold) { |
| 96 |
if (currentX < 0 && actions?.left?.action) { |
| 97 |
actions.left.action(); |
| 98 |
} else if (currentX > 0 && actions?.right?.action) { |
| 99 |
actions.right.action(); |
| 100 |
} |
| 101 |
} |
| 102 |
|
| 103 |
activeRow.style.transform = 'translateX(0)'; |
| 104 |
activeRow = null; |
| 105 |
actions = null; |
| 106 |
currentX = 0; |
| 107 |
isHorizontal = null; |
| 108 |
} |
| 109 |
|
| 110 |
container.addEventListener('touchend', onEnd, { passive: true }); |
| 111 |
container.addEventListener('touchcancel', onEnd, { passive: true }); |
| 112 |
} |
| 113 |
|
| 114 |
|
| 115 |
|
| 116 |
|
| 117 |
* Add delegated long-press handling to a container. |
| 118 |
* @param {HTMLElement} container |
| 119 |
* @param {string} rowSelector - CSS selector for pressable rows |
| 120 |
* @param {Function} onLongPress - (rowEl) => void |
| 121 |
|
| 122 |
function addLongPressDelegate(container, rowSelector, onLongPress) { |
| 123 |
let timer = null; |
| 124 |
let startX = 0; |
| 125 |
let startY = 0; |
| 126 |
let activeRow = null; |
| 127 |
const MOVE_THRESHOLD = 10; |
| 128 |
const DURATION = 500; |
| 129 |
|
| 130 |
container.addEventListener('touchstart', function(e) { |
| 131 |
const row = e.target.closest(rowSelector); |
| 132 |
if (!row) return; |
| 133 |
activeRow = row; |
| 134 |
|
| 135 |
const touch = e.touches[0]; |
| 136 |
startX = touch.clientX; |
| 137 |
startY = touch.clientY; |
| 138 |
|
| 139 |
timer = setTimeout(() => { |
| 140 |
timer = null; |
| 141 |
|
| 142 |
activeRow.addEventListener('click', function prevent(ev) { |
| 143 |
ev.preventDefault(); |
| 144 |
ev.stopPropagation(); |
| 145 |
}, { once: true, capture: true }); |
| 146 |
onLongPress(activeRow); |
| 147 |
}, DURATION); |
| 148 |
}, { passive: true }); |
| 149 |
|
| 150 |
container.addEventListener('touchmove', function(e) { |
| 151 |
if (!timer) return; |
| 152 |
const touch = e.touches[0]; |
| 153 |
if (Math.abs(touch.clientX - startX) > MOVE_THRESHOLD || |
| 154 |
Math.abs(touch.clientY - startY) > MOVE_THRESHOLD) { |
| 155 |
clearTimeout(timer); |
| 156 |
timer = null; |
| 157 |
} |
| 158 |
}, { passive: true }); |
| 159 |
|
| 160 |
function cancel() { |
| 161 |
if (timer) { |
| 162 |
clearTimeout(timer); |
| 163 |
timer = null; |
| 164 |
} |
| 165 |
activeRow = null; |
| 166 |
} |
| 167 |
|
| 168 |
container.addEventListener('touchend', cancel, { passive: true }); |
| 169 |
container.addEventListener('touchcancel', cancel, { passive: true }); |
| 170 |
} |
| 171 |
|
| 172 |
|
| 173 |
|
| 174 |
function init() { |
| 175 |
wireItemSwipe(); |
| 176 |
wirePullToRefresh(); |
| 177 |
wireLongPress(); |
| 178 |
wireDetailSwipe(); |
| 179 |
wireModalDismiss(); |
| 180 |
wireSourceLongPress(); |
| 181 |
} |
| 182 |
|
| 183 |
|
| 184 |
|
| 185 |
function wireItemSwipe() { |
| 186 |
const container = document.getElementById('items-list'); |
| 187 |
if (!container) return; |
| 188 |
|
| 189 |
addSwipeDelegate(container, { |
| 190 |
rowSelector: '.item', |
| 191 |
getActions: (row) => { |
| 192 |
const id = row.dataset.id; |
| 193 |
if (!id) return null; |
| 194 |
const item = BB.state.items.find(i => i.id === id); |
| 195 |
if (!item) return null; |
| 196 |
return { |
| 197 |
right: { action: () => BB.items.toggleStar(id, item.isStarred) }, |
| 198 |
left: { action: () => BB.items.toggleRead(id, item.isRead) }, |
| 199 |
}; |
| 200 |
}, |
| 201 |
}); |
| 202 |
} |
| 203 |
|
| 204 |
|
| 205 |
|
| 206 |
function wirePullToRefresh() { |
| 207 |
const itemsList = document.getElementById('items-list'); |
| 208 |
const sourcesList = document.getElementById('sources-list'); |
| 209 |
if (itemsList) BB.touch.addPullToRefresh(itemsList, () => BB.feeds.refresh()); |
| 210 |
if (sourcesList) BB.touch.addPullToRefresh(sourcesList, () => BB.feeds.refresh()); |
| 211 |
} |
| 212 |
|
| 213 |
|
| 214 |
|
| 215 |
function wireLongPress() { |
| 216 |
const container = document.getElementById('items-list'); |
| 217 |
if (!container) return; |
| 218 |
|
| 219 |
addLongPressDelegate(container, '.item', (row) => { |
| 220 |
const id = row.dataset.id; |
| 221 |
const item = BB.state.items.find(i => i.id === id); |
| 222 |
if (!item) return; |
| 223 |
showItemActionSheet(item); |
| 224 |
}); |
| 225 |
} |
| 226 |
|
| 227 |
|
| 228 |
|
| 229 |
function wireDetailSwipe() { |
| 230 |
const detail = document.getElementById('detail-panel'); |
| 231 |
if (!detail) return; |
| 232 |
|
| 233 |
BB.touch.addSwipeNavigation(detail, { |
| 234 |
onRight: () => BB.detail.close(), |
| 235 |
}); |
| 236 |
} |
| 237 |
|
| 238 |
|
| 239 |
|
| 240 |
function wireModalDismiss() { |
| 241 |
const modal = document.querySelector('.modal-content'); |
| 242 |
if (!modal) return; |
| 243 |
|
| 244 |
BB.touch.addDragToDismiss(modal, () => BB.ui.closeModal()); |
| 245 |
} |
| 246 |
|
| 247 |
|
| 248 |
|
| 249 |
function wireSourceLongPress() { |
| 250 |
const container = document.getElementById('sources-list'); |
| 251 |
if (!container) return; |
| 252 |
addLongPressDelegate(container, '.source-item', (row) => { |
| 253 |
const sourceId = row.dataset.source; |
| 254 |
if (!sourceId) return; |
| 255 |
const source = BB.state.sources.find(s => s.id === sourceId); |
| 256 |
if (!source) return; |
| 257 |
showSourceActionSheet(source); |
| 258 |
}); |
| 259 |
} |
| 260 |
|
| 261 |
|
| 262 |
* Show a mobile action sheet for a source (edit, tags, delete). |
| 263 |
* @param {Object} source - Source object with id and name. |
| 264 |
|
| 265 |
function showSourceActionSheet(source) { |
| 266 |
const body = document.getElementById('modal-body'); |
| 267 |
const title = document.getElementById('modal-title'); |
| 268 |
title.textContent = source.name; |
| 269 |
body.innerHTML = ''; |
| 270 |
const actions = [ |
| 271 |
{ label: 'Edit Feed', action: () => BB.sources.editFeed(source) }, |
| 272 |
{ label: 'Edit Tags', action: () => BB.sources.editTags(source) }, |
| 273 |
{ label: 'Delete', action: () => BB.sources.deleteFeed(source), danger: true }, |
| 274 |
]; |
| 275 |
|
| 276 |
|
| 277 |
|
| 278 |
for (const a of actions) { |
| 279 |
const btn = document.createElement('button'); |
| 280 |
btn.className = 'btn btn-stacked' + (a.danger ? ' btn-danger' : ''); |
| 281 |
btn.textContent = a.label; |
| 282 |
btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); }); |
| 283 |
body.appendChild(btn); |
| 284 |
} |
| 285 |
BB.ui.openModal(); |
| 286 |
} |
| 287 |
|
| 288 |
|
| 289 |
|
| 290 |
|
| 291 |
* Show a mobile action sheet for an item (star, read, open). |
| 292 |
* @param {Object} item - Item object with id, isStarred, isRead, url. |
| 293 |
|
| 294 |
function showItemActionSheet(item) { |
| 295 |
const body = document.getElementById('modal-body'); |
| 296 |
const title = document.getElementById('modal-title'); |
| 297 |
title.textContent = 'Actions'; |
| 298 |
body.innerHTML = ''; |
| 299 |
|
| 300 |
const actions = [ |
| 301 |
{ label: item.isStarred ? 'Unstar' : 'Star', action: () => BB.items.toggleStar(item.id, item.isStarred) }, |
| 302 |
{ label: item.isRead ? 'Mark Unread' : 'Mark Read', action: () => BB.items.toggleRead(item.id, item.isRead) }, |
| 303 |
]; |
| 304 |
if (item.url) { |
| 305 |
actions.push({ label: 'Open in Browser', action: () => BB.detail.openUrl() }); |
| 306 |
} |
| 307 |
|
| 308 |
|
| 309 |
|
| 310 |
for (const a of actions) { |
| 311 |
const btn = document.createElement('button'); |
| 312 |
btn.className = 'btn btn-stacked'; |
| 313 |
btn.textContent = a.label; |
| 314 |
btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); }); |
| 315 |
body.appendChild(btn); |
| 316 |
} |
| 317 |
|
| 318 |
BB.ui.openModal(); |
| 319 |
} |
| 320 |
|
| 321 |
|
| 322 |
|
| 323 |
BB.mobile = { init }; |
| 324 |
|
| 325 |
if (document.readyState === 'loading') { |
| 326 |
document.addEventListener('DOMContentLoaded', init); |
| 327 |
} else { |
| 328 |
setTimeout(init, 0); |
| 329 |
} |
| 330 |
})(); |
| 331 |
|