diff --git a/ts/editor/helpers.ts b/ts/editor/helpers.ts index 5ff18f3a6..7493f6698 100644 --- a/ts/editor/helpers.ts +++ b/ts/editor/helpers.ts @@ -7,6 +7,10 @@ export function nodeIsElement(node: Node): node is Element { return node.nodeType === Node.ELEMENT_NODE; } +export function isHTMLElement(elem: Element): elem is HTMLElement { + return elem instanceof HTMLElement; +} + const INLINE_TAGS = [ "A", "ABBR", diff --git a/ts/editor/htmlFilter.ts b/ts/editor/htmlFilter.ts index d8cd8e61b..60528c70e 100644 --- a/ts/editor/htmlFilter.ts +++ b/ts/editor/htmlFilter.ts @@ -1,68 +1,56 @@ /* Copyright: Ankitects Pty Ltd and contributors * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ -import { nodeIsElement } from "./helpers"; -import { filterElement } from "./htmlFilterElement"; +import { + filterElementBasic, + filterElementExtended, + filterElementInternal, +} from "./htmlFilterElement"; +import { filterNode } from "./htmlFilterNode"; -////////////////////// //////////////////// //////////////////// - -function isHTMLElement(elem: Element): elem is HTMLElement { - return elem instanceof HTMLElement; +enum FilterMode { + Basic, + Extended, + Internal, } -// filtering from another field -function filterInternalNode(elem: Element): void { - if (isHTMLElement(elem)) { - elem.style.removeProperty("background-color"); - elem.style.removeProperty("font-size"); - elem.style.removeProperty("font-family"); - } - // recurse - for (let i = 0; i < elem.children.length; i++) { - const child = elem.children[i]; - filterInternalNode(child); - } +const filters: Record void> = { + [FilterMode.Basic]: filterElementBasic, + [FilterMode.Extended]: filterElementExtended, + [FilterMode.Internal]: filterElementInternal, +}; + +const whitespace = /[\n\t ]+/g; + +function collapseWhitespace(value: string): string { + return value.replace(whitespace, " "); } -// filtering from external sources -function filterNode(node: Node, extendedMode: boolean): void { - if (node.nodeType === Node.COMMENT_NODE) { - node.parentNode.removeChild(node); - return; - } - if (!nodeIsElement(node)) { - return; - } - - // descend first, and take a copy of the child nodes as the loop will skip - // elements due to node modifications otherwise - for (const child of [...node.childNodes]) { - filterNode(child, extendedMode); - } - - filterElement(node, extendedMode); +function trim(value: string): string { + return value.trim(); } +const outputHTMLProcessors: Record string> = { + [FilterMode.Basic]: (outputHTML: string): string => + trim(collapseWhitespace(outputHTML)), + [FilterMode.Extended]: trim, + [FilterMode.Internal]: trim, +}; -export function filterHTML( - html: string, - internal: boolean, - extendedMode: boolean -): string { - // wrap it in as we aren't allowed to change top level elements - const top = document.createElement("ankitop"); - top.innerHTML = html; +export function filterHTML(html: string, internal: boolean, extended: boolean): string { + const template = document.createElement("template"); + template.innerHTML = html; - if (internal) { - filterInternalNode(top); - } else { - filterNode(top, extendedMode); - } - let outHtml = top.innerHTML; - if (!extendedMode && !internal) { - // collapse whitespace - outHtml = outHtml.replace(/[\n\t ]+/g, " "); - } - outHtml = outHtml.trim(); - return outHtml; + const mode = internal + ? FilterMode.Internal + : extended + ? FilterMode.Extended + : FilterMode.Basic; + + const content = template.content; + const filter = filterNode(filters[mode]); + + filter(content); + + return outputHTMLProcessors[mode](template.innerHTML); } diff --git a/ts/editor/htmlFilterElement.ts b/ts/editor/htmlFilterElement.ts index 272d398b8..90b314de1 100644 --- a/ts/editor/htmlFilterElement.ts +++ b/ts/editor/htmlFilterElement.ts @@ -1,4 +1,10 @@ -import { filterSpan } from "./htmlFilterSpan"; +import { isNightMode, isHTMLElement } from "./helpers"; +import { removeNode as removeElement } from "./htmlFilterNode"; +import { + filterStylingNightMode, + filterStylingLightMode, + filterStylingInternal, +} from "./htmlFilterStyling"; interface TagsAllowed { [tagName: string]: FilterMethod; @@ -6,8 +12,6 @@ interface TagsAllowed { type FilterMethod = (element: Element) => void; -function doNothing() {} - function filterOutAttributes( attributePredicate: (attributeName: string) => boolean, element: Element @@ -33,17 +37,19 @@ function blockExcept(attrs: string[]): FilterMethod { ); } - -function removeElement(element: Element): void { - element.parentNode?.removeChild(element); -} - function unwrapElement(element: Element): void { element.outerHTML = element.innerHTML; } +function filterSpan(element: Element): void { + const filterAttrs = blockExcept(["STYLE"]); + filterAttrs(element); + + const filterStyle = isNightMode() ? filterStylingNightMode : filterStylingLightMode; + filterStyle(element as HTMLSpanElement); +} + const tagsAllowedBasic: TagsAllowed = { - ANKITOP: doNothing, BR: blockAll, IMG: blockExcept(["SRC"]), DIV: blockAll, @@ -84,17 +90,25 @@ const tagsAllowedExtended: TagsAllowed = { UL: blockAll, }; -export function filterElement(element: Element, extendedMode: boolean): void { +const filterElementTagsAllowed = (tagsAllowed: TagsAllowed) => ( + element: Element +): void => { const tagName = element.tagName; - const tagsAllowed = extendedMode ? tagsAllowedExtended : tagsAllowedBasic; if (tagsAllowed.hasOwnProperty(tagName)) { tagsAllowed[tagName](element); - } - else if (element.innerHTML) { + } else if (element.innerHTML) { removeElement(element); - } - else { + } else { unwrapElement(element); } +}; + +export const filterElementBasic = filterElementTagsAllowed(tagsAllowedBasic); +export const filterElementExtended = filterElementTagsAllowed(tagsAllowedExtended); + +export function filterElementInternal(element: Element): void { + if (isHTMLElement(element)) { + filterStylingInternal(element); + } } diff --git a/ts/editor/htmlFilterNode.ts b/ts/editor/htmlFilterNode.ts new file mode 100644 index 000000000..03d16f963 --- /dev/null +++ b/ts/editor/htmlFilterNode.ts @@ -0,0 +1,32 @@ +export function removeNode(element: Node): void { + element.parentNode?.removeChild(element); +} + +function iterateElement( + filter: (node: Node) => void, + fragment: DocumentFragment | Element +): void { + for (const child of [...fragment.childNodes]) { + filter(child); + } +} + +export const filterNode = (elementFilter: (element: Element) => void) => ( + node: Node +): void => { + switch (node.nodeType) { + case Node.COMMENT_NODE: + removeNode(node); + break; + + case Node.DOCUMENT_FRAGMENT_NODE: + iterateElement(filterNode(elementFilter), node as DocumentFragment); + + case Node.ELEMENT_NODE: + iterateElement(filterNode(elementFilter), node as Element); + elementFilter(node as Element); + + default: + // do nothing + } +}; diff --git a/ts/editor/htmlFilterSpan.ts b/ts/editor/htmlFilterSpan.ts deleted file mode 100644 index b02e76281..000000000 --- a/ts/editor/htmlFilterSpan.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { isNightMode } from "./helpers"; - -/* keys are allowed properties, values are blocked values */ -const stylingAllowListNightMode = { - "font-weight": [], - "font-style": [], - "text-decoration-line": [], -}; - -const stylingAllowList = { - color: [], - "background-color": ["transparent"], - ...stylingAllowListNightMode, -}; - -function isStylingAllowed(property: string, value: string): boolean { - const allowList = isNightMode() ? stylingAllowListNightMode : stylingAllowList; - - return allowList.hasOwnProperty(property) && !allowList[property].includes(value); -} - -const allowedAttrs = ["STYLE"]; - -export function filterSpan(element: Element): void { - // filter out attributes - for (const attr of [...element.attributes]) { - const attrName = attr.name.toUpperCase(); - - if (!allowedAttrs.includes(attrName)) { - element.removeAttributeNode(attr); - } - } - - // filter styling - const elementStyle = (element as HTMLSpanElement).style; - - for (const property of [...elementStyle]) { - const value = elementStyle.getPropertyValue(name); - - if (!isStylingAllowed(property, value)) { - elementStyle.removeProperty(property); - } - } -} diff --git a/ts/editor/htmlFilterStyling.ts b/ts/editor/htmlFilterStyling.ts new file mode 100644 index 000000000..818e266df --- /dev/null +++ b/ts/editor/htmlFilterStyling.ts @@ -0,0 +1,54 @@ +interface AllowPropertiesBlockValues { + [property: string]: string[]; +} + +type BlockProperties = string[]; + +type StylingPredicate = (property: string, value: string) => boolean; + +const stylingNightMode: AllowPropertiesBlockValues = { + "font-weight": [], + "font-style": [], + "text-decoration-line": [], +}; + +const stylingLightMode: AllowPropertiesBlockValues = { + color: [], + "background-color": ["transparent"], + ...stylingNightMode, +}; + +const stylingInternal: BlockProperties = [ + "background-color", + "font-size", + "font-family", +]; + +const allowPropertiesBlockValues = ( + allowBlock: AllowPropertiesBlockValues +): StylingPredicate => (property: string, value: string): boolean => + allowBlock.hasOwnProperty(property) && !allowBlock[property].includes(value); + +const blockProperties = (block: BlockProperties): StylingPredicate => ( + property: string +): boolean => !block.includes(property); + +const filterStyling = (predicate: (property: string, value: string) => boolean) => ( + element: HTMLElement +): void => { + for (const property of [...element.style]) { + const value = element.style.getPropertyValue(name); + + if (!predicate(property, value)) { + element.style.removeProperty(property); + } + } +}; + +export const filterStylingNightMode = filterStyling( + allowPropertiesBlockValues(stylingNightMode) +); +export const filterStylingLightMode = filterStyling( + allowPropertiesBlockValues(stylingLightMode) +); +export const filterStylingInternal = filterStyling(blockProperties(stylingInternal));