You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

在Next.js中基于html2canvas和jsPDF导出带A4样式分页的Lexical编辑器为PDF的实现方案

在Next.js中基于html2canvas和jsPDF导出带A4样式分页的Lexical编辑器为PDF的实现方案

嘿,我刚好之前在Next.js里做过类似的Lexical编辑器分页导出PDF的需求,踩了好几个样式和测量的坑,给你整理一套亲测可行的实现方案,应该能解决你的问题!

一、核心思路

我们的目标是先把Lexical的富文本内容按照A4尺寸自动拆分成独立的页面块,再逐个把这些页面转成高清图片,最后用jsPDF拼接成完整的PDF。关键在于准确的内容高度测量样式一致性,不然分页会乱套。

二、具体实现步骤

1. 准备DOM容器与样式定义

先准备两个关键容器:一个隐藏的测量容器(用来计算内容高度,不影响页面布局),一个用来渲染实际分页块的容器。同时严格定义A4的尺寸(按96DPI换算,宽794px,高1122px),并且保证所有容器的样式完全统一。

2. 同步Lexical内容到测量容器

Lexical的内容是内部状态管理的,不能直接拿编辑器的DOM节点,得通过它的API获取最新的HTML内容,同步到隐藏容器里,这样才能准确测量高度。

3. 实现自动分页逻辑

遍历隐藏容器里的所有内容节点,逐个往当前页面块里添加,每次添加后测量页面高度,如果超过A4的高度,就新建一个页面块,把节点移到新页面里。这里要注意克隆节点,避免修改原隐藏容器的内容。

4. 用html2canvas+jsPDF导出PDF

把每个分页块转成高清图片(提高scale值避免模糊),然后逐个添加到jsPDF实例里,最后保存成PDF文件。

三、完整代码示例

import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot } from 'lexical';
import { useRef } from 'react';

// 替换成你自己的Lexical编辑器组件
const YourLexicalEditorComponent = () => {
  const [editor] = useLexicalComposerContext();
  return <div className="lexical-editor" contentEditable />;
};

