ByteNoteByteNote
shadcn 多图上传与编辑器粘贴换行
字

字节笔记本

2026年10月7日 · 约 10 分钟读完

shadcn 多图上传与编辑器粘贴换行

API中转
¥120

上传组件第一版用了 shadcn 的 Card、按钮和一段假的进度条:选中文件之后再点一次上传,定时器每 500 毫秒把进度加 10,到 100 就假装得到一个示例地址。这和真实页面差得远。后面的约束一次比一次具体:不要 Card,不要模拟上传,支持一组图片连续选,选完就能预览,尺寸收小,选完一张就交给父组件或直接传,成功和失败都要有提示。编辑器那边还另有一个问题:从外面粘进 BlockNote 的文字,换行没了。

选完即传,并标出响应类型

选完就传,进度不要自己演

假进度会让人以为文件已经在服务器上。去掉 simulateUpload 之后,选文件的回调里直接组 FormData 发请求,或者把 File 交给父组件的 onFileUpload。父组件负责真正的接口,组件自己只负责选择、预览和结果状态。

多图用一个数组,而不是单个 file。每一项记四件事:原始 File、本地预览地址、是否正在传、是否已完成。预览用 URL.createObjectURL,组件卸载或用户点掉某一张时要 revokeObjectURL,不然一张张换图会把内存留在页面上。

单张传完再自动开始下一张,还是并行,取决于接口。对话里的交互是“不用再点上传按钮,一张选完就传”。实现上可以在 onChange 里对 event.target.files 逐个调用上传,每张有自己的 uploading 标记,失败的那张留在列表里并允许重试,不要因为一张失败把整组预览清掉。

data 是 unknown,以及 Toast 的路径

请求回来的 data 如果没标注类型,会撞上 TS18046: data is of type unknown。在使用 data.url 之前收窄,或者给响应一个接口:

ts
interface UploadResponse {
  url: string
}

const { data } = await axios.post<UploadResponse>(uploadUrl, form, { headers })
onUploadComplete(data.url)

成功和失败用 Toast,不要只用按钮上的文字。对话里的导入写的是:

ts
import { useToast } from "@/components/ui/use-toast"

紧接着构建报 Module not found: Can't resolve '@/components/ui/use-toast'。这通常不是业务代码写错,而是 shadcn 的 Toast 还没加进项目,或者这一版 CLI 把钩子放在了别的路径。先看 components/ui 下面有没有 use-toast.ts、toast.tsx、toaster.tsx。没有就用 CLI 把 toast 装上。有文件但路径不同,就改导入,不要同时保留一个不存在的别名。Toaster 还要挂在布局里,只调用 toast() 而根上没有容器,调用成功也不会出现提示。

分类下拉是后来加在表单上的,选项包括链接、代码片段、分享、开源。它不属于上传组件本身,布局上把表单拆成左右:左边是分类和说明,右边是编辑区和图片列表,上传条不要再占一整张 Card。

粘贴进 BlockNote 时,换行在 HTML 里

编辑器是 BlockNote,useCreateBlockNote 关掉了动画,变更时用 blocksToMarkdownLossy 把文档交回父组件。第一轮误判把“换行不见了”理解成组件源码被贴乱了,于是去调整了缩进。用户纠正之后才对上:是粘贴到 BlockNoteView 里的剪贴板内容没有换行。

浏览器对富文本剪贴板通常同时给出 text/html 和 text/plain。只取纯文本,有些来源的换行已经变成 <br> 或段落结束,纯文本那一路是空的或挤成一行。处理函数先取 HTML,没有再退回纯文本,然后把换行标签变回换行符,剥掉其余标签,再交给编辑器:

tsx
const handlePaste = async (event: React.ClipboardEvent) => {
  event.preventDefault()
  let pasted = event.clipboardData.getData("text/html")
  if (!pasted) pasted = event.clipboardData.getData("text/plain")
  pasted = pasted
    .replace(/<br\s*\/?>/gi, "\n")
    .replace(/<\/p>/gi, "\n\n")
    .replace(/<[^>]+>/g, "")
    .replace(/\n{3,}/g, "\n\n")
  const blocks = await editor.tryParseMarkdownToBlocks(pasted)
  editor.insertBlocks(blocks, editor.getTextCursorPosition().block)
}

