Merge pull request #4266 from crazyserver/MOBILE-3063

Mobile 3063
main
Dani Palou 2024-12-18 08:06:52 +01:00 committed by GitHub
commit 0af6865703
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 780 additions and 108 deletions

View File

@ -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",

View File

@ -24,7 +24,7 @@
<div class="safe-area-padding-horizontal core-swipe-slides-container">
<core-swipe-slides [manager]="manager" [options]="swiperOpts">
<ng-template let-chapter="item" let-active="active">
<div class="ion-padding">
<div class="ion-padding" core-reading-mode>
<core-format-text [component]="component" [componentId]="cmId" [text]="chapter.content" contextLevel="module"
[contextInstanceId]="cmId" [courseId]="courseId" [disabled]="!active" />
<div class="ion-margin-top" *ngIf="chapter.tags?.length > 0">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -8,6 +8,7 @@
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId" [courseId]="courseId" />
</h1>
</ion-title>
<ion-buttons slot="end" />
</ion-toolbar>
</ion-header>
<ion-content [core-swipe-navigation]="entries" class="limited-width">
@ -23,72 +24,77 @@
<ion-label>{{ 'core.hasdatatosync' | translate: { $a: 'addon.mod_glossary.entry' | translate } }}</ion-label>
</ion-item>
</ion-card>
<ion-item class="ion-text-wrap" *ngIf="showAuthor">
<core-user-avatar [user]="entry" slot="start" />
<ion-label>
<h2>
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId"
[courseId]="courseId" />
</h2>
<p *ngIf="onlineEntry">{{ onlineEntry.userfullname }}</p>
</ion-label>
<ion-note slot="end" *ngIf="showDate && onlineEntry">{{ onlineEntry.timemodified | coreDateDayOrTime }}</ion-note>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="!showAuthor">
<ion-label>
<p class="item-heading">
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId" />
</p>
</ion-label>
<ion-note slot="end" *ngIf="showDate && onlineEntry">{{ onlineEntry.timemodified | coreDateDayOrTime }}</ion-note>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="entry.definition" contextLevel="module"
[contextInstanceId]="componentId" [courseId]="courseId" />
</ion-label>
</ion-item>
<div *ngIf="onlineEntry && onlineEntry.attachment">
<core-file *ngFor="let file of onlineEntry.attachments" [file]="file" [component]="component" [componentId]="componentId" />
</div>
<div *ngIf="offlineEntry && offlineEntry.attachments">
<core-file *ngFor="let file of offlineEntry.attachments.online" [file]="file" [component]="component"
[componentId]="componentId" />
</div>
<div *ngIf="offlineEntry && offlineEntryFiles">
<core-local-file *ngFor="let file of offlineEntryFiles" [file]="file" />
</div>
<ion-item class="ion-text-wrap" *ngIf="onlineEntry && tagsEnabled && entry && onlineEntry.tags && onlineEntry.tags.length > 0">
<ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div>
<core-tag-list [tags]="onlineEntry.tags" />
</ion-label>
</ion-item>
<ion-item *ngIf="canDelete || canEdit">
<div slot="end">
<ion-button *ngIf="canDelete" fill="clear" color="danger" (click)="deleteEntry()"
[ariaLabel]="'addon.mod_glossary.deleteentry' | translate">
<ion-icon slot="icon-only" name="fas-trash" aria-hidden="true" />
</ion-button>
<ion-button *ngIf="canEdit" fill="clear" (click)="editEntry()" [ariaLabel]="'addon.mod_glossary.editentry' | translate">
<ion-icon slot="icon-only" name="fas-pen" aria-hidden="true" />
</ion-button>
<div core-reading-mode>
<ion-item class="ion-text-wrap" *ngIf="showAuthor">
<core-user-avatar [user]="entry" slot="start" />
<ion-label>
<h2>
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId"
[courseId]="courseId" />
</h2>
<p *ngIf="onlineEntry">{{ onlineEntry.userfullname }}</p>
</ion-label>
<ion-note slot="end" *ngIf="showDate && onlineEntry">{{ onlineEntry.timemodified | coreDateDayOrTime }}</ion-note>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="!showAuthor">
<ion-label>
<p class="item-heading">
<core-format-text [text]="entry.concept" contextLevel="module" [contextInstanceId]="componentId" />
</p>
</ion-label>
<ion-note slot="end" *ngIf="showDate && onlineEntry">{{ onlineEntry.timemodified | coreDateDayOrTime }}</ion-note>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="entry.definition"
contextLevel="module" [contextInstanceId]="componentId" [courseId]="courseId" />
</ion-label>
</ion-item>
<div *ngIf="onlineEntry && onlineEntry.attachment">
<core-file *ngFor="let file of onlineEntry.attachments" [file]="file" [component]="component"
[componentId]="componentId" />
</div>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="onlineEntry && !onlineEntry.approved">
<ion-label>
<p><em>{{ 'addon.mod_glossary.entrypendingapproval' | translate }}</em></p>
</ion-label>
</ion-item>
<core-comments *ngIf="glossary && glossary.allowcomments && onlineEntry && onlineEntry.id > 0 && commentsEnabled"
contextLevel="module" [instanceId]="glossary.coursemodule" component="mod_glossary" [itemId]="onlineEntry.id"
area="glossary_entry" [courseId]="glossary.course" [showItem]="true" />
<core-rating-rate *ngIf="glossary && ratingInfo && onlineEntry" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="onlineEntry.id" [itemSetId]="0" [courseId]="glossary.course"
[aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale" [userId]="entry.userid" (onUpdate)="ratingUpdated()" />
<core-rating-aggregate *ngIf="glossary && ratingInfo && onlineEntry" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="onlineEntry.id" [courseId]="glossary.course"
[aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale" />
<div *ngIf="offlineEntry && offlineEntry.attachments">
<core-file *ngFor="let file of offlineEntry.attachments.online" [file]="file" [component]="component"
[componentId]="componentId" />
</div>
<div *ngIf="offlineEntry && offlineEntryFiles">
<core-local-file *ngFor="let file of offlineEntryFiles" [file]="file" />
</div>
<ion-item class="ion-text-wrap"
*ngIf="onlineEntry && tagsEnabled && entry && onlineEntry.tags && onlineEntry.tags.length > 0">
<ion-label>
<div slot="start">{{ 'core.tag.tags' | translate }}:</div>
<core-tag-list [tags]="onlineEntry.tags" />
</ion-label>
</ion-item>
<ion-item *ngIf="canDelete || canEdit">
<div slot="end">
<ion-button *ngIf="canDelete" fill="clear" color="danger" (click)="deleteEntry()"
[ariaLabel]="'addon.mod_glossary.deleteentry' | translate">
<ion-icon slot="icon-only" name="fas-trash" aria-hidden="true" />
</ion-button>
<ion-button *ngIf="canEdit" fill="clear" (click)="editEntry()"
[ariaLabel]="'addon.mod_glossary.editentry' | translate">
<ion-icon slot="icon-only" name="fas-pen" aria-hidden="true" />
</ion-button>
</div>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="onlineEntry && !onlineEntry.approved">
<ion-label>
<p><em>{{ 'addon.mod_glossary.entrypendingapproval' | translate }}</em></p>
</ion-label>
</ion-item>
<core-comments *ngIf="glossary && glossary.allowcomments && onlineEntry && onlineEntry.id > 0 && commentsEnabled"
contextLevel="module" [instanceId]="glossary.coursemodule" component="mod_glossary" [itemId]="onlineEntry.id"
area="glossary_entry" [courseId]="glossary.course" [showItem]="true" />
<core-rating-rate *ngIf="glossary && ratingInfo && onlineEntry" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="onlineEntry.id" [itemSetId]="0" [courseId]="glossary.course"
[aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale" [userId]="entry.userid" (onUpdate)="ratingUpdated()" />
<core-rating-aggregate *ngIf="glossary && ratingInfo && onlineEntry" [ratingInfo]="ratingInfo" contextLevel="module"
[instanceId]="glossary.coursemodule" [itemId]="onlineEntry.id" [courseId]="glossary.course"
[aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale" />
</div>
</ng-container>
<ion-card *ngIf="!entry" class="core-warning-card">

View File

@ -19,7 +19,7 @@
<core-course-module-info [module]="module" [description]="displayDescription && description" [component]="component"
[componentId]="componentId" [courseId]="courseId" (completionChanged)="onCompletionChange()" />
<div class="ion-padding">
<div class="ion-padding" core-reading-mode>
<core-format-text [component]="component" [componentId]="componentId" [text]="contents" contextLevel="module"
[contextInstanceId]="module.id" [courseId]="courseId" />

View File

@ -89,7 +89,7 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy {
*/
async ngOnInit(): Promise<void> {
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<HTMLIonHeaderElement> {
await CoreDom.waitToBeInDOM(this.element);
let parentPage: HTMLElement | null = this.element;
while (parentPage && parentPage.parentElement) {
const content = parentPage.closest<HTMLIonContentElement>('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<HTMLIonHeaderElement>(':scope > ion-header');
if (header && getComputedStyle(header).display !== 'none') {
return header;
}
// Find using content if any.
header = content?.parentElement?.querySelector<HTMLIonHeaderElement>(':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.
*/

View File

@ -100,6 +100,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest
constructor(el: ElementRef) {
this.collapsedHeader = el.nativeElement;
CoreDirectivesRegistry.register(this.collapsedHeader, this);
}
/**

View File

@ -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 {}

View File

@ -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:
* <core-format-text [text]="myText" [component]="component" [componentId]="componentId"></core-format-text>
* <core-format-text [text]="myText" [component]="component" [componentId]="componentId" />
*/
@Directive({
selector: 'core-format-text',

View File

@ -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:
* <div core-reading-mode>
*/
@Directive({
selector: '[core-reading-mode]',
})
export class CoreReadingModeDirective implements AfterViewInit, OnDestroy {
protected element: HTMLElement;
protected viewportPromise?: CoreCancellablePromise<void>;
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<void> {
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<void> {
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<HTMLIonButtonsElement>('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 = `<ion-icon name="fas-${iconName}" aria-hidden="true" src="${src}"></ion-icon>`;
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<void> {
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<void> {
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<void> {
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();
}
}

View File

@ -0,0 +1,53 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [ariaLabel]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-xmark" aria-hidden="true" />
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<div class="content-auto-height">
<ion-item lines="none">
<ion-label class="flex-row">
<div id="readingmode-range-label">{{ 'core.settings.fontsize' | translate }}</div>
</ion-label>
<div slot="end">{{settings.zoom}}% @if (defaultZoom) { {{ 'core.viewer.default' | translate }} }</div>
</ion-item>
<ion-item lines="full">
<ion-range aria-labelledby="readingmode-range-label" [min]="MIN_TEXT_SIZE_ZOOM" [max]="MAX_TEXT_SIZE_ZOOM"
[step]="TEXT_SIZE_ZOOM_STEP" [value]="settings.zoom" (ionInput)="changeTextSizeZoom($event.detail.value)">
<ion-button slot="start" fill="clear" [ariaLabel]="'core.viewer.decreasetextsize' | translate"
(click)="changeTextSizeZoom(settings.zoom - TEXT_SIZE_ZOOM_STEP)">
<ion-icon name="fas-font" slot="icon-only" aria-hidden="true" class="zoom-decrease" />
</ion-button>
<ion-button slot="end" fill="clear" [ariaLabel]="'core.viewer.increasetextsize' | translate"
(click)="changeTextSizeZoom(settings.zoom + TEXT_SIZE_ZOOM_STEP)">
<ion-icon name="fas-font" slot="icon-only" aria-hidden="true" class="zoom-increase" />
</ion-button>
</ion-range>
</ion-item>
<ion-item>
<ion-label>
{{ 'core.viewer.theme' | translate }}
</ion-label>
</ion-item>
<ion-radio-group [(ngModel)]="settings.theme" (ionChange)="onSettingChange()">
@for (theme of themes; track $index; let last = $last;) {
<ion-item class="ion-text-wrap" [lines]="last ? 'full': 'none'">
<ion-radio [value]="theme" class="reading-theme">
<div class="preview {{theme}}">Aa</div> {{ 'core.viewer.readingtheme'+theme | translate }}
</ion-radio>
</ion-item>
}
</ion-radio-group>
<ion-item class="ion-text-wrap" (ionChange)="onSettingChange()" lines="full">
<ion-toggle [(ngModel)]="settings.showMultimedia">
<p class="item-heading">{{ 'core.viewer.showmedia' | translate }}</p>
</ion-toggle>
</ion-item>
<ion-button (click)="exit()" fill="outline" expand="block" class="ion-margin ion-text-wrap">
<ion-icon name="fas-book-open-reader" slot="start" aria-hidden="true" class="icon-slash" />
{{ 'core.viewer.exitreadingmode' | translate }}
</ion-button>
</div>

View File

@ -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;
}
}
}

View File

@ -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<void> {
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);
}
}

View File

@ -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,
};

View File

@ -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"
}

View File

@ -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<CoreViewerReadingModeSettings> {
return CoreConfig.getJSON<CoreViewerReadingModeSettings>(CORE_READING_MODE_SETTINGS, CORE_READING_MODE_DEFAULT_SETTINGS);
}
/**
* Load and apply reading mode settings.
*/
async loadReadingModeSettings(): Promise<void> {
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<void> {
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<ModalOptions>; // Modal options.
};
export type CoreViewerReadingModeSettings = {
zoom: number; // Zoom level.
showMultimedia: boolean; // Show images and multimedia.
theme: CoreViewerReadingModeThemesType; // Theme to use.
};

View File

@ -346,7 +346,8 @@ export class CoreAppProvider {
*/
setSystemUIColors(): void {
this.setStatusBarColor();
this.setAndroidNavigationBarColor(); }
this.setAndroidNavigationBarColor();
}
/**
* Set StatusBar color depending on platform.

View File

@ -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<T>(name: string, defaultValue?: T): Promise<T> {
try {
const configString = await CoreConfig.get<string>(name);
if (!configString) {
throw new Error('Config not found');
}
return CoreText.parseJSON<T>(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<void> {
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<void> {
await this.set(name, JSON.stringify(value));
}
/**
* Update config with the given values.
*

View File

@ -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<HTMLElement | null> {
await CoreDom.waitToBeInDOM(element);
let parentPage: HTMLElement | null = element;
while (parentPage && parentPage.parentElement) {
const content = parentPage.closest<HTMLIonContentElement>('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<HTMLIonHeaderElement>(':scope > ion-header');
if (header && getComputedStyle(header).display !== 'none') {
return header;
}
// Find using content if any.
header = content?.parentElement?.querySelector<HTMLIonHeaderElement>(':scope > ion-header');
if (header && getComputedStyle(header).display !== 'none') {
return header;
}
}
// Header not found, reject.
throw Error('Header not found.');
}
}
/**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -105,6 +105,7 @@ ion-button {
}
ion-button,
ion-button.button, // Add specificity
ion-fab-button,
button,
[role="button"] {

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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";