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 `<sup>` (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";
4129type Props = {
4230 text? : string ;
4331 isSvg? : boolean ;
32+ truncateWidth? : number ;
4433 highlight? : boolean ;
4534};
4635const 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+ ` (<${tagName }${attrsPattern }>)(.*?)(</${tagName }>) ` ,
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 < with < and > 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
233114onMounted (() => {
@@ -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}
0 commit comments