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:
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 { insertPlainText, insertLinkText } from './editor/paste'
|
||||
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 { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text'
|
||||
import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
|
||||
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
|
||||
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'
|
||||
import * as store from './services/store'
|
||||
import { recoveryKey, planRecovery } from './utils/recovery'
|
||||
@@ -708,11 +708,47 @@ export default function App() {
|
||||
// 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。
|
||||
const startupFile = new URLSearchParams(window.location.search).get('file')
|
||||
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 }
|
||||
// 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(() => {
|
||||
if (workspaceRoot) {
|
||||
store.addWorkspace(workspaceRoot)
|
||||
@@ -1090,6 +1126,8 @@ export default function App() {
|
||||
/** 右键图片的「超链接」目标(图片模式链接弹窗)与「调整大小」目标(docs/31) */
|
||||
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)
|
||||
/** 文件关联注册后的引导文案(docs/32) */
|
||||
const [assocMsg, setAssocMsg] = useState<string | null>(null)
|
||||
const openEditorMenu = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const inEditor = sourceMode
|
||||
@@ -1586,6 +1624,8 @@ export default function App() {
|
||||
{ divider: true },
|
||||
{ label: '上传文档图片到 S3…', disabled: !tab || sourceMode, onClick: () => void runBatchImageUpload() },
|
||||
{ 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 && (
|
||||
<ImageSizeModal
|
||||
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 }) {
|
||||
return (
|
||||
<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 })
|
||||
}
|
||||
|
||||
/** 取走待打开文件队列:启动参数/双击第二个文件转来的 .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),返回回填地址。 */
|
||||
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
|
||||
return invoke<string>('s3_upload_base64', { config, base64, fileName })
|
||||
|
||||
Reference in New Issue
Block a user