update at 2025-10-08 12:53:49

This commit is contained in:
douboer
2025-10-08 12:53:49 +08:00
parent 584d4151fc
commit 719021bc67
25 changed files with 3844 additions and 95 deletions

View File

@@ -20,6 +20,7 @@ import { XiaohongshuContentAdapter } from './xiaohongshu/adapter';
import { XiaohongshuImageManager } from './xiaohongshu/image';
import { XiaohongshuAPIManager } from './xiaohongshu/api';
import { XiaohongshuPost } from './xiaohongshu/types';
import { XiaohongshuPreviewView } from './xiaohongshu/preview-view';
// 切图功能
import { sliceArticleImage } from './slice-image';
@@ -56,6 +57,7 @@ export class NotePreview extends ItemView {
markedParser: MarkedParser;
cachedElements: Map<string, string> = new Map();
_articleRender: ArticleRender | null = null;
_xiaohongshuPreview: XiaohongshuPreviewView | null = null;
isCancelUpload: boolean = false;
isBatchRuning: boolean = false;
@@ -196,11 +198,16 @@ export class NotePreview extends ItemView {
this.toolbar = parent.createDiv({ cls: 'preview-toolbar' });
let lineDiv;
// 平台选择器(新增)
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line' });
lineDiv.createDiv({ cls: 'style-label' }).innerText = '发布平台:';
// 平台选择器(新增)- 始终显示
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line platform-selector-line' });
lineDiv.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: linear-gradient(135deg, #fff3e0 0%, #ffffff 100%); border-left: 4px solid #1e88e5; border-radius: 6px; margin: 8px 10px;';
const platformLabel = lineDiv.createDiv({ cls: 'style-label' });
platformLabel.innerText = '发布平台';
platformLabel.style.cssText = 'font-size: 13px; color: #5f6368; font-weight: 500; white-space: nowrap;';
const platformSelect = lineDiv.createEl('select', { cls: 'style-select' });
platformSelect.setAttr('style', 'width: 200px');
platformSelect.style.cssText = 'padding: 6px 12px; border: 1px solid #dadce0; border-radius: 6px; background: white; font-size: 13px; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 1px 3px rgba(0,0,0,0.1); min-width: 150px; font-weight: 500;';
// 添加平台选项
const wechatOption = platformSelect.createEl('option');
@@ -221,10 +228,15 @@ export class NotePreview extends ItemView {
// 公众号
if (this.settings.wxInfo.length > 1 || Platform.isDesktop) {
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line' });
lineDiv.createDiv({ cls: 'style-label' }).innerText = '公众号:';
const wxSelect = lineDiv.createEl('select', { cls: 'style-select' })
wxSelect.setAttr('style', 'width: 200px');
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line wechat-only' });
lineDiv.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: white; border-radius: 6px; margin: 8px 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);';
const wxLabel = lineDiv.createDiv({ cls: 'style-label' });
wxLabel.innerText = '公众号';
wxLabel.style.cssText = 'font-size: 13px; color: #5f6368; font-weight: 500; white-space: nowrap;';
const wxSelect = lineDiv.createEl('select', { cls: 'style-select' });
wxSelect.style.cssText = 'padding: 6px 12px; border: 1px solid #dadce0; border-radius: 6px; background: white; font-size: 13px; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 1px 3px rgba(0,0,0,0.1); min-width: 200px;';
wxSelect.onchange = async () => {
this.currentAppId = wxSelect.value;
this.onAppIdChanged();
@@ -245,9 +257,14 @@ export class NotePreview extends ItemView {
this.wechatSelect = wxSelect;
if (Platform.isDesktop) {
const openBtn = lineDiv.createEl('button', { cls: 'refresh-button' }, async (button) => {
button.setText('去公众号后台');
})
// 分隔线
const separator = lineDiv.createDiv();
separator.style.cssText = 'width: 1px; height: 24px; background: #dadce0; margin: 0 4px;';
const openBtn = lineDiv.createEl('button', { text: '🌐 去公众号后台' });
openBtn.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);';
openBtn.onmouseenter = () => openBtn.style.transform = 'translateY(-1px)';
openBtn.onmouseleave = () => openBtn.style.transform = 'translateY(0)';
openBtn.onclick = async () => {
const { shell } = require('electron');
@@ -261,10 +278,13 @@ export class NotePreview extends ItemView {
}
// 复制,刷新,带图片复制,发草稿箱
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line' });
const refreshBtn = lineDiv.createEl('button', { cls: 'refresh-button' }, async (button) => {
button.setText('刷新');
})
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line wechat-only' });
lineDiv.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: white; border-radius: 6px; margin: 8px 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); flex-wrap: wrap;';
const refreshBtn = lineDiv.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 = async () => {
await this.assetsManager.loadCustomCSS();
@@ -274,9 +294,10 @@ export class NotePreview extends ItemView {
uevent('refresh');
}
if (Platform.isDesktop) {
const copyBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => {
button.setText('复制');
})
const copyBtn = lineDiv.createEl('button', { text: '📋 复制' });
copyBtn.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);';
copyBtn.onmouseenter = () => copyBtn.style.transform = 'translateY(-1px)';
copyBtn.onmouseleave = () => copyBtn.style.transform = 'translateY(0)';
copyBtn.onclick = async() => {
try {
@@ -290,27 +311,30 @@ export class NotePreview extends ItemView {
}
}
const uploadImgBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => {
button.setText('上传图片');
})
const uploadImgBtn = lineDiv.createEl('button', { text: '📤 上传图片' });
uploadImgBtn.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);';
uploadImgBtn.onmouseenter = () => uploadImgBtn.style.transform = 'translateY(-1px)';
uploadImgBtn.onmouseleave = () => uploadImgBtn.style.transform = 'translateY(0)';
uploadImgBtn.onclick = async() => {
await this.uploadImages();
uevent('upload');
}
const postBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => {
button.setText('发草稿');
})
const postBtn = lineDiv.createEl('button', { text: '📝 发草稿' });
postBtn.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);';
postBtn.onmouseenter = () => postBtn.style.transform = 'translateY(-1px)';
postBtn.onmouseleave = () => postBtn.style.transform = 'translateY(0)';
postBtn.onclick = async() => {
await this.postArticle();
uevent('pub');
}
const imagesBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => {
button.setText('图片/文字');
})
const imagesBtn = lineDiv.createEl('button', { text: '🖼️ 图片/文字' });
imagesBtn.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);';
imagesBtn.onmouseenter = () => imagesBtn.style.transform = 'translateY(-1px)';
imagesBtn.onmouseleave = () => imagesBtn.style.transform = 'translateY(0)';
imagesBtn.onclick = async() => {
await this.postImages();
@@ -318,9 +342,10 @@ export class NotePreview extends ItemView {
}
if (Platform.isDesktop && this.settings.isAuthKeyVaild()) {
const htmlBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => {
button.setText('导出HTML');
})
const htmlBtn = lineDiv.createEl('button', { text: '💾 导出HTML' });
htmlBtn.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);';
htmlBtn.onmouseenter = () => htmlBtn.style.transform = 'translateY(-1px)';
htmlBtn.onmouseleave = () => htmlBtn.style.transform = 'translateY(0)';
htmlBtn.onclick = async() => {
await this.exportHTML();
@@ -328,24 +353,13 @@ export class NotePreview extends ItemView {
}
}
// 切图按钮
if (Platform.isDesktop) {
const sliceBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => {
button.setText('切图');
})
sliceBtn.onclick = async() => {
await this.sliceArticleImage();
uevent('slice-image');
}
}
// 封面
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line' });
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line wechat-only' });
lineDiv.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: white; border-radius: 6px; margin: 8px 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);';
const coverTitle = lineDiv.createDiv({ cls: 'style-label' });
coverTitle.innerText = '封面:';
coverTitle.innerText = '封面';
coverTitle.style.cssText = 'font-size: 13px; color: #5f6368; font-weight: 500; white-space: nowrap;';
this.useDefaultCover = lineDiv.createEl('input', { cls: 'input-style' });
this.useDefaultCover.setAttr('type', 'radio');
@@ -364,6 +378,7 @@ export class NotePreview extends ItemView {
const defaultLable = lineDiv.createEl('label');
defaultLable.innerText = '默认';
defaultLable.setAttr('for', 'default-cover');
defaultLable.style.cssText = 'font-size: 13px; color: #5f6368; cursor: pointer; user-select: none;';
this.useLocalCover = lineDiv.createEl('input', { cls: 'input-style' });
this.useLocalCover.setAttr('type', 'radio');
@@ -383,6 +398,7 @@ export class NotePreview extends ItemView {
const localLabel = lineDiv.createEl('label');
localLabel.setAttr('for', 'local-cover');
localLabel.innerText = '上传';
localLabel.style.cssText = 'font-size: 13px; color: #5f6368; cursor: pointer; user-select: none;';
this.coverEl = lineDiv.createEl('input', { cls: 'upload-input' });
this.coverEl.setAttr('type', 'file');
@@ -393,13 +409,15 @@ export class NotePreview extends ItemView {
// 样式
if (this.settings.showStyleUI) {
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line' });
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line wechat-only' });
lineDiv.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: white; border-radius: 6px; margin: 8px 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); flex-wrap: wrap;';
const cssStyle = lineDiv.createDiv({ cls: 'style-label' });
cssStyle.innerText = '样式:';
cssStyle.innerText = '样式';
cssStyle.style.cssText = 'font-size: 13px; color: #5f6368; font-weight: 500; white-space: nowrap;';
const selectBtn = lineDiv.createEl('select', { cls: 'style-select' }, async (sel) => {
})
const selectBtn = lineDiv.createEl('select', { cls: 'style-select' });
selectBtn.style.cssText = 'padding: 6px 12px; border: 1px solid #dadce0; border-radius: 6px; background: white; font-size: 13px; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 1px 3px rgba(0,0,0,0.1); min-width: 120px;';
selectBtn.onchange = async () => {
this.currentTheme = selectBtn.value;
@@ -415,12 +433,16 @@ export class NotePreview extends ItemView {
this.themeSelect = selectBtn;
// 分隔线
const separator = lineDiv.createDiv();
separator.style.cssText = 'width: 1px; height: 24px; background: #dadce0; margin: 0 4px;';
const highlightStyle = lineDiv.createDiv({ cls: 'style-label' });
highlightStyle.innerText = '代码高亮:';
highlightStyle.innerText = '代码高亮';
highlightStyle.style.cssText = 'font-size: 13px; color: #5f6368; font-weight: 500; white-space: nowrap;';
const highlightStyleBtn = lineDiv.createEl('select', { cls: 'style-select' }, async (sel) => {
})
const highlightStyleBtn = lineDiv.createEl('select', { cls: 'style-select' });
highlightStyleBtn.style.cssText = 'padding: 6px 12px; border: 1px solid #dadce0; border-radius: 6px; background: white; font-size: 13px; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 1px 3px rgba(0,0,0,0.1); min-width: 120px;';
highlightStyleBtn.onchange = async () => {
this.currentHighlight = highlightStyleBtn.value;
@@ -519,6 +541,12 @@ export class NotePreview extends ItemView {
this.highlightSelect.value = this.currentHighlight;
}
}
// 如果当前是小红书平台,更新小红书预览
if (this.currentPlatform === 'xiaohongshu' && this._xiaohongshuPreview) {
this.articleHTML = this.render.articleHTML;
await this._xiaohongshuPreview.renderArticle(this.articleHTML, af);
}
}
/**
@@ -528,25 +556,92 @@ export class NotePreview extends ItemView {
async onPlatformChanged() {
console.log(`[NotePreview] 平台切换至: ${this.currentPlatform}`);
// 根据平台显示/隐藏相关控件
if (this.currentPlatform === 'wechat') {
// 显示微信公众号相关控件
if (this.wechatSelect) {
this.wechatSelect.style.display = 'block';
}
// 更新按钮文本为微信相关
this.updateButtonsForWechat();
} else if (this.currentPlatform === 'xiaohongshu') {
// 隐藏微信公众号选择器
if (this.wechatSelect) {
this.wechatSelect.style.display = 'none';
}
// 更新按钮文本为小红书相关
this.updateButtonsForXiaohongshu();
if (this.currentPlatform === 'xiaohongshu') {
// 切换到小红书预览模式
this.switchToXiaohongshuMode();
} else {
// 切换到微信公众号模式
this.switchToWechatMode();
}
}
/**
* 切换到小红书预览模式
*/
private switchToXiaohongshuMode() {
// 隐藏微信相关的工具栏行和平台选择器
if (this.toolbar) {
const wechatLines = this.toolbar.querySelectorAll('.wechat-only');
wechatLines.forEach((line: HTMLElement) => {
line.style.display = 'none';
});
// 也隐藏平台选择器行
// const platformLine = this.toolbar.querySelector('.platform-selector-line') as HTMLElement;
// if (platformLine) {
// platformLine.style.display = 'none';
// }
}
// 重新渲染内容以适应新平台
await this.renderMarkdown();
// 隐藏渲染区域
if (this.renderDiv) this.renderDiv.style.display = 'none';
// 创建或显示小红书预览视图
if (!this._xiaohongshuPreview) {
const xhsContainer = this.mainDiv.createDiv({ cls: 'xiaohongshu-preview-container' });
xhsContainer.style.cssText = 'width: 100%; height: 100%;';
this._xiaohongshuPreview = new XiaohongshuPreviewView(xhsContainer, this.app);
// 设置回调函数
this._xiaohongshuPreview.onRefreshCallback = async () => {
await this.onXiaohongshuRefresh();
};
this._xiaohongshuPreview.onPublishCallback = async () => {
await this.onXiaohongshuPublish();
};
this._xiaohongshuPreview.onPlatformChangeCallback = async (platform: string) => {
this.currentPlatform = platform;
if (platform === 'wechat') {
await this.onPlatformChanged();
}
};
this._xiaohongshuPreview.build();
} else {
const xhsContainer = this.mainDiv.querySelector('.xiaohongshu-preview-container') as HTMLElement;
if (xhsContainer) xhsContainer.style.display = 'flex';
}
// 如果有当前文件,渲染小红书预览
if (this.currentFile && this.articleHTML) {
this._xiaohongshuPreview.renderArticle(this.articleHTML, this.currentFile);
}
}
/**
* 切换到微信公众号模式
*/
private switchToWechatMode() {
// 显示微信相关的工具栏行和平台选择器
if (this.toolbar) {
const wechatLines = this.toolbar.querySelectorAll('.wechat-only');
wechatLines.forEach((line: HTMLElement) => {
line.style.display = 'flex';
});
// 也显示平台选择器行
const platformLine = this.toolbar.querySelector('.platform-selector-line') as HTMLElement;
if (platformLine) {
platformLine.style.display = 'flex';
}
}
// 显示渲染区域
if (this.renderDiv) this.renderDiv.style.display = 'block';
// 隐藏小红书预览视图
const xhsContainer = this.mainDiv.querySelector('.xiaohongshu-preview-container') as HTMLElement;
if (xhsContainer) xhsContainer.style.display = 'none';
}
/**
@@ -727,6 +822,27 @@ export class NotePreview extends ItemView {
this.showMsg('发布失败: ' + error.message);
}
}
/**
* 小红书预览的刷新回调
*/
async onXiaohongshuRefresh() {
await this.assetsManager.loadCustomCSS();
await this.assetsManager.loadExpertSettings();
// 更新小红书预览的样式
if (this._xiaohongshuPreview) {
this._xiaohongshuPreview.assetsManager = this.assetsManager;
}
await this.renderMarkdown();
new Notice('刷新成功');
}
/**
* 小红书预览的发布回调
*/
async onXiaohongshuPublish() {
await this.postToXiaohongshu();
}
async postImages() {
this.showLoading('发布图片中...');

View 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);
}

View 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
View 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);
}
}