// tslint:disable: max-file-line-count import _ from 'lodash' import Quill, { Parchment, Range } from 'quill' import { Delta, EmitterSource } from 'quill/core' import { Component, Inject, Input } from '../decorators' import type { TranslateFactory } from '../translate/translate-factory' import AllowInputPasteClipboard from './allow-input-paste-clipboard' import { v4 as uuidv4 } from 'uuid' import RichTextService from './rich-text.service' import { IComponentController } from 'angular' import { StyleAttributor, Scope } from 'parchment' import GoogleWebfontsApiService from '../font-selector/google-webfonts-api.service' interface LinkValue { href: string target?: string } interface LinkBlotConstructor { create(value: string): HTMLAnchorElement formats(domNode: HTMLElement): string sanitize(url: string): string readonly blotName: string } /** Custom Link blot that supports target */ function createCustomLinkBlot(): Parchment.BlotConstructor { type BaseLinkInstance = { domNode: HTMLElement; statics: { blotName: string }; format(name: string, value: unknown): void } const BaseLink = Quill.import('formats/link') as LinkBlotConstructor & (new (...args: unknown[]) => BaseLinkInstance) return class CustomLink extends BaseLink { static create(value: string | LinkValue) { const href = typeof value === 'string' ? value : (value?.href ?? '') const target = (typeof value === 'object' && value?.target) ? value.target : '_self' const node = BaseLink.create(href) node.setAttribute('target', target) if (target === '_blank') { node.setAttribute('rel', 'noopener noreferrer') } else { node.removeAttribute('rel') } return node } static formats(domNode: HTMLElement) { const anchor = domNode as HTMLAnchorElement const href = anchor.getAttribute('href') ?? '' const target = anchor.getAttribute('target') if (target === '_blank' || target === '_self') { // Quill's type definitions are terrible, avoid a type error while returning a valid data structure which is not included in the type defs. // This is necessary to avoid copy-pasted links losing their target attribute. return { href, target } as unknown as string } return href } format(name: string, value: string | LinkValue) { if (name !== (this as unknown as { statics: { blotName: string } }).statics.blotName || !value) { super.format(name, value) return } const href = typeof value === 'string' ? value : (value?.href ?? '') const target = (typeof value === 'object' && value?.target) ? value.target : '_self' const node = this.domNode as HTMLAnchorElement node.setAttribute('href', BaseLink.sanitize(href)) node.setAttribute('target', target) if (target === '_blank') { node.setAttribute('rel', 'noopener noreferrer') } else { node.removeAttribute('rel') } } } as unknown as Parchment.BlotConstructor } @Component({ selector: 'richText', require: { ngModelCtrl: 'ngModel', }, template: require('./rich-text.component.html'), }) export default class RichTextComponentController implements IComponentController { @Input() allowColorSelection?: boolean @Input() allowFontSelection?: boolean @Input() allowImages?: boolean @Input() isRequired!: boolean protected cachedSelection?: Range | null protected colorInputId: string protected editorSizeId: string protected fontSelectorId: string protected requiredText: string protected selectedColor: string protected selectedFont: string protected fontWeightOptions: string[] = [] protected fontWeightOptionsCache: Map = new Map() protected selectedFontWeight: string protected toolbarId: string protected validationForm: ng.IFormController private colorPicker: HTMLElement | null private editor: Quill private linkTargetCheckbox: HTMLInputElement | null private ngModelCtrl: ng.INgModelController private debouncedSanitizeAndValidate = _.debounce(() => this.sanitizeAndValidate(), 250) private DEFAULT_COLOR = '#535353' private DEFAULT_FONT = 'Roboto' private DEFAULT_SIZE = '14px' private DEFAULT_WEIGHT = '400' private HEADING_1_COLOR = '#535353' private HEADING_1_FONT = 'Poppins' private HEADING_1_SIZE = '24px' private HEADING_2_COLOR = '#535353' private HEADING_2_FONT = 'Poppins' private HEADING_2_SIZE = '20px' private UNIVERSAL_FONT_WEIGHTS = ['400', '700'] constructor( @Inject('$element') private $element: ng.IAugmentedJQuery, @Inject('$rootScope') private $rootScope: JSUIRootScope, @Inject('$timeout') private $timeout: ng.ITimeoutService, @Inject('$q') private $q: ng.IQService, @Inject('googleWebfontsApiService') private googleWebfontsApiService: GoogleWebfontsApiService, @Inject('richTextService') private richTextService: RichTextService, @Inject('translateFactory') private translateFactory: TranslateFactory, ) { } $onChanges(changes: { isRequired?: ng.IChangesObject }) { if (!changes.isRequired) { return } if (changes.isRequired.currentValue === changes.isRequired.previousValue) { return } this.checkValidity() } $onDestroy() { this.editor.off('text-change', (delta: Delta, oldContents, source) => { this.onTextChange(delta, oldContents, source) }) this.colorPicker?.removeEventListener('change', this.colorChanged.bind(this)) this.colorPicker?.removeEventListener('click', this.colorInputOpened.bind(this)) } $onInit() { this.requiredText = this.translateFactory.instant('JSUI.REQUIRED') this.colorInputId = 'rteColorInput-' + uuidv4() this.editorSizeId = 'rteEditorSize-' + uuidv4() this.fontSelectorId = 'rteFontSelector-' + uuidv4() this.toolbarId = 'rteToolbar-' + uuidv4() // Populate the available font weights for the default font if (this.allowFontSelection) { this.updateAvailableFontWeights([this.DEFAULT_FONT]) } this.$timeout(() => { this.colorPicker = document.getElementById(this.colorInputId) this.colorPicker?.addEventListener('change', this.colorChanged.bind(this)) this.colorPicker?.addEventListener('click', this.colorInputOpened.bind(this)) this.updateFontPicker(this.DEFAULT_FONT) this.updateFontWeightPicker(this.DEFAULT_WEIGHT) this.updateSizePicker(this.DEFAULT_SIZE) this.updateColorPicker(this.DEFAULT_COLOR) }) } $postLink() { Quill.register(Quill.import('attributors/class/background') as Parchment.Attributor, true) Quill.register(Quill.import('attributors/style/color') as Parchment.Attributor, true) Quill.register(new StyleAttributor('font-weight', 'font-weight', { scope: Scope.INLINE }), true) const fontStyle = Quill.import('attributors/style/font') as Parchment.Attributor // Setting the whitelist to undefined allows all fonts. (We don't know what fonts Google Fonts will give us.) fontStyle.whitelist = undefined Quill.register(fontStyle, true) const fontSizeArr = ['6px', '7px', '8px', '9px', '10px', '11px', '12px', '14px', '16px', '18px', '20px', '21px', '24px', '28px', '32px', '36px', '48px', '60px'] const size = Quill.import('attributors/style/size') as Parchment.Attributor size.whitelist = fontSizeArr Quill.register(size, true) Quill.register('formats/link', createCustomLinkBlot(), true) // Quill will intercept and block paste events into the link editting tooltip, make sure those run as normal. Quill.register('modules/clipboard', AllowInputPasteClipboard, true) const options = { modules: { toolbar: `#${this.toolbarId}`, }, theme: 'snow', } this.$timeout(() => { this.editor = new Quill(this.$element.find('.js-rich-text-target')[0], options) // Disable spellcheck because of a bad bug that deletes text (CE-4692). this.editor.root.setAttribute('spellcheck', 'false') this.editor.on('text-change', (delta: Delta, oldContents, source) => { this.onTextChange(delta, oldContents, source) }) this.editor.on('selection-change', this.selectionChange.bind(this)) this.setupLinkTooltipWithTarget() }) this.ngModelCtrl.$render = () => { this.$timeout(() => { if (!this.ngModelCtrl.$viewValue) { this.editor.setText('\n') return } this.editor.root.innerHTML = this.richTextService.backwardsCompatibleHtml(this.ngModelCtrl.$viewValue) }) } } private setupLinkTooltipWithTarget(): void { type ThemeWithTooltip = { tooltip?: { root: HTMLElement textbox: HTMLInputElement save: () => void restoreFocus: () => void hide: () => void edit?: (mode: string, preview: string | null) => void linkRange?: Range } } const theme = this.editor.theme as ThemeWithTooltip const tooltip = theme?.tooltip if (!tooltip?.root) { return } // Override quill's default placeholder ("https://quilljs.com") if (tooltip.textbox) { tooltip.textbox.setAttribute('data-link', 'https://') } const openInNewTabLabel = this.translateFactory.instant('JSUI.OPEN_IN_NEW_TAB') const wrapper = document.createElement('span') wrapper.className = 'c-rich-text-link-tooltip-target' const checkbox = document.createElement('input') checkbox.type = 'checkbox' checkbox.id = `rteLinkTarget-${uuidv4()}` checkbox.className = 'c-rich-text-link-tooltip-target__checkbox' const label = document.createElement('label') label.htmlFor = checkbox.id label.className = 'c-rich-text-link-tooltip-target__label' label.textContent = openInNewTabLabel wrapper.appendChild(label) wrapper.appendChild(checkbox) const removeLink = tooltip.root.querySelector('a.ql-remove') if (removeLink && removeLink.parentNode) { removeLink.parentNode.insertBefore(wrapper, removeLink) } this.linkTargetCheckbox = checkbox const originalEdit = tooltip.edit?.bind(tooltip) if (originalEdit) { tooltip.edit = (mode: string, preview: string | null) => { originalEdit(mode, preview) if (mode === 'link' && !tooltip.linkRange) { checkbox.checked = false if (tooltip.textbox) { tooltip.textbox.value = '' } } } } const originalSave = tooltip.save.bind(tooltip) tooltip.save = () => { if (tooltip.root.getAttribute('data-mode') !== 'link') { originalSave() return } const value = tooltip.textbox?.value ?? '' const openInNewTab = !!checkbox.checked const linkValue: LinkValue = { href: value, target: openInNewTab ? '_blank' : '_self' } const { scrollTop } = this.editor.root if (tooltip.linkRange) { this.editor.formatText(tooltip.linkRange, 'link', linkValue, 'user') delete tooltip.linkRange } else { tooltip.restoreFocus() this.editor.format('link', linkValue, 'user') } this.editor.root.scrollTop = scrollTop tooltip.textbox.value = '' tooltip.hide() this.debouncedSanitizeAndValidate() } } protected colorChanged() { // Re-apply the text selection that was made before selecting a color, // as the act of hiding some browsers' color inputs can change the selected text if (this.cachedSelection) { this.editor.setSelection(this.cachedSelection.index, this.cachedSelection.length) } this.editor.format('color', this.selectedColor) this.debouncedSanitizeAndValidate() } protected colorInputOpened() { this.cachedSelection = this.editor.getSelection() } protected fontSelected(fontFamily: string, fontWeightOptions?: string[]): void { if (!fontFamily) { return } this.$rootScope.$broadcast('richTextToolbarContainer:closeContainer') this.editor.format('font', `"${fontFamily}"`) this.fontWeightOptionsCache.set(fontFamily, fontWeightOptions || []) this.updateAvailableFontWeights([fontFamily]) if (![...(fontWeightOptions || []), ...this.UNIVERSAL_FONT_WEIGHTS].includes(this.selectedFontWeight)) { this.selectedFontWeight = this.DEFAULT_WEIGHT this.editor.format('font-weight', this.DEFAULT_WEIGHT) } this.debouncedSanitizeAndValidate() } protected fontWeightSelected(fontWeight: string): void { this.selectedFontWeight = fontWeight this.editor.format('font-weight', fontWeight) this.debouncedSanitizeAndValidate() } /** The user entered/deleted/modified some text. Handle any formatting issues, then store the model. */ protected onTextChange(delta: Delta, oldContents: Delta, source: EmitterSource): void { if (source !== 'user') { return } if (!this.allowImages && delta.ops.some(op => op.insert && typeof op.insert === 'object' && 'image' in op.insert)) { // Don't allow images to be added in any way: pasted, drag-dropped, etc. this.editor.history.undo() } this.$timeout(() => { // In some cases, toggling Bold/Italic/Underline clears the font. this.fixClearedFonts(oldContents, delta) // When we choose "Heading 1" or "Heading 2", we also need to set the font, size, and color. this.fixHeadingFormat(delta) // Quill's "Clear formatting" button leaves us inheriting styles from the CSS. We want to keep things explicit. this.fixClearFormatting(delta) // Also, things like adding a line break can leave us with no style. this.fixMissingFormats(delta) }) this.debouncedSanitizeAndValidate() } /** The user moved the cursor. Fix missing formats and update the toolbar to reflect the current selection. */ protected selectionChange(range: Range, _oldRange: Range, source: EmitterSource): void { if (source !== 'user') { return } if (!range) { return } this.fixMissingFormats() this.$timeout(() => { // Quill updates the toolbar to reflect most formatting (bold, italics, bullet points, etc.), but // fonts, text size, and color are handled by plugins so we need to update those ourselves. const format = this.editor.getFormat(range) this.updateFontPicker(this.getFontFromFormat(format)) this.updateFontWeightPicker(this.getFontWeightFromFormat(format)) this.updateColorPicker(this.getColorFromFormat(format)) this.updateSizePicker(this.getSizeFromFormat(format)) this.updateAvailableFontWeights(Array.isArray(format.font) ? format.font : [format.font]) // Sync "Open in a new tab" checkbox when cursor moves between links while the tooltip is visible. if (range.length === 0 && this.linkTargetCheckbox) { const LinkBlot = Quill.import('formats/link') as new (...args: any[]) => Parchment.Blot const [linkBlot] = this.editor.scroll.descendant(LinkBlot, range.index) || [] this.linkTargetCheckbox.checked = !!(linkBlot?.domNode && (linkBlot.domNode as HTMLAnchorElement).getAttribute('target') === '_blank') } }) } private checkValidity(): void { const isValid = !this.isRequired || !!this.ngModelCtrl.$viewValue this.$timeout(() => { this.ngModelCtrl.$setValidity('required', isValid) }) } /** * Quill's "Clear formatting" button leaves us with no font, size, or color, so the style is inherited from the CSS. * We don't want that: we want everything to be fully defined, so that the user-entered text can be faithfully * reproduced elsewhere. */ private fixClearFormatting(delta: Delta): void { // Using the "Clear formatting" button creates a delta that sets font, size, and color to `null`. Detect any of those. // (Usually you'd get all three together, but some might be missing from the delta if they were already clear, // for example if we were editing an old Workspace Text Section that was created using an older version of this component.) if (delta.ops.some((op) => op.retain && ( op.attributes?.font === null || op.attributes?.size === null || op.attributes?.color === null ))) { this.editor.format('font', `"${this.DEFAULT_FONT}"`) this.updateFontPicker(this.DEFAULT_FONT) this.editor.format('size', this.DEFAULT_SIZE) this.updateSizePicker(this.DEFAULT_SIZE) this.editor.format('color', this.DEFAULT_COLOR) this.updateColorPicker(this.DEFAULT_COLOR) this.editor.format('font-weight', this.DEFAULT_WEIGHT) this.updateFontWeightPicker(this.DEFAULT_WEIGHT) } } /** * Quill's "Heading 1" and "Heading 2" just change the

