Files
laoyang 7c86859ab9 笔记附件:修好拖放、传完回编辑态、选中即上传 #21
用户原话:「网页端,笔记应该可以同时上传多个附件。」

诊断后确认「多选」本来就是好的(multiple + name=file[] + 后端数组分支,
一次选 3 个混合类型真的存了 3 行)。真正坏的是另外两件事:

1) 拖放是假承诺。界面写「把文件拖到这里」,但没有任何 drop 监听器:
   拖完 input.files 仍是 0、POST 0 个文件。更糟的是 dragover 没有
   preventDefault ⇒ 浏览器按默认行为直接打开那个文件,正在写的笔记
   连未保存正文一起丢。
   修法:给框加 data-sd-for="noteAttInput",复用 app.js 里既有的
   bindInput() → bindZone()。一行属性,不另写一套拖放。
   踩过的坑:data-sd-for 写的是**裸 id**,第一版带了个 `#`,选择器
   变成 [data-sd-for="#noteAttInput"],一个都匹配不上,拖放照样没人接。

2) 传完跳回阅读态(redirect 不带 edit=1),附件区在 $editMode 分支里
   根本不渲染 ⇒ 用户看到的是"没传上"。attach_add / attach_del 两处
   都补 edit => 1。

另外补了即时反馈:change 一来就把提示换成「已选择 N 个文件:…,正在
上传…」并自动提交。用 requestSubmit(btn) 而不是 form.submit() ——
后者绕过 submit 事件、也不带按钮的 formaction/formenctype,提交会跑去
notes/save 且非 multipart,文件直接丢。

判据:
- _b40_note_multi_attach.py  16/0(点选多选 / 拖放 / 回编辑态 / 单选回归 / 提示文案)
- _b40_falsify21.py  三处注入各自红在预期编号(data-sd-for 带# → ③④a-d;
  requestSubmit→submit → ①-b;去掉 edit=1 → ③-a/b)
- _b40_regress21.py  20/0(删除生效且回编辑态 / 越权删除被拒 / 公共绑定器
  未受影响 / MAX_PER_HOST=60 端到端仍生效)
- _b40_regress21_falsify.py  关掉上限与归属校验各自变红
2026-10-28 20:13:22 +08:00