插入用的是当前光标所在的 block,不是把整篇文档换掉。三个以上的换行压成两个,避免从网页复制时带进大段空行。这段替换很粗,会丢掉链接和行内样式,只保证段落还在。如果业务要保留链接,就不能先把所有标签删掉,而要走 BlockNote 自己的 HTML 粘贴,只对“纯文本挤成一行”的那一种来源做补换行。

onChange 里用一个 ref 挡住重入。blocksToMarkdownLossy 是异步的,转换过程中如果再触发变更,会把尚未写完的 markdown 再写回去。

上传和粘贴是两件小事

图片上传要的是真实请求、预览和逐张状态,不是 Card 里的假进度。类型错误先标注响应,Toast 找不到模块就先核对 shadcn 文件在不在。编辑器粘贴丢失换行时,先看剪贴板里到底是 HTML 还是纯文本,再决定要不要把 <br> 和 </p> 换回换行。两件事都不是视觉问题,都是数据在交出去之前被处理错了。

粘贴时把换行标签还原成换行

预览地址和左右栏

每一张图的预览来自浏览器本地,不来自服务器。所以上传失败时预览还可以留着,用户能看到是哪一张没传上去。成功之后父组件拿到的是接口返回的地址,预览地址仍然只活在这个页面里,刷新就没了。把返回的地址写进表单状态,不要把 blob: 开头的预览地址提交出去。

假进度那版的间隔是 500 毫秒、每次加 10,四秒左右走到 100,然后拼一个示例域名。这种计时器哪怕后来接到了真接口上,也不能再用它代表进度,除非接口本身提供了上传进度事件。没有进度事件,就用“正在传 / 已完成 / 失败”三种状态,不要画一条自己在走的条。

组件还被要求把单张尺寸收小,并且不要 Card 把上传区包成一张大卡片。缩略图用固定的小方块,多图横排换行,比一张大卡片里只放一个文件更接近“一组图片”。删除某一张时,只从数组里拿掉那一项,并释放它的预览地址,其他张的上传状态保持不动。

表单后半段把内容区拆到右边,左边放分类。分类选项是链接、代码片段、分享、开源这一类主题,用来给整条内容打标签,不是给每一张图单独打。所以下拉框属于表单,不属于 MultiImageUpload 的内部状态。上传组件继续只抛文件和地址,分类由外层持有,提交时再放在一起。

BlockNote 那一侧,animations 被关掉,是为了减少编辑器自己的过渡干扰粘贴后的光标。blocksToMarkdownLossy 这个名字里的 lossy 就说明换回 markdown 会丢一部分结构。粘贴处理如果已经把 HTML 剥成纯文本,再转 markdown,链接和加粗本来就不会留下。产品如果只承诺“换行还在”,这段替换是够的。产品如果承诺“从网页贴过来样式还在”,就要停在 BlockNote 默认的粘贴上,只对纯文本挤成一行的情况补换行,两条路径用剪贴板里有没有 text/html 来分支,而不是永远剥标签。

重入锁也很具体。change 一开始如果 isUpdatingRef 已经是真的,就直接返回。异步把 markdown 交给父组件之后,在 finally 里把锁放开。少了这把锁,父组件 setState 引起的再渲染会再次触发 onChange,同一段文字被转换多次,光标也会跳。 选择文件时用 input 的 multiple,接受的类型收在图片上,避免把文档也放进预览网格。一次选了很多张,就按文件列表逐个进入上传状态,而不是只拿 files[0]。只拿第一张,是单文件组件留下来的习惯,多图改造时最容易漏。

Toast 的文案分成成功和失败两种。成功至少带上文件名或返回的地址,失败带上接口返回的状态,不要两张图都只写“上传完成”。useToast 的模块找不到时,相对路径导入曾经被试过,从当前文件往上找到 components/ui/use-toast。相对路径能绕过别名配错,但文件本身不存在时仍然失败。先确认文件,再决定用别名还是相对路径。布局里的 Toaster 只挂一次,挂在根上,不要每张卡片各挂一个。

相关文章

分享: