Compare commits
6 Commits
fb2315a2f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8194345667 | |||
| 499cec2273 | |||
|
|
cd64110bf8 | ||
|
|
eb27e4ca2d | ||
|
|
4fca4b7143 | ||
|
|
c602573d8e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ target/
|
|||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.idea/
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据
|
|||||||
- D1(中)非 UTF-8 文件首次保存被静默转码为 UTF-8(GBK/UTF-16
|
- D1(中)非 UTF-8 文件首次保存被静默转码为 UTF-8(GBK/UTF-16
|
||||||
文档存一次即变 UTF-8):编辑器内部统一 UTF-8 是既定设计,但
|
文档存一次即变 UTF-8):编辑器内部统一 UTF-8 是既定设计,但
|
||||||
落盘编码变更应至少首次提示一次。需前端交互拍板,未擅自加。
|
落盘编码变更应至少首次提示一次。需前端交互拍板,未擅自加。
|
||||||
|
→ **已于当晚按用户拍板实现**:saveTab 在落盘前拦截,首次保存
|
||||||
|
弹 EncodingConvertModal 说明将转 UTF-8(按路径记已确认、本次
|
||||||
|
运行只提示一次);自动保存不弹窗也不静默转码,跳过该文档等
|
||||||
|
手动保存时提示;确认后以冻结快照完成落盘,标签 encoding 如实
|
||||||
|
更新为 UTF-8。弹窗渲染置于所有弹窗之后(避免被关窗确认框
|
||||||
|
盖住)。vitest 123+2(含弹窗契约测试)。真机验证点:拿一个
|
||||||
|
GBK 文档改字后 Ctrl+S,应先弹提示、确认后才落盘。
|
||||||
- D2(中)专注模式跨块时全量重建装饰集:大文档每次回车重建与
|
- D2(中)专注模式跨块时全量重建装饰集:大文档每次回车重建与
|
||||||
顶层块数等量的装饰对象。改增量方案需重构插件,单独立项。
|
顶层块数等量的装饰对象。改增量方案需重构插件,单独立项。
|
||||||
- D3(低)源码模式每次击键物化全文回写 App(无渲染模式的 200ms
|
- D3(低)源码模式每次击键物化全文回写 App(无渲染模式的 200ms
|
||||||
@@ -183,3 +190,11 @@ src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据
|
|||||||
- H1 的守卫交错无法在 jsdom 自动化复现(见上),真机侧可用
|
- H1 的守卫交错无法在 jsdom 自动化复现(见上),真机侧可用
|
||||||
「打开 >60K 文档后立刻连开多个标签挤掉其窗格,再回看该文档
|
「打开 >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 存量,沙箱无法完整编译时尤其如此。
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ fn write_new_file(dir: &Path, file_name: &str, data: &[u8]) -> Result<PathBuf, S
|
|||||||
/// 大文件会冻 UI 线程且内存整载(docs/29)。
|
/// 大文件会冻 UI 线程且内存整载(docs/29)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
|
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 src = PathBuf::from(&source);
|
||||||
let name = sanitize_file_name(
|
let name = sanitize_file_name(
|
||||||
&src.file_name()
|
&src.file_name()
|
||||||
@@ -287,7 +287,7 @@ pub async fn save_bytes_to_assets(
|
|||||||
file_name: String,
|
file_name: String,
|
||||||
base64: String,
|
base64: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
tokio::task::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let name = sanitize_file_name(&file_name);
|
let name = sanitize_file_name(&file_name);
|
||||||
let bytes = base64_decode(&base64)?;
|
let bytes = base64_decode(&base64)?;
|
||||||
if bytes.len() as u64 > MAX_ASSET_BYTES {
|
if bytes.len() as u64 > MAX_ASSET_BYTES {
|
||||||
@@ -784,7 +784,7 @@ fn git_status_inner(root: &str) -> GitStatusInfo {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn git_status(root: String) -> GitStatusInfo {
|
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
|
.await
|
||||||
.unwrap_or(GitStatusInfo {
|
.unwrap_or(GitStatusInfo {
|
||||||
is_repo: false,
|
is_repo: false,
|
||||||
@@ -799,7 +799,7 @@ pub async fn git_status(root: String) -> GitStatusInfo {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn git_init_repo(root: String, remote_url: String, branch: String) -> Result<GitStatusInfo, String> {
|
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 dir = Path::new(&root);
|
||||||
let b = validate_branch(&branch)?;
|
let b = validate_branch(&branch)?;
|
||||||
let url = validate_remote_url(&remote_url)?;
|
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]
|
#[tauri::command]
|
||||||
pub async fn git_set_remote(root: String, remote_url: String) -> Result<GitStatusInfo, String> {
|
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 dir = Path::new(&root);
|
||||||
let url_owned = validate_remote_url(&remote_url)?;
|
let url_owned = validate_remote_url(&remote_url)?;
|
||||||
let url = url_owned.as_str();
|
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]
|
#[tauri::command]
|
||||||
pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcome, String> {
|
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 dir = Path::new(&root);
|
||||||
let st = git_status_inner(&root);
|
let st = git_status_inner(&root);
|
||||||
if !st.is_repo {
|
if !st.is_repo {
|
||||||
|
|||||||
@@ -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"}
|
||||||
76
src/App.tsx
76
src/App.tsx
@@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor'))
|
|||||||
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
|
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
|
||||||
import { insertPlainText, insertLinkText } from './editor/paste'
|
import { insertPlainText, insertLinkText } from './editor/paste'
|
||||||
import { isImeComposing } from './utils/ime'
|
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 { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs'
|
||||||
import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
|
import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
|
||||||
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
|
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
|
||||||
@@ -472,11 +472,32 @@ export default function App() {
|
|||||||
|
|
||||||
const handleSidebarOpenFolder = useCallback(() => { void openFolder() }, [openFolder])
|
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 只把这份快照标为已保存,
|
// 审计修复:保存用调用时刻的快照;saved 只把这份快照标为已保存,
|
||||||
// 写盘期间新输入的内容保持「脏」,不会被误标、不会丢恢复记录。
|
// 写盘期间新输入的内容保持「脏」,不会被误标、不会丢恢复记录。
|
||||||
const snapshot = target.content
|
const snapshot = target.content
|
||||||
const oldKey = recoveryKey(target)
|
|
||||||
try {
|
try {
|
||||||
if (!tauriMode) {
|
if (!tauriMode) {
|
||||||
downloadTextFile(`${target.title}.md`, snapshot)
|
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`))
|
path = await pickSaveFile(path ?? (workspaceRoot ? joinPath(workspaceRoot, `${target.title}.md`) : `${target.title}.md`))
|
||||||
if (!path) return false
|
if (!path) return false
|
||||||
}
|
}
|
||||||
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
|
// 非 UTF-8 文档保存会转码为 UTF-8:首次保存前提示一次(按路径记已确认)。
|
||||||
dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path) })
|
// 自动保存不弹窗、也不静默转码——跳过本轮,等手动保存/关窗保存时提示。
|
||||||
// 落点变化(未命名首次保存、另存为)意味着磁盘上多/改了一个文件,刷新左侧文件树
|
if (target.encoding !== 'UTF-8' && !encodingAckedRef.current.has(path)) {
|
||||||
if (target.path !== path) setTreeRefresh((n) => n + 1)
|
if (fromAutosave) return false
|
||||||
const stat = await statFile(path)
|
setEncodingPrompt({ target, path, snapshot })
|
||||||
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
|
return false
|
||||||
// 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿
|
|
||||||
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)
|
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
|
||||||
|
await finishSave(target, path, snapshot)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e))
|
setError(String(e))
|
||||||
return false
|
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 requestClose = useCallback((ids: string[], after: () => void) => {
|
||||||
const dirtyOnes = state.tabs.filter((t) => ids.includes(t.id) && isDirty(t))
|
const dirtyOnes = state.tabs.filter((t) => ids.includes(t.id) && isDirty(t))
|
||||||
@@ -731,7 +762,7 @@ export default function App() {
|
|||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
for (const t of stateRef.current.tabs) {
|
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)
|
}, Math.max(10, ui.autosaveSeconds) * 1000)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
@@ -1363,7 +1394,7 @@ export default function App() {
|
|||||||
}, [tauriMode, flash])
|
}, [tauriMode, flash])
|
||||||
|
|
||||||
anyModalRef.current = Boolean(
|
anyModalRef.current = Boolean(
|
||||||
modal || appearanceOpen || exportOpen || pendingClose || conflict
|
modal || appearanceOpen || exportOpen || pendingClose || conflict || encodingPrompt
|
||||||
|| promptSpec || confirmSpec || quitOpen || gitSettingsOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
|
|| promptSpec || confirmSpec || quitOpen || gitSettingsOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
|
||||||
)
|
)
|
||||||
findOpenRef.current = findUi.open
|
findOpenRef.current = findUi.open
|
||||||
@@ -1857,6 +1888,15 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{error && <ErrorModal message={error} onClose={() => setError(null)} />}
|
{error && <ErrorModal message={error} onClose={() => setError(null)} />}
|
||||||
|
{encodingPrompt && (
|
||||||
|
<EncodingConvertModal
|
||||||
|
encoding={encodingPrompt.target.encoding}
|
||||||
|
fileName={baseName(encodingPrompt.path)}
|
||||||
|
onConfirm={() => { void confirmEncodingSave() }}
|
||||||
|
onCancel={() => setEncodingPrompt(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
|
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
|
||||||
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
|
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
|
||||||
// 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。
|
// 选中 --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'
|
import { useDismiss } from '../hooks/useDismiss'
|
||||||
|
|
||||||
export interface DropdownOption { value: string; label: string }
|
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 }: {
|
export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||||
value: string
|
value: string
|
||||||
options: DropdownOption[]
|
options: DropdownOption[]
|
||||||
@@ -14,13 +24,35 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
|||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [pos, setPos] = useState<MenuPos | null>(null)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const close = useCallback(() => setOpen(false), [])
|
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 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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -29,6 +61,14 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open ])
|
}, [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 pick = (v: string) => { setOpen(false); onChange(v) }
|
||||||
|
|
||||||
const onMenuKey = (e: React.KeyboardEvent) => {
|
const onMenuKey = (e: React.KeyboardEvent) => {
|
||||||
@@ -46,17 +86,23 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
|||||||
<div className="dropdown" ref={ref}>
|
<div className="dropdown" ref={ref}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={btnRef}
|
||||||
className="dropdown-button"
|
className="dropdown-button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => { if (open) setOpen(false); else openMenu() }}
|
||||||
>
|
>
|
||||||
<span className="dropdown-label">{current?.label ?? ''}</span>
|
<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>
|
<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>
|
</button>
|
||||||
{open && (
|
{open && pos && createPortal(
|
||||||
<div className="dropdown-menu" ref={menuRef} onKeyDown={onMenuKey}>
|
<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) => (
|
{options.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={o.value}
|
key={o.value}
|
||||||
@@ -67,7 +113,8 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
|||||||
{o.label}
|
{o.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function GitSyncModal({ isRepo, branch, initialRemoteUrl, initialInterval
|
|||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={remoteUrl}
|
value={remoteUrl}
|
||||||
placeholder="https://git.xhome.cc/xieyinlu/notes.git"
|
placeholder="https://github.com/example/example.git"
|
||||||
onChange={(e) => setRemoteUrl(e.target.value)}
|
onChange={(e) => setRemoteUrl(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -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 }) {
|
export function AboutModal({ onClose }: { onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Shell onCancel={onClose}>
|
<Shell onCancel={onClose}>
|
||||||
|
|||||||
@@ -5,12 +5,22 @@ import { useEffect, type RefObject } from 'react'
|
|||||||
* 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
|
* 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
|
||||||
* 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行,
|
* 指针监听用捕获阶段:先于 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(() => {
|
useEffect(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const outside = (target: EventTarget | null) =>
|
const list = Array.isArray(refs) ? refs : [refs]
|
||||||
ref.current && target instanceof Node && !ref.current.contains(target)
|
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 onPointer = (e: MouseEvent) => { if (outside(e.target)) onClose() }
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||||
const onScroll = (e: Event) => { if (outside(e.target)) 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)
|
document.removeEventListener('scroll', onScroll, true)
|
||||||
window.removeEventListener('blur', onClose)
|
window.removeEventListener('blur', onClose)
|
||||||
}
|
}
|
||||||
}, [ref, active, onClose])
|
}, [refs, active, onClose])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export type TabsAction =
|
|||||||
| { type: 'open'; tab: DocTab }
|
| { type: 'open'; tab: DocTab }
|
||||||
| { type: 'activate'; id: string }
|
| { type: 'activate'; id: string }
|
||||||
| { type: 'updateContent'; id: string; content: 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: 'syncEditorContent'; id: string; content: string }
|
||||||
| { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean }
|
| { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean }
|
||||||
| { type: 'setPath'; id: string; path: string; title: string }
|
| { type: 'setPath'; id: string; path: string; title: string }
|
||||||
@@ -93,7 +93,7 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
|
|||||||
...state,
|
...state,
|
||||||
tabs: state.tabs.map((t) =>
|
tabs: state.tabs.map((t) =>
|
||||||
t.id === action.id
|
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,
|
: t,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:disabled { opacity: 0.5; cursor: default; }
|
||||||
.dropdown-button svg { flex: none; color: var(--text-dim); }
|
.dropdown-button svg { flex: none; color: var(--text-dim); }
|
||||||
.dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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 { 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:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; }
|
||||||
.dropdown-menu button.selected { background: var(--accent-weak); }
|
.dropdown-menu button.selected { background: var(--accent-weak); }
|
||||||
|
|||||||
@@ -22,18 +22,44 @@ describe('Dropdown', () => {
|
|||||||
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
|
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
|
||||||
expect(button.textContent).toContain('GitHub')
|
expect(button.textContent).toContain('GitHub')
|
||||||
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
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).not.toBeNull()
|
||||||
expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub')
|
expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub')
|
||||||
const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion')
|
const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion')
|
||||||
await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
expect(onChange).toHaveBeenCalledWith('notion')
|
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 })) })
|
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 })) })
|
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()
|
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() })
|
await act(async () => { root.unmount() })
|
||||||
container.remove()
|
container.remove()
|
||||||
})
|
})
|
||||||
|
|||||||
27
tests/encoding-modal.test.tsx
Normal file
27
tests/encoding-modal.test.tsx
Normal 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() })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user