diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 14c7a8c..f3e9968 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -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(); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index aac4ad2..7fb86d7 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -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-编辑器设置真实生效) diff --git a/apps/desktop/src/renderer/features/editor/BlockOps.ts b/apps/desktop/src/renderer/features/editor/BlockOps.ts index 48389c0..73d93d4 100644 --- a/apps/desktop/src/renderer/features/editor/BlockOps.ts +++ b/apps/desktop/src/renderer/features/editor/BlockOps.ts @@ -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; diff --git a/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx b/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx index e59f865..64bbeca 100644 --- a/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx +++ b/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx @@ -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 (
@@ -53,7 +62,7 @@ export function EditorToolbar() { e.chain().focus().toggleBold().run())} disabled={d}> e.chain().focus().toggleItalic().run())} disabled={d}> e.chain().focus().toggleStrike().run())} disabled={d}> - e.chain().focus().toggleCode().run())} disabled={d}> + e.chain().focus().toggleCode().run())} disabled={d}> e.chain().focus().unsetAllMarks().run())} disabled={d}> e.chain().focus().toggleBlockquote().run())} disabled={d}> @@ -61,10 +70,10 @@ export function EditorToolbar() { e.chain().focus().toggleHeading({ level: 2 }).run())} disabled={d}> e.chain().focus().toggleHeading({ level: 3 }).run())} disabled={d}> - e.chain().focus().toggleBulletList().run())} disabled={d}> - e.chain().focus().toggleOrderedList().run())} disabled={d}> - e.chain().focus().toggleCodeBlock().run())} disabled={d}> - e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())} disabled={d}> + e.chain().focus().toggleBulletList().run())} disabled={d}> + e.chain().focus().toggleOrderedList().run())} disabled={d}> + e.chain().focus().toggleCodeBlock().run())} disabled={d}> + e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())} disabled={d}>
{ void link(); }} disabled={d}>链接 { void pickImage(); }} disabled={d}>图片 diff --git a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx index 55ecee4..689cb78 100644 --- a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx +++ b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx @@ -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 = () => { diff --git a/apps/desktop/src/renderer/features/editor/TyporaKeys.ts b/apps/desktop/src/renderer/features/editor/TyporaKeys.ts new file mode 100644 index 0000000..baf5a68 --- /dev/null +++ b/apps/desktop/src/renderer/features/editor/TyporaKeys.ts @@ -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); +} diff --git a/apps/desktop/src/renderer/features/editor/typewriter-store.ts b/apps/desktop/src/renderer/features/editor/typewriter-store.ts new file mode 100644 index 0000000..fd5daed --- /dev/null +++ b/apps/desktop/src/renderer/features/editor/typewriter-store.ts @@ -0,0 +1,5 @@ +import { create } from 'zustand'; + +// F9 打字机模式:光标行垂直居中滚动(区别于默认的底部留白) +interface TypewriterState { enabled: boolean; toggle: () => void; set: (v: boolean) => void; } +export const useTypewriter = create(set => ({ enabled: false, toggle: () => set(s => ({ enabled: !s.enabled })), set: v => set({ enabled: v }) })); diff --git a/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx b/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx index 88449e3..37f22c0 100644 --- a/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx +++ b/apps/desktop/src/renderer/features/keymap/CheatSheet.tsx @@ -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 = { 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 (
{ 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 }}> -
-
快捷键速查
- {SCOPE_ORDER.map(scope => { - const items = bindings.filter(b => b.scope === scope); +
+
快捷键速查(Typora 标准)
+ {GROUPS.map(g => { + const items = g.ids.map(id => byId.get(id)).filter((b): b is NonNullable => !!b); if (items.length === 0) return null; return ( -
-
{SCOPE_LABELS[scope]}
+
+
{g.label}
{items.map(b => (
{b.label} diff --git a/apps/desktop/src/renderer/features/keymap/keymap-service.ts b/apps/desktop/src/renderer/features/keymap/keymap-service.ts index 8e78f27..ea8d44d 100644 --- a/apps/desktop/src/renderer/features/keymap/keymap-service.ts +++ b/apps/desktop/src/renderer/features/keymap/keymap-service.ts @@ -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 { diff --git a/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts b/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts index 50313c8..1ce4f3a 100644 --- a/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts +++ b/apps/desktop/src/renderer/features/keymap/use-hotkeys.ts @@ -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) => { diff --git a/apps/desktop/src/renderer/features/tabs/tab-store.ts b/apps/desktop/src/renderer/features/tabs/tab-store.ts index e716dff..8abc77f 100644 --- a/apps/desktop/src/renderer/features/tabs/tab-store.ts +++ b/apps/desktop/src/renderer/features/tabs/tab-store.ts @@ -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((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((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((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,避免频繁写入) diff --git a/apps/desktop/tests/e2e/button-all.spec.ts b/apps/desktop/tests/e2e/button-all.spec.ts index ad82f51..b0699a6 100644 --- a/apps/desktop/tests/e2e/button-all.spec.ts +++ b/apps/desktop/tests/e2e/button-all.spec.ts @@ -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); diff --git a/apps/desktop/tests/e2e/editor-codeblock.spec.ts b/apps/desktop/tests/e2e/editor-codeblock.spec.ts index e5368a1..57f5cbf 100644 --- a/apps/desktop/tests/e2e/editor-codeblock.spec.ts +++ b/apps/desktop/tests/e2e/editor-codeblock.spec.ts @@ -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'); // 代码块顶部应渲染语言下拉 diff --git a/apps/desktop/tests/e2e/editor-inline.spec.ts b/apps/desktop/tests/e2e/editor-inline.spec.ts index 1a6fa7b..545f595 100644 --- a/apps/desktop/tests/e2e/editor-inline.spec.ts +++ b/apps/desktop/tests/e2e/editor-inline.spec.ts @@ -54,7 +54,7 @@ test('FR-E3:行内代码 + 清除格式 工具栏按钮生效', async () => { // 行内代码 -> 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(''); // 清除格式 -> 移除 diff --git a/apps/desktop/tests/e2e/keymap-all.spec.ts b/apps/desktop/tests/e2e/keymap-all.spec.ts index 95899a6..ef05527 100644 --- a/apps/desktop/tests/e2e/keymap-all.spec.ts +++ b/apps/desktop/tests/e2e/keymap-all.spec.ts @@ -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'); diff --git a/apps/desktop/tests/e2e/transparency-check.spec.ts b/apps/desktop/tests/e2e/transparency-check.spec.ts new file mode 100644 index 0000000..4d4b234 --- /dev/null +++ b/apps/desktop/tests/e2e/transparency-check.spec.ts @@ -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(); +}); \ No newline at end of file diff --git a/apps/desktop/tests/e2e/typora-shortcuts.spec.ts b/apps/desktop/tests/e2e/typora-shortcuts.spec.ts new file mode 100644 index 0000000..5865390 --- /dev/null +++ b/apps/desktop/tests/e2e/typora-shortcuts.spec.ts @@ -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(''); + // 清除格式后重试下划线 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(''); + // 无序列表 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(' { + 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(); +}); diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index fa931f8..7534a18 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -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 };