Files
code-crispies/src/impl/CodeEditor.js
Michael Czechowski c0db8bba48 feat: complete section color coding with logo, hints, editor themes, and footers
- Add section-specific CodeMirror syntax highlighting (purple selectors for CSS)
- Logo now uses section colors (CSS purple as default, changes per section)
- Add section color coding for hints
- Add full footer to section and reference pages
- Fix nav highlight updates for sidebar and prev/next navigation
- Change welcome module mode to CSS for purple theme on first lesson
- Rebrand "Code Crispies" to "CODE CRISPIES" across all translations
- Fix scroll to top on section page navigation
- Change HTML section color to raspberry (#c75b7a)
2026-01-16 04:32:55 +01:00

301 lines
7.2 KiB
JavaScript

/**
* CodeEditor - CodeMirror 6 wrapper with Emmet support
*/
import { EditorState, Prec } from "@codemirror/state";
import { EditorView, keymap, placeholder } from "@codemirror/view";
import { defaultKeymap, historyKeymap, indentMore, indentLess, undo, redo } from "@codemirror/commands";
import { history } 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";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
// Custom theme with purple accent colors (matching app completed state)
const crispyTheme = EditorView.theme(
{
"&": {
backgroundColor: "#262630",
color: "#c8c8d0"
},
".cm-content": {
caretColor: "#9b6dd4"
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "#9b6dd4"
},
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "#3e3e4a"
},
".cm-panels": {
backgroundColor: "#262630",
color: "#c8c8d0"
},
".cm-searchMatch": {
backgroundColor: "#3e3e4a",
outline: "1px solid #9b6dd4"
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "rgba(155, 109, 212, 0.3)"
},
".cm-activeLine": {
backgroundColor: "#2e2e3a"
},
".cm-selectionMatch": {
backgroundColor: "#3e3e4a"
},
".cm-gutters": {
backgroundColor: "#262630",
color: "#808090",
border: "none"
},
".cm-activeLineGutter": {
backgroundColor: "#2e2e3a"
},
".cm-lineNumbers .cm-gutterElement": {
color: "#808090"
}
},
{ dark: true }
);
// Default syntax highlighting (blue accent)
const defaultHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#c9a6eb" },
{ tag: tags.operator, color: "#cdd6f4" },
{ tag: tags.variableName, color: "#89b4fa" },
{ tag: tags.propertyName, color: "#89b4fa" },
{ tag: tags.attributeName, color: "#89b4fa" },
{ tag: tags.className, color: "#89b4fa" },
{ tag: tags.tagName, color: "#c9a6eb" },
{ tag: tags.string, color: "#a6e3a1" },
{ tag: tags.number, color: "#fab387" },
{ tag: tags.bool, color: "#fab387" },
{ tag: tags.null, color: "#fab387" },
{ tag: tags.comment, color: "#6c7086", fontStyle: "italic" },
{ tag: tags.bracket, color: "#cdd6f4" },
{ tag: tags.punctuation, color: "#cdd6f4" },
{ tag: tags.definition(tags.variableName), color: "#89b4fa" },
{ tag: tags.function(tags.variableName), color: "#89b4fa" },
{ tag: tags.atom, color: "#c9a6eb" },
{ tag: tags.unit, color: "#a6e3a1" },
{ tag: tags.color, color: "#f9e2af" }
]);
// CSS section highlighting (purple selectors)
const cssHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#c9a6eb" },
{ tag: tags.operator, color: "#cdd6f4" },
{ tag: tags.variableName, color: "#c9a6eb" },
{ tag: tags.propertyName, color: "#89b4fa" },
{ tag: tags.attributeName, color: "#89b4fa" },
{ tag: tags.className, color: "#c9a6eb" },
{ tag: tags.tagName, color: "#c9a6eb" },
{ tag: tags.string, color: "#a6e3a1" },
{ tag: tags.number, color: "#fab387" },
{ tag: tags.bool, color: "#fab387" },
{ tag: tags.null, color: "#fab387" },
{ tag: tags.comment, color: "#6c7086", fontStyle: "italic" },
{ tag: tags.bracket, color: "#cdd6f4" },
{ tag: tags.punctuation, color: "#cdd6f4" },
{ tag: tags.definition(tags.variableName), color: "#c9a6eb" },
{ tag: tags.function(tags.variableName), color: "#89b4fa" },
{ tag: tags.atom, color: "#c9a6eb" },
{ tag: tags.unit, color: "#a6e3a1" },
{ tag: tags.color, color: "#f9e2af" }
]);
// Get highlight style based on section
function getHighlightForSection(section) {
if (section === "css") return cssHighlight;
return defaultHighlight;
}
// Get theme with section-specific highlighting
export function getEditorTheme(section) {
return [crispyTheme, syntaxHighlighting(getHighlightForSection(section))];
}
// Default combined theme export (for backwards compatibility)
export const crispyEditorTheme = [crispyTheme, syntaxHighlighting(defaultHighlight)];
// Custom overrides for editor styling
const editorTheme = EditorView.theme(
{
"&": {
height: "100%",
fontSize: "14px"
},
".cm-content": {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
padding: "12px 0"
},
".cm-line": {
padding: "0 12px"
}
},
{ dark: true }
);
export class CodeEditor {
constructor(container, options = {}) {
this.container = container;
this.options = options;
this.view = null;
this.mode = options.mode || "css";
this.section = options.section || null;
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,
getEditorTheme(this.section),
editorTheme,
// History for undo/redo
history(),
// Emmet abbreviation tracking
abbreviationTracker(),
// High priority keymap for Emmet
Prec.highest(
keymap.of([
{
key: "Tab",
run: expandAbbreviation
}
])
),
// Standard keymaps including history (Ctrl+Z, Ctrl+Shift+Z)
keymap.of([...historyKeymap, { 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 (preserves history)
*/
setValue(value) {
if (!this.view) return;
this.view.dispatch({
changes: {
from: 0,
to: this.view.state.doc.length,
insert: value
}
});
}
/**
* Set editor value and clear history (for lesson switching)
*/
setValueAndClearHistory(value) {
this.init(value);
}
/**
* Set editor mode (html or css)
*/
setMode(mode) {
if (this.mode === mode) return;
this.mode = mode;
const currentValue = this.getValue();
this.init(currentValue);
}
/**
* Set section for theme (css, html, tailwind)
*/
setSection(section) {
if (this.section === section) return;
this.section = section;
const currentValue = this.getValue();
this.init(currentValue);
}
/**
* Focus the editor
*/
focus() {
if (this.view) {
this.view.focus();
}
}
/**
* Undo last change
*/
undo() {
if (this.view) {
undo(this.view);
}
}
/**
* Redo last undone change
*/
redo() {
if (this.view) {
redo(this.view);
}
}
/**
* Destroy the editor
*/
destroy() {
if (this.view) {
this.view.destroy();
this.view = null;
}
}
}