1+ /**
2+ * Render special characters and control characters as a symbol with their hex code.
3+ * Files: special-chars.js, special-chars.css
4+ */
5+
6+ // INCOMPLETE: TODO Optimise regex - compile at start; Update CSS for character display; clean up + comment
7+
8+ codeInput . plugins . SpecialChars = class extends codeInput . Plugin {
9+ specialCharRegExp ;
10+
11+ cachedColors ; // ascii number > [background color, text color]
12+ cachedWidths ; // font > {character > character width}
13+ canvasContext ;
14+
15+ /**
16+ * Create a special characters plugin instance
17+ * @param {Boolean } colorInSpecialChars Whether or not to give special characters custom background colors based on their hex code
18+ * @param {Boolean } inheritTextColor If `colorInSpecialChars` is false, forces the color of the hex code to inherit from syntax highlighting. Otherwise, the base colour of the `pre code` element is used to give contrast to the small characters.
19+ * @param {RegExp } specialCharRegExp The regular expression which matches special characters
20+ */
21+ constructor ( colorInSpecialChars = false , inheritTextColor = false , specialCharRegExp = / (? ! \n ) (? ! \t ) [ \u{0000} - \u{001F} ] | [ \u{007F} - \u{009F} ] | [ \u{0200} - \u{FFFF} ] / ug) { // By default, covers many non-renderable ASCII characters
22+ super ( ) ;
23+
24+ this . specialCharRegExp = specialCharRegExp ;
25+ this . colorInSpecialChars = colorInSpecialChars ;
26+ this . inheritTextColor = inheritTextColor ;
27+
28+ this . cachedColors = { } ;
29+ this . cachedWidths = { } ;
30+
31+ let canvas = document . createElement ( "canvas" ) ;
32+ this . canvasContext = canvas . getContext ( "2d" ) ;
33+ }
34+
35+ /* Runs before elements are added into a `code-input`; Params: codeInput element) */
36+ beforeElementsAdded ( codeInput ) {
37+ codeInput . classList . add ( "code-input_special-char_container" ) ;
38+ }
39+
40+ /* Runs after elements are added into a `code-input` (useful for adding events to the textarea); Params: codeInput element) */
41+ afterElementsAdded ( codeInput ) {
42+ // For some reason, special chars aren't synced the first time - TODO is there a cleaner way to do this?
43+ setTimeout ( ( ) => { codeInput . update ( codeInput . value ) ; } , 100 ) ;
44+ }
45+
46+ /* Runs after code is highlighted; Params: codeInput element) */
47+ afterHighlight ( codeInput ) {
48+ let result_element = codeInput . querySelector ( "pre code" ) ;
49+
50+ // Reset data each highlight so can change if font size, etc. changes
51+ codeInput . pluginData . specialChars = { } ;
52+ codeInput . pluginData . specialChars . textarea = codeInput . getElementsByTagName ( "textarea" ) [ 0 ] ;
53+ codeInput . pluginData . specialChars . contrastColor = window . getComputedStyle ( result_element ) . color ;
54+
55+ this . recursivelyReplaceText ( codeInput , result_element ) ;
56+
57+ this . lastFont = window . getComputedStyle ( codeInput . pluginData . specialChars . textarea ) . font ;
58+ }
59+
60+ recursivelyReplaceText ( codeInput , element ) {
61+ for ( let i = 0 ; i < element . childNodes . length ; i ++ ) {
62+
63+ let nextNode = element . childNodes [ i ] ;
64+ if ( nextNode . nodeName == "#text" && nextNode . nodeValue != "" ) {
65+ // Replace in here
66+ let oldValue = nextNode . nodeValue ;
67+
68+ this . specialCharRegExp . lastIndex = 0 ;
69+ let searchResult = this . specialCharRegExp . exec ( oldValue ) ;
70+ if ( searchResult != null ) {
71+ let charIndex = searchResult . index ; // Start as returns end
72+
73+ nextNode = nextNode . splitText ( charIndex + 1 ) . previousSibling ;
74+
75+ if ( charIndex > 0 ) {
76+ nextNode = nextNode . splitText ( charIndex ) ; // Keep those before in difft. span
77+ }
78+
79+ if ( nextNode . textContent != "" ) {
80+ let replacementElement = this . specialCharReplacer ( codeInput , nextNode . textContent ) ;
81+ nextNode . parentNode . insertBefore ( replacementElement , nextNode ) ;
82+ nextNode . textContent = "" ;
83+ }
84+ }
85+ } else if ( nextNode . nodeType == 1 ) {
86+ if ( nextNode . className != "code-input_special-char" && nextNode . nodeValue != "" ) {
87+ // Element - recurse
88+ this . recursivelyReplaceText ( codeInput , nextNode ) ;
89+ }
90+ }
91+ }
92+ }
93+
94+ specialCharReplacer ( codeInput , match_char ) {
95+ let hex_code = match_char . codePointAt ( 0 ) ;
96+
97+ let colors ;
98+ if ( this . colorInSpecialChars ) colors = this . getCharacterColor ( hex_code ) ;
99+
100+ hex_code = hex_code . toString ( 16 ) ;
101+ hex_code = ( "0000" + hex_code ) . substring ( hex_code . length ) ; // So 2 chars with leading 0
102+ hex_code = hex_code . toUpperCase ( ) ;
103+
104+ let char_width = this . getCharacterWidth ( codeInput , match_char ) ;
105+
106+ // Create element with hex code
107+ let result = document . createElement ( "span" ) ;
108+ result . classList . add ( "code-input_special-char" ) ;
109+ result . style . setProperty ( "--hex-0" , "var(--code-input_special-chars_" + hex_code [ 0 ] + ")" ) ;
110+ result . style . setProperty ( "--hex-1" , "var(--code-input_special-chars_" + hex_code [ 1 ] + ")" ) ;
111+ result . style . setProperty ( "--hex-2" , "var(--code-input_special-chars_" + hex_code [ 2 ] + ")" ) ;
112+ result . style . setProperty ( "--hex-3" , "var(--code-input_special-chars_" + hex_code [ 3 ] + ")" ) ;
113+
114+ // Handle zero-width chars
115+ if ( char_width == 0 ) result . classList . add ( "code-input_special-char_zero-width" ) ;
116+ else result . style . width = char_width + "px" ;
117+
118+ if ( this . colorInSpecialChars ) {
119+ result . style . backgroundColor = "#" + colors [ 0 ] ;
120+ result . style . setProperty ( "--code-input_special-char_color" , colors [ 1 ] ) ;
121+ } else if ( ! this . inheritTextColor ) {
122+ result . style . setProperty ( "--code-input_special-char_color" , codeInput . pluginData . specialChars . contrastColor ) ;
123+ }
124+ return result ;
125+ }
126+
127+ getCharacterColor ( ascii_code ) {
128+ // Choose colors based on character code - lazy load and return [background color, text color]
129+ let background_color ;
130+ let text_color ;
131+ if ( ! ( ascii_code in this . cachedColors ) ) {
132+ // Get background color - arbitrary bit manipulation to get a good range of colours
133+ background_color = ascii_code ^ ( ascii_code << 3 ) ^ ( ascii_code << 7 ) ^ ( ascii_code << 14 ) ^ ( ascii_code << 16 ) ; // Arbitrary
134+ background_color = background_color ^ 0x1fc627 ; // Arbitrary
135+ background_color = background_color . toString ( 16 ) ;
136+ background_color = ( "000000" + background_color ) . substring ( background_color . length ) ; // So 6 chars with leading 0
137+
138+ // Get most suitable text color - white or black depending on background brightness
139+ let color_brightness = 0 ;
140+ let luminance_coefficients = [ 0.299 , 0.587 , 0.114 ] ;
141+ for ( let i = 0 ; i < 6 ; i += 2 ) {
142+ color_brightness += parseInt ( background_color . substring ( i , i + 2 ) , 16 ) * luminance_coefficients [ i / 2 ] ;
143+ }
144+ // Calculate darkness
145+ text_color = color_brightness < 128 ? "white" : "black" ;
146+
147+ // console.log(background_color, color_brightness, text_color);
148+
149+ this . cachedColors [ ascii_code ] = [ background_color , text_color ] ;
150+ return [ background_color , text_color ] ;
151+ } else {
152+ return this . cachedColors [ ascii_code ] ;
153+ }
154+ }
155+
156+ getCharacterWidth ( codeInput , char ) { // TODO: Check StackOverflow question
157+ // Force zero-width characters
158+ if ( new RegExp ( "\u00AD|\u02de|[\u0300-\u036F]|[\u0483-\u0489]|\u200b" ) . test ( char ) ) { return 0 }
159+ // Non-renderable ASCII characters should all be rendered at same size
160+ if ( char != "\u0096" && new RegExp ( "[\u{0000}-\u{001F}]|[\u{007F}-\u{009F}]" , "g" ) . test ( char ) ) {
161+ let fallbackWidth = this . getCharacterWidth ( "\u0096" ) ;
162+ return fallbackWidth ;
163+ }
164+
165+ let font = window . getComputedStyle ( codeInput . pluginData . specialChars . textarea ) . font ;
166+
167+ // Lazy-load - TODO: Get a cleaner way of doing this
168+ if ( this . cachedWidths [ font ] == undefined ) {
169+ this . cachedWidths [ font ] = { } ; // Create new cached widths for this font
170+ }
171+ if ( this . cachedWidths [ font ] [ char ] != undefined ) { // Use cached width
172+ return this . cachedWidths [ font ] [ char ] ;
173+ }
174+
175+ // Ensure font the same
176+ // console.log(font);
177+ this . canvasContext . font = font ;
178+
179+ // Try to get width from canvas
180+ let width = this . canvasContext . measureText ( char ) . width ;
181+ if ( width > Number ( font . split ( "px" ) [ 0 ] ) ) {
182+ width /= 2 ; // Fix double-width-in-canvas Firefox bug
183+ } else if ( width == 0 && char != "\u0096" ) {
184+ let fallbackWidth = this . getCharacterWidth ( "\u0096" ) ;
185+ return fallbackWidth ; // In Firefox some control chars don't render, but all control chars are the same width
186+ }
187+
188+ this . cachedWidths [ font ] [ char ] = width ;
189+
190+ // console.log(this.cachedWidths);
191+ return width ;
192+ }
193+
194+ // getCharacterWidth(char) { // Doesn't work for now - from StackOverflow suggestion https://stackoverflow.com/a/76146120/21785620
195+ // let textarea = codeInput.pluginData.specialChars.textarea;
196+
197+ // // Create a temporary element to measure the width of the character
198+ // const span = document.createElement('span');
199+ // span.textContent = char;
200+
201+ // // Copy the textarea's font to the temporary element
202+ // span.style.fontSize = window.getComputedStyle(textarea).fontSize;
203+ // span.style.fontFamily = window.getComputedStyle(textarea).fontFamily;
204+ // span.style.fontWeight = window.getComputedStyle(textarea).fontWeight;
205+ // span.style.visibility = 'hidden';
206+ // span.style.position = 'absolute';
207+
208+ // // Add the temporary element to the document so we can measure its width
209+ // document.body.appendChild(span);
210+
211+ // // Get the width of the character in pixels
212+ // const width = span.offsetWidth;
213+
214+ // // Remove the temporary element from the document
215+ // document.body.removeChild(span);
216+
217+ // return width;
218+ // }
219+ }
0 commit comments