export default function DocumentEditor() {
  const [editor] = useLexicalComposerContext();
  const hiddenMeasuringRef = useRef<HTMLDivElement>(null);
  const renderedPagesRef = useRef<HTMLDivElement>(null);
  // A4尺寸配置(96DPI)
  const A4_CONFIG = {
    WIDTH: 794,
    HEIGHT: 1122,
    PADDING: 20,
    FONT: 'Arial, sans-serif',
    FONT_SIZE: '16px',
    LINE_HEIGHT: 1.5,
  };

  // 同步Lexical最新内容到隐藏测量容器
  const syncContentToMeasuringContainer = () => {
    editor.update(() => {
      const rootNode = $getRoot();
      const contentHTML = rootNode.innerHTML;
      if (hiddenMeasuringRef.current) {
        hiddenMeasuringRef.current.innerHTML = contentHTML;
      }
    });
  };

  // 创建标准A4页面块的工具函数
  const createA4Page = () => {
    const page = document.createElement('div');
    page.className = 'a4-page';
    page.style.width = `${A4_CONFIG.WIDTH}px`;
    page.style.height = `${A4_CONFIG.HEIGHT}px`;
    page.style.overflow = 'hidden';
    page.style.margin = '0 auto 20px';
    page.style.padding = `${A4_CONFIG.PADDING}px`;
    page.style.border = '1px solid #e0e0e0'; // 开发调试用,导出时可移除
    page.style.fontFamily = A4_CONFIG.FONT;
    page.style.fontSize = A4_CONFIG.FONT_SIZE;
    page.style.lineHeight = `${A4_CONFIG.LINE_HEIGHT}`;
    return page;
  };

  // 执行内容拆分,生成A4分页块
  const splitContentIntoA4Pages = () => {
    if (!hiddenMeasuringRef.current || !renderedPagesRef.current) return;
    
    const measuringContainer = hiddenMeasuringRef.current;
    const pagesContainer = renderedPagesRef.current;
    // 清空现有分页
    pagesContainer.innerHTML = '';

    // 初始化第一个A4页面
    let currentPage = createA4Page();
    pagesContainer.appendChild(currentPage);

    const contentNodes = Array.from(measuringContainer.childNodes) as HTMLElement[];

    for (const node of contentNodes) {
      const clonedNode = node.cloneNode(true) as HTMLElement;
      currentPage.appendChild(clonedNode);

      // 检查当前页面高度是否超出A4限制
      const currentPageHeight = currentPage.scrollHeight;
      if (currentPageHeight > A4_CONFIG.HEIGHT) {
        // 移除刚添加的节点,因为它导致页面超了
        currentPage.removeChild(clonedNode);
        // 创建新的A4页面
        currentPage = createA4Page();
        pagesContainer.appendChild(currentPage);
        // 把节点加到新页面
        currentPage.appendChild(clonedNode);
      }
    }
  };

  // 导出PDF的核心函数
  const exportToPDF = async () => {
    // 先同步最新内容到测量容器
    syncContentToMeasuringContainer();
    // 等待DOM更新完成再执行分页
    requestAnimationFrame(async () => {
      splitContentIntoA4Pages();
      // 等待分页渲染完成
      requestAnimationFrame(async () => {
        // 动态导入依赖,减少首屏体积
        const { default: jsPDF } = await import('jspdf');
        const { default: html2canvas } = await import('html2canvas');
        
        const pdfDoc = new jsPDF('p', 'px', 'a4');
        const allPages = document.querySelectorAll('.a4-page');

        for (let i = 0; i < allPages.length; i++) {
          const pageElement = allPages[i];
          // 生成高清canvas,scale=2避免模糊
          const canvas = await html2canvas(pageElement, {
            scale: 2,
            logging: false,
            useCORS: true, // 有跨域图片时开启
            backgroundColor: '#ffffff',
          });
          const imageData = canvas.toDataURL('image/png');
          
          // 除了第一页,每加一页都要新增页面
          if (i !== 0) pdfDoc.addPage();
          pdfDoc.addImage(imageData, 'PNG', 0, 0, A4_CONFIG.WIDTH, A4_CONFIG.HEIGHT);
        }

        pdfDoc.save('document.pdf');
      });
    });
  };

  return (
    <div className="document-editor-wrapper">
      {/* 你的Lexical编辑器 */}
      <YourLexicalEditorComponent />

      {/* 隐藏的内容测量容器 - 不可见,仅用于计算高度 */}
      <div
        ref={hiddenMeasuringRef}
        style={{
          position: 'absolute',
          visibility: 'hidden',
          top: 0,
          left: 0,
          width: `${A4_CONFIG.WIDTH}px`,
          padding: `${A4_CONFIG.PADDING}px`,
          fontFamily: A4_CONFIG.FONT,
          fontSize: A4_CONFIG.FONT_SIZE,
          lineHeight: `${A4_CONFIG.LINE_HEIGHT}`,
        }}
      />

      {/* 分页预览容器(可选,开发时可以看分页效果,生产环境可以隐藏) */}
      <div 
        ref={renderedPagesRef} 
        style={{ marginTop: '30px', padding: '0 10px' }}
      />

      {/* 导出按钮 */}
      <button
        onClick={exportToPDF}
        style={{
          marginTop: '20px',
          padding: '10px 24px',
          backgroundColor: '#2563eb',
          color: '#fff',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer',
          fontSize: '16px',
        }}
      >
        导出为PDF
      </button>
    </div>
  );
}

四、关键注意事项

  • 样式绝对一致:隐藏测量容器和A4页面块的样式必须完全相同,包括字体、内边距、行高这些细节,不然测量的高度会不准,分页直接乱掉。
  • Lexical内容同步:一定要用Lexical的editor.update() API获取最新内容,不能直接拿编辑器的DOM,因为Lexical内部有虚拟DOM机制,直接取DOM可能拿到旧内容或者不完整的内容。
  • 跨页元素处理:如果有单个元素(比如长图片、大表格)超过A4高度,上面的代码会把整个元素放到新页面,但如果元素本身高度超过A4,会超出页面边界。这种情况可以加额外逻辑:判断单个节点的高度,如果超过A4高度,就缩放元素(比如图片设max-height: 100%; width: auto;),或者提示用户调整内容。
  • 清晰度优化:html2canvas的scale参数设为2或3,导出的PDF会清晰很多,默认scale=1的话会模糊。
  • Next.js动态导入:把html2canvas和jsPDF用动态导入,避免把大体积的库打包进首屏代码,优化页面加载速度。

内容来源于stack exchange

火山引擎 最新活动