从0到1实现块级编辑器的文件导入

一、今日开发回顾

今天,我实现了文档的导入功能。

blocknote生态已有API支持markdown的导入(由于blocknote是基于块的编辑器(源码是JSON),markdown格式传输有损)。

我仿照昨天做的导出逻辑重新设置了一个hook实现,因不考虑复用导入别的格式,直接hardcode为仅支持md格式的导入。

初期实现单文件导入,后来又引入了文件夹的导入。在做此前补充了文档清空功能和新建窗口功能,相关逻辑就方便复用了!

以下是开发复盘,菜鸟一枚,路过的大佬们见谅...

二、导入流程与内容处理

我在一开始有一个疑问,怎样让hook拿到editor实例的存在?

因为editor实例是在全局的根组件里面创建的。首先要确认一个数据流,理解这个文件导入的整个过程,其实就是分为两步:

  • 解析 (Parse):把 String (Markdown) 变成 Blocks (Array)。
  • 写入 (Update):把 Blocks 塞进 editor 实例。

解析使用官方格式转换API,写入通过button在UI层绑定事件,通过props传递editor实例。

当前编辑区已有内容时,导入有两种处理方式:

  1. 追加内容:将新内容拼接在现有内容之后。
  2. 替换内容:清空现有内容后再导入新内容。

我选择2,因为blocknote的md导入有损,其实格式是不完美衔接的。

BlockNote:有损转换

  • "The functions to import / export to and from HTML / Markdown are considered "lossy"; some information might be dropped when you export Blocks to those formats."
  • "To serialize Blocks to a non-lossy format (for example, to store the contents of the editor in your backend), simply export the built-in Block format using JSON.stringify(editor.topLevelBlocks)."

所以我又补充了文档清空功能,同时实现新建窗口功能------在UI层绑定事件跳转新窗口,新建逻辑为清空当前文本,使用blocknote原生API replace替换所有代码块内容。 并且注意交互:导入新建(清空)操作有ant design的modal警示弹窗。

三、编辑器优化

ts 复制代码
import { BlockNoteEditor } from "@blocknote/core";
export const clearEditor = (editor: BlockNoteEditor) => {
  const blockIds = editor.document.map((block) => block.id);
  editor.replaceBlocks(blockIds, [{ type: "paragraph" }]);
  editor.focus();
};

在之前的新建逻辑中,我简单而粗暴地直接调replace传入空数组,但是这会带来副作用:编辑器无内容、光标无法定位报错。传入空数组会因无block出错,实际可传入空paragraph块。最终采用手动清空,操作editor的document属性,从底层存储层清空重置所有block(editordocument是包含所有块对象的顶层array),避免因无活跃块导致渲染报错、页面卡死。

还有一个小细节,光标优化,采用editor的focus方法让用户打开页面即可输入,在UX上称为0摩擦。

四、hook实现与类型处理

自定义useFileImport hook的逻辑:先校验文件后缀(仅支持markdown,防止上传无法解析的文件),再读取内容。

ts 复制代码
import { BlockNoteEditor } from "@blocknote/core";
import { clearEditor } from "../utils/clearEditor";
import { useCallback } from "react";

export const useFileImport = (editor: BlockNoteEditor | null) => {
  const importFile = useCallback(
    async (file: File) => {
      if (!editor) return;
      if (!file.name.endsWith(".md")) {
        alert("请选择一个Markdown文件进行导入");
        return;
      }
      try {
        const content = await file.text();
        const blocks = await editor.tryParseHTMLToBlocks(content);

        clearEditor(editor);
        editor.replaceBlocks(
          editor.document.map((b) => b.id),
          blocks,
        );
      } catch (error) {
        console.error("导入失败:", error);
      }
    },
    [editor],
  );

  return { importFile };
};

原本以为文档导入可直接用原生API,实际上,需要结合浏览器API操作原生dom(如 <input type="file">),需跳出react框架,直接操作DOM,也就是我之前似是而非学的Ref,还有react的脱围机制,这一块我重新学习了文档。

Ref与脱围机制

  • "ref 是一种脱围机制,用于保留不用于渲染的值。你不会经常需要它们。"
  • "当你的组件需要'走出' React 并与外部 API 通信时,你会用到 ref ------ 通常是不会影响组件外观的浏览器 API。例如:存储和操作 DOM 元素。"
  • "将 ref 视为脱围机制。当你使用外部系统或浏览器 API 时,ref 很有用。如果你很大一部分应用程序逻辑和数据流都依赖于 ref,你可能需要重新考虑你的方法。"

单独提取了一个import相关的useFilePicker hook,防止操作dom的逻辑和ui耦合,代码职责更清晰,并且后续这个文件选择器hook逻辑正好要全局可复用,支持单文件和文件夹导入(文件夹模块暂未开发)。

ts 复制代码
import React, { useRef } from "react";

type PickerMode = "file" | "directory";

interface DirectoryInputElement extends HTMLInputElement {
  webkitdirectory: boolean;
  directory: boolean;
}
export const useFilePicker = (onFilesSelected: (files: File[]) => void) => {
  const inputRef = useRef<HTMLInputElement>(null);
  const openPicker = (mode: PickerMode = "file") => {
    if (inputRef.current) {
      const input = inputRef.current as DirectoryInputElement;
      if (mode === "directory") {
        input.webkitdirectory = true;
        input.directory = true;
      } else {
        input.webkitdirectory = false;
        input.directory = false;
      }
      inputRef.current.click();
    }
  };

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files || []);
    if (files.length > 0) {
      onFilesSelected(files);
      e.target.value = "";
    }
  };

  return {
    Picker: (
      <input
        type="file"
        ref={inputRef}
        onChange={handleFileChange}
        style={{ display: "none" }}
        accept=".md"
      />
    ),
    openPicker,
  };
};

关于区分:在hook中通过webkitdirectory、directory属性区分文件夹与单文件。这个过程中,遇到了 TypeScript 的类型冲突问题------因为 webkitdirectory 并非所有浏览器都支持的官方标准属性,直接使用会导致类型报错。最终的解决方案是扩展接口,为输入元素定义了一个更包容的类型,从而保证了类型安全。

扩展DOM类型 "TypeScript 允许我们通过声明合并的方式来扩展接口。这意味着我们可以在多处定义同一个接口,然后将它们合并成一个单一的接口。"

五、总结

以上是文件导入功能的完整开发过程,整体采用交互层、触发层、逻辑层三层架构,目前文件处理模块已基本可用。

回头看,只是这样一个小功能,我似乎又学习到了不少:我对hook的封装,纯函数的理解在实践中更明确,并且回顾了之前根本没仔细读的docs,还有在写代码前想好数据流的走向,web api的操作和开源库原生api的阅读源码和使用,小细节上有类型安全。

虽然大部分代码并非完全原创,有之前的文件导入逻辑参考,依葫芦画瓢仿写,但这样我认为自己在开发中的参与度变高了,收获也就变多了。

参考文档(部分)

Blob:text() 方法 - Web API | MDN

FileReader - Web API | MDN

文档对象模型(DOM) - Web API | MDN

相关推荐
毛骗导演1 小时前
万字解析 OpenClaw 源码架构-代理系统(二)
前端·架构
不可能的是1 小时前
彻底搞懂 Module Federation(中中):MF 模块加载(上)
前端·webpack
毛骗导演1 小时前
万字解析 OpenClaw 源码架构-工具与自动化
前端
毛骗导演1 小时前
万字解析 OpenClaw 源码架构-代理系统(一)
前端·架构
波哥学开发1 小时前
🎯 Canvas 箭头绘制算法(附完整源码)
前端·计算机图形学
拖拉斯旋风1 小时前
从零到一:用 Node.js + LangChain + Milvus 打造《天龙八部》专属 RAG 问答机器人
前端
不可能的是1 小时前
彻底搞懂 Module Federation(中下):MF 模块加载(下)
前端·webpack
独特的账号1 小时前
前端浏览器插件的开发一步搞定
前端·react.js
李剑一1 小时前
超实用!数字孪生 Cesium 园区 3D 模型加载,一次学会的保姆级教程
前端·vue.js·cesium