diff --git a/src/core/directives/fa-icon.ts b/src/core/directives/fa-icon.ts index c01d1809e..c80b8b643 100644 --- a/src/core/directives/fa-icon.ts +++ b/src/core/directives/fa-icon.ts @@ -14,12 +14,10 @@ import { AfterViewInit, Directive, ElementRef, Input, OnChanges, SimpleChange } from '@angular/core'; import { CoreLogger } from '@singletons/logger'; -import { Http } from '@singletons'; -import { CoreConstants } from '@/core/constants'; -import { CorePromisedValue } from '@classes/promised-value'; +import { CoreIcons } from '@singletons/icons'; /** - * Directive to enable font-awesome 6.3 as ionicons. + * Directive to enable font-awesome 6.4 as ionicons. * Check available icons at https://fontawesome.com/search?o=r&m=free * * Example usage: @@ -31,13 +29,6 @@ import { CorePromisedValue } from '@classes/promised-value'; }) export class CoreFaIconDirective implements AfterViewInit, OnChanges { - /** - * Object used to store whether icons exist or not during development. - */ - private static readonly DEV_ICONS_STATUS: Record> = {}; - - protected static aliases?: CorePromisedValue>; - @Input() name = ''; protected element: HTMLElement; @@ -95,19 +86,19 @@ export class CoreFaIconDirective implements AfterViewInit, OnChanges { iconName = iconName.substring(parts[0].length + 1); // Set it here to avoid loading unexisting icon paths (svg/iconName) caused by the tick delay of the checkIconAlias promise. - let src = `assets/fonts/${font}/${library}/${iconName}.svg`; + let src = CoreIcons.getIconSrc(font, library, iconName); this.element.setAttribute('src', src); if (font === 'font-awesome') { - const iconNameChecked = await this.checkIconAlias(iconName); - if (iconNameChecked !== iconName) { - src = `assets/fonts/${font}/${library}/${iconName}.svg`; + const { fileName } = await CoreIcons.getFontAwesomeIconFileName(iconName); + if (fileName !== iconName) { + src = CoreIcons.getIconSrc(font, library, fileName); this.element.setAttribute('src', src); } } this.element.classList.add('faicon'); - this.validateIcon(this.name, src); + CoreIcons.validateIcon(this.name, src); } @@ -135,77 +126,4 @@ export class CoreFaIconDirective implements AfterViewInit, OnChanges { this.setIcon(); } - /** - * Check icon alias and returns the new icon name. - * - * @param iconName Icon name. - * @returns New icon name. - */ - protected async checkIconAlias(iconName: string): Promise { - const aliases = await CoreFaIconDirective.getIconsAliases(); - - if (aliases[iconName]) { - this.logger.error(`Icon ${iconName} is an alias of ${aliases[iconName]}, please use the new name.`); - - return aliases[iconName]; - } - - return iconName; - } - - /** - * Read the icon aliases json file. - * - * @returns Promise resolved when loaded. - */ - protected static async getIconsAliases(): Promise> { - if (CoreFaIconDirective.aliases !== undefined) { - return CoreFaIconDirective.aliases; - } - - CoreFaIconDirective.aliases = new CorePromisedValue(); - - try { - const aliases = await Http.get>('assets/fonts/font-awesome/aliases.json', { - responseType: 'json', - }).toPromise(); - - CoreFaIconDirective.aliases.resolve(aliases); - - return aliases; - } catch { - CoreFaIconDirective.aliases.resolve({}); - - return {}; - } - } - - /** - * Validate that an icon exists, or show warning otherwise (only in development and testing environments). - * - * @param name Icon name. - * @param src Icon source url. - */ - private validateIcon(name: string, src: string): void { - if (!CoreConstants.BUILD.isDevelopment && !CoreConstants.BUILD.isTesting) { - return; - } - - if (!(src in CoreFaIconDirective.DEV_ICONS_STATUS)) { - CoreFaIconDirective.DEV_ICONS_STATUS[src] = Http.get(src, { responseType: 'text' }) - .toPromise() - .then(() => true) - .catch(() => false); - } - - // eslint-disable-next-line promise/catch-or-return - CoreFaIconDirective.DEV_ICONS_STATUS[src].then(exists => { - if (exists) { - return; - } - - return this.logger.error(`Icon ${name} not found`); - }); - } - } diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index 77b09a000..11b47274f 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -54,6 +54,7 @@ import { ElementController } from '@classes/element-controllers/ElementControlle import { MediaElementController } from '@classes/element-controllers/MediaElementController'; import { FrameElementController } from '@classes/element-controllers/FrameElementController'; import { CoreUrl } from '@singletons/url'; +import { CoreIcons } from '@singletons/icons'; /** * Directive to format text rendered. It renders the HTML and treats all links and media, using CoreLinkDirective @@ -278,10 +279,10 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirec button.classList.add('core-image-viewer-icon'); button.classList.add('hidden'); button.setAttribute('aria-label', label); + const iconName = 'up-right-and-down-left-from-center'; + 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 = ''; + button.innerHTML = ``; button.addEventListener('click', (e: Event) => { e.preventDefault(); @@ -478,6 +479,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirec const videos = Array.from(div.querySelectorAll('video')); const iframes = Array.from(div.querySelectorAll('iframe')); const buttons = Array.from(div.querySelectorAll('.button')); + const icons = Array.from(div.querySelectorAll('i.fa,i.fas,i.far,i.fab')); const elementsWithInlineStyles = Array.from(div.querySelectorAll('*[style]')); const stopClicksElements = Array.from(div.querySelectorAll('button,input,select,textarea')); const frames = Array.from(div.querySelectorAll(CoreIframeUtilsProvider.FRAME_TAGS.join(',').replace(/iframe,?/, ''))); @@ -550,6 +552,11 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirec } }); + // Handle Font Awesome icons to be rendered by the app. + icons.forEach((icon) => { + CoreIcons.replaceCSSIcon(icon); + }); + // Handle inline styles. elementsWithInlineStyles.forEach((el: HTMLElement) => { // Only add external content for tags that haven't been treated already. diff --git a/src/core/features/question/services/question-helper.ts b/src/core/features/question/services/question-helper.ts index f797dc92c..3777120f8 100644 --- a/src/core/features/question/services/question-helper.ts +++ b/src/core/features/question/services/question-helper.ts @@ -26,6 +26,7 @@ import { CoreWSFile } from '@services/ws'; import { makeSingleton, Translate } from '@singletons'; import { CoreQuestion, CoreQuestionProvider, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from './question'; import { CoreQuestionDelegate } from './question-delegate'; +import { CoreIcons } from '@singletons/icons'; /** * Service with some common functions to handle questions. @@ -801,12 +802,14 @@ export class CoreQuestionHelperProvider { const newIcon: HTMLIonIconElement = document.createElement('ion-icon'); if (correct) { - newIcon.setAttribute('name', 'fas-check'); - newIcon.setAttribute('src', 'assets/fonts/font-awesome/solid/check.svg'); + const iconName = 'check'; + newIcon.setAttribute('name', `fas-${iconName}`); + newIcon.setAttribute('src', CoreIcons.getIconSrc('font-awesome', 'solid', iconName)); newIcon.className = 'core-correct-icon ion-color ion-color-success questioncorrectnessicon'; } else { - newIcon.setAttribute('name', 'fas-xmark'); - newIcon.setAttribute('src', 'assets/fonts/font-awesome/solid/xmark.svg'); + const iconName = 'xmark'; + newIcon.setAttribute('name', `fas-${iconName}`); + newIcon.setAttribute('src', CoreIcons.getIconSrc('font-awesome', 'solid', iconName)); newIcon.className = 'core-correct-icon ion-color ion-color-danger questioncorrectnessicon'; } diff --git a/src/core/singletons/icons.ts b/src/core/singletons/icons.ts new file mode 100644 index 000000000..c4c66d2f8 --- /dev/null +++ b/src/core/singletons/icons.ts @@ -0,0 +1,198 @@ +// (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 { Http } from '@singletons'; +import { CoreConstants } from '../constants'; +import { CoreLogger } from './logger'; +import aliases from '@/assets/fonts/font-awesome/aliases.json'; + +/** + * Singleton with helper functions for icon management. + */ +export class CoreIcons { + + /** + * Object used to store whether icons exist or not during development. + */ + private static readonly DEV_ICONS_STATUS: Record> = {}; + + private static readonly ALIASES = { ...aliases } as unknown as Record; + + protected static logger = CoreLogger.getInstance('CoreIcons'); + + /** + * Check icon alias and returns the new icon name. + * + * @param icon Icon name. + * @returns New icon name and new library if changed. + */ + static async getFontAwesomeIconFileName(icon: string): Promise<{fileName: string; newLibrary?: string}> { + let newLibrary: string | undefined = undefined; + if (icon.endsWith('-o')) { + newLibrary = 'regular'; + icon = icon.substring(0, icon.length - 2); + } + + if (CoreIcons.ALIASES[icon]) { + this.logger.error(`Icon ${icon} is an alias of ${CoreIcons.ALIASES[icon]}, please use the new name.`); + + return { newLibrary, fileName: CoreIcons.ALIASES[icon] }; + } + + return { newLibrary, fileName: icon }; + } + + /** + * Validate that an icon exists, or show warning otherwise (only in development and testing environments). + * + * @param name Icon name. + * @param src Icon source url. + */ + static validateIcon(name: string, src: string): void { + if (!CoreConstants.BUILD.isDevelopment && !CoreConstants.BUILD.isTesting) { + return; + } + + if (!(src in CoreIcons.DEV_ICONS_STATUS)) { + CoreIcons.DEV_ICONS_STATUS[src] = Http.get(src, { responseType: 'text' }) + .toPromise() + .then(() => true) + .catch(() => false); + } + + // eslint-disable-next-line promise/catch-or-return + CoreIcons.DEV_ICONS_STATUS[src].then(exists => { + if (exists) { + return; + } + + return this.logger.error(`Icon ${name} not found`); + }); + } + + /** + * Replaces an icon that uses CSS by a ion-icon with SVG. + * It supports from 4.7 to 6.4 Font awesome versions. + * But it can fail on 4.7 and 5.x because of the lack of assets. + * + * @param icon Current icon element. + * @returns New icon, already included in the DOM. + */ + static async replaceCSSIcon(icon: Element): Promise { + let library = 'solid'; + let iconName = ''; + + Array.from(icon.classList).forEach(async (className) => { + // Library name of 5.x + switch (className) { + case 'fas': + library = 'solid'; + + return; + case 'far': + library = 'regular'; + + return; + case 'fab': + library = 'brands'; + + return; + } + + // Check fa- style class names. + const faPart = className.match(/fa-([a-zA-Z0-9-]+)/); + if (!faPart) { + return; + } + + const firstPart = faPart[1].split('-')[0]; + + switch (firstPart) { + // Class is defining library. + case 'solid': + library = 'solid'; + break; + case 'regular': + case 'light': + library = 'regular'; + break; + case 'brands': + library = 'brands'; + break; + // Class is defining special cases. + case '2xs': + case 'xs': + case 'sm': + case 'lg': + case 'xl': + case '2xl': + case 'fw': + case 'sharp': + case 'rotate': + return; + // Class is defining the icon name (fa-ICONNAME). + default: + iconName = faPart[1]; + break; + } + }); + + if (!iconName) { + return; + } + + const newIcon = document.createElement('ion-icon'); + + Array.from(icon.attributes).forEach(attr => { + newIcon.setAttribute(attr.nodeName, attr.nodeValue || ''); + }); + + if (!newIcon.getAttribute('aria-label') && + !newIcon.getAttribute('aria-labelledby') && + !newIcon.getAttribute('title')) { + newIcon.setAttribute('aria-hidden', 'true'); + } + + const { fileName, newLibrary } = await CoreIcons.getFontAwesomeIconFileName(iconName); + if (newLibrary) { + library = newLibrary; + } + iconName = fileName; + + const src = CoreIcons.getIconSrc('font-awesome', library, iconName); + + newIcon.setAttribute('src', src); + + newIcon.classList.add('faicon'); + CoreIcons.validateIcon(iconName, src); + + icon.parentElement?.insertBefore(newIcon, icon); + icon.remove(); + + return newIcon; + } + + /** + * Get icon SVG path. + * + * @param font Font Family. + * @param library Library to use. + * @param icon Icon Name. + * @returns Path. + */ + static getIconSrc(font: string, library: string, icon: string): string { + return `assets/fonts/${font}/${library}/${icon}.svg`; + } + +} diff --git a/src/core/singletons/tests/icons.test.ts b/src/core/singletons/tests/icons.test.ts new file mode 100644 index 000000000..e2abc8a90 --- /dev/null +++ b/src/core/singletons/tests/icons.test.ts @@ -0,0 +1,84 @@ +// (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 { CoreIcons } from '@singletons/icons'; + +describe('CoreIcons singleton', () => { + + it('replaces CSS icon with the correspondant ion-icon', async () => { + const icon = document.createElement('i'); + + // Not an icon + icon.className = 'test'; + expect((await CoreIcons.replaceCSSIcon(icon))) + .toEqual(undefined); + + icon.className = 'fas fanoicon'; + expect((await CoreIcons.replaceCSSIcon(icon))) + .toEqual(undefined); + + icon.className = 'fa-solid fanoicon'; + expect((await CoreIcons.replaceCSSIcon(icon))) + .toEqual(undefined); + + // Font awesome 6 + icon.className = 'fa-solid fa-face-awesome'; + expect((await CoreIcons.replaceCSSIcon(icon))?.getAttribute('src')) + .toEqual('assets/fonts/font-awesome/solid/face-awesome.svg'); + + icon.className = 'fa-regular fa-face-awesome'; + expect((await CoreIcons.replaceCSSIcon(icon))?.getAttribute('src')) + .toEqual('assets/fonts/font-awesome/regular/face-awesome.svg'); + + icon.className = 'fa-light fa-face-awesome'; + expect((await CoreIcons.replaceCSSIcon(icon))?.getAttribute('src')) + .toEqual('assets/fonts/font-awesome/regular/face-awesome.svg'); + + icon.className = 'fa-brands fa-facebook'; + expect((await CoreIcons.replaceCSSIcon(icon))?.getAttribute('src')) + .toEqual('assets/fonts/font-awesome/brands/facebook.svg'); + + // Font awesome 5 + icon.className = 'fas fa-yin-yang'; + expect((await CoreIcons.replaceCSSIcon(icon))?.getAttribute('src')) + .toEqual('assets/fonts/font-awesome/solid/yin-yang.svg'); + + icon.className = 'far fa-wrench'; + expect((await CoreIcons.replaceCSSIcon(icon))?.getAttribute('src')) + .toEqual('assets/fonts/font-awesome/regular/wrench.svg'); + + icon.className = 'fab fa-youtube'; + expect((await CoreIcons.replaceCSSIcon(icon))?.getAttribute('src')) + .toEqual('assets/fonts/font-awesome/brands/youtube.svg'); + + // Font awesome 4.7 + icon.className = 'fa fa-address-book'; + expect((await CoreIcons.replaceCSSIcon(icon))?.getAttribute('src')) + .toEqual('assets/fonts/font-awesome/solid/address-book.svg'); + + icon.className = 'fa fa-address-book-o'; + expect((await CoreIcons.replaceCSSIcon(icon))?.getAttribute('src')) + .toEqual('assets/fonts/font-awesome/regular/address-book.svg'); + + // Aliases + icon.className = 'fas fa-battery-5'; + expect((await CoreIcons.replaceCSSIcon(icon))?.getAttribute('src')) + .toEqual('assets/fonts/font-awesome/solid/battery-full.svg'); + + icon.className = 'fa fa-check-square'; + expect((await CoreIcons.replaceCSSIcon(icon))?.getAttribute('src')) + .toEqual('assets/fonts/font-awesome/solid/square-check.svg'); + }); + +}); diff --git a/upgrade.txt b/upgrade.txt index 922dadc2e..e0b022b49 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -4,6 +4,7 @@ information provided here is intended especially for developers. === 4.3.0 === - CoreSiteBasicInfo fullName attribute has changed to fullname and avatar to userpictureurl to match user fields. + - Font Awesome icon library has been updated to 6.4.0. But nothing has changed, only version number. === 4.2.0 ===