diff --git a/docs/settings-card-sticky-header-decisions.md b/docs/settings-card-sticky-header-decisions.md new file mode 100644 index 0000000..a1b0305 --- /dev/null +++ b/docs/settings-card-sticky-header-decisions.md @@ -0,0 +1,121 @@ +# 设置页卡片标题 Sticky 实现决策 + +本文锁定本轮“设置页卡片标题 sticky 悬浮”的最终实现策略。 + +## 1. 样式落点决策 + +- sticky 样式直接写在 [PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts) 的 `initializeCards()` 中。 +- 不新建 `styles.css`。 +- 不引入 settings card CSS class 体系。 + +原因: + +- 当前仓库没有现成的 settings stylesheet。 +- 现有 settings 页布局样式大量使用 inline style。 +- 本轮改动只影响 card header,一次性在 `headerEl` 上落样式最小、最稳定。 + +## 2. sticky 行为决策 + +- 所有五个 settings card header 都使用同一套 sticky 样式。 +- sticky 目标元素保持为原有 `button` header。 +- DOM 结构继续保持: + +```text +cardEl + headerEl(button) + bodyEl(div) +``` + +- 不把 header 移到单独容器。 +- 不做全局固定标题栏。 + +原因: + +- 浏览器可利用当前 cardEl 边界自然完成“当前卡片吸附,下一卡片接替”。 +- 这能最大程度保持当前展开/折叠和局部刷新行为不变。 + +## 3. 最终样式决策 + +- 必选样式: + - `position: sticky` + - `top: 8px` + - `zIndex: 20` + - `background: var(--background-primary)` +- 额外样式采用轻量版本: + - `marginBottom: 8px` + - `boxShadow: 0 2px 8px rgba(0, 0, 0, 0.08)` + +- 本轮不额外加: + - `border` + - `borderRadius` + - `width: fit-content` + +原因: + +- 目标是提供 sticky 吸附感,而不是把 header 重新视觉包装成独立卡片。 +- 当前按钮基础外观应继续尽量交给 Obsidian 主题处理。 +- 只补最小 sticky 必需样式和轻微阴影,视觉风险最低。 + +## 4. 兼容性决策 + +- 不新增任何 `overflow` 到 `cardEl` 或 `bodyEl`。 +- 不调整现有 card shell 边界。 +- 如果后续在真实 Obsidian 主题中出现 sticky 不生效,优先排查外层宿主容器,而不是引入 JS fallback。 + +原因: + +- 当前仓库内未发现 card shell 祖先上的 overflow 阻断。 +- 本轮没有证据表明需要 CSS 之外的方案。 + +## 5. 性能决策 + +- 严格使用 CSS sticky。 +- 不实现: + - `window.addEventListener("scroll", ...)` + - `containerEl.addEventListener("scroll", ...)` + - `IntersectionObserver` + - `requestAnimationFrame` + +原因: + +- 当前 DOM 结构已足够支持 sticky。 +- JS 滚动同步会扩大变更面,并增加设置页滚动负担。 +- 本轮目标明确要求无滚动监听。 + +## 6. 行为保留决策 + +- 保持以下逻辑不变: + - `expandedCardIds` + - `toggleCard()` + - `renderCard()` + - `bodyEl.style.display = expanded ? "block" : "none"` + - `bodyEl.empty()` + - 所有局部刷新调用点 + +原因: + +- 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 diff --git a/docs/settings-card-sticky-header-gap-report.md b/docs/settings-card-sticky-header-gap-report.md new file mode 100644 index 0000000..975e1f4 --- /dev/null +++ b/docs/settings-card-sticky-header-gap-report.md @@ -0,0 +1,122 @@ +# 设置页卡片标题 Sticky 差距审计 + +本文基于当前仓库真实实现,审计“设置页卡片标题 sticky 悬浮”的接入点与差距。 + +## 1. 当前设置页已经有稳定的卡片壳层结构 + +- 入口在 [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 结构就是目标方案需要的: + +```text +cardEl + headerEl(button) + bodyEl(div) +``` + +这说明 sticky 可以直接挂在现有 `headerEl` 上,不需要重组 DOM。 + +## 2. 当前卡片标题确实是按钮,并负责展开/折叠 + +- `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()` + +因此 sticky 实现必须保持 header 仍然是同一个按钮元素,不能换成纯文本标题或外部固定容器。 + +## 3. 当前局部刷新机制已经存在,sticky 不应触碰 + +- `display()` 首次渲染后,不会反复重建整个设置页。 +- 后续更新都走 `renderCard(cardId)`。 +- 当前局部刷新触点包括: + - `toggleCard()` + - `loadAnkiCardTypeConfig()` + - `saveCardTypeConfig()` + - `saveFieldMapping()` + - `refreshFolderTree()` + - `updateScopeMode()` + - `updateFolderSelection()` +- 现有测试已经覆盖: + - 展开/折叠不重建整页 + - 手动读取 Anki 配置只刷新 card 1 + - scope tree 交互不重建整页 + +这说明本轮最安全的实现是只给 header 增加样式,不改 `toggleCard()`、`renderCard()`、`bodyEl.empty()`、`expandedCardIds`。 + +## 4. 当前仓库没有设置页专用样式表,样式约定偏向 inline + +- 仓库根目录当前没有 `styles.css`。 +- 当前 [PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts) 已经大量用 inline style 设置布局: + - flex / grid + - width / gap / padding + - border / borderRadius + - overflow / ellipsis +- 没有现成的 settings card class + stylesheet 体系可复用。 + +因此本轮若新增 sticky 样式,直接写在 `initializeCards()` 的 `headerEl.style` 上是与仓库现实最一致的方案。 + +## 5. 当前未发现会直接破坏 sticky 的卡片祖先 overflow + +- `cardEl` 当前没有设置 `overflow`。 +- `bodyEl` 当前没有设置 `overflow`。 +- card shell 附近没有发现: + - `overflow: hidden` + - `overflow: auto` + - `overflow: scroll` +- 当前在卡片内部出现的 `overflow` 主要是局部控件布局: + - 某些 grid 容器显式为 `visible` + - 某些字段选择器为 ellipsis 设置 `hidden` + +这些控件级 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 样式断言,不需要引入新的测试基础设施。 + +## 8. 结论 + +当前仓库与目标方案高度兼容,差距很窄: + +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 diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index c99ab6c..9805b18 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -512,6 +512,22 @@ describe("PluginSettingTab", () => { expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none"); }); + it("applies sticky styles to all settings card headers", () => { + 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 header = queryByDataset(tab.containerEl, "settingsCardToggle", cardId); + expect(header.style.position).toBe("sticky"); + expect(header.style.top).toBe("8px"); + expect(header.style.zIndex).toBe("20"); + expect(header.style.background).toBe("var(--background-primary)"); + expect(header.style.boxShadow).toBe("0 2px 8px rgba(0, 0, 0, 0.08)"); + } + }); + it("renders card 1 as three readable card type blocks", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); @@ -635,9 +651,11 @@ describe("PluginSettingTab", () => { const initialEmptyCount = getEmptyCallCount(tab.containerEl); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); + expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("block"); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click"); + expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none"); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); }); diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index b9181e7..fec4c19 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -117,6 +117,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { const headerEl = cardEl.createEl("button") as HTMLButtonElement; headerEl.type = "button"; headerEl.dataset.settingsCardToggle = cardId; + headerEl.style.position = "sticky"; + headerEl.style.top = "8px"; + headerEl.style.zIndex = "20"; + headerEl.style.background = "var(--background-primary)"; + headerEl.style.marginBottom = "8px"; + headerEl.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.08)"; headerEl.addEventListener("click", () => { this.toggleCard(cardId); });