Tài liệu Logic Phân trang PagePreview (Cập nhật Sửa lỗi)

Tài liệu này tổng hợp chi tiết logic hoạt động, mã nguồn có chú thích và thuật toán cốt lõi của bộ máy phân trang tĩnh PagePreview đã được sửa các lỗi tràn bảng, mất bullet list và hỗ trợ các kích thước trang linh hoạt.

Mục tiêu của Kiến trúc
Tách biệt hoàn toàn chế độ Soạn thảo (Draft Mode) và chế độ Trang in (Page Preview). Người dùng soạn thảo trên một editor liên tục (hiệu năng cao, không lỗi bộ gõ, không nhảy cursor). Khi chuyển sang chế độ Trang in, một engine đo đạc DOM ảo sẽ tự động phân tách tài liệu thành các trang vật lý chính xác trước khi hiển thị.

1. Các cải tiến & Khắc phục Edge Cases mới nhất

2. Mã nguồn chi tiết có chú thích (PagePreview.tsx)

Dưới đây là toàn bộ code React TypeScript của component PagePreview đã được cập nhật sửa lỗi:

import React, { useEffect, useState } from 'react';
import type { PageSettings } from '../../shared/types';
import { mmToPixels } from '../../shared/pageUtils';

interface PagePreviewProps {
  html: string;           // Dữ liệu HTML thô lấy từ editor.getHTML()
  pageSettings: PageSettings; // Khổ giấy và lề margins (đơn vị mm)
  headerHtml: string;     // Mã HTML của vùng đầu trang
  footerHtml: string;     // Mã HTML của vùng chân trang
  zoomLevel: number;      // Tỉ lệ thu phóng màn hình (50% - 200%)
  defaultFont: string;    // Font chữ mặc định được cấu hình
  defaultFontSize: number;// Kích thước font chữ mặc định (pt)
}

