lesson more edit
This commit is contained in:
@@ -109,7 +109,7 @@ function parseHeadings(mdText) {
|
||||
links: { lesson:'', prompt:'', score:'' },
|
||||
raw: { lesson:'', prompt:'', score:'' },
|
||||
parsed:{ lesson:null, prompt:null, score:null },
|
||||
active:{ phase:null, step:null },
|
||||
active:{ phase:null, step:null, lessontxt:null, prompt:null, score:null },
|
||||
outline:null
|
||||
};
|
||||
function bustCache(url) {
|
||||
@@ -185,12 +185,40 @@ function parseHeadings(mdText) {
|
||||
function addThemeWithHelp(tree, themeText) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'lvl1';
|
||||
li.textContent = themeText;
|
||||
|
||||
// 标题文本容器(单独一个 span,避免更新 textContent 时把子元素清掉)
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'title-text';
|
||||
titleSpan.textContent = normalizeTitle(themeText);
|
||||
li.appendChild(titleSpan);
|
||||
|
||||
const help = document.createElement('span');
|
||||
help.className = 'help-icon';
|
||||
help.textContent = ' ?';
|
||||
li.appendChild(help);
|
||||
// 新增:铅笔编辑按钮
|
||||
const editBtn = createEditButton(() => {
|
||||
openTitleEditor({
|
||||
title: '修改标题',
|
||||
initialValue: titleSpan.textContent,
|
||||
onSubmit: (value) => {
|
||||
const res = validateTitle(value);
|
||||
if (!res.ok) return res; // 返回 {ok:false, message:'...'} => 在模态框里显示错误
|
||||
titleSpan.textContent = res.value; // 合法后的值写回
|
||||
const newLine = "# " + res.value;
|
||||
CURRENT.raw.lesson = setFirstLine(CURRENT.raw.lesson || "", newLine);
|
||||
CURRENT.raw.prompt = setFirstLine(CURRENT.raw.prompt || "", newLine);
|
||||
CURRENT.raw.score = setFirstLine(CURRENT.raw.score || "", newLine);
|
||||
let confirmMsg = "是否立即保存?注意当前编辑内容";
|
||||
// —— 二次确认 —— //
|
||||
if (window.confirm(confirmMsg)) {
|
||||
saveUpdatedStep()
|
||||
}
|
||||
return { ok: true }; // 关闭模态框
|
||||
}
|
||||
});
|
||||
});
|
||||
li.appendChild(editBtn);
|
||||
|
||||
// 绑定即时 tooltip(挂到 body,不会被裁切)
|
||||
attachImmediateTooltip(
|
||||
@@ -214,7 +242,47 @@ function parseHeadings(mdText) {
|
||||
// 二级标题
|
||||
const li2 = document.createElement('li');
|
||||
li2.className = 'lvl2';
|
||||
li2.textContent = `${ph.text}`;//##
|
||||
li2.textContent = `${ph.text}`;
|
||||
// 新增:铅笔编辑按钮
|
||||
const editBtn = createEditButton(() => {
|
||||
openTitleEditor({
|
||||
title: '修改阶段标题',
|
||||
initialValue: ph.text,
|
||||
onSubmit: (value) => {
|
||||
const res = validateTitle(value);
|
||||
if (!res.ok) return res; // 返回 {ok:false, message:'...'} => 在模态框里显示错误
|
||||
const oldH2 = (ph.text || "").trim();
|
||||
const newH2 = (res.value || "").trim();
|
||||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const hasH2 = (text, h2) => {
|
||||
const re = new RegExp(`^##\\s*${escapeRegExp(h2)}\\s*$`, 'm');
|
||||
return re.test(text || '');
|
||||
};
|
||||
const replaceH2Once = (text, oldT, newT) => {
|
||||
const re = new RegExp(`^##\\s*${escapeRegExp(oldT)}\\s*$`, 'm');
|
||||
return (text || '').replace(re, `## ${newT}`);
|
||||
};
|
||||
['lesson', 'prompt', 'score'].forEach((key) => {
|
||||
const src = CURRENT.raw[key] || '';
|
||||
// 已经有 "## newH2" 就不动;否则把第一处 "## oldH2" 换成新标题
|
||||
if (!hasH2(src, newH2)) {
|
||||
CURRENT.raw[key] = replaceH2Once(src, oldH2, newH2);
|
||||
li2.textContent = res.value; // 合法后的值写回
|
||||
let confirmMsg = "是否立即保存?注意当前编辑内容";
|
||||
// —— 二次确认 —— //
|
||||
if (window.confirm(confirmMsg)) {
|
||||
saveUpdatedStep()
|
||||
}
|
||||
}else{
|
||||
// 已存在新标题:保持原文不变
|
||||
alert("新标题已存在")
|
||||
}
|
||||
});
|
||||
return { ok: true }; // 关闭模态框
|
||||
}
|
||||
});
|
||||
});
|
||||
li2.appendChild(editBtn);
|
||||
tree.appendChild(li2);
|
||||
|
||||
// 渲染三级标题(步骤)
|
||||
@@ -406,6 +474,97 @@ function addStepToPhase(phaseText) {
|
||||
return newLines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
const outlineAside = document.querySelector('.outline');
|
||||
const outlineTree = document.getElementById('outline-tree');
|
||||
const btnDeleteMode = document.getElementById('toggle-outline-delete');
|
||||
|
||||
// 切换删除模式
|
||||
function setDeleteMode(enabled) {
|
||||
btnDeleteMode.setAttribute('aria-pressed', String(enabled));
|
||||
outlineAside.classList.toggle('delete-mode', enabled);
|
||||
}
|
||||
|
||||
// 点击按钮:进入/退出删除模式
|
||||
btnDeleteMode.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const enabled = btnDeleteMode.getAttribute('aria-pressed') !== 'true';
|
||||
setDeleteMode(enabled);
|
||||
});
|
||||
|
||||
// 工具:判断是否是 lvl2 / lvl3 节点(允许点到它的内部子元素)
|
||||
function findDeletableNode(el) {
|
||||
if (!el) return null;
|
||||
if (el.classList?.contains('lvl2') || el.classList?.contains('lvl3')) return el;
|
||||
return el.closest?.('.lvl2, .lvl3') || null;
|
||||
}
|
||||
|
||||
// 事件代理:删除模式下,点击二/三级标题进行删除确认
|
||||
outlineTree.addEventListener('click', (e) => {
|
||||
if (btnDeleteMode.getAttribute('aria-pressed') !== 'true') return; // 非删除模式,忽略
|
||||
const targetNode = findDeletableNode(e.target);
|
||||
if (!targetNode) return;
|
||||
|
||||
// 阻止与其他点击逻辑(比如展开/跳转)冲突
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 取层级与文案
|
||||
const level = targetNode.classList.contains('lvl2') ? 2 : 3;
|
||||
// 标题文本:如果内部结构复杂,优先拿一个有标题含义的元素,否则退回整个文本
|
||||
const text = (targetNode.querySelector('.title-text')?.textContent
|
||||
|| targetNode.firstChild?.textContent
|
||||
|| targetNode.textContent || '').trim();
|
||||
|
||||
const ok = window.confirm(`确认删除 ${level} 级标题「${text || '(未命名)'}」吗?`);
|
||||
if (!ok) return;
|
||||
|
||||
// 执行删除:从大纲中移除该节点
|
||||
targetNode.remove();
|
||||
|
||||
// 可选:如果你需要同步删除到正文内容(CURRENT.raw.xxx),可以在这里调用你的同步逻辑
|
||||
// 例:把正文中第一处行首 "## 标题" 或 "### 标题" 删除(仅示例,按你项目规则调整):
|
||||
deleteSectionInRaw(level, text);
|
||||
});
|
||||
/**
|
||||
* 删除指定 level(2/3) 与 title 的小节:
|
||||
* 从 "^## title$" 或 "^### title$" 那一行开始,
|
||||
* 一直到“下一个同级标题”(不含)或文末。
|
||||
*/
|
||||
function escapeRegExp(s) {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
function deleteSectionInRaw(level, title) {
|
||||
const hashes = '#'.repeat(level); // "##" 或 "###"
|
||||
const escTitle = escapeRegExp(String(title).trim());
|
||||
|
||||
// 精确匹配:行首同级标题 + 可选空格 + 标题文本 + 行尾
|
||||
const headingRe = new RegExp(`^${hashes}\\s*${escTitle}\\s*$`, 'm');
|
||||
|
||||
// 下一个同级标题(任意文本)
|
||||
const nextSameLevelRe = new RegExp(`^${hashes}\\s+.*$`, 'm');
|
||||
|
||||
['lesson', 'prompt', 'score'].forEach((key) => {
|
||||
let text = CURRENT.raw[key] || '';
|
||||
const m = headingRe.exec(text);
|
||||
if (!m) return; // 这份原文里没有该标题,跳过
|
||||
const start = m.index;
|
||||
const afterHeading = m.index + m[0].length;
|
||||
const sub = text.slice(afterHeading);
|
||||
const m2 = nextSameLevelRe.exec(sub);
|
||||
const end = m2 ? (afterHeading + m2.index) : text.length;
|
||||
let updated = text.slice(0, start) + text.slice(end);
|
||||
updated = updated.replace(/\r?\n{3,}/g, '\n\n');
|
||||
CURRENT.raw[key] = updated;
|
||||
});
|
||||
if (window.confirm("确认删除标题(含内部所有内容)并保存?")) {
|
||||
saveUpdatedStep()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// 解析Markdown并定位章节/步骤
|
||||
function parseHeadings(mdText) {
|
||||
const lines = mdText.split('\n');
|
||||
@@ -465,11 +624,88 @@ function addStepToPhase(phaseText) {
|
||||
return res.json().catch(()=> ({}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Markdown 标题(1~3 个 #)匹配:仅行首、最多 3 个 #,后跟空格与内容
|
||||
const HEADING_RE = /^\s{0,3}#{1,3}\s+\S/;
|
||||
const FIRSTLINE_RE = /^#{3}\s+\S/;
|
||||
/**
|
||||
* 扫描一个文本,找出从第2行开始的非法标题(# / ## / ###)
|
||||
* @return {Array<{line: number, text: string}>}
|
||||
*/
|
||||
function findIllegalHeadings(text = "") {
|
||||
const lines = String(text).split(/\r?\n/);
|
||||
const issues = [];
|
||||
if (!FIRSTLINE_RE.test(lines[0] || "")) {
|
||||
issues.push({ line: 1, text: lines[0] + " 首行必须是 '### 标题'" });
|
||||
}
|
||||
for (let i = 1; i < lines.length; i++) { // 跳过首行 i=0
|
||||
if (HEADING_RE.test(lines[i])) {
|
||||
issues.push({ line: i + 1, text: lines[i] }); // 人类可读行号 = 下标+1
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验三个编辑器:除首行外不允许 # / ## / ###
|
||||
* @param {HTMLTextAreaElement} lEditor
|
||||
* @param {HTMLTextAreaElement} pEditor
|
||||
* @param {HTMLTextAreaElement} sEditor
|
||||
* @returns {{ok: boolean, report: string, details: object}}
|
||||
*/
|
||||
function validateEditorsBeforeCloudSave(lEditor, pEditor, sEditor) {
|
||||
const buckets = {
|
||||
lesson: findIllegalHeadings(lEditor?.value || ""),
|
||||
prompt: findIllegalHeadings(pEditor?.value || ""),
|
||||
score: findIllegalHeadings(sEditor?.value || ""),
|
||||
};
|
||||
|
||||
const parts = [];
|
||||
for (const [key, arr] of Object.entries(buckets)) {
|
||||
if (arr.length) {
|
||||
const name = key === 'lesson' ? 'lesson' : key === 'prompt' ? 'prompt' : 'score';
|
||||
parts.push(
|
||||
`${name} 存在非法标题:\n` +
|
||||
arr.map(x => ` - 第 ${x.line} 行:${x.text}`).join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: parts.length === 0,
|
||||
report: parts.join('\n\n'),
|
||||
details: buckets
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* (可选)自动修复:去掉除首行外行首的 # / ## / ### 前缀
|
||||
*/
|
||||
function sanitizeHeadingsBeyondFirst(text = "") {
|
||||
const lines = String(text).split(/\r?\n/);
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
lines[i] = lines[i].replace(/^\s{0,3}#{1,3}\s+/, ''); // 去掉前缀与一个以上空格
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// 保存某个步骤的更新
|
||||
async function saveUpdatedStep() {
|
||||
const lEditor = document.getElementById('editor-lesson');
|
||||
const pEditor = document.getElementById('editor-prompt');
|
||||
const sEditor = document.getElementById('editor-score');
|
||||
const check = validateEditorsBeforeCloudSave(lEditor, pEditor, sEditor);
|
||||
|
||||
if (!check.ok) {
|
||||
alert("保存被阻止:" + check.report);
|
||||
return ;
|
||||
}
|
||||
|
||||
// 获取原始的文档内容
|
||||
const originalLesson = CURRENT.raw.lesson;
|
||||
@@ -497,6 +733,9 @@ function addStepToPhase(phaseText) {
|
||||
//result 可包含新的 CDN 链接或版本号,更新到 CURRENT.links / UI
|
||||
if (result?.links) { CURRENT.links = { ...CURRENT.links, ...result.links }; }
|
||||
alert('已保存');
|
||||
CURRENT.active.lesson = lEditor.value
|
||||
CURRENT.active.prompt = pEditor.value
|
||||
CURRENT.active.score = sEditor.value
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
@@ -510,6 +749,13 @@ function addStepToPhase(phaseText) {
|
||||
CURRENT.parsed.prompt = parseHeadings(CURRENT.raw.prompt)
|
||||
CURRENT.parsed.score = parseHeadings(CURRENT.raw.score)
|
||||
CURRENT.outline = buildUnifiedOutline(CURRENT.parsed.lesson, CURRENT.parsed.prompt, CURRENT.parsed.score);
|
||||
renderOutline(CURRENT.outline);
|
||||
let btn = document.getElementById("tab-btn-lesson");
|
||||
btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
btn = document.getElementById("tab-btn-prompt");
|
||||
btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
btn = document.getElementById("tab-btn-score");
|
||||
btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
}
|
||||
|
||||
// 更新对应步骤的内容
|
||||
@@ -530,6 +776,121 @@ function addStepToPhase(phaseText) {
|
||||
return lines.join('\n'); // 返回更新后的完整内容
|
||||
}
|
||||
|
||||
// 获取所有 textarea
|
||||
function AddModifiedStat(str){
|
||||
return str.charAt(str.length - 1) !== '*' ? str + '*' : str;
|
||||
}
|
||||
function ClearModifiedStat(str){
|
||||
return str.charAt(str.length - 1) !== '*' ? str:str.slice(0, str.length-1);
|
||||
}
|
||||
function ValidTextareaNoBlankHead(textarea) {
|
||||
let lines = textarea.value.split("\n");
|
||||
// 如果第一行是空的,则删掉
|
||||
while (lines.length > 0 && lines[0].trim() === "") {
|
||||
lines.shift(); // 删除第一行
|
||||
}
|
||||
// 更新 textarea 的值(如果有变化)
|
||||
textarea.value = lines.join("\n");
|
||||
}
|
||||
const editors = document.querySelectorAll("textarea");
|
||||
const PREFIX = "### ";
|
||||
// 记录每个 textarea 最新的“标题内容”(不含前缀)
|
||||
const lastTitleMap = new Map();
|
||||
// 防止循环触发
|
||||
let isProgrammaticUpdate = false;
|
||||
function getFirstLine(text) {
|
||||
const idx = text.indexOf("\n");
|
||||
return idx === -1 ? text : text.slice(0, idx);
|
||||
}
|
||||
function setFirstLine(text, newFirstLine) {
|
||||
const idx = text.indexOf("\n");
|
||||
return idx === -1 ? newFirstLine : newFirstLine + text.slice(idx);
|
||||
}
|
||||
// 工具:规范化首行 -> 始终以 '### ' 开头,并返回 { line, title }
|
||||
function normalizeFirstLine(line) {
|
||||
// 去掉前导的 # 和空格,保留后面的标题内容
|
||||
const title = line.replace(/^#+\s*/,''); // 如果用户没写#,也直接当作标题
|
||||
const normalized = PREFIX + title;
|
||||
return { line: normalized, title };
|
||||
}
|
||||
// 初始化 lastTitleMap(把当前值规范化一次)
|
||||
editors.forEach(ed => {
|
||||
const rawFirst = getFirstLine(ed.value || "");
|
||||
const { line, title } = normalizeFirstLine(rawFirst);
|
||||
if (line !== rawFirst) {
|
||||
isProgrammaticUpdate = true;
|
||||
ed.value = setFirstLine(ed.value || "", line);
|
||||
isProgrammaticUpdate = false;
|
||||
}
|
||||
lastTitleMap.set(ed, title);
|
||||
});
|
||||
// 同步到其他两个 textarea 的首行
|
||||
function syncOthers(sourceEditor, newTitle) {
|
||||
isProgrammaticUpdate = true;
|
||||
try {
|
||||
editors.forEach(ed => {
|
||||
if (ed === sourceEditor) return;
|
||||
const first = getFirstLine(ed.value || "");
|
||||
const { line } = normalizeFirstLine(first);
|
||||
const newLine = PREFIX + newTitle;
|
||||
if (line !== newLine) {
|
||||
ed.value = setFirstLine(ed.value || "", newLine);
|
||||
}
|
||||
lastTitleMap.set(ed, newTitle);
|
||||
});
|
||||
} finally {
|
||||
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}`);
|
||||
|
||||
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 的片段(不存在则回退)
|
||||
@@ -551,6 +912,21 @@ function loadStepToEditors(phaseText, stepText) {
|
||||
document.getElementById('editor-lesson').value = lText;
|
||||
document.getElementById('editor-prompt').value = pText;
|
||||
document.getElementById('editor-score').value = sText;
|
||||
|
||||
CURRENT.active={ //保存打开时的三个文本
|
||||
phase:phaseText,
|
||||
step:stepText,
|
||||
lesson:lText,
|
||||
prompt:pText,
|
||||
score:sText
|
||||
}
|
||||
let btn = document.getElementById("tab-btn-lesson");
|
||||
btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
btn = document.getElementById("tab-btn-prompt");
|
||||
btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
btn = document.getElementById("tab-btn-score");
|
||||
btn.innerText = ClearModifiedStat(btn.innerText)
|
||||
|
||||
}
|
||||
|
||||
// Tab 切换
|
||||
@@ -572,7 +948,6 @@ function loadStepToEditors(phaseText, stepText) {
|
||||
await saveUpdatedStep();
|
||||
} catch (err) {
|
||||
console.error('保存错误:', err);
|
||||
alert('保存失败,请重试');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user