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:
2025-12-21 23:37:14 +01:00
parent db4f143924
commit e427349749
6 changed files with 1436 additions and 820 deletions

View File

@@ -47,3 +47,8 @@ For Tailwind mode, user classes are injected via `{{USER_CLASSES}}` placeholder
### Testing
Tests use Vitest with jsdom environment. Setup in `tests/setup.js` includes DOM testing library matchers. Test files are in `tests/unit/`.
## Git Commits
- Do NOT add co-authoring lines to commit messages
- Follow conventional commit format (feat:, fix:, refactor:, docs:, etc.)

1933
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,14 @@
"vitest": "^3.1.3"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.39.4",
"@emmetio/codemirror6-plugin": "^0.4.0",
"codemirror": "^6.0.2",
"whatwg-fetch": "^3.6.20"
}
}

View File

@@ -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
View 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;
}
}
}

View File

@@ -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%;