export const PagePreview: React.FC<PagePreviewProps> = ({
  html,
  pageSettings,
  headerHtml,
  footerHtml,
  zoomLevel,
  defaultFont,
  defaultFontSize,
}) => {
  const [pages, setPages] = useState<string[][]>([]);

  // --- BƯỚC 1: TÍNH TOÁN KÍCH THƯỚC TRANG GIẤY (Đổi mm sang pixel ở Zoom 100%) ---
  const baseZoom = 100;
  const containerWidthPx = Math.round(mmToPixels(pageSettings.pageWidth, baseZoom));
  const containerHeightPx = Math.round(mmToPixels(pageSettings.pageHeight, baseZoom));

  const paddingTopPx = Math.round(mmToPixels(pageSettings.marginTop, baseZoom));
  const paddingBottomPx = Math.round(mmToPixels(pageSettings.marginBottom, baseZoom));
  const paddingLeftPx = Math.round(mmToPixels(pageSettings.marginLeft, baseZoom));
  const paddingRightPx = Math.round(mmToPixels(pageSettings.marginRight, baseZoom));

  const contentAreaWidthPx = containerWidthPx - paddingLeftPx - paddingRightPx;
  const headerHeight = headerHtml ? 40 : 0;
  const footerHeight = footerHtml ? 40 : 0;
  const contentAreaHeightPx = containerHeightPx - paddingTopPx - paddingBottomPx - headerHeight - footerHeight;

  const scaleFactor = zoomLevel / 100;
  const compensatingWidth = `${100 / scaleFactor}%`;

  const formatHeaderFooter = (markup: string, pageNum: number, totalPages: number) => {
    if (!markup) return '';
    return markup
      .replace(/\{page\}/g, String(pageNum))
      .replace(/\{total\}/g, String(totalPages));
  };

  // --- BƯỚC 2: THỰC HIỆN PHÂN TRANG ĐỘNG QUA SANDBOX DOM ---
  useEffect(() => {
    // 1. Tạo container bọc ngoài để kế thừa biến CSS của editor chính
    const wrapper = document.createElement('div');
    wrapper.className = 'leandix-editor-content';
    wrapper.style.fontFamily = defaultFont.includes(' ') && !defaultFont.startsWith("'") && !defaultFont.startsWith('"') ? `'${defaultFont}'` : defaultFont;
    wrapper.style.setProperty('--leandix-default-font', defaultFont.includes(' ') && !defaultFont.startsWith("'") && !defaultFont.startsWith('"') ? `'${defaultFont}'` : defaultFont);
    wrapper.style.setProperty('--leandix-default-font-size', `${defaultFontSize}pt`);
    wrapper.style.position = 'absolute';
    wrapper.style.top = '-9999px';
    wrapper.style.left = '-9999px';
    wrapper.style.width = `${contentAreaWidthPx}px`;

    // 2. Tạo sandbox editor thực tế
    const sandbox = document.createElement('div');
    sandbox.className = 'leandix-prosemirror tiptap';
    wrapper.appendChild(sandbox);
    document.body.appendChild(wrapper);

    const parser = new DOMParser();
    const doc = parser.parseFromString(html || '<p></p>', 'text/html');
    const nodes = Array.from(doc.body.childNodes);

    const paginatedHtmlList: string[][] = [[]];
    let currentPageIdx = 0;

    // 3. Khởi tạo trang ảo đầu tiên có thuộc tính BFC flow-root tránh trôi margin
    let tempPage = document.createElement('div');
    tempPage.style.width = `${contentAreaWidthPx}px`;
    tempPage.style.display = 'flow-root';
    sandbox.appendChild(tempPage);

    for (const node of nodes) {
      if (node.nodeType === Node.TEXT_NODE && !node.textContent?.trim()) {
        continue;
      }
      const clonedNode = node.cloneNode(true) as HTMLElement;

      const isManualPageBreak =
        clonedNode.nodeType === Node.ELEMENT_NODE &&
        (clonedNode.classList.contains('page-break') || (clonedNode.tagName === 'HR' && clonedNode.classList.contains('page-break')));

      if (isManualPageBreak) {
        tempPage = document.createElement('div');
        tempPage.style.width = `${contentAreaWidthPx}px`;
        tempPage.style.display = 'flow-root';
        sandbox.appendChild(tempPage);
        paginatedHtmlList.push([]);
        currentPageIdx++;
        continue;
      }

      tempPage.appendChild(clonedNode);

      if (tempPage.offsetHeight > contentAreaHeightPx) {
        tempPage.removeChild(clonedNode);

        // 3.1 Tách các dòng của bảng (TABLE)
        if (clonedNode.tagName === 'TABLE') {
          const tableShell = clonedNode.cloneNode(false) as HTMLElement;
          // Chỉ lấy các thẻ tr trực tiếp thuộc bảng (tránh hàng trong bảng lồng)
          const rows = Array.from(clonedNode.querySelectorAll('tr')).filter(
            row => row.closest('table') === clonedNode
          );

          let currentTable = tableShell.cloneNode(true) as HTMLElement;
          tempPage.appendChild(currentTable);

          for (const row of rows) {
            const rowClone = row.cloneNode(true) as HTMLTableRowElement;
            currentTable.appendChild(rowClone);

            if (tempPage.offsetHeight > contentAreaHeightPx) {
              currentTable.removeChild(rowClone);

              if (currentTable.querySelectorAll('tr').length > 0) {
                paginatedHtmlList[currentPageIdx].push(currentTable.outerHTML);
              }

              tempPage = document.createElement('div');
              tempPage.style.width = `${contentAreaWidthPx}px`;
              tempPage.style.display = 'flow-root';
              sandbox.appendChild(tempPage);

              paginatedHtmlList.push([]);
              currentPageIdx++;

              currentTable = tableShell.cloneNode(true) as HTMLElement;
              tempPage.appendChild(currentTable);
              currentTable.appendChild(rowClone);
            }
          }
          if (currentTable.querySelectorAll('tr').length > 0) {
            paginatedHtmlList[currentPageIdx].push(currentTable.outerHTML);
          }
        }
        // 3.2 Tách danh sách (UL/OL)
        else if (clonedNode.tagName === 'UL' || clonedNode.tagName === 'OL') {
          const listShell = clonedNode.cloneNode(false) as HTMLElement;
          // Chỉ lấy các li con trực tiếp (tránh li trong danh sách con lồng nhau)
          const items = Array.from(clonedNode.children).filter(child => child.tagName === 'LI') as HTMLLIElement[];

          let currentList = listShell.cloneNode(true) as HTMLElement;
          tempPage.appendChild(currentList);

          let itemIndex = 1;
          for (const item of items) {
            const itemClone = item.cloneNode(true) as HTMLLIElement;
            currentList.appendChild(itemClone);

            if (tempPage.offsetHeight > contentAreaHeightPx) {
              currentList.removeChild(itemClone);

              if (currentList.children.length > 0) {
                paginatedHtmlList[currentPageIdx].push(currentList.outerHTML);
              }

              tempPage = document.createElement('div');
              tempPage.style.width = `${contentAreaWidthPx}px`;
              tempPage.style.display = 'flow-root';
              sandbox.appendChild(tempPage);

              paginatedHtmlList.push([]);
              currentPageIdx++;

              currentList = listShell.cloneNode(true) as HTMLElement;
              if (clonedNode.tagName === 'OL') {
                currentList.setAttribute('start', String(itemIndex));
              }
              tempPage.appendChild(currentList);
              currentList.appendChild(itemClone);
            }
            itemIndex++;
          }
          if (currentList.children.length > 0) {
            paginatedHtmlList[currentPageIdx].push(currentList.outerHTML);
          }
        }
        // 3.3 Đẩy nguyên khối đối với các thẻ khác
        else {
          tempPage = document.createElement('div');
          tempPage.style.width = `${contentAreaWidthPx}px`;
          tempPage.style.display = 'flow-root';
          sandbox.appendChild(tempPage);
          paginatedHtmlList.push([]);
          currentPageIdx++;

          tempPage.appendChild(clonedNode);
          paginatedHtmlList[currentPageIdx].push(clonedNode.outerHTML);
        }
      } else {
        paginatedHtmlList[currentPageIdx].push(clonedNode.outerHTML);
      }
    }

    document.body.removeChild(wrapper);
    setPages(paginatedHtmlList);
  }, [html, contentAreaHeightPx, contentAreaWidthPx, defaultFont, defaultFontSize]);

  const totalPages = pages.length;

  return (
    <div
      className="leandix-page-surround"
      style={{
        width: compensatingWidth,
        margin: '0 auto',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        gap: '24px',
        padding: '24px 0',
      }}
    >
      {pages.map((pageElements, index) => {
        const pageNum = index + 1;
        const formattedHeader = formatHeaderFooter(headerHtml, pageNum, totalPages);
        const formattedFooter = formatHeaderFooter(footerHtml, pageNum, totalPages);

        return (
          <div
            key={index}
            className="leandix-page-view page-preview-sheet"
            style={{
              width: `${containerWidthPx}px`,
              height: `${containerHeightPx}px`,
              paddingTop: `${paddingTopPx}px`,
              paddingBottom: `${paddingBottomPx}px`,
              paddingLeft: `${paddingLeftPx}px`,
              paddingRight: `${paddingRightPx}px`,
              transform: `scale(${scaleFactor})`,
              transformOrigin: 'top center',
              background: 'var(--leandix-page-bg)',
              boxShadow: 'var(--leandix-page-shadow)',
              position: 'relative',
              boxSizing: 'border-box',
              display: 'flex',
              flexDirection: 'column',
              overflow: 'hidden',
            }}
          >
            {/* Header */}
            {headerHtml && (
              <div
                className="leandix-header-section read-only"
                style={{
                  height: `${headerHeight}px`,
                  borderBottom: '1px dashed var(--leandix-border)',
                  marginBottom: '12px',
                  fontSize: '9pt',
                  color: 'var(--leandix-text-secondary)',
                  display: 'flex',
                  alignItems: 'center',
                  overflow: 'hidden',
                }}
                dangerouslySetInnerHTML={{ __html: formattedHeader }}
              />
            )}

            {/* Page Content area (overflow: visible cho bảng lấn lề) */}
            <div
              className="leandix-editor-content leandix-prosemirror tiptap read-only"
              style={{
                flexGrow: 1,
                width: '100%',
                overflow: 'visible',
                outline: 'none',
                fontFamily: defaultFont.includes(' ') && !defaultFont.startsWith("'") && !defaultFont.startsWith('"') ? `'${defaultFont}'` : defaultFont,
                ['--leandix-default-font' as any]: defaultFont.includes(' ') && !defaultFont.startsWith("'") && !defaultFont.startsWith('"') ? `'${defaultFont}'` : defaultFont,
                ['--leandix-default-font-size' as any]: `${defaultFontSize}pt`,
              }}
            >
              {pageElements.map((elHtml, elIdx) => (
                <div key={elIdx} dangerouslySetInnerHTML={{ __html: elHtml }} />
              ))}
            </div>

            {/* Footer */}
            {footerHtml && (
              <div
                className="leandix-footer-section read-only"
                style={{
                  height: `${footerHeight}px`,
                  borderTop: '1px dashed var(--leandix-border)',
                  marginTop: '12px',
                  fontSize: '9pt',
                  color: 'var(--leandix-text-secondary)',
                  display: 'flex',
                  alignItems: 'center',
                  overflow: 'hidden',
                }}
                dangerouslySetInnerHTML={{ __html: formattedFooter }}
              />
            )}
          </div>
        );
      })}
    </div>
  );
};