Compare commits

...

6 Commits

Author SHA1 Message Date
8194345667 chore(bundle): NSIS 安装器切换为简体中文界面 2026-11-14 21:22:12 +08:00
499cec2273 chore(git): 忽略 IDE 配置与 vite 时间戳临时文件 2026-11-14 20:36:28 +08:00
Muse
cd64110bf8 chore(git-sync): 仓库地址输入框提示改为 github 示例地址 2026-11-14 20:29:02 +08:00
Muse
eb27e4ca2d fix(dropdown): 展开层改 Portal + fixed 定位,逃出弹窗 overflow 裁剪
弹窗(.modal 带 overflow-y:auto)里的下拉展开层是 absolute 定位,
会被弹窗滚动盒裁掉下半截(Git 同步设置里实测被遮挡)。展开层改经
Portal 挂到 body、按触发钮 rect 用 fixed 定位:下方空间不足时
向上翻,maxHeight 按可用空间夹取;窗口 resize 时关闭。
useDismiss 泛化为可传多个 ref(触发器与 Portal 菜单两棵子树都算内)。
CSS 相应去掉 .dropdown-menu 的 absolute 定位(几何改由内联给出)。
验证:vitest 124+2(新增翻转几何断言:贴底触发钮翻上展开、
Portal 落点不在组件容器内);无头 Chromium 实测弹窗内下拉菜单
完整落在视口内、8 项全显、已逃出弹窗 DOM。
2026-11-14 20:17:51 +08:00
Muse
4fca4b7143 fix(rust): spawn_blocking 改走 tauri::async_runtime(工程无 tokio 依赖,Windows 构建 E0433)
6 处 tokio::task::spawn_blocking(Git 同步 4 处 + docs/29 资源
命令 2 处)全部换成工程既有写法 tauri::async_runtime::spawn_blocking;
全量核对源码外部 crate 前缀与 Cargo.toml 依赖一致。docs/29 补勘误。
2026-11-14 18:09:30 +08:00
Muse
c602573d8e feat(save): 非 UTF-8 文档保存前转码提示(docs/29 D1,用户拍板)
GBK/UTF-16 文档保存会落盘为 UTF-8,原先无任何提示。saveTab 在
落盘前拦截:首次保存弹 EncodingConvertModal 说明将转 UTF-8,
按路径记已确认、本次运行只提示一次;自动保存不弹窗也不静默
转码,跳过等手动保存时提示;确认后以冻结快照 finishSave 收尾,
标签 encoding 如实更新为 UTF-8。弹窗渲染移到弹窗栈最后,避免
被关窗确认框盖住。vitest 123+2、构建通过。
2026-11-14 18:06:02 +08:00
13 changed files with 228 additions and 42 deletions

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@ target/
src-tauri/target/
src-tauri/gen/
*.tsbuildinfo
.idea/
vite.config.ts.timestamp-*

View File

