import angular, { sanitize } from 'angular'
import { Inject, Injectable } from '../decorators'
// text editor requires sanitization but also needs to allow for style attributes which are removed by default
angular.module('app').config(['$sanitizeProvider', ($sanitizeProvider: ng.sanitize.ISanitizeProvider) => {
$sanitizeProvider.addValidAttrs(['style'])
// Quill uses data-list to distinguish bulleted lists from numbered lists (they are both provided as
)
$sanitizeProvider.addValidAttrs(['data-list'])
// Allow target attribute on anchor tags
$sanitizeProvider.addValidAttrs(['target'])
}])
@Injectable('richTextService')
export default class RichTextService {
constructor(
@Inject('$sanitize') private $sanitize: sanitize.ISanitizeService,
) { }
/** Older versions of Quill rendered some things differently. Patch those up. */
backwardsCompatibleHtml(html: string): string {
html = this.removeZeroWidthNoBreakSpaces(html)
html = this.removeCursor(html)
html = this.fixWrappingCursor(html)
html = this.fixFontTags(html)
html = this.fixBulletPoints(html)
html = this.fixStrongBold(html)
return html
}
/**
* Transform
into
,
* and
into
* accounting for any pattern of nested
and tags.
*/
fixBulletPoints(html: string): string {
if (!html) {
return html
}
let newHtml: string = ''
// Regexes for these, in case someone invents a tag or something
const olStartTagRegex = new RegExp(`)`)
const ulStartTagRegex = new RegExp(`
)`)
const liStartTagRegex = new RegExp(`
)`)
// Literal string matches for these
const olEndTag = `
`
const ulEndTag = `
`
// Stack of and
tags, representing how the lists are nested
const listStack: string[] = []
let parsedLength = 0
let currentListType: string | undefined
// Loop through the input html, looking for ,
and
tags. Maintain a stack of nested lists, so
// we can transform the current
correctly.
while (parsedLength < html.length) {
// Find the start of the next ,
or
tag
const olStart = this.indexOfRegex(html, olStartTagRegex, parsedLength)
const ulStart = this.indexOfRegex(html, ulStartTagRegex, parsedLength)
const liStart = this.indexOfRegex(html, liStartTagRegex, parsedLength)
// Find what might be the end of the current list (or might be the end of a nested list)
let nextListEnd: number
switch (currentListType) {
case 'ol':
nextListEnd = html.indexOf(olEndTag, parsedLength)
break
case 'ul':
nextListEnd = html.indexOf(ulEndTag, parsedLength)
break
default:
nextListEnd = -1
break
}
// Find which tag comes next
const nextItemPos = Math.min(
olStart === -1 ? html.length : olStart,
ulStart === -1 ? html.length : ulStart,
liStart === -1 ? html.length : liStart,
nextListEnd === -1 ? html.length : nextListEnd)
newHtml += html.substring(parsedLength, nextItemPos)
parsedLength = nextItemPos
switch (nextItemPos) {
case olStart:
case ulStart:
// We've found the start of a list
newHtml += ``
const listTagEnd = html.indexOf('>', nextItemPos)
if (listTagEnd === -1) {
// Invalid HTML. Dump the lot.
return ''
}
parsedLength = listTagEnd + 1
if (nextItemPos === olStart) {
listStack.push('ol')
currentListType = 'ol'
} else {
listStack.push('ul')
currentListType = 'ul'
}
break
case liStart:
// We've found a list item
if (!currentListType) {
// Invalid HTML. Dump the lot.
return ''
}
const liTagEnd = html.indexOf('>', liStart + 1)
if (liTagEnd === -1) {
// Invalid HTML. Dump the lot.
return ''
}
let liTag = html.substring(liStart, liTagEnd + 1)
if (liTag.indexOf('data-list') === -1) {
const dataAttr = ` data-list="${currentListType === 'ol' ? 'ordered' : 'bullet'}"`
liTag = `
0) {
// We didn't close the last list. Invalid HTML. Dump the lot.
return ''
}
return newHtml
}
/**
* Transform tags from the old Quill editor into tags, as used by the new editor.
* Quill used to set fonts using
* Now it uses
* Although the former still renders correctly, it is skipped for editor.getText(), so it might report that the text editor is empty.
*/
fixFontTags(html: string): string {
if (!html) {
return html
}
let fontTagPos = html.indexOf(' -1) {
const fontTagLength = html.indexOf('>', fontTagPos) - fontTagPos + 1
const fontTag = html.substr(fontTagPos, fontTagLength)
const faceMatch = fontTag.match(/face="([^"]+)"/)
const colorMatch = fontTag.match(/color="([^"]+)"/)
const styles: string[] = []
if (faceMatch) {
styles.push(`font-family: "${faceMatch[1]}";`)
}
if (colorMatch) {
styles.push(`color: ${colorMatch[1]};`)
}
html =
html.substr(0, fontTagPos) +
`` +
html.substr(fontTagPos + fontTagLength)
fontTagPos = html.indexOf('/g, '<\/span>')
return html
}
/**
* Quill adds its own editor cursor to its HTML: |
* These days we remove that on save, but sometimes it's present in the HTML that was saved by an older version of the editor.
* In rare cases, the HTML wraps that span around actual content. This function fixes that.
*/
fixWrappingCursor(html: string): string {
if (!html) {
return html
}
// Regexes can't save us because the cursor span may contain other spans, like this:
//
Yikes!
// We want to remove the outer span, while preserving the inner span.
const cursorStartTag = ``
const spanStartTag = ``
let cursorStart = html.indexOf(cursorStartTag)
while (cursorStart > -1) {
let currentPos = cursorStart
let spanDepth = 1
while (spanDepth > 0) {
const nextSpanStart = html.indexOf(spanStartTag, currentPos + 1)
const nextSpanEnd = html.indexOf(spanEndTag, currentPos + 1)
if (nextSpanStart === -1 && nextSpanEnd === -1) {
// Spans aren't closed properly, so we have invalid html. Dump the lot. (Should never happen.)
html = ''
spanDepth = 0
} else if (nextSpanStart > -1 && nextSpanStart < nextSpanEnd) {
// We've found another nested span
spanDepth++
currentPos = nextSpanStart
} else {
// We've found the end of our current span
spanDepth--
currentPos = nextSpanEnd
}
}
if (html !== '') {
const cursorEnd = currentPos + spanEndTag.length - 1
const cursorOuterLength = cursorEnd - cursorStart + 1
const cursorInnerLength = cursorOuterLength - cursorStartTag.length - spanEndTag.length
html =
html.substr(0, cursorStart) + // Everything before the cursor
html.substr(cursorStart + cursorStartTag.length, cursorInnerLength) + // Everything inside the cursor
html.substr(cursorEnd + 1) // Everything after the cursor
}
// There might be multiple cursors - even nested cursors - so keep going until there are none left
cursorStart = html.indexOf(``)
}
return html
}
/**
* To transition to supporting Google font weights, we switched to inline font weights rather than quill's default tags.
* Replace the legacy strong's with regular span's and an inline font weight.
*/
fixStrongBold(html: string): string {
if (!html) { return html }
const bold = '700'
// Replace tags with tags and add font-weight: 700
html = html.replace(/]*)>/g, (match, attributes) => {
// Check if there's already a style attribute
const styleMatch = attributes.match(/style="([^"]*)"/)
if (styleMatch) {
// There's already a style attribute, add font-weight to it
const existingStyles = (styleMatch[1] || '').trim()
// Check if font-weight is already present
if (existingStyles.includes('font-weight')) {
// Ignore existing font-weight
return ``
} else {
// Add font-weight to styles
const separator = existingStyles.endsWith(';') ? ' ' : '; '
return ``
}
} else {
// No style attribute exists, add one with font-weight
return ``
}
})
// Replace closing tags
html = html.replace(/<\/strong>/g, '')
return html
}
/** Helper method because regexes can't natively start searching from a given index */
indexOfRegex(text: string, regex: RegExp, fromIndex: number): number {
const match = text.substring(fromIndex).match(regex)
if (!match || match.index === undefined || match.index === -1) {
return -1
}
return fromIndex + match.index
}
/** Sometimes Quill's HTML includes its own cursor. We don't want to save that. */
removeCursor(html: string): string {
if (!html) {
return html
}
return html.replace(/\.?\<\/span\>/g, '')
}
/** Sometimes Quill adds Zero Width No-Break Spaces. */
removeZeroWidthNoBreakSpaces(html: string): string {
if (!html) {
return html
}
return html.replace(//g, '')
}
/** Clean up the HTML, ready for save */
sanitizeHtml(html: string): string {
html = this.removeZeroWidthNoBreakSpaces(html)
html = this.removeCursor(html)
html = this.$sanitize(html)
return html
}
}