551 lines
20 KiB
TypeScript
551 lines
20 KiB
TypeScript
/**
|
||
* 文件: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);
|
||
}
|
||
}
|
||
}
|