Files
FamilyBook/templates/notes/form.php
T
laoyang 404b1afee0 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 数组:移动块时用户已输入的内容一字不丢
2026-10-07 18:22:55 +08:00

140 lines
6.4 KiB
PHP
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.
<?php
/**
* 个人笔记 · 写 / 改(v27 用户 2026-10-07)。新建与编辑**同一个模板**。
*
* 「分块」的落点就在这一页:正文区由若干**块**组成,每块是
* 段落 / 图片 / 清单 三者之一,可增、可删、可上下移。
*
* 三条刻意的轻量取舍(写清楚,免得后人"顺手升级"成重型编辑器):
* ① **不做富文本工具栏**(粗体/斜体/颜色…)。本项目全站的文本都是"纯文本 +
* 换行",只有这里破例就会出现两套文本口径(导出、搜索、复制都会不一致)。
* 需要强调就写符号,需要排版就用清单块。
* ② **不做 Markdown 预览**。可见即所得:段落块就是段落,清单块就是清单 ——
* 用户不必再学"我打的东西会变成什么"。这也是"轻量"最实在的一层。
* ③ **不做拖拽排序**,只有 ↑ ↓ 两个按钮。鼠标拖拽要引入一整套 pointer 事件
* 与触摸兼容(本仓名册拖拽就为它写了上千行),而笔记的块通常只有几块,
* 两个按钮足够,代码少一个数量级。
*
* ⚠️ 提交形状:所有块打包成一个 JSON 放进隐藏域 `blocks`(见页尾脚本)。
* 为什么不是 `kind[]` / `content[]` 并行数组:服务端要按下标对齐,
* 一旦前端少发一个(比如空块被过滤)整个错位,而且很难在页面上看出来。
* JSON 一把梭,服务端 `json_decode` 之后逐块清洗,简单且不会错位。
* ⚠️ **无 JS 兜底**:`<noscript>` 里给一个普通 textarea(`body_plain`),
* 服务端在 `blocks` 为空时会退回读它(见 index.php 的 notes/save)。
*
* @param array|null $editing 编辑时的笔记(带 blocks/tags/is_shared);新建为 null
* @param array $tagSuggest 我用过的标签(点一下就加)
*/
$editing = $editing ?? null;
$isEditing = $editing !== null;
$canWrite = \App\Auth::canWrite();
// 把编辑中的数据整理成前端要的形状(块数组 + 标签词)
$blocks = [];
$tagsStr = '';
if ($isEditing) {
foreach ((array)$editing['blocks'] as $b) {
$blocks[] = ['kind' => (string)$b['kind'], 'content' => (string)$b['content']];
}
$tagsStr = implode(' ', array_map(static fn($t): string => '#' . $t, (array)$editing['tags']));
}
if (!$blocks) {
// 新建时给一个空段落块打底(否则页面上一片空白,用户不知道从哪写)
$blocks = [['kind' => 'text', 'content' => '']];
}
?>
<div class="page-head">
<h1><?= $isEditing ? '改笔记' : '写笔记' ?></h1>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('notes')) ?>">返回列表</a>
</div>
</div>
<?php if (!$canWrite): ?>
<div class="card card-pad"><p class="empty-hint">只读账号不能写笔记。</p></div>
<?php else: ?>
<form method="post" action="<?= e(url('notes/save')) ?>" id="noteForm" class="note-form">
<?= csrf_field() ?>
<?php if ($isEditing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<?php /* 块的真实载荷(由页尾脚本维护) */ ?>
<input type="hidden" name="blocks" id="noteBlocks" value="">
<div class="card card-pad">
<div class="field">
<label for="noteTitle">标题</label>
<input class="input" id="noteTitle" name="title" maxlength="200"
value="<?= e((string)($editing['title'] ?? '')) ?>"
placeholder="一句话概括(也可以不写,只写正文)" autocomplete="off">
</div>
</div>
<?php /* ---------------- 分块编辑区 ---------------- */ ?>
<div class="card card-pad">
<div class="note-block-head">
<h2 class="cinema-h2">正文</h2>
<div class="note-add">
<button class="btn btn-ghost btn-sm" type="button" data-add="text">+ 段落</button>
<button class="btn btn-ghost btn-sm" type="button" data-add="image">+ 图片</button>
<button class="btn btn-ghost btn-sm" type="button" data-add="list">+ 清单</button>
</div>
</div>
<div id="noteBlockList" class="note-blocks"></div>
<?php /* 无 JS 兜底:脚本没跑时用户仍能写一条纯文字笔记 */ ?>
<noscript>
<div class="field" style="margin-top:10px">
<label for="notePlain">正文(纯文本)</label>
<textarea class="input" id="notePlain" name="body_plain" rows="8"
placeholder="没有启用脚本时,可以在这里写纯文字正文"><?php
echo e(implode("\n\n", array_map(
static fn($b): string => (string)$b['content'], $blocks)));
?></textarea>
</div>
</noscript>
<?php /* 图片上传用的隐藏 file 框(点"+ 图片"时程序化触发) */ ?>
<input type="file" id="noteImgInput" accept="image/*" hidden>
</div>
<?php /* ---------------- 标签 + 共享 ---------------- */ ?>
<div class="card card-pad">
<div class="field">
<label for="noteTags">标签</label>
<input class="input" id="noteTags" name="tags" maxlength="300"
value="<?= e($tagsStr) ?>"
placeholder="用空格分开,如:读书 灵感 待办(可写 # 也可不写)" autocomplete="off">
<?php if ($tagSuggest): ?>
<div class="note-tags note-tags-suggest">
<span class="small muted">用过的:</span>
<?php foreach ($tagSuggest as $t): ?>
<button type="button" class="note-tag" data-tag="<?= e((string)$t) ?>">
#<?= e((string)$t) ?>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<label class="note-share-toggle">
<input type="checkbox" name="is_shared" value="1"
<?= !empty($editing['is_shared']) ? 'checked' : '' ?>>
<span>共享给全家(默认只有自己看得到)</span>
</label>
</div>
<div class="note-actions">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '记下来' ?></button>
<a class="btn btn-ghost" href="<?= e(url('notes')) ?>">取消</a>
</div>
</form>
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
<script type="application/json" id="noteSeed"><?= json_encode(
$blocks,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
) ?></script>
<?php endif; ?>