diff --git a/src/main/index.ts b/src/main/index.ts index db23c30..9e15ef0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, shell, nativeImage, Menu, dialog } from 'electron' import { join } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { registerIpcHandlers } from './ipcHandlers' -import { getCollectionRoot } from './globalConfig' +import { getCollectionRoot, readGlobalConfig } from './globalConfig' app.setName('Borges') @@ -92,6 +92,8 @@ function buildAppMenu(win: BrowserWindow): void { function createWindow(): BrowserWindow { const icon = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png')) + const config = readGlobalConfig() + const isDark = config.theme !== 'light' const mainWindow = new BrowserWindow({ width: 1440, height: 900, @@ -99,6 +101,8 @@ function createWindow(): BrowserWindow { minHeight: 600, title: 'Borges', titleBarStyle: 'hiddenInset', + vibrancy: 'sidebar', + backgroundColor: isDark ? '#1c1a18' : '#f5f0ea', icon, show: false, webPreferences: { diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index 8ebe7e7..b1c1679 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -1,4 +1,4 @@ -import { ipcMain, dialog, BrowserWindow } from 'electron' +import { ipcMain, dialog, BrowserWindow, Menu } from 'electron' import { listStories, readStory, writeStory, createStory, renameStory, deleteStory, getStoryMeta, setStoryMeta, getCollectionConfig, setCollectionContext, @@ -60,6 +60,34 @@ export function registerIpcHandlers(): void { return result.canceled ? null : result.filePaths[0] }) + // ── Native context menus ───────────────────────────────────────────────────── + ipcMain.handle('menu:editorContext', async (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return + const menu = Menu.buildFromTemplate([ + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { type: 'separator' }, + { role: 'selectAll' }, + ]) + menu.popup({ window: win }) + }) + + ipcMain.handle('menu:storyContext', async (event, _storyId: string) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return + return new Promise((resolve) => { + const menu = Menu.buildFromTemplate([ + { label: 'Rename', click: () => resolve('rename') }, + { type: 'separator' }, + { label: 'Delete', click: () => resolve('delete') }, + ]) + menu.on('menu-will-close', () => setTimeout(() => resolve(null), 100)) + menu.popup({ window: win }) + }) + }) + // ── AI streaming ────────────────────────────────────────────────────────────── ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => { try { diff --git a/src/preload/index.ts b/src/preload/index.ts index d90fdba..e71df22 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -66,6 +66,10 @@ contextBridge.exposeInMainWorld('api', { }) }, + // Native context menus + showEditorContextMenu: (): Promise => ipcRenderer.invoke('menu:editorContext'), + showStoryContextMenu: (storyId: string): Promise => ipcRenderer.invoke('menu:storyContext', storyId), + // Menu onMenuAction: (handler: (action: string) => void): (() => void) => { const listener = (_: Electron.IpcRendererEvent, action: string): void => handler(action) diff --git a/src/renderer/components/Editor/MarkdownEditor.tsx b/src/renderer/components/Editor/MarkdownEditor.tsx index 06480d5..0d5c918 100644 --- a/src/renderer/components/Editor/MarkdownEditor.tsx +++ b/src/renderer/components/Editor/MarkdownEditor.tsx @@ -83,6 +83,7 @@ export function MarkdownEditor(): JSX.Element { const editorRef = useRef(null) const viewRef = useRef(null) const lastPathRef = useRef(null) + const storeTimerRef = useRef | null>(null) // Initialize editor @@ -107,10 +108,17 @@ export function MarkdownEditor(): JSX.Element { EditorView.updateListener.of((update) => { if (update.docChanged) { const content = update.state.doc.toString() - useBorgesStore.getState().setContent(content) + if (storeTimerRef.current) clearTimeout(storeTimerRef.current) + storeTimerRef.current = setTimeout(() => { + useBorgesStore.getState().setContent(content) + }, 300) } }), EditorView.domEventHandlers({ + contextmenu: (e) => { + e.preventDefault() + window.api.showEditorContextMenu() + }, keydown: (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault() diff --git a/src/renderer/components/Sidebar/StorySidebar.tsx b/src/renderer/components/Sidebar/StorySidebar.tsx index 3cad1dc..3c9606a 100644 --- a/src/renderer/components/Sidebar/StorySidebar.tsx +++ b/src/renderer/components/Sidebar/StorySidebar.tsx @@ -44,16 +44,9 @@ function StoryItem({ story, index, isActive, onClick, onContextMenu, onDragStart ) } -interface ContextMenuState { - x: number - y: number - story: StoryFile -} - export function StorySidebar(): JSX.Element { const { stories, activeStoryPath, activeStoryId, activeStoryContent, isDirty, setStories, moveStory, markSaved } = useBorgesStore() const [search, setSearch] = useState('') - const [contextMenu, setContextMenu] = useState(null) const [renaming, setRenaming] = useState(null) const [renameValue, setRenameValue] = useState('') const dragFrom = useRef(-1) @@ -95,15 +88,25 @@ export function StorySidebar(): JSX.Element { } const handleDelete = async (story: StoryFile): Promise => { - setContextMenu(null) await window.api.deleteStory(story.path) if (story.path === activeStoryPath) useBorgesStore.getState().clearActiveStory() const refreshed = await window.api.listStories() setStories(refreshed) } + const handleStoryContextMenu = async (e: React.MouseEvent, story: StoryFile): Promise => { + e.preventDefault() + const action = await window.api.showStoryContextMenu(story.id) + if (action === 'rename') { + setRenaming(story.id) + setRenameValue(story.id) + } else if (action === 'delete') { + handleDelete(story) + } + } + return ( -
contextMenu && setContextMenu(null)}> +
Stories @@ -139,10 +142,7 @@ export function StorySidebar(): JSX.Element { index={idx} isActive={story.id === activeStoryId} onClick={() => openStory(story)} - onContextMenu={(e) => { - e.preventDefault() - setContextMenu({ x: e.clientX, y: e.clientY, story }) - }} + onContextMenu={(e) => handleStoryContextMenu(e, story)} onDragStart={(i) => { dragFrom.current = i }} onDragOver={(i) => { if (dragFrom.current !== -1 && dragFrom.current !== i) moveStory(dragFrom.current, i) }} onDrop={() => { dragFrom.current = -1 }} @@ -152,21 +152,6 @@ export function StorySidebar(): JSX.Element { ))}
- {contextMenu && ( -
e.stopPropagation()} - > - -
- -
- )}
) } diff --git a/src/renderer/styles/app.css b/src/renderer/styles/app.css index 4b41b66..438d45b 100644 --- a/src/renderer/styles/app.css +++ b/src/renderer/styles/app.css @@ -45,7 +45,7 @@ body { font-size: 13px; background: var(--bg); color: var(--text); - -webkit-font-smoothing: antialiased; + -webkit-font-smoothing: subpixel-antialiased; user-select: none; } @@ -161,7 +161,7 @@ textarea { resize: vertical; } grid-area: sidebar; display: flex; flex-direction: column; - background: var(--bg2); + background: transparent; border-right: 1px solid var(--border); overflow: hidden; min-width: 0; @@ -296,12 +296,13 @@ textarea { resize: vertical; } flex: 1; overflow-y: auto; padding: 32px 0; + overscroll-behavior: none; } /* CodeMirror overrides */ .cm-editor { height: 100%; } .cm-editor.cm-focused { outline: none; } -.cm-scroller { font-family: 'Georgia', 'Times New Roman', serif; line-height: 1.8; font-size: 16px; transition: font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1); } +.cm-scroller { font-family: 'Georgia', 'Times New Roman', serif; line-height: 1.8; font-size: 16px; transition: font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1); overscroll-behavior: none; } .cm-content { max-width: 680px; margin: 0 auto; padding: 48px 24px 24px; transition: max-width 0.4s cubic-bezier(0.4, 0, 0.2, 1), padding 0.4s cubic-bezier(0.4, 0, 0.2, 1); } .cm-line { padding: 0; margin-bottom: 0.75em; } .cm-line:last-child { margin-bottom: 0; } diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index ef5dae3..59255a4 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -57,6 +57,8 @@ declare global { writeConfig(updates: Partial): Promise pickFolder(): Promise streamAIMessage(payload: AIPayload, onChunk: (chunk: string) => void): Promise + showEditorContextMenu(): Promise + showStoryContextMenu(storyId: string): Promise onMenuAction(handler: (action: string) => void): () => void } }