import type { SmartCodeEditorOptions, RunResult, LanguageConfig, QuestionMarkdownOptions, } from "../types"; import { MonacoWrapper } from "./MonacoWrapper"; import { LayoutManager } from "./LayoutManager"; import { LanguageManager } from "./LanguageManager"; import { CodeRunner } from "./CodeRunner"; import { debounce } from "../utils/loader"; import { renderMarkdown } from "../utils/markdown"; /** * 智能代码编辑器主类 * 所有业务逻辑的入口点 */ export class SmartCodeEditor { private container: HTMLElement; private options: SmartCodeEditorOptions; // 核心模块 private monacoWrapper: MonacoWrapper | null = null; private layoutManager: LayoutManager | null = null; private languageManager: LanguageManager; private codeRunner: CodeRunner; // 状态 private id: string; private initialized: boolean = false; private _pendingLanguage: string | null = null; private _pendingValue: string | null = null; private resizeObserver: ResizeObserver | null = null; private loadingMask: HTMLElement | null = null; private toolbar: HTMLElement | null = null; private langSelect: HTMLSelectElement | null = null; private markdownEditor: HTMLTextAreaElement | null = null; private markdownPreview: HTMLElement | null = null; constructor(options: SmartCodeEditorOptions) { this.id = `sce_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; this.options = options; // 获取容器 if (typeof options.container === "string") { const el = document.querySelector(options.container); if (!el) { throw new Error(`Container "${options.container}" not found`); } this.container = el as HTMLElement; } else { this.container = options.container; } // 初始化管理器 this.languageManager = new LanguageManager( options.language || "javascript", ); this.codeRunner = new CodeRunner(options.runTimeout || 5000); // 异步初始化 this.init(); } /** * 显示加载遮罩 */ private showLoading(): void { if (this.loadingMask) return; this.loadingMask = document.createElement("div"); this.loadingMask.className = "sce-loading-mask"; this.loadingMask.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: ${this.options.theme === "vs-dark" ? "#1e1e1e" : "#ffffff"}; display: flex; align-items: center; justify-content: center; z-index: 1000; color: ${this.options.theme === "vs-dark" ? "#d4d4d4" : "#333333"}; font-size: 14px; font-family: -apple-system, system-ui, sans-serif; `; const spinner = document.createElement("div"); spinner.className = "sce-spinner"; spinner.textContent = "Loading..."; this.loadingMask.appendChild(spinner); this.container.style.position = "relative"; // 确保容器有定位上下文 this.container.appendChild(this.loadingMask); } /** * 隐藏加载遮罩 */ private hideLoading(): void { if (this.loadingMask && this.loadingMask.parentNode) { this.loadingMask.parentNode.removeChild(this.loadingMask); this.loadingMask = null; } } /** * 创建 Markdown 题目面板 */ private createMarkdownPanel(options: QuestionMarkdownOptions): HTMLElement { const wrapper = document.createElement("div"); wrapper.className = "sce-question-markdown"; wrapper.style.display = "flex"; wrapper.style.flexDirection = "column"; wrapper.style.height = "100%"; wrapper.style.minHeight = "0"; wrapper.style.gap = "12px"; wrapper.style.overflow = "hidden"; const editable = options.editable !== false; const showEditor = editable && options.showEditor !== false; const showPreview = editable ? options.showPreview !== false : true; const initialValue = options.value || ""; let updatePreview: ((value: string) => void) | null = null; let editorSection: HTMLDivElement | null = null; let previewSection: HTMLDivElement | null = null; let editorTab: HTMLButtonElement | null = null; let previewTab: HTMLButtonElement | null = null; const setTabState = () => { if (editorSection && editorTab) { const isVisible = editorSection.style.display !== "none"; editorTab.classList.toggle("active", isVisible); } if (previewSection && previewTab) { const isVisible = previewSection.style.display !== "none"; previewTab.classList.toggle("active", isVisible); } }; const showOnly = (section: HTMLDivElement | null) => { if (editorSection) { editorSection.style.display = section === editorSection ? "flex" : "none"; } if (previewSection) { previewSection.style.display = section === previewSection ? "flex" : "none"; } setTabState(); }; if (editable && showEditor && showPreview) { const tabs = document.createElement("div"); tabs.className = "sce-markdown-tabs"; tabs.style.display = "flex"; tabs.style.gap = "8px"; editorTab = document.createElement("button"); editorTab.type = "button"; editorTab.className = "sce-markdown-tab active"; editorTab.textContent = "编辑"; previewTab = document.createElement("button"); previewTab.type = "button"; previewTab.className = "sce-markdown-tab active"; previewTab.textContent = "预览"; tabs.appendChild(editorTab); tabs.appendChild(previewTab); wrapper.appendChild(tabs); } if (showEditor) { editorSection = document.createElement("div"); editorSection.className = "sce-markdown-editor-section"; editorSection.style.display = "flex"; editorSection.style.flexDirection = "column"; editorSection.style.flex = showPreview ? "1" : "1"; editorSection.style.minHeight = "0"; const editor = document.createElement("textarea"); editor.className = "sce-markdown-editor"; editor.value = initialValue; editor.placeholder = options.placeholder || "在此编辑 Markdown..."; editor.readOnly = options.editable === false; editor.style.width = "100%"; editor.style.flex = "1"; editor.style.minHeight = "120px"; editor.style.resize = "none"; editor.style.boxSizing = "border-box"; editor.style.padding = "10px"; editor.style.borderRadius = "6px"; editor.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"; editor.style.fontSize = "12px"; editor.style.lineHeight = "1.5"; editorSection.appendChild(editor); wrapper.appendChild(editorSection); this.markdownEditor = editor; const debouncedUpdate = debounce((value: string) => { if (updatePreview) { updatePreview(value); } if (options.onChange) { options.onChange(value); } }, 150); editor.addEventListener("input", () => { debouncedUpdate(editor.value); }); } if (showPreview) { previewSection = document.createElement("div"); previewSection.className = "sce-markdown-preview-section"; previewSection.style.display = "flex"; previewSection.style.flexDirection = "column"; previewSection.style.flex = "1"; previewSection.style.minHeight = "0"; const preview = document.createElement("div"); preview.className = "sce-markdown-preview"; preview.style.flex = "1"; preview.style.minHeight = "0"; preview.style.overflow = "auto"; preview.style.padding = "10px"; preview.style.borderRadius = "6px"; preview.style.fontFamily = "-apple-system, system-ui, sans-serif"; preview.style.fontSize = "13px"; preview.style.lineHeight = "1.6"; updatePreview = (value: string) => { preview.innerHTML = renderMarkdown(value); }; updatePreview(initialValue); previewSection.appendChild(preview); wrapper.appendChild(previewSection); this.markdownPreview = preview; } if (editorTab && previewTab) { editorTab.addEventListener("click", () => { showOnly(editorSection); }); previewTab.addEventListener("click", () => { showOnly(previewSection); }); // 默认显示编辑,隐藏预览 showOnly(editorSection); } if (!showEditor && !showPreview) { const empty = document.createElement("div"); empty.textContent = "未启用 Markdown 编辑/预览"; wrapper.appendChild(empty); } return wrapper; } /** * 初始化 */ private async init(): Promise { try { this.showLoading(); // 1. 创建布局 if (this.options.showQuestionPanel !== false) { this.layoutManager = new LayoutManager( this.container, this.options.defaultSplitRatio || 0.5, this.options.theme || "vs-dark", ); // 设置试题内容(Markdown 优先) if (this.options.questionMarkdown) { const markdownPanel = this.createMarkdownPanel( this.options.questionMarkdown, ); this.layoutManager.setLeftContent(markdownPanel); this.layoutManager.setTheme(this.options.theme || "vs-dark"); } else if (this.options.questionContent) { this.layoutManager.setLeftContent(this.options.questionContent); } // 使用右侧容器作为编辑器容器 const editorContainer = this.layoutManager.getRightContainer(); // 创建 Monaco 编辑器专用容器,确保 flex 布局正确 const monacoContainer = document.createElement("div"); monacoContainer.className = "sce-monaco-container"; monacoContainer.style.flex = "1"; monacoContainer.style.minHeight = "0"; monacoContainer.style.overflow = "hidden"; monacoContainer.style.position = "relative"; editorContainer.appendChild(monacoContainer); this.monacoWrapper = new MonacoWrapper(monacoContainer); } else { // 不显示试题面板,但仍需确保 flex 布局 this.container.style.display = "flex"; this.container.style.flexDirection = "column"; const monacoContainer = document.createElement("div"); monacoContainer.className = "sce-monaco-container"; monacoContainer.style.flex = "1"; monacoContainer.style.minHeight = "0"; monacoContainer.style.overflow = "hidden"; monacoContainer.style.position = "relative"; this.container.appendChild(monacoContainer); this.monacoWrapper = new MonacoWrapper(monacoContainer); } // 2. 初始化 Monaco Editor const currentLang = this.languageManager.getCurrentLanguage(); // 获取初始代码:优先使用 options.value,否则使用模板 // 注意:使用 !== undefined 而不是 ||,因为空字符串是有效值 const initialValue = this.options.value !== undefined && this.options.value !== null ? this.options.value : this.languageManager.getTemplate( currentLang.id, this.options.questionConfig, this.options.languageTemplates, ); console.log("🎨 SmartCodeEditor 初始化:", { hasValue: this.options.value !== undefined, valueLength: this.options.value?.length, hasQuestionConfig: !!this.options.questionConfig, hasLanguageTemplates: !!this.options.languageTemplates, initialValuePreview: initialValue?.substring(0, 50) + "...", }); await this.monacoWrapper.initialize({ value: initialValue, language: currentLang.monacoId, theme: this.options.theme || "vs-dark", readOnly: this.options.readOnly || false, automaticLayout: false, // Explicitly disable to avoid ResizeObserver loops with our own observer suggestOnTriggerCharacters: this.options.suggestOnTriggerCharacters, quickSuggestions: this.options.quickSuggestions, }); // 确保初始模型也有正确的文件扩展名 URI const ext = currentLang.extensions ? currentLang.extensions[0] : ""; this.monacoWrapper.updateModel( initialValue, currentLang.monacoId, ext, this.id, ); // Check if language changed during initialization const actualCurrentLang = this.languageManager.getCurrentLanguage(); if (actualCurrentLang.id !== currentLang.id) { const ext = actualCurrentLang.extensions ? actualCurrentLang.extensions[0] : ""; this.monacoWrapper.updateModel( this.getValue(), actualCurrentLang.monacoId, ext, this.id, ); } // Check for pending value if (this._pendingValue !== null) { this.monacoWrapper.setValue(this._pendingValue); this._pendingValue = null; } // 3. 监听内容变化 if (this.options.onChange) { const debouncedOnChange = debounce(this.options.onChange, 300); this.monacoWrapper.onDidChangeContent(debouncedOnChange); } this.initialized = true; // Apply pending language if any if (this._pendingLanguage) { this.setLanguage(this._pendingLanguage); this._pendingLanguage = null; } // Check for pending value (apply last to overwrite template from setLanguage) if (this._pendingValue !== null) { this.monacoWrapper.setValue(this._pendingValue); this._pendingValue = null; } // 4. 创建工具栏 this.createToolbar(); // Monitor container resize if (typeof ResizeObserver !== "undefined") { this.resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { this.layout(); }); }); this.resizeObserver.observe(this.container); } // Initial layout this.layout(); } catch (error) { console.error("Failed to initialize SmartCodeEditor:", error); throw error; } finally { this.hideLoading(); } } /** * 创建工具栏 */ private createToolbar(): void { if (!this.monacoWrapper) return; const editorContainer = this.layoutManager ? this.layoutManager.getRightContainer() : this.container; // 创建工具栏容器 const toolbar = document.createElement("div"); this.toolbar = toolbar; toolbar.className = "sce-toolbar"; const isDark = this.options.theme === "vs-dark"; const bg = isDark ? "#2d2d30" : "#fff"; const border = isDark ? "#454545" : "#f3f3f3"; toolbar.style.cssText = ` display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: ${bg}; border-bottom: 1px solid ${border}; transition: all 0.2s; flex-shrink: 0; `; // 语言选择器 if (this.options.enableLanguageSwitch !== false) { const langSelect = this.createLanguageSelector(); toolbar.appendChild(langSelect); } // 运行按钮 if (this.options.enableRun !== false) { const runButton = this.createRunButton(); toolbar.appendChild(runButton); } // 提交按钮 if (this.options.enableSubmit !== false) { const submitButton = this.createSubmitButton(); toolbar.appendChild(submitButton); } // 插入工具栏到编辑器容器前面 editorContainer.insertBefore(toolbar, editorContainer.firstChild); } /** * 创建语言选择器 */ private createLanguageSelector(): HTMLElement { const container = document.createElement("div"); container.style.display = "flex"; container.style.alignItems = "center"; container.style.gap = "8px"; const label = document.createElement("span"); label.textContent = "语言:"; label.style.color = this.options.theme === "vs-dark" ? "#ccc" : "#666"; label.style.fontSize = "13px"; const select = document.createElement("select"); this.langSelect = select; const isDark = this.options.theme === "vs-dark"; const bg = isDark ? "#3c3c3c" : "#ffffff"; const color = isDark ? "#ccc" : "#333"; const border = isDark ? "#454545" : "#ccc"; select.style.cssText = ` padding: 4px 8px; border: 1px solid ${border}; background: ${bg}; color: ${color}; border-radius: 4px; cursor: pointer; font-size: 13px; `; // 添加语言选项 let languages = this.languageManager.getAllLanguages(); if ( this.options.supportedLanguages && this.options.supportedLanguages.length > 0 ) { languages = languages.filter((lang) => this.options.supportedLanguages!.includes(lang.id), ); } languages.forEach((lang) => { const option = document.createElement("option"); option.value = lang.id; option.textContent = `${lang.icon || ""} ${lang.name}`.trim(); if (lang.id === this.languageManager.getCurrentLanguage().id) { option.selected = true; } select.appendChild(option); }); // 监听变化 select.addEventListener("change", () => { this.setLanguage(select.value); }); container.appendChild(label); container.appendChild(select); return container; } /** * 创建运行按钮 */ private createRunButton(): HTMLElement { const button = document.createElement("button"); button.innerHTML = ` 运行`; button.style.cssText = ` padding: 6px 16px; border: none; background: #0e639c; color: white; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; margin-left: auto; display: flex; align-items: center; gap: 6px; transition: background 0.2s; `; button.addEventListener("click", async () => { const originalContent = button.innerHTML; try { button.disabled = true; button.style.background = "#0e639c80"; button.style.cursor = "not-allowed"; button.innerHTML = ` 运行中...`; await this.run(); } finally { button.disabled = false; button.style.background = "#0e639c"; button.style.cursor = "pointer"; button.innerHTML = originalContent; } }); button.addEventListener("mouseenter", () => { if (!button.disabled) button.style.background = "#1177bb"; }); button.addEventListener("mouseleave", () => { if (!button.disabled) button.style.background = "#0e639c"; }); // Inject spinner style if not exists this.injectSpinnerStyle(); return button; } /** * 创建提交按钮 */ private createSubmitButton(): HTMLElement { const button = document.createElement("button"); button.textContent = "提交"; button.title = "提交代码进行评测"; button.style.cssText = ` padding: 6px 16px; border: none; background: #4caf50; color: white; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; margin-left: 8px; display: flex; align-items: center; gap: 6px; transition: background 0.2s; `; button.addEventListener("click", async () => { if (this.options.onSubmit) { const originalContent = button.textContent || "提交"; try { button.disabled = true; button.style.background = "#4caf5080"; button.style.cursor = "not-allowed"; button.innerHTML = ` 提交中...`; await this.options.onSubmit(this.getValue(), this.getLanguage()); } finally { button.disabled = false; button.style.background = "#4caf50"; button.style.cursor = "pointer"; button.textContent = originalContent; } } }); button.addEventListener("mouseenter", () => { if (!button.disabled) button.style.background = "#43a047"; }); button.addEventListener("mouseleave", () => { if (!button.disabled) button.style.background = "#4caf50"; }); return button; } private injectSpinnerStyle() { if (document.getElementById("sce-btn-spinner-style")) return; const style = document.createElement("style"); style.id = "sce-btn-spinner-style"; style.innerHTML = ` @keyframes sce-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .sce-btn-spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid rgba(255,255,255,0.3); border-radius: 50%; border-top-color: #fff; animation: sce-spin 1s ease-in-out infinite; } `; document.head.appendChild(style); } /** * 获取代码 */ getValue(): string { return this.monacoWrapper?.getValue() || ""; } /** * 设置代码 */ setValue(value: string): void { if (!this.monacoWrapper || !this.initialized) { this._pendingValue = value; return; } this.monacoWrapper.setValue(value); } /** * 获取当前语言 */ getLanguage(): string { return this.languageManager.getCurrentLanguage().id; } /** * 设置语言 */ setLanguage(language: string): void { if (!this.monacoWrapper || !this.initialized) { this._pendingLanguage = language; return; } const lang = this.languageManager.switchLanguage(language); // 获取当前代码和文件扩展名 const code = this.getValue(); const ext = lang.extensions ? lang.extensions[0] : ""; this.monacoWrapper?.updateModel(code, lang.monacoId, ext, this.id); // 切换语言时,自动加载对应的模板代码 const template = this.languageManager.getTemplate( language, this.options.questionConfig, this.options.languageTemplates, ); this.setValue(template); // 触发回调 if (this.options.onLanguageChange) { this.options.onLanguageChange(language); } } /** * 获取支持的语言列表 */ getSupportedLanguages(): LanguageConfig[] { return this.languageManager.getAllLanguages(); } /** * 设置主题 */ setTheme(theme: string): void { this.monacoWrapper?.setTheme(theme); this.layoutManager?.setTheme(theme); this.options.theme = theme; // 更新 options 中的 theme // 更新工具栏样式 if (this.toolbar) { const isDark = theme === "vs-dark"; this.toolbar.style.background = isDark ? "#2d2d30" : "#f3f3f3"; this.toolbar.style.borderBottom = isDark ? "1px solid #454545" : "1px solid #e0e0e0"; } // 更新语言选择器样式 if (this.langSelect) { const isDark = theme === "vs-dark"; this.langSelect.style.background = isDark ? "#3c3c3c" : "#ffffff"; this.langSelect.style.color = isDark ? "#ccc" : "#333"; this.langSelect.style.borderColor = isDark ? "#454545" : "#ccc"; // 更新 label 颜色 (它是 select 的前一个兄弟节点) if (this.langSelect.previousElementSibling instanceof HTMLElement) { this.langSelect.previousElementSibling.style.color = isDark ? "#ccc" : "#666"; } } } /** * 获取主题 */ getTheme(): string { return this.options.theme || "vs-dark"; } /** * 设置试题内容 */ setQuestionContent(content: string): void { this.layoutManager?.setLeftContent(content); this.markdownEditor = null; this.markdownPreview = null; } /** * 设置 Markdown 题目内容 */ setQuestionMarkdown( value: string, options?: Partial, ): void { if (!this.layoutManager) return; const nextOptions: QuestionMarkdownOptions = { ...(this.options.questionMarkdown || {}), ...(options || {}), value, }; const panel = this.createMarkdownPanel(nextOptions); this.layoutManager.setLeftContent(panel); this.options.questionMarkdown = nextOptions; this.layoutManager.setTheme(this.options.theme || "vs-dark"); } /** * 显示试题面板 */ showQuestionPanel(): void { this.layoutManager?.setQuestionPanelVisible(true); } /** * 隐藏试题面板 */ hideQuestionPanel(): void { this.layoutManager?.setQuestionPanelVisible(false); } private testCases: import("../types").TestCase[] = []; /** * 设置测试用例 */ setTestCases(testCases: import("../types").TestCase[]): void { this.testCases = testCases; } /** * 运行代码 */ async run(testCases?: import("../types").TestCase[]): Promise { const code = this.getValue(); const language = this.getLanguage(); const currentLang = this.languageManager.getCurrentLanguage(); if (!currentLang.canRun) { const result: RunResult = { output: "", error: `语言 "${currentLang.name}" 不支持运行`, executionTime: 0, status: "error", }; if (this.options.onRun) { this.options.onRun(result); } return result; } // Use custom runner if provided if (this.options.customRunner) { // 优先使用传入的 testCases,如果没有则使用内部存储的 const casesToRun = testCases || this.testCases; try { const result = await this.options.customRunner( code, language, casesToRun, ); if (this.options.onRun) { this.options.onRun(result); } return result; } catch (error: any) { const errorResult: RunResult = { output: "", error: error.message || "自定义运行器执行失败", executionTime: 0, status: "error", }; if (this.options.onRun) { this.options.onRun(errorResult); } return errorResult; } } try { // 优先使用传入的 testCases,如果没有则使用内部存储的 const casesToRun = testCases || this.testCases; const result = await this.codeRunner.run(code, language, casesToRun); // 触发回调 if (this.options.onRun) { this.options.onRun(result); } return result; } catch (error: any) { const result: RunResult = { output: "", error: error.message, executionTime: 0, status: "error", }; if (this.options.onRun) { this.options.onRun(result); } return result; } } /** * 取消运行 */ cancelRun(): void { this.codeRunner.cancel(); } /** * 调整布局 */ resize(): void { this.monacoWrapper?.layout(); } /** * 设置分割比例 */ setSplitRatio(ratio: number): void { this.layoutManager?.setSplitRatio(ratio); this.monacoWrapper?.layout(); } /** * 重新布局 */ layout(): void { this.monacoWrapper?.layout(); } /** * 更新配置 */ updateConfig(config: Partial): void { const monacoOptions: any = {}; if (config.questionConfig) { this.options.questionConfig = config.questionConfig; } if (config.languageTemplates) { this.options.languageTemplates = config.languageTemplates; } if (config.supportedLanguages) { this.options.supportedLanguages = config.supportedLanguages; this.refreshLanguageSelector(); } // Update suggestion options if provided if (config.suggestOnTriggerCharacters !== undefined) { this.options.suggestOnTriggerCharacters = config.suggestOnTriggerCharacters; monacoOptions.suggestOnTriggerCharacters = config.suggestOnTriggerCharacters; } if (config.quickSuggestions !== undefined) { this.options.quickSuggestions = config.quickSuggestions; monacoOptions.quickSuggestions = config.quickSuggestions; } // Apply updates to monaco editor if we have any relevant changes if (Object.keys(monacoOptions).length > 0) { this.monacoWrapper?.updateOptions(monacoOptions); } } /** * 刷新语言选择器 */ private refreshLanguageSelector(): void { if (!this.langSelect) return; // 清空现有选项 this.langSelect.innerHTML = ""; // 重新添加选项 let languages = this.languageManager.getAllLanguages(); if ( this.options.supportedLanguages && this.options.supportedLanguages.length > 0 ) { languages = languages.filter((lang) => this.options.supportedLanguages!.includes(lang.id), ); } languages.forEach((lang) => { const option = document.createElement("option"); option.value = lang.id; option.textContent = `${lang.icon || ""} ${lang.name}`.trim(); if (lang.id === this.languageManager.getCurrentLanguage().id) { option.selected = true; } this.langSelect!.appendChild(option); }); } /** * 销毁 */ destroy(): void { this.resizeObserver?.disconnect(); this.monacoWrapper?.dispose(); this.layoutManager?.destroy(); this.codeRunner.destroy(); } }