diff --git a/navigator-html-injectables/package.json b/navigator-html-injectables/package.json index 61bacb7a..396ef2fa 100644 --- a/navigator-html-injectables/package.json +++ b/navigator-html-injectables/package.json @@ -1,6 +1,6 @@ { "name": "@readium/navigator-html-injectables", - "version": "1.3.3", + "version": "2.0.0-beta.1", "type": "module", "description": "An embeddable solution for connecting frames of HTML publications with a Readium Navigator", "author": "readium", @@ -48,7 +48,6 @@ "node": ">=18" }, "devDependencies": { - "@juggle/resize-observer": "^3.4.0", "@readium/shared": "workspace:*", "css-selector-generator": "^3.6.9", "tslib": "^2.6.1", diff --git a/navigator-html-injectables/src/comms/keys.ts b/navigator-html-injectables/src/comms/keys.ts index 11f8b564..373b0734 100644 --- a/navigator-html-injectables/src/comms/keys.ts +++ b/navigator-html-injectables/src/comms/keys.ts @@ -28,6 +28,8 @@ export type CommsCommandKey = "go_end" | "go_start" | "go_progression" | + "get_properties" | + "update_properties" | "set_property" | "remove_property" | // "exact_progress" | diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts new file mode 100644 index 00000000..37af7186 --- /dev/null +++ b/navigator-html-injectables/src/helpers/color.ts @@ -0,0 +1,114 @@ +export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number; } => { + if (color.startsWith("rgb")) { + const rgb = color.match(/rgb\((\d+),\s(\d+),\s(\d+)(?:,\s(\d+))?\)/); + if (rgb) { + return { + r: parseInt(rgb[1], 10), + g: parseInt(rgb[2], 10), + b: parseInt(rgb[3], 10), + a: rgb[4] ? parseInt(rgb[4], 10) / 255 : 1, + }; + } + } else if (color.startsWith("#")) { + const hex = color.slice(1); + if (hex.length === 3 || hex.length === 4) { + return { + r: parseInt(hex[0] + hex[0], 16) / 255, + g: parseInt(hex[1] + hex[1], 16) / 255, + b: parseInt(hex[2] + hex[2], 16) / 255, + a: hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1, + }; + } else if (hex.length === 6 || hex.length === 8) { + return { + r: parseInt(hex[0] + hex[1], 16) / 255, + g: parseInt(hex[2] + hex[3], 16) / 255, + b: parseInt(hex[4] + hex[5], 16) / 255, + a: hex.length === 8 ? parseInt(hex[6] + hex[7], 16) / 255 : 1, + }; + } + } + return { r: 0, g: 0, b: 0, a: 1 }; +}; + +export const getLuminance = (color: { r: number; g: number; b: number; a: number }): number => { + return 0.2126 * color.r * color.a + 0.7152 * color.g * color.a + 0.0722 * color.b * color.a; +} + +export const isDarkColor = (color: string): boolean => { + const rgba = colorToRgba(color); + const luminance = getLuminance(rgba); + return luminance < 128; +}; + +export const isLightColor = (color: string): boolean => !isDarkColor(color); + +export const checkContrast = (color1: string, color2: string): number => { + const rgba1 = colorToRgba(color1); + const rgba2 = colorToRgba(color2); + const lum1 = getLuminance(rgba1); + const lum2 = getLuminance(rgba2); + const brightest = Math.max(lum1, lum2); + const darkest = Math.min(lum1, lum2); + return (brightest + 0.05) / (darkest + 0.05); +}; + +export const ensureContrast = (color1: string, color2: string, contrast: number = 4.5): string[] => { + const c1 = colorToRgba(color1); + const c2 = colorToRgba(color2); + + const lum1 = getLuminance(c1); + const lum2 = getLuminance(c2); + const [darkest, brightest] = lum1 < lum2 ? [lum1, lum2] : [lum2, lum1]; + + const contrastRatio = (brightest + 0.05) / (darkest + 0.05); + if (contrastRatio >= contrast) { + return [ + `rgba(${c1.r}, ${c1.g}, ${c1.b}, ${c1.a})`, + `rgba(${c2.r}, ${c2.g}, ${c2.b}, ${c2.a})` + ]; + } + + const adjustColor = (color: { r: number; g: number; b: number; a: number }, delta: number) => ({ + r: Math.max(0, Math.min(255, color.r + delta)), + g: Math.max(0, Math.min(255, color.g + delta)), + b: Math.max(0, Math.min(255, color.b + delta)), + a: color.a + }); + + const delta = ((contrast - contrastRatio) * 255) / (contrastRatio + 0.05); + let correctedColor: { r: number; g: number; b: number; a: number }; + let otherColor: { r: number; g: number; b: number; a: number }; + if (lum1 < lum2) { + correctedColor = c1; + otherColor = c2; + } else { + correctedColor = c2; + otherColor = c1; + } + + const correctedColorAdjusted = adjustColor(correctedColor, -delta); + const newLum = getLuminance(correctedColorAdjusted); + const newContrastRatio = (brightest + 0.05) / (newLum + 0.05); + + if (newContrastRatio < contrast) { + const updatedDelta = ((contrast - newContrastRatio) * 255) / (newContrastRatio + 0.05); + const otherColorAdjusted = adjustColor(otherColor, updatedDelta); + return [ + lum1 < lum2 + ? `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})` + : `rgba(${otherColorAdjusted.r}, ${otherColorAdjusted.g}, ${otherColorAdjusted.b}, ${otherColorAdjusted.a})`, + lum1 < lum2 + ? `rgba(${otherColorAdjusted.r}, ${otherColorAdjusted.g}, ${otherColorAdjusted.b}, ${otherColorAdjusted.a})` + : `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})`, + ]; + } + + return [ + lum1 < lum2 + ? `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})` + : `rgba(${otherColor.r}, ${otherColor.g}, ${otherColor.b}, ${otherColor.a})`, + lum1 < lum2 + ? `rgba(${otherColor.r}, ${otherColor.g}, ${otherColor.b}, ${otherColor.a})` + : `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})`, + ]; +}; diff --git a/navigator-html-injectables/src/helpers/css.ts b/navigator-html-injectables/src/helpers/css.ts index 7caf6645..69413c80 100644 --- a/navigator-html-injectables/src/helpers/css.ts +++ b/navigator-html-injectables/src/helpers/css.ts @@ -1,5 +1,42 @@ import { ReadiumWindow } from "./dom"; +export function getProperties(wnd: ReadiumWindow) { + const cssProperties: { [key: string]: string } = {}; + + const rootStyle = wnd.document.documentElement.style; + + for (const prop in wnd.document.documentElement.style) { + // We check if the property belongs to the CSSStyleDeclaration instance + // We also ensure that the property is a numeric index (indicating an inline style) + if ( + Object.hasOwn(rootStyle, prop) && + !Number.isNaN(Number.parseInt(prop)) + ) { + cssProperties[rootStyle[prop]] = rootStyle.getPropertyValue(rootStyle[prop]); + } + } + + return cssProperties; +} + +export function updateProperties(wnd: ReadiumWindow, properties: { [key: string]: string }) { + const currentProperties = getProperties(wnd); + + // Remove properties + Object.keys(currentProperties).forEach((key) => { + if (!properties.hasOwnProperty(key)) { + removeProperty(wnd, key); + } + }); + + // Update properties + Object.entries(properties).forEach(([key, value]) => { + if (currentProperties[key] !== value) { + setProperty(wnd, key, value); + } + }); +} + // Easy way to get a CSS property export function getProperty(wnd: ReadiumWindow, key: string) { return wnd.document.documentElement.style.getPropertyValue(key); diff --git a/navigator-html-injectables/src/helpers/document.ts b/navigator-html-injectables/src/helpers/document.ts index a0b83fb5..e8a6c442 100644 --- a/navigator-html-injectables/src/helpers/document.ts +++ b/navigator-html-injectables/src/helpers/document.ts @@ -13,31 +13,32 @@ export function getColumnCountPerScreen(wnd: ReadiumWindow) { } /** - * Having an odd number of columns when displaying two columns per screen causes snapping and page - * turning issues. To fix this, we insert a blank virtual column at the end of the resource. + * We have to make sure that the total number of columns is a multiple + * of the number of columns per screen. + * Otherwise it causes snapping and page turning issues. + * To fix this, we insert and remove blank virtual columns at the end of the resource. */ export function appendVirtualColumnIfNeeded(wnd: ReadiumWindow) { - const id = "readium-virtual-page"; - let virtualCol = wnd.document.getElementById(id); - if (getColumnCountPerScreen(wnd) !== 2) { - if (virtualCol) { - virtualCol.remove(); - } - } else { - const documentWidth = wnd.document.scrollingElement!.scrollWidth; - const colCount = documentWidth / wnd.innerWidth; - const hasOddColCount = (Math.round(colCount * 2) / 2) % 1 > 0.1; - if (hasOddColCount) { - if (virtualCol) - virtualCol.remove(); - else { - virtualCol = wnd.document.createElement("div"); - virtualCol.setAttribute("id", id); - virtualCol.dataset.readium = "true"; - virtualCol.style.breakBefore = "column"; - virtualCol.innerHTML = "​"; // zero-width space - wnd.document.body.appendChild(virtualCol); - } + const virtualCols = wnd.document.querySelectorAll("div[id^='readium-virtual-page']"); + // Remove first so that we don’t end up with an incorrect scrollWidth + // Even when removing their width we risk having an incorrect scrollWidth + // so removing them entirely is the most robust solution + for (const virtualCol of virtualCols) { + virtualCol.remove(); + } + const colCount = getColumnCountPerScreen(wnd); + const documentWidth = wnd.document.scrollingElement!.scrollWidth; + const neededColCount = Math.ceil(documentWidth / wnd.innerWidth) * colCount; + const totalColCount = Math.round((documentWidth / wnd.innerWidth) * colCount); + const needed = colCount === 1 ? 0 : neededColCount - totalColCount; + if (needed > 0) { + for (let i = 0; i < needed; i++) { + const virtualCol = wnd.document.createElement("div"); + virtualCol.setAttribute("id", `readium-virtual-page-${ i }`); + virtualCol.dataset.readium = "true"; + virtualCol.style.breakBefore = "column"; + virtualCol.innerHTML = "​"; // zero-width space + wnd.document.body.appendChild(virtualCol); } } } \ No newline at end of file diff --git a/navigator-html-injectables/src/helpers/scrollSnapperHelper.ts b/navigator-html-injectables/src/helpers/scrollSnapperHelper.ts deleted file mode 100644 index f15f90e8..00000000 --- a/navigator-html-injectables/src/helpers/scrollSnapperHelper.ts +++ /dev/null @@ -1,43 +0,0 @@ -const iframeAnchors = { - top: "js-iframe-reader-top-anchor", - bottom: "js-iframe-reader-bottom-anchor", -}; - -export class AnchorObserver extends HTMLElement { - connectedCallback() { - this.setAttribute("aria-hidden", "true"); - this.style.setProperty("display", "block", "important"); - this.style.setProperty("padding", "0", "important"); - this.style.setProperty("margin", "0", "important"); - this.style.setProperty("visibility", "hidden", "important"); - } -} - -export function helperCreateAnchorElements(iframeElement: HTMLElement) { - const body = iframeElement.getElementsByTagName("body")[0]; - const anchorTop = document.createElement("anchor-observer"); - const anchorBottom = document.createElement("anchor-observer"); - anchorTop.dataset.readium = "true"; - anchorBottom.dataset.readium = "true"; - - anchorTop?.setAttribute("id", iframeAnchors.top); - anchorTop?.style.setProperty("height", "115px", "important"); - - anchorBottom?.setAttribute("id", iframeAnchors.bottom); - anchorBottom?.style.setProperty("height", "80px", "important"); - - body?.insertBefore(anchorTop, body.firstChild); - body?.appendChild(anchorBottom); -} - -export function helperRemoveAnchorElements(iframeElement: HTMLElement) { - const body = iframeElement.getElementsByTagName("body")[0]; - const anchorTop = body?.querySelector(`#${iframeAnchors.top}`); - const anchorBottom = body?.querySelector(`#${iframeAnchors.bottom}`); - if (anchorTop) { - anchorTop.parentElement?.removeChild(anchorTop); - } - if (anchorBottom) { - anchorBottom.parentElement?.removeChild(anchorBottom); - } -} \ No newline at end of file diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index cbfeea2d..840e2f57 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -4,12 +4,9 @@ import { Module } from "./Module"; import { rangeFromLocator } from "../helpers/locator"; import { ModuleName } from "./ModuleLibrary"; import { Rect, getClientRectsNoOverlap } from "../helpers/rect"; -import { ResizeObserver as Polyfill } from '@juggle/resize-observer'; import { getProperty } from "../helpers/css"; import { ReadiumWindow } from "../helpers/dom"; - -// Necessary for iOS 13 and below -const ResizeObserver = window.ResizeObserver || Polyfill; +import { isDarkColor } from "../helpers/color"; export enum Width { Wrap = "wrap", // Smallest width fitting the CSS border box. @@ -254,7 +251,8 @@ class DecorationGroup { // template.innerHTML = item.decoration.element.trim(); // TODO more styles logic - const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on"; + const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on" || + isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")); template.innerHTML = `
{ + getProperties(wnd); + ack(true); + }); + comms.register("update_properties", ReflowableSetup.moduleName, (data, ack) => { + // In order to keep the viewport width that is added in the setup itself, + // and don’t have EpubNavigator concerned with this, we need to add it there + (data as { [key: string]: string })["--RS__viewportWidth"] = `${ wnd.innerWidth }px`; + updateProperties(wnd, data as { [key: string]: string }); + ack(true); + }) comms.register("set_property", ReflowableSetup.moduleName, (data, ack) => { const kv = data as string[]; setProperty(wnd, kv[0], kv[1]); diff --git a/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts b/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts index d63cd20b..127e4dc2 100644 --- a/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts +++ b/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts @@ -1,4 +1,3 @@ -import { ResizeObserver as Polyfill } from '@juggle/resize-observer'; import { Comms } from "../../comms"; import { Snapper } from "./Snapper"; import { getColumnCountPerScreen, isRTL, appendVirtualColumnIfNeeded } from "../../helpers/document"; @@ -272,18 +271,43 @@ export class ColumnSnapper extends Snapper { `; wnd.document.head.appendChild(d); - // Necessary for iOS 13 and below - const ResizeObserver = (wnd as ReadiumWindow & typeof globalThis).ResizeObserver || Polyfill; - - this.resizeObserver = new ResizeObserver(() => wnd.requestAnimationFrame(() => { - wnd && appendVirtualColumnIfNeeded(wnd); - })); + this.resizeObserver = new ResizeObserver(() => { + wnd.requestAnimationFrame(() => { + wnd && appendVirtualColumnIfNeeded(wnd); + }); + this.onWidthChange(); + }); this.resizeObserver.observe(wnd.document.body); - this.mutationObserver = new MutationObserver(() => { - this.wnd.requestAnimationFrame(() => this.cachedScrollWidth = this.doc().scrollWidth!); + + this.mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + // We have to check it’s not onTouchMove + snapOffset setting transforms + if (mutation.target === this.wnd.document.documentElement) { + const oldValue = mutation.oldValue as string; + const newValue = (mutation.target as HTMLElement).getAttribute("style") as string; + const transformRegex = /transform\s*:\s*([^;]+)/; + const oldValueTransform = oldValue?.match(transformRegex); + const newValueTransform = newValue?.match(transformRegex); + if ( + (!oldValueTransform && !newValueTransform) || + (oldValueTransform && !newValueTransform) || + (oldValueTransform && newValueTransform && oldValueTransform[1] !== newValueTransform[1]) + ) { + wnd.requestAnimationFrame(() => { + wnd && appendVirtualColumnIfNeeded(wnd); + }); + this.onWidthChange(); + } + } else { + wnd.requestAnimationFrame(() => this.cachedScrollWidth = this.doc().scrollWidth!); + } + } }); wnd.frameElement && this.mutationObserver.observe(wnd.frameElement, {attributes: true, attributeFilter: ["style"]}); this.mutationObserver.observe(wnd.document, {attributes: true, attributeFilter: ["style"]}); + // For cases the resizeObserver is not able to detect cos body is not resizing despite colCount, + // we need to check the syle attribute on the documentElement (ReadiumCSS props) + this.mutationObserver.observe(wnd.document.documentElement, {attributes: true, attributeFilter: ["style"]}); const scrollToOffset = (offset: number): boolean => { const oldScrollLeft = this.doc().scrollLeft; @@ -387,6 +411,7 @@ export class ColumnSnapper extends Snapper { comms.register("go_prev", ColumnSnapper.moduleName, (_, ack) => { this.wnd.requestAnimationFrame(() => { + this.cachedScrollWidth = this.doc().scrollWidth!; const offset = wnd.scrollX - wnd.innerWidth; const minOffset = isRTL(wnd) ? - (this.cachedScrollWidth - wnd.innerWidth) : 0; const change = scrollToOffset(Math.max(offset, minOffset)); @@ -400,6 +425,7 @@ export class ColumnSnapper extends Snapper { comms.register("go_next", ColumnSnapper.moduleName, (_, ack) => { this.wnd.requestAnimationFrame(() => { + this.cachedScrollWidth = this.doc().scrollWidth!; const offset = wnd.scrollX + wnd.innerWidth; const maxOffset = isRTL(wnd) ? 0 : this.cachedScrollWidth - wnd.innerWidth; const change = scrollToOffset(Math.min(offset, maxOffset)); diff --git a/navigator-html-injectables/src/modules/snapper/ScrollSnapper.ts b/navigator-html-injectables/src/modules/snapper/ScrollSnapper.ts index d18fdde8..14cdb6cf 100644 --- a/navigator-html-injectables/src/modules/snapper/ScrollSnapper.ts +++ b/navigator-html-injectables/src/modules/snapper/ScrollSnapper.ts @@ -1,7 +1,6 @@ import { Locator, LocatorLocations, LocatorText } from "@readium/shared"; import { Comms } from "../../comms"; import { ReadiumWindow, deselect, findFirstVisibleLocator } from "../../helpers/dom"; -import { AnchorObserver, helperCreateAnchorElements, helperRemoveAnchorElements } from '../../helpers/scrollSnapperHelper'; import { ModuleName } from "../ModuleLibrary"; import { Snapper } from "./Snapper"; import { rangeFromLocator } from "../../helpers/locator"; @@ -17,19 +16,6 @@ export class ScrollSnapper extends Snapper { return this.wnd.document.scrollingElement as HTMLElement; } - private createAnchorElements = () => { - helperCreateAnchorElements(this.doc()); - } - - private removeAnchorElements = () => { - helperRemoveAnchorElements(this.doc()); - } - - private createCustomElement = () => { - customElements.get("anchor-observer") || - customElements.define("anchor-observer", AnchorObserver); - } - private reportProgress(data: { progress: number, reference: number }) { this.comms.send("progress", data); } @@ -158,14 +144,11 @@ export class ScrollSnapper extends Snapper { }); comms.log("ScrollSnapper Mounted"); - this.createCustomElement(); - this.createAnchorElements(); return true; } unmount(wnd: ReadiumWindow, comms: Comms): boolean { comms.unregisterAll(ScrollSnapper.moduleName); - this.removeAnchorElements(); wnd.document.getElementById(SCROLL_SNAPPER_STYLE_ID)?.remove(); comms.log("ScrollSnapper Unmounted"); return true; diff --git a/navigator/package.json b/navigator/package.json index 637c48d3..5fbe0701 100644 --- a/navigator/package.json +++ b/navigator/package.json @@ -1,6 +1,6 @@ { "name": "@readium/navigator", - "version": "1.3.4", + "version": "2.0.0-beta.1", "type": "module", "description": "Next generation SDK for publications in Web Apps", "author": "readium", @@ -49,12 +49,12 @@ }, "devDependencies": { "@laynezh/vite-plugin-lib-assets": "^0.5.25", + "@readium/css": ">=2.0.0-beta.5", "@readium/navigator-html-injectables": "workspace:*", "@readium/shared": "workspace:*", "@types/path-browserify": "^1.0.3", "css-selector-generator": "^3.6.9", "path-browserify": "^1.0.1", - "@readium/css": "^1.1.0", "tslib": "^2.8.1", "typescript": "^5.6.3", "typescript-plugin-css-modules": "^5.1.0", diff --git a/navigator/src/epub/EpubNavigator.ts b/navigator/src/epub/EpubNavigator.ts index f2de6b21..ee9712fb 100644 --- a/navigator/src/epub/EpubNavigator.ts +++ b/navigator/src/epub/EpubNavigator.ts @@ -1,5 +1,5 @@ import { EPUBLayout, Link, Locator, Publication, ReadingProgression } from "@readium/shared"; -import { VisualNavigator } from "../"; +import { Configurable, ConfigurablePreferences, ConfigurableSettings, LineLengths, VisualNavigator } from "../"; import { FramePoolManager } from "./frame/FramePoolManager"; import { FXLFramePoolManager } from "./fxl/FXLFramePoolManager"; import { CommsEventKey, FXLModules, ModuleLibrary, ModuleName, ReflowableModules } from "@readium/navigator-html-injectables"; @@ -7,9 +7,21 @@ import { BasicTextSelection, FrameClickEvent } from "@readium/navigator-html-inj import * as path from "path-browserify"; import { FXLFrameManager } from "./fxl/FXLFrameManager"; import { FrameManager } from "./frame/FrameManager"; +import { IEpubPreferences, EpubPreferences } from "./preferences/EpubPreferences"; +import { IEpubDefaults, EpubDefaults } from "./preferences/EpubDefaults"; +import { EpubSettings } from "./preferences"; +import { EpubPreferencesEditor } from "./preferences/EpubPreferencesEditor"; +import { ReadiumCSS } from "./css/ReadiumCSS"; +import { RSProperties, UserProperties } from "./css/Properties"; +import { getContentWidth } from "../helpers/dimensions"; export type ManagerEventKey = "zoom"; +export interface EpubNavigatorConfiguration { + preferences: IEpubPreferences; + defaults: IEpubDefaults; +} + export interface EpubNavigatorListeners { frameLoaded: (wnd: Window) => void; positionChanged: (locator: Locator) => void; @@ -35,7 +47,7 @@ const defaultListeners = (listeners: EpubNavigatorListeners): EpubNavigatorListe textSelected: listeners.textSelected || (() => {}) }) -export class EpubNavigator extends VisualNavigator { +export class EpubNavigator extends VisualNavigator implements Configurable { private readonly pub: Publication; private readonly container: HTMLElement; private readonly listeners: EpubNavigatorListeners; @@ -46,7 +58,15 @@ export class EpubNavigator extends VisualNavigator { private currentProgression: ReadingProgression; public readonly layout: EPUBLayout; - constructor(container: HTMLElement, pub: Publication, listeners: EpubNavigatorListeners, positions: Locator[] = [], initialPosition: Locator | undefined = undefined) { + private _preferences: EpubPreferences; + private _defaults: EpubDefaults; + private _settings: EpubSettings; + private _css: ReadiumCSS; + private _preferencesEditor: EpubPreferencesEditor | null = null; + + private resizeObserver: ResizeObserver; + + constructor(container: HTMLElement, pub: Publication, listeners: EpubNavigatorListeners, positions: Locator[] = [], initialPosition: Locator | undefined = undefined, configuration: EpubNavigatorConfiguration = { preferences: {}, defaults: {} }) { super(); this.pub = pub; this.layout = EpubNavigator.determineLayout(pub); @@ -56,6 +76,32 @@ export class EpubNavigator extends VisualNavigator { this.currentLocation = initialPosition!; if (positions.length) this.positions = positions; + + this._preferences = new EpubPreferences(configuration.preferences); + this._defaults = new EpubDefaults(configuration.defaults); + this._settings = new EpubSettings(this._preferences, this._defaults); + this._css = new ReadiumCSS({ + rsProperties: new RSProperties({}), + userProperties: new UserProperties({}), + lineLengths: new LineLengths({ + optimalChars: this._settings.optimalLineLength, + minChars: this._settings.minimalLineLength, + maxChars: this._settings.maximalLineLength, + pageGutter: this._settings.pageGutter, + fontFace: this._settings.fontFamily, + letterSpacing: this._settings.letterSpacing, + wordSpacing: this._settings.wordSpacing, + // sample: this.pub.metadata.description + }), + container: container, + constraint: this._settings.constraint + }); + + // We use a resizeObserver cos’ the container parent may not be the width of + // the document/window e.g. app using a docking system with left and right panels. + // If we observe this.container, that won’t obviously work since we set its width. + this.resizeObserver = new ResizeObserver(() => this.ownerWindow.requestAnimationFrame(() => this.resizeHandler())); + this.resizeObserver.observe(this.container.parentElement || document.documentElement); } public static determineLayout(pub: Publication): EPUBLayout { @@ -79,13 +125,139 @@ export class EpubNavigator extends VisualNavigator { this.framePool.listener = (key: CommsEventKey | ManagerEventKey, data: unknown) => { this.eventListener(key, data); } - } else - this.framePool = new FramePoolManager(this.container, this.positions); + } else { + await this.updateCSS(false); + const cssProperties = this.compileCSSProperties(this._css); + this.framePool = new FramePoolManager(this.container, this.positions, cssProperties); + } + if(this.currentLocation === undefined) this.currentLocation = this.positions[0]; + + this.resizeHandler(); await this.apply(); } + public get settings(): Readonly { + if (this.layout === EPUBLayout.fixed) { + return Object.freeze({ ...this._settings }); + } else { + // Given all the nasty issues moving auto-pagination to EpubSettings creates + // Especially as it’s tied to ReadiumCSS in the first place and could be + // problematic if you intend to use something else, + // we return the properties with columnCount overridden + const columnCount = this._css.userProperties.colCount || this._css.rsProperties.colCount || this._settings.columnCount; + return Object.freeze({ ...this._settings, columnCount: columnCount }); + } + } + + public get preferencesEditor() { + if (this._preferencesEditor === null) { + // Note: we pass this.settings instead of this._settings to ensure the columnCount is correct + this._preferencesEditor = new EpubPreferencesEditor(this._preferences, this.settings, this.pub.metadata); + } + return this._preferencesEditor; + } + + public async submitPreferences(preferences: EpubPreferences) { + this._preferences = this._preferences.merging(preferences) as EpubPreferences; + await this.applyPreferences(); + } + + private async applyPreferences() { + const oldSettings = this._settings; + this._settings = new EpubSettings(this._preferences, this._defaults); + + if (this._preferencesEditor !== null) { + // Note: we pass this.settings instead of this._settings to ensure the columnCount is correct + this._preferencesEditor = new EpubPreferencesEditor(this._preferences, this.settings, this.pub.metadata); + } + + if (this.layout === EPUBLayout.fixed) { + this.handleFXLPrefs(oldSettings, this._settings); + } else { + await this.updateCSS(true); + } + } + + // TODO: fit, etc. + private handleFXLPrefs(from: EpubSettings, to: EpubSettings) { + if (from.columnCount !== to.columnCount) { + (this.framePool as FXLFramePoolManager).setPerPage(to.columnCount); + } + } + + private async updateCSS(commit: boolean) { + this._css.update(this._settings); + + if ( + this._css.userProperties.view === "paged" && + this.readingProgression === ReadingProgression.ttb + ) { + await this.setReadingProgression(this.pub.metadata.effectiveReadingProgression); + } else if ( + this._css.userProperties.view === "scroll" && + (this.readingProgression === ReadingProgression.ltr || this.readingProgression === ReadingProgression.rtl) + ) { + await this.setReadingProgression(ReadingProgression.ttb); + } + + this._css.setContainerWidth(); + + if (commit) this.commitCSS(this._css); + }; + + private compileCSSProperties(css: ReadiumCSS) { + const properties: { [key: string]: string } = {}; + + for (const [key, value] of Object.entries(css.rsProperties.toCSSProperties())) { + properties[key] = value; + } + + for (const [key, value] of Object.entries(css.userProperties.toCSSProperties())) { + properties[key] = value; + } + + return properties; + } + + private commitCSS(css: ReadiumCSS) { + // Since we’re updating the CSS properties in injectables by removing + // the existing properties that are not inside this object first, + // then adding all from it, we don’t compare the previous properties here + const properties = this.compileCSSProperties(css); + + (this.framePool as FramePoolManager).setCSSProperties(properties); + } + + resizeHandler() { + // We check the parentElement cos we want to remove constraint from the container + // and the container may not be the entire width of the document/window + const parentEl = this.container.parentElement || document.documentElement; + + if (this.layout === EPUBLayout.fixed) { + this.container.style.width = `${ getContentWidth(parentEl) - this._settings.constraint }px`; + (this.framePool as FXLFramePoolManager).resizeHandler(); + } else { + // for reflow ReadiumCSS gets the width from columns + line-lengths + // but we need to check whether colCount has changed to commit new CSS + const oldColCount = this._css.userProperties.colCount; + const oldLineLength = this._css.userProperties.lineLength; + this._css.resizeHandler(); + if ( + this._css.userProperties.view !== "scroll" && + oldColCount !== this._css.userProperties.colCount || + oldLineLength !== this._css.userProperties.lineLength + ) { + this.commitCSS(this._css); + } + } + } + + get ownerWindow() { + return this.container.ownerDocument.defaultView || window; + } + /** * Exposed to the public to compensate for lack of implemented readium conveniences * TODO remove when settings management is incorporated @@ -264,6 +436,10 @@ export class EpubNavigator extends VisualNavigator { const idx = this.pub.readingOrder.findIndexWithHref(this.currentLocation.href); if (idx < 0) throw Error("Link for " + this.currentLocation.href + " not found!"); + + if (this.layout === EPUBLayout.reflowable) { + this.updateCSS(true); + } } public async destroy() { @@ -442,7 +618,7 @@ export class EpubNavigator extends VisualNavigator { // TODO: This is temporary until user settings are implemented. public async setReadingProgression(newProgression: ReadingProgression) { - if(this.currentProgression === newProgression) return; + if(this.currentProgression === newProgression || !this.framePool) return; this.currentProgression = newProgression; await this.framePool.update(this.pub, this.currentLocator, this.determineModules(), true); this.attachListener(); diff --git a/navigator/src/epub/css/Properties.ts b/navigator/src/epub/css/Properties.ts new file mode 100644 index 00000000..7e650dc8 --- /dev/null +++ b/navigator/src/epub/css/Properties.ts @@ -0,0 +1,368 @@ +import { TextAlignment, Theme } from "../../preferences/Types"; + +export type BodyHyphens = "auto" | "none"; +export type BoxSizing = "content-box" | "border-box"; +export type FontOpticalSizing = "auto" | "none"; +export type FontWidth = "ultra-condensed" | "extra-condensed" | "condensed" | "semi-condensed" | "normal" | "semi-expanded" | "expanded" | "extra-expanded" | "ultra-expanded" | number; +export type Ligatures = "common-ligatures" | "none"; +export type TypeScale = 1 | 1.067 | 1.125 | 1.2 | 1.25 | 1.333 | 1.414 | 1.5 | 1.618; +export type View = "paged" | "scroll"; + +abstract class Properties { + constructor() {} + + protected toFlag(name: string) { + return `readium-${ name }-on`; + } + + protected toUnitless(value: number) { + return value.toString(); + } + + protected toPercentage(value: number, ratio: boolean = false) { + if (ratio || value > 0 && value <= 1) { + return `${ Math.round(value * 100) }%`; + } else { + return `${ value }%`; + } + } + + protected toVw(value: number) { + const percentage = Math.round(value * 100); + return `${ Math.min(percentage, 100) }vw`; + } + + protected toVh(value: number) { + const percentage = Math.round(value * 100); + return `${ Math.min(percentage, 100) }vh`; + } + + protected toPx(value: number) { + return `${ value }px`; + } + + protected toRem(value: number) { + return `${ value }rem`; + } + + abstract toCSSProperties(): { [key: string]: string }; +} + +export interface IUserProperties { + advancedSettings?: boolean | null; + a11yNormalize?: boolean | null; + appearance?: Theme | null; + backgroundColor?: string | null; + blendFilter?: boolean | null; + bodyHyphens?: BodyHyphens | null; + colCount?: number | null; + darkenFilter?: boolean | number | null; + fontFamily?: string | null; + fontOpticalSizing?: FontOpticalSizing | null; + fontOverride?: boolean | null; + fontSize?: number | null; + fontWeight?: number | null; + fontWidth?: FontWidth | null; + invertFilter?: boolean | number | null; + invertGaijiFilter?: boolean | number | null; + letterSpacing?: number | null; + ligatures?: Ligatures | null; + lineHeight?: number | null; + lineLength?: number | null; + linkColor?: string | null; + noRuby?: boolean | null; + paraIndent?: number | null; + paraSpacing?: number | null; + publisherStyles?: boolean | null; + selectionBackgroundColor?: string | null; + selectionTextColor?: string | null; + textAlign?: TextAlignment | null; + textColor?: string | null; + view?: View | null; + visitedColor?: string | null; + wordSpacing?: number | null; +} + +export class UserProperties extends Properties { + advancedSettings: boolean | null; + a11yNormalize: boolean | null; + appearance: Theme | null; + backgroundColor: string | null; + blendFilter: boolean | null; + bodyHyphens: BodyHyphens | null; + colCount: number | null | undefined; + darkenFilter: boolean | number | null; + fontFamily: string | null; + fontOpticalSizing: FontOpticalSizing | null; + fontOverride: boolean | null; + fontSize: number | null; + fontWeight: number | null; + fontWidth: FontWidth | null; + invertFilter: boolean | number | null; + invertGaijiFilter: boolean | number | null; + letterSpacing: number | null; + ligatures: Ligatures | null; + lineHeight: number | null; + lineLength: number | null; + linkColor: string | null; + noRuby: boolean | null; + paraIndent: number | null; + paraSpacing: number | null; + selectionBackgroundColor?: string | null; + selectionTextColor?: string | null; + textAlign: TextAlignment | null; + textColor: string | null; + view: View | null; + visitedColor: string | null; + wordSpacing: number | null; + + constructor(props: IUserProperties) { + super(); + this.advancedSettings = props.advancedSettings ?? null; + this.a11yNormalize = props.a11yNormalize ?? null; + this.appearance = props.appearance ?? null; + this.backgroundColor = props.backgroundColor ?? null; + this.blendFilter = props.blendFilter ?? null; + this.bodyHyphens = props.bodyHyphens ?? null; + this.colCount = props.colCount ?? null; + this.darkenFilter = props.darkenFilter ?? null; + this.fontFamily = props.fontFamily ?? null; + this.fontOpticalSizing = props.fontOpticalSizing ?? null; + this.fontOverride = props.fontOverride ?? null; + this.fontSize = props.fontSize ?? null; + this.fontWeight = props.fontWeight ?? null; + this.fontWidth = props.fontWidth ?? null; + this.invertFilter = props.invertFilter ?? null; + this.invertGaijiFilter = props.invertGaijiFilter ?? null; + this.letterSpacing = props.letterSpacing ?? null; + this.ligatures = props.ligatures ?? null; + this.lineHeight = props.lineHeight ?? null; + this.lineLength = props.lineLength ?? null; + this.linkColor = props.linkColor ?? null; + this.noRuby = props.noRuby ?? null; + this.paraIndent = props.paraIndent ?? null; + this.paraSpacing = props.paraSpacing ?? null; + this.selectionBackgroundColor = props.selectionBackgroundColor ?? null; + this.selectionTextColor = props.selectionTextColor ?? null; + this.textAlign = props.textAlign ?? null; + this.textColor = props.textColor ?? null; + this.view = props.view ?? null; + this.visitedColor = props.visitedColor ?? null; + this.wordSpacing = props.wordSpacing ?? null; + } + + toCSSProperties() { + const cssProperties: { [key: string]: string } = {}; + + if (this.advancedSettings) cssProperties["--USER__advancedSettings"] = this.toFlag("advanced"); + if (this.a11yNormalize) cssProperties["--USER__a11yNormalize"] = this.toFlag("a11y"); + if (this.appearance) cssProperties["--USER__appearance"] = this.toFlag(this.appearance); + if (this.backgroundColor) cssProperties["--USER__backgroundColor"] = this.backgroundColor; + if (this.blendFilter) cssProperties["--USER__blendFilter"] = this.toFlag("blend"); + if (this.bodyHyphens) cssProperties["--USER__bodyHyphens"] = this.bodyHyphens; + if (this.colCount) cssProperties["--USER__colCount"] = this.toUnitless(this.colCount); + if (this.darkenFilter != null) { + cssProperties["--USER__darkenFilter"] = typeof this.darkenFilter === "number" + ? this.toPercentage(this.darkenFilter) + : this.toFlag("darken"); + } + if (this.fontFamily) cssProperties["--USER__fontFamily"] = this.fontFamily; + if (this.fontOpticalSizing != null) cssProperties["--USER__fontOpticalSizing"] = this.fontOpticalSizing; + if (this.fontOverride) cssProperties["--USER__fontOverride"] = this.toFlag("font"); + if (this.fontSize != null) cssProperties["--USER__fontSize"] = this.toPercentage(this.fontSize, true); + if (this.fontWeight != null) cssProperties["--USER__fontWeight"] = this.toUnitless(this.fontWeight); + if (this.fontWidth != null) { + cssProperties["--USER__fontWidth"] = typeof this.fontWidth === "string" + ? this.fontWidth + : this.toUnitless(this.fontWidth); + } + if (this.invertFilter != null) { + cssProperties["--USER__invertFilter"] = typeof this.invertFilter === "number" + ? this.toPercentage(this.invertFilter) + : this.toFlag("invert"); + } + if (this.invertGaijiFilter != null) { + cssProperties["--USER__invertGaiji"] = typeof this.invertGaijiFilter === "number" + ? this.toPercentage(this.invertGaijiFilter) + : this.toFlag("invertGaiji"); + } + if (this.letterSpacing != null) cssProperties["--USER__letterSpacing"] = this.toRem(this.letterSpacing); + if (this.ligatures) cssProperties["--USER__ligatures"] = this.ligatures; + if (this.lineHeight != null) cssProperties["--USER__lineHeight"] = this.toUnitless(this.lineHeight); + if (this.lineLength != null) cssProperties["--USER__lineLength"] = this.toPx(this.lineLength); + if (this.linkColor) cssProperties["--USER__linkColor"] = this.linkColor; + if (this.noRuby) cssProperties["--USER__noRuby"] = this.toFlag("noRuby"); + if (this.paraIndent != null) cssProperties["--USER__paraIndent"] = this.toRem(this.paraIndent); + if (this.paraSpacing != null) cssProperties["--USER__paraSpacing"] = this.toRem(this.paraSpacing); + if (this.selectionBackgroundColor) cssProperties["--USER__selectionBackgroundColor"] = this.selectionBackgroundColor; + if (this.selectionTextColor) cssProperties["--USER__selectionTextColor"] = this.selectionTextColor; + if (this.textAlign) cssProperties["--USER__textAlign"] = this.textAlign; + if (this.textColor) cssProperties["--USER__textColor"] = this.textColor; + if (this.view) cssProperties["--USER__view"] = this.toFlag(this.view); + if (this.visitedColor) cssProperties["--USER__visitedColor"] = this.visitedColor; + if (this.wordSpacing != null) cssProperties["--USER__wordSpacing"] = this.toRem(this.wordSpacing); + + return cssProperties; + } +} + +export interface IRSProperties { + backgroundColor?: string | null; + baseFontFamily?: string | null; + baseFontSize?: number | null; + baseLineHeight?: number | null; + boxSizingMedia?: BoxSizing | null; + boxSizingTable?: BoxSizing | null; + colWidth?: string | null; + colCount?: number | null; + colGap?: number | null; + codeFontFamily?: string | null; + compFontFamily?: string | null; + defaultLineLength?: number | null; + flowSpacing?: number | null; + humanistTf?: string | null; + linkColor?: string | null; + maxMediaWidth?: number | null; + maxMediaHeight?: number | null; + modernTf?: string | null; + monospaceTf?: string | null; + noVerticalPagination?: boolean | null; + oldStyleTf?: string | null; + pageGutter?: number | null; + paraIndent?: number | null; + paraSpacing?: number | null; + primaryColor?: string | null; + sansSerifJa?: string | null; + sansSerifJaV?: string | null; + sansTf?: string | null; + secondaryColor?: string | null; + selectionBackgroundColor?: string | null; + selectionTextColor?: string | null; + serifJa?: string | null; + serifJaV?: string | null; + textColor?: string | null; + typeScale?: TypeScale | null; + visitedColor?: string | null; +} + +export class RSProperties extends Properties { + backgroundColor: string | null; + baseFontFamily: string | null; + baseFontSize: number | null; + baseLineHeight: number | null; + boxSizingMedia: BoxSizing | null; + boxSizingTable: BoxSizing | null; + colWidth: string | null; + colCount: number | null; + colGap: number | null; + codeFontFamily: string | null; + compFontFamily: string | null; + defaultLineLength: number | null; + flowSpacing: number | null; + humanistTf: string | null; + linkColor: string | null; + maxMediaWidth: number | null; + maxMediaHeight: number | null; + modernTf: string | null; + monospaceTf: string | null; + noVerticalPagination: boolean | null; + oldStyleTf: string | null; + pageGutter: number | null; + paraIndent: number | null; + paraSpacing: number | null; + primaryColor: string | null; + sansSerifJa: string | null; + sansSerifJaV: string | null; + sansTf: string | null; + secondaryColor: string | null; + selectionBackgroundColor: string | null; + selectionTextColor: string | null; + serifJa: string | null; + serifJaV: string | null; + textColor: string | null; + typeScale: TypeScale | null; + visitedColor: string | null; + + constructor(props: IRSProperties) { + super(); + this.backgroundColor = props.backgroundColor ?? null; + this.baseFontFamily = props.baseFontFamily ?? null; + this.baseFontSize = props.baseFontSize ?? null; + this.baseLineHeight = props.baseLineHeight ?? null; + this.boxSizingMedia = props.boxSizingMedia ?? null; + this.boxSizingTable = props.boxSizingTable ?? null; + this.colWidth = props.colWidth ?? null; + this.colCount = props.colCount ?? null; + this.colGap = props.colGap ?? null; + this.codeFontFamily = props.codeFontFamily ?? null; + this.compFontFamily = props.compFontFamily ?? null; + this.defaultLineLength = props.defaultLineLength ?? null; + this.flowSpacing = props.flowSpacing ?? null; + this.humanistTf = props.humanistTf ?? null; + this.linkColor = props.linkColor ?? null; + this.maxMediaWidth = props.maxMediaWidth ?? null; + this.maxMediaHeight = props.maxMediaHeight ?? null; + this.modernTf = props.modernTf ?? null; + this.monospaceTf = props.monospaceTf ?? null; + this.noVerticalPagination = props.noVerticalPagination ?? null; + this.oldStyleTf = props.oldStyleTf ?? null; + this.pageGutter = props.pageGutter ?? null; + this.paraIndent = props.paraIndent ?? null; + this.paraSpacing = props.paraSpacing ?? null; + this.primaryColor = props.primaryColor ?? null; + this.sansSerifJa = props.sansSerifJa ?? null; + this.sansSerifJaV = props.sansSerifJaV ?? null; + this.sansTf = props.sansTf ?? null; + this.secondaryColor = props.secondaryColor ?? null; + this.selectionBackgroundColor = props.selectionBackgroundColor ?? null; + this.selectionTextColor = props.selectionTextColor ?? null; + this.serifJa = props.serifJa ?? null; + this.serifJaV = props.serifJaV ?? null; + this.textColor = props.textColor ?? null; + this.typeScale = props.typeScale ?? null; + this.visitedColor = props.visitedColor ?? null; + } + + toCSSProperties(): { [key: string]: string; } { + const cssProperties: { [key: string]: string } = {}; + + if (this.backgroundColor) cssProperties["--RS__backgroundColor"] = this.backgroundColor; + if (this.baseFontFamily) cssProperties["--RS__baseFontFamily"] = this.baseFontFamily; + if (this.baseFontSize != null) cssProperties["--RS__baseFontSize"] = this.toRem(this.baseFontSize); + if (this.baseLineHeight != null) cssProperties["--RS__baseLineHeight"] = this.toUnitless(this.baseLineHeight); + if (this.boxSizingMedia) cssProperties["--RS__boxSizingMedia"] = this.boxSizingMedia; + if (this.boxSizingTable) cssProperties["--RS__boxSizingTable"] = this.boxSizingTable; + if (this.colWidth != null) cssProperties["--RS__colWidth"] = this.colWidth; + if (this.colCount != null) cssProperties["--RS__colCount"] = this.toUnitless(this.colCount); + if (this.colGap != null) cssProperties["--RS__colGap"] = this.toPx(this.colGap); + if (this.codeFontFamily) cssProperties["--RS__codeFontFamily"] = this.codeFontFamily; + if (this.compFontFamily) cssProperties["--RS__compFontFamily"] = this.compFontFamily; + if (this.defaultLineLength != null) cssProperties["--RS__defaultLineLength"] = this.toPx(this.defaultLineLength); + if (this.flowSpacing != null) cssProperties["--RS__flowSpacing"] = this.toRem(this.flowSpacing); + if (this.humanistTf) cssProperties["--RS__humanistTf"] = this.humanistTf; + if (this.linkColor) cssProperties["--RS__linkColor"] = this.linkColor; + if (this.maxMediaWidth) cssProperties["--RS__maxMediaWidth"] = this.toVw(this.maxMediaWidth); + if (this.maxMediaHeight) cssProperties["--RS__maxMediaHeight"] = this.toVh(this.maxMediaHeight); + if (this.modernTf) cssProperties["--RS__modernTf"] = this.modernTf; + if (this.monospaceTf) cssProperties["--RS__monospaceTf"] = this.monospaceTf; + if (this.noVerticalPagination) cssProperties["--RS__disablePagination"] = this.toFlag("noVerticalPagination"); + if (this.oldStyleTf) cssProperties["--RS__oldStyleTf"] = this.oldStyleTf; + if (this.pageGutter != null) cssProperties["--RS__pageGutter"] = this.toPx(this.pageGutter); + if (this.paraIndent != null) cssProperties["--RS__paraIndent"] = this.toRem(this.paraIndent); + if (this.paraSpacing != null) cssProperties["--RS__paraSpacing"] = this.toRem(this.paraSpacing); + if (this.primaryColor) cssProperties["--RS__primaryColor"] = this.primaryColor; + if (this.sansSerifJa) cssProperties["--RS__sans-serif-ja"] = this.sansSerifJa; + if (this.sansSerifJaV) cssProperties["--RS__sans-serif-ja-v"] = this.sansSerifJaV; + if (this.sansTf) cssProperties["--RS__sansTf"] = this.sansTf; + if (this.secondaryColor) cssProperties["--RS__secondaryColor"] = this.secondaryColor; + if (this.selectionBackgroundColor) cssProperties["--RS__selectionBackgroundColor"] = this.selectionBackgroundColor; + if (this.selectionTextColor) cssProperties["--RS__selectionTextColor"] = this.selectionTextColor; + if (this.serifJa) cssProperties["--RS__serif-ja"] = this.serifJa; + if (this.serifJaV) cssProperties["--RS__serif-ja-v"] = this.serifJaV; + if (this.textColor) cssProperties["--RS__textColor"] = this.textColor; + if (this.typeScale) cssProperties["--RS__typeScale"] = this.toUnitless(this.typeScale); + if (this.visitedColor) cssProperties["--RS__visitedColor"] = this.visitedColor; + + return cssProperties; + } +} \ No newline at end of file diff --git a/navigator/src/epub/css/ReadiumCSS.ts b/navigator/src/epub/css/ReadiumCSS.ts new file mode 100644 index 00000000..581f56d6 --- /dev/null +++ b/navigator/src/epub/css/ReadiumCSS.ts @@ -0,0 +1,334 @@ +import { ILineLengthsConfig, LineLengths } from "../../helpers"; +import { getContentWidth } from "../../helpers/dimensions"; +import { LayoutStrategy } from "../../preferences"; +import { EpubSettings } from "../preferences/EpubSettings"; +import { IUserProperties, RSProperties, UserProperties } from "./Properties"; + +type ILineLengthsProps = { + [K in Exclude]?: ILineLengthsConfig[K] +}; + +export interface IReadiumCSS { + rsProperties: RSProperties; + userProperties: UserProperties; + lineLengths: LineLengths; + container: HTMLElement; + constraint: number; + layoutStrategy?: LayoutStrategy | null; +} + +export class ReadiumCSS { + rsProperties: RSProperties; + userProperties: UserProperties; + lineLengths: LineLengths; + container: HTMLElement; + containerParent: HTMLElement; + constraint: number; + layoutStrategy: LayoutStrategy; + private cachedColCount: number | null | undefined; + private effectiveContainerWidth: number; + + constructor(props: IReadiumCSS) { + this.rsProperties = props.rsProperties; + this.userProperties = props.userProperties; + this.lineLengths = props.lineLengths; + this.container = props.container; + this.containerParent = props.container.parentElement || document.documentElement; + this.constraint = props.constraint; + this.layoutStrategy = props.layoutStrategy || LayoutStrategy.lineLength; + this.cachedColCount = props.userProperties.colCount; + this.effectiveContainerWidth = getContentWidth(this.containerParent); + } + + update(settings: EpubSettings) { + // We need to keep the column count reference for resizeHandler + this.cachedColCount = settings.columnCount; + + if (settings.constraint !== this.constraint) + this.constraint = settings.constraint; + + if (settings.layoutStrategy && settings.layoutStrategy !== this.layoutStrategy) + this.layoutStrategy = settings.layoutStrategy; + + if (settings.pageGutter !== this.rsProperties.pageGutter) + this.rsProperties.pageGutter = settings.pageGutter; + + // This has to be updated before pagination + // otherwise the metrics won’t be correct for line length + this.updateLineLengths({ + fontFace: settings.fontFamily, + letterSpacing: settings.letterSpacing, + pageGutter: settings.pageGutter, + wordSpacing: settings.wordSpacing, + minChars: settings.minimalLineLength, + maxChars: settings.maximalLineLength, + optimalChars: settings.optimalLineLength, + userChars: settings.lineLength + }); + + const layout = this.updateLayout(settings.fontSize, settings.scroll, settings.columnCount); + + if (layout?.effectiveContainerWidth) + this.effectiveContainerWidth = layout?.effectiveContainerWidth; + + const updated: IUserProperties = { + advancedSettings: !settings.publisherStyles, + a11yNormalize: settings.textNormalization, + appearance: settings.theme, + backgroundColor: settings.backgroundColor, + blendFilter: settings.blendFilter, + bodyHyphens: typeof settings.hyphens !== "boolean" + ? null + : settings.hyphens + ? "auto" + : "none", + colCount: layout?.colCount, + darkenFilter: settings.darkenFilter, + fontFamily: settings.fontFamily, + fontOpticalSizing: typeof settings.fontOpticalSizing !== "boolean" + ? null + : settings.fontOpticalSizing + ? "auto" + : "none", + fontOverride: settings.textNormalization || settings.fontFamily ? true : false, + fontSize: settings.fontSize, + fontWeight: settings.fontWeight, + fontWidth: settings.fontWidth, + invertFilter: settings.invertFilter, + letterSpacing: settings.letterSpacing, + ligatures: typeof settings.ligatures !== "boolean" + ? null + : settings.ligatures + ? "common-ligatures" + : "none", + lineHeight: settings.lineHeight, + lineLength: layout?.effectiveLineLength, + noRuby: settings.noRuby, + paraIndent: settings.paragraphIndent, + paraSpacing: settings.paragraphSpacing, + textAlign: settings.textAlign, + textColor: settings.textColor, + view: typeof settings.scroll !== "boolean" + ? null + : settings.scroll + ? "scroll" + : "paged", + wordSpacing: settings.wordSpacing + }; + + this.userProperties = new UserProperties(updated); + } + + private updateLineLengths(props: ILineLengthsProps) { + if (props.fontFace !== undefined) this.lineLengths.fontFace = props.fontFace; + if (props.letterSpacing !== undefined) this.lineLengths.letterSpacing = props.letterSpacing || 0; + if (props.pageGutter !== undefined) this.lineLengths.pageGutter = props.pageGutter || 0; + if (props.wordSpacing !== undefined) this.lineLengths.wordSpacing = props.wordSpacing || 0; + if (props.minChars !== undefined) this.lineLengths.minChars = props.minChars; + if (props.maxChars !== undefined) this.lineLengths.maxChars = props.maxChars; + if (props.optimalChars) this.lineLengths.optimalChars = props.optimalChars; + if (props.userChars !== undefined) this.lineLengths.userChars = props.userChars; + } + + private updateLayout(scale: number | null, scroll: boolean | null, colCount?: number | null) { + const isScroll = scroll ?? this.userProperties.view === "scroll"; + + if (isScroll) { + return this.computeScrollLength(scale); + } else { + return this.paginate(scale, colCount); + } + } + + private getCompensatedMetrics(scale: number | null) { + const zoomFactor = scale || this.userProperties.fontSize || 1; + const zoomCompensation = zoomFactor < 1 + ? this.layoutStrategy === LayoutStrategy.margin + ? 1 / (zoomFactor + 0.003) + : 1 / zoomFactor + : 1; + + return { + zoomFactor: zoomFactor, + zoomCompensation: zoomCompensation, + optimal: Math.round(this.lineLengths.userLineLength || this.lineLengths.optimalLineLength) * zoomFactor, + minimal: this.lineLengths.minimalLineLength !== null + ? Math.round(this.lineLengths.minimalLineLength * zoomFactor) + : null, + maximal: this.lineLengths.maximalLineLength !== null + ? Math.round(this.lineLengths.maximalLineLength * zoomFactor) + : null + } + } + + // Note: Kept intentionally verbose for debugging + // TODO: As scroll shows, the effective line-length + // should be the same as uncompensated when scale >= 1 + private paginate(scale: number | null, colCount?: number | null) { + const constrainedWidth = Math.round(getContentWidth(this.containerParent) - (this.constraint)); + const metrics = this.getCompensatedMetrics(scale); + const zoomCompensation = metrics.zoomCompensation; + const optimal = metrics.optimal; + const minimal = metrics.minimal; + const maximal = metrics.maximal; + + let RCSSColCount = 1; + let effectiveContainerWidth = constrainedWidth; + + if (colCount === undefined) { + return { + colCount: undefined, + effectiveContainerWidth: effectiveContainerWidth, + effectiveLineLength: Math.round((effectiveContainerWidth / RCSSColCount) * zoomCompensation) + }; + } + + if (colCount === null) { + if (this.layoutStrategy === LayoutStrategy.margin) { + if (constrainedWidth >= optimal) { + RCSSColCount = Math.floor(constrainedWidth / optimal); + const requiredWidth = Math.round(RCSSColCount * (optimal * zoomCompensation)); + effectiveContainerWidth = Math.min(requiredWidth, constrainedWidth); + } else { + RCSSColCount = 1; + effectiveContainerWidth = constrainedWidth; + } + } else if (this.layoutStrategy === LayoutStrategy.lineLength) { + if (constrainedWidth < optimal || maximal === null) { + RCSSColCount = 1; + effectiveContainerWidth = constrainedWidth; + } else { + RCSSColCount = Math.floor(constrainedWidth / optimal); + const requiredWidth = Math.round(RCSSColCount * (maximal * zoomCompensation)); + effectiveContainerWidth = Math.min(requiredWidth, constrainedWidth); + } + } else if (this.layoutStrategy === LayoutStrategy.columns) { + if (constrainedWidth >= optimal) { + if (maximal === null) { + RCSSColCount = Math.floor(constrainedWidth / optimal); + effectiveContainerWidth = constrainedWidth; + } else { + RCSSColCount = Math.floor(constrainedWidth / (minimal || optimal)); + const requiredWidth = Math.round((RCSSColCount * (optimal * zoomCompensation))); + effectiveContainerWidth = Math.min(requiredWidth, constrainedWidth); + } + } else { + RCSSColCount = 1; + effectiveContainerWidth = constrainedWidth; + } + } + } else if (colCount > 1) { + const minRequiredWidth = Math.round(colCount * (minimal !== null ? minimal : optimal)); + + if (constrainedWidth >= minRequiredWidth) { + RCSSColCount = colCount; + if (this.layoutStrategy === LayoutStrategy.margin) { + const requiredWidth = Math.round(RCSSColCount * (optimal * zoomCompensation)); + effectiveContainerWidth = Math.min(requiredWidth, constrainedWidth); + } else if ( + this.layoutStrategy === LayoutStrategy.lineLength || + this.layoutStrategy === LayoutStrategy.columns + ) { + if (maximal === null) { + effectiveContainerWidth = constrainedWidth + } else { + const requiredWidth = Math.round(RCSSColCount * (maximal * zoomCompensation)); + effectiveContainerWidth = Math.min(requiredWidth, constrainedWidth); + } + + if (this.layoutStrategy === LayoutStrategy.columns) { + console.error("Columns strategy is not compatible with a column count whose value is a number. Falling back to lineLength strategy."); + } + } + } else { + if (minimal !== null && constrainedWidth < Math.round(colCount * minimal)) { + RCSSColCount = Math.floor(constrainedWidth / minimal); + } else { + RCSSColCount = colCount; + } + const requiredWidth = Math.round((RCSSColCount * (optimal * zoomCompensation))); + effectiveContainerWidth = Math.min(requiredWidth, constrainedWidth); + } + } else { + RCSSColCount = 1; + + if (constrainedWidth >= optimal) { + if (this.layoutStrategy === LayoutStrategy.margin) { + const requiredWidth = Math.round(optimal * zoomCompensation); + effectiveContainerWidth = Math.min(requiredWidth, constrainedWidth); + } else if ( + this.layoutStrategy === LayoutStrategy.lineLength || + this.layoutStrategy === LayoutStrategy.columns + ) { + if (maximal === null) { + effectiveContainerWidth = constrainedWidth + } else { + const requiredWidth = Math.round(maximal * zoomCompensation); + effectiveContainerWidth = Math.min(requiredWidth, constrainedWidth); + } + + if (this.layoutStrategy === LayoutStrategy.columns) { + console.error("Columns strategy is not compatible with a column count whose value is a number. Falling back to lineLength strategy."); + } + } + } else { + effectiveContainerWidth = constrainedWidth + } + } + + return { + colCount: RCSSColCount, + effectiveContainerWidth: effectiveContainerWidth, + effectiveLineLength: Math.round(((effectiveContainerWidth / RCSSColCount) / (scale && scale >= 1 ? scale : 1)) * zoomCompensation) + }; + } + + // This behaves as paginate where colCount = 1 + private computeScrollLength(scale: number | null) { + const constrainedWidth = Math.round(getContentWidth(this.containerParent) - (this.constraint)); + const metrics = this.getCompensatedMetrics(scale && scale < 1 ? scale : 1); + const zoomCompensation = metrics.zoomCompensation; + const optimal = metrics.optimal; + const maximal = metrics.maximal; + + let RCSSColCount = undefined; + let effectiveContainerWidth = constrainedWidth; + let effectiveLineLength = Math.round(optimal * zoomCompensation); + + if (this.layoutStrategy === LayoutStrategy.margin) { + const computedWidth = Math.min(Math.round(optimal * zoomCompensation), constrainedWidth); + effectiveLineLength = Math.round(computedWidth * zoomCompensation); + } else if ( + this.layoutStrategy === LayoutStrategy.lineLength || + this.layoutStrategy === LayoutStrategy.columns + ) { + if (this.layoutStrategy === LayoutStrategy.columns) { + console.error("Columns strategy is not compatible with scroll. Falling back to lineLength strategy."); + } + if (maximal === null) { + effectiveLineLength = constrainedWidth; + } else { + const computedWidth = Math.min(Math.round(maximal * zoomCompensation), constrainedWidth); + effectiveLineLength = Math.round(computedWidth * zoomCompensation); + } + } + + return { + colCount: RCSSColCount, + effectiveContainerWidth: effectiveContainerWidth, + effectiveLineLength: effectiveLineLength + } + } + + setContainerWidth() { + this.container.style.width = `${ this.effectiveContainerWidth }px`; + } + + resizeHandler() { + const pagination = this.updateLayout(this.userProperties.fontSize, this.userProperties.view === "scroll", this.cachedColCount); + this.userProperties.colCount = pagination.colCount; + this.userProperties.lineLength = pagination.effectiveLineLength; + this.effectiveContainerWidth = pagination.effectiveContainerWidth; + this.container.style.width = `${ this.effectiveContainerWidth }px`; + } +} \ No newline at end of file diff --git a/navigator/src/epub/css/index.ts b/navigator/src/epub/css/index.ts new file mode 100644 index 00000000..63db5162 --- /dev/null +++ b/navigator/src/epub/css/index.ts @@ -0,0 +1,2 @@ +export * from "./Properties"; +export * from "./ReadiumCSS"; \ No newline at end of file diff --git a/navigator/src/epub/frame/FrameBlobBuilder.ts b/navigator/src/epub/frame/FrameBlobBuilder.ts index af3c539b..f7c7e8e1 100644 --- a/navigator/src/epub/frame/FrameBlobBuilder.ts +++ b/navigator/src/epub/frame/FrameBlobBuilder.ts @@ -82,11 +82,13 @@ export default class FrameBlobBuider { private readonly item: Link; private readonly burl: string; private readonly pub: Publication; + private readonly cssProperties?: { [key: string]: string }; - constructor(pub: Publication, baseURL: string, item: Link) { + constructor(pub: Publication, baseURL: string, item: Link, cssProperties?: { [key: string]: string }) { this.pub = pub; this.item = item; this.burl = item.toURL(baseURL) || ""; + this.cssProperties = cssProperties; } public async build(fxl = false): Promise { @@ -113,7 +115,7 @@ export default class FrameBlobBuider { const details = perror.querySelector("div"); throw new Error(`Failed parsing item ${this.item.href}: ${details?.textContent || perror.textContent}`); } - return this.finalizeDOM(doc, this.burl, this.item.mediaType, fxl); + return this.finalizeDOM(doc, this.burl, this.item.mediaType, fxl, this.cssProperties); } private buildImageFrame(): string { @@ -150,7 +152,14 @@ export default class FrameBlobBuider { return false; } - private finalizeDOM(doc: Document, base: string | undefined, mediaType: MediaType, fxl = false): string { + private setProperties(cssProperties: { [key: string]: string }, doc: Document) { + for (const key in cssProperties) { + const value = cssProperties[key]; + if (value) doc.documentElement.style.setProperty(key, value); + } + } + + private finalizeDOM(doc: Document, base: string | undefined, mediaType: MediaType, fxl = false, cssProperties?: { [key: string]: string }): string { if(!doc) return ""; // Inject styles @@ -171,6 +180,10 @@ export default class FrameBlobBuider { // Readium CSS After doc.head.appendChild(styleify(doc, cached("ReadiumCSS-after", () => blobify(stripCSS(readiumCSSAfter), "text/css")))); + + if (cssProperties) { + this.setProperties(cssProperties, doc); + } } // Set all elements to high priority @@ -183,6 +196,49 @@ export default class FrameBlobBuider { doc.body.querySelectorAll("img").forEach((img) => { img.setAttribute("fetchpriority", "high"); }); + + // We need to ensure that lang is set on the root element + // since it is used for settings such as font-family, hyphens, ligatures, etc. + // but also screen readers, etc. + // Metadata’s effectiveReadingProgression uses first item in array as primary language + // so we keep it consistent. + if (mediaType.isHTML && this.pub.metadata.languages?.[0]) { + const primaryLanguage = this.pub.metadata.languages[0]; + + if (mediaType === MediaType.XHTML) { + // InDesign is infamous for setting xml:lang on the body instead of the root element + // So we have to check whether lang is set on the body and move it to the root element + const rootLang = document.documentElement.lang || document.documentElement.getAttribute("xml:lang"); + const bodyLang = document.body.lang || document.body.getAttribute("xml:lang"); + if (bodyLang && !rootLang) { + document.documentElement.lang = bodyLang; + document.documentElement.setAttribute("xml:lang", bodyLang); + document.body.removeAttribute("xml:lang"); + document.body.removeAttribute("lang"); + } else if (!rootLang) { + document.documentElement.lang = primaryLanguage; + document.documentElement.setAttribute("xml:lang", primaryLanguage); + } + } else if ( + mediaType === MediaType.HTML && + !document.documentElement.lang + ) { + document.documentElement.lang = primaryLanguage; + } + } + + // We need to ensure that dir is set on the root element if rtl + // Since body can bubble up, we also need to check it’s not here. + // https://github.com/readium/readium-css/blob/develop/docs/CSS03-injection_and_pagination.md#be-cautious-the-direction-propagates + + // TODO: ReadiumCSS stylesheets are injected as LTR/default no matter what so disabled ATM + /* if ( + !document.documentElement.dir && + !document.body.dir && + this.pub.metadata.effectiveReadingProgression === ReadingProgression.rtl + ) { + document.documentElement.dir = this.pub.metadata.effectiveReadingProgression; + } */ if(base !== undefined) { // Set all URL bases. Very convenient! diff --git a/navigator/src/epub/frame/FrameManager.ts b/navigator/src/epub/frame/FrameManager.ts index 408cef82..41c96b5e 100644 --- a/navigator/src/epub/frame/FrameManager.ts +++ b/navigator/src/epub/frame/FrameManager.ts @@ -8,6 +8,7 @@ export class FrameManager { private loader: Loader | undefined; public readonly source: string; private comms: FrameComms | undefined; + private hidden: boolean = true; private destroyed: boolean = false; private currModules: ModuleName[] = []; @@ -67,6 +68,7 @@ export class FrameManager { this.frame.style.setProperty("aria-hidden", "true"); this.frame.style.opacity = "0"; this.frame.style.pointerEvents = "none"; + this.hidden = true; if(this.frame.parentElement) { if(this.comms === undefined || !this.comms.ready) return; return new Promise((res, _) => { @@ -92,6 +94,7 @@ export class FrameManager { this.frame.style.removeProperty("aria-hidden"); this.frame.style.removeProperty("opacity"); this.frame.style.removeProperty("pointer-events"); + this.hidden = false; res(); } if(atProgress && atProgress > 0) { @@ -104,6 +107,19 @@ export class FrameManager { }); } + setCSSProperties(properties: { [key: string]: string }) { + if(this.destroyed || !this.frame.contentWindow) return; + + // We need to resume and halt postMessage to update the properties + // if the frame is hidden since it’s been halted in hide() + if (this.hidden) { + if (this.comms) this.comms?.resume(); + else this.comms = new FrameComms(this.frame.contentWindow!, this.source); + } + this.comms?.send("update_properties", properties); + if (this.hidden) this.comms?.halt(); + } + get iframe() { if(this.destroyed) throw Error("Trying to use frame when it doesn't exist"); return this.frame; diff --git a/navigator/src/epub/frame/FramePoolManager.ts b/navigator/src/epub/frame/FramePoolManager.ts index 4352803e..a1db126a 100644 --- a/navigator/src/epub/frame/FramePoolManager.ts +++ b/navigator/src/epub/frame/FramePoolManager.ts @@ -10,14 +10,16 @@ export class FramePoolManager { private readonly container: HTMLElement; private readonly positions: Locator[]; private _currentFrame: FrameManager | undefined; + private currentCssProperties: { [key: string]: string } | undefined; private readonly pool: Map = new Map(); private readonly blobs: Map = new Map(); private readonly inprogress: Map> = new Map(); private currentBaseURL: string | undefined; - constructor(container: HTMLElement, positions: Locator[]) { + constructor(container: HTMLElement, positions: Locator[], cssProperties?: { [key: string]: string }) { this.container = container; this.positions = positions; + this.currentCssProperties = cssProperties; } async destroy() { @@ -104,7 +106,7 @@ export class FramePoolManager { const itm = pub.readingOrder.findWithHref(href); if(!itm) return; // TODO throw? if(!this.blobs.has(href)) { - const blobBuilder = new FrameBlobBuider(pub, this.currentBaseURL || "", itm); + const blobBuilder = new FrameBlobBuider(pub, this.currentBaseURL || "", itm, this.currentCssProperties); const blobURL = await blobBuilder.build(); this.blobs.set(href, blobURL); } @@ -144,6 +146,11 @@ export class FramePoolManager { this.inprogress.delete(newHref); // Delete it from the in progress map! } + setCSSProperties(properties: { [key: string]: string }) { + this.currentCssProperties = properties; + this.pool.forEach((frame) => frame.setCSSProperties(properties)); + } + get currentFrames(): (FrameManager | undefined)[] { return [this._currentFrame]; } diff --git a/navigator/src/epub/fxl/FXLFramePoolManager.ts b/navigator/src/epub/fxl/FXLFramePoolManager.ts index 30dfd880..8841ba3b 100644 --- a/navigator/src/epub/fxl/FXLFramePoolManager.ts +++ b/navigator/src/epub/fxl/FXLFramePoolManager.ts @@ -39,7 +39,6 @@ export class FXLFramePoolManager { private readonly spreadPresentation: Spread; private orientationInternal = -1; // Portrait = 1, Landscape = 0, Unknown = -1 private containerHeightCached: number; - private readonly resizeBoundHandler: EventListenerOrEventListenerObject; private resizeTimeout: number | undefined; // private readonly pages: FXLFrameManager[] = []; public readonly peripherals: FXLPeripherals; @@ -57,10 +56,6 @@ export class FXLFramePoolManager { // NEW this.spreader = new FXLSpreader(this.pub); this.containerHeightCached = container.clientHeight; - this.resizeBoundHandler = this.nativeResizeHandler.bind(this); - - this.ownerWindow.addEventListener("resize", this.resizeBoundHandler); - this.ownerWindow.addEventListener("orientationchange", this.resizeBoundHandler); this.bookElement = document.createElement("div"); this.bookElement.ariaLabel = "Book"; @@ -101,10 +96,6 @@ export class FXLFramePoolManager { return this.peripherals.pan.touchID > 0; } - private nativeResizeHandler(_: Event) { - this.resizeHandler(true); - } - /** * When window resizes, resize slider components as well */ @@ -259,8 +250,8 @@ export class FXLFramePoolManager { return this.spreader.nLandscape; } - public setPerPage(perPage: number) { - if(perPage === 0) { + public setPerPage(perPage: number | null) { + if(perPage === null) { // TODO this mode is auto this.spread = true; } else if(perPage === 1) { @@ -376,9 +367,6 @@ export class FXLFramePoolManager { async destroy() { - this.ownerWindow.removeEventListener("resize", this.resizeBoundHandler); - this.ownerWindow.removeEventListener("orientationchange", this.resizeBoundHandler); - // Wait for all in-progress loads to complete let iit = this.inprogress.values(); let inp = iit.next(); @@ -629,4 +617,4 @@ export class FXLFramePoolManager { deselect() { this.currentFrames?.forEach(f => f?.deselect()); } -} \ No newline at end of file +} diff --git a/navigator/src/epub/index.ts b/navigator/src/epub/index.ts index fc3b767f..6dd3da6b 100644 --- a/navigator/src/epub/index.ts +++ b/navigator/src/epub/index.ts @@ -1,3 +1,5 @@ export * from "./EpubNavigator"; export * from "./frame"; -export * from "./fxl"; \ No newline at end of file +export * from "./fxl"; +export * from "./preferences"; +export * from "./css"; \ No newline at end of file diff --git a/navigator/src/epub/preferences/EpubDefaults.ts b/navigator/src/epub/preferences/EpubDefaults.ts new file mode 100644 index 00000000..95c33408 --- /dev/null +++ b/navigator/src/epub/preferences/EpubDefaults.ts @@ -0,0 +1,138 @@ +import { + fontSizeRangeConfig, + fontWeightRangeConfig, + fontWidthRangeConfig, + LayoutStrategy, + TextAlignment, + Theme +} from "../../preferences/Types"; + +import { + ensureBoolean, + ensureEnumValue, + ensureFilter, + ensureLessThanOrEqual, + ensureMoreThanOrEqual, + ensureNonNegative, + ensureString, + ensureValueInRange, + withFallback +} from "./guards"; + +export interface IEpubDefaults { + backgroundColor?: string | null, + blendFilter?: boolean | null, + columnCount?: number | null, + constraint?: number | null, + darkenFilter?: boolean | number | null, + fontFamily?: string | null, + fontSize?: number | null, + fontOpticalSizing?: boolean | null, + fontWeight?: number | null, + fontWidth?: number | null, + hyphens?: boolean | null, + invertFilter?: boolean | number | null, + invertGaijiFilter?: boolean | number | null, + layoutStrategy?: LayoutStrategy | null, + letterSpacing?: number | null, + ligatures?: boolean | null, + lineHeight?: number | null, + lineLength?: number | null, + linkColor?: string | null, + maximalLineLength?: number | null, + minimalLineLength?: number | null, + noRuby?: boolean | null, + optimalLineLength?: number | null, + pageGutter?: number | null, + paragraphIndent?: number | null, + paragraphSpacing?: number | null, + publisherStyles?: boolean | null, + scroll?: boolean | null, + selectionBackgroundColor?: string | null, + selectionTextColor?: string | null, + textAlign?: TextAlignment | null, + textColor?: string | null, + textNormalization?: boolean | null, + theme?: Theme | null, + visitedColor?: string | null, + wordSpacing?: number | null +} + +export class EpubDefaults { + backgroundColor: string | null; + blendFilter: boolean | null; + columnCount: number | null; + constraint: number; + darkenFilter: boolean | number | null; + fontFamily: string | null; + fontSize: number | null; + fontOpticalSizing: boolean | null; + fontWeight: number | null; + fontWidth: number | null; + hyphens: boolean | null; + invertFilter: boolean | number | null; + invertGaijiFilter: boolean | number | null; + layoutStrategy: LayoutStrategy | null; + letterSpacing: number | null; + ligatures: boolean | null; + lineHeight: number | null; + lineLength: number | null; + linkColor: string | null; + maximalLineLength: number | null; + minimalLineLength: number | null; + noRuby: boolean | null; + optimalLineLength: number; + pageGutter: number | null; + paragraphIndent: number | null; + paragraphSpacing: number | null; + publisherStyles: boolean | null; + scroll: boolean | null; + selectionBackgroundColor: string | null; + selectionTextColor: string | null; + textAlign: TextAlignment | null; + textColor: string | null; + textNormalization: boolean | null; + theme: Theme | null; + visitedColor: string | null; + wordSpacing: number | null; + + constructor(defaults: IEpubDefaults) { + this.backgroundColor = ensureString(defaults.backgroundColor) || null; + this.blendFilter = ensureBoolean(defaults.blendFilter) ?? false; + this.constraint = ensureNonNegative(defaults.constraint) || 0; + this.columnCount = ensureNonNegative(defaults.columnCount) || null; + this.darkenFilter = ensureFilter(defaults.darkenFilter) ?? false; + this.fontFamily = ensureString(defaults.fontFamily) || null; + this.fontSize = ensureValueInRange(defaults.fontSize, fontSizeRangeConfig.range) || 1; + this.fontOpticalSizing = ensureBoolean(defaults.fontOpticalSizing) ?? null; + this.fontWeight = ensureValueInRange(defaults.fontWeight, fontWeightRangeConfig.range) || null; + this.fontWidth = ensureValueInRange(defaults.fontWidth,fontWidthRangeConfig.range) || null; + this.hyphens = ensureBoolean(defaults.hyphens) ?? null; + this.invertFilter = ensureFilter(defaults.invertFilter) ?? false; + this.invertGaijiFilter = ensureFilter(defaults.invertGaijiFilter) ?? false; + this.layoutStrategy = ensureEnumValue(defaults.layoutStrategy, LayoutStrategy) || LayoutStrategy.lineLength; + this.letterSpacing = ensureNonNegative(defaults.letterSpacing) || null; + this.ligatures = ensureBoolean(defaults.ligatures) ?? null; + this.lineHeight = ensureNonNegative(defaults.lineHeight) || null; + this.linkColor = ensureString(defaults.linkColor) || null; + this.noRuby = ensureBoolean(defaults.noRuby) ?? false; + this.pageGutter = withFallback(ensureNonNegative(defaults.pageGutter), 20); + this.paragraphIndent = ensureNonNegative(defaults.paragraphIndent) ?? null; + this.paragraphSpacing = ensureNonNegative(defaults.paragraphSpacing) ?? null; + this.publisherStyles = ensureBoolean(defaults.publisherStyles) ?? true; + this.scroll = ensureBoolean(defaults.scroll) ?? false; + this.selectionBackgroundColor = ensureString(defaults.selectionBackgroundColor) || null; + this.selectionTextColor = ensureString(defaults.selectionTextColor) || null; + this.textAlign = ensureEnumValue(defaults.textAlign, TextAlignment) || null; + this.textColor = ensureString(defaults.textColor) || null; + this.textNormalization = ensureBoolean(defaults.textNormalization) ?? false; + this.theme = ensureEnumValue(defaults.theme, Theme) || null; + this.visitedColor = ensureString(defaults.visitedColor) || null; + this.wordSpacing = ensureNonNegative(defaults.wordSpacing) || null; + + this.lineLength = ensureNonNegative(defaults.lineLength) || null; + this.optimalLineLength = ensureNonNegative(defaults.optimalLineLength) || 65; + this.maximalLineLength = withFallback(ensureMoreThanOrEqual(defaults.maximalLineLength, this.lineLength || this.optimalLineLength), 80); + this.minimalLineLength = withFallback(ensureLessThanOrEqual(defaults.minimalLineLength, this.lineLength || this.optimalLineLength), 40); + } +} \ No newline at end of file diff --git a/navigator/src/epub/preferences/EpubPreferences.ts b/navigator/src/epub/preferences/EpubPreferences.ts new file mode 100644 index 00000000..5f50fc6c --- /dev/null +++ b/navigator/src/epub/preferences/EpubPreferences.ts @@ -0,0 +1,174 @@ +import { ConfigurablePreferences } from "../../preferences/Configurable"; + +import { + LayoutStrategy, + TextAlignment, + Theme, + fontSizeRangeConfig, + fontWeightRangeConfig, + fontWidthRangeConfig +} from "../../preferences/Types"; + +import { + ensureBoolean, + ensureEnumValue, + ensureFilter, + ensureNonNegative, + ensureString, + ensureValueInRange +} from "./guards"; + +export interface IEpubPreferences { + backgroundColor?: string | null, + blendFilter?: boolean | null, + columnCount?: number | null, + constraint?: number | null, + darkenFilter?: boolean | number | null, + fontFamily?: string | null, + fontSize?: number | null, + fontOpticalSizing?: boolean | null, + fontWeight?: number | null, + fontWidth?: number | null, + hyphens?: boolean | null, + invertFilter?: boolean | number | null, + invertGaijiFilter?: boolean | number | null, + layoutStrategy?: LayoutStrategy | null, + letterSpacing?: number | null, + ligatures?: boolean | null, + lineHeight?: number | null, + lineLength?: number | null, + linkColor?: string | null, + maximalLineLength?: number | null, + minimalLineLength?: number | null, + noRuby?: boolean | null, + optimalLineLength?: number | null, + pageGutter?: number | null, + paragraphIndent?: number | null, + paragraphSpacing?: number | null, + publisherStyles?: boolean | null, + scroll?: boolean | null, + selectionBackgroundColor?: string | null, + selectionTextColor?: string | null, + textAlign?: TextAlignment | null, + textColor?: string | null, + textNormalization?: boolean | null, + theme?: Theme | null, + visitedColor?: string | null, + wordSpacing?: number | null +} + +export class EpubPreferences implements ConfigurablePreferences { + backgroundColor?: string | null; + blendFilter?: boolean | null; + constraint?: number | null; + columnCount?: number | null; + darkenFilter?: boolean | number | null; + fontFamily?: string | null; + fontSize?: number | null; + fontOpticalSizing?: boolean | null; + fontWeight?: number | null; + fontWidth?: number | null; + hyphens?: boolean | null; + invertFilter?: boolean | number | null; + invertGaijiFilter?: boolean | number | null; + layoutStrategy?: LayoutStrategy | null; + letterSpacing?: number | null; + ligatures?: boolean | null; + lineHeight?: number | null; + lineLength?: number | null; + linkColor?: string | null; + maximalLineLength?: number | null; + minimalLineLength?: number | null; + noRuby?: boolean | null; + optimalLineLength?: number | null; + pageGutter?: number | null; + paragraphIndent?: number | null; + paragraphSpacing?: number | null; + publisherStyles?: boolean | null; + scroll?: boolean | null; + selectionBackgroundColor?: string | null; + selectionTextColor?: string | null; + textAlign?: TextAlignment | null; + textColor?: string | null; + textNormalization?: boolean | null; + theme?: Theme | null; + visitedColor?: string | null; + wordSpacing?: number | null; + + constructor(preferences: IEpubPreferences = {}) { + this.backgroundColor = ensureString(preferences.backgroundColor); + this.blendFilter = ensureBoolean(preferences.blendFilter); + this.constraint = ensureNonNegative(preferences.constraint); + this.columnCount = ensureNonNegative(preferences.columnCount); + this.darkenFilter = ensureFilter(preferences.darkenFilter); + this.fontFamily = ensureString(preferences.fontFamily); + this.fontSize = ensureValueInRange(preferences.fontSize, fontSizeRangeConfig.range); + this.fontOpticalSizing = ensureBoolean(preferences.fontOpticalSizing); + this.fontWeight = ensureValueInRange(preferences.fontWeight, fontWeightRangeConfig.range); + this.fontWidth = ensureValueInRange(preferences.fontWidth,fontWidthRangeConfig.range); + this.hyphens = ensureBoolean(preferences.hyphens); + this.invertFilter = ensureFilter(preferences.invertFilter); + this.invertGaijiFilter = ensureFilter(preferences.invertGaijiFilter); + this.layoutStrategy = ensureEnumValue(preferences.layoutStrategy, LayoutStrategy); + this.letterSpacing = ensureNonNegative(preferences.letterSpacing); + this.ligatures = ensureBoolean(preferences.ligatures); + this.lineHeight = ensureNonNegative(preferences.lineHeight); + this.linkColor = ensureString(preferences.linkColor); + this.noRuby = ensureBoolean(preferences.noRuby); + this.pageGutter = ensureNonNegative(preferences.pageGutter); + this.paragraphIndent = ensureNonNegative(preferences.paragraphIndent); + this.paragraphSpacing = ensureNonNegative(preferences.paragraphSpacing); + this.publisherStyles = ensureBoolean(preferences.publisherStyles); + this.scroll = ensureBoolean(preferences.scroll); + this.selectionBackgroundColor = ensureString(preferences.selectionBackgroundColor); + this.selectionTextColor = ensureString(preferences.selectionTextColor); + this.textAlign = ensureEnumValue(preferences.textAlign, TextAlignment); + this.textColor = ensureString(preferences.textColor); + this.textNormalization = ensureBoolean(preferences.textNormalization); + this.theme = ensureEnumValue(preferences.theme, Theme); + this.visitedColor = ensureString(preferences.visitedColor); + this.wordSpacing = ensureNonNegative(preferences.wordSpacing); + + this.lineLength = ensureNonNegative(preferences.lineLength); + this.optimalLineLength = ensureNonNegative(preferences.optimalLineLength); + this.maximalLineLength = ensureNonNegative(preferences.maximalLineLength); + this.minimalLineLength = ensureNonNegative(preferences.minimalLineLength); + } + + static serialize(preferences: EpubPreferences): string { + const { ...properties } = preferences; + return JSON.stringify(properties); + } + + static deserialize(preferences: string): EpubPreferences | null { + try { + const parsedPreferences = JSON.parse(preferences); + return new EpubPreferences(parsedPreferences); + } catch (error) { + console.error("Failed to deserialize preferences:", error); + return null; + } + } + + merging(other: ConfigurablePreferences): ConfigurablePreferences { + const merged: IEpubPreferences = { ...this }; + for (const key of Object.keys(other) as (keyof IEpubPreferences)[]) { + if ( + other[key] !== undefined && + ( + key !== "maximalLineLength" || + other[key] === null || + (other[key] >= (other.lineLength ?? merged.lineLength ?? other.optimalLineLength ?? merged.optimalLineLength ?? 65)) + ) && + ( + key !== "minimalLineLength" || + other[key] === null || + (other[key] <= (other.lineLength ?? merged.lineLength ?? other.optimalLineLength ?? merged.optimalLineLength ?? 65)) + ) + ) { + merged[key] = other[key]; + } + } + return new EpubPreferences(merged); + } +} \ No newline at end of file diff --git a/navigator/src/epub/preferences/EpubPreferencesEditor.ts b/navigator/src/epub/preferences/EpubPreferencesEditor.ts new file mode 100644 index 00000000..d2c763af --- /dev/null +++ b/navigator/src/epub/preferences/EpubPreferencesEditor.ts @@ -0,0 +1,471 @@ +import { EPUBLayout, Metadata, ReadingProgression } from "@readium/shared"; +import { IPreferencesEditor } from "../../preferences/PreferencesEditor"; +import { EpubPreferences } from "./EpubPreferences"; +import { EpubSettings } from "./EpubSettings"; +import { BooleanPreference, EnumPreference, Preference, RangePreference } from "../../preferences/Preference"; +import { + LayoutStrategy, + TextAlignment, + Theme, + fontSizeRangeConfig, + fontWeightRangeConfig, + fontWidthRangeConfig +} from "../../preferences/Types"; + +import dayMode from "@readium/css/css/vars/day.json"; +import fontStacks from "@readium/css/css/vars/fontStacks.json"; + +// WIP: will change cos’ of all the missing pieces +export class EpubPreferencesEditor implements IPreferencesEditor { + preferences: EpubPreferences; + private settings: EpubSettings; + private metadata: Metadata | null; + private layout: EPUBLayout; + + constructor(initialPreferences: EpubPreferences, settings: EpubSettings, metadata: Metadata) { + this.preferences = initialPreferences; + this.settings = settings; + this.metadata = metadata; + this.layout = this.metadata?.getPresentation()?.layout || EPUBLayout.reflowable; + } + + clear() { + this.preferences = new EpubPreferences({ optimalLineLength: 65 }); + } + + private updatePreference(key: K, value: EpubPreferences[K]) { + this.preferences[key] = value; + } + + get backgroundColor(): Preference { + return new Preference({ + initialValue: this.preferences.backgroundColor, + effectiveValue: this.settings.backgroundColor || dayMode.RS__backgroundColor, + isEffective: this.preferences.backgroundColor !== null, + onChange: (newValue: string | null | undefined) => { + this.updatePreference("backgroundColor", newValue || null); + } + }); + } + get blendFilter(): BooleanPreference { + return new BooleanPreference({ + initialValue: this.preferences.blendFilter, + effectiveValue: this.settings.blendFilter || false, + isEffective: this.preferences.blendFilter !== null, + onChange: (newValue: boolean | null | undefined) => { + this.updatePreference("blendFilter", newValue || null); + } + }); + } + + get columnCount(): Preference { + return new Preference({ + initialValue: this.preferences.columnCount, + effectiveValue: this.settings.columnCount || null, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.scroll, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("columnCount", newValue || null); + } + }); + } + + get constraint(): Preference { + return new Preference({ + initialValue: this.preferences.constraint, + effectiveValue: this.preferences.constraint || 0, + isEffective: true, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("constraint", newValue || null); + } + }) + } + + get darkenFilter(): RangePreference { + return new RangePreference({ + initialValue: typeof this.preferences.darkenFilter === "boolean" ? 100 : this.preferences.darkenFilter, + effectiveValue: typeof this.settings.darkenFilter === "boolean" ? 100 : this.settings.darkenFilter || 0, + isEffective: this.settings.darkenFilter !== null, + onChange: (newValue: number | boolean | null | undefined) => { + this.updatePreference("darkenFilter", newValue || null); + }, + supportedRange: [0, 100], + step: 1 + }); + } + + get fontFamily(): Preference { + return new Preference({ + initialValue: this.preferences.fontFamily, + // TODO infer effectiveValue of fontFamily as it’s more complex than that: + // while it’s using --RS__oldStyleTf as a default, it is actually var + // --RS__baseFontFamily that is used as a proxy so that it can be redefined + // for each language ReadiumCSS supports, and these values are not extracted + effectiveValue: this.settings.fontFamily || fontStacks.RS__oldStyleTf, + isEffective: this.layout === EPUBLayout.reflowable, + onChange: (newValue: string | null | undefined) => { + this.updatePreference("fontFamily", newValue || null); + } + }); + } + + get fontSize(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.fontSize, + effectiveValue: this.settings.fontSize || 1, + isEffective: this.layout === EPUBLayout.reflowable, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("fontSize", newValue || null); + }, + supportedRange: fontSizeRangeConfig.range, + step: fontSizeRangeConfig.step + }); + } + + get fontOpticalSizing(): BooleanPreference { + return new BooleanPreference({ + initialValue: this.preferences.fontOpticalSizing, + effectiveValue: this.settings.fontOpticalSizing || true, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.publisherStyles && this.preferences.fontOpticalSizing !== null, + onChange: (newValue: boolean | null | undefined) => { + this.updatePreference("fontOpticalSizing", newValue || null); + } + }); + } + + get fontWeight(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.fontWeight, + effectiveValue: this.settings.fontWeight || 400, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.publisherStyles && this.preferences.fontWeight !== null, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("fontWeight", newValue || null); + }, + supportedRange: fontWeightRangeConfig.range, + step: fontWeightRangeConfig.step + }); + } + + get fontWidth(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.fontWidth, + effectiveValue: this.settings.fontWidth || 100, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.publisherStyles && this.preferences.fontWidth !== null, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("fontWidth", newValue || null); + }, + supportedRange: fontWidthRangeConfig.range, + step: fontWidthRangeConfig.step + }); + } + + get hyphens(): BooleanPreference { + return new BooleanPreference({ + initialValue: this.preferences.hyphens, + effectiveValue: this.settings.hyphens || false, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.publisherStyles && this.metadata?.effectiveReadingProgression === ReadingProgression.ltr, + onChange: (newValue: boolean | null | undefined) => { + this.updatePreference("hyphens", newValue || null); + } + }); + } + + get invertFilter(): RangePreference { + return new RangePreference({ + initialValue: typeof this.preferences.invertFilter === "boolean" ? 100 : this.preferences.invertFilter, + effectiveValue: typeof this.settings.invertFilter === "boolean" ? 100 : this.settings.invertFilter || 0, + isEffective: this.settings.invertFilter !== null, + onChange: (newValue: number | boolean | null | undefined) => { + this.updatePreference("invertFilter", newValue || null); + }, + supportedRange: [0, 100], + step: 1 + }); + } + + get invertGaijiFilter(): RangePreference { + return new RangePreference({ + initialValue: typeof this.preferences.invertGaijiFilter === "boolean" ? 100 : this.preferences.invertGaijiFilter, + effectiveValue: typeof this.settings.invertGaijiFilter === "boolean" ? 100 : this.settings.invertGaijiFilter || 0, + isEffective: this.preferences.invertGaijiFilter !== null, + onChange: (newValue: number | boolean | null | undefined) => { + this.updatePreference("invertGaijiFilter", newValue || null); + }, + supportedRange: [0, 100], + step: 1 + }); + } + + get layoutStrategy(): EnumPreference { + return new EnumPreference({ + initialValue: this.preferences.layoutStrategy, + effectiveValue: this.settings.layoutStrategy, + isEffective: this.layout === EPUBLayout.reflowable, + onChange: (newValue: LayoutStrategy | null | undefined) => { + this.updatePreference("layoutStrategy", newValue || null); + }, + supportedValues: Object.values(LayoutStrategy) + }) + } + + get letterSpacing(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.letterSpacing, + effectiveValue: this.settings.letterSpacing || 0, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.publisherStyles && this.metadata?.effectiveReadingProgression === ReadingProgression.ltr, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("letterSpacing", newValue || null); + }, + supportedRange: [0, 1], + step: .125 + }); + } + + get ligatures(): BooleanPreference { + return new BooleanPreference({ + initialValue: this.preferences.ligatures, + effectiveValue: this.settings.ligatures || true, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.publisherStyles && this.metadata?.effectiveReadingProgression === ReadingProgression.rtl, + onChange: (newValue: boolean | null | undefined) => { + this.updatePreference("ligatures", newValue || null); + } + }); + } + + get lineHeight(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.lineHeight, + effectiveValue: this.settings.lineHeight, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.publisherStyles, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("lineHeight", newValue || null); + }, + supportedRange: [1, 2], + step: .1 + }); + } + + get lineLength(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.lineLength, + effectiveValue: this.settings.lineLength || this.settings.optimalLineLength, + isEffective: this.layout === EPUBLayout.reflowable, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("lineLength", newValue || null); + }, + supportedRange: [20, 100], + step: 1 + }); + } + + get linkColor(): Preference { + return new Preference({ + initialValue: this.preferences.linkColor, + effectiveValue: this.settings.linkColor || dayMode.RS__linkColor, + isEffective: this.layout === EPUBLayout.reflowable && this.preferences.linkColor !== null, + onChange: (newValue: string | null | undefined) => { + this.updatePreference("linkColor", newValue || null); + } + }); + } + + get maximalLineLength(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.maximalLineLength, + effectiveValue: this.settings.maximalLineLength, + isEffective: this.layout === EPUBLayout.reflowable, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("maximalLineLength", newValue); + }, + supportedRange: [20, 100], + step: 1 + }); + } + + get minimalLineLength(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.minimalLineLength, + effectiveValue: this.settings.minimalLineLength, + isEffective: this.layout === EPUBLayout.reflowable, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("minimalLineLength", newValue); + }, + supportedRange: [20, 100], + step: 1 + }); + } + + get noRuby(): BooleanPreference { + return new BooleanPreference({ + initialValue: this.preferences.noRuby, + effectiveValue: this.settings.noRuby || false, + isEffective: this.layout === EPUBLayout.reflowable && this.metadata?.languages?.includes("ja") || false, + onChange: (newValue: boolean | null | undefined) => { + this.updatePreference("noRuby", newValue || null); + } + }); + } + + get optimalLineLength(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.optimalLineLength, + effectiveValue: this.settings.optimalLineLength, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.lineLength, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("optimalLineLength", newValue as number); + }, + supportedRange: [20, 100], + step: 1 + }); + } + + get pageGutter(): Preference { + return new Preference({ + initialValue: this.preferences.pageGutter, + effectiveValue: this.settings.pageGutter, + isEffective: this.layout === EPUBLayout.reflowable, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("pageGutter", newValue || null); + } + }); + } + + get paragraphIndent(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.paragraphIndent, + effectiveValue: this.settings.paragraphIndent || 0, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.publisherStyles && this.preferences.paragraphIndent !== null, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("paragraphIndent", newValue || null); + }, + supportedRange: [0, 3], + step: .25 + }); + } + + get paragraphSpacing(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.paragraphSpacing, + effectiveValue: this.settings.paragraphSpacing || 0, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.publisherStyles && this.preferences.paragraphSpacing !== null, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("paragraphSpacing", newValue || null); + }, + supportedRange: [0, 3], + step: .25 + }); + } + + get publisherStyles(): BooleanPreference { + return new BooleanPreference({ + initialValue: this.preferences.publisherStyles, + effectiveValue: this.settings.publisherStyles || true, + isEffective: this.layout === EPUBLayout.reflowable, + onChange: (newValue: boolean | null | undefined) => { + this.updatePreference("publisherStyles", newValue || null); + } + }); + } + + get scroll(): BooleanPreference { + return new BooleanPreference({ + initialValue: this.preferences.scroll, + effectiveValue: this.settings.scroll || false, + isEffective: this.layout === EPUBLayout.reflowable, + onChange: (newValue: boolean | null | undefined) => { + this.updatePreference("scroll", newValue || null); + } + }); + } + + get selectionBackgroundColor(): Preference { + return new Preference({ + initialValue: this.preferences.selectionBackgroundColor, + effectiveValue: this.settings.selectionBackgroundColor || dayMode.RS__selectionBackgroundColor, + isEffective: this.layout === EPUBLayout.reflowable && this.preferences.selectionBackgroundColor !== null, + onChange: (newValue: string | null | undefined) => { + this.updatePreference("selectionBackgroundColor", newValue || null); + } + }); + } + + get selectionTextColor(): Preference { + return new Preference({ + initialValue: this.preferences.selectionTextColor, + effectiveValue: this.settings.selectionTextColor || dayMode.RS__selectionTextColor, + isEffective: this.layout === EPUBLayout.reflowable && this.preferences.selectionTextColor !== null, + onChange: (newValue: string | null | undefined) => { + this.updatePreference("selectionTextColor", newValue || null); + } + }); + } + + get textAlign(): EnumPreference { + return new EnumPreference({ + initialValue: this.preferences.textAlign, + effectiveValue: this.settings.textAlign || TextAlignment.start, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.publisherStyles, + onChange: (newValue: TextAlignment | null | undefined) => { + this.updatePreference("textAlign", newValue || null); + }, + supportedValues: Object.values(TextAlignment) + }); + } + + get textColor(): Preference { + return new Preference({ + initialValue: this.preferences.textColor, + effectiveValue: this.settings.textColor || dayMode.RS__textColor, + isEffective: this.layout === EPUBLayout.reflowable && this.preferences.textColor !== null, + onChange: (newValue: string | null | undefined) => { + this.updatePreference("textColor", newValue || null); + } + }); + } + + get textNormalization(): BooleanPreference { + return new BooleanPreference({ + initialValue: this.preferences.textNormalization, + effectiveValue: this.settings.textNormalization || false, + isEffective: this.layout === EPUBLayout.reflowable, + onChange: (newValue: boolean | null | undefined) => { + this.updatePreference("textNormalization", newValue || null); + } + }); + } + + get theme(): EnumPreference { + return new EnumPreference({ + initialValue: this.preferences.theme, + effectiveValue: this.settings.theme || Theme.day, + isEffective: this.layout === EPUBLayout.reflowable, + onChange: (newValue: Theme | null | undefined) => { + this.updatePreference("theme", newValue || null); + }, + supportedValues: Object.values(Theme) + }); + } + + get visitedColor(): Preference { + return new Preference({ + initialValue: this.preferences.visitedColor, + effectiveValue: this.settings.visitedColor || dayMode.RS__visitedColor, + isEffective: this.layout === EPUBLayout.reflowable && this.preferences.visitedColor !== null, + onChange: (newValue: string | null | undefined) => { + this.updatePreference("visitedColor", newValue || null); + } + }); + } + + get wordSpacing(): RangePreference { + return new RangePreference({ + initialValue: this.preferences.wordSpacing, + effectiveValue: this.settings.wordSpacing || 0, + isEffective: this.layout === EPUBLayout.reflowable && !this.settings.publisherStyles && this.preferences.wordSpacing !== null, + onChange: (newValue: number | null | undefined) => { + this.updatePreference("wordSpacing", newValue || null); + }, + supportedRange: [0, 2], + step: 0.125 + }); + } +} \ No newline at end of file diff --git a/navigator/src/epub/preferences/EpubSettings.ts b/navigator/src/epub/preferences/EpubSettings.ts new file mode 100644 index 00000000..1cf2c2c3 --- /dev/null +++ b/navigator/src/epub/preferences/EpubSettings.ts @@ -0,0 +1,191 @@ +import { ConfigurableSettings } from "../../preferences/Configurable"; +import { LayoutStrategy, TextAlignment, Theme } from "../../preferences/Types"; +import { EpubDefaults } from "./EpubDefaults"; +import { EpubPreferences } from "./EpubPreferences"; + +export interface IEpubSettings { + backgroundColor?: string | null, + blendFilter?: boolean | null, + columnCount?: number | null, + constraint?: number | null, + darkenFilter?: boolean | number | null, + fontFamily?: string | null, + fontSize?: number | null, + fontOpticalSizing?: boolean | null, + fontWeight?: number | null, + fontWidth?: number | null, + hyphens?: boolean | null, + invertFilter?: boolean | number | null, + invertGaijiFilter: boolean | number | null, + layoutStrategy?: LayoutStrategy | null, + letterSpacing?: number | null, + ligatures?: boolean | null, + lineHeight?: number | null, + lineLength?: number | null, + linkColor?: string | null, + maximalLineLength?: number | null, + minimalLineLength?: number | null, + noRuby?: boolean | null, + optimalLineLength?: number | null, + pageGutter?: number | null, + paragraphIndent?: number | null, + paragraphSpacing?: number | null, + publisherStyles?: boolean | null, + scroll?: boolean | null, + selectionBackgroundColor?: string | null, + selectionTextColor?: string | null, + textAlign?: TextAlignment | null, + textColor?: string | null, + textNormalization?: boolean | null, + theme?: Theme | null, + visitedColor?: string | null, + wordSpacing?: number | null +} + +export class EpubSettings implements ConfigurableSettings { + backgroundColor: string | null; + blendFilter: boolean | null; + columnCount: number | null; + constraint: number; + darkenFilter: boolean | number | null; + fontFamily: string | null; + fontSize: number | null; + fontOpticalSizing: boolean | null; + fontWeight: number | null; + fontWidth: number | null; + hyphens: boolean | null; + invertFilter: boolean | number | null; + invertGaijiFilter: boolean | number | null; + layoutStrategy: LayoutStrategy | null; + letterSpacing: number | null; + ligatures: boolean | null; + lineHeight: number | null; + lineLength: number | null; + linkColor: string | null; + maximalLineLength: number | null; + minimalLineLength: number | null; + noRuby: boolean | null; + optimalLineLength: number; + pageGutter: number | null; + paragraphIndent: number | null; + paragraphSpacing: number | null; + publisherStyles: boolean | null; + scroll: boolean | null; + selectionBackgroundColor: string | null; + selectionTextColor: string | null; + textAlign: TextAlignment | null; + textColor: string | null; + textNormalization: boolean | null; + theme: Theme | null; + visitedColor: string | null; + wordSpacing: number | null; + + constructor(preferences: EpubPreferences, defaults: EpubDefaults) { + this.backgroundColor = preferences.backgroundColor || defaults.backgroundColor || null; + this.blendFilter = typeof preferences.blendFilter === "boolean" + ? preferences.blendFilter + : defaults.blendFilter || null; + this.columnCount = preferences.columnCount !== undefined + ? preferences.columnCount + : defaults.columnCount !== undefined + ? defaults.columnCount + : null; + this.constraint = preferences.constraint || defaults.constraint; + this.darkenFilter = typeof preferences.darkenFilter === "boolean" + ? preferences.darkenFilter + : defaults.darkenFilter || null; + this.fontFamily = preferences.fontFamily || defaults.fontFamily || null; + this.fontSize = preferences.fontSize !== undefined + ? preferences.fontSize + : defaults.fontSize !== undefined + ? defaults.fontSize + : null; + this.fontOpticalSizing = typeof preferences.fontOpticalSizing === "boolean" + ? preferences.fontOpticalSizing + : defaults.fontOpticalSizing || null; + this.fontWeight = preferences.fontWeight !== undefined + ? preferences.fontWeight + : defaults.fontWeight !== undefined + ? defaults.fontWeight + : null; + this.fontWidth = preferences.fontWidth !== undefined + ? preferences.fontWidth + : defaults.fontWidth !== undefined + ? defaults.fontWidth + : null; + this.hyphens = typeof preferences.hyphens === "boolean" + ? preferences.hyphens + : defaults.hyphens || null; + this.invertFilter = typeof preferences.invertFilter === "boolean" + ? preferences.invertFilter + : defaults.invertFilter || null; + this.invertGaijiFilter = typeof preferences.invertGaijiFilter === "boolean" + ? preferences.invertGaijiFilter + : defaults.invertGaijiFilter || null; + this.layoutStrategy = preferences.layoutStrategy || defaults.layoutStrategy || null; + this.letterSpacing = preferences.letterSpacing !== undefined + ? preferences.letterSpacing + : defaults.letterSpacing !== undefined + ? defaults.letterSpacing + : null; + this.ligatures = typeof preferences.ligatures === "boolean" + ? preferences.ligatures + : defaults.ligatures || null; + this.lineHeight = preferences.lineHeight !== undefined + ? preferences.lineHeight + : defaults.lineHeight !== undefined + ? defaults.lineHeight + : null; + this.lineLength = preferences.lineLength !== undefined + ? preferences.lineLength + : defaults.lineLength !== undefined + ? defaults.lineLength + : null; + this.linkColor = preferences.linkColor || defaults.linkColor || null; + this.maximalLineLength = preferences.maximalLineLength === null + ? null + : preferences.maximalLineLength || defaults.maximalLineLength || null; + this.minimalLineLength = preferences.minimalLineLength === null + ? null + : preferences.minimalLineLength || defaults.minimalLineLength || null; + this.noRuby = typeof preferences.noRuby === "boolean" + ? preferences.noRuby + : defaults.noRuby || null; + this.optimalLineLength = preferences.optimalLineLength || defaults.optimalLineLength; + this.pageGutter = preferences.pageGutter !== undefined + ? preferences.pageGutter + : defaults.pageGutter !== undefined + ? defaults.pageGutter + : null; + this.paragraphIndent = preferences.paragraphIndent !== undefined + ? preferences.paragraphIndent + : defaults.paragraphIndent !== undefined + ? defaults.paragraphIndent + : null; + this.paragraphSpacing = preferences.paragraphSpacing !== undefined + ? preferences.paragraphSpacing + : defaults.paragraphSpacing !== undefined + ? defaults.paragraphSpacing + : null; + this.publisherStyles = typeof preferences.publisherStyles === "boolean" + ? preferences.publisherStyles + : defaults.publisherStyles || null; + this.scroll = typeof preferences.scroll === "boolean" + ? preferences.scroll + : defaults.scroll || null; + this.selectionBackgroundColor = preferences.selectionBackgroundColor || defaults.selectionBackgroundColor || null; + this.selectionTextColor = preferences.selectionTextColor || defaults.selectionTextColor || null; + this.textAlign = preferences.textAlign || defaults.textAlign || null; + this.textColor = preferences.textColor || defaults.textColor || null; + this.textNormalization = typeof preferences.textNormalization === "boolean" + ? preferences.textNormalization + : defaults.textNormalization || null; + this.theme = preferences.theme || defaults.theme || null; + this.visitedColor = preferences.visitedColor || defaults.visitedColor || null; + this.wordSpacing = preferences.wordSpacing !== undefined + ? preferences.wordSpacing + : defaults.wordSpacing !== undefined + ? defaults.wordSpacing + : null; + } +} \ No newline at end of file diff --git a/navigator/src/epub/preferences/guards.ts b/navigator/src/epub/preferences/guards.ts new file mode 100644 index 00000000..fd2f221a --- /dev/null +++ b/navigator/src/epub/preferences/guards.ts @@ -0,0 +1,86 @@ +export function ensureLessThanOrEqual(value: T, compareTo: T): T | undefined { + if (value === undefined || value === null) { + return value; + } + if (compareTo === undefined || compareTo === null) { + return value; + } + return value <= compareTo ? value : undefined; +} + +export function ensureMoreThanOrEqual(value: T, compareTo: T): T | undefined { + if (value === undefined || value === null) { + return value; + } + if (compareTo === undefined || compareTo === null) { + return value; + } + return value >= compareTo ? value : undefined; +} + +export function ensureString(value: string | null | undefined): string | null | undefined { + if (typeof value === "string") { + return value; + } else if (value === null) { + return null; + } else { + return undefined; + } +} + +export function ensureBoolean(value: boolean | null | undefined): boolean | null | undefined { + return typeof value === "boolean" + ? value + : value === undefined || value === null + ? value + : undefined; +} + + +export function ensureEnumValue(value: T | null | undefined, enumType: Record): T | null | undefined { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return enumType[value as T] !== undefined ? value : undefined; +} + +export function ensureFilter(filter: boolean | number | null | undefined): boolean | number | null | undefined { + if (typeof filter === "boolean") { + return filter; + } else if (typeof filter === "number" && filter >= 0) { + return filter; + } else if (filter === null) { + return null; + } else { + return undefined; + } +} + +export function ensureNonNegative(value: number | null | undefined): number | null | undefined { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return value < 0 ? undefined : value; +} + +export function ensureValueInRange(value: number | null | undefined, range: [number, number]): number | null | undefined { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + const min = Math.min(...range); + const max = Math.max(...range); + return value >= min && value <= max ? value : undefined; +} + +export function withFallback(value: T | null | undefined, defaultValue: T | null): T | null { + return value === undefined ? defaultValue : value; +} \ No newline at end of file diff --git a/navigator/src/epub/preferences/index.ts b/navigator/src/epub/preferences/index.ts new file mode 100644 index 00000000..88df4a0b --- /dev/null +++ b/navigator/src/epub/preferences/index.ts @@ -0,0 +1,4 @@ +export * from "./EpubDefaults"; +export * from "./EpubPreferencesEditor"; +export * from "./EpubPreferences"; +export * from "./EpubSettings"; \ No newline at end of file diff --git a/navigator/src/helpers/dimensions.ts b/navigator/src/helpers/dimensions.ts new file mode 100644 index 00000000..697e521d --- /dev/null +++ b/navigator/src/helpers/dimensions.ts @@ -0,0 +1,13 @@ +/** + * Returns the "content width" of an element, which is its clientWidth + * minus any horizontal padding. + * + * @param el - The element to measure. + */ +export function getContentWidth(el: Element) { + const cStyle = getComputedStyle(el); + const paddingLeft = parseFloat(cStyle.paddingLeft || "0"); + const paddingRight = parseFloat(cStyle.paddingRight || "0"); + return el.clientWidth - paddingLeft - paddingRight; +} + diff --git a/navigator/src/helpers/index.ts b/navigator/src/helpers/index.ts index e6c0fa2f..3f2b2450 100644 --- a/navigator/src/helpers/index.ts +++ b/navigator/src/helpers/index.ts @@ -1 +1,2 @@ +export * from "./lineLength"; export * from './sML'; \ No newline at end of file diff --git a/navigator/src/helpers/lineLength.ts b/navigator/src/helpers/lineLength.ts new file mode 100644 index 00000000..0f493c00 --- /dev/null +++ b/navigator/src/helpers/lineLength.ts @@ -0,0 +1,293 @@ +import fontStacks from "@readium/css/css/vars/fontStacks.json"; + +export interface ICustomFontFace { + name: string; + url: string; +} + +export interface ILineLengthsConfig { + optimalChars: number; + minChars?: number | null; + maxChars?: number | null; + userChars?: number | null; + baseFontSize?: number | null; + sample?: string | null; + pageGutter?: number | null; + fontFace?: string | ICustomFontFace | null; + letterSpacing?: number | null; + wordSpacing?: number | null; + isCJK?: boolean | null; + getRelative?: boolean | null; +} + +export interface ILineLengths { + min: number | null; + user: number | null; + max: number | null; + optimal: number; + baseFontSize: number; +} + +const DEFAULT_FONT_SIZE = 16; +const DEFAULT_FONT_FACE = fontStacks.RS__oldStyleTf; + +// Notes: +// +// We’re “embracing” design limitations of the ch length +// See https://developer.mozilla.org/en-US/docs/Web/CSS/length#ch +// +// Vertical-writing is not implemented yet, as it is not supported in canvas +// which means it has to be emulated by writing each character with an +// offset on the y-axis (using fillText), and getting the total height. +// If you don’t need high accuracy, it’s acceptable to use the one returned with isCJK. +// +// Instead of measuring text for min, user, and optimal each, we define multipliers +// at the end, with optimalLineLength as a ref, before returning the lineLengths object. + +export class LineLengths { + private _canvas: HTMLCanvasElement; + + private _optimalChars: number; + private _minChars?: number | null; + private _maxChars?: number | null; + private _userChars: number | null; + private _baseFontSize: number; + private _fontFace: string | ICustomFontFace; + private _sample: string | null; + private _pageGutter: number; + private _letterSpacing: number; + private _wordSpacing: number; + private _isCJK: boolean; + private _getRelative: boolean; + + private _padding: number; + private _minDivider: number | null; + private _userMultiplier: number | null; + private _maxMultiplier: number | null; + private _approximatedWordSpaces: number; + + private _optimalLineLength: number | null = null; + + constructor(config: ILineLengthsConfig) { + this._canvas = document.createElement("canvas"); + this._optimalChars = config.optimalChars; + this._minChars = config.minChars; + this._maxChars = config.maxChars; + this._userChars = config.userChars || null; + this._baseFontSize = config.baseFontSize || DEFAULT_FONT_SIZE; + this._fontFace = config.fontFace || DEFAULT_FONT_FACE; + this._sample = config.sample || null; + this._pageGutter = config.pageGutter || 0; + this._letterSpacing = config.letterSpacing + ? Math.round(config.letterSpacing * this._baseFontSize) + : 0; + this._wordSpacing = config.wordSpacing + ? Math.round(config.wordSpacing * this._baseFontSize) + : 0; + this._isCJK = config.isCJK || false; + this._getRelative = config.getRelative || false; + this._padding = this._pageGutter * 2; + this._minDivider = this._minChars && this._minChars < this._optimalChars + ? this._optimalChars / this._minChars + : this._minChars === null + ? null + : 1; + this._userMultiplier = this._userChars + ? this._userChars / this._optimalChars + : null; + this._maxMultiplier = this._maxChars && this._maxChars > this._optimalChars + ? this._maxChars / this._optimalChars + : this._maxChars === null + ? null + : 1; + this._approximatedWordSpaces = LineLengths.approximateWordSpaces(this._optimalChars, this._sample); + } + + set minChars(n: number | null) { + if (n === this._minChars) return; + this._minChars = n; + this._minDivider = this._minChars && this._minChars < this._optimalChars + ? this._optimalChars / this._minChars + : this._minChars === null + ? null + : 1; + } + + set optimalChars(n: number) { + if (n === this._optimalChars) return; + this._optimalChars = n; + this._optimalLineLength = this.getOptimalLineLength(); + } + + set maxChars(n: number | null) { + if (n === this._maxChars) return; + this._maxChars = n; + this._maxMultiplier = this._maxChars && this._maxChars > this._optimalChars + ? this._maxChars / this._optimalChars + : this._maxChars === null + ? null + : 1; + } + + set userChars(n: number | null) { + if (n === this._userChars) return; + this._userChars = n; + this._userMultiplier = this._userChars ? this._userChars / this._optimalChars : null; + } + + set letterSpacing(n: number) { + if (n === this._letterSpacing) return; + this._letterSpacing = Math.round(n * this._baseFontSize); + this._optimalLineLength = this.getOptimalLineLength(); + } + + set wordSpacing(n: number) { + if (n === this._wordSpacing) return; + this._wordSpacing = Math.round(n * this._baseFontSize); + this._optimalLineLength = this.getOptimalLineLength(); + } + + set baseFontSize(n: number) { + this._baseFontSize = n; + this._optimalLineLength = this.getOptimalLineLength(); + } + + set fontFace(f: string | ICustomFontFace | null) { + this._fontFace = f || DEFAULT_FONT_FACE; + this._optimalLineLength = this.getOptimalLineLength(); + } + + set sample(s: string) { + if (s === this._sample) return; + this._sample = s; + this._approximatedWordSpaces = LineLengths.approximateWordSpaces(this._optimalChars, this._sample); + } + + set pageGutter(n: number) { + if (n === this._pageGutter) return; + this._pageGutter = n; + this._padding = this._pageGutter * 2; + this._optimalLineLength = this.getOptimalLineLength(); + } + + set relativeGetters(b: boolean) { + if (b === this._getRelative) return; + this._getRelative = b; + } + + get baseFontSize() { + return this._baseFontSize; + } + + get minimalLineLength(): number | null { + if (!this._optimalLineLength) { + this._optimalLineLength = this.getOptimalLineLength(); + } + return this._minDivider !== null + ? Math.round((this._optimalLineLength / this._minDivider) + this._padding) / (this._getRelative ? this._baseFontSize : 1) + : null; + } + + get userLineLength(): number | null { + if (!this._optimalLineLength) { + this._optimalLineLength = this.getOptimalLineLength(); + } + return this._userMultiplier !== null + ? Math.round((this._optimalLineLength * this._userMultiplier) + this._padding) / (this._getRelative ? this._baseFontSize : 1) + : null; + } + + get maximalLineLength(): number | null { + if (!this._optimalLineLength) { + this._optimalLineLength = this.getOptimalLineLength(); + } + return this._maxMultiplier !== null + ? Math.round((this._optimalLineLength * this._maxMultiplier) + this._padding) / (this._getRelative ? this._baseFontSize : 1) + : null; + } + + get optimalLineLength(): number { + if (!this._optimalLineLength) { + this._optimalLineLength = this.getOptimalLineLength(); + } + return Math.round(this._optimalLineLength + this._padding) / (this._getRelative ? this._baseFontSize : 1); + } + + get all(): ILineLengths { + if (!this._optimalLineLength) { + this._optimalLineLength = this.getOptimalLineLength(); + } + return { + min: this.minimalLineLength, + user: this.userLineLength, + max: this.maximalLineLength, + optimal: this.optimalLineLength, + baseFontSize: this._baseFontSize + } + } + + private static approximateWordSpaces(chars: number, sample: string | null | undefined) { + let wordSpaces = 0; + if (sample && sample.length >= chars) { + const spaceCount = sample.match(/([\s]+)/gi); + // Average for number of chars + wordSpaces = (spaceCount ? spaceCount.length : 0) * (chars / sample.length); + } + return wordSpaces; + } + + private getLineLengthFallback() { + const letterSpace = this._letterSpacing * (this._optimalChars - 1); + const wordSpace = this._wordSpacing * this._approximatedWordSpaces; + return (this._optimalChars * (this._baseFontSize * 0.5)) + letterSpace + wordSpace; + } + + private getOptimalLineLength() { + if (this._fontFace) { + // We know the font and can use canvas as a proxy + // to get the optimal width for the number of characters + if (typeof this._fontFace === "string") { + return this.measureText(this._fontFace); + } else { + const customFont = new FontFace(this._fontFace.name, `url(${this._fontFace.url})`); + customFont.load().then( + () => { + document.fonts.add(customFont); + return this.measureText(customFont.family) + }, + (_err) => {}); + } + } + + return this.getLineLengthFallback(); + } + + private measureText(fontFace: string | null) { + // Note: We don’t clear the canvas since we’re not filling it, just measuring + const ctx: CanvasRenderingContext2D | null = this._canvas.getContext("2d"); + if (ctx && fontFace) { + // ch based on 0, ic based on water ideograph + let txt = this._isCJK ? "水".repeat(this._optimalChars) : "0".repeat(this._optimalChars); + ctx.font = `${this._baseFontSize}px ${fontFace}`; + + if (this._sample && this._sample.length >= this._optimalChars) { + txt = this._sample.slice(0, this._optimalChars); + } + + // Not supported in Safari + if (Object.hasOwn(ctx, "letterSpacing") && Object.hasOwn(ctx, "wordSpacing")) { + ctx.letterSpacing = this._letterSpacing.toString() + "px"; + ctx.wordSpacing = this._wordSpacing.toString() + "px"; + return ctx.measureText(txt).width; + } else { + // Instead of filling text with an offset for each character and space + // We simply add them to the measured width since we don’t need high accuracy + const letterSpace = this._letterSpacing * (this._optimalChars - 1); + const wordSpace = this._wordSpacing * LineLengths.approximateWordSpaces(this._optimalChars, this._sample); + return ctx.measureText(txt).width + letterSpace + wordSpace; + } + } else { + return this.getLineLengthFallback(); + } + } +} \ No newline at end of file diff --git a/navigator/src/index.ts b/navigator/src/index.ts index c87af6f1..d00f7fc9 100644 --- a/navigator/src/index.ts +++ b/navigator/src/index.ts @@ -1,4 +1,5 @@ export * from './Navigator'; export * from './epub'; export * from './audio'; -export * from './helpers'; \ No newline at end of file +export * from './helpers'; +export * from './preferences'; \ No newline at end of file diff --git a/navigator/src/preferences/Configurable.ts b/navigator/src/preferences/Configurable.ts new file mode 100644 index 00000000..b326ec13 --- /dev/null +++ b/navigator/src/preferences/Configurable.ts @@ -0,0 +1,16 @@ +import { IPreferencesEditor } from "./PreferencesEditor"; + +export interface ConfigurableSettings { + [key: string]: any; +} + +export interface ConfigurablePreferences { + [key: string]: any; + merging(other: ConfigurablePreferences): ConfigurablePreferences; +} + +export interface Configurable { + settings: ConfigurableSettings; + submitPreferences(preferences: ConfigurablePreferences): void; + preferencesEditor: IPreferencesEditor; +} \ No newline at end of file diff --git a/navigator/src/preferences/Preference.ts b/navigator/src/preferences/Preference.ts new file mode 100644 index 00000000..00603c21 --- /dev/null +++ b/navigator/src/preferences/Preference.ts @@ -0,0 +1,272 @@ +export interface IPreference { + /** + * The current value of the preference. + */ + value: T | null | undefined; + + /** + * The value that will be effectively used by the Configurable object if preferences are submitted as they are. + */ + effectiveValue: T | null | undefined; + + /** + * Indicates if this preference will be effectively used by the Configurable object if preferences are submitted as they are. + */ + isEffective: boolean; + + /** + * Unset the preference. + * Equivalent to set(null). + */ + clear(): void; +} + +export interface IBooleanPreference extends IPreference { + /** + * Toggle the preference to its opposite value. + */ + toggle(): void; +} + +export interface IEnumPreference extends IPreference { + /** + * The possible values for this preference. + */ + supportedValues: T[]; +} + +export interface IRangePreference extends IPreference { + /** + * The supported range [min, max] for this preference. + */ + supportedRange: [T, T]; + + /** + * The step value for the incrementing/decrementing into the range. + */ + step: number; + + /** + * Increase the preference value. + */ + increment(): void; + + /** + * Decrease the preference value. + */ + decrement(): void; + + /** + * Format the preference value as a string. + */ + format(value: T): string; +} + +export class Preference implements Preference { + protected _value?: T | null; + protected readonly _effectiveValue?: T | null; + protected readonly _isEffective: boolean; + + protected _onChange: (newValue: T | null | undefined) => void; + + constructor({ + initialValue = null, + effectiveValue, + isEffective, + onChange + } : { + initialValue?: T | null, + effectiveValue?: T | null, + isEffective: boolean, + onChange: (newValue: T | null | undefined) => void + }) { + this._value = initialValue; + this._effectiveValue = effectiveValue; + this._isEffective = isEffective; + this._onChange = onChange; + } + + set value(value: T | null | undefined) { + this._value = value; + this._onChange(this._value); + } + + get value(): T | null | undefined { + return this._value; + } + + get effectiveValue(): T | null | undefined { + return this._effectiveValue; + } + + get isEffective(): boolean { + return this._isEffective; + } + + clear(): void { + this._value = null; + } +} + +export class BooleanPreference extends Preference implements IBooleanPreference { + set value(value: boolean | null | undefined) { + this._value = value; + this._onChange(this._value); + } + + get value(): boolean | null | undefined { + return this._value; + } + + get effectiveValue(): boolean | null | undefined { + return this._effectiveValue; + } + + get isEffective(): boolean { + return this._isEffective; + } + + clear(): void { + this._value = null; + } + + toggle(): void { + this._value = !this._value; + this._onChange(this._value); + } +} + +export class EnumPreference extends Preference implements IEnumPreference { + private readonly _supportedValues: T[]; + + constructor({ + initialValue = null, + effectiveValue, + isEffective, + onChange, + supportedValues + } : { + initialValue?: T | null, + effectiveValue?: T | null, + isEffective: boolean, + onChange: (newValue: T | null | undefined) => void, + supportedValues: T[] + }) { + super({ initialValue, effectiveValue, isEffective, onChange }); + this._supportedValues = supportedValues; + } + + set value(value: T | null | undefined) { + if (value && !this._supportedValues.includes(value)) { + throw new Error(`Value '${ String(value) }' is not in the supported values for this preference.`); + } + this._value = value; + this._onChange(this._value); + } + + get value(): T | null | undefined { + return this._value; + } + + get effectiveValue(): T | null | undefined { + return this._effectiveValue; + } + + get isEffective(): boolean { + return this._isEffective; + } + + get supportedValues(): T[] { + return this._supportedValues; + } + + clear(): void { + this._value = null; + } +} + +export class RangePreference extends Preference implements IRangePreference { + private readonly _supportedRange: [T, T]; + private readonly _step: number; + private readonly _decimals: number; + + constructor({ + initialValue = null, + effectiveValue, + isEffective, + onChange, + supportedRange, + step + } : { + initialValue?: T | null, + effectiveValue?: T | null, + isEffective: boolean, + onChange: (newValue: T | null | undefined) => void, + supportedRange: [T, T], + step: number + } + ) { + super({ initialValue, effectiveValue, isEffective, onChange }); + this._supportedRange = supportedRange; + this._step = step; + this._decimals = this._step.toString().includes('.') + ? this._step.toString().split('.')[1].length + : 0; + } + + set value(value: T | null | undefined) { + if (value && (value < this._supportedRange[0] || value > this._supportedRange[1])) { + throw new Error(`Value '${ String(value) }' is out of the supported range for this preference.`); + } + this._value = value; + this._onChange(this._value); + } + + get value(): T | null | undefined { + return this._value; + } + + get effectiveValue(): T | null | undefined { + return this._effectiveValue; + } + + get isEffective(): boolean { + return this._isEffective; + } + + get supportedRange(): [T, T] { + return this._supportedRange; + } + + get step(): number { + return this._step; + } + + increment(): void { + if (this._value && this._value < this._supportedRange[1]) { + this._value = Math.min( + Math.round((this._value + this._step) * 10 ** this._decimals) / 10 ** this._decimals, + this._supportedRange[1] + ) as T; + this._onChange(this._value); + } + } + + decrement(): void { + if (this._value && this._value > this._supportedRange[0]) { + this._value = Math.max( + Math.round((this._value - this._step) * 10 ** this._decimals) / 10 ** this._decimals, + this._supportedRange[0] + ) as T; + this._onChange(this._value); + } + } + + format(value: T): string { + return value.toString(); + } + + clear(): void { + this._value = null; + } +} \ No newline at end of file diff --git a/navigator/src/preferences/PreferencesEditor.ts b/navigator/src/preferences/PreferencesEditor.ts new file mode 100644 index 00000000..218beea2 --- /dev/null +++ b/navigator/src/preferences/PreferencesEditor.ts @@ -0,0 +1,6 @@ +import { ConfigurablePreferences } from "./Configurable"; + +export interface IPreferencesEditor { + preferences: ConfigurablePreferences; + clear(): void; +} \ No newline at end of file diff --git a/navigator/src/preferences/Types.ts b/navigator/src/preferences/Types.ts new file mode 100644 index 00000000..b655a186 --- /dev/null +++ b/navigator/src/preferences/Types.ts @@ -0,0 +1,39 @@ +export enum TextAlignment { + start = "start", + left = "left", + right = "right", + justify = "justify" +}; + +export enum Theme { + day = "day", + sepia = "sepia", + night = "night", + custom = "custom" +} + +export enum LayoutStrategy { + margin = "margin", + lineLength = "lineLength", + columns = "columns" +} + +export type RangeConfig = { + range: [number, number], + step: number +} + +export const fontSizeRangeConfig: RangeConfig = { + range: [0.7, 2.5], + step: 0.05 +} + +export const fontWeightRangeConfig: RangeConfig = { + range: [100, 1000], + step: 100 +} + +export const fontWidthRangeConfig: RangeConfig = { + range: [50, 250], + step: 10 +} \ No newline at end of file diff --git a/navigator/src/preferences/index.ts b/navigator/src/preferences/index.ts new file mode 100644 index 00000000..1d4c1cea --- /dev/null +++ b/navigator/src/preferences/index.ts @@ -0,0 +1,4 @@ +export * from "./Configurable"; +export * from "./Preference"; +export * from "./PreferencesEditor"; +export * from "./Types"; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6c84a27..3795523f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^0.5.25 version: 0.5.25(vite@4.5.5(@types/node@20.4.8)(less@4.2.0)(sass@1.81.0)(stylus@0.62.0)) '@readium/css': - specifier: ^1.1.0 - version: 1.1.0 + specifier: '>=2.0.0-beta.5' + version: 2.0.0-beta.5 '@readium/navigator-html-injectables': specifier: workspace:* version: link:../navigator-html-injectables @@ -56,9 +56,6 @@ importers: navigator-html-injectables: devDependencies: - '@juggle/resize-observer': - specifier: ^3.4.0 - version: 3.4.0 '@readium/shared': specifier: workspace:* version: link:../shared @@ -1241,6 +1238,9 @@ packages: '@readium/css@1.1.0': resolution: {integrity: sha512-vcUx/+UYlWXuG6ioZNVBFDlKCuyH+65x9dNJM9jLlA8yT5ReH0k2UR9DN8cwx5/BgJhoQLUsA9s2DPhGaMhX6A==} + '@readium/css@2.0.0-beta.5': + resolution: {integrity: sha512-TOyrzv2BhePNA6LYqpjkSYTjWtJPRjcFGJqyJt+N5kUaVysb9Oxmr8E78RidG2VGxu2lXGbaiqPKmFzm2NtnAg==} + '@sinclair/typebox@0.24.51': resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} @@ -3970,6 +3970,8 @@ snapshots: '@readium/css@1.1.0': {} + '@readium/css@2.0.0-beta.5': {} + '@sinclair/typebox@0.24.51': {} '@sinclair/typebox@0.27.8': {}