feat: Implement bookmark functionality for epub reader
Replace bookmark stubs with full add/remove/navigate implementation: - BookmarkStore: per-book binary persistence on SD card with v2 format supporting text snippets (backward-compatible with v1) - Visual bookmark ribbon indicator drawn on bookmarked pages via fillPolygon - Reader menu dynamically shows Add/Remove Bookmark based on current page state - Bookmark selection activity with chapter name, first sentence snippet, and page number display; long-press to delete with confirmation - Go to Bookmark falls back to Table of Contents when no bookmarks exist - Smart snippet extraction: skips partial sentences (lowercase first word) to capture the first full sentence on the page - Label truncation reserves space for page suffix so it's never cut off - Half refresh forced on menu exit to clear popup/menu artifacts Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||
#include "EpubReaderChapterSelectionActivity.h"
|
||||
#include "EpubReaderPercentSelectionActivity.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
@@ -15,6 +16,7 @@
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BookmarkStore.h"
|
||||
#include "util/Dictionary.h"
|
||||
#include "util/LookupHistory.h"
|
||||
|
||||
@@ -235,10 +237,13 @@ void EpubReaderActivity::loop() {
|
||||
}
|
||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||
const bool hasDictionary = Dictionary::exists();
|
||||
const bool isBookmarked = BookmarkStore::hasBookmark(
|
||||
epub->getCachePath(), currentSpineIndex, section ? section->currentPage : 0);
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderMenuActivity(
|
||||
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||
SETTINGS.orientation, hasDictionary, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
||||
SETTINGS.orientation, hasDictionary, isBookmarked,
|
||||
[this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
||||
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
@@ -332,6 +337,8 @@ void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) {
|
||||
// Apply the user-selected orientation when the menu is dismissed.
|
||||
// This ensures the menu can be navigated without immediately rotating the screen.
|
||||
applyOrientation(orientation);
|
||||
// Force a half refresh on the next render to clear menu/popup artifacts
|
||||
pagesUntilFullRefresh = 1;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
@@ -400,21 +407,151 @@ void EpubReaderActivity::jumpToPercent(int percent) {
|
||||
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
||||
switch (action) {
|
||||
case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: {
|
||||
// Stub — bookmark feature coming soon
|
||||
const int page = section ? section->currentPage : 0;
|
||||
|
||||
// Extract first full sentence from the current page for the bookmark snippet.
|
||||
// If the first word is lowercase, the page starts mid-sentence — skip to the
|
||||
// next sentence boundary and start collecting from there.
|
||||
std::string snippet;
|
||||
if (section) {
|
||||
auto p = section->loadPageFromSectionFile();
|
||||
if (p) {
|
||||
// Gather all words on the page into a flat list for easier traversal
|
||||
std::vector<std::string> allWords;
|
||||
for (const auto& element : p->elements) {
|
||||
const auto* line = static_cast<const PageLine*>(element.get());
|
||||
if (!line) continue;
|
||||
const auto& block = line->getBlock();
|
||||
if (!block) continue;
|
||||
for (const auto& word : block->getWords()) {
|
||||
allWords.push_back(word);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allWords.empty()) {
|
||||
size_t startIdx = 0;
|
||||
|
||||
// Check if the first word starts with a lowercase letter (mid-sentence)
|
||||
const char firstChar = allWords[0].empty() ? '\0' : allWords[0][0];
|
||||
if (firstChar >= 'a' && firstChar <= 'z') {
|
||||
// Skip past the end of this partial sentence
|
||||
for (size_t i = 0; i < allWords.size(); i++) {
|
||||
if (!allWords[i].empty()) {
|
||||
char last = allWords[i].back();
|
||||
if (last == '.' || last == '!' || last == '?' || last == ':') {
|
||||
startIdx = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no sentence boundary found, fall back to using everything from the start
|
||||
if (startIdx >= allWords.size()) {
|
||||
startIdx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect words from startIdx until the next sentence boundary
|
||||
for (size_t i = startIdx; i < allWords.size(); i++) {
|
||||
if (!snippet.empty()) snippet += " ";
|
||||
snippet += allWords[i];
|
||||
if (!allWords[i].empty()) {
|
||||
char last = allWords[i].back();
|
||||
if (last == '.' || last == '!' || last == '?' || last == ':') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BookmarkStore::addBookmark(epub->getCachePath(), currentSpineIndex, page, snippet);
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
GUI.drawPopup(renderer, "Coming soon");
|
||||
GUI.drawPopup(renderer, "Bookmark added");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
xSemaphoreGive(renderingMutex);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
vTaskDelay(750 / portTICK_PERIOD_MS);
|
||||
// Exit the menu and return to reading — the bookmark indicator will show on re-render,
|
||||
// and next menu open will reflect the updated state.
|
||||
exitActivity();
|
||||
pagesUntilFullRefresh = 1;
|
||||
updateRequired = true;
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::REMOVE_BOOKMARK: {
|
||||
const int page = section ? section->currentPage : 0;
|
||||
BookmarkStore::removeBookmark(epub->getCachePath(), currentSpineIndex, page);
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
GUI.drawPopup(renderer, "Bookmark removed");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
xSemaphoreGive(renderingMutex);
|
||||
vTaskDelay(750 / portTICK_PERIOD_MS);
|
||||
exitActivity();
|
||||
pagesUntilFullRefresh = 1;
|
||||
updateRequired = true;
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: {
|
||||
// Stub — bookmark feature coming soon
|
||||
auto bookmarks = BookmarkStore::load(epub->getCachePath());
|
||||
|
||||
if (bookmarks.empty()) {
|
||||
// No bookmarks: fall back to Table of Contents if available, otherwise go back
|
||||
if (epub->getTocItemsCount() > 0) {
|
||||
const int currentP = section ? section->currentPage : 0;
|
||||
const int totalP = section ? section->pageCount : 0;
|
||||
const int spineIdx = currentSpineIndex;
|
||||
const std::string path = epub->getPath();
|
||||
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
||||
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
|
||||
[this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
},
|
||||
[this](const int newSpineIndex) {
|
||||
if (currentSpineIndex != newSpineIndex) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = 0;
|
||||
section.reset();
|
||||
}
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
},
|
||||
[this](const int newSpineIndex, const int newPage) {
|
||||
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = newPage;
|
||||
section.reset();
|
||||
}
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
// If no TOC either, just return to reader (menu already closed by callback)
|
||||
break;
|
||||
}
|
||||
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
GUI.drawPopup(renderer, "Coming soon");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderBookmarkSelectionActivity(
|
||||
this->renderer, this->mappedInput, epub, std::move(bookmarks), epub->getCachePath(),
|
||||
[this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
},
|
||||
[this](const int newSpineIndex, const int newPage) {
|
||||
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||
currentSpineIndex = newSpineIndex;
|
||||
nextPageNumber = newPage;
|
||||
section.reset();
|
||||
}
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: {
|
||||
@@ -832,6 +969,22 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
const int orientedMarginRight, const int orientedMarginBottom,
|
||||
const int orientedMarginLeft) {
|
||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||
|
||||
// Draw bookmark ribbon indicator in top-right corner if current page is bookmarked
|
||||
if (section && BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage)) {
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int bkWidth = 12;
|
||||
const int bkHeight = 22;
|
||||
const int bkX = screenWidth - orientedMarginRight - bkWidth + 2;
|
||||
const int bkY = 0;
|
||||
const int notchDepth = bkHeight / 3;
|
||||
const int centerX = bkX + bkWidth / 2;
|
||||
|
||||
const int xPoints[5] = {bkX, bkX + bkWidth, bkX + bkWidth, centerX, bkX};
|
||||
const int yPoints[5] = {bkY, bkY, bkY + bkHeight, bkY + bkHeight - notchDepth, bkY + bkHeight};
|
||||
renderer.fillPolygon(xPoints, yPoints, 5, true);
|
||||
}
|
||||
|
||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
if (pagesUntilFullRefresh <= 1) {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
|
||||
Reference in New Issue
Block a user