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 `<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+
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";
2941type Props = {
3042 text? : string ;
3143 isSvg? : boolean ;
32- truncateWidth? : number ;
3344 highlight? : boolean ;
3445};
3546const 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- ` (<${tagName }${attrsPattern }>)(.*?)(</${tagName }>) ` ,
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 < 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 ;
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
114233onMounted (() => {
@@ -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}
0 commit comments