diff --git a/injectables/style/style.css b/injectables/style/style.css index 992d5a23..cc20c540 100644 --- a/injectables/style/style.css +++ b/injectables/style/style.css @@ -21,14 +21,99 @@ --RS__highlightColor: rgba(255, 255, 0, 0.5); --RS__highlightMixBlendMode: multiply; --RS__highlightHoverColor: rgba(255, 255, 0, 0.75); + --RS__underlineBorderColor: rgba(255, 255, 0, 1); + --RS__underlineHoverColor: rgba(255, 255, 0, 0.1); } -.R2_CLASS_HIGHLIGHT_AREA { +.R2_CLASS_HIGHLIGHT_AREA[data-marker="0"] { background-color: var(--RS__highlightColor) !important; mix-blend-mode: var(--RS__highlightMixBlendMode) !important; + z-index: 1; } -.R2_CLASS_HIGHLIGHT_AREA:hover, -.R2_CLASS_HIGHLIGHT_AREA.hover { +.R2_CLASS_HIGHLIGHT_AREA[data-marker="0"]:hover, +.R2_CLASS_HIGHLIGHT_AREA[data-marker="0"].hover { background-color: var(--RS__highlightHoverColor) !important; } +.R2_CLASS_HIGHLIGHT_AREA[data-marker="1"] { + border-bottom: 2px solid var(--RS__underlineBorderColor); + mix-blend-mode: var(--RS__highlightMixBlendMode) !important; + z-index: 1; +} +.R2_CLASS_HIGHLIGHT_AREA[data-marker="1"]:hover, +.R2_CLASS_HIGHLIGHT_AREA[data-marker="1"].hover { + background-color: var(--RS__underlineHoverColor) !important; +} + +[data-tts-current-word="true"][data-tts-color="orange"] { + background: rgba(255, 165, 0, 0.5) !important; + border-bottom: solid 2px rgb(255, 165, 0) !important; + z-index: 2; + position: relative; +} +[data-tts-current-line="true"][data-tts-color="orange"] { + background: rgba(255, 165, 0, 0.5) !important; + z-index: 2; + position: relative; +} + + +[data-tts-current-word="true"][data-tts-color="red"] { + background: rgba(255,0,0,0.5) !important; + border-bottom: solid 2px rgba(255,0,0) !important; + z-index: 2; + position: relative; +} +[data-tts-current-line="true"][data-tts-color="red"] { + background: rgba(255,0,0,0.5) !important; + z-index: 2; + position: relative; +} + +[data-tts-current-word="true"][data-tts-color="blue"] { + background: rgba(0,0,255,0.5) !important; + border-bottom: solid 2px rgba(0,0,255) !important; + z-index: 2; + position: relative; +} +[data-tts-current-line="true"][data-tts-color="blue"] { + background: rgba(0,0,255,0.5) !important; + z-index: 2; + position: relative; +} + +[data-tts-current-word="true"][data-tts-color="purple"] { + background: rgba(102,0,153,0.5) !important; + border-bottom: solid 2px rgba(102,0,153) !important; + z-index: 2; + position: relative; +} +[data-tts-current-line="true"][data-tts-color="purple"] { + background: rgba(102,0,153,0.5) !important; + z-index: 2; + position: relative; +} + +[data-tts-current-word="true"][data-tts-color="green"] { + background: rgba(0,102,0,0.5) !important; + border-bottom: solid 2px rgba(0,102,0) !important; + z-index: 2; + position: relative; +} +[data-tts-current-line="true"][data-tts-color="green"] { + background: rgba(0,102,0,0.5) !important; + z-index: 2; + position: relative; +} + +[data-tts-current-word="true"][data-tts-color="gray"] { + background: rgba(85,85,85,0.5) !important; + border-bottom: solid 2px rgba(85,85,85) !important; + z-index: 2; + position: relative; +} +[data-tts-current-line="true"][data-tts-color="gray"] { + background: rgba(85,85,85,0.5) !important; + z-index: 2; + position: relative; +} diff --git a/package-lock.json b/package-lock.json index 3bfab6d8..17446a07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@d-i-t-a/reader", - "version": "1.1.10", + "version": "1.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1753,9 +1753,9 @@ "dev": true }, "elliptic": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz", - "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", + "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", "dev": true, "requires": { "bn.js": "^4.4.0", @@ -3610,9 +3610,9 @@ } }, "lodash": { - "version": "4.17.19", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", "dev": true }, "lodash.get": { diff --git a/package.json b/package.json index 471d220b..19bd22e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@d-i-t-a/reader", - "version": "1.1.10", + "version": "1.2.0", "description": "A viewer application for EPUB files.", "repository": "https://github.com/d-i-t-a/R2D2BC", "license": "Apache-2.0", diff --git a/src/index.ts b/src/index.ts index fd830806..dec27c2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,10 +24,12 @@ import Publication from "./model/Publication"; import BookmarkModule from "./modules/BookmarkModule"; import { UserSettings } from "./model/user-settings/UserSettings"; import AnnotationModule from "./modules/AnnotationModule"; -import TTSModule from "./modules/TTSModule"; +import TTSModule from "./modules/TTS/TTSModule"; import {oc} from "ts-optchain" +import { TTSSettings } from "./modules/TTS/TTSSettings"; var R2Settings: UserSettings; +var R2TTSSettings: TTSSettings; var R2Navigator: IFrameNavigator; var BookmarkModuleInstance: BookmarkModule; var AnnotationModuleInstance: AnnotationModule; @@ -41,6 +43,9 @@ export async function unload() { document.body.onscroll = () => { } R2Navigator.stop() R2Settings.stop() + if (oc(R2Navigator.rights).enableTTS(false)) { + R2TTSSettings.stop() + } BookmarkModuleInstance.stop() AnnotationModuleInstance.stop() TTSModuleInstance.stop() @@ -62,34 +67,49 @@ export function resumeReadAloud() { return R2Navigator.resumeReadAloud() } - export async function saveBookmark() { - if (IS_DEV) { console.log("saveBookmark") } - BookmarkModuleInstance.saveBookmark() + if (oc(R2Navigator.rights).enableBookmarks(false)) { + if (IS_DEV) { console.log("saveBookmark") } + BookmarkModuleInstance.saveBookmark() + } } export async function deleteBookmark(bookmark) { - if (IS_DEV) { console.log("deleteBookmark") } - BookmarkModuleInstance.deleteBookmark(bookmark) + if (oc(R2Navigator.rights).enableBookmarks(false)) { + if (IS_DEV) { console.log("deleteBookmark") } + BookmarkModuleInstance.deleteBookmark(bookmark) + } } export async function deleteAnnotation(highlight) { - if (IS_DEV) { console.log("deleteAnnotation") } - AnnotationModuleInstance.deleteAnnotation(highlight) + if (oc(R2Navigator.rights).enableAnnotations(false)) { + if (IS_DEV) { console.log("deleteAnnotation") } + AnnotationModuleInstance.deleteAnnotation(highlight) + } } export async function addAnnotation(highlight) { - if (IS_DEV) { console.log("addAnnotation") } - AnnotationModuleInstance.addAnnotation(highlight) + if (oc(R2Navigator.rights).enableAnnotations(false)) { + if (IS_DEV) { console.log("addAnnotation") } + AnnotationModuleInstance.addAnnotation(highlight) + } } export async function tableOfContents() { if (IS_DEV) { console.log("bookmarks") } return await R2Navigator.tableOfContents() } export async function bookmarks() { - if (IS_DEV) { console.log("bookmarks") } - return await BookmarkModuleInstance.getBookmarks() + if (oc(R2Navigator.rights).enableBookmarks(false)) { + if (IS_DEV) { console.log("bookmarks") } + return await BookmarkModuleInstance.getBookmarks() + } else { + return [] + } } export async function annotations() { - if (IS_DEV) { console.log("annotations") } - return await AnnotationModuleInstance.getAnnotations() + if (oc(R2Navigator.rights).enableAnnotations(false)) { + if (IS_DEV) { console.log("annotations") } + return await AnnotationModuleInstance.getAnnotations() + } else { + return [] + } } export function currentResource() { if (IS_DEV) { console.log("currentResource") } @@ -103,6 +123,10 @@ export function totalResources() { if (IS_DEV) { console.log("totalResources") } return R2Navigator.totalResources() } +export function publicationLanguage() { + if (IS_DEV) { console.log("publicationLanguage") } + return R2Navigator.publication.metadata.language +} export async function resetUserSettings() { if (IS_DEV) { console.log("resetSettings") } R2Settings.resetUserSettings() @@ -111,18 +135,57 @@ export async function applyUserSettings(userSettings) { if (IS_DEV) { console.log("applyUserSettings") } R2Settings.applyUserSettings(userSettings) } +export async function currentSettings() { + if (IS_DEV) { console.log("currentSettings") } + return R2Settings.currentSettings() +} export async function increase(incremental) { - if (IS_DEV) { console.log("increase " + incremental) } - R2Settings.increase(incremental) + if ((incremental == "pitch" || incremental == "rate" || incremental == "volume") && oc(R2Navigator.rights).enableTTS(false) ) { + if (IS_DEV) { console.log("increase " + incremental) } + R2TTSSettings.increase(incremental) + } else { + if (IS_DEV) { console.log("increase " + incremental) } + R2Settings.increase(incremental) + } } export async function decrease(incremental) { - if (IS_DEV) { console.log("decrease " + incremental) } - R2Settings.decrease(incremental) + if ((incremental == "pitch" || incremental == "rate" || incremental == "volume") && oc(R2Navigator.rights).enableTTS(false) ) { + if (IS_DEV) { console.log("decrease " + incremental) } + R2TTSSettings.decrease(incremental) + } else { + if (IS_DEV) { console.log("decrease " + incremental) } + R2Settings.decrease(incremental) + } } export async function publisher(on) { if (IS_DEV) { console.log("publisher " + on) } R2Settings.publisher(on) } +export async function resetTTSSettings() { + if (oc(R2Navigator.rights).enableTTS(false)) { + if (IS_DEV) { console.log("resetSettings") } + R2TTSSettings.resetTTSSettings() + } +} +export async function applyTTSSettings(ttsSettings) { + if (oc(R2Navigator.rights).enableTTS(false)) { + if (IS_DEV) { console.log("applyTTSSettings") } + R2TTSSettings.applyTTSSettings(ttsSettings) + } +} + +export async function ttsSet(key, value) { + if (oc(R2Navigator.rights).enableTTS(false)) { + if (IS_DEV) { console.log("set " + key + " value " + value) } + R2TTSSettings.ttsSet(key, value) + } +} +export async function preferredVoice(value) { + if (oc(R2Navigator.rights).enableTTS(false)) { + R2TTSSettings.preferredVoice(value) + } +} + export async function goTo(locator) { if (IS_DEV) { console.log("goTo " + locator) } @@ -164,7 +227,6 @@ export async function load(config: ReaderConfig): Promise { useLocalStorage: config.useLocalStorage }); - var annotator = new LocalAnnotator({ store: store }); var upLink: UpLinkConfig @@ -174,6 +236,7 @@ export async function load(config: ReaderConfig): Promise { const publication: Publication = await Publication.getManifest(webpubManifestUrl, store); + // Settings R2Settings = await UserSettings.create({ store: settingsStore, initialUserSettings: config.userSettings, @@ -182,6 +245,7 @@ export async function load(config: ReaderConfig): Promise { api: config.api }) + // Navigator R2Navigator = await IFrameNavigator.create({ mainElement: mainElement, headerMenu: headerMenu, @@ -194,13 +258,15 @@ export async function load(config: ReaderConfig): Promise { initialLastReadingPosition: config.lastReadingPosition, material: config.material, api: config.api, + rights: config.rights, + tts: config.tts, injectables: config.injectables, selectionMenuItems: config.selectionMenuItems, initialAnnotationColor: config.initialAnnotationColor }) - // add custom modules + // Bookmark Module - if (oc(config.rights).enableBookmarks) { + if (oc(config.rights).enableBookmarks(false)) { BookmarkModuleInstance = await BookmarkModule.create({ annotator: annotator, headerMenu: headerMenu, @@ -213,7 +279,7 @@ export async function load(config: ReaderConfig): Promise { } // Annotation Module - if (oc(config.rights).enableAnnotations) { + if (oc(config.rights).enableAnnotations(false)) { AnnotationModuleInstance = await AnnotationModule.create({ annotator: annotator, headerMenu: headerMenu, @@ -223,9 +289,19 @@ export async function load(config: ReaderConfig): Promise { delegate: R2Navigator, initialAnnotations: config.initialAnnotations }) - TTSModuleInstance = await TTSModule.create({ - annotationModule: AnnotationModuleInstance - }) + // TTS Module + if (oc(config.rights).enableTTS(false)) { + R2TTSSettings = await TTSSettings.create({ + store: settingsStore, + initialTTSSettings: config.tts, + headerMenu: headerMenu, + api:config.tts.api + }) + TTSModuleInstance = await TTSModule.create({ + annotationModule: AnnotationModuleInstance, + tts: R2TTSSettings + }) + } } return new Promise(resolve => resolve(R2Navigator)); @@ -246,6 +322,9 @@ exports.resetUserSettings = function () { exports.applyUserSettings = function (userSettings) { applyUserSettings(userSettings) } +exports.currentSettings = function () { + return currentSettings() +} exports.increase = function (incremental) { increase(incremental) } @@ -269,6 +348,19 @@ exports.resumeReadAloud = function () { resumeReadAloud() } +exports.applyTTSSettings = function (ttsSettings) { + applyTTSSettings(ttsSettings) +} +exports.ttsSet = function (key, value) { + ttsSet(key, value) +} +exports.preferredVoice = function (value) { + preferredVoice(value) +} +exports.resetTTSSettings = function () { + resetTTSSettings() +} + // - add bookmark // - delete bookmark exports.saveBookmark = function () { @@ -332,3 +424,6 @@ exports.mostRecentNavigatedTocItem = function() { exports.totalResources = function() { return totalResources() } +exports.publicationLanguage = function() { + return publicationLanguage() +} \ No newline at end of file diff --git a/src/model/Locator.ts b/src/model/Locator.ts index e39867c9..57814a84 100644 --- a/src/model/Locator.ts +++ b/src/model/Locator.ts @@ -68,6 +68,7 @@ export interface ISelectionInfo { cleanText: string; rawText: string; color: string; + range: Range; } export interface IRangeInfo { diff --git a/src/model/user-settings/UserProperties.ts b/src/model/user-settings/UserProperties.ts index 0a5d0c64..090576d5 100644 --- a/src/model/user-settings/UserProperties.ts +++ b/src/model/user-settings/UserProperties.ts @@ -29,6 +29,39 @@ export class UserProperty { } +export class Stringable extends UserProperty { + + constructor(value: any, ref: string, name: string) { + super(); + this.value = value + this.ref = ref + this.name = name + } + + toString(): string { + return this.value + } + +} + +export class JSONable extends UserProperty { + + constructor(value: string, ref: string, name: string) { + super(); + this.value = value + this.ref = ref + this.name = name + } + + toString(): string { + return this.value + } + toJson(): any { + return JSON.parse(this.value) + } + +} + export class Enumerable extends UserProperty { values: Array; @@ -114,6 +147,13 @@ export class UserProperties { this.properties.push(new Incremental(nValue, min, max, step, suffix, ref, key)) } + addStringable(nValue: string, ref: string, key: string) { + this.properties.push(new Stringable(nValue, ref, key)) + } + addJSONable(nValue: string, ref: string, key: string) { + this.properties.push(new JSONable(nValue, ref, key)) + } + addSwitchable(onValue: string, offValue: string, on: boolean, ref: string, key: string) { this.properties.push(new Switchable(onValue, offValue, on, ref, key)) } diff --git a/src/model/user-settings/UserSettings.ts b/src/model/user-settings/UserSettings.ts index b4e376fb..f82d265d 100644 --- a/src/model/user-settings/UserSettings.ts +++ b/src/model/user-settings/UserSettings.ts @@ -57,7 +57,7 @@ export interface UserSettings { fontOverride: boolean fontFamily: number appearance: any - verticalScroll: boolean + verticalScroll: any //Advanced settings publisherDefaults: boolean @@ -71,10 +71,19 @@ export interface UserSettings { export class UserSettings implements UserSettings { + async isPaginated() { + let scroll = ( await this.getProperty(ReadiumCSS.SCROLL_KEY) != null) ? ( await this.getProperty(ReadiumCSS.SCROLL_KEY) as Switchable).value : 0 + return scroll === 1 + } + async isScrollmode() { + let scroll = ( await this.getProperty(ReadiumCSS.SCROLL_KEY) != null) ? ( await this.getProperty(ReadiumCSS.SCROLL_KEY) as Switchable).value : 0 + return scroll === 0 + } private readonly store: Store; private readonly USERSETTINGS = "userSetting"; + private static scrollValues = ["readium-scroll-on", "readium-scroll-off"] private static appearanceValues = ["readium-default-on", "readium-sepia-on", "readium-night-on"] private static fontFamilyValues = ["Original", "serif", "sans-serif"] private static readonly textAlignmentValues = ["auto", "justify", "start"] @@ -84,7 +93,7 @@ export class UserSettings implements UserSettings { fontOverride = false fontFamily = 0 appearance: any = 0 - verticalScroll = false + verticalScroll: any = 0 //Advanced settings publisherDefaults = true @@ -130,6 +139,10 @@ export class UserSettings implements UserSettings { if (config.initialUserSettings) { var initialUserSettings:UserSettings= config.initialUserSettings + if(initialUserSettings.verticalScroll) { + settings.verticalScroll = UserSettings.scrollValues.findIndex((el: any) => el === initialUserSettings.verticalScroll); + if (IS_DEV) console.log(settings.verticalScroll) + } if(initialUserSettings.appearance) { settings.appearance = UserSettings.appearanceValues.findIndex((el: any) => el === initialUserSettings.appearance); if (IS_DEV) console.log(settings.appearance) @@ -145,28 +158,6 @@ export class UserSettings implements UserSettings { settings.fontOverride = true } } - if(oc(initialUserSettings.verticalScroll)) { - settings.verticalScroll = initialUserSettings.verticalScroll; - if (IS_DEV) console.log(settings.verticalScroll) - let selectedView = settings.bookViews[0]; - var selectedViewName = 'scrolling-book-view' - if (settings.verticalScroll) { - selectedViewName = 'scrolling-book-view' - } else { - selectedViewName = 'columns-paginated-view' - } - - if (selectedViewName) { - for (const bookView of settings.bookViews) { - if (bookView.name === selectedViewName) { - selectedView = bookView; - break; - } - } - } - settings.selectedView = selectedView; - settings.store.set(ReadiumCSS.SCROLL_KEY, selectedView.name); - } if(initialUserSettings.textAlignment) { settings.textAlignment = UserSettings.textAlignmentValues.findIndex((el: any) => el === initialUserSettings.textAlignment); settings.publisherDefaults = false @@ -239,7 +230,7 @@ export class UserSettings implements UserSettings { private async reset() { this.appearance = 0 - this.verticalScroll = false + this.verticalScroll = 0 this.fontSize = 100.0 this.fontOverride = false this.fontFamily = 0 @@ -260,25 +251,21 @@ export class UserSettings implements UserSettings { if (this.headerMenu) this.settingsView = HTMLUtilities.findElement(this.headerMenu, "#container-view-settings") as HTMLDivElement; - if (oc(this.ui).settings.scroll) { - if (this.bookViews.length >= 1) { - let selectedView = this.bookViews[0]; - const selectedViewName = await this.store.get(ReadiumCSS.SCROLL_KEY); - if (selectedViewName) { - for (const bookView of this.bookViews) { - if (bookView.name === selectedViewName) { - selectedView = bookView; - break; - } - } + + + let selectedView = this.bookViews[this.verticalScroll]; + let scroll = UserSettings.scrollValues[this.verticalScroll]; + var selectedViewName = scroll + + if (selectedViewName) { + for (const bookView of this.bookViews) { + if (bookView.name === selectedViewName) { + selectedView = bookView; + break; } - this.selectedView = selectedView; } - } else { - let selectedView = this.bookViews[0]; - this.selectedView = selectedView; } - + this.selectedView = selectedView; } applyProperties(): any { @@ -347,13 +334,13 @@ export class UserSettings implements UserSettings { private renderControls(element: HTMLElement): void { - if (oc(this.ui).settings.fontSize) { + if (oc(this.ui).settings.fontSize(false)) { this.fontSizeButtons = {}; for (const fontSizeName of ["decrease", "increase"]) { this.fontSizeButtons[fontSizeName] = HTMLUtilities.findElement(element, "#" + fontSizeName + "-font") as HTMLButtonElement; } } - if (oc(this.ui).settings.fontFamily) { + if (oc(this.ui).settings.fontFamily(false)) { this.fontButtons = {}; this.fontButtons[0] = HTMLUtilities.findElement(element, "#publisher-font") as HTMLButtonElement; this.fontButtons[1] = HTMLUtilities.findElement(element, "#serif-font") as HTMLButtonElement; @@ -365,7 +352,7 @@ export class UserSettings implements UserSettings { } this.updateFontButtons(); } - if (oc(this.ui).settings.appearance) { + if (oc(this.ui).settings.appearance(false)) { this.themeButtons = {}; this.themeButtons[0] = HTMLUtilities.findElement(element, "#day-theme") as HTMLButtonElement; this.themeButtons[1] = HTMLUtilities.findElement(element, "#sepia-theme") as HTMLButtonElement; @@ -380,10 +367,15 @@ export class UserSettings implements UserSettings { HTMLUtilities.findRequiredElement(element, "#container-view-appearance").remove() } - if (oc(this.ui).settings.scroll) { + if (oc(this.ui).settings.scroll(false)) { this.viewButtons = {}; this.viewButtons[0] = HTMLUtilities.findElement(element, "#view-scroll") as HTMLButtonElement; this.viewButtons[1] = HTMLUtilities.findElement(element, "#view-paginated") as HTMLButtonElement; + // if (UserSettings.scrollValues.length > 3) { + // for (let index = 2; index < UserSettings.scrollValues.length; index++) { + // this.viewButtons[index] = HTMLUtilities.findElement(element, "#" + UserSettings.scrollValues[index] + "-theme") as HTMLButtonElement; + // } + // } this.updateViewButtons(); } else { // remove buttons @@ -408,7 +400,7 @@ export class UserSettings implements UserSettings { private async setupEvents(): Promise { - if (oc(this.ui).settings.fontSize) { + if (oc(this.ui).settings.fontSize(false)) { addEventListenerOptional(this.fontSizeButtons["decrease"], 'click', (event: MouseEvent) => { (this.userProperties.getByRef(ReadiumCSS.FONT_SIZE_REF) as Incremental).decrement() this.storeProperty(this.userProperties.getByRef(ReadiumCSS.FONT_SIZE_REF)) @@ -429,7 +421,7 @@ export class UserSettings implements UserSettings { }); } - if (oc(this.ui).settings.fontFamily) { + if (oc(this.ui).settings.fontFamily(false)) { for (let index = 0; index < UserSettings.fontFamilyValues.length; index++) { const button = this.fontButtons[index]; if (button) { @@ -445,7 +437,7 @@ export class UserSettings implements UserSettings { } } - if (oc(this.ui).settings.appearance) { + if (oc(this.ui).settings.appearance(false)) { for (let index = 0; index < UserSettings.appearanceValues.length; index++) { const button = this.themeButtons[index]; if (button) { @@ -460,9 +452,9 @@ export class UserSettings implements UserSettings { } } - if (oc(this.ui).settings.scroll) { - for (let index = 0; index < this.bookViews.length; index++) { - const view = this.bookViews[index]; + if (oc(this.ui).settings.scroll(false)) { + for (let index = 0; index < UserSettings.scrollValues.length; index++) { + const view = this.bookViews[this.bookViews.findIndex((el: any) => el.name === UserSettings.scrollValues[index])]; const button = this.viewButtons[index]; if (button) { addEventListenerOptional(button, 'click', (event: MouseEvent) => { @@ -472,7 +464,9 @@ export class UserSettings implements UserSettings { view.goToPosition(position) this.selectedView = view; this.updateViewButtons(); - this.storeSelectedView(view); + this.verticalScroll = UserSettings.scrollValues.findIndex((el: any) => el === view.name); + this.userProperties.getByRef(ReadiumCSS.SCROLL_REF).value = this.verticalScroll; + this.storeProperty(this.userProperties.getByRef(ReadiumCSS.SCROLL_REF)) this.viewChangeCallback(); event.preventDefault(); }); @@ -504,6 +498,8 @@ export class UserSettings implements UserSettings { } public getSelectedView(): BookView { + this.initializeSelections() + this.updateViewButtons() return this.selectedView; } @@ -512,11 +508,6 @@ export class UserSettings implements UserSettings { this.saveProperty(property) } - private async storeSelectedView(view: BookView): Promise { - this.updateUserSettings() - return this.store.set(ReadiumCSS.SCROLL_KEY, view.name); - } - addAppearance(appearance: string): any { UserSettings.appearanceValues.push(appearance) this.applyProperties() @@ -593,12 +584,6 @@ export class UserSettings implements UserSettings { } - // private async initProperties(list: string): Promise { - // let savedObj = JSON.parse(list); - // await this.store.set(this.USERSETTINGS, JSON.stringify(savedObj)); - // return new Promise(resolve => resolve(list)); - // } - private async saveProperty(property: any): Promise { let savedProperties = await this.store.get(this.USERSETTINGS); if (savedProperties) { @@ -614,16 +599,6 @@ export class UserSettings implements UserSettings { return new Promise(resolve => resolve(property)); } - // private async deleteProperty(property: any): Promise { - // let array = await this.store.get(this.USERSETTINGS); - // if (array) { - // let savedObj = JSON.parse(array) as Array; - // savedObj = savedObj.filter((el: any) => el.name !== property.name); - // await this.store.set(this.USERSETTINGS, JSON.stringify(savedObj)); - // } - // return new Promise(resolve => resolve(property)); - // } - // private async getProperties(): Promise { // let array = await this.store.get(this.USERSETTINGS); // if (array) { @@ -633,7 +608,7 @@ export class UserSettings implements UserSettings { // return new Promise(resolve => resolve()); // } - private async getProperty(name: string): Promise { + async getProperty(name: string): Promise { let array = await this.store.get(this.USERSETTINGS); if (array) { let properties = JSON.parse(array) as Array; @@ -653,8 +628,40 @@ export class UserSettings implements UserSettings { this.settingsChangeCallback(); } + async currentSettings() { + var userSettings = { + appearance: UserSettings.appearanceValues[this.userProperties.getByRef(ReadiumCSS.APPEARANCE_REF).value], //readium-default-on, readium-night-on, readium-sepia-on + fontFamily: UserSettings.fontFamilyValues[this.userProperties.getByRef(ReadiumCSS.FONT_FAMILY_REF).value], //Original, serif, sans-serif + textAlignment: UserSettings.textAlignmentValues[this.userProperties.getByRef(ReadiumCSS.TEXT_ALIGNMENT_REF).value], //"auto", "justify", "start" + columnCount: UserSettings.columnCountValues[this.userProperties.getByRef(ReadiumCSS.COLUMN_COUNT_REF).value], // "auto", "1", "2" + verticalScroll: UserSettings.scrollValues[this.userProperties.getByRef(ReadiumCSS.SCROLL_REF).value], //readium-scroll-on, readium-scroll-off, + fontSize: this.fontSize, + wordSpacing: this.wordSpacing, + letterSpacing: this.letterSpacing, + pageMargins: this.pageMargins, + lineHeight: this.lineHeight + } + return userSettings + } + async applyUserSettings(userSettings: UserSettings): Promise { + if (userSettings.verticalScroll) { + var verticalScroll + if (userSettings.verticalScroll == 'scroll') { + verticalScroll = UserSettings.scrollValues[0] + } else if (userSettings.verticalScroll == 'paginated') { + verticalScroll = UserSettings.scrollValues[1] + } else { + verticalScroll = userSettings.verticalScroll + } + this.verticalScroll = UserSettings.scrollValues.findIndex((el: any) => el === verticalScroll); + this.userProperties.getByRef(ReadiumCSS.SCROLL_REF).value = this.verticalScroll; + this.storeProperty(this.userProperties.getByRef(ReadiumCSS.SCROLL_REF)) + this.applyProperties() + this.viewChangeCallback(); + } + if (userSettings.appearance) { var appearance if (userSettings.appearance == 'day') { @@ -668,7 +675,7 @@ export class UserSettings implements UserSettings { } this.appearance = UserSettings.appearanceValues.findIndex((el: any) => el === appearance); this.userProperties.getByRef(ReadiumCSS.APPEARANCE_REF).value = this.appearance; - await this.saveProperty(this.userProperties.getByRef(ReadiumCSS.APPEARANCE_REF)) + this.storeProperty(this.userProperties.getByRef(ReadiumCSS.APPEARANCE_REF)) this.applyProperties() this.settingsChangeCallback(); } @@ -676,7 +683,7 @@ export class UserSettings implements UserSettings { if (userSettings.fontSize) { this.fontSize = userSettings.fontSize this.userProperties.getByRef(ReadiumCSS.FONT_SIZE_REF).value = this.fontSize; - await this.saveProperty(this.userProperties.getByRef(ReadiumCSS.FONT_SIZE_REF)) + this.storeProperty(this.userProperties.getByRef(ReadiumCSS.FONT_SIZE_REF)) this.userProperties.getByRef(ReadiumCSS.PUBLISHER_DEFAULT_REF).value = false this.storeProperty(this.userProperties.getByRef(ReadiumCSS.PUBLISHER_DEFAULT_REF)) this.applyProperties() @@ -686,7 +693,7 @@ export class UserSettings implements UserSettings { if (userSettings.fontFamily) { this.fontFamily = UserSettings.fontFamilyValues.findIndex((el: any) => el === userSettings.fontFamily); this.userProperties.getByRef(ReadiumCSS.FONT_FAMILY_REF).value = this.fontFamily; - await this.saveProperty(this.userProperties.getByRef(ReadiumCSS.FONT_FAMILY_REF)) + this.storeProperty(this.userProperties.getByRef(ReadiumCSS.FONT_FAMILY_REF)) this.applyProperties() this.settingsChangeCallback(); } @@ -694,7 +701,7 @@ export class UserSettings implements UserSettings { if (userSettings.letterSpacing) { this.letterSpacing = userSettings.letterSpacing this.userProperties.getByRef(ReadiumCSS.LETTER_SPACING_REF).value = this.letterSpacing; - await this.saveProperty(this.userProperties.getByRef(ReadiumCSS.LETTER_SPACING_REF)) + this.storeProperty(this.userProperties.getByRef(ReadiumCSS.LETTER_SPACING_REF)) this.userProperties.getByRef(ReadiumCSS.PUBLISHER_DEFAULT_REF).value = false this.storeProperty(this.userProperties.getByRef(ReadiumCSS.PUBLISHER_DEFAULT_REF)) this.applyProperties() @@ -704,7 +711,7 @@ export class UserSettings implements UserSettings { if (userSettings.wordSpacing) { this.wordSpacing = userSettings.wordSpacing this.userProperties.getByRef(ReadiumCSS.WORD_SPACING_REF).value = this.wordSpacing; - await this.saveProperty(this.userProperties.getByRef(ReadiumCSS.WORD_SPACING_REF)) + this.storeProperty(this.userProperties.getByRef(ReadiumCSS.WORD_SPACING_REF)) this.userProperties.getByRef(ReadiumCSS.PUBLISHER_DEFAULT_REF).value = false this.storeProperty(this.userProperties.getByRef(ReadiumCSS.PUBLISHER_DEFAULT_REF)) this.applyProperties() @@ -714,7 +721,7 @@ export class UserSettings implements UserSettings { if (userSettings.columnCount) { this.columnCount = UserSettings.columnCountValues.findIndex((el: any) => el === userSettings.columnCount); this.userProperties.getByRef(ReadiumCSS.COLUMN_COUNT_REF).value = this.columnCount; - await this.saveProperty(this.userProperties.getByRef(ReadiumCSS.COLUMN_COUNT_REF)) + this.storeProperty(this.userProperties.getByRef(ReadiumCSS.COLUMN_COUNT_REF)) this.applyProperties() this.settingsChangeCallback(); } @@ -722,7 +729,7 @@ export class UserSettings implements UserSettings { if (userSettings.textAlignment) { this.textAlignment = UserSettings.textAlignmentValues.findIndex((el: any) => el === userSettings.textAlignment); this.userProperties.getByRef(ReadiumCSS.TEXT_ALIGNMENT_REF).value = this.textAlignment; - await this.saveProperty(this.userProperties.getByRef(ReadiumCSS.TEXT_ALIGNMENT_REF)) + this.storeProperty(this.userProperties.getByRef(ReadiumCSS.TEXT_ALIGNMENT_REF)) this.userProperties.getByRef(ReadiumCSS.PUBLISHER_DEFAULT_REF).value = false this.storeProperty(this.userProperties.getByRef(ReadiumCSS.PUBLISHER_DEFAULT_REF)) this.applyProperties() @@ -732,7 +739,7 @@ export class UserSettings implements UserSettings { if (userSettings.lineHeight) { this.lineHeight = userSettings.lineHeight this.userProperties.getByRef(ReadiumCSS.LINE_HEIGHT_REF).value = this.lineHeight; - await this.saveProperty(this.userProperties.getByRef(ReadiumCSS.LINE_HEIGHT_REF)) + this.storeProperty(this.userProperties.getByRef(ReadiumCSS.LINE_HEIGHT_REF)) this.userProperties.getByRef(ReadiumCSS.PUBLISHER_DEFAULT_REF).value = false this.storeProperty(this.userProperties.getByRef(ReadiumCSS.PUBLISHER_DEFAULT_REF)) this.applyProperties() @@ -742,7 +749,7 @@ export class UserSettings implements UserSettings { if (userSettings.pageMargins) { this.pageMargins = userSettings.pageMargins this.userProperties.getByRef(ReadiumCSS.PAGE_MARGINS_REF).value = this.pageMargins; - await this.saveProperty(this.userProperties.getByRef(ReadiumCSS.PAGE_MARGINS_REF)) + this.storeProperty(this.userProperties.getByRef(ReadiumCSS.PAGE_MARGINS_REF)) this.applyProperties() this.settingsChangeCallback(); } @@ -753,11 +760,14 @@ export class UserSettings implements UserSettings { const position = this.selectedView.getCurrentPosition(); this.selectedView.stop(); let selectedView = this.bookViews[0]; - var selectedViewName = 'scrolling-book-view' + var verticalScroll + var selectedViewName = 'readium-scroll-on' if (scroll) { - selectedViewName = 'scrolling-book-view' + selectedViewName = 'readium-scroll-on' + verticalScroll = UserSettings.scrollValues[0] } else { - selectedViewName = 'columns-paginated-view' + selectedViewName = 'readium-scroll-off' + verticalScroll = UserSettings.scrollValues[1] } if (selectedViewName) { @@ -773,7 +783,10 @@ export class UserSettings implements UserSettings { selectedView.goToPosition(position) this.selectedView = selectedView; this.updateViewButtons(); - this.storeSelectedView(selectedView); + this.verticalScroll = UserSettings.scrollValues.findIndex((el: any) => el === verticalScroll); + this.userProperties.getByRef(ReadiumCSS.SCROLL_REF).value = this.verticalScroll; + this.saveProperty(this.userProperties.getByRef(ReadiumCSS.SCROLL_REF)) + this.applyProperties() this.viewChangeCallback(); } diff --git a/src/modules/AnnotationModule.ts b/src/modules/AnnotationModule.ts index 7bf43b4e..4fd7cb5d 100644 --- a/src/modules/AnnotationModule.ts +++ b/src/modules/AnnotationModule.ts @@ -73,7 +73,7 @@ export default class AnnotationModule implements ReaderModule { const annotations = new this( config.annotator, config.headerMenu, - config.rights || { enableAnnotations: false }, + config.rights || { enableAnnotations: false , enableTTS: false}, config.publication, config.settings, config.delegate, @@ -162,6 +162,7 @@ export default class AnnotationModule implements ReaderModule { var deleted = await this.annotator.deleteAnnotation(id); if (IS_DEV) {console.log("Highlight deleted " + JSON.stringify(deleted));} + await this.showHighlights(); await this.drawHighlights(); if (this.delegate.material) { toast({ html: 'highlight deleted' }) @@ -178,6 +179,7 @@ export default class AnnotationModule implements ReaderModule { } public async addAnnotation(highlight: Annotation): Promise { await this.annotator.saveAnnotation(highlight); + await this.showHighlights(); await this.drawHighlights(); } @@ -241,11 +243,13 @@ export default class AnnotationModule implements ReaderModule { this.api.addAnnotation(annotation).then(async result => { annotation.id = result.id var saved = await this.annotator.saveAnnotation(annotation); + await this.showHighlights(); await this.drawHighlights(); return saved }) } else { var saved = await this.annotator.saveAnnotation(annotation); + await this.showHighlights(); await this.drawHighlights(); return saved } @@ -274,7 +278,7 @@ export default class AnnotationModule implements ReaderModule { }) } } - this.createTree(AnnotationType.Annotation, highlights, this.highlightsView) + if (this.highlightsView) this.createTree(AnnotationType.Annotation, highlights, this.highlightsView) } async drawHighlights(): Promise { @@ -409,6 +413,7 @@ export default class AnnotationModule implements ReaderModule { title: linkElement.title }; + this.delegate.stopReadAloud(); this.delegate.navigate(position); }); @@ -425,16 +430,23 @@ export default class AnnotationModule implements ReaderModule { if (type == AnnotationType.Annotation) { bookmarkLink.className = "highlight-link" - bookmarkLink.innerHTML = IconLib.highlight let title: HTMLSpanElement = document.createElement("span"); let marker: HTMLSpanElement = document.createElement("span"); title.className = "title" marker.innerHTML = locator.highlight.selectionInfo.cleanText if ((locator as Annotation).marker == AnnotationMarker.Underline) { - marker.style.setProperty("border-bottom", `2px solid ${TextHighlighter.hexToRgbA((locator as Annotation).color)}`, "important"); + if (typeof (locator as Annotation).color === 'object') { + marker.style.setProperty("border-bottom", `2px solid ${TextHighlighter.hexToRgbA((locator as Annotation).color)}`, "important"); + } else { + marker.style.setProperty("border-bottom", `2px solid ${(locator as Annotation).color}`, "important"); + } } else { - marker.style.backgroundColor = TextHighlighter.hexToRgbA((locator as Annotation).color); + if (typeof (locator as Annotation).color === 'object') { + marker.style.backgroundColor = TextHighlighter.hexToRgbA((locator as Annotation).color); + } else { + marker.style.backgroundColor = (locator as Annotation).color; + } } title.appendChild(marker) bookmarkLink.appendChild(title) @@ -496,6 +508,7 @@ export default class AnnotationModule implements ReaderModule { if (locator) { const linkHref = this.publication.getAbsoluteHref(locator.href); locator.href = linkHref + this.delegate.stopReadAloud(); this.delegate.navigate(locator); } else { if (IS_DEV) {console.log('annotation data missing: ', event);} diff --git a/src/modules/BookmarkModule.ts b/src/modules/BookmarkModule.ts index fff14d0b..c9ac2e68 100644 --- a/src/modules/BookmarkModule.ts +++ b/src/modules/BookmarkModule.ts @@ -279,6 +279,7 @@ export default class BookmarkModule implements ReaderModule { title: linkElement.title }; + this.delegate.stopReadAloud(); this.delegate.navigate(position); }); @@ -295,7 +296,6 @@ export default class BookmarkModule implements ReaderModule { if (type == AnnotationType.Bookmark) { bookmarkLink.className = "bookmark-link" - bookmarkLink.innerHTML = IconLib.bookmark let title: HTMLSpanElement = document.createElement("span"); let formattedProgression = Math.round(locator.locations.progression!! * 100) + "% " + "through resource" @@ -354,6 +354,7 @@ export default class BookmarkModule implements ReaderModule { if (locator) { const linkHref = this.publication.getAbsoluteHref(locator.href); locator.href = linkHref + this.delegate.stopReadAloud(); this.delegate.navigate(locator); } else { if (IS_DEV) { console.log('bookmark data missing: ', event); } diff --git a/src/modules/TTS/TTSModule.ts b/src/modules/TTS/TTSModule.ts new file mode 100644 index 00000000..5296aff2 --- /dev/null +++ b/src/modules/TTS/TTSModule.ts @@ -0,0 +1,467 @@ +/* + * Copyright 2018-2020 DITA (AM Consulting LLC) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Developed on behalf of: CAST (http://www.cast.org) and DITA + * Licensed to: Bokbasen AS and CAST under one or more contributor license agreements. + */ + +import ReaderModule from "../ReaderModule"; +import AnnotationModule from "../AnnotationModule"; +import { IS_DEV } from "../.."; +import { ISelectionInfo } from "../../model/Locator"; +import { TTSSettings, TTSVoice } from "./TTSSettings"; +import * as HTMLUtilities from "../../utils/HTMLUtilities"; +import { addEventListenerOptional, removeEventListenerOptional } from "../../utils/EventHandler"; +import { oc } from "ts-optchain"; + +export interface TTSModuleConfig { + annotationModule: AnnotationModule; + tts: TTSSettings +} + +export interface TTSSpeechConfig { + enableSplitter?: boolean; + color?: string; + autoScroll?: boolean; + rate?: number; + pitch?: number; + volume?: number; + voice?: TTSVoice; +} + +export default class TTSModule implements ReaderModule { + + annotationModule: AnnotationModule; + tts: TTSSettings; + body: any + splittingResult: any[] + voices: SpeechSynthesisVoice[] = [] + + initialize(body: any) { + if (this.annotationModule.highlighter !== undefined) { + this.annotationModule.highlighter.ttsDelegate = this + this.tts.setControls(); + this.tts.onSettingsChange(this.handleResize.bind(this)); + this.body = body + let splittingResult = body.querySelectorAll("[data-word]"); + splittingResult.forEach(splittingWord => { + splittingWord.dataset.ttsColor = this.tts.color + }); + let whitespace = body.querySelectorAll("[data-whitespace]"); + whitespace.forEach(splittingWord => { + splittingWord.dataset.ttsColor = this.tts.color + }); + + this.initVoices(true); + + addEventListenerOptional(document, 'wheel', this.wheel.bind(this)); + addEventListenerOptional(this.body, 'wheel', this.wheel.bind(this)); + addEventListenerOptional(document, 'keydown', this.wheel.bind(this)); + addEventListenerOptional(this.annotationModule.delegate.iframe.contentDocument, 'keydown', this.wheel.bind(this)); + } + } + + private initVoices(first:boolean) { + function setSpeech() { + return new Promise( + function (resolve, _reject) { + let synth = window.speechSynthesis; + let id; + + id = setInterval(() => { + if (synth.getVoices().length !== 0) { + resolve(synth.getVoices()); + clearInterval(id); + } + }, 10); + } + ); + } + + let s = setSpeech(); + s.then(async (voices: SpeechSynthesisVoice[]) => { + if (IS_DEV) console.log(voices); + this.voices = [] + voices.forEach(voice => { + if (voice.localService == true) { + this.voices.push(voice); + } + }); + if (IS_DEV) console.log(this.voices); + if (first) { + // preferred-languages + if (this.annotationModule.delegate.headerMenu) { + var preferredLanguageSelector = HTMLUtilities.findElement(this.annotationModule.delegate.headerMenu, "#preferred-languages") as HTMLSelectElement; + if (preferredLanguageSelector) { + this.voices.forEach(voice => { + var v = document.createElement("option") as HTMLOptionElement; + v.value = voice.name + ":" + voice.lang; + v.innerHTML = voice.name + " (" + voice.lang + ")"; + preferredLanguageSelector.add(v); + }); + } + } + } + }); + } + + cancel() { + if (window.speechSynthesis.speaking) { + if (this.tts.api && typeof this.tts.api.stopped === "function") this.tts.api.stopped() + this.userScrolled = false + window.speechSynthesis.cancel() + if (this.splittingResult && this.annotationModule.delegate.tts.enableSplitter) { + this.splittingResult.forEach(splittingWord => { + splittingWord.dataset.ttsCurrentWord = "false" + splittingWord.dataset.ttsCurrentLine = "false" + }); + } + } + } + + private handleResize(): void { + let splittingResult = this.body.querySelectorAll("[data-word]"); + splittingResult.forEach(splittingWord => { + splittingWord.dataset.ttsColor = this.tts.color + splittingWord.dataset.ttsCurrentWord = "false" + splittingWord.dataset.ttsCurrentLine = "false" + + }); + let whitespace = this.body.querySelectorAll("[data-whitespace]"); + whitespace.forEach(splittingWord => { + splittingWord.dataset.ttsColor = this.tts.color + splittingWord.dataset.ttsCurrentWord = "false" + splittingWord.dataset.ttsCurrentLine = "false" + }); + } + + async speak(selectionInfo: ISelectionInfo | undefined, node: any, partial: boolean, callback: () => void): Promise { + + if (this.tts.api && typeof this.tts.api.started === "function") this.tts.api.started() + + this.userScrolled = false + var self = this + + this.cancel() + + if (this.annotationModule.delegate.tts.enableSplitter) { + if (partial) { + var allWords = node.querySelectorAll("[data-word]"); + + var startNode = (selectionInfo as ISelectionInfo).range.startContainer.parentElement + if (startNode.tagName.toLowerCase() === "a") { + startNode = startNode.parentElement as HTMLElement + } + if (startNode.dataset == undefined) { + startNode = startNode.nextElementSibling as HTMLElement + } + + var endNode = (selectionInfo as ISelectionInfo).range.endContainer.parentElement + if (endNode.tagName.toLowerCase() === "a") { + endNode = endNode.parentElement as HTMLElement + } + if (endNode.dataset == undefined) { + endNode = endNode.previousElementSibling as HTMLElement + } + + var startWordIndex = parseInt(startNode.dataset.wordIndex) + var endWordIndex = parseInt(endNode.dataset.wordIndex) + 1 + + let array = Array.from(allWords) + this.splittingResult = array.slice(startWordIndex, endWordIndex) + } else { + this.splittingResult = node.querySelectorAll("[data-word]"); + } + } + const utterance = new SpeechSynthesisUtterance(selectionInfo.cleanText); + utterance.rate = this.tts.rate + utterance.pitch = this.tts.pitch + utterance.volume = this.tts.volume + + if (IS_DEV) console.log("this.tts.voice.lang", this.tts.voice.lang) + + var initialVoiceHasHyphen = true + if (this.tts.voice && this.tts.voice.lang) { + initialVoiceHasHyphen = (this.tts.voice.lang.indexOf("-") !== -1) + if (initialVoiceHasHyphen == false) { + this.tts.voice.lang = this.tts.voice.lang.replace("_", "-") + initialVoiceHasHyphen = true + } + } + if (IS_DEV) console.log("initialVoiceHasHyphen", initialVoiceHasHyphen) + if (IS_DEV) console.log("voices", this.voices) + var initialVoice = undefined + if (initialVoiceHasHyphen == true) { + initialVoice = (this.tts.voice && this.tts.voice.lang && this.tts.voice.name) ? this.voices.filter((v: any) => { + var lang = v.lang.replace("_", "-") + return lang == this.tts.voice.lang && v.name == this.tts.voice.name + })[0] : undefined + if (initialVoice == undefined) { + initialVoice = (this.tts.voice && this.tts.voice.lang) ? this.voices.filter((v: any) => v.lang.replace("_", "-") == this.tts.voice.lang)[0] : undefined + } + } else { + initialVoice = (this.tts.voice && this.tts.voice.lang && this.tts.voice.name) ? this.voices.filter((v: any) => { + return v.lang == this.tts.voice.lang && v.name == this.tts.voice.name + })[0] : undefined + if (initialVoice == undefined) { + initialVoice = (this.tts.voice && this.tts.voice.lang) ? this.voices.filter((v: any) => v.lang == this.tts.voice.lang)[0] : undefined + } + } + if (IS_DEV) console.log("initialVoice", initialVoice) + + var publicationVoiceHasHyphen = (self.annotationModule.delegate.publication.metadata.language[0].indexOf("-") !== -1) + if (IS_DEV) console.log("publicationVoiceHasHyphen", publicationVoiceHasHyphen) + var publicationVoice = undefined + if (publicationVoiceHasHyphen == true) { + publicationVoice = (this.tts.voice && this.tts.voice.usePublication) ? this.voices.filter((v: any) => { + var lang = v.lang.replace("_", "-") + return lang.startsWith(self.annotationModule.delegate.publication.metadata.language[0]) || lang.endsWith(self.annotationModule.delegate.publication.metadata.language[0].toUpperCase()) + })[0] : undefined + } else { + publicationVoice = (this.tts.voice && this.tts.voice.usePublication) ? this.voices.filter((v: any) => { + return v.lang.startsWith(self.annotationModule.delegate.publication.metadata.language[0]) || v.lang.endsWith(self.annotationModule.delegate.publication.metadata.language[0].toUpperCase()) + })[0] : undefined + } + if (IS_DEV) console.log("publicationVoice", publicationVoice) + + var defaultVoiceHasHyphen = (navigator.language.indexOf("-") !== -1) + if (IS_DEV) console.log("defaultVoiceHasHyphen", defaultVoiceHasHyphen) + var defaultVoice = undefined + if (defaultVoiceHasHyphen == true) { + defaultVoice = this.voices.filter((v: any) => { + var lang = v.lang.replace("_", "-") + return lang == navigator.language && v.localService == true + })[0] + } else { + defaultVoice = this.voices.filter((v: any) => { + return v.lang == navigator.language && v.localService == true + })[0] + } + if (IS_DEV) console.log("defaultVoice", defaultVoice) + + if (initialVoice) { + if (IS_DEV) console.log("initialVoice") + utterance.voice = initialVoice + } else if (publicationVoice) { + if (IS_DEV) console.log("publicationVoice") + utterance.voice = publicationVoice + } else if (defaultVoice) { + if (IS_DEV) console.log("defaultVoice") + utterance.voice = defaultVoice + } + utterance.lang = utterance.voice.lang + if (IS_DEV) console.log("utterance.voice.lang", utterance.voice.lang) + if (IS_DEV) console.log("utterance.lang", utterance.lang) + if (IS_DEV) console.log("navigator.language", navigator.language) + + window.speechSynthesis.speak(utterance); + + var index = 0 + var lastword = undefined + + utterance.onboundary = function (e: any) { + if (e.name === "sentence") { + if (IS_DEV) console.log("sentence boundary", e.charIndex, e.charLength, utterance.text.slice(e.charIndex, e.charIndex + e.charLength)); + } + if (e.name === "word") { + + function getWordAt(str, pos) { + // Perform type conversions. + str = String(str); + pos = Number(pos) >>> 0; + + // Search for the word's beginning and end. + var left = str.slice(0, pos + 1).search(/\S+$/), + right = str.slice(pos).search(/\s/); + + // The last word in the string is a special case. + if (right < 0) { + return str.slice(left); + } + + // Return the word, using the located bounds to extract it from the string. + return str.slice(left, right + pos); + } + const word = getWordAt(utterance.text, e.charIndex) + if (lastword == word) { + index-- + } + lastword = word + + if (self.annotationModule.delegate.tts.enableSplitter) { + + + processWord(word) + + } + } + } + + + + async function processWord(word) { + + var spokenWordCleaned = word.replace(/[^a-zA-Z0-9 ]/g, "") + if (IS_DEV) console.log("spokenWordCleaned", spokenWordCleaned); + + var splittingWord = self.splittingResult[index] as HTMLElement + var splittingWordCleaned = oc(splittingWord).innerText("").replace(/[^a-zA-Z0-9 ]/g, "") + if (IS_DEV) console.log("splittingWordCleaned", splittingWordCleaned); + + if (splittingWordCleaned.length == 0) { + index++ + splittingWord = self.splittingResult[index] as HTMLElement + splittingWordCleaned = oc(splittingWord).innerText("").replace(/[^a-zA-Z0-9 ]/g, "") + if (IS_DEV) console.log("splittingWordCleaned", splittingWordCleaned); + } + + if (splittingWord) { + + var isAnchorParent = splittingWord.parentElement.tagName.toLowerCase() === "a" + if (!isAnchorParent) { + + if (spokenWordCleaned.length > 0 && splittingWordCleaned.length > 0) { + + if (splittingWordCleaned.startsWith(spokenWordCleaned) || splittingWordCleaned.endsWith(spokenWordCleaned) + || spokenWordCleaned.startsWith(splittingWordCleaned) || spokenWordCleaned.endsWith(splittingWordCleaned)) { + + if (index > 0) { + let splittingResult = self.body.querySelectorAll("[data-word]"); + splittingResult.forEach(splittingWord => { + splittingWord.dataset.ttsColor = self.tts.color + splittingWord.dataset.ttsCurrentWord = "false" + splittingWord.dataset.ttsCurrentLine = "false" + + }); + let whitespace = self.body.querySelectorAll("[data-whitespace]"); + whitespace.forEach(splittingWord => { + splittingWord.dataset.ttsColor = self.tts.color + splittingWord.dataset.ttsCurrentWord = "false" + splittingWord.dataset.ttsCurrentLine = "false" + }); + + } + splittingWord.dataset.ttsCurrentWord = "true" + + const scroll = await self.isScrollmode() + if (scroll && self.tts.autoScroll && !self.userScrolled) { + splittingWord.scrollIntoView({ + block: "center", + behavior: "smooth", + }) + } + } else { + index++ + } + } else if (spokenWordCleaned.length == 0) { + index-- + } + } + index++ + } + + } + + utterance.onend = function () { + if (IS_DEV) console.log("utterance ended"); + self.annotationModule.highlighter.doneSpeaking() + if (self.annotationModule.delegate.tts.enableSplitter) { + + let splittingResult = self.body.querySelectorAll("[data-word]"); + splittingResult.forEach(splittingWord => { + splittingWord.dataset.ttsColor = self.tts.color + splittingWord.dataset.ttsCurrentWord = "false" + splittingWord.dataset.ttsCurrentLine = "false" + + }); + let whitespace = self.body.querySelectorAll("[data-whitespace]"); + whitespace.forEach(splittingWord => { + splittingWord.dataset.ttsColor = self.tts.color + splittingWord.dataset.ttsCurrentWord = "false" + splittingWord.dataset.ttsCurrentLine = "false" + }); + + } + if (self.tts.api && typeof self.tts.api.finished === "function") self.tts.api.finished() + } + callback() + + } + + async isScrollmode() { + var verticalScroll = await this.annotationModule.delegate.settings.isScrollmode() + return verticalScroll + } + + speakPause() { + if (window.speechSynthesis.speaking) { + if (this.tts.api && typeof this.tts.api.paused === "function") this.tts.api.paused() + this.userScrolled = false + window.speechSynthesis.pause() + } + } + + speakResume() { + if (window.speechSynthesis.speaking) { + if (this.tts.api && typeof this.tts.api.resumed === "function") this.tts.api.resumed() + this.userScrolled = false + window.speechSynthesis.resume() + } + } + + public static async create(config: TTSModuleConfig) { + const tts = new this( + config.annotationModule, + config.tts + ); + await tts.start(); + return tts; + } + + public constructor(annotationModule: AnnotationModule, tts: TTSSettings) { + this.annotationModule = annotationModule + this.tts = tts + } + + protected async start(): Promise { + this.annotationModule.delegate.ttsModule = this + } + + userScrolled = false + private wheel(event: KeyboardEvent | MouseEvent | TrackEvent): void { + if (event instanceof KeyboardEvent) { + const key = event.key; + switch (key) { + case "ArrowUp": + this.userScrolled = true + break; + case "ArrowDown": + this.userScrolled = true + break; + } + } else { + this.userScrolled = true + } + } + + async stop() { + if (IS_DEV) { console.log("TTS module stop") } + removeEventListenerOptional(document, 'wheel', this.wheel.bind(this)); + removeEventListenerOptional(this.body, 'wheel', this.wheel.bind(this)); + removeEventListenerOptional(document, 'keydown', this.wheel.bind(this)); + removeEventListenerOptional(this.annotationModule.delegate.iframe.contentDocument, 'keydown', this.wheel.bind(this)); + } + +} \ No newline at end of file diff --git a/src/modules/TTS/TTSSettings.ts b/src/modules/TTS/TTSSettings.ts new file mode 100644 index 00000000..d9fc0fd0 --- /dev/null +++ b/src/modules/TTS/TTSSettings.ts @@ -0,0 +1,445 @@ +/* + * Copyright 2018-2020 DITA (AM Consulting LLC) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Developed on behalf of: CAST (http://www.cast.org) + * Licensed to: Bokbasen AS and CAST under one or more contributor license agreements. + */ + +import Store from "../../store/Store"; +import { UserProperty, UserProperties, Stringable, Switchable, Incremental, JSONable } from "../../model/user-settings/UserProperties"; +import * as HTMLUtilities from "../../utils/HTMLUtilities"; +import { IS_DEV } from "../.."; +import { addEventListenerOptional } from "../../utils/EventHandler"; +import { TTSSpeechConfig } from "./TTSModule"; + +export class TTSREFS { + + static readonly COLOR_REF = "color" + static readonly AUTO_SCROLL_REF = "autoscroll" + static readonly RATE_REF = "rate" + static readonly PITCH_REF = "pitch" + static readonly VOLUME_REF = "volume" + static readonly VOICE_REF = "voice" + + static readonly COLOR_KEY = "tts-" + TTSREFS.COLOR_REF + static readonly AUTO_SCROLL_KEY = "tts-" + TTSREFS.AUTO_SCROLL_REF + static readonly RATE_KEY = "tts-" + TTSREFS.RATE_REF + static readonly PITCH_KEY = "tts-" + TTSREFS.PITCH_REF + static readonly VOLUME_KEY = "tts-" + TTSREFS.VOLUME_REF + static readonly VOICE_KEY = "tts-" + TTSREFS.VOICE_REF + +} + +export interface TTSSettingsConfig { + store: Store, + initialTTSSettings: TTSSettings; + headerMenu: HTMLElement; + api: any; +} + +export interface TTSVoice { + usePublication: boolean + name?: string + lang?: string +} + +export class TTSSettings implements TTSSpeechConfig { + + private readonly store: Store; + private readonly TTSSETTINGS = "ttsSetting"; + + color = "orange" + autoScroll = true + rate = 1.0 + pitch = 1.0 + volume = 1.0 + + voice:TTSVoice = { + usePublication : true + } + + userProperties: UserProperties + + private rateButtons: { [key: string]: HTMLButtonElement }; + private pitchButtons: { [key: string]: HTMLButtonElement }; + private volumeButtons: { [key: string]: HTMLButtonElement }; + + private settingsChangeCallback: (key?:string) => void = () => { }; + + private settingsView: HTMLDivElement; + private headerMenu: HTMLElement; + private speechRate: HTMLInputElement; + private speechPitch: HTMLInputElement; + private speechVolume: HTMLInputElement; + private speechAutoScroll: HTMLInputElement; + + api: any; + + public static async create(config: TTSSettingsConfig): Promise { + const settings = new this( + config.store, + config.headerMenu, + config.api + ); + + if (config.initialTTSSettings) { + var initialTTSSettings:TTSSettings = config.initialTTSSettings + + if(initialTTSSettings.rate) { + settings.rate = initialTTSSettings.rate + if (IS_DEV) console.log(settings.rate) + } + if(initialTTSSettings.pitch) { + settings.pitch = initialTTSSettings.pitch + if (IS_DEV) console.log(settings.pitch) + } + if(initialTTSSettings.volume) { + settings.volume = initialTTSSettings.volume + if (IS_DEV) console.log(settings.volume) + } + if(initialTTSSettings.color) { + settings.color = initialTTSSettings.color + if (IS_DEV) console.log(settings.color) + } + if(initialTTSSettings.autoScroll) { + settings.autoScroll = initialTTSSettings.autoScroll + if (IS_DEV) console.log(settings.autoScroll) + } + if(initialTTSSettings.voice) { + settings.voice = initialTTSSettings.voice + if (IS_DEV) console.log(settings.voice) + } + + } + + await settings.initializeSelections(); + return new Promise(resolve => resolve(settings)); + } + + protected constructor(store: Store, headerMenu: HTMLElement, api: any) { + this.store = store; + + this.headerMenu = headerMenu; + this.api = api; + this.initialise(); + } + + async stop() { + if (IS_DEV) { console.log("tts settings stop") } + } + + private async initialise() { + this.autoScroll = (await this.getProperty(TTSREFS.AUTO_SCROLL_KEY) != null) ? (await this.getProperty(TTSREFS.AUTO_SCROLL_KEY) as Switchable).value : this.autoScroll + + this.rate = (await this.getProperty(TTSREFS.RATE_KEY) != null) ? (await this.getProperty(TTSREFS.RATE_KEY) as Incremental).value : this.rate + this.pitch = (await this.getProperty(TTSREFS.PITCH_KEY) != null) ? (await this.getProperty(TTSREFS.PITCH_KEY) as Incremental).value : this.pitch + this.volume = (await this.getProperty(TTSREFS.VOLUME_KEY) != null) ? (await this.getProperty(TTSREFS.VOLUME_KEY) as Incremental).value : this.volume + + this.color = (await this.getProperty(TTSREFS.COLOR_KEY) != null) ? (await this.getProperty(TTSREFS.COLOR_KEY) as Stringable).value : this.color + this.voice = (await this.getProperty(TTSREFS.VOICE_REF) != null) ? (await this.getProperty(TTSREFS.VOICE_REF) as JSONable).value : this.voice + + this.userProperties = this.getTTSSettings() + } + + private async reset() { + + this.color = "orange" + this.autoScroll = true + this.rate = 1.0 + this.pitch = 1.0 + this.volume = 1.0 + + this.voice = { + usePublication : true + } + + this.userProperties = this.getTTSSettings() + } + + private async initializeSelections(): Promise { + + if (this.headerMenu) this.settingsView = HTMLUtilities.findElement(this.headerMenu, "#container-view-tts-settings") as HTMLDivElement; + + } + + setControls() { + if (this.settingsView) this.renderControls(this.settingsView); + } + + + private renderControls(element: HTMLElement): void { + this.rateButtons = {}; + for (const rateName of ["decrease", "increase"]) { + this.rateButtons[rateName] = HTMLUtilities.findElement(element, "#" + rateName + "-rate") as HTMLButtonElement; + } + this.pitchButtons = {}; + for (const pitchName of ["decrease", "increase"]) { + this.pitchButtons[pitchName] = HTMLUtilities.findElement(element, "#" + pitchName + "-pitch") as HTMLButtonElement; + } + this.volumeButtons = {}; + for (const volumeName of ["decrease", "increase"]) { + this.volumeButtons[volumeName] = HTMLUtilities.findElement(element, "#" + volumeName + "-volume") as HTMLButtonElement; + } + + if (this.headerMenu) this.speechRate = HTMLUtilities.findElement(this.headerMenu, "#speechRate") as HTMLInputElement; + if (this.headerMenu) this.speechPitch = HTMLUtilities.findElement(this.headerMenu, "#speechPitch") as HTMLInputElement; + if (this.headerMenu) this.speechVolume = HTMLUtilities.findElement(this.headerMenu, "#speechVolume") as HTMLInputElement; + + if (this.headerMenu) this.speechAutoScroll = HTMLUtilities.findElement(this.headerMenu, "#autoScroll") as HTMLInputElement; + + this.setupEvents(); + + if (this.speechRate) this.speechRate.value = this.rate.toString() + if (this.speechPitch) this.speechPitch.value = this.pitch.toString() + if (this.speechVolume) this.speechVolume.value = this.volume.toString() + if (this.speechAutoScroll) this.speechAutoScroll.checked = this.autoScroll + + // Clicking the settings view outside the ul hides it, but clicking inside the ul keeps it up. + addEventListenerOptional(HTMLUtilities.findElement(element, "ul"), 'click', (event: Event) => { + event.stopPropagation(); + }); + } + + public onSettingsChange(callback: () => void) { + this.settingsChangeCallback = callback; + } + + private async setupEvents(): Promise { + + addEventListenerOptional(this.rateButtons["decrease"], 'click', (event: MouseEvent) => { + if (IS_DEV) console.log(TTSREFS.RATE_REF); + (this.userProperties.getByRef(TTSREFS.RATE_REF) as Incremental).decrement() + this.storeProperty(this.userProperties.getByRef(TTSREFS.RATE_REF)) + this.settingsChangeCallback(); + event.preventDefault(); + }); + addEventListenerOptional(this.rateButtons["increase"], 'click', (event: MouseEvent) => { + if (IS_DEV) console.log(TTSREFS.RATE_REF); + (this.userProperties.getByRef(TTSREFS.RATE_REF) as Incremental).increment() + this.storeProperty(this.userProperties.getByRef(TTSREFS.RATE_REF)) + this.settingsChangeCallback(); + event.preventDefault(); + }); + addEventListenerOptional(this.pitchButtons["decrease"], 'click', (event: MouseEvent) => { + if (IS_DEV) console.log(TTSREFS.PITCH_REF); + (this.userProperties.getByRef(TTSREFS.PITCH_REF) as Incremental).decrement() + this.storeProperty(this.userProperties.getByRef(TTSREFS.PITCH_REF)) + this.settingsChangeCallback(); + event.preventDefault(); + }); + addEventListenerOptional(this.pitchButtons["increase"], 'click', (event: MouseEvent) => { + if (IS_DEV) console.log(TTSREFS.PITCH_REF); + (this.userProperties.getByRef(TTSREFS.PITCH_REF) as Incremental).increment() + this.storeProperty(this.userProperties.getByRef(TTSREFS.PITCH_REF)) + this.settingsChangeCallback(); + event.preventDefault(); + }); + addEventListenerOptional(this.volumeButtons["decrease"], 'click', (event: MouseEvent) => { + if (IS_DEV) console.log(TTSREFS.VOLUME_REF); + (this.userProperties.getByRef(TTSREFS.VOLUME_REF) as Incremental).decrement() + this.storeProperty(this.userProperties.getByRef(TTSREFS.VOLUME_REF)) + this.settingsChangeCallback(); + event.preventDefault(); + }); + addEventListenerOptional(this.volumeButtons["increase"], 'click', (event: MouseEvent) => { + if (IS_DEV) console.log(TTSREFS.VOLUME_REF); + (this.userProperties.getByRef(TTSREFS.VOLUME_REF) as Incremental).increment() + this.storeProperty(this.userProperties.getByRef(TTSREFS.VOLUME_REF)) + this.settingsChangeCallback(); + event.preventDefault(); + }); + + } + + private async storeProperty(property: UserProperty): Promise { + this.updateUserSettings() + this.saveProperty(property) + } + + private async updateUserSettings() { + var ttsSettings:TTSSpeechConfig = { + rate: this.userProperties.getByRef(TTSREFS.RATE_REF).value, + pitch: this.userProperties.getByRef(TTSREFS.PITCH_REF).value, + volume: this.userProperties.getByRef(TTSREFS.VOLUME_REF).value, + voice: this.userProperties.getByRef(TTSREFS.VOLUME_REF).value, + color: this.userProperties.getByRef(TTSREFS.COLOR_REF).value, + autoScroll: this.userProperties.getByRef(TTSREFS.AUTO_SCROLL_REF).value + } + this.applyTTSSettings(ttsSettings) + if (this.api && this.api.updateTTSSettings) { + this.api.updateUserSettings(ttsSettings).then(_ => { + if (IS_DEV) { console.log("api updated tts settings", ttsSettings) } + }) + } + } + + private getTTSSettings(): UserProperties { + + var userProperties = new UserProperties() + + userProperties.addSwitchable("tts-auto-scroll-off", "tts-auto-scroll-on", this.autoScroll, TTSREFS.AUTO_SCROLL_REF, TTSREFS.AUTO_SCROLL_KEY) + userProperties.addIncremental(this.rate, 0.1, 10, 0.1, "", TTSREFS.RATE_REF, TTSREFS.RATE_KEY) + userProperties.addIncremental(this.pitch, 0.1, 2, 0.1, "", TTSREFS.PITCH_REF, TTSREFS.PITCH_KEY) + userProperties.addIncremental(this.volume, 0.1, 1, 0.1, "", TTSREFS.VOLUME_REF, TTSREFS.VOLUME_KEY) + userProperties.addStringable(this.color, TTSREFS.COLOR_REF, TTSREFS.COLOR_KEY) + userProperties.addJSONable(JSON.stringify(this.voice), TTSREFS.VOICE_REF, TTSREFS.VOICE_KEY) + + return userProperties + + } + + private async saveProperty(property: any): Promise { + let savedProperties = await this.store.get(this.TTSSETTINGS); + if (savedProperties) { + let array = JSON.parse(savedProperties); + array = array.filter((el: any) => el.name !== property.name); + array.push(property); + await this.store.set(this.TTSSETTINGS, JSON.stringify(array)); + } else { + let array = new Array(); + array.push(property); + await this.store.set(this.TTSSETTINGS, JSON.stringify(array)); + } + return new Promise(resolve => resolve(property)); + } + + async getProperty(name: string): Promise { + let array = await this.store.get(this.TTSSETTINGS); + if (array) { + let properties = JSON.parse(array) as Array; + properties = properties.filter((el: UserProperty) => el.name === name); + if (properties.length == 0) { + return null; + } + return properties[0]; + } + return null; + } + + async resetTTSSettings(): Promise { + await this.store.remove(this.TTSSETTINGS) + await this.reset() + this.settingsChangeCallback(); + } + + async applyTTSSettings(ttsSettings: TTSSpeechConfig): Promise { + + if (ttsSettings.rate) { + if (IS_DEV) console.log("rate " + this.rate) + this.rate = ttsSettings.rate + this.userProperties.getByRef(TTSREFS.RATE_REF).value = this.rate; + await this.saveProperty(this.userProperties.getByRef(TTSREFS.RATE_REF)) + this.settingsChangeCallback(); + } + if (ttsSettings.pitch) { + if (IS_DEV) console.log("pitch " + this.pitch) + this.pitch = ttsSettings.pitch + this.userProperties.getByRef(TTSREFS.PITCH_REF).value = this.pitch; + await this.saveProperty(this.userProperties.getByRef(TTSREFS.PITCH_REF)) + this.settingsChangeCallback(); + } + if (ttsSettings.volume) { + if (IS_DEV) console.log("volume " + this.volume) + this.volume = ttsSettings.volume + this.userProperties.getByRef(TTSREFS.VOLUME_REF).value = this.volume; + await this.saveProperty(this.userProperties.getByRef(TTSREFS.VOLUME_REF)) + this.settingsChangeCallback(); + } + + if (ttsSettings.color) { + this.color = ttsSettings.color + this.userProperties.getByRef(TTSREFS.COLOR_REF).value = this.color; + await this.saveProperty(this.userProperties.getByRef(TTSREFS.COLOR_REF)) + this.settingsChangeCallback(); + } + if (ttsSettings.autoScroll != undefined) { + if (IS_DEV) console.log("autoScroll " + this.autoScroll) + this.autoScroll = ttsSettings.autoScroll + this.userProperties.getByRef(TTSREFS.AUTO_SCROLL_REF).value = this.autoScroll; + await this.saveProperty(this.userProperties.getByRef(TTSREFS.AUTO_SCROLL_REF)) + this.settingsChangeCallback(); + } + if (ttsSettings.voice) { + if (IS_DEV) console.log("voice " + this.voice) + this.voice = ttsSettings.voice + this.userProperties.getByRef(TTSREFS.VOICE_REF).value = this.voice; + await this.saveProperty(this.userProperties.getByRef(TTSREFS.VOICE_REF)) + this.settingsChangeCallback(); + } + } + + async preferredVoice(value:any) { + var name = (value.indexOf(":") !== -1) ? value.slice(0, value.indexOf(':')) : undefined + var lang = (value.indexOf(":") !== -1) ? value.slice(value.indexOf(':') + 1) : value + if (name != undefined && lang != undefined) { + this.ttsSet('voice', { usePublication: true, name : name, lang : lang }) + } else if (lang != undefined && name == undefined) { + this.ttsSet('voice', { usePublication: true, lang : lang }) + } + } + + async ttsSet(key: any, value: any) { + if (key == TTSREFS.COLOR_REF) { + this.color = value + this.userProperties.getByRef(TTSREFS.COLOR_REF).value = this.color; + await this.saveProperty(this.userProperties.getByRef(TTSREFS.COLOR_REF)) + this.settingsChangeCallback(); + } else if (key == TTSREFS.AUTO_SCROLL_REF) { + this.autoScroll = value + this.userProperties.getByRef(TTSREFS.AUTO_SCROLL_REF).value = this.autoScroll; + await this.saveProperty(this.userProperties.getByRef(TTSREFS.AUTO_SCROLL_REF)) + this.settingsChangeCallback(); + } else if (key == TTSREFS.VOICE_REF) { + this.voice = value + this.userProperties.getByRef(TTSREFS.VOICE_REF).value = this.voice; + await this.saveProperty(this.userProperties.getByRef(TTSREFS.VOICE_REF)) + this.settingsChangeCallback(); + } + } + + + async increase(incremental: string): Promise { + if (incremental == 'rate') { + (this.userProperties.getByRef(TTSREFS.RATE_REF) as Incremental).increment() + this.storeProperty(this.userProperties.getByRef(TTSREFS.RATE_REF)) + this.settingsChangeCallback(); + } else if (incremental == 'pitch') { + (this.userProperties.getByRef(TTSREFS.PITCH_REF) as Incremental).increment() + this.storeProperty(this.userProperties.getByRef(TTSREFS.PITCH_REF)) + this.settingsChangeCallback(); + } else if (incremental == 'volume') { + (this.userProperties.getByRef(TTSREFS.VOLUME_REF) as Incremental).increment() + this.storeProperty(this.userProperties.getByRef(TTSREFS.VOLUME_REF)) + this.settingsChangeCallback(); + } + } + + async decrease(incremental: string): Promise { + if (incremental == 'rate') { + (this.userProperties.getByRef(TTSREFS.RATE_REF) as Incremental).decrement() + this.storeProperty(this.userProperties.getByRef(TTSREFS.RATE_REF)) + this.settingsChangeCallback(); + } else if (incremental == 'pitch') { + (this.userProperties.getByRef(TTSREFS.PITCH_REF) as Incremental).decrement() + this.storeProperty(this.userProperties.getByRef(TTSREFS.PITCH_REF)) + this.settingsChangeCallback(); + } else if (incremental == 'volume') { + (this.userProperties.getByRef(TTSREFS.VOLUME_REF) as Incremental).decrement() + this.storeProperty(this.userProperties.getByRef(TTSREFS.VOLUME_REF)) + this.settingsChangeCallback(); + } + } + +} diff --git a/src/modules/TTS/splitting.js b/src/modules/TTS/splitting.js new file mode 100644 index 00000000..f148ff31 --- /dev/null +++ b/src/modules/TTS/splitting.js @@ -0,0 +1,359 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.Splitting = factory()); +}(this, (function () { 'use strict'; + +var root = document; +var createText = root.createTextNode.bind(root); + +/** + * # setProperty + * Apply a CSS var + * @param el{HTMLElement} + * @param varName {string} + * @param value {string|number} + */ +function setProperty(el, varName, value, key) { + if (key != undefined) { + el.setAttribute("data-" + key + "-index", value); + } else { + el.style.setProperty(varName, value); + } +} + +/** + * + * @param {Node} el + * @param {Node} child + */ +function appendChild(el, child) { + return el.appendChild(child); +} + +function createElement(parent, key, text, whitespace) { + var el = root.createElement('span'); +// key;// && (el.className = "orange"); + if (text) { + if (!whitespace) { + if (text.replace(/[^a-zA-Z0-9 ]/g, "").length > 0) { + el.setAttribute("data-" + key, text.replace(/[^a-zA-Z0-9 ]/g, "")); + } else { + el.setAttribute("data-" + key, ""); + } + } else { + el.setAttribute("data-" + key, ""); + } + el.textContent = text; + } + return (parent && appendChild(parent, el)) || el; +} + +function getData(el, key) { + return el.getAttribute("data-" + key) +} + +/** + * + * @param e {import('../types').Target} + * @param parent {HTMLElement} + * @returns {HTMLElement[]} + */ +function $(e, parent) { + return !e || e.length == 0 + ? // null or empty string returns empty array + [] + : e.nodeName + ? // a single element is wrapped in an array + [e] + : // selector and NodeList are converted to Element[] + [].slice.call(e[0].nodeName ? e : (parent || root).querySelectorAll(e)); +} + +/** + * Creates and fills an array with the value provided + * @template {T} + * @param {number} len + * @param {() => T} valueProvider + * @return {T} + */ +function Array2D(len) { + var a = []; + for (; len--; ) { + a[len] = []; + } + return a; +} + +function each(items, fn) { + items && items.some(fn); +} + +function selectFrom(obj) { + return function (key) { + return obj[key]; + } +} + +/** + * # Splitting.index + * Index split elements and add them to a Splitting instance. + * + * @param element {HTMLElement} + * @param key {string} + * @param items {HTMLElement[] | HTMLElement[][]} + */ +function index(element, key, items) { + var prefix = '--' + key; + var cssVar = prefix + "-index"; + + each(items, function (items, i) { + if (Array.isArray(items)) { + each(items, function(item) { + setProperty(item, cssVar, i, key); + + }); + } else { + setProperty(items, cssVar, i, key); + } + }); + + setProperty(element, prefix + "-total", items.length); +} + +/** + * @type {Record} + */ +var plugins = {}; + +/** + * @param by {string} + * @param parent {string} + * @param deps {string[]} + * @return {string[]} + */ +function resolvePlugins(by, parent, deps) { + // skip if already visited this dependency + var index = deps.indexOf(by); + if (index == -1) { + // if new to dependency array, add to the beginning + deps.unshift(by); + + // recursively call this function for all dependencies + each(plugins[by].depends, function(p) { + resolvePlugins(p, by, deps); + }); + } else { + // if this dependency was added already move to the left of + // the parent dependency so it gets loaded in order + var indexOfParent = deps.indexOf(parent); + deps.splice(index, 1); + deps.splice(indexOfParent, 0, by); + } + return deps; +} + +/** + * Internal utility for creating plugins... essentially to reduce + * the size of the library + * @param {string} by + * @param {string} key + * @param {string[]} depends + * @param {Function} split + * @returns {import('./types').ISplittingPlugin} + */ +function createPlugin(by, depends, key, split) { + return { + by: by, + depends: depends, + key: key, + split: split + } +} + +/** + * + * @param by {string} + * @returns {import('./types').ISplittingPlugin[]} + */ +function resolve(by) { + return resolvePlugins(by, 0, []).map(selectFrom(plugins)); +} + +/** + * Adds a new plugin to splitting + * @param opts {import('./types').ISplittingPlugin} + */ +function add(opts) { + plugins[opts.by] = opts; +} + +/** + * # Splitting.split + * Split an element's textContent into individual elements + * @param el {Node} Element to split + * @param key {string} + * @param splitOn {string} + * @param includeSpace {boolean} + * @returns {HTMLElement[]} + */ +function splitText(el, key, splitOn, includePrevious, preserveWhitespace) { + // Combine any strange text nodes or empty whitespace. + el.normalize(); + + // Use fragment to prevent unnecessary DOM thrashing. + var elements = []; + var F = document.createDocumentFragment(); + + if (includePrevious) { + elements.push(el.previousSibling); + } + + var allElements = []; + $(el.childNodes).some(function(next) { + if (next.tagName && !next.hasChildNodes()) { + // keep elements without child nodes (no text and no children) + allElements.push(next); + return; + } + if (next.tagName == "select" || next.tagName == "input" || next.tagName == "option" || next.tagName == "textarea" || next.tagName == "script") { + allElements.push(next); + return; + } + // Recursively run through child nodes + if (next.childNodes && next.childNodes.length) { + allElements.push(next); + elements.push.apply(elements, splitText(next, key, splitOn, includePrevious, preserveWhitespace)); + return; + } + + // Get the text to split, trimming out the whitespace + /** @type {string} */ + var wholeText = next.wholeText || ''; + var contentsTrimmed = wholeText.trim(); + // var contents = wholeText; + + // If there's no text left after trimming whitespace, continue the loop + if (contentsTrimmed.length) { + // insert leading space if there was one + allElements.push(createElement(F, "whitespace", " ", preserveWhitespace)); + if (wholeText[0] === ' ') { + allElements.push(createText(' ')); + } + // Concatenate the split text children back into the full array + each(contentsTrimmed.split(splitOn), function(splitText, i) { + if (i && preserveWhitespace) { + allElements.push(createElement(F, "whitespace", " ", preserveWhitespace)); + } + var splitEl = createElement(F, key, splitText); + elements.push(splitEl); + allElements.push(splitEl); + }); + // insert trailing space if there was one + if (wholeText[wholeText.length - 1] === ' ') { + allElements.push(createText(' ')); + } + allElements.push(createElement(F, "whitespace", " ", preserveWhitespace)); + } else { + allElements.push(createElement(F, "whitespace", " ", preserveWhitespace)); + } + }); + + each(allElements, function(el) { + appendChild(F, el); + }); + + // Clear out the existing element + el.innerHTML = ""; + appendChild(el, F); + return elements; +} + +/** an empty value */ +var _ = 0; + +function copy(dest, src) { + for (var k in src) { + dest[k] = src[k]; + } + return dest; +} + +var WORDS = 'words'; + +var wordPlugin = createPlugin( + /*by: */ WORDS, + /*depends: */ _, + /*key: */ 'word', + /*split: */ function(el) { + return splitText(el, 'word', /\s+/, 0, 1) + } +); + +/** + * # Splitting + * + * @param opts {import('./types').ISplittingOptions} + */ +function Splitting (opts) { + opts = opts || {}; + var key = opts.key; + + return $(opts.target || '[data-splitting]').map(function(el) { + var ctx = el['🍌']; + if (!opts.force && ctx) { + return ctx; + } + + ctx = el['🍌'] = { el: el }; + var items = resolve(opts.by || getData(el, 'splitting') || CHARS); + var opts2 = copy({}, opts); + each(items, function(plugin) { + if (plugin.split) { + var pluginBy = plugin.by; + var key2 = (key ? '-' + key : '') + plugin.key; + var results = plugin.split(el, opts2, ctx); + key2 && index(el, key2, results); + ctx[pluginBy] = results; + el.classList.add(pluginBy); + } + }); + + el.classList.add('splitting'); + return ctx; + }) +} + +function detectGrid(el, options, side) { + var items = $(options.matching || el.children, el); + var c = {}; + + each(items, function(w) { + var val = Math.round(w[side]); + (c[val] || (c[val] = [])).push(w); + }); + + return Object.keys(c).map(Number).sort(byNumber).map(selectFrom(c)); +} + +function byNumber(a, b) { + return a - b; +} + +var linePlugin = createPlugin( + /*by: */ 'lines', + /*depends: */ [WORDS], + /*key: */ 'line', + /*split: */ function(el, options, ctx) { + return detectGrid(el, { matching: ctx[WORDS] }, 'offsetTop') + } +); + +// install plugins +// word/char plugins +add(wordPlugin); +add(linePlugin); + +return Splitting; + +}))); diff --git a/src/modules/TTSModule.ts b/src/modules/TTSModule.ts deleted file mode 100644 index 192f44e0..00000000 --- a/src/modules/TTSModule.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2018-2020 DITA (AM Consulting LLC) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * Developed on behalf of: CAST (http://www.cast.org) - * Licensed to: Bokbasen AS and CAST under one or more contributor license agreements. - */ - -import ReaderModule from "./ReaderModule"; -import AnnotationModule from "./AnnotationModule"; -import { IS_DEV } from ".."; -import { ISelectionInfo } from "../model/Locator"; - -export interface TTSModuleConfig { - annotationModule: AnnotationModule; -} - -export default class TTSModule implements ReaderModule { - - annotationModule: AnnotationModule; - synth = window.speechSynthesis - - initialize() { - if (this.annotationModule.highlighter !== undefined) { - this.annotationModule.highlighter.ttsDelegate = this - } - } - cancel() { - this.synth.cancel() - } - - speak(selectionInfo: ISelectionInfo | undefined ): any { - if (IS_DEV) console.log(selectionInfo.cleanText) - var self = this - var utterance = new SpeechSynthesisUtterance(selectionInfo.cleanText); - this.synth.cancel() - this.synth.speak(utterance); - utterance.onend = function () { - if (IS_DEV) console.log("utterance ended"); - self.annotationModule.highlighter.doneSpeaking() - } - } - speakAll(selectionInfo: string | undefined , callback: () => void): any { - if (IS_DEV) console.log(selectionInfo) - var self = this - var utterance = new SpeechSynthesisUtterance(selectionInfo); - this.synth.cancel() - this.synth.speak(utterance); - utterance.onend = function () { - if (IS_DEV) console.log("utterance ended"); - self.annotationModule.highlighter.doneSpeaking() - } - callback() - } - speakPause() { - this.synth.pause() - } - speakResume() { - this.synth.resume() - } - - public static async create(config: TTSModuleConfig) { - const annotations = new this( - config.annotationModule, - ); - await annotations.start(); - return annotations; - } - - public constructor(annotationModule: AnnotationModule) { - this.annotationModule = annotationModule - } - - protected async start(): Promise { - this.annotationModule.delegate.ttsModule = this - } - - async stop() { - if (IS_DEV) { console.log("TTS module stop")} - } - -} \ No newline at end of file diff --git a/src/modules/highlight/TextHighlighter.ts b/src/modules/highlight/TextHighlighter.ts index 43c8d2bc..73531479 100644 --- a/src/modules/highlight/TextHighlighter.ts +++ b/src/modules/highlight/TextHighlighter.ts @@ -41,7 +41,7 @@ import AnnotationModule from "../AnnotationModule"; import { Annotation, AnnotationMarker } from "../../model/Locator"; import { IS_DEV } from "../.."; import { icons } from "../../utils/IconLib"; -import TTSModule from "../TTSModule"; +import TTSModule from "../TTS/TTSModule"; import { SelectionMenuItem } from "../../navigator/IFrameNavigator"; export const ID_HIGHLIGHTS_CONTAINER = "R2_ID_HIGHLIGHTS_CONTAINER"; @@ -481,7 +481,6 @@ export default class TextHighlighter { var selection = self.dom(el).getSelection(); selection.removeAllRanges(); self.toolboxHide(); - self.ttsDelegate.cancel() }, /** @@ -489,7 +488,7 @@ export default class TextHighlighter { * @returns {Selection} */ getSelection: function (): Selection { - return self.dom(el).getWindow().getSelection(); + return self.dom(el).getWindow().getSelection(); }, /** @@ -621,7 +620,7 @@ export default class TextHighlighter { (highlightIcon.getElementsByTagName("span")[0] as HTMLSpanElement).style.background = self.getColor(); } if(underlineIcon.getElementsByTagName("span").length>0) { - (underlineIcon.getElementsByTagName("span")[0] as HTMLSpanElement).style.background = self.getColor(); + (underlineIcon.getElementsByTagName("span")[0] as HTMLSpanElement).style.borderBottomColor = self.getColor(); } self.toolboxMode('add'); @@ -662,10 +661,7 @@ export default class TextHighlighter { toolboxHide() { var toolbox = document.getElementById("highlight-toolbox"); - var backdrop = document.getElementById("toolbox-backdrop"); - toolbox.style.display = "none"; - backdrop.style.display = "none"; } // Use short timeout to let the selection updated to 'finish', otherwise some @@ -673,9 +669,48 @@ export default class TextHighlighter { toolboxShowDelayed() { var self = this; setTimeout(function() { + if (!self.isAndroid()) { + self.snapSelectionToWord() + } self.toolboxShow(); }, 100); } + + snapSelectionToWord() { + var self = this; + // Check for existence of window.getSelection() and that it has a + // modify() method. IE 9 has both selection APIs but no modify() method. + if (self.dom(self.el)) { + var selection = self.dom(self.el).getWindow().getSelection(); + if (!selection.isCollapsed) { + + // Detect if selection is backwards + var range = document.createRange(); + range.setStart(selection.anchorNode, selection.anchorOffset); + range.setEnd(selection.focusNode, selection.focusOffset); + var backwards = range.collapsed; + range.detach(); + + // modify() works on the focus of the selection + var endNode = selection.focusNode, endOffset = selection.focusOffset; + selection.collapse(selection.anchorNode, selection.anchorOffset); + + var direction = []; + if (backwards) { + direction = ['backward', 'forward']; + } else { + direction = ['forward', 'backward']; + } + + selection.modify("move", direction[0], "character"); + selection.modify("move", direction[1], "word"); + selection.extend(endNode, endOffset); + selection.modify("extend", direction[1], "character"); + selection.modify("extend", direction[0], "word"); + } + } + return selection + } toolboxShow() { var self = this; @@ -725,13 +760,9 @@ export default class TextHighlighter { if(this.delegate.rights.enableAnnotations) { var toolbox = document.getElementById("highlight-toolbox"); - var backdrop = document.getElementById("toolbox-backdrop"); if(getComputedStyle(toolbox).display === "none") { toolbox.style.display = "block"; - if (!this.isIOS() && !this.isAndroid()) { - backdrop.style.display = "block"; - } var self = this; @@ -807,37 +838,15 @@ export default class TextHighlighter { } const selectionInfo = getCurrentSelectionInfo(self.dom(self.el).getWindow(), getCssSelector) - - menuItem.callback(selectionInfo.cleanText); + if (selectionInfo != undefined) { + menuItem.callback(selectionInfo.cleanText); + } self.callbackComplete() } itemElement.addEventListener("click", itemEvent); }) } - - - var backdropButton = document.getElementById("toolbox-backdrop"); - - function backdropEvent(){ - try { - self.dom(self.el).removeAllRanges(); - } catch (err) { - console.error(err) - } - toolbox.style.display = "none"; - // self.delegate.api.highlightUnSelected().then(async () => { - // if (IS_DEV) {console.log("highlightUnSelected, click on backdrop (click, mousedown,mouseup )")} - // }) - backdropButton.removeEventListener("click", backdropEvent); - backdropButton.removeEventListener("mousedown", backdropEvent); - backdropButton.removeEventListener("mouseup", backdropEvent); - } - - backdropButton.addEventListener("click", backdropEvent); - backdropButton.addEventListener("mousedown", backdropEvent); - backdropButton.addEventListener("mouseup", backdropEvent); - } } }; @@ -879,65 +888,90 @@ export default class TextHighlighter { if (!keepRange) { this.dom(this.el).removeAllRanges(); } + } else { + if (!keepRange) { + this.dom(this.el).removeAllRanges(); + } } }; speak() { - var self = this - function getCssSelector(element: Element): string { - const options = { - className: (str: string) => { - return _blacklistIdClassForCssSelectors.indexOf(str) < 0; - }, - idName: (str: string) => { - return _blacklistIdClassForCssSelectors.indexOf(str) < 0; - }, - }; - return uniqueCssSelector(element, self.dom(self.el).getDocument(), options); - } - - const selectionInfo = getCurrentSelectionInfo(this.dom(this.el).getWindow(), getCssSelector) - if (selectionInfo) { - // if (this.options.onBeforeHighlight(selectionInfo) === true) { - // var highlight = this.createHighlight(self.dom(self.el).getWindow(), selectionInfo, TextHighlighter.hexToRgbString(this.getColor()),true, marker) - // this.options.onAfterHighlight(highlight, marker); - // } - this.ttsDelegate.speak(selectionInfo as any); + if (this.delegate.rights.enableTTS) { + var self = this + function getCssSelector(element: Element): string { + const options = { + className: (str: string) => { + return _blacklistIdClassForCssSelectors.indexOf(str) < 0; + }, + idName: (str: string) => { + return _blacklistIdClassForCssSelectors.indexOf(str) < 0; + }, + }; + return uniqueCssSelector(element, self.dom(self.el).getDocument(), options); + } + const selectionInfo = getCurrentSelectionInfo(this.dom(this.el).getWindow(), getCssSelector) + if (selectionInfo != undefined) { + // if (this.options.onBeforeHighlight(selectionInfo) === true) { + // var highlight = this.createHighlight(self.dom(self.el).getWindow(), selectionInfo, TextHighlighter.hexToRgbString(this.getColor()),true, marker) + // this.options.onAfterHighlight(highlight, marker); + // } + var node = this.dom(this.el).getWindow().document.body; + this.ttsDelegate.speak(selectionInfo as any, node, true, () => { + }) + } + if (this.delegate.delegate.tts.enableSplitter) { + const selection = self.dom(self.el).getSelection(); + selection.removeAllRanges(); + var toolbox = document.getElementById("highlight-toolbox"); + toolbox.style.display = "none"; + } } }; stopReadAloud() { - this.doneSpeaking() + if (this.delegate.rights.enableTTS) { + this.doneSpeaking() + } } speakAll() { - var self = this - function getCssSelector(element: Element): string { - const options = { - className: (str: string) => { - return _blacklistIdClassForCssSelectors.indexOf(str) < 0; - }, - idName: (str: string) => { - return _blacklistIdClassForCssSelectors.indexOf(str) < 0; - }, - }; - return uniqueCssSelector(element, self.dom(self.el).getDocument(), options); - } - - var node = this.dom(this.el).getWindow().document.body; - if (IS_DEV) console.log(self.delegate.delegate.iframe.contentDocument) - const selection = self.dom(self.el).getSelection(); - const range = this.dom(this.el).getWindow().document.createRange(); - range.selectNodeContents(node); - selection.removeAllRanges(); - selection.addRange(range); - const selectionInfo = getCurrentSelectionInfo(this.dom(this.el).getWindow(), getCssSelector) + if (this.delegate.rights.enableTTS) { + var self = this + function getCssSelector(element: Element): string { + const options = { + className: (str: string) => { + return _blacklistIdClassForCssSelectors.indexOf(str) < 0; + }, + idName: (str: string) => { + return _blacklistIdClassForCssSelectors.indexOf(str) < 0; + }, + }; + return uniqueCssSelector(element, self.dom(self.el).getDocument(), options); + } - if (selectionInfo.cleanText) { - this.ttsDelegate.speakAll(selectionInfo.cleanText as any, () => { - var selection = self.dom(self.el).getSelection(); + const selectionInfo = getCurrentSelectionInfo(this.dom(this.el).getWindow(), getCssSelector) + if (selectionInfo != undefined) { + self.speak() + } else { + var node = this.dom(this.el).getWindow().document.body; + if (IS_DEV) console.log(self.delegate.delegate.iframe.contentDocument) + const selection = self.dom(self.el).getSelection(); + const range = this.dom(this.el).getWindow().document.createRange(); + range.selectNodeContents(node); selection.removeAllRanges(); - self.toolboxHide(); - }) + selection.addRange(range); + const selectionInfo = getCurrentSelectionInfo(this.dom(this.el).getWindow(), getCssSelector) + + if (selectionInfo != undefined && selectionInfo.cleanText) { + this.ttsDelegate.speak(selectionInfo as any, node, false, () => { + var selection = self.dom(self.el).getSelection(); + selection.removeAllRanges(); + self.toolboxHide(); + }) + } else { + self.dom(self.el).getSelection().removeAllRanges(); + self.toolboxHide(); + } + } } }; @@ -946,9 +980,16 @@ export default class TextHighlighter { this.dom(this.el).removeAllRanges(); } - doneSpeaking() { - this.toolboxHide(); - this.dom(this.el).removeAllRanges(); + doneSpeaking(reload:boolean = false) { + if (this.delegate.rights.enableTTS) { + this.toolboxHide(); + this.dom(this.el).removeAllRanges(); + this.ttsDelegate.cancel() + + if(reload) { + this.delegate.delegate.reload() + } + } } /** @@ -1331,8 +1372,13 @@ export default class TextHighlighter { if (highlight) { const opacity = DEFAULT_BACKGROUND_COLOR_OPACITY; if(highlight.marker == AnnotationMarker.Underline) { - highlightArea.style.setProperty("background-color", `rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${0})`, "important"); - highlightArea.style.setProperty("border-bottom", `2px solid rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${1})`, "important"); + // Highlight color as string check + if (typeof highlight.color === 'object') { + highlightArea.style.setProperty("background-color", `rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${0})`, "important"); + highlightArea.style.setProperty("border-bottom", `2px solid rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${1})`, "important"); + } else { + highlightArea.classList.remove('hover'); + } } else { // Highlight color as string check if (typeof highlight.color === 'object') { @@ -1349,8 +1395,13 @@ export default class TextHighlighter { for (const highlightArea of highlightAreas) { const opacity = ALT_BACKGROUND_COLOR_OPACITY; if(highlight.marker == AnnotationMarker.Underline) { - highlightArea.style.setProperty("background-color", `rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${0.1})`, "important"); - highlightArea.style.setProperty("border-bottom", `2px solid rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${1})`, "important"); + // Highlight color as string check + if (typeof highlight.color === 'object') { + highlightArea.style.setProperty("background-color", `rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${0.1})`, "important"); + highlightArea.style.setProperty("border-bottom", `2px solid rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${1})`, "important"); + } else { + highlightArea.classList.add('hover'); + } } else { // Highlight color as string check if (typeof highlight.color === 'object') { @@ -1370,7 +1421,13 @@ export default class TextHighlighter { isAndroid() { return navigator.userAgent.match(/Android/i) != null; } - + getScrollingElement = (documant: Document): Element => { + if (documant.scrollingElement) { + return documant.scrollingElement; + } + return documant.body; + }; + async processMouseEvent(win: IReadiumIFrameWindow, ev: MouseEvent) { const documant = win.document; // const scrollElement = getScrollingElement(documant); @@ -1384,10 +1441,12 @@ export default class TextHighlighter { return; } - // const paginated = isPaginated(documant); + const paginated = this.isPaginated(); const bodyRect = documant.body.getBoundingClientRect(); - const xOffset = bodyRect.left; - const yOffset = bodyRect.top; + const scrollElement = this.getScrollingElement(documant); + + const xOffset = paginated ? (-scrollElement.scrollLeft) : bodyRect.left; + const yOffset = paginated ? (-scrollElement.scrollTop) : bodyRect.top; let foundHighlight: IHighlight | undefined; let foundElement: IHTMLDivElementWithRect | undefined; @@ -1473,7 +1532,6 @@ export default class TextHighlighter { self.lastSelectedHighlight = anno.id // } else { var toolbox = document.getElementById("highlight-toolbox"); - var backdrop = document.getElementById("toolbox-backdrop"); // toolbox.style.top = ev.clientY + 74 + 'px'; toolbox.style.top = ev.clientY + 'px'; toolbox.style.left = ev.clientX + "px"; @@ -1481,9 +1539,6 @@ export default class TextHighlighter { if(getComputedStyle(toolbox).display === "none") { toolbox.style.display = "block"; - if (!this.isIOS() && !this.isAndroid()) { - backdrop.style.display = "block"; - } this.toolboxMode('edit'); var colorIcon = document.getElementById("colorIcon"); @@ -1514,7 +1569,6 @@ export default class TextHighlighter { self.delegate.deleteSelectedHighlight(anno).then(async () => { if (IS_DEV) {console.log("delete highlight "+anno.id)} toolbox.style.display = "none"; - backdrop.style.display = "none"; }) deleteIcon.removeEventListener("click", deleteH); }; @@ -1526,35 +1580,6 @@ export default class TextHighlighter { deleteIcon.addEventListener("click", deleteH); } - var backdropButton = document.getElementById("toolbox-backdrop"); - - function backdropEvent(){ - try { - self.dom(self.el).removeAllRanges(); - } catch (err) { - console.error(err) - } - - toolbox.style.display = "none"; - // self.delegate.api.highlightUnSelected().then(async () => { - // if (IS_DEV) {console.log("highlightUnSelected, click on backdrop (click, mousedown,mouseup )")} - // }) - if (deleteIcon) { - deleteIcon.removeEventListener("click", deleteH); - } - // commentIcon.removeEventListener("click", addCommenH); - - backdropButton.removeEventListener("click", backdropEvent); - backdropButton.removeEventListener("mousedown", backdropEvent); - backdropButton.removeEventListener("mouseup", backdropEvent); - - - } - - backdropButton.addEventListener("click", backdropEvent); - backdropButton.addEventListener("mousedown", backdropEvent); - backdropButton.addEventListener("mouseup", backdropEvent); - } else { toolbox.style.display = "none"; void toolbox.offsetWidth; @@ -1576,20 +1601,17 @@ export default class TextHighlighter { bodyEventListenersSet = true; async function mousedown(ev: MouseEvent){ - if (IS_DEV) console.log('mousedown') lastMouseDownX = ev.clientX; lastMouseDownY = ev.clientY; } async function mouseup(ev: MouseEvent){ - if (IS_DEV) console.log('mouseup') if ((Math.abs(lastMouseDownX - ev.clientX) < 3) && (Math.abs(lastMouseDownY - ev.clientY) < 3)) { self.processMouseEvent(win, ev); } } async function mousemove(ev: MouseEvent){ - if (IS_DEV) console.log('mousemove') self.processMouseEvent(win, ev); } @@ -1694,6 +1716,11 @@ export default class TextHighlighter { } } + async isPaginated() { + var verticalScroll = await this.delegate.delegate.settings.isPaginated() + return verticalScroll + } + createHighlightDom(win: IReadiumIFrameWindow, highlight: IHighlight): HTMLDivElement | undefined { const documant = win.document; @@ -1713,18 +1740,25 @@ export default class TextHighlighter { highlightParent.setAttribute("data-click", "1"); } + const paginated = this.isPaginated(); + // Resize Sensor sets body position to "relative" (default static), // which may breaks things! // (e.g. highlights CSS absolute/fixed positioning) // Also note that ReadiumCSS default to (via stylesheet :root): - documant.body.style.position = "relative"; + if (paginated) { + documant.body.style.position = "revert"; + } else { + documant.body.style.position = "relative"; + } const bodyRect = documant.body.getBoundingClientRect(); + const scrollElement = this.getScrollingElement(documant); - const xOffset = bodyRect.left; - const yOffset = bodyRect.top; + const xOffset = paginated ? (-scrollElement.scrollLeft) : bodyRect.left; + const yOffset = paginated ? (-scrollElement.scrollTop) : bodyRect.top; - const scale = 1 / (1); + const scale = 1 / 1; const drawUnderline = false; const drawStrikeThrough = false; @@ -1741,6 +1775,7 @@ export default class TextHighlighter { const highlightArea = documant.createElement("div") as IHTMLDivElementWithRect; highlightArea.setAttribute("class", CLASS_HIGHLIGHT_AREA); + highlightArea.dataset.marker = "" + highlight.marker let extra = ""; if (drawUnderline) { @@ -1748,14 +1783,19 @@ export default class TextHighlighter { } if(highlight.marker == AnnotationMarker.Underline) { - highlightArea.setAttribute("style", `mix-blend-mode: multiply; border-radius: ${roundedCorner}px !important; background-color: rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${0}) !important; ${extra}`); - highlightArea.style.setProperty("border-bottom", `2px solid rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${1})`, "important"); + // Highlight color as string check + if (typeof highlight.color === 'object') { + highlightArea.setAttribute("style", `mix-blend-mode: multiply; border-radius: ${roundedCorner}px !important; background-color: rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${0}) !important; ${extra}`); + highlightArea.style.setProperty("border-bottom", `2px solid rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${1})`, "important"); + } else { + highlightArea.setAttribute("style", `border-radius: ${roundedCorner}px !important; ${extra}`); + } } else { // Highlight color as string check if (typeof highlight.color === 'object') { highlightArea.setAttribute("style", `mix-blend-mode: multiply; border-radius: ${roundedCorner}px !important; background-color: rgba(${highlight.color.red}, ${highlight.color.green}, ${highlight.color.blue}, ${opacity}) !important; ${extra}`); } else { - highlightArea.setAttribute("style", `mix-blend-mode: multiply; border-radius: ${roundedCorner}px !important; ${extra}`); + highlightArea.setAttribute("style", `border-radius: ${roundedCorner}px !important; ${extra}`); } } highlightArea.style.setProperty("pointer-events", "none"); diff --git a/src/modules/highlight/common/selection.ts b/src/modules/highlight/common/selection.ts index 256748f8..8a34685a 100644 --- a/src/modules/highlight/common/selection.ts +++ b/src/modules/highlight/common/selection.ts @@ -85,6 +85,7 @@ export interface ISelectionInfo { rangeInfo: IRangeInfo; cleanText: string; rawText: string; + range: Range; } export function sameSelections(sel1: ISelectionInfo, sel2: ISelectionInfo): boolean { diff --git a/src/modules/highlight/common/styles.ts b/src/modules/highlight/common/styles.ts index acb1b342..078a7534 100644 --- a/src/modules/highlight/common/styles.ts +++ b/src/modules/highlight/common/styles.ts @@ -17,7 +17,7 @@ * Licensed to: Bokbasen AS and CAST under one or more contributor license agreements. */ - export const ROOT_CLASS_REDUCE_MOTION = "r2-reduce-motion"; +export const ROOT_CLASS_REDUCE_MOTION = "r2-reduce-motion"; export const ROOT_CLASS_NO_FOOTNOTES = "r2-no-popup-foonotes"; export const POPUP_DIALOG_CLASS = "r2-popup-dialog"; export const FOOTNOTES_CONTAINER_CLASS = "r2-footnote-container"; diff --git a/src/modules/highlight/renderer/iframe/selection.ts b/src/modules/highlight/renderer/iframe/selection.ts index 6d4ef36d..609901fb 100644 --- a/src/modules/highlight/renderer/iframe/selection.ts +++ b/src/modules/highlight/renderer/iframe/selection.ts @@ -102,7 +102,7 @@ export function getCurrentSelectionInfo( // selection.removeAllRanges(); // // selection.addRange(range); - return { rangeInfo, cleanText, rawText }; + return { rangeInfo, cleanText, rawText, range }; } export function createOrderedRange(startNode: Node, startOffset: number, endNode: Node, endOffset: number): diff --git a/src/navigator/IFrameNavigator.ts b/src/navigator/IFrameNavigator.ts index 62239581..4e9da852 100644 --- a/src/navigator/IFrameNavigator.ts +++ b/src/navigator/IFrameNavigator.ts @@ -31,8 +31,10 @@ import { Sidenav, Collapsible, Dropdown, Tabs } from "materialize-css"; import { UserSettingsUIConfig, UserSettings } from "../model/user-settings/UserSettings"; import BookmarkModule from "../modules/BookmarkModule"; import AnnotationModule from "../modules/AnnotationModule"; -import TTSModule from "../modules/TTSModule"; +import TTSModule, { TTSSpeechConfig } from "../modules/TTS/TTSModule"; import { IS_DEV } from ".."; +import Splitting from "../modules/TTS/splitting"; +import { oc } from "ts-optchain"; export interface UpLinkConfig { url?: URL; @@ -54,6 +56,7 @@ export interface IFrameNavigatorConfig { rights?: ReaderRights; material: boolean; api: any; + tts: any; injectables: Array; selectionMenuItems?: Array; initialAnnotationColor?:string; @@ -78,7 +81,9 @@ export interface SelectionMenuItem { export interface ReaderRights { enableBookmarks?: boolean; enableAnnotations?: boolean; + enableTTS?: boolean; } + export interface ReaderUI { settings: UserSettingsUIConfig; } @@ -92,6 +97,7 @@ export interface ReaderConfig { ui: ReaderUI; material: boolean; api: any; + tts: any; injectables: Array; selectionMenuItems: Array; initialAnnotationColor: string; @@ -122,7 +128,7 @@ export default class IFrameNavigator implements Navigator { currentTOCRawLink: string; private nextChapterLink: Link; private previousChapterLink: Link; - private settings: UserSettings; + settings: UserSettings; private annotator: Annotator | null; private paginator: PaginatedBookView | null; @@ -141,6 +147,13 @@ export default class IFrameNavigator implements Navigator { private previousPageAnchorElement: HTMLAnchorElement; private espandMenuIcon: HTMLElement; + private landmarksView: HTMLDivElement; + private landmarksSection: HTMLDivElement; + private pageListView: HTMLDivElement; + private goToPageView: HTMLLIElement; + private goToPageNumberInput: HTMLInputElement; + private goToPageNumberButton: HTMLButtonElement; + private bookmarksControl: HTMLButtonElement; private bookmarksView: HTMLDivElement; private links: HTMLUListElement; @@ -163,6 +176,8 @@ export default class IFrameNavigator implements Navigator { private isLoading: boolean; private initialLastReadingPosition: ReadingPosition; api: any; + rights: ReaderRights; + tts: TTSSpeechConfig; injectables: Array selectionMenuItems: Array initialAnnotationColor: string @@ -177,6 +192,8 @@ export default class IFrameNavigator implements Navigator { config.publication, config.material, config.api, + config.rights, + config.tts, config.injectables, config.selectionMenuItems || null, config.initialAnnotationColor || null @@ -195,6 +212,8 @@ export default class IFrameNavigator implements Navigator { publication: Publication, material: boolean, api: any, + rights: ReaderRights, + tts: any, injectables: Array, selectionMenuItems: Array | null = null, initialAnnotationColor: string | null = null @@ -209,6 +228,8 @@ export default class IFrameNavigator implements Navigator { this.publication = publication this.material = material this.api = api + this.rights = rights + this.tts = tts this.injectables = injectables this.selectionMenuItems = selectionMenuItems this.initialAnnotationColor = initialAnnotationColor @@ -295,6 +316,14 @@ export default class IFrameNavigator implements Navigator { if (this.headerMenu) this.tocView = HTMLUtilities.findElement(this.headerMenu, "#container-view-toc") as HTMLDivElement; + if (this.headerMenu) this.landmarksView = HTMLUtilities.findElement(headerMenu, "#container-view-landmarks") as HTMLDivElement; + if (this.headerMenu) this.landmarksSection = HTMLUtilities.findElement(headerMenu, "#sidenav-section-landmarks") as HTMLDivElement; + if (this.headerMenu) this.pageListView = HTMLUtilities.findElement(headerMenu, "#container-view-pagelist") as HTMLDivElement; + if (this.headerMenu) this.goToPageView = HTMLUtilities.findElement(headerMenu, "#sidenav-section-gotopage") as HTMLLIElement; + if (this.headerMenu) this.goToPageNumberInput = HTMLUtilities.findElement(headerMenu, "#goToPageNumberInput") as HTMLInputElement; + if (this.headerMenu) this.goToPageNumberButton = HTMLUtilities.findElement(headerMenu, "#goToPageNumberButton") as HTMLButtonElement; + + // Footer Menu if (footerMenu) this.linksBottom = HTMLUtilities.findElement(footerMenu, "ul.links.bottom") as HTMLUListElement; if (footerMenu) this.linksMiddle = HTMLUtilities.findElement(footerMenu, "ul.links.middle") as HTMLUListElement; @@ -377,6 +406,20 @@ export default class IFrameNavigator implements Navigator { clearTimeout(this.timeout); this.timeout = setTimeout(this.handleResize.bind(this), 200); } + reload = async () => { + let lastReadingPosition: ReadingPosition | null = null; + if (this.annotator) { + lastReadingPosition = await this.annotator.getLastReadingPosition() as ReadingPosition | null; + } + + if (lastReadingPosition) { + const linkHref = this.publication.getAbsoluteHref(lastReadingPosition.href); + if (IS_DEV)console.log(lastReadingPosition.href) + if (IS_DEV)console.log(linkHref) + lastReadingPosition.href = linkHref + this.navigate(lastReadingPosition); + } + } private setupEvents(): void { addEventListenerOptional(this.iframe, 'load', this.handleIFrameLoad.bind(this)); @@ -397,6 +440,9 @@ export default class IFrameNavigator implements Navigator { addEventListenerOptional(this.espandMenuIcon, 'click', this.handleEditClick.bind(this)); + addEventListenerOptional(this.goToPageNumberInput, 'keypress', this.goToPageNumber.bind(this)); + addEventListenerOptional(this.goToPageNumberButton, 'click', this.goToPageNumber.bind(this)); + addEventListenerOptional(window, 'resize', this.onResize); } @@ -432,6 +478,35 @@ export default class IFrameNavigator implements Navigator { }); } + private async goToPageNumber(event: any): Promise { + if (this.goToPageNumberInput.value && (event.key === 'Enter' || event.type === "click")) { + var filteredPages = this.publication.pageList.filter((el: any) => el.href.slice(el.href.indexOf("#") + 1).replace(/[^0-9]/g, '') === this.goToPageNumberInput.value); + if (filteredPages && filteredPages.length > 0) { + var firstPage = filteredPages[0] + let locations: Locations = { + progression: 0 + } + if (firstPage.href.indexOf("#") !== -1) { + const elementId = firstPage.href.slice(firstPage.href.indexOf("#") + 1); + if (elementId !== null) { + locations = { + fragment: elementId + } + } + } + const position: Locator = { + href: this.publication.getAbsoluteHref(firstPage.href), + locations: locations, + type: firstPage.type, + title: firstPage.title + }; + + this.stopReadAloud(); + this.navigate(position); + } + } + } + private updateBookView(): void { if (this.settings.getSelectedView() === this.paginator) { document.body.onscroll = () => { }; @@ -527,7 +602,11 @@ export default class IFrameNavigator implements Navigator { } setTimeout(() => { this.updatePositionInfo(); + if (this.annotationModule !== undefined) { + this.annotationModule.drawHighlights() + } }, 100); + } onScroll(): void { @@ -658,6 +737,10 @@ export default class IFrameNavigator implements Navigator { } const toc = this.publication.tableOfContents; + const landmarks = this.publication.landmarks; + const pageList = this.publication.pageList; + + if (this.tocView) { if (toc.length) { createSubmenu(this.tocView, toc); @@ -666,6 +749,30 @@ export default class IFrameNavigator implements Navigator { } } + if (this.pageListView) { + if (pageList.length) { + createSubmenu(this.pageListView, pageList); + } else { + this.pageListView.parentElement.parentElement.removeChild(this.pageListView.parentElement); + } + } + + if (this.goToPageView) { + if (pageList.length) { + // + } else { + this.goToPageView.parentElement.removeChild(this.goToPageView); + } + } + + if (this.landmarksView) { + if (landmarks.length) { + createSubmenu(this.landmarksView, landmarks); + } else { + this.landmarksSection.parentElement.removeChild(this.landmarksSection); + } + } + if ((this.links || this.linksTopLeft) && this.upLinkConfig && this.upLinkConfig.url) { const upUrl = this.upLinkConfig.url; const upLabel = this.upLinkConfig.label || ""; @@ -855,6 +962,17 @@ export default class IFrameNavigator implements Navigator { }); } + setTimeout(() => { + + const body = HTMLUtilities.findRequiredIframeElement(this.iframe.contentDocument, "body") as HTMLBodyElement; + + if (oc(this.rights).enableTTS(false) && oc(this.tts).enableSplitter(false)) { + Splitting({ + target: body, + by: "lines" + }); + } + }, 50); setTimeout(() => { @@ -872,12 +990,14 @@ export default class IFrameNavigator implements Navigator { this.annotationModule.selectionMenuItems = this.selectionMenuItems } } - setTimeout(() => { - if (this.ttsModule !== undefined) { - this.ttsModule.initialize() - } - }, 200); - + if (oc(this.rights).enableTTS(false)) { + setTimeout(() => { + const body = HTMLUtilities.findRequiredIframeElement(this.iframe.contentDocument, "body") as HTMLBodyElement; + if (this.ttsModule !== undefined) { + this.ttsModule.initialize(body) + } + }, 200); + } const body = HTMLUtilities.findRequiredIframeElement(this.iframe.contentDocument, "body") as HTMLBodyElement; var pagebreaks = body.querySelectorAll('[*|type="pagebreak"]'); for (var i = 0; i < pagebreaks.length; i++) { @@ -891,6 +1011,8 @@ export default class IFrameNavigator implements Navigator { }, 100); + + return new Promise(resolve => resolve()); } catch (err) { console.error(err) @@ -1033,27 +1155,37 @@ export default class IFrameNavigator implements Navigator { element.innerText = "unfold_less"; this.sideNavExanded = true this.bookmarkModule.showBookmarks() + this.annotationModule.showHighlights() } else { element.className = element.className.replace(" active", ""); sidenav.className = sidenav.className.replace(" expanded", ""); element.innerText = "unfold_more"; this.sideNavExanded = false this.bookmarkModule.showBookmarks() + this.annotationModule.showHighlights() } event.preventDefault(); event.stopPropagation(); } startReadAloud() { - this.annotationModule.highlighter.speakAll() + if (oc(this.rights).enableTTS(false)) { + this.annotationModule.highlighter.speakAll() + } } stopReadAloud() { - this.annotationModule.highlighter.stopReadAloud() + if (oc(this.rights).enableTTS(false)) { + this.annotationModule.highlighter.stopReadAloud() + } } pauseReadAloud() { - this.ttsModule.speakPause() + if (oc(this.rights).enableTTS(false)) { + this.ttsModule.speakPause() + } } resumeReadAloud() { - this.ttsModule.speakResume() + if (oc(this.rights).enableTTS(false)) { + this.ttsModule.speakResume() + } } totalResources(): number { return this.publication.readingOrder.length @@ -1100,6 +1232,7 @@ export default class IFrameNavigator implements Navigator { if (IS_DEV) console.log(locator.href) if (IS_DEV) console.log(linkHref) position.href = linkHref + this.stopReadAloud(); this.navigate(position); } @@ -1116,6 +1249,7 @@ export default class IFrameNavigator implements Navigator { title: this.previousChapterLink.title }; + this.stopReadAloud(); this.navigate(position); var pagi = this.paginator setTimeout(() => { @@ -1146,6 +1280,7 @@ export default class IFrameNavigator implements Navigator { title: this.previousChapterLink.title }; + this.stopReadAloud(); this.navigate(position); } } else { @@ -1173,6 +1308,7 @@ export default class IFrameNavigator implements Navigator { title: this.nextChapterLink.title }; + this.stopReadAloud(); this.navigate(position); var pagi = this.paginator setTimeout(() => { @@ -1202,6 +1338,7 @@ export default class IFrameNavigator implements Navigator { title: this.nextChapterLink.title }; + this.stopReadAloud(); this.navigate(position); } } else { @@ -1248,6 +1385,7 @@ export default class IFrameNavigator implements Navigator { event.preventDefault(); event.stopPropagation(); + this.stopReadAloud(); this.navigate(position); } @@ -1327,6 +1465,7 @@ export default class IFrameNavigator implements Navigator { title: this.previousChapterLink.title }; + this.stopReadAloud(); this.navigate(position); } if (event) { @@ -1346,6 +1485,7 @@ export default class IFrameNavigator implements Navigator { title: this.nextChapterLink.title }; + this.stopReadAloud(); this.navigate(position); } if (event) { @@ -1384,56 +1524,77 @@ export default class IFrameNavigator implements Navigator { navigate(locator: Locator): void { - this.hideIframeContents(); - this.showLoadingMessageAfterDelay(); - if (locator.locations === undefined) { - locator.locations = { - progression: 0 - } - } - this.newPosition = locator; - this.currentTOCRawLink = locator.href + const exists = this.publication.getTOCItem(locator.href) + if (exists) { + this.hideIframeContents(); + this.showLoadingMessageAfterDelay(); + if (locator.locations === undefined) { + locator.locations = { + progression: 0 + } + } + this.newPosition = locator; + this.currentTOCRawLink = locator.href - if (locator.href.indexOf("#") !== -1) { - const newResource = locator.href.slice(0, locator.href.indexOf("#")) - this.currentChapterLink.href = newResource; - this.currentChapterLink.type = locator.type - this.currentChapterLink.title = locator.title - } else { - this.currentChapterLink.href = locator.href - this.currentChapterLink.type = locator.type - this.currentChapterLink.title = locator.title - } - this.iframe.src = this.currentChapterLink.href + if (locator.href.indexOf("#") !== -1) { + const newResource = locator.href.slice(0, locator.href.indexOf("#")) + this.currentChapterLink.href = newResource; + this.currentChapterLink.type = locator.type + this.currentChapterLink.title = locator.title + } else { + this.currentChapterLink.href = locator.href + this.currentChapterLink.type = locator.type + this.currentChapterLink.title = locator.title + } + this.iframe.src = this.currentChapterLink.href - if (locator.locations.fragment === undefined) { - this.currentTocUrl = null; - } else { - this.newElementId = locator.locations.fragment - this.currentTocUrl = this.currentChapterLink.href + "#" + this.newElementId; - } - setTimeout(() => { - if (this.annotationModule !== undefined) { - this.annotationModule.drawHighlights() + if (locator.locations.fragment === undefined) { + this.currentTocUrl = null; + } else { + this.newElementId = locator.locations.fragment + this.currentTocUrl = this.currentChapterLink.href + "#" + this.newElementId; } + setTimeout(() => { + if (this.annotationModule !== undefined) { + this.annotationModule.drawHighlights() + this.annotationModule.showHighlights(); + } - if (this.settings.getSelectedView() === this.scroller) { - if (this.scroller.atTop() && this.scroller.atBottom()) { - if (this.nextChapterBottomAnchorElement) this.nextChapterBottomAnchorElement.style.display = "unset" - if (this.previousChapterTopAnchorElement) this.previousChapterTopAnchorElement.style.display = "unset" - } else if (this.scroller.atBottom()) { - if (this.nextChapterBottomAnchorElement) this.previousChapterTopAnchorElement.style.display = "none" - if (this.nextChapterBottomAnchorElement) this.nextChapterBottomAnchorElement.style.display = "unset" - } else if (this.scroller.atTop()) { - if (this.nextChapterBottomAnchorElement) this.nextChapterBottomAnchorElement.style.display = "none" - if (this.previousChapterTopAnchorElement) this.previousChapterTopAnchorElement.style.display = "unset" - } else { - if (this.nextChapterBottomAnchorElement) this.nextChapterBottomAnchorElement.style.display = "none" - if (this.previousChapterTopAnchorElement) this.previousChapterTopAnchorElement.style.display = "none" + if (this.settings.getSelectedView() === this.scroller) { + if (this.scroller.atTop() && this.scroller.atBottom()) { + if (this.nextChapterBottomAnchorElement) this.nextChapterBottomAnchorElement.style.display = "unset" + if (this.previousChapterTopAnchorElement) this.previousChapterTopAnchorElement.style.display = "unset" + } else if (this.scroller.atBottom()) { + if (this.nextChapterBottomAnchorElement) this.previousChapterTopAnchorElement.style.display = "none" + if (this.nextChapterBottomAnchorElement) this.nextChapterBottomAnchorElement.style.display = "unset" + } else if (this.scroller.atTop()) { + if (this.nextChapterBottomAnchorElement) this.nextChapterBottomAnchorElement.style.display = "none" + if (this.previousChapterTopAnchorElement) this.previousChapterTopAnchorElement.style.display = "unset" + } else { + if (this.nextChapterBottomAnchorElement) this.nextChapterBottomAnchorElement.style.display = "none" + if (this.previousChapterTopAnchorElement) this.previousChapterTopAnchorElement.style.display = "none" + } } + }, 300); + } else { + const startLink = this.publication.getStartLink(); + let startUrl: string | null = null; + if (startLink && startLink.href) { + startUrl = this.publication.getAbsoluteHref(startLink.href); } - }, 300); + if (startUrl) { + const position: ReadingPosition = { + href: startUrl, + locations: { + progression: 0 + }, + created: new Date(), + title: startLink.title + }; + this.navigate(position); + } + } } diff --git a/src/styles/sass/reader.scss b/src/styles/sass/reader.scss index be0f85be..641f2110 100644 --- a/src/styles/sass/reader.scss +++ b/src/styles/sass/reader.scss @@ -41,3 +41,5 @@ @import "reader/annotations"; // highlight toolbox @import "reader/toolbox"; +// TTS ui tools +@import "reader/tts"; diff --git a/src/styles/sass/reader/_annotations.scss b/src/styles/sass/reader/_annotations.scss index 28a99212..53f8d46a 100644 --- a/src/styles/sass/reader/_annotations.scss +++ b/src/styles/sass/reader/_annotations.scss @@ -16,7 +16,9 @@ * Developed on behalf of: Bokbasen AS (https://www.bokbasen.no) * Licensed to: Bokbasen AS and CAST under one or more contributor license agreements. */ - + :root { + --RS__highlightColor: rgba(255, 255, 0, 0.5); + } .sidenav-annotations { .chapter-link { display: block; @@ -40,12 +42,11 @@ top: 50%; transform: translate(0,-50%); } - .bookmark-link { + .bookmark-link, .highlight-link { display: block; position: relative; padding-top: 8px !important; padding-bottom: 8px !important; - padding-left: 34px !important; line-height: 1.25; svg, i { left: 4px; diff --git a/src/styles/sass/reader/_bookmarks.scss b/src/styles/sass/reader/_bookmarks.scss index 27e6c107..9bfbac10 100644 --- a/src/styles/sass/reader/_bookmarks.scss +++ b/src/styles/sass/reader/_bookmarks.scss @@ -21,7 +21,7 @@ // Bookmarks Styles // ////////////////////////////// -.bookmarks-view{ +.bookmarks-view, .highlights-view{ background-color: $ui-white; overflow: scroll; top: 3.5rem; @@ -65,7 +65,7 @@ } [data-viewer-theme="sepia"] { - .bookmarks-view { + .bookmarks-view, .highlights-view { background-color: $sepia-bg; ol li, @@ -101,7 +101,7 @@ } [data-viewer-theme="night"] { - .bookmarks-view { + .bookmarks-view, .highlights-view { background-color: $night-bg; ol li, diff --git a/src/styles/sass/reader/_toc.scss b/src/styles/sass/reader/_toc.scss index 0db124b0..751549da 100644 --- a/src/styles/sass/reader/_toc.scss +++ b/src/styles/sass/reader/_toc.scss @@ -36,7 +36,7 @@ } -.contents-view { +.contents-view, .pageList-view, .landmarks-view { background-color: $ui-white; overflow: scroll; top: 3.5rem; @@ -88,7 +88,7 @@ } [data-viewer-theme="sepia"] { - .contents-view { + .contents-view, .pageList-view, .landmarks-view { background-color: $sepia-bg; ol li, @@ -124,7 +124,7 @@ } [data-viewer-theme="night"] { - .contents-view { + .contents-view, .pageList-view, .landmarks-view { background-color: $night-bg; ol li, diff --git a/src/styles/sass/reader/_tts.scss b/src/styles/sass/reader/_tts.scss new file mode 100644 index 00000000..85db03b3 --- /dev/null +++ b/src/styles/sass/reader/_tts.scss @@ -0,0 +1,285 @@ + + + +.thumb { + border-radius: 50% 50% 50% 0 !important; + height: 30px !important; + width: 30px !important; + margin-left: -15px !important; + top: -20px !important; + left: 50%; +} + +input[type=range] + .thumb .value { + // color: #fff; + margin-left: -1px; + margin-top: 8px; + font-size: 10px; +} + + + + +// radios +$checkbox-size: 16px; +$margin: 16px; +$margin-small: $margin / 2; +$text-lighter: #ccc; +$brand: rgb(16, 16, 16);; + + + +input[type=checkbox] label { + display: flex; + flex-direction: row; + align-items: center; +} + + +input[type=checkbox] { + position: relative !important; + appearance: none; +// margin: $margin-small; + box-sizing: content-box; + overflow: hidden; + + // circle + &:before { + content: ''; + display: block; + box-sizing: content-box; + width: $checkbox-size; + height: $checkbox-size; + border: 2px solid $text-lighter; + transition: 0.2s border-color ease; + } + + &:checked:before { + border-color: $brand; + transition: 0.5s border-color ease; + } + + &:disabled:before { + border-color: $text-lighter; + background-color: $text-lighter; + } + + // dot + &:after { + content: ''; + display: block; + position: absolute; + box-sizing: content-box; + top: 50%; + left: 50%; + transform-origin: 50% 50%; + background-color: $brand; + width: $checkbox-size; + height: $checkbox-size; + border-radius: 100vh; + transform: translate(-50%, -50%) scale(0); + } + + &[type="radio"] { + &:before { + border-radius: 100vh; + } + + &:after { + width: $checkbox-size; + height: $checkbox-size; + border-radius: 100vh; + transform: translate(-50%, -50%) scale(0); + } + + &:checked:after { + animation: toggleOnRadio 0.2s ease forwards; + } + } + + &[type="checkbox"] { + &:before { + border-radius: $checkbox-size / 4; + } + + &:after { + width: $checkbox-size * 0.6; + height: $checkbox-size; + border-radius: 0; + transform: translate(-50%, -85%) scale(0) rotate(45deg); + background-color: transparent; + box-shadow: 4px 4px 0px 0px $brand; + } + + &:checked:after { + animation: toggleOnCheckbox 0.2s ease forwards; + } + } + + &[type="checkbox"].filled { + &:before { + border-radius: $checkbox-size / 4; + transition: 0.2s border-color ease, 0.2s background-color ease; + } + + &:checked:not(:disabled):before { + background-color: $brand; + } + + &:not(:disabled):after { + box-shadow: 4px 4px 0px 0px white; + } + } +} + +@keyframes toggleOnCheckbox { + 0% { + opacity: 0; + transform: translate(-50%, -85%) scale(0) rotate(45deg); + } + + 70% { + opacity: 1; + transform: translate(-50%, -85%) scale(0.9) rotate(45deg); + } + + 100% { + transform: translate(-50%, -85%) scale(0.8) rotate(45deg); + } +} + +@keyframes toggleOnRadio { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0); + } + + 70% { + opacity: 1; + transform: translate(-50%, -50%) scale(0.9); + } + + 100% { + transform: translate(-50%, -50%) scale(0.8); + } +} + + +$shade-10: #2c3e50 !default; +$shade-1: #d7dcdf !default; +$shade-0: #fff !default; +$teal: #1abc9c !default; + + +// Range Slider +$range-width: 100% !default; + +$range-handle-color: $shade-10 !default; +$range-handle-color-hover: $teal !default; +$range-handle-size: 20px !default; + +$range-track-color: $shade-1 !default; +$range-track-height: 10px !default; + +$range-label-color: $shade-10 !default; +$range-label-width: 60px !default; + +.range-slider { +width: $range-width; +} + +.range-slider__range { +-webkit-appearance: none; +width: calc(100% - (#{$range-label-width + 13px})); +height: $range-track-height; +border-radius: 5px; +background: $range-track-color; +outline: none; +padding: 0; +margin: 0; + +// Range Handle +&::-webkit-slider-thumb { + appearance: none; + width: $range-handle-size; + height: $range-handle-size; + border-radius: 50%; + background: $range-handle-color; + cursor: pointer; + transition: .15s ease-in-out; + + &:hover { + background: $range-handle-color-hover; + } +} + +&:active::-webkit-slider-thumb { + background: $range-handle-color-hover; +} + +&::-moz-range-thumb { + width: $range-handle-size; + height: $range-handle-size; + border: 0; + border-radius: 50%; + background: $range-handle-color; + cursor: pointer; + transition: .15s ease-in-out; + + &:hover { + background: $range-handle-color-hover; + } +} + +&:active::-moz-range-thumb { + background: $range-handle-color-hover; +} + +// Focus state +&:focus { + + &::-webkit-slider-thumb { + box-shadow: 0 0 0 3px $shade-0, + 0 0 0 6px $teal; + } +} +} + + +// Range Label +.range-slider__value { +display: inline-block; +position: relative; +width: $range-label-width; +color: $shade-0; +line-height: 20px; +text-align: center; +border-radius: 3px; +background: $range-label-color; +padding: 5px 10px; +margin-left: 8px; + +&:after { + position: absolute; + top: 8px; + left: -7px; + width: 0; + height: 0; + border-top: 7px solid transparent; + border-right: 7px solid $range-label-color; + border-bottom: 7px solid transparent; + content: ''; +} +} + + +// Firefox Overrides +::-moz-range-track { + background: $range-track-color; + border: 0; +} + +input::-moz-focus-inner, +input::-moz-focus-outer { +border: 0; +} diff --git a/src/views/ColumnsPaginatedBookView.ts b/src/views/ColumnsPaginatedBookView.ts index f3425e59..f095d1b8 100644 --- a/src/views/ColumnsPaginatedBookView.ts +++ b/src/views/ColumnsPaginatedBookView.ts @@ -22,7 +22,7 @@ import * as HTMLUtilities from "../utils/HTMLUtilities"; import * as BrowserUtilities from "../utils/BrowserUtilities"; export default class ColumnsPaginatedBookView implements PaginatedBookView { - public readonly name = "columns-paginated-view"; + public readonly name = "readium-scroll-off"; public readonly label = "Paginated"; public iframe: HTMLIFrameElement; diff --git a/src/views/ScrollingBookView.ts b/src/views/ScrollingBookView.ts index 82f01fac..2f8c2dd8 100644 --- a/src/views/ScrollingBookView.ts +++ b/src/views/ScrollingBookView.ts @@ -53,7 +53,7 @@ export default class ScrollingBookView implements ContinuousBookView { return windowBottom - windowTop - 100 } - public readonly name = "scrolling-book-view"; + public readonly name = "readium-scroll-on"; public readonly label = "Scrolling"; public iframe: HTMLIFrameElement; diff --git a/viewer/index_api.html b/viewer/index_api.html index d81dcff6..f454cf4e 100644 --- a/viewer/index_api.html +++ b/viewer/index_api.html @@ -42,12 +42,13 @@
-
+




+



@@ -84,6 +85,20 @@

+ + +
+
+
+
+
+
+
+
+
+
+ +


@@ -172,6 +187,11 @@ })">show annotations
+
+
@@ -191,6 +211,9 @@ + @@ -255,10 +278,10 @@ var userSettings = { // appearance: "readium-sepia-on", //readium-default-on, readium-night-on, readium-sepia-on - // fontFamily: "serif", //Original, serif, sans-serif + // fontFamily: "sans-serif", //Original, serif, sans-serif // textAlignment: "justify", //"auto", "justify", "start" // columnCount: "auto", // "auto", "1", "2" - // verticalScroll: false, + // verticalScroll: "readium-scroll-off", //readium-scroll-on, readium-scroll-off, // fontSize: 100.0, // wordSpacing: 0.0, // letterSpacing: 0.0, @@ -277,13 +300,29 @@ var urlParams = getURLQueryParams(); var upLink = { url: new URL("/", window.location.href), label: "Back", ariaLabel: "Go back" }; + // var lastReadingPosition = '{"href":"http://localhost:4444/pub/L1ZvbHVtZXMvRGF0YS9Cb2tiYXNlbi9yMi1uYXZpZ2F0b3ItcG9jL2V4YW1wbGVzL3N0cmVhbWVkL2VwdWJzL0ZyYW5rZW5zdGVpbi5lcHVi/OEBPS/Text/cover.xhtml","locations":{"progression":0},"created":"2019-05-21T12:53:08.145Z","type":"application/xhtml+xml","title":"Cover"}'; D2Reader.load({ url: new URL(urlParams['url']), rights: { enableBookmarks: true, - enableAnnotations: true + enableAnnotations: true, + enableTTS: true + }, + tts: { + enableSplitter: true, + // highlight: "lines", + // autoScroll: false, + // rate: 1.2, + // pitch: 1.0, + // volume: 0.5, + // voice: { + // usePublication: true, + // name : "Daniel", + // lang : "en-GB", + // } }, + // lastReadingPosition: lastReadingPosition, injectables: injectables, selectionMenuItems: selectionMenuItems, // initialAnnotationColor: "#ff8500", @@ -292,7 +331,6 @@ useLocalStorage: true // true = uses local storage, false = uses session storage (default) }).then(instance => { console.log("D2Reader loaded ", instance); - console.log("instance.bookmarkModule ", instance.bookmarkModule) }).catch(error => { console.error("error.message ", error.message); }); diff --git a/viewer/index_material.html b/viewer/index_material.html index 38a55e28..f170dc90 100644 --- a/viewer/index_material.html +++ b/viewer/index_material.html @@ -51,6 +51,8 @@