fix: several bookmarks UX improvments (#2372)

## Summary

This PR enhances the EPUB reader's bookmark system with two
complementary improvements: a per-page bookmark indicator icon and
toggle behavior on the existing long-press action.

---

### What Changed

**Bookmark Toggle (was: add-only)**

The long-press Confirm action now toggles bookmarks rather than always
adding. `addBookmark()` checks whether a bookmark with the same xpath
already exists in the in-memory cache:
- If found → removes it and shows "Bookmark removed."
- If not found → adds it and shows "Bookmark added."

A new `STR_BOOKMARK_REMOVED` translation string was added to support the
removal message.

**Bookmark Icon Indicator**

A `BookmarkIcon` is now drawn at the top-right corner of the page
whenever the current page has a bookmark. `updateBookmarkFlag()` is
called at render time to determine whether the current page is
bookmarked.

**In-Memory Bookmark Cache**

Bookmarks are now loaded into `cachedBookmarks` on `onEnter()` rather
than being re-read from disk on every toggle. All subsequent add/remove
operations work against this cache and flush to disk, avoiding redundant
file reads on each bookmark action.

**Faster bookmarks list**

Previously, calculating "page X/Y" for each entry required decompressing
the entire spine item. We now persist `si`/`pc`/`pp` (spine index, page
count, and page progress) in the bookmark JSON when saving, and restore
them when loading. This avoids the expensive `toCrossPoint()` loop in
`onEnter()`, significantly reducing the cost of initializing the
bookmarks list.

---

### Files Changed

- `EpubReaderActivity.cpp` — `addBookmark()` toggle logic,
`updateBookmarkFlag()` (new), icon rendering in `renderContents()`,
cache initialization in `onEnter()`
- `EpubReaderActivity.h` — new fields: `currentPageBookmarked`,
`bookmarkRemoved`, `cachedBookmarks`; new method declaration
`updateBookmarkFlag()`

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< PARTIALLY >**_

---------

Co-authored-by: Julia Nguyen <julia@uxj.io>
This commit is contained in:
Uri Tauber
2026-06-23 14:55:01 -04:00
committed by GitHub
co-authored by Julia Nguyen
parent 362dcb2a65
commit 1db1442319
36 changed files with 272 additions and 92 deletions
+106 -34
View File
@@ -41,6 +41,8 @@ namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
// pages per minute, first item is 1 to prevent division by zero if accessed
constexpr int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12};
constexpr size_t initialBookmarkCacheCapacity = 16;
constexpr float bookmarkProgressEpsilon = 0.0001f;
int clampPercent(int percent) {
if (percent < 0) {
@@ -63,6 +65,35 @@ bool isInReadFolder(const std::string& path) {
return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/';
}
struct ProgressRange {
float start;
float end;
};
ProgressRange getPageProgressRange(const std::shared_ptr<Epub>& epub, const int spineIndex, const int page,
const int pageCount) {
if (pageCount <= 1) {
return {epub->calculateProgress(spineIndex, 0.0f), epub->calculateProgress(spineIndex, 1.0f)};
}
const float step = 1.0f / static_cast<float>(pageCount - 1);
const float anchor = std::clamp(static_cast<float>(page) * step, 0.0f, 1.0f);
const float start = std::max(0.0f, anchor - (step * 0.5f));
const float end = std::min(1.0f, anchor + (step * 0.5f));
return {epub->calculateProgress(spineIndex, start), epub->calculateProgress(spineIndex, end)};
}
bool bookmarkMatchesProgress(const BookmarkEntry& bookmark, const SavedProgressPosition& progress,
const ProgressRange& pageRange) {
if (bookmark.xpath == progress.xpath) {
return true;
}
const float bookmarkProgress = std::clamp(bookmark.percentage, 0.0f, 1.0f);
return bookmarkProgress + bookmarkProgressEpsilon >= pageRange.start &&
bookmarkProgress - bookmarkProgressEpsilon <= pageRange.end;
}
// Pick a non-colliding destination path inside /Read/ for a finished book.
// Mirrors the suffixing scheme used elsewhere: "name.epub" -> "name (2).epub", etc.
std::string buildReadFolderDestination(const std::string& srcPath) {
@@ -165,6 +196,8 @@ void EpubReaderActivity::onEnter() {
APP_STATE.saveToFile();
RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath());
loadCachedBookmarks();
// Trigger first update
requestUpdate();
}
@@ -281,7 +314,7 @@ void EpubReaderActivity::loop() {
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, !currentPageFootnotes.empty()),
SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()),
[this](const ActivityResult& result) {
// Always apply orientation change even if the menu was cancelled
const auto& menu = std::get<MenuResult>(result.data);
@@ -500,6 +533,7 @@ void EpubReaderActivity::jumpToPercent(int percent) {
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
auto progressChangeResultHandler = [this](const ActivityResult& result) {
loadCachedBookmarks();
if (!result.isCancelled) {
const auto& sync = std::get<ProgressChangeResult>(result.data);
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
@@ -615,6 +649,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
progressChangeResultHandler);
break;
}
case EpubReaderMenuActivity::MenuAction::TOGGLE_BOOKMARK: {
addBookmark();
break;
}
}
}
@@ -902,6 +940,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
return;
}
updateBookmarkFlag();
{
auto p = section->loadPageFromSectionFile();
if (!p) {
@@ -933,7 +973,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
}
if (showBookmarkMessage) {
GUI.drawPopup(renderer, tr(STR_BOOKMARK_ADDED));
GUI.drawPopup(renderer, bookmarkRemoved ? tr(STR_BOOKMARK_REMOVED) : tr(STR_BOOKMARK_ADDED));
}
}
@@ -1172,7 +1212,7 @@ void EpubReaderActivity::renderStatusBar() const {
title = epub->getTitle();
}
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset);
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked);
}
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
@@ -1234,11 +1274,31 @@ void EpubReaderActivity::restoreSavedPosition() {
requestUpdate();
}
void EpubReaderActivity::loadCachedBookmarks() {
cachedBookmarks.clear();
if (cachedBookmarks.capacity() < initialBookmarkCacheCapacity) {
cachedBookmarks.reserve(initialBookmarkCacheCapacity);
}
if (!epub) {
currentPageBookmarked = false;
return;
}
const std::string bmPath = BookmarkUtil::getBookmarkPath(epub->getPath());
if (Storage.exists(bmPath.c_str())) {
String json = Storage.readFile(bmPath.c_str());
if (!json.isEmpty()) {
JsonSettingsIO::loadBookmarks(cachedBookmarks, json.c_str());
}
}
updateBookmarkFlag();
}
void EpubReaderActivity::addBookmark() {
if (!section || !epub) {
return;
}
LOG_DBG("ERS", "Adding bookmark at spine %d, page %d", currentSpineIndex, section ? section->currentPage : -1);
LOG_DBG("ERS", "Toggle bookmark at spine %d, page %d", currentSpineIndex, section ? section->currentPage : -1);
int currentPage;
int pageCount;
{
@@ -1247,45 +1307,57 @@ void EpubReaderActivity::addBookmark() {
currentPage = section->currentPage;
}
std::string pageText;
if (currentPage >= 0 && currentPage < pageCount) {
pageText = section->getTextFromSectionFile();
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, currentPage, pageCount);
const size_t bookmarkCountBeforeToggle = cachedBookmarks.size();
cachedBookmarks.erase(
std::remove_if(cachedBookmarks.begin(), cachedBookmarks.end(),
[&](const BookmarkEntry& b) { return bookmarkMatchesProgress(b, progress, pageRange); }),
cachedBookmarks.end());
if (cachedBookmarks.size() != bookmarkCountBeforeToggle) {
bookmarkRemoved = true;
currentPageBookmarked = false;
} else {
std::string pageText;
if (currentPage >= 0 && currentPage < pageCount) {
pageText = section->getTextFromSectionFile();
}
BookmarkEntry entry;
entry.percentage = progress.percentage;
entry.xpath = progress.xpath;
entry.summary = BookmarkUtil::sanitizeBookmarkSummary(pageText);
entry.computedSpineIndex = currentSpineIndex;
entry.computedChapterPageCount = pageCount;
entry.computedChapterProgress = currentPage;
cachedBookmarks.insert(cachedBookmarks.begin(), entry);
bookmarkRemoved = false;
currentPageBookmarked = true;
}
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
BookmarkEntry entry;
entry.percentage = progress.percentage;
entry.xpath = progress.xpath;
entry.summary = BookmarkUtil::sanitizeBookmarkSummary(pageText);
// Add bookmark
const std::string path = BookmarkUtil::getBookmarkPath(epub->getPath());
LOG_DBG("ERS", "Bookmark path: %s", path.c_str());
const std::string bookmarksDir = BookmarkUtil::getBookmarksDir();
Storage.mkdir(bookmarksDir.c_str());
std::vector<BookmarkEntry> bookmarks;
if (Storage.exists(path.c_str())) {
LOG_DBG("ERS", "Existing bookmark file found, loading bookmarks");
String json = Storage.readFile(path.c_str());
if (!json.isEmpty()) {
JsonSettingsIO::loadBookmarks(bookmarks, json.c_str());
}
} else {
LOG_DBG("ERS", "No existing bookmark file, starting with empty bookmark list");
const bool ok = JsonSettingsIO::saveBookmarks(cachedBookmarks, path.c_str());
if (!ok) {
LOG_ERR("ERS", "Failed to save bookmarks to: %s", path.c_str());
}
bookmarks.insert(bookmarks.begin(), entry);
LOG_DBG("ERS", "Saving bookmark to file: %s", path.c_str());
const bool ok = JsonSettingsIO::saveBookmarks(bookmarks, path.c_str());
if (ok) {
showBookmarkMessage = true;
} else {
LOG_ERR("ERS", "Failed to save bookmark to: %s", path.c_str());
}
requestUpdate();
}
void EpubReaderActivity::updateBookmarkFlag() {
if (!section || !epub || cachedBookmarks.empty()) {
currentPageBookmarked = false;
return;
}
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
const ProgressRange pageRange =
getPageProgressRange(epub, currentSpineIndex, section->currentPage, section->pageCount);
currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) {
return bookmarkMatchesProgress(b, progress, pageRange);
});
}
ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
ScreenshotInfo info;
info.readerType = ScreenshotInfo::ReaderType::Epub;