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 +114 -61
- package/package.json +3 -1
- package/src/ai.js +47 -1
- package/src/editor/entry.js +4 -0
- package/src/main.js +189 -12
- package/src/preload.js +6 -2
- package/src/renderer/live-editor.js +31 -4
- package/src/renderer/note.css +36 -2
- package/src/renderer/note.html +1 -0
- package/src/renderer/note.js +57 -7
- package/src/renderer/vendor/codemirror.js +22 -21
- package/src/store.js +269 -69
- package/src/welcome.js +2 -1
package/README.md
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# LaTeX Stickies
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/latex-stickies)
|
|
4
|
+
[](https://github.com/kev-nj/latex-stickies/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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+
|
|
70
|
-
| `
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 #
|
|
92
|
-
npm run vendor #
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
};
|
package/src/editor/entry.js
CHANGED
|
@@ -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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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: '
|
|
221
|
-
accelerator: 'CmdOrCtrl+
|
|
222
|
-
click: () => BrowserWindow.getFocusedWindow()?.webContents.send('
|
|
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')
|
|
508
|
+
if (process.platform !== 'darwin') {
|
|
509
|
+
prepareToExit();
|
|
510
|
+
process.exit(0);
|
|
511
|
+
}
|
|
379
512
|
});
|
|
380
|
-
app.on('before-quit',
|
|
513
|
+
app.on('before-quit', prepareToExit);
|
|
381
514
|
|
|
382
515
|
ipcMain.handle('note:get', (_e, id) => store.get(id));
|
|
383
|
-
|
|
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', () =>
|
|
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
|
-
'
|
|
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));
|