v27 个人笔记模块:分块(段落/图片/清单)+ 单层多标签 + 默认私有可共享
参考 flomo(时间流卡片 + 标签即筛)、Obsidian(图文混排的直觉)、
思源(「块」的粒度),但刻意只取轻的那一层:
- 分块只做 text / image / list 三种,可增删、可上下移;不做块引用 / 块 ID 双链
- 标签单层、多对多,只存字符串(**不建标签表**)——改名/合并这类重型能力一律不做
- 不做富文本工具栏、不做 Markdown 预览、不做拖拽排序(只有 ↑↓ 按钮)
- 可见性默认**私有**,单条可勾「共享给全家」;共享出去只给别人看,不给别人改
落地:
- 新增 app/Note.php(块清洗、标签归一、可见性、认领草稿附件)
- 新增 3 张表 notes / note_blocks / note_tags;Schema VERSION 26→27
- 🔴 attachments.target_type 是 ENUM:加 'note' 必须同时改建表串 +
新增 widenAttachTargetEnum()(老库不做这一步,传笔记图片会被严格模式拒掉)
- 路由 5 条:notes / notes/form / notes/save / notes/destroy / notes/upload
- 模板 templates/notes/index.php(列表)+ form.php(分块编辑)
- JS assets/js/note_editor.js(按需加载,同 person_picker 那套)
- 编辑器状态放 DOM 而非 JS 数组:移动块时用户已输入的内容一字不丢
This commit is contained in:
@@ -4465,3 +4465,109 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
||||
.nav-reuse-f { font-size: 12px; color: var(--muted); margin-top: 8px; }
|
||||
/* 空 img(没有图标可复用)别占位成一个破图框 */
|
||||
.nav-reuse-item img:not([src]) { visibility: hidden; }
|
||||
|
||||
/* ============================================================
|
||||
v27 个人笔记(用户 2026-10-07)
|
||||
参考 flomo(时间流卡片 + 标签)/ Obsidian(图文混排的直觉)/ 思源(分块),
|
||||
但一律取**轻**的那一层:块只有 段落/图片/清单,不做块引用、不做富文本。
|
||||
配色沿用宣纸米 + 砖红,不引入第三套色。
|
||||
============================================================ */
|
||||
|
||||
/* ---- 列表页:左标签云 + 右卡片流 ---- */
|
||||
.note-layout { display: grid; grid-template-columns: 220px 1fr; gap: 16px; align-items: start; }
|
||||
.note-side { position: sticky; top: 16px; }
|
||||
.note-main { min-width: 0; }
|
||||
.note-search-card { margin-bottom: 12px; }
|
||||
.note-search-card .search { margin: 0; }
|
||||
|
||||
.note-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.note-tag {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 4px 9px; border-radius: 999px; cursor: pointer;
|
||||
border: 1px solid var(--line); background: #fff;
|
||||
font-size: 12.5px; color: var(--muted); text-decoration: none;
|
||||
}
|
||||
.note-tag:hover { border-color: var(--brand-2); color: var(--brand-ink); text-decoration: none; }
|
||||
.note-tag.is-on { border-color: var(--brand); color: var(--brand-ink); background: var(--brand-soft, #FDF3EC); }
|
||||
.note-tag b { font-weight: 600; color: var(--faint); font-variant-numeric: tabular-nums; }
|
||||
.note-tag.is-on b { color: var(--brand-ink); }
|
||||
.note-tags-inline { margin-top: 10px; }
|
||||
.note-tags-suggest { margin-top: 8px; align-items: center; }
|
||||
|
||||
/* ---- 卡片 ---- */
|
||||
.note-card { margin-bottom: 12px; }
|
||||
.note-head { display: flex; align-items: center; gap: 8px; }
|
||||
.note-meta { display: flex; align-items: center; gap: 8px; flex: 1 1 auto; min-width: 0; }
|
||||
.note-ava { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
|
||||
.note-who { font-size: 13px; color: var(--ink); font-weight: 500; }
|
||||
.note-time { font-size: 12px; color: var(--faint); margin-left: auto; padding-right: 4px; }
|
||||
/* 共享/私有标记:一眼分清"这是我写给自己看的"还是"全家都能看的" */
|
||||
.note-share, .note-private {
|
||||
font-size: 11px; padding: 1px 7px; border-radius: 999px; flex: 0 0 auto;
|
||||
}
|
||||
.note-share { color: #2F6B4F; background: #E7F2EC; }
|
||||
.note-private { color: var(--faint); background: var(--line-2); }
|
||||
.note-ops { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
|
||||
.note-ops .inline-form { display: inline; }
|
||||
|
||||
.note-title { margin: 10px 0 6px; font-size: 16px; font-weight: 600; color: var(--ink); }
|
||||
.note-body { display: flex; flex-direction: column; gap: 8px; }
|
||||
.note-text { margin: 0; font-size: 14.5px; line-height: 1.7; color: var(--ink); white-space: pre-wrap; }
|
||||
.note-list { margin: 0; padding-left: 20px; font-size: 14.5px; line-height: 1.7; color: var(--ink); }
|
||||
.note-list li { margin: 2px 0; }
|
||||
/* 图片:等比缩到卡片宽,圆角与卡片一致;点开走全站预览层(data-prev) */
|
||||
.note-img { display: block; }
|
||||
.note-img img {
|
||||
max-width: 100%; max-height: 420px; border-radius: var(--radius-s);
|
||||
border: 1px solid var(--line); display: block;
|
||||
}
|
||||
.note-more { text-align: center; margin: 16px 0 8px; }
|
||||
|
||||
/* ---- 编辑页:分块 ---- */
|
||||
.note-form .card { margin-bottom: 12px; }
|
||||
.note-block-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.note-block-head .cinema-h2 { margin: 0; flex: 1 1 auto; }
|
||||
.note-add { display: flex; gap: 6px; flex: 0 0 auto; }
|
||||
.note-blocks { display: flex; flex-direction: column; gap: 8px; }
|
||||
.note-block {
|
||||
display: flex; gap: 8px; align-items: flex-start;
|
||||
border: 1px dashed var(--line); border-radius: var(--radius-s);
|
||||
padding: 8px; background: #FDFCFA;
|
||||
}
|
||||
.note-block:focus-within { border-color: var(--brand-2); border-style: solid; }
|
||||
.note-block-ops { display: flex; flex-direction: column; gap: 4px; flex: 0 0 auto; }
|
||||
.nb-btn {
|
||||
width: 24px; height: 22px; line-height: 1; padding: 0;
|
||||
border: 1px solid var(--line); border-radius: 5px; background: #fff;
|
||||
color: var(--muted); cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.nb-btn:hover { border-color: var(--brand-2); color: var(--brand-ink); }
|
||||
.nb-del:hover { border-color: var(--red); color: var(--red); }
|
||||
.note-block-body { flex: 1 1 auto; min-width: 0; }
|
||||
.note-block-ta { width: 100%; resize: vertical; font-size: 14.5px; line-height: 1.7; }
|
||||
.note-block-img {
|
||||
max-width: 100%; max-height: 320px; border-radius: var(--radius-s);
|
||||
border: 1px solid var(--line); display: block; margin-bottom: 6px;
|
||||
}
|
||||
/* 还没选图时:不要显示破图框(那是 <img src=""> 的默认表现,很难看) */
|
||||
.note-block-img.is-empty { display: none; }
|
||||
.note-img-pick {
|
||||
padding: 4px 10px; border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||
background: #fff; color: var(--muted); cursor: pointer; font-size: 12.5px;
|
||||
}
|
||||
.note-img-pick:hover { border-color: var(--brand-2); color: var(--brand-ink); }
|
||||
.note-share-toggle {
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 12px;
|
||||
font-size: 13.5px; color: var(--muted); cursor: pointer;
|
||||
}
|
||||
.note-share-toggle input { width: 16px; height: 16px; accent-color: var(--brand); }
|
||||
.note-actions { display: flex; gap: 8px; margin: 4px 0 20px; }
|
||||
|
||||
/* ---- 窄屏:标签云折到上面,卡片满宽 ---- */
|
||||
@media (max-width: 760px) {
|
||||
.note-layout { grid-template-columns: 1fr; }
|
||||
.note-side { position: static; }
|
||||
.note-side .card { margin-bottom: 12px; }
|
||||
/* 长英文/URL 不要把卡片撑出横向滚动(全站反复栽在这条上) */
|
||||
.note-text, .note-list, .note-title { overflow-wrap: anywhere; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 个人笔记 · 分块编辑器(v27 用户 2026-10-07)。
|
||||
*
|
||||
* 只做四件事(刻意的轻量,见 templates/notes/form.php 头部的取舍说明):
|
||||
* ① 把「块」渲染成一列可编辑行(段落 textarea / 图片卡 / 清单 textarea)
|
||||
* ② 增(+段落 / +图片 / +清单)、删、上移、下移
|
||||
* ③ 点「+图片」→ 触发隐藏 file 框 → 上传 → 把返回的 key 填进该图片块
|
||||
* ④ 提交前把当前块**序列化成 JSON** 写进隐藏域 `blocks`
|
||||
*
|
||||
* ⚠️ 为什么状态放在 DOM 上而不是一个 JS 数组里:
|
||||
* 用户可能在某个 textarea 里打字打到一半就点「上移」——若块数据存在数组里、
|
||||
* 每次操作都按数组重新渲染,那次输入就会丢。改成"DOM 即真相":
|
||||
* textarea 的值**就在 DOM 里**,移动只是把一个节点挪个位置,输入一字不丢。
|
||||
* 只有提交时才从 DOM 读一遍(serialize())。
|
||||
*
|
||||
* ⚠️ 用**事件委托**(在容器上挂一个点击处理)而不是给每块挂:块会被增删,
|
||||
* 逐个挂容易漏掉新加的;委托则天然覆盖后来加的块。
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var form = document.getElementById('noteForm');
|
||||
var list = document.getElementById('noteBlockList');
|
||||
var sink = document.getElementById('noteBlocks');
|
||||
var seed = document.getElementById('noteSeed');
|
||||
var fileIn = document.getElementById('noteImgInput');
|
||||
if (!form || !list || !sink) { return; }
|
||||
|
||||
// 上传目标(哪个图片块在等这次上传的结果)
|
||||
var pendingBlock = null;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 渲染一块
|
||||
// ---------------------------------------------------------------------
|
||||
// media 出口的基址由模板注入(页面上的 `data-media-base`)——
|
||||
// 不写死 'media.php?k=':将来 media 端点换名字/加前缀,这里不用跟着改。
|
||||
var mediaBase = (document.body.getAttribute('data-media-base') || 'media.php?k=');
|
||||
|
||||
function makeBlock(kind, content) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'note-block';
|
||||
wrap.setAttribute('data-kind', kind);
|
||||
|
||||
var ops = document.createElement('div');
|
||||
ops.className = 'note-block-ops';
|
||||
ops.innerHTML =
|
||||
'<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 img = document.createElement('img');
|
||||
img.className = 'note-block-img';
|
||||
img.alt = '笔记图片';
|
||||
img.src = content ? (mediaBase + encodeURIComponent(content)) : '';
|
||||
if (!content) { img.classList.add('is-empty'); }
|
||||
var hint = document.createElement('button');
|
||||
hint.type = 'button';
|
||||
hint.className = 'note-img-pick';
|
||||
hint.textContent = content ? '换一张' : '选图片';
|
||||
hint.setAttribute('data-op', 'pick');
|
||||
body.appendChild(img);
|
||||
body.appendChild(hint);
|
||||
body.setAttribute('data-key', content || '');
|
||||
} else {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.className = 'input note-block-ta';
|
||||
ta.rows = kind === 'list' ? 4 : 3;
|
||||
ta.value = content || '';
|
||||
ta.placeholder = kind === 'list'
|
||||
? '一行一项,回车换行'
|
||||
: (kind === 'text' ? '写点什么…' : '');
|
||||
// 清单块自动在每行前加「· 」的视觉提示(内容本身不加,保持纯文本)
|
||||
body.appendChild(ta);
|
||||
}
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 序列化:从 DOM 读回块数组,写进隐藏域
|
||||
// ---------------------------------------------------------------------
|
||||
function serialize() {
|
||||
var out = [];
|
||||
Array.prototype.forEach.call(list.children, function (el) {
|
||||
var kind = el.getAttribute('data-kind') || 'text';
|
||||
var content;
|
||||
if (kind === 'image') {
|
||||
var b = el.querySelector('.note-block-body');
|
||||
content = (b && b.getAttribute('data-key')) || '';
|
||||
} else {
|
||||
var ta = el.querySelector('textarea');
|
||||
content = ta ? ta.value : '';
|
||||
}
|
||||
out.push({ kind: kind, content: content });
|
||||
});
|
||||
sink.value = JSON.stringify(out);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 增 / 删 / 移
|
||||
// ---------------------------------------------------------------------
|
||||
function addBlock(kind, content, afterEl) {
|
||||
var el = makeBlock(kind, content);
|
||||
if (afterEl && afterEl.parentNode === list) {
|
||||
list.insertBefore(el, afterEl.nextSibling);
|
||||
} else {
|
||||
list.appendChild(el);
|
||||
}
|
||||
var ta = el.querySelector('textarea');
|
||||
if (ta) { ta.focus(); }
|
||||
serialize();
|
||||
return el;
|
||||
}
|
||||
|
||||
function move(el, dir) {
|
||||
if (dir < 0) {
|
||||
var prev = el.previousElementSibling;
|
||||
if (prev) { list.insertBefore(el, prev); }
|
||||
} else {
|
||||
var next = el.nextElementSibling;
|
||||
if (next) { list.insertBefore(next, el); }
|
||||
}
|
||||
serialize();
|
||||
}
|
||||
|
||||
// 事件委托:统一处理块上的所有按钮
|
||||
list.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-op]');
|
||||
if (!btn || !list.contains(btn)) { return; }
|
||||
var op = btn.getAttribute('data-op');
|
||||
var el = btn.closest('.note-block');
|
||||
if (!el) { return; }
|
||||
|
||||
if (op === 'up') { move(el, -1); return; }
|
||||
if (op === 'down') { move(el, 1); return; }
|
||||
if (op === 'del') { el.parentNode.removeChild(el); serialize(); return; }
|
||||
if (op === 'pick') {
|
||||
// 记住是哪个块在等图片,然后触发隐藏 file 框
|
||||
pendingBlock = el;
|
||||
fileIn.click();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// 顶部「+段落 / +图片 / +清单」
|
||||
document.querySelectorAll('.note-add [data-add]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var kind = b.getAttribute('data-add');
|
||||
if (kind === 'image') {
|
||||
// 加图块的同时直接把选图流程带起来(少一次点击)
|
||||
var el = addBlock('image', '');
|
||||
pendingBlock = el;
|
||||
fileIn.click();
|
||||
} else {
|
||||
addBlock(kind, '');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 图片上传
|
||||
// ---------------------------------------------------------------------
|
||||
fileIn.addEventListener('change', function () {
|
||||
if (!fileIn.files || !fileIn.files[0]) { return; }
|
||||
var el = pendingBlock;
|
||||
if (!el) { return; }
|
||||
var f = fileIn.files[0];
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('img', f);
|
||||
// CSRF:从表单里那颗隐藏 _csrf 拿(同一个 token)
|
||||
var tok = form.querySelector('input[name="_csrf"]');
|
||||
if (tok) { fd.append('_csrf', tok.value); }
|
||||
|
||||
// 上传中:把按钮文案换掉,避免用户以为没反应又点一次
|
||||
var pick = el.querySelector('.note-img-pick');
|
||||
var oldTxt = pick ? pick.textContent : '';
|
||||
if (pick) { pick.textContent = '上传中…'; pick.disabled = true; }
|
||||
|
||||
fetch('index.php?r=notes/upload', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
}).then(function (r) { return r.json().catch(function () { return null; }); })
|
||||
.then(function (d) {
|
||||
if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; }
|
||||
if (!d || !d.ok) {
|
||||
alert('图片上传失败:' + ((d && d.error) || '未知原因'));
|
||||
return;
|
||||
}
|
||||
var b = el.querySelector('.note-block-body');
|
||||
if (b) { b.setAttribute('data-key', d.key); }
|
||||
var img = el.querySelector('.note-block-img');
|
||||
if (img) { img.src = d.url; img.classList.remove('is-empty'); }
|
||||
if (pick) { pick.textContent = '换一张'; }
|
||||
serialize();
|
||||
}).catch(function () {
|
||||
if (pick) { pick.textContent = oldTxt || '选图片'; pick.disabled = false; }
|
||||
alert('图片上传失败,请重试。');
|
||||
});
|
||||
// 清空 input,否则连选同一张图不会再触发 change
|
||||
fileIn.value = '';
|
||||
});
|
||||
|
||||
// textarea 输入时同步(这样即便用户直接点提交,内容也是最新的)
|
||||
list.addEventListener('input', serialize);
|
||||
|
||||
// 提交前再序列化一次(双保险)
|
||||
form.addEventListener('submit', serialize);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 初始化:从服务端种子渲染
|
||||
// ---------------------------------------------------------------------
|
||||
var initial = [];
|
||||
if (seed) {
|
||||
try { initial = JSON.parse(seed.textContent || '[]'); } catch (e) { initial = []; }
|
||||
}
|
||||
if (!initial.length) { initial = [{ kind: 'text', content: '' }]; }
|
||||
initial.forEach(function (b) { addBlock(b.kind, b.content); });
|
||||
serialize();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 标签联想:点一下把 #标签 追加进输入框
|
||||
// ---------------------------------------------------------------------
|
||||
var tagIn = document.getElementById('noteTags');
|
||||
document.querySelectorAll('.note-tags-suggest [data-tag]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
if (!tagIn) { return; }
|
||||
var t = '#' + b.getAttribute('data-tag');
|
||||
var cur = tagIn.value.trim();
|
||||
// 已经有就不重复加
|
||||
if ((' ' + cur + ' ').indexOf(' ' + t + ' ') >= 0) { return; }
|
||||
tagIn.value = cur === '' ? t : (cur + ' ' + t);
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user