latex-stickies 1.0.0 → 1.1.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 +5 -1
- package/package.json +20 -5
- package/src/editor/entry.js +69 -0
- package/src/main.js +197 -26
- package/src/preload.js +18 -5
- package/src/renderer/editor.js +69 -0
- package/src/renderer/live-editor.js +631 -0
- package/src/renderer/note.css +174 -139
- package/src/renderer/note.html +3 -7
- package/src/renderer/note.js +100 -129
- package/src/renderer/vendor/codemirror.js +36 -0
- package/src/welcome.js +45 -0
- package/src/renderer/markdown.js +0 -154
- package/src/renderer/vendor/marked.js +0 -80
- package/src/renderer/vendor/prism.js +0 -2009
- package/src/renderer/vendor/purify.js +0 -3
package/README.md
CHANGED
|
@@ -21,7 +21,8 @@ give it a minute; later launches are instant.
|
|
|
21
21
|
|
|
22
22
|
## Writing in a note
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Everything renders as you type. Click into a line to see the markdown behind
|
|
25
|
+
it; move away and it renders again.
|
|
25
26
|
|
|
26
27
|
**Math** — `$…$` inline, `$$…$$` on its own line. Delimiters have to hug their
|
|
27
28
|
content, so ordinary prose like `costs $5 and $6` stays text rather than turning
|
|
@@ -63,6 +64,9 @@ Use `Ctrl` in place of `Cmd` on Windows and Linux.
|
|
|
63
64
|
| `Cmd+E` | Toggle edit / preview |
|
|
64
65
|
| `Cmd+T` | Keep on top |
|
|
65
66
|
| `Cmd+±` | Text size |
|
|
67
|
+
| `Cmd+B` / `Cmd+I` / `Cmd+E` | Bold / italic / code |
|
|
68
|
+
| `Cmd+K` | Link |
|
|
69
|
+
| `Cmd+Shift+C` | Copy note as image |
|
|
66
70
|
| `Esc` | Leave edit mode |
|
|
67
71
|
|
|
68
72
|
## Where notes live
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "latex-stickies",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Sticky notes for your desktop that render LaTeX and Markdown.",
|
|
5
5
|
"main": "src/main.js",
|
|
6
6
|
"scripts": {
|
|
@@ -29,12 +29,27 @@
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"
|
|
32
|
+
"@codemirror/commands": "^6.11.0",
|
|
33
|
+
"@codemirror/lang-cpp": "^6.0.3",
|
|
34
|
+
"@codemirror/lang-css": "^6.3.1",
|
|
35
|
+
"@codemirror/lang-go": "^6.0.1",
|
|
36
|
+
"@codemirror/lang-html": "^6.4.12",
|
|
37
|
+
"@codemirror/lang-java": "^6.0.2",
|
|
38
|
+
"@codemirror/lang-javascript": "^6.2.5",
|
|
39
|
+
"@codemirror/lang-json": "^6.0.2",
|
|
40
|
+
"@codemirror/lang-markdown": "^6.5.2",
|
|
41
|
+
"@codemirror/lang-python": "^6.2.1",
|
|
42
|
+
"@codemirror/lang-rust": "^6.0.2",
|
|
43
|
+
"@codemirror/lang-sql": "^6.10.0",
|
|
44
|
+
"@codemirror/lang-yaml": "^6.1.3",
|
|
45
|
+
"@codemirror/language": "^6.12.4",
|
|
46
|
+
"@codemirror/state": "^6.7.2",
|
|
47
|
+
"@codemirror/view": "^6.43.10",
|
|
48
|
+
"@lezer/highlight": "^1.2.3",
|
|
33
49
|
"electron-builder": "^26.15.3",
|
|
50
|
+
"esbuild": "^0.28.2",
|
|
34
51
|
"jsdom": "^30.0.1",
|
|
35
|
-
"katex": "^0.16.11"
|
|
36
|
-
"marked": "^18.0.11",
|
|
37
|
-
"prismjs": "^1.30.0"
|
|
52
|
+
"katex": "^0.16.11"
|
|
38
53
|
},
|
|
39
54
|
"dependencies": {
|
|
40
55
|
"electron": "^44.1.1"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle entry for the live-preview editor.
|
|
3
|
+
*
|
|
4
|
+
* CodeMirror 6 ships as ES modules, which cannot be loaded over file:// under
|
|
5
|
+
* the page's `script-src 'self'` policy. esbuild rolls the pieces we use into
|
|
6
|
+
* one classic script that hangs off window.CM, matching how katex, purify and
|
|
7
|
+
* prism are vendored. Run `npm run vendor` after changing this.
|
|
8
|
+
*
|
|
9
|
+
* The language packages are imported statically rather than through
|
|
10
|
+
* @codemirror/language-data, which loads grammars with dynamic import -- that
|
|
11
|
+
* would leave the bundle with chunks it cannot fetch over file://.
|
|
12
|
+
*/
|
|
13
|
+
import { EditorState, StateField, RangeSetBuilder, Prec } from '@codemirror/state';
|
|
14
|
+
import {
|
|
15
|
+
EditorView, keymap, Decoration, WidgetType, ViewPlugin,
|
|
16
|
+
} from '@codemirror/view';
|
|
17
|
+
import {
|
|
18
|
+
defaultKeymap, history, historyKeymap, indentWithTab,
|
|
19
|
+
} from '@codemirror/commands';
|
|
20
|
+
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
|
21
|
+
import {
|
|
22
|
+
syntaxTree, HighlightStyle, syntaxHighlighting, defaultHighlightStyle,
|
|
23
|
+
LanguageDescription,
|
|
24
|
+
} from '@codemirror/language';
|
|
25
|
+
import { tags } from '@lezer/highlight';
|
|
26
|
+
|
|
27
|
+
import { python } from '@codemirror/lang-python';
|
|
28
|
+
import { javascript } from '@codemirror/lang-javascript';
|
|
29
|
+
import { json } from '@codemirror/lang-json';
|
|
30
|
+
import { yaml } from '@codemirror/lang-yaml';
|
|
31
|
+
import { html } from '@codemirror/lang-html';
|
|
32
|
+
import { css } from '@codemirror/lang-css';
|
|
33
|
+
import { cpp } from '@codemirror/lang-cpp';
|
|
34
|
+
import { rust } from '@codemirror/lang-rust';
|
|
35
|
+
import { go } from '@codemirror/lang-go';
|
|
36
|
+
import { java } from '@codemirror/lang-java';
|
|
37
|
+
import { sql } from '@codemirror/lang-sql';
|
|
38
|
+
|
|
39
|
+
/** What a fence's language name may be written as, mapped to a grammar. */
|
|
40
|
+
const codeLanguages = [
|
|
41
|
+
LanguageDescription.of({ name: 'python', alias: ['py'], load: async () => python() }),
|
|
42
|
+
LanguageDescription.of({
|
|
43
|
+
name: 'javascript',
|
|
44
|
+
alias: ['js', 'jsx', 'ts', 'typescript', 'tsx'],
|
|
45
|
+
load: async () => javascript(),
|
|
46
|
+
}),
|
|
47
|
+
LanguageDescription.of({ name: 'json', load: async () => json() }),
|
|
48
|
+
LanguageDescription.of({ name: 'yaml', alias: ['yml'], load: async () => yaml() }),
|
|
49
|
+
LanguageDescription.of({ name: 'html', alias: ['xml'], load: async () => html() }),
|
|
50
|
+
LanguageDescription.of({ name: 'css', load: async () => css() }),
|
|
51
|
+
LanguageDescription.of({
|
|
52
|
+
name: 'cpp',
|
|
53
|
+
alias: ['c', 'c++', 'h'],
|
|
54
|
+
load: async () => cpp(),
|
|
55
|
+
}),
|
|
56
|
+
LanguageDescription.of({ name: 'rust', alias: ['rs'], load: async () => rust() }),
|
|
57
|
+
LanguageDescription.of({ name: 'go', load: async () => go() }),
|
|
58
|
+
LanguageDescription.of({ name: 'java', load: async () => java() }),
|
|
59
|
+
LanguageDescription.of({ name: 'sql', load: async () => sql() }),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
window.CM = {
|
|
63
|
+
EditorState, StateField, RangeSetBuilder, Prec,
|
|
64
|
+
EditorView, keymap, Decoration, WidgetType, ViewPlugin,
|
|
65
|
+
defaultKeymap, history, historyKeymap, indentWithTab,
|
|
66
|
+
markdown, markdownLanguage,
|
|
67
|
+
syntaxTree, HighlightStyle, syntaxHighlighting, defaultHighlightStyle,
|
|
68
|
+
tags, codeLanguages,
|
|
69
|
+
};
|
package/src/main.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
app, BrowserWindow, ipcMain, Menu, clipboard, ClipboardItem, screen, shell,
|
|
3
|
+
} = require('electron');
|
|
2
4
|
const path = require('path');
|
|
3
5
|
|
|
4
6
|
// Pin the identity before anything derives a path from it.
|
|
@@ -16,6 +18,19 @@ const store = require('./store');
|
|
|
16
18
|
|
|
17
19
|
const ICON = path.join(__dirname, '..', 'build', 'icon.png');
|
|
18
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Test hook for scripts/smoke.js, inert unless the variable is set.
|
|
23
|
+
*
|
|
24
|
+
* What it enables is the ability to observe the window lifecycle from outside
|
|
25
|
+
* the app: how many notes are open, and closing them all on cue. That is the
|
|
26
|
+
* one thing CI could not reach, and the reason a Windows-only bug -- the app
|
|
27
|
+
* lingering with no windows and no way back -- shipped unnoticed.
|
|
28
|
+
*/
|
|
29
|
+
const SMOKE_CLOSE_MS = Number(process.env.LATEX_STICKIES_SMOKE_CLOSE_MS) || 0;
|
|
30
|
+
const smokeLog = (message) => {
|
|
31
|
+
if (SMOKE_CLOSE_MS) console.log(`SMOKE ${message}`);
|
|
32
|
+
};
|
|
33
|
+
|
|
19
34
|
const COLORS = ['yellow', 'blue', 'green', 'pink', 'purple', 'gray'];
|
|
20
35
|
|
|
21
36
|
/** noteId -> BrowserWindow */
|
|
@@ -94,7 +109,10 @@ function openNote(note) {
|
|
|
94
109
|
};
|
|
95
110
|
win.on('resize', persistBounds);
|
|
96
111
|
win.on('move', persistBounds);
|
|
97
|
-
win.on('closed', () =>
|
|
112
|
+
win.on('closed', () => {
|
|
113
|
+
windows.delete(note.id);
|
|
114
|
+
smokeLog(`windows=${windows.size}`);
|
|
115
|
+
});
|
|
98
116
|
|
|
99
117
|
// Links inside a note open in the real browser, never inside the sticky.
|
|
100
118
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
@@ -109,6 +127,7 @@ function openNote(note) {
|
|
|
109
127
|
});
|
|
110
128
|
|
|
111
129
|
windows.set(note.id, win);
|
|
130
|
+
smokeLog(`windows=${windows.size}`);
|
|
112
131
|
return win;
|
|
113
132
|
}
|
|
114
133
|
|
|
@@ -137,22 +156,7 @@ function restoreNotes() {
|
|
|
137
156
|
notes.forEach(openNote);
|
|
138
157
|
}
|
|
139
158
|
|
|
140
|
-
const WELCOME =
|
|
141
|
-
'# Welcome to LaTeX Stickies',
|
|
142
|
-
'',
|
|
143
|
-
'Type math inline like $e^{i\\pi} + 1 = 0$, or on its own line:',
|
|
144
|
-
'',
|
|
145
|
-
'$$\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}$$',
|
|
146
|
-
'',
|
|
147
|
-
'Markdown works too:',
|
|
148
|
-
'',
|
|
149
|
-
'- **bold**, *italic*, `code`, ~~strikethrough~~',
|
|
150
|
-
'- [ ] task lists',
|
|
151
|
-
'- > blockquotes, tables, and fenced code blocks',
|
|
152
|
-
'',
|
|
153
|
-
'Click the note to edit, click outside to render.',
|
|
154
|
-
'Cmd+N new note · Cmd+E toggle edit · Cmd+T always on top',
|
|
155
|
-
].join('\n');
|
|
159
|
+
const { WELCOME } = require('./welcome');
|
|
156
160
|
|
|
157
161
|
function buildMenu() {
|
|
158
162
|
const template = [
|
|
@@ -196,6 +200,13 @@ function buildMenu() {
|
|
|
196
200
|
accelerator: 'CmdOrCtrl+E',
|
|
197
201
|
click: () => BrowserWindow.getFocusedWindow()?.webContents.send('toggle-edit'),
|
|
198
202
|
},
|
|
203
|
+
{
|
|
204
|
+
label: 'Copy Note as Image',
|
|
205
|
+
accelerator: 'CmdOrCtrl+Shift+C',
|
|
206
|
+
click: () => BrowserWindow.getFocusedWindow()
|
|
207
|
+
?.webContents.send('copy-note-image'),
|
|
208
|
+
},
|
|
209
|
+
{ type: 'separator' },
|
|
199
210
|
{
|
|
200
211
|
label: 'Always on Top',
|
|
201
212
|
accelerator: 'CmdOrCtrl+T',
|
|
@@ -247,12 +258,27 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
247
258
|
app.exit(0);
|
|
248
259
|
}
|
|
249
260
|
|
|
250
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Brings the notes back to the front -- from a Dock click on macOS, or from a
|
|
263
|
+
* second launch anywhere.
|
|
264
|
+
*
|
|
265
|
+
* Reopening from disk when nothing is on screen is the important half. Without
|
|
266
|
+
* it, launching again while a note-less instance was alive iterated an empty
|
|
267
|
+
* set of windows and did nothing at all, so the app looked hung: the launcher
|
|
268
|
+
* reported it was running, and no window ever appeared.
|
|
269
|
+
*/
|
|
270
|
+
function surfaceNotes() {
|
|
271
|
+
if (windows.size === 0) {
|
|
272
|
+
restoreNotes();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
251
275
|
windows.forEach((win) => {
|
|
252
276
|
if (win.isMinimized()) win.restore();
|
|
253
277
|
win.show();
|
|
254
278
|
});
|
|
255
|
-
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
app.on('second-instance', surfaceNotes);
|
|
256
282
|
|
|
257
283
|
app.whenReady().then(() => {
|
|
258
284
|
// Run from npm there is no .app bundle to carry the icon, so macOS would show
|
|
@@ -268,14 +294,24 @@ app.whenReady().then(() => {
|
|
|
268
294
|
buildMenu();
|
|
269
295
|
restoreNotes();
|
|
270
296
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
297
|
+
if (SMOKE_CLOSE_MS) {
|
|
298
|
+
setTimeout(() => {
|
|
299
|
+
smokeLog('closing every note');
|
|
300
|
+
[...windows.values()].forEach((win) => win.close());
|
|
301
|
+
}, SMOKE_CLOSE_MS);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
app.on('activate', surfaceNotes);
|
|
275
305
|
});
|
|
276
306
|
|
|
277
|
-
//
|
|
278
|
-
|
|
307
|
+
// macOS apps outlive their windows -- that is how Stickies behaves, and the
|
|
308
|
+
// Dock icon is the way back. Windows and Linux have no such affordance: the
|
|
309
|
+
// process lingered invisibly with no way to reach it, and the next launch
|
|
310
|
+
// handed off to it and appeared to do nothing. Quit there instead. The notes
|
|
311
|
+
// are already on disk and come back on the next launch.
|
|
312
|
+
app.on('window-all-closed', () => {
|
|
313
|
+
if (process.platform !== 'darwin') app.quit();
|
|
314
|
+
});
|
|
279
315
|
app.on('before-quit', () => store.saveNow());
|
|
280
316
|
|
|
281
317
|
ipcMain.handle('note:get', (_e, id) => store.get(id));
|
|
@@ -305,4 +341,139 @@ ipcMain.handle('note:setAlwaysOnTop', (e, value) => {
|
|
|
305
341
|
|
|
306
342
|
ipcMain.handle('note:close', (e) => BrowserWindow.fromWebContents(e.sender)?.close());
|
|
307
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Right-click menu for a rendered equation.
|
|
346
|
+
*
|
|
347
|
+
* "Copy as Image" screenshots just the equation's rectangle out of the live
|
|
348
|
+
* window. KaTeX draws with HTML and fonts rather than emitting a picture, so
|
|
349
|
+
* capturing what is already on screen is both the simplest route to a
|
|
350
|
+
* pasteable image and the one guaranteed to match what the note shows.
|
|
351
|
+
*
|
|
352
|
+
* Resolves once the menu closes, so the renderer knows when to drop the
|
|
353
|
+
* temporary capture styling.
|
|
354
|
+
*/
|
|
355
|
+
ipcMain.handle('note:mathMenu', (e, { rect, tex } = {}) => {
|
|
356
|
+
const win = BrowserWindow.fromWebContents(e.sender);
|
|
357
|
+
if (!win) return null;
|
|
358
|
+
|
|
359
|
+
return new Promise((resolve) => {
|
|
360
|
+
const menu = Menu.buildFromTemplate([
|
|
361
|
+
{
|
|
362
|
+
label: 'Copy as Image',
|
|
363
|
+
enabled: !!rect,
|
|
364
|
+
click: async () => {
|
|
365
|
+
try {
|
|
366
|
+
// capturePage stalls on macOS when the window is not frontmost.
|
|
367
|
+
// Right-clicking a note focuses it, but make that explicit.
|
|
368
|
+
if (!win.isFocused()) win.focus();
|
|
369
|
+
// capturePage wants whole pixels inside the page. It captures at
|
|
370
|
+
// the display's scale factor, so this comes back at 2x on Retina.
|
|
371
|
+
const image = await win.webContents.capturePage({
|
|
372
|
+
x: Math.max(0, Math.floor(rect.x)),
|
|
373
|
+
y: Math.max(0, Math.floor(rect.y)),
|
|
374
|
+
width: Math.max(1, Math.ceil(rect.width)),
|
|
375
|
+
height: Math.max(1, Math.ceil(rect.height)),
|
|
376
|
+
});
|
|
377
|
+
await copyImage(image);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
console.error('could not copy the equation as an image', err);
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
label: 'Copy LaTeX',
|
|
385
|
+
enabled: !!tex,
|
|
386
|
+
click: () => {
|
|
387
|
+
Promise.resolve(clipboard.writeText(tex)).catch((err) =>
|
|
388
|
+
console.error('could not copy the LaTeX source', err));
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{ type: 'separator' },
|
|
392
|
+
{
|
|
393
|
+
label: 'Copy Note as Image',
|
|
394
|
+
// The renderer has to hide the toolbar and measure the content first.
|
|
395
|
+
click: () => win.webContents.send('copy-note-image'),
|
|
396
|
+
},
|
|
397
|
+
]);
|
|
398
|
+
menu.popup({ window: win, callback: () => resolve(null) });
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
308
402
|
ipcMain.handle('note:openExternal', (_e, url) => openExternal(url));
|
|
403
|
+
|
|
404
|
+
ipcMain.handle('note:copyText', (_e, text) => clipboard.writeText(String(text)));
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Puts a PNG on the clipboard.
|
|
408
|
+
*
|
|
409
|
+
* Electron 44 replaced the clipboard with the async, web-shaped API: there is
|
|
410
|
+
* no writeImage, and write() takes ClipboardItems. The older call fails
|
|
411
|
+
* silently rather than throwing, so everything image-related goes through here.
|
|
412
|
+
*/
|
|
413
|
+
async function copyImage(image) {
|
|
414
|
+
if (!image || image.isEmpty()) throw new Error('captured an empty image');
|
|
415
|
+
await clipboard.write([new ClipboardItem({ 'image/png': image.toPNG() })]);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Screenshots a whole note.
|
|
420
|
+
*
|
|
421
|
+
* A note can hold more than its window shows, and a picture that stops at the
|
|
422
|
+
* scroll line is not the note. So the window is grown to fit its content for
|
|
423
|
+
* the capture and put straight back -- capped to the display, since a very long
|
|
424
|
+
* note would otherwise ask for a window taller than the screen.
|
|
425
|
+
*/
|
|
426
|
+
/**
|
|
427
|
+
* Screenshots a whole note, however long it is.
|
|
428
|
+
*
|
|
429
|
+
* The note's own window cannot do this: macOS clamps a window to the display,
|
|
430
|
+
* and the editor only renders the lines it believes are on screen, so a note
|
|
431
|
+
* taller than the screen came out cut off. Instead the note is opened again in
|
|
432
|
+
* an offscreen window -- which has no such limit -- sized to its full content,
|
|
433
|
+
* and that is what gets photographed.
|
|
434
|
+
*
|
|
435
|
+
* The offscreen page is the same note.html loading the same note id, so the
|
|
436
|
+
* picture cannot drift from what the real window shows.
|
|
437
|
+
*/
|
|
438
|
+
ipcMain.handle('note:copyNote', async (e, { width } = {}) => {
|
|
439
|
+
const source = BrowserWindow.fromWebContents(e.sender);
|
|
440
|
+
const entry = [...windows.entries()].find(([, w]) => w === source);
|
|
441
|
+
if (!source || !entry) return null;
|
|
442
|
+
const noteId = entry[0];
|
|
443
|
+
|
|
444
|
+
const shot = new BrowserWindow({
|
|
445
|
+
width: width || source.getBounds().width,
|
|
446
|
+
height: 800, // resized once the page reports how tall it really is
|
|
447
|
+
show: false,
|
|
448
|
+
frame: false,
|
|
449
|
+
webPreferences: {
|
|
450
|
+
offscreen: true,
|
|
451
|
+
preload: path.join(__dirname, 'preload.js'),
|
|
452
|
+
contextIsolation: true,
|
|
453
|
+
nodeIntegration: false,
|
|
454
|
+
additionalArguments: [`--note-id=${noteId}`, '--snapshot'],
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const height = await new Promise((resolve, reject) => {
|
|
460
|
+
const timer = setTimeout(
|
|
461
|
+
() => reject(new Error('the note never reported its height')), 8000);
|
|
462
|
+
ipcMain.once('note:snapshotReady', (_event, reported) => {
|
|
463
|
+
clearTimeout(timer);
|
|
464
|
+
resolve(Math.ceil(reported) || 800);
|
|
465
|
+
});
|
|
466
|
+
shot.loadFile(path.join(__dirname, 'renderer', 'note.html')).catch(reject);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
shot.setBounds({ ...shot.getBounds(), height });
|
|
470
|
+
// Let the editor fill in the lines the taller viewport now covers.
|
|
471
|
+
await new Promise((resolve) => setTimeout(resolve, 450));
|
|
472
|
+
await copyImage(await shot.webContents.capturePage());
|
|
473
|
+
} catch (err) {
|
|
474
|
+
console.error('could not copy the note as an image', err);
|
|
475
|
+
} finally {
|
|
476
|
+
if (!shot.isDestroyed()) shot.destroy();
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
});
|
package/src/preload.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
const { contextBridge, ipcRenderer
|
|
1
|
+
const { contextBridge, ipcRenderer } = require('electron');
|
|
2
2
|
|
|
3
|
+
const snapshot = process.argv.includes('--snapshot');
|
|
3
4
|
const arg = process.argv.find((a) => a.startsWith('--note-id='));
|
|
4
5
|
const noteId = arg ? arg.slice('--note-id='.length) : null;
|
|
5
6
|
|
|
6
7
|
contextBridge.exposeInMainWorld('sticky', {
|
|
7
8
|
noteId,
|
|
9
|
+
// True in the offscreen window used to photograph a note. That window is
|
|
10
|
+
// not display-limited, so a note longer than the screen can be captured
|
|
11
|
+
// whole -- which the visible window can never do.
|
|
12
|
+
isSnapshot: snapshot,
|
|
13
|
+
snapshotReady: (height) => ipcRenderer.send('note:snapshotReady', height),
|
|
8
14
|
get: () => ipcRenderer.invoke('note:get', noteId),
|
|
9
15
|
update: (patch) => ipcRenderer.invoke('note:update', { id: noteId, ...patch }),
|
|
10
16
|
create: () => ipcRenderer.invoke('note:new'),
|
|
@@ -12,15 +18,22 @@ contextBridge.exposeInMainWorld('sticky', {
|
|
|
12
18
|
setAlwaysOnTop: (v) => ipcRenderer.invoke('note:setAlwaysOnTop', v),
|
|
13
19
|
close: () => ipcRenderer.invoke('note:close'),
|
|
14
20
|
openExternal: (url) => ipcRenderer.invoke('note:openExternal', url),
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
copy
|
|
21
|
+
// Routed through the main process rather than written here. The clipboard
|
|
22
|
+
// API differs between processes and changed shape in Electron 44; this way
|
|
23
|
+
// every copy in the app goes through one implementation that is known to
|
|
24
|
+
// work, instead of a second one nobody tested.
|
|
25
|
+
copy: (text) => ipcRenderer.invoke('note:copyText', String(text)),
|
|
18
26
|
// Synchronous on purpose. This is called while the window is being torn
|
|
19
27
|
// down, where an async invoke() would race the renderer's destruction and
|
|
20
28
|
// lose the edit. sendSync blocks until main has the text.
|
|
21
29
|
flush: (body) => ipcRenderer.sendSync('note:flush', { id: noteId, body }),
|
|
30
|
+
mathMenu: (payload) => ipcRenderer.invoke('note:mathMenu', payload),
|
|
31
|
+
copyNote: (payload) => ipcRenderer.invoke('note:copyNote', payload),
|
|
22
32
|
on: (channel, fn) => {
|
|
23
|
-
const allowed = [
|
|
33
|
+
const allowed = [
|
|
34
|
+
'toggle-edit', 'request-delete', 'font-size', 'always-on-top-changed',
|
|
35
|
+
'copy-note-image',
|
|
36
|
+
];
|
|
24
37
|
if (!allowed.includes(channel)) return;
|
|
25
38
|
ipcRenderer.on(channel, (_e, payload) => fn(payload));
|
|
26
39
|
},
|
package/src/renderer/editor.js
CHANGED
|
@@ -164,9 +164,78 @@ function onBackspace(value, start, end) {
|
|
|
164
164
|
return null;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/* ---------- formatting shortcuts ---------- */
|
|
168
|
+
|
|
169
|
+
const WRAPPERS = {
|
|
170
|
+
b: '**',
|
|
171
|
+
i: '*',
|
|
172
|
+
e: '`',
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Cmd/Ctrl+B, +I, +E wrap the selection, and unwrap it again when it is
|
|
177
|
+
* already wrapped -- pressing bold twice should leave the text as it started,
|
|
178
|
+
* not bury it in four asterisks.
|
|
179
|
+
*/
|
|
180
|
+
function onWrap(value, start, end, key) {
|
|
181
|
+
const mark = WRAPPERS[key];
|
|
182
|
+
if (!mark) return null;
|
|
183
|
+
const n = mark.length;
|
|
184
|
+
|
|
185
|
+
const selected = value.slice(start, end);
|
|
186
|
+
|
|
187
|
+
// Already wrapped, either inside the selection or just outside it.
|
|
188
|
+
if (selected.startsWith(mark) && selected.endsWith(mark) && selected.length > n * 2) {
|
|
189
|
+
const inner = selected.slice(n, -n);
|
|
190
|
+
return {
|
|
191
|
+
from: start, to: end, insert: inner,
|
|
192
|
+
selStart: start, selEnd: start + inner.length,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (value.slice(start - n, start) === mark && value.slice(end, end + n) === mark) {
|
|
196
|
+
return {
|
|
197
|
+
from: start - n, to: end + n, insert: selected,
|
|
198
|
+
selStart: start - n, selEnd: start - n + selected.length,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (start === end) {
|
|
203
|
+
// No selection: leave the caret between a fresh pair, ready to type.
|
|
204
|
+
return { from: start, to: end, insert: mark + mark, selStart: start + n };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
from: start, to: end,
|
|
209
|
+
insert: mark + selected + mark,
|
|
210
|
+
selStart: start + n, selEnd: end + n,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Cmd/Ctrl+K makes a link, and leaves the caret wherever there is still
|
|
216
|
+
* something to type: in the URL when the label came from the selection, in the
|
|
217
|
+
* brackets when the link is empty and needs a label first.
|
|
218
|
+
*/
|
|
219
|
+
function onLink(value, start, end) {
|
|
220
|
+
const selected = value.slice(start, end);
|
|
221
|
+
const insert = `[${selected}]()`;
|
|
222
|
+
const caret = selected
|
|
223
|
+
? start + selected.length + 3 // inside ( )
|
|
224
|
+
: start + 1; // inside [ ]
|
|
225
|
+
return { from: start, to: end, insert, selStart: caret };
|
|
226
|
+
}
|
|
227
|
+
|
|
167
228
|
/* ---------- dispatch ---------- */
|
|
168
229
|
|
|
169
230
|
function computeEdit(e, value, start, end) {
|
|
231
|
+
// Cmd on macOS, Ctrl elsewhere -- but never Alt, which carries its own
|
|
232
|
+
// characters, and never both modifiers at once.
|
|
233
|
+
const mod = (e.metaKey || e.ctrlKey) && !e.altKey;
|
|
234
|
+
if (mod) {
|
|
235
|
+
const key = (e.key || '').toLowerCase();
|
|
236
|
+
if (key === 'k') return onLink(value, start, end);
|
|
237
|
+
return onWrap(value, start, end, key);
|
|
238
|
+
}
|
|
170
239
|
if (e.metaKey || e.ctrlKey || e.altKey) return null;
|
|
171
240
|
|
|
172
241
|
if (e.key === 'Enter') return onEnter(value, start, end);
|