import { Component, Inject, Input, Output } from '../decorators' import GoogleWebfontsApiService from './google-webfonts-api.service' import WebfontLoaderService from './webfont-loader.service' import type { TranslateFactory } from '../translate/translate-factory' import _ from 'lodash' import { IComponentController } from 'angular' export interface FontFamilyStyle { 'font-family': string } @Component({ selector: 'mflyFontSelector', template: require('./font-selector.html') }) export default class FontSelectorController implements IComponentController { @Input('<') inputId!: string @Input('<') isDisabled?: boolean @Input('<') isRequired?: boolean @Input('=?') fontFamily?: string @Output('&?') onFontSelect?: (param: { fontFamily: string, fontWeightOptions?: string[] }) => void protected delay: number = 500 protected googleFonts: google.fonts.WebfontFamily[] protected inputValue?: string protected interactionMode: 'mouse' | 'keyboard' = 'mouse' protected isDropdownVisible: boolean protected isFetching: boolean protected kbSelectedIndex = -1 protected keyEventElement: HTMLInputElement | null = null protected loadingFonts: boolean = true protected placeholderText!: string protected popularFonts: string[] protected popularFontsText: string protected matches: string[] = [] protected minLength: number = 2 protected requiredText: string protected showPopular: boolean = true protected whatAreGoogleFonts: string private debouncedFetchAndSetMatches = _.debounce(() => this.fetchAndSetMatches(), this.delay) private inputElement: HTMLElement private observer: MutationObserver constructor ( @Inject('$document') private $document: ng.IDocumentService, @Inject('$element') private $element: ng.IAugmentedJQuery, @Inject('$timeout') private $timeout: ng.ITimeoutService, @Inject('$q') private $q: ng.IQService, @Inject('googleWebfontsApiService') private googleWebfontsApiService: GoogleWebfontsApiService, @Inject('translateFactory') private translateFactory: TranslateFactory, @Inject('webfontLoaderService') private webfontLoaderService: WebfontLoaderService, ) {} $onDestroy() { if (this.keyEventElement) { this.keyEventElement.removeEventListener('input', this.debouncedFetchAndSetMatches) this.keyEventElement.removeEventListener('keydown', this.onKeyDown) } const body = (this.$document[0] as Document).body body.removeEventListener('click', this.onClickElsewhere) this.observer.disconnect() } $onInit() { if (!this.inputId) { throw new Error(`You must provide a string to key-event-element-id on the FontSelector.`) } // set labels and text this.placeholderText = this.translateFactory.instant('JSUI.SEARCH_ALL_GOOGLE_FONTS') this.popularFontsText = this.translateFactory.instant('JSUI.POPULAR_FONTS') this.requiredText = this.translateFactory.instant('JSUI.REQUIRED') this.whatAreGoogleFonts = this.translateFactory.instant('JSUI.WHAT_ARE_GOOGLE_FONTS') // init list of popular fonts this.googleWebfontsApiService.getFontList('popularity') .then(popularList => { this.popularFonts = popularList.map(f => f.family) this.popularFonts = this.popularFonts.slice(0, 50) this.webfontLoaderService.loadFonts(this.popularFonts) this.matches = this.popularFonts }) // init list of google fonts this.googleWebfontsApiService.getFontList() .then(fontList => { this.googleFonts = fontList this.loadingFonts = false }) const body = (this.$document[0] as Document).body body.addEventListener('click', this.onClickElsewhere) // Set up an observer that will find the input element and attach events when it is present. this.observer = new MutationObserver(() => { // expect the tag-input component to be defined in a div with the id autocomplete-tag-lookup // this allows us to more reliably find the input to listen to when there are multiple instances of this combination this.inputElement = this.$element.parent().find(`#${this.inputId}`)[0] if (this.inputElement) { this.keyEventElement = this.inputElement as HTMLInputElement this.keyEventElement.addEventListener('input', this.debouncedFetchAndSetMatches) this.keyEventElement.addEventListener('keydown', this.onKeyDown) this.observer.disconnect() } }) this.observer.observe(body, { attributes: true, childList: true, characterData: false, subtree: true }) } protected clearInput($event): void { this.inputValue = '' this.matches = this.popularFonts this.showPopular = true $event.stopPropagation() } protected clickSelect($event): void { if (this.isDisabled) { return } this.isDropdownVisible = !this.isDropdownVisible if (this.isDropdownVisible) { this.$timeout(() => { this.inputElement.focus() }, 250) } else { this.resetDropdown() } $event.stopPropagation() } // When the user mouses over the dropdown, we should remove any keyboard // highlighting, since the mouse hover provides it too. dropdownWasMousedOver() { this.interactionMode = 'mouse' this.kbSelectedIndex = -1 } protected inputClick($event): void{ $event.stopPropagation() } protected inputValueWasChanged() { this.debouncedFetchAndSetMatches() } protected resetDropdown() { this.isDropdownVisible = false this.matches = this.popularFonts this.showPopular = true this.kbSelectedIndex = -1 this.inputValue = '' } protected onFontSelected(fontFamily: string, $event?: MouseEvent) { $event?.stopPropagation() this.fontFamily = fontFamily this.getFontWeightOptions(fontFamily) .then((fontWeightOptions: string[]) => { this.onFontSelect?.({ fontFamily, fontWeightOptions }) this.resetDropdown() }) } protected getFontWeightOptions(fontFamily: string | undefined): ng.IPromise { if (!fontFamily) { return this.$q.resolve([]) } return this.googleWebfontsApiService.getFontVariants(fontFamily, this.googleFonts) .then(variants => this.googleWebfontsApiService.sortFontWeightVariants(variants)) } protected onFontLookup(value: string): ng.IPromise { value = value.toLocaleLowerCase() const fonts = this.googleFonts.filter(font => { return font.family.toLowerCase().includes(value) }) // load this set with the api service this.webfontLoaderService.loadFonts(fonts.map(font => font.family)) return this.$q.resolve(fonts) } private fetchAndSetMatches(): void { this.isFetching = true const input = this.inputValue || (this.keyEventElement ? this.keyEventElement.value : '') if (input.length < this.minLength) { this.$timeout(() => { this.matches = this.popularFonts this.showPopular = true }) return } this.isFetching = true // Wrap the entire fetch in a timeout so that fetching callbacks will // be called correctly while the async part happens. this.$timeout(() => { this.$q.when(this.onFontLookup(input)) .then(matches => { this.matches = matches.map(match => match.family) this.showPopular = false this.isFetching = false }) }) } private onClickElsewhere = ($event: MouseEvent) => { this.$timeout(() => { this.resetDropdown() }) } private onKeyDown = ($event: KeyboardEvent) => this.$timeout(() => { this.interactionMode = 'keyboard' // Enter with no selection: let the $event through if ($event.key === 'Enter' && this.kbSelectedIndex === -1) { return } // If the dropdown is closed, all we can do is arrow down to open the thing if (this.matches.length === 0 && $event.key !== 'ArrowDown') { return } // Escape: close the dropdown if ($event.key === 'Escape') { this.matches = this.popularFonts this.showPopular = true } // Arrow down with dropdown closed: fetch matches if ($event.key === 'ArrowDown' && this.matches.length === 0) { this.fetchAndSetMatches() } // Arrow keys with dropdown open: move the selection highlight if ($event.key === 'ArrowDown' && this.matches.length > 0) { const idx = Math.min(this.kbSelectedIndex + 1, this.matches.length - 1) this.kbSelectedIndex = idx this.scrollToMatch(idx) } if ($event.key === 'ArrowUp') { const idx = Math.max(this.kbSelectedIndex - 1, 0) this.kbSelectedIndex = idx this.scrollToMatch(idx) } // Enter with a selection: set the selection to the currently highlighted match if ($event.key === 'Enter' && this.kbSelectedIndex > -1) { const match = this.matches[this.kbSelectedIndex] this.onFontSelected(match) $event.preventDefault() } $event.stopPropagation() }) private scrollToMatch(index: number) { const row = this.$element.find('.c-font-selector__match-row')[index] as HTMLDivElement if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) } } }