feat: add CodeMirror 6 editor with Emmet support
- Replace textarea with CodeMirror 6 for syntax highlighting - Add Emmet abbreviation expansion (Tab to expand) - Support HTML and CSS language modes with autocomplete - Add dark theme matching app design - Tab indentation with Shift-Tab for outdent - Update help modal with Emmet shortcuts
This commit is contained in:
102
src/app.js
102
src/app.js
@@ -1,4 +1,5 @@
|
||||
import { LessonEngine } from "./impl/LessonEngine.js";
|
||||
import { CodeEditor } from "./impl/CodeEditor.js";
|
||||
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js";
|
||||
import { loadModules } from "./config/lessons.js";
|
||||
|
||||
@@ -56,6 +57,10 @@ const elements = {
|
||||
// Initialize the lesson engine - now the single source of truth
|
||||
const lessonEngine = new LessonEngine();
|
||||
|
||||
// Code editor instance (initialized later)
|
||||
let codeEditor = null;
|
||||
let currentMode = "css";
|
||||
|
||||
// ================= SIDEBAR FUNCTIONS =================
|
||||
|
||||
function openSidebar() {
|
||||
@@ -214,27 +219,34 @@ function resetSuccessIndicators() {
|
||||
}
|
||||
|
||||
function updateEditorForMode(mode) {
|
||||
const codeInput = elements.codeInput;
|
||||
const editorLabel = document.querySelector(".editor-label");
|
||||
|
||||
const modeConfig = {
|
||||
html: {
|
||||
placeholder: "Write your HTML here (e.g., <p>Hello World</p>)",
|
||||
label: "HTML Editor"
|
||||
placeholder: "Type HTML here... Try: nav>ul>li*3 then press Tab",
|
||||
label: "HTML Editor",
|
||||
cmMode: "html"
|
||||
},
|
||||
tailwind: {
|
||||
placeholder: "Enter Tailwind classes (e.g., bg-blue-500 text-white p-4)",
|
||||
label: "Tailwind Classes"
|
||||
label: "Tailwind Classes",
|
||||
cmMode: "css"
|
||||
},
|
||||
css: {
|
||||
placeholder: "Enter your CSS code here...",
|
||||
label: "CSS Editor"
|
||||
label: "CSS Editor",
|
||||
cmMode: "css"
|
||||
}
|
||||
};
|
||||
|
||||
const config = modeConfig[mode] || modeConfig.css;
|
||||
codeInput.placeholder = config.placeholder;
|
||||
if (editorLabel) editorLabel.textContent = config.label;
|
||||
|
||||
// Update CodeMirror mode if needed
|
||||
if (codeEditor && currentMode !== config.cmMode) {
|
||||
currentMode = config.cmMode;
|
||||
codeEditor.setMode(config.cmMode);
|
||||
}
|
||||
}
|
||||
|
||||
function loadCurrentLesson() {
|
||||
@@ -269,13 +281,15 @@ function loadCurrentLesson() {
|
||||
elements.taskInstruction,
|
||||
elements.previewArea,
|
||||
null, // editorPrefix no longer used
|
||||
elements.codeInput,
|
||||
null, // codeInput no longer used (using CodeMirror)
|
||||
null, // editorSuffix no longer used
|
||||
lesson
|
||||
);
|
||||
|
||||
// Set user code in input
|
||||
elements.codeInput.value = engineState.userCode;
|
||||
// Set user code in CodeMirror
|
||||
if (codeEditor) {
|
||||
codeEditor.setValue(engineState.userCode);
|
||||
}
|
||||
|
||||
// Reset validation indicators
|
||||
elements.validationIndicators.innerHTML = "";
|
||||
@@ -312,25 +326,19 @@ function loadCurrentLesson() {
|
||||
updateProgressDisplay();
|
||||
|
||||
// Focus on the code editor
|
||||
elements.codeInput.focus();
|
||||
if (codeEditor) {
|
||||
codeEditor.focus();
|
||||
}
|
||||
|
||||
// Render the expected/solution preview
|
||||
lessonEngine.renderExpectedPreview();
|
||||
|
||||
// Setup live preview
|
||||
setupLivePreview();
|
||||
}
|
||||
|
||||
// ================= LIVE PREVIEW =================
|
||||
|
||||
let previewTimer = null;
|
||||
|
||||
function setupLivePreview() {
|
||||
elements.codeInput.removeEventListener("input", handleUserInput);
|
||||
elements.codeInput.addEventListener("input", handleUserInput);
|
||||
}
|
||||
|
||||
function handleUserInput() {
|
||||
function handleEditorChange(code) {
|
||||
if (previewTimer) {
|
||||
clearTimeout(previewTimer);
|
||||
}
|
||||
@@ -369,7 +377,7 @@ function prevLesson() {
|
||||
// ================= CODE EXECUTION =================
|
||||
|
||||
function runCode() {
|
||||
const userCode = elements.codeInput.value;
|
||||
const userCode = codeEditor ? codeEditor.getValue() : "";
|
||||
|
||||
// Rotate the Run button icon
|
||||
const runButtonImg = document.querySelector("#run-btn img");
|
||||
@@ -460,9 +468,17 @@ function showHelp() {
|
||||
<ul>
|
||||
<li>Click "Show Expected" to see the target result</li>
|
||||
<li>Your progress is saved automatically</li>
|
||||
<li>Use Tab for indentation</li>
|
||||
<li>Ctrl+Enter runs your code</li>
|
||||
</ul>
|
||||
|
||||
<h4>Emmet Shortcuts (HTML mode)</h4>
|
||||
<p>Type abbreviations and press Tab to expand:</p>
|
||||
<ul>
|
||||
<li><kbd>div.container</kbd> → div with class</li>
|
||||
<li><kbd>ul>li*5</kbd> → ul with 5 li children</li>
|
||||
<li><kbd>nav>ul>li*3>a</kbd> → nested structure</li>
|
||||
<li><kbd>p{Hello}</kbd> → p with text content</li>
|
||||
</ul>
|
||||
`;
|
||||
|
||||
elements.modalContainer.classList.remove("hidden");
|
||||
@@ -501,24 +517,35 @@ function closeModal() {
|
||||
elements.modalContainer.classList.add("hidden");
|
||||
}
|
||||
|
||||
// ================= KEYBOARD HANDLERS =================
|
||||
|
||||
function handleTabKey(e) {
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
|
||||
const start = e.target.selectionStart;
|
||||
const end = e.target.selectionEnd;
|
||||
|
||||
e.target.value = e.target.value.substring(0, start) + " " + e.target.value.substring(end);
|
||||
e.target.selectionStart = e.target.selectionEnd = start + 2;
|
||||
}
|
||||
}
|
||||
|
||||
// ================= INITIALIZATION =================
|
||||
|
||||
function initCodeEditor() {
|
||||
const container = elements.editorContent;
|
||||
if (!container) return;
|
||||
|
||||
// Remove the textarea - CodeMirror will replace it
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (textarea) {
|
||||
textarea.remove();
|
||||
}
|
||||
|
||||
// Initialize CodeMirror
|
||||
codeEditor = new CodeEditor(container, {
|
||||
mode: currentMode,
|
||||
placeholder: "Type your code here...",
|
||||
onChange: handleEditorChange
|
||||
});
|
||||
|
||||
codeEditor.init("");
|
||||
}
|
||||
|
||||
function init() {
|
||||
loadUserSettings();
|
||||
|
||||
// Initialize CodeMirror editor
|
||||
initCodeEditor();
|
||||
|
||||
// Load modules after editor is ready
|
||||
initializeModules().catch(console.error);
|
||||
|
||||
// Sidebar controls
|
||||
@@ -545,10 +572,9 @@ function init() {
|
||||
saveUserSettings();
|
||||
});
|
||||
|
||||
// Editor interactions
|
||||
elements.codeInput.addEventListener("keydown", handleTabKey);
|
||||
// Click on editor content to focus CodeMirror
|
||||
elements.editorContent?.addEventListener("click", () => {
|
||||
elements.codeInput.focus();
|
||||
if (codeEditor) codeEditor.focus();
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
|
||||
194
src/impl/CodeEditor.js
Normal file
194
src/impl/CodeEditor.js
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* CodeEditor - CodeMirror 6 wrapper with Emmet support
|
||||
*/
|
||||
import { EditorState, Prec } from "@codemirror/state";
|
||||
import { EditorView, keymap, placeholder } from "@codemirror/view";
|
||||
import { defaultKeymap, indentWithTab, indentMore, indentLess } from "@codemirror/commands";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-plugin";
|
||||
|
||||
// Dark theme matching our editor
|
||||
const editorTheme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
backgroundColor: "#1e1e1e"
|
||||
},
|
||||
".cm-content": {
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
padding: "12px 0",
|
||||
caretColor: "#fff",
|
||||
color: "#d4d4d4"
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0 12px"
|
||||
},
|
||||
"&.cm-focused .cm-cursor": {
|
||||
borderLeftColor: "#fff"
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
|
||||
backgroundColor: "#3a3d41"
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "#2a2d2e"
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "#1e1e1e",
|
||||
color: "#858585",
|
||||
border: "none",
|
||||
paddingRight: "8px"
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "#2a2d2e"
|
||||
},
|
||||
// Syntax highlighting
|
||||
".cm-keyword": { color: "#569cd6" },
|
||||
".cm-tagName": { color: "#569cd6" },
|
||||
".cm-attributeName": { color: "#9cdcfe" },
|
||||
".cm-attributeValue": { color: "#ce9178" },
|
||||
".cm-string": { color: "#ce9178" },
|
||||
".cm-comment": { color: "#6a9955" },
|
||||
".cm-propertyName": { color: "#9cdcfe" },
|
||||
".cm-number": { color: "#b5cea8" },
|
||||
".cm-unit": { color: "#b5cea8" },
|
||||
".cm-variableName": { color: "#9cdcfe" },
|
||||
// Autocomplete
|
||||
".cm-tooltip": {
|
||||
backgroundColor: "#252526",
|
||||
border: "1px solid #454545"
|
||||
},
|
||||
".cm-tooltip-autocomplete": {
|
||||
"& > ul > li": {
|
||||
padding: "4px 8px"
|
||||
},
|
||||
"& > ul > li[aria-selected]": {
|
||||
backgroundColor: "#094771",
|
||||
color: "#fff"
|
||||
}
|
||||
}
|
||||
}, { dark: true });
|
||||
|
||||
export class CodeEditor {
|
||||
constructor(container, options = {}) {
|
||||
this.container = container;
|
||||
this.options = options;
|
||||
this.view = null;
|
||||
this.mode = options.mode || "css";
|
||||
this.onChange = options.onChange || (() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the editor
|
||||
*/
|
||||
init(initialValue = "") {
|
||||
// Clear container
|
||||
this.container.innerHTML = "";
|
||||
|
||||
// Get language extension based on mode
|
||||
const langExtension = this.mode === "html" ? html() : css();
|
||||
|
||||
// Build extensions array
|
||||
const extensions = [
|
||||
langExtension,
|
||||
editorTheme,
|
||||
// Emmet abbreviation tracking
|
||||
abbreviationTracker(),
|
||||
// High priority keymap for Emmet
|
||||
Prec.highest(keymap.of([
|
||||
{
|
||||
key: "Tab",
|
||||
run: expandAbbreviation
|
||||
}
|
||||
])),
|
||||
// Standard keymaps
|
||||
keymap.of([
|
||||
{ key: "Tab", run: indentMore },
|
||||
{ key: "Shift-Tab", run: indentLess },
|
||||
...defaultKeymap
|
||||
]),
|
||||
autocompletion({
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 10
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
this.onChange(this.getValue());
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping
|
||||
];
|
||||
|
||||
// Add placeholder if provided
|
||||
if (this.options.placeholder) {
|
||||
extensions.push(placeholder(this.options.placeholder));
|
||||
}
|
||||
|
||||
// Create editor state
|
||||
const state = EditorState.create({
|
||||
doc: initialValue,
|
||||
extensions
|
||||
});
|
||||
|
||||
// Create editor view
|
||||
this.view = new EditorView({
|
||||
state,
|
||||
parent: this.container
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current editor value
|
||||
*/
|
||||
getValue() {
|
||||
return this.view ? this.view.state.doc.toString() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set editor value
|
||||
*/
|
||||
setValue(value) {
|
||||
if (!this.view) return;
|
||||
|
||||
this.view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: this.view.state.doc.length,
|
||||
insert: value
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set editor mode (html or css)
|
||||
*/
|
||||
setMode(mode) {
|
||||
if (this.mode === mode) return;
|
||||
|
||||
this.mode = mode;
|
||||
const currentValue = this.getValue();
|
||||
this.init(currentValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the editor
|
||||
*/
|
||||
focus() {
|
||||
if (this.view) {
|
||||
this.view.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the editor
|
||||
*/
|
||||
destroy() {
|
||||
if (this.view) {
|
||||
this.view.destroy();
|
||||
this.view = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/main.css
14
src/main.css
@@ -264,9 +264,23 @@ code, kbd {
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--editor-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* CodeMirror container styles */
|
||||
.editor-content .cm-editor {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editor-content .cm-scroller {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Legacy textarea (fallback) */
|
||||
.code-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user