Files
FamilyBook/assets/js/note_editor.js
T
laoyang 37ba86321b v29 第 29 批(9 条):日程定位/复选框提示/名册错乱三处真因修复 + 读书落盘 + 导航四设置 + 笔记阅读态与共享
前 4 条是"上批改了但用户仍报"的,这次先拿真机证据再动手,三处真因如下:

1. 首页没定位到红线 —— 真根因:定位脚本的 PHP 条件是
   `if (!$todayEvents || empty($timed))`,**今天只有全天日程时整段脚本不渲染**
   (实测 hasScript=False、scrollTop=0),而 .ag-grid(0–24h) 与红线都在。
   有分时段日程时定位其实完全正确(偏差 0px)。已改成 `if (!$todayEvents)`。
2. 任务复选框提示还在 —— 真根因:`_task_check.php` 里那句无障碍文字
   `<span class="visually-hidden">` 用的类名**在 app.css 里从来没有定义过**
   (项目一直只用 .sr-only)→ 没有规则 = 普通 span = 直接可见。
   已把两个类名合并到同一条规则(clip 隐藏,仍可读屏)。
3. 名册完全错乱 —— 真根因:v29 重写平铺列表时**漏了一个 `</div>`**(闭合 .person),
   浏览器把下一张卡嵌进上一张里,实测 .person-main 只剩 24~151px(本该 ~800px)。
   `php -l` 与全部静态断言都抓不到,只有真机量 DOM 才现形。已补上闭合。
   另把 v28 引入的多列 grid 改回单列(它与 content-visibility 冲突)+窄屏动作区折行。
4. 读书慢 —— 按用户给的思路「做一个同步,然后落盘」:
   新增 weread_snapshots 表 + App\WeReadCache(按人+数据集存 JSON);
   渲染路径改走 notebooksCached()/readStatsCached()(永不联网);
   同步从"渲染之前"挪到"**响应之后**"(queueBackgroundSync + fastcgi_finish_request)。
5. 导航设置改成右上角**下拉菜单 + 四视图**(addr/general/board/engine),
   表单带 back 域 → 保存后回原来那一项。
   🔴 顺带修掉一个真 bug:`if (!$inSettings && $canWrite)` 的 endif 漂到了文件末尾,
   把 board/engine 两个视图也包进了"非设置态" → 打开时标题对、**内容是空的**。
6. 笔记列表缩略图:cover → contain + 固定高度(按比例完整显示,不裁切)。
7. 笔记段落编辑器:加 Markdown 工具条 + 斜线命令(行首打 /co → 回车补全代码块),
   两者共用同一份命令表。
8. 笔记点进来默认**阅读态**(图文混排、附件在最下方、图片四档宽度、点「编辑」才可改);
   清单块并入 Markdown —— 但**历史 list 块转写成 `- 项` 文本,老数据不丢**。
9. 笔记共享改成**逐人 + 权限**(note_shares,与 resource_shares 同构);
   可见性收敛成全站唯一的 Note::VIS_COND;迁移用一次性哨兵。
2026-10-08 10:54:43 +08:00

605 lines
26 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 起;v29 用户 2026-10-08 第 7/8 条大改)。
*
* 只做这几件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明):
* ① 把「块」渲染成一列可编辑行(**段落 textarea / 图片卡** —— v29 起没有清单块了)
* ② 增(+段落 / +图片)、删、上移、下移
* ③ 点「+图片」→ 触发隐藏 file 框 → 上传 → 把返回的 key 填进该图片块
* ④ 提交前把当前块**序列化成 JSON** 写进隐藏域 `blocks`
* ⑤ 🔴 v29 第 7 条:**工具条**(点一下插到光标处)+ **斜线命令**(行首打 `/`)
* ⑥ 🔴 v29 第 8 条:图片可以**调大小**(宽度 25/50/75/100%)
*
* ⚠️ 为什么状态放在 DOM 上而不是一个 JS 数组里:
* 用户可能在某个 textarea 里打字打到一半就点「上移」——若块数据存在数组里、
* 每次操作都按数组重新渲染,那次输入就会丢。改成"DOM 即真相":
* textarea 的值**就在 DOM 里**,移动只是把一个节点挪个位置,输入一字不丢。
* 只有提交时才从 DOM 读一遍(serialize())。
*
* ⚠️ 用**事件委托**(在容器上挂一个点击处理)而不是给每块挂:块会被增删,
* 逐个挂容易漏掉新加的;委托则天然覆盖后来加的块。
*
* 🔴 v29 第 8 条:**清单块去掉**(用户原话「清单功能不要了吧,markdown 里面
* 包含这个功能」)。所以 `+清单` 按钮与 list 分支都没了 ——
* 想列清单就在段落里写 `- 项`(Markdown),渲染出来一样是列表。
*/
(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 = (form.getAttribute('data-media-base')
|| document.body.getAttribute('data-media-base')
|| 'media.php?k=');
// ---------------------------------------------------------------------
// 🔴 v29 第 7 条:Markdown 命令表(**工具条与斜线菜单共用这一份**)
// ---------------------------------------------------------------------
//
// ⚠️ 为什么只有一份:如果工具条和斜线各写一套,迟早出现"按钮能插、
// 斜线插不了"或者"两处语法不一样"(这正是用户抱怨的"要记住所有命令")。
// 每个命令:wrap = 用来包住选中文本(含 %s),或 insert = 无选中时插的模板。
var CMDS = {
b: { word: '粗体', keys: ['bold', 'b'], wrap: '**%s**', tip: '加粗' },
i: { word: '斜体', keys: ['italic', 'i'], wrap: '*%s*', tip: '斜体' },
h2: { word: '标题', keys: ['h2', 'head'], wrap: '## %s', tip: '小标题' },
ul: { word: '列表', keys: ['ul', 'list'], wrap: '- %s', tip: '列表项' },
quote: { word: '引用', keys: ['quote', 'q'], wrap: '> %s', tip: '引用' },
code: { word: '代码', keys: ['code'], wrap: '```\n%s\n```', tip: '代码块' },
link: { word: '链接', keys: ['link', 'url'], wrap: '[%s](https://)', tip: '链接' },
img: { word: '图片', keys: ['img'], insert: '+ 图片(点左侧「+ 图片」按钮选文件)' }
};
/** 在 textarea 的**光标处**套上/插入一段 Markdown。 */
function applyCmd(ta, key) {
var c = CMDS[key];
if (!ta || !c) { return; }
var v = ta.value;
var s = ta.selectionStart || 0;
var e = ta.selectionEnd || 0;
var sel = v.slice(s, e);
var out;
if (c.insert) {
out = c.insert;
ta.value = v.slice(0, s) + out + v.slice(e);
ta.selectionStart = ta.selectionEnd = s + out.length;
} else if (sel !== '') {
// 有选中 → 包住它(多行时**逐行**套,这样"选中三行 → 列表"会得到三个列表项)
out = c.wrap.indexOf('%s') >= 0
? c.wrap.replace('%s', sel.indexOf('\n') >= 0
? sel.split('\n').map(function (l) { return l; }).join('\n')
: sel)
: sel + c.wrap;
ta.value = v.slice(0, s) + out + v.slice(e);
ta.selectionStart = s;
ta.selectionEnd = s + out.length;
} else {
// 没选中 → 插模板,光标落在 %s 处(用户接着打内容)
out = c.wrap;
var at = out.indexOf('%s');
ta.value = v.slice(0, s) + out + v.slice(e);
ta.selectionStart = ta.selectionEnd = s + (at >= 0 ? at : out.length);
}
ta.focus();
// 输入事件要**手动触发**:程序化改 value 浏览器不会发 input,
// 而预览与序列化都挂在那个事件上(不触发就会出现"插了但预览没变")。
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
// ---------------------------------------------------------------------
// 渲染一块
// ---------------------------------------------------------------------
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 parts = String(content || '').split('|');
var key = parts[0] || '';
var w = parseInt(parts[1] || '0', 10) || 0;
var img = document.createElement('img');
img.className = 'note-block-img';
img.alt = '笔记图片';
img.src = key ? (mediaBase + encodeURIComponent(key)) : '';
if (!key) { img.classList.add('is-empty'); }
applyImgWidth(img, w);
var bar = document.createElement('div');
bar.className = 'note-img-bar';
var hint = document.createElement('button');
hint.type = 'button';
hint.className = 'note-img-pick';
hint.textContent = key ? '换一张' : '选图片';
hint.setAttribute('data-op', 'pick');
bar.appendChild(hint);
/* 🔴 v29 第 8 条:「图文混排时,需要可以调整大小。」
四个档位(25 / 50 / 75 / 100%),点一下立即生效。
⚠️ 用**百分比**而不是像素:同一张图在手机与桌面上要按容器宽等比缩,
写死 px 在窄屏会溢出去(本项目在名册上刚栽过一次溢出)。 */
var sizes = document.createElement('span');
sizes.className = 'note-img-sizes';
[25, 50, 75, 100].forEach(function (pct) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'note-img-size' + ((pct === w || (pct === 100 && w === 0)) ? ' is-on' : '');
b.setAttribute('data-op', 'size');
b.setAttribute('data-pct', String(pct));
b.textContent = pct + '%';
sizes.appendChild(b);
});
bar.appendChild(sizes);
body.appendChild(img);
body.appendChild(bar);
body.setAttribute('data-key', key);
body.setAttribute('data-w', String(w));
} else {
var ta = document.createElement('textarea');
ta.className = 'input note-block-ta';
ta.rows = 4;
ta.value = content || '';
ta.placeholder = '写点什么…支持 Markdown:**粗体** *斜体* # 标题 - 列表 > 引用 `代码`(行首打 / 选命令)';
body.appendChild(ta);
/* 段落下方的实时预览("所见即所得")。
⚠️ v29 起**只有段落**这一种文本块了(清单并入 Markdown),
所以这里不再需要按 kind 分支。 */
var pv = document.createElement('div');
pv.className = 'note-block-pv';
pv.setAttribute('data-pv', '1');
body.appendChild(pv);
}
wrap.appendChild(body);
return wrap;
}
/** 把宽度百分比应用到 <img>(0 或 100 都不写 style,用默认 100%) */
function applyImgWidth(img, pct) {
if (!img) { return; }
if (pct > 0 && pct < 100) {
img.style.width = pct + '%';
} else {
img.style.width = '';
}
}
/* ---- 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; }
var tok = form.querySelector('input[name="_csrf"]');
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');
if (!b) { return; }
var key = b.getAttribute('data-key') || '';
var w = parseInt(b.getAttribute('data-w') || '0', 10) || 0;
// 宽度只在 20~99 时写进内容(100 是默认,不必写)
/* ⚠️ 宽度只在**有图**时才写进 content(`key|50`)——没有 key 的
`|50` 是无效内容(服务端会当空块丢掉,用户看到"刚选的尺寸没了")。
但**尺寸本身要留住**(存在 data-w 上):用户可能先点 50% 再选图,
选完图那一刻 serialize 会带上宽度(见下面)。 */
content = (key !== '' && w > 20 && w < 100) ? (key + '|' + w) : 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 ? e.target.closest('[data-op]') : null;
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;
}
if (op === 'size') {
// 🔴 v29 第 8 条:调图片大小(就地生效 + 存进块内容)
var b = el.querySelector('.note-block-body');
var pct = parseInt(btn.getAttribute('data-pct') || '100', 10) || 100;
b.setAttribute('data-w', String(pct));
applyImgWidth(el.querySelector('.note-block-img'), pct);
Array.prototype.forEach.call(el.querySelectorAll('.note-img-size'), function (x) {
x.classList.toggle('is-on', x === btn);
});
serialize();
return;
}
});
/* 🔴 v29 第 7 条:工具条(点一下插到光标处)。
⚠️ 作用对象是**最后聚焦的那个 textarea**(`lastTa`)—— 点按钮时焦点已经
离开 textarea 了,光看 `document.activeElement` 是拿不到它的。 */
var lastTa = null;
list.addEventListener('focusin', function (e) {
if (e.target && e.target.tagName === 'TEXTAREA') { lastTa = e.target; }
});
Array.prototype.forEach.call(document.querySelectorAll('[data-cmdbar] [data-cmd]'),
function (b) {
b.addEventListener('click', function () {
var ta = lastTa || list.querySelector('textarea');
applyCmd(ta, b.getAttribute('data-cmd'));
if (ta) { schedulePreview(ta.closest('.note-block')); }
});
});
// 顶部「+段落 / +图片」
Array.prototype.forEach.call(document.querySelectorAll('.note-add [data-add]'), 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, '');
}
});
});
/* ---------------------------------------------------------------------
🔴 v29 第 7 条:**斜线命令**(行首打 `/` 弹出菜单)
---------------------------------------------------------------------
用户原话:「例如插入一个斜线,然后输入代码,例如 code,
就能自动补全 code 的 markdown 代码。」
⇒ 在段落 textarea 里:光标所在的**整行**是 `/xxx` 形态时弹出候选;
继续打字是**过滤**,回车/点击选中即执行那个命令。
⚠️⚠️ 判据挂在 `input` 事件上,**不是** `keydown('/')` ——
第一版写的是"按下 `/` 时 setTimeout(0) 再回头看光标前一个字符是不是 `/`"。
实测**必现失败**:打字快时(或粘贴、或中文输入法上屏、或自动化测试的
`type()` 连打)等到那个 timeout 跑起来,光标前一个字符早就是 `o` 了,
于是菜单**永远不弹** —— 用户看到的就是"这个功能没做"。
正确姿势:每次输入后看**整行**(行首到光标)是不是 `/` + 若干词字符。
这样无论打得多快、是不是一次粘贴进来,判断都成立。 */
function slashContext(ta) {
var v = ta.value;
var pos = ta.selectionStart;
if (pos !== ta.selectionEnd) { return null; } // 有选区时不弹
var before = v.slice(0, pos);
var lineStart = before.lastIndexOf('\n') + 1;
var line = before.slice(lineStart);
var m = /^\/([A-Za-z]*)$/.exec(line);
if (!m) { return null; }
return {start: lineStart, q: m[1].toLowerCase()};
}
function closeSlash() {
if (slashState && slashState.box && slashState.box.parentNode) {
slashState.box.parentNode.removeChild(slashState.box);
}
slashState = null;
}
function slashItemsFor(q) {
var out = [];
Object.keys(CMDS).forEach(function (k) {
var c = CMDS[k];
var hit = k.indexOf(q) === 0 || c.word.indexOf(q) === 0
|| (c.keys || []).some(function (x) { return x.indexOf(q) === 0; });
if (hit) { out.push([k, c]); }
});
return out;
}
function openSlash(ta, ctx) {
var box = document.createElement('div');
box.className = 'slash-menu';
// 挂在块上(不是 body):跟着块走,块被删/移动时不会留下孤儿菜单
var host = ta.closest('.note-block-body') || ta.parentNode;
host.appendChild(box);
slashState = { ta: ta, box: box, start: ctx.start, items: [] };
renderSlash(ctx.q);
}
/** 当前打开的斜线菜单状态:{ta, box, start, items};null = 没开 */
var slashState = null;
/** 输入过程中同步菜单:该弹就弹、该换词就换词、不该弹就收起来 */
function syncSlash(ta) {
var ctx = slashContext(ta);
if (!ctx) { closeSlash(); return; }
var items = slashItemsFor(ctx.q);
if (!items.length) { closeSlash(); return; }
if (!slashState || slashState.ta !== ta) { openSlash(ta, ctx); return; }
slashState.start = ctx.start;
renderSlash(ctx.q);
}
function renderSlash(q) {
if (!slashState) { return; }
var items = slashItemsFor(q);
slashState.items = items;
var box = slashState.box;
box.innerHTML = '';
if (!items.length) { box.classList.remove('is-on'); return; }
box.classList.add('is-on');
items.forEach(function (it, i) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'slash-item' + (i === 0 ? ' is-on' : '');
b.setAttribute('data-slash', it[0]);
b.innerHTML = '<b>/' + it[0] + '</b><span>' + it[1].tip + '</span>';
b.addEventListener('mousedown', function (ev) {
ev.preventDefault(); // 别让 textarea 失焦(失焦就取不到选区了)
runSlash(it[0]);
});
box.appendChild(b);
});
}
function runSlash(key) {
if (!slashState) { return; }
var ta = slashState.ta;
var box = slashState.box;
// 先把 `/xxx` 这段从 textarea 里剪掉(它只是命令,不该留在正文里)
var v = ta.value;
var upto = ta.selectionStart;
var cut = v.slice(0, slashState.start) + v.slice(upto);
ta.value = cut;
ta.selectionStart = ta.selectionEnd = slashState.start;
// 关掉菜单(先关再执行,applyCmd 会重新 focus)
if (box && box.parentNode) { box.parentNode.removeChild(box); }
slashState = null;
applyCmd(ta, key);
schedulePreview(ta.closest('.note-block'));
}
list.addEventListener('keydown', function (e) {
var ta = e.target;
if (!ta || ta.tagName !== 'TEXTAREA') { return; }
if (slashState && slashState.ta === ta) {
if (e.key === 'Escape') { e.preventDefault(); closeSlash(); return; }
if (e.key === 'Enter' || e.key === 'Tab') {
if (slashState.items.length) {
e.preventDefault(); // 别让它换行
runSlash(slashState.items[0][0]);
return;
}
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
var bs = Array.prototype.slice.call(slashState.box.querySelectorAll('.slash-item'));
if (bs.length > 1) {
var cur = bs.findIndex(function (x) { return x.classList.contains('is-on'); });
var nxt = ((cur + (e.key === 'ArrowDown' ? 1 : -1)) % bs.length + bs.length) % bs.length;
bs.forEach(function (x, i) { x.classList.toggle('is-on', i === nxt); });
}
return;
}
}
});
// 输入时:刷新斜线过滤 + 预览
list.addEventListener('input', function (e) {
serialize();
var ta = e.target;
if (!ta || ta.tagName !== 'TEXTAREA') { return; }
var block = ta.closest('.note-block');
// 🔴 v29 第 7 条:斜线菜单在**每次输入后**按"整行是不是 /xxx"重判
// (见 slashContext 的注释:挂在 keydown('/') 上会漏掉快速输入/粘贴)
syncSlash(ta);
if (block && block.getAttribute('data-kind') === 'text') {
schedulePreview(block);
}
});
list.addEventListener('blur', function (e) {
// 失焦就关菜单(点别处了)。用 0ms 让"点击候选"先跑完(它是 mousedown)。
if (slashState && e.target === slashState.ta) {
window.setTimeout(closeSlash, 120);
}
}, true);
// ---------------------------------------------------------------------
// 图片上传
// ---------------------------------------------------------------------
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:此时 key 才有了值,宽度(data-w)才写进 content。
// 否则"先点 50% 再选图"的人,尺寸要到下次改动才生效。
serialize();
}).catch(function () {
if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; }
alert('图片上传失败,请重试。');
});
// 清空 input,否则连选同一张图不会再触发 change
fileIn.value = '';
});
/* 实时预览开关("允许所见即所得编辑,也允许 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();
/* 编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下
键盘才第一次看到"所见即所得",会以为这功能没生效)。 */
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
function (b) { renderPreview(b); });
// ---------------------------------------------------------------------
// 标签联想:点一下把 #标签 追加进输入框
// ---------------------------------------------------------------------
var tagIn = document.getElementById('noteTags');
Array.prototype.forEach.call(document.querySelectorAll('.note-tags-suggest [data-tag]'),
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);
});
});
})();