Files
FamilyBook/assets/js/note_editor.js
T
laoyang ed122c034b v52:旅程 CalDAV 结束时间 / 笔记(标题·段落下加段加图·逐段就地编辑)/ 名册自动匹配与设置页 / 模块设置整合表
用户 2026-10-15 七条。

① 旅程行程经 CalDAV 同步后「结束时间 = 开始时间」
   **真根因**(app/Calendar.php 的 exportRows()):对**派生条目**(观影/旅程)写死
     'ends_at' => null,而 Ics::vevent() 遇到空结束会退化成「DTEND = DTSTART」
     ⇒ 手机上每条行程的结束时间都等于开始。
   **附带第二个 bug**:分组键含日期(kind-ref_id-date),而 month() 把**跨天条目逐天**
     铺出来 ⇒ 跨天节点被拆成 N 条(UID 相同),resources() 按 UID 去重后**只留第一天**,
     而第一天那条没有结束时刻 ⇒ 又回到 DTEND=DTSTART。
   修法:键改成 `kind-ref_id`,先收集同节点的各天,再用**首末天**拼一条真实跨度
     (末天那条才带 end_time)。实测单日 DTEND=12:30、跨天 DTEND=第3天11:00,
     每节点恰好 1 条 VEVENT。
   ⚠️ 副作用(已处理):v31 DAV 去重的反向验证原来靠「跨天节点逐天出、UID 全同」造重复,
     本修把那条素材**修没了** ⇒ 给夹具换成「同一份 .ics 导两次」(两条 UID 相同的
     自定义日程),守卫仍能被证伪(见 _fb_dav_diag.py 与 pitfalls 的同族条目)。

② 笔记详情页标题是"看笔记"而不是笔记名
   阅读态 h1 改用笔记自己的 $noteTitle;**编辑态仍用「改笔记/写笔记」**
   (编辑时标题就在下面第一个输入框,两处重名看着像重复)。浏览器 <title> 同步。

③ 加段落/加图片要在**输入框下面**
   note_editor.js 的 makeBlock() 里每块下方加一排 .note-block-add(+段落/+图片),
   点了插在**这一块后面**;顶部那组**保留**(用户明确"上面和下面都保留")。
   ⚠️ 挂在 body 里而不是 wrap 上 —— .note-block 是 display:flex 横排,
      挂 wrap 会变成"输入框右边的第三列",不是"下面"。
   ⚠️ 用 data-add-below(不是 data-add):顶部是"追加到末尾"、块内是"插在本块后"。

④ 笔记详情页「逐段就地编辑」(分块多时不用跳编辑页找那一块)
   模板给每块包 .note-read-item[data-block][data-kind][data-raw] + 右上角「编辑」按钮;
   新增 assets/js/note_inline.js(按需加载):划过出现 → 点编辑就地变 textarea →
   保存 POST notes/block → 只换这一块。
   服务端 Note::updateBlock():**按块主键改一行**(不是整组替换)——
   ⚠️ 整组替换会换掉每个块的 id(前端手里的 data-block 当场失效)+ 并发互相覆盖;
   ⚠️ SQL 必须带 `AND note_id = ?`(块 id 全局自增,少了它就能改别人笔记里的块)。
   权限与整条编辑同一把尺(find() + canWrite())。

⑤ 删掉「从已有家里人中挑选」,改成「新增/导入时自动匹配」
   新增 Person::matchExisting():手机号(有 phone_hash,一条 SQL 精确命中)→
   身份证(无哈希,整表解密比)→ 姓名(**先记下不立刻返回**,等精确命中看完再退到它)。
   people/create:校验通过后先 matchExisting,命中就在**同一张表单里**出确认条
   (#personConfirm)。⚠️ **不做成单独确认页**:单独页要把整张表(含工作经历/补充联系
   方式这些**数组字段**)原样回传,漏一类就静默丢数据;同表单里字段天然全在。
   ⚠️ 判"是不是第一次提交"用 array_key_exists('confirm_existing')(0=不是、1=是、无=首次)。
   导入那条路的缺口(本轮补):ContactImporter 命中已有的人时只补字段、**不挂进导入者的
   名册** ⇒ "文件里有他、系统里也有他,但导完我的名册里看不到他"。已在命中分支加
   PersonRoster::ensure(..., $meId, ...)。
   顺手加 PersonRoster::has()(避免调用方又踩"visibleIds 是值列表不是集合"那个坑)。

