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;
+ };
}