Files
FamilyBook/assets/js/note_editor.js
T
laoyang 6852b4ee2c v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式
其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:

1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
   → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
   修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
   「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
   (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
   新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
   🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
   单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
   全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
2026-10-08 02:20:19 +08:00

347 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 个人笔记 · 分块编辑器(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'
// v28 第 12 条要求"清单可以多级":缩进即层级(两个空格一层)
? '一行一项;行首缩进两个空格 = 子项(可多级)'
: '写点什么…支持 Markdown:**粗体** *斜体* # 标题 > 引用 `代码`';
body.appendChild(ta);
/* 🔴 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);
}
}
wrap.appendChild(body);
return wrap;
}
/* ---- 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);
}
// ---------------------------------------------------------------------
// 序列化:从 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 输入时同步(这样即便用户直接点提交,内容也是最新的)
// + 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();
});
}
// 提交前再序列化一次(双保险)
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();
/* v28:编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下
键盘才第一次看到"所见即所得",会以为这功能没生效)。 */
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
function (b) { renderPreview(b); });
// ---------------------------------------------------------------------
// 标签联想:点一下把 #标签 追加进输入框
// ---------------------------------------------------------------------
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);
});
});
})();