├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── demo.gif ├── index.html ├── keybinding-settings.png ├── package.json ├── pnpm-lock.yaml ├── readme.md ├── release.config.js ├── renovate.json ├── src ├── App.tsx ├── PageTabs.css ├── PageTabs.tsx ├── main.tsx ├── reset.css ├── settings.ts ├── types.ts └── utils.ts ├── tsconfig.json └── vite.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/eslint-recommended", 5 | "plugin:@typescript-eslint/recommended" 6 | ], 7 | "plugins": ["@typescript-eslint", "react-hooks"], 8 | "parser": "@typescript-eslint/parser", 9 | "rules": { 10 | "react-hooks/rules-of-hooks": "error", 11 | "react-hooks/exhaustive-deps": "warn", 12 | "import/prefer-default-export": "off", 13 | "@typescript-eslint/ban-ts-comment": "off", 14 | "@typescript-eslint/no-non-null-assertion": "off", 15 | "@typescript-eslint/explicit-module-boundary-types": "off" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Releases 4 | 5 | # Controls when the action will run. 6 | on: 7 | push: 8 | branches: 9 | - "master" 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | release: 16 | # The type of runner that the job will run on 17 | runs-on: ubuntu-latest 18 | 19 | # Steps represent a sequence of tasks that will be executed as part of the job 20 | steps: 21 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 22 | - uses: actions/checkout@v2 23 | - uses: actions/setup-node@v2 24 | with: 25 | node-version: "16" 26 | - uses: pnpm/action-setup@v2.0.1 27 | with: 28 | version: 6.0.2 29 | - run: pnpm install 30 | - run: pnpm build 31 | - name: Install zip 32 | uses: montudor/action-zip@v1 33 | - name: Release 34 | run: npx semantic-release 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.19.4](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.19.3...v1.19.4) (2024-01-03) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * name of the Journals tab ([d495c6b](https://github.com/pengx17/logseq-plugin-tabs/commit/d495c6b325a8c48a71f2fc2feda0cde17e2ad2b4)) 7 | 8 | ## [1.19.3](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.19.2...v1.19.3) (2023-09-28) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * support draggable app region for the awesome UI plugin ([7472067](https://github.com/pengx17/logseq-plugin-tabs/commit/74720672ae192b68e230d2f6ec452e1b68d47a66)) 14 | 15 | ## [1.19.2](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.19.1...v1.19.2) (2023-09-27) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * tabs unclickable occasionally ([6fab7a1](https://github.com/pengx17/logseq-plugin-tabs/commit/6fab7a15dcdd551d422449cc2ae861e4056ab19a)) 21 | 22 | ## [1.19.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.19.0...v1.19.1) (2023-05-05) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * a bug where a tab for a block is converted to a Journals tab when activating it ([d94921a](https://github.com/pengx17/logseq-plugin-tabs/commit/d94921af8a61251e58fab3eca1d0fba9d9686eff)) 28 | 29 | # [1.19.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.18.3...v1.19.0) (2023-04-28) 30 | 31 | 32 | ### Features 33 | 34 | * bump version for new features ([e00b972](https://github.com/pengx17/logseq-plugin-tabs/commit/e00b972fcd15dcf9226ed640d49528e7d1968f0a)) 35 | 36 | ## [1.18.3](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.18.2...v1.18.3) (2023-03-07) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * Page padding ([5197e28](https://github.com/pengx17/logseq-plugin-tabs/commit/5197e28ec3fff91ff0ba961e94be973ee45e38e5)) 42 | 43 | ## [1.18.2](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.18.1...v1.18.2) (2023-02-17) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * bump new version ([7bea47a](https://github.com/pengx17/logseq-plugin-tabs/commit/7bea47a1e874f7aa201a3375212dce5c77f5c008)) 49 | 50 | ## [1.18.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.18.0...v1.18.1) (2022-11-02) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * issue on opening non-existed tab ([6bfb0a6](https://github.com/pengx17/logseq-plugin-tabs/commit/6bfb0a6de56cc6a4f7abebb19dd5d6439a9712c7)) 56 | 57 | # [1.18.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.17.3...v1.18.0) (2022-10-29) 58 | 59 | 60 | ### Features 61 | 62 | * option to hide "Close All" button ([5e93c8a](https://github.com/pengx17/logseq-plugin-tabs/commit/5e93c8af0ce5ba2c950fac695cdff6312db64ac7)) 63 | 64 | ## [1.17.3](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.17.2...v1.17.3) (2022-10-27) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * graggable area ([909ee6a](https://github.com/pengx17/logseq-plugin-tabs/commit/909ee6ac2cb128517da0a378ba9c25b286c4b2ca)) 70 | 71 | ## [1.17.2](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.17.1...v1.17.2) (2022-09-30) 72 | 73 | 74 | ### Bug Fixes 75 | 76 | * bump version for Add BODY class on load ([6334bc3](https://github.com/pengx17/logseq-plugin-tabs/commit/6334bc3583bc4e2d95482939d9a9fd7bf670a54b)) 77 | 78 | ## [1.17.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.17.0...v1.17.1) (2022-08-23) 79 | 80 | 81 | ### Bug Fixes 82 | 83 | * key collision issue ([a1ec22a](https://github.com/pengx17/logseq-plugin-tabs/commit/a1ec22a0787243c44fe0d82b07c4735fdd839dca)) 84 | 85 | # [1.17.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.16.0...v1.17.0) (2022-08-22) 86 | 87 | 88 | ### Features 89 | 90 | * option to have tab closeButton on left ([183a494](https://github.com/pengx17/logseq-plugin-tabs/commit/183a4946a62e7cb39c0e764bba44aa6d703359ac)) 91 | 92 | # [1.16.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.15.3...v1.16.0) (2022-08-22) 93 | 94 | 95 | ### Features 96 | 97 | * middleMouseClick opens new tab ([2cf6de5](https://github.com/pengx17/logseq-plugin-tabs/commit/2cf6de57c9bc53dca098c84ed581d31c2868c61a)) 98 | 99 | ## [1.15.3](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.15.2...v1.15.3) (2022-08-01) 100 | 101 | 102 | ### Bug Fixes 103 | 104 | * set toggle pin to empty binding by default since it conflicts with default ones ([dcf08e6](https://github.com/pengx17/logseq-plugin-tabs/commit/dcf08e6517bb1ca4d37e01e66a8dd011abd60b3b)) 105 | 106 | ## [1.15.2](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.15.1...v1.15.2) (2022-07-21) 107 | 108 | 109 | ### Bug Fixes 110 | 111 | * bump for new version ([de2dc87](https://github.com/pengx17/logseq-plugin-tabs/commit/de2dc87ae02a870fda00b427ce28957c914fb48a)) 112 | 113 | ## [1.15.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.15.0...v1.15.1) (2022-06-29) 114 | 115 | 116 | ### Bug Fixes 117 | 118 | * append the block id aggressively when opening a block ([0c898fa](https://github.com/pengx17/logseq-plugin-tabs/commit/0c898fa2d845d49335821569bb55cec1cdaf41a7)) 119 | 120 | # [1.15.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.14.1...v1.15.0) (2022-06-17) 121 | 122 | 123 | ### Features 124 | 125 | * allow user custom tabs styles through custom.css ([61f23e8](https://github.com/pengx17/logseq-plugin-tabs/commit/61f23e80f41387f9f77d47d9145a7b510cb73def)) 126 | 127 | ## [1.14.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.14.0...v1.14.1) (2022-06-09) 128 | 129 | 130 | ### Bug Fixes 131 | 132 | * add new tab via MOD+ENTER in search menu ([01d2a4a](https://github.com/pengx17/logseq-plugin-tabs/commit/01d2a4a1a7cd207c0b28b8953ad2a7fcea1ed511)) 133 | 134 | # [1.14.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.13.1...v1.14.0) (2022-06-01) 135 | 136 | 137 | ### Features 138 | 139 | * open tabs via search menu ([f65bd53](https://github.com/pengx17/logseq-plugin-tabs/commit/f65bd538f4f8939f9a371ba65419e83bbabda5fb)) 140 | 141 | ## [1.13.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.13.0...v1.13.1) (2022-05-19) 142 | 143 | 144 | ### Bug Fixes 145 | 146 | * add home page to show tab routes ([0a73716](https://github.com/pengx17/logseq-plugin-tabs/commit/0a73716e5cd15ccb7f6d0bfcdceb8297c9c58af7)) 147 | 148 | # [1.13.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.12.6...v1.13.0) (2022-05-19) 149 | 150 | 151 | ### Features 152 | 153 | * add button for closing all tabs ([3022ff5](https://github.com/pengx17/logseq-plugin-tabs/commit/3022ff581bb2b6e7fd38cfb840923fa1b0eae502)) 154 | 155 | ## [1.12.6](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.12.5...v1.12.6) (2022-05-17) 156 | 157 | 158 | ### Bug Fixes 159 | 160 | * only show tabs when needed ([061796f](https://github.com/pengx17/logseq-plugin-tabs/commit/061796f57546142b79be6b1d24126b0a745d9ab4)) 161 | 162 | ## [1.12.5](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.12.4...v1.12.5) (2022-05-10) 163 | 164 | 165 | ### Bug Fixes 166 | 167 | * close other tabs not working properly ([25caa65](https://github.com/pengx17/logseq-plugin-tabs/commit/25caa65a9b402d538f590178e3bff97b3a019a0a)) 168 | 169 | ## [1.12.4](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.12.3...v1.12.4) (2022-05-10) 170 | 171 | 172 | ### Bug Fixes 173 | 174 | * add close other tabs command ([991deab](https://github.com/pengx17/logseq-plugin-tabs/commit/991deab91dac4851adf81956da453b39ff0e6270)) 175 | 176 | ## [1.12.3](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.12.2...v1.12.3) (2022-05-05) 177 | 178 | 179 | ### Bug Fixes 180 | 181 | * add a command to close all tabs ([8359d73](https://github.com/pengx17/logseq-plugin-tabs/commit/8359d733e4cfb537000f2bde53a192f94f7f6ae6)) 182 | 183 | ## [1.12.2](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.12.1...v1.12.2) (2022-04-29) 184 | 185 | 186 | ### Bug Fixes 187 | 188 | * clean up get alias call ([22973fb](https://github.com/pengx17/logseq-plugin-tabs/commit/22973fbe5270112b31a60b90eace851f852b7696)) 189 | * pinned tab issue ([b9d78ac](https://github.com/pengx17/logseq-plugin-tabs/commit/b9d78acd4fb1e1daf4ec29f0ce1f907bbd876fc8)) 190 | 191 | ## [1.12.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.12.0...v1.12.1) (2022-04-26) 192 | 193 | 194 | ### Bug Fixes 195 | 196 | * allow the user give empty keybinding to revoke ([5fcd334](https://github.com/pengx17/logseq-plugin-tabs/commit/5fcd334c8b14801a188e05c5d7dcb66f8ed76118)) 197 | 198 | # [1.12.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.11.0...v1.12.0) (2022-04-26) 199 | 200 | 201 | ### Features 202 | 203 | * support selecting tabs with mod+1 ~ 9 ([5a939e5](https://github.com/pengx17/logseq-plugin-tabs/commit/5a939e53ad4d9520a06b54590b1ab7c40b14268b)) 204 | 205 | # [1.11.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.10.0...v1.11.0) (2022-04-24) 206 | 207 | 208 | ### Features 209 | 210 | * allow users to customize shortcuts ([041f048](https://github.com/pengx17/logseq-plugin-tabs/commit/041f048f5b9396a4ccfc12ce3c47baa3b0c5b2ba)) 211 | 212 | # [1.10.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.9.2...v1.10.0) (2022-04-18) 213 | 214 | 215 | ### Features 216 | 217 | * add CTRL+TAB & CTRL+SHIFT+TAB commands ([34f2e3d](https://github.com/pengx17/logseq-plugin-tabs/commit/34f2e3d3d2e18821bbbf417a47d3573f35cc691b)) 218 | 219 | ## [1.9.2](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.9.1...v1.9.2) (2022-04-06) 220 | 221 | 222 | ### Bug Fixes 223 | 224 | * add a padding top to make tabs & content fit nicer ([04e4bdf](https://github.com/pengx17/logseq-plugin-tabs/commit/04e4bdf23b419be117bda38023dfabe1e31ccb4b)) 225 | 226 | ## [1.9.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.9.0...v1.9.1) (2022-04-06) 227 | 228 | 229 | ### Bug Fixes 230 | 231 | * keyboard binding sometimes does not work ([465c5a7](https://github.com/pengx17/logseq-plugin-tabs/commit/465c5a750c54b11c95930626e6acbead6e3ae732)) 232 | 233 | # [1.9.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.8.9...v1.9.0) (2022-03-11) 234 | 235 | 236 | ### Bug Fixes 237 | 238 | * cursor blink issue ([0947f31](https://github.com/pengx17/logseq-plugin-tabs/commit/0947f31d5f99122b4dffa9c2c3f2a647d5f04b17)) 239 | 240 | 241 | ### Features 242 | 243 | * add two keyboard shortcuts ([5bffa80](https://github.com/pengx17/logseq-plugin-tabs/commit/5bffa80be9eb01d22cd4106d896a8e892e764e43)) 244 | 245 | ## [1.8.9](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.8.8...v1.8.9) (2022-02-21) 246 | 247 | 248 | ### Bug Fixes 249 | 250 | * bump version for [#18](https://github.com/pengx17/logseq-plugin-tabs/issues/18) ([eecd6af](https://github.com/pengx17/logseq-plugin-tabs/commit/eecd6af18776b4a66ca99d2641e4512be0983a25)) 251 | 252 | ## [1.8.8](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.8.7...v1.8.8) (2022-02-17) 253 | 254 | 255 | ### Bug Fixes 256 | 257 | * remove keyboard shortcuts for now (will refactor this later) ([24d9445](https://github.com/pengx17/logseq-plugin-tabs/commit/24d9445b51d707eea635bb9f3cb3373d5794f850)) 258 | * show block content in tab ([9239369](https://github.com/pengx17/logseq-plugin-tabs/commit/92393696953d57ccfaa034d3dfc4824926907c4c)), closes [#16](https://github.com/pengx17/logseq-plugin-tabs/issues/16) 259 | 260 | ## [1.8.7](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.8.6...v1.8.7) (2021-12-01) 261 | 262 | 263 | ### Bug Fixes 264 | 265 | * disable context menu event for now ([4092b38](https://github.com/pengx17/logseq-plugin-tabs/commit/4092b3832952c594b786bcbac764486bf5d8ec27)) 266 | 267 | ## [1.8.6](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.8.5...v1.8.6) (2021-11-29) 268 | 269 | 270 | ### Bug Fixes 271 | 272 | * add a border to tabs ([46cdd8c](https://github.com/pengx17/logseq-plugin-tabs/commit/46cdd8c7e6dec0f872d3c75a580068eec0f181c9)) 273 | 274 | ## [1.8.5](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.8.4...v1.8.5) (2021-11-28) 275 | 276 | 277 | ### Bug Fixes 278 | 279 | * allow open sidebar items into tabs ([69ab179](https://github.com/pengx17/logseq-plugin-tabs/commit/69ab1794c769e3a55866a6359eedd2a348c2eff4)) 280 | * preserve focus when hovering tabs ([aeb1a23](https://github.com/pengx17/logseq-plugin-tabs/commit/aeb1a23fff666626e42d0e871eec93b69f8b1c57)) 281 | 282 | ## [1.8.4](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.8.3...v1.8.4) (2021-11-19) 283 | 284 | 285 | ### Bug Fixes 286 | 287 | * minor ux ([9ce517e](https://github.com/pengx17/logseq-plugin-tabs/commit/9ce517e446631cd39a3feccbd7949579f84cb453)) 288 | 289 | ## [1.8.3](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.8.2...v1.8.3) (2021-11-15) 290 | 291 | 292 | ### Bug Fixes 293 | 294 | * tabs scroll on active is not working ([41635e9](https://github.com/pengx17/logseq-plugin-tabs/commit/41635e90b7e9118dbc317f645eea093e785e6c36)) 295 | 296 | ## [1.8.2](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.8.1...v1.8.2) (2021-11-12) 297 | 298 | 299 | ### Bug Fixes 300 | 301 | * should only pushState when change to different pages ([1ece34c](https://github.com/pengx17/logseq-plugin-tabs/commit/1ece34ce91e8f16c37b879add5920029879d347b)) 302 | 303 | ## [1.8.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.8.0...v1.8.1) (2021-11-11) 304 | 305 | 306 | ### Bug Fixes 307 | 308 | * update tab icons ([0de928d](https://github.com/pengx17/logseq-plugin-tabs/commit/0de928dd2afff60301ea5c557b89d27627ae36fa)) 309 | 310 | # [1.8.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.7.5...v1.8.0) (2021-11-08) 311 | 312 | 313 | ### Features 314 | 315 | * adapt page level properties as the prefix ([2f7aca9](https://github.com/pengx17/logseq-plugin-tabs/commit/2f7aca9fc1d60d7882d2fc71ef5e55a94313eb7c)) 316 | 317 | ## [1.7.5](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.7.4...v1.7.5) (2021-10-12) 318 | 319 | 320 | ### Bug Fixes 321 | 322 | * tabs width when pdf is on ([6df1e41](https://github.com/pengx17/logseq-plugin-tabs/commit/6df1e41873cc2e3c31bf459c2ddf23554d463565)) 323 | 324 | ## [1.7.4](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.7.3...v1.7.4) (2021-10-12) 325 | 326 | 327 | ### Bug Fixes 328 | 329 | * remove log ([cd5afcd](https://github.com/pengx17/logseq-plugin-tabs/commit/cd5afcdb899c92bc1e1130758d4074781b6e7370)) 330 | 331 | ## [1.7.3](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.7.2...v1.7.3) (2021-10-12) 332 | 333 | 334 | ### Bug Fixes 335 | 336 | * adapt for logseq 0.4.3 ([abe7d95](https://github.com/pengx17/logseq-plugin-tabs/commit/abe7d9514f079efb63ae042120534b1ba44aa436)) 337 | 338 | ## [1.7.2](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.7.1...v1.7.2) (2021-10-08) 339 | 340 | 341 | ### Bug Fixes 342 | 343 | * tabs sometimes prevent user from using shortcuts & tab name rename issue ([57c86d7](https://github.com/pengx17/logseq-plugin-tabs/commit/57c86d765e11b3cea9f094d5363e8903df51b703)) 344 | 345 | ## [1.7.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.7.0...v1.7.1) (2021-09-26) 346 | 347 | 348 | ### Bug Fixes 349 | 350 | * build issue ([8d9c1bf](https://github.com/pengx17/logseq-plugin-tabs/commit/8d9c1bfcf6d11091a9b57a5f69873752b20ea682)) 351 | 352 | # [1.7.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.6.0...v1.7.0) (2021-09-26) 353 | 354 | 355 | ### Features 356 | 357 | * allow use shift+cmd+click to open and visit new tab ([7ea20ad](https://github.com/pengx17/logseq-plugin-tabs/commit/7ea20ad1e24a03549ec7d59974295d4edacf2ad6)) 358 | 359 | # [1.6.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.5.4...v1.6.0) (2021-09-07) 360 | 361 | 362 | ### Features 363 | 364 | * enable multi-graph support. ([d1b2224](https://github.com/pengx17/logseq-plugin-tabs/commit/d1b222466c8f6281194bfcff21356639256ac2a9)) 365 | 366 | ## [1.5.4](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.5.3...v1.5.4) (2021-09-06) 367 | 368 | 369 | ### Bug Fixes 370 | 371 | * optimize animation ([6ba07f2](https://github.com/pengx17/logseq-plugin-tabs/commit/6ba07f2a7c6b799b5daf6c30ce6ce02cad76b028)) 372 | 373 | ## [1.5.3](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.5.2...v1.5.3) (2021-09-02) 374 | 375 | 376 | ### Bug Fixes 377 | 378 | * some style enhancements ([277ee7c](https://github.com/pengx17/logseq-plugin-tabs/commit/277ee7c3c7ff56251240eaffcc82de0fec52971f)) 379 | 380 | ## [1.5.2](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.5.1...v1.5.2) (2021-09-02) 381 | 382 | 383 | ### Bug Fixes 384 | 385 | * a issue when targeting block/page is not yet created ([5dae24e](https://github.com/pengx17/logseq-plugin-tabs/commit/5dae24e3a134660126712cd69f4d6b1e94e116f2)) 386 | 387 | ## [1.5.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.5.0...v1.5.1) (2021-09-02) 388 | 389 | 390 | ### Bug Fixes 391 | 392 | * build ([7b57803](https://github.com/pengx17/logseq-plugin-tabs/commit/7b57803817af2f3a64477b6e496d88fc3df90d80)) 393 | 394 | # [1.5.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.4.3...v1.5.0) (2021-08-31) 395 | 396 | 397 | ### Features 398 | 399 | * support opening block refs ([ccf3c7e](https://github.com/pengx17/logseq-plugin-tabs/commit/ccf3c7e5295aee97f9e3706e629ffb50d82f5bd6)) 400 | 401 | ## [1.4.3](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.4.2...v1.4.3) (2021-08-31) 402 | 403 | 404 | ### Bug Fixes 405 | 406 | * some npe issues ([d0cdfa5](https://github.com/pengx17/logseq-plugin-tabs/commit/d0cdfa539c39063e0707b470f8facfc7fa07bda7)) 407 | 408 | ## [1.4.2](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.4.1...v1.4.2) (2021-08-31) 409 | 410 | 411 | ### Bug Fixes 412 | 413 | * tabs position when there is pdf opening ([147bea5](https://github.com/pengx17/logseq-plugin-tabs/commit/147bea52b0e32bf1fcbc61d63e16aeeaacc610bb)) 414 | 415 | ## [1.4.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.4.0...v1.4.1) (2021-08-31) 416 | 417 | 418 | ### Bug Fixes 419 | 420 | * sorted not working after using immer ([2cb184e](https://github.com/pengx17/logseq-plugin-tabs/commit/2cb184eb65c1b96500679495a3165f6630fd0672)) 421 | 422 | # [1.4.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.3.8...v1.4.0) (2021-08-31) 423 | 424 | 425 | ### Features 426 | 427 | * restore tab scroll position ([fe5008c](https://github.com/pengx17/logseq-plugin-tabs/commit/fe5008c23e9d82d037772ff029a998c882f6ad98)) 428 | 429 | ## [1.3.8](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.3.7...v1.3.8) (2021-08-31) 430 | 431 | 432 | ### Bug Fixes 433 | 434 | * fix polling issue ([191573c](https://github.com/pengx17/logseq-plugin-tabs/commit/191573ca680a3c34710bd11c2db4e3ca85c14e55)) 435 | 436 | ## [1.3.7](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.3.6...v1.3.7) (2021-08-31) 437 | 438 | 439 | ### Bug Fixes 440 | 441 | * makes tabs more responsiveness ([18f269f](https://github.com/pengx17/logseq-plugin-tabs/commit/18f269f6ded39a747e2c968dcac4c5d497d86c83)) 442 | 443 | ## [1.3.6](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.3.5...v1.3.6) (2021-08-30) 444 | 445 | 446 | ### Bug Fixes 447 | 448 | * do not use deprecated navigator.platform field ([0670d3e](https://github.com/pengx17/logseq-plugin-tabs/commit/0670d3e5d34115758e6ee5f903c0f9d270647d43)) 449 | 450 | ## [1.3.5](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.3.4...v1.3.5) (2021-08-30) 451 | 452 | 453 | ### Bug Fixes 454 | 455 | * show tabs more aggresively ([5f1392e](https://github.com/pengx17/logseq-plugin-tabs/commit/5f1392ed7bca336f3ad96e8fb88cffcb18fd6717)) 456 | 457 | ## [1.3.4](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.3.3...v1.3.4) (2021-08-30) 458 | 459 | 460 | ### Bug Fixes 461 | 462 | * sorting & animation ([381b2eb](https://github.com/pengx17/logseq-plugin-tabs/commit/381b2ebdbdc2722b6168d630987bcaaf4c474462)) 463 | 464 | ## [1.3.3](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.3.2...v1.3.3) (2021-08-30) 465 | 466 | 467 | ### Bug Fixes 468 | 469 | * tabs background may cover other elements ([16a0ac4](https://github.com/pengx17/logseq-plugin-tabs/commit/16a0ac40aaf537d11f3a9026b974d6a328503a3f)) 470 | 471 | ## [1.3.2](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.3.1...v1.3.2) (2021-08-30) 472 | 473 | 474 | ### Bug Fixes 475 | 476 | * animation fix ([7e4957f](https://github.com/pengx17/logseq-plugin-tabs/commit/7e4957f15e0dbab6f56c9013b51db35ab785962f)) 477 | * optimize show/hide logic ([2e9a60d](https://github.com/pengx17/logseq-plugin-tabs/commit/2e9a60d5021a6e3296700218dfac232caea4834d)) 478 | 479 | ## [1.3.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.3.0...v1.3.1) (2021-08-29) 480 | 481 | ### Bug Fixes 482 | 483 | - show view if there is a single pinned tab ([1b476d6](https://github.com/pengx17/logseq-plugin-tabs/commit/1b476d60b3902f2f2d790262dc0eb802d121c1f1)) 484 | 485 | # [1.3.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.2.1...v1.3.0) (2021-08-29) 486 | 487 | ### Bug Fixes 488 | 489 | - do not hide label when pinned ([47013b4](https://github.com/pengx17/logseq-plugin-tabs/commit/47013b479284e18e3003662ccf9d6eda518ec0a9)) 490 | 491 | ### Features 492 | 493 | - allow drag & drop tabs ([94587b7](https://github.com/pengx17/logseq-plugin-tabs/commit/94587b73c36f4930af94dd8176e5bf16c4a47821)) 494 | 495 | ## [1.2.1](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.2.0...v1.2.1) (2021-08-29) 496 | 497 | ### Bug Fixes 498 | 499 | - ux fix ([ae63d8f](https://github.com/pengx17/logseq-plugin-tabs/commit/ae63d8fe032e7b342f1b6a4e7c27918afbd7132d)) 500 | 501 | # [1.2.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.1.0...v1.2.0) (2021-08-28) 502 | 503 | ### Features 504 | 505 | - align browser behaviors for ux ([ef31004](https://github.com/pengx17/logseq-plugin-tabs/commit/ef3100430d32c1cd6a847f57d78828b079709cf3)) 506 | 507 | # [1.1.0](https://github.com/pengx17/logseq-plugin-tabs/compare/v1.0.0...v1.1.0) (2021-08-28) 508 | 509 | ### Features 510 | 511 | - finish for new ux ([865105a](https://github.com/pengx17/logseq-plugin-tabs/commit/865105ad315a014c19c35f17735a0340d53fbf43)) 512 | 513 | # 1.0.0 (2021-08-27) 514 | 515 | ### Bug Fixes 516 | 517 | - deps ([659bbe4](https://github.com/pengx17/logseq-plugin-tabs/commit/659bbe40ebe05b5a9765f8b2e16f7429128078f6)) 518 | - release ([936092f](https://github.com/pengx17/logseq-plugin-tabs/commit/936092f34dcdf24af4c70ab18215f7c234f19857)) 519 | 520 | ### Features 521 | 522 | - adapt for theme ([d7d9999](https://github.com/pengx17/logseq-plugin-tabs/commit/d7d9999743b0fd8363ab2ce0247497c60780a61a)) 523 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Peng Xiao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pengx17/logseq-plugin-tabs/430ec3da743cd838896a291670500602ad95b679/demo.gif -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Logseq Plugin 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /keybinding-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pengx17/logseq-plugin-tabs/430ec3da743cd838896a291670500602ad95b679/keybinding-settings.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logseq-plugin-tabs", 3 | "version": "1.19.4", 4 | "schemaVersion": "1.0.0", 5 | "main": "dist/index.html", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preinstall": "npx only-allow pnpm" 10 | }, 11 | "license": "MIT", 12 | "dependencies": { 13 | "@logseq/libs": "^0.0.15", 14 | "fast-deep-equal": "^3.1.3", 15 | "immer": "^9.0.16", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "react-use": "^17.4.0" 19 | }, 20 | "devDependencies": { 21 | "@semantic-release/changelog": "6.0.1", 22 | "@semantic-release/exec": "^6.0.3", 23 | "@semantic-release/git": "10.0.1", 24 | "@semantic-release/npm": "9.0.1", 25 | "@types/react": "18.0.24", 26 | "@types/react-dom": "18.0.8", 27 | "@typescript-eslint/eslint-plugin": "^5.42.0", 28 | "@typescript-eslint/parser": "^5.42.0", 29 | "@vitejs/plugin-react": "^2.2.0", 30 | "conventional-changelog-conventionalcommits": "5.0.0", 31 | "eslint": "^8.26.0", 32 | "eslint-plugin-react": "^7.31.10", 33 | "eslint-plugin-react-hooks": "^4.6.0", 34 | "semantic-release": "^19.0.5", 35 | "typescript": "4.8.4", 36 | "vite": "3.2.2", 37 | "vite-plugin-logseq": "^1.1.2", 38 | "vite-plugin-windicss": "1.8.8", 39 | "windicss": "3.5.6" 40 | }, 41 | "logseq": { 42 | "id": "_pengx17-logseq-tabs" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Logseq Plugin Tabs 2 | 3 | ### 🔔 Looking for maintainers! 🔔 4 | 5 | [![Github All Releases](https://img.shields.io/github/downloads/pengx17/logseq-plugin-tabs/total.svg)](https://github.com/pengx17/logseq-plugin-tabs/releases) 6 | 7 | A plugin that let's you to manage your working pages with tabs. 8 | 9 | UX is mainly brought from modern browsers: 10 | 11 | - normally, if a new page is visited, the current tab will be replaced by the new page 12 | - if you click a page link or a block ref while holding CTRL (or CMD on Mac) key, a new tab will be created, but it is not visited yet 13 | - you can click the remove icon or middle click a tab to close tabs 14 | - you can double-click a tab to pin it. A pinned tab will not be replaced or be removed. 15 | - you can drag & drop to reorder tabs 16 | - tabs info will be persisted in your local storage, so that your tabs will recover even if you re-open the app 17 | 18 | ![](./demo.gif) 19 | 20 | ## Keyboard shortcuts 21 | 22 | - Pin/unpin a tab: CTRL + P (macOS: CMD + P) 23 | - Close a tab: SHIFT + CTRL + W (macOS: SHIFT + CMD + W) 24 | - Change to next tab: CTRL + TAB 25 | - Change to nth tab: CTRL + 1 ~ 9 (this is not configurable yet) 26 | 27 | Hint: you can change them in the Settings. After change, you need to restart the app. 28 | 29 | ![](./keybinding-settings.png) 30 | 31 | ## Contributing 32 | 33 | - Please follow [Logseq's guidelines](https://github.com/logseq/logseq/blob/master/CONTRIBUTING.md) for contributions and Pull Requests. 34 | - See the [logseq-plugin-template-react readme](https://github.com/pengx17/logseq-plugin-template-react?tab=readme-ov-file#how-to-get-started) for steps on how to build and test this plugin. 35 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ["master"], 3 | plugins: [ 4 | [ 5 | "@semantic-release/commit-analyzer", 6 | { 7 | preset: "conventionalcommits", 8 | }, 9 | ], 10 | "@semantic-release/release-notes-generator", 11 | "@semantic-release/changelog", 12 | [ 13 | "@semantic-release/npm", 14 | { 15 | npmPublish: false, 16 | }, 17 | ], 18 | "@semantic-release/git", 19 | [ 20 | "@semantic-release/exec", 21 | { 22 | prepareCmd: 23 | "zip -qq -r logseq-plugin-tabs-${nextRelease.version}.zip dist readme.md LICENSE package.json", 24 | }, 25 | ], 26 | [ 27 | "@semantic-release/github", 28 | { 29 | assets: "logseq-plugin-tabs-*.zip", 30 | }, 31 | ], 32 | ], 33 | }; 34 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "packageRules": [ 4 | { 5 | "matchUpdateTypes": ["minor", "patch"], 6 | "automerge": true, 7 | "requiredStatusChecks": null 8 | }, 9 | { 10 | "matchPackageNames": ["@logseq/libs"], 11 | "ignoreUnstable": false, 12 | "automerge": true, 13 | "requiredStatusChecks": null 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { PageTabs } from "./PageTabs"; 3 | import { usePreventFocus, useThemeMode } from "./utils"; 4 | 5 | function App(): JSX.Element { 6 | const themeMode = useThemeMode(); 7 | usePreventFocus(); 8 | return ( 9 |
16 | 17 |
18 | ); 19 | } 20 | 21 | export default App; 22 | -------------------------------------------------------------------------------- /src/PageTabs.css: -------------------------------------------------------------------------------- 1 | .logseq-tab { 2 | @apply cursor-pointer font-sans select-none text-xs h-6 transition-all duration-100 3 | flex items-center rounded mx-0.5 border border-1 light:border-gray-200 dark:border-gray-900 4 | px-2 light:text-black dark:text-white; 5 | } 6 | 7 | .logseq-tab[data-active="false"] { 8 | @apply light:(bg-cool-gray-100 text-gray-400 hover:text-black) 9 | dark:(bg-cool-gray-800 text-gray-400 hover:text-white); 10 | } 11 | 12 | .logseq-tab[data-active="true"] { 13 | @apply light:bg-cool-gray-300 dark:bg-cool-gray-900; 14 | } 15 | 16 | .logseq-tab[data-dragging="true"] { 17 | @apply ring-1 ring-red-500 mx-6; 18 | } 19 | 20 | .logseq-tab-title { 21 | @apply overflow-ellipsis max-w-80 px-0.5 overflow-hidden whitespace-nowrap inline transition-all delay-75 duration-100 ease-in-out; 22 | } 23 | 24 | .logseq-tab[data-active="false"] .logseq-tab-title { 25 | @apply max-w-40; 26 | } 27 | 28 | .logseq-tab[data-active="true"] .logseq-tab-title { 29 | @apply max-w-80; 30 | transition-property: none; 31 | } 32 | 33 | .logseq-tab[data-active="false"] button:hover { 34 | visibility: hidden; 35 | } 36 | 37 | .logseq-tab[data-active="false"]:hover button { 38 | visibility: visible; 39 | } 40 | 41 | [data-dragging="false"] 42 | .logseq-tab[data-active="false"][data-active="false"]:hover 43 | .logseq-tab-title { 44 | @apply max-w-80; 45 | transition-delay: 1s; 46 | transition-property: max-width; 47 | } 48 | 49 | .logseq-tab .close-button { 50 | @apply text-10px p-1 opacity-60 hover:opacity-100 ml-1 rounded; 51 | } 52 | 53 | .logseq-tab button:hover { 54 | @apply light:(bg-cool-gray-400) 55 | dark:(bg-cool-gray-600); 56 | } 57 | 58 | .close-all { 59 | opacity: 0; 60 | } 61 | 62 | .logseq-tab-wrapper:hover .close-all { 63 | opacity: 1; 64 | } -------------------------------------------------------------------------------- /src/PageTabs.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | BlockEntity, 3 | SimpleCommandKeybinding, 4 | } from "@logseq/libs/dist/LSPlugin"; 5 | import produce from "immer"; 6 | import React from "react"; 7 | import { useDeepCompareEffect, useLatest } from "react-use"; 8 | import "./PageTabs.css"; 9 | import { keyBindings } from "./settings"; 10 | import { ITabInfo } from "./types"; 11 | import { 12 | delay, 13 | getSourcePage, 14 | isBlock, 15 | isMac, 16 | mainContainerScroll, 17 | useAdaptMainUIStyle, 18 | useDebounceFn, 19 | useEventCallback, 20 | useScrollWidth, 21 | useStoreTabs, 22 | } from "./utils"; 23 | 24 | const CloseSVG = () => ( 25 | 31 | 32 | 33 | 34 | 35 | ); 36 | 37 | function isTabEqual( 38 | tab: ITabInfo | null | undefined, 39 | anotherTab: ITabInfo | null | undefined 40 | ) { 41 | function isEqual(a?: string, b?: string) { 42 | return a != null && b != null && a.toLowerCase() === b.toLowerCase(); 43 | } 44 | if (tab?.name == getJournalsString() && !anotherTab?.uuid) { // not possible to check 'anotherTab.name == undefined' for Journal, because a tab for a block also has no name 45 | return true; 46 | } 47 | 48 | if (tab?.page || anotherTab?.page) { 49 | return isEqual(tab?.uuid, anotherTab?.uuid); 50 | } 51 | return Boolean( 52 | isEqual(tab?.originalName, anotherTab?.originalName) || 53 | isEqual(tab?.name, anotherTab?.name) || 54 | // isEqual(tab?.uuid, anotherTab?.uuid) || 55 | // @ts-expect-error 56 | tab?.alias?.includes(anotherTab?.id) 57 | ); 58 | } 59 | 60 | interface TabsProps { 61 | tabs: ITabInfo[]; 62 | closeButtonLeft: boolean; 63 | hideCloseAllButton: boolean; 64 | showSingleTab: boolean; 65 | activeTab: ITabInfo | null | undefined; 66 | onClickTab: (tab: ITabInfo, isShiftKeyPressed: boolean) => void; 67 | onCloseTab: (tab: ITabInfo, force?: boolean) => void; 68 | onCloseAllTabs: (excludeActive: boolean) => void; 69 | onPinTab: (tab: ITabInfo) => void; 70 | onSwapTab: (tab: ITabInfo, anotherTab: ITabInfo) => void; 71 | } 72 | 73 | const Tabs = React.forwardRef( 74 | ( 75 | { 76 | activeTab, 77 | onClickTab, 78 | tabs, 79 | closeButtonLeft, 80 | hideCloseAllButton, 81 | showSingleTab, 82 | onCloseTab, 83 | onCloseAllTabs, 84 | onPinTab, 85 | onSwapTab, 86 | }, 87 | ref 88 | ) => { 89 | const [draggingTab, setDraggingTab] = React.useState(); 90 | 91 | React.useEffect(() => { 92 | const dragEndListener = () => { 93 | setDraggingTab(undefined); 94 | }; 95 | document.addEventListener("dragend", dragEndListener); 96 | return () => { 97 | document.removeEventListener("dragend", dragEndListener); 98 | }; 99 | }, []); 100 | 101 | // Clean out link markup and internal block metadata from text shown in tab titles. 102 | const cleanBlockTitle = (text: string): string => ( 103 | text 104 | .replace(/\[([^\]]+)\]\([^\)]+\)/, "$1") 105 | .replace(/\s+(id|background-color|collapsed):: .+/g, "") 106 | ) 107 | 108 | const debouncedSwap = useDebounceFn(onSwapTab, 0); 109 | const showTabs = 110 | showSingleTab || 111 | 0 < tabs.filter((tab) => tab.pinned).length || 112 | 1 < tabs.length; 113 | 114 | if (!showTabs) { 115 | return null; 116 | } 117 | return ( 118 |
{ 126 | if (e.button === 1) e.preventDefault(); 127 | }} 128 | > 129 | {tabs.map((tab) => { 130 | const isActive = isTabEqual(tab, activeTab); 131 | const onClose: React.MouseEventHandler = (e) => { 132 | e.stopPropagation(); 133 | onCloseTab(tab); 134 | }; 135 | const onDragOver: React.DragEventHandler = (e) => { 136 | if (draggingTab) { 137 | // Prevent drag fly back animation 138 | e.preventDefault(); 139 | e.dataTransfer.dropEffect = "move"; 140 | debouncedSwap(tab, draggingTab); 141 | } 142 | }; 143 | const onDragStart: React.DragEventHandler = (e) => { 144 | e.dataTransfer.effectAllowed = "move"; 145 | setDraggingTab(tab); 146 | e.stopPropagation(); 147 | }; 148 | const prefix = tab.properties?.icon 149 | ? tab.properties?.icon 150 | : isBlock(tab) 151 | ? "B" 152 | : tab.uuid == undefined // is Journal 153 | ? "J" 154 | : "P"; 155 | return ( 156 |
onClickTab(tab, e.shiftKey)} 158 | onDoubleClick={() => onPinTab(tab)} 159 | onContextMenu={(e) => { 160 | e.preventDefault(); 161 | console.log(e); 162 | // onAuxClick={/*onClose*/} 163 | // TODO: show the same context menu like right-clicking the title? 164 | console.log("Not implemented yet"); 165 | }} 166 | key={[tab.originalName, tab.uuid].join("-")} 167 | data-active={isActive} 168 | data-pinned={tab.pinned} 169 | data-dragging={draggingTab === tab} 170 | draggable={true} 171 | onDragOver={onDragOver} 172 | onDragStart={onDragStart} 173 | className="logseq-tab group" 174 | > 175 |
176 | {prefix} 177 |
178 | {closeButtonLeft && tab.pinned ? ( 179 | 📌 180 | ) : ( 181 | closeButtonLeft && ( 182 | 185 | ) 186 | )} 187 | 188 | {tab.originalName ?? tab.name}{" "} 189 | {isBlock(tab) && ( 190 | 191 | 192 | {cleanBlockTitle(tab.content)} 193 | 194 | )} 195 | 196 | {!closeButtonLeft && tab.pinned ? ( 197 | 📌 198 | ) : ( 199 | !closeButtonLeft && ( 200 | 203 | ) 204 | )} 205 |
206 | ); 207 | })} 208 | {!hideCloseAllButton && ( 209 |
onCloseAllTabs(true)} 211 | key={"Close All"} 212 | draggable={false} 213 | className="logseq-tab close-all group" 214 | > 215 | Close All 216 |
217 | )} 218 |
219 | ); 220 | } 221 | ); 222 | 223 | function getPageRef(element: HTMLElement) { 224 | const el = element as HTMLAnchorElement; 225 | return ( 226 | getBlockContentPageRef(el) ?? 227 | getSidebarPageRef(el) ?? 228 | getReferencesPageRef(el) ?? 229 | getSearchMenuPageRef(el) ?? 230 | getFavoritesOrRecentPageRef(el) 231 | ); 232 | } 233 | 234 | function getBlockContentPageRef(element: HTMLElement) { 235 | const el = element as HTMLAnchorElement; 236 | if ( 237 | el.tagName === "A" && 238 | el.hasAttribute("data-ref") && 239 | (el.className.includes("page-ref") || el.className.includes("tag")) 240 | ) { 241 | return element.getAttribute("data-ref"); 242 | } 243 | } 244 | 245 | function getSidebarPageRef(element: HTMLElement) { 246 | const el = element as HTMLAnchorElement; 247 | if (el.tagName === "A" && el.querySelector(".page-icon")) { 248 | return Array.from(element.childNodes) 249 | .find((n) => n.nodeName === "#text") 250 | ?.textContent?.trim(); 251 | } 252 | } 253 | 254 | function getReferencesPageRef(element: HTMLElement) { 255 | const el = element as HTMLAnchorElement; 256 | // if it is a page ref link in the references section 257 | if ( 258 | el.tagName === "A" && 259 | el.closest(".references") && 260 | el.getAttribute("href")?.startsWith("#/page/") 261 | ) { 262 | return Array.from(element.childNodes) 263 | .find((n) => n.nodeName === "#text") 264 | ?.textContent?.trim(); 265 | } 266 | } 267 | 268 | function getFavoritesOrRecentPageRef(element: HTMLElement) { 269 | const el = element as HTMLAnchorElement; 270 | if (el.tagName === "SPAN" && el.classList.contains("page-title")) { 271 | const parentListItem = el.closest("li"); 272 | 273 | if (parentListItem?.classList.contains("favorite-item") || parentListItem?.classList.contains("recent-item")) { 274 | return parentListItem.getAttribute("data-ref"); 275 | } 276 | } 277 | } 278 | 279 | function getClosestSearchMenuLink(element: HTMLElement): HTMLElement | null { 280 | return element.closest(".search-results-wrap .menu-link"); 281 | } 282 | 283 | function getSearchMenuPageRef(element: HTMLElement) { 284 | const refEl = getClosestSearchMenuLink(element); 285 | return refEl?.querySelector("[data-page-ref]")?.dataset.pageRef; 286 | } 287 | 288 | function getSearchMenuBlockRef(element: HTMLElement) { 289 | const refEl = getClosestSearchMenuLink(element); 290 | return refEl?.querySelector("[data-block-ref]")?.dataset 291 | .blockRef; 292 | } 293 | 294 | function getBlockUUID(element: HTMLElement) { 295 | return ( 296 | element.getAttribute("blockid") ?? 297 | element.querySelector("[blockid]")?.getAttribute("blockid") ?? 298 | getSearchMenuBlockRef(element) 299 | ); 300 | } 301 | 302 | function getJournalsString(): string { 303 | let storedJournalsString = localStorage.getItem("journalsString"); 304 | 305 | if (!storedJournalsString) { 306 | let readJournalsString = top?.document.querySelector(".journals-nav")?.firstChild?.children[1]?.textContent; 307 | 308 | // Be careful if we read "gj": it's likely because we selected the wrong element. 309 | if (readJournalsString && readJournalsString == "gj") { 310 | readJournalsString = top?.document.querySelector(".journals-nav")?.firstChild?.lastChild?.textContent; 311 | } 312 | 313 | if (readJournalsString) { 314 | storedJournalsString = readJournalsString; 315 | } else { 316 | storedJournalsString = "Journals" // fallback 317 | } 318 | 319 | localStorage.setItem("journalsString", storedJournalsString); 320 | } 321 | 322 | return storedJournalsString; 323 | } 324 | 325 | function getIsJournalLink(element: HTMLElement) { 326 | return null !== element.closest(".journals-nav"); 327 | } 328 | 329 | function stop(e: Event) { 330 | e.stopPropagation(); 331 | e.stopImmediatePropagation(); 332 | e.preventDefault(); // prevent scrolling from middle mouse button click 333 | } 334 | 335 | /** 336 | * Captures user CTRL Click a page link. 337 | */ 338 | function useCaptureAddTabAction(cb: (e: ITabInfo, open: boolean) => void) { 339 | const handleAddTab = React.useCallback( 340 | async (e: Event, target: HTMLElement) => { 341 | let newTab: ITabInfo | null = null; 342 | if (getPageRef(target)) { 343 | stop(e); 344 | const p = await getSourcePage(getPageRef(target)); 345 | if (p) { 346 | newTab = p; 347 | } 348 | } else if (getBlockUUID(target)) { 349 | stop(e); 350 | const blockId = getBlockUUID(target); 351 | if (blockId) { 352 | const block = await logseq.Editor.getBlock(blockId); 353 | if (block) { 354 | const page = await logseq.Editor.getPage(block?.page.id); 355 | // also, write block id to the block properties if it is missing there ... 356 | setTimeout(async () => { 357 | if (!(await logseq.Editor.getBlockProperty(blockId, "id"))) { 358 | logseq.Editor.upsertBlockProperty(blockId, "id", blockId); 359 | } 360 | }, 100); 361 | if (page) { 362 | newTab = { ...page, ...block }; 363 | } 364 | } 365 | } 366 | } else if (getIsJournalLink(target)) { 367 | newTab = { name: getJournalsString(), uuid: undefined } 368 | } 369 | 370 | if (newTab) { 371 | cb(newTab, false); 372 | } 373 | }, 374 | [cb] 375 | ); 376 | 377 | React.useEffect(() => { 378 | const listener = async (e: MouseEvent) => { 379 | const target = e.composedPath()[0] as HTMLElement; 380 | // If CtrlKey is pressed or Middle Mouse Button is clicked, always open a new tab 381 | const ctrlKey = isMac() ? e.metaKey : e.ctrlKey; 382 | const middleMouseClick = e.button == 1; 383 | 384 | if (ctrlKey || middleMouseClick) { 385 | handleAddTab(e, target); 386 | } 387 | }; 388 | top?.document.addEventListener("mousedown", listener, true); 389 | return () => { 390 | top?.document.removeEventListener("mousedown", listener, true); 391 | }; 392 | }, [handleAddTab]); 393 | 394 | // Capture MOD+ENTER on search results 395 | React.useEffect(() => { 396 | const listener = async (e: KeyboardEvent) => { 397 | const ctrlKey = isMac() ? e.metaKey : e.ctrlKey; 398 | if (e.key === "Enter" && ctrlKey) { 399 | // Find out chosen search item 400 | const chosenMenuItem = top?.document.querySelector( 401 | ".search-results-wrap .menu-link.chosen" 402 | ); 403 | if (chosenMenuItem) { 404 | handleAddTab(e, chosenMenuItem); 405 | } 406 | } 407 | }; 408 | top?.document.addEventListener("keydown", listener, true); 409 | return () => { 410 | top?.document.removeEventListener("keydown", listener, true); 411 | }; 412 | }, [handleAddTab]); 413 | } 414 | 415 | /** 416 | * the active page is the page that is currently being viewed 417 | */ 418 | export function useActiveTab(tabs: ITabInfo[]) { 419 | const [page, setPage] = React.useState(null); 420 | const pageRef = React.useRef(page); 421 | const setActivePage = useEventCallback(async () => { 422 | const p = await logseq.Editor.getCurrentPage(); 423 | let tab: ITabInfo | null = null; 424 | tab = tabs.find((t) => isTabEqual(t, p)) ?? null; 425 | if (!tab) { 426 | if (p) { 427 | tab = await logseq.Editor.getPage( 428 | p.name ?? (p as BlockEntity)?.page.id 429 | ); 430 | } else { 431 | tab = { name: getJournalsString(), uuid: undefined } 432 | } 433 | } 434 | 435 | tab = { ...tab, ...p }; 436 | if (tab.scrollTop) { 437 | setTimeout(() => { mainContainerScroll({ top: tab.scrollTop }); }, 250); 438 | } 439 | pageRef.current = tab; 440 | setPage(tab); 441 | }); 442 | React.useEffect(() => { 443 | return logseq.App.onRouteChanged(setActivePage); 444 | }, [setActivePage]); 445 | React.useEffect(() => { 446 | let stopped = false; 447 | async function poll() { 448 | await delay(1500); 449 | if (!pageRef.current && !stopped) { 450 | await setActivePage(); 451 | await poll(); 452 | } 453 | } 454 | poll(); 455 | return () => { 456 | stopped = true; 457 | }; 458 | }, [setActivePage]); 459 | 460 | return [page, setPage] as const; 461 | } 462 | 463 | const sortTabs = (tabs: ITabInfo[]) => { 464 | tabs.sort((a, b) => { 465 | if (a.pinned && !b.pinned) { 466 | return -1; 467 | } else if (!a.pinned && b.pinned) { 468 | return 1; 469 | } else { 470 | return 0; 471 | } 472 | }); 473 | }; 474 | 475 | // Avoid register issues during dev 476 | const registeredKeybindings = new Set(); 477 | 478 | function registerKeybinding( 479 | setting: { 480 | key: string; 481 | label: string; 482 | keybinding?: SimpleCommandKeybinding | undefined; 483 | }, 484 | cb: () => void 485 | ) { 486 | if (registeredKeybindings.has(setting.key)) { 487 | return; 488 | } 489 | registeredKeybindings.add(setting.key); 490 | logseq.App.registerCommandPalette(setting, cb); 491 | } 492 | 493 | const useRegisterKeybindings = ( 494 | key: keyof typeof keyBindings, 495 | cb: () => void 496 | ) => { 497 | const cbRef = useEventCallback(cb); 498 | 499 | React.useEffect(() => { 500 | const userKeybinding: string = logseq.settings?.[key]; 501 | if (userKeybinding.trim() !== "") { 502 | const setting = { 503 | key, 504 | label: keyBindings[key].label, 505 | keybinding: { 506 | binding: logseq.settings?.[key], 507 | mode: "global", 508 | } as SimpleCommandKeybinding, 509 | }; 510 | registerKeybinding(setting, cbRef); 511 | } 512 | // eslint-disable-next-line react-hooks/exhaustive-deps 513 | }, []); 514 | }; 515 | 516 | const useRegisterSelectNthTabKeybindings = (cb: (nth: number) => void) => { 517 | const cbRef = useEventCallback(cb); 518 | 519 | React.useEffect(() => { 520 | for (let i = 1; i <= 9; i++) { 521 | const key = `tabs-select-nth-tab-${i}`; 522 | const setting = { 523 | key, 524 | label: `Select tab ${i}`, 525 | keybinding: { 526 | binding: `mod+${i}`, 527 | mode: "non-editing", 528 | } as SimpleCommandKeybinding, 529 | }; 530 | registerKeybinding(setting, () => { 531 | cbRef(i); 532 | }); 533 | } 534 | // eslint-disable-next-line react-hooks/exhaustive-deps 535 | }, []); 536 | }; 537 | 538 | const useRegisterCloseAllButPins = (cb: (b: boolean) => void) => { 539 | const cbRef = useEventCallback(cb); 540 | 541 | React.useEffect(() => { 542 | registerKeybinding( 543 | { 544 | key: `tabs-close-all`, 545 | label: `Close all tabs`, 546 | // no keybindings yet 547 | }, 548 | () => { 549 | cbRef(false); 550 | } 551 | ); 552 | // eslint-disable-next-line react-hooks/exhaustive-deps 553 | }, []); 554 | 555 | React.useEffect(() => { 556 | registerKeybinding( 557 | { 558 | key: `tabs-close-others`, 559 | label: `Close other tabs`, 560 | // no keybindings yet 561 | }, 562 | () => { 563 | cbRef(true); 564 | } 565 | ); 566 | // eslint-disable-next-line react-hooks/exhaustive-deps 567 | }, []); 568 | }; 569 | 570 | export function PageTabs(): JSX.Element { 571 | const [tabs, setTabs] = useStoreTabs(); 572 | const [activeTab, setActiveTab] = useActiveTab(tabs); 573 | 574 | const currActiveTabRef = React.useRef(); 575 | const latestTabsRef = useLatest(tabs); 576 | const showSingleTab = !!logseq.settings?.["tabs:show-single-tab"]; 577 | const closeButtonLeft = !!logseq.settings?.["tabs:close-button-left"]; 578 | const hideCloseAllButton = !!logseq.settings?.["tabs:hide-close-all-button"]; 579 | 580 | const onCloseTab = useEventCallback((tab: ITabInfo, force?: boolean) => { 581 | const idx = tabs.findIndex((t) => isTabEqual(t, tab)); 582 | 583 | // Do not close pinned 584 | if (tabs[idx]?.pinned && !force) { 585 | return; 586 | } 587 | const newTabs = [...tabs]; 588 | newTabs.splice(idx, 1); 589 | setTabs(newTabs); 590 | 591 | if (newTabs.length === 0) { 592 | logseq.App.pushState("home"); 593 | } else if (isTabEqual(tab, activeTab)) { 594 | const newTab = newTabs[Math.min(newTabs.length - 1, idx)]; 595 | setActiveTab(newTab); 596 | } 597 | }); 598 | 599 | const getCurrentActiveIndex = () => { 600 | return tabs.findIndex((ct) => isTabEqual(ct, currActiveTabRef.current)); 601 | }; 602 | 603 | const onCloseAllTabs = useEventCallback((excludeActive: boolean) => { 604 | const newTabs = tabs.filter( 605 | (t) => 606 | t.pinned || (excludeActive && isTabEqual(t, currActiveTabRef.current)) 607 | ); 608 | setTabs(newTabs); 609 | if (!excludeActive) { 610 | logseq.App.pushState("home"); 611 | } 612 | }); 613 | 614 | const onTabClick = useEventCallback(async (t: ITabInfo, isShiftKeyPressed: boolean) => { 615 | if (isBlock(t) && t.uuid) { 616 | const block = await logseq.Editor.getBlock(t.uuid); 617 | if (!block) { 618 | logseq.UI.showMsg( 619 | `The target block ${t.content} is not found!`, 620 | "error", 621 | { 622 | timeout: 1000, 623 | } 624 | ); 625 | // force close it if it's not found 626 | onCloseTab(t, true); 627 | return; 628 | } 629 | } 630 | 631 | if (isShiftKeyPressed) { 632 | // TODO shift click on Journals tab is not displayed in right side bar (working when shift clicked on Journals link in left sidebar) 633 | logseq.Editor.openInRightSidebar(t.uuid as string); 634 | } else { 635 | onChangeTab(t); 636 | } 637 | }); 638 | 639 | const onChangeTab = useEventCallback(async (t: ITabInfo) => { 640 | if (isBlock(t) && t.uuid) { 641 | const block = await logseq.Editor.getBlock(t.uuid); 642 | if (!block) { 643 | logseq.UI.showMsg( 644 | `The target block ${t.content} is not found!`, 645 | "error", 646 | { 647 | timeout: 1000, 648 | } 649 | ); 650 | // force close it if it's not found 651 | onCloseTab(t, true); 652 | return; 653 | } 654 | } 655 | 656 | if (t.name == getJournalsString() && !t.uuid) { 657 | logseq.App.pushState("all-journals"); 658 | } 659 | 660 | setActiveTab(t); 661 | const idx = getCurrentActiveIndex(); 662 | // remember current page's scroll position 663 | if (idx !== -1) { 664 | const scrollTop = 665 | top?.document.querySelector("#main-content-container")?.scrollTop; 666 | 667 | setTabs( 668 | produce(tabs, (draft) => { 669 | draft[idx].scrollTop = scrollTop; 670 | }) 671 | ); 672 | } 673 | }); 674 | 675 | const onNewTab = useEventCallback((t: ITabInfo | null, open = false) => { 676 | if (t) { 677 | const previous = tabs.find((_t) => isTabEqual(t, _t)); 678 | if (!previous) { 679 | setTabs([...tabs, t]); 680 | } else { 681 | open = true; 682 | } 683 | if (open) { 684 | onChangeTab({ ...t, pinned: previous?.pinned }); 685 | } 686 | } 687 | }); 688 | 689 | useCaptureAddTabAction(onNewTab); 690 | useDeepCompareEffect(() => { 691 | let timer = 0; 692 | let newTabs = latestTabsRef.current; 693 | const prevTab = currActiveTabRef.current; 694 | // If a new ActiveTab is set, we will need to replace or insert the tab 695 | if (activeTab) { 696 | newTabs = produce(tabs, (draft) => { 697 | if (tabs.every((t) => !isTabEqual(t, activeTab))) { 698 | const currentIndex = draft.findIndex((t) => isTabEqual(t, prevTab)); 699 | const currentPinned = draft[currentIndex]?.pinned; 700 | if (currentIndex === -1 || currentPinned) { 701 | draft.push(activeTab); 702 | } else { 703 | draft[currentIndex] = activeTab; 704 | } 705 | } else { 706 | // Update the data if it is already in the list (to update icons etc) 707 | const currentIndex = draft.findIndex((t) => isTabEqual(t, activeTab)); 708 | draft[currentIndex] = activeTab; 709 | } 710 | }); 711 | timer = setTimeout(async () => { 712 | const p = await logseq.Editor.getCurrentPage(); 713 | if (!isTabEqual(activeTab, p)) { 714 | logseq.App.pushState("page", { 715 | name: isBlock(activeTab) 716 | ? activeTab.uuid 717 | : activeTab.originalName ?? activeTab.name, 718 | }); 719 | } 720 | }, 200); 721 | } 722 | currActiveTabRef.current = activeTab; 723 | setTabs(newTabs); 724 | return () => { 725 | if (timer) { 726 | clearTimeout(timer); 727 | } 728 | }; 729 | }, [activeTab ?? {}]); 730 | 731 | const onPinTab = useEventCallback((t) => { 732 | setTabs( 733 | produce(tabs, (draft) => { 734 | const idx = draft.findIndex((ct) => isTabEqual(ct, t)); 735 | draft[idx].pinned = !draft[idx].pinned; 736 | sortTabs(draft); 737 | }) 738 | ); 739 | }); 740 | 741 | const onSwapTab = (t0: ITabInfo, t1: ITabInfo) => { 742 | setTabs( 743 | produce(tabs, (draft) => { 744 | const i0 = draft.findIndex((t) => isTabEqual(t, t0)); 745 | const i1 = draft.findIndex((t) => isTabEqual(t, t1)); 746 | draft[i0] = t1; 747 | draft[i1] = t0; 748 | sortTabs(draft); 749 | }) 750 | ); 751 | }; 752 | 753 | const ref = React.useRef(null); 754 | const scrollWidth = useScrollWidth(ref); 755 | 756 | useAdaptMainUIStyle(tabs.length > 0, scrollWidth); 757 | 758 | React.useEffect(() => { 759 | if (activeTab && ref) { 760 | setTimeout(() => { 761 | ref.current 762 | ?.querySelector(`[data-active="true"]`) 763 | ?.scrollIntoView({ behavior: "smooth" }); 764 | }, 100); 765 | } 766 | }, [activeTab, ref]); 767 | 768 | useRegisterKeybindings("tabs:toggle-pin", () => { 769 | if (currActiveTabRef.current) { 770 | onPinTab(currActiveTabRef.current); 771 | } 772 | }); 773 | 774 | useRegisterKeybindings("tabs:close", () => { 775 | if (currActiveTabRef.current) { 776 | onCloseTab(currActiveTabRef.current); 777 | } 778 | }); 779 | 780 | useRegisterKeybindings("tabs:select-next", () => { 781 | let idx = getCurrentActiveIndex() ?? -1; 782 | idx = (idx + 1) % tabs.length; 783 | onChangeTab(tabs[idx]); 784 | }); 785 | 786 | useRegisterKeybindings("tabs:select-prev", () => { 787 | let idx = getCurrentActiveIndex() ?? -1; 788 | idx = (idx - 1 + tabs.length) % tabs.length; 789 | onChangeTab(tabs[idx]); 790 | }); 791 | 792 | useRegisterSelectNthTabKeybindings((idx) => { 793 | if (idx > 0 && idx <= tabs.length) { 794 | onChangeTab(tabs[idx - 1]); 795 | } 796 | }); 797 | 798 | useRegisterCloseAllButPins(onCloseAllTabs); 799 | 800 | return ( 801 | 814 | ); 815 | } 816 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import "@logseq/libs"; 2 | import React from "react"; 3 | import * as ReactDOM from "react-dom/client"; 4 | import "virtual:windi.css"; 5 | import { settings } from "./settings"; 6 | 7 | import App from "./App"; 8 | import "./reset.css"; 9 | import { isMac } from "./utils"; 10 | 11 | function main() { 12 | const pluginId = logseq.baseInfo.id; 13 | console.info(`#${pluginId}: MAIN`); 14 | const mac = isMac(); 15 | logseq.provideStyle(` 16 | [data-active-keystroke=${mac ? "Meta" : "Control"} i] 17 | :is(.block-ref,.page-ref,a.tag) { 18 | cursor: n-resize 19 | } 20 | `); 21 | 22 | const root = ReactDOM.createRoot(document.getElementById("app")!); 23 | root.render( 24 | 25 | 26 | 27 | ); 28 | 29 | parent.document.body.classList.add('is-plugin-tabs-enabled'); 30 | logseq.beforeunload(async () => { 31 | parent.document.body.classList.remove('is-plugin-tabs-enabled'); 32 | }); 33 | 34 | console.info(`#${pluginId}: MAIN DONE`); 35 | } 36 | 37 | logseq.useSettingsSchema(settings).ready(main).catch(console.error); 38 | -------------------------------------------------------------------------------- /src/reset.css: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | display: none; 3 | } 4 | 5 | .drag-region { 6 | -webkit-app-region: drag; 7 | } -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { SettingSchemaDesc } from "@logseq/libs/dist/LSPlugin.user"; 2 | 3 | export const keyBindings = { 4 | "tabs:toggle-pin": { 5 | label: "Toggle Tab Pin Status", 6 | binding: "", 7 | }, 8 | "tabs:close": { 9 | label: "Close Tab", 10 | binding: "mod+shift+w", 11 | }, 12 | "tabs:select-next": { 13 | label: "Select Next Tab", 14 | binding: "ctrl+tab", 15 | }, 16 | "tabs:select-prev": { 17 | label: "Select Previous Tab", 18 | binding: "ctrl+shift+tab", 19 | } 20 | }; 21 | 22 | const keybindingSettings: SettingSchemaDesc[] = Object.entries(keyBindings).map( 23 | ([key, value]) => ({ 24 | key, 25 | title: value.label, 26 | type: "string", 27 | default: value.binding, 28 | description: 29 | "Keybinding: " + 30 | value.label + 31 | ". Default: `" + 32 | value.binding + 33 | "`. You need to restart the app for the changes to take effect.", 34 | }) 35 | ); 36 | 37 | export const inheritCustomCSSSetting: SettingSchemaDesc = { 38 | key: "tabs:inherit-custom-css", 39 | title: "Advanced: inherit custom.css styles", 40 | default: false, 41 | description: 42 | "When turning this on, this plugin will also applies styles in custom.css. You need to restart the app for the changes to take effect.", 43 | type: "boolean", 44 | }; 45 | 46 | export const showSingleTab: SettingSchemaDesc = { 47 | key: "tabs:show-single-tab", 48 | title: "Show single tab?", 49 | description: "When turned on the tab bar will only show if at least two tabs are open.", 50 | type: "boolean", 51 | default: true, 52 | } 53 | 54 | export const closeButtonLeft: SettingSchemaDesc = { 55 | key: "tabs:close-button-left", 56 | title: "Close tab button on left side?", 57 | description: "When turned on the close button will be on the left side of the tab.", 58 | type: "boolean", 59 | default: false, 60 | } 61 | 62 | export const hideCloseAllButton: SettingSchemaDesc = { 63 | key: "tabs:hide-close-all-button", 64 | title: "Hide 'Close All' button?", 65 | description: "When turned on 'Close All' button at the end of tabs list will be hidden.", 66 | type: "boolean", 67 | default: false, 68 | } 69 | 70 | export const settings: SettingSchemaDesc[] = [ 71 | ...keybindingSettings, 72 | inheritCustomCSSSetting, 73 | showSingleTab, 74 | closeButtonLeft, 75 | hideCloseAllButton 76 | ]; 77 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ITabInfo { 2 | // Main attributes from page/block 3 | uuid?: string; 4 | name?: string; 5 | originalName?: string; 6 | content?: string; 7 | page?: { 8 | id: number; 9 | }; 10 | properties?: { 11 | icon?: string; 12 | }; 13 | 14 | // UI States: 15 | pinned?: boolean; 16 | scrollTop?: number; 17 | } 18 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { PageEntity } from "@logseq/libs/dist/LSPlugin"; 3 | import React, { useMemo, useState } from "react"; 4 | import isEqual from "fast-deep-equal"; 5 | import { useHoverDirty, useMountedState } from "react-use"; 6 | import { schemaVersion } from "../package.json"; 7 | import { ITabInfo } from "./types"; 8 | import { inheritCustomCSSSetting } from "./settings"; 9 | 10 | export const useAppVisible = () => { 11 | const [visible, setVisible] = useState(logseq.isMainUIVisible); 12 | const isMounted = useMountedState(); 13 | React.useEffect(() => { 14 | const eventName = "ui:visible:changed"; 15 | const handler = async ({ visible }: { visible: boolean }) => { 16 | if (isMounted()) { 17 | setVisible(visible); 18 | } 19 | }; 20 | logseq.on(eventName, handler); 21 | return () => { 22 | logseq.off(eventName, handler); 23 | }; 24 | }, [isMounted]); 25 | return visible; 26 | }; 27 | 28 | export const useSidebarVisible = () => { 29 | const [visible, setVisible] = useState(false); 30 | const isMounted = useMountedState(); 31 | React.useEffect(() => { 32 | logseq.App.onSidebarVisibleChanged(({ visible }) => { 33 | if (isMounted()) { 34 | setVisible(visible); 35 | } 36 | }); 37 | }, [isMounted]); 38 | return visible; 39 | }; 40 | 41 | export const useThemeMode = () => { 42 | const isMounted = useMountedState(); 43 | const [mode, setMode] = React.useState<"dark" | "light">("light"); 44 | React.useEffect(() => { 45 | setMode( 46 | (top!.document 47 | .querySelector("html") 48 | ?.getAttribute("data-theme") as typeof mode) ?? 49 | (matchMedia("prefers-color-scheme: dark").matches ? "dark" : "light") 50 | ); 51 | return logseq.App.onThemeModeChanged((s) => { 52 | if (isMounted()) { 53 | setMode(s.mode); 54 | } 55 | }); 56 | }, [isMounted]); 57 | 58 | return mode; 59 | }; 60 | 61 | export async function getSourcePage( 62 | pageName?: string | null 63 | ): Promise { 64 | if (!pageName) { 65 | return null; 66 | } 67 | const page = await logseq.Editor.getPage(pageName); 68 | 69 | // If the page contains alias and it has no property alias ... 70 | // @ts-expect-error 71 | if (page && page.alias?.length > 0 && !(page.properties?.alias.length > 0)) { 72 | // @ts-expect-error 73 | const pageId = page.alias[0]?.id; 74 | if (pageId) { 75 | return await logseq.Editor.getPage(pageId); 76 | } 77 | } 78 | return page; 79 | } 80 | 81 | export const delay = (ms: number) => 82 | new Promise((resolve) => setTimeout(resolve, ms)); 83 | 84 | function getKeyId(graph: string) { 85 | return "logseq-plugin-tabs:" + schemaVersion + "/" + graph; 86 | } 87 | 88 | const readFromLocalStorage = (graph: string): ITabInfo[] | null => { 89 | const str = localStorage.getItem(getKeyId(graph)); 90 | if (str) { 91 | try { 92 | return JSON.parse(str); 93 | } catch { 94 | // no ops 95 | } 96 | } 97 | return null; 98 | }; 99 | 100 | const persistToLocalStorage = (tabs: ITabInfo[], graph: string) => { 101 | localStorage.setItem(getKeyId(graph), JSON.stringify(tabs)); 102 | }; 103 | 104 | function useCurrentGraph() { 105 | const [graph, setGraph] = useState(null); 106 | const reset = async () => { 107 | const g = await logseq.App.getCurrentGraph(); 108 | setGraph(g?.path ?? null); 109 | }; 110 | React.useEffect(() => { 111 | reset(); 112 | return logseq.App.onCurrentGraphChanged(() => { 113 | reset(); 114 | }); 115 | }, []); 116 | return graph; 117 | } 118 | 119 | export function useStoreTabs() { 120 | const [tabs, setTabs] = React.useState([]); 121 | const currentGraph = useCurrentGraph(); 122 | 123 | React.useEffect(() => { 124 | if (currentGraph) { 125 | const tabs = readFromLocalStorage(currentGraph); 126 | setTabs(tabs ?? []); 127 | } 128 | }, [currentGraph]); 129 | 130 | const userSetTabs = (newTabs: ITabInfo[]) => { 131 | if (currentGraph && !isEqual(tabs, newTabs)) { 132 | persistToLocalStorage(newTabs, currentGraph); 133 | return setTabs(newTabs); 134 | } 135 | }; 136 | 137 | return [tabs, userSetTabs] as const; 138 | } 139 | 140 | export function debounce any>(fn: T, ms: number) { 141 | let timeout: number | null = null; 142 | return (...args: any[]) => { 143 | if (timeout) { 144 | clearTimeout(timeout); 145 | } 146 | timeout = window.setTimeout(() => { 147 | fn(...args); 148 | timeout = null; 149 | }, ms); 150 | }; 151 | } 152 | 153 | export function useDebounceFn any>( 154 | callback: T, 155 | timeout = 300 156 | ) { 157 | const safeCallback = useEventCallback(callback); 158 | return useMemo( 159 | () => debounce(safeCallback, timeout), 160 | [safeCallback, timeout] 161 | ); 162 | } 163 | 164 | interface RouteState { 165 | template: string; 166 | path: string; 167 | } 168 | 169 | function useRouteState() { 170 | const [state, setState] = React.useState( 171 | // @ts-expect-error getStateFromStore sames not working properly 172 | top?.logseq.api.get_state_from_store("route-match") 173 | ); 174 | 175 | React.useEffect(() => { 176 | return logseq.App.onRouteChanged(setState); 177 | }, []); 178 | 179 | return state; 180 | } 181 | 182 | function useSettingValue(key: string) { 183 | const [value, setValue] = React.useState(logseq.settings?.[key]); 184 | React.useEffect(() => { 185 | return logseq.onSettingsChanged(() => { 186 | setValue(logseq.settings?.[key]); 187 | }); 188 | }); 189 | return value; 190 | } 191 | 192 | export function useCustomCSS() { 193 | const enabled = useSettingValue(inheritCustomCSSSetting.key); 194 | React.useEffect(() => { 195 | const rootHead = top?.document.head; 196 | if (rootHead && enabled) { 197 | const applyCustomCSS = () => { 198 | let customCSSLink = document.getElementById( 199 | "logseq-custom-theme-id" 200 | ) as HTMLLinkElement; 201 | if (!customCSSLink) { 202 | customCSSLink = document.createElement("link"); 203 | customCSSLink.id = "logseq-custom-theme-id"; 204 | customCSSLink.rel = "stylesheet"; 205 | customCSSLink.media = "all"; 206 | document.head.append(customCSSLink); 207 | } 208 | const content = top?.document.querySelector( 209 | "#logseq-custom-theme-id" 210 | )?.href; 211 | if (content) { 212 | customCSSLink.href = content; 213 | } 214 | }; 215 | 216 | const observer = new MutationObserver((mutations) => { 217 | for (const mutation of mutations) { 218 | for (const addedNode of mutation.addedNodes) { 219 | if ( 220 | addedNode.nodeName === "LINK" && 221 | (addedNode as HTMLLinkElement).id === "logseq-custom-theme-id" 222 | ) { 223 | applyCustomCSS(); 224 | break; 225 | } 226 | } 227 | } 228 | }); 229 | observer.observe(rootHead, { 230 | childList: true, 231 | }); 232 | applyCustomCSS(); 233 | return () => { 234 | observer.disconnect(); 235 | }; 236 | } 237 | if (!enabled) { 238 | document.getElementById("logseq-custom-theme-id")?.remove(); 239 | } 240 | }); 241 | } 242 | 243 | export function useAdaptMainUIStyle(show: boolean, tabsWidth?: number | null) { 244 | const route = useRouteState(); 245 | useCustomCSS(); 246 | const shouldShow = 247 | show && 248 | (!route?.template || 249 | ["/", "/all-journals", "/page/:name", "/file/:path"].includes( 250 | route?.template 251 | )); 252 | const docRef = React.useRef(document.documentElement); 253 | const isHovering = useHoverDirty(docRef); 254 | React.useEffect(() => { 255 | logseq.provideStyle({ 256 | key: "tabs--top-padding", 257 | style: ` 258 | .cp__sidebar-main-content { 259 | padding-top: ${shouldShow ? "64px" : ""}; 260 | }`, 261 | }); 262 | 263 | logseq.showMainUI({ autoFocus: false }); 264 | const headerEl = top!.document.querySelector( 265 | "#head.cp__header" 266 | )! as HTMLElement; 267 | 268 | const mainContainer = top!.document.querySelector( 269 | "#main-content-container" 270 | ) as HTMLElement; 271 | 272 | if (!mainContainer) { 273 | return; 274 | } 275 | 276 | const listener = () => { 277 | const { left: leftOffset, width } = mainContainer.getBoundingClientRect(); 278 | const maxWidth = width - 10; 279 | logseq.setMainUIInlineStyle({ 280 | zIndex: 9, 281 | userSelect: "none", 282 | position: "fixed", 283 | left: `${leftOffset}px`, 284 | top: `${headerEl.offsetHeight + 2}px`, 285 | height: shouldShow ? "28px" : "0px", 286 | width: isHovering ? "100%" : tabsWidth + "px", // 10 is the width of the scrollbar 287 | maxWidth: maxWidth + "px", 288 | }); 289 | }; 290 | listener(); 291 | const ob = new ResizeObserver(listener); 292 | ob.observe(mainContainer); 293 | return () => { 294 | ob.disconnect(); 295 | }; 296 | }, [shouldShow, tabsWidth, isHovering]); 297 | } 298 | 299 | export const isMac = () => { 300 | return navigator.userAgent.includes("Mac"); 301 | }; 302 | 303 | export function useEventCallback any>(fn: T): T { 304 | const ref: any = React.useRef(); 305 | 306 | // we copy a ref to the callback scoped to the current state/props on each render 307 | React.useLayoutEffect(() => { 308 | ref.current = fn; 309 | }); 310 | 311 | return React.useCallback( 312 | (...args: any[]) => ref.current.apply(void 0, args), 313 | [] 314 | ) as T; 315 | } 316 | 317 | export const useScrollWidth = ( 318 | ref: React.RefObject 319 | ) => { 320 | const [scrollWidth, setScrollWidth] = React.useState(); 321 | React.useEffect(() => { 322 | const update = () => setScrollWidth(ref.current?.scrollWidth || 0); 323 | const mo = new MutationObserver(() => { 324 | // Run multiple times to take animation into account, hacky... 325 | update(); 326 | setTimeout(update, 100); 327 | setTimeout(update, 200); 328 | setTimeout(update, 300); 329 | }); 330 | if (ref.current) { 331 | setScrollWidth(ref.current.scrollWidth || 0); 332 | mo.observe(ref.current, { 333 | childList: true, 334 | subtree: true, 335 | attributes: true, 336 | }); 337 | } 338 | return () => mo.disconnect(); 339 | }, [ref]); 340 | return scrollWidth; 341 | }; 342 | 343 | export const mainContainerScroll = (scrollOptions: ScrollToOptions) => { 344 | top?.document.querySelector("#main-content-container")?.scrollTo(scrollOptions); 345 | }; 346 | 347 | export const isBlock = (t: ITabInfo) => { 348 | return Boolean(t.page); 349 | }; 350 | 351 | // Makes sure the user will not lose focus (editing state) when previewing a link 352 | export const usePreventFocus = () => { 353 | const restoreFocus = useDebounceFn( 354 | useEventCallback(() => { 355 | if (window.document.hasFocus()) { 356 | (top as any).focus(); 357 | logseq.Editor.restoreEditingCursor(); 358 | } 359 | }), 360 | 10 361 | ); 362 | React.useEffect(() => { 363 | let timer = 0; 364 | timer = setInterval(restoreFocus, 1000); 365 | window.addEventListener("focus", restoreFocus); 366 | return () => { 367 | window.removeEventListener("focus", restoreFocus); 368 | clearInterval(timer); 369 | }; 370 | }); 371 | }; 372 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "types": ["vite/client"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react" 18 | }, 19 | "include": ["./src"] 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import reactRefresh from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | import WindiCSS from "vite-plugin-windicss"; 4 | 5 | import logseqPlugin from "vite-plugin-logseq"; 6 | 7 | const reactRefreshPlugin = reactRefresh({ 8 | fastRefresh: false, 9 | }); 10 | 11 | // https://vitejs.dev/config/ 12 | export default defineConfig({ 13 | plugins: [reactRefreshPlugin, WindiCSS(), logseqPlugin()], 14 | clearScreen: false, 15 | build: { 16 | target: "esnext", 17 | minify: "esbuild", 18 | }, 19 | }); 20 | --------------------------------------------------------------------------------