latex-stickies 1.4.0 → 1.4.2

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
@@ -23,6 +23,11 @@ npm install -g latex-stickies
23
23
  latex-stickies
24
24
  ```
25
25
 
26
+ If a launch fails, the launcher says so and shows the runtime's own output;
27
+ the full log is kept at `~/Library/Logs/latex-stickies/launch.log` on macOS,
28
+ `%LOCALAPPDATA%\latex-stickies\` on Windows, and `$XDG_STATE_HOME` (or
29
+ `~/.local/state/latex-stickies/`) on Linux.
30
+
26
31
  Requires Node 22.12 or newer. The first run downloads the Electron runtime
27
32
  (~230 MB), so give it a minute; later launches are instant. On a Mac it also
28
33
  keeps a copy of the runtime carrying this app's name and icon in
@@ -6,10 +6,26 @@
6
6
  * We spawn it against this package's own directory, detached, so the notes
7
7
  * keep running after the terminal that started them is closed -- a sticky
8
8
  * note that dies with your shell is not a sticky note.
9
+ *
10
+ * Detached, but not unwatched. This used to print "LaTeX Stickies is running"
11
+ * the instant it had spawned something, with output going to /dev/null, so a
12
+ * runtime that started and died a moment later -- a missing shared library, a
13
+ * half-downloaded Electron, root without --no-sandbox -- reported success and
14
+ * left nothing behind to read. The launcher now keeps the child for a couple
15
+ * of seconds and says what happened if it does not survive.
9
16
  */
10
17
  const { spawn } = require('child_process');
18
+ const fs = require('fs');
19
+ const os = require('os');
11
20
  const path = require('path');
12
21
 
22
+ /** How long a launch has to survive before we call it a launch. */
23
+ const STARTUP_MS = 2000;
24
+ /** How much of the log to show when it does not. */
25
+ const LOG_TAIL_LINES = 20;
26
+ /** Kept from growing without bound: each launch starts a fresh log. */
27
+ const LOG_NAME = 'launch.log';
28
+
13
29
  // Brand before resolving anything: this returns the path to a copy of the
14
30
  // Electron shell carrying our name and icon, kept outside node_modules so it
15
31
  // survives npx cache churn and npm ci. Falls back to the plain shell, which
@@ -37,16 +53,108 @@ if (!electron) {
37
53
  }
38
54
  }
39
55
 
56
+ /** Where this platform expects a log to live. */
57
+ function logDirectory() {
58
+ if (process.platform === 'darwin') {
59
+ return path.join(os.homedir(), 'Library', 'Logs', 'latex-stickies');
60
+ }
61
+ if (process.platform === 'win32') {
62
+ const base = process.env.LOCALAPPDATA
63
+ || path.join(os.homedir(), 'AppData', 'Local');
64
+ return path.join(base, 'latex-stickies');
65
+ }
66
+ const base = process.env.XDG_STATE_HOME
67
+ || path.join(os.homedir(), '.local', 'state');
68
+ return path.join(base, 'latex-stickies');
69
+ }
70
+
71
+ /**
72
+ * Opens the log the app's output goes to.
73
+ *
74
+ * Truncated on every launch rather than appended to: it exists to explain the
75
+ * launch that just failed, and that keeps it bounded without any rotation.
76
+ * If it cannot be opened -- a read-only home, an odd container -- the launch
77
+ * still goes ahead with the output discarded, because a missing log is a far
78
+ * smaller problem than refusing to start.
79
+ */
80
+ function openLog() {
81
+ try {
82
+ const dir = logDirectory();
83
+ fs.mkdirSync(dir, { recursive: true });
84
+ const file = path.join(dir, LOG_NAME);
85
+ return { file, fd: fs.openSync(file, 'w') };
86
+ } catch (_) {
87
+ return { file: null, fd: 'ignore' };
88
+ }
89
+ }
90
+
91
+ /** The tail of the log, for a failure message. */
92
+ function logTail(file) {
93
+ if (!file) return '';
94
+ try {
95
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
96
+ return lines.slice(-LOG_TAIL_LINES).join('\n');
97
+ } catch (_) {
98
+ return '';
99
+ }
100
+ }
101
+
102
+ const { file: logFile, fd: logFd } = openLog();
103
+
40
104
  const appDir = path.join(__dirname, '..');
41
105
  const child = spawn(electron, [appDir, ...process.argv.slice(2)], {
42
106
  detached: true,
43
- stdio: 'ignore',
107
+ stdio: ['ignore', logFd, logFd],
44
108
  });
45
109
 
110
+ // The child has its own handle on the log now; this one would otherwise keep
111
+ // the descriptor open for the life of the parent.
112
+ if (typeof logFd === 'number') {
113
+ try { fs.closeSync(logFd); } catch (_) { /* already gone */ }
114
+ }
115
+
46
116
  child.on('error', (err) => {
47
- console.error('Failed to launch LaTeX Stickies:', err.message);
117
+ console.error(`Failed to launch LaTeX Stickies: ${err.message}`);
48
118
  process.exit(1);
49
119
  });
50
120
 
51
- child.unref();
52
- console.log('LaTeX Stickies is running. Close the notes to quit.');
121
+ /**
122
+ * Electron will not run as root without --no-sandbox, and says so only in the
123
+ * output nobody was reading. Naming it beats adding the flag quietly: turning
124
+ * off the sandbox is a decision for whoever is running as root to make.
125
+ */
126
+ function rootHint() {
127
+ if (process.getuid && process.getuid() === 0) {
128
+ return '\nRunning as root: Electron refuses to start without a sandbox.'
129
+ + '\nRun it as an ordinary user, or pass --no-sandbox if you mean it:'
130
+ + '\n latex-stickies --no-sandbox';
131
+ }
132
+ return '';
133
+ }
134
+
135
+ const onEarlyExit = (code, signal) => {
136
+ clearTimeout(timer);
137
+ const tail = logTail(logFile);
138
+ console.error(
139
+ `LaTeX Stickies exited immediately (code=${code}, signal=${signal}).`
140
+ + rootHint()
141
+ + (tail ? `\n\nLast ${LOG_TAIL_LINES} lines of the log:\n${tail}` : '')
142
+ + (logFile ? `\n\nFull log: ${logFile}` : '\n\nNo log could be written.')
143
+ );
144
+ process.exit(1);
145
+ };
146
+
147
+ child.on('exit', onEarlyExit);
148
+
149
+ // Survived the window: let go of it and let this process end, so the terminal
150
+ // is not held open by a note that is running perfectly well.
151
+ const timer = setTimeout(() => {
152
+ child.removeListener('exit', onEarlyExit);
153
+ child.removeAllListeners('error');
154
+ child.unref();
155
+ console.log('LaTeX Stickies is running. Close the notes to quit.');
156
+ }, STARTUP_MS);
157
+
158
+ // Watching must not become waiting: an unreferenced timer still fires, but it
159
+ // no longer holds the event loop open on its own.
160
+ timer.unref();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latex-stickies",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Sticky notes for your desktop that render LaTeX and Markdown.",
5
5
  "main": "src/main.js",
6
6
  "scripts": {
@@ -63,6 +63,12 @@ function source() {
63
63
  const binary = require(path.join(dir, 'index.js'));
64
64
  // .../Electron.app/Contents/MacOS/Electron -> .../Electron.app
65
65
  const app = path.resolve(path.dirname(binary), '..', '..');
66
+ // Make sure that really is a bundle before anything is built from it. A
67
+ // stub or an odd install resolves to something that is not an .app, and the
68
+ // build then fails partway and takes the existing branded copy with it.
69
+ if (!app.endsWith('.app') || !fs.existsSync(path.join(app, 'Contents', 'MacOS'))) {
70
+ throw new Error(`the Electron runtime at ${app} is not an app bundle`);
71
+ }
66
72
  const { version } = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
67
73
  return { app, version };
68
74
  }
@@ -173,8 +179,12 @@ function ensure() {
173
179
  return binary;
174
180
  } catch (err) {
175
181
  // Leave nothing half-built: a bundle with a broken signature will not
176
- // launch at all, which is far worse than the wrong name.
177
- fs.rmSync(TARGET, { recursive: true, force: true });
182
+ // launch at all, which is far worse than the wrong name. Only what this
183
+ // run was building, though -- a failure part way through is no reason to
184
+ // remove a copy that was working before it.
185
+ if (!isCurrent(version)) {
186
+ fs.rmSync(TARGET, { recursive: true, force: true });
187
+ }
178
188
  report(`could not brand the app (${first(err)})`);
179
189
  return null;
180
190
  }
@@ -10,12 +10,15 @@
10
10
  * @codemirror/language-data, which loads grammars with dynamic import -- that
11
11
  * would leave the bundle with chunks it cannot fetch over file://.
12
12
  */
13
- import { EditorState, StateField, StateEffect, RangeSetBuilder, Prec } from '@codemirror/state';
13
+ import {
14
+ EditorState, EditorSelection, StateField, StateEffect, RangeSetBuilder, Prec,
15
+ } from '@codemirror/state';
14
16
  import {
15
17
  EditorView, keymap, Decoration, WidgetType, ViewPlugin,
16
18
  } from '@codemirror/view';
17
19
  import {
18
20
  defaultKeymap, history, historyKeymap, indentWithTab,
21
+ cursorLineUp, cursorLineDown, selectLineUp, selectLineDown,
19
22
  } from '@codemirror/commands';
20
23
  import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
21
24
  import {
@@ -63,9 +66,10 @@ const codeLanguages = [
63
66
  ];
64
67
 
65
68
  window.CM = {
66
- EditorState, StateField, StateEffect, RangeSetBuilder, Prec,
69
+ EditorState, EditorSelection, StateField, StateEffect, RangeSetBuilder, Prec,
67
70
  EditorView, keymap, Decoration, WidgetType, ViewPlugin,
68
71
  defaultKeymap, history, historyKeymap, indentWithTab,
72
+ cursorLineUp, cursorLineDown, selectLineUp, selectLineDown,
69
73
  markdown, markdownLanguage,
70
74
  syntaxTree, HighlightStyle, syntaxHighlighting, defaultHighlightStyle,
71
75
  tags, codeLanguages,
@@ -15,8 +15,10 @@
15
15
  */
16
16
 
17
17
  const {
18
- EditorState, StateField, EditorView, Decoration, WidgetType, keymap, Prec,
18
+ EditorState, EditorSelection, StateField, StateEffect, EditorView, Decoration,
19
+ WidgetType, ViewPlugin, keymap, Prec,
19
20
  defaultKeymap, history, historyKeymap, indentWithTab,
21
+ cursorLineUp, cursorLineDown, selectLineUp, selectLineDown,
20
22
  markdown, markdownLanguage, codeLanguages,
21
23
  syntaxTree, HighlightStyle, syntaxHighlighting, defaultHighlightStyle, tags,
22
24
  search, searchKeymap, highlightSelectionMatches,
@@ -126,18 +128,19 @@ class BulletWidget extends WidgetType {
126
128
  }
127
129
 
128
130
  class CopyButtonWidget extends WidgetType {
129
- constructor(code) {
131
+ constructor(code, visible) {
130
132
  super();
131
133
  this.code = code;
134
+ this.visible = visible;
132
135
  }
133
136
 
134
137
  eq(other) {
135
- return other.code === this.code;
138
+ return other.code === this.code && other.visible === this.visible;
136
139
  }
137
140
 
138
141
  toDOM() {
139
142
  const button = document.createElement('span');
140
- button.className = 'cm-copy';
143
+ button.className = this.visible ? 'cm-copy cm-copy-open' : 'cm-copy';
141
144
  button.textContent = 'Copy';
142
145
  button.title = 'Copy this block';
143
146
  button.addEventListener('mousedown', (e) => {
@@ -291,6 +294,32 @@ class RuleWidget extends WidgetType {
291
294
 
292
295
  const HIDE = Decoration.replace({});
293
296
 
297
+ /**
298
+ * A syntax marker that is hidden by width rather than removed.
299
+ *
300
+ * Replacing "## " takes it out of the rendered line entirely, so the caret
301
+ * jumps over it and the heading pops between two widths as you arrive. Kept as
302
+ * a mark at font-size 0 the characters stay in the line box and can simply
303
+ * slide back in. Only their width animates -- the heading text beside them
304
+ * already sets the line height -- so CodeMirror's vertical measurements, which
305
+ * drive cursor placement and scrolling, are never in motion.
306
+ *
307
+ * The class never varies. A decoration with a different class is a different
308
+ * decoration, and CodeMirror rebuilds the span rather than restyling it --
309
+ * a brand new element has no previous width to animate from, so the marker
310
+ * snapped back into place. The reveal is `markerReveal` below, which sets a
311
+ * class on the existing element and leaves the decoration alone.
312
+ */
313
+ const MARKER = Decoration.mark({ class: 'cm-md-marker' });
314
+
315
+ /** Elements whose markers are shown while the caret is inside them. */
316
+ const REVEAL = new Set([
317
+ 'Emphasis', 'StrongEmphasis', 'Strikethrough', 'InlineCode', 'FencedCode',
318
+ 'Blockquote', 'ListItem', 'Link',
319
+ 'ATXHeading1', 'ATXHeading2', 'ATXHeading3',
320
+ 'ATXHeading4', 'ATXHeading5', 'ATXHeading6',
321
+ ]);
322
+
294
323
  /** Line classes for blocks that keep their markdown but are styled as blocks. */
295
324
  const LINE_CLASS = {
296
325
  Table: 'cm-md-table',
@@ -310,6 +339,47 @@ const MARKS = new Set([
310
339
  'QuoteMark', 'StrikethroughMark',
311
340
  ]);
312
341
 
342
+ /**
343
+ * Which fenced block the pointer is over, as its start position, or -1.
344
+ *
345
+ * Each line is its own element with nothing wrapping the block, so there is no
346
+ * element to hang a CSS :hover on -- hovering has to be worked out from the
347
+ * pointer's document position instead.
348
+ */
349
+ const setHoveredFence = StateEffect.define();
350
+
351
+ const hoveredFence = StateField.define({
352
+ create: () => -1,
353
+ update(value, tr) {
354
+ for (const effect of tr.effects) if (effect.is(setHoveredFence)) return effect.value;
355
+ return tr.docChanged ? -1 : value;
356
+ },
357
+ });
358
+
359
+ /** Reports the fenced block under the pointer, dispatching only on a change. */
360
+ const fenceHover = EditorView.domEventHandlers({
361
+ mousemove(event, view) {
362
+ const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
363
+ let fence = -1;
364
+ if (pos !== null) {
365
+ for (let node = syntaxTree(view.state).resolveInner(pos, 1); node; node = node.parent) {
366
+ if (node.name === 'FencedCode') {
367
+ fence = node.from;
368
+ break;
369
+ }
370
+ }
371
+ }
372
+ if (fence !== view.state.field(hoveredFence)) {
373
+ view.dispatch({ effects: setHoveredFence.of(fence) });
374
+ }
375
+ },
376
+ mouseleave(_event, view) {
377
+ if (view.state.field(hoveredFence) !== -1) {
378
+ view.dispatch({ effects: setHoveredFence.of(-1) });
379
+ }
380
+ },
381
+ });
382
+
313
383
  function buildDecorations(state) {
314
384
  const sel = state.selection.main;
315
385
  const ranges = [];
@@ -389,7 +459,10 @@ function buildDecorations(state) {
389
459
  ranges.push({
390
460
  from: openLine.to,
391
461
  to: openLine.to,
392
- value: Decoration.widget({ widget: new CopyButtonWidget(body), side: 1 }),
462
+ value: Decoration.widget({
463
+ widget: new CopyButtonWidget(body, state.field(hoveredFence, false) === node.from),
464
+ side: 1,
465
+ }),
393
466
  });
394
467
  }
395
468
  }
@@ -444,13 +517,10 @@ function buildDecorations(state) {
444
517
  return;
445
518
  }
446
519
 
447
- // The ``` runs are punctuation, not content. Hide them unless the caret
448
- // is in this block; the language name stays as the block's label.
520
+ // The ``` runs are punctuation, not content. They stay hidden until the
521
+ // caret is on their line; the language name is the block's label.
449
522
  if (node.name === 'CodeMark' && node.to - node.from >= 3) {
450
- const fence = node.node.parent || node;
451
- if (!cursorInside(fence.from, fence.to)) {
452
- ranges.push({ from: node.from, to: node.to, value: HIDE });
453
- }
523
+ ranges.push({ from: node.from, to: node.to, value: MARKER });
454
524
  return;
455
525
  }
