Updates view, formats codebase

This commit is contained in:
Simon Schödler 2023-04-04 09:24:49 +02:00
parent 2378bdc0c1
commit 63b8949f20
10 changed files with 534 additions and 305 deletions

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}

View file

@ -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));

View file

@ -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;

View file

@ -1,14 +1,14 @@
import { addIcon } from "obsidian";
import { addIcon } from 'obsidian';
const crossbowIcon = {
name: "crossbow",
name: 'crossbow',
svg: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512.002 512.002" xmlns:v="https://vecta.io/nano" fill="currentColor">
<path d="M505.753 354.901l-15.083-15.083c-19.702-19.702-49.044-23.859-72.839-12.5l-43.878-43.878-13.906-208.61 23.327-23.327c8.331-8.331 8.331-21.839 0-30.17s-21.839-8.331-30.17 0l-30.165 30.165c-16.716 16.716-68.646 36.398-92.869 41.533-11.745 2.518-23.28 6.645-34.556 12.071l-53.605-53.605c-8.332-8.332-21.842-8.331-30.173.003l-15.08 15.086-23.91-23.91h8.819c11.782 0 21.333-9.551 21.333-21.333S93.447.01 81.665.01H21.513a21.41 21.41 0 0 0-4.441.428c-.103.021-.201.052-.304.074-.578.126-1.152.268-1.72.442-.134.041-.263.094-.395.138-.528.174-1.054.358-1.57.575-.12.05-.234.111-.353.164-.517.228-1.029.467-1.53.737-.091.049-.177.107-.268.158-.511.285-1.017.584-1.509.916-.061.041-.116.088-.177.13-1.052.726-2.061 1.54-2.996 2.476-.885.885-1.655 1.838-2.351 2.826-.09.127-.189.245-.275.374-.281.418-.53.849-.779 1.282-.099.172-.208.336-.302.511-.218.406-.407.822-.597 1.238-.101.221-.212.435-.306.66-.161.389-.295.785-.432 1.18-.093.266-.197.527-.279.798-.117.384-.204.773-.299 1.162-.071.291-.154.577-.213.872-.086.431-.14.866-.199 1.301-.035.258-.086.511-.111.771-.07.703-.107 1.409-.107 2.114v60.335c0 11.782 9.551 21.333 21.333 21.333s21.333-9.551 21.333-21.333v-8.836L66.591 96.76l-15.086 15.091c-8.329 8.332-8.327 21.838.003 30.168l53.066 53.062c-4.78 9.663-8.35 19.301-10.444 28.877a92.9 92.9 0 0 0-1.378 7.89c-1.967 15.328-24.484 74.421-41.245 91.182l-30.165 30.165c-8.331 8.331-8.331 21.839 0 30.17s21.839 8.331 30.17 0l23.318-23.318 208.628 13.906 43.863 43.86c-11.366 23.793-7.209 53.155 12.495 72.859l15.083 15.083c8.331 8.331 21.839 8.331 30.17 0l120.683-120.683c8.332-8.332 8.332-21.84.001-30.171zM158.582 188.747c2.639-3.559 5.336-6.989 8.038-10.208 2.747-3.272 4.819-5.561 5.868-6.653.571-.543 2.88-2.632 6.269-5.428 3.156-2.604 6.54-5.211 10.105-7.768l148.811 148.811.018.018 47.378 47.378-30.161 30.161-196.326-196.311zm80.484-53.987c7.095-1.504 28.104-7.881 46.173-14.729 12.532-4.75 23.942-9.708 34.064-14.941l8.834 132.535-99.868-99.868c3.626-1.223 7.23-2.232 10.797-2.997zm-127.433-22.707c.072-.07.15-.129.221-.2s.131-.151.202-.223l14.871-14.876 31.395 31.395c-6.755 5.127-12.201 9.867-16.113 13.686-4.056 4.209-8.919 9.76-14.107 16.434L96.761 126.93l14.872-14.877zm-6.789 207.232c15.579-29.378 28.148-65.788 30.226-81.986.174-1.348.423-2.765.743-4.228a64.51 64.51 0 0 1 1.427-5.328l100.401 100.394-132.797-8.852zm265.141 141.214c-8.315-8.317-8.325-21.807-.035-30.139.015-.015.032-.028.047-.043l60.331-60.331c.013-.013.024-.028.037-.041a21.35 21.35 0 0 1 30.132.041l-90.512 90.513z"/>
</svg>
`
}
`,
};
export const registerCrossbowIcons = () => {
addIcon(crossbowIcon.name, crossbowIcon.svg)
}
addIcon(crossbowIcon.name, crossbowIcon.svg);
};

View file

@ -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<typeof setTimeout>;
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<CacheMatch> = 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.');
};
}

View file

@ -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 <K extends keyof CrossbowPluginSettings>(key: K, value: CrossbowPluginSettings[K]) => {
private updateSettingValue = async <K extends keyof CrossbowPluginSettings>(
key: K,
value: CrossbowPluginSettings[K]
) => {
this.plugin.settings[key] = value;
await this.plugin.saveSettings();
this.plugin.runWithCacheUpdate(true);
}
};
}

View file

@ -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<string> {
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<CacheMatch['rank']>();
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<EditorPosition> {
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<CacheMatch> {
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 {}
}

View file

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

View file

@ -1,52 +1,71 @@
import { ButtonComponent, getIcon } from "obsidian";
import { ButtonComponent, getIcon } from 'obsidian';
export abstract class TreeItemBase<TData> 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<TData> 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<TData> extends TreeItemBase<TData> {
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<TData> extends TreeItemBase<TData> {
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<any>[];
public abstract getChildren(): TreeItemBase<any>[];
public abstract addChildren(children: TreeItemBase<any>[]): 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<any>).setDisable());
}
Array.from(this.childrenWrapper.children).forEach((child) =>
(child as TreeItemBase<any>).setDisable()
);
};
public addChildren = (children: TreeItemBase<any>[]) => {
console.log('addChildren', this.childrenWrapper, children);
children.forEach((child) => {
this.childrenWrapper.appendChild(child);
});
};
}

View file

@ -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<CacheMatch['rank']>();
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;
};
}