feat: use Obsidian theme colors for settings page action buttons and toggles

- Settings page action buttons now use Obsidian theme accent colors.
- Settings toggles now have clearer themed states.
- Styling is scoped to the plugin settings page.

功能:设置页面按钮和切换开关现在使用 Obsidian 主题色

- 设置页面的操作按钮现在使用 Obsidian 主题强调色。
- 设置切换开关现在具有更清晰的主题化状态。
- 样式已限定在插件设置页面范围内。
This commit is contained in:
Dusk 2026-04-26 17:55:23 +08:00
parent 8357992ee3
commit 79ddbef838
4 changed files with 179 additions and 24 deletions

30
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,17 +1,32 @@
import { copyFile, mkdir, writeFile } from "node:fs/promises";
import { access, copyFile, mkdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(scriptDir, "..");
const outputDir = join(rootDir, "dist", "plugin");
const stylesSourcePath = join(rootDir, "styles.css");
await mkdir(outputDir, { recursive: true });
await Promise.all([
let hasStylesheet = false;
try {
await access(stylesSourcePath);
hasStylesheet = true;
} catch {
hasStylesheet = false;
}
const stageTasks = [
copyFile(join(rootDir, "manifest.json"), join(outputDir, "manifest.json")),
copyFile(join(rootDir, "versions.json"), join(outputDir, "versions.json")),
]);
];
if (hasStylesheet) {
stageTasks.push(copyFile(stylesSourcePath, join(outputDir, "styles.css")));
}
await Promise.all(stageTasks);
await writeFile(
join(outputDir, "README.md"),
@ -25,6 +40,7 @@ await writeFile(
"- manifest.json",
"- main.js",
"- versions.json",
...(hasStylesheet ? ["- styles.css"] : []),
].join("\n"),
"utf8",
);

View file

@ -36,8 +36,35 @@ const {
}
}
class HoistedFakeClassList {
private readonly values = new Set<string>();
add(...tokens: string[]): void {
for (const token of tokens) {
if (token) {
this.values.add(token);
}
}
}
remove(...tokens: string[]): void {
for (const token of tokens) {
this.values.delete(token);
}
}
contains(token: string): boolean {
return this.values.has(token);
}
toString(): string {
return [...this.values].join(" ");
}
}
class HoistedFakeElement {
public readonly children: HoistedFakeElement[] = [];
public readonly classList = new HoistedFakeClassList();
public readonly dataset: Record<string, string> = {};
public readonly style = new HoistedFakeStyle();
public readonly ownedSettings: HoistedFakeSetting[] = [];
@ -148,10 +175,13 @@ const {
class HoistedFakeButtonComponent {
public text = "";
public readonly buttonEl = new HoistedFakeElement(new HoistedFakeContainerEl(), "button", null);
private onClickHandler?: () => void | Promise<void>;
setButtonText(text: string): this {
this.text = text;
this.buttonEl.text = text;
this.buttonEl.textContent = text;
return this;
}
@ -218,6 +248,7 @@ const {
class HoistedFakeToggleComponent {
public value = false;
public readonly toggleEl = new HoistedFakeElement(new HoistedFakeContainerEl(), "div", null);
private onChangeHandler?: (value: boolean) => void | Promise<void>;
setValue(value: boolean): this {
@ -351,7 +382,8 @@ import { AnkiHeadingSyncSettingTab } from "./PluginSettingTab";
type FakeContainer = InstanceType<typeof FakePluginSettingTab>["containerEl"];
type FakeSettingInstance = InstanceType<typeof FakeSetting>;
type FakeToggleInstance = { triggerChange(value: boolean): Promise<void> };
type FakeButtonInstance = { click(): Promise<void>; buttonEl: FakeElementInstance };
type FakeToggleInstance = { value: boolean; triggerChange(value: boolean): Promise<void>; toggleEl: FakeElementInstance };
type FakeElementInstance = InstanceType<typeof FakeElement>;
type QueryRoot = FakeContainer | FakeElementInstance | HTMLElement;
@ -540,6 +572,15 @@ function getToggle(setting: FakeSettingInstance): FakeToggleInstance {
return toggle as FakeToggleInstance;
}
function getButton(setting: FakeSettingInstance): FakeButtonInstance {
const button = setting.controls.find((control) => typeof control === "object" && control !== null && "click" in control && "buttonEl" in control);
if (!button) {
throw new Error(`Button not found for setting: ${setting.name}`);
}
return button as FakeButtonInstance;
}
function collectTexts(container: QueryRoot): string[] {
return findElements(container, () => true)
.map((element) => element.textContent || element.text)
@ -620,6 +661,7 @@ describe("PluginSettingTab", () => {
tab.display();
expect(tab.containerEl.classList.contains("anki-heading-sync-settings")).toBe(true);
expect(queryByDataset(tab.containerEl, "settingsPageHeader", "true")).toBeDefined();
const cards = queryAllByDataset(tab.containerEl, "settingsCard");
expect(cards).toHaveLength(5);
@ -795,6 +837,69 @@ describe("PluginSettingTab", () => {
expect(plugin.updateCalls).toContainEqual({ convertHighlightsToCloze: false });
});
it("applies the themed class only to settings action buttons", async () => {
const plugin = new FakePlugin();
plugin.settings = normalizePluginSettings({
...plugin.settings,
scopeMode: "include",
});
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await expandCard(tab, "card-types");
await expandCard(tab, "scope");
await flushPromises();
expect(queryByDataset(tab.containerEl, "cardTypesRefresh", "true").classList.contains("ahs-theme-action-button")).toBe(true);
expect(queryByDataset(tab.containerEl, "scopeRefreshFolders", "true").classList.contains("ahs-theme-action-button")).toBe(true);
const cardHeaderButton = queryByDataset(tab.containerEl, "settingsCardToggle", "card-types");
expect(cardHeaderButton.classList.contains("ahs-theme-action-button")).toBe(false);
const folderToggleButton = queryByDataset(tab.containerEl, "folderToggle", "notes");
expect(folderToggleButton.classList.contains("ahs-theme-action-button")).toBe(false);
await expandCard(tab, "deck");
const fileDeckSetting = findSetting(tab.containerEl, "开启文件级自定义牌组");
await getToggle(fileDeckSetting).triggerChange(true);
const insertTemplateSetting = findSetting(tab.containerEl, "向当前文件插入牌组模板");
const insertTemplateButton = getButton(insertTemplateSetting);
expect(insertTemplateButton.buttonEl.classList.contains("ahs-theme-action-button")).toBe(true);
await insertTemplateButton.click();
expect(plugin.insertDeckTemplateCalls).toBe(1);
});
it("marks every settings toggle with theme state and updates the dataset on change", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await expandCard(tab, "sync-content");
await expandCard(tab, "deck");
const toggleSettings = [
findSetting(tab.containerEl, "添加 Obsidian 回链"),
findSetting(tab.containerEl, "同步 Obsidian 标签到 Anki"),
findSetting(tab.containerEl, "在卡片正文中保留纯标签行"),
findSetting(tab.containerEl, "高亮转填空题"),
findSetting(tab.containerEl, "开启文件级自定义牌组"),
];
for (const setting of toggleSettings) {
const toggle = getToggle(setting);
expect(toggle.toggleEl.classList.contains("ahs-theme-toggle")).toBe(true);
expect(toggle.toggleEl.dataset.ahsToggleState).toBe(toggle.value ? "on" : "off");
}
const highlightsToggle = getToggle(findSetting(tab.containerEl, "高亮转填空题"));
const nextValue = !highlightsToggle.value;
await highlightsToggle.triggerChange(nextValue);
expect(highlightsToggle.toggleEl.dataset.ahsToggleState).toBe(nextValue ? "on" : "off");
});
it("auto saves toggle, heading, note type and field mapping edits", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);

View file

@ -1,4 +1,5 @@
import { PluginSettingTab, Setting } from "obsidian";
import type { ButtonComponent, ToggleComponent } from "obsidian";
import {
DEFAULT_OBSIDIAN_BACKLINK_LABEL,
@ -49,6 +50,9 @@ const SETTINGS_PAGE_HEADER_PADDING_TOP = "12px";
const SETTINGS_PAGE_HEADER_MASK_TOP = "-128px";
const SETTINGS_PAGE_HEADER_MASK_SIDE = "-24px";
const DECK_HELPER_TEXT_INDENT = "32px";
const SETTINGS_ROOT_CLASS = "anki-heading-sync-settings";
const THEME_ACTION_BUTTON_CLASS = "ahs-theme-action-button";
const THEME_TOGGLE_CLASS = "ahs-theme-toggle";
type SettingsCardId = (typeof SETTINGS_CARD_ORDER)[number];
type NoteTypeCacheCheckStatus = "idle" | "checking" | "same" | "changed" | "failed";
@ -110,6 +114,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const { containerEl } = this;
const previousScrollTop = containerEl.scrollTop;
containerEl.classList.add(SETTINGS_ROOT_CLASS);
if (!this.displayInitialized) {
containerEl.style.setProperty(SETTINGS_PAGE_HEADER_HEIGHT_VARIABLE, SETTINGS_PAGE_HEADER_HEIGHT_FALLBACK);
containerEl.style.setProperty(SETTINGS_STICKY_CARD_GAP_VARIABLE, SETTINGS_STICKY_CARD_GAP);
@ -162,6 +168,31 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
containerEl.scrollTop = previousScrollTop;
}
private markThemeButton(buttonEl: HTMLButtonElement): void {
buttonEl.classList.add(THEME_ACTION_BUTTON_CLASS);
}
private markThemeButtonComponent(button: ButtonComponent): void {
this.markThemeButton(button.buttonEl);
}
private markThemeToggle(
toggle: ToggleComponent,
value: boolean,
onChange: (value: boolean) => void | Promise<void>,
): void {
toggle.toggleEl.classList.add(THEME_TOGGLE_CLASS);
this.syncThemeToggleState(toggle, value);
toggle.setValue(value).onChange((nextValue) => {
this.syncThemeToggleState(toggle, nextValue);
return onChange(nextValue);
});
}
private syncThemeToggleState(toggle: ToggleComponent, value: boolean): void {
toggle.toggleEl.dataset.ahsToggleState = value ? "on" : "off";
}
private initializeCards(containerEl: HTMLElement): void {
this.cardShells.clear();
@ -297,6 +328,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
? t("settings.cards.cardTypes.loadAnki.loading")
: t("settings.cards.cardTypes.loadAnki.button"),
}) as HTMLButtonElement;
this.markThemeButton(loadButton);
loadButton.type = "button";
loadButton.dataset.cardTypesRefresh = "true";
loadButton.disabled = this.ankiConfigLoading;
@ -573,7 +605,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
.setName(t("settings.syncOptions.addObsidianBacklink.name"))
.setDesc(t("settings.syncOptions.addObsidianBacklink.desc"))
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.addObsidianBacklink).onChange((value) => {
this.markThemeToggle(toggle, this.plugin.settings.addObsidianBacklink, (value) => {
void this.plugin.updateSettings({ addObsidianBacklink: value });
});
});
@ -613,7 +645,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
.setName(t("settings.syncOptions.syncObsidianTagsToAnki.name"))
.setDesc(t("settings.syncOptions.syncObsidianTagsToAnki.desc"))
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.syncObsidianTagsToAnki).onChange((value) => {
this.markThemeToggle(toggle, this.plugin.settings.syncObsidianTagsToAnki, (value) => {
void this.plugin.updateSettings({ syncObsidianTagsToAnki: value });
});
});
@ -622,7 +654,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
.setName(t("settings.syncOptions.keepPureTagLinesInCardBody.name"))
.setDesc(t("settings.syncOptions.keepPureTagLinesInCardBody.desc"))
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.keepPureTagLinesInCardBody).onChange((value) => {
this.markThemeToggle(toggle, this.plugin.settings.keepPureTagLinesInCardBody, (value) => {
void this.plugin.updateSettings({ keepPureTagLinesInCardBody: value });
});
});
@ -632,7 +664,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
.setName(t("settings.syncOptions.highlightsToCloze.name"))
.setDesc(t("settings.syncOptions.highlightsToCloze.desc"))
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.convertHighlightsToCloze).onChange((value) => {
this.markThemeToggle(toggle, this.plugin.settings.convertHighlightsToCloze, (value) => {
void this.plugin.updateSettings({ convertHighlightsToCloze: value });
});
});
@ -678,6 +710,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
const refreshRow = containerEl.createDiv();
const refreshButton = refreshRow.createEl("button", { text: t("settings.cards.scope.refreshFolders") }) as HTMLButtonElement;
this.markThemeButton(refreshButton);
refreshButton.type = "button";
refreshButton.dataset.scopeRefreshFolders = "true";
refreshButton.disabled = Boolean(this.folderTreeLoadPromise);
@ -731,7 +764,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
.setName(t("settings.deck.fileDeckEnabled.name"))
.setDesc(t("settings.deck.fileDeckEnabled.desc"))
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.fileDeckEnabled).onChange(async (value) => {
this.markThemeToggle(toggle, this.plugin.settings.fileDeckEnabled, async (value) => {
await this.plugin.updateSettings({ fileDeckEnabled: value });
this.renderCard("deck");
});
@ -794,6 +827,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
.setName(t("settings.deck.insertTemplate.name"))
.setDesc(t("settings.deck.insertTemplate.desc"))
.addButton((button) => {
this.markThemeButtonComponent(button);
button.setButtonText(t("settings.deck.insertTemplate.button")).onClick(() => {
void this.plugin.insertDeckTemplateToCurrentFile();
});