💄 native menu items
This commit is contained in:
@@ -20,7 +20,17 @@ import '@renderer/styles/app.css'
|
|||||||
saveRevision: () => Promise.resolve(),
|
saveRevision: () => Promise.resolve(),
|
||||||
listRevisions: () => Promise.resolve([]),
|
listRevisions: () => Promise.resolve([]),
|
||||||
loadRevision: () => Promise.resolve(''),
|
loadRevision: () => Promise.resolve(''),
|
||||||
deleteRevision: () => Promise.resolve()
|
deleteRevision: () => Promise.resolve(),
|
||||||
|
renameNode: () => Promise.resolve(''),
|
||||||
|
deleteNode: () => Promise.resolve(),
|
||||||
|
createFile: () => Promise.resolve(''),
|
||||||
|
createDir: () => Promise.resolve(''),
|
||||||
|
moveFile: () => Promise.resolve(''),
|
||||||
|
openStoryBible: () => Promise.resolve({ path: '', content: '' }),
|
||||||
|
writeStoryBible: () => Promise.resolve(''),
|
||||||
|
readSession: () => Promise.resolve({}),
|
||||||
|
writeSession: () => Promise.resolve(),
|
||||||
|
onMenuAction: () => () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEMO_TEXT =
|
const DEMO_TEXT =
|
||||||
|
|||||||
@@ -1,14 +1,157 @@
|
|||||||
import { app, BrowserWindow, shell, nativeImage } from 'electron'
|
import { app, BrowserWindow, shell, nativeImage, Menu, dialog } from 'electron'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { config } from 'dotenv'
|
import { config } from 'dotenv'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import { registerIpcHandlers } from './ipcHandlers'
|
import { registerIpcHandlers } from './ipcHandlers'
|
||||||
|
|
||||||
|
const DRAFT_ROOT = process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
|
||||||
|
|
||||||
|
app.setName('Hohoff')
|
||||||
|
|
||||||
// Load .env then .env.local so values in .env.local override .env
|
// Load .env then .env.local so values in .env.local override .env
|
||||||
config({ path: resolve(process.cwd(), '.env') })
|
config({ path: resolve(process.cwd(), '.env') })
|
||||||
config({ path: resolve(process.cwd(), '.env.local'), override: true })
|
config({ path: resolve(process.cwd(), '.env.local'), override: true })
|
||||||
|
|
||||||
function createWindow(): void {
|
function send(win: BrowserWindow, action: string): void {
|
||||||
|
if (!win.isDestroyed()) win.webContents.send('menu:action', action)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAppMenu(win: BrowserWindow): void {
|
||||||
|
const isMac = process.platform === 'darwin'
|
||||||
|
|
||||||
|
const template: Electron.MenuItemConstructorOptions[] = [
|
||||||
|
// ── Hohoff (macOS app menu) ──────────────────────────────────────────
|
||||||
|
...(isMac
|
||||||
|
? ([
|
||||||
|
{
|
||||||
|
label: 'Hohoff',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'About Hohoff Editor',
|
||||||
|
click: () =>
|
||||||
|
dialog.showMessageBox(win, {
|
||||||
|
type: 'info',
|
||||||
|
title: 'Hohoff Editor',
|
||||||
|
message: 'Hohoff Editor',
|
||||||
|
detail: `Version ${app.getVersion()}\n\nA writing environment for Gothic fiction.`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'services' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'hide' },
|
||||||
|
{ role: 'hideOthers' },
|
||||||
|
{ role: 'unhide' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'quit' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
] as Electron.MenuItemConstructorOptions[])
|
||||||
|
: []),
|
||||||
|
|
||||||
|
// ── File ─────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
label: 'File',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Save',
|
||||||
|
accelerator: 'CmdOrCtrl+S',
|
||||||
|
click: () => send(win, 'save')
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Open Draft Folder in Finder',
|
||||||
|
click: () => shell.openPath(DRAFT_ROOT)
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
isMac ? { role: 'close' } : { role: 'quit' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Edit ─────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'undo' },
|
||||||
|
{ role: 'redo' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'cut' },
|
||||||
|
{ role: 'copy' },
|
||||||
|
{ role: 'paste' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'selectAll' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Find / Replace',
|
||||||
|
accelerator: 'CmdOrCtrl+F',
|
||||||
|
click: () => send(win, 'find')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── View ─────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Toggle File Tree',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+1',
|
||||||
|
click: () => send(win, 'toggleSidebar')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Toggle AI Chat',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+2',
|
||||||
|
click: () => send(win, 'toggleChat')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Toggle Revision History',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+R',
|
||||||
|
click: () => send(win, 'toggleRevisions')
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Toggle Dark Mode',
|
||||||
|
click: () => send(win, 'toggleTheme')
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Increase Font Size',
|
||||||
|
accelerator: 'CmdOrCtrl+=',
|
||||||
|
click: () => send(win, 'fontIncrease')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Decrease Font Size',
|
||||||
|
accelerator: 'CmdOrCtrl+-',
|
||||||
|
click: () => send(win, 'fontDecrease')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reset Font Size',
|
||||||
|
accelerator: 'CmdOrCtrl+0',
|
||||||
|
click: () => send(win, 'fontReset')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Window ───────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
label: 'Window',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'minimize' },
|
||||||
|
{ role: 'zoom' },
|
||||||
|
...(isMac
|
||||||
|
? ([
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'front' }
|
||||||
|
] as Electron.MenuItemConstructorOptions[])
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow(): BrowserWindow {
|
||||||
const icon = nativeImage.createFromPath(
|
const icon = nativeImage.createFromPath(
|
||||||
join(__dirname, '../../resources/icon.png')
|
join(__dirname, '../../resources/icon.png')
|
||||||
)
|
)
|
||||||
@@ -44,6 +187,8 @@ function createWindow(): void {
|
|||||||
} else {
|
} else {
|
||||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return mainWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
@@ -61,7 +206,8 @@ app.whenReady().then(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
createWindow()
|
const mainWindow = createWindow()
|
||||||
|
buildAppMenu(mainWindow)
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|||||||
@@ -90,5 +90,11 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.invoke('fs:openStoryBible'),
|
ipcRenderer.invoke('fs:openStoryBible'),
|
||||||
|
|
||||||
writeStoryBible: (content: string): Promise<string> =>
|
writeStoryBible: (content: string): Promise<string> =>
|
||||||
ipcRenderer.invoke('fs:writeStoryBible', content)
|
ipcRenderer.invoke('fs:writeStoryBible', content),
|
||||||
|
|
||||||
|
onMenuAction: (handler: (action: string) => void): (() => void) => {
|
||||||
|
const listener = (_: Electron.IpcRendererEvent, action: string): void => handler(action)
|
||||||
|
ipcRenderer.on('menu:action', listener)
|
||||||
|
return () => ipcRenderer.removeListener('menu:action', listener)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import './styles/app.css'
|
|||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
const {
|
const {
|
||||||
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
|
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
|
||||||
loadSession, revisionPanelOpen, toggleRevisionPanel
|
loadSession, revisionPanelOpen, toggleRevisionPanel, fontSize, setFontSize
|
||||||
} = useEditorStore()
|
} = useEditorStore()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(
|
const [sidebarOpen, setSidebarOpen] = useState(
|
||||||
() => localStorage.getItem('sidebarOpen') !== 'false'
|
() => localStorage.getItem('sidebarOpen') !== 'false'
|
||||||
@@ -27,6 +27,33 @@ export default function App(): JSX.Element {
|
|||||||
loadSession()
|
loadSession()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Handle menu actions sent from the main process
|
||||||
|
useEffect(() => {
|
||||||
|
return window.api.onMenuAction(async (action) => {
|
||||||
|
if (action === 'save') {
|
||||||
|
if (activeFilePath && isDirty) {
|
||||||
|
await window.api.writeFile(activeFilePath, activeFileContent)
|
||||||
|
await window.api.saveRevision(activeFilePath, activeFileContent)
|
||||||
|
markSaved()
|
||||||
|
}
|
||||||
|
} else if (action === 'toggleSidebar') {
|
||||||
|
setSidebarOpen((v) => { const n = !v; localStorage.setItem('sidebarOpen', String(n)); return n })
|
||||||
|
} else if (action === 'toggleChat') {
|
||||||
|
setChatOpen((v) => { const n = !v; localStorage.setItem('chatOpen', String(n)); return n })
|
||||||
|
} else if (action === 'toggleRevisions') {
|
||||||
|
toggleRevisionPanel()
|
||||||
|
} else if (action === 'toggleTheme') {
|
||||||
|
toggleTheme()
|
||||||
|
} else if (action === 'fontIncrease') {
|
||||||
|
setFontSize(fontSize + 1)
|
||||||
|
} else if (action === 'fontDecrease') {
|
||||||
|
setFontSize(fontSize - 1)
|
||||||
|
} else if (action === 'fontReset') {
|
||||||
|
setFontSize(15)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [activeFilePath, isDirty, activeFileContent, fontSize])
|
||||||
|
|
||||||
// Handle Cmd+S / Ctrl+S
|
// Handle Cmd+S / Ctrl+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = async (e: KeyboardEvent): Promise<void> => {
|
const handler = async (e: KeyboardEvent): Promise<void> => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { EditorState, StateField, StateEffect, Annotation, RangeSetBuilder, Comp
|
|||||||
import { markdown } from '@codemirror/lang-markdown'
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
||||||
import { history, defaultKeymap, historyKeymap, invertedEffects, selectAll, indentLess } from '@codemirror/commands'
|
import { history, defaultKeymap, historyKeymap, invertedEffects, selectAll, indentLess } from '@codemirror/commands'
|
||||||
import { search, searchKeymap } from '@codemirror/search'
|
import { search, searchKeymap, openSearchPanel } from '@codemirror/search'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { useEditorStore } from '../../store/editorStore'
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
@@ -632,6 +632,14 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
view.dispatch({ effects: themeCompartment.reconfigure(buildTheme(fontSize, theme === 'dark')) })
|
view.dispatch({ effects: themeCompartment.reconfigure(buildTheme(fontSize, theme === 'dark')) })
|
||||||
}, [fontSize, theme])
|
}, [fontSize, theme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return window.api.onMenuAction((action) => {
|
||||||
|
if (action === 'find' && viewRef.current) {
|
||||||
|
openSearchPanel(viewRef.current)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
function handleContextMenu(e: React.MouseEvent): void {
|
function handleContextMenu(e: React.MouseEvent): void {
|
||||||
if (!activeFilePath) return
|
if (!activeFilePath) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
1
src/renderer/types/global.d.ts
vendored
1
src/renderer/types/global.d.ts
vendored
@@ -25,6 +25,7 @@ declare global {
|
|||||||
moveFile: (sourcePath: string, targetDirPath: string) => Promise<string>
|
moveFile: (sourcePath: string, targetDirPath: string) => Promise<string>
|
||||||
openStoryBible: () => Promise<{ path: string; content: string }>
|
openStoryBible: () => Promise<{ path: string; content: string }>
|
||||||
writeStoryBible: (content: string) => Promise<string>
|
writeStoryBible: (content: string) => Promise<string>
|
||||||
|
onMenuAction: (handler: (action: string) => void) => () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user