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 @@
-
+
0">
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 }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0">
-
- {{ 'core.tag.tags' | translate }}:
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ {{ onlineEntry.userfullname }}
+
+ {{ onlineEntry.timemodified | coreDateDayOrTime }}
+
+
+
+
+
+
+
+ {{ onlineEntry.timemodified | coreDateDayOrTime }}
+
+
+
+
+
+
+
+
-
-
-
- {{ 'addon.mod_glossary.entrypendingapproval' | translate }}
-
-
-
0 && commentsEnabled"
- contextLevel="module" [instanceId]="glossary.coursemodule" component="mod_glossary" [itemId]="onlineEntry.id"
- area="glossary_entry" [courseId]="glossary.course" [showItem]="true" />
-
-
+
+
+
+
+
+
+ 0">
+
+ {{ 'core.tag.tags' | translate }}:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'addon.mod_glossary.entrypendingapproval' | translate }}
+
+
+ 0 && commentsEnabled"
+ contextLevel="module" [instanceId]="glossary.coursemodule" component="mod_glossary" [itemId]="onlineEntry.id"
+ area="glossary_entry" [courseId]="glossary.course" [showItem]="true" />
+
+
+
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 }}
+
+
+
+
+ {{ 'core.viewer.exitreadingmode' | 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";