Skip to content

Commit 73dedf7

Browse files
authored
Merge pull request #945 from monarch-initiative/truncating-app-node-text
Allow truncating SVG in AppNodeText
2 parents 042a914 + e80acf8 commit 73dedf7

File tree

4 files changed

+237
-209
lines changed

4 files changed

+237
-209
lines changed

frontend/src/components/AppNodeText.vue

Lines changed: 69 additions & 197 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,16 @@
44
Selectively renders the following tags in HTML and SVG:
55
- <sup>
66
- <i>
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-
7+
- <b>
8+
- <a> with an `href` attribute starting with `http`, surrounded in double quotes
229
-->
2310

2411
<template>
25-
<tspan v-if="isSvg" ref="container">
26-
{{ text }}
27-
</tspan>
28-
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>
2917
<span
3018
v-else
3119
ref="container"
@@ -41,193 +29,86 @@ import { onMounted, onUpdated, ref } from "vue";
4129
type Props = {
4230
text?: string;
4331
isSvg?: boolean;
32+
truncateWidth?: number;
4433
highlight?: boolean;
4534
};
4635
const props = withDefaults(defineProps<Props>(), {
4736
text: "",
4837
isSvg: false,
38+
truncateWidth: undefined,
4939
highlight: false,
5040
});
5141
52-
const container = ref<HTMLSpanElement | SVGTSpanElement | null>(null);
42+
const container = ref<HTMLSpanElement | null>(null);
5343
5444
// Use $attrs to capture external classes and styles
5545
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-
};
71-
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");
46+
function makeEscapedTagPattern(tagName: string, attrsPattern: string = "") {
47+
return new RegExp(
48+
`(&lt;${tagName}${attrsPattern}&gt;)(.*?)(&lt;/${tagName}&gt;)`,
49+
"g",
50+
);
51+
}
10252
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");
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;
13075
},
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-
}
76+
);
18577
});
18678
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-
]);
79+
containerEl.innerHTML = html;
19280
193-
// Sort that list by the position of the tag token (with the last token
194-
// first and the first token last).
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.
19587
//
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;
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);
207101
});
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-
});
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+
}
231112
}
232113
233114
onMounted(() => {
@@ -242,15 +123,6 @@ onUpdated(() => {
242123
</script>
243124

244125
<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-
}
254126
.highlight-text em {
255127
background-color: yellow;
256128
}

frontend/src/components/ThePhenogrid.vue

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@
7979
cellSize * 0.25
8080
}) rotate(-45)`"
8181
>
82-
{{ truncateLabels ? truncate(col.label) : col.label }}
82+
<AppNodeText
83+
is-svg
84+
:text="col.label"
85+
:truncate-width="marginLeft * 0.8"
86+
/>
8387
</text>
8488
<template #content>
8589
<AppNodeBadge :node="col" :absolute="true" :show-id="true" />
@@ -116,7 +120,11 @@
116120
(0.5 + rowIndex) * cellSize
117121
})`"
118122
>
119-
{{ truncateLabels ? truncate(row.label) : row.label }}
123+
<AppNodeText
124+
is-svg
125+
:text="row.label"
126+
:truncate-width="marginLeft * 0.8"
127+
/>
120128
</text>
121129
<template #content>
122130
<AppNodeBadge :node="row" :absolute="true" :show-id="true" />
@@ -364,10 +372,11 @@ import { useResizeObserver, useScroll } from "@vueuse/core";
364372
import type { TermInfo, TermPairwiseSimilarity } from "@/api/model";
365373
import AppCheckbox from "@/components/AppCheckbox.vue";
366374
import AppNodeBadge from "@/components/AppNodeBadge.vue";
375+
import AppNodeText from "@/components/AppNodeText.vue";
367376
import AppSelectSingle, { type Option } from "@/components/AppSelectSingle.vue";
368377
import { appendToBody } from "@/global/tooltip";
369378
import { frame } from "@/util/debug";
370-
import { screenToSvgCoords, truncateBySize } from "@/util/dom";
379+
import { screenToSvgCoords } from "@/util/dom";
371380
import { downloadSvg } from "@/util/download";
372381
import { copyToClipboard } from "@/util/string";
373382
import type AppFlex from "./AppFlex.vue";
@@ -491,15 +500,6 @@ function copy() {
491500
);
492501
}
493502
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)