├── .gitignore ├── LICENSE ├── README.md ├── file-suggest.ts ├── main.ts ├── manifest.json ├── package.json ├── rollup.config.js ├── suggest.ts ├── tsconfig.json └── versions.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | main.js 11 | *.js.map 12 | 13 | # obsidian 14 | data.json 15 | 16 | test/ 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Timur Sidoriuk 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 | ## Random Structural Diary Questions 2 | Answer on random questions of your diary to get new thoughts; 3 | This plugin use a prepared list of questions if you doesn't have one. 4 | There is the list - https://zttl.wiki/Structural-diary-b5ecbe5e0dd643b1a868bd773b34094b 5 | ### Update 1.1.0 6 | Now by default plugin picks number of question from the whole file. 7 | You can change this behaviour to old style in plugin settings. 8 | 9 | You can setup your own questions for example 10 | ```markdown 11 | #Section1 12 | Question1 13 | Question2 14 | Question3 15 | #Section2 16 | Question1 17 | Question2 18 | Question3 19 | #Section3 20 | Question1 21 | Question2 22 | Question3 23 | ``` 24 | And fill this filename in plugin settings. 25 | It's important to keep section headers as # headers. 26 | Also questions separated by linebrakes `\n`. So, please write your questions 27 | by one at line. 28 | 29 | Also you can setup number of questions from each section. 30 | Use the template 31 | `sectionNumber-numberOfQuestions;sectionNumber-numberOfQuestions;` 32 | Like `1-3;2-2;4-0` - it takes three questions from first section, two from second and zero from fourth; 33 | If section ommited then number of questions picks randomly (it can be 0); 34 | 35 | To use plugin create a new file and execute command 36 | `Create questions list` 37 | --- 38 | 39 | Release 1.1.2. 40 | - Now question inserts into cursor location. 41 | 42 | Release 1.1.0. 43 | - Add global question picking feature 44 | 45 | Release 1.0.6 46 | - Add support for questions files in folders 47 | - Add autofill for filename 48 | - Add option to show or hide section headers 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /file-suggest.ts: -------------------------------------------------------------------------------- 1 | import { TAbstractFile, TFile, TFolder } from "obsidian"; 2 | import { TextInputSuggest } from "./suggest"; 3 | 4 | 5 | export class FileSuggest extends TextInputSuggest { 6 | getSuggestions(inputStr: string): TFile[] { 7 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 8 | const files: TFile[] = []; 9 | const lowerCaseInputStr = inputStr.toLowerCase(); 10 | 11 | abstractFiles.forEach((file: TAbstractFile) => { 12 | if ( 13 | file instanceof TFile && 14 | file.extension === "md" && 15 | file.path.toLowerCase().contains(lowerCaseInputStr) 16 | ) { 17 | files.push(file); 18 | } 19 | }); 20 | 21 | return files; 22 | } 23 | 24 | renderSuggestion(file: TFile, el: HTMLElement): void { 25 | el.setText(file.path); 26 | } 27 | 28 | selectSuggestion(file: TFile): void { 29 | this.inputEl.value = file.path; 30 | this.inputEl.trigger("input"); 31 | this.close(); 32 | } 33 | } 34 | 35 | export class FolderSuggest extends TextInputSuggest { 36 | getSuggestions(inputStr: string): TFolder[] { 37 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 38 | const folders: TFolder[] = []; 39 | const lowerCaseInputStr = inputStr.toLowerCase(); 40 | 41 | abstractFiles.forEach((folder: TAbstractFile) => { 42 | if ( 43 | folder instanceof TFolder && 44 | folder.path.toLowerCase().contains(lowerCaseInputStr) 45 | ) { 46 | folders.push(folder); 47 | } 48 | }); 49 | 50 | return folders; 51 | } 52 | 53 | renderSuggestion(file: TFolder, el: HTMLElement): void { 54 | el.setText(file.path); 55 | } 56 | 57 | selectSuggestion(file: TFolder): void { 58 | this.inputEl.value = file.path; 59 | this.inputEl.trigger("input"); 60 | this.close(); 61 | } 62 | } -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import {App, Plugin, PluginSettingTab, Setting, TFile, WorkspaceLeaf, Editor, MarkdownView} from 'obsidian'; 2 | import {FileSuggest} from "./file-suggest"; 3 | 4 | interface PluginSettings { 5 | fileWithQuestions: string; 6 | questionsTemplate: string; 7 | showHeaders: boolean; 8 | useAdvancedTemplate: boolean; 9 | globalNumberOfQuestions: number; 10 | } 11 | 12 | const MARKDOWN_EXTENSION = "md"; 13 | 14 | const DEFAULT_SETTINGS: PluginSettings = { 15 | fileWithQuestions: null, 16 | questionsTemplate: '', 17 | showHeaders: false, 18 | useAdvancedTemplate: false, 19 | globalNumberOfQuestions: 5 20 | } 21 | 22 | 23 | export default class RandomStructuralDiaryPlugin extends Plugin { 24 | settings: PluginSettings; 25 | 26 | async onload() { 27 | await this.loadSettings(); 28 | 29 | this.addCommand({ 30 | id: 'create-questions-list', 31 | name: 'Create questions list', 32 | 33 | callback: async () => { 34 | let file = this.app.vault.getAbstractFileByPath(`${this.settings.fileWithQuestions}`); 35 | if (file instanceof TFile) { 36 | let fileContent = await this.app.vault.cachedRead(file); 37 | await this.fillFileWithQuestions(fileContent); 38 | } else { 39 | await this.fillFileWithQuestions(DEFAULT_QUESTIONS); 40 | } 41 | } 42 | }); 43 | 44 | this.addSettingTab(new SettingTab(this.app, this)); 45 | } 46 | 47 | onunload() { 48 | } 49 | 50 | async loadSettings() { 51 | let oldSettings = await this.loadData(); 52 | this.settings = Object.assign({}, DEFAULT_SETTINGS, oldSettings); 53 | 54 | if(oldSettings && !oldSettings.hasOwnProperty("useAdvancedTemplate")){ 55 | this.settings.useAdvancedTemplate = true; 56 | } 57 | 58 | } 59 | 60 | async saveSettings() { 61 | await this.saveData(this.settings); 62 | } 63 | 64 | private async fillFileWithQuestions(fileContent: string) { 65 | let outputString = ''; 66 | 67 | if (this.settings.useAdvancedTemplate) { 68 | let sections = this.getSections(fileContent); 69 | let headers = sections.map(x => x[0]); 70 | let questionsSettings = this.parseQuestionsTemplate(); 71 | let questions = sections.map(x => { 72 | let numOfQuestions = questionsSettings.get(sections.indexOf(x) + 1); 73 | if (!numOfQuestions) 74 | numOfQuestions = this.getRandomInt(x.length); 75 | 76 | return this.generateRandomQuestionsFromSection(x, numOfQuestions); 77 | }, this); 78 | 79 | let flattenQuestions = questions.reduce((acc, val, index) => { 80 | if (this.settings.showHeaders) 81 | return acc.concat(headers[index], val); 82 | else 83 | return acc.concat(val); 84 | }, []); 85 | 86 | outputString = flattenQuestions.join("\n\n\n"); 87 | 88 | 89 | } else { 90 | let sections = this.getSections(fileContent); 91 | let allQuestions = sections.reduce((acc, rec) => { 92 | rec.shift(); 93 | return acc.concat(rec); 94 | }, []) 95 | let numOfQuestions = this.settings.globalNumberOfQuestions; 96 | 97 | let pickedQuestions = []; 98 | 99 | for(let i = 0; i < numOfQuestions; i++){ 100 | let currentRandomNumber = this.getRandomInt(allQuestions.length) 101 | let pickedQuestion = allQuestions[currentRandomNumber]; 102 | pickedQuestions.push(pickedQuestion); 103 | allQuestions = allQuestions.filter(x => x !== pickedQuestion); 104 | } 105 | 106 | outputString = pickedQuestions.join("\n\n\n"); 107 | } 108 | 109 | let activeFile = this.app.workspace.getActiveFile(); 110 | if (!activeFile || activeFile.extension !== MARKDOWN_EXTENSION) { 111 | let fileName = `RandomDiaryQuestions by ${this.getFancyDate()}.${MARKDOWN_EXTENSION}`; 112 | activeFile = await this.app.vault.create(fileName, outputString); 113 | } else { 114 | let view = this.app.workspace.getActiveViewOfType(MarkdownView); 115 | view.editor.replaceRange(outputString, view.editor.getCursor()) 116 | } 117 | 118 | let leaf = this.app.workspace.getMostRecentLeaf(); 119 | if (!leaf) { 120 | let leaf = new WorkspaceLeaf(); 121 | this.app.workspace.createLeafBySplit(leaf); 122 | } 123 | 124 | await leaf.openFile(activeFile); 125 | } 126 | 127 | /** 128 | * Creates array of sections with questions 129 | * @param content 130 | */ 131 | private getSections(content: string): string[][] { 132 | let splitLines = content.split("\n"); 133 | 134 | let sections: string[][] = []; 135 | let currentArray: string[] = []; 136 | for (let i = 0; i < splitLines.length; i++) { 137 | 138 | let curEl = splitLines[i]; 139 | 140 | if (curEl.contains("# ")) { 141 | if (currentArray.length) 142 | sections.push(currentArray) 143 | currentArray = []; 144 | } 145 | if(curEl.trim().length !== 0){ 146 | currentArray.push(curEl.trim()) 147 | } 148 | } 149 | sections.push(currentArray) 150 | 151 | return sections; 152 | } 153 | 154 | /** 155 | * Returns random int from 0 to max 156 | * @param max int top border 157 | * @private 158 | */ 159 | private getRandomInt(max: number): number { 160 | return Math.floor(Math.random() * max); 161 | } 162 | 163 | /** 164 | * Create array of random questions 165 | * @param section questions section 166 | * @param numOfQuestions number of generated questions 167 | * @private 168 | */ 169 | private generateRandomQuestionsFromSection(section: string[], numOfQuestions: number): string[] { 170 | section.shift(); 171 | if (numOfQuestions >= section.length) 172 | return section; 173 | if (numOfQuestions === 0) 174 | return []; 175 | 176 | let result = []; 177 | 178 | for (let i = 0; i < numOfQuestions; i++) { 179 | let question = this.getRandomQuestion(section); 180 | section.remove(question); 181 | result.push(question); 182 | } 183 | 184 | return result; 185 | } 186 | 187 | /** 188 | * Returns random question from array 189 | * @param questions question array 190 | * @private 191 | */ 192 | private getRandomQuestion(questions: string[]): string { 193 | let randomNumber = this.getRandomInt(questions.length); 194 | return questions[randomNumber]; 195 | } 196 | 197 | /** 198 | * Prepare settings for using 199 | * @private 200 | */ 201 | private parseQuestionsTemplate(): Map { 202 | let result: Map = new Map(); 203 | 204 | if (!this.settings.questionsTemplate) 205 | return result; 206 | 207 | let splitedSettings = this.settings.questionsTemplate.split(';'); 208 | splitedSettings.map(x => { 209 | let splitedValues = x.split('-'); 210 | let sectionNumber = splitedValues[0]; 211 | let numberOfQuestions = splitedValues[1]; 212 | 213 | result.set(Number(sectionNumber), Number(numberOfQuestions)); 214 | }) 215 | 216 | return result; 217 | } 218 | 219 | private getFancyDate(): string { 220 | let date = new Date(); 221 | let fancyDate = `${date.getDay() + 1}-${date.getMonth() + 1}-${date.getFullYear()}` 222 | return fancyDate; 223 | } 224 | } 225 | 226 | class SettingTab 227 | extends PluginSettingTab { 228 | plugin: RandomStructuralDiaryPlugin; 229 | 230 | constructor(app: App, plugin: RandomStructuralDiaryPlugin) { 231 | super(app, plugin); 232 | this.plugin = plugin; 233 | } 234 | 235 | display(): void { 236 | let {containerEl} = this; 237 | 238 | containerEl.empty(); 239 | 240 | containerEl.createEl('h2', {text: 'Settings for RandomStructuralDiary plugin.'}); 241 | 242 | new Setting(containerEl) 243 | .setName("File with questions to open") 244 | .setDesc("With file extension!") 245 | .addText(cb => { 246 | new FileSuggest(this.app, cb.inputEl); 247 | cb 248 | .setPlaceholder("Directory/file.md") 249 | .setValue(this.plugin.settings.fileWithQuestions) 250 | .onChange(async (value) => { 251 | this.plugin.settings.fileWithQuestions = value; 252 | await this.plugin.saveSettings(); 253 | }); 254 | }); 255 | 256 | new Setting(containerEl) 257 | .setName('Global number of questions') 258 | .setDesc('Picks that number of questions from the whole questions file') 259 | .addText(text => text 260 | .setPlaceholder('Example: 5') 261 | .setValue(this.plugin.settings.globalNumberOfQuestions.toString()) 262 | .onChange(async (new_template) => { 263 | this.plugin.settings.globalNumberOfQuestions = +new_template; 264 | await this.plugin.saveSettings(); 265 | })) 266 | .setDisabled(this.plugin.settings.useAdvancedTemplate); 267 | 268 | new Setting(containerEl) 269 | .setName('Use advanced template') 270 | .setDesc('Allows you to setup template in old fashion way') 271 | .addToggle((toggle) => 272 | toggle.setValue(this.plugin.settings.useAdvancedTemplate) 273 | .onChange(async (value) => { 274 | this.plugin.settings.useAdvancedTemplate = value 275 | await this.plugin.saveSettings(); 276 | await this.display(); 277 | })); 278 | 279 | new Setting(containerEl) 280 | .setName('Questions Template') 281 | .setDesc('Format: section1-numberOfQuestions;section1-numberOfQuestions; 1-3;2-2;...\n If section not specified picks random number of questions') 282 | .addText(text => text 283 | .setPlaceholder('Example: 1-3;2-2;') 284 | .setValue(this.plugin.settings.questionsTemplate) 285 | .onChange(async (new_template) => { 286 | this.plugin.settings.questionsTemplate = new_template; 287 | await this.plugin.saveSettings(); 288 | })) 289 | .setDisabled(!this.plugin.settings.useAdvancedTemplate); 290 | 291 | new Setting(containerEl) 292 | .setName('Show headers') 293 | .setDesc('Show header for generated groups. Option available only for advanced template.') 294 | .addToggle((toggle) => 295 | toggle.setValue(this.plugin.settings.showHeaders) 296 | .onChange(async (value) => { 297 | this.plugin.settings.showHeaders = value 298 | await this.plugin.saveSettings(); 299 | })) 300 | .setDisabled(!this.plugin.settings.useAdvancedTemplate); 301 | } 302 | } 303 | 304 | const DEFAULT_QUESTIONS = 305 | "# Remembering important events\n" + 306 | "\n" + 307 | "What important things happened  today?\n" + 308 | "\n" + 309 | "What places did I visit?\n" + 310 | "\n" + 311 | "With whom did I meet/speak?\n" + 312 | "\n" + 313 | "What was the topic of conversations?\n" + 314 | "\n" + 315 | "What did I purchase?\n" + 316 | "\n" + 317 | "What important did I do, start to do, accomplished?\n" + 318 | "\n" + 319 | "# Introspection (To sum up every project/day/week/month/year)\n" + 320 | "\n" + 321 | "What was successful or unsuccessful?\n" + 322 | "\n" + 323 | "Why?\n" + 324 | "\n" + 325 | "What could be done in more simple, easy and efficient way?\n" + 326 | "\n" + 327 | "What things weren't accomplished?\n" + 328 | "\n" + 329 | "What did hinder me?\n" + 330 | "\n" + 331 | "# Rationality\n" + 332 | "\n" + 333 | "Did I think thoroughly before speak/act? Did I weight consequences?\n" + 334 | "\n" + 335 | "What were wrong or irrational in my actions, words, thoughts?\n" + 336 | "\n" + 337 | "What would be the better way?\n" + 338 | "\n" + 339 | "What was the reason for my misstep?\n" + 340 | "\n" + 341 | "What should I do in order to not repeat my mistake?\n" + 342 | "\n" + 343 | "# Patterns\n" + 344 | "\n" + 345 | "What are my most common failures?\n" + 346 | "\n" + 347 | "What virtues were forgotten?\n" + 348 | "\n" + 349 | "What responsibilities are neglected?\n" + 350 | "\n" + 351 | "# Time-managment\n" + 352 | "\n" + 353 | "Did I spend time efficiently?\n" + 354 | "\n" + 355 | "What was time wasting?\n" + 356 | "\n" + 357 | "How can I optimise my day?\n" + 358 | "\n" + 359 | "What resources do I have?\n" + 360 | "\n" + 361 | "Am I using them effectively?\n" + 362 | "\n" + 363 | "Did I procrastinate?\n" + 364 | "\n" + 365 | "# Future\n" + 366 | "\n" + 367 | "Are there potential areas of crisis in my life?\n" + 368 | "\n" + 369 | "What am I doing to prepare?\n" + 370 | "\n" + 371 | "Is there opportunities for future development?\n" + 372 | "\n" + 373 | "# Emotions\n" + 374 | "\n" + 375 | "Did I keep peaceful spirit (Inner peace)?\n" + 376 | "\n" + 377 | "What is the most frequent topic of my thoughts?\n" + 378 | "\n" + 379 | "What thoughts are troubling me?\n" + 380 | "\n" + 381 | "What is the source of bad feelings?\n" + 382 | "\n" + 383 | "Are there any fearful, worrisome, upsetting things?\n" + 384 | "\n" + 385 | "Do I feel guilty, angry, offended, unsure?\n" + 386 | "\n" + 387 | "Why do I feel this way?\n" + 388 | "\n" + 389 | "What was the source of joy and pleasure for me recently?\n" + 390 | "\n" + 391 | "Why?\n" + 392 | "\n" + 393 | "# Intuition\n" + 394 | "\n" + 395 | "Do I have the feeling that something is wrong?\n" + 396 | "\n" + 397 | "Do I have the feeling that I missed something?\n" + 398 | "\n" + 399 | "Did I have an impulse to do something?\n" + 400 | "\n" + 401 | "What are opportunities for me in these circumstances to serve God and neighbors?\n" + 402 | "\n" + 403 | "Did I saw in a dream something meaningful?\n" + 404 | "\n" + 405 | "# Self-actualization\n" + 406 | "\n" + 407 | "What are my priorities for this period of life?\n" + 408 | "\n" + 409 | "What are my values?\n" + 410 | "\n" + 411 | "What are my dreams?\n" + 412 | "\n" + 413 | "What is my weakest point in character, knowledge, skills, habits?\n" + 414 | "\n" + 415 | "What is my strongest point?\n" + 416 | "\n" + 417 | "What makes me happy and gratified?\n" + 418 | "\n" + 419 | "# Intellectual life\n" + 420 | "\n" + 421 | "All ideas, aha reactions, eurekas, observations go here.\n" + 422 | "\n" + 423 | "What books/articles have I started to read or have finished to read?\n" + 424 | "\n" + 425 | "What texts am I working on?\n" + 426 | "\n" + 427 | "What have l finished?\n" + 428 | "\n" + 429 | "In what conferences and meetings did I participate?\n" + 430 | "\n" + 431 | "What movies have I watched?\n" + 432 | "\n" + 433 | "What new information have I discovered from books or conversations?\n" + 434 | "\n" + 435 | "What information needs to be checked?\n" + 436 | "\n" + 437 | "What topics do I need to consider more carefully and in depth?\n" + 438 | "\n" + 439 | "What facts are against my point of view?\n" + 440 | "\n" + 441 | "Is there reasonable doubt?\n" + 442 | "\n" + 443 | "# Social life\n" + 444 | "\n" + 445 | "Did I practice mindful listening?\n" + 446 | "\n" + 447 | "What new information have I found out about my acquaintance and friends?\n" + 448 | "\n" + 449 | "What are their dreams, problems, virtues, prayer needs?\n" + 450 | "\n" + 451 | "How can I participate in their life?\n" + 452 | "\n" + 453 | "Was I thankful?\n" + 454 | "\n" + 455 | "# Prayers\n" + 456 | "\n" + 457 | "What are my confessions to God?\n" + 458 | "\n" + 459 | "gratitudes to God?\n" + 460 | "\n" + 461 | "asking to God?\n" + 462 | "\n" + 463 | "What answers have I received?" 464 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "random-structural-diary-plugin", 3 | "name": "Random Structural Diary", 4 | "version": "1.1.2", 5 | "minAppVersion": "0.9.12", 6 | "description": "This is a plugin for picking random questions from prepared question list. It allows you answer on different questions each time.", 7 | "author": "ShockThunder", 8 | "authorUrl": "https://github.com/ShockThunder", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-sample-plugin", 3 | "version": "0.12.1", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js --environment BUILD:production" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@rollup/plugin-commonjs": "^18.0.0", 15 | "@rollup/plugin-node-resolve": "^11.2.1", 16 | "@rollup/plugin-typescript": "^8.2.1", 17 | "@types/node": "^14.14.37", 18 | "obsidian": "^0.12.0", 19 | "rollup": "^2.32.1", 20 | "tslib": "^2.2.0", 21 | "typescript": "^4.2.4" 22 | }, 23 | "dependencies": { 24 | "@popperjs/core": "^2.10.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import {nodeResolve} from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | const isProd = (process.env.BUILD === 'production'); 6 | 7 | const banner = 8 | `/* 9 | THIS IS A GENERATED/BUNDLED FILE BY ROLLUP 10 | if you want to view the source visit the plugins github repository 11 | */ 12 | `; 13 | 14 | export default { 15 | input: 'main.ts', 16 | output: { 17 | dir: '.', 18 | sourcemap: 'inline', 19 | sourcemapExcludeSources: isProd, 20 | format: 'cjs', 21 | exports: 'default', 22 | banner, 23 | }, 24 | external: ['obsidian'], 25 | plugins: [ 26 | typescript(), 27 | nodeResolve({browser: true}), 28 | commonjs(), 29 | ] 30 | }; -------------------------------------------------------------------------------- /suggest.ts: -------------------------------------------------------------------------------- 1 |  2 | import { createPopper, Instance as PopperInstance } from "@popperjs/core"; 3 | import { App, ISuggestOwner, Scope } from "obsidian"; 4 | 5 | 6 | class Suggest { 7 | private owner: ISuggestOwner; 8 | private values: T[]; 9 | private suggestions: HTMLDivElement[]; 10 | private selectedItem: number; 11 | private containerEl: HTMLElement; 12 | 13 | constructor(owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope) { 14 | this.owner = owner; 15 | this.containerEl = containerEl; 16 | 17 | containerEl.on( 18 | "click", 19 | ".suggestion-item", 20 | this.onSuggestionClick.bind(this) 21 | ); 22 | containerEl.on( 23 | "mousemove", 24 | ".suggestion-item", 25 | this.onSuggestionMouseover.bind(this) 26 | ); 27 | 28 | scope.register([], "ArrowUp", (event) => { 29 | if (!event.isComposing) { 30 | this.setSelectedItem(this.selectedItem - 1, true); 31 | return false; 32 | } 33 | }); 34 | 35 | scope.register([], "ArrowDown", (event) => { 36 | if (!event.isComposing) { 37 | this.setSelectedItem(this.selectedItem + 1, true); 38 | return false; 39 | } 40 | }); 41 | 42 | scope.register([], "Enter", (event) => { 43 | if (!event.isComposing) { 44 | this.useSelectedItem(event); 45 | return false; 46 | } 47 | }); 48 | } 49 | 50 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 51 | event.preventDefault(); 52 | 53 | const item = this.suggestions.indexOf(el); 54 | this.setSelectedItem(item, false); 55 | this.useSelectedItem(event); 56 | } 57 | 58 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 59 | const item = this.suggestions.indexOf(el); 60 | this.setSelectedItem(item, false); 61 | } 62 | 63 | setSuggestions(values: T[]) { 64 | this.containerEl.empty(); 65 | const suggestionEls: HTMLDivElement[] = []; 66 | 67 | values.forEach((value) => { 68 | const suggestionEl = this.containerEl.createDiv("suggestion-item"); 69 | this.owner.renderSuggestion(value, suggestionEl); 70 | suggestionEls.push(suggestionEl); 71 | }); 72 | 73 | this.values = values; 74 | this.suggestions = suggestionEls; 75 | this.setSelectedItem(0, false); 76 | } 77 | 78 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 79 | const currentValue = this.values[this.selectedItem]; 80 | if (currentValue) { 81 | this.owner.selectSuggestion(currentValue, event); 82 | } 83 | } 84 | 85 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 86 | const normalizedIndex = this.wrapAround(selectedIndex, this.suggestions.length); 87 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 88 | const selectedSuggestion = this.suggestions[normalizedIndex]; 89 | 90 | prevSelectedSuggestion?.removeClass("is-selected"); 91 | selectedSuggestion?.addClass("is-selected"); 92 | 93 | this.selectedItem = normalizedIndex; 94 | 95 | if (scrollIntoView) { 96 | selectedSuggestion.scrollIntoView(false); 97 | } 98 | } 99 | wrapAround = (value: number, size: number): number => { 100 | return ((value % size) + size) % size; 101 | }; 102 | } 103 | 104 | export abstract class TextInputSuggest implements ISuggestOwner { 105 | protected app: App; 106 | protected inputEl: HTMLInputElement; 107 | 108 | private popper: PopperInstance; 109 | private scope: Scope; 110 | private suggestEl: HTMLElement; 111 | private suggest: Suggest; 112 | 113 | constructor(app: App, inputEl: HTMLInputElement) { 114 | this.app = app; 115 | this.inputEl = inputEl; 116 | this.scope = new Scope(); 117 | 118 | this.suggestEl = createDiv("suggestion-container"); 119 | const suggestion = this.suggestEl.createDiv("suggestion"); 120 | this.suggest = new Suggest(this, suggestion, this.scope); 121 | 122 | this.scope.register([], "Escape", this.close.bind(this)); 123 | 124 | this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); 125 | this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); 126 | this.inputEl.addEventListener("blur", this.close.bind(this)); 127 | this.suggestEl.on( 128 | "mousedown", 129 | ".suggestion-container", 130 | (event: MouseEvent) => { 131 | event.preventDefault(); 132 | } 133 | ); 134 | } 135 | 136 | onInputChanged(): void { 137 | const inputStr = this.inputEl.value; 138 | const suggestions = this.getSuggestions(inputStr); 139 | 140 | if (suggestions.length > 0) { 141 | this.suggest.setSuggestions(suggestions); 142 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 143 | this.open((this.app).dom.appContainerEl, this.inputEl); 144 | } 145 | } 146 | 147 | open(container: HTMLElement, inputEl: HTMLElement): void { 148 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 149 | (this.app).keymap.pushScope(this.scope); 150 | 151 | container.appendChild(this.suggestEl); 152 | this.popper = createPopper(inputEl, this.suggestEl, { 153 | placement: "bottom-start", 154 | modifiers: [ 155 | { 156 | name: "sameWidth", 157 | enabled: true, 158 | fn: ({ state, instance }) => { 159 | // Note: positioning needs to be calculated twice - 160 | // first pass - positioning it according to the width of the popper 161 | // second pass - position it with the width bound to the reference element 162 | // we need to early exit to avoid an infinite loop 163 | const targetWidth = `${state.rects.reference.width}px`; 164 | if (state.styles.popper.width === targetWidth) { 165 | return; 166 | } 167 | state.styles.popper.width = targetWidth; 168 | instance.update(); 169 | }, 170 | phase: "beforeWrite", 171 | requires: ["computeStyles"], 172 | }, 173 | ], 174 | }); 175 | } 176 | 177 | close(): void { 178 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 179 | (this.app).keymap.popScope(this.scope); 180 | 181 | this.suggest.setSuggestions([]); 182 | this.popper.destroy(); 183 | this.suggestEl.detach(); 184 | } 185 | 186 | abstract getSuggestions(inputStr: string): T[]; 187 | abstract renderSuggestion(item: T, el: HTMLElement): void; 188 | abstract selectSuggestion(item: T): void; 189 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "lib": [ 13 | "dom", 14 | "es5", 15 | "scripthost", 16 | "es2015" 17 | ] 18 | }, 19 | "include": [ 20 | "**/*.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.9.12", 3 | "1.1.0": "0.9.12", 4 | "1.1.2": "0.9.12" 5 | } 6 | --------------------------------------------------------------------------------