用户 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% 在别的批次数据上假红)。
644 lines
29 KiB
JavaScript
644 lines
29 KiB
JavaScript
/**
|
||
* 个人笔记 · 分块编辑器(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);
|
||
});
|
||
});
|
||
})(); |