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.
pageSettings để định hình lại kích thước trang và lề. Tiêu đề tab trên Toolbar tự động hiển thị khổ giấy tương ứng dạng Trang in (Letter) hoặc Trang in (A4).
.leandix-editor-content có truyền các biến CSS Custom Properties, giúp việc đo lường chiều cao của các bullet và chữ chính xác 100% so với kết quả render thực tế.display: flow-root trên các container trang đo đạc nhằm tránh hiện tượng sụt lề (margin collapse), đảm bảo chiều cao đo được chứa toàn bộ lề trên và lề dưới của phần tử.children) thay vì dùng querySelectorAll('li') nhằm tránh tình trạng làm phẳng danh sách lồng (nested lists) hoặc xử lý trùng lặp gây mất/thiếu mục.overflow: visible (thay vì overflow: hidden như trước), cho phép các bảng rộng lấn sang lề trái/phải hiển thị trọn vẹn giống như trong chế độ soạn thảo mà không bị cắt cụt cột, trong khi vẫn duy trì ngắt trang chiều dọc chuẩn xác.
<br class="ProseMirror-trailingBreak"> bên trong thẻ <p>. Nhưng khi gọi editor.getHTML(), nội dung trả về là <p></p> thuần túy (không có <br>). Khi DOMParser parse HTML này để đo đạc, thẻ <p> rỗng không có con, chiều cao nội dung bằng 0 pixel, khiến dòng trống bị “sập” hoàn toàn trong bộ phân trang.p, h1-h6 rỗng (childNodes.length === 0) và tự động inject <br> vào, đảm bảo chúng chiếm đúng 1 dòng chiều cao giống như trong editor thực tế.convertCellChildren của [DocxParser.ts](file:///d:/WORK/LEANDIX/leandix-atlas-editor-extension/src/extension/DocxParser.ts), bộ parser trước đây tự động lọc bỏ các thẻ <w:p> rỗng bên trong ô bảng (chỉ giữ lại thẻ rỗng đầu tiên nếu ô trống hoàn toàn). Điều này khiến các dòng trống (nhấn Enter) dùng để tạo khoảng trống ký tên (ví dụ giữa chữ "Sinh viên" và tên "Thịnh Hải Triều" nằm trong ô bảng) bị mất sạch.<w:p> trong ô bảng thành thẻ <p></p> mà không bị lọc bỏ.htmlparser2 có callback ontext bỏ qua toàn bộ text node rỗng bằng if (text.trim()). Điều này đúng cho whitespace giữa các thẻ XML, nhưng sai cho <w:t xml:space="preserve"> </w:t> — nơi ký tự khoảng trắng là nội dung hợp lệ.w:t, w:instrText, w:delText). Nếu đang ở bên trong các thẻ text-content này, giữ nguyên text node dù chỉ chứa khoảng trắng.transform: scale() thay đổi kích thước thị giác của thẻ, nhưng trong dòng chảy tài liệu (document flow) trình duyệt vẫn giữ nguyên chiều cao thực chưa scale của thẻ đó, dẫn đến khoảng trắng khổng lồ khi thu nhỏ.page-preview-scale-wrapper) có width và height được nhân chính xác với scaleFactor. Đồng thời đổi transformOrigin của trang con thành top left. Việc căn giữa được đảm bảo bởi thuộc tính align-items: center của container cha ngoài cùng.wheel khi e.ctrlKey hoạt động trong ZoomController.tsx, kết hợp e.preventDefault() để triệt tiêu hành vi zoom toàn trang mặc định của trình duyệt, thay thế bằng điều chỉnh zoomLevel của dự án..leandix-page-view vật lý giống hệt PagePreview trước khi in ra PDF, giúp kết quả PDF khớp 100% so với bản preview trong editor.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>
);
};