Files
toolbox/resources/js/components/CodeEditor.vue
2025-08-01 16:55:23 +08:00

970 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="simple-code-editor">
<!-- 编辑器头部工具栏 -->
<div class="editor-header">
<div class="editor-tabs">
<div class="tab active">
<div class="tab-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
</svg>
</div>
<span class="tab-name">{{ getFileName() }}</span>
<div class="tab-close">×</div>
</div>
</div>
<div class="editor-controls">
<button class="control-btn" @click="formatCode" title="格式化代码">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
</svg>
</button>
<button class="control-btn" @click="toggleWrap" title="切换自动换行">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M4,6H20V8H4V6M4,18V16H20V18H4M4,13H16L13,16H15L19,12L15,8H13L16,11H4V13Z"/>
</svg>
</button>
</div>
</div>
<!-- 编辑器主体 - 单层渲染方案 -->
<div class="editor-body" :class="{ 'dark-theme': theme === 'dark', 'word-wrap': wordWrap }">
<!-- 行号区域 -->
<div class="line-numbers-area" ref="lineNumbers">
<div
v-for="(line, index) in lines"
:key="index"
class="line-number"
:class="{ 'active': index + 1 === currentLine }"
>
{{ String(index + 1).padStart(3, ' ') }}
</div>
</div>
<!-- 代码区域 - 只使用一个带语法高亮的可编辑div -->
<div class="code-area">
<div
ref="codeEditor"
class="code-editor-div"
:contenteditable="!readonly"
@input="handleDivInput"
@scroll="handleScroll"
@keydown="handleKeydown"
@click="updateCursor"
@keyup="updateCursor"
@focus="handleFocus"
@blur="handleBlur"
@paste="handlePaste"
spellcheck="false"
v-html="highlightedContent"
></div>
</div>
</div>
<!-- 编辑器底部状态栏 -->
<div class="editor-footer">
<div class="status-left">
<span class="status-item"> {{ currentLine }}</span>
<span class="status-item"> {{ currentColumn }}</span>
<span class="status-item">{{ lines.length }} </span>
<span class="status-item">{{ content.length }} 字符</span>
</div>
<div class="status-right">
<span class="status-item">{{ language.toUpperCase() }}</span>
<span class="status-item">UTF-8</span>
<span class="status-item" :class="{ 'status-success': !readonly }">
{{ readonly ? '只读' : '可编辑' }}
</span>
</div>
</div>
</div>
</template>
<script>
// 纯原生实现,无外部依赖
export default {
name: 'CodeEditor',
props: {
modelValue: {
type: String,
default: ''
},
language: {
type: String,
default: 'env' // 'env', 'javascript', 'php'
},
theme: {
type: String,
default: 'light' // 'light', 'dark'
},
readonly: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: ''
}
},
emits: ['update:modelValue', 'change'],
data() {
return {
content: this.modelValue,
currentLine: 1,
currentColumn: 1,
focused: false,
wordWrap: false
}
},
computed: {
lines() {
return this.content.split('\n');
},
highlightedContent() {
if (!this.content) return '';
if (this.language === 'env') {
return this.highlightEnvContent(this.content);
} else if (this.language === 'javascript') {
return this.highlightJavaScriptContent(this.content);
} else if (this.language === 'php') {
return this.highlightPhpContent(this.content);
}
return this.escapeHtml(this.content);
}
},
mounted() {
// 初始化时自动检测语言
this.autoDetectLanguage();
// 初始化编辑器
this.initEditor();
},
watch: {
modelValue(newValue) {
if (newValue !== this.content) {
this.content = newValue;
this.$nextTick(() => {
this.initEditor();
});
}
},
content() {
this.$nextTick(() => {
this.initEditor();
});
},
language() {
// 语言变化时重新渲染语法高亮
this.$nextTick(() => {
this.initEditor();
});
}
},
methods: {
handleInput() {
this.$emit('update:modelValue', this.content);
this.$emit('change', this.content);
this.updateCursor();
},
handleDivInput(event) {
// 获取纯文本内容
const text = this.getPlainTextFromDiv(event.target);
this.content = text;
this.$emit('update:modelValue', text);
this.$emit('change', text);
// 延迟更新语法高亮,避免光标跳动
this.$nextTick(() => {
this.updateSyntaxHighlight();
this.updateCursor();
});
},
handleScroll() {
// 同步行号滚动
const codeEditor = this.$refs.codeEditor;
const lineNumbers = this.$refs.lineNumbers;
if (codeEditor && lineNumbers) {
lineNumbers.scrollTop = codeEditor.scrollTop;
}
},
handleFocus() {
this.focused = true;
},
handleBlur() {
this.focused = false;
},
handleKeydown(event) {
// Tab键处理
if (event.key === 'Tab') {
event.preventDefault();
this.insertText(' ');
}
},
handlePaste(event) {
event.preventDefault();
const text = (event.clipboardData || window.clipboardData).getData('text');
this.insertText(text);
},
insertText(text) {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
// 触发input事件
this.handleDivInput({ target: this.$refs.codeEditor });
}
},
updateCursor() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const beforeCursor = this.getTextBeforeCursor(range);
const lines = beforeCursor.split('\n');
this.currentLine = lines.length;
this.currentColumn = lines[lines.length - 1].length + 1;
}
},
initEditor() {
// 初始化编辑器内容
const codeEditor = this.$refs.codeEditor;
if (codeEditor && this.content) {
codeEditor.innerHTML = this.highlightedContent;
}
},
getPlainTextFromDiv(div) {
// 从contenteditable div中提取纯文本
return div.innerText || div.textContent || '';
},
getTextBeforeCursor(range) {
// 获取光标前的文本
const container = this.$refs.codeEditor;
if (!container) return '';
const tempRange = document.createRange();
tempRange.setStart(container, 0);
tempRange.setEnd(range.startContainer, range.startOffset);
return tempRange.toString();
},
updateSyntaxHighlight() {
const codeEditor = this.$refs.codeEditor;
if (!codeEditor) return;
// 保存当前光标位置
const selection = window.getSelection();
let savedRange = null;
let cursorOffset = 0;
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
cursorOffset = this.getTextBeforeCursor(range).length;
}
// 更新语法高亮内容
codeEditor.innerHTML = this.highlightedContent;
// 恢复光标位置
this.$nextTick(() => {
this.restoreCursorPosition(cursorOffset);
});
},
restoreCursorPosition(offset) {
const codeEditor = this.$refs.codeEditor;
if (!codeEditor) return;
const selection = window.getSelection();
const range = document.createRange();
try {
let currentOffset = 0;
const walker = document.createTreeWalker(
codeEditor,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= offset) {
range.setStart(node, offset - currentOffset);
range.setEnd(node, offset - currentOffset);
break;
}
currentOffset += nodeLength;
}
selection.removeAllRanges();
selection.addRange(range);
} catch (e) {
// 如果恢复失败,将光标放到末尾
range.selectNodeContents(codeEditor);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
},
formatCode() {
// 简单的代码格式化
if (this.language === 'env') {
const lines = this.content.split('\n');
const formatted = lines.map(line => {
line = line.trim();
if (line.includes('=') && !line.startsWith('#')) {
const [key, ...valueParts] = line.split('=');
const value = valueParts.join('=');
return `${key.trim()}=${value}`;
}
return line;
}).join('\n');
this.content = formatted;
this.handleInput();
}
},
toggleWrap() {
this.wordWrap = !this.wordWrap;
},
getFileName() {
const fileNames = {
'env': '.env',
'javascript': 'script.js',
'php': 'config.php'
};
return fileNames[this.language] || '.env';
},
// 根据文件名自动检测语言
detectLanguageFromFilename(filename) {
if (!filename) return 'env';
const ext = filename.toLowerCase();
// .env 文件检测
if (ext === '.env' || ext.startsWith('.env.') || ext.endsWith('.env')) {
return 'env';
}
// JavaScript 文件检测
if (ext.endsWith('.js') || ext.endsWith('.jsx') || ext.endsWith('.ts') || ext.endsWith('.tsx') || ext.endsWith('.mjs')) {
return 'javascript';
}
// PHP 文件检测
if (ext.endsWith('.php') || ext.endsWith('.phtml') || ext.endsWith('.php3') || ext.endsWith('.php4') || ext.endsWith('.php5')) {
return 'php';
}
// 默认返回 env
return 'env';
},
// 自动设置语言
autoDetectLanguage() {
const filename = this.getFileName();
const detectedLanguage = this.detectLanguageFromFilename(filename);
if (detectedLanguage !== this.language) {
this.$emit('language-change', detectedLanguage);
}
},
highlightEnvContent(content) {
return content
.split('\n')
.map(line => {
if (line.trim().startsWith('#')) {
// 注释
return `<span class="env-comment">${this.escapeHtml(line)}</span>`;
} else if (line.includes('=')) {
// 键值对
const equalIndex = line.indexOf('=');
const key = line.substring(0, equalIndex);
const value = line.substring(equalIndex + 1);
return `<span class="env-key">${this.escapeHtml(key)}</span><span class="env-operator">=</span><span class="env-value">${this.escapeHtml(value)}</span>`;
} else {
return this.escapeHtml(line);
}
})
.join('\n');
},
highlightJavaScriptContent(content) {
// 简单的JavaScript语法高亮
const keywords = ['function', 'const', 'let', 'var', 'if', 'else', 'for', 'while', 'return', 'class', 'import', 'export', 'default'];
const keywordRegex = new RegExp(`\\b(${keywords.join('|')})\\b`, 'g');
return content
.split('\n')
.map(line => {
let highlightedLine = this.escapeHtml(line);
// 高亮关键字
highlightedLine = highlightedLine.replace(keywordRegex, '<span class="js-keyword">$1</span>');
// 高亮字符串
highlightedLine = highlightedLine.replace(/(["'`])((?:\\.|(?!\1)[^\\])*?)\1/g, '<span class="js-string">$1$2$1</span>');
// 高亮注释
highlightedLine = highlightedLine.replace(/(\/\/.*$)/g, '<span class="js-comment">$1</span>');
return highlightedLine;
})
.join('\n');
},
highlightPhpContent(content) {
// 简单的PHP语法高亮
const keywords = ['function', 'class', 'if', 'else', 'elseif', 'while', 'for', 'foreach', 'return', 'public', 'private', 'protected', 'static'];
const keywordRegex = new RegExp(`\\b(${keywords.join('|')})\\b`, 'g');
return content
.split('\n')
.map(line => {
let highlightedLine = this.escapeHtml(line);
// 高亮PHP标签
highlightedLine = highlightedLine.replace(/(&lt;\?php|&lt;\?=|\?&gt;)/g, '<span class="php-tag">$1</span>');
// 高亮关键字
highlightedLine = highlightedLine.replace(keywordRegex, '<span class="php-keyword">$1</span>');
// 高亮变量
highlightedLine = highlightedLine.replace(/(\$\w+)/g, '<span class="php-variable">$1</span>');
// 高亮字符串
highlightedLine = highlightedLine.replace(/(["'])((?:\\.|(?!\1)[^\\])*?)\1/g, '<span class="php-string">$1$2$1</span>');
// 高亮注释
highlightedLine = highlightedLine.replace(/(\/\/.*$|\/\*.*?\*\/)/g, '<span class="php-comment">$1</span>');
return highlightedLine;
})
.join('\n');
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
focus() {
if (this.$refs.textarea) {
this.$refs.textarea.focus();
}
},
getContent() {
return this.content;
},
}
}
</script>
<style scoped>
.simple-code-editor {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Monaco', 'Inconsolata', 'Consolas', monospace;
background: #ffffff;
border-radius: 12px;
overflow: visible; /* 移除容器级别的滚动条限制 */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
}
/* 编辑器头部 */
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid #e2e8f0;
padding: 0;
min-height: 40px;
}
.editor-tabs {
display: flex;
align-items: center;
}
.tab {
display: flex;
align-items: center;
padding: 8px 16px;
background: #ffffff;
border-right: 1px solid #e2e8f0;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.tab.active {
background: #ffffff;
border-bottom: 2px solid #3b82f6;
}
.tab:hover {
background: #f8fafc;
}
.tab-icon {
margin-right: 8px;
color: #6b7280;
display: flex;
align-items: center;
}
.tab-name {
font-size: 13px;
font-weight: 500;
color: #374151;
margin-right: 8px;
}
.tab-close {
color: #9ca3af;
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 2px;
border-radius: 2px;
transition: all 0.2s ease;
}
.tab-close:hover {
background: #f3f4f6;
color: #374151;
}
.editor-controls {
display: flex;
align-items: center;
padding: 0 12px;
gap: 4px;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #6b7280;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.control-btn:hover {
background: #f3f4f6;
color: #374151;
}
/* 编辑器主体 */
.editor-body {
display: flex;
flex: 1;
min-height: 0;
background: #ffffff;
overflow: visible; /* 允许内容正常显示,不被截断 */
}
.editor-body.dark-theme {
background: #1e1e1e;
}
/* 行号区域 */
.line-numbers-area {
background: linear-gradient(180deg, #fafbfc 0%, #f4f6f8 100%);
border-right: 1px solid #e2e8f0;
padding: 16px 0;
min-width: 60px;
overflow: hidden; /* 行号区域保持隐藏溢出,避免水平滚动 */
user-select: none;
font-size: 13px;
line-height: 1.6;
color: #8b949e;
position: relative;
flex-shrink: 0; /* 防止行号区域被压缩 */
}
.dark-theme .line-numbers-area {
background: linear-gradient(180deg, #2d3748 0%, #1a202c 100%);
border-right-color: #4a5568;
color: #718096;
}
.line-number {
text-align: right;
padding: 0 12px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
transition: all 0.2s ease;
}
.line-number.active {
color: #3b82f6;
font-weight: 600;
background: rgba(59, 130, 246, 0.1);
}
.dark-theme .line-number.active {
color: #60a5fa;
background: rgba(96, 165, 250, 0.1);
}
/* 代码区域 */
.code-area {
flex: 1;
min-height: 0;
position: relative;
overflow: visible; /* 确保代码区域不被截断 */
display: flex;
flex-direction: column;
}
/* 可编辑的代码编辑器div - 单层方案,无重影 */
.code-editor-div {
width: 100%;
height: 100%; /* 改为100%高度以充分利用父容器空间 */
min-height: 200px; /* 设置最小高度确保可见 */
padding: 16px 20px;
margin: 0;
font-size: 14px;
line-height: 1.6;
font-family: inherit;
color: #24292f;
background: transparent;
border: none;
outline: none;
overflow: auto; /* 确保编辑器内部滚动条正常工作 */
white-space: pre;
caret-color: #3b82f6;
font-variant-ligatures: common-ligatures;
/* 确保可编辑性 */
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
/* 优化渲染性能 */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* 防止内容溢出 */
word-wrap: break-word;
overflow-wrap: break-word;
/* 确保编辑器能够自适应父容器 */
box-sizing: border-box;
}
.word-wrap .code-editor-div {
white-space: pre-wrap;
word-wrap: break-word;
}
/* 确保编辑器在不同容器中都能正确显示 */
.code-editor-div:focus {
outline: none;
}
/* 自适应高度的额外样式 */
.simple-code-editor {
max-height: 100vh; /* 防止超出视口高度 */
}
.editor-body {
min-height: 300px; /* 确保编辑器有足够的最小高度 */
}
.dark-theme .code-editor-div {
color: #f0f6fc;
caret-color: #60a5fa;
}
/* 编辑器底部状态栏 */
.editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-top: 1px solid #e2e8f0;
padding: 6px 16px;
font-size: 12px;
color: #6b7280;
min-height: 32px;
}
.dark-theme .editor-footer {
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
border-top-color: #4a5568;
color: #a0aec0;
}
.status-left,
.status-right {
display: flex;
align-items: center;
gap: 16px;
}
.status-item {
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.status-success {
color: #059669;
}
.dark-theme .status-success {
color: #68d391;
}
/* 语法高亮样式 */
.code-editor-div :deep(.env-comment) {
color: #6a737d;
font-style: italic;
}
.code-editor-div :deep(.env-key) {
color: #005cc5;
font-weight: 600;
}
.code-editor-div :deep(.env-operator) {
color: #d73a49;
font-weight: 600;
}
.code-editor-div :deep(.env-value) {
color: #032f62;
}
/* 深色主题语法高亮 */
.dark-theme .code-editor-div :deep(.env-comment) {
color: #8b949e;
}
.dark-theme .code-editor-div :deep(.env-key) {
color: #79c0ff;
font-weight: 600;
}
.dark-theme .code-editor-div :deep(.env-operator) {
color: #ff7b72;
font-weight: 600;
}
.dark-theme .code-editor-div :deep(.env-value) {
color: #a5d6ff;
}
/* JavaScript 语法高亮样式 */
.code-editor-div :deep(.js-keyword) {
color: #d73a49;
font-weight: 600;
}
.code-editor-div :deep(.js-string) {
color: #032f62;
}
.code-editor-div :deep(.js-comment) {
color: #6a737d;
font-style: italic;
}
/* PHP 语法高亮样式 */
.code-editor-div :deep(.php-tag) {
color: #d73a49;
font-weight: 600;
}
.code-editor-div :deep(.php-keyword) {
color: #6f42c1;
font-weight: 600;
}
.code-editor-div :deep(.php-variable) {
color: #e36209;
}
.code-editor-div :deep(.php-string) {
color: #032f62;
}
.code-editor-div :deep(.php-comment) {
color: #6a737d;
font-style: italic;
}
/* 深色主题 JavaScript */
.dark-theme .code-editor-div :deep(.js-keyword) {
color: #ff7b72;
font-weight: 600;
}
.dark-theme .code-editor-div :deep(.js-string) {
color: #a5d6ff;
}
.dark-theme .code-editor-div :deep(.js-comment) {
color: #8b949e;
font-style: italic;
}
/* 深色主题 PHP */
.dark-theme .code-editor-div :deep(.php-tag) {
color: #ff7b72;
font-weight: 600;
}
.dark-theme .code-editor-div :deep(.php-keyword) {
color: #d2a8ff;
font-weight: 600;
}
.dark-theme .code-editor-div :deep(.php-variable) {
color: #ffa657;
}
.dark-theme .code-editor-div :deep(.php-string) {
color: #a5d6ff;
}
.dark-theme .code-editor-div :deep(.php-comment) {
color: #8b949e;
font-style: italic;
}
/* 滚动条样式 - 只为代码编辑器设置滚动条 */
.code-editor-div::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.code-editor-div::-webkit-scrollbar-track {
background: #f6f8fa;
border-radius: 6px;
}
.code-editor-div::-webkit-scrollbar-thumb {
background: #d0d7de;
border-radius: 6px;
border: 2px solid #f6f8fa;
}
.code-editor-div::-webkit-scrollbar-thumb:hover {
background: #a8b3c1;
}
/* 深色主题滚动条样式 */
.dark-theme .code-editor-div::-webkit-scrollbar-track {
background: #21262d;
}
.dark-theme .code-editor-div::-webkit-scrollbar-thumb {
background: #484f58;
border-color: #21262d;
}
.dark-theme .code-editor-div::-webkit-scrollbar-thumb:hover {
background: #6e7681;
}
/* 选择文本样式 */
.code-editor-div::selection {
background: rgba(59, 130, 246, 0.2);
}
.dark-theme .code-editor-div::selection {
background: rgba(96, 165, 250, 0.3);
}
/* placeholder样式 */
.code-editor-div:empty:before {
content: attr(data-placeholder);
color: #8b949e;
font-style: italic;
pointer-events: none;
}
.dark-theme .code-editor-div:empty:before {
color: #6e7681;
}
/* 响应式设计 */
@media (max-width: 768px) {
.editor-header {
padding: 0 8px;
}
.tab {
padding: 6px 12px;
}
.tab-name {
font-size: 12px;
}
.line-numbers-area {
min-width: 50px;
}
.line-number {
padding: 0 8px;
}
.code-editor-div {
padding: 12px 16px;
font-size: 13px;
}
.status-left,
.status-right {
gap: 12px;
}
}
</style>