@bytechain.cn/colamd 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +66 -0
- package/.trae/documents/fix-mermaid-colors-and-sankey.md +50 -0
- package/CLAUDE.md +87 -0
- package/LICENSE +21 -0
- package/README.md +540 -0
- package/README_CN.md +543 -0
- package/demo.md +486 -0
- package/dist/main/index.js +735 -0
- package/dist/preload/index.js +71 -0
- package/dist/renderer/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/renderer/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/renderer/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/renderer/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/renderer/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/renderer/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/renderer/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/renderer/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/renderer/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/renderer/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/renderer/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/renderer/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/renderer/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/renderer/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/renderer/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/renderer/assets/arc-tTbbM8LO.js +131 -0
- package/dist/renderer/assets/architectureDiagram-3BPJPVTR-CEgYow6c.js +8720 -0
- package/dist/renderer/assets/blockDiagram-GPEHLZMM-LHyVtPwW.js +3825 -0
- package/dist/renderer/assets/c4Diagram-AAUBKEIU-C1P1eJrf.js +2482 -0
- package/dist/renderer/assets/channel-upve91Tq.js +7 -0
- package/dist/renderer/assets/chunk-2J33WTMH-lag2vhq9.js +24 -0
- package/dist/renderer/assets/chunk-4BX2VUAB-BXJ8Ggh-.js +16 -0
- package/dist/renderer/assets/chunk-55IACEB6-CiBpxRa1.js +13 -0
- package/dist/renderer/assets/chunk-727SXJPM-ODeKQFXC.js +2016 -0
- package/dist/renderer/assets/chunk-AQP2D5EJ-BK7xJolB.js +1953 -0
- package/dist/renderer/assets/chunk-FMBD7UC4-BxpCZPtz.js +19 -0
- package/dist/renderer/assets/chunk-ND2GUHAM-CqqaU9Ue.js +116 -0
- package/dist/renderer/assets/chunk-QZHKN3VN-Biq_K124.js +19 -0
- package/dist/renderer/assets/classDiagram-4FO5ZUOK-Cq95X99o.js +23 -0
- package/dist/renderer/assets/classDiagram-v2-Q7XG4LA2-Cq95X99o.js +23 -0
- package/dist/renderer/assets/cose-bilkent-S5V4N54A-XasiD0bu.js +4942 -0
- package/dist/renderer/assets/cytoscape.esm-CpHeHM5e.js +30269 -0
- package/dist/renderer/assets/dagre-BM42HDAG-Nq84Gfx4.js +705 -0
- package/dist/renderer/assets/defaultLocale-B2RvLBDe.js +206 -0
- package/dist/renderer/assets/diagram-2AECGRRQ-DwuB1GWt.js +301 -0
- package/dist/renderer/assets/diagram-5GNKFQAL-C2tgeI1h.js +169 -0
- package/dist/renderer/assets/diagram-KO2AKTUF-D5KzjNBc.js +632 -0
- package/dist/renderer/assets/diagram-LMA3HP47-C12xHS1c.js +212 -0
- package/dist/renderer/assets/diagram-OG6HWLK6-CnxI9oEa.js +851 -0
- package/dist/renderer/assets/erDiagram-TEJ5UH35-D_uPaKwn.js +1227 -0
- package/dist/renderer/assets/flowDiagram-I6XJVG4X-B6q_1-tE.js +2332 -0
- package/dist/renderer/assets/ganttDiagram-6RSMTGT7-CFo7ifF9.js +3720 -0
- package/dist/renderer/assets/gitGraphDiagram-PVQCEYII-WSexHTnq.js +1373 -0
- package/dist/renderer/assets/graph-DyX_9f6d.js +1988 -0
- package/dist/renderer/assets/index-DW7LS8C1.js +72292 -0
- package/dist/renderer/assets/index-dyHEFYvY.css +2184 -0
- package/dist/renderer/assets/infoDiagram-5YYISTIA-DaeJdLRq.js +31 -0
- package/dist/renderer/assets/init-ZxktEp_H.js +16 -0
- package/dist/renderer/assets/ishikawaDiagram-YF4QCWOH-DDCZc35f.js +967 -0
- package/dist/renderer/assets/journeyDiagram-JHISSGLW-BEdmpAgl.js +1255 -0
- package/dist/renderer/assets/kanban-definition-UN3LZRKU-BEFtQcFb.js +1052 -0
- package/dist/renderer/assets/layout-CAJgQHdw.js +2610 -0
- package/dist/renderer/assets/linear-B2ggJ8Am.js +340 -0
- package/dist/renderer/assets/mindmap-definition-RKZ34NQL-DSxVgHB5.js +1180 -0
- package/dist/renderer/assets/ordinal-DSZU4PqD.js +76 -0
- package/dist/renderer/assets/pieDiagram-4H26LBE5-CwYoJBuL.js +246 -0
- package/dist/renderer/assets/quadrantDiagram-W4KKPZXB-CST9Fvg9.js +1344 -0
- package/dist/renderer/assets/requirementDiagram-4Y6WPE33-DtrH52jS.js +1204 -0
- package/dist/renderer/assets/sankeyDiagram-5OEKKPKP-ca1tPzJ_.js +1274 -0
- package/dist/renderer/assets/sequenceDiagram-3UESZ5HK-Dfp1EJZ7.js +4514 -0
- package/dist/renderer/assets/stateDiagram-AJRCARHV-Bha2QoNB.js +450 -0
- package/dist/renderer/assets/stateDiagram-v2-BHNVJYJU-DWgFUYu1.js +21 -0
- package/dist/renderer/assets/timeline-definition-PNZ67QCA-C3h_-OTj.js +1596 -0
- package/dist/renderer/assets/vennDiagram-CIIHVFJN-DFzjSrZi.js +2486 -0
- package/dist/renderer/assets/wardley-L42UT6IY-Cx-VbqoS.js +30699 -0
- package/dist/renderer/assets/wardleyDiagram-YWT4CUSO-S2D9XqX6.js +975 -0
- package/dist/renderer/assets/xychartDiagram-2RQKCTM6-Cfxigbts.js +1932 -0
- package/dist/renderer/index.html +19 -0
- package/docs/agent-diff-view.md +48 -0
- package/electron-builder.yml +57 -0
- package/electron.vite.config.ts +30 -0
- package/package.json +40 -0
- package/resources/entitlements.mac.plist +12 -0
- package/resources/icon.icns +0 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +23 -0
- package/resources/templates/slides/icon.png +0 -0
- package/resources/templates/slides/slides-template.md +74 -0
- package/resources/templates/slides/template.html +535 -0
- package/scripts/afterPack.js +13 -0
- package/src/main/index.ts +881 -0
- package/src/preload/index.ts +110 -0
- package/src/renderer/editor/editor.ts +204 -0
- package/src/renderer/editor/html-view.ts +15 -0
- package/src/renderer/editor/plugins/index.ts +76 -0
- package/src/renderer/editor/plugins/math-plugin.ts +297 -0
- package/src/renderer/editor/plugins/mermaid-plugin-custom.css +431 -0
- package/src/renderer/editor/plugins/mermaid-plugin-dark.css +428 -0
- package/src/renderer/editor/plugins/mermaid-plugin-elegant.css +443 -0
- package/src/renderer/editor/plugins/mermaid-plugin-newsprint.css +208 -0
- package/src/renderer/editor/plugins/mermaid-plugin.css +111 -0
- package/src/renderer/editor/plugins/mermaid-plugin.ts +679 -0
- package/src/renderer/env.d.ts +7 -0
- package/src/renderer/index.html +18 -0
- package/src/renderer/main.ts +303 -0
- package/src/renderer/themes/base.css +509 -0
- package/src/renderer/themes/theme-manager.ts +40 -0
- package/themes/README.md +280 -0
- package/themes/elegant.css +664 -0
- package/themes/guizang.css +732 -0
- package/tsconfig.json +14 -0
- package/tsconfig.main.json +11 -0
- package/tsconfig.preload.json +11 -0
- package/tsconfig.renderer.json +12 -0
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
import { app, BrowserWindow, ipcMain, dialog, Menu, shell } from 'electron'
|
|
2
|
+
import { join, basename, dirname, extname } from 'path'
|
|
3
|
+
import { readFile, writeFile, readdir, copyFile, mkdir } from 'fs/promises'
|
|
4
|
+
import { watch, FSWatcher, existsSync, readdirSync, readFileSync, createServer } from 'fs'
|
|
5
|
+
import { IncomingMessage, ServerResponse } from 'http'
|
|
6
|
+
import { createServer as createHttpServer } from 'http'
|
|
7
|
+
|
|
8
|
+
// Custom themes directory
|
|
9
|
+
const themesDir = join(app.getPath('home'), '.colamd', 'themes')
|
|
10
|
+
|
|
11
|
+
function ensureThemesDir(): void {
|
|
12
|
+
if (!existsSync(themesDir)) {
|
|
13
|
+
mkdir(themesDir, { recursive: true }).catch(() => {})
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function scanCustomThemes(): Promise<string[]> {
|
|
18
|
+
try {
|
|
19
|
+
const files = await readdir(themesDir)
|
|
20
|
+
return files.filter(f => f.endsWith('.css')).sort()
|
|
21
|
+
} catch {
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Per-window state
|
|
27
|
+
interface WindowState {
|
|
28
|
+
filePath: string | null
|
|
29
|
+
watcher: FSWatcher | null
|
|
30
|
+
isInternalSave: boolean
|
|
31
|
+
debounceTimer: ReturnType<typeof setTimeout> | null
|
|
32
|
+
agentState: 'idle' | 'active' | 'cooldown'
|
|
33
|
+
lastExternalChange: number
|
|
34
|
+
agentCooldownTimer: ReturnType<typeof setTimeout> | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const windowStates = new Map<number, WindowState>()
|
|
38
|
+
let pendingFilePaths: string[] = []
|
|
39
|
+
|
|
40
|
+
// Plugin menu state — populated by renderer via register-plugins IPC
|
|
41
|
+
let pluginMenuItems: Electron.MenuItemConstructorOptions[] = []
|
|
42
|
+
|
|
43
|
+
function getState(win: BrowserWindow): WindowState {
|
|
44
|
+
let state = windowStates.get(win.id)
|
|
45
|
+
if (!state) {
|
|
46
|
+
state = { filePath: null, watcher: null, isInternalSave: false, debounceTimer: null, agentState: 'idle', lastExternalChange: 0, agentCooldownTimer: null }
|
|
47
|
+
windowStates.set(win.id, state)
|
|
48
|
+
}
|
|
49
|
+
return state
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getWinFromEvent(event: Electron.IpcMainInvokeEvent): BrowserWindow | null {
|
|
53
|
+
return BrowserWindow.fromWebContents(event.sender)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createWindow(filePath?: string): BrowserWindow {
|
|
57
|
+
const win = new BrowserWindow({
|
|
58
|
+
width: 960,
|
|
59
|
+
height: 720,
|
|
60
|
+
minWidth: 600,
|
|
61
|
+
minHeight: 400,
|
|
62
|
+
titleBarStyle: 'hiddenInset',
|
|
63
|
+
trafficLightPosition: { x: 16, y: 16 },
|
|
64
|
+
webPreferences: {
|
|
65
|
+
preload: join(__dirname, '../preload/index.js'),
|
|
66
|
+
contextIsolation: true,
|
|
67
|
+
nodeIntegration: false,
|
|
68
|
+
sandbox: false
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const state = getState(win)
|
|
73
|
+
|
|
74
|
+
if (process.env.ELECTRON_RENDERER_URL) {
|
|
75
|
+
win.loadURL(process.env.ELECTRON_RENDERER_URL)
|
|
76
|
+
} else {
|
|
77
|
+
win.loadFile(join(__dirname, '../renderer/index.html'))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
win.webContents.on('did-finish-load', () => {
|
|
81
|
+
if (filePath) {
|
|
82
|
+
loadFileInWindow(win, filePath)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
win.on('closed', () => {
|
|
87
|
+
stopWatching(state)
|
|
88
|
+
windowStates.delete(win.id)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
updateTitle(win)
|
|
92
|
+
return win
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function updateTitle(win: BrowserWindow): void {
|
|
96
|
+
const state = getState(win)
|
|
97
|
+
const fileName = state.filePath ? basename(state.filePath) : 'Untitled'
|
|
98
|
+
win.setTitle(`${fileName} — ColaMD`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function suggestFileName(win: BrowserWindow, content?: string): string | undefined {
|
|
102
|
+
const state = getState(win)
|
|
103
|
+
if (state.filePath) return basename(state.filePath, '.md')
|
|
104
|
+
if (!content) return undefined
|
|
105
|
+
// Extract first heading or first non-empty line
|
|
106
|
+
const match = content.match(/^#\s+(.+)/m) || content.match(/^(.+)/m)
|
|
107
|
+
if (!match) return undefined
|
|
108
|
+
return match[1].trim().replace(/[/\\:*?"<>|]/g, '').slice(0, 60) || undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function stopWatching(state: WindowState): void {
|
|
112
|
+
if (state.watcher) {
|
|
113
|
+
state.watcher.close()
|
|
114
|
+
state.watcher = null
|
|
115
|
+
}
|
|
116
|
+
if (state.agentCooldownTimer) {
|
|
117
|
+
clearTimeout(state.agentCooldownTimer)
|
|
118
|
+
state.agentCooldownTimer = null
|
|
119
|
+
}
|
|
120
|
+
state.agentState = 'idle'
|
|
121
|
+
state.lastExternalChange = 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function transitionAgentState(win: BrowserWindow, state: WindowState, newState: 'idle' | 'active' | 'cooldown'): void {
|
|
125
|
+
if (state.agentCooldownTimer) {
|
|
126
|
+
clearTimeout(state.agentCooldownTimer)
|
|
127
|
+
state.agentCooldownTimer = null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (newState === 'active') {
|
|
131
|
+
if (state.agentState !== 'active') {
|
|
132
|
+
state.agentState = 'active'
|
|
133
|
+
if (!win.isDestroyed()) win.webContents.send('agent-activity', 'active')
|
|
134
|
+
}
|
|
135
|
+
// Reset cooldown timer — 3s after last write
|
|
136
|
+
state.agentCooldownTimer = setTimeout(() => {
|
|
137
|
+
transitionAgentState(win, state, 'cooldown')
|
|
138
|
+
}, 3000)
|
|
139
|
+
} else if (newState === 'cooldown') {
|
|
140
|
+
state.agentState = 'cooldown'
|
|
141
|
+
if (!win.isDestroyed()) win.webContents.send('agent-activity', 'cooldown')
|
|
142
|
+
state.agentCooldownTimer = setTimeout(() => {
|
|
143
|
+
transitionAgentState(win, state, 'idle')
|
|
144
|
+
}, 2000)
|
|
145
|
+
} else {
|
|
146
|
+
state.agentState = 'idle'
|
|
147
|
+
if (!win.isDestroyed()) win.webContents.send('agent-activity', 'idle')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function watchFile(win: BrowserWindow, state: WindowState): void {
|
|
152
|
+
if (!state.filePath) return
|
|
153
|
+
stopWatching(state)
|
|
154
|
+
const filePath = state.filePath
|
|
155
|
+
state.watcher = watch(filePath, (eventType) => {
|
|
156
|
+
if (eventType !== 'change' || state.isInternalSave) return
|
|
157
|
+
|
|
158
|
+
// Agent activity detection
|
|
159
|
+
const now = Date.now()
|
|
160
|
+
const gap = now - state.lastExternalChange
|
|
161
|
+
state.lastExternalChange = now
|
|
162
|
+
if (gap > 0 && gap < 2000) {
|
|
163
|
+
transitionAgentState(win, state, 'active')
|
|
164
|
+
} else if (state.agentState === 'active') {
|
|
165
|
+
transitionAgentState(win, state, 'active') // reset cooldown timer
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (state.debounceTimer) clearTimeout(state.debounceTimer)
|
|
169
|
+
state.debounceTimer = setTimeout(() => {
|
|
170
|
+
readFile(filePath, 'utf-8')
|
|
171
|
+
.then((data) => {
|
|
172
|
+
if (!win.isDestroyed()) win.webContents.send('file-changed', resolveImagePaths(data, filePath))
|
|
173
|
+
})
|
|
174
|
+
.catch(() => {})
|
|
175
|
+
}, 100)
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Rewrite relative image paths in markdown to absolute file:// URLs
|
|
180
|
+
function resolveImagePaths(content: string, filePath: string): string {
|
|
181
|
+
const dir = dirname(filePath)
|
|
182
|
+
return content.replace(/!\[([^\]]*)\]\((?!https?:\/\/|file:\/\/|data:)([^)]+)\)/g, (_match, alt, src) => {
|
|
183
|
+
const abs = join(dir, src)
|
|
184
|
+
return ``
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function loadFileInWindow(win: BrowserWindow, filePath: string): void {
|
|
189
|
+
readFile(filePath, 'utf-8')
|
|
190
|
+
.then((data) => {
|
|
191
|
+
const state = getState(win)
|
|
192
|
+
state.filePath = filePath
|
|
193
|
+
watchFile(win, state)
|
|
194
|
+
updateTitle(win)
|
|
195
|
+
win.webContents.send('file-opened', { path: filePath, content: resolveImagePaths(data, filePath) })
|
|
196
|
+
})
|
|
197
|
+
.catch(() => {})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Find window that already has this file open
|
|
201
|
+
function findWindowForFile(filePath: string): BrowserWindow | null {
|
|
202
|
+
for (const [id, state] of windowStates) {
|
|
203
|
+
if (state.filePath === filePath) {
|
|
204
|
+
return BrowserWindow.fromId(id) || null
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Open file: reuse existing window or create new one
|
|
211
|
+
function openFile(filePath: string): void {
|
|
212
|
+
// If already open, focus that window
|
|
213
|
+
const existing = findWindowForFile(filePath)
|
|
214
|
+
if (existing) {
|
|
215
|
+
existing.focus()
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Find an untitled empty window to reuse
|
|
220
|
+
const emptyWin = findEmptyWindow()
|
|
221
|
+
if (emptyWin) {
|
|
222
|
+
loadFileInWindow(emptyWin, filePath)
|
|
223
|
+
emptyWin.focus()
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Create new window
|
|
228
|
+
const win = createWindow(filePath)
|
|
229
|
+
win.focus()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function findEmptyWindow(): BrowserWindow | null {
|
|
233
|
+
for (const [id, state] of windowStates) {
|
|
234
|
+
if (!state.filePath) {
|
|
235
|
+
return BrowserWindow.fromId(id) || null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function saveToPath(win: BrowserWindow, filePath: string, content: string): Promise<boolean> {
|
|
242
|
+
const state = getState(win)
|
|
243
|
+
try {
|
|
244
|
+
state.isInternalSave = true
|
|
245
|
+
await writeFile(filePath, content, 'utf-8')
|
|
246
|
+
state.filePath = filePath
|
|
247
|
+
watchFile(win, state)
|
|
248
|
+
updateTitle(win)
|
|
249
|
+
return true
|
|
250
|
+
} catch {
|
|
251
|
+
return false
|
|
252
|
+
} finally {
|
|
253
|
+
setTimeout(() => { state.isInternalSave = false }, 100)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// IPC Handlers
|
|
258
|
+
|
|
259
|
+
ipcMain.on('open-external', (_event, url: string) => {
|
|
260
|
+
if (typeof url === 'string' && (url.startsWith('https://') || url.startsWith('http://'))) {
|
|
261
|
+
shell.openExternal(url)
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
ipcMain.handle('open-file', async (event) => {
|
|
266
|
+
const win = getWinFromEvent(event)
|
|
267
|
+
if (!win) return null
|
|
268
|
+
const result = await dialog.showOpenDialog(win, {
|
|
269
|
+
filters: [
|
|
270
|
+
{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] },
|
|
271
|
+
{ name: 'Text', extensions: ['txt'] },
|
|
272
|
+
{ name: 'All Files', extensions: ['*'] }
|
|
273
|
+
],
|
|
274
|
+
properties: ['openFile']
|
|
275
|
+
})
|
|
276
|
+
if (result.canceled || result.filePaths.length === 0) return null
|
|
277
|
+
|
|
278
|
+
const filePath = result.filePaths[0]
|
|
279
|
+
|
|
280
|
+
// If this window has no file, load here; otherwise open in new window
|
|
281
|
+
const state = getState(win)
|
|
282
|
+
if (!state.filePath) {
|
|
283
|
+
try {
|
|
284
|
+
const content = await readFile(filePath, 'utf-8')
|
|
285
|
+
state.filePath = filePath
|
|
286
|
+
watchFile(win, state)
|
|
287
|
+
updateTitle(win)
|
|
288
|
+
return { path: filePath, content }
|
|
289
|
+
} catch {
|
|
290
|
+
return null
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
openFile(filePath)
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
ipcMain.handle('open-file-path', async (event, filePath: string) => {
|
|
299
|
+
const win = getWinFromEvent(event)
|
|
300
|
+
if (!win) return null
|
|
301
|
+
const state = getState(win)
|
|
302
|
+
|
|
303
|
+
// If this window has no file, load here
|
|
304
|
+
if (!state.filePath) {
|
|
305
|
+
try {
|
|
306
|
+
const content = await readFile(filePath, 'utf-8')
|
|
307
|
+
state.filePath = filePath
|
|
308
|
+
watchFile(win, state)
|
|
309
|
+
updateTitle(win)
|
|
310
|
+
return { path: filePath, content }
|
|
311
|
+
} catch {
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
openFile(filePath)
|
|
316
|
+
return null
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
ipcMain.handle('save-file', async (event, content: string) => {
|
|
321
|
+
const win = getWinFromEvent(event)
|
|
322
|
+
if (!win) return false
|
|
323
|
+
const state = getState(win)
|
|
324
|
+
if (!state.filePath) {
|
|
325
|
+
const result = await dialog.showSaveDialog(win, {
|
|
326
|
+
defaultPath: suggestFileName(win, content),
|
|
327
|
+
filters: [
|
|
328
|
+
{ name: 'Markdown', extensions: ['md'] },
|
|
329
|
+
{ name: 'All Files', extensions: ['*'] }
|
|
330
|
+
]
|
|
331
|
+
})
|
|
332
|
+
if (result.canceled || !result.filePath) return false
|
|
333
|
+
state.filePath = result.filePath
|
|
334
|
+
// Copy slides assets alongside the file if this looks like a slides file
|
|
335
|
+
if (content.includes('kicker:') || content.includes('chip:')) {
|
|
336
|
+
const destDir = dirname(state.filePath)
|
|
337
|
+
try {
|
|
338
|
+
const files = await readdir(slidesTemplateDir)
|
|
339
|
+
await Promise.all(files.filter(f => f !== 'slides-template.md').map(async (f) => {
|
|
340
|
+
const dest = join(destDir, f)
|
|
341
|
+
if (!existsSync(dest)) await copyFile(join(slidesTemplateDir, f), dest)
|
|
342
|
+
}))
|
|
343
|
+
} catch { /* best effort */ }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return saveToPath(win, state.filePath, content)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
ipcMain.handle('save-file-as', async (event, content: string) => {
|
|
350
|
+
const win = getWinFromEvent(event)
|
|
351
|
+
if (!win) return false
|
|
352
|
+
const result = await dialog.showSaveDialog(win, {
|
|
353
|
+
defaultPath: suggestFileName(win, content),
|
|
354
|
+
filters: [
|
|
355
|
+
{ name: 'Markdown', extensions: ['md'] },
|
|
356
|
+
{ name: 'All Files', extensions: ['*'] }
|
|
357
|
+
]
|
|
358
|
+
})
|
|
359
|
+
if (result.canceled || !result.filePath) return false
|
|
360
|
+
return saveToPath(win, result.filePath, content)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
ipcMain.handle('save-export-file', async (event, dataUrl: string, defaultName: string) => {
|
|
364
|
+
const win = getWinFromEvent(event)
|
|
365
|
+
if (!win) return false
|
|
366
|
+
const ext = (defaultName.match(/\.(\w+)$/)?.[1] || 'png').toLowerCase()
|
|
367
|
+
const result = await dialog.showSaveDialog(win, {
|
|
368
|
+
defaultPath: defaultName,
|
|
369
|
+
filters: [{ name: `${ext.toUpperCase()} File`, extensions: [ext] }]
|
|
370
|
+
})
|
|
371
|
+
if (result.canceled || !result.filePath) return false
|
|
372
|
+
try {
|
|
373
|
+
const base64 = dataUrl.replace(/^data:[^;]+;base64,/, '')
|
|
374
|
+
await writeFile(result.filePath, Buffer.from(base64, 'base64'))
|
|
375
|
+
return true
|
|
376
|
+
} catch {
|
|
377
|
+
return false
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
ipcMain.handle('export-pdf', async (event) => {
|
|
383
|
+
const win = getWinFromEvent(event)
|
|
384
|
+
if (!win) return false
|
|
385
|
+
const result = await dialog.showSaveDialog(win, {
|
|
386
|
+
defaultPath: suggestFileName(win),
|
|
387
|
+
filters: [{ name: 'PDF', extensions: ['pdf'] }]
|
|
388
|
+
})
|
|
389
|
+
if (result.canceled || !result.filePath) return false
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const cssKey = await win.webContents.insertCSS(
|
|
393
|
+
'html, body { height: auto !important; overflow: visible !important; } #titlebar { display: none !important; } #editor { height: auto !important; overflow: visible !important; } #editor .ProseMirror { min-height: auto !important; } .mermaid-error { display: none !important; } .mermaid-loading { display: none !important; } svg .error-icon, svg .error-text { display: none !important; } .math-inline-raw, .math-block-raw, textarea.mermaid-source { display: none !important; }'
|
|
394
|
+
)
|
|
395
|
+
const pdfData = await win.webContents.printToPDF({
|
|
396
|
+
marginType: 0,
|
|
397
|
+
printBackground: true,
|
|
398
|
+
pageSize: 'A4'
|
|
399
|
+
})
|
|
400
|
+
await win.webContents.removeInsertedCSS(cssKey)
|
|
401
|
+
await writeFile(result.filePath, pdfData)
|
|
402
|
+
return true
|
|
403
|
+
} catch {
|
|
404
|
+
return false
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
ipcMain.handle('export-html', async (event, htmlContent: string) => {
|
|
409
|
+
const win = getWinFromEvent(event)
|
|
410
|
+
if (!win) return false
|
|
411
|
+
const result = await dialog.showSaveDialog(win, {
|
|
412
|
+
defaultPath: suggestFileName(win),
|
|
413
|
+
filters: [{ name: 'HTML', extensions: ['html'] }]
|
|
414
|
+
})
|
|
415
|
+
if (result.canceled || !result.filePath) return false
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
await writeFile(result.filePath, htmlContent, 'utf-8')
|
|
419
|
+
return true
|
|
420
|
+
} catch {
|
|
421
|
+
return false
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
// ─── Slides feature ──────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
const slidesTemplateDir = app.isPackaged
|
|
428
|
+
? join(process.resourcesPath, 'templates', 'slides')
|
|
429
|
+
: join(__dirname, '../../resources/templates/slides')
|
|
430
|
+
|
|
431
|
+
// Per-directory HTTP servers for slides preview: dir -> { server, port }
|
|
432
|
+
const slidesServers = new Map<string, { port: number; server: ReturnType<typeof createHttpServer> }>()
|
|
433
|
+
|
|
434
|
+
const MIME: Record<string, string> = {
|
|
435
|
+
'.html': 'text/html',
|
|
436
|
+
'.md': 'text/plain',
|
|
437
|
+
'.css': 'text/css',
|
|
438
|
+
'.js': 'application/javascript',
|
|
439
|
+
'.png': 'image/png',
|
|
440
|
+
'.jpg': 'image/jpeg',
|
|
441
|
+
'.jpeg': 'image/jpeg',
|
|
442
|
+
'.mp4': 'video/mp4',
|
|
443
|
+
'.webm': 'video/webm',
|
|
444
|
+
'.svg': 'image/svg+xml',
|
|
445
|
+
'.ico': 'image/x-icon',
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function getOrCreateSlidesServer(dir: string): Promise<number> {
|
|
449
|
+
const existing = slidesServers.get(dir)
|
|
450
|
+
if (existing) return Promise.resolve(existing.port)
|
|
451
|
+
|
|
452
|
+
return new Promise((resolve, reject) => {
|
|
453
|
+
const server = createHttpServer((req: IncomingMessage, res: ServerResponse) => {
|
|
454
|
+
const url = req.url === '/' ? '/template.html' : (req.url || '/')
|
|
455
|
+
const filePath = join(dir, url.split('?')[0])
|
|
456
|
+
const ext = extname(filePath).toLowerCase()
|
|
457
|
+
const mime = MIME[ext] || 'application/octet-stream'
|
|
458
|
+
try {
|
|
459
|
+
const data = readFileSync(filePath)
|
|
460
|
+
res.writeHead(200, { 'Content-Type': mime })
|
|
461
|
+
res.end(data)
|
|
462
|
+
} catch {
|
|
463
|
+
res.writeHead(404)
|
|
464
|
+
res.end('Not found')
|
|
465
|
+
}
|
|
466
|
+
})
|
|
467
|
+
server.listen(0, '127.0.0.1', () => {
|
|
468
|
+
const addr = server.address()
|
|
469
|
+
if (!addr || typeof addr === 'string') { reject(new Error('no port')); return }
|
|
470
|
+
slidesServers.set(dir, { port: addr.port, server })
|
|
471
|
+
resolve(addr.port)
|
|
472
|
+
})
|
|
473
|
+
server.on('error', reject)
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// New Slides: load template into editor without saving first (⌘S saves later)
|
|
478
|
+
// Also copy assets (template.html, icon.png) to the save directory when user saves
|
|
479
|
+
ipcMain.handle('new-slides', async (event) => {
|
|
480
|
+
const win = getWinFromEvent(event)
|
|
481
|
+
if (!win) return null
|
|
482
|
+
try {
|
|
483
|
+
const content = await readFile(join(slidesTemplateDir, 'slides-template.md'), 'utf-8')
|
|
484
|
+
win.webContents.send('new-slides-content', content)
|
|
485
|
+
return true
|
|
486
|
+
} catch {
|
|
487
|
+
return null
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
// Open as Slides: serve the directory containing the current .md file
|
|
492
|
+
// If no file is open, first create a new slides file (same as New Slides)
|
|
493
|
+
ipcMain.handle('open-as-slides', async (event, content?: string) => {
|
|
494
|
+
const win = getWinFromEvent(event)
|
|
495
|
+
if (!win) return false
|
|
496
|
+
const state = getState(win)
|
|
497
|
+
|
|
498
|
+
// No file open — create one first
|
|
499
|
+
if (!state.filePath) {
|
|
500
|
+
const result = await dialog.showSaveDialog(win, {
|
|
501
|
+
title: 'Create New Slides',
|
|
502
|
+
defaultPath: 'slides.md',
|
|
503
|
+
filters: [{ name: 'Markdown', extensions: ['md'] }]
|
|
504
|
+
})
|
|
505
|
+
if (result.canceled || !result.filePath) return false
|
|
506
|
+
try {
|
|
507
|
+
await copyFile(join(slidesTemplateDir, 'slides-template.md'), result.filePath)
|
|
508
|
+
loadFileInWindow(win, result.filePath)
|
|
509
|
+
state.filePath = result.filePath
|
|
510
|
+
} catch {
|
|
511
|
+
return false
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Auto-save current content to disk before opening browser
|
|
516
|
+
if (content !== undefined && state.filePath) {
|
|
517
|
+
try {
|
|
518
|
+
await writeFile(state.filePath, content, 'utf-8')
|
|
519
|
+
} catch { /* best effort */ }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const dir = dirname(state.filePath)
|
|
523
|
+
const mdName = basename(state.filePath)
|
|
524
|
+
|
|
525
|
+
// Always overwrite template.html so updates take effect
|
|
526
|
+
const templateDest = join(dir, 'template.html')
|
|
527
|
+
try {
|
|
528
|
+
await copyFile(join(slidesTemplateDir, 'template.html'), templateDest)
|
|
529
|
+
} catch {
|
|
530
|
+
return false
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Rename slides.md reference in template to match actual filename
|
|
534
|
+
// (template always fetches 'slides.md' — if file is named differently, patch it)
|
|
535
|
+
if (mdName !== 'slides.md') {
|
|
536
|
+
try {
|
|
537
|
+
let html = await readFile(templateDest, 'utf-8')
|
|
538
|
+
html = html.replace(/fetch\('slides\.md'\)/, `fetch('${mdName}')`)
|
|
539
|
+
await writeFile(templateDest, html, 'utf-8')
|
|
540
|
+
} catch { /* best effort */ }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
const port = await getOrCreateSlidesServer(dir)
|
|
545
|
+
shell.openExternal(`http://127.0.0.1:${port}/template.html`)
|
|
546
|
+
return true
|
|
547
|
+
} catch {
|
|
548
|
+
return false
|
|
549
|
+
}
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
// Export Slides: inline images as base64, copy videos alongside, produce shareable output
|
|
553
|
+
ipcMain.handle('export-slides', async (event, content: string) => {
|
|
554
|
+
const win = getWinFromEvent(event)
|
|
555
|
+
if (!win) return false
|
|
556
|
+
const state = getState(win)
|
|
557
|
+
if (!state.filePath) return false
|
|
558
|
+
|
|
559
|
+
const srcDir = dirname(state.filePath)
|
|
560
|
+
|
|
561
|
+
// Detect if content references any video files
|
|
562
|
+
const videoRefs = [...content.matchAll(/<!--\s*type:\s*video[^>]*src:\s*([^\s,>]+)/g)]
|
|
563
|
+
.map(m => m[1].trim())
|
|
564
|
+
.filter(Boolean)
|
|
565
|
+
const hasVideo = videoRefs.length > 0
|
|
566
|
+
|
|
567
|
+
// Choose export destination
|
|
568
|
+
let destDir: string
|
|
569
|
+
let destHtml: string
|
|
570
|
+
|
|
571
|
+
if (hasVideo) {
|
|
572
|
+
const result = await dialog.showSaveDialog(win, {
|
|
573
|
+
title: 'Export Slides Folder',
|
|
574
|
+
defaultPath: join(srcDir, 'slides-export'),
|
|
575
|
+
buttonLabel: 'Export'
|
|
576
|
+
})
|
|
577
|
+
if (result.canceled || !result.filePath) return false
|
|
578
|
+
destDir = result.filePath
|
|
579
|
+
destHtml = join(destDir, 'index.html')
|
|
580
|
+
await mkdir(destDir, { recursive: true })
|
|
581
|
+
} else {
|
|
582
|
+
const result = await dialog.showSaveDialog(win, {
|
|
583
|
+
title: 'Export Slides',
|
|
584
|
+
defaultPath: join(srcDir, 'slides.html'),
|
|
585
|
+
filters: [{ name: 'HTML', extensions: ['html'] }]
|
|
586
|
+
})
|
|
587
|
+
if (result.canceled || !result.filePath) return false
|
|
588
|
+
destDir = dirname(result.filePath)
|
|
589
|
+
destHtml = result.filePath
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Read template and inline the markdown content
|
|
593
|
+
let html = await readFile(join(srcDir, 'template.html'), 'utf-8')
|
|
594
|
+
|
|
595
|
+
// Replace fetch('slides.md') with inline content
|
|
596
|
+
const escaped = content.replace(/`/g, '\\`').replace(/\$/g, '\\$')
|
|
597
|
+
html = html.replace(
|
|
598
|
+
/fetch\('[^']+'\)\s*\n?\s*\.then\(r => r\.text\(\)\)/,
|
|
599
|
+
`Promise.resolve(\`${escaped}\`)`
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
// Inline images as base64
|
|
603
|
+
const imgMatches = [...content.matchAll(/!\[[^\]]*\]\((?!https?:\/\/|data:)([^)]+)\)/g)]
|
|
604
|
+
const inlinedImages = new Map<string, string>()
|
|
605
|
+
for (const m of imgMatches) {
|
|
606
|
+
const imgPath = m[1].trim()
|
|
607
|
+
if (inlinedImages.has(imgPath)) continue
|
|
608
|
+
try {
|
|
609
|
+
const abs = join(srcDir, imgPath)
|
|
610
|
+
const buf = await readFile(abs)
|
|
611
|
+
const ext = extname(imgPath).slice(1).toLowerCase()
|
|
612
|
+
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
|
|
613
|
+
: ext === 'png' ? 'image/png'
|
|
614
|
+
: ext === 'gif' ? 'image/gif'
|
|
615
|
+
: ext === 'webp' ? 'image/webp'
|
|
616
|
+
: ext === 'svg' ? 'image/svg+xml'
|
|
617
|
+
: 'image/png'
|
|
618
|
+
inlinedImages.set(imgPath, `data:${mime};base64,${buf.toString('base64')}`)
|
|
619
|
+
} catch { /* skip missing images */ }
|
|
620
|
+
}
|
|
621
|
+
for (const [src, dataUrl] of inlinedImages) {
|
|
622
|
+
html = html.replaceAll(`src="${src}"`, `src="${dataUrl}"`)
|
|
623
|
+
html = html.replaceAll(`src='${src}'`, `src='${dataUrl}'`)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Copy video files alongside if needed
|
|
627
|
+
if (hasVideo) {
|
|
628
|
+
for (const videoSrc of videoRefs) {
|
|
629
|
+
try {
|
|
630
|
+
await copyFile(join(srcDir, videoSrc), join(destDir, videoSrc))
|
|
631
|
+
} catch { /* skip missing videos */ }
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
await writeFile(destHtml, html, 'utf-8')
|
|
636
|
+
shell.showItemInFolder(destHtml)
|
|
637
|
+
return true
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
ipcMain.handle('load-custom-theme', async (event) => {
|
|
641
|
+
const win = getWinFromEvent(event)
|
|
642
|
+
if (!win) return null
|
|
643
|
+
const result = await dialog.showOpenDialog(win, {
|
|
644
|
+
filters: [{ name: 'CSS', extensions: ['css'] }],
|
|
645
|
+
properties: ['openFile']
|
|
646
|
+
})
|
|
647
|
+
if (result.canceled || result.filePaths.length === 0) return null
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
const srcPath = result.filePaths[0]
|
|
651
|
+
const fileName = basename(srcPath)
|
|
652
|
+
const destPath = join(themesDir, fileName)
|
|
653
|
+
await copyFile(srcPath, destPath)
|
|
654
|
+
const css = await readFile(destPath, 'utf-8')
|
|
655
|
+
buildMenu() // rebuild menu to include new theme
|
|
656
|
+
return { name: fileName, css }
|
|
657
|
+
} catch {
|
|
658
|
+
return null
|
|
659
|
+
}
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
ipcMain.handle('load-theme-css', async (_event, fileName: string) => {
|
|
663
|
+
try {
|
|
664
|
+
return await readFile(join(themesDir, fileName), 'utf-8')
|
|
665
|
+
} catch {
|
|
666
|
+
return null
|
|
667
|
+
}
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
// Plugin menu — receives plugin list from renderer, builds dynamic submenu
|
|
671
|
+
|
|
672
|
+
ipcMain.handle('register-plugins', (_event, plugins: Array<{ id: string; name: string; enabled: boolean }>) => {
|
|
673
|
+
pluginMenuItems = plugins.map((p) => ({
|
|
674
|
+
id: p.id,
|
|
675
|
+
label: p.name,
|
|
676
|
+
type: 'checkbox' as const,
|
|
677
|
+
checked: p.enabled,
|
|
678
|
+
click: () => sendToFocused('menu-toggle-plugin', p.id)
|
|
679
|
+
}))
|
|
680
|
+
buildMenu()
|
|
681
|
+
return true
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
ipcMain.handle('sync-plugin-state', (_event, id: string, enabled: boolean) => {
|
|
685
|
+
const item = pluginMenuItems.find((p: any) => p.id === id)
|
|
686
|
+
if (item) item.checked = enabled
|
|
687
|
+
buildMenu()
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
// Menu — targets the focused window
|
|
691
|
+
|
|
692
|
+
function getFocusedWindow(): BrowserWindow | null {
|
|
693
|
+
return BrowserWindow.getFocusedWindow()
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function sendToFocused(channel: string, ...args: unknown[]): void {
|
|
697
|
+
const win = getFocusedWindow()
|
|
698
|
+
if (win) win.webContents.send(channel, ...args)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function buildMenu(): void {
|
|
702
|
+
const isMac = process.platform === 'darwin'
|
|
703
|
+
|
|
704
|
+
// Scan custom themes synchronously for menu building
|
|
705
|
+
const customThemeItems: Electron.MenuItemConstructorOptions[] = []
|
|
706
|
+
try {
|
|
707
|
+
const files = readdirSync(themesDir).filter((f: string) => f.endsWith('.css')).sort()
|
|
708
|
+
for (const file of files) {
|
|
709
|
+
customThemeItems.push({
|
|
710
|
+
label: file.replace(/\.css$/, ''),
|
|
711
|
+
click: async () => {
|
|
712
|
+
try {
|
|
713
|
+
const css = await readFile(join(themesDir, file), 'utf-8')
|
|
714
|
+
sendToFocused('set-theme', `custom:${file}`)
|
|
715
|
+
sendToFocused('set-custom-css', css)
|
|
716
|
+
} catch { /* ignore */ }
|
|
717
|
+
}
|
|
718
|
+
})
|
|
719
|
+
}
|
|
720
|
+
} catch { /* themes dir may not exist yet */ }
|
|
721
|
+
|
|
722
|
+
const themeSubmenu: Electron.MenuItemConstructorOptions[] = [
|
|
723
|
+
{ label: 'Light', click: () => sendToFocused('set-theme', 'light') },
|
|
724
|
+
{ label: 'Dark', click: () => sendToFocused('set-theme', 'dark') },
|
|
725
|
+
{ label: 'Elegant', click: () => sendToFocused('set-theme', 'elegant') },
|
|
726
|
+
{ label: 'Newsprint', click: () => sendToFocused('set-theme', 'newsprint') },
|
|
727
|
+
]
|
|
728
|
+
if (customThemeItems.length > 0) {
|
|
729
|
+
themeSubmenu.push({ type: 'separator' }, ...customThemeItems)
|
|
730
|
+
}
|
|
731
|
+
themeSubmenu.push({ type: 'separator' }, {
|
|
732
|
+
label: 'Import Theme...',
|
|
733
|
+
click: () => sendToFocused('menu-import-theme')
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
const template: Electron.MenuItemConstructorOptions[] = [
|
|
737
|
+
...(isMac ? [{
|
|
738
|
+
label: 'ColaMD',
|
|
739
|
+
submenu: [
|
|
740
|
+
{ role: 'about' as const },
|
|
741
|
+
{ type: 'separator' as const },
|
|
742
|
+
{ role: 'hide' as const },
|
|
743
|
+
{ role: 'hideOthers' as const },
|
|
744
|
+
{ role: 'unhide' as const },
|
|
745
|
+
{ type: 'separator' as const },
|
|
746
|
+
{ role: 'quit' as const }
|
|
747
|
+
]
|
|
748
|
+
}] : []),
|
|
749
|
+
{
|
|
750
|
+
label: 'File',
|
|
751
|
+
submenu: [
|
|
752
|
+
{
|
|
753
|
+
label: 'New',
|
|
754
|
+
accelerator: 'CmdOrCtrl+N',
|
|
755
|
+
click: () => createWindow()
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
label: 'New Slides...',
|
|
759
|
+
accelerator: 'CmdOrCtrl+Shift+N',
|
|
760
|
+
click: () => sendToFocused('menu-new-slides')
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
label: 'Open...',
|
|
764
|
+
accelerator: 'CmdOrCtrl+O',
|
|
765
|
+
click: () => sendToFocused('menu-open')
|
|
766
|
+
},
|
|
767
|
+
{ type: 'separator' },
|
|
768
|
+
{
|
|
769
|
+
label: 'Save',
|
|
770
|
+
accelerator: 'CmdOrCtrl+S',
|
|
771
|
+
click: () => sendToFocused('menu-save')
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
label: 'Save As...',
|
|
775
|
+
accelerator: 'CmdOrCtrl+Shift+S',
|
|
776
|
+
click: () => sendToFocused('menu-save-as')
|
|
777
|
+
},
|
|
778
|
+
{ type: 'separator' },
|
|
779
|
+
{
|
|
780
|
+
label: 'Export PDF...',
|
|
781
|
+
click: () => sendToFocused('menu-export-pdf')
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
label: 'Export HTML...',
|
|
785
|
+
click: () => sendToFocused('menu-export-html')
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
label: 'Export Slides...',
|
|
789
|
+
click: () => sendToFocused('menu-export-slides')
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
label: 'Open as Slides',
|
|
793
|
+
accelerator: 'CmdOrCtrl+Shift+P',
|
|
794
|
+
click: () => sendToFocused('menu-open-as-slides')
|
|
795
|
+
},
|
|
796
|
+
{ type: 'separator' },
|
|
797
|
+
isMac ? { role: 'close' } : { role: 'quit' }
|
|
798
|
+
]
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
label: 'Edit',
|
|
802
|
+
submenu: [
|
|
803
|
+
{ role: 'undo' },
|
|
804
|
+
{ role: 'redo' },
|
|
805
|
+
{ type: 'separator' },
|
|
806
|
+
{ role: 'cut' },
|
|
807
|
+
{ role: 'copy' },
|
|
808
|
+
{ role: 'paste' },
|
|
809
|
+
{ role: 'selectAll' }
|
|
810
|
+
]
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
label: 'View',
|
|
814
|
+
submenu: [
|
|
815
|
+
{ role: 'resetZoom' },
|
|
816
|
+
{ role: 'zoomIn' },
|
|
817
|
+
{ role: 'zoomOut' },
|
|
818
|
+
{ type: 'separator' },
|
|
819
|
+
{ role: 'togglefullscreen' },
|
|
820
|
+
...(pluginMenuItems.length > 0
|
|
821
|
+
? [{ type: 'separator' as const }, { label: 'Plugins', submenu: pluginMenuItems }]
|
|
822
|
+
: [])
|
|
823
|
+
]
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
label: 'Theme',
|
|
827
|
+
submenu: themeSubmenu
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
label: 'Help',
|
|
831
|
+
submenu: [
|
|
832
|
+
{
|
|
833
|
+
label: 'About ColaMD',
|
|
834
|
+
click: () => shell.openExternal('https://github.com/marswaveai/colamd')
|
|
835
|
+
}
|
|
836
|
+
]
|
|
837
|
+
}
|
|
838
|
+
]
|
|
839
|
+
|
|
840
|
+
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// App lifecycle
|
|
844
|
+
|
|
845
|
+
app.whenReady().then(() => {
|
|
846
|
+
ensureThemesDir()
|
|
847
|
+
buildMenu()
|
|
848
|
+
|
|
849
|
+
// Check command line args for file paths
|
|
850
|
+
const args = process.argv.slice(app.isPackaged ? 1 : 2)
|
|
851
|
+
const fileArgs = args.filter((arg) => !arg.startsWith('-'))
|
|
852
|
+
if (fileArgs.length > 0) {
|
|
853
|
+
pendingFilePaths = fileArgs
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (pendingFilePaths.length > 0) {
|
|
857
|
+
for (const fp of pendingFilePaths) {
|
|
858
|
+
createWindow(fp)
|
|
859
|
+
}
|
|
860
|
+
pendingFilePaths = []
|
|
861
|
+
} else {
|
|
862
|
+
createWindow()
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
app.on('activate', () => {
|
|
866
|
+
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
867
|
+
})
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
app.on('window-all-closed', () => {
|
|
871
|
+
if (process.platform !== 'darwin') app.quit()
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
app.on('open-file', (event, filePath) => {
|
|
875
|
+
event.preventDefault()
|
|
876
|
+
if (app.isReady()) {
|
|
877
|
+
openFile(filePath)
|
|
878
|
+
} else {
|
|
879
|
+
pendingFilePaths.push(filePath)
|
|
880
|
+
}
|
|
881
|
+
})
|