to

or

. The inner will still have a `style` on it, * setting the font, size, and color. Quill prefers no `style` tags, so it expects the CSS to cover that. We use `style` for * all our formatting, so we also need to define appropriate values for the Headings. */ private fixHeadingFormat(delta: Delta): void { // Look for an operation that sets a heading style (header = 1 or 2), or clears it (header = null) const headingOp = delta.ops.find((op) => op.attributes?.header !== undefined) if (!headingOp) { return } const selection = this.editor.getSelection() if (!selection) { return } // The selection might cover multiple paragraphs, so get them all let lines = this.editor.getLines(selection) if (lines.length === 0) { // The selection has zero length, so get the current paragraph const line = this.editor.getLine(selection.index) // (Perversely, `line` is an array, containing the actual line followed by a number :shrug:) if (!line || !line[0]) { return } lines = [line[0]] } let font = this.DEFAULT_FONT let size = this.DEFAULT_SIZE let color = this.DEFAULT_COLOR switch (headingOp.attributes?.header) { case 1: font = this.HEADING_1_FONT size = this.HEADING_1_SIZE color = this.HEADING_1_COLOR break case 2: font = this.HEADING_2_FONT size = this.HEADING_2_SIZE color = this.HEADING_2_COLOR break } for (const line of lines) { const range = new Range(line.offset(), line.length()) this.editor.formatText(range, 'font', `"${font}"`) this.editor.formatText(range, 'size', size) this.editor.formatText(range, 'color', color) } this.updateFontPicker(font) this.updateSizePicker(size) this.updateColorPicker(color) } /** * Toggling bold/italic/underline clears the font, if the font name contains spaces and ends with digits: https://github.com/slab/quill/issues/4370. * Spot when that happens and restore the old font(s). */ private fixClearedFonts(oldContents: Delta, delta: Delta): void { let currentPos: number = 0 let haveFirstFont: boolean = false let allIsFont: string | undefined let fontRanges: FontRange[] | undefined // The changes come as an array of ops. Most will be just { retain: ... } where nothing has changed, // but some will have attributes indicating what has changed. // If the change crosses a line break, there will be multiple ops with attributes. Loop through them all. for (const op of delta.ops) { if (typeof op.retain !== 'number') { // I've only ever seen this be a number, but the interface says it can be something more complex. // We can't handle that, so get out. return } if (op.retain && op.attributes && !op.attributes.font && ('bold' in op.attributes || 'italic' in op.attributes || 'underline' in op.attributes)) { // The bad thing has happened, so get a list of how the fonts used to be (if we don't have it already). if (!fontRanges) { fontRanges = this.getFontRanges(oldContents) } const affectedRange: Range = { index: currentPos, length: op.retain, } const fontOfRange = this.reinstateMissingFonts(affectedRange, fontRanges) if (!haveFirstFont) { allIsFont = fontOfRange haveFirstFont = true } else if (fontOfRange !== allIsFont) { allIsFont = undefined } } currentPos += op.retain } if (allIsFont) { // The whole selection was the same font, so show that in the font picker. this.updateFontPicker(allIsFont) } } /** * Quill likes to default to no formatting: an empty paragraph has no font, size, or color, so the style is inherited * from the CSS. * We don't want that: we want everything to be fully defined, so that the user-entered text can be faithfully * reproduced elsewhere. * We try to get the format from the previous line, or fall back to the default. */ private fixMissingFormats(delta?: Delta): void { // When debugging this, it can be useful to add this to your CSS to highlight problems: // .ql-editor { color: #f0f; font-family: 'Wingdings', sans-serif; font-size: 9px; } const selection = this.editor.getSelection() if (!selection) { return } if (selection.length) { // Some text is selected; we don't want to change this return } const format = this.editor.getFormat() if (format.font && format.size && format.color && format['font-weight']) { // We are fully formatted; nothing more to do return } const addedFontlessTextRange = this.getRangeOfAddedFontlessText(delta) // Copy missing format from the previous character (or the next, if we're at the start of the document) const nearbyCharIndex = selection.index === 0 ? 1 : selection.index - 1 const nearbyRange: Range = { index: nearbyCharIndex, length: 0, } const nearbyFormat = this.editor.getFormat(nearbyRange) this.editor.format('bold', nearbyFormat.bold) this.editor.format('italic', nearbyFormat.italic) this.editor.format('underline', nearbyFormat.underline) if (!format.font) { const font = this.getFontFromFormat(nearbyFormat) this.editor.format('font', `"${font}"`) if (addedFontlessTextRange) { this.editor.formatText(addedFontlessTextRange, 'font', `"${font}"`) } this.updateFontPicker(font) } if (!format.size) { const size = this.getSizeFromFormat(nearbyFormat) this.editor.format('size', size) this.updateSizePicker(size) } if (!format.color) { const color = this.getColorFromFormat(nearbyFormat) this.editor.format('color', color) this.updateColorPicker(color) } if (!format['font-weight']) { const fontWeight = this.getFontWeightFromFormat(nearbyFormat) this.editor.format('font-weight', fontWeight) this.updateFontWeightPicker(fontWeight) } } private getColorFromFormat(format: QuillFormat): string { if (!format.color) { // We get no color if the default color is selected; or if the selection contains multiple colors, one of which is the default. return this.DEFAULT_COLOR } if (typeof format.color === 'string') { // The selected range is all the same color, which is not the default. return format.color } if (Array.isArray(format.color)) { // The selected range contains multiple colors, none of which are the default. Return the default, to be consistent with other multi-color selections. return this.DEFAULT_COLOR } // Fall back to the default (should never get here) return this.DEFAULT_COLOR } private getFontFromFormat(format: QuillFormat): string { if (!format.font) { // No font is defined for the selection, so show the default. This can happen if you're on a new empty line. return this.DEFAULT_FONT } if (typeof format.font === 'string') { // The range uses a single font. return format.font } if (Array.isArray(format.font)) { // The selected range contains multiple fonts, so show blank. return '' } // Fall back to the default (should never get here) return this.DEFAULT_FONT } private getFontWeightFromFormat(format: QuillFormat): string { if (typeof format['font-weight'] === 'string') { return format['font-weight'] } return this.DEFAULT_WEIGHT } /** * Scans the entire text and returns an array of objects denoting which font is used for each range. */ private getFontRanges(contents: Delta): FontRange[] { let currentPos = 0 const fontRanges: FontRange[] = [] for (const op of contents.ops) { if (typeof op.insert === 'string') { const font = op.attributes ? this.getFontFromFormat(op.attributes) : this.DEFAULT_FONT fontRanges.push({ end: currentPos + op.insert.length - 1, font, length: op.insert.length, start: currentPos, }) currentPos += op.insert.length } } return fontRanges } /** * If you recently toggled bold/italic/underline with no text selected whilst in a block of text whose font name * contains spaces and ends in a number, it will have forgotten the font. * There's no event for the bold/italic/underline toggle, so we detect this when the user types the first letter. */ private getRangeOfAddedFontlessText(delta?: Delta): Range | undefined { if (!delta) { return undefined } let currentPos = 0 for (const op of delta.ops) { if (!('retain' in op) && 'insert' in op && typeof op.insert === 'string' && op.attributes && !op.attributes.font) { // Disclaimer: if you are typing in a block of text whose font name contains spaces and ends in a // number, and has bold/italic/underline applied, but you have just turned bold/italic/underline off with // no text selected, AND IF the letter you then type matches the prior letter in the existing text, // AND that prior letter is the first letter of a word, then the `font` attribute will already be set // correctly here, but the font will not be applied to the text you typed. I cannot see a good way to // detect this, and quite frankly Quill can go do one at this point. // TODO: CE-10797: Yeah right. return { index: currentPos, length: op.insert.length, } } else if ('retain' in op) { if (typeof op.retain !== 'number') { // I've only ever seen this be a number, but the interface says it can be something more complex. // We can't handle that, so get out. return undefined } currentPos += op.retain } else { // Neither a 'retain' nor a buggy 'insert', so the type of edit is not one we're interested in here return undefined } } return undefined } private getSizeFromFormat(format: QuillFormat): string { if (!format.size) { // We get no size if the default size is selected; or if the selection contains multiple sizes, one of which is the default. return this.DEFAULT_SIZE } if (typeof format.size === 'string') { // The selected range is all the same size, which is not the default. return format.size } if (Array.isArray(format.size)) { // The selected range contains multiple sizes, none of which are the default. Return the first size in the array. return format.size[0] } // Fall back to the default (should never get here) return this.DEFAULT_SIZE } /** * Reapply the old fonts to a given range. * @returns If the whole range is of the same font, returns that font. Otherwise undefined. */ private reinstateMissingFonts(affectedRange: Range, fontRanges: FontRange[]): string | undefined { let haveFirstFont: boolean = false let allIsFont: string | undefined // The affected range might contain multiple fonts. Loop through each font section that overlaps our range. let currentStart = affectedRange.index let fontRange = fontRanges.find((fr) => fr.start <= currentStart && fr.end >= currentStart) while (fontRange && currentStart < affectedRange.index + affectedRange.length) { if (!haveFirstFont) { allIsFont = fontRange?.font haveFirstFont = true } else if (fontRange.font !== allIsFont) { allIsFont = undefined } const overlappingRange: Range = { index: Math.max(fontRange.start, currentStart), length: Math.min(fontRange.end, affectedRange.index + affectedRange.length - 1) - currentStart + 1, } this.editor.formatText(overlappingRange, 'font', `"${fontRange.font}"`) currentStart = overlappingRange.index + overlappingRange.length fontRange = fontRanges.find((fr) => fr.start <= currentStart && fr.end >= currentStart) } return allIsFont } private sanitizeAndValidate(): void { let html: string | undefined // An empty Quill editor's html might be like: //



// Detect emptiness and return `undefined` instead, for easier "Required" validation. if (!this.editor.getText().trim()) { html = undefined } else { html = this.richTextService.sanitizeHtml(this.editor.root.innerHTML) } this.ngModelCtrl.$setViewValue(html) this.checkValidity() } /** Updates the color shown in the toolbar. Does not change the color being used in the text. */ private updateColorPicker(color: string): void { this.selectedColor = color } /** Updates the font shown in the toolbar. Does not change the font being used in the text. */ private updateFontPicker(font: string): void { this.selectedFont = font if (font) { this.updateAvailableFontWeights([font]) } } private updateFontWeightPicker(fontWeight: string): void { this.selectedFontWeight = fontWeight } /** Updates the size shown in the toolbar. Does not change the size being used in the text. */ private updateSizePicker(size: string): void { this.$element.find('.ql-size').val(size) this.$element.find(`#${this.editorSizeId} .ql-picker-label`).attr('data-value', size) this.$element.find(`#${this.editorSizeId} .ql-picker-label`).attr('data-label', size) } /** * Caches all available font weights for all provided fonts. * Sets fontWeightOptions to the weights common to all fonts (Always include normal and bold). */ private updateAvailableFontWeights(fonts: string[]): void { // Cache all weights for all requested fonts const fontPromises = fonts.map(font => { const cachedWeights = this.fontWeightOptionsCache.get(font) if (cachedWeights) { return this.$q.resolve(cachedWeights) } return this.googleWebfontsApiService.getFontVariants(font) .then(variants => this.googleWebfontsApiService.sortFontWeightVariants(variants)) .then(weights => { this.fontWeightOptionsCache.set(font, weights) return weights }) }) // Set fontWeightOptions to the weights common to all requested fonts (plus normal and bold) this.$q.all(fontPromises).then(results => { const commonWeights = results.reduce((outputWeights: string[], newWeights: string[]) => outputWeights.filter(weight => newWeights.includes(weight), [])) // Normal and bold should always be available and can be handled by the browser even if there are no weights provided this.fontWeightOptions = [...new Set([...commonWeights, ...this.UNIVERSAL_FONT_WEIGHTS].sort())] }) } } interface FontRange { end: number font: string length: number start: number } interface QuillFormat { [format: string]: unknown }