fix(editor): 新文档回到顶部 + 超链接用系统浏览器打开 + 链接气泡仅选中时弹出
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<BubbleMenu editor={editor} shouldShow={({ editor }) => editor.isActive('link')} className="link-bubble"
|
||||
<BubbleMenu editor={editor} shouldShow={({ editor, state }) =>
|
||||
// 只在「选中了链接文字」时才弹出,避免光标停在链接上就弹气泡打断编辑
|
||||
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)' }}>
|
||||
<input value={href} onChange={e => 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' }} />
|
||||
|
||||
@@ -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:长文档底部书写——光标行距编辑区底部保持留白,避免输入内容贴着页面底缘
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user