From 63b8949f2009fd2c0e4a077b8c309b6baaea4b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Sch=C3=B6dler?= Date: Tue, 4 Apr 2023 09:24:49 +0200 Subject: [PATCH] Updates view, formats codebase --- .prettierrc | 5 + esbuild.config.mjs | 4 +- src/editorExtension.ts | 63 ++++++------ src/icons.ts | 12 +-- main.ts => src/main.ts | 212 +++++++++++++++++++++++++++-------------- src/settings.ts | 104 ++++++++++++++------ src/suggestion.ts | 82 +++++++--------- src/util.ts | 51 ++++++---- src/view/treeItem.ts | 116 +++++++++++++--------- src/view/view.ts | 190 ++++++++++++++++++++++++------------ 10 files changed, 534 insertions(+), 305 deletions(-) create mode 100644 .prettierrc rename main.ts => src/main.ts (52%) diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0092d69 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "tabWidth": 2, + "useTabs": false +} \ No newline at end of file diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 8e2dad0..ec61db6 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -15,7 +15,7 @@ esbuild.build({ banner: { js: banner, }, - entryPoints: ['main.ts'], + entryPoints: ['src/main.ts'], bundle: true, external: [ 'obsidian', @@ -38,5 +38,5 @@ esbuild.build({ logLevel: "info", sourcemap: prod ? false : 'inline', treeShaking: true, - outfile: 'main.js', + outfile: './main.js', }).catch(() => process.exit(1)); diff --git a/src/editorExtension.ts b/src/editorExtension.ts index 0275889..da8f785 100644 --- a/src/editorExtension.ts +++ b/src/editorExtension.ts @@ -1,61 +1,60 @@ -import { Editor, EditorPosition } from "obsidian"; -import { stripMarkdown } from "./util"; +import { Editor, EditorPosition } from 'obsidian'; +import { stripMarkdown } from './util'; -declare module "obsidian" { +declare module 'obsidian' { interface Editor { - getWordLookup(): { [key: string]: EditorPosition[] } + getWordLookup(): { [key: string]: EditorPosition[] }; } } -Editor.prototype.getWordLookup = function (): { [key: string]: EditorPosition[] } { +Editor.prototype.getWordLookup = function (): { + [key: string]: EditorPosition[]; +} { const plainText = this.getValue(); // Split the plain text into word-objects, which contain the word and its position in the editor - const words: { word: string, pos: EditorPosition }[] = [] + const words: { word: string; pos: EditorPosition }[] = []; for (let i = 0; i < plainText.length; i++) { - if (plainText[i].match(/\s/)) - continue + if (plainText[i].match(/\s/)) continue; else { - let word = '' - let pos = this.offsetToPos(i) + let word = ''; + let pos = this.offsetToPos(i); - while (plainText[i] && !plainText[i].match(/\s/)) - word += plainText[i++] + while (plainText[i] && !plainText[i].match(/\s/)) word += plainText[i++]; - words.push({ word, pos }) + words.push({ word, pos }); } } // Remove Headings and obsidian links - const filteredWords = words - .filter(w => { - if (w.word.length <= 0) - return false - if (w.word.startsWith('[[') && w.word.endsWith(']]')) - return false - if (w.word.startsWith('#')) - return false + const filteredWords = words.filter((w) => { + if (w.word.length <= 0) return false; + if (w.word.startsWith('[[') && w.word.endsWith(']]')) return false; + if (w.word.startsWith('#')) return false; - return true - }); + return true; + }); // Create a lookup table for the words, where the key is the word and the value is an array of positions (occurrences of the word) - const wordLookup: { [key: string]: EditorPosition[] } = {} + const wordLookup: { [key: string]: EditorPosition[] } = {}; for (let i = 0; i < filteredWords.length; i++) { - const word = stripMarkdown(filteredWords[i].word) + const word = stripMarkdown(filteredWords[i].word); // Remove obsidian links which are longer than one word if (filteredWords[i].word.startsWith('[[')) { - while (!filteredWords[++i].word.endsWith(']]') || i >= filteredWords.length) { /* nothing */ } + while ( + !filteredWords[++i].word.endsWith(']]') || + i >= filteredWords.length + ) { + /* nothing */ + } } - if (word in wordLookup) - wordLookup[word].push(filteredWords[i].pos) - else - wordLookup[word] = [filteredWords[i].pos] + if (word in wordLookup) wordLookup[word].push(filteredWords[i].pos); + else wordLookup[word] = [filteredWords[i].pos]; } - return wordLookup -} + return wordLookup; +}; export default Editor; diff --git a/src/icons.ts b/src/icons.ts index 74ce4d3..aefe09b 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -1,14 +1,14 @@ -import { addIcon } from "obsidian"; +import { addIcon } from 'obsidian'; const crossbowIcon = { - name: "crossbow", + name: 'crossbow', svg: ` - ` -} + `, +}; export const registerCrossbowIcons = () => { - addIcon(crossbowIcon.name, crossbowIcon.svg) -} + addIcon(crossbowIcon.name, crossbowIcon.svg); +}; diff --git a/main.ts b/src/main.ts similarity index 52% rename from main.ts rename to src/main.ts index 5e3ce7c..85feb6b 100644 --- a/main.ts +++ b/src/main.ts @@ -1,9 +1,17 @@ -import { CrossbowView } from './src/view/view'; -import { registerCrossbowIcons } from './src/icons'; -import { CacheItem, Editor, MarkdownView, Plugin, TFile, EditorPosition, CachedMetadata } from 'obsidian'; -import { CrossbowSettingTab } from './src/settings'; -import './src/editorExtension'; -import { Match, Occurrence, Suggestion } from './src/suggestion'; +import { CrossbowView } from './view/view'; +import { registerCrossbowIcons } from './icons'; +import { + CacheItem, + Editor, + MarkdownView, + Plugin, + TFile, + EditorPosition, + CachedMetadata, +} from 'obsidian'; +import { CrossbowSettingTab } from './settings'; +import './editorExtension'; +import { Match, Occurrence, Suggestion } from './suggestion'; export interface CrossbowPluginSettings { ignoredWords: string[]; @@ -15,14 +23,14 @@ export interface CrossbowPluginSettings { } const DEFAULT_SETTINGS: CrossbowPluginSettings = { - ignoredWords: ["image", "the", "always", "some"], + ignoredWords: ['image', 'the', 'always', 'some'], suggestReferencesInSameFile: false, ignoreSuggestionsWhichStartWithLowercaseLetter: true, suggestedReferencesMinimumWordLength: 3, useLogging: false, -} +}; -type CustomCache = { [key: string]: CacheEntry } +type CustomCache = { [key: string]: CacheEntry }; export interface CacheEntry { file: TFile; @@ -37,38 +45,47 @@ export interface CacheMatch extends CacheEntry { export default class CrossbowPlugin extends Plugin { public settings: CrossbowPluginSettings; private _currentEditor: Editor; - public get currentEditor(): Editor { return this._currentEditor; } + public get currentEditor(): Editor { + return this._currentEditor; + } private _currentFile: TFile; - public get currentFile(): TFile { return this._currentFile; } + public get currentFile(): TFile { + return this._currentFile; + } private updateTimeout: ReturnType; - private readonly crossbowCache: CustomCache = {} + private readonly crossbowCache: CustomCache = {}; public runWithCacheUpdate = (fileHasChanged: boolean) => { const files = this.app.vault.getFiles(); files.forEach((file) => this.updateCrossbowCacheEntitiesOfFile(file)); this.runWithoutCacheUpdate(fileHasChanged); - } + }; public runWithoutCacheUpdate = (fileHasChanged: boolean) => { const data = this.getCrossbowSuggestionsInCurrentEditor(); this.getCrossbowView()?.updateSuggestions(data, fileHasChanged); - } + }; public onload = async () => { await this.loadSettings(); // Register view elements - registerCrossbowIcons() + registerCrossbowIcons(); Suggestion.register(); Occurrence.register(); Match.register(); - this.registerView(CrossbowView.viewType, leaf => new CrossbowView(leaf, this)); + this.registerView( + CrossbowView.viewType, + (leaf) => new CrossbowView(leaf, this) + ); // Ribbon icon to access the crossbow pane this.addRibbonIcon('crossbow', 'Crossbow', async (ev: MouseEvent) => { - const existing = this.app.workspace.getLeavesOfType(CrossbowView.viewType); + const existing = this.app.workspace.getLeavesOfType( + CrossbowView.viewType + ); if (existing.length) { this.app.workspace.revealLeaf(existing[0]); @@ -81,7 +98,7 @@ export default class CrossbowPlugin extends Plugin { }); this.app.workspace.revealLeaf( - this.app.workspace.getLeavesOfType(CrossbowView.viewType)[0], + this.app.workspace.getLeavesOfType(CrossbowView.viewType)[0] ); }); @@ -90,145 +107,200 @@ export default class CrossbowPlugin extends Plugin { // Register event handlers this.registerEvent(this.app.workspace.on('file-open', this.onFileOpen)); - this.registerEvent(this.app.metadataCache.on('changed', this.onMetadataChange)); + this.registerEvent( + this.app.metadataCache.on('changed', this.onMetadataChange) + ); this.debugLog('Crossbow is ready.'); - } + }; public onunload = () => { Object.assign(this.crossbowCache, {}); - this.getCrossbowView().unload() + this.getCrossbowView().unload(); this.debugLog('Unloaded Crossbow.'); - } + }; - public loadSettings = async () => this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + public loadSettings = async () => + (this.settings = Object.assign( + {}, + DEFAULT_SETTINGS, + await this.loadData() + )); - public loadDefaultSettings = async () => this.settings = DEFAULT_SETTINGS; + public loadDefaultSettings = async () => (this.settings = DEFAULT_SETTINGS); public saveSettings = async () => await this.saveData(this.settings); - public debugLog = (message: string) => this.settings.useLogging && console.log(`🏹: ${message}`); + public debugLog = (message: string) => + this.settings.useLogging && console.log(`🏹: ${message}`); - private onMetadataChange = (file: TFile, data: string, cache: CachedMetadata) => { + private onMetadataChange = ( + file: TFile, + data: string, + cache: CachedMetadata + ) => { this.updateCrossbowCacheEntitiesOfFile(file, cache); this.debugLog(`Metadata cache updated for ${file.basename}.`); - if (this.updateTimeout) - clearTimeout(this.updateTimeout) + if (this.updateTimeout) clearTimeout(this.updateTimeout); this.updateTimeout = setTimeout(() => { - this.runWithoutCacheUpdate(false) + this.runWithoutCacheUpdate(false); }, 700); - } + }; private onFileOpen = () => { const prevCurrentFile = this._currentFile; - this.setActiveEditorAndFile() + this.setActiveEditorAndFile(); this.debugLog('File opened.'); - if (this.updateTimeout) - clearTimeout(this.updateTimeout) + if (this.updateTimeout) clearTimeout(this.updateTimeout); this.updateTimeout = setTimeout(() => { - if (!prevCurrentFile) - this.runWithCacheUpdate(true); // Initial run + if (!prevCurrentFile) this.runWithCacheUpdate(true); // Initial run else if (this._currentFile !== prevCurrentFile) - this.runWithoutCacheUpdate(true) // Opened a different file + this.runWithoutCacheUpdate(true); // Opened a different file }, 200); - } + }; - private getCrossbowView = (): CrossbowView => app.workspace.getLeavesOfType(CrossbowView.viewType)[0]?.view as CrossbowView; + private getCrossbowView = (): CrossbowView => + app.workspace.getLeavesOfType(CrossbowView.viewType)[0] + ?.view as CrossbowView; - private addOrUpdateCacheEntity = (entity: CacheEntry) => this.crossbowCache[entity.text] = entity; + private addOrUpdateCacheEntity = (entity: CacheEntry) => + (this.crossbowCache[entity.text] = entity); private getCrossbowSuggestionsInCurrentEditor = (): Suggestion[] => { const result: Suggestion[] = []; const wordLookup = this.currentEditor.getWordLookup(); - Object.entries(wordLookup).forEach(entry => { + Object.entries(wordLookup).forEach((entry) => { const [word, editorPositions] = entry; const matchSet: Set = new Set(); - // Find matches - Object.keys(this.crossbowCache).forEach(crossbowCacheKey => { + // Find matches + Object.keys(this.crossbowCache).forEach((crossbowCacheKey) => { // If we have a complete match, we always add it, even if it does not satisfy the filters. Say we have a chapter with a heading 'C' (the programming language) // We do want to match a word 'C' in the current editor. if (crossbowCacheKey === word) { - matchSet.add({ ...this.crossbowCache[crossbowCacheKey], rank: '🏆' }); + matchSet.add({ + ...this.crossbowCache[crossbowCacheKey], + rank: '🏆', + }); return; } if (crossbowCacheKey.toLowerCase() === word.toLowerCase()) { - matchSet.add({ ...this.crossbowCache[crossbowCacheKey], rank: '🥇' }); + matchSet.add({ + ...this.crossbowCache[crossbowCacheKey], + rank: '🥇', + }); return; } // Hard-filters on words of current editor: // If the word is too short, skip - if (word.length <= 3) - return; - if (this.settings.ignoredWords.includes(word)) - return; + if (word.length <= 3) return; + if (this.settings.ignoredWords.includes(word)) return; // Hard-filters on cache keys: // If the cache key is too short, skip - if (crossbowCacheKey.length <= this.settings.suggestedReferencesMinimumWordLength) + if ( + crossbowCacheKey.length <= + this.settings.suggestedReferencesMinimumWordLength + ) return; // If reference is in the same file, and we don't want to suggest references in the same file, skip - if (!this.settings.suggestReferencesInSameFile && this.crossbowCache[crossbowCacheKey].file === this._currentFile) + if ( + !this.settings.suggestReferencesInSameFile && + this.crossbowCache[crossbowCacheKey].file === this._currentFile + ) return; // If the word is not a substring of the key or the key is not a substring of the word, skip - if ((crossbowCacheKey.toLowerCase().includes(word.toLowerCase()) || word.toLowerCase().includes(crossbowCacheKey.toLowerCase())) === false) + if ( + (crossbowCacheKey.toLowerCase().includes(word.toLowerCase()) || + word.toLowerCase().includes(crossbowCacheKey.toLowerCase())) === + false + ) return; // If the word does not start with an uppercase letter, skip - if (this.settings.ignoreSuggestionsWhichStartWithLowercaseLetter && (word.charCodeAt(0) === word.charAt(0).toLowerCase().charCodeAt(0))) + if ( + this.settings.ignoreSuggestionsWhichStartWithLowercaseLetter && + word.charCodeAt(0) === word.charAt(0).toLowerCase().charCodeAt(0) + ) return; // Soft-filters: // If the lengths differ too much, add as not-very-good suggestion - if ((1 / crossbowCacheKey.length * word.length) <= 0.2) { - matchSet.add({ ...this.crossbowCache[crossbowCacheKey], rank: '🥉' }); + if ((1 / crossbowCacheKey.length) * word.length <= 0.2) { + matchSet.add({ + ...this.crossbowCache[crossbowCacheKey], + rank: '🥉', + }); return; } - matchSet.add({ ...this.crossbowCache[crossbowCacheKey], rank: '🥈' }); - }) + matchSet.add({ + ...this.crossbowCache[crossbowCacheKey], + rank: '🥈', + }); + }); if (matchSet.size > 0) { - result.push(new Suggestion(word, editorPositions, Array.from(matchSet))); + result.push( + this.getCrossbowView().createSuggestion( + word, + editorPositions, + Array.from(matchSet) + ) + ); } - }) + }); return result; - } + }; // 'cache' can be passed in, if this is called from an event handler which already has the cache // This will prevent the cache from being retrieved twice - private updateCrossbowCacheEntitiesOfFile = (file: TFile, cache?: CachedMetadata) => { - if (file.extension !== 'md') - return; + private updateCrossbowCacheEntitiesOfFile = ( + file: TFile, + cache?: CachedMetadata + ) => { + if (file.extension !== 'md') return; const metadata = cache ? cache : app.metadataCache.getFileCache(file); - if (file.basename.length >= this.settings.suggestedReferencesMinimumWordLength) + if ( + file.basename.length >= this.settings.suggestedReferencesMinimumWordLength + ) this.addOrUpdateCacheEntity({ file, text: file.basename }); if (metadata) { if (metadata.headings) - metadata.headings.forEach(headingCache => this.addOrUpdateCacheEntity({ item: headingCache, file, text: headingCache.heading })); + metadata.headings.forEach((headingCache) => + this.addOrUpdateCacheEntity({ + item: headingCache, + file, + text: headingCache.heading, + }) + ); if (metadata.tags) - metadata.tags.forEach(tagCache => this.addOrUpdateCacheEntity({ item: tagCache, file, text: tagCache.tag })); + metadata.tags.forEach((tagCache) => + this.addOrUpdateCacheEntity({ + item: tagCache, + file, + text: tagCache.tag, + }) + ); } - } + }; private setActiveEditorAndFile = (): void => { const leaf = this.app.workspace.getMostRecentLeaf(); if (leaf?.view instanceof MarkdownView) { this._currentEditor = leaf.view.editor; this._currentFile = leaf.view.file; - } - else - console.warn('🏹: Unable to determine current editor.'); - } + } else console.warn('🏹: Unable to determine current editor.'); + }; } diff --git a/src/settings.ts b/src/settings.ts index bf48e30..dd1047c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,5 +1,5 @@ import { App, PluginSettingTab, Setting } from 'obsidian'; -import CrossbowPlugin, { CrossbowPluginSettings } from '../main'; +import CrossbowPlugin, { CrossbowPluginSettings } from './main'; export class CrossbowSettingTab extends PluginSettingTab { plugin: CrossbowPlugin; @@ -17,48 +17,98 @@ export class CrossbowSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Ignored Words') - .setDesc('A case-sensitive, comma separated list of words to ignore when searching for linkables. (Whitepaces will be trimmed)') - .addText(text => text - .setValue(this.plugin.settings.ignoredWords?.join(", ") ?? "") - .onChange(async value => await this.updateSettingValue('ignoredWords', value.split(",").map(word => word.trim())))); + .setDesc( + 'A case-sensitive, comma separated list of words to ignore when searching for linkables. (Whitepaces will be trimmed)' + ) + .addText((text) => + text + .setValue(this.plugin.settings.ignoredWords?.join(', ') ?? '') + .onChange( + async (value) => + await this.updateSettingValue( + 'ignoredWords', + value.split(',').map((word) => word.trim()) + ) + ) + ); new Setting(containerEl) .setName('Ignore suggestions which start with a lowercase letter') - .setDesc('If checked, suggestions which start with a lowercase letter will be ignored') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.ignoreSuggestionsWhichStartWithLowercaseLetter) - .onChange(async value => await this.updateSettingValue('ignoreSuggestionsWhichStartWithLowercaseLetter', value))); + .setDesc( + 'If checked, suggestions which start with a lowercase letter will be ignored' + ) + .addToggle((toggle) => + toggle + .setValue( + this.plugin.settings.ignoreSuggestionsWhichStartWithLowercaseLetter + ) + .onChange( + async (value) => + await this.updateSettingValue( + 'ignoreSuggestionsWhichStartWithLowercaseLetter', + value + ) + ) + ); new Setting(containerEl) .setName('Suggest references in same file') - .setDesc('If checked, references (Headers, Tags) to items in the same file will be suggested') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.suggestReferencesInSameFile) - .onChange(async value => await this.updateSettingValue('suggestReferencesInSameFile', value))); + .setDesc( + 'If checked, references (Headers, Tags) to items in the same file will be suggested' + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.suggestReferencesInSameFile) + .onChange( + async (value) => + await this.updateSettingValue( + 'suggestReferencesInSameFile', + value + ) + ) + ); new Setting(containerEl) .setName('Minimum word length of suggestions') - .setDesc('Defines the min. length a cached word must have for it to be considered a suggestion') - .addText(text => text - .setValue(this.plugin.settings.suggestedReferencesMinimumWordLength.toString()) - .onChange(async value => { - if (!/^\s*\d+\s*$/.test(value)) - console.error(`Cannot set "suggestedReferencesMinimumWordLength" to NaN. Must be integer`); - else - await this.updateSettingValue('suggestedReferencesMinimumWordLength', parseInt(value, 10)); - })); + .setDesc( + 'Defines the min. length a cached word must have for it to be considered a suggestion' + ) + .addText((text) => + text + .setValue( + this.plugin.settings.suggestedReferencesMinimumWordLength.toString() + ) + .onChange(async (value) => { + if (!/^\s*\d+\s*$/.test(value)) + console.error( + `Cannot set "suggestedReferencesMinimumWordLength" to NaN. Must be integer` + ); + else + await this.updateSettingValue( + 'suggestedReferencesMinimumWordLength', + parseInt(value, 10) + ); + }) + ); new Setting(containerEl) .setName('Enable logging') .setDesc('If checked, debug logs will be printed to the console') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.useLogging) - .onChange(async value => await this.updateSettingValue('useLogging', value))); + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.useLogging) + .onChange( + async (value) => await this.updateSettingValue('useLogging', value) + ) + ); } - private updateSettingValue = async (key: K, value: CrossbowPluginSettings[K]) => { + private updateSettingValue = async ( + key: K, + value: CrossbowPluginSettings[K] + ) => { this.plugin.settings[key] = value; await this.plugin.saveSettings(); this.plugin.runWithCacheUpdate(true); - } + }; } diff --git a/src/suggestion.ts b/src/suggestion.ts index c594b33..fa2c518 100644 --- a/src/suggestion.ts +++ b/src/suggestion.ts @@ -1,70 +1,57 @@ -import { EditorPosition } from "obsidian"; -import { CacheMatch } from "../main"; -import { TreeItem, TreeItemBase } from "./view/treeItem"; +import { EditorPosition } from 'obsidian'; +import { CacheMatch } from './main'; +import { TreeItem, TreeItemBase } from './view/treeItem'; export class Suggestion extends TreeItem { - public constructor(word: string, editorPositions: EditorPosition[], public readonly cacheMatches: CacheMatch[]) { + public constructor(word: string, public readonly cacheMatches: CacheMatch[]) { super(word); - this.addChildren(editorPositions.map(position => new Occurrence(position, this.cacheMatches))); } - public static register = (): void => customElements.define('crossbow-suggestion', Suggestion); + public static register = (): void => + customElements.define('crossbow-suggestion', Suggestion); - public get hash(): string { return this.value; } + public get hash(): string { + return this.value; + } + public get text(): string { + return this.value; + } - public get text(): string { return this.value; } - - public getChildren = () => - Array.from(this.childrenWrapper.children) as Occurrence[]; + public getChildren = () => + Array.from(this.childrenWrapper.children) as Occurrence[]; public sortChildren(): void { this.getChildren() .sort((a, b) => a.value.line - b.value.line) - .forEach(child => { + .forEach((child) => { this.appendChild(child); - child.sortChildren() + child.sortChildren(); }); } - - public addChildren(children: Occurrence[]): void { - children.forEach(child => this.appendChild(child)); - } - - public attach = (parent: HTMLElement, insertBeforeIndex: number): void => { - super.attach(parent, insertBeforeIndex); - - const ranks = new Set(); - this.cacheMatches.forEach(match => ranks.add(match.rank)); - - const availableMatchRanks = Array.from(ranks).sort((a, b) => a.codePointAt(0)! - b.codePointAt(0)!).join(''); - - this.addFlair(availableMatchRanks); - this.addTextSuffix(`(${this.children.length.toString()})`); - } } export class Occurrence extends TreeItem { - public constructor(value: EditorPosition, cacheMatches: CacheMatch[]) { + public constructor(value: EditorPosition) { super(value); - this.addChildren(cacheMatches.map(match => new Match(match))); } - public static register = (): void => customElements.define('crossbow-occurrence', Occurrence); + public static register = (): void => + customElements.define('crossbow-occurrence', Occurrence); - public get hash(): string { return `${this.value.line}:${this.value.ch}`; } - - public get text(): string { return `On line ${this.value.line}:${this.value.ch}` } + public get hash(): string { + return `${this.value.line}:${this.value.ch}`; + } + public get text(): string { + return `On line ${this.value.line}:${this.value.ch}`; + } public getChildren = () => Array.from(this.childrenWrapper.children) as Match[]; public sortChildren(): void { - this.getChildren() - .sort((a, b) => a.value.rank.codePointAt(0)! - b.value.rank.codePointAt(0)!) - } - - public addChildren(children: Match[]): void { - children.forEach(child => this.appendChild(child)); + this.getChildren().sort( + (a, b) => a.value.rank.codePointAt(0)! - b.value.rank.codePointAt(0)! + ); } } @@ -73,11 +60,16 @@ export class Match extends TreeItemBase { super(value); } - public static register = (): void => customElements.define('crossbow-match', Match); + public static register = (): void => + customElements.define('crossbow-match', Match); - public get hash(): string { return `${this.value.text}|${this.value.file.path}`; } + public get hash(): string { + return `${this.value.text}|${this.value.file.path}`; + } - public get text(): string { return `${this.value.rank} ${this.value.text}` } + public get text(): string { + return `${this.value.rank} ${this.value.text}`; + } - public sortChildren(): void { } + public sortChildren(): void {} } diff --git a/src/util.ts b/src/util.ts index cd7bbd3..7077152 100644 --- a/src/util.ts +++ b/src/util.ts @@ -20,13 +20,23 @@ export interface StripMarkdownOptions { export function stripMarkdown(md: string, options?: StripMarkdownOptions) { options = options || {}; - options.listUnicodeChar = options.hasOwnProperty('listUnicodeChar') ? options.listUnicodeChar : undefined; - options.stripListLeaders = options.hasOwnProperty('stripListLeaders') ? options.stripListLeaders : true; + options.listUnicodeChar = options.hasOwnProperty('listUnicodeChar') + ? options.listUnicodeChar + : undefined; + options.stripListLeaders = options.hasOwnProperty('stripListLeaders') + ? options.stripListLeaders + : true; options.gfm = options.hasOwnProperty('gfm') ? options.gfm : true; - options.useImgAltText = options.hasOwnProperty('useImgAltText') ? options.useImgAltText : true; + options.useImgAltText = options.hasOwnProperty('useImgAltText') + ? options.useImgAltText + : true; options.abbr = options.hasOwnProperty('abbr') ? options.abbr : undefined; - options.replaceLinksWithUrl = options.hasOwnProperty('replaceLinksWithURL') ? options.replaceLinksWithUrl : true; - options.htmlTagsToSkip = options.hasOwnProperty('htmlTagsToSkip') ? options.htmlTagsToSkip : []; + options.replaceLinksWithUrl = options.hasOwnProperty('replaceLinksWithURL') + ? options.replaceLinksWithUrl + : true; + options.htmlTagsToSkip = options.hasOwnProperty('htmlTagsToSkip') + ? options.htmlTagsToSkip + : []; var output = md || ''; @@ -36,9 +46,11 @@ export function stripMarkdown(md: string, options?: StripMarkdownOptions) { try { if (options.stripListLeaders) { if (options.listUnicodeChar) - output = output.replace(/^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, options.listUnicodeChar + ' $1'); - else - output = output.replace(/^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, '$1'); + output = output.replace( + /^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, + options.listUnicodeChar + ' $1' + ); + else output = output.replace(/^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, '$1'); } if (options.gfm) { output = output @@ -57,18 +69,17 @@ export function stripMarkdown(md: string, options?: StripMarkdownOptions) { } output = output // Remove HTML tags - .replace(/<[^>]*>/g, '') + .replace(/<[^>]*>/g, ''); var htmlReplaceRegex = new RegExp('<[^>]*>', 'g'); if (options.htmlTagsToSkip!.length > 0) { // Using negative lookahead. Eg. (?!sup|sub) will not match 'sup' and 'sub' tags. - var joinedHtmlTagsToSkip = '(?!' + options.htmlTagsToSkip!.join("|") + ')'; + var joinedHtmlTagsToSkip = + '(?!' + options.htmlTagsToSkip!.join('|') + ')'; // Adding the lookahead literal with the default regex for html. Eg./<(?!sup|sub)[^>]*>/ig htmlReplaceRegex = new RegExp( - '<' + - joinedHtmlTagsToSkip + - '[^>]*>', + '<' + joinedHtmlTagsToSkip + '[^>]*>', 'ig' ); } @@ -84,17 +95,23 @@ export function stripMarkdown(md: string, options?: StripMarkdownOptions) { // Remove images .replace(/\!\[(.*?)\][\[\(].*?[\]\)]/g, options.useImgAltText ? '$1' : '') // Remove inline links - .replace(/\[([^\]]*?)\][\[\(].*?[\]\)]/g, options.replaceLinksWithUrl ? '$2' : '$1') + .replace( + /\[([^\]]*?)\][\[\(].*?[\]\)]/g, + options.replaceLinksWithUrl ? '$2' : '$1' + ) // Remove blockquotes .replace(/^\s{0,3}>\s?/gm, '') // .replace(/(^|\n)\s{0,3}>\s?/g, '\n\n') // Remove reference-style links? .replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, '') // Remove atx-style headers - .replace(/^(\n)?\s{0,}#{1,6}\s+| {0,}(\n)?\s{0,}#{0,} #{0,}(\n)?\s{0,}$/gm, '$1$2$3') + .replace( + /^(\n)?\s{0,}#{1,6}\s+| {0,}(\n)?\s{0,}#{0,} #{0,}(\n)?\s{0,}$/gm, + '$1$2$3' + ) // Remove * emphasis .replace(/([\*]+)(\S)(.*?\S)??\1/g, '$2$3') - // Remove _ emphasis. Unlike *, _ emphasis gets rendered only if + // Remove _ emphasis. Unlike *, _ emphasis gets rendered only if // 1. Either there is a whitespace character before opening _ and after closing _. // 2. Or _ is at the start/end of the string. .replace(/(^|\W)([_]+)(\S)(.*?\S)??\2($|\W)/g, '$1$3$4$5') @@ -113,4 +130,4 @@ export function stripMarkdown(md: string, options?: StripMarkdownOptions) { return md; } return output; -}; +} diff --git a/src/view/treeItem.ts b/src/view/treeItem.ts index 8affbe5..076334f 100644 --- a/src/view/treeItem.ts +++ b/src/view/treeItem.ts @@ -1,52 +1,71 @@ -import { ButtonComponent, getIcon } from "obsidian"; +import { ButtonComponent, getIcon } from 'obsidian'; export abstract class TreeItemBase extends HTMLElement { - private inner: HTMLDivElement; - private suffix: HTMLSpanElement; - private flair: HTMLSpanElement; - private buttons: ButtonComponent[] = []; - protected mainWrapper: HTMLDivElement; - protected flairWrapper: HTMLDivElement; + private readonly inner: HTMLDivElement; + private readonly suffix: HTMLSpanElement; + private readonly flair: HTMLSpanElement; + private readonly buttons: ButtonComponent[] = []; + protected readonly mainWrapper: HTMLDivElement; + protected readonly flairWrapper: HTMLDivElement; public readonly value: TData; + public abstract get hash(): string; + public abstract get text(): string; + constructor(value: TData) { super(); this.value = value; - this.addClass("tree-item"); - this.mainWrapper = this.createDiv({ cls: 'tree-item-self is-clickable' }); + this.addClass('tree-item'); + this.mainWrapper = this.createDiv({ + cls: 'tree-item-self is-clickable', + }); - this.inner = this.mainWrapper.createDiv({ cls: 'tree-item-inner tree-item-inner-extensions' }); - this.flairWrapper = this.mainWrapper.createDiv({ cls: 'tree-item-flair-outer' }); + this.inner = this.mainWrapper.createDiv({ + cls: 'tree-item-inner tree-item-inner-extensions', + }); + this.flairWrapper = this.mainWrapper.createDiv({ + cls: 'tree-item-flair-outer', + }); - this.suffix = this.inner.createEl('span', { cls: 'tree-item-inner-suffix' }); - this.flair = this.flairWrapper.createEl('span', { cls: 'tree-item-flair' }); + this.suffix = this.inner.createEl('span', { + cls: 'tree-item-inner-suffix', + }); + this.flair = this.flairWrapper.createEl('span', { + cls: 'tree-item-flair', + }); + } + + public connectedCallback() { + this.inner.setText(this.text); } public abstract sortChildren(): void; - public abstract get hash(): string; - - public abstract get text(): string; - public setDisable = () => { this.mainWrapper.style.textDecoration = 'line-through'; - this.buttons.forEach(button => button.setDisabled(true)); - } + this.buttons.forEach((button) => button.setDisabled(true)); + }; - public addOnClick = (listener: (this: HTMLDivElement, ev: HTMLElementEventMap['click']) => any) => { + public addOnClick = ( + listener: (this: HTMLDivElement, ev: HTMLElementEventMap['click']) => any + ) => { this.mainWrapper.addEventListener('click', listener); - } + }; public addFlair = (text: string) => { this.flair.innerText = text; - } + }; public addTextSuffix = (text: string) => { this.suffix.innerText = text; - } + }; - public addButton = (label: string, iconName: string, onclick: (this: HTMLDivElement, ev: MouseEvent) => any) => { + public addButton = ( + label: string, + iconName: string, + onclick: (this: HTMLDivElement, ev: MouseEvent) => any + ) => { const button = new ButtonComponent(this.mainWrapper); button.setTooltip(label); @@ -55,17 +74,12 @@ export abstract class TreeItemBase extends HTMLElement { button.onClick(onclick); this.buttons.push(button); - } - - public attach = (parent: HTMLElement, insertBeforeIndex?: number) => { - this.inner.setText(this.text); - insertBeforeIndex === undefined ? parent.appendChild(this) : parent.insertBefore(this, parent.children[insertBeforeIndex]); - } + }; } export abstract class TreeItem extends TreeItemBase { - protected childrenWrapper: HTMLDivElement; - private iconWrapper: HTMLDivElement; + protected readonly childrenWrapper: HTMLDivElement; + private readonly iconWrapper: HTMLDivElement; public constructor(value: TData) { super(value); @@ -76,35 +90,47 @@ export abstract class TreeItem extends TreeItemBase { this.childrenWrapper = createEl('div', { cls: 'tree-item-children' }); this.childrenWrapper.style.display = 'none'; - this.iconWrapper = createEl('div', { cls: ['tree-item-icon', 'collapse-icon'] }) + this.iconWrapper = createEl('div', { + cls: ['tree-item-icon', 'collapse-icon'], + }); this.iconWrapper.appendChild(getIcon('right-triangle')!); this.appendChild(this.childrenWrapper); this.mainWrapper.prepend(this.iconWrapper); // Collapse / Fold - this.mainWrapper.addEventListener('click', () => this.isCollapsed() ? this.expand() : this.collapse()); + this.mainWrapper.addEventListener('click', () => + this.isCollapsed() ? this.expand() : this.collapse() + ); } - public abstract getChildren() : TreeItemBase[]; + public abstract getChildren(): TreeItemBase[]; - public abstract addChildren(children: TreeItemBase[]): void; - - public isCollapsed = () => this.hasClass("is-collapsed"); + public isCollapsed = () => this.hasClass('is-collapsed'); public expand = () => { - this.removeClass("is-collapsed"); - this.childrenWrapper.style.display = "block"; - } + this.removeClass('is-collapsed'); + this.childrenWrapper.style.display = 'block'; + }; public collapse = () => { - this.addClass("is-collapsed"); + this.addClass('is-collapsed'); this.childrenWrapper.style.display = 'none'; - } + }; public setDisable = () => { super.setDisable(); this.mainWrapper.style.textDecoration = 'line-through'; - Array.from(this.childrenWrapper.children).forEach(child => (child as TreeItemBase).setDisable()); - } + Array.from(this.childrenWrapper.children).forEach((child) => + (child as TreeItemBase).setDisable() + ); + }; + + public addChildren = (children: TreeItemBase[]) => { + console.log('addChildren', this.childrenWrapper, children); + + children.forEach((child) => { + this.childrenWrapper.appendChild(child); + }); + }; } diff --git a/src/view/view.ts b/src/view/view.ts index 094882b..cd451d4 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,11 +1,6 @@ -import CrossbowPlugin, { CacheMatch } from '../../main'; -import { - EditorPosition, - ItemView, - WorkspaceLeaf, -} from 'obsidian'; -import { TreeItem, TreeItemBase } from './treeItem'; +import { EditorPosition, ItemView, WorkspaceLeaf } from 'obsidian'; import { Match, Occurrence, Suggestion } from 'src/suggestion'; +import CrossbowPlugin, { CacheMatch } from 'src/main'; export class CrossbowView extends ItemView { private readonly crossbow: CrossbowPlugin; @@ -37,86 +32,159 @@ export class CrossbowView extends ItemView { public clear = (): void => this.contentEl.empty(); private getCurrentSuggestions = (): Suggestion[] => - this.contentEl.children.length > 0 ? Array.from(this.contentEl.children) as Suggestion[] : []; + this.contentEl.children.length > 0 + ? (Array.from(this.contentEl.children) as Suggestion[]) + : []; - public updateSuggestions = (suggestions: Suggestion[], fileHasChanged: boolean) => { - this.crossbow.debugLog(`${fileHasChanged ? "Clearing & adding" : "Updating"} suggestions`); + public updateSuggestions = ( + suggestions: Suggestion[], + fileHasChanged: boolean + ) => { + this.crossbow.debugLog( + `${fileHasChanged ? 'Clearing & adding' : 'Updating'} suggestions` + ); - const currentSuggestions = this.getCurrentSuggestions() + const currentSuggestions = this.getCurrentSuggestions(); if (fileHasChanged) { this.clear(); } - // Sort suggestions + // Sort suggestions.sort((a, b) => a.hash.localeCompare(b.hash)); - suggestions.forEach(suggestion => suggestion.sortChildren()); + suggestions.forEach((suggestion) => suggestion.sortChildren()); suggestions.forEach((suggestion, index) => { - // Find if this treeItem already exists - const existingSuggestionIndex = currentSuggestions.findIndex(item => item.hash === suggestion.hash); - const existingSuggestion = existingSuggestionIndex !== -1 ? - currentSuggestions.splice(existingSuggestionIndex, 1)[0] : - undefined; + // Find if this Suggestion already exists + const existingSuggestionIndex = currentSuggestions.findIndex( + (item) => item.hash === suggestion.hash + ); + const existingSuggestion = + existingSuggestionIndex !== -1 + ? currentSuggestions.splice(existingSuggestionIndex, 1)[0] + : undefined; - const expandedOccurrencesHashes = existingSuggestion ? - existingSuggestion.getChildren().filter(item => !item.isCollapsed()).map(item => item.hash) : - []; + const expandedOccurrencesHashes = existingSuggestion + ? existingSuggestion + .getChildren() + .filter((item) => !item.isCollapsed()) + .map((item) => item.hash) + : []; - // Insert index, so we keep the sorted order - suggestion.attach(this.contentEl, index); - // Configure Occurrences - suggestion.getChildren().forEach(occurrence => { + suggestion.getChildren().forEach((occurrence) => { // Toggle expanded state, if it was expanded before if (expandedOccurrencesHashes.includes(occurrence.hash)) { occurrence.expand(); } + }); - // Scroll into view action - const scrollIntoView = () => { - const occurrenceEnd = { ch: occurrence.value.ch + suggestion.hash.length, line: occurrence.value.line } as EditorPosition - this.crossbow.currentEditor.setSelection(occurrence.value, occurrenceEnd); - this.crossbow.currentEditor.scrollIntoView({ from: occurrence.value, to: occurrenceEnd }, true) - } + // Insert / append the new suggestion, depending on whether it already existed + existingSuggestion + ? this.contentEl.replaceChild(suggestion, existingSuggestion) + : this.contentEl.appendChild(suggestion); + existingSuggestion?.remove(); - // Can be invoked via flair button - occurrence.addButton("Scroll into View", "lucide-scroll", (ev: MouseEvent) => { + // Add flair + const ranks = new Set(); + suggestion.cacheMatches.forEach((match) => ranks.add(match.rank)); + + const availableMatchRanks = Array.from(ranks) + .sort((a, b) => a.codePointAt(0)! - b.codePointAt(0)!) + .join(''); + + suggestion.addFlair(availableMatchRanks); + suggestion.addTextSuffix(`(${suggestion.children.length.toString()})`); + }); + + // Now, we're left with the items that we need to remove + currentSuggestions.forEach((item) => item.remove()); + }; + + public createSuggestion = ( + word: string, + editorPositions: EditorPosition[], + cacheMatches: CacheMatch[] + ): Suggestion => { + const suggestion = new Suggestion(word, cacheMatches); + const occurrences = editorPositions.map((p) => new Occurrence(p)); + + occurrences.forEach((occurrence) => { + const matches = suggestion.cacheMatches.map((m) => new Match(m)); + occurrence.addChildren(matches); + + // Scroll into view action + const scrollIntoView = () => { + const occurrenceEnd = { + ch: occurrence.value.ch + suggestion.hash.length, + line: occurrence.value.line, + } as EditorPosition; + this.crossbow.currentEditor.setSelection( + occurrence.value, + occurrenceEnd + ); + this.crossbow.currentEditor.scrollIntoView( + { from: occurrence.value, to: occurrenceEnd }, + true + ); + }; + + // Can be invoked via flair button... + occurrence.addButton( + 'Scroll into View', + 'lucide-scroll', + (ev: MouseEvent) => { scrollIntoView(); ev.preventDefault(); ev.stopPropagation(); + } + ); + + // As well as when expanding the suggestions, if it's collapsed. Greatly improves UX + occurrence.addOnClick(() => { + if (!occurrence.isCollapsed()) scrollIntoView(); + }); + + // Configure Matches + occurrence.getChildren().forEach((match) => { + // Add backlink & remove action + match.addButton('Use', 'lucide-inspect', () => { + occurrence.getChildren().forEach((o) => o.setDisable()); + + const occurrenceEnd = { + ch: occurrence.value.ch + suggestion.hash.length, + line: occurrence.value.line, + } as EditorPosition; + const link = match.value.item + ? this.app.fileManager.generateMarkdownLink( + match.value.file, + match.value.text, + '#' + match.value.text, + suggestion.hash + ) + : this.app.fileManager.generateMarkdownLink( + match.value.file, + match.value.text, + undefined, + suggestion.hash + ); + + this.crossbow.currentEditor.replaceRange( + link, + occurrence.value, + occurrenceEnd + ); }); - // As well as when expanding the suggestions, if it's collapsed. Greatly improves UX - occurrence.addOnClick(() => { - if (!occurrence.isCollapsed()) - scrollIntoView(); - }); - - // Configure Matches - occurrence.getChildren().forEach(match => { - // Add backlink & remove action - match.addButton("Use", 'lucide-inspect', () => { - occurrence.getChildren() - .forEach(o => o.setDisable()); - - const occurrenceEnd = { ch: occurrence.value.ch + suggestion.hash.length, line: occurrence.value.line } as EditorPosition - const link = match.value.item ? - this.app.fileManager.generateMarkdownLink(match.value.file, match.value.text, "#" + match.value.text, suggestion.hash) : - this.app.fileManager.generateMarkdownLink(match.value.file, match.value.text, undefined, suggestion.hash); - - this.crossbow.currentEditor.replaceRange(link, occurrence.value, occurrenceEnd); - }); - - // Go to source action - match.addButton("Go To Source", 'lucide-search', () => { - console.warn("🏹: 'Go To Source' is not yet implemented"); - }); + // Go to source action + match.addButton('Go To Source', 'lucide-search', () => { + console.warn("🏹: 'Go To Source' is not yet implemented"); }); }); }); - // Now, we're left with the items that we need to remove - currentSuggestions.forEach(item => item.remove()); - } + suggestion.addChildren(occurrences); + + return suggestion; + }; }