fix(editor): 新文档回到顶部 + 超链接用系统浏览器打开 + 链接气泡仅选中时弹出
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
// main 进程入口:创建窗口 + 初始化库 + 注册 IPC + 迁移检测
|
// 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 nodePath from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
@@ -31,6 +31,19 @@ function createWindow() {
|
|||||||
});
|
});
|
||||||
if (!win) win = w;
|
if (!win) win = w;
|
||||||
w.on('ready-to-show', () => w.show());
|
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) => {
|
w.webContents.on('console-message', (e, level, message, line, sourceId) => {
|
||||||
const lv = ['log', 'info', 'warn', 'error'][level] || 'log';
|
const lv = ['log', 'info', 'warn', 'error'][level] || 'log';
|
||||||
|
|||||||
@@ -93,4 +93,13 @@ export function registerIpc(vault: VaultService) {
|
|||||||
if (!fs2) return;
|
if (!fs2) return;
|
||||||
shell.showItemInFolder(nodePath.join(fs2.getRoot(), p.path));
|
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 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();
|
const remove = () => editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||||
return (
|
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)' }}>
|
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(); } }}
|
<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' }} />
|
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;
|
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 }) => {
|
onUpdate: ({ editor }) => {
|
||||||
if (skipUpdate.current) return;
|
if (skipUpdate.current) return;
|
||||||
@@ -166,6 +179,12 @@ export function TiptapEditor() {
|
|||||||
skipUpdate.current = true;
|
skipUpdate.current = true;
|
||||||
editor.commands.setContent(body || '', { emitUpdate: false });
|
editor.commands.setContent(body || '', { emitUpdate: false });
|
||||||
skipUpdate.current = false;
|
skipUpdate.current = false;
|
||||||
|
// 切到新文档时回到顶部(编辑器实例复用,滚动容器不随文档重建,需手动复位)
|
||||||
|
const scroller = editor.view.dom.parentElement as HTMLElement | null;
|
||||||
|
if (scroller) {
|
||||||
|
// 内容换入后再复位,确保 scrollTop 真正落到顶部
|
||||||
|
requestAnimationFrame(() => { scroller.scrollTop = 0; });
|
||||||
|
}
|
||||||
}, [currentPath, editor]);
|
}, [currentPath, editor]);
|
||||||
|
|
||||||
// FR-E13:长文档底部书写——光标行距编辑区底部保持留白,避免输入内容贴着页面底缘
|
// FR-E13:长文档底部书写——光标行距编辑区底部保持留白,避免输入内容贴着页面底缘
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ test('FR-E6:链接气泡编辑(添加+移除)', async () => {
|
|||||||
let html: string = await a.page.evaluate(() => document.querySelector('.ProseMirror')?.innerHTML ?? '');
|
let html: string = await a.page.evaluate(() => document.querySelector('.ProseMirror')?.innerHTML ?? '');
|
||||||
expect(html).toContain('href="https://example.com"');
|
expect(html).toContain('href="https://example.com"');
|
||||||
|
|
||||||
|
// 气泡只在「选中链接文字」时弹出(纯点击链接改为调用系统浏览器打开)
|
||||||
await a.page.locator('.ProseMirror a').click();
|
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')).toBeVisible({ timeout: 5000 });
|
||||||
await expect(a.page.locator('.link-bubble input')).toHaveValue('https://example.com');
|
await expect(a.page.locator('.link-bubble input')).toHaveValue('https://example.com');
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const IPC = {
|
|||||||
appearanceSave: 'appearance:save',
|
appearanceSave: 'appearance:save',
|
||||||
keymapLoad: 'keymap:load',
|
keymapLoad: 'keymap:load',
|
||||||
keymapSave: 'keymap:save',
|
keymapSave: 'keymap:save',
|
||||||
|
openExternal: 'open:external',
|
||||||
} as const;
|
} as const;
|
||||||
export type IpcChannel = (typeof IPC)[keyof typeof IPC];
|
export type IpcChannel = (typeof IPC)[keyof typeof IPC];
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ export interface IpcMap {
|
|||||||
[IPC.appearanceSave]: { args: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }; result: void };
|
[IPC.appearanceSave]: { args: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }; result: void };
|
||||||
[IPC.keymapLoad]: { args: void; result: { id: string; combo: string }[] | null };
|
[IPC.keymapLoad]: { args: void; result: { id: string; combo: string }[] | null };
|
||||||
[IPC.keymapSave]: { args: { id: string; combo: string }[]; result: void };
|
[IPC.keymapSave]: { args: { id: string; combo: string }[]; result: void };
|
||||||
|
[IPC.openExternal]: { args: { url: string }; result: void };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 类型安全的 invoke 帮助器签名(preload 暴露 + 渲染层使用)
|
// 类型安全的 invoke 帮助器签名(preload 暴露 + 渲染层使用)
|
||||||
|
|||||||
Reference in New Issue
Block a user