主题与快捷键:透明主题真透明 + 完整 Typora 快捷键
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
// main 进程入口:创建窗口 + 初始化库 + 注册 IPC + 迁移检测
|
||||
import { app, BrowserWindow, ipcMain, protocol, net } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, protocol, net, dialog } from 'electron';
|
||||
import nodePath from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { VaultService } from './services/vault-service.js';
|
||||
import { registerIpc } from './ipc/handlers.js';
|
||||
import { IPC } from '@hunter/shared';
|
||||
@@ -14,35 +15,56 @@ protocol.registerSchemesAsPrivileged([
|
||||
]);
|
||||
|
||||
let win: BrowserWindow | null = null;
|
||||
// 多窗口支持:窗口控制作用于当前聚焦窗口,其次主窗口
|
||||
const focusedWin = () => BrowserWindow.getFocusedWindow() ?? win;
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
const w = new BrowserWindow({
|
||||
width: 1200, height: 760, minWidth: 900, minHeight: 580,
|
||||
center: true, frame: false, show: false,
|
||||
transparent: true, backgroundColor: '#00000000', backgroundMaterial: 'acrylic',
|
||||
transparent: true, backgroundColor: '#00000000', backgroundMaterial: 'none',
|
||||
// 真透明:透明主题下窗口直接透出下层软件(不用 acrylic 模糊,保证可清晰看到背后内容)
|
||||
webPreferences: {
|
||||
preload: nodePath.join(__dirname, '../preload/index.mjs'),
|
||||
contextIsolation: true, nodeIntegration: false, sandbox: false
|
||||
}
|
||||
});
|
||||
win.on('ready-to-show', () => win?.show());
|
||||
if (!win) win = w;
|
||||
w.on('ready-to-show', () => w.show());
|
||||
// 转发渲染进程控制台消息到主进程终端(便于开发调试)
|
||||
win.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';
|
||||
console.log('[renderer:' + lv + '] ' + message + (sourceId ? ' (' + sourceId + ':' + line + ')' : ''));
|
||||
});
|
||||
win.on('closed', () => { win = null; });
|
||||
// 窗口控制(FR-W2)
|
||||
ipcMain.on(IPC.winMinimize, () => win?.minimize());
|
||||
ipcMain.on(IPC.winMaximize, () => { if (win?.isMaximized()) win.unmaximize(); else win?.maximize(); });
|
||||
// FR-W2:返回窗口最大化状态供 TitleBar 切换图标
|
||||
ipcMain.handle(IPC.winIsMaximized, () => win?.isMaximized() ?? false);
|
||||
ipcMain.on(IPC.winClose, () => win?.close());
|
||||
if (process.env.ELECTRON_RENDERER_URL) win.loadURL(process.env.ELECTRON_RENDERER_URL);
|
||||
else win.loadFile(nodePath.join(__dirname, '../renderer/index.html'));
|
||||
return win;
|
||||
w.on('closed', () => { if (win === w) win = null; });
|
||||
if (process.env.ELECTRON_RENDERER_URL) w.loadURL(process.env.ELECTRON_RENDERER_URL);
|
||||
else w.loadFile(nodePath.join(__dirname, '../renderer/index.html'));
|
||||
return w;
|
||||
}
|
||||
|
||||
// 窗口控制(FR-W2 + 多窗口安全 + Typora 快捷键:全屏 F11 / 新窗口 Ctrl+Shift+N)
|
||||
ipcMain.on(IPC.winMinimize, () => focusedWin()?.minimize());
|
||||
ipcMain.on(IPC.winMaximize, () => { const w = focusedWin(); if (w?.isMaximized()) w.unmaximize(); else w?.maximize(); });
|
||||
ipcMain.handle(IPC.winIsMaximized, () => focusedWin()?.isMaximized() ?? false);
|
||||
ipcMain.on(IPC.winClose, () => focusedWin()?.close());
|
||||
ipcMain.on(IPC.winFullscreen, () => { const w = focusedWin(); if (w) w.setFullScreen(!w.isFullScreen()); });
|
||||
ipcMain.on(IPC.winNew, () => { createWindow(); });
|
||||
// Ctrl+O 打开文件(选 .md → 返回绝对路径,渲染层换算为库内相对路径)
|
||||
ipcMain.handle(IPC.fileOpen, async () => {
|
||||
const w = focusedWin();
|
||||
const r = await dialog.showOpenDialog(w ?? undefined as never, { properties: ['openFile'], filters: [{ name: 'Markdown', extensions: ['md'] }] });
|
||||
if (r.canceled || r.filePaths.length === 0) return null;
|
||||
return r.filePaths[0];
|
||||
});
|
||||
// Ctrl+Shift+S 另存为:弹保存对话框写入正文副本
|
||||
ipcMain.handle(IPC.docSaveAs, async (_e, payload: { body: string; title: string }) => {
|
||||
const w = focusedWin();
|
||||
const r = await dialog.showSaveDialog(w ?? undefined as never, { defaultPath: (payload.title || 'untitled') + '.md', filters: [{ name: 'Markdown', extensions: ['md'] }] });
|
||||
if (r.canceled || !r.filePath) return null;
|
||||
await fs.writeFile(r.filePath, payload.body, 'utf8');
|
||||
return r.filePath;
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const vault = new VaultService();
|
||||
await vault.init();
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useKeymap } from './features/keymap/keymap-service';
|
||||
import { useLayout } from './features/shell/layout-store';
|
||||
import { Resizer } from './features/shell/Resizer';
|
||||
import { StatusBar } from './features/shell/StatusBar';
|
||||
import { useTypewriter } from './features/editor/typewriter-store';
|
||||
import { EffectLayer } from './features/effects/EffectLayer';
|
||||
import { useSettings } from './features/settings/settings-store';
|
||||
import { SearchBox } from './features/search/SearchBox';
|
||||
@@ -75,14 +76,63 @@ export function App() {
|
||||
},
|
||||
onSave: () => { window.dispatchEvent(new CustomEvent('dsh-flush-body')); useStore.getState().saveNow(); },
|
||||
onFocusSearch: () => { const el = document.querySelector('input[placeholder*="搜索"]') as HTMLInputElement; el?.focus(); },
|
||||
onToggleOutline: () => layout.toggleOutline(),
|
||||
onToggleOutline: () => { setRightTab('outline'); layout.toggleOutline(); },
|
||||
onToggleInfo: () => { setRightTab('info'); if (!layout.outlineVisible) layout.toggleOutline(); },
|
||||
onToggleSidebar: () => layout.toggleSidebar(),
|
||||
onFileTree: () => layout.toggleSidebar(),
|
||||
onCheat: () => setShowCheat(true),
|
||||
onSettings: () => setShowSettings(true),
|
||||
onFind: () => { window.dispatchEvent(new CustomEvent('dsh-toggle-find')); },
|
||||
onReplace: () => { window.dispatchEvent(new CustomEvent('dsh-toggle-find')); },
|
||||
onClearFormat: () => {
|
||||
const ed = useEditorShared.getState().editor;
|
||||
ed?.chain().focus().clearNodes().unsetAllMarks().run();
|
||||
},
|
||||
onCloseTab: () => {
|
||||
const t = useTabs.getState();
|
||||
if (t.activeId) t.closeTab(t.activeId);
|
||||
},
|
||||
onReopenTab: () => {
|
||||
const t = useTabs.getState();
|
||||
const last = t.popClosed();
|
||||
if (last) t.openTab(last.path);
|
||||
},
|
||||
onOpenFile: async () => {
|
||||
const abs = await invoke(IPC.fileOpen, undefined as never);
|
||||
if (!abs) return;
|
||||
const root = useStore.getState().vault?.currentRoot ?? '';
|
||||
const norm = abs.replace(/\\/g, '/');
|
||||
let rel = norm;
|
||||
if (root) {
|
||||
const prefix = root.replace(/\\/g, '/') + '/';
|
||||
if (norm.startsWith(prefix)) rel = norm.slice(prefix.length);
|
||||
}
|
||||
if (rel && !rel.startsWith('..')) await useStore.getState().openDoc(rel);
|
||||
},
|
||||
onQuickOpen: () => { const el = document.querySelector('input[placeholder*="搜索"]') as HTMLInputElement; el?.focus(); },
|
||||
onNewWindow: () => { void invoke(IPC.winNew, undefined as never); },
|
||||
onSaveAs: async () => {
|
||||
const ed = useEditorShared.getState().editor;
|
||||
if (!ed) return;
|
||||
const body = (ed.storage as any)?.markdown?.getMarkdown?.() ?? '';
|
||||
const title = useStore.getState().currentPath?.split('/').pop()?.replace(/\.md$/, '') ?? 'untitled';
|
||||
await invoke(IPC.docSaveAs, { body, title });
|
||||
},
|
||||
onCopyMd: async () => {
|
||||
const path = useStore.getState().currentPath;
|
||||
if (!path) return;
|
||||
try { const md = await invoke(IPC.copyMd, { path }); await navigator.clipboard.writeText(md); } catch { /* 剪贴板写入失败忽略 */ }
|
||||
},
|
||||
onPastePlain: async () => {
|
||||
const ed = useEditorShared.getState().editor;
|
||||
if (!ed) return;
|
||||
try { const text = await navigator.clipboard.readText(); ed.chain().focus().insertContent(text).run(); } catch { /* 剪贴板读取失败忽略 */ }
|
||||
},
|
||||
onInsertLink: () => { window.dispatchEvent(new CustomEvent('dsh-insert-link')); },
|
||||
onInsertImage: () => { window.dispatchEvent(new CustomEvent('dsh-insert-image')); },
|
||||
onFullscreen: () => { void invoke(IPC.winFullscreen, undefined as never); },
|
||||
onFocusMode: () => layout.toggleMinimal(),
|
||||
onTypewriter: () => useTypewriter.getState().toggle(),
|
||||
});
|
||||
|
||||
// 编辑器样式随设置生效(FR-编辑器设置真实生效)
|
||||
|
||||
@@ -3,12 +3,15 @@ import { Fragment, type Node } from '@tiptap/pm/model';
|
||||
import { TextSelection } from '@tiptap/pm/state';
|
||||
|
||||
// 块级操作(FR-E7):Alt+↑/↓ 上移/下移当前顶层块,Alt+Backspace 删除当前块
|
||||
// Typora 标准:Ctrl+Shift+↑/↓ 段落上移/下移(同时保留 Alt+↑/↓ 兼容旧习惯)
|
||||
export const BlockOps = Extension.create({
|
||||
name: 'blockOps',
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Alt-ArrowUp': () => moveBlock(this.editor, 'up'),
|
||||
'Alt-ArrowDown': () => moveBlock(this.editor, 'down'),
|
||||
'Mod-Shift-ArrowUp': () => moveBlock(this.editor, 'up'),
|
||||
'Mod-Shift-ArrowDown': () => moveBlock(this.editor, 'down'),
|
||||
'Alt-Backspace': () => deleteBlock(this.editor),
|
||||
};
|
||||
},
|
||||
@@ -27,7 +30,7 @@ function topBlock(editor: { state: any }): { start: number; end: number; node: N
|
||||
return { start, end: start + node.nodeSize, node, idx };
|
||||
}
|
||||
|
||||
function moveBlock(editor: any, dir: 'up' | 'down'): boolean {
|
||||
export function moveBlock(editor: any, dir: 'up' | 'down'): boolean {
|
||||
const { doc, tr } = editor.state;
|
||||
const r = topBlock(editor);
|
||||
if (!r || r.idx < 0) return false;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { Editor } from '@tiptap/react';
|
||||
import { useStore } from '../../store';
|
||||
import { useEditorRef } from './editor-context';
|
||||
@@ -44,6 +45,14 @@ export function EditorToolbar() {
|
||||
if (!url) { editor.commands.focus(); return; }
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
};
|
||||
// Typora:Ctrl+K 插入链接 / Ctrl+Shift+I 插入图片(由全局热键派发事件)
|
||||
useEffect(() => {
|
||||
const onLink = () => { void link(); };
|
||||
const onImg = () => { void pickImage(); };
|
||||
window.addEventListener('dsh-insert-link', onLink);
|
||||
window.addEventListener('dsh-insert-image', onImg);
|
||||
return () => { window.removeEventListener('dsh-insert-link', onLink); window.removeEventListener('dsh-insert-image', onImg); };
|
||||
}, [editor]);
|
||||
return (
|
||||
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--control-border)', display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<StarButton />
|
||||
@@ -53,7 +62,7 @@ export function EditorToolbar() {
|
||||
<T title="加粗 Ctrl+B" onClick={cmd(e => e.chain().focus().toggleBold().run())} disabled={d}><Bold size={16} /></T>
|
||||
<T title="斜体 Ctrl+I" onClick={cmd(e => e.chain().focus().toggleItalic().run())} disabled={d}><Italic size={16} /></T>
|
||||
<T title="删除线 Alt+Shift+5" onClick={cmd(e => e.chain().focus().toggleStrike().run())} disabled={d}><Strikethrough size={16} /></T>
|
||||
<T title="行内代码 Ctrl+E" onClick={cmd(e => e.chain().focus().toggleCode().run())} disabled={d}><Code size={16} /></T>
|
||||
<T title="行内代码 Ctrl+Shift+`" onClick={cmd(e => e.chain().focus().toggleCode().run())} disabled={d}><Code size={16} /></T>
|
||||
<T title="清除格式" onClick={cmd(e => e.chain().focus().unsetAllMarks().run())} disabled={d}><Eraser size={16} /></T>
|
||||
<T title="引用 Ctrl+Shift+Q" onClick={cmd(e => e.chain().focus().toggleBlockquote().run())} disabled={d}><Quote size={16} /></T>
|
||||
<span style={{ width: 1, background: 'var(--control-border)', height: 20, margin: '0 4px' }} />
|
||||
@@ -61,10 +70,10 @@ export function EditorToolbar() {
|
||||
<T title="二级标题 Ctrl+2" onClick={cmd(e => e.chain().focus().toggleHeading({ level: 2 }).run())} disabled={d}><Heading2 size={16} /></T>
|
||||
<T title="三级标题 Ctrl+3" onClick={cmd(e => e.chain().focus().toggleHeading({ level: 3 }).run())} disabled={d}><Heading3 size={16} /></T>
|
||||
<span style={{ width: 1, background: 'var(--control-border)', height: 20, margin: '0 4px' }} />
|
||||
<T title="无序列表 Ctrl+L" onClick={cmd(e => e.chain().focus().toggleBulletList().run())} disabled={d}><List size={16} /></T>
|
||||
<T title="有序列表 Ctrl+Shift+O" onClick={cmd(e => e.chain().focus().toggleOrderedList().run())} disabled={d}><ListOrdered size={16} /></T>
|
||||
<T title="代码块 Ctrl+Shift+M" onClick={cmd(e => e.chain().focus().toggleCodeBlock().run())} disabled={d}><SquareCode size={16} /></T>
|
||||
<T title="表格 Ctrl+Shift+T" onClick={cmd(e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())} disabled={d}><Table size={16} /></T>
|
||||
<T title="无序列表 Ctrl+Shift+]" onClick={cmd(e => e.chain().focus().toggleBulletList().run())} disabled={d}><List size={16} /></T>
|
||||
<T title="有序列表 Ctrl+Shift+[" onClick={cmd(e => e.chain().focus().toggleOrderedList().run())} disabled={d}><ListOrdered size={16} /></T>
|
||||
<T title="代码块 Ctrl+Shift+K" onClick={cmd(e => e.chain().focus().toggleCodeBlock().run())} disabled={d}><SquareCode size={16} /></T>
|
||||
<T title="表格 Ctrl+T" onClick={cmd(e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())} disabled={d}><Table size={16} /></T>
|
||||
<span style={{ width: 1, background: 'var(--control-border)', height: 20, margin: '0 4px' }} />
|
||||
<T title="链接" onClick={() => { void link(); }} disabled={d}><Link size={15} /><span style={{ marginLeft: 3 }}>链接</span></T>
|
||||
<T title="插入图片(本地选图入库)" onClick={() => { void pickImage(); }} disabled={d}><ImagePlus size={15} /><span style={{ marginLeft: 3 }}>图片</span></T>
|
||||
|
||||
@@ -22,6 +22,8 @@ import { EditorToolbar } from './EditorToolbar';
|
||||
import { FindReplace, FindReplaceHighlight } from './FindReplace';
|
||||
import { SlashMenu } from './SlashMenu';
|
||||
import { BlockOps } from './BlockOps';
|
||||
import { TyporaKeys, Underline } from './TyporaKeys';
|
||||
import { useTypewriter } from './typewriter-store';
|
||||
import { CodeBlockView } from './CodeBlockView';
|
||||
import { ImageView } from './ImageView';
|
||||
import { LinkBubbleMenu } from './LinkBubbleMenu';
|
||||
@@ -62,6 +64,8 @@ export function TiptapEditor() {
|
||||
FindReplaceHighlight,
|
||||
MathBlock,
|
||||
InlineMath,
|
||||
Underline,
|
||||
TyporaKeys, // 放在最后:覆盖 Tiptap 默认快捷键为 Typora 标准
|
||||
],
|
||||
content: useStore.getState().body,
|
||||
editorProps: {
|
||||
@@ -137,8 +141,14 @@ export function TiptapEditor() {
|
||||
if (!sel.empty) return;
|
||||
const caret = editor.view.coordsAtPos(sel.from);
|
||||
const rect = scroller.getBoundingClientRect();
|
||||
const delta = caret.bottom - (rect.bottom - BOTTOM_MARGIN);
|
||||
if (delta > 0) scroller.scrollTop += delta;
|
||||
// F9 打字机模式:光标行垂直居中(默认是底部留白)
|
||||
if (useTypewriter.getState().enabled) {
|
||||
const delta = caret.bottom - (rect.top + rect.height / 2);
|
||||
if (Math.abs(delta) > 1) scroller.scrollTop += delta;
|
||||
} else {
|
||||
const delta = caret.bottom - (rect.bottom - BOTTOM_MARGIN);
|
||||
if (delta > 0) scroller.scrollTop += delta;
|
||||
}
|
||||
} catch { /* 编辑器销毁等异常:忽略 */ }
|
||||
};
|
||||
const syncActiveLine = () => {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Extension, Mark, mergeAttributes } from '@tiptap/core';
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
// ── 下划线 mark(Typora Ctrl+U)──
|
||||
export const Underline = Mark.create({
|
||||
name: 'underline',
|
||||
parseHTML() { return [{ tag: 'u' }]; },
|
||||
renderHTML({ HTMLAttributes }) { return ['u', mergeAttributes(HTMLAttributes), 0]; },
|
||||
addCommands() {
|
||||
return {
|
||||
toggleUnderline: () => ({ commands }: any) => commands.toggleMark('underline'),
|
||||
} as any;
|
||||
},
|
||||
});
|
||||
|
||||
// ── TyporaKeys:把编辑器排版/段落快捷键统一为 Typora 标准(覆盖 Tiptap 默认绑定)──
|
||||
// 说明:查找/替换/链接/图片/纯文本/清除格式由全局 use-hotkeys 处理,此处不重复绑定
|
||||
export const TyporaKeys = Extension.create({
|
||||
name: 'typoraKeys',
|
||||
addKeyboardShortcuts() {
|
||||
const ed = () => this.editor;
|
||||
return {
|
||||
'Mod-b': () => ed().commands.toggleBold(),
|
||||
'Mod-i': () => ed().commands.toggleItalic(),
|
||||
'Mod-u': () => ed().commands.toggleMark('underline'),
|
||||
'Alt-Shift-5': () => ed().commands.toggleStrike(),
|
||||
'Mod-Shift-`': () => ed().commands.toggleCode(),
|
||||
'Mod-Shift-k': () => ed().commands.toggleCodeBlock(),
|
||||
'Mod-Shift-q': () => ed().commands.toggleBlockquote(),
|
||||
'Mod-Shift-]': () => ed().commands.toggleBulletList(),
|
||||
'Mod-Shift-[': () => ed().commands.toggleOrderedList(),
|
||||
'Mod-t': () => ed().commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true }),
|
||||
'Mod-Shift-m': () => ed().commands.insertContent({ type: 'mathBlock' }),
|
||||
'Mod-=': () => headingAdjust(ed(), 1),
|
||||
'Mod--': () => headingAdjust(ed(), -1),
|
||||
'Mod-]': () => indentBlock(ed(), 1),
|
||||
'Mod-[': () => indentBlock(ed(), -1),
|
||||
'Mod-l': () => selectLine(ed()),
|
||||
'Mod-d': () => selectWord(ed()),
|
||||
'Mod-e': () => selectSameFormat(ed()),
|
||||
'Mod-Shift-d': () => deleteWord(ed()),
|
||||
'Mod-Home': () => docJump(ed(), 'start'),
|
||||
'Mod-End': () => docJump(ed(), 'end'),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Ctrl+= 标题级别升高(数字减小)/ Ctrl+- 降低;普通段落按 + 升为二级标题
|
||||
function headingAdjust(editor: Editor, dir: number): boolean {
|
||||
const { state } = editor;
|
||||
const $pos = state.doc.resolve(state.selection.from);
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
const node = $pos.node(d);
|
||||
if (node.type.name === 'heading') {
|
||||
const next = node.attrs.level - dir;
|
||||
if (next >= 1 && next <= 6) return editor.commands.setHeading({ level: next as 1 | 2 | 3 | 4 | 5 | 6 });
|
||||
return false;
|
||||
}
|
||||
if (node.type.name === 'paragraph') {
|
||||
return dir > 0 ? editor.commands.setHeading({ level: 2 }) : false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+] 增加缩进 / Ctrl+[ 减少缩进(列表内生效:sink/lift)
|
||||
function indentBlock(editor: Editor, dir: number): boolean {
|
||||
const { state } = editor;
|
||||
const $pos = state.doc.resolve(state.selection.from);
|
||||
let inList = false;
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
const t = $pos.node(d).type.name;
|
||||
if (t === 'bulletList' || t === 'orderedList' || t === 'listItem') { inList = true; break; }
|
||||
}
|
||||
if (!inList) return false;
|
||||
return dir > 0 ? editor.commands.sinkListItem('listItem') : editor.commands.liftListItem('listItem');
|
||||
}
|
||||
|
||||
// Ctrl+L 选中当前行/块
|
||||
function selectLine(editor: Editor): boolean {
|
||||
const { state } = editor;
|
||||
const $pos = state.doc.resolve(state.selection.from);
|
||||
const depth = $pos.depth;
|
||||
if (depth < 1) return false;
|
||||
const start = $pos.start(depth), end = $pos.end(depth);
|
||||
if (end > start) return editor.commands.setTextSelection({ from: start, to: end });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+D 选中当前单词(支持中英文)
|
||||
function selectWord(editor: Editor): boolean {
|
||||
const { state } = editor;
|
||||
const from = state.selection.from;
|
||||
const before = state.doc.textBetween(Math.max(0, from - 100), from);
|
||||
const after = state.doc.textBetween(from, Math.min(state.doc.content.size, from + 100));
|
||||
const word = '[A-Za-z0-9_\u4e00-\u9fa5]';
|
||||
const m1 = new RegExp(word + '*$').exec(before);
|
||||
const m2 = new RegExp('^' + word + '*').exec(after);
|
||||
const start = from - (m1 ? m1[0].length : 0);
|
||||
const end = from + (m2 ? m2[0].length : 0);
|
||||
if (end > start) return editor.commands.setTextSelection({ from: start, to: end });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+E 选中相同格式文本(光标所在 mark 覆盖的文本范围)
|
||||
function selectSameFormat(editor: Editor): boolean {
|
||||
const { state } = editor;
|
||||
const { doc, selection } = state;
|
||||
const $pos = doc.resolve(selection.from);
|
||||
const fmt = $pos.marks().find(m => ['bold', 'italic', 'strike', 'code', 'underline', 'link'].includes(m.type.name));
|
||||
if (!fmt) return false;
|
||||
let bestFrom = -1, bestTo = -1;
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) return true;
|
||||
const mFrom = pos, mTo = pos + node.nodeSize;
|
||||
if (selection.from >= mFrom && selection.from <= mTo && node.marks.some(m => m.type.name === fmt.type.name)) {
|
||||
if (bestTo <= 0 || mTo - mFrom > bestTo - bestFrom) { bestFrom = mFrom; bestTo = mTo; }
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (bestTo > bestFrom) return editor.commands.setTextSelection({ from: bestFrom, to: bestTo });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+D 删除光标后的单词(无则删一个字符)
|
||||
function deleteWord(editor: Editor): boolean {
|
||||
const { state } = editor;
|
||||
const from = state.selection.from;
|
||||
const after = state.doc.textBetween(from, Math.min(state.doc.content.size, from + 100));
|
||||
const m = /^[A-Za-z0-9_\u4e00-\u9fa5]*/.exec(after);
|
||||
const end = from + (m ? m[0].length : 0);
|
||||
return editor.commands.deleteRange({ from, to: Math.max(end, from + 1) });
|
||||
}
|
||||
|
||||
// Ctrl+Home 文档开头 / Ctrl+End 文档末尾
|
||||
function docJump(editor: Editor, where: 'start' | 'end'): boolean {
|
||||
if (where === 'start') return editor.commands.setTextSelection(0);
|
||||
return editor.commands.setTextSelection(editor.state.doc.content.size);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// F9 打字机模式:光标行垂直居中滚动(区别于默认的底部留白)
|
||||
interface TypewriterState { enabled: boolean; toggle: () => void; set: (v: boolean) => void; }
|
||||
export const useTypewriter = create<TypewriterState>(set => ({ enabled: false, toggle: () => set(s => ({ enabled: !s.enabled })), set: v => set({ enabled: v }) }));
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useKeymap, formatCombo, type Scope } from './keymap-service';
|
||||
import { useKeymap, formatCombo } from './keymap-service';
|
||||
|
||||
const SCOPE_LABELS: Record<Scope, string> = { global: '全局', app: '应用级', editor: '编辑器' };
|
||||
const SCOPE_ORDER: Scope[] = ['global', 'app', 'editor'];
|
||||
// 按 Typora 官方菜单分组的快捷键速查
|
||||
const GROUPS: { label: string; ids: string[] }[] = [
|
||||
{ label: '📁 文件', ids: ['newDoc', 'newWindow', 'openFile', 'quickOpen', 'save', 'saveAs', 'toggleSettings', 'closeTab', 'reopenTab'] },
|
||||
{ label: '✏️ 基础编辑', ids: ['find', 'replace', 'copyMd', 'pastePlain', 'selectLine', 'selectWord', 'selectSameFormat', 'deleteWord', 'docStart', 'docEnd'] },
|
||||
{ label: '📝 Markdown 格式', ids: ['bold', 'italic', 'underline', 'strike', 'inlineCode', 'link', 'insertImage', 'clearFormat'] },
|
||||
{ label: '📑 段落、标题、块', ids: ['heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6', 'paragraph', 'headingUp', 'headingDown', 'blockquote', 'bulletList', 'orderedList', 'codeBlock', 'mathBlock', 'table', 'indent', 'outdent', 'moveUp', 'moveDown'] },
|
||||
{ label: '👁️ 视图', ids: ['toggleSidebar', 'toggleOutline', 'toggleInfo', 'fileTree', 'focusMode', 'typewriter', 'fullscreen', 'focusSearch', 'toggleCheat'] },
|
||||
];
|
||||
|
||||
export function CheatSheet({ onClose }: { onClose: () => void }) {
|
||||
const bindings = useKeymap(s => s.bindings);
|
||||
@@ -12,16 +18,17 @@ export function CheatSheet({ onClose }: { onClose: () => void }) {
|
||||
window.addEventListener('keydown', onEsc, true);
|
||||
return () => window.removeEventListener('keydown', onEsc, true);
|
||||
}, [onClose]);
|
||||
const byId = new Map(bindings.map(b => [b.id, b] as const));
|
||||
return (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) onClose(); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1500 }}>
|
||||
<div className="panel-slide-in" style={{ width: 480, maxHeight: '80vh', overflow: 'auto', background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 10, padding: 20, boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}><strong style={{ fontSize: 15 }}>快捷键速查</strong><button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 16 }}>✕</button></div>
|
||||
{SCOPE_ORDER.map(scope => {
|
||||
const items = bindings.filter(b => b.scope === scope);
|
||||
<div className="panel-slide-in" style={{ width: 520, maxHeight: '80vh', overflow: 'auto', background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 10, padding: 20, boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}><strong style={{ fontSize: 15 }}>快捷键速查(Typora 标准)</strong><button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 16 }}>✕</button></div>
|
||||
{GROUPS.map(g => {
|
||||
const items = g.ids.map(id => byId.get(id)).filter((b): b is NonNullable<typeof b> => !!b);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={scope} style={{ marginBottom: 14 }}>
|
||||
<div style={{ opacity: 0.5, fontSize: 12, marginBottom: 6 }}>{SCOPE_LABELS[scope]}</div>
|
||||
<div key={g.label} style={{ marginBottom: 14 }}>
|
||||
<div style={{ opacity: 0.5, fontSize: 12, marginBottom: 6 }}>{g.label}</div>
|
||||
{items.map(b => (
|
||||
<div key={b.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{b.label}</span>
|
||||
|
||||
@@ -5,22 +5,70 @@ import { IPC } from '@hunter/shared';
|
||||
export type Scope = 'global' | 'app' | 'editor';
|
||||
export interface Binding { id: string; scope: Scope; label: string; combo: string; defaultCombo: string; }
|
||||
|
||||
// 完整 Typora 快捷键列表(用户指定为标准)——所有 combo 唯一、无冲突
|
||||
// 全局/应用级绑定由 use-hotkeys 真正驱动;编辑器绑定由 Tiptap(TyporaKeys) 以同样默认值驱动
|
||||
const DEFAULTS: Binding[] = [
|
||||
// FR-K1 全局作用域——任何位置生效
|
||||
{ id: 'newDoc', scope: 'global', label: '新建文档', combo: 'ctrl+n', defaultCombo: 'ctrl+n' },
|
||||
// ── 📁 文件操作(全局)──
|
||||
{ id: 'newDoc', scope: 'global', label: '新建文件', combo: 'ctrl+n', defaultCombo: 'ctrl+n' },
|
||||
{ id: 'newWindow', scope: 'global', label: '新建窗口', combo: 'ctrl+shift+n', defaultCombo: 'ctrl+shift+n' },
|
||||
{ id: 'openFile', scope: 'global', label: '打开文件', combo: 'ctrl+o', defaultCombo: 'ctrl+o' },
|
||||
{ id: 'quickOpen', scope: 'global', label: '快速打开历史文件', combo: 'ctrl+p', defaultCombo: 'ctrl+p' },
|
||||
{ id: 'save', scope: 'global', label: '保存', combo: 'ctrl+s', defaultCombo: 'ctrl+s' },
|
||||
{ id: 'focusSearch', scope: 'global', label: '全库搜索', combo: 'ctrl+shift+f', defaultCombo: 'ctrl+shift+f' },
|
||||
{ id: 'toggleOutline', scope: 'global', label: '大纲面板', combo: 'ctrl+shift+l', defaultCombo: 'ctrl+shift+l' },
|
||||
// FR-K3 应用级——编辑器外也生效,但编辑器聚焦时优先编辑器
|
||||
{ id: 'toggleCheat', scope: 'app', label: '快捷键速查', combo: 'ctrl+/', defaultCombo: 'ctrl+/' },
|
||||
{ id: 'toggleSettings', scope: 'app', label: '设置', combo: 'ctrl+shift+,', defaultCombo: 'ctrl+shift+,' },
|
||||
// FR-K3 编辑器作用域——仅编辑器聚焦时生效
|
||||
{ id: 'saveAs', scope: 'global', label: '另存为', combo: 'ctrl+shift+s', defaultCombo: 'ctrl+shift+s' },
|
||||
{ id: 'toggleSettings', scope: 'app', label: '偏好设置', combo: 'ctrl+,', defaultCombo: 'ctrl+,' },
|
||||
{ id: 'closeTab', scope: 'global', label: '关闭标签页', combo: 'ctrl+w', defaultCombo: 'ctrl+w' },
|
||||
{ id: 'reopenTab', scope: 'global', label: '重新打开关闭的文档', combo: 'ctrl+shift+t', defaultCombo: 'ctrl+shift+t' },
|
||||
// ── ✏️ 基础编辑(编辑器)──
|
||||
{ id: 'find', scope: 'editor', label: '查找', combo: 'ctrl+f', defaultCombo: 'ctrl+f' },
|
||||
{ id: 'replace', scope: 'editor', label: '替换', combo: 'ctrl+h', defaultCombo: 'ctrl+h' },
|
||||
{ id: 'copyMd', scope: 'global', label: '复制为 Markdown 源码', combo: 'ctrl+shift+c', defaultCombo: 'ctrl+shift+c' },
|
||||
{ id: 'pastePlain', scope: 'editor', label: '粘贴纯文本', combo: 'ctrl+shift+v', defaultCombo: 'ctrl+shift+v' },
|
||||
{ id: 'selectLine', scope: 'editor', label: '选中当前行', combo: 'ctrl+l', defaultCombo: 'ctrl+l' },
|
||||
{ id: 'selectWord', scope: 'editor', label: '选中当前单词', combo: 'ctrl+d', defaultCombo: 'ctrl+d' },
|
||||
{ id: 'selectSameFormat', scope: 'editor', label: '选中相同格式文本', combo: 'ctrl+e', defaultCombo: 'ctrl+e' },
|
||||
{ id: 'deleteWord', scope: 'editor', label: '删除当前单词', combo: 'ctrl+shift+d', defaultCombo: 'ctrl+shift+d' },
|
||||
{ id: 'docStart', scope: 'editor', label: '跳到文档开头', combo: 'ctrl+home', defaultCombo: 'ctrl+home' },
|
||||
{ id: 'docEnd', scope: 'editor', label: '跳到文档末尾', combo: 'ctrl+end', defaultCombo: 'ctrl+end' },
|
||||
// ── 📝 Markdown 格式(编辑器)──
|
||||
{ id: 'bold', scope: 'editor', label: '加粗', combo: 'ctrl+b', defaultCombo: 'ctrl+b' },
|
||||
{ id: 'italic', scope: 'editor', label: '斜体', combo: 'ctrl+i', defaultCombo: 'ctrl+i' },
|
||||
{ id: 'underline', scope: 'editor', label: '下划线', combo: 'ctrl+u', defaultCombo: 'ctrl+u' },
|
||||
{ id: 'strike', scope: 'editor', label: '删除线', combo: 'alt+shift+5', defaultCombo: 'alt+shift+5' },
|
||||
{ id: 'inlineCode', scope: 'editor', label: '行内代码', combo: 'ctrl+e', defaultCombo: 'ctrl+e' },
|
||||
{ id: 'inlineCode', scope: 'editor', label: '行内代码', combo: 'ctrl+shift+`', defaultCombo: 'ctrl+shift+`' },
|
||||
{ id: 'link', scope: 'editor', label: '插入链接', combo: 'ctrl+k', defaultCombo: 'ctrl+k' },
|
||||
{ id: 'insertImage', scope: 'editor', label: '插入图片', combo: 'ctrl+shift+i', defaultCombo: 'ctrl+shift+i' },
|
||||
{ id: 'clearFormat', scope: 'editor', label: '清除格式', combo: 'ctrl+\\', defaultCombo: 'ctrl+\\' },
|
||||
// ── 📑 段落、标题、块元素(编辑器)──
|
||||
{ id: 'heading1', scope: 'editor', label: '一级标题', combo: 'ctrl+1', defaultCombo: 'ctrl+1' },
|
||||
{ id: 'heading2', scope: 'editor', label: '二级标题', combo: 'ctrl+2', defaultCombo: 'ctrl+2' },
|
||||
{ id: 'heading3', scope: 'editor', label: '三级标题', combo: 'ctrl+3', defaultCombo: 'ctrl+3' },
|
||||
{ id: 'heading4', scope: 'editor', label: '四级标题', combo: 'ctrl+4', defaultCombo: 'ctrl+4' },
|
||||
{ id: 'heading5', scope: 'editor', label: '五级标题', combo: 'ctrl+5', defaultCombo: 'ctrl+5' },
|
||||
{ id: 'heading6', scope: 'editor', label: '六级标题', combo: 'ctrl+6', defaultCombo: 'ctrl+6' },
|
||||
{ id: 'paragraph', scope: 'editor', label: '转为普通段落', combo: 'ctrl+0', defaultCombo: 'ctrl+0' },
|
||||
{ id: 'headingUp', scope: 'editor', label: '标题级别升高', combo: 'ctrl+=', defaultCombo: 'ctrl+=' },
|
||||
{ id: 'headingDown', scope: 'editor', label: '标题级别降低', combo: 'ctrl+-', defaultCombo: 'ctrl+-' },
|
||||
{ id: 'blockquote', scope: 'editor', label: '引用块', combo: 'ctrl+shift+q', defaultCombo: 'ctrl+shift+q' },
|
||||
{ id: 'bulletList', scope: 'editor', label: '无序列表', combo: 'ctrl+shift+]', defaultCombo: 'ctrl+shift+]' },
|
||||
{ id: 'orderedList', scope: 'editor', label: '有序列表', combo: 'ctrl+shift+[', defaultCombo: 'ctrl+shift+[' },
|
||||
{ id: 'codeBlock', scope: 'editor', label: '代码块', combo: 'ctrl+shift+k', defaultCombo: 'ctrl+shift+k' },
|
||||
{ id: 'mathBlock', scope: 'editor', label: '公式块', combo: 'ctrl+shift+m', defaultCombo: 'ctrl+shift+m' },
|
||||
{ id: 'table', scope: 'editor', label: '插入表格', combo: 'ctrl+t', defaultCombo: 'ctrl+t' },
|
||||
{ id: 'indent', scope: 'editor', label: '增加缩进', combo: 'ctrl+]', defaultCombo: 'ctrl+]' },
|
||||
{ id: 'outdent', scope: 'editor', label: '减少缩进', combo: 'ctrl+[', defaultCombo: 'ctrl+[' },
|
||||
{ id: 'moveUp', scope: 'editor', label: '段落上移', combo: 'ctrl+shift+up', defaultCombo: 'ctrl+shift+up' },
|
||||
{ id: 'moveDown', scope: 'editor', label: '段落下移', combo: 'ctrl+shift+down', defaultCombo: 'ctrl+shift+down' },
|
||||
// ── 👁️ 视图(全局/应用)──
|
||||
{ id: 'toggleSidebar', scope: 'global', label: '显示/隐藏侧边栏', combo: 'ctrl+shift+l', defaultCombo: 'ctrl+shift+l' },
|
||||
{ id: 'toggleOutline', scope: 'global', label: '大纲视图', combo: 'ctrl+shift+1', defaultCombo: 'ctrl+shift+1' },
|
||||
{ id: 'toggleInfo', scope: 'global', label: '信息面板', combo: 'ctrl+shift+2', defaultCombo: 'ctrl+shift+2' },
|
||||
{ id: 'fileTree', scope: 'global', label: '文件树视图', combo: 'ctrl+shift+3', defaultCombo: 'ctrl+shift+3' },
|
||||
{ id: 'focusMode', scope: 'global', label: '专注模式', combo: 'f8', defaultCombo: 'f8' },
|
||||
{ id: 'typewriter', scope: 'global', label: '打字机模式', combo: 'f9', defaultCombo: 'f9' },
|
||||
{ id: 'fullscreen', scope: 'global', label: '全屏', combo: 'f11', defaultCombo: 'f11' },
|
||||
{ id: 'focusSearch', scope: 'global', label: '全库搜索', combo: 'ctrl+shift+f', defaultCombo: 'ctrl+shift+f' },
|
||||
// FR-K3 应用级——快捷键速查
|
||||
{ id: 'toggleCheat', scope: 'app', label: '快捷键速查', combo: 'ctrl+/', defaultCombo: 'ctrl+/' },
|
||||
];
|
||||
|
||||
interface KeymapState {
|
||||
|
||||
@@ -2,7 +2,18 @@ import { useEffect, useRef } from 'react';
|
||||
import { useKeymap } from './keymap-service';
|
||||
|
||||
// FR-K1/K3/K7:从 keymap store 读绑定,统一用捕获阶段 keydown 拦截(先于 ProseMirror / Electron 菜单)
|
||||
export function useGlobalHotkeys(opts: { onNewDoc: () => void; onSave: () => void; onFocusSearch: () => void; onToggleOutline: () => void; onCheat: () => void; onSettings: () => void; onFind: () => void; onClearFormat: () => void }) {
|
||||
// 完整 Typora 快捷键——全局/应用级与编辑器类(查找/替换/链接/图片/纯文本/复制MD)在此拦截;
|
||||
// 纯排版快捷键(加粗/斜体/标题/列表等)由 Tiptap(TyporaKeys) 在编辑器内处理,不进此表
|
||||
export interface GlobalHotkeyHandlers {
|
||||
onNewDoc: () => void; onSave: () => void; onFocusSearch: () => void; onToggleOutline: () => void;
|
||||
onCheat: () => void; onSettings: () => void; onFind: () => void; onClearFormat: () => void;
|
||||
onReplace: () => void; onCloseTab: () => void; onReopenTab: () => void; onOpenFile: () => void;
|
||||
onQuickOpen: () => void; onNewWindow: () => void; onSaveAs: () => void; onCopyMd: () => void;
|
||||
onPastePlain: () => void; onToggleSidebar: () => void; onToggleInfo: () => void; onFileTree: () => void;
|
||||
onInsertLink: () => void; onInsertImage: () => void; onFullscreen: () => void;
|
||||
onFocusMode: () => void; onTypewriter: () => void;
|
||||
}
|
||||
export function useGlobalHotkeys(opts: GlobalHotkeyHandlers) {
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
const bindings = useKeymap(s => s.bindings);
|
||||
@@ -17,6 +28,23 @@ export function useGlobalHotkeys(opts: { onNewDoc: () => void; onSave: () => voi
|
||||
toggleSettings: () => optsRef.current.onSettings(),
|
||||
find: () => optsRef.current.onFind(),
|
||||
clearFormat: () => optsRef.current.onClearFormat(),
|
||||
replace: () => optsRef.current.onReplace(),
|
||||
closeTab: () => optsRef.current.onCloseTab(),
|
||||
reopenTab: () => optsRef.current.onReopenTab(),
|
||||
openFile: () => optsRef.current.onOpenFile(),
|
||||
quickOpen: () => optsRef.current.onQuickOpen(),
|
||||
newWindow: () => optsRef.current.onNewWindow(),
|
||||
saveAs: () => optsRef.current.onSaveAs(),
|
||||
copyMd: () => optsRef.current.onCopyMd(),
|
||||
pastePlain: () => optsRef.current.onPastePlain(),
|
||||
toggleSidebar: () => optsRef.current.onToggleSidebar(),
|
||||
toggleInfo: () => optsRef.current.onToggleInfo(),
|
||||
fileTree: () => optsRef.current.onFileTree(),
|
||||
link: () => optsRef.current.onInsertLink(),
|
||||
insertImage: () => optsRef.current.onInsertImage(),
|
||||
fullscreen: () => optsRef.current.onFullscreen(),
|
||||
focusMode: () => optsRef.current.onFocusMode(),
|
||||
typewriter: () => optsRef.current.onTypewriter(),
|
||||
};
|
||||
|
||||
const parseCombo = (combo: string) => {
|
||||
|
||||
@@ -5,8 +5,10 @@ export interface Tab { id: string; path: string; pinned: boolean; scrollTop: num
|
||||
interface TabState {
|
||||
tabs: Tab[];
|
||||
activeId: string | null;
|
||||
closedStack: { path: string; pinned: boolean; scrollTop: number }[]; // Ctrl+Shift+T 重开(Typora)
|
||||
openTab: (path: string) => void;
|
||||
closeTab: (id: string) => void;
|
||||
popClosed: () => { path: string; pinned: boolean; scrollTop: number } | null;
|
||||
setActive: (id: string) => void;
|
||||
togglePin: (id: string) => void;
|
||||
updateScroll: (id: string, scrollTop: number) => void; // FR-T5:保存滚动位置
|
||||
@@ -22,6 +24,7 @@ const SESSION_KEY = 'dsh-session';
|
||||
export const useTabs = create<TabState>((set, get) => ({
|
||||
tabs: [],
|
||||
activeId: null,
|
||||
closedStack: [],
|
||||
|
||||
openTab: (path) => {
|
||||
const existing = get().tabs.find(t => t.path === path);
|
||||
@@ -34,6 +37,8 @@ export const useTabs = create<TabState>((set, get) => ({
|
||||
closeTab: (id) => {
|
||||
const { tabs, activeId } = get();
|
||||
const idx = tabs.findIndex(t => t.id === id);
|
||||
const closed = tabs.find(t => t.id === id);
|
||||
if (closed) set({ closedStack: [...get().closedStack, { path: closed.path, pinned: closed.pinned, scrollTop: closed.scrollTop }].slice(-20) });
|
||||
const remaining = tabs.filter(t => t.id !== id);
|
||||
let newActive = activeId;
|
||||
if (activeId === id) {
|
||||
@@ -45,6 +50,14 @@ export const useTabs = create<TabState>((set, get) => ({
|
||||
get().persist();
|
||||
},
|
||||
|
||||
popClosed: () => {
|
||||
const { closedStack } = get();
|
||||
if (closedStack.length === 0) return null;
|
||||
const last = closedStack[closedStack.length - 1];
|
||||
set({ closedStack: closedStack.slice(0, -1) });
|
||||
return last;
|
||||
},
|
||||
|
||||
setActive: (id) => { set({ activeId: id }); get().persist(); },
|
||||
|
||||
// FR-T5:更新标签页滚动位置(不触发 persist,避免频繁写入)
|
||||
|
||||
@@ -92,16 +92,16 @@ test('EditorToolbar: 格式按钮可点击生效', async () => {
|
||||
// 删除线
|
||||
await a.page.getByTitle('删除线 Alt+Shift+5').click(); await a.page.waitForTimeout(100);
|
||||
// 行内代码
|
||||
await a.page.getByTitle('行内代码 Ctrl+E').click(); await a.page.waitForTimeout(100);
|
||||
await a.page.getByTitle('行内代码 Ctrl+Shift+`').click(); await a.page.waitForTimeout(100);
|
||||
// 标题
|
||||
await a.page.getByTitle('一级标题 Ctrl+1').click(); await a.page.waitForTimeout(100);
|
||||
await a.page.getByTitle('二级标题 Ctrl+2').click(); await a.page.waitForTimeout(100);
|
||||
await a.page.getByTitle('三级标题 Ctrl+3').click(); await a.page.waitForTimeout(100);
|
||||
// 列表
|
||||
await a.page.getByTitle('无序列表 Ctrl+L').click(); await a.page.waitForTimeout(100);
|
||||
await a.page.getByTitle('有序列表 Ctrl+Shift+O').click(); await a.page.waitForTimeout(100);
|
||||
await a.page.getByTitle('无序列表 Ctrl+Shift+]').click(); await a.page.waitForTimeout(100);
|
||||
await a.page.getByTitle('有序列表 Ctrl+Shift+[').click(); await a.page.waitForTimeout(100);
|
||||
// 代码块
|
||||
await a.page.getByTitle('代码块 Ctrl+Shift+M').click(); await a.page.waitForTimeout(100);
|
||||
await a.page.getByTitle('代码块 Ctrl+Shift+K').click(); await a.page.waitForTimeout(100);
|
||||
// 撤销/重做
|
||||
await a.page.getByTitle('撤销 Ctrl+Z').click(); await a.page.waitForTimeout(100);
|
||||
await a.page.getByTitle('重做 Ctrl+Y').click(); await a.page.waitForTimeout(100);
|
||||
|
||||
@@ -33,7 +33,7 @@ test('FR-E5:代码块语言下拉切换生效', async () => {
|
||||
const editor = a.page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
// 插入代码块并输入内容
|
||||
await a.page.locator('button[title="代码块 Ctrl+Shift+M"]').click();
|
||||
await a.page.locator('button[title="代码块 Ctrl+Shift+K"]').click();
|
||||
await a.page.keyboard.type('key: value');
|
||||
|
||||
// 代码块顶部应渲染语言下拉
|
||||
|
||||
@@ -54,7 +54,7 @@ test('FR-E3:行内代码 + 清除格式 工具栏按钮生效', async () => {
|
||||
// 行内代码 -> <code>
|
||||
await editor.click();
|
||||
await a.page.keyboard.press('Control+A');
|
||||
await a.page.locator('button[title="行内代码 Ctrl+E"]').click();
|
||||
await a.page.locator('button[title="行内代码 Ctrl+Shift+`"]').click();
|
||||
expect(await html()).toContain('<code>');
|
||||
|
||||
// 清除格式 -> 移除 <code>
|
||||
|
||||
@@ -95,7 +95,7 @@ test('快捷键 Ctrl+Shift+L 大纲面板切换', async () => {
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
return btns.some(b => b.textContent === '大纲');
|
||||
});
|
||||
await a.page.keyboard.press('Control+Shift+l');
|
||||
await a.page.keyboard.press('Control+Shift+1');
|
||||
await a.page.waitForTimeout(500);
|
||||
const afterPanel = await a.page.evaluate(() => {
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
@@ -125,7 +125,7 @@ test('快捷键 Ctrl+Shift+, 设置面板', async () => {
|
||||
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
|
||||
const a = await launch(ud);
|
||||
await a.invoke('vault:switch', { root: v });
|
||||
await a.page.keyboard.press('Control+Shift+Comma');
|
||||
await a.page.keyboard.press('Control+Comma');
|
||||
await expect(a.page.getByText('设置', { exact: true }).first()).toBeVisible({ timeout: 5000 });
|
||||
await a.page.keyboard.press('Escape');
|
||||
await a.app.close();
|
||||
@@ -155,7 +155,7 @@ test('编辑器快捷键:加粗+斜体+行内代码+删除线+清除格式', a
|
||||
expect(html).toContain('em');
|
||||
// 行内代码
|
||||
await a.page.keyboard.press('Control+a');
|
||||
await a.page.keyboard.press('Control+e');
|
||||
await a.page.keyboard.press('Control+Shift+Backquote');
|
||||
await a.page.waitForTimeout(100);
|
||||
html = await a.page.locator('.ProseMirror').innerHTML();
|
||||
expect(html).toContain('code');
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { _electron as electron } from 'playwright';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import nodePath from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
|
||||
|
||||
// 透明主题真透明:bgPrimary=transparent 时根背景必须是 alpha=0(配合主进程 transparent:true + backgroundColor:#00000000 + backgroundMaterial:'none' 即可透出下层软件)
|
||||
test('透明主题:根背景 alpha=0(真透明,可见下层软件)', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-tr-'));
|
||||
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
|
||||
const app = await electron.launch({ args: ['--user-data-dir=' + ud, MAIN_JS] });
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.evaluate(({ root }) => (globalThis as any).dsh.invoke('vault:switch', { root }), { root: v });
|
||||
// 应用透明主题的 CSS 变量(bgPrimary='transparent'),并移除背景图遮罩
|
||||
await page.evaluate(() => {
|
||||
const el = document.documentElement;
|
||||
el.style.setProperty('--bg-primary', 'transparent');
|
||||
el.style.setProperty('--bg-secondary', 'rgba(20,20,28,0.5)');
|
||||
el.style.setProperty('--text-primary', '#ddd');
|
||||
document.querySelectorAll('div[style*="background-image"]').forEach((n: any) => n.remove());
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const rootBg = await page.evaluate(() => {
|
||||
const root = document.querySelector('#root > div') as HTMLElement;
|
||||
if (!root) return null;
|
||||
return getComputedStyle(root).backgroundColor;
|
||||
});
|
||||
console.log('ROOT_BG', rootBg);
|
||||
expect(rootBg).not.toBeNull();
|
||||
// 透明背景应为 alpha=0(rgba(0,0,0,0) 或 transparent)
|
||||
expect(rootBg).toMatch(/rgba\(0, 0, 0, 0\)|transparent/i);
|
||||
await app.close();
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { _electron as electron } from 'playwright';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import nodePath from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
|
||||
|
||||
async function launch(userData: string) {
|
||||
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
|
||||
return { app, page, invoke };
|
||||
}
|
||||
|
||||
async function newDoc(a: { page: any; invoke: any }, title: string) {
|
||||
await a.page.getByRole('button', { name: /新建文档/ }).click();
|
||||
await a.page.getByPlaceholder(/输入文档标题/).fill(title);
|
||||
await a.page.getByPlaceholder(/输入文档标题/).press('Enter');
|
||||
await expect(a.page.getByTestId('tabbar').getByText(title, { exact: true })).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
test('keymap store:完整 Typora 列表无冲突', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-ty-'));
|
||||
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
|
||||
const a = await launch(ud);
|
||||
await a.invoke('vault:switch', { root: v });
|
||||
const r = await a.page.evaluate(() => {
|
||||
const store = (window as any).__KEYMAP_STORE__;
|
||||
if (!store) return { error: 'no store' };
|
||||
const bs = store.getState().bindings;
|
||||
const ids = bs.map((b: any) => b.id);
|
||||
const want = ['newDoc','save','closeTab','reopenTab','openFile','quickOpen','newWindow','saveAs','copyMd','pastePlain','selectLine','selectWord','selectSameFormat','deleteWord','docStart','docEnd','bold','italic','underline','strike','inlineCode','link','insertImage','clearFormat','heading1','heading2','heading3','heading4','heading5','heading6','paragraph','headingUp','headingDown','blockquote','bulletList','orderedList','codeBlock','mathBlock','table','indent','outdent','moveUp','moveDown','toggleSidebar','toggleOutline','toggleInfo','focusMode','typewriter','fullscreen','focusSearch','toggleCheat','toggleSettings','find','replace'];
|
||||
return { count: bs.length, conflictCount: store.getState().conflictCount(), hasAll: want.every((id: string) => ids.includes(id)) };
|
||||
});
|
||||
console.log('TYPORA_STORE', JSON.stringify(r));
|
||||
expect(r.error).toBeUndefined();
|
||||
expect(r.conflictCount).toBe(0);
|
||||
expect(r.count).toBeGreaterThanOrEqual(50);
|
||||
expect(r.hasAll).toBe(true);
|
||||
await a.app.close();
|
||||
});
|
||||
// 编辑器排版快捷键:行内代码 Ctrl+Shift+`、下划线 Ctrl+U、无序/有序列表、代码块、表格、引用
|
||||
test('Typora 编辑器快捷键:代码/下划线/列表/代码块/表格/引用', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-ty2-'));
|
||||
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
|
||||
const a = await launch(ud);
|
||||
await a.invoke('vault:switch', { root: v });
|
||||
await newDoc(a, 'TyporaHK');
|
||||
const ed = a.page.locator('.ProseMirror');
|
||||
await ed.click();
|
||||
const html = async () => (await a.page.locator('.ProseMirror').innerHTML());
|
||||
// 行内代码 Ctrl+Shift+`
|
||||
await a.page.keyboard.type('code sample');
|
||||
await a.page.keyboard.press('Control+a');
|
||||
await a.page.keyboard.press('Control+Shift+Backquote');
|
||||
await a.page.waitForTimeout(120);
|
||||
expect(await html()).toContain('<code>');
|
||||
// 清除格式后重试下划线 Ctrl+U
|
||||
await a.page.keyboard.press('Control+a');
|
||||
await a.page.keyboard.press('Control+\\');
|
||||
await a.page.keyboard.type('underline text');
|
||||
await a.page.keyboard.press('Control+a');
|
||||
await a.page.keyboard.press('Control+u');
|
||||
await a.page.waitForTimeout(120);
|
||||
expect(await html()).toContain('<u>');
|
||||
// 无序列表 Ctrl+Shift+]
|
||||
await a.page.keyboard.press('Control+a');
|
||||
await a.page.keyboard.press('Control+Shift+]');
|
||||
await a.page.waitForTimeout(120);
|
||||
expect(await html()).toContain('<ul');
|
||||
// 有序列表 Ctrl+Shift+[
|
||||
await a.page.keyboard.press('Control+a');
|
||||
await a.page.keyboard.press('Control+Shift+[');
|
||||
await a.page.waitForTimeout(120);
|
||||
expect(await html()).toContain('<ol');
|
||||
// 代码块 Ctrl+Shift+K
|
||||
await a.page.keyboard.press('Control+a');
|
||||
await a.page.keyboard.press('Control+Shift+k');
|
||||
await a.page.waitForTimeout(120);
|
||||
expect(await html()).toContain('<pre');
|
||||
// 表格 Ctrl+T
|
||||
await a.page.keyboard.press('Control+t');
|
||||
await a.page.waitForTimeout(150);
|
||||
expect(await html()).toContain('<table');
|
||||
// 引用 Ctrl+Shift+Q
|
||||
await a.page.keyboard.press('Enter');
|
||||
await a.page.keyboard.press('Control+Shift+q');
|
||||
await a.page.waitForTimeout(120);
|
||||
expect(await html()).toContain('<blockquote');
|
||||
await a.app.close();
|
||||
});
|
||||
|
||||
// 段落移动 Ctrl+Shift+↑/↓ + 选中行 Ctrl+L
|
||||
test('Typora 段落:Ctrl+Shift+↑/↓ 移动块、Ctrl+L 选中行', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-ty3-'));
|
||||
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
|
||||
const a = await launch(ud);
|
||||
await a.invoke('vault:switch', { root: v });
|
||||
await newDoc(a, 'BlockHK');
|
||||
const ed = a.page.locator('.ProseMirror');
|
||||
await ed.click();
|
||||
await a.page.keyboard.type('AAAA');
|
||||
await a.page.keyboard.press('Enter');
|
||||
await a.page.keyboard.type('BBBB');
|
||||
// 光标在第二段,Ctrl+Shift+↑ 上移(BBBB 块上移到顶部)
|
||||
await a.page.keyboard.press('Control+Shift+ArrowUp');
|
||||
await a.page.waitForTimeout(120);
|
||||
const text = await a.page.locator('.ProseMirror').innerText();
|
||||
expect(text.indexOf('BBBB')).toBeLessThan(text.indexOf('AAAA'));
|
||||
// Ctrl+L 选中当前行
|
||||
await a.page.keyboard.press('Control+l');
|
||||
await a.page.waitForTimeout(120);
|
||||
const sel = await a.page.evaluate(() => {
|
||||
const s = window.getSelection();
|
||||
return s ? s.toString() : '';
|
||||
});
|
||||
expect(sel.length).toBeGreaterThan(0);
|
||||
await a.app.close();
|
||||
});
|
||||
|
||||
// 视图快捷键:Ctrl+Shift+L 侧边栏 / Ctrl+Shift+1 大纲 / Ctrl+, 设置
|
||||
test('Typora 视图:Ctrl+Shift+L 侧边栏 / Ctrl+Shift+1 大纲 / Ctrl+, 设置', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-ty4-'));
|
||||
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
|
||||
const a = await launch(ud);
|
||||
await a.invoke('vault:switch', { root: v });
|
||||
await newDoc(a, 'ViewHK');
|
||||
// Ctrl+Shift+L 隐藏侧边栏(搜索框消失)
|
||||
await a.page.getByPlaceholder(/搜索/).waitFor({ timeout: 5000 });
|
||||
await a.page.keyboard.press('Control+Shift+l');
|
||||
await a.page.waitForTimeout(200);
|
||||
await expect(a.page.getByPlaceholder(/搜索/)).not.toBeVisible({ timeout: 4000 });
|
||||
// 再次 Ctrl+Shift+L 恢复
|
||||
await a.page.keyboard.press('Control+Shift+l');
|
||||
await a.page.waitForTimeout(200);
|
||||
await expect(a.page.getByPlaceholder(/搜索/)).toBeVisible({ timeout: 4000 });
|
||||
// Ctrl+Shift+1 大纲面板(右栏 大纲/信息 按钮可见性切换)
|
||||
const infoBtn = a.page.getByRole('button', { name: '信息' });
|
||||
const before = await infoBtn.isVisible().catch(() => false);
|
||||
await a.page.keyboard.press('Control+Shift+1');
|
||||
await a.page.waitForTimeout(200);
|
||||
const after = await infoBtn.isVisible().catch(() => false);
|
||||
expect(before).not.toBe(after);
|
||||
// Ctrl+, 设置
|
||||
await a.page.keyboard.press('Control+Comma');
|
||||
await expect(a.page.getByText('设置', { exact: true }).first()).toBeVisible({ timeout: 5000 });
|
||||
await a.page.keyboard.press('Escape');
|
||||
await a.app.close();
|
||||
});
|
||||
@@ -31,6 +31,10 @@ export const IPC = {
|
||||
winMaximize: 'win:maximize',
|
||||
winIsMaximized: 'win:is-maximized',
|
||||
winClose: 'win:close',
|
||||
winFullscreen: 'win:fullscreen',
|
||||
winNew: 'win:new',
|
||||
fileOpen: 'file:open',
|
||||
docSaveAs: 'doc:save-as',
|
||||
gpuInfo: 'gpu:info',
|
||||
copyMd: 'doc:copy-md',
|
||||
printPdf: 'doc:print-pdf',
|
||||
@@ -69,6 +73,10 @@ export interface IpcMap {
|
||||
[IPC.winMaximize]: { args: void; result: void };
|
||||
[IPC.winIsMaximized]: { args: void; result: boolean };
|
||||
[IPC.winClose]: { args: void; result: void };
|
||||
[IPC.winFullscreen]: { args: void; result: void };
|
||||
[IPC.winNew]: { args: void; result: void };
|
||||
[IPC.fileOpen]: { args: void; result: string | null };
|
||||
[IPC.docSaveAs]: { args: { body: string; title: string }; result: string | null };
|
||||
[IPC.gpuInfo]: { args: void; result: { softwareRendering: boolean; vendor: string; renderer: string } };
|
||||
[IPC.copyMd]: { args: { path: string }; result: string };
|
||||
[IPC.printPdf]: { args: void; result: void };
|
||||
|
||||
Reference in New Issue
Block a user