This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

524
index.html Normal file
View File

@@ -0,0 +1,524 @@
<!DOCTYPE html>
<!--
lang="zh-CN" + dir="ltr"Round 12 P2 auditCJK 默认 ltr但 a11y lint
通常要求显式 dir。Electron 桌面应用虽不像移动 web 那样卡 viewport 缩放,
但 viewport meta 仍然是为未来 web 模式合规铺路P1 audit
-->
<html lang="zh-CN" dir="ltr">
<head>
<meta charset="UTF-8">
<!-- viewport meta (Round 12 P1 audit):桌面 Electron 也有 webview / web mode 兼容场景 -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--
Content Security Policy:
- default-src 'self' 只允许加载同源资源
- style-src 'self' 'unsafe-inline' 允许 inline stylemarked 渲染的代码块可能会用到)
- script-src 'self' 'unsafe-inline' 允许同源 + 内联脚本(用于 importmap
- img-src 'self' data: file: 允许 markdown 内嵌图片(本地 + base64
关于 'unsafe-inline':本应用唯一的内联脚本是 importmap用于映射 CodeMirror 包)。
渲染端的实际代码全部走 .js 文件加载。Markdown 内容经 DOMPurify 清洗,<script> 会被剥离。
在 contextIsolation + nodeIntegration:false 下,加 'unsafe-inline' 不引入额外 XSS 风险。
-->
<!--
Content Security Policy (加固版):
- default-src 'self' 只允许加载同源资源
- style-src 'self' 'unsafe-inline' 允许 inline stylemarked 渲染的代码块可能会用到)
- script-src 'self' 'unsafe-inline' 允许同源 + 内联脚本(用于 importmap
- img-src 'self' data: file: 允许 markdown 内嵌图片(本地 + base64
- object-src 'none' 禁止 <object>/<embed>/<applet>(插件 XSS 通道)
- base-uri 'none' 禁止 <base> 标签劫持相对 URL
- form-action 'none' 禁止 form 提交外发(即使有 <form> 也是死链)
关于 'unsafe-inline':本应用唯一的内联脚本是 importmap用于映射 CodeMirror 包)。
渲染端的实际代码全部走 .js 文件加载。Markdown 内容经 DOMPurify 清洗,<script> 会被剥离。
在 contextIsolation + nodeIntegration:false 下,加 'unsafe-inline' 不引入额外 XSS 风险。
未来若改用 nonce 或 hash可去除 'unsafe-inline'。
-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: file:; object-src 'none'; base-uri 'none'; form-action 'none';">
<title>Notes</title>
<!--
Import map: 把 CodeMirror 6 及其传递依赖映射到本地 node_modules。
浏览器原生 ESM 不解析 package.json 的 exports 字段,所以必须写完整相对路径。
style-mod 的 ESM 在 src/ 而不是 dist/dist/ 只有 CJS—— 这是常见坑。
-->
<script type="importmap">
{
"imports": {
"codemirror": "./node_modules/codemirror/dist/index.js",
"@codemirror/state": "./node_modules/@codemirror/state/dist/index.js",
"@codemirror/view": "./node_modules/@codemirror/view/dist/index.js",
"@codemirror/commands": "./node_modules/@codemirror/commands/dist/index.js",
"@codemirror/language": "./node_modules/@codemirror/language/dist/index.js",
"@codemirror/lang-markdown": "./node_modules/@codemirror/lang-markdown/dist/index.js",
"@codemirror/lang-css": "./node_modules/@codemirror/lang-css/dist/index.js",
"@codemirror/lang-html": "./node_modules/@codemirror/lang-html/dist/index.js",
"@codemirror/lang-javascript": "./node_modules/@codemirror/lang-javascript/dist/index.js",
"@codemirror/search": "./node_modules/@codemirror/search/dist/index.js",
"@codemirror/autocomplete": "./node_modules/@codemirror/autocomplete/dist/index.js",
"@codemirror/lint": "./node_modules/@codemirror/lint/dist/index.js",
"@codemirror/theme-one-dark": "./node_modules/@codemirror/theme-one-dark/dist/index.js",
"@lezer/common": "./node_modules/@lezer/common/dist/index.js",
"@lezer/highlight": "./node_modules/@lezer/highlight/dist/index.js",
"@lezer/markdown": "./node_modules/@lezer/markdown/dist/index.js",
"@lezer/css": "./node_modules/@lezer/css/dist/index.js",
"@lezer/html": "./node_modules/@lezer/html/dist/index.js",
"@lezer/javascript": "./node_modules/@lezer/javascript/dist/index.js",
"@lezer/lr": "./node_modules/@lezer/lr/dist/index.js",
"@marijn/find-cluster-break": "./node_modules/@marijn/find-cluster-break/src/index.js",
"style-mod": "./node_modules/style-mod/src/style-mod.js",
"w3c-keyname": "./node_modules/w3c-keyname/index.js",
"crelt": "./node_modules/crelt/index.js"
}
}
</script>
<link rel="stylesheet" href="src/styles.css">
</head>
<body data-theme="dark" data-theme-palette="default" data-focus-mode="false">
<!-- 聚焦模式顶边热区:触发时滑出工具栏 -->
<div class="focus-hotzone" aria-hidden="true"></div>
<!--
Skip link (Phase M a11y)
键盘用户 Tab 时第一站直达主内容,跳过工具栏 9 个按钮 + 侧栏若干入口。
默认视觉隐藏不占布局focus 时滑出右上角。target 是 #viewer-main
- 标记 <main id="viewer-main" tabindex="-1">:让非交互元素也能 focus
以承接 hash 跳转 + 屏幕阅读器宣告「进入主区域」。
-->
<a href="#viewer-main" class="skip-link">跳到主内容</a>
<!-- 启动错误兜底window.api 不可用 / bootstrap 抛错时显示) -->
<!-- a11y #2criticalrole="alert" + aria-live="assertive" 让屏幕阅读器在出现
bootstrap 失败的瞬间立即播报不会漏掉关键故障。hidden 时虽然 [hidden] 会
display:none但 role/aria-live 仍声明「一旦显示就立即播报」。 -->
<div id="boot-error" hidden role="alert" aria-live="assertive">
<h2>⚠ 启动失败</h2>
<p>无法连接到主进程。请重启应用。</p>
<p class="boot-error-detail"></p>
</div>
<!-- 自定义标题栏:可拖拽 + 窗口控件 -->
<header class="toolbar">
<div class="toolbar-left drag-region">
<!-- 应用图标:由 preload 读 icon.ico 转 PNG data URL渲染端注入 src。
hidden 直到 src load 成功避免破图占位闪烁。src 失败 → 走 .app-title::before 占位 -->
<img id="app-icon" class="app-icon" alt="" draggable="false" hidden>
<!--
长文件名截断fix长文件名导致 toolbar 偏移):
- flex 容器里的直接文本节点 min-content 是整段文本,无法自然收缩;
必须把文本包到 .app-title-text 里,给它 min-width:0 + ellipsis 才能截断
- 渲染端updateWindowTitle写 textContent 到 #app-title-text
没有这个 span 时退回 #app-title textContent向后兼容
-->
<span id="app-title" class="app-title"><span id="app-title-text" class="app-title-text">Notes</span></span>
</div>
<div class="toolbar-right no-drag">
<!-- 应用组:主题 + 置顶 -->
<div class="toolbar-group">
<div class="btn-group" role="group" aria-label="外观">
<button class="btn-icon btn-group-item" id="btn-theme" title="切换主题 (当前: 暗色主题)" aria-label="切换主题">
<svg class="theme-icon theme-icon-sun" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg class="theme-icon theme-icon-moon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button class="btn-icon btn-group-item" id="btn-pin" title="始终置顶" aria-label="始终置顶">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="17" x2="12" y2="22"></line>
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h-6v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17z"></path>
</svg>
</button>
</div>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 视图组:预览 / 编辑 / 双栏(图标 + 文字按钮,比纯图标更易识别) -->
<div class="toolbar-group">
<div class="btn-group btn-group-text" role="group" aria-label="视图模式">
<button class="btn-icon-text btn-group-item" id="btn-mode-preview" title="预览模式 (Ctrl+E 切换)" aria-label="预览模式" aria-pressed="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span class="btn-label">预览</span>
</button>
<button class="btn-icon-text btn-group-item" id="btn-mode-edit" title="编辑模式 (Ctrl+E 切换)" aria-label="编辑模式" aria-pressed="false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
<span class="btn-label">编辑</span>
</button>
<button class="btn-icon-text btn-group-item" id="btn-mode-split" title="双栏模式 (Ctrl+E 切换)" aria-label="双栏模式" aria-pressed="false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="12" y1="3" x2="12" y2="21"></line>
</svg>
<span class="btn-label">双栏</span>
</button>
</div>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 布局组:恢复默认侧栏宽度 + 双栏比例 -->
<div class="toolbar-group">
<button class="btn-icon" id="btn-reset-layout" title="重置布局(恢复默认侧栏宽度 + 双栏比例)" aria-label="重置布局">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 3-6.7"></path>
<polyline points="3 4 3 9 8 9"></polyline>
</svg>
</button>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 行为组:自动保存开关(贴近编辑器,作用在编辑器 → 放顶部工具栏;见 [[feedback-prefer-near-affected-area]])。
iOS pill switch 替代原先的「按钮 + status dot」让开关语义更明显。
role="switch" 让屏幕阅读器读「开关 开/关」而非「按钮 按/未按」。
初始 aria-pressed="true" 与 settings-schema DEFAULT_SETTINGS.autoSaveDebounceMs=500 一致,
避免首帧 FOUC 闪一下「关 → 开」audit D1-->
<div class="toolbar-group">
<button class="btn-icon-text toolbar-toggle" id="toolbar-autosave" type="button"
title="自动保存:开启(停打后 500ms 保存)· 点击关闭"
aria-label="自动保存"
aria-pressed="true"
role="switch">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<polyline points="12 7 12 12 15 14"></polyline>
</svg>
<span class="btn-label">自动保存</span>
<span class="toggle-switch" aria-hidden="true">
<span class="toggle-switch-track"></span>
<span class="toggle-switch-thumb"></span>
</span>
</button>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 操作组:保存 + 设置 -->
<div class="toolbar-group">
<!--
保存按钮始终显示在工具栏;需要保存(脏)时进入 is-dirty 提醒态warning 色 + 脉动)。
见 [[feedback-save-button-always-visible]]。
-->
<button class="btn-icon" id="btn-save" title="保存 (Ctrl+S)" aria-label="保存">
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
<button class="btn-icon" id="btn-settings" title="设置 (Ctrl+,)" aria-label="设置">
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
</div>
<span class="toolbar-divider" aria-hidden="true"></span>
<!-- 窗口组:最小化 / 最大化 / 关闭 -->
<div class="toolbar-group">
<div class="window-controls">
<button class="btn-window" id="btn-min" title="最小化" aria-label="最小化">
<svg aria-hidden="true" viewBox="0 0 10 10" width="10" height="10"><line x1="0" y1="5" x2="10" y2="5" stroke="currentColor" stroke-width="1"/></svg>
</button>
<button class="btn-window" id="btn-max" title="最大化" aria-label="最大化">
<svg aria-hidden="true" viewBox="0 0 10 10" width="10" height="10"><rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1"/></svg>
</button>
<button class="btn-window btn-close" id="btn-close" title="关闭" aria-label="关闭">
<svg aria-hidden="true" viewBox="0 0 10 10" width="10" height="10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1"/></svg>
</button>
</div>
</div>
</div>
</header>
<!-- 主区:侧边栏 + (可选编辑器 + 分割条) + 阅读区 -->
<!--
id="app" 是 modal.js:126 背景 inert 隔离机制的锚点modal 打开时给 #app
打 inert 让背景 DOM侧栏/工具栏/状态栏)失活,避免键盘 Tab 跳出 modal、
屏幕阅读器朗读背景。HTML 里必须显式存在该 id否则 getElementById 返回
null整套 inert 隔离静默失效Round 12 P0 audit 发现)。
不要把 inert 挂在 body —— 那会让 modal 自身也失活;#app 是 app-shell
跟 modal-root独立容器平级。
-->
<div class="app-shell" id="app">
<aside class="sidebar" id="sidebar" aria-label="文件与目录">
<div class="sidebar-header">
<div class="search-wrap">
<svg aria-hidden="true" class="search-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="search"
id="search-input"
class="search-input"
placeholder="搜索文件..."
aria-label="搜索文件"
>
</div>
</div>
<!-- 新建笔记:放在搜索框下方的独立主操作行,不再和搜索框挤在同一排抢首屏注意力(见 [[feedback-prefer-near-affected-area]] -->
<div class="sidebar-new-row">
<button id="btn-new-file" class="sidebar-new-btn" type="button" title="新建笔记 (Ctrl+N)" aria-label="新建笔记">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>新建笔记</span>
</button>
</div>
<!-- 排序工具条:紧贴它作用的列表,避免埋进设置对话框(见 [[feedback-prefer-near-affected-area]] -->
<div class="sidebar-sort" role="group" aria-label="文件列表排序">
<button id="btn-sort-name" type="button" class="sidebar-sort-btn is-active" aria-pressed="true" title="按名称 A → Z 排序">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h13M3 12h9M3 18h5"></path>
<path d="M19 4v16M16 17l3 3 3-3"></path>
</svg>
<span>名称</span>
</button>
<button id="btn-sort-mtime" type="button" class="sidebar-sort-btn" aria-pressed="false" title="按修改时间排序(最近修改在前)">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<polyline points="12 7 12 12 15 14"></polyline>
</svg>
<span>修改时间</span>
</button>
</div>
<!-- Folder BrowserStage 8面包屑 + 「回到根」按钮,贴近它作用的文件列表。
根目录时整段 hidden避免空栏占位。渲染端控制 .sidebar-breadcrumb[data-rel-dir] 与各段按钮 -->
<nav id="sidebar-breadcrumb" class="sidebar-breadcrumb" aria-label="当前目录" hidden>
<button id="btn-breadcrumb-root" type="button" class="sidebar-breadcrumb-root" title="返回数据文件夹根目录" aria-label="返回根目录">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12L12 4l9 8"></path>
<path d="M5 10v9h14v-9"></path>
</svg>
<span></span>
</button>
<span class="sidebar-breadcrumb-sep" aria-hidden="true">/</span>
<span id="sidebar-breadcrumb-trail" class="sidebar-breadcrumb-trail"></span>
</nav>
<ul id="file-list" class="file-list" role="listbox" aria-label="文件列表" tabindex="-1" data-empty="此文件夹为空">
<!-- 文件项由 file-list.js 注入 -->
</ul>
<div class="sidebar-footer">
<button type="button" id="link-open-folder" class="link-subtle footer-action" title="在系统文件管理器中打开数据文件夹">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<span>打开数据文件夹</span>
</button>
<button type="button" id="btn-switch-folder" class="link-subtle footer-action" title="切换到其他数据文件夹">
<!-- folder + 双向 swap 箭头 (Lucide folder-sync 风格) -->
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1-2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
<polyline points="16 3 19 3 19 6"></polyline>
<polyline points="8 21 5 21 5 18"></polyline>
<line x1="19" y1="3" x2="13" y2="9"></line>
<line x1="5" y1="21" x2="11" y2="15"></line>
</svg>
<span>切换数据文件夹</span>
</button>
<!-- auto-fallback 2026-08「回到默认」按钮 —— 清空持久化的 dataDir 并切回 ~/Notes。
始终显示:用户切到非默认目录后想回到默认不需要先「切换」再选;救命的入口
不能藏在条件分支里。当已经在默认文件夹时按钮可点toast 提示「已在默认」),
复用现有 .link-subtle .footer-action 样式,不新增 CSS。 -->
<button type="button" id="btn-reset-folder" class="link-subtle footer-action" title="回到默认数据文件夹(~/Notes">
<!-- home + 屋顶(表达「回到原始默认」) -->
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12l9-9 9 9"></path>
<path d="M5 10v10h14V10"></path>
</svg>
<span>回到默认</span>
</button>
<!-- 视觉分隔:把上方"打开/切换数据目录"二级链接和下方 AI 主操作分开 -->
<div class="sidebar-footer-sep" aria-hidden="true"></div>
<!-- AI 入口:放在侧栏最底,与顶部"主操作(新建笔记)→ 列表 → 排序 → 二级链接"形成层次。
默认沿用 .sidebar-new-btn 的描边主操作样式;展开后切到实心强调态(见 src/styles/11-ai.css 的 [aria-pressed="true"])。
AI 是「点开就弹,关闭就退」的命令式入口,与 settingsStore 无交集。 -->
<button id="btn-ai" class="sidebar-new-btn" type="button" title="AI 修改 (Ctrl+Shift+A)" aria-label="AI 修改" aria-pressed="false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 2a4 4 0 0 0-4 4v1H6a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3v-9a3 3 0 0 0-3-3h-2V6a4 4 0 0 0-4-4z"></path>
<circle cx="9" cy="13" r="1.2" fill="currentColor" stroke="none"></circle>
<circle cx="15" cy="13" r="1.2" fill="currentColor" stroke="none"></circle>
<path d="M9 17h6"></path>
</svg>
<span>AI 修改</span>
</button>
</div>
</aside>
<!-- 侧栏拖拽分割条(拖动调侧栏宽度)。
视觉宽度 4px命中区通过 -4px margin 扩到 12px。
内嵌 ↺ 按钮hover splitter 时显形,点击把侧栏恢复到 CSS 默认宽度220px
并写回 sidebarWidth=null 让下次启动也走默认。 -->
<div id="sidebar-splitter" class="sidebar-splitter" role="separator" aria-orientation="vertical" aria-label="拖动调整侧栏宽度" aria-controls="sidebar" title="拖动调整侧栏宽度">
<button id="btn-sidebar-reset-width" class="sidebar-splitter-reset" type="button" title="恢复默认侧栏宽度" aria-label="恢复默认侧栏宽度" tabindex="-1">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8"></path>
<polyline points="3 3 3 8 8 8"></polyline>
</svg>
</button>
</div>
<div id="editor-pane" class="editor-pane">
<!-- CodeMirror 挂载到这里 -->
</div>
<!-- fix(audit 2026-08)#splitter 加 ARIA separator role键盘用户能感知
可拖拽边界(与 #sidebar-splitter / #ai-splitter 一致) -->
<div id="splitter" class="splitter" role="separator" aria-orientation="vertical" aria-label="拖动调整编辑器与阅读视图宽度" aria-controls="editor-pane viewer-main"></div>
<!--
重要CSS Grid auto-placement 按 DOM 顺序处理。
.app-ai (grid-column:5) / .ai-splitter (grid-column:6) 都是显式钉位,
.viewer 是 auto。
若 .viewer 出现在 .app-ai 之前DOM 处理时 .viewer 会先占 col 5
.app-ai 显式 col 5 与 .viewer 冲突、被挤到 row 2错位
所以 .app-ai / .ai-splitter 必须在 .viewer 之前。
此外 .app-ai 必须先于 .ai-splitter —— cursor 处理到 .app-ai (col 5)
后再前进到 col 6.ai-splitter (col 6) 顺势放下,最后 .viewer
auto 落到 col 7倒过来 .ai-splitter 先显式占 col 6.app-ai 再
显式占 col 5cursor 被推到 col 7.viewer 反过来覆盖 .app-ai、
把 .app-ai 挤到 row 2实测验证
-->
<!--
AI 修改差异中间面板:
- 默认 hidden由 ai-controller 切换 body.dataset.aiDiffOpen 控制可见性
- 渲染走 ai-diff-panel.js与 viewer 共享 markdown-body 渲染管线
- 必须在 #ai-splitter 之前CSS Grid auto-placement 按 DOM 顺序处理,
app-ai 显式 grid-column:5 必须先于 viewer 处理,详见 splitter 上方注释)
-->
<aside id="app-ai" class="app-ai" hidden aria-label="AI 修改预览"></aside>
<!--
AI 拖动分割条(拖动调整 AI 中间面板宽度)。
与 #splitter / #sidebar-splitter 同设计:视觉 4px命中区由 margin 扩展。
由 body[data-ai-diff-open="true"] 显示;与 .app-ai 紧邻。
- 必须在 .app-ai 之后、.viewer 之前CSS Grid auto-placement
app-ai col 5 放好后ai-splitter col 6 用光 row 1 的中段,
viewer 才能 auto 落到 col 7倒过来顺序会让 ai-splitter 把 col 5
让给 viewerapp-ai 显式 col 5 与 viewer 冲突、被挤到 row 2
-->
<div id="ai-splitter" class="ai-splitter" role="separator" aria-orientation="vertical" aria-label="拖动调整 AI 面板宽度" aria-controls="app-ai" title="拖动调整 AI 面板宽度" hidden></div>
<main class="viewer" id="viewer-main" tabindex="-1">
<article id="markdown-body" class="markdown-body" hidden>
<!-- markdown 渲染输出 -->
</article>
<div id="empty-state" class="empty-state">
<div class="empty-state-icon">
<svg aria-hidden="true" viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="9" y1="13" x2="15" y2="13"></line>
<line x1="9" y1="17" x2="15" y2="17"></line>
</svg>
</div>
<p>选择一个 Markdown 文件开始阅读</p>
<p class="hint">
<code>.md</code> 文件放入数据文件夹后,<br>
会自动出现在左侧列表。
</p>
<p class="hint hint-secondary">左下角可切换数据文件夹,或在系统文件管理器中打开。</p>
</div>
</main>
</div>
<!--
AI 对话输入面板(底部 dock
- 位于 body grid 第 3 行app-shell 与 statusbar 之间),
由 03-base.css 的 .ai-chat-dock { grid-row: 3 } 显式钉位
- 默认 hidden点工具栏 btn-ai 展开;展开时挤占 app-shell 高度,
状态栏自动让位、始终钉底
- 与中部 diff 面板联动diff 由 ai-controller 写入 .app-ai
-->
<div class="ai-chat-dock" id="ai-chat-dock" hidden aria-label="AI 修改输入">
<div class="ai-chat-form-row">
<button class="ai-chat-toggle" type="button" title="关闭 AI 面板" aria-label="关闭 AI 面板" aria-controls="ai-chat-dock">×</button>
<div class="ai-chat-input-wrap">
<input
type="text"
class="ai-chat-input"
placeholder="描述你想如何修改当前文档..."
aria-label="AI 修改提示"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
/>
</div>
<button class="ai-chat-submit" type="button" data-mode="submit" disabled>生成修改</button>
</div>
</div>
<!-- 状态栏3 段式(路径弱化 / 字数核心 / 模式 + 进度 + 文件数)
segment 之间用 .status-spacer 推开,让眼睛先扫到核心信息 -->
<footer class="statusbar">
<div class="status-segment status-segment-left">
<button id="status-path" class="status-path status-path-clickable" type="button" title="在文件管理器中显示" aria-label="在文件管理器中显示当前文件"></button>
<span class="status-divider"></span>
<span id="status-meta" class="status-meta"></span>
</div>
<div class="status-spacer"></div>
<div class="status-segment status-segment-center">
<span id="status-stats" class="status-chip status-chip-stats" hidden></span>
<span id="status-heading" class="status-heading" hidden></span>
</div>
<div class="status-spacer"></div>
<div class="status-segment status-segment-right">
<span id="status-scroll" class="status-chip status-chip-muted" hidden>0%</span>
<button id="status-mode" class="status-chip status-chip-clickable status-chip-mode" data-mode="preview" type="button" title="切换视图模式 (Ctrl+E)" aria-label="切换视图模式(当前:预览)">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="17 1 21 5 17 9"></polyline>
<path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
<polyline points="7 23 3 19 7 15"></polyline>
<path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
</svg>
<span id="status-mode-label">预览</span>
</button>
<button id="status-ai" class="status-chip status-chip-clickable status-chip-ai" data-ai-state="idle" type="button" title="AI 修改" aria-label="AI 修改" aria-live="polite">
<span id="status-ai-label" class="status-chip-ai-text">AI</span>
</button>
<!-- 未保存指示器prefers-reduced-motion 下也保留,比 toolbar is-dirty 按钮更显眼)
aria-live=polite 让屏幕阅读器在 dirty 状态切换时朗读「未保存」Phase N a11y 修复)。-->
<span id="status-unsaved" class="status-chip status-chip-unsaved" hidden aria-live="polite">未保存</span>
<span class="status-divider"></span>
<span id="status-count" class="status-count"></span>
</div>
</footer>
<!-- Toast 容器 -->
<div id="toast-container" class="toast-container" aria-live="polite"></div>
<!-- 设置对话框模板(由 settings-dialog.js 复用填充) -->
<div id="modal-root"></div>
<!-- 聚焦模式退出提示:鼠标悬停时显出,按 Ctrl+Shift+F 退出 -->
<div class="focus-exit-hint" aria-hidden="true"><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd> 退出聚焦</div>
<script type="module" src="src/app.js"></script>
</body>
</html>