├── .github ├── FUNDING.yaml └── workflows │ └── publish.yml ├── .gitignore ├── .npmcheckrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── coffee.png ├── logseq-logo.png └── logseq.png ├── icon.png ├── index.html ├── package.json ├── pnpm-lock.yaml ├── release.config.js ├── src ├── app.ts ├── commands │ ├── index.ts │ └── magic_markup.ts ├── css │ ├── border_view.css │ ├── columns_view.css │ ├── gallery_view.css │ ├── hide_dot_refs__hover.css │ ├── hide_dot_refs__wrap.css │ ├── spare_blocks.css │ └── tabular_view.css ├── entry.ts ├── features.ts ├── utils │ ├── index.ts │ ├── logseq.ts │ └── other.ts └── views.ts ├── tsconfig.json └── vite.config.ts /.github/FUNDING.yaml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: buymeacoffee.com/stdword 4 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | # Allows you to run this workflow manually from the Actions tab 5 | workflow_dispatch: 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | persist-credentials: false 16 | 17 | - name: Setup Node.js environment 18 | uses: actions/setup-node@v3.6.0 19 | with: 20 | node-version: "20" 21 | 22 | - name: Setup pnpm 23 | uses: pnpm/action-setup@v4 24 | with: 25 | version: latest 26 | 27 | - name: Install dependencies 28 | run: pnpm install 29 | 30 | - name: Build dist 31 | run: pnpm prod 32 | 33 | - name: Install zip 34 | uses: montudor/action-zip@v1 35 | 36 | - name: Release 37 | run: npx semantic-release 38 | env: 39 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | *.sublime-* 5 | -------------------------------------------------------------------------------- /.npmcheckrc: -------------------------------------------------------------------------------- 1 | {"depcheck": {"ignoreMatches": [ 2 | "@semantic-release/changelog", 3 | "@semantic-release/exec", 4 | "@semantic-release/git", 5 | "conventional-changelog-conventionalcommits", 6 | "semantic-release" 7 | ]}} 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.8.3](https://github.com/stdword/logseq13-missing-commands/compare/v1.8.2...v1.8.3) (2024-11-09) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * bug with properties got shown after block update ([b730a54](https://github.com/stdword/logseq13-missing-commands/commit/b730a54cf26969ba163a8abbcaa7fcef97490a03)) 7 | 8 | ## [1.8.2](https://github.com/stdword/logseq13-missing-commands/compare/v1.8.1...v1.8.2) (2024-09-16) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * keep cursor position ast the start/end of the blocks in movement commands ([b96b05b](https://github.com/stdword/logseq13-missing-commands/commit/b96b05baa220570a199e70bc4c8676436095da52)) 14 | * set heading command in selection mode ([2efc368](https://github.com/stdword/logseq13-missing-commands/commit/2efc3680dd1538a444d5ac7a7d32ac45291642fe)) 15 | 16 | ## [1.8.1](https://github.com/stdword/logseq13-missing-commands/compare/v1.8.0...v1.8.1) (2024-04-25) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * **features:** ⌥ + mouse click on ref wihen ref contains «-» sign ([0eeea74](https://github.com/stdword/logseq13-missing-commands/commit/0eeea744d7b056ac2d7f8be0c88bc49d1b1df2de)) 22 | 23 | # [1.8.0](https://github.com/stdword/logseq13-missing-commands/compare/v1.7.0...v1.8.0) (2024-04-25) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * **commands:** toggle heading: preserve edited content ([d4db38e](https://github.com/stdword/logseq13-missing-commands/commit/d4db38ece9a486ef4ca120e7b11869e540e177d4)) 29 | 30 | 31 | ### Features 32 | 33 | * **commands:** split by commas ([a0acf35](https://github.com/stdword/logseq13-missing-commands/commit/a0acf355c91e49ac12a5219ea5a2051aa9fcf297)) 34 | * **commands:** trim lines punctiation & split by semicolon ([11a9d7e](https://github.com/stdword/logseq13-missing-commands/commit/11a9d7e73977abb9a8f55f789bd5d800b33f0182)) 35 | 36 | # [1.7.0](https://github.com/stdword/logseq13-missing-commands/compare/v1.6.0...v1.7.0) (2024-01-31) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * **commands:** magic split: add case with ordered lists in 1.2 style (no dot in the end) ([61b065a](https://github.com/stdword/logseq13-missing-commands/commit/61b065a5909ac16e84d2f96e1becd3fa9353223c)) 42 | 43 | 44 | ### Features 45 | 46 | * **features:** mouse ref click: plugins integration — shorten my links & awesome links ([c84160b](https://github.com/stdword/logseq13-missing-commands/commit/c84160b0f3d81dbcced32386445e213304ab751d)) 47 | * **views:** tabular view: add hidden header mode with #.tabular0 ([fe0fca0](https://github.com/stdword/logseq13-missing-commands/commit/fe0fca0db8862703a90d583777442f2522b1add2)) 48 | 49 | # [1.6.0](https://github.com/stdword/logseq13-missing-commands/compare/v1.5.0...v1.6.0) (2024-01-24) 50 | 51 | 52 | ### Bug Fixes 53 | 54 | * click alignment for tags ([df407aa](https://github.com/stdword/logseq13-missing-commands/commit/df407aad08a74be54ac210b9f3923b70ca6899d1)) 55 | 56 | 57 | ### Features 58 | 59 | * **commands:** magic formatting: magic quotes ([f8282c6](https://github.com/stdword/logseq13-missing-commands/commit/f8282c687064d0485612162c74af11ffbed9d7b4)) 60 | * **commands:** magic formatting: magic reference & magic tag commands ([cfbd402](https://github.com/stdword/logseq13-missing-commands/commit/cfbd402ade3053b60213a03438deaf818eda334d)) 61 | * **features:** mouse click with opt/alt on page ref ([89ebb29](https://github.com/stdword/logseq13-missing-commands/commit/89ebb29ccc667a5d4dbb67b6f523b98d54112f1a)) 62 | 63 | # [1.5.0](https://github.com/stdword/logseq13-missing-commands/compare/v1.4.0...v1.5.0) (2024-01-13) 64 | 65 | 66 | ### Bug Fixes 67 | 68 | * **commands:** change italics style from *text* to _text_ ([511bbb2](https://github.com/stdword/logseq13-missing-commands/commit/511bbb2df32d95722769eb7871d47a8ea4993f90)) 69 | * **commands:** not applying markup on empty lines without selected text in edit mode ([16ba455](https://github.com/stdword/logseq13-missing-commands/commit/16ba4557b97e487a902ad256a86b473852336375)) 70 | * **commands:** prevent overwriting during update of editing block ([3d6d834](https://github.com/stdword/logseq13-missing-commands/commit/3d6d834f385aefc93055932be45f302e844d26d3)) 71 | * **views:** tabular view ([c03b7e6](https://github.com/stdword/logseq13-missing-commands/commit/c03b7e632cadf1eea61f4c897ffc3f9782a4f30f)) 72 | 73 | 74 | ### Features 75 | 76 | * **command:** remove HTML tags ([e312691](https://github.com/stdword/logseq13-missing-commands/commit/e312691d8b3fc22d881cc06435876dd269a04bc4)) 77 | * **commands:** magic bold ([c8b6314](https://github.com/stdword/logseq13-missing-commands/commit/c8b63146fda7bc09094ca6bf6a921a88369efba5)) 78 | * **commands:** magic formatting: add alternative markup styles ([b21a7a0](https://github.com/stdword/logseq13-missing-commands/commit/b21a7a03ba6ac48b384d04e9995c223db40e4720)) 79 | * **commands:** magic italics, underline, strikethrough, highlight, code ([2afc7cf](https://github.com/stdword/logseq13-missing-commands/commit/2afc7cf93d9704eecfb2d08c67ab6b2554944586)) 80 | * **commands:** parse youtube timestamps ([8d5002a](https://github.com/stdword/logseq13-missing-commands/commit/8d5002aea39ac6e75acf12e41c17b0592f2783b8)) 81 | * **views:** columns view, box view, gallery view ([5998d9b](https://github.com/stdword/logseq13-missing-commands/commit/5998d9b37e5517566d6af26274f7e3248dff20a5)) 82 | * **views:** columns, gallery and border views ([05bc386](https://github.com/stdword/logseq13-missing-commands/commit/05bc3866eaadb77fc81e697d7039377a43e25757)) 83 | 84 | # [1.4.0](https://github.com/stdword/logseq13-missing-commands/compare/v1.3.2...v1.4.0) (2024-01-04) 85 | 86 | 87 | ### Bug Fixes 88 | 89 | * **views:** tabular view style fix ([cbdbc5d](https://github.com/stdword/logseq13-missing-commands/commit/cbdbc5d26d07dc1386cc6eb53f89a27897ab09b0)) 90 | 91 | 92 | ### Features 93 | 94 | * **commands:** change case set of commands ([7af5ffb](https://github.com/stdword/logseq13-missing-commands/commit/7af5ffb0ba381d7b5255fec8ffd290aadcb2944e)) 95 | * **commands:** possibility to specify spare space size ([4f1e77d](https://github.com/stdword/logseq13-missing-commands/commit/4f1e77d8a3dbc2fdaad46cb9409eabae5f2e6610)) 96 | * **feature:** cSS: spare blocks ([335eda8](https://github.com/stdword/logseq13-missing-commands/commit/335eda8b3690c6895c4830fdddb71689209ca438)) 97 | * **views:** hide dotted refs in two styles ([68f515c](https://github.com/stdword/logseq13-missing-commands/commit/68f515c2868a3d76ae3ea8cfe9c172a462edee19)) 98 | * **views:** tabular view ([90490a5](https://github.com/stdword/logseq13-missing-commands/commit/90490a5472ac846e2b491c63e583d1fb43e69452)) 99 | 100 | ## [1.3.2](https://github.com/stdword/logseq13-missing-commands/compare/v1.3.1...v1.3.2) (2023-12-31) 101 | 102 | 103 | ### Bug Fixes 104 | 105 | * **commands:** add non-nested version for remove new lines command ([790066b](https://github.com/stdword/logseq13-missing-commands/commit/790066b8ec2940a19c3aeda37c5585ac20028224)) 106 | 107 | ## [1.3.1](https://github.com/stdword/logseq13-missing-commands/compare/v1.3.0...v1.3.1) (2023-12-30) 108 | 109 | 110 | ### Bug Fixes 111 | 112 | * **commands:** remove new lines command improve: add spaces ([f47b19d](https://github.com/stdword/logseq13-missing-commands/commit/f47b19db34dd1301250ffe0a7b588a1320887995)) 113 | 114 | # [1.3.0](https://github.com/stdword/logseq13-missing-commands/compare/v1.2.0...v1.3.0) (2023-12-30) 115 | 116 | 117 | ### Features 118 | 119 | * **commands:** remove new lines ([277bcf8](https://github.com/stdword/logseq13-missing-commands/commit/277bcf86754f0b7b3a3a99d48469d13096df1d69)) 120 | * **commands:** split & join by sentences ([d30801e](https://github.com/stdword/logseq13-missing-commands/commit/d30801e1073b8733c9df5e9aaf6f9127362e81e2)) 121 | * **features:** home-End processings for edit mode & Tab-trigger + access to page name on Search ([eb9476b](https://github.com/stdword/logseq13-missing-commands/commit/eb9476b139add07993444f1cf58560da3ee66520)) 122 | * support • as bullet list ([b006d3f](https://github.com/stdword/logseq13-missing-commands/commit/b006d3fcb2f7ee36d0b89f99010780846aeb0e49)) 123 | 124 | # [1.2.0](https://github.com/stdword/logseq13-missing-commands/compare/v1.1.0...v1.2.0) (2023-12-21) 125 | 126 | 127 | ### Bug Fixes 128 | 129 | * bug in batch inserting with cutting off spaces ([888ec13](https://github.com/stdword/logseq13-missing-commands/commit/888ec132792369a478903d27c0ed8d38bc549a04)) 130 | * bug with inserting under parent when blokc is numbered; fix wrong command key ([9ad9f9e](https://github.com/stdword/logseq13-missing-commands/commit/9ad9f9e2345b736588ab48c1a427ba8c4e44c718)) 131 | * bug with splittting & wrong commands keys ([43d61f9](https://github.com/stdword/logseq13-missing-commands/commit/43d61f93d562769635d5d802dfe4260f61628070)) 132 | * bugs with indentation and fake bullets ([c2b7b7c](https://github.com/stdword/logseq13-missing-commands/commit/c2b7b7c0696f4d635e54920f0bda13033de24bf1)) 133 | * change order of the commands ([d241027](https://github.com/stdword/logseq13-missing-commands/commit/d24102749f20a39df1174fcd05e08a084129f667)) 134 | * **commands:** editNextBlock: bug with children iterations ([8d873fc](https://github.com/stdword/logseq13-missing-commands/commit/8d873fcca823f7bfe4a359728555169c3520aaea)) 135 | * empty & numbering bugs in inserting ([b42f249](https://github.com/stdword/logseq13-missing-commands/commit/b42f24927ae8098a5de7dc642e1c51a70145135e)) 136 | * fix properties handling in all transform commands ([04cfb78](https://github.com/stdword/logseq13-missing-commands/commit/04cfb785fa02cae7af7be1dd5caf2823c0287002)) 137 | * join via lines (nested) multylines bug ([9404062](https://github.com/stdword/logseq13-missing-commands/commit/9404062c56d6eb9c6705e454180eea5300569c56)) 138 | * join via lines command ([bb88c94](https://github.com/stdword/logseq13-missing-commands/commit/bb88c94f57691e9634a9995a2cfdbfe927031cbd)) 139 | * join with commas command ([f92439d](https://github.com/stdword/logseq13-missing-commands/commit/f92439d84250ecb69252d2321c54895581295c31)) 140 | * magic split initial state bug ([f2cbfdc](https://github.com/stdword/logseq13-missing-commands/commit/f2cbfdc1e179e3bda5e66b592ca4f2557d961e19)) 141 | * regexps for unusual numbering when splitting; plugin description ([d4946ad](https://github.com/stdword/logseq13-missing-commands/commit/d4946adc5ff7dd5e840611f37534281aef3a51bf)) 142 | * try to avoid empty parent bug ([b3731eb](https://github.com/stdword/logseq13-missing-commands/commit/b3731ebbd3415c0e97930a441b5050824b8d3e3b)) 143 | * updating properties and inserting children bug in split commands ([f2d16ee](https://github.com/stdword/logseq13-missing-commands/commit/f2d16eeb5c30b4f4303c1c009b1fd5e322581563)) 144 | * **windows:** adapt shortcuts for better ux ([f34d082](https://github.com/stdword/logseq13-missing-commands/commit/f34d0828306038921de65cb56b1b1f44ad4d6a30)) 145 | 146 | 147 | ### Features 148 | 149 | * add support for roman numbers & refacatoring ([38f3a31](https://github.com/stdword/logseq13-missing-commands/commit/38f3a31d359d2312e146153ec671dacabe11e868)) 150 | * check for properties before joining ([cee24f7](https://github.com/stdword/logseq13-missing-commands/commit/cee24f7afec69ef3a9d8a09abdb311b6901db6c2)) 151 | * **commands:** join to paragraphs ([3a3d3ce](https://github.com/stdword/logseq13-missing-commands/commit/3a3d3ce474dfa16dea6f39c10a747eb1fb4f0c05)) 152 | * **commands:** join via space, commas & new lines ([447e5c9](https://github.com/stdword/logseq13-missing-commands/commit/447e5c9dfd1c8f91087546145f33c1e8ab487ced)) 153 | * **commands:** magic join ([17949cc](https://github.com/stdword/logseq13-missing-commands/commit/17949ccac961fff9e69d5fad7874ba4afc1269f9)) 154 | * **commands:** magic split command ([68a2ab2](https://github.com/stdword/logseq13-missing-commands/commit/68a2ab26821b1bc215082a6b8125fdb24c9d5fb5)) 155 | * **commands:** make split by paragraphs be nested ([d454644](https://github.com/stdword/logseq13-missing-commands/commit/d4546444c452dc0998349727e0625d4939abbc99)) 156 | * **commands:** split by lines ([aee7e2e](https://github.com/stdword/logseq13-missing-commands/commit/aee7e2edca89ff896cadc75286b04439fdec82b4)) 157 | * **commands:** wplit by words ([a0052c6](https://github.com/stdword/logseq13-missing-commands/commit/a0052c6e6844c23cdd212b3f9de254fcada6f48d)) 158 | * different icons for windows & other platforms ([4a660f5](https://github.com/stdword/logseq13-missing-commands/commit/4a660f5c32668c5a706b9e5a3b429492acf40f80)) 159 | * save cursor positions for edirPreviousBlock & editNextBlock commands ([0c22fbf](https://github.com/stdword/logseq13-missing-commands/commit/0c22fbf1d0fa3fd9d89d176da7c4c135d3d6241c)) 160 | * support different ordered lists types & remove split by paragraph command ([8b403ad](https://github.com/stdword/logseq13-missing-commands/commit/8b403adbf32d93d8ac44a1e9112f92d0ba62ea33)) 161 | 162 | # [1.1.0](https://github.com/stdword/logseq13-missing-commands/compare/v1.0.0...v1.1.0) (2023-12-12) 163 | 164 | 165 | ### Bug Fixes 166 | 167 | * change commands order in keymap ([2234b3b](https://github.com/stdword/logseq13-missing-commands/commit/2234b3bfef25547d892462221d5f50249373ab90)) 168 | 169 | 170 | ### Features 171 | 172 | * **commands:** fast outdenting ([cc636ea](https://github.com/stdword/logseq13-missing-commands/commit/cc636eaee9ee67da9ff91d59987f055770cdf3af)) 173 | * **commands:** forced block swap ([3b0cf16](https://github.com/stdword/logseq13-missing-commands/commit/3b0cf1636e885b1ec843d90e82e96652fd7e8853)) 174 | * **commands:** navigation commands set ([e35705e](https://github.com/stdword/logseq13-missing-commands/commit/e35705eaa1158981a0b0422d356f13e5f64bb9f6)) 175 | * **commands:** revrese & shuffle ([ba7aad7](https://github.com/stdword/logseq13-missing-commands/commit/ba7aad70c54461f77cbf2353b12d43b6ddf8e83c)) 176 | * **commands:** sort blocks ([d938448](https://github.com/stdword/logseq13-missing-commands/commit/d938448a0f9d24e1584db2cb644a983b4c24947c)) 177 | * **commands:** split by paragraphs ([6016952](https://github.com/stdword/logseq13-missing-commands/commit/601695283c6f586ab268f8a33caf2dcc55e096d1)) 178 | 179 | # 1.0.0 (2023-12-10) 180 | 181 | 182 | ### Features 183 | 184 | * auto heading command ([cae4edf](https://github.com/stdword/logseq13-missing-commands/commit/cae4edfed768843e0a117b48e353a09c3a10a59b)) 185 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sergey Kolesnik (@stdword) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |                    3 | Missing Commands, Views & Features 4 | 5 | 6 | 7 |

8 | 9 |

10 | 11 | 12 | 13 |

