Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
text-marking.service.ts 11.65 KiB
import { TextComponent } from 'common/components/text/text.component';
import { Injectable } from '@angular/core';
import { LogService } from 'player/modules/logging/services/log.service';

@Injectable({
  providedIn: 'root'
})
export class TextMarkingService {
  private static readonly MARKING_TAG = 'ASPECT-MARKED';

  static applyMarkingDataToText(
    mode: 'mark' | 'delete',
    color: string,
    textComponent: TextComponent
  ): void {
    const selection = window.getSelection();
    if (selection && TextMarkingService.isSelectionValid(selection) && selection.rangeCount > 0) {
      const range = selection.getRangeAt(0);
      const element = textComponent.textContainerRef.nativeElement;
      if (TextMarkingService.isRangeInside(range, element)) {
        TextMarkingService.applyRange(range, selection, mode === 'delete', color);
        textComponent.elementValueChanged.emit({
          id: textComponent.elementModel.id,
          value: TextMarkingService.getMarkedTextIndices(element.innerHTML)
        });
        textComponent.savedText = element.innerHTML;
      } else {
        LogService.info('Selection contains elements that are outside the text component!');
      }
      selection.removeAllRanges();
    } // nothing to do!
  }

  static isSelectionValid = (selection: Selection): boolean => selection.toString().length > 0;

  static isRangeInside(range: Range, element: HTMLElement): boolean {
    return (TextMarkingService.isDescendantOf(range.startContainer, element) &&
      TextMarkingService.isDescendantOf(range.endContainer, element));
  }

  static getMarkedTextIndices = (htmlText: string): string[] => {
    const markingStartPattern =
      new RegExp(`<${TextMarkingService.MARKING_TAG.toLowerCase()} [a-z]+="[\\w\\d()-;:, #]+">`);
    const markingClosingTag = `</${TextMarkingService.MARKING_TAG.toLowerCase()}>`;
    let newHtmlText = htmlText;
    const markCollection = [];
    let matchesArray;
    do {
      matchesArray = newHtmlText.match(markingStartPattern);
      if (matchesArray) {
        const startMatch = matchesArray[0];
        matchesArray = newHtmlText.match(markingClosingTag);
        if (matchesArray) {
          const endMatch = matchesArray[0];
          // we have to escape the brackets of rgb color
          const startIndex = newHtmlText.search(startMatch.replace('(', '\\(').replace(')', '\\)'));
          newHtmlText = newHtmlText.replace(startMatch, '');
          const endIndex = newHtmlText.search(endMatch);
          newHtmlText = newHtmlText.replace(endMatch, '');
          markCollection.push(`${startIndex}-${endIndex}-${TextMarkingService.getMarkingColor(startMatch)}`);
        }
      }
    } while (matchesArray);
    return markCollection;
  };

  static restoreMarkedTextIndices(markings: string[], htmlText: string): string {
    let newHtmlText = htmlText;
    if (markings.length) {
      const markCollectionReversed = [...markings].reverse();
      const markingDataPattern = /^(\d+)-(\d+)-(.+)$/;
      const markingClosingTag = `</${TextMarkingService.MARKING_TAG.toLowerCase()}>`;
      markCollectionReversed.forEach(markingData => {
        const matchesArray = markingData.match(markingDataPattern);
        if (matchesArray) {
          const startIndex = Number(matchesArray[1]);
          const endIndex = Number(matchesArray[2]);
          const startMatch = TextMarkingService.createMarkingStartTag(matchesArray[3]);
          newHtmlText = newHtmlText.substring(0, endIndex) + markingClosingTag + newHtmlText.substring(endIndex);
          newHtmlText = newHtmlText.substring(0, startIndex) + startMatch + newHtmlText.substring(startIndex);
        }
      });
    }
    return newHtmlText;
  }

  static isDescendantOf(node: Node | null, element: HTMLElement): boolean {
    if (!node || node === document) {
      return false;
    }
    if (node.parentElement === element) {
      return true;
    }
    return TextMarkingService.isDescendantOf(node.parentNode, element);
  }

  private static getMarkingColor = (tag: string): string => {
    const colors = tag.match(/\d{1,3}, \d{1,3}, \d{1,3}/);
    return (colors) ? TextMarkingService.rgbToHex(colors[0].split(',').map(value => Number(value))) : 'none';
  };

  private static createMarkingStartTag(color: string): string {
    const rgb = TextMarkingService.hexToRgb(color);
    return `<${
      TextMarkingService.MARKING_TAG.toLowerCase()
    } style="background-color: rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]});">`;
  }

  private static applyRange(
    range: Range, selection: Selection, clear: boolean, color: string
  ): void {
    const nodes: Node[] = TextMarkingService.getSelectedNodes(range, selection);
    if ((range.startContainer === range.endContainer) && range.startContainer.nodeType === Node.TEXT_NODE) {
      if (clear) {
        if (range.startContainer.nodeType === Node.TEXT_NODE) {
          TextMarkingService.clearMarkingFromNode(range);
        } else {
          TextMarkingService.clearNodes(nodes, range);
        }
      } else {
        TextMarkingService.markNode(range, color);
      }
    } else if (clear) {
      TextMarkingService.clearNodes(nodes, range);
    } else {
      TextMarkingService.markNodes(nodes, range, color);
    }
  }

  private static getSelectedNodes = (range: Range, selection: Selection): Node[] => {
    let startContainerNodes: Node[] = [];
    const endContainerNodes: Node[] = [];
    const nodes: Node[] = TextMarkingService.findNodes(range.commonAncestorContainer.childNodes, selection);
    // The range is structured differently in FF and Chrome.
    // With the same selection startcontainer and endcontainer differ.
    // Under certain conditions, startcontainer and endcontainer are not present
    // in the list of child nodes in FF and must therefore be added here
    if (!nodes.includes(range.startContainer)) {
      if (range.startContainer.nodeType === Node.TEXT_NODE) {
        startContainerNodes.push(range.startContainer);
      } else {
        startContainerNodes = TextMarkingService.findNodes(range.startContainer.childNodes, selection);
      }
    }
    if (range.endOffset === 0 && !nodes.includes(range.endContainer) && !range.endContainer.childNodes.length) {
      endContainerNodes.push(range.endContainer);
    }
    return [...startContainerNodes, ...nodes, ...endContainerNodes];
  };

  private static clearMarkingFromNode(range: Range): void {
    if (range.startContainer.parentElement?.tagName?.toUpperCase() === TextMarkingService.MARKING_TAG) {
      const previousText = range.startContainer.nodeValue?.substring(0, range.startOffset) || '';
      const text = range.startContainer.nodeValue?.substring(range.startOffset, range.endOffset) || '';
      const nextText = range.startContainer.nodeValue?.substring(range.endOffset) || '';
      if (text) {
        TextMarkingService.clearMarking(range.startContainer, text, previousText, nextText);
      }
    }
  }

  private static clearMarking(node: Node, text: string, previousText: string, nextText: string) {
    const textElement = document.createTextNode(text as string);
    if (node.parentNode) {
      const { parentNode } = node.parentNode;
      const color = (node.parentNode as HTMLElement).style.backgroundColor || 'none';
      parentNode?.replaceChild(textElement, node.parentNode);
      if (previousText) {
        const prev = TextMarkingService.createMarkedElement(color);
        prev.append(document.createTextNode(previousText));
        parentNode?.insertBefore(prev, textElement);
      }
      if (nextText) {
        const end = TextMarkingService.createMarkedElement(color);
        end.append(document.createTextNode(nextText));
        parentNode?.insertBefore(end, textElement.nextSibling);
      }
    }
  }

