├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── esbuild.config.mjs ├── manifest.json ├── package.json ├── resources └── img1.png ├── src ├── EpubView.tsx ├── main.ts ├── styles.css └── utils.ts ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | 5 | build 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | package-lock.json 11 | 12 | # Don't include the compiled main.js file in the repo. 13 | # They should be uploaded to GitHub releases instead. 14 | main.js 15 | 16 | # Exclude sourcemaps 17 | *.map 18 | 19 | # obsidian 20 | data.json 21 | styles.css 22 | build 23 | 24 | # Exclude macOS Finder (System Explorer) View States 25 | .DS_Store 26 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 AwesomeDog 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Awesome Reader 2 | 3 | Make Obsidian a proper Reader. 4 | 5 | ## Features 6 | 7 | - 💾 Remember reading progress and sync to all devices. You can now continue reading where you left off across all 8 | devices. 9 | - ✒ Create book note from TOC(table of contents). With well organized heading formats. 10 | - ♳ Support multiple ebook formats. Currently, epub and pdf are supported. 11 | 12 | ## How to use 13 | 14 | You may want to toggle on `Detect all file extensions` first in `options -> Files & Links`. 15 | 16 | 1. Drop some ebooks(e.g. epub files) to your vault. Click it and read. 17 | 2. To create book note, click `Open/create book note` from file menu. Note will be in the same folder alongside the 18 | book. 19 | 3. Reading progress is automatically remembered in plugin's folder. And will be sync to all devices if you got "Obsidian 20 | Sync" or similar services. 21 | 22 | ![](resources/img1.png) 23 | 24 | ## Known issues 25 | 26 | - Reading progress of pdf format is not fully implemented now 27 | 28 | ## Attribution 29 | 30 | Special thanks to caronchen's 31 | marvelous [Obsidian ePub Reader Plugin](https://github.com/caronchen/obsidian-epub-plugin), 32 | some code is from this great work. 33 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules' 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === 'production'); 13 | 14 | esbuild.build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['src/main.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/collab', 25 | '@codemirror/commands', 26 | '@codemirror/language', 27 | '@codemirror/lint', 28 | '@codemirror/search', 29 | '@codemirror/state', 30 | '@codemirror/view', 31 | '@lezer/common', 32 | '@lezer/highlight', 33 | '@lezer/lr', 34 | ...builtins], 35 | format: 'cjs', 36 | watch: !prod, 37 | target: 'es2018', 38 | logLevel: "info", 39 | sourcemap: prod ? false : 'inline', 40 | treeShaking: true, 41 | outfile: 'main.js', 42 | }).catch(() => process.exit(1)); 43 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "awesome-reader", 3 | "name": "Awesome Reader", 4 | "version": "0.1.4", 5 | "minAppVersion": "0.15.0", 6 | "description": "Make Obsidian a proper Reader.", 7 | "author": "AwesomeDog", 8 | "authorUrl": "https://github.com/AwesomeDog", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-awesome-reader", 3 | "version": "0.1.0", 4 | "description": "Make Obsidian a proper Reader.", 5 | "main": "main.js", 6 | "scripts": { 7 | "clean": "del-cli -f main.js styles.css data.json build", 8 | "rename_css": "node -e \"require('fs').rename('main.css', 'styles.css', function(err) { if (err) console.log(err); console.log('File successfully renamed!') })\"", 9 | "dev": "node esbuild.config.mjs", 10 | "build": "npm run clean && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production && npm run rename_css", 11 | "release": "npm run build && mkdir build && shx cp main.js styles.css manifest.json build", 12 | "version": "node version-bump.mjs && git add manifest.json versions.json" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "MIT", 17 | "devDependencies": { 18 | "@types/node": "^16.11.6", 19 | "@typescript-eslint/eslint-plugin": "5.29.0", 20 | "@typescript-eslint/parser": "5.29.0", 21 | "@types/react": "^18.0.8", 22 | "@types/react-dom": "^18.0.3", 23 | "builtin-modules": "3.3.0", 24 | "esbuild": "0.14.47", 25 | "obsidian": "latest", 26 | "tslib": "2.4.0", 27 | "typescript": "4.7.4" 28 | }, 29 | "dependencies": { 30 | "react": "18.2.0", 31 | "react-dom": "18.2.0", 32 | "react-reader": "^1.0.2", 33 | "del-cli": "^4.0.1", 34 | "shx": "^0.3.4" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /resources/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AwesomeDog/obsidian-awesome-reader/3d6b733d67029d23edcb66af0de1624b5ff785ce/resources/img1.png -------------------------------------------------------------------------------- /src/EpubView.tsx: -------------------------------------------------------------------------------- 1 | import {FileView, Menu, TFile, WorkspaceLeaf} from "obsidian"; 2 | import * as React from 'react'; 3 | import {useState} from 'react'; 4 | import * as ReactDOM from 'react-dom'; 5 | import AwesomeReaderPlugin, {AwesomeReaderPluginSettings} from "./main"; 6 | import {getEpubTocMd, openOrCreateNote} from "./utils"; 7 | import {ReactReader, ReactReaderStyle} from "react-reader"; 8 | 9 | export const EpubReader = ({contents, title, scrolled, tocOffset, initLocation, saveLocation, tocMemo}: { 10 | contents: ArrayBuffer; 11 | title: string; 12 | scrolled: boolean; 13 | tocOffset: number; 14 | initLocation: string | number; 15 | saveLocation: Function; 16 | tocMemo: Function; 17 | }) => { 18 | const [location, setLocation] = useState(initLocation); 19 | const locationChanged = (epubcifi: string | number) => { 20 | setLocation(epubcifi); 21 | saveLocation(epubcifi); 22 | }; 23 | 24 | // @ts-ignore 25 | return
27 | tocMemo(toc)} 35 | epubOptions={scrolled ? { 36 | allowPopups: false, 37 | flow: "scrolled", 38 | manager: "continuous" 39 | } : { 40 | allowPopups: false 41 | }} 42 | readerStyles={ 43 | { 44 | ...ReactReaderStyle, 45 | tocArea: { 46 | ...ReactReaderStyle.tocArea, 47 | top: (tocOffset + 20).toString() + 'px', 48 | bottom: 0, 49 | left: 'auto', 50 | backgroundColor: 'currentColor', 51 | }, 52 | tocButtonExpanded: { 53 | ...ReactReaderStyle.tocButtonExpanded, 54 | backgroundColor: 'currentColor', 55 | } 56 | } 57 | } 58 | /> 59 |
; 60 | }; 61 | 62 | export class EpubView extends FileView { 63 | allowNoFile: false; 64 | fileToc: null; 65 | 66 | constructor(leaf: WorkspaceLeaf, private settings: AwesomeReaderPluginSettings, private plugin: AwesomeReaderPlugin) { 67 | super(leaf); 68 | } 69 | 70 | onPaneMenu(menu: Menu,): void { 71 | menu.addItem((item) => { 72 | item 73 | .setTitle("Open/create book note") 74 | .setIcon("document") 75 | .onClick(async () => { 76 | await openOrCreateNote(this.app, this.file, getEpubTocMd(this.fileToc)); 77 | }); 78 | }); 79 | } 80 | 81 | async setInitLocation(initLocation: string | number) { 82 | this.plugin.settings.bookInitLocations[this.file.path] = initLocation; 83 | await this.plugin.saveSettings(); 84 | } 85 | 86 | async getInitLocation() { 87 | const location = this.plugin.settings.bookInitLocations[this.file.path]; 88 | return location ? location : null; 89 | } 90 | 91 | 92 | async onLoadFile(file: TFile): Promise { 93 | ReactDOM.unmountComponentAtNode(this.contentEl); 94 | this.contentEl.empty(); 95 | // @ts-ignore 96 | const style = getComputedStyle(this.containerEl.parentElement.querySelector('div.view-header')); 97 | const width = parseFloat(style.width); 98 | const height = parseFloat(style.height); 99 | const tocOffset = height < width ? height : 0; 100 | 101 | const contents = await this.app.vault.readBinary(file); 102 | ReactDOM.render( 103 | { 111 | this.setInitLocation(location); 112 | }} 113 | tocMemo={(toc: any) => { 114 | this.fileToc = toc; 115 | }} 116 | />, 117 | this.contentEl 118 | ); 119 | } 120 | 121 | onunload(): void { 122 | ReactDOM.unmountComponentAtNode(this.contentEl); 123 | } 124 | 125 | canAcceptExtension(extension: string) { 126 | return extension == "epub"; 127 | } 128 | 129 | getViewType() { 130 | return "epub"; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'src/styles.css'; 2 | import {App, Menu, MenuItem, Plugin, PluginSettingTab, Setting, TFile, WorkspaceLeaf} from 'obsidian'; 3 | import {EpubView} from "./EpubView"; 4 | import {getPdfTocMd, openOrCreateNote} from "./utils"; 5 | 6 | export interface AwesomeReaderPluginSettings { 7 | scrolledView: boolean; 8 | bookInitLocations: Record; 9 | } 10 | 11 | const DEFAULT_SETTINGS: AwesomeReaderPluginSettings = { 12 | scrolledView: false, 13 | bookInitLocations: {} 14 | }; 15 | 16 | export default class AwesomeReaderPlugin extends Plugin { 17 | settings: AwesomeReaderPluginSettings; 18 | 19 | async onload() { 20 | await this.loadSettings(); 21 | 22 | this.registerView("epub", (leaf: WorkspaceLeaf) => { 23 | return new EpubView(leaf, this.settings, this); 24 | }); 25 | 26 | try { 27 | this.registerExtensions(["epub"], "epub"); 28 | } catch (error) { 29 | console.log(`registerExtensions epub failed.`); 30 | } 31 | 32 | this.registerEvent( 33 | this.app.workspace.on( 34 | "file-menu", 35 | (menu: Menu, file: TFile) => { 36 | if (file.extension.toLowerCase() === "pdf") { 37 | menu.addItem((item: MenuItem) => { 38 | item 39 | .setTitle("Open/create book note") 40 | .setIcon('document') 41 | .onClick(async () => { 42 | await openOrCreateNote(this.app, file, await getPdfTocMd(file)); 43 | }); 44 | } 45 | ); 46 | } 47 | } 48 | ), 49 | ); 50 | 51 | // This adds a settings tab so the user can configure various aspects of the plugin 52 | this.addSettingTab(new AwesomeReaderSettingTab(this.app, this)); 53 | 54 | 55 | } 56 | 57 | onunload() { 58 | 59 | } 60 | 61 | async loadSettings() { 62 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 63 | } 64 | 65 | async saveSettings() { 66 | await this.saveData(this.settings); 67 | } 68 | } 69 | 70 | class AwesomeReaderSettingTab extends PluginSettingTab { 71 | plugin: AwesomeReaderPlugin; 72 | 73 | constructor(app: App, plugin: AwesomeReaderPlugin) { 74 | super(app, plugin); 75 | this.plugin = plugin; 76 | } 77 | 78 | display(): void { 79 | const {containerEl} = this; 80 | containerEl.empty(); 81 | containerEl.createEl('h2', {text: 'Awesome Reader Settings'}); 82 | 83 | new Setting(containerEl) 84 | .setName("Scrolled View") 85 | .setDesc("This enables seamless scrolling between pages.") 86 | .addToggle(toggle => toggle 87 | .setValue(this.plugin.settings.scrolledView) 88 | .onChange(async (value) => { 89 | this.plugin.settings.scrolledView = value; 90 | await this.plugin.saveSettings(); 91 | })); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | .is-mobile div.awesome-reader-epub button { 10 | width: auto !important; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import {App, loadPdfJs, TFile, WorkspaceLeaf} from "obsidian"; 2 | 3 | export async function openOrCreateNote(app: App, file: TFile, toc: string) { 4 | const noteFilename = `${file.parent.path}/${file.basename}.md`; 5 | 6 | let noteFile = app.vault.getAbstractFileByPath(noteFilename); 7 | if (noteFile == null || !(noteFile instanceof TFile)) { 8 | noteFile = await app.vault.create( 9 | noteFilename, 10 | `---\nbookname: "${file.basename}.${file.extension}"\n---\n\n` + toc 11 | ); 12 | } 13 | const leaf = app.workspace.getMostRecentLeaf(); 14 | if (leaf instanceof WorkspaceLeaf) { 15 | const fileLeaf = app.workspace.createLeafBySplit(leaf); 16 | await fileLeaf.openFile(noteFile as TFile, {active: true}); 17 | } 18 | } 19 | 20 | export function getEpubTocMd(rawToc: any) { 21 | function dfs(node: { label: string; subitems: any; }, output: any[], depth: number) { 22 | if (!node) return; 23 | const cleanedLabel = node.label.replace(/\u0000/g, '').trim(); 24 | output.push("#".repeat(depth) + " " + cleanedLabel); 25 | for (let sub of node.subitems) { 26 | dfs(sub, output, depth + 1); 27 | } 28 | } 29 | 30 | if (!rawToc) return ""; 31 | const output: any[] = []; 32 | for (let sub of rawToc) { 33 | dfs(sub, output, 1); 34 | } 35 | return output.join("\n\n"); 36 | } 37 | 38 | export async function getPdfTocMd(file: TFile) { 39 | const pdfjsLib = await loadPdfJs(); 40 | const content = await this.app.vault.readBinary(file); 41 | const pdf = await pdfjsLib.getDocument(new Uint8Array(content)).promise; 42 | const rawToc = await pdf.getOutline(); 43 | 44 | function dfs(node: { title: string; items: any; }, output: any[], depth: number) { 45 | if (!node) return; 46 | const cleanedLabel = node.title.replace(/\u0000/g, '').trim(); 47 | output.push("#".repeat(depth) + " " + cleanedLabel); 48 | for (let sub of node.items) { 49 | dfs(sub, output, depth + 1); 50 | } 51 | } 52 | 53 | if (!rawToc) return ""; 54 | const output: any[] = []; 55 | for (let sub of rawToc) { 56 | dfs(sub, output, 1); 57 | } 58 | return output.join("\n\n"); 59 | } 60 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "jsx": "react", 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "strictNullChecks": true, 15 | "allowSyntheticDefaultImports": true, 16 | "lib": [ 17 | "DOM", 18 | "ES5", 19 | "ES6", 20 | "ES7" 21 | ] 22 | }, 23 | "include": [ 24 | "**/*.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "0.15.0", 3 | "0.1.2": "0.15.0", 4 | "0.1.3": "0.15.0", 5 | "0.1.4": "0.15.0" 6 | } 7 | --------------------------------------------------------------------------------