latex-stickies 1.2.0 → 1.3.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/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # LaTeX Stickies
2
2
 
3
- Sticky notes for your desktop that render LaTeX and Markdown.
3
+ Sticky notes for your desktop that render LaTeX and Markdown as you type.
4
4
 
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.
5
+ Write `$e^{i\pi} + 1 = 0$` and it becomes the equation. Click into it and the
6
+ source comes back. There is no edit mode and no preview mode.
7
7
 
8
8
  ```
9
9
  npx latex-stickies
@@ -16,41 +16,63 @@ npm install -g latex-stickies
16
16
  latex-stickies
17
17
  ```
18
18
 
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.
19
+ Requires Node 22.12 or newer. The first run downloads the Electron runtime
20
+ (~230 MB), so give it a minute; later launches are instant. On a Mac you can
21
+ download a `.dmg` from [Releases](https://github.com/kev-nj/latex-stickies/releases)
22
+ instead and skip Node entirely.
21
23
 
22
24
  ## Writing in a note
23
25
 
24
- Everything renders as you type. Click into a line to see the markdown behind
25
- it; move away and it renders again.
26
+ Everything renders while you write. Put the caret in an element to see the
27
+ markdown behind it; move away and it renders again.
26
28
 
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.
29
+ **Maths** — `$…$` inline, `$$…$$` on its own line. Delimiters have to hug their
30
+ content, so prose like `costs $5 and $6` stays text rather than turning into a
31
+ formula. Broken TeX shows the source underlined in red with the parse error on
32
+ hover, instead of blanking the note.
31
33
 
32
- **Markdown** — headings, `**bold**`, `*italic*`, `~~strikethrough~~`, lists,
33
- task lists, tables, blockquotes, and links (which open in your browser).
34
+ **Markdown** — headings, `**bold**`, `*italic*`, `` `code` ``, `~~struck~~`,
35
+ quotes, lists, and links that open in your browser. Tables render as tables,
36
+ and their cells render maths. Task list checkboxes are clickable: tick one and
37
+ `[ ]` becomes `[x]` in the file.
34
38
 
35
- **Code** — fenced blocks are highlighted in 20 languages, with the language
36
- labelled and a copy button on hover:
39
+ **Code** — fenced blocks are highlighted across eleven languages, labelled with
40
+ the language, and each has a copy button.
37
41
 
38
- ````
39
- ```python
40
- def gaussian(x, mu, sigma):
41
- return exp(-((x - mu) ** 2) / (2 * sigma ** 2))
42
+ Inside a fence the editor behaves like a code editor: Enter keeps your
43
+ indentation and adds a level after `:` or `{`, Tab and Shift+Tab indent and
44
+ dedent, and brackets and quotes auto-close. Outside one it behaves like a notes
45
+ field — lists continue on Enter, and quotes are left alone so `don't` types
46
+ normally.
47
+
48
+ **Images of your maths** — right-click a rendered equation for *Copy as Image*
49
+ or *Copy LaTeX*. `Cmd+Shift+C` copies the whole note as an image, however long
50
+ it is. Both go straight to the clipboard, ready to paste into Slack or an email.
51
+
52
+ ## Autocomplete (optional, local)
53
+
54
+ A grey suggestion appears after a pause in typing; **Tab** accepts it, **Esc**
55
+ dismisses it. It runs entirely on your machine through
56
+ [Ollama](https://ollama.com) — nothing is sent anywhere — and is **off by
57
+ default**. Turn it on under **Note → Autocomplete**.
58
+
59
+ Pull a small model. This fires on every pause, so speed matters far more than
60
+ size:
61
+
62
+ ```
63
+ ollama pull qwen2.5-coder:1.5b
42
64
  ```
43
- ````
44
65
 
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.
66
+ Measured on a MacBook: **0.06–0.5s** with that model, against 0.4–3.5s for a
67
+ 14B — and the small one wrote better prose. Choose yours under **Note →
68
+ Autocomplete Model**; installed models are listed with their sizes.
48
69
 
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.
70
+ It knows what this app is for. `$$\int_0^1 x^2 dx = ` suggests `\frac{1}{3}$$`,
71
+ and `The derivative of $x^2$ is ` suggests `$2x$.`
72
+
73
+ **`Cmd+Shift+M`** turns a description into LaTeX: select *"integral of e to the
74
+ minus x squared from 0 to infinity"* and it becomes
75
+ `$\int_{0}^{\infty} e^{-x^2}\,dx$`.
54
76
 
55
77
  ## Keyboard
56
78
 
@@ -59,58 +81,81 @@ Use `Ctrl` in place of `Cmd` on Windows and Linux.
59
81
  | | |
60
82
  |---|---|
61
83
  | `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 |
84
+ | `Cmd+W` | Close note (it reopens next launch) |
85
+ | `Cmd+Shift+Backspace` | Delete note, permanently |
67
86
  | `Cmd+B` / `Cmd+I` / `Cmd+E` | Bold / italic / code |
68
87
  | `Cmd+K` | Link |
69
- | `Cmd+Shift+C` | Copy note as image |
70
- | `Esc` | Leave edit mode |
88
+ | `Cmd+F` | Find in note |
89
+ | `Cmd+Shift+M` | Maths from a description |
90
+ | `Cmd+Shift+C` | Copy note as an image |
91
+ | `Cmd+Shift+O` | Open the notes folder |
92
+ | `Cmd+T` | Keep on top |
93
+ | `Cmd+±` | Text size |
94
+
95
+ On Windows and Linux the note windows have no menu bar, so the **☰** button
96
+ in a note's toolbar opens the same menu. Every command is there.
97
+
98
+ The **All Notes** menu lists every note you have. A tick means it is on screen;
99
+ click to open a note, or click a ticked one to close it. So a note you closed
100
+ is one click away rather than lost until the next launch.
71
101
 
72
102
  ## Where notes live
73
103
 
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` |
104
+ One Markdown file per note, in a folder you can open:
105
+
106
+ ```
107
+ ~/Documents/LaTeX Stickies/
108
+ shopping-list.md
109
+ lecture-3.md
110
+ .stickies.json # colours, positions, pinned state
111
+ ```
112
+
113
+ They are ordinary files: grep them, keep the folder in git, sync it through
114
+ Dropbox, or edit a note in another editor — the open note follows the change.
115
+ `Cmd+Shift+O` opens the folder.
79
116
 
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.
117
+ Files are named from the note's first line, and renamed if you retitle it. A
118
+ file you rename yourself keeps its name. Dropping a `.tex` or `.txt` file into
119
+ the folder makes it a note too, and it keeps that extension.
120
+
121
+ Metadata lives in the sidecar index rather than in frontmatter, so the note
122
+ files stay clean — a `.tex` file with YAML at the top would not compile.
123
+
124
+ Saves are atomic: written to a temp file, flushed, then renamed over the
125
+ target, so a crash or force quit leaves the previous file intact rather than a
126
+ truncated one. Only one instance runs at a time, because two copies would hold
127
+ divergent notes and the last to quit would overwrite the other.
128
+
129
+ An existing `notes.json` from an older version is migrated the first time and
130
+ then left in place.
85
131
 
86
132
  ## Development
87
133
 
88
134
  ```
89
135
  npm install
90
136
  npm start # run from source
91
- npm test # 4 suites, 62 assertions
92
- npm run vendor # regenerate the bundled Prism
137
+ npm test # unit suites
138
+ npm run vendor # rebuild the bundled libraries
93
139
  npm run icon # rebuild the app icon from assets/icon-master.png
140
+ npm run dist # build a macOS .app and .dmg
94
141
  ```
95
142
 
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`:
143
+ Beyond the unit tests, several checks drive the real app and run in CI on
144
+ macOS, Windows and Linux:
107
145
 
108
146
  ```
109
- npm run dist
147
+ node scripts/smoke.js # boots, and stays up
148
+ node scripts/smoke.js --lifecycle # closing every note behaves per platform
149
+ node scripts/render-check.js # the first-run note renders correctly
150
+ node scripts/ghost-check.js # autocomplete suggests, and Tab accepts
151
+ node scripts/snapshot-check.js # a long note is captured whole
110
152
  ```
111
153
 
112
- The build is unsigned, so macOS will warn on other machines unless you sign it
113
- with an Apple Developer ID.
154
+ The renderer loads over `file://` under a strict content-security policy, so
155
+ CodeMirror and KaTeX are bundled into `src/renderer/vendor/` rather than pulled
156
+ from a CDN. Note text is never parsed as HTML, and the renderer reaches the
157
+ filesystem and the network only through a narrow preload bridge — Ollama is
158
+ called from the main process, so the page keeps `default-src 'none'`.
114
159
 
115
160
  ## License
116
161
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latex-stickies",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Sticky notes for your desktop that render LaTeX and Markdown.",
5
5
  "main": "src/main.js",
6
6
  "scripts": {
@@ -45,6 +45,7 @@
45
45
  "@codemirror/lang-sql": "^6.10.0",
46
46
  "@codemirror/lang-yaml": "^6.1.3",
47
47
  "@codemirror/language": "^6.12.4",
48
+ "@codemirror/search": "^6.7.2",
48
49
  "@codemirror/state": "^6.7.2",
49
50
  "@codemirror/view": "^6.43.10",
50
51
  "@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));