⑥ 名册页只剩「添加成员 + 设置」;分组搬到名册设置
   templates/people/_settings_menu.php(新):☰ 下拉两行 = 导入联系人 / 名册分组。
   templates/people/settings.php(新,路由 people/settings):require 从个人设置**整块搬出来**
   的 templates/_person_groups.php。
   ⚠️ 是**真搬**不是复制(两份一模一样的分组界面,改一处忘一处是必然的);
   ⚠️ 片段变量一律加 pg 前缀 —— 模板片段与宿主 require 是**共享作用域**,
      用 $groups 这种通名会覆盖宿主同名变量(本轮真踩到,已改)。
   ⚠️ 表单 action 仍指 settings/groups/*(一个字段没改)。

⑦ 个人设置:模块设置整合成一张表
   原来是**三张卡三个表单**(首页模块 / 导航模块 / 主题色),同一串模块名出现两遍。
   现在一张表五列:顺序 | 模块 | 导航 | 首页 | 颜色,一次保存。
   新增 settings/modules_save 路由:**底层存储一个没改**(仍是 nav_modules /
   home_modules / module_colors),只是把三个 save 依次调一遍 ⇒ 老数据天然兼容。
   ⚠️ 权限**没有**因为"合并"被放宽:颜色仍是家庭一份、只有管理员能改
      (非管理员那列是只读色块;提交时服务端静默忽略 c[])。
   窄屏:表格 min-width:560px + 外层 overflow-x:auto(375px 实测横向溢出 0)。

验证(新增 5 套件 + 更新若干旧断言):
 · _fb_v52_ics.py(3)/ _fb_v52_note.py(19)/ _fb_v52_roster.py(12)/ _fb_v52_mods.py(13)
 · _b52_falsify.py(**12 注入 / 31 断言全抓**)
 · _fb_import_test.py 补 ⑯(命中的人也要回到导入者名册);_fb_dav_diag.py 换去重素材
 · e2e 1516/0;_fb_batch_e.py / _fb_e2e.py / familybook_deploy.sh 里随 #6/#7 搬家或改形的
   旧断言(分组在个人设置、模块行 li→tr、show_present、RRULE 前缀)意图一字不改
 · 全流水线 **PUSH_RC=0**、部署 0 FAIL(含"第52批形态");线上 16 文件 md5 逐字节一致

⚠️ 工程纪律(已写进 pitfalls.md / 工作日志):
 · 探针自起的 PHP 服务**一律随机空闲端口**(写死 + "连上就复用" 会静默测到别的目录/库);
 · **同一工具链目录 + 同一库名不支持两个会话并发** —— 本轮最后一个多小时的"诡异全红"
   全是**另一个会话**在同时跑 _fb_e2e.py、共享 fb_test 与 _fb_site 造成的;
 · 并发下**连 git stash 都不能用**(会把别人的半成品一起藏起来),基线对照用 git worktree;
 · 修好业务可能让"守着它的反向验证"空转(素材被修没了)⇒ 换一处等价素材,别删守卫;
 · 断言盯**形态**而非具体数值(分栏那次写死 50% 在别的批次数据上假红)。
2026-10-15 23:04:25 +08:00

644 lines
29 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);
/* 🔴🔴 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 =
'<button type="button" class="nb-add" data-add-below="text" title="在这段下面加一段">+ 段落</button>' +
'<button type="button" class="nb-add" data-add-below="image" title="在这段下面加一张图">+ 图片</button>';
body.appendChild(foot);
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) {
/* 🔴 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 = '<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);
});
});
})();