import { createEffect, createRoot, createSignal, Show, } from "solid-js"; import { EnhanceSearchGrids } from "../components/Enhance/EnhanceSearchGrids"; import { ThumbsGrids, } from "../components/Enhance/EnhanceThumbsGrids"; import { ScrollPreview, } from "../components/Enhance/ScrollPreview"; import { ReadHistoryPage } from "../components/Enhance/ReadHistory"; import { loadDisplayReadHistoryRecords, loadReadHistory, ReadingProgressSession, type ReadingProgress, } from "../state/readHistory"; import { SearchHistory } from "../components/Enhance/SearchHistory"; import { loadMyTagAppearances, refreshMyTags } from "../components/Enhance/MyTags"; import { SettingsMenu } from "../components/SettingsMenu"; import { BackToTop, clearBackToTopPosition, } from "../components/Widgets/BackToTop"; import { GalleryInfoPanel, FavoritesCategorySelect, TouchSearchAction, TouchSearchCategoryToggle, TouchSearchOptionToggle, TouchSearchPanel, TouchTopBar, } from "../components/TouchUI"; import * as eh from "../eh"; import { state } from "../state"; import { dispatchReady } from "../state/events"; import texts from "../i18n"; import { registerGlobalStyle } from "../utils"; import ehDomCss from "../eh/dom/styles.css"; import unoCss from "ehpeek:uno.css"; import themeCss from "../theme.css"; import { reportReaderOpenError } from "./Reader"; import { createGalleryCoordinator, type GalleryCoordinator, } from "./GalleryCoordinator"; import { createGalleryPreviewCache, type GalleryPreviewCache, } from "./GalleryPreviewCache"; import { createAppMount } from "./host"; import { createOverlayHost, OverlayHostProvider, type OverlayHost, } from "./OverlayHost"; import { applyUiScale, configureUi, markUiRoot, setUiPointer, type UiScale, } from "../ui"; function settingsMenuState(defaults = false) { const read = (setting: { defaultValue: T; value: T }): T => defaults ? setting.defaultValue : setting.value; return { openGalleryInNewTab: read(state.app.openGalleryInNewTab), locale: read(state.app.locale), readerEnabled: read(state.reader.enabled), exitReaderOnFullscreenExit: read(state.reader.exitOnFullscreenExit), readerFullscreenEnabled: read(state.reader.fullscreen), replacePreviewWithScroll: read(state.gallery.replacePreviewWithScroll), enhanceThumbsGridsEnabled: read(state.gallery.enhanceThumbs), enhanceSearchGridsEnabled: read(state.search.enhance), myTagsEnabled: read(state.gallery.myTags), readHistoryEnabled: read(state.gallery.readHistory), includeUnreadHistoryEnabled: read(state.gallery.includeUnreadHistory), searchHistoryEnabled: read(state.search.history), touchUiEnabled: read(state.touch.enabled), fitToViewport: read(state.touch.fitToViewport), portraitUiScale: read(state.app.portraitUiScale), landscapeUiScale: read(state.app.landscapeUiScale), }; } function applySettingsMenuState( next: ReturnType, ): void { if (!next.touchUiEnabled) { clearBackToTopPosition(); } state.app.openGalleryInNewTab.set(next.openGalleryInNewTab); state.app.locale.set(next.locale); state.reader.enabled.set(next.readerEnabled); state.reader.exitOnFullscreenExit.set(next.exitReaderOnFullscreenExit); state.reader.fullscreen.set(next.readerFullscreenEnabled); state.gallery.replacePreviewWithScroll.set(next.replacePreviewWithScroll); state.gallery.enhanceThumbs.set(next.enhanceThumbsGridsEnabled); state.search.enhance.set(next.enhanceSearchGridsEnabled); state.gallery.myTags.set(next.myTagsEnabled); state.gallery.readHistory.set(next.readHistoryEnabled); state.gallery.includeUnreadHistory.set(next.includeUnreadHistoryEnabled); state.search.history.set(next.searchHistoryEnabled); state.touch.enabled.set(next.touchUiEnabled); state.touch.fitToViewport.set(next.fitToViewport); state.app.portraitUiScale.set(next.portraitUiScale); state.app.landscapeUiScale.set(next.landscapeUiScale); window.location.reload(); } const gState = (() => { const settings = settingsMenuState(); const [columnsEnabled, setColumnsEnabled] = createSignal(currentColumnsEnabled()); const [leftHandedControls, setLeftHandedControls] = createSignal(state.app.leftHandedControls.value); const [settingsMenuOpen, setSettingsMenuOpen] = createSignal(false); const [uiScale, setUiScale] = createSignal(currentUiScale()); return { columnsEnabled, leftHandedControls, setLeftHandedControls, settings, settingsMenuOpen, setUiScale, setColumnsEnabled, setSettingsMenuOpen, uiScale, }; })(); function currentUiScale(): UiScale { return currentUiScaleSetting().value; } function currentUiScaleSetting() { return window.matchMedia("(orientation: landscape)").matches ? state.app.landscapeUiScale : state.app.portraitUiScale; } function currentColumnsEnabled(): boolean { return window.matchMedia("(orientation: landscape)").matches ? state.touch.landscapeColumns.value : state.touch.portraitColumns.value; } let overlayHost: OverlayHost; function updateUiScale(): void { const scale = currentUiScale(); gState.setUiScale(scale); applyUiScale(scale); overlayHost?.setUiScale(scale); } function updateColumnsLayout(): void { if (!gState.settings.touchUiEnabled) { return; } gState.setColumnsEnabled(currentColumnsEnabled()); } function setCurrentColumnsEnabled(enabled: boolean): void { const setting = window.matchMedia("(orientation: landscape)").matches ? state.touch.landscapeColumns : state.touch.portraitColumns; setting.set(enabled); gState.setColumnsEnabled(enabled); } function setCurrentUiScale(scale: UiScale): void { currentUiScaleSetting().set(scale); gState.setUiScale(scale); applyUiScale(scale); overlayHost?.setUiScale(scale); } function setLeftHandedControls(enabled: boolean): void { state.app.leftHandedControls.set(enabled); gState.setLeftHandedControls(enabled); } configureUi({ pointer: window.matchMedia("(hover: hover) and (pointer: fine)").matches ? "mouse" : "touch", site: eh.ehSiteTheme(), }); const PRESS_MIN_VISIBLE_MS = 100; const PRESS_MOVE_TOLERANCE_PX = 8; const UI_INTERACTION_SELECTOR = "a[href], button, input, select, textarea, label, [onclick], [role=button], [role=tab]"; const UI_PRESSABLE_SELECTOR = "[data-ehpeek-pressable=true]"; let pressedInteraction: HTMLElement | undefined; let pressedClearTimer: number | undefined; let pendingPress: { interaction: HTMLElement; pointerId: number; startX: number; startY: number; } | undefined; const clearPressedInteraction = () => { if (pressedClearTimer !== undefined) { window.clearTimeout(pressedClearTimer); pressedClearTimer = undefined; } pendingPress = undefined; pressedInteraction?.removeAttribute("data-ehpeek-pressed"); pressedInteraction = undefined; }; const showPressedInteraction = (interaction: HTMLElement) => { pressedInteraction = interaction; interaction.setAttribute("data-ehpeek-pressed", "true"); }; const releasePressedInteraction = () => { pressedClearTimer = window.setTimeout( clearPressedInteraction, PRESS_MIN_VISIBLE_MS, ); }; document.addEventListener("pointerdown", (event) => { if (event.pointerType !== "mouse") { setUiPointer("touch"); } clearPressedInteraction(); const interaction = event.target instanceof Element ? event.target.closest(UI_INTERACTION_SELECTOR) : null; if (!interaction?.closest(".ehpeek-ui-root")) { return; } pendingPress = { interaction, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, }; }, { capture: true, passive: true }); document.addEventListener("pointerup", (event) => { if (pendingPress?.pointerId !== event.pointerId) { return; } showPressedInteraction(pendingPress.interaction); pendingPress = undefined; releasePressedInteraction(); }, { capture: true, passive: true, }); document.addEventListener("click", (event) => { const target = event.target instanceof Element ? event.target : null; const interaction = target?.closest(UI_INTERACTION_SELECTOR); const pressable = target?.closest(UI_PRESSABLE_SELECTOR); if (interaction || !pressable?.closest(".ehpeek-ui-root")) { return; } clearPressedInteraction(); showPressedInteraction(pressable); releasePressedInteraction(); }); document.addEventListener("pointercancel", clearPressedInteraction, { capture: true, passive: true, }); document.addEventListener("pointermove", (event) => { if (event.pointerType === "mouse") { setUiPointer("mouse"); } if ( pendingPress?.pointerId === event.pointerId && Math.hypot( event.clientX - pendingPress.startX, event.clientY - pendingPress.startY, ) > PRESS_MOVE_TOLERANCE_PX ) { clearPressedInteraction(); } }, { capture: true, passive: true }); updateUiScale(); registerGlobalStyle("ehpeek-uno-style", unoCss); registerGlobalStyle("ehpeek-theme-style", themeCss); registerGlobalStyle("ehpeek-dom-style", ehDomCss); function allowFeatureFailure(name: string, run: () => void): void { try { run(); } catch (error) { console.error(`[ehpeek] ${name} failed`, error); } } async function allowAsyncFeatureFailure( name: string, run: () => Promise, ): Promise { try { await run(); } catch (error) { console.error(`[ehpeek] ${name} failed`, error); } } function requirePageDependency(name: string, dependency: T | null): T { if (dependency === null) { throw new Error(`Cannot initialize ${name}.`); } return dependency; } type GalleryReadButtonProps = { onRead: () => void; progress: () => ReadingProgress; }; function readButtonLabel(progress: ReadingProgress): string { return progress.hasHistory ? texts.reader.continueReading : texts.reader.startReading; } function readButtonProgress(progress: ReadingProgress): string { return progress.totalPages ? `${progress.currentPage}/${progress.totalPages}` : String(progress.currentPage); } function GalleryReadButton(props: GalleryReadButtonProps) { return ( ); } function TouchGalleryReadButton(props: GalleryReadButtonProps) { return ( ); } function installSettingsMenu(): void { if (typeof GM_registerMenuCommand === "function") { GM_registerMenuCommand(texts.settings.openSettings, () => { gState.setSettingsMenuOpen(true); }); } const mount = createAppMount( "fixed inset-0 z-[1150] pointer-events-none", overlayHost.element, ); mount.mount(() => ( { applySettingsMenuState(next); }} onOpenChange={gState.setSettingsMenuOpen} /> )); } function injectCommon(page: eh.PageType): void { if (gState.settings.searchHistoryEnabled) { allowFeatureFailure("Search history", () => { let host: ReturnType | null = null; eh.observeSearchBar((source) => { host ??= createAppMount(); host.mount(() => ); }); }); } if (!gState.settings.touchUiEnabled) { allowFeatureFailure("Desktop settings entry", () => { const settingsMount = eh.manageSettingsMenuMount(); if (!settingsMount) { return; } settingsMount.mount(() => ( { event.preventDefault(); event.stopPropagation(); gState.setSettingsMenuOpen(true); }} > {__EHPEEK_NAME__} )); }); return; } allowFeatureFailure("Touch top bar", () => { const topBarDom = eh.manageTopBar(); if (!topBarDom) { return; } const columnsAvailable = page.type === "gallery" || page.type === "readHistory" || ((page.type === "search" || page.type === "favorites") && state.search.grid.value !== null); topBarDom.elems.mount.mount(() => ( { gState.setSettingsMenuOpen(true); }} /> )); }); if ( page.type === "gallery" || page.type === "search" || page.type === "favorites" || page.type === "readHistory" ) { allowFeatureFailure("Back to top", () => { const host = createAppMount(); host.mount(() => ); }); } } function injectGalleryDetails( previewCache: GalleryPreviewCache, coordinator: GalleryCoordinator, ): void { const preview = previewCache.current(); allowFeatureFailure("Touch GalleryInfo", () => { eh.mutateGalleryTouchLayout(gState.settings.fitToViewport); const galleryInfoDom = requirePageDependency( "Touch GalleryInfo", eh.manageGalleryInfo(preview.data), ); galleryInfoDom.handle.installGalleryInfoPanel(); galleryInfoDom.elems.mount.mount(() => ( )} /> )); const wideLayout = requirePageDependency( "Touch Gallery layout", eh.mutateGalleryWideLayout( galleryInfoDom, preview, gState.columnsEnabled(), gState.settings.replacePreviewWithScroll, ), ); createEffect(() => wideLayout.updateEnabled(gState.columnsEnabled())); }); allowFeatureFailure("Touch Gallery comments", () => { eh.manageGalleryCommentsTouch(reportReaderOpenError); }); } function injectGalleryPreview( previewCache: GalleryPreviewCache, coordinator: GalleryCoordinator, ): void { const preview = previewCache.current(); const previewMount = preview.elems.mount; if (gState.settings.readerEnabled) { allowFeatureFailure("Reader thumbnail links", () => { preview.handle.interceptPreviewImageOpen((pageUrl) => { coordinator.openGalleryPage(pageUrl); }); }); } if (!gState.settings.touchUiEnabled) { allowFeatureFailure("Desktop Read button", () => { const galleryReadButtonMount = eh.manageGalleryContinueReadingButtonMount(); galleryReadButtonMount.mount(() => ( )); }); } if (!previewMount) { return; } allowFeatureFailure("Gallery Preview enhancements", () => { if (gState.settings.replacePreviewWithScroll) { preview.handle.installScrollPreviewMount(); } previewMount.mount(() => (
{ if (gState.columnsEnabled()) { state.gallery.embeddedScrollPreviewColumnsDirection.set(direction); } else { state.gallery.embeddedScrollPreviewSingleDirection.set(direction); } }} onReadDirectionChange={(direction) => { state.gallery.scrollPreviewDirection.set(direction); }} previewCache={previewCache} readDirection={state.gallery.scrollPreviewDirection.value} replaceOriginalPreview={gState.settings.replacePreviewWithScroll} /> {gState.settings.enhanceThumbsGridsEnabled && !gState.settings.replacePreviewWithScroll ? ( ) : null}
)); }); } function injectGalleryPage( page: Extract, ): void { const preview = eh.manageGalleryPreview(); const previewCache = createGalleryPreviewCache(preview); const coordinator = createGalleryCoordinator({ enhanceThumbsGridsEnabled: gState.settings.enhanceThumbsGridsEnabled, exitReaderOnFullscreenExit: gState.settings.exitReaderOnFullscreenExit, includeUnreadHistoryEnabled: gState.settings.includeUnreadHistoryEnabled, overlayHost, previewCache, readHistoryEnabled: gState.settings.readHistoryEnabled, readerEnabled: gState.settings.readerEnabled, readerFullscreenEnabled: gState.settings.readerFullscreenEnabled, replacePreviewWithScroll: gState.settings.replacePreviewWithScroll, }); if (gState.settings.myTagsEnabled) { allowFeatureFailure("Gallery My Tags appearance", () => { const myTagAppearances = loadMyTagAppearances(); if (myTagAppearances) { eh.mutateGalleryMyTags(myTagAppearances); return; } void allowAsyncFeatureFailure("My Tags appearance", async () => { const appearances = await refreshMyTags(); if (appearances) { eh.mutateGalleryMyTags(appearances); } }); }); } if (gState.settings.touchUiEnabled) { injectGalleryDetails(previewCache, coordinator); } injectGalleryPreview(previewCache, coordinator); if (state.reader.enabled.value && page.peekPage !== null) { void allowAsyncFeatureFailure("Reader deep link", async () => { await coordinator.openReaderFromHash(); }); } } function injectSearchControls( page: Extract, ): eh.TouchResultsPageDom { const touchResultsDom = eh.manageTouchResultsPage( page, gState.settings.fitToViewport, ); allowFeatureFailure("Touch Search panel", () => { const searchPanelDom = eh.manageSearchPanel(); if (!searchPanelDom) { return; } markUiRoot(searchPanelDom.elems.form.Component()); searchPanelDom.elems.mount.mount(() => ( ) : undefined} /> )); searchPanelDom.elems.categoryToggleMount?.mount(() => ( )); searchPanelDom.elems.advancedToggleMount?.mount(() => ( )); searchPanelDom.elems.fileSearchToggleMount?.mount(() => ( )); searchPanelDom.elems.searchActionMount.mount(() => ( )); searchPanelDom.elems.clearActionMount?.mount(() => ( )); }); return touchResultsDom; } function injectSearchPage( page: Extract, ): void { const initialResultsDom = requirePageDependency( "Search results", eh.manageSearchResults(), ); const [resultsDom, setResultsDom] = createSignal( initialResultsDom, ); markUiRoot(initialResultsDom.elems.resultList.Component()); const updateSearchGridModeSelector = () => { eh.mutateSearchGridModeSelect( state.search.grid.value, (mode) => { state.search.grid.set(mode); const url = new URL(window.location.href); url.searchParams.set("inline_set", "dm_e"); window.location.assign(url.href); }, () => { state.search.grid.set(null); }, ); }; allowFeatureFailure("Search grid mode selector", updateSearchGridModeSelector); const searchGridMode = state.search.grid.value; if (searchGridMode) { allowFeatureFailure( "Search grid", () => eh.manageSearchGrids(searchGridMode), ); } const updateSearchReadHistoryAppearance = () => { if (!gState.settings.readHistoryEnabled) { return; } eh.mutateSearchReadHistoryAppearance(loadReadHistory); }; allowFeatureFailure("Search Read History appearance", updateSearchReadHistoryAppearance); let stopObservingAppendedResults: () => void = () => undefined; const observeAppendedResults = (source: eh.SearchResultsDom) => { stopObservingAppendedResults(); stopObservingAppendedResults = source.handle.listenResultRowsAdded(() => { allowFeatureFailure("Appended Search results", () => { if (searchGridMode) { eh.manageSearchGrids(searchGridMode); } updateSearchReadHistoryAppearance(); }); }); }; observeAppendedResults(initialResultsDom); if (gState.settings.openGalleryInNewTab) { allowFeatureFailure("Gallery links in new tabs", () => { initialResultsDom.handle.listenGalleryLinksOpenInNewTab(); }); } const updateSearchPage = (source: eh.SearchResultsDom) => { markUiRoot(source.elems.resultList.Component()); setResultsDom(source); observeAppendedResults(source); updateSearchGridModeSelector(); if (searchGridMode) { eh.manageSearchGrids(searchGridMode); } updateSearchReadHistoryAppearance(); }; const mountSearchPagination = ( onPageChange: (source: eh.SearchResultsDom) => void, ) => { if (!gState.settings.enhanceSearchGridsEnabled) { return; } allowFeatureFailure("Enhanced Search pagination", () => { const host = createAppMount(); host.mount(() => ( allowFeatureFailure("Changed Search page", () => onPageChange(source))} /> )); }); }; if (gState.settings.touchUiEnabled) { const touchResultsDom = injectSearchControls(page); createEffect(() => { resultsDom().handle.updateResultColumns(gState.columnsEnabled()); }); mountSearchPagination((source) => { updateSearchPage(source); touchResultsDom.handle.updateTouchResultsLayout(); }); } else { mountSearchPagination(updateSearchPage); } } function injectReadHistoryPage( page: Extract, ): void { const pageSize = 25; const records = loadDisplayReadHistoryRecords(); const pageCount = Math.max(1, Math.ceil(records.length / pageSize)); const pageIndex = Math.min(page.pageIndex, pageCount - 1); const items = records.map((record) => ({ currentPage: record.pageNum, galleryId: record.galleryId, info: record.gallery, token: record.token, totalPages: record.totalPages, updatedAt: record.updatedAt, })); const historyDom = requirePageDependency( "Read History page", eh.manageReadHistoryPage( items.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize), state.gallery.titlePreference.reload(), state.search.grid.value ?? "ehpeek-lite", ), ); markUiRoot(historyDom.elems.resultList.Component()); markUiRoot(historyDom.elems.navigationBottomMount.Component()); if (gState.settings.openGalleryInNewTab) { historyDom.handle.listenGalleryLinksOpenInNewTab(); } if (gState.settings.touchUiEnabled) { createEffect(() => { historyDom.handle.updateResultColumns(gState.columnsEnabled()); }); allowFeatureFailure("Touch Read History layout", () => { eh.manageTouchResultsPage(page, true); }); } historyDom.elems.navigationTopMount.mount(() => ( )); } function injectImagePage( page: Extract, ): void { if (!gState.settings.readHistoryEnabled) { return; } const gallery = requirePageDependency( "Image Gallery data", eh.extractImageGalleryPage(), ); if (gallery.galleryId !== page.galleryId) { return; } const previous = loadReadHistory(gallery.galleryId, gallery.token); const historySession = new ReadingProgressSession({ gallery: previous?.gallery, galleryId: gallery.galleryId, token: gallery.token, totalPages: previous?.totalPages, }, { currentPage: previous?.pageNum && previous.pageNum > 0 ? previous.pageNum : 1, hasHistory: Boolean(previous && previous.pageNum > 0), totalPages: previous?.totalPages ?? null, }); historySession.update(page.pageNum, previous?.totalPages); } function injectPage(page: eh.PageType): void { updateUiScale(); injectCommon(page); switch (page.type) { case "gallery": allowFeatureFailure("Gallery page", () => injectGalleryPage(page)); break; case "favorites": case "search": allowFeatureFailure("Search page", () => injectSearchPage(page)); break; case "readHistory": allowFeatureFailure("Read History page", () => injectReadHistoryPage(page)); break; case "image": allowFeatureFailure("Image page", () => injectImagePage(page)); break; case "myTags": if (gState.settings.myTagsEnabled) { void allowAsyncFeatureFailure("My Tags refresh", async () => { await refreshMyTags(eh.extractMyTagsPageData()); }); } break; case "settings": { const titlePreference = eh.extractGalleryTitlePreference(); if (titlePreference) { state.gallery.titlePreference.set(titlePreference); } break; } case "other": break; } } eh.initializeExternalAutocompleteUi(); let historyRouteActive = eh.extractPageType().type === "readHistory"; window.addEventListener("hashchange", () => { const nextHistoryRouteActive = eh.extractPageType().type === "readHistory"; if (historyRouteActive !== nextHistoryRouteActive) { window.location.reload(); } historyRouteActive = nextHistoryRouteActive; }); async function startApp(): Promise { if (document.readyState === "loading") { await new Promise((resolve) => { document.addEventListener("DOMContentLoaded", () => resolve(), { once: true }); }); } overlayHost = createOverlayHost(document.body, currentUiScale()); const page = eh.extractPageType(); const onViewportResize = () => { updateUiScale(); updateColumnsLayout(); }; window.addEventListener("resize", onViewportResize, { passive: true }); createRoot(() => { installSettingsMenu(); injectPage(page); }); dispatchReady(); } void startApp().catch((error) => { console.error("[ehpeek] App startup failed", error); });