first commit
454
static/css/rich-editor.css
Normal file
@@ -0,0 +1,454 @@
|
||||
/* ========== Rich Editor 样式 ========== */
|
||||
|
||||
/* 工具栏 */
|
||||
.re-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.re-toolbar.re-compact {
|
||||
padding: 4px 6px;
|
||||
gap: 1px;
|
||||
}
|
||||
.re-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.re-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
}
|
||||
.re-compact .re-btn {
|
||||
width: 26px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.re-bold { font-weight: 700; }
|
||||
.re-italic { font-style: italic; }
|
||||
.re-code { font-family: Consolas, monospace; font-size: 11px; }
|
||||
.re-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: #e2e8f0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.re-dropdown-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 面板通用 */
|
||||
.re-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px -4px rgba(0,0,0,0.12);
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* 颜色面板 */
|
||||
.re-color-panel {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 4px;
|
||||
width: 240px;
|
||||
}
|
||||
.re-swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.re-swatch:hover {
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* 字体面板 */
|
||||
.re-font-panel {
|
||||
padding: 6px;
|
||||
width: 180px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.re-font-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.re-font-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Emoji 面板 */
|
||||
.re-emoji-panel {
|
||||
width: 300px;
|
||||
padding: 0;
|
||||
}
|
||||
.re-emoji-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
overflow-x: auto;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.re-emoji-tab {
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.re-emoji-tab:hover { color: #334155; }
|
||||
.re-emoji-tab.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
.re-emoji-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
}
|
||||
.re-emoji-item {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.re-emoji-item:hover {
|
||||
background: #f1f5f9;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* 数学公式面板 */
|
||||
.re-math-panel {
|
||||
width: 380px;
|
||||
padding: 0;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.re-math-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
padding: 8px 10px 4px;
|
||||
}
|
||||
.re-math-panel .re-emoji-tabs {
|
||||
padding: 0 4px;
|
||||
}
|
||||
.re-math-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
.re-math-item {
|
||||
padding: 6px 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
color: #334155;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.re-math-item:hover {
|
||||
background: #eff6ff;
|
||||
border-color: #93c5fd;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.re-math-wrap-btn {
|
||||
display: block;
|
||||
width: calc(100% - 16px);
|
||||
margin: 0 8px 8px;
|
||||
padding: 6px;
|
||||
border: 1px dashed #93c5fd;
|
||||
background: #eff6ff;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.re-math-wrap-btn:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
.re-math-preview {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
margin: 0 8px 8px;
|
||||
}
|
||||
.re-math-preview-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.re-math-preview-content {
|
||||
min-height: 24px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 行内代码样式 */
|
||||
.re-inline-code {
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
/* 隐藏类 */
|
||||
.re-panel.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 实时预览面板 */
|
||||
.re-live-preview {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
padding: 0;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.re-pv-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
padding: 6px 10px 2px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
.re-pv-body {
|
||||
padding: 4px 10px 8px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.re-pv-body img {
|
||||
max-width: 100%;
|
||||
max-height: 120px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.re-pv-body strong { font-weight: 700; }
|
||||
.re-pv-body em { font-style: italic; }
|
||||
.re-pv-body .katex { font-size: 1.05em; }
|
||||
|
||||
/* ========== 数学公式编辑器弹窗 ========== */
|
||||
.re-math-editor-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 9990;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: reMathFadeIn 0.2s ease;
|
||||
}
|
||||
.re-math-editor-overlay.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@keyframes reMathFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes reMathScaleIn {
|
||||
from { opacity: 0; transform: scale(0.92); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.re-math-editor-modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px -12px rgba(0,0,0,0.25);
|
||||
width: 100%;
|
||||
max-width: 672px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: reMathScaleIn 0.25s ease;
|
||||
}
|
||||
.re-math-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.re-math-editor-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.re-math-editor-close:hover {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.re-math-editor-body {
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.re-math-editor-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
resize: vertical;
|
||||
transition: border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.re-math-editor-input:focus {
|
||||
outline: none;
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
||||
}
|
||||
.re-math-editor-preview {
|
||||
min-height: 60px;
|
||||
padding: 14px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
font-size: 16px;
|
||||
color: #334155;
|
||||
}
|
||||
.re-math-editor-preview .katex-display {
|
||||
margin: 0;
|
||||
}
|
||||
.re-math-editor-templates {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.re-math-editor-tpl-card {
|
||||
padding: 10px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.re-math-editor-tpl-card:hover {
|
||||
border-color: #93c5fd;
|
||||
background: #eff6ff;
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.1);
|
||||
}
|
||||
.re-math-editor-tpl-tex {
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
overflow: hidden;
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.re-math-editor-tpl-tex .katex {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.re-math-editor-tpl-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.re-math-editor-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
.re-math-editor-btn-cancel {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.re-math-editor-btn-cancel:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.re-math-editor-btn-confirm {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
background: #3b82f6;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.re-math-editor-btn-confirm:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.re-fx-btn {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
184
static/css/style.css
Normal file
@@ -0,0 +1,184 @@
|
||||
/* 自定义样式 */
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(148, 163, 184, 0.4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(148, 163, 184, 0.7);
|
||||
}
|
||||
|
||||
/* 高级玻璃拟态效果 */
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.glass-panel-dark {
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 高级阴影与悬浮动效 */
|
||||
.hover-card-up {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.hover-card-up:hover {
|
||||
transform: translateY(-4px) scale(1.01);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.hover-glow {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.hover-glow:hover {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
/* 文本渐变发光 */
|
||||
.text-gradient-glow {
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: 0 0 20px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 渐变动画边框 */
|
||||
.animated-border {
|
||||
position: relative;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.animated-border::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(45deg, #3b82f6, #8b5cf6, #ec4899, #3b82f6);
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-move 3s linear infinite;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.animated-border:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
@keyframes gradient-move {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
/* 背景流动网格 */
|
||||
.bg-grid-pattern {
|
||||
background-size: 40px 40px;
|
||||
background-image: linear-gradient(to right, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
|
||||
}
|
||||
.bg-grid-pattern-dark {
|
||||
background-size: 40px 40px;
|
||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* 通用过渡动画 */
|
||||
.transition-all-200 {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.transition-all-300 {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 拖拽排序 */
|
||||
.question-item {
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.question-item[draggable="true"]:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.question-item.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.drag-handle {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* 选项选中高亮 */
|
||||
.option-label:has(input:checked) {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
/* 计时器闪烁 */
|
||||
@keyframes timer-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.animate-pulse {
|
||||
animation: timer-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 题号导航按钮 */
|
||||
#nav-panel button,
|
||||
#nav-panel-mobile button {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
/* 进度条动画 */
|
||||
#progress-bar {
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* 批改页面底部栏 */
|
||||
.sticky.bottom-4 {
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(255,255,255,0.95);
|
||||
}
|
||||
|
||||
/* 提交列表左侧颜色条 */
|
||||
.border-l-4.border-l-green-400 {
|
||||
border-left: 4px solid #4ade80;
|
||||
}
|
||||
.border-l-4.border-l-yellow-400 {
|
||||
border-left: 4px solid #facc15;
|
||||
}
|
||||
|
||||
/* 快速给分按钮 */
|
||||
button[onclick^="quickScore"] {
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
button[onclick^="quickScore"]:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 640px) {
|
||||
.sticky.top-0 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
#nav-panel-mobile {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
736
static/js/rich-editor.js
Normal file
@@ -0,0 +1,736 @@
|
||||
/**
|
||||
* RichEditor - 共享富文本工具栏组件
|
||||
* 用于聊天和论坛的输入区域
|
||||
*/
|
||||
|
||||
// ========== 常量 ==========
|
||||
const RE_FONTS = [
|
||||
{ label: '默认', value: '' },
|
||||
{ label: '宋体', value: '宋体,SimSun' },
|
||||
{ label: '黑体', value: '黑体,SimHei' },
|
||||
{ label: '楷体', value: '楷体,KaiTi' },
|
||||
{ label: '仿宋', value: '仿宋,FangSong' },
|
||||
{ label: '微软雅黑', value: '微软雅黑,Microsoft YaHei' },
|
||||
{ label: '华文行楷', value: '华文行楷,STXingkai' },
|
||||
{ label: '华文新魏', value: '华文新魏,STXinwei' },
|
||||
{ label: '隶书', value: '隶书,LiSu' },
|
||||
{ label: '幼圆', value: '幼圆,YouYuan' },
|
||||
{ label: 'Consolas', value: 'Consolas,monospace' },
|
||||
{ label: 'Georgia', value: 'Georgia,serif' },
|
||||
{ label: 'Comic Sans', value: 'Comic Sans MS,cursive' },
|
||||
{ label: 'Times New Roman', value: 'Times New Roman,serif' },
|
||||
];
|
||||
|
||||
const RE_EMOJIS = {
|
||||
'表情': ['😀','😁','😂','🤣','😊','😍','🥰','😘','😜','🤪'],
|
||||
'手势': ['👍','👎','👏','🙌','🤝','✌️','🤞','👌','🫶','🙏'],
|
||||
'心形': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','💖','💝'],
|
||||
'动物': ['🐱','🐶','🐼','🦊','🐰','🐸','🐵','🦁','🐯','🐮'],
|
||||
'物品': ['🎉','🎊','🔥','⭐','💡','📚','✏️','🏆','🎯','💻'],
|
||||
'符号': ['✅','❌','⚠️','💯','🔴','🟢','🔵','⬆️','⬇️','➡️'],
|
||||
};
|
||||
|
||||
const RE_COLORS = [
|
||||
'#ef4444','#f97316','#eab308','#22c55e','#06b6d4',
|
||||
'#3b82f6','#8b5cf6','#ec4899','#6b7280','#000000',
|
||||
'#dc2626','#ea580c','#ca8a04','#16a34a','#0891b2',
|
||||
'#2563eb','#7c3aed','#db2777','#9ca3af','#ffffff',
|
||||
];
|
||||
|
||||
const RE_MATH_SYMBOLS = {
|
||||
'基础运算': [
|
||||
{ label: '+', tex: '+' },
|
||||
{ label: '−', tex: '-' },
|
||||
{ label: '±', tex: '\\pm' },
|
||||
{ label: '∓', tex: '\\mp' },
|
||||
{ label: '×', tex: '\\times' },
|
||||
{ label: '÷', tex: '\\div' },
|
||||
{ label: '·', tex: '\\cdot' },
|
||||
{ label: '=', tex: '=' },
|
||||
{ label: '≠', tex: '\\neq' },
|
||||
{ label: '≈', tex: '\\approx' },
|
||||
{ label: '≡', tex: '\\equiv' },
|
||||
{ label: '<', tex: '<' },
|
||||
{ label: '>', tex: '>' },
|
||||
{ label: '≤', tex: '\\leq' },
|
||||
{ label: '≥', tex: '\\geq' },
|
||||
{ label: '≪', tex: '\\ll' },
|
||||
{ label: '≫', tex: '\\gg' },
|
||||
{ label: '∝', tex: '\\propto' },
|
||||
],
|
||||
'上下标': [
|
||||
{ label: 'x²', tex: '^{2}' },
|
||||
{ label: 'x³', tex: '^{3}' },
|
||||
{ label: 'xⁿ', tex: '^{}', cursor: -1 },
|
||||
{ label: 'x₁', tex: '_{}', cursor: -1 },
|
||||
{ label: 'xₙ', tex: '_{n}' },
|
||||
{ label: 'x₁²', tex: '_{}^{}', cursor: -4 },
|
||||
],
|
||||
'分数根号': [
|
||||
{ label: '分数', tex: '\\frac{}{}', cursor: -3 },
|
||||
{ label: '√', tex: '\\sqrt{}', cursor: -1 },
|
||||
{ label: 'ⁿ√', tex: '\\sqrt[n]{}', cursor: -1 },
|
||||
{ label: 'ᵃ⁄ᵦ', tex: '\\dfrac{}{}', cursor: -3 },
|
||||
],
|
||||
'希腊字母': [
|
||||
{ label: 'α', tex: '\\alpha' },
|
||||
{ label: 'β', tex: '\\beta' },
|
||||
{ label: 'γ', tex: '\\gamma' },
|
||||
{ label: 'δ', tex: '\\delta' },
|
||||
{ label: 'ε', tex: '\\epsilon' },
|
||||
{ label: 'ζ', tex: '\\zeta' },
|
||||
{ label: 'η', tex: '\\eta' },
|
||||
{ label: 'θ', tex: '\\theta' },
|
||||
{ label: 'λ', tex: '\\lambda' },
|
||||
{ label: 'μ', tex: '\\mu' },
|
||||
{ label: 'ξ', tex: '\\xi' },
|
||||
{ label: 'π', tex: '\\pi' },
|
||||
{ label: 'ρ', tex: '\\rho' },
|
||||
{ label: 'σ', tex: '\\sigma' },
|
||||
{ label: 'τ', tex: '\\tau' },
|
||||
{ label: 'φ', tex: '\\varphi' },
|
||||
{ label: 'ω', tex: '\\omega' },
|
||||
{ label: 'Δ', tex: '\\Delta' },
|
||||
{ label: 'Σ', tex: '\\Sigma' },
|
||||
{ label: 'Ω', tex: '\\Omega' },
|
||||
{ label: 'Φ', tex: '\\Phi' },
|
||||
{ label: 'Π', tex: '\\Pi' },
|
||||
{ label: 'Λ', tex: '\\Lambda' },
|
||||
{ label: 'Γ', tex: '\\Gamma' },
|
||||
],
|
||||
'高等数学': [
|
||||
{ label: '∑', tex: '\\sum_{i=1}^{n}', cursor: 0 },
|
||||
{ label: '∏', tex: '\\prod_{i=1}^{n}', cursor: 0 },
|
||||
{ label: '∫', tex: '\\int_{a}^{b}', cursor: 0 },
|
||||
{ label: '∬', tex: '\\iint' },
|
||||
{ label: '∮', tex: '\\oint' },
|
||||
{ label: 'lim', tex: '\\lim_{x \\to }', cursor: -1 },
|
||||
{ label: '∞', tex: '\\infty' },
|
||||
{ label: 'log', tex: '\\log_{}', cursor: -1 },
|
||||
{ label: 'ln', tex: '\\ln' },
|
||||
{ label: 'sin', tex: '\\sin' },
|
||||
{ label: 'cos', tex: '\\cos' },
|
||||
{ label: 'tan', tex: '\\tan' },
|
||||
{ label: 'arcsin', tex: '\\arcsin' },
|
||||
{ label: 'arccos', tex: '\\arccos' },
|
||||
{ label: 'arctan', tex: '\\arctan' },
|
||||
{ label: '∂', tex: '\\partial' },
|
||||
{ label: '∇', tex: '\\nabla' },
|
||||
{ label: 'dx', tex: '\\mathrm{d}x' },
|
||||
],
|
||||
'集合逻辑': [
|
||||
{ label: '∈', tex: '\\in' },
|
||||
{ label: '∉', tex: '\\notin' },
|
||||
{ label: '⊂', tex: '\\subset' },
|
||||
{ label: '⊃', tex: '\\supset' },
|
||||
{ label: '⊆', tex: '\\subseteq' },
|
||||
{ label: '⊇', tex: '\\supseteq' },
|
||||
{ label: '∪', tex: '\\cup' },
|
||||
{ label: '∩', tex: '\\cap' },
|
||||
{ label: '∅', tex: '\\emptyset' },
|
||||
{ label: '∀', tex: '\\forall' },
|
||||
{ label: '∃', tex: '\\exists' },
|
||||
{ label: '¬', tex: '\\neg' },
|
||||
{ label: '∧', tex: '\\land' },
|
||||
{ label: '∨', tex: '\\lor' },
|
||||
{ label: '⇒', tex: '\\Rightarrow' },
|
||||
{ label: '⇔', tex: '\\Leftrightarrow' },
|
||||
{ label: '→', tex: '\\to' },
|
||||
{ label: '↦', tex: '\\mapsto' },
|
||||
],
|
||||
'括号矩阵': [
|
||||
{ label: '()', tex: '\\left( \\right)', cursor: -8 },
|
||||
{ label: '[]', tex: '\\left[ \\right]', cursor: -8 },
|
||||
{ label: '{}', tex: '\\left\\{ \\right\\}', cursor: -9 },
|
||||
{ label: '||', tex: '\\left| \\right|', cursor: -8 },
|
||||
{ label: '⌈⌉', tex: '\\lceil \\rceil', cursor: -7 },
|
||||
{ label: '⌊⌋', tex: '\\lfloor \\rfloor', cursor: -8 },
|
||||
{ label: '矩阵', tex: '\\begin{pmatrix} \\\\ \\end{pmatrix}', cursor: -18 },
|
||||
{ label: '行列式', tex: '\\begin{vmatrix} \\\\ \\end{vmatrix}', cursor: -18 },
|
||||
{ label: '方程组', tex: '\\begin{cases} \\\\ \\end{cases}', cursor: -15 },
|
||||
],
|
||||
'其他符号': [
|
||||
{ label: '…', tex: '\\cdots' },
|
||||
{ label: '⋮', tex: '\\vdots' },
|
||||
{ label: '⋱', tex: '\\ddots' },
|
||||
{ label: '°', tex: '^{\\circ}' },
|
||||
{ label: '‰', tex: '\\permil' },
|
||||
{ label: '→', tex: '\\vec{}', cursor: -1 },
|
||||
{ label: 'â', tex: '\\hat{}', cursor: -1 },
|
||||
{ label: 'ā', tex: '\\bar{}', cursor: -1 },
|
||||
{ label: 'ã', tex: '\\tilde{}', cursor: -1 },
|
||||
{ label: '⊥', tex: '\\perp' },
|
||||
{ label: '∥', tex: '\\parallel' },
|
||||
{ label: '∠', tex: '\\angle' },
|
||||
{ label: '△', tex: '\\triangle' },
|
||||
{ label: '□', tex: '\\square' },
|
||||
{ label: '⊕', tex: '\\oplus' },
|
||||
{ label: '⊗', tex: '\\otimes' },
|
||||
{ label: '★', tex: '\\star' },
|
||||
{ label: '♠', tex: '\\spadesuit' },
|
||||
],
|
||||
};
|
||||
|
||||
const RE_FORMULA_TEMPLATES = [
|
||||
{ label: '二次公式', tex: 'x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}' },
|
||||
{ label: '勾股定理', tex: 'a^2 + b^2 = c^2' },
|
||||
{ label: '求和公式', tex: '\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}' },
|
||||
{ label: '二项式定理', tex: '(a+b)^n = \\sum_{k=0}^{n} \\binom{n}{k} a^{n-k} b^k' },
|
||||
{ label: '欧拉公式', tex: 'e^{i\\pi} + 1 = 0' },
|
||||
{ label: '三角恒等式', tex: '\\sin^2\\theta + \\cos^2\\theta = 1' },
|
||||
{ label: '对数换底', tex: '\\log_a b = \\frac{\\ln b}{\\ln a}' },
|
||||
{ label: '导数定义', tex: "f'(x) = \\lim_{h \\to 0} \\frac{f(x+h) - f(x)}{h}" },
|
||||
{ label: '定积分', tex: '\\int_a^b f(x)\\,dx = F(b) - F(a)' },
|
||||
{ label: '矩阵乘法', tex: '\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix} \\begin{pmatrix} e \\\\ f \\end{pmatrix} = \\begin{pmatrix} ae+bf \\\\ ce+df \\end{pmatrix}' },
|
||||
{ label: '方程组', tex: '\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}' },
|
||||
{ label: '极限', tex: '\\lim_{x \\to \\infty} \\left(1 + \\frac{1}{x}\\right)^x = e' },
|
||||
];
|
||||
|
||||
// ========== RichEditor 类 ==========
|
||||
class RichEditor {
|
||||
constructor(textareaId, options = {}) {
|
||||
this.ta = document.getElementById(textareaId);
|
||||
if (!this.ta) return;
|
||||
this.compact = options.compact || false; // 紧凑模式(聊天用)
|
||||
this.onEnter = options.onEnter || null;
|
||||
this._buildToolbar();
|
||||
this._buildPreview();
|
||||
this._bindKeys();
|
||||
this._bindPreviewUpdate();
|
||||
}
|
||||
|
||||
_buildToolbar() {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 're-toolbar' + (this.compact ? ' re-compact' : '');
|
||||
|
||||
// 粗体
|
||||
bar.appendChild(this._btn('B', '粗体', 're-bold', () => this._wrap('**', '**')));
|
||||
// 斜体
|
||||
bar.appendChild(this._btn('I', '斜体', 're-italic', () => this._wrap('*', '*')));
|
||||
// 代码
|
||||
bar.appendChild(this._btn('<>', '代码', 're-code', () => this._wrap('`', '`')));
|
||||
bar.appendChild(this._sep());
|
||||
|
||||
// 颜色
|
||||
const colorWrap = document.createElement('div');
|
||||
colorWrap.className = 're-dropdown-wrap';
|
||||
const colorBtn = this._btn('A', '文字颜色', 're-color-btn', () => this._togglePanel(colorWrap));
|
||||
colorBtn.innerHTML = '<span style="border-bottom:2px solid #ef4444;padding-bottom:1px">A</span>';
|
||||
colorWrap.appendChild(colorBtn);
|
||||
colorWrap.appendChild(this._buildColorPanel(colorBtn));
|
||||
bar.appendChild(colorWrap);
|
||||
|
||||
// 字体
|
||||
const fontWrap = document.createElement('div');
|
||||
fontWrap.className = 're-dropdown-wrap';
|
||||
fontWrap.appendChild(this._btn('字', '字体', 're-font-btn', () => this._togglePanel(fontWrap)));
|
||||
fontWrap.appendChild(this._buildFontPanel());
|
||||
bar.appendChild(fontWrap);
|
||||
bar.appendChild(this._sep());
|
||||
|
||||
// Emoji
|
||||
const emojiWrap = document.createElement('div');
|
||||
emojiWrap.className = 're-dropdown-wrap';
|
||||
emojiWrap.appendChild(this._btn('😊', 'Emoji', 're-emoji-btn', () => this._togglePanel(emojiWrap)));
|
||||
emojiWrap.appendChild(this._buildEmojiPanel());
|
||||
bar.appendChild(emojiWrap);
|
||||
|
||||
// 公式编辑器按钮
|
||||
bar.appendChild(this._sep());
|
||||
const self = this;
|
||||
bar.appendChild(this._btn('fx', '公式编辑器', 're-fx-btn', () => openMathEditor(self.ta)));
|
||||
|
||||
this.ta.parentNode.insertBefore(bar, this.ta);
|
||||
this.toolbar = bar;
|
||||
|
||||
// 点击外部关闭面板
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!bar.contains(e.target)) {
|
||||
bar.querySelectorAll('.re-panel').forEach(p => p.classList.add('hidden'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_btn(html, title, cls, onclick) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 're-btn ' + (cls || '');
|
||||
b.title = title;
|
||||
b.innerHTML = html;
|
||||
b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onclick(); });
|
||||
return b;
|
||||
}
|
||||
|
||||
_sep() {
|
||||
const s = document.createElement('span');
|
||||
s.className = 're-sep';
|
||||
return s;
|
||||
}
|
||||
|
||||
_togglePanel(wrap) {
|
||||
const panel = wrap.querySelector('.re-panel');
|
||||
const wasHidden = panel.classList.contains('hidden');
|
||||
this.toolbar.querySelectorAll('.re-panel').forEach(p => p.classList.add('hidden'));
|
||||
if (wasHidden) panel.classList.remove('hidden');
|
||||
}
|
||||
|
||||
_wrap(pre, suf) {
|
||||
const s = this.ta.selectionStart, e = this.ta.selectionEnd;
|
||||
const txt = this.ta.value;
|
||||
const sel = txt.substring(s, e) || '文本';
|
||||
this.ta.value = txt.substring(0, s) + pre + sel + suf + txt.substring(e);
|
||||
this.ta.focus();
|
||||
this.ta.selectionStart = s + pre.length;
|
||||
this.ta.selectionEnd = s + pre.length + sel.length;
|
||||
this._refreshPreview();
|
||||
}
|
||||
|
||||
_insertAt(text, cursorOffset) {
|
||||
const s = this.ta.selectionStart;
|
||||
const v = this.ta.value;
|
||||
this.ta.value = v.substring(0, s) + text + v.substring(s);
|
||||
this.ta.focus();
|
||||
this.ta.selectionStart = this.ta.selectionEnd = s + text.length + (cursorOffset || 0);
|
||||
this._refreshPreview();
|
||||
}
|
||||
|
||||
_bindKeys() {
|
||||
if (!this.onEnter) return;
|
||||
this.ta.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.onEnter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_buildPreview() {
|
||||
// 实时预览面板,插在 textarea 后面
|
||||
const pv = document.createElement('div');
|
||||
pv.className = 're-live-preview';
|
||||
pv.innerHTML = '<div class="re-pv-label">预览</div><div class="re-pv-body"></div>';
|
||||
// 插到 textarea 的父容器末尾(border 容器内)
|
||||
this.ta.parentNode.appendChild(pv);
|
||||
this.previewEl = pv.querySelector('.re-pv-body');
|
||||
}
|
||||
|
||||
_bindPreviewUpdate() {
|
||||
let timer = null;
|
||||
const update = () => {
|
||||
if (!this.previewEl) return;
|
||||
const val = this.ta.value.trim();
|
||||
if (!val) { this.previewEl.innerHTML = '<span style="color:#94a3b8;font-size:13px">输入内容后这里会显示实时预览...</span>'; return; }
|
||||
this.previewEl.innerHTML = renderRichContent(val);
|
||||
};
|
||||
this.ta.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(update, 150); });
|
||||
update();
|
||||
}
|
||||
|
||||
_refreshPreview() {
|
||||
if (!this.previewEl) return;
|
||||
const val = this.ta.value.trim();
|
||||
if (!val) { this.previewEl.innerHTML = '<span style="color:#94a3b8;font-size:13px">输入内容后这里会显示实时预览...</span>'; return; }
|
||||
this.previewEl.innerHTML = renderRichContent(val);
|
||||
}
|
||||
|
||||
// ===== 颜色面板 =====
|
||||
_buildColorPanel(colorBtn) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 're-panel re-color-panel hidden';
|
||||
RE_COLORS.forEach(c => {
|
||||
const swatch = document.createElement('button');
|
||||
swatch.type = 'button';
|
||||
swatch.className = 're-swatch';
|
||||
swatch.style.background = c;
|
||||
if (c === '#ffffff') swatch.style.border = '1px solid #e2e8f0';
|
||||
swatch.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._wrap(`[color:${c}]`, '[/color]');
|
||||
colorBtn.querySelector('span').style.borderBottomColor = c;
|
||||
panel.classList.add('hidden');
|
||||
});
|
||||
panel.appendChild(swatch);
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
// ===== 字体面板 =====
|
||||
_buildFontPanel() {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 're-panel re-font-panel hidden';
|
||||
RE_FONTS.forEach(f => {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 're-font-item';
|
||||
item.textContent = f.label;
|
||||
if (f.value) item.style.fontFamily = f.value;
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (f.value) {
|
||||
this._wrap(`[font:${f.label}]`, '[/font]');
|
||||
}
|
||||
panel.classList.add('hidden');
|
||||
});
|
||||
panel.appendChild(item);
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
// ===== Emoji 面板 =====
|
||||
_buildEmojiPanel() {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 're-panel re-emoji-panel hidden';
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 're-emoji-tabs';
|
||||
const body = document.createElement('div');
|
||||
body.className = 're-emoji-body';
|
||||
|
||||
const categories = Object.keys(RE_EMOJIS);
|
||||
categories.forEach((cat, i) => {
|
||||
const tab = document.createElement('button');
|
||||
tab.type = 'button';
|
||||
tab.className = 're-emoji-tab' + (i === 0 ? ' active' : '');
|
||||
tab.textContent = cat;
|
||||
tab.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
tabs.querySelectorAll('.re-emoji-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
this._showEmojiCat(body, cat);
|
||||
});
|
||||
tabs.appendChild(tab);
|
||||
});
|
||||
|
||||
panel.appendChild(tabs);
|
||||
panel.appendChild(body);
|
||||
this._showEmojiCat(body, categories[0]);
|
||||
return panel;
|
||||
}
|
||||
|
||||
_showEmojiCat(body, cat) {
|
||||
body.innerHTML = '';
|
||||
RE_EMOJIS[cat].forEach(em => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 're-emoji-item';
|
||||
btn.textContent = em;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._insertAt(em);
|
||||
});
|
||||
body.appendChild(btn);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== MathFormulaEditor 单例类 ==========
|
||||
class MathFormulaEditor {
|
||||
constructor() {
|
||||
if (MathFormulaEditor._instance) return MathFormulaEditor._instance;
|
||||
MathFormulaEditor._instance = this;
|
||||
this._built = false;
|
||||
this._target = null;
|
||||
this._onInsert = null;
|
||||
this._debounceTimer = null;
|
||||
}
|
||||
|
||||
open(targetTextarea, onInsert) {
|
||||
this._target = targetTextarea;
|
||||
this._onInsert = onInsert || null;
|
||||
if (!this._built) this._buildDOM();
|
||||
this._input.value = '';
|
||||
this._previewEl.innerHTML = '<span style="color:#94a3b8">在上方输入 LaTeX 公式,这里实时预览</span>';
|
||||
this._overlay.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
// 激活第一个符号分类 tab
|
||||
const firstTab = this._symbolTabs.querySelector('.re-emoji-tab');
|
||||
if (firstTab) firstTab.click();
|
||||
setTimeout(() => this._input.focus(), 50);
|
||||
}
|
||||
|
||||
close() {
|
||||
this._overlay.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
_buildDOM() {
|
||||
this._built = true;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 're-math-editor-overlay hidden';
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 're-math-editor-modal';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 're-math-editor-header';
|
||||
header.innerHTML = '<span style="font-weight:600;font-size:16px;color:#1e293b">数学公式编辑器</span>';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 're-math-editor-close';
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
header.appendChild(closeBtn);
|
||||
modal.appendChild(header);
|
||||
|
||||
// Body
|
||||
const body = document.createElement('div');
|
||||
body.className = 're-math-editor-body';
|
||||
|
||||
// LaTeX input
|
||||
const inputLabel = document.createElement('div');
|
||||
inputLabel.style.cssText = 'font-size:12px;color:#64748b;margin-bottom:4px;font-weight:500';
|
||||
inputLabel.textContent = 'LaTeX 公式';
|
||||
body.appendChild(inputLabel);
|
||||
const input = document.createElement('textarea');
|
||||
input.className = 're-math-editor-input';
|
||||
input.rows = 3;
|
||||
input.placeholder = '输入 LaTeX 公式,如 \\frac{a}{b}、x^2 + y^2 = r^2';
|
||||
input.addEventListener('input', () => this._updatePreview());
|
||||
this._input = input;
|
||||
body.appendChild(input);
|
||||
|
||||
// Preview
|
||||
const pvLabel = document.createElement('div');
|
||||
pvLabel.style.cssText = 'font-size:12px;color:#64748b;margin:12px 0 4px;font-weight:500';
|
||||
pvLabel.textContent = '实时预览';
|
||||
body.appendChild(pvLabel);
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 're-math-editor-preview';
|
||||
this._previewEl = preview;
|
||||
body.appendChild(preview);
|
||||
|
||||
// Symbol panel
|
||||
const symLabel = document.createElement('div');
|
||||
symLabel.style.cssText = 'font-size:12px;color:#64748b;margin:12px 0 4px;font-weight:500';
|
||||
symLabel.textContent = '符号面板';
|
||||
body.appendChild(symLabel);
|
||||
// PLACEHOLDER_SYMBOL_PANEL
|
||||
const symTabs = document.createElement('div');
|
||||
symTabs.className = 're-emoji-tabs';
|
||||
symTabs.style.cssText = 'border:1px solid #e2e8f0;border-radius:8px 8px 0 0';
|
||||
this._symbolTabs = symTabs;
|
||||
const symGrid = document.createElement('div');
|
||||
symGrid.className = 're-math-grid';
|
||||
symGrid.style.cssText = 'border:1px solid #e2e8f0;border-top:none;border-radius:0 0 8px 8px;margin-bottom:0';
|
||||
const categories = Object.keys(RE_MATH_SYMBOLS);
|
||||
categories.forEach((cat, i) => {
|
||||
const tab = document.createElement('button');
|
||||
tab.type = 'button';
|
||||
tab.className = 're-emoji-tab' + (i === 0 ? ' active' : '');
|
||||
tab.textContent = cat;
|
||||
tab.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
symTabs.querySelectorAll('.re-emoji-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
this._showSymCat(symGrid, cat);
|
||||
});
|
||||
symTabs.appendChild(tab);
|
||||
});
|
||||
body.appendChild(symTabs);
|
||||
body.appendChild(symGrid);
|
||||
this._showSymCat(symGrid, categories[0]);
|
||||
|
||||
// Template section
|
||||
const tplLabel = document.createElement('div');
|
||||
tplLabel.style.cssText = 'font-size:12px;color:#64748b;margin:14px 0 6px;font-weight:500';
|
||||
tplLabel.textContent = '常用公式模板';
|
||||
body.appendChild(tplLabel);
|
||||
const tplGrid = document.createElement('div');
|
||||
tplGrid.className = 're-math-editor-templates';
|
||||
RE_FORMULA_TEMPLATES.forEach(tpl => {
|
||||
const card = document.createElement('button');
|
||||
card.type = 'button';
|
||||
card.className = 're-math-editor-tpl-card';
|
||||
const texDiv = document.createElement('div');
|
||||
texDiv.className = 're-math-editor-tpl-tex';
|
||||
if (window.katex) {
|
||||
try { texDiv.innerHTML = katex.renderToString(tpl.tex, { throwOnError: false, displayMode: false }); }
|
||||
catch(e) { texDiv.textContent = tpl.tex; }
|
||||
} else { texDiv.textContent = tpl.tex; }
|
||||
const labelDiv = document.createElement('div');
|
||||
labelDiv.className = 're-math-editor-tpl-label';
|
||||
labelDiv.textContent = tpl.label;
|
||||
card.appendChild(texDiv);
|
||||
card.appendChild(labelDiv);
|
||||
card.addEventListener('click', () => this._applyTemplate(tpl.tex));
|
||||
tplGrid.appendChild(card);
|
||||
});
|
||||
body.appendChild(tplGrid);
|
||||
modal.appendChild(body);
|
||||
|
||||
// Footer
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 're-math-editor-footer';
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 're-math-editor-btn-cancel';
|
||||
cancelBtn.textContent = '取消';
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.className = 're-math-editor-btn-confirm';
|
||||
confirmBtn.textContent = '插入公式';
|
||||
confirmBtn.addEventListener('click', () => this._confirm());
|
||||
footer.appendChild(cancelBtn);
|
||||
footer.appendChild(confirmBtn);
|
||||
modal.appendChild(footer);
|
||||
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
this._overlay = overlay;
|
||||
|
||||
// ESC to close
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !this._overlay.classList.contains('hidden')) this.close();
|
||||
});
|
||||
}
|
||||
|
||||
_updatePreview() {
|
||||
clearTimeout(this._debounceTimer);
|
||||
this._debounceTimer = setTimeout(() => {
|
||||
const tex = this._input.value.trim();
|
||||
if (!tex) {
|
||||
this._previewEl.innerHTML = '<span style="color:#94a3b8">在上方输入 LaTeX 公式,这里实时预览</span>';
|
||||
return;
|
||||
}
|
||||
if (window.katex) {
|
||||
try {
|
||||
this._previewEl.innerHTML = katex.renderToString(tex, { throwOnError: true, displayMode: true });
|
||||
} catch(e) {
|
||||
this._previewEl.innerHTML = '<span style="color:#ef4444;font-size:13px">语法错误: ' + e.message.replace(/</g,'<') + '</span>';
|
||||
}
|
||||
} else {
|
||||
this._previewEl.textContent = tex;
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
_insertSymbol(tex, cursor) {
|
||||
const start = this._input.selectionStart;
|
||||
const val = this._input.value;
|
||||
this._input.value = val.substring(0, start) + tex + val.substring(start);
|
||||
this._input.focus();
|
||||
this._input.selectionStart = this._input.selectionEnd = start + tex.length + (cursor || 0);
|
||||
this._updatePreview();
|
||||
}
|
||||
|
||||
_applyTemplate(tex) {
|
||||
this._input.value = tex;
|
||||
this._input.focus();
|
||||
this._input.selectionStart = this._input.selectionEnd = tex.length;
|
||||
this._updatePreview();
|
||||
}
|
||||
|
||||
_confirm() {
|
||||
const tex = this._input.value.trim();
|
||||
if (!tex) { this.close(); return; }
|
||||
const formula = '$' + tex + '$';
|
||||
if (this._target) {
|
||||
const start = this._target.selectionStart || this._target.value.length;
|
||||
const val = this._target.value;
|
||||
this._target.value = val.substring(0, start) + formula + val.substring(start);
|
||||
this._target.selectionStart = this._target.selectionEnd = start + formula.length;
|
||||
this._target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
if (typeof this._onInsert === 'function') this._onInsert(formula);
|
||||
this.close();
|
||||
}
|
||||
|
||||
_showSymCat(grid, cat) {
|
||||
grid.innerHTML = '';
|
||||
RE_MATH_SYMBOLS[cat].forEach(sym => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 're-math-item';
|
||||
btn.textContent = sym.label;
|
||||
btn.title = sym.tex;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._insertSymbol(sym.tex, sym.cursor || 0);
|
||||
});
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openMathEditor(textarea, callback) {
|
||||
new MathFormulaEditor().open(textarea, callback);
|
||||
}
|
||||
|
||||
// ========== 全局渲染函数 ==========
|
||||
const RE_FONT_MAP = {};
|
||||
RE_FONTS.forEach(f => { if (f.value) RE_FONT_MAP[f.label] = f.value; });
|
||||
|
||||
function _reEsc(text) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = text;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function renderRichContent(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// 1. 先提取公式,避免 HTML 转义破坏 LaTeX
|
||||
const formulas = [];
|
||||
let raw = text.replace(/\$([^$\n]+?)\$/g, function(m, tex) {
|
||||
const idx = formulas.length;
|
||||
formulas.push(tex);
|
||||
return '\x00MATH' + idx + '\x00';
|
||||
});
|
||||
|
||||
// 2. HTML 转义
|
||||
let h = _reEsc(raw);
|
||||
|
||||
// 3. 图片标签 [img:url]
|
||||
h = h.replace(/\[img:(\/static\/uploads\/[^\]]+)\]/g,
|
||||
'<img src="$1" class="max-w-full rounded-lg border border-slate-200 my-2 cursor-pointer" style="max-height:400px" onclick="window.open(this.src)">');
|
||||
|
||||
// 4. 还原公式并用 KaTeX 渲染
|
||||
h = h.replace(/\x00MATH(\d+)\x00/g, function(m, idx) {
|
||||
const tex = formulas[parseInt(idx)];
|
||||
if (window.katex) {
|
||||
try {
|
||||
return katex.renderToString(tex, { throwOnError: false });
|
||||
} catch(e) { return '$' + _reEsc(tex) + '$'; }
|
||||
}
|
||||
// KaTeX 未加载时显示原始公式并标记 class 供后续渲染
|
||||
return '<span class="re-pending-math" data-tex="' + _reEsc(tex) + '">$' + _reEsc(tex) + '$</span>';
|
||||
});
|
||||
|
||||
// 5. 粗体 **...**
|
||||
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// 6. 斜体 *...*
|
||||
h = h.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||||
// 7. 行内代码 `...`
|
||||
h = h.replace(/`([^`]+?)`/g, '<code class="re-inline-code">$1</code>');
|
||||
|
||||
// 8. 颜色 [color:#hex]...[/color]
|
||||
h = h.replace(/\[color:(#[0-9a-fA-F]{3,8})\]([\s\S]*?)\[\/color\]/g,
|
||||
'<span style="color:$1">$2</span>');
|
||||
|
||||
// 9. 字体 [font:name]...[/font]
|
||||
h = h.replace(/\[font:([^\]]+)\]([\s\S]*?)\[\/font\]/g, function(m, name, content) {
|
||||
const family = RE_FONT_MAP[name];
|
||||
if (family) return '<span style="font-family:' + _reEsc(family) + '">' + content + '</span>';
|
||||
return content;
|
||||
});
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
// KaTeX 延迟加载后,重新渲染页面上未渲染的公式
|
||||
function _reRenderPendingMath() {
|
||||
if (!window.katex) return;
|
||||
document.querySelectorAll('.re-pending-math').forEach(el => {
|
||||
try {
|
||||
el.innerHTML = katex.renderToString(el.dataset.tex, { throwOnError: false });
|
||||
el.classList.remove('re-pending-math');
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
// 定期检查 KaTeX 是否加载完成
|
||||
(function _waitKatex() {
|
||||
if (window.katex) { _reRenderPendingMath(); return; }
|
||||
setTimeout(_waitKatex, 500);
|
||||
})();
|
||||
BIN
static/uploads/1771815228_6668.jpg
Normal file
|
After Width: | Height: | Size: 493 KiB |
BIN
static/uploads/1771817391_3644.jpg
Normal file
|
After Width: | Height: | Size: 493 KiB |
BIN
static/uploads/pdf_page_03738e6c_3.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
static/uploads/pdf_page_12f39cbe_4.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
static/uploads/pdf_page_165af37b_2.png
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
static/uploads/pdf_page_1d5a8760_1.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
static/uploads/pdf_page_20e063d5_1.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
static/uploads/pdf_page_221de6aa_3.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
static/uploads/pdf_page_24549e30_2.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
static/uploads/pdf_page_28918fb1_1.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
static/uploads/pdf_page_2f7774e7_3.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
static/uploads/pdf_page_4139831a_2.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
static/uploads/pdf_page_450cf3b5_2.png
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
static/uploads/pdf_page_582668b4_2.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
static/uploads/pdf_page_5a3c6d3a_4.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
static/uploads/pdf_page_6ae215b4_2.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
static/uploads/pdf_page_74ee682a_4.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
static/uploads/pdf_page_8d7c735e_4.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
static/uploads/pdf_page_8de9cc81_3.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
static/uploads/pdf_page_9132729b_3.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
static/uploads/pdf_page_9932d23b_2.png
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
static/uploads/pdf_page_9e8a03f5_1.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
static/uploads/pdf_page_b2e7c6a9_1.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
static/uploads/pdf_page_cde8ab54_1.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
static/uploads/pdf_page_cf136549_3.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
static/uploads/pdf_page_d7bd4cc9_4.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
static/uploads/pdf_page_e824a2ea_3.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
static/uploads/pdf_page_f07773ac_4.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
static/uploads/pdf_page_f54ea912_1.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
static/uploads/pdf_page_fe066e89_4.png
Normal file
|
After Width: | Height: | Size: 122 KiB |