mirror of
https://github.com/shoedler/crossbow.git
synced 2026-07-22 07:40:26 +00:00
commit
d062051b63
28 changed files with 2497 additions and 2137 deletions
|
|
@ -18,6 +18,7 @@
|
|||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off"
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"lines-between-class-members": [ "error", "always", { "exceptAfterSingleLine": true }]
|
||||
}
|
||||
}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -19,4 +19,7 @@ main.js
|
|||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# Dump files
|
||||
*.dump.*
|
||||
2
LICENSE
2
LICENSE
|
|
@ -641,7 +641,7 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"id": "crossbow",
|
||||
"name": "Crossbow",
|
||||
"version": "1.3.0",
|
||||
"minAppVersion": "1.1.1",
|
||||
"version": "1.4.0",
|
||||
"minAppVersion": "1.4.11",
|
||||
"description": "Find possible backlinks in your notes.",
|
||||
"author": "shoedler",
|
||||
"authorUrl": "https://github.com/shoedler",
|
||||
|
|
|
|||
3389
package-lock.json
generated
3389
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "crossbow",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Obsidian plugin to find possible backlinks in your notes (https://obsidian.md)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -26,4 +26,4 @@
|
|||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { Editor } from 'obsidian';
|
||||
|
|
@ -44,10 +44,6 @@ export class CrossbowViewController {
|
|||
this.getCrossbowView()?.unload();
|
||||
}
|
||||
|
||||
private getCrossbowView(): CrossbowView | undefined {
|
||||
return app.workspace.getLeavesOfType(CrossbowView.viewType)[0]?.view as CrossbowView;
|
||||
}
|
||||
|
||||
public addOrUpdateSuggestions(suggestions: Suggestion[], targetEditor: Editor, fileHasChanged: boolean): void {
|
||||
const view = this.getCrossbowView();
|
||||
|
||||
|
|
@ -57,4 +53,8 @@ export class CrossbowViewController {
|
|||
const showManualRefreshButton = !this.settingsService.getSettings().useAutoRefresh;
|
||||
view.update(suggestions, targetEditor, showManualRefreshButton);
|
||||
}
|
||||
|
||||
private getCrossbowView(): CrossbowView | undefined {
|
||||
return app.workspace.getLeavesOfType(CrossbowView.viewType)[0]?.view as CrossbowView;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { addIcon } from 'obsidian';
|
||||
|
|
|
|||
64
src/main.ts
64
src/main.ts
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { App, CachedMetadata, MarkdownView, Plugin, PluginManifest, TAbstractFile, TFile } from 'obsidian';
|
||||
|
|
@ -20,7 +20,7 @@ import { CrossbowSuggestionsService } from './services/suggestionsService';
|
|||
import { CrossbowTokenizationService } from './services/tokenizationService';
|
||||
import { CrossbowUtilsService } from './services/utilsService';
|
||||
import { CrossbowSettingTab } from './settings';
|
||||
import { registerTreeItemElements } from './view/treeItem';
|
||||
import { registerTreeElements } from './view/tree/tree';
|
||||
import { CrossbowView } from './view/view';
|
||||
|
||||
export default class CrossbowPlugin extends Plugin {
|
||||
|
|
@ -50,6 +50,7 @@ export default class CrossbowPlugin extends Plugin {
|
|||
this.viewController = new CrossbowViewController(this.settingsService);
|
||||
}
|
||||
|
||||
/** @implements {@link Plugin.onload} */
|
||||
public async onload(): Promise<void> {
|
||||
// Load settings
|
||||
const settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) as CrossbowPluginSettings;
|
||||
|
|
@ -57,7 +58,8 @@ export default class CrossbowPlugin extends Plugin {
|
|||
|
||||
// Register view elements
|
||||
registerCrossbowIcons();
|
||||
registerTreeItemElements();
|
||||
registerTreeElements();
|
||||
|
||||
this.registerView(CrossbowView.viewType, (leaf) => new CrossbowView(leaf, this.onManualRefreshButtonClick));
|
||||
|
||||
// Ribbon icon to access the crossbow pane
|
||||
|
|
@ -82,13 +84,44 @@ export default class CrossbowPlugin extends Plugin {
|
|||
this.loggingService.debugLog('Crossbow is ready.');
|
||||
}
|
||||
|
||||
public onunload(): void {
|
||||
public onunload() {
|
||||
this.viewController.unloadView();
|
||||
this.indexingService.clearCache();
|
||||
|
||||
this.loggingService.debugLog('Unloaded Crossbow.');
|
||||
}
|
||||
|
||||
public runWithCacheUpdate(fileHasChanged: boolean): void {
|
||||
this.indexingService.indexVault(this.app.vault);
|
||||
this.runWithoutCacheUpdate(fileHasChanged);
|
||||
}
|
||||
|
||||
public runWithoutCacheUpdate(fileHasChanged: boolean): void {
|
||||
// Get editor of current file
|
||||
const fileView = app.workspace
|
||||
.getLeavesOfType('markdown')
|
||||
.find((leaf) => leaf.view instanceof MarkdownView && leaf.view.file === this.currentFile)?.view as
|
||||
| MarkdownView
|
||||
| undefined;
|
||||
|
||||
// #26 https://github.com/shoedler/crossbow/issues/26 - Don't know why we cannot set the mode programmatically.
|
||||
// console.log('fileView mode', fileView?.getMode()); // 'preview' is 'Reading' mode, 'source' is 'Editing' mode (aka. livePreview)
|
||||
// (fileView as any).setMode('source');
|
||||
|
||||
if (!fileView) return;
|
||||
|
||||
const targetEditor = fileView.editor;
|
||||
|
||||
if (!targetEditor) return;
|
||||
|
||||
const wordLookup = this.tokenizationService.getWordLookupFromEditor(targetEditor);
|
||||
const suggestions = this.suggestionsService.getSuggestionsFromWordlookup(wordLookup, this.currentFile);
|
||||
|
||||
this.loggingService.debugLog(`Created ${suggestions.length} suggestions.`);
|
||||
|
||||
this.viewController.addOrUpdateSuggestions(suggestions, targetEditor, fileHasChanged);
|
||||
}
|
||||
|
||||
private onMetadataChange = (file: TFile, data: string, cache: CachedMetadata): void => {
|
||||
if (this.metadataChangedTimeout) clearTimeout(this.metadataChangedTimeout);
|
||||
|
||||
|
|
@ -112,7 +145,7 @@ export default class CrossbowPlugin extends Plugin {
|
|||
this.loggingService.debugLog(`⚡File renamed. '${file.name}'`);
|
||||
this.indexingService.clearCacheFromFile(oldPath);
|
||||
|
||||
// TODO: Verify if we could just use: this.indexingService.indexFile(file as TFile);$
|
||||
// TODO: Verify if we could just use: this.indexingService.indexFile(file as TFile);
|
||||
// Could be problematic since file is TAbstractFile
|
||||
this.app.metadataCache.trigger('changed', file as TFile, ''); // Trigger metadata change to update cache
|
||||
};
|
||||
|
|
@ -123,7 +156,7 @@ export default class CrossbowPlugin extends Plugin {
|
|||
const prevCurrentFile = this.currentFile;
|
||||
|
||||
this.setActiveFile();
|
||||
this.loggingService.debugLog(`⚡File opened. '${this.currentFile?.basename}`);
|
||||
this.loggingService.debugLog(`⚡File opened. '${this.currentFile?.name}'`);
|
||||
|
||||
if (this.fileOpenTimeout) clearTimeout(this.fileOpenTimeout);
|
||||
|
||||
|
|
@ -144,26 +177,9 @@ export default class CrossbowPlugin extends Plugin {
|
|||
this.runWithoutCacheUpdate(true);
|
||||
};
|
||||
|
||||
public runWithCacheUpdate(fileHasChanged: boolean): void {
|
||||
this.indexingService.indexVault(this.app.vault);
|
||||
this.runWithoutCacheUpdate(fileHasChanged);
|
||||
}
|
||||
|
||||
public runWithoutCacheUpdate(fileHasChanged: boolean): void {
|
||||
const targetEditor = this.app.workspace.activeEditor?.editor;
|
||||
if (!targetEditor) return;
|
||||
|
||||
const wordLookup = this.tokenizationService.getWordLookupFromEditor(targetEditor);
|
||||
const suggestions = this.suggestionsService.getSuggestionsFromWordlookup(wordLookup, this.currentFile);
|
||||
|
||||
this.loggingService.debugLog(`Created ${suggestions.length} suggestions.`);
|
||||
|
||||
this.viewController.addOrUpdateSuggestions(suggestions, targetEditor, fileHasChanged);
|
||||
}
|
||||
|
||||
private setActiveFile(): void {
|
||||
const leaf = this.app.workspace.getMostRecentLeaf();
|
||||
if (leaf?.view instanceof MarkdownView) {
|
||||
if (leaf?.view instanceof MarkdownView && leaf.view.file) {
|
||||
this.currentFile = leaf.view.file;
|
||||
} else CrossbowLoggingService.forceLog('warn', 'Unable to determine current editor.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,56 +7,224 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { EditorPosition } from 'obsidian';
|
||||
import { EditorPosition, MarkdownView } from 'obsidian';
|
||||
import { CacheMatch } from 'src/services/indexingService';
|
||||
import { ITreeVisualizable } from '../view/treeItem';
|
||||
import { CrossbowLoggingService } from 'src/services/loggingService';
|
||||
import { ITreeNodeContext, ITreeNodeData, TreeItemButtonIcon, TreeNode } from 'src/view/tree/treeNode';
|
||||
|
||||
export class Suggestion implements ITreeVisualizable {
|
||||
public constructor(public readonly word: string, public readonly occurrences: Occurrence[]) {}
|
||||
export class Suggestion implements ITreeNodeData {
|
||||
public readonly parent: null = null;
|
||||
public readonly children: Occurrence[] = [];
|
||||
public readonly word: string;
|
||||
|
||||
public get hash(): string {
|
||||
public get suffix(): string {
|
||||
return this.children.length.toString();
|
||||
}
|
||||
|
||||
public get flair(): string {
|
||||
const ranks = new Set<CacheMatch['rank']>();
|
||||
this.children[0].children.forEach((match) => ranks.add(match.cacheMatch.rank));
|
||||
|
||||
return Array.from(ranks)
|
||||
.sort((a, b) => (a.codePointAt(0) ?? 0) - (b.codePointAt(0) ?? 0))
|
||||
.join('');
|
||||
}
|
||||
|
||||
public get subtitle(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public get actions(): {
|
||||
name: string;
|
||||
icon: TreeItemButtonIcon;
|
||||
callback(this: Suggestion, ev: MouseEvent, ctx: ITreeNodeContext): void;
|
||||
}[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
public get uid(): string {
|
||||
return this.word;
|
||||
}
|
||||
|
||||
public get text(): string {
|
||||
return this.word;
|
||||
}
|
||||
|
||||
public get matches(): Match[] {
|
||||
return this.occurrences[0].matches;
|
||||
public constructor(word: string, matches: CacheMatch[], matchOccurrences: EditorPosition[]) {
|
||||
this.word = word;
|
||||
this.children = matchOccurrences.map((p) => {
|
||||
const matchOccurrenceEnd = { ch: p.ch + word.length, line: p.line } as EditorPosition;
|
||||
return new Occurrence(this, p, matchOccurrenceEnd, matches);
|
||||
});
|
||||
}
|
||||
|
||||
public sortChildren(): void {
|
||||
this.occurrences.sort((a, b) => a.editorPosition.line - b.editorPosition.line).forEach((occ) => occ.sortChildren());
|
||||
this.children.sort((a, b) => a.editorPosition.line - b.editorPosition.line).forEach((occ) => occ.sortChildren());
|
||||
}
|
||||
}
|
||||
|
||||
export class Occurrence implements ITreeVisualizable {
|
||||
public constructor(public readonly editorPosition: EditorPosition, public readonly matches: Match[]) {}
|
||||
export class Occurrence implements ITreeNodeData {
|
||||
public readonly parent: Suggestion;
|
||||
public readonly children: Match[] = [];
|
||||
public readonly editorPosition: EditorPosition;
|
||||
public readonly editorEndPosition: EditorPosition;
|
||||
|
||||
public get hash(): string {
|
||||
public get suffix(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public get flair(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public get subtitle(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public get actions(): {
|
||||
name: string;
|
||||
icon: TreeItemButtonIcon;
|
||||
callback(this: Occurrence, ev: MouseEvent, ctx: ITreeNodeContext): void;
|
||||
}[] {
|
||||
return [
|
||||
{
|
||||
name: 'Scroll into View',
|
||||
icon: TreeItemButtonIcon.Scroll,
|
||||
callback(ev, ctx) {
|
||||
this.scrollIntoView(ctx);
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public get uid(): string {
|
||||
return `${this.editorPosition.line}:${this.editorPosition.ch}`;
|
||||
}
|
||||
|
||||
public get text(): string {
|
||||
return `On line ${this.editorPosition.line + 1}:${this.editorPosition.ch + 1}`;
|
||||
}
|
||||
|
||||
public constructor(
|
||||
parent: Suggestion,
|
||||
editorPosition: EditorPosition,
|
||||
editorEndPosition: EditorPosition,
|
||||
cacheMatches: CacheMatch[]
|
||||
) {
|
||||
this.parent = parent;
|
||||
this.editorPosition = editorPosition;
|
||||
this.editorEndPosition = editorEndPosition;
|
||||
this.children = cacheMatches.map((m) => new Match(this, m));
|
||||
}
|
||||
|
||||
public sortChildren(): void {
|
||||
this.matches.sort((a, b) => (a.cacheMatch.rank.codePointAt(0) ?? 0) - (b.cacheMatch.rank.codePointAt(0) ?? 0));
|
||||
this.children.sort((a, b) => (a.cacheMatch.rank.codePointAt(0) ?? 0) - (b.cacheMatch.rank.codePointAt(0) ?? 0));
|
||||
}
|
||||
|
||||
public onClick(this: Occurrence, ctx: ITreeNodeContext): void {
|
||||
if (ctx.self.isCollapsed()) {
|
||||
this.scrollIntoView(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
public scrollIntoView(ctx: ITreeNodeContext): void {
|
||||
ctx.targetEditor.setSelection(this.editorPosition, this.editorEndPosition);
|
||||
ctx.targetEditor.scrollIntoView({ from: this.editorPosition, to: this.editorEndPosition }, true);
|
||||
}
|
||||
}
|
||||
|
||||
export class Match implements ITreeVisualizable {
|
||||
public constructor(public readonly cacheMatch: CacheMatch) {}
|
||||
export class Match implements ITreeNodeData {
|
||||
public readonly parent: Occurrence;
|
||||
public readonly cacheMatch: CacheMatch;
|
||||
|
||||
public get hash(): string {
|
||||
public constructor(parent: Occurrence, cacheMatch: CacheMatch) {
|
||||
this.parent = parent;
|
||||
this.cacheMatch = cacheMatch;
|
||||
}
|
||||
|
||||
public get suffix(): string | null {
|
||||
return this.cacheMatch.type;
|
||||
}
|
||||
|
||||
public get flair(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public get subtitle(): string | null {
|
||||
return this.cacheMatch.type === 'File' ? null : this.cacheMatch.file.name;
|
||||
}
|
||||
|
||||
public get actions(): {
|
||||
name: string;
|
||||
icon: TreeItemButtonIcon;
|
||||
callback(this: Match, ev: MouseEvent, ctx: ITreeNodeContext): void;
|
||||
}[] {
|
||||
return [
|
||||
{
|
||||
name: 'Use',
|
||||
icon: TreeItemButtonIcon.Inspect,
|
||||
callback(ev, ctx) {
|
||||
(ctx.self.parentElement?.parentElement as TreeNode<ITreeNodeData>).setDisable();
|
||||
ctx.targetEditor.replaceRange(this.createLink(), this.parent.editorPosition, this.parent.editorEndPosition);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Go To Source',
|
||||
icon: TreeItemButtonIcon.Search,
|
||||
callback(ev, ctx) {
|
||||
const leaf = app.workspace.getLeaf(true);
|
||||
|
||||
leaf.openFile(this.cacheMatch.file, { active: false }).then(() => {
|
||||
if (leaf.view instanceof MarkdownView) {
|
||||
app.workspace.setActiveLeaf(leaf);
|
||||
|
||||
if (this.cacheMatch.item?.position) {
|
||||
const from = {
|
||||
ch: this.cacheMatch.item.position.start.col,
|
||||
line: this.cacheMatch.item.position.start.line + 1,
|
||||
};
|
||||
const to = {
|
||||
ch: this.cacheMatch.item.position.end.col,
|
||||
line: this.cacheMatch.item.position.end.line + 1,
|
||||
};
|
||||
|
||||
leaf.view.editor.scrollIntoView({ from, to }, true);
|
||||
}
|
||||
} else {
|
||||
CrossbowLoggingService.forceLog('warn', 'Could not go to source, not a markdown file');
|
||||
leaf.detach();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public get uid(): string {
|
||||
return `${this.cacheMatch.text}|${this.cacheMatch.file.path}`;
|
||||
}
|
||||
|
||||
public get text(): string {
|
||||
return `${this.cacheMatch.rank} ${this.cacheMatch.text}`;
|
||||
}
|
||||
|
||||
public sortChildren(): void {}
|
||||
|
||||
public createLink(): string {
|
||||
const word = this.parent.parent.word;
|
||||
|
||||
return this.cacheMatch.item
|
||||
? app.fileManager.generateMarkdownLink(
|
||||
this.cacheMatch.file,
|
||||
this.cacheMatch.text,
|
||||
'#' + this.cacheMatch.text,
|
||||
word
|
||||
)
|
||||
: app.fileManager.generateMarkdownLink(this.cacheMatch.file, this.cacheMatch.text, undefined, word);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { CachedMetadata, FileStats, HeadingCache, TFile, TFolder, TagCache, Vault } from 'obsidian';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { CacheItem, CachedMetadata, TAbstractFile, TFile, Vault } from 'obsidian';
|
||||
|
|
@ -15,6 +15,7 @@ import { CrossbowLoggingService } from './loggingService';
|
|||
import { CrossbowSettingsService } from './settingsService';
|
||||
|
||||
type CacheEntryLookup = { [key: string]: CacheEntry };
|
||||
|
||||
export type SourceCacheEntryLookupMap = { [key: TFile['path']]: CacheEntryLookup };
|
||||
|
||||
export interface CacheEntry {
|
||||
|
|
@ -36,11 +37,6 @@ export class CrossbowIndexingService {
|
|||
private readonly loggingService: CrossbowLoggingService
|
||||
) {}
|
||||
|
||||
private addOrUpdateCacheEntry(entry: CacheEntry, source: TFile): void {
|
||||
this.crossbowCache[source.path] = this.crossbowCache[source.path] ? this.crossbowCache[source.path] : {};
|
||||
this.crossbowCache[source.path][entry.text] = entry;
|
||||
}
|
||||
|
||||
public getCache(): SourceCacheEntryLookupMap {
|
||||
return this.crossbowCache;
|
||||
}
|
||||
|
|
@ -105,4 +101,9 @@ export class CrossbowIndexingService {
|
|||
public clearCache(): void {
|
||||
this.crossbowCache = {};
|
||||
}
|
||||
|
||||
private addOrUpdateCacheEntry(entry: CacheEntry, source: TFile): void {
|
||||
this.crossbowCache[source.path] = this.crossbowCache[source.path] ? this.crossbowCache[source.path] : {};
|
||||
this.crossbowCache[source.path][entry.text] = entry;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { CrossbowSettingsService } from './settingsService';
|
||||
|
||||
export class CrossbowLoggingService {
|
||||
public constructor(private readonly settingsService: CrossbowSettingsService) {}
|
||||
|
||||
private static LOGGER_PREFIX = '🏹: ';
|
||||
|
||||
public constructor(private readonly settingsService: CrossbowSettingsService) {}
|
||||
|
||||
public debugLog(message: string): void {
|
||||
this.settingsService.getSettings().useLogging && console.log(CrossbowLoggingService.LOGGER_PREFIX + message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
export interface CrossbowPluginSettings {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { TFile } from 'obsidian';
|
||||
import { Match, Occurrence, Suggestion } from 'src/model/suggestion';
|
||||
import { Suggestion } from 'src/model/suggestion';
|
||||
import { CacheMatch, CrossbowIndexingService } from './indexingService';
|
||||
import { CrossbowSettingsService } from './settingsService';
|
||||
import { WordLookup } from './tokenizationService';
|
||||
|
|
@ -35,7 +35,7 @@ export class CrossbowSuggestionsService {
|
|||
const [word, editorPositions] = wordEntries[i];
|
||||
const lowercaseWord = word.toLowerCase();
|
||||
|
||||
const matchSet: Set<CacheMatch> = new Set();
|
||||
const matches: CacheMatch[] = [];
|
||||
|
||||
// Find matches in the cache
|
||||
const cacheValues = Object.values(cache);
|
||||
|
|
@ -48,15 +48,15 @@ export class CrossbowSuggestionsService {
|
|||
const [cacheKey, cacheValue] = cacheEntries[k];
|
||||
const lowercaseCacheKey = cacheKey.toLowerCase();
|
||||
|
||||
if (matchSet.size >= 100) continue;
|
||||
if (matches.length >= 300) continue;
|
||||
|
||||
// If reference is in the same file, and we don't want to suggest references in the same file, skip
|
||||
if (!this.settingsService.getSettings().suggestInSameFile && cacheValue.file === currentFile) continue;
|
||||
if (!settings.suggestInSameFile && cacheValue.file === currentFile) continue;
|
||||
|
||||
// If we have a case-sensitive exact match, we always add it, even if it does not satisfy the other filters. Say we have a chapter with a heading 'C' (eg. the programming language)
|
||||
// We want to match a word 'C' in the current editor, even if it is too short or is on the ignore list.
|
||||
if (cacheKey === word) {
|
||||
matchSet.add({ ...cacheValue, rank: '🏆' });
|
||||
matches.push({ ...cacheValue, rank: '🏆' });
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -78,30 +78,28 @@ export class CrossbowSuggestionsService {
|
|||
|
||||
// If the word is a case-insensitive exact match, add as a very good suggestion
|
||||
if (lowercaseCacheKey === lowercaseWord) {
|
||||
matchSet.add({ ...cacheValue, rank: '🥇' });
|
||||
matches.push({ ...cacheValue, rank: '🥇' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the lengths differ too much, add as not-very-good suggestion
|
||||
if ((1 / cacheKey.length) * word.length <= 0.2) {
|
||||
matchSet.add({ ...cacheValue, rank: '🥉' });
|
||||
matches.push({ ...cacheValue, rank: '🥉' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Else, add as a mediocre suggestion
|
||||
matchSet.add({ ...cacheValue, rank: '🥈' });
|
||||
matches.push({ ...cacheValue, rank: '🥈' });
|
||||
}
|
||||
}
|
||||
|
||||
if (matchSet.size > 0) {
|
||||
const matches = Array.from(matchSet).map((m) => new Match(m));
|
||||
const occurrences = editorPositions.map((p) => new Occurrence(p, matches));
|
||||
result.push(new Suggestion(word, occurrences));
|
||||
if (matches.length > 0) {
|
||||
result.push(new Suggestion(word, matches, editorPositions));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the result
|
||||
result.sort((a, b) => a.hash.localeCompare(b.hash)).forEach((suggestion) => suggestion.sortChildren());
|
||||
result.sort((a, b) => a.uid.localeCompare(b.uid)).forEach((suggestion) => suggestion.sortChildren());
|
||||
|
||||
// Remove ignored words from the result
|
||||
return this.removeIgnoredWords(result);
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
/* eslint-disable no-useless-escape */ // Reson: `testFile`
|
||||
|
||||
import { writeFileSync } from 'fs';
|
||||
import { Editor } from 'obsidian';
|
||||
import { CrossbowTokenizationService, WordLookup } from './tokenizationService';
|
||||
|
||||
|
|
@ -135,16 +136,24 @@ describe(CrossbowTokenizationService.constructor.name, () => {
|
|||
const editor = mockEditor(testFile);
|
||||
const service = new CrossbowTokenizationService();
|
||||
const actual = service.getWordLookupFromEditor(editor);
|
||||
|
||||
// Dump actual and expected to files for debugging
|
||||
writeFileSync('src/services/tokenizationService.test.actual.dump.json', JSON.stringify(actual, null, 0));
|
||||
writeFileSync(
|
||||
'src/services/tokenizationService.test.expected.dump.json',
|
||||
JSON.stringify(expectedFromTestFile, null, 0)
|
||||
);
|
||||
|
||||
expect(actual).toEqual(expectedFromTestFile);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const expectedFromTestFile = {
|
||||
'0': [{ line: 0, ch: 2268 }],
|
||||
'0': [{ line: 0, ch: 2269 }],
|
||||
'1': [
|
||||
{ line: 0, ch: 1827 },
|
||||
{ line: 0, ch: 3327 },
|
||||
{ line: 0, ch: 1828 },
|
||||
{ line: 0, ch: 3328 },
|
||||
{ line: 0, ch: 4554 },
|
||||
],
|
||||
'2': [{ line: 0, ch: 3779 }],
|
||||
|
|
@ -163,11 +172,11 @@ const expectedFromTestFile = {
|
|||
],
|
||||
'9': [{ line: 0, ch: 3331 }],
|
||||
'51': [{ line: 0, ch: 1916 }],
|
||||
'93': [{ line: 0, ch: 3784 }],
|
||||
'93': [{ line: 0, ch: 3786 }],
|
||||
'185': [{ line: 0, ch: 3754 }],
|
||||
'1249': [{ line: 0, ch: 3041 }],
|
||||
'1851': [{ line: 0, ch: 3769 }],
|
||||
'12494': [{ line: 0, ch: 3063 }],
|
||||
'1851': [{ line: 0, ch: 3770 }],
|
||||
'12494': [{ line: 0, ch: 3066 }],
|
||||
'12589': [{ line: 0, ch: 3186 }],
|
||||
'34567': [{ line: 0, ch: 3142 }],
|
||||
'55555': [{ line: 0, ch: 3098 }],
|
||||
|
|
@ -192,7 +201,7 @@ const expectedFromTestFile = {
|
|||
{ line: 0, ch: 621 },
|
||||
{ line: 0, ch: 1801 },
|
||||
{ line: 0, ch: 2340 },
|
||||
{ line: 0, ch: 3021 },
|
||||
{ line: 0, ch: 3023 },
|
||||
],
|
||||
Schuhe: [
|
||||
{ line: 0, ch: 151 },
|
||||
|
|
@ -206,7 +215,7 @@ const expectedFromTestFile = {
|
|||
Kriterien: [{ line: 0, ch: 173 }],
|
||||
Man: [
|
||||
{ line: 0, ch: 184 },
|
||||
{ line: 0, ch: 2103 },
|
||||
{ line: 0, ch: 2104 },
|
||||
],
|
||||
hat: [
|
||||
{ line: 0, ch: 188 },
|
||||
|
|
@ -216,7 +225,7 @@ const expectedFromTestFile = {
|
|||
zur: [{ line: 0, ch: 200 }],
|
||||
Auswahl: [{ line: 0, ch: 204 }],
|
||||
Objekte: [
|
||||
{ line: 0, ch: 217 },
|
||||
{ line: 0, ch: 219 },
|
||||
{ line: 0, ch: 233 },
|
||||
],
|
||||
Die: [
|
||||
|
|
@ -236,7 +245,7 @@ const expectedFromTestFile = {
|
|||
{ line: 0, ch: 1316 },
|
||||
{ line: 0, ch: 1845 },
|
||||
],
|
||||
statistische: [{ line: 0, ch: 252 }],
|
||||
statistische: [{ line: 0, ch: 254 }],
|
||||
bezeichnet: [{ line: 0, ch: 277 }],
|
||||
Grundgesamtheit: [{ line: 0, ch: 292 }],
|
||||
Menge: [
|
||||
|
|
@ -284,7 +293,7 @@ const expectedFromTestFile = {
|
|||
Frage: [{ line: 0, ch: 378 }],
|
||||
kommen: [{ line: 0, ch: 384 }],
|
||||
Im: [
|
||||
{ line: 0, ch: 392 },
|
||||
{ line: 0, ch: 393 },
|
||||
{ line: 0, ch: 486 },
|
||||
],
|
||||
obigen: [
|
||||
|
|
@ -307,7 +316,7 @@ const expectedFromTestFile = {
|
|||
Farbe: [
|
||||
{ line: 0, ch: 506 },
|
||||
{ line: 0, ch: 630 },
|
||||
{ line: 0, ch: 721 },
|
||||
{ line: 0, ch: 722 },
|
||||
],
|
||||
Material: [{ line: 0, ch: 513 }],
|
||||
Absatzhöhe: [{ line: 0, ch: 523 }],
|
||||
|
|
@ -315,7 +324,7 @@ const expectedFromTestFile = {
|
|||
{ line: 0, ch: 534 },
|
||||
{ line: 0, ch: 1851 },
|
||||
],
|
||||
Eigenschaften: [{ line: 0, ch: 539 }],
|
||||
Eigenschaften: [{ line: 0, ch: 540 }],
|
||||
einer: [
|
||||
{ line: 0, ch: 554 },
|
||||
{ line: 0, ch: 1390 },
|
||||
|
|
@ -386,12 +395,12 @@ const expectedFromTestFile = {
|
|||
],
|
||||
Unterkategorien: [{ line: 0, ch: 788 }],
|
||||
diskret: [
|
||||
{ line: 0, ch: 804 },
|
||||
{ line: 0, ch: 805 },
|
||||
{ line: 0, ch: 1001 },
|
||||
{ line: 0, ch: 1134 },
|
||||
],
|
||||
stetig: [
|
||||
{ line: 0, ch: 818 },
|
||||
{ line: 0, ch: 819 },
|
||||
{ line: 0, ch: 1147 },
|
||||
{ line: 0, ch: 1266 },
|
||||
],
|
||||
|
|
@ -400,7 +409,7 @@ const expectedFromTestFile = {
|
|||
{ line: 0, ch: 1012 },
|
||||
{ line: 0, ch: 2250 },
|
||||
],
|
||||
diskretes: [{ line: 0, ch: 832 }],
|
||||
diskretes: [{ line: 0, ch: 834 }],
|
||||
ist: [
|
||||
{ line: 0, ch: 854 },
|
||||
{ line: 0, ch: 1037 },
|
||||
|
|
@ -446,9 +455,9 @@ const expectedFromTestFile = {
|
|||
{ line: 0, ch: 969 },
|
||||
{ line: 0, ch: 2138 },
|
||||
],
|
||||
Zählvariablen: [{ line: 0, ch: 975 }],
|
||||
Zählvariablen: [{ line: 0, ch: 976 }],
|
||||
stets: [{ line: 0, ch: 995 }],
|
||||
stetiges: [{ line: 0, ch: 1016 }],
|
||||
stetiges: [{ line: 0, ch: 1018 }],
|
||||
hingegen: [{ line: 0, ch: 1041 }],
|
||||
dadurch: [{ line: 0, ch: 1050 }],
|
||||
gekennzeichnet: [{ line: 0, ch: 1058 }],
|
||||
|
|
@ -458,7 +467,7 @@ const expectedFromTestFile = {
|
|||
],
|
||||
Intervall: [
|
||||
{ line: 0, ch: 1100 },
|
||||
{ line: 0, ch: 2030 },
|
||||
{ line: 0, ch: 2031 },
|
||||
{ line: 0, ch: 2744 },
|
||||
],
|
||||
bilden: [{ line: 0, ch: 1110 }],
|
||||
|
|
@ -527,7 +536,7 @@ const expectedFromTestFile = {
|
|||
],
|
||||
Beste: [{ line: 0, ch: 1539 }],
|
||||
Bild: [
|
||||
{ line: 0, ch: 1554 },
|
||||
{ line: 0, ch: 1555 },
|
||||
{ line: 0, ch: 1741 },
|
||||
{ line: 0, ch: 2319 },
|
||||
],
|
||||
|
|
@ -535,7 +544,7 @@ const expectedFromTestFile = {
|
|||
OG: [{ line: 0, ch: 1572 }],
|
||||
Wurzelskala: [{ line: 0, ch: 1583 }],
|
||||
Nominalskala: [
|
||||
{ line: 0, ch: 1603 },
|
||||
{ line: 0, ch: 1605 },
|
||||
{ line: 0, ch: 1662 },
|
||||
],
|
||||
Rangordnung: [
|
||||
|
|
@ -544,7 +553,7 @@ const expectedFromTestFile = {
|
|||
{ line: 0, ch: 2063 },
|
||||
],
|
||||
fehlt: [{ line: 0, ch: 1689 }],
|
||||
Wie: [{ line: 0, ch: 1695 }],
|
||||
Wie: [{ line: 0, ch: 1696 }],
|
||||
soll: [{ line: 0, ch: 1700 }],
|
||||
ich: [{ line: 0, ch: 1705 }],
|
||||
ordnen: [{ line: 0, ch: 1713 }],
|
||||
|
|
@ -552,7 +561,7 @@ const expectedFromTestFile = {
|
|||
{ line: 0, ch: 1724 },
|
||||
{ line: 0, ch: 1978 },
|
||||
{ line: 0, ch: 2302 },
|
||||
{ line: 0, ch: 3808 },
|
||||
{ line: 0, ch: 3810 },
|
||||
{ line: 0, ch: 4114 },
|
||||
],
|
||||
Siehe: [
|
||||
|
|
@ -573,12 +582,12 @@ const expectedFromTestFile = {
|
|||
{ line: 0, ch: 2199 },
|
||||
],
|
||||
keine: [{ line: 0, ch: 1872 }],
|
||||
sinnvollen: [{ line: 0, ch: 1878 }],
|
||||
sinnvollen: [{ line: 0, ch: 1879 }],
|
||||
Abstände: [
|
||||
{ line: 0, ch: 1891 },
|
||||
{ line: 0, ch: 2079 },
|
||||
],
|
||||
zB: [{ line: 0, ch: 1901 }],
|
||||
zB: [{ line: 0, ch: 1902 }],
|
||||
an: [
|
||||
{ line: 0, ch: 1920 },
|
||||
{ line: 0, ch: 1945 },
|
||||
|
|
@ -603,7 +612,7 @@ const expectedFromTestFile = {
|
|||
{ line: 0, ch: 2135 },
|
||||
{ line: 0, ch: 4542 },
|
||||
],
|
||||
Abstand: [{ line: 0, ch: 2147 }],
|
||||
Abstand: [{ line: 0, ch: 2149 }],
|
||||
zw: [{ line: 0, ch: 2159 }],
|
||||
zwei: [{ line: 0, ch: 2163 }],
|
||||
Punkten: [{ line: 0, ch: 2168 }],
|
||||
|
|
@ -612,10 +621,10 @@ const expectedFromTestFile = {
|
|||
inhaltlicher: [{ line: 0, ch: 2209 }],
|
||||
Nullpunkt: [{ line: 0, ch: 2222 }],
|
||||
lucarrowright: [
|
||||
{ line: 0, ch: 2232 },
|
||||
{ line: 0, ch: 3108 },
|
||||
{ line: 0, ch: 3152 },
|
||||
{ line: 0, ch: 3196 },
|
||||
{ line: 0, ch: 2233 },
|
||||
{ line: 0, ch: 3109 },
|
||||
{ line: 0, ch: 3153 },
|
||||
{ line: 0, ch: 3197 },
|
||||
],
|
||||
Einkommen: [
|
||||
{ line: 0, ch: 2254 },
|
||||
|
|
@ -672,7 +681,7 @@ const expectedFromTestFile = {
|
|||
],
|
||||
D: [{ line: 0, ch: 3037 }],
|
||||
xquer0: [{ line: 0, ch: 3051 }],
|
||||
Problem: [{ line: 0, ch: 3080 }],
|
||||
Problem: [{ line: 0, ch: 3082 }],
|
||||
D1: [
|
||||
{ line: 0, ch: 3093 },
|
||||
{ line: 0, ch: 4210 },
|
||||
|
|
@ -691,7 +700,7 @@ const expectedFromTestFile = {
|
|||
arithmetisches: [{ line: 0, ch: 3263 }],
|
||||
Bei: [{ line: 0, ch: 3292 }],
|
||||
grossen: [{ line: 0, ch: 3310 }],
|
||||
Spikes: [{ line: 0, ch: 3318 }],
|
||||
Spikes: [{ line: 0, ch: 3319 }],
|
||||
im: [
|
||||
{ line: 0, ch: 3340 },
|
||||
{ line: 0, ch: 3347 },
|
||||
|
|
@ -699,7 +708,7 @@ const expectedFromTestFile = {
|
|||
gut: [{ line: 0, ch: 3343 }],
|
||||
repräsentiert: [{ line: 0, ch: 3357 }],
|
||||
Lagemasse: [{ line: 0, ch: 3376 }],
|
||||
Lage: [{ line: 0, ch: 3386 }],
|
||||
Lage: [{ line: 0, ch: 3387 }],
|
||||
Daten: [
|
||||
{ line: 0, ch: 3396 },
|
||||
{ line: 0, ch: 4043 },
|
||||
|
|
@ -716,7 +725,7 @@ const expectedFromTestFile = {
|
|||
{ line: 0, ch: 3743 },
|
||||
],
|
||||
Halbierung: [{ line: 0, ch: 3516 }],
|
||||
Biespiel: [{ line: 0, ch: 3718 }],
|
||||
Biespiel: [{ line: 0, ch: 3719 }],
|
||||
Berechne: [{ line: 0, ch: 3730 }],
|
||||
den: [{ line: 0, ch: 3739 }],
|
||||
Werten: [{ line: 0, ch: 3758 }],
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { Editor, EditorPosition } from 'obsidian';
|
||||
|
|
@ -25,14 +25,13 @@ const MARKDOWN_LATEX_BLOCK_REGEX = /\$\$([^$]+)\$\$/g;
|
|||
const MARKDOWN_LATEX_INLINE_REGEX = /\$([^$]+)\$/g;
|
||||
const MARKDOWN_LINKS_AND_IMAGES_REGEX = /!?\[([^\]]+)\]\((?:<.*>)?\s*([^\s)]+)\s*\)/gm;
|
||||
const MARKDOWN_CODE_BLOCK_REGEX = /```[\s\S]+?```/g;
|
||||
// const MARKDOWN_ASTERISK_EMPHASIS_REGEX = /([\*]+)(\S)(.*?\S)??\1/g;
|
||||
const MARKDOWN_ASTERISK_EMPHASIS_REGEX = /([*]+)(\S)(.*?\S)??(\1)/g; // g1 = *, g2 = first char, g3 = middle, g4 = *
|
||||
|
||||
// const MARKDOWN_LODASH_EMPHASIS_REGEX = /(^|\W)([_]+)(\S)(.*?\S)??\2($|\W)/g;
|
||||
// const MARKDOWN_CODE_INLINE_REGEX = /`(.+?)`/g;
|
||||
// const MARKDOWN_STRIKETROUGH_REGEX = /~(.*?)~/g;
|
||||
|
||||
export class CrossbowTokenizationService {
|
||||
public constructor() {}
|
||||
|
||||
private readonly SKIP_REGEX = /\s/;
|
||||
|
||||
public getWordLookupFromEditor(targetEditor: Editor): WordLookup {
|
||||
|
|
@ -51,32 +50,38 @@ export class CrossbowTokenizationService {
|
|||
|
||||
while (plainText[i] && !plainText[i].match(this.SKIP_REGEX)) word += plainText[i++];
|
||||
|
||||
word = CrossbowTokenizationService.cleanWord(word);
|
||||
const cleanWord = CrossbowTokenizationService.cleanWord(word);
|
||||
if (cleanWord.length <= 0) continue;
|
||||
|
||||
if (word.length <= 0) continue;
|
||||
// Offset the word start pos if the 'cleaning' of the word removed characters
|
||||
const wordStartOffset = word.indexOf(cleanWord[0]);
|
||||
if (wordStartOffset > 0) pos.ch += wordStartOffset;
|
||||
|
||||
if (word in wordLookup) wordLookup[word].push(pos);
|
||||
else wordLookup[word] = [pos];
|
||||
if (cleanWord in wordLookup) wordLookup[cleanWord].push(pos);
|
||||
else wordLookup[cleanWord] = [pos];
|
||||
}
|
||||
}
|
||||
|
||||
return wordLookup;
|
||||
}
|
||||
|
||||
public static redactText(text: string): string {
|
||||
const muteString = (str: string): string => str.replace(/[^\r\n]+/g, (m) => ' '.repeat(m.length));
|
||||
public static muteString = (str: string): string => str.replace(/[^\r\n]+/g, (m) => ' '.repeat(m.length));
|
||||
|
||||
public static muteWord = (word: string): string => ' '.repeat(word.length);
|
||||
|
||||
public static redactText(text: string): string {
|
||||
// Order matters here
|
||||
return text
|
||||
.replace(MARKDOWN_CODE_BLOCK_REGEX, (m) => muteString(m))
|
||||
.replace(MARKDOWN_LATEX_BLOCK_REGEX, (m) => muteString(m))
|
||||
.replace(MARKDOWN_LATEX_INLINE_REGEX, (m) => muteString(m))
|
||||
.replace(MARKDOWN_LINKS_AND_IMAGES_REGEX, (m) => muteString(m))
|
||||
.replace(OBSIDIAN_METADATA_REGEX, (m) => muteString(m))
|
||||
.replace(OBSIDIAN_TAG_REGEX, (m) => muteString(m))
|
||||
.replace(OBSIDIAN_LINKS_REGEX, (m) => muteString(m))
|
||||
.replace(HTML_COMMENT_REGEX, (m) => muteString(m))
|
||||
.replace(HTML_TAG_REGEX, (m) => muteString(m));
|
||||
.replace(MARKDOWN_ASTERISK_EMPHASIS_REGEX, (m, g1, g2, g3) => this.muteWord(g1) + g2 + g3 + this.muteWord(g1))
|
||||
.replace(MARKDOWN_CODE_BLOCK_REGEX, (m) => this.muteString(m))
|
||||
.replace(MARKDOWN_LATEX_BLOCK_REGEX, (m) => this.muteString(m))
|
||||
.replace(MARKDOWN_LATEX_INLINE_REGEX, (m) => this.muteString(m))
|
||||
.replace(MARKDOWN_LINKS_AND_IMAGES_REGEX, (m) => this.muteString(m))
|
||||
.replace(OBSIDIAN_METADATA_REGEX, (m) => this.muteString(m))
|
||||
.replace(OBSIDIAN_TAG_REGEX, (m) => this.muteString(m))
|
||||
.replace(OBSIDIAN_LINKS_REGEX, (m) => this.muteString(m))
|
||||
.replace(HTML_COMMENT_REGEX, (m) => this.muteString(m))
|
||||
.replace(HTML_TAG_REGEX, (m) => this.muteString(m));
|
||||
}
|
||||
|
||||
public static cleanWord(word: string): string {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { CrossbowUtilsService } from './utilsService';
|
||||
|
|
|
|||
|
|
@ -7,12 +7,10 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
export class CrossbowUtilsService {
|
||||
constructor() {}
|
||||
|
||||
// folders names (or paths, separated by "/"). (Whitepaces around commas will be trimmed)
|
||||
public toArrayOfPaths(pathArrayLike: string): string[] {
|
||||
return pathArrayLike
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
|
|
|
|||
57
src/view/tree/tree.ts
Normal file
57
src/view/tree/tree.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Copyright (C) 2023 - shoedler - github.com/shoedler
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { Editor } from 'obsidian';
|
||||
import { ITreeNodeData, TreeNode } from './treeNode';
|
||||
import { TreeUpdater } from './treeUpdater';
|
||||
|
||||
export const registerTreeElements = (): void => {
|
||||
TreeNode.register();
|
||||
Tree.register();
|
||||
};
|
||||
|
||||
export type ITreeContextProvider = {
|
||||
readonly targetEditor: Editor;
|
||||
};
|
||||
|
||||
export class Tree extends HTMLElement implements ITreeContextProvider {
|
||||
public readonly targetEditor: Editor;
|
||||
private readonly treeUpdater: TreeUpdater;
|
||||
|
||||
public constructor(targetEditor: Editor) {
|
||||
super();
|
||||
this.targetEditor = targetEditor;
|
||||
this.treeUpdater = new TreeUpdater();
|
||||
}
|
||||
|
||||
public static register(): void {
|
||||
if (!customElements.get('crossbow-tree')) {
|
||||
customElements.define('crossbow-tree', Tree);
|
||||
}
|
||||
}
|
||||
|
||||
public update<T extends ITreeNodeData>(data: T[]): void {
|
||||
const oldNodes = this.getChildTreeNodes();
|
||||
const batch = this.treeUpdater.update(
|
||||
data.map((d) => new TreeNode(d, this)),
|
||||
oldNodes
|
||||
);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
batch.forEach((update) => update(this));
|
||||
});
|
||||
}
|
||||
|
||||
private getChildTreeNodes<T extends ITreeNodeData>(): TreeNode<T>[] {
|
||||
return this.children.length > 0 ? (Array.from(this.children) as TreeNode<T>[]) : [];
|
||||
}
|
||||
}
|
||||
203
src/view/tree/treeNode.ts
Normal file
203
src/view/tree/treeNode.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
// Copyright (C) 2023 - shoedler - github.com/shoedler
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { ButtonComponent, Editor, getIcon } from 'obsidian';
|
||||
import { ITreeContextProvider } from './tree';
|
||||
import { IComparable } from './treeUpdater';
|
||||
|
||||
export enum TreeItemButtonIcon {
|
||||
Scroll = 'lucide-scroll',
|
||||
Inspect = 'lucide-inspect',
|
||||
Search = 'lucide-search',
|
||||
}
|
||||
|
||||
export interface ITreeNodeContext {
|
||||
targetEditor: Editor;
|
||||
self: TreeNode<ITreeNodeData>;
|
||||
}
|
||||
|
||||
export interface ITreeNodeData extends IComparable {
|
||||
readonly parent: ITreeNodeData | null;
|
||||
readonly children?: ITreeNodeData[];
|
||||
|
||||
get text(): string;
|
||||
get suffix(): string | null;
|
||||
get flair(): string | null;
|
||||
get subtitle(): string | null;
|
||||
get actions(): {
|
||||
name: string;
|
||||
icon: TreeItemButtonIcon;
|
||||
callback(this: ITreeNodeData, ev: MouseEvent, ctx: ITreeNodeContext): void;
|
||||
}[];
|
||||
|
||||
onClick?(this: ITreeNodeData, ctx: ITreeNodeContext): void;
|
||||
}
|
||||
|
||||
export class TreeNode<TData extends ITreeNodeData> extends HTMLElement {
|
||||
private readonly manager: ITreeContextProvider;
|
||||
protected readonly childrenWrapper: HTMLDivElement | null = null;
|
||||
private readonly iconWrapper: HTMLDivElement | null = null;
|
||||
private readonly inner: HTMLDivElement;
|
||||
private readonly mainWrapper: HTMLDivElement;
|
||||
private readonly flairWrapper: HTMLDivElement;
|
||||
private suffix: HTMLSpanElement;
|
||||
private subtitle: HTMLSpanElement;
|
||||
private flair: HTMLSpanElement;
|
||||
private buttons: ButtonComponent[] = [];
|
||||
public readonly value: TData;
|
||||
|
||||
public constructor(value: TData, manager: ITreeContextProvider) {
|
||||
super();
|
||||
|
||||
this.value = value;
|
||||
this.manager = manager;
|
||||
const isLeaf = this.value.children === undefined;
|
||||
|
||||
this.addClass('tree-item');
|
||||
|
||||
this.mainWrapper = this.createDiv({
|
||||
cls: 'tree-item-self is-clickable mod-collapsible',
|
||||
});
|
||||
|
||||
if (!isLeaf) {
|
||||
this.iconWrapper = this.mainWrapper.createDiv({
|
||||
cls: 'tree-item-icon collapse-icon is-collapsed',
|
||||
});
|
||||
this.iconWrapper.appendChild(getIcon('right-triangle') ?? new SVGElement());
|
||||
}
|
||||
|
||||
this.inner = this.mainWrapper.createDiv({
|
||||
cls: 'tree-item-inner cb-tree-item-inner-extensions',
|
||||
text: this.value.text,
|
||||
});
|
||||
|
||||
if (this.value.flair !== null) {
|
||||
this.flairWrapper = this.mainWrapper.createDiv({
|
||||
cls: 'tree-item-flair-outer',
|
||||
});
|
||||
this.flair = this.flairWrapper.createEl('span', {
|
||||
cls: 'tree-item-flair',
|
||||
text: this.value.flair,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.value.suffix !== null) {
|
||||
this.suffix = this.inner.createSpan({
|
||||
cls: 'cb-tree-item-inner-suffix',
|
||||
text: this.value.suffix,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.value.subtitle !== null) {
|
||||
this.subtitle = this.inner.createEl('span', {
|
||||
cls: 'cb-tree-item-inner-subtitle',
|
||||
text: this.value.subtitle,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.value.onClick !== undefined) {
|
||||
const boundOnClick = this.value.onClick.bind(this.value);
|
||||
this.mainWrapper.addEventListener('click', () => boundOnClick(this.context()));
|
||||
}
|
||||
|
||||
if (!isLeaf) {
|
||||
this.childrenWrapper = this.createDiv({ cls: 'tree-item-children' });
|
||||
this.childrenWrapper.style.display = 'none';
|
||||
|
||||
// collapse / expand
|
||||
this.mainWrapper.addEventListener('click', () => (this.isCollapsed() ? this.expand() : this.collapse()));
|
||||
|
||||
// lazy-create children nodes
|
||||
this.mainWrapper.addEventListener('click', () => this.generateChildren(), { once: true });
|
||||
}
|
||||
|
||||
if (this.value.actions.length > 0) {
|
||||
this.value.actions.forEach((action) => {
|
||||
const boundCallback = action.callback.bind(this.value);
|
||||
this.addButton(action.name, action.icon, (ev) => boundCallback(ev, this.context()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public get childTreeNodes(): TreeNode<ITreeNodeData>[] {
|
||||
return this.childrenWrapper ? (Array.from(this.childrenWrapper.children) as TreeNode<ITreeNodeData>[]) : [];
|
||||
}
|
||||
|
||||
public static register(): void {
|
||||
if (!customElements.get('crossbow-tree-item')) {
|
||||
customElements.define('crossbow-tree-item', TreeNode);
|
||||
}
|
||||
}
|
||||
|
||||
public context(): ITreeNodeContext {
|
||||
return {
|
||||
targetEditor: this.manager.targetEditor,
|
||||
self: this,
|
||||
};
|
||||
}
|
||||
|
||||
public generateChildren(): void {
|
||||
if (!this.childrenWrapper || !this.value.children) return;
|
||||
if (this.childrenWrapper.children.length > 0) return;
|
||||
|
||||
const nodes = this.value.children.map((child) => new TreeNode(child, this.manager));
|
||||
this.childrenWrapper.replaceChildren(...nodes);
|
||||
}
|
||||
|
||||
public isCollapsed() {
|
||||
if (!this.childrenWrapper || !this.iconWrapper) return true;
|
||||
|
||||
return this.iconWrapper.hasClass('is-collapsed');
|
||||
}
|
||||
|
||||
public expand() {
|
||||
if (!this.childrenWrapper || !this.iconWrapper) return;
|
||||
|
||||
this.iconWrapper.removeClass('is-collapsed');
|
||||
this.childrenWrapper.style.display = 'block';
|
||||
}
|
||||
|
||||
public collapse() {
|
||||
if (!this.childrenWrapper || !this.iconWrapper) return;
|
||||
|
||||
this.iconWrapper.addClass('is-collapsed');
|
||||
this.childrenWrapper.style.display = 'none';
|
||||
}
|
||||
|
||||
public setDisable() {
|
||||
this.mainWrapper.style.textDecoration = 'line-through';
|
||||
this.mainWrapper.style.color = 'var(--text-muted)';
|
||||
this.style.cursor = 'wait';
|
||||
|
||||
this.buttons.forEach((button) => {
|
||||
button.setDisabled(true);
|
||||
button.disabled = true;
|
||||
});
|
||||
|
||||
this.childTreeNodes.forEach((child) => child.setDisable());
|
||||
}
|
||||
|
||||
public addButton(
|
||||
label: string,
|
||||
iconName: TreeItemButtonIcon,
|
||||
onclick: (this: HTMLDivElement, ev: MouseEvent) => void
|
||||
): void {
|
||||
const button = new ButtonComponent(this.mainWrapper);
|
||||
|
||||
button.setTooltip(label);
|
||||
button.setIcon(iconName);
|
||||
button.setClass('cb-tree-item-button');
|
||||
button.onClick(onclick);
|
||||
|
||||
this.buttons.push(button);
|
||||
}
|
||||
}
|
||||
78
src/view/tree/treeUpdater.ts
Normal file
78
src/view/tree/treeUpdater.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Copyright (C) 2023 - shoedler - github.com/shoedler
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { ITreeNodeData, TreeNode } from './treeNode';
|
||||
|
||||
export interface IComparable {
|
||||
uid: string;
|
||||
}
|
||||
|
||||
export const equals = (a: IComparable, b: IComparable): boolean => a.uid === b.uid;
|
||||
|
||||
export type BatchUpdate = ((container: HTMLElement) => void)[];
|
||||
|
||||
export class TreeUpdater {
|
||||
public update<T extends ITreeNodeData>(newNodes: TreeNode<T>[], oldNodes: TreeNode<T>[]): BatchUpdate {
|
||||
const updates: BatchUpdate = [];
|
||||
|
||||
for (let i = 0; i < newNodes.length; i++) {
|
||||
const newNode = newNodes[i];
|
||||
const index = oldNodes.findIndex((oldNode) => equals(oldNode.value, newNode.value));
|
||||
const existingNode = index !== -1 ? oldNodes.splice(index, 1)[0] : undefined;
|
||||
|
||||
if (existingNode) {
|
||||
// Toggle expanded state of children, if it was expanded before
|
||||
existingNode.childTreeNodes
|
||||
.filter((child) => !child.isCollapsed())
|
||||
.forEach((expandedChild) => {
|
||||
const child = newNode.childTreeNodes.find((child) => equals(child.value, expandedChild.value));
|
||||
if (child) {
|
||||
child.expand();
|
||||
child.generateChildren();
|
||||
}
|
||||
});
|
||||
|
||||
// Replace existing node with new node
|
||||
updates.push((container) => {
|
||||
container.insertAfter(newNode, existingNode);
|
||||
|
||||
if (!existingNode.isCollapsed()) {
|
||||
newNode.expand();
|
||||
newNode.generateChildren();
|
||||
}
|
||||
|
||||
existingNode.remove();
|
||||
});
|
||||
} else {
|
||||
// Insert the new suggestion at the correct position. They are sorted by localeCompare of their 'hash' property
|
||||
const insertionIndex = oldNodes.findIndex((oldNode) => newNode.value.uid.localeCompare(oldNode.value.uid) < 0);
|
||||
|
||||
updates.push((container) => {
|
||||
if (insertionIndex === -1) {
|
||||
container.appendChild(newNode);
|
||||
} else {
|
||||
container.insertBefore(newNode, oldNodes[insertionIndex]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Now, we're left with the existing suggestions that we need to remove
|
||||
updates.push((container) => {
|
||||
oldNodes.forEach((item) => {
|
||||
item.remove();
|
||||
});
|
||||
});
|
||||
|
||||
return updates;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
// Copyright (C) 2023 - shoedler - github.com/shoedler
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { ButtonComponent, getIcon } from 'obsidian';
|
||||
|
||||
export interface ITreeVisualizable {
|
||||
get hash(): string;
|
||||
get text(): string;
|
||||
|
||||
sortChildren(): void;
|
||||
}
|
||||
|
||||
export const registerTreeItemElements = () => {
|
||||
TreeItem.register();
|
||||
TreeItemLeaf.register();
|
||||
};
|
||||
|
||||
export enum TreeItemButtonIcon {
|
||||
Scroll = 'lucide-scroll',
|
||||
Inspect = 'lucide-inspect',
|
||||
Search = 'lucide-search',
|
||||
}
|
||||
|
||||
export class TreeItemLeaf<TData extends ITreeVisualizable> extends HTMLElement {
|
||||
private readonly inner: HTMLDivElement;
|
||||
private readonly suffix: HTMLSpanElement;
|
||||
private readonly subtitle: HTMLSpanElement;
|
||||
private readonly flair: HTMLSpanElement;
|
||||
protected readonly buttons: ButtonComponent[] = [];
|
||||
protected readonly mainWrapper: HTMLDivElement;
|
||||
protected readonly flairWrapper: HTMLDivElement;
|
||||
public readonly value: TData;
|
||||
|
||||
public get hash(): string {
|
||||
return this.value.hash;
|
||||
}
|
||||
public get text(): string {
|
||||
return this.value.text;
|
||||
}
|
||||
|
||||
public static register(): void {
|
||||
customElements.define('crossbow-tree-item-leaf', TreeItemLeaf);
|
||||
}
|
||||
|
||||
constructor(value: TData) {
|
||||
super();
|
||||
this.value = value;
|
||||
|
||||
this.addClass('tree-item');
|
||||
this.mainWrapper = this.createDiv({
|
||||
cls: 'tree-item-self is-clickable',
|
||||
});
|
||||
|
||||
this.inner = this.mainWrapper.createDiv({
|
||||
cls: 'tree-item-inner cb-tree-item-inner-extensions',
|
||||
text: this.text,
|
||||
});
|
||||
this.flairWrapper = this.mainWrapper.createDiv({
|
||||
cls: 'tree-item-flair-outer',
|
||||
});
|
||||
|
||||
this.suffix = this.inner.createEl('span', {
|
||||
cls: 'cb-tree-item-inner-suffix',
|
||||
});
|
||||
this.subtitle = this.inner.createEl('span', {
|
||||
cls: 'cb-tree-item-inner-subtitle',
|
||||
});
|
||||
this.flair = this.flairWrapper.createEl('span', {
|
||||
cls: 'tree-item-flair',
|
||||
});
|
||||
}
|
||||
|
||||
public setDisable() {
|
||||
this.mainWrapper.style.textDecoration = 'line-through';
|
||||
this.buttons.forEach((button) => button.setDisabled(true));
|
||||
}
|
||||
|
||||
public addOnClick(listener: (this: HTMLDivElement, ev: HTMLElementEventMap['click']) => void): void {
|
||||
this.mainWrapper.addEventListener('click', listener);
|
||||
}
|
||||
|
||||
public addFlair(text: string): void {
|
||||
this.flair.innerText = text;
|
||||
}
|
||||
|
||||
public addTextSuffix(text: string): void {
|
||||
this.suffix.innerText = text;
|
||||
}
|
||||
|
||||
public addSubtitle(text: string): void {
|
||||
this.subtitle.innerText = text;
|
||||
}
|
||||
|
||||
public addButton(
|
||||
label: string,
|
||||
iconName: TreeItemButtonIcon,
|
||||
onclick: (this: HTMLDivElement, ev: MouseEvent) => void
|
||||
): void {
|
||||
const button = new ButtonComponent(this.mainWrapper);
|
||||
|
||||
button.setTooltip(label);
|
||||
button.setIcon(iconName);
|
||||
button.setClass('cb-tree-item-button');
|
||||
button.onClick(onclick);
|
||||
|
||||
this.buttons.push(button);
|
||||
}
|
||||
}
|
||||
|
||||
export class TreeItem<TData extends ITreeVisualizable> extends TreeItemLeaf<TData> {
|
||||
protected readonly childrenWrapper: HTMLDivElement;
|
||||
private readonly iconWrapper: HTMLDivElement;
|
||||
|
||||
private childrenFactory: ((self: TreeItem<TData>) => TreeItemLeaf<ITreeVisualizable>[]) | null = null;
|
||||
|
||||
public static register(): void {
|
||||
customElements.define('crossbow-tree-item', TreeItem);
|
||||
}
|
||||
|
||||
public constructor(value: TData, childrenFactory: (self: TreeItem<TData>) => TreeItemLeaf<ITreeVisualizable>[]) {
|
||||
super(value);
|
||||
this.childrenFactory = childrenFactory;
|
||||
|
||||
this.addClass('is-collapsed');
|
||||
this.mainWrapper.addClass('mod-collapsible');
|
||||
|
||||
this.childrenWrapper = this.createDiv({ cls: 'tree-item-children' });
|
||||
this.childrenWrapper.style.display = 'none';
|
||||
|
||||
this.iconWrapper = this.createDiv({
|
||||
cls: ['tree-item-icon', 'collapse-icon'],
|
||||
});
|
||||
this.iconWrapper.appendChild(getIcon('right-triangle') ?? new SVGElement());
|
||||
|
||||
this.appendChild(this.childrenWrapper);
|
||||
this.mainWrapper.prepend(this.iconWrapper);
|
||||
|
||||
// Collapse / Fold
|
||||
this.mainWrapper.addEventListener('click', () => (this.isCollapsed() ? this.expand() : this.collapse()));
|
||||
}
|
||||
|
||||
public isCollapsed() {
|
||||
return this.hasClass('is-collapsed');
|
||||
}
|
||||
|
||||
public expand() {
|
||||
if (this.childrenFactory) {
|
||||
this.addTreeItems(this.childrenFactory(this));
|
||||
this.childrenFactory = null;
|
||||
}
|
||||
|
||||
this.removeClass('is-collapsed');
|
||||
this.childrenWrapper.style.display = 'block';
|
||||
}
|
||||
|
||||
public collapse() {
|
||||
this.addClass('is-collapsed');
|
||||
this.childrenWrapper.style.display = 'none';
|
||||
}
|
||||
|
||||
public setDisable() {
|
||||
super.setDisable();
|
||||
this.mainWrapper.style.textDecoration = 'line-through';
|
||||
this.buttons.forEach((button) => (button.disabled = true));
|
||||
this.getTreeItems().forEach((child) => child.setDisable());
|
||||
}
|
||||
|
||||
public addTreeItems(children: TreeItemLeaf<ITreeVisualizable>[]) {
|
||||
this.childrenWrapper.replaceChildren(...children);
|
||||
}
|
||||
|
||||
public getTreeItems(): TreeItemLeaf<ITreeVisualizable>[] {
|
||||
return Array.from(this.childrenWrapper.children) as TreeItemLeaf<ITreeVisualizable>[];
|
||||
}
|
||||
}
|
||||
|
|
@ -7,30 +7,31 @@
|
|||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { Editor, ItemView, WorkspaceLeaf } from 'obsidian';
|
||||
import { ButtonComponent, Editor, ItemView, WorkspaceLeaf } from 'obsidian';
|
||||
import { CrossbowViewController } from 'src/controllers/viewController';
|
||||
import { Suggestion } from 'src/model/suggestion';
|
||||
import { ITreeVisualizable, TreeItem } from './treeItem';
|
||||
import { viewBuilder } from './viewBuilder';
|
||||
import { Tree } from './tree/tree';
|
||||
|
||||
export class CrossbowView extends ItemView {
|
||||
private readonly treeEl: HTMLElement;
|
||||
private readonly controlsEl: HTMLElement;
|
||||
public static viewType = 'crossbow-toolbar';
|
||||
private readonly controlsContainer: HTMLDivElement;
|
||||
private readonly manualRefreshButton: ButtonComponent;
|
||||
private readonly treeContainer: HTMLDivElement;
|
||||
private tree: Tree | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, private readonly onManualRefreshButtonClick: (evt: MouseEvent) => void) {
|
||||
super(leaf);
|
||||
this.controlsEl = this.contentEl.createDiv({ cls: 'cb-view-controls' });
|
||||
this.treeEl = this.contentEl.createDiv({ cls: 'cb-view-tree' });
|
||||
|
||||
viewBuilder.createManualRefreshButton(this.controlsEl, this.onManualRefreshButtonClick);
|
||||
this.treeEl.createSpan({ text: 'Open a note to run crossbow', cls: 'cb-view-empty' });
|
||||
this.controlsContainer = this.contentEl.createDiv({ cls: 'cb-view-controls' });
|
||||
this.treeContainer = this.contentEl.createDiv({ cls: 'cb-view-tree' });
|
||||
this.treeContainer.createSpan({ text: 'Open a note to run crossbow', cls: 'cb-view-empty' });
|
||||
|
||||
this.manualRefreshButton = this.createManualRefreshButton(this.controlsContainer, this.onManualRefreshButtonClick);
|
||||
}
|
||||
|
||||
public static viewType = 'crossbow-toolbar';
|
||||
|
||||
public getViewType(): string {
|
||||
return CrossbowView.viewType;
|
||||
}
|
||||
|
|
@ -49,62 +50,39 @@ export class CrossbowView extends ItemView {
|
|||
}
|
||||
|
||||
public clear(): void {
|
||||
this.treeEl.empty();
|
||||
this.tree?.remove();
|
||||
this.tree = null;
|
||||
}
|
||||
|
||||
public update(suggestions: Suggestion[], targetEditor: Editor, showManualRefreshButton: boolean): void {
|
||||
showManualRefreshButton ? this.getManualRefreshButton().show() : this.getManualRefreshButton().hide();
|
||||
showManualRefreshButton ? this.manualRefreshButton.buttonEl.show() : this.manualRefreshButton.buttonEl.hide();
|
||||
|
||||
this.addOrUpdateSuggestions(suggestions, targetEditor);
|
||||
if (!this.tree) {
|
||||
this.createTree(targetEditor);
|
||||
} else if (this.tree.targetEditor !== targetEditor) {
|
||||
this.tree.remove();
|
||||
this.createTree(targetEditor);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.tree!.update(suggestions);
|
||||
}
|
||||
|
||||
public addOrUpdateSuggestions(suggestions: Suggestion[], targetEditor: Editor): void {
|
||||
const currentSuggestionTreeItems = this.getCurrentSuggestions();
|
||||
|
||||
suggestions.forEach((suggestion) => {
|
||||
// Find if this Suggestion already exists
|
||||
const index = currentSuggestionTreeItems.findIndex((item) => item.hash === suggestion.hash);
|
||||
const existingSuggestion = index !== -1 ? currentSuggestionTreeItems.splice(index, 1)[0] : undefined;
|
||||
|
||||
const suggestionTreeItem = viewBuilder.createSuggestionTreeItem(suggestion, targetEditor);
|
||||
|
||||
if (existingSuggestion) {
|
||||
const expandedOccurrencesHashes = existingSuggestion
|
||||
.getTreeItems()
|
||||
.filter((item) => !(item as TreeItem<ITreeVisualizable>).isCollapsed())
|
||||
.map((item) => item.hash);
|
||||
|
||||
suggestionTreeItem.getTreeItems().forEach((occurrence) => {
|
||||
// Toggle expanded state, if it was expanded before
|
||||
if (expandedOccurrencesHashes.includes(occurrence.hash)) {
|
||||
(occurrence as TreeItem<ITreeVisualizable>).expand();
|
||||
}
|
||||
});
|
||||
|
||||
// Insert / append the new suggestion, depending on whether it already existed
|
||||
this.treeEl.insertAfter(suggestionTreeItem, existingSuggestion);
|
||||
existingSuggestion.isCollapsed() ? suggestionTreeItem.collapse() : suggestionTreeItem.expand();
|
||||
existingSuggestion.remove();
|
||||
} else {
|
||||
// Insert the new suggestion at the correct position. They are sorted by localeCompare of their 'hash' property
|
||||
const index = currentSuggestionTreeItems.findIndex((item) => suggestion.hash.localeCompare(item.hash) < 0);
|
||||
if (index === -1) {
|
||||
this.treeEl.appendChild(suggestionTreeItem);
|
||||
} else {
|
||||
this.treeEl.insertBefore(suggestionTreeItem, currentSuggestionTreeItems[index]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Now, we're left with the existing suggestions that we need to remove
|
||||
currentSuggestionTreeItems.forEach((item) => item.remove());
|
||||
private createTree(targetEditor: Editor) {
|
||||
this.tree = new Tree(targetEditor);
|
||||
this.treeContainer.empty();
|
||||
this.treeContainer.appendChild(this.tree);
|
||||
}
|
||||
|
||||
private getManualRefreshButton(): HTMLElement {
|
||||
return this.controlsEl.querySelector('#' + CrossbowViewController.MANUAL_REFRESH_BUTTON_ID) as HTMLElement;
|
||||
}
|
||||
private createManualRefreshButton(parentEl: HTMLElement, onClick: (ev: MouseEvent) => void): ButtonComponent {
|
||||
const button = new ButtonComponent(parentEl);
|
||||
|
||||
private getCurrentSuggestions(): TreeItem<Suggestion>[] {
|
||||
return this.treeEl.children.length > 0 ? (Array.from(this.treeEl.children) as TreeItem<Suggestion>[]) : [];
|
||||
button.buttonEl.id = CrossbowViewController.MANUAL_REFRESH_BUTTON_ID;
|
||||
button.setTooltip('Refresh suggestions');
|
||||
button.setIcon('lucide-rotate-cw');
|
||||
button.setClass('cb-tree-item-button');
|
||||
button.onClick(onClick);
|
||||
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,139 +0,0 @@
|
|||
// Copyright (C) 2023 - shoedler - github.com/shoedler
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
import { ButtonComponent, Editor, EditorPosition, MarkdownView } from 'obsidian';
|
||||
import { CrossbowViewController } from 'src/controllers/viewController';
|
||||
import { Match, Occurrence, Suggestion } from 'src/model/suggestion';
|
||||
import { CacheMatch } from 'src/services/indexingService';
|
||||
import { CrossbowLoggingService } from 'src/services/loggingService';
|
||||
import { TreeItem, TreeItemButtonIcon, TreeItemLeaf } from './treeItem';
|
||||
|
||||
const createManualRefreshButton = (parentEl: HTMLElement, onClick: (ev: MouseEvent) => void): ButtonComponent => {
|
||||
const button = new ButtonComponent(parentEl);
|
||||
|
||||
button.buttonEl.id = CrossbowViewController.MANUAL_REFRESH_BUTTON_ID;
|
||||
button.setTooltip('Refresh suggestions');
|
||||
button.setIcon('lucide-rotate-cw');
|
||||
button.setClass('cb-tree-item-button');
|
||||
button.onClick(onClick);
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
const createSuggestionTreeItem = (suggestion: Suggestion, targetEditor: Editor): TreeItem<Suggestion> => {
|
||||
const lazySuggestionChildrenBuilder = () => createOccurrenceTreeItems(suggestion, targetEditor);
|
||||
|
||||
const suggestionTreeItem = new TreeItem(suggestion, lazySuggestionChildrenBuilder);
|
||||
|
||||
// Add flair
|
||||
const ranks = new Set<CacheMatch['rank']>();
|
||||
suggestion.matches.forEach((match) => ranks.add(match.cacheMatch.rank));
|
||||
|
||||
const availableMatchRanks = Array.from(ranks)
|
||||
.sort((a, b) => (a.codePointAt(0) ?? 0) - (b.codePointAt(0) ?? 0))
|
||||
.join('');
|
||||
|
||||
suggestionTreeItem.addFlair(availableMatchRanks);
|
||||
suggestionTreeItem.addTextSuffix(`(${suggestion.occurrences.length.toString()})`);
|
||||
|
||||
return suggestionTreeItem;
|
||||
};
|
||||
|
||||
const createOccurrenceTreeItems = (suggestion: Suggestion, targetEditor: Editor): TreeItem<Occurrence>[] => {
|
||||
return suggestion.occurrences.map((occurrence) => {
|
||||
const occurrenceEnd = {
|
||||
ch: occurrence.editorPosition.ch + suggestion.word.length,
|
||||
line: occurrence.editorPosition.line,
|
||||
} as EditorPosition;
|
||||
|
||||
const lazyOccurrenceChildrenBuilder = (self: TreeItem<Occurrence>) =>
|
||||
createMatchTreeItems(suggestion.word, self, occurrenceEnd, targetEditor);
|
||||
|
||||
const occurrenceTreeItem = new TreeItem(occurrence, lazyOccurrenceChildrenBuilder);
|
||||
|
||||
// Configure Occurrences
|
||||
// Scroll into view action...
|
||||
const scrollIntoView = () => {
|
||||
targetEditor.setSelection(occurrence.editorPosition, occurrenceEnd);
|
||||
targetEditor.scrollIntoView({ from: occurrence.editorPosition, to: occurrenceEnd }, true);
|
||||
};
|
||||
|
||||
// ...Can be invoked via flair button...
|
||||
occurrenceTreeItem.addButton('Scroll into View', TreeItemButtonIcon.Scroll, (ev: MouseEvent) => {
|
||||
scrollIntoView();
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
|
||||
// ...As well as when expanding the suggestions, if it's collapsed. Greatly improves UX
|
||||
occurrenceTreeItem.addOnClick(() => {
|
||||
if (!occurrenceTreeItem.isCollapsed()) scrollIntoView();
|
||||
});
|
||||
|
||||
return occurrenceTreeItem;
|
||||
});
|
||||
};
|
||||
|
||||
const createMatchTreeItems = (
|
||||
word: Suggestion['word'],
|
||||
occurrenceTreeItem: TreeItem<Occurrence>,
|
||||
occurrenceEnd: EditorPosition,
|
||||
targetEditor: Editor
|
||||
): TreeItemLeaf<Match>[] => {
|
||||
return occurrenceTreeItem.value.matches.map((match) => {
|
||||
const matchTreeItem = new TreeItemLeaf(match);
|
||||
|
||||
const link = match.cacheMatch.item
|
||||
? app.fileManager.generateMarkdownLink(
|
||||
match.cacheMatch.file,
|
||||
match.cacheMatch.text,
|
||||
'#' + match.cacheMatch.text,
|
||||
word
|
||||
)
|
||||
: app.fileManager.generateMarkdownLink(match.cacheMatch.file, match.cacheMatch.text, undefined, word);
|
||||
|
||||
// 'Use' button inserts backlink & disables the occurrence
|
||||
matchTreeItem.addButton('Use', TreeItemButtonIcon.Inspect, () => {
|
||||
occurrenceTreeItem.setDisable();
|
||||
targetEditor.replaceRange(link, occurrenceTreeItem.value.editorPosition, occurrenceEnd);
|
||||
});
|
||||
|
||||
// Go to source action
|
||||
matchTreeItem.addButton('Go To Source', TreeItemButtonIcon.Search, () => {
|
||||
const leaf = app.workspace.getLeaf(true);
|
||||
app.workspace.setActiveLeaf(leaf);
|
||||
leaf.openFile(match.cacheMatch.file).then(() => {
|
||||
if (leaf.view instanceof MarkdownView) {
|
||||
if (match.cacheMatch.item?.position) {
|
||||
const { line, col } = match.cacheMatch.item.position.start;
|
||||
leaf.view.editor.setCursor(line, col);
|
||||
}
|
||||
} else {
|
||||
CrossbowLoggingService.forceLog('warn', 'Could not go to source, not a markdown file');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
matchTreeItem.addTextSuffix(match.cacheMatch.type);
|
||||
|
||||
if (match.cacheMatch.type !== 'File') matchTreeItem.addSubtitle(match.cacheMatch.file.name);
|
||||
|
||||
return matchTreeItem;
|
||||
});
|
||||
};
|
||||
|
||||
export const viewBuilder = {
|
||||
createManualRefreshButton,
|
||||
createMatchTreeItems,
|
||||
createOccurrenceTreeItems,
|
||||
createSuggestionTreeItem,
|
||||
};
|
||||
10
styles.css
10
styles.css
|
|
@ -1,11 +1,17 @@
|
|||
.cb-tree-item-inner-extensions {
|
||||
word-break: break-word;
|
||||
margin-right: auto; /* To make sure the buttons are on the right */
|
||||
}
|
||||
|
||||
.cb-tree-item-inner-suffix {
|
||||
padding-left: 0.2rem;
|
||||
margin-left: 0.2rem;
|
||||
font-size: var(--font-smallest);
|
||||
color: var(--text-muted);
|
||||
opacity: var(--icon-opacity);
|
||||
padding: 0.2rem;
|
||||
padding-top: 0rem;
|
||||
padding-bottom: 0.1rem;
|
||||
background-color: var(--color-base-30);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.cb-tree-item-inner-subtitle {
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@
|
|||
"1.1.1": "1.1.1",
|
||||
"1.2.0": "1.1.1",
|
||||
"1.2.1": "1.1.1",
|
||||
"1.3.0": "1.1.1"
|
||||
"1.3.0": "1.1.1",
|
||||
"1.4.0": "1.4.11"
|
||||
}
|
||||
Loading…
Reference in a new issue