/** * 个人笔记 · 分块编辑器(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 = '' + '' + ''; 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); /* 🔴🔴 v52(用户 2026-10-15 第 3 条):「在编辑笔记的时候,或者在新增笔记的时候, …加段落和加图片应该是在**输入框的下面**。否则的话,如果都在上面的话, 我一段一段的往下写,每次要增加一个段落或者增加一个图片,我都要划上去增加一个段落, 再划下来……所以它应该是在正文这个框的**下面**,每增加一段,就应该在这个段落的下面 出现加段落和加图片。上面和下(的按钮)都保留,下面再增加一个加段落和加图片的按钮。 就是它在随着这个输入框中的增多,一直往下走。」 ⇒ 每一块的**下方**再放一排「+段落 / +图片」,点了就**插在这一块后面** (`addBlock(kind, '', wrap)` 的 afterEl 语义正是"插在它后面")。 ⚠️ 挂在 **`body` 里**(不是挂 `wrap`):`.note-block` 是 `display:flex` 的横排 (左边操作列 + 右边正文),挂到 `wrap` 上会变成横排的第三列(跑到输入框右边), 而不是"在输入框下面"。挂进 `body` 才是真的在输入框下方。 ⚠️ 顶部的 `.note-add` **保留不动** —— 用户明确说"上面和下面都保留"。 ⚠️ 用 `data-add-below` 标记(不是 `data-add`):顶部那组是"追加到末尾", 这一组是"插在本块后面",语义不同,共用标记会互相抢绑定。 */ var foot = document.createElement('div'); foot.className = 'note-block-add'; foot.innerHTML = '' + ''; body.appendChild(foot); return wrap; } /** 把宽度百分比应用到 (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) { /* 🔴 v52(用户第 3 条):**块内下方**的「+段落 / +图片」也走这个委托。 ⚠️ 必须放前面判(它不带 `data-op`),否则会被下面那行 `[data-op]` 的 `closest` 直接漏掉 —— 按钮点了没反应,而且看起来"按钮明明在"。 */ var addBelow = e.target.closest ? e.target.closest('[data-add-below]') : null; if (addBelow && list.contains(addBelow)) { var thisBlock = addBelow.closest('.note-block'); if (!thisBlock) { return; } var k2 = addBelow.getAttribute('data-add-below'); // 「插在这一块**后面**」—— addBlock 的 afterEl 语义正是如此 var el2 = addBlock(k2, '', thisBlock); if (k2 === 'image') { pendingBlock = el2; fileIn.click(); } return; } 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 = '/' + it[0] + '' + it[1].tip + ''; 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); }); }); })();