Skip to content

Commit 61c3052

Browse files
authored
Merge pull request #1259 from monarch-initiative/revert-945-truncating-app-node-text
Revert "Allow truncating SVG in AppNodeText"
2 parents e32ef46 + 17bce6f commit 61c3052

File tree

4 files changed

+209
-237
lines changed

4 files changed

+209
-237
lines changed

frontend/src/components/AppNodeText.vue

Lines changed: 197 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,28 @@
44
Selectively renders the following tags in HTML and SVG:
55
- <sup>
66
- <i>
7-
- <b>
8-
- <a> with an `href` attribute starting with `http`, surrounded in double quotes
7+
- <a> with an `href` attribute surrounded in double quotes
8+
9+
There are two alternatives to the approach taken here, but neither are
10+
sufficient.
11+
12+
1. We could use a sanitizer like [DOMPurify](https://github.com/cure53/DOMPurify)
13+
to sanitize arbitrary strings, but that would strip out legitimate text
14+
that an HTML parser might confuse for a tag. An example of such text can be
15+
found here: <https://github.com/monarch-initiative/monarch-app/issues/887#issuecomment-2479676335>
16+
17+
2. We could escape the entire string, selectively unescape `&lt;sup&gt;` (and
18+
so on), and then pass the string to `containerEl.innerHTML`. However, this
19+
would lead to markup without the desired effect in SVG, since the <sup> and
20+
<i> elements do not do anything in SVG.
21+
922
-->
1023

1124
<template>
12-
<foreignObject v-if="isSvg" x="0" y="0" width="100%" height="1.5em">
13-
<span ref="container" xmlns="http://www.w3.org/1999/xhtml">
14-
{{ text }}
15-
</span>
16-
</foreignObject>
25+
<tspan v-if="isSvg" ref="container">
26+
{{ text }}
27+
</tspan>
28+
1729
<span
1830
v-else
1931
ref="container"
@@ -29,86 +41,193 @@ import { onMounted, onUpdated, ref } from "vue";
2941
type Props = {
3042
text?: string;
3143
isSvg?: boolean;
32-
truncateWidth?: number;
3344
highlight?: boolean;
3445
};
3546
const props = withDefaults(defineProps<Props>(), {
3647
text: "",
3748
isSvg: false,
38-
truncateWidth: undefined,
3949
highlight: false,
4050
});
4151
42-
const container = ref<HTMLSpanElement | null>(null);
52+
const container = ref<HTMLSpanElement | SVGTSpanElement | null>(null);
4353
4454
// Use $attrs to capture external classes and styles
4555
46-
function makeEscapedTagPattern(tagName: string, attrsPattern: string = "") {
47-
return new RegExp(
48-
`(&lt;${tagName}${attrsPattern}&gt;)(.*?)(&lt;/${tagName}&gt;)`,
49-
"g",
50-
);
51-
}
56+
type ReplacedTag = "sup" | "a" | "i" | "b";
57+
58+
type Replacement = {
59+
type: ReplacedTag;
60+
start: [number, number];
61+
end: [number, number];
62+
startNode?: Text;
63+
endNode?: Text;
64+
};
65+
66+
type ReplacementPosition = {
67+
type: "start" | "end";
68+
replacement: Replacement;
69+
at: [number, number];
70+
};
5271
53-
const replacementPatterns = [
54-
makeEscapedTagPattern("sup"),
55-
makeEscapedTagPattern("i"),
56-
makeEscapedTagPattern("b"),
57-
makeEscapedTagPattern("a", ' href="http[^"]+"'),
58-
];
59-
60-
function buildDOM(containerEl: HTMLSpanElement) {
61-
const { truncateWidth, isSvg } = props;
62-
63-
let html = containerEl.innerHTML;
64-
65-
replacementPatterns.forEach((pattern) => {
66-
html = html.replaceAll(
67-
pattern,
68-
(match, openTag: string, content: string, closeTag: string) => {
69-
// Replace &lt; with < and &gt; with > to unescape them
70-
let unescaped = "";
71-
unescaped += "<" + openTag.slice(4, -4) + ">";
72-
unescaped += content;
73-
unescaped += "<" + closeTag.slice(4, -4) + ">";
74-
return unescaped;
72+
const replacementTags = new Map([
73+
[
74+
"sup" as ReplacedTag,
75+
{
76+
regex: /(<sup>).*?(<\/sup>)/dg,
77+
createSurroundingEl(isSvg: Boolean) {
78+
return isSvg
79+
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
80+
: document.createElement("sup");
81+
},
82+
afterMount(isSvg: Boolean, el: Element) {
83+
if (!isSvg) return;
84+
el.setAttribute("dy", "-1ex");
85+
el.classList.add("svg-superscript");
86+
87+
// The next sibling will be the text node "</sup>". Check if there is
88+
// remaining text after that. If there is, adjust the text baseline back
89+
// down to the normal level.
90+
const nextSibling = el.nextSibling!.nextSibling;
91+
if (!nextSibling) return;
92+
93+
const range = new Range();
94+
range.selectNode(nextSibling);
95+
96+
const tspan = document.createElementNS(
97+
"http://www.w3.org/2000/svg",
98+
"tspan",
99+
);
100+
101+
tspan.setAttribute("dy", "+1ex");
102+
103+
range.surroundContents(tspan);
104+
},
105+
},
106+
],
107+
[
108+
"i" as ReplacedTag,
109+
{
110+
regex: /(<i>).*?(<\/i>)/dg,
111+
createSurroundingEl(isSvg: Boolean) {
112+
return isSvg
113+
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
114+
: document.createElement("i");
115+
},
116+
afterMount(isSvg: Boolean, el: Element) {
117+
if (!isSvg) return;
118+
el.classList.add("svg-italic");
119+
},
120+
},
121+
],
122+
[
123+
"a" as ReplacedTag,
124+
{
125+
regex: /(<a href="http[^"]+">).*?(<\/a>)/dg,
126+
createSurroundingEl(isSvg: Boolean) {
127+
return isSvg
128+
? document.createElementNS("http://www.w3.org/2000/svg", "a")
129+
: document.createElement("a");
75130
},
76-
);
131+
afterMount(isSvg: Boolean, el: Element) {
132+
// The previous sibling will be the text node containing the string
133+
// <a href="http...">. Slice it to get the value of the href.
134+
const tagTextNode = el.previousSibling!;
135+
const href = tagTextNode.textContent!.slice(9, -2);
136+
el.setAttribute("href", href);
137+
},
138+
},
139+
],
140+
[
141+
"b" as ReplacedTag,
142+
{
143+
regex: /(<b>).*?(<\/b>)/dg,
144+
createSurroundingEl(isSvg: Boolean) {
145+
return isSvg
146+
? document.createElementNS("http://www.w3.org/2000/svg", "tspan")
147+
: document.createElement("b");
148+
},
149+
afterMount(isSvg: Boolean, el: Element) {
150+
if (!isSvg) return;
151+
el.classList.add("svg-bold");
152+
},
153+
},
154+
],
155+
]);
156+
157+
function buildDOM(containerEl: Element) {
158+
const text = props.text;
159+
160+
const containsOnlyText =
161+
containerEl.childNodes.length === 1 &&
162+
containerEl.firstChild?.nodeType === Node.TEXT_NODE &&
163+
text !== null;
164+
165+
// This should always be false, but just in case-- bail out of the function
166+
// if the element contains anything but a single text node.
167+
if (!containsOnlyText) return;
168+
169+
const textNode = containerEl.firstChild as Text;
170+
171+
const replacements: Replacement[] = [];
172+
173+
// Create a list of every place there's a match for a start and end tag
174+
// matched from the defined regexes.
175+
Array.from(replacementTags.entries()).forEach(([type, { regex }]) => {
176+
for (const match of text.matchAll(regex)) {
177+
const { indices } = match;
178+
179+
replacements.push({
180+
type,
181+
start: indices![1],
182+
end: indices![2],
183+
});
184+
}
77185
});
78186
79-
containerEl.innerHTML = html;
187+
// Now create a new list that has the position of each start and end token
188+
const positions: ReplacementPosition[] = replacements.flatMap((x) => [
189+
{ type: "start", replacement: x, at: x.start },
190+
{ type: "end", replacement: x, at: x.end },
191+
]);
80192
81-
// WebKit browsers have strange behavior rendering <sup> tags inside
82-
// <foreignObject> tags. It seems to be because they use relative
83-
// positioning to style superscripts, and relative positioning inside
84-
// <foreignObject> tags doesn't work very well (in WebKit). As a result, the
85-
// superscripted text shows up at the very top of the SVG, as if it were
86-
// relatively positioned there.
193+
// Sort that list by the position of the tag token (with the last token
194+
// first and the first token last).
87195
//
88-
// To avoid this, we can change the <sup> tags to <span>s and style them
89-
// ourselves without using relative positioning.
90-
if (isSvg) {
91-
const supTags = [...containerEl.querySelectorAll("sup")];
92-
93-
supTags.forEach((supTag) => {
94-
const spanTag = document.createElement("span");
95-
spanTag.style.verticalAlign = "super";
96-
spanTag.style.fontSize = "80%";
97-
spanTag.style.lineHeight = "1.0";
98-
99-
spanTag.replaceChildren(...supTag.childNodes);
100-
supTag.parentNode!.replaceChild(spanTag, supTag);
196+
// After that, iterate through each of the token positions and split the
197+
// text node at the token's boundaries. Store the text node of each start
198+
// and end tag in the `replacements` array to be used later.
199+
positions
200+
.sort((a, b) => {
201+
return b.at[0] - a.at[0];
202+
})
203+
.forEach((position) => {
204+
textNode.splitText(position.at[1]);
205+
const node = textNode.splitText(position.at[0]);
206+
position.replacement[`${position.type}Node`] = node;
101207
});
102-
}
103-
104-
if (truncateWidth) {
105-
containerEl.style.display = "inline-block";
106-
containerEl.style.overflow = "hidden";
107-
containerEl.style.whiteSpace = "nowrap";
108-
containerEl.style.textOverflow = "ellipsis";
109-
containerEl.style.verticalAlign = "top";
110-
containerEl.style.maxWidth = `${truncateWidth}px`;
111-
}
208+
209+
// Build the correct DOM tree for each replacement found
210+
replacements.forEach((replacement) => {
211+
const { startNode, endNode, type } = replacement;
212+
const { createSurroundingEl, afterMount } = replacementTags.get(type)!;
213+
214+
// Select the range that goes from the end of the opening tag text node to
215+
// the start of the closing tag text node.
216+
const range = new Range();
217+
range.setStartAfter(startNode!);
218+
range.setEndBefore(endNode!);
219+
220+
// Surround that range with the appropriate DOM element.
221+
const el = createSurroundingEl(props.isSvg);
222+
range.surroundContents(el);
223+
224+
// Run any code required after the container element is mounted.
225+
afterMount(props.isSvg, el);
226+
227+
// Remove the start and end tag text nodes
228+
startNode!.parentNode!.removeChild(startNode!);
229+
endNode!.parentNode!.removeChild(endNode!);
230+
});
112231
}
113232
114233
onMounted(() => {
@@ -123,6 +242,15 @@ onUpdated(() => {
123242
</script>
124243

125244
<style>
245+
.svg-superscript {
246+
font-size: 0.7rem;
247+
}
248+
.svg-italic {
249+
font-style: italic;
250+
}
251+
.svg-bold {
252+
font-weight: bold;
253+
}
126254
.highlight-text em {
127255
background-color: yellow;
128256
}

frontend/src/components/ThePhenogrid.vue

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,7 @@
7979
cellSize * 0.25
8080
}) rotate(-45)`"
8181
>
82-
<AppNodeText
83-
is-svg
84-
:text="col.label"
85-
:truncate-width="marginLeft * 0.8"
86-
/>
82+
{{ truncateLabels ? truncate(col.label) : col.label }}
8783
</text>
8884
<template #content>
8985
<AppNodeBadge :node="col" :absolute="true" :show-id="true" />
@@ -120,11 +116,7 @@
120116
(0.5 + rowIndex) * cellSize
121117
})`"
122118
>
123-
<AppNodeText
124-
is-svg
125-
:text="row.label"
126-
:truncate-width="marginLeft * 0.8"
127-
/>
119+
{{ truncateLabels ? truncate(row.label) : row.label }}
128120
</text>
129121
<template #content>
130122
<AppNodeBadge :node="row" :absolute="true" :show-id="true" />
@@ -372,11 +364,10 @@ import { useResizeObserver, useScroll } from "@vueuse/core";
372364
import type { TermInfo, TermPairwiseSimilarity } from "@/api/model";
373365
import AppCheckbox from "@/components/AppCheckbox.vue";
374366
import AppNodeBadge from "@/components/AppNodeBadge.vue";
375-
import AppNodeText from "@/components/AppNodeText.vue";
376367
import AppSelectSingle, { type Option } from "@/components/AppSelectSingle.vue";
377368
import { appendToBody } from "@/global/tooltip";
378369
import { frame } from "@/util/debug";
379-
import { screenToSvgCoords } from "@/util/dom";
370+
import { screenToSvgCoords, truncateBySize } from "@/util/dom";
380371
import { downloadSvg } from "@/util/download";
381372
import { copyToClipboard } from "@/util/string";
382373
import type AppFlex from "./AppFlex.vue";
@@ -500,6 +491,15 @@ function copy() {
500491
);
501492
}
502493
494+
/** truncate labels */
495+
function truncate(text?: string) {
496+
return truncateBySize(
497+
text || "",
498+
marginLeft * 0.9,
499+
cellSize * 0.5 + "px Poppins",
500+
);
501+
}
502+
503503
/** track grid scroll */
504504
const scrollInfo = useScroll(scroll);
505505
const scrollCoords = ref({ x: 0, y: 0, w: 0, h: 0 });

0 commit comments

Comments
 (0)