update at 2025-10-09 16:23:27
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 41 KiB |
11
release.md
@@ -1,5 +1,7 @@
|
||||
|
||||
# 版本信息(发布版本时填写,供脚本使用)
|
||||
# 版本信息
|
||||
‼️ 发布版本时填写,供脚本~/pubsh/release.sh使用
|
||||
|
||||
## v1.3.4
|
||||
### 重构
|
||||
#### 新的架构图
|
||||
@@ -59,11 +61,4 @@
|
||||
|
||||
现在分页依据真实渲染高度,预览窗口内不会再丢失底部内容。建议在小红书预览里多翻几页、调整字号后重新分页验证结果。
|
||||
|
||||
## v1.3.9
|
||||
重新实现分页测量,清理多余日志。
|
||||
- 重新实现分页测量:构建隐藏的“测量页面”,与真实页面同样的宽度、内边距(40px)和 class,逐个把克隆元素追加进去,利用 scrollHeight 决定是否换页,保证 margin 折叠后计算准确 (src/xiaohongshu/paginator.ts:57;waitForLayout 新增)。
|
||||
- 当元素放不下当前页时,移除测量克隆并把已排内容写入分页,再以该元素开启新页;不可分割元素允许独占一页即便超高 (src/xiaohongshu/paginator.ts:101)。
|
||||
- 清理多余日志,同时共用 PAGE_PADDING 常量让 renderPage 和测量逻辑保持一致 (src/xiaohongshu/paginator.ts:182)。
|
||||
|
||||
现在分页依据真实渲染高度,预览窗口内不会再丢失底部内容。建议在小红书预览里多翻几页、调整字号后重新分页验证结果。
|
||||
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
#!/bin/bash
|
||||
# create_milestone.sh - 自动创建项目里程碑版本
|
||||
# 使用方法: ./create_milestone.sh v1.3.0 "里程碑版本描述"
|
||||
|
||||
set -e # 遇到错误立即退出
|
||||
|
||||
VERSION=$1
|
||||
DESCRIPTION=${2:-"里程碑版本"}
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "❌ 错误: 请提供版本号"
|
||||
echo "使用方法: $0 <version> [description]"
|
||||
echo "示例: $0 v1.3.0 '批量发布系统完成'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 版本号格式验证
|
||||
if [[ ! $VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "❌ 版本号格式错误,应为 vX.X.X 格式 (如: v1.3.0)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查版本是否已存在
|
||||
if git tag | grep -q "^$VERSION$"; then
|
||||
echo "❌ 版本 $VERSION 已存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🚀 开始创建里程碑版本: $VERSION"
|
||||
|
||||
# 1. 检查工作目录状态
|
||||
echo "📋 检查Git状态..."
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "⚠️ 发现未提交的更改,正在自动提交..."
|
||||
git add .
|
||||
git commit -m "feat: $DESCRIPTION
|
||||
|
||||
版本: $VERSION (里程碑版本)"
|
||||
fi
|
||||
|
||||
# 2. 创建Git标签
|
||||
echo "🏷️ 创建Git标签..."
|
||||
git tag -a "$VERSION" -m "$DESCRIPTION
|
||||
|
||||
此版本为稳定的里程碑版本,用于后续大规模修改前的对比和回滚基准。
|
||||
|
||||
创建时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
Git提交: $(git rev-parse HEAD)"
|
||||
|
||||
# 3. 创建发布分支
|
||||
echo "🌿 创建发布分支..."
|
||||
git checkout -b "release/$VERSION"
|
||||
|
||||
# 4. 构建项目
|
||||
echo "🔨 构建项目..."
|
||||
if [ -f package.json ]; then
|
||||
npm run build
|
||||
else
|
||||
echo "⚠️ 未找到package.json,跳过构建步骤"
|
||||
fi
|
||||
|
||||
# 5. 创建归档目录
|
||||
echo "📁 创建归档目录..."
|
||||
mkdir -p "archives/$VERSION"
|
||||
|
||||
# 6. 复制关键文件
|
||||
echo "📋 复制构建文件..."
|
||||
for file in main.js manifest.json styles.css package.json; do
|
||||
if [ -f "$file" ]; then
|
||||
cp "$file" "archives/$VERSION/"
|
||||
echo " ✅ 复制 $file"
|
||||
else
|
||||
echo " ⚠️ $file 不存在,跳过"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "📄 复制文档文件..."
|
||||
for file in README.md CHANGELOG.md detaildesign.md diagrams.md; do
|
||||
if [ -f "$file" ]; then
|
||||
cp "$file" "archives/$VERSION/"
|
||||
echo " ✅ 复制 $file"
|
||||
else
|
||||
echo " ⚠️ $file 不存在,跳过"
|
||||
fi
|
||||
done
|
||||
|
||||
# 7. 创建源码快照
|
||||
echo "📦 创建源码快照..."
|
||||
PROJECT_NAME=$(basename "$(pwd)")
|
||||
cd .. && tar -czf "$PROJECT_NAME/archives/$VERSION/source-snapshot-$VERSION.tar.gz" \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.git' \
|
||||
--exclude='archives' \
|
||||
"$PROJECT_NAME/"
|
||||
cd "$PROJECT_NAME"
|
||||
|
||||
# 8. 创建版本信息文档
|
||||
echo "📋 创建版本信息文档..."
|
||||
cat > "archives/$VERSION/VERSION_INFO.md" << EOF
|
||||
# 里程碑版本 $VERSION
|
||||
|
||||
## 版本信息
|
||||
- **版本号**: $VERSION
|
||||
- **发布日期**: $(date +%Y年%m月%d日)
|
||||
- **Git Tag**: $VERSION
|
||||
- **Git Branch**: release/$VERSION
|
||||
- **Git Commit**: $(git rev-parse HEAD)
|
||||
- **描述**: $DESCRIPTION
|
||||
|
||||
## 主要内容
|
||||
$(if [ -f "archives/$VERSION/main.js" ]; then echo "- 构建文件: main.js ($(du -h "archives/$VERSION/main.js" | cut -f1))"; fi)
|
||||
$(if [ -f "archives/$VERSION/manifest.json" ]; then echo "- 插件清单: manifest.json"; fi)
|
||||
$(if [ -f "archives/$VERSION/styles.css" ]; then echo "- 样式文件: styles.css"; fi)
|
||||
$(if [ -f "archives/$VERSION/README.md" ]; then echo "- 项目文档: README.md"; fi)
|
||||
$(if [ -f "archives/$VERSION/CHANGELOG.md" ]; then echo "- 变更日志: CHANGELOG.md"; fi)
|
||||
$(if [ -f "archives/$VERSION/detaildesign.md" ]; then echo "- 设计文档: detaildesign.md"; fi)
|
||||
$(if [ -f "archives/$VERSION/diagrams.md" ]; then echo "- 架构图表: diagrams.md"; fi)
|
||||
- 源码快照: source-snapshot-$VERSION.tar.gz ($(du -h "archives/$VERSION/source-snapshot-$VERSION.tar.gz" | cut -f1))
|
||||
|
||||
## 回滚说明
|
||||
如需回滚到此版本:
|
||||
|
||||
### 方法1: 使用Git Tag回滚
|
||||
\`\`\`bash
|
||||
git checkout $VERSION
|
||||
git checkout -b rollback-to-$VERSION
|
||||
\`\`\`
|
||||
|
||||
### 方法2: 使用发布分支
|
||||
\`\`\`bash
|
||||
git checkout release/$VERSION
|
||||
\`\`\`
|
||||
|
||||
### 方法3: 使用源代码快照
|
||||
\`\`\`bash
|
||||
tar -xzf archives/$VERSION/source-snapshot-$VERSION.tar.gz
|
||||
\`\`\`
|
||||
|
||||
### 方法4: 使用构建文件
|
||||
\`\`\`bash
|
||||
$(if [ -f "archives/$VERSION/main.js" ]; then echo "cp archives/$VERSION/main.js ./"; fi)
|
||||
$(if [ -f "archives/$VERSION/manifest.json" ]; then echo "cp archives/$VERSION/manifest.json ./"; fi)
|
||||
$(if [ -f "archives/$VERSION/styles.css" ]; then echo "cp archives/$VERSION/styles.css ./"; fi)
|
||||
\`\`\`
|
||||
|
||||
## 版本对比
|
||||
此版本可作为后续重大修改的对比基准,主要用于:
|
||||
- 功能回归测试
|
||||
- 性能对比分析
|
||||
- 代码架构变更评估
|
||||
- 稳定性基准对比
|
||||
|
||||
---
|
||||
*此版本为稳定的里程碑版本,建议在进行大规模代码修改前保存此状态。*
|
||||
*创建脚本: scripts/create_milestone.sh*
|
||||
EOF
|
||||
|
||||
# 9. 切换回主分支
|
||||
echo "🔄 切换回主分支..."
|
||||
git checkout main
|
||||
|
||||
# 10. 推送到远程
|
||||
echo "☁️ 推送到远程仓库..."
|
||||
if git remote | grep -q origin; then
|
||||
echo " 推送主分支和标签..."
|
||||
git push origin main --tags
|
||||
echo " 推送发布分支..."
|
||||
git push origin "release/$VERSION"
|
||||
echo "✅ 已推送到远程仓库"
|
||||
else
|
||||
echo "⚠️ 无远程仓库配置,跳过推送"
|
||||
fi
|
||||
|
||||
# 11. 验证创建结果
|
||||
echo ""
|
||||
echo "🔍 验证里程碑创建结果..."
|
||||
echo "📁 归档目录内容:"
|
||||
ls -la "archives/$VERSION/" | while read line; do echo " $line"; done
|
||||
|
||||
echo ""
|
||||
echo "🎯 里程碑版本 $VERSION 创建完成!"
|
||||
echo ""
|
||||
echo "📋 创建内容:"
|
||||
echo " - Git标签: $VERSION"
|
||||
echo " - 发布分支: release/$VERSION"
|
||||
echo " - 归档目录: archives/$VERSION/"
|
||||
echo " - 源码快照: source-snapshot-$VERSION.tar.gz"
|
||||
echo " - 版本文档: VERSION_INFO.md"
|
||||
echo ""
|
||||
echo "🔄 快速回滚命令:"
|
||||
echo " git checkout $VERSION # 使用标签"
|
||||
echo " git checkout release/$VERSION # 使用分支"
|
||||
echo " tar -xzf archives/$VERSION/source-snapshot-$VERSION.tar.gz # 使用快照"
|
||||
echo ""
|
||||
echo "📖 详细信息请查看: archives/$VERSION/VERSION_INFO.md"
|
||||
@@ -1,895 +0,0 @@
|
||||
/**
|
||||
* 文件:note-preview.ts
|
||||
* 功能:侧边预览视图;支持多平台预览(公众号/小红书)与发布触发。
|
||||
* - 渲染 Markdown
|
||||
* - 平台切换下拉
|
||||
* - 单篇发布入口
|
||||
* - 与批量发布/图片处理集成预留
|
||||
*/
|
||||
|
||||
import { EventRef, ItemView, Workspace, WorkspaceLeaf, Notice, Platform, TFile, TFolder, TAbstractFile, Plugin } from 'obsidian';
|
||||
import { uevent, debounce, waitForLayoutReady } from './utils';
|
||||
import { NMPSettings } from './settings';
|
||||
import AssetsManager from './assets';
|
||||
import { MarkedParser } from './markdown/parser';
|
||||
import { LocalImageManager, LocalFile } from './markdown/local-file';
|
||||
import { CardDataManager } from './markdown/code';
|
||||
import { ArticleRender } from './article-render';
|
||||
// 平台选择组件
|
||||
import { PlatformChooser, PlatformType } from './platform-chooser';
|
||||
// 微信公众号功能模块
|
||||
import { WechatPreview } from './wechat/wechat-preview';
|
||||
// 小红书功能模块
|
||||
import { XiaohongshuContentAdapter } from './xiaohongshu/adapter';
|
||||
import { XiaohongshuImageManager } from './xiaohongshu/image';
|
||||
import { XiaohongshuAPIManager } from './xiaohongshu/api';
|
||||
import { XiaohongshuPost } from './xiaohongshu/types';
|
||||
import { XiaohongshuPreview } from './xiaohongshu/xhs-preview';
|
||||
// 切图功能
|
||||
import { sliceArticleImage } from './slice-image';
|
||||
|
||||
|
||||
export const VIEW_TYPE_NOTE_PREVIEW = 'note-preview';
|
||||
|
||||
export class NotePreview extends ItemView {
|
||||
workspace: Workspace;
|
||||
plugin: Plugin;
|
||||
mainDiv: HTMLDivElement;
|
||||
toolbar: HTMLDivElement;
|
||||
renderDiv: HTMLDivElement;
|
||||
articleDiv: HTMLDivElement;
|
||||
styleEl: HTMLElement;
|
||||
coverEl: HTMLInputElement;
|
||||
useDefaultCover: HTMLInputElement;
|
||||
useLocalCover: HTMLInputElement;
|
||||
msgView: HTMLDivElement;
|
||||
wechatSelect: HTMLSelectElement;
|
||||
platformSelect: HTMLSelectElement; // 新增:平台选择器
|
||||
themeSelect: HTMLSelectElement;
|
||||
highlightSelect: HTMLSelectElement;
|
||||
listeners?: EventRef[];
|
||||
container: Element;
|
||||
settings: NMPSettings;
|
||||
assetsManager: AssetsManager;
|
||||
articleHTML: string;
|
||||
title: string;
|
||||
currentFile?: TFile;
|
||||
currentTheme: string;
|
||||
currentHighlight: string;
|
||||
currentAppId: string;
|
||||
currentPlatform: string = 'wechat'; // 新增:当前选择的平台,默认微信
|
||||
markedParser: MarkedParser;
|
||||
cachedElements: Map<string, string> = new Map();
|
||||
_articleRender: ArticleRender | null = null;
|
||||
_xiaohongshuPreview: XiaohongshuPreview | null = null;
|
||||
_wechatPreview: WechatPreview | null = null;
|
||||
_platformChooser: PlatformChooser | null = null;
|
||||
isCancelUpload: boolean = false;
|
||||
isBatchRuning: boolean = false;
|
||||
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: Plugin) {
|
||||
super(leaf);
|
||||
this.workspace = this.app.workspace;
|
||||
this.plugin = plugin;
|
||||
this.settings = NMPSettings.getInstance();
|
||||
this.assetsManager = AssetsManager.getInstance();
|
||||
this.currentTheme = this.settings.defaultStyle;
|
||||
this.currentHighlight = this.settings.defaultHighlight;
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_NOTE_PREVIEW;
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return 'clipboard-paste';
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return '笔记预览';
|
||||
}
|
||||
|
||||
get render() {
|
||||
if (!this._articleRender) {
|
||||
this._articleRender = new ArticleRender(this.app, this, this.styleEl, this.articleDiv);
|
||||
this._articleRender.currentTheme = this.currentTheme;
|
||||
this._articleRender.currentHighlight = this.currentHighlight;
|
||||
}
|
||||
return this._articleRender;
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
this.viewLoading();
|
||||
this.setup();
|
||||
uevent('open');
|
||||
}
|
||||
|
||||
async setup() {
|
||||
await waitForLayoutReady(this.app);
|
||||
|
||||
if (!this.settings.isLoaded) {
|
||||
const data = await this.plugin.loadData();
|
||||
NMPSettings.loadSettings(data);
|
||||
}
|
||||
if (!this.assetsManager.isLoaded) {
|
||||
await this.assetsManager.loadAssets();
|
||||
}
|
||||
|
||||
this.buildUI();
|
||||
this.listeners = [
|
||||
this.workspace.on('file-open', () => {
|
||||
this.update();
|
||||
}),
|
||||
this.app.vault.on("modify", (file) => {
|
||||
if (this.currentFile?.path == file.path) {
|
||||
this.renderMarkdown();
|
||||
}
|
||||
} )
|
||||
];
|
||||
|
||||
this.renderMarkdown();
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
this.listeners?.forEach(listener => this.workspace.offref(listener));
|
||||
LocalFile.fileCache.clear();
|
||||
uevent('close');
|
||||
}
|
||||
|
||||
onAppIdChanged() {
|
||||
// 清理上传过的图片
|
||||
this.cleanArticleData();
|
||||
}
|
||||
|
||||
async update() {
|
||||
if (this.isBatchRuning) {
|
||||
return;
|
||||
}
|
||||
this.cleanArticleData();
|
||||
this.renderMarkdown();
|
||||
}
|
||||
|
||||
cleanArticleData() {
|
||||
LocalImageManager.getInstance().cleanup();
|
||||
CardDataManager.getInstance().cleanup();
|
||||
}
|
||||
|
||||
buildMsgView(parent: HTMLDivElement) {
|
||||
this.msgView = parent.createDiv({ cls: 'msg-view' });
|
||||
const title = this.msgView.createDiv({ cls: 'msg-title' });
|
||||
title.id = 'msg-title';
|
||||
title.innerText = '加载中...';
|
||||
const okBtn = this.msgView.createEl('button', { cls: 'msg-ok-btn' }, async (button) => {
|
||||
|
||||
});
|
||||
okBtn.id = 'msg-ok-btn';
|
||||
okBtn.innerText = '确定';
|
||||
okBtn.onclick = async () => {
|
||||
this.msgView.setAttr('style', 'display: none;');
|
||||
}
|
||||
const cancelBtn = this.msgView.createEl('button', { cls: 'msg-ok-btn' }, async (button) => {
|
||||
});
|
||||
cancelBtn.id = 'msg-cancel-btn';
|
||||
cancelBtn.innerText = '取消';
|
||||
cancelBtn.onclick = async () => {
|
||||
this.isCancelUpload = true;
|
||||
this.msgView.setAttr('style', 'display: none;');
|
||||
}
|
||||
}
|
||||
|
||||
showLoading(msg: string, cancelable: boolean = false) {
|
||||
const title = this.msgView.querySelector('#msg-title') as HTMLElement;
|
||||
title!.innerText = msg;
|
||||
const btn = this.msgView.querySelector('#msg-ok-btn') as HTMLElement;
|
||||
btn.setAttr('style', 'display: none;');
|
||||
this.msgView.setAttr('style', 'display: flex;');
|
||||
const cancelBtn = this.msgView.querySelector('#msg-cancel-btn') as HTMLElement;
|
||||
cancelBtn.setAttr('style', cancelable ? 'display: block;': 'display: none;');
|
||||
this.msgView.setAttr('style', 'display: flex;');
|
||||
}
|
||||
|
||||
showMsg(msg: string) {
|
||||
const title = this.msgView.querySelector('#msg-title') as HTMLElement;
|
||||
title!.innerText = msg;
|
||||
const btn = this.msgView.querySelector('#msg-ok-btn') as HTMLElement;
|
||||
btn.setAttr('style', 'display: block;');
|
||||
this.msgView.setAttr('style', 'display: flex;');
|
||||
const cancelBtn = this.msgView.querySelector('#msg-cancel-btn') as HTMLElement;
|
||||
cancelBtn.setAttr('style', 'display: none;');
|
||||
this.msgView.setAttr('style', 'display: flex;');
|
||||
}
|
||||
|
||||
buildToolbar(parent: HTMLDivElement) {
|
||||
this.toolbar = parent.createDiv({ cls: 'preview-toolbar' });
|
||||
let lineDiv;
|
||||
|
||||
// 平台选择器(新增)- 始终显示
|
||||
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line platform-selector-line' });
|
||||
|
||||
const platformLabel = lineDiv.createDiv({ cls: 'style-label' });
|
||||
platformLabel.innerText = '发布平台';
|
||||
|
||||
const platformSelect = lineDiv.createEl('select', { cls: 'platform-select' });
|
||||
|
||||
// 添加平台选项
|
||||
const wechatOption = platformSelect.createEl('option');
|
||||
wechatOption.value = 'wechat';
|
||||
wechatOption.text = '微信公众号';
|
||||
wechatOption.selected = true;
|
||||
|
||||
const xiaohongshuOption = platformSelect.createEl('option');
|
||||
xiaohongshuOption.value = 'xiaohongshu';
|
||||
xiaohongshuOption.text = '小红书';
|
||||
|
||||
platformSelect.onchange = async () => {
|
||||
this.currentPlatform = platformSelect.value;
|
||||
await this.onPlatformChanged();
|
||||
};
|
||||
|
||||
this.platformSelect = platformSelect;
|
||||
|
||||
// 公众号
|
||||
if (this.settings.wxInfo.length > 1 || Platform.isDesktop) {
|
||||
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line wechat-only' });
|
||||
|
||||
const wxLabel = lineDiv.createDiv({ cls: 'style-label' });
|
||||
wxLabel.innerText = '公众号';
|
||||
|
||||
const wxSelect = lineDiv.createEl('select', { cls: 'wechat-select' });
|
||||
wxSelect.onchange = async () => {
|
||||
this.currentAppId = wxSelect.value;
|
||||
this.onAppIdChanged();
|
||||
}
|
||||
const defautlOp =wxSelect.createEl('option');
|
||||
defautlOp.value = '';
|
||||
defautlOp.text = '请在设置里配置公众号';
|
||||
for (let i = 0; i < this.settings.wxInfo.length; i++) {
|
||||
const op = wxSelect.createEl('option');
|
||||
const wx = this.settings.wxInfo[i];
|
||||
op.value = wx.appid;
|
||||
op.text = wx.name;
|
||||
if (i== 0) {
|
||||
op.selected = true
|
||||
this.currentAppId = wx.appid;
|
||||
}
|
||||
}
|
||||
this.wechatSelect = wxSelect;
|
||||
|
||||
if (Platform.isDesktop) {
|
||||
// 分隔线
|
||||
const separator = lineDiv.createDiv({ cls: 'toolbar-separator' });
|
||||
|
||||
const openBtn = lineDiv.createEl('button', { text: '🌐 去公众号后台', cls: 'toolbar-button purple-gradient' });
|
||||
|
||||
openBtn.onclick = async () => {
|
||||
const { shell } = require('electron');
|
||||
shell.openExternal('https://mp.weixin.qq.com')
|
||||
uevent('open-mp');
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (this.settings.wxInfo.length > 0) {
|
||||
this.currentAppId = this.settings.wxInfo[0].appid;
|
||||
}
|
||||
|
||||
// 复制,刷新,带图片复制,发草稿箱
|
||||
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line wechat-only flex-wrap' });
|
||||
|
||||
const refreshBtn = lineDiv.createEl('button', { text: '🔄 刷新', cls: 'toolbar-button purple-gradient' });
|
||||
|
||||
refreshBtn.onclick = async () => {
|
||||
await this.assetsManager.loadCustomCSS();
|
||||
await this.assetsManager.loadExpertSettings();
|
||||
this.render.reloadStyle();
|
||||
await this.renderMarkdown();
|
||||
uevent('refresh');
|
||||
}
|
||||
if (Platform.isDesktop) {
|
||||
const copyBtn = lineDiv.createEl('button', { text: '📋 复制', cls: 'toolbar-button' });
|
||||
|
||||
copyBtn.onclick = async() => {
|
||||
try {
|
||||
await this.render.copyArticle();
|
||||
new Notice('复制成功,请到公众号编辑器粘贴。');
|
||||
uevent('copy');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
new Notice('复制失败: ' + error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uploadImgBtn = lineDiv.createEl('button', { text: '📤 上传图片', cls: 'toolbar-button' });
|
||||
|
||||
uploadImgBtn.onclick = async() => {
|
||||
await this.uploadImages();
|
||||
uevent('upload');
|
||||
}
|
||||
|
||||
const postBtn = lineDiv.createEl('button', { text: '📝 发草稿', cls: 'toolbar-button' });
|
||||
|
||||
postBtn.onclick = async() => {
|
||||
await this.postArticle();
|
||||
uevent('pub');
|
||||
}
|
||||
|
||||
const imagesBtn = lineDiv.createEl('button', { text: '🖼️ 图片/文字', cls: 'toolbar-button' });
|
||||
|
||||
imagesBtn.onclick = async() => {
|
||||
await this.postImages();
|
||||
uevent('pub-images');
|
||||
}
|
||||
|
||||
if (Platform.isDesktop && this.settings.isAuthKeyVaild()) {
|
||||
const htmlBtn = lineDiv.createEl('button', { text: '💾 导出HTML', cls: 'toolbar-button' });
|
||||
|
||||
htmlBtn.onclick = async() => {
|
||||
await this.exportHTML();
|
||||
uevent('export-html');
|
||||
}
|
||||
}
|
||||
|
||||
// 封面
|
||||
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line wechat-only' });
|
||||
|
||||
const coverTitle = lineDiv.createDiv({ cls: 'style-label' });
|
||||
coverTitle.innerText = '封面';
|
||||
|
||||
this.useDefaultCover = lineDiv.createEl('input', { cls: 'input-style' });
|
||||
this.useDefaultCover.setAttr('type', 'radio');
|
||||
this.useDefaultCover.setAttr('name', 'cover');
|
||||
this.useDefaultCover.setAttr('value', 'default');
|
||||
this.useDefaultCover.setAttr('checked', true);
|
||||
this.useDefaultCover.id = 'default-cover';
|
||||
this.useDefaultCover.onchange = async () => {
|
||||
if (this.useDefaultCover.checked) {
|
||||
this.coverEl.setAttr('style', 'visibility:hidden;width:0px;');
|
||||
}
|
||||
else {
|
||||
this.coverEl.setAttr('style', 'visibility:visible;width:180px;');
|
||||
}
|
||||
}
|
||||
const defaultLable = lineDiv.createEl('label');
|
||||
defaultLable.innerText = '默认';
|
||||
defaultLable.setAttr('for', 'default-cover');
|
||||
|
||||
this.useLocalCover = lineDiv.createEl('input', { cls: 'input-style' });
|
||||
this.useLocalCover.setAttr('type', 'radio');
|
||||
this.useLocalCover.setAttr('name', 'cover');
|
||||
this.useLocalCover.setAttr('value', 'local');
|
||||
this.useLocalCover.id = 'local-cover';
|
||||
this.useLocalCover.setAttr('style', 'margin-left:20px;');
|
||||
this.useLocalCover.onchange = async () => {
|
||||
if (this.useLocalCover.checked) {
|
||||
this.coverEl.setAttr('style', 'visibility:visible;width:180px;');
|
||||
}
|
||||
else {
|
||||
this.coverEl.setAttr('style', 'visibility:hidden;width:0px;');
|
||||
}
|
||||
}
|
||||
|
||||
const localLabel = lineDiv.createEl('label');
|
||||
localLabel.setAttr('for', 'local-cover');
|
||||
localLabel.innerText = '上传';
|
||||
|
||||
this.coverEl = lineDiv.createEl('input', { cls: 'upload-input' });
|
||||
this.coverEl.setAttr('type', 'file');
|
||||
this.coverEl.setAttr('placeholder', '封面图片');
|
||||
this.coverEl.setAttr('accept', '.png, .jpg, .jpeg');
|
||||
this.coverEl.setAttr('name', 'cover');
|
||||
this.coverEl.id = 'cover-input';
|
||||
|
||||
// 样式
|
||||
if (this.settings.showStyleUI) {
|
||||
lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line wechat-only flex-wrap' });
|
||||
|
||||
const cssStyle = lineDiv.createDiv({ cls: 'style-label' });
|
||||
cssStyle.innerText = '样式';
|
||||
|
||||
const selectBtn = lineDiv.createEl('select', { cls: 'style-select' });
|
||||
|
||||
selectBtn.onchange = async () => {
|
||||
this.currentTheme = selectBtn.value;
|
||||
this.render.updateStyle(selectBtn.value);
|
||||
}
|
||||
|
||||
for (let s of this.assetsManager.themes) {
|
||||
const op = selectBtn.createEl('option');
|
||||
op.value = s.className;
|
||||
op.text = s.name;
|
||||
op.selected = s.className == this.settings.defaultStyle;
|
||||
}
|
||||
|
||||
this.themeSelect = selectBtn;
|
||||
|
||||
// 分隔线
|
||||
const separator = lineDiv.createDiv({ cls: 'toolbar-separator' });
|
||||
|
||||
const highlightStyle = lineDiv.createDiv({ cls: 'style-label' });
|
||||
highlightStyle.innerText = '代码高亮';
|
||||
|
||||
const highlightStyleBtn = lineDiv.createEl('select', { cls: 'style-select' });
|
||||
|
||||
highlightStyleBtn.onchange = async () => {
|
||||
this.currentHighlight = highlightStyleBtn.value;
|
||||
this.render.updateHighLight(highlightStyleBtn.value);
|
||||
}
|
||||
|
||||
for (let s of this.assetsManager.highlights) {
|
||||
const op = highlightStyleBtn.createEl('option');
|
||||
op.value = s.name;
|
||||
op.text = s.name;
|
||||
op.selected = s.name == this.settings.defaultHighlight;
|
||||
}
|
||||
|
||||
this.highlightSelect = highlightStyleBtn;
|
||||
}
|
||||
|
||||
this.buildMsgView(this.toolbar);
|
||||
}
|
||||
|
||||
async buildUI() {
|
||||
this.container = this.containerEl.children[1];
|
||||
this.container.empty();
|
||||
|
||||
this.mainDiv = this.container.createDiv({ cls: 'note-preview' });
|
||||
|
||||
this.buildToolbar(this.mainDiv);
|
||||
|
||||
this.renderDiv = this.mainDiv.createDiv({cls: 'render-div'});
|
||||
this.renderDiv.id = 'render-div';
|
||||
this.styleEl = this.renderDiv.createEl('style');
|
||||
this.styleEl.setAttr('title', 'note-to-mp-style');
|
||||
this.articleDiv = this.renderDiv.createEl('div');
|
||||
}
|
||||
|
||||
async viewLoading() {
|
||||
const container = this.containerEl.children[1]
|
||||
container.empty();
|
||||
const loading = container.createDiv({cls: 'loading-wrapper'})
|
||||
loading.createDiv({cls: 'loading-spinner'})
|
||||
}
|
||||
|
||||
async renderMarkdown(af: TFile | null = null) {
|
||||
if (!af) {
|
||||
af = this.app.workspace.getActiveFile();
|
||||
}
|
||||
if (!af || af.extension.toLocaleLowerCase() !== 'md') {
|
||||
return;
|
||||
}
|
||||
this.currentFile = af;
|
||||
// 如果关闭了样式 UI,则在渲染前强制使用全局默认样式/高亮(忽略 frontmatter 中的 theme/highlight)
|
||||
if (!this.settings.showStyleUI) {
|
||||
const globalStyle = this.settings.defaultStyle;
|
||||
const globalHighlight = this.settings.defaultHighlight;
|
||||
// 仅当变更时更新当前与 articleRender 中的值,避免不必要的刷新
|
||||
if (this.currentTheme !== globalStyle) {
|
||||
this.currentTheme = globalStyle;
|
||||
if (this._articleRender) {
|
||||
this._articleRender.currentTheme = globalStyle;
|
||||
}
|
||||
}
|
||||
if (this.currentHighlight !== globalHighlight) {
|
||||
this.currentHighlight = globalHighlight;
|
||||
if (this._articleRender) {
|
||||
this._articleRender.currentHighlight = globalHighlight;
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.render.renderMarkdown(af);
|
||||
const metadata = this.render.getMetadata();
|
||||
if (metadata.appid) {
|
||||
this.wechatSelect.value = metadata.appid;
|
||||
}
|
||||
else {
|
||||
this.wechatSelect.value = this.currentAppId;
|
||||
}
|
||||
// 仅当 UI 开启时才允许 frontmatter 覆盖与下拉同步;关闭时忽略 frontmatter 的 theme/highlight
|
||||
if (this.settings.showStyleUI) {
|
||||
if (metadata.theme) {
|
||||
this.assetsManager.themes.forEach(theme => {
|
||||
if (theme.name === metadata.theme) {
|
||||
this.currentTheme = theme.className;
|
||||
if (this.themeSelect) this.themeSelect.value = theme.className;
|
||||
if (this._articleRender) this._articleRender.currentTheme = theme.className;
|
||||
}
|
||||
});
|
||||
} else if (this.themeSelect) {
|
||||
this.themeSelect.value = this.currentTheme;
|
||||
}
|
||||
|
||||
if (metadata.highlight) {
|
||||
this.currentHighlight = metadata.highlight;
|
||||
if (this.highlightSelect) this.highlightSelect.value = metadata.highlight;
|
||||
if (this._articleRender) this._articleRender.currentHighlight = metadata.highlight;
|
||||
} else if (this.highlightSelect) {
|
||||
this.highlightSelect.value = this.currentHighlight;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果当前是小红书平台,更新小红书预览
|
||||
if (this.currentPlatform === 'xiaohongshu' && this._xiaohongshuPreview) {
|
||||
this.articleHTML = this.render.articleHTML;
|
||||
await this._xiaohongshuPreview.renderArticle(this.articleHTML, af);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台切换处理
|
||||
* 当用户切换发布平台时调用
|
||||
*/
|
||||
async onPlatformChanged() {
|
||||
console.log(`[NotePreview] 平台切换至: ${this.currentPlatform}`);
|
||||
|
||||
if (this.currentPlatform === 'xiaohongshu') {
|
||||
// 切换到小红书预览模式
|
||||
await this.switchToXiaohongshuMode();
|
||||
} else {
|
||||
// 切换到微信公众号模式
|
||||
this.switchToWechatMode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到小红书预览模式
|
||||
*/
|
||||
private async 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';
|
||||
// }
|
||||
}
|
||||
|
||||
// 隐藏渲染区域
|
||||
if (this.renderDiv) this.renderDiv.style.display = 'none';
|
||||
|
||||
// 创建或显示小红书预览视图
|
||||
if (!this._xiaohongshuPreview) {
|
||||
const xhsContainer = this.mainDiv.createDiv({ cls: 'xiaohongshu-preview-container' });
|
||||
this._xiaohongshuPreview = new XiaohongshuPreview(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) {
|
||||
// 如果还没有生成 articleHTML,先生成它
|
||||
if (!this.articleHTML) {
|
||||
await this.render.renderMarkdown(this.currentFile);
|
||||
this.articleHTML = this.render.articleHTML;
|
||||
}
|
||||
|
||||
// 渲染到小红书预览
|
||||
if (this.articleHTML) {
|
||||
await 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新按钮文本为微信公众号相关
|
||||
*/
|
||||
private updateButtonsForWechat() {
|
||||
const buttons = this.toolbar.querySelectorAll('button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent;
|
||||
if (text === '发布到小红书') {
|
||||
button.textContent = '发草稿';
|
||||
} else if (text === '上传图片(小红书)') {
|
||||
button.textContent = '上传图片';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新按钮文本为小红书相关
|
||||
*/
|
||||
private updateButtonsForXiaohongshu() {
|
||||
const buttons = this.toolbar.querySelectorAll('button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent;
|
||||
if (text === '发草稿') {
|
||||
button.textContent = '发布到小红书';
|
||||
} else if (text === '上传图片') {
|
||||
button.textContent = '上传图片(小红书)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async uploadImages() {
|
||||
if (this.currentPlatform === 'wechat') {
|
||||
await this.uploadImagesToWechat();
|
||||
} else if (this.currentPlatform === 'xiaohongshu') {
|
||||
await this.uploadImagesToXiaohongshu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片到微信公众号
|
||||
*/
|
||||
async uploadImagesToWechat() {
|
||||
this.showLoading('图片上传中...');
|
||||
try {
|
||||
await this.render.uploadImages(this.currentAppId);
|
||||
this.showMsg('图片上传成功,并且文章内容已复制,请到公众号编辑器粘贴。');
|
||||
} catch (error) {
|
||||
this.showMsg('图片上传失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片到小红书
|
||||
*/
|
||||
async uploadImagesToXiaohongshu() {
|
||||
this.showLoading('处理图片中...');
|
||||
try {
|
||||
// 获取小红书适配器和图片处理器
|
||||
const adapter = new XiaohongshuContentAdapter();
|
||||
const imageHandler = XiaohongshuImageManager.getInstance();
|
||||
|
||||
// 获取当前文档的图片
|
||||
const imageManager = LocalImageManager.getInstance();
|
||||
const images = imageManager.getImageInfos(this.articleDiv);
|
||||
|
||||
if (images.length === 0) {
|
||||
this.showMsg('当前文档没有图片需要处理');
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理图片:转换为PNG格式
|
||||
const imageBlobs: { name: string; blob: Blob }[] = [];
|
||||
for (const img of images) {
|
||||
// 从filePath获取文件
|
||||
const file = this.app.vault.getAbstractFileByPath(img.filePath);
|
||||
if (file && file instanceof TFile) {
|
||||
const fileData = await this.app.vault.readBinary(file);
|
||||
imageBlobs.push({
|
||||
name: file.name,
|
||||
blob: new Blob([fileData])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const processedImages = await imageHandler.processImages(imageBlobs);
|
||||
|
||||
this.showMsg(`成功处理 ${processedImages.length} 张图片,已转换为PNG格式`);
|
||||
} catch (error) {
|
||||
this.showMsg('图片处理失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async postArticle() {
|
||||
if (this.currentPlatform === 'wechat') {
|
||||
await this.postToWechat();
|
||||
} else if (this.currentPlatform === 'xiaohongshu') {
|
||||
await this.postToXiaohongshu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布到微信公众号草稿
|
||||
*/
|
||||
async postToWechat() {
|
||||
let localCover = null;
|
||||
if (this.useLocalCover.checked) {
|
||||
const fileInput = this.coverEl;
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
this.showMsg('请选择封面文件');
|
||||
return;
|
||||
}
|
||||
localCover = fileInput.files[0];
|
||||
if (!localCover) {
|
||||
this.showMsg('请选择封面文件');
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.showLoading('发布中...');
|
||||
try {
|
||||
await this.render.postArticle(this.currentAppId, localCover);
|
||||
this.showMsg('发布成功');
|
||||
}
|
||||
catch (error) {
|
||||
this.showMsg('发布失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布到小红书
|
||||
*/
|
||||
async postToXiaohongshu() {
|
||||
this.showLoading('发布到小红书中...');
|
||||
try {
|
||||
if (!this.currentFile) {
|
||||
this.showMsg('没有可发布的文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
const fileContent = await this.app.vault.read(this.currentFile);
|
||||
|
||||
// 使用小红书适配器转换内容
|
||||
const adapter = new XiaohongshuContentAdapter();
|
||||
const xiaohongshuPost = adapter.adaptMarkdownToXiaohongshu(fileContent, {
|
||||
addStyle: true,
|
||||
generateTitle: true
|
||||
});
|
||||
|
||||
// 验证内容
|
||||
const validation = adapter.validatePost(xiaohongshuPost);
|
||||
if (!validation.valid) {
|
||||
this.showMsg('内容验证失败: ' + validation.errors.join('; '));
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取小红书API实例
|
||||
const api = XiaohongshuAPIManager.getInstance(false); // 暂时使用false
|
||||
|
||||
// 检查登录状态
|
||||
const isLoggedIn = await api.checkLoginStatus();
|
||||
if (!isLoggedIn) {
|
||||
this.showMsg('请先登录小红书,或检查登录状态');
|
||||
return;
|
||||
}
|
||||
|
||||
// 发布内容
|
||||
const result = await api.createPost(xiaohongshuPost);
|
||||
|
||||
if (result.success) {
|
||||
this.showMsg('发布到小红书成功!');
|
||||
} else {
|
||||
this.showMsg('发布失败: ' + result.message);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
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('发布图片中...');
|
||||
try {
|
||||
await this.render.postImages(this.currentAppId);
|
||||
this.showMsg('图片发布成功');
|
||||
} catch (error) {
|
||||
this.showMsg('图片发布失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async exportHTML() {
|
||||
this.showLoading('导出HTML中...');
|
||||
try {
|
||||
await this.render.exportHTML();
|
||||
this.showMsg('HTML导出成功');
|
||||
} catch (error) {
|
||||
this.showMsg('HTML导出失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async sliceArticleImage() {
|
||||
if (!this.currentFile) {
|
||||
new Notice('请先打开一个笔记文件');
|
||||
return;
|
||||
}
|
||||
this.showLoading('切图处理中...');
|
||||
try {
|
||||
const articleSection = this.render.getArticleSection();
|
||||
if (!articleSection) {
|
||||
throw new Error('未找到预览区域');
|
||||
}
|
||||
await sliceArticleImage(articleSection, this.currentFile, this.app);
|
||||
this.showMsg('切图完成');
|
||||
} catch (error) {
|
||||
console.error('切图失败:', error);
|
||||
this.showMsg('切图失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async batchPost(folder: TFolder) {
|
||||
const files = folder.children.filter((child: TAbstractFile) => child.path.toLocaleLowerCase().endsWith('.md'));
|
||||
if (!files) {
|
||||
new Notice('没有可渲染的笔记或文件不支持渲染');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCancelUpload = false;
|
||||
this.isBatchRuning = true;
|
||||
|
||||
try {
|
||||
for (let file of files) {
|
||||
this.showLoading(`即将发布: ${file.name}`, true);
|
||||
await sleep(5000);
|
||||
if (this.isCancelUpload) {
|
||||
break;
|
||||
}
|
||||
this.cleanArticleData();
|
||||
await this.renderMarkdown(file as TFile);
|
||||
await this.postArticle();
|
||||
}
|
||||
|
||||
if (!this.isCancelUpload) {
|
||||
this.showMsg(`批量发布完成:成功发布 ${files.length} 篇笔记`);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
new Notice('批量发布失败: ' + e.message);
|
||||
}
|
||||
finally {
|
||||
this.isBatchRuning = false;
|
||||
this.isCancelUpload = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
/* 文件:slice-image.ts — 预览页面切图功能:将渲染完的 HTML 页面转为长图,再按比例裁剪为多张 PNG 图片。 */
|
||||
|
||||
import { toPng } from 'html-to-image';
|
||||
import { Notice, TFile } from 'obsidian';
|
||||
import { NMPSettings } from './settings';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 解析横竖比例字符串(如 "3:4")为数值
|
||||
*/
|
||||
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] };
|
||||
}
|
||||
// 默认 3:4
|
||||
return { width: 3, height: 4 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 切图主函数
|
||||
* @param articleElement 预览文章的 HTML 元素(#article-section)
|
||||
* @param file 当前文件
|
||||
* @param app Obsidian App 实例
|
||||
*/
|
||||
export async function sliceArticleImage(articleElement: HTMLElement, file: TFile, app: any) {
|
||||
const settings = NMPSettings.getInstance();
|
||||
const { sliceImageSavePath, sliceImageWidth, sliceImageAspectRatio } = settings;
|
||||
|
||||
// 解析比例
|
||||
const ratio = parseAspectRatio(sliceImageAspectRatio);
|
||||
const sliceHeight = Math.round((sliceImageWidth * ratio.height) / ratio.width);
|
||||
|
||||
// 获取 slug
|
||||
const slug = getSlugFromFile(file, app);
|
||||
|
||||
new Notice(`开始切图:${slug},宽度=${sliceImageWidth},比例=${sliceImageAspectRatio}`);
|
||||
|
||||
try {
|
||||
// 1. 保存原始样式
|
||||
const originalWidth = articleElement.style.width;
|
||||
const originalMaxWidth = articleElement.style.maxWidth;
|
||||
const originalMinWidth = articleElement.style.minWidth;
|
||||
|
||||
// 2. 临时设置为目标宽度进行渲染
|
||||
articleElement.style.width = `${sliceImageWidth}px`;
|
||||
articleElement.style.maxWidth = `${sliceImageWidth}px`;
|
||||
articleElement.style.minWidth = `${sliceImageWidth}px`;
|
||||
|
||||
// 等待样式生效和重排
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
new Notice(`设置渲染宽度: ${sliceImageWidth}px`);
|
||||
|
||||
// 3. 生成长图 - 使用实际渲染宽度
|
||||
new Notice('正在生成长图...');
|
||||
const longImageDataURL = await toPng(articleElement, {
|
||||
width: sliceImageWidth,
|
||||
pixelRatio: 1,
|
||||
cacheBust: true,
|
||||
});
|
||||
|
||||
// 4. 恢复原始样式
|
||||
articleElement.style.width = originalWidth;
|
||||
articleElement.style.maxWidth = originalMaxWidth;
|
||||
articleElement.style.minWidth = originalMinWidth;
|
||||
|
||||
// 5. 创建临时 Image 对象以获取长图实际高度
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = reject;
|
||||
img.src = longImageDataURL;
|
||||
});
|
||||
|
||||
const fullHeight = img.height;
|
||||
const fullWidth = img.width;
|
||||
|
||||
new Notice(`长图生成完成:${fullWidth}x${fullHeight}px`);
|
||||
|
||||
// 3. 保存完整长图
|
||||
ensureDir(sliceImageSavePath);
|
||||
const longImagePath = path.join(sliceImageSavePath, `${slug}.png`);
|
||||
const longImageBuffer = dataURLToBuffer(longImageDataURL);
|
||||
fs.writeFileSync(longImagePath, new Uint8Array(longImageBuffer));
|
||||
new Notice(`长图已保存:${longImagePath}`);
|
||||
|
||||
// 4. 计算需要切多少片
|
||||
const sliceCount = Math.ceil(fullHeight / sliceHeight);
|
||||
new Notice(`开始切图:共 ${sliceCount} 张,每张 ${sliceImageWidth}x${sliceHeight}px`);
|
||||
|
||||
// 5. 使用 Canvas 裁剪
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = sliceImageWidth;
|
||||
canvas.height = sliceHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('无法创建 Canvas 上下文');
|
||||
}
|
||||
|
||||
for (let i = 0; i < sliceCount; i++) {
|
||||
const yOffset = i * sliceHeight;
|
||||
const actualHeight = Math.min(sliceHeight, fullHeight - yOffset);
|
||||
|
||||
// 清空画布(处理最后一张可能不足高度的情况,用白色填充)
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, sliceImageWidth, sliceHeight);
|
||||
|
||||
// 绘制裁剪区域
|
||||
ctx.drawImage(
|
||||
img,
|
||||
0, yOffset, fullWidth, actualHeight, // 源区域
|
||||
0, 0, sliceImageWidth, actualHeight // 目标区域
|
||||
);
|
||||
|
||||
// 导出为 PNG
|
||||
const sliceDataURL = canvas.toDataURL('image/png');
|
||||
const sliceBuffer = dataURLToBuffer(sliceDataURL);
|
||||
const sliceFilename = `${slug}_${i + 1}.png`;
|
||||
const slicePath = path.join(sliceImageSavePath, sliceFilename);
|
||||
fs.writeFileSync(slicePath, new Uint8Array(sliceBuffer));
|
||||
|
||||
new Notice(`已保存:${sliceFilename}`);
|
||||
}
|
||||
|
||||
new Notice(`✅ 切图完成!共 ${sliceCount} 张图片,保存在:${sliceImageSavePath}`);
|
||||
} catch (error) {
|
||||
console.error('切图失败:', error);
|
||||
new Notice(`❌ 切图失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import { sliceCurrentPage, sliceAllPages } from './slice';
|
||||
|
||||
const XHS_PREVIEW_DEFAULT_WIDTH = 540;
|
||||
const XHS_PREVIEW_WIDTH_OPTIONS = [1080, 720, 540, 360];
|
||||
const XHS_FONT_SIZE_MIN = 18;
|
||||
const XHS_FONT_SIZE_MAX = 45;
|
||||
const XHS_FONT_SIZE_DEFAULT = 36;
|
||||
|
||||
/**
|
||||
* 小红书预览视图类
|
||||
@@ -45,7 +48,7 @@ export class XiaohongshuPreview {
|
||||
// 分页数据
|
||||
pages: PageInfo[] = [];
|
||||
currentPageIndex: number = 0;
|
||||
currentFontSize: number = 16;
|
||||
currentFontSize: number = XHS_FONT_SIZE_DEFAULT;
|
||||
articleHTML: string = '';
|
||||
|
||||
// 回调函数
|
||||
@@ -149,7 +152,12 @@ export class XiaohongshuPreview {
|
||||
|
||||
this.fontSizeInput = fontSizeGroup.createEl('input', {
|
||||
cls: 'font-size-input',
|
||||
attr: { type: 'number', min: '12', max: '36', value: '16' }
|
||||
attr: {
|
||||
type: 'number',
|
||||
min: String(XHS_FONT_SIZE_MIN),
|
||||
max: String(XHS_FONT_SIZE_MAX),
|
||||
value: String(XHS_FONT_SIZE_DEFAULT)
|
||||
}
|
||||
});
|
||||
this.fontSizeInput.style.width = '50px';
|
||||
this.fontSizeInput.style.textAlign = 'center';
|
||||
@@ -363,7 +371,7 @@ export class XiaohongshuPreview {
|
||||
* 切换字号(± 按钮)
|
||||
*/
|
||||
private async changeFontSize(delta: number): Promise<void> {
|
||||
this.currentFontSize = Math.max(12, Math.min(36, this.currentFontSize + delta));
|
||||
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();
|
||||
}
|
||||
@@ -373,9 +381,9 @@ export class XiaohongshuPreview {
|
||||
*/
|
||||
private async onFontSizeInputChanged(): Promise<void> {
|
||||
const val = parseInt(this.fontSizeInput.value, 10);
|
||||
if (isNaN(val) || val < 12 || val > 36) {
|
||||
if (isNaN(val) || val < XHS_FONT_SIZE_MIN || val > XHS_FONT_SIZE_MAX) {
|
||||
this.fontSizeInput.value = String(this.currentFontSize);
|
||||
new Notice('字号范围: 12-36');
|
||||
new Notice(`字号范围: ${XHS_FONT_SIZE_MIN}-${XHS_FONT_SIZE_MAX}`);
|
||||
return;
|
||||
}
|
||||
this.currentFontSize = val;
|
||||
|
||||