Merge pull request #30 from shoedler/feat/better-tree

Feat/better tree
This commit is contained in:
Simon Schödler 2023-11-07 19:07:33 +01:00 committed by GitHub
commit d062051b63
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 2497 additions and 2137 deletions

View file

@ -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
View file

@ -19,4 +19,7 @@ main.js
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
.DS_Store
# Dump files
*.dump.*

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -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.');
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }],

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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