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:
@@ -47,3 +47,8 @@ For Tailwind mode, user classes are injected via `{{USER_CLASSES}}` placeholder
|
|||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
Tests use Vitest with jsdom environment. Setup in `tests/setup.js` includes DOM testing library matchers. Test files are in `tests/unit/`.
|
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
1933
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,14 @@
|
|||||||
"vitest": "^3.1.3"
|
"vitest": "^3.1.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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"
|
"whatwg-fetch": "^3.6.20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/app.js
102
src/app.js
@@ -1,4 +1,5 @@
|
|||||||
import { LessonEngine } from "./impl/LessonEngine.js";
|
import { LessonEngine } from "./impl/LessonEngine.js";
|
||||||
|
import { CodeEditor } from "./impl/CodeEditor.js";
|
||||||
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js";
|
import { renderLesson, renderModuleList, renderLevelIndicator, updateActiveLessonInSidebar } from "./helpers/renderer.js";
|
||||||
import { loadModules } from "./config/lessons.js";
|
import { loadModules } from "./config/lessons.js";
|
||||||
|
|
||||||
@@ -56,6 +57,10 @@ const elements = {
|
|||||||
// Initialize the lesson engine - now the single source of truth
|
// Initialize the lesson engine - now the single source of truth
|
||||||
const lessonEngine = new LessonEngine();
|
const lessonEngine = new LessonEngine();
|
||||||
|
|
||||||
|
// Code editor instance (initialized later)
|
||||||
|
let codeEditor = null;
|
||||||
|
let currentMode = "css";
|
||||||
|
|
||||||
// ================= SIDEBAR FUNCTIONS =================
|
// ================= SIDEBAR FUNCTIONS =================
|
||||||
|
|
||||||
function openSidebar() {
|
function openSidebar() {
|
||||||
@@ -214,27 +219,34 @@ function resetSuccessIndicators() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateEditorForMode(mode) {
|
function updateEditorForMode(mode) {
|
||||||
const codeInput = elements.codeInput;
|
|
||||||
const editorLabel = document.querySelector(".editor-label");
|
const editorLabel = document.querySelector(".editor-label");
|
||||||
|
|
||||||
const modeConfig = {
|
const modeConfig = {
|
||||||
html: {
|
html: {
|
||||||
placeholder: "Write your HTML here (e.g., <p>Hello World</p>)",
|
placeholder: "Type HTML here... Try: nav>ul>li*3 then press Tab",
|
||||||
label: "HTML Editor"
|
label: "HTML Editor",
|
||||||
|
cmMode: "html"
|
||||||
},
|
},
|
||||||
tailwind: {
|
tailwind: {
|
||||||
placeholder: "Enter Tailwind classes (e.g., bg-blue-500 text-white p-4)",
|
placeholder: "Enter Tailwind classes (e.g., bg-blue-500 text-white p-4)",
|
||||||
label: "Tailwind Classes"
|
label: "Tailwind Classes",
|
||||||
|
cmMode: "css"
|
||||||
},
|
},
|
||||||
css: {
|
css: {
|
||||||
placeholder: "Enter your CSS code here...",
|
placeholder: "Enter your CSS code here...",
|
||||||
label: "CSS Editor"
|
label: "CSS Editor",
|
||||||
|
cmMode: "css"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = modeConfig[mode] || modeConfig.css;
|
const config = modeConfig[mode] || modeConfig.css;
|
||||||
codeInput.placeholder = config.placeholder;
|
|
||||||
if (editorLabel) editorLabel.textContent = config.label;
|
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() {
|
function loadCurrentLesson() {
|
||||||
@@ -269,13 +281,15 @@ function loadCurrentLesson() {
|
|||||||
elements.taskInstruction,
|
elements.taskInstruction,
|
||||||
elements.previewArea,
|
elements.previewArea,
|
||||||
null, // editorPrefix no longer used
|
null, // editorPrefix no longer used
|
||||||
elements.codeInput,
|
null, // codeInput no longer used (using CodeMirror)
|
||||||
null, // editorSuffix no longer used
|
null, // editorSuffix no longer used
|
||||||
lesson
|
lesson
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set user code in input
|
// Set user code in CodeMirror
|
||||||
elements.codeInput.value = engineState.userCode;
|
if (codeEditor) {
|
||||||
|
codeEditor.setValue(engineState.userCode);
|
||||||
|
}
|
||||||
|
|
||||||
// Reset validation indicators
|
// Reset validation indicators
|
||||||
elements.validationIndicators.innerHTML = "";
|
elements.validationIndicators.innerHTML = "";
|
||||||
@@ -312,25 +326,19 @@ function loadCurrentLesson() {
|
|||||||
updateProgressDisplay();
|
updateProgressDisplay();
|
||||||
|
|
||||||
// Focus on the code editor
|
// Focus on the code editor
|
||||||
elements.codeInput.focus();
|
if (codeEditor) {
|
||||||
|
codeEditor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
// Render the expected/solution preview
|
// Render the expected/solution preview
|
||||||
lessonEngine.renderExpectedPreview();
|
lessonEngine.renderExpectedPreview();
|
||||||
|
|
||||||
// Setup live preview
|
|
||||||
setupLivePreview();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================= LIVE PREVIEW =================
|
// ================= LIVE PREVIEW =================
|
||||||
|
|
||||||
let previewTimer = null;
|
let previewTimer = null;
|
||||||
|
|
||||||
function setupLivePreview() {
|
function handleEditorChange(code) {
|
||||||
elements.codeInput.removeEventListener("input", handleUserInput);
|
|
||||||
elements.codeInput.addEventListener("input", handleUserInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUserInput() {
|
|
||||||
if (previewTimer) {
|
if (previewTimer) {
|
||||||
clearTimeout(previewTimer);
|
clearTimeout(previewTimer);
|
||||||
}
|
}
|
||||||
@@ -369,7 +377,7 @@ function prevLesson() {
|
|||||||
// ================= CODE EXECUTION =================
|
// ================= CODE EXECUTION =================
|
||||||
|
|
||||||
function runCode() {
|
function runCode() {
|
||||||
const userCode = elements.codeInput.value;
|
const userCode = codeEditor ? codeEditor.getValue() : "";
|
||||||
|
|
||||||
// Rotate the Run button icon
|
// Rotate the Run button icon
|
||||||
const runButtonImg = document.querySelector("#run-btn img");
|
const runButtonImg = document.querySelector("#run-btn img");
|
||||||
@@ -460,9 +468,17 @@ function showHelp() {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Click "Show Expected" to see the target result</li>
|
<li>Click "Show Expected" to see the target result</li>
|
||||||
<li>Your progress is saved automatically</li>
|
<li>Your progress is saved automatically</li>
|
||||||
<li>Use Tab for indentation</li>
|
|
||||||
<li>Ctrl+Enter runs your code</li>
|
<li>Ctrl+Enter runs your code</li>
|
||||||
</ul>
|
</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");
|
elements.modalContainer.classList.remove("hidden");
|
||||||
@@ -501,24 +517,35 @@ function closeModal() {
|
|||||||
elements.modalContainer.classList.add("hidden");
|
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 =================
|
// ================= 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() {
|
function init() {
|
||||||
loadUserSettings();
|
loadUserSettings();
|
||||||
|
|
||||||
|
// Initialize CodeMirror editor
|
||||||
|
initCodeEditor();
|
||||||
|
|
||||||
|
// Load modules after editor is ready
|
||||||
initializeModules().catch(console.error);
|
initializeModules().catch(console.error);
|
||||||
|
|
||||||
// Sidebar controls
|
// Sidebar controls
|
||||||
@@ -545,10 +572,9 @@ function init() {
|
|||||||
saveUserSettings();
|
saveUserSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editor interactions
|
// Click on editor content to focus CodeMirror
|
||||||
elements.codeInput.addEventListener("keydown", handleTabKey);
|
|
||||||
elements.editorContent?.addEventListener("click", () => {
|
elements.editorContent?.addEventListener("click", () => {
|
||||||
elements.codeInput.focus();
|
if (codeEditor) codeEditor.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// 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 {
|
.editor-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-height: 0;
|
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 {
|
.code-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user