mirror of
https://github.com/shoedler/crossbow.git
synced 2026-07-22 07:40:26 +00:00
Updates view, formats codebase
This commit is contained in:
parent
2378bdc0c1
commit
63b8949f20
10 changed files with 534 additions and 305 deletions
5
.prettierrc
Normal file
5
.prettierrc
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
12
src/icons.ts
12
src/icons.ts
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
};
|
||||
}
|
||||
104
src/settings.ts
104
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 <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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
51
src/util.ts
51
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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
190
src/view/view.ts
190
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<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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue