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;迁移用一次性哨兵。
This commit is contained in:
+125
-14
@@ -1089,8 +1089,18 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
||||
以下都是 2026-09-30 那一批反馈加的东西
|
||||
============================================================ */
|
||||
|
||||
/* 只给读屏用的标签:视觉上不占地,但无障碍名还在(别用 display:none,那会一起藏掉) */
|
||||
.sr-only {
|
||||
/* 只给读屏用的标签:视觉上不占地,但无障碍名还在(别用 display:none,那会一起藏掉)
|
||||
🔴 v29 用户第 2 条:「任务完成复选框的提示文字还在」——
|
||||
真根因:`_task_check.php` 与任务看板用的是 `class="visually-hidden"`,
|
||||
而**本文件里从来没有这个类**(项目一直只定义 `.sr-only`)。
|
||||
CSS 里查不到的类 = 没有任何样式 → 那段文字是**普通 inline span,直接显示出来**,
|
||||
就贴在复选框旁边(用户看到的就是「标记完成:待办」)。
|
||||
⇒ 修法:把两个名字指向同一条规则(两种写法都认)。
|
||||
为什么不是"把模板改成 sr-only 了事":这个类名在下游(别的模板、以后新写的)
|
||||
同样可能被用到,名字不再是隐患;而只改调用点的话,下次谁再写
|
||||
`visually-hidden` 就又漏了。这里是"给能力补上定义",不是"给某一处打补丁"。*/
|
||||
.sr-only,
|
||||
.visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
@@ -4611,17 +4621,23 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
||||
.roster-tagbar-k { font-size: 12.5px; color: var(--faint); margin-right: 2px; }
|
||||
.roster-tagbar-clear { margin-left: auto; font-size: 12.5px; }
|
||||
|
||||
/* ---- 平铺列表:卡片单列铺开,不再有"栏"的分隔线 ----
|
||||
⚠️ `.people-grid` 原来在多栏里是 grid;平铺后让它 2 列自适应(宽屏两列、
|
||||
窄屏一列),既不像单列那样长得没边,也不至于把姓名行挤断。
|
||||
用 auto-fill + minmax 而不是写死列数:名册人数从 3 到 1000+ 都用同一套。 */
|
||||
.roster.is-flat .people-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.roster.is-flat .people-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
/* ---- 平铺列表:**仍然单列**(只是不再有"栏"的分隔线)----
|
||||
🔴🔴 v29 用户第 3 条:「名册页面完全错乱」—— 这个 bug 是 v28 **我自己引入的**。
|
||||
v28 我把这里写成了多列 grid(`repeat(auto-fill, minmax(320px,1fr))`),
|
||||
真实后果有两个,都实测到了:
|
||||
① 卡片上有 `content-visibility: auto` + `contain-intrinsic-size: auto 68px`
|
||||
(v24 为"千人名册不卡死"加的)。它在**单列流**里是安全的,
|
||||
一旦卡片变成 grid item,屏外卡片的"估算高度"与网格轨道互相打架,
|
||||
布局当场乱掉;
|
||||
② 每列只剩 ~339px(1280 宽 ÷ 3),而卡片里是
|
||||
[拖柄][头像 52px + 姓名/徽标][标签+编辑+删除 三个按钮 ≈213px] ——
|
||||
实测 `person-main` 只分到 **106px**(含 52px 头像 → 姓名区仅 42px),
|
||||
姓名、徽标全被挤碎,动作区还会折行。
|
||||
⇒ 修法:**回到单列**(`.people-grid` 本来就是单列 flex,见文件上方 328 行),
|
||||
把 v28 那条 grid 规则整个删掉。名册人数从 3 到 1000+ 都是单列,
|
||||
与"分栏时代每一栏的卡片"同宽 —— 那才是这套卡片的原始设计宽度。
|
||||
⚠️ 别再用"多列网格"来塞名册卡片:它的内容是**横向三段式**的,
|
||||
天生吃宽度;窄了就一定碎。 */
|
||||
|
||||
/* ---- 🔴 用户第 6 条:「名册,每个人后面的按钮,也是要有背景颜色的。」
|
||||
原来三颗按钮全是 .btn-ghost(白底 + 极浅描边),在纸白卡片上几乎看不见边界。
|
||||
@@ -4756,8 +4772,16 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
||||
.note-ex-empty { color: var(--faint); font-style: italic; }
|
||||
/* 图片:等比缩放到卡片宽,放在摘要下方(用户:"图片缩放后,放在下面") */
|
||||
.note-thumb { margin-top: 8px; flex: 0 0 auto; overflow: hidden; border-radius: var(--radius-s); }
|
||||
/* 🔴 v29 第 6 条:「笔记,首页显示的时候,里面的图片,和行程的类似,
|
||||
把图片按照比例缩放到固定的高度或者宽度,放在下面就行了。」
|
||||
⇒ 固定高度 + **等比完整显示**(contain),不再 cover 裁切。
|
||||
⚠️ 从 cover 改 contain 是有意的取舍:
|
||||
cover 能让每张图铺满、看着"实",但**看不到完整内容**(横图两端被切掉)——
|
||||
用户要的是"按比例缩小放在下面",那就是要看到**整张**。
|
||||
代价是宽高比与格子不一致时两侧留浅底,所以给个底色让留白显得是有意的。 */
|
||||
.note-thumb img {
|
||||
width: 100%; height: 88px; object-fit: cover; display: block;
|
||||
width: 100%; height: 110px; object-fit: contain; display: block;
|
||||
background: #F6F4F0;
|
||||
border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||
}
|
||||
.note-card .note-tags-inline { margin-top: auto; padding-top: 8px; flex: 0 0 auto; }
|
||||
@@ -4872,3 +4896,90 @@ h5.md-h, h6.md-h { font-size: 14px; }
|
||||
/* 姓名那一栏允许收缩并截断,否则长名字也会顶破 */
|
||||
.pname { overflow-wrap: anywhere; }
|
||||
}
|
||||
/* 🔴 v29 第 3 条(窄屏半边):360px 下卡片只有 328px,
|
||||
[拖柄 24][头像 52 + 姓名][标签+编辑+删除 ≈197] 硬塞在一行 → 姓名区只剩 ~40px。
|
||||
⇒ 窄屏把动作区**整体折到第二行**(卡片改纵向),姓名那一行就宽裕了。
|
||||
用 flex-direction: column 而不是给动作区定宽:后者的宽度随标签数变化,
|
||||
定了也会被长标签顶开(v28 就是这么栽的)。 */
|
||||
@media (max-width: 640px) {
|
||||
.person { flex-wrap: wrap; }
|
||||
.person .person-main { flex: 1 1 calc(100% - 40px); }
|
||||
.person-actions {
|
||||
flex: 1 1 100%; justify-content: flex-start;
|
||||
padding: 0 10px 10px 34px; /* 左边缩进到与姓名对齐(躲开拖柄) */
|
||||
}
|
||||
.person-tag-btn { max-width: 46vw; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
v29 用户第 7 / 8 / 9 条:笔记 —— 阅读态 / 工具条 / 斜线命令 / 图片调大小
|
||||
============================================================ */
|
||||
|
||||
/* ---- 阅读态(第 8 条:点进来默认是"看")----
|
||||
与编辑态的区别要**一眼看得出**:没有虚线框、没有按钮,就是一篇排好版的文档。 */
|
||||
.note-read { max-width: 820px; }
|
||||
.note-read-title { margin: 0 0 6px; font-size: 22px; font-weight: 600; color: var(--ink); }
|
||||
.note-read-tags { margin: 0 0 14px; }
|
||||
.note-read-body { display: flex; flex-direction: column; gap: 14px; }
|
||||
.note-read-block { font-size: 15px; line-height: 1.8; color: var(--ink); }
|
||||
/* 图片:默认满宽,可调(内联 style 给百分比);圆角与卡片一致 */
|
||||
.note-read-fig { margin: 0; }
|
||||
.note-read-fig img {
|
||||
max-width: 100%; height: auto; display: block;
|
||||
border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||
}
|
||||
.note-read-fig a { display: inline-block; max-width: 100%; }
|
||||
.note-read-h2 {
|
||||
margin: 0 0 8px; font-size: 14px; font-weight: 600; color: var(--muted);
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
/* 附件区:一条细线把它与正文分开("在最下方"要看得出来是**另一块**) */
|
||||
.note-read-files { margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--line-2); }
|
||||
.note-read .note-share-now { margin-top: 16px; }
|
||||
|
||||
/* ---- 编辑态:Markdown 工具条(第 7 条"要么有工具条")---- */
|
||||
.note-cmdbar {
|
||||
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
|
||||
padding: 6px 8px; margin-bottom: 8px;
|
||||
border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||
background: #FDFCFA;
|
||||
}
|
||||
.note-cmdbar .cmd {
|
||||
min-width: 28px; height: 26px; padding: 0 7px;
|
||||
border: 1px solid var(--line); border-radius: 5px; background: #fff;
|
||||
color: var(--ink); cursor: pointer; font-size: 13px; line-height: 1;
|
||||
}
|
||||
.note-cmdbar .cmd:hover { border-color: var(--brand-2); color: var(--brand-ink); background: var(--brand-soft, #FDF3EC); }
|
||||
.note-cmdbar .cmd-sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
|
||||
.note-cmdbar .note-live { margin-left: auto; }
|
||||
|
||||
/* ---- 斜线命令菜单(第 7 条"要么有代码提示")----
|
||||
⚠️ 绝对定位在**块内**(不是 body):跟着块走,块删除/移动时不会留孤儿菜单。 */
|
||||
.note-block-body { position: relative; }
|
||||
.slash-menu {
|
||||
position: absolute; left: 0; top: 100%; z-index: 30; display: none;
|
||||
min-width: 190px; max-height: 220px; overflow-y: auto;
|
||||
background: #fff; border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||
box-shadow: var(--shadow); padding: 4px;
|
||||
}
|
||||
.slash-menu.is-on { display: block; }
|
||||
.slash-item {
|
||||
display: flex; align-items: baseline; gap: 8px; width: 100%;
|
||||
padding: 5px 8px; border: 0; border-radius: 5px; background: transparent;
|
||||
cursor: pointer; text-align: left; font-size: 13px; color: var(--ink);
|
||||
}
|
||||
.slash-item b { color: var(--brand); font-family: ui-monospace, Consolas, monospace; }
|
||||
.slash-item span { color: var(--muted); font-size: 12px; }
|
||||
.slash-item:hover, .slash-item.is-on { background: var(--brand-soft, #FDF3EC); }
|
||||
|
||||
/* ---- 图片块:尺寸档位(第 8 条"图文混排时,需要可以调整大小")---- */
|
||||
.note-img-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.note-img-sizes { display: inline-flex; gap: 3px; margin-left: auto; }
|
||||
.note-img-size {
|
||||
padding: 2px 7px; border: 1px solid var(--line); border-radius: 5px;
|
||||
background: #fff; color: var(--muted); cursor: pointer; font-size: 11.5px;
|
||||
}
|
||||
.note-img-size:hover { border-color: var(--brand-2); color: var(--brand-ink); }
|
||||
.note-img-size.is-on { border-color: var(--brand); background: var(--brand-soft, #FDF3EC); color: var(--brand-ink); }
|
||||
/* 图上标着当前档位,编辑器里一眼能看出"这张被缩过了" */
|
||||
.note-block[data-kind="image"] .note-block-img { transition: width .12s ease; }
|
||||
|
||||
+332
-74
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* 个人笔记 · 分块编辑器(v27 用户 2026-10-07)。
|
||||
* 个人笔记 · 分块编辑器(v27 起;v29 用户 2026-10-08 第 7/8 条大改)。
|
||||
*
|
||||
* 只做四件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明):
|
||||
* ① 把「块」渲染成一列可编辑行(段落 textarea / 图片卡 / 清单 textarea)
|
||||
* ② 增(+段落 / +图片 / +清单)、删、上移、下移
|
||||
* 只做这几件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明):
|
||||
* ① 把「块」渲染成一列可编辑行(**段落 textarea / 图片卡** —— v29 起没有清单块了)
|
||||
* ② 增(+段落 / +图片)、删、上移、下移
|
||||
* ③ 点「+图片」→ 触发隐藏 file 框 → 上传 → 把返回的 key 填进该图片块
|
||||
* ④ 提交前把当前块**序列化成 JSON** 写进隐藏域 `blocks`
|
||||
* ⑤ 🔴 v29 第 7 条:**工具条**(点一下插到光标处)+ **斜线命令**(行首打 `/`)
|
||||
* ⑥ 🔴 v29 第 8 条:图片可以**调大小**(宽度 25/50/75/100%)
|
||||
*
|
||||
* ⚠️ 为什么状态放在 DOM 上而不是一个 JS 数组里:
|
||||
* 用户可能在某个 textarea 里打字打到一半就点「上移」——若块数据存在数组里、
|
||||
@@ -15,6 +17,10 @@
|
||||
*
|
||||
* ⚠️ 用**事件委托**(在容器上挂一个点击处理)而不是给每块挂:块会被增删,
|
||||
* 逐个挂容易漏掉新加的;委托则天然覆盖后来加的块。
|
||||
*
|
||||
* 🔴 v29 第 8 条:**清单块去掉**(用户原话「清单功能不要了吧,markdown 里面
|
||||
* 包含这个功能」)。所以 `+清单` 按钮与 list 分支都没了 ——
|
||||
* 想列清单就在段落里写 `- 项`(Markdown),渲染出来一样是列表。
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -29,13 +35,70 @@
|
||||
// 上传目标(哪个图片块在等这次上传的结果)
|
||||
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 }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 渲染一块
|
||||
// ---------------------------------------------------------------------
|
||||
// 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';
|
||||
@@ -53,53 +116,75 @@
|
||||
body.className = 'note-block-body';
|
||||
|
||||
if (kind === 'image') {
|
||||
// 图片块:content 是附件 key。填 key 的同时把预览换出来。
|
||||
// 图片块: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 = content ? (mediaBase + encodeURIComponent(content)) : '';
|
||||
if (!content) { img.classList.add('is-empty'); }
|
||||
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 = content ? '换一张' : '选图片';
|
||||
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(hint);
|
||||
body.setAttribute('data-key', content || '');
|
||||
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 = kind === 'list' ? 4 : 3;
|
||||
ta.rows = 4;
|
||||
ta.value = content || '';
|
||||
ta.placeholder = kind === 'list'
|
||||
// v28 第 12 条要求"清单可以多级":缩进即层级(两个空格一层)
|
||||
? '一行一项;行首缩进两个空格 = 子项(可多级)'
|
||||
: '写点什么…支持 Markdown:**粗体** *斜体* # 标题 > 引用 `代码`';
|
||||
ta.placeholder = '写点什么…支持 Markdown:**粗体** *斜体* # 标题 - 列表 > 引用 `代码`(行首打 / 选命令)';
|
||||
body.appendChild(ta);
|
||||
/* 🔴 v28 第 12 条:段落支持 Markdown,且要**所见即所得**。
|
||||
做法:在 textarea 下方跟一个预览块,输入时(防抖)请服务端渲染。
|
||||
⚠️ 为什么预览请求服务端而不是前端自己渲染:两套渲染器必然漂移
|
||||
(预览是粗体、保存后不是),而用户看到的就是预览。
|
||||
清单块**不给**预览:清单是"一行一项"的结构化输入,预览意义不大,
|
||||
反而会占掉一半高度(这里是"轻量"的取舍,不是漏了)。 */
|
||||
/* 🔴 v28 第 12 条:段落与**清单**都支持 Markdown 渲染(清单的多级就靠它)。
|
||||
段落:原文直接渲染。
|
||||
清单:把每一行包成 `- 行内容` 再渲染 —— 行首的缩进原样保留,
|
||||
于是"缩进两个空格 = 子项"这条规则自然生效(多级清单)。
|
||||
这样做还有个好处:**渲染规则只有一份**(都在服务端 Markdown 里),
|
||||
不必为清单再写一套层级解析(两套必然漂移)。 */
|
||||
if (kind === 'text' || kind === 'list') {
|
||||
var pv = document.createElement('div');
|
||||
pv.className = 'note-block-pv';
|
||||
pv.setAttribute('data-pv', '1');
|
||||
body.appendChild(pv);
|
||||
}
|
||||
/* 段落下方的实时预览("所见即所得")。
|
||||
⚠️ 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) {
|
||||
@@ -110,15 +195,7 @@
|
||||
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; }
|
||||
// 清单块:每行包成 `- xxx`(缩进原样),交给服务端同一套 Markdown 渲染 → 天然多级
|
||||
var block = pv.closest ? pv.closest('.note-block') : null;
|
||||
if (block && block.getAttribute('data-kind') === 'list') {
|
||||
md = md.split(/\r?\n/).map(function (line) {
|
||||
return line.trim() === '' ? '' : ('- ' + line.replace(/^(\s*)[-*+]\s+/, '$1'));
|
||||
}).join('\n');
|
||||
}
|
||||
var form = document.getElementById('noteForm');
|
||||
var tok = form ? form.querySelector('input[name="_csrf"]') : null;
|
||||
var tok = form.querySelector('input[name="_csrf"]');
|
||||
var fd = new FormData();
|
||||
fd.append('md', md);
|
||||
if (tok) { fd.append('_csrf', tok.value); }
|
||||
@@ -150,7 +227,15 @@
|
||||
var content;
|
||||
if (kind === 'image') {
|
||||
var b = el.querySelector('.note-block-body');
|
||||
content = (b && b.getAttribute('data-key')) || '';
|
||||
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 : '';
|
||||
@@ -189,7 +274,7 @@
|
||||
|
||||
// 事件委托:统一处理块上的所有按钮
|
||||
list.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-op]');
|
||||
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');
|
||||
@@ -204,10 +289,38 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// 顶部「+段落 / +图片 / +清单」
|
||||
document.querySelectorAll('.note-add [data-add]').forEach(function (b) {
|
||||
/* 🔴 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') {
|
||||
@@ -221,6 +334,161 @@
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
🔴 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);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 图片上传
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -258,6 +526,8 @@
|
||||
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; }
|
||||
@@ -267,20 +537,7 @@
|
||||
fileIn.value = '';
|
||||
});
|
||||
|
||||
// textarea 输入时同步(这样即便用户直接点提交,内容也是最新的)
|
||||
// + v28:段落块顺手刷新 Markdown 实时预览
|
||||
list.addEventListener('input', function (e) {
|
||||
serialize();
|
||||
var ta = e.target;
|
||||
if (ta && ta.tagName === 'TEXTAREA') {
|
||||
var block = ta.closest ? ta.closest('.note-block') : null;
|
||||
if (block && block.getAttribute('data-kind') === 'text') {
|
||||
schedulePreview(block);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* 实时预览开关(用户第 12 条:"允许所见即所得编辑,也允许 markdown 编辑")——
|
||||
/* 实时预览开关("允许所见即所得编辑,也允许 markdown 编辑")——
|
||||
关掉之后预览块收起,textarea 能占满整块(专心改 Markdown 源码时更清爽)。 */
|
||||
var liveBox = document.getElementById('noteLivePreview');
|
||||
if (liveBox) {
|
||||
@@ -325,7 +582,7 @@
|
||||
initial.forEach(function (b) { addBlock(b.kind, b.content); });
|
||||
serialize();
|
||||
|
||||
/* v28:编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下
|
||||
/* 编辑已有笔记时,让段落**一进来就看到渲染结果**(否则用户要动一下
|
||||
键盘才第一次看到"所见即所得",会以为这功能没生效)。 */
|
||||
Array.prototype.forEach.call(list.querySelectorAll('.note-block[data-kind="text"]'),
|
||||
function (b) { renderPreview(b); });
|
||||
@@ -334,14 +591,15 @@
|
||||
// 标签联想:点一下把 #标签 追加进输入框
|
||||
// ---------------------------------------------------------------------
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user