├── .npmrc ├── .eslintignore ├── demo.gif ├── multi_pane.gif ├── utils ├── log.ts ├── info.ts └── focusManager.ts ├── .editorconfig ├── versions.json ├── manifest.json ├── .gitignore ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── package.json ├── .github └── FUNDING.yml ├── styles.css ├── LICENSE ├── esbuild.config.mjs ├── README.md └── main.ts /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1999a/obsidian-focus-plugin/HEAD/demo.gif -------------------------------------------------------------------------------- /multi_pane.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1999a/obsidian-focus-plugin/HEAD/multi_pane.gif -------------------------------------------------------------------------------- /utils/log.ts: -------------------------------------------------------------------------------- 1 | export class FocusPluginLogger { 2 | static log(level: 'Debug' | 'Info' | 'Error' , message: string) { 3 | console.log(`focus-plugin: [${level}] ${message}`); 4 | } 5 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.1.0": "0.12.0", 3 | "1.1.1": "0.12.0", 4 | "1.1.2": "0.12.0", 5 | "1.1.3": "0.12.0", 6 | "1.1.4": "0.12.0", 7 | "1.1.5": "0.12.0", 8 | "1.2.0": "0.12.0", 9 | "1.2.1": "0.12.0", 10 | "1.2.2": "0.12.0", 11 | "1.2.3": "0.12.0", 12 | "1.3.0": "0.12.0", 13 | "1.3.1": "0.12.0", 14 | "1.3.2": "0.12.0" 15 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-focus-plugin", 3 | "name": "Focus and Highlight", 4 | "version": "1.3.2", 5 | "minAppVersion": "0.12.0", 6 | "description": "A plugin for Obsidian (https://obsidian.md) that will highlight and focus on the currently selected heading", 7 | "author": "BO YI TSAI", 8 | "authorUrl": "https://github.com/nagi1999a", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-focus-plugin", 3 | "version": "1.3.2", 4 | "description": "A plugin for Obsidian (https://obsidian.md) that will highlight and focus on the currently selected heading", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "0.14.47", 20 | "obsidian": "latest", 21 | "tslib": "2.4.0", 22 | "typescript": "4.7.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://www.buymeacoffee.com/nagi1999a'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* @settings 2 | 3 | name: Focus and Highlight 4 | id: focus-plugin-css-options 5 | settings: 6 | - 7 | id: focus-plugin-opacity 8 | title: Dim Opacity 9 | type: variable-number-slider 10 | default: 0.1 11 | min: 0 12 | max: 1 13 | step: 0.01 14 | - 15 | id: focus-plugin-speed 16 | title: Dim Speed (sec) 17 | type: variable-number 18 | default: 0.5 19 | 20 | */ 21 | 22 | body.focus-plugin-enabled .focus-plugin-dimmed { 23 | opacity: var(--focus-plugin-opacity, 0.1); 24 | } 25 | 26 | body.focus-plugin-enabled .focus-plugin-focus-animation { 27 | animation: focus-plugin-keyframes calc(var(--focus-plugin-speed, 0.5) * 1s) both ease-in-out; 28 | } 29 | 30 | body.focus-plugin-enabled .focus-plugin-dim-animation { 31 | animation: focus-plugin-keyframes calc(var(--focus-plugin-speed, 0.5) * 1s) reverse both ease-in-out; 32 | } 33 | 34 | @keyframes focus-plugin-keyframes { 35 | 0% { 36 | opacity: var(--focus-plugin-opacity, 0.1); 37 | } 38 | 100% { 39 | opacity: 1; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 BO YI TSAI 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. -------------------------------------------------------------------------------- /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: ['main.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/closebrackets', 25 | '@codemirror/collab', 26 | '@codemirror/commands', 27 | '@codemirror/comment', 28 | '@codemirror/fold', 29 | '@codemirror/gutter', 30 | '@codemirror/highlight', 31 | '@codemirror/history', 32 | '@codemirror/language', 33 | '@codemirror/lint', 34 | '@codemirror/matchbrackets', 35 | '@codemirror/panel', 36 | '@codemirror/rangeset', 37 | '@codemirror/rectangular-selection', 38 | '@codemirror/search', 39 | '@codemirror/state', 40 | '@codemirror/stream-parser', 41 | '@codemirror/text', 42 | '@codemirror/tooltip', 43 | '@codemirror/view', 44 | '@lezer/common', 45 | '@lezer/highlight', 46 | '@lezer/lr', 47 | ...builtins], 48 | format: 'cjs', 49 | watch: !prod, 50 | target: 'es2016', 51 | logLevel: "info", 52 | sourcemap: prod ? false : 'inline', 53 | treeShaking: true, 54 | outfile: 'main.js', 55 | }).catch(() => process.exit(1)); 56 | -------------------------------------------------------------------------------- /utils/info.ts: -------------------------------------------------------------------------------- 1 | import { FocusPluginLogger } from 'utils/log' 2 | import { CachedMetadata } from "obsidian"; 3 | export interface FocusInfoBase { 4 | block: Element; 5 | type: string; 6 | } 7 | 8 | export interface HeaderFocusInfo extends FocusInfoBase { 9 | body: Set; 10 | content: Set; 11 | } 12 | 13 | export interface ListFocusInfo extends FocusInfoBase { 14 | target: Element; 15 | } 16 | 17 | export interface IntermediateFocusInfo extends FocusInfoBase { 18 | before: Set; 19 | after: Set; 20 | metadata: CachedMetadata | null; 21 | level: number | null; 22 | } 23 | 24 | export function isHeaderFocusInfo(info: FocusInfoBase | undefined | null): info is HeaderFocusInfo { 25 | return !!info && info.type.startsWith('H'); 26 | } 27 | 28 | export function isListFocusInfo(info: FocusInfoBase | undefined | null): info is ListFocusInfo { 29 | return !!info && info.type === 'LI'; 30 | } 31 | 32 | export function isIntermediateFocusInfo(info: FocusInfoBase | undefined | null): info is IntermediateFocusInfo { 33 | return !!info && info.type === 'UNKNOWN'; 34 | } 35 | 36 | export function toIntermediateFocusInfo(info: FocusInfoBase): IntermediateFocusInfo { 37 | return { 38 | block: info.block, 39 | type: 'UNKNOWN', 40 | before: new Set(), 41 | after: new Set(), 42 | metadata: null, 43 | level: null 44 | } 45 | } 46 | 47 | export function getFocusInfo(el: Element): HeaderFocusInfo | ListFocusInfo | IntermediateFocusInfo | null { 48 | let focusType: string | null = null; 49 | let focusBlock: Element | null = null; 50 | let focusTarget: Element | null = null; 51 | 52 | let cursor: Element | null = el; 53 | while ((cursor !== null) && !(cursor.hasClass('markdown-preview-section'))) { 54 | if (cursor.tagName.match(/^H[1-6]$/)) { 55 | focusType = cursor.tagName; 56 | } 57 | else if (cursor.tagName === 'LI') { 58 | focusType = 'LI'; 59 | focusTarget = cursor; 60 | } 61 | else if (cursor.hasClass('heading-collapse-indicator')) { 62 | // Click on collapse indicator, skipping 63 | return null; 64 | } 65 | 66 | if (cursor.parentElement?.hasClass('markdown-preview-section')) { 67 | focusBlock = cursor; 68 | break; 69 | } 70 | 71 | cursor = cursor.parentElement; 72 | } 73 | 74 | if (focusBlock === null) 75 | return null; 76 | 77 | if (focusType === null) 78 | return { 79 | before: new Set(), 80 | after: new Set(), 81 | block: focusBlock, 82 | type: 'UNKNOWN', 83 | metadata: null, 84 | level: null 85 | } 86 | else if (focusType.match(/^H[1-6]$/)) 87 | return { 88 | block: focusBlock, 89 | type: focusType, 90 | body: new Set(), 91 | content: new Set() 92 | } 93 | else if (focusType === 'LI') 94 | return { 95 | block: focusBlock, 96 | type: focusType, 97 | target: focusTarget as Element, 98 | body: new Set() 99 | } 100 | else 101 | FocusPluginLogger.log('Error', `Unexpected focus type: ${focusType}`); 102 | return null; 103 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Focus and Highlight 2 | A plugin for [obsidian](https://obsidian.md/) to focus on a specific paragraph in [Reading mode](https://help.obsidian.md/How+to/Read+and+edit+modes). 3 | 4 | ## Features 5 | - Focus on a specific heading and its children when clicking on them. 6 | - Start from v1.2.0. You can also focus on a specific paragraph by changing the setting `Content Behavior` to `Only Focus the Element`. 7 | - Start from v1.2.0. You can focus by clicking the children of a heading. 8 | - If you don't want to change your focus state when selecting the text, you can make the value of `Focus Sensitivity` smaller. 9 | 10 | ![](demo.gif) 11 | 12 | ## Usage 13 | 1. The plugin is now available in the community plugins list! You can download the plugin directly through Obsidian's `Settings > Community plugins` Tab. 14 | - You can also install with [BRAT](https://github.com/TfTHacker/obsidian42-brat), with the repository name `nagi1999a/obsidian-focus-plugin`. 15 | 2. Enable the plugin named `Focus and Highlight` in Obsidian's `Settings > Community plugins` Tab. 16 | 17 | Once the plugin is installed and enabled, you can focus on different headings by clicking on any of them. 18 | 19 | ## Options 20 | You can adjust the behavior of this plugin by accessing Obsidian's `Settings > Focus and Highlight` Tab. 21 | 22 | ### Clear Method 23 | This option affects the way to clear the focus state. 24 | 25 | #### Click Again 26 | Clear the focus state by clicking again on the focused heading. 27 | 28 | #### Click Outside 29 | Clear the focus state by clicking on the blank area at the left or right side of the reading area, which may not work correctly when `Settings > Editor > Readable line length` is turned off. 30 | 31 | ### Focus Scope 32 | This option affects the scope of the focus state. 33 | 34 | #### Only One Block 35 | Focus only on the block you clicked on. 36 | 37 | #### Also the Content 38 | Focus on the block you clicked on and related content. 39 | 40 | ### Content Behavior 41 | This option affects the behavior when clicking on the content elements, e.g. pure text, and callout block. 42 | 43 | #### Only Focus on the Element 44 | Focus only on the element you clicked on. 45 | 46 | #### Focus Related Contents 47 | Focus on the element you clicked on and related content. 48 | 49 | ### Enable List 50 | Focus on the list item (experimental, only works on the first level list) 51 | 52 | ### Focus Sensitivity 53 | Focus only when the mouse is 'not' still for a while (larger means longer). 54 | 55 | ### Style Settings 56 | With the [Style Settings](https://github.com/mgmeyers/obsidian-style-settings) plugin installed and enabled, you can further customize some visual properties under `Settings > Style Settings > Focus and Highlight`. 57 | 58 | #### Dim Opacity 59 | Set the opacity of dimmed elements. Default is 0.1. 60 | 61 | #### Dim Speed 62 | Set the speed of the animation in seconds. Default is 0.5. 63 | 64 | ## Discussion 65 | 66 | ### Behavior under Multiple Panes 67 | The plugin now supports multi-pane scenarios. Different panes will be able to focus on their headings, as the following GIF shows. 68 | 69 | ![](multi_pane.gif) 70 | 71 | ### Issues 72 | If you encounter any problems or have suggestions about the plugin, please feel free to open issues. 73 | 74 | ### TODO 75 | - [ ] Add support in edit mode. 76 | ### Support 77 | > [!NOTE] Maintaining a plugin is not an easy task. 78 | > If you like this plugin, please consider the following methods to support the author: 79 | > 1. Please give me a star! 80 | > 2. [Buy me a coffee](https://www.buymeacoffee.com/nagi1999a)! -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { App, MarkdownView, Plugin, PluginSettingTab, Setting } from 'obsidian'; 2 | import { FocusManager } from 'utils/focusManager'; 3 | import { getFocusInfo, isHeaderFocusInfo, isIntermediateFocusInfo, isListFocusInfo, toIntermediateFocusInfo } from 'utils/info'; 4 | import { FocusPluginLogger } from 'utils/log'; 5 | interface FocusPluginSettings { 6 | clearMethod: 'click-again' | 'click-outside'; 7 | contentBehavior: 'element' | 'content' | 'none'; 8 | focusScope: 'block' | 'content'; 9 | enableList: boolean; 10 | focusSensitivity: number; 11 | indicator: boolean; 12 | isEnabled: boolean; 13 | } 14 | 15 | const DEFAULT_SETTINGS: FocusPluginSettings = { 16 | clearMethod: 'click-again', 17 | contentBehavior: 'none', 18 | focusScope: 'content', 19 | enableList: false, 20 | focusSensitivity: 1600, 21 | indicator: true, 22 | isEnabled: true, 23 | } 24 | 25 | interface PaneState { 26 | mode: string; 27 | head: Element; 28 | } 29 | 30 | export default class FocusPlugin extends Plugin { 31 | settings: FocusPluginSettings; 32 | focusManager: FocusManager = new FocusManager(); 33 | lastClick: number = 0; 34 | indicator: HTMLElement | null = null; 35 | indicatorEl: HTMLElement = document.createElement("div"); 36 | 37 | private getPaneState(): PaneState | null { 38 | let view = this.app.workspace.getActiveViewOfType(MarkdownView); 39 | if (!view) 40 | return null; 41 | 42 | return { 43 | mode: view.getMode(), 44 | head: view.contentEl.querySelector('.markdown-preview-section') as Element 45 | } 46 | } 47 | 48 | async onload() { 49 | 50 | await this.loadSettings(); 51 | 52 | this.addCommand({ 53 | id: 'clear-focus', 54 | name: 'Clear Focus', 55 | callback: () => { 56 | this.focusManager.clearAll(); 57 | } 58 | }); 59 | 60 | this.addCommand({ 61 | id: 'toggle-focus-mode', 62 | name: 'Toggle Focus Mode', 63 | callback: () => { 64 | this.toggle(); 65 | } 66 | }); 67 | 68 | this.addSettingTab(new FocusPluginSettingTab(this.app, this)); 69 | 70 | this.registerEvent(this.app.workspace.on('layout-change', () => { 71 | 72 | let paneState = this.getPaneState(); 73 | if (!paneState || paneState.mode !== 'preview') 74 | return; 75 | 76 | this.focusManager.clear(paneState.head); 77 | })); 78 | 79 | this.registerEvent(this.app.workspace.on('active-leaf-change', () => { 80 | 81 | let paneState = this.getPaneState(); 82 | if (!paneState || paneState.mode !== 'preview') 83 | return; 84 | 85 | this.focusManager.changePane(paneState.head); 86 | })); 87 | 88 | this.registerDomEvent(document, 'pointerdown', (evt: PointerEvent) => { 89 | this.lastClick = evt.timeStamp; 90 | }) 91 | 92 | this.registerDomEvent(document, 'pointerup', (evt: MouseEvent) => { 93 | if (!this.settings.isEnabled) 94 | return; 95 | 96 | if (evt.timeStamp - this.lastClick > this.settings.focusSensitivity) 97 | return; 98 | 99 | if (!(evt.target instanceof Element)) 100 | return; 101 | 102 | let paneState = this.getPaneState(); 103 | if (!paneState || paneState.mode !== 'preview') 104 | return; 105 | 106 | let focusInfo = getFocusInfo(evt.target) 107 | 108 | // fallback to intermediate focus if list is disabled 109 | if (!this.settings.enableList && isListFocusInfo(focusInfo)) 110 | focusInfo = toIntermediateFocusInfo(focusInfo); 111 | 112 | if (isIntermediateFocusInfo(focusInfo) && this.settings.contentBehavior === 'none') 113 | return; 114 | 115 | let currentFocus = this.focusManager.getFocus(paneState.head); 116 | if (currentFocus !== undefined) { 117 | switch (this.settings.clearMethod) { 118 | case 'click-again': 119 | if (focusInfo && this.focusManager.isSameFocus(paneState.head, focusInfo)) { 120 | this.focusManager.clear(paneState.head); 121 | return; 122 | } 123 | break; 124 | case 'click-outside': 125 | if (evt.target.classList.contains('markdown-preview-view')) { 126 | this.focusManager.clear(paneState.head); 127 | return; 128 | } 129 | break; 130 | } 131 | } 132 | 133 | if (isIntermediateFocusInfo(focusInfo)) { 134 | let activeFile = this.app.workspace.getActiveFile(); 135 | let metadata = activeFile !== null ? this.app.metadataCache.getFileCache(activeFile) : null; 136 | if (metadata) { 137 | switch (this.settings.contentBehavior) { 138 | case 'content': 139 | focusInfo.metadata = metadata; 140 | case 'element': 141 | this.focusManager.focus(paneState.head, focusInfo); 142 | break; 143 | default: 144 | break; 145 | } 146 | } 147 | else { 148 | FocusPluginLogger.log('Error', 'No metadata found for active file'); 149 | } 150 | } 151 | else if (focusInfo != null) 152 | this.focusManager.focus(paneState.head, focusInfo); 153 | }); 154 | } 155 | 156 | onunload() { 157 | this.focusManager.destroy(); 158 | } 159 | 160 | private async settingsPreprocessor(settings: FocusPluginSettings) { 161 | this.focusManager.clearAll(); 162 | this.focusManager.includeBody = settings.focusScope === 'content'; 163 | 164 | if (settings.indicator && !this.indicator) { 165 | this.indicator = this.addStatusBarItem(); 166 | this.indicator.appendChild(this.indicatorEl); 167 | this.indicator.classList.add('mod-clickable'); 168 | this.indicator.onclick = () => this.toggle(); 169 | } 170 | else if (!settings.indicator && this.indicator) { 171 | this.indicator.remove(); 172 | this.indicator = null; 173 | } 174 | 175 | if (settings.isEnabled){ 176 | this.indicatorEl.innerHTML = 'Focus: on'; 177 | this.focusManager.init(); 178 | } 179 | else { 180 | this.indicatorEl.innerHTML = 'Focus: off'; 181 | this.focusManager.destroy(); 182 | } 183 | } 184 | 185 | async loadSettings() { 186 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 187 | await this.settingsPreprocessor(this.settings); 188 | } 189 | 190 | async saveSettings() { 191 | await this.saveData(this.settings); 192 | await this.settingsPreprocessor(this.settings); 193 | } 194 | 195 | async toggle() { 196 | this.settings.isEnabled = !this.settings.isEnabled; 197 | await this.saveSettings(); 198 | } 199 | } 200 | 201 | class FocusPluginSettingTab extends PluginSettingTab { 202 | plugin: FocusPlugin; 203 | 204 | constructor(app: App, plugin: FocusPlugin) { 205 | super(app, plugin); 206 | this.plugin = plugin; 207 | } 208 | 209 | display(): void { 210 | const { containerEl } = this; 211 | 212 | containerEl.empty(); 213 | 214 | containerEl.createEl('h2', { text: 'Focus and Highlight Settings' }); 215 | 216 | new Setting(containerEl) 217 | .setName('Enabled Focus Mode') 218 | .setDesc('Enable the focus feature') 219 | .addToggle(toggle => toggle 220 | .setValue(this.plugin.settings.isEnabled) 221 | .onChange(async (value: FocusPluginSettings["isEnabled"]) => { 222 | this.plugin.settings.isEnabled = value; 223 | await this.plugin.saveSettings(); 224 | FocusPluginLogger.log('Debug', 'isEnable changed to ' + value); 225 | })); 226 | 227 | new Setting(containerEl) 228 | .setName('Clear Method') 229 | .setDesc('How to clear the focused elements') 230 | .addDropdown(dropdown => dropdown.addOptions({ 231 | 'click-again': 'Click again', 232 | 'click-outside': 'Click outside', 233 | }) 234 | .setValue(this.plugin.settings.clearMethod) 235 | .onChange(async (value: FocusPluginSettings["clearMethod"]) => { 236 | this.plugin.settings.clearMethod = value; 237 | await this.plugin.saveSettings(); 238 | FocusPluginLogger.log('Debug', 'clear method changed to ' + value); 239 | })); 240 | 241 | new Setting(containerEl) 242 | .setName('Focus Scope') 243 | .setDesc('What to focus when clicking') 244 | .addDropdown(dropdown => dropdown.addOptions({ 245 | 'block': 'Only one block', 246 | 'content': 'Also the content' 247 | }) 248 | .setValue(this.plugin.settings.focusScope) 249 | .onChange(async (value: FocusPluginSettings["focusScope"]) => { 250 | this.plugin.settings.focusScope = value; 251 | await this.plugin.saveSettings(); 252 | FocusPluginLogger.log('Debug', 'focus scope changed to ' + value); 253 | })); 254 | 255 | new Setting(containerEl) 256 | .setName('Content Behavior') 257 | .setDesc('What to do when clicking on the content elements, e.g. pure text, callout block') 258 | .addDropdown(dropdown => dropdown.addOptions({ 259 | 'element': 'Only focus on the element', 260 | 'content': 'Focus related contents', 261 | 'none': 'Do nothing' 262 | 263 | }) 264 | .setValue(this.plugin.settings.contentBehavior) 265 | .onChange(async (value: FocusPluginSettings["contentBehavior"]) => { 266 | this.plugin.settings.contentBehavior = value; 267 | await this.plugin.saveSettings(); 268 | FocusPluginLogger.log('Debug', 'content behavior changed to ' + value); 269 | })); 270 | 271 | new Setting(containerEl) 272 | .setName('Enable List') 273 | .setDesc('Focus on the list item (experimental, only works on the first level list)') 274 | .addToggle(toggle => toggle 275 | .setValue(this.plugin.settings.enableList) 276 | .onChange(async (value: FocusPluginSettings["enableList"]) => { 277 | this.plugin.settings.enableList = value; 278 | await this.plugin.saveSettings(); 279 | FocusPluginLogger.log('Debug', 'enable list changed to ' + value); 280 | })); 281 | 282 | new Setting(containerEl) 283 | .setName('Enable Status Indicator') 284 | .setDesc('Show the status indicator in the status bar') 285 | .addToggle(toggle => toggle 286 | .setValue(this.plugin.settings.indicator) 287 | .onChange(async (value: FocusPluginSettings["indicator"]) => { 288 | this.plugin.settings.indicator = value; 289 | await this.plugin.saveSettings(); 290 | FocusPluginLogger.log('Debug', 'indicator changed to ' + value); 291 | })); 292 | 293 | new Setting(containerEl) 294 | .setName('Focus Sensitivity') 295 | .setDesc("Focus only when the mouse is 'not' still for a while (larger means longer)") 296 | .addSlider(slider => slider 297 | .setLimits(100, 10100, 500) 298 | .setValue(this.plugin.settings.focusSensitivity) 299 | .onChange(async (value: FocusPluginSettings["focusSensitivity"]) => { 300 | this.plugin.settings.focusSensitivity = value; 301 | await this.plugin.saveSettings(); 302 | FocusPluginLogger.log('Debug', 'focus delay changed to ' + value); 303 | })); 304 | 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /utils/focusManager.ts: -------------------------------------------------------------------------------- 1 | import { FocusPluginLogger } from 'utils/log' 2 | import { CachedMetadata } from 'obsidian'; 3 | import { FocusInfoBase, HeaderFocusInfo, ListFocusInfo, IntermediateFocusInfo, isHeaderFocusInfo, isListFocusInfo, isIntermediateFocusInfo } from 'utils/info'; 4 | 5 | 6 | export class FocusManager { 7 | paneInfo: WeakMap = new WeakMap(); 8 | classes: { [key: string]: string } = { 9 | 'enabled': 'focus-plugin-enabled', 10 | 'dimmed': 'focus-plugin-dimmed', 11 | 'focus-animation': 'focus-plugin-focus-animation', 12 | 'dim-animation': 'focus-plugin-dim-animation' 13 | } 14 | includeBody: boolean = true; 15 | observer: MutationObserver = new MutationObserver((mutations) => { 16 | mutations.forEach(mutation => { 17 | if (mutation.addedNodes.length > 0) { 18 | const pane = mutation.target as Element; 19 | const info = this.paneInfo.get(pane) 20 | if (!info) { 21 | this.clear(pane, false); 22 | return; 23 | } 24 | 25 | if (isIntermediateFocusInfo(info)) 26 | this.processIntermediate(pane, info, false); 27 | else 28 | this.process(pane, info, false); 29 | } 30 | }); 31 | }); 32 | 33 | constructor() { 34 | this.init(); 35 | } 36 | 37 | init() { 38 | this.clearAll(); 39 | document.body.classList.add(this.classes['enabled']); 40 | } 41 | 42 | private dim(elements: Array, animation: boolean) { 43 | if (animation) { 44 | elements.forEach(element => { 45 | if (!element.classList.contains(this.classes['dimmed'])) { 46 | element.addEventListener('animationend', () => { 47 | element.classList.remove(this.classes['dim-animation']); 48 | }, { once: true }); 49 | element.classList.add(this.classes['dim-animation']); 50 | } 51 | }); 52 | } 53 | elements.forEach(element => element.classList.add(this.classes['dimmed'])); 54 | } 55 | 56 | private undim(elements: Array, animation: boolean, children: boolean = true) { 57 | let dimmed_elements: Array = [] 58 | elements.forEach(element => { 59 | if (element.classList.contains(this.classes['dimmed'])) 60 | dimmed_elements.push(element); 61 | if (children) 62 | dimmed_elements.push(...Array.from(element.querySelectorAll(`.${this.classes['dimmed']}`))); 63 | 64 | }); 65 | if (animation) { 66 | dimmed_elements.forEach(element => { 67 | if (element.classList.contains(this.classes['dimmed'])) { 68 | element.addEventListener('animationend', () => { 69 | element.classList.remove(this.classes['focus-animation']); 70 | }, { once: true }); 71 | element.classList.add(this.classes['focus-animation']); 72 | } 73 | }); 74 | } 75 | dimmed_elements.forEach(element => element.classList.remove(this.classes['dimmed'])); 76 | } 77 | 78 | private process(pane: Element, info: FocusInfoBase, animation: boolean) { 79 | // undim block 80 | if (isHeaderFocusInfo(info)) { 81 | this.undim([info.block], animation); 82 | } 83 | else if (isListFocusInfo(info)) { 84 | this.undim([info.block], animation, false); 85 | } 86 | else { 87 | FocusPluginLogger.log('Error', 'Unknown focus info type'); 88 | } 89 | 90 | if (isHeaderFocusInfo(info)) { 91 | [info.block, ...info.body].forEach(element => { 92 | let cursor: Element | null = element.nextElementSibling; 93 | let cursorTag: string | undefined; 94 | while (cursor !== null) { 95 | cursorTag = cursor.firstElementChild?.tagName; 96 | // TODO: create a priority and check function for tags 97 | if (cursorTag && cursorTag.match(/^H[1-6]$/)) { 98 | if (this.includeBody && cursorTag > info.type) 99 | info.content.add(cursor); 100 | break; 101 | } 102 | info.body.add(cursor); 103 | cursor = cursor.nextElementSibling; 104 | } 105 | }); 106 | info.content.forEach(element => { 107 | let cursor: Element | null = element.nextElementSibling; 108 | let cursorTag: string | undefined; 109 | while (cursor !== null) { 110 | cursorTag = cursor.firstElementChild?.tagName; 111 | if (cursorTag && (cursorTag.match(/^H[1-6]$/) && (cursorTag <= info.type))) 112 | break; 113 | info.content.add(cursor); 114 | cursor = cursor.nextElementSibling; 115 | } 116 | }); 117 | this.undim([...info.body, ...info.content], animation); 118 | this.dim(Array.from(pane.children || []).filter(element => (element !== info.block) && !info.body.has(element) && !(info.content.has(element))), animation); 119 | } 120 | else if (isListFocusInfo(info)) { 121 | // undim target 122 | this.undim([info.target], animation); 123 | // dim siblings 124 | this.dim(Array.from(info.target.parentElement?.children || []).filter(element => (element !== info.target)), animation); 125 | this.dim(Array.from(pane.children || []).filter(element => (element !== info.block)), animation); 126 | } 127 | } 128 | 129 | private processIntermediate(pane: Element, info: IntermediateFocusInfo, animation: boolean = true): boolean { 130 | // undim 131 | if (info.metadata) { 132 | const after = [info.block, ...info.after]; 133 | for (const element of after) { 134 | if (element.nextElementSibling !== null) { 135 | let cursor: Element | null = element; 136 | while (cursor !== null) { 137 | if (cursor.firstElementChild?.tagName.match(/^H[1-6]$/)) { 138 | let headings = info.metadata.headings || []; 139 | let headingIndex = headings.map(heading => heading.heading).indexOf(cursor.firstElementChild.getAttribute('data-heading') as string); 140 | 141 | if (headingIndex === -1) 142 | FocusPluginLogger.log('Error', `Heading '${cursor.firstElementChild.getAttribute('data-heading')}' not found in metadata`); 143 | 144 | if (info.level === null) { 145 | if (headingIndex === 0) 146 | info.level = 0; 147 | else { 148 | let prevHeading = headings[headingIndex - 1]; 149 | info.level = prevHeading.level; 150 | } 151 | } 152 | 153 | if (headings[headingIndex].level >= info.level) { 154 | break; 155 | } 156 | 157 | info.after.add(info.block); 158 | break; 159 | } 160 | info.after.add(cursor); 161 | this.undim([cursor], animation); 162 | cursor = cursor.nextElementSibling; 163 | } 164 | } 165 | }; 166 | 167 | const before = [info.block, ...info.before]; 168 | for (const element of before) { 169 | if (element.previousElementSibling !== null) { 170 | let cursor: Element | null = element.previousElementSibling; 171 | while (cursor !== null) { 172 | if (cursor.firstElementChild?.hasAttribute('data-heading')) { 173 | let focusInfo: HeaderFocusInfo = { 174 | type: cursor.firstElementChild.tagName, 175 | block: cursor, 176 | body: new Set(), 177 | content: new Set() 178 | }; 179 | this.focus(pane, focusInfo); 180 | return true; 181 | } 182 | info.before.add(cursor); 183 | this.undim([cursor], animation); 184 | cursor = cursor.previousElementSibling; 185 | } 186 | 187 | } 188 | } 189 | } 190 | 191 | // dim siblings 192 | this.dim(Array.from(pane.children || []).filter(element => element !== info.block && !info.before.has(element) && !info.after.has(element)), animation); 193 | return false 194 | } 195 | 196 | isSameFocus(pane: Element, info: FocusInfoBase): boolean { 197 | const currentFocus = this.paneInfo.get(pane); 198 | if (!currentFocus) 199 | return false; 200 | 201 | else if (isHeaderFocusInfo(currentFocus)) { 202 | if (isHeaderFocusInfo(info)) 203 | return currentFocus.block === info.block; 204 | else if (isIntermediateFocusInfo(info)) 205 | return currentFocus.body.has(info.block); 206 | else 207 | return false; 208 | } 209 | else if (isListFocusInfo(currentFocus)) 210 | return isListFocusInfo(info) && currentFocus.target === info.target; 211 | else if (isIntermediateFocusInfo(currentFocus) ) 212 | return currentFocus.block === info.block || currentFocus.before.has(info.block) || currentFocus.after.has(info.block); 213 | else 214 | return false; 215 | } 216 | 217 | focus(pane: Element, info: FocusInfoBase) { 218 | 219 | if (isIntermediateFocusInfo(info)) { 220 | if (info.metadata === null) { 221 | this.undim([info.block], true); 222 | this.dim(Array.from(info.block.parentElement?.children || []).filter(element => (element !== info.block)), true); 223 | this.paneInfo.set(pane, info); 224 | } 225 | else { 226 | if (!this.processIntermediate(pane, info)) 227 | this.paneInfo.set(pane, info); 228 | } 229 | } 230 | else { 231 | this.process(pane, info, true); 232 | this.paneInfo.set(pane, info); 233 | } 234 | this.observer.observe(pane, { childList: true }); 235 | } 236 | 237 | changePane(pane: Element) { 238 | if (!this.paneInfo.has(pane)) 239 | return; 240 | this.observer.observe(pane, { childList: true }); 241 | } 242 | 243 | getFocus(pane: Element): FocusInfoBase | undefined { 244 | return this.paneInfo.get(pane); 245 | } 246 | 247 | clear(pane: Element, animation: boolean = true) { 248 | this.undim(Array.from(pane.querySelectorAll(`.${this.classes['dimmed']}`)), animation); 249 | this.paneInfo.delete(pane); 250 | } 251 | 252 | clearAll(animation: boolean = false) { 253 | this.undim(Array.from(document.querySelectorAll(`.${this.classes['dimmed']}`)), animation); 254 | this.paneInfo = new WeakMap(); 255 | } 256 | 257 | destroy() { 258 | this.clearAll(); 259 | document.body.classList.remove(this.classes['enabled']); 260 | } 261 | } --------------------------------------------------------------------------------