/** * 文件: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; themeSelect!: HTMLSelectElement; platformSelect: HTMLSelectElement | null = null; 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('note2any-platform-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 header = this.container.createDiv({ cls: 'note2any-platform-header' }); // 平台选择器区域 const platformSelector = header.createDiv({ cls: 'note2any-platform-selector' }); const platformLabel = platformSelector.createDiv({ cls: 'note2any-platform-label', text: '发布平台' }); // 使用真实的 select 元素,直接应用样式 this.platformSelect = platformSelector.createEl('select', { cls: 'note2any-select note2any-platform-select' }) as HTMLSelectElement; const wechatOption = this.platformSelect.createEl('option'); wechatOption.value = 'wechat'; wechatOption.text = '📱 公众号'; const xhsOption = this.platformSelect.createEl('option'); xhsOption.value = 'xiaohongshu'; xhsOption.text = '📔 小红书'; // 设置默认选中为小红书 this.platformSelect.value = 'xiaohongshu'; this.platformSelect.onchange = () => { if (this.platformSelect && this.platformSelect.value === 'wechat' && this.onPlatformChangeCallback) { this.onPlatformChangeCallback('wechat'); } }; // 按钮组 const buttonGroup = header.createDiv({ cls: 'note2any-button-group' }); const refreshBtn = buttonGroup.createEl('button', { text: '刷新', cls: 'note2any-button' }); refreshBtn.onclick = () => this.refresh(); const publishBtn = buttonGroup.createEl('button', { text: '发布', cls: 'note2any-button' }); publishBtn.onclick = () => this.publish(); const accessBtn = buttonGroup.createEl('button', { text: '访问', cls: 'note2any-button' }); accessBtn.onclick = () => { // TODO: 实现小红书访问逻辑 new Notice('小红书访问功能待实现'); }; // 控制栏 (账号、主题、宽度) const controlsRow = this.container.createDiv({ cls: 'note2any-controls-row' }); // 账号字段 const accountField = controlsRow.createDiv({ cls: 'note2any-field note2any-field-account' }); accountField.createDiv({ cls: 'note2any-field-label', text: '账号' }); const accountSelect = accountField.createEl('select', { cls: 'note2any-select' }); const accountOption = accountSelect.createEl('option'); accountOption.value = 'default'; accountOption.text = 'Value'; // 主题字段 const themeField = controlsRow.createDiv({ cls: 'note2any-field note2any-field-theme' }); themeField.createDiv({ cls: 'note2any-field-label', text: '主题' }); this.themeSelect = themeField.createEl('select', { cls: 'note2any-select' }) as HTMLSelectElement; this.themeSelect.onchange = async () => { this.settings.defaultStyle = this.themeSelect.value; this.applyThemeCSS(); await this.repaginateAndRender(); const plugin = (this.app as any)?.plugins?.getPlugin?.('note2any'); if (plugin?.saveSettings) { await plugin.saveSettings(); } }; for (let theme of this.assetsManager.themes) { const option = this.themeSelect.createEl('option'); option.value = theme.className; option.text = theme.name; option.selected = theme.className === this.settings.defaultStyle; } // 宽度字段 const widthField = controlsRow.createDiv({ cls: 'note2any-field note2any-field-width' }); widthField.createDiv({ cls: 'note2any-field-label', text: '宽度' }); const currentPreviewWidth = this.settings.xhsPreviewWidth || XHS_PREVIEW_DEFAULT_WIDTH; this.previewWidthSelect = widthField.createEl('select', { cls: 'note2any-select' }) as HTMLSelectElement; // 添加宽度选项 const widthOptions = [360, 540, 720]; for (let width of widthOptions) { const option = this.previewWidthSelect.createEl('option'); option.value = String(width); option.text = `${width}px`; option.selected = width === currentPreviewWidth; } this.previewWidthSelect.onchange = async () => { const newWidth = parseInt(this.previewWidthSelect.value); this.settings.xhsPreviewWidth = newWidth; await this.repaginateAndRender(); const plugin = (this.app as any)?.plugins?.getPlugin?.('note2any'); if (plugin?.saveSettings) { await plugin.saveSettings(); } }; // 内容区域 this.pageContainer = this.container.createDiv({ cls: 'note2any-content-area' }); // 底部工具栏 const bottomToolbar = this.container.createDiv({ cls: 'note2any-bottom-toolbar' }); // 当前图按钮 const sliceCurrentBtn = bottomToolbar.createEl('button', { text: '当前图', cls: 'note2any-slice-button' }); sliceCurrentBtn.onclick = () => this.sliceCurrentPage(); // 字体大小控制 const fontSizeControl = bottomToolbar.createDiv({ cls: 'note2any-fontsize-control' }); // 字体大小下拉选择器 const fontSizeSelectWrapper = fontSizeControl.createDiv({ cls: 'note2any-fontsize-select-wrapper' }); const fontSizeSelect = fontSizeSelectWrapper.createEl('select', { cls: 'note2any-fontsize-select' }) as HTMLSelectElement; // 添加字体大小选项 (30-40) for (let size = XHS_FONT_SIZE_MIN; size <= XHS_FONT_SIZE_MAX; size++) { const option = fontSizeSelect.createEl('option'); option.value = String(size); option.text = String(size); option.selected = size === XHS_FONT_SIZE_DEFAULT; } fontSizeSelect.onchange = async () => { this.currentFontSize = parseInt(fontSizeSelect.value); this.fontSizeInput.value = String(this.currentFontSize); await this.repaginateAndRender(); }; const stepper = fontSizeControl.createDiv({ cls: 'note2any-stepper' }); const decreaseBtn = stepper.createEl('button', { text: '−', cls: 'note2any-stepper-button' }); decreaseBtn.onclick = async () => { if (this.currentFontSize > XHS_FONT_SIZE_MIN) { this.currentFontSize--; fontSizeSelect.value = String(this.currentFontSize); this.fontSizeInput.value = String(this.currentFontSize); await this.repaginateAndRender(); } }; stepper.createDiv({ cls: 'note2any-stepper-separator' }); const increaseBtn = stepper.createEl('button', { text: '+', cls: 'note2any-stepper-button' }); increaseBtn.onclick = async () => { if (this.currentFontSize < XHS_FONT_SIZE_MAX) { this.currentFontSize++; fontSizeSelect.value = String(this.currentFontSize); this.fontSizeInput.value = String(this.currentFontSize); await this.repaginateAndRender(); } }; // 隐藏的字体输入框 (保留以兼容现有逻辑) this.fontSizeInput = fontSizeControl.createEl('input', { attr: { type: 'number', min: String(XHS_FONT_SIZE_MIN), max: String(XHS_FONT_SIZE_MAX), value: String(XHS_FONT_SIZE_DEFAULT), style: 'display: none;' } }); this.fontSizeInput.onchange = () => { this.currentFontSize = parseInt(this.fontSizeInput.value); fontSizeSelect.value = String(this.currentFontSize); }; // 分页控制 const pagination = bottomToolbar.createDiv({ cls: 'note2any-pagination' }); pagination.createDiv({ cls: 'note2any-pagination-separator' }); const prevBtn = pagination.createEl('button', { cls: 'note2any-pagination-button', attr: { 'aria-label': '上一页' } }); prevBtn.innerHTML = ''; prevBtn.onclick = () => this.previousPage(); const pageCurrent = pagination.createEl('input', { cls: 'note2any-pagination-current', type: 'text', value: '1', attr: { 'inputmode': 'numeric', 'pattern': '[0-9]*' } }); // 处理页码输入 - 回车跳转 pageCurrent.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { const targetPage = parseInt(pageCurrent.value); if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= this.pages.length) { this.currentPageIndex = targetPage - 1; this.renderCurrentPage(); } else { // 恢复当前页码 pageCurrent.value = String(this.currentPageIndex + 1); } pageCurrent.blur(); } }); // 失焦时跳转 pageCurrent.addEventListener('blur', () => { const targetPage = parseInt(pageCurrent.value); if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= this.pages.length) { this.currentPageIndex = targetPage - 1; this.renderCurrentPage(); } else { // 恢复当前页码 pageCurrent.value = String(this.currentPageIndex + 1); } }); // 聚焦时全选文本,方便输入 pageCurrent.addEventListener('focus', () => { pageCurrent.select(); }); // 存储引用以便在其他地方更新显示 (this as any).pageCurrentDisplay = pageCurrent; pagination.createDiv({ cls: 'note2any-pagination-separator-text', text: '/' }); this.pageTotalLabel = pagination.createEl('span', { cls: 'note2any-pagination-total', text: '68' }); const nextBtn = pagination.createEl('button', { cls: 'note2any-pagination-button', attr: { 'aria-label': '下一页' } }); nextBtn.innerHTML = ''; nextBtn.onclick = () => this.nextPage(); // 将可见的页码输入框设为主输入框 this.pageNumberInput = pageCurrent as HTMLInputElement; // 存储显示元素引用以便更新 (this as any).pageCurrentDisplay = pageCurrent; // 全部图按钮 const sliceAllBtn = bottomToolbar.createEl('button', { text: '全部图', cls: 'note2any-slice-button' }); sliceAllBtn.onclick = () => this.sliceAllPages(); // 模板选择器 (隐藏,保留以兼容) this.templateSelect = this.container.createEl('select', { attr: { style: 'display: none;' } }); ['默认模板', '简约模板', '杂志模板'].forEach(name => { const option = this.templateSelect.createEl('option'); option.value = name; option.text = name; }); } /** * 渲染文章内容并分页 */ 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('note2any'); 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('note2any'); 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; const pageCurrentDisplay = (this as any).pageCurrentDisplay; if (total === 0) { this.pageNumberInput.value = '0'; this.pageTotalLabel.innerText = '0'; if (pageCurrentDisplay) pageCurrentDisplay.textContent = '0'; return; } const current = Math.min(this.currentPageIndex + 1, total); this.pageNumberInput.value = String(current); this.pageTotalLabel.innerText = String(total); if (pageCurrentDisplay) pageCurrentDisplay.textContent = String(current); } 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 = 'center center'; pageElement.style.position = 'absolute'; pageElement.style.top = '50%'; pageElement.style.left = '50%'; pageElement.style.transform = `translate(-50%, -50%) scale(${scale})`; } 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'; } // 确保平台选择器显示正确的选项 if (this.platformSelect) { this.platformSelect.value = 'xiaohongshu'; } } /** * 隐藏小红书预览视图 */ 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 ? `.note2any {${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('note2any'); tempContainer.className = this.currentThemeClass ? `note2any ${this.currentThemeClass}` : 'note2any'; 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); } } /** * 更新主题和高亮选择器的值 */ updateStyleAndHighlight(theme: string, highlight: string): void { if (this.themeSelect) { this.themeSelect.value = theme; } // 高亮已移除,保留此方法以兼容外部调用 this.applyThemeCSS(); } }