/* * Copyright (c) 2024-2025 Sun Booshi * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ 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'; 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; themeSelect: HTMLSelectElement; highlightSelect: HTMLSelectElement; listeners?: EventRef[]; container: Element; settings: NMPSettings; assetsManager: AssetsManager; articleHTML: string; title: string; currentFile?: TFile; currentTheme: string; currentHighlight: string; currentAppId: string; markedParser: MarkedParser; cachedElements: Map = new Map(); _articleRender: ArticleRender | 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; // 公众号 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'); 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 openBtn = lineDiv.createEl('button', { cls: 'refresh-button' }, async (button) => { button.setText('去公众号后台'); }) 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' }); const refreshBtn = lineDiv.createEl('button', { cls: 'refresh-button' }, async (button) => { button.setText('刷新'); }) 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', { cls: 'copy-button' }, async (button) => { button.setText('复制'); }) 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', { cls: 'copy-button' }, async (button) => { button.setText('上传图片'); }) uploadImgBtn.onclick = async() => { await this.uploadImages(); uevent('upload'); } const postBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => { button.setText('发草稿'); }) postBtn.onclick = async() => { await this.postArticle(); uevent('pub'); } const imagesBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => { button.setText('图片/文字'); }) imagesBtn.onclick = async() => { await this.postImages(); uevent('pub-images'); } if (Platform.isDesktop && this.settings.isAuthKeyVaild()) { const htmlBtn = lineDiv.createEl('button', { cls: 'copy-button' }, async (button) => { button.setText('导出HTML'); }) htmlBtn.onclick = async() => { await this.exportHTML(); uevent('export-html'); } } // 封面 lineDiv = this.toolbar.createDiv({ cls: 'toolbar-line' }); 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' }); const cssStyle = lineDiv.createDiv({ cls: 'style-label' }); cssStyle.innerText = '样式:'; const selectBtn = lineDiv.createEl('select', { cls: 'style-select' }, async (sel) => { }) 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 highlightStyle = lineDiv.createDiv({ cls: 'style-label' }); highlightStyle.innerText = '代码高亮:'; const highlightStyleBtn = lineDiv.createEl('select', { cls: 'style-select' }, async (sel) => { }) 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.renderDiv.setAttribute('style', '-webkit-user-select: text; user-select: text;'); 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; } } } async uploadImages() { this.showLoading('图片上传中...'); try { await this.render.uploadImages(this.currentAppId); this.showMsg('图片上传成功,并且文章内容已复制,请到公众号编辑器粘贴。'); } catch (error) { this.showMsg('图片上传失败: ' + error.message); } } async postArticle() { 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 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 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; } } }