feat(app): 支持设置 .md 默认打开方式(docs/32)
三条链路:①tauri.conf bundle.fileAssociations 注册 md/markdown, 新版安装包重装后出现在「打开方式」候选;②文件菜单「设为 .md 默认打开方式…」:Rust 命令经 reg.exe 写 HKCU 的 ProgID NewMarkdown.md/OpenWithProgids/SupportedTypes(免管理员、指向 当前 exe),再打开系统默认应用设置让用户确认一次(Windows 不许 程序直接抢默认);③双击打开:单实例插件转发第二实例参数到待 打开队列+聚焦主窗口,冷启动读 env args,共用 md_files_from_args 过滤;前端 take_pending_open_files 取走逐个打开,只由 main 窗口 消费防子窗口抢文件。命令数 31→33 对账,参数过滤纯函数已抽 crate 实测,vitest 154+2、构建通过;Windows 构建与双击全流程待真机验。
This commit is contained in:
48
docs/32-md文件关联.md
Normal file
48
docs/32-md文件关联.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# docs/32 .md 文件关联(默认打开方式)
|
||||||
|
|
||||||
|
2026-11-15 用户要求:「支持设置 md 格式的打开方式」。目标:在资源
|
||||||
|
管理器双击 .md 能用本程序打开,且程序内能发起设置。三条链路:
|
||||||
|
|
||||||
|
## 1. 安装器注册(tauri.conf.json)
|
||||||
|
|
||||||
|
`bundle.fileAssociations` 增加 md/markdown(name=NewMarkdown,
|
||||||
|
role=Editor)。NSIS/MSI 安装时写入关联注册信息,本程序出现在
|
||||||
|
「打开方式」候选里,用户在打开方式对话框勾「始终」或在系统设置
|
||||||
|
里选一次即设为默认。注意:需用新版安装包**重装一次**才生效。
|
||||||
|
|
||||||
|
## 2. 程序内发起设置(文件菜单)
|
||||||
|
|
||||||
|
「文件 → 设为 .md 默认打开方式…」(仅桌面模式):Rust 命令
|
||||||
|
`register_file_association` 用 reg.exe 写当前用户注册表(免管理员):
|
||||||
|
ProgID `NewMarkdown.md`(类型名、打开命令 `"<exe>" "%1"`、图标)、
|
||||||
|
.md/.markdown 的 OpenWithProgids、Applications 的 SupportedTypes 与
|
||||||
|
打开命令——便携版/未走安装器的场景也能注册,且指向当前 exe 路径。
|
||||||
|
写完后打开 `ms-settings:defaultapps` 并弹窗说明:**Windows 不允许
|
||||||
|
程序直接抢占默认**(UserChoice 带哈希校验),最后一步必须由用户
|
||||||
|
在设置里把 .md 选为 new-markdown,只需一次。
|
||||||
|
|
||||||
|
## 3. 双击真正打开文件
|
||||||
|
|
||||||
|
- 单实例插件(tauri-plugin-single-instance,须最先注册):程序已
|
||||||
|
运行时双击第二个文件不再起新进程,第二实例的参数经回调转入
|
||||||
|
待打开队列并发 `pending-open-files` 事件,同时聚焦主窗口。
|
||||||
|
- 冷启动:setup 里读 `std::env::args`,与第二实例共用纯函数
|
||||||
|
`md_files_from_args` 过滤(跳过开关项、只收存在的 .md/.markdown),
|
||||||
|
存入 `PendingOpen` 队列。
|
||||||
|
- 前端:新命令 `take_pending_open_files` 取走队列并逐个 openPath。
|
||||||
|
主窗口在启动流程末尾拉一次(冷启动),并监听事件随时拉(热转
|
||||||
|
发)。**只由 label=main 的窗口消费**:子窗口(?file= 打开的)跑
|
||||||
|
同一套 App,不拦会把文件抢到子窗口里。
|
||||||
|
|
||||||
|
命令总数 31 → 33。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 参数过滤纯函数抽入独立 crate 实测:开关项/.txt/不存在的文件被
|
||||||
|
排除,.md 与 .markdown 存在文件入选。
|
||||||
|
- rustfmt 语法解析通过;命令数对账 33=33;tsc 0;vitest 全过
|
||||||
|
(InfoModal 新增 1 例);生产构建通过。
|
||||||
|
- 沙箱验不了(待真机):Windows 完整构建(含单实例插件与
|
||||||
|
cfg(windows) 的 reg.exe 分支)、安装包关联注册实效、双击冷启动
|
||||||
|
打开、已运行双击第二个文件转发到主窗口、菜单项注册后在系统
|
||||||
|
设置里选默认全流程。
|
||||||
463
src-tauri/Cargo.lock
generated
463
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
|
|||||||
@@ -941,6 +941,76 @@ pub async fn s3_upload_image(
|
|||||||
.map_err(|e| format!("上传任务失败:{e}"))?
|
.map_err(|e| format!("上传任务失败:{e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 取走待打开文件队列(docs/32):启动参数/第二实例双击转来的 .md 文件。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn take_pending_open_files(state: tauri::State<'_, crate::PendingOpen>) -> Vec<String> {
|
||||||
|
match state.0.lock() {
|
||||||
|
Ok(mut queue) => std::mem::take(&mut *queue),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把本程序注册为 .md/.markdown 的候选打开方式(docs/32),并打开 Windows
|
||||||
|
/// 「默认应用」设置页让用户确认。Windows 不允许程序直接抢占默认(UserChoice
|
||||||
|
/// 带校验),应用能做的是注册候选 + 引导用户点一次。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn register_file_association() -> Result<String, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(register_file_association_impl)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("注册任务失败:{e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn register_file_association_impl() -> Result<String, String> {
|
||||||
|
Err("文件关联设置仅支持 Windows。".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn register_file_association_impl() -> Result<String, String> {
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
use std::process::Command;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
let exe = std::env::current_exe().map_err(|e| format!("无法定位程序位置:{e}"))?;
|
||||||
|
let exe_s = exe.to_string_lossy().to_string();
|
||||||
|
let exe_name = exe
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "new-markdown.exe".into());
|
||||||
|
let prog_id = "NewMarkdown.md";
|
||||||
|
let open_cmd = format!("\"{exe_s}\" \"%1\"");
|
||||||
|
let mut failures: Vec<String> = Vec::new();
|
||||||
|
let mut reg = |args: &[&str]| {
|
||||||
|
match Command::new("reg.exe")
|
||||||
|
.args(args)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Ok(st) if st.success() => {}
|
||||||
|
Ok(st) => failures.push(format!("reg {:?} 退出码 {}", args.get(1), st.code().unwrap_or(-1))),
|
||||||
|
Err(e) => failures.push(format!("reg 执行失败:{e}")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// ProgID:类型名、打开命令、图标
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}"), "/ve", "/d", "Markdown 文档", "/f"]);
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}\shell\open\command"), "/ve", "/d", &open_cmd, "/f"]);
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}\DefaultIcon"), "/ve", "/d", &format!("\"{exe_s}\",0"), "/f"]);
|
||||||
|
for ext in [".md", ".markdown"] {
|
||||||
|
// 扩展名的候选列表 + 应用的 SupportedTypes(出现在「打开方式」里)
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\{ext}\OpenWithProgids"), "/v", prog_id, "/t", "REG_SZ", "/d", "", "/f"]);
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\Applications\{exe_name}\SupportedTypes"), "/v", ext, "/t", "REG_SZ", "/d", "", "/f"]);
|
||||||
|
}
|
||||||
|
reg(&["add", &format!(r"HKCU\Software\Classes\Applications\{exe_name}\shell\open\command"), "/ve", "/d", &open_cmd, "/f"]);
|
||||||
|
if !failures.is_empty() {
|
||||||
|
return Err(format!("注册表写入失败:{}", failures.join(";")));
|
||||||
|
}
|
||||||
|
// 打开系统「默认应用」设置,用户在那里把 .md 选成本程序(只需一次)
|
||||||
|
let _ = Command::new("explorer.exe")
|
||||||
|
.arg("ms-settings:defaultapps")
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.spawn();
|
||||||
|
Ok("已把 new-markdown 注册为 .md / .markdown 的候选打开方式,并打开了 Windows「默认应用」设置。请在设置里找到 .md,把默认应用选为 new-markdown(Windows 要求手动确认一次)。".into())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn s3_upload_base64(
|
pub async fn s3_upload_base64(
|
||||||
config: crate::s3::S3Config,
|
config: crate::s3::S3Config,
|
||||||
|
|||||||
@@ -4,11 +4,28 @@ mod search;
|
|||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::Manager;
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
/// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。
|
/// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。
|
||||||
pub struct AppDb(pub Mutex<rusqlite::Connection>);
|
pub struct AppDb(pub Mutex<rusqlite::Connection>);
|
||||||
|
|
||||||
|
/// 待打开文件队列(docs/32):启动参数与第二实例转发的 .md 文件先存这里,
|
||||||
|
/// 前端就绪后经 take_pending_open_files 命令取走打开。
|
||||||
|
pub struct PendingOpen(pub Mutex<Vec<String>>);
|
||||||
|
|
||||||
|
/// 从命令行参数里挑出要打开的 Markdown 文件:跳过开关项,只收扩展名
|
||||||
|
/// .md/.markdown 且确实存在的文件。
|
||||||
|
pub fn md_files_from_args<I: IntoIterator<Item = String>>(args: I) -> Vec<String> {
|
||||||
|
args.into_iter()
|
||||||
|
.filter(|a| !a.starts_with('-'))
|
||||||
|
.filter(|a| {
|
||||||
|
let lower = a.to_lowercase();
|
||||||
|
(lower.ends_with(".md") || lower.ends_with(".markdown"))
|
||||||
|
&& std::path::Path::new(a).is_file()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// 打开数据库并建表:WAL 降低并发写冲突,busy_timeout 让多开实例的写操作排队而非直接报错。
|
/// 打开数据库并建表:WAL 降低并发写冲突,busy_timeout 让多开实例的写操作排队而非直接报错。
|
||||||
fn open_and_init(path: &std::path::Path) -> Result<rusqlite::Connection, String> {
|
fn open_and_init(path: &std::path::Path) -> Result<rusqlite::Connection, String> {
|
||||||
let conn = rusqlite::Connection::open(path).map_err(|e| e.to_string())?;
|
let conn = rusqlite::Connection::open(path).map_err(|e| e.to_string())?;
|
||||||
@@ -22,6 +39,23 @@ fn open_and_init(path: &std::path::Path) -> Result<rusqlite::Connection, String>
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
// 单实例必须最先注册:双击第二个 .md 时不再起新进程,把文件转给
|
||||||
|
// 已运行的窗口打开(docs/32)。
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||||
|
let files = md_files_from_args(argv);
|
||||||
|
if !files.is_empty() {
|
||||||
|
if let Some(state) = app.try_state::<PendingOpen>() {
|
||||||
|
if let Ok(mut queue) = state.0.lock() {
|
||||||
|
queue.extend(files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = app.emit("pending-open-files", ());
|
||||||
|
}
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
let _ = w.unminimize();
|
||||||
|
let _ = w.set_focus();
|
||||||
|
}
|
||||||
|
}))
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let data_dir = app.path().app_data_dir().expect("app data dir");
|
let data_dir = app.path().app_data_dir().expect("app data dir");
|
||||||
@@ -50,6 +84,9 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
app.manage(AppDb(Mutex::new(conn)));
|
app.manage(AppDb(Mutex::new(conn)));
|
||||||
|
// 冷启动参数(双击 .md 打开本程序时,文件路径在这里)
|
||||||
|
let initial = md_files_from_args(std::env::args().skip(1));
|
||||||
|
app.manage(PendingOpen(Mutex::new(initial)));
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -70,6 +107,8 @@ pub fn run() {
|
|||||||
commands::s3_upload_image,
|
commands::s3_upload_image,
|
||||||
commands::s3_upload_base64,
|
commands::s3_upload_base64,
|
||||||
commands::s3_test_connection,
|
commands::s3_test_connection,
|
||||||
|
commands::take_pending_open_files,
|
||||||
|
commands::register_file_association,
|
||||||
commands::delete_to_trash,
|
commands::delete_to_trash,
|
||||||
commands::db_recent_list,
|
commands::db_recent_list,
|
||||||
commands::db_recent_add,
|
commands::db_recent_add,
|
||||||
|
|||||||
@@ -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","windows":{"nsis":{"languages":["SimpChinese"],"displayLanguageSelector":false},"wix":{"language":"zh-CN"}},"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},"wix":{"language":"zh-CN"}},"icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"],"fileAssociations":[{"ext":["md","markdown"],"name":"NewMarkdown","description":"Markdown 文档","role":"Editor"}]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"}
|
||||||
45
src/App.tsx
45
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 , EncodingConvertModal, ImageSizeModal, ImageLinkModal } from './components/Modals'
|
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal, ImageSizeModal, ImageLinkModal, InfoModal } 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'
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
|
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
|
||||||
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
|
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
|
||||||
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
|
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
|
||||||
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, type GitStatusInfo,
|
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, takePendingOpenFiles, registerFileAssociation, type GitStatusInfo,
|
||||||
} from './services/platform'
|
} from './services/platform'
|
||||||
import * as store from './services/store'
|
import * as store from './services/store'
|
||||||
import { recoveryKey, planRecovery } from './utils/recovery'
|
import { recoveryKey, planRecovery } from './utils/recovery'
|
||||||
@@ -708,11 +708,47 @@ export default function App() {
|
|||||||
// 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。
|
// 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。
|
||||||
const startupFile = new URLSearchParams(window.location.search).get('file')
|
const startupFile = new URLSearchParams(window.location.search).get('file')
|
||||||
if (!cancelled && startupFile) void openPath(startupFile)
|
if (!cancelled && startupFile) void openPath(startupFile)
|
||||||
|
// 双击 .md 冷启动:文件路径经启动参数进 Rust 的待打开队列(docs/32)。
|
||||||
|
// 只由主窗口消费——子窗口(?file= 打开的)也在跑同一套启动流程,
|
||||||
|
// 不拦会把队列里的文件抢去自己窗口打开。
|
||||||
|
if (!cancelled && tauriMode) {
|
||||||
|
try {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||||
|
if (getCurrentWindow().label === 'main') {
|
||||||
|
const pending = await takePendingOpenFiles()
|
||||||
|
for (const p of pending) { if (!cancelled) await openPath(p) }
|
||||||
|
}
|
||||||
|
} catch { /* 队列不可用时不影响正常启动 */ }
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 第二个文件双击转来时(单实例转发):Rust 发事件,这里取队列打开(docs/32)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tauriMode) return
|
||||||
|
let unlisten: (() => void) | undefined
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||||
|
if (getCurrentWindow().label !== 'main') return // 只由主窗口消费(理由同冷启动)
|
||||||
|
const { listen } = await import('@tauri-apps/api/event')
|
||||||
|
const un = await listen('pending-open-files', async () => {
|
||||||
|
try {
|
||||||
|
const pending = await takePendingOpenFiles()
|
||||||
|
for (const p of pending) await openPath(p)
|
||||||
|
} catch { /* 同上 */ }
|
||||||
|
})
|
||||||
|
if (cancelled) un()
|
||||||
|
else unlisten = un
|
||||||
|
} catch { /* 事件不可用时退化为仅冷启动打开 */ }
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true; unlisten?.() }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tauriMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workspaceRoot) {
|
if (workspaceRoot) {
|
||||||
store.addWorkspace(workspaceRoot)
|
store.addWorkspace(workspaceRoot)
|
||||||
@@ -1090,6 +1126,8 @@ export default function App() {
|
|||||||
/** 右键图片的「超链接」目标(图片模式链接弹窗)与「调整大小」目标(docs/31) */
|
/** 右键图片的「超链接」目标(图片模式链接弹窗)与「调整大小」目标(docs/31) */
|
||||||
const [imageLinkTarget, setImageLinkTarget] = useState<{ tabId: string; el: HTMLElement; src: string; href: string } | null>(null)
|
const [imageLinkTarget, setImageLinkTarget] = useState<{ tabId: string; el: HTMLElement; src: string; href: string } | null>(null)
|
||||||
const [imageSizeTarget, setImageSizeTarget] = useState<{ tabId: string; el: HTMLElement; width: string | null } | null>(null)
|
const [imageSizeTarget, setImageSizeTarget] = useState<{ tabId: string; el: HTMLElement; width: string | null } | null>(null)
|
||||||
|
/** 文件关联注册后的引导文案(docs/32) */
|
||||||
|
const [assocMsg, setAssocMsg] = useState<string | null>(null)
|
||||||
const openEditorMenu = (e: React.MouseEvent) => {
|
const openEditorMenu = (e: React.MouseEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
const inEditor = sourceMode
|
const inEditor = sourceMode
|
||||||
@@ -1586,6 +1624,8 @@ export default function App() {
|
|||||||
{ divider: true },
|
{ divider: true },
|
||||||
{ label: '上传文档图片到 S3…', disabled: !tab || sourceMode, onClick: () => void runBatchImageUpload() },
|
{ label: '上传文档图片到 S3…', disabled: !tab || sourceMode, onClick: () => void runBatchImageUpload() },
|
||||||
{ label: 'S3 上传设置…', onClick: () => setS3SettingsOpen(true) },
|
{ label: 'S3 上传设置…', onClick: () => setS3SettingsOpen(true) },
|
||||||
|
{ divider: true },
|
||||||
|
{ label: '设为 .md 默认打开方式…', disabled: !tauriMode, onClick: () => { void (async () => { try { setAssocMsg(await registerFileAssociation()) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } })() } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2091,6 +2131,7 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{assocMsg && <InfoModal title="文件关联" message={assocMsg} onClose={() => setAssocMsg(null)} />}
|
||||||
{imageSizeTarget && (
|
{imageSizeTarget && (
|
||||||
<ImageSizeModal
|
<ImageSizeModal
|
||||||
current={imageSizeTarget.width}
|
current={imageSizeTarget.width}
|
||||||
|
|||||||
@@ -260,6 +260,16 @@ export function ImageLinkModal({ src, linkHref, onSubmit, onCancel }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InfoModal({ title, message, onClose }: { title: string; message: string; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<Shell onCancel={onClose}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{message}</p>
|
||||||
|
<div className="modal-actions"><button className="primary" onClick={onClose}>知道了</button></div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
|
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Shell onCancel={onClose}>
|
<Shell onCancel={onClose}>
|
||||||
|
|||||||
@@ -366,6 +366,16 @@ export async function s3UploadImage(config: S3ConfigPayload, filePath: string, f
|
|||||||
return invoke<string>('s3_upload_image', { config, filePath, fileName })
|
return invoke<string>('s3_upload_image', { config, filePath, fileName })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 取走待打开文件队列:启动参数/双击第二个文件转来的 .md 路径(docs/32)。 */
|
||||||
|
export async function takePendingOpenFiles(): Promise<string[]> {
|
||||||
|
return invoke<string[]>('take_pending_open_files')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册 .md/.markdown 文件关联并打开系统默认应用设置,返回引导文案(docs/32)。 */
|
||||||
|
export async function registerFileAssociation(): Promise<string> {
|
||||||
|
return invoke<string>('register_file_association')
|
||||||
|
}
|
||||||
|
|
||||||
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
|
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
|
||||||
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
|
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
|
||||||
return invoke<string>('s3_upload_base64', { config, base64, fileName })
|
return invoke<string>('s3_upload_base64', { config, base64, fileName })
|
||||||
|
|||||||
24
tests/info-modal.test.tsx
Normal file
24
tests/info-modal.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// 通用信息弹窗(docs/32 文件关联引导用):标题/正文渲染,点「知道了」关闭。
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { act } from 'react-dom/test-utils'
|
||||||
|
import { InfoModal } from '../src/components/Modals'
|
||||||
|
|
||||||
|
describe('InfoModal', () => {
|
||||||
|
it('渲染标题与正文,点知道了回调关闭', async () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
const root = createRoot(container)
|
||||||
|
const onClose = vi.fn()
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<InfoModal title="文件关联" message="已注册为候选打开方式。" onClose={onClose} />)
|
||||||
|
})
|
||||||
|
expect(container.textContent).toContain('文件关联')
|
||||||
|
expect(container.textContent).toContain('已注册为候选打开方式。')
|
||||||
|
const btn = [...container.querySelectorAll('button')].find((b) => b.textContent === '知道了')!
|
||||||
|
await act(async () => { btn.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
|
await act(async () => { root.unmount() })
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user