lesson more edit

This commit is contained in:
Cai
2025-09-18 11:47:24 +00:00
parent 15963190cb
commit 6575ee80bc
7 changed files with 638 additions and 83 deletions

View File

@@ -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('保存失败,请重试');
}
});

View File

@@ -0,0 +1,115 @@
// ======= 工具:创建编辑按钮(含铅笔 SVG =======
function createEditButton(onClick) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'edit-btn';
btn.setAttribute('aria-label', '编辑标题');
btn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<i class="fas fa-pencil-alt"></i>
</svg>
`;
btn.addEventListener('click', (e) => {
e.stopPropagation();
onClick && onClick();
});
return btn;
}
// ======= 模态框:打开标题编辑器 =======
function openTitleEditor({ title = '修改标题', initialValue = '', onSubmit }) {
const backdrop = document.createElement('div');
backdrop.className = 'title_change_modal-backdrop';
const title_change_modal = document.createElement('div');
title_change_modal.className = 'title_change_modal';
title_change_modal.setAttribute('role', 'dialog');
title_change_modal.setAttribute('aria-title_change_modal', 'true');
title_change_modal.innerHTML = `
<h3 id="dialog-title">${title}</h3>
<div class="field">
<label for="title-input" class="sr-only">标题</label>
<input id="title-input" type="text" placeholder="请输入标题" value="${escapeHtmlAttr(initialValue)}" />
<div class="error" id="title-error"></div>
</div>
<div class="actions">
<button type="button" class="btn" id="cancel-btn">取消</button>
<button type="button" class="btn primary" id="ok-btn">确定</button>
</div>
`;
backdrop.appendChild(title_change_modal);
document.body.appendChild(backdrop);
const input = title_change_modal.querySelector('#title-input');
const okBtn = title_change_modal.querySelector('#ok-btn');
const cancelBtn = title_change_modal.querySelector('#cancel-btn');
const errBox = title_change_modal.querySelector('#title-error');
const prevFocus = document.activeElement;
const close = () => {
document.body.removeChild(backdrop);
if (prevFocus && prevFocus.focus) prevFocus.focus();
};
const submit = () => {
const raw = input.value;
const result = onSubmit ? onSubmit(raw) : { ok: true };
if (result && result.ok) {
close();
} else if (result && result.message) {
errBox.textContent = result.message;
input.setAttribute('aria-invalid', 'true');
input.focus();
}
};
okBtn.addEventListener('click', submit);
cancelBtn.addEventListener('click', close);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) close(); // 点击遮罩关闭
});
title_change_modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') close();
if (e.key === 'Enter') submit();
});
// 初始聚焦
setTimeout(() => input.focus(), 0);
}
// ======= 验证与规范化 =======
function validateTitle(raw) {
if (typeof raw !== 'string') return { ok: false, message: '标题格式不正确' };
// 1) 去掉 Markdown 井号前缀(若用户手动输入了 ###
let t = raw.replace(/^#+\s*/, '');
// 2) 替换换行为单空格(标题单行)
t = t.replace(/\r?\n+/g, ' ');
// 3) 去首尾空白 & 折叠多空格
t = t.trim().replace(/\s{2,}/g, ' ');
// 4) 基础规则
if (t.length === 0) return { ok: false, message: '标题不能为空' };
if (t.length > 80) return { ok: false, message: '标题不能超过 80 个字符' };
// 5) 可选:禁止只包含标点
if (!/[0-9A-Za-z\u4e00-\u9fa5]/.test(t)) {
return { ok: false, message: '标题需包含字母/数字/中文' };
}
return { ok: true, value: t };
}
// 显示时的规范化(比如初始渲染)
function normalizeTitle(s) {
if (!s) return '';
return s.replace(/^#+\s*/, '').replace(/\r?\n+/g, ' ').trim().replace(/\s{2,}/g, ' ');
}
// 简单转义,避免把初始值插进 input 时破坏属性
function escapeHtmlAttr(s) {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}