diff --git a/src/pages/syncPage.ts b/src/pages/syncPage.ts index 3476760431..bdf4d87ee0 100644 --- a/src/pages/syncPage.ts +++ b/src/pages/syncPage.ts @@ -243,6 +243,141 @@ export class SyncPage { } } + private async handleMangaResume() { + if ( + !this.page.sync.readerConfig || + !this.mangaProgress || + this.curState === 'undefined' || + this.curState.identifier === 'undefined' || + this.curState.episode === 'undefined' + ) + return; + this.mangaProgress.setChapter(this.page.sync.getEpisode(this.url)); + this.mangaProgress.setIdentifier(this.page.sync.getIdentifier(this.url)); + const savedProgress = await this.mangaProgress.loadMangaPage(); + const scrollElement = this.mangaProgress.isLongStrip(); + + if (savedProgress && scrollElement) { + logger.log('Manga Progress Found', savedProgress); + + const targetElement = getElementFromPath(savedProgress.nearestElementPath, scrollElement); + const AutoCloseUI = scrollElement; + const eventClose = + AutoCloseUI === document.documentElement || AutoCloseUI === document.body + ? window + : AutoCloseUI; + + const resumeMsg = utils.flashm( + ` +
+ `, + { + permanent: true, + error: false, + type: 'resume', + minimized: false, + position: 'bottom', + }, + ); + + const removeUIOnScroll = () => { + if (!targetElement || !resumeMsg) return; + + const rect = targetElement.getBoundingClientRect(); + if (rect.top < window.innerHeight * 0.25) { + j.$(resumeMsg).remove(); + eventClose.removeEventListener('scroll', removeUIOnScroll); + } + }; + + resumeMsg.find('.sync').on('click', () => { + eventClose.removeEventListener('scroll', removeUIOnScroll); + resume(savedProgress); + j.$(resumeMsg).remove(); + }); + + resumeMsg.find('.resumeClose').on('click', () => { + eventClose.removeEventListener('scroll', removeUIOnScroll); + j.$(resumeMsg).remove(); + }); + + eventClose.addEventListener('scroll', removeUIOnScroll, { passive: true }); + } + + // resume button handling + function resume(saved) { + if (!saved || !scrollElement) return; + + const el = getElementFromPath(saved.nearestElementPath, scrollElement); + logger.log('element check', el, saved.nearestElementPath, scrollElement); + if (!el) return; + + const isMainOrDiv = + scrollElement === document.documentElement || scrollElement === document.body; + let finalTarget = 0; + + const performPrecisionScroll = () => { + const rect = el.getBoundingClientRect(); + + const currentScroll = isMainOrDiv + ? window.pageYOffset || document.documentElement.scrollTop + : scrollElement.scrollTop; + + // If scroll in div, find container's offset from the top of the screen + const containerTop = isMainOrDiv ? 0 : scrollElement.getBoundingClientRect().top; + const elementTopInContainer = currentScroll + (rect.top - containerTop); + const absoluteTargetPoint = + elementTopInContainer + rect.height * (saved.pixelOffsets || 0.5); + finalTarget = absoluteTargetPoint - window.innerHeight / 2; + + scrollElement.scrollTo({ + top: finalTarget, + behavior: 'smooth', + }); + }; + + performPrecisionScroll(); + + let checks = 0; + const scrollInterval = setInterval(() => { + performPrecisionScroll(); + checks++; + + // Stop after few second or if the user starts scrolling manually + if (checks > 100) { + clearInterval(scrollInterval); + } + }, 500); + + const stopOnUserScroll = () => { + clearInterval(scrollInterval); + window.removeEventListener('wheel', stopOnUserScroll); + window.removeEventListener('touchmove', stopOnUserScroll); + window.removeEventListener('mousedown', stopOnUserScroll); + window.removeEventListener('keydown', stopOnUserScroll); + }; + window.addEventListener('wheel', stopOnUserScroll); + window.addEventListener('touchmove', stopOnUserScroll); + window.addEventListener('mousedown', stopOnUserScroll); + window.addEventListener('keydown', e => { + if (['ArrowUp', 'ArrowDown', 'Space', 'PageUp', 'PageDown'].includes(e.code)) { + stopOnUserScroll(); + } + }); + logger.log('Resumed manga progress', saved); + } + + function getElementFromPath(path: number[], root: HTMLElement): HTMLElement { + return path.reduce((current, index) => { + // If the next array (child) element exists, move down the tree; otherwise, stay at current + const child = current.children[index] as HTMLElement; + return child || current; + }, root); + } + } + curState: any = undefined; tempPlayer: any = undefined; @@ -337,6 +472,9 @@ export class SyncPage { }); }); } + if (this.page.type === 'manga') { + this.handleMangaResume(); + } logger.m('Sync', 'green').log(state); } else { if (typeof this.page.overview === 'undefined') { diff --git a/src/utils/mangaProgress/MangaProgress.ts b/src/utils/mangaProgress/MangaProgress.ts index f69127fd99..719c7b4e6f 100644 --- a/src/utils/mangaProgress/MangaProgress.ts +++ b/src/utils/mangaProgress/MangaProgress.ts @@ -34,6 +34,10 @@ export class MangaProgress { protected page: string; + protected identifier?: string; + + protected chapter?: number; + protected result: mangaProgress | null = null; protected interval; @@ -42,9 +46,11 @@ export class MangaProgress { // do nothing }; - constructor(configs: mangaProgressConfig[], page: string) { + constructor(configs: mangaProgressConfig[], page: string, identifier?: string, chapter?: number) { this.configs = [...alternativeReader, ...configs]; this.page = page; + this.identifier = identifier; + this.chapter = chapter; logger.log('config', this.configs); } @@ -141,10 +147,224 @@ export class MangaProgress { setProgress() { j.$('.ms-progress').css('width', `${this.progressPercentage()! * 100}%`); j.$('#malSyncProgress').removeClass('ms-loading').removeClass('ms-done'); + + if (this.page && this.identifier && this.chapter && (this.result?.current || 0) > 1) + this.saveMangaPage(); + if (this.finished() && j.$('#malSyncProgress').length) { j.$('#malSyncProgress').addClass('ms-done'); j.$('.flash.type-update .sync').trigger('click'); clearInterval(this.interval); + localStorage.removeItem(`mangaProgress-${this.page}-${this.identifier}-${this.chapter}`); + } + } + + setIdentifier(identifier: string) { + if (this.identifier !== identifier) { + this.identifier = identifier; + } + } + + setChapter(chapter: number) { + if (this.chapter !== chapter) { + this.chapter = chapter; + } + } + + // Page saving/loading logic + + isLongStrip(): HTMLElement | null { + const heightThreshold = window.innerHeight * 3; + const minWidth = 200; + + const containers = Array.from( + document.querySelectorAll('div, section, main, article'), + ) as HTMLElement[]; + + const bestDiv = containers.reduce((best: HTMLElement | null, current) => { + if (current.offsetWidth < minWidth) return best; + const scrollHeight = current.scrollHeight; + if (scrollHeight <= current.clientHeight || scrollHeight <= heightThreshold) { + return best; + } + + const style = window.getComputedStyle(current); + const isScrollable = /(auto|scroll)/.test(style.overflowY + style.overflow); + if (!isScrollable) return best; + + const bestHeight = best ? best.scrollHeight : 0; + if (scrollHeight > bestHeight) { + return current; + } + return best; + }, null); + if (bestDiv) return bestDiv; + + const docHeight = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight); + return docHeight > heightThreshold ? document.documentElement : null; + } + + saveMangaPage() { + const root = this.isLongStrip(); + if (!root) return; + + function getElementSelector(el: HTMLElement): number[] { + const path: number[] = []; + let current: HTMLElement | null = el; + + while (current && current !== root) { + const parent = current.parentElement; + if (!parent) break; + + const index = Array.from(parent.children).indexOf(current); + path.unshift(index); + current = parent; + } + return path; + } + + const viewportCenter = window.innerHeight / 2; + const elements = Array.from(root.querySelectorAll('*')); + + const result = elements.reduce( + (closest, el) => { + const style = getComputedStyle(el); + if (style.display === 'none' || style.opacity === '0') return closest; + if (el.tagName !== 'IMG') return closest; + + const rect = el.getBoundingClientRect(); + const vHeight = window.innerHeight; + if (rect.bottom < 0 || rect.top > vHeight) return closest; + if (rect.height < 100 || rect.width < 200) return closest; + + let currentSearch: HTMLElement | null = el; + let maxSiblingsFound = 0; + const currentLineage: HTMLElement[] = []; + for (let i = 0; i < 10; i++) { + if (!currentSearch || currentSearch === root) break; + currentLineage.push(currentSearch); + + const parent = currentSearch.parentElement; + if (parent) { + const tagName = currentSearch.tagName; + const siblingCount = Array.from(parent.children).filter( + child => (child as HTMLElement).tagName === tagName, + ).length; + if (siblingCount > maxSiblingsFound) { + maxSiblingsFound = siblingCount; + } + } + currentSearch = parent; + } + + if (maxSiblingsFound < 3) return closest; + + const elementCenter = rect.top + rect.height / 2; + const distance = Math.abs(elementCenter - viewportCenter); + if (!closest) { + return { + el, + distance, + maxSiblings: maxSiblingsFound, + lineageSet: new Set(currentLineage), + }; + } + const sharesAncestor = currentLineage.some(ancestor => closest.lineageSet.has(ancestor)); + + if (!sharesAncestor) { + if (maxSiblingsFound > closest.maxSiblings) { + return { + el, + distance, + maxSiblings: maxSiblingsFound, + lineageSet: new Set(currentLineage), + }; + } + if (maxSiblingsFound < closest.maxSiblings) { + return closest; + } + } + + if (distance < closest.distance) { + return { + el, + distance, + maxSiblings: maxSiblingsFound, + lineageSet: new Set(currentLineage), + }; + } + return closest; + }, + null as { + el: HTMLElement; + distance: number; + maxSiblings: number; + lineageSet: Set; + } | null, + ); + let relativeOffset = null as unknown; + + const nearestElement = result?.el; + if (nearestElement) { + const pixelPos = nearestElement.getBoundingClientRect(); + if (pixelPos.height > 0) { + relativeOffset = (viewportCenter - pixelPos.top) / pixelPos.height; + } + } + if (!this.result || !nearestElement || !relativeOffset) { + logger.log( + 'One of manga saving componenet is null', + this.result, + nearestElement, + relativeOffset, + ); + return; + } + const data = { + current: this.result.current, + total: this.result.total, + nearestElementPath: nearestElement ? getElementSelector(nearestElement) : null, + pixelOffsets: relativeOffset, + }; + localStorage.setItem( + `mangaProgress-${this.page}-${this.identifier}-${this.chapter}`, + JSON.stringify(data), + ); + } + + async loadMangaPage() { + const isReady = await new Promise(resolve => { + let attempts = 0; + const interval = setInterval(() => { + if (this.isLongStrip()) { + clearInterval(interval); + resolve(true); + } + if (++attempts > 10) { + // Check only for 5 seconds after page load + clearInterval(interval); + resolve(false); + } + }, 500); + }); + if (!isReady) { + logger.log('Page save only support for long strip as of now'); + return null; + } + + const saved = localStorage.getItem( + `mangaProgress-${this.page}-${this.identifier}-${this.chapter}`, + ); + if (!saved) return null; + + try { + const data = JSON.parse(saved); + if (data.current === data.total) return null; + + return data; + } catch (e) { + logger.warn('Failed to load manga progress', e); + return null; } } }