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': {}