From f9e004498ac4db02a7c01b2cad92064fee84ec18 Mon Sep 17 00:00:00 2001 From: Maxime Bret Date: Fri, 9 Aug 2024 21:40:58 +0200 Subject: [PATCH] feat: rewrite of frame loader --- .../layoutEnhancer/layoutEnhancer.ts | 4 +- .../src/enhancers/pagination/pagination.ts | 4 +- packages/core/src/hooks/types.ts | 1 - .../core/src/spine/loader/SpineItemsLoader.ts | 6 +- .../core/src/spine/locator/SpineLocator.ts | 8 +- .../core/src/spineItem/commonSpineItem.ts | 38 +-- .../core/src/spineItem/frame/FrameItem.ts | 101 +++--- .../spineItem/frame/loader/attachFrameSrc.ts | 82 +++++ .../spineItem/frame/loader/configureFrame.ts | 70 ++++ .../createFrameElement.ts} | 7 +- .../src/spineItem/frame/loader/loadFrame.ts | 44 +++ .../core/src/spineItem/frame/loader/loader.ts | 309 +++++------------- .../src/spineItem/frame/loader/unloadFrame.ts | 25 ++ .../frame/loader/waitForFrameLoad.ts | 11 + .../frame/loader/waitForFrameReady.ts | 8 + .../src/spineItem/prePaginatedSpineItem.ts | 6 +- .../reflowable/ReflowableSpineItems.ts | 2 +- packages/core/src/utils/rxjs.ts | 16 +- 18 files changed, 416 insertions(+), 326 deletions(-) create mode 100644 packages/core/src/spineItem/frame/loader/attachFrameSrc.ts create mode 100644 packages/core/src/spineItem/frame/loader/configureFrame.ts rename packages/core/src/spineItem/frame/{createFrame$.ts => loader/createFrameElement.ts} (83%) create mode 100644 packages/core/src/spineItem/frame/loader/loadFrame.ts create mode 100644 packages/core/src/spineItem/frame/loader/unloadFrame.ts create mode 100644 packages/core/src/spineItem/frame/loader/waitForFrameLoad.ts create mode 100644 packages/core/src/spineItem/frame/loader/waitForFrameReady.ts diff --git a/packages/core/src/enhancers/layoutEnhancer/layoutEnhancer.ts b/packages/core/src/enhancers/layoutEnhancer/layoutEnhancer.ts index 24eed4c0..02660a11 100644 --- a/packages/core/src/enhancers/layoutEnhancer/layoutEnhancer.ts +++ b/packages/core/src/enhancers/layoutEnhancer/layoutEnhancer.ts @@ -88,7 +88,7 @@ export const layoutEnhancer = * Consider creating a bug ticket on both chromium and gecko projects. */ reader.spineItemsManager.items.forEach((item) => { - const frame = item.frame.getFrameElement() + const frame = item.frame.element if (!hasRedrawn && frame) { /* eslint-disable-next-line no-void */ @@ -113,7 +113,7 @@ export const layoutEnhancer = if ( item?.item.renditionLayout === `reflowable` && - frame?.getIsReady() && + frame?.isReady && !isImageType() && !frame.getViewportDimensions() ) { diff --git a/packages/core/src/enhancers/pagination/pagination.ts b/packages/core/src/enhancers/pagination/pagination.ts index 190b4959..771faae8 100644 --- a/packages/core/src/enhancers/pagination/pagination.ts +++ b/packages/core/src/enhancers/pagination/pagination.ts @@ -54,9 +54,9 @@ export const mapPaginationInfoToExtendedInfo = // domIndex: number; // charOffset: number; // serializeString?: string; - beginSpineItemReadingDirection: beginItem?.getReadingDirection(), + beginSpineItemReadingDirection: beginItem?.readingDirection, endChapterInfo: endItem ? chaptersInfo[endItem.item.id] : undefined, - endSpineItemReadingDirection: endItem?.getReadingDirection(), + endSpineItemReadingDirection: endItem?.readingDirection, // spineItemReadingDirection: focusedSpineItem?.getReadingDirection(), /** * This percentage is based of the weight (kb) of every items and the number of pages. diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index 350b519b..b4e35f96 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -1,6 +1,5 @@ import { Manifest } from "@prose-reader/shared" import { Observable } from "rxjs" -import { type createFrameItem } from "../spineItem/frame/FrameItem" export type UserDestroyFn = () => void | Observable diff --git a/packages/core/src/spine/loader/SpineItemsLoader.ts b/packages/core/src/spine/loader/SpineItemsLoader.ts index 6ec019d2..e74c3a1e 100644 --- a/packages/core/src/spine/loader/SpineItemsLoader.ts +++ b/packages/core/src/spine/loader/SpineItemsLoader.ts @@ -14,6 +14,7 @@ import { ReaderSettingsManager } from "../../settings/ReaderSettingsManager" import { DestroyableClass } from "../../utils/DestroyableClass" import { loadItems } from "./loadItems" import { mapToItemsToLoad } from "./mapToItemsToLoad" +import { waitForSwitch } from "../../utils/rxjs" export class SpineItemsLoader extends DestroyableClass { constructor( @@ -45,8 +46,11 @@ export class SpineItemsLoader extends DestroyableClass { * layout method we have to take it into consideration. */ const loadSpineItems$ = merge(navigationUpdate$, layoutHasChanged$).pipe( + // this can be changed by whatever we want and SHOULD not break navigation. + // Ideally loading faster is better but loading too close to user navigating can + // be dangerous. debounceTime(100, animationFrameScheduler), - withLatestFrom(this.context.bridgeEvent.viewportFree$), + waitForSwitch(this.context.bridgeEvent.viewportFree$), withLatestFrom(this.context.bridgeEvent.navigation$), map(([, navigation]) => navigation.position), mapToItemsToLoad({ spineLocator }), diff --git a/packages/core/src/spine/locator/SpineLocator.ts b/packages/core/src/spine/locator/SpineLocator.ts index f2d13a79..e96dfdcf 100644 --- a/packages/core/src/spine/locator/SpineLocator.ts +++ b/packages/core/src/spine/locator/SpineLocator.ts @@ -13,9 +13,7 @@ import { getSpineItemFromPosition } from "./getSpineItemFromPosition" import { getVisibleSpineItemsFromPosition } from "./getVisibleSpineItemsFromPosition" import { getItemVisibilityForPosition } from "./getItemVisibilityForPosition" -export type SpineLocator = ReturnType< - typeof createSpineLocator -> +export type SpineLocator = ReturnType export const createSpineLocator = ({ spineItemsManager, @@ -105,9 +103,7 @@ export const createSpineLocator = ({ } const getSpineItemFromIframe = (iframe: Element) => { - return spineItemsManager - .items - .find((item) => item.frame.getFrameElement() === iframe) + return spineItemsManager.items.find((item) => item.frame.element === iframe) } const getSpineItemPageIndexFromNode = ( diff --git a/packages/core/src/spineItem/commonSpineItem.ts b/packages/core/src/spineItem/commonSpineItem.ts index 8a7d1614..d21a0296 100644 --- a/packages/core/src/spineItem/commonSpineItem.ts +++ b/packages/core/src/spineItem/commonSpineItem.ts @@ -33,7 +33,7 @@ export const createCommonSpineItem = ({ const overlayElement = createOverlayElement(parentElement, item) const fingerTracker = createFingerTracker() const selectionTracker = createSelectionTracker() - const frame = new FrameItem( + const frameItem = new FrameItem( containerElement, item, context, @@ -74,8 +74,8 @@ export const createCommonSpineItem = ({ } const injectStyle = (cssText: string) => { - frame.removeStyle(`prose-reader-css`) - frame.addStyle(`prose-reader-css`, cssText) + frameItem.removeStyle(`prose-reader-css`) + frameItem.addStyle(`prose-reader-css`, cssText) } const adjustPositionOfElement = ({ @@ -106,8 +106,8 @@ export const createCommonSpineItem = ({ const getViewPortInformation = () => { const { width: pageWidth, height: pageHeight } = context.getPageSize() - const viewportDimensions = frame.getViewportDimensions() - const frameElement = frame.element + const viewportDimensions = frameItem.getViewportDimensions() + const frameElement = frameItem.element if ( containerElement && @@ -125,12 +125,12 @@ export const createCommonSpineItem = ({ } } - const load = () => frame.load() + const load = () => frameItem.load() - const unload = () => frame.unload() + const unload = () => frameItem.unload() const getBoundingRectOfElementFromSelector = (selector: string) => { - const frameElement = frame.element + const frameElement = frameItem.element if (frameElement && selector) { if (selector.startsWith(`#`)) { return frameElement.contentDocument @@ -206,7 +206,7 @@ export const createCommonSpineItem = ({ // window (viewport). This is handy because we can easily get the translated x/y without any extra information // such as page index, etc. However this might be a bit less performance to request heavily getBoundingClientRect const { left = 0, top = 0 } = - frame.getFrameElement()?.getBoundingClientRect() || {} + frameItem.element?.getBoundingClientRect() || {} const computedScale = getViewPortInformation()?.computedScale ?? 1 const adjustedX = position.clientX * computedScale + left const adjustedY = position.clientY * computedScale + top @@ -249,8 +249,8 @@ export const createCommonSpineItem = ({ ...options, }) - const contentLayout$ = frame.contentLayoutChange$.pipe( - withLatestFrom(frame.isReady$), + const contentLayout$ = frameItem.contentLayoutChange$.pipe( + withLatestFrom(frameItem.isReady$), map(([data, isReady]) => ({ isFirstLayout: data.isFirstLayout, isReady, @@ -263,22 +263,22 @@ export const createCommonSpineItem = ({ overlayElement, adjustPositionOfElement, getElementDimensions, - getHtmlFromResource: frame.getHtmlFromResource, + getHtmlFromResource: frameItem.getHtmlFromResource, getResource, translateFramePositionIntoPage, injectStyle, load, unload, - frame, + frame: frameItem, element: containerElement, getBoundingRectOfElementFromSelector, getViewPortInformation, isImageType, - isReady: frame.getIsReady, + isReady: () => frameItem.isReady, destroy: () => { destroySubject$.next() containerElement.remove() - frame?.destroy() + frameItem?.destroy() fingerTracker.destroy() selectionTracker.destroy() destroySubject$.complete() @@ -297,14 +297,14 @@ export const createCommonSpineItem = ({ return `rtl` } - const direction = this.frame.loader.getComputedStyleAfterLoad()?.direction + const direction = this.frame.getComputedStyleAfterLoad()?.direction if ([`ltr`, `rtl`].includes(direction || ``)) return direction as `ltr` | `rtl` return undefined }, isUsingVerticalWriting: () => - frame.getWritingMode()?.startsWith(`vertical`), + frameItem.getWritingMode()?.startsWith(`vertical`), executeOnLayoutBeforeMeasurementHook: executeOnLayoutBeforeMeasurementHook, selectionTracker, fingerTracker, @@ -312,8 +312,8 @@ export const createCommonSpineItem = ({ getDimensionsForPaginatedContent, $: { contentLayout$, - loaded$: frame.loaded$, - isReady$: frame.isReady$, + loaded$: frameItem.loaded$, + isReady$: frameItem.isReady$, }, } } diff --git a/packages/core/src/spineItem/frame/FrameItem.ts b/packages/core/src/spineItem/frame/FrameItem.ts index da9196f4..7d524062 100644 --- a/packages/core/src/spineItem/frame/FrameItem.ts +++ b/packages/core/src/spineItem/frame/FrameItem.ts @@ -1,4 +1,4 @@ -import { merge, Observable, Subject } from "rxjs" +import { merge, Observable } from "rxjs" import { Manifest } from "../.." import { Context } from "../../context/Context" import { @@ -14,7 +14,7 @@ import { type HookManager } from "../../hooks/HookManager" import { DestroyableClass } from "../../utils/DestroyableClass" export class FrameItem extends DestroyableClass { - public loader: ReturnType + protected loader: ReturnType public contentLayoutChange$: Observable<{ isFirstLayout: boolean @@ -37,43 +37,29 @@ export class FrameItem extends DestroyableClass { settings, }) - this.loader.$.isLoaded$.subscribe({ - next: (value) => { - this.isLoadedSync = value - }, - }) - - this.loader.$.isReady$.subscribe({ - next: (value) => { - this.isReadySync = value - }, - }) - /** * This is used as upstream layout change. This event is being listened to by upper app * in order to layout again and adjust every element based on the new content. */ this.contentLayoutChange$ = merge( - this.loader.$.unloaded$.pipe(map(() => ({ isFirstLayout: false }))), + this.loader.unloaded$.pipe(map(() => ({ isFirstLayout: false }))), this.ready$.pipe(map(() => ({ isFirstLayout: true }))), ) } - public destroySubject$ = new Subject() + // @todo optimize + public getComputedStyleAfterLoad() { + const frame = this.loader.element + const body = frame?.contentDocument?.body - /** - * @deprecated - */ - public isLoadedSync = false - - /** - * @deprecated - */ - public isReadySync = false + if (body) { + return frame?.contentWindow?.getComputedStyle(body) + } + } // @todo memoize public getViewportDimensions = () => { - const frame = this.loader.$.frameElement$.getValue() + const frame = this.loader.element if (frame && frame?.contentDocument) { const doc = frame.contentDocument @@ -102,7 +88,7 @@ export class FrameItem extends DestroyableClass { } public getWritingMode = () => { - return this.loader.getComputedStyleAfterLoad()?.writingMode as + return this.getComputedStyleAfterLoad()?.writingMode as | `vertical-rl` | `horizontal-tb` | undefined @@ -116,21 +102,33 @@ export class FrameItem extends DestroyableClass { return createHtmlPageFromResource(response, this.item) } - get element() { - return this.loader.$.frameElement$.getValue() + public get element() { + return this.loader.element } - /** - * @deprecated - */ - public getIsLoaded = () => this.isLoadedSync + public get unloaded$() { + return this.loader.unloaded$ + } - /** - * @deprecated - */ - public getIsReady = () => this.isReadySync + public get loaded$() { + return this.loader.loaded$ + } - public getFrameElement = () => this.loader.$.frameElement$.getValue() + public get ready$() { + return this.loader.ready$ + } + + public get isReady$() { + return this.loader.isReady$ + } + + public get isLoaded() { + return this.loader.state === "loaded" || this.loader.state === "ready" + } + + public get isReady() { + return this.loader.state === "ready" + } public load() { this.loader.load() @@ -147,7 +145,7 @@ export class FrameItem extends DestroyableClass { * want the iframe to trigger a new `layout` even and have infinite loop. */ public staticLayout = (size: { width: number; height: number }) => { - const frame = this.loader.$.frameElement$.getValue() + const frame = this.loader.element if (frame) { frame.style.width = `${size.width}px` frame.style.height = `${size.height}px` @@ -160,7 +158,7 @@ export class FrameItem extends DestroyableClass { } public addStyle(id: string, style: string, prepend?: boolean) { - const frameElement = this.loader.$.frameElement$.getValue() + const frameElement = this.loader.element if (frameElement) { createAddStyleHelper(frameElement)(id, style, prepend) @@ -168,37 +166,16 @@ export class FrameItem extends DestroyableClass { } public removeStyle(id: string) { - const frameElement = this.loader.$.frameElement$.getValue() + const frameElement = this.loader.element if (frameElement) { createRemoveStyleHelper(frameElement)(id) } } - get unload$() { - return this.loader.$.unload$ - } - - get unloaded$() { - return this.loader.$.unloaded$ - } - - get loaded$() { - return this.loader.$.loaded$ - } - - get ready$() { - return this.loader.$.ready$ - } - - get isReady$() { - return this.loader.$.isReady$ - } - public destroy = () => { super.destroy() - this.loader.unload() this.loader.destroy() } } diff --git a/packages/core/src/spineItem/frame/loader/attachFrameSrc.ts b/packages/core/src/spineItem/frame/loader/attachFrameSrc.ts new file mode 100644 index 00000000..92fb2ce2 --- /dev/null +++ b/packages/core/src/spineItem/frame/loader/attachFrameSrc.ts @@ -0,0 +1,82 @@ +import { + catchError, + from, + map, + mergeMap, + Observable, + of, + switchMap, + tap, +} from "rxjs" +import { ReaderSettingsManager } from "../../../settings/ReaderSettingsManager" +import { Report } from "../../../report" +import { ITEM_EXTENSION_VALID_FOR_FRAME_SRC } from "../../../constants" +import { createHtmlPageFromResource } from "../createHtmlPageFromResource" +import { Manifest } from "@prose-reader/shared" + +export const attachFrameSrc = ({ + settings, + item, +}: { + settings: ReaderSettingsManager + item: Manifest[`spineItems`][number] +}) => { + const getHtmlFromResource = (response: Response) => + createHtmlPageFromResource(response, item) + + return (stream: Observable) => + stream.pipe( + switchMap((frameElement) => { + const { fetchResource } = settings.settings + + /** + * Because of the bug with iframe and sw, we should not use srcdoc and sw together for + * html document. This is because resources will not pass through SW. IF `fetchResource` is being + * used the user should be aware of the limitation. We use srcdoc for everything except if we detect + * an html document and same origin. Hopefully that bug gets fixed one day. + * @see https://bugs.chromium.org/p/chromium/issues/detail?id=880768 + */ + if ( + !fetchResource && + item.href.startsWith(window.location.origin) && + // we have an encoding and it's a valid html + ((item.mediaType && + [ + `application/xhtml+xml`, + `application/xml`, + `text/html`, + `text/xml`, + ].includes(item.mediaType)) || + // no encoding ? then try to detect html + (!item.mediaType && + ITEM_EXTENSION_VALID_FOR_FRAME_SRC.some((extension) => + item.href.endsWith(extension), + ))) + ) { + frameElement?.setAttribute(`src`, item.href) + + return of(frameElement) + } else { + const fetchFn = fetchResource || (() => fetch(item.href)) + + return from(fetchFn(item)).pipe( + mergeMap((response) => getHtmlFromResource(response)), + tap((htmlDoc) => { + if (htmlDoc) { + frameElement?.setAttribute(`srcdoc`, htmlDoc) + } + }), + map(() => frameElement), + catchError((e) => { + Report.error( + `Error while trying to fetch or load resource for item ${item.id}`, + ) + console.error(e) + + return of(frameElement) + }), + ) + } + }), + ) +} diff --git a/packages/core/src/spineItem/frame/loader/configureFrame.ts b/packages/core/src/spineItem/frame/loader/configureFrame.ts new file mode 100644 index 00000000..763bb4c4 --- /dev/null +++ b/packages/core/src/spineItem/frame/loader/configureFrame.ts @@ -0,0 +1,70 @@ +import { combineLatest, EMPTY, map, mergeMap, Observable, of } from "rxjs" +import { ReaderSettingsManager } from "../../../settings/ReaderSettingsManager" +import { Report } from "../../../report" +import { Manifest } from "@prose-reader/shared" +import { HookManager } from "../../.." + +export const configureFrame = ({ + settings, + item, + hookManager, +}: { + settings: ReaderSettingsManager + item: Manifest[`spineItems`][number] + hookManager: HookManager +}) => { + return (stream: Observable) => + stream.pipe( + // We don't need sandbox since we are actually already allowing too much to the iframe + // frame.setAttribute(`sandbox`, `allow-same-origin allow-scripts`) + mergeMap((frame) => { + const body: HTMLElement | undefined | null = frame.contentDocument?.body + + if (!body) { + Report.error(`Something went wrong on iframe load ${item.id}`) + + return EMPTY + } + // console.log(frame.contentDocument?.head.childNodes) + // console.log(frame.contentDocument?.body.childNodes) + + // const script = frame.contentDocument?.createElement(`script`) + // // script?.setAttribute(`src`, `https://fred-wang.github.io/mathml.css/mspace.js`) + // // script?.setAttribute(`src`, `https://fred-wang.github.io/mathjax.js/mpadded-min.js`) + // // script?.setAttribute(`src`, `https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js`) + // // script?.setAttribute(`src`, `https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS-MML_CHTML`) + // if (script) { + // // console.log(frame.contentDocument?.head.childNodes) + // // console.log(frame.contentDocument?.body.childNodes) + // // frame.contentDocument?.head.appendChild(script) + // } + + frame.setAttribute(`role`, `main`) + + if (frame?.contentDocument && body) { + // computedStyleAfterLoad = frame?.contentWindow?.getComputedStyle(body) + } + + if (settings.settings.computedPageTurnMode !== `scrollable`) { + // @todo see what's the impact + frame.setAttribute(`tab-index`, `0`) + } + + // we conveniently wait for all the hooks so that the dom is correctly prepared + // in addition to be ready. + // domReadySubject$.next(frame) + + const hookResults = hookManager + .execute(`item.onLoad`, item.id, { + itemId: item.id, + frame, + }) + .filter( + (result): result is Observable => + result instanceof Observable, + ) + + return combineLatest([of(null), ...hookResults]).pipe(map(() => frame)) + }), + ) +} diff --git a/packages/core/src/spineItem/frame/createFrame$.ts b/packages/core/src/spineItem/frame/loader/createFrameElement.ts similarity index 83% rename from packages/core/src/spineItem/frame/createFrame$.ts rename to packages/core/src/spineItem/frame/loader/createFrameElement.ts index e966ce9e..3d3ede5c 100644 --- a/packages/core/src/spineItem/frame/createFrame$.ts +++ b/packages/core/src/spineItem/frame/loader/createFrameElement.ts @@ -1,7 +1,6 @@ -import { of } from "rxjs" -import { Report } from "../../report" +import { Report } from "../../../report" -export const createFrame$ = Report.measurePerformance( +export const createFrameElement = Report.measurePerformance( `SpineItemFrame createFrame`, Infinity, () => { @@ -29,6 +28,6 @@ export const createFrame$ = Report.measurePerformance( opacity: 0; ` - return of(frame) + return frame }, ) diff --git a/packages/core/src/spineItem/frame/loader/loadFrame.ts b/packages/core/src/spineItem/frame/loader/loadFrame.ts new file mode 100644 index 00000000..8e5b1d1f --- /dev/null +++ b/packages/core/src/spineItem/frame/loader/loadFrame.ts @@ -0,0 +1,44 @@ +import { of, tap } from "rxjs" +import { attachFrameSrc } from "./attachFrameSrc" +import { configureFrame } from "./configureFrame" +import { createFrameElement } from "./createFrameElement" +import { waitForFrameLoad } from "./waitForFrameLoad" +import { HookManager, Manifest } from "../../.." +import { ReaderSettingsManager } from "../../../settings/ReaderSettingsManager" +import { Context } from "../../../context/Context" +import { waitForSwitch } from "../../../utils/rxjs" + +export const loadFrame = ({ + settings, + item, + hookManager, + element, + onFrameElement, + context, +}: { + settings: ReaderSettingsManager + item: Manifest[`spineItems`][number] + hookManager: HookManager + element: HTMLElement + onFrameElement: (element: HTMLIFrameElement) => void + context: Context +}) => { + const frameElement = createFrameElement() + + return of(frameElement).pipe( + tap((frame) => { + element.appendChild(frame) + + onFrameElement(frame) + }), + attachFrameSrc({ settings, item }), + waitForSwitch(context.bridgeEvent.viewportFree$), + waitForFrameLoad, + waitForSwitch(context.bridgeEvent.viewportFree$), + configureFrame({ + item, + settings, + hookManager, + }), + ) +} diff --git a/packages/core/src/spineItem/frame/loader/loader.ts b/packages/core/src/spineItem/frame/loader/loader.ts index 5115b43c..0d6d6873 100644 --- a/packages/core/src/spineItem/frame/loader/loader.ts +++ b/packages/core/src/spineItem/frame/loader/loader.ts @@ -1,37 +1,25 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { - BehaviorSubject, - combineLatest, - EMPTY, - from, - fromEvent, - merge, - Observable, - of, - Subject, -} from "rxjs" +import { BehaviorSubject, merge, of, Subject } from "rxjs" import { exhaustMap, filter, - map, - mergeMap, share, - take, takeUntil, tap, - withLatestFrom, switchMap, + first, + map, distinctUntilChanged, - catchError, + withLatestFrom, } from "rxjs/operators" -import { Report } from "../../.." -import { ITEM_EXTENSION_VALID_FOR_FRAME_SRC } from "../../../constants" import { Context } from "../../../context/Context" import { Manifest } from "../../.." -import { createFrame$ } from "../createFrame$" -import { createHtmlPageFromResource } from "../createHtmlPageFromResource" import { ReaderSettingsManager } from "../../../settings/ReaderSettingsManager" import { HookManager } from "../../../hooks/HookManager" +import { waitForSwitch } from "../../../utils/rxjs" +import { loadFrame } from "./loadFrame" +import { unloadFrame } from "./unloadFrame" +import { waitForFrameReady } from "./waitForFrameReady" export const createLoader = ({ item, @@ -47,234 +35,109 @@ export const createLoader = ({ hookManager: HookManager }) => { const destroySubject$ = new Subject() - const loadSubject$ = new Subject() - const unloadSubject$ = new Subject() - const frameElementSubject$ = new BehaviorSubject< + const stateSubject = new BehaviorSubject< + "idle" | "loading" | "loaded" | "unloading" | "ready" + >("idle") + const loadSubject = new Subject() + const unloadSubject = new Subject() + const frameElementSubject = new BehaviorSubject< HTMLIFrameElement | undefined >(undefined) - const isLoadedSubject$ = new BehaviorSubject(false) - const isReadySubject$ = new BehaviorSubject(false) - // let onLoadHookReturns: ((() => void) | Subscription | void)[] = [] - let computedStyleAfterLoad: CSSStyleDeclaration | undefined - - const makeItHot = (source$: Observable) => { - source$.pipe(takeUntil(context.destroy$)).subscribe() - - return source$ - } - - const getHtmlFromResource = (response: Response) => - createHtmlPageFromResource(response, item) - - const waitForViewportFree$ = context.bridgeEvent.viewportState$.pipe( - filter((v) => v === `free`), - take(1), + const load$ = loadSubject.asObservable() + const unload$ = unloadSubject.asObservable() + const stateIdle$ = stateSubject.pipe(filter((state) => state === "idle")) + const stateIsReady$ = stateSubject.pipe( + map((state) => state === "ready"), + distinctUntilChanged(), ) - const unload$ = unloadSubject$.asObservable().pipe( - // @todo remove iframe when viewport is free - // @todo use takeUntil(load$) when it's the case to cancel - withLatestFrom(frameElementSubject$), - filter(([_, frame]) => !!frame), - map(([, frame]) => { - hookManager.destroy(`item.onLoad`, item.id) - - frame?.remove() - - frameElementSubject$.next(undefined) + const unloaded$ = unload$.pipe( + withLatestFrom(stateSubject), + filter(([, state]) => state !== "unloading" && state !== "idle"), + exhaustMap(() => { + stateSubject.next("unloading") + + return unloadFrame({ + hookManager, + item, + frameElement: frameElementSubject.getValue(), + context, + }).pipe( + tap(() => { + frameElementSubject.next(undefined) + + stateSubject.next("idle") + }), + ) }), share(), - takeUntil(destroySubject$), ) - /** - * Observable for loading the frame - */ - const load$ = loadSubject$.asObservable().pipe( - withLatestFrom(isLoadedSubject$), - filter(([_, isLoaded]) => !isLoaded), - // let's ignore later load as long as the first one still runs - exhaustMap(() => { - return createFrame$().pipe( - mergeMap((frame) => waitForViewportFree$.pipe(map(() => frame))), - mergeMap((frame) => { - parent.appendChild(frame) - - frameElementSubject$.next(frame) - - const { fetchResource } = settings.settings - - /** - * Because of the bug with iframe and sw, we should not use srcdoc and sw together for - * html document. This is because resources will not pass through SW. IF `fetchResource` is being - * used the user should be aware of the limitation. We use srcdoc for everything except if we detect - * an html document and same origin. Hopefully that bug gets fixed one day. - * @see https://bugs.chromium.org/p/chromium/issues/detail?id=880768 - */ - if ( - !fetchResource && - item.href.startsWith(window.location.origin) && - // we have an encoding and it's a valid html - ((item.mediaType && - [ - `application/xhtml+xml`, - `application/xml`, - `text/html`, - `text/xml`, - ].includes(item.mediaType)) || - // no encoding ? then try to detect html - (!item.mediaType && - ITEM_EXTENSION_VALID_FOR_FRAME_SRC.some((extension) => - item.href.endsWith(extension), - ))) - ) { - frame?.setAttribute(`src`, item.href) - - return of(frame) - } else { - const fetchFn = fetchResource || (() => fetch(item.href)) - - return from(fetchFn(item)).pipe( - mergeMap((response) => getHtmlFromResource(response)), - tap((htmlDoc) => { - if (htmlDoc) { - frame?.setAttribute(`srcdoc`, htmlDoc) - } - }), - map(() => frame), - catchError((e) => { - Report.error( - `Error while trying to fetch or load resource for item ${item.id}`, - ) - console.error(e) - - return of(frame) - }), - ) - } + const loaded$ = load$.pipe( + exhaustMap(() => + stateIdle$.pipe( + first(), + tap(() => { + stateSubject.next("loading") }), - - mergeMap((frame) => { - if (!frame) return EMPTY - - // We don't need sandbox since we are actually already allowing too much to the iframe - // frame.setAttribute(`sandbox`, `allow-same-origin allow-scripts`) - - return fromEvent(frame, `load`).pipe( - take(1), - mergeMap(() => { - const body: HTMLElement | undefined | null = - frame.contentDocument?.body - - if (!body) { - Report.error(`Something went wrong on iframe load ${item.id}`) - - return EMPTY - } - // console.log(frame.contentDocument?.head.childNodes) - // console.log(frame.contentDocument?.body.childNodes) - - // const script = frame.contentDocument?.createElement(`script`) - // // script?.setAttribute(`src`, `https://fred-wang.github.io/mathml.css/mspace.js`) - // // script?.setAttribute(`src`, `https://fred-wang.github.io/mathjax.js/mpadded-min.js`) - // // script?.setAttribute(`src`, `https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js`) - // // script?.setAttribute(`src`, `https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS-MML_CHTML`) - // if (script) { - // // console.log(frame.contentDocument?.head.childNodes) - // // console.log(frame.contentDocument?.body.childNodes) - // // frame.contentDocument?.head.appendChild(script) - // } - - frame.setAttribute(`role`, `main`) - - if (frame?.contentDocument && body) { - computedStyleAfterLoad = - frame?.contentWindow?.getComputedStyle(body) - } - - if (settings.settings.computedPageTurnMode !== `scrollable`) { - // @todo see what's the impact - frame.setAttribute(`tab-index`, `0`) - } - - // we conveniently wait for all the hooks so that the dom is correctly prepared - // in addition to be ready. - // domReadySubject$.next(frame) - - const hookResults = hookManager - .execute(`item.onLoad`, item.id, { - itemId: item.id, - frame, - }) - .filter( - (result): result is Observable => - result instanceof Observable, - ) - - return combineLatest([of(null), ...hookResults]).pipe( - map(() => frame), - ) - }), - ) + waitForSwitch(context.bridgeEvent.viewportFree$), + switchMap(() => + loadFrame({ + element: parent, + hookManager, + item, + onFrameElement: (element) => { + frameElementSubject.next(element) + }, + settings, + context, + }), + ), + tap(() => { + stateSubject.next("loaded") }), - - // we stop loading as soon as unload is requested - takeUntil(unloadSubject$), - ) - }), + takeUntil(unload$), + ), + ), share(), - makeItHot, - takeUntil(destroySubject$), ) - /** - * Observable for when the frame is: - * - loaded - * - ready - */ - const ready$ = load$.pipe( + const ready$ = loaded$.pipe( switchMap((frame) => - from(frame?.contentDocument?.fonts.ready || of(undefined)).pipe( - takeUntil(unloadSubject$), + of(frame).pipe( + waitForFrameReady, + tap(() => { + stateSubject.next("ready") + }), + takeUntil(unload$), ), ), share(), - makeItHot, - takeUntil(destroySubject$), ) - merge(load$.pipe(map(() => true)), unloadSubject$.pipe(map(() => false))) - .pipe(distinctUntilChanged(), takeUntil(destroySubject$)) - .subscribe(isLoadedSubject$) - - merge(ready$.pipe(map(() => true)), unloadSubject$.pipe(map(() => false))) - .pipe(distinctUntilChanged(), takeUntil(destroySubject$)) - .subscribe(isReadySubject$) - - unload$.subscribe() + merge(unloaded$, loaded$, ready$).pipe(takeUntil(destroySubject$)).subscribe() return { - load: () => loadSubject$.next(), - unload: () => unloadSubject$.next(), + load: () => loadSubject.next(), + unload: () => unloadSubject.next(), destroy: () => { - loadSubject$.complete() - unloadSubject$.complete() - frameElementSubject$.complete() + unloadSubject.next() + unloadSubject.complete() + loadSubject.complete() + frameElementSubject.complete() destroySubject$.next() destroySubject$.complete() - isReadySubject$.complete() - isLoadedSubject$.complete() }, - getComputedStyleAfterLoad: () => computedStyleAfterLoad, - $: { - load$: loadSubject$.asObservable(), - unload$: unloadSubject$.asObservable(), - loaded$: load$, - isLoaded$: isLoadedSubject$.asObservable(), - isReady$: isReadySubject$.asObservable().pipe(distinctUntilChanged()), - ready$, - unloaded$: unload$, - frameElement$: frameElementSubject$, + get state() { + return stateSubject.getValue() + }, + get element() { + return frameElementSubject.getValue() }, + isReady$: stateIsReady$, + ready$, + loaded$, + unloaded$, + element$: frameElementSubject.asObservable(), } } diff --git a/packages/core/src/spineItem/frame/loader/unloadFrame.ts b/packages/core/src/spineItem/frame/loader/unloadFrame.ts new file mode 100644 index 00000000..6f3af6d3 --- /dev/null +++ b/packages/core/src/spineItem/frame/loader/unloadFrame.ts @@ -0,0 +1,25 @@ +import { of, tap } from "rxjs" +import { HookManager, Manifest } from "../../.." +import { Context } from "../../../context/Context" +import { waitForSwitch } from "../../../utils/rxjs" + +export const unloadFrame = ({ + item, + hookManager, + frameElement, + context, +}: { + item: Manifest[`spineItems`][number] + hookManager: HookManager + frameElement?: HTMLIFrameElement + context: Context +}) => { + return of(null).pipe( + waitForSwitch(context.bridgeEvent.viewportFree$), + tap(() => { + hookManager.destroy(`item.onLoad`, item.id) + + frameElement?.remove() + }), + ) +} diff --git a/packages/core/src/spineItem/frame/loader/waitForFrameLoad.ts b/packages/core/src/spineItem/frame/loader/waitForFrameLoad.ts new file mode 100644 index 00000000..34d786ff --- /dev/null +++ b/packages/core/src/spineItem/frame/loader/waitForFrameLoad.ts @@ -0,0 +1,11 @@ +import { fromEvent, map, Observable, switchMap, take } from "rxjs" + +export const waitForFrameLoad = (stream: Observable) => + stream.pipe( + switchMap((frame) => + fromEvent(frame, `load`).pipe( + take(1), + map(() => frame), + ), + ), + ) diff --git a/packages/core/src/spineItem/frame/loader/waitForFrameReady.ts b/packages/core/src/spineItem/frame/loader/waitForFrameReady.ts new file mode 100644 index 00000000..e6984472 --- /dev/null +++ b/packages/core/src/spineItem/frame/loader/waitForFrameReady.ts @@ -0,0 +1,8 @@ +import { from, Observable, of, switchMap } from "rxjs" + +export const waitForFrameReady = (stream: Observable) => + stream.pipe( + switchMap((frame) => + from(frame?.contentDocument?.fonts.ready || of(undefined)), + ), + ) diff --git a/packages/core/src/spineItem/prePaginatedSpineItem.ts b/packages/core/src/spineItem/prePaginatedSpineItem.ts index 26e6c4b9..3563d47a 100644 --- a/packages/core/src/spineItem/prePaginatedSpineItem.ts +++ b/packages/core/src/spineItem/prePaginatedSpineItem.ts @@ -11,7 +11,7 @@ export const createPrePaginatedSpineItem = ({ containerElement, settings, hookManager, - index + index, }: { item: Manifest[`spineItems`][number] containerElement: HTMLElement @@ -26,7 +26,7 @@ export const createPrePaginatedSpineItem = ({ parentElement: containerElement, settings, hookManager, - index + index, }) const spineItemFrame = commonSpineItem.frame @@ -46,7 +46,7 @@ export const createPrePaginatedSpineItem = ({ const frameElement = spineItemFrame.element if ( - spineItemFrame?.getIsLoaded() && + spineItemFrame?.isLoaded && frameElement?.contentDocument && frameElement?.contentWindow ) { diff --git a/packages/core/src/spineItem/reflowable/ReflowableSpineItems.ts b/packages/core/src/spineItem/reflowable/ReflowableSpineItems.ts index 3cd2f236..57622ddd 100644 --- a/packages/core/src/spineItem/reflowable/ReflowableSpineItems.ts +++ b/packages/core/src/spineItem/reflowable/ReflowableSpineItems.ts @@ -94,7 +94,7 @@ export const createReflowableSpineItem = ({ // @todo simplify ? should be from common spine item if ( - spineItemFrame?.getIsLoaded() && + spineItemFrame?.isLoaded && frameElement?.contentDocument && frameElement?.contentWindow ) { diff --git a/packages/core/src/utils/rxjs.ts b/packages/core/src/utils/rxjs.ts index 8f51880d..c94447d0 100644 --- a/packages/core/src/utils/rxjs.ts +++ b/packages/core/src/utils/rxjs.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Observable, OperatorFunction } from "rxjs" -import { map } from "rxjs/operators" +import { Observable, of, OperatorFunction } from "rxjs" +import { first, map, switchMap, withLatestFrom } from "rxjs/operators" export const mapKeysTo = ( keys: K[], @@ -37,3 +37,15 @@ export function observeResize( } }) } + +export const waitForSwitch = + (waitForStream: Observable) => + (stream: Observable) => + stream.pipe( + switchMap((value) => + waitForStream.pipe( + first(), + map(() => value), + ), + ), + )