mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
fix(settings): fix settings-page sticky overlap and add opaque page-header mask
- Fixed settings-page sticky overlap issues. - Added an opaque mask to the page header. - Card headers now dock correctly below the page header. 修复设置页吸顶重叠问题并添加不透明页头遮罩 - 修复了设置页面吸顶元素重叠的问题。 - 为页头添加了不透明遮罩。 - 卡片头部现在可以正确停靠在页头下方。
This commit is contained in:
parent
33a4d29c4d
commit
0d0f04aba2
2 changed files with 197 additions and 13 deletions
|
|
@ -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<string, string> = {};
|
||||
public readonly style: Record<string, string> = {};
|
||||
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<string, Array<() => void | Promise<void>>>();
|
||||
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<void> {
|
|||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<void> | 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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue