/** * 文件: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; onPublishCallback?: () => Promise; onPlatformChangeCallback?: (platform: string) => Promise; 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 { 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 { 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 { 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 { 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 { 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 { if (this.onRefreshCallback) { await this.onRefreshCallback(); } } /** * 发布按钮点击 */ private async onPublish(): Promise { if (this.onPublishCallback) { await this.onPublishCallback(); } } async refresh(): Promise { await this.onRefresh(); } async publish(): Promise { await this.onPublish(); } /** * 全部页切图 */ private async sliceAllPages(): Promise { 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 { 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 { 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); } } }