diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f4697d0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[**.{ts,json,js}] +end_of_line = crlf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/main.ts b/main.ts index eeb3dda..1a2896d 100644 --- a/main.ts +++ b/main.ts @@ -1,112 +1,46 @@ -import { App, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; +import { Plugin } from "obsidian"; -interface MyPluginSettings { - mySetting: string; -} +import { LiquidTemplatesSettings, DEFAULT_SETTINGS, LiquidTemplatesSettingsTab } from "./settings"; +import TemplateSuggest from "./suggest/template-suggest"; -const DEFAULT_SETTINGS: MyPluginSettings = { - mySetting: 'default' -} +export default class LiquidTemplates extends Plugin { + private autosuggest: TemplateSuggest; + public settings: LiquidTemplatesSettings; -export default class MyPlugin extends Plugin { - settings: MyPluginSettings; + async onload(): Promise { + console.log('loading liquid templates plugin'); + await this.loadSettings(); - async onload() { - console.log('loading plugin'); + this.setupAutosuggest(); - await this.loadSettings(); + this.addSettingTab(new LiquidTemplatesSettingsTab(this.app, this)) + } - this.addRibbonIcon('dice', 'Sample Plugin', () => { - new Notice('This is a notice!'); - }); + onunload(): void { + console.log('unloading liquid templates plugin'); + } - this.addStatusBarItem().setText('Status Bar Text'); - - this.addCommand({ - id: 'open-sample-modal', - name: 'Open Sample Modal', - // callback: () => { - // console.log('Simple Callback'); - // }, - checkCallback: (checking: boolean) => { - let leaf = this.app.workspace.activeLeaf; - if (leaf) { - if (!checking) { - new SampleModal(this.app).open(); - } - return true; - } - return false; - } - }); - - this.addSettingTab(new SampleSettingTab(this.app, this)); + setupAutosuggest(): void { + this.autosuggest = new TemplateSuggest(this.app, this); this.registerCodeMirror((cm: CodeMirror.Editor) => { - console.log('codemirror', cm); + cm.on("change", this.autosuggestHandler); }); + } - this.registerDomEvent(document, 'click', (evt: MouseEvent) => { - console.log('click', evt); - }); + autosuggestHandler = ( + cmEditor: CodeMirror.Editor, + changeObj: CodeMirror.EditorChange + ): boolean => { + return this.autosuggest?.update(cmEditor, changeObj); + }; - this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000)); - } + async loadSettings(): Promise { + this.settings = Object.assign(DEFAULT_SETTINGS, await this.loadData()); + } - onunload() { - console.log('unloading plugin'); - } - - async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - } - - async saveSettings() { - await this.saveData(this.settings); - } -} - -class SampleModal extends Modal { - constructor(app: App) { - super(app); - } - - onOpen() { - let {contentEl} = this; - contentEl.setText('Woah!'); - } - - onClose() { - let {contentEl} = this; - contentEl.empty(); - } -} - -class SampleSettingTab extends PluginSettingTab { - plugin: MyPlugin; - - constructor(app: App, plugin: MyPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - let {containerEl} = this; - - containerEl.empty(); - - containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'}); - - new Setting(containerEl) - .setName('Setting #1') - .setDesc('It\'s a secret') - .addText(text => text - .setPlaceholder('Enter your secret') - .setValue('') - .onChange(async (value) => { - console.log('Secret: ' + value); - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - })); - } + async saveSettings(): Promise { + this.setupAutosuggest(); + await this.saveData(this.settings); + } } diff --git a/settings.ts b/settings.ts new file mode 100644 index 0000000..79caf3e --- /dev/null +++ b/settings.ts @@ -0,0 +1,87 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import LiquidTemplates from "./main"; + +export interface LiquidTemplatesSettings { + templatesFolder: string; + dateFormat: string; + timeFormat: string; + autocompleteTrigger: string; +} + +export const DEFAULT_SETTINGS: LiquidTemplatesSettings = { + templatesFolder: 'templates', + dateFormat: '%Y-%m-%d', + timeFormat: '%H:%M', + autocompleteTrigger: ';;' +}; + +export class LiquidTemplatesSettingsTab extends PluginSettingTab { + plugin: LiquidTemplates; + + constructor(app: App, plugin: LiquidTemplates) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + + containerEl.empty(); + + containerEl.createEl('h2', { + text: 'Liquid Templates', + }); + + new Setting(containerEl) + .setName('Templates folder') + .setDesc('Folder where to find your templates, it will be used also as root folder for the liquid tags, like the include ones') + .addMomentFormat((text) => + text + .setDefaultFormat(DEFAULT_SETTINGS.templatesFolder) + .setValue(this.plugin.settings.templatesFolder) + .onChange(async (value) => { + this.plugin.settings.templatesFolder = value || DEFAULT_SETTINGS.templatesFolder; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName('Date format') + .setDesc('Output format for render dates') + .addMomentFormat((text) => + text + .setDefaultFormat(DEFAULT_SETTINGS.dateFormat) + .setValue(this.plugin.settings.dateFormat) + .onChange(async (value) => { + this.plugin.settings.dateFormat = value || DEFAULT_SETTINGS.dateFormat; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName('Time format') + .setDesc('Output format for render time') + .addMomentFormat((text) => + text + .setDefaultFormat(DEFAULT_SETTINGS.timeFormat) + .setValue(this.plugin.settings.timeFormat) + .onChange(async (value) => { + this.plugin.settings.timeFormat = value || DEFAULT_SETTINGS.timeFormat; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName('Autocomplete trigger') + .setDesc('Character/s that trigger the autocomplete menu') + .addMomentFormat((text) => + text + .setPlaceholder(DEFAULT_SETTINGS.autocompleteTrigger) + .setValue(this.plugin.settings.autocompleteTrigger || DEFAULT_SETTINGS.autocompleteTrigger) + .onChange(async (value) => { + this.plugin.settings.autocompleteTrigger = value.trim(); + await this.plugin.saveSettings(); + }) + ); + } +} \ No newline at end of file diff --git a/suggest/codemirror-suggest.ts b/suggest/codemirror-suggest.ts new file mode 100644 index 0000000..118cbbe --- /dev/null +++ b/suggest/codemirror-suggest.ts @@ -0,0 +1,129 @@ +import { App, ISuggestOwner, Scope } from "obsidian"; + +import Suggest from "./suggest"; + +function checkForInputPhrase( + cmEditor: CodeMirror.Editor, + pos: CodeMirror.Position, + phrase: string +): boolean { + const from = { + line: pos.line, + ch: pos.ch - phrase.length, + }; + return cmEditor.getRange(from, pos) === phrase; +} + +function isCursorBeforePos( + pos: CodeMirror.Position, + cursor: CodeMirror.Position +): boolean { + if (pos.line === cursor.line) { + return cursor.ch < pos.ch; + } + return cursor.line < pos.line; +} + +export default abstract class CodeMirrorSuggest implements ISuggestOwner { + protected app: App; + protected cmEditor: CodeMirror.Editor; + private scope: Scope; + + private suggestEl: HTMLElement; + private instructionsEl: HTMLElement; + private suggest: Suggest; + + private startPos: CodeMirror.Position; + private triggerPhrase: string; + + constructor(app: App, triggerPhrase: string) { + this.triggerPhrase = triggerPhrase; + this.app = app; + this.scope = new Scope(); + + this.suggestEl = createDiv("suggestion-container"); + const suggestion = this.suggestEl.createDiv("suggestion"); + this.instructionsEl = this.suggestEl.createDiv("prompt-instructions"); + this.suggest = new Suggest(this, suggestion, this.scope); + + this.scope.register([], "Escape", this.close.bind(this)); + } + + public setInstructions( + createInstructionsFn: (containerEl: HTMLElement) => void + ): void { + this.instructionsEl.empty(); + createInstructionsFn(this.instructionsEl); + } + + public update( + cmEditor: CodeMirror.Editor, + changeObj: CodeMirror.EditorChange + ): boolean { + if (this.cmEditor !== cmEditor) { + this.suggestEl?.detach(); + } + this.cmEditor = cmEditor; + const cursorPos = cmEditor.getCursor(); + + // autosuggest is open + if (this.suggestEl.parentNode) { + if (isCursorBeforePos(this.startPos, cursorPos)) { + this.close(); + return false; + } + this.attachAtCursor(); + } else { + if ( + changeObj.text.length === 1 && // ignore multi-cursors + checkForInputPhrase(this.cmEditor, cursorPos, this.triggerPhrase) && + !document.querySelector(".suggestion-container") // don't trigger multiple autosuggests + ) { + this.startPos = cursorPos; + this.open(); + this.attachAtCursor(); + } + } + + return false; + } + + protected getStartPos(): CodeMirror.Position { + return { + line: this.startPos.line, + ch: this.startPos.ch - this.triggerPhrase.length, + }; + } + + protected getInputStr(): string { + // return string from / to cursor + const cursor = this.cmEditor.getCursor(); + const line = this.cmEditor.getLine(cursor.line); + return line.substring(this.startPos.ch, cursor.ch); + } + + private attachAtCursor() { + const inputStr = this.getInputStr(); + const suggestions = this.getSuggestions(inputStr); + this.suggest.setSuggestions(suggestions); + + this.cmEditor.addWidget(this.cmEditor.getCursor(), this.suggestEl, true); + } + + open(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.app).keymap.pushScope(this.scope); + } + + close(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.app).keymap.popScope(this.scope); + this.startPos = null; + this.suggest.setSuggestions([]); + this.suggestEl.detach(); + } + + abstract getSuggestions(inputStr: string): T[]; + abstract renderSuggestion(item: T, el: HTMLElement): void; + abstract selectSuggestion(item: T, evt: MouseEvent | KeyboardEvent): void; +} diff --git a/suggest/suggest.ts b/suggest/suggest.ts new file mode 100644 index 0000000..da05677 --- /dev/null +++ b/suggest/suggest.ts @@ -0,0 +1,102 @@ +import { ISuggestOwner, Scope } from "obsidian"; + +export const wrapAround = (value: number, size: number): number => { + return ((value % size) + size) % size; +}; + +export default class Suggest { + private owner: ISuggestOwner; + private values: T[]; + private suggestions: HTMLDivElement[]; + private selectedItem: number; + private containerEl: HTMLElement; + + constructor(owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope) { + this.owner = owner; + this.containerEl = containerEl; + + containerEl.on( + "click", + ".suggestion-item", + this.onSuggestionClick.bind(this) + ); + containerEl.on( + "mousemove", + ".suggestion-item", + this.onSuggestionMouseover.bind(this) + ); + + scope.register([], "ArrowUp", (event) => { + if (!event.isComposing) { + this.setSelectedItem(this.selectedItem - 1, true); + return false; + } + }); + + scope.register([], "ArrowDown", (event) => { + if (!event.isComposing) { + this.setSelectedItem(this.selectedItem + 1, true); + return false; + } + }); + + const selectItem = (event: KeyboardEvent) => { + if (!event.isComposing) { + this.useSelectedItem(event); + return false; + } + }; + scope.register([], "Enter", selectItem); + scope.register(["Shift"], "Enter", selectItem); + } + + onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { + event.preventDefault(); + + const item = this.suggestions.indexOf(el); + this.setSelectedItem(item, false); + this.useSelectedItem(event); + } + + onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { + const item = this.suggestions.indexOf(el); + this.setSelectedItem(item, false); + } + + setSuggestions(values: T[]): void { + this.containerEl.empty(); + const suggestionEls: HTMLDivElement[] = []; + + values.forEach((value) => { + const suggestionEl = this.containerEl.createDiv("suggestion-item"); + this.owner.renderSuggestion(value, suggestionEl); + suggestionEls.push(suggestionEl); + }); + + this.values = values; + this.suggestions = suggestionEls; + this.setSelectedItem(0, false); + } + + useSelectedItem(event: MouseEvent | KeyboardEvent): void { + const currentValue = this.values[this.selectedItem]; + if (currentValue) { + this.owner.selectSuggestion(currentValue, event); + } + } + + setSelectedItem(selectedIndex: number, scrollIntoView: boolean): void { + const normalizedIndex = wrapAround(selectedIndex, this.suggestions.length); + const prevSelectedSuggestion = this.suggestions[this.selectedItem]; + const selectedSuggestion = this.suggestions[normalizedIndex]; + + prevSelectedSuggestion?.removeClass("is-selected"); + selectedSuggestion?.addClass("is-selected"); + + this.selectedItem = normalizedIndex; + + if (scrollIntoView) { + selectedSuggestion.scrollIntoView(false); + } + } +} diff --git a/suggest/template-suggest.ts b/suggest/template-suggest.ts new file mode 100644 index 0000000..12ac955 --- /dev/null +++ b/suggest/template-suggest.ts @@ -0,0 +1,108 @@ +import { App, TFile } from "obsidian"; +import { Liquid } from "liquidjs"; +import type PoweredTemplates from "../main"; +import CodeMirrorSuggest from "./codemirror-suggest"; +import { getTFilesFromFolder } from "utils"; + +interface ITemplateCompletion { + label: string; + file?: TFile; +} + +// TODO: create a class for the suggestion that implements the ITemplateCompletion and have some +// utility method to render the template +// TODO: generate a proper context and use the defined settings for the date and time format +export default class TemplateSuggest extends CodeMirrorSuggest { + plugin: PoweredTemplates; + engine: Liquid; + + constructor(app: App, plugin: PoweredTemplates) { + super(app, plugin.settings.autocompleteTrigger); + + this.plugin = plugin; + // TODO: move me in a separate class + this.engine = new Liquid({ + fs: { + readFileSync: (file) => { + // TODO: implement me + return ""; + }, + readFile: async (file) => { + const { templatesFolder } = this.plugin.settings; + // TODO: find a better way to do this + const fileObj = getTFilesFromFolder(app, templatesFolder).find(f => f.basename === file); + return app.vault.read(fileObj); + }, + existsSync: () => { + return true + }, + exists: async () => { + return true + }, + resolve: (_root, file, _ext) => { + return file + } + }, + extname: '.md', + greedy: true, + }); + } + + open(): void { + super.open(); + } + + + getSuggestions(inputStr: string): ITemplateCompletion[] { + // handle no matches + const suggestions = this.getTemplateSuggestions(inputStr); + if (suggestions.length) { + return suggestions; + } else { + return [{ label: inputStr }]; + } + } + + getTemplateSuggestions(inputStr: string): ITemplateCompletion[] { + // find the list of files + // TODO: filter before returning all the files + const templates = getTFilesFromFolder(this.app, this.plugin.settings.templatesFolder); + return templates + .map(file => ({ label: file.basename, file: file })) + .filter((items) => items.label.toLowerCase().startsWith(inputStr)); + } + + renderSuggestion(suggestion: ITemplateCompletion, el: HTMLElement): void { + el.setText(suggestion.label); + } + + async generateContext() { + return { + // TODO: improve me + date: await this.engine.parseAndRender(`{{ "now" | date: "${this.plugin.settings.dateFormat}"}}`), + time: await this.engine.parseAndRender(`{{ "now" | date: "${this.plugin.settings.timeFormat}"}}`), + title: await this.app.workspace.getActiveFile().basename, + default_date_format: this.plugin.settings.dateFormat, + default_time_format: this.plugin.settings.timeFormat + } + } + + async selectSuggestion( + suggestion: ITemplateCompletion, + _event: KeyboardEvent | MouseEvent + ): Promise { + const head = this.getStartPos(); + const anchor = this.cmEditor.getCursor(); + + if (suggestion.file) { + const templateString = await this.app.vault.read(suggestion.file) + + const rendered = await this.engine.parseAndRender( + templateString, + await this.generateContext() + ); + this.cmEditor.replaceRange(rendered.trim(), head, anchor); + } + this.close() + } +} diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..bb374b5 --- /dev/null +++ b/utils.ts @@ -0,0 +1,21 @@ +import { App, normalizePath, TAbstractFile, TFile, TFolder, Vault } from "obsidian"; + +export function getTFilesFromFolder(app: App, folderName: string): Array { + folderName = normalizePath(folderName); + let folder = app.vault.getAbstractFileByPath(folderName); + if (!folder) throw new Error(`${folderName} folder doesn't exist`); + if (!(folder instanceof TFolder)) throw new Error(`${folderName} is a file, not a folder`); + + let files: Array = []; + Vault.recurseChildren(folder, (file: TAbstractFile) => { + if (file instanceof TFile) { + files.push(file); + } + }); + + files.sort((a, b) => { + return a.basename.localeCompare(b.basename); + }); + + return files; +}