Skip to content

Commit 17d695c

Browse files
authored
Merge pull request #37 from WebCoder49/display-special-chars
Display special chars
2 parents e6d1201 + 4ea2094 commit 17d695c

File tree

4 files changed

+330
-2
lines changed

4 files changed

+330
-2
lines changed

code-input.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ var codeInput = {
7272
// been run). Thank you to peterprvy for this.
7373
if(this.ignoreValueUpdate) return;
7474

75-
//console.log("Update", text);
7675
this.ignoreValueUpdate = true;
7776
this.value = text; // Change value attribute if necessary.
7877
this.ignoreValueUpdate = false;
@@ -86,6 +85,7 @@ var codeInput = {
8685
if (text[text.length - 1] == "\n") {
8786
text += " ";
8887
}
88+
8989
// Update code
9090
result_element.innerHTML = this.escape_html(text);
9191
this.plugin_evt("beforeHighlight");
@@ -216,7 +216,7 @@ var codeInput = {
216216
if(this.template.preElementStyled) this.classList.add("code-input_pre-element-styled");
217217
else this.classList.remove("code-input_pre-element-styled");
218218
// Syntax Highlight
219-
this.update(this.value, true);
219+
this.update(this.value);
220220

221221
break;
222222

@@ -321,6 +321,8 @@ var codeInput = {
321321
set placeholder(val) {
322322
return this.setAttribute("placeholder", val);
323323
}
324+
325+
pluginData = {} // For plugins to store element-specific data under their name, e.g. <code-input>.pluginData.specialChars
324326
},
325327

326328
registerTemplate: function(template_name, template) {

plugins/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ Allows code-input elements to be used with the Prism.js line-numbers plugin, as
3535
Files: [prism-line-numbers.css](./prism-line-numbers.css) (NO JS FILE)
3636

3737
[🚀 *CodePen Demo*](https://codepen.io/WebCoder49/pen/XWPVrWv)
38+
39+
### Special Chars
40+
Render special characters and control characters as a symbol
41+
with their hex code.
42+
43+
Files: [special-chars.js](./special-chars.js) / [special-chars.css](./special-chars.css)
44+
45+
[🚀 *CodePen Demo*](https://codepen.io/WebCoder49/pen/jOeYJbm)
46+
3847
## Using Plugins
3948
Plugins allow you to add extra features to a template, like [automatic indentation](./indent.js) or [support for highlight.js's language autodetection](./autodetect.js). To use them, just:
4049
- Import the plugins' JS/CSS files (there may only be one of these; import all of the files that exist) after you have imported `code-input` and before registering the template.

plugins/special-chars.css

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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+
/* Main styling */
7+
8+
:root, body { /* Font for hex chars */
9+
--code-input_special-chars_0: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAABtJREFUGFdjZGBgYPj///9/RhCAMcA0bg6yHgAPmh/6BoxTcQAAAABJRU5ErkJgggAA');
10+
--code-input_special-chars_1: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAABZJREFUGFdjZGBgYPj///9/RhAggwMAitIUBr9U6sYAAAAASUVORK5CYII=');
11+
--code-input_special-chars_2: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAAB9JREFUGFdj/P///38GKGCEMUCCjCgyYBFGRrAKFBkAuLYT9kYcIu0AAAAASUVORK5CYII=');
12+
--code-input_special-chars_3: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAABhJREFUGFdj/P///38GKGCEMUCCjMTJAACYiBPyG8sfAgAAAABJRU5ErkJggg==');
13+
--code-input_special-chars_4: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAAB5JREFUGFdj/P///39GRkZGMI3BYYACRhgDrAKZAwAYxhvyz0DRIQAAAABJRU5ErkJggg==');
14+
--code-input_special-chars_5: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAACJJREFUGFdj/P///38GKGAEcRgZGRlBfDAHLgNjgFUgywAAuR4T9hxJl2YAAAAASUVORK5CYII=');
15+
--code-input_special-chars_6: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAACBJREFUGFdj/P///38GKGAEcRgZGRlBfDAHQwasAlkGABcdF/Y4yco2AAAAAElFTkSuQmCC');
16+
--code-input_special-chars_7: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAABZJREFUGFdj/P///38GKGCEMUCCRHIAWMgT8kue3bQAAAAASUVORK5CYII=');
17+
--code-input_special-chars_8: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAABlJREFUGFdj/P///38GKGAEcRgZGSE0cTIAvHcb8v+mIfAAAAAASUVORK5CYII=');
18+
--code-input_special-chars_9: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAAB9JREFUGFdj/P///38GKGAEcRgZGSE0igxMCVgGmQMAPqcX8hWL1K0AAAAASUVORK5CYII=');
19+
--code-input_special-chars_A: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAACBJREFUGFdjZGBgYPj///9/RhCAMcA0iADJggCmDEw5ALdxH/aGuYHqAAAAAElFTkSuQmCC');
20+
--code-input_special-chars_B: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAABlJREFUGFdj/P///38GBgYGRhAAceA0cTIAvc0b/vRDnVoAAAAASUVORK5CYII=');
21+
--code-input_special-chars_C: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAAB5JREFUGFdjZGBgYPj///9/EM0IYjAyMjIS4CDrAQC57hP+uLwvFQAAAABJRU5ErkJggg==');
22+
--code-input_special-chars_D: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAABtJREFUGFdj/P///38GBgYGRhAAceA0fg5MDwAveh/6ToN9VwAAAABJRU5ErkJggg==');
23+
--code-input_special-chars_E: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAABxJREFUGFdj/P///38GKGAEcRgZGRlBfDCHsAwA2UwT+mVIH1MAAAAASUVORK5CYII=');
24+
--code-input_special-chars_F: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAAFCAYAAACAcVaiAAAAAXNSR0IArs4c6QAAAB5JREFUGFdj/P///38GKGAEcRgZGRlBfDAHtwxMGQDZZhP+BnB1kwAAAABJRU5ErkJggg==');
25+
}
26+
27+
.code-input_special-char_container { /* pre element */
28+
font-size: 20px;
29+
}
30+
31+
.code-input_special-char {
32+
display: inline-block;
33+
position: relative;
34+
top: 0;
35+
left: 0;
36+
height: 1em;
37+
/* width: set by JS */
38+
overflow: hidden;
39+
text-decoration: none;
40+
text-shadow: none;
41+
vertical-align: middle;
42+
outline: 0.1px solid currentColor;
43+
44+
--hex-0: var(
45+
--code-input_special-chars_0);
46+
--hex-1: var(
47+
--code-input_special-chars_0);
48+
--hex-2: var(
49+
--code-input_special-chars_0);
50+
--hex-3: var(
51+
--code-input_special-chars_0);
52+
}
53+
54+
/* Default - Two bytes - 4 hex chars */
55+
56+
.code-input_special-char::before {
57+
margin-left: 50%;
58+
transform: translate(-50%, 0);
59+
content: " ";
60+
61+
background-color: var(--code-input_special-char_color, currentColor);
62+
image-rendering: pixelated;
63+
display: inline-block;
64+
width: calc(100%-2px);
65+
height: 100%;
66+
67+
mask-image: var(--hex-0), var(--hex-1), var(--hex-2), var(--hex-3);
68+
mask-repeat: no-repeat, no-repeat, no-repeat, no-repeat;
69+
mask-size: 40%, 40%, 40%, 40%;
70+
mask-position: 10% 10%, 90% 10%, 10% 90%, 90% 90%;
71+
72+
-webkit-mask-image: var(--hex-0), var(--hex-1), var(--hex-2), var(--hex-3);
73+
-webkit-mask-repeat: no-repeat, no-repeat, no-repeat, no-repeat;
74+
-webkit-mask-size: min(40%, 0.25em), min(40%, 0.25em), min(40%, 0.25em), min(40%, 0.25em);
75+
-webkit-mask-position: 10% 10%, min(90%, 0.5em) 10%, 10% 90%, min(90%, 0.5em) 90%;
76+
}
77+
78+
.code-input_special-char_zero-width {
79+
z-index: 1;
80+
width: 1em;
81+
margin-left: -0.5em;
82+
margin-right: -0.5em;
83+
position: relative;
84+
85+
opacity: 0.75;
86+
}
87+
88+
/* One byte - 2 hex chars */
89+
.code-input_special-char_one-byte::before {
90+
height: 1.5em;
91+
top: -1em;
92+
content: attr(data-hex2);
93+
}
94+
.code-input_special-char_one-byte::after {
95+
height: 1.5em;
96+
bottom: -1em;
97+
content: attr(data-hex3);
98+
}

plugins/special-chars.js

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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

Comments
 (0)