General improvements (#151)
* Updated folder synchronization logic and redesigned the file type filter dialog. * Fixed CFI generation and navigation stability in the EPUB reader. * Improved CFI scrolling and position calculation in the EPUB reader by implementing text node traversal using `TreeWalker`. This ensures accurate positioning and scrolling when a CFI offset spans multiple fragmented text nodes. * fix fb2 multiline titles, retain footnotes, and prevent stream leaks - Fix FB2 titles with multiple paragraphs by inserting breaks/spaces - Prevent resource leaks by properly closing InputStreams in all importers - Retain "notes" and "comments" sections in FB2 instead of skipping them - Add support for FB2 poem, stanza, cite, and link tags * Implement persistence for zoom and pan states when pan lock is enabled in the PDF reader. * Added `FileTypeBadge` to home and library screens * Bump version to 1.0.41(42)
This commit is contained in:
parent
65e0570d0e
commit
26692d2c05
14 changed files with 747 additions and 664 deletions
|
|
@ -30,8 +30,8 @@ android {
|
||||||
applicationId = "com.aryan.reader"
|
applicationId = "com.aryan.reader"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 41
|
versionCode = 42
|
||||||
versionName = "1.0.40"
|
versionName = "1.0.41"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
|
|
|
||||||
|
|
@ -588,7 +588,9 @@
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (window.reportScrollState) {
|
if (window.triggerInitialScrollStateReport) {
|
||||||
|
window.triggerInitialScrollStateReport();
|
||||||
|
} else if (window.reportScrollState) {
|
||||||
setTimeout(window.reportScrollState, 60);
|
setTimeout(window.reportScrollState, 60);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -693,26 +695,20 @@
|
||||||
|
|
||||||
window.triggerInitialScrollStateReport = function () {
|
window.triggerInitialScrollStateReport = function () {
|
||||||
var attempts = 0;
|
var attempts = 0;
|
||||||
var maxAttempts = 7;
|
var maxAttempts = 15;
|
||||||
var baseInterval = 100;
|
|
||||||
|
|
||||||
function tryReport() {
|
function tryReport() {
|
||||||
attempts++;
|
attempts++;
|
||||||
window.reportScrollState();
|
if (window.reportScrollState) {
|
||||||
var currentClientHeight = document.documentElement.clientHeight || window.innerHeight || 0;
|
window.reportScrollState();
|
||||||
|
|
||||||
if (currentClientHeight > 0) {
|
|
||||||
setTimeout(window.reportScrollState, 50);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempts < maxAttempts) {
|
if (attempts < maxAttempts) {
|
||||||
var retryDelay = baseInterval + attempts * 50;
|
setTimeout(tryReport, 200);
|
||||||
setTimeout(tryReport, retryDelay);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(tryReport, baseInterval);
|
setTimeout(tryReport, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.scrollToChapterStart = function () {
|
window.scrollToChapterStart = function () {
|
||||||
|
|
@ -1418,223 +1414,131 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCfiPath(rootElement, path) {
|
function resolveCfiPath(rootElement, path) {
|
||||||
log(`Attempting to resolve path '${path}' from root <$ {
|
|
||||||
rootElement.tagName
|
|
||||||
}
|
|
||||||
|
|
||||||
>`);
|
|
||||||
let currentNode = rootElement;
|
let currentNode = rootElement;
|
||||||
const steps = path.substring(1).split("/").map(Number);
|
const steps = path.substring(1).split("/").map(Number);
|
||||||
|
|
||||||
for (let i = 0; i < steps.length; i++) {
|
for (let i = 0; i < steps.length; i++) {
|
||||||
const cfiIndex = steps[i];
|
const cfiIndex = steps[i];
|
||||||
|
if (!currentNode) return null;
|
||||||
|
|
||||||
if (!currentNode) {
|
// Handle virtualized content container specially
|
||||||
log(`Traversal failed: currentNode became null before step $ {
|
if (currentNode.id === 'content-container') {
|
||||||
i
|
const childNodeIndex = (cfiIndex - 2) / 2;
|
||||||
|
let chunkIndex = Math.floor(childNodeIndex / 20);
|
||||||
|
let indexInChunk = childNodeIndex % 20;
|
||||||
|
|
||||||
|
let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`);
|
||||||
|
if (chunkElement) {
|
||||||
|
let elementsInChunk = Array.from(chunkElement.childNodes).filter(n => n.nodeType === Node.ELEMENT_NODE);
|
||||||
|
if (indexInChunk >= 0 && indexInChunk < elementsInChunk.length) {
|
||||||
|
currentNode = elementsInChunk[indexInChunk];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(CFI index $ {
|
|
||||||
cfiIndex
|
|
||||||
}).`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elementChildren = Array.from(currentNode.childNodes).filter((node) => node.nodeType === Node.ELEMENT_NODE);
|
let elementChildren = Array.from(currentNode.childNodes).filter((node) => node.nodeType === Node.ELEMENT_NODE);
|
||||||
const childNodeIndex = (cfiIndex - 2) / 2;
|
const childNodeIndex = (cfiIndex - 2) / 2;
|
||||||
|
|
||||||
if (childNodeIndex >= 0 && childNodeIndex < elementChildren.length) {
|
if (childNodeIndex >= 0 && childNodeIndex < elementChildren.length) {
|
||||||
currentNode = elementChildren[childNodeIndex];
|
currentNode = elementChildren[childNodeIndex];
|
||||||
} else {
|
} else {
|
||||||
const childrenTags = elementChildren
|
return null;
|
||||||
.map(
|
|
||||||
(node) => `<$ {
|
|
||||||
node.tagName || "TEXT"
|
|
||||||
}
|
|
||||||
|
|
||||||
>`,
|
|
||||||
)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
log(`Step $ {
|
|
||||||
i
|
|
||||||
}
|
|
||||||
|
|
||||||
: FAILED. CFI index $ {
|
|
||||||
cfiIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
(child index $ {
|
|
||||||
childNodeIndex
|
|
||||||
|
|
||||||
}) is out of bounds for $ {
|
|
||||||
elementChildren.length
|
|
||||||
}
|
|
||||||
|
|
||||||
element children: [$ {
|
|
||||||
childrenTags
|
|
||||||
}
|
|
||||||
|
|
||||||
]`);
|
|
||||||
|
|
||||||
log(`Parent Node HTML at failure point (<$ {
|
|
||||||
currentNode.tagName
|
|
||||||
}
|
|
||||||
|
|
||||||
>): $ {
|
|
||||||
currentNode.innerHTML.substring(0, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
...`); // ADD THIS LINE
|
|
||||||
return null; // Path is invalid from this root
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentNode;
|
return currentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.getNodeAndOffsetFromCfi = function (cfi) {
|
window.getNodeAndOffsetFromCfi = function (cfi) {
|
||||||
log(`getNodeAndOffsetFromCfi called with: $ {
|
|
||||||
cfi
|
|
||||||
}
|
|
||||||
|
|
||||||
`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var pathParts = cfi.split(":");
|
var pathParts = cfi.split(":");
|
||||||
var nodePath = pathParts[0];
|
var nodePath = pathParts[0];
|
||||||
var charOffset = pathParts.length > 1 ? parseInt(pathParts[1], 10) : 0;
|
var charOffset = pathParts.length > 1 ? parseInt(pathParts[1], 10) : 0;
|
||||||
|
|
||||||
log(`Parsed CFI: path=$ {
|
|
||||||
nodePath
|
|
||||||
}
|
|
||||||
|
|
||||||
, offset=$ {
|
|
||||||
charOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
`);
|
|
||||||
|
|
||||||
let cfiRoot = document.getElementById("content-container") || document.body;
|
let cfiRoot = document.getElementById("content-container") || document.body;
|
||||||
let pathToResolve = nodePath;
|
let pathToResolve = nodePath;
|
||||||
|
|
||||||
const firstChunk = cfiRoot.querySelector("[data-chunk-index]");
|
// Strip the /4 prefix because cfiRoot (content-container or body) represents /4
|
||||||
|
if (pathToResolve.startsWith("/4/")) {
|
||||||
if (firstChunk && pathToResolve.startsWith("/4/")) {
|
|
||||||
log(`Paginator CFI detected. Adjusting root to first chunk and stripping '/4' from path.`);
|
|
||||||
cfiRoot = firstChunk;
|
|
||||||
pathToResolve = "/" + pathToResolve.substring(3);
|
pathToResolve = "/" + pathToResolve.substring(3);
|
||||||
|
} else if (pathToResolve === "/4") {
|
||||||
|
pathToResolve = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`CFI Root is <$ {
|
if (!pathToResolve) {
|
||||||
cfiRoot.tagName
|
return { node: cfiRoot, offset: charOffset };
|
||||||
}
|
}
|
||||||
|
|
||||||
>, Path to resolve is $ {
|
|
||||||
pathToResolve
|
|
||||||
}
|
|
||||||
|
|
||||||
`);
|
|
||||||
|
|
||||||
let resolvedNode = resolveCfiPath(cfiRoot, pathToResolve);
|
let resolvedNode = resolveCfiPath(cfiRoot, pathToResolve);
|
||||||
|
|
||||||
log(`Resolution attempt #1 (from CFI root) result: $ {
|
if (!resolvedNode) return null;
|
||||||
resolvedNode ? resolvedNode.tagName : 'null'
|
|
||||||
}
|
|
||||||
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (!resolvedNode) {
|
|
||||||
log("Resolution failed from all roots.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentNode = resolvedNode;
|
let currentNode = resolvedNode;
|
||||||
|
|
||||||
log(`Successfully resolved containing element: <$ {
|
|
||||||
currentNode.tagName || 'TEXT_NODE'
|
|
||||||
}
|
|
||||||
|
|
||||||
>`);
|
|
||||||
|
|
||||||
if (currentNode.nodeType === Node.ELEMENT_NODE) {
|
if (currentNode.nodeType === Node.ELEMENT_NODE) {
|
||||||
const treeWalker = document.createTreeWalker(currentNode, NodeFilter.SHOW_TEXT, null, false);
|
const treeWalker = document.createTreeWalker(currentNode, NodeFilter.SHOW_TEXT, null, false);
|
||||||
const firstTextNode = treeWalker.nextNode();
|
const firstTextNode = treeWalker.nextNode();
|
||||||
|
|
||||||
if (firstTextNode) {
|
if (firstTextNode) {
|
||||||
log(`Found first text node inside element to apply offset.`);
|
|
||||||
currentNode = firstTextNode;
|
currentNode = firstTextNode;
|
||||||
} else {
|
|
||||||
log(`Could not find a text node inside the target element. Using the element itself.`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { node: currentNode, offset: charOffset };
|
return { node: currentNode, offset: charOffset };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`ERROR in getNodeAndOffsetFromCfi: $ {
|
|
||||||
e.message
|
|
||||||
}
|
|
||||||
|
|
||||||
`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.getCfiPathForElement = function (element, charOffset) {
|
window.getCfiPathForElement = function (element, charOffset) {
|
||||||
const logStack = [];
|
const logStack = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var path = [];
|
var path =[];
|
||||||
var currentNode = element;
|
var currentNode = element;
|
||||||
|
|
||||||
if (currentNode.nodeType === Node.TEXT_NODE) {
|
if (currentNode.nodeType === Node.TEXT_NODE) {
|
||||||
logStack.push(`Initial node is a TEXT_NODE. Calculating cumulative offset.`);
|
|
||||||
|
|
||||||
// --- FIX: Accumulate offsets from previous siblings ---
|
|
||||||
var accumulatedOffset = charOffset || 0;
|
var accumulatedOffset = charOffset || 0;
|
||||||
var sibling = currentNode.previousSibling;
|
var sibling = currentNode.previousSibling;
|
||||||
|
|
||||||
while (sibling) {
|
while (sibling) {
|
||||||
if (sibling.nodeType === Node.TEXT_NODE) {
|
if (sibling.nodeType === Node.TEXT_NODE) {
|
||||||
accumulatedOffset += sibling.nodeValue.length;
|
accumulatedOffset += sibling.nodeValue.length;
|
||||||
} else if (sibling.nodeType === Node.ELEMENT_NODE) {
|
} else if (sibling.nodeType === Node.ELEMENT_NODE) {
|
||||||
// Elements like <em>, <b> contribute text content to the flow
|
|
||||||
accumulatedOffset += (sibling.textContent || "").length;
|
accumulatedOffset += (sibling.textContent || "").length;
|
||||||
}
|
}
|
||||||
|
|
||||||
sibling = sibling.previousSibling;
|
sibling = sibling.previousSibling;
|
||||||
}
|
}
|
||||||
|
|
||||||
logStack.push(`Original offset: $ {
|
|
||||||
charOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
, Cumulative offset: $ {
|
|
||||||
accumulatedOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
`);
|
|
||||||
charOffset = accumulatedOffset;
|
charOffset = accumulatedOffset;
|
||||||
// -----------------------------------------------------
|
|
||||||
|
|
||||||
logStack.push(`Using its parent <$ {
|
|
||||||
currentNode.parentNode.tagName
|
|
||||||
}
|
|
||||||
|
|
||||||
> for path generation.`);
|
|
||||||
currentNode = currentNode.parentNode;
|
currentNode = currentNode.parentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = document.getElementById("content-container") || document.body;
|
const root = document.getElementById("content-container") || document.body;
|
||||||
|
|
||||||
logStack.push(`Using <$ {
|
|
||||||
root.tagName
|
|
||||||
}
|
|
||||||
|
|
||||||
id='${root.id}' class='${root.className}' > as the consistent CFI root.`);
|
|
||||||
|
|
||||||
while (currentNode && currentNode !== root && currentNode.parentNode) {
|
while (currentNode && currentNode !== root && currentNode.parentNode) {
|
||||||
const parentNode = currentNode.parentNode;
|
let parentNode = currentNode.parentNode;
|
||||||
|
|
||||||
|
if (parentNode.classList && parentNode.classList.contains('chunk-container')) {
|
||||||
|
let trueParent = parentNode.parentNode;
|
||||||
|
let elementSiblingsInChunk = Array.from(parentNode.childNodes).filter(node => node.nodeType === Node.ELEMENT_NODE);
|
||||||
|
let indexInChunk = elementSiblingsInChunk.indexOf(currentNode);
|
||||||
|
|
||||||
|
if (indexInChunk === -1) {
|
||||||
|
currentNode = trueParent;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunkIndex = parseInt(parentNode.dataset.chunkIndex, 10);
|
||||||
|
let elementsInPrecedingChunks = chunkIndex * 20;
|
||||||
|
|
||||||
|
let trueIndex = elementsInPrecedingChunks + indexInChunk;
|
||||||
|
let cfiIndex = trueIndex * 2 + 2;
|
||||||
|
path.unshift(cfiIndex);
|
||||||
|
|
||||||
|
logStack.push(`Chunk ${chunkIndex}, IdxInChunk ${indexInChunk}, TrueIdx ${trueIndex} -> CFI /${cfiIndex}`);
|
||||||
|
|
||||||
|
currentNode = trueParent;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const elementSiblings = Array.from(parentNode.childNodes).filter((node) => node.nodeType === Node.ELEMENT_NODE);
|
const elementSiblings = Array.from(parentNode.childNodes).filter((node) => node.nodeType === Node.ELEMENT_NODE);
|
||||||
const nodeIndex = elementSiblings.indexOf(currentNode);
|
const nodeIndex = elementSiblings.indexOf(currentNode);
|
||||||
|
|
||||||
|
|
@ -1645,110 +1549,56 @@
|
||||||
|
|
||||||
const cfiIndex = nodeIndex * 2 + 2;
|
const cfiIndex = nodeIndex * 2 + 2;
|
||||||
path.unshift(cfiIndex);
|
path.unshift(cfiIndex);
|
||||||
|
|
||||||
const childrenTags = elementSiblings
|
|
||||||
.map(
|
|
||||||
(node) => `<$ {
|
|
||||||
node.tagName || "TEXT"
|
|
||||||
}
|
|
||||||
|
|
||||||
>`,
|
|
||||||
)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
logStack.push(`GENERATION: Parent <$ {
|
|
||||||
parentNode.tagName
|
|
||||||
}
|
|
||||||
|
|
||||||
> has $ {
|
|
||||||
elementSiblings.length
|
|
||||||
}
|
|
||||||
|
|
||||||
element children: [$ {
|
|
||||||
childrenTags
|
|
||||||
}
|
|
||||||
|
|
||||||
]. Current node <$ {
|
|
||||||
currentNode.tagName
|
|
||||||
}
|
|
||||||
|
|
||||||
> is at index $ {
|
|
||||||
nodeIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
, becoming CFI step $ {
|
|
||||||
cfiIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
. Path: /$ {
|
|
||||||
path.join('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
`);
|
|
||||||
|
|
||||||
currentNode = parentNode;
|
currentNode = parentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfi = `/` + path.join("/");
|
var cfi = "/4";
|
||||||
|
if (path.length > 0) {
|
||||||
|
cfi += "/" + path.join("/");
|
||||||
|
}
|
||||||
if (charOffset !== undefined && charOffset > 0) {
|
if (charOffset !== undefined && charOffset > 0) {
|
||||||
cfi += ":" + charOffset;
|
cfi += ":" + charOffset;
|
||||||
}
|
}
|
||||||
|
logStack.push(`Generated CFI: ${cfi}`);
|
||||||
logStack.push(`Final CFI generated: $ {
|
|
||||||
cfi
|
|
||||||
}
|
|
||||||
|
|
||||||
`);
|
|
||||||
|
|
||||||
return { cfi: cfi, log: logStack };
|
return { cfi: cfi, log: logStack };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logStack.push(`ERROR in getCfiPathForElement: $ {
|
logStack.push("Error: " + e.message);
|
||||||
e.message
|
return { cfi: "/4/2", log: logStack };
|
||||||
}
|
|
||||||
|
|
||||||
`);
|
|
||||||
|
|
||||||
return { cfi: `/2`, log: logStack };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.getCurrentCfi = function() {
|
window.getCurrentCfi = function() {
|
||||||
const debugLog = [];
|
const debugLog =[];
|
||||||
let finalCfi = "/2"; // Fallback to root
|
let finalCfi = "/4/2";
|
||||||
|
|
||||||
function logCfi(m) { debugLog.push(m); }
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const viewportX = window.innerWidth / 2;
|
const viewportX = window.innerWidth / 2;
|
||||||
// Probe slightly further down to avoid headers/padding issues
|
let viewportY = window.VIEWPORT_PADDING_TOP + 5;
|
||||||
const viewportY = window.VIEWPORT_PADDING_TOP + 50;
|
let topElement = null;
|
||||||
|
|
||||||
logCfi("Probing for CFI at " + viewportX + "," + viewportY);
|
for (let i = 0; i < 10; i++) {
|
||||||
|
let el = document.elementFromPoint(viewportX, viewportY);
|
||||||
|
if (el && el.id !== 'content-container' &&
|
||||||
|
!(el.classList && el.classList.contains('chunk-container') && el.innerText.trim() === "")) {
|
||||||
|
topElement = el;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
viewportY += 15;
|
||||||
|
}
|
||||||
|
|
||||||
let topElement = document.elementFromPoint(viewportX, viewportY);
|
if (!topElement || topElement.id === 'content-container' || (topElement.classList && topElement.classList.contains('chunk-container'))) {
|
||||||
|
const elements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, span');
|
||||||
// FIX: Ignore empty chunk containers or the container wrapper itself
|
|
||||||
if (topElement && (topElement.id === 'content-container' ||
|
|
||||||
(topElement.classList && topElement.classList.contains('chunk-container') && topElement.innerText.trim() === ""))) {
|
|
||||||
|
|
||||||
logCfi("Hit empty chunk or container. scanning for first visible content...");
|
|
||||||
// Fallback: Query specifically for content elements
|
|
||||||
const elements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, span, div.chunk-container');
|
|
||||||
for (let i = 0; i < elements.length; i++) {
|
for (let i = 0; i < elements.length; i++) {
|
||||||
const el = elements[i];
|
const el = elements[i];
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
// Find first element that is effectively visible below the top padding
|
|
||||||
if (rect.bottom > window.VIEWPORT_PADDING_TOP && el.innerText.trim().length > 0) {
|
if (rect.bottom > window.VIEWPORT_PADDING_TOP && el.innerText.trim().length > 0) {
|
||||||
topElement = el;
|
topElement = el;
|
||||||
logCfi("Found alternative content element: <" + el.tagName + ">");
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!topElement) {
|
if (!topElement) {
|
||||||
logCfi("No element found. Fallback to body first child.");
|
|
||||||
topElement = document.body.firstElementChild;
|
topElement = document.body.firstElementChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1756,9 +1606,6 @@
|
||||||
return JSON.stringify({ cfi: finalCfi, log: debugLog });
|
return JSON.stringify({ cfi: finalCfi, log: debugLog });
|
||||||
}
|
}
|
||||||
|
|
||||||
logCfi("Selected Reference Element: <" + topElement.tagName + "> ID:" + topElement.id);
|
|
||||||
|
|
||||||
// Try caret range for precision
|
|
||||||
let range = null;
|
let range = null;
|
||||||
if (document.caretRangeFromPoint) {
|
if (document.caretRangeFromPoint) {
|
||||||
range = document.caretRangeFromPoint(viewportX, viewportY);
|
range = document.caretRangeFromPoint(viewportX, viewportY);
|
||||||
|
|
@ -1770,27 +1617,22 @@
|
||||||
if (range && range.startContainer && range.startContainer.nodeType === Node.TEXT_NODE) {
|
if (range && range.startContainer && range.startContainer.nodeType === Node.TEXT_NODE) {
|
||||||
nodeForCfi = range.startContainer;
|
nodeForCfi = range.startContainer;
|
||||||
offsetForCfi = range.startOffset;
|
offsetForCfi = range.startOffset;
|
||||||
logCfi("Precision match via caretRangeFromPoint.");
|
debugLog.push(`Caret range hit successfully at offset ${offsetForCfi}`);
|
||||||
} else {
|
} else {
|
||||||
// Walker fallback
|
|
||||||
const treeWalker = document.createTreeWalker(topElement, NodeFilter.SHOW_TEXT, null, false);
|
const treeWalker = document.createTreeWalker(topElement, NodeFilter.SHOW_TEXT, null, false);
|
||||||
let firstTextNode = treeWalker.nextNode();
|
let firstTextNode = treeWalker.nextNode();
|
||||||
nodeForCfi = (firstTextNode && firstTextNode.textContent.trim().length > 0) ? firstTextNode : topElement;
|
nodeForCfi = (firstTextNode && firstTextNode.textContent.trim().length > 0) ? firstTextNode : topElement;
|
||||||
offsetForCfi = 0;
|
offsetForCfi = 0;
|
||||||
logCfi("Fallback match via TreeWalker.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfiResult = window.getCfiPathForElement(nodeForCfi, offsetForCfi);
|
const cfiResult = window.getCfiPathForElement(nodeForCfi, offsetForCfi);
|
||||||
finalCfi = cfiResult.cfi;
|
finalCfi = cfiResult.cfi;
|
||||||
// Merge logs
|
|
||||||
if (cfiResult.log) debugLog.push(...cfiResult.log);
|
if (cfiResult.log) debugLog.push(...cfiResult.log);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugLog.push("Error in getCurrentCfi: " + e.message);
|
debugLog.push("Error in getCurrentCfi: " + e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We intentionally do not use TAG_BM here as this is called frequently on scroll.
|
|
||||||
// The CfiBridge in Kotlin logs this separately.
|
|
||||||
return JSON.stringify({ cfi: finalCfi, log: debugLog });
|
return JSON.stringify({ cfi: finalCfi, log: debugLog });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1801,15 +1643,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
window.scrollToCfi = function(cfi) {
|
window.scrollToCfi = function(cfi) {
|
||||||
logBm("scrollToCfi called with: " + cfi);
|
|
||||||
let cleanCfi = cfi;
|
let cleanCfi = cfi;
|
||||||
|
|
||||||
if (cfi && cfi.includes('@')) {
|
if (cfi && cfi.includes('@')) {
|
||||||
cleanCfi = cfi.substring(cfi.indexOf('@') + 1);
|
cleanCfi = cfi.substring(cfi.indexOf('@') + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("PosSaveDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi);
|
||||||
|
|
||||||
if (!cleanCfi || !cleanCfi.startsWith('/')) {
|
if (!cleanCfi || !cleanCfi.startsWith('/')) {
|
||||||
logBm("Invalid CFI format, aborting scroll.");
|
|
||||||
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
|
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
|
||||||
window.CfiBridge.onScrollFinished(false);
|
window.CfiBridge.onScrollFinished(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1818,77 +1660,103 @@
|
||||||
|
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 20;
|
const maxAttempts = 20;
|
||||||
|
let stabilizingFrames = 0;
|
||||||
|
const maxStabilizingFrames = 8;
|
||||||
|
|
||||||
function attemptScroll() {
|
function attemptScroll() {
|
||||||
attempts++;
|
attempts++;
|
||||||
logBm("Scroll Attempt " + attempts + "/" + maxAttempts + " for " + cleanCfi);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const location = window.getNodeAndOffsetFromCfi(cleanCfi);
|
const location = window.getNodeAndOffsetFromCfi(cleanCfi);
|
||||||
|
|
||||||
if (location && location.node) {
|
if (location && location.node) {
|
||||||
logBm("Target node FOUND. Node: " + location.node.nodeName);
|
|
||||||
|
|
||||||
if (!document.body.contains(location.node)) {
|
if (!document.body.contains(location.node)) {
|
||||||
logBm("Node found but detached. Retrying...");
|
|
||||||
if (attempts < maxAttempts) setTimeout(attemptScroll, 100);
|
if (attempts < maxAttempts) setTimeout(attemptScroll, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let targetScrollY = 0;
|
||||||
if (location.node.nodeType === Node.TEXT_NODE && location.offset > 0) {
|
if (location.node.nodeType === Node.TEXT_NODE && location.offset > 0) {
|
||||||
try {
|
try {
|
||||||
|
console.log("PosSaveDiag: Initial text node length=" + location.node.nodeValue.length + ", location.offset=" + location.offset);
|
||||||
|
let currentNode = location.node;
|
||||||
|
let remainingOffset = location.offset;
|
||||||
|
|
||||||
|
// Traverse sibling text nodes to find the exact offset
|
||||||
|
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||||
|
walker.currentNode = currentNode;
|
||||||
|
|
||||||
|
while (currentNode && remainingOffset > currentNode.nodeValue.length) {
|
||||||
|
remainingOffset -= currentNode.nodeValue.length;
|
||||||
|
const next = walker.nextNode();
|
||||||
|
if (!next) {
|
||||||
|
console.log("PosSaveDiag: Reached end of text nodes while traversing.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentNode = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("PosSaveDiag: Traversed to node with text: '" + currentNode.nodeValue.substring(0, 20) + "', remainingOffset=" + remainingOffset + ", actual length=" + currentNode.nodeValue.length);
|
||||||
|
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
const validOffset = Math.min(location.offset, location.node.nodeValue.length);
|
const validOffset = Math.min(remainingOffset, currentNode.nodeValue.length);
|
||||||
range.setStart(location.node, validOffset);
|
range.setStart(currentNode, validOffset);
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
const rect = range.getBoundingClientRect();
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
if (rect.top !== 0 || rect.bottom !== 0) {
|
if (rect.top !== 0 || rect.bottom !== 0) {
|
||||||
const targetScrollY = window.scrollY + rect.top - window.VIEWPORT_PADDING_TOP;
|
targetScrollY = window.scrollY + rect.top - (window.VIEWPORT_PADDING_TOP + 5);
|
||||||
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
|
|
||||||
setTimeout(() => {
|
|
||||||
window.reportScrollState();
|
|
||||||
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
|
|
||||||
window.CfiBridge.onScrollFinished(true);
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logBm("Precise scroll failed: " + e.message);
|
console.log("PosSaveDiag: Error calculating range bounding rect: " + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetElement = (location.node.nodeType === Node.TEXT_NODE) ? location.node.parentNode : location.node;
|
if (targetScrollY === 0) {
|
||||||
targetElement.scrollIntoView({ behavior: 'auto', block: 'start', inline: 'nearest' });
|
const targetElement = (location.node.nodeType === Node.TEXT_NODE) ? location.node.parentNode : location.node;
|
||||||
|
const rect = targetElement.getBoundingClientRect();
|
||||||
|
targetScrollY = window.scrollY + rect.top - (window.VIEWPORT_PADDING_TOP + 5);
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
if (Math.abs(window.scrollY - targetScrollY) > 1) {
|
||||||
if (window.VIEWPORT_PADDING_TOP > 0) window.scrollBy(0, -window.VIEWPORT_PADDING_TOP);
|
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
|
||||||
window.reportScrollState();
|
}
|
||||||
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
|
|
||||||
window.CfiBridge.onScrollFinished(true);
|
stabilizingFrames++;
|
||||||
}
|
if (stabilizingFrames < maxStabilizingFrames) {
|
||||||
}, 150);
|
setTimeout(attemptScroll, 100);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.reportScrollState();
|
||||||
|
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
|
||||||
|
window.CfiBridge.onScrollFinished(true);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
if (attempts < maxAttempts) {
|
if (attempts < maxAttempts) {
|
||||||
setTimeout(attemptScroll, 100);
|
setTimeout(attemptScroll, 100);
|
||||||
} else {
|
} else {
|
||||||
logBm("Max attempts reached. Scroll failed.");
|
|
||||||
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
|
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
|
||||||
window.CfiBridge.onScrollFinished(false);
|
window.CfiBridge.onScrollFinished(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logBm("Fatal error: " + e.message);
|
|
||||||
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
|
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
|
||||||
window.CfiBridge.onScrollFinished(false);
|
window.CfiBridge.onScrollFinished(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attemptScroll();
|
if (document.fonts && document.fonts.ready) {
|
||||||
|
document.fonts.ready.then(function() {
|
||||||
|
attemptScroll();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
attemptScroll();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.getElementByCfi = function(cfi) {
|
window.getElementByCfi = function(cfi) {
|
||||||
|
|
@ -1927,6 +1795,15 @@
|
||||||
const treeWalker = document.createTreeWalker(location.node, NodeFilter.SHOW_TEXT, null, false);
|
const treeWalker = document.createTreeWalker(location.node, NodeFilter.SHOW_TEXT, null, false);
|
||||||
textNode = treeWalker.nextNode();
|
textNode = treeWalker.nextNode();
|
||||||
offset = 0;
|
offset = 0;
|
||||||
|
} else if (offset > 0) {
|
||||||
|
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
|
||||||
|
walker.currentNode = textNode;
|
||||||
|
while (textNode && offset > textNode.nodeValue.length) {
|
||||||
|
offset -= textNode.nodeValue.length;
|
||||||
|
const next = walker.nextNode();
|
||||||
|
if (!next) break;
|
||||||
|
textNode = next;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (textNode) {
|
if (textNode) {
|
||||||
|
|
@ -2026,10 +1903,9 @@
|
||||||
if (container) {
|
if (container) {
|
||||||
container.querySelectorAll(".chunk-container").forEach((div) => {
|
container.querySelectorAll(".chunk-container").forEach((div) => {
|
||||||
let idx = parseInt(div.dataset.chunkIndex, 10);
|
let idx = parseInt(div.dataset.chunkIndex, 10);
|
||||||
let content = div.innerHTML; // Don't trim, keep original HTML structure
|
let content = div.innerHTML;
|
||||||
|
|
||||||
if (content.trim().length > 0) {
|
if (content.trim().length > 0) {
|
||||||
// FIX: Assign to specific index, don't overwrite the whole array
|
|
||||||
this.chunksData[idx] = content;
|
this.chunksData[idx] = content;
|
||||||
this.chunkHeights[idx] = div.getBoundingClientRect().height;
|
this.chunkHeights[idx] = div.getBoundingClientRect().height;
|
||||||
}
|
}
|
||||||
|
|
@ -2045,6 +1921,7 @@
|
||||||
this.observer = new IntersectionObserver(
|
this.observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
let scrollAdjust = 0;
|
let scrollAdjust = 0;
|
||||||
|
let domChanged = false;
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
let div = entry.target;
|
let div = entry.target;
|
||||||
|
|
@ -2062,6 +1939,7 @@
|
||||||
|
|
||||||
let newHeight = div.getBoundingClientRect().height;
|
let newHeight = div.getBoundingClientRect().height;
|
||||||
this.chunkHeights[idx] = newHeight;
|
this.chunkHeights[idx] = newHeight;
|
||||||
|
domChanged = true;
|
||||||
|
|
||||||
if (div.getBoundingClientRect().top < 0) {
|
if (div.getBoundingClientRect().top < 0) {
|
||||||
scrollAdjust += (newHeight - oldHeight);
|
scrollAdjust += (newHeight - oldHeight);
|
||||||
|
|
@ -2076,6 +1954,7 @@
|
||||||
this.chunkHeights[idx] = oldHeight;
|
this.chunkHeights[idx] = oldHeight;
|
||||||
div.style.height = oldHeight + "px";
|
div.style.height = oldHeight + "px";
|
||||||
div.innerHTML = "";
|
div.innerHTML = "";
|
||||||
|
domChanged = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -2083,8 +1962,12 @@
|
||||||
if (scrollAdjust !== 0) {
|
if (scrollAdjust !== 0) {
|
||||||
window.scrollBy(0, scrollAdjust);
|
window.scrollBy(0, scrollAdjust);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (domChanged && window.reportScrollState) {
|
||||||
|
setTimeout(window.reportScrollState, 50);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ rootMargin: "2500px 0px" } // Keep large margin for smooth scrolling
|
{ rootMargin: "2500px 0px" }
|
||||||
);
|
);
|
||||||
|
|
||||||
document.querySelectorAll(".chunk-container").forEach((div) => {
|
document.querySelectorAll(".chunk-container").forEach((div) => {
|
||||||
|
|
@ -2114,6 +1997,11 @@
|
||||||
if (div.getBoundingClientRect().bottom < 0) {
|
if (div.getBoundingClientRect().bottom < 0) {
|
||||||
window.scrollBy(0, newHeight - oldHeight);
|
window.scrollBy(0, newHeight - oldHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.reportScrollState) {
|
||||||
|
setTimeout(window.reportScrollState, 50);
|
||||||
|
}
|
||||||
|
|
||||||
if (window.CURRENT_HIGHLIGHTS) {
|
if (window.CURRENT_HIGHLIGHTS) {
|
||||||
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
|
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,9 @@ class FolderSyncWorker(
|
||||||
|
|
||||||
val file = fileQueue.removeAt(0)
|
val file = fileQueue.removeAt(0)
|
||||||
if (file.isDirectory) {
|
if (file.isDirectory) {
|
||||||
|
if (file.name?.startsWith(".") == true) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
file.listFiles().let { fileQueue.addAll(it) }
|
file.listFiles().let { fileQueue.addAll(it) }
|
||||||
} else if (file.isFile) {
|
} else if (file.isFile) {
|
||||||
val name = file.name ?: ""
|
val name = file.name ?: ""
|
||||||
|
|
|
||||||
|
|
@ -630,21 +630,15 @@ fun RecentFileCard(
|
||||||
.fallback(placeholder).crossfade(true).build(),
|
.fallback(placeholder).crossfade(true).build(),
|
||||||
contentDescription = item.displayName,
|
contentDescription = item.displayName,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier.height(160.dp).fillMaxWidth(),
|
||||||
.height(160.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (item.sourceFolderUri != null) {
|
if (item.sourceFolderUri != null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.padding(8.dp)
|
|
||||||
.background(
|
|
||||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
shape = CircleShape
|
shape = CircleShape
|
||||||
)
|
).padding(4.dp)
|
||||||
.padding(4.dp)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Folder,
|
imageVector = Icons.Default.Folder,
|
||||||
|
|
@ -658,14 +652,10 @@ fun RecentFileCard(
|
||||||
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
|
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
|
||||||
if (isOpdsStream) {
|
if (isOpdsStream) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.padding(8.dp)
|
|
||||||
.background(
|
|
||||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
shape = CircleShape
|
shape = CircleShape
|
||||||
)
|
).padding(4.dp)
|
||||||
.padding(4.dp)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Cloud,
|
imageVector = Icons.Default.Cloud,
|
||||||
|
|
@ -678,14 +668,10 @@ fun RecentFileCard(
|
||||||
|
|
||||||
if (isPinned) {
|
if (isPinned) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.align(Alignment.TopStart).padding(8.dp).background(
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.padding(8.dp)
|
|
||||||
.background(
|
|
||||||
color = MaterialTheme.colorScheme.primaryContainer,
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
shape = CircleShape
|
shape = CircleShape
|
||||||
)
|
).padding(4.dp)
|
||||||
.padding(4.dp)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.PushPin,
|
imageVector = Icons.Default.PushPin,
|
||||||
|
|
@ -715,6 +701,11 @@ fun RecentFileCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)
|
||||||
|
) {
|
||||||
|
FileTypeBadge(type = item.type, overlay = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|
|
||||||
|
|
@ -1404,13 +1404,25 @@ private fun LibraryListItem(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item.progressPercentage?.let {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = "${it.toInt()}% complete",
|
FileTypeBadge(type = item.type, overlay = false)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
item.progressPercentage?.let {
|
||||||
)
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "•",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "${it.toInt()}% complete",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1743,6 +1755,7 @@ private fun FolderCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EditFolderFiltersDialog(
|
private fun EditFolderFiltersDialog(
|
||||||
folder: SyncedFolder,
|
folder: SyncedFolder,
|
||||||
|
|
@ -1753,44 +1766,74 @@ private fun EditFolderFiltersDialog(
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.filter_file_types)) },
|
title = {
|
||||||
text = {
|
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.filter_file_types_desc),
|
text = stringResource(R.string.filter_file_types),
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Text(
|
||||||
FileType.entries.forEach { type ->
|
text = stringResource(R.string.filter_file_types_desc),
|
||||||
Row(
|
style = MaterialTheme.typography.bodySmall,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
modifier = Modifier
|
)
|
||||||
.fillMaxWidth()
|
}
|
||||||
.clickable {
|
},
|
||||||
selectedTypes = if (type in selectedTypes) selectedTypes - type else selectedTypes + type
|
text = {
|
||||||
}
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
.padding(vertical = 4.dp)
|
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
|
||||||
) {
|
|
||||||
androidx.compose.material3.Checkbox(
|
androidx.compose.foundation.layout.FlowRow(
|
||||||
checked = type in selectedTypes,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onCheckedChange = { checked ->
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
selectedTypes = if (checked) selectedTypes + type else selectedTypes - type
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
}
|
) {
|
||||||
|
FileType.entries.forEach { type ->
|
||||||
|
val isSelected = type in selectedTypes
|
||||||
|
FilterChip(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
selectedTypes = if (isSelected) {
|
||||||
|
selectedTypes - type
|
||||||
|
} else {
|
||||||
|
selectedTypes + type
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = type.name,
|
||||||
|
style = MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = if (isSelected) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(type.name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
androidx.compose.material3.Button(
|
||||||
onClick = { onConfirm(selectedTypes) },
|
onClick = { onConfirm(selectedTypes) },
|
||||||
enabled = selectedTypes.isNotEmpty()
|
enabled = selectedTypes.isNotEmpty(),
|
||||||
) { Text(stringResource(R.string.action_save)) }
|
shape = MaterialTheme.shapes.medium
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_save))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
remoteConfigRepository.init()
|
remoteConfigRepository.init()
|
||||||
|
|
||||||
if (_internalState.value.syncedFolders.isNotEmpty()) {
|
if (_internalState.value.syncedFolders.isNotEmpty()) {
|
||||||
syncFolderMetadata()
|
triggerFolderSyncWorker(metadataOnly = false, showFeedback = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
sweepOrphanedCache()
|
sweepOrphanedCache()
|
||||||
|
|
|
||||||
|
|
@ -21,263 +21,305 @@ class Fb2Parser(private val context: Context) {
|
||||||
bookId: String,
|
bookId: String,
|
||||||
originalBookNameHint: String,
|
originalBookNameHint: String,
|
||||||
parseContent: Boolean = true
|
parseContent: Boolean = true
|
||||||
): EpubBook {
|
): EpubBook = withContext(Dispatchers.IO) {
|
||||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
||||||
if (!exists()) mkdirs()
|
if (!exists()) mkdirs()
|
||||||
}
|
}
|
||||||
|
|
||||||
var streamToParse = inputStream
|
var streamToParse = inputStream
|
||||||
if (originalBookNameHint.endsWith(".zip", ignoreCase = true)) {
|
try {
|
||||||
val zis = ZipInputStream(inputStream)
|
if (originalBookNameHint.endsWith(".zip", ignoreCase = true)) {
|
||||||
var entry = zis.nextEntry
|
val zis = ZipInputStream(inputStream)
|
||||||
while (entry != null) {
|
var entry = zis.nextEntry
|
||||||
if (entry.name.endsWith(".fb2", ignoreCase = true)) {
|
while (entry != null) {
|
||||||
break
|
if (entry.name.endsWith(".fb2", ignoreCase = true)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
entry = zis.nextEntry
|
||||||
|
}
|
||||||
|
if (entry != null) {
|
||||||
|
streamToParse = zis
|
||||||
|
} else {
|
||||||
|
throw Exception("No .fb2 file found inside the ZIP archive.")
|
||||||
}
|
}
|
||||||
entry = zis.nextEntry
|
|
||||||
}
|
}
|
||||||
if (entry != null) {
|
|
||||||
streamToParse = zis
|
|
||||||
} else {
|
|
||||||
throw Exception("No .fb2 file found inside the ZIP archive.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val parser = Xml.newPullParser()
|
val parser = Xml.newPullParser()
|
||||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||||
parser.setInput(streamToParse, null)
|
parser.setInput(streamToParse, null)
|
||||||
|
|
||||||
var title = originalBookNameHint.substringBeforeLast(".")
|
var title = originalBookNameHint.substringBeforeLast(".")
|
||||||
var author = "Unknown"
|
var author = "Unknown"
|
||||||
var coverImageId: String? = null
|
var coverImageId: String? = null
|
||||||
var coverBytes: ByteArray? = null
|
var coverBytes: ByteArray? = null
|
||||||
|
|
||||||
val chapters = mutableListOf<EpubChapter>()
|
val chapters = mutableListOf<EpubChapter>()
|
||||||
val images = mutableListOf<EpubImage>() // Keep track of extracted images
|
val images = mutableListOf<EpubImage>() // Keep track of extracted images
|
||||||
|
|
||||||
var currentChapterHtml = StringBuilder()
|
var currentChapterHtml = StringBuilder()
|
||||||
var currentChapterTitle = "Chapter"
|
var currentChapterTitle = "Chapter 1"
|
||||||
var chapterCount = 0
|
var chapterCount = 0
|
||||||
var inSection = false
|
var inSection = false
|
||||||
var inBody = false
|
var inBody = false
|
||||||
var inTitle = false
|
var inTitle = false
|
||||||
var skipElement = false
|
val titleBuilder = java.lang.StringBuilder() // Buffer to handle <p> tags inside <title>
|
||||||
val titleBuilder = java.lang.StringBuilder() // Buffer to handle <p> tags inside <title>
|
|
||||||
|
|
||||||
val cssStyle = """
|
val cssStyle = """
|
||||||
body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; }
|
body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; }
|
||||||
p { margin-bottom: 1em; text-indent: 1.5em; text-align: justify; }
|
p { margin-bottom: 1em; text-indent: 1.5em; text-align: justify; }
|
||||||
h1, h2, h3, h4 { text-align: center; margin-top: 1.5em; margin-bottom: 1em; }
|
h1, h2, h3, h4 { text-align: center; margin-top: 1.5em; margin-bottom: 1em; }
|
||||||
.empty-line { height: 1.5em; }
|
.empty-line { height: 1.5em; }
|
||||||
img { max-width: 100%; height: auto; display: block; margin: 1em auto; }
|
img { max-width: 100%; height: auto; display: block; margin: 1em auto; }
|
||||||
.epigraph { margin-left: 2em; font-style: italic; margin-bottom: 1.5em; }
|
.epigraph { margin-left: 2em; font-style: italic; margin-bottom: 1.5em; }
|
||||||
""".trimIndent()
|
.cite { border-left: 4px solid currentColor; padding-left: 1em; margin-left: 0; opacity: 0.8; font-style: italic; }
|
||||||
|
.poem { margin: 1.5em 0; padding-left: 2em; }
|
||||||
fun saveChapter() {
|
.stanza { margin-bottom: 1em; }
|
||||||
if (!parseContent || currentChapterHtml.isEmpty()) return
|
|
||||||
chapterCount++
|
|
||||||
val fileName = "chapter_$chapterCount.html"
|
|
||||||
val file = File(extractionDir, fileName)
|
|
||||||
|
|
||||||
val fullHtml = """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>${currentChapterTitle.replace("\"", """)}</title>
|
|
||||||
<style>${cssStyle}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
$currentChapterHtml
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
FileOutputStream(file).use { it.write(fullHtml.toByteArray()) }
|
fun saveChapter() {
|
||||||
val plainText = Jsoup.parse(fullHtml).text()
|
if (!parseContent || currentChapterHtml.isEmpty()) return
|
||||||
|
chapterCount++
|
||||||
|
val fileName = "chapter_$chapterCount.html"
|
||||||
|
val file = File(extractionDir, fileName)
|
||||||
|
|
||||||
chapters.add(
|
val fullHtml = """
|
||||||
EpubChapter(
|
<!DOCTYPE html>
|
||||||
chapterId = "${bookId}_${chapterCount}",
|
<html>
|
||||||
absPath = fileName,
|
<head>
|
||||||
title = currentChapterTitle,
|
<title>${currentChapterTitle.replace("\"", """)}</title>
|
||||||
htmlFilePath = fileName,
|
<style>${cssStyle}</style>
|
||||||
plainTextContent = plainText,
|
</head>
|
||||||
htmlContent = "",
|
<body>
|
||||||
depth = 0,
|
$currentChapterHtml
|
||||||
isInToc = true
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
FileOutputStream(file).use { it.write(fullHtml.toByteArray()) }
|
||||||
|
val plainText = Jsoup.parse(fullHtml).text()
|
||||||
|
|
||||||
|
chapters.add(
|
||||||
|
EpubChapter(
|
||||||
|
chapterId = "${bookId}_${chapterCount}",
|
||||||
|
absPath = fileName,
|
||||||
|
title = currentChapterTitle,
|
||||||
|
htmlFilePath = fileName,
|
||||||
|
plainTextContent = plainText,
|
||||||
|
htmlContent = "",
|
||||||
|
depth = 0,
|
||||||
|
isInToc = true
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
currentChapterHtml.clear()
|
||||||
currentChapterHtml.clear()
|
currentChapterTitle = "Chapter ${chapterCount + 1}"
|
||||||
currentChapterTitle = "Chapter ${chapterCount + 1}"
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var eventType = parser.eventType
|
var eventType = parser.eventType
|
||||||
|
|
||||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
when (eventType) {
|
when (eventType) {
|
||||||
XmlPullParser.START_TAG -> {
|
XmlPullParser.START_TAG -> {
|
||||||
val name = parser.name.lowercase()
|
val name = parser.name.lowercase()
|
||||||
when (name) {
|
when (name) {
|
||||||
"book-title" -> {
|
"book-title" -> {
|
||||||
title = parser.nextText().trim()
|
title = parser.nextText().trim()
|
||||||
}
|
|
||||||
"first-name", "last-name", "middle-name" -> {
|
|
||||||
val namePart = parser.nextText().trim()
|
|
||||||
if (namePart.isNotBlank()) {
|
|
||||||
if (author == "Unknown") author = namePart else author += " $namePart"
|
|
||||||
}
|
}
|
||||||
}
|
"first-name", "last-name", "middle-name" -> {
|
||||||
"body" -> {
|
val namePart = parser.nextText().trim()
|
||||||
val nameAttr = parser.getAttributeValue(null, "name")
|
if (namePart.isNotBlank()) {
|
||||||
if (nameAttr == "notes" || nameAttr == "comments") {
|
if (author == "Unknown") author = namePart else author += " $namePart"
|
||||||
skipElement = true
|
}
|
||||||
} else {
|
}
|
||||||
|
"body" -> {
|
||||||
inBody = true
|
inBody = true
|
||||||
}
|
}
|
||||||
}
|
"section" -> {
|
||||||
"section" -> {
|
if (inBody) {
|
||||||
if (inBody && !skipElement) {
|
if (currentChapterHtml.isNotBlank()) {
|
||||||
if (currentChapterHtml.isNotBlank()) {
|
saveChapter()
|
||||||
saveChapter()
|
}
|
||||||
|
inSection = true
|
||||||
}
|
}
|
||||||
inSection = true
|
|
||||||
}
|
}
|
||||||
}
|
"title" -> {
|
||||||
"title" -> {
|
if (inSection && currentChapterHtml.isEmpty()) {
|
||||||
if (inSection && currentChapterHtml.isEmpty()) {
|
inTitle = true
|
||||||
inTitle = true
|
titleBuilder.clear()
|
||||||
titleBuilder.clear()
|
}
|
||||||
|
currentChapterHtml.append("<h2>")
|
||||||
}
|
}
|
||||||
currentChapterHtml.append("<h2>")
|
"p" -> {
|
||||||
}
|
if (!inTitle) {
|
||||||
"p" -> if (!inTitle) currentChapterHtml.append("<p>")
|
currentChapterHtml.append("<p>")
|
||||||
"v" -> if (!inTitle) currentChapterHtml.append("<p style='text-indent: 0;'>")
|
} else if (titleBuilder.isNotEmpty()) {
|
||||||
"subtitle" -> currentChapterHtml.append("<h3>")
|
titleBuilder.append(" ")
|
||||||
"empty-line" -> currentChapterHtml.append("<div class='empty-line'></div>")
|
currentChapterHtml.append("<br>")
|
||||||
"strong" -> currentChapterHtml.append("<b>")
|
}
|
||||||
"emphasis" -> currentChapterHtml.append("<i>")
|
}
|
||||||
"strikethrough" -> currentChapterHtml.append("<s>")
|
"v" -> {
|
||||||
"sup" -> currentChapterHtml.append("<sup>")
|
if (!inTitle) {
|
||||||
"sub" -> currentChapterHtml.append("<sub>")
|
currentChapterHtml.append("<p style='text-indent: 0; text-align: left;'>")
|
||||||
"epigraph" -> currentChapterHtml.append("<div class='epigraph'>")
|
} else if (titleBuilder.isNotEmpty()) {
|
||||||
"image" -> {
|
titleBuilder.append(" ")
|
||||||
// Safely extract href checking all possible namespace stripped versions
|
currentChapterHtml.append("<br>")
|
||||||
val href = parser.getAttributeValue(null, "l:href")
|
}
|
||||||
?: parser.getAttributeValue(null, "xlink:href")
|
}
|
||||||
?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href")
|
"subtitle" -> currentChapterHtml.append("<h3>")
|
||||||
?: parser.getAttributeValue(null, "href")
|
"empty-line" -> {
|
||||||
|
if (!inTitle) {
|
||||||
|
currentChapterHtml.append("<div class='empty-line'></div>")
|
||||||
|
} else if (titleBuilder.isNotEmpty()) {
|
||||||
|
titleBuilder.append(" ")
|
||||||
|
currentChapterHtml.append("<br>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"strong" -> currentChapterHtml.append("<b>")
|
||||||
|
"emphasis" -> currentChapterHtml.append("<i>")
|
||||||
|
"strikethrough" -> currentChapterHtml.append("<s>")
|
||||||
|
"sup" -> currentChapterHtml.append("<sup>")
|
||||||
|
"sub" -> currentChapterHtml.append("<sub>")
|
||||||
|
"epigraph" -> currentChapterHtml.append("<div class='epigraph'>")
|
||||||
|
"cite" -> currentChapterHtml.append("<blockquote class='cite'>")
|
||||||
|
"poem" -> currentChapterHtml.append("<div class='poem'>")
|
||||||
|
"stanza" -> currentChapterHtml.append("<div class='stanza'>")
|
||||||
|
"a" -> {
|
||||||
|
val href = parser.getAttributeValue(null, "l:href")
|
||||||
|
?: parser.getAttributeValue(null, "xlink:href")
|
||||||
|
?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href")
|
||||||
|
if (!inTitle) {
|
||||||
|
if (href != null) {
|
||||||
|
currentChapterHtml.append("<a href=\"$href\">")
|
||||||
|
} else {
|
||||||
|
currentChapterHtml.append("<a>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"image" -> {
|
||||||
|
// Safely extract href checking all possible namespace stripped versions
|
||||||
|
val href = parser.getAttributeValue(null, "l:href")
|
||||||
|
?: parser.getAttributeValue(null, "xlink:href")
|
||||||
|
?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href")
|
||||||
|
?: parser.getAttributeValue(null, "href")
|
||||||
|
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
val id = href.removePrefix("#")
|
val id = href.removePrefix("#")
|
||||||
if (!inBody) {
|
if (!inBody) {
|
||||||
coverImageId = id
|
if (coverImageId == null) coverImageId = id
|
||||||
} else {
|
} else {
|
||||||
currentChapterHtml.append("<img src=\"$id\" />")
|
currentChapterHtml.append("<img src=\"$id\" />")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
"binary" -> {
|
||||||
"binary" -> {
|
val id = parser.getAttributeValue(null, "id")
|
||||||
val id = parser.getAttributeValue(null, "id")
|
if (id != null) {
|
||||||
if (id != null) {
|
val base64Data = parser.nextText()
|
||||||
val base64Data = parser.nextText()
|
try {
|
||||||
try {
|
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
|
||||||
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
|
if (parseContent) {
|
||||||
if (parseContent) {
|
val imgFile = File(extractionDir, id)
|
||||||
val imgFile = File(extractionDir, id)
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
FileOutputStream(imgFile).use { it.write(bytes) }
|
FileOutputStream(imgFile).use { it.write(bytes) }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
images.add(EpubImage(absPath = id))
|
images.add(EpubImage(absPath = id))
|
||||||
|
|
||||||
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
|
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
|
||||||
coverBytes = bytes
|
coverBytes = bytes
|
||||||
coverImageId = id
|
coverImageId = id
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to decode binary image $id")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to decode binary image $id")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
XmlPullParser.TEXT -> {
|
||||||
XmlPullParser.TEXT -> {
|
val text = parser.text?.replace("&", "&")?.replace("<", "<")?.replace(">", ">")
|
||||||
val text = parser.text?.replace("&", "&")?.replace("<", "<")?.replace(">", ">")
|
if (!text.isNullOrBlank()) {
|
||||||
if (!text.isNullOrBlank()) {
|
|
||||||
if (inTitle) {
|
|
||||||
titleBuilder.append(text) // Append to buffer since it could be split by <p> tags
|
|
||||||
currentChapterHtml.append(text)
|
|
||||||
} else if (inBody && !skipElement) {
|
|
||||||
currentChapterHtml.append(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
XmlPullParser.END_TAG -> {
|
|
||||||
val name = parser.name.lowercase()
|
|
||||||
when (name) {
|
|
||||||
"body" -> {
|
|
||||||
skipElement = false
|
|
||||||
inBody = false
|
|
||||||
}
|
|
||||||
"title" -> {
|
|
||||||
if (inTitle) {
|
if (inTitle) {
|
||||||
currentChapterTitle = titleBuilder.toString().trim()
|
titleBuilder.append(text) // Append to buffer since it could be split by <p> tags
|
||||||
inTitle = false
|
currentChapterHtml.append(text)
|
||||||
|
} else if (inBody) {
|
||||||
|
currentChapterHtml.append(text)
|
||||||
}
|
}
|
||||||
currentChapterHtml.append("</h2>\n")
|
|
||||||
}
|
}
|
||||||
"p", "v" -> if (!inTitle) currentChapterHtml.append("</p>\n")
|
|
||||||
"subtitle" -> currentChapterHtml.append("</h3>\n")
|
|
||||||
"strong" -> currentChapterHtml.append("</b>")
|
|
||||||
"emphasis" -> currentChapterHtml.append("</i>")
|
|
||||||
"strikethrough" -> currentChapterHtml.append("</s>")
|
|
||||||
"sup" -> currentChapterHtml.append("</sup>")
|
|
||||||
"sub" -> currentChapterHtml.append("</sub>")
|
|
||||||
"epigraph" -> currentChapterHtml.append("</div>\n")
|
|
||||||
}
|
}
|
||||||
|
XmlPullParser.END_TAG -> {
|
||||||
|
val name = parser.name.lowercase()
|
||||||
|
when (name) {
|
||||||
|
"body" -> {
|
||||||
|
inBody = false
|
||||||
|
}
|
||||||
|
"title" -> {
|
||||||
|
if (inTitle) {
|
||||||
|
currentChapterTitle = titleBuilder.toString().replace("\\s+".toRegex(), " ").trim()
|
||||||
|
if (currentChapterTitle.isBlank()) {
|
||||||
|
currentChapterTitle = "Chapter ${chapterCount + 1}"
|
||||||
|
}
|
||||||
|
inTitle = false
|
||||||
|
}
|
||||||
|
currentChapterHtml.append("</h2>\n")
|
||||||
|
}
|
||||||
|
"p", "v" -> if (!inTitle) currentChapterHtml.append("</p>\n")
|
||||||
|
"subtitle" -> currentChapterHtml.append("</h3>\n")
|
||||||
|
"strong" -> currentChapterHtml.append("</b>")
|
||||||
|
"emphasis" -> currentChapterHtml.append("</i>")
|
||||||
|
"strikethrough" -> currentChapterHtml.append("</s>")
|
||||||
|
"sup" -> currentChapterHtml.append("</sup>")
|
||||||
|
"sub" -> currentChapterHtml.append("</sub>")
|
||||||
|
"epigraph" -> currentChapterHtml.append("</div>\n")
|
||||||
|
"cite" -> currentChapterHtml.append("</blockquote>\n")
|
||||||
|
"poem", "stanza" -> currentChapterHtml.append("</div>\n")
|
||||||
|
"a" -> if (!inTitle) currentChapterHtml.append("</a>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
|
eventType = parser.next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calling nextText() moves the parser directly to END_TAG.
|
saveChapter()
|
||||||
// We ensure we don't accidentally read past the EOF.
|
|
||||||
if (eventType != XmlPullParser.END_DOCUMENT) {
|
if (chapters.isEmpty() && parseContent) {
|
||||||
eventType = parser.next()
|
if (currentChapterHtml.isNotBlank()) {
|
||||||
|
saveChapter()
|
||||||
|
} else {
|
||||||
|
throw Exception("No valid content found in FB2 file.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
saveChapter()
|
val coverBitmap = coverBytes?.let {
|
||||||
|
try {
|
||||||
if (chapters.isEmpty() && parseContent) {
|
BitmapFactory.decodeByteArray(it, 0, it.size)
|
||||||
if (currentChapterHtml.isNotBlank()) {
|
} catch (e: Exception) {
|
||||||
saveChapter()
|
Timber.e(e, "Failed to decode cover bitmap for FB2")
|
||||||
} else {
|
null
|
||||||
throw Exception("No valid content found in FB2 file.")
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val coverBitmap = coverBytes?.let {
|
return@withContext EpubBook(
|
||||||
|
fileName = originalBookNameHint,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
language = "en",
|
||||||
|
coverImage = coverBitmap,
|
||||||
|
chapters = chapters,
|
||||||
|
chaptersForPagination = chapters,
|
||||||
|
images = images,
|
||||||
|
pageList = emptyList(),
|
||||||
|
tableOfContents = emptyList(),
|
||||||
|
extractionBasePath = extractionDir.absolutePath,
|
||||||
|
css = emptyMap()
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
try {
|
try {
|
||||||
BitmapFactory.decodeByteArray(it, 0, it.size)
|
streamToParse.close()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Failed to decode cover bitmap for FB2")
|
Timber.e(e, "Error closing FB2 stream")
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return EpubBook(
|
|
||||||
fileName = originalBookNameHint,
|
|
||||||
title = title,
|
|
||||||
author = author,
|
|
||||||
language = "en",
|
|
||||||
coverImage = coverBitmap,
|
|
||||||
chapters = chapters,
|
|
||||||
chaptersForPagination = chapters,
|
|
||||||
images = images, // Extracted images attached!
|
|
||||||
pageList = emptyList(),
|
|
||||||
tableOfContents = emptyList(),
|
|
||||||
extractionBasePath = extractionDir.absolutePath,
|
|
||||||
css = emptyMap()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -306,41 +306,42 @@ class SingleFileImporter(private val context: Context) {
|
||||||
.replace(">", ">")
|
.replace(">", ">")
|
||||||
}
|
}
|
||||||
|
|
||||||
val reader = inputStream.bufferedReader()
|
|
||||||
var inParagraph = false
|
var inParagraph = false
|
||||||
|
|
||||||
while (true) {
|
inputStream.bufferedReader().use { reader ->
|
||||||
val line = reader.readLine()
|
while (true) {
|
||||||
if (line == null) {
|
val line = reader.readLine()
|
||||||
if (inParagraph) {
|
if (line == null) {
|
||||||
currentChapterContent.append("</p>\n")
|
if (inParagraph) {
|
||||||
}
|
currentChapterContent.append("</p>\n")
|
||||||
break
|
}
|
||||||
}
|
break
|
||||||
|
|
||||||
val trimmed = line.trim()
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
if (inParagraph) {
|
|
||||||
currentChapterContent.append("</p>\n")
|
|
||||||
inParagraph = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentChapterContent.length >= chapterTargetSize) {
|
val trimmed = line.trim()
|
||||||
flushChapter()
|
if (trimmed.isEmpty()) {
|
||||||
}
|
if (inParagraph) {
|
||||||
} else {
|
currentChapterContent.append("</p>\n")
|
||||||
if (!inParagraph) {
|
inParagraph = false
|
||||||
currentChapterContent.append("<p>")
|
}
|
||||||
inParagraph = true
|
|
||||||
|
if (currentChapterContent.length >= chapterTargetSize) {
|
||||||
|
flushChapter()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
currentChapterContent.append(" ")
|
if (!inParagraph) {
|
||||||
}
|
currentChapterContent.append("<p>")
|
||||||
currentChapterContent.append(escapeHtml(trimmed))
|
inParagraph = true
|
||||||
|
} else {
|
||||||
|
currentChapterContent.append(" ")
|
||||||
|
}
|
||||||
|
currentChapterContent.append(escapeHtml(trimmed))
|
||||||
|
|
||||||
if (currentChapterContent.length >= chapterTargetSize * 2) {
|
if (currentChapterContent.length >= chapterTargetSize * 2) {
|
||||||
currentChapterContent.append("</p>\n")
|
currentChapterContent.append("</p>\n")
|
||||||
flushChapter()
|
flushChapter()
|
||||||
inParagraph = false
|
inParagraph = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -590,9 +591,10 @@ class SingleFileImporter(private val context: Context) {
|
||||||
val parseStart = System.currentTimeMillis()
|
val parseStart = System.currentTimeMillis()
|
||||||
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint")
|
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint")
|
||||||
|
|
||||||
val converter = DocumentConverter()
|
val htmlContent = inputStream.use { stream ->
|
||||||
val result = converter.convertToHtml(inputStream)
|
val converter = DocumentConverter()
|
||||||
val htmlContent = result.value ?: ""
|
converter.convertToHtml(stream).value ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms")
|
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -560,6 +560,11 @@ fun ChapterWebView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message.startsWith("PosSaveDiag:") -> {
|
||||||
|
Timber.tag("PosSaveDiag")
|
||||||
|
.d("JS -> ${message.substringAfter("PosSaveDiag: ")}")
|
||||||
|
}
|
||||||
|
|
||||||
message.startsWith("HIGHLIGHT_DEBUG:") -> {
|
message.startsWith("HIGHLIGHT_DEBUG:") -> {
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"JS -> ${message.substringAfter("HIGHLIGHT_DEBUG: ")}"
|
"JS -> ${message.substringAfter("HIGHLIGHT_DEBUG: ")}"
|
||||||
|
|
|
||||||
|
|
@ -483,7 +483,7 @@ fun EpubReaderHost(
|
||||||
var showJustifyWarningDialog by remember { mutableStateOf(false) }
|
var showJustifyWarningDialog by remember { mutableStateOf(false) }
|
||||||
var isNavigatingByToc by remember { mutableStateOf(false) }
|
var isNavigatingByToc by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var chunkTargetOverride by remember { mutableStateOf<Int?>(null) }
|
var chunkTargetOverride by remember { mutableStateOf(initialLocator?.let { it.blockIndex / 20 }) }
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
|
@ -957,6 +957,8 @@ fun EpubReaderHost(
|
||||||
skipChapterRequest = false
|
skipChapterRequest = false
|
||||||
if (ttsShouldStartOnChapterLoad && currentChapterIndex < chapters.size - 1) {
|
if (ttsShouldStartOnChapterLoad && currentChapterIndex < chapters.size - 1) {
|
||||||
Timber.d("Executing skip chapter request for continuous TTS.")
|
Timber.d("Executing skip chapter request for continuous TTS.")
|
||||||
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex++
|
currentChapterIndex++
|
||||||
} else {
|
} else {
|
||||||
ttsShouldStartOnChapterLoad = false
|
ttsShouldStartOnChapterLoad = false
|
||||||
|
|
@ -1212,6 +1214,7 @@ fun EpubReaderHost(
|
||||||
initialScrollTargetForChapter = ChapterScrollPosition.START
|
initialScrollTargetForChapter = ChapterScrollPosition.START
|
||||||
cfiToLoad = null
|
cfiToLoad = null
|
||||||
currentScrollYPosition = 0
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex = nextIndex
|
currentChapterIndex = nextIndex
|
||||||
},
|
},
|
||||||
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
|
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
|
||||||
|
|
@ -1362,7 +1365,17 @@ fun EpubReaderHost(
|
||||||
chapterHead = result.head
|
chapterHead = result.head
|
||||||
chapterChunks = result.chunks
|
chapterChunks = result.chunks
|
||||||
isChapterParsing = false
|
isChapterParsing = false
|
||||||
loadUpToChunkIndex = result.startChunkIndex
|
|
||||||
|
if (initialScrollTargetForChapter == ChapterScrollPosition.END) {
|
||||||
|
loadUpToChunkIndex = max(0, result.chunks.size - 1)
|
||||||
|
loadedChunkCount = result.chunks.size
|
||||||
|
topVisibleChunkIndex = loadUpToChunkIndex
|
||||||
|
} else {
|
||||||
|
loadUpToChunkIndex = result.startChunkIndex
|
||||||
|
loadedChunkCount = min(result.chunks.size, result.startChunkIndex + 2)
|
||||||
|
topVisibleChunkIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: loadChapterContent finished. chapterChunks.size=${chapterChunks.size}, isChapterParsing=$isChapterParsing")
|
Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: loadChapterContent finished. chapterChunks.size=${chapterChunks.size}, isChapterParsing=$isChapterParsing")
|
||||||
|
|
||||||
if (chunkTargetOverride != null) {
|
if (chunkTargetOverride != null) {
|
||||||
|
|
@ -1371,9 +1384,6 @@ fun EpubReaderHost(
|
||||||
if (isInitialCfiLoad) {
|
if (isInitialCfiLoad) {
|
||||||
isInitialCfiLoad = false
|
isInitialCfiLoad = false
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedChunkCount = 1
|
|
||||||
topVisibleChunkIndex = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
EpubReaderSystemUiController(
|
EpubReaderSystemUiController(
|
||||||
|
|
@ -1604,6 +1614,8 @@ fun EpubReaderHost(
|
||||||
coroutineScope = scope,
|
coroutineScope = scope,
|
||||||
onVerticalChapterChange = { chapterIdx, chunkIdx, result ->
|
onVerticalChapterChange = { chapterIdx, chunkIdx, result ->
|
||||||
initialScrollTargetForChapter = ChapterScrollPosition.START
|
initialScrollTargetForChapter = ChapterScrollPosition.START
|
||||||
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex = chapterIdx
|
currentChapterIndex = chapterIdx
|
||||||
searchHighlightTarget = result
|
searchHighlightTarget = result
|
||||||
loadUpToChunkIndex = chunkIdx
|
loadUpToChunkIndex = chunkIdx
|
||||||
|
|
@ -1674,6 +1686,7 @@ fun EpubReaderHost(
|
||||||
if (targetChapterIndex != currentChapterIndex) {
|
if (targetChapterIndex != currentChapterIndex) {
|
||||||
initialScrollTargetForChapter = null
|
initialScrollTargetForChapter = null
|
||||||
currentScrollYPosition = 0
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex = targetChapterIndex
|
currentChapterIndex = targetChapterIndex
|
||||||
} else {
|
} else {
|
||||||
if (entry.fragmentId != null) {
|
if (entry.fragmentId != null) {
|
||||||
|
|
@ -1718,6 +1731,7 @@ fun EpubReaderHost(
|
||||||
if (index != currentChapterIndex) {
|
if (index != currentChapterIndex) {
|
||||||
initialScrollTargetForChapter = ChapterScrollPosition.START
|
initialScrollTargetForChapter = ChapterScrollPosition.START
|
||||||
currentScrollYPosition = 0
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex = index
|
currentChapterIndex = index
|
||||||
pullToNextProgress = 0f
|
pullToNextProgress = 0f
|
||||||
pullToPrevProgress = 0f
|
pullToPrevProgress = 0f
|
||||||
|
|
@ -1773,6 +1787,8 @@ fun EpubReaderHost(
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex = bookmark.chapterIndex
|
currentChapterIndex = bookmark.chapterIndex
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
|
@ -1876,6 +1892,8 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
if (highlight.chapterIndex != currentChapterIndex) {
|
if (highlight.chapterIndex != currentChapterIndex) {
|
||||||
chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) targetChunk else 0
|
chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) targetChunk else 0
|
||||||
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex = highlight.chapterIndex
|
currentChapterIndex = highlight.chapterIndex
|
||||||
} else {
|
} else {
|
||||||
if (targetChunk != null && targetChunk >= 0) {
|
if (targetChunk != null && targetChunk >= 0) {
|
||||||
|
|
@ -2069,7 +2087,8 @@ fun EpubReaderHost(
|
||||||
onNavigateChapter = { offset, target ->
|
onNavigateChapter = { offset, target ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
initialScrollTargetForChapter = target
|
initialScrollTargetForChapter = target
|
||||||
if (target == ChapterScrollPosition.START) currentScrollYPosition = 0
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex += offset
|
currentChapterIndex += offset
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -2147,19 +2166,19 @@ fun EpubReaderHost(
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
} else if (chapterChunks.isNotEmpty()) {
|
} else if (chapterChunks.isNotEmpty()) {
|
||||||
val initialContentToLoad =
|
val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) {
|
||||||
remember(loadUpToChunkIndex, chapterChunks) {
|
val targetIdx = loadUpToChunkIndex
|
||||||
val startIdx = maxOf(0, loadUpToChunkIndex - 1)
|
val startIdx = maxOf(0, targetIdx - 1)
|
||||||
val endIdx = minOf(chapterChunks.lastIndex, loadUpToChunkIndex + 1)
|
val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1)
|
||||||
|
|
||||||
chapterChunks.indices.joinToString(separator = "\n") { index ->
|
chapterChunks.indices.joinToString(separator = "\n") { index ->
|
||||||
if (index in startIdx..endIdx) {
|
if (index in startIdx..endIdx) {
|
||||||
"<div class='chunk-container' data-chunk-index='$index'>${chapterChunks[index]}</div>"
|
"<div class='chunk-container' data-chunk-index='$index'>${chapterChunks[index]}</div>"
|
||||||
} else {
|
} else {
|
||||||
"<div class='chunk-container' data-chunk-index='$index'></div>"
|
"<div class='chunk-container' data-chunk-index='$index'></div>"
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
val initialHtml = """
|
val initialHtml = """
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -2364,6 +2383,7 @@ fun EpubReaderHost(
|
||||||
Timber.d("Screen: Moving to next chapter (${currentChapterIndex + 1}).")
|
Timber.d("Screen: Moving to next chapter (${currentChapterIndex + 1}).")
|
||||||
initialScrollTargetForChapter = ChapterScrollPosition.START
|
initialScrollTargetForChapter = ChapterScrollPosition.START
|
||||||
currentScrollYPosition = 0
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex++
|
currentChapterIndex++
|
||||||
isAutoScrollPlaying = true
|
isAutoScrollPlaying = true
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2384,6 +2404,8 @@ fun EpubReaderHost(
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(20)
|
delay(20)
|
||||||
initialScrollTargetForChapter = ChapterScrollPosition.END
|
initialScrollTargetForChapter = ChapterScrollPosition.END
|
||||||
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex--
|
currentChapterIndex--
|
||||||
if (showBars) showBars = false
|
if (showBars) showBars = false
|
||||||
delay(300)
|
delay(300)
|
||||||
|
|
@ -2405,6 +2427,7 @@ fun EpubReaderHost(
|
||||||
delay(20)
|
delay(20)
|
||||||
initialScrollTargetForChapter = ChapterScrollPosition.START
|
initialScrollTargetForChapter = ChapterScrollPosition.START
|
||||||
currentScrollYPosition = 0
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex++
|
currentChapterIndex++
|
||||||
if (showBars) showBars = false
|
if (showBars) showBars = false
|
||||||
delay(300)
|
delay(300)
|
||||||
|
|
@ -2423,12 +2446,12 @@ fun EpubReaderHost(
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(50)
|
delay(50)
|
||||||
initialScrollTargetForChapter =
|
initialScrollTargetForChapter = ChapterScrollPosition.END
|
||||||
ChapterScrollPosition.END
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex--
|
currentChapterIndex--
|
||||||
if (showBars) showBars = false
|
if (showBars) showBars = false
|
||||||
Timber.d("Changed to previous chapter: $currentChapterIndex, will scroll to END"
|
Timber.d("Changed to previous chapter: $currentChapterIndex, will scroll to END")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pullToPrevProgress = 0f
|
pullToPrevProgress = 0f
|
||||||
|
|
@ -2443,9 +2466,9 @@ fun EpubReaderHost(
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(50)
|
delay(50)
|
||||||
initialScrollTargetForChapter =
|
initialScrollTargetForChapter = ChapterScrollPosition.START
|
||||||
ChapterScrollPosition.START
|
|
||||||
currentScrollYPosition = 0
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex++
|
currentChapterIndex++
|
||||||
if (showBars) showBars = false
|
if (showBars) showBars = false
|
||||||
}
|
}
|
||||||
|
|
@ -2604,7 +2627,7 @@ fun EpubReaderHost(
|
||||||
showDictionaryUpsellDialog = true
|
showDictionaryUpsellDialog = true
|
||||||
},
|
},
|
||||||
onCfiGenerated = { cfi ->
|
onCfiGenerated = { cfi ->
|
||||||
Timber.tag("POS_DIAG").d("JS generated CFI: '$cfi'")
|
Timber.tag("PosSaveDiag").d("JS generated CFI string: '$cfi'")
|
||||||
|
|
||||||
if (cfi.isBlank() || !cfi.startsWith('/')) {
|
if (cfi.isBlank() || !cfi.startsWith('/')) {
|
||||||
if (isSavingAndExiting) {
|
if (isSavingAndExiting) {
|
||||||
|
|
@ -2623,6 +2646,7 @@ fun EpubReaderHost(
|
||||||
)
|
)
|
||||||
|
|
||||||
if (locator != null) {
|
if (locator != null) {
|
||||||
|
Timber.tag("PosSaveDiag").d("✅ Converted CFI to Locator successfully: chapter=${locator.chapterIndex}, block=${locator.blockIndex}, charOffset=${locator.charOffset}")
|
||||||
lastKnownLocator = locator
|
lastKnownLocator = locator
|
||||||
|
|
||||||
val progressWithinChapter =
|
val progressWithinChapter =
|
||||||
|
|
@ -3131,8 +3155,11 @@ fun EpubReaderHost(
|
||||||
val chapterTitle =
|
val chapterTitle =
|
||||||
chapters.getOrNull(currentChapterIndex)?.title?.take(30)?.trim()
|
chapters.getOrNull(currentChapterIndex)?.title?.take(30)?.trim()
|
||||||
?: "Chapter"
|
?: "Chapter"
|
||||||
|
|
||||||
|
val displayPageInfo = if (currentScrollHeightValue <= 0 || isChapterParsing) "" else " ($currentPageInChapter/$totalPagesInCurrentChapter)"
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "$chapterTitle ($currentPageInChapter/$totalPagesInCurrentChapter)",
|
text = "$chapterTitle$displayPageInfo",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = effectiveText.copy(alpha = 0.8f),
|
color = effectiveText.copy(alpha = 0.8f),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
|
@ -3143,7 +3170,7 @@ fun EpubReaderHost(
|
||||||
.padding(horizontal = 48.dp)
|
.padding(horizontal = 48.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (totalBookLengthChars > 0) {
|
if (totalBookLengthChars > 0 && currentScrollHeightValue > 0 && !isChapterParsing) {
|
||||||
Text(
|
Text(
|
||||||
text = "%.1f%%".format(currentBookProgress),
|
text = "%.1f%%".format(currentBookProgress),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|
@ -3484,10 +3511,14 @@ fun EpubReaderHost(
|
||||||
val targetChunk = locator.blockIndex / 20
|
val targetChunk = locator.blockIndex / 20
|
||||||
chunkTargetOverride = targetChunk
|
chunkTargetOverride = targetChunk
|
||||||
if (currentChapterIndex != locator.chapterIndex) {
|
if (currentChapterIndex != locator.chapterIndex) {
|
||||||
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex = locator.chapterIndex
|
currentChapterIndex = locator.chapterIndex
|
||||||
}
|
}
|
||||||
cfiToLoad = cfi
|
cfiToLoad = cfi
|
||||||
} else {
|
} else {
|
||||||
|
currentScrollYPosition = 0
|
||||||
|
currentScrollHeightValue = 0
|
||||||
currentChapterIndex = locator.chapterIndex
|
currentChapterIndex = locator.chapterIndex
|
||||||
cfiToLoad = null
|
cfiToLoad = null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,12 +167,14 @@ class LocatorConverter(
|
||||||
val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath)
|
val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath)
|
||||||
|
|
||||||
if (bestMatch != null) {
|
if (bestMatch != null) {
|
||||||
|
Timber.tag("PosSaveDiag").d("Found best match for baseCfiPath $baseCfiPath -> blockIndex=${bestMatch.blockIndex}, actualBlockCfi=${bestMatch.cfi}")
|
||||||
Locator(
|
Locator(
|
||||||
chapterIndex = chapterIndex,
|
chapterIndex = chapterIndex,
|
||||||
blockIndex = bestMatch.blockIndex,
|
blockIndex = bestMatch.blockIndex,
|
||||||
charOffset = charOffset
|
charOffset = charOffset
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
Timber.tag("PosSaveDiag").e("No semantic block match found for baseCfiPath $baseCfiPath inside ${allBlocks.size} parsed blocks")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -432,6 +432,7 @@ internal fun PdfPageComposable(
|
||||||
draggingBoxId: String? = null,
|
draggingBoxId: String? = null,
|
||||||
isScrollLocked: Boolean = false,
|
isScrollLocked: Boolean = false,
|
||||||
isVisible: Boolean = true,
|
isVisible: Boolean = true,
|
||||||
|
isActivePage: Boolean = true,
|
||||||
isStylusOnlyMode: Boolean = false,
|
isStylusOnlyMode: Boolean = false,
|
||||||
isHighlighterSnapEnabled: Boolean = false,
|
isHighlighterSnapEnabled: Boolean = false,
|
||||||
userHighlights: List<PdfUserHighlight> = emptyList(),
|
userHighlights: List<PdfUserHighlight> = emptyList(),
|
||||||
|
|
@ -442,7 +443,9 @@ internal fun PdfPageComposable(
|
||||||
onTts: (Int, Int) -> Unit = { _, _ -> },
|
onTts: (Int, Int) -> Unit = { _, _ -> },
|
||||||
activeToolThickness: Float = 0f,
|
activeToolThickness: Float = 0f,
|
||||||
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
||||||
onPaletteClick: (() -> Unit)? = null
|
onPaletteClick: (() -> Unit)? = null,
|
||||||
|
lockedState: Triple<Float, Float, Float>? = null,
|
||||||
|
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val pdfDocumentItem = pdfDocument.item
|
val pdfDocumentItem = pdfDocument.item
|
||||||
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
|
var bitmapState by remember { mutableStateOf(PdfThumbnailCache.get(pageIndex)) }
|
||||||
|
|
@ -474,6 +477,10 @@ internal fun PdfPageComposable(
|
||||||
var scale by remember { mutableFloatStateOf(1f) }
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
|
||||||
|
LaunchedEffect(scale, offset) {
|
||||||
|
onZoomAndPanChanged?.invoke(scale, offset)
|
||||||
|
}
|
||||||
|
|
||||||
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
|
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
|
||||||
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
|
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
|
||||||
|
|
||||||
|
|
@ -720,12 +727,6 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(pageIndex) {
|
|
||||||
scale = 1f
|
|
||||||
offset = Offset.Zero
|
|
||||||
onScaleChanged(1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(isPerformingOcrForSelection) { onOcrStateChange(isPerformingOcrForSelection) }
|
LaunchedEffect(isPerformingOcrForSelection) { onOcrStateChange(isPerformingOcrForSelection) }
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
|
|
@ -1067,9 +1068,10 @@ internal fun PdfPageComposable(
|
||||||
canvasHeightPx.floatValue,
|
canvasHeightPx.floatValue,
|
||||||
isVerticalScroll,
|
isVerticalScroll,
|
||||||
isScrolling,
|
isScrolling,
|
||||||
virtualPage
|
virtualPage,
|
||||||
|
isActivePage
|
||||||
) {
|
) {
|
||||||
val needsTiling = effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000
|
val needsTiling = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
|
||||||
if (!needsTiling) {
|
if (!needsTiling) {
|
||||||
if (tiles.isNotEmpty()) {
|
if (tiles.isNotEmpty()) {
|
||||||
val oldTiles = tiles
|
val oldTiles = tiles
|
||||||
|
|
@ -2594,7 +2596,7 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, onDoubleTap = { tapOffset ->
|
}, onDoubleTap = { tapOffset ->
|
||||||
if (isZoomEnabled && !isVerticalScroll) {
|
if (isZoomEnabled && !isVerticalScroll && !isScrollLocked) {
|
||||||
if (actualBitmapWidthPx == 0) return@detectTapGestures
|
if (actualBitmapWidthPx == 0) return@detectTapGestures
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val startScale = scale
|
val startScale = scale
|
||||||
|
|
@ -2689,7 +2691,11 @@ internal fun PdfPageComposable(
|
||||||
|
|
||||||
if (!canceled) {
|
if (!canceled) {
|
||||||
val rawPanChange = event.calculatePan()
|
val rawPanChange = event.calculatePan()
|
||||||
val panChange = if (isScrollLocked) Offset(0f, rawPanChange.y) else rawPanChange
|
val panChange = if (isScrollLocked && pointerCount == 1) {
|
||||||
|
if (isVerticalScroll) Offset(0f, rawPanChange.y) else Offset.Zero
|
||||||
|
} else {
|
||||||
|
rawPanChange
|
||||||
|
}
|
||||||
val zoomChange = event.calculateZoom()
|
val zoomChange = event.calculateZoom()
|
||||||
|
|
||||||
if (scale > 1f) {
|
if (scale > 1f) {
|
||||||
|
|
@ -3112,14 +3118,21 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight
|
pageIndex, this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight,
|
||||||
|
isScrollLocked, lockedState
|
||||||
) {
|
) {
|
||||||
scale = 1f
|
if (isScrollLocked && !isVerticalScroll && lockedState != null) {
|
||||||
offset = Offset.Zero
|
scale = lockedState.first
|
||||||
onScaleChanged(1f)
|
offset = Offset(lockedState.second, lockedState.third)
|
||||||
|
onScaleChanged(scale)
|
||||||
|
} else if (!isScrollLocked && !isVerticalScroll) {
|
||||||
|
scale = 1f
|
||||||
|
offset = Offset.Zero
|
||||||
|
onScaleChanged(1f)
|
||||||
|
}
|
||||||
|
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"PdfPageComposable Page $pageIndex | Constraints: maxWidth=${this@BoxWithConstraints.maxWidth}, maxHeight=${this@BoxWithConstraints.maxHeight}"
|
"PdfPageComposable Page $pageIndex initialized/resized/locked. scale=$scale, offset=$offset"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,9 @@ internal fun PdfVerticalReader(
|
||||||
onTts: (Int, Int) -> Unit = { _, _ -> },
|
onTts: (Int, Int) -> Unit = { _, _ -> },
|
||||||
activeToolThickness: Float = 0f,
|
activeToolThickness: Float = 0f,
|
||||||
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
|
||||||
onPaletteClick: () -> Unit = {}
|
onPaletteClick: () -> Unit = {},
|
||||||
|
lockedState: Triple<Float, Float, Float>? = null,
|
||||||
|
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
|
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
|
||||||
DisposableEffect(state) {
|
DisposableEffect(state) {
|
||||||
|
|
@ -336,6 +338,10 @@ internal fun PdfVerticalReader(
|
||||||
val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) }
|
val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) }
|
||||||
val panYAnimatable = remember { Animatable(0f) }
|
val panYAnimatable = remember { Animatable(0f) }
|
||||||
|
|
||||||
|
LaunchedEffect(zoomAnimatable.value, panXAnimatable.value, panYAnimatable.value) {
|
||||||
|
onZoomAndPanChanged?.invoke(zoomAnimatable.value, Offset(panXAnimatable.value, panYAnimatable.value))
|
||||||
|
}
|
||||||
|
|
||||||
var isResizing by remember { mutableStateOf(false) }
|
var isResizing by remember { mutableStateOf(false) }
|
||||||
var previousScreenWidth by remember { mutableFloatStateOf(0f) }
|
var previousScreenWidth by remember { mutableFloatStateOf(0f) }
|
||||||
var previousScreenHeight by remember { mutableFloatStateOf(0f) }
|
var previousScreenHeight by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
@ -401,6 +407,12 @@ internal fun PdfVerticalReader(
|
||||||
delay(50)
|
delay(50)
|
||||||
isResizing = false
|
isResizing = false
|
||||||
targetPageDuringResize.intValue = -1
|
targetPageDuringResize.intValue = -1
|
||||||
|
} else if (isScrollLocked && lockedState != null) {
|
||||||
|
val (savedScale, savedPanX, _) = lockedState
|
||||||
|
coroutineScope {
|
||||||
|
launch { zoomAnimatable.snapTo(savedScale) }
|
||||||
|
launch { panXAnimatable.snapTo(savedPanX) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
isInitialLayout = false
|
isInitialLayout = false
|
||||||
}
|
}
|
||||||
|
|
@ -768,63 +780,65 @@ internal fun PdfVerticalReader(
|
||||||
}
|
}
|
||||||
|
|
||||||
val onDoubleTapToZoom: (Offset) -> Unit = { tapScreenOffset ->
|
val onDoubleTapToZoom: (Offset) -> Unit = { tapScreenOffset ->
|
||||||
val currentZoom = zoomAnimatable.value
|
if (!isScrollLocked) {
|
||||||
|
val currentZoom = zoomAnimatable.value
|
||||||
|
|
||||||
val targetZoom = when {
|
val targetZoom = when {
|
||||||
currentZoom < 0.95f -> 1f
|
currentZoom < 0.95f -> 1f
|
||||||
currentZoom < 2.45f -> 2.5f
|
currentZoom < 2.45f -> 2.5f
|
||||||
else -> fitZoom
|
else -> fitZoom
|
||||||
}
|
|
||||||
|
|
||||||
val startPanX = panXAnimatable.value
|
|
||||||
val startPanY = panYAnimatable.value
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
zoomAnimatable.stop()
|
|
||||||
panXAnimatable.stop()
|
|
||||||
panYAnimatable.stop()
|
|
||||||
|
|
||||||
val pivotContentX = (tapScreenOffset.x - startPanX) / currentZoom
|
|
||||||
val pivotContentY = (tapScreenOffset.y - startPanY) / currentZoom
|
|
||||||
|
|
||||||
val rawNextPanX = tapScreenOffset.x - (pivotContentX * targetZoom)
|
|
||||||
val rawNextPanY = tapScreenOffset.y - (pivotContentY * targetZoom)
|
|
||||||
|
|
||||||
val (finalZoom, finalX, finalY) = clampCamera(targetZoom, rawNextPanX, rawNextPanY)
|
|
||||||
|
|
||||||
panXAnimatable.updateBounds(
|
|
||||||
lowerBound = minOf(panXAnimatable.lowerBound ?: finalX, finalX, startPanX),
|
|
||||||
upperBound = maxOf(panXAnimatable.upperBound ?: finalX, finalX, startPanX)
|
|
||||||
)
|
|
||||||
panYAnimatable.updateBounds(
|
|
||||||
lowerBound = minOf(panYAnimatable.lowerBound ?: finalY, finalY, startPanY),
|
|
||||||
upperBound = maxOf(panYAnimatable.upperBound ?: finalY, finalY, startPanY)
|
|
||||||
)
|
|
||||||
|
|
||||||
coroutineScope {
|
|
||||||
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
|
|
||||||
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
|
|
||||||
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onZoomChange(zoomAnimatable.value)
|
val startPanX = panXAnimatable.value
|
||||||
|
val startPanY = panYAnimatable.value
|
||||||
|
|
||||||
val zoomedDocWidth = screenWidth * finalZoom
|
scope.launch {
|
||||||
val finalMinX: Float
|
zoomAnimatable.stop()
|
||||||
val finalMaxX: Float
|
panXAnimatable.stop()
|
||||||
if (zoomedDocWidth < screenWidth) {
|
panYAnimatable.stop()
|
||||||
val centeredX = (screenWidth - zoomedDocWidth) / 2f
|
|
||||||
finalMinX = centeredX
|
val pivotContentX = (tapScreenOffset.x - startPanX) / currentZoom
|
||||||
finalMaxX = centeredX
|
val pivotContentY = (tapScreenOffset.y - startPanY) / currentZoom
|
||||||
} else {
|
|
||||||
finalMinX = -(zoomedDocWidth - screenWidth)
|
val rawNextPanX = tapScreenOffset.x - (pivotContentX * targetZoom)
|
||||||
finalMaxX = 0f
|
val rawNextPanY = tapScreenOffset.y - (pivotContentY * targetZoom)
|
||||||
|
|
||||||
|
val (finalZoom, finalX, finalY) = clampCamera(targetZoom, rawNextPanX, rawNextPanY)
|
||||||
|
|
||||||
|
panXAnimatable.updateBounds(
|
||||||
|
lowerBound = minOf(panXAnimatable.lowerBound ?: finalX, finalX, startPanX),
|
||||||
|
upperBound = maxOf(panXAnimatable.upperBound ?: finalX, finalX, startPanX)
|
||||||
|
)
|
||||||
|
panYAnimatable.updateBounds(
|
||||||
|
lowerBound = minOf(panYAnimatable.lowerBound ?: finalY, finalY, startPanY),
|
||||||
|
upperBound = maxOf(panYAnimatable.upperBound ?: finalY, finalY, startPanY)
|
||||||
|
)
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
|
||||||
|
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
|
||||||
|
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
onZoomChange(zoomAnimatable.value)
|
||||||
|
|
||||||
|
val zoomedDocWidth = screenWidth * finalZoom
|
||||||
|
val finalMinX: Float
|
||||||
|
val finalMaxX: Float
|
||||||
|
if (zoomedDocWidth < screenWidth) {
|
||||||
|
val centeredX = (screenWidth - zoomedDocWidth) / 2f
|
||||||
|
finalMinX = centeredX
|
||||||
|
finalMaxX = centeredX
|
||||||
|
} else {
|
||||||
|
finalMinX = -(zoomedDocWidth - screenWidth)
|
||||||
|
finalMaxX = 0f
|
||||||
|
}
|
||||||
|
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
|
||||||
|
|
||||||
|
val zDocH = totalDocHeight * finalZoom
|
||||||
|
val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx)
|
||||||
|
panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx)
|
||||||
}
|
}
|
||||||
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
|
|
||||||
|
|
||||||
val zDocH = totalDocHeight * finalZoom
|
|
||||||
val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx)
|
|
||||||
panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1042,7 +1056,7 @@ internal fun PdfVerticalReader(
|
||||||
|
|
||||||
val zoomChange = event.calculateZoom()
|
val zoomChange = event.calculateZoom()
|
||||||
val rawPanChange = event.calculatePan()
|
val rawPanChange = event.calculatePan()
|
||||||
val panChange = if (isScrollLocked) Offset(0f, rawPanChange.y) else rawPanChange
|
val panChange = if (isScrollLocked && !isMultiTouch) Offset(0f, rawPanChange.y) else rawPanChange
|
||||||
|
|
||||||
val centroid = event.calculateCentroid(useCurrent = false)
|
val centroid = event.calculateCentroid(useCurrent = false)
|
||||||
val panMagnitude = panChange.getDistance()
|
val panMagnitude = panChange.getDistance()
|
||||||
|
|
|
||||||
|
|
@ -583,6 +583,25 @@ private fun getSuggestedFilename(originalName: String?, isAnnotated: Boolean): S
|
||||||
return "${safeBase}${suffix}_${shortId}.pdf"
|
return "${safeBase}${suffix}_${shortId}.pdf"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun savePdfLockedState(context: Context, bookId: String, scale: Float, offsetX: Float, offsetY: Float) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit {
|
||||||
|
putFloat("pdf_locked_scale_$bookId", scale)
|
||||||
|
putFloat("pdf_locked_offset_x_$bookId", offsetX)
|
||||||
|
putFloat("pdf_locked_offset_y_$bookId", offsetY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPdfLockedState(context: Context, bookId: String): Triple<Float, Float, Float>? {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
if (!prefs.contains("pdf_locked_scale_$bookId")) return null
|
||||||
|
return Triple(
|
||||||
|
prefs.getFloat("pdf_locked_scale_$bookId", 1f),
|
||||||
|
prefs.getFloat("pdf_locked_offset_x_$bookId", 0f),
|
||||||
|
prefs.getFloat("pdf_locked_offset_y_$bookId", 0f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private enum class SaveMode {
|
private enum class SaveMode {
|
||||||
ORIGINAL, ANNOTATED
|
ORIGINAL, ANNOTATED
|
||||||
}
|
}
|
||||||
|
|
@ -1181,6 +1200,9 @@ fun PdfViewerScreen(
|
||||||
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
|
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
|
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
|
||||||
var isScrollLocked by remember { mutableStateOf(false) }
|
var isScrollLocked by remember { mutableStateOf(false) }
|
||||||
|
var lockedState by remember { mutableStateOf<Triple<Float, Float, Float>?>(null) }
|
||||||
|
var currentActiveScale by remember { mutableFloatStateOf(1f) }
|
||||||
|
var currentActiveOffset by remember { mutableStateOf(Offset.Zero) }
|
||||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||||
var isPasswordError by remember { mutableStateOf(false) }
|
var isPasswordError by remember { mutableStateOf(false) }
|
||||||
LocalView.current
|
LocalView.current
|
||||||
|
|
@ -1239,6 +1261,7 @@ fun PdfViewerScreen(
|
||||||
LaunchedEffect(bookId) {
|
LaunchedEffect(bookId) {
|
||||||
isScrollLocked = loadPdfScrollLocked(context, bookId)
|
isScrollLocked = loadPdfScrollLocked(context, bookId)
|
||||||
isFullScreen = loadPdfFullScreen(context, bookId)
|
isFullScreen = loadPdfFullScreen(context, bookId)
|
||||||
|
lockedState = loadPdfLockedState(context, bookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAutoScrollModeActive by remember { mutableStateOf(false) }
|
var isAutoScrollModeActive by remember { mutableStateOf(false) }
|
||||||
|
|
@ -1504,6 +1527,14 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) }
|
LaunchedEffect(displayMode) { saveDisplayMode(context, displayMode) }
|
||||||
|
|
||||||
|
LaunchedEffect(currentActiveScale, currentActiveOffset, isScrollLocked) {
|
||||||
|
if (isScrollLocked) {
|
||||||
|
delay(500)
|
||||||
|
lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
|
||||||
|
savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
|
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
|
||||||
val toolSettings by annotationSettingsRepo.settings.collectAsState()
|
val toolSettings by annotationSettingsRepo.settings.collectAsState()
|
||||||
var showToolSettings by rememberSaveable { mutableStateOf(false) }
|
var showToolSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
@ -4367,7 +4398,7 @@ fun PdfViewerScreen(
|
||||||
key = { it },
|
key = { it },
|
||||||
beyondViewportPageCount = dynamicBeyondViewportPageCount,
|
beyondViewportPageCount = dynamicBeyondViewportPageCount,
|
||||||
userScrollEnabled = run {
|
userScrollEnabled = run {
|
||||||
val enabled = currentPageScale == 1f && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null
|
val enabled = (currentPageScale == 1f || (isScrollLocked && displayMode == DisplayMode.PAGINATION)) && !(ttsState.isPlaying || ttsState.isLoading || searchState.isSearchActive) && !isPageSliderVisible && paginationDraggingBoxId == null
|
||||||
SideEffect {
|
SideEffect {
|
||||||
Timber.tag("PdfZoomDebug").v("Pager Scroll Enabled: $enabled (Scale: $currentPageScale, Playing: ${ttsState.isPlaying}, Slider: $isPageSliderVisible, DraggingBox: $paginationDraggingBoxId)")
|
Timber.tag("PdfZoomDebug").v("Pager Scroll Enabled: $enabled (Scale: $currentPageScale, Playing: ${ttsState.isPlaying}, Slider: $isPageSliderVisible, DraggingBox: $paginationDraggingBoxId)")
|
||||||
}
|
}
|
||||||
|
|
@ -4619,6 +4650,13 @@ fun PdfViewerScreen(
|
||||||
onNoteRequested = onNoteRequested,
|
onNoteRequested = onNoteRequested,
|
||||||
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
|
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
|
||||||
activeToolThickness = currentStrokeWidthState,
|
activeToolThickness = currentStrokeWidthState,
|
||||||
|
lockedState = lockedState,
|
||||||
|
onZoomAndPanChanged = { newScale, newOffset ->
|
||||||
|
if (pagerState.currentPage == pageIndex) {
|
||||||
|
currentActiveScale = newScale
|
||||||
|
currentActiveOffset = newOffset
|
||||||
|
}
|
||||||
|
},
|
||||||
onTwoFingerSwipe = { direction ->
|
onTwoFingerSwipe = { direction ->
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val targetPage =
|
val targetPage =
|
||||||
|
|
@ -4774,6 +4812,8 @@ fun PdfViewerScreen(
|
||||||
},
|
},
|
||||||
onDragPageTurn = { /* Handled in onTextBoxDrag */ },
|
onDragPageTurn = { /* Handled in onTextBoxDrag */ },
|
||||||
isVisible = isVisiblePage,
|
isVisible = isVisiblePage,
|
||||||
|
isActivePage = pagerState.currentPage == pageIndex,
|
||||||
|
isScrolling = pagerState.isScrollInProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5025,7 +5065,12 @@ fun PdfViewerScreen(
|
||||||
isAutoScrollPlaying = isAutoScrollPlaying,
|
isAutoScrollPlaying = isAutoScrollPlaying,
|
||||||
isAutoScrollTempPaused = isAutoScrollTempPaused,
|
isAutoScrollTempPaused = isAutoScrollTempPaused,
|
||||||
autoScrollSpeed = autoScrollSpeed * 0.5f,
|
autoScrollSpeed = autoScrollSpeed * 0.5f,
|
||||||
onInteractionListener = onAutoScrollInteraction
|
onInteractionListener = onAutoScrollInteraction,
|
||||||
|
lockedState = lockedState,
|
||||||
|
onZoomAndPanChanged = { newScale, newOffset ->
|
||||||
|
currentActiveScale = newScale
|
||||||
|
currentActiveOffset = newOffset
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5529,6 +5574,10 @@ fun PdfViewerScreen(
|
||||||
onClick = {
|
onClick = {
|
||||||
isScrollLocked = !isScrollLocked
|
isScrollLocked = !isScrollLocked
|
||||||
savePdfScrollLocked(context, bookId, isScrollLocked)
|
savePdfScrollLocked(context, bookId, isScrollLocked)
|
||||||
|
if (isScrollLocked) {
|
||||||
|
savePdfLockedState(context, bookId, currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
|
||||||
|
lockedState = Triple(currentActiveScale, currentActiveOffset.x, currentActiveOffset.y)
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen,
|
imageVector = if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue