Skip to content

Commit

Permalink
Add more documentation to AppNodeText
Browse files Browse the repository at this point in the history
  • Loading branch information
Patrick Golden committed Nov 21, 2024
1 parent e96a020 commit 161a149
Showing 1 changed file with 41 additions and 27 deletions.
68 changes: 41 additions & 27 deletions frontend/src/components/AppNodeText.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,21 @@
Selectively renders the following tags in HTML and SVG:
- <sup>
- <i>
- <a> with an `href` property surrounded in double quotes
- <a> with an `href` attribute surrounded in double quotes

There are two alternatives to the approach taken here, but neither are
sufficient.

1. We could use a sanitizer like [DOMPurify](https://github.com/cure53/DOMPurify)
to sanitize arbitrary strings, but that would strip out legitimate text
that an HTML parser might confuse for a tag. An example of such text can be
found here: <https://github.com/monarch-initiative/monarch-app/issues/887#issuecomment-2479676335>

2. We could escape the entire string, selectively unescape `&lt;sup&gt;` (and
so on), and then pass the string to `containerEl.innerHTML`. However, this
would lead to markup without the desired effect in SVG, since the <sup> and
<i> elements do not do anything in SVG.

-->

<template>
Expand All @@ -31,10 +45,10 @@ const props = withDefaults(defineProps<Props>(), {

const container = ref<HTMLSpanElement | SVGTSpanElement | null>(null);

type ReplacementTag = "sup" | "a" | "i";
type ReplacedTag = "sup" | "a" | "i";

type Replacement = {
type: ReplacementTag;
type: ReplacedTag;
start: [number, number];
end: [number, number];
startNode?: Text;
Expand All @@ -49,26 +63,27 @@ type ReplacementPosition = {

const replacementTags = new Map([
[
"sup" as ReplacementTag,
"sup" as ReplacedTag,
{
regex: /(<sup>).*?(<\/sup>)/dg,
createSurroundingTag(isSvg: Boolean) {
createSurroundingEl(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
: document.createElement("sup");
},
afterMount(isSvg: Boolean, node: Element) {
afterMount(isSvg: Boolean, el: Element) {
if (!isSvg) return;
node.setAttribute("dy", "-1ex");
node.classList.add("svg-superscript");
el.setAttribute("dy", "-1ex");
el.classList.add("svg-superscript");

// The next sibling will be the text node "</sup>". Check if there is
// remaining text after that. If there is, adjust the text baseline back
// down to the normal level.
const nextSibling = node.nextSibling!.nextSibling;
const nextSibling = el.nextSibling!.nextSibling;
if (!nextSibling) return;

const range = new Range();
range.selectNode(nextSibling);

const tspan = document.createElementNS(
"http://www.w3.org/2000/svg",
Expand All @@ -77,59 +92,58 @@ const replacementTags = new Map([

tspan.setAttribute("dy", "+1ex");

range.selectNode(nextSibling);
range.surroundContents(tspan);
},
},
],
[
"i" as ReplacementTag,
"i" as ReplacedTag,
{
regex: /(<i>).*?(<\/i>)/dg,
createSurroundingTag(isSvg: Boolean) {
createSurroundingEl(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
: document.createElement("i");
},
afterMount(isSvg: Boolean, node: Element) {
afterMount(isSvg: Boolean, el: Element) {
if (!isSvg) return;
node.classList.add("svg-italic");
el.classList.add("svg-italic");
},
},
],
[
"a" as ReplacementTag,
"a" as ReplacedTag,
{
regex: /(<a href="http[^"]+">).*?(<\/a>)/dg,
createSurroundingTag(isSvg: Boolean) {
createSurroundingEl(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "a")
: document.createElement("a");
},
afterMount(isSvg: Boolean, node: Element) {
afterMount(isSvg: Boolean, el: Element) {
// The previous sibling will be the text node containing the string
// <a href="http...">. Slice it to get the value of the href.
const tagTextNode = node.previousSibling!;
const tagTextNode = el.previousSibling!;
const href = tagTextNode.textContent!.slice(9, -2);
node.setAttribute("href", href);
el.setAttribute("href", href);
},
},
],
]);

function buildDOM(el: Element) {
function buildDOM(containerEl: Element) {
const text = props.text;

const containsOnlyText =
el.childNodes.length === 1 &&
el.firstChild?.nodeType === Node.TEXT_NODE &&
containerEl.childNodes.length === 1 &&
containerEl.firstChild?.nodeType === Node.TEXT_NODE &&
text !== null;

// This should always be false, but just in case-- bail out of the function
// if the element contains anything but a single text node.
if (!containsOnlyText) return;

const textNode = el.firstChild as Text;
const textNode = containerEl.firstChild as Text;

const replacements: Replacement[] = [];

Expand Down Expand Up @@ -157,8 +171,8 @@ function buildDOM(el: Element) {
// first and the first token last).
//
// After that, iterate through each of the token positions and split the
// text node at the boundaries of each token. Store the text node of each
// start and end tag in the `replacements` array to be used later.
// text node at the token's boundaries. Store the text node of each start
// and end tag in the `replacements` array to be used later.
positions
.sort((a, b) => {
return b.at[0] - a.at[0];
Expand All @@ -172,7 +186,7 @@ function buildDOM(el: Element) {
// Build the correct DOM tree for each replacement found
replacements.forEach((replacement) => {
const { startNode, endNode, type } = replacement;
const { createSurroundingTag, afterMount } = replacementTags.get(type)!;
const { createSurroundingEl, afterMount } = replacementTags.get(type)!;

// Select the range that goes from the end of the opening tag text node to
// the start of the closing tag text node.
Expand All @@ -181,7 +195,7 @@ function buildDOM(el: Element) {
range.setEndBefore(endNode!);

// Surround that range with the appropriate DOM element.
const el = createSurroundingTag(props.isSvg);
const el = createSurroundingEl(props.isSvg);
range.surroundContents(el);

// Run any code required after the container element is mounted.
Expand Down

0 comments on commit 161a149

Please sign in to comment.