From 0d0f04aba2194bdd8c58d545908ea259a86d7936 Mon Sep 17 00:00:00 2001 From: Dusk Date: Sat, 25 Apr 2026 21:21:01 +0800 Subject: [PATCH] fix(settings): fix settings-page sticky overlap and add opaque page-header mask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed settings-page sticky overlap issues. - Added an opaque mask to the page header. - Card headers now dock correctly below the page header. 修复设置页吸顶重叠问题并添加不透明页头遮罩 - 修复了设置页面吸顶元素重叠的问题。 - 为页头添加了不透明遮罩。 - 卡片头部现在可以正确停靠在页头下方。 --- .../settings/PluginSettingTab.test.ts | 144 ++++++++++++++++-- src/presentation/settings/PluginSettingTab.ts | 66 +++++++- 2 files changed, 197 insertions(+), 13 deletions(-) diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index ad71e73..b11e626 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -9,14 +9,37 @@ const { FakeElement, FakePluginSettingTab, FakeSetting, + FakeResizeObserver, getLanguageMock, + getResizeObserverInstances, + resetResizeObserverInstances, } = vi.hoisted(() => { const hoistedGetLanguage = vi.fn(() => "zh"); + const hoistedResizeObserverInstances: HoistedFakeResizeObserver[] = []; + + class HoistedFakeStyle { + [key: string]: unknown; + + setProperty(name: string, value: string): void { + this[name] = value; + } + + getPropertyValue(name: string): string { + const value = this[name]; + return typeof value === "string" ? value : ""; + } + + removeProperty(name: string): string { + const value = this.getPropertyValue(name); + delete this[name]; + return value; + } + } class HoistedFakeElement { public readonly children: HoistedFakeElement[] = []; public readonly dataset: Record = {}; - public readonly style: Record = {}; + public readonly style = new HoistedFakeStyle(); public readonly ownedSettings: HoistedFakeSetting[] = []; public checked = false; public indeterminate = false; @@ -28,6 +51,7 @@ const { public scrollTop = 0; private readonly listeners = new Map void | Promise>>(); + private rectHeight = 0; constructor( public readonly root: HoistedFakeContainerEl, @@ -74,6 +98,26 @@ const { return this; } + setBoundingClientRect(rect: { height?: number }): void { + if (typeof rect.height === "number") { + this.rectHeight = rect.height; + } + } + + getBoundingClientRect(): DOMRect { + return { + bottom: this.rectHeight, + height: this.rectHeight, + left: 0, + right: 0, + toJSON: () => ({}), + top: 0, + width: 0, + x: 0, + y: 0, + } as DOMRect; + } + private removeOwnedSettingsRecursively(): void { if (this.ownedSettings.length > 0) { this.root.settings = this.root.settings.filter((setting) => !this.ownedSettings.includes(setting)); @@ -256,13 +300,44 @@ const { } } + class HoistedFakeResizeObserver { + public readonly observedElements: HoistedFakeElement[] = []; + public disconnected = false; + + constructor( + private readonly callback: ( + entries: Array<{ target: HoistedFakeElement }>, + observer: HoistedFakeResizeObserver, + ) => void, + ) { + hoistedResizeObserverInstances.push(this); + } + + observe(target: HoistedFakeElement): void { + this.observedElements.push(target); + } + + disconnect(): void { + this.disconnected = true; + } + + trigger(): void { + this.callback(this.observedElements.map((target) => ({ target })), this); + } + } + return { FakeButtonComponent: HoistedFakeButtonComponent, FakeDropdownComponent: HoistedFakeDropdownComponent, FakeElement: HoistedFakeElement, FakePluginSettingTab: HoistedFakePluginSettingTab, + FakeResizeObserver: HoistedFakeResizeObserver, FakeSetting: HoistedFakeSetting, getLanguageMock: hoistedGetLanguage, + getResizeObserverInstances: () => hoistedResizeObserverInstances, + resetResizeObserverInstances: () => { + hoistedResizeObserverInstances.length = 0; + }, }; }); @@ -478,6 +553,17 @@ function collectOptionValues(selectEl: FakeElementInstance): string[] { .map((element) => element.value); } +function getStyleProperty(element: { style: { getPropertyValue?: (name: string) => string } }, property: string): string { + const style = element.style as { getPropertyValue?: (name: string) => string; [key: string]: unknown }; + + if (typeof style.getPropertyValue === "function") { + return style.getPropertyValue(property); + } + + const value = style[property]; + return typeof value === "string" ? value : ""; +} + function getEmptyCallCount(container: QueryRoot): number { return asFakeContainer(container).emptyCallCount; } @@ -488,13 +574,23 @@ async function flushPromises(): Promise { } describe("PluginSettingTab", () => { + const originalResizeObserver = globalThis.ResizeObserver; + beforeEach(() => { getLanguageMock.mockReturnValue("zh"); vi.useRealTimers(); + resetResizeObserverInstances(); + Reflect.set(globalThis, "ResizeObserver", FakeResizeObserver); }); afterEach(() => { vi.useRealTimers(); + if (typeof originalResizeObserver === "undefined") { + Reflect.deleteProperty(globalThis, "ResizeObserver"); + return; + } + + Reflect.set(globalThis, "ResizeObserver", originalResizeObserver); }); it("renders five cards with the required default expansion state", () => { @@ -513,30 +609,40 @@ describe("PluginSettingTab", () => { expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none"); }); - it("applies sticky styles to the page header and keeps section headers static", () => { + it("applies sticky styles to the page header, mask, and card headers", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); const pageHeader = queryByDataset(tab.containerEl, "settingsPageHeader", "true"); + const pageHeaderMask = queryByDataset(pageHeader, "settingsPageHeaderMask", "true"); expect(pageHeader.style.position).toBe("sticky"); - expect(pageHeader.style.top).toBe("0"); - expect(pageHeader.style.zIndex).toBe("100"); + expect(pageHeader.style.top).toBe("0px"); + expect(pageHeader.style.zIndex).toBe("300"); expect(pageHeader.style.width).toBe("100%"); expect(pageHeader.style.boxSizing).toBe("border-box"); - expect(pageHeader.style.background).toBe("var(--background-primary)"); + expect(pageHeader.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))"); expect(pageHeader.style.boxShadow).toBe("none"); expect(collectTexts(pageHeader)).toContain("Anki Heading Sync"); + expect(pageHeaderMask.style.position).toBe("absolute"); + expect(pageHeaderMask.style.top).toBe("-128px"); + expect(pageHeaderMask.style.left).toBe("-24px"); + expect(pageHeaderMask.style.right).toBe("-24px"); + expect(pageHeaderMask.style.bottom).toBe("0px"); + expect(pageHeaderMask.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))"); + + expect(getStyleProperty(tab.containerEl, "--ahs-settings-page-header-height")).toBe("64px"); + for (const cardId of ["card-types", "sync-content", "scope", "deck", "commands"] as const) { const header = queryByDataset(tab.containerEl, "settingsCardToggle", cardId); - expect(header.style.position).toBeUndefined(); - expect(header.style.top).toBeUndefined(); - expect(header.style.zIndex).toBeUndefined(); + expect(header.style.position).toBe("sticky"); + expect(header.style.top).toBe("var(--ahs-settings-page-header-height, 64px)"); + expect(header.style.zIndex).toBe("200"); expect(header.style.display).toBe("flex"); expect(header.style.width).toBe("100%"); - expect(header.style.background).toBe("var(--background-primary)"); + expect(header.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))"); expect(header.style.fontSize).toBe("1.5em"); expect(header.style.fontWeight).toBe("600"); expect(header.style.border).toBe("2px solid var(--background-modifier-border)"); @@ -544,6 +650,26 @@ describe("PluginSettingTab", () => { } }); + it("updates the page header height variable from ResizeObserver and disconnects it on hide", () => { + const plugin = new FakePlugin(); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + + tab.display(); + + const pageHeader = queryByDataset(tab.containerEl, "settingsPageHeader", "true"); + pageHeader.setBoundingClientRect({ height: 72 }); + + const resizeObserver = getResizeObserverInstances().at(0); + expect(resizeObserver).toBeDefined(); + resizeObserver?.trigger(); + + expect(getStyleProperty(tab.containerEl, "--ahs-settings-page-header-height")).toBe("72px"); + + tab.hide(); + + expect(resizeObserver?.disconnected).toBe(true); + }); + it("renders card 1 as three readable card type blocks", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index ceff86b..e3fa749 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -36,6 +36,11 @@ const FOLDER_TREE_STATUS_LOADING: UserFacingMessage = { key: "settings.scope.loa const TEXT_SAVE_DEBOUNCE_MS = 500; const SETTINGS_CARD_ORDER = ["card-types", "sync-content", "scope", "deck", "commands"] as const; const VISIBLE_CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze"] as const; +const SETTINGS_PAGE_HEADER_BACKGROUND = "var(--modal-background, var(--background-primary))"; +const SETTINGS_PAGE_HEADER_HEIGHT_VARIABLE = "--ahs-settings-page-header-height"; +const SETTINGS_PAGE_HEADER_HEIGHT_FALLBACK = "64px"; +const SETTINGS_PAGE_HEADER_MASK_TOP = "-128px"; +const SETTINGS_PAGE_HEADER_MASK_SIDE = "-24px"; type SettingsCardId = (typeof SETTINGS_CARD_ORDER)[number]; type NoteTypeCacheCheckStatus = "idle" | "checking" | "same" | "changed" | "failed"; @@ -69,6 +74,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { private noteTypeCacheCheckPromise: Promise | null = null; private detectedAnkiNoteTypeCache: string[] | null = null; private noteTypeCacheCheckToken = 0; + private pageHeaderResizeObserver: ResizeObserver | null = null; constructor(plugin: AnkiHeadingSyncPlugin) { super(plugin.app, plugin); @@ -78,6 +84,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { declare plugin: AnkiHeadingSyncPlugin; hide(): void { + this.disconnectPageHeaderResizeObserver(); super.hide(); this.displayInitialized = false; this.cardShells.clear(); @@ -92,6 +99,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { display(): void { const { containerEl } = this; const previousScrollTop = containerEl.scrollTop; + containerEl.style.setProperty(SETTINGS_PAGE_HEADER_HEIGHT_VARIABLE, SETTINGS_PAGE_HEADER_HEIGHT_FALLBACK); if (!this.displayInitialized) { containerEl.empty(); @@ -99,17 +107,35 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { const pageHeaderEl = containerEl.createDiv(); pageHeaderEl.dataset.settingsPageHeader = "true"; pageHeaderEl.style.position = "sticky"; - pageHeaderEl.style.top = "0"; - pageHeaderEl.style.zIndex = "100"; + pageHeaderEl.style.top = "0px"; + pageHeaderEl.style.zIndex = "300"; pageHeaderEl.style.width = "100%"; pageHeaderEl.style.boxSizing = "border-box"; pageHeaderEl.style.padding = "12px 0"; pageHeaderEl.style.marginBottom = "8px"; - pageHeaderEl.style.background = "var(--background-primary)"; + pageHeaderEl.style.overflow = "visible"; + pageHeaderEl.style.background = SETTINGS_PAGE_HEADER_BACKGROUND; + pageHeaderEl.style.backgroundColor = SETTINGS_PAGE_HEADER_BACKGROUND; pageHeaderEl.style.boxShadow = "none"; + const pageHeaderMaskEl = pageHeaderEl.createDiv(); + pageHeaderMaskEl.dataset.settingsPageHeaderMask = "true"; + pageHeaderMaskEl.style.position = "absolute"; + pageHeaderMaskEl.style.top = SETTINGS_PAGE_HEADER_MASK_TOP; + pageHeaderMaskEl.style.left = SETTINGS_PAGE_HEADER_MASK_SIDE; + pageHeaderMaskEl.style.right = SETTINGS_PAGE_HEADER_MASK_SIDE; + pageHeaderMaskEl.style.bottom = "0px"; + pageHeaderMaskEl.style.zIndex = "0"; + pageHeaderMaskEl.style.pointerEvents = "none"; + pageHeaderMaskEl.style.background = SETTINGS_PAGE_HEADER_BACKGROUND; + pageHeaderMaskEl.style.backgroundColor = SETTINGS_PAGE_HEADER_BACKGROUND; + const titleEl = pageHeaderEl.createEl("h2", { text: t("settings.pluginTitle") }); titleEl.style.margin = "0"; + titleEl.style.position = "relative"; + titleEl.style.zIndex = "1"; + + this.observePageHeaderHeight(pageHeaderEl); const cardsContainerEl = containerEl.createDiv(); cardsContainerEl.dataset.settingsCardsContainer = "true"; @@ -137,13 +163,17 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { headerEl.style.display = "flex"; headerEl.style.alignItems = "center"; headerEl.style.justifyContent = "flex-start"; + headerEl.style.position = "sticky"; + headerEl.style.top = `var(${SETTINGS_PAGE_HEADER_HEIGHT_VARIABLE}, ${SETTINGS_PAGE_HEADER_HEIGHT_FALLBACK})`; + headerEl.style.zIndex = "200"; headerEl.style.width = "100%"; headerEl.style.maxWidth = "100%"; headerEl.style.boxSizing = "border-box"; headerEl.style.padding = "10px 14px"; headerEl.style.fontSize = "1.5em"; headerEl.style.fontWeight = "600"; - headerEl.style.background = "var(--background-primary)"; + headerEl.style.background = SETTINGS_PAGE_HEADER_BACKGROUND; + headerEl.style.backgroundColor = SETTINGS_PAGE_HEADER_BACKGROUND; headerEl.style.border = "2px solid var(--background-modifier-border)"; headerEl.style.borderRadius = "0"; headerEl.style.marginBottom = "8px"; @@ -213,6 +243,34 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { this.renderCommandsCard(shell.bodyEl); } + private observePageHeaderHeight(pageHeaderEl: HTMLElement): void { + this.disconnectPageHeaderResizeObserver(); + this.updatePageHeaderHeight(pageHeaderEl); + + if (typeof ResizeObserver === "undefined") { + return; + } + + this.pageHeaderResizeObserver = new ResizeObserver(() => { + this.updatePageHeaderHeight(pageHeaderEl); + }); + this.pageHeaderResizeObserver.observe(pageHeaderEl); + } + + private updatePageHeaderHeight(pageHeaderEl: HTMLElement): void { + const measuredHeight = pageHeaderEl.getBoundingClientRect().height; + if (!Number.isFinite(measuredHeight) || measuredHeight <= 0) { + return; + } + + this.containerEl.style.setProperty(SETTINGS_PAGE_HEADER_HEIGHT_VARIABLE, `${Math.round(measuredHeight)}px`); + } + + private disconnectPageHeaderResizeObserver(): void { + this.pageHeaderResizeObserver?.disconnect(); + this.pageHeaderResizeObserver = null; + } + private renderCardTypesCard(containerEl: HTMLElement): void { this.hydrateVisibleCardTypeCaches(); this.maybeStartNoteTypeCacheCheck();