update at 2025-10-08 12:53:49
This commit is contained in:
177
src/xiaohongshu/paginator.ts
Normal file
177
src/xiaohongshu/paginator.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/* 文件: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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算目标页面高度
|
||||
*/
|
||||
function getTargetPageHeight(settings: NMPSettings): number {
|
||||
const ratio = parseAspectRatio(settings.sliceImageAspectRatio);
|
||||
return Math.round((settings.sliceImageWidth * ratio.height) / ratio.width);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断元素是否为不可分割元素(表格、图片、代码块等)
|
||||
*/
|
||||
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 measureContainer = document.createElement('div');
|
||||
measureContainer.style.cssText = `
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
width: ${pageWidth}px;
|
||||
visibility: hidden;
|
||||
`;
|
||||
document.body.appendChild(measureContainer);
|
||||
|
||||
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;
|
||||
measureContainer.innerHTML = '';
|
||||
measureContainer.appendChild(childClone);
|
||||
|
||||
// 等待浏览器完成渲染
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const childHeight = childClone.offsetHeight;
|
||||
const isIndivisible = isIndivisibleElement(child);
|
||||
|
||||
// 判断是否需要换页
|
||||
if (currentPageHeight + childHeight > pageHeight && currentPageContent.length > 0) {
|
||||
// 如果是不可分割元素且加入后会超出,先保存当前页
|
||||
if (isIndivisible) {
|
||||
pages.push({
|
||||
index: pageIndex++,
|
||||
content: wrapPageContent(currentPageContent),
|
||||
height: currentPageHeight
|
||||
});
|
||||
currentPageContent = [child];
|
||||
currentPageHeight = childHeight;
|
||||
} else {
|
||||
// 可分割元素(段落等),尝试加入当前页
|
||||
if (currentPageHeight + childHeight <= pageHeight * 1.1) {
|
||||
// 允许 10% 的溢出容差
|
||||
currentPageContent.push(child);
|
||||
currentPageHeight += childHeight;
|
||||
} else {
|
||||
// 超出太多,换页
|
||||
pages.push({
|
||||
index: pageIndex++,
|
||||
content: wrapPageContent(currentPageContent),
|
||||
height: currentPageHeight
|
||||
});
|
||||
currentPageContent = [child];
|
||||
currentPageHeight = childHeight;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 加入当前页
|
||||
currentPageContent.push(child);
|
||||
currentPageHeight += childHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存最后一页
|
||||
if (currentPageContent.length > 0) {
|
||||
pages.push({
|
||||
index: pageIndex,
|
||||
content: wrapPageContent(currentPageContent),
|
||||
height: currentPageHeight
|
||||
});
|
||||
}
|
||||
|
||||
// 清理临时容器
|
||||
document.body.removeChild(measureContainer);
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装页面内容为完整的 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 pageHeight = getTargetPageHeight(settings);
|
||||
const pageWidth = settings.sliceImageWidth;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.style.cssText = `
|
||||
width: ${pageWidth}px;
|
||||
min-height: ${pageHeight}px;
|
||||
max-height: ${pageHeight}px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
`;
|
||||
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'xhs-page-content';
|
||||
contentDiv.innerHTML = pageContent;
|
||||
container.appendChild(contentDiv);
|
||||
}
|
||||
415
src/xiaohongshu/preview-view.ts
Normal file
415
src/xiaohongshu/preview-view.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
/* 文件:xiaohongshu/preview-view.ts — 小红书预览视图组件:顶部工具栏、分页导航、底部切图按钮。 */
|
||||
|
||||
import { Notice, TFile } from 'obsidian';
|
||||
import { NMPSettings } from '../settings';
|
||||
import AssetsManager from '../assets';
|
||||
import { paginateArticle, renderPage, PageInfo } from './paginator';
|
||||
import { sliceCurrentPage, sliceAllPages } from './slice';
|
||||
|
||||
/**
|
||||
* 小红书预览视图
|
||||
*/
|
||||
export class XiaohongshuPreviewView {
|
||||
container: HTMLElement;
|
||||
settings: NMPSettings;
|
||||
assetsManager: AssetsManager;
|
||||
app: any;
|
||||
currentFile: TFile | null = null;
|
||||
|
||||
// UI 元素
|
||||
topToolbar: HTMLDivElement;
|
||||
templateSelect: HTMLSelectElement;
|
||||
themeSelect: HTMLSelectElement;
|
||||
fontSelect: HTMLSelectElement;
|
||||
fontSizeDisplay: HTMLSpanElement;
|
||||
|
||||
pageContainer: HTMLDivElement;
|
||||
bottomToolbar: HTMLDivElement;
|
||||
pageNavigation: HTMLDivElement;
|
||||
pageNumberDisplay: HTMLSpanElement;
|
||||
|
||||
// 分页数据
|
||||
pages: PageInfo[] = [];
|
||||
currentPageIndex: number = 0;
|
||||
currentFontSize: number = 16;
|
||||
articleHTML: string = '';
|
||||
|
||||
// 回调函数
|
||||
onRefreshCallback?: () => Promise<void>;
|
||||
onPublishCallback?: () => Promise<void>;
|
||||
onPlatformChangeCallback?: (platform: string) => Promise<void>;
|
||||
|
||||
constructor(container: HTMLElement, app: any) {
|
||||
this.container = container;
|
||||
this.app = app;
|
||||
this.settings = NMPSettings.getInstance();
|
||||
this.assetsManager = AssetsManager.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的小红书预览界面
|
||||
*/
|
||||
build(): void {
|
||||
this.container.empty();
|
||||
this.container.style.cssText = 'display: flex; flex-direction: column; height: 100%; background: linear-gradient(135deg, #f5f7fa 0%, #e8eaf6 100%);';
|
||||
|
||||
// 顶部工具栏
|
||||
this.buildTopToolbar();
|
||||
|
||||
// 页面容器
|
||||
this.pageContainer = this.container.createDiv({ cls: 'xhs-page-container' });
|
||||
this.pageContainer.style.cssText = 'flex: 1; overflow: auto; display: flex; justify-content: center; align-items: center; padding: 20px; background: radial-gradient(ellipse at top, rgba(255,255,255,0.1) 0%, transparent 70%);';
|
||||
|
||||
// 分页导航
|
||||
this.buildPageNavigation();
|
||||
|
||||
// 底部操作栏
|
||||
this.buildBottomToolbar();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建顶部工具栏
|
||||
*/
|
||||
private buildTopToolbar(): void {
|
||||
this.topToolbar = this.container.createDiv({ cls: 'xhs-top-toolbar' });
|
||||
this.topToolbar.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); border-bottom: 1px solid #e8eaed; box-shadow: 0 2px 4px rgba(0,0,0,0.04); flex-wrap: wrap;';
|
||||
|
||||
// 刷新按钮
|
||||
const refreshBtn = this.topToolbar.createEl('button', { text: '🔄 刷新' });
|
||||
refreshBtn.style.cssText = 'padding: 6px 14px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s ease; box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3);';
|
||||
refreshBtn.onmouseenter = () => refreshBtn.style.transform = 'translateY(-1px)';
|
||||
refreshBtn.onmouseleave = () => refreshBtn.style.transform = 'translateY(0)';
|
||||
refreshBtn.onclick = () => this.onRefresh();
|
||||
|
||||
// 发布按钮
|
||||
const publishBtn = this.topToolbar.createEl('button', { text: '📤 发布' });
|
||||
publishBtn.style.cssText = 'padding: 6px 14px; background: linear-gradient(135deg, #1e88e5 0%, #1565c0 100%); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s ease; box-shadow: 0 2px 6px rgba(30, 136, 229, 0.3);';
|
||||
publishBtn.onmouseenter = () => publishBtn.style.transform = 'translateY(-1px)';
|
||||
publishBtn.onmouseleave = () => publishBtn.style.transform = 'translateY(0)';
|
||||
publishBtn.onclick = () => this.onPublish();
|
||||
|
||||
// 分隔线
|
||||
const separator2 = this.topToolbar.createDiv({ cls: 'toolbar-separator' });
|
||||
separator2.style.cssText = 'width: 1px; height: 24px; background: #dadce0; margin: 0 4px;';
|
||||
|
||||
// 模板选择
|
||||
const templateLabel = this.topToolbar.createDiv({ cls: 'toolbar-label' });
|
||||
templateLabel.innerText = '模板';
|
||||
templateLabel.style.cssText = 'font-size: 11px; color: #5f6368; font-weight: 500; white-space: nowrap;';
|
||||
this.templateSelect = this.topToolbar.createEl('select');
|
||||
this.templateSelect.style.cssText = 'padding: 4px 8px; border: 1px solid #dadce0; border-radius: 4px; background: white; font-size: 11px; cursor: pointer; transition: border-color 0.2s ease;';
|
||||
['默认模板', '简约模板', '杂志模板'].forEach(name => {
|
||||
const option = this.templateSelect.createEl('option');
|
||||
option.value = name;
|
||||
option.text = name;
|
||||
});
|
||||
|
||||
// 主题选择
|
||||
const themeLabel = this.topToolbar.createDiv({ cls: 'toolbar-label' });
|
||||
themeLabel.innerText = '主题';
|
||||
themeLabel.style.cssText = 'font-size: 11px; color: #5f6368; font-weight: 500; white-space: nowrap;';
|
||||
this.themeSelect = this.topToolbar.createEl('select');
|
||||
this.themeSelect.style.cssText = 'padding: 4px 8px; border: 1px solid #dadce0; border-radius: 4px; background: white; font-size: 11px; cursor: pointer; transition: border-color 0.2s ease;';
|
||||
const themes = this.assetsManager.themes;
|
||||
themes.forEach(theme => {
|
||||
const option = this.themeSelect.createEl('option');
|
||||
option.value = theme.className;
|
||||
option.text = theme.name;
|
||||
});
|
||||
this.themeSelect.value = this.settings.defaultStyle;
|
||||
this.themeSelect.onchange = () => this.onThemeChanged();
|
||||
|
||||
// 字体选择
|
||||
const fontLabel = this.topToolbar.createDiv({ cls: 'toolbar-label' });
|
||||
fontLabel.innerText = '字体';
|
||||
fontLabel.style.cssText = 'font-size: 11px; color: #5f6368; font-weight: 500; white-space: nowrap;';
|
||||
this.fontSelect = this.topToolbar.createEl('select');
|
||||
this.fontSelect.style.cssText = 'padding: 4px 8px; border: 1px solid #dadce0; border-radius: 4px; background: white; font-size: 11px; cursor: pointer; transition: border-color 0.2s ease;';
|
||||
['系统默认', '宋体', '黑体', '楷体', '仿宋'].forEach(name => {
|
||||
const option = this.fontSelect.createEl('option');
|
||||
option.value = name;
|
||||
option.text = name;
|
||||
});
|
||||
this.fontSelect.onchange = () => this.onFontChanged();
|
||||
|
||||
// 字号控制
|
||||
const fontSizeLabel = this.topToolbar.createDiv({ cls: 'toolbar-label' });
|
||||
fontSizeLabel.innerText = '字号';
|
||||
fontSizeLabel.style.cssText = 'font-size: 11px; color: #5f6368; font-weight: 500; white-space: nowrap;';
|
||||
const fontSizeGroup = this.topToolbar.createDiv({ cls: 'font-size-group' });
|
||||
fontSizeGroup.style.cssText = 'display: flex; align-items: center; gap: 6px; background: white; border: 1px solid #dadce0; border-radius: 4px; padding: 2px;';
|
||||
|
||||
const decreaseBtn = fontSizeGroup.createEl('button', { text: '−' });
|
||||
decreaseBtn.style.cssText = 'width: 24px; height: 24px; border: none; background: transparent; border-radius: 3px; cursor: pointer; font-size: 16px; color: #5f6368; transition: background 0.2s ease;';
|
||||
decreaseBtn.onmouseenter = () => decreaseBtn.style.background = '#f1f3f4';
|
||||
decreaseBtn.onmouseleave = () => decreaseBtn.style.background = 'transparent';
|
||||
decreaseBtn.onclick = () => this.changeFontSize(-1);
|
||||
|
||||
this.fontSizeDisplay = fontSizeGroup.createEl('span', { text: '16' });
|
||||
this.fontSizeDisplay.style.cssText = 'min-width: 24px; text-align: center; font-size: 12px; color: #202124; font-weight: 500;';
|
||||
|
||||
const increaseBtn = fontSizeGroup.createEl('button', { text: '+' });
|
||||
increaseBtn.style.cssText = 'width: 24px; height: 24px; border: none; background: transparent; border-radius: 3px; cursor: pointer; font-size: 14px; color: #5f6368; transition: background 0.2s ease;';
|
||||
increaseBtn.onmouseenter = () => increaseBtn.style.background = '#f1f3f4';
|
||||
increaseBtn.onmouseleave = () => increaseBtn.style.background = 'transparent';
|
||||
increaseBtn.onclick = () => this.changeFontSize(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建分页导航
|
||||
*/
|
||||
private buildPageNavigation(): void {
|
||||
this.pageNavigation = this.container.createDiv({ cls: 'xhs-page-navigation' });
|
||||
this.pageNavigation.style.cssText = 'display: flex; justify-content: center; align-items: center; gap: 16px; padding: 12px; background: white; border-bottom: 1px solid #e8eaed;';
|
||||
|
||||
const prevBtn = this.pageNavigation.createEl('button', { text: '‹' });
|
||||
prevBtn.style.cssText = 'width: 36px; height: 36px; border: 1px solid #dadce0; border-radius: 50%; cursor: pointer; font-size: 20px; background: white; color: #5f6368; transition: all 0.2s ease; box-shadow: 0 1px 3px rgba(0,0,0,0.08);';
|
||||
prevBtn.onmouseenter = () => {
|
||||
prevBtn.style.background = 'linear-gradient(135deg, #1e88e5 0%, #1565c0 100%)';
|
||||
prevBtn.style.color = 'white';
|
||||
prevBtn.style.borderColor = '#1e88e5';
|
||||
};
|
||||
prevBtn.onmouseleave = () => {
|
||||
prevBtn.style.background = 'white';
|
||||
prevBtn.style.color = '#5f6368';
|
||||
prevBtn.style.borderColor = '#dadce0';
|
||||
};
|
||||
prevBtn.onclick = () => this.previousPage();
|
||||
|
||||
this.pageNumberDisplay = this.pageNavigation.createEl('span', { text: '1/1' });
|
||||
this.pageNumberDisplay.style.cssText = 'font-size: 14px; min-width: 50px; text-align: center; color: #202124; font-weight: 500;';
|
||||
|
||||
const nextBtn = this.pageNavigation.createEl('button', { text: '›' });
|
||||
nextBtn.style.cssText = 'width: 36px; height: 36px; border: 1px solid #dadce0; border-radius: 50%; cursor: pointer; font-size: 20px; background: white; color: #5f6368; transition: all 0.2s ease; box-shadow: 0 1px 3px rgba(0,0,0,0.08);';
|
||||
nextBtn.onmouseenter = () => {
|
||||
nextBtn.style.background = 'linear-gradient(135deg, #1e88e5 0%, #1565c0 100%)';
|
||||
nextBtn.style.color = 'white';
|
||||
nextBtn.style.borderColor = '#1e88e5';
|
||||
};
|
||||
nextBtn.onmouseleave = () => {
|
||||
nextBtn.style.background = 'white';
|
||||
nextBtn.style.color = '#5f6368';
|
||||
nextBtn.style.borderColor = '#dadce0';
|
||||
};
|
||||
nextBtn.onclick = () => this.nextPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建底部操作栏
|
||||
*/
|
||||
private buildBottomToolbar(): void {
|
||||
this.bottomToolbar = this.container.createDiv({ cls: 'xhs-bottom-toolbar' });
|
||||
this.bottomToolbar.style.cssText = 'display: flex; justify-content: center; gap: 12px; padding: 12px 16px; background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); border-top: 1px solid #e8eaed; box-shadow: 0 -2px 4px rgba(0,0,0,0.04);';
|
||||
|
||||
const currentPageBtn = this.bottomToolbar.createEl('button', { text: '⬇ 当前页切图' });
|
||||
currentPageBtn.style.cssText = 'padding: 8px 20px; background: linear-gradient(135deg, #1e88e5 0%, #1565c0 100%); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s ease; box-shadow: 0 2px 6px rgba(30, 136, 229, 0.3);';
|
||||
currentPageBtn.onmouseenter = () => {
|
||||
currentPageBtn.style.transform = 'translateY(-2px)';
|
||||
currentPageBtn.style.boxShadow = '0 4px 12px rgba(30, 136, 229, 0.4)';
|
||||
};
|
||||
currentPageBtn.onmouseleave = () => {
|
||||
currentPageBtn.style.transform = 'translateY(0)';
|
||||
currentPageBtn.style.boxShadow = '0 2px 6px rgba(30, 136, 229, 0.3)';
|
||||
};
|
||||
currentPageBtn.onclick = () => this.sliceCurrentPage();
|
||||
|
||||
const allPagesBtn = this.bottomToolbar.createEl('button', { text: '⇓ 全部页切图' });
|
||||
allPagesBtn.style.cssText = 'padding: 8px 20px; background: linear-gradient(135deg, #42a5f5 0%, #1e88e5 100%); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s ease; box-shadow: 0 2px 6px rgba(66, 165, 245, 0.3);';
|
||||
allPagesBtn.onmouseenter = () => {
|
||||
allPagesBtn.style.transform = 'translateY(-2px)';
|
||||
allPagesBtn.style.boxShadow = '0 4px 12px rgba(66, 165, 245, 0.4)';
|
||||
};
|
||||
allPagesBtn.onmouseleave = () => {
|
||||
allPagesBtn.style.transform = 'translateY(0)';
|
||||
allPagesBtn.style.boxShadow = '0 2px 6px rgba(66, 165, 245, 0.3)';
|
||||
};
|
||||
allPagesBtn.onclick = () => this.sliceAllPages();
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染文章内容并分页
|
||||
*/
|
||||
async renderArticle(articleHTML: string, file: TFile): Promise<void> {
|
||||
this.articleHTML = articleHTML;
|
||||
this.currentFile = file;
|
||||
|
||||
new Notice('正在分页...');
|
||||
|
||||
// 创建临时容器用于分页
|
||||
const tempContainer = document.createElement('div');
|
||||
tempContainer.innerHTML = articleHTML;
|
||||
tempContainer.style.cssText = `width: ${this.settings.sliceImageWidth}px;`;
|
||||
document.body.appendChild(tempContainer);
|
||||
|
||||
try {
|
||||
this.pages = await paginateArticle(tempContainer, this.settings);
|
||||
new Notice(`分页完成:共 ${this.pages.length} 页`);
|
||||
|
||||
this.currentPageIndex = 0;
|
||||
this.renderCurrentPage();
|
||||
} finally {
|
||||
document.body.removeChild(tempContainer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染当前页
|
||||
*/
|
||||
private renderCurrentPage(): void {
|
||||
if (this.pages.length === 0) return;
|
||||
|
||||
const page = this.pages[this.currentPageIndex];
|
||||
this.pageContainer.empty();
|
||||
|
||||
const pageElement = this.pageContainer.createDiv({ cls: 'xhs-page' });
|
||||
renderPage(pageElement, page.content, this.settings);
|
||||
|
||||
// 应用字体设置
|
||||
this.applyFontSettings(pageElement);
|
||||
|
||||
// 更新页码显示
|
||||
this.pageNumberDisplay.innerText = `${this.currentPageIndex + 1}/${this.pages.length}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用字体设置
|
||||
*/
|
||||
private applyFontSettings(element: HTMLElement): void {
|
||||
const fontFamily = this.fontSelect.value;
|
||||
const fontSize = this.currentFontSize;
|
||||
|
||||
let fontFamilyCSS = '';
|
||||
switch (fontFamily) {
|
||||
case '宋体': fontFamilyCSS = 'SimSun, serif'; break;
|
||||
case '黑体': fontFamilyCSS = 'SimHei, sans-serif'; break;
|
||||
case '楷体': fontFamilyCSS = 'KaiTi, serif'; break;
|
||||
case '仿宋': fontFamilyCSS = 'FangSong, serif'; break;
|
||||
default: fontFamilyCSS = 'system-ui, -apple-system, sans-serif';
|
||||
}
|
||||
|
||||
element.style.fontFamily = fontFamilyCSS;
|
||||
element.style.fontSize = `${fontSize}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换字号
|
||||
*/
|
||||
private changeFontSize(delta: number): void {
|
||||
this.currentFontSize = Math.max(12, Math.min(24, this.currentFontSize + delta));
|
||||
this.fontSizeDisplay.innerText = String(this.currentFontSize);
|
||||
this.renderCurrentPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题改变
|
||||
*/
|
||||
private onThemeChanged(): void {
|
||||
new Notice('主题已切换,请刷新预览');
|
||||
// TODO: 重新渲染文章
|
||||
}
|
||||
|
||||
/**
|
||||
* 字体改变
|
||||
*/
|
||||
private onFontChanged(): void {
|
||||
this.renderCurrentPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 上一页
|
||||
*/
|
||||
private previousPage(): void {
|
||||
if (this.currentPageIndex > 0) {
|
||||
this.currentPageIndex--;
|
||||
this.renderCurrentPage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一页
|
||||
*/
|
||||
private nextPage(): void {
|
||||
if (this.currentPageIndex < this.pages.length - 1) {
|
||||
this.currentPageIndex++;
|
||||
this.renderCurrentPage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前页切图
|
||||
*/
|
||||
private async sliceCurrentPage(): Promise<void> {
|
||||
if (!this.currentFile) {
|
||||
new Notice('请先打开一个笔记');
|
||||
return;
|
||||
}
|
||||
|
||||
const pageElement = this.pageContainer.querySelector('.xhs-page') as HTMLElement;
|
||||
if (!pageElement) {
|
||||
new Notice('未找到页面元素');
|
||||
return;
|
||||
}
|
||||
|
||||
new Notice('正在切图...');
|
||||
try {
|
||||
await sliceCurrentPage(pageElement, this.currentFile, this.currentPageIndex, this.app);
|
||||
new Notice('✅ 当前页切图完成');
|
||||
} catch (error) {
|
||||
console.error('切图失败:', error);
|
||||
new Notice('❌ 切图失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新按钮点击
|
||||
*/
|
||||
private async onRefresh(): Promise<void> {
|
||||
if (this.onRefreshCallback) {
|
||||
await this.onRefreshCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布按钮点击
|
||||
*/
|
||||
private async onPublish(): Promise<void> {
|
||||
if (this.onPublishCallback) {
|
||||
await this.onPublishCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部页切图
|
||||
*/
|
||||
private async sliceAllPages(): Promise<void> {
|
||||
if (!this.currentFile) {
|
||||
new Notice('请先打开一个笔记');
|
||||
return;
|
||||
}
|
||||
|
||||
new Notice(`开始切图:共 ${this.pages.length} 页`);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < this.pages.length; i++) {
|
||||
new Notice(`正在处理第 ${i + 1}/${this.pages.length} 页...`);
|
||||
|
||||
// 临时渲染这一页
|
||||
this.currentPageIndex = i;
|
||||
this.renderCurrentPage();
|
||||
|
||||
// 等待渲染完成
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
const pageElement = this.pageContainer.querySelector('.xhs-page') as HTMLElement;
|
||||
if (pageElement) {
|
||||
await sliceCurrentPage(pageElement, this.currentFile, i, this.app);
|
||||
}
|
||||
}
|
||||
|
||||
new Notice(`✅ 全部页切图完成:共 ${this.pages.length} 张`);
|
||||
} catch (error) {
|
||||
console.error('批量切图失败:', error);
|
||||
new Notice('❌ 批量切图失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/xiaohongshu/slice.ts
Normal file
98
src/xiaohongshu/slice.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/* 文件:xiaohongshu/slice.ts — 小红书单页/多页切图功能。 */
|
||||
|
||||
import { toPng } from 'html-to-image';
|
||||
import { TFile } from 'obsidian';
|
||||
import { NMPSettings } from '../settings';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 从 frontmatter 获取 slug
|
||||
*/
|
||||
function getSlugFromFile(file: TFile, app: any): string {
|
||||
const cache = app.metadataCache.getFileCache(file);
|
||||
if (cache?.frontmatter?.slug) {
|
||||
return String(cache.frontmatter.slug).trim();
|
||||
}
|
||||
return file.basename;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
function ensureDir(dirPath: string) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 base64 dataURL 转为 Buffer
|
||||
*/
|
||||
function dataURLToBuffer(dataURL: string): Buffer {
|
||||
const base64 = dataURL.split(',')[1];
|
||||
return Buffer.from(base64, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* 切图单个页面
|
||||
*/
|
||||
export async function sliceCurrentPage(
|
||||
pageElement: HTMLElement,
|
||||
file: TFile,
|
||||
pageIndex: number,
|
||||
app: any
|
||||
): Promise<void> {
|
||||
const settings = NMPSettings.getInstance();
|
||||
const { sliceImageSavePath, sliceImageWidth } = settings;
|
||||
|
||||
const slug = getSlugFromFile(file, app);
|
||||
|
||||
// 保存原始样式
|
||||
const originalWidth = pageElement.style.width;
|
||||
const originalMaxWidth = pageElement.style.maxWidth;
|
||||
const originalMinWidth = pageElement.style.minWidth;
|
||||
|
||||
try {
|
||||
// 临时设置为目标宽度
|
||||
pageElement.style.width = `${sliceImageWidth}px`;
|
||||
pageElement.style.maxWidth = `${sliceImageWidth}px`;
|
||||
pageElement.style.minWidth = `${sliceImageWidth}px`;
|
||||
|
||||
// 等待重排
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 生成图片
|
||||
const dataURL = await toPng(pageElement, {
|
||||
width: sliceImageWidth,
|
||||
pixelRatio: 1,
|
||||
cacheBust: true,
|
||||
});
|
||||
|
||||
// 保存文件
|
||||
ensureDir(sliceImageSavePath);
|
||||
const filename = `${slug}_${pageIndex + 1}.png`;
|
||||
const filepath = path.join(sliceImageSavePath, filename);
|
||||
const buffer = dataURLToBuffer(dataURL);
|
||||
fs.writeFileSync(filepath, new Uint8Array(buffer));
|
||||
|
||||
} finally {
|
||||
// 恢复样式
|
||||
pageElement.style.width = originalWidth;
|
||||
pageElement.style.maxWidth = originalMaxWidth;
|
||||
pageElement.style.minWidth = originalMinWidth;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量切图(由 preview-view.ts 调用)
|
||||
*/
|
||||
export async function sliceAllPages(
|
||||
pages: HTMLElement[],
|
||||
file: TFile,
|
||||
app: any
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
await sliceCurrentPage(pages[i], file, i, app);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user