mirror of
https://github.com/jacobtread/obsidian-timekeep.git
synced 2026-07-22 10:10:27 +00:00
feat: add reactive settings store for realtime updates
Settings changes can now be observed by the app without needing to re-open/reload the app
This commit is contained in:
parent
8d2a493478
commit
3cfa89bb4b
5 changed files with 182 additions and 64 deletions
13
src/App.tsx
13
src/App.tsx
|
|
@ -1,6 +1,5 @@
|
|||
import { Timekeep } from "@/schema";
|
||||
import { isKeepRunning } from "@/timekeep";
|
||||
import { TimekeepSettings } from "@/settings";
|
||||
import { App as ObsidianApp } from "obsidian";
|
||||
import TimesheetStart from "@/components/TimesheetStart";
|
||||
import TimesheetTable from "@/components/TimesheetTable";
|
||||
|
|
@ -11,15 +10,15 @@ import React, { useState, useCallback, SetStateAction } from "react";
|
|||
import TimesheetExportActions from "@/components/TimesheetExportActions";
|
||||
|
||||
import { AppContext } from "./contexts/use-app-context";
|
||||
import { SettingsStore, useSettingsStore } from "./store/settings-store";
|
||||
|
||||
export type AppProps = {
|
||||
app: ObsidianApp;
|
||||
// Initial state loaded from the document
|
||||
initialState: Timekeep;
|
||||
// The timekeep settings
|
||||
settings: TimekeepSettings;
|
||||
settingsStore: SettingsStore;
|
||||
// Function to save the timekeep data
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
save: (timekeep: Timekeep) => Promise<boolean>;
|
||||
};
|
||||
|
||||
|
|
@ -30,9 +29,15 @@ export type AppProps = {
|
|||
* Wraps the state updates for `setTimekeep` with logic that
|
||||
* saves the changes to the vault
|
||||
*/
|
||||
export default function App({ app, initialState, save, settings }: AppProps) {
|
||||
export default function App({
|
||||
app,
|
||||
initialState,
|
||||
save,
|
||||
settingsStore,
|
||||
}: AppProps) {
|
||||
const [timekeep, setTimekeep] = useState(initialState);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
const settings = useSettingsStore(settingsStore);
|
||||
|
||||
const trySave = (timekeep: Timekeep) => {
|
||||
save(timekeep).then((isSaved) => {
|
||||
|
|
|
|||
30
src/main.ts
30
src/main.ts
|
|
@ -15,9 +15,10 @@ import {
|
|||
} from "obsidian";
|
||||
|
||||
import { Timekeep } from "./schema";
|
||||
import { SettingsStore, createSettingsStore } from "./store/settings-store";
|
||||
|
||||
export default class TimekeepPlugin extends Plugin {
|
||||
settings: TimekeepSettings;
|
||||
settingsStore: SettingsStore;
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
|
|
@ -37,7 +38,7 @@ export default class TimekeepPlugin extends Plugin {
|
|||
new TimekeepComponent(
|
||||
el,
|
||||
this.app,
|
||||
this.settings,
|
||||
this.settingsStore,
|
||||
context,
|
||||
loadResult
|
||||
)
|
||||
|
|
@ -55,23 +56,34 @@ export default class TimekeepPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
this.settings = Object.assign(
|
||||
const settings = Object.assign(
|
||||
{},
|
||||
defaultSettings,
|
||||
await this.loadData()
|
||||
);
|
||||
|
||||
this.settingsStore = createSettingsStore(settings);
|
||||
}
|
||||
|
||||
async updateSettings(
|
||||
update: (currentValue: TimekeepSettings) => TimekeepSettings
|
||||
): Promise<void> {
|
||||
const newValue = update(this.settingsStore.getSettings());
|
||||
|
||||
this.settingsStore.setSettings(newValue);
|
||||
await this.saveData(newValue);
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
await this.saveData(this.settingsStore.getSettings());
|
||||
}
|
||||
}
|
||||
|
||||
class TimekeepComponent extends MarkdownRenderChild {
|
||||
// Obsidian app instance
|
||||
app: ObsidianApp;
|
||||
// Timekeep settings
|
||||
settings: TimekeepSettings;
|
||||
// Timekeep settings store
|
||||
settingsStore: SettingsStore;
|
||||
// Markdown context for the current markdown block
|
||||
context: MarkdownPostProcessorContext;
|
||||
// Timekeep load result
|
||||
|
|
@ -84,13 +96,13 @@ class TimekeepComponent extends MarkdownRenderChild {
|
|||
constructor(
|
||||
containerEl: HTMLElement,
|
||||
app: ObsidianApp,
|
||||
settings: TimekeepSettings,
|
||||
settingsStore: SettingsStore,
|
||||
context: MarkdownPostProcessorContext,
|
||||
loadResult: LoadResult
|
||||
) {
|
||||
super(containerEl);
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
this.settingsStore = settingsStore;
|
||||
this.context = context;
|
||||
this.loadResult = loadResult;
|
||||
this.root = createRoot(containerEl);
|
||||
|
|
@ -126,7 +138,7 @@ class TimekeepComponent extends MarkdownRenderChild {
|
|||
React.createElement(App, {
|
||||
app: this.app,
|
||||
initialState: timekeep,
|
||||
settings: this.settings,
|
||||
settingsStore: this.settingsStore,
|
||||
save: this.trySave.bind(this),
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
this.containerEl.empty();
|
||||
this.containerEl.createEl("h2", { text: "Settings" });
|
||||
|
||||
const settings = this.plugin.settingsStore.getSettings();
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName("Timestamp display format")
|
||||
.setDesc(
|
||||
|
|
@ -29,12 +31,17 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
})
|
||||
)
|
||||
.addText((t) => {
|
||||
t.setValue(String(this.plugin.settings.timestampFormat));
|
||||
t.setValue(String(settings.timestampFormat));
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.timestampFormat = v.length
|
||||
// Only use a custom format if the value is not blank
|
||||
const newFormat = v.length
|
||||
? v
|
||||
: defaultSettings.timestampFormat;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
await this.plugin.updateSettings((currentValue) => ({
|
||||
...currentValue,
|
||||
timestampFormat: newFormat,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -43,12 +50,15 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
.setDesc("The title to include on generated PDFs")
|
||||
|
||||
.addText((t) => {
|
||||
t.setValue(String(this.plugin.settings.pdfTitle));
|
||||
t.setValue(String(settings.pdfTitle));
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.pdfTitle = v.length
|
||||
? v
|
||||
: defaultSettings.pdfTitle;
|
||||
await this.plugin.saveSettings();
|
||||
// Only use a custom format if the value is not blank
|
||||
const newPdfTitle = v.length ? v : defaultSettings.pdfTitle;
|
||||
|
||||
await this.plugin.updateSettings((currentValue) => ({
|
||||
...currentValue,
|
||||
pdfTitle: newPdfTitle,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -57,12 +67,17 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
.setDesc("The footnote to include PDFs")
|
||||
|
||||
.addTextArea((t) => {
|
||||
t.setValue(String(this.plugin.settings.pdfFootnote));
|
||||
t.setValue(String(settings.pdfFootnote));
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.pdfFootnote = v.length
|
||||
// Only use a custom format if the value is not blank
|
||||
const newPdfFootnote = v.length
|
||||
? v
|
||||
: defaultSettings.pdfFootnote;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
await this.plugin.updateSettings((currentValue) => ({
|
||||
...currentValue,
|
||||
pdfFootnote: newPdfFootnote,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -78,11 +93,12 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
[PdfExportBehavior.OPEN_PATH]:
|
||||
"Open directory containing the exported file",
|
||||
});
|
||||
t.setValue(String(this.plugin.settings.pdfExportBehavior));
|
||||
t.setValue(String(settings.pdfExportBehavior));
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.pdfExportBehavior =
|
||||
v as PdfExportBehavior;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.updateSettings((currentValue) => ({
|
||||
...currentValue,
|
||||
pdfExportBehavior: v as PdfExportBehavior,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -101,12 +117,17 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
})
|
||||
)
|
||||
.addText((t) => {
|
||||
t.setValue(String(this.plugin.settings.pdfDateFormat));
|
||||
t.setValue(String(settings.pdfDateFormat));
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.pdfDateFormat = v.length
|
||||
// Only use a custom format if the value is not blank
|
||||
const newPdfDateFormat = v.length
|
||||
? v
|
||||
: defaultSettings.pdfDateFormat;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
await this.plugin.updateSettings((currentValue) => ({
|
||||
...currentValue,
|
||||
pdfDateFormat: newPdfDateFormat,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -125,12 +146,17 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
})
|
||||
)
|
||||
.addText((t) => {
|
||||
t.setValue(String(this.plugin.settings.pdfRowDateFormat));
|
||||
t.setValue(String(settings.pdfRowDateFormat));
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.pdfRowDateFormat = v.length
|
||||
// Only use a custom format if the value is not blank
|
||||
const newPdfRowDateFormat = v.length
|
||||
? v
|
||||
: defaultSettings.pdfRowDateFormat;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
await this.plugin.updateSettings((currentValue) => ({
|
||||
...currentValue,
|
||||
pdfRowDateFormat: newPdfRowDateFormat,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -140,10 +166,12 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
"Whether to use the first row of generated CSV as a title row"
|
||||
)
|
||||
.addToggle((t) => {
|
||||
t.setValue(this.plugin.settings.csvTitle);
|
||||
t.setValue(settings.csvTitle);
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.csvTitle = v;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.updateSettings((currentValue) => ({
|
||||
...currentValue,
|
||||
csvTitle: v,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -153,37 +181,31 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
"The delimiter character that should be used when copying a tracker table as CSV. For example, some languages use a semicolon instead of a comma."
|
||||
)
|
||||
.addText((t) => {
|
||||
t.setValue(String(this.plugin.settings.csvDelimiter));
|
||||
t.setValue(String(settings.csvDelimiter));
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.csvDelimiter = v.length
|
||||
const newCsvDelimiter = v.length
|
||||
? v
|
||||
: defaultSettings.csvDelimiter;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
await this.plugin.updateSettings((currentValue) => ({
|
||||
...currentValue,
|
||||
csvDelimiter: newCsvDelimiter,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName("Timestamp durations")
|
||||
.setDesc(
|
||||
"Whether durations should be displayed in a timestamp format (12:15:01) rather than the default duration format (12h 15m 1s)."
|
||||
)
|
||||
.addToggle((t) => {
|
||||
t.setValue(this.plugin.settings.timestampDurations);
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.timestampDurations = v;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
new Setting(this.containerEl)
|
||||
.setName("Show decimal hours")
|
||||
.setDesc(
|
||||
"Whether to show the shortened hour only durations under the current and total timers (12h 8m 39s would be shown as 12.14h)"
|
||||
)
|
||||
.addToggle((t) => {
|
||||
t.setValue(this.plugin.settings.showDecimalHours);
|
||||
t.setValue(settings.showDecimalHours);
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.showDecimalHours = v;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.updateSettings((currentValue) => ({
|
||||
...currentValue,
|
||||
showDecimalHours: v,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -193,10 +215,12 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
"Whether older tracker segments should be displayed towards the bottom of the tracker, rather than the top."
|
||||
)
|
||||
.addToggle((t) => {
|
||||
t.setValue(this.plugin.settings.reverseSegmentOrder);
|
||||
t.setValue(settings.reverseSegmentOrder);
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.reverseSegmentOrder = v;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.updateSettings((currentValue) => ({
|
||||
...currentValue,
|
||||
reverseSegmentOrder: v,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -206,10 +230,12 @@ export class TimekeepSettingsTab extends PluginSettingTab {
|
|||
"Whether to limit the height of the table, will clamp the height and make the table scrollable"
|
||||
)
|
||||
.addToggle((t) => {
|
||||
t.setValue(this.plugin.settings.limitTableSize);
|
||||
t.setValue(settings.limitTableSize);
|
||||
t.onChange(async (v) => {
|
||||
this.plugin.settings.limitTableSize = v;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.updateSettings((currentValue) => ({
|
||||
...currentValue,
|
||||
limitTableSize: v,
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/* eslint-disable no-unused-vars */
|
||||
export enum PdfExportBehavior {
|
||||
// Don't do anything after exporting
|
||||
NONE = "NONE",
|
||||
|
|
@ -19,7 +18,6 @@ export interface TimekeepSettings {
|
|||
pdfDateFormat: string;
|
||||
pdfRowDateFormat: string;
|
||||
reverseSegmentOrder: boolean;
|
||||
timestampDurations: boolean;
|
||||
timestampFormat: string;
|
||||
showDecimalHours: boolean;
|
||||
}
|
||||
|
|
@ -36,7 +34,6 @@ export const defaultSettings: TimekeepSettings = {
|
|||
csvTitle: true,
|
||||
csvDelimiter: ",",
|
||||
reverseSegmentOrder: false,
|
||||
timestampDurations: false,
|
||||
limitTableSize: true,
|
||||
showDecimalHours: true,
|
||||
};
|
||||
|
|
|
|||
78
src/store/settings-store.ts
Normal file
78
src/store/settings-store.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { TimekeepSettings } from "@/settings";
|
||||
|
||||
/**
|
||||
* Simple reactive store for storing the current settings
|
||||
* and allowing the app to subscribe to changes
|
||||
*/
|
||||
export type SettingsStore = {
|
||||
// Getter for the current value
|
||||
getSettings: () => TimekeepSettings;
|
||||
|
||||
// Sets the current settings value and updates all subscribers
|
||||
setSettings: (value: TimekeepSettings) => void;
|
||||
|
||||
// Allows subscribing to changes using a callback
|
||||
subscribe: (callback: VoidFunction) => void;
|
||||
// Allows unsubscribing from changes for the provided callback
|
||||
unsubscribe: (callback: VoidFunction) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new settings store with the provided initial value
|
||||
*
|
||||
* @param initialValue The initial value
|
||||
* @returns The created store
|
||||
*/
|
||||
export function createSettingsStore(
|
||||
initialValue: TimekeepSettings
|
||||
): SettingsStore {
|
||||
const eventTarget = new EventTarget();
|
||||
const object = { settings: initialValue };
|
||||
|
||||
const getSettings = () => object.settings;
|
||||
const setSettings = (value: TimekeepSettings) => {
|
||||
object.settings = value;
|
||||
eventTarget.dispatchEvent(new Event("stateChange"));
|
||||
};
|
||||
|
||||
const subscribe = (callback: VoidFunction) =>
|
||||
eventTarget.addEventListener("stateChange", callback);
|
||||
|
||||
const unsubscribe = (callback: VoidFunction) =>
|
||||
eventTarget.removeEventListener("stateChange", callback);
|
||||
|
||||
return {
|
||||
getSettings,
|
||||
setSettings,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook to subscribe to a settings store. Will
|
||||
* trigger state updates and re-render when the settings
|
||||
* change
|
||||
*
|
||||
* @param store The settings store
|
||||
* @returns The settings store value
|
||||
*/
|
||||
export function useSettingsStore(store: SettingsStore) {
|
||||
const [state, setState] = useState(store.getSettings());
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => {
|
||||
const value = store.getSettings();
|
||||
setState(value);
|
||||
};
|
||||
|
||||
store.subscribe(handleUpdate);
|
||||
|
||||
return () => {
|
||||
store.unsubscribe(handleUpdate);
|
||||
};
|
||||
}, [setState, store]);
|
||||
|
||||
return state;
|
||||
}
|
||||
Loading…
Reference in a new issue