Skip to content

Commit 09f29f3

Browse files
authored
Merge pull request #91 from githubnext/copilot/restore-syntax-highlighting
Restore syntax highlighting in playground editors
2 parents 9a11f1f + aecb439 commit 09f29f3

2 files changed

Lines changed: 162 additions & 20 deletions

File tree

docs/playground.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,6 @@ The CI pipeline (`pages.yml`) runs this automatically during deployment.
120120

121121
## Non-Goals (Current Scope)
122122

123-
- **Syntax highlighting** in the editor: the current implementation uses a
124-
plain `<textarea>`. A future enhancement could integrate CodeMirror or
125-
Monaco for richer editing.
126123
- **Infinite loop protection**: long-running or infinite loops will hang the
127124
browser tab. A Web Worker–based sandbox could be added later.
128125
- **Type checking**: the playground transpiles but does not type-check.

playground/playground-runtime.js

Lines changed: 162 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,104 @@ function setEditorCode(editor, code) {
202202
}
203203
}
204204

205+
// ── Syntax highlighting ────────────────────────────────────────────
206+
207+
function escapeHtml(text) {
208+
return text
209+
.replace(/&/g, "&amp;")
210+
.replace(/</g, "&lt;")
211+
.replace(/>/g, "&gt;");
212+
}
213+
214+
var HL_KEYWORDS =
215+
"import|from|export|default|const|let|var|function|return|if|else|for|" +
216+
"while|do|switch|case|break|continue|throw|try|catch|finally|new|class|" +
217+
"extends|implements|interface|type|enum|as|typeof|instanceof|in|of|void|" +
218+
"async|await|yield|static|get|set|super|delete|namespace";
219+
var HL_LITERALS = "true|false|null|undefined|this|NaN|Infinity";
220+
var HL_LITERALS_SET = {};
221+
HL_LITERALS.split("|").forEach(function (w) {
222+
HL_LITERALS_SET[w] = true;
223+
});
224+
225+
var HL_REGEX = new RegExp(
226+
"(" +
227+
"\\/\\/[^\\n]*" +
228+
"|\\/\\*[\\s\\S]*?\\*\\/" +
229+
'|"(?:[^"\\\\]|\\\\.)*"' +
230+
"|'(?:[^'\\\\]|\\\\.)*'" +
231+
"|`(?:[^`\\\\]|\\\\.)*`" +
232+
"|\\b(?:" +
233+
HL_KEYWORDS +
234+
")\\b" +
235+
"|\\b(?:" +
236+
HL_LITERALS +
237+
")\\b" +
238+
"|\\b\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?\\b" +
239+
")",
240+
"g",
241+
);
242+
243+
function highlightCode(code) {
244+
var html = "";
245+
var lastIndex = 0;
246+
HL_REGEX.lastIndex = 0;
247+
var match;
248+
while ((match = HL_REGEX.exec(code)) !== null) {
249+
if (match.index > lastIndex) {
250+
html += escapeHtml(code.slice(lastIndex, match.index));
251+
}
252+
var token = match[0];
253+
var cls;
254+
var ch = token.charAt(0);
255+
if (ch === "/" && (token.charAt(1) === "/" || token.charAt(1) === "*")) {
256+
cls = "hl-comment";
257+
} else if (ch === '"' || ch === "'" || ch === "`") {
258+
cls = "hl-string";
259+
} else if (/^\d/.test(token)) {
260+
cls = "hl-number";
261+
} else if (HL_LITERALS_SET[token]) {
262+
cls = "hl-literal";
263+
} else {
264+
cls = "hl-keyword";
265+
}
266+
html += '<span class="' + cls + '">' + escapeHtml(token) + "</span>";
267+
lastIndex = HL_REGEX.lastIndex;
268+
}
269+
if (lastIndex < code.length) {
270+
html += escapeHtml(code.slice(lastIndex));
271+
}
272+
return html;
273+
}
274+
275+
function injectHighlightStyles() {
276+
var style = document.createElement("style");
277+
style.textContent =
278+
".editor-wrapper{position:relative}" +
279+
".editor-highlight{" +
280+
"position:absolute;top:0;left:0;right:0;bottom:0;" +
281+
"margin:0;pointer-events:none;z-index:1;" +
282+
"background:transparent;" +
283+
"border:1px solid transparent;border-top:none;border-bottom:none;" +
284+
"border-radius:0;" +
285+
"padding:1rem;" +
286+
"font-family:var(--font-mono,'Cascadia Code','Fira Code','JetBrains Mono',monospace);" +
287+
"font-size:0.875rem;line-height:1.55;" +
288+
"white-space:pre;tab-size:2;overflow:hidden;" +
289+
"color:#e6edf3;" +
290+
"}" +
291+
".playground-editor.editor-transparent{" +
292+
"color:transparent!important;" +
293+
"caret-color:#e6edf3;" +
294+
"}" +
295+
".hl-keyword{color:#ff7b72}" +
296+
".hl-string{color:#a5d6ff}" +
297+
".hl-comment{color:#8b949e;font-style:italic}" +
298+
".hl-number{color:#79c0ff}" +
299+
".hl-literal{color:#79c0ff}";
300+
document.head.appendChild(style);
301+
}
302+
205303
// ── Playground block setup ─────────────────────────────────────────
206304

