├── .github └── workflows │ └── build_and_release.yml ├── .gitignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── screenshot_00.png └── screenshot_01.png ├── manifest.json ├── package.json ├── rollup.config.js ├── scripts └── archive.sh ├── src ├── click.ts ├── constant.ts ├── main.ts ├── obsidian │ └── types.ts ├── open.ts ├── profile.ts ├── types.ts ├── utils.ts └── view.ts └── tsconfig.json /.github/workflows/build_and_release.yml: -------------------------------------------------------------------------------- 1 | name: build_and_release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build-and-release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: use node.js 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 16 17 | - name: update submodules 18 | run: git submodule update --init --recursive 19 | - name: build 20 | run: npm install && npm run build 21 | - name: archive 22 | run: sh scripts/archive.sh 23 | - name: draft release 24 | uses: softprops/action-gh-release@v1 25 | with: 26 | draft: true 27 | files: | 28 | obsidian-open-link-with-${{ github.ref_name }}.zip 29 | manifest.json 30 | dist/main.js 31 | tag_name: ${{ github.ref_name }} 32 | name: ${{ github.ref_name }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | 3 | /dist 4 | 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "bracketSpacing": true, 6 | "semi": false, 7 | "printWidth": 79 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.10 4 | 5 | - fixed: no longer ignores non-clickable elements with a valid pending URL [#20](https://github.com/mamoruds/obsidian-open-link-with/issues/20) 6 | - fixed: blocks internal links under the .community-modal-info class [#19](https://github.com/mamoruds/obsidian-open-link-with/issues/20) 7 | - improved: adds background to in-app view iframe [#21](https://github.com/mamoruds/obsidian-open-link-with/issues/21) 8 | 9 | ## 0.1.9 10 | 11 | - fixed: external-link click ignored under live preview mode 12 | - added: more and native [pane-type](https://github.com/obsidianmd/obsidian-api/blob/38dd22168d2925086371bfc59e36fd9121527a39/obsidian.d.ts#L2591) support for creating views 13 | - improved: using rule-based checker for `clickable` checking 14 | - improved: in-app view opening now follows Obsidian's click behaviors 15 | 16 | ## 0.1.8 17 | 18 | - fixed: multi-window handling was not correct [#16](https://github.com/mamoruds/obsidian-open-link-with/issues/16) 19 | - fixed: unloading plugin was not being handled correctly [#16](https://github.com/mamoruds/obsidian-open-link-with/issues/16) 20 | - rewrote: click handler (no longer depends on Window.open) 21 | - added: new toggle in settings panel for toggling in-app-view update focus 22 | - added: preset browsers brave and waterfox 23 | - updated: bump up the minimum version requirement to 1.1 24 | 25 | ## 0.1.7 26 | 27 | - fixed: open links not working in edit mode [#3](https://github.com/MamoruDS/obsidian-open-link-with/issues/3) 28 | - fixed: clickable elements support [#12](https://github.com/MamoruDS/obsidian-open-link-with/issues/12) 29 | - added: popout windows support [#13](https://github.com/MamoruDS/obsidian-open-link-with/issues/13) 30 | - updated: bump up the minimum version requirement to 0.15 31 | 32 | ## 0.1.6 33 | 34 | - added: two new open methods: `in-app view` and `in-app view (always new split)` suggested by [#9](https://github.com/MamoruDS/obsidian-open-link-with/issues/9) 35 | 36 | ## 0.1.5 37 | 38 | - added: custom modifier key bindings 39 | - added: middle mouse click support 40 | 41 | ## 0.1.3 42 | 43 | - added: new setting items `Logs` and `Timeout` 44 | - fixed: browser open failed due to command path [#1](https://github.com/mamoruds/obsidian-open-link-with/issues/1) 45 | 46 | ## 0.1.1 47 | 48 | - fixed: browser detecting failed on linux 49 | - added: chromium has been added to detect list 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 MamoruDS 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 | # obsidian-open-link-with 2 | 3 | [![all downloads](https://img.shields.io/github/downloads/mamoruds/obsidian-open-link-with/total?style=flat-square)](https://github.com/MamoruDS/obsidian-open-link-with) 4 | [![latest release](https://img.shields.io/github/v/release/mamoruds/obsidian-open-link-with?style=flat-square)](https://github.com/MamoruDS/obsidian-open-link-with/releases/latest) 5 | 6 | Choose your own way to open external links. 7 | 8 | ## Installation 9 | 10 | ### Manual installation 11 | 12 | Download zip archive from [releases page](https://github.com/MamoruDS/obsidian-open-link-with/releases). Extract the archive into `/.obsidian/plugins` 13 | Enable `Open Link With` under `Settings > Community plugins > Installed Plugins` 14 | 15 | ## Usage 16 | 17 | Select which browser you want to open external link with in plugin's setting menu. 18 | 19 |

20 | 21 |

22 | 23 | ### Customization 24 | 25 | Put your custom profile in plugin's settings menu. Profile should contain `name(string): commands(string[])` which is demonstrated in the following: 26 | _PS._ If the name in the user defined profile is same as the preset, it will be _ignored_. 27 | 28 | Examples: 29 | 30 |
For MacOS 31 | 32 | ```json 33 | { 34 | "waterfox": [ 35 | "/Applications/Waterfox.app/Contents/MacOS/waterfox" 36 | ], 37 | "waterfox-private": [ 38 | "/Applications/Waterfox.app/Contents/MacOS/waterfox", 39 | "--private-window" 40 | ] 41 | } 42 | ``` 43 | 44 |
45 | 46 |
For Windows 47 | 48 | ```json 49 | { 50 | "opera": [ 51 | "c:/Users/mamoru/AppData/Local/Programs/Opera/launcher.exe" 52 | ], 53 | "opera-private": [ 54 | "c:/Users/mamoru/AppData/Local/Programs/Opera/launcher.exe", 55 | "--private" 56 | ] 57 | } 58 | ``` 59 | 60 |
61 | 62 | ### Modifier key bindings 63 | 64 | The plugin supports multiple open settings by binding modifier key after version `0.1.5`. You can set the modifier key bindings to match your personal preferences through the plugin's settings menus. 65 | 66 | By default, any modifier key and any mouse button (left or middle button) click will use the _global_ browser, i.e. the browser profile selected in the setting `Browser`. You can create a custom modifier binding by clicking the `New` button and setting whether the binding is triggered only by middle mouse button clicks. You can create multiple bindings to personalize the plugin's behavior, and the bindings will be matched from top to bottom. 67 | 68 |

69 | 70 |

71 | 72 | For example, in the above setting, the link will be opened by chrome when **shift** is pressed and the **middle** mouse button is clicked; the link will be opened by safari when **shift** is pressed and the **left** mouse button is clicked; in other cases, it will be opened by _global_ browser firefox. 73 | 74 | ## Changelog 75 | 76 | [link](./CHANGELOG.md) of changelogs. 77 | -------------------------------------------------------------------------------- /assets/screenshot_00.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MamoruDS/obsidian-open-link-with/311f99343ee335cfc5a19ac365903faca9c2cfff/assets/screenshot_00.png -------------------------------------------------------------------------------- /assets/screenshot_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MamoruDS/obsidian-open-link-with/311f99343ee335cfc5a19ac365903faca9c2cfff/assets/screenshot_01.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-open-link-with", 3 | "name": "Open Link With", 4 | "version": "0.1.10", 5 | "minAppVersion": "1.1", 6 | "description": "Open external link with specific browser / in-app view in Obsidian", 7 | "author": "MamoruDS", 8 | "authorUrl": "https://github.com/MamoruDS", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-open-link-with", 3 | "version": "0.1.10", 4 | "description": "Open external link with specific browser / in-app view in Obsidian", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js --environment BUILD:production" 9 | }, 10 | "keywords": [], 11 | "author": "MamoruDS", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@rollup/plugin-commonjs": "^18.0.0", 15 | "@rollup/plugin-node-resolve": "^11.2.1", 16 | "@rollup/plugin-typescript": "^8.2.1", 17 | "@types/node": "^14.14.37", 18 | "obsidian": "^1.1.1", 19 | "rollup": "^2.32.1", 20 | "tslib": "^2.2.0", 21 | "typescript": "^4.5.5" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | const isProd = (process.env.BUILD === 'production'); 6 | 7 | export default { 8 | input: 'src/main.ts', 9 | output: { 10 | dir: 'dist', 11 | sourcemap: 'inline', 12 | sourcemapExcludeSources: isProd, 13 | format: 'cjs', 14 | exports: 'default', 15 | }, 16 | external: ['obsidian', 'child_process', 'fs', 'path', 'os'], 17 | plugins: [ 18 | typescript(), 19 | nodeResolve({ browser: true }), 20 | commonjs(), 21 | ] 22 | }; -------------------------------------------------------------------------------- /scripts/archive.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | VERSION=$(cat manifest.json|jq -r .version) 3 | 4 | rm obsidian-open-link-with.zip 2> /dev/null 5 | 6 | zip -j \ 7 | "obsidian-open-link-with-$VERSION.zip" \ 8 | "dist/main.js" \ 9 | "manifest.json" 10 | 11 | echo "done for version: $VERSION" -------------------------------------------------------------------------------- /src/click.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from 'obsidian' 2 | 3 | import { 4 | Clickable, 5 | Modifier, 6 | MWindow, 7 | OpenLinkPluginITF, 8 | Rule as MR, 9 | } from './types' 10 | import { 11 | genRandomStr, 12 | getModifiersFromMouseEvt, 13 | getValidHttpURL, 14 | log, 15 | RulesChecker, 16 | WindowUtils, 17 | } from './utils' 18 | 19 | const checkClickable = (el: Element): Clickable => { 20 | const res = { 21 | is_clickable: false, 22 | url: null, // url is safe to be null when `oolwPendingUrls` is not empty 23 | paneType: undefined, 24 | modifier_rules: [], 25 | } as Clickable 26 | const CTRL = Platform.isMacOS ? Modifier.Meta : Modifier.Ctrl 27 | const ALT = Modifier.Alt 28 | const SHIFT = Modifier.Shift 29 | // - links in read mode 30 | if (el.classList.contains('external-link')) { 31 | res.is_clickable = true 32 | res.url = el.getAttribute('href') 33 | res.modifier_rules = [ 34 | new MR.Exact([CTRL], 'tab'), 35 | new MR.Exact([CTRL, ALT], 'split'), 36 | new MR.Exact([CTRL, SHIFT], 'tab'), 37 | new MR.Exact([CTRL, ALT, SHIFT], 'window'), 38 | new MR.Contains([], undefined), // fallback 39 | ] 40 | } 41 | // - 42 | if (el.classList.contains('clickable-icon')) { 43 | // res.is_clickable = true 44 | // res.paneType = 'window' 45 | } 46 | // - links in live preview mode 47 | if (el.classList.contains('cm-underline')) { 48 | res.is_clickable = null 49 | // res.url = // determined by `window._builtInOpen` 50 | res.modifier_rules = [ 51 | new MR.Empty(undefined), 52 | new MR.Exact([CTRL], 'tab'), 53 | new MR.Exact([CTRL, ALT], 'split'), 54 | new MR.Exact([CTRL, SHIFT], 'tab'), 55 | new MR.Exact([CTRL, ALT, SHIFT], 'window'), 56 | ] 57 | } 58 | // - links in edit mode 59 | if (el.classList.contains('cm-url')) { 60 | res.is_clickable = null 61 | // res.url = // determined by `window._builtInOpen` 62 | res.modifier_rules = [ 63 | new MR.Exact([CTRL], undefined), 64 | new MR.Exact([CTRL, ALT], 'split'), 65 | new MR.Exact([CTRL, SHIFT], 'tab'), 66 | new MR.Exact([CTRL, ALT, SHIFT], 'window'), 67 | ] 68 | } 69 | // - links in community plugins' readme 70 | if (res.is_clickable === false && el.tagName === 'A') { 71 | let p = el 72 | while (p.tagName !== 'BODY') { 73 | if (p.classList.contains('internal-link')) { 74 | break 75 | } else if (p.classList.contains('community-modal-info')) { 76 | res.is_clickable = true 77 | res.url = el.getAttribute('href') 78 | res.paneType = 79 | el.getAttribute('target') === '_blank' 80 | ? 'window' 81 | : res.paneType 82 | break 83 | } 84 | p = p.parentElement 85 | } 86 | } 87 | return res 88 | } 89 | 90 | class LocalDocClickHandler { 91 | private _enabled: boolean 92 | private _handleAuxClick: boolean 93 | constructor(public clickUilts: ClickUtils) { 94 | this._enabled = false 95 | this._handleAuxClick = false 96 | } 97 | get enabled(): boolean { 98 | return this._enabled 99 | } 100 | set enabled(val: boolean) { 101 | this._enabled = val 102 | } 103 | get handleAuxClick(): boolean { 104 | return this._handleAuxClick 105 | } 106 | set handleAuxClick(val: boolean) { 107 | this._handleAuxClick = val 108 | } 109 | call(evt: MouseEvent) { 110 | const win = evt.doc.win as MWindow 111 | if (typeof win.mid !== 'undefined' && this._enabled) { 112 | this._handler(evt) 113 | } else { 114 | // plugin has been unloaded 115 | // or 116 | // click handler is disabled 117 | } 118 | } 119 | protected _handler(evt: MouseEvent) { 120 | const el = evt.target as Element 121 | const win = evt.doc.win as MWindow 122 | const modifiers = getModifiersFromMouseEvt(evt) 123 | const clickable = checkClickable(el) 124 | let fire = true 125 | let url: string = clickable.url 126 | if (win.oolwPendingUrls.length > 0) { 127 | // win.oolwPendingUrls for getting correct urls from default open API 128 | url = win.oolwPendingUrls.pop() 129 | } else { 130 | // for urls could be invalid (inner links) 131 | if (url !== null && !getValidHttpURL(url)) { 132 | fire = false 133 | win._builtInOpen(url) 134 | } 135 | } 136 | if (clickable.is_clickable === false && url === null) { 137 | return false 138 | } 139 | let { paneType } = clickable 140 | if (url === null) { 141 | fire = false 142 | } 143 | if (clickable.modifier_rules.length > 0) { 144 | const checker = new RulesChecker(clickable.modifier_rules) 145 | const matched = checker.check(modifiers, { 146 | breakOnFirstSuccess: true, 147 | }) 148 | if (matched.length == 0) { 149 | if (clickable.is_clickable) { 150 | // 151 | } else { 152 | fire = false 153 | } 154 | } else if (matched[0] === false) { 155 | fire = false 156 | } else if (typeof matched[0] === 'undefined') { 157 | paneType = undefined 158 | } else { 159 | paneType = matched[0] 160 | } 161 | } 162 | // apply on middle click only 163 | if (this.handleAuxClick && evt.button === 2) { 164 | fire = false 165 | } 166 | evt.preventDefault() 167 | if (this.clickUilts._plugin.settings.enableLog) { 168 | log('info', 'click event (LocalDocClickHandler)', { 169 | is_aux: this.handleAuxClick, 170 | clickable, 171 | url, 172 | modifiers, 173 | btn: evt.button, 174 | }) 175 | } 176 | if (!fire) { 177 | return false 178 | } 179 | const dummy = evt.doc.createElement('a') 180 | const cid = genRandomStr(4) 181 | dummy.setAttribute('href', url) 182 | dummy.setAttribute('oolw-pane-type', paneType || '') 183 | dummy.setAttribute('oolw-cid', cid) 184 | dummy.addClass('oolw-external-link-dummy') 185 | evt.doc.body.appendChild(dummy) 186 | // 187 | const e_cp = new MouseEvent(evt.type, evt) 188 | dummy.dispatchEvent(e_cp) 189 | dummy.remove() 190 | } 191 | } 192 | 193 | class ClickUtils { 194 | private _localHandlers: Record< 195 | string, 196 | { 197 | click: LocalDocClickHandler 198 | auxclick: LocalDocClickHandler 199 | } 200 | > 201 | constructor( 202 | public _plugin: OpenLinkPluginITF, 203 | private _windowUtils: WindowUtils 204 | ) { 205 | this._localHandlers = {} 206 | } 207 | initDocClickHandler(win: MWindow) { 208 | if (!this._localHandlers.hasOwnProperty(win.mid)) { 209 | const clickHandler = new LocalDocClickHandler(this) 210 | clickHandler.enabled = true 211 | const auxclickHandler = new LocalDocClickHandler(this) 212 | auxclickHandler.enabled = true 213 | auxclickHandler.handleAuxClick = true 214 | // 215 | win.document.addEventListener( 216 | 'click', 217 | clickHandler.call.bind(clickHandler) 218 | ) 219 | win.document.addEventListener( 220 | 'auxclick', 221 | auxclickHandler.call.bind(auxclickHandler) 222 | ) 223 | // 224 | this._localHandlers[win.mid] = { 225 | click: clickHandler, 226 | auxclick: auxclickHandler, 227 | } 228 | } 229 | } 230 | removeDocClickHandler(win: MWindow) { 231 | if (this._localHandlers.hasOwnProperty(win.mid)) { 232 | const handlers = this._localHandlers[win.mid] 233 | handlers.click.enabled = false 234 | handlers.auxclick.enabled = false 235 | 236 | win.document.removeEventListener( 237 | 'click', 238 | handlers.click.call.bind(handlers.click) 239 | ) 240 | win.document.removeEventListener( 241 | 'auxclick', 242 | handlers.auxclick.call.bind(handlers.auxclick) 243 | ) 244 | delete this._localHandlers[win.mid] 245 | } 246 | } 247 | overrideDefaultWindowOpen(win: MWindow, enabled: boolean = true) { 248 | if (enabled && typeof win._builtInOpen === 'undefined') { 249 | win._builtInOpen = win.open 250 | win.oolwCIDs = [] 251 | win.oolwPendingUrls = [] 252 | win.open = (url, target, feature) => { 253 | if (this._plugin.settings.enableLog) { 254 | log('info', 'Obsidian.window._builtInOpen', { 255 | url, 256 | target, 257 | feature, 258 | }) 259 | } 260 | const validUrl = getValidHttpURL(url) 261 | if (validUrl === null) { 262 | return win._builtInOpen(url, target, feature) 263 | } else { 264 | win.oolwPendingUrls.push(validUrl) 265 | return win 266 | } 267 | } 268 | } else if (!enabled && typeof win._builtInOpen !== 'undefined') { 269 | win.open = win._builtInOpen 270 | delete win._builtInOpen 271 | delete win.oolwCIDs 272 | delete win.oolwPendingUrls 273 | } 274 | } 275 | } 276 | 277 | export { ClickUtils } 278 | -------------------------------------------------------------------------------- /src/constant.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process' 2 | import { existsSync } from 'fs' 3 | import * as path from 'path' 4 | 5 | import { 6 | BrowserProfile, 7 | Platform, 8 | ProfileDisplay, 9 | ValidModifier, 10 | } from './types' 11 | 12 | const BROWSER_SYSTEM: ProfileDisplay = { 13 | val: '_system', 14 | display: 'system-default', 15 | } 16 | const BROWSER_GLOBAL: ProfileDisplay = { 17 | val: '_global', 18 | display: 'global', 19 | } 20 | 21 | const BROWSER_IN_APP: ProfileDisplay = { 22 | val: '_in_app', 23 | display: 'in-app view (always new split)', 24 | } 25 | 26 | const BROWSER_IN_APP_LAST: ProfileDisplay = { 27 | val: '_in_app_last', 28 | display: 'in-app view', 29 | } 30 | 31 | const _isExecutableExist = async (fp: string): Promise => { 32 | return existsSync(fp) 33 | } 34 | 35 | const _isExecutableAvailable = async (exec: string): Promise => { 36 | return spawnSync('which', [exec]).status === 0 37 | } 38 | 39 | const PRESET_BROWSERS = { 40 | safari: { 41 | darwin: { 42 | sysCmd: 'open', 43 | sysArgs: ['-a'], 44 | cmd: 'safari', 45 | optional: {}, 46 | isAvailable: async (b) => { 47 | return true 48 | }, 49 | }, 50 | }, 51 | firefox: { 52 | darwin: { 53 | cmd: path.join( 54 | '/Applications', 55 | 'Firefox.app', 56 | 'Contents', 57 | 'MacOS', 58 | 'firefox' 59 | ), 60 | optional: { 61 | private: { 62 | args: ['--private-window'], 63 | }, 64 | }, 65 | isAvailable: async (b) => _isExecutableExist(b.cmd), 66 | }, 67 | linux: { 68 | cmd: 'firefox', 69 | optional: { 70 | private: { 71 | args: ['--private-window'], 72 | }, 73 | }, 74 | isAvailable: async (b) => _isExecutableAvailable(b.cmd), 75 | }, 76 | win32: { 77 | cmd: path.join( 78 | 'c:', 79 | 'Program Files', 80 | 'Mozilla Firefox', 81 | 'firefox.exe' 82 | ), 83 | optional: { 84 | private: { 85 | args: ['--private-window'], 86 | }, 87 | }, 88 | isAvailable: async (b) => _isExecutableExist(b.cmd), 89 | }, 90 | }, 91 | chrome: { 92 | darwin: { 93 | cmd: path.join( 94 | '/Applications', 95 | 'Google Chrome.app', 96 | 'Contents', 97 | 'MacOS', 98 | 'Google Chrome' 99 | ), 100 | optional: { 101 | private: { 102 | args: ['-incognito'], 103 | }, 104 | }, 105 | isAvailable: async (b) => _isExecutableExist(b.cmd), 106 | }, 107 | linux: { 108 | cmd: 'google-chrome', 109 | optional: { 110 | private: { 111 | args: ['-incognito'], 112 | }, 113 | }, 114 | isAvailable: async (b) => _isExecutableAvailable(b.cmd), 115 | }, 116 | win32: { 117 | cmd: path.join( 118 | 'c:', 119 | 'Program Files (x86)', 120 | 'Google', 121 | 'Chrome', 122 | 'Application', 123 | 'chrome.exe' 124 | ), 125 | optional: { 126 | private: { 127 | args: ['-incognito'], 128 | }, 129 | }, 130 | isAvailable: async (b) => _isExecutableExist(b.cmd), 131 | }, 132 | }, 133 | chromium: { 134 | darwin: { 135 | cmd: path.join( 136 | '/Applications', 137 | 'Chromium.app', 138 | 'Contents', 139 | 'MacOS', 140 | 'Chromium' 141 | ), 142 | optional: { 143 | private: { 144 | args: ['-incognito'], 145 | }, 146 | }, 147 | isAvailable: async (b) => _isExecutableExist(b.cmd), 148 | }, 149 | linux: { 150 | cmd: 'chromium-browser', 151 | optional: { 152 | private: { 153 | args: ['-incognito'], 154 | }, 155 | }, 156 | isAvailable: async (b) => _isExecutableAvailable(b.cmd), 157 | }, 158 | }, 159 | edge: { 160 | darwin: { 161 | cmd: path.join( 162 | '/Applications', 163 | 'Microsoft Edge.app', 164 | 'Contents', 165 | 'MacOS', 166 | 'Microsoft Edge' 167 | ), 168 | optional: { 169 | private: { 170 | args: ['-inprivate'], 171 | }, 172 | }, 173 | isAvailable: async (b) => _isExecutableExist(b.cmd), 174 | }, 175 | win32: { 176 | cmd: path.join( 177 | 'c:', 178 | 'Program Files (x86)', 179 | 'Microsoft', 180 | 'Edge', 181 | 'Application', 182 | 'msedge.exe' 183 | ), 184 | optional: { 185 | private: { 186 | args: ['-inprivate'], 187 | }, 188 | }, 189 | isAvailable: async (b) => _isExecutableExist(b.cmd), 190 | }, 191 | }, 192 | brave: { 193 | darwin: { 194 | cmd: path.join( 195 | '/Applications', 196 | 'Brave Browser.app', 197 | 'Contents', 198 | 'MacOS', 199 | 'Brave Browser' 200 | ), 201 | optional: { 202 | private: { 203 | args: ['-incognito'], 204 | }, 205 | }, 206 | isAvailable: async (b) => _isExecutableExist(b.cmd), 207 | }, 208 | linux: { 209 | cmd: 'brave-browser', 210 | optional: { 211 | private: { 212 | args: ['-incognito'], 213 | }, 214 | }, 215 | isAvailable: async (b) => _isExecutableAvailable(b.cmd), 216 | }, 217 | win32: { 218 | cmd: path.join( 219 | 'c:', 220 | 'Program Files', 221 | 'BraveSoftware', 222 | 'Brave-Browser', 223 | 'Application', 224 | 'brave.exe' 225 | ), 226 | optional: { 227 | private: { 228 | args: ['-incognito'], 229 | }, 230 | }, 231 | isAvailable: async (b) => _isExecutableExist(b.cmd), 232 | }, 233 | }, 234 | waterfox: { 235 | darwin: { 236 | cmd: path.join( 237 | '/Applications', 238 | 'Waterfox.app', 239 | 'Contents', 240 | 'MacOS', 241 | 'Waterfox' 242 | ), 243 | optional: { 244 | private: { 245 | args: ['-private-window'], 246 | }, 247 | }, 248 | isAvailable: async (b) => _isExecutableExist(b.cmd), 249 | }, 250 | linux: { 251 | cmd: 'waterfox', 252 | optional: { 253 | private: { 254 | args: ['-private-window'], 255 | }, 256 | }, 257 | isAvailable: async (b) => _isExecutableAvailable(b.cmd), 258 | }, 259 | win32: { 260 | cmd: path.join('c:', 'Program Files', 'Waterfox', 'waterfox.exe'), 261 | optional: { 262 | private: { 263 | args: ['-private-window'], 264 | }, 265 | }, 266 | isAvailable: async (b) => _isExecutableExist(b.cmd), 267 | }, 268 | }, 269 | } as Record>> 270 | 271 | const MODIFIER_TEXT_FALLBACK: Record = { 272 | none: 'None', 273 | meta: 'Meta', 274 | alt: 'Alt', 275 | ctrl: 'Ctrl', 276 | shift: 'Shift', 277 | } 278 | 279 | const MODIFIER_TEXT: Partial< 280 | Record>> 281 | > = { 282 | mac: { 283 | meta: 'Cmd⌘', 284 | alt: 'Option⌥', 285 | ctrl: 'Control⌃', 286 | shift: 'Shift⇧', 287 | }, 288 | win: { 289 | meta: 'Windows', 290 | }, 291 | } 292 | 293 | export { 294 | BROWSER_SYSTEM, 295 | BROWSER_GLOBAL, 296 | BROWSER_IN_APP, 297 | BROWSER_IN_APP_LAST, 298 | MODIFIER_TEXT, 299 | MODIFIER_TEXT_FALLBACK, 300 | PRESET_BROWSERS, 301 | } 302 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | debounce, 4 | Debouncer, 5 | Modal, 6 | PaneType, 7 | Plugin, 8 | PluginSettingTab, 9 | Setting, 10 | } from 'obsidian' 11 | 12 | import { ClickUtils } from './click' 13 | import { 14 | BROWSER_SYSTEM, 15 | BROWSER_GLOBAL, 16 | BROWSER_IN_APP, 17 | BROWSER_IN_APP_LAST, 18 | MODIFIER_TEXT, 19 | MODIFIER_TEXT_FALLBACK, 20 | } from './constant' 21 | import { openWith } from './open' 22 | import { ProfileMgr } from './profile' 23 | import { 24 | Modifier, 25 | ModifierBinding, 26 | MouseButton, 27 | MWindow, 28 | Optional, 29 | OpenLinkPluginITF, 30 | Platform, 31 | PluginSettings, 32 | ProfileDisplay, 33 | ValidModifier, 34 | ProfileMgrITF, 35 | } from './types' 36 | import { 37 | genRandomStr, 38 | getModifiersFromMouseEvt, 39 | getPlatform, 40 | getValidModifiers, 41 | log, 42 | WindowUtils, 43 | } from './utils' 44 | import { ViewMgr, ViewMode, ViewRec } from './view' 45 | 46 | const DEFAULT_SETTINGS: PluginSettings = { 47 | selected: BROWSER_SYSTEM.val, 48 | custom: {}, 49 | modifierBindings: [], 50 | enableLog: false, 51 | timeout: 500, 52 | inAppViewRec: [], 53 | } 54 | 55 | export default class OpenLinkPlugin 56 | extends Plugin 57 | implements OpenLinkPluginITF 58 | { 59 | public settings: PluginSettings 60 | public profiles: ProfileMgrITF 61 | private _clickUtils?: ClickUtils 62 | private _windowUtils?: WindowUtils 63 | private _viewmgr: ViewMgr 64 | async onload(): Promise { 65 | this._viewmgr = new ViewMgr(this) 66 | await this.loadSettings() 67 | this.profiles = new ProfileMgr() 68 | await this.profiles.loadValidPresetBrowsers() 69 | const extLinkClick = async ( 70 | evt: MouseEvent, 71 | validClassName: string, 72 | options: { 73 | allowedButton?: MouseButton 74 | } = {} 75 | ): Promise => { 76 | const win = activeWindow as MWindow 77 | const el = evt.target as Element 78 | if (!el.classList.contains(validClassName)) { 79 | return 80 | } 81 | const oolwCID = el.getAttribute('oolw-cid') 82 | if (typeof oolwCID !== 'undefined') { 83 | if (win.oolwCIDs.contains(oolwCID)) { 84 | return // FIXME: prevent double click 85 | } else { 86 | win.oolwCIDs.push(oolwCID) 87 | setTimeout(() => { 88 | win.oolwCIDs.remove(oolwCID) 89 | }, 10) 90 | } 91 | } 92 | const { button, altKey, ctrlKey, metaKey, shiftKey } = evt 93 | let modifier: ValidModifier = 'none' 94 | if (altKey) { 95 | modifier = 'alt' 96 | } else if (ctrlKey) { 97 | modifier = 'ctrl' 98 | } else if (metaKey) { 99 | modifier = 'meta' 100 | } else if (shiftKey) { 101 | modifier = 'shift' 102 | } 103 | // const modifiers = getModifiersFromMouseEvt(evt) 104 | const url = el.getAttr('href') 105 | const matchedMB: ModifierBinding | undefined = 106 | this.settings.modifierBindings.find((mb) => { 107 | if (mb.auxClickOnly && button != MouseButton.Auxiliary) { 108 | return false 109 | } else { 110 | return mb.modifier === modifier 111 | } 112 | }) 113 | const profileName = matchedMB?.browser ?? this.settings.selected 114 | const paneType = 115 | el.getAttr('target') === '_blank' 116 | ? 'window' // higher priority 117 | : (el.getAttr('oolw-pane-type') as PaneType) || undefined 118 | const cmd = this._getOpenCMD(profileName) 119 | if (this.settings.enableLog) { 120 | log('info', 'click event (extLinkClick)', { 121 | click: { 122 | button, 123 | altKey, 124 | ctrlKey, 125 | metaKey, 126 | shiftKey, 127 | }, 128 | el, 129 | modifier, 130 | mouseEvent: evt, 131 | win: evt.doc.win, 132 | mid: (evt.doc.win as MWindow).mid, 133 | url, 134 | profileName, 135 | paneType, 136 | cmd, 137 | matchedBinding: matchedMB, 138 | }) 139 | } 140 | // right click trigger (windows only) 141 | if ( 142 | typeof options.allowedButton != 'undefined' && 143 | button != options.allowedButton 144 | ) { 145 | return 146 | } 147 | // in-app view 148 | if (profileName === BROWSER_IN_APP.val) { 149 | evt.preventDefault() 150 | this._viewmgr.createView(url, ViewMode.NEW, { 151 | focus: matchedMB?.focusOnView, 152 | paneType, 153 | }) 154 | return 155 | } 156 | if (profileName === BROWSER_IN_APP_LAST.val) { 157 | evt.preventDefault() 158 | this._viewmgr.createView(url, ViewMode.LAST, { 159 | focus: matchedMB?.focusOnView, 160 | paneType, 161 | }) 162 | return 163 | } 164 | if (typeof cmd !== 'undefined') { 165 | evt.preventDefault() 166 | const code = await openWith(url, cmd, { 167 | enableLog: this.settings.enableLog, 168 | timeout: this.settings.timeout, 169 | }) 170 | if (code !== 0) { 171 | if (this.settings.enableLog) { 172 | log( 173 | 'error', 174 | 'failed to open', 175 | `'spawn' exited with code ${code} when ` + 176 | `trying to open an external link with ${profileName}.` 177 | ) 178 | } 179 | win._builtInOpen(url) 180 | } 181 | } else { 182 | win._builtInOpen(url) 183 | } 184 | } 185 | // 186 | this.addSettingTab(new SettingTab(this.app, this)) 187 | // 188 | this._windowUtils = new WindowUtils(this) 189 | this._clickUtils = new ClickUtils(this, this._windowUtils) 190 | const initWindow = (win: MWindow) => { 191 | this._windowUtils.registerWindow(win) 192 | this._clickUtils.overrideDefaultWindowOpen(win, true) 193 | this._clickUtils.initDocClickHandler(win) 194 | this.registerDomEvent(win, 'click', (evt) => { 195 | return extLinkClick(evt, 'oolw-external-link-dummy', { 196 | allowedButton: MouseButton.Main, 197 | }) 198 | }) 199 | this.registerDomEvent(win, 'auxclick', (evt) => { 200 | return extLinkClick(evt, 'oolw-external-link-dummy', { 201 | allowedButton: MouseButton.Auxiliary, 202 | }) 203 | }) 204 | } 205 | initWindow(activeWindow as MWindow) 206 | this.app.workspace.on('window-open', (ww, win) => { 207 | initWindow(win as MWindow) 208 | }) 209 | this.app.workspace.on('window-close', (ww, win) => { 210 | this._oolwUnloadWindow(win as MWindow) 211 | }) 212 | // 213 | this.app.workspace.onLayoutReady(async () => { 214 | await this._viewmgr.restoreView() 215 | if (this.settings.enableLog) { 216 | log('info', 'restored views', this.settings.inAppViewRec) 217 | } 218 | }) 219 | } 220 | async onunload(): Promise { 221 | if (typeof this._windowUtils !== 'undefined') { 222 | Object.keys(this._windowUtils.getRecords()).forEach((mid) => { 223 | this._oolwUnloadWindowByMID(mid) 224 | }) 225 | delete this._clickUtils 226 | delete this._windowUtils 227 | } 228 | } 229 | async loadSettings(): Promise { 230 | this.settings = Object.assign( 231 | {}, 232 | DEFAULT_SETTINGS, 233 | await this.loadData() 234 | ) 235 | } 236 | async saveSettings(): Promise { 237 | if (this.settings.enableLog) { 238 | log('info', 'saving settings', this.settings) 239 | } 240 | await this.saveData(this.settings) 241 | } 242 | _getOpenCMD(val: string): Optional { 243 | if (val === BROWSER_SYSTEM.val) { 244 | return undefined 245 | } 246 | if (val === BROWSER_GLOBAL.val) { 247 | val = this.settings.selected 248 | } 249 | return this.profiles.getBrowsersCMD(this.settings.custom)[val] 250 | } 251 | _oolwUnloadWindow(win: MWindow): void { 252 | if (typeof this._clickUtils !== 'undefined') { 253 | this._clickUtils.removeDocClickHandler(win) 254 | this._clickUtils.overrideDefaultWindowOpen(win, false) 255 | } 256 | if (typeof this._windowUtils !== 'undefined') { 257 | this._windowUtils.unregisterWindow(win) 258 | } 259 | } 260 | _oolwUnloadWindowByMID(mid: string): void { 261 | if (typeof this._windowUtils !== 'undefined') { 262 | const win = this._windowUtils.getWindow(mid) 263 | if (typeof win !== 'undefined') { 264 | this._oolwUnloadWindow(win) 265 | } 266 | } 267 | } 268 | } 269 | 270 | class PanicModal extends Modal { 271 | constructor(app: App, public message: string) { 272 | super(app) 273 | this.message = message 274 | } 275 | onOpen() { 276 | let { contentEl } = this 277 | contentEl.setText(this.message) 278 | } 279 | onClose() { 280 | let { contentEl } = this 281 | contentEl.empty() 282 | } 283 | } 284 | 285 | class SettingTab extends PluginSettingTab { 286 | _profileChangeHandler: Debouncer 287 | _timeoutChangeHandler: Debouncer 288 | constructor(app: App, public plugin: OpenLinkPluginITF) { 289 | super(app, plugin) 290 | this.plugin = plugin 291 | this._profileChangeHandler = debounce( 292 | async (val) => { 293 | try { 294 | const profiles = JSON.parse(val) 295 | this.plugin.settings.custom = profiles 296 | await this.plugin.saveSettings() 297 | this._render() 298 | } catch (e) { 299 | this.panic( 300 | e.message ?? 301 | e.toString() ?? 302 | 'some error occurred in open-link-with' 303 | ) 304 | } 305 | }, 306 | 1500, 307 | true 308 | ) 309 | this._timeoutChangeHandler = debounce( 310 | async (val) => { 311 | const timeout = parseInt(val) 312 | if (Number.isNaN(timeout)) { 313 | this.panic('Value of timeout should be interger.') 314 | } else { 315 | this.plugin.settings.timeout = timeout 316 | await this.plugin.saveSettings() 317 | this._render() 318 | } 319 | }, 320 | 1500, 321 | true 322 | ) 323 | } 324 | panic(msg: string) { 325 | new PanicModal(this.app, msg).open() 326 | } 327 | _render() { 328 | let { containerEl } = this 329 | containerEl.empty() 330 | new Setting(containerEl) 331 | .setName('Browser') 332 | .setDesc('Open external link with selected browser.') 333 | .addDropdown((dd) => { 334 | const browsers: ProfileDisplay[] = [ 335 | BROWSER_SYSTEM, 336 | BROWSER_IN_APP_LAST, 337 | BROWSER_IN_APP, 338 | ...Object.keys( 339 | this.plugin.profiles.getBrowsersCMD( 340 | this.plugin.settings.custom 341 | ) 342 | ).map((b) => { 343 | return { val: b } 344 | }), 345 | ] 346 | let current = browsers.findIndex( 347 | ({ val }) => val === this.plugin.settings.selected 348 | ) 349 | if (current !== -1) { 350 | browsers.unshift(browsers.splice(current, 1)[0]) 351 | } 352 | browsers.forEach((b) => 353 | dd.addOption(b.val, b.display ?? b.val) 354 | ) 355 | dd.onChange(async (p) => { 356 | this.plugin.settings.selected = p 357 | await this.plugin.saveSettings() 358 | }) 359 | }) 360 | new Setting(containerEl) 361 | .setName('Customization') 362 | .setDesc('Customization profiles in JSON.') 363 | .addTextArea((text) => 364 | text 365 | .setPlaceholder('{}') 366 | .setValue( 367 | JSON.stringify(this.plugin.settings.custom, null, 4) 368 | ) 369 | .onChange(this._profileChangeHandler) 370 | ) 371 | const mbSetting = new Setting(containerEl) 372 | .setName('Modifier Bindings') 373 | .setDesc('Matching from top to bottom') 374 | .addButton((btn) => { 375 | btn.setButtonText('New') 376 | btn.onClick(async (_) => { 377 | this.plugin.settings.modifierBindings.unshift({ 378 | id: genRandomStr(6), 379 | platform: Platform.Unknown, 380 | modifier: 'none', 381 | focusOnView: true, 382 | auxClickOnly: false, 383 | }) 384 | await this.plugin.saveSettings() 385 | this._render() 386 | }) 387 | }) 388 | const mbSettingEl = mbSetting.settingEl 389 | mbSettingEl.setAttr('style', 'flex-wrap:wrap') 390 | const bindings = this.plugin.settings.modifierBindings 391 | 392 | bindings.forEach((mb) => { 393 | const ctr = document.createElement('div') 394 | ctr.setAttr('style', 'flex-basis:100%;height:auto;margin-top:18px') 395 | const mini = document.createElement('div') 396 | const kb = new Setting(mini) 397 | kb.addDropdown((dd) => { 398 | const browsers: ProfileDisplay[] = [ 399 | BROWSER_GLOBAL, 400 | BROWSER_IN_APP_LAST, 401 | BROWSER_IN_APP, 402 | ...Object.keys( 403 | this.plugin.profiles.getBrowsersCMD( 404 | this.plugin.settings.custom 405 | ) 406 | ).map((b) => { 407 | return { val: b } 408 | }), 409 | BROWSER_SYSTEM, 410 | ] 411 | browsers.forEach((b) => { 412 | dd.addOption(b.val, b.display ?? b.val) 413 | }) 414 | dd.setValue(mb.browser ?? BROWSER_GLOBAL.val) 415 | dd.onChange(async (browser) => { 416 | if (browser === BROWSER_GLOBAL.val) { 417 | browser = undefined 418 | } 419 | this.plugin.settings.modifierBindings.find( 420 | (m) => m.id === mb.id 421 | ).browser = browser 422 | await this.plugin.saveSettings() 423 | this._render() 424 | }) 425 | }) 426 | kb.addToggle((toggle) => { 427 | toggle.toggleEl.setAttribute('id', 'oolw-aux-click-toggle') 428 | toggle.setValue(mb.auxClickOnly) 429 | toggle.setTooltip('Triggers on middle mouse button click only') 430 | toggle.onChange(async (val) => { 431 | this.plugin.settings.modifierBindings.find( 432 | (m) => m.id === mb.id 433 | ).auxClickOnly = val 434 | await this.plugin.saveSettings() 435 | }) 436 | }) 437 | kb.addToggle((toggle) => { 438 | toggle.toggleEl.setAttribute('id', 'oolw-view-focus-toggle') 439 | if ( 440 | mb.browser === BROWSER_IN_APP.val || 441 | mb.browser === BROWSER_IN_APP_LAST.val 442 | ) { 443 | toggle.setDisabled(false) 444 | toggle.setValue(mb.focusOnView) 445 | } else { 446 | toggle.toggleEl.setAttribute('style', 'opacity:0.2') 447 | toggle.setDisabled(true) 448 | toggle.setValue(false) 449 | } 450 | toggle.setTooltip( 451 | 'Focus on view after opening/updating (in-app browser only)' 452 | ) 453 | toggle.onChange(async (val) => { 454 | this.plugin.settings.modifierBindings.find( 455 | (m) => m.id === mb.id 456 | ).focusOnView = val 457 | await this.plugin.saveSettings() 458 | }) 459 | }) 460 | kb.addDropdown((dd) => { 461 | const platform = getPlatform() 462 | getValidModifiers(platform).forEach((m) => { 463 | dd.addOption( 464 | m, 465 | { 466 | ...MODIFIER_TEXT_FALLBACK, 467 | ...MODIFIER_TEXT[platform], 468 | }[m] 469 | ) 470 | }) 471 | dd.setValue(mb.modifier) 472 | dd.onChange(async (modifier: ValidModifier) => { 473 | this.plugin.settings.modifierBindings.find( 474 | (m) => m.id === mb.id 475 | ).modifier = modifier 476 | await this.plugin.saveSettings() 477 | }) 478 | }) 479 | kb.addButton((btn) => { 480 | btn.setButtonText('Remove') 481 | btn.setClass('mod-warning') 482 | btn.onClick(async (_) => { 483 | const idx = 484 | this.plugin.settings.modifierBindings.findIndex( 485 | (m) => m.id === mb.id 486 | ) 487 | this.plugin.settings.modifierBindings.splice(idx, 1) 488 | await this.plugin.saveSettings() 489 | this._render() 490 | }) 491 | }) 492 | kb.controlEl.setAttr( 493 | 'style', 494 | 'justify-content: space-between !important;' 495 | ) 496 | mbSettingEl.appendChild(ctr) 497 | ctr.appendChild(kb.controlEl) 498 | }) 499 | 500 | new Setting(containerEl) 501 | .setName('Logs') 502 | .setDesc('Display logs in console (open developer tools to view).') 503 | .addToggle((toggle) => { 504 | toggle.setValue(this.plugin.settings.enableLog) 505 | toggle.onChange(async (val) => { 506 | this.plugin.settings.enableLog = val 507 | await this.plugin.saveSettings() 508 | this._render() 509 | }) 510 | }) 511 | new Setting(containerEl) 512 | .setName('Timeout') 513 | .addText((text) => 514 | text 515 | .setPlaceholder('500') 516 | .setValue(this.plugin.settings.timeout.toString()) 517 | .onChange(this._timeoutChangeHandler) 518 | ) 519 | } 520 | display(): void { 521 | this._render() 522 | } 523 | } 524 | -------------------------------------------------------------------------------- /src/obsidian/types.ts: -------------------------------------------------------------------------------- 1 | // source: https://github.com/obsidianmd/obsidian-api/issues/3 2 | 3 | export type BuiltinIcon = 4 | | 'logo-crystal' 5 | | 'create-new' 6 | | 'trash' 7 | | 'search' 8 | | 'right-triangle' 9 | | 'document' 10 | | 'folder' 11 | | 'pencil' 12 | | 'left-arrow' 13 | | 'right-arrow' 14 | | 'three-horizontal-bars' 15 | | 'dot-network' 16 | | 'audio-file' 17 | | 'image-file' 18 | | 'pdf-file' 19 | | 'gear' 20 | | 'documents' 21 | | 'blocks' 22 | | 'go-to-file' 23 | | 'presentation' 24 | | 'cross-in-box' 25 | | 'microphone' 26 | | 'microphone-filled' 27 | | 'two-columns' 28 | | 'link' 29 | | 'popup-open' 30 | | 'checkmark' 31 | | 'hashtag' 32 | | 'left-arrow-with-tail' 33 | | 'right-arrow-with-tail' 34 | | 'lines-of-text' 35 | | 'vertical-three-dots' 36 | | 'pin' 37 | | 'magnifying-glass' 38 | | 'info' 39 | | 'horizontal-split' 40 | | 'vertical-split' 41 | | 'calendar-with-checkmark' 42 | | 'sheets-in-box' 43 | | 'up-and-down-arrows' 44 | | 'broken-link' 45 | | 'cross' 46 | | 'any-key' 47 | | 'reset' 48 | | 'star' 49 | | 'crossed-star' 50 | | 'dice' 51 | | 'filled-pin' 52 | | 'enter' 53 | | 'help' 54 | | 'vault' 55 | | 'open-vault' 56 | | 'paper-plane' 57 | | 'bullet-list' 58 | | 'uppercase-lowercase-a' 59 | | 'star-list' 60 | | 'expand-vertically' 61 | | 'languages' 62 | | 'switch' 63 | | 'pane-layout' 64 | | 'install' 65 | -------------------------------------------------------------------------------- /src/open.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | 3 | import { log } from './utils' 4 | 5 | class OpenErr extends Error { 6 | constructor(msg: string) { 7 | super(msg) 8 | } 9 | } 10 | 11 | const openWith = async ( 12 | url: string, 13 | cmd: string[], 14 | options: Partial<{ 15 | enableLog: boolean 16 | timeout: number 17 | }> = {} 18 | ): Promise => { 19 | const _spawn = async (args: string[]): Promise => { 20 | return new Promise((res) => { 21 | const _args: string[] = [...args] 22 | const reg = RegExp(/^[^"|'](.+)(? { 37 | res(code) 38 | }) 39 | setTimeout(() => { 40 | res(0) 41 | }, options?.timeout ?? 250) 42 | }) 43 | } 44 | const target = '$TARGET_URL' 45 | let match = false 46 | const _cmd = cmd.map((arg) => { 47 | const idx = arg.indexOf(target) 48 | if (idx !== -1) { 49 | match = true 50 | return ( 51 | arg.slice(0, idx) + 52 | encodeURIComponent(url) + 53 | arg.slice(idx + target.length) 54 | ) 55 | } else { 56 | return arg 57 | } 58 | }) 59 | if (!match) { 60 | _cmd.push(url) 61 | } 62 | return await _spawn(_cmd) 63 | } 64 | 65 | export { openWith, OpenErr } 66 | -------------------------------------------------------------------------------- /src/profile.ts: -------------------------------------------------------------------------------- 1 | import { platform } from 'os' 2 | 3 | import { PRESET_BROWSERS } from './constant' 4 | import { 5 | Browser as _Browser, 6 | BrowserProfile, 7 | BrowserProfileBase, 8 | ProfileMgrITF, 9 | } from './types' 10 | 11 | class Browser implements _Browser { 12 | public profiles: Partial> 13 | public customCMD: string 14 | constructor( 15 | public name: string, 16 | defaultCMD?: Partial> 17 | ) { 18 | this.name = name 19 | this.profiles = defaultCMD 20 | } 21 | getExecCommands = ( 22 | platform: NodeJS.Platform 23 | ): { 24 | main: string[] 25 | private?: string[] 26 | } => { 27 | const res = {} as { 28 | main: string[] 29 | private?: string[] 30 | } 31 | let bp: BrowserProfile = this.profiles[platform] 32 | for (const pvt of [0, 1]) { 33 | const cmds = [] 34 | let bpBase: BrowserProfileBase 35 | if (pvt) { 36 | if (!bp?.optional?.private) { 37 | continue 38 | } 39 | bpBase = { 40 | ...bp, 41 | ...(bp.optional.private ?? {}), 42 | } 43 | } else { 44 | bpBase = bp 45 | } 46 | if (bpBase.sysCmd) { 47 | cmds.push(bpBase.sysCmd) 48 | } 49 | if (bpBase.sysArgs) { 50 | bpBase.sysArgs.forEach((arg) => cmds.push(arg)) 51 | } 52 | cmds.push(bpBase.cmd) 53 | if (bpBase.args) { 54 | bpBase.args.forEach((arg) => cmds.push(arg)) 55 | } 56 | if (pvt) { 57 | res.private = cmds 58 | } else { 59 | res.main = cmds 60 | } 61 | } 62 | return res 63 | } 64 | } 65 | 66 | const getPresetBrowsers = (): Browser[] => { 67 | const presets: Browser[] = [] 68 | for (const name of Object.keys(PRESET_BROWSERS)) { 69 | presets.push(new Browser(name, PRESET_BROWSERS[name])) 70 | } 71 | return presets 72 | } 73 | 74 | class ProfileMgr implements ProfileMgrITF { 75 | private _preset_browser: Browser[] 76 | private _browsers: Browser[] 77 | constructor() { 78 | this._browsers = [] 79 | } 80 | loadValidPresetBrowsers = async (): Promise => { 81 | this._preset_browser = [] 82 | const presets = getPresetBrowsers() 83 | const os = platform() 84 | presets.forEach(async (browser) => { 85 | const { profiles, name } = browser 86 | let app = profiles[os] 87 | if ( 88 | typeof app !== 'undefined' && 89 | app.isAvailable && 90 | (await app.isAvailable(app)) 91 | ) { 92 | this._preset_browser.push(browser) 93 | } 94 | }) 95 | } 96 | getBrowsers = (): Browser[] => { 97 | return [...this._preset_browser, ...this._browsers] 98 | } 99 | getBrowsersCMD = ( 100 | custom: Record 101 | ): Record => { 102 | const res: Record = {} 103 | this.getBrowsers().forEach((browser) => { 104 | const cmds = browser.getExecCommands(platform()) 105 | res[browser.name] = cmds.main 106 | if (typeof cmds.private !== 'undefined') { 107 | res[browser.name + '-private'] = cmds.private 108 | } 109 | }) 110 | return { ...res, ...custom } 111 | } 112 | } 113 | 114 | export { ProfileMgr } 115 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { PaneType, Plugin } from 'obsidian' 2 | 3 | type Optional = T | undefined 4 | 5 | namespace Rule { 6 | export class _Rule { 7 | constructor(public items: R[], public value: V) {} 8 | } 9 | 10 | export class Empty extends _Rule { 11 | constructor(value: V) { 12 | super([], value) 13 | } 14 | } 15 | 16 | export class Exact extends _Rule {} 17 | 18 | export class Contains extends _Rule {} 19 | 20 | export class NotExact extends _Rule {} 21 | 22 | export class NotContains extends _Rule {} 23 | } 24 | 25 | enum Platform { 26 | Unknown = 'unknown', 27 | Linux = 'linux', 28 | Mac = 'mac', 29 | Win = 'win', 30 | } 31 | 32 | enum Modifier { 33 | Alt = 'alt', 34 | Ctrl = 'ctrl', 35 | Meta = 'meta', 36 | Shift = 'shift', 37 | } 38 | 39 | enum MouseButton { 40 | Main, 41 | Auxiliary, 42 | Secondary, 43 | Fourth, 44 | Fifth, 45 | } 46 | 47 | enum ViewMode { 48 | LAST, 49 | NEW, 50 | } 51 | 52 | interface BrowserOptions { 53 | private: Partial> 54 | background: boolean 55 | } 56 | 57 | interface BrowserProfileBase { 58 | sysCmd?: string 59 | sysArgs?: string[] 60 | cmd: string 61 | args?: string[] 62 | } 63 | 64 | interface BrowserProfile extends BrowserProfileBase { 65 | optional: Partial 66 | isAvailable: (b: BrowserProfile) => Promise 67 | } 68 | 69 | interface Browser { 70 | name: string 71 | profiles: Partial> 72 | getExecCommands: (platform: NodeJS.Platform) => { 73 | main: string[] 74 | private?: string[] 75 | } 76 | } 77 | 78 | interface ModifierBinding { 79 | id: string 80 | browser?: string 81 | platform: Platform 82 | modifier: ValidModifier // TODO: 83 | focusOnView?: boolean 84 | auxClickOnly: boolean 85 | paneType?: PaneType 86 | } 87 | 88 | interface OpenLinkPluginITF extends Plugin { 89 | settings: PluginSettings 90 | profiles: ProfileMgrITF 91 | loadSettings(): Promise 92 | saveSettings(): Promise 93 | } 94 | 95 | interface PluginSettings { 96 | selected: string 97 | custom: Record 98 | modifierBindings: ModifierBinding[] 99 | enableLog: boolean 100 | timeout: number 101 | inAppViewRec: ViewRec[] 102 | } 103 | 104 | interface ProfileDisplay { 105 | val: string 106 | display?: string 107 | } 108 | 109 | interface ProfileMgrITF { 110 | loadValidPresetBrowsers: () => Promise 111 | getBrowsers: () => Browser[] 112 | getBrowsersCMD: ( 113 | custom: Record 114 | ) => Record 115 | } 116 | 117 | interface MWindow extends Window { 118 | mid: string 119 | oolwCIDs: string[] 120 | oolwPendingUrls: string[] 121 | _builtInOpen: (url?: string | URL, target?: any, features?: any) => Window 122 | } 123 | 124 | type Clickable = { 125 | is_clickable: boolean | null 126 | url: string | null 127 | paneType?: PaneType 128 | modifier_rules?: Rule._Rule | false>[] 129 | } 130 | 131 | type LogLevels = 'info' | 'warn' | 'error' 132 | 133 | type ValidModifier = 'none' | 'ctrl' | 'meta' | 'alt' | 'shift' 134 | 135 | type ViewRec = { 136 | leafId: string 137 | url: string 138 | mode: ViewMode 139 | } 140 | 141 | export { 142 | Browser, 143 | BrowserOptions, 144 | BrowserProfile, 145 | BrowserProfileBase, 146 | Clickable, 147 | LogLevels, 148 | Modifier, 149 | ModifierBinding, 150 | MouseButton, 151 | MWindow, 152 | OpenLinkPluginITF, 153 | Optional, 154 | Platform, 155 | PluginSettings, 156 | ProfileDisplay, 157 | ProfileMgrITF, 158 | Rule, 159 | ValidModifier, 160 | ViewMode, 161 | ViewRec, 162 | } 163 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LogLevels, 3 | Modifier, 4 | MWindow, 5 | Platform, 6 | Rule as MR, 7 | ValidModifier, 8 | OpenLinkPluginITF, 9 | } from './types' 10 | 11 | class RulesChecker { 12 | constructor(private _rules: MR._Rule[] = []) {} 13 | addRule(rule: MR._Rule) { 14 | this._rules.push(rule) 15 | } 16 | check(input: R[], options: { breakOnFirstSuccess?: boolean } = {}): V[] { 17 | const matched: V[] = [] 18 | for (const rule of this._rules) { 19 | if ( 20 | (options?.breakOnFirstSuccess ?? false) && 21 | matched.length > 0 22 | ) { 23 | break 24 | } 25 | const { items } = rule 26 | if (rule instanceof MR.Exact || rule instanceof MR.NotExact) { 27 | let ok = false 28 | if (items.length === input.length) { 29 | ok = items.every((item) => input.contains(item)) 30 | } 31 | if (rule instanceof MR.Exact ? ok : !ok) { 32 | matched.push(rule.value) 33 | } 34 | } else if ( 35 | rule instanceof MR.Contains || 36 | rule instanceof MR.NotContains 37 | ) { 38 | let ok = false 39 | if (items.length <= input.length) { 40 | ok = items.every((item) => input.contains(item)) 41 | } 42 | if (rule instanceof MR.Contains ? ok : !ok) { 43 | matched.push(rule.value) 44 | } 45 | } else if (rule instanceof MR.Empty) { 46 | if (input.length === 0) { 47 | matched.push(rule.value) 48 | } 49 | } else { 50 | throw new TypeError( 51 | `invalid rule type: ${rule.constructor.name}` 52 | ) 53 | } 54 | } 55 | return matched 56 | } 57 | } 58 | 59 | class WindowUtils { 60 | private _windows: Record 61 | constructor(private _plugin: OpenLinkPluginITF) { 62 | this._windows = {} 63 | } 64 | initWindow(win: MWindow) { 65 | win.mid = genRandomStr(8) 66 | return win 67 | } 68 | registerWindow(win: MWindow) { 69 | if (typeof win.mid === 'undefined') { 70 | win = this.initWindow(win) 71 | if (this._plugin.settings.enableLog) { 72 | log('info', 'window registered', { mid: win.mid, window: win }) 73 | } 74 | this._windows[win.mid] = win 75 | } else { 76 | // panic 77 | // if (this._plugin.settings.enableLog) { 78 | // log('warn', 'existing window registered', { 79 | // mid: win.mid, 80 | // window: win, 81 | // }) 82 | // } 83 | } 84 | } 85 | unregisterWindow(win: MWindow) { 86 | if (typeof win.mid !== 'undefined') { 87 | delete this._windows[win.mid] 88 | log('info', 'window unregistered', { mid: win.mid, window: win }) 89 | win.mid = undefined 90 | } 91 | } 92 | getRecords(): Record { 93 | return this._windows 94 | } 95 | getWindow(mid: string): MWindow { 96 | return this._windows[mid] 97 | } 98 | } 99 | 100 | const getPlatform = (): Platform => { 101 | const platform = window.navigator.platform 102 | switch (platform.slice(0, 3)) { 103 | case 'Mac': 104 | return Platform.Mac 105 | case 'Win': 106 | return Platform.Win 107 | default: 108 | return Platform.Linux 109 | } 110 | } 111 | 112 | const getModifiersFromMouseEvt = (evt: MouseEvent): Modifier[] => { 113 | const { altKey, ctrlKey, metaKey, shiftKey } = evt 114 | const mods: Modifier[] = [] 115 | if (altKey) { 116 | mods.push(Modifier.Alt) 117 | } 118 | if (ctrlKey) { 119 | mods.push(Modifier.Ctrl) 120 | } 121 | if (metaKey) { 122 | mods.push(Modifier.Meta) 123 | } 124 | if (shiftKey) { 125 | mods.push(Modifier.Shift) 126 | } 127 | return mods 128 | } 129 | 130 | const genRandomChar = (radix: number): string => { 131 | return Math.floor(Math.random() * radix) 132 | .toString(radix) 133 | .toLocaleUpperCase() 134 | } 135 | 136 | const genRandomStr = (len: number): string => { 137 | const id = [] 138 | for (const _ of ' '.repeat(len)) { 139 | id.push(genRandomChar(36)) 140 | } 141 | return id.join('') 142 | } 143 | 144 | const getValidHttpURL = (url?: string | URL): string | null => { 145 | if (typeof url === 'undefined') { 146 | return null 147 | } else if (url instanceof URL) { 148 | return ['http:', 'https:'].indexOf(url.protocol) != -1 149 | ? url.toString() 150 | : null 151 | } else { 152 | try { 153 | return getValidHttpURL(new URL(url)) 154 | } catch (TypeError) { 155 | return null 156 | } 157 | } 158 | } 159 | 160 | const getValidModifiers = (platform: Platform): ValidModifier[] => { 161 | if (platform === Platform.Unknown) { 162 | return ['none'] 163 | } else { 164 | return ['none', 'ctrl', 'meta', 'alt', 'shift'] 165 | } 166 | } 167 | 168 | const intersection = (...lists: T[][]): T[] => { 169 | let lhs: T[] = lists.pop() 170 | while (lists.length) { 171 | const rhs = lists.pop() 172 | lhs = lhs.filter((v) => rhs.contains(v)) 173 | } 174 | return lhs 175 | } 176 | 177 | const log = (level: LogLevels, title: string, message: any) => { 178 | let logger: (...args: any[]) => any 179 | if (level === 'warn') { 180 | logger = console.warn 181 | } else if (level === 'error') { 182 | logger = console.error 183 | } else { 184 | logger = console.info 185 | } 186 | logger(`[open-link-with] ${title}`, message) 187 | } 188 | 189 | export { 190 | getPlatform, 191 | getModifiersFromMouseEvt, 192 | genRandomStr, 193 | getValidModifiers, 194 | getValidHttpURL, 195 | intersection, 196 | log, 197 | RulesChecker, 198 | WindowUtils, 199 | } 200 | -------------------------------------------------------------------------------- /src/view.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, PaneType, WorkspaceLeaf } from 'obsidian' 2 | import { BuiltinIcon } from './obsidian/types' 3 | import { OpenLinkPluginITF, ViewMode, ViewRec } from './types' 4 | import { log } from './utils' 5 | 6 | class InAppView extends ItemView { 7 | public icon: BuiltinIcon = 'link' 8 | public frame: HTMLIFrameElement 9 | public title: string 10 | constructor(leaf: WorkspaceLeaf, public url: string) { 11 | super(leaf) 12 | this.title = new URL(url).host 13 | // TODO: remove this after tab title issue is fixed 14 | this.leaf.setPinned(true) 15 | setTimeout(() => { 16 | this.leaf.setPinned(false) 17 | }, 10) 18 | } 19 | async onOpen(): Promise { 20 | const frame_styles: string[] = [ 21 | 'height: 100%', 22 | 'width: 100%', 23 | 'background-color: white', // for pages with no background 24 | ] 25 | this.frame = document.createElement('iframe') 26 | this.frame.setAttr('style', frame_styles.join('; ')) 27 | this.frame.setAttr('src', this.url) 28 | this.containerEl.children[1].appendChild(this.frame) 29 | } 30 | getDisplayText(): string { 31 | return this.title 32 | } 33 | getViewType(): string { 34 | return 'OOLW::InAppView' 35 | } 36 | } 37 | 38 | class ViewMgr { 39 | constructor(public plugin: OpenLinkPluginITF) {} 40 | private _getLeafId(leaf: any): string { 41 | return leaf['id'] ?? '' 42 | } 43 | private _validRecords(): ViewRec[] { 44 | const records = this.plugin.settings.inAppViewRec ?? [] 45 | const validRec: ViewRec[] = [] 46 | try { 47 | for (const rec of records) { 48 | if ( 49 | this.plugin.app.workspace.getLeafById(rec.leafId) !== null 50 | ) { 51 | validRec.push(rec) 52 | } 53 | } 54 | } catch (err) { 55 | if (this.plugin.settings.enableLog) { 56 | log('error', 'failed to restore views', `${err}`) 57 | } 58 | } 59 | return validRec 60 | } 61 | async createView( 62 | url: string, 63 | mode: ViewMode, 64 | options: { 65 | focus?: boolean 66 | paneType?: PaneType 67 | } = {} 68 | ): Promise { 69 | const getNewLeafId = (): string => { 70 | const newLeaf = 71 | typeof options.paneType === 'undefined' 72 | ? false 73 | : options.paneType 74 | const leaf = this.plugin.app.workspace.getLeaf( 75 | newLeaf === false ? 'tab' : newLeaf // TODO: missing navigation; using tab for now 76 | ) 77 | return this._getLeafId(leaf) 78 | } 79 | let id: string = undefined 80 | // TODO: more robust open behaviors 81 | if (typeof options.paneType !== 'undefined' || mode === ViewMode.NEW) { 82 | id = getNewLeafId() 83 | } else { 84 | const viewRec = this._validRecords() 85 | let rec = 86 | viewRec.find(({ mode }) => mode === ViewMode.LAST) ?? 87 | viewRec.find(({ mode }) => mode === ViewMode.NEW) 88 | id = rec?.leafId ?? getNewLeafId() 89 | } 90 | return await this.updateView(id, url, mode, options?.focus) 91 | } 92 | async updateView( 93 | leafId: string, 94 | url: string, 95 | mode: ViewMode, 96 | focus: boolean = true 97 | ): Promise { 98 | const leaf = this.plugin.app.workspace.getLeafById(leafId) 99 | if (leaf === null) { 100 | return null 101 | } else { 102 | const view = new InAppView(leaf, url) 103 | await leaf.open(view) 104 | const rec = this.plugin.settings.inAppViewRec.find( 105 | (rec) => rec.leafId === leafId 106 | ) 107 | if (typeof rec !== 'undefined') { 108 | rec.url = url 109 | // TODO: 110 | rec.mode = rec.mode ?? mode 111 | } else { 112 | this.plugin.settings.inAppViewRec.unshift({ 113 | leafId, 114 | url, 115 | mode, 116 | }) 117 | } 118 | await this.plugin.saveSettings() 119 | // this.plugin.app.workspace.setActiveLeaf(leaf, { focus }) // TODO: option `focus` is not working (cliVer == 1.1.9) 120 | if (focus) { 121 | this.plugin.app.workspace.setActiveLeaf(leaf) 122 | } 123 | return leafId 124 | } 125 | } 126 | async restoreView() { 127 | const viewRec = this._validRecords() 128 | const restored: ViewRec[] = [] 129 | for (const rec of viewRec) { 130 | if ( 131 | (await this.updateView( 132 | rec.leafId, 133 | rec.url, 134 | rec.mode, 135 | false 136 | )) !== null 137 | ) { 138 | restored.push(rec) 139 | } 140 | } 141 | this.plugin.settings.inAppViewRec = restored 142 | await this.plugin.saveSettings() 143 | } 144 | } 145 | 146 | export { InAppView, ViewMgr, ViewMode, ViewRec } 147 | -------------------------------------------------------------------------------- /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 | "lib": [ 13 | "dom", 14 | "es5", 15 | "scripthost", 16 | "es2015" 17 | ] 18 | }, 19 | "include": [ 20 | "**/*.ts" 21 | ] 22 | } --------------------------------------------------------------------------------