Files
Notes/index.html
2026-09-12 14:15:26 +08:00

525 lines
33 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>