From 33a4d29c4d22b2a1ade77cb5af878c0936246d6b Mon Sep 17 00:00:00 2001 From: Dusk Date: Sat, 25 Apr 2026 18:33:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(settings):=20=E5=9B=BA=E5=AE=9A=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E7=BA=A7=E6=A0=87=E9=A2=98=E6=A0=8F=20/=20pin=20page-?= =?UTF-8?q?level=20settings=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文: 将设置页顶部标题改为页面级 sticky 白色标题栏,并取消每个卡片标题的 sticky,修复滚动遮挡、正文重叠和边框异常。 English: Convert the settings page title into a page-level sticky white header and remove sticky behavior from section headers to fix scroll overlap, body occlusion, and border artifacts. --- docs/settings-card-sticky-header-decisions.md | 148 ++++++---------- .../settings-card-sticky-header-gap-report.md | 165 ++++++++---------- .../settings/PluginSettingTab.test.ts | 29 +-- src/presentation/settings/PluginSettingTab.ts | 29 +-- 4 files changed, 164 insertions(+), 207 deletions(-) diff --git a/docs/settings-card-sticky-header-decisions.md b/docs/settings-card-sticky-header-decisions.md index a1b0305..41a905b 100644 --- a/docs/settings-card-sticky-header-decisions.md +++ b/docs/settings-card-sticky-header-decisions.md @@ -1,121 +1,89 @@ -# 设置页卡片标题 Sticky 实现决策 +# 设置页页面级 Sticky 标题栏实现决策 -本文锁定本轮“设置页卡片标题 sticky 悬浮”的最终实现策略。 +本文锁定本轮设置页 sticky 修复的最终决策:固定的是页面级白色标题栏,而不是每个设置卡片标题。 -## 1. 样式落点决策 +## 1. sticky 目标决策 -- sticky 样式直接写在 [PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts) 的 `initializeCards()` 中。 -- 不新建 `styles.css`。 -- 不引入 settings card CSS class 体系。 +- sticky 目标改为页面级 header 容器。 +- 不再让 `settingsCardToggle` sticky。 -原因: - -- 当前仓库没有现成的 settings stylesheet。 -- 现有 settings 页布局样式大量使用 inline style。 -- 本轮改动只影响 card header,一次性在 `headerEl` 上落样式最小、最稳定。 - -## 2. sticky 行为决策 - -- 所有五个 settings card header 都使用同一套 sticky 样式。 -- sticky 目标元素保持为原有 `button` header。 -- DOM 结构继续保持: +最终结构: ```text -cardEl - headerEl(button) - bodyEl(div) +containerEl + pageHeaderEl(sticky) + h2 + cardsContainerEl + cardEl + headerEl(button) + bodyEl(div) ``` -- 不把 header 移到单独容器。 -- 不做全局固定标题栏。 - -原因: - -- 浏览器可利用当前 cardEl 边界自然完成“当前卡片吸附,下一卡片接替”。 -- 这能最大程度保持当前展开/折叠和局部刷新行为不变。 - -## 3. 最终样式决策 +## 2. 页面级标题栏样式决策 +- `pageHeaderEl` 使用 inline style。 - 必选样式: - `position: sticky` - - `top: 8px` - - `zIndex: 20` + - `top: 0` + - `zIndex: 100` + - `width: 100%` + - `boxSizing: border-box` + - `padding: 12px 0` - `background: var(--background-primary)` -- 额外样式采用轻量版本: - - `marginBottom: 8px` - - `boxShadow: 0 2px 8px rgba(0, 0, 0, 0.08)` - -- 本轮不额外加: - - `border` - - `borderRadius` - - `width: fit-content` + - `boxShadow: none` +- 额外使用 `marginBottom: 8px` 与下方内容拉开少量距离。 原因: -- 目标是提供 sticky 吸附感,而不是把 header 重新视觉包装成独立卡片。 -- 当前按钮基础外观应继续尽量交给 Obsidian 主题处理。 -- 只补最小 sticky 必需样式和轻微阴影,视觉风险最低。 +- 用户要的是页面顶部白色固定标题区。 +- sticky header 仍在文档流内,配合少量底部间距即可避免内容贴得太紧。 -## 4. 兼容性决策 +## 3. 卡片标题样式决策 -- 不新增任何 `overflow` 到 `cardEl` 或 `bodyEl`。 -- 不调整现有 card shell 边界。 -- 如果后续在真实 Obsidian 主题中出现 sticky 不生效,优先排查外层宿主容器,而不是引入 JS fallback。 +- `settingsCardToggle` 保留普通标题条样式: + - `fontSize: 1.5em` + - `fontWeight: 600` + - `border: 2px solid var(--background-modifier-border)` + - `background: var(--background-primary)` + - `boxShadow: none` +- 取消以下样式: + - `position: sticky` + - `top` + - `zIndex` 原因: -- 当前仓库内未发现 card shell 祖先上的 overflow 阻断。 -- 本轮没有证据表明需要 CSS 之外的方案。 +- 卡片标题只应承担“标题 + 点击折叠展开”职责。 +- 继续让它们 sticky 会重复制造遮挡和叠层冲突。 + +## 4. DOM 初始化决策 + +- `display()` 首次初始化时先创建 `settingsPageHeader`。 +- 再创建 `settingsCardsContainer`,并把五张卡片初始化到该容器里。 +- 不对现有 `renderCard()`、`toggleCard()`、`bodyEl.empty()` 做行为改动。 ## 5. 性能决策 - 严格使用 CSS sticky。 -- 不实现: - - `window.addEventListener("scroll", ...)` - - `containerEl.addEventListener("scroll", ...)` +- 不引入: + - `scroll` listener - `IntersectionObserver` - `requestAnimationFrame` -原因: +## 6. 测试决策 -- 当前 DOM 结构已足够支持 sticky。 -- JS 滚动同步会扩大变更面,并增加设置页滚动负担。 -- 本轮目标明确要求无滚动监听。 +- `PluginSettingTab.test.ts` 需要断言: + - `settingsPageHeader` 存在 + - `settingsPageHeader` 是 sticky + - 每个 `settingsCardToggle` 不再 sticky + - 每个 `settingsCardToggle` 继续保留标题按钮视觉样式 +- 原有展开/折叠、局部刷新和 card 1 刷新测试继续保留。 -## 6. 行为保留决策 +## 7. 与旧方案的受控偏离 -- 保持以下逻辑不变: - - `expandedCardIds` - - `toggleCard()` - - `renderCard()` - - `bodyEl.style.display = expanded ? "block" : "none"` - - `bodyEl.empty()` - - 所有局部刷新调用点 +- 偏离 1:取消“每个卡片标题 sticky”。 + - 这是本轮修复的核心。 + - 原方案已经被截图与真实滚动行为证明方向错误。 -原因: - -- sticky 只是展示增强,不应改变现有设置页状态管理或刷新模型。 - -## 7. 测试决策 - -- 在 [PluginSettingTab.test.ts](src/presentation/settings/PluginSettingTab.test.ts) 新增一条 DOM 断言: - - 五个 `settingsCardToggle` 都存在 sticky 样式 - - 至少断言: - - `position = sticky` - - `top = 8px` - - `zIndex = 20` - - `background = var(--background-primary)` -- 原有以下测试继续保留并通过: - - 展开/折叠不重建整页 - - 读取 Anki 配置只刷新 card 1 - - folder tree 交互不重建整页 - -## 8. 与计划的受控偏离 - -- 偏离 1:不新增 `styles.css` 或 class-based 样式。 - - 原计划允许 class 或 inline 二选一。 - - 仓库现实更偏向 inline style,因此本轮使用 inline。 - -- 偏离 2:不额外加 `border` / `borderRadius` / `fit-content`。 - - 原计划将这些作为可选视觉项。 - - 为避免 header 视觉权重过重,本轮只保留 sticky 必需样式和轻微阴影。 \ No newline at end of file +- 偏离 2:不使用每卡片 sticky wrapper、mask、负 margin。 + - 原因是用户真实目标是页面级顶栏遮挡,而不是 section 级吸附。 \ No newline at end of file diff --git a/docs/settings-card-sticky-header-gap-report.md b/docs/settings-card-sticky-header-gap-report.md index 975e1f4..afca970 100644 --- a/docs/settings-card-sticky-header-gap-report.md +++ b/docs/settings-card-sticky-header-gap-report.md @@ -1,16 +1,32 @@ -# 设置页卡片标题 Sticky 差距审计 +# 设置页 Sticky 标题栏差距审计 -本文基于当前仓库真实实现,审计“设置页卡片标题 sticky 悬浮”的接入点与差距。 +本文基于当前仓库真实实现,审计“设置页顶部白色标题栏固定显示”的正确接入点,以及之前方案为什么会导致遮挡异常。 -## 1. 当前设置页已经有稳定的卡片壳层结构 +## 1. 当前真实问题不在卡片内部,而在页面顶部缺少 sticky 标题栏 -- 入口在 [src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts)。 -- `display()` 首次进入时会调用 `initializeCards(containerEl)`。 -- 当前五张设置卡片都通过同一套 card shell 创建: - - `cardEl.dataset.settingsCard = cardId` - - `headerEl = cardEl.createEl("button")` - - `bodyEl = cardEl.createDiv()` -- 当前 DOM 结构就是目标方案需要的: +- 入口仍在 [src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts)。 +- `display()` 首次进入时,原先只创建了一个普通 `h2`: + - `containerEl.createEl("h2", { text: t("settings.pluginTitle") })` +- 这个 `h2` 不 sticky,也不是单独的遮挡层。 + +结果是:设置页真正缺少的是页面级顶部白色标题区域,而不是卡片级 sticky header。 + +## 2. 之前错误方案把 sticky 放到了每个设置卡片标题上 + +- `initializeCards()` 里,原先对每个 `settingsCardToggle` 设置了: + - `position: sticky` + - `top: 0` + - 高 `z-index` +- 每个卡片标题仍然是交互按钮,同时承担 sticky、边框、背景、遮挡等职责。 + +这会导致两个问题: + +1. 多个 section header 在同一滚动上下文里争抢顶部位置。 +2. 卡片标题压住正文,出现边框、留白和叠层异常。 + +## 3. 当前卡片壳层逻辑本来就不需要卡片标题 sticky + +- 当前五张卡片仍然是统一 card shell: ```text cardEl @@ -18,105 +34,66 @@ cardEl bodyEl(div) ``` -这说明 sticky 可以直接挂在现有 `headerEl` 上,不需要重组 DOM。 +- `toggleCard()` 负责折叠/展开。 +- `renderCard()` 负责: + - 更新 header 文本 + - 更新 `aria-expanded` + - 切换 `bodyEl.style.display` + - 清空并重绘 body +- 局部刷新仍然基于 `renderCard(cardId)`,而不是整页重建。 -## 2. 当前卡片标题确实是按钮,并负责展开/折叠 +这说明卡片标题只需要保持“普通标题按钮”职责,不应该再兼任 sticky 遮挡层。 -- `initializeCards()` 中的 header 是原生 `button`。 -- `headerEl.dataset.settingsCardToggle = cardId` 已经被测试使用。 -- `headerEl.addEventListener("click", () => this.toggleCard(cardId))` 负责折叠/展开。 -- `renderCard()` 中继续刷新: - - `headerEl.textContent = ...` - - `headerEl.setAttr("aria-expanded", ...)` - - `bodyEl.style.display = expanded ? "block" : "none"` - - `bodyEl.empty()` +## 4. 正确 sticky 层级应该是页面级 header,而不是 section header -因此 sticky 实现必须保持 header 仍然是同一个按钮元素,不能换成纯文本标题或外部固定容器。 +- 用户真实想要的效果是: + - 最顶部的 “Anki Heading Sync” 始终固定显示 + - 它作为白色背景遮挡层,阻止下面内容跑到页面最上方 + - 卡片标题本身正常滚动,不再吸附 +- 因此正确结构应是: -## 3. 当前局部刷新机制已经存在,sticky 不应触碰 +```text +containerEl + pageHeaderEl(sticky) + h2 + cardsContainerEl + cardEl + headerEl(button, static) + bodyEl +``` -- `display()` 首次渲染后,不会反复重建整个设置页。 -- 后续更新都走 `renderCard(cardId)`。 -- 当前局部刷新触点包括: - - `toggleCard()` - - `loadAnkiCardTypeConfig()` - - `saveCardTypeConfig()` - - `saveFieldMapping()` - - `refreshFolderTree()` - - `updateScopeMode()` - - `updateFolderSelection()` -- 现有测试已经覆盖: - - 展开/折叠不重建整页 - - 手动读取 Anki 配置只刷新 card 1 - - scope tree 交互不重建整页 +## 5. 当前仓库仍然适合用 inline style,而不是新增样式表 -这说明本轮最安全的实现是只给 header 增加样式,不改 `toggleCard()`、`renderCard()`、`bodyEl.empty()`、`expandedCardIds`。 +- 仓库当前没有 `styles.css`。 +- [PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts) 现有布局样式仍然主要是 inline style。 -## 4. 当前仓库没有设置页专用样式表,样式约定偏向 inline +因此页面级 sticky header 仍应直接在 `display()` 初始化时落 inline style,与仓库现状一致。 -- 仓库根目录当前没有 `styles.css`。 -- 当前 [PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts) 已经大量用 inline style 设置布局: - - flex / grid - - width / gap / padding - - border / borderRadius - - overflow / ellipsis -- 没有现成的 settings card class + stylesheet 体系可复用。 +## 6. 当前没有证据表明需要 JS 滚动逻辑 -因此本轮若新增 sticky 样式,直接写在 `initializeCards()` 的 `headerEl.style` 上是与仓库现实最一致的方案。 +- 没有现成的 `scroll` listener、`IntersectionObserver` 或 `requestAnimationFrame` 滚动同步逻辑。 +- 页面级 sticky header 已经满足需求,不需要 JS fallback。 -## 5. 当前未发现会直接破坏 sticky 的卡片祖先 overflow +## 7. 之前测试锚点也验证了错误方向 -- `cardEl` 当前没有设置 `overflow`。 -- `bodyEl` 当前没有设置 `overflow`。 -- card shell 附近没有发现: - - `overflow: hidden` - - `overflow: auto` - - `overflow: scroll` -- 当前在卡片内部出现的 `overflow` 主要是局部控件布局: - - 某些 grid 容器显式为 `visible` - - 某些字段选择器为 ellipsis 设置 `hidden` +- [src/presentation/settings/PluginSettingTab.test.ts](src/presentation/settings/PluginSettingTab.test.ts) 之前断言的是: + - 每个 `settingsCardToggle` 都是 sticky +- 这与用户真实目标冲突。 -这些控件级 overflow 不在 sticky header 的祖先链关键位置上,不构成当前实现的直接阻断。 +正确测试应该改成: -## 6. 当前没有 JS 滚动逻辑,也不需要新增 - -- 没有现成的: - - `scroll` listener - - `IntersectionObserver` - - `requestAnimationFrame` 滚动同步 -- 当前 card shell 结构已经满足浏览器原生 sticky 的典型使用条件。 - -因此本轮不存在“沿用旧 JS 方案”的历史包袱,直接使用 CSS sticky 即可。 - -## 7. 当前测试还不知道 sticky 样式 - -- [src/presentation/settings/PluginSettingTab.test.ts](src/presentation/settings/PluginSettingTab.test.ts) 当前已覆盖: - - 五张卡片存在 - - 默认展开状态 - - 展开/折叠不重建整页 - - card 1 局部刷新 - - scope / deck 局部刷新 -- 但还没有断言: - - `settingsCardToggle` 的 sticky 样式 - - `position: sticky` - - `top: 8px` - - `zIndex: 20` - - `background` - -这意味着本轮只需要在现有测试体系中补一条 header 样式断言,不需要引入新的测试基础设施。 +1. 存在页面级 `settingsPageHeader`。 +2. `settingsPageHeader` 是 sticky。 +3. 每个 `settingsCardToggle` 不再 sticky。 +4. 每个 `settingsCardToggle` 仍保留标题按钮视觉样式。 ## 8. 结论 -当前仓库与目标方案高度兼容,差距很窄: +当前仓库的真实差距不是“section header sticky 还不够强”,而是“页面级标题栏本来就没有 sticky”。 -1. 已有合适的 `cardEl > headerEl(button) + bodyEl` 结构。 -2. 已有稳定的展开/折叠与局部刷新机制。 -3. 未发现卡片祖先 overflow 会直接破坏 sticky。 -4. 仓库没有 settings stylesheet,inline style 才是当前实现惯例。 -5. 当前唯一缺口是:header 尚未添加 sticky 样式,测试也尚未覆盖该样式。 +最小、安全、符合真实需求的修复路径是: -因此最小、安全、与仓库一致的实现路径是: - -1. 在 `initializeCards()` 中直接给 `headerEl` 增加 sticky 相关 inline 样式。 -2. 不改 card shell DOM,不改 render/toggle/local-refresh 逻辑。 -3. 在现有 `PluginSettingTab.test.ts` 里补 5 张卡片 header 的 sticky 样式断言。 \ No newline at end of file +1. 在 `display()` 中创建单独的 `settingsPageHeader` 容器并设为 sticky。 +2. 将原来的 `h2` 放入该 header 容器。 +3. 将卡片列表放到单独的 cards container 下方。 +4. 取消每个 `settingsCardToggle` 的 sticky,只保留普通标题条样式。 \ No newline at end of file diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 0cb2df6..ad71e73 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -503,6 +503,7 @@ describe("PluginSettingTab", () => { tab.display(); + expect(queryByDataset(tab.containerEl, "settingsPageHeader", "true")).toBeDefined(); const cards = queryAllByDataset(tab.containerEl, "settingsCard"); expect(cards).toHaveLength(5); expect(queryByDataset(tab.containerEl, "settingsCardBody", "card-types").style.display).toBe("block"); @@ -512,22 +513,27 @@ describe("PluginSettingTab", () => { expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none"); }); - it("applies sticky styles to all settings card headers", () => { + it("applies sticky styles to the page header and keeps section headers static", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); - for (const cardId of ["card-types", "sync-content", "scope", "deck", "commands"] as const) { - const card = queryByDataset(tab.containerEl, "settingsCard", cardId); - const header = queryByDataset(tab.containerEl, "settingsCardToggle", cardId); - const body = queryByDataset(tab.containerEl, "settingsCardBody", cardId); + const pageHeader = queryByDataset(tab.containerEl, "settingsPageHeader", "true"); + expect(pageHeader.style.position).toBe("sticky"); + expect(pageHeader.style.top).toBe("0"); + expect(pageHeader.style.zIndex).toBe("100"); + expect(pageHeader.style.width).toBe("100%"); + expect(pageHeader.style.boxSizing).toBe("border-box"); + expect(pageHeader.style.background).toBe("var(--background-primary)"); + expect(pageHeader.style.boxShadow).toBe("none"); + expect(collectTexts(pageHeader)).toContain("Anki Heading Sync"); - expect(card.style.position).toBe("relative"); - expect(card.style.zIndex).toBe("0"); - expect(header.style.position).toBe("sticky"); - expect(header.style.top).toBe("0"); - expect(header.style.zIndex).toBe("80"); + 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.display).toBe("flex"); expect(header.style.width).toBe("100%"); expect(header.style.background).toBe("var(--background-primary)"); @@ -535,9 +541,6 @@ describe("PluginSettingTab", () => { expect(header.style.fontWeight).toBe("600"); expect(header.style.border).toBe("2px solid var(--background-modifier-border)"); expect(header.style.boxShadow).toBe("none"); - expect(body.style.position).toBe("relative"); - expect(body.style.zIndex).toBe("0"); - expect(body.style.paddingTop).toBe("8px"); } }); diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index e4aaac8..ceff86b 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -95,8 +95,25 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { if (!this.displayInitialized) { containerEl.empty(); - containerEl.createEl("h2", { text: t("settings.pluginTitle") }); - this.initializeCards(containerEl); + + const pageHeaderEl = containerEl.createDiv(); + pageHeaderEl.dataset.settingsPageHeader = "true"; + pageHeaderEl.style.position = "sticky"; + pageHeaderEl.style.top = "0"; + pageHeaderEl.style.zIndex = "100"; + 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.boxShadow = "none"; + + const titleEl = pageHeaderEl.createEl("h2", { text: t("settings.pluginTitle") }); + titleEl.style.margin = "0"; + + const cardsContainerEl = containerEl.createDiv(); + cardsContainerEl.dataset.settingsCardsContainer = "true"; + this.initializeCards(cardsContainerEl); this.displayInitialized = true; } @@ -113,15 +130,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { for (const cardId of SETTINGS_CARD_ORDER) { const cardEl = containerEl.createDiv(); cardEl.dataset.settingsCard = cardId; - cardEl.style.position = "relative"; - cardEl.style.zIndex = "0"; const headerEl = cardEl.createEl("button") as HTMLButtonElement; headerEl.type = "button"; headerEl.dataset.settingsCardToggle = cardId; - headerEl.style.position = "sticky"; - headerEl.style.top = "0"; - headerEl.style.zIndex = "80"; headerEl.style.display = "flex"; headerEl.style.alignItems = "center"; headerEl.style.justifyContent = "flex-start"; @@ -143,9 +155,6 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { const bodyEl = cardEl.createDiv(); bodyEl.dataset.settingsCardBody = cardId; - bodyEl.style.position = "relative"; - bodyEl.style.zIndex = "0"; - bodyEl.style.paddingTop = "8px"; this.cardShells.set(cardId, { cardEl,