diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d9a34a6..69743bf 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,5 +1,5 @@ // main 进程入口:创建窗口 + 初始化库 + 注册 IPC + 迁移检测 -import { app, BrowserWindow, ipcMain, protocol, net, dialog } from 'electron'; +import { app, BrowserWindow, ipcMain, protocol, net, dialog, shell } from 'electron'; import nodePath from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { promises as fs } from 'node:fs'; @@ -31,6 +31,19 @@ function createWindow() { }); if (!win) win = w; w.on('ready-to-show', () => w.show()); + // 安全:外部链接一律交给系统默认浏览器,不在应用窗口内导航(避免丢失编辑器状态) + w.webContents.setWindowOpenHandler(({ url }) => { + if (/^https?:\/\//i.test(url)) void shell.openExternal(url); + return { action: 'deny' }; + }); + w.webContents.on('will-navigate', (e, url) => { + const current = w.webContents.getURL(); + // 仅拦截跳转到其它 http(s) 站点的导航;应用自身 (vaultasset/file/app) 不拦 + if (/^https?:\/\//i.test(url) && url !== current) { + e.preventDefault(); + void shell.openExternal(url); + } + }); // 转发渲染进程控制台消息到主进程终端(便于开发调试) w.webContents.on('console-message', (e, level, message, line, sourceId) => { const lv = ['log', 'info', 'warn', 'error'][level] || 'log'; diff --git a/apps/desktop/src/main/ipc/handlers.ts b/apps/desktop/src/main/ipc/handlers.ts index 2652a28..f5ff419 100644 --- a/apps/desktop/src/main/ipc/handlers.ts +++ b/apps/desktop/src/main/ipc/handlers.ts @@ -93,4 +93,13 @@ export function registerIpc(vault: VaultService) { if (!fs2) return; shell.showItemInFolder(nodePath.join(fs2.getRoot(), p.path)); }); + + // 超链接用系统默认浏览器打开(不用 Electron 内置窗口) + ipcMain.handle(IPC.openExternal, async (_e, p: { url: string }) => { + const url = p?.url?.trim(); + if (!url) return; + // 仅允许 http/https,防止把本地协议或 file: 路径交给外部程序 + if (!/^https?:\/\//i.test(url)) return; + await shell.openExternal(url); + }); } \ No newline at end of file diff --git a/apps/desktop/src/renderer/features/editor/LinkBubbleMenu.tsx b/apps/desktop/src/renderer/features/editor/LinkBubbleMenu.tsx index fef287a..7c5b985 100644 --- a/apps/desktop/src/renderer/features/editor/LinkBubbleMenu.tsx +++ b/apps/desktop/src/renderer/features/editor/LinkBubbleMenu.tsx @@ -16,7 +16,9 @@ export function LinkBubbleMenu({ editor }: { editor: Editor }) { const apply = () => { const u = href.trim(); if (u) editor.chain().focus().extendMarkRange('link').setLink({ href: u }).run(); }; const remove = () => editor.chain().focus().extendMarkRange('link').unsetLink().run(); return ( - editor.isActive('link')} className="link-bubble" + + // 只在「选中了链接文字」时才弹出,避免光标停在链接上就弹气泡打断编辑 + editor.isActive('link') && !state.selection.empty} className="link-bubble" style={{ display: 'flex', gap: 4, alignItems: 'center', background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 6, padding: 4, boxShadow: '0 4px 16px rgba(0,0,0,0.4)' }}> setHref(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); apply(); } }} placeholder="链接地址" style={{ width: 180, background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', color: 'var(--text-primary)', borderRadius: 4, padding: '3px 8px', fontSize: 13, outline: 'none' }} /> diff --git a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx index c1a11bd..de65990 100644 --- a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx +++ b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx @@ -141,6 +141,19 @@ export function TiptapEditor() { } return false; }, + // 点击超链接 → 用系统默认浏览器打开(openOnClick 关闭,手动接管) + handleClick: (view, _pos, event) => { + const target = (event.target as HTMLElement | null)?.closest?.('a[href]'); + if (!target) return false; + const href = (target as HTMLAnchorElement).getAttribute('href') ?? ''; + // 仅接管纯单击(不含 Ctrl/Cmd/Shift/Alt),避免影响文字选取编辑 + if (/^https?:\/\//i.test(href) && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) { + event.preventDefault(); + void invoke(IPC.openExternal, { url: href }); + return true; + } + return false; + }, }, onUpdate: ({ editor }) => { if (skipUpdate.current) return; @@ -166,6 +179,12 @@ export function TiptapEditor() { skipUpdate.current = true; editor.commands.setContent(body || '', { emitUpdate: false }); skipUpdate.current = false; + // 切到新文档时回到顶部(编辑器实例复用,滚动容器不随文档重建,需手动复位) + const scroller = editor.view.dom.parentElement as HTMLElement | null; + if (scroller) { + // 内容换入后再复位,确保 scrollTop 真正落到顶部 + requestAnimationFrame(() => { scroller.scrollTop = 0; }); + } }, [currentPath, editor]); // FR-E13:长文档底部书写——光标行距编辑区底部保持留白,避免输入内容贴着页面底缘 diff --git a/apps/desktop/tests/e2e/editor-link-image.spec.ts b/apps/desktop/tests/e2e/editor-link-image.spec.ts index 5f6caf9..69efab4 100644 --- a/apps/desktop/tests/e2e/editor-link-image.spec.ts +++ b/apps/desktop/tests/e2e/editor-link-image.spec.ts @@ -46,7 +46,9 @@ test('FR-E6:链接气泡编辑(添加+移除)', async () => { let html: string = await a.page.evaluate(() => document.querySelector('.ProseMirror')?.innerHTML ?? ''); expect(html).toContain('href="https://example.com"'); + // 气泡只在「选中链接文字」时弹出(纯点击链接改为调用系统浏览器打开) await a.page.locator('.ProseMirror a').click(); + await a.page.keyboard.press('Control+A'); await expect(a.page.locator('.link-bubble')).toBeVisible({ timeout: 5000 }); await expect(a.page.locator('.link-bubble input')).toHaveValue('https://example.com'); diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index 6c0120a..82aeeb9 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -43,6 +43,7 @@ export const IPC = { appearanceSave: 'appearance:save', keymapLoad: 'keymap:load', keymapSave: 'keymap:save', + openExternal: 'open:external', } as const; export type IpcChannel = (typeof IPC)[keyof typeof IPC]; @@ -86,6 +87,7 @@ export interface IpcMap { [IPC.appearanceSave]: { args: { themeId: string; params: Record; effect: string; bgImage: string | null }; result: void }; [IPC.keymapLoad]: { args: void; result: { id: string; combo: string }[] | null }; [IPC.keymapSave]: { args: { id: string; combo: string }[]; result: void }; + [IPC.openExternal]: { args: { url: string }; result: void }; } // 类型安全的 invoke 帮助器签名(preload 暴露 + 渲染层使用)