/**
* Monaco Editor 封装模块
*
* - 全局单例 MonacoEditorManager,切换文件时 setModel() 复用实例,不销毁重建。
* - 中文 NLS:通过 require.config 的 availableLanguages 加载 Monaco 自带的 zh-cn 包。
* - 行高亮:点击行后高亮该行并将 #L{n} 写入 URL,刷新后自动恢复。
*/
import { monacoTheme } from './util.js';
// ─────────────────────────────────────────────────────────────────────────────
// 常量
// ─────────────────────────────────────────────────────────────────────────────
const MONACO_VS = 'https://dpangzi.com/library/monaco-editor/min/vs';
const MONACO_FONT_FAMILY = '"Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Cascadia Mono", "Courier New", monospace';
const MONACO_FONT_LIGATURES = true;
/**
* 服务端语言 ID / Prism 语言 ID → Monaco 语言 ID
*/
const LANG_MAP = {
// Prism 别名
markup: 'html',
sh: 'shell',
bash: 'shell',
ps1: 'powershell',
// 服务端配置值 → Monaco 语言 ID
docker: 'dockerfile', // ExtensionToLanguage: "dockerfile": "docker"
ignore: 'plaintext', // .dockerignore / .gitignore
git: 'plaintext', // .gitattributes
sln: 'ini', // .sln 格式近似 INI
text: 'plaintext', // .txt
};
/** Monaco 语言 ID → 中文显示名称 */
const LANG_DISPLAY = {
csharp: 'C#',
javascript: 'JavaScript',
typescript: 'TypeScript',
html: 'HTML',
css: 'CSS',
less: 'Less',
scss: 'SCSS',
json: 'JSON',
xml: 'XML',
sql: 'SQL',
python: 'Python',
java: 'Java',
cpp: 'C++',
c: 'C',
go: 'Go',
rust: 'Rust',
php: 'PHP',
ruby: 'Ruby',
shell: 'Shell',
powershell: 'PowerShell',
yaml: 'YAML',
markdown: 'Markdown',
razor: 'Razor',
ini: 'INI',
dockerfile: 'Dockerfile',
plaintext: '纯文本',
};
// ─────────────────────────────────────────────────────────────────────────────
// MonacoEditorManager
// ─────────────────────────────────────────────────────────────────────────────
class MonacoEditorManager {
constructor() {
/** @type {Promise<typeof import('monaco-editor')> | null} */
this._loadPromise = null;
/** @type {import('monaco-editor').editor.IStandaloneCodeEditor | null} */
this._editor = null;
/** @type {HTMLElement | null} */
this._hostEl = null;
/** @type {import('monaco-editor').editor.ITextModel | null} */
this._currentModel = null;
/** @type {import('monaco-editor').editor.IEditorDecorationsCollection | null} */
this._decorations = null;
/** @type {typeof import('monaco-editor') | null} */
this._monaco = null;
/** @type {import('monaco-editor').IDisposable | null} 鼠标点击监听器 */
this._clickDisposable = null;
}
// ── 公开 API ──────────────────────────────────────────────────────────────
/**
* 在指定容器内显示代码。
* @param {HTMLElement} hostEl 挂载容器(.monaco-host 元素)
* @param {string} code 源代码文本
* @param {string} lang Prism 语言 ID,自动映射为 Monaco 语言 ID
*/
async show(hostEl, code, lang) {
const monaco = await this._loadMonaco();
this._monaco = monaco;
const monacoLang = this._toMonacoLang(lang);
// 根据行数自适应高度(避免 Monaco 渲染到 0px 容器)
this._setHeight(hostEl, code);
// ── 创建或重新挂载 Editor ──────────────────────────────────────────
if (!this._editor || this._hostEl !== hostEl) {
if (this._editor) {
this._clickDisposable?.dispose();
this._editor.dispose();
this._editor = null;
}
if (this._currentModel) {
this._currentModel.dispose();
this._currentModel = null;
}
this._editor = monaco.editor.create(hostEl, {
model: null, // 稍后 setModel
readOnly: true,
theme: monacoTheme(),
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
fontFamily: MONACO_FONT_FAMILY,
fontLigatures: MONACO_FONT_LIGATURES,
fontWeight: 'normal',
lineNumbers: 'on',
automaticLayout: true,
renderWhitespace: 'none',
contextmenu: false,
wordWrap: 'off',
smoothScrolling: true,
padding: { top: 8, bottom: 8 },
});
this._decorations = this._editor.createDecorationsCollection();
this._clickDisposable = this._bindLineClick();
this._hostEl = hostEl;
}
// ── 恢复 URL Hash 行号(在 setModel 前解析,避免遗漏事件) ────────
const hashLine = this._parseHashLine();
if (hashLine) {
// onDidContentSizeChange 在 setModel 完成内容测量后触发。
// 注册一次性监听,滚动完成后立即销毁,避免后续切换时重复触发。
const disposable = this._editor.onDidContentSizeChange(() => {
disposable.dispose();
this._editor.revealLineInCenter(hashLine);
});
}
// ── 替换 Model(核心:避免 dispose+create)────────────────────────
const oldModel = this._currentModel;
this._currentModel = monaco.editor.createModel(code, monacoLang);
this._editor.setModel(this._currentModel); // ← 触发 onDidContentSizeChange
if (oldModel) oldModel.dispose();
// Chromium 下偶发字符宽度测量偏差:切换模型后主动重新测量一次。
this._stabilizeTextMetrics();
// decoration 在 setModel 后立即生效,无需等待布局
if (hashLine) this._highlightLine(hashLine);
// ── 同步主题 ──────────────────────────────────────────────────────
monaco.editor.setTheme(monacoTheme());
// ── 语言徽章 ──────────────────────────────────────────────────────
this._renderLangBadge(hostEl, monacoLang);
}
/** 销毁 Editor 实例(切换到非代码页面时调用)。 */
destroy() {
this._clickDisposable?.dispose();
this._clickDisposable = null;
if (this._currentModel) {
this._currentModel.dispose();
this._currentModel = null;
}
if (this._editor) {
this._editor.dispose();
this._editor = null;
}
this._hostEl = null;
this._decorations = null;
this._monaco = null;
}
// ── 内部方法 ──────────────────────────────────────────────────────────────
/**
* 动态加载 Monaco AMD loader
* @returns {Promise<typeof import('monaco-editor')>}
*/
_loadMonaco() {
if (this._loadPromise) return this._loadPromise;
this._loadPromise = new Promise((resolve, reject) => {
const bootstrap = () => {
window.require.config({
paths: { vs: MONACO_VS },
// 中文 NLS:Monaco 会自动加载 *.nls.zh-cn.js 文件
'vs/nls': { availableLanguages: { '*': 'zh-cn' } },
});
window.require(['vs/editor/editor.main'], () => resolve(window.monaco), reject);
};
if (window.require?.config) {
bootstrap();
return;
}
const s = document.createElement('script');
s.src = `${MONACO_VS}/loader.js`;
s.onload = bootstrap;
s.onerror = () => {
this._loadPromise = null; // 允许下次重试
reject(new Error('Monaco loader.js 加载失败'));
};
document.head.appendChild(s);
});
return this._loadPromise;
}
/**
* 根据代码行数自适应设置容器高度。
* @param {HTMLElement} hostEl
* @param {string} code
*/
_setHeight(hostEl, code) {
const lineCount = code.split('\n').length;
const lineHeight = 19;
const maxHeight = Math.floor(window.innerHeight * 0.8);
const minHeight = 300;
const height = Math.max(minHeight, Math.min(lineCount * lineHeight + 40, maxHeight));
hostEl.style.height = `${height}px`;
}
/** Prism 语言 ID → Monaco 语言 ID */
_toMonacoLang(lang) {
return LANG_MAP[lang] ?? lang;
}
/** Monaco 语言 ID → 中文显示名称 */
_getDisplayName(monacoLang) {
return LANG_DISPLAY[monacoLang] ?? monacoLang;
}
/**
* 在容器右下角渲染语言徽章。
* @param {HTMLElement} hostEl
* @param {string} monacoLang
*/
_renderLangBadge(hostEl, monacoLang) {
let badge = hostEl.querySelector('.monaco-lang-badge');
if (!badge) {
badge = document.createElement('div');
badge.className = 'monaco-lang-badge';
hostEl.appendChild(badge);
}
badge.textContent = this._getDisplayName(monacoLang);
}
// ── 行高亮 & URL Hash ─────────────────────────────────────────────────────
/**
* 绑定编辑器的鼠标点击事件。
* @returns {import('monaco-editor').IDisposable}
*/
_bindLineClick() {
return this._editor.onMouseDown((e) => {
const pos = e.target?.position;
if (!pos) return;
this._highlightLine(pos.lineNumber);
// 更新 URL hash,不触发页面跳转
const newUrl = `${window.location.pathname}${window.location.search}#L${pos.lineNumber}`;
window.history.replaceState(null, '', newUrl);
});
}
/**
* 解析 URL Hash 中的行号(格式 #L42),无效时返回 null。
* @returns {number | null}
*/
_parseHashLine() {
const match = window.location.hash.match(/^#L(\d+)$/);
if (!match) return null;
const n = parseInt(match[1], 10);
return n >= 1 ? n : null;
}
/**
* 高亮指定行(替换之前的高亮)。
* @param {number} lineNumber
*/
_highlightLine(lineNumber) {
if (!this._editor || !this._monaco || !this._decorations) return;
this._decorations.set([{
range: new this._monaco.Range(lineNumber, 1, lineNumber, 1),
options: {
isWholeLine: true,
className: 'monaco-highlighted-line',
linesDecorationsClassName: 'monaco-highlighted-line-gutter',
},
}]);
}
/**
* 强制编辑器重新进行字体与布局测量,修复 Chromium 选区/光标偶发偏移。
*/
_stabilizeTextMetrics() {
if (!this._editor) return;
this._editor.updateOptions({
fontFamily: MONACO_FONT_FAMILY,
fontLigatures: MONACO_FONT_LIGATURES,
});
this._editor.layout();
// 等待字体就绪后再做一次,避免 Web 字体异步加载导致的宽度漂移。
const applyFinalLayout = () => {
if (!this._editor) return;
this._editor.layout();
};
if (document.fonts?.ready) {
document.fonts.ready.then(() => {
requestAnimationFrame(applyFinalLayout);
});
} else {
requestAnimationFrame(applyFinalLayout);
}
}
}
const _manager = new MonacoEditorManager();
/**
* @param {HTMLElement} hostEl
* @param {string} code
* @param {string} lang
*/
export const show = (hostEl, code, lang) => _manager.show(hostEl, code, lang);
export const destroy = () => _manager.destroy();
评论加载中...