Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Eliminate use of v-html when rendering node names and descriptions #908

Merged
merged 6 commits into from
Nov 21, 2024
Merged
Changes from 4 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
10 changes: 7 additions & 3 deletions frontend/src/components/AppNodeBadge.vue
Original file line number Diff line number Diff line change
@@ -18,10 +18,13 @@
? { breadcrumbs: [...currentBreadcrumbs, ...breadcrumbs] }
: state || undefined
"
v-html="name"
></AppLink>
>
<AppNodeText :text="name" />
</AppLink>
<span v-else>
<span class="name" v-html="name"></span>
<span class="name">
<AppNodeText :text="name" />
</span>
<span v-if="info">({{ info }})</span>
</span>
</span>
@@ -31,6 +34,7 @@
import { computed } from "vue";
import { getCategoryIcon, getCategoryLabel } from "@/api/categories";
import type { Node } from "@/api/model";
import AppNodeText from "@/components/AppNodeText.vue";
import { breadcrumbs as currentBreadcrumbs } from "@/global/breadcrumbs";
import type { Breadcrumb } from "@/global/breadcrumbs";

214 changes: 214 additions & 0 deletions frontend/src/components/AppNodeText.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<!--
The text of a node in the knowledge graph.

Selectively renders the following tags in HTML and SVG:
- <sup>
- <i>
- <a> with an `href` property surrounded in double quotes
-->

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

<script setup lang="ts">
import { onMounted, onUpdated, ref } from "vue";

type Props = {
text?: string;
isSvg?: boolean;
};

const props = withDefaults(defineProps<Props>(), {
text: "",
isSvg: false,
});

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

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

type Replacement = {
type: ReplacementTag;
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 ReplacementTag,
{
regex: /(<sup>).*?(<\/sup>)/dg,
createSurroundingTag(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
: document.createElement("sup");
},
afterMount(isSvg: Boolean, node: Element) {
if (!isSvg) return;
node.setAttribute("dy", "-1ex");
node.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;
if (!nextSibling) return;

const range = new Range();

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

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

range.selectNode(nextSibling);
range.surroundContents(tspan);
},
},
],
[
"i" as ReplacementTag,
{
regex: /(<i>).*?(<\/i>)/dg,
createSurroundingTag(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
: document.createElement("i");
},
afterMount(isSvg: Boolean, node: Element) {
if (!isSvg) return;
node.classList.add("svg-italic");
},
},
],
[
"a" as ReplacementTag,
{
regex: /(<a href="http[^"]+">).*?(<\/a>)/dg,
createSurroundingTag(isSvg: Boolean) {
return isSvg
? document.createElementNS("http://www.w3.org/2000/svg", "a")
: document.createElement("a");
},
afterMount(isSvg: Boolean, node: 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 href = tagTextNode.textContent!.slice(9, -2);
node.setAttribute("href", href);
},
},
],
]);

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

const containsOnlyText =
el.childNodes.length === 1 &&
el.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 replacements: Replacement[] = [];

// Create a list of every place there's a match for a start and end tag
// matched from the defined regexes.
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 },
]);

// Sort that list by the position of the tag token (with the last token
// 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.
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;
});

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

onMounted(() => {
if (!container.value) return;
buildDOM(container.value);
});

onUpdated(() => {
if (!container.value) return;
buildDOM(container.value);
});
</script>

<style>
.svg-superscript {
font-size: 0.7rem;
}
.svg-italic {
font-style: italic;
}
</style>
14 changes: 7 additions & 7 deletions frontend/src/pages/node/SectionOverview.vue
Original file line number Diff line number Diff line change
@@ -31,8 +31,9 @@
v-tooltip="'Click to expand'"
class="description truncate-10"
tabindex="0"
v-html="node.description?.trim()"
></p>
>
<AppNodeText :text="node.description?.trim()" />
</p>
</AppDetail>

<!-- inheritance -->
@@ -71,11 +72,9 @@
title="Also Known As"
:full="true"
>
<p
class="truncate-2"
tabindex="0"
v-html="node.synonym?.join(',\n&nbsp;')"
></p>
<p class="truncate-2" tabindex="0">
<AppNodeText :text="node.synonym?.join(',\n&nbsp;')" />
</p>
</AppDetail>

<!-- URI -->
@@ -155,6 +154,7 @@ import type { Node } from "@/api/model";
import AppDetail from "@/components/AppDetail.vue";
import AppDetails from "@/components/AppDetails.vue";
import AppNodeBadge from "@/components/AppNodeBadge.vue";
import AppNodeText from "@/components/AppNodeText.vue";
import { scrollTo } from "@/router";
import { sleep } from "@/util/debug";

3 changes: 2 additions & 1 deletion frontend/src/pages/node/SectionTitle.vue
Original file line number Diff line number Diff line change
@@ -26,8 +26,8 @@
>
<span
:style="{ textDecoration: node.deprecated ? 'line-through' : '' }"
v-html="node.name"
>
<AppNodeText :text="node.name" />
</span>
<template v-if="node.deprecated"> (OBSOLETE)</template>
</AppHeading>
@@ -48,6 +48,7 @@ import { computed } from "vue";
import { truncate } from "lodash";
import { getCategoryIcon, getCategoryLabel } from "@/api/categories";
import type { Node } from "@/api/model";
import AppNodeText from "@/components/AppNodeText.vue";
import { parse } from "@/util/object";

type Props = {