diff --git a/scripts/langindex.json b/scripts/langindex.json index 3f8d345ee..db523decd 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -2680,6 +2680,19 @@ "core.viewcode": "local_moodlemobileapp", "core.vieweditor": "local_moodlemobileapp", "core.viewembeddedcontent": "local_moodlemobileapp", + "core.viewer.decreasetextsize": "local_moodlemobileapp", + "core.viewer.default": "moodle", + "core.viewer.enterreadingmode": "local_moodlemobileapp", + "core.viewer.exitreadingmode": "local_moodlemobileapp", + "core.viewer.increasetextsize": "local_moodlemobileapp", + "core.viewer.openreadingmodesettings": "local_moodlemobileapp", + "core.viewer.readingthemeauto": "local_moodlemobileapp", + "core.viewer.readingthemedark": "local_moodlemobileapp/core.settings.colorscheme-dark", + "core.viewer.readingthemehcm": "local_moodlemobileapp", + "core.viewer.readingthemelight": "local_moodlemobileapp/core.settings.colorscheme-light", + "core.viewer.readingthemesepia": "local_moodlemobileapp", + "core.viewer.showmedia": "zoom", + "core.viewer.theme": "moodle", "core.viewprofile": "moodle", "core.wanttochangesite": "local_moodlemobileapp", "core.warningofflinedatadeleted": "local_moodlemobileapp", diff --git a/src/addons/mod/book/pages/contents/contents.html b/src/addons/mod/book/pages/contents/contents.html index 22501f112..83191f3c5 100644 --- a/src/addons/mod/book/pages/contents/contents.html +++ b/src/addons/mod/book/pages/contents/contents.html @@ -24,7 +24,7 @@
-
+
diff --git a/src/addons/mod/book/tests/behat/snapshots/test-basic-usage-of-book-activity-in-app-open-chapters-from-table-of-contents_11.png b/src/addons/mod/book/tests/behat/snapshots/test-basic-usage-of-book-activity-in-app-open-chapters-from-table-of-contents_11.png index 2f932c02b..416674093 100644 Binary files a/src/addons/mod/book/tests/behat/snapshots/test-basic-usage-of-book-activity-in-app-open-chapters-from-table-of-contents_11.png and b/src/addons/mod/book/tests/behat/snapshots/test-basic-usage-of-book-activity-in-app-open-chapters-from-table-of-contents_11.png differ diff --git a/src/addons/mod/glossary/pages/entry/entry.html b/src/addons/mod/glossary/pages/entry/entry.html index 02d6f6278..2f4a80db0 100644 --- a/src/addons/mod/glossary/pages/entry/entry.html +++ b/src/addons/mod/glossary/pages/entry/entry.html @@ -8,6 +8,7 @@ + @@ -23,72 +24,77 @@ {{ 'core.hasdatatosync' | translate: { $a: 'addon.mod_glossary.entry' | translate } }} - - - -

- -

-

{{ onlineEntry.userfullname }}

-
- {{ onlineEntry.timemodified | coreDateDayOrTime }} -
- - -

- -

-
- {{ onlineEntry.timemodified | coreDateDayOrTime }} -
- - - - - -
- -
-
- -
-
- -
- - -
{{ 'core.tag.tags' | translate }}:
- -
-
- -
- - - - +
+ + + +

+ +

+

{{ onlineEntry.userfullname }}

+
+ {{ onlineEntry.timemodified | coreDateDayOrTime }} +
+ + +

+ +

+
+ {{ onlineEntry.timemodified | coreDateDayOrTime }} +
+ + + + + +
+
- - - -

{{ 'addon.mod_glossary.entrypendingapproval' | translate }}

-
-
- - - +
+ +
+
+ +
+ + +
{{ 'core.tag.tags' | translate }}:
+ +
+
+ +
+ + + + +
+
+ + +

{{ 'addon.mod_glossary.entrypendingapproval' | translate }}