557 lines
30 KiB
PHP
Raw Permalink 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
/**
* 个人笔记 · 一条笔记的两种面貌(v29 用户 2026-10-08 第 6/7/8/9 条)。
*
* 🔴 用户原话(第 8 条):
* 「笔记点进来,默认是阅读状态,图文混排,附件在最下方,图文混排时,
* 需要可以调整大小。点击编辑按钮,才进入编辑功能。
* 清单功能不要了吧,markdown 里面包含这个功能,把段落做的完善一点就可以了。」
* ⇒ 这个模板现在有**两个态**:
* · **阅读态**(`$editMode = false`):图文按顺序排下来、附件在最底下、
* 右上角一个「编辑」按钮。多数时候用户是来看的。
* · **编辑态**(`$editMode = true`):分块编辑器 + 工具条 + Markdown 补全。
*
* 🔴 用户原话(第 7 条):
* 「添加段落部分,还是不太好,这样就意味着用户还是要记住所有的 markdown 命令,
* 要么有工具条,要么有代码提示,例如插入一个斜线,然后输入代码,例如 code,
* 就能自动补全 code 的 markdown 代码。」
* ⇒ **两样都给**(用户原话就是"要么…要么…",那两样都给最省事):
* · 工具条:粗体 / 斜体 / 标题 / 列表 / 引用 / 代码 / 链接 —— 点一下插到光标处;
* · 斜线提示:行首打 `/` 弹出命令菜单,打 `code` 回车就补上代码块。
*
* ⚠️ 为什么"阅读态"要单独一份渲染(而不是复用编辑器的预览):
* 编辑器里的预览是"每块一个方框、带虚线边框",那是**编辑**的视觉语言;
* 阅读态要的是干净的排版(没有框、没有按钮)。两者混用会让阅读页
* 看着像"还没保存的草稿"。
* ⚠️ 图片"可以调整大小"(第 8 条):用 width 百分比存进图片块的内容里
* (`key|50`),渲染时按它出宽度。见下面 `$imgW` 的解析与注释。
*
* @param array|null $editing 笔记(阅读/编辑都以它为准);新建时 null
* @param bool $editMode true = 编辑态
* @param string $perm 'owner' / 'write' / 'read' / ''
* @param bool $canEdit 能不能改(作者或 write)
* @param array $shares 已共享给谁(作者视角才有内容)
* @param array $tagSuggest 我用过的标签(点一下就加)
*/
$editing = $editing ?? null;
$isEditing = $editing !== null;
$editMode = (bool)($editMode ?? ($isEditing ? false : true));
$canWrite = \App\Auth::canWrite();
$perm = (string)($perm ?? '');
$canEdit = (bool)($canEdit ?? false);
$shares = (array)($shares ?? []);
$isMine = $isEditing && (int)$editing['person_id'] === (int)\App\Auth::personId();
// 把编辑中的数据整理成前端要的形状(块数组 + 标签词)
$blocks = [];
$tagsStr = '';
if ($isEditing) {
foreach ((array)$editing['blocks'] as $b) {
// ⚠️ 历史 list 块在编辑态里**转成 Markdown 文本**再放进编辑器:
// 否则编辑器认不出这种块(v29 已把清单并入 Markdown),
// 会渲染成一个空的方块 —— 用户一保存内容就丢了。
$k = (string)$b['kind'];
$c = (string)$b['content'];
if ($k === 'list') {
$c = \App\Note::blockHtmlOut($c); // 见 Note::blockHtmlOut(返回 Markdown 文本)
$k = 'text';
}
$blocks[] = ['kind' => $k, 'content' => $c];
}
$tagsStr = implode(' ', array_map(static fn($t): string => '#' . $t, (array)$editing['tags']));
}
if (!$blocks) {
// 新建时给一个空段落块打底(否则页面上一片空白,用户不知道从哪写)
$blocks = [['kind' => 'text', 'content' => '']];
}
/** 图片块内容 → [key, 宽度百分比]。内容形如 `abc123` 或 `abc123|50`。 */
$imgParts = static function (string $c): array {
$p = explode('|', $c);
$w = isset($p[1]) ? (int)$p[1] : 0;
if ($w < 20 || $w > 100) { $w = 0; }
return [trim((string)$p[0]), $w];
};
?>
<div class="page-head">
<?php
/* 🔴 v52(用户 2026-10-15 第 2 条):「笔记这个功能模块,点进去一个笔记的详情之后,
上边的标题不是看笔记,而是**用户自己写的这个标题的名称**。」
⇒ 阅读态页头显示笔记自己的标题;没有标题时才退回「看笔记」。
⚠️ 只在阅读态这么做 —— 编辑态仍用「改笔记 / 写笔记」:
编辑时页面标题是"我在干什么",而笔记标题就在下面第一个输入框里,
两处都写同一个名字反而看着像重复。
⚠️ 用 `$noteHeadTitle`(HTTP 里也参与 <title>,见 View::render 的 title 参数)——
由路由传进来,见 index.php 的 notes/form 分支。 */
$noteTitle = trim((string)($editing['title'] ?? ''));
$headText = (!$editMode && $isEditing && $noteTitle !== '') ? $noteTitle
: ($editMode ? ($isEditing ? '改笔记' : '写笔记') : '看笔记');
?>
<h1><?= e($headText) ?></h1>
<span class="sub">
<?php if ($isEditing && !$editMode): ?>
<?= e(substr((string)$editing['updated_at'], 0, 16)) ?>
<?php if (!$isMine): ?>
· 作者 <?= e((string)($editing['author_name'] ?? '')) ?>
· 你的权限:<?= e(\App\Note::permWord($perm)) ?>
<?php endif; ?>
<?php endif; ?>
</span>
<div class="actions">
<?php /* 🔴 v29 第 8 条:阅读态的右上角就是那个「编辑」按钮 —— 用户明确说
"点击编辑按钮,才进入编辑功能"。 */ ?>
<?php if ($isEditing && !$editMode && $canEdit && $canWrite): ?>
<a class="btn btn-sm" href="<?= e(url('notes/form', ['id' => (int)$editing['id'], 'edit' => 1])) ?>">编辑</a>
<?php endif; ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('notes')) ?>">返回列表</a>
</div>
</div>
<?php if (!$isEditing && !$canWrite): ?>
<div class="card card-pad"><p class="empty-hint">只读账号不能写笔记。</p></div>
<?php elseif (!$editMode): ?>
<?php /* ==================== ✅ 阅读态(默认)====================
用户第 8 条要的"图文混排 + 附件在最下方",就在这一段。
⚠️ 图片块用 `<figure>` 包:宽度可调(第 8 条),且要与正文有间距。
⚠️ 附件(`$editing['files']`)**排在正文之后、页面最下方** ——
用户原话「附件在最下方」。 */ ?>
<article class="note-read card card-pad">
<?php /* 🔴 v55(用户 2026-10-17 第 1 条):「标题已经变成这个日笔记的题目了,
那下面那个里面就**不用再显示这个题目**了。」
⇒ 正文区里那个重复的 `<h1 class="note-read-title">` 删掉。
⚠️ 背景:v52 已经把**页头**改成笔记自己的标题(见本文件上面那段),
而正文这里**又写了一遍同一个标题** ⇒ 同一页同一个名字出现两次
(页头一行、正文开头一行),用户看到的就是这个重复。
⚠️ **页头那份保留**(那是"这一页叫什么",也参与 <title> 与浏览器标签);
删的只是正文里的副本。
⚠️ 编辑态不受影响(本段只在阅读态渲染)。 */ ?>
<?php if (!empty($editing['tags'])): ?>
<div class="note-tags note-read-tags">
<?php foreach ((array)$editing['tags'] as $t): ?>
<a class="note-tag" href="<?= e(url('notes', ['tag' => (string)$t])) ?>">#<?= e((string)$t) ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="note-read-body" data-note-id="<?= (int)($editing['id'] ?? 0) ?>">
<?php foreach ((array)$editing['blocks'] as $b): ?>
<?php
$k = (string)$b['kind']; $c = (string)$b['content'];
/* 🔴 v52(用户第 4 条):每一块包一层 `.note-read-item`,带**块主键** ——
详情页的"分段编辑"要靠它定位改哪一块(AJAX 按主键改一行)。
⚠️ 加 `data-block` 的**同时**保留原来的类名(`note-read-fig` / `note-read-block`)——
现有 e2e 与 CSS 都按它们断言,改类名会静默弄坏那些检查。 */
?>
<div class="note-read-item" data-block="<?= (int)($b['id'] ?? 0) ?>"
data-kind="<?= e($k) ?>" data-raw="<?= e($c) ?>">
<?php if ($k === 'image'): ?>
<?php [$key, $w] = $imgParts($c); ?>
<?php if ($key !== ''): ?>
<figure class="note-read-fig">
<a href="<?= e(media_url($key)) ?>" data-prev>
<img src="<?= e(media_url($key)) ?>" alt=""
<?= $w > 0 ? 'style="width:' . (int)$w . '%"' : '' ?>
loading="lazy" decoding="async">
</a>
</figure>
<?php endif; ?>
<?php else: ?>
<div class="note-read-block md-body"><?= (string)($b['html'] ?? '') ?></div>
<?php endif; ?>
<?php /* 鼠标划过才出现的「编辑」——只在能写且这一块真的有主键时给
(`$b['id']` 为 0 的块理论上不该有;给了也改不了,不如不给)。 */ ?>
<?php if ($canEdit && $canWrite && (int)($b['id'] ?? 0) > 0): ?>
<button type="button" class="note-item-edit"
data-item-edit="<?= (int)$b['id'] ?>" title="只改这一段">编辑</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (!$editing['blocks']): ?>
<p class="empty-hint">(这条笔记没有正文)</p>
<?php endif; ?>
</div>
<?php /* ---- 附件:永远在最后 ---- */ ?>
<?php if (!empty($editing['files'])): ?>
<section class="note-read-files">
<h2 class="note-read-h2">附件</h2>
<div class="note-files">
<?php foreach ((array)$editing['files'] as $f): ?>
<?php $fk = \App\Attachment::kind($f); ?>
<div class="note-file">
<span class="note-file-ico"><?= e(\App\Attachment::kindWord($fk)) ?></span>
<a class="note-file-nm" href="<?= e(media_url((string)$f['key'], ['dl' => 1])) ?>">
<?= e((string)($f['name'] ?? '附件')) ?>
</a>
<span class="small muted"><?= e(\App\Attachment::sizeWord($f['size'] ?? 0)) ?></span>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
</article>
<?php /* ==================== 共享(v29 第 9 条:选人 + 权限,同资源)====================
🔴 只在**作者**视角显示:共享范围是作者的决定,拿到只读的人不该看到名单。
交互与资源页完全一致:选人 → 选权限 → 共享;已共享的逐个可「取消共享」。 */ ?>
<?php if ($isMine && $canWrite): ?>
<div class="card card-pad">
<h2 class="cinema-h2">共享
<span class="hint">默认只有自己看得到;共享出去给别人看或改</span></h2>
<form method="post" action="<?= e(url('notes/share')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<div class="field">
<label>共享给谁</label>
<?php
$ppName = 'person_id';
$ppPeople = $people ?? [];
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<span class="tip">你自己本来就有全部权限,不用(也不能)共享给自己。</span>
</div>
<div class="field">
<label>权限</label>
<select class="select" name="perm">
<option value="read">只读</option>
<option value="write">可读写</option>
</select>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn btn-ok" type="submit">共享</button>
</div>
</form>
<div class="note-share-now">
<h3 class="note-read-h2">已共享给</h3>
<?php if ($shares): ?>
<div class="note-files">
<?php foreach ($shares as $s): ?>
<div class="note-file">
<span class="note-file-ico"><?= e(\App\Note::permWord((string)$s['perm'])) ?></span>
<span class="note-file-nm"><?= e((string)$s['name']) ?></span>
<form method="post" action="<?= e(url('notes/unshare')) ?>" class="inline-form">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
<button type="submit" class="note-file-del" title="取消共享">取消共享</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="empty-hint">还没有共享给谁。</p>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php else: ?>
<?php /* ==================== ✏️ 编辑态 ==================== */ ?>
<form method="post" action="<?= e(url('notes/save')) ?>" id="noteForm" class="note-form">
<?php /* ⚠️ media 地址**不在表单上注入**:`media_url('')` 返回的是默认头像
(见 helpers.php 里 `$key === ''` 那一支),拿它当基址会拼出
`avatar-default.svgabc123` 这种地址。基址统一由 layout 的
`<body data-media-base="media.php?k=">` 提供,脚本读它就行。 */ ?>
<?= 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>
</div>
</div>
<?php /* 🔴 v29 第 7 条:**工具条**(点一下插到光标处)+ 斜线命令提示。
工具条按钮与斜线菜单是**同一份命令表**(见 note_editor.js 的 CMDS),
避免"按钮能插、斜线插不了"这种两套实现。
🔴 v61(用户 2026-10-18):按成熟软件的块清单补齐 ——
标题一/二/三、有序列表、待办清单、删除线、行内代码、表格、分割线。
工具条只摆**高频**的;全部命令(含标题三、行内代码)斜线 `/` 都能调出。 */ ?>
<div class="note-cmdbar" data-cmdbar>
<button type="button" class="cmd" data-cmd="b" title="粗体 **文字**"><b>B</b></button>
<button type="button" class="cmd" data-cmd="i" title="斜体 *文字*"><i>I</i></button>
<button type="button" class="cmd" data-cmd="s" title="删除线 ~~文字~~"><s>S</s></button>
<span class="cmd-sep"></span>
<button type="button" class="cmd" data-cmd="h1" title="标题一 # 文字">H1</button>
<button type="button" class="cmd" data-cmd="h2" title="标题二 ## 文字">H2</button>
<button type="button" class="cmd" data-cmd="h3" title="标题三 ### 文字(全部命令见斜线)">H3</button>
<span class="cmd-sep"></span>
<button type="button" class="cmd" data-cmd="ul" title="无序列表 - 项目">≡</button>
<button type="button" class="cmd" data-cmd="ol" title="有序列表 1. 项目">1.</button>
<button type="button" class="cmd" data-cmd="todo" title="待办清单 - [ ] 项目">☑</button>
<button type="button" class="cmd" data-cmd="quote" title="引用 > 文字">❝</button>
<span class="cmd-sep"></span>
<button type="button" class="cmd" data-cmd="code" title="代码块 ```">&#60;/&#62;</button>
<button type="button" class="cmd" data-cmd="table" title="表格 | 列 | 列 |">▦</button>
<button type="button" class="cmd" data-cmd="hr" title="分割线 ---">—</button>
<button type="button" class="cmd" data-cmd="link" title="链接 [文字](地址)">🔗</button>
<span class="cmd-sep"></span>
<span class="small muted">行首打 <code>/</code> 也能选命令</span>
<label class="note-live">
<input type="checkbox" id="noteLivePreview" checked>
<span>实时预览</span>
</label>
</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>
<?php /* ⚠️ v29:原来的「共享给全家」勾选框**删掉**了 ——
共享改成"保存后在阅读态选人 + 选权限"(与资源一致,见第 9 条)。 */ ?>
</div>
<?php /* 🔴 v28 用户第 12 条:「再加上附件上传功能,附件,都放在笔记最下面就行了。」
⇒ 附件区放在**整张表单的最下面**。
⚠️ 附件要挂在**已存在的笔记**上(attachments.target_id 要真实 id),
所以新建时这一区是**禁用并说明**的 —— 保存之后进入编辑态就能传了。 */ ?>
<?php if ($isEditing): ?>
<div class="card card-pad" id="noteFilesCard">
<h2 class="cinema-h2">附件 <span class="hint">放最后,与正文分开</span></h2>
<?php if (!empty($editing['files'])): ?>
<div class="note-files">
<?php foreach ((array)$editing['files'] as $f): ?>
<?php $fk = \App\Attachment::kind($f); ?>
<div class="note-file">
<span class="note-file-ico"><?= e(\App\Attachment::kindWord($fk)) ?></span>
<a class="note-file-nm" href="<?= e(media_url((string)$f['key'], ['dl' => 1])) ?>">
<?= e((string)($f['name'] ?? '附件')) ?>
</a>
<span class="small muted"><?= e(\App\Attachment::sizeWord($f['size'] ?? 0)) ?></span>
<button type="button" class="note-file-del" data-note-file-del="<?= (int)$f['id'] ?>"
title="删掉这个附件">✕</button>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="empty-hint" id="noteFilesEmpty">还没有附件。</p>
<?php endif; ?>
<?php /* 🔴🔴 v47(用户 2026-10-13):「编辑一个笔记,点击编辑,然后这时候点保存
是没有任何反应的。」——真因就是**这里原来是一个嵌套 <form>**。
HTML 不允许 form 套 form:浏览器解析时会把**内层** `<form>` 标签丢掉,
但**保留**它后面的 `</form>`。于是那个 `</form>` 提前把外层的
`#noteForm`(整页的保存表单)关掉了 ——
结果「保存」按钮落入**没有任何表单**的区域:
· `button.form === null`;
· 点它既不提交、也不报错(**所以"没有任何反应"**)。
⚠️ 页面里其它字段看着都正常,只有"提交"这条链断了 ——
这类 bug 不打开真浏览器量 `btn.form` 是看不出来的。
改法:内层不再用 `<form>`,改成 `<div>`,把"上传所选文件"这个按钮
用 HTML5 的 `formaction` / `formenctype` **指向自己的端点**。
—— 这样仍然只有**一个**表单(保存表单),上传按钮却能提交到
`notes/attach_add` 且是 multipart。既修了 bug,又不牺牲上传。
⚠️ 隐藏域 `id` 与 `_csrf` 与保存表单同名同值(都是这条笔记的 id /
同一个令牌),合进一个表单不会冲突 —— 已在探针里断言过。 */ ?>
<div id="noteAttForm">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php /* 🔴 工单 #21(用户 2026-10-28):「网页端,笔记应该可以同时上传多个附件。」
实测查清的三件事(别凭印象改):
① **多选本来就是好的**:`multiple` + `name="file[]"` + 后端
`attach_save_files()` 那段数组分支,一次选 3 个(txt/md/png 混合)
真的存了 3 行。这条不用动。
② **拖放是假承诺**:上面那句「把文件拖到这里」从来没人接。
`app.js` 的 `scan()` 会给每个 file input 挂 `bindZone(input, input)`,
但落点绑在 **input 自己**身上 —— 而用户拖的是**外层这个框**,
事件发生在框上、不会反向冒泡到 input ⇒ input.files 永远是 0。
更糟的是没有 `dragover` 的 preventDefault,浏览器按默认行为
**直接打开那个文件**,正在写的笔记连未保存的正文一起丢。
③ 传完跳回**阅读态**(见 index.php 的 attach_add),用户看不见刚传的附件,
体感就是"没传上"。
修法:给框加 `data-sd-for="noteAttInput"` —— 这是项目里既有的约定
(`bindInput()` 见到它就 `bindZone(box, input)` + `bindBox`),
一行属性就能复用成熟实现,不另写一套拖放。
🔴🔴 `data-sd-for` 里写的是**裸 id,不带 `#`**!
取值处是 `querySelectorAll('[data-sd-for="' + input.id + '"]')`,
而 `input.id` 是 `noteAttInput`。我第一版写成 `#noteAttInput`,
选择器就变成 `[data-sd-for="#noteAttInput"]` —— 一个都匹配不上,
拖放照样没人接(判据 ④-a/④-b 直接红)。**照抄 _stage_drop.php:135
的写法**(那里是 `data-sd-for="<?= e($sdId) ?>"`,$sdId 是裸 id)。
⚠️ 不能改成 `data-drop-stage`(暂存式):那套要配合 `upload_stage.php`
与 `stage_token[]` 认领,笔记这边是"直接 POST 到 attach_add",两套不能混。
⚠️ `bindFileEcho` 只认 `.upbox-msg` / `.upbox-ico`,本框用的是 `.note-att-tip`,
所以它插不上手 —— 选中后的即时反馈由下面那段小脚本自己负责。 */ ?>
<div class="note-att-drop" id="noteAttDrop" data-sd-for="noteAttInput">
<input type="file" name="file[]" id="noteAttInput" multiple
accept="image/*,.pdf,.txt,.md,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx">
<span class="note-att-tip" data-note-att-tip>把文件拖到这里,或<label for="noteAttInput">点这里选文件</label>(可一次选多个)</span>
</div>
<div class="btn-row">
<?php /* ⚠️ formaction/formenctype 必须写在**按钮**上:表单主体是"保存",
只有这一个按钮走附件端点。 */ ?>
<button class="btn btn-ghost btn-sm" type="submit"
formaction="<?= e(url('notes/attach_add')) ?>"
formenctype="multipart/form-data">上传所选文件</button>
</div>
</div>
</div>
<?php else: ?>
<div class="card card-pad">
<h2 class="cinema-h2">附件</h2>
<p class="empty-hint">先「记下来」,保存之后回到这一页就能传附件了。</p>
</div>
<?php endif; ?>
<div class="note-actions">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '记下来' ?></button>
<?php /* 取消:编辑已有笔记时回它的阅读态;新建时回列表 */ ?>
<a class="btn btn-ghost"
href="<?= e($isEditing ? url('notes/form', ['id' => (int)$editing['id']]) : url('notes')) ?>">取消</a>
</div>
</form>
<?php if ($isEditing): ?>
<?php /* 删附件:全页唯一的一份表单,脚本填 id 后提交(沿用项目既有做法,
避免"每行一份 form"那种页面膨胀)。 */ ?>
<form method="post" action="<?= e(url('notes/attach_del')) ?>" id="noteFileDelForm" hidden>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
</form>
<?php endif; ?>
<?php /* 初始块数据:JSON 塞进 <script type="application/json"> 而不是 data-* ——
属性里的引号/换行会被 HTML 实体化,读出来还得反转义一次;JSON 块原样读。 */ ?>
<script type="application/json" id="noteSeed"><?php echo json_encode(
$blocks,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
) ?></script>
<?php endif; ?>
<?php if ($isEditing): ?>
<script>
/* ============================================================================
笔记附件:选中 / 拖入多个文件后**立刻**给出反馈,并自动提交上传(工单 #21)
----------------------------------------------------------------------------
用户原话:「网页端,笔记应该可以同时上传多个附件。」
拖放本身不在这里做 —— `app.js` 的 `bindInput()` 靠框上的
`data-sd-for="noteAttInput"` 已经挂好了 `bindZone`,会把 drop 的文件
写进 `input.files` 并派发一次 `change`。**本段只做两件事**:
① `change` 一来就把提示文案换成「已选择 N 个文件」——
否则用户点完/拖完看不到任何变化,会再点一遍(重复选同一批还会重叠)。
② 顺手自动提交到 `notes/attach_add`:以前要再点一次「上传所选文件」,
多选时这个多余的步骤特别容易漏点,漏点就等于"没传上"。
⚠️ 为什么用 `requestSubmit(btn)` 而不是 `form.submit()`:
`form.submit()` 会**绕过** submit 事件(`note_editor.js` 在 submit 里
serialize 正文块),而且不会带上按钮的 `formaction`/`formenctype`——
那样这次提交会跑去 `notes/save` 且不是 multipart,文件直接丢。
`requestSubmit(btn)` 会带着那颗按钮的 `formaction` 走,语义正确。
⚠️ 老浏览器没有 `requestSubmit` 就退回 `form.submit()` 之前先手动把
action/enctype 改对——但更简单的做法是让用户点那颗按钮(下面兜底提示)。
⚠️ 提交前把 `data-sd-echo`/拖放高亮之类收尾交给 app.js,这里不碰。
============================================================================ */
(function () {
var input = document.getElementById('noteAttInput');
var form = document.getElementById('noteForm');
var tip = document.querySelector('[data-note-att-tip]');
if (!input || !form || !tip) { return; }
/* 原始文案要记下来:用户取消选择时要还原(不能变成空字符串) */
var original = tip.innerHTML;
var btn = document.querySelector('#noteAttForm button[formaction]');
function paint() {
var n = input.files ? input.files.length : 0;
if (n === 0) { tip.innerHTML = original; return; }
var names = Array.prototype.slice.call(input.files, 0, 3)
.map(function (f) { return f.name; }).join('、');
tip.textContent = '已选择 ' + n + ' 个文件:' + names + (n > 3 ? ' 等' : '')
+ ',正在上传…';
}
input.addEventListener('change', function () {
paint();
/* 没选到东西(用户点取消)就别提交了 */
if (!input.files || input.files.length === 0) { return; }
if (!btn) { return; }
/* requestSubmit 会**带上这颗按钮的 formaction** 触发完整提交流程;
form.submit() 不会 —— 见上面那条 ⚠️。 */
if (typeof form.requestSubmit === 'function') {
form.requestSubmit(btn);
} else {
/* 兜底:把 action/enctype 临时改成附件端点再提交。
⚠️ 改完**不还原**——页面马上就要跳走了,还原反而可能在
"跳转前的最后一刻"被别的代码读到错误状态。 */
form.setAttribute('action', btn.getAttribute('formaction'));
form.setAttribute('enctype', 'multipart/form-data');
form.submit();
}
});
/* 上传回来(页面重载)时把提示复位 —— 服务端渲染的是原始文案,
不需要额外做;这里只在"从往返缓存恢复"的极端情况下兜一下。 */
window.addEventListener('pageshow', function (ev) {
if (ev.persisted) { paint(); }
});
})();
</script>
<?php endif; ?>