mirror of
https://github.com/yeban8090/book-smith.git
synced 2026-07-22 05:46:23 +00:00
commit
c5c80a2e17
16 changed files with 3380 additions and 2005 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "book-smith",
|
||||
"name": "Book Smith",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Simplify long-form writing and book creation. Organize chapters, track progress, and export your manuscript in various formats for a seamless publishing workflow.",
|
||||
"author": "Yeban",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,4 +11,12 @@ export interface CommonTranslation {
|
|||
OPEN_BOOK_PANEL: string;
|
||||
OPEN_TOOL_PANEL: string;
|
||||
OPEN_ALL_PANELS: string;
|
||||
|
||||
PREFACE: string;
|
||||
OUTLINE: string;
|
||||
VOLUME_1: string;
|
||||
CHAPTER_1: string;
|
||||
CHAPTER_2: string;
|
||||
AFTERWORD: string;
|
||||
TEMPLATE_OPTIONS_DESC: string;
|
||||
}
|
||||
|
|
@ -22,6 +22,14 @@ const commonTranslation: CommonTranslation = {
|
|||
OPEN_BOOK_PANEL: 'Open book panel',
|
||||
OPEN_TOOL_PANEL: 'Open tool panel',
|
||||
OPEN_ALL_PANELS: 'Open all panels',
|
||||
|
||||
PREFACE: 'Preface',
|
||||
OUTLINE: 'Outline',
|
||||
VOLUME_1: 'Volume 1',
|
||||
CHAPTER_1: 'Chapter 1',
|
||||
CHAPTER_2: 'Chapter 2',
|
||||
AFTERWORD: 'Afterword',
|
||||
TEMPLATE_OPTIONS_DESC: 'Standard structure with preface, outline, chapters and afterword'
|
||||
};
|
||||
|
||||
// 书籍管理视图翻译
|
||||
|
|
@ -479,7 +487,7 @@ const translation: Translation = {
|
|||
...modalTranslation,
|
||||
...managerTranslation,
|
||||
...toolbarModalTranslation,
|
||||
...componentTranslation
|
||||
...componentTranslation
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ const commonTranslation: CommonTranslation = {
|
|||
OPEN_BOOK_PANEL: '打开书籍管理面板',
|
||||
OPEN_TOOL_PANEL: '打开工具面板',
|
||||
OPEN_ALL_PANELS: '打开所有面板',
|
||||
|
||||
PREFACE: '前言',
|
||||
OUTLINE: '大纲',
|
||||
VOLUME_1: '第一卷',
|
||||
CHAPTER_1: '第一章',
|
||||
CHAPTER_2: '第二章',
|
||||
AFTERWORD: '后记',
|
||||
TEMPLATE_OPTIONS_DESC: '包含前言、大纲、正文卷章和后记的标准结构',
|
||||
};
|
||||
|
||||
// 书籍管理视图翻译
|
||||
|
|
@ -392,10 +400,10 @@ const toolbarModalTranslation: ToolbarModalTranslation = {
|
|||
SELECT_BOOK_FIRST: '请先选择一本书籍',
|
||||
EXPORT_SUCCESS: '导出成功',
|
||||
EXPORT_FAILED: '导出失败: ',
|
||||
FORMAT : '格式',
|
||||
FORMAT: '格式',
|
||||
CUSTOM_SIZE: '自定义大小',
|
||||
SELECT_EXPORT_FORMAT: '选择导出格式',
|
||||
|
||||
|
||||
// CoverModal
|
||||
DESIGN_COVER: '设计封面',
|
||||
SHOW_COVER: '显示封面',
|
||||
|
|
@ -479,7 +487,7 @@ const translation: Translation = {
|
|||
...modalTranslation,
|
||||
...managerTranslation,
|
||||
...toolbarModalTranslation,
|
||||
...componentTranslation
|
||||
...componentTranslation
|
||||
};
|
||||
|
||||
export default translation;
|
||||
|
|
|
|||
169
src/modals/BookSelectionModal.ts
Normal file
169
src/modals/BookSelectionModal.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { App, Modal, Notice, ButtonComponent } from 'obsidian';
|
||||
import { BookManager } from '../services/BookManager';
|
||||
import { ExportModal } from './ExportModal';
|
||||
import { BookRenderService } from '../services/BookRenderService';
|
||||
import { i18n } from '../i18n/i18n';
|
||||
import { Book } from '../types/book';
|
||||
import BookSmithPlugin from '../main';
|
||||
|
||||
export class BookSelectionModal extends Modal {
|
||||
private selectedBook: Book | null = null;
|
||||
private books: Book[] = [];
|
||||
private bookListContainer: HTMLElement;
|
||||
constructor(
|
||||
app: App,
|
||||
private plugin: BookSmithPlugin
|
||||
) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
// 设置模态框标题
|
||||
contentEl.createEl('h2', { text: '选择要导出的书籍' });
|
||||
|
||||
// 加载书籍列表
|
||||
await this.loadBooks();
|
||||
|
||||
// 创建书籍列表容器
|
||||
this.bookListContainer = contentEl.createDiv({ cls: 'book-selection-list' });
|
||||
this.renderBookList();
|
||||
|
||||
// 创建按钮容器
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'book-selection-buttons' });
|
||||
|
||||
// 取消按钮
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText(i18n.t('CANCEL') || '取消')
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
// 导出按钮
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText('导出设置')
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.handleExport();
|
||||
});
|
||||
}
|
||||
|
||||
private async loadBooks() {
|
||||
try {
|
||||
this.books = await this.plugin.bookManager.getAllBooks();
|
||||
} catch (error) {
|
||||
console.error('加载书籍列表失败:', error);
|
||||
new Notice('加载书籍列表失败');
|
||||
this.books = [];
|
||||
}
|
||||
}
|
||||
|
||||
private renderBookList() {
|
||||
this.bookListContainer.empty();
|
||||
|
||||
if (this.books.length === 0) {
|
||||
const emptyMessage = this.bookListContainer.createDiv({ cls: 'book-selection-empty' });
|
||||
emptyMessage.createEl('p', { text: '未找到任何书籍' });
|
||||
emptyMessage.createEl('p', {
|
||||
text: '请先创建一本书籍再进行导出',
|
||||
cls: 'book-selection-hint'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.books.forEach((book, index) => {
|
||||
const bookItem = this.bookListContainer.createDiv({ cls: 'book-selection-item' });
|
||||
|
||||
// 单选框
|
||||
const radioContainer = bookItem.createDiv({ cls: 'book-selection-radio' });
|
||||
const radio = radioContainer.createEl('input', {
|
||||
type: 'radio',
|
||||
attr: {
|
||||
name: 'selected-book',
|
||||
value: book.basic.uuid
|
||||
}
|
||||
});
|
||||
|
||||
// 书籍信息
|
||||
const bookInfo = bookItem.createDiv({ cls: 'book-selection-info' });
|
||||
const title = bookInfo.createEl('h3', {
|
||||
text: book.basic.title,
|
||||
cls: 'book-selection-title'
|
||||
});
|
||||
|
||||
const details = bookInfo.createDiv({ cls: 'book-selection-details' });
|
||||
details.createEl('span', {
|
||||
text: `${i18n.t('AUTHOR') || '作者'}: ${book.basic.author.join(', ')}`,
|
||||
cls: 'book-selection-author'
|
||||
});
|
||||
|
||||
if (book.stats) {
|
||||
details.createEl('span', {
|
||||
text: `${i18n.t('WORD_COUNT') || '字数'}: ${book.stats.total_words || 0}`,
|
||||
cls: 'book-selection-words'
|
||||
});
|
||||
}
|
||||
|
||||
const createdDate = new Date(book.basic.created_at).toLocaleDateString();
|
||||
details.createEl('span', {
|
||||
text: `${'创建日期'}: ${createdDate}`,
|
||||
cls: 'book-selection-date'
|
||||
});
|
||||
|
||||
// 点击整个项目选中
|
||||
bookItem.addEventListener('click', () => {
|
||||
// 清除其他选中状态
|
||||
this.bookListContainer.querySelectorAll('.book-selection-item').forEach(item => {
|
||||
item.removeClass('selected');
|
||||
});
|
||||
this.bookListContainer.querySelectorAll('input[type="radio"]').forEach((input: HTMLInputElement) => {
|
||||
input.checked = false;
|
||||
});
|
||||
|
||||
// 设置当前选中
|
||||
bookItem.addClass('selected');
|
||||
radio.checked = true;
|
||||
this.selectedBook = book;
|
||||
});
|
||||
|
||||
// 默认选中第一本书
|
||||
if (index === 0) {
|
||||
bookItem.addClass('selected');
|
||||
radio.checked = true;
|
||||
this.selectedBook = book;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleExport() {
|
||||
if (!this.selectedBook) {
|
||||
new Notice('请先选择一本书籍');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.close();
|
||||
const bookRenderService = new BookRenderService(this.app);
|
||||
// 创建并打开导出模态框 - 修正参数顺序
|
||||
const exportModal = new ExportModal(
|
||||
this.app,
|
||||
this.plugin,
|
||||
bookRenderService,
|
||||
this.selectedBook
|
||||
);
|
||||
exportModal.open();
|
||||
} catch (error) {
|
||||
console.error('打开导出模态框失败:', error);
|
||||
new Notice('打开导出模态框失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 移除 addStyles 方法,因为样式已经移到独立的CSS文件中
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { App, Modal, Setting } from 'obsidian';
|
||||
import { App, Modal, Setting, Notice } from 'obsidian';
|
||||
import { CoverManager } from '../services/CoverManager';
|
||||
import { i18n } from '../i18n/i18n';
|
||||
import { CoverSettings, TextStyleConfig } from '../types/book';
|
||||
|
|
@ -146,6 +146,10 @@ export class CoverSettingModal extends Modal {
|
|||
authorStyleConfig: this.authorStyleConfig,
|
||||
subtitleStyleConfig: this.subtitleStyleConfig
|
||||
});
|
||||
|
||||
// 添加提示
|
||||
new Notice('封面设置已更新,请点击"重新渲染"按钮查看效果');
|
||||
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -52,7 +52,7 @@ export class HeaderFooterTocModal extends Modal {
|
|||
headerLeft: '{{title}}',
|
||||
headerCenter: '',
|
||||
headerRight: '{{author}}',
|
||||
headerFontSize: 12,
|
||||
headerFontSize: 15,
|
||||
headerColor: '#000000',
|
||||
headerHeight: 15,
|
||||
|
||||
|
|
@ -60,14 +60,14 @@ export class HeaderFooterTocModal extends Modal {
|
|||
footerLeft: '',
|
||||
footerCenter: '',
|
||||
footerRight: '{{pageNumber}}/{{totalPages}}',
|
||||
footerFontSize: 12,
|
||||
footerFontSize: 15,
|
||||
footerColor: '#000000',
|
||||
footerHeight: 20,
|
||||
|
||||
tocEnabled: true,
|
||||
tocTitle: '目录',
|
||||
tocMaxLevel: 3,
|
||||
tocFontSize: 14,
|
||||
tocFontSize: 15,
|
||||
tocFontFamily: 'serif', // 新增默认值
|
||||
tocColor: '#000000', // 新增默认值
|
||||
tocLineHeight: 1.5,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export class BookManager {
|
|||
}
|
||||
|
||||
async createBook(
|
||||
basicInfo: Omit<BookBasicInfo, 'uuid' | 'created_at'>,
|
||||
basicInfo: Omit<BookBasicInfo, 'uuid' | 'created_at'>,
|
||||
templateType: string = 'default',
|
||||
targetTotalWords: number = 0
|
||||
): Promise<Book> {
|
||||
|
|
@ -144,16 +144,16 @@ export class BookManager {
|
|||
...updates.basic
|
||||
},
|
||||
structure: {
|
||||
...book.structure,
|
||||
...updates.structure
|
||||
...book.structure,
|
||||
...updates.structure
|
||||
},
|
||||
stats: {
|
||||
...book.stats,
|
||||
...updates.stats
|
||||
...book.stats,
|
||||
...updates.stats
|
||||
},
|
||||
export: {
|
||||
...book.export,
|
||||
...updates.export
|
||||
...book.export,
|
||||
...updates.export
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ export class BookManager {
|
|||
try {
|
||||
const configFile = this.app.vault.getAbstractFileByPath(configPath);
|
||||
const jsonContent = JSON.stringify(book, null, 2);
|
||||
|
||||
|
||||
if (configFile instanceof TFile) {
|
||||
// 如果文件已存在,使用 modify 方法
|
||||
await this.app.vault.modify(configFile, jsonContent);
|
||||
|
|
@ -221,7 +221,49 @@ export class BookManager {
|
|||
}
|
||||
|
||||
private async getTemplateStructure(templateType: string): Promise<ChapterTree> {
|
||||
return this.templateManager.getTemplate(templateType);
|
||||
const template = this.templateManager.getTemplate(templateType);
|
||||
|
||||
// 如果是默认模板,应用国际化
|
||||
if (templateType === 'default') {
|
||||
// 深拷贝模板结构以避免修改原始模板
|
||||
const localizedTemplate = JSON.parse(JSON.stringify(template));
|
||||
|
||||
// 更新模板结构中的节点标题
|
||||
const updateNodeTitles = (nodes: ChapterNode[]) => {
|
||||
for (const node of nodes) {
|
||||
// 根据节点 id 或原始标题进行翻译
|
||||
if (node.id === 'preface') {
|
||||
node.title = i18n.t('PREFACE');
|
||||
node.path = `${i18n.t('PREFACE')}.md`;
|
||||
} else if (node.id === 'outline') {
|
||||
node.title = i18n.t('OUTLINE');
|
||||
node.path = `${i18n.t('OUTLINE')}.md`;
|
||||
} else if (node.id === 'volume1') {
|
||||
node.title = i18n.t('VOLUME_1');
|
||||
node.path = i18n.t('VOLUME_1');
|
||||
} else if (node.id === 'chapter1') {
|
||||
node.title = i18n.t('CHAPTER_1');
|
||||
node.path = `${i18n.t('VOLUME_1')}/${i18n.t('CHAPTER_1')}.md`;
|
||||
} else if (node.id === 'chapter2') {
|
||||
node.title = i18n.t('CHAPTER_2');
|
||||
node.path = `${i18n.t('VOLUME_1')}/${i18n.t('CHAPTER_2')}.md`;
|
||||
} else if (node.id === 'afterword') {
|
||||
node.title = i18n.t('AFTERWORD');
|
||||
node.path = `${i18n.t('AFTERWORD')}.md`;
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
if (node.children) {
|
||||
updateNodeTitles(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateNodeTitles(localizedTemplate.tree);
|
||||
return localizedTemplate;
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
private async createInitialStructure(folder: TFolder, structure: ChapterTree): Promise<void> {
|
||||
|
|
@ -250,15 +292,15 @@ export class BookManager {
|
|||
}
|
||||
|
||||
// 在 BookManager 类中添加这个方法
|
||||
|
||||
|
||||
async importBookFromFolder(folderName: string): Promise<Book> {
|
||||
try {
|
||||
// 获取文件夹内的文件结构
|
||||
const folderPath = `${this.settings.defaultBookPath}/${folderName}`;
|
||||
|
||||
|
||||
// 创建书籍结构
|
||||
const structure = await this.buildFolderStructure(folderPath, '');
|
||||
|
||||
|
||||
// 创建新书籍对象,确保符合 Book 接口定义
|
||||
const newBook: Book = {
|
||||
basic: {
|
||||
|
|
@ -287,13 +329,13 @@ export class BookManager {
|
|||
include_cover: true
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 保存书籍配置
|
||||
const bookFolder = this.app.vault.getAbstractFileByPath(folderPath);
|
||||
if (!(bookFolder instanceof TFolder)) {
|
||||
throw new Error(i18n.t('BOOK_FOLDER_NOT_FOUND'));
|
||||
}
|
||||
|
||||
|
||||
await this.saveBookConfig(bookFolder, newBook);
|
||||
return newBook;
|
||||
} catch (error) {
|
||||
|
|
@ -301,26 +343,26 @@ export class BookManager {
|
|||
throw new Error(i18n.t('IMPORT_BOOK_FAILED'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 构建文件夹结构的辅助方法
|
||||
private async buildFolderStructure(folderPath: string, parentPath: string = ''): Promise<ChapterNode[]> {
|
||||
const structure: ChapterNode[] = [];
|
||||
let order = 0;
|
||||
|
||||
|
||||
const folderContents = await this.app.vault.adapter.list(folderPath);
|
||||
|
||||
|
||||
// 处理文件
|
||||
for (const filePath of folderContents.files) {
|
||||
// 只处理markdown文件
|
||||
if (!filePath.endsWith('.md')) continue;
|
||||
|
||||
|
||||
const fileName = filePath.split('/').pop() || '';
|
||||
// 过滤掉隐藏文件和配置文件
|
||||
if (fileName.startsWith('.')) continue;
|
||||
|
||||
|
||||
const title = fileName.replace('.md', '');
|
||||
const relativePath = parentPath ? `${parentPath}/${fileName}` : fileName;
|
||||
|
||||
|
||||
structure.push({
|
||||
id: uuidv4(),
|
||||
title: title,
|
||||
|
|
@ -332,24 +374,24 @@ export class BookManager {
|
|||
last_modified: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 处理文件夹
|
||||
for (const subFolderPath of folderContents.folders) {
|
||||
const folderName = subFolderPath.split('/').pop() || '';
|
||||
|
||||
|
||||
// 过滤掉隐藏文件夹和特殊系统文件夹
|
||||
if (folderName.startsWith('.') ||
|
||||
folderName === '__MACOSX' ||
|
||||
if (folderName.startsWith('.') ||
|
||||
folderName === '__MACOSX' ||
|
||||
folderName === 'node_modules') continue;
|
||||
|
||||
|
||||
const relativePath = parentPath ? `${parentPath}/${folderName}` : folderName;
|
||||
|
||||
|
||||
// 递归获取子文件夹内容
|
||||
const children = await this.buildFolderStructure(
|
||||
subFolderPath,
|
||||
subFolderPath,
|
||||
relativePath
|
||||
);
|
||||
|
||||
|
||||
structure.push({
|
||||
id: uuidv4(),
|
||||
title: folderName,
|
||||
|
|
@ -363,7 +405,7 @@ export class BookManager {
|
|||
is_expanded: true
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return structure;
|
||||
}
|
||||
}
|
||||
747
src/services/BookRenderService.ts
Normal file
747
src/services/BookRenderService.ts
Normal file
|
|
@ -0,0 +1,747 @@
|
|||
import { App, Component, MarkdownRenderer, MarkdownView, TFile } from 'obsidian';
|
||||
import { Book, ChapterNode, CoverSettings } from '../types/book';
|
||||
import { HeaderFooterTocSettings } from '../modals/HeaderFooterTocModal';
|
||||
import * as electron from 'electron';
|
||||
|
||||
export interface RenderConfig {
|
||||
showTitle: boolean;
|
||||
scale: number;
|
||||
displayHeader: boolean;
|
||||
displayFooter: boolean;
|
||||
cssSnippet?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
onProgress?: (current: number, total: number, fileName: string) => void;
|
||||
headerFooterToc?: HeaderFooterTocSettings; // 添加目录设置
|
||||
showCover?: boolean;
|
||||
coverSettings?: CoverSettings;
|
||||
}
|
||||
|
||||
export interface DocType {
|
||||
doc: Document;
|
||||
frontMatter: any;
|
||||
file: TFile;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ParamType {
|
||||
app: App;
|
||||
file: TFile;
|
||||
config: RenderConfig;
|
||||
book: Book;
|
||||
rootPath: string;
|
||||
extra?: {
|
||||
title?: string;
|
||||
id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class BookRenderService {
|
||||
private docs: DocType[] = [];
|
||||
private scale = 1;
|
||||
|
||||
constructor(private app: App) { }
|
||||
|
||||
/**
|
||||
* 主要渲染方法 - 基于 obsidian-better-export-pdf 架构
|
||||
*/
|
||||
async renderToWebview(
|
||||
webview: electron.WebviewTag,
|
||||
book: Book,
|
||||
rootPath: string,
|
||||
config: RenderConfig
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Webview setup timeout'));
|
||||
}, 10000);
|
||||
|
||||
|
||||
webview.addEventListener('dom-ready', async () => {
|
||||
try {
|
||||
clearTimeout(timeout);
|
||||
|
||||
// 1. 收集所有文件并渲染为文档
|
||||
const { data, docs } = await this.getAllFiles(book, rootPath, config);
|
||||
await this.renderFiles(data, docs, config);
|
||||
|
||||
// 2. 如果启用了目录,生成目录 HTML
|
||||
let tocHtml = '';
|
||||
if (config.headerFooterToc?.tocEnabled) {
|
||||
tocHtml = this.generateTOC(config.headerFooterToc, book);
|
||||
}
|
||||
|
||||
// 3. 注入所有样式
|
||||
const styles = this.getAllStyles();
|
||||
for (const css of styles) {
|
||||
await webview.insertCSS(css);
|
||||
}
|
||||
|
||||
// 4. 处理自定义CSS片段
|
||||
if (config?.cssSnippet && config.cssSnippet !== '0') {
|
||||
try {
|
||||
await webview.insertCSS(config.cssSnippet);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load CSS snippet:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 将目录和渲染好的文档注入到webview
|
||||
// 先处理目录,将其插入到内容最前面
|
||||
if (tocHtml) {
|
||||
const doc = this.docs[0].doc;
|
||||
const contentEl = doc.querySelector('.markdown-preview-view');
|
||||
if (contentEl) {
|
||||
const tocContainer = doc.createElement('div');
|
||||
tocContainer.innerHTML = tocHtml;
|
||||
contentEl.insertBefore(tocContainer, contentEl.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
await this.appendWebview(webview, this.docs[0]);
|
||||
|
||||
|
||||
// 6. 注入补丁样式
|
||||
const patchStyles = this.getPatchStyles();
|
||||
for (const css of patchStyles) {
|
||||
await webview.insertCSS(css);
|
||||
}
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集所有文件 - 对应 modal.ts 的 getAllFiles
|
||||
*/
|
||||
private async getAllFiles(book: Book, rootPath: string, config: RenderConfig): Promise<{ data: ParamType[], docs: DocType[] }> {
|
||||
const fileNodes = this.collectFileNodes(book.structure.tree);
|
||||
const data: ParamType[] = fileNodes.map(node => ({
|
||||
app: this.app,
|
||||
file: this.getFileFromNode(node, book, rootPath),
|
||||
config,
|
||||
book,
|
||||
rootPath,
|
||||
extra: {
|
||||
title: node.title,
|
||||
id: node.id
|
||||
}
|
||||
})).filter(param => param.file !== null) as ParamType[];
|
||||
|
||||
return { data, docs: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染所有文件 - 对应 modal.ts 的 renderFiles
|
||||
*/
|
||||
private async renderFiles(
|
||||
data: ParamType[],
|
||||
docs: DocType[] = [],
|
||||
config: RenderConfig
|
||||
): Promise<void> {
|
||||
const totalFiles = data.length;
|
||||
|
||||
// 并发渲染所有文件
|
||||
const inputs = data.map((param, i) =>
|
||||
this.renderMarkdown(param).then(res => {
|
||||
config.onProgress?.(i + 1, totalFiles, param.file.basename);
|
||||
return res;
|
||||
})
|
||||
);
|
||||
|
||||
let _docs = [...docs, ...(await Promise.all(inputs))];
|
||||
|
||||
// 合并所有文档为一个完整的书籍文档
|
||||
_docs = this.mergeDoc(_docs);
|
||||
|
||||
// 修复文档
|
||||
this.docs = _docs.map(({ doc, ...rest }) => {
|
||||
return { ...rest, doc: this.fixDoc(doc, doc.title) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染单个Markdown文件 - 基于 render.ts 的 renderMarkdown
|
||||
*/
|
||||
private async renderMarkdown({ app, file, config, book, extra }: ParamType): Promise<DocType> {
|
||||
const startTime = new Date().getTime();
|
||||
|
||||
// 检查中断信号
|
||||
if (config.abortSignal?.aborted) {
|
||||
throw new Error('Render aborted');
|
||||
}
|
||||
|
||||
const leaf = app.workspace.getLeaf(true);
|
||||
await leaf.openFile(file);
|
||||
const view = leaf.view as MarkdownView;
|
||||
const data: string = view?.data || await app.vault.cachedRead(file);
|
||||
|
||||
const frontMatter = this.getFrontMatter(file);
|
||||
const cssclasses = this.extractCssClasses(frontMatter);
|
||||
|
||||
const comp = new Component();
|
||||
comp.load();
|
||||
|
||||
try {
|
||||
const printEl = document.body.createDiv('print');
|
||||
const viewEl = printEl.createDiv({
|
||||
cls: 'markdown-preview-view markdown-rendered ' + cssclasses.join(' '),
|
||||
});
|
||||
|
||||
// 设置RTL和属性显示
|
||||
// @ts-ignore
|
||||
viewEl.toggleClass('rtl', app.vault.getConfig('rightToLeft'));
|
||||
// @ts-ignore
|
||||
viewEl.toggleClass('show-properties', 'hidden' !== app.vault.getConfig('propertiesInDocument'));
|
||||
|
||||
// 设置标题
|
||||
const title = extra?.title || frontMatter?.title || file.basename;
|
||||
viewEl.createEl('h1', { text: title }, (e) => {
|
||||
e.addClass('__title__');
|
||||
e.style.display = config.showTitle ? 'block' : 'none';
|
||||
e.id = extra?.id || '';
|
||||
});
|
||||
|
||||
// 处理块引用
|
||||
const processedData = this.processBlockReferences(data || '', file);
|
||||
|
||||
// 渲染markdown - 使用 better-export-pdf 的方式
|
||||
const fragment = this.createRenderFragment();
|
||||
const promises: Array<() => Promise<unknown>> = [];
|
||||
|
||||
try {
|
||||
await MarkdownRenderer.render(app, processedData, fragment, file.path, comp);
|
||||
} catch (error) {
|
||||
// 预期的错误,用于避免postProcess
|
||||
}
|
||||
|
||||
// 添加渲染内容到viewEl
|
||||
const el = createFragment();
|
||||
Array.from(fragment.children).forEach((item) => {
|
||||
el.createDiv({}, (t) => {
|
||||
//@ts-ignore
|
||||
return t.appendChild(item);
|
||||
});
|
||||
});
|
||||
viewEl.appendChild(el);
|
||||
|
||||
// 后处理
|
||||
// @ts-ignore
|
||||
await MarkdownRenderer.postProcess(app, {
|
||||
docId: this.generateDocId(16),
|
||||
sourcePath: file.path,
|
||||
frontmatter: {},
|
||||
promises,
|
||||
addChild: function (e: Component) {
|
||||
return comp.addChild(e);
|
||||
},
|
||||
getSectionInfo: function () {
|
||||
return null;
|
||||
},
|
||||
containerEl: viewEl,
|
||||
el: viewEl,
|
||||
displayMode: true,
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// 修复内部链接
|
||||
this.fixInternalLinks(printEl, file);
|
||||
|
||||
// 等待动态内容渲染
|
||||
await this.fixWaitRender(data || '', viewEl);
|
||||
|
||||
// 修复canvas为图片
|
||||
this.fixCanvasToImage(viewEl);
|
||||
|
||||
// 创建文档
|
||||
const doc = document.implementation.createHTMLDocument('document');
|
||||
doc.body.appendChild(printEl.cloneNode(true));
|
||||
doc.title = title;
|
||||
|
||||
// 清理
|
||||
printEl.detach();
|
||||
printEl.remove();
|
||||
leaf.detach();
|
||||
|
||||
return { doc, frontMatter, file, title };
|
||||
|
||||
} finally {
|
||||
comp.unload();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并所有文档 - 对应 modal.ts 的 mergeDoc
|
||||
*/
|
||||
private mergeDoc(docs: DocType[]): DocType[] {
|
||||
if (docs.length <= 1) return docs;
|
||||
|
||||
const { doc: doc0, frontMatter, file, title } = docs[0];
|
||||
const sections = [];
|
||||
|
||||
for (const { doc } of docs) {
|
||||
const element = doc.querySelector('.markdown-preview-view');
|
||||
if (element) {
|
||||
const section = doc0.createElement('section');
|
||||
section.className = 'book-chapter';
|
||||
Array.from(element.children).forEach((child) => {
|
||||
section.appendChild(doc0.importNode(child, true));
|
||||
});
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
const root = doc0.querySelector('.markdown-preview-view');
|
||||
if (root) {
|
||||
root.innerHTML = '';
|
||||
sections.forEach((section, index) => {
|
||||
root.appendChild(section);
|
||||
|
||||
// 添加分页符(除了最后一章)
|
||||
// if (index < sections.length - 1) {
|
||||
// const pageBreak = doc0.createElement('div');
|
||||
// pageBreak.style.pageBreakAfter = 'always';
|
||||
// pageBreak.style.breakAfter = 'page';
|
||||
// pageBreak.className = 'page-break';
|
||||
// root.appendChild(pageBreak);
|
||||
// }
|
||||
});
|
||||
}
|
||||
|
||||
return [{ doc: doc0, frontMatter, file, title }];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文档注入到webview - 对应 modal.ts 的 appendWebview
|
||||
*/
|
||||
private async appendWebview(webview: electron.WebviewTag, docData: DocType): Promise<void> {
|
||||
const { doc } = docData;
|
||||
|
||||
// 使用 better-export-pdf 的安全注入方式
|
||||
const webviewJs = this.makeWebviewJs(doc);
|
||||
|
||||
try {
|
||||
await webview.executeJavaScript(webviewJs);
|
||||
} catch (error) {
|
||||
console.error('Failed to inject content to webview:', error);
|
||||
throw new Error(`Failed to render book: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成webview注入脚本 - 对应 modal.ts 的 makeWebviewJs
|
||||
*/
|
||||
private makeWebviewJs(doc: Document): string {
|
||||
// 使用更安全的方式传输HTML内容
|
||||
const bodyContent = doc.body.innerHTML;
|
||||
const headContent = doc.head.innerHTML;
|
||||
const docTitle = doc.title;
|
||||
|
||||
return `
|
||||
try {
|
||||
// 安全地设置内容
|
||||
document.body.innerHTML = decodeURIComponent(\`${encodeURIComponent(bodyContent)}\`);
|
||||
document.head.innerHTML = decodeURIComponent(\`${encodeURIComponent(headContent)}\`);
|
||||
|
||||
// 递归解码嵌入内容
|
||||
function decodeAndReplaceEmbed(element) {
|
||||
if (!element || !element.innerHTML) return;
|
||||
try {
|
||||
element.innerHTML = decodeURIComponent(element.innerHTML);
|
||||
const newEmbeds = element.querySelectorAll("span.markdown-embed");
|
||||
newEmbeds.forEach(decodeAndReplaceEmbed);
|
||||
} catch (e) {
|
||||
console.warn('Failed to decode embed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll("span.markdown-embed").forEach(decodeAndReplaceEmbed);
|
||||
|
||||
// 应用主题类名
|
||||
const currentBodyClasses = "${Array.from(document.body.classList).join(' ')}";
|
||||
const currentHtmlClasses = "${Array.from(document.documentElement.classList).join(' ')}";
|
||||
|
||||
if (currentBodyClasses) {
|
||||
document.body.className = currentBodyClasses;
|
||||
}
|
||||
if (currentHtmlClasses) {
|
||||
document.documentElement.className = currentHtmlClasses;
|
||||
}
|
||||
|
||||
// 设置主题相关属性
|
||||
const themeAttr = "${document.documentElement.getAttribute('data-theme') || ''}";
|
||||
const modeAttr = "${document.documentElement.getAttribute('data-mode') || ''}";
|
||||
|
||||
if (themeAttr) document.documentElement.setAttribute('data-theme', themeAttr);
|
||||
if (modeAttr) document.documentElement.setAttribute('data-mode', modeAttr);
|
||||
|
||||
document.body.addClass("theme-light");
|
||||
document.body.removeClass("theme-dark");
|
||||
document.title = \`${docTitle.replace(/[`\\]/g, '\\$&')}\`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in webview script:', error);
|
||||
throw error;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复文档 - 对应 render.ts 的 fixDoc
|
||||
*/
|
||||
private fixDoc(doc: Document, title: string): Document {
|
||||
this.encodeEmbeds(doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码嵌入内容 - 对应 render.ts 的 encodeEmbeds
|
||||
*/
|
||||
private encodeEmbeds(doc: Document): void {
|
||||
const spans = Array.from(doc.querySelectorAll('span.markdown-embed')).reverse();
|
||||
spans.forEach((span: HTMLElement) => {
|
||||
span.innerHTML = encodeURIComponent(span.innerHTML);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据章节节点获取文件对象
|
||||
*/
|
||||
private getFileFromNode(node: ChapterNode, book: Book, rootPath: string): TFile | null {
|
||||
const filePath = `${rootPath}/${book.basic.title}/${node.path}`;
|
||||
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||||
return file instanceof TFile ? file : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归收集所有文件类型的章节节点
|
||||
*/
|
||||
private collectFileNodes(nodes: ChapterNode[]): ChapterNode[] {
|
||||
const fileNodes: ChapterNode[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.exclude) continue;
|
||||
|
||||
if (node.type === 'file') {
|
||||
fileNodes.push(node);
|
||||
} else if (node.type === 'group' && node.children) {
|
||||
fileNodes.push(...this.collectFileNodes(node.children));
|
||||
}
|
||||
}
|
||||
|
||||
return fileNodes.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取前置元数据
|
||||
*/
|
||||
private getFrontMatter(file: TFile) {
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
return cache?.frontmatter || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取CSS类
|
||||
*/
|
||||
private extractCssClasses(frontMatter: any): string[] {
|
||||
const cssclasses: string[] = [];
|
||||
for (const [key, val] of Object.entries(frontMatter)) {
|
||||
if (key.toLowerCase() === 'cssclass' || key.toLowerCase() === 'cssclasses') {
|
||||
if (Array.isArray(val)) {
|
||||
cssclasses.push(...val);
|
||||
} else {
|
||||
cssclasses.push(val as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
return cssclasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理块引用
|
||||
*/
|
||||
private processBlockReferences(data: string, file: TFile): string {
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
const blocks = new Map(Object.entries(cache?.blocks || {}));
|
||||
|
||||
const lines = data.split('\n').map((line, i) => {
|
||||
for (const { id, position: { start, end } } of blocks.values()) {
|
||||
const blockid = `^${id}`;
|
||||
if (line.includes(blockid) && i >= start.line && i <= end.line) {
|
||||
blocks.delete(id);
|
||||
return line.replace(blockid, `<span id="${blockid}" class="blockid"></span> ${blockid}`);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
[...blocks.values()].forEach(({ id, position: { start } }) => {
|
||||
const idx = start.line;
|
||||
lines[idx] = `<span id="^${id}" class="blockid"></span>\n\n` + lines[idx];
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建渲染片段
|
||||
*/
|
||||
private createRenderFragment(): any {
|
||||
return {
|
||||
children: undefined,
|
||||
appendChild(e: DocumentFragment) {
|
||||
this.children = e?.children;
|
||||
throw new Error('exit');
|
||||
},
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复内部链接
|
||||
*/
|
||||
private fixInternalLinks(printEl: HTMLElement, file: TFile): void {
|
||||
printEl.findAll('a.internal-link').forEach((el: HTMLAnchorElement) => {
|
||||
const [title, anchor] = el.dataset.href?.split('#') || [];
|
||||
if ((!title || title?.length === 0 || title === file.basename) && anchor?.startsWith('^')) {
|
||||
return;
|
||||
}
|
||||
el.removeAttribute('href');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待动态内容渲染
|
||||
*/
|
||||
private async fixWaitRender(data: string, viewEl: HTMLElement): Promise<void> {
|
||||
if (data.includes('```dataview') || data.includes('```gEvent') || data.includes('![[')) {
|
||||
await this.sleep(2000);
|
||||
}
|
||||
try {
|
||||
await this.waitForDomChange(viewEl);
|
||||
} catch (error) {
|
||||
await this.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复Canvas为图片
|
||||
*/
|
||||
private fixCanvasToImage(el: HTMLElement): void {
|
||||
for (const canvas of Array.from(el.querySelectorAll('canvas'))) {
|
||||
const data = canvas.toDataURL();
|
||||
const img = document.createElement('img');
|
||||
img.src = data;
|
||||
img.className = '__canvas__';
|
||||
Array.from(canvas.attributes).forEach(attr => {
|
||||
img.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
canvas.replaceWith(img);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有样式
|
||||
*/
|
||||
private getAllStyles(): string[] {
|
||||
const cssTexts: string[] = [];
|
||||
|
||||
Array.from(document.styleSheets).forEach((sheet) => {
|
||||
// @ts-ignore
|
||||
const id = sheet.ownerNode?.id;
|
||||
if (id?.startsWith('svelte-')) return;
|
||||
|
||||
// @ts-ignore
|
||||
const href = sheet.ownerNode?.href;
|
||||
const division = `/* ----------${id ? `id:${id}` : href ? `href:${href}` : 'inline'}---------- */`;
|
||||
cssTexts.push(division);
|
||||
|
||||
try {
|
||||
Array.from(sheet?.cssRules || []).forEach((rule) => {
|
||||
cssTexts.push(rule.cssText);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading CSS rules:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return cssTexts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取补丁样式
|
||||
*/
|
||||
private getPatchStyles(): string[] {
|
||||
const patchCSS = `
|
||||
/* ---------- css patch ---------- */
|
||||
body {
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.print .markdown-preview-view {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.md-print-anchor, .blockid {
|
||||
white-space: pre !important;
|
||||
border: none !important;
|
||||
display: inline-block !important;
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
img.__canvas__ {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.book-chapter {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.page-break {
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
}
|
||||
`;
|
||||
|
||||
return [patchCSS];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具方法
|
||||
*/
|
||||
private generateDocId(n: number): string {
|
||||
return Array.from({ length: n }, () => ((16 * Math.random()) | 0).toString(16)).join('');
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private waitForDomChange(target: HTMLElement, timeout = 2000, interval = 200): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout;
|
||||
const observer = new MutationObserver(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(true);
|
||||
}, interval);
|
||||
});
|
||||
|
||||
observer.observe(target, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
reject(new Error(`timeout ${timeout}ms`));
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成目录 HTML
|
||||
* @param settings 目录设置
|
||||
* @param book 书籍信息
|
||||
* @returns 目录 HTML 字符串
|
||||
*/
|
||||
public generateTOC(settings: any, book: Book): string {
|
||||
if (!settings?.tocEnabled) return '';
|
||||
|
||||
// 收集所有标题
|
||||
const headings = this.collectHeadings();
|
||||
|
||||
if (headings.length === 0) return '';
|
||||
|
||||
let tocHtml = `
|
||||
<div class="table-of-contents" style="
|
||||
page-break-after: ${settings.tocPageBreak ? 'always' : 'auto'};
|
||||
font-family: ${settings.tocFontFamily || 'serif'};
|
||||
font-size: ${settings.tocFontSize}px;
|
||||
color: ${settings.tocColor || '#000000'};
|
||||
margin: 20px 0;
|
||||
line-height: ${settings.tocLineHeight};
|
||||
">
|
||||
<h1 style="text-align: center; margin-bottom: 30px;">${settings.tocTitle}</h1>
|
||||
<div class="toc-content">
|
||||
`;
|
||||
|
||||
headings.forEach(heading => {
|
||||
if (heading.level <= settings.tocMaxLevel) {
|
||||
const indent = (heading.level - 1) * (settings.tocIndent || settings.tocIndentSize || 20);
|
||||
tocHtml += `
|
||||
<div class="toc-item" style="
|
||||
margin-left: ${indent}px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
">
|
||||
<span class="toc-text">${heading.text}</span>
|
||||
<span class="toc-dots" style="
|
||||
flex: 1;
|
||||
border-bottom: 1px dotted #ccc;
|
||||
margin: 0 10px;
|
||||
height: 1px;
|
||||
align-self: center;
|
||||
"></span>
|
||||
<span class="toc-page" data-heading-id="${heading.id}">?</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
tocHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return tocHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集文档中的所有标题
|
||||
* @returns 标题数组
|
||||
*/
|
||||
private collectHeadings(): Array<{ level: number, text: string, id: string }> {
|
||||
interface Heading {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
}
|
||||
const headings: Heading[] = [];
|
||||
|
||||
// 遍历所有文档
|
||||
this.docs.forEach(docData => {
|
||||
const { doc } = docData;
|
||||
const headingElements = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
|
||||
headingElements.forEach((el, index) => {
|
||||
const level = parseInt(el.tagName.substring(1));
|
||||
const id = el.id || `heading-${index}`;
|
||||
if (!el.id) el.id = id;
|
||||
|
||||
headings.push({
|
||||
level,
|
||||
text: el.textContent?.trim() || '',
|
||||
id
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return headings;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,707 +0,0 @@
|
|||
import { App, TFile } from 'obsidian';
|
||||
import { Book, ChapterNode } from '../types/book';
|
||||
import { TypographySettings } from '../components/TypographyView';
|
||||
import { HeaderFooterTocSettings } from '../modals/HeaderFooterTocModal';
|
||||
import * as fs from "fs/promises";
|
||||
|
||||
// 导出服务类
|
||||
export class ExportService {
|
||||
private rootPath: string;
|
||||
private strategies: Record<string, ExportStrategy>;
|
||||
|
||||
constructor(private app: App, settings: any) {
|
||||
this.rootPath = settings.defaultBookPath;
|
||||
this.strategies = {
|
||||
'txt': new TxtExportStrategy(this.rootPath),
|
||||
'pdf': new PdfExportStrategy(this.rootPath),
|
||||
};
|
||||
}
|
||||
|
||||
async exportBook(format: string, book: Book, options?: ExportOptions): Promise<{ content: string, fileName: string }> {
|
||||
const strategy = this.strategies[format];
|
||||
if (!strategy) {
|
||||
throw new Error(`不支持的导出格式: ${format}`);
|
||||
}
|
||||
|
||||
const content = await strategy.export(this.app, book, options);
|
||||
return {
|
||||
content: content,
|
||||
fileName: `${book.basic.title}.${format}`
|
||||
};
|
||||
}
|
||||
|
||||
getSupportedFormats(): string[] {
|
||||
return Object.keys(this.strategies);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出格式接口
|
||||
export interface ExportStrategy {
|
||||
export(app: App, book: Book, options?: ExportOptions): Promise<string>;
|
||||
}
|
||||
|
||||
// 导出选项接口
|
||||
export interface ExportOptions {
|
||||
selectedChapters?: ChapterNode[];
|
||||
htmlContent?: HTMLElement;
|
||||
useTypography?: boolean;
|
||||
typographySettings?: TypographySettings;
|
||||
}
|
||||
|
||||
// TXT导出策略 - 实际实现
|
||||
export class TxtExportStrategy implements ExportStrategy {
|
||||
private rootPath: string;
|
||||
constructor(rootPath: string) {
|
||||
this.rootPath = rootPath;
|
||||
}
|
||||
async export(app: App, book: Book, options?: ExportOptions): Promise<string> {
|
||||
try {
|
||||
const content = await this.generateContent(app, book, options?.selectedChapters);
|
||||
|
||||
// 获取保存路径
|
||||
const filePath = await this.getOutputFile(book.basic.title);
|
||||
if (!filePath) return "cancelled";
|
||||
|
||||
// 写入文件
|
||||
await fs.writeFile(filePath, content);
|
||||
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
console.error('TXT导出错误:', error);
|
||||
throw new Error(`TXT导出失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async generateContent(app: App, book: Book, selectedChapters?: ChapterNode[]): Promise<string> {
|
||||
let content = `${book.basic.title}`;
|
||||
if (book.basic.subtitle) {
|
||||
content += `${book.basic.subtitle}`;
|
||||
}
|
||||
content += `作者: ${book.basic.author.join(', ')}`;
|
||||
|
||||
if (book.basic.desc) {
|
||||
content += `${book.basic.desc}`;
|
||||
}
|
||||
|
||||
const chapters = selectedChapters || book.structure.tree;
|
||||
content += await this.processChapters(app, book, chapters);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private async processChapters(app: App, book: Book, chapters: ChapterNode[], level: number = 0): Promise<string> {
|
||||
let content = '';
|
||||
for (const chapter of chapters) {
|
||||
if (chapter.exclude) continue;
|
||||
|
||||
// 添加章节标题
|
||||
content += `${chapter.title}`;
|
||||
if (chapter.type === 'file') {
|
||||
// 读取文件内容
|
||||
const filePath = `${this.rootPath}/${book?.basic.title}/${chapter.path}`;
|
||||
const file = app.vault.getAbstractFileByPath(filePath);
|
||||
if (file instanceof TFile) {
|
||||
const fileContent = await app.vault.read(file);
|
||||
// 处理Markdown语法,转换为纯文本
|
||||
const plainTextContent = this.convertMarkdownToPlainText(fileContent);
|
||||
content += `${plainTextContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理子章节
|
||||
if (chapter.children && chapter.children.length > 0) {
|
||||
content += await this.processChapters(app, book, chapter.children, level + 1);
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Markdown格式转换为纯文本
|
||||
* @param markdown Markdown格式的文本
|
||||
* @returns 转换后的纯文本
|
||||
*/
|
||||
private convertMarkdownToPlainText(markdown: string): string {
|
||||
let plainText = markdown;
|
||||
|
||||
// 移除标题格式 (# 标题)
|
||||
plainText = plainText.replace(/^#{1,6}\s+(.+)$/gm, '$1');
|
||||
|
||||
// 移除加粗和斜体 (**文本** 或 *文本*)
|
||||
plainText = plainText.replace(/\*\*(.+?)\*\*/g, '$1');
|
||||
plainText = plainText.replace(/\*(.+?)\*/g, '$1');
|
||||
plainText = plainText.replace(/__(.+?)__/g, '$1');
|
||||
plainText = plainText.replace(/_(.+?)_/g, '$1');
|
||||
|
||||
// 移除双链接 [[链接]] 或 [[链接|显示文本]]
|
||||
plainText = plainText.replace(/\[\[([^\|\]]+)\|([^\]]+)\]\]/g, '$2');
|
||||
plainText = plainText.replace(/\[\[([^\]]+)\]\]/g, '$1');
|
||||
|
||||
// 移除普通链接 [显示文本](链接)
|
||||
plainText = plainText.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1');
|
||||
|
||||
// 移除代码块
|
||||
plainText = plainText.replace(/```[\s\S]*?```/g, '');
|
||||
|
||||
// 移除行内代码
|
||||
plainText = plainText.replace(/`([^`]+)`/g, '$1');
|
||||
|
||||
// 移除引用块
|
||||
plainText = plainText.replace(/^>\s+(.+)$/gm, '$1');
|
||||
|
||||
// 移除水平分割线
|
||||
plainText = plainText.replace(/^-{3,}|^\*{3,}|^_{3,}/gm, '');
|
||||
|
||||
// 移除任务列表标记 [x] 或 [ ]
|
||||
plainText = plainText.replace(/^\s*- \[[x\s]\]\s+(.+)$/gm, '- $1');
|
||||
|
||||
// 处理HTML标签
|
||||
plainText = plainText.replace(/<[^>]+>/g, '');
|
||||
|
||||
// 处理转义字符
|
||||
plainText = plainText.replace(/([\\`*_{}$begin:math:display$$end:math:display$()#+\-.!])/g, '\\$1');
|
||||
|
||||
// 移除多余的空行(连续两个以上的换行符替换为两个)
|
||||
plainText = plainText.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return plainText;
|
||||
}
|
||||
|
||||
private async getOutputFile(filename: string): Promise<string | null> {
|
||||
//@ts-ignore
|
||||
const result = await electron.remote.dialog.showSaveDialog({
|
||||
title: "导出 TXT 文件",
|
||||
defaultPath: filename + ".txt",
|
||||
filters: [{ name: "TXT", extensions: ["txt"] }],
|
||||
properties: ["showOverwriteConfirmation", "createDirectory"]
|
||||
});
|
||||
|
||||
return result.canceled ? null : result.filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// PDF导出策略
|
||||
export class PdfExportStrategy implements ExportStrategy {
|
||||
private rootPath: string;
|
||||
private bookSizeMap: Record<string, { width: number; height: number }> = {
|
||||
A3: { width: 841.89, height: 1190.55 }, // 297mm x 420mm
|
||||
A4: { width: 595.28, height: 841.89 }, // 210mm x 297mm
|
||||
A5: { width: 419.53, height: 595.28 }, // 148mm x 210mm
|
||||
Legal: { width: 612, height: 1008 }, // 8.5in x 14in
|
||||
Letter: { width: 612, height: 792 }, // 8.5in x 11in
|
||||
Tabloid: { width: 792, height: 1224 } // 11in x 17in
|
||||
};
|
||||
constructor(rootPath: string) {
|
||||
this.rootPath = rootPath;
|
||||
}
|
||||
|
||||
async export(app: App, book: Book, options?: ExportOptions): Promise<string> {
|
||||
try {
|
||||
// 如果提供了排版后的 HTML 内容,使用 HTML 转 PDF 策略
|
||||
if (options?.useTypography && options?.htmlContent && options?.typographySettings) {
|
||||
return await this.exportHTML(book, options.htmlContent, options.typographySettings);
|
||||
}
|
||||
|
||||
// 否则返回一个提示信息
|
||||
return "PDF导出功能需要使用排版视图";
|
||||
} catch (error) {
|
||||
console.error('PDF导出错误:', error);
|
||||
throw new Error(`PDF导出失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async exportHTML(book: Book, htmlContent: HTMLElement, typographySettings: TypographySettings): Promise<string> {
|
||||
try {
|
||||
// 0. 处理htmlContent
|
||||
await PdfExportStrategy.processImages(htmlContent);
|
||||
|
||||
// 0.5 生成目录(如果启用)- 使用准确页码计算
|
||||
let tocHtml = '';
|
||||
if (typographySettings.headerFooterToc?.tocEnabled) {
|
||||
tocHtml = await this.generateAccurateTOC(typographySettings, htmlContent, book);
|
||||
}
|
||||
|
||||
// 1. 构建样式 CSS 字符串
|
||||
const style = `
|
||||
body {
|
||||
font-family: ${typographySettings.fontFamily || 'serif'};
|
||||
font-size: ${typographySettings.fontSize || '16px'};
|
||||
line-height: ${typographySettings.lineHeight || '1.75'};
|
||||
margin: ${typographySettings.margin || '2cm'};
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
.markdown-preview-view {
|
||||
max-width: 720px;
|
||||
margin: auto;
|
||||
}
|
||||
.table-of-contents {
|
||||
page-break-after: always;
|
||||
}
|
||||
.toc-item {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
.page-break {
|
||||
page-break-before: always;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// 2. 构建完整 HTML 页面(包含目录和分页)
|
||||
const fullHtml = `
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${book.basic.title}</title>
|
||||
<style>${style}</style>
|
||||
</head>
|
||||
<body>
|
||||
${tocHtml}
|
||||
${htmlContent.innerHTML}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
// 3. 创建窗口加载 HTML 页面
|
||||
//@ts-ignore
|
||||
const win = new electron.remote.BrowserWindow({
|
||||
show: false, // 设置为 true 可调试
|
||||
width: 1024,
|
||||
height: 768,
|
||||
webPreferences: {
|
||||
sandbox: false,
|
||||
contextIsolation: false,
|
||||
nodeIntegration: true,
|
||||
}
|
||||
});
|
||||
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
win.webContents.once("did-finish-load", resolve);
|
||||
});
|
||||
await win.loadURL(`data:text/html;charset=UTF-8,${encodeURIComponent(fullHtml)}`);
|
||||
await ready;
|
||||
|
||||
// 稍等以确保样式生效
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
// 4. 打印设置(自动分页)
|
||||
const printOptions = {
|
||||
marginsType: 1,
|
||||
pageSize: typographySettings.bookSize || "A4",
|
||||
printBackground: true,
|
||||
landscape: false,
|
||||
scale: 1.0,
|
||||
displayHeaderFooter: typographySettings.headerFooterToc?.headerEnabled || typographySettings.headerFooterToc?.footerEnabled,
|
||||
headerTemplate: this.buildHeaderTemplate(typographySettings.headerFooterToc, book),
|
||||
footerTemplate: this.buildFooterTemplate(typographySettings.headerFooterToc, book),
|
||||
};
|
||||
|
||||
// 5. 生成 PDF Buffer
|
||||
const bodyPdfBuffer = await win.webContents.printToPDF(printOptions);
|
||||
win.close();
|
||||
|
||||
// 替换第295-325行的代码
|
||||
const PDFLib = await import('pdf-lib');
|
||||
|
||||
// 检查是否有封面图片数据
|
||||
if (typographySettings.showCover && typographySettings.coverImageData) {
|
||||
// 创建封面页
|
||||
const coverDoc = await PDFLib.PDFDocument.create();
|
||||
const page = coverDoc.addPage();
|
||||
const size = this.bookSizeMap[typographySettings.bookSize || "A4"];
|
||||
page.setSize(size.width, size.height);
|
||||
|
||||
try {
|
||||
// 处理base64图片数据
|
||||
const base64Data = typographySettings.coverImageData.split(',')[1]; // 移除 "data:image/xxx;base64," 前缀
|
||||
const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
|
||||
|
||||
// 根据图片类型嵌入图片
|
||||
let coverImage;
|
||||
if (typographySettings.coverImageData.includes('data:image/png')) {
|
||||
coverImage = await coverDoc.embedPng(imageBytes);
|
||||
} else if (typographySettings.coverImageData.includes('data:image/jpeg') || typographySettings.coverImageData.includes('data:image/jpg')) {
|
||||
coverImage = await coverDoc.embedJpg(imageBytes);
|
||||
} else {
|
||||
// 默认尝试PNG格式
|
||||
coverImage = await coverDoc.embedPng(imageBytes);
|
||||
}
|
||||
|
||||
// 绘制封面图片
|
||||
page.drawImage(coverImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: page.getWidth(),
|
||||
height: page.getHeight(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('封面图片处理失败,使用默认封面:', error);
|
||||
// 如果图片处理失败,绘制默认封面
|
||||
page.drawRectangle({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: page.getWidth(),
|
||||
height: page.getHeight(),
|
||||
color: PDFLib.rgb(0.2, 0.2, 0.2)
|
||||
});
|
||||
page.drawText("Book", {
|
||||
x: page.getWidth() / 2 - 50,
|
||||
y: page.getHeight() / 2,
|
||||
size: 30,
|
||||
color: PDFLib.rgb(1, 1, 1),
|
||||
});
|
||||
}
|
||||
|
||||
const coverBuffer = await coverDoc.save();
|
||||
|
||||
// 合并封面和内容PDF
|
||||
const finalPdf = await PDFLib.PDFDocument.create();
|
||||
const [coverPage] = await finalPdf.copyPages(await PDFLib.PDFDocument.load(coverBuffer), [0]);
|
||||
finalPdf.addPage(coverPage);
|
||||
|
||||
const bodyDoc = await PDFLib.PDFDocument.load(bodyPdfBuffer);
|
||||
const bodyPages = await finalPdf.copyPages(bodyDoc, bodyDoc.getPageIndices());
|
||||
bodyPages.forEach(p => finalPdf.addPage(p));
|
||||
|
||||
const finalBuffer = await finalPdf.save();
|
||||
|
||||
const filePath = await this.getOutputFile(book.basic.title);
|
||||
if (!filePath) return "cancelled";
|
||||
await fs.writeFile(filePath, finalBuffer);
|
||||
|
||||
return filePath;
|
||||
} else {
|
||||
// 如果没有封面设置或不显示封面,创建简单的文本封面
|
||||
const coverDoc = await PDFLib.PDFDocument.create();
|
||||
const page = coverDoc.addPage();
|
||||
const size = this.bookSizeMap[typographySettings.bookSize || "A4"];
|
||||
page.setSize(size.width, size.height);
|
||||
|
||||
// 绘制默认封面
|
||||
page.drawRectangle({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: page.getWidth(),
|
||||
height: page.getHeight(),
|
||||
color: PDFLib.rgb(0.1, 0.1, 0.1)
|
||||
});
|
||||
|
||||
// 添加书名
|
||||
const titleText = "Book";
|
||||
page.drawText(titleText, {
|
||||
x: page.getWidth() / 2 - (titleText.length * 10),
|
||||
y: page.getHeight() / 2 + 50,
|
||||
size: 24,
|
||||
color: PDFLib.rgb(1, 1, 1),
|
||||
});
|
||||
|
||||
// 添加作者信息
|
||||
if (book.basic.author && book.basic.author.length > 0) {
|
||||
const authorText = book.basic.author.join(', ');
|
||||
page.drawText(authorText, {
|
||||
x: page.getWidth() / 2 - (authorText.length * 6),
|
||||
y: page.getHeight() / 2 - 50,
|
||||
size: 16,
|
||||
color: PDFLib.rgb(0.8, 0.8, 0.8),
|
||||
});
|
||||
}
|
||||
|
||||
const coverBuffer = await coverDoc.save();
|
||||
|
||||
// 合并封面和内容PDF
|
||||
const finalPdf = await PDFLib.PDFDocument.create();
|
||||
const [coverPage] = await finalPdf.copyPages(await PDFLib.PDFDocument.load(coverBuffer), [0]);
|
||||
finalPdf.addPage(coverPage);
|
||||
|
||||
const bodyDoc = await PDFLib.PDFDocument.load(bodyPdfBuffer);
|
||||
const bodyPages = await finalPdf.copyPages(bodyDoc, bodyDoc.getPageIndices());
|
||||
bodyPages.forEach(p => finalPdf.addPage(p));
|
||||
|
||||
const finalBuffer = await finalPdf.save();
|
||||
|
||||
const filePath = await this.getOutputFile(book.basic.title);
|
||||
if (!filePath) return "cancelled";
|
||||
await fs.writeFile(filePath, finalBuffer);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("PDF导出错误:", err);
|
||||
throw new Error(`PDF导出失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async processImages(container: HTMLElement): Promise<void> {
|
||||
const images = container.querySelectorAll('img');
|
||||
const imageArray = Array.from(images);
|
||||
|
||||
for (const img of imageArray) {
|
||||
try {
|
||||
const response = await fetch(img.src);
|
||||
const blob = await response.blob();
|
||||
const reader = new FileReader();
|
||||
await new Promise((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
img.src = reader.result as string;
|
||||
resolve(null);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('图片转换失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加页眉模板构建方法
|
||||
private buildHeaderTemplate(settings?: HeaderFooterTocSettings, book?: Book): string {
|
||||
if (!settings?.headerEnabled) return '';
|
||||
|
||||
return `
|
||||
<div style="
|
||||
width: 100%;
|
||||
height: ${settings.headerHeight}px;
|
||||
font-size: ${settings.headerFontSize}px;
|
||||
color: ${settings.headerColor};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid #ddd;
|
||||
">
|
||||
<span style="flex: 1; text-align: left;">${this.replaceVariables(settings.headerLeft, book)}</span>
|
||||
<span style="flex: 1; text-align: center;">${this.replaceVariables(settings.headerCenter, book)}</span>
|
||||
<span style="flex: 1; text-align: right;">${this.replaceVariables(settings.headerRight, book)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 添加页脚模板构建方法
|
||||
private buildFooterTemplate(settings?: HeaderFooterTocSettings, book?: Book): string {
|
||||
if (!settings?.footerEnabled) return '';
|
||||
|
||||
return `
|
||||
<div style="
|
||||
width: 100%;
|
||||
height: ${settings.footerHeight}px;
|
||||
font-size: ${settings.footerFontSize}px;
|
||||
color: ${settings.footerColor};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid #ddd;
|
||||
">
|
||||
<span style="flex: 1; text-align: left;">${this.replaceVariables(settings.footerLeft)}</span>
|
||||
<span style="flex: 1; text-align: center;">${this.replaceVariables(settings.footerCenter)}</span>
|
||||
<span style="flex: 1; text-align: right;">${this.replaceVariables(settings.footerRight)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// 添加变量替换方法
|
||||
private replaceVariables(text: string, book?: Book, pageNumber?: number, totalPages?: number): string {
|
||||
return text
|
||||
.replace(/\{\{title\}\}/g, book?.basic.title || '书籍标题')
|
||||
.replace(/\{\{author\}\}/g, book?.basic.author?.join(', ') || '作者')
|
||||
.replace(/\{\{date\}\}/g, new Date().toLocaleDateString())
|
||||
.replace(/\{\{pageNumber\}\}/g, pageNumber?.toString() || '<span class="pageNumber"></span>')
|
||||
.replace(/\{\{totalPages\}\}/g, totalPages?.toString() || '<span class="totalPages"></span>');
|
||||
}
|
||||
|
||||
// 新增:生成准确页码的目录
|
||||
private async generateAccurateTOC(typographySettings: TypographySettings, htmlContent: HTMLElement, book: Book): Promise<string> {
|
||||
const settings = typographySettings.headerFooterToc;
|
||||
if (!settings?.tocEnabled) return '';
|
||||
|
||||
// 获取标题到页码的映射
|
||||
const headingPageMapping = await this.getHeadingPageMapping(typographySettings, htmlContent, book);
|
||||
|
||||
if (headingPageMapping.length === 0) return '';
|
||||
|
||||
let tocHtml = `
|
||||
<div class="table-of-contents" style="
|
||||
page-break-after: always;
|
||||
font-family: ${settings.tocFontFamily || 'serif'};
|
||||
font-size: ${settings.tocFontSize}px;
|
||||
color: ${settings.tocColor || '#000000'};
|
||||
margin: 40px 0;
|
||||
">
|
||||
<h1 style="text-align: center; margin-bottom: 30px;">${settings.tocTitle}</h1>
|
||||
<div class="toc-content">
|
||||
`;
|
||||
|
||||
headingPageMapping.forEach(heading => {
|
||||
const indent = (heading.level - 1) * (settings.tocIndent || settings.tocIndentSize || 20);
|
||||
tocHtml += `
|
||||
<div class="toc-item" style="
|
||||
margin-left: ${indent}px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
">
|
||||
<span class="toc-text">${heading.text}</span>
|
||||
<span class="toc-dots" style="
|
||||
flex: 1;
|
||||
border-bottom: 1px dotted #ccc;
|
||||
margin: 0 10px;
|
||||
height: 1px;
|
||||
align-self: center;
|
||||
"></span>
|
||||
<span class="toc-page">${heading.pageNumber}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
tocHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return tocHtml;
|
||||
}
|
||||
|
||||
// 新增:获取标题页码映射
|
||||
private async getHeadingPageMapping(typographySettings: TypographySettings, htmlContent: HTMLElement, book: Book): Promise<Array<{ level: number, text: string, id: string, pageNumber: number }>> {
|
||||
const settings = typographySettings.headerFooterToc;
|
||||
|
||||
// 1. 构建用于页码计算的完整HTML(不包含目录)
|
||||
const style = `
|
||||
body {
|
||||
font-family: ${typographySettings.fontFamily || 'serif'};
|
||||
font-size: ${typographySettings.fontSize || '16px'};
|
||||
line-height: ${typographySettings.lineHeight || '1.75'};
|
||||
margin: ${typographySettings.margin || '2cm'};
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
.markdown-preview-view {
|
||||
max-width: 720px;
|
||||
margin: auto;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
.page-break {
|
||||
page-break-before: always;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const measureHtml = `
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${book.basic.title}</title>
|
||||
<style>${style}</style>
|
||||
</head>
|
||||
<body>
|
||||
${htmlContent.innerHTML}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
// 2. 创建临时窗口进行页码计算
|
||||
//@ts-ignore
|
||||
const measureWin = new electron.remote.BrowserWindow({
|
||||
show: false, // 设置为 true 可调试
|
||||
width: 1024,
|
||||
height: 768,
|
||||
webPreferences: {
|
||||
sandbox: false,
|
||||
contextIsolation: false,
|
||||
nodeIntegration: true,
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// 3. 加载HTML并等待完成
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
measureWin.webContents.once("did-finish-load", resolve);
|
||||
});
|
||||
await measureWin.loadURL(`data:text/html;charset=UTF-8,${encodeURIComponent(measureHtml)}`);
|
||||
await ready;
|
||||
await new Promise((r) => setTimeout(r, 300)); // 等待样式生效
|
||||
|
||||
// 4. 设置打印参数(与最终PDF相同)
|
||||
const printOptions = {
|
||||
marginsType: 1,
|
||||
pageSize: typographySettings.bookSize || "A4",
|
||||
printBackground: true,
|
||||
landscape: false,
|
||||
scale: 1.0,
|
||||
displayHeaderFooter: settings?.headerEnabled || settings?.footerEnabled,
|
||||
headerTemplate: this.buildHeaderTemplate(settings, book),
|
||||
footerTemplate: this.buildFooterTemplate(settings, book),
|
||||
};
|
||||
|
||||
// 5. 执行JavaScript获取标题页码信息
|
||||
const headingData = await measureWin.webContents.executeJavaScript(`
|
||||
(async () => {
|
||||
const headings = [];
|
||||
const headingElements = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
const maxLevel = ${settings?.tocMaxLevel || 3};
|
||||
|
||||
// 模拟打印环境的页面高度计算
|
||||
const printOptions = ${JSON.stringify(printOptions)};
|
||||
const pageHeight = window.innerHeight;
|
||||
const margin = parseFloat('${typographySettings.margin || '2cm'}'.replace('cm', '')) * 37.8; // cm to px
|
||||
const contentHeight = pageHeight - (margin * 2);
|
||||
|
||||
headingElements.forEach((el, index) => {
|
||||
const level = parseInt(el.tagName.substring(1));
|
||||
if (level <= maxLevel) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const elementTop = rect.top + window.pageYOffset;
|
||||
|
||||
// 计算页码(考虑页边距)
|
||||
const pageNumber = Math.max(1, Math.ceil((elementTop - margin) / contentHeight) + 1);
|
||||
|
||||
const id = el.id || \`heading-\${index}\`;
|
||||
if (!el.id) el.id = id;
|
||||
|
||||
headings.push({
|
||||
level,
|
||||
text: el.textContent?.trim() || '',
|
||||
id,
|
||||
pageNumber
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return headings;
|
||||
})()
|
||||
`);
|
||||
|
||||
return headingData;
|
||||
} finally {
|
||||
measureWin.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async getOutputFile(filename: string): Promise<string | null> {
|
||||
//@ts-ignore
|
||||
const result = await electron.remote.dialog.showSaveDialog({
|
||||
title: "导出 PDF 文件",
|
||||
defaultPath: filename + ".pdf",
|
||||
filters: [{ name: "PDF", extensions: ["pdf"] }],
|
||||
properties: ["showOverwriteConfirmation", "createDirectory"]
|
||||
});
|
||||
|
||||
return result.canceled ? null : result.filePath;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
@import './modals/manage-book-modal.css';
|
||||
@import './modals/unimported-books-modal.css';
|
||||
@import './modals/template-modal.css';
|
||||
@import './modals/book-selection-modal.css'; /* 新增 */
|
||||
@import './views/tools-view.css';
|
||||
@import './tools/focus-tool.css';
|
||||
@import './modals/base-modal.css';
|
||||
|
|
|
|||
76
src/styles/modals/book-selection-modal.css
Normal file
76
src/styles/modals/book-selection-modal.css
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/* 书籍选择模态框样式 */
|
||||
.book-selection-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin: 20px 0;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.book-selection-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.book-selection-item:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.book-selection-item.selected {
|
||||
background-color: var(--background-modifier-active-hover);
|
||||
}
|
||||
|
||||
.book-selection-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.book-selection-radio {
|
||||
margin-right: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.book-selection-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.book-selection-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.book-selection-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.book-selection-details span {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.book-selection-empty {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.book-selection-hint {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.book-selection-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
|
@ -1,135 +1,879 @@
|
|||
/* 导出模态框样式 */
|
||||
.book-smith-modal-content {
|
||||
padding: 20px;
|
||||
/* 导出模态框主体样式 */
|
||||
.export-modal {
|
||||
--export-primary: var(--interactive-accent);
|
||||
--export-primary-hover: var(--interactive-accent-hover);
|
||||
--export-secondary: var(--background-secondary);
|
||||
--export-border: var(--background-modifier-border);
|
||||
--export-radius: 8px;
|
||||
--export-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.export-modal .modal {
|
||||
max-width: 50vw;
|
||||
width: 50vw;
|
||||
height: 70vh;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
/* 模态框头部 */
|
||||
.export-modal-header {
|
||||
position: relative;
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 1px solid var(--export-border);
|
||||
background: linear-gradient(135deg, var(--background-primary) 0%, var(--background-secondary) 100%);
|
||||
}
|
||||
|
||||
.export-modal-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-normal);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 隐藏关闭按钮 */
|
||||
.export-modal-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 模态框内容 */
|
||||
.export-modal-content {
|
||||
padding: 24px;
|
||||
max-height: calc(70vh - 120px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 模态框底部 */
|
||||
.export-modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--export-border);
|
||||
background: var(--background-primary);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 主要内容区域 - 支持动态布局切换 */
|
||||
.export-modal-main {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: calc(70vh - 120px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 未选择格式时的居中布局 */
|
||||
.export-modal-main.centered {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 选择格式后的左右分栏布局 */
|
||||
.export-modal-main.split-layout {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 右侧设置面板 */
|
||||
.export-settings-panel {
|
||||
flex: 0 0 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
.book-smith-modal-header h2 {
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding-bottom: 10px;
|
||||
color: var(--text-normal);
|
||||
/* 居中布局时的设置面板 */
|
||||
.export-modal-main.centered .export-settings-panel {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* 导出内容区域 */
|
||||
.export-content {
|
||||
/* 左侧预览面板 */
|
||||
.export-preview-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--export-border);
|
||||
border-radius: var(--export-radius);
|
||||
background: var(--background-primary);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
/* 导出标题 */
|
||||
.export-title {
|
||||
/* 居中布局时隐藏预览面板 */
|
||||
.export-modal-main.centered .export-preview-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 预览头部 */
|
||||
.preview-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--export-border);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.preview-header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* 预览渲染按钮 */
|
||||
.preview-render-btn {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.preview-render-btn:hover {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.preview-render-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 预览滚动容器 - 包含封面和内容 */
|
||||
.preview-scroll-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 封面预览容器 */
|
||||
.cover-preview-container {
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--export-radius);
|
||||
background: var(--background-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 封面预览标题 */
|
||||
.cover-preview-header {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--export-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.cover-preview-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* 封面预览内容 */
|
||||
.cover-preview-content {
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 预览内容区域 */
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
height: inherit;
|
||||
overflow: hidden;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 预览状态样式 - 合并相似的状态样式 */
|
||||
.preview-placeholder,
|
||||
.preview-loading,
|
||||
.preview-error,
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.preview-placeholder-icon,
|
||||
.preview-error-icon,
|
||||
.preview-empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.preview-placeholder-text,
|
||||
.preview-error-text,
|
||||
.preview-empty-text {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--background-modifier-border);
|
||||
border-top: 3px solid var(--export-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.preview-loading-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 渲染内容包装器 */
|
||||
.rendered-content-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rendered-content-wrapper .markdown-preview-view {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 书籍信息卡片 */
|
||||
.export-book-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--export-secondary);
|
||||
border-radius: var(--export-radius);
|
||||
border: 1px solid var(--export-border);
|
||||
margin: 24px 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.export-book-card:hover {
|
||||
box-shadow: var(--export-shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.export-book-icon {
|
||||
font-size: 32px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--export-primary);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.export-book-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.export-book-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.export-book-author {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.export-book-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-faint);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 章节标题 */
|
||||
.export-section-title {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-section-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: var(--export-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 格式选择区域 */
|
||||
.export-format-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.export-format-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.export-format-card {
|
||||
padding: 16px;
|
||||
border: 2px solid var(--export-border);
|
||||
border-radius: var(--export-radius);
|
||||
background: var(--background-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.export-format-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.export-format-card:hover {
|
||||
border-color: var(--export-primary);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--export-shadow);
|
||||
}
|
||||
|
||||
.export-format-card:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.export-format-card.selected {
|
||||
border-color: var(--export-primary);
|
||||
background: var(--export-primary);
|
||||
color: white;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--export-shadow);
|
||||
}
|
||||
|
||||
.export-format-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.export-format-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.export-format-desc {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 设置区域 */
|
||||
.export-settings-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.export-settings-content {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.export-settings-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.export-placeholder-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.export-placeholder-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.export-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 设置卡片 */
|
||||
.export-setting-card {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--export-border);
|
||||
border-radius: var(--export-radius);
|
||||
background: var(--background-primary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.export-setting-card:hover {
|
||||
border-color: var(--export-primary);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.export-setting-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.export-setting-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.export-setting-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* 设置控件样式 */
|
||||
.export-setting-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-toggle-input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.export-toggle-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.export-setting-button {
|
||||
width: 50%;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--export-border);
|
||||
border-radius: 4px;
|
||||
background: var(--export-secondary);
|
||||
color: var(--text-normal);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.export-setting-button:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
border-color: var(--export-primary);
|
||||
}
|
||||
|
||||
/* 信息卡片 */
|
||||
.export-info-card {
|
||||
background: var(--background-secondary);
|
||||
border-color: var(--text-accent);
|
||||
}
|
||||
|
||||
.export-info-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 按钮组 */
|
||||
.export-button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 80px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.export-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.3s ease, height 0.3s ease;
|
||||
}
|
||||
|
||||
.export-btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.export-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.export-btn-secondary {
|
||||
background: var(--export-secondary);
|
||||
color: var(--text-normal);
|
||||
border: 1px solid var(--export-border);
|
||||
}
|
||||
|
||||
.export-btn-secondary:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
border-color: var(--export-primary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.export-btn-primary {
|
||||
background: var(--export-primary);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.export-btn-primary:hover {
|
||||
background: var(--export-primary-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.export-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 隐藏旧的action-section */
|
||||
.export-action-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 进度条样式 */
|
||||
.preview-progress {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--interactive-accent);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
.export-divider {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background-color: var(--background-modifier-border);
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
/* 格式选择容器 */
|
||||
.export-formats-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
/* 格式按钮样式 */
|
||||
.export-format-btn {
|
||||
padding: 12px 20px;
|
||||
background-color: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.export-format-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px var(--background-modifier-box-shadow);
|
||||
background-color: var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.export-format-btn.selected {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px var(--background-modifier-box-shadow);
|
||||
}
|
||||
|
||||
/* 按钮容器 */
|
||||
.export-buttons-container {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 取消按钮 */
|
||||
.export-cancel-btn {
|
||||
padding: 10px 16px;
|
||||
background-color: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
.progress-file {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.export-cancel-btn:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
transform: translateY(-2px);
|
||||
/* 预览文档样式 */
|
||||
.preview-document {
|
||||
font-size: 14px;
|
||||
max-height: none !important;
|
||||
overflow-y: visible !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* 确认按钮 */
|
||||
.export-confirm-btn {
|
||||
padding: 10px 16px;
|
||||
background-color: var(--interactive-accent-hover);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
.preview-document h1,
|
||||
.preview-document h2,
|
||||
.preview-document h3,
|
||||
.preview-document h4,
|
||||
.preview-document h5,
|
||||
.preview-document h6 {
|
||||
color: var(--text-normal);
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.export-confirm-btn:hover {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
.preview-document p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* 导出模态框标题 */
|
||||
.book-smith-modal-header {
|
||||
.preview-document img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.export-modal .modal {
|
||||
width: 85vw;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
.export-settings-panel {
|
||||
flex: 0 0 350px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.export-modal-main {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.export-preview-panel {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.export-settings-panel {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.preview-scroll-container {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.export-modal .modal {
|
||||
width: 95vw;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.export-modal-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.export-modal-footer {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.export-format-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.export-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.export-button-group {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.export-format-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.export-book-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
/* 封面容器背景图片样式 */
|
||||
.cover-container {
|
||||
background-size: 100%;
|
||||
background-position: 0px 0px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* 封面内容容器样式 */
|
||||
.cover-content {
|
||||
position: relative;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
margin-bottom: 15px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 选择导出格式标题 */
|
||||
.export-title {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin: 0 0 15px 0;
|
||||
color: var(--text-normal);
|
||||
/* 封面标题样式 */
|
||||
.cover-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 30%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 封面副标题默认样式 */
|
||||
.cover-subtitle {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
font-size: 18px;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* 封面作者样式 */
|
||||
.cover-author {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 70%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* PDF 设置部分样式 */
|
||||
.export-setting-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.export-setting-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 尺寸选择容器 */
|
||||
.size-select-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 尺寸选择下拉框 */
|
||||
.export-setting-select {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--export-border);
|
||||
background-color: var(--background-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* PDF 设置卡片 */
|
||||
.export-setting-card {
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 封面控制容器 */
|
||||
.cover-controls-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 封面切换开关 */
|
||||
.export-setting-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 封面复选框 */
|
||||
.export-toggle-input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 封面标签 */
|
||||
.export-toggle-label {
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 设置按钮 */
|
||||
.export-setting-button {
|
||||
width: auto;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background-color: var(--export-secondary);
|
||||
border: 1px solid var(--export-border);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 页眉页脚目录按钮容器 */
|
||||
.header-footer-toc-button-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 页眉页脚目录按钮 */
|
||||
.header-footer-toc-button {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background-color: var(--export-secondary);
|
||||
border: 1px solid var(--export-border);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
|
@ -6,8 +6,7 @@ import { CommunityModal } from '../modals/CommunityModal';
|
|||
import { ContactModal } from '../modals/ContactModal';
|
||||
import BookSmithPlugin from '../main';
|
||||
import { i18n } from '../i18n/i18n';
|
||||
import { TypographyView } from '../components/TypographyView';
|
||||
|
||||
import { BookSelectionModal } from '../modals/BookSelectionModal';
|
||||
interface ToolItem {
|
||||
icon: string;
|
||||
text: string;
|
||||
|
|
@ -19,7 +18,6 @@ interface ToolItem {
|
|||
export class ToolView extends ItemView {
|
||||
private normalView: HTMLElement | null = null;
|
||||
private focusView: FocusToolView | null = null;
|
||||
private typographyView: TypographyView | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, private plugin: BookSmithPlugin) {
|
||||
super(leaf);
|
||||
|
|
@ -231,25 +229,7 @@ export class ToolView extends ItemView {
|
|||
|
||||
// 添加进入排版模式的方法
|
||||
private enterTypographyMode() {
|
||||
if (!this.normalView) return;
|
||||
this.normalView.empty();
|
||||
|
||||
this.typographyView = new TypographyView(
|
||||
this.app,
|
||||
this.plugin,
|
||||
this.normalView,
|
||||
() => {
|
||||
this.typographyView?.remove();
|
||||
this.typographyView = null;
|
||||
if (this.normalView) {
|
||||
this.normalView.empty();
|
||||
this.createNormalView(this.normalView);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 初始化视图(加载书籍等)
|
||||
this.typographyView.initialize();
|
||||
new BookSelectionModal(this.app, this.plugin).open();
|
||||
}
|
||||
|
||||
// 修改 onClose 方法,确保所有视图都被正确关闭
|
||||
|
|
@ -258,10 +238,6 @@ export class ToolView extends ItemView {
|
|||
this.focusView.remove();
|
||||
this.focusView = null;
|
||||
}
|
||||
|
||||
if (this.typographyView) {
|
||||
this.typographyView.remove();
|
||||
this.typographyView = null;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue