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