207305
function setupBlock(block, ts) {
@@ -214,6 +312,20 @@ function setupBlock(block, ts) {
214312

215313
var originalCode = getEditorCode(editor);
216314

315+
// Normalize: convert contenteditable <pre> to <textarea>
316+
if (!isTextarea(editor)) {
317+
var ta = document.createElement("textarea");
318+
ta.className = editor.className;
319+
ta.value = originalCode;
320+
ta.spellcheck = false;
321+
ta.setAttribute("autocomplete", "off");
322+
ta.setAttribute("autocorrect", "off");
323+
ta.setAttribute("autocapitalize", "off");
324+
editor.parentNode.replaceChild(ta, editor);
325+
editor = ta;
326+
}
327+
editor.spellcheck = false;
328+
217329
// ── Python tab setup ───────────────────────────────
218330
if (pythonSource) {
219331
var pythonCode = getEditorCode(pythonSource);
@@ -250,11 +362,16 @@ function setupBlock(block, ts) {
250362
// Insert Python view after editor
251363
editor.parentNode.insertBefore(pythonView, editor.nextSibling);
252364

253-
// Tab switching
365+
// Tab switching (use wrapper to also hide/show the highlight overlay)
254366
tsTab.addEventListener("click", function () {
255367
tsTab.classList.add("active");
256368
pyTab.classList.remove("active");
257-
editor.style.display = "";
369+
var editorWrapper = editor.closest(".editor-wrapper");
370+
if (editorWrapper) {
371+
editorWrapper.style.display = "";
372+
} else {
373+
editor.style.display = "";
374+
}
258375
pythonView.classList.remove("active");
259376
runBtn.style.display = "";
260377
if (resetBtn) resetBtn.style.display = "";
@@ -263,18 +380,49 @@ function setupBlock(block, ts) {
263380
pyTab.addEventListener("click", function () {
264381
pyTab.classList.add("active");
265382
tsTab.classList.remove("active");
266-
editor.style.display = "none";
383+
var editorWrapper = editor.closest(".editor-wrapper");
384+
if (editorWrapper) {
385+
editorWrapper.style.display = "none";
386+
} else {
387+
editor.style.display = "none";
388+
}
267389
pythonView.classList.add("active");
268390
runBtn.style.display = "none";
269391
if (resetBtn) resetBtn.style.display = "none";
270392
});
271393
}
272394

395+
// Wrap editor in a container and add a highlight overlay
396+
var wrapper = document.createElement("div");
397+
wrapper.className = "editor-wrapper";
398+
editor.parentNode.insertBefore(wrapper, editor);
399+
wrapper.appendChild(editor);
400+
401+
var highlightPre = document.createElement("pre");
402+
highlightPre.className = "editor-highlight";
403+
highlightPre.setAttribute("aria-hidden", "true");
404+
wrapper.appendChild(highlightPre);
405+
406+
editor.classList.add("editor-transparent");
407+
408+
function syncHighlight() {
409+
highlightPre.innerHTML = highlightCode(editor.value) + "\n";
410+
}
411+
412+
function syncScroll() {
413+
highlightPre.scrollTop = editor.scrollTop;
414+
highlightPre.scrollLeft = editor.scrollLeft;
415+
}
416+
417+
editor.addEventListener("input", syncHighlight);
418+
editor.addEventListener("scroll", syncScroll);
419+
syncHighlight();
420+
273421
// ── Auto-resize textarea to fit content ────────────
274422
function autoResize() {
275-
if (!isTextarea(editor)) return;
276423
editor.style.height = "auto";
277424
editor.style.height = editor.scrollHeight + 2 + "px";
425+
syncScroll();
278426
}
279427
editor.addEventListener("input", autoResize);
280428
autoResize();
@@ -283,17 +431,12 @@ function setupBlock(block, ts) {
283431
editor.addEventListener("keydown", function (e) {
284432
if (e.key === "Tab") {
285433
e.preventDefault();
286-
if (isTextarea(editor)) {
287-
var start = editor.selectionStart;
288-
var end = editor.selectionEnd;
289-
editor.value =
290-
editor.value.substring(0, start) +
291-
" " +
292-
editor.value.substring(end);
293-
editor.selectionStart = editor.selectionEnd = start + 2;
294-
} else {
295-
document.execCommand("insertText", false, " ");
296-
}
434+
var start = editor.selectionStart;
435+
var end = editor.selectionEnd;
436+
editor.value =
437+
editor.value.substring(0, start) + " " + editor.value.substring(end);
438+
editor.selectionStart = editor.selectionEnd = start + 2;
439+
syncHighlight();
297440
}
298441
// Ctrl+Enter or Cmd+Enter runs the code
299442
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
@@ -316,7 +459,7 @@ function setupBlock(block, ts) {
316459
output.classList.add("active");
317460
timingEl.innerHTML = "";
318461
try {
319-
var code = getEditorCode(editor);
462+
var code = editor.value;
320463
var startTime = performance.now();
321464
var js = transformCode(code, ts);
322465
var result = executeCode(js);
@@ -336,9 +479,10 @@ function setupBlock(block, ts) {
336479
// Reset button handler
337480
if (resetBtn) {
338481
resetBtn.addEventListener("click", function () {
339-
setEditorCode(editor, originalCode);
482+
editor.value = originalCode;
340483
output.textContent = "";
341484
output.classList.remove("error", "active");
485+
syncHighlight();
342486
timingEl.innerHTML = "";
343487
autoResize();
344488
});
@@ -364,6 +508,7 @@ async function initPlayground() {
364508
var ts = await loadTypeScript();
365509

366510
setStatus("Initializing editors\u2026");
511+
injectHighlightStyles();
367512

368513
// Initialize all playground blocks on the page
369514
var blocks = document.querySelectorAll(".playground-block");

0 commit comments

Comments
 (0)