Skip to content

Commit b331ec7

Browse files
author
Fredrik Hallin
committed
Improve drag sorting and speedread ETA
1 parent cf7a3a5 commit b331ec7

3 files changed

Lines changed: 128 additions & 3 deletions

File tree

app/src/main/kotlin/com/fredapp/wbooks/transfer/UploadServer.kt

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ class UploadServer(
195195
.library-section.drag-over,.file-picker.drag-over{outline:3px solid rgba(179,83,24,.24);background:#fff4e4}
196196
.book-card[draggable="true"]{cursor:grab}
197197
.book-card.dragging{opacity:.45}
198+
.book-card.touch-dragging{opacity:.72;transform:scale(.985);touch-action:none}
198199
.book-card.drag-target{outline:3px solid rgba(31,111,105,.22);border-color:var(--accent-2)}
199200
.chev{display:inline-flex;width:14px;justify-content:center;color:var(--muted);font-size:0.9rem;flex:0 0 auto}
200201
.folder-shell,.folder-head{display:flex;align-items:center;gap:10px}
@@ -610,6 +611,79 @@ class UploadServer(
610611
// dragover for security. Track the active in-page drag so we can
611612
// still highlight valid drop targets while a book is being moved.
612613
var activeBookDrag = null;
614+
var touchBookDrag = null;
615+
function isInteractiveDragStart(target) {
616+
return !!(target && target.closest && target.closest('button,input,select,textarea,a,form'));
617+
}
618+
function clearTouchDragTargets() {
619+
document.querySelectorAll('.drop-zone.drag-over').forEach(function(z){ z.classList.remove('drag-over'); });
620+
document.querySelectorAll('.book-card.drag-target').forEach(function(z){ z.classList.remove('drag-target'); });
621+
}
622+
function dragTargetAtPoint(x, y, sourceCard) {
623+
var el = document.elementFromPoint(x, y);
624+
if (!el || !el.closest) return null;
625+
var card = el.closest('.book-card');
626+
if (card && card !== sourceCard) return {type:'card', el:card};
627+
var zone = el.closest('.drop-zone');
628+
if (zone) return {type:'zone', el:zone};
629+
return null;
630+
}
631+
function installTouchBookSorting(card) {
632+
if (!window.PointerEvent) return;
633+
card.addEventListener('pointerdown', function(e) {
634+
if (e.pointerType === 'mouse' || e.button !== 0 || isInteractiveDragStart(e.target)) return;
635+
var rel = card.dataset.rel || '';
636+
if (!rel || touchBookDrag) return;
637+
var startX = e.clientX;
638+
var startY = e.clientY;
639+
var timer = window.setTimeout(function() {
640+
touchBookDrag.active = true;
641+
activeBookDrag = rel;
642+
card.classList.add('touch-dragging');
643+
try { card.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
644+
}, 320);
645+
touchBookDrag = {rel:rel, card:card, pointerId:e.pointerId, startX:startX, startY:startY, active:false, timer:timer};
646+
});
647+
card.addEventListener('pointermove', function(e) {
648+
if (!touchBookDrag || touchBookDrag.pointerId !== e.pointerId) return;
649+
var dx = e.clientX - touchBookDrag.startX;
650+
var dy = e.clientY - touchBookDrag.startY;
651+
if (!touchBookDrag.active && Math.sqrt(dx * dx + dy * dy) > 12) {
652+
window.clearTimeout(touchBookDrag.timer);
653+
touchBookDrag = null;
654+
return;
655+
}
656+
if (!touchBookDrag.active) return;
657+
e.preventDefault();
658+
clearTouchDragTargets();
659+
var target = dragTargetAtPoint(e.clientX, e.clientY, touchBookDrag.card);
660+
if (target) target.el.classList.add(target.type === 'zone' ? 'drag-over' : 'drag-target');
661+
});
662+
function finishPointerDrag(e, shouldDrop) {
663+
if (!touchBookDrag || touchBookDrag.pointerId !== e.pointerId) return;
664+
window.clearTimeout(touchBookDrag.timer);
665+
var drag = touchBookDrag;
666+
touchBookDrag = null;
667+
drag.card.classList.remove('touch-dragging');
668+
try { drag.card.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ }
669+
if (drag.active && shouldDrop) {
670+
e.preventDefault();
671+
var target = dragTargetAtPoint(e.clientX, e.clientY, drag.card);
672+
clearTouchDragTargets();
673+
activeBookDrag = null;
674+
if (target && target.type === 'card') {
675+
reorderBookRelative(drag.rel, target.el.dataset.rel || '', dropPlacement(e, target.el));
676+
} else if (target && target.type === 'zone') {
677+
moveBookTo(drag.rel, target.el.dataset.folder || '');
678+
}
679+
} else {
680+
clearTouchDragTargets();
681+
activeBookDrag = null;
682+
}
683+
}
684+
card.addEventListener('pointerup', function(e) { finishPointerDrag(e, true); });
685+
card.addEventListener('pointercancel', function(e) { finishPointerDrag(e, false); });
686+
}
613687
function installDropZones() {
614688
document.querySelectorAll('.drop-zone').forEach(function(zone) {
615689
zone.addEventListener('dragover', function(e) {
@@ -673,6 +747,7 @@ class UploadServer(
673747
document.querySelectorAll('.drop-zone.drag-over').forEach(function(z){ z.classList.remove('drag-over'); });
674748
document.querySelectorAll('.book-card.drag-target').forEach(function(z){ z.classList.remove('drag-target'); });
675749
});
750+
installTouchBookSorting(card);
676751
});
677752
var pin = document.getElementById('pin');
678753
pin.value = sessionStorage.getItem('wbooksPin') || '';

app/src/main/kotlin/com/fredapp/wbooks/ui/ReaderViewModel.kt

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,10 +333,15 @@ class ReaderViewModel(
333333
val readingEta: StateFlow<ReadingEta?> = _document
334334
.flatMapLatest { state ->
335335
if (state !is DocumentState.Loaded) flow { emit(null) }
336-
else combine(paceRepo.paceFlow(state.book.id), currentPosition) { pace, pos ->
336+
else combine(paceRepo.paceFlow(state.book.id), currentPosition, settings) { pace, pos, settings ->
337337
val metrics = state.metrics
338-
if (pace == null || !pace.isReady || metrics == null) null
339-
else computeEta(state.doc, metrics, pos, pace.msPerBlock)
338+
when {
339+
metrics == null -> null
340+
settings.mode == ReadingMode.SPEEDREAD ->
341+
computeSpeedreadEta(state.doc, metrics, pos, settings.speedreadWpm)
342+
pace == null || !pace.isReady -> null
343+
else -> computeEta(state.doc, metrics, pos, pace.msPerBlock)
344+
}
340345
}
341346
}
342347
.stateIn(
@@ -391,6 +396,30 @@ class ReaderViewModel(
391396
)
392397
}
393398

399+
private fun computeSpeedreadEta(
400+
doc: Document,
401+
metrics: DocumentMetrics,
402+
position: BookPosition,
403+
wpm: Int,
404+
): ReadingEta? {
405+
if (doc.chapters.isEmpty() || metrics.totalWords == 0 || wpm <= 0) return null
406+
407+
val ci = position.chapterIndex.coerceIn(0, doc.chapters.lastIndex)
408+
val chapter = doc.chapters.getOrNull(ci) ?: return null
409+
if (chapter.blocks.isEmpty()) return null
410+
411+
val wordsRead = metrics.wordIndexAt(position)
412+
val chapterWordsEnd = metrics.wordsBeforeBlock[ci][chapter.blocks.size]
413+
val msPerWord = 60_000.0 / wpm.coerceIn(ReaderSettings.WPM_RANGE)
414+
val wordsRemainingInChapter = (chapterWordsEnd - wordsRead).coerceAtLeast(0)
415+
val wordsRemainingInBook = (metrics.totalWords - wordsRead).coerceAtLeast(0)
416+
417+
return ReadingEta(
418+
chapterMs = (wordsRemainingInChapter * msPerWord).toLong(),
419+
bookMs = (wordsRemainingInBook * msPerWord).toLong(),
420+
)
421+
}
422+
394423
// ---- Bookmarks ----
395424
// Bookmarks are stored in three separate buckets — one per reading mode —
396425
// so switching modes shows a structurally different list, not a filter of

companion/src/main/kotlin/com/fredapp/wbooksutil/MainViewModel.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
120120

121121
fun assignBookToFolder(bookId: String, folderId: String?) {
122122
val targetFolder = folderId ?: ""
123+
val state = _state.value
124+
val currentFolder = state.bookFolders[bookId] ?: ""
125+
if (currentFolder == targetFolder) {
126+
moveBookToTopOfFolder(bookId, targetFolder)
127+
return
128+
}
123129
viewModelScope.launch {
124130
_state.value = _state.value.copy(sending = true)
125131
val result = repo.moveBook(bookId, targetFolder)
@@ -128,6 +134,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
128134
}
129135
}
130136

137+
private fun moveBookToTopOfFolder(bookId: String, folder: String) {
138+
val state = _state.value
139+
val folderBooks = state.books.filter { (state.bookFolders[it.id] ?: "") == folder }
140+
if (folderBooks.firstOrNull()?.id == bookId) return
141+
val reordered = listOfNotNull(folderBooks.firstOrNull { it.id == bookId }) +
142+
folderBooks.filterNot { it.id == bookId }
143+
if (reordered.size != folderBooks.size) return
144+
viewModelScope.launch {
145+
_state.value = _state.value.copy(sending = true)
146+
val result = repo.reorderBooks(folder, reordered.map { it.id })
147+
_state.value = _state.value.copy(sending = false)
148+
applyResult(result)
149+
}
150+
}
151+
131152
fun reorderBook(fromId: String, targetId: String, placeAfterTarget: Boolean) {
132153
if (fromId == targetId) return
133154
val state = _state.value

0 commit comments

Comments
 (0)