feat: slim footnotes support (#1031)
## Summary **What is the goal of this PR?** Implement support for footnotes in epub files. It is based on #553, but simplified — removed the parts which complicated the code and burden the CPU/RAM. This version supports basic footnotes and lets the user jump from location to location inside the epub. **What changes are included?** - `FootnoteEntry` struct — A small POD struct (number[24], href[64]) shared between parser, page storage, and UI. - Parser: `<a href>` detection (`ChapterHtmlSlimParser`) — During a single parsing pass, internal epub links are detected and collected as footnotes. The link text is underlined to hint navigability. Bracket/whitespace normalization is applied to the display label (e.g. [1] → 1). - Footnote-to-page assignment (`ChapterHtmlSlimParser`, `Page`) — Footnotes are attached to the exact page where their anchor word appears, tracked via a cumulative word counter during layout, surviving paragraph splits and the 750-word mid-paragraph safety flush. - Page serialization (`Page`, `Section`) — Footnotes are serialized/deserialized per page (max 16 per page). Section cache version bumped to 14 to force a clean rebuild. - Href → spine resolution (`Epub`) — `resolveHrefToSpineIndex()` maps an href (e.g. `chapter2.xhtml#note1`) to its spine index by filename matching. - Footnotes menu + activity (`EpubReaderMenuActivity`, `EpubReaderFootnotesActivity`) — A new "Footnotes" entry in the reader menu lists all footnote links found on the current page. The user scrolls and selects to navigate. - Navigate & restore (`EpubReaderActivity`) — `navigateToHref()` saves the current spine index and page number, then jumps to the target. The Back button restores the saved position when the user is done reading the footnote. **Additional Context** **What was removed vs #553:** virtual spine items (`addVirtualSpineItem`, `isVirtualSpineItem`), two-pass parsing, `<aside>` content extraction to temp HTML files, `<p class="note">` paragraph note extraction, `replaceHtmlEntities` (master already has `lookupHtmlEntity`), `footnotePages` / `buildFilteredChapterList`, `noterefCallback` / `Noteref` struct, and the stack size increase from 8 KB to 24 KB (not needed without two-pass parsing and virtual file I/O on the render task). **Performance:** Single-pass parsing. No new heap allocations in the hot path — footnote text is collected into fixed stack buffers (char[24], char[64]). Active runtime memory is ~2.8 KB worst-case (one page × 16 footnotes × 88 bytes, mirrored in `currentPageFootnotes`). Flash usage is unchanged at 97.4%; RAM stays at 31%. **Known limitations:** When clicking a footnote, it jumps to the start of the HTML file instead of the specific anchor. This could be problematic for books that don't have separate files for each footnote. (no element-id-to-page mapping yet - will be another PR soon). --- ### AI Usage Did you use AI tools to help write this code? _**< PARTIALLY>**_ Claude Opus 4.6 was used to do most of the migration, I checked manually its work, and fixed some stuff, but I haven't review all the changes yet, so feedback is welcomed. --------- Co-authored-by: Arthur Tazhitdinov <lisnake@gmail.com>
This commit is contained in:
@@ -858,3 +858,30 @@ float Epub::calculateProgress(const int currentSpineIndex, const float currentSp
|
|||||||
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
|
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
|
||||||
return totalProgress / static_cast<float>(bookSize);
|
return totalProgress / static_cast<float>(bookSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int Epub::resolveHrefToSpineIndex(const std::string& href) const {
|
||||||
|
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) return -1;
|
||||||
|
|
||||||
|
// Extract filename (remove #anchor)
|
||||||
|
std::string target = href;
|
||||||
|
size_t hashPos = target.find('#');
|
||||||
|
if (hashPos != std::string::npos) target = target.substr(0, hashPos);
|
||||||
|
|
||||||
|
// Same-file reference (anchor-only)
|
||||||
|
if (target.empty()) return -1;
|
||||||
|
|
||||||
|
// Extract just the filename for comparison
|
||||||
|
size_t targetSlash = target.find_last_of('/');
|
||||||
|
std::string targetFilename = (targetSlash != std::string::npos) ? target.substr(targetSlash + 1) : target;
|
||||||
|
|
||||||
|
for (int i = 0; i < getSpineItemsCount(); i++) {
|
||||||
|
const auto& spineHref = getSpineItem(i).href;
|
||||||
|
// Try exact match first
|
||||||
|
if (spineHref == target) return i;
|
||||||
|
// Then filename-only match
|
||||||
|
size_t spineSlash = spineHref.find_last_of('/');
|
||||||
|
std::string spineFilename = (spineSlash != std::string::npos) ? spineHref.substr(spineSlash + 1) : spineHref;
|
||||||
|
if (spineFilename == targetFilename) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,4 +72,5 @@ class Epub {
|
|||||||
size_t getBookSize() const;
|
size_t getBookSize() const;
|
||||||
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
||||||
CssParser* getCssParser() const { return cssParser.get(); }
|
CssParser* getCssParser() const { return cssParser.get(); }
|
||||||
|
int resolveHrefToSpineIndex(const std::string& href) const;
|
||||||
};
|
};
|
||||||
|
|||||||
13
lib/Epub/Epub/FootnoteEntry.h
Normal file
13
lib/Epub/Epub/FootnoteEntry.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
struct FootnoteEntry {
|
||||||
|
char number[24];
|
||||||
|
char href[64];
|
||||||
|
|
||||||
|
FootnoteEntry() {
|
||||||
|
number[0] = '\0';
|
||||||
|
href[0] = '\0';
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -67,6 +67,18 @@ bool Page::serialize(FsFile& file) const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serialize footnotes (clamp to MAX_FOOTNOTES_PER_PAGE to match addFootnote/deserialize limits)
|
||||||
|
const uint16_t fnCount = std::min<uint16_t>(footnotes.size(), MAX_FOOTNOTES_PER_PAGE);
|
||||||
|
serialization::writePod(file, fnCount);
|
||||||
|
for (uint16_t i = 0; i < fnCount; i++) {
|
||||||
|
const auto& fn = footnotes[i];
|
||||||
|
if (file.write(fn.number, sizeof(fn.number)) != sizeof(fn.number) ||
|
||||||
|
file.write(fn.href, sizeof(fn.href)) != sizeof(fn.href)) {
|
||||||
|
LOG_ERR("PGE", "Failed to write footnote");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,5 +104,24 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deserialize footnotes
|
||||||
|
uint16_t fnCount;
|
||||||
|
serialization::readPod(file, fnCount);
|
||||||
|
if (fnCount > MAX_FOOTNOTES_PER_PAGE) {
|
||||||
|
LOG_ERR("PGE", "Invalid footnote count %u", fnCount);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
page->footnotes.resize(fnCount);
|
||||||
|
for (uint16_t i = 0; i < fnCount; i++) {
|
||||||
|
auto& entry = page->footnotes[i];
|
||||||
|
if (file.read(entry.number, sizeof(entry.number)) != sizeof(entry.number) ||
|
||||||
|
file.read(entry.href, sizeof(entry.href)) != sizeof(entry.href)) {
|
||||||
|
LOG_ERR("PGE", "Failed to read footnote %u", i);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
entry.number[sizeof(entry.number) - 1] = '\0';
|
||||||
|
entry.href[sizeof(entry.href) - 1] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "FootnoteEntry.h"
|
||||||
#include "blocks/ImageBlock.h"
|
#include "blocks/ImageBlock.h"
|
||||||
#include "blocks/TextBlock.h"
|
#include "blocks/TextBlock.h"
|
||||||
|
|
||||||
@@ -57,6 +58,19 @@ class Page {
|
|||||||
public:
|
public:
|
||||||
// the list of block index and line numbers on this page
|
// the list of block index and line numbers on this page
|
||||||
std::vector<std::shared_ptr<PageElement>> elements;
|
std::vector<std::shared_ptr<PageElement>> elements;
|
||||||
|
std::vector<FootnoteEntry> footnotes;
|
||||||
|
static constexpr uint16_t MAX_FOOTNOTES_PER_PAGE = 16;
|
||||||
|
|
||||||
|
void addFootnote(const char* number, const char* href) {
|
||||||
|
if (footnotes.size() >= MAX_FOOTNOTES_PER_PAGE) return; // Cap per-page footnotes
|
||||||
|
FootnoteEntry entry;
|
||||||
|
strncpy(entry.number, number, sizeof(entry.number) - 1);
|
||||||
|
entry.number[sizeof(entry.number) - 1] = '\0';
|
||||||
|
strncpy(entry.href, href, sizeof(entry.href) - 1);
|
||||||
|
entry.href[sizeof(entry.href) - 1] = '\0';
|
||||||
|
footnotes.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
||||||
bool serialize(FsFile& file) const;
|
bool serialize(FsFile& file) const;
|
||||||
static std::unique_ptr<Page> deserialize(FsFile& file);
|
static std::unique_ptr<Page> deserialize(FsFile& file);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class TextBlock final : public Block {
|
|||||||
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
||||||
const std::vector<std::string>& getWords() const { return words; }
|
const std::vector<std::string>& getWords() const { return words; }
|
||||||
bool isEmpty() override { return words.empty(); }
|
bool isEmpty() override { return words.empty(); }
|
||||||
|
size_t wordCount() const { return words.size(); }
|
||||||
// given a renderer works out where to break the words into lines
|
// given a renderer works out where to break the words into lines
|
||||||
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
||||||
BlockType getType() override { return TEXT_BLOCK; }
|
BlockType getType() override { return TEXT_BLOCK; }
|
||||||
|
|||||||
@@ -49,6 +49,24 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const char* getAttribute(const XML_Char** atts, const char* attrName) {
|
||||||
|
if (!atts) return nullptr;
|
||||||
|
for (int i = 0; atts[i]; i += 2) {
|
||||||
|
if (strcmp(atts[i], attrName) == 0) return atts[i + 1];
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isInternalEpubLink(const char* href) {
|
||||||
|
if (!href || href[0] == '\0') return false;
|
||||||
|
if (strncmp(href, "http://", 7) == 0 || strncmp(href, "https://", 8) == 0) return false;
|
||||||
|
if (strncmp(href, "mailto:", 7) == 0) return false;
|
||||||
|
if (strncmp(href, "ftp://", 6) == 0) return false;
|
||||||
|
if (strncmp(href, "tel:", 4) == 0) return false;
|
||||||
|
if (strncmp(href, "javascript:", 11) == 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool isHeaderOrBlock(const char* name) {
|
bool isHeaderOrBlock(const char* name) {
|
||||||
return matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
|
return matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
|
||||||
}
|
}
|
||||||
@@ -121,6 +139,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
|
|||||||
makePages();
|
makePages();
|
||||||
}
|
}
|
||||||
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle));
|
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle));
|
||||||
|
wordsExtractedInBlock = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
|
void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
|
||||||
@@ -430,6 +449,50 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect internal <a href="..."> links (footnotes, cross-references)
|
||||||
|
// Note: <aside epub:type="footnote"> elements are rendered as normal content
|
||||||
|
// without special handling. Links pointing to them are collected as footnotes.
|
||||||
|
if (strcmp(name, "a") == 0) {
|
||||||
|
const char* href = getAttribute(atts, "href");
|
||||||
|
|
||||||
|
bool isInternalLink = isInternalEpubLink(href);
|
||||||
|
|
||||||
|
// Special case: javascript:void(0) links with data attributes
|
||||||
|
// Example: <a href="javascript:void(0)"
|
||||||
|
// data-xyz="{"name":"OPS/ch2.xhtml","frag":"id46"}">
|
||||||
|
if (href && strncmp(href, "javascript:", 11) == 0) {
|
||||||
|
isInternalLink = false;
|
||||||
|
// TODO: Parse data-* attributes to extract actual href
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInternalLink) {
|
||||||
|
// Flush buffer before style change
|
||||||
|
if (self->partWordBufferIndex > 0) {
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
self->nextWordContinues = true;
|
||||||
|
}
|
||||||
|
self->insideFootnoteLink = true;
|
||||||
|
self->footnoteLinkDepth = self->depth;
|
||||||
|
strncpy(self->currentFootnoteLinkHref, href, sizeof(self->currentFootnoteLinkHref) - 1);
|
||||||
|
self->currentFootnoteLinkHref[sizeof(self->currentFootnoteLinkHref) - 1] = '\0';
|
||||||
|
self->currentFootnoteLinkText[0] = '\0';
|
||||||
|
self->currentFootnoteLinkTextLen = 0;
|
||||||
|
|
||||||
|
// Apply underline style to visually indicate the link
|
||||||
|
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
||||||
|
StyleStackEntry entry;
|
||||||
|
entry.depth = self->depth;
|
||||||
|
entry.hasUnderline = true;
|
||||||
|
entry.underline = true;
|
||||||
|
self->inlineStyleStack.push_back(entry);
|
||||||
|
self->updateEffectiveInlineStyle();
|
||||||
|
|
||||||
|
// Skip CSS resolution — we already handled styling for this <a> tag
|
||||||
|
self->depth += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compute CSS style for this element
|
// Compute CSS style for this element
|
||||||
CssStyle cssStyle;
|
CssStyle cssStyle;
|
||||||
if (self->cssParser) {
|
if (self->cssParser) {
|
||||||
@@ -582,6 +645,19 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect footnote link display text (for the number label)
|
||||||
|
// Skip whitespace and brackets to normalize noterefs like "[1]" → "1"
|
||||||
|
if (self->insideFootnoteLink) {
|
||||||
|
for (int i = 0; i < len; i++) {
|
||||||
|
unsigned char c = static_cast<unsigned char>(s[i]);
|
||||||
|
if (isWhitespace(c) || c == '[' || c == ']') continue;
|
||||||
|
if (self->currentFootnoteLinkTextLen < static_cast<int>(sizeof(self->currentFootnoteLinkText)) - 1) {
|
||||||
|
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen++] = c;
|
||||||
|
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < len; i++) {
|
for (int i = 0; i < len; i++) {
|
||||||
if (isWhitespace(s[i])) {
|
if (isWhitespace(s[i])) {
|
||||||
// Currently looking at whitespace, if there's anything in the partWordBuffer, flush it
|
// Currently looking at whitespace, if there's anything in the partWordBuffer, flush it
|
||||||
@@ -743,6 +819,21 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
|
|
||||||
self->depth -= 1;
|
self->depth -= 1;
|
||||||
|
|
||||||
|
// Closing a footnote link — create entry from collected text and href
|
||||||
|
if (self->insideFootnoteLink && self->depth == self->footnoteLinkDepth) {
|
||||||
|
if (self->currentFootnoteLinkText[0] != '\0' && self->currentFootnoteLinkHref[0] != '\0') {
|
||||||
|
FootnoteEntry entry;
|
||||||
|
strncpy(entry.number, self->currentFootnoteLinkText, sizeof(entry.number) - 1);
|
||||||
|
entry.number[sizeof(entry.number) - 1] = '\0';
|
||||||
|
strncpy(entry.href, self->currentFootnoteLinkHref, sizeof(entry.href) - 1);
|
||||||
|
entry.href[sizeof(entry.href) - 1] = '\0';
|
||||||
|
int wordIndex =
|
||||||
|
self->wordsExtractedInBlock + (self->currentTextBlock ? static_cast<int>(self->currentTextBlock->size()) : 0);
|
||||||
|
self->pendingFootnotes.push_back({wordIndex, entry});
|
||||||
|
}
|
||||||
|
self->insideFootnoteLink = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Leaving skip
|
// Leaving skip
|
||||||
if (self->skipUntilDepth == self->depth) {
|
if (self->skipUntilDepth == self->depth) {
|
||||||
self->skipUntilDepth = INT_MAX;
|
self->skipUntilDepth = INT_MAX;
|
||||||
@@ -910,6 +1001,15 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
|
|||||||
currentPageNextY = 0;
|
currentPageNextY = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track cumulative words to assign footnotes to the page containing their anchor
|
||||||
|
wordsExtractedInBlock += line->wordCount();
|
||||||
|
auto footnoteIt = pendingFootnotes.begin();
|
||||||
|
while (footnoteIt != pendingFootnotes.end() && footnoteIt->first <= wordsExtractedInBlock) {
|
||||||
|
currentPage->addFootnote(footnoteIt->second.number, footnoteIt->second.href);
|
||||||
|
++footnoteIt;
|
||||||
|
}
|
||||||
|
pendingFootnotes.erase(pendingFootnotes.begin(), footnoteIt);
|
||||||
|
|
||||||
// Apply horizontal left inset (margin + padding) as x position offset
|
// Apply horizontal left inset (margin + padding) as x position offset
|
||||||
const int16_t xOffset = line->getBlockStyle().leftInset();
|
const int16_t xOffset = line->getBlockStyle().leftInset();
|
||||||
currentPage->elements.push_back(std::make_shared<PageLine>(line, xOffset, currentPageNextY));
|
currentPage->elements.push_back(std::make_shared<PageLine>(line, xOffset, currentPageNextY));
|
||||||
@@ -947,6 +1047,16 @@ void ChapterHtmlSlimParser::makePages() {
|
|||||||
renderer, fontId, effectiveWidth,
|
renderer, fontId, effectiveWidth,
|
||||||
[this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); });
|
[this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); });
|
||||||
|
|
||||||
|
// Fallback: transfer any remaining pending footnotes to current page.
|
||||||
|
// Normally addLineToPage handles this via word-index tracking, but this catches
|
||||||
|
// edge cases where a footnote's word index equals the exact block size.
|
||||||
|
if (!pendingFootnotes.empty() && currentPage) {
|
||||||
|
for (const auto& [idx, fn] : pendingFootnotes) {
|
||||||
|
currentPage->addFootnote(fn.number, fn.href);
|
||||||
|
}
|
||||||
|
pendingFootnotes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Apply bottom spacing after the paragraph (stored in pixels)
|
// Apply bottom spacing after the paragraph (stored in pixels)
|
||||||
if (blockStyle.marginBottom > 0) {
|
if (blockStyle.marginBottom > 0) {
|
||||||
currentPageNextY += blockStyle.marginBottom;
|
currentPageNextY += blockStyle.marginBottom;
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
#include <climits>
|
#include <climits>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../FootnoteEntry.h"
|
||||||
#include "../ParsedText.h"
|
#include "../ParsedText.h"
|
||||||
#include "../blocks/ImageBlock.h"
|
#include "../blocks/ImageBlock.h"
|
||||||
#include "../blocks/TextBlock.h"
|
#include "../blocks/TextBlock.h"
|
||||||
@@ -66,6 +68,15 @@ class ChapterHtmlSlimParser {
|
|||||||
int tableRowIndex = 0;
|
int tableRowIndex = 0;
|
||||||
int tableColIndex = 0;
|
int tableColIndex = 0;
|
||||||
|
|
||||||
|
// Footnote link tracking
|
||||||
|
bool insideFootnoteLink = false;
|
||||||
|
int footnoteLinkDepth = -1;
|
||||||
|
char currentFootnoteLinkText[24] = {};
|
||||||
|
int currentFootnoteLinkTextLen = 0;
|
||||||
|
char currentFootnoteLinkHref[64] = {};
|
||||||
|
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
|
||||||
|
int wordsExtractedInBlock = 0;
|
||||||
|
|
||||||
void updateEffectiveInlineStyle();
|
void updateEffectiveInlineStyle();
|
||||||
void startNewTextBlock(const BlockStyle& blockStyle);
|
void startNewTextBlock(const BlockStyle& blockStyle);
|
||||||
void flushPartWordBuffer();
|
void flushPartWordBuffer();
|
||||||
|
|||||||
@@ -331,4 +331,7 @@ STR_UPLOAD: "Upload"
|
|||||||
STR_BOOK_S_STYLE: "Book's Style"
|
STR_BOOK_S_STYLE: "Book's Style"
|
||||||
STR_EMBEDDED_STYLE: "Embedded Style"
|
STR_EMBEDDED_STYLE: "Embedded Style"
|
||||||
STR_OPDS_SERVER_URL: "OPDS Server URL"
|
STR_OPDS_SERVER_URL: "OPDS Server URL"
|
||||||
|
STR_FOOTNOTES: "Footnotes"
|
||||||
|
STR_NO_FOOTNOTES: "No footnotes on this page"
|
||||||
|
STR_LINK: "[link]"
|
||||||
STR_SCREENSHOT_BUTTON: "Take screenshot"
|
STR_SCREENSHOT_BUTTON: "Take screenshot"
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "EpubReaderChapterSelectionActivity.h"
|
#include "EpubReaderChapterSelectionActivity.h"
|
||||||
|
#include "EpubReaderFootnotesActivity.h"
|
||||||
#include "EpubReaderPercentSelectionActivity.h"
|
#include "EpubReaderPercentSelectionActivity.h"
|
||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
#include "KOReaderSyncActivity.h"
|
#include "KOReaderSyncActivity.h"
|
||||||
@@ -177,7 +178,8 @@ void EpubReaderActivity::loop() {
|
|||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new EpubReaderMenuActivity(
|
enterNewActivity(new EpubReaderMenuActivity(
|
||||||
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||||
SETTINGS.orientation, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
SETTINGS.orientation, !currentPageFootnotes.empty(),
|
||||||
|
[this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
||||||
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +189,12 @@ void EpubReaderActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Short press BACK goes directly to home
|
// Short press BACK goes directly to home (or restores position if viewing footnote)
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
|
||||||
|
if (footnoteDepth > 0) {
|
||||||
|
restoreSavedPosition();
|
||||||
|
return;
|
||||||
|
}
|
||||||
onGoHome();
|
onGoHome();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -379,6 +385,23 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case EpubReaderMenuActivity::MenuAction::FOOTNOTES: {
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new EpubReaderFootnotesActivity(
|
||||||
|
this->renderer, this->mappedInput, currentPageFootnotes,
|
||||||
|
[this] {
|
||||||
|
// Go back from footnotes list
|
||||||
|
exitActivity();
|
||||||
|
requestUpdate();
|
||||||
|
},
|
||||||
|
[this](const char* href) {
|
||||||
|
// Navigate to selected footnote
|
||||||
|
navigateToHref(href, true);
|
||||||
|
exitActivity();
|
||||||
|
requestUpdate();
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: {
|
case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: {
|
||||||
// Launch the slider-based percent selector and return here on confirm/cancel.
|
// Launch the slider-based percent selector and return here on confirm/cancel.
|
||||||
float bookProgress = 0.0f;
|
float bookProgress = 0.0f;
|
||||||
@@ -641,6 +664,10 @@ void EpubReaderActivity::render(Activity::RenderLock&& lock) {
|
|||||||
// TODO: prevent infinite loop if the page keeps failing to load for some reason
|
// TODO: prevent infinite loop if the page keeps failing to load for some reason
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect footnotes from the loaded page
|
||||||
|
currentPageFootnotes = std::move(p->footnotes);
|
||||||
|
|
||||||
const auto start = millis();
|
const auto start = millis();
|
||||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
||||||
@@ -757,3 +784,57 @@ void EpubReaderActivity::renderStatusBar() const {
|
|||||||
|
|
||||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title);
|
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void EpubReaderActivity::navigateToHref(const char* href, const bool savePosition) {
|
||||||
|
if (!epub || !href) return;
|
||||||
|
|
||||||
|
// Push current position onto saved stack
|
||||||
|
if (savePosition && section && footnoteDepth < MAX_FOOTNOTE_DEPTH) {
|
||||||
|
savedPositions[footnoteDepth] = {currentSpineIndex, section->currentPage};
|
||||||
|
footnoteDepth++;
|
||||||
|
LOG_DBG("ERS", "Saved position [%d]: spine %d, page %d", footnoteDepth, currentSpineIndex, section->currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string hrefStr(href);
|
||||||
|
|
||||||
|
// Check for same-file anchor reference (#anchor only)
|
||||||
|
bool sameFile = !hrefStr.empty() && hrefStr[0] == '#';
|
||||||
|
|
||||||
|
int targetSpineIndex;
|
||||||
|
if (sameFile) {
|
||||||
|
// Same file — navigate to page 0 of current spine item
|
||||||
|
targetSpineIndex = currentSpineIndex;
|
||||||
|
} else {
|
||||||
|
targetSpineIndex = epub->resolveHrefToSpineIndex(hrefStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetSpineIndex < 0) {
|
||||||
|
LOG_DBG("ERS", "Could not resolve href: %s", href);
|
||||||
|
if (savePosition && footnoteDepth > 0) footnoteDepth--; // undo push
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
currentSpineIndex = targetSpineIndex;
|
||||||
|
nextPageNumber = 0;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
requestUpdate();
|
||||||
|
LOG_DBG("ERS", "Navigated to spine %d for href: %s", targetSpineIndex, href);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderActivity::restoreSavedPosition() {
|
||||||
|
if (footnoteDepth <= 0) return;
|
||||||
|
footnoteDepth--;
|
||||||
|
const auto& pos = savedPositions[footnoteDepth];
|
||||||
|
LOG_DBG("ERS", "Restoring position [%d]: spine %d, page %d", footnoteDepth, pos.spineIndex, pos.pageNumber);
|
||||||
|
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
currentSpineIndex = pos.spineIndex;
|
||||||
|
nextPageNumber = pos.pageNumber;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
|
#include <Epub/FootnoteEntry.h>
|
||||||
#include <Epub/Section.h>
|
#include <Epub/Section.h>
|
||||||
|
|
||||||
#include "EpubReaderMenuActivity.h"
|
#include "EpubReaderMenuActivity.h"
|
||||||
@@ -25,6 +26,16 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
|||||||
const std::function<void()> onGoBack;
|
const std::function<void()> onGoBack;
|
||||||
const std::function<void()> onGoHome;
|
const std::function<void()> onGoHome;
|
||||||
|
|
||||||
|
// Footnote support
|
||||||
|
std::vector<FootnoteEntry> currentPageFootnotes;
|
||||||
|
struct SavedPosition {
|
||||||
|
int spineIndex;
|
||||||
|
int pageNumber;
|
||||||
|
};
|
||||||
|
static constexpr int MAX_FOOTNOTE_DEPTH = 3;
|
||||||
|
SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {};
|
||||||
|
int footnoteDepth = 0;
|
||||||
|
|
||||||
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
||||||
int orientedMarginBottom, int orientedMarginLeft);
|
int orientedMarginBottom, int orientedMarginLeft);
|
||||||
void renderStatusBar() const;
|
void renderStatusBar() const;
|
||||||
@@ -35,6 +46,10 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
|||||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
||||||
void applyOrientation(uint8_t orientation);
|
void applyOrientation(uint8_t orientation);
|
||||||
|
|
||||||
|
// Footnote navigation
|
||||||
|
void navigateToHref(const char* href, bool savePosition = false);
|
||||||
|
void restoreSavedPosition();
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
|
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
|
||||||
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
|
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
|
||||||
|
|||||||
95
src/activities/reader/EpubReaderFootnotesActivity.cpp
Normal file
95
src/activities/reader/EpubReaderFootnotesActivity.cpp
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#include "EpubReaderFootnotesActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <I18n.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
void EpubReaderFootnotesActivity::onEnter() {
|
||||||
|
ActivityWithSubactivity::onEnter();
|
||||||
|
selectedIndex = 0;
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderFootnotesActivity::onExit() { ActivityWithSubactivity::onExit(); }
|
||||||
|
|
||||||
|
void EpubReaderFootnotesActivity::loop() {
|
||||||
|
if (subActivity) {
|
||||||
|
subActivity->loop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
onGoBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(footnotes.size())) {
|
||||||
|
onSelectFootnote(footnotes[selectedIndex].href);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonNavigator.onNext([this] {
|
||||||
|
if (!footnotes.empty()) {
|
||||||
|
selectedIndex = (selectedIndex + 1) % footnotes.size();
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator.onPrevious([this] {
|
||||||
|
if (!footnotes.empty()) {
|
||||||
|
selectedIndex = (selectedIndex - 1 + footnotes.size()) % footnotes.size();
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderFootnotesActivity::render(Activity::RenderLock&&) {
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_FOOTNOTES), true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
if (footnotes.empty()) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 90, tr(STR_NO_FOOTNOTES));
|
||||||
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int startY = 50;
|
||||||
|
constexpr int lineHeight = 36;
|
||||||
|
const int screenWidth = renderer.getScreenWidth();
|
||||||
|
constexpr int marginLeft = 20;
|
||||||
|
|
||||||
|
const int visibleCount = std::max(1, (renderer.getScreenHeight() - startY) / lineHeight);
|
||||||
|
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
|
||||||
|
if (selectedIndex >= scrollOffset + visibleCount) scrollOffset = selectedIndex - visibleCount + 1;
|
||||||
|
|
||||||
|
for (int i = scrollOffset; i < static_cast<int>(footnotes.size()) && i < scrollOffset + visibleCount; i++) {
|
||||||
|
const int y = startY + (i - scrollOffset) * lineHeight;
|
||||||
|
const bool isSelected = (i == selectedIndex);
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
renderer.fillRect(0, y, screenWidth, lineHeight, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show footnote number and abbreviated href
|
||||||
|
std::string label = footnotes[i].number;
|
||||||
|
if (label.empty()) {
|
||||||
|
label = tr(STR_LINK);
|
||||||
|
}
|
||||||
|
renderer.drawText(UI_10_FONT_ID, marginLeft, y + 4, label.c_str(), !isSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "", "");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
|
renderer.displayBuffer();
|
||||||
|
}
|
||||||
35
src/activities/reader/EpubReaderFootnotesActivity.h
Normal file
35
src/activities/reader/EpubReaderFootnotesActivity.h
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Epub/FootnoteEntry.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <functional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../ActivityWithSubactivity.h"
|
||||||
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
|
class EpubReaderFootnotesActivity final : public ActivityWithSubactivity {
|
||||||
|
public:
|
||||||
|
explicit EpubReaderFootnotesActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
const std::vector<FootnoteEntry>& footnotes,
|
||||||
|
const std::function<void()>& onGoBack,
|
||||||
|
const std::function<void(const char*)>& onSelectFootnote)
|
||||||
|
: ActivityWithSubactivity("EpubReaderFootnotes", renderer, mappedInput),
|
||||||
|
footnotes(footnotes),
|
||||||
|
onGoBack(onGoBack),
|
||||||
|
onSelectFootnote(onSelectFootnote) {}
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
void render(Activity::RenderLock&&) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const std::vector<FootnoteEntry>& footnotes;
|
||||||
|
const std::function<void()> onGoBack;
|
||||||
|
const std::function<void(const char*)> onSelectFootnote;
|
||||||
|
int selectedIndex = 0;
|
||||||
|
int scrollOffset = 0;
|
||||||
|
ButtonNavigator buttonNavigator;
|
||||||
|
};
|
||||||
@@ -7,6 +7,38 @@
|
|||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
const std::string& title, const int currentPage, const int totalPages,
|
||||||
|
const int bookProgressPercent, const uint8_t currentOrientation,
|
||||||
|
const bool hasFootnotes, const std::function<void(uint8_t)>& onBack,
|
||||||
|
const std::function<void(MenuAction)>& onAction)
|
||||||
|
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
||||||
|
menuItems(buildMenuItems(hasFootnotes)),
|
||||||
|
title(title),
|
||||||
|
pendingOrientation(currentOrientation),
|
||||||
|
currentPage(currentPage),
|
||||||
|
totalPages(totalPages),
|
||||||
|
bookProgressPercent(bookProgressPercent),
|
||||||
|
onBack(onBack),
|
||||||
|
onAction(onAction) {}
|
||||||
|
|
||||||
|
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) {
|
||||||
|
std::vector<MenuItem> items;
|
||||||
|
items.reserve(9);
|
||||||
|
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
|
||||||
|
if (hasFootnotes) {
|
||||||
|
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
|
||||||
|
}
|
||||||
|
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION});
|
||||||
|
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
|
||||||
|
items.push_back({MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON});
|
||||||
|
items.push_back({MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR});
|
||||||
|
items.push_back({MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON});
|
||||||
|
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
|
||||||
|
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
void EpubReaderMenuActivity::onEnter() {
|
void EpubReaderMenuActivity::onEnter() {
|
||||||
ActivityWithSubactivity::onEnter();
|
ActivityWithSubactivity::onEnter();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
|||||||
// Menu actions available from the reader menu.
|
// Menu actions available from the reader menu.
|
||||||
enum class MenuAction {
|
enum class MenuAction {
|
||||||
SELECT_CHAPTER,
|
SELECT_CHAPTER,
|
||||||
|
FOOTNOTES,
|
||||||
GO_TO_PERCENT,
|
GO_TO_PERCENT,
|
||||||
ROTATE_SCREEN,
|
ROTATE_SCREEN,
|
||||||
SCREENSHOT,
|
SCREENSHOT,
|
||||||
@@ -25,16 +26,9 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
|||||||
|
|
||||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||||
const int currentPage, const int totalPages, const int bookProgressPercent,
|
const int currentPage, const int totalPages, const int bookProgressPercent,
|
||||||
const uint8_t currentOrientation, const std::function<void(uint8_t)>& onBack,
|
const uint8_t currentOrientation, const bool hasFootnotes,
|
||||||
const std::function<void(MenuAction)>& onAction)
|
const std::function<void(uint8_t)>& onBack,
|
||||||
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
const std::function<void(MenuAction)>& onAction);
|
||||||
title(title),
|
|
||||||
pendingOrientation(currentOrientation),
|
|
||||||
currentPage(currentPage),
|
|
||||||
totalPages(totalPages),
|
|
||||||
bookProgressPercent(bookProgressPercent),
|
|
||||||
onBack(onBack),
|
|
||||||
onAction(onAction) {}
|
|
||||||
|
|
||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
void onExit() override;
|
void onExit() override;
|
||||||
@@ -47,15 +41,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
|||||||
StrId labelId;
|
StrId labelId;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fixed menu layout (order matters for up/down navigation).
|
static std::vector<MenuItem> buildMenuItems(bool hasFootnotes);
|
||||||
const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER},
|
|
||||||
{MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION},
|
// Fixed menu layout
|
||||||
{MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT},
|
const std::vector<MenuItem> menuItems;
|
||||||
{MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON},
|
|
||||||
{MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR},
|
|
||||||
{MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON},
|
|
||||||
{MenuAction::SYNC, StrId::STR_SYNC_PROGRESS},
|
|
||||||
{MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}};
|
|
||||||
int selectedIndex = 0;
|
int selectedIndex = 0;
|
||||||
|
|
||||||
ButtonNavigator buttonNavigator;
|
ButtonNavigator buttonNavigator;
|
||||||
|
|||||||
Reference in New Issue
Block a user