latex-stickies 1.2.0 → 1.3.1

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/README.md CHANGED
@@ -1,9 +1,16 @@
1
1
  # LaTeX Stickies
2
2
 
3
- Sticky notes for your desktop that render LaTeX and Markdown.
3
+ [![npm](https://img.shields.io/npm/v/latex-stickies)](https://www.npmjs.com/package/latex-stickies)
4
+ [![CI](https://github.com/kev-nj/latex-stickies/actions/workflows/ci.yml/badge.svg)](https://github.com/kev-nj/latex-stickies/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/latex-stickies)](LICENSE)
4
6
 
5
- Write `$e^{i\pi} + 1 = 0$` in a note and it renders as soon as you click away.
6
- Notes stay where you put them, in the color you chose, across restarts.
7
+ Sticky notes for your desktop that render LaTeX and Markdown as you type.
8
+ macOS, Windows and Linux.
9
+
10
+ Write `$e^{i\pi} + 1 = 0$` and it becomes the equation. Click into it and the
11
+ source comes back. There is no edit mode and no preview mode.
12
+
13
+ <img src="https://raw.githubusercontent.com/kev-nj/latex-stickies/main/assets/demo.gif" alt="Typing maths, a table, a code block and an autocompleted equation into a note" width="420">
7
14
 
8
15
  ```
9
16
  npx latex-stickies
@@ -16,41 +23,63 @@ npm install -g latex-stickies
16
23
  latex-stickies
17
24
  ```
18
25
 
19
- Requires Node 22.12 or newer. The first run downloads the Electron runtime (~230 MB), so
20
- give it a minute; later launches are instant.
26
+ Requires Node 22.12 or newer. The first run downloads the Electron runtime
27
+ (~230 MB), so give it a minute; later launches are instant. On a Mac you can
28
+ download a `.dmg` from [Releases](https://github.com/kev-nj/latex-stickies/releases)
29
+ instead and skip Node entirely.
21
30
 
22
31
  ## Writing in a note
23
32
 
24
- Everything renders as you type. Click into a line to see the markdown behind
25
- it; move away and it renders again.
33
+ Everything renders while you write. Put the caret in an element to see the
34
+ markdown behind it; move away and it renders again.
35
+
36
+ **Maths** — `$…$` inline, `$$…$$` on its own line. Delimiters have to hug their
37
+ content, so prose like `costs $5 and $6` stays text rather than turning into a
38
+ formula. Broken TeX shows the source underlined in red with the parse error on
39
+ hover, instead of blanking the note.
40
+
41
+ **Markdown** — headings, `**bold**`, `*italic*`, `` `code` ``, `~~struck~~`,
42
+ quotes, lists, and links that open in your browser. Tables render as tables,
43
+ and their cells render maths. Task list checkboxes are clickable: tick one and
44
+ `[ ]` becomes `[x]` in the file.
45
+
46
+ **Code** — fenced blocks are highlighted across eleven languages, labelled with
47
+ the language, and each has a copy button.
48
+
49
+ Inside a fence the editor behaves like a code editor: Enter keeps your
50
+ indentation and adds a level after `:` or `{`, Tab and Shift+Tab indent and
51
+ dedent, and brackets and quotes auto-close. Outside one it behaves like a notes
52
+ field — lists continue on Enter, and quotes are left alone so `don't` types
53
+ normally.
54
+
55
+ **Images of your maths** — right-click a rendered equation for *Copy as Image*
56
+ or *Copy LaTeX*. `Cmd+Shift+C` copies the whole note as an image, however long
57
+ it is. Both go straight to the clipboard, ready to paste into Slack or an email.
26
58
 
27
- **Math** — `$…$` inline, `$$…$$` on its own line. Delimiters have to hug their
28
- content, so ordinary prose like `costs $5 and $6` stays text rather than turning
29
- into a formula. Broken TeX shows the source underlined in red with the parse
30
- error on hover, instead of blanking the note.
59
+ ## Autocomplete (optional, local)
31
60
 
32
- **Markdown** — headings, `**bold**`, `*italic*`, `~~strikethrough~~`, lists,
33
- task lists, tables, blockquotes, and links (which open in your browser).
61
+ A grey suggestion appears after a pause in typing; **Tab** accepts it, **Esc**
62
+ dismisses it. It runs entirely on your machine through
63
+ [Ollama](https://ollama.com) — nothing is sent anywhere — and is **off by
64
+ default**. Turn it on under **Note → Autocomplete**.
34
65
 
35
- **Code** — fenced blocks are highlighted in 20 languages, with the language
36
- labelled and a copy button on hover:
66
+ Pull a small model. This fires on every pause, so speed matters far more than
67
+ size:
37
68
 
38
- ````
39
- ```python
40
- def gaussian(x, mu, sigma):
41
- return exp(-((x - mu) ** 2) / (2 * sigma ** 2))
42
69
  ```
43
- ````
70
+ ollama pull qwen2.5-coder:1.5b
71
+ ```
44
72
 
45
- Aliases work as you would expect: `sh`, `shell` and `zsh` all mean bash, `py`
46
- means python, and `tex` means latex. A fence with no language stays plain --
47
- guessing on a two-line snippet is wrong often enough to be worse than nothing.
73
+ Measured on a MacBook: **0.06–0.5s** with that model, against 0.4–3.5s for a
74
+ 14B — and the small one wrote better prose. Choose yours under **Note →
75
+ Autocomplete Model**; installed models are listed with their sizes.
48
76
 
49
- Inside a fence the editor behaves like a code editor: Enter keeps your
50
- indentation and adds a level after `:` or `{`, Tab and Shift+Tab indent and
51
- dedent (including a whole selection), and brackets and quotes auto-close.
52
- Outside a fence it behaves like a notes field instead -- lists continue on
53
- Enter, and quotes are left alone so `don't` types normally.
77
+ It knows what this app is for. `$$\int_0^1 x^2 dx = ` suggests `\frac{1}{3}$$`,
78
+ and `The derivative of $x^2$ is ` suggests `$2x$.`
79
+
80
+ **`Cmd+Shift+M`** turns a description into LaTeX: select *"integral of e to the
81
+ minus x squared from 0 to infinity"* and it becomes
82
+ `$\int_{0}^{\infty} e^{-x^2}\,dx$`.
54
83
 
55
84
  ## Keyboard
56
85
 
@@ -59,58 +88,82 @@ Use `Ctrl` in place of `Cmd` on Windows and Linux.
59
88
  | | |
60
89
  |---|---|
61
90
  | `Cmd+N` | New note |
62
- | `Cmd+W` | Close note |
63
- | `Cmd+Backspace` | Delete note |
64
- | `Cmd+E` | Toggle edit / preview |
65
- | `Cmd+T` | Keep on top |
66
- | `Cmd+±` | Text size |
91
+ | `Cmd+W` | Close note (it reopens next launch) |
92
+ | `Cmd+Shift+Backspace` | Delete note, permanently |
67
93
  | `Cmd+B` / `Cmd+I` / `Cmd+E` | Bold / italic / code |
68
94
  | `Cmd+K` | Link |
69
- | `Cmd+Shift+C` | Copy note as image |
70
- | `Esc` | Leave edit mode |
95
+ | `Cmd+F` | Find in note |
96
+ | `Cmd+Shift+M` | Maths from a description |
97
+ | `Cmd+Shift+C` | Copy note as an image |
98
+ | `Cmd+Shift+O` | Open the notes folder |
99
+ | `Cmd+T` | Keep on top |
100
+ | `Cmd+±` | Text size |
101
+
102
+ On Windows and Linux the note windows have no menu bar, so the **☰** button
103
+ in a note's toolbar opens the same menu. Every command is there.
104
+
105
+ The **All Notes** menu lists every note you have. A tick means it is on screen;
106
+ click to open a note, or click a ticked one to close it. So a note you closed
107
+ is one click away rather than lost until the next launch.
71
108
 
72
109
  ## Where notes live
73
110
 
74
- | | |
75
- |---|---|
76
- | macOS | `~/Library/Application Support/latex-stickies/notes.json` |
77
- | Windows | `%APPDATA%\latex-stickies\notes.json` |
78
- | Linux | `~/.config/latex-stickies/notes.json` |
111
+ One Markdown file per note, in a folder you can open:
112
+
113
+ ```
114
+ ~/Documents/LaTeX Stickies/
115
+ shopping-list.md
116
+ lecture-3.md
117
+ .stickies.json # colours, positions, pinned state
118
+ ```
79
119
 
80
- Saves are atomic -- written to a temp file, flushed, then renamed over the
81
- target -- so a crash, a force quit, or pulling the power leaves the previous
82
- good file intact rather than a truncated one. Only one instance runs at a time,
83
- because two copies would hold divergent notes in memory and the last to quit
84
- would silently overwrite the other.
120
+ They are ordinary files: grep them, keep the folder in git, sync it through
121
+ Dropbox, or edit a note in another editor — the open note follows the change.
122
+ `Cmd+Shift+O` opens the folder.
123
+
124
+ Files are named from the note's first line, and renamed if you retitle it. A
125
+ file you rename yourself keeps its name. Dropping a `.tex` or `.txt` file into
126
+ the folder makes it a note too, and it keeps that extension.
127
+
128
+ Metadata lives in the sidecar index rather than in frontmatter, so the note
129
+ files stay clean — a `.tex` file with YAML at the top would not compile.
130
+
131
+ Saves are atomic: written to a temp file, flushed, then renamed over the
132
+ target, so a crash or force quit leaves the previous file intact rather than a
133
+ truncated one. Only one instance runs at a time, because two copies would hold
134
+ divergent notes and the last to quit would overwrite the other.
135
+
136
+ An existing `notes.json` from an older version is migrated the first time and
137
+ then left in place.
85
138
 
86
139
  ## Development
87
140
 
88
141
  ```
89
142
  npm install
90
143
  npm start # run from source
91
- npm test # 4 suites, 62 assertions
92
- npm run vendor # regenerate the bundled Prism
144
+ npm test # unit suites
145
+ npm run vendor # rebuild the bundled libraries
93
146
  npm run icon # rebuild the app icon from assets/icon-master.png
147
+ npm run demo # re-record assets/demo.gif from the real app (needs ffmpeg)
148
+ npm run dist # build a macOS .app and .dmg
94
149
  ```
95
150
 
96
- Artwork lives in `assets/`: `logo.jpeg` is the original, and `icon-master.png`
97
- is the prepared 1024px master the icon is generated from -- transparent ground,
98
- art filling 824 of the canvas, which is the macOS convention that keeps an icon
99
- at the same visual weight as the rest of the Dock.
100
-
101
- The renderer loads over `file://` under a strict content-security policy, so
102
- KaTeX, marked, DOMPurify and Prism are vendored into `src/renderer/vendor/`
103
- rather than pulled from a CDN. Note text is sanitized before it reaches the
104
- page, and the renderer talks to disk only through a six-function preload bridge.
105
-
106
- To build a macOS `.app` and `.dmg`:
151
+ Beyond the unit tests, several checks drive the real app and run in CI on
152
+ macOS, Windows and Linux:
107
153
 
108
154
  ```
109
- npm run dist
155
+ node scripts/smoke.js # boots, and stays up
156
+ node scripts/smoke.js --lifecycle # closing every note behaves per platform
157
+ node scripts/render-check.js # the first-run note renders correctly
158
+ node scripts/ghost-check.js # autocomplete suggests, and Tab accepts
159
+ node scripts/snapshot-check.js # a long note is captured whole
110
160
  ```
111
161
 
112
- The build is unsigned, so macOS will warn on other machines unless you sign it
113
- with an Apple Developer ID.
162
+ The renderer loads over `file://` under a strict content-security policy, so
163
+ CodeMirror and KaTeX are bundled into `src/renderer/vendor/` rather than pulled
164
+ from a CDN. Note text is never parsed as HTML, and the renderer reaches the
165
+ filesystem and the network only through a narrow preload bridge — Ollama is
166
+ called from the main process, so the page keeps `default-src 'none'`.
114
167
 
115
168
  ## License
116
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latex-stickies",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Sticky notes for your desktop that render LaTeX and Markdown.",
5
5
  "main": "src/main.js",
6
6
  "scripts": {
@@ -8,6 +8,7 @@
8
8
  "test": "node scripts/test.js",
9
9
  "vendor": "node scripts/vendor.js",
10
10
  "icon": "node scripts/make-icon.js",
11
+ "demo": "node scripts/make-demo.js",
11
12
  "dist": "node scripts/build-dmg.js",
12
13
  "install-app": "npm run dist && rm -rf \"/Applications/LaTeX Stickies.app\" && cp -R \"dist/mac-arm64/LaTeX Stickies.app\" /Applications/ && xattr -dr com.apple.quarantine \"/Applications/LaTeX Stickies.app\"",
13
14
  "prepublishOnly": "npm test",
@@ -45,6 +46,7 @@
45
46
  "@codemirror/lang-sql": "^6.10.0",
46
47
  "@codemirror/lang-yaml": "^6.1.3",
47
48
  "@codemirror/language": "^6.12.4",
49
+ "@codemirror/search": "^6.7.2",
48
50
  "@codemirror/state": "^6.7.2",
49
51
  "@codemirror/view": "^6.43.10",
50
52
  "@lezer/highlight": "^1.2.3",
package/src/ai.js CHANGED
@@ -168,4 +168,50 @@ async function complete({ prefix, suffix, model }) {
168
168
  }
169
169
  }
170
170
 
171
- module.exports = { status, complete, pickDefault, trimOverlap, tidy, HOST };
171
+ /**
172
+ * Turns a description of some maths into LaTeX.
173
+ *
174
+ * Unlike autocomplete this is a real instruction, so it goes through the chat
175
+ * template rather than fill-in-the-middle -- the model is being asked a
176
+ * question, not continuing a sentence.
177
+ */
178
+ async function toLatex({ text, model }) {
179
+ if (!text || !text.trim()) return '';
180
+
181
+ const prompt = 'Write the LaTeX for the following, and reply with the LaTeX '
182
+ + 'only -- no explanation, no dollar signs, no code fences.\n\n'
183
+ + `${text.trim()}\n`;
184
+
185
+ try {
186
+ const res = await withTimeout(`${HOST}/api/generate`, {
187
+ method: 'POST',
188
+ headers: { 'Content-Type': 'application/json' },
189
+ body: JSON.stringify({
190
+ model,
191
+ prompt,
192
+ stream: false,
193
+ options: { num_predict: 120, temperature: 0.1 },
194
+ }),
195
+ }, 20000);
196
+ if (!res.ok) return '';
197
+ const data = await res.json();
198
+
199
+ // Models wrap the answer in fences, dollars or \[ \] however firmly you
200
+ // ask them not to. The caller supplies its own delimiters.
201
+ return String(data.response || '')
202
+ .replace(/```[a-z]*\n?/gi, '')
203
+ .replace(/```/g, '')
204
+ .trim()
205
+ .replace(/^\\\[|\\\]$/g, '')
206
+ .replace(/^\\\(|\\\)$/g, '')
207
+ .trim()
208
+ .replace(/^\$+|\$+$/g, '')
209
+ .trim();
210
+ } catch (_) {
211
+ return '';
212
+ }
213
+ }
214
+
215
+ module.exports = {
216
+ status, complete, toLatex, pickDefault, trimOverlap, tidy, HOST,
217
+ };
@@ -23,6 +23,9 @@ import {
23
23
  LanguageDescription,
24
24
  } from '@codemirror/language';
25
25
  import { tags } from '@lezer/highlight';
26
+ import {
27
+ search, searchKeymap, highlightSelectionMatches, openSearchPanel,
28
+ } from '@codemirror/search';
26
29
 
27
30
  import { python } from '@codemirror/lang-python';
28
31
  import { javascript } from '@codemirror/lang-javascript';
@@ -66,4 +69,5 @@ window.CM = {
66
69
  markdown, markdownLanguage,
67
70
  syntaxTree, HighlightStyle, syntaxHighlighting, defaultHighlightStyle,
68
71
  tags, codeLanguages,
72
+ search, searchKeymap, highlightSelectionMatches, openSearchPanel,
69
73
  };
package/src/main.js CHANGED
@@ -43,6 +43,48 @@ function randomId() {
43
43
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
44
44
  }
45
45
 
46
+ /** What to call a note in a menu: its first non-empty line. */
47
+ function titleOf(note) {
48
+ const first = (note.body || '').split('\n').find((l) => l.trim());
49
+ if (!first) return 'Untitled note';
50
+ const clean = first.replace(/^#+\s*/, '').trim();
51
+ return clean.length > 40 ? `${clean.slice(0, 40)}...` : clean;
52
+ }
53
+
54
+ /**
55
+ * Deletes the focused note, asking first.
56
+ *
57
+ * The confirmation is a native dialog rather than window.confirm(). These
58
+ * windows are frameless, transparent and often on top, and a web confirm can
59
+ * end up behind the note or invisible -- the delete then blocks on a prompt
60
+ * nobody can see, which looks exactly like the shortcut doing nothing.
61
+ */
62
+ async function deleteFocusedNote() {
63
+ const win = BrowserWindow.getFocusedWindow();
64
+ if (!win) return;
65
+ const entry = [...windows.entries()].find(([, w]) => w === win);
66
+ if (!entry) return;
67
+ const [id] = entry;
68
+
69
+ const note = store.get(id);
70
+ if (note && note.body.trim()) {
71
+ const { response } = await dialog.showMessageBox(win, {
72
+ type: 'warning',
73
+ buttons: ['Delete', 'Cancel'],
74
+ defaultId: 1,
75
+ cancelId: 1,
76
+ message: 'Delete this note?',
77
+ detail: `"${titleOf(note)}" will be removed from your notes folder. `
78
+ + 'This cannot be undone.',
79
+ });
80
+ if (response !== 0) return;
81
+ }
82
+
83
+ store.remove(id);
84
+ if (!win.isDestroyed()) win.destroy();
85
+ buildMenu();
86
+ }
87
+
46
88
  function cascadeBounds() {
47
89
  const area = screen.getPrimaryDisplay().workArea;
48
90
  const n = windows.size;
@@ -115,6 +157,19 @@ function openNote(note) {
115
157
  win.on('closed', () => {
116
158
  windows.delete(note.id);
117
159
  smokeLog(`windows=${windows.size}`);
160
+
161
+ // macOS apps outlive their windows; the Dock icon is the way back.
162
+ // Elsewhere there is no way back, so an app with no windows is just an
163
+ // invisible process -- and the next launch hands off to it and appears to
164
+ // do nothing. Leave, rather than trusting the quit lifecycle to unwind an
165
+ // event loop that open handles can keep alive.
166
+ if (windows.size === 0 && process.platform !== 'darwin') {
167
+ prepareToExit();
168
+ process.exit(0);
169
+ return;
170
+ }
171
+
172
+ buildMenu(); // the tick beside this note in the All Notes menu
118
173
  });
119
174
 
120
175
  // Links inside a note open in the real browser, never inside the sticky.
@@ -180,6 +235,22 @@ async function buildMenu() {
180
235
  },
181
236
  }));
182
237
 
238
+ // Every saved note, so a closed one is not lost until the next launch --
239
+ // until now nothing in the interface said it still existed.
240
+ const noteItems = store.all().map((note) => ({
241
+ label: titleOf(note),
242
+ type: 'checkbox',
243
+ // The tick means "on screen". Since it looks like a checkbox it has to
244
+ // behave like one: clicking a ticked note closes it, rather than the tick
245
+ // being decoration you cannot affect.
246
+ checked: windows.has(note.id),
247
+ click: () => {
248
+ const open = windows.get(note.id);
249
+ if (open && !open.isDestroyed()) open.close();
250
+ else openNote(store.get(note.id) || note);
251
+ },
252
+ }));
253
+
183
254
  const template = [
184
255
  {
185
256
  label: app.name,
@@ -204,11 +275,12 @@ async function buildMenu() {
204
275
  { type: 'separator' },
205
276
  {
206
277
  label: 'Delete Note',
207
- accelerator: 'CmdOrCtrl+Backspace',
208
- click: () => {
209
- const win = BrowserWindow.getFocusedWindow();
210
- if (win) win.webContents.send('request-delete');
211
- },
278
+ // Not Cmd+Backspace: that is "delete to start of line" in every
279
+ // macOS text field, and CodeMirror binds it too -- so the editor ate
280
+ // the key, and had it not, reaching for a normal editing shortcut
281
+ // would have destroyed a note with no undo.
282
+ accelerator: 'CmdOrCtrl+Shift+Backspace',
283
+ click: () => deleteFocusedNote(),
212
284
  },
213
285
  ],
214
286
  },
@@ -217,10 +289,24 @@ async function buildMenu() {
217
289
  label: 'Note',
218
290
  submenu: [
219
291
  {
220
- label: 'Toggle Edit / Preview',
221
- accelerator: 'CmdOrCtrl+E',
222
- click: () => BrowserWindow.getFocusedWindow()?.webContents.send('toggle-edit'),
292
+ label: 'Maths from Description...',
293
+ accelerator: 'CmdOrCtrl+Shift+M',
294
+ click: () => BrowserWindow.getFocusedWindow()?.webContents.send('describe-latex'),
223
295
  },
296
+ { type: 'separator' },
297
+ {
298
+ label: 'Find in Note',
299
+ accelerator: 'CmdOrCtrl+F',
300
+ // The editor owns the panel; this entry is for discoverability.
301
+ click: () => BrowserWindow.getFocusedWindow()?.webContents.send('find-in-note'),
302
+ },
303
+ { type: 'separator' },
304
+ {
305
+ label: 'Open Notes Folder',
306
+ accelerator: 'CmdOrCtrl+Shift+O',
307
+ click: () => shell.openPath(store.DIR),
308
+ },
309
+ { type: 'separator' },
224
310
  {
225
311
  label: 'Autocomplete (Ollama)',
226
312
  type: 'checkbox',
@@ -292,6 +378,15 @@ async function buildMenu() {
292
378
  },
293
379
  ],
294
380
  },
381
+ {
382
+ // "All Notes", not "Notes": it sits beside the "Note" menu, and two
383
+ // near-identical names for the current note and the list of every note
384
+ // is a coin toss every time you reach for one.
385
+ label: 'All Notes',
386
+ submenu: noteItems.length
387
+ ? noteItems
388
+ : [{ label: 'No notes yet', enabled: false }],
389
+ },
295
390
  {
296
391
  label: 'Window',
297
392
  submenu: [
@@ -341,6 +436,38 @@ function surfaceNotes() {
341
436
 
342
437
  app.on('second-instance', surfaceNotes);
343
438
 
439
+ /**
440
+ * Follows edits made to the note files by anything else -- another editor,
441
+ * Dropbox, a git checkout. The open window updates in place rather than
442
+ * quietly overwriting what was changed on disk.
443
+ */
444
+ let stopWatchingNotes = null;
445
+
446
+ /**
447
+ * Everything that must happen before the process goes away.
448
+ *
449
+ * Synchronous on purpose: it runs immediately before process.exit(), which
450
+ * gives nothing asynchronous a chance to finish. The watcher is closed first
451
+ * because its handle is what keeps the event loop -- and so the invisible
452
+ * process -- alive after the last window has gone.
453
+ */
454
+ function prepareToExit() {
455
+ if (stopWatchingNotes) {
456
+ stopWatchingNotes();
457
+ stopWatchingNotes = null;
458
+ }
459
+ store.saveNow();
460
+ }
461
+
462
+ function watchNotesFolder() {
463
+ stopWatchingNotes = store.watch((changed) => {
464
+ for (const note of changed) {
465
+ const win = windows.get(note.id);
466
+ if (win && !win.isDestroyed()) win.webContents.send('note-changed', note.body);
467
+ }
468
+ });
469
+ }
470
+
344
471
  app.whenReady().then(async () => {
345
472
  // Run from npm there is no .app bundle to carry the icon, so macOS would show
346
473
  // the generic Electron atom in the Dock. Set it explicitly.
@@ -358,6 +485,7 @@ app.whenReady().then(async () => {
358
485
 
359
486
  await buildMenu();
360
487
  restoreNotes();
488
+ watchNotesFolder();
361
489
 
362
490
  if (SMOKE_CLOSE_MS) {
363
491
  setTimeout(() => {
@@ -374,13 +502,35 @@ app.whenReady().then(async () => {
374
502
  // process lingered invisibly with no way to reach it, and the next launch
375
503
  // handed off to it and appeared to do nothing. Quit there instead. The notes
376
504
  // are already on disk and come back on the next launch.
505
+ // Belt and braces: the window 'closed' handler above normally gets there
506
+ // first, but this catches any window torn down by another route.
377
507
  app.on('window-all-closed', () => {
378
- if (process.platform !== 'darwin') app.quit();
508
+ if (process.platform !== 'darwin') {
509
+ prepareToExit();
510
+ process.exit(0);
511
+ }
379
512
  });
380
- app.on('before-quit', () => store.saveNow());
513
+ app.on('before-quit', prepareToExit);
381
514
 
382
515
  ipcMain.handle('note:get', (_e, id) => store.get(id));
383
- ipcMain.handle('note:update', (_e, patch) => store.upsert(patch));
516
+ let titleTimer = null;
517
+ let lastTitles = '';
518
+
519
+ /** Rebuilds the Notes menu when a title changes, at a human pace. */
520
+ function refreshNoteTitles() {
521
+ clearTimeout(titleTimer);
522
+ titleTimer = setTimeout(() => {
523
+ const titles = store.all().map(titleOf).join('\u0000');
524
+ if (titles === lastTitles) return;
525
+ lastTitles = titles;
526
+ buildMenu();
527
+ }, 800);
528
+ }
529
+
530
+ ipcMain.handle('note:update', (_e, patch) => {
531
+ store.upsert(patch);
532
+ if (patch && patch.body !== undefined) refreshNoteTitles();
533
+ });
384
534
 
385
535
  // A closing note flushes its last edit here. Write straight through rather than
386
536
  // joining the debounce: the window is already going away.
@@ -391,12 +541,17 @@ ipcMain.on('note:flush', (e, patch) => {
391
541
  }
392
542
  e.returnValue = true;
393
543
  });
394
- ipcMain.handle('note:new', () => createNote().id);
544
+ ipcMain.handle('note:new', () => {
545
+ const id = createNote().id;
546
+ buildMenu();
547
+ return id;
548
+ });
395
549
 
396
550
  ipcMain.handle('note:delete', (_e, id) => {
397
551
  store.remove(id);
398
552
  const win = windows.get(id);
399
553
  if (win && !win.isDestroyed()) win.destroy();
554
+ buildMenu();
400
555
  });
401
556
 
402
557
  ipcMain.handle('note:setAlwaysOnTop', (e, value) => {
@@ -466,6 +621,19 @@ ipcMain.handle('note:mathMenu', (e, { rect, tex } = {}) => {
466
621
 
467
622
  ipcMain.handle('note:openExternal', (_e, url) => openExternal(url));
468
623
 
624
+ /**
625
+ * Pops the application menu open at the toolbar button.
626
+ *
627
+ * frame: false means Windows and Linux draw no menu bar, so every command
628
+ * behind it was reachable only by a shortcut someone had to already know.
629
+ * The same menu, so accelerators and this stay in step by construction.
630
+ */
631
+ ipcMain.on('app:show-menu', (event) => {
632
+ const win = BrowserWindow.fromWebContents(event.sender);
633
+ const menu = Menu.getApplicationMenu();
634
+ if (win && menu) menu.popup({ window: win, x: 8, y: 26 });
635
+ });
636
+
469
637
  ipcMain.handle('note:copyText', (_e, text) => clipboard.writeText(String(text)));
470
638
 
471
639
  /**
@@ -492,6 +660,15 @@ ipcMain.handle('ai:settings', async () => {
492
660
 
493
661
  ipcMain.handle('ai:complete', (_e, payload) => ai.complete(payload || {}));
494
662
 
663
+ ipcMain.handle('ai:toLatex', async (_e, { text } = {}) => {
664
+ const saved = store.settings();
665
+ const { available, models } = await ai.status();
666
+ if (!available || !models.length) return { error: 'ollama-unreachable' };
667
+ const names = models.map((m) => m.name);
668
+ const model = names.includes(saved.aiModel) ? saved.aiModel : ai.pickDefault(models);
669
+ return { latex: await ai.toLatex({ text, model }) };
670
+ });
671
+
495
672
  /**
496
673
  * Puts a PNG on the clipboard.
497
674
  *
package/src/preload.js CHANGED
@@ -29,17 +29,21 @@ contextBridge.exposeInMainWorld('sticky', {
29
29
  flush: (body) => ipcRenderer.sendSync('note:flush', { id: noteId, body }),
30
30
  mathMenu: (payload) => ipcRenderer.invoke('note:mathMenu', payload),
31
31
  copyNote: (payload) => ipcRenderer.invoke('note:copyNote', payload),
32
+ // These windows are frameless, so on Windows and Linux there is no menu
33
+ // bar at all. This is the way in.
34
+ openAppMenu: () => ipcRenderer.send('app:show-menu'),
32
35
 
33
36
  // Optional, local-only autocomplete. Requests are made in the main process:
34
37
  // this page cannot reach the network, and should not be able to.
35
38
  ai: {
36
39
  settings: () => ipcRenderer.invoke('ai:settings'),
37
40
  complete: (payload) => ipcRenderer.invoke('ai:complete', payload),
41
+ toLatex: (payload) => ipcRenderer.invoke('ai:toLatex', payload),
38
42
  },
39
43
  on: (channel, fn) => {
40
44
  const allowed = [
41
- 'toggle-edit', 'request-delete', 'font-size', 'always-on-top-changed',
42
- 'copy-note-image', 'ai-settings-changed',
45
+ 'font-size', 'always-on-top-changed',
46
+ 'copy-note-image', 'ai-settings-changed', 'note-changed', 'find-in-note', 'describe-latex',
43
47
  ];
44
48
  if (!allowed.includes(channel)) return;
45
49
  ipcRenderer.on(channel, (_e, payload) => fn(payload));