  private static clearNodes(nodes: Node[], range: Range): void {
    nodes.forEach((node: Node) => {
      const index = nodes.findIndex(rangeNode => rangeNode === node);
      if (node.parentElement?.tagName.toUpperCase() === TextMarkingService.MARKING_TAG) {
        const nodeValues = TextMarkingService.getNodeValues(node, nodes, index, range);
        if (nodeValues.text) {
          TextMarkingService.clearMarking(node, nodeValues.text, nodeValues.previousText, nodeValues.nextText);
        } else {
          LogService.warn('Cannot recreate node for text', nodeValues);
        }
      }
    });
  }

  private static mark(
    node: Node, text: string, previousText: string, nextText: string, color: string
  ): void {
    const markedElement: HTMLElement = TextMarkingService.createMarkedElement(color);
    markedElement.append(document.createTextNode(text));
    // important!
    const { parentNode } = node;
    parentNode?.replaceChild(markedElement, node);
    if (previousText) {
      const prevDOM = document.createTextNode(previousText);
      parentNode?.insertBefore(prevDOM, markedElement);
    }
    if (nextText) {
      const nextDOM = document.createTextNode(nextText);
      parentNode?.insertBefore(nextDOM, markedElement.nextSibling);
    }
  }

  private static getNodeValues = (node: Node, nodes: Node[], index: number, range: Range): {
    text: string, previousText: string, nextText: string
  } => {
    const start = range.startOffset;
    const end = range.endOffset;
    let text: string;
    let previousText = '';
    let nextText = '';
    if (index === 0) {
      previousText = node.nodeValue?.substring(0, start) || '';
      text = node.nodeValue?.substring(start) || '';
    } else if (index === nodes.length - 1) {
      text = node.nodeValue?.substring(0, end) || '';
      nextText = node.nodeValue?.substring(end) || '';
    } else {
      text = node.nodeValue || '';
    }
    return { text, previousText, nextText };
  };

  private static markNode(range: Range, color: string): void {
    if (range.startContainer.parentElement?.tagName.toUpperCase() !== TextMarkingService.MARKING_TAG) {
      const markedElement: HTMLElement = TextMarkingService.createMarkedElement(color);
      range.surroundContents(markedElement);
    }
  }

  private static markNodes(nodes: Node[], range: Range, color: string): void {
    nodes.forEach((node, index) => {
      const nodeValues = TextMarkingService.getNodeValues(node, nodes, index, range);
      if (nodeValues.text && node.parentElement?.tagName.toUpperCase() !== TextMarkingService.MARKING_TAG) {
        TextMarkingService.mark(node, nodeValues.text, nodeValues.previousText, nodeValues.nextText, color);
      }
    });
  }

  private static createMarkedElement = (color: string): HTMLElement => {
    const markedElement = document.createElement(TextMarkingService.MARKING_TAG);
    markedElement.style.backgroundColor = color;
    return markedElement;
  };

  private static findNodes(childList: Node[] | NodeListOf<ChildNode>, selection: Selection): Node[] {
    const nodes: Node[] = [];
    childList.forEach((node: Node) => {
      if (selection.containsNode(node, true)) {
        if (node.nodeType === Node.TEXT_NODE && !nodes.includes(node)) {
          nodes.push(node);
        }
        if (node.nodeType === Node.ELEMENT_NODE) {
          if (node.childNodes.length) {
            nodes.push(...TextMarkingService.findNodes(node.childNodes, selection));
          } else if (!nodes.includes(node)) {
            nodes.push(node);
          }
        }
      }
    });
    return nodes;
  }

  private static rgbToHex = (rgb: number[]): string => `#${rgb.map(x => {
    const hex = x.toString(16);
    return hex.length === 1 ? `0${hex}` : hex;
  }).join('')}`;

  private static hexToRgb = (hex: string): number[] => {
    const normal = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
    if (normal) return normal.slice(1).map(e => parseInt(e, 16));
    const shorthand = hex.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
    if (shorthand) return shorthand.slice(1).map(e => 0x11 * parseInt(e, 16));
    return [];
  };
}