fix(ui): complete scheme A preview and navigation

This commit is contained in:
aliyun1121003339 2026-07-19 00:35:57 +08:00
parent 6b18f09d93
commit dce330172d
16 changed files with 263 additions and 89 deletions

View file

@ -8,8 +8,12 @@ The previous preview history was a 12-item process-local session list. The curre
The previous settings page was one sequential renderer with no discovery layer. The current page adds a sticky search/navigation surface and Vault-persisted favorites while retaining existing Obsidian `Setting` controls. Catalog copy is captured at the `Setting.setName/setDesc` declaration boundary rather than scraped from rendered name/description DOM. Favorites resolve IDs from canonical translation paths; localized settings receive canonical English aliases, advanced provider declarations carry structured `advanced` metadata, and dynamic rows receive deterministic fallback IDs.
The first settings navigation implementation mirrored every heading as a horizontal button, producing 28 choices and 10 simultaneously visible controls in a 1500×855 real Obsidian viewport. The current implementation keeps every heading reachable through one localized category selector. Together with search and favorites, the discovery header now exposes three concurrent controls, each at least 44 px high, while filtered-out categories are disabled and hidden inside the selector.
The previous batch title flow validated the selected path only inside `batchGenerateContentForTitles`, which converted a missing folder into a late generic error and error log. The new preparation boundary runs before the batch: missing folders can be created after consent, empty folders continue, non-empty folders require one batch-level confirmation, file collisions are rejected, and non-interactive callers receive a recoverable result. Only missing-folder auto-creation can be remembered.
The preview runtime previously had a split dependency model: the unused standalone runtime imported Mermaid/Vega statically, but the shipped single `main.js` bridge and the SVG/PNG/PDF export path could fall back to loading bare package names at runtime. Installed Vault plugins do not contain repository `node_modules`, so a real Mermaid render failed with `MODULE_NOT_FOUND`; hot reload also retained the stale global bridge. The shared `bundledPreviewDeps.ts` boundary now statically binds Mermaid, Vega-Lite, and Vega into the shipped bundle. Every current iframe preview refreshes the bridge owned by the active plugin bundle, while modal preview and export operations explicitly receive the same bundled dependencies.
## Implemented
- Vault diagram history domain with newest-first query, fuzzy search, filters, 20-item default pages, retention, clone safety, and index-only removal.
@ -20,6 +24,9 @@ The previous batch title flow validated the selected path only inside `batchGene
- Settings search header, heading navigation, responsive layout, and per-setting favorites saved in plugin data.
- Settings discovery now announces localized visible/total result counts, shows a localized empty state, assigns catalog entries to their nearest heading category, and prunes obsolete or duplicate favorite IDs without disturbing valid saved order.
- `SettingsNavigation.ts` now owns the combined fuzzy-query/favorites visibility calculation and visible-category set, so category buttons disappear when their group has no matching settings rather than presenting dead navigation choices.
- The 28-button category rail is replaced by one localized category selector. Search, favorites, and category navigation are all at least 44 px high; all headings remain reachable without presenting ten parallel choices in the default viewport.
- The preview history management entry now uses shared English/Simplified-Chinese copy instead of a hard-coded English label.
- Mermaid and Vega-Lite dependencies are statically bundled behind a shared adapter. Plugin reload replaces stale global render bridges before a new iframe preview is created, and SVG/PNG/PDF export no longer depends on runtime bare-package resolution.
- Batch-folder preparation domain and integration before title-generation batch execution.
- Hosts without an interactive preparation callback now use a read-only Vault inspection fallback. Missing, non-empty, or file-collision targets stop before generation with a recoverable interaction-required status; they do not create folders, emit an error log, or authorize writes.
- New defaults: `favoriteSettingIds`, `diagramHistoryRetentionLimit`, `diagramHistoryEntries`, and `autoCreateMissingBatchTargetFolders`.
@ -27,7 +34,7 @@ The previous batch title flow validated the selected path only inside `batchGene
## Verification State
- Final full verification after declaration-bound catalog capture passed: 220 Jest suites and 1,885 tests, the TypeScript production build, the UI i18n audit, and `git diff --check`.
- Final full verification on 2026-07-18 passed: 221 Jest suites and 1,889 tests, the TypeScript production build, the UI i18n audit, the render-host bundle audit, and `git diff --check`.
- The built plugin was copied into the `Study` Vault and `obsidian vault="Study" plugin:reload id=notemd` returned `Reloaded: notemd`.
- The official `obsidian help` CLI surface executed successfully. A separate `obsidian-cli` executable is not installed, so no success is claimed for that compatibility alias.
- After a full Obsidian restart cleared the Electron module cache, official CLI evaluation opened the real `notemd` settings tab and observed 128 settings/favorite controls. Searching `provider` reduced the live result count to 5. A favorite persisted across plugin reload and was then restored to the original empty state.
@ -36,8 +43,15 @@ The previous batch title flow validated the selected path only inside `batchGene
- The final deployed build reopened the real settings tab after restart with no captured errors. Searching the Chinese-rendered settings using the canonical English phrase `model identifier` returned live results, proving declaration-derived English aliases are active without reading rendered name/description DOM.
- History-manager copy is now localized through the shared English/Simplified-Chinese registry. A Frontend Law Auditor strict run based on CSS and contract evidence scored 100/100 after raising critical favorites, filters, and history actions to 44 px targets and adding explicit focus-visible rings. Windows screenshot capture still failed at the platform interface (`0x80004002`), so the score is a code-level gate, not a substitute for future screenshot comparison.
- Automated coverage confirms the repository/query behavior and batch-folder policies. Final clean-worktree evidence is obtained after commit and push.
- Fresh 2026-07-18 real-machine verification rebuilt and hash-matched the plugin before every affected runtime pass, reloaded Notemd 1.9.3 through the official Obsidian CLI, and attached the developer console before final interactions. Settings showed 128 catalog entries, persisted and restored a favorite across reload, and returned 39 results for `provider` after the progressive category-selector change.
- A temporary history entry persisted across plugin reload, manager search reduced the list to that entry, and cleanup removed only verification records. Missing-folder creation prompted once, the resulting empty folder proceeded without a prompt, and the same folder with one file prompted exactly once before the batch.
- A fresh one-file batch used the provider configured in Obsidian and completed with processed/generated/moved counts of 1/1/1 and zero errors. Its 4,949-character output contained substantive Chinese explanation, equations, and Mermaid content; both temporary source and completion folders were removed.
- The original Mermaid preview failure was reproduced at error-console level as `Cannot find module 'mermaid'`. After the bundled-dependency and stale-bridge fixes, real Mermaid and Vega-Lite iframes each contained one visible SVG, their error nodes were hidden and empty, and the fallback panels stayed closed. A final post-deployment Mermaid pass also exported an 8,707-byte valid SVG through the modal action, proving the export path uses the shipped dependencies. Final visible error modal/notice inspection, `dev:errors`, and error-level `dev:console` were all empty; both temporary history records and the exported verification file were removed.
- The 2026-07-18 Frontend Law Auditor first failed at 77.63/100 using live DOM measurements (30 px targets and 10 concurrent category choices). After the selector and 44 px target correction, the same strict audit scored 100/100 with zero fast-gate or principle failures. Obsidian `dev:screenshot` produced a main-window image but did not include the settings modal, so settings visual evidence remains DOM/runtime based rather than screenshot-comparison evidence.
## Next Direction
1. Consider explicit opt-in CLI authorization flags in a future automation-focused release; the current safe non-interactive behavior intentionally requires interaction for missing or non-empty targets.
2. Preserve the current query, catalog, navigation, and folder-preparation boundaries as new settings and diagram targets are added.
2. Preserve the current query, catalog, navigation, bundled-preview-dependency, and folder-preparation boundaries as new settings and diagram targets are added.
3. Treat the 714 tracked website MDX files as a publication surface, not a blanket synchronization target. Keep canonical authored pages reviewable, and move generated locale mirrors to build artifacts or cache unless a translated page is intentionally maintained and changed.
4. Monitor the self-contained `main.js` size after statically bundling preview runtimes. Correct installed-plugin behavior takes priority; split assets should be considered only with an explicit release-asset and loading contract.

View file

@ -8,8 +8,12 @@
此前设置页是单个顺序渲染器,没有发现层。当前页面在保留 Obsidian 原生 `Setting` 控件的同时,增加吸顶搜索/导览和 Vault 持久化收藏。Catalog 文案在 `Setting.setName/setDesc` 声明边界采集,不再抓取渲染后的名称/描述 DOM。收藏 ID 由规范翻译路径解析;本地化设置自动获得规范英文别名,高级 provider 声明携带结构化 `advanced` 元数据,动态行使用确定性 fallback ID。
第一版设置导览把每个标题都映射成横向按钮,实际产生 28 个选项,在 1500×855 的真实 Obsidian 视口中同时可见 10 个控件。当前实现改为单一本地化分类选择器,所有标题仍可到达。发现头部默认只并列搜索、收藏、分类选择 3 个控件,且高度均不低于 44 px筛选后无匹配内容的分类会在选择器中禁用并隐藏。
此前从标题批量生成只在 `batchGenerateContentForTitles` 内验证路径,缺失文件夹会变成过晚的通用错误和错误日志。新的准备边界在批处理开始前运行:缺失文件夹可经授权创建,空文件夹直接继续,非空文件夹对整批确认一次,文件冲突会被拒绝,无交互调用返回可恢复结果。只有“缺失文件夹自动创建”可以被记住。
此前预览运行时存在两套依赖模型:未实际发布的独立 runtime 静态导入 Mermaid/Vega但真实发布的单文件 `main.js` bridge 与 SVG/PNG/PDF 导出链路可能在运行时加载裸包名。Vault 中安装的插件没有仓库 `node_modules`,因此真实 Mermaid 渲染会报 `MODULE_NOT_FOUND`;热重载还会保留旧的全局 bridge。现在由共享 `bundledPreviewDeps.ts` 边界把 Mermaid、Vega-Lite 与 Vega 静态绑定进发布包。每次创建 iframe 预览都会刷新当前插件 bundle 所拥有的 bridge弹窗即时预览与多格式导出也会显式接收同一套 bundled 依赖。
## 已实现
- Vault 图形历史领域:倒序查询、模糊搜索、筛选、默认每页 20 条、保留上限、克隆安全和只删索引。
@ -20,6 +24,9 @@
- 设置搜索头部、标题导览、响应式布局,以及写入插件数据的单项收藏。
- 设置发现层现在会以本地化方式播报“可见项/总项”数量、显示本地化空状态、把 catalog 项归入最近的标题分类,并清理失效或重复收藏 ID同时保持有效收藏的原有顺序。
- `SettingsNavigation.ts` 现在负责组合模糊查询、收藏筛选、可见项与可见分类集合;分类内没有匹配设置时,对应导览按钮会隐藏,不再形成无效导航选择。
- 28 个分类按钮已改为单一本地化分类选择器。搜索、收藏和分类导览高度均不低于 44 px全部标题仍可到达默认视口不再同时呈现 10 个并列选项。
- 预览中的历史管理入口已改用共享英/简中文案,不再硬编码英文。
- Mermaid 与 Vega-Lite 依赖已通过共享适配器静态打包;插件重载后会在创建新 iframe 预览前替换旧的全局渲染 bridgeSVG/PNG/PDF 导出也不再依赖运行时裸包解析。
- 批处理文件夹准备领域,并接入从标题批量生成执行前。
- 未提供交互式准备回调的 host 现在会使用只读 Vault 检查 fallback。目标缺失、非空或为文件冲突时会在生成前以可恢复的“需要交互”状态停止不会创建目录、写错误日志或默许写入。
- 新增默认设置:`favoriteSettingIds`、`diagramHistoryRetentionLimit`、`diagramHistoryEntries`、`autoCreateMissingBatchTargetFolders`。
@ -27,7 +34,7 @@
## 验证状态
- 设置声明边界 catalog 采集完成后的最终全量验证通过220 个 Jest 套件、1,885 项测试、TypeScript 生产构建、UI 国际化审计和 `git diff --check`
- 2026-07-18 最终全量验证通过221 个 Jest 套件、1,889 项测试、TypeScript 生产构建、UI 国际化审计、render-host bundle 审计和 `git diff --check`
- 已把构建产物复制到 `Study` Vault并执行 `obsidian vault="Study" plugin:reload id=notemd`,返回 `Reloaded: notemd`
- 官方 `obsidian help` CLI 可正常执行;本机未安装独立的 `obsidian-cli` 可执行文件,因此不对该兼容别名宣称验证成功。
- 完整重启 Obsidian、清除 Electron 模块缓存后,官方 CLI evaluation 成功打开真实 `notemd` 设置页,并观察到 128 个设置/收藏控件。搜索 `provider` 后实时结果缩减为 5 项。收藏 ID 经插件 reload 后仍保留,随后已恢复到原先的空状态。
@ -36,8 +43,15 @@
- 最终部署构建在重启后成功重新打开真实设置页,未捕获错误。使用规范英文短语 `model identifier` 搜索以中文渲染的设置时返回了实时结果,证明声明派生的英文别名已生效,且无需读取渲染后的名称/描述 DOM。
- 历史管理器文案现已接入共享的英文/简体中文注册表。基于 CSS 与合同测试证据的 Frontend Law Auditor 严格门禁,在把收藏、筛选和历史操作提升到 44 px 点击区域并增加明确的键盘焦点环后得到 100/100。Windows 截图捕获仍在平台接口处失败(`0x80004002`),因此该分数只是代码级门禁,不能替代后续截图对比。
- 自动化测试已覆盖仓库查询行为与批处理文件夹策略;提交并推送后再记录最终 clean 工作区证据。
- 2026-07-18 新鲜实机验收在每个受影响运行阶段前重新构建、部署并校验哈希,通过官方 Obsidian CLI 重载 Notemd 1.9.3,并在最终交互前附加开发者控制台。设置页识别到 128 个 catalog 项;收藏经重载持久化后已恢复原值;改为渐进式分类选择器后,搜索 `provider` 返回 39 项。
- 临时历史记录经插件重载后仍存在,管理器搜索可把列表收敛到该记录,清理时只移除了验证记录。缺失文件夹只提示一次并创建;随后空文件夹无需提示;加入一个文件后,在批处理开始前只出现一次整批确认。
- 新鲜的单文件批处理使用 Obsidian 中已配置的 provider处理/生成/移动为 1/1/1错误为 0。生成内容共 4,949 字符,包含实质性的中文说明、公式和 Mermaid临时源目录与完成目录均已移除。
- 已在 error 级控制台复现原始 Mermaid 预览错误 `Cannot find module 'mermaid'`。完成依赖打包与 stale bridge 修复后,真实 Mermaid 和 Vega-Lite iframe 均包含 1 个可见 SVG错误节点隐藏且为空fallback 关闭。最终部署后的 Mermaid 验收还通过弹窗操作导出了 8,707 字节的有效 SVG证明导出链路使用了发布包内依赖。最终可见错误弹窗/通知检查、`dev:errors` 与 error 级 `dev:console` 均为空;两条临时历史记录和验证导出文件已清理。
- 2026-07-18 Frontend Law Auditor 首次根据真实 DOM 指标得到 77.63/10030 px 目标、10 个并列分类选项)。改为分类选择器并提升到 44 px 后,同一严格审计为 100/100快速门禁和原则失败均为 0。Obsidian `dev:screenshot` 能输出主窗口图像,但未包含设置 Modal因此设置页视觉证据仍来自 DOM/运行态测量,不宣称完成截图对比。
## 后续方向
1. 可在未来面向自动化的版本中考虑显式 CLI 授权参数;当前安全的非交互行为有意要求缺失或非空目标进入交互确认。
2. 后续新增设置与图形目标时继续保持现有查询、catalog、导航和文件夹准备边界。
2. 后续新增设置与图形目标时继续保持现有查询、catalog、导航、预览依赖打包和文件夹准备边界。
3. 将 714 个已跟踪的网站 MDX 文件视为发布表面,而不是每次都全量同步的目标。规范源文档应保持可审阅;由翻译或构建生成的语言镜像,除非该页面确实由人工维护且内容发生变化,否则应移到构建制品或缓存。
4. 持续监控静态打包预览运行时后的 `main.js` 大小。已安装插件的正确运行优先;只有在明确设计新的发布资产与加载合同时,才考虑拆分运行时文件。

View file

@ -7,7 +7,9 @@ module.exports = {
modulePathIgnorePatterns: ['<rootDir>/.worktrees/', '<rootDir>/.cache/', '<rootDir>/.tmp_repo_saga_tool/', '<rootDir>/ref/'],
moduleNameMapper: {
'^obsidian$': '<rootDir>/src/__mocks__/obsidian.ts',
'^mermaid$': '<rootDir>/src/__mocks__/mermaid.ts'
'^mermaid$': '<rootDir>/src/__mocks__/mermaid.ts',
'^vega-lite$': '<rootDir>/src/__mocks__/vega-lite.ts',
'^vega$': '<rootDir>/src/__mocks__/vega.ts'
},
transform: {
'^.+\\.txt$': '<rootDir>/jest-txt-transform.js',

View file

@ -0,0 +1,3 @@
export function compile(spec: Record<string, unknown>): { spec: Record<string, unknown> } {
return { spec };
}

13
src/__mocks__/vega.ts Normal file
View file

@ -0,0 +1,13 @@
export function parse(spec: Record<string, unknown>): Record<string, unknown> {
return spec;
}
export class View {
constructor(private readonly runtime: unknown) {}
async toSVG(): Promise<string> {
return `<svg data-runtime="${typeof this.runtime}"></svg>`;
}
finalize(): void {}
}

View file

@ -982,6 +982,7 @@ export const STRINGS_EN = {
exportPdfFailedNotice: 'Failed to export diagram PDF: {message}',
sourceFile: 'Saved file: {path}',
historyTitle: 'History',
manageVaultHistory: 'Manage Vault history',
diagnosticsTitle: 'Artifact diagnostics',
diagnosticSummary: {
errors: 'error(s)',
@ -1005,6 +1006,7 @@ export const STRINGS_EN = {
},
settingsDiscovery: {
searchPlaceholder: 'Search settings…', searchLabel: 'Search settings', favorites: '★ Favorites',
categoryNavigationLabel: 'Jump to settings section', allCategories: 'All settings sections',
addFavorite: 'Add setting to favorites', removeFavorite: 'Remove setting from favorites',
resultCount: '{visible} of {total} settings', noResults: 'No settings match the current search and filters.'
},

View file

@ -969,6 +969,7 @@ export const STRINGS_ZH_CN: DeepPartial<NotemdEnglishStrings> = {
copyFailedNotice: '复制图形源码失败,请查看控制台。',
sourceFile: '已保存文件:{path}',
historyTitle: '历史',
manageVaultHistory: '管理 Vault 图形历史',
diagnosticsTitle: 'Artifact 诊断',
diagnosticSummary: {
errors: '错误',
@ -988,6 +989,7 @@ export const STRINGS_ZH_CN: DeepPartial<NotemdEnglishStrings> = {
},
settingsDiscovery: {
searchPlaceholder: '搜索设置…', searchLabel: '搜索设置', favorites: '★ 收藏',
categoryNavigationLabel: '跳转到设置分类', allCategories: '全部设置分类',
addFavorite: '收藏此设置', removeFavorite: '取消收藏此设置',
resultCount: '显示 {visible} / {total} 项设置', noResults: '没有设置符合当前搜索和筛选条件。'
},

View file

@ -1,59 +1,10 @@
import mermaid from 'mermaid';
import * as vegaLiteModule from 'vega-lite';
import * as vegaModule from 'vega';
import { DiagramIntent } from '../../diagram/types';
import { normalizeMermaidDefinition } from '../preview/mermaidDefinitionShared';
import {
MermaidPreviewDeps,
renderNormalizedMermaidDefinitionSvgWithDeps
} from '../preview/mermaidPreviewShared';
import { MermaidPreviewDeps, renderNormalizedMermaidDefinitionSvgWithDeps } from '../preview/mermaidPreviewShared';
import { RenderWebviewTheme } from '../theme';
import { VegaLitePreviewDeps, renderVegaLiteSpecSvgWithDeps } from '../preview/vegaLitePreviewShared';
import { RenderWebviewPayload } from '../webview/contract';
function resolveMermaidRuntimeExport(moduleExports: unknown): Record<string, unknown> {
if (moduleExports && typeof moduleExports === 'object' && 'default' in moduleExports) {
const defaultExport = (moduleExports as Record<string, unknown>).default;
if (defaultExport && typeof defaultExport === 'object') {
return defaultExport as Record<string, unknown>;
}
}
return (moduleExports ?? {}) as Record<string, unknown>;
}
function createBundledMermaidPreviewDeps(): MermaidPreviewDeps {
const mermaidRuntime = resolveMermaidRuntimeExport(mermaid);
const initialize = mermaidRuntime.initialize;
const parse = mermaidRuntime.parse;
const render = mermaidRuntime.render;
if (typeof initialize !== 'function' || typeof parse !== 'function' || typeof render !== 'function') {
throw new Error('Mermaid preview runtime is unavailable.');
}
return {
initialize: (config) => initialize(config),
parse: (source) => parse(source),
render: (id, source) => render(id, source)
};
}
function createBundledVegaLitePreviewDeps(): VegaLitePreviewDeps {
const compile = (vegaLiteModule as any).compile;
const parse = (vegaModule as any).parse;
const View = (vegaModule as any).View;
if (typeof compile !== 'function' || typeof parse !== 'function' || typeof View !== 'function') {
throw new Error('Vega-Lite preview runtime is unavailable.');
}
return {
compile,
parse,
createView: (runtime) => new View(runtime, { renderer: 'svg' })
};
}
import { getBundledMermaidPreviewDeps, getBundledVegaLitePreviewDeps } from '../webview/bundledPreviewDeps';
function parseRenderHostPayload(source: string): RenderWebviewPayload {
const parsed = JSON.parse(source);
@ -154,11 +105,11 @@ export async function bootstrapRenderHostDocument(doc: Document = document): Pro
}
export function loadBundledVegaLitePreviewDeps(): VegaLitePreviewDeps {
return createBundledVegaLitePreviewDeps();
return getBundledVegaLitePreviewDeps();
}
export function loadBundledMermaidPreviewDeps(): MermaidPreviewDeps {
return createBundledMermaidPreviewDeps();
return getBundledMermaidPreviewDeps();
}
export async function renderBundledMermaidToSvg(
@ -172,7 +123,7 @@ export async function renderBundledMermaidToSvg(
return renderNormalizedMermaidDefinitionSvgWithDeps(
definition,
createBundledMermaidPreviewDeps(),
getBundledMermaidPreviewDeps(),
theme
);
}
@ -184,7 +135,7 @@ export async function renderBundledVegaLiteToSvg(
): Promise<string> {
return renderVegaLiteSpecSvgWithDeps(
content,
createBundledVegaLitePreviewDeps(),
getBundledVegaLitePreviewDeps(),
theme
);
}

View file

@ -2,6 +2,7 @@ import { DiagramIntent } from '../../diagram/types';
import { renderMermaidArtifactSvg } from '../preview/mermaidPreview';
import { RenderWebviewTheme } from '../theme';
import { renderVegaLiteArtifactSvg } from '../preview/vegaLitePreview';
import { getBundledMermaidPreviewDeps, getBundledVegaLitePreviewDeps } from './bundledPreviewDeps';
export const RENDER_HOST_BRIDGE_GLOBAL = '__NOTEMD_RENDER_BRIDGE__';
@ -22,7 +23,7 @@ function createRenderHostBridge(): RenderHostBridge {
content,
mimeType: 'text/vnd.mermaid',
sourceIntent
}, undefined, theme);
}, getBundledMermaidPreviewDeps(), theme);
},
renderVegaLiteToSvg(content, theme = 'system', sourceIntent = 'dataChart') {
return renderVegaLiteArtifactSvg({
@ -30,17 +31,15 @@ function createRenderHostBridge(): RenderHostBridge {
content,
mimeType: 'application/json',
sourceIntent
}, undefined, theme);
}, async () => getBundledVegaLitePreviewDeps(), theme);
}
};
}
export function ensureRenderHostBridge(root: RenderHostGlobal = globalThis as RenderHostGlobal): RenderHostBridge {
if (!root[RENDER_HOST_BRIDGE_GLOBAL]) {
root[RENDER_HOST_BRIDGE_GLOBAL] = createRenderHostBridge();
}
return root[RENDER_HOST_BRIDGE_GLOBAL] as RenderHostBridge;
const bridge = createRenderHostBridge();
root[RENDER_HOST_BRIDGE_GLOBAL] = bridge;
return bridge;
}
export function buildMermaidRenderBootstrap(): string {

View file

@ -0,0 +1,33 @@
import mermaid from 'mermaid';
import * as vegaLiteModule from 'vega-lite';
import * as vegaModule from 'vega';
import { MermaidPreviewDeps, validateMermaidPreviewDeps } from '../preview/mermaidPreviewShared';
import { VegaLitePreviewDeps } from '../preview/vegaLitePreview';
function resolveDefaultExport(moduleExports: unknown): unknown {
if (moduleExports && typeof moduleExports === 'object' && 'default' in moduleExports) {
return (moduleExports as Record<string, unknown>).default;
}
return moduleExports;
}
export function getBundledMermaidPreviewDeps(): MermaidPreviewDeps {
return validateMermaidPreviewDeps('bundled mermaid', resolveDefaultExport(mermaid) as MermaidPreviewDeps);
}
export function getBundledVegaLitePreviewDeps(): VegaLitePreviewDeps {
const compile = (vegaLiteModule as any).compile;
const parse = (vegaModule as any).parse;
const View = (vegaModule as any).View;
if (typeof compile !== 'function' || typeof parse !== 'function' || typeof View !== 'function') {
throw new Error('Bundled Vega-Lite preview runtime is unavailable.');
}
return {
compile,
parse,
createView: (runtime) => new View(runtime, { renderer: 'svg' })
};
}

View file

@ -4,6 +4,18 @@ import { clearDiagramPreviewHistory } from '../ui/diagramPreviewHistory';
import { mockApp } from './__mocks__/app';
import * as mermaidPreview from '../rendering/preview/mermaidPreview';
import * as previewExport from '../rendering/preview/previewExport';
import * as bundledPreviewDeps from '../rendering/webview/bundledPreviewDeps';
const bundledMermaidDeps = {
initialize: jest.fn(),
parse: jest.fn(),
render: jest.fn()
};
const bundledVegaLiteDeps = {
compile: jest.fn(),
parse: jest.fn(),
createView: jest.fn()
};
jest.mock('../rendering/preview/mermaidPreview', () => ({
renderMermaidArtifactSvg: jest.fn().mockResolvedValue('<svg><g /></svg>')
@ -21,6 +33,11 @@ jest.mock('../rendering/preview/previewExport', () => {
};
});
jest.mock('../rendering/webview/bundledPreviewDeps', () => ({
getBundledMermaidPreviewDeps: jest.fn(() => bundledMermaidDeps),
getBundledVegaLitePreviewDeps: jest.fn(() => bundledVegaLiteDeps)
}));
type MockElement = {
tag: string;
text: string;
@ -221,8 +238,15 @@ describe('diagram preview modal', () => {
mockApp,
'Notes/Topic.md',
expect.objectContaining({ target: 'mermaid' }),
expect.objectContaining({ theme: 'dark' })
expect.objectContaining({
theme: 'dark',
mermaid: bundledMermaidDeps,
vegaLiteDepsLoader: expect.any(Function)
})
);
const exportDeps = (previewExport.saveDiagramPreviewSvg as jest.Mock).mock.calls[0][3];
await expect(exportDeps.vegaLiteDepsLoader()).resolves.toBe(bundledVegaLiteDeps);
expect(bundledPreviewDeps.getBundledVegaLitePreviewDeps).toHaveBeenCalled();
expect(Notice).toHaveBeenCalledWith('Diagram preview exported to Notes/Topic_preview.svg');
expect(exportButton?.text).toBe('Export SVG');
expect(exportButton?.disabled).toBe(false);
@ -525,6 +549,20 @@ describe('diagram preview modal', () => {
expect(buttons.some(button => button.text === '保存源码文件')).toBe(true);
});
test('uses localized vault history label for chinese preview modal', async () => {
const modal = mountModal(new DiagramPreviewModal(mockApp, createSession(), 'zh-CN', {
historyStore: { loadPage: jest.fn(), removeEntry: jest.fn() }
}) as any);
modal.onOpen();
await flushPromises();
const historyPanel = findByClass(modal.contentEl, 'notemd-diagram-preview-history');
const buttons = collectButtons(historyPanel as MockElement);
expect(buttons.some(button => button.text === '管理 Vault 图形历史')).toBe(true);
expect(buttons.some(button => button.text === 'Manage Vault history')).toBe(false);
});
test('renders localized preview title when session provides one', async () => {
const modal = mountModal(new DiagramPreviewModal(mockApp, {
...createSession(),

View file

@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
const stylesPath = path.join(__dirname, '..', '..', 'styles.css');
const settingTabPath = path.join(__dirname, '..', 'ui', 'NotemdSettingTab.ts');
describe('provider settings styles', () => {
test('styles ship dedicated selectors for advanced provider settings and discovered model rows', () => {
@ -40,10 +41,18 @@ describe('provider settings styles', () => {
expect(styles).toContain('.notemd-settings-discovery button:focus-visible');
expect(styles).toContain('.notemd-setting-favorite-button { min-width: 44px; min-height: 44px;');
expect(styles).toContain('.notemd-settings-search, .notemd-settings-category-navigation, .notemd-settings-favorites-filter { min-height: 44px; }');
expect(styles).toContain('.notemd-diagram-history-toolbar');
expect(styles).toContain('.notemd-diagram-history-actions');
expect(styles).toContain('.notemd-settings-result-count');
expect(styles).toContain('.notemd-settings-empty-state');
expect(styles).toContain('@media (max-width: 720px)');
});
test('settings categories use one progressive selector instead of parallel heading buttons', () => {
const source = fs.readFileSync(settingTabPath, 'utf8');
expect(source).toContain("header.createEl('select', { cls: 'notemd-settings-category-navigation' })");
expect(source).not.toContain("navigation.createEl('button', { text: label })");
});
});

View file

@ -0,0 +1,68 @@
import { ensureRenderHostBridge, RENDER_HOST_BRIDGE_GLOBAL } from '../rendering/webview/bootstrap';
import * as mermaidPreview from '../rendering/preview/mermaidPreview';
import * as vegaLitePreview from '../rendering/preview/vegaLitePreview';
jest.mock('mermaid', () => {
const runtime = {
initialize: jest.fn(),
parse: jest.fn().mockResolvedValue(true),
render: jest.fn().mockResolvedValue({ svg: '<svg />' })
};
return { __esModule: true, default: runtime, ...runtime };
});
jest.mock('vega-lite', () => ({
compile: jest.fn(() => ({ spec: { marks: [] } }))
}));
jest.mock('vega', () => ({
parse: jest.fn(() => ({ runtime: true })),
View: class {}
}));
jest.mock('../rendering/preview/mermaidPreview', () => ({
renderMermaidArtifactSvg: jest.fn().mockResolvedValue('<svg data-renderer="mermaid" />')
}));
jest.mock('../rendering/preview/vegaLitePreview', () => ({
renderVegaLiteArtifactSvg: jest.fn().mockResolvedValue('<svg data-renderer="vega-lite" />')
}));
describe('render webview bridge', () => {
test('replaces a bridge retained from a previous plugin reload', () => {
const staleBridge = {
renderMermaidToSvg: jest.fn(),
renderVegaLiteToSvg: jest.fn()
};
const root = { [RENDER_HOST_BRIDGE_GLOBAL]: staleBridge } as any;
const currentBridge = ensureRenderHostBridge(root);
expect(currentBridge).not.toBe(staleBridge);
expect(root[RENDER_HOST_BRIDGE_GLOBAL]).toBe(currentBridge);
});
test('injects bundled preview dependencies instead of loading bare packages at runtime', async () => {
const root = {} as any;
const bridge = ensureRenderHostBridge(root);
await bridge.renderMermaidToSvg('flowchart TD\nA --> B', 'dark', 'flowchart');
await bridge.renderVegaLiteToSvg('{"mark":"bar"}', 'dark', 'dataChart');
expect(root[RENDER_HOST_BRIDGE_GLOBAL]).toBe(bridge);
expect(mermaidPreview.renderMermaidArtifactSvg).toHaveBeenCalledWith(
expect.objectContaining({ target: 'mermaid' }),
expect.objectContaining({
initialize: expect.any(Function),
parse: expect.any(Function),
render: expect.any(Function)
}),
'dark'
);
expect(vegaLitePreview.renderVegaLiteArtifactSvg).toHaveBeenCalledWith(
expect.objectContaining({ target: 'vega-lite' }),
expect.any(Function),
'dark'
);
});
});

View file

@ -29,6 +29,10 @@ import {
} from '../rendering/diagnostics';
import { DiagramHistoryModal } from './DiagramHistoryModal';
import type { DiagramHistoryExportKind, DiagramHistoryQuery } from '../diagram/history/diagramHistoryRepository';
import {
getBundledMermaidPreviewDeps,
getBundledVegaLitePreviewDeps
} from '../rendering/webview/bundledPreviewDeps';
interface DiagramHistoryStore {
loadPage: (query: DiagramHistoryQuery) => Promise<any>;
@ -144,7 +148,7 @@ export class DiagramPreviewModal extends Modal {
this.app,
this.session.payload.sourcePath as string,
this.session.payload.artifact,
{ theme: this.session.payload.resolvedTheme ?? this.session.payload.theme }
this.createBundledPreviewRenderDeps()
);
await this.recordExportPath('svg', outputPath);
new Notice(formatI18n(i18n.previewModal.exportSuccessNotice, { path: outputPath }));
@ -170,7 +174,7 @@ export class DiagramPreviewModal extends Modal {
this.session.payload.sourcePath as string,
this.session.payload.artifact,
{
theme: this.session.payload.resolvedTheme ?? this.session.payload.theme,
...this.createBundledPreviewRenderDeps(),
ppi: this.exportPpi
}
);
@ -198,7 +202,7 @@ export class DiagramPreviewModal extends Modal {
this.session.payload.sourcePath as string,
this.session.payload.artifact,
{
theme: this.session.payload.resolvedTheme ?? this.session.payload.theme,
...this.createBundledPreviewRenderDeps(),
ppi: this.exportPpi
}
);
@ -297,7 +301,7 @@ export class DiagramPreviewModal extends Modal {
cls: 'notemd-diagram-preview-history-title'
});
if (this.historyStore) {
const manage = historyEl.createEl('button', { text: 'Manage Vault history' });
const manage = historyEl.createEl('button', { text: i18n.previewModal.manageVaultHistory });
manage.onclick = () => new DiagramHistoryModal(this.app, this.historyStore!.loadPage, this.historyStore!.removeEntry, this.historyStore!.deleteArtifacts, this.historyStore!.reopenArtifact, this.uiLocale).open();
}
@ -376,9 +380,10 @@ export class DiagramPreviewModal extends Modal {
private async tryRenderCanvas(container: HTMLElement): Promise<boolean> {
try {
const svg = await renderPreviewArtifactSvg(this.session.payload.artifact, {
theme: this.session.payload.resolvedTheme ?? this.session.payload.theme
});
const svg = await renderPreviewArtifactSvg(
this.session.payload.artifact,
this.createBundledPreviewRenderDeps()
);
container.empty();
container.addClass('is-json-canvas');
container.innerHTML = svg;
@ -391,9 +396,10 @@ export class DiagramPreviewModal extends Modal {
private async tryRenderPreviewSvg(container: HTMLElement): Promise<boolean> {
try {
const svg = await renderPreviewArtifactSvg(this.session.payload.artifact, {
theme: this.session.payload.resolvedTheme ?? this.session.payload.theme
});
const svg = await renderPreviewArtifactSvg(
this.session.payload.artifact,
this.createBundledPreviewRenderDeps()
);
container.empty();
container.addClass('is-svg-preview');
container.innerHTML = svg;
@ -422,6 +428,14 @@ export class DiagramPreviewModal extends Modal {
});
}
private createBundledPreviewRenderDeps() {
return {
mermaid: getBundledMermaidPreviewDeps(),
vegaLiteDepsLoader: async () => getBundledVegaLitePreviewDeps(),
theme: this.session.payload.resolvedTheme ?? this.session.payload.theme
};
}
private getIframeSandboxPolicy(): string {
if (
this.session.payload.artifact.target === 'vega-lite'

View file

@ -143,8 +143,11 @@ export class NotemdSettingTab extends PluginSettingTab {
const favoritesButton = header.createEl('button', { text: copy.favorites, cls: 'notemd-settings-favorites-filter' });
favoritesButton.type = 'button';
let favoritesOnly = false;
const navigation = header.createDiv({ cls: 'notemd-settings-category-navigation' });
const categoryButtons = new Map<string, HTMLButtonElement>();
const navigation = header.createEl('select', { cls: 'notemd-settings-category-navigation' });
navigation.setAttribute('aria-label', copy.categoryNavigationLabel);
navigation.createEl('option', { text: copy.allCategories, value: '' });
const categoryOptions = new Map<string, HTMLOptionElement>();
const categoryHeadings = new Map<string, HTMLElement>();
const resultCount = header.createDiv({ cls: 'notemd-settings-result-count' });
resultCount.setAttribute('aria-live', 'polite');
const emptyState = header.createDiv({ cls: 'notemd-settings-empty-state', text: copy.noResults });
@ -153,12 +156,16 @@ export class NotemdSettingTab extends PluginSettingTab {
const label = heading.textContent?.trim();
if (!label) return;
heading.id = `notemd-settings-category-${index}`;
const button = navigation.createEl('button', { text: label });
button.type = 'button';
button.onclick = () => heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
const settingIndex = settingItems.indexOf(heading);
if (settingIndex >= 0) categoryButtons.set(catalog[settingIndex].id, button);
if (settingIndex < 0) return;
const categoryId = catalog[settingIndex].id;
const option = navigation.createEl('option', { text: label, value: categoryId });
categoryOptions.set(categoryId, option);
categoryHeadings.set(categoryId, heading);
});
navigation.onchange = () => {
categoryHeadings.get(navigation.value)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const applyFilter = () => {
const navigationState = resolveSettingsNavigation(catalog, { query: search.value, favoritesOnly, favoriteIds: favorites });
settingItems.forEach((item, index) => {
@ -166,9 +173,12 @@ export class NotemdSettingTab extends PluginSettingTab {
const hidden = !navigationState.visibleIds.has(settingId);
item.toggleAttribute('hidden', hidden);
});
categoryButtons.forEach((button, categoryId) => {
button.hidden = !navigationState.visibleCategoryIds.has(categoryId);
categoryOptions.forEach((option, categoryId) => {
const visible = navigationState.visibleCategoryIds.has(categoryId);
option.hidden = !visible;
option.disabled = !visible;
});
if (navigation.value && !navigationState.visibleCategoryIds.has(navigation.value)) navigation.value = '';
resultCount.setText(formatI18n(copy.resultCount, { visible: navigationState.visibleCount, total: navigationState.totalCount }));
emptyState.hidden = navigationState.visibleCount !== 0;
};

View file

@ -2094,14 +2094,16 @@
}
.notemd-settings-discovery { position: sticky; top: 0; z-index: 5; display: grid; grid-template-columns: minmax(14rem, 1fr) auto; gap: 0.6rem; padding: 0.75rem 0; background: var(--background-primary); border-bottom: 1px solid var(--background-modifier-border); }
.notemd-settings-search { min-width: 0; width: 100%; }
.notemd-settings-category-navigation { grid-column: 1 / -1; display: flex; gap: 0.35rem; overflow-x: auto; padding-bottom: 0.2rem; }
.notemd-settings-result-count { color: var(--text-muted); font-size: 12px; line-height: 1.4; align-self: center; }
.notemd-settings-category-navigation { grid-column: 1 / -1; min-width: 0; width: 100%; }
.notemd-settings-search, .notemd-settings-category-navigation, .notemd-settings-favorites-filter { min-height: 44px; }
.notemd-settings-result-count { grid-column: 1 / -1; color: var(--text-muted); font-size: 12px; line-height: 1.4; align-self: center; }
.notemd-settings-empty-state { grid-column: 1 / -1; padding: 0.65rem 0.75rem; border: 1px dashed var(--notemd-border); border-radius: 10px; color: var(--text-muted); background: var(--notemd-surface-alt); }
.notemd-settings-category-navigation button, .notemd-settings-favorites-filter { white-space: nowrap; }
.notemd-settings-favorites-filter { white-space: nowrap; }
.notemd-settings-favorites-filter.is-active { color: var(--text-on-accent); background: var(--interactive-accent); }
.notemd-setting-favorite-button { min-width: 44px; min-height: 44px; align-self: center; flex: 0 0 auto; margin-inline-start: 0.4rem; padding: 0.2rem 0.45rem; color: var(--text-accent); background: transparent; border: 0; box-shadow: none; }
.notemd-settings-discovery button:focus-visible,
.notemd-settings-discovery input:focus-visible,
.notemd-settings-discovery select:focus-visible,
.notemd-diagram-history-toolbar :is(input, select, button):focus-visible,
.notemd-diagram-history-actions button:focus-visible {
outline: 2px solid var(--notemd-focus);