456
526
 
@@ -493,7 +563,7 @@ function buildDecorations(state) {
493
563
  ranges.push({
494
564
  from: node.from,
495
565
  to: node.to,
496
- value: isTask ? HIDE : Decoration.replace({ widget: new BulletWidget() }),
566
+ value: isTask ? MARKER : Decoration.replace({ widget: new BulletWidget() }),
497
567
  });
498
568
  return;
499
569
  }
@@ -502,24 +572,18 @@ function buildDecorations(state) {
502
572
  // Inline backticks only: hiding a fence would strand its language
503
573
  // label and the block's boundaries.
504
574
  if (node.to - node.from > 2) return;
505
- const parent = node.node.parent || node;
506
- if (!cursorInside(parent.from, parent.to)) {
507
- ranges.push({ from: node.from, to: node.to, value: HIDE });
508
- }
575
+ ranges.push({ from: node.from, to: node.to, value: MARKER });
509
576
  return;
510
577
  }
511
578
 
512
579
  if (MARKS.has(node.name)) {
513
- const parent = node.node.parent || node;
514
- if (cursorInside(parent.from, parent.to)) return;
515
-
516
580
  // Take the space that follows a heading or quote marker with it.
517
581
  // Hiding "#" alone leaves the title indented by one space.
518
582
  let to = node.to;
519
583
  if (node.name === 'HeaderMark' || node.name === 'QuoteMark') {
520
584
  while (state.doc.sliceString(to, to + 1) === ' ') to += 1;
521
585
  }
522
- ranges.push({ from: node.from, to, value: HIDE });
586
+ ranges.push({ from: node.from, to, value: MARKER });
523
587
  }
