mirror of
https://github.com/jacobtread/obsidian-timekeep.git
synced 2026-07-22 10:10:27 +00:00
feat: autocomplete and registry improvements
This commit is contained in:
parent
8ce8b29dd9
commit
98da21c0de
23 changed files with 874 additions and 90 deletions
|
|
@ -9,6 +9,7 @@
|
|||
"timesheet",
|
||||
"unstarted",
|
||||
"Timekeeps",
|
||||
"pdfmake"
|
||||
"pdfmake",
|
||||
"activedescendant"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "1.16.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fuse.js": "^7.1.0",
|
||||
"moment": "^2.30.1",
|
||||
"p-limit": "^7.3.0",
|
||||
"pdfmake": "^0.3.7",
|
||||
|
|
@ -1664,6 +1665,15 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
|
||||
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"test": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"fuse.js": "^7.1.0",
|
||||
"moment": "^2.30.1",
|
||||
"p-limit": "^7.3.0",
|
||||
"pdfmake": "^0.3.7",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { App, Component } from "obsidian";
|
||||
|
||||
import { CustomOutputFormat } from "@/output";
|
||||
import { TimekeepAutocomplete } from "@/service/autocomplete";
|
||||
import { TimekeepSettings } from "@/settings";
|
||||
import { Store } from "@/store";
|
||||
import { Timekeep } from "@/timekeep/schema";
|
||||
|
|
@ -26,6 +27,8 @@ export class Timesheet extends Component {
|
|||
settings: Store<TimekeepSettings>;
|
||||
/** Access to custom output formats */
|
||||
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
|
||||
/** Autocomplete */
|
||||
autocomplete: TimekeepAutocomplete;
|
||||
|
||||
/** Callback to save the timekeep */
|
||||
handleSaveTimekeep: (value: Timekeep) => Promise<void>;
|
||||
|
|
@ -40,6 +43,7 @@ export class Timesheet extends Component {
|
|||
saveError: Store<boolean>,
|
||||
settings: Store<TimekeepSettings>,
|
||||
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
|
||||
autocomplete: TimekeepAutocomplete,
|
||||
handleSaveTimekeep: (value: Timekeep) => Promise<void>
|
||||
) {
|
||||
super();
|
||||
|
|
@ -51,6 +55,7 @@ export class Timesheet extends Component {
|
|||
this.saveError = saveError;
|
||||
this.settings = settings;
|
||||
this.customOutputFormats = customOutputFormats;
|
||||
this.autocomplete = autocomplete;
|
||||
this.handleSaveTimekeep = handleSaveTimekeep;
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +88,7 @@ export class Timesheet extends Component {
|
|||
this.timekeep,
|
||||
this.settings,
|
||||
this.customOutputFormats,
|
||||
this.autocomplete,
|
||||
this.handleSaveTimekeep
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { App, Component } from "obsidian";
|
||||
|
||||
import { CustomOutputFormat } from "@/output";
|
||||
import { TimekeepAutocomplete } from "@/service/autocomplete";
|
||||
import { TimekeepSettings } from "@/settings";
|
||||
import { Store } from "@/store";
|
||||
import { Timekeep } from "@/timekeep/schema";
|
||||
|
|
@ -25,6 +26,8 @@ export class TimesheetApp extends Component {
|
|||
settings: Store<TimekeepSettings>;
|
||||
/** Access to custom output formats */
|
||||
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
|
||||
/** Autocomplete */
|
||||
autocomplete: TimekeepAutocomplete;
|
||||
|
||||
/** Callback to save the timekeep */
|
||||
handleSaveTimekeep: (value: Timekeep) => Promise<void>;
|
||||
|
|
@ -38,6 +41,7 @@ export class TimesheetApp extends Component {
|
|||
timekeep: Store<Timekeep>,
|
||||
settings: Store<TimekeepSettings>,
|
||||
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
|
||||
autocomplete: TimekeepAutocomplete,
|
||||
handleSaveTimekeep: (value: Timekeep) => Promise<void>
|
||||
) {
|
||||
super();
|
||||
|
|
@ -48,6 +52,8 @@ export class TimesheetApp extends Component {
|
|||
this.timekeep = timekeep;
|
||||
this.settings = settings;
|
||||
this.customOutputFormats = customOutputFormats;
|
||||
this.autocomplete = autocomplete;
|
||||
|
||||
this.handleSaveTimekeep = handleSaveTimekeep;
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +73,13 @@ export class TimesheetApp extends Component {
|
|||
|
||||
const counters = new TimesheetCounters(wrapperEl, this.settings, this.timekeep);
|
||||
|
||||
const start = new TimesheetStart(wrapperEl, this.app, this.timekeep, this.settings);
|
||||
const start = new TimesheetStart(
|
||||
wrapperEl,
|
||||
this.app,
|
||||
this.timekeep,
|
||||
this.settings,
|
||||
this.autocomplete
|
||||
);
|
||||
|
||||
const table = new TimesheetTable(wrapperEl, this.app, this.timekeep, this.settings);
|
||||
|
||||
|
|
|
|||
374
src/components/timesheetNameInput.ts
Normal file
374
src/components/timesheetNameInput.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import Fuse, { FuseResult } from "fuse.js";
|
||||
import { Component, debounce } from "obsidian";
|
||||
|
||||
import { TimekeepAutocomplete } from "@/service/autocomplete";
|
||||
|
||||
export class TimesheetNameInput extends Component {
|
||||
#containerEl: HTMLElement;
|
||||
|
||||
/** Access to autocomplete */
|
||||
autocomplete: TimekeepAutocomplete;
|
||||
|
||||
/** Name input wrapper container */
|
||||
#wrapperEl: HTMLDivElement | undefined;
|
||||
/** Name input */
|
||||
#inputEl: HTMLInputElement | undefined;
|
||||
/** Suggestions container element */
|
||||
#suggestionsEl: HTMLDivElement | undefined;
|
||||
|
||||
/** Current list of suggestions */
|
||||
#suggestions: FuseResult<string>[] = [];
|
||||
/** Whether the suggestions should be open */
|
||||
#suggestionsOpen: boolean = false;
|
||||
/** Index of the currently focused suggestion */
|
||||
#suggestionFocusIndex: number = -1;
|
||||
|
||||
constructor(containerEl: HTMLElement, autocomplete: TimekeepAutocomplete) {
|
||||
super();
|
||||
|
||||
this.#containerEl = containerEl;
|
||||
this.autocomplete = autocomplete;
|
||||
}
|
||||
|
||||
onload(): void {
|
||||
super.onload();
|
||||
|
||||
const wrapperEl = this.#containerEl.createDiv({ cls: "timekeep-name-containers" });
|
||||
this.#wrapperEl = wrapperEl;
|
||||
|
||||
const inputEl = wrapperEl.createEl("input", {
|
||||
cls: "timekeep-name",
|
||||
placeholder: "Example Block",
|
||||
type: "text",
|
||||
});
|
||||
inputEl.id = "timekeepBlockName";
|
||||
inputEl.role = "combobox";
|
||||
inputEl.setAttribute("aria-expanded", "false");
|
||||
inputEl.setAttribute("aria-controls", "timekeepSuggestions");
|
||||
inputEl.setAttribute("aria-autocomplete", "list");
|
||||
this.#inputEl = inputEl;
|
||||
|
||||
const suggestionsEl = wrapperEl.createDiv({ cls: "timekeep-suggestions" });
|
||||
suggestionsEl.id = "timekeepSuggestions";
|
||||
suggestionsEl.role = "listbox";
|
||||
suggestionsEl.hidden = true;
|
||||
this.#suggestionsEl = suggestionsEl;
|
||||
|
||||
this.registerDomEvent(
|
||||
inputEl,
|
||||
"input",
|
||||
debounce(this.onDebouncedChange.bind(this), 300, true)
|
||||
);
|
||||
|
||||
this.registerDomEvent(inputEl, "focus", this.onFocus.bind(this));
|
||||
this.registerDomEvent(inputEl, "keydown", this.onKeyDown.bind(this));
|
||||
|
||||
this.registerDomEvent(wrapperEl, "mousedown", this.onClickOutside.bind(this));
|
||||
this.registerDomEvent(wrapperEl, "touchstart", this.onClickOutside.bind(this), {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
this.registerDomEvent(suggestionsEl, "mousedown", this.onClickSuggestions.bind(this));
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
super.onunload();
|
||||
this.#wrapperEl?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current list of suggestions based on the currently
|
||||
* typed input words
|
||||
*/
|
||||
getFilteredSuggestions(): FuseResult<string>[] {
|
||||
const autocomplete = this.autocomplete;
|
||||
const suggestions = autocomplete.names.getState();
|
||||
|
||||
const value = this.getValue();
|
||||
const fuse = new Fuse(suggestions, {
|
||||
includeMatches: true,
|
||||
shouldSort: true,
|
||||
});
|
||||
const results = fuse.search(value);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the suggestion children within the suggestion container
|
||||
* for the current available suggestions
|
||||
*/
|
||||
renderSuggestions() {
|
||||
const suggestionsEl = this.#suggestionsEl;
|
||||
if (!suggestionsEl) return;
|
||||
|
||||
suggestionsEl.empty();
|
||||
const suggestions = this.#suggestions;
|
||||
if (suggestions.length < 1) return;
|
||||
|
||||
for (let i = 0; i < suggestions.length; i += 1) {
|
||||
const result = suggestions[i];
|
||||
const suggestion = result.item;
|
||||
|
||||
const suggestionEl = suggestionsEl.createDiv({ cls: "timekeep-suggestion" });
|
||||
suggestionEl.role = "option";
|
||||
suggestionEl.id = `timekeepSuggestion-${i}`;
|
||||
suggestionEl.setAttribute("aria-selected", "false");
|
||||
suggestionEl.setAttribute("value", suggestion);
|
||||
|
||||
if (result.matches && result.matches.length > 0) {
|
||||
const match = result.matches[0];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const [start, end] of match.indices) {
|
||||
if (start > lastIndex) {
|
||||
suggestionEl.appendText(suggestion.slice(lastIndex, start));
|
||||
}
|
||||
|
||||
const text = suggestion.slice(start, end + 1);
|
||||
suggestionEl.createEl("mark", { text });
|
||||
lastIndex = end + 1;
|
||||
}
|
||||
|
||||
if (lastIndex < suggestion.length) {
|
||||
suggestionEl.appendText(suggestion.slice(lastIndex));
|
||||
}
|
||||
} else {
|
||||
suggestionEl.setText(suggestion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clicking suggestion elements
|
||||
*
|
||||
* @param event The click event
|
||||
*/
|
||||
onClickSuggestions(event: MouseEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
if (!target.id.startsWith("timekeepSuggestion-")) return;
|
||||
const suggestion = target;
|
||||
const value = suggestion.getAttribute("value");
|
||||
if (!value) return;
|
||||
this.onSelectSuggestion(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle changes to the input value, this updates the filtered
|
||||
* suggestions list and triggers re-rendering of the suggestions
|
||||
*/
|
||||
onDebouncedChange() {
|
||||
const suggestions = this.getFilteredSuggestions();
|
||||
this.#suggestions = suggestions;
|
||||
|
||||
this.updateSuggestionsOpen();
|
||||
this.renderSuggestions();
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
this.setSuggestionFocus(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clicks and close the suggestion box if they are
|
||||
* outside of the container
|
||||
*
|
||||
* @param event The click / touch event
|
||||
*/
|
||||
onClickOutside(event: MouseEvent | TouchEvent) {
|
||||
if (
|
||||
event.target &&
|
||||
event.target instanceof HTMLElement &&
|
||||
this.#wrapperEl &&
|
||||
!this.#wrapperEl.contains(event.target)
|
||||
) {
|
||||
this.setSuggestionsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input focus, this should open the suggestions
|
||||
* box if it is not already open
|
||||
*/
|
||||
onFocus() {
|
||||
this.setSuggestionsOpen(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events for selecting the suggestion
|
||||
* items using the keyboard
|
||||
*
|
||||
* @param event The keyboard event
|
||||
*/
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
const suggestionsEl = this.#suggestionsEl;
|
||||
if (!suggestionsEl) return;
|
||||
|
||||
const suggestionsOpen = !(suggestionsEl.hidden ?? false);
|
||||
|
||||
if (!suggestionsOpen && event.key === "ArrowDown") {
|
||||
this.setSuggestionsOpen(true);
|
||||
this.setSuggestionFocus(0);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown": {
|
||||
event.preventDefault();
|
||||
|
||||
const focusIndex = this.#suggestionFocusIndex;
|
||||
const nextFocusIndex = Math.min(focusIndex + 1, this.#suggestions.length - 1);
|
||||
this.setSuggestionFocus(nextFocusIndex);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "ArrowUp": {
|
||||
event.preventDefault();
|
||||
|
||||
const focusIndex = this.#suggestionFocusIndex;
|
||||
const prevFocusIndex = Math.max(focusIndex - 1, 0);
|
||||
this.setSuggestionFocus(prevFocusIndex);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "Enter": {
|
||||
const focusIndex = this.#suggestionFocusIndex;
|
||||
if (focusIndex >= 0) {
|
||||
event.preventDefault();
|
||||
const suggestion = this.#suggestions[focusIndex];
|
||||
if (suggestion) {
|
||||
this.onSelectSuggestion(suggestion.item);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "Escape": {
|
||||
this.setSuggestionsOpen(false);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps the current suggestion focus index to be within
|
||||
* the current suggestion bounds
|
||||
*/
|
||||
clampSuggestionFocus() {
|
||||
if (this.#suggestions.length > 0) {
|
||||
const focusIndex = this.#suggestionFocusIndex;
|
||||
const newFocusIndex =
|
||||
focusIndex === -1 ? 0 : Math.min(focusIndex, this.#suggestions.length - 1);
|
||||
|
||||
this.setSuggestionFocus(newFocusIndex);
|
||||
} else {
|
||||
this.setSuggestionFocus(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the focused suggestion index within the drop down menu.
|
||||
*
|
||||
* @param index The index that is focused
|
||||
*/
|
||||
setSuggestionFocus(index: number) {
|
||||
this.#suggestionFocusIndex = index;
|
||||
|
||||
const inputEl = this.#inputEl;
|
||||
const suggestionsEl = this.#suggestionsEl;
|
||||
if (!suggestionsEl || !inputEl) return;
|
||||
|
||||
const children = suggestionsEl.querySelectorAll(".timekeep-suggestion");
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children.item(i);
|
||||
|
||||
const selected = index === i;
|
||||
child.setAttribute("aria-selected", String(selected));
|
||||
|
||||
// Bring the suggestion element into view if its selected
|
||||
if (selected) {
|
||||
child.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update the input aria-activedescendant for screen readers
|
||||
if (index === -1) {
|
||||
inputEl.removeAttribute("aria-activedescendant");
|
||||
} else {
|
||||
inputEl.setAttribute(
|
||||
"aria-activedescendant",
|
||||
`timekeepSuggestion-${this.#suggestionFocusIndex}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the open state of the suggestions box
|
||||
*
|
||||
* @param value The open state of the box
|
||||
*/
|
||||
setSuggestionsOpen(value: boolean) {
|
||||
this.#suggestionsOpen = value;
|
||||
this.updateSuggestionsOpen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the open state of the suggestion box to actually show or
|
||||
* hide the element, this is to ensure an empty suggestion box is
|
||||
* not shown
|
||||
*/
|
||||
updateSuggestionsOpen() {
|
||||
const inputEl = this.#inputEl;
|
||||
const suggestionsEl = this.#suggestionsEl;
|
||||
if (!inputEl || !suggestionsEl) return;
|
||||
|
||||
// Force closed state if theres no suggestions
|
||||
const open = this.#suggestions.length < 1 ? false : this.#suggestionsOpen;
|
||||
|
||||
suggestionsEl.hidden = !open;
|
||||
inputEl.setAttribute("aria-expanded", String(open));
|
||||
|
||||
// Update the focus
|
||||
this.clampSuggestionFocus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle selecting a suggestion value, this updates the
|
||||
* current input value to match the suggestion
|
||||
*
|
||||
* @param value The suggestion value
|
||||
*/
|
||||
onSelectSuggestion(value: string) {
|
||||
if (this.#inputEl) {
|
||||
this.#inputEl.value = value;
|
||||
}
|
||||
|
||||
this.setSuggestionsOpen(false);
|
||||
this.setSuggestionFocus(-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter for the name input value
|
||||
*
|
||||
* @returns The name value
|
||||
*/
|
||||
getValue(): string {
|
||||
return this.#inputEl?.value ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the name input value to an empty string
|
||||
*/
|
||||
resetValue() {
|
||||
if (!this.#inputEl) return;
|
||||
this.#inputEl.value = "";
|
||||
}
|
||||
}
|
||||
|
|
@ -5,9 +5,11 @@ import type { TimekeepSettings } from "@/settings";
|
|||
import type { Store } from "@/store";
|
||||
import type { Timekeep } from "@/timekeep/schema";
|
||||
|
||||
import { TimekeepAutocomplete } from "@/service/autocomplete";
|
||||
import { getRunningEntry, startNewEntry } from "@/timekeep";
|
||||
|
||||
import { createObsidianIcon } from "./obsidianIcon";
|
||||
import { TimesheetNameInput } from "./timesheetNameInput";
|
||||
import { TimekeepStartEditing } from "./timesheetStartEditing";
|
||||
import { TimesheetStartRunning } from "./timesheetStartRunning";
|
||||
|
||||
|
|
@ -24,6 +26,8 @@ export class TimesheetStart extends Component {
|
|||
timekeep: Store<Timekeep>;
|
||||
/** Access to the timekeep settings */
|
||||
settings: Store<TimekeepSettings>;
|
||||
/** Access to autocomplete */
|
||||
autocomplete: TimekeepAutocomplete;
|
||||
|
||||
/** Wrapper for the component content */
|
||||
#wrapperEl: HTMLElement | undefined;
|
||||
|
|
@ -31,7 +35,8 @@ export class TimesheetStart extends Component {
|
|||
#contentEl: HTMLElement | undefined;
|
||||
|
||||
/** Name input for starting entries */
|
||||
#nameInputEl: HTMLInputElement | undefined;
|
||||
#nameInput: TimesheetNameInput | undefined;
|
||||
|
||||
/** Warning message element */
|
||||
#blockPauseWarningEl: HTMLElement | undefined;
|
||||
/** Start button element */
|
||||
|
|
@ -44,13 +49,16 @@ export class TimesheetStart extends Component {
|
|||
containerEl: HTMLElement,
|
||||
app: App,
|
||||
timekeep: Store<Timekeep>,
|
||||
settings: Store<TimekeepSettings>
|
||||
settings: Store<TimekeepSettings>,
|
||||
autocomplete: TimekeepAutocomplete
|
||||
) {
|
||||
super();
|
||||
|
||||
this.app = app;
|
||||
this.timekeep = timekeep;
|
||||
this.settings = settings;
|
||||
this.autocomplete = autocomplete;
|
||||
|
||||
this.#containerEl = containerEl;
|
||||
}
|
||||
|
||||
|
|
@ -87,13 +95,9 @@ export class TimesheetStart extends Component {
|
|||
|
||||
blockPauseWarningEl.hidden = true;
|
||||
|
||||
const nameInputEl = nameWrapperEl.createEl("input", {
|
||||
cls: "timekeep-name",
|
||||
placeholder: "Example Block",
|
||||
type: "text",
|
||||
});
|
||||
nameInputEl.id = "timekeepBlockName";
|
||||
this.#nameInputEl = nameInputEl;
|
||||
const nameInput = new TimesheetNameInput(nameWrapperEl, this.autocomplete);
|
||||
this.#nameInput = nameInput;
|
||||
this.addChild(nameInput);
|
||||
|
||||
const startButton = formEl.createEl("button", {
|
||||
cls: "timekeep-start",
|
||||
|
|
@ -212,17 +216,15 @@ export class TimesheetStart extends Component {
|
|||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const nameInputEl = this.#nameInputEl;
|
||||
if (!nameInputEl) return;
|
||||
|
||||
const name = nameInputEl.value;
|
||||
if (!this.#nameInput) return;
|
||||
const name = this.#nameInput.getValue();
|
||||
|
||||
this.timekeep.setState((timekeep) => {
|
||||
const currentTime = moment();
|
||||
const entries = startNewEntry(name, currentTime, timekeep.entries);
|
||||
|
||||
// Reset name input
|
||||
nameInputEl.value = "";
|
||||
this.#nameInput?.resetValue();
|
||||
|
||||
return {
|
||||
...timekeep,
|
||||
|
|
|
|||
46
src/main.ts
46
src/main.ts
|
|
@ -23,8 +23,8 @@ import { load, replaceTimekeepCodeblock, extractTimekeepCodeblocks } from "@/tim
|
|||
|
||||
import { stopAllTimekeeps } from "./commands/stopAllTimekeeps";
|
||||
import { stopFileTimekeeps } from "./commands/stopFileTimekeeps";
|
||||
import { TimesheetStatusBarItem } from "./components/timesheetStatusBarItem";
|
||||
import { CustomOutputFormat } from "./output";
|
||||
import { TimekeepAutocomplete } from "./service/autocomplete";
|
||||
import { TimekeepRegistry } from "./service/registry";
|
||||
import { Timekeep, TimeEntry } from "./timekeep/schema";
|
||||
import { TimekeepLocatorModal } from "./views/timekeep-locator-modal";
|
||||
|
|
@ -54,6 +54,9 @@ export default class TimekeepPlugin extends Plugin {
|
|||
/** Registry of all timekeeps within the vault */
|
||||
registry: TimekeepRegistry | null = null;
|
||||
|
||||
/** Currently loaded status bar view if present */
|
||||
#statusBarView: TimekeepStatusBarView | null = null;
|
||||
|
||||
constructor(app: ObsidianApp, manifest: PluginManifest) {
|
||||
super(app, manifest);
|
||||
|
||||
|
|
@ -96,6 +99,16 @@ export default class TimekeepPlugin extends Plugin {
|
|||
|
||||
this.addSettingTab(new TimekeepSettingsTab(this.app, this));
|
||||
|
||||
const registry = new TimekeepRegistry(this.app.vault, this.settingsStore);
|
||||
this.registry = registry;
|
||||
|
||||
const autocomplete = new TimekeepAutocomplete(this.registry, this.settingsStore);
|
||||
this.addChild(autocomplete);
|
||||
|
||||
const onLoadStatusBar = this.onLoadStatusBar.bind(this);
|
||||
this.settingsStore.subscribe(onLoadStatusBar);
|
||||
onLoadStatusBar();
|
||||
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
const settings = this.settingsStore.getState();
|
||||
|
||||
|
|
@ -103,15 +116,8 @@ export default class TimekeepPlugin extends Plugin {
|
|||
return;
|
||||
}
|
||||
|
||||
// Initialize the registry (After the layout is ready and the vault is loaded)
|
||||
this.registry = new TimekeepRegistry(this.app.vault);
|
||||
this.registry.concurrencyLimit = settings.registryConcurrencyLimit;
|
||||
this.registry.onload();
|
||||
|
||||
if (settings.statusBarEnabled) {
|
||||
const containerEl = this.addStatusBarItem();
|
||||
new TimekeepStatusBarView(containerEl, this.app, this.registry, this.settingsStore);
|
||||
}
|
||||
// Initialize the registry (Only after the layout is ready and the vault is loaded)
|
||||
this.addChild(registry);
|
||||
});
|
||||
|
||||
this.registerMarkdownCodeBlockProcessor(
|
||||
|
|
@ -125,6 +131,7 @@ export default class TimekeepPlugin extends Plugin {
|
|||
this.app,
|
||||
this.settingsStore,
|
||||
this.customOutputFormats,
|
||||
autocomplete,
|
||||
context,
|
||||
loadResult
|
||||
)
|
||||
|
|
@ -246,4 +253,23 @@ export default class TimekeepPlugin extends Plugin {
|
|||
return newState;
|
||||
});
|
||||
}
|
||||
|
||||
private onLoadStatusBar() {
|
||||
if (!this.registry) {
|
||||
throw new Error("registry is not initialized");
|
||||
}
|
||||
|
||||
if (this.#statusBarView) {
|
||||
this.removeChild(this.#statusBarView);
|
||||
this.#statusBarView = null;
|
||||
}
|
||||
|
||||
const settings = this.settingsStore.getState();
|
||||
if (!settings.statusBarEnabled) return;
|
||||
|
||||
const containerEl = this.addStatusBarItem();
|
||||
const statusBarView = new TimekeepStatusBarView(containerEl, this.app, this.registry);
|
||||
this.addChild(statusBarView);
|
||||
this.#statusBarView = statusBarView;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
src/service/autocomplete.ts
Normal file
98
src/service/autocomplete.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { Component } from "obsidian";
|
||||
|
||||
import { TimekeepSettings } from "@/settings";
|
||||
import { createStore, Store } from "@/store";
|
||||
import { getEntriesNames } from "@/timekeep/queries";
|
||||
import { isNumberText } from "@/utils/number";
|
||||
|
||||
import { TimekeepRegistry } from "./registry";
|
||||
|
||||
/**
|
||||
* Autocomplete registry to provide entry name autocomplete based
|
||||
* on all the existing tasks within the vault
|
||||
*/
|
||||
export class TimekeepAutocomplete extends Component {
|
||||
/** The registry of timekeeps */
|
||||
registry: TimekeepRegistry;
|
||||
/** Access to settings */
|
||||
settings: Store<TimekeepSettings>;
|
||||
/** The collection of timekeep names */
|
||||
names: Store<string[]>;
|
||||
|
||||
constructor(registry: TimekeepRegistry, settings: Store<TimekeepSettings>) {
|
||||
super();
|
||||
|
||||
this.registry = registry;
|
||||
this.settings = settings;
|
||||
|
||||
this.names = createStore([]);
|
||||
}
|
||||
|
||||
onload() {
|
||||
super.onload();
|
||||
|
||||
const onUpdateRegistry = this.onUpdateRegistry.bind(this);
|
||||
const unsubscribe = this.settings.subscribe(onUpdateRegistry);
|
||||
onUpdateRegistry();
|
||||
this.register(unsubscribe);
|
||||
}
|
||||
|
||||
onUpdateRegistry() {
|
||||
const settings = this.settings.getState();
|
||||
if (!settings.autocompleteEnabled) {
|
||||
this.names.setState([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const onChangeEntries = this.onChangeEntries.bind(this);
|
||||
const unsubscribe = this.registry.entries.subscribe(onChangeEntries);
|
||||
onChangeEntries();
|
||||
this.register(unsubscribe);
|
||||
}
|
||||
|
||||
onChangeEntries() {
|
||||
const entries = this.registry.entries.getState();
|
||||
const namesSet = new Set<string>();
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const timekeep of entry.timekeeps) {
|
||||
getEntriesNames(timekeep.timekeep.entries, namesSet);
|
||||
}
|
||||
}
|
||||
|
||||
const names = Array.from(namesSet)
|
||||
//
|
||||
.filter((name) => !TimekeepAutocomplete.isIgnoredName(name));
|
||||
|
||||
names.sort();
|
||||
|
||||
this.names.setState(names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a name should be ignored from autocomplete
|
||||
*
|
||||
* @param name The name to check
|
||||
* @returns Whether the name should be ignored
|
||||
*/
|
||||
static isIgnoredName(name: string) {
|
||||
if (name.length < 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ignore "Part 1" "Part 2", "Block 1" ...etc
|
||||
if (name.startsWith("Part") || name.startsWith("Block")) {
|
||||
const parts = name.split(" ");
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const numericPart = parts[1];
|
||||
if (isNumberText(numericPart)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { EventRef, TAbstractFile, TFile, Vault } from "obsidian";
|
||||
import { Component, TAbstractFile, TFile, Vault } from "obsidian";
|
||||
import { limitFunction } from "p-limit";
|
||||
|
||||
import { TimekeepSettings } from "@/settings";
|
||||
import { createStore, Store } from "@/store";
|
||||
import {
|
||||
extractTimekeepCodeblocksWithPosition,
|
||||
|
|
@ -15,30 +16,41 @@ export type TimekeepRegistryEntry = {
|
|||
/**
|
||||
* Obsidian vault registry of all timekeep instances
|
||||
*/
|
||||
export class TimekeepRegistry {
|
||||
export class TimekeepRegistry extends Component {
|
||||
/** Vault that the registry is operating on */
|
||||
#vault: Vault;
|
||||
|
||||
/** Store for entries within the registry */
|
||||
entries: Store<TimekeepRegistryEntry[]>;
|
||||
|
||||
/** Tracked events */
|
||||
#events: EventRef[] = [];
|
||||
/** Settings access */
|
||||
settings: Store<TimekeepSettings>;
|
||||
|
||||
/** Concurrency limit for registry indexing */
|
||||
concurrencyLimit: number = 10;
|
||||
/** Whether the store is initialized */
|
||||
initialized: boolean;
|
||||
|
||||
constructor(vault: Vault) {
|
||||
constructor(vault: Vault, settings: Store<TimekeepSettings>) {
|
||||
super();
|
||||
this.#vault = vault;
|
||||
this.entries = createStore([]);
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
onload() {
|
||||
const settings = this.settings.getState();
|
||||
if (!settings.registryEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach vault events
|
||||
const createEvent = this.#vault.on("create", this.onFileCreated.bind(this));
|
||||
const modifyEvent = this.#vault.on("modify", this.onFileModified.bind(this));
|
||||
const deleteEvent = this.#vault.on("delete", this.onFileRemoved.bind(this));
|
||||
this.#events.push(createEvent, modifyEvent, deleteEvent);
|
||||
|
||||
// Register events for unloading
|
||||
this.registerEvent(createEvent);
|
||||
this.registerEvent(modifyEvent);
|
||||
this.registerEvent(deleteEvent);
|
||||
|
||||
// Load the registry from the vault
|
||||
void this.loadFromVault()
|
||||
|
|
@ -93,7 +105,6 @@ export class TimekeepRegistry {
|
|||
|
||||
this.entries.setState((entries) => {
|
||||
const newEntries = entries.filter((entry) => {
|
||||
console.log(file, entry.file, file === entry.file);
|
||||
return entry.file !== file;
|
||||
});
|
||||
return newEntries;
|
||||
|
|
@ -104,10 +115,11 @@ export class TimekeepRegistry {
|
|||
* Load the registry from the current vault
|
||||
*/
|
||||
async loadFromVault() {
|
||||
const settings = this.settings.getState();
|
||||
const entries = await TimekeepRegistry.getTimekeepsWithinVault(
|
||||
this.#vault,
|
||||
true,
|
||||
this.concurrencyLimit
|
||||
settings.registryConcurrencyLimit
|
||||
);
|
||||
this.entries.setState(entries);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -440,5 +440,26 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
}));
|
||||
});
|
||||
});
|
||||
|
||||
// Autocomplete section
|
||||
new Setting(this.containerEl)
|
||||
.setName("Autocomplete")
|
||||
.setDesc(
|
||||
"Timekeep can autocomplete entry names from existing timekeeps. This requires that the registry option above is enabled"
|
||||
)
|
||||
.setHeading();
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName("Enabled")
|
||||
.setDesc("Whether to enable autocomplete.")
|
||||
.addToggle((t) => {
|
||||
t.setValue(settings.autocompleteEnabled);
|
||||
t.onChange((v) => {
|
||||
this.settingsStore.setState((currentValue) => ({
|
||||
...currentValue,
|
||||
autocompleteEnabled: v,
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ export interface TimekeepSettings {
|
|||
registryConcurrencyLimit: number;
|
||||
|
||||
statusBarEnabled: boolean;
|
||||
|
||||
autocompleteEnabled: boolean;
|
||||
}
|
||||
|
||||
export const defaultSettings: TimekeepSettings = {
|
||||
|
|
@ -102,6 +104,8 @@ export const defaultSettings: TimekeepSettings = {
|
|||
registryConcurrencyLimit: 15,
|
||||
|
||||
statusBarEnabled: true,
|
||||
|
||||
autocompleteEnabled: true,
|
||||
};
|
||||
|
||||
export function legacySettingsCompatibility(settings: TimekeepSettings): void {
|
||||
|
|
|
|||
10
src/store.ts
10
src/store.ts
|
|
@ -21,6 +21,16 @@ type StoreState<T> = {
|
|||
listeners: VoidFunction[];
|
||||
};
|
||||
|
||||
export function derived<T, V>(store: Store<T>, derive: (value: T) => V): Store<V> {
|
||||
const derivedStore = createStore(derive(store.getState()));
|
||||
store.subscribe(() => {
|
||||
const newValue = derive(store.getState());
|
||||
derivedStore.setState(newValue);
|
||||
});
|
||||
|
||||
return derivedStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new store
|
||||
*
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@
|
|||
flex-flow: column;
|
||||
flex: auto;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
|
@ -299,3 +299,41 @@
|
|||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timekeep-name-containers {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timekeep-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
|
||||
width: 100%;
|
||||
max-height: 100px;
|
||||
background-color: var(--color-base-20);
|
||||
border: 1px solid var(--color-base-30);
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
padding: 0.5rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.timekeep-suggestion {
|
||||
height: calc((var(--font-ui-small) * var(--line-height-normal)) + 0.5rem);
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: var(--font-ui-small);
|
||||
}
|
||||
|
||||
.timekeep-suggestion[aria-selected="true"] {
|
||||
background-color: var(--color-base-40);
|
||||
}
|
||||
|
|
|
|||
20
src/timekeep/__fixtures__/names/flatNames.ts
Normal file
20
src/timekeep/__fixtures__/names/flatNames.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { TimeEntry } from "@/timekeep/schema";
|
||||
|
||||
export const input: TimeEntry[] = [
|
||||
{
|
||||
id: "9054dee3-8c15-493b-ad31-f070e08c2699",
|
||||
name: "Part 1",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
subEntries: null,
|
||||
},
|
||||
{
|
||||
id: "9054dee3-8c15-493b-ad31-f070e08c2699",
|
||||
name: "Part 2",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
subEntries: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const expected: string[] = ["Part 1", "Part 2"].sort();
|
||||
57
src/timekeep/__fixtures__/names/nestedNames.ts
Normal file
57
src/timekeep/__fixtures__/names/nestedNames.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { TimeEntry } from "@/timekeep/schema";
|
||||
|
||||
export const input: TimeEntry[] = [
|
||||
{
|
||||
id: "9054dee3-8c15-493b-ad31-f070e08c2699",
|
||||
name: "Part 1",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
subEntries: null,
|
||||
},
|
||||
{
|
||||
id: "d25ab2f5-00ac-4c89-9168-6062f7e01688",
|
||||
name: "Part 2",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
subEntries: [
|
||||
{
|
||||
id: "8fec44f0-9d5d-4b83-b960-63f73ec2d29d",
|
||||
name: "Part 3",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
subEntries: null,
|
||||
},
|
||||
{
|
||||
id: "4a806a42-7463-4bbc-82e8-4ef193ed5bc6",
|
||||
name: "Part 4",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
subEntries: [
|
||||
{
|
||||
id: "609c792d-eebc-42bb-85a7-1614b5b0a792",
|
||||
name: "Part 5",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
subEntries: null,
|
||||
},
|
||||
{
|
||||
id: "7c7955f5-4363-4cc1-93a5-9f654ffc8767",
|
||||
name: "Part 6",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
subEntries: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const expected: string[] = [
|
||||
"Part 1",
|
||||
"Part 2",
|
||||
"Part 3",
|
||||
"Part 4",
|
||||
"Part 5",
|
||||
"Part 6",
|
||||
].sort();
|
||||
|
|
@ -8,7 +8,9 @@ import {
|
|||
getRunningEntry,
|
||||
getEntryDuration,
|
||||
getTotalDuration,
|
||||
getEntriesNames,
|
||||
} from "./queries";
|
||||
import { TimeEntry } from "./schema";
|
||||
|
||||
describe("getEntryById", () => {
|
||||
it("find top level entry", async () => {
|
||||
|
|
@ -163,3 +165,41 @@ describe("getTotalDuration", () => {
|
|||
expect(output).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEntriesNames", () => {
|
||||
it("empty list should return no names", () => {
|
||||
const input: TimeEntry[] = [];
|
||||
const expected: string[] = [];
|
||||
|
||||
const output = new Set<string>();
|
||||
|
||||
getEntriesNames(input, output);
|
||||
|
||||
// Sort output for consistent result
|
||||
const outputSet = Array.from(output).sort();
|
||||
expect(outputSet).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should return all names from a flat list", async () => {
|
||||
const { input, expected } = await import("./__fixtures__/names/flatNames");
|
||||
|
||||
const output = new Set<string>();
|
||||
|
||||
getEntriesNames(input, output);
|
||||
|
||||
// Sort output for consistent result
|
||||
const outputSet = Array.from(output).sort();
|
||||
expect(outputSet).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should return all names including names from nested entries", async () => {
|
||||
const { input, expected } = await import("./__fixtures__/names/nestedNames");
|
||||
|
||||
const output = new Set<string>();
|
||||
getEntriesNames(input, output);
|
||||
|
||||
// Sort output for consistent result
|
||||
const outputSet = Array.from(output).sort();
|
||||
expect(outputSet).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -193,3 +193,25 @@ export function getStartTime(entry: TimeEntry, newest: boolean): Moment | null {
|
|||
|
||||
return entry.startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the names of all entries within a collection
|
||||
* of entries and any nested entries
|
||||
*
|
||||
* @param entries The entries to extract names from
|
||||
* @param names The set to store collected names in
|
||||
* @returns The list of names
|
||||
*/
|
||||
export function getEntriesNames(entries: TimeEntry[], names: Set<string>) {
|
||||
const stack: TimeEntry[] = [...entries];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const entry: TimeEntry = stack.pop()!;
|
||||
|
||||
names.add(entry.name);
|
||||
|
||||
if (entry.subEntries !== null) {
|
||||
stack.push(...entry.subEntries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
src/utils/number.test.ts
Normal file
45
src/utils/number.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { isNumberText } from "./number";
|
||||
|
||||
describe("isNumberText", () => {
|
||||
it("empty string should not be treated as number text", () => {
|
||||
const input = "";
|
||||
const expected = false;
|
||||
|
||||
const output = isNumberText(input);
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it("string with words should not be treated as number text", () => {
|
||||
const input = "Test";
|
||||
const expected = false;
|
||||
|
||||
const output = isNumberText(input);
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it("string with words and numbers should not be treated as number text", () => {
|
||||
const input = "Test 123";
|
||||
const expected = false;
|
||||
|
||||
const output = isNumberText(input);
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it("string starting with numbers and ending with words should not be treated as number text", () => {
|
||||
const input = "123 Test";
|
||||
const expected = false;
|
||||
|
||||
const output = isNumberText(input);
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it("string only containing numbers should be treated as number text", () => {
|
||||
const input = "123";
|
||||
const expected = true;
|
||||
|
||||
const output = isNumberText(input);
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
10
src/utils/number.ts
Normal file
10
src/utils/number.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Checks if the provided string value is made up of
|
||||
* only numbers
|
||||
*
|
||||
* @param value The value to check
|
||||
* @returns Whether the value is only numbers
|
||||
*/
|
||||
export function isNumberText(value: string): boolean {
|
||||
return /^\d+$/.test(value);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { TFile, TAbstractFile, MarkdownRenderChild, MarkdownPostProcessorContext
|
|||
import { Timesheet } from "@/components/timesheet";
|
||||
import { TimesheetLoadError } from "@/components/timesheetLoadError";
|
||||
import { CustomOutputFormat } from "@/output";
|
||||
import { TimekeepAutocomplete } from "@/service/autocomplete";
|
||||
import { TimekeepSettings } from "@/settings";
|
||||
import { Store, createStore } from "@/store";
|
||||
import { LoadResult, replaceTimekeepCodeblock } from "@/timekeep/parser";
|
||||
|
|
@ -23,6 +24,8 @@ export class TimekeepMarkdownView extends MarkdownRenderChild {
|
|||
loadResult: LoadResult;
|
||||
/** The rendered timesheet component */
|
||||
timesheet: Timesheet | TimesheetLoadError | undefined;
|
||||
/** Autocomplete */
|
||||
autocomplete: TimekeepAutocomplete;
|
||||
|
||||
// Path to the file the timekeep is within
|
||||
fileSourcePath: string;
|
||||
|
|
@ -32,6 +35,7 @@ export class TimekeepMarkdownView extends MarkdownRenderChild {
|
|||
app: ObsidianApp,
|
||||
settingsStore: Store<TimekeepSettings>,
|
||||
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
|
||||
autocomplete: TimekeepAutocomplete,
|
||||
context: MarkdownPostProcessorContext,
|
||||
loadResult: LoadResult
|
||||
) {
|
||||
|
|
@ -39,6 +43,7 @@ export class TimekeepMarkdownView extends MarkdownRenderChild {
|
|||
this.app = app;
|
||||
this.settingsStore = settingsStore;
|
||||
this.customOutputFormats = customOutputFormats;
|
||||
this.autocomplete = autocomplete;
|
||||
this.context = context;
|
||||
this.loadResult = loadResult;
|
||||
|
||||
|
|
@ -84,12 +89,12 @@ export class TimekeepMarkdownView extends MarkdownRenderChild {
|
|||
|
||||
const timesheet = new Timesheet(
|
||||
this.containerEl,
|
||||
|
||||
this.app,
|
||||
timekeepStore,
|
||||
saveErrorStore,
|
||||
this.settingsStore,
|
||||
this.customOutputFormats,
|
||||
this.autocomplete,
|
||||
handleSaveTimekeep
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { App, MarkdownView } from "obsidian";
|
||||
import { App, Component, MarkdownView } from "obsidian";
|
||||
|
||||
import { TimesheetStatusBarItem } from "@/components/timesheetStatusBarItem";
|
||||
import { TimekeepRegistry, TimekeepRegistryEntry } from "@/service/registry";
|
||||
import { TimekeepSettings } from "@/settings";
|
||||
import { Store } from "@/store";
|
||||
import { getRunningEntry } from "@/timekeep";
|
||||
import { TimekeepWithPosition } from "@/timekeep/parser";
|
||||
import { TimeEntry } from "@/timekeep/schema";
|
||||
|
||||
export class TimekeepStatusBarView {
|
||||
export class TimekeepStatusBarView extends Component {
|
||||
/** Parent container element */
|
||||
#containerEl: HTMLElement;
|
||||
|
||||
|
|
@ -16,34 +14,36 @@ export class TimekeepStatusBarView {
|
|||
app: App;
|
||||
/** Access to the app registry */
|
||||
registry: TimekeepRegistry;
|
||||
/** Access to the timekeep settings */
|
||||
settings: Store<TimekeepSettings>;
|
||||
|
||||
/** Currently rendered items */
|
||||
items: TimesheetStatusBarItem[] = [];
|
||||
|
||||
constructor(
|
||||
containerEl: HTMLElement,
|
||||
app: App,
|
||||
registry: TimekeepRegistry,
|
||||
settings: Store<TimekeepSettings>
|
||||
) {
|
||||
constructor(containerEl: HTMLElement, app: App, registry: TimekeepRegistry) {
|
||||
super();
|
||||
|
||||
this.#containerEl = containerEl;
|
||||
|
||||
this.app = app;
|
||||
this.registry = registry;
|
||||
this.settings = settings;
|
||||
|
||||
// Render the initial state
|
||||
this.render(this.registry.entries.getState());
|
||||
|
||||
// Subscribe to changes to re-render
|
||||
this.registry.entries.subscribe(() => {
|
||||
this.render(this.registry.entries.getState());
|
||||
});
|
||||
}
|
||||
|
||||
render(entries: TimekeepRegistryEntry[]) {
|
||||
onload(): void {
|
||||
super.onload();
|
||||
|
||||
const render = this.render.bind(this);
|
||||
const unsubscribe = this.registry.entries.subscribe(render);
|
||||
this.register(unsubscribe);
|
||||
render();
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
super.onunload();
|
||||
this.#containerEl?.remove();
|
||||
}
|
||||
|
||||
render() {
|
||||
const entries = this.registry.entries.getState();
|
||||
|
||||
// Unload the current children
|
||||
for (const item of this.items) {
|
||||
item.unload();
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue