:lightning: macOS performance
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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<string | null>((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 {
|
||||
|
||||
@@ -66,6 +66,10 @@ contextBridge.exposeInMainWorld('api', {
|
||||
})
|
||||
},
|
||||
|
||||
// Native context menus
|
||||
showEditorContextMenu: (): Promise<void> => ipcRenderer.invoke('menu:editorContext'),
|
||||
showStoryContextMenu: (storyId: string): Promise<string | null> => ipcRenderer.invoke('menu:storyContext', storyId),
|
||||
|
||||
// Menu
|
||||
onMenuAction: (handler: (action: string) => void): (() => void) => {
|
||||
const listener = (_: Electron.IpcRendererEvent, action: string): void => handler(action)
|
||||
|
||||
@@ -83,6 +83,7 @@ export function MarkdownEditor(): JSX.Element {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const lastPathRef = useRef<string | null>(null)
|
||||
const storeTimerRef = useRef<ReturnType<typeof setTimeout> | 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()
|
||||
|
||||
@@ -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<ContextMenuState | null>(null)
|
||||
const [renaming, setRenaming] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const dragFrom = useRef<number>(-1)
|
||||
@@ -95,15 +88,25 @@ export function StorySidebar(): JSX.Element {
|
||||
}
|
||||
|
||||
const handleDelete = async (story: StoryFile): Promise<void> => {
|
||||
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<void> => {
|
||||
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 (
|
||||
<div className="sidebar" onClick={() => contextMenu && setContextMenu(null)}>
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<span className="sidebar-title">Stories</span>
|
||||
<button className="sidebar-btn" onClick={handleNew} title="New story">+</button>
|
||||
@@ -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 {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="context-menu"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button className="context-menu-item" onClick={() => {
|
||||
setRenaming(contextMenu.story.id)
|
||||
setRenameValue(contextMenu.story.id)
|
||||
setContextMenu(null)
|
||||
}}>Rename</button>
|
||||
<div className="context-menu-sep" />
|
||||
<button className="context-menu-item danger" onClick={() => handleDelete(contextMenu.story)}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
2
src/renderer/types/global.d.ts
vendored
2
src/renderer/types/global.d.ts
vendored
@@ -57,6 +57,8 @@ declare global {
|
||||
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
|
||||
pickFolder(): Promise<string | null>
|
||||
streamAIMessage(payload: AIPayload, onChunk: (chunk: string) => void): Promise<void>
|
||||
showEditorContextMenu(): Promise<void>
|
||||
showStoryContextMenu(storyId: string): Promise<string | null>
|
||||
onMenuAction(handler: (action: string) => void): () => void
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user