524
588
  },
525
589
  });
@@ -529,6 +593,63 @@ function buildDecorations(state) {
529
593
  return Decoration.set(ranges.map((r) => r.value.range(r.from, r.to)), true);
530
594
  }
531
595
 
596
+ /**
597
+ * Shows the markers of whichever element the caret is in.
598
+ *
599
+ * This is a plugin rather than a decoration because the reveal has to leave
600
+ * the DOM node alone: change the decoration and CodeMirror builds a new span,
601
+ * which has no width to animate from and snaps. Setting a class on the element
602
+ * already there is what lets it slide.
603
+ *
604
+ * It also asks for a re-measure when the slide finishes. The caret is
605
+ * positioned once, at the start of the transition, so without this it sits a
606
+ * few pixels off the text until the next keystroke -- which reads as a click
607
+ * that did not take.
608
+ */
609
+ const markerReveal = ViewPlugin.fromClass(class {
610
+ constructor(view) {
611
+ this.view = view;
612
+ this.onEnd = (event) => {
613
+ if (event.target.classList.contains('cm-md-marker')) view.requestMeasure();
614
+ };
615
+ view.contentDOM.addEventListener('transitionend', this.onEnd);
616
+ this.sync();
617
+ }
618
+
619
+ update(update) {
620
+ if (update.docChanged || update.selectionSet || update.viewportChanged) this.sync();
621
+ }
622
+
623
+ destroy() {
624
+ this.view.contentDOM.removeEventListener('transitionend', this.onEnd);
625
+ }
626
+
627
+ sync() {
628
+ const { view } = this;
629
+ const sel = view.state.selection.main;
630
+ let from = -1;
631
+ let to = -1;
632
+ if (!snapshotMode) {
633
+ for (let node = syntaxTree(view.state).resolveInner(sel.from, 1); node; node = node.parent) {
634
+ if (REVEAL.has(node.name) && sel.to <= node.to) {
635
+ from = node.from;
636
+ to = node.to;
637
+ break;
638
+ }
639
+ }
640
+ }
641
+ for (const el of view.contentDOM.querySelectorAll('.cm-md-marker')) {
642
+ let pos;
643
+ try {
644
+ pos = view.posAtDOM(el);
645
+ } catch (_) {
646
+ continue; // mid-update, and the next sync will catch it
647
+ }
648
+ el.classList.toggle('cm-md-marker-open', pos >= from && pos < to);
649
+ }
650
+ }
651
+ });
652
+
532
653
  /**
533
654
  * Decorations live in a state field rather than a view plugin. Display maths
534
655
  * replaces a whole line, and CodeMirror only accepts block decorations from a
@@ -537,7 +658,10 @@ function buildDecorations(state) {
537
658
  const livePreview = StateField.define({
538
659
  create: (state) => buildDecorations(state),
539
660
  update(deco, tr) {
540
- if (tr.docChanged || tr.selection || snapshotDirty) return buildDecorations(tr.state);
661
+ const hovered = tr.effects.some((effect) => effect.is(setHoveredFence));
662
+ if (tr.docChanged || tr.selection || hovered || snapshotDirty) {
663
+ return buildDecorations(tr.state);
664
+ }
541
665
  return deco.map(tr.changes);
542
666
  },
543
667
  provide: (field) => EditorView.decorations.from(field),
@@ -586,6 +710,80 @@ function wrapCommand(key) {
586
710
  };
587
711
  }
588
712
 
713
+ /**
714
+ * ArrowUp and ArrowDown that cannot skip a line.
715
+ *
716
+ * CodeMirror moves the caret by screen geometry, and its measurements assume
717
+ * one text height for the whole document -- a single number, measured once
718
+ * from one short line. This note is not like that: code is smaller than the
719
+ * prose, headings are bigger. When a probe lands in a line's padding rather
720
+ * than on its glyphs, `posAtCoords` does not clamp into that line, it moves to
721
+ * the top of the block and tries again, so one press could clear a whole code
722
+ * block, the heading above it and the table above that.
723
+ *
724
+ * Only vertical motion hits this: it is the one caller that passes a scan
725
+ * direction. The public posAtCoords, used below to find the column, does not.
726
+ *
727
+ * So the built-in command still decides *whether* to move -- it knows about
728
+ * wrapped lines, which this must not break -- and this only pulls the caret
729
+ * back when it has flown past a line it could have landed on.
730
+ *
731
+ * Extending a selection goes through the same geometry, so it gets the same
732
+ * treatment: only the head moves, the anchor is left where the selection
733
+ * started.
734
+ */
735
+ function verticalStep(forward, base, extend) {
736
+ return (view) => {
737
+ const { doc } = view.state;
738
+ const start = view.state.selection.main;
739
+ const startLine = doc.lineAt(start.head);
740
+ if (!base(view)) return false;
741
+
742
+ const landed = view.state.selection.main;
743
+ const landedNumber = doc.lineAt(landed.head).number;
744
+ const moved = forward ? landedNumber - startLine.number : startLine.number - landedNumber;
745
+ // 0 is a step within a wrapped line, 1 is the next line: both are right.
746
+ if (moved <= 1) return true;
747
+
748
+ const next = doc.line(forward ? startLine.number + 1 : startLine.number - 1);
749
+
750
+ // A widget standing in for whole lines has nowhere to put a caret, so
751
+ // flying over a table is the correct answer, not a bug to undo.
752
+ const block = view.lineBlockAt(next.from);
753
+ if (block.from !== next.from || block.to !== next.to) return true;
754
+
755
+ // Coming up, the caret belongs on the line's last wrapped row.
756
+ const edge = forward ? next.from : next.to;
757
+ const goal = landed.goalColumn ?? start.goalColumn;
758
+ let pos = null;
759
+ if (goal != null) {
760
+ const coords = view.coordsAtPos(edge);
761
+ if (coords) {
762
+ const left = view.contentDOM.getBoundingClientRect().left;
763
+ pos = view.posAtCoords({ x: left + goal, y: (coords.top + coords.bottom) / 2 }, false);
764
+ }
765
+ }
766
+ if (pos == null || doc.lineAt(pos).number !== next.number) {
767
+ pos = Math.min(next.to, next.from + (start.head - startLine.from));
768
+ }
769
+
770
+ view.dispatch({
771
+ selection: extend
772
+ ? EditorSelection.range(start.anchor, pos, goal ?? undefined)
773
+ : EditorSelection.cursor(pos, undefined, undefined, goal ?? undefined),
774
+ scrollIntoView: true,
775
+ });
776
+ return true;
777
+ };
778
+ }
779
+
780
+ const verticalKeymap = [
781
+ { key: 'ArrowUp', run: verticalStep(false, cursorLineUp), preventDefault: true },
782
+ { key: 'ArrowDown', run: verticalStep(true, cursorLineDown), preventDefault: true },
783
+ { key: 'Shift-ArrowUp', run: verticalStep(false, selectLineUp, true), preventDefault: true },
784
+ { key: 'Shift-ArrowDown', run: verticalStep(true, selectLineDown, true), preventDefault: true },
785
+ ];
786
+
589
787
  const shortcuts = [
590
788
  { key: 'Mod-b', run: wrapCommand('b') },
591
789
  { key: 'Mod-i', run: wrapCommand('i') },
@@ -605,6 +803,7 @@ function createLiveEditor({ parent, doc, onChange }) {
605
803
  history(),
606
804
  // Ahead of the defaults, so Mod-i and friends are not swallowed.
607
805
  Prec.high(keymap.of(shortcuts)),
806
+ Prec.high(keymap.of(verticalKeymap)),
608
807
  // Search ahead of the defaults: Cmd+F must open the panel rather than
609
808
  // fall through to anything else bound to it.
610
809
  Prec.high(keymap.of(searchKeymap)),
@@ -614,7 +813,10 @@ function createLiveEditor({ parent, doc, onChange }) {
614
813
  markdown({ base: markdownLanguage, codeLanguages }),
615
814
  Prec.high(syntaxHighlighting(markdownStyle)),
616
815
  syntaxHighlighting(defaultHighlightStyle), // colours inside code fences
816
+ hoveredFence,
617
817
  livePreview,
818
+ markerReveal,
819
+ fenceHover,
618
820
  ghostCompletion(),
619
821
  EditorView.lineWrapping,
620
822
  EditorView.updateListener.of((update) => {
@@ -160,20 +160,32 @@ body.editing #tools > * { visibility: visible; }
160
160
  opacity: 0.85;
161
161
  }
162
162
 
163
+ /* Smaller text, but the same line box as prose. CodeMirror moves the caret
164
+ vertically by one default line height, so a line shorter than that gets
165
+ stepped over: arrowing up out of a code block landed above it, and clicking
166
+ a line put the caret on the one below. Dividing the prose line height by the
167
+ font size keeps the block looking the same and the geometry uniform. */
163
168
  .cm-editor .cm-md-table {
164
169
  font-family: ui-monospace, Menlo, monospace;
165
170
  font-size: 0.9em;
171
+ line-height: calc(1.55 / 0.9);
166
172
  background: rgba(0, 0, 0, 0.04);
167
173
  }
168
174
 
169
175
  .cm-editor .cm-md-code {
170
176
  font-family: ui-monospace, Menlo, monospace;
171
177
  font-size: 0.86em;
178
+ line-height: calc(1.55 / 0.86);
172
179
  background: rgba(0, 0, 0, 0.07);
173
- /* Negative margin pulls the tint just past the text, then padding puts the
174
- code back where it was -- so the block reads as a panel with breathing
175
- room, without the tint running into the window edge. */
176
- margin: 0 2px;
180
+ /* The block reads as a panel with breathing room, without the tint running
181
+ into the window edge. Transparent borders rather than a margin: a margin
182
+ takes those 2px out of the line's box, and CodeMirror could then not hit
183
+ the line at that x -- clicking there did nothing, and arrowing up out of
184
+ a code block sailed over the whole block looking for something it could
185
+ land on. */
186
+ border-left: 2px solid transparent;
187
+ border-right: 2px solid transparent;
188
+ background-clip: padding-box;
177
189
  padding-left: 8px;
178
190
  padding-right: 8px;
179
191
  }
@@ -196,6 +208,40 @@ body.editing #tools > * { visibility: visible; }
196
208
  padding-bottom: 5px;
197
209
  }
198
210
 
211
+ /* Syntax markers -- "## ", "**", the backticks -- are hidden by width, not
212
+ removed, so the caret still lands on them and they can slide back in when
213
+ it reaches their element.
214
+
215
+ font-size: 0 is the obvious way to do that and it breaks arrow keys. A span
216
+ with no font size has a rect with no *height*, so a line beginning with a
217
+ marker cannot be hit at any y, and moving up or down steps straight over it
218
+ -- the caret would skip every heading. Clipping a full-size box keeps the
219
+ height and takes the width, which is what the caret is measured against.
220
+
221
+ max-width rather than width because there is nothing to animate towards:
222
+ the open state has to be wide enough for the longest marker and let the
223
+ content stop it short. Only the width moves; the text beside a marker is
224
+ already at full size and owns the line height, so nothing CodeMirror
225
+ measures vertically changes mid-transition. Heading sizes deliberately snap
226
+ for the same reason.
227
+
228
+ The open class is put on by hand in live-editor.js, not by a decoration:
229
+ CodeMirror rebuilds a span whose decoration changed, and a new element has
230
+ no width to animate from. */
231
+ .cm-editor .cm-md-marker {
232
+ display: inline-block;
233
+ overflow: hidden;
234
+ white-space: pre;
235
+ vertical-align: bottom;
236
+ max-width: 0;
237
+ opacity: 0;
238
+ transition: max-width 0.12s ease, opacity 0.12s ease;
239
+ }
240
+ .cm-editor .cm-md-marker-open {
241
+ max-width: 4em;
242
+ opacity: 0.35;
243
+ }
244
+
199
245
  /* Sits at the end of a block's opening fence line, beside the language name. */
200
246
  .cm-editor .cm-copy {
201
247
  float: right;
@@ -210,14 +256,17 @@ body.editing #tools > * { visibility: visible; }
210
256
  cursor: default;
211
257
  transition: opacity 0.12s ease;
212
258
 
213
- /* Always visible, just quiet. CodeMirror renders each line as its own
214
- element with nothing wrapping the block, so there is no "hovering the
215
- code block" to hang a reveal on -- keying it to the fence line alone made
216
- the button findable only by someone who already knew it was there. */
217
- opacity: 0.4;
218
- }
219
- .cm-editor .cm-md-code-first:hover .cm-copy { opacity: 0.75; }
220
- .cm-editor .cm-copy:hover { opacity: 1; background: rgba(0, 0, 0, 0.16); }
259
+ /* Hidden until the pointer is anywhere in the block. CodeMirror renders
260
+ each line as its own element with nothing wrapping the block, so there is
261
+ no element to hang a :hover on -- live-editor.js works the hovered block
262
+ out from the pointer's document position and adds .cm-copy-open. Keying
263
+ it to the fence line alone left the button findable only by someone who
264
+ already knew it was there. */
265
+ opacity: 0;
266
+ pointer-events: none;
267
+ }
268
+ .cm-editor .cm-copy-open { opacity: 0.55; pointer-events: auto; }
269
+ .cm-editor .cm-copy-open:hover { opacity: 1; background: rgba(0, 0, 0, 0.16); }
221
270
  .cm-editor .cm-copy.done { opacity: 1; background: rgba(47, 107, 74, 0.22); }
222
271
 
223
272
  .cm-editor .cm-table {