+
+
+ + + +
diff --git a/src/addons/mod/page/components/index/addon-mod-page-index.html b/src/addons/mod/page/components/index/addon-mod-page-index.html index a8eaf4b32..eb99ae3e6 100644 --- a/src/addons/mod/page/components/index/addon-mod-page-index.html +++ b/src/addons/mod/page/components/index/addon-mod-page-index.html @@ -19,7 +19,7 @@ -
+
diff --git a/src/core/components/navbar-buttons/navbar-buttons.ts b/src/core/components/navbar-buttons/navbar-buttons.ts index e06a88435..6fc5179f0 100644 --- a/src/core/components/navbar-buttons/navbar-buttons.ts +++ b/src/core/components/navbar-buttons/navbar-buttons.ts @@ -89,7 +89,7 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy { */ async ngOnInit(): Promise { try { - const header = await this.searchHeader(); + const header = await CoreDom.findIonHeaderFromElement(this.element); if (header) { // Search the right buttons container (start, end or any). let selector = 'ion-buttons'; @@ -192,43 +192,6 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy { return componentRef.instance; } - /** - * Search the ion-header where the buttons should be added. - * - * @returns Promise resolved with the header element. - */ - protected async searchHeader(): Promise { - await CoreDom.waitToBeInDOM(this.element); - let parentPage: HTMLElement | null = this.element; - - while (parentPage && parentPage.parentElement) { - const content = parentPage.closest('ion-content'); - if (content) { - // Sometimes ion-page class is not yet added by the ViewController, wait for content to render. - await content.componentOnReady(); - } - - parentPage = parentPage.parentElement.closest('.ion-page, .ion-page-hidden, .ion-page-invisible'); - - // Check if the page has a header. If it doesn't, search the next parent page. - let header = parentPage?.querySelector(':scope > ion-header'); - - if (header && getComputedStyle(header).display !== 'none') { - return header; - } - - // Find using content if any. - header = content?.parentElement?.querySelector(':scope > ion-header'); - - if (header && getComputedStyle(header).display !== 'none') { - return header; - } - } - - // Header not found, reject. - throw Error('Header not found.'); - } - /** * Show or hide all the elements. */ diff --git a/src/core/directives/collapsible-header.ts b/src/core/directives/collapsible-header.ts index 1f09bad8f..d671d98a8 100644 --- a/src/core/directives/collapsible-header.ts +++ b/src/core/directives/collapsible-header.ts @@ -100,6 +100,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest constructor(el: ElementRef) { this.collapsedHeader = el.nativeElement; + CoreDirectivesRegistry.register(this.collapsedHeader, this); } /** diff --git a/src/core/directives/directives.module.ts b/src/core/directives/directives.module.ts index 6225f56a1..f26fc2ab2 100644 --- a/src/core/directives/directives.module.ts +++ b/src/core/directives/directives.module.ts @@ -35,6 +35,7 @@ import { CoreContentDirective } from './content'; import { CoreUpdateNonReactiveAttributesDirective } from './update-non-reactive-attributes'; import { CoreUserTourDirective } from './user-tour'; import { CoreIonDatetimeDirective } from './datetime'; +import { CoreReadingModeDirective } from './reading-mode'; @NgModule({ declarations: [ @@ -59,6 +60,7 @@ import { CoreIonDatetimeDirective } from './datetime'; CoreUpdateNonReactiveAttributesDirective, CoreUserTourDirective, CoreIonDatetimeDirective, + CoreReadingModeDirective, ], exports: [ CoreAutoFocusDirective, @@ -82,6 +84,7 @@ import { CoreIonDatetimeDirective } from './datetime'; CoreUpdateNonReactiveAttributesDirective, CoreUserTourDirective, CoreIonDatetimeDirective, + CoreReadingModeDirective, ], }) export class CoreDirectivesModule {} diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index 800756f0a..93379a63e 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -67,7 +67,7 @@ import { CorePromiseUtils } from '@singletons/promise-utils'; * Please use this directive if your text needs to be filtered or it can contain links or media (images, audio, video). * * Example usage: - * + * */ @Directive({ selector: 'core-format-text', diff --git a/src/core/directives/reading-mode.ts b/src/core/directives/reading-mode.ts new file mode 100644 index 000000000..f46a5f124 --- /dev/null +++ b/src/core/directives/reading-mode.ts @@ -0,0 +1,232 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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. + +import { + AfterViewInit, + Directive, + ElementRef, + OnDestroy, +} from '@angular/core'; + +import { Translate } from '@singletons'; +import { CoreIcons } from '@singletons/icons'; +import { CoreDom } from '@singletons/dom'; +import { CoreWait } from '@singletons/wait'; +import { CoreCancellablePromise } from '@classes/cancellable-promise'; +import { CoreModals } from '@services/modals'; +import { CoreViewer } from '@features/viewer/services/viewer'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; +import { CoreCollapsibleHeaderDirective } from './collapsible-header'; +import { CoreLogger } from '@singletons/logger'; + +/** + * Directive to add the reading mode to the selected html tag. + * + * Example usage: + *
+ */ +@Directive({ + selector: '[core-reading-mode]', +}) +export class CoreReadingModeDirective implements AfterViewInit, OnDestroy { + + protected element: HTMLElement; + protected viewportPromise?: CoreCancellablePromise; + protected disabledStyles: HTMLStyleElement[] = []; + protected hiddenElements: HTMLElement[] = []; + protected renamedStyles: HTMLElement[] = []; + protected enabled = false; + protected header?: CoreCollapsibleHeaderDirective; + protected logger = CoreLogger.getInstance('CoreReadingModeDirective'); + + constructor( + element: ElementRef, + ) { + this.element = element.nativeElement; + this.viewportPromise = CoreDom.waitToBeInViewport(this.element); + } + + /** + * @inheritdoc + */ + async ngAfterViewInit(): Promise { + await this.viewportPromise; + await CoreWait.nextTick(); + await this.addTextViewerButton(); + + this.enabled = document.body.classList.contains('core-reading-mode-enabled'); + if (this.enabled) { + await this.enterReadingMode(); + } + } + + /** + * Add text viewer button to enable the reading mode. + */ + protected async addTextViewerButton(): Promise { + const page = CoreDom.closest(this.element, '.ion-page'); + const contentEl = page?.querySelector('ion-content') ?? undefined; + + const header = await CoreDom.findIonHeaderFromElement(this.element); + const buttonsContainer = header?.querySelector('ion-toolbar ion-buttons[slot="end"]'); + if (!buttonsContainer || !contentEl) { + this.logger.warn('The header was not found, or it didn\'t have any ion-buttons on slot end.'); + + return; + } + + contentEl.classList.add('core-reading-mode-content'); + + if (buttonsContainer.querySelector('.core-text-viewer-button')) { + + return; + } + + const collapsibleHeader = CoreDirectivesRegistry.resolve(header, CoreCollapsibleHeaderDirective); + if (collapsibleHeader) { + this.header = collapsibleHeader; + } + + const label = Translate.instant('core.viewer.enterreadingmode'); + const button = document.createElement('ion-button'); + + button.classList.add('core-text-viewer-button'); + button.setAttribute('aria-label', label); + button.setAttribute('fill', 'clear'); + + const iconName = 'book-open-reader'; + const src = CoreIcons.getIconSrc('font-awesome', 'solid', iconName); + // Add an ion-icon item to apply the right styles, but the ion-icon component won't be executed. + button.innerHTML = ``; + buttonsContainer.appendChild(button); + + button.addEventListener('click', (e: Event) => { + if (!this.element.innerHTML) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + if (!this.enabled) { + this.enterReadingMode(); + } else { + this.showReadingSettings(); + } + }); + } + + /** + * Enters the reading mode. + */ + protected async enterReadingMode(): Promise { + this.enabled = true; + CoreViewer.loadReadingModeSettings(); + + this.header?.setEnabled(false); + + document.body.classList.add('core-reading-mode-enabled'); + + // Disable all styles in element. + this.disabledStyles = Array.from(this.element.querySelectorAll('style:not(disabled)')); + this.disabledStyles.forEach((style) => { + style.disabled = true; + }); + + // Rename style attributes on DOM elements. + this.renamedStyles = Array.from(this.element.querySelectorAll('*[style]')); + this.renamedStyles.forEach((element: HTMLElement) => { + this.renamedStyles.push(element); + element.setAttribute('data-original-style', element.getAttribute('style') || ''); + element.removeAttribute('style'); + }); + + // Navigate to parent hidding all other elements. + let currentChild = this.element; + let parent = currentChild.parentElement; + while (parent && parent.tagName.toLowerCase() !== 'ion-content') { + Array.from(parent.children).forEach((child: HTMLElement) => { + if (child !== currentChild && child.tagName.toLowerCase() !== 'swiper-slide') { + this.hiddenElements.push(child); + child.classList.add('hide-on-reading-mode'); + } + }); + + currentChild = parent; + parent = currentChild.parentElement; + } + } + + /** + * Disable the reading mode. + */ + protected async disableReadingMode(): Promise { + this.enabled = false; + document.body.classList.remove('core-reading-mode-enabled'); + + this.header?.setEnabled(true); + + // Enable all styles in element. + this.disabledStyles.forEach((style) => { + style.disabled = false; + }); + this.disabledStyles = []; + + // Rename style attributes on DOM elements. + this.renamedStyles.forEach((element) => { + element.setAttribute('style', element.getAttribute('data-original-style') || ''); + element.removeAttribute('data-original-style'); + }); + this.renamedStyles = []; + + this.hiddenElements.forEach((element) => { + element.classList.remove('hide-on-reading-mode'); + }); + this.hiddenElements = []; + } + + /** + * Show the reading settings. + */ + protected async showReadingSettings(): Promise { + const { CoreReadingModeSettingsModalComponent } = + await import('@features/viewer/components/reading-mode-settings/reading-mode-settings'); + + const exit = await CoreModals.openModal({ + component: CoreReadingModeSettingsModalComponent, + initialBreakpoint: 1, + breakpoints: [0, 1], + cssClass: 'core-modal-auto-height', + }); + + if (exit) { + this.disableReadingMode(); + } + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.viewportPromise?.cancel(); + + if (this.enabled && document.body.querySelectorAll('[core-reading-mode]')) { + // Do not disable if there are more instances of the directive in the DOM. + + return; + } + this.disableReadingMode(); + } + +} diff --git a/src/core/features/viewer/components/reading-mode-settings/reading-mode-settings.html b/src/core/features/viewer/components/reading-mode-settings/reading-mode-settings.html new file mode 100644 index 000000000..f50e182ba --- /dev/null +++ b/src/core/features/viewer/components/reading-mode-settings/reading-mode-settings.html @@ -0,0 +1,53 @@ + + + + + + + + +
+ + +
{{ 'core.settings.fontsize' | translate }}
+
+
{{settings.zoom}}% @if (defaultZoom) { {{ 'core.viewer.default' | translate }} }
+
+ + + + + + + + + + + {{ 'core.viewer.theme' | translate }} + + + + @for (theme of themes; track $index; let last = $last;) { + + +
Aa
{{ 'core.viewer.readingtheme'+theme | translate }} +
+
+ } +
+ + +

{{ 'core.viewer.showmedia' | translate }}

+
+
+ + +
diff --git a/src/core/features/viewer/components/reading-mode-settings/reading-mode-settings.scss b/src/core/features/viewer/components/reading-mode-settings/reading-mode-settings.scss new file mode 100644 index 000000000..93cf3faf0 --- /dev/null +++ b/src/core/features/viewer/components/reading-mode-settings/reading-mode-settings.scss @@ -0,0 +1,55 @@ +@use "theme/globals" as *; + +ion-button ion-icon.zoom-decrease { + font-size: 1.5em; +} + +ion-button ion-icon.zoom-increase { + font-size: 2em; +} + +ion-radio.reading-theme { + + &::part(label) { + margin: 0px; + display: inline-flex; + align-items: center; + } + + .preview { + width: 40px; + height: 40px; + border-radius: 100%; + @include margin(4px, 16px, 4px, 2px); + text-align: center; + line-height: 40px; + font-size: #{dynamic-font(16px)}; + font-weight: bold; + border: 1px solid var(--stroke); + + &.auto { + background: linear-gradient(to right, #{$background-color-dark} 50%, #{$background-color} 50%); + color: #{$text-color}; + &::first-letter { + color: #{$text-color-dark}; + } + } + &.light { + background-color: #{$background-color}; + color: #{$text-color}; + } + &.dark { + background-color: #{$background-color-dark}; + color: #{$text-color-dark}; + } + &.sepia { + background-color: var(--core-reading-mode-sepia-background); + color: var(--core-reading-mode-sepia-text-color); + + } + &.hcm { + background-color: black; + color: white; + } + } +} diff --git a/src/core/features/viewer/components/reading-mode-settings/reading-mode-settings.ts b/src/core/features/viewer/components/reading-mode-settings/reading-mode-settings.ts new file mode 100644 index 000000000..3bf67c5c4 --- /dev/null +++ b/src/core/features/viewer/components/reading-mode-settings/reading-mode-settings.ts @@ -0,0 +1,95 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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. + +import { CoreSharedModule } from '@/core/shared.module'; +import { Component, OnInit } from '@angular/core'; +import { + CORE_READING_MODE_DEFAULT_SETTINGS, + CoreViewerReadingModeThemes, + CoreViewerReadingModeThemesType, +} from '@features/viewer/constants'; +import { CoreViewer } from '@features/viewer/services/viewer'; + +import { ModalController } from '@singletons'; +import { CoreMath } from '@singletons/math'; + +/** + * Component to display a text modal. + */ +@Component({ + selector: 'core-reading-mode-settings-modal', + templateUrl: 'reading-mode-settings.html', + styleUrl: 'reading-mode-settings.scss', + standalone: true, + imports: [ + CoreSharedModule, + ], +}) +export class CoreReadingModeSettingsModalComponent implements OnInit { + + readonly MAX_TEXT_SIZE_ZOOM = 200; + readonly MIN_TEXT_SIZE_ZOOM = 75; + readonly TEXT_SIZE_ZOOM_STEP = 25; + + settings = CORE_READING_MODE_DEFAULT_SETTINGS; + + defaultZoom = true; + + themes: CoreViewerReadingModeThemesType[] = Object.values(CoreViewerReadingModeThemes); + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + this.settings = await CoreViewer.getReadingModeSettings(); + } + + /** + * Close modal. + */ + closeModal(): void { + ModalController.dismiss(); + } + + /** + * Close modal. + */ + exit(): void { + ModalController.dismiss(true); + } + + /** + * Change text size zoom. + * + * @param newTextSizeZoom New text size zoom. + */ + changeTextSizeZoom(newTextSizeZoom: number): void { + this.settings.zoom = CoreMath.clamp( + newTextSizeZoom, + this.MIN_TEXT_SIZE_ZOOM, + this.MAX_TEXT_SIZE_ZOOM, + ); + + this.defaultZoom = this.settings.zoom === 100; + this.onSettingChange(); + } + + /** + * Save settings on any change. + */ + onSettingChange(): void { + CoreViewer.setReadingModeSettings(this.settings); + } + +} diff --git a/src/core/features/viewer/constants.ts b/src/core/features/viewer/constants.ts new file mode 100644 index 000000000..3b56833d1 --- /dev/null +++ b/src/core/features/viewer/constants.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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. + +import { CoreViewerReadingModeSettings } from './services/viewer'; + +export const CORE_READING_MODE_SETTINGS = 'CoreReadingModeSettings'; + +export const CoreViewerReadingModeThemes = { + AUTO: 'auto', // eslint-disable-line @typescript-eslint/naming-convention + LIGHT: 'light', // eslint-disable-line @typescript-eslint/naming-convention + DARK: 'dark', // eslint-disable-line @typescript-eslint/naming-convention + SEPIA: 'sepia', // eslint-disable-line @typescript-eslint/naming-convention + HCM: 'hcm', // eslint-disable-line @typescript-eslint/naming-convention +} as const; + +export type CoreViewerReadingModeThemesType = typeof CoreViewerReadingModeThemes[keyof typeof CoreViewerReadingModeThemes]; + +export const CORE_READING_MODE_DEFAULT_SETTINGS: CoreViewerReadingModeSettings = { + zoom: 100, + showMultimedia: false, + theme: CoreViewerReadingModeThemes.HCM, +}; diff --git a/src/core/features/viewer/lang.json b/src/core/features/viewer/lang.json new file mode 100644 index 000000000..a13928951 --- /dev/null +++ b/src/core/features/viewer/lang.json @@ -0,0 +1,15 @@ +{ + "decreasetextsize": "Decrease text size", + "default": "(Default)", + "enterreadingmode": "Enter reading mode", + "exitreadingmode": "Exit reading mode", + "increasetextsize": "Increase text size", + "openreadingmodesettings": "Open reading mode settings", + "readingthemeauto": "Match app", + "readingthemedark": "Dark", + "readingthemehcm": "High contrast", + "readingthemelight": "Light", + "readingthemesepia": "Sepia", + "showmedia": "Show images and media", + "theme": "Theme" +} diff --git a/src/core/features/viewer/services/viewer.ts b/src/core/features/viewer/services/viewer.ts index 98b38f676..9efcfc277 100644 --- a/src/core/features/viewer/services/viewer.ts +++ b/src/core/features/viewer/services/viewer.ts @@ -15,10 +15,17 @@ import { ContextLevel } from '@/core/constants'; import { Injectable } from '@angular/core'; import { ModalOptions } from '@ionic/angular'; +import { CoreConfig } from '@services/config'; import { CoreModals } from '@services/modals'; import { CoreNavigator } from '@services/navigator'; import { CoreWSFile } from '@services/ws'; import { makeSingleton } from '@singletons'; +import { + CORE_READING_MODE_SETTINGS, + CoreViewerReadingModeThemes, + CoreViewerReadingModeThemesType, + CORE_READING_MODE_DEFAULT_SETTINGS, +} from '../constants'; /** * Viewer services. @@ -97,6 +104,49 @@ export class CoreViewerService { await CoreNavigator.navigateToSitePath('viewer/iframe', { params: { title, url, autoLogin } }); } + /** + * Get reading mode settings. + * + * @returns Reading mode settings. + */ + async getReadingModeSettings(): Promise { + return CoreConfig.getJSON(CORE_READING_MODE_SETTINGS, CORE_READING_MODE_DEFAULT_SETTINGS); + } + + /** + * Load and apply reading mode settings. + */ + async loadReadingModeSettings(): Promise { + const settings = await this.getReadingModeSettings(); + + this.applyReadingModeSettings(settings); + } + + /** + * Apply the reading mode settings to the DOM. + * + * @param settings Settings to apply. + */ + protected applyReadingModeSettings(settings: CoreViewerReadingModeSettings): void { + document.body.style.setProperty('--reading-mode-zoom', settings.zoom + '%'); + Object.values(CoreViewerReadingModeThemes).forEach((theme) => { + document.body.classList.remove(`core-reading-mode-theme-${theme}`); + }); + document.body.classList.add(`core-reading-mode-theme-${settings.theme}`); + document.body.classList.toggle('core-reading-mode-multimedia-hidden', !settings.showMultimedia); + } + + /** + * Save reading mode settings. + * + * @param settings Settings to save. + */ + async setReadingModeSettings(settings: CoreViewerReadingModeSettings): Promise { + await CoreConfig.setJSON(CORE_READING_MODE_SETTINGS, settings); + + this.applyReadingModeSettings(settings); + } + } export const CoreViewer = makeSingleton(CoreViewerService); @@ -114,3 +164,9 @@ export type CoreViewerTextOptions = { displayCopyButton?: boolean; // Whether to display a button to copy the text. modalOptions?: Partial; // Modal options. }; + +export type CoreViewerReadingModeSettings = { + zoom: number; // Zoom level. + showMultimedia: boolean; // Show images and multimedia. + theme: CoreViewerReadingModeThemesType; // Theme to use. +}; diff --git a/src/core/services/app.ts b/src/core/services/app.ts index 5da40dbb9..c8c4165ee 100644 --- a/src/core/services/app.ts +++ b/src/core/services/app.ts @@ -346,7 +346,8 @@ export class CoreAppProvider { */ setSystemUIColors(): void { this.setStatusBarColor(); - this.setAndroidNavigationBarColor(); } + this.setAndroidNavigationBarColor(); + } /** * Set StatusBar color depending on platform. diff --git a/src/core/services/config.ts b/src/core/services/config.ts index aa27a80f2..53aa6b4f5 100644 --- a/src/core/services/config.ts +++ b/src/core/services/config.ts @@ -24,6 +24,7 @@ import { CoreDatabaseTable } from '@classes/database/database-table'; import { asyncInstance } from '../utils/async-instance'; import { CorePromisedValue } from '@classes/promised-value'; import { CoreBrowser } from '@singletons/browser'; +import { CoreText } from '@singletons/text'; declare module '@singletons/events' { @@ -118,6 +119,30 @@ export class CoreConfigProvider { } } + /** + * Get an app setting with json format + * + * @param name The config name. + * @param defaultValue Default value to use if the entry is not found. + * @returns Resolves upon success along with the config data. Reject on failure. + */ + async getJSON(name: string, defaultValue?: T): Promise { + try { + const configString = await CoreConfig.get(name); + if (!configString) { + throw new Error('Config not found'); + } + + return CoreText.parseJSON(configString, defaultValue); + } catch (error) { + if (defaultValue !== undefined) { + return defaultValue; + } + + throw error; + } + } + /** * Get an app setting directly from the database, without using any optimizations.. * @@ -152,12 +177,21 @@ export class CoreConfigProvider { * * @param name The config name. * @param value The config value. Can only store number or strings. - * @returns Promise resolved when done. */ async set(name: string, value: number | string): Promise { await this.table.insert({ name, value }); } + /** + * Set an app setting with json format. + * + * @param name The config name. + * @param value The config value. Can only store objects. + */ + async setJSON(name: string, value: unknown): Promise { + await this.set(name, JSON.stringify(value)); + } + /** * Update config with the given values. * diff --git a/src/core/singletons/dom.ts b/src/core/singletons/dom.ts index 0a2ddea57..f7cad2467 100644 --- a/src/core/singletons/dom.ts +++ b/src/core/singletons/dom.ts @@ -781,6 +781,44 @@ export class CoreDom { return !!units && units.length > 1; } + /** + * Search the ion-header of the page. + * This function is usually used to find the header of a page to add buttons. + * + * @returns The header element if found. + */ + static async findIonHeaderFromElement(element: HTMLElement): Promise { + await CoreDom.waitToBeInDOM(element); + let parentPage: HTMLElement | null = element; + + while (parentPage && parentPage.parentElement) { + const content = parentPage.closest('ion-content'); + if (content) { + // Sometimes ion-page class is not yet added by the ViewController, wait for content to render. + await content.componentOnReady(); + } + + parentPage = parentPage.parentElement.closest('.ion-page, .ion-page-hidden, .ion-page-invisible'); + + // Check if the page has a header. If it doesn't, search the next parent page. + let header = parentPage?.querySelector(':scope > ion-header'); + + if (header && getComputedStyle(header).display !== 'none') { + return header; + } + + // Find using content if any. + header = content?.parentElement?.querySelector(':scope > ion-header'); + + if (header && getComputedStyle(header).display !== 'none') { + return header; + } + } + + // Header not found, reject. + throw Error('Header not found.'); + } + } /** diff --git a/src/core/tests/behat/snapshots/fontawesome-icons-are-correctly-shown-in-the-app-view-fontawesome-icons-in-the-app_7.png b/src/core/tests/behat/snapshots/fontawesome-icons-are-correctly-shown-in-the-app-view-fontawesome-icons-in-the-app_7.png index ce2463119..703afebe6 100644 Binary files a/src/core/tests/behat/snapshots/fontawesome-icons-are-correctly-shown-in-the-app-view-fontawesome-icons-in-the-app_7.png and b/src/core/tests/behat/snapshots/fontawesome-icons-are-correctly-shown-in-the-app-view-fontawesome-icons-in-the-app_7.png differ diff --git a/src/theme/components/ion-button.scss b/src/theme/components/ion-button.scss index 690456df3..0a22be6cb 100644 --- a/src/theme/components/ion-button.scss +++ b/src/theme/components/ion-button.scss @@ -105,6 +105,7 @@ ion-button { } ion-button, +ion-button.button, // Add specificity ion-fab-button, button, [role="button"] { diff --git a/src/theme/components/ion-modal.scss b/src/theme/components/ion-modal.scss index 93edc24e7..9b56548c6 100644 --- a/src/theme/components/ion-modal.scss +++ b/src/theme/components/ion-modal.scss @@ -122,4 +122,16 @@ ion-modal { justify-content: space-between; } } + + &.core-modal-auto-height { + --height: auto; + display: flex; + flex-direction: column; + justify-content: flex-end; + + .content-auto-height { + max-height: 80vh; + overflow: auto; + } + } } diff --git a/src/theme/components/reading-mode.scss b/src/theme/components/reading-mode.scss new file mode 100644 index 000000000..2fec7a7b6 --- /dev/null +++ b/src/theme/components/reading-mode.scss @@ -0,0 +1,60 @@ + +html { + --core-reading-mode-sepia-background: #f4ecd8; + --core-reading-mode-sepia-text-color: #5b4636; +} + +body.core-reading-mode-enabled { + .core-text-viewer-button { + --core-header-buttons-background: var(--stroke); + } + + &.core-reading-mode-theme-light { + --reading-mode-background: #{$background-color}; + --reading-mode-text-color: #{$text-color}; + } + + &.core-reading-mode-theme-dark { + --reading-mode-background: #{$background-color-dark}; + --reading-mode-text-color: #{$text-color-dark}; + } + + &.core-reading-mode-theme-sepia { + --reading-mode-background: var(--core-reading-mode-sepia-background); + --reading-mode-text-color: var(--core-reading-mode-sepia-text-color); + } + + &.core-reading-mode-theme-hcm { + --reading-mode-background: #000000; + --reading-mode-text-color: #ffffff; + } + + &.core-reading-mode-multimedia-hidden { + ion-content.core-reading-mode-content { + img, video, iframe { + display: none !important; + } + } + } + + ion-content.core-reading-mode-content, + ion-content.core-reading-mode-content core-split-view ion-content { + --background: var(--reading-mode-background, --ion-background-color); + background: var(--background); + + [core-reading-mode] { + zoom: var(--reading-mode-zoom, 1); + &> * { + --ion-item-background: var(--reading-mode-background, --ion-background-color); + --text-color: var(--reading-mode-text-color, --text-color); + --color: var(--reading-mode-text-color, --text-color); + --subdued-text-color: var(--text-color); + color: var(--text-color); + } + } + + .hide-on-reading-mode { + display: none !important; + } + } +} diff --git a/src/theme/theme.scss b/src/theme/theme.scss index e35ae118a..23181639d 100644 --- a/src/theme/theme.scss +++ b/src/theme/theme.scss @@ -21,6 +21,7 @@ @import "components/collapsible-header.scss"; @import "components/collapsible-item.scss"; @import "components/error-accordion.scss"; +@import "components/reading-mode.scss"; @import "components/format-text.scss"; @import "components/iframe.scss"; @import "components/mod-label.scss";