@@ -132,6 +132,13 @@ src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据
- D1(中)非 UTF-8 文件首次保存被静默转码为 UTF-8(GBK/UTF-16
文档存一次即变 UTF-8):编辑器内部统一 UTF-8 是既定设计,但
落盘编码变更应至少首次提示一次。需前端交互拍板,未擅自加。
→ **已于当晚按用户拍板实现**:saveTab 在落盘前拦截,首次保存
弹 EncodingConvertModal 说明将转 UTF-8(按路径记已确认、本次
运行只提示一次);自动保存不弹窗也不静默转码,跳过该文档等
手动保存时提示;确认后以冻结快照完成落盘,标签 encoding 如实
更新为 UTF-8。弹窗渲染置于所有弹窗之后(避免被关窗确认框
盖住)。vitest 123+2(含弹窗契约测试)。真机验证点:拿一个
GBK 文档改字后 Ctrl+S,应先弹提示、确认后才落盘。
- D2(中)专注模式跨块时全量重建装饰集:大文档每次回车重建与
顶层块数等量的装饰对象。改增量方案需重构插件,单独立项。
- D3(低)源码模式每次击键物化全文回写 App(无渲染模式的 200ms
@@ -183,3 +190,11 @@ src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据
- H1 的守卫交错无法在 jsdom 自动化复现(见上),真机侧可用
「打开 >60K 文档后立刻连开多个标签挤掉其窗格,再回看该文档
是否完整」一验。
- **勘误(2026-11-14 晚,Windows 构建回报)**:本轮 M1 与 docs/27
的 Git 命令共 6 处用了 `tokio::task::spawn_blocking`,但工程并
没有 tokio 直接依赖(Cargo.toml 只有 tauri 等 7 个),Windows
构建报 E0433。已全部改为工程既有写法的
`tauri::async_runtime::spawn_blocking`(read/write 命令一直在
用),并全量核对源码中所有外部 crate 前缀与 Cargo.toml 依赖
一致、无其他幻影 crate。教训:写 Rust 时新引入的 crate 路径
必须先对 Cargo.toml 存量,沙箱无法完整编译时尤其如此。

View File

@@ -258,7 +258,7 @@ fn write_new_file(dir: &Path, file_name: &str, data: &[u8]) -> Result<PathBuf, S
/// 大文件会冻 UI 线程且内存整载(docs/29)。
#[tauri::command]
pub async fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
tokio::task::spawn_blocking(move || {
tauri::async_runtime::spawn_blocking(move || {
let src = PathBuf::from(&source);
let name = sanitize_file_name(
&src.file_name()
@@ -287,7 +287,7 @@ pub async fn save_bytes_to_assets(
file_name: String,
base64: String,
) -> Result<String, String> {
tokio::task::spawn_blocking(move || {
tauri::async_runtime::spawn_blocking(move || {
let name = sanitize_file_name(&file_name);
let bytes = base64_decode(&base64)?;
if bytes.len() as u64 > MAX_ASSET_BYTES {
@@ -784,7 +784,7 @@ fn git_status_inner(root: &str) -> GitStatusInfo {
#[tauri::command]
pub async fn git_status(root: String) -> GitStatusInfo {
tokio::task::spawn_blocking(move || git_status_inner(&root))
tauri::async_runtime::spawn_blocking(move || git_status_inner(&root))
.await
.unwrap_or(GitStatusInfo {
is_repo: false,
@@ -799,7 +799,7 @@ pub async fn git_status(root: String) -> GitStatusInfo {
#[tauri::command]
pub async fn git_init_repo(root: String, remote_url: String, branch: String) -> Result<GitStatusInfo, String> {
tokio::task::spawn_blocking(move || {
tauri::async_runtime::spawn_blocking(move || {
let dir = Path::new(&root);
let b = validate_branch(&branch)?;
let url = validate_remote_url(&remote_url)?;
@@ -833,7 +833,7 @@ pub async fn git_init_repo(root: String, remote_url: String, branch: String) ->
#[tauri::command]
pub async fn git_set_remote(root: String, remote_url: String) -> Result<GitStatusInfo, String> {
tokio::task::spawn_blocking(move || {
tauri::async_runtime::spawn_blocking(move || {
let dir = Path::new(&root);
let url_owned = validate_remote_url(&remote_url)?;
let url = url_owned.as_str();
@@ -855,7 +855,7 @@ pub async fn git_set_remote(root: String, remote_url: String) -> Result<GitStatu
#[tauri::command]
pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcome, String> {
tokio::task::spawn_blocking(move || {
tauri::async_runtime::spawn_blocking(move || {
let dir = Path::new(&root);
let st = git_status_inner(&root);
if !st.is_repo {

View File

@@ -1 +1 @@
{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"}
{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","windows":{"nsis":{"languages":["SimpChinese"],"displayLanguageSelector":false}},"icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"}

View File

@@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor'))
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
import { insertPlainText, insertLinkText } from './editor/paste'
import { isImeComposing } from './utils/ime'
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal } from './components/Modals'
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal } from './components/Modals'
import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs'
import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
@@ -472,11 +472,32 @@ export default function App() {
const handleSidebarOpenFolder = useCallback(() => { void openFolder() }, [openFolder])
const saveTab = useCallback(async (target: DocTab, saveAs: boolean): Promise<boolean> => {
// 非 UTF-8 文档保存转码提示(docs/29 D1,用户拍板加):按路径记已确认,
// 本次运行内同一文件只提示一次;确认后走 finishSave 完成落盘收尾。
const encodingAckedRef = useRef<Set<string>>(new Set())
const [encodingPrompt, setEncodingPrompt] = useState<{ target: DocTab; path: string; snapshot: string } | null>(null)
/** 保存落盘后的收尾:标已保存(编码如实记为 UTF-8)、刷树、更新基线、清恢复记录。 */
const finishSave = useCallback(async (target: DocTab, path: string, snapshot: string) => {
const oldKey = recoveryKey(target)
dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path), encoding: 'UTF-8' })
// 落点变化(未命名首次保存、另存为)意味着磁盘上多/改了一个文件,刷新左侧文件树
if (target.path !== path) setTreeRefresh((n) => n + 1)
const stat = await statFile(path)
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
// 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿
const newKey = recoveryKey({ id: target.id, path })
for (const key of new Set([oldKey, newKey])) {
recoveryWrittenRef.current.delete(key)
void store.deleteRecovery(key)
}
pushRecent(path)
}, [pushRecent])
const saveTab = useCallback(async (target: DocTab, saveAs: boolean, fromAutosave = false): Promise<boolean> => {
// 审计修复:保存用调用时刻的快照;saved 只把这份快照标为已保存,
// 写盘期间新输入的内容保持「脏」,不会被误标、不会丢恢复记录。
const snapshot = target.content
const oldKey = recoveryKey(target)
try {
if (!tauriMode) {
downloadTextFile(`${target.title}.md`, snapshot)
@@ -498,25 +519,35 @@ export default function App() {
path = await pickSaveFile(path ?? (workspaceRoot ? joinPath(workspaceRoot, `${target.title}.md`) : `${target.title}.md`))
if (!path) return false
}
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path) })
// 落点变化(未命名首次保存、另存为)意味着磁盘上多/改了一个文件,刷新左侧文件树
if (target.path !== path) setTreeRefresh((n) => n + 1)
const stat = await statFile(path)
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
// 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿
const newKey = recoveryKey({ id: target.id, path })
for (const key of new Set([oldKey, newKey])) {
recoveryWrittenRef.current.delete(key)
void store.deleteRecovery(key)
// 非 UTF-8 文档保存会转码为 UTF-8:首次保存前提示一次(按路径记已确认)。
// 自动保存不弹窗、也不静默转码——跳过本轮,等手动保存/关窗保存时提示。
if (target.encoding !== 'UTF-8' && !encodingAckedRef.current.has(path)) {
if (fromAutosave) return false
setEncodingPrompt({ target, path, snapshot })
return false
}
pushRecent(path)
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
await finishSave(target, path, snapshot)
return true
} catch (e) {
setError(String(e))
return false
}
}, [tauriMode, pushRecent, workspaceRoot])
}, [tauriMode, finishSave, workspaceRoot])
/** 编码提示确认:记下该路径已确认,用弹窗时冻结的快照完成落盘。 */
const confirmEncodingSave = useCallback(async () => {
const job = encodingPrompt
if (!job) return
setEncodingPrompt(null)
encodingAckedRef.current.add(job.path)
try {
await writeTextFile(job.path, toDiskContent(job.snapshot, job.target.lineEnding), job.target.hadBom)
await finishSave(job.target, job.path, job.snapshot)
} catch (e) {
setError(String(e))
}
}, [encodingPrompt, finishSave])
const requestClose = useCallback((ids: string[], after: () => void) => {
const dirtyOnes = state.tabs.filter((t) => ids.includes(t.id) && isDirty(t))
@@ -731,7 +762,7 @@ export default function App() {
const timer = window.setInterval(() => {
for (const t of stateRef.current.tabs) {
// 冲突弹窗正等用户裁决的标签不自动保存,否则会抢先覆盖外部修改
if (t.path && isDirty(t) && conflictRef.current !== t.id) void saveTabRef.current(t, false)
if (t.path && isDirty(t) && conflictRef.current !== t.id) void saveTabRef.current(t, false, true)
}
}, Math.max(10, ui.autosaveSeconds) * 1000)
return () => window.clearInterval(timer)
@@ -1363,7 +1394,7 @@ export default function App() {
}, [tauriMode, flash])
anyModalRef.current = Boolean(
modal || appearanceOpen || exportOpen || pendingClose || conflict
modal || appearanceOpen || exportOpen || pendingClose || conflict || encodingPrompt
|| promptSpec || confirmSpec || quitOpen || gitSettingsOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
)
findOpenRef.current = findUi.open
@@ -1857,6 +1888,15 @@ export default function App() {
/>
)}
{error && <ErrorModal message={error} onClose={() => setError(null)} />}
{encodingPrompt && (
<EncodingConvertModal
encoding={encodingPrompt.target.encoding}
fileName={baseName(encodingPrompt.path)}
onConfirm={() => { void confirmEncodingSave() }}
onCancel={() => setEncodingPrompt(null)}
/>
)}
</div>
)
}

View File

@@ -1,11 +1,21 @@
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
// 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。
import { useCallback, useEffect, useRef, useState } from 'react'
// 展开层经 Portal 挂到 body 并用 fixed 定位(按触发钮 rect 计算、下方空间
// 不足时向上翻):弹窗等带 overflow 的容器里 absolute 定位会被滚动盒裁掉
// (Git 同步设置里实测被遮挡),Portal + fixed 才能真正逃出裁剪。
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useDismiss } from '../hooks/useDismiss'
export interface DropdownOption { value: string; label: string }
interface MenuPos { left: number; top: number; width: number; maxHeight: number }
const ITEM_H = 31
const MENU_PAD = 8
const MAX_H = 264
export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
value: string
options: DropdownOption[]
@@ -14,13 +24,35 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
ariaLabel?: string
}) {
const [open, setOpen] = useState(false)
const [pos, setPos] = useState<MenuPos | null>(null)
const ref = useRef<HTMLDivElement>(null)
const btnRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const close = useCallback(() => setOpen(false), [])
useDismiss(ref, open, close)
const dismissRefs = useMemo(() => [ref, menuRef], [])
useDismiss(dismissRefs, open, close)
const current = options.find((o) => o.value === value) ?? options[0]
// 打开时按触发钮位置算展开层几何:优先向下,下方放不下且上方更宽裕时翻上去
const openMenu = useCallback(() => {
const rect = btnRef.current?.getBoundingClientRect()
if (!rect) { setOpen(true); return }
const est = Math.min(MAX_H, options.length * ITEM_H + MENU_PAD)
const spaceBelow = window.innerHeight - rect.bottom - 8
const spaceAbove = rect.top - 8
const above = spaceBelow < est && spaceAbove > spaceBelow
const maxHeight = Math.max(80, Math.min(MAX_H, above ? spaceAbove : spaceBelow))
const height = Math.min(est, maxHeight)
setPos({
left: Math.max(4, Math.min(rect.left, window.innerWidth - rect.width - 4)),
top: above ? Math.max(4, rect.top - height - 4) : rect.bottom + 4,
width: rect.width,
maxHeight,
})
setOpen(true)
}, [options.length])
// 打开时把焦点放到选中项,便于直接上下键选行
useEffect(() => {
if (!open) return
@@ -29,6 +61,14 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open ])
// 窗口尺寸变化时展开层位置失效,直接关闭(与菜单类组件惯例一致)
useEffect(() => {
if (!open) return
const onResize = () => setOpen(false)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [open ])
const pick = (v: string) => { setOpen(false); onChange(v) }
const onMenuKey = (e: React.KeyboardEvent) => {
@@ -46,17 +86,23 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
<div className="dropdown" ref={ref}>
<button
type="button"
ref={btnRef}
className="dropdown-button"
disabled={disabled}
aria-label={ariaLabel}
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
onClick={() => { if (open) setOpen(false); else openMenu() }}
>
<span className="dropdown-label">{current?.label ?? ''}</span>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
{open && (
<div className="dropdown-menu" ref={menuRef} onKeyDown={onMenuKey}>
{open && pos && createPortal(
<div
className="dropdown-menu"
ref={menuRef}
onKeyDown={onMenuKey}
style={{ position: 'fixed', left: pos.left, top: pos.top, width: pos.width, maxHeight: pos.maxHeight, zIndex: 200 }}
>
{options.map((o) => (
<button
key={o.value}
@@ -67,7 +113,8 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
{o.label}
</button>
))}
</div>
</div>,
document.body,
)}
</div>
)

View File

@@ -45,7 +45,7 @@ export function GitSyncModal({ isRepo, branch, initialRemoteUrl, initialInterval
<input
className="input"
value={remoteUrl}
placeholder="https://git.xhome.cc/xieyinlu/notes.git"
placeholder="https://github.com/example/example.git"
onChange={(e) => setRemoteUrl(e.target.value)}
/>
</label>

View File

@@ -28,6 +28,24 @@ export function ConfirmSaveModal({ title, onSave, onDiscard, onCancel }: {
)
}
export function EncodingConvertModal({ encoding, fileName, onConfirm, onCancel }: {
encoding: string
fileName: string
onConfirm: () => void
onCancel: () => void
}) {
return (
<Shell onCancel={onCancel}>
<h3>文档编码将转为 UTF-8</h3>
<p>「{fileName}」原是 {encoding} 编码。本编辑器统一以 UTF-8 保存,保存后文件编码将转为 UTF-8(内容不变)。本次运行内,同一文件只提示这一次。</p>
<div className="modal-actions">
<button onClick={onCancel}>取消保存</button>
<button className="primary" onClick={onConfirm}>转为 UTF-8 保存</button>
</div>
</Shell>
)
}
export function AboutModal({ onClose }: { onClose: () => void }) {
return (
<Shell onCancel={onClose}>

View File

@@ -5,12 +5,22 @@ import { useEffect, type RefObject } from 'react'
* 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
* 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行,
* 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。
* refs 可传多个:菜单经 Portal 渲染到 body 时(如下拉展开层),触发器与
* 菜单是两个分离的 DOM 子树,两边都算「内」。
*/
export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean, onClose: () => void) {
export function useDismiss(
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
active: boolean,
onClose: () => void,
) {
useEffect(() => {
if (!active) return
const outside = (target: EventTarget | null) =>
ref.current && target instanceof Node && !ref.current.contains(target)
const list = Array.isArray(refs) ? refs : [refs]
const outside = (target: EventTarget | null) => {
if (!(target instanceof Node)) return false
const containers = list.map((r) => r.current).filter(Boolean) as HTMLElement[]
return containers.length > 0 && containers.every((el) => !el.contains(target))
}
const onPointer = (e: MouseEvent) => { if (outside(e.target)) onClose() }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
const onScroll = (e: Event) => { if (outside(e.target)) onClose() }
@@ -26,5 +36,5 @@ export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean,
document.removeEventListener('scroll', onScroll, true)
window.removeEventListener('blur', onClose)
}
}, [ref, active, onClose])
}, [refs, active, onClose])
}

View File

@@ -46,7 +46,7 @@ export type TabsAction =
| { type: 'open'; tab: DocTab }
| { type: 'activate'; id: string }
| { type: 'updateContent'; id: string; content: string }
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string }
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string; encoding?: string }
| { type: 'syncEditorContent'; id: string; content: string }
| { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean }
| { type: 'setPath'; id: string; path: string; title: string }
@@ -93,7 +93,7 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
...state,
tabs: state.tabs.map((t) =>
t.id === action.id
? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title }
? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title, encoding: action.encoding ?? t.encoding }
: t,
),
}

View File

@@ -358,7 +358,8 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.dropdown-button:disabled { opacity: 0.5; cursor: default; }
.dropdown-button svg { flex: none; color: var(--text-dim); }
.dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dropdown-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; max-height: 264px; overflow-y: auto; }
/* 展开层几何(fixed 定位/宽/最大高)由 Dropdown 组件内联给出:Portal 到 body 逃出弹窗等 overflow 容器的裁剪 */
.dropdown-menu { background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow-y: auto; }
.dropdown-menu button { border: 0; background: transparent; color: var(--text); padding: 6px 9px; border-radius: 5px; font-size: 13px; font-family: inherit; text-align: left; cursor: pointer; }
.dropdown-menu button:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; }
.dropdown-menu button.selected { background: var(--accent-weak); }

View File

@@ -22,18 +22,44 @@ describe('Dropdown', () => {
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
expect(button.textContent).toContain('GitHub')
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
const menu = container.querySelector('.dropdown-menu')
const menu = document.querySelector('.dropdown-menu')
expect(menu).not.toBeNull()
expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub')
const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion')
await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(onChange).toHaveBeenCalledWith('notion')
expect(container.querySelector('.dropdown-menu')).toBeNull()
expect(document.querySelector('.dropdown-menu')).toBeNull()
// 再次展开,点别处关闭
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(container.querySelector('.dropdown-menu')).not.toBeNull()
expect(document.querySelector('.dropdown-menu')).not.toBeNull()
await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
expect(document.querySelector('.dropdown-menu')).toBeNull()
await act(async () => { root.unmount() })
container.remove()
})
it('展开层经 Portal 挂到 body;触发钮靠近窗口底部时向上翻且不溢出', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(<Dropdown value="github" options={OPTIONS} onChange={() => undefined} />)
})
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
// 模拟触发钮贴近窗口底部(jsdom 默认视口高 768):下方只剩 30px
button.getBoundingClientRect = () => ({
top: 700, bottom: 730, left: 100, right: 300, width: 200, height: 30, x: 100, y: 700, toJSON: () => ({}),
}) as DOMRect
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
// Portal:菜单不在组件容器内,而是 body 直属
expect(container.querySelector('.dropdown-menu')).toBeNull()
const menu = document.querySelector('.dropdown-menu') as HTMLElement | null
expect(menu).not.toBeNull()
expect(menu!.style.position).toBe('fixed')
// 3 项估算高 101,下方放不下 → 翻到触发钮上方:top = 700 - 101 - 4
expect(menu!.style.top).toBe('595px')
expect(menu!.style.left).toBe('100px')
expect(menu!.style.width).toBe('200px')
await act(async () => { root.unmount() })
container.remove()
})

View File

@@ -0,0 +1,27 @@
// docs/29 D1:非 UTF-8 文档保存转码提示弹窗的渲染与回调契约。
import { describe, it, expect, vi } from 'vitest'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { EncodingConvertModal } from '../src/components/Modals'
describe('EncodingConvertModal', () => {
it('显示原编码与文件名,确认/取消回调正确', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const onConfirm = vi.fn()
const onCancel = vi.fn()
await act(async () => {
root.render(<EncodingConvertModal encoding="GBK" fileName="老文档.md" onConfirm={onConfirm} onCancel={onCancel} />)
})
expect(container.textContent).toContain('GBK')
expect(container.textContent).toContain('老文档.md')
expect(container.textContent).toContain('转为 UTF-8')
const buttons = [...container.querySelectorAll('button')]
buttons.find((b) => b.textContent?.includes('转为 UTF-8'))?.click()
expect(onConfirm).toHaveBeenCalledTimes(1)
buttons.find((b) => b.textContent?.includes('取消'))?.click()
expect(onCancel).toHaveBeenCalledTimes(1)
await act(async () => { root.unmount() })
})
})