Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 69 additions & 197 deletions frontend/src/components/AppNodeText.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,16 @@
Selectively renders the following tags in HTML and SVG:
- <sup>
- <i>
- <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.

- <b>
- <a> with an `href` attribute starting with `http`, surrounded in double quotes
-->

<template>
<tspan v-if="isSvg" ref="container">
{{ text }}
</tspan>

<foreignObject v-if="isSvg" x="0" y="0" width="100%" height="1.5em">
<span ref="container" xmlns="http://www.w3.org/1999/xhtml">
{{ text }}
</span>
</foreignObject>
<span
v-else
ref="container"
Expand All @@ -41,193 +29,86 @@ import { onMounted, onUpdated, ref } from "vue";
type Props = {
text?: string;
isSvg?: boolean;
truncateWidth?: number;
highlight?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
text: "",
isSvg: false,
truncateWidth: undefined,
highlight: false,
});

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

// Use $attrs to capture external classes and styles

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

type Replacement = {
type: ReplacedTag;
start: [number, number];
end: [number, number];
startNode?: Text;
endNode?: Text;
};

type ReplacementPosition = {
type: "start" | "end";
replacement: Replacement;
at: [number, number];
};

const replacementTags = new Map([
[
"sup" as ReplacedTag,
{
regex: /(<sup>).*?(<\/sup>)/dg,
createSurroundingEl(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
: document.createElement("sup");
},
afterMount(isSvg: Boolean, el: Element) {
if (!isSvg) return;
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 = el.nextSibling!.nextSibling;
if (!nextSibling) return;

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

const tspan = document.createElementNS(
"http://www.w3.org/2000/svg",
"tspan",
);

tspan.setAttribute("dy", "+1ex");
function makeEscapedTagPattern(tagName: string, attrsPattern: string = "") {
return new RegExp(
`(&lt;${tagName}${attrsPattern}&gt;)(.*?)(&lt;/${tagName}&gt;)`,
"g",
);
}

range.surroundContents(tspan);
},
},
],
[
"i" as ReplacedTag,
{
regex: /(<i>).*?(<\/i>)/dg,
createSurroundingEl(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
: document.createElement("i");
},
afterMount(isSvg: Boolean, el: Element) {
if (!isSvg) return;
el.classList.add("svg-italic");
},
},
],
[
"a" as ReplacedTag,
{
regex: /(<a href="http[^"]+">).*?(<\/a>)/dg,
createSurroundingEl(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "a")
: document.createElement("a");
const replacementPatterns = [
makeEscapedTagPattern("sup"),
makeEscapedTagPattern("i"),
makeEscapedTagPattern("b"),
makeEscapedTagPattern("a", ' href="http[^"]+"'),
];

function buildDOM(containerEl: HTMLSpanElement) {
const { truncateWidth, isSvg } = props;

let html = containerEl.innerHTML;

replacementPatterns.forEach((pattern) => {
html = html.replaceAll(
pattern,
(match, openTag: string, content: string, closeTag: string) => {
// Replace &lt; with < and &gt; with > to unescape them
let unescaped = "";
unescaped += "<" + openTag.slice(4, -4) + ">";
unescaped += content;
unescaped += "<" + closeTag.slice(4, -4) + ">";
return unescaped;
},
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 = el.previousSibling!;
const href = tagTextNode.textContent!.slice(9, -2);
el.setAttribute("href", href);
},
},
],
[
"b" as ReplacedTag,
{
regex: /(<b>).*?(<\/b>)/dg,
createSurroundingEl(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
: document.createElement("b");
},
afterMount(isSvg: Boolean, el: Element) {
if (!isSvg) return;
el.classList.add("svg-bold");
},
},
],
]);

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

const containsOnlyText =
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 = containerEl.firstChild as Text;

const replacements: Replacement[] = [];

// Create a list of every place there's a match for a start and end tag
// matched from the defined regexes.
Array.from(replacementTags.entries()).forEach(([type, { regex }]) => {
for (const match of text.matchAll(regex)) {
const { indices } = match;

replacements.push({
type,
start: indices![1],
end: indices![2],
});
}
);
});

// Now create a new list that has the position of each start and end token
const positions: ReplacementPosition[] = replacements.flatMap((x) => [
{ type: "start", replacement: x, at: x.start },
{ type: "end", replacement: x, at: x.end },
]);
containerEl.innerHTML = html;

// Sort that list by the position of the tag token (with the last token
// first and the first token last).
// WebKit browsers have strange behavior rendering <sup> tags inside
// <foreignObject> tags. It seems to be because they use relative
// positioning to style superscripts, and relative positioning inside
// <foreignObject> tags doesn't work very well (in WebKit). As a result, the
// superscripted text shows up at the very top of the SVG, as if it were
// relatively positioned there.
//
// After that, iterate through each of the token positions and split the
// 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];
})
.forEach((position) => {
textNode.splitText(position.at[1]);
const node = textNode.splitText(position.at[0]);
position.replacement[`${position.type}Node`] = node;
// To avoid this, we can change the <sup> tags to <span>s and style them
// ourselves without using relative positioning.
if (isSvg) {
const supTags = [...containerEl.querySelectorAll("sup")];

supTags.forEach((supTag) => {
const spanTag = document.createElement("span");
spanTag.style.verticalAlign = "super";
spanTag.style.fontSize = "80%";
spanTag.style.lineHeight = "1.0";

spanTag.replaceChildren(...supTag.childNodes);
supTag.parentNode!.replaceChild(spanTag, supTag);
});

// Build the correct DOM tree for each replacement found
replacements.forEach((replacement) => {
const { startNode, endNode, type } = replacement;
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.
const range = new Range();
range.setStartAfter(startNode!);
range.setEndBefore(endNode!);

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

// Run any code required after the container element is mounted.
afterMount(props.isSvg, el);

// Remove the start and end tag text nodes
startNode!.parentNode!.removeChild(startNode!);
endNode!.parentNode!.removeChild(endNode!);
});
}

if (truncateWidth) {
containerEl.style.display = "inline-block";
containerEl.style.overflow = "hidden";
containerEl.style.whiteSpace = "nowrap";
containerEl.style.textOverflow = "ellipsis";
containerEl.style.verticalAlign = "top";
containerEl.style.maxWidth = `${truncateWidth}px`;
}
}

onMounted(() => {
Expand All @@ -242,15 +123,6 @@ onUpdated(() => {
</script>

<style>
.svg-superscript {
font-size: 0.7rem;
}
.svg-italic {
font-style: italic;
}
.svg-bold {
font-weight: bold;
}
.highlight-text em {
background-color: yellow;
}
Expand Down
24 changes: 12 additions & 12 deletions frontend/src/components/ThePhenogrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@
cellSize * 0.25
}) rotate(-45)`"
>
{{ truncateLabels ? truncate(col.label) : col.label }}
<AppNodeText
is-svg
:text="col.label"
:truncate-width="marginLeft * 0.8"
/>
</text>
<template #content>
<AppNodeBadge :node="col" :absolute="true" :show-id="true" />
Expand Down Expand Up @@ -116,7 +120,11 @@
(0.5 + rowIndex) * cellSize
})`"
>
{{ truncateLabels ? truncate(row.label) : row.label }}
<AppNodeText
is-svg
:text="row.label"
:truncate-width="marginLeft * 0.8"
/>
</text>
<template #content>
<AppNodeBadge :node="row" :absolute="true" :show-id="true" />
Expand Down Expand Up @@ -364,10 +372,11 @@ import { useResizeObserver, useScroll } from "@vueuse/core";
import type { TermInfo, TermPairwiseSimilarity } from "@/api/model";
import AppCheckbox from "@/components/AppCheckbox.vue";
import AppNodeBadge from "@/components/AppNodeBadge.vue";
import AppNodeText from "@/components/AppNodeText.vue";
import AppSelectSingle, { type Option } from "@/components/AppSelectSingle.vue";
import { appendToBody } from "@/global/tooltip";
import { frame } from "@/util/debug";
import { screenToSvgCoords, truncateBySize } from "@/util/dom";
import { screenToSvgCoords } from "@/util/dom";
import { downloadSvg } from "@/util/download";
import { copyToClipboard } from "@/util/string";
import type AppFlex from "./AppFlex.vue";
Expand Down Expand Up @@ -491,15 +500,6 @@ function copy() {
);
}

/** truncate labels */
function truncate(text?: string) {
return truncateBySize(
text || "",
marginLeft * 0.9,
cellSize * 0.5 + "px Poppins",
);
}

/** track grid scroll */
const scrollInfo = useScroll(scroll);
const scrollCoords = ref({ x: 0, y: 0, w: 0, h: 0 });
Expand Down
Loading