use markdown to edit
This commit is contained in:
@@ -801,10 +801,11 @@ function ValidTextareaNoBlankHead(textarea) {
|
||||
// 更新 textarea 的值(如果有变化)
|
||||
textarea.value = lines.join("\n");
|
||||
}
|
||||
const editors = document.querySelectorAll("textarea");
|
||||
const editorsDoc = document.querySelectorAll("textarea");
|
||||
const PREFIX = "### ";
|
||||
// 记录每个 textarea 最新的“标题内容”(不含前缀)
|
||||
const lastTitleMap = new Map();
|
||||
|
||||
// 防止循环触发
|
||||
let isProgrammaticUpdate = false;
|
||||
function getFirstLine(text) {
|
||||
@@ -823,7 +824,7 @@ const lastTitleMap = new Map();
|
||||
return { line: normalized, title };
|
||||
}
|
||||
// 初始化 lastTitleMap(把当前值规范化一次)
|
||||
editors.forEach(ed => {
|
||||
editorsDoc.forEach(ed => {
|
||||
const rawFirst = getFirstLine(ed.value || "");
|
||||
const { line, title } = normalizeFirstLine(rawFirst);
|
||||
if (line !== rawFirst) {
|
||||
@@ -837,7 +838,7 @@ const lastTitleMap = new Map();
|
||||
function syncOthers(sourceEditor, newTitle) {
|
||||
isProgrammaticUpdate = true;
|
||||
try {
|
||||
editors.forEach(ed => {
|
||||
editorsDoc.forEach(ed => {
|
||||
if (ed === sourceEditor) return;
|
||||
const first = getFirstLine(ed.value || "");
|
||||
const { line } = normalizeFirstLine(first);
|
||||
@@ -851,56 +852,204 @@ const lastTitleMap = new Map();
|
||||
isProgrammaticUpdate = false;
|
||||
}
|
||||
}
|
||||
editors.forEach(editor => {
|
||||
editor.addEventListener("input", (event) => {
|
||||
const textarea = event.target
|
||||
const value = event.target.value;
|
||||
const type = event.target.dataset.type;
|
||||
// 在这里做提示或校验
|
||||
console.log(`内容已更改: ${event.target.id} => ${value}`);
|
||||
|
||||
// ==== 前置:你的三编辑器实例与 id 映射(按你实际为准)====
|
||||
const ID_LESSON = 'editor-lesson';
|
||||
const ID_PROMPT = 'editor-prompt';
|
||||
const ID_SCORE = 'editor-score';
|
||||
|
||||
ValidTextareaNoBlankHead(event.target)
|
||||
// editors: { [textareaId]: EasyMDEInstance }
|
||||
const editorIds = [ID_LESSON, ID_PROMPT, ID_SCORE];
|
||||
|
||||
const rawFirst = getFirstLine(value);
|
||||
// 全局防抖/重入保护:避免程序化更新再触发同步
|
||||
|
||||
// // 记录上一次各编辑器的标题文本(不含 "### " 前缀)
|
||||
// const lastTitleMap = new Map();
|
||||
|
||||
// —— 工具:从 EasyMDE 拿值 / 设值(会同步 textarea,因为 forceSync:true)——
|
||||
const getMD = (id) => editors[id].value();
|
||||
const setMD = (id, text) => editors[id].value(text);
|
||||
|
||||
// —— 工具:仅替换“首行”为 normalizedLine,尽量不破坏光标与撤销栈 ——
|
||||
// line 是完整首行文本(含 "### ")
|
||||
function setFirstLineInEditor(ed, normalizedLine) {
|
||||
const cm = ed.codemirror;
|
||||
cm.operation(() => {
|
||||
const doc = cm.getDoc();
|
||||
const firstLine = doc.getLine(0) ?? "";
|
||||
if (firstLine === normalizedLine) return;
|
||||
|
||||
// 记住用户光标
|
||||
const cursors = doc.listSelections();
|
||||
|
||||
// 用 replaceRange 仅替换首行
|
||||
doc.replaceRange(
|
||||
normalizedLine,
|
||||
{ line: 0, ch: 0 },
|
||||
{ line: 0, ch: firstLine.length }
|
||||
);
|
||||
|
||||
// 尽力恢复光标(如果原先在首行,偏移可能要校正)
|
||||
doc.setSelections(cursors);
|
||||
});
|
||||
}
|
||||
|
||||
// —— 工具:从 Markdown 文本取首行(兼容你现有函数)
|
||||
function getFirstLineFromText(text) {
|
||||
const idx = text.indexOf('\n');
|
||||
return idx === -1 ? text : text.slice(0, idx);
|
||||
}
|
||||
|
||||
// —— 工具:把 normalizedLine 写回文本首行(备用:整段 setValue)——
|
||||
function setFirstLineInText(text, normalizedLine) {
|
||||
const idx = text.indexOf('\n');
|
||||
return idx === -1 ? normalizedLine : (normalizedLine + text.slice(idx));
|
||||
}
|
||||
|
||||
// —— 同步另外两个编辑器的首行(仅标题行),避免全文覆盖 ——
|
||||
function syncOthersTitle(fromId, titleText /*不含前缀*/, normalizedLine /*含### */) {
|
||||
editorIds.forEach((id) => {
|
||||
if (id === fromId) return;
|
||||
const ed = editors[id];
|
||||
if (!ed) return;
|
||||
|
||||
const currentFirst = ed.codemirror.getDoc().getLine(0) ?? "";
|
||||
if (currentFirst !== normalizedLine) {
|
||||
setFirstLineInEditor(ed, normalizedLine);
|
||||
lastTitleMap.set(ed, titleText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// —— 更新 tab 按钮上的 “*” 修改状态(保持你原逻辑)——
|
||||
function refreshTabDirtyBadges() {
|
||||
let btn = document.getElementById("tab-btn-lesson");
|
||||
if (CURRENT.active.lesson !== getMD(ID_LESSON)) {
|
||||
btn.innerText = AddModifiedStat(btn.innerText);
|
||||
} else {
|
||||
btn.innerText = ClearModifiedStat(btn.innerText);
|
||||
}
|
||||
|
||||
btn = document.getElementById("tab-btn-prompt");
|
||||
if (CURRENT.active.prompt !== getMD(ID_PROMPT)) {
|
||||
btn.innerText = AddModifiedStat(btn.innerText);
|
||||
} else {
|
||||
btn.innerText = ClearModifiedStat(btn.innerText);
|
||||
}
|
||||
|
||||
btn = document.getElementById("tab-btn-score");
|
||||
if (CURRENT.active.score !== getMD(ID_SCORE)) {
|
||||
btn.innerText = AddModifiedStat(btn.innerText);
|
||||
} else {
|
||||
btn.innerText = ClearModifiedStat(btn.innerText);
|
||||
}
|
||||
}
|
||||
|
||||
// —— 单个编辑器的 change 处理器工厂 ——
|
||||
function bindEditorChangeHandler(textareaId) {
|
||||
const ed = editors[textareaId];
|
||||
const cm = ed.codemirror;
|
||||
|
||||
// 初始化 lastTitleMap
|
||||
lastTitleMap.set(ed, "");
|
||||
|
||||
cm.on('change', () => {
|
||||
if (isProgrammaticUpdate) return;
|
||||
|
||||
const value = ed.value();
|
||||
const rawFirst = getFirstLineFromText(value);
|
||||
|
||||
// 你自己的校验(如果要继续保留)
|
||||
// ValidTextareaNoBlankHead(ed.element.nextSibling /* textarea DOM? */);
|
||||
|
||||
// 归一化首行,要求 "### " 前缀
|
||||
const { line: normalizedLine, title: currentTitle } = normalizeFirstLine(rawFirst);
|
||||
|
||||
// 若首行不规范,程序化改回去(仅替换首行)
|
||||
if (normalizedLine !== rawFirst) {
|
||||
isProgrammaticUpdate = true;// 把首行改回以 '### ' 开头
|
||||
isProgrammaticUpdate = true;
|
||||
try {
|
||||
textarea.value = setFirstLine(value, normalizedLine);
|
||||
setFirstLineInEditor(ed, normalizedLine);
|
||||
} finally {
|
||||
isProgrammaticUpdate = false;
|
||||
}
|
||||
}
|
||||
const lastTitle = lastTitleMap.get(textarea) ?? "";// —— 2) 如标题内容有变化,则同步到其他两个 —— //
|
||||
|
||||
// 标题变化则同步到其他两个编辑器(只同步首行)
|
||||
const lastTitle = lastTitleMap.get(ed) ?? "";
|
||||
if (currentTitle !== lastTitle) {
|
||||
lastTitleMap.set(textarea, currentTitle);// 更新自身记录
|
||||
syncOthers(textarea, currentTitle);
|
||||
lastTitleMap.set(ed, currentTitle);
|
||||
isProgrammaticUpdate = true;
|
||||
try {
|
||||
syncOthersTitle(textareaId, currentTitle, normalizedLine);
|
||||
} finally {
|
||||
isProgrammaticUpdate = false;
|
||||
}
|
||||
}
|
||||
|
||||
let btn = document.getElementById("tab-btn-lesson");
|
||||
if (CURRENT.active.lesson != document.getElementById("editor-lesson").value){
|
||||
btn.innerText = AddModifiedStat(btn.innerText)
|
||||
}else{
|
||||
btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
}
|
||||
btn = document.getElementById("tab-btn-prompt");
|
||||
if (CURRENT.active.prompt != document.getElementById("editor-prompt").value){
|
||||
btn.innerText = AddModifiedStat(btn.innerText)
|
||||
}else{
|
||||
btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
}
|
||||
btn = document.getElementById("tab-btn-score");
|
||||
if (CURRENT.active.score != document.getElementById("editor-score").value){
|
||||
btn.innerText = AddModifiedStat(btn.innerText)
|
||||
}else{
|
||||
btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
// 刷新三个 Tab 的“修改中 * ”标识
|
||||
refreshTabDirtyBadges();
|
||||
|
||||
// 如果你还有右侧预览:
|
||||
if (typeof renderToPreview === 'function' && textareaId === activeId) {
|
||||
renderToPreview(ed.value());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 绑定三个编辑器
|
||||
editorIds.forEach(bindEditorChangeHandler);
|
||||
|
||||
// editorsDoc.forEach(editor => {
|
||||
// editor.addEventListener("input", (event) => {
|
||||
// const textarea = event.target
|
||||
// const value = event.target.value;
|
||||
// const type = event.target.dataset.type;
|
||||
// // 在这里做提示或校验
|
||||
// console.log(`内容已更改: ${event.target.id} => ${value}`);
|
||||
|
||||
// ValidTextareaNoBlankHead(event.target)
|
||||
|
||||
// const rawFirst = getFirstLine(value);
|
||||
// const { line: normalizedLine, title: currentTitle } = normalizeFirstLine(rawFirst);
|
||||
|
||||
// if (normalizedLine !== rawFirst) {
|
||||
// isProgrammaticUpdate = true;// 把首行改回以 '### ' 开头
|
||||
// try {
|
||||
// textarea.value = setFirstLine(value, normalizedLine);
|
||||
// } finally {
|
||||
// isProgrammaticUpdate = false;
|
||||
// }
|
||||
// }
|
||||
// const lastTitle = lastTitleMap.get(textarea) ?? "";// —— 2) 如标题内容有变化,则同步到其他两个 —— //
|
||||
// if (currentTitle !== lastTitle) {
|
||||
// lastTitleMap.set(textarea, currentTitle);// 更新自身记录
|
||||
// syncOthers(textarea, currentTitle);
|
||||
// }
|
||||
|
||||
// let btn = document.getElementById("tab-btn-lesson");
|
||||
// if (CURRENT.active.lesson != document.getElementById("editor-lesson").value){
|
||||
// btn.innerText = AddModifiedStat(btn.innerText)
|
||||
// }else{
|
||||
// btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
// }
|
||||
// btn = document.getElementById("tab-btn-prompt");
|
||||
// if (CURRENT.active.prompt != document.getElementById("editor-prompt").value){
|
||||
// btn.innerText = AddModifiedStat(btn.innerText)
|
||||
// }else{
|
||||
// btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
// }
|
||||
// btn = document.getElementById("tab-btn-score");
|
||||
// if (CURRENT.active.score != document.getElementById("editor-score").value){
|
||||
// btn.innerText = AddModifiedStat(btn.innerText)
|
||||
// }else{
|
||||
// btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
|
||||
|
||||
// 把“某个步骤”对应的三个片段载入到三个编辑器
|
||||
// 把“某个步骤”对应的三个片段载入到三个编辑器
|
||||
function loadStepToEditors(phaseText, stepText) {
|
||||
// 三份文档里都取对应 phase/step 的片段(不存在则回退)
|
||||
const L = CURRENT.parsed.lesson, P = CURRENT.parsed.prompt, S = CURRENT.parsed.score;
|
||||
@@ -918,9 +1067,9 @@ function loadStepToEditors(phaseText, stepText) {
|
||||
const pText = sliceSection(subP, stepText, 3) || promptPhase;
|
||||
const sText = sliceSection(subS, stepText, 3) || scorePhase;
|
||||
|
||||
document.getElementById('editor-lesson').value = lText;
|
||||
document.getElementById('editor-prompt').value = pText;
|
||||
document.getElementById('editor-score').value = sText;
|
||||
editors['editor-lesson'].value(lText);
|
||||
editors['editor-prompt'].value(pText);
|
||||
editors['editor-score'].value(sText);
|
||||
|
||||
CURRENT.active={ //保存打开时的三个文本
|
||||
phase:phaseText,
|
||||
@@ -938,16 +1087,32 @@ function loadStepToEditors(phaseText, stepText) {
|
||||
|
||||
}
|
||||
|
||||
// Tab 切换
|
||||
document.addEventListener('click', (e)=>{
|
||||
if (e.target.classList.contains('tab-btn')) {
|
||||
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
const id = e.target.dataset.target;
|
||||
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('tab-btn')) {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
|
||||
const id = e.target.dataset.target;
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.getElementById(id).classList.add('active');
|
||||
|
||||
// 广播 tab 变更(告诉别人当前激活的 tab id / 以及要用哪个 editorId)
|
||||
const editorIdMap = {
|
||||
'tab-lesson' : 'editor-lesson',
|
||||
'tab-prompt' : 'editor-prompt',
|
||||
'tab-score' : 'editor-score',
|
||||
};
|
||||
const activeBtnId = e.target.id; // 如 'tab-btn-lesson'
|
||||
const editorId =
|
||||
activeBtnId === 'tab-btn-lesson' ? 'editor-lesson' :
|
||||
activeBtnId === 'tab-btn-prompt' ? 'editor-prompt' :
|
||||
'editor-score';
|
||||
|
||||
document.dispatchEvent(new CustomEvent('tab:change', {
|
||||
detail: { tabId: id, editorId }
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// 保存全部:将三个编辑器中的文本保存回相应的 markdown 文件
|
||||
document.getElementById('save-all-btn')?.addEventListener('click', async () => {
|
||||
|
||||
198
Html/apps/static/js/lesson_markdown.js
Normal file
198
Html/apps/static/js/lesson_markdown.js
Normal file
@@ -0,0 +1,198 @@
|
||||
const editors = {}; // id -> EasyMDE
|
||||
(function(){
|
||||
// ====== 配置:图片上传接口 ======
|
||||
const UPLOAD_URL = '/api/upload'; // 返回 { url: 'https://...' }
|
||||
|
||||
// ====== Markdown 渲染器 ======
|
||||
const md = window.markdownit({
|
||||
html: false, // 禁止原生HTML,安全些(如需支持可设为 true)
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true
|
||||
});
|
||||
|
||||
const $preview = document.getElementById('md-preview');
|
||||
|
||||
// ====== 三个 textarea 的 EasyMDE 实例 ======
|
||||
const textareas = [
|
||||
{ id: 'editor-lesson', tabBtnId: 'tab-btn-lesson' },
|
||||
{ id: 'editor-prompt', tabBtnId: 'tab-btn-prompt' },
|
||||
{ id: 'editor-score', tabBtnId: 'tab-btn-score' },
|
||||
];
|
||||
|
||||
let activeId = 'editor-lesson'; // 默认激活
|
||||
|
||||
// 工具:将 Markdown 渲染到右侧预览
|
||||
const renderToPreview = (markdownStr) => {
|
||||
const rawHtml = md.render(markdownStr || '');
|
||||
const safe = DOMPurify.sanitize(rawHtml);
|
||||
$preview.innerHTML = safe;
|
||||
};
|
||||
|
||||
// 工具:节流/防抖
|
||||
function debounce(fn, delay){
|
||||
let t;
|
||||
return function(...args){
|
||||
clearTimeout(t);
|
||||
t = setTimeout(()=>fn.apply(this,args), delay);
|
||||
}
|
||||
}
|
||||
const debouncedPreview = debounce(renderToPreview, 120);
|
||||
|
||||
// 工具:插入文本到当前光标
|
||||
function insertAtCursor(cm, text){
|
||||
const doc = cm.getDoc();
|
||||
const cursor = doc.getCursor();
|
||||
doc.replaceRange(text, cursor);
|
||||
}
|
||||
|
||||
// 图片上传:给粘贴/拖拽/按钮选择共用
|
||||
async function uploadFile(file){
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await axios.post(UPLOAD_URL, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
if(!data || !data.url){
|
||||
throw new Error('上传返回缺少 url 字段');
|
||||
}
|
||||
return data.url;
|
||||
}
|
||||
|
||||
// 为一个 EasyMDE 实例注入图片粘贴/拖拽/按钮上传
|
||||
function bindImageHandlers(easy){
|
||||
const cm = easy.codemirror;
|
||||
|
||||
// 1) 粘贴图片
|
||||
cm.on('paste', async (cmInst, e)=>{
|
||||
const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items || [];
|
||||
for(const it of items){
|
||||
if(it.kind === 'file'){
|
||||
const file = it.getAsFile();
|
||||
if(file && file.type.startsWith('image/')){
|
||||
e.preventDefault();
|
||||
try{
|
||||
const url = await uploadFile(file);
|
||||
insertAtCursor(cmInst, ``);
|
||||
}catch(err){
|
||||
console.error(err);
|
||||
alert('图片上传失败,请重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2) 拖拽图片
|
||||
easy.codemirror.getWrapperElement().addEventListener('drop', async (e)=>{
|
||||
const files = e.dataTransfer?.files || [];
|
||||
if(!files.length) return;
|
||||
const img = Array.from(files).find(f=>f.type.startsWith('image/'));
|
||||
if(!img) return;
|
||||
e.preventDefault();
|
||||
try{
|
||||
const url = await uploadFile(img);
|
||||
insertAtCursor(cm, ``);
|
||||
}catch(err){
|
||||
console.error(err);
|
||||
alert('图片上传失败,请重试');
|
||||
}
|
||||
});
|
||||
|
||||
// 3) 工具栏“上传图片”按钮:使用隐藏 input[type=file]
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
fileInput.addEventListener('change', async ()=>{
|
||||
const file = fileInput.files?.[0];
|
||||
if(!file) return;
|
||||
try{
|
||||
const url = await uploadFile(file);
|
||||
insertAtCursor(cm, ``);
|
||||
}catch(err){
|
||||
console.error(err);
|
||||
alert('图片上传失败,请重试');
|
||||
}finally{
|
||||
fileInput.value = '';
|
||||
}
|
||||
});
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
// 将 input 触发器挂到 toolbar 按钮上(下面初始化里有 name: 'upload-image')
|
||||
easy.toolbar?.forEach(btn=>{
|
||||
if(btn?.name === 'upload-image'){
|
||||
btn.action = () => fileInput.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 创建并初始化一个 EasyMDE
|
||||
function createMDEFor(textareaId){
|
||||
const el = document.getElementById(textareaId);
|
||||
if(!el) return null;
|
||||
|
||||
const mde = new EasyMDE({
|
||||
element: el,
|
||||
autoDownloadFontAwesome: false,
|
||||
spellChecker: false,
|
||||
status: false,
|
||||
autofocus: textareaId === activeId,
|
||||
minHeight: '100%',
|
||||
renderingConfig: { singleLineBreaks: false },
|
||||
uploadImage: false, // 我们自定义上传
|
||||
toolbar: [
|
||||
'bold','italic','heading','|',
|
||||
'quote','unordered-list','ordered-list','table','|',
|
||||
'code','link',
|
||||
{ name:'upload-image', action:null, className:'fa fa-image', title:'插入图片(上传)' },
|
||||
'|','preview','side-by-side','fullscreen',
|
||||
'|','guide'
|
||||
],
|
||||
// 保证 textarea 同步(默认就会同步 value;保存时你的原逻辑仍可读取)
|
||||
forceSync: true
|
||||
});
|
||||
|
||||
// 内容变化 -> 刷新预览(仅激活编辑器触发)
|
||||
mde.codemirror.on('change', ()=>{
|
||||
if(textareaId === activeId){
|
||||
debouncedPreview(mde.value());
|
||||
}
|
||||
});
|
||||
|
||||
bindImageHandlers(mde);
|
||||
return mde;
|
||||
}
|
||||
|
||||
// 初始化三个编辑器
|
||||
for(const t of textareas){
|
||||
editors[t.id] = createMDEFor(t.id);
|
||||
}
|
||||
|
||||
// 初次渲染预览
|
||||
debouncedPreview(editors[activeId]?.value() || '');
|
||||
|
||||
// 监听 Tab 切换按钮,让预览跟随当前激活编辑器
|
||||
function setActiveEditor(id){
|
||||
activeId = id;
|
||||
// 确保 EasyMDE 的编辑区获得焦点(可选)
|
||||
const ed = editors[id];
|
||||
if(ed){
|
||||
ed.codemirror.refresh();
|
||||
ed.codemirror.focus();
|
||||
renderToPreview(ed.value());
|
||||
}
|
||||
}
|
||||
document.addEventListener('tab:change', (e) => {
|
||||
const { editorId } = e.detail || {};
|
||||
if (editorId) setActiveEditor(editorId);
|
||||
});
|
||||
|
||||
// 如果你已有 tab 切换逻辑(例如给 .tab-btn 切换 active 类),保留原逻辑即可;
|
||||
// 我们只需在切换时调用 setActiveEditor 对齐预览内容。
|
||||
})();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user