203 lines
6.4 KiB
TypeScript
203 lines
6.4 KiB
TypeScript
/* 文件:xiaohongshu/paginator.ts — 小红书内容分页器:按切图比例自动分页,确保表格和图片不跨页。 */
|
||
|
||
import { NMPSettings } from '../settings';
|
||
|
||
/**
|
||
* 分页结果
|
||
*/
|
||
export interface PageInfo {
|
||
index: number; // 页码(从 0 开始)
|
||
content: string; // 该页的 HTML 内容
|
||
height: number; // 该页内容的实际高度(用于调试)
|
||
}
|
||
|
||
/**
|
||
* 解析横竖比例字符串为数值
|
||
*/
|
||
function parseAspectRatio(ratio: string): { width: number; height: number } {
|
||
const parts = ratio.split(':').map(p => parseFloat(p.trim()));
|
||
if (parts.length === 2 && parts[0] > 0 && parts[1] > 0) {
|
||
return { width: parts[0], height: parts[1] };
|
||
}
|
||
return { width: 3, height: 4 };
|
||
}
|
||
|
||
const PAGE_PADDING = 40; // 与 renderPage 保持一致的页面内边距
|
||
|
||
/**
|
||
* 计算目标页面高度
|
||
*/
|
||
function getTargetPageHeight(settings: NMPSettings): number {
|
||
const ratio = parseAspectRatio(settings.sliceImageAspectRatio);
|
||
const height = Math.round((settings.sliceImageWidth * ratio.height) / ratio.width);
|
||
return height;
|
||
}
|
||
|
||
/**
|
||
* 判断元素是否为不可分割元素(表格、图片、代码块等)
|
||
*/
|
||
function isIndivisibleElement(element: Element): boolean {
|
||
const tagName = element.tagName.toLowerCase();
|
||
// 表格、图片、代码块、公式等不应跨页
|
||
return ['table', 'img', 'pre', 'figure', 'svg'].includes(tagName) ||
|
||
element.classList.contains('math-block') ||
|
||
element.classList.contains('mermaid') ||
|
||
element.classList.contains('excalidraw');
|
||
}
|
||
|
||
/**
|
||
* 将 HTML 内容分页
|
||
* @param articleElement 文章预览的 DOM 元素
|
||
* @param settings 插件设置
|
||
* @returns 分页结果数组
|
||
*/
|
||
export async function paginateArticle(
|
||
articleElement: HTMLElement,
|
||
settings: NMPSettings
|
||
): Promise<PageInfo[]> {
|
||
const pageHeight = getTargetPageHeight(settings);
|
||
const pageWidth = settings.sliceImageWidth;
|
||
|
||
// 创建临时测量容器:与实际页面一致的宽度与内边距
|
||
const measureHost = document.createElement('div');
|
||
measureHost.style.cssText = `
|
||
position: absolute;
|
||
left: -9999px;
|
||
top: 0;
|
||
width: ${pageWidth}px;
|
||
visibility: hidden;
|
||
box-sizing: border-box;
|
||
`;
|
||
document.body.appendChild(measureHost);
|
||
|
||
const measurePage = document.createElement('div');
|
||
measurePage.className = 'xhs-page';
|
||
measurePage.style.boxSizing = 'border-box';
|
||
measurePage.style.width = `${pageWidth}px`;
|
||
measurePage.style.padding = `${PAGE_PADDING}px`;
|
||
measurePage.style.background = 'white';
|
||
measurePage.style.position = 'relative';
|
||
measureHost.appendChild(measurePage);
|
||
|
||
const measureContent = document.createElement('div');
|
||
measureContent.className = 'xhs-page-content';
|
||
measurePage.appendChild(measureContent);
|
||
if (articleElement.classList.length > 0) {
|
||
measureContent.classList.add(...Array.from(articleElement.classList));
|
||
}
|
||
const measuredFontSize = window.getComputedStyle(articleElement).fontSize;
|
||
if (measuredFontSize) {
|
||
measureContent.style.fontSize = measuredFontSize;
|
||
}
|
||
|
||
const pages: PageInfo[] = [];
|
||
let currentPageContent: Element[] = [];
|
||
let currentPageHeight = 0;
|
||
let pageIndex = 0;
|
||
|
||
// 克隆文章内容以避免修改原始 DOM
|
||
const clonedArticle = articleElement.cloneNode(true) as HTMLElement;
|
||
const children = Array.from(clonedArticle.children);
|
||
|
||
for (const child of children) {
|
||
const childClone = child.cloneNode(true) as HTMLElement;
|
||
measureContent.appendChild(childClone);
|
||
|
||
await waitForLayout();
|
||
|
||
const totalHeight = measurePage.scrollHeight;
|
||
const isIndivisible = isIndivisibleElement(child);
|
||
const fitsCurrentPage =
|
||
totalHeight <= pageHeight ||
|
||
(!isIndivisible && totalHeight <= pageHeight * 1.1) ||
|
||
currentPageContent.length === 0;
|
||
|
||
if (fitsCurrentPage) {
|
||
currentPageContent.push(child);
|
||
currentPageHeight = totalHeight;
|
||
continue;
|
||
}
|
||
|
||
// 当前页已放不下:移除刚刚加入的克隆节点
|
||
measureContent.removeChild(childClone);
|
||
await waitForLayout();
|
||
|
||
if (currentPageContent.length > 0) {
|
||
pages.push({
|
||
index: pageIndex++,
|
||
content: wrapPageContent(currentPageContent),
|
||
height: currentPageHeight
|
||
});
|
||
}
|
||
|
||
currentPageContent = [child];
|
||
measureContent.innerHTML = '';
|
||
const firstClone = child.cloneNode(true) as HTMLElement;
|
||
measureContent.appendChild(firstClone);
|
||
await waitForLayout();
|
||
currentPageHeight = measurePage.scrollHeight;
|
||
|
||
// 不可分割元素即使超过高度也直接保留在新页
|
||
}
|
||
|
||
if (currentPageContent.length > 0) {
|
||
pages.push({
|
||
index: pageIndex,
|
||
content: wrapPageContent(currentPageContent),
|
||
height: currentPageHeight
|
||
});
|
||
}
|
||
|
||
document.body.removeChild(measureHost);
|
||
|
||
return pages;
|
||
}
|
||
|
||
async function waitForLayout(): Promise<void> {
|
||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
|
||
}
|
||
|
||
/**
|
||
* 包装页面内容为完整的 HTML
|
||
*/
|
||
function wrapPageContent(elements: Element[]): string {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'xhs-page-content';
|
||
elements.forEach(el => {
|
||
wrapper.appendChild(el.cloneNode(true));
|
||
});
|
||
return wrapper.innerHTML;
|
||
}
|
||
|
||
/**
|
||
* 渲染单个页面到容器
|
||
* 预览时缩放显示,切图时使用实际尺寸
|
||
*/
|
||
export function renderPage(
|
||
container: HTMLElement,
|
||
pageContent: string,
|
||
settings: NMPSettings
|
||
): void {
|
||
// 实际内容尺寸(切图使用)
|
||
const actualPageWidth = settings.sliceImageWidth;
|
||
const actualPageHeight = getTargetPageHeight(settings);
|
||
|
||
container.innerHTML = '';
|
||
|
||
// 直接设置为实际尺寸,用于切图
|
||
// 预览时通过外层 CSS 的 max-width 限制显示宽度,浏览器自动缩放
|
||
container.style.cssText = `
|
||
width: ${actualPageWidth}px;
|
||
height: ${actualPageHeight}px;
|
||
overflow: hidden;
|
||
box-sizing: border-box;
|
||
padding: ${PAGE_PADDING}px;
|
||
background: #faf1f1;
|
||
`;
|
||
|
||
const contentDiv = document.createElement('div');
|
||
contentDiv.className = 'xhs-page-content';
|
||
contentDiv.innerHTML = pageContent;
|
||
container.appendChild(contentDiv);
|
||
}
|