2026-10-07 18:22:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 个人笔记 · 分块编辑器(v27 用户 2026-10-07)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 只做四件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明):
|
|
|
|
|
|
* ① 把「块」渲染成一列可编辑行(段落 textarea / 图片卡 / 清单 textarea)
|
|
|
|
|
|
* ② 增(+段落 / +图片 / +清单)、删、上移、下移
|
|
|
|
|
|
* ③ 点「+图片」→ 触发隐藏 file 框 → 上传 → 把返回的 key 填进该图片块
|
|
|
|
|
|
* ④ 提交前把当前块**序列化成 JSON** 写进隐藏域 `blocks`
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么状态放在 DOM 上而不是一个 JS 数组里:
|
|
|
|
|
|
* 用户可能在某个 textarea 里打字打到一半就点「上移」——若块数据存在数组里、
|
|
|
|
|
|
* 每次操作都按数组重新渲染,那次输入就会丢。改成"DOM 即真相":
|
|
|
|
|
|
* textarea 的值**就在 DOM 里**,移动只是把一个节点挪个位置,输入一字不丢。
|
|
|
|
|
|
* 只有提交时才从 DOM 读一遍(serialize())。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 用**事件委托**(在容器上挂一个点击处理)而不是给每块挂:块会被增删,
|
|
|
|
|
|
* 逐个挂容易漏掉新加的;委托则天然覆盖后来加的块。
|
|
|
|
|
|
*/
|
|
|
|
|
|
(function () {
|
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
var form = document.getElementById('noteForm');
|
|
|
|
|
|
var list = document.getElementById('noteBlockList');
|
|
|
|
|
|
var sink = document.getElementById('noteBlocks');
|
|
|
|
|
|
var seed = document.getElementById('noteSeed');
|
|
|
|
|
|
var fileIn = document.getElementById('noteImgInput');
|
|
|
|
|
|
if (!form || !list || !sink) { return; }
|
|
|
|
|
|
|
|
|
|
|
|
// 上传目标(哪个图片块在等这次上传的结果)
|
|
|
|
|
|
var pendingBlock = null;
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
// 渲染一块
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
// media 出口的基址由模板注入(页面上的 `data-media-base`)——
|
|
|
|
|
|
// 不写死 'media.php?k=':将来 media 端点换名字/加前缀,这里不用跟着改。
|
|
|
|
|
|
var mediaBase = (document.body.getAttribute('data-media-base') || 'media.php?k=');
|
|
|
|
|
|
|
|
|
|
|
|
function makeBlock(kind, content) {
|
|
|
|
|
|
var wrap = document.createElement('div');
|
|
|
|
|
|
wrap.className = 'note-block';
|
|
|
|
|
|
wrap.setAttribute('data-kind', kind);
|
|
|
|
|
|
|
|
|
|
|
|
var ops = document.createElement('div');
|
|
|
|
|
|
ops.className = 'note-block-ops';
|
|
|
|
|
|
ops.innerHTML =
|
|
|
|
|
|
'<button type="button" class="nb-btn" data-op="up" title="上移" aria-label="上移">↑</button>' +
|
|
|
|
|
|
'<button type="button" class="nb-btn" data-op="down" title="下移" aria-label="下移">↓</button>' +
|
|
|
|
|
|
'<button type="button" class="nb-btn nb-del" data-op="del" title="删除" aria-label="删除">✕</button>';
|
|
|
|
|
|
wrap.appendChild(ops);
|
|
|
|
|
|
|
|
|
|
|
|
var body = document.createElement('div');
|
|
|
|
|
|
body.className = 'note-block-body';
|
|
|
|
|
|
|
|
|
|
|
|
if (kind === 'image') {
|
|
|
|
|
|
// 图片块:content 是附件 key。填 key 的同时把预览换出来。
|
|
|
|
|
|
var img = document.createElement('img');
|
|
|
|
|
|
img.className = 'note-block-img';
|
|
|
|
|
|
img.alt = '笔记图片';
|
|
|
|
|
|
img.src = content ? (mediaBase + encodeURIComponent(content)) : '';
|
|
|
|
|
|
if (!content) { img.classList.add('is-empty'); }
|
|
|
|
|
|
var hint = document.createElement('button');
|
|
|
|
|
|
hint.type = 'button';
|
|
|
|
|
|
hint.className = 'note-img-pick';
|
|
|
|
|
|
hint.textContent = content ? '换一张' : '选图片';
|
|
|
|
|
|
hint.setAttribute('data-op', 'pick');
|
|
|
|
|
|
body.appendChild(img);
|
|
|
|
|
|
body.appendChild(hint);
|
|
|
|
|
|
body.setAttribute('data-key', content || '');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
var ta = document.createElement('textarea');
|
|
|
|
|
|
ta.className = 'input note-block-ta';
|
|
|
|
|
|
ta.rows = kind === 'list' ? 4 : 3;
|
|
|
|
|
|
ta.value = content || '';
|
|
|
|
|
|
ta.placeholder = kind === 'list'
|
2026-10-08 02:20:19 +08:00
|
|
|
|
// v28 第 12 条要求"清单可以多级":缩进即层级(两个空格一层)
|
|
|
|
|
|
? '一行一项;行首缩进两个空格 = 子项(可多级)'
|
|
|
|
|
|
: '写点什么…支持 Markdown:**粗体** *斜体* # 标题 > 引用 `代码`';
|
2026-10-07 18:22:55 +08:00
|
|
|
|
body.appendChild(ta);
|
2026-10-08 02:20:19 +08:00
|
|
|
|
/* 🔴 v28 第 12 条:段落支持 Markdown,且要**所见即所得**。
|
|
|
|
|
|
做法:在 textarea 下方跟一个预览块,输入时(防抖)请服务端渲染。
|
|
|
|
|
|
⚠️ 为什么预览请求服务端而不是前端自己渲染:两套渲染器必然漂移
|
|
|
|
|
|
(预览是粗体、保存后不是),而用户看到的就是预览。
|
|
|
|
|
|
清单块**不给**预览:清单是"一行一项"的结构化输入,预览意义不大,
|
|
|
|
|
|
反而会占掉一半高度(这里是"轻量"的取舍,不是漏了)。 */
|
|
|
|
|
|
/* 🔴 v28 第 12 条:段落与**清单**都支持 Markdown 渲染(清单的多级就靠它)。
|
|
|
|
|
|
段落:原文直接渲染。
|
|
|
|
|
|
清单:把每一行包成 `- 行内容` 再渲染 —— 行首的缩进原样保留,
|
|
|
|
|
|
于是"缩进两个空格 = 子项"这条规则自然生效(多级清单)。
|
|
|
|
|
|
这样做还有个好处:**渲染规则只有一份**(都在服务端 Markdown 里),
|
|
|
|
|
|
不必为清单再写一套层级解析(两套必然漂移)。 */
|
|
|
|
|
|
if (kind === 'text' || kind === 'list') {
|
|
|
|
|
|
var pv = document.createElement('div');
|
|
|
|
|
|
pv.className = 'note-block-pv';
|
|
|
|
|
|
pv.setAttribute('data-pv', '1');
|
|
|
|
|
|
body.appendChild(pv);
|
|
|
|
|
|
}
|
2026-10-07 18:22:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
wrap.appendChild(body);
|
|
|
|
|
|
return wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 02:20:19 +08:00
|
|
|
|
/* ---- Markdown 实时预览(服务端渲染,防抖 350ms)---- */
|
|
|
|
|
|
var pvTimer = 0;
|
|
|
|
|
|
function renderPreview(block) {
|
|
|
|
|
|
var pv = block.querySelector('[data-pv]');
|
|
|
|
|
|
var ta = block.querySelector('textarea');
|
|
|
|
|
|
if (!pv || !ta) { return; }
|
|
|
|
|
|
var box = document.getElementById('noteLivePreview');
|
|
|
|
|
|
if (box && !box.checked) { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
|
|
|
|
|
|
var md = ta.value;
|
|
|
|
|
|
if (md.trim() === '') { pv.innerHTML = ''; pv.classList.remove('is-on'); return; }
|
|
|
|
|
|
// 清单块:每行包成 `- xxx`(缩进原样),交给服务端同一套 Markdown 渲染 → 天然多级
|
|
|
|
|
|
var block = pv.closest ? pv.closest('.note-block') : null;
|
|
|
|
|
|
if (block && block.getAttribute('data-kind') === 'list') {
|
|
|
|
|
|
md = md.split(/\r?\n/).map(function (line) {
|
|
|
|
|
|
return line.trim() === '' ? '' : ('- ' + line.replace(/^(\s*)[-*+]\s+/, '$1'));
|
|
|
|
|
|
}).join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
var form = document.getElementById('noteForm');
|
|
|
|
|
|
var tok = form ? form.querySelector('input[name="_csrf"]') : null;
|
|
|
|
|
|
var fd = new FormData();
|
|
|
|
|
|
fd.append('md', md);
|
|
|
|
|
|
if (tok) { fd.append('_csrf', tok.value); }
|
|
|
|
|
|
fetch('index.php?r=notes/preview', {
|
|
|
|
|
|
method: 'POST', body: fd, credentials: 'same-origin',
|
|
|
|
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
|
|
|
|
}).then(function (r) { return r.json().catch(function () { return null; }); })
|
|
|
|
|
|
.then(function (d) {
|
|
|
|
|
|
if (!d || !d.ok) { return; }
|
|
|
|
|
|
// ⚠️ 响应回来时用户可能已经又改了:只在内容仍一致时才贴上去
|
|
|
|
|
|
// (否则会把"旧的渲染结果"盖在"新的输入"下面,看着像卡住了)
|
|
|
|
|
|
if (ta.value !== md) { return; }
|
|
|
|
|
|
pv.innerHTML = d.html;
|
|
|
|
|
|
pv.classList.add('is-on');
|
|
|
|
|
|
}).catch(function () { /* 预览失败不打扰用户,正文照旧可保存 */ });
|
|
|
|
|
|
}
|
|
|
|
|
|
function schedulePreview(block) {
|
|
|
|
|
|
window.clearTimeout(pvTimer);
|
|
|
|
|
|
pvTimer = window.setTimeout(function () { renderPreview(block); }, 350);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 18:22:55 +08:00
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
// 序列化:从 DOM 读回块数组,写进隐藏域
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
function serialize() {
|
|
|
|
|
|
var out = [];
|
|
|
|
|
|
Array.prototype.forEach.call(list.children, function (el) {
|
|
|
|
|
|
var kind = el.getAttribute('data-kind') || 'text';
|
|
|
|
|
|
var content;
|
|
|
|
|
|
if (kind === 'image') {
|
|
|
|
|
|
var b = el.querySelector('.note-block-body');
|
|
|
|
|
|
content = (b && b.getAttribute('data-key')) || '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
var ta = el.querySelector('textarea');
|
|
|
|
|
|
content = ta ? ta.value : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
out.push({ kind: kind, content: content });
|
|
|
|
|
|
});
|
|
|
|
|
|
sink.value = JSON.stringify(out);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
// 增 / 删 / 移
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
function addBlock(kind, content, afterEl) {
|
|
|
|
|
|
var el = makeBlock(kind, content);
|
|
|
|
|
|
if (afterEl && afterEl.parentNode === list) {
|
|
|
|
|
|
list.insertBefore(el, afterEl.nextSibling);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
list.appendChild(el);
|
|
|
|
|
|
}
|
|
|
|
|
|
var ta = el.querySelector('textarea');
|
|
|
|
|
|
if (ta) { ta.focus(); }
|
|
|
|
|
|
serialize();
|
|
|
|
|
|
return el;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function move(el, dir) {
|
|
|
|
|
|
if (dir < 0) {
|
|
|
|
|
|
var prev = el.previousElementSibling;
|
|
|
|
|
|
if (prev) { list.insertBefore(el, prev); }
|
|
|
|
|
|
} else {
|
|
|
|
|
|
var next = el.nextElementSibling;
|
|
|
|
|
|
if (next) { list.insertBefore(next, el); }
|
|
|
|
|
|
}
|
|
|
|
|
|
serialize();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 事件委托:统一处理块上的所有按钮
|
|
|
|
|
|
list.addEventListener('click', function (e) {
|
|
|
|
|
|
var btn = e.target.closest('[data-op]');
|
|
|
|
|
|
if (!btn || !list.contains(btn)) { return; }
|
|
|
|
|
|
var op = btn.getAttribute('data-op');
|
|
|
|
|
|
var el = btn.closest('.note-block');
|
|
|
|
|
|
if (!el) { return; }
|
|
|
|
|
|
|
|
|
|
|
|
if (op === 'up') { move(el, -1); return; }
|
|
|
|
|
|
if (op === 'down') { move(el, 1); return; }
|
|
|
|
|
|
if (op === 'del') { el.parentNode.removeChild(el); serialize(); return; }
|
|
|
|
|
|
if (op === 'pick') {
|
|
|
|
|
|
// 记住是哪个块在等图片,然后触发隐藏 file 框
|
|
|
|
|
|
pendingBlock = el;
|
|
|
|
|
|
fileIn.click();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 顶部「+段落 / +图片 / +清单」
|
|
|
|
|
|
document.querySelectorAll('.note-add [data-add]').forEach(function (b) {
|
|
|
|
|
|
b.addEventListener('click', function () {
|
|
|
|
|
|
var kind = b.getAttribute('data-add');
|
|
|
|
|
|
if (kind === 'image') {
|
|
|
|
|
|
// 加图块的同时直接把选图流程带起来(少一次点击)
|
|
|
|
|
|
var el = addBlock('image', '');
|
|
|
|
|
|
pendingBlock = el;
|
|
|
|
|
|
fileIn.click();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addBlock(kind, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
// 图片上传
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
fileIn.addEventListener('change', function () {
|
|
|
|
|
|
if (!fileIn.files || !fileIn.files[0]) { return; }
|
|
|
|
|
|
var el = pendingBlock;
|
|
|
|
|
|
if (!el) { return; }
|
|
|
|
|
|
var f = fileIn.files[0];
|
|
|
|
|
|
|
|
|
|
|
|
var fd = new FormData();
|
|
|
|
|
|
fd.append('img', f);
|
|
|
|
|
|
// CSRF:从表单里那颗隐藏 _csrf 拿(同一个 token)
|
|
|
|
|
|
var tok = form.querySelector('input[name="_csrf"]');
|
|
|
|
|
|
if (tok) { fd.append('_csrf', tok.value); }
|
|
|
|
|
|
|
|
|
|
|
|
// 上传中:把按钮文案换掉,避免用户以为没反应又点一次
|
|
|
|
|
|
var pick = el.querySelector('.note-img-pick');
|
|
|
|
|
|
var oldTxt = pick ? pick.textContent : '';
|
|
|
|
|
|
if (pick) { pick.textContent = '上传中…'; pick.disabled = true; }
|
|
|
|
|
|
|
|
|
|
|
|
fetch('index.php?r=notes/upload', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: fd,
|
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
|
|
|
|
}).then(function (r) { return r.json().catch(function () { return null; }); })
|
|
|
|
|
|
.then(function (d) {
|
|
|
|
|
|
if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; }
|
|
|
|
|
|
if (!d || !d.ok) {
|
|
|
|
|
|
alert('图片上传失败:' + ((d && d.error) || '未知原因'));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var b = el.querySelector('.note-block-body');
|
|
|
|
|
|
if (b) { b.setAttribute('data-key', d.key); }
|
|
|
|
|
|
var img = el.querySelector('.note-block-img');
|
|
|
|
|
|
if (img) { img.src = d.url; img.classList.remove('is-empty'); }
|
|
|
|
|
|
if (pick) { pick.textContent = '换一张'; }
|
|
|
|
|
|
serialize();
|
|
|
|
|
|
}).catch(function () {
|
|
|
|
|
|
if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; }
|
|
|
|
|
|
alert('图片上传失败,请重试。');
|
|
|
|
|
|
});
|
|
|
|
|
|
// 清空 input,否则连选同一张图不会再触发 change
|
|
|
|
|
|
fileIn.value = '';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// textarea 输入时同步(这样即便用户直接点提交,内容也是最新的)
|
2026-10-08 02:20:19 +08:00
|
|
|
|
// + v28:段落块顺手刷新 Markdown 实时预览
|
|
|
|
|
|
list.addEventListener('input', function (e) {
|
|
|
|
|
|
serialize();
|
|
|
|
|
|
var ta = e.target;
|
|
|
|
|
|
if (ta && ta.tagName === 'TEXTAREA') {
|
|
|
|
|
|
var block = ta.closest ? ta.closest('.note-block') : null;
|
|
|
|
|
|
if (block && block.getAttribute('data-kind') === 'text') {
|
|
|
|
|
|
schedulePreview(block);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/* 实时预览开关(用户第 12 条:"允许所见即所得编辑,也允许 markdown 编辑")——
|
|
|
|
|
|
关掉之后预览块收起,textarea 能占满整块(专心改 Markdown 源码时更清爽)。 */
|
|
|
|
|
|
var liveBox = document.getElementById('noteLivePreview');
|
|
|
|
|
|
if (liveBox) {
|
|
|
|
|
|
liveBox.addEventListener('change', function () {
|
|
|
|
|
|
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
|
|
|
|
|
|
function (b) {
|
|
|
|
|
|
if (liveBox.checked) { renderPreview(b); }
|
|
|
|
|
|
else {
|
|
|
|
|
|
var pv = b.querySelector('[data-pv]');
|
|
|
|
|
|
if (pv) { pv.innerHTML = ''; pv.classList.remove('is-on'); }
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------- 底部附件:删除(v28 第 12 条)----------------
|
|
|
|
|
|
⚠️ 用全页唯一的那一份 #noteFileDelForm(与项目其它模块同一做法):
|
|
|
|
|
|
每个附件一份 form 会在附件多时把页面撑爆。 */
|
|
|
|
|
|
var fileDelForm = document.getElementById('noteFileDelForm');
|
|
|
|
|
|
if (fileDelForm) {
|
|
|
|
|
|
document.addEventListener('click', function (e) {
|
|
|
|
|
|
var b = e.target && e.target.closest ? e.target.closest('[data-note-file-del]') : null;
|
|
|
|
|
|
if (!b) { return; }
|
|
|
|
|
|
if (!confirm('删掉这个附件?')) { return; }
|
|
|
|
|
|
var inp = fileDelForm.querySelector('input[name="id"]');
|
|
|
|
|
|
if (inp) { inp.value = b.getAttribute('data-note-file-del'); }
|
|
|
|
|
|
fileDelForm.submit();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-10-07 18:22:55 +08:00
|
|
|
|
|
|
|
|
|
|
// 提交前再序列化一次(双保险)
|
|
|
|
|
|
form.addEventListener('submit', serialize);
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
// 初始化:从服务端种子渲染
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
var initial = [];
|
|
|
|
|
|
if (seed) {
|
|
|
|
|
|
try { initial = JSON.parse(seed.textContent || '[]'); } catch (e) { initial = []; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!initial.length) { initial = [{ kind: 'text', content: '' }]; }
|
|
|
|
|
|
initial.forEach(function (b) { addBlock(b.kind, b.content); });
|
|
|
|
|
|
serialize();
|
|
|
|
|
|
|
2026-10-08 02:20:19 +08:00
|
|
|
|
/* v28:编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下
|
|
|
|
|
|
键盘才第一次看到"所见即所得",会以为这功能没生效)。 */
|
|
|
|
|
|
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
|
|
|
|
|
|
function (b) { renderPreview(b); });
|
|
|
|
|
|
|
2026-10-07 18:22:55 +08:00
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
// 标签联想:点一下把 #标签 追加进输入框
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
var tagIn = document.getElementById('noteTags');
|
|
|
|
|
|
document.querySelectorAll('.note-tags-suggest [data-tag]').forEach(function (b) {
|
|
|
|
|
|
b.addEventListener('click', function () {
|
|
|
|
|
|
if (!tagIn) { return; }
|
|
|
|
|
|
var t = '#' + b.getAttribute('data-tag');
|
|
|
|
|
|
var cur = tagIn.value.trim();
|
|
|
|
|
|
// 已经有就不重复加
|
|
|
|
|
|
if ((' ' + cur + ' ').indexOf(' ' + t + ' ') >= 0) { return; }
|
|
|
|
|
|
tagIn.value = cur === '' ? t : (cur + ' ' + t);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
})();
|