14 | 15 | 16 |
17 | 18 | [![](https://img.shields.io/badge/status-support-ca966c)](https://github.com/stdword/logseq13-missing-commands/releases) 19 | [![Version](https://img.shields.io/github/v/release/stdword/logseq13-missing-commands?color=b3c5d0)](https://github.com/stdword/logseq13-missing-commands/releases) 20 | [![Downloads](https://img.shields.io/github/downloads/stdword/logseq13-missing-commands/total.svg?color=ca966c)](https://github.com/stdword/logseq13-missing-commands#from-logseq-marketplace-recommended-way) 21 | 22 |
23 | 24 |

A part of the Logseq13 family of plugins

25 | 26 | 27 | ## Summary 28 | 29 | Missing, but helpful _commands_, _views_ & _features_ for [Logseq](https://logseq.com) 30 | 31 | _Designed to be very productive with keyboard_ ❤️ 32 | 33 |

 

34 | 35 | > ℹ️ Some commands has default shortcut and some not. 36 | > 37 | > To find out the shortcut for the particular command (or bind your own) use [this](https://github.com/stdword/logseq13-missing-commands/tree/main?tab=readme-ov-file#how-to-change-default-shortcut-for-the-particular-command) instruction. 38 | > 39 | > Any command could be called from Commands Palette, but consider [this](https://github.com/stdword/logseq13-missing-commands/tree/main?tab=readme-ov-file#any-command-from-the-command-palette-doesnt-work-why) Logseq bug. 40 | 41 |

 

42 | 43 | > ❗️ Some parts of this plugin heavily rely on Logseq's Document Object Model (DOM) structure. This means that every Logseq update could potentially break specific plugin functions. If you notice anything unusual, please create an issue with details. 44 | 45 |

 

46 | 47 | > ⚠️ GitHub may need some time to load all demo animations (GIF) in collapsed blocks on this page. 48 | 49 |

 

50 | 51 | ## 1) ⛓️ Features 52 | 53 | 54 | 58 | 59 | 63 | 64 | 69 | 70 | 75 | 76 | 80 | 81 |
TAB-trigger on Search 55 |

To fill the input with selected search item. Just press the tab key to speed up the input values.

56 |

57 |
Fast access to current page name on Search 60 |

Helpfull, when you need to access subpages of the current page. Just press the arrow key on empty search input.

61 |

62 |
Go to the block start (end) with double-pressing the «Home» («End») key 65 |

Just like in Sublime Text editor. MacOS's ⌘ ← / ⌘ → and Windows's fn ← / fn → are also supported.

66 |

Restriction: This feature only works for natural lines of block, which have a «new line» character or «\n». It does not work with lines created due to the size of the layout. In such cases, the only way to proceed is to press Esc to exit edit mode and then use the or arrow key to re-enter it.

67 |

68 |
Spare space between 1-level blocks 71 |

Increase the space between 1-level blocks in order to clearly separate them from each other.

72 |

Motivation: blocks on the first level represent the most general parts of the information, which usually stand separately: headings, categories, clients, code snippets, links, etc.

73 |

74 |
Edit block on mouse click on page reference or tag with (or Alt for Windows) key 77 |

Restriction: this feature only works for the first page reference or tag. There is no way to recognize the others if they are the same.

78 |

79 |
82 | 83 | 84 | ## 2) 🔧 Blocks reordering 85 | 86 | 87 | 91 | 92 | 97 |
Toggle auto heading 88 |

Without accessing block context menu.

89 |

90 |
Sort / reverse / shuffle blocks 93 |

Note: To sort in descending order use sort and then reverse commands.

94 |

Note: Sort and reverse commands available via block context menu. Shuffle command only via Command Palette

95 |

96 |
98 | 99 | 100 | ## 3) 🔧 Fast navigation 101 | 102 | 103 | 112 | 113 | 117 | 118 | 125 |
Go to (↑) previous / (↓) next block 104 |

Instantly goes to next / prev block. Even with multiline blocks.

105 |

Note: cursor position saves from block to block.

106 |

107 | before     &     after
108 | 109 | 110 |

111 |
Go to (↖︎) parent / (↘︎) last child block 114 |

Navigating whole block tree throught diagonal — jumping between the parent and the last child block.

115 |

116 |
Go to |↑| previous / |↓| next sibling block 119 |

Jumping between sibling blocks only.

120 |

Note: cursor position saves from block to block.

121 |

Note: we cannot leave current parent.

122 |

Note: the difference from prevous command is skipping all child blocks.

123 |

124 |
126 | 127 | 128 | ## 4) 🔧 Blocks movements 129 | 130 | 131 | 136 | 137 | 142 |
Outdent (⇤) children of the block 132 |

Perform outdent (indent to the left) for every child of the particular block.

133 |

Note: standard Logseq commands ⇧⇥ can acheive this, but it required to select all child blocks manually one by one before using it.

134 |

135 |
Move block (⤒) on top / (⤓) on bottom of siblings 138 |

Instantly makes block the first (or the last) child of the parent.

139 |

Note: standard Logseq commands ⌘⇧↑ or ⌘⇧↓ can acheive this, but via one step at a time.

140 |

141 |
143 | 144 | 145 | ## 5) 🔧 Splitting & Joining blocks 146 | 147 | 148 | 158 | 159 | 168 | 169 | 178 | 179 | 189 | 190 | 199 |
Magic Split & Magic Join 149 |

Search block content for ordered / unordered lists and split it to corresponding blocks structure.

150 |

Note: supported numeration style: 1. 1) (1) 1.2) for arabic & roman numbers and letters from alphabet.

151 |

Note: supported bullets style: - + *.

152 |

153 | Split     &     Join
154 | 155 | 156 |

157 |
Split by lines / Join via new lines 160 |

Simple command to pick out each line of block to separate block (and vica-versa).

161 |

Note: There are two types of join command: with respect to block structure and without it.

162 |

163 | Split     &                     Join
164 | 165 | 166 |

167 |
Split by words / Join via spaces 170 |

Get all words from the text and place it at the separate blocks (and vice-versa).

171 |

Note: Words could contain letters, ', _ & - characters.

172 |

173 | Split     &                     Join
174 | 175 | 176 |

177 |
Split by commas or semicolons / Join via commas 180 |

Split the text by commas (or semicolons with the separate command).

181 |

182 | 183 |

Join separate blocks via commas.

184 |

185 | 186 |

Note: Joining can respect the root node with colon «:».

187 |

188 |
Split / Join sentences 191 |

Split paragraph of text by sentences (one block = one sentence). And join the blocks to single paragraph.

192 |

Note: Split removes the dots at the end. Join returns the dots back.

193 |

194 | Split     &     Join
195 | 196 | 197 |

198 |
200 | 201 | 202 | ## 6) 🔧 Updating blocks 203 | 204 | 205 | 206 | 217 | 218 | 223 | 224 | 230 | 231 | 235 | 236 | 241 | 242 | 246 | 247 |
Magic Bold / Italics / Underlne / Hightlight / Strikethrough / Code / Reference / Tag / Quotes 207 |

Apply various formatting in a smart way: auto-select whole words, recognize Logseq special syntax, smart undo formatting, and work out of editing mode.

208 |

To use it in edit mode:

    209 |
  1. Go to the Keymap (g s) → Formatting section and replace standard Logseq commands (Bold, Highlight, Italics, Strikethrough) with magic ones.
  2. 210 |
  3. Bind Magic underline, Magic `code`, Magic [[reference]], Magic #tag and Magic "quotes" commands to shortcuts of your choice (e.g. ⌘U, ⌥~, etc.).
  4. 211 |

212 |

Note: command uses «_» for italics to prevent this cases.

213 |

214 |

215 |

216 |
Remove new lines 219 |

Remove all «new line» characters from text. Helpful for work with OCR texts.

220 |

Note: command adds spaces when it's necessary.

221 |

222 |
Trim lines punctuation ". , ;" 225 |

Remove any of «. , ;» characters from the end of every line of text.

226 |

Note: command removes only one punctuation character.

227 | 228 |

229 |
Lower / upper / title letters case 232 |

Note: title case command has two variations — title words and title sentences.

233 |

234 |
Remove HTML tags 237 |

Remove all HTML tags from the block, leaving only the text content.

238 |

Note: there is no exceptions — everything between «<» and «>» will be removed.

239 |

240 |
Parse YouTube timestamps 243 |

Transform copied from YouTube timestamps to Logseq format.

244 |

245 |
248 | 249 | 250 | ## 7) 🔭 Views 251 | 252 | 253 | 262 | 263 | 284 | 285 | 297 | 298 | 311 | 312 | 317 | 318 |
Hide references started with «.» 254 |

Hide any page and tag references that start with the dot: «.», assuming that these are special reserved references that do not need to be shown.

255 |

Note: there are two ways of hiding:

256 |

257 | Hide by wrapping to «…» only                     &   Hide completely and show on block hover
258 | 259 | 260 |

261 |
Tabular view 264 |

Use the #.tabular reference in a block to apply a Tana-like tabular view for all its children.

265 |

266 | 267 |

Note: it could be nested — #.tabular inside another #.tabular. However, only two-level depth is supported.

268 |

269 | 270 |

Use the #.tabular0 reference in another tabular row to skip the immediate children.

271 |

272 | 273 |

Use the #.tabular0 reference to hide heading block.

274 |

275 | 276 |

FAQ: How to return back double square brackets for page references in the left column?

277 |

Add following code to custom.css:

278 |
279 |     .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container > .block-content-wrapper .page-reference .bracket {
280 |       display: inline-flex;
281 |     }
282 |   
283 |
Columns view 286 |

Use the #.columns reference to organize child blocks to columns of the same width.
287 | Note: 1 column = 1 block.

288 | 289 |

Use the #.columns-N reference to organize child blocks to N columns of the same width, where N = 2…6.
290 | Note: 1 column = 1 or more blocks.

291 | 292 |

Use the #.columns-fit reference to organize child blocks to columns with different width (based on content).
293 | Note: 1 column = 1 block.

294 | 295 |

296 |
Gallery view 299 |

Use the #.gallery reference to organize child blocks containing images to gallery.
300 | Note: image sizes automatically fills whole space for width. There is only one row of images.

301 |

302 | 303 |

Use the #.gallery-wN reference to organize child blocks containing images as fixed-width (based on N) images.
304 | Note: there can be multiple rows of images.

305 |

306 | 307 |

Use the #.gallery-hN reference to organize child blocks containing images as fixed-height (based on N) images.
308 | Note: there can be multiple rows of images.

309 |

310 |
Border view 313 |

Use the #.border & #.border-child references to organize borders around the blocks.
314 | Note: these references can be combined.

315 |

316 |
319 | 320 | 321 | ## If you ❤️ what I'm doing — consider to support my work 322 |

323 | 324 | Buy Me A Coffee 325 | 326 |

327 | 328 | 329 | ## Installation 330 | ### From Logseq Marketplace (recommended way): 331 |      332 | 333 | - Click «...» and open the «Plugins» section (or press `t p`) 334 | - Click on the «Marketplace» 335 | - On the «Plugins» tab search for «Missing Commands & Views» plugin and click install 336 | - If you want to change default shortcuts commands — go to «Keymap» (`g s`) 337 | 338 | ### Manual way (in case of any troubles with recommended way) 339 | 1. *In Logseq*: Enable «Developer mode» in «...» → «Settings» → «Advanced» 340 | 2. Download the latest plugin release in a raw .zip archive from [here](https://github.com/stdword/logseq13-missing-commands/releases) 341 | 4. Unzip it 342 | 5. *In Logseq*: Go to the «...» → «Plugins», click «Load unpacked plugin» and point to the unzipped plugin folder 343 | 6. ⚠️ The important point here is: every new plugin release should be updated manually 344 | 345 | 346 | ## FAQ 347 | ### Any command from the _Command Palette_ doesn't work! Why? 348 | The reason is the bug in Logseq's interaction with the _Command Palette_: 349 | - If you select the command **with your mouse**, it cannot detect the currently selected blocks or the currently editing block. 350 | - If you select the command **with your keyboard** (using Enter), it cannot detect the currently editing block, but it can detect the currently selected blocks. 351 | - If you want to execute a particular command for the currently editing block — [bind a shortcut](https://github.com/stdword/logseq13-missing-commands/tree/main?tab=readme-ov-file#how-to-change-default-shortcut-for-the-particular-command) to it. 352 | 353 | ### How to change default shortcut for the particular command? 354 | 1. Open «Settings» → «Keymap» (or press `g s`). 355 | 2. Copy this emoji «🪚» (for Windows use «🔪») and insert it to search input. 356 | 3. Change any shortcut you want
357 | 358 | ### Why I cannot revert the result of particular command with one _Undo_ action? 359 | This is a restriction of the Logseq API: there is no way to execute complex commands in a single _Undo_. Therefore, the plugin attempts (when it makes sense) to minimize the count of _Undo_ actions by removing the entire block tree instead of removing each block independently. 360 | 361 | ### Why there is strange «ø» charactear appears sometimes during _Undo_ command? 362 |

363 | 364 | The reason is [this](https://github.com/logseq/logseq/issues/10729) bug in Logseq plugin API. The plugin uses «ø» character intentionally as a workaround for this issue. When the bug is resolved, this workaround will no longer be necessary. 365 | 366 | 367 | ## Additional helpful plugins with the same vibe 368 | - [Shallow Copy](https://github.com/MateuszMyalski/logseq-plugin-shallow-copy) by `MateuszMyalski` 369 | - [Side Block](https://github.com/YU000jp/logseq-plugin-side-block) by `YU000jp` 370 | - [Custom Files](https://github.com/cannibalox/logseq-custom-files) by `cannibalox` 371 | - [LogTools](https://github.com/cannibalox/logtools) by `cannibalox` 372 | - [Awesome Content](https://github.com/yoyurec/logseq-awesome-content) by `yoyurec` 373 | 374 | 375 | ## Credits 376 | Some parts of this plugin based on reviewed and refined works of another authors: 377 | 378 | - Auto heading based on [Another Embed](https://github.com/sethyuan/logseq-plugin-another-embed) by `sethyuan` 379 | - Tabular view based on _«Tabular Journals»_ by `nmartin84` (there is no such repo anymore) 380 | - Gallery, box & columns views based on [LogTools](https://github.com/cannibalox/logtools) by `cannibalox` 381 | - Columns view based on [Awesome Content](https://github.com/yoyurec/logseq-awesome-content) by `yoyurec` 382 | - Magic formatting based on [Obsidian: Smarter MD Hotkeys](https://github.com/chrisgrieser/obsidian-smarter-md-hotkeys) by `chrisgrieser` 383 | 384 | + Icon created by Nuricon 385 | 386 | 387 | ## License 388 | [MIT License](https://github.com/stdword/logseq13-missing-commands/blob/main/LICENSE) 389 | -------------------------------------------------------------------------------- /assets/coffee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stdword/logseq13-missing-commands/22429b3c9031e5ad1629b95262e85069100de0a5/assets/coffee.png -------------------------------------------------------------------------------- /assets/logseq-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stdword/logseq13-missing-commands/22429b3c9031e5ad1629b95262e85069100de0a5/assets/logseq-logo.png -------------------------------------------------------------------------------- /assets/logseq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stdword/logseq13-missing-commands/22429b3c9031e5ad1629b95262e85069100de0a5/assets/logseq.png -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stdword/logseq13-missing-commands/22429b3c9031e5ad1629b95262e85069100de0a5/icon.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | logseq13-missing-commands 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logseq13-missing-commands", 3 | "version": "1.8.3", 4 | "description": "Sort blocks, TAB-trigger on search, split by sentences, parse text structure, blocks navigation, etc.", 5 | "author": "stdword", 6 | "repository": "https://github.com/stdword/logseq13-missing-commands.git", 7 | "license": "MIT", 8 | "logseq": { 9 | "id": "logseq13-missing-commands", 10 | "title": "Missing Commands & Views", 11 | "icon": "./icon.png", 12 | "main": "./dist/index.html" 13 | }, 14 | "scripts": { 15 | "preinstall": "npx only-allow pnpm", 16 | "clean": "rm -r ./dist/* || true", 17 | "dev": "vite", 18 | "build": "tsc && vite build --mode=dev", 19 | "prod": "tsc && pnpm run clean && vite build" 20 | }, 21 | "dependencies": { 22 | "@logseq/libs": "^0.0.15", 23 | "markdown-it": "^14.0.0" 24 | }, 25 | "devDependencies": { 26 | "@semantic-release/changelog": "^6.0.3", 27 | "@semantic-release/exec": "^6.0.3", 28 | "@semantic-release/git": "^10.0.1", 29 | "@types/markdown-it": "^13.0.7", 30 | "@types/node": "^20.10.4", 31 | "conventional-changelog-conventionalcommits": "^7.0.2", 32 | "cz-conventional-changelog": "^3.3.0", 33 | "semantic-release": "^22.0.10", 34 | "typescript": "^5.3.3", 35 | "vite": "^5.0.7", 36 | "vite-plugin-logseq": "^1.1.2" 37 | }, 38 | "config": { 39 | "commitizen": { 40 | "path": "cz-conventional-changelog" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | var PLUGIN_NAME = 'logseq13-missing-commands' 2 | 3 | module.exports = { 4 | branches: ['main'], 5 | plugins: [ 6 | ['@semantic-release/commit-analyzer', { 7 | preset: 'conventionalcommits', 8 | }], 9 | '@semantic-release/release-notes-generator', 10 | '@semantic-release/changelog', 11 | ['@semantic-release/npm', { 12 | verifyConditions: false, 13 | npmPublish: false, 14 | }], 15 | '@semantic-release/git', 16 | ['@semantic-release/exec', { 17 | prepareCmd: 18 | `zip -qq -r ${PLUGIN_NAME}-` + "${nextRelease.version}.zip dist icon.png package.json README.md LICENSE", 19 | }], 20 | ['@semantic-release/github', { 21 | assets: `${PLUGIN_NAME}-*.zip`, 22 | fail: false, 23 | failComment: false, 24 | failTitle: false, 25 | }], 26 | ], 27 | } 28 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { BlockEntity, SettingSchemaDesc } from '@logseq/libs/dist/LSPlugin.user' 2 | 3 | import { 4 | ICON, 5 | 6 | toggleAutoHeadingCommand, 7 | outdentChildrenCommand, 8 | editNextBlockCommand, editPreviousBlockCommand, 9 | parentBlockCommand, lastChildBlockCommand, 10 | moveToBottomOfSiblingsCommand, moveToTopOfSiblingsCommand, 11 | nextSiblingBlockCommand, previousSiblingBlockCommand, 12 | reverseBlocksCommand, shuffleBlocksCommand, sortBlocksCommand, 13 | 14 | joinBlocksCommand, splitBlocksCommand, updateBlocksCommand, 15 | 16 | magicJoinCommand, magicSplit, 17 | joinAsSentences_Map, joinViaCommas_Attach, joinViaSpaces_Attach, 18 | joinViaNewLines_Attach, joinViaNewLines_Map, 19 | 20 | splitByLines, splitBySentences, splitByWords, 21 | removeNewLines, removeHTML, parseYoutubeTimestamp, 22 | lowerCase, upperCase, titleCaseWords, titleCaseSentences, splitByCommas, trimLinePunctuation, splitBySemicolons, 23 | } from './commands' 24 | import { MARKUP, magicQuotes, magicWrap } from './commands/magic_markup' 25 | import { improveCursorMovementFeature, improveMouseRefClick, improveSearchFeature, spareBlocksFeature } from './features' 26 | import { borderView, columnsView, galleryView, hideDotRefs, tabularView } from './views' 27 | import { getChosenBlocks, p, scrollToBlock } from './utils' 28 | 29 | 30 | const DEV = process.env.NODE_ENV === 'development' 31 | 32 | const defaultSpareBlocksSpace = 20 33 | const defaultMagicQuotes = '""' 34 | 35 | const settingsSchema: SettingSchemaDesc[] = [ 36 | { 37 | key: 'headingCommands', 38 | type: 'heading', 39 | title: '🔧 Commands', 40 | description: ` 41 |

See detailed description about every command in 42 | 43 | documentation. 44 |


45 |

To change shortcut for the particular command:

46 |

    47 |
  1. Open «Settings» → «Keymap».
  2. 48 |
  3. Copy this emoji «🪚» (for Windows use «🔪») and insert it to search input.
  4. 49 |
  5. Change any shortcut you want
  6. 50 |

51 | `.trim(), 52 | default: null, 53 | }, 54 | { 55 | key: 'magicQuotes', 56 | title: 'Use this quotes for «Magic quotes» command', 57 | description: ` 58 |

Default is ${defaultMagicQuotes}.

59 | `.trim(), 60 | type: 'string', 61 | default: defaultMagicQuotes, 62 | }, 63 | { 64 | key: 'headingFeatures', 65 | type: 'heading', 66 | title: '⛓️ Features', 67 | description: ` 68 |

See detailed description about every feature in 69 | 70 | documentation. 71 |

72 | `.trim(), 73 | default: null, 74 | }, 75 | { 76 | key: 'enableHomeEnd', 77 | title: 'Enable improved «Home» / «End» keys processing?', 78 | description: ` 79 |

1. Double-press the Home / End key (in edit mode) to go to the block start / end.

80 |

2. MacOS's ⌘ ← / ⌘ → and Windows's fn ← / fn → are also supported.

81 |

Restriction: This feature only works for natural lines of block, which have a «new line» character («\\n»). It does not work with lines created due to the size of the layout. In such cases, the only way to proceed is to press Esc to exit edit mode and then use the or arrow key to re-enter it.

82 | `.trim(), 83 | type: 'enum', 84 | enumPicker: 'radio', 85 | enumChoices: ['Yes', 'No'], 86 | default: 'Yes', 87 | }, 88 | { 89 | key: 'enableSearchImprovements', 90 | title: 'Enable improved keys processing on Search?', 91 | description: ` 92 |

1. Press Tab to fill the input with selected search item.

93 |

2. Press arrow (with empty input) to fill the input with the current page name.

94 | `.trim(), 95 | type: 'enum', 96 | enumPicker: 'radio', 97 | enumChoices: ['Yes', 'No'], 98 | default: 'Yes', 99 | }, 100 | { 101 | key: 'enableMouseRefClick', 102 | title: 'Enable block editing on mouse click to [[reference]]?', 103 | description: ` 104 |

With (or Alt for Windows) pressed.

105 |

Restriction: this feature only works for the first page reference or tag. There is no way to recognize the others if they are the same.

106 | `.trim(), 107 | type: 'enum', 108 | enumPicker: 'radio', 109 | enumChoices: ['Yes', 'No'], 110 | default: 'Yes', 111 | }, 112 | { 113 | key: 'spareBlocksSpace', 114 | title: 'Spare space between 1-level blocks', 115 | description: ` 116 |

Increase the space to clearly separate blocks from each other.

117 |

Motivation: blocks on the 1-level represent the most general parts of the information, which usually stand separately: headings, categories, clients, code snippets, links, etc.

118 |

In pixels: default is ${defaultSpareBlocksSpace}. Set to 0 to disable.

119 | `.trim(), 120 | type: 'number', 121 | default: defaultSpareBlocksSpace, 122 | }, 123 | { 124 | key: 'headingViews', 125 | type: 'heading', 126 | title: '🔭 Views', 127 | description: ` 128 |

See detailed description about every view in 129 | 130 | documentation. 131 |

132 | `.trim(), 133 | default: null, 134 | }, 135 | { 136 | key: 'hideDotRefs', 137 | title: 'Hide references started with «.»?', 138 | description: ` 139 |

Hide any page and tag references that start with the dot: «.», assuming that these are special reserved references that do not need to be shown.

140 | `.trim(), 141 | type: 'enum', 142 | enumPicker: 'select', 143 | enumChoices: ['Hide completely and show on block hover', 'Hide by wrapping to «…» only', 'No'], 144 | default: 'Hide by wrapping to «…» only', 145 | }, 146 | { 147 | key: 'enableTabularView', 148 | title: 'Enable tabular view?', 149 | description: ` 150 |
    151 |
  1. Use the #.tabular reference in a block to apply a Tana-like tabular view for all its children. 152 | It could be subsequent: #.tabular inside another #.tabular. However, only two subsequent levels are supported.
  2. 153 |
  3. Use the #.tabular0 reference in another tabular row to skip the immediate children.
  4. 154 |
  5. Use the #.tabular0 reference to hide heading block.
  6. 155 |
156 | `.trim(), 157 | type: 'enum', 158 | enumPicker: 'radio', 159 | enumChoices: ['Yes', 'No'], 160 | default: 'Yes', 161 | }, 162 | { 163 | key: 'enableColumnsView', 164 | title: 'Enable columns view?', 165 | description: ` 166 |
    167 |
  1. Use the #.columns reference to organize child blocks 168 | to columns of the same width. 169 | 1 column = 1 block.
  2. 170 |
  3. Use the #.columns-N reference to organize child blocks 171 | to N columns of the same width, where N = 2…6. 172 | 1 column = 1 or more blocks.
  4. 173 |
  5. Use the #.columns-fit reference to organize child blocks 174 | to columns with different width (based on content). 175 | 1 column = 1 block.
  6. 176 |
177 | `.trim(), 178 | type: 'enum', 179 | enumPicker: 'radio', 180 | enumChoices: ['Yes', 'No'], 181 | default: 'Yes', 182 | }, 183 | { 184 | key: 'enableGalleryView', 185 | title: 'Enable gallery view?', 186 | description: ` 187 |
    188 |
  1. Use the #.gallery reference to organize child blocks containing images to gallery. 189 | Image sizes automatically fills whole space for width. There is only one row of images.
  2. 190 |
  3. Use the #.gallery-wN reference to organize child blocks containing images as fixed-width (based on N) images. 191 | There can be multiple rows of images.
  4. 192 |
  5. Use the #.gallery-hN reference to organize child blocks containing images as fixed-height (based on N) images. 193 | There can be multiple rows of images.
  6. 194 |
195 | `.trim(), 196 | type: 'enum', 197 | enumPicker: 'radio', 198 | enumChoices: ['Yes', 'No'], 199 | default: 'Yes', 200 | }, 201 | { 202 | key: 'enableBorderView', 203 | title: 'Enable border view?', 204 | description: ` 205 |

Use the #.border & #.border-child references to organize borders around the blocks.
206 | These references can be combined.

207 | `.trim(), 208 | type: 'enum', 209 | enumPicker: 'radio', 210 | enumChoices: ['Yes', 'No'], 211 | default: 'Yes', 212 | }, 213 | ] 214 | const settings_: any = settingsSchema.reduce((r, v) => ({ ...r, [v.key]: v}), {}) 215 | 216 | 217 | async function init() { 218 | if (DEV) { 219 | logseq.UI.showMsg( 220 | `[:div [:b "Missing Commands"] [:p "HMR"] ]`, 221 | 'info', 222 | {timeout: 3000}, 223 | ) 224 | } 225 | 226 | logseq.useSettingsSchema(settingsSchema) 227 | 228 | console.info(p`Loaded`) 229 | } 230 | async function postInit(settings) { 231 | await onAppSettingsChanged(settings, undefined) 232 | } 233 | async function onAppSettingsChanged(current, old) { 234 | if (current.magicQuotes !== old?.magicQuotes) { 235 | if (current.magicQuotes.length === 0) 236 | current.magicQuotes = defaultMagicQuotes 237 | if (current.magicQuotes.length === 1) 238 | current.magicQuotes += current.magicQuotes 239 | if (current.magicQuotes.length > 2) 240 | current.magicQuotes = current.magicQuotes.slice(0, 2) 241 | 242 | if (current.magicQuotes !== logseq.settings!.magicQuotes) 243 | logseq.settings!.magicQuotes = current.magicQuotes 244 | } 245 | 246 | if (!old || current.enableHomeEnd !== old.enableHomeEnd) { 247 | improveCursorMovementFeature(false) 248 | if (current.enableHomeEnd === 'Yes') 249 | improveCursorMovementFeature(true) 250 | } 251 | 252 | if (!old || current.enableSearchImprovements !== old.enableSearchImprovements) { 253 | improveSearchFeature(false) 254 | if (current.enableSearchImprovements === 'Yes') 255 | improveSearchFeature(true) 256 | } 257 | 258 | if (!old || current.enableMouseRefClick !== old.enableMouseRefClick) { 259 | improveMouseRefClick(false) 260 | if (current.enableMouseRefClick === 'Yes') 261 | improveMouseRefClick(true) 262 | } 263 | 264 | if (!old || current.spareBlocksSpace !== old.spareBlocksSpace) 265 | spareBlocksFeature(current.spareBlocksSpace) 266 | 267 | if (!old || current.hideDotRefs !== old.hideDotRefs) 268 | if (current.hideDotRefs === 'No') 269 | hideDotRefs(false) 270 | else if (current.hideDotRefs === settings_.hideDotRefs.default) 271 | hideDotRefs(true, false) // wrap only 272 | else 273 | hideDotRefs(true, true) // wrap & hide 274 | 275 | if (!old || current.enableTabularView !== old.enableTabularView) 276 | tabularView(current.enableTabularView === 'Yes') 277 | 278 | if (!old || current.enableColumnsView !== old.enableColumnsView) 279 | columnsView(current.enableColumnsView === 'Yes') 280 | 281 | if (!old || current.enableGalleryView !== old.enableGalleryView) 282 | galleryView(current.enableGalleryView === 'Yes') 283 | 284 | if (!old || current.enableBorderView !== old.enableBorderView) 285 | borderView(current.enableBorderView === 'Yes') 286 | } 287 | 288 | 289 | async function main() { 290 | await init() 291 | 292 | const settings = logseq.settings! 293 | const settingsOff = logseq.onSettingsChanged(onAppSettingsChanged) 294 | 295 | logseq.beforeunload(async () => { 296 | improveCursorMovementFeature(false) 297 | improveSearchFeature(false) 298 | settingsOff() 299 | }) 300 | 301 | 302 | function setting_storeChildBlocksIn() { 303 | return false // the last one 304 | } 305 | 306 | 307 | // Decoration 308 | logseq.App.registerCommandPalette({ 309 | label: ICON + ' Toggle auto heading', key: 'mc-1-auto-heading', 310 | keybinding: {mac: 'mod+1', binding: 'ctrl+1', mode: 'global'}, 311 | }, (e) => toggleAutoHeadingCommand({togglingBasedOnFirstBlock: true}) ) 312 | 313 | 314 | // Splitting 315 | logseq.App.registerCommandPalette({ 316 | label: ICON + ' Split by words', key: 'mc-5-split-1-by-words', 317 | // @ts-expect-error 318 | keybinding: {}, 319 | }, (e) => splitBlocksCommand(splitByWords, setting_storeChildBlocksIn())) 320 | logseq.App.registerCommandPalette({ 321 | label: ICON + ' Split by words (with nested)', key: 'mc-5-split-2-by-words-nested', 322 | // @ts-expect-error 323 | keybinding: {}, 324 | }, (e) => splitBlocksCommand(splitByWords, setting_storeChildBlocksIn(), true)) 325 | 326 | logseq.App.registerCommandPalette({ 327 | label: ICON + ' Split by lines', key: 'mc-5-split-3-by-lines', 328 | // @ts-expect-error 329 | keybinding: {}, 330 | }, (e) => splitBlocksCommand(splitByLines, setting_storeChildBlocksIn())) 331 | logseq.App.registerCommandPalette({ 332 | label: ICON + ' Split by lines (with nested)', key: 'mc-5-split-4-by-lines-nested', 333 | // @ts-expect-error 334 | keybinding: {}, 335 | }, (e) => splitBlocksCommand( 336 | splitByLines, setting_storeChildBlocksIn(), true)) 337 | 338 | logseq.App.registerCommandPalette({ 339 | label: ICON + ' Magic Split', key: 'mc-5-split-5-magic', 340 | // @ts-expect-error 341 | keybinding: {}, 342 | }, (e) => splitBlocksCommand(magicSplit, setting_storeChildBlocksIn(), false) ) 343 | logseq.App.registerCommandPalette({ 344 | label: ICON + ' Magic Split (with nested)', key: 'mc-5-split-6-magic-nested', 345 | // @ts-expect-error 346 | keybinding: {}, 347 | }, (e) => splitBlocksCommand(magicSplit, setting_storeChildBlocksIn(), true) ) 348 | 349 | logseq.App.registerCommandPalette({ 350 | label: ICON + ' Split by sentences', key: 'mc-5-split-7-by-sentences', 351 | // @ts-expect-error 352 | keybinding: {}, 353 | }, (e) => splitBlocksCommand(splitBySentences, setting_storeChildBlocksIn())) 354 | logseq.App.registerCommandPalette({ 355 | label: ICON + ' Split by sentences (with nested)', key: 'mc-5-split-8-by-sentences-nested', 356 | // @ts-expect-error 357 | keybinding: {}, 358 | }, (e) => splitBlocksCommand( 359 | splitBySentences, setting_storeChildBlocksIn(), true)) 360 | 361 | logseq.App.registerCommandPalette({ 362 | label: ICON + ' Split by commas', key: 'mc-5-split-9-by-commas', 363 | // @ts-expect-error 364 | keybinding: {}, 365 | }, (e) => splitBlocksCommand(splitByCommas, setting_storeChildBlocksIn())) 366 | logseq.App.registerCommandPalette({ 367 | label: ICON + ' Split by commas (with nested)', key: 'mc-5-split-10-by-commas', 368 | // @ts-expect-error 369 | keybinding: {}, 370 | }, (e) => splitBlocksCommand(splitByCommas, setting_storeChildBlocksIn(), true)) 371 | 372 | logseq.App.registerCommandPalette({ 373 | label: ICON + ' Split by semicolons', key: 'mc-5-split-11-by-semicolons', 374 | // @ts-expect-error 375 | keybinding: {}, 376 | }, (e) => splitBlocksCommand(splitBySemicolons, setting_storeChildBlocksIn())) 377 | logseq.App.registerCommandPalette({ 378 | label: ICON + ' Split by semicolons (with nested)', key: 'mc-5-split-12-by-semicolons-nested', 379 | // @ts-expect-error 380 | keybinding: {}, 381 | }, (e) => splitBlocksCommand(splitBySemicolons, setting_storeChildBlocksIn(), true)) 382 | 383 | 384 | // Joining 385 | logseq.App.registerCommandPalette({ 386 | label: ICON + ' Join via spaces', key: 'mc-6-join-1-spaces', 387 | // @ts-expect-error 388 | keybinding: {}, 389 | }, (e) => joinBlocksCommand(false, joinViaSpaces_Attach)) 390 | 391 | logseq.App.registerCommandPalette({ 392 | label: ICON + ' Join together via commas (with respect to root block)', key: 'mc-6-join-2-commas', 393 | // @ts-expect-error 394 | keybinding: {}, 395 | }, (e) => joinBlocksCommand(false, joinViaCommas_Attach)) 396 | logseq.App.registerCommandPalette({ 397 | label: ICON + ' Join independently via commas (with respect to root block)', key: 'mc-6-join-3-commas-independently', 398 | // @ts-expect-error 399 | keybinding: {}, 400 | }, (e) => joinBlocksCommand(true, joinViaCommas_Attach)) 401 | 402 | logseq.App.registerCommandPalette({ 403 | label: ICON + ' Join via new lines', key: 'mc-6-join-4-lines', 404 | // @ts-expect-error 405 | keybinding: {}, 406 | }, (e) => joinBlocksCommand(false, joinViaNewLines_Attach)) 407 | logseq.App.registerCommandPalette({ 408 | label: ICON + ' Join via new lines (keep nested structure)', key: 'mc-6-join-5-lines-nested', 409 | // @ts-expect-error 410 | keybinding: {}, 411 | }, (e) => joinBlocksCommand(false, joinViaNewLines_Attach, joinViaNewLines_Map)) 412 | 413 | logseq.App.registerCommandPalette({ 414 | label: ICON + ' Magic Join', key: 'mc-6-join-6-magic', 415 | // @ts-expect-error 416 | keybinding: {}, 417 | }, (e) => magicJoinCommand(false)) 418 | logseq.App.registerCommandPalette({ 419 | label: ICON + ' Magic Join (independently)', key: 'mc-6-join-7-magic-independently', 420 | // @ts-expect-error 421 | keybinding: {}, 422 | }, (e) => magicJoinCommand(true)) 423 | 424 | logseq.App.registerCommandPalette({ 425 | label: ICON + ' Join as sentences', key: 'mc-6-join-8-sentences', 426 | // @ts-expect-error 427 | keybinding: {}, 428 | }, (e) => joinBlocksCommand(false, joinViaSpaces_Attach, joinAsSentences_Map, {shouldHandleSingleBlock: true})) 429 | logseq.App.registerCommandPalette({ 430 | label: ICON + ' Join as sentences (independently)', key: 'mc-6-join-9-sentences-independently', 431 | // @ts-expect-error 432 | keybinding: {}, 433 | }, (e) => joinBlocksCommand(true, joinViaSpaces_Attach, joinAsSentences_Map, {shouldHandleSingleBlock: true})) 434 | 435 | 436 | // Updates 437 | logseq.App.registerCommandPalette({ 438 | label: ICON + ' Remove new lines', key: 'mc-7-update-1-remove-new-lines', 439 | // @ts-expect-error 440 | keybinding: {}, 441 | }, (e) => updateBlocksCommand(removeNewLines)) 442 | logseq.App.registerCommandPalette({ 443 | label: ICON + ' Remove new lines (with nested)', key: 'mc-7-update-2-remove-new-lines-nested', 444 | // @ts-expect-error 445 | keybinding: {}, 446 | }, (e) => updateBlocksCommand(removeNewLines, true)) 447 | 448 | logseq.App.registerCommandPalette({ 449 | label: ICON + ' Lower case', key: 'mc-7-update-3-lower-case', 450 | // @ts-expect-error 451 | keybinding: {}, 452 | }, (e) => updateBlocksCommand(lowerCase)) 453 | logseq.App.registerCommandPalette({ 454 | label: ICON + ' Lower case (with nested)', key: 'mc-7-update-4-lower-case-nested', 455 | // @ts-expect-error 456 | keybinding: {}, 457 | }, (e) => updateBlocksCommand(lowerCase, true)) 458 | 459 | logseq.App.registerCommandPalette({ 460 | label: ICON + ' Upper case', key: 'mc-7-update-5-upper-case', 461 | // @ts-expect-error 462 | keybinding: {}, 463 | }, (e) => updateBlocksCommand(upperCase)) 464 | logseq.App.registerCommandPalette({ 465 | label: ICON + ' Upper case (with nested)', key: 'mc-7-update-6-upper-case-nested', 466 | // @ts-expect-error 467 | keybinding: {}, 468 | }, (e) => updateBlocksCommand(upperCase, true)) 469 | 470 | logseq.App.registerCommandPalette({ 471 | label: ICON + ' Title case words', key: 'mc-7-update-7-title-case-words', 472 | // @ts-expect-error 473 | keybinding: {}, 474 | }, (e) => updateBlocksCommand(titleCaseWords)) 475 | logseq.App.registerCommandPalette({ 476 | label: ICON + ' Title case words (with nested)', key: 'mc-7-update-8-title-case-words-nested', 477 | // @ts-expect-error 478 | keybinding: {}, 479 | }, (e) => updateBlocksCommand(titleCaseWords, true)) 480 | 481 | logseq.App.registerCommandPalette({ 482 | label: ICON + ' Title case sentences', key: 'mc-7-update-9-title-case-sentences', 483 | // @ts-expect-error 484 | keybinding: {}, 485 | }, (e) => updateBlocksCommand(titleCaseSentences)) 486 | logseq.App.registerCommandPalette({ 487 | label: ICON + ' Title case sentences (with nested)', key: 'mc-7-update-10-title-case-sentences-nested', 488 | // @ts-expect-error 489 | keybinding: {}, 490 | }, (e) => updateBlocksCommand(titleCaseSentences, true)) 491 | 492 | logseq.App.registerCommandPalette({ 493 | label: ICON + ' Remove HTML tags', key: 'mc-7-update-11-remove-html', 494 | // @ts-expect-error 495 | keybinding: {}, 496 | }, (e) => updateBlocksCommand(removeHTML)) 497 | logseq.App.registerCommandPalette({ 498 | label: ICON + ' Remove HTML tags (with nested)', key: 'mc-7-update-12-remove-html-nested', 499 | // @ts-expect-error 500 | keybinding: {}, 501 | }, (e) => updateBlocksCommand(removeHTML, true)) 502 | 503 | logseq.App.registerCommandPalette({ 504 | label: ICON + ' Parse YouTube timestamps (with nested)', key: 'mc-7-update-13-parse-yt-ts', 505 | // @ts-expect-error 506 | keybinding: {}, 507 | }, (e) => updateBlocksCommand(parseYoutubeTimestamp, true)) 508 | 509 | 510 | // Formatting 511 | logseq.App.registerCommandPalette({ 512 | label: ICON + ' Magic **bold**', key: 'mc-7-update-14-magic-bold', 513 | // @ts-expect-error 514 | keybinding: {}, 515 | }, (e) => updateBlocksCommand( 516 | (content, level, block, parent) => {return magicWrap(block, content, MARKUP.bold)}, 517 | false, false)) 518 | logseq.App.registerCommandPalette({ 519 | label: ICON + ' Magic _italics_', key: 'mc-7-update-15-magic-italics', 520 | // @ts-expect-error 521 | keybinding: {}, 522 | }, (e) => updateBlocksCommand( 523 | (content, level, block, parent) => {return magicWrap(block, content, MARKUP.italics)}, 524 | false, false)) 525 | logseq.App.registerCommandPalette({ 526 | label: ICON + ' Magic ~~strikethrough~~', key: 'mc-7-update-16-magic-strikethrough', 527 | // @ts-expect-error 528 | keybinding: {}, 529 | }, (e) => updateBlocksCommand( 530 | (content, level, block, parent) => {return magicWrap(block, content, MARKUP.strikethrough)}, 531 | false, false)) 532 | logseq.App.registerCommandPalette({ 533 | label: ICON + ' Magic ==highlight==', key: 'mc-7-update-17-magic-highlight', 534 | // @ts-expect-error 535 | keybinding: {}, 536 | }, (e) => updateBlocksCommand( 537 | (content, level, block, parent) => {return magicWrap(block, content, MARKUP.highlight)}, 538 | false, false)) 539 | logseq.App.registerCommandPalette({ 540 | label: ICON + ' Magic underline', key: 'mc-7-update-18-magic-underline', 541 | // @ts-expect-error 542 | keybinding: {}, 543 | }, (e) => updateBlocksCommand( 544 | (content, level, block, parent) => {return magicWrap(block, content, MARKUP.underline)}, 545 | false, false)) 546 | logseq.App.registerCommandPalette({ 547 | label: ICON + ' Magic `code`', key: 'mc-7-update-19-magic-code', 548 | // @ts-expect-error 549 | keybinding: {}, 550 | }, (e) => updateBlocksCommand( 551 | (content, level, block, parent) => {return magicWrap(block, content, MARKUP.code)}, 552 | false, false)) 553 | logseq.App.registerCommandPalette({ 554 | label: ICON + ' Magic [[reference]]', key: 'mc-7-update-20-magic-ref', 555 | // @ts-expect-error 556 | keybinding: {}, 557 | }, (e) => updateBlocksCommand( 558 | (content, level, block, parent) => {return magicWrap(block, content, MARKUP.ref)}, 559 | false, false)) 560 | logseq.App.registerCommandPalette({ 561 | label: ICON + ' Magic #tag', key: 'mc-7-update-21-magic-tag', 562 | // @ts-expect-error 563 | keybinding: {}, 564 | }, (e) => updateBlocksCommand( 565 | (content, level, block, parent) => {return magicWrap(block, content, MARKUP.tag)}, 566 | false, false)) 567 | logseq.App.registerCommandPalette({ 568 | label: ICON + ' Magic "quotes"', key: 'mc-7-update-22-magic-quotes', 569 | // @ts-expect-error 570 | keybinding: {}, 571 | }, (e) => updateBlocksCommand( 572 | (content, level, block, parent) => {return magicQuotes(block, content, settings.magicQuotes)}, 573 | false, false)) 574 | 575 | logseq.App.registerCommandPalette({ 576 | label: ICON + ' Trim lines punctuation ". , ;"', key: 'mc-7-update-23-trim-punctuation', 577 | // @ts-expect-error 578 | keybinding: {}, 579 | }, (e) => updateBlocksCommand(trimLinePunctuation)) 580 | logseq.App.registerCommandPalette({ 581 | label: ICON + ' Trim lines punctuation ". , ;" (with nested)', key: 'mc-7-update-24-trim-punctuation-nested', 582 | // @ts-expect-error 583 | keybinding: {}, 584 | }, (e) => updateBlocksCommand(trimLinePunctuation, true)) 585 | 586 | 587 | // Navigation 588 | logseq.App.registerCommandPalette({ 589 | label: ICON + ' Go to (↖︎) parent block', key: 'mc-2-edit-block-1-deep-dive-out', 590 | keybinding: {mac: 'mod+alt+left', binding: 'ctrl+alt+left', mode: 'global'}, 591 | }, async (e) => parentBlockCommand() ) 592 | 593 | logseq.App.registerCommandPalette({ 594 | label: ICON + ' Go to (↘︎) last child block', key: 'mc-2-edit-block-2-deep-dive-in', 595 | keybinding: {mac: 'mod+alt+right', binding: 'ctrl+alt+right', mode: 'global'}, 596 | }, async (e) => lastChildBlockCommand() ) 597 | 598 | logseq.App.registerCommandPalette({ 599 | label: ICON + ' Go to |↑| previous sibling block', key: 'mc-2-edit-block-5-prev-sibling', 600 | keybinding: {mac: 'ctrl+shift+up', binding: 'meta+alt+up', mode: 'global'}, 601 | }, async (e) => previousSiblingBlockCommand() ) 602 | 603 | logseq.App.registerCommandPalette({ 604 | label: ICON + ' Go to |↓| next sibling block', key: 'mc-2-edit-block-6-next-sibling', 605 | keybinding: {mac: 'ctrl+shift+down', binding: 'meta+alt+down', mode: 'global'}, 606 | }, async (e) => nextSiblingBlockCommand() ) 607 | 608 | logseq.App.registerCommandPalette({ 609 | label: ICON + ' Go to (↑) previous block', key: 'mc-2-edit-block-3-step-up', 610 | keybinding: {mac: 'mod+alt+up', binding: 'ctrl+alt+up', mode: 'global'}, 611 | }, async (e) => editPreviousBlockCommand() ) 612 | 613 | logseq.App.registerCommandPalette({ 614 | label: ICON + ' Go to (↓) next block', key: 'mc-2-edit-block-4-step-down', 615 | keybinding: {mac: 'mod+alt+down', binding: 'ctrl+alt+down', mode: 'global'}, 616 | }, async (e) => editNextBlockCommand() ) 617 | 618 | 619 | // Movements 620 | logseq.App.registerCommandPalette({ 621 | label: ICON + ' Move block (⤒) on top of siblings', key: 'mc-3-move-block-1-on-top', 622 | keybinding: {mac: 'mod+alt+shift+up', binding: 'ctrl+alt+shift+up', mode: 'global'}, 623 | }, async (e) => moveToTopOfSiblingsCommand() ) 624 | 625 | logseq.App.registerCommandPalette({ 626 | label: ICON + ' Move block (⤓) on bottom of siblings', key: 'mc-3-move-block-2-on-bottom', 627 | keybinding: {mac: 'mod+alt+shift+down', binding: 'ctrl+alt+shift+down', mode: 'global'}, 628 | }, async (e) => moveToBottomOfSiblingsCommand() ) 629 | 630 | logseq.App.registerCommandPalette({ 631 | label: ICON + ' Outdent (⇤) children of the block', key: 'mc-3-move-block-3-outdent', 632 | keybinding: {mac: 'ctrl+shift+tab', binding: 'ctrl+shift+tab', mode: 'global'}, 633 | }, async (e) => outdentChildrenCommand() ) 634 | 635 | 636 | // Transformations 637 | logseq.App.registerCommandPalette({ 638 | label: ICON + ' Sort blocks', key: 'mc-4-transform-1-sort-blocks', 639 | // @ts-expect-error 640 | keybinding: {}, 641 | }, (e) => sortBlocksCommand() ) 642 | logseq.Editor.registerBlockContextMenuItem( 643 | ICON + ' Sort blocks', async (e) => sortBlocksCommand(e.uuid) ) 644 | 645 | logseq.App.registerCommandPalette({ 646 | label: ICON + ' Reverse blocks', key: 'mc-4-transform-2-reverse-blocks', 647 | // @ts-expect-error 648 | keybinding: {}, 649 | }, (e) => reverseBlocksCommand() ) 650 | logseq.Editor.registerBlockContextMenuItem( 651 | ICON + ' Reverse blocks', async (e) => reverseBlocksCommand(e.uuid) ) 652 | 653 | logseq.App.registerCommandPalette({ 654 | label: ICON + ' Shuffle blocks', key: 'mc-4-transform-3-shuffle-blocks', 655 | // @ts-expect-error 656 | keybinding: {}, 657 | }, (e) => shuffleBlocksCommand() ) 658 | 659 | 660 | await postInit(settings) 661 | } 662 | 663 | 664 | export const App = (logseq: any) => { 665 | logseq.ready(main).catch(console.error) 666 | } 667 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import '@logseq/libs' 2 | import { BlockEntity, IBatchBlock } from '@logseq/libs/dist/LSPlugin' 3 | 4 | import markdownit from 'markdown-it' 5 | 6 | import { 7 | PropertiesUtils, WalkBlock, checkPropertyExistenceInTree, ensureChildrenIncluded, 8 | filterOutChildBlocks, getBlocksWithReferences, 9 | getChosenBlocks, getEditingCursorSelection, insertBatchBlockBefore, isWindows, lettersToNumber, 10 | numberToLetters, numberToRoman, p, reduceBlockTree, 11 | reduceTextWithLength, scrollToBlock, setEditingCursorSelection, sleep, transformBlocksTreeByReplacing, 12 | transformSelectedBlocksWithMovements, unique, walkBlockTree, walkBlockTreeAsync, 13 | } from '../utils' 14 | 15 | 16 | // there is no `saw` emoji in Windows — use `kitchen knife`: it has the same colors 17 | export const ICON = isWindows ? '🔪' : '🪚' 18 | 19 | const md = markdownit('zero').enable([ 20 | 'paragraph', 21 | 'text', 22 | 'list', 23 | // 'newline', 24 | ], true) 25 | 26 | 27 | export async function toggleAutoHeadingCommand(opts: {togglingBasedOnFirstBlock: boolean}) { 28 | const HEADING_REGEX = /^#+ / 29 | const PROPERTY = PropertiesUtils.headingProperty 30 | 31 | const [ blocks ] = await getChosenBlocks() 32 | if (blocks.length === 0) 33 | return 34 | 35 | const firstBlockHeading = blocks[0].properties?.heading || HEADING_REGEX.test(blocks[0].content) 36 | 37 | for (const block of blocks) { 38 | if (HEADING_REGEX.test(block.content)) { 39 | block.content = block.content.replace(HEADING_REGEX, '') 40 | 41 | // ensure there is no `heading::` 42 | PropertiesUtils.deleteProperty(block, PROPERTY) 43 | 44 | if (opts.togglingBasedOnFirstBlock && !firstBlockHeading) 45 | block.content += '\nheading:: true' 46 | 47 | await logseq.Editor.updateBlock(block.uuid, block.content) 48 | await logseq.Editor.exitEditingMode(true) 49 | continue 50 | } 51 | 52 | const heading = opts.togglingBasedOnFirstBlock ? firstBlockHeading : block.properties?.heading 53 | if (!heading) 54 | await logseq.Editor.upsertBlockProperty(block.uuid, PROPERTY, true) 55 | else 56 | await logseq.Editor.removeBlockProperty(block.uuid, PROPERTY) 57 | 58 | // ensure currently edited content will be saved 59 | block.content = block.content.replace(/\n?^heading:: true$/m, '') 60 | await logseq.Editor.updateBlock(block.uuid, block.content) 61 | } 62 | } 63 | 64 | export async function transformSelectedBlocksCommand( 65 | blocks: BlockEntity[], 66 | transformCallback: (blocks: BlockEntity[]) => BlockEntity[], 67 | isSelectedState: boolean, 68 | ) { 69 | // CASE: all transformed blocks relates to one root block 70 | if (blocks.length === 1) { 71 | const tree = await ensureChildrenIncluded(blocks[0]) 72 | if (!tree.children || tree.children.length === 0) 73 | return // nothing to transform 74 | 75 | const newRoot = await transformBlocksTreeByReplacing(tree, transformCallback) 76 | if (newRoot) { // successfully replaced 77 | if (isSelectedState) 78 | await logseq.Editor.selectBlock(newRoot.uuid) 79 | else 80 | await logseq.Editor.editBlock(newRoot.uuid) 81 | 82 | return 83 | } 84 | 85 | // fallback to array of blocks 86 | blocks = tree.children as BlockEntity[] 87 | } 88 | 89 | 90 | // CASE: selected blocks from different parents 91 | transformSelectedBlocksWithMovements(blocks, transformCallback) 92 | } 93 | 94 | export async function sortBlocksCommand(contextBlockUUID: string | null = null) { 95 | let blocks: BlockEntity[] 96 | let isSelectedState = true 97 | if (contextBlockUUID) 98 | blocks = [(await logseq.Editor.getBlock(contextBlockUUID))!] 99 | else 100 | [blocks, isSelectedState] = await getChosenBlocks() 101 | 102 | if (blocks.length === 0) { 103 | await logseq.UI.showMsg( 104 | `[:div 105 | [:b "${ICON} Sort Blocks Command"] 106 | [:p "Select some blocks to use the command"]]`, 107 | 'warning', 108 | {timeout: 10000}, 109 | ) 110 | return 111 | } 112 | 113 | const comparer = (a: string, b: string) => a.localeCompare(b, 'en', { numeric: true }) 114 | const sortBlocks = (blocks) => Array 115 | .from(blocks as BlockEntity[]) 116 | .sort((a, b) => comparer(a.content, b.content)) 117 | 118 | transformSelectedBlocksCommand(blocks, sortBlocks, isSelectedState) 119 | } 120 | 121 | export async function reverseBlocksCommand(contextBlockUUID: string | null = null) { 122 | let blocks: BlockEntity[] 123 | let isSelectedState = true 124 | if (contextBlockUUID) 125 | blocks = [(await logseq.Editor.getBlock(contextBlockUUID))!] 126 | else 127 | [blocks, isSelectedState] = await getChosenBlocks() 128 | 129 | if (blocks.length === 0) { 130 | await logseq.UI.showMsg( 131 | `[:div 132 | [:b "${ICON} Reverse Blocks Command"] 133 | [:p "Select some blocks to use the command"]]`, 134 | 'warning', 135 | {timeout: 10000}, 136 | ) 137 | return 138 | } 139 | 140 | const reverseBlocks = (blocks) => Array 141 | .from(blocks as BlockEntity[]) 142 | .reverse() 143 | 144 | transformSelectedBlocksCommand(blocks, reverseBlocks, isSelectedState) 145 | } 146 | 147 | export async function shuffleBlocksCommand(contextBlockUUID: string | null = null) { 148 | let blocks: BlockEntity[] 149 | let isSelectedState = true 150 | if (contextBlockUUID) 151 | blocks = [(await logseq.Editor.getBlock(contextBlockUUID))!] 152 | else 153 | [blocks, isSelectedState] = await getChosenBlocks() 154 | 155 | if (blocks.length === 0) { 156 | await logseq.UI.showMsg( 157 | `[:div 158 | [:b "${ICON} Shuffle Blocks Command"] 159 | [:p "Select some blocks to use the command"]]`, 160 | 'warning', 161 | {timeout: 10000}, 162 | ) 163 | return 164 | } 165 | 166 | const shuffleBlocks = (blocks) => Array 167 | .from(blocks as BlockEntity[]) 168 | .sort(() => Math.random() - 0.5) 169 | 170 | transformSelectedBlocksCommand(blocks, shuffleBlocks, isSelectedState) 171 | } 172 | 173 | 174 | export async function parentBlockCommand() { 175 | const [blocks] = await getChosenBlocks() 176 | const [first] = blocks 177 | if (!first) 178 | return 179 | 180 | if (first.parent.id === first.page.id) 181 | return 182 | 183 | const parentBlock = await logseq.Editor.getBlock(first.parent.id) as BlockEntity 184 | await logseq.Editor.editBlock(parentBlock.uuid) 185 | } 186 | 187 | export async function lastChildBlockCommand() { 188 | const [blocks] = await getChosenBlocks() 189 | const [first] = blocks 190 | if (!first) 191 | return 192 | 193 | const tree = await logseq.Editor.getBlock(first.uuid, {includeChildren: true}) as BlockEntity 194 | if (!tree.children || tree.children.length === 0) 195 | return 196 | 197 | const lastChild = tree.children!.at(-1)! as BlockEntity 198 | await logseq.Editor.editBlock(lastChild.uuid) 199 | } 200 | 201 | export async function previousSiblingBlockCommand() { 202 | const [blocks] = await getChosenBlocks() 203 | const [first] = blocks 204 | if (!first) 205 | return 206 | 207 | const prevBlock = await logseq.Editor.getPreviousSiblingBlock(first.uuid) 208 | if (!prevBlock) 209 | return 210 | 211 | const cursorPosition = (await logseq.Editor.getEditingCursorPosition())?.pos 212 | const content = await logseq.Editor.getEditingBlockContent() 213 | const isPositionOnTheEnd = cursorPosition === undefined || cursorPosition === content.length 214 | 215 | await logseq.Editor.editBlock( 216 | prevBlock.uuid, 217 | !isPositionOnTheEnd ? {pos: cursorPosition} : undefined 218 | ) 219 | } 220 | 221 | export async function nextSiblingBlockCommand() { 222 | const [blocks] = await getChosenBlocks() 223 | const [first] = blocks 224 | if (!first) 225 | return 226 | 227 | const nextBlock = await logseq.Editor.getNextSiblingBlock(first.uuid) 228 | if (!nextBlock) 229 | return 230 | 231 | const cursorPosition = (await logseq.Editor.getEditingCursorPosition())?.pos 232 | const content = await logseq.Editor.getEditingBlockContent() 233 | const isPositionOnTheEnd = cursorPosition === undefined || cursorPosition === content.length 234 | 235 | await logseq.Editor.editBlock( 236 | nextBlock.uuid, 237 | !isPositionOnTheEnd ? {pos: cursorPosition} : undefined 238 | ) 239 | } 240 | 241 | export async function editPreviousBlockCommand() { 242 | const [blocks] = await getChosenBlocks() 243 | let [current] = blocks 244 | if (!current) 245 | return 246 | 247 | let prevBlock = await logseq.Editor.getPreviousSiblingBlock(current.uuid) 248 | if (prevBlock) { 249 | let iteration = prevBlock 250 | while (true) { 251 | if (!iteration['collapsed?']) { 252 | const tree = await logseq.Editor.getBlock(iteration.uuid, {includeChildren: true}) as BlockEntity 253 | if (tree.children && tree.children.length !== 0) { 254 | iteration = tree.children.at(-1) as BlockEntity 255 | continue 256 | } 257 | } 258 | 259 | prevBlock = iteration 260 | break 261 | } 262 | } else { 263 | if (current.parent.id === current.page.id) { 264 | // no prev block at all → go to the start of current 265 | await logseq.Editor.editBlock(current.uuid, {pos: 0}) 266 | return 267 | } 268 | 269 | const parent = await logseq.Editor.getBlock(current.parent.id) as BlockEntity 270 | prevBlock = parent 271 | } 272 | 273 | const cursorPosition = (await logseq.Editor.getEditingCursorPosition())?.pos 274 | const content = await logseq.Editor.getEditingBlockContent() 275 | const isPositionOnTheEnd = cursorPosition === undefined || cursorPosition === content.length 276 | 277 | await logseq.Editor.editBlock( 278 | (prevBlock as BlockEntity).uuid, 279 | !isPositionOnTheEnd ? {pos: cursorPosition} : undefined 280 | ) 281 | } 282 | 283 | export async function editNextBlockCommand() { 284 | const [blocks] = await getChosenBlocks() 285 | let [current] = blocks 286 | if (!current) 287 | return 288 | 289 | let nextBlock: BlockEntity | null = null 290 | if (!current['collapsed?']) { 291 | const tree = await logseq.Editor.getBlock(current.uuid, {includeChildren: true}) as BlockEntity 292 | if (tree.children && tree.children.length !== 0) 293 | nextBlock = tree.children[0] as BlockEntity 294 | } 295 | 296 | if (!nextBlock) { 297 | let iteration = current 298 | while (true) { 299 | if (!nextBlock) { 300 | nextBlock = await logseq.Editor.getNextSiblingBlock(iteration.uuid) 301 | if (nextBlock) 302 | break 303 | } 304 | 305 | if (iteration.parent.id === iteration.page.id) 306 | break 307 | 308 | const parent = await logseq.Editor.getBlock(iteration.parent.id) as BlockEntity 309 | iteration = parent 310 | 311 | nextBlock = await logseq.Editor.getNextSiblingBlock(parent.uuid) 312 | if (nextBlock) 313 | break 314 | } 315 | } 316 | 317 | if (!nextBlock) { 318 | // no next block at all → go to the end of current 319 | await logseq.Editor.editBlock(current.uuid) 320 | return 321 | } 322 | 323 | const cursorPosition = (await logseq.Editor.getEditingCursorPosition())?.pos 324 | const content = await logseq.Editor.getEditingBlockContent() 325 | const isPositionOnTheEnd = cursorPosition === undefined || cursorPosition === content.length 326 | 327 | await logseq.Editor.editBlock( 328 | (nextBlock as BlockEntity).uuid, 329 | !isPositionOnTheEnd ? {pos: cursorPosition} : undefined 330 | ) 331 | } 332 | 333 | 334 | export async function moveToTopOfSiblingsCommand() { 335 | const [blocks] = await getChosenBlocks() 336 | const [first] = blocks 337 | if (!first) 338 | return 339 | 340 | // already on top 341 | if (first.parent.id === first.left.id || first.left.id === first.page.id) 342 | return 343 | 344 | let topmost = first 345 | while (true) { 346 | const prev = await logseq.Editor.getPreviousSiblingBlock(topmost.uuid) 347 | if (!prev) 348 | break 349 | topmost = prev 350 | } 351 | 352 | await logseq.Editor.moveBlock(first.uuid, topmost.uuid, {before: true, children: false}) 353 | await scrollToBlock(first) 354 | } 355 | 356 | export async function moveToBottomOfSiblingsCommand() { 357 | const [blocks] = await getChosenBlocks() 358 | const [first] = blocks 359 | if (!first) 360 | return 361 | 362 | let bottommost = first 363 | while (true) { 364 | const next = await logseq.Editor.getNextSiblingBlock(bottommost.uuid) 365 | if (!next) 366 | break 367 | bottommost = next 368 | } 369 | 370 | // already on bottom 371 | if (first.id === bottommost.id) 372 | return 373 | 374 | await logseq.Editor.moveBlock(first.uuid, bottommost.uuid, {before: false, children: false}) 375 | await scrollToBlock(first) 376 | } 377 | 378 | export async function outdentChildrenCommand() { 379 | const [blocks] = await getChosenBlocks() 380 | for (const block of blocks) { 381 | const tree = await logseq.Editor.getBlock(block.uuid, {includeChildren: true}) 382 | if (!tree) 383 | continue 384 | 385 | for (const child of (tree.children ?? []).toReversed()) { 386 | await logseq.Editor.moveBlock( 387 | (child as BlockEntity).uuid, 388 | block.uuid, 389 | {before: false, children: false}, 390 | ) 391 | } 392 | } 393 | } 394 | 395 | 396 | export function splitByLines(text: string): IBatchBlock[] { 397 | const textBlocks = text.split(/\n/) 398 | return textBlocks 399 | .map((tb) => {return {content: tb}}) 400 | } 401 | 402 | export function splitByWords(text: string): IBatchBlock[] { 403 | const textBlocks = text.split(/[^\p{Lowercase_Letter}'-_]+/iu) 404 | return textBlocks 405 | .filter((tb) => !!tb) 406 | .map((tb) => {return {content: tb}}) 407 | } 408 | 409 | export function splitByCommas(text: string): IBatchBlock[] { 410 | const textBlocks = text.split(/,\s*/) 411 | return textBlocks 412 | .filter((tb) => !!tb) 413 | .map((tb) => {return {content: tb}}) 414 | } 415 | 416 | export function splitBySemicolons(text: string): IBatchBlock[] { 417 | const textBlocks = text.split(/;\s*/) 418 | return textBlocks 419 | .filter((tb) => !!tb) 420 | .map((tb) => {return {content: tb}}) 421 | } 422 | 423 | export function splitBySentences(text: string): IBatchBlock[] { 424 | const textBlocks = text.split(/(?<=[.!?…])\s+/) 425 | return textBlocks 426 | .map((tb) => tb.endsWith('.') ? tb.slice(0, -1) : tb) 427 | .map((tb) => {return {content: tb}}) 428 | } 429 | 430 | export function magicSplit(text: string): IBatchBlock[] { 431 | // add special types of ordered lists for parser to recognize it 432 | // (1) → 1) 433 | text = text.replaceAll(/^(\s*)\((\d{1,3}\)\s)/gm, '$1$2') 434 | // 1.2.3. → 1) 435 | text = text.replaceAll(/^(\s*)((\d|\p{Lowercase_Letter}){1,3}\.){2,10}\s/gmiu, '$11) ') // $11 means $1 and 1 436 | // 1.2.3 → 1) 437 | text = text.replaceAll(/^(\s*)((\d|\p{Lowercase_Letter}){1,3}\.){1,10}(\d|\p{Lowercase_Letter}){1,3}\s/gmiu, '$11) ') // $11 means $1 and 1 438 | // (1.2.3) → 1) and 1.2.3) → 1) 439 | text = text.replaceAll(/^(\s*)\(?((\d|\p{Lowercase_Letter}){1,3}\.){1,10}(\d|\p{Lowercase_Letter}){1,3}\)\s/gmiu, '$11) ') 440 | // a) → 1) and (a) → 1) 441 | text = text.replaceAll(/^(\s*)\(?(\p{Lowercase_Letter}{1,3})\)\s/gmiu, '$11) ') 442 | // a. → 1) 443 | text = text.replaceAll(/^(\s*)(\p{Lowercase_Letter}{1,3})\.\s/gmiu, '$11) ') 444 | 445 | // add special types of unordered lists for parser to recognize it 446 | // • → - 447 | text = text.replaceAll(/^(\s*)•\s*/gmu, '$1- ') 448 | 449 | const tokens = md.parse(text, {}) 450 | console.debug(p`Parsed tokens`, Array.from(tokens)) 451 | console.debug(p`HTML-view`, md.renderer.render(tokens, {}, {})) 452 | 453 | const results: IBatchBlock[] = [] 454 | 455 | type State = {container: IBatchBlock, isForNumbering: boolean} 456 | const statesStack: State[] = [] 457 | 458 | const initialState: State = { 459 | container: {content: '', children: results}, 460 | isForNumbering: false, 461 | } 462 | let state = initialState 463 | 464 | function createBlock(content: string, opts: { numbering: boolean } = { numbering: false }) { 465 | const properties: {[name: string]: string} = {} 466 | if (opts.numbering) 467 | properties[PropertiesUtils.numberingProperty_] = 'number' 468 | return {content, children: [], properties} 469 | } 470 | 471 | while (true) { 472 | if (tokens.length === 0) 473 | break 474 | 475 | let token = tokens.shift()! 476 | 477 | switch (token!.type) { 478 | case 'paragraph_open': { 479 | const message = 'paragraph_open, inline, paragraph_close always comes along' 480 | 481 | token = tokens.shift()! 482 | if (token.type !== 'inline') 483 | throw new Error(message) 484 | 485 | state.container.children!.push( 486 | createBlock(token.content, {numbering: state.isForNumbering}) 487 | ) 488 | 489 | token = tokens.shift()! 490 | if (token.type !== 'paragraph_close') 491 | throw new Error(message) 492 | 493 | break 494 | } 495 | 496 | case 'ordered_list_open': 497 | case 'bullet_list_open': { 498 | const isForNumbering = token.type === 'ordered_list_open' 499 | 500 | // items of ordered list are always child items 501 | // so try to get the parent block here 502 | const lastBlock = state.container.children!.at(-1) 503 | if (lastBlock) { 504 | statesStack.push(state) 505 | state = {container: lastBlock, isForNumbering} 506 | } 507 | else 508 | state.isForNumbering = isForNumbering 509 | 510 | break 511 | } 512 | 513 | case 'ordered_list_close': 514 | case 'bullet_list_close': 515 | if (statesStack.length === 0) { 516 | state = initialState 517 | state.isForNumbering = false 518 | } 519 | else 520 | state = statesStack.pop()! 521 | 522 | break 523 | 524 | case 'list_item_open': 525 | case 'list_item_close': 526 | break 527 | 528 | default: 529 | throw new Error(`Unknown token: ${token.type}`) 530 | } 531 | } 532 | 533 | return results 534 | } 535 | 536 | export async function splitBlocksCommand( 537 | splitCallback: (content: string) => IBatchBlock[], 538 | keepChildrenInFirstBlock: boolean = true, 539 | recursive: boolean = false, 540 | ) { 541 | let [ blocks, isSelectedState ] = await getChosenBlocks() 542 | if (blocks.length === 0) 543 | return 544 | 545 | blocks = unique(blocks, (b) => b.uuid) 546 | if (recursive) { 547 | blocks = await Promise.all( 548 | blocks.map(async (b) => { 549 | return ( 550 | await logseq.Editor.getBlock(b.uuid, {includeChildren: true}) 551 | )! 552 | }) 553 | ) 554 | blocks = filterOutChildBlocks(blocks) 555 | } 556 | 557 | for (const block of blocks) { 558 | async function processBlock(b) { 559 | const block = b as BlockEntity 560 | 561 | const content = PropertiesUtils.deleteAllProperties(block.content) 562 | const batch = splitCallback(content) 563 | 564 | let head: IBatchBlock, tail: IBatchBlock[] 565 | if (keepChildrenInFirstBlock) 566 | [head, tail] = [batch[0], batch.slice(1)] 567 | else 568 | [tail, head] = [batch.slice(0, -1), batch.at(-1)!] 569 | 570 | if (content !== head.content) { // has changes? 571 | const properties = PropertiesUtils.fromCamelCaseAll(block.properties) 572 | Object.assign(properties, head.properties ?? {}) 573 | 574 | await logseq.Editor.updateBlock(block.uuid, head.content, {properties}) 575 | } 576 | 577 | if (head.children && head.children.length !== 0) 578 | await logseq.Editor.insertBatchBlock( 579 | block.uuid, head.children, {sibling: false}) 580 | 581 | if (tail.length !== 0) { 582 | if (keepChildrenInFirstBlock) 583 | await logseq.Editor.insertBatchBlock( 584 | block.uuid, tail, {before: false, sibling: true}) 585 | else 586 | await insertBatchBlockBefore(block, tail) 587 | } 588 | } 589 | 590 | if (recursive) 591 | await walkBlockTreeAsync(block as IBatchBlock, async (b, level) => processBlock(b)) 592 | else 593 | await processBlock(block) 594 | } 595 | 596 | if (isSelectedState) { 597 | await sleep(20) 598 | await logseq.Editor.exitEditingMode() 599 | } 600 | } 601 | 602 | 603 | export function joinAsSentences_Map(content, level): string { 604 | if (content && /(? string, 643 | joinMapCallback?: (content: string, level: number, block: BlockEntity, parent?: BlockEntity) => string, 644 | opts: {shouldHandleSingleBlock: boolean} = {shouldHandleSingleBlock: false}, 645 | ) { 646 | let [ blocks, isSelectedState ] = await getChosenBlocks() 647 | if (blocks.length === 0) 648 | return 649 | 650 | blocks = unique(blocks, (b) => b.uuid) 651 | blocks = await Promise.all( 652 | blocks.map(async (b) => { 653 | return ( 654 | await logseq.Editor.getBlock(b.uuid, {includeChildren: true}) 655 | )! 656 | }) 657 | ) 658 | blocks = filterOutChildBlocks(blocks) 659 | 660 | if (blocks.length === 0) 661 | return 662 | 663 | independentMode = independentMode || blocks.length === 1 664 | 665 | 666 | // it is important to check if any block in the tree has references 667 | // (Logseq replaces references with it's text) 668 | let noWarnings = true 669 | for (const block of blocks) { 670 | let blocksWithReferences = await getBlocksWithReferences(block) 671 | 672 | // root can have references (in independent mode), others — not 673 | const blocksWithReferences_noRoot = blocksWithReferences.filter((b) => b.uuid !== block.uuid) 674 | block._rootHasReferences = blocksWithReferences.length !== blocksWithReferences_noRoot.length 675 | 676 | if (independentMode) 677 | blocksWithReferences = blocksWithReferences_noRoot 678 | 679 | if (blocksWithReferences.length !== 0) { 680 | const html = blocksWithReferences.map((b) => { 681 | let content = PropertiesUtils.deleteAllProperties(b.content) 682 | content = reduceTextWithLength(content, 35) 683 | return `[:li [:i "${content}"]]` 684 | }) 685 | await logseq.UI.showMsg( 686 | `[:div 687 | [:b "${ICON} Join Blocks Command"] 688 | [:p "There are blocks that have references from another blocks: " 689 | [:ul ${html}] 690 | ] 691 | [:p "Remove references or select only blocks without them."] 692 | ]`, 693 | 'warning', 694 | {timeout: 20000}, 695 | ) 696 | noWarnings = false 697 | } 698 | } 699 | if (!noWarnings) 700 | return 701 | 702 | 703 | // check for properties 704 | for (const block of blocks) { 705 | const names = checkPropertyExistenceInTree(block as IBatchBlock, {skipRoot: independentMode}) 706 | if (names.length !== 0) { 707 | const html = names.map((name) => { 708 | return `[:li [:i "${PropertiesUtils.fromCamelCase(name)}"]]` 709 | }) 710 | await logseq.UI.showMsg( 711 | `[:div 712 | [:b "${ICON} Join Blocks Command"] 713 | [:p "There are blocks that have properties (command can't handle them): " 714 | [:ul ${html}] 715 | ] 716 | [:p "Remove properties or select only blocks without them."] 717 | ]`, 718 | 'warning', 719 | {timeout: 20000}, 720 | ) 721 | noWarnings = false 722 | } 723 | } 724 | if (!noWarnings) 725 | return 726 | 727 | 728 | function reduceTree(block: IBatchBlock, { startLevel }: { startLevel: number }) { 729 | const preparedTree = walkBlockTree(block, (b, level, p, data) => { 730 | const content = PropertiesUtils.deleteAllProperties(b.content) 731 | return joinMapCallback 732 | ? joinMapCallback( 733 | content, level, b as BlockEntity, p as BlockEntity | undefined) 734 | : content 735 | }, startLevel) 736 | 737 | const reducedContent = reduceBlockTree(preparedTree, (b, level, children, data) => { 738 | return joinAttachCallback(b.content, level, children, b.data.node as BlockEntity) 739 | }, startLevel) 740 | 741 | return reducedContent 742 | } 743 | 744 | if (independentMode) { 745 | for (const block of blocks) { 746 | // ensure .children always is array 747 | if (!block.children) 748 | block.children = [] 749 | 750 | if (block.children.length === 0) { 751 | if (!opts.shouldHandleSingleBlock) 752 | continue // nothing to join 753 | } 754 | 755 | const content = reduceTree(block as IBatchBlock, {startLevel: 0}) 756 | const properties = PropertiesUtils.fromCamelCaseAll(block.properties) 757 | 758 | if (block._rootHasReferences || block.children.length === 0) { 759 | await logseq.Editor.updateBlock(block.uuid, content, {properties}) 760 | for (const child of block.children) 761 | await logseq.Editor.removeBlock((child as BlockEntity).uuid) 762 | } else { 763 | await insertBatchBlockBefore(block, {content, properties}) 764 | await logseq.Editor.removeBlock(block.uuid) 765 | } 766 | } 767 | } else { 768 | const top = blocks[0] 769 | const pseudoRoot: IBatchBlock = {content: '', children: blocks as IBatchBlock[]} 770 | const content = reduceTree(pseudoRoot, {startLevel: 0}) 771 | const properties = PropertiesUtils.fromCamelCaseAll(top.properties) 772 | 773 | await insertBatchBlockBefore(top, {content, properties}) 774 | for (const block of blocks) 775 | await logseq.Editor.removeBlock(block.uuid) 776 | } 777 | 778 | if (isSelectedState) { 779 | await sleep(20) 780 | await logseq.Editor.exitEditingMode() 781 | } 782 | } 783 | 784 | export function magicJoinCommand(independentMode: boolean) { 785 | const bulletPrefix = '* ' 786 | const numberingPrefixGetters = [ 787 | (n) => `${n}) `, 788 | (n) => `${numberToLetters(n)}) `, 789 | (n) => `${numberToRoman(n)}) `, 790 | ] 791 | 792 | return joinBlocksCommand( 793 | independentMode, 794 | (content, level, children, root) => { 795 | if (!root) 796 | throw new Error('assertion') 797 | 798 | let numbering = 1 799 | function resolvePrefix(content: string, block: BlockEntity) { 800 | const prefix = block._prefixGetter(numbering) 801 | 802 | if (block._isOrdered) 803 | numbering++ 804 | else // start again on non-numbered child 805 | numbering = 1 806 | 807 | const filler = ' '.repeat(prefix.length) 808 | return prefix + content.replaceAll(/\n^/gm, '\n' + filler) 809 | } 810 | 811 | if (level == 0) 812 | content = resolvePrefix(content, root) 813 | 814 | 815 | const divider = '\n' 816 | const dividerParagraph = '\n\n' 817 | 818 | if (content && children.length !== 0) 819 | content += (level < 1 ? dividerParagraph : divider) 820 | 821 | 822 | content += children.map((child, index) => { 823 | child = resolvePrefix(child, root.children![index] as BlockEntity) 824 | 825 | let chosenDivider = (level < 1 ? dividerParagraph : divider) 826 | if (level === 0) { 827 | const childBlock = root.children![index] as BlockEntity 828 | const prevChildBlock = root.children![index - 1] as BlockEntity 829 | if (childBlock._isOrdered) { 830 | // reduce divider for items in ordered list 831 | chosenDivider = divider 832 | } 833 | else if (prevChildBlock && prevChildBlock._isOrdered) { 834 | // increase upper divider for the last item in ordered list 835 | child = divider + child 836 | } 837 | } 838 | 839 | return child + chosenDivider 840 | }).join('').trimEnd() 841 | 842 | if (level === 1) { 843 | // Logseq markdown syntax require additional new line after block with children 844 | // to display next block as a separate paragraph 845 | if (children.length !== 0) 846 | content += '\n' 847 | } 848 | 849 | return content 850 | }, 851 | (content, level, block, parent) => { 852 | const properties = block.properties ?? {} 853 | const isOrdered = properties[PropertiesUtils.numberingProperty] === 'number' 854 | 855 | // there shouldn't be a numbering after joining 856 | delete properties[PropertiesUtils.numberingProperty] 857 | 858 | if (isOrdered) { 859 | const type = ( 860 | (parent?._numberingType + 1) || 0 861 | ) % numberingPrefixGetters.length 862 | 863 | // don't save type for level 0 as the root item moves to the child level 864 | // and they need the one level of numbering 865 | if (level !== 0) 866 | block._numberingType = type 867 | 868 | block._isOrdered = true 869 | block._prefixGetter = numberingPrefixGetters[type] 870 | } 871 | else { 872 | if (level <= 1) 873 | block._prefixGetter = (_) => '' 874 | else 875 | block._prefixGetter = (_) => bulletPrefix 876 | } 877 | 878 | return content 879 | }, 880 | ) 881 | } 882 | 883 | 884 | export async function updateBlocksCommand( 885 | callback: (content: string, level: number, block: BlockEntity, parent?: BlockEntity) => string, 886 | recursive: boolean = false, 887 | cleanPropertiesBefore: boolean = true, 888 | ) { 889 | let [ blocks, isSelectedState ] = await getChosenBlocks() 890 | if (blocks.length === 0) 891 | return 892 | 893 | blocks = unique(blocks, (b) => b.uuid) 894 | if (recursive) { 895 | blocks = await Promise.all( 896 | blocks.map(async (b) => { 897 | return ( 898 | await logseq.Editor.getBlock(b.uuid, {includeChildren: true}) 899 | )! 900 | }) 901 | ) 902 | blocks = filterOutChildBlocks(blocks) 903 | } 904 | 905 | if (blocks.length === 0) 906 | return 907 | 908 | if (!isSelectedState) 909 | blocks[0]._selectPosition = getEditingCursorSelection()! 910 | 911 | // it is important to check if any block in the tree has references 912 | // (Logseq replaces references with it's text) 913 | if (recursive) 914 | for (const block of blocks) { 915 | const blocksWithReferences = await getBlocksWithReferences(block) 916 | block._treeHasReferences = blocksWithReferences.length !== 0 917 | } 918 | 919 | for (const block of blocks) { 920 | // ensure .children is always an array 921 | if (!block.children) 922 | block.children = [] 923 | 924 | // skip child nodes in non-recursive mode 925 | if (!recursive) 926 | block.children = [] 927 | 928 | const newTree = walkBlockTree(block as WalkBlock, (b, level, p, data) => { 929 | const properties = PropertiesUtils.fromCamelCaseAll(data.node.properties) 930 | const propertiesOrder = PropertiesUtils.getPropertyNames(b.content) 931 | 932 | let content = b.content 933 | if (cleanPropertiesBefore) 934 | content = PropertiesUtils.deleteAllProperties(content) 935 | const newContent = callback(content, level, data.node, p as BlockEntity | undefined) 936 | if (content === newContent) 937 | data.leftIntact = true 938 | content = newContent 939 | 940 | if (cleanPropertiesBefore) 941 | for (const property of propertiesOrder) 942 | content += `\n${property}:: ${properties[property]}` 943 | 944 | return content 945 | }) 946 | 947 | if (block._treeHasReferences || block.children.length === 0) { 948 | walkBlockTreeAsync(newTree, async (b, level) => { 949 | if (!b.data.leftIntact) 950 | await logseq.Editor.updateBlock(b.data.node.uuid, b.content) 951 | }) 952 | } else { 953 | await insertBatchBlockBefore(block, newTree) 954 | await logseq.Editor.removeBlock(block.uuid) 955 | } 956 | } 957 | 958 | if (isSelectedState) { 959 | await sleep(20) 960 | await logseq.Editor.exitEditingMode() 961 | } else { 962 | await sleep(20) 963 | const sole = blocks[0] 964 | setEditingCursorSelection(sole._selectPosition[0], sole._selectPosition[1]) 965 | } 966 | } 967 | 968 | export function trimLinePunctuation(content, level, block, parent) { 969 | return content 970 | .replaceAll(/[.;,](?=\n|$)/g, '') 971 | } 972 | 973 | export function removeNewLines(content, level, block, parent) { 974 | return content 975 | .replaceAll(/\n+/g, '\n') 976 | .replaceAll(/(?<=[^\S\n])\n/g, '') // remove \n when there are spaces before 977 | .replaceAll(/(? m[0].toUpperCase() + m.slice(1).toLowerCase(), 993 | ) 994 | } 995 | 996 | export function titleCaseSentences(content, level, block, parent) { 997 | return content 998 | .toLowerCase() 999 | .replace( // first letter of text 1000 | /^([^\p{Lowercase_Letter}]*)(\p{Lowercase_Letter})/iu, 1001 | (m, gap, letter) => gap + letter.toUpperCase(), 1002 | ) 1003 | .replaceAll( // first letter after end of sentence 1004 | /(?<=[.!?…]\s+)\p{Lowercase_Letter}/giu, 1005 | (m) => m.toUpperCase(), 1006 | ) 1007 | } 1008 | 1009 | export function removeHTML(content, level, block, parent) { 1010 | return content 1011 | .replaceAll(/<\w+\s*[^>]*>/g, '') 1012 | .replaceAll(/<\/\w+\s*>/g, '') 1013 | } 1014 | 1015 | export function parseYoutubeTimestamp(content, level, block, parent) { 1016 | function fromTimestamp(ts) { 1017 | const time = parseInt(ts) 1018 | 1019 | const iso = new Date(time * 1000).toISOString() 1020 | // '1970-01-01T00:14:25.000Z' 1021 | 1022 | if (time < 3600) // MM:SS 1023 | return iso.substring(14, 19) 1024 | else // HH:MM:SS 1025 | return iso.substring(11, 19) 1026 | } 1027 | 1028 | const replacer = (m, h, ts) => `{{youtube-timestamp ${fromTimestamp(ts)}}}` 1029 | return content 1030 | .replaceAll( 1031 | /\[(\d+:)?\d+:\d+\]\(https:\/\/www\.youtube\.com\/watch\?.+?&t=(\d+)s\)/g, 1032 | replacer, 1033 | ) 1034 | .replaceAll( 1035 | /\[(\d+:)?\d+:\d+\]\(https:\/\/www\.youtube\.com\/watch\?t=(\d+)s.*?\)/g, 1036 | replacer, 1037 | ) 1038 | } 1039 | -------------------------------------------------------------------------------- /src/commands/magic_markup.ts: -------------------------------------------------------------------------------- 1 | import '@logseq/libs' 2 | import { BlockEntity } from '@logseq/libs/dist/LSPlugin' 3 | 4 | 5 | class MarkUp implements Iterable<[string, string]> { 6 | public wrap: [string, string] 7 | public alternativeWrap: [string, string] 8 | public unwrap: [string, string][] 9 | 10 | public wrappedWith?: string 11 | public alternativeWrapWhenMatch?: RegExp 12 | 13 | constructor({ wrap, unwrap, alternativeWrapWhenMatch, alternativeWrapIndex }: { 14 | wrap: [string, string] 15 | unwrap: ([string, string] | null)[] 16 | alternativeWrapWhenMatch?: RegExp 17 | alternativeWrapIndex?: number 18 | }) { 19 | this.wrap = wrap 20 | this.unwrap = unwrap.map((item) => item === null ? wrap : item) 21 | if (!this.unwrap.includes(wrap)) 22 | this.unwrap.splice(0, 0, wrap) 23 | this.wrappedWith = undefined 24 | this.alternativeWrapWhenMatch = alternativeWrapWhenMatch 25 | this.alternativeWrap = this.unwrap[alternativeWrapIndex ?? 1] 26 | } 27 | 28 | [Symbol.iterator]() { 29 | let index = 0 30 | return { 31 | next: () => { 32 | return { 33 | done: index >= this.unwrap.length, 34 | value: this.unwrap[index++], 35 | } 36 | } 37 | } 38 | } 39 | getWrapFor(selection: string) { 40 | if (this.alternativeWrapWhenMatch && this.alternativeWrapWhenMatch.test(selection)) 41 | return this.alternativeWrap 42 | return this.wrap 43 | } 44 | getUnwrapFor(line: string, selectPosition: [number, number]): [string, string] | null { 45 | // Assertion: selectPosition should be already trimmed, so markup is always OUTside 46 | 47 | for (const markup of this.unwrap) 48 | if (MarkUp.isMarkedUpWith(line, selectPosition, markup)) 49 | return markup 50 | return null 51 | } 52 | static isMarkedUpWith(line: string, selectPosition: [number, number], markup: [string, string]): boolean { 53 | const [start, end] = selectPosition 54 | const [frontMarkup, backMarkup] = markup 55 | 56 | if (start < frontMarkup.length) 57 | return false 58 | if (end + backMarkup.length > line.length) 59 | return false 60 | 61 | const charsBefore = line.slice(start - frontMarkup.length, start) 62 | const charsAfter = line.slice(end, end + backMarkup.length) 63 | return charsBefore === frontMarkup && charsAfter === backMarkup 64 | } 65 | } 66 | 67 | export const MARKUP: {[type: string]: MarkUp } = { 68 | bold: new MarkUp({ 69 | wrap: ['**', '**'], 70 | unwrap: [['', '']] }), 71 | italics: new MarkUp({ 72 | wrap: ['_', '_'], 73 | unwrap: [['*', '*'], ['', '']] }), 74 | strikethrough: new MarkUp({ 75 | wrap: ['~~', '~~'], 76 | unwrap: [['', '']] }), 77 | highlight: new MarkUp({ 78 | wrap: ['==', '=='], 79 | unwrap: [['', '']] }), 80 | underline: new MarkUp({ 81 | wrap: ['', ''], 82 | unwrap: [['', '']] }), 83 | code: new MarkUp({ 84 | wrap: ['`', '`'], 85 | unwrap: [['', '']] }), 86 | ref: new MarkUp({ 87 | wrap: ['[[', ']]'], 88 | unwrap: [['#[[', ']]'], null, ['#', '']] }), 89 | tag: new MarkUp({ 90 | wrap: ['#', ''], 91 | unwrap: [['#[[', ']]'], ['[[', ']]']], 92 | alternativeWrapWhenMatch: /\s+/, 93 | alternativeWrapIndex: 1, }), 94 | } 95 | 96 | const TRIM_BEGIN = [ 97 | /^\s+/, // spaces at the beginning 98 | 99 | /^#{1,6} /, // headings 100 | 101 | /^\s*[-+*] /, // list item 102 | /^\s*[-+*] \[.\] /, // list item with markdown task 103 | /^>/, // quote 104 | 105 | // logseq tasks 106 | /^(LATER|TODO) (\[#(A|B|C)\])?/, 107 | /^(NOW|DOING) (\[#(A|B|C)\])?/, 108 | /^DONE (\[#(A|B|C)\])?/, 109 | /^(WAIT|WAITING) (\[#(A|B|C)\])?/, 110 | /^(CANCELED|CANCELLED) (\[#(A|B|C)\])?/, 111 | 112 | /^\[#(A|B|C)\]/, // non-task blocks with priorities 113 | 114 | /^\s*[^\s:;,^@#~"`/|\\(){}[\]]+:: /u, // property without value 115 | /^\s*DEADLINE: <[^>]+>$/, 116 | /^\s*SCHEDULED: <[^>]+>$/, 117 | ] 118 | const TRIM_BEGIN_SELECTION_ADDITION = [ 119 | /^\s*[^\s:;,^@#~"`/|\\(){}[\]]+:: .*$/u, // property with value 120 | ] 121 | const TRIM_BEFORE: (string | RegExp)[] = [ 122 | /^\(\([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\)\)/, // block ref 123 | /^{{\w+.*?}}/, // macro call 124 | 125 | /^\s/, 126 | ] 127 | const TRIM_AFTER: (string | RegExp)[] = [ 128 | /\!\[.*?\]\(.+?\){.*?}$/, // link to image 129 | /\!\[.*?\]\(.+?\)$/, // link to image 130 | 131 | // /\]\(.+?\)$/, // link to page 132 | /\]\(\[\[.+?\]\]\)$/, // link to page 133 | 134 | /\]\(\(\([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\)\)\)$/, // link to block 135 | /\(\([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\)\)$/, // block ref 136 | 137 | /{{\w+.*?}}$/, // macro call 138 | 139 | /\s$/, 140 | 141 | /\]\($/, // to not break markdown links 142 | ] 143 | const EXPAND_WHEN_OUTSIDE = [ 144 | // this is OR groups 145 | // order is expansion direction 146 | 147 | [ // order is exceptional priority 148 | ['$', ''], 149 | ['', '$'], 150 | ['', '€'], 151 | ], 152 | 153 | [ 154 | ['\"', '\"'], 155 | ["'", "'"], 156 | ["«", "»"], 157 | ], 158 | 159 | [ 160 | ['#[[', ']]'], 161 | ['#', ''], 162 | ['[[', ']]'], 163 | ], 164 | ] 165 | 166 | 167 | function trim(line: string, markup: MarkUp, selectPosition: [number, number], isSelectionMode: boolean) { 168 | let [start, end] = selectPosition 169 | 170 | let trimBegin = TRIM_BEGIN 171 | if (!isSelectionMode) 172 | trimBegin = TRIM_BEGIN_SELECTION_ADDITION.concat(trimBegin) 173 | 174 | // begin: once 175 | for (const re of trimBegin) { 176 | const m = line.match(re) 177 | if (m) { 178 | const match = m[0] 179 | start = Math.max(start, match.length) 180 | break 181 | } 182 | } 183 | 184 | let selection = line.slice(start, end) 185 | 186 | const trimBefore = Array.from(TRIM_BEFORE) 187 | const trimAfter = Array.from(TRIM_AFTER) 188 | 189 | for (const [frontMarkup, backMarkup] of markup) { 190 | trimBefore.push(frontMarkup) 191 | trimAfter.push(backMarkup) 192 | } 193 | 194 | // before 195 | while (true) { 196 | let wasTrimmed = false 197 | trimBefore.forEach(strOrRE => { 198 | if (!strOrRE) 199 | return 200 | 201 | if (typeof strOrRE === 'string') { 202 | if (selection.startsWith(strOrRE)) { 203 | selection = selection.slice(strOrRE.length) 204 | start += strOrRE.length 205 | wasTrimmed = true 206 | } 207 | } else { 208 | const m = strOrRE.exec(selection) 209 | if (m) { 210 | selection = selection.slice(m[0].length) 211 | start += m[0].length 212 | wasTrimmed = true 213 | } 214 | } 215 | }) 216 | if (!wasTrimmed || selection.length === 0) 217 | break 218 | } 219 | 220 | // after 221 | while (true) { 222 | let wasTrimmed = false 223 | trimAfter.forEach((strOrRE) => { 224 | if (!strOrRE) 225 | return 226 | 227 | if (typeof strOrRE === 'string') { 228 | if (selection.endsWith(strOrRE)) { 229 | selection = selection.slice(0, -strOrRE.length) 230 | end -= strOrRE.length 231 | wasTrimmed = true 232 | } 233 | } else { 234 | const m = strOrRE.exec(selection) 235 | if (m) { 236 | selection = selection.slice(0, -m[0].length) 237 | end -= m[0].length 238 | wasTrimmed = true 239 | } 240 | } 241 | }) 242 | if (!wasTrimmed || !selection.length) 243 | break 244 | } 245 | 246 | selectPosition[0] = start 247 | selectPosition[1] = end 248 | } 249 | 250 | function wordAtPosition(line: string, position: number) { 251 | const wordLeftRegexp = /(?!_)[\p{Letter}\p{Number}'_-]*$/u 252 | const wordRightRegexp = /^[\p{Letter}\p{Number}'_-]*(? { 279 | for (const pair of group) { 280 | const [L, R] = pair 281 | 282 | let skipPair = false 283 | for (const [frontMarkup, backMarkup] of markup) 284 | if (L === frontMarkup || R === backMarkup) { 285 | // allow undoing of the syntax 286 | skipPair = true 287 | break 288 | } 289 | if (skipPair) 290 | break 291 | 292 | const trimLastSpace = Boolean(pair[2]) 293 | 294 | if (MarkUp.isMarkedUpWith(line, [start, end], [L, R])) { 295 | start -= L.length 296 | end += R.length 297 | setSelection() 298 | break 299 | } 300 | } 301 | }) 302 | } 303 | 304 | function applyMarkup(line: string, markup: MarkUp, selectPosition?: [number, number]): string { 305 | if (!line && !selectPosition) 306 | return '' 307 | 308 | const isSelectionMode = Boolean(selectPosition) 309 | if (!selectPosition) 310 | selectPosition = [0, line.length] 311 | 312 | trim(line, markup, selectPosition, isSelectionMode) 313 | if (isSelectionMode) 314 | expand(line, markup, selectPosition) 315 | 316 | let [start, end] = selectPosition 317 | if (start > end) 318 | return line // no changes 319 | 320 | if (start === end) { 321 | if (!isSelectionMode) 322 | return line 323 | } 324 | 325 | let selection = line.slice(start, end) 326 | const wrappedWith = markup.getUnwrapFor(line, selectPosition) 327 | if (wrappedWith) { 328 | const [frontMarkup, backMarkup] = wrappedWith 329 | selectPosition[0] -= frontMarkup.length 330 | selectPosition[1] -= frontMarkup.length 331 | return line.slice(0, start - frontMarkup.length) + selection + line.slice(end + backMarkup.length) 332 | } else { 333 | const [frontMarkup, backMarkup] = markup.getWrapFor(selection) 334 | selectPosition[0] += frontMarkup.length 335 | selectPosition[1] += frontMarkup.length 336 | return line.slice(0, start) + frontMarkup + selection + backMarkup + line.slice(end) 337 | } 338 | } 339 | 340 | export function magicWrap(block: BlockEntity, content: string, markup: MarkUp) { 341 | if (!block._selectPosition) 342 | return content.split('\n').map((line) => applyMarkup(line, markup)).join('\n') 343 | 344 | let lineStartPosition = 0 345 | let newLineStartPosition = 0 346 | 347 | const selectStart = block._selectPosition[0] 348 | const selectEnd = block._selectPosition[1] 349 | const selectReversedEnd = content.length - selectEnd 350 | 351 | const wrapped = content.split('\n').map((line, index) => { 352 | if (index !== 0) { 353 | // \n sign 354 | lineStartPosition++ 355 | newLineStartPosition++ 356 | } 357 | 358 | const lineEndPosition = lineStartPosition + line.length 359 | 360 | // line completely before OR after selection OR on the edge 361 | if ( 362 | lineEndPosition < selectStart || 363 | selectEnd < lineStartPosition || 364 | lineEndPosition === selectStart && selectEnd !== selectStart || 365 | lineStartPosition === selectEnd && selectEnd !== selectStart 366 | ) { 367 | lineStartPosition += line.length 368 | newLineStartPosition += line.length 369 | return line 370 | } 371 | 372 | let wholeLine = false // line inside selection 373 | if (selectStart < lineStartPosition && lineEndPosition < selectEnd) 374 | wholeLine = true 375 | 376 | const selectPositionLine: [number, number] = [ 377 | Math.max(selectStart - lineStartPosition, 0), 378 | Math.min(selectEnd - lineStartPosition, line.length), 379 | ] 380 | const newLine = applyMarkup(line, markup, wholeLine ? undefined : selectPositionLine) 381 | 382 | if (!wholeLine) { 383 | // first line of selection 384 | if (lineStartPosition <= selectStart && selectStart <= lineEndPosition) { 385 | block._selectPosition[0] = newLineStartPosition + selectPositionLine[0] 386 | } 387 | // last line of selection 388 | if (lineStartPosition <= selectEnd && selectStart <= lineEndPosition) { 389 | block._selectPosition[1] = newLineStartPosition + selectPositionLine[1] 390 | } 391 | } 392 | 393 | lineStartPosition += line.length 394 | newLineStartPosition += newLine.length 395 | return newLine 396 | }).join('\n') 397 | 398 | return wrapped 399 | } 400 | 401 | export function magicQuotes(block: BlockEntity, content: string, quotes: string) { 402 | const wrap: [string, string] = [quotes[0], quotes[1]] 403 | const unwrap: ([string, string] | null)[] = [ 404 | ['«', '»'], ['"', '"'], ["'", "'"], ['“', '“'], ['‘', '‘']] 405 | 406 | for (const [i, pair] of Object.entries(unwrap)) { 407 | if (pair && pair[0] == wrap[0] && pair[1] == wrap[1]) { 408 | unwrap.splice(Number(i), 1) 409 | break 410 | } 411 | } 412 | 413 | console.log('TRACING', {wrap, unwrap}) 414 | 415 | const quotesMarkup = new MarkUp({wrap, unwrap}) 416 | return magicWrap(block, content, quotesMarkup) 417 | } 418 | -------------------------------------------------------------------------------- /src/css/border_view.css: -------------------------------------------------------------------------------- 1 | /* border view: borders around blocks */ 2 | /* (based on logtools) */ 3 | 4 | .ls-block[data-refs-self*='".border"'] { 5 | box-shadow: 2px 2px 3px #0000003b; 6 | border: 1px solid var(--ls-border-color); 7 | border-radius: var(--ls-border-radius-medium); 8 | background-color: var(--ls-primary-background-color); 9 | margin-right: 0.5em !important; 10 | margin-bottom: 0.2em !important; 11 | margin-left: 0.5em !important; 12 | padding: 0.6em 1em 0.7em 0em !important 13 | } 14 | .ls-block[data-refs-self*='".border"'].selected { 15 | background-color: var(--ls-block-highlight-color); 16 | } 17 | 18 | .ls-block[data-refs-self*='".border"'] > .block-main-container { 19 | /* - ls-block::margin-left - ls-block::border-width */ 20 | margin-left: calc(-0.5em - 1px) !important; 21 | } 22 | .ls-block[data-refs-self*='".border"'] > .block-children-container { 23 | /* block-children-container::margin-left - ls-block::margin-left - ls-block::border-width */ 24 | margin-left: calc(29px - 0.5em - 1px) !important; 25 | } 26 | 27 | .ls-block[data-refs-self*='".border-child"'] > .block-children-container > .block-children > .ls-block { 28 | box-shadow: 2px 2px 3px #0000003b; 29 | border: 1px solid var(--ls-border-color); 30 | border-radius: var(--ls-border-radius-low); 31 | background-color: var(--ls-tertiary-background-color); 32 | margin: 0.5em 1em 0.5em 0em !important; 33 | padding: 0.5em 1em 0.5em 0em !important; 34 | } 35 | .ls-block[data-refs-self*='".border-child"'] > .block-children-container > .block-children > .ls-block.selected { 36 | background-color: var(--ls-block-highlight-color); 37 | } 38 | .ls-block[data-refs-self*='".border-child"'] > .block-children-container > .block-children { 39 | border-left-width: 0px !important; 40 | } 41 | -------------------------------------------------------------------------------- /src/css/columns_view.css: -------------------------------------------------------------------------------- 1 | /* columns view */ 2 | /* (based on logtools & awesome-content) */ 3 | 4 | /* .columns-N: split exactly by N columns */ 5 | 6 | .ls-block[data-refs-self*='".columns-2"'] > .block-children-container > .block-children { 7 | column-count: 2; 8 | width: 100%; 9 | } 10 | .ls-block[data-refs-self*='".columns-3"'] > .block-children-container > .block-children { 11 | column-count: 3; 12 | width: 100%; 13 | } 14 | .ls-block[data-refs-self*='".columns-4"'] > .block-children-container > .block-children { 15 | column-count: 4; 16 | width: 100%; 17 | } 18 | .ls-block[data-refs-self*='".columns-5"'] > .block-children-container > .block-children { 19 | column-count: 5; 20 | width: 100%; 21 | } 22 | .ls-block[data-refs-self*='".columns-6"'] > .block-children-container > .block-children { 23 | column-count: 6; 24 | width: 100%; 25 | } 26 | 27 | 28 | /* .columns: split by children count */ 29 | 30 | .ls-block[data-refs-self*='".columns"'] > .block-children-container { 31 | width: 100%; 32 | } 33 | .ls-block[data-refs-self*='".columns"'] > .block-children-container > .block-children { 34 | display: inline-flex; 35 | position: relative; 36 | overflow-x: auto; 37 | overflow-y: hidden; 38 | } 39 | .ls-block[data-refs-self*='".columns"'] > .block-children-container > .block-children > .ls-block { 40 | display: inline-block; 41 | padding: 0; 42 | 43 | width: inherit; 44 | min-width: 200px; 45 | margin-right: 10px; 46 | } 47 | 48 | /* remove left border */ 49 | .ls-block[data-refs-self*='".columns"'] > .block-children-container > .block-children > .ls-block, 50 | .ls-block[data-refs-self*='".columns-fit"'] > .block-children-container > .block-children > .ls-block { 51 | margin-left: 0px; 52 | } 53 | .ls-block[data-refs-self*='".columns"'] > .block-children-container > .block-children, 54 | .ls-block[data-refs-self*='".columns-fit"'] > .block-children-container > .block-children { 55 | border-left-width: 0px !important; 56 | } 57 | 58 | 59 | /* .columns-fit: fit width of the columns */ 60 | 61 | .ls-block[data-refs-self*='".columns-fit"'] > .block-children-container > .block-children > .ls-block { 62 | min-width: 100px; 63 | } 64 | [data-refs-self*='".columns-fit"'] > .block-children-container > .block-children { 65 | display: inline-flex; 66 | position: relative; 67 | border: 0; 68 | } 69 | [data-refs-self*='".columns-fit"'] .pr-2 { 70 | padding: 0; 71 | } 72 | -------------------------------------------------------------------------------- /src/css/gallery_view.css: -------------------------------------------------------------------------------- 1 | /* gallery view: for images */ 2 | /* (based on logtools) */ 3 | 4 | [data-refs-self*='".gallery'] > .block-children-container { 5 | width: 100%; 6 | } 7 | 8 | [data-refs-self*='".gallery'] > .block-children-container > .block-children { 9 | display: inline-flex; 10 | margin: 0px 0px 0px 10px; 11 | border-left: none; 12 | } 13 | [data-refs-self*='".gallery'] > .block-children-container > .block-children > .ls-block { 14 | margin: 0px 10px 0px 0px; 15 | min-width: 80px; 16 | } 17 | 18 | /* changing content sizes */ 19 | [data-refs-self*='".gallery'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper { 20 | width: 100%; 21 | } 22 | [data-refs-self*='".gallery'] > .block-children-container > .block-children > .ls-block > .block-children-container { 23 | margin-left: -10px; 24 | } 25 | 26 | /* hide unnecessary parts */ 27 | [data-refs-self*='".gallery'] > .block-children-container .block-content .asset-container { 28 | margin-top: 0rem; 29 | } 30 | 31 | [data-refs-self*='".gallery'] > .block-children-container > .block-children > .ls-block > div { 32 | padding-right: 0; 33 | } 34 | [data-refs-self*='".gallery'] > .block-children-container > .block-children > .ls-block > div > .block-control-wrap { 35 | display: none; 36 | } 37 | [data-refs-self*='".gallery'] > .block-children-container > .block-children > .ls-block > .block-children-container > .block-children-left-border { 38 | display: none; 39 | } 40 | [data-refs-self*='".gallery'] > .block-children-container > .block-children > .ls-block > .block-children-container > .block-children { 41 | border-left: 0px; 42 | } 43 | 44 | 45 | /* in case fixed width or height (note «-») */ 46 | [data-refs-self*='".gallery-'] > .block-children-container > .block-children > .ls-block { 47 | min-width: unset; 48 | } 49 | [data-refs-self*='".gallery-'] > .block-children-container > .block-children { 50 | flex-wrap: wrap; 51 | } 52 | 53 | 54 | /* .gallery-wN: force width of the columns */ 55 | [data-refs-self*='".gallery-w1"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 56 | [data-refs-self*='".gallery-w1"'] > .block-children-container > .block-children > .ls-block img { 57 | width: 100px !important; 58 | } 59 | [data-refs-self*='".gallery-w2"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 60 | [data-refs-self*='".gallery-w2"'] > .block-children-container > .block-children > .ls-block img { 61 | width: 120px !important; 62 | } 63 | [data-refs-self*='".gallery-w3"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 64 | [data-refs-self*='".gallery-w3"'] > .block-children-container > .block-children > .ls-block img { 65 | width: 150px !important; 66 | } 67 | [data-refs-self*='".gallery-w4"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 68 | [data-refs-self*='".gallery-w4"'] > .block-children-container > .block-children > .ls-block img { 69 | width: 200px !important; 70 | } 71 | [data-refs-self*='".gallery-w5"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 72 | [data-refs-self*='".gallery-w5"'] > .block-children-container > .block-children > .ls-block img { 73 | width: 250px !important; 74 | } 75 | [data-refs-self*='".gallery-w6"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 76 | [data-refs-self*='".gallery-w6"'] > .block-children-container > .block-children > .ls-block img { 77 | width: 300px !important; 78 | } 79 | [data-refs-self*='".gallery-w7"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 80 | [data-refs-self*='".gallery-w7"'] > .block-children-container > .block-children > .ls-block img { 81 | width: 350px !important; 82 | } 83 | [data-refs-self*='".gallery-w8"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 84 | [data-refs-self*='".gallery-w8"'] > .block-children-container > .block-children > .ls-block img { 85 | width: 400px !important; 86 | } 87 | [data-refs-self*='".gallery-w9"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 88 | [data-refs-self*='".gallery-w9"'] > .block-children-container > .block-children > .ls-block img { 89 | width: 800px !important; 90 | } 91 | 92 | 93 | /* .gallery-hN: force height of the columns */ 94 | [data-refs-self*='".gallery-h1"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 95 | [data-refs-self*='".gallery-h1"'] > .block-children-container > .block-children > .ls-block img { 96 | height: 100px !important; 97 | width: auto !important; 98 | } 99 | [data-refs-self*='".gallery-h2"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 100 | [data-refs-self*='".gallery-h2"'] > .block-children-container > .block-children > .ls-block img { 101 | height: 120px !important; 102 | width: auto !important; 103 | } 104 | [data-refs-self*='".gallery-h3"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 105 | [data-refs-self*='".gallery-h3"'] > .block-children-container > .block-children > .ls-block img { 106 | height: 150px !important; 107 | width: auto !important; 108 | } 109 | [data-refs-self*='".gallery-h4"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 110 | [data-refs-self*='".gallery-h4"'] > .block-children-container > .block-children > .ls-block img { 111 | height: 200px !important; 112 | width: auto !important; 113 | } 114 | [data-refs-self*='".gallery-h5"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 115 | [data-refs-self*='".gallery-h5"'] > .block-children-container > .block-children > .ls-block img { 116 | height: 250px !important; 117 | width: auto !important; 118 | } 119 | [data-refs-self*='".gallery-h6"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 120 | [data-refs-self*='".gallery-h6"'] > .block-children-container > .block-children > .ls-block img { 121 | height: 300px !important; 122 | width: auto !important; 123 | } 124 | [data-refs-self*='".gallery-h7"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 125 | [data-refs-self*='".gallery-h7"'] > .block-children-container > .block-children > .ls-block img { 126 | height: 350px !important; 127 | width: auto !important; 128 | } 129 | [data-refs-self*='".gallery-h8"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 130 | [data-refs-self*='".gallery-h8"'] > .block-children-container > .block-children > .ls-block img { 131 | height: 400px !important; 132 | width: auto !important; 133 | } 134 | [data-refs-self*='".gallery-h9"'] > .block-children-container > .block-children > .ls-block > div > .block-content-wrapper, 135 | [data-refs-self*='".gallery-h9"'] > .block-children-container > .block-children > .ls-block img { 136 | height: 600px !important; 137 | width: auto !important; 138 | } 139 | -------------------------------------------------------------------------------- /src/css/hide_dot_refs__hover.css: -------------------------------------------------------------------------------- 1 | /* hide tags, started with "." and show on hover */ 2 | 3 | 4 | a.tag[data-ref^="."] { 5 | display: none; 6 | } 7 | 8 | .block-main-container:hover a.tag[data-ref^="."] { 9 | display: unset; 10 | } 11 | -------------------------------------------------------------------------------- /src/css/hide_dot_refs__wrap.css: -------------------------------------------------------------------------------- 1 | /* hide tags, started with "." */ 2 | /* (based on logtools plugin) */ 3 | 4 | a.tag[data-ref^="."]::before { 5 | content: "…"; 6 | font-size: 0.7rem; 7 | line-height: 1.0rem; 8 | } 9 | 10 | a.tag[data-ref^="."]:hover::before { 11 | content: ""; 12 | padding-right: 0.7rem; 13 | } 14 | 15 | a.tag[data-ref^="."]:hover { 16 | line-height: 1.3rem; 17 | font-size: 1.0rem !important; 18 | transition: 0s; 19 | 20 | color: var(--ls-primary-text-color) !important; 21 | border: 1px solid var(--ls-primary-text-color); 22 | } 23 | 24 | a.tag[data-ref^="."] { 25 | font-family: monospace; 26 | font-size: 0px !important; 27 | transition: 0s; 28 | 29 | color: var(--ls-primary-text-color) !important; 30 | background-color: unset !important; 31 | 32 | border: none; 33 | border-radius: 3px; 34 | padding: 0 2px; 35 | } 36 | -------------------------------------------------------------------------------- /src/css/spare_blocks.css: -------------------------------------------------------------------------------- 1 | /* make a spare space between 1-level blocks */ 2 | .page:has(> div > div > div > .page-title) div.ls-block[level="1"]:not(:first-child), 3 | .page:has(> div > div > div > div > .journal-title) div.ls-block[level="1"]:not(:first-child) { 4 | margin-top: ${size}px; 5 | } 6 | .whiteboard div.ls-block[level="1"]:not(:first-child) { 7 | margin-top: 0px !important; 8 | } 9 | [data-refs-self*=".tabular"] > .block-children-container > .block-children > div.ls-block[level="1"]:not(:first-child) { 10 | margin-top: 0px !important; 11 | } 12 | .embed-page div.ls-block[level="1"]:not(:first-child) { 13 | margin-top: 0px !important; 14 | } 15 | .embed-block div.ls-block[level="1"]:not(:first-child) { 16 | margin-top: 0px !important; 17 | } 18 | #right-sidebar-container div.ls-block[level="1"]:not(:first-child) { 19 | margin-top: 0px !important; 20 | } 21 | .references-blocks div.ls-block[level="1"]:not(:first-child) { 22 | margin-top: 0px !important; 23 | } 24 | .references.page-unlinked div.ls-block[level="1"]:not(:first-child) { 25 | margin-top: 0px !important; 26 | } 27 | -------------------------------------------------------------------------------- /src/css/tabular_view.css: -------------------------------------------------------------------------------- 1 | /* Tana-like tabular view */ 2 | /* based on «Tabular Journals» by nmartin84 */ 3 | 4 | /* header */ 5 | .ls-block[data-refs-self*='".tabular"'] > .block-main-container > .block-content-wrapper > div > div > .block-content > .block-content-inner > div > :is(h1,h2) { 6 | border-bottom: none; 7 | margin: 0px; 8 | padding: 0px; 9 | } 10 | 11 | @keyframes hideAnimation { 12 | to { 13 | display: none; 14 | } 15 | } 16 | .ls-block[data-refs-self*='".tabular0"'] > .block-main-container { 17 | animation: hideAnimation 0s forwards; 18 | animation-delay: 1s; 19 | } 20 | .ls-block[data-refs-self*='".tabular0"'].selected > .block-main-container, 21 | .ls-block[data-refs-self*='".tabular0"'] > .block-main-container:hover, 22 | .ls-block[data-refs-self*='".tabular0"'] > .block-main-container:has(> .editor-wrapper), 23 | .ls-block[data-refs-self*='".tabular0"'] > .block-main-container:has(+ .block-children-container > .block-children-left-border:hover) { 24 | display: flex; 25 | animation: none; 26 | } 27 | 28 | .ls-block[data-refs-self*='".tabular0"'] > .block-children-container { 29 | margin-left: 0px; 30 | } 31 | 32 | /* table alignment */ 33 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children { 34 | border-left: 0px; 35 | 36 | display: flex; 37 | flex-direction: column; 38 | } 39 | 40 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block:first-child { 41 | border-top: 1px solid var(--ls-guideline-color); 42 | } 43 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block { 44 | display: flex; 45 | border-bottom: 1px solid var(--ls-guideline-color); 46 | padding: 0px; 47 | 48 | flex-grow: 1; 49 | width: 100%; 50 | } 51 | 52 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container { 53 | padding: 2px 0px; 54 | } 55 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container.items-baseline { 56 | align-items: center; 57 | } 58 | 59 | /* left: size & header */ 60 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container > .block-content-wrapper > div > div > .block-content > .block-content-inner { 61 | font-weight: 500; 62 | } 63 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container > .block-content-wrapper > div > div > .block-content > .block-content-inner, 64 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container > .block-content-wrapper > div > div > .block-content > .block-body, 65 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container > .editor-wrapper > .editor-inner { 66 | min-width: 165px; 67 | max-width: 165px; 68 | } 69 | 70 | /* left: plain text */ 71 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container > .block-content-wrapper > div > div > .block-content > .block-body { 72 | font-size: 85%; 73 | opacity: 0.7; 74 | font-weight: 400; 75 | } 76 | 77 | /* left: hide brackets */ 78 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container > .block-content-wrapper .page-reference .bracket { 79 | display: none; 80 | } 81 | 82 | /* right: adapt margins */ 83 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-children-container { 84 | margin-left: 0px; 85 | width: 100%; 86 | } 87 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-children-container > .block-children { 88 | padding-bottom: 10px; 89 | } 90 | 91 | /* sub tabular */ 92 | /* table alignment */ 93 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children { 94 | border-left: 1px solid var(--ls-guideline-color); 95 | padding-top: 0px; 96 | padding-bottom: 0px; 97 | } 98 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block:first-child { 99 | border-top: 0px; 100 | } 101 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block:last-child { 102 | margin-bottom: -1px; 103 | border-bottom: 0px; 104 | } 105 | /* left: size */ 106 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container > .block-content-wrapper > div > div > .block-content > .block-content-inner, 107 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container > .block-content-wrapper > div > div > .block-content > .block-body, 108 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-main-container > .editor-wrapper > .editor-inner { 109 | min-width: 115px; 110 | max-width: 115px; 111 | } 112 | /* right: adapt margins */ 113 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block > .block-children-container > .block-children { 114 | padding-bottom: 7px; 115 | } 116 | 117 | /* zero sub tabular */ 118 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular0"'] > .block-main-container { 119 | animation: none; /* to prevent animating when #.tabular0 is the root */ 120 | border-right: 1px solid var(--ls-guideline-color); 121 | } 122 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular0"'] > .block-children-container { 123 | margin-left: -1px; 124 | } 125 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular0"'] > .block-children-container > .block-children-left-border { 126 | display: none; 127 | } 128 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular0"'] > .block-children-container > .block-children { 129 | border-left: 0px; 130 | } 131 | .ls-block[data-refs-self*='".tabular'] > .block-children-container > .block-children > .ls-block[data-refs-self*='".tabular0"'] > .block-children-container > .block-children > .ls-block > .block-main-container { 132 | display: none; 133 | } 134 | -------------------------------------------------------------------------------- /src/entry.ts: -------------------------------------------------------------------------------- 1 | import '@logseq/libs' 2 | 3 | import { App } from './app' 4 | 5 | 6 | App(logseq) 7 | -------------------------------------------------------------------------------- /src/features.ts: -------------------------------------------------------------------------------- 1 | import { PropertiesUtils, escapeForRegExp, provideStyle, setNativeValue } from './utils' 2 | 3 | import spareBlocksStyle from './css/spare_blocks.css?inline' 4 | 5 | 6 | /** 7 | * Sublime Text-like double ⌘→ or ⌘← to move cursor to the end / start of the text area 8 | */ 9 | function improveCursorMovement_KeyDownListener(e: KeyboardEvent) { 10 | if (!e.target) 11 | return 12 | 13 | const target = e.target as HTMLInputElement 14 | if (target.tagName !== 'TEXTAREA') 15 | return 16 | 17 | if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(e.key)) 18 | return 19 | 20 | 21 | const homeEvent = ( 22 | (e.key === 'ArrowLeft' && e.metaKey && !e.altKey && !e.ctrlKey) || 23 | (e.key === 'Home') 24 | ) 25 | const endEvent = ( 26 | (e.key === 'ArrowRight' && e.metaKey && !e.altKey && !e.ctrlKey) || 27 | (e.key === 'End') 28 | ) 29 | 30 | if (target.selectionEnd === 0 || target.selectionStart === target.value.length) 31 | return 32 | 33 | if (endEvent && target.value[target.selectionEnd as number] === '\n') { 34 | if (e.shiftKey) 35 | target.selectionEnd = -1 36 | else 37 | target.selectionStart = -1 38 | } else if (homeEvent && target.value[(target.selectionStart as number) - 1] === '\n') 39 | target.selectionStart = 0 40 | 41 | // @ts-expect-error 42 | target.scrollIntoViewIfNeeded() 43 | } 44 | export function improveCursorMovementFeature(toggle: boolean) { 45 | if (toggle) 46 | parent.document.addEventListener('keydown', improveCursorMovement_KeyDownListener) 47 | else 48 | parent.document.removeEventListener('keydown', improveCursorMovement_KeyDownListener) 49 | } 50 | 51 | /** 52 | * TAB-trigger and access current page name on Search 53 | */ 54 | async function improveSearch_KeyDownListener(e: KeyboardEvent) { 55 | if (!e.target) 56 | return 57 | 58 | const target = e.target as HTMLInputElement 59 | if (target.tagName !== 'INPUT' || target.id !== 'search') 60 | return 61 | 62 | if (!['ArrowLeft', 'Tab'].includes(e.key)) 63 | return 64 | 65 | if ( !( 66 | !e.altKey && !e.shiftKey && !e.metaKey && !e.ctrlKey 67 | )) 68 | return 69 | 70 | if (e.key === 'ArrowLeft') { 71 | if (!target.value) { 72 | e.preventDefault() 73 | const page = await logseq.Editor.getCurrentPage() 74 | if (page) 75 | setNativeValue(target, page.originalName, true) 76 | } 77 | return 78 | } 79 | 80 | e.preventDefault() 81 | 82 | const panel = target.closest('.cp__cmdk')! 83 | const active = panel.querySelector('.\\!opacity-100') 84 | if (!active) 85 | return 86 | 87 | // skip blocks results 88 | if (!active.parentElement!.classList.contains('search-results')) 89 | return 90 | 91 | const label = active!.querySelector('.text-sm.font-medium')!.childNodes[0] as HTMLElement 92 | 93 | let text: string = '' 94 | if (label.tagName === 'DIV') { // page with alias 95 | const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT, null)! 96 | text = walker.nextNode()!.textContent! 97 | } else if (label.tagName === 'SPAN') { // normal page, command, create command 98 | text = label.textContent! 99 | } 100 | 101 | if (e.key === 'Tab') 102 | if (target.value.toLowerCase() !== text.toLowerCase()) 103 | setNativeValue(target, text, true) 104 | } 105 | export function improveSearchFeature(toggle: boolean) { 106 | if (toggle) 107 | parent.document.addEventListener('keydown', improveSearch_KeyDownListener, true) 108 | else 109 | parent.document.removeEventListener('keydown', improveSearch_KeyDownListener, true) 110 | } 111 | 112 | /** 113 | * Edit block on mouse click to reference with ALT/OPT pressed 114 | */ 115 | async function improveMouseRefClick_MouseUpListener(e: MouseEvent) { 116 | if (!e.target) 117 | return 118 | 119 | if (! (e.altKey && !e.shiftKey && !e.metaKey && !e.ctrlKey)) 120 | return 121 | 122 | let target = e.target as HTMLElement 123 | if (target.classList.contains('awLi-icon')) // Awesome Links: icon element 124 | target = target.parentElement! 125 | if (target.classList.contains('shml-anchor')) // Shorten My Links: anchor element 126 | target = target.parentElement! 127 | if (target.tagName !== 'A') 128 | return 129 | 130 | const isTag = target.classList.contains('tag') 131 | if (!target.classList.contains('page-ref') && !isTag) 132 | return 133 | 134 | const block = target.closest('.block-content') as HTMLElement 135 | // @ts-expect-error 136 | const uuid = block.attributes.blockid.value 137 | if (!uuid) 138 | return 139 | 140 | e.stopPropagation() 141 | 142 | // get block content 143 | let content = (await logseq.Editor.getBlock(uuid))!.content 144 | content = PropertiesUtils.deletePropertyFromString(content, 'id') 145 | // 146 | 147 | // get ref text and its width per char 148 | const textNode = target.childNodes[target.childNodes.length - 1] 149 | let text = textNode.textContent ?? '' 150 | 151 | const rect = target.getBoundingClientRect() 152 | let textWidth = rect.width 153 | for (const child of target.childNodes) { 154 | const e = child as HTMLElement 155 | if (child.nodeType !== document.TEXT_NODE) { 156 | textWidth -= e.getBoundingClientRect().width 157 | if (e.classList.contains('awLi-icon')) 158 | textWidth -= 5 // additional margin for icon 159 | } 160 | } 161 | 162 | const widthPerChar = textWidth / (text.length || 1) 163 | if (isTag) 164 | text = text.slice(1) 165 | // 166 | 167 | // find ref position in block 168 | const escapedText = escapeForRegExp(text) 169 | const refText = `\\[\\[${escapedText}\\]\\]` 170 | 171 | let startRefPos = -1 172 | if (isTag) { 173 | startRefPos = content.search(new RegExp(`#${escapedText}`, 'u')) 174 | 175 | if (startRefPos === -1) { 176 | startRefPos = content.search(new RegExp(`#${refText}`, 'u')) 177 | if (startRefPos !== -1) 178 | startRefPos += 2 // for [[ 179 | } 180 | 181 | if (startRefPos !== -1) 182 | startRefPos += 1 // for # 183 | } else { 184 | startRefPos = content.search(new RegExp(refText, 'u')) 185 | if (startRefPos !== -1) 186 | startRefPos += 2 // for [[ 187 | } 188 | 189 | if (startRefPos === -1) { 190 | // Shorten My Links plugin integration 191 | const shortenRefText = `/${escapedText}\\]\\]` 192 | startRefPos = content.search(new RegExp(shortenRefText, 'u')) 193 | if (startRefPos !== -1) 194 | startRefPos += 1 // for / 195 | } 196 | 197 | if (startRefPos === -1) 198 | startRefPos = 0 199 | // 200 | 201 | // find relative click position 202 | const relativeToEndPos = rect.right - e.x 203 | const charPosRight = Math.round(relativeToEndPos / widthPerChar) 204 | const charsOffset = Math.max(0, text.length - charPosRight) 205 | // 206 | 207 | await logseq.Editor.editBlock(uuid, {pos: startRefPos + charsOffset}) 208 | } 209 | export function improveMouseRefClick(toggle: boolean) { 210 | if (toggle) 211 | parent.document.addEventListener('mouseup', improveMouseRefClick_MouseUpListener, true) 212 | else 213 | parent.document.removeEventListener('mouseup', improveMouseRefClick_MouseUpListener, true) 214 | } 215 | 216 | /** 217 | * CSS: Make spare space between 1-level blocks 218 | */ 219 | export function spareBlocksFeature(size: number) { 220 | const key = 'spare-blocks' 221 | if (size > 0) 222 | provideStyle(key, spareBlocksStyle.replace('${size}', size.toString())) 223 | else 224 | provideStyle(key) 225 | } 226 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logseq' 2 | export * from './other' 3 | -------------------------------------------------------------------------------- /src/utils/logseq.ts: -------------------------------------------------------------------------------- 1 | import { BlockEntity, IBatchBlock, PageEntity } from '@logseq/libs/dist/LSPlugin.user' 2 | 3 | import { f, indexOfNth, p, sleep, unique } from './other' 4 | 5 | 6 | export async function getChosenBlocks(): Promise<[BlockEntity[], boolean]> { 7 | const selected = await logseq.Editor.getSelectedBlocks() 8 | if (selected) 9 | return [selected, true] 10 | 11 | const uuid = await logseq.Editor.checkEditing() 12 | if (!uuid) 13 | return [[], false] 14 | 15 | const editingBlock = await logseq.Editor.getBlock(uuid as string) as BlockEntity 16 | 17 | // to get ahead of Logseq block content saving process 18 | editingBlock.content = await logseq.Editor.getEditingBlockContent() 19 | 20 | return [ [editingBlock], false ] 21 | } 22 | 23 | /** 24 | * Sets the current editing block cursor position. 25 | * There is no need to check boundaries. 26 | * Negative indexing is supported. 27 | * 28 | * @param `pos`: new cursor position 29 | * @usage 30 | * setEditingCursorPosition(0) — set to the start 31 | * setEditingCursorPosition(-1) — set to the end 32 | * setEditingCursorPosition(-2) — set before the last char 33 | */ 34 | export function setEditingCursorPosition(pos: number) { 35 | return setEditingCursorSelection(pos, pos) 36 | } 37 | 38 | function adjustIndexForLength(i, len) { 39 | if (i > len) 40 | i = len 41 | if (i < (-len - 1)) 42 | i = -len - 1 43 | if (i < 0) 44 | i += len + 1 45 | return i 46 | } 47 | 48 | export function setEditingCursorSelection(start: number, end: number) { 49 | const editorElement = top!.document.getElementsByClassName('editor-wrapper')[0] as HTMLDivElement 50 | if (!editorElement) 51 | return false 52 | const textAreaElement = top!.document.getElementById( 53 | editorElement.id.replace(/^editor-/, '') 54 | ) as HTMLTextAreaElement 55 | if (!textAreaElement) 56 | return false 57 | 58 | const length = textAreaElement.value.length 59 | start = adjustIndexForLength(start, length) 60 | end = adjustIndexForLength(end, length) 61 | 62 | textAreaElement.selectionStart = start 63 | textAreaElement.selectionEnd = end 64 | return true 65 | } 66 | 67 | export function getEditingCursorSelection() { 68 | const editorElement = top!.document.getElementsByClassName('editor-wrapper')[0] as HTMLDivElement 69 | if (!editorElement) 70 | return null 71 | 72 | const textAreaElement = top!.document.getElementById( 73 | editorElement.id.replace(/^editor-/, '') 74 | ) as HTMLTextAreaElement 75 | if (!textAreaElement) 76 | return null 77 | 78 | return [textAreaElement.selectionStart, textAreaElement.selectionEnd] 79 | } 80 | 81 | 82 | export class PropertiesUtils { 83 | static readonly idProperty = 'id' 84 | static readonly headingProperty = 'heading' 85 | static readonly numberingProperty = 'logseq.orderListType' 86 | static readonly numberingProperty_ = 'logseq.order-list-type' 87 | 88 | // source: https://github.com/logseq/logseq/blob/master/deps/graph-parser/src/logseq/graph_parser/property.cljs#L81 89 | // logseq.* prefix need to be checked separately 90 | static readonly systemBlockProperties = [ 91 | 'id', 'heading', 'collapsed', 92 | 'created-at', 'created_at', 93 | 'updated-at', 'last-modified-at', 'last_modified_at', 94 | ] 95 | 96 | static propertyContentFormat = f`\n?^[^\\S]*${'name'}::.*$` 97 | static propertyRestrictedChars = '\\s:;,^@#~"`/|\\(){}[\\]' 98 | 99 | static toCamelCase(text: string): string { 100 | text = text.toLowerCase() 101 | text = text.replaceAll(/(?<=-)(\w)/g, (m, ch) => ch.toUpperCase()) 102 | text = text.replaceAll(/(?<=_)(\w)/g, (m, ch) => ch.toUpperCase()) 103 | text = text.replaceAll('-', '') 104 | text = text.replaceAll('_', '') 105 | if (text) 106 | text = text[0].toLowerCase() + text.slice(1) 107 | return text 108 | } 109 | static fromCamelCase(text: string): string { 110 | return text.replaceAll( 111 | /\p{Uppercase_Letter}\p{Lowercase_Letter}/gu, 112 | (m) => '-' + m.toLowerCase(), 113 | ) 114 | } 115 | static fromCamelCaseAll(properties: Record | undefined) { 116 | return Object.fromEntries( 117 | Object.entries(properties ?? {}) 118 | .map(([k, v]) => [PropertiesUtils.fromCamelCase(k), v]) 119 | ) 120 | } 121 | 122 | static hasProperty(blockContent: string, name: string): boolean { 123 | // case when properties in content use different style of naming 124 | // logseq-prop-name 125 | // logseq_prop_name 126 | // logseq_prop-name 127 | // all this names is the same for logseq 128 | for (const n of [name, name.replaceAll('-', '_'), name.replaceAll('_', '-')]) { 129 | const propRegexp = PropertiesUtils.propertyContentFormat({name: n}) 130 | const exists = new RegExp(propRegexp, 'gim').test(blockContent) 131 | if (exists) 132 | return true 133 | } 134 | return false 135 | } 136 | static deleteProperty(block: BlockEntity, name: string): void { 137 | const nameCamelCased = PropertiesUtils.toCamelCase(name) 138 | 139 | if (block.properties) 140 | delete block.properties[nameCamelCased] 141 | if (block.propertiesTextValues) 142 | delete block.propertiesTextValues[nameCamelCased] 143 | 144 | block.content = PropertiesUtils.deletePropertyFromString(block.content, name) 145 | } 146 | static deletePropertyFromString(content: string, name: string): string { 147 | // case when properties in content use different style of naming 148 | // logseq-prop-name 149 | // logseq_prop_name 150 | // logseq_prop-name 151 | // all this names is the same for logseq → we should erase all 152 | for (const n of [name, name.replaceAll('-', '_'), name.replaceAll('_', '-')]) { 153 | const propRegexp = PropertiesUtils.propertyContentFormat({name: n}) 154 | content = content.replaceAll(new RegExp(propRegexp, 'gim'), '') 155 | } 156 | return content 157 | } 158 | static deleteAllProperties(content: string): string { 159 | for (const name of PropertiesUtils.getPropertyNames(content)) 160 | content = PropertiesUtils.deletePropertyFromString(content, name) 161 | return content 162 | } 163 | static getPropertyNames(text: string): string[] { 164 | const propertyNames: string[] = [] 165 | const propertyLine = new RegExp(PropertiesUtils.propertyContentFormat({ 166 | name: `([^${PropertiesUtils.propertyRestrictedChars}]+)` 167 | }), 'gim') 168 | text.replaceAll(propertyLine, (m, name) => {propertyNames.push(name); return m}) 169 | return propertyNames 170 | } 171 | } 172 | 173 | /** 174 | * Scroll to block if it has disappeared from view 175 | */ 176 | export async function scrollToBlock(block: BlockEntity) { 177 | const position = (await logseq.Editor.getEditingCursorPosition())?.pos // editing mode? 178 | const view = await logseq.App.queryElementRect(`.ls-block[blockid="${block.uuid}"]`) 179 | if (view && (view.top < 0 || view.bottom > top!.window.innerHeight)) { 180 | const page = await logseq.Editor.getPage(block.page.id) as PageEntity 181 | logseq.Editor.scrollToBlockInPage(page.name, block.uuid) 182 | 183 | // .scrollToBlockInPage exists editing mode — return to it if necessary 184 | if (position) { 185 | await sleep(250) 186 | await logseq.Editor.editBlock(block.uuid, {pos: position}) 187 | } 188 | } 189 | } 190 | 191 | export async function ensureChildrenIncluded(node: BlockEntity): Promise { 192 | // @ts-expect-error 193 | if (node.children?.at(0)?.uuid) 194 | return node 195 | return (await logseq.Editor.getBlock(node.uuid, {includeChildren: true}))! 196 | } 197 | 198 | export async function getBlocksWithReferences(root: BlockEntity): Promise { 199 | const blocksWithPersistedID = findPropertyInTree(root as IBatchBlock, PropertiesUtils.idProperty) 200 | const blocksAndItsReferences = (await Promise.all( 201 | blocksWithPersistedID.map(async (b): Promise<[BlockEntity, Number[]]> => { 202 | const block = b as BlockEntity 203 | const references = await findBlockReferences(block.uuid) 204 | return [block, references] 205 | }) 206 | )) 207 | const blocksWithReferences = blocksAndItsReferences.filter(([b, rs]) => (rs.length !== 0)) 208 | return blocksWithReferences.map(([b, rs]) => { 209 | b._references = rs 210 | return b 211 | }) 212 | } 213 | 214 | export async function transformBlocksTreeByReplacing( 215 | root: BlockEntity, 216 | transformChildrenCallback: (blocks: BlockEntity[]) => BlockEntity[], 217 | ): Promise { 218 | root = await ensureChildrenIncluded(root) 219 | if (!root || !root.children || root.children.length === 0) 220 | return null // nothing to replace 221 | 222 | // METHOD: blocks removal to replace whole tree 223 | // but it is important to check if any block in the tree has references 224 | // (Logseq replaces references with it's text) 225 | const blocksWithReferences = await getBlocksWithReferences(root) 226 | if (blocksWithReferences.length !== 0) 227 | return null // blocks removal cannot be used 228 | 229 | const transformedBlocks = transformChildrenCallback(root.children as BlockEntity[]) 230 | walkBlockTree({content: '', children: transformedBlocks as IBatchBlock[]}, (b, level) => { 231 | b.properties = PropertiesUtils.fromCamelCaseAll(b.properties ?? {}) 232 | }) 233 | 234 | // root is the first block in page 235 | if (root.left.id === root.page.id) { 236 | const page = await logseq.Editor.getPage(root.page.id) 237 | await logseq.Editor.removeBlock(root.uuid) 238 | 239 | // logseq bug: cannot use sibling next to root to insert whole tree to a page 240 | // so insert root of a tree separately from children 241 | const properties = PropertiesUtils.fromCamelCaseAll(root.properties) 242 | let prepended = await logseq.Editor.insertBlock( 243 | page!.uuid, root.content, 244 | {properties, before: true, customUUID: root.uuid}, 245 | ) 246 | if (!prepended) { 247 | // logseq bug: for empty pages need to change `before: true → false` 248 | prepended = (await logseq.Editor.insertBlock( 249 | page!.uuid, root.content, 250 | {properties, before: false, customUUID: root.uuid}, 251 | ))! 252 | } 253 | 254 | await logseq.Editor.insertBatchBlock( 255 | prepended.uuid, transformedBlocks as IBatchBlock[], 256 | {before: false, sibling: false, keepUUID: true}, 257 | ) 258 | return prepended 259 | } 260 | 261 | // use root to insert whole tree at once 262 | const oldChildren = root.children 263 | root.children = transformedBlocks 264 | 265 | // root is the first child for its parent 266 | if (root.left.id === root.parent.id) { 267 | let parentRoot = (await logseq.Editor.getBlock(root.parent.id))! 268 | await logseq.Editor.removeBlock(root.uuid) 269 | await logseq.Editor.insertBatchBlock( 270 | parentRoot.uuid, root as IBatchBlock, 271 | {before: true, sibling: false, keepUUID: true}, 272 | ) 273 | 274 | // restore original object 275 | root.children = oldChildren 276 | 277 | parentRoot = (await logseq.Editor.getBlock(parentRoot.uuid, {includeChildren: true}))! 278 | return parentRoot.children![0] as BlockEntity 279 | } 280 | 281 | // root is not first child of parent and is not first block on page: it has sibling 282 | const preRoot = (await logseq.Editor.getPreviousSiblingBlock(root.uuid))! 283 | await logseq.Editor.removeBlock(root.uuid) 284 | await logseq.Editor.insertBatchBlock( 285 | preRoot.uuid, root as IBatchBlock, 286 | {before: false, sibling: true, keepUUID: true}, 287 | ) 288 | 289 | // restore original object 290 | root.children = oldChildren 291 | 292 | return (await logseq.Editor.getNextSiblingBlock(preRoot.uuid))! 293 | } 294 | 295 | export async function transformSelectedBlocksWithMovements( 296 | blocks: BlockEntity[], 297 | transformCallback: (blocks: BlockEntity[]) => BlockEntity[], 298 | ) { 299 | // METHOD: blocks movement 300 | 301 | // Logseq sorts selected blocks, so the first is the most upper one 302 | let insertionPoint = blocks[0] 303 | 304 | // Logseq bug: selected blocks can be duplicated (but sorted!) 305 | // just remove duplication 306 | blocks = unique(blocks, (b) => b.uuid) 307 | 308 | const transformed = transformCallback(blocks) 309 | for (const block of transformed) { 310 | // Logseq don't add movement to history if there was no movement at all 311 | // so we don't have to save API calls: just call .moveBlock on EVERY block 312 | await logseq.Editor.moveBlock(block.uuid, insertionPoint.uuid, {before: false}) 313 | insertionPoint = block 314 | } 315 | } 316 | 317 | export async function walkBlockTreeAsync( 318 | root: WalkBlock, 319 | callback: (b: WalkBlock, lvl: number, data?: any) => Promise, 320 | level: number = 0, 321 | ): Promise { 322 | const data = {node: root as IBatchBlock} 323 | return { 324 | data, 325 | content: (await callback(root, level, data)) ?? '', 326 | children: await Promise.all( 327 | (root.children || []).map( 328 | async (b) => await walkBlockTreeAsync(b, callback, level + 1) 329 | )) 330 | } 331 | } 332 | 333 | export type WalkBlock = IBatchBlock & {data?: any} 334 | export function walkBlockTree( 335 | root: WalkBlock, 336 | callback: (b: WalkBlock, lvl: number, parent?: WalkBlock, data?: any) => string | void, 337 | level: number = 0, 338 | parent?: WalkBlock, 339 | ): WalkBlock { 340 | const data = {node: root as IBatchBlock} 341 | const content = callback(root, level, parent, data) ?? '' 342 | return { 343 | data, 344 | content, 345 | children: (root.children || []).map( 346 | (b) => walkBlockTree(b, callback, level + 1, root) 347 | ), 348 | } 349 | } 350 | 351 | export function reduceBlockTree( 352 | root: WalkBlock, 353 | callback: (b: WalkBlock, lvl: number, children: string[], data?: any) => string, 354 | level: number = 0, 355 | ): string { 356 | const children = (root.children || []).map( 357 | (b) => reduceBlockTree(b as WalkBlock, callback, level + 1) 358 | ) 359 | return callback(root, level, children, root.data) ?? '' 360 | } 361 | 362 | export function findPropertyInTree(tree: IBatchBlock, propertyName: string): IBatchBlock[] { 363 | const found: IBatchBlock[] = [] 364 | walkBlockTree(tree, (node, level) => { 365 | if (PropertiesUtils.hasProperty(node.content, propertyName)) 366 | found.push(node) 367 | }) 368 | return found 369 | } 370 | export function checkPropertyExistenceInTree( 371 | tree: IBatchBlock, 372 | {skipRoot = false, onlyUser = true }: { skipRoot?: boolean, onlyUser?: boolean }, 373 | ): string[] { 374 | const found: string[] = [] 375 | walkBlockTree(tree, (node, level) => { 376 | if (skipRoot && level === 0) 377 | return 378 | for (const name of Object.keys(node.properties ?? {})) { 379 | if (!found.includes(name)) 380 | found.push(name) 381 | } 382 | }) 383 | if (onlyUser) 384 | return found 385 | .filter((p) => !PropertiesUtils.systemBlockProperties.includes(p)) 386 | .filter((p) => !/^logseq\..*/.test(p)) 387 | return found 388 | } 389 | 390 | export async function findBlockReferences(uuid: string): Promise { 391 | const results = await logseq.DB.datascriptQuery(` 392 | [:find (pull ?b [:db/id]) 393 | :where 394 | [?b :block/content ?c] 395 | [(clojure.string/includes? ?c "((${uuid}))")] 396 | ]`) 397 | if (!results) 398 | return [] 399 | return results.flat().map((item) => item.id) 400 | } 401 | 402 | export function filterOutChildBlocks(blocks: BlockEntity[]): BlockEntity[] { 403 | const filtered: BlockEntity[] = [] 404 | 405 | const uuids: string[] = [] 406 | for (const block of blocks) { 407 | if (uuids.includes(block.uuid)) 408 | continue 409 | 410 | walkBlockTree(block as IBatchBlock, (b, level) => { 411 | const block = b as BlockEntity 412 | if (!uuids.includes(block.uuid)) 413 | uuids.push(block.uuid) 414 | }) 415 | 416 | filtered.push(block) 417 | } 418 | 419 | return filtered 420 | } 421 | 422 | /** 423 | * Reason: logseq bug — `before: true` doesn't work for batch inserting 424 | */ 425 | export async function insertBatchBlockBefore( 426 | srcBlock: BlockEntity, 427 | blocks: IBatchBlock | IBatchBlock[], 428 | opts?: Partial<{ 429 | keepUUID: boolean; 430 | }> 431 | ) { 432 | // logseq bug: two space cut off from 2, 3, ... lines of all inserting blocks 433 | // so add fake two spaces to every line 434 | // issue: https://github.com/logseq/logseq/issues/10730 435 | let tree = blocks 436 | if (Array.isArray(blocks)) 437 | tree = {content: '', children: blocks} 438 | walkBlockTree(tree as IBatchBlock, (b, level) => { 439 | b.content = b.content.trim().replaceAll(/\n^/gm, '\n ')}) 440 | 441 | // first block in a page 442 | if (srcBlock.left.id === srcBlock.page.id) { 443 | // there is bug with first block in page: use pseudo block 444 | // issue: https://github.com/logseq/logseq/issues/10871 445 | const first = ( await logseq.Editor.insertBlock( 446 | srcBlock.uuid, 'ø', {before: true, sibling: true}) )! 447 | const result = await logseq.Editor.insertBatchBlock( 448 | first.uuid, blocks, {before: false, sibling: true, ...opts}) 449 | await logseq.Editor.removeBlock(first.uuid) 450 | return result 451 | } 452 | 453 | const prev = await logseq.Editor.getPreviousSiblingBlock(srcBlock.uuid) 454 | if (prev) { 455 | // special handling for numbering 456 | // issue: https://github.com/logseq/logseq/issues/10729 457 | let numbering = undefined 458 | let properties = {} 459 | if (prev.properties) { 460 | numbering = prev.properties[PropertiesUtils.numberingProperty] 461 | delete prev.properties[PropertiesUtils.numberingProperty] 462 | properties = PropertiesUtils.fromCamelCaseAll(prev.properties) 463 | } 464 | if (numbering) 465 | await logseq.Editor.removeBlockProperty(prev.uuid, PropertiesUtils.numberingProperty_) 466 | 467 | const inserted = await logseq.Editor.insertBatchBlock( 468 | prev.uuid, blocks, {before: false, sibling: true, ...opts}) 469 | 470 | if (numbering) 471 | await logseq.Editor.upsertBlockProperty(prev.uuid, PropertiesUtils.numberingProperty_, numbering) 472 | 473 | return inserted 474 | } 475 | 476 | // first block for parent 477 | const parent = ( await logseq.Editor.getBlock(srcBlock.parent.id) )! 478 | 479 | // special handling for numbering 480 | // issue: https://github.com/logseq/logseq/issues/10729 481 | let numbering = undefined 482 | let properties = {} 483 | if (parent.properties) { 484 | numbering = parent.properties[PropertiesUtils.numberingProperty] 485 | delete parent.properties[PropertiesUtils.numberingProperty] 486 | properties = PropertiesUtils.fromCamelCaseAll(parent.properties) 487 | } 488 | if (numbering) 489 | await logseq.Editor.removeBlockProperty(parent.uuid, PropertiesUtils.numberingProperty_) 490 | 491 | const inserted = await logseq.Editor.insertBatchBlock( 492 | parent.uuid, blocks, {before: true, sibling: false, ...opts}) 493 | 494 | if (numbering) 495 | await logseq.Editor.upsertBlockProperty(parent.uuid, PropertiesUtils.numberingProperty_, numbering) 496 | 497 | return inserted 498 | } 499 | 500 | export function setNativeValue(element, value, needToDispatch) { 501 | const valueSetter = Object.getOwnPropertyDescriptor(element, 'value')!.set! 502 | const prototype = Object.getPrototypeOf(element) 503 | const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')!.set! 504 | 505 | if (valueSetter && valueSetter !== prototypeValueSetter) 506 | prototypeValueSetter.call(element, value) 507 | else 508 | valueSetter.call(element, value) 509 | 510 | if (needToDispatch) 511 | element.dispatchEvent(new Event('input', {bubbles: true})) 512 | } 513 | 514 | export function provideStyle(key: string, style: string = '') { 515 | const emptyStyle = '/**/' 516 | if (!style) 517 | style = emptyStyle 518 | 519 | logseq.provideStyle({key, style}) 520 | return () => {logseq.provideStyle({key, style: emptyStyle})} 521 | } 522 | -------------------------------------------------------------------------------- /src/utils/other.ts: -------------------------------------------------------------------------------- 1 | import { logseq as packageInfo } from '../../package.json' 2 | 3 | 4 | export const isMacOS = navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 5 | export const isWindows = navigator.userAgent.toUpperCase().indexOf('WIN') >= 0 6 | 7 | 8 | /** 9 | * Tagged template printing function 10 | * @usage console.log(p`Hello, Logseq!`) 11 | * @usage console.debug(p``, {var}) 12 | **/ 13 | export function p(strings: any, ...values: any[]): string { 14 | const raw = String.raw({raw: strings}, ...values) 15 | const space = raw ? ' ' : '' 16 | return `#${packageInfo.id}:${space}${raw}` 17 | } 18 | 19 | /** 20 | * Format-string 21 | * @usage f`Hello, ${'name'}!`({name: 'Logseq'}) 22 | * @usage 23 | * const format = f`Hello, ${'name'}!` 24 | * format({name: 'Logseq'}) // => 'Hello, Logseq!' 25 | **/ 26 | export function f(strings: any, ...values: any[]): Function { 27 | return (format: {[i: string]: any}) => String.raw({raw: strings}, ...values.map(v => format[v])) 28 | } 29 | 30 | export function sleep(ms: number) { 31 | return new Promise(resolve => setTimeout(resolve, ms)) 32 | } 33 | 34 | /** 35 | * Count substrings in string 36 | */ 37 | export function countOf(string: string, substring: string): number { 38 | if (substring.length === 0) 39 | return 0 40 | 41 | const matchedCount = string.length - string.replaceAll(substring, '').length 42 | return matchedCount / substring.length 43 | } 44 | 45 | /** 46 | * Find index of Nth substring in string 47 | */ 48 | export function indexOfNth(string: string, substring: string, count: number = 1): number | null { 49 | if (count <= 0) 50 | throw new Error('count param should be positive') 51 | 52 | const realCount = countOf(string, substring) 53 | if (count > realCount) 54 | return null 55 | 56 | return string.split(substring, count).join(substring).length 57 | } 58 | 59 | /** 60 | * Remove duplicates 61 | */ 62 | export function unique(items: Array, keyFunction: (item: X) => any) { 63 | return items.filter((b, i, r) => { 64 | if (i === 0) 65 | return true 66 | return keyFunction(r[i - 1]) !== keyFunction(b) 67 | }) 68 | } 69 | 70 | 71 | export function reduceTextWithLength(text: string, length: number, suffix = '...') { 72 | if (text.length <= length) 73 | return text 74 | return text.substring(0, length).trimEnd() + suffix 75 | } 76 | 77 | export function escapeForRegExp(str: string) { 78 | const specials = [ 79 | '^', '$', 80 | '/', '.', '*', '+', '?', '|', 81 | '(', ')', '[', ']', '{', '}', '\\', 82 | ] 83 | 84 | const replacer = new RegExp('(\\' + specials.join('|\\') + ')', 'g') 85 | return str.replaceAll(replacer, '\\$1') 86 | } 87 | 88 | 89 | /** 90 | * source: https://stackoverflow.com/a/75643566 91 | */ 92 | export function numberToLetters(x: number) { 93 | if (x <= 0) 94 | return '' 95 | const letters = numberToLetters(Math.floor((x - 1) / 26)) + String.fromCharCode((x - 1) % 26 + 65) 96 | return letters.toLowerCase() 97 | } 98 | export function lettersToNumber(letters: string) { 99 | return letters.toLowerCase().split('').reduce((acc, val) => acc * 26 + val.charCodeAt(0) - 64, 0) 100 | } 101 | 102 | /** 103 | * source: https://stackoverflow.com/a/70844631 104 | */ 105 | const romanValues = { 106 | M: 1000, CM: 900, D: 500, CD: 400, 107 | C: 100, XC: 90, L: 50, XL: 40, 108 | X: 10, IX: 9, V: 5, IV: 4, 109 | I: 1, 110 | } 111 | export function numberToRoman(x: number = 0) { 112 | return Object.keys(romanValues).reduce((acc, key) => { 113 | while (x >= romanValues[key]) { 114 | acc += key 115 | x -= romanValues[key] 116 | } 117 | return acc 118 | }, '') 119 | } 120 | export function fromRoman(letters: string = '') { 121 | return Object.keys(romanValues).reduce((acc, key) => { 122 | while (letters.indexOf(key) === 0) { 123 | acc += romanValues[key]; 124 | letters = letters.substr(key.length); 125 | } 126 | return acc; 127 | }, 0); 128 | } 129 | -------------------------------------------------------------------------------- /src/views.ts: -------------------------------------------------------------------------------- 1 | import { provideStyle } from './utils' 2 | 3 | import tabularViewStyle from './css/tabular_view.css?inline' 4 | import borderViewStyle from './css/border_view.css?inline' 5 | import columnsViewStyle from './css/columns_view.css?inline' 6 | import galleryViewStyle from './css/gallery_view.css?inline' 7 | 8 | import hideDotRefs_WrapStyle from './css/hide_dot_refs__wrap.css?inline' 9 | import hideDotRefs_HoverStyle from './css/hide_dot_refs__hover.css?inline' 10 | 11 | 12 | /** 13 | * CSS: Hide references started with «.» 14 | */ 15 | export function hideDotRefs(wrapToDots: boolean, hideWhenNotHovered?: boolean) { 16 | const key = 'dot-refs' 17 | if (!wrapToDots) 18 | provideStyle(key) 19 | else if (hideWhenNotHovered) 20 | provideStyle(key, hideDotRefs_WrapStyle + '\n' + hideDotRefs_HoverStyle) 21 | else 22 | provideStyle(key, hideDotRefs_WrapStyle) 23 | } 24 | 25 | /** 26 | * CSS: Tabular view 27 | */ 28 | export function tabularView(toggle: boolean) { 29 | const key = 'tabular-view' 30 | if (toggle) 31 | provideStyle(key, tabularViewStyle) 32 | else 33 | provideStyle(key) 34 | } 35 | 36 | /** 37 | * CSS: Box view 38 | */ 39 | export function borderView(toggle: boolean) { 40 | const key = 'border-view' 41 | if (toggle) 42 | provideStyle(key, borderViewStyle) 43 | else 44 | provideStyle(key) 45 | } 46 | 47 | /** 48 | * CSS: Columns view 49 | */ 50 | export function columnsView(toggle: boolean) { 51 | const key = 'columns-view' 52 | if (toggle) 53 | provideStyle(key, columnsViewStyle) 54 | else 55 | provideStyle(key) 56 | } 57 | 58 | /** 59 | * CSS: Gallery view 60 | */ 61 | export function galleryView(toggle: boolean) { 62 | const key = 'gallery-view' 63 | if (toggle) 64 | provideStyle(key, galleryViewStyle) 65 | else 66 | provideStyle(key) 67 | } 68 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src", 4 | ], 5 | "exclude": [ 6 | "dist", 7 | "node_modules" 8 | ], 9 | "compilerOptions": { 10 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 11 | 12 | /* Projects */ 13 | // "incremental": true, /* Enable incremental compilation */ 14 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 15 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 16 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 17 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 18 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 19 | 20 | /* Language and Environment */ 21 | "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 22 | "lib": ["DOM", "DOM.Iterable", "ESNext"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 23 | // "jsx": "react-jsx", /* Specify what JSX code is generated. */ 24 | "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 25 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 26 | // "jsxFactory": "h", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 27 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 28 | // "jsxImportSource": "preact", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 29 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 30 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 31 | "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 32 | 33 | /* Modules */ 34 | "module": "ESNext", /* Specify what module code is generated. */ 35 | // "rootDir": "./", /* Specify the root folder within your source files. */ 36 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 37 | "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 38 | "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ 39 | "@src/*": ["src/*"], 40 | }, 41 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 42 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 43 | "types": ["vite/client", "node"], /* Specify type package names to be included without being referenced in a source file. */ 44 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 45 | "resolveJsonModule": true, /* Enable importing .json files */ 46 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 47 | 48 | /* JavaScript Support */ 49 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 50 | "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 51 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 52 | 53 | /* Emit */ 54 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 55 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 56 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 57 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 58 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 59 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 60 | // "removeComments": true, /* Disable emitting comments. */ 61 | "noEmit": true, /* Disable emitting files from a compilation. */ 62 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 63 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 64 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 65 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 66 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 67 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 68 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 69 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 70 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 71 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 72 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 73 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 74 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 75 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 76 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 77 | 78 | /* Interop Constraints */ 79 | "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 80 | "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 81 | "esModuleInterop": false, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 82 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 83 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 84 | 85 | /* Type Checking */ 86 | "strict": true, /* Enable all strict type-checking options. */ 87 | "noImplicitAny": false, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 88 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 89 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 90 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 91 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 92 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 93 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 94 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 95 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 96 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 97 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 98 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 99 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 100 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 101 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 102 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 103 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 104 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 105 | 106 | /* Completeness */ 107 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 108 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import logseqDevPlugin from 'vite-plugin-logseq' 3 | 4 | 5 | export default defineConfig(({ command, mode, ssrBuild }) => { 6 | const forProd = mode === 'production' 7 | 8 | return { 9 | plugins: [ 10 | // Makes HMR available for development 11 | logseqDevPlugin() 12 | ], 13 | build: { 14 | sourcemap: !forProd, 15 | target: 'esnext', 16 | minify: forProd ? 'esbuild' : false, 17 | }, 18 | } 19 | }) 20 | --------------------------------------------------------------------------------