├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml └── workflows │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── LICENSE ├── README-ZH.md ├── README.md ├── assets └── obsidian-web-browser.png ├── bookmark.json ├── esbuild.config.mjs ├── main.css ├── manifest.json ├── package.json ├── src ├── App.css ├── component │ ├── BookMarkBar │ │ ├── BookMarkBar.ts │ │ └── BookMarkItem.ts │ ├── BookmarkManager │ │ ├── BookmarkForm.tsx │ │ ├── BookmarkImporter.tsx │ │ ├── BookmarkManager.tsx │ │ └── utils.ts │ ├── EmbededWebView.ts │ ├── HeaderBar.ts │ ├── InNodeWebView.ts │ ├── InPageHeaderBar.ts │ ├── InPageIconList.ts │ ├── LastOpenedFiles.ts │ ├── OmniSearchContainer.ts │ ├── OmniSearchItem.ts │ ├── PopoverWebView.ts │ ├── SearchBox.ts │ ├── TabTreeView │ │ ├── CustomDragPreview.module.css │ │ ├── CustomDragPreview.tsx │ │ ├── CustomNode.module.css │ │ ├── CustomNode.tsx │ │ ├── Placeholder.module.css │ │ ├── Placeholder.tsx │ │ ├── TabTree.module.css │ │ ├── TabTree.tsx │ │ ├── TabTreeView.tsx │ │ ├── TypeIcon.tsx │ │ ├── types.ts │ │ └── workspace.ts │ ├── inPageSearchBar.ts │ └── suggester │ │ ├── bookmarkSuggester.ts │ │ ├── fileSuggester.ts │ │ ├── searchSuggester.ts │ │ └── suggest.ts ├── surfingBookmarkManager.tsx ├── surfingFileView.ts ├── surfingIndex.ts ├── surfingPluginSetting.ts ├── surfingView.ts ├── surfingViewNext.ts ├── translations │ ├── helper.ts │ └── locale │ │ ├── ar.ts │ │ ├── cz.ts │ │ ├── da.ts │ │ ├── de.ts │ │ ├── en-gb.ts │ │ ├── en.ts │ │ ├── es.ts │ │ ├── fr.ts │ │ ├── hi.ts │ │ ├── id.ts │ │ ├── it.ts │ │ ├── ja.ts │ │ ├── ko.ts │ │ ├── nl.ts │ │ ├── no.ts │ │ ├── pl.ts │ │ ├── pt-br.ts │ │ ├── pt.ts │ │ ├── ro.ts │ │ ├── ru.ts │ │ ├── tr.ts │ │ ├── zh-cn.ts │ │ └── zh-tw.ts ├── types │ ├── bookmark.d.ts │ ├── globals.d.ts │ ├── obsidian.d.ts │ └── widget.d.ts └── utils │ ├── json.ts │ ├── splitContent.ts │ └── url.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs ├── versions.json └── vite.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.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 | "@typescript-eslint/no-explicit-any": "off", 23 | "no-useless-escape": "off", 24 | "no-unexpected-multiline": "off", 25 | "no-mixed-spaces-and-tabs": "off", 26 | 27 | "@typescript-eslint/no-this-alias": "off" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: [ "bug" ] 5 | body: 6 | - type: textarea 7 | id: bug-description 8 | attributes: 9 | label: Bug Description 10 | description: A clear and concise description of the bug. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: screenshot 15 | attributes: 16 | label: Relevant Screenshot 17 | description: If applicable, add screenshots or a screen recording to help explain your problem. 18 | - type: textarea 19 | id: reproduction-steps 20 | attributes: 21 | label: To Reproduce 22 | description: Steps to reproduce the problem 23 | placeholder: | 24 | For example: 25 | 1. Go to '...' 26 | 2. Click on '...' 27 | 3. Scroll down to '...' 28 | - type: input 29 | id: obsi-version 30 | attributes: 31 | label: Obsidian Version 32 | description: You can find the version in the *About* Tab of the settings. 33 | placeholder: 0.13.19 34 | validations: 35 | required: true 36 | - type: checkboxes 37 | id: web-browser-only 38 | attributes: 39 | label: web-browser-only 40 | options: 41 | - label: Did you install only one web browser plugin? 42 | required: true 43 | - type: checkboxes 44 | id: checklist 45 | attributes: 46 | label: Checklist 47 | options: 48 | - label: I updated to the latest version of the plugin. 49 | required: true 50 | 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea 3 | title: "Feature Request: " 4 | labels: [ "feature request" ] 5 | body: 6 | - type: textarea 7 | id: feature-requested 8 | attributes: 9 | label: Feature Requested 10 | description: A clear and concise description of the feature. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: screenshot 15 | attributes: 16 | label: Relevant Screenshot 17 | description: If applicable, add screenshots or a screen recording to help explain the request. 18 | - type: checkboxes 19 | id: checklist 20 | attributes: 21 | label: Checklist 22 | options: 23 | - label: The feature would be useful to more users than just me. 24 | required: true 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | 7 | env: 8 | PLUGIN_NAME: obsidian-surfing 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 16 19 | - name: Build 20 | id: build 21 | run: | 22 | npm install 23 | npm run build 24 | mkdir ${{ env.PLUGIN_NAME }} 25 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 26 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 27 | ls 28 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 29 | - name: Upload zip file 30 | id: upload-zip 31 | uses: actions/upload-release-asset@v1 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | with: 35 | upload_url: ${{ github.event.release.upload_url }} 36 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 37 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 38 | asset_content_type: application/zip 39 | 40 | - name: Upload main.js 41 | id: upload-main 42 | uses: actions/upload-release-asset@v1 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | with: 46 | upload_url: ${{ github.event.release.upload_url }} 47 | asset_path: ./main.js 48 | asset_name: main.js 49 | asset_content_type: text/javascript 50 | 51 | - name: Upload manifest.json 52 | id: upload-manifest 53 | uses: actions/upload-release-asset@v1 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | with: 57 | upload_url: ${{ github.event.release.upload_url }} 58 | asset_path: ./manifest.json 59 | asset_name: manifest.json 60 | asset_content_type: application/json 61 | 62 | - name: Upload styles.css 63 | id: upload-css 64 | uses: actions/upload-release-asset@v1 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | with: 68 | upload_url: ${{ github.event.release.upload_url }} 69 | asset_path: ./styles.css 70 | asset_name: styles.css 71 | asset_content_type: text/css 72 | -------------------------------------------------------------------------------- /.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 | 24 | # Exclude pnpm lock file 25 | pnpm-lock.yaml 26 | 27 | # Exclude rar/zip/7z file 28 | *.rar 29 | *.zip 30 | *.7z 31 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint:fix 5 | npm run type-check 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dion Tryban (Trikzon) 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. -------------------------------------------------------------------------------- /README-ZH.md: -------------------------------------------------------------------------------- 1 | ## Surfing 2 | 3 | > 这个分支由 Boninall 维护,仅作个人使用。如果你想用该插件,使用 `quorafind/obsidian-surfing` 分支而不是以前的; 4 | > 原始分支来自[这里](https://github.com/Trikzon/obsidian-web-browser) 5 | 6 | [English Doc](README.md) 7 | 8 | ## 简介 9 | 10 | 这是一款 Obsidian 插件,允许你在 Obsidian v1.0 的标签页中浏览任意网址。 11 | 12 | 这个插件的核心功能——渲染一个 webview 13 | ,离不开 [Ellpeck's Obsidian Custom Frames](https://github.com/Ellpeck/ObsidianCustomFrames) 插件。 14 | 15 | ![](assets/obsidian-web-browser.png) 16 | 17 | ## 功能介绍 18 | 19 | - 核心功能 20 | - 浏览任意网页:该插件会劫持Obsidian的 file、http、https 协议,使得链接能直接在Obsidian里打开,而不是外部浏览器。对,本地HTML等资源也支持。 21 | - 编辑器网页搜索你可以在编辑器选中关键字后,右键在 web-browser 中打开,使用默认的搜索引擎搜索。 22 | - 网页内网页搜索:同样的,你可以在网页内右键使用默认的搜索引擎搜索。 23 | - 复制指向高亮的链接:同浏览器一样,你可以选中文字,复制指向该处的链接。 24 | - 在浏览器中使用书签直接在 Obsidian 中打开网址。 25 | - 复制视频时间戳(实验性功能:目前仅支持bilibili):右键文字弹出复制时间戳的菜单,目前有些bug,已知有时弹不出菜单。 26 | - 辅助功能 27 | - 用外部浏览器打开当前URL:右键菜单 28 | - 默认搜索引擎:设置项 29 | - 默认复制高亮的模板:设置项(目前仅支持非常简单的模板),请避免使用一些特殊字符 30 | - 支持浏览历史记录:前后跳转网页 31 | - 清除浏览历史记录:命令面板 32 | - 所有链接都在右侧同一个面板中打开:设置项 33 | - 切换是否在右侧同一面板中打开:命令面板 34 | - 简单的夜间模式:主要是为了bilibili的夜间观感,有些瑕疵,问题不大 35 | 36 | ## 使用方法 37 | 38 | ### 利用浏览器书签在obsidain中打开网站 39 | 40 | 插件注册了一个 Obsidain uri 协议,该协议允许你使用`obsidian://web-open?url=`的网址在 Obsidian 中打开 Web-broswer 41 | 。其中`` 42 | 指网页地址链接。配合 [bookmarklets](https://www.ruanyifeng.com/blog/2011/06/a_guide_for_writing_bookmarklet.html) 43 | 便能实现点击浏览器的一个书签,在ob内打开当前浏览器网址。 44 | 45 | 1. 在插件设置里打开`Open URL In Obsidian Web`选项 46 | 2. 该选项下有一个链接,拖拽这个链接到你外部浏览器的书签栏处,会生成一个书签; 你也可以点击这个链接复制 bookmarklets 47 | 代码,自己新建一个书签 48 | 3. 现在可以点击书签,将浏览器当前页面在 Obsidian 打开了 49 | 50 | ## 使用技巧 51 | 52 | 对于中文用户,你可能希望复制的高亮链接是可以直接显示原文,这种情况下,你可以应用下述的 Quickadd 脚本: 53 | 54 | ```javascript 55 | selObj = window.getSelection(); 56 | text = selObj.toString(); 57 | text = await decodeURIComponent(text) 58 | this.quickAddApi.utility.setClipboard(text); 59 | 60 | return text; 61 | ``` 62 | 63 | 然后粘贴取代原来的文本内容即可。 64 | 65 | ## 安装 66 | 67 | - 目前尚未准备好上架市场 68 | - 可以通过 [Brat](https://github.com/TfTHacker/obsidian42-brat) 插件安装 69 | - 手动安装 70 | 71 | 1. 在该github页面找到release页面并点击 72 | 2. 下载最新的一次release压缩包 73 | 3. 解压,复制解压后的文件夹到obsidian插件文件夹下,确保该文件夹里有main.js和manifest.json文件 74 | 4. 重启obsidian(不重启也行,得刷新),在设置界面启用该插件 75 | 5. 欧了 76 | 77 | ## Contribution 78 | 79 | - [皮皮](https://github.com/windily-cloud) - 中文翻译 && 部分功能实现 80 | 81 | ## Support 82 | 83 | You can support original author `Trikzon` : 84 | 85 | [](https://ko-fi.com/trikzon) 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Surfing 2 | 3 | [中文文档](README-ZH.md) | [English Doc](README.md) 4 | 5 | ## Introduction 6 | 7 | An [Obsidian](https://obsidian.md/) plugin that allows you to browse the web within Obsidian using v1.0 tabs. 8 | 9 | The core functionality of the plugin, rendering a web view, is greatly influenced 10 | by [Ellpeck's Obsidian Custom Frames](https://github.com/Ellpeck/ObsidianCustomFrames) plugin and this plugin wouldn't 11 | have been possible without it. 12 | 13 | ![](assets/obsidian-web-browser.png) 14 | 15 | ## TODO 16 | 17 | - [ ] Support extensions 18 | - [ ] Support custom CSS 19 | - [ ] Support custom JS 20 | 21 | ## Feature 22 | 23 | - Core Feature 24 | - Browse arbitrary web pages: The plugin hijacks Obsidian's file, http, https protocols, enabling links to be opened 25 | directly in Obsidian, rather than in external browsers. Yes, local HTML and other resources are also supported. 26 | - Editor web search: You can select keywords in the editor and then right-click to open them in web-browser and 27 | search using the default search engine. 28 | - In-page web search: Again, you can right-click within a web page to use the default search engine search. 29 | - Copy links pointing to highlights: As with the browser, you can select text and copy the links pointing to it. 30 | - Use BookmarkLets in your browser to open the URL directly in Obsidian. 31 | - Copy video timestamp (experimental feature: currently only bilibili is supported): right click on the text to pop 32 | up the menu to copy the timestamp, currently there are some bugs, it is known that sometimes the menu does not pop 33 | up. 34 | - Auxiliary Feature 35 | - Open current URL with external browser: right-click menu 36 | - Default search engine: setting item 37 | - Default copy highlighted template: setting item (currently only supports very simple templates), please avoid 38 | using some special characters 39 | - Support browsing history: Jump back and forth to the page 40 | - Clear browsing history: command panel 41 | - All links are opened in the same panel on the right: Settings 42 | - Toggle whether to open in the same panel on the right: command panel 43 | - Simple dark mode: just simple 44 | 45 | ## Usage 46 | 47 | ### Use BookmarkLets Open URL 48 | 49 | The plugin registers an Obsidain uri protocol that allows you to open eb-broswer in Obsidian using the 50 | URL `obsidian://web-open?url=`. Where `` refers to the web address link. 51 | Match [bookmarklets](https://en.wikipedia.org/wiki/Bookmarklet) will be able to click a bookmark in the browser to open 52 | the current browser URL within Obsidain. 53 | 54 | 1. Open the `Open URL In Obsidian Web` option in the plugin settings. 55 | 2. Under this option there is a link of bookmarklets, drag this link into your browser's bookmark bar. You can also 56 | click this link(will copy bookmarklets code), then create bookmarklets by yourself. 57 | 3. Now you can click on the bookmark to open the current page of your browser in Obsidian. 58 | 59 | ### Use Quickadd to search selection in ChatGPT in Surfing 60 | 61 | 1. Create a macro based on this 62 | script: [search-in-surfing](https://gist.github.com/Quorafind/c70c6c698feeed66465d59efc39e4e1c) 63 | 2. Open ChatGPT in surfing, and select some text, then run the macro. 64 | 65 | ## Installation 66 | 67 | - Search `Surfing` in the Obsidian Community Plugin marketplace. 68 | - Can be installed via the [Brat](https://github.com/TfTHacker/obsidian42-brat) plugin 69 | - Manual installation 70 | 71 | 1. Find the release page on this github page and click 72 | 2. Download the latest release zip file 73 | 3. Unzip it, copy the unzipped folder to the obsidian plugin folder, make sure there are main.js and manifest.json files 74 | in the folder 75 | 4. Restart obsidian (do not restart also, you have to refresh plugin list), in the settings interface to enable the 76 | plugin 77 | 5. Done! 78 | 79 | ## Contribution 80 | 81 | - [Windily-cloud(皮皮)](https://github.com/windily-cloud) - Chinese translation && Features 82 | 83 | ## Support 84 | 85 | If you are enjoying this plugin then please support my work and enthusiasm by buying me a coffee 86 | on [https://www.buymeacoffee.com/boninall](https://www.buymeacoffee.com/boninall). 87 | . 88 | 89 | 90 | -------------------------------------------------------------------------------- /assets/obsidian-web-browser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PKM-er/Obsidian-Surfing/88de80577cd11989cf7ef9c1e6f872f28dc66fe5/assets/obsidian-web-browser.png -------------------------------------------------------------------------------- /bookmark.json: -------------------------------------------------------------------------------- 1 | {"bookmarks":[{"id":"-1986065712","name":"Recycled Steel Mouse","description":"New ABC 13 9370, 13.3, 5th Gen CoreA5-8250U, 8GB RAM, 256GB SSD, power UHD Graphics, OS 10 Home, OS Office A & J 2016","url":"https://unhappy-bratwurst.com","tags":"awesome great bad good soso","category":["计算机","算法"],"created":1698820965571,"modified":1694518168351},{"id":"-350896611","name":"Handcrafted Fresh Chicken","description":"The Football Is Good For Training And Recreational Purposes","url":"https://incomparable-wink.net","tags":"soso good great","category":["计算机","算法"],"created":1675093236862,"modified":1694767879657},{"id":"1127823550","name":"Recycled Steel Tuna","description":"The Apollotech B340 is an affordable wireless mouse with reliable connectivity, 12 months battery life and modern design","url":"https://usable-colonization.com","tags":"soso great awesome","category":["计算机","算法"],"created":1698614943536,"modified":1693028657538},{"id":"617707055","name":"Gorgeous Plastic Soap","description":"The slim & simple Maple Gaming Keyboard from Dev Byte comes with a sleek body and 7- Color RGB LED Back-lighting for smart functionality","url":"http://classic-hyacinth.org","tags":"awesome bad","category":["计算机","算法"],"created":1680028680814,"modified":1678004275787},{"id":"-486466370","name":"Sleek Concrete Towels","description":"New range of formal shirts are designed keeping you in mind. With fits and styling that will make you stand apart","url":"https://average-bog.name","tags":"great awesome good bad soso","category":["计算机","算法"],"created":1671726260410,"modified":1701146954600},{"id":"562058444","name":"Recycled Granite Chair","description":"Ergonomic executive chair upholstered in bonded black leather and PVC padded seat and back for all-day comfort and support","url":"http://metallic-carport.com","tags":"soso bad good great","category":["计算机","算法"],"created":1677129626448,"modified":1671945779127},{"id":"1039892883","name":"Rustic Plastic Shoes","description":"The beautiful range of Apple Naturalé that has an exciting mix of natural ingredients. With the Goodness of 100% Natural Ingredients","url":"http://able-similarity.net","tags":"awesome great bad good soso","category":["计算机","算法"],"created":1695048094893,"modified":1684107148328},{"id":"1242469051","name":"Generic Bronze Table","description":"The beautiful range of Apple Naturalé that has an exciting mix of natural ingredients. With the Goodness of 100% Natural Ingredients","url":"https://flamboyant-pillbox.info","tags":"good great bad soso awesome","category":["计算机","算法"],"created":1690734802362,"modified":1677401690780},{"id":"-1291428680","name":"Awesome Frozen Chips","description":"The automobile layout consists of a front-engine design, with transaxle-type transmissions mounted at the rear of the engine and four wheel drive","url":"https://squiggly-chaos.name","tags":"awesome soso great","category":["计算机","算法"],"created":1686546248961,"modified":1675689666498},{"id":"-1913352840","name":"Modern Concrete Soap","description":"The Apollotech B340 is an affordable wireless mouse with reliable connectivity, 12 months battery life and modern design","url":"https://teeming-shred.net","tags":"soso bad","category":["计算机","算法"],"created":1688607537878,"modified":1701903702189},{"id":"-1913352110","name":"Modern Concrete Soap","description":"The Apollotech B340 is an affordable wireless mouse with reliable connectivity, 12 months battery life and modern design","url":"https://teeming-shred.net","tags":"soso bad","category":["ROOT"],"created":1688607537878,"modified":1701903702189},{"id":"-1913352220","name":"Modern Concrete Soap","description":"The Apollotech B340 is an affordable wireless mouse with reliable connectivity, 12 months battery life and modern design","url":"https://teeming-shred.net","tags":"soso bad","category":["Computer","Algorithm"],"created":1688607537878,"modified":1701903702189}],"categories":[{"value":"ROOT","text":"","label":"","children":[]},{"value":"计算机","text":"计算机","label":"计算机","children":[{"value":"算法","text":"算法","label":"算法"},{"value":"数据结构","text":"数据结构","label":"数据结构"}]},{"value":"政治学","text":"政治学","label":"政治学","children":[{"value":"比较政治","text":"比较政治","label":"比较政治"},{"value":"地缘政治","text":"地缘政治","label":"地缘政治"}]}]} -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules'; 4 | import cssModulesPlugin from 'esbuild-css-modules-plugin'; 5 | import fs from 'fs'; 6 | 7 | const banner = 8 | `/* 9 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 10 | if you want to view the source, please visit the github repository of this plugin 11 | */ 12 | `; 13 | 14 | const prod = (process.argv[2] === 'production'); 15 | 16 | const renamePlugin = { 17 | name: 'rename-styles', 18 | setup(build) { 19 | build.onEnd(() => { 20 | const {outfile} = build.initialOptions; 21 | const outcss = outfile.replace(/\.js$/, '.css'); 22 | const fixcss = outfile.replace(/main\.js$/, 'styles.css'); 23 | if (fs.existsSync(outcss)) { 24 | console.log('Renaming', outcss, 'to', fixcss); 25 | fs.renameSync(outcss, fixcss); 26 | } 27 | }); 28 | }, 29 | }; 30 | 31 | esbuild.build({ 32 | banner: { 33 | js: banner, 34 | }, 35 | entryPoints: ['src/surfingIndex.ts'], 36 | bundle: true, 37 | plugins: [ 38 | cssModulesPlugin({ 39 | inject: false, 40 | localsConvention: 'camelCaseOnly', // optional. value could be one of 'camelCaseOnly', 'camelCase', 'dashes', 'dashesOnly', default is 'camelCaseOnly' 41 | 42 | generateScopedName: (name, filename, css) => string, // optional. refer to: https://github.com/madyankin/postcss-modules#generating-scoped-names 43 | 44 | filter: /\.modules?\.css$/i, // Optional. Regex to filter certain CSS files. 45 | 46 | v2: true, // experimental. v2 can bundle images in css, note if set `v2` to true, other options except `inject` will be ignored. and v2 only works with `bundle: true`. 47 | v2CssModulesOption: { // Optional. 48 | dashedIndents: false, // Optional. refer to: https://github.com/parcel-bundler/parcel-css/releases/tag/v1.9.0 49 | /** 50 | * Optional. The currently supported segments are: 51 | * [name] - the base name of the CSS file, without the extension 52 | * [hash] - a hash of the full file path 53 | * [local] - the original class name 54 | */ 55 | pattern: `surfing_[local]_[hash]` 56 | } 57 | }), renamePlugin 58 | ], 59 | external: [ 60 | 'obsidian', 61 | 'electron', 62 | '@codemirror/autocomplete', 63 | '@codemirror/collab', 64 | '@codemirror/commands', 65 | '@codemirror/language', 66 | '@codemirror/lint', 67 | '@codemirror/search', 68 | '@codemirror/state', 69 | '@codemirror/view', 70 | '@lezer/common', 71 | '@lezer/highlight', 72 | '@lezer/lr', 73 | ...builtins], 74 | format: 'cjs', 75 | watch: !prod, 76 | target: 'es2021', 77 | logLevel: "info", 78 | sourcemap: prod ? false : 'inline', 79 | treeShaking: true, 80 | outfile: 'main.js', 81 | }).catch(() => process.exit(1)); 82 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "surfing", 3 | "name": "Surfing", 4 | "version": "0.9.14", 5 | "minAppVersion": "1.4.0", 6 | "description": "Surf the Net in Obsidian.", 7 | "author": "Boninall & Windily-cloud", 8 | "authorUrl": "https://github.com/Quorafind", 9 | "isDesktopOnly": true, 10 | "fundingUrl": { 11 | "Buy Me a Coffee": "https://www.buymeacoffee.com/boninall", 12 | "爱发电": "https://afdian.net/a/boninall", 13 | "支付宝": "https://cdn.jsdelivr.net/gh/Quorafind/.github@main/IMAGE/%E6%94%AF%E4%BB%98%E5%AE%9D%E4%BB%98%E6%AC%BE%E7%A0%81.jpg" 14 | } 15 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "surfing", 3 | "version": "0.9.14", 4 | "description": "Use surfing to surf the net in Obsidian.", 5 | "main": "main.js", 6 | "scripts": { 7 | "lint": "eslint . --ext .ts", 8 | "dev": "npm run lint && vite build --watch --mode=development", 9 | "build:nolint": "NODE_ENV=production rollup -c", 10 | "build": "vite build", 11 | "version": "node version-bump.mjs && git add manifest.json versions.json", 12 | "lint:fix": "eslint --fix . --ext .ts", 13 | "prepare": "husky install", 14 | "type-check": "tsc -noEmit -skipLibCheck" 15 | }, 16 | "keywords": [], 17 | "author": "Boninall & Windily-cloud", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@ant-design/icons": "^5.0.1", 21 | "@codemirror/language": "^6.2.1", 22 | "@codemirror/state": "^6.1.2", 23 | "@codemirror/view": "^6.3.0", 24 | "@minoru/react-dnd-treeview": "^3.4.4", 25 | "@mui/icons-material": "^5.11.0", 26 | "@popperjs/core": "^2.11.6", 27 | "@rollup/plugin-commonjs": "^25.0.7", 28 | "@rollup/plugin-node-resolve": "^15.2.3", 29 | "@rollup/plugin-replace": "^5.0.5", 30 | "@rollup/plugin-terser": "^0.4.4", 31 | "@types/node": "^20.11.5", 32 | "@types/react": "^18.0.26", 33 | "@types/react-dom": "^18.0.9", 34 | "@typescript-eslint/eslint-plugin": "5.29.0", 35 | "@typescript-eslint/parser": "5.29.0", 36 | "@vitejs/plugin-react": "^4.2.1", 37 | "antd": "^5.0.5", 38 | "builtin-modules": "3.3.0", 39 | "electron": "^32.2.0", 40 | "eslint": "^8.28.0", 41 | "eslint-plugin-import": "^2.26.0", 42 | "husky": "^9.1.6", 43 | "monkey-around": "^2.3.0", 44 | "obsidian": "latest", 45 | "react": "^18.2.0", 46 | "react-dnd": "^16.0.1", 47 | "react-dom": "^18.2.0", 48 | "react-scripts": "^5.0.1", 49 | "react-usestateref": "^1.0.8", 50 | "tslib": "2.4.0", 51 | "typescript": "4.7.4", 52 | "vite": "^5.0.12" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/component/BookMarkBar/BookMarkBar.ts: -------------------------------------------------------------------------------- 1 | import SurfingPlugin from "../../surfingIndex"; 2 | import { Bookmark, CategoryType } from "../../types/bookmark"; 3 | import { BookMarkItem } from "./BookMarkItem"; 4 | import { ItemView, setIcon } from "obsidian"; 5 | import { initializeJson, loadJson } from "../../utils/json"; 6 | import { WEB_BROWSER_BOOKMARK_MANAGER_ID } from "../../surfingBookmarkManager"; 7 | 8 | export class BookMarkBar { 9 | private view: ItemView; 10 | private plugin: SurfingPlugin; 11 | private BookmarkBarEl: HTMLElement; 12 | private BookmarkBarContainerEl: HTMLElement; 13 | private bookmarkData: Bookmark[] = []; 14 | private categoryData: CategoryType[] = []; 15 | 16 | constructor(view: ItemView, plugin: SurfingPlugin) { 17 | this.view = view; 18 | this.plugin = plugin; 19 | } 20 | 21 | async onload() { 22 | this.BookmarkBarEl = this.view.contentEl.createEl("div", { 23 | cls: "wb-bookmark-bar" 24 | }) 25 | 26 | this.renderIcon(); 27 | 28 | try { 29 | const { bookmarks, categories } = await loadJson(this.plugin); 30 | this.bookmarkData = bookmarks; 31 | this.categoryData = categories; 32 | } catch (e) { 33 | if (this.bookmarkData?.length === 0 || !this.bookmarkData) { 34 | await initializeJson(this.plugin); 35 | const { bookmarks, categories } = await loadJson(this.plugin); 36 | this.bookmarkData = bookmarks; 37 | this.categoryData = categories; 38 | } 39 | } 40 | 41 | // this.convertToBookmarkFolder(this.bookmarkData); 42 | this.render(this.bookmarkData, this.categoryData); 43 | } 44 | 45 | renderIcon() { 46 | const bookmarkManagerEl = this.BookmarkBarEl.createEl("div", { 47 | cls: "wb-bookmark-manager-entry" 48 | }); 49 | 50 | const bookmarkManagerIconEl = bookmarkManagerEl.createEl("div", { 51 | cls: "wb-bookmark-manager-icon", 52 | }) 53 | 54 | bookmarkManagerEl.onclick = async () => { 55 | const workspace = this.plugin.app.workspace; 56 | workspace.detachLeavesOfType(WEB_BROWSER_BOOKMARK_MANAGER_ID); 57 | await workspace.getLeaf(false).setViewState({ type: WEB_BROWSER_BOOKMARK_MANAGER_ID }); 58 | workspace.revealLeaf(workspace.getLeavesOfType(WEB_BROWSER_BOOKMARK_MANAGER_ID)[0]); 59 | } 60 | 61 | setIcon(bookmarkManagerIconEl, "bookmark"); 62 | } 63 | 64 | render(bookmarks: Bookmark[], categories: CategoryType[]) { 65 | 66 | if (this.BookmarkBarContainerEl) this.BookmarkBarContainerEl.detach(); 67 | 68 | // Move root to the end; 69 | const rootCategory = categories.shift(); 70 | if (rootCategory) categories.push(rootCategory); 71 | 72 | this.BookmarkBarContainerEl = this.BookmarkBarEl.createEl("div", { 73 | cls: "wb-bookmark-bar-container" 74 | }); 75 | 76 | categories?.forEach((item: CategoryType) => { 77 | new BookMarkItem(this.BookmarkBarContainerEl, this.plugin, this.view, item, bookmarks).onload(); 78 | }) 79 | } 80 | } 81 | 82 | export const updateBookmarkBar = (plugin: SurfingPlugin, bookmarks: Bookmark[], categories: CategoryType[], refreshBookmarkManager: boolean) => { 83 | if (refreshBookmarkManager) { 84 | const currentBookmarkLeaves = plugin.app.workspace.getLeavesOfType("surfing-bookmark-manager"); 85 | if (currentBookmarkLeaves.length > 0) { 86 | currentBookmarkLeaves[0].rebuildView(); 87 | } 88 | } 89 | 90 | const emptyLeaves = plugin.app.workspace.getLeavesOfType("empty"); 91 | if (emptyLeaves.length > 0) { 92 | emptyLeaves.forEach((leaf) => { 93 | leaf.rebuildView(); 94 | }) 95 | } 96 | 97 | const surfingLeaves = plugin.app.workspace.getLeavesOfType("surfing-view"); 98 | if (surfingLeaves.length > 0) { 99 | surfingLeaves.forEach((leaf) => { 100 | // @ts-ignore 101 | leaf.view?.bookmarkBar?.render(bookmarks, categories); 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/component/BookMarkBar/BookMarkItem.ts: -------------------------------------------------------------------------------- 1 | import { SurfingView } from "../../surfingView"; 2 | import SurfingPlugin from "../../surfingIndex"; 3 | import { Bookmark, CategoryType } from "../../types/bookmark"; 4 | import { ItemView, Menu, setIcon } from "obsidian"; 5 | 6 | export class BookMarkItem { 7 | private parentEl: HTMLElement; 8 | private plugin: SurfingPlugin; 9 | private readonly item: CategoryType; 10 | private view: ItemView; 11 | private readonly bookmark: Bookmark[]; 12 | 13 | constructor(parentEl: HTMLElement, plugin: SurfingPlugin, view: ItemView, item: CategoryType, bookmark: Bookmark[]) { 14 | this.parentEl = parentEl; 15 | this.plugin = plugin; 16 | this.item = item; 17 | this.view = view; 18 | this.bookmark = bookmark; 19 | } 20 | 21 | onload() { 22 | if (typeof this.item === "object" && (this.item.value || this.item.children) && this.item.value !== "ROOT") { 23 | this.renderFolder(); 24 | } else { 25 | this.renderBookmark(); 26 | } 27 | } 28 | 29 | renderFolder() { 30 | const folderEl = this.parentEl.createEl("div", { 31 | cls: "wb-bookmark-folder" 32 | }); 33 | const folderIconEl = folderEl.createEl("div", { 34 | cls: "wb-bookmark-folder-icon", 35 | }) 36 | folderEl.createEl("div", { 37 | cls: "wb-bookmark-folder-title", 38 | text: this.item.label, 39 | }); 40 | 41 | setIcon(folderIconEl, "folder-closed"); 42 | 43 | let currentPos: DOMRect; 44 | 45 | folderEl.onclick = (e: MouseEvent) => { 46 | const menu = new Menu(); 47 | 48 | if (!currentPos) { 49 | const targetEl = e.target as HTMLElement; 50 | const parentElement = targetEl.parentElement as HTMLElement; 51 | 52 | if (parentElement.classList.contains("wb-bookmark-folder")) currentPos = parentElement.getBoundingClientRect(); 53 | else currentPos = targetEl.getBoundingClientRect(); 54 | } 55 | 56 | this.loopMenu(menu, this.item); 57 | 58 | menu.showAtPosition({ 59 | x: currentPos.left, 60 | y: currentPos.bottom, 61 | }); 62 | 63 | } 64 | } 65 | 66 | loopMenu(menu: Menu, category: CategoryType) { 67 | if (category?.children) category?.children.forEach((child) => { 68 | let subMenu: Menu | undefined; 69 | menu.addItem((item) => 70 | subMenu = item.setTitle(child.label).setIcon('folder-closed').setSubmenu() 71 | ); 72 | 73 | if (!child?.children) { 74 | const bookmark = this.bookmark.filter((item) => { 75 | if (!item.category.length) return false; 76 | return item.category[item.category.length - 1] === child.value; 77 | }); 78 | 79 | if (bookmark.length > 0) { 80 | bookmark.forEach((bookmarkItem) => { 81 | subMenu?.addItem((item) => { 82 | item.setIcon('surfing') 83 | .setTitle(bookmarkItem.name) 84 | .onClick((e: MouseEvent) => { 85 | // @ts-ignore 86 | if (e.shiftKey) { 87 | window.open(bookmarkItem.url, "_blank", "external"); 88 | return; 89 | } 90 | if (!e.ctrlKey && !e.metaKey) SurfingView.spawnWebBrowserView(this.plugin, false, { url: bookmarkItem.url }); 91 | else SurfingView.spawnWebBrowserView(this.plugin, true, { url: bookmarkItem.url }); 92 | }) 93 | }); 94 | }); 95 | } 96 | } 97 | 98 | if (child?.children && subMenu) this.loopMenu(subMenu, child); 99 | }); 100 | 101 | const bookmark = this.bookmark.filter((item) => { 102 | if (!item.category.length) return false; 103 | return item.category[item.category.length - 1]?.contains(category.value); 104 | }); 105 | 106 | if (bookmark.length > 0) { 107 | bookmark.forEach((bookmarkItem) => { 108 | menu.addItem((item) => { 109 | item.setIcon('surfing') 110 | .setTitle(bookmarkItem.name) 111 | .onClick((e: MouseEvent) => { 112 | if (e.shiftKey) { 113 | window.open(bookmarkItem.url, "_blank", "external"); 114 | return; 115 | } 116 | if (!e.ctrlKey && !e.metaKey) SurfingView.spawnWebBrowserView(this.plugin, false, { url: bookmarkItem.url }); 117 | else SurfingView.spawnWebBrowserView(this.plugin, true, { url: bookmarkItem.url }); 118 | }) 119 | }); 120 | }); 121 | } 122 | } 123 | 124 | renderBookmark() { 125 | if (this.bookmark.length === 0) return; 126 | const rootBookmark = this.bookmark.filter((item) => (item?.category[0] === this.item?.value && item?.category.length === 1)); 127 | 128 | if (rootBookmark?.length > 0) { 129 | rootBookmark.forEach((bookmarkItem) => { 130 | const bookmarkEl = this.parentEl.createEl("div", { 131 | cls: "wb-bookmark-item" 132 | }); 133 | const folderIconEl = bookmarkEl.createEl("div", { 134 | cls: "wb-bookmark-item-icon", 135 | }) 136 | 137 | setIcon(folderIconEl, "album"); 138 | 139 | bookmarkEl.createEl("div", { 140 | cls: "wb-bookmark-item-title", 141 | text: bookmarkItem.name, 142 | }); 143 | bookmarkEl.onclick = (e: MouseEvent) => { 144 | if (e.shiftKey) { 145 | window.open(bookmarkItem.url, "", "external"); 146 | return; 147 | } 148 | if (!e.ctrlKey && !e.metaKey) SurfingView.spawnWebBrowserView(this.plugin, false, { url: bookmarkItem.url }); 149 | else SurfingView.spawnWebBrowserView(this.plugin, true, { url: bookmarkItem.url }); 150 | } 151 | }); 152 | } 153 | 154 | 155 | } 156 | 157 | } 158 | -------------------------------------------------------------------------------- /src/component/BookmarkManager/BookmarkForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Cascader, Form, Input, Select } from "antd"; 2 | import { DefaultOptionType } from "antd/es/select"; 3 | import { moment } from "obsidian"; 4 | import React, { ReactNode } from "react"; 5 | import { Bookmark, CategoryType, FilterType } from "../../types/bookmark"; 6 | import { fetchWebTitleAndDescription, hashCode, isValidURL } from "./utils"; 7 | 8 | const {Option} = Select; 9 | 10 | interface Props { 11 | bookmark?: Bookmark; 12 | options: { 13 | tagsOptions: FilterType[]; 14 | }; 15 | categories: CategoryType[]; 16 | handleSaveBookmark: (bookmark: Bookmark, previousID: string) => void; 17 | } 18 | 19 | interface FieldData { 20 | name: string | number | (string | number)[]; 21 | value?: any; 22 | touched?: boolean; 23 | validating?: boolean; 24 | errors?: string[]; 25 | } 26 | 27 | export function BookmarkForm(props: Props) { 28 | const [form] = Form.useForm(); 29 | // const inputRef = useRef(null) 30 | // const [id, setID] = useState(props.bookmark?.id); 31 | // let index = 0 32 | if (!props.bookmark) return <>; 33 | 34 | const {tagsOptions: tagsList} = props.options; 35 | 36 | // const [items, setItems] = useState( 37 | // categoriesList.map((category) => { 38 | // return category.value 39 | // }) 40 | // ) 41 | 42 | // const handleAreaClick = ( 43 | // e: React.MouseEvent, 44 | // label: string, 45 | // option: DefaultOptionType 46 | // ) => { 47 | // e.stopPropagation() 48 | // console.log("clicked", label, option) 49 | // } 50 | 51 | const categoryDisplayRender = ( 52 | texts: string[], 53 | selectedOptions?: DefaultOptionType[] 54 | ): ReactNode => { 55 | if (!selectedOptions || !texts[0]) return null; 56 | return texts.map((text, i) => { 57 | const option = selectedOptions[i]; 58 | if (!option?.value) return {text}; 59 | if (i === texts.length - 1) { 60 | return {text}; 61 | } 62 | return {text} / ; 63 | }); 64 | }; 65 | 66 | if (props.bookmark && props.bookmark.id) { 67 | props.bookmark.modified = Date.now(); 68 | } 69 | 70 | // const onNameChange = (event: React.ChangeEvent) => { 71 | // setName(event.target.value) 72 | // } 73 | 74 | // const addItem = (e: React.MouseEvent) => { 75 | // e.preventDefault() 76 | // setItems([...items, name || `New item ${index++}`]) 77 | // setName("") 78 | // setTimeout(() => { 79 | // inputRef.current?.focus() 80 | // }, 0) 81 | // } 82 | 83 | const onFieldsChange = async ( 84 | changedFields: FieldData[], 85 | allFields: FieldData[] 86 | ) => { 87 | // 获取当前表单中的url字段 88 | const urlField = allFields.find((f: any) => f.name[0] === "url"); 89 | const nameField = allFields.find((f: any) => f.name[0] === "name"); 90 | 91 | if (!nameField?.value && urlField && isValidURL(urlField.value)) { 92 | try { 93 | const {title, description} = 94 | await fetchWebTitleAndDescription(urlField.value); 95 | 96 | if (title && description) { 97 | form.setFieldValue("name", title); 98 | form.setFieldValue("description", description); 99 | } 100 | } catch (err) { 101 | console.log(err); 102 | } 103 | } 104 | }; 105 | // 定义表单提交的处理函数 106 | const onFinish = (values: { 107 | id: string; 108 | name: string; 109 | url: string; 110 | description: string; 111 | tags: string[]; 112 | category: string[]; 113 | created: number; 114 | modified: number; 115 | }) => { 116 | // 这里可以根据你的需要处理表单提交的数据 117 | const bookmark: Bookmark = { 118 | id: String(hashCode(values.url)), 119 | name: values.name, 120 | url: values.url, 121 | description: values.description, 122 | category: values.category, 123 | tags: values.tags.join(" "), 124 | created: moment(values.created, "YYYY-MM-DD HH:mm").valueOf(), 125 | modified: moment(values.modified, "YYYY-MM-DD HH:mm").valueOf(), 126 | }; 127 | 128 | if (props.bookmark?.id) props.handleSaveBookmark(bookmark, props.bookmark?.id); 129 | else props.handleSaveBookmark(bookmark, ""); 130 | form.resetFields(); 131 | }; 132 | 133 | const onReset = () => { 134 | form.resetFields(); 135 | }; 136 | 137 | return ( 138 |
144 | 156 | 157 | 158 | 171 | 172 | 173 | 185 | 186 | 187 | 196 | 208 | 209 | 220 | 225 | 226 | 240 | 241 | 242 | 256 | 257 | 258 | 259 |
260 | 263 | 266 |
267 |
268 |
269 | ); 270 | } 271 | -------------------------------------------------------------------------------- /src/component/BookmarkManager/BookmarkImporter.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Upload, UploadProps } from "antd"; 3 | import { Bookmark } from "src/types/bookmark"; 4 | import { hashCode } from "./utils"; 5 | import { loadJson, saveJson } from "../../utils/json"; 6 | import { moment, Notice } from "obsidian"; 7 | 8 | interface Props { 9 | handleImportFinished: (importedBookmarks: Bookmark[]) => Promise; 10 | } 11 | 12 | const BookmarkImporter: any = (Props: Props): any => { 13 | 14 | const handleSaveBookmark = async (newBookmark: Bookmark, bookmarks: Bookmark[]) => { 15 | const urlRegEx = 16 | /^(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#?&//=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/g; 17 | if (!urlRegEx.test(newBookmark.url)) { 18 | return; 19 | } 20 | 21 | try { 22 | const isBookmarkExist = bookmarks.some((bookmark) => { 23 | if (bookmark.url === newBookmark.url) { 24 | return true; 25 | } else { 26 | return false; 27 | } 28 | }); 29 | 30 | if (!isBookmarkExist) { 31 | bookmarks.unshift(newBookmark); 32 | } 33 | } catch (err) { 34 | console.log(err); 35 | } 36 | }; 37 | 38 | const uploadProps: UploadProps = { 39 | action: "", 40 | listType: "text", 41 | beforeUpload(file) { 42 | return new Promise((resolve) => { 43 | const reader = new FileReader(); 44 | reader.readAsText(file, "utf-8"); 45 | reader.onload = async () => { 46 | const result = reader.result as string; 47 | if (!result) return; 48 | 49 | const regex = /
(.*)<\/A>/gm; 50 | let bookmarkData; 51 | 52 | const {bookmarks, categories} = await loadJson(this.plugin); 53 | 54 | while ((bookmarkData = regex.exec(result)) !== null) { 55 | // This is necessary to avoid infinite loops with zero-width matches 56 | if (bookmarkData.index === regex.lastIndex) { 57 | regex.lastIndex++; 58 | } 59 | 60 | const categories = this.plugin.settings.bookmarkManager.defaultCategory.split(",").map((c: any) => c.trim()); 61 | 62 | const newBookmark: Bookmark = { 63 | id: String(hashCode(bookmarkData[1])), 64 | name: bookmarkData[3], 65 | url: bookmarkData[1], 66 | description: "", 67 | category: categories.length > 0 ? categories : [""], 68 | tags: "", 69 | created: moment(bookmarkData[2], "X").valueOf() ?? moment().valueOf(), 70 | modified: moment(bookmarkData[2], "X").valueOf() ?? moment().valueOf(), 71 | }; 72 | try { 73 | await handleSaveBookmark(newBookmark, bookmarks); 74 | } catch (err) { 75 | console.log( 76 | "Failed to import this bookmark!", 77 | newBookmark.name 78 | ); 79 | new Notice(`import ${newBookmark.name} faield`); 80 | } 81 | } 82 | 83 | await saveJson(this.plugin, { 84 | bookmarks, 85 | categories, 86 | }); 87 | await Props.handleImportFinished(bookmarks); 88 | }; 89 | 90 | reader.onloadend = async () => { 91 | new Notice("Import successfully!!!"); 92 | }; 93 | }); 94 | }, 95 | }; 96 | 97 | return ( 98 | 99 | 100 | 101 | ); 102 | }; 103 | 104 | export default BookmarkImporter; 105 | -------------------------------------------------------------------------------- /src/component/BookmarkManager/BookmarkManager.tsx: -------------------------------------------------------------------------------- 1 | import { moment, prepareFuzzySearch } from "obsidian"; 2 | import { 3 | Button, 4 | Checkbox, 5 | Col, 6 | ConfigProvider, 7 | Input, 8 | Modal, 9 | Popconfirm, 10 | Row, 11 | Space, 12 | Table, 13 | TableProps, 14 | Tag, 15 | theme, 16 | } from "antd"; 17 | import React, { KeyboardEventHandler, useEffect, useState } from "react"; 18 | import useStateRef from "react-usestateref"; 19 | import { generateColor, generateTagsOptions, stringToCategory } from "./utils"; 20 | import type { Bookmark, CategoryType } from "../../types/bookmark"; 21 | import { ColumnsType } from "antd/es/table"; 22 | import { CheckboxValueType } from "antd/es/checkbox/Group"; 23 | import { BookmarkForm } from "./BookmarkForm"; 24 | import BookmarkImporter from "./BookmarkImporter"; 25 | import SurfingPlugin from "src/surfingIndex"; 26 | import { saveJson } from "../../utils/json"; 27 | import { SurfingView } from "../../surfingView"; 28 | import { t } from "../../translations/helper"; 29 | import { FilterValue, SorterResult } from "antd/es/table/interface"; 30 | import { updateBookmarkBar } from "../BookMarkBar/BookMarkBar"; 31 | 32 | const columnOptions = [ 33 | "name", 34 | "description", 35 | "url", 36 | "category", 37 | "tags", 38 | "created", 39 | "modified", 40 | ]; 41 | 42 | const emptyBookmark = { 43 | id: "", 44 | name: "", 45 | description: "", 46 | url: "", 47 | tags: "", 48 | category: [""], 49 | created: moment().valueOf(), 50 | modified: moment().valueOf(), 51 | }; 52 | 53 | interface Props { 54 | bookmarks: Bookmark[]; 55 | categories: CategoryType[]; 56 | plugin: SurfingPlugin; 57 | } 58 | 59 | export default function BookmarkManager(props: Props) { 60 | const [bookmarks, setBookmarks, bookmarksRef] = useStateRef(props.bookmarks); 61 | const [categories, setCategories] = useState(props.categories); 62 | const options = generateTagsOptions(bookmarks); 63 | const [currentBookmark, setCurrentBookmark] = useState(emptyBookmark); 64 | const [searchWord, setSearchWord] = useState(""); 65 | const [currentPage, setCurrentPage] = useState(1); 66 | const [tagFiltered, setTagFiltered, tagFilteredRef] = useStateRef>({ 67 | tags: null, 68 | }); 69 | const [categoryFiltered, setCategoryFiltered, categoryFilteredRef] = useStateRef>({ 70 | category: null, 71 | }); 72 | const [sortedInfo, setSortedInfo, sortedInfoRef] = useStateRef>({ 73 | order: 'descend', 74 | }); 75 | 76 | const defaultColumns: ColumnsType = [ 77 | { 78 | title: t("Name"), 79 | dataIndex: "name", 80 | key: "name", 81 | render: (text, record) => { 82 | return ( 83 | { 86 | e.preventDefault(); 87 | if (e.ctrlKey || e.metaKey) { 88 | window.open(record.url, "_blank", "external"); 89 | return; 90 | } 91 | SurfingView.spawnWebBrowserView(props.plugin, true, { 92 | url: record.url, 93 | }); 94 | }} 95 | > 96 | {text} 97 | 98 | ); 99 | }, 100 | showSorterTooltip: false, 101 | sorter: (a, b) => { 102 | return a.name.localeCompare(b.name); 103 | }, 104 | sortOrder: sortedInfo.columnKey === 'name' ? sortedInfo.order : null, 105 | }, 106 | { 107 | title: t("Description"), 108 | dataIndex: "description", 109 | key: "description", 110 | onFilter: (value, record) => { 111 | return record.description.indexOf(value as string) === 0; 112 | }, 113 | }, 114 | { 115 | title: t("Url"), 116 | dataIndex: "url", 117 | key: "url", 118 | }, 119 | { 120 | title: t("Category"), 121 | dataIndex: "category", 122 | key: "category", 123 | render: (value) => { 124 | if (value[0] === "") { 125 | return

; 126 | } 127 | return

{value.join(">")}

; 128 | }, 129 | filters: stringToCategory( 130 | props.plugin.settings.bookmarkManager.category 131 | ) as any, 132 | filterMode: props.plugin.settings.bookmarkManager 133 | .defaultFilterType as any, 134 | filterSearch: true, 135 | onFilter: (value, record) => { 136 | return record.category.includes(value as string) || (value === "ROOT" && !props.plugin.settings.bookmarkManager.category.contains(record.category[0] ? record.category[0] : "")); 137 | }, 138 | }, 139 | { 140 | title: t("Tags"), 141 | dataIndex: "tags", 142 | key: "tags", 143 | render: (text: string) => { 144 | if (!text) return ""; 145 | return text.split(" ").map((tag: string) => { 146 | const color = generateColor(tag); 147 | return ( 148 | { 149 | let originalTags = null; 150 | if (tagFilteredRef.current.tags) { 151 | originalTags = tagFilteredRef.current.tags.slice(); 152 | if (!originalTags.contains(tag)) originalTags = [...originalTags, tag]; 153 | } else { 154 | originalTags = [tag]; 155 | } 156 | 157 | setTagFiltered({ 158 | ...tagFilteredRef.current, 159 | tags: originalTags, 160 | }); 161 | }}> 162 | {tag.toUpperCase()} 163 | 164 | ); 165 | }); 166 | }, 167 | filters: options.tagsOptions, 168 | onFilter: (value, record) => { 169 | if (value === "") return record.tags === ""; 170 | return record.tags.indexOf(value as string) === 0; 171 | }, 172 | }, 173 | { 174 | title: t("Created"), 175 | dataIndex: "created", 176 | key: "created", 177 | render: (text: number) => { 178 | return

{moment(text).format("YYYY-MM-DD")}

; 179 | }, 180 | sorter: (a, b) => a.created - b.created, 181 | sortOrder: sortedInfo.columnKey === 'created' ? sortedInfo.order : null, 182 | }, 183 | { 184 | title: t("Modified"), 185 | dataIndex: "modified", 186 | key: "modified", 187 | render: (text: number) => { 188 | return

{moment(text).format("YYYY-MM-DD")}

; 189 | }, 190 | sorter: (a, b) => { 191 | return a.modified - b.modified; 192 | }, 193 | sortOrder: sortedInfo.columnKey === 'modified' ? sortedInfo.order : null, 194 | }, 195 | { 196 | title: t("Action"), 197 | dataIndex: "action", 198 | key: "action", 199 | render: (text, record) => ( 200 | 201 | { 203 | setCurrentBookmark(record); 204 | setModalVisible(true); 205 | }} 206 | > 207 | Edit 208 | 209 | { 212 | handleDeleteBookmark(record); 213 | }} 214 | onCancel={() => { 215 | }} 216 | okText="Yes" 217 | cancelText="No" 218 | > 219 | Delete 220 | 221 | 222 | ), 223 | }, 224 | ]; 225 | 226 | const [checkedColumn, setCheckedColumn] = useState( 227 | props.plugin.settings.bookmarkManager.defaultColumnList 228 | ); 229 | const [columns, setColumns, columnsRef] = useStateRef(defaultColumns.filter((column) => { 230 | return checkedColumn.includes(column.key as string) || column.key === "action"; 231 | })); 232 | 233 | const handleChange: TableProps['onChange'] = (pagination, filters, sorter) => { 234 | setSortedInfo(sorter as SorterResult); 235 | 236 | if (filters.tags !== undefined) setTagFiltered(filters); 237 | else if (filters.category !== undefined) setCategoryFiltered(filters); 238 | }; 239 | 240 | useEffect(() => { 241 | return () => { 242 | setColumns(columnsRef.current.map(item => { 243 | if (item.key === sortedInfoRef.current.columnKey) { 244 | return { 245 | ...item, 246 | sortOrder: sortedInfoRef.current.order, 247 | }; 248 | } 249 | if (item.key == "tags") { 250 | return { 251 | ...item, 252 | filteredValue: tagFilteredRef.current.tags, 253 | }; 254 | } 255 | if (item.key == "category") { 256 | return { 257 | ...item, 258 | filteredValue: tagFilteredRef.current.category, 259 | }; 260 | } 261 | return item; 262 | })); 263 | }; 264 | }, [tagFiltered, categoryFiltered, sortedInfo]); 265 | 266 | const CheckboxGroup = Checkbox.Group; 267 | const onColumnChange = async (list: CheckboxValueType[]) => { 268 | const newColumns = defaultColumns.filter((column) => { 269 | return list.includes(column.key as string) || column.key === "action"; 270 | }); 271 | 272 | setColumns(newColumns); 273 | setCheckedColumn(list); 274 | props.plugin.settings.bookmarkManager.defaultColumnList = list as any; 275 | await props.plugin.saveSettings(); 276 | }; 277 | const [modalVisible, setModalVisible] = useState(false); 278 | 279 | useEffect(() => { 280 | return () => { 281 | const tempCategories = stringToCategory( 282 | props.plugin.settings.bookmarkManager.category 283 | ); 284 | setCategories(tempCategories); 285 | 286 | if (tempCategories) { 287 | saveJson(props.plugin, { 288 | bookmarks: bookmarks, 289 | categories: tempCategories, 290 | }); 291 | } 292 | }; 293 | }, [props.categories]); 294 | 295 | const handleSearch = (value: string) => { 296 | if (value === undefined) value = searchWord; 297 | 298 | const query = prepareFuzzySearch(value); 299 | 300 | if (value === "") { 301 | setBookmarks(props.bookmarks); 302 | } else { 303 | const filteredBookmarks = props.bookmarks.filter((bookmark) => { 304 | return ( 305 | query(bookmark.name.toLocaleLowerCase())?.score || 306 | query(bookmark.description.toLocaleLowerCase())?.score 307 | ); 308 | }); 309 | setBookmarks(filteredBookmarks); 310 | } 311 | 312 | setSearchWord(value); 313 | }; 314 | 315 | const handleCancelSearch: KeyboardEventHandler = ( 316 | event 317 | ) => { 318 | if (event.key === "Escape") { 319 | setBookmarks(props.bookmarks); 320 | setSearchWord(""); 321 | } 322 | }; 323 | 324 | const handleAddBookmark = () => { 325 | setCurrentBookmark(emptyBookmark); 326 | setModalVisible(true); 327 | }; 328 | 329 | const handleDeleteBookmark = async (oldBookmark: Bookmark) => { 330 | const newBookmarks = [...bookmarksRef.current]; 331 | 332 | setBookmarks(newBookmarks.filter((bookmark) => bookmark.id !== oldBookmark.id)); 333 | 334 | await saveJson(props.plugin, { 335 | bookmarks: bookmarksRef.current, 336 | categories: props.categories, 337 | }); 338 | 339 | updateBookmarkBar(props.plugin, bookmarksRef.current, props.categories, false); 340 | }; 341 | 342 | const handleImportFinished = async (importedBookmarks: Bookmark[]): Promise => { 343 | setBookmarks([...importedBookmarks]); 344 | }; 345 | 346 | const handleModalOk = () => { 347 | setCurrentBookmark(emptyBookmark); 348 | setModalVisible(false); 349 | }; 350 | 351 | const handleModalCancel = () => { 352 | setCurrentBookmark(emptyBookmark); 353 | setModalVisible(false); 354 | }; 355 | 356 | const handleSaveBookmark = async (newBookmark: Bookmark, previousId: string) => { 357 | const isBookmarkExist = props.bookmarks.some((bookmark, index) => { 358 | if ( 359 | bookmark.url === newBookmark.url || 360 | bookmark.id === previousId 361 | ) { 362 | bookmarks[index] = newBookmark; 363 | setBookmarks(bookmarks); 364 | 365 | setModalVisible(false); 366 | setCurrentBookmark(emptyBookmark); 367 | 368 | return true; 369 | } else { 370 | return false; 371 | } 372 | }); 373 | 374 | if (!isBookmarkExist) { 375 | bookmarks.unshift(newBookmark); 376 | setBookmarks(bookmarks); 377 | setModalVisible(false); 378 | } 379 | 380 | await saveJson(props.plugin, { 381 | bookmarks: bookmarks, 382 | categories: props.categories, 383 | }); 384 | 385 | updateBookmarkBar(props.plugin, bookmarks, props.categories, false); 386 | }; 387 | 388 | const importProps = { 389 | handleImportFinished: (importedBookmarks: Bookmark[]) => handleImportFinished(importedBookmarks), 390 | }; 391 | 392 | return ( 393 |
394 | 402 |
403 | 404 | 405 |
406 | { 409 | handleSearch(e.target.value); 410 | }} 411 | defaultValue={searchWord} 412 | placeholder={` ${t("Search from ")} ${ 413 | bookmarks.length 414 | } ${t(" bookmarks")} `} 415 | onPressEnter={(e) => { 416 | handleSearch(e.currentTarget.value); 417 | }} 418 | onKeyDown={handleCancelSearch} 419 | allowClear 420 | /> 421 | 422 | 423 |
424 | 425 | 426 | 431 | 432 |
433 |
434 | { 446 | setCurrentPage(page); 447 | } 448 | }} 449 | scroll={{ 450 | y: "100%", 451 | x: "fit-content", 452 | }} 453 | sticky={true} 454 | rowKey="id" 455 | showSorterTooltip={false} 456 | onChange={handleChange} 457 | >
458 | 467 | 473 | 474 |
475 |
476 | ); 477 | } 478 | -------------------------------------------------------------------------------- /src/component/BookmarkManager/utils.ts: -------------------------------------------------------------------------------- 1 | import { request } from "obsidian"; 2 | import { Bookmark, CategoryType, FilterType } from "../../types/bookmark"; 3 | 4 | export function hashCode(str: string) { 5 | let hash = 0; 6 | for (let i = 0; i < str.length; i++) { 7 | hash = (hash << 5) - hash + str.charCodeAt(i); 8 | hash = hash & hash; // Convert to 32bit integer 9 | } 10 | return hash; 11 | } 12 | 13 | export function generateColor(str: string) { 14 | // 计算字符串的哈希值 15 | const hash = hashCode(str); 16 | 17 | // 生成颜色值 18 | let color = "#"; 19 | for (let i = 0; i < 3; i++) { 20 | const value = (hash >> (i * 8)) & 0xff; 21 | color += ("00" + value.toString(16)).substr(-2); 22 | } 23 | return color; 24 | } 25 | 26 | export function generateTagsOptions(bookmarks: Bookmark[]) { 27 | const tagsOptions: FilterType[] = []; 28 | const tags: Set = new Set(); 29 | for (let i = 0; i < bookmarks?.length; i++) { 30 | bookmarks[i].tags.split(" ").forEach((tag: string) => { 31 | tags.add(tag); 32 | }); 33 | } 34 | 35 | for (const tag of tags) { 36 | tagsOptions.push({ 37 | text: tag, 38 | value: tag 39 | }); 40 | } 41 | 42 | return { 43 | tagsOptions 44 | }; 45 | } 46 | 47 | export function isValidURL(str: string): boolean { 48 | // 定义一个正则表达式,用于匹配合法的URL 49 | const regexp = 50 | /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/; 51 | return regexp.test(str); 52 | } 53 | 54 | export async function nonElectronGetPageTitle(url: string): Promise<{ 55 | title: string | null, 56 | name: string | null, 57 | description: string | null 58 | }> { 59 | try { 60 | const html = await request({url}); 61 | 62 | const doc = new DOMParser().parseFromString(html, "text/html"); 63 | const title = doc.querySelector("title")?.innerText; 64 | 65 | // 从文档中查找标签,并获取其name和description属性的值 66 | const nameTag = doc.querySelector("meta[name=name]"); 67 | const name = nameTag ? nameTag.getAttribute("content") : null; 68 | const descriptionTag = doc.querySelector("meta[name=description]"); 69 | 70 | const description = descriptionTag 71 | ? descriptionTag.getAttribute("content") 72 | : null; 73 | 74 | return { 75 | title: title ? title : "", 76 | name, 77 | description 78 | }; 79 | } catch (ex) { 80 | console.error(ex); 81 | return { 82 | title: "", 83 | name: "", 84 | description: "" 85 | }; 86 | } 87 | } 88 | 89 | export async function fetchWebTitleAndDescription(url: string): Promise<{ 90 | title: string | null, 91 | name: string | null, 92 | description: string | null 93 | }> { 94 | // If we're on Desktop use the Electron scraper 95 | if (!(url.startsWith("http") || url.startsWith("https"))) { 96 | url = "https://" + url; 97 | } 98 | 99 | return nonElectronGetPageTitle(url); 100 | } 101 | 102 | export function stringToCategory(categoryString: string): CategoryType[] { 103 | const categoryOptions: CategoryType[] = doParse(categoryString); 104 | 105 | categoryOptions.unshift({ 106 | "value": "ROOT", 107 | "text": "ROOT", 108 | "label": "ROOT", 109 | "children": [] 110 | }); 111 | 112 | return categoryOptions; 113 | } 114 | 115 | export function doParse(categoryString: string): CategoryType[] { 116 | const categoryOptions: CategoryType[] = []; 117 | const lines = categoryString.split('\n'); 118 | const regex = /^(\s*)-\s(.*)/; 119 | 120 | lines.forEach(function (line, i) { 121 | const matches = line.match(regex); 122 | if (matches) { 123 | let level; 124 | const blank = matches[1]; 125 | 126 | if (new RegExp(/^\t+/g).test(blank)) level = blank.length; 127 | else level = blank.length / 4; 128 | 129 | const title = matches[2]; 130 | const node = Node(title); 131 | 132 | if (level === 0) { 133 | categoryOptions.push(node); 134 | } else { 135 | const p = getParentNode(level, categoryOptions); 136 | // For menu mode of el-select 137 | p?.children?.push(node); 138 | } 139 | } 140 | }); 141 | return categoryOptions; 142 | } 143 | 144 | function getParentNode(level: number, categoryOptions: CategoryType[]) { 145 | let i = 0; 146 | let node = categoryOptions[categoryOptions.length - 1]; 147 | while (i < level - 1) { 148 | const childNodes = node.children; 149 | if (childNodes) node = childNodes[childNodes.length - 1]; 150 | i++; 151 | } 152 | 153 | if (!node?.children && node) { 154 | node.children = []; 155 | } 156 | return node; 157 | } 158 | 159 | function Node(title: string) { 160 | return { 161 | "value": title, 162 | "text": title, 163 | "label": title, 164 | }; 165 | } 166 | -------------------------------------------------------------------------------- /src/component/EmbededWebView.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { clipboard, remote } from "electron"; 3 | import { SurfingView } from "../surfingView"; 4 | import { t } from "../translations/helper"; 5 | import { FileSystemAdapter, moment, App } from "obsidian"; 6 | import SurfingPlugin from "src/surfingIndex"; 7 | 8 | export class EmbededWebView { 9 | private contentEl: HTMLElement; 10 | private webviewEl: HTMLElement & { 11 | getWebContentsId: () => number; 12 | }; 13 | private node: any; 14 | private file: any; 15 | private menu: any; 16 | private app: App; 17 | private plugin: SurfingPlugin; 18 | private currentUrl: string; 19 | 20 | constructor(node: any, file: any, app: App, plugin: SurfingPlugin) { 21 | this.contentEl = node.containerEl; 22 | this.node = node; 23 | this.file = file; 24 | this.app = app; 25 | this.plugin = plugin; 26 | } 27 | 28 | load() { 29 | // this.contentEl.empty(); 30 | 31 | this.appendWebView(); 32 | this.contentEl.addClass("wb-view-content-embeded"); 33 | } 34 | 35 | unload() { 36 | this.contentEl.removeClass("wb-view-content-embeded"); 37 | // this.contentEl.empty(); 38 | } 39 | 40 | loadFile(file: any) { 41 | // this.file = file; 42 | this.load(); 43 | } 44 | 45 | appendWebView() { 46 | const doc = this.contentEl.doc; 47 | this.webviewEl = doc.createElement("webview"); 48 | this.webviewEl.setAttribute("allowpopups", ""); 49 | this.webviewEl.addClass("wb-frame"); 50 | const self = this; 51 | 52 | const adapter = this.app.vault.adapter as FileSystemAdapter; 53 | this.currentUrl = 54 | "file:///" + 55 | (adapter.getBasePath() + "/" + this.file.path) 56 | .toString() 57 | .replace(/\s/g, "%20"); 58 | 59 | if (this.currentUrl) 60 | this.webviewEl.setAttribute("src", this.currentUrl); 61 | else this.webviewEl.setAttribute("src", this.file.path); 62 | // this.node.placeholderEl.innerText = this.node.url; 63 | 64 | this.webviewEl.addEventListener("dom-ready", (event: any) => { 65 | // @ts-ignore 66 | const webContents = remote.webContents.fromId( 67 | this.webviewEl.getWebContentsId() 68 | ); 69 | 70 | // Open new browser tab if the web view requests it. 71 | webContents.setWindowOpenHandler((event: any) => { 72 | if (event.disposition !== "foreground-tab") { 73 | SurfingView.spawnWebBrowserView(self.plugin, true, { url: event.url }); 74 | return { 75 | action: "allow", 76 | }; 77 | } 78 | 79 | // if (this.canvas) { 80 | // const linkNode = this.canvas.createLinkNode(event.url, { 81 | // x: this.node.x + this.node.width + 20, 82 | // y: this.node.y 83 | // }, { 84 | // height: this.node.height, 85 | // width: this.node.width 86 | // }); 87 | // this.canvas.deselectAll(); 88 | // this.canvas.addNode(linkNode); 89 | // 90 | // this.canvas.select(linkNode); 91 | // this.canvas.zoomToSelection(); 92 | // this.canvas.requestSave(); 93 | // 94 | // return { 95 | // action: "allow", 96 | // } 97 | // } 98 | }); 99 | 100 | try { 101 | const pluginSettings = 102 | this.app.plugins.getPlugin("surfing").settings; 103 | const highlightFormat = pluginSettings.highlightFormat; 104 | const getCurrentTime = () => { 105 | let link = ""; 106 | // eslint-disable-next-line no-useless-escape 107 | const timeString = highlightFormat.match( 108 | /\{TIME\:[^\{\}\[\]]*\}/g 109 | )?.[0]; 110 | if (timeString) { 111 | // eslint-disable-next-line no-useless-escape 112 | const momentTime = moment().format( 113 | timeString.replace(/{TIME:([^\}]*)}/g, "$1") 114 | ); 115 | link = highlightFormat.replace(timeString, momentTime); 116 | return link; 117 | } 118 | return link; 119 | }; 120 | webContents 121 | .executeJavaScript( 122 | ` 123 | window.addEventListener('dragstart', (e) => { 124 | if(e.ctrlKey || e.metaKey) { 125 | e.dataTransfer.clearData(); 126 | const selectionText = document.getSelection().toString(); 127 | const linkToHighlight = e.srcElement.baseURI.replace(/\#\:\~\:text\=(.*)/g, "") + "#:~:text=" + encodeURIComponent(selectionText); 128 | let link = ""; 129 | if ("${highlightFormat}".includes("{TIME")) { 130 | link = "${getCurrentTime()}"; 131 | // // eslint-disable-next-line no-useless-escape 132 | // const timeString = "${highlightFormat}".match(/\{TIME\:[^\{\}\[\]]*\}/g)?.[0]; 133 | // if (timeString) { 134 | // // eslint-disable-next-line no-useless-escape 135 | // const momentTime = moment().format(timeString.replace(/{TIME:([^\}]*)}/g, "$1")); 136 | // link = "${highlightFormat}".replace(timeString, momentTime); 137 | // } 138 | } 139 | link = (link != "" ? link : "${highlightFormat}").replace(/\{URL\}/g, linkToHighlight).replace(/\{CONTENT\}/g, selectionText.replace(/\\n/g, " ")); 140 | 141 | e.dataTransfer.setData('text/plain', link); 142 | console.log(e); 143 | } 144 | }); 145 | `, 146 | true 147 | ) 148 | .then((result: any) => {}); 149 | } catch (err) { 150 | console.error("Failed to add event: ", err); 151 | } 152 | 153 | webContents.on( 154 | "context-menu", 155 | (event: any, params: any) => { 156 | event.preventDefault(); 157 | 158 | const { Menu, MenuItem } = remote; 159 | this.menu = new Menu(); 160 | // Basic Menu For Webview 161 | // TODO: Support adding different commands to the menu. 162 | // Possible to use Obsidian Default API? 163 | this.menu.append( 164 | new MenuItem({ 165 | label: t("Open Current URL In External Browser"), 166 | click: function () { 167 | window.open(params.pageURL, "_blank"); 168 | }, 169 | }) 170 | ); 171 | 172 | this.menu.append( 173 | new MenuItem({ 174 | label: "Open Current URL In Surfing", 175 | click: function () { 176 | window.open(params.pageURL); 177 | }, 178 | }) 179 | ); 180 | 181 | if (params.selectionText) { 182 | const pluginSettings = 183 | this.app.plugins.getPlugin("surfing").settings; 184 | 185 | this.menu.append(new MenuItem({ type: "separator" })); 186 | this.menu.append( 187 | new MenuItem({ 188 | label: t("Search Text"), 189 | click: function () { 190 | try { 191 | SurfingView.spawnWebBrowserView(self.plugin, true, { 192 | url: params.selectionText, 193 | }); 194 | console.log( 195 | "Page URL copied to clipboard" 196 | ); 197 | } catch (err) { 198 | console.error("Failed to copy: ", err); 199 | } 200 | }, 201 | }) 202 | ); 203 | this.menu.append(new MenuItem({ type: "separator" })); 204 | this.menu.append( 205 | new MenuItem({ 206 | label: t("Copy Plain Text"), 207 | click: function () { 208 | try { 209 | webContents.copy(); 210 | console.log( 211 | "Page URL copied to clipboard" 212 | ); 213 | } catch (err) { 214 | console.error("Failed to copy: ", err); 215 | } 216 | }, 217 | }) 218 | ); 219 | const highlightFormat = pluginSettings.highlightFormat; 220 | this.menu.append( 221 | new MenuItem({ 222 | label: t("Copy Link to Highlight"), 223 | click: function () { 224 | try { 225 | // eslint-disable-next-line no-useless-escape 226 | const linkToHighlight = 227 | params.pageURL.replace( 228 | /\#\:\~\:text\=(.*)/g, 229 | "" 230 | ) + 231 | "#:~:text=" + 232 | encodeURIComponent( 233 | params.selectionText 234 | ); 235 | const selectionText = 236 | params.selectionText; 237 | let link = ""; 238 | if (highlightFormat.contains("{TIME")) { 239 | // eslint-disable-next-line no-useless-escape 240 | const timeString = 241 | highlightFormat.match( 242 | /\{TIME\:[^\{\}\[\]]*\}/g 243 | )?.[0]; 244 | if (timeString) { 245 | // eslint-disable-next-line no-useless-escape 246 | const momentTime = 247 | moment().format( 248 | timeString.replace( 249 | /{TIME:([^\}]*)}/g, 250 | "$1" 251 | ) 252 | ); 253 | link = highlightFormat.replace( 254 | timeString, 255 | momentTime 256 | ); 257 | } 258 | } 259 | link = ( 260 | link != "" ? link : highlightFormat 261 | ) 262 | .replace( 263 | /\{URL\}/g, 264 | linkToHighlight 265 | ) 266 | .replace( 267 | /\{CONTENT\}/g, 268 | selectionText 269 | ); 270 | clipboard.writeText(link); 271 | console.log( 272 | "Link URL copied to clipboard" 273 | ); 274 | } catch (err) { 275 | console.error("Failed to copy: ", err); 276 | } 277 | }, 278 | }) 279 | ); 280 | 281 | this.menu.popup(webContents); 282 | } 283 | 284 | if (params.pageURL?.contains("bilibili.com/")) { 285 | this.menu.append( 286 | new MenuItem({ 287 | label: t("Copy Video Timestamp"), 288 | click: function () { 289 | try { 290 | webContents 291 | .executeJavaScript( 292 | ` 293 | var time = document.querySelectorAll('.bpx-player-ctrl-time-current')[0].innerHTML; 294 | var timeYMSArr=time.split(':'); 295 | var joinTimeStr='00h00m00s'; 296 | if(timeYMSArr.length===3){ 297 | joinTimeStr=timeYMSArr[0]+'h'+timeYMSArr[1]+'m'+timeYMSArr[2]+'s'; 298 | }else if(timeYMSArr.length===2){ 299 | joinTimeStr=timeYMSArr[0]+'m'+timeYMSArr[1]+'s'; 300 | } 301 | var timeStr= ""; 302 | var pageStrMatch = window.location.href.match(/(p=[1-9]{1,})/g); 303 | var pageStr = ""; 304 | if(typeof pageStrMatch === "object" && pageStrMatch?.length > 0){ 305 | pageStr = '&' + pageStrMatch[0]; 306 | }else if(typeof pageStrMatch === "string") { 307 | pageStr = '&' + pageStrMatch; 308 | } 309 | timeStr = window.location.href.split('?')[0]+'?t=' + joinTimeStr + pageStr; 310 | `, 311 | true 312 | ) 313 | .then((result: any) => { 314 | clipboard.writeText( 315 | "[" + 316 | result 317 | .split("?t=")[1] 318 | .replace( 319 | /&p=[1-9]{1,}/g, 320 | "" 321 | ) + 322 | "](" + 323 | result + 324 | ")" 325 | ); // Will be the JSON object from the fetch call 326 | }); 327 | console.log( 328 | "Page URL copied to clipboard" 329 | ); 330 | } catch (err) { 331 | console.error("Failed to copy: ", err); 332 | } 333 | }, 334 | }) 335 | ); 336 | } 337 | 338 | setTimeout(() => { 339 | this.menu.popup(webContents); 340 | // Dirty workaround for showing the menu, when currentUrl is not the same as the url of the webview 341 | if ( 342 | this.node.url !== params.pageURL && 343 | !params.selectionText 344 | ) { 345 | this.menu.popup(webContents); 346 | } 347 | }, 0); 348 | }, 349 | false 350 | ); 351 | }); 352 | 353 | this.webviewEl.addEventListener("will-navigate", (event: any) => { 354 | this.currentUrl = event.url; 355 | // this.webviewEl.setAttribute("src", event.url); 356 | }); 357 | 358 | this.webviewEl.addEventListener( 359 | "did-navigate-in-page", 360 | (event: any) => { 361 | this.currentUrl = event.url; 362 | this.webviewEl.setAttribute("src", event.url); 363 | } 364 | ); 365 | 366 | this.webviewEl.addEventListener("destroyed", () => { 367 | if (doc !== this.contentEl.doc) { 368 | this.webviewEl.detach(); 369 | this.appendWebView(); 370 | } 371 | }); 372 | 373 | doc.contains(this.contentEl) 374 | ? this.contentEl.appendChild(this.webviewEl) 375 | : this.contentEl.onNodeInserted(() => { 376 | this.contentEl.doc === doc 377 | ? this.contentEl.appendChild(this.webviewEl) 378 | : this.appendWebView(); 379 | }); 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /src/component/HeaderBar.ts: -------------------------------------------------------------------------------- 1 | import SurfingPlugin from "../surfingIndex"; 2 | import { t } from "../translations/helper"; 3 | import { Component, ItemView, Scope, setIcon } from "obsidian"; 4 | import { BookmarkSuggester } from "./suggester/bookmarkSuggester"; 5 | import { FileSuggester } from "./suggester/fileSuggester"; 6 | 7 | export class HeaderBar extends Component { 8 | plugin: SurfingPlugin; 9 | private searchBar: HTMLInputElement; 10 | private onSearchBarEnterListener = new Array<(url: string) => void>(); 11 | private view: ItemView; 12 | private parentEl: Element; 13 | private removeHeaderChild = true; 14 | 15 | constructor( 16 | parent: Element, 17 | plugin: SurfingPlugin, 18 | view: ItemView, 19 | removeHeaderChild?: boolean 20 | ) { 21 | super(); 22 | this.plugin = plugin; 23 | this.view = view; 24 | 25 | this.parentEl = parent; 26 | if (removeHeaderChild !== undefined) 27 | this.removeHeaderChild = removeHeaderChild; 28 | } 29 | 30 | onLoad() { 31 | // CSS class removes the gradient at the right of the header bar. 32 | this.parentEl.addClass("wb-header-bar"); 33 | 34 | if (this.removeHeaderChild) this.parentEl.empty(); 35 | 36 | this.initScope(); 37 | 38 | if ( 39 | this.plugin.settings.showRefreshButton && 40 | this.removeHeaderChild && 41 | this.view.getViewType() !== "empty" 42 | ) { 43 | const refreshButton = this.parentEl.createEl("div", { 44 | cls: "wb-refresh-button", 45 | }); 46 | refreshButton.addEventListener("click", () => { 47 | this.view.leaf.rebuildView(); 48 | }); 49 | 50 | setIcon(refreshButton, "refresh-cw"); 51 | } 52 | 53 | // Create search bar in header bar. 54 | // Use Obsidian CreateEL method. 55 | this.searchBar = this.parentEl.createEl("input", { 56 | type: "text", 57 | placeholder: 58 | t("Search with") + 59 | this.plugin.settings.defaultSearchEngine + 60 | t("or enter address"), 61 | cls: "wb-search-bar", 62 | }); 63 | 64 | this.registerDomEvent( 65 | this.searchBar, 66 | "keydown", 67 | (event: KeyboardEvent) => { 68 | if (!event) { 69 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 70 | const event = window.event as KeyboardEvent; 71 | } 72 | if (event.key === "Enter") { 73 | // When enter is pressed, search for the url. 74 | for (const listener of this.onSearchBarEnterListener) { 75 | listener(this.searchBar.value); 76 | } 77 | } 78 | } 79 | ); 80 | 81 | if (!this.plugin.settings.bookmarkManager.openBookMark) 82 | new FileSuggester(this.plugin.app, this.plugin, this.searchBar, this.view); 83 | if (this.plugin.settings.bookmarkManager.openBookMark) 84 | new BookmarkSuggester(this.plugin.app, this.plugin, this.searchBar); 85 | 86 | // Use focusin to bubble up to the parent 87 | // Rather than just input element itself. 88 | // this.searchBar.addEventListener("focusin", (event: FocusEvent) => { 89 | // this.searchBar.select(); 90 | // }); 91 | this.registerDomEvent( 92 | this.searchBar, 93 | "focusin", 94 | (event: FocusEvent) => { 95 | this.searchBar.select(); 96 | } 97 | ); 98 | 99 | // When focusout, unselect the text to prevent it is still selected when focus back 100 | // It will trigger some unexpected behavior,like you will not select all text and the cursor will set to current position; 101 | // The expected behavior is that you will select all text when focus back; 102 | // this.searchBar.addEventListener("focusout", (event: FocusEvent) => { 103 | // window.getSelection()?.removeAllRanges(); 104 | // if (!this.removeChild) { 105 | // this.searchBar.detach(); 106 | // } 107 | // }); 108 | this.registerDomEvent( 109 | this.searchBar, 110 | "focusout", 111 | (event: FocusEvent) => { 112 | window.getSelection()?.removeAllRanges(); 113 | if (!this.removeHeaderChild) { 114 | this.searchBar.detach(); 115 | } 116 | } 117 | ); 118 | } 119 | 120 | initScope() { 121 | // console.log(this.view.scope); 122 | if (!this.view.scope) { 123 | this.view.scope = new Scope(this.plugin.app.scope); 124 | (this.view.scope as Scope).register([], "/", (evt) => { 125 | if (!this.plugin.settings.focusSearchBarViaKeyboard) return; 126 | if (evt.target === this.searchBar) return; 127 | evt.preventDefault(); 128 | this.searchBar.focus(); 129 | }); 130 | } else { 131 | (this.view.scope as Scope).register([], "/", (evt) => { 132 | if (!this.plugin.settings.focusSearchBarViaKeyboard) return; 133 | if (evt.target === this.searchBar) return; 134 | evt.preventDefault(); 135 | this.searchBar.focus(); 136 | }); 137 | } 138 | } 139 | 140 | addOnSearchBarEnterListener(listener: (url: string) => void) { 141 | this.onSearchBarEnterListener.push(listener); 142 | } 143 | 144 | setSearchBarUrl(url: string) { 145 | this.searchBar.value = url; 146 | } 147 | 148 | focus() { 149 | this.searchBar.focus(); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/component/InNodeWebView.ts: -------------------------------------------------------------------------------- 1 | import { InPageHeaderBar } from "./InPageHeaderBar"; 2 | // @ts-ignore 3 | import { clipboard, remote } from "electron"; 4 | import { SurfingView } from "../surfingView"; 5 | import { t } from "../translations/helper"; 6 | import { ExtraButtonComponent, moment } from "obsidian"; 7 | import { getUrl } from "../utils/url"; 8 | import SurfingPlugin from "../surfingIndex"; 9 | 10 | export class InNodeWebView { 11 | private contentEl: HTMLElement; 12 | private webviewEl: HTMLElement; 13 | private canvas: any; 14 | private node: any; 15 | private searchBarEl: InPageHeaderBar; 16 | 17 | private currentUrl: string; 18 | private plugin: SurfingPlugin; 19 | 20 | private type: 'canvas' | 'inline'; 21 | 22 | private editor: any; 23 | private widget: any; 24 | 25 | constructor(node: any, plugin: SurfingPlugin, type: 'canvas' | 'inline', canvas?: any) { 26 | this.contentEl = node.contentEl; 27 | this.node = node; 28 | this.canvas = canvas; 29 | 30 | if (this.type === 'inline') { 31 | this.editor = this.node?.editor; 32 | this.widget = this.node?.widget; 33 | } 34 | 35 | this.plugin = plugin; 36 | this.type = type; 37 | } 38 | 39 | onload() { 40 | this.contentEl.empty(); 41 | 42 | this.type === 'canvas' && this.appendSearchBar(); 43 | this.type === 'inline' && this.appendShowOriginalCode(); 44 | this.appendWebView(); 45 | 46 | this.contentEl.toggleClass("wb-view-content", true); 47 | this.type === 'inline' && this.contentEl.toggleClass('wb-browser-inline', true); 48 | } 49 | 50 | appendShowOriginalCode() { 51 | const btnEl = this.contentEl.createEl('div'); 52 | btnEl.addClass('wb-show-original-code'); 53 | new ExtraButtonComponent(btnEl).setIcon('code-2').setTooltip(t('Show original url')); 54 | // .onClick(() => { 55 | // // Dispatch selection event to the codemirror editor based on widget start and end position. 56 | // const {start, end} = this.widget; 57 | // this.editor.dispatch({ 58 | // selection: EditorSelection.create([ 59 | // EditorSelection.range(start, end - 1), 60 | // // EditorSelection.range(6, 7), 61 | // EditorSelection.cursor(start) 62 | // ], 0) 63 | // }); 64 | // }); 65 | } 66 | 67 | appendSearchBar() { 68 | this.searchBarEl = new InPageHeaderBar(this.plugin.app, this.node, this.node.url); 69 | this.searchBarEl.onload(); 70 | this.currentUrl = this.node.url; 71 | this.searchBarEl.setSearchBarUrl(this.node.url); 72 | 73 | this.searchBarEl.addOnSearchBarEnterListener((url: string) => { 74 | const finalURL = getUrl(url, this.plugin); 75 | if (finalURL) this.currentUrl = finalURL; 76 | else this.currentUrl = url; 77 | 78 | this.webviewEl.setAttribute("src", this.currentUrl); 79 | this.searchBarEl.setSearchBarUrl(this.currentUrl); 80 | 81 | const oldData = this.node.getData(); 82 | if (oldData.url === this.currentUrl) return; 83 | oldData.url = this.currentUrl; 84 | this.node.setData(oldData); 85 | this.node.canvas?.requestSave(); 86 | 87 | this.node.render(); 88 | }); 89 | } 90 | 91 | appendWebView() { 92 | const doc = this.contentEl.doc; 93 | this.webviewEl = doc.createElement('webview'); 94 | this.webviewEl.setAttribute("allowpopups", ""); 95 | this.webviewEl.addClass("wb-frame"); 96 | const self = this; 97 | 98 | if (this.currentUrl) this.webviewEl.setAttribute("src", this.currentUrl); 99 | else this.webviewEl.setAttribute("src", this.node.url); 100 | // this.node.placeholderEl.innerText = this.node.url; 101 | 102 | this.webviewEl.addEventListener("dom-ready", (event: any) => { 103 | // @ts-ignore 104 | const webContents = remote.webContents.fromId(this.webviewEl.getWebContentsId()); 105 | 106 | // Open new browser tab if the web view requests it. 107 | webContents.setWindowOpenHandler((event: any) => { 108 | if (event.disposition !== "foreground-tab") { 109 | SurfingView.spawnWebBrowserView(self.plugin, true, {url: event.url}); 110 | return { 111 | action: "allow", 112 | }; 113 | } 114 | 115 | if (this.canvas) { 116 | const linkNode = this.canvas.createLinkNode(event.url, { 117 | x: this.node.x + this.node.width + 20, 118 | y: this.node.y 119 | }, { 120 | height: this.node.height, 121 | width: this.node.width 122 | }); 123 | this.canvas.deselectAll(); 124 | this.canvas.addNode(linkNode); 125 | 126 | this.canvas.select(linkNode); 127 | this.canvas.zoomToSelection(); 128 | this.canvas.requestSave(); 129 | 130 | return { 131 | action: "allow", 132 | }; 133 | } 134 | 135 | }); 136 | 137 | try { 138 | const pluginSettings = this.plugin.settings; 139 | const highlightFormat = pluginSettings.highlightFormat; 140 | const getCurrentTime = () => { 141 | let link = ""; 142 | // eslint-disable-next-line no-useless-escape 143 | const timeString = highlightFormat.match(/\{TIME\:[^\{\}\[\]]*\}/g)?.[0]; 144 | if (timeString) { 145 | // eslint-disable-next-line no-useless-escape 146 | const momentTime = moment().format(timeString.replace(/{TIME:([^\}]*)}/g, "$1")); 147 | link = highlightFormat.replace(timeString, momentTime); 148 | return link; 149 | } 150 | return link; 151 | }; 152 | webContents.executeJavaScript(` 153 | window.addEventListener('dragstart', (e) => { 154 | if(e.ctrlKey || e.metaKey) { 155 | e.dataTransfer.clearData(); 156 | const selectionText = document.getSelection().toString(); 157 | const linkToHighlight = e.srcElement.baseURI.replace(/\#\:\~\:text\=(.*)/g, "") + "#:~:text=" + encodeURIComponent(selectionText); 158 | let link = ""; 159 | if ("${highlightFormat}".includes("{TIME")) { 160 | link = "${getCurrentTime()}"; 161 | // // eslint-disable-next-line no-useless-escape 162 | // const timeString = "${highlightFormat}".match(/\{TIME\:[^\{\}\[\]]*\}/g)?.[0]; 163 | // if (timeString) { 164 | // // eslint-disable-next-line no-useless-escape 165 | // const momentTime = moment().format(timeString.replace(/{TIME:([^\}]*)}/g, "$1")); 166 | // link = "${highlightFormat}".replace(timeString, momentTime); 167 | // } 168 | } 169 | link = (link != "" ? link : "${highlightFormat}").replace(/\{URL\}/g, linkToHighlight).replace(/\{CONTENT\}/g, selectionText.replace(/\\n/g, " ")); 170 | 171 | e.dataTransfer.setData('text/plain', link); 172 | console.log(e); 173 | } 174 | }); 175 | `, true).then((result: any) => { 176 | }); 177 | } catch (err) { 178 | console.error('Failed to add event: ', err); 179 | } 180 | 181 | 182 | webContents.on("context-menu", (event: any, params: any) => { 183 | event.preventDefault(); 184 | 185 | const {Menu, MenuItem} = remote; 186 | const menu = new Menu(); 187 | // Basic Menu For Webview 188 | // TODO: Support adding different commands to the menu. 189 | // Possible to use Obsidian Default API? 190 | menu.append( 191 | new MenuItem( 192 | { 193 | label: t('Open Current URL In External Browser'), 194 | click: function () { 195 | window.open(params.pageURL, "_blank"); 196 | } 197 | } 198 | ) 199 | ); 200 | 201 | menu.append( 202 | new MenuItem( 203 | { 204 | label: 'Open Current URL In Surfing', 205 | click: function () { 206 | window.open(params.pageURL); 207 | } 208 | } 209 | ) 210 | ); 211 | 212 | if (params.selectionText) { 213 | const pluginSettings = this.plugin.settings; 214 | 215 | menu.append(new MenuItem({type: 'separator'})); 216 | menu.append(new MenuItem({ 217 | label: t('Search Text'), click: function () { 218 | try { 219 | SurfingView.spawnWebBrowserView(self.plugin, true, {url: params.selectionText}); 220 | console.log('Page URL copied to clipboard'); 221 | } catch (err) { 222 | console.error('Failed to copy: ', err); 223 | } 224 | } 225 | })); 226 | menu.append(new MenuItem({type: 'separator'})); 227 | menu.append(new MenuItem({ 228 | label: t('Copy Plain Text'), click: function () { 229 | try { 230 | webContents.copy(); 231 | console.log('Page URL copied to clipboard'); 232 | } catch (err) { 233 | console.error('Failed to copy: ', err); 234 | } 235 | } 236 | })); 237 | const highlightFormat = pluginSettings.highlightFormat; 238 | menu.append(new MenuItem({ 239 | label: t('Copy Link to Highlight'), click: function () { 240 | try { 241 | // eslint-disable-next-line no-useless-escape 242 | const linkToHighlight = params.pageURL.replace(/\#\:\~\:text\=(.*)/g, "") + "#:~:text=" + encodeURIComponent(params.selectionText); 243 | const selectionText = params.selectionText; 244 | let link = ""; 245 | if (highlightFormat.contains("{TIME")) { 246 | // eslint-disable-next-line no-useless-escape 247 | const timeString = highlightFormat.match(/\{TIME\:[^\{\}\[\]]*\}/g)?.[0]; 248 | if (timeString) { 249 | // eslint-disable-next-line no-useless-escape 250 | const momentTime = moment().format(timeString.replace(/{TIME:([^\}]*)}/g, "$1")); 251 | link = highlightFormat.replace(timeString, momentTime); 252 | } 253 | } 254 | link = (link != "" ? link : highlightFormat).replace(/\{URL\}/g, linkToHighlight).replace(/\{CONTENT\}/g, selectionText); 255 | clipboard.writeText(link); 256 | console.log('Link URL copied to clipboard'); 257 | } catch (err) { 258 | console.error('Failed to copy: ', err); 259 | } 260 | } 261 | })); 262 | 263 | menu.popup(webContents); 264 | } 265 | 266 | if (params.pageURL?.contains("bilibili.com/")) { 267 | menu.append(new MenuItem({ 268 | label: t('Copy Video Timestamp'), click: function () { 269 | try { 270 | webContents.executeJavaScript(` 271 | var time = document.querySelectorAll('.bpx-player-ctrl-time-current')[0].innerHTML; 272 | var timeYMSArr=time.split(':'); 273 | var joinTimeStr='00h00m00s'; 274 | if(timeYMSArr.length===3){ 275 | joinTimeStr=timeYMSArr[0]+'h'+timeYMSArr[1]+'m'+timeYMSArr[2]+'s'; 276 | }else if(timeYMSArr.length===2){ 277 | joinTimeStr=timeYMSArr[0]+'m'+timeYMSArr[1]+'s'; 278 | } 279 | var timeStr= ""; 280 | var pageStrMatch = window.location.href.match(/(p=[1-9]{1,})/g); 281 | var pageStr = ""; 282 | if(typeof pageStrMatch === "object" && pageStrMatch?.length > 0){ 283 | pageStr = '&' + pageStrMatch[0]; 284 | }else if(typeof pageStrMatch === "string") { 285 | pageStr = '&' + pageStrMatch; 286 | } 287 | timeStr = window.location.href.split('?')[0]+'?t=' + joinTimeStr + pageStr; 288 | `, true).then((result: any) => { 289 | clipboard.writeText("[" + result.split('?t=')[1].replace(/&p=[1-9]{1,}/g, "") + "](" + result + ")"); // Will be the JSON object from the fetch call 290 | }); 291 | console.log('Page URL copied to clipboard'); 292 | } catch (err) { 293 | console.error('Failed to copy: ', err); 294 | } 295 | } 296 | })); 297 | } 298 | 299 | setTimeout(() => { 300 | menu.popup(webContents); 301 | // Dirty workaround for showing the menu, when currentUrl is not the same as the url of the webview 302 | if (this.node.url !== params.pageURL && !params.selectionText) { 303 | menu.popup(webContents); 304 | } 305 | }, 0); 306 | }, false); 307 | }); 308 | 309 | this.webviewEl.addEventListener("will-navigate", (event: any) => { 310 | if (this.type === 'canvas') { 311 | const oldData = this.node.getData(); 312 | oldData.url = event.url; 313 | this.node.setData(oldData); 314 | this.node.canvas?.requestSave(); 315 | } else { 316 | this.node.url = event.url; 317 | } 318 | }); 319 | 320 | this.webviewEl.addEventListener("did-navigate-in-page", (event: any) => { 321 | if (this.type === 'canvas') { 322 | const oldData = this.node.getData(); 323 | 324 | if (event.url.contains("contacts.google.com/widget") || (this.node.canvas?.isDragging && oldData.url === event.url)) { 325 | // @ts-ignore 326 | const webContents = remote.webContents.fromId(this.webviewEl.getWebContentsId()); 327 | webContents.stop(); 328 | return; 329 | } 330 | if (oldData.url === event.url) return; 331 | oldData.url = event.url; 332 | oldData.alwaysKeepLoaded = true; 333 | this.node.setData(oldData); 334 | this.node.canvas?.requestSave(); 335 | } else { 336 | this.node.url = event.url; 337 | } 338 | }); 339 | 340 | this.webviewEl.addEventListener('destroyed', () => { 341 | if (doc !== this.contentEl.doc) { 342 | this.webviewEl.detach(); 343 | this.appendWebView(); 344 | } 345 | }); 346 | 347 | doc.contains(this.contentEl) ? this.contentEl.appendChild(this.webviewEl) : this.contentEl.onNodeInserted(() => { 348 | this.contentEl.doc === doc ? this.contentEl.appendChild(this.webviewEl) : this.appendWebView(); 349 | }); 350 | 351 | 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /src/component/InPageHeaderBar.ts: -------------------------------------------------------------------------------- 1 | import { t } from "../translations/helper"; 2 | import { App, Component } from "obsidian"; 3 | 4 | 5 | export class InPageHeaderBar extends Component { 6 | private node: any; 7 | private url: string; 8 | private searchBar: HTMLInputElement; 9 | private onSearchBarEnterListener = new Array<(url: string) => void>; 10 | 11 | private app: App; 12 | 13 | constructor(app: App, node: any, url: string) { 14 | super(); 15 | this.node = node; 16 | this.url = url; 17 | this.app = app; 18 | } 19 | 20 | onload() { 21 | const pluginSettings = this.app.plugins.getPlugin("surfing").settings; 22 | 23 | this.searchBar = this.node?.contentEl.createEl("input", { 24 | type: "text", 25 | placeholder: t("Search with") + pluginSettings.defaultSearchEngine + t("or enter address"), 26 | cls: "wb-search-bar" 27 | }); 28 | 29 | // TODO: Would this be ok to use Obsidian add domlistener instead? 30 | // this.searchBar.addEventListener("keydown", (event: KeyboardEvent) => { 31 | // // eslint-disable-next-line @typescript-eslint/no-unused-vars 32 | // if (!event) { 33 | // // eslint-disable-next-line @typescript-eslint/no-unused-vars 34 | // const event = window.event as KeyboardEvent; 35 | // } 36 | // if (event.key === "Enter") { 37 | // // When enter is pressed, search for the url. 38 | // for (const listener of this.onSearchBarEnterListener) { 39 | // listener(this.searchBar.value); 40 | // } 41 | // } 42 | // }, false); 43 | 44 | this.registerDomEvent(this.searchBar, "keydown", (event: KeyboardEvent) => { 45 | if (!event) { 46 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 47 | const event = window.event as KeyboardEvent; 48 | } 49 | if (event.key === "Enter") { 50 | // When enter is pressed, search for the url. 51 | for (const listener of this.onSearchBarEnterListener) { 52 | listener(this.searchBar.value); 53 | } 54 | } 55 | }); 56 | 57 | // Use focusin to bubble up to the parent 58 | // Rather than just input element itself. 59 | // this.searchBar.addEventListener("focusin", (event: FocusEvent) => { 60 | // this.searchBar.select(); 61 | // }) 62 | this.registerDomEvent(this.searchBar, "focusin", (event: FocusEvent) => { 63 | this.searchBar.select(); 64 | }); 65 | 66 | this.registerDomEvent(this.searchBar, "focusout", (event: FocusEvent) => { 67 | window.getSelection()?.removeAllRanges(); 68 | }); 69 | 70 | // When focusout, unselect the text to prevent it is still selected when focus back 71 | // It will trigger some unexpected behavior,like you will not select all text and the cursor will set to current position; 72 | // The expected behavior is that you will select all text when focus back; 73 | // this.searchBar.addEventListener("focusout", (event: FocusEvent) => { 74 | // window.getSelection()?.removeAllRanges(); 75 | // }) 76 | } 77 | 78 | addOnSearchBarEnterListener(listener: (url: string) => void) { 79 | this.onSearchBarEnterListener.push(listener); 80 | } 81 | 82 | setSearchBarUrl(url: string) { 83 | this.searchBar.value = url; 84 | } 85 | 86 | focus() { 87 | this.searchBar.focus(); 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/component/InPageIconList.ts: -------------------------------------------------------------------------------- 1 | import SurfingPlugin from "../surfingIndex"; 2 | import { ButtonComponent, ItemView } from "obsidian"; 3 | import { t } from "../translations/helper"; 4 | 5 | export class InPageIconList { 6 | plugin: SurfingPlugin; 7 | view: ItemView; 8 | private closeBtnEl: HTMLElement; 9 | private searchBtnEl: HTMLElement; 10 | private createBtnEl: HTMLElement; 11 | private iconListEl: HTMLElement; 12 | 13 | private searchBtn: ButtonComponent; 14 | private createBtn: ButtonComponent; 15 | private closeBtn: ButtonComponent; 16 | 17 | constructor(parent: Element, view: ItemView, plugin: SurfingPlugin) { 18 | this.plugin = plugin; 19 | this.view = view; 20 | // Remove default title from header bar. 21 | // Use Obsidian API to remove the title. 22 | 23 | this.iconListEl = parent.createEl("div", { 24 | cls: "wb-icon-list-container" 25 | }); 26 | 27 | 28 | this.createBtnEl = this.iconListEl.createEl("div", { 29 | cls: "wb-create-btn" 30 | }); 31 | 32 | this.searchBtnEl = this.iconListEl.createEl("div", { 33 | cls: "wb-search-btn" 34 | }); 35 | 36 | this.closeBtnEl = this.iconListEl.createEl("div", { 37 | cls: "wb-close-btn" 38 | }); 39 | 40 | 41 | this.closeBtn = new ButtonComponent(this.closeBtnEl); 42 | this.createBtn = new ButtonComponent(this.createBtnEl); 43 | this.searchBtn = new ButtonComponent(this.searchBtnEl); 44 | 45 | 46 | this.createBtn.setIcon("file-plus").onClick(() => { 47 | this.plugin.app.commands.executeCommandById("file-explorer:new-file"); 48 | }); 49 | this.searchBtn.setIcon("file-search-2").onClick(() => { 50 | this.plugin.app.commands.executeCommandById("switcher:open"); 51 | }); 52 | this.closeBtn.setIcon("x-square").onClick(() => { 53 | if (this.view?.leaf) this.view?.leaf.detach(); 54 | }); 55 | 56 | this.closeBtn.setTooltip(t("Close Current Leaf")); 57 | this.createBtn.setTooltip(t("Create A New Note")); 58 | this.searchBtn.setTooltip(t("Open Quick Switcher")); 59 | 60 | // setIcon(this.createBtnEl, "file-plus"); 61 | // setIcon(this.searchBtnEl, "file-search-2"); 62 | // setIcon(this.closeBtnEl, "x-square"); 63 | // 64 | // this.plugin.registerDomEvent(this.createBtnEl, 'click', () => { 65 | // app.commands.executeCommandById("file-explorer:new-file"); 66 | // }) 67 | // 68 | // this.plugin.registerDomEvent(this.searchBtnEl, 'click', () => { 69 | // app.commands.executeCommandById("switcher:open"); 70 | // }) 71 | // 72 | // this.plugin.registerDomEvent(this.closeBtnEl, 'click', () => { 73 | // if (this.view?.leaf) this.view?.leaf.detach(); 74 | // }) 75 | 76 | } 77 | 78 | onunload() { 79 | this.searchBtn.buttonEl.detach(); 80 | this.createBtn.buttonEl.detach(); 81 | this.closeBtn.buttonEl.detach(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/component/LastOpenedFiles.ts: -------------------------------------------------------------------------------- 1 | import { Component, setIcon } from "obsidian"; 2 | import SurfingPlugin from "../surfingIndex"; 3 | 4 | export class LastOpenedFiles extends Component { 5 | private plugin: SurfingPlugin; 6 | private parent: HTMLElement; 7 | 8 | private listEl: HTMLElement; 9 | 10 | constructor(plugin: SurfingPlugin, parent: HTMLElement) { 11 | super(); 12 | 13 | this.plugin = plugin; 14 | this.parent = parent; 15 | } 16 | 17 | onload() { 18 | this.listEl = this.parent.createEl('div', { 19 | cls: 'wb-last-opened-files' 20 | }); 21 | 22 | const lastOpenFiles = this.plugin.app.workspace.getLastOpenFiles().slice(0, 8); 23 | for (const file of lastOpenFiles) { 24 | const fileEl = this.listEl.createEl('button', { 25 | cls: 'wb-last-opened-file' 26 | }); 27 | const iconEl = fileEl.createEl('span', { 28 | cls: 'wb-last-opened-file-icon', 29 | }); 30 | setIcon(iconEl, 'file-text'); 31 | 32 | fileEl.createEl('span', { 33 | cls: 'wb-last-opened-file-name', 34 | text: file 35 | }); 36 | 37 | fileEl.onclick = async () => { 38 | await this.plugin.app.workspace.openLinkText(file, file, false); 39 | }; 40 | } 41 | } 42 | 43 | onunload() { 44 | super.onunload(); 45 | this.listEl.empty(); 46 | this.listEl.detach(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/component/OmniSearchContainer.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceLeaf } from "obsidian"; 2 | import SurfingPlugin from "../surfingIndex"; 3 | import { OmniSearchItem } from "./OmniSearchItem"; 4 | 5 | 6 | export type ResultNoteApi = { 7 | score: number 8 | path: string 9 | basename: string 10 | foundWords: string[] 11 | matches: SearchMatchApi[] 12 | } 13 | export type SearchMatchApi = { 14 | match: string 15 | offset: number 16 | } 17 | 18 | export class OmniSearchContainer { 19 | private leaf: WorkspaceLeaf; 20 | private plugin: SurfingPlugin; 21 | private wbOmniSearchCtnEl: HTMLElement; 22 | private query: string; 23 | private result: ResultNoteApi[]; 24 | 25 | constructor(leaf: WorkspaceLeaf, plugin: SurfingPlugin) { 26 | this.plugin = plugin; 27 | this.leaf = leaf; 28 | } 29 | 30 | onload() { 31 | this.wbOmniSearchCtnEl = this.leaf.view.contentEl.createEl("div", { 32 | cls: "wb-omni-box" 33 | }) 34 | this.hide(); 35 | } 36 | 37 | hide() { 38 | if (this.wbOmniSearchCtnEl.isShown()) this.wbOmniSearchCtnEl.hide(); 39 | } 40 | 41 | show() { 42 | if (!this.wbOmniSearchCtnEl.isShown()) this.wbOmniSearchCtnEl.show(); 43 | } 44 | 45 | notFound() { 46 | this.wbOmniSearchCtnEl.empty(); 47 | this.wbOmniSearchCtnEl.createEl("div", { 48 | text: "No results found", 49 | cls: "wb-omni-item-notfound" 50 | }) 51 | } 52 | 53 | // Tick current search box so that make it run again when Obsidian Reload 54 | tick(result: ResultNoteApi[]) { 55 | if (this.result !== result) this.result = result; 56 | 57 | // @ts-ignore 58 | if (this.result !== result) this.result = result; 59 | 60 | if (!this.result || this.result?.length === 0) { 61 | this.show(); 62 | this.notFound(); 63 | return; 64 | } 65 | 66 | if (this.result.length > 0) { 67 | if (!this.result[0].foundWords.find(word => word === this.query)) { 68 | this.notFound(); 69 | return; 70 | } 71 | this.show(); 72 | this.result.forEach((item: ResultNoteApi) => { 73 | (new OmniSearchItem(this.wbOmniSearchCtnEl, item.path, item.foundWords, item.matches, this.plugin)).onload(); 74 | }) 75 | } 76 | } 77 | 78 | public async update(query: string) { 79 | if (this.query === query) return; 80 | 81 | this.wbOmniSearchCtnEl.empty(); 82 | this.query = query; 83 | 84 | // @ts-ignore 85 | const result = await omnisearch?.search(this.query); 86 | 87 | this.tick(result); 88 | 89 | if (!result || result?.length === 0) { 90 | setTimeout(async () => { 91 | // @ts-ignore 92 | const result = await omnisearch.search(this.query); 93 | this.tick(result); 94 | }, 3000); 95 | } 96 | } 97 | 98 | onunload() { 99 | this.wbOmniSearchCtnEl.empty(); 100 | this.wbOmniSearchCtnEl.detach(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/component/OmniSearchItem.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from "obsidian"; 2 | import type { SearchMatchApi } from "./OmniSearchContainer"; 3 | import { SplitContent } from "../utils/splitContent"; 4 | import SurfingPlugin from "src/surfingIndex"; 5 | 6 | export class OmniSearchItem { 7 | private parent: HTMLElement; 8 | private path: string; 9 | private foundWords: string[]; 10 | private matches: SearchMatchApi[]; 11 | private plugin: SurfingPlugin; 12 | 13 | constructor(parent: HTMLElement, path: string, foundWords: string[], matches: SearchMatchApi[], plugin: SurfingPlugin) { 14 | this.parent = parent; 15 | this.path = path; 16 | this.foundWords = foundWords; 17 | this.matches = matches; 18 | this.plugin = plugin; 19 | } 20 | 21 | async onload() { 22 | const wbOmniSearchItemEl = this.parent.createEl("div", { 23 | cls: "wb-omni-item" 24 | }) 25 | wbOmniSearchItemEl.createEl("div", { 26 | cls: "wb-omni-item-path", 27 | text: this.path, 28 | }); 29 | const itemListEl = wbOmniSearchItemEl.createEl("div", { 30 | cls: "wb-omni-item-content-list", 31 | }); 32 | 33 | const file = this.plugin.app.vault.getAbstractFileByPath(this.path); 34 | let content = ""; 35 | if (file instanceof TFile) content = await this.plugin.app.vault.cachedRead(file); 36 | if (!file) return; 37 | 38 | const contentSearch = new SplitContent(content); 39 | 40 | 41 | if (this.matches.length > 0) { 42 | this.matches.forEach((word: SearchMatchApi) => { 43 | const textEl = itemListEl.createEl("div", { 44 | cls: "wb-content-list-text", 45 | }) 46 | textEl.innerHTML = contentSearch.search(word.offset, true); 47 | }) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/component/PopoverWebView.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { remote } from "electron"; 3 | import { SurfingView } from "../surfingView"; 4 | import { moment } from "obsidian"; 5 | import SurfingPlugin from "src/surfingIndex"; 6 | 7 | export class PopoverWebView { 8 | private contentEl: HTMLElement; 9 | private webviewEl: HTMLElement; 10 | private node: HTMLElement; 11 | 12 | private currentUrl: string; 13 | private plugin: SurfingPlugin; 14 | 15 | constructor(node: HTMLElement, targetUrl: string, plugin: SurfingPlugin) { 16 | this.contentEl = node.createEl("div", {cls: "wb-view-content"}); 17 | this.node = node; 18 | this.currentUrl = targetUrl; 19 | this.plugin = plugin; 20 | } 21 | 22 | onload() { 23 | this.contentEl.empty(); 24 | 25 | this.appendWebView(); 26 | } 27 | 28 | appendWebView() { 29 | const doc = this.contentEl.doc; 30 | this.webviewEl = doc.createElement('webview'); 31 | this.webviewEl.setAttribute("allowpopups", ""); 32 | this.webviewEl.addClass("wb-frame"); 33 | const self = this; 34 | 35 | if (this.currentUrl) this.webviewEl.setAttribute("src", this.currentUrl); 36 | // this.node.placeholderEl.innerText = this.node.url; 37 | 38 | this.webviewEl.addEventListener("dom-ready", (event: any) => { 39 | // @ts-ignore 40 | const webContents = remote.webContents.fromId(this.webviewEl.getWebContentsId()); 41 | 42 | // Open new browser tab if the web view requests it. 43 | webContents.setWindowOpenHandler((event: any) => { 44 | if (event.disposition !== "foreground-tab") { 45 | SurfingView.spawnWebBrowserView(self.plugin, true, {url: event.url}); 46 | return { 47 | action: "allow", 48 | }; 49 | } 50 | }); 51 | 52 | try { 53 | const pluginSettings = this.plugin.settings; 54 | const highlightFormat = pluginSettings.highlightFormat; 55 | const getCurrentTime = () => { 56 | let link = ""; 57 | // eslint-disable-next-line no-useless-escape 58 | const timeString = highlightFormat.match(/\{TIME\:[^\{\}\[\]]*\}/g)?.[0]; 59 | if (timeString) { 60 | // eslint-disable-next-line no-useless-escape 61 | const momentTime = moment().format(timeString.replace(/{TIME:([^\}]*)}/g, "$1")); 62 | link = highlightFormat.replace(timeString, momentTime); 63 | return link; 64 | } 65 | return link; 66 | }; 67 | webContents.executeJavaScript(` 68 | window.addEventListener('dragstart', (e) => { 69 | if(e.ctrlKey || e.metaKey) { 70 | e.dataTransfer.clearData(); 71 | const selectionText = document.getSelection().toString(); 72 | const linkToHighlight = e.srcElement.baseURI.replace(/\#\:\~\:text\=(.*)/g, "") + "#:~:text=" + encodeURIComponent(selectionText); 73 | let link = ""; 74 | if ("${highlightFormat}".includes("{TIME")) { 75 | link = "${getCurrentTime()}"; 76 | } 77 | link = (link != "" ? link : "${highlightFormat}").replace(/\{URL\}/g, linkToHighlight).replace(/\{CONTENT\}/g, selectionText.replace(/\\n/g, " ")); 78 | 79 | e.dataTransfer.setData('text/plain', link); 80 | console.log(e); 81 | } 82 | }); 83 | `, true).then((result: any) => { 84 | }); 85 | } catch (err) { 86 | console.error('Failed to add event: ', err); 87 | } 88 | }); 89 | 90 | 91 | this.webviewEl.addEventListener("did-navigate-in-page", (event: any) => { 92 | if (event.url.contains("contacts.google.com/widget")) { 93 | // @ts-ignore 94 | const webContents = remote.webContents.fromId(this.webviewEl.getWebContentsId()); 95 | webContents.stop(); 96 | return; 97 | } 98 | this.currentUrl = event.url; 99 | }); 100 | 101 | this.webviewEl.addEventListener('destroyed', () => { 102 | if (doc !== this.contentEl.doc) { 103 | this.webviewEl.detach(); 104 | this.appendWebView(); 105 | } 106 | }); 107 | 108 | doc.contains(this.contentEl) ? this.contentEl.appendChild(this.webviewEl) : this.contentEl.onNodeInserted(() => { 109 | this.contentEl.doc === doc ? this.contentEl.appendChild(this.webviewEl) : this.appendWebView(); 110 | }); 111 | 112 | 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/component/SearchBox.ts: -------------------------------------------------------------------------------- 1 | import { setIcon, WorkspaceLeaf } from "obsidian"; 2 | import SurfingPlugin from "../surfingIndex"; 3 | 4 | export default class searchBox { 5 | plugin: SurfingPlugin; 6 | leaf: WorkspaceLeaf; 7 | 8 | webContents: any; 9 | closeButtonEl: HTMLElement; 10 | backwardButtonEl: HTMLElement; 11 | forwardButtonEl: HTMLElement; 12 | inputEl: HTMLInputElement; 13 | searchBoxEl: HTMLElement; 14 | clicked: boolean; 15 | 16 | constructor(leaf: WorkspaceLeaf, webContents: any, plugin: SurfingPlugin, removeChild?: boolean) { 17 | this.leaf = leaf; 18 | this.webContents = webContents; 19 | this.plugin = plugin; 20 | 21 | this.onload(); 22 | } 23 | 24 | onload() { 25 | const containerEl = this.leaf.view.contentEl; 26 | this.searchBoxEl = containerEl.createEl("div", { 27 | cls: "wb-search-box" 28 | }); 29 | this.inputEl = this.searchBoxEl.createEl("input", { 30 | type: "text", 31 | placeholder: "", 32 | cls: "wb-search-input" 33 | }); 34 | const searchButtonGroupEl = this.searchBoxEl.createEl("div", { 35 | cls: "wb-search-button-group" 36 | }); 37 | this.backwardButtonEl = searchButtonGroupEl.createEl("div", { 38 | cls: "wb-search-button search-forward" 39 | }); 40 | this.forwardButtonEl = searchButtonGroupEl.createEl("div", { 41 | cls: "wb-search-button search-backward" 42 | }); 43 | this.closeButtonEl = searchButtonGroupEl.createEl("div", { 44 | cls: "wb-search-button search-close" 45 | }); 46 | 47 | this.closeButtonEl.addEventListener("click", this.unload.bind(this)); 48 | this.backwardButtonEl.addEventListener("click", this.backward.bind(this)); 49 | this.forwardButtonEl.addEventListener("click", this.forward.bind(this)); 50 | this.inputEl.addEventListener("keyup", this.search.bind(this)); 51 | this.inputEl.addEventListener("keyup", this.exist.bind(this)); 52 | 53 | setIcon(this.closeButtonEl, "x"); 54 | setIcon(this.backwardButtonEl, "arrow-up"); 55 | setIcon(this.forwardButtonEl, "arrow-down"); 56 | 57 | this.inputEl.focus(); 58 | } 59 | 60 | search(event: KeyboardEvent) { 61 | event.preventDefault(); 62 | if (this.inputEl.value === "") return; 63 | 64 | if (event.key === "Enter" && !event.shiftKey) { 65 | this.forward(); 66 | } 67 | if (event.key === "Enter" && event.shiftKey) { 68 | this.backward(); 69 | } 70 | } 71 | 72 | exist(event: KeyboardEvent) { 73 | event.preventDefault(); 74 | if (event.key === "Escape") { 75 | this.unload(); 76 | } 77 | } 78 | 79 | backward() { 80 | if (this.inputEl.value === "") return; 81 | 82 | if (!this.clicked) { 83 | this.webContents.findInPage(this.inputEl.value, { 84 | forward: false, 85 | findNext: true 86 | }); 87 | } else { 88 | this.webContents.findInPage(this.inputEl.value, { 89 | forward: false, 90 | findNext: false 91 | }); 92 | } 93 | this.clicked = true; 94 | } 95 | 96 | forward() { 97 | if (this.inputEl.value === "") return; 98 | if (!this.clicked) { 99 | this.webContents.findInPage(this.inputEl.value, { 100 | forward: true, 101 | findNext: true 102 | }); 103 | } else { 104 | this.webContents.findInPage(this.inputEl.value, { 105 | forward: true, 106 | findNext: false 107 | }); 108 | } 109 | this.clicked = true; 110 | } 111 | 112 | unload() { 113 | this.webContents.stopFindInPage('clearSelection'); 114 | this.inputEl.value = ""; 115 | 116 | this.closeButtonEl.removeEventListener("click", this.unload); 117 | this.backwardButtonEl.removeEventListener("click", this.backward); 118 | this.forwardButtonEl.removeEventListener("click", this.forward); 119 | this.inputEl.removeEventListener('keyup', this.search); 120 | this.inputEl.removeEventListener('keyup', this.exist); 121 | 122 | this.searchBoxEl.detach(); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/component/TabTreeView/CustomDragPreview.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | align-items: "center"; 3 | background-color: #1967d2; 4 | border-radius: 4px; 5 | box-shadow: 0 12px 24px -6px rgba(0, 0, 0, 0.25), 6 | 0 0 0 1px rgba(0, 0, 0, 0.08); 7 | color: #fff; 8 | display: inline-grid; 9 | font-size: 14px; 10 | gap: 8px; 11 | grid-template-columns: auto auto; 12 | padding: 4px 8px; 13 | pointer-events: none; 14 | } 15 | 16 | .icon, 17 | .label { 18 | align-items: center; 19 | display: flex; 20 | } 21 | -------------------------------------------------------------------------------- /src/component/TabTreeView/CustomDragPreview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DragLayerMonitorProps } from "@minoru/react-dnd-treeview"; 3 | import { CustomData } from "./types"; 4 | import { TypeIcon } from "./TypeIcon"; 5 | import styles from './CustomDragPreview.module.css'; 6 | 7 | type Props = { 8 | monitorProps: DragLayerMonitorProps; 9 | }; 10 | 11 | export const CustomDragPreview: React.FC = (props) => { 12 | const item = props.monitorProps.item; 13 | 14 | return ( 15 |
16 |
17 | 21 |
22 |
{ item.text }
23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/component/TabTreeView/CustomNode.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | align-items: center; 3 | display: grid; 4 | grid-template-columns: auto auto 1fr auto; 5 | height: 32px; 6 | padding-inline-end: 8px; 7 | border-bottom: solid 1px #eee; 8 | border-radius: var(--size-2-2); 9 | } 10 | 11 | .root:hover { 12 | background: var(--color-base-30); 13 | } 14 | 15 | .root.isSelected { 16 | background: var(--color-base-40); 17 | border-radius: var(--size-2-2); 18 | } 19 | 20 | .expandIconWrapper { 21 | align-items: center; 22 | font-size: 0; 23 | cursor: pointer; 24 | display: flex; 25 | height: 24px; 26 | justify-content: center; 27 | width: 24px; 28 | transition: transform linear 0.1s; 29 | transform: rotate(0deg); 30 | } 31 | 32 | .expandIconWrapper.isOpen { 33 | transform: rotate(90deg); 34 | } 35 | 36 | .labelGridItem { 37 | padding-inline-start: 8px; 38 | width: 100%; 39 | overflow: hidden; 40 | } 41 | 42 | .pipeY { 43 | position: absolute; 44 | border-left: 2px solid #e7e7e7; 45 | left: -7px; 46 | top: -7px; 47 | } 48 | 49 | .pipeX { 50 | position: absolute; 51 | left: -7px; 52 | top: 15px; 53 | height: 2px; 54 | background-color: #e7e7e7; 55 | z-index: -1; 56 | } 57 | -------------------------------------------------------------------------------- /src/component/TabTreeView/CustomNode.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Typography } from 'antd'; 3 | import { ArrowRightOutlined } from "@ant-design/icons"; 4 | import { NodeModel } from "@minoru/react-dnd-treeview"; 5 | import { CustomData } from "./types"; 6 | import { TypeIcon } from "./TypeIcon"; 7 | import styles from './CustomNode.module.css'; 8 | import { Menu, Notice } from "obsidian"; 9 | import SurfingPlugin from "src/surfingIndex"; 10 | 11 | type Props = { 12 | plugin: SurfingPlugin; 13 | node: NodeModel; 14 | depth: number; 15 | isOpen: boolean; 16 | hasChild: boolean; 17 | onToggle: (id: NodeModel["id"]) => void; 18 | onSelect: (node: NodeModel) => void; 19 | isSelected: boolean; 20 | }; 21 | 22 | const TREE_X_OFFSET = 24; 23 | 24 | export const CustomNode: React.FC = (props) => { 25 | const { droppable, data } = props.node; 26 | const indent = props.depth * TREE_X_OFFSET; 27 | 28 | const { Paragraph } = Typography; 29 | 30 | const handleToggle = (e: React.MouseEvent) => { 31 | e.stopPropagation(); 32 | props.onToggle(props.node.id); 33 | }; 34 | 35 | const handleSelect = () => props.onSelect(props.node); 36 | 37 | const handleClick = (e: React.MouseEvent) => { 38 | e.stopPropagation(); 39 | const leaf = props.plugin.app.workspace.getLeafById(String(props.node.id)); 40 | if (!leaf) return; 41 | 42 | props.plugin.app.workspace.setActiveLeaf(leaf); 43 | handleSelect(); 44 | // app.workspace.revealLeaf(leaf); 45 | } 46 | 47 | const handleContextMenu = (e: React.MouseEvent) => { 48 | e.stopPropagation(); 49 | e.preventDefault(); 50 | 51 | const menu = new Menu(); 52 | menu.addItem((item) => { 53 | item.setTitle("Not Ready Yet") 54 | .setIcon("surfing") 55 | .onClick(() => { 56 | new Notice("Not Ready Yet"); 57 | }); 58 | }); 59 | menu.showAtPosition({ x: e.clientX, y: e.clientY }); 60 | } 61 | 62 | 63 | return ( 64 |
72 |
77 | { props.hasChild && ( 78 | 79 | ) } 80 |
81 |
82 | 83 |
84 |
85 | { `${ props.node.text }` } 92 |
93 |
94 | ); 95 | }; 96 | 97 | -------------------------------------------------------------------------------- /src/component/TabTreeView/Placeholder.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | background-color: #1967d2; 3 | height: 2px; 4 | position: absolute; 5 | right: 0; 6 | transform: translateY(-50%); 7 | top: 0; 8 | } 9 | -------------------------------------------------------------------------------- /src/component/TabTreeView/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NodeModel } from "@minoru/react-dnd-treeview"; 3 | import styles from './Placeholder.module.css'; 4 | 5 | type Props = { 6 | node: NodeModel; 7 | depth: number; 8 | }; 9 | 10 | export const Placeholder: React.FC = (props) => { 11 | const left = props.depth * 24; 12 | return
; 13 | }; 14 | -------------------------------------------------------------------------------- /src/component/TabTreeView/TabTree.module.css: -------------------------------------------------------------------------------- 1 | .app { 2 | height: 100%; 3 | margin: var(--size-4-2); 4 | border-radius: var(--size-2-2); 5 | } 6 | 7 | .container { 8 | height: 100%; 9 | } 10 | 11 | .treeRoot { 12 | height: 100%; 13 | } 14 | 15 | .draggingSource { 16 | opacity: 0.3; 17 | } 18 | 19 | .placeholderContainer { 20 | position: relative; 21 | } 22 | 23 | .dropTarget { 24 | background-color: var(--color-accent); 25 | } 26 | -------------------------------------------------------------------------------- /src/component/TabTreeView/TabTree.tsx: -------------------------------------------------------------------------------- 1 | import SurfingPlugin from "../../surfingIndex"; 2 | import React, { useEffect, useState } from "react"; 3 | import { DndProvider } from "react-dnd"; 4 | import { 5 | Tree, 6 | NodeModel, 7 | MultiBackend, 8 | getBackendOptions 9 | } from "@minoru/react-dnd-treeview"; 10 | import { CustomData } from "./types"; 11 | import { CustomNode } from "./CustomNode"; 12 | import { Placeholder } from "./Placeholder"; 13 | import styles from './TabTree.module.css'; 14 | import { CustomDragPreview } from "./CustomDragPreview"; 15 | import { ItemView, Menu } from "obsidian"; 16 | import { SurfingView, WEB_BROWSER_VIEW_ID } from "../../surfingView"; 17 | import { random, SaveWorkspaceModal } from "./workspace"; 18 | import { CrownTwoTone } from "@ant-design/icons"; 19 | 20 | interface Props { 21 | plugin: SurfingPlugin; 22 | } 23 | 24 | export const useDndProvider = () => { 25 | const [dndArea, setDndArea] = useState(); 26 | const handleRef = React.useCallback((node: Node | undefined | null) => setDndArea(node as Node), []); 27 | 28 | useEffect(() => { 29 | const view = document.body.find(`div[data-type="surfing-tab-tree"]`); 30 | setDndArea(view); 31 | }, []); 32 | 33 | const html5Options = React.useMemo( 34 | () => ({rootElement: dndArea}), 35 | [dndArea] 36 | ); 37 | return {dndArea, handleRef, html5Options}; 38 | }; 39 | 40 | export default function TabTree(props: Props) { 41 | const [treeData, setTreeData] = useState[]>(props.plugin.settings.treeData || []); 42 | const handleDrop = (newTree: NodeModel[]) => setTreeData(newTree); 43 | const {dndArea, handleRef, html5Options} = useDndProvider(); 44 | 45 | const [selectedNode, setSelectedNode] = useState | null>(null); 46 | const handleSelect = (node: NodeModel) => { 47 | setSelectedNode(node as NodeModel); 48 | }; 49 | 50 | 51 | const handleContextMenu = (e: React.MouseEvent) => { 52 | const menu = new Menu(); 53 | menu.addItem((item) => { 54 | item.setTitle("New Group") 55 | .setIcon("folder") 56 | .onClick(() => { 57 | new SaveWorkspaceModal(props.plugin.app, props.plugin, (result) => { 58 | const newGroup = { 59 | "id": random(16), 60 | "parent": 0, 61 | "droppable": true, 62 | "text": result, 63 | "data": { 64 | "fileType": "workspace", 65 | "fileSize": "", 66 | "icon": "folder", 67 | } 68 | }; 69 | setTreeData([...treeData, newGroup]); 70 | }).open(); 71 | }); 72 | }); 73 | menu.showAtPosition({x: e.clientX, y: e.clientY}); 74 | }; 75 | 76 | 77 | React.useEffect(() => { 78 | const leafIndex = treeData.findIndex((node) => node.data?.fileType === "site"); 79 | if (leafIndex === -1) return; 80 | const leafId = String(treeData[leafIndex].id); 81 | const leaf = props.plugin.app.workspace.getLeafById(leafId); 82 | if (!leaf) { 83 | setTreeData([]); 84 | props.plugin.settings.treeData = []; 85 | props.plugin.settingsTab.applySettingsUpdate(); 86 | return; 87 | } 88 | if (!treeData) return; 89 | props.plugin.settings.treeData = treeData; 90 | props.plugin.settingsTab.applySettingsUpdate(); 91 | }, [treeData]); 92 | 93 | React.useEffect(() => { 94 | if (treeData.length > 0) return; 95 | const leaves = props.plugin.app.workspace.getLeavesOfType(WEB_BROWSER_VIEW_ID); 96 | const nodes = leaves.map((leaf) => ({ 97 | "id": leaf.id, 98 | "parent": 0, 99 | "droppable": true, 100 | "text": (leaf.view as SurfingView).currentTitle, 101 | "data": { 102 | "fileType": "site", 103 | "fileSize": "", 104 | "icon": (leaf.view as SurfingView).favicon, 105 | } 106 | })); 107 | setTreeData([...treeData, ...nodes]); 108 | 109 | return () => { 110 | leaves.forEach((leaf) => { 111 | if (checkExist(leaf.id)) { 112 | setTreeData([ 113 | ...treeData, 114 | { 115 | "id": leaf.id, 116 | "parent": 0, 117 | "droppable": true, 118 | "text": (leaf.view as SurfingView).currentTitle, 119 | "data": { 120 | "fileType": "site", 121 | "fileSize": "", 122 | "icon": (leaf.view as SurfingView).favicon, 123 | } 124 | } 125 | ]); 126 | (leaf.view as SurfingView).webviewEl.addEventListener("dom-ready", () => { 127 | updateTabsData(leaf.view as SurfingView, ''); 128 | }); 129 | } 130 | }); 131 | }; 132 | }, []); 133 | 134 | const updateTabsData = React.useCallback((activeView: SurfingView, url: string) => { 135 | //@ts-ignore 136 | setTreeData((prevTreeData) => { 137 | const existingNodeIndex = prevTreeData.findIndex(node => node.id === activeView.leaf.id); 138 | if (existingNodeIndex !== -1) { 139 | // If the node already exists in the tree, update its text and icon 140 | const existingNode = prevTreeData[existingNodeIndex]; 141 | return [ 142 | ...prevTreeData.slice(0, existingNodeIndex), 143 | { 144 | ...existingNode, 145 | text: url || activeView.currentTitle, 146 | data: { 147 | ...existingNode.data, 148 | icon: activeView.favicon, 149 | }, 150 | }, 151 | ...prevTreeData.slice(existingNodeIndex + 1), 152 | ]; 153 | } else { 154 | // If the node does not exist in the tree, add it to the tree 155 | return [ 156 | ...prevTreeData, 157 | { 158 | "id": activeView.leaf.id, 159 | "parent": 0, 160 | "droppable": true, 161 | "text": url || activeView.currentTitle, 162 | "data": { 163 | "fileType": "site", 164 | "fileSize": "", 165 | "icon": activeView.favicon, 166 | }, 167 | }, 168 | ]; 169 | } 170 | }); 171 | }, []); 172 | 173 | const checkExist = (leafID: string) => { 174 | return !treeData.some(obj => obj.id === leafID); 175 | }; 176 | 177 | useEffect(() => { 178 | props.plugin.app.workspace.on('surfing:page-change', (url: string, view: SurfingView) => { 179 | if (checkExist(view.leaf.id)) { 180 | updateTabsData(view, url); 181 | } 182 | }); 183 | 184 | 185 | props.plugin.app.workspace.on("layout-change", () => { 186 | // const activeView = props.plugin.app.workspace.getActiveViewOfType(ItemView); 187 | // if (activeView?.getViewType() === WEB_BROWSER_VIEW_ID && checkExist(activeView.leaf.id)) { 188 | // updateTabsData(activeView as SurfingView); 189 | // (activeView as SurfingView).webviewEl.addEventListener("dom-ready", () => { 190 | // updateTabsData(activeView as SurfingView); 191 | // }); 192 | // return; 193 | // } 194 | 195 | const leaves = props.plugin.app.workspace.getLeavesOfType(WEB_BROWSER_VIEW_ID); 196 | if (leaves.length === 0) { 197 | setTreeData([]); 198 | return; 199 | } 200 | }); 201 | }, []); 202 | 203 | return ( 204 | treeData.length > 0 ? ( 205 |
206 | 213 |
214 | ( 218 | 228 | )} 229 | dragPreviewRender={(monitorProps) => ( 230 | 231 | )} 232 | onDrop={handleDrop} 233 | classes={{ 234 | root: styles.treeRoot, 235 | draggingSource: styles.draggingSource, 236 | placeholder: styles.placeholderContainer 237 | }} 238 | sort={false} 239 | insertDroppableFirst={false} 240 | canDrop={(tree, {dragSource, dropTargetId}) => { 241 | if (dragSource?.parent === dropTargetId) { 242 | return true; 243 | } 244 | }} 245 | dropTargetOffset={10} 246 | placeholderRender={(node, {depth}) => ( 247 | 248 | )} 249 | /> 250 |
251 |
252 |
253 | 254 | ) : (
255 |
256 | 257 | 258 | {"No surfing tabs open"} 259 | 260 |
261 |
) 262 | 263 | ); 264 | } 265 | -------------------------------------------------------------------------------- /src/component/TabTreeView/TabTreeView.tsx: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf } from "obsidian"; 2 | import SurfingPlugin from "../../surfingIndex"; 3 | import React from "react"; 4 | import ReactDOM from "react-dom/client"; 5 | import TabTree from "./TabTree"; 6 | 7 | export const WEB_BROWSER_TAB_TREE_ID = "surfing-tab-tree"; 8 | 9 | export class TabTreeView extends ItemView { 10 | constructor(leaf: WorkspaceLeaf, public plugin: SurfingPlugin) { 11 | super(leaf); 12 | this.plugin = plugin; 13 | } 14 | 15 | getViewType() { 16 | return WEB_BROWSER_TAB_TREE_ID; 17 | } 18 | 19 | getDisplayText() { 20 | return "Surfing Tab Tree"; 21 | } 22 | 23 | getIcon(): string { 24 | return "chrome"; 25 | } 26 | 27 | protected async onOpen(): Promise { 28 | ReactDOM.createRoot(this.containerEl).render( 29 | 30 | 33 | 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/component/TabTreeView/TypeIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FolderAddOutlined, FileImageOutlined, TableOutlined, FileTextOutlined, CrownTwoTone } from "@ant-design/icons"; 3 | 4 | type Props = { 5 | droppable: boolean; 6 | fileType?: string; 7 | }; 8 | 9 | export const TypeIcon: React.FC = (props) => { 10 | if (props.droppable && !(props.fileType === "workspace")) { 11 | return ; 12 | } 13 | 14 | switch (props.fileType) { 15 | case "image": 16 | return ; 17 | case "csv": 18 | return ; 19 | case "text": 20 | return ; 21 | case "workspace": 22 | return ; 23 | default: 24 | return null; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/component/TabTreeView/types.ts: -------------------------------------------------------------------------------- 1 | export type CustomData = { 2 | fileType: string; 3 | fileSize: string; 4 | icon: HTMLImageElement | string; 5 | }; 6 | -------------------------------------------------------------------------------- /src/component/TabTreeView/workspace.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from "obsidian"; 2 | import SurfingPlugin from "../../surfingIndex"; 3 | 4 | export class SaveWorkspaceModal extends Modal { 5 | private plugin: SurfingPlugin; 6 | 7 | private result: string; 8 | private onSubmit: (result: string) => void; 9 | 10 | constructor(app: App, plugin: SurfingPlugin, onSubmit: (result: string) => void) { 11 | super(app); 12 | 13 | this.onSubmit = onSubmit; 14 | } 15 | 16 | onOpen() { 17 | const { contentEl } = this; 18 | contentEl.parentElement?.classList.add("wb-workspace-modal"); 19 | 20 | contentEl.createEl("h2", { text: "Workspace Name" }); 21 | 22 | new Setting(contentEl) 23 | .setName("Name") 24 | .addText((text) => 25 | text.onChange((value) => { 26 | this.result = value 27 | })); 28 | 29 | new Setting(contentEl) 30 | .addButton((btn) => 31 | btn 32 | .setButtonText("Submit") 33 | .setCta() 34 | .onClick(() => { 35 | this.close(); 36 | this.onSubmit(this.result); 37 | })); 38 | } 39 | 40 | onClose() { 41 | const { contentEl } = this; 42 | contentEl.empty(); 43 | } 44 | } 45 | 46 | export const random = (size: number) => { 47 | const chars = []; 48 | for (let n = 0; n < size; n++) chars.push(((16 * Math.random()) | 0).toString(16)); 49 | return chars.join(""); 50 | } 51 | -------------------------------------------------------------------------------- /src/component/inPageSearchBar.ts: -------------------------------------------------------------------------------- 1 | import SurfingPlugin from "../surfingIndex"; 2 | import { t } from "../translations/helper"; 3 | import { SearchEngineSuggester } from "./suggester/searchSuggester"; 4 | import { Component, ItemView, Scope } from "obsidian"; 5 | 6 | export class InPageSearchBar extends Component { 7 | plugin: SurfingPlugin; 8 | private inPageSearchBarInputEl: HTMLInputElement; 9 | private SearchBarInputContainerEl: HTMLElement; 10 | inPageSearchBarContainerEl: HTMLDivElement; 11 | private onSearchBarEnterListener = new Array<(url: string) => void>(); 12 | private searchEnginesSuggester: SearchEngineSuggester; 13 | 14 | private view: ItemView; 15 | 16 | constructor(parent: Element, view: ItemView, plugin: SurfingPlugin) { 17 | super(); 18 | this.plugin = plugin; 19 | this.view = view; 20 | 21 | this.inPageSearchBarContainerEl = parent.createEl("div", { 22 | cls: "wb-page-search-bar-container", 23 | }); 24 | 25 | // @ts-ignore 26 | this.initScope(); 27 | 28 | this.inPageSearchBarContainerEl.createEl("div", { 29 | text: "Surfing", 30 | cls: "wb-page-search-bar-text", 31 | }); 32 | 33 | this.SearchBarInputContainerEl = 34 | this.inPageSearchBarContainerEl.createEl("div", { 35 | cls: "wb-page-search-bar-input-container", 36 | }); 37 | 38 | // Create search bar in header bar. 39 | // Use Obsidian CreateEL method. 40 | this.inPageSearchBarInputEl = this.SearchBarInputContainerEl.createEl( 41 | "input", 42 | { 43 | type: "text", 44 | placeholder: 45 | t("Search with") + 46 | this.plugin.settings.defaultSearchEngine + 47 | t("or enter address"), 48 | cls: "wb-page-search-bar-input", 49 | } 50 | ); 51 | 52 | this.registerDomEvent( 53 | this.inPageSearchBarInputEl, 54 | "keydown", 55 | (event: KeyboardEvent) => { 56 | if (!event) { 57 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 58 | const event = window.event as KeyboardEvent; 59 | } 60 | if (event.key === "Enter") { 61 | for (const listener of this.onSearchBarEnterListener) { 62 | listener(this.inPageSearchBarInputEl.value); 63 | } 64 | } 65 | } 66 | ); 67 | 68 | this.registerDomEvent( 69 | this.inPageSearchBarInputEl, 70 | "focusin", 71 | (event: FocusEvent) => { 72 | this.inPageSearchBarInputEl.select(); 73 | } 74 | ); 75 | 76 | this.registerDomEvent( 77 | this.inPageSearchBarInputEl, 78 | "focusout", 79 | (event: FocusEvent) => { 80 | window.getSelection()?.removeAllRanges(); 81 | } 82 | ); 83 | 84 | if (this.plugin.settings.showOtherSearchEngines) 85 | this.searchEnginesSuggester = new SearchEngineSuggester( 86 | this.plugin.app, 87 | this.plugin, 88 | this.inPageSearchBarInputEl, 89 | this.view 90 | ); 91 | } 92 | 93 | addOnSearchBarEnterListener(listener: (url: string) => void) { 94 | this.onSearchBarEnterListener.push(listener); 95 | } 96 | 97 | initScope() { 98 | if (!this.plugin.settings.focusSearchBarViaKeyboard) return; 99 | if (!this.view.scope) { 100 | this.view.scope = new Scope(this.plugin.app.scope); 101 | (this.view.scope as Scope).register([], "i", (evt) => { 102 | if (evt.target === this.inPageSearchBarInputEl) return; 103 | evt.preventDefault(); 104 | this.inPageSearchBarInputEl.focus(); 105 | }); 106 | } else { 107 | (this.view.scope as Scope).register([], "i", (evt) => { 108 | if (evt.target === this.inPageSearchBarInputEl) return; 109 | evt.preventDefault(); 110 | this.inPageSearchBarInputEl.focus(); 111 | }); 112 | } 113 | } 114 | 115 | focus() { 116 | this.inPageSearchBarInputEl.focus(); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/component/suggester/bookmarkSuggester.ts: -------------------------------------------------------------------------------- 1 | import { TextInputSuggest } from "./suggest"; 2 | import type { App } from "obsidian"; 3 | import { SurfingView } from "../../surfingView"; 4 | import SurfingPlugin from "../../surfingIndex"; 5 | import { loadJson } from "../../utils/json"; 6 | import type { Bookmark } from "../../types/bookmark"; 7 | import { getComposedUrl } from "../../utils/url"; 8 | 9 | export class BookmarkSuggester extends TextInputSuggest { 10 | private plugin: SurfingPlugin; 11 | private bookmarkData: Bookmark[] = []; 12 | private suggestions: Bookmark[] = []; 13 | 14 | constructor(public app: App, plugin: SurfingPlugin, public inputEl: HTMLInputElement | HTMLTextAreaElement) { 15 | super(app, inputEl); 16 | 17 | this.plugin = plugin; 18 | } 19 | 20 | getSuggestions(inputStr: string): Bookmark[] { 21 | const inputLowerCase: string = inputStr.toLowerCase(); 22 | 23 | try { 24 | if (this.suggestions.length === 0) loadJson(this.plugin).then((data) => { 25 | this.bookmarkData = data.bookmarks; 26 | this.suggestions = this.bookmarkData; 27 | }); 28 | } catch (e) { 29 | console.error(e); 30 | } 31 | 32 | if (this.suggestions.length === 0) return []; 33 | 34 | const filtered = this.suggestions.filter((item) => { 35 | if (item.url.toLowerCase().contains(inputLowerCase) || item.name.toLowerCase().contains(inputLowerCase)) return item; 36 | }); 37 | 38 | if (!filtered) this.close(); 39 | if (filtered?.length > 0) { 40 | filtered.unshift({ 41 | id: "BOOKMARK", 42 | name: inputLowerCase, 43 | description: "", 44 | url: "", 45 | tags: "", 46 | category: [], 47 | created: 1111111111111, 48 | modified: 1111111111111, 49 | }); 50 | return filtered; 51 | } 52 | 53 | return filtered ? filtered : []; 54 | } 55 | 56 | renderSuggestion(item: Bookmark, el: HTMLElement): void { 57 | const bookmarkSuggestContainerEl = el.createEl("div", { 58 | cls: "wb-bookmark-suggest-container" 59 | }); 60 | bookmarkSuggestContainerEl.createEl("div", { 61 | text: item.name, 62 | cls: "wb-bookmark-suggestion-text" 63 | }); 64 | bookmarkSuggestContainerEl.createEl("div", { 65 | text: item.url, 66 | cls: "wb-bookmark-suggestion-url" 67 | }); 68 | el.classList.add("wb-bookmark-suggest-item"); 69 | } 70 | 71 | selectSuggestion(item: Bookmark): void { 72 | if (!item) return; 73 | 74 | if (item.id === "BOOKMARK") { 75 | const finalUrl = getComposedUrl("", item.name); 76 | SurfingView.spawnWebBrowserView(this.plugin, false, {url: finalUrl}); 77 | 78 | this.close(); 79 | return; 80 | } 81 | 82 | SurfingView.spawnWebBrowserView(this.plugin, false, {url: item.url}); 83 | this.close(); 84 | return; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/component/suggester/fileSuggester.ts: -------------------------------------------------------------------------------- 1 | import { TextInputSuggest } from "./suggest"; 2 | import SurfingPlugin from "../../surfingIndex"; 3 | import { App, FuzzyMatch, ItemView, prepareFuzzySearch, TFile } from "obsidian"; 4 | import { SurfingView } from "../../surfingView"; 5 | import { getComposedUrl } from "../../utils/url"; 6 | 7 | interface CustomItem { 8 | path: string; 9 | type: string; 10 | } 11 | 12 | export class FileSuggester extends TextInputSuggest { 13 | private plugin: SurfingPlugin; 14 | 15 | private files: TFile[]; 16 | private view: ItemView; 17 | 18 | constructor(public app: App, plugin: SurfingPlugin, public inputEl: HTMLInputElement | HTMLTextAreaElement, view: ItemView) { 19 | super(app, inputEl); 20 | 21 | this.plugin = plugin; 22 | this.view = view; 23 | 24 | this.files = this.app.vault.getFiles(); 25 | } 26 | 27 | fuzzySearchItemsOptimized(query: string, items: string[]): FuzzyMatch[] { 28 | const preparedSearch = prepareFuzzySearch(query); 29 | 30 | return items 31 | .map((item) => { 32 | const result = preparedSearch(item); 33 | if (result) { 34 | return { 35 | item: item, 36 | match: result, 37 | }; 38 | } 39 | return null; 40 | }) 41 | .filter(Boolean) as FuzzyMatch[]; 42 | } 43 | 44 | getSuggestions(inputStr: string): CustomItem[] { 45 | const names = this.files.map((file) => file.path); 46 | 47 | const query = this.fuzzySearchItemsOptimized(inputStr.slice(1), names) 48 | .sort((a, b) => { 49 | return b.match.score - a.match.score; 50 | }).map((match) => { 51 | return { 52 | path: match.item, 53 | type: 'file' 54 | }; 55 | }); 56 | 57 | // Add a blank item to the top of the list 58 | query.unshift({ 59 | path: inputStr, 60 | type: 'web' 61 | }); 62 | 63 | return query; 64 | } 65 | 66 | renderSuggestion(item: CustomItem, el: HTMLElement): void { 67 | const bookmarkSuggestContainerEl = el.createEl("div", { 68 | cls: "wb-bookmark-suggest-container" 69 | }); 70 | bookmarkSuggestContainerEl.createEl("div", { 71 | text: item.path, 72 | cls: "wb-bookmark-suggestion-text" 73 | }); 74 | el.classList.add("wb-bookmark-suggest-item"); 75 | } 76 | 77 | async selectSuggestion(item: CustomItem): Promise { 78 | if (!item) return; 79 | 80 | switch (item.type) { 81 | case 'web': { 82 | const finalUrl = getComposedUrl("", item.path); 83 | SurfingView.spawnWebBrowserView(this.plugin, false, {url: finalUrl}); 84 | break; 85 | } 86 | case 'file': { 87 | const file = this.files.find((file) => file.path === item.path); 88 | if (file) { 89 | await this.view.leaf.openFile(file); 90 | } 91 | break; 92 | } 93 | } 94 | 95 | this.close(); 96 | return; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/component/suggester/searchSuggester.ts: -------------------------------------------------------------------------------- 1 | import { TextInputSuggest } from "./suggest"; 2 | import { App, FuzzyMatch, ItemView, prepareFuzzySearch, TFile } from "obsidian"; 3 | import { t } from "../../translations/helper"; 4 | import { SEARCH_ENGINES, SearchEngine } from "../../surfingPluginSetting"; 5 | import { SurfingView } from "../../surfingView"; 6 | import SurfingPlugin from "../../surfingIndex"; 7 | import { getComposedUrl } from "../../utils/url"; 8 | 9 | export class SearchEngineSuggester extends TextInputSuggest { 10 | private searchEngines: SearchEngine[]; 11 | private searchEnginesString: string[] = []; 12 | private plugin: SurfingPlugin; 13 | 14 | private mode: 'web' | 'file' = 'web'; 15 | 16 | private files: TFile[] = []; 17 | 18 | private view: ItemView; 19 | 20 | constructor(public app: App, plugin: SurfingPlugin, public inputEl: HTMLInputElement | HTMLTextAreaElement, view: ItemView) { 21 | super(app, inputEl); 22 | 23 | this.plugin = plugin; 24 | this.files = this.app.vault.getFiles(); 25 | this.view = view; 26 | } 27 | 28 | fuzzySearchItemsOptimized(query: string, items: string[]): FuzzyMatch[] { 29 | const preparedSearch = prepareFuzzySearch(query); 30 | 31 | return items 32 | .map((item) => { 33 | const result = preparedSearch(item); 34 | if (result) { 35 | return { 36 | item: item, 37 | match: result, 38 | }; 39 | } 40 | return null; 41 | }) 42 | .filter(Boolean) as FuzzyMatch[]; 43 | } 44 | 45 | getSuggestions(inputStr: string): string[] { 46 | if (inputStr.trim().startsWith('/')) { 47 | this.mode = 'file'; 48 | 49 | const names = this.files.map((file) => file.path); 50 | 51 | const query = this.fuzzySearchItemsOptimized(inputStr.slice(1), names) 52 | .sort((a, b) => { 53 | return b.match.score - a.match.score; 54 | }).map((match) => match.item); 55 | 56 | return query; 57 | } 58 | 59 | this.mode = 'web'; 60 | 61 | this.searchEnginesString = []; 62 | 63 | const currentDefault = this.plugin.settings.defaultSearchEngine; 64 | this.searchEngines = [...SEARCH_ENGINES, ...this.plugin.settings.customSearchEngine].sort(function (x, y) { 65 | return x.name.toLowerCase() == currentDefault.toLowerCase() ? -1 : y.name.toLowerCase() == currentDefault.toLowerCase() ? 1 : 0; 66 | }); 67 | 68 | this.searchEngines.forEach((item) => { 69 | this.searchEnginesString.push(item.name); 70 | }); 71 | 72 | return this.searchEnginesString; 73 | } 74 | 75 | renderSuggestion(item: string, el: HTMLElement): void { 76 | switch (this.mode) { 77 | case 'web': 78 | el.createEl("div", { 79 | text: t("Search with") + item, 80 | cls: "wb-search-suggestion-text" 81 | }); 82 | el.classList.add("wb-search-suggest-item"); 83 | break; 84 | case 'file': 85 | el.createEl("div", { 86 | text: 'Open ' + item, 87 | cls: "wb-search-suggestion-text" 88 | }); 89 | el.classList.add("wb-search-suggest-item"); 90 | break; 91 | } 92 | } 93 | 94 | async selectSuggestion(item: string) { 95 | const currentInputValue: string = this.inputEl.value; 96 | 97 | if (currentInputValue.trim() === '') return; 98 | 99 | switch (this.mode) { 100 | case 'web': { 101 | const currentSearchEngine = this.searchEngines.find((engine) => engine.name === item); 102 | const url = (currentSearchEngine ? currentSearchEngine.url : SEARCH_ENGINES[0].url); 103 | 104 | const finalUrl = getComposedUrl(url, currentInputValue); 105 | SurfingView.spawnWebBrowserView(this.plugin, false, {url: finalUrl}); 106 | break; 107 | } 108 | case 'file': { 109 | const file = this.files.find((file) => file.path === item); 110 | if (file) { 111 | await this.view.leaf.openFile(file); 112 | } 113 | break; 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/component/suggester/suggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { App, Platform, Scope } from "obsidian"; 4 | import type { ISuggestOwner } from "obsidian"; 5 | import { createPopper } from "@popperjs/core"; 6 | import type { Instance as PopperInstance } from "@popperjs/core"; 7 | import { SEARCH_ENGINES } from "../../surfingPluginSetting"; 8 | 9 | const wrapAround = (value: number, size: number): number => { 10 | return ((value % size) + size) % size; 11 | }; 12 | 13 | class Suggest { 14 | private owner: ISuggestOwner; 15 | private values: T[]; 16 | private suggestions: HTMLDivElement[]; 17 | private selectedItem: number; 18 | private containerEl: HTMLElement; 19 | private app: App; 20 | 21 | constructor(owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope, app: App) { 22 | this.owner = owner; 23 | this.containerEl = containerEl; 24 | this.app = app; 25 | 26 | containerEl.on( 27 | "click", 28 | ".suggestion-item", 29 | this.onSuggestionClick.bind(this) 30 | ); 31 | containerEl.on( 32 | "mousemove", 33 | ".suggestion-item", 34 | this.onSuggestionMouseover.bind(this) 35 | ); 36 | 37 | scope.register([], "ArrowUp", (event) => { 38 | if (!event.isComposing) { 39 | this.setSelectedItem(this.selectedItem - 1, true); 40 | return false; 41 | } 42 | }); 43 | 44 | scope.register([], "ArrowDown", (event) => { 45 | if (!event.isComposing) { 46 | this.setSelectedItem(this.selectedItem + 1, true); 47 | return false; 48 | } 49 | }); 50 | 51 | scope.register([], "Enter", (event) => { 52 | if (!event.isComposing) { 53 | this.useSelectedItem(event); 54 | return false; 55 | } 56 | }); 57 | 58 | 59 | // Register Control+Number to select specific items. 60 | const pluginSettings = this.app.plugins.getPlugin("surfing").settings; 61 | const searchEngines = [...SEARCH_ENGINES, ...pluginSettings.customSearchEngine]; 62 | for (let i = 0; i < searchEngines.length; i++) { 63 | if (i === 9) { 64 | scope.register(["Mod"], "0", (event) => { 65 | if (!event.isComposing) { 66 | this.setSelectedItem(i, false); 67 | this.useSelectedItem(event); 68 | return false; 69 | } 70 | }); 71 | break; 72 | } 73 | scope.register(["Mod"], `${ i + 1 }`, (event) => { 74 | if (!event.isComposing) { 75 | this.setSelectedItem(i, false); 76 | this.useSelectedItem(event); 77 | return false; 78 | } 79 | }); 80 | } 81 | 82 | 83 | } 84 | 85 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 86 | event.preventDefault(); 87 | 88 | const item = this.suggestions.indexOf(el); 89 | this.setSelectedItem(item, false); 90 | this.useSelectedItem(event); 91 | } 92 | 93 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 94 | const item = this.suggestions.indexOf(el); 95 | this.setSelectedItem(item, false); 96 | } 97 | 98 | setSuggestions(values: T[]) { 99 | this.containerEl.empty(); 100 | const suggestionEls: HTMLDivElement[] = []; 101 | 102 | values.forEach((value, index) => { 103 | const suggestionEl = this.containerEl.createDiv("suggestion-item"); 104 | this.owner.renderSuggestion(value, suggestionEl); 105 | if (index < 10) { 106 | suggestionEl.createEl("div", { 107 | text: `${ Platform.isMacOS ? "CMD + " : "Ctrl + " }` + `${ index != 9 ? index + 1 : 0 }`, 108 | cls: "wb-search-suggestion-index" 109 | }) 110 | } 111 | suggestionEls.push(suggestionEl); 112 | }); 113 | 114 | this.values = values; 115 | this.suggestions = suggestionEls; 116 | this.setSelectedItem(0, false); 117 | } 118 | 119 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 120 | const currentValue = this.values[this.selectedItem]; 121 | if (currentValue) { 122 | this.owner.selectSuggestion(currentValue, event); 123 | } 124 | } 125 | 126 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 127 | const normalizedIndex = wrapAround(selectedIndex, this.suggestions.length); 128 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 129 | const selectedSuggestion = this.suggestions[normalizedIndex]; 130 | 131 | prevSelectedSuggestion?.removeClass("is-selected"); 132 | selectedSuggestion?.addClass("is-selected"); 133 | 134 | this.selectedItem = normalizedIndex; 135 | 136 | if (scrollIntoView) { 137 | selectedSuggestion.scrollIntoView(false); 138 | } 139 | } 140 | } 141 | 142 | export abstract class TextInputSuggest implements ISuggestOwner { 143 | protected app: App; 144 | protected inputEl: HTMLInputElement | HTMLTextAreaElement; 145 | 146 | private popper: PopperInstance; 147 | private scope: Scope; 148 | private suggestEl: HTMLElement; 149 | private suggest: Suggest; 150 | 151 | constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) { 152 | this.app = app; 153 | this.inputEl = inputEl; 154 | this.scope = new Scope(); 155 | 156 | this.suggestEl = createDiv("wb-search-suggestion-container"); 157 | const suggestion = this.suggestEl.createDiv("wb-search-suggestion"); 158 | this.suggest = new Suggest(this, suggestion, this.scope, this.app); 159 | 160 | this.scope.register([], "Escape", this.close.bind(this)); 161 | 162 | this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); 163 | this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); 164 | this.inputEl.addEventListener("blur", this.close.bind(this)); 165 | this.suggestEl.on( 166 | "mousedown", 167 | ".wb-search-suggestion-container", 168 | (event: MouseEvent) => { 169 | event.preventDefault(); 170 | } 171 | ); 172 | } 173 | 174 | onInputChanged(): void { 175 | const inputStr = this.inputEl.value; 176 | const suggestions = this.getSuggestions(inputStr); 177 | 178 | if (!suggestions || (/^\s{0,}$/.test(inputStr))) { 179 | this.close(); 180 | return; 181 | } 182 | 183 | if (suggestions.length > 0) { 184 | this.suggest.setSuggestions(suggestions); 185 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 186 | this.open((this.app).dom.appContainerEl, this.inputEl); 187 | } else { 188 | this.close() 189 | } 190 | } 191 | 192 | open(container: HTMLElement, inputEl: HTMLElement): void { 193 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 194 | (this.app).keymap.pushScope(this.scope); 195 | 196 | container.appendChild(this.suggestEl); 197 | this.popper = createPopper(inputEl, this.suggestEl, { 198 | placement: "bottom-start", 199 | modifiers: [ 200 | { 201 | name: "sameWidth", 202 | enabled: true, 203 | fn: ({ state, instance }) => { 204 | // Note: positioning needs to be calculated twice - 205 | // first pass - positioning it according to the width of the popper 206 | // second pass - position it with the width bound to the reference element 207 | // we need to early exit to avoid an infinite loop 208 | const targetWidth = `${ state.rects.reference.width }px`; 209 | if (state.styles.popper.width === targetWidth) { 210 | return; 211 | } 212 | state.styles.popper.width = targetWidth; 213 | instance.update(); 214 | }, 215 | phase: "beforeWrite", 216 | requires: ["computeStyles"], 217 | }, 218 | { 219 | name: 'offset', 220 | options: { 221 | offset: [0, 5], 222 | }, 223 | } 224 | ], 225 | }); 226 | } 227 | 228 | close(): void { 229 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 230 | (this.app).keymap.popScope(this.scope); 231 | 232 | this.suggest.setSuggestions([]); 233 | if (this.popper) 234 | this.popper.destroy(); 235 | this.suggestEl.detach(); 236 | } 237 | 238 | abstract getSuggestions(inputStr: string): T[]; 239 | 240 | abstract renderSuggestion(item: T, el: HTMLElement): void; 241 | 242 | abstract selectSuggestion(item: T): void; 243 | } 244 | -------------------------------------------------------------------------------- /src/surfingBookmarkManager.tsx: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf } from "obsidian"; 2 | import SurfingPlugin from "./surfingIndex"; 3 | import React from "react"; 4 | import ReactDOM from "react-dom/client"; 5 | import BookmarkManager from "./component/BookmarkManager/BookmarkManager"; 6 | import { initializeJson, loadJson } from "./utils/json"; 7 | import { Bookmark, CategoryType } from "./types/bookmark"; 8 | import { t } from "./translations/helper"; 9 | 10 | export const WEB_BROWSER_BOOKMARK_MANAGER_ID = "surfing-bookmark-manager"; 11 | 12 | export class SurfingBookmarkManagerView extends ItemView { 13 | private bookmarkData: Bookmark[] = []; 14 | private categoryData: CategoryType[] = []; 15 | private plugin: SurfingPlugin; 16 | 17 | constructor(leaf: WorkspaceLeaf, plugin: SurfingPlugin) { 18 | super(leaf); 19 | this.plugin = plugin; 20 | } 21 | 22 | getViewType() { 23 | return WEB_BROWSER_BOOKMARK_MANAGER_ID; 24 | } 25 | 26 | getDisplayText() { 27 | return "Surfing Bookmark Manager"; 28 | } 29 | 30 | getIcon(): string { 31 | return "album"; 32 | } 33 | 34 | protected async onOpen(): Promise { 35 | try { 36 | const { bookmarks, categories } = await loadJson(this.plugin); 37 | this.bookmarkData = bookmarks; 38 | this.categoryData = categories; 39 | } catch (e) { 40 | if (this.bookmarkData.length === 0) { 41 | await initializeJson(this.plugin); 42 | const { bookmarks, categories } = await loadJson(this.plugin); 43 | this.bookmarkData = bookmarks; 44 | this.categoryData = categories; 45 | } 46 | } 47 | 48 | if (this.bookmarkData && this.categoryData) { 49 | ReactDOM.createRoot(this.containerEl).render( 50 | 51 | 56 | 57 | ); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/surfingFileView.ts: -------------------------------------------------------------------------------- 1 | import { FileSystemAdapter, FileView, TFile, WorkspaceLeaf } from "obsidian"; 2 | import { SurfingView } from "./surfingView"; 3 | import SurfingPlugin from "./surfingIndex"; 4 | 5 | export const HTML_FILE_EXTENSIONS = ["html", "htm"]; 6 | export const WEB_BROWSER_FILE_VIEW_ID = "surfing-file-view"; 7 | 8 | export class SurfingFileView extends FileView { 9 | allowNoFile: false; 10 | private plugin: SurfingPlugin; 11 | 12 | constructor(leaf: WorkspaceLeaf, plugin: SurfingPlugin) { 13 | super(leaf); 14 | this.allowNoFile = false; 15 | this.plugin = plugin; 16 | } 17 | 18 | async onLoadFile(file: TFile): Promise { 19 | const adapter = this.app.vault.adapter as FileSystemAdapter; 20 | const urlString = "file:///" + (adapter.getBasePath() + "/" + file.path).toString().replace(/\s/g, "%20"); 21 | SurfingView.spawnWebBrowserView(this.plugin, true, {url: urlString}); 22 | if (this.leaf) this.leaf.detach(); 23 | } 24 | 25 | onunload(): void { 26 | } 27 | 28 | canAcceptExtension(extension: string) { 29 | return HTML_FILE_EXTENSIONS.includes(extension); 30 | } 31 | 32 | getViewType() { 33 | return WEB_BROWSER_FILE_VIEW_ID; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/surfingViewNext.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ItemView, 3 | WorkspaceLeaf, 4 | } from "obsidian"; 5 | import { WebContentsView, WebContents } from "electron"; 6 | import SurfingPlugin from "./surfingIndex"; 7 | 8 | export const WEB_BROWSER_VIEW_ID = "surfing-view-next"; 9 | 10 | export class SurfingViewNext extends ItemView { 11 | plugin: SurfingPlugin; 12 | currentUrl = "https://obsidian.md"; // Set default URL 13 | currentTitle = "Surfing"; 14 | webView: WebContentsView; 15 | webContents: WebContents; 16 | 17 | constructor(leaf: WorkspaceLeaf, plugin: SurfingPlugin) { 18 | super(leaf); 19 | this.plugin = plugin; 20 | } 21 | 22 | getViewType(): string { 23 | return WEB_BROWSER_VIEW_ID; 24 | } 25 | 26 | getDisplayText(): string { 27 | return this.currentTitle; 28 | } 29 | 30 | async onOpen() { 31 | // Create the WebContentsView with security options 32 | this.webView = new WebContentsView({ 33 | webPreferences: { 34 | nodeIntegration: false, 35 | contextIsolation: true, 36 | sandbox: true, 37 | webSecurity: true, 38 | allowRunningInsecureContent: false 39 | } 40 | }); 41 | 42 | this.webContents = this.webView.webContents; 43 | 44 | // Set up event handlers 45 | this.registerEventHandlers(); 46 | 47 | // Add the WebContentsView to the Obsidian view 48 | const container = this.containerEl.children[1]; 49 | container.empty(); 50 | 51 | // Always navigate to obsidian.md first when opening 52 | this.navigate(this.currentUrl); 53 | } 54 | 55 | private registerEventHandlers() { 56 | // Handle page title updates 57 | this.webContents.on('page-title-updated', (event, title) => { 58 | this.currentTitle = title; 59 | this.leaf.tabHeaderInnerTitleEl.innerText = title; 60 | }); 61 | 62 | // Handle page favicon updates 63 | this.webContents.on('page-favicon-updated', (event, favicons) => { 64 | if (favicons && favicons[0]) { 65 | const favicon = document.createElement('img'); 66 | favicon.src = favicons[0]; 67 | favicon.width = 16; 68 | favicon.height = 16; 69 | this.leaf.tabHeaderInnerIconEl.empty(); 70 | this.leaf.tabHeaderInnerIconEl.appendChild(favicon); 71 | } 72 | }); 73 | 74 | // Handle navigation events 75 | this.webContents.on('did-start-loading', () => { 76 | // Add loading indicator 77 | this.leaf.tabHeaderInnerTitleEl.addClass('loading'); 78 | }); 79 | 80 | this.webContents.on('did-stop-loading', () => { 81 | // Remove loading indicator 82 | this.leaf.tabHeaderInnerTitleEl.removeClass('loading'); 83 | }); 84 | 85 | // Handle errors 86 | this.webContents.on('did-fail-load', (event, errorCode, errorDescription) => { 87 | console.error('Page failed to load:', errorDescription); 88 | // Could show error in view 89 | }); 90 | 91 | // Handle new window requests 92 | this.webContents.setWindowOpenHandler(({ url }) => { 93 | // Open URLs in new tabs 94 | this.navigate(url); 95 | return { action: 'deny' }; 96 | }); 97 | } 98 | 99 | async onClose() { 100 | if (this.webContents && !this.webContents.isDestroyed()) { 101 | this.webContents.close(); 102 | } 103 | } 104 | 105 | navigate(url: string) { 106 | if (!url) return; 107 | 108 | // Basic URL validation and protocol addition 109 | if (!url.startsWith('http://') && !url.startsWith('https://')) { 110 | url = 'https://' + url; 111 | } 112 | 113 | this.currentUrl = url; 114 | this.webContents.loadURL(url, { 115 | userAgent: undefined 116 | }); 117 | } 118 | 119 | // Add zoom control methods 120 | zoomIn() { 121 | const currentZoom = this.webContents.getZoomLevel(); 122 | this.webContents.setZoomLevel(currentZoom + 0.5); 123 | } 124 | 125 | zoomOut() { 126 | const currentZoom = this.webContents.getZoomLevel(); 127 | this.webContents.setZoomLevel(currentZoom - 0.5); 128 | } 129 | 130 | resetZoom() { 131 | this.webContents.setZoomLevel(0); 132 | } 133 | 134 | // Add navigation methods 135 | goBack() { 136 | if (this.webContents.navigationHistory?.canGoBack()) { 137 | this.webContents.navigationHistory.goBack(); 138 | } 139 | } 140 | 141 | goForward() { 142 | if (this.webContents.navigationHistory?.canGoForward()) { 143 | this.webContents.navigationHistory.goForward(); 144 | } 145 | } 146 | 147 | reload() { 148 | this.webContents.reload(); 149 | } 150 | 151 | // Add dev tools toggle 152 | toggleDevTools() { 153 | if (this.webContents.isDevToolsOpened()) { 154 | this.webContents.closeDevTools(); 155 | } else { 156 | this.webContents.openDevTools(); 157 | } 158 | } 159 | 160 | getState(): any { 161 | return { 162 | url: this.currentUrl 163 | }; 164 | } 165 | 166 | async setState(state: any) { 167 | if (state.url) { 168 | this.navigate(state.url); 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/translations/helper.ts: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/valentine195/obsidian-admonition/blob/master/src/lang/helpers.ts 2 | 3 | import { moment } from 'obsidian'; 4 | 5 | import ar from './locale/ar'; 6 | import cz from './locale/cz'; 7 | import da from './locale/da'; 8 | import de from './locale/de'; 9 | import en from './locale/en'; 10 | import enGB from './locale/en-gb'; 11 | import es from './locale/es'; 12 | import fr from './locale/fr'; 13 | import hi from './locale/hi'; 14 | import id from './locale/id'; 15 | import it from './locale/it'; 16 | import ja from './locale/ja'; 17 | import ko from './locale/ko'; 18 | import nl from './locale/nl'; 19 | import no from './locale/no'; 20 | import pl from './locale/pl'; 21 | import pt from './locale/pt'; 22 | import ptBR from './locale/pt-br'; 23 | import ro from './locale/ro'; 24 | import ru from './locale/ru'; 25 | import tr from './locale/tr'; 26 | import zhCN from './locale/zh-cn'; 27 | import zhTW from './locale/zh-tw'; 28 | 29 | const localeMap: { [k: string]: Partial } = { 30 | ar, 31 | cs: cz, 32 | da, 33 | de, 34 | en, 35 | 'en-gb': enGB, 36 | es, 37 | fr, 38 | hi, 39 | id, 40 | it, 41 | ja, 42 | ko, 43 | nl, 44 | nn: no, 45 | pl, 46 | pt, 47 | 'pt-br': ptBR, 48 | ro, 49 | ru, 50 | tr, 51 | 'zh-cn': zhCN, 52 | 'zh-tw': zhTW, 53 | }; 54 | 55 | const locale = localeMap[moment.locale()]; 56 | 57 | export function t(str: keyof typeof en): string { 58 | // @ts-ignore 59 | return (locale && locale[str]) || en[str]; 60 | } 61 | -------------------------------------------------------------------------------- /src/translations/locale/ar.ts: -------------------------------------------------------------------------------- 1 | // العربية 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/cz.ts: -------------------------------------------------------------------------------- 1 | // čeština 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/da.ts: -------------------------------------------------------------------------------- 1 | // Dansk 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/de.ts: -------------------------------------------------------------------------------- 1 | // Deutsch 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/en-gb.ts: -------------------------------------------------------------------------------- 1 | // British English 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/en.ts: -------------------------------------------------------------------------------- 1 | // English 2 | 3 | export default { 4 | // surfingPluginSetting.ts 5 | "Search with": " Search with ", 6 | "or enter address": " or enter address", 7 | 'Default Search Engine': 'Default Search Engine', 8 | 'Set Custom Search Engine Url': 'Set Custom Search Engine Url', 9 | "Set custom search engine url for yourself. 'Duckduckgo' By default": "Set custom search engine url for yourself. 'Duckduckgo' By default", 10 | 'Custom Link to Highlight Format': 'Custom Link to Highlight Format', 11 | 'Copy Link to Highlight Format': 'Copy Link to Highlight Format', 12 | "Set copy link to text fragment format. [{CONTENT}]({URL}) By default. You can also set {TIME:YYYY-MM-DD HH:mm:ss} to get the current date.": "Set copy link to text fragment format. [{CONTENT}]({URL}) By default. You can also set {TIME:YYYY-MM-DD HH:mm:ss} to get the current date.", 13 | 'Open URL In Same Tab': 'Open In Same Tab', 14 | 'Custom': 'Custom', 15 | 'Baidu': 'Baidu', 16 | 'Yahoo': 'Yahoo', 17 | 'Bing': 'Bing', 18 | 'Google': 'Google', 19 | 'DuckDuckGo': 'DuckDuckGo', 20 | 'Toggle Same Tab In Web Browser': 'Toggle Same Tab In Web Browser', 21 | 'Clear Current Page History': 'Clear Current Page History', 22 | 'Open Current URL In External Browser': 'Open Current URL In External Browser', 23 | 'Search Text': 'Search Text', 24 | 'Copy Plain Text': 'Copy Plain Text', 25 | 'Copy Link to Highlight': 'Copy Link to Highlight', 26 | 'Copy Video Timestamp': 'Copy Video Time', 27 | 'Open URL In Obsidian Web From Other Software': 'Open URL In Obsidian Web From Other Software', 28 | '(Reload to take effect)': '(Reload to take effect)', 29 | "Copy BookmarkLets Success": "Copy BookmarkLets Success", 30 | 'Refresh Current Page': 'Refresh Current Page', 31 | 'Show Search Bar In Empty Page': 'Show Search Bar In Empty Page', 32 | "You enabled obsidian-web-browser plugin, please disable it/disable surfing to avoid conflict.": "You enabled obsidian-web-browser plugin, please disable it/disable Surfing to avoid conflict.", 33 | "You didn't enable show tab title bar in apperance settings, please enable it to use surfing happily.": "You didn't enable show tab header in apperance settings, please enable it to use Surfing happily.", 34 | 'Get Current Timestamp from Web Browser': 'Get Current Timestamp from Web Browser', 35 | 'Search In Current Page Title Bar': 'Search In Current Page Title Bar', 36 | " <- Drag or click on me": " <- Drag or click on me", 37 | 'Name': 'Name', 38 | 'Url': 'Url', 39 | 'Custom Search': 'Custom Search', 40 | 'Delete Custom Search': 'Delete Custom Search', 41 | 'Add new custom search engine': 'Add new custom search engine', 42 | 'Search all settings': 'Search all settings', 43 | 'General': 'General', 44 | 'Search': 'Search', 45 | 'Bookmark': 'Bookmark', 46 | 'Theme': 'Theme', 47 | 'Always Show Custom Engines': 'Always Show Custom Engines', 48 | 'Save Current Page As Markdown': 'Save Current Page As Markdown', 49 | 'Save As Markdown Path': 'Save As Markdown Path', 50 | 'Path like /_Tempcard': 'Path like /_Tempcard', 51 | "Search Engine": "Search Engine", 52 | 'settings': 'settings', 53 | 'Using ': 'Using ', 54 | ' to search': ' to search', 55 | "Surfing Iframe": "Surfing Iframe", 56 | 'Surfing is using iframe to prevent crashed when loading some websites.': 'Surfing is using iframe to prevent crashed when loading some websites.', 57 | 'Open With External Browser': 'Open With External Browser', 58 | 'Open With Surfing': 'Open With Surfing', 59 | "When click on the URL from same domain name in the note, jump to the same surfing view rather than opening a new Surfing view.": "When click on the URL from same domain name in the note, jump to the same surfing view rather than opening a new Surfing view.", 60 | 'Jump to Opened Page': 'Jump to Opened Page', 61 | "Open Quick Switcher": "Open Quick Switcher | Ctrl/CMD+O", 62 | "Close Current Leaf": "Close Current Leaf | Ctrl/CMD+W", 63 | "Create A New Note": "Create A New Note | Ctrl/CMD+N", 64 | 'Show Other Search Engines When Searching': 'Show Other Search Engines When Searching', 65 | "Random Icons From Default Art": "Random Icons From Default Art", 66 | "Working On, Not Available Now": "Working On, Not Available Now", 67 | "Toggle Dark Mode": "Toggle Dark Mode", 68 | '[Experimental] Replace Iframe In Canvas': '[Experimental] Replace Iframe In Canvas', 69 | 'Use icon list to replace defult text actions in empty view': 'Use icon list to replace defult text actions in empty view', 70 | "Open BookmarkBar & Bookmark Manager": "Open BookmarkBar & Bookmark Manager", 71 | "Pagination": "Pagination", 72 | "Category": "Category", 73 | "Default Column List": "Default Column List", 74 | 'Show Refresh Button Near Search Bar': 'Show Refresh Button Near Search Bar', 75 | 'Focus On Current Search Bar': 'Focus On Current Search Bar', 76 | "Default Category Filter Type": "Default Filter Type", 77 | 'Tree': 'Tree', 78 | 'Menu': 'Menu', 79 | "Description": "Description", 80 | "Tags": "Tags", 81 | "Created": "Created", 82 | "Modified": "Modified", 83 | "Action": "Action", 84 | "Search from ": "Search from ", 85 | " bookmarks": " bookmarks", 86 | "Save Bookmark When Open URI": "Save Bookmark When Open URI", 87 | 'Copy Current Viewport As Image': 'Copy Current Viewport As Image', 88 | 'Back': 'Back', 89 | 'Forward': 'Forward', 90 | "star": "star", 91 | 'Copy failed, you may focus on surfing view, click the title bar, and try again.': 'Copy failed, you may focus on surfing view, click the title bar, and try again.', 92 | "Default Category (Use , to split)": "Default Category (Use , to split)", 93 | "Send to ReadWise": "Send to ReadWise", 94 | "Add a action in page header to Send to ReadWise.": "Add a action in page header to Send to ReadWise.", 95 | 'Disable / to search when on these sites': 'Disable / to search when on these sites', 96 | 'Focus search bar via keyboard': 'Focus search bar via keyboard', 97 | 'Hover Popover': 'Hover Popover', 98 | 'Show a popover when hover on the link.': 'Show a popover when hover on the link.', 99 | 'Enable HTML Preview': 'Enable HTML Preview', 100 | 'Enable HTML Preview in Surfing': 'Enable HTML Preview in Surfing', 101 | 'Show original url': 'Show original url', 102 | 'Enable Inline Preview': 'Enable Inline Preview', 103 | 'Enable inline preview with surfing. Currently only support Live preview': 'Enable inline preview with surfing. Currently only support Live preview', 104 | 'Enable Tree View in Surfing': 'Enable Tree View in Surfing', 105 | 'Enable Tree View': 'Enable Tree View', 106 | }; 107 | -------------------------------------------------------------------------------- /src/translations/locale/es.ts: -------------------------------------------------------------------------------- 1 | // Español 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/fr.ts: -------------------------------------------------------------------------------- 1 | // français 2 | 3 | export default { 4 | // surfingPluginSetting.ts 5 | }; 6 | -------------------------------------------------------------------------------- /src/translations/locale/hi.ts: -------------------------------------------------------------------------------- 1 | // हिन्दी 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/id.ts: -------------------------------------------------------------------------------- 1 | // Bahasa Indonesia 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/it.ts: -------------------------------------------------------------------------------- 1 | // Italiano 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/ja.ts: -------------------------------------------------------------------------------- 1 | // 日本語 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/ko.ts: -------------------------------------------------------------------------------- 1 | // 한국어 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/nl.ts: -------------------------------------------------------------------------------- 1 | // Nederlands 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/no.ts: -------------------------------------------------------------------------------- 1 | // Norsk 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/pl.ts: -------------------------------------------------------------------------------- 1 | // język polski 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/pt-br.ts: -------------------------------------------------------------------------------- 1 | // Português do Brasil 2 | // Brazilian Portuguese 3 | 4 | export default { 5 | // surfingPluginSetting.ts 6 | }; 7 | -------------------------------------------------------------------------------- /src/translations/locale/pt.ts: -------------------------------------------------------------------------------- 1 | // Português 2 | 3 | export default { 4 | // surfingPluginSetting.ts 5 | }; 6 | -------------------------------------------------------------------------------- /src/translations/locale/ro.ts: -------------------------------------------------------------------------------- 1 | // Română 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/ru.ts: -------------------------------------------------------------------------------- 1 | // русский 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/tr.ts: -------------------------------------------------------------------------------- 1 | // Türkçe 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/translations/locale/zh-cn.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | // surfingPluginSetting.ts 3 | "Search with": "使用 ", 4 | "or enter address": " 搜索,或输入地址", 5 | 'Default Search Engine': '默认搜索引擎', 6 | 'Set Custom Search Engine Url': '设置自定义搜索引擎网址', 7 | "Set custom search engine url for yourself. 'Duckduckgo' By default": "设置自定义搜索引擎网址。默认为'Duckduckgo'", 8 | 'Custom Link to Highlight Format': '自定义指向突出显示的链接的格式', 9 | 'Copy Link to Highlight Format': '复制指向突出显示的链接的格式', 10 | "Set copy link to text fragment format. [{CONTENT}]({URL}) By default. You can also set {TIME:YYYY-MM-DD HH:mm:ss} to get the current date.": "设置复制文本片段的链接的格式。默认为[{CONTENT}]({URL})。你也可以设置{TIME:YYYY-MM-DD HH:mm:ss}来获取当前日期时间。", 11 | 'Open URL In Same Tab': '在固定且唯一标签页中打开网页', 12 | 'Custom': '自定义', 13 | 'Baidu': '百度', 14 | 'Yahoo': '雅虎', 15 | 'Bing': '必应', 16 | 'Google': '谷歌', 17 | 'DuckDuckGo': 'DuckDuckGo', 18 | 'Toggle Same Tab In Web Browser': '切换是否在固定且唯一标签页访问网址', 19 | 'Clear Current Page History': '清除当前页面的历史记录', 20 | 'Open Current URL In External Browser': '在外部浏览器中打开当前网址', 21 | 'Search Text': '搜索文本', 22 | 'Copy Plain Text': '复制纯文本', 23 | 'Copy Link to Highlight': '复制指向突出显示的链接', 24 | 'Copy Video Timestamp': '复制视频时间戳', 25 | 'Open URL In Obsidian Web From Other Software': '从别的软件在 Obsidian Web 中打开网址', 26 | '(Reload to take effect)': '(重启 Ob 以生效)', 27 | "Copy BookmarkLets Success": '复制 BookmarkLets 成功', 28 | 'Refresh Current Page': '刷新当前页面', 29 | 'Show Search Bar In Empty Page': '在空白页面中显示搜索栏', 30 | "You enabled obsidian-web-browser plugin, please disable it/disable surfing to avoid conflict.": "你启用了 obsidian-web-browser 插件,请禁用它或禁用 surfing 插件以避免冲突。", 31 | "You didn't enable show tab title bar in apperance settings, please enable it to use surfing happily.": "你没有在外观设置中启用显示标签页标题,请启用它以便使用 surfing。", 32 | 'Get Current Timestamp from Web Browser': '从浏览器获取当前时间戳', 33 | 'Search In Current Page Title Bar': '在当前页面标题栏中搜索', 34 | " <- Drag or click on me": " <- 拖动或点击", 35 | 'Name': '名称', 36 | 'Url': '链接', 37 | 'Custom Search': '自定义搜索', 38 | 'Delete Custom Search': '删除自定义', 39 | 'Add new custom search engine': '添加新的自定义搜索引擎', 40 | 'Search all settings': '搜索设置', 41 | 'General': '常规选项', 42 | 'Search': '搜索选项', 43 | 'Theme': '主题选项', 44 | 'Bookmark': '书签选项', 45 | 'Always Show Custom Engines': '始终显示自定义引擎', 46 | 'Save Current Page As Markdown': '保存当前网页为 Markdown', 47 | 'Save As Markdown Path': '保存为 Markdown 路径', 48 | 'Path like /_Tempcard': '路径例如 /_Tempcard', 49 | "Search Engine": "搜索引擎", 50 | 'settings': '设置', 51 | 'Using ': '使用', 52 | ' to search': '来检索', 53 | "Surfing Iframe": "Surfing Iframe", 54 | 'Surfing is using iframe to prevent crashed when loading some websites.': 'Surfing 使用 iframe 来防止加载某些网站时崩溃。', 55 | 'Open With External Browser': '在外部浏览器中打开', 56 | 'Open With Surfing': '在 Surfing 中打开', 57 | "When click on the URL from same domain name in the note, jump to the same surfing view rather than opening a new Surfing view.": "当在笔记中点击相同域名的 URL 时,跳转到相同的 Surfing 视图而不是打开新的 Surfing 视图。", 58 | 'Jump to Opened Page': '跳转到已打开的页面', 59 | "Open Quick Switcher": "打开快速切换 | Ctrl/CMD+O", 60 | "Close Current Leaf": "关闭当前的页面 | Ctrl/CMD+W", 61 | "Create A New Note": "新建笔记 | Ctrl/CMD+N", 62 | 'Show Other Search Engines When Searching': '搜索时显示其它搜索引擎', 63 | "Random Icons From Default Art": "从默认的 Art 中挑选随机 Icon", 64 | "Working On, Not Available Now": "正在建设中,当前不可用", 65 | "Toggle Dark Mode": "切换夜间模式", 66 | '[Experimental] Replace Iframe In Canvas': '【实验性功能】在 Canvas 中替换网页节点', 67 | 'Use icon list to replace defult text actions in empty view': '使用图标列替换空页面中的默认文本操作', 68 | "Open BookmarkBar & Bookmark Manager": "打开书签栏和书签管理器", 69 | "Pagination": "分页书签数", 70 | "Category": "分类", 71 | "Default Column List": "默认的列", 72 | 'Show Refresh Button Near Search Bar': '在搜索栏旁边显示刷新按钮', 73 | 'Focus On Current Search Bar': '聚焦到当前的搜索栏', 74 | "Default Category Filter Type": "默认目录过滤形式", 75 | 'Tree': '树状', 76 | 'Menu': '菜单', 77 | "Description": "描述", 78 | "Tags": "标签", 79 | "Created": "创建时间", 80 | "Modified": "修改时间", 81 | "Action": "操作", 82 | "Search from ": "从", 83 | " bookmarks": "个书签中搜索", 84 | "Save Bookmark When Open URI": "打开 URI 时保存书签", 85 | 'Copy Current Viewport As Image': "复制当前页面为图片", 86 | 'Back': '返回', 87 | 'Forward': '前进', 88 | "star": "星标", 89 | 'Copy failed, you may focus on surfing view, click the title bar, and try again.': '复制失败,你可能聚焦到了 Surfing 视图,点击标题栏,然后再试一次。', 90 | "Default Category (Use , to split)": "默认分类 (用,分层)", 91 | "Send to ReadWise": "发送到 ReadWise", 92 | "Add a action in page header to Send to ReadWise.": "在页面标题栏中添加一个动作来发送到 ReadWise。", 93 | 'Disable / to search when on these sites': '当在这些网站中禁止按 / 来搜索的功能', 94 | 'Focus search bar via keyboard': '通过键盘聚焦到搜索栏', 95 | 'Show a popover when hover on the link.': '当鼠标悬停在链接上时显示一个弹出窗口。', 96 | 'Hover Popover': '悬停弹出窗口', 97 | 'Show original url': '显示原始链接', 98 | 'Enable Inline Preview': '启用内联预览', 99 | 'Enable inline preview with surfing. Currently only support Live preview': '启用 Surfing 的内联预览。目前仅支持实时预览', 100 | 'Enable HTML Preview': '启用 HTML 预览', 101 | 'Enable HTML Preview in Surfing': '在 Surfing 中启用 HTML 预览' 102 | }; 103 | -------------------------------------------------------------------------------- /src/translations/locale/zh-tw.ts: -------------------------------------------------------------------------------- 1 | // 繁體中文 2 | 3 | export default {}; 4 | -------------------------------------------------------------------------------- /src/types/bookmark.d.ts: -------------------------------------------------------------------------------- 1 | export interface BookmarkFolder { 2 | name: string; 3 | children: (Bookmark | BookmarkFolder)[]; 4 | root: boolean; 5 | parent?: BookmarkFolder | undefined; 6 | } 7 | 8 | export interface Bookmark { 9 | id: string, 10 | name: string, 11 | description: string, 12 | url: string, 13 | tags: string, 14 | category: string[], 15 | created: number, 16 | modified: number, 17 | } 18 | 19 | export interface FilterType { 20 | text: string 21 | value: string 22 | } 23 | 24 | export interface CategoryType { 25 | value: string 26 | label: string 27 | text: string 28 | children?: CategoryType[] 29 | } 30 | -------------------------------------------------------------------------------- /src/types/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | -------------------------------------------------------------------------------- /src/types/obsidian.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | import * as obsidian from 'obsidian'; 3 | import { EditorPosition, EventRef, MarkdownPreviewRenderer, Scope } from "obsidian"; 4 | import { SurfingView } from "../surfingView"; 5 | 6 | declare module "obsidian" { 7 | export interface ItemView { 8 | headerEl: HTMLDivElement; 9 | titleContainerEl: HTMLDivElement; 10 | } 11 | 12 | interface FileView { 13 | allowNoFile: boolean; 14 | } 15 | 16 | interface FileView { 17 | allowNoFile: boolean; 18 | } 19 | 20 | interface App { 21 | plugins: { 22 | getPlugin(name: string): any; 23 | enabledPlugins: Set; 24 | getPluginFolder(): string; 25 | }; 26 | internalPlugins: { 27 | plugins: { 28 | [name: string]: { 29 | enabled: boolean; 30 | enable(): void; 31 | disable(): void; 32 | instance: any; 33 | }; 34 | }; 35 | }; 36 | commands: any; 37 | getTheme: () => string; 38 | } 39 | 40 | interface Menu { 41 | close(): void; 42 | 43 | addSections(sections: any[]): Menu; 44 | } 45 | 46 | interface ItemView { 47 | scope: Scope; 48 | } 49 | 50 | interface HoverPopover { 51 | targetEl: HTMLElement; 52 | 53 | hide(): void; 54 | 55 | position({x, y, doc}: { 56 | x: number; 57 | y: number; 58 | doc: Document; 59 | }): void; 60 | } 61 | 62 | interface settings { 63 | applySettingsUpdate: () => void; 64 | } 65 | 66 | export interface WorkspaceLeaf { 67 | id: string 68 | 69 | history: { 70 | backHistory: Array, 71 | forwardHistory: Array 72 | }, 73 | tabHeaderInnerIconEl: HTMLDivElement, 74 | tabHeaderInnerTitleEl: HTMLDivElement 75 | activeTime: number 76 | rebuildView: () => void; 77 | setDimension: (dimension: any) => void; 78 | } 79 | 80 | interface Workspace { 81 | on(name: 'surfing:page-change', callback: (url: string, view: SurfingView) => any, ctx?: any): EventRef; 82 | } 83 | 84 | export interface WorkspaceItem { 85 | type: string; 86 | } 87 | 88 | interface VaultSettings { 89 | showViewHeader: boolean; 90 | } 91 | 92 | export interface Vault { 93 | config: Record; 94 | 95 | getConfig(setting: T): VaultSettings[T]; 96 | 97 | setConfig(setting: T, value: any): void; 98 | } 99 | 100 | class MarkdownPreviewRendererStatic extends MarkdownPreviewRenderer { 101 | static registerDomEvents(el: HTMLElement, handlerInstance: unknown, cb: (el: HTMLElement) => unknown): void; 102 | } 103 | 104 | export interface View { 105 | contentEl: HTMLElement, 106 | editMode: any, 107 | sourceMode: any, 108 | canvas?: any, 109 | } 110 | 111 | export interface Editor { 112 | getClickableTokenAt: (editorPos: EditorPosition) => tokenType; 113 | } 114 | 115 | export interface MenuItem { 116 | setSubmenu: () => Menu; 117 | } 118 | 119 | export interface MarkdownView { 120 | triggerClickableToken: (token: tokenType, t: boolean | string) => void; 121 | } 122 | 123 | export interface MarkdownSourceView { 124 | triggerClickableToken: (token: tokenType, t: boolean | string) => void; 125 | } 126 | 127 | export interface MarkdownRenderer { 128 | constructor: (t: any, e: any, c: any) => any; 129 | } 130 | } 131 | 132 | export type Side = 'top' | 'right' | 'bottom' | 'left'; 133 | 134 | export interface CanvasData { 135 | nodes: (CanvasFileData | CanvasTextData | CanvasLinkData)[]; 136 | edges: CanvasEdgeData[]; 137 | } 138 | 139 | export interface CanvasNodeData { 140 | id: string; 141 | x: number; 142 | y: number; 143 | width: number; 144 | height: number; 145 | color: string; 146 | } 147 | 148 | export interface CanvasEdgeData { 149 | id: string; 150 | fromNode: string; 151 | fromSide: Side; 152 | toNode: string; 153 | toSide: Side; 154 | color: string; 155 | label: string; 156 | } 157 | 158 | export interface CanvasFileData extends CanvasNodeData { 159 | type: 'file'; 160 | file: string; 161 | } 162 | 163 | export interface CanvasTextData extends CanvasNodeData { 164 | type: 'text'; 165 | text: string; 166 | } 167 | 168 | export interface CanvasLinkData extends CanvasNodeData { 169 | type: 'link'; 170 | url: string; 171 | } 172 | 173 | export interface ISuggestOwner { 174 | renderSuggestion(value: T, el: HTMLElement, index?: number): void; 175 | } 176 | 177 | 178 | interface tokenType { 179 | end: { 180 | line: number, 181 | ch: number 182 | }; 183 | start: { 184 | line: number, 185 | ch: number 186 | }; 187 | text: string; 188 | type: string; 189 | } 190 | -------------------------------------------------------------------------------- /src/types/widget.d.ts: -------------------------------------------------------------------------------- 1 | import { WidgetType } from "@codemirror/view"; 2 | 3 | interface MathWidget extends WidgetType { 4 | math: string; 5 | block: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/json.ts: -------------------------------------------------------------------------------- 1 | import { Bookmark, CategoryType } from "../types/bookmark"; 2 | import SurfingPlugin from "../surfingIndex"; 3 | 4 | interface jsonOutput { 5 | "bookmarks": Bookmark[], 6 | "categories": CategoryType[], 7 | } 8 | 9 | const bookmarkSavePath = (plugin: SurfingPlugin) => `${ plugin.app.vault.configDir }/surfing-bookmark.json` 10 | 11 | export const loadJson = async (plugin: SurfingPlugin): Promise => { 12 | const result = JSON.parse( 13 | await plugin.app.vault.adapter.read( 14 | bookmarkSavePath(plugin) 15 | ) 16 | ); 17 | 18 | return result; 19 | } 20 | 21 | export const saveJson = async (plugin: SurfingPlugin, data: any) => { 22 | await plugin.app.vault.adapter.write( 23 | bookmarkSavePath(plugin), 24 | JSON.stringify(data, null, 2) 25 | ); 26 | } 27 | 28 | export const initializeJson = async (plugin: SurfingPlugin) => { 29 | await plugin.app.vault.adapter.write( 30 | bookmarkSavePath(plugin), 31 | JSON.stringify({ 32 | "bookmarks": [ 33 | { 34 | "id": "2014068036", 35 | "name": "Obsidian", 36 | "url": "https://obsidian.md/", 37 | "description": "A awesome note-taking tool", 38 | "category": [ 39 | "ROOT" 40 | ], 41 | "tags": "", 42 | "created": 1672840861051, 43 | "modified": 1672840861052 44 | } 45 | ], 46 | "categories": [ 47 | { 48 | "value": "ROOT", 49 | "text": "ROOT", 50 | "label": "ROOT", 51 | "children": [] 52 | }, 53 | ] 54 | }, null, 2 55 | ) 56 | ); 57 | } 58 | 59 | export const exportJsonToClipboard = async (plugin: SurfingPlugin) => { 60 | const data = JSON.parse( 61 | await plugin.app.vault.adapter.read( 62 | bookmarkSavePath(plugin) 63 | ) 64 | ); 65 | navigator.clipboard.writeText(JSON.stringify(data, null, 2)); 66 | } 67 | 68 | export const exportJsonToMarkdown = async (plugin: SurfingPlugin) => { 69 | const data = JSON.parse( 70 | await plugin.app.vault.adapter.read( 71 | bookmarkSavePath(plugin) 72 | ) 73 | ); 74 | let result = `# Surfing Bookmarks`; 75 | for (const item of data) { 76 | result += `- [${ item.title }](${ item.url })`; 77 | } 78 | return result; 79 | } 80 | -------------------------------------------------------------------------------- /src/utils/splitContent.ts: -------------------------------------------------------------------------------- 1 | export class SplitContent { 2 | private content: string; 3 | private sContent: string[]; 4 | 5 | constructor(content: string) { 6 | this.content = content; 7 | 8 | this.sContent = content.split("\n"); 9 | } 10 | 11 | searchLines(s: string, i: number) { 12 | return s.substring(s.substring(0, i).lastIndexOf("\n") + 1, i + s.substring(i).indexOf("\n")); 13 | } 14 | 15 | search(offset: number, context: boolean) { 16 | let content = ""; 17 | if (!context) content = this.searchLines(this.content, offset); 18 | else { 19 | content = this.searchLines(this.content, offset); 20 | const matchLine = this.sContent.findIndex((item) => item.startsWith(content)); 21 | if (matchLine === 0) { 22 | content = this.sContent.slice(0, 2).filter(i => i && i.trim()).join("
"); 23 | } else { 24 | content = this.sContent.slice(matchLine - 1, matchLine + 1).filter(i => i && i.trim()).join("
"); 25 | } 26 | } 27 | return content; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/url.ts: -------------------------------------------------------------------------------- 1 | import { SEARCH_ENGINES } from "../surfingPluginSetting"; 2 | import SurfingPlugin from "src/surfingIndex"; 3 | 4 | export const checkIfWebBrowserAvailable = (url: string) => { 5 | return url.startsWith("http://") || url.startsWith("https://") || (url.startsWith("file://") && /\.htm(l)?/g.test(url)); 6 | }; 7 | 8 | export const getComposedUrl = (url: string, value: string) => { 9 | // Support both http:// and https:// 10 | // TODO: ?Should we support Localhost? 11 | // And the before one is : /[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4}\b(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?/gi; which will only match `blabla.blabla` 12 | // Support 192.168.0.1 for some local software server, and localhost 13 | // eslint-disable-next-line no-useless-escape 14 | const urlRegEx = /^(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#?&//=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/g; 15 | // eslint-disable-next-line no-useless-escape 16 | const urlRegEx2 = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(:[0-9]+)?|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w\-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/g; 17 | 18 | let tempValue = value; 19 | 20 | if (urlRegEx.test(tempValue)) { 21 | const first7 = tempValue.slice(0, 7).toLowerCase(); 22 | const first8 = tempValue.slice(0, 8).toLowerCase(); 23 | if (!(first7 === "http://" || first7 === "file://" || first8 === "https://")) { 24 | tempValue = "https://" + tempValue; 25 | } 26 | } else if ((!(tempValue.startsWith("file://") || (/\.htm(l)?/g.test(tempValue))) && !urlRegEx2.test(encodeURI(tempValue)))) { 27 | // If url is not a valid FILE url, search it with search engine. 28 | // @ts-ignore 29 | tempValue = url + tempValue; 30 | } 31 | 32 | if (!(/^(https?|file):\/\//g.test(tempValue))) tempValue = url + tempValue; 33 | 34 | return tempValue; 35 | }; 36 | 37 | export const getUrl = (urlString: string, plugin: SurfingPlugin) => { 38 | let url = urlString; 39 | 40 | if (!url) return; 41 | 42 | const pluginSettings = plugin.settings; 43 | 44 | const urlRegEx = /^(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#?&//=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/g; 45 | // eslint-disable-next-line no-useless-escape 46 | const urlRegEx2 = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(:[0-9]+)?|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w\-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/g; 47 | 48 | if (urlRegEx.test(url)) { 49 | const first7 = url.slice(0, 7).toLowerCase(); 50 | const first8 = url.slice(0, 8).toLowerCase(); 51 | if (!(first7 === "http://" || first7 === "file://" || first8 === "https://")) { 52 | url = "https://" + url; 53 | } 54 | } else if ((!(url.startsWith("file://") || (/\.htm(l)?/g.test(url))) && !urlRegEx2.test(encodeURI(url))) || !(/^(https?|file):\/\//g.test(url))) { 55 | // If url is not a valid FILE url, search it with search engine. 56 | const allSearchEngine = [...SEARCH_ENGINES, ...pluginSettings.customSearchEngine]; 57 | const currentSearchEngine = allSearchEngine.find((engine) => engine.name === pluginSettings.defaultSearchEngine); 58 | // @ts-ignore 59 | url = (currentSearchEngine ? currentSearchEngine.url : SEARCH_ENGINES[0].url) + url; 60 | } 61 | 62 | if (url) return url; 63 | else return urlString; 64 | 65 | }; 66 | 67 | 68 | export function isNormalLink(e: string) { 69 | if (!e || e.contains(" ")) 70 | return false; 71 | try { 72 | new URL(e); 73 | } catch (e) { 74 | return false; 75 | } 76 | return true; 77 | } 78 | 79 | const isEmail = /^(([^<>()[\]\\.,;:\s@\"`]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))\b/; 80 | 81 | export function isEmailLink(e: string) { 82 | return isEmail.test(e); 83 | } 84 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | ._root_1nd2u_1{align-items:center;display:grid;grid-template-columns:auto auto 1fr auto;height:32px;padding-inline-end:8px;border-bottom:solid 1px #eee;border-radius:var(--size-2-2)}._root_1nd2u_1:hover{background:var(--color-base-30)}._root_1nd2u_1._isSelected_1nd2u_29{background:var(--color-base-40);border-radius:var(--size-2-2)}._expandIconWrapper_1nd2u_39{align-items:center;font-size:0;cursor:pointer;display:flex;height:24px;justify-content:center;width:24px;transition:transform linear .1s;transform:rotate(0)}._expandIconWrapper_1nd2u_39._isOpen_1nd2u_63{transform:rotate(90deg)}._labelGridItem_1nd2u_71{padding-inline-start:8px;width:100%;overflow:hidden}._pipeY_1nd2u_83{position:absolute;border-left:2px solid #e7e7e7;left:-7px;top:-7px}._pipeX_1nd2u_97{position:absolute;left:-7px;top:15px;height:2px;background-color:#e7e7e7;z-index:-1}._root_kgzt2_1{background-color:#1967d2;height:2px;position:absolute;right:0;transform:translateY(-50%);top:0}._app_15i3q_1{height:100%;margin:var(--size-4-2);border-radius:var(--size-2-2)}._container_15i3q_13,._treeRoot_15i3q_21{height:100%}._draggingSource_15i3q_29{opacity:.3}._placeholderContainer_15i3q_37{position:relative}._dropTarget_15i3q_45{background-color:var(--color-accent)}._root_1gl8h_1{align-items:"center";background-color:#1967d2;border-radius:4px;box-shadow:0 12px 24px -6px #00000040,0 0 0 1px #00000014;color:#fff;display:inline-grid;font-size:14px;gap:8px;grid-template-columns:auto auto;padding:4px 8px;pointer-events:none}._icon_1gl8h_31,._label_1gl8h_33{align-items:center;display:flex}.wb-view-content{padding:0!important;overflow:hidden!important}.wb-frame{width:100%;height:100%;border:none;background-color:#fff;background-clip:content-box}.wb-view-content:has(.wb-bookmark-bar) .wb-frame{height:calc(100% - 32px)}.wb-header-bar:after{background:transparent!important}.wb-search-bar{width:100%}.wb-search-box{display:flex;flex-direction:row;position:absolute;z-index:20;top:35px;right:200px;width:200px;height:44px;background-color:var(--color-base-20);padding:7px;border:var(--input-border-width) solid var(--background-modifier-border)}.wb-search-input{width:60%;height:100%}.wb-search-button-group{width:40%;height:100%;display:flex;flex-direction:row}.wb-search-button{display:flex;align-items:center;width:100%;height:var(--input-height);border:var(--input-border-width) solid var(--background-modifier-border);background-color:var(--background-modifier-form-field);margin-left:4px}.wb-page-search-bar-input-container,.wb-page-search-bar-input{width:500px;min-width:20px;height:44px!important;border-radius:15px!important;margin-bottom:20px;margin-left:auto;margin-right:auto}.workspace-split:not(.mod-root) .wb-page-search-bar-input-container{width:250px}.workspace-split:not(.mod-root) .wb-page-search-bar-input{width:250px}.wb-page-search-bar{flex-direction:column-reverse}.wb-page-search-bar .wb-empty-actions{display:none}.wb-page-search-bar .empty-state-container{padding-top:100px}.wb-random-background .empty-state{background:url(https://source.unsplash.com/random/?mountain) no-repeat center center;background-size:cover}.wb-random-background .empty-state input{filter:opacity(.8)}.wb-search-bar-container{margin-left:auto;margin-right:auto;position:absolute;top:26%}.wb-page-search-bar-container .wb-last-opened-files{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:var(--size-4-2);justify-items:center;margin-top:var(--size-4-12)}.wb-page-search-bar-container .wb-last-opened-files .wb-last-opened-file{display:flex;flex-direction:row;align-items:center;gap:var(--size-2-2);background-color:var(--interactive-normal);box-shadow:var(--input-shadow);opacity:.6;justify-content:flex-start;width:160px;height:40px;cursor:pointer}.wb-page-search-bar-container .wb-last-opened-files .wb-last-opened-file:hover{opacity:1}.wb-page-search-bar-container .wb-last-opened-files .wb-last-opened-file-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wb-page-search-bar-text{text-align:center;margin-bottom:20px;font-size:72px;font-weight:bolder;color:var(--color-accent)}.wb-create-btn,.wb-search-btn{opacity:.4;color:#9da7d9}.wb-close-btn{opacity:.4;color:#d99da8}.wb-icon-list-container button{padding:1px 6px}.wb-create-btn:hover,.wb-search-btn:hover,.wb-close-btn:hover{opacity:1}.wb-close-btn:hover>button>.lucide-x-square{color:#d99da8}.wb-close-btn>button>.lucide-x-square{color:var(--color-red)}.wb-icon-list-container{margin-right:auto;margin-left:auto;position:absolute;bottom:12%;display:flex;flex-direction:row;gap:10px}.wb-btn-tip{color:var(--color-base-60)}.wb-btn:hover{background:var(--color-accent)!important}.wb-btn{filter:drop-shadow(0 4px 3px rgb(0 0 0 / .07)) drop-shadow(0 2px 2px rgb(0 0 0 / .06))}.theme-dark .wb-btn a{color:var(--color-base-80)!important}.setting-item.search-engine-setting{flex-wrap:wrap}.search-engine-setting .setting-item-control{flex:1 1 auto;text-align:right;display:flex;justify-content:flex-end;align-items:center;gap:var(--size-4-2)}.search-engine-setting .search-engine-main-settings{width:100%;display:flex;flex-direction:column;border-top:solid 1px var(--background-modifier-border);margin-top:10px}.search-engine-main-settings-name{display:flex;justify-content:space-between;align-items:center;margin-bottom:5px;margin-top:5px}.search-engine-main-settings-url{display:flex;justify-content:space-between;align-items:center;margin-top:5px}.search-engine-setting .setting-item-name:before{content:"";display:inline-block;height:20px;width:1px;border-left:3px solid var(--text-accent);vertical-align:middle;margin-right:10px;margin-left:0}.wb-setting-title{display:flex;justify-content:space-between;flex-direction:row;align-items:center}.wb-setting-tab-group{display:flex;justify-content:flex-start}.wb-setting-searching{opacity:.4}.wb-tab-settings textarea{width:500px;height:200px;overflow-y:scroll}.wb-navigation-item{display:flex;align-items:flex-start;gap:3px;margin-right:10px;margin-bottom:2px;padding:6px 5px 4px;border-radius:5px}.wb-navigation-item-selected{background-color:var(--interactive-accent);color:var(--text-on-accent)}.wb-setting-header{border-bottom:var(--color-base-40) 0px solid}.wb-tab-settings{margin-bottom:20px}.wb-setting-heading{color:var(--color-accent)}.wb-about-icon{height:72px;text-align:center}.setting-item-control .surfing-setting-textarea{height:400px;width:200px}.setting-item-control .surfing-setting-input{width:400px}.wb-about-icon .surfing{height:72px!important;width:72px!important}.wb-about-text{font-size:16px;color:var(--color-accent)}.wb-about-card{display:flex;align-items:center;flex-direction:column;margin-top:30px}.wb-about-version{font-size:14px;text-decoration:unset!important;opacity:.8;color:var(--link-color)}.surfing-settings-icon{width:fit-content;height:fit-content;position:absolute;right:20px}.mod-wb-bookmark-bar .surfing-settings-icon{top:calc(var(--header-height) + 40px)}.wb-frame-notice{text-align:center;background-color:var(--color-yellow);font-size:14px;padding-top:4px;padding-bottom:4px}.wb-search-suggestion-container{background-color:var(--color-base-10);border-radius:var(--radius-l);filter:drop-shadow(0 4px 3px rgb(0 0 0 / .07)) drop-shadow(0 2px 2px rgb(0 0 0 / .06))}.wb-search-suggestion{border-radius:var(--radius-l);margin-bottom:-1px}.wb-search-suggestion:has(.wb-bookmark-suggest-item){max-height:300px;overflow-y:auto}.wb-search-suggestion::--webkit-scrollbar{display:none}.wb-search-suggest-item.is-selected{background-color:var(--color-accent)}.theme-light .wb-search-suggest-item.is-selected{color:var(--color-base-10)}.wb-search-suggest-item:first-child.is-selected{border-radius:var(--radius-l) var(--radius-l) var(--radius-m) var(--radius-m)}.wb-search-suggest-item:last-child.is-selected{border-radius:var(--radius-m) var(--radius-m) var(--radius-l) var(--radius-l)}.wb-search-suggest-item{display:flex;justify-content:space-between;align-items:center}.theme-light .wb-search-suggest-item.is-selected .wb-search-suggestion-index{color:var(--color-base-10);opacity:.6}.wb-search-suggestion-index{opacity:.2;font-size:12px;font-weight:700}input[type=text].wb-search-bar:active,input[type=text].wb-search-bar:focus,input[type=text].wb-search-bar:focus-visible{box-shadow:unset}input[type=text].wb-page-search-bar-input:active,input[type=text].wb-page-search-bar-input:focus,input[type=text].wb-page-search-bar-input:focus-visible{box-shadow:unset}.wb-theme-settings-working-on{background-color:var(--color-accent);flex-direction:column;border-radius:var(--radius-l)}.theme-light .wb-theme-settings-working-on .setting-item-name{color:var(--color-base-10)}.wb-omni-box{position:absolute;right:var(--size-4-9);top:var(--size-4-18);width:30%;height:fit-content;max-height:40%;overflow:auto;border-radius:var(--radius-m);padding:var(--size-4-4)}.wb-omni-box::-webkit-scrollbar{display:none}.theme-light .wb-omni-box{background-color:var(--color-base-10)}.theme-dark .wb-omni-box{background-color:var(--color-base-30)}.wb-omni-item-path{margin:var(--size-2-3) var(--size-2-2);text-emphasis:inherit;overflow-x:hidden;padding:var(--size-2-1);border-radius:var(--radius-s)}.theme-light .wb-omni-item-path{color:var(--color-base-20)}.wb-omni-item{margin:var(--size-2-3);background-color:var(--color-accent);padding:var(--size-2-1);border:var(--color-accent) 1px solid;border-radius:var(--radius-s)}.wb-omni-item-content-list{margin:var(--size-2-2) var(--size-2-1);gap:var(--size-2-1);border-radius:var(--radius-m)}.theme-light .wb-omni-item-content-list{background-color:var(--color-base-10)}.theme-dark .wb-omni-item-content-list{background-color:var(--color-base-30)}.wb-content-list-text{padding:var(--size-2-2) var(--size-2-1);line-height:var(--size-4-5);background-color:var(--color-base-10);border:var(--color-accent) 1px solid;width:initial;overflow-x:hidden;border-radius:var(--radius-m);margin-bottom:var(--size-4-3)}.theme-light .wb-content-list-text{background-color:var(--color-base-20);filter:drop-shadow(0 4px 3px rgb(0 0 0 / .07)) drop-shadow(0 2px 2px rgb(0 0 0 / .06))}.theme-dark .wb-content-list-text{background-color:var(--color-base-30)}.mod-wb-bookmark-bar .empty-state.wb-page-search-bar{position:unset}.wb-bookmark-bar{display:flex;align-items:center;overflow:hidden;padding-bottom:var(--size-2-1);padding-top:var(--size-2-1);padding-left:var(--size-4-2);min-height:32px;border-top:1px solid var(--background-modifier-border);border-bottom:1px solid var(--background-modifier-border)}div[data-type^=empty] .wb-bookmark-bar{position:absolute;top:var(--header-height);width:100%;margin-top:-1px;z-index:1}.wb-bookmark-item,.wb-bookmark-folder{max-width:120px;text-overflow:hidden;overflow:hidden;margin-right:var(--size-2-2);padding:var(--size-2-2);border:1px solid var(--color-base-10);border-radius:var(--radius-s);white-space:nowrap;width:10%;display:flex;align-items:flex-end;align-content:flex-end}.wb-bookmark-item:hover,.wb-bookmark-folder:hover{background-color:var(--color-base-30)}.wb-bookmark-item-title,.wb-bookmark-folder-title{text-overflow:ellipsis;overflow:hidden;padding-right:var(--size-4-1);padding-left:var(--size-2-1);font-size:var(--font-smallest)}.wb-bookmark-bar::-webkit-scrollbar{display:none}.wb-bookmark-bar-container{display:flex;width:95%;overflow-x:scroll}.wb-bookmark-bar-container::-webkit-scrollbar{display:none}.wb-bookmark-folder-icon,.wb-bookmark-item-icon{padding:unset;height:16px;margin-right:var(--size-2-2)}.wb-bookmark-folder-icon .lucide-folder-open,.wb-bookmark-item-icon .lucide-album{height:var(--size-4-4);width:var(--size-4-4)}div[data-type^=empty].workspace-leaf-content .view-content.mod-wb-bookmark-bar{padding:unset;overflow:auto}.surfing-bookmark-manager-header-bar{display:flex;justify-content:start}.surfing-bookmark-manager{margin:0 1em;display:flex;flex-direction:column}.surfing-bookmark-manager-header-bar .surfing-bookmark-manager-search-bar{display:flex;align-items:center}.surfing-bookmark-manager-search-bar .ant-input-affix-wrapper{padding:0 11px}.surfing-bookmark-manager-header-bar .ant-row{display:flex;align-items:center}.ant-table-header{min-height:55px}.surfing-bookmark-manager-header-bar{height:50px}:where(.css-dev-only-do-not-override-1np4o0i).ant-input-affix-wrapper{background-color:var(--background-modifier-form-field)}.surfing-bookmark-manager-header-bar button{margin:0 0 0 10px}.wb-bookmark-manager-entry{position:absolute;right:var(--size-4-3);padding:var(--size-2-1);border-radius:var(--radius-s);color:var(--color-red)}.wb-bookmark-manager-icon{height:18px;width:18px;display:flex}.wb-refresh-button,.wb-refresh-button .lucide-refresh-cw{height:var(--size-4-4);width:var(--size-4-4);color:var(--color-base-50)}.wb-refresh-button{margin-right:var(--size-4-2)}.ant-table-wrapper .ant-table-pagination.ant-pagination{margin:6px 0}.ant-table-container{height:100%;overflow:hidden;display:flex;flex-direction:column}.ant-table-wrapper,.ant-spin-nested-loading,.ant-spin-container{height:100%}.ant-table-wrapper{height:86vh}.ant-table-wrapper .ant-table-thead>tr>th{background-color:var(--background-secondary)}.ant-table{height:100%}.wb-reset-button{left:0}.ant-form-item .submit-bar{display:flex;justify-content:space-between}.theme-light .ant-btn-primary{background-color:#1677ff}.theme-light .ant-tree-treenode-checkbox-checked .ant-tree-node-content-wrapper{color:var(--text-on-accent)}.surfing-bookmark-manager-header .ant-col-6{display:flex;align-items:center}div[data-type^=surfing-bookmark-manager] .ant-table-thead{height:20px}.wb-bookmark-manager-entry:hover{background-color:var(--color-base-30)}.cm-scroller .wb-view-content-embeded{height:500px}.suggestion-item.wb-bookmark-suggest-item{display:flex;align-items:center;justify-content:space-between}.wb-bookmark-suggest-container{display:flex;gap:10px;max-width:92%}.wb-bookmark-suggestion-text{font-weight:bolder;overflow-x:hidden;text-overflow:ellipsis;white-space:nowrap}.wb-bookmark-suggestion-url{opacity:.4;overflow-x:hidden;text-overflow:ellipsis;white-space:nowrap}.wb-bookmark-modal h2{text-align:center}.wb-bookmark-modal .wb-bookmark-modal-btn-container{display:flex;align-items:center;flex-direction:row;gap:10px}.wb-bookmark-modal .modal-content{display:flex;flex-direction:column;align-items:center}.anticon-arrow-right svg{width:var(--icon-xs);height:var(--icon-xs)}.tab-tree-empty-container svg{width:var(--icon-xl);height:var(--icon-xl);opacity:50%}.tab-tree-empty-container{display:flex;align-items:center;text-align:center;justify-content:center}.tab-tree-empty-state{display:flex;flex-direction:column;align-items:center;gap:1em;opacity:30%}div[data-type^=surfing-tab-tree] ul,div[data-type^=surfing-tab-tree] ul li{list-style:none;margin:0;padding:0}.surfing-hover-popover{height:400px}.surfing-embed-website{height:800px}.surfing-hover-popover .surfing-hover-popover-container,.surfing-hover-popover .wb-view-content.node-insert-event,.surfing-embed-website .surfing-embed-website-container,.surfing-embed-website .surfing-embed-website-container .wb-view-content.node-insert-event{height:100%}.popover.hover-editor .popover-content:has(div[data-type^=surfing-view]){width:100%}.cm-browser-widget{border:1px solid var(--background-modifier-border)}.cm-browser-widget .wb-browser-inline{height:max(4vw,400px)}.cm-browser-widget .wb-show-original-code{position:absolute;right:var(--size-4-2);top:var(--size-4-2);visibility:hidden}.cm-browser-widget:hover .wb-show-original-code{visibility:visible}.surfing-hover-popover{z-index:99999} 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES2022", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": ["DOM", "ES5", "ES6", "ES7"], 15 | "jsx": "react-jsx", 16 | "allowSyntheticDefaultImports": true 17 | }, 18 | "include": ["**/*.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.1": "1.0.0", 3 | "0.1.2": "1.0.0", 4 | "0.1.3": "1.0.0", 5 | "0.1.4": "1.0.0", 6 | "0.1.5": "1.0.0", 7 | "0.1.6": "1.0.0", 8 | "0.1.7": "1.0.0", 9 | "0.1.8": "1.0.0", 10 | "0.1.9": "1.0.0", 11 | "0.2.0": "1.0.0", 12 | "0.2.1": "1.0.0", 13 | "0.2.2": "1.0.0", 14 | "0.2.3": "1.0.0", 15 | "0.2.4": "1.0.0", 16 | "0.3.0": "1.0.0", 17 | "0.4.0": "1.0.0", 18 | "0.4.1": "1.0.0", 19 | "0.4.2": "1.0.0", 20 | "0.4.3": "1.0.0", 21 | "0.4.4": "1.0.0", 22 | "0.4.5": "1.0.0", 23 | "0.4.6": "1.0.0", 24 | "0.5.0": "1.0.0", 25 | "0.6.0": "1.0.0", 26 | "0.6.1": "1.0.0", 27 | "0.6.2": "1.0.0", 28 | "0.6.3": "1.0.0", 29 | "0.6.4": "1.0.0", 30 | "0.6.5": "1.0.0", 31 | "0.6.6": "1.0.0", 32 | "0.6.7": "1.0.0", 33 | "0.7.0": "1.0.0", 34 | "0.8.0": "1.0.0", 35 | "0.8.1": "1.0.0", 36 | "0.8.2": "1.0.0", 37 | "0.8.3": "1.0.0", 38 | "0.8.4": "1.0.0", 39 | "0.8.5": "1.0.0", 40 | "0.8.6": "1.0.0", 41 | "0.8.7": "1.0.0", 42 | "0.8.8": "1.0.0", 43 | "0.8.9": "1.0.0", 44 | "0.8.10": "1.0.0", 45 | "0.8.11": "1.0.0", 46 | "0.8.12": "1.0.0", 47 | "0.8.13": "1.0.0", 48 | "0.8.14": "1.0.0", 49 | "0.8.15": "1.0.0", 50 | "0.8.16": "1.0.0", 51 | "0.9.0": "1.4.0", 52 | "0.9.1": "1.4.0", 53 | "0.9.2": "1.4.0", 54 | "0.9.3": "1.4.0", 55 | "0.9.4": "1.4.0", 56 | "0.9.5": "1.4.0", 57 | "0.9.8": "1.4.0", 58 | "0.9.9": "1.4.0", 59 | "0.9.10": "1.4.0", 60 | "0.9.11": "1.4.0", 61 | "0.9.12": "1.4.0", 62 | "0.9.13": "1.4.0", 63 | "0.9.14": "1.4.0" 64 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import {defineConfig} from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | import terser from '@rollup/plugin-terser'; 5 | import replace from '@rollup/plugin-replace'; 6 | import resolve from '@rollup/plugin-node-resolve'; 7 | 8 | export default defineConfig(({mode}) => { 9 | return { 10 | plugins: [react()], 11 | build: { 12 | sourcemap: mode === 'development' ? 'inline' : false, 13 | minify: mode === 'development' ? false : true, 14 | // Use Vite lib mode https://vitejs.dev/guide/build.html#library-mode 15 | lib: { 16 | entry: path.resolve(__dirname, './src/surfingIndex.ts'), 17 | formats: ['cjs'], 18 | }, 19 | rollupOptions: { 20 | plugins: [ 21 | mode === 'development' 22 | ? '' 23 | : terser({ 24 | compress: { 25 | defaults: false, 26 | drop_console: true, 27 | }, 28 | mangle: { 29 | eval: true, 30 | module: true, 31 | toplevel: true, 32 | safari10: true, 33 | properties: false, 34 | }, 35 | output: { 36 | comments: false, 37 | ecma: '2020', 38 | }, 39 | }), 40 | resolve({ 41 | browser: false, 42 | }), 43 | replace({ 44 | preventAssignment: true, 45 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 46 | }), 47 | ], 48 | treeshake: true, 49 | output: { 50 | // Overwrite default Vite output fileName 51 | entryFileNames: 'main.js', 52 | assetFileNames: 'styles.css', 53 | }, 54 | external: [ 55 | 'obsidian', 56 | 'electron', 57 | '@codemirror/autocomplete', 58 | '@codemirror/collab', 59 | '@codemirror/commands', 60 | '@codemirror/language', 61 | '@codemirror/lint', 62 | '@codemirror/search', 63 | '@codemirror/state', 64 | '@codemirror/view', 65 | '@lezer/common', 66 | '@lezer/highlight', 67 | '@lezer/lr', 68 | ], 69 | }, 70 | emptyOutDir: false, 71 | outDir: '.', 72 | }, 73 | resolve: { 74 | alias: { 75 | '@': path.resolve(__dirname, './src'), 76 | }, 77 | }, 78 | }; 79 | }); 80 | --------------------------------------------------------------------------------