use markdown to edit

This commit is contained in:
Cai
2025-11-03 16:14:57 +08:00
parent ce88ae645e
commit 33844786a1
9 changed files with 1126 additions and 53 deletions

View File

@@ -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 () => {

View 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, `![image](${url})`);
}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, `![image](${url})`);
}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, `![image](${url})`);
}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 对齐预览内容。
})();