@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.
Files changed (157) hide show
  1. package/.github/workflows/release.yml +66 -0
  2. package/.trae/documents/fix-mermaid-colors-and-sankey.md +50 -0
  3. package/CLAUDE.md +87 -0
  4. package/LICENSE +21 -0
  5. package/README.md +540 -0
  6. package/README_CN.md +543 -0
  7. package/demo.md +486 -0
  8. package/dist/main/index.js +735 -0
  9. package/dist/preload/index.js +71 -0
  10. package/dist/renderer/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  11. package/dist/renderer/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  12. package/dist/renderer/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  13. package/dist/renderer/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  14. package/dist/renderer/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  15. package/dist/renderer/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  16. package/dist/renderer/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  17. package/dist/renderer/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  18. package/dist/renderer/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  19. package/dist/renderer/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  20. package/dist/renderer/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  21. package/dist/renderer/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  22. package/dist/renderer/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  23. package/dist/renderer/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  24. package/dist/renderer/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  25. package/dist/renderer/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  26. package/dist/renderer/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  27. package/dist/renderer/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  28. package/dist/renderer/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  29. package/dist/renderer/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  30. package/dist/renderer/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  31. package/dist/renderer/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  32. package/dist/renderer/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  33. package/dist/renderer/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  34. package/dist/renderer/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  35. package/dist/renderer/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  36. package/dist/renderer/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  37. package/dist/renderer/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  38. package/dist/renderer/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  39. package/dist/renderer/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  40. package/dist/renderer/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  41. package/dist/renderer/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  42. package/dist/renderer/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  43. package/dist/renderer/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  44. package/dist/renderer/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  45. package/dist/renderer/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  46. package/dist/renderer/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  47. package/dist/renderer/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  48. package/dist/renderer/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  49. package/dist/renderer/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  50. package/dist/renderer/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  51. package/dist/renderer/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  52. package/dist/renderer/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  53. package/dist/renderer/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  54. package/dist/renderer/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  55. package/dist/renderer/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  56. package/dist/renderer/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  57. package/dist/renderer/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  58. package/dist/renderer/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  59. package/dist/renderer/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  60. package/dist/renderer/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  61. package/dist/renderer/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  62. package/dist/renderer/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  63. package/dist/renderer/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  64. package/dist/renderer/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  65. package/dist/renderer/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  66. package/dist/renderer/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  67. package/dist/renderer/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  68. package/dist/renderer/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  69. package/dist/renderer/assets/arc-tTbbM8LO.js +131 -0
  70. package/dist/renderer/assets/architectureDiagram-3BPJPVTR-CEgYow6c.js +8720 -0
  71. package/dist/renderer/assets/blockDiagram-GPEHLZMM-LHyVtPwW.js +3825 -0
  72. package/dist/renderer/assets/c4Diagram-AAUBKEIU-C1P1eJrf.js +2482 -0
  73. package/dist/renderer/assets/channel-upve91Tq.js +7 -0
  74. package/dist/renderer/assets/chunk-2J33WTMH-lag2vhq9.js +24 -0
  75. package/dist/renderer/assets/chunk-4BX2VUAB-BXJ8Ggh-.js +16 -0
  76. package/dist/renderer/assets/chunk-55IACEB6-CiBpxRa1.js +13 -0
  77. package/dist/renderer/assets/chunk-727SXJPM-ODeKQFXC.js +2016 -0
  78. package/dist/renderer/assets/chunk-AQP2D5EJ-BK7xJolB.js +1953 -0
  79. package/dist/renderer/assets/chunk-FMBD7UC4-BxpCZPtz.js +19 -0
  80. package/dist/renderer/assets/chunk-ND2GUHAM-CqqaU9Ue.js +116 -0
  81. package/dist/renderer/assets/chunk-QZHKN3VN-Biq_K124.js +19 -0
  82. package/dist/renderer/assets/classDiagram-4FO5ZUOK-Cq95X99o.js +23 -0
  83. package/dist/renderer/assets/classDiagram-v2-Q7XG4LA2-Cq95X99o.js +23 -0
  84. package/dist/renderer/assets/cose-bilkent-S5V4N54A-XasiD0bu.js +4942 -0
  85. package/dist/renderer/assets/cytoscape.esm-CpHeHM5e.js +30269 -0
  86. package/dist/renderer/assets/dagre-BM42HDAG-Nq84Gfx4.js +705 -0
  87. package/dist/renderer/assets/defaultLocale-B2RvLBDe.js +206 -0
  88. package/dist/renderer/assets/diagram-2AECGRRQ-DwuB1GWt.js +301 -0
  89. package/dist/renderer/assets/diagram-5GNKFQAL-C2tgeI1h.js +169 -0
  90. package/dist/renderer/assets/diagram-KO2AKTUF-D5KzjNBc.js +632 -0
  91. package/dist/renderer/assets/diagram-LMA3HP47-C12xHS1c.js +212 -0
  92. package/dist/renderer/assets/diagram-OG6HWLK6-CnxI9oEa.js +851 -0
  93. package/dist/renderer/assets/erDiagram-TEJ5UH35-D_uPaKwn.js +1227 -0
  94. package/dist/renderer/assets/flowDiagram-I6XJVG4X-B6q_1-tE.js +2332 -0
  95. package/dist/renderer/assets/ganttDiagram-6RSMTGT7-CFo7ifF9.js +3720 -0
  96. package/dist/renderer/assets/gitGraphDiagram-PVQCEYII-WSexHTnq.js +1373 -0
  97. package/dist/renderer/assets/graph-DyX_9f6d.js +1988 -0
  98. package/dist/renderer/assets/index-DW7LS8C1.js +72292 -0
  99. package/dist/renderer/assets/index-dyHEFYvY.css +2184 -0
  100. package/dist/renderer/assets/infoDiagram-5YYISTIA-DaeJdLRq.js +31 -0
  101. package/dist/renderer/assets/init-ZxktEp_H.js +16 -0
  102. package/dist/renderer/assets/ishikawaDiagram-YF4QCWOH-DDCZc35f.js +967 -0
  103. package/dist/renderer/assets/journeyDiagram-JHISSGLW-BEdmpAgl.js +1255 -0
  104. package/dist/renderer/assets/kanban-definition-UN3LZRKU-BEFtQcFb.js +1052 -0
  105. package/dist/renderer/assets/layout-CAJgQHdw.js +2610 -0
  106. package/dist/renderer/assets/linear-B2ggJ8Am.js +340 -0
  107. package/dist/renderer/assets/mindmap-definition-RKZ34NQL-DSxVgHB5.js +1180 -0
  108. package/dist/renderer/assets/ordinal-DSZU4PqD.js +76 -0
  109. package/dist/renderer/assets/pieDiagram-4H26LBE5-CwYoJBuL.js +246 -0
  110. package/dist/renderer/assets/quadrantDiagram-W4KKPZXB-CST9Fvg9.js +1344 -0
  111. package/dist/renderer/assets/requirementDiagram-4Y6WPE33-DtrH52jS.js +1204 -0
  112. package/dist/renderer/assets/sankeyDiagram-5OEKKPKP-ca1tPzJ_.js +1274 -0
  113. package/dist/renderer/assets/sequenceDiagram-3UESZ5HK-Dfp1EJZ7.js +4514 -0
  114. package/dist/renderer/assets/stateDiagram-AJRCARHV-Bha2QoNB.js +450 -0
  115. package/dist/renderer/assets/stateDiagram-v2-BHNVJYJU-DWgFUYu1.js +21 -0
  116. package/dist/renderer/assets/timeline-definition-PNZ67QCA-C3h_-OTj.js +1596 -0
  117. package/dist/renderer/assets/vennDiagram-CIIHVFJN-DFzjSrZi.js +2486 -0
  118. package/dist/renderer/assets/wardley-L42UT6IY-Cx-VbqoS.js +30699 -0
  119. package/dist/renderer/assets/wardleyDiagram-YWT4CUSO-S2D9XqX6.js +975 -0
  120. package/dist/renderer/assets/xychartDiagram-2RQKCTM6-Cfxigbts.js +1932 -0
  121. package/dist/renderer/index.html +19 -0
  122. package/docs/agent-diff-view.md +48 -0
  123. package/electron-builder.yml +57 -0
  124. package/electron.vite.config.ts +30 -0
  125. package/package.json +40 -0
  126. package/resources/entitlements.mac.plist +12 -0
  127. package/resources/icon.icns +0 -0
  128. package/resources/icon.png +0 -0
  129. package/resources/icon.svg +23 -0
  130. package/resources/templates/slides/icon.png +0 -0
  131. package/resources/templates/slides/slides-template.md +74 -0
  132. package/resources/templates/slides/template.html +535 -0
  133. package/scripts/afterPack.js +13 -0
  134. package/src/main/index.ts +881 -0
  135. package/src/preload/index.ts +110 -0
  136. package/src/renderer/editor/editor.ts +204 -0
  137. package/src/renderer/editor/html-view.ts +15 -0
  138. package/src/renderer/editor/plugins/index.ts +76 -0
  139. package/src/renderer/editor/plugins/math-plugin.ts +297 -0
  140. package/src/renderer/editor/plugins/mermaid-plugin-custom.css +431 -0
  141. package/src/renderer/editor/plugins/mermaid-plugin-dark.css +428 -0
  142. package/src/renderer/editor/plugins/mermaid-plugin-elegant.css +443 -0
  143. package/src/renderer/editor/plugins/mermaid-plugin-newsprint.css +208 -0
  144. package/src/renderer/editor/plugins/mermaid-plugin.css +111 -0
  145. package/src/renderer/editor/plugins/mermaid-plugin.ts +679 -0
  146. package/src/renderer/env.d.ts +7 -0
  147. package/src/renderer/index.html +18 -0
  148. package/src/renderer/main.ts +303 -0
  149. package/src/renderer/themes/base.css +509 -0
  150. package/src/renderer/themes/theme-manager.ts +40 -0
  151. package/themes/README.md +280 -0
  152. package/themes/elegant.css +664 -0
  153. package/themes/guizang.css +732 -0
  154. package/tsconfig.json +14 -0
  155. package/tsconfig.main.json +11 -0
  156. package/tsconfig.preload.json +11 -0
  157. 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 `![${alt}](file://${abs})`
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
+ })