Files
note2any/src/xiaohongshu/xhs-preview.ts
2025-10-16 14:03:45 +08:00

551 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 文件xiaohongshu/xhs-preview.ts
* 作用:小红书预览视图组件,专门处理小红书平台的预览、分页和切图功能
*
* 功能:
* 1. 渲染小红书专属的预览界面(顶部工具栏、分页导航、底部切图按钮)
* 2. 处理文章内容的小红书格式化和分页
* 3. 提供切图功能(当前页/全部页)
* 4. 管理小红书特有的样式和字体设置
*/
import { Notice, TFile } from 'obsidian';
import { NMPSettings } from '../settings';
import AssetsManager from '../assets';
import { paginateArticle, renderPage, PageInfo } from './paginator';
import { sliceCurrentPage, sliceAllPages } from './slice';
const XHS_PREVIEW_DEFAULT_WIDTH = 540;
const XHS_PREVIEW_WIDTH_OPTIONS = [1080, 720, 540, 360];
// 字号控制常量:一处修改即可同步 UI 显示、输入校验和渲染逻辑
const XHS_FONT_SIZE_MIN = 18;
const XHS_FONT_SIZE_MAX = 45;
const XHS_FONT_SIZE_DEFAULT = 36;
/**
* 小红书预览视图类
*/
export class XiaohongshuPreview {
container: HTMLElement;
settings: NMPSettings;
assetsManager: AssetsManager;
app: any;
currentFile: TFile | null = null;
// UI 元素
templateSelect!: HTMLSelectElement;
fontSizeInput!: HTMLInputElement;
previewWidthSelect!: HTMLSelectElement;
pageContainer!: HTMLDivElement;
pageNumberInput!: HTMLInputElement;
pageTotalLabel!: HTMLSpanElement;
styleEl: HTMLStyleElement | null = null; // 主题样式注入节点
currentThemeClass: string = '';
// 分页数据
pages: PageInfo[] = [];
currentPageIndex: number = 0;
currentFontSize: number = XHS_FONT_SIZE_DEFAULT;
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.addClass('xhs-preview-container');
// 准备样式挂载节点
if (!this.styleEl) {
this.styleEl = document.createElement('style');
this.styleEl.setAttr('data-xhs-style', '');
}
if (!this.container.contains(this.styleEl)) {
this.container.appendChild(this.styleEl);
}
const board = this.container.createDiv({ cls: 'xhs-board' });
const templateCard = this.createGridCard(board, 'xhs-area-template');
const templateLabel = templateCard.createDiv({ cls: 'xhs-label', text: '模板' });
this.templateSelect = templateCard.createEl('select', { cls: 'xhs-select' });
['默认模板', '简约模板', '杂志模板'].forEach(name => {
const option = this.templateSelect.createEl('option');
option.value = name;
option.text = name;
});
const previewCard = this.createGridCard(board, 'xhs-area-preview');
const previewLabel = previewCard.createDiv({ cls: 'xhs-label', text: '宽度' });
this.previewWidthSelect = previewCard.createEl('select', { cls: 'xhs-select' });
const currentPreviewWidth = this.settings.xhsPreviewWidth || XHS_PREVIEW_DEFAULT_WIDTH;
XHS_PREVIEW_WIDTH_OPTIONS.forEach(value => {
const option = this.previewWidthSelect.createEl('option');
option.value = String(value);
option.text = `${value}px`;
});
if (!XHS_PREVIEW_WIDTH_OPTIONS.includes(currentPreviewWidth)) {
const customOption = this.previewWidthSelect.createEl('option');
customOption.value = String(currentPreviewWidth);
customOption.text = `${currentPreviewWidth}px`;
}
this.previewWidthSelect.value = String(currentPreviewWidth);
this.previewWidthSelect.onchange = async () => {
const value = parseInt(this.previewWidthSelect.value, 10);
if (Number.isFinite(value) && value > 0) {
await this.onPreviewWidthChanged(value);
} else {
this.previewWidthSelect.value = String(this.settings.xhsPreviewWidth || XHS_PREVIEW_DEFAULT_WIDTH);
}
};
const fontCard = this.createGridCard(board, 'xhs-area-font');
//fontCard.createDiv({ cls: 'xhs-label', text: '字号' });
const fontSizeGroup = fontCard.createDiv({ cls: 'font-size-group' });
const decreaseBtn = fontSizeGroup.createEl('button', { text: '', cls: 'font-size-btn' });
decreaseBtn.onclick = () => this.changeFontSize(-1);
this.fontSizeInput = fontSizeGroup.createEl('input', {
cls: 'font-size-input',
attr: {
type: 'number',
min: String(XHS_FONT_SIZE_MIN),
max: String(XHS_FONT_SIZE_MAX),
value: String(XHS_FONT_SIZE_DEFAULT)
}
});
this.fontSizeInput.onchange = () => this.onFontSizeInputChanged();
const increaseBtn = fontSizeGroup.createEl('button', { text: '', cls: 'font-size-btn' });
increaseBtn.onclick = () => this.changeFontSize(1);
const contentWrapper = board.createDiv({ cls: 'xhs-area-content' });
this.pageContainer = contentWrapper.createDiv({ cls: 'xhs-page-container' });
const paginationCard = this.createGridCard(board, 'xhs-area-pagination xhs-pagination');
const prevBtn = paginationCard.createEl('button', { text: '', cls: 'xhs-nav-btn' });
prevBtn.onclick = () => this.previousPage();
const indicator = paginationCard.createDiv({ cls: 'xhs-page-indicator' });
this.pageNumberInput = indicator.createEl('input', {
cls: 'xhs-page-number-input',
attr: { type: 'text', value: '1', inputmode: 'numeric', 'aria-label': '当前页码' }
}) as HTMLInputElement;
this.pageNumberInput.onfocus = () => this.pageNumberInput.select();
this.pageNumberInput.onkeydown = (evt: KeyboardEvent) => {
if (evt.key === 'Enter') {
evt.preventDefault();
this.handlePageNumberInput();
}
};
this.pageNumberInput.oninput = () => {
const sanitized = this.pageNumberInput.value.replace(/\D/g, '');
if (sanitized !== this.pageNumberInput.value) {
this.pageNumberInput.value = sanitized;
}
};
this.pageNumberInput.onblur = () => this.handlePageNumberInput();
this.pageTotalLabel = indicator.createEl('span', { cls: 'xhs-page-number-total', text: '/1' });
const nextBtn = paginationCard.createEl('button', { text: '', cls: 'xhs-nav-btn' });
nextBtn.onclick = () => this.nextPage();
const sliceCard = this.createGridCard(board, 'xhs-area-slice');
const sliceCurrentBtn = sliceCard.createEl('button', { text: '当前页切图', cls: 'xhs-slice-btn' });
sliceCurrentBtn.onclick = () => this.sliceCurrentPage();
const sliceAllBtn = sliceCard.createEl('button', { text: '全部页切图', cls: 'xhs-slice-btn secondary' });
sliceAllBtn.onclick = () => this.sliceAllPages();
}
private createGridCard(parent: HTMLElement, areaClass: string): HTMLDivElement {
return parent.createDiv({ cls: `xhs-card ${areaClass}` });
}
/**
* 渲染文章内容并分页
*/
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.width = `${this.settings.sliceImageWidth}px`;
tempContainer.classList.add('note-to-mp');
if (this.currentThemeClass) {
tempContainer.classList.add(this.currentThemeClass);
}
tempContainer.style.fontSize = `${this.currentFontSize}px`;
document.body.appendChild(tempContainer);
try {
// 在分页前先应用主题与高亮,确保测量使用正确样式
this.applyThemeCSS();
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();
// 重置滚动位置到顶部
this.pageContainer.scrollTop = 0;
// 创建包裹器,为缩放后的页面预留正确的布局空间
const wrapper = this.pageContainer.createDiv({ cls: 'xhs-page-wrapper' });
const classes = ['xhs-page'];
if (this.currentThemeClass) classes.push('note-to-mp');
const pageElement = wrapper.createDiv({ cls: classes.join(' ') });
renderPage(pageElement, page.content, this.settings);
this.applyPreviewSizing(wrapper, pageElement);
// 应用字体设置
this.applyFontSettings(pageElement);
// 更新页码显示
this.updatePageNumberDisplay();
}
private updatePageNumberDisplay(): void {
if (!this.pageNumberInput || !this.pageTotalLabel) return;
const total = this.pages.length;
if (total === 0) {
this.pageNumberInput.value = '0';
this.pageTotalLabel.innerText = '/0';
return;
}
const current = Math.min(this.currentPageIndex + 1, total);
this.pageNumberInput.value = String(current);
this.pageTotalLabel.innerText = `/${total}`;
}
private handlePageNumberInput(): void {
if (!this.pageNumberInput) return;
const total = this.pages.length;
if (total === 0) {
this.pageNumberInput.value = '0';
if (this.pageTotalLabel) this.pageTotalLabel.innerText = '/0';
return;
}
const raw = this.pageNumberInput.value.trim();
if (raw.length === 0) {
this.updatePageNumberDisplay();
return;
}
const parsed = parseInt(raw, 10);
if (!Number.isFinite(parsed)) {
this.updatePageNumberDisplay();
return;
}
const target = Math.min(Math.max(parsed, 1), total) - 1;
if (target !== this.currentPageIndex) {
this.currentPageIndex = target;
this.renderCurrentPage();
} else {
this.updatePageNumberDisplay();
}
}
/**
* 根据设置的宽度和横竖比应用预览尺寸与缩放
*/
private applyPreviewSizing(wrapper: HTMLElement, pageElement: HTMLElement): void {
const configuredWidth = this.settings.sliceImageWidth || 1080;
const actualWidth = Math.max(1, configuredWidth);
const ratio = this.parseAspectRatio(this.settings.sliceImageAspectRatio);
const actualHeight = Math.round((actualWidth * ratio.height) / ratio.width);
const previewWidthSetting = this.settings.xhsPreviewWidth || XHS_PREVIEW_DEFAULT_WIDTH;
const previewWidth = Math.max(1, previewWidthSetting);
const scale = Math.max(previewWidth / actualWidth, 0.01);
const previewHeight = Math.max(1, Math.round(actualHeight * scale));
wrapper.style.width = `${previewWidth}px`;
wrapper.style.height = `${previewHeight}px`;
pageElement.style.width = `${actualWidth}px`;
pageElement.style.height = `${actualHeight}px`;
pageElement.style.transform = `scale(${scale})`;
pageElement.style.transformOrigin = 'top left';
pageElement.style.position = 'absolute';
pageElement.style.top = '0';
pageElement.style.left = '0';
}
private async onPreviewWidthChanged(newWidth: number): Promise<void> {
if (newWidth <= 0) return;
if (this.settings.xhsPreviewWidth === newWidth) return;
this.settings.xhsPreviewWidth = newWidth;
await this.persistSettings();
this.renderCurrentPage();
}
/**
* 解析横竖比例字符串
*/
private parseAspectRatio(ratio: string | undefined): { width: number; height: number } {
const parts = (ratio ?? '').split(':').map(part => parseFloat(part.trim()));
if (parts.length === 2 && isFinite(parts[0]) && isFinite(parts[1]) && parts[0] > 0 && parts[1] > 0) {
return { width: parts[0], height: parts[1] };
}
return { width: 3, height: 4 };
}
/**
* 应用字体设置(仅字号,字体从主题读取)
*/
private applyFontSettings(element: HTMLElement): void {
element.style.fontSize = `${this.currentFontSize}px`;
}
/**
* 切换字号(± 按钮)
*/
private async changeFontSize(delta: number): Promise<void> {
this.currentFontSize = Math.max(XHS_FONT_SIZE_MIN, Math.min(XHS_FONT_SIZE_MAX, this.currentFontSize + delta));
this.fontSizeInput.value = String(this.currentFontSize);
await this.repaginateAndRender();
}
/**
* 字号输入框改变事件
*/
private async onFontSizeInputChanged(): Promise<void> {
const val = parseInt(this.fontSizeInput.value, 10);
if (isNaN(val) || val < XHS_FONT_SIZE_MIN || val > XHS_FONT_SIZE_MAX) {
this.fontSizeInput.value = String(this.currentFontSize);
new Notice(`字号范围: ${XHS_FONT_SIZE_MIN}-${XHS_FONT_SIZE_MAX}`);
return;
}
this.currentFontSize = val;
await this.repaginateAndRender();
}
/**
* 上一页
*/
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 instanceof Error ? error.message : String(error)));
}
}
/**
* 刷新按钮点击
*/
private async onRefresh(): Promise<void> {
if (this.onRefreshCallback) {
await this.onRefreshCallback();
}
}
/**
* 发布按钮点击
*/
private async onPublish(): Promise<void> {
if (this.onPublishCallback) {
await this.onPublishCallback();
}
}
async refresh(): Promise<void> {
await this.onRefresh();
}
async publish(): Promise<void> {
await this.onPublish();
}
/**
* 全部页切图
*/
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 instanceof Error ? error.message : String(error)));
}
}
private async persistSettings(): Promise<void> {
try {
const plugin = (this.app as any)?.plugins?.getPlugin?.('note2any');
if (plugin?.saveSettings) {
await plugin.saveSettings();
}
} catch (error) {
console.warn('[XiaohongshuPreview] 保存设置失败', error);
}
}
/**
* 显示小红书预览视图
*/
show(): void {
if (this.container) {
this.container.style.display = 'flex';
}
}
/**
* 隐藏小红书预览视图
*/
hide(): void {
if (this.container) {
this.container.style.display = 'none';
}
}
/**
* 清理资源
*/
destroy(): void {
this.templateSelect = null as any;
this.previewWidthSelect = null as any;
this.fontSizeInput = null as any;
this.pageContainer = null as any;
this.pageNumberInput = null as any;
this.pageTotalLabel = null as any;
this.pages = [];
this.currentFile = null;
this.styleEl = null;
}
/** 组合并注入主题 + 高亮 + 自定义 CSS使用全局默认主题 */
private applyThemeCSS() {
if (!this.styleEl) return;
const themeName = this.settings.defaultStyle;
const highlightName = this.settings.defaultHighlight;
const theme = this.assetsManager.getTheme(themeName);
const highlight = this.assetsManager.getHighlight(highlightName);
const customCSS = (this.settings.useCustomCss || this.settings.customCSSNote.length>0) ? this.assetsManager.customCSS : '';
const baseCSS = this.settings.baseCSS ? `.note-to-mp {${this.settings.baseCSS}}` : '';
const css = `${highlight?.css || ''}\n\n${theme?.css || ''}\n\n${baseCSS}\n\n${customCSS}`;
this.styleEl.textContent = css;
this.currentThemeClass = theme?.className || '';
}
private async repaginateAndRender(): Promise<void> {
if (!this.articleHTML) return;
const totalBefore = this.pages.length || 1;
const posRatio = (this.currentPageIndex + 0.5) / totalBefore; // 以当前页中心作为相对位置
//new Notice('重新分页中...');
const tempContainer = document.createElement('div');
tempContainer.innerHTML = this.articleHTML;
tempContainer.style.width = `${this.settings.sliceImageWidth}px`;
tempContainer.style.fontSize = `${this.currentFontSize}px`;
// 字体从全局主题中继承,无需手动指定
tempContainer.classList.add('note-to-mp');
tempContainer.className = this.currentThemeClass ? `note-to-mp ${this.currentThemeClass}` : 'note-to-mp';
document.body.appendChild(tempContainer);
try {
this.pages = await paginateArticle(tempContainer, this.settings);
if (this.pages.length > 0) {
const newIndex = Math.floor(posRatio * this.pages.length - 0.5);
this.currentPageIndex = Math.min(this.pages.length - 1, Math.max(0, newIndex));
} else {
this.currentPageIndex = 0;
}
this.renderCurrentPage();
//new Notice(`重新分页完成:共 ${this.pages.length} 页`);
} catch (e) {
console.error('重新分页失败', e);
new Notice('重新分页失败');
} finally {
document.body.removeChild(tempContainer);
}
}
}