: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 { join } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import { registerIpcHandlers } from './ipcHandlers'
|
import { registerIpcHandlers } from './ipcHandlers'
|
||||||
import { getCollectionRoot } from './globalConfig'
|
import { getCollectionRoot, readGlobalConfig } from './globalConfig'
|
||||||
|
|
||||||
app.setName('Borges')
|
app.setName('Borges')
|
||||||
|
|
||||||
@@ -92,6 +92,8 @@ function buildAppMenu(win: BrowserWindow): void {
|
|||||||
|
|
||||||
function createWindow(): BrowserWindow {
|
function createWindow(): BrowserWindow {
|
||||||
const icon = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png'))
|
const icon = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png'))
|
||||||
|
const config = readGlobalConfig()
|
||||||
|
const isDark = config.theme !== 'light'
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1440,
|
width: 1440,
|
||||||
height: 900,
|
height: 900,
|
||||||
@@ -99,6 +101,8 @@ function createWindow(): BrowserWindow {
|
|||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
title: 'Borges',
|
title: 'Borges',
|
||||||
titleBarStyle: 'hiddenInset',
|
titleBarStyle: 'hiddenInset',
|
||||||
|
vibrancy: 'sidebar',
|
||||||
|
backgroundColor: isDark ? '#1c1a18' : '#f5f0ea',
|
||||||
icon,
|
icon,
|
||||||
show: false,
|
show: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ipcMain, dialog, BrowserWindow } from 'electron'
|
import { ipcMain, dialog, BrowserWindow, Menu } from 'electron'
|
||||||
import {
|
import {
|
||||||
listStories, readStory, writeStory, createStory, renameStory, deleteStory,
|
listStories, readStory, writeStory, createStory, renameStory, deleteStory,
|
||||||
getStoryMeta, setStoryMeta, getCollectionConfig, setCollectionContext,
|
getStoryMeta, setStoryMeta, getCollectionConfig, setCollectionContext,
|
||||||
@@ -60,6 +60,34 @@ export function registerIpcHandlers(): void {
|
|||||||
return result.canceled ? null : result.filePaths[0]
|
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 ──────────────────────────────────────────────────────────────
|
// ── AI streaming ──────────────────────────────────────────────────────────────
|
||||||
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||||
try {
|
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
|
// Menu
|
||||||
onMenuAction: (handler: (action: string) => void): (() => void) => {
|
onMenuAction: (handler: (action: string) => void): (() => void) => {
|
||||||
const listener = (_: Electron.IpcRendererEvent, action: string): void => handler(action)
|
const listener = (_: Electron.IpcRendererEvent, action: string): void => handler(action)
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
const editorRef = useRef<HTMLDivElement>(null)
|
const editorRef = useRef<HTMLDivElement>(null)
|
||||||
const viewRef = useRef<EditorView | null>(null)
|
const viewRef = useRef<EditorView | null>(null)
|
||||||
const lastPathRef = useRef<string | null>(null)
|
const lastPathRef = useRef<string | null>(null)
|
||||||
|
const storeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
|
||||||
// Initialize editor
|
// Initialize editor
|
||||||
@@ -107,10 +108,17 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
const content = update.state.doc.toString()
|
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({
|
EditorView.domEventHandlers({
|
||||||
|
contextmenu: (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
window.api.showEditorContextMenu()
|
||||||
|
},
|
||||||
keydown: (e) => {
|
keydown: (e) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||||
e.preventDefault()
|
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 {
|
export function StorySidebar(): JSX.Element {
|
||||||
const { stories, activeStoryPath, activeStoryId, activeStoryContent, isDirty, setStories, moveStory, markSaved } = useBorgesStore()
|
const { stories, activeStoryPath, activeStoryId, activeStoryContent, isDirty, setStories, moveStory, markSaved } = useBorgesStore()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
|
||||||
const [renaming, setRenaming] = useState<string | null>(null)
|
const [renaming, setRenaming] = useState<string | null>(null)
|
||||||
const [renameValue, setRenameValue] = useState('')
|
const [renameValue, setRenameValue] = useState('')
|
||||||
const dragFrom = useRef<number>(-1)
|
const dragFrom = useRef<number>(-1)
|
||||||
@@ -95,15 +88,25 @@ export function StorySidebar(): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (story: StoryFile): Promise<void> => {
|
const handleDelete = async (story: StoryFile): Promise<void> => {
|
||||||
setContextMenu(null)
|
|
||||||
await window.api.deleteStory(story.path)
|
await window.api.deleteStory(story.path)
|
||||||
if (story.path === activeStoryPath) useBorgesStore.getState().clearActiveStory()
|
if (story.path === activeStoryPath) useBorgesStore.getState().clearActiveStory()
|
||||||
const refreshed = await window.api.listStories()
|
const refreshed = await window.api.listStories()
|
||||||
setStories(refreshed)
|
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 (
|
return (
|
||||||
<div className="sidebar" onClick={() => contextMenu && setContextMenu(null)}>
|
<div className="sidebar">
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<span className="sidebar-title">Stories</span>
|
<span className="sidebar-title">Stories</span>
|
||||||
<button className="sidebar-btn" onClick={handleNew} title="New story">+</button>
|
<button className="sidebar-btn" onClick={handleNew} title="New story">+</button>
|
||||||
@@ -139,10 +142,7 @@ export function StorySidebar(): JSX.Element {
|
|||||||
index={idx}
|
index={idx}
|
||||||
isActive={story.id === activeStoryId}
|
isActive={story.id === activeStoryId}
|
||||||
onClick={() => openStory(story)}
|
onClick={() => openStory(story)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => handleStoryContextMenu(e, story)}
|
||||||
e.preventDefault()
|
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, story })
|
|
||||||
}}
|
|
||||||
onDragStart={(i) => { dragFrom.current = i }}
|
onDragStart={(i) => { dragFrom.current = i }}
|
||||||
onDragOver={(i) => { if (dragFrom.current !== -1 && dragFrom.current !== i) moveStory(dragFrom.current, i) }}
|
onDragOver={(i) => { if (dragFrom.current !== -1 && dragFrom.current !== i) moveStory(dragFrom.current, i) }}
|
||||||
onDrop={() => { dragFrom.current = -1 }}
|
onDrop={() => { dragFrom.current = -1 }}
|
||||||
@@ -152,21 +152,6 @@ export function StorySidebar(): JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ body {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: subpixel-antialiased;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ textarea { resize: vertical; }
|
|||||||
grid-area: sidebar;
|
grid-area: sidebar;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg2);
|
background: transparent;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -296,12 +296,13 @@ textarea { resize: vertical; }
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 32px 0;
|
padding: 32px 0;
|
||||||
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CodeMirror overrides */
|
/* CodeMirror overrides */
|
||||||
.cm-editor { height: 100%; }
|
.cm-editor { height: 100%; }
|
||||||
.cm-editor.cm-focused { outline: none; }
|
.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-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 { padding: 0; margin-bottom: 0.75em; }
|
||||||
.cm-line:last-child { margin-bottom: 0; }
|
.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>
|
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
|
||||||
pickFolder(): Promise<string | null>
|
pickFolder(): Promise<string | null>
|
||||||
streamAIMessage(payload: AIPayload, onChunk: (chunk: string) => void): Promise<void>
|
streamAIMessage(payload: AIPayload, onChunk: (chunk: string) => void): Promise<void>
|
||||||
|
showEditorContextMenu(): Promise<void>
|
||||||
|
showStoryContextMenu(storyId: string): Promise<string | null>
|
||||||
onMenuAction(handler: (action: string) => void): () => void
|
onMenuAction(handler: (action: string) => void): () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user