/** * 个人笔记 · 分块编辑器(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 = '' + '' + ''; 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' ? '一行一项,回车换行' : (kind === 'text' ? '写点什么…' : ''); // 清单块自动在每行前加「· 」的视觉提示(内容本身不加,保持纯文本) body.appendChild(ta); } wrap.appendChild(body); return wrap; } // --------------------------------------------------------------------- // 序列化:从 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 输入时同步(这样即便用户直接点提交,内容也是最新的) list.addEventListener('input', serialize); // 提交前再序列化一次(双保险) 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(); // --------------------------------------------------------------------- // 标签联想:点一下把 #标签 追加进输入框 // --------------------------------------------------------------------- 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); }); }); })();