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 CHANGED
@@ -21,7 +21,8 @@ give it a minute; later launches are instant.
21
21
 
22
22
  ## Writing in a note
23
23
 
24
- Click a note to edit it, click away to render. `Esc` also leaves edit mode.
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.0.0",
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
- "dompurify": "^3.1.6",
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 { app, BrowserWindow, ipcMain, Menu, screen, shell } = require('electron');
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', () => windows.delete(note.id));
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
- app.on('second-instance', () => {
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
- app.on('activate', () => {
272
- if (windows.size === 0) restoreNotes();
273
- else windows.forEach((w) => w.show());
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
- // Stickies live on even with no window open, matching the macOS app.
278
- app.on('window-all-closed', () => {});
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, clipboard } = require('electron');
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
- // Electron's clipboard rather than navigator.clipboard, which is
16
- // unreliable for a page served over file://.
17
- copy: (text) => clipboard.writeText(String(text)),
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 = ['toggle-edit', 'request-delete', 'font-size', 'always-on-top-changed'];
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
  },
@@ -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);