latex-stickies 1.4.1 → 1.4.3

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
@@ -169,6 +169,7 @@ node scripts/render-check.js # the first-run note renders correctly
169
169
  node scripts/ghost-check.js # autocomplete suggests, and Tab accepts
170
170
  node scripts/snapshot-check.js # a long note is captured whole
171
171
  node scripts/conflict-check.js # the changed-on-disk banner behaves
172
+ node scripts/denied-check.js # a notes folder it may not read is reported
172
173
  node scripts/verify-install.js # the install path, end to end (macOS)
173
174
  ```
174
175
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latex-stickies",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Sticky notes for your desktop that render LaTeX and Markdown.",
5
5
  "main": "src/main.js",
6
6
  "scripts": {
@@ -25,6 +25,9 @@
25
25
  ],
26
26
  "mac": {
27
27
  "category": "public.app-category.productivity",
28
+ "extendInfo": {
29
+ "NSDocumentsFolderUsageDescription": "LaTeX Stickies keeps your notes as Markdown files in Documents, so you can read and edit them with any other app."
30
+ },
28
31
  "target": [
29
32
  "dmg",
30
33
  "zip"
@@ -94,6 +94,10 @@ function isCurrent(version) {
94
94
  }
95
95
  }
96
96
 
97
+ /** Shown in the macOS prompt asking to let the app read the notes folder. */
98
+ const DOCUMENTS_REASON = 'LaTeX Stickies keeps your notes as Markdown files in '
99
+ + 'Documents, so you can read and edit them with any other app.';
100
+
97
101
  function setPlist(plist, key, value) {
98
102
  try {
99
103
  run('plutil', ['-replace', key, '-string', value, plist]);
@@ -126,6 +130,10 @@ function build(app, version) {
126
130
  setPlist(plist, 'CFBundleDisplayName', NAME);
127
131
  setPlist(plist, 'CFBundleIdentifier', APP_ID);
128
132
  setPlist(plist, 'CFBundleExecutable', NAME);
133
+ // Without this key macOS denies the notes folder without ever asking. The
134
+ // app then starts, reads nothing and shows no window, which looks exactly
135
+ // like a launch that failed.
136
+ setPlist(plist, 'NSDocumentsFolderUsageDescription', DOCUMENTS_REASON);
129
137
 
130
138
  const from = path.join(TARGET, 'Contents', 'MacOS', 'Electron');
131
139
  const to = path.join(TARGET, 'Contents', 'MacOS', NAME);
@@ -10,14 +10,19 @@
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
- import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
23
+ import {
24
+ markdown, markdownLanguage, insertNewlineContinueMarkup,
25
+ } from '@codemirror/lang-markdown';
21
26
  import {
22
27
  syntaxTree, HighlightStyle, syntaxHighlighting, defaultHighlightStyle,
23
28
  LanguageDescription,
@@ -63,10 +68,11 @@ const codeLanguages = [
63
68
  ];
64
69
 
65
70
  window.CM = {
66
- EditorState, StateField, StateEffect, RangeSetBuilder, Prec,
71
+ EditorState, EditorSelection, StateField, StateEffect, RangeSetBuilder, Prec,
67
72
  EditorView, keymap, Decoration, WidgetType, ViewPlugin,
68
73
  defaultKeymap, history, historyKeymap, indentWithTab,
69
- markdown, markdownLanguage,
74
+ cursorLineUp, cursorLineDown, selectLineUp, selectLineDown,
75
+ markdown, markdownLanguage, insertNewlineContinueMarkup,
70
76
  syntaxTree, HighlightStyle, syntaxHighlighting, defaultHighlightStyle,
71
77
  tags, codeLanguages,
72
78
  search, searchKeymap, highlightSelectionMatches, openSearchPanel,
package/src/main.js CHANGED
@@ -527,6 +527,37 @@ function watchNotesFolder() {
527
527
  });
528
528
  }
529
529
 
530
+ /**
531
+ * macOS can refuse the notes folder outright, and quietly.
532
+ *
533
+ * Documents is protected, and permission is tied to the app's signature -- so
534
+ * a rebuilt copy is a different app to the system and starts again with no
535
+ * access. Reading the folder then fails, nothing is restored and no window
536
+ * opens, which is indistinguishable from a launch that crashed. Say what
537
+ * happened instead, and offer the settings pane that fixes it.
538
+ */
539
+ function reportNoAccess(err) {
540
+ // Printed as well as shown: the dialog is the answer for the person at the
541
+ // screen, this line is what a harness or a bug report can see.
542
+ console.error(`notes folder refused: ${err.code} ${store.DIR}`);
543
+ const settings = 'x-apple.systempreferences:com.apple.preference.security'
544
+ + '?Privacy_DocumentsFolder';
545
+ const choice = dialog.showMessageBoxSync({
546
+ type: 'error',
547
+ message: 'LaTeX Stickies cannot read your notes',
548
+ detail: `macOS is not letting it open ${store.DIR}.\n\n`
549
+ + 'Allow it under Privacy & Security, in Files and Folders, then open '
550
+ + `the app again.\n\n(${err.code}: ${err.message})`,
551
+ buttons: ['Open Privacy Settings', 'Quit'],
552
+ defaultId: 0,
553
+ });
554
+ if (choice === 0 && process.platform === 'darwin') shell.openExternal(settings);
555
+ app.quit();
556
+ }
557
+
558
+ /** True when the filesystem said no, rather than the folder being absent. */
559
+ const denied = (err) => err && (err.code === 'EPERM' || err.code === 'EACCES');
560
+
530
561
  app.whenReady().then(async () => {
531
562
  // Run from npm there is no .app bundle of our own to carry the icon, so
532
563
  // macOS would show the generic Electron atom in the Dock. Set it explicitly.
@@ -546,9 +577,15 @@ app.whenReady().then(async () => {
546
577
  }
547
578
  }
548
579
 
549
- await buildMenu();
550
- restoreNotes();
551
- watchNotesFolder();
580
+ try {
581
+ await buildMenu();
582
+ restoreNotes();
583
+ watchNotesFolder();
584
+ } catch (err) {
585
+ if (!denied(err)) throw err;
586
+ reportNoAccess(err);
587
+ return;
588
+ }
552
589
 
553
590
  if (SMOKE_CLOSE_MS) {
554
591
  setTimeout(() => {
@@ -15,9 +15,11 @@
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,
20
- markdown, markdownLanguage, codeLanguages,
21
+ cursorLineUp, cursorLineDown, selectLineUp, selectLineDown,
22
+ markdown, markdownLanguage, codeLanguages, insertNewlineContinueMarkup,
21
23
  syntaxTree, HighlightStyle, syntaxHighlighting, defaultHighlightStyle, tags,
22
24
  search, searchKeymap, highlightSelectionMatches,
23
25
  } = window.CM;
@@ -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,34 @@ 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
+
316
+
317
+ /** Elements whose markers are shown while the caret is inside them. */
318
+ const REVEAL = new Set([
319
+ 'Emphasis', 'StrongEmphasis', 'Strikethrough', 'InlineCode', 'FencedCode',
320
+ 'Blockquote', 'ListItem', 'Link',
321
+ 'ATXHeading1', 'ATXHeading2', 'ATXHeading3',
322
+ 'ATXHeading4', 'ATXHeading5', 'ATXHeading6',
323
+ ]);
324
+
294
325
  /** Line classes for blocks that keep their markdown but are styled as blocks. */
295
326
  const LINE_CLASS = {
296
327
  Table: 'cm-md-table',
@@ -310,6 +341,47 @@ const MARKS = new Set([
310
341
  'QuoteMark', 'StrikethroughMark',
311
342
  ]);
312
343
 
344
+ /**
345
+ * Which fenced block the pointer is over, as its start position, or -1.
346
+ *
347
+ * Each line is its own element with nothing wrapping the block, so there is no
348
+ * element to hang a CSS :hover on -- hovering has to be worked out from the
349
+ * pointer's document position instead.
350
+ */
351
+ const setHoveredFence = StateEffect.define();
352
+
353
+ const hoveredFence = StateField.define({
354
+ create: () => -1,
355
+ update(value, tr) {
356
+ for (const effect of tr.effects) if (effect.is(setHoveredFence)) return effect.value;
357
+ return tr.docChanged ? -1 : value;
358
+ },
359
+ });
360
+
361
+ /** Reports the fenced block under the pointer, dispatching only on a change. */
362
+ const fenceHover = EditorView.domEventHandlers({
363
+ mousemove(event, view) {
364
+ const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
365
+ let fence = -1;
366
+ if (pos !== null) {
367
+ for (let node = syntaxTree(view.state).resolveInner(pos, 1); node; node = node.parent) {
368
+ if (node.name === 'FencedCode') {
369
+ fence = node.from;
370
+ break;
371
+ }
372
+ }
373
+ }
374
+ if (fence !== view.state.field(hoveredFence)) {
375
+ view.dispatch({ effects: setHoveredFence.of(fence) });
376
+ }
377
+ },
378
+ mouseleave(_event, view) {
379
+ if (view.state.field(hoveredFence) !== -1) {
380
+ view.dispatch({ effects: setHoveredFence.of(-1) });
381
+ }
382
+ },
383
+ });
384
+
313
385
  function buildDecorations(state) {
314
386
  const sel = state.selection.main;
315
387
  const ranges = [];
@@ -389,7 +461,10 @@ function buildDecorations(state) {
389
461
  ranges.push({
390
462
  from: openLine.to,
391
463
  to: openLine.to,
392
- value: Decoration.widget({ widget: new CopyButtonWidget(body), side: 1 }),
464
+ value: Decoration.widget({
465
+ widget: new CopyButtonWidget(body, state.field(hoveredFence, false) === node.from),
466
+ side: 1,
467
+ }),
393
468
  });
394
469
  }
395
470
  }
@@ -444,13 +519,10 @@ function buildDecorations(state) {
444
519
  return;
445
520
  }
446
521
 
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.
522
+ // The ``` runs are punctuation, not content. They stay hidden until the
523
+ // caret is on their line; the language name is the block's label.
449
524
  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
- }
525
+ ranges.push({ from: node.from, to: node.to, value: MARKER });
454
526
  return;
455
527
  }
456
528
 
@@ -493,7 +565,7 @@ function buildDecorations(state) {
493
565
  ranges.push({
494
566
  from: node.from,
495
567
  to: node.to,
496
- value: isTask ? HIDE : Decoration.replace({ widget: new BulletWidget() }),
568
+ value: isTask ? MARKER : Decoration.replace({ widget: new BulletWidget() }),
497
569
  });
498
570
  return;
499
571
  }
@@ -502,24 +574,18 @@ function buildDecorations(state) {
502
574
  // Inline backticks only: hiding a fence would strand its language
503
575
  // label and the block's boundaries.
504
576
  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
- }
577
+ ranges.push({ from: node.from, to: node.to, value: MARKER });
509
578
  return;
510
579
  }
511
580
 
512
581
  if (MARKS.has(node.name)) {
513
- const parent = node.node.parent || node;
514
- if (cursorInside(parent.from, parent.to)) return;
515
-
516
582
  // Take the space that follows a heading or quote marker with it.
517
583
  // Hiding "#" alone leaves the title indented by one space.
518
584
  let to = node.to;
519
585
  if (node.name === 'HeaderMark' || node.name === 'QuoteMark') {
520
586
  while (state.doc.sliceString(to, to + 1) === ' ') to += 1;
521
587
  }
522
- ranges.push({ from: node.from, to, value: HIDE });
588
+ ranges.push({ from: node.from, to, value: MARKER });
523
589
  }
524
590
  },
525
591
  });
@@ -529,6 +595,77 @@ function buildDecorations(state) {
529
595
  return Decoration.set(ranges.map((r) => r.value.range(r.from, r.to)), true);
530
596
  }
531
597
 
598
+ /**
599
+ * Shows the markers of whichever element the caret is in.
600
+ *
601
+ * This is a plugin rather than a decoration because the reveal has to leave
602
+ * the DOM node alone: change the decoration and CodeMirror builds a new span,
603
+ * which has no width to animate from and snaps. Setting a class on the element
604
+ * already there is what lets it slide.
605
+ *
606
+ * It also asks for a re-measure when the slide finishes. The caret is
607
+ * positioned once, at the start of the transition, so without this it sits a
608
+ * few pixels off the text until the next keystroke -- which reads as a click
609
+ * that did not take.
610
+ */
611
+ const markerReveal = ViewPlugin.fromClass(class {
612
+ constructor(view) {
613
+ this.view = view;
614
+ this.onEnd = (event) => {
615
+ if (event.target.classList.contains('cm-md-marker')) view.requestMeasure();
616
+ };
617
+ view.contentDOM.addEventListener('transitionend', this.onEnd);
618
+ this.sync();
619
+ }
620
+
621
+ update() {
622
+ // Every update, not only the ones that moved the caret. The parser runs
623
+ // behind the keystroke, so the first "#" of a heading arrives as a plain
624
+ // character and becomes a marker on a later update that changed neither
625
+ // the document nor the selection. Skipping those left the reveal one
626
+ // keystroke behind: a heading stayed hidden while it was being typed.
627
+ this.sync();
628
+ }
629
+
630
+ destroy() {
631
+ this.view.contentDOM.removeEventListener('transitionend', this.onEnd);
632
+ }
633
+
634
+ sync() {
635
+ const { view } = this;
636
+ const sel = view.state.selection.main;
637
+ let from = -1;
638
+ let to = -1;
639
+ if (!snapshotMode) {
640
+ const tree = syntaxTree(view.state);
641
+ // Both sides of the caret, and the side before it first. Typing "###"
642
+ // leaves the caret at the end of the line, where the node *starting*
643
+ // here is the document, not the heading -- so looking forward only, a
644
+ // heading stayed hidden the whole time you were typing it.
645
+ const enclosing = (side) => {
646
+ for (let node = tree.resolveInner(sel.from, side); node; node = node.parent) {
647
+ if (REVEAL.has(node.name) && sel.to <= node.to) return node;
648
+ }
649
+ return null;
650
+ };
651
+ const node = enclosing(-1) || enclosing(1);
652
+ if (node) {
653
+ from = node.from;
654
+ to = node.to;
655
+ }
656
+ }
657
+ for (const el of view.contentDOM.querySelectorAll('.cm-md-marker')) {
658
+ let pos;
659
+ try {
660
+ pos = view.posAtDOM(el);
661
+ } catch (_) {
662
+ continue; // mid-update, and the next sync will catch it
663
+ }
664
+ el.classList.toggle('cm-md-marker-open', pos >= from && pos < to);
665
+ }
666
+ }
667
+ });
668
+
532
669
  /**
533
670
  * Decorations live in a state field rather than a view plugin. Display maths
534
671
  * replaces a whole line, and CodeMirror only accepts block decorations from a
@@ -537,7 +674,10 @@ function buildDecorations(state) {
537
674
  const livePreview = StateField.define({
538
675
  create: (state) => buildDecorations(state),
539
676
  update(deco, tr) {
540
- if (tr.docChanged || tr.selection || snapshotDirty) return buildDecorations(tr.state);
677
+ const hovered = tr.effects.some((effect) => effect.is(setHoveredFence));
678
+ if (tr.docChanged || tr.selection || hovered || snapshotDirty) {
679
+ return buildDecorations(tr.state);
680
+ }
541
681
  return deco.map(tr.changes);
542
682
  },
543
683
  provide: (field) => EditorView.decorations.from(field),
@@ -545,9 +685,24 @@ const livePreview = StateField.define({
545
685
 
546
686
  /* ---------- how markdown reads ---------- */
547
687
 
688
+ /**
689
+ * Markers are punctuation, not prose, and should not compete with the text
690
+ * they mark up. CodeMirror's default paints them #404740 -- all but the ink
691
+ * colour -- because every marker carries tags.processingInstruction, a child
692
+ * of meta, and defaultHighlightStyle is loaded here for its code colours.
693
+ *
694
+ * A flat grey rather than a faded ink: fading a marker into the paper is what
695
+ * made an H1 and an H2 indistinguishable while one was being typed.
696
+ */
697
+ const MARKER_INK = '#757575';
698
+
548
699
  const markdownStyle = HighlightStyle.define([
549
700
  // textDecoration: 'none' is deliberate -- defaultHighlightStyle, loaded for
550
701
  // the colours inside code fences, underlines every heading.
702
+ // One entry covers every marker: the hashes, the stars, the backticks, the
703
+ // quote arrow, the list dash, a table's pipes and a strikethrough's tildes
704
+ // all carry this tag.
705
+ { tag: tags.processingInstruction, color: MARKER_INK },
551
706
  { tag: tags.heading, textDecoration: 'none' },
552
707
  { tag: tags.heading1, fontSize: '1.35em', fontWeight: '600', textDecoration: 'none' },
553
708
  { tag: tags.heading2, fontSize: '1.18em', fontWeight: '600', textDecoration: 'none' },
@@ -586,6 +741,91 @@ function wrapCommand(key) {
586
741
  };
587
742
  }
588
743
 
744
+ /**
745
+ * ArrowUp and ArrowDown that cannot skip a line.
746
+ *
747
+ * CodeMirror moves the caret by screen geometry, and its measurements assume
748
+ * one text height for the whole document -- a single number, measured once
749
+ * from one short line. This note is not like that: code is smaller than the
750
+ * prose, headings are bigger. When a probe lands in a line's padding rather
751
+ * than on its glyphs, `posAtCoords` does not clamp into that line, it moves to
752
+ * the top of the block and tries again, so one press could clear a whole code
753
+ * block, the heading above it and the table above that.
754
+ *
755
+ * Only vertical motion hits this: it is the one caller that passes a scan
756
+ * direction. The public posAtCoords, used below to find the column, does not.
757
+ *
758
+ * So the built-in command still decides *whether* to move -- it knows about
759
+ * wrapped lines, which this must not break -- and this only pulls the caret
760
+ * back when it has flown past a line it could have landed on.
761
+ *
762
+ * Extending a selection goes through the same geometry, so it gets the same
763
+ * treatment: only the head moves, the anchor is left where the selection
764
+ * started.
765
+ */
766
+ function verticalStep(forward, base, extend) {
767
+ return (view) => {
768
+ const { doc } = view.state;
769
+ const start = view.state.selection.main;
770
+ const startLine = doc.lineAt(start.head);
771
+ if (!base(view)) return false;
772
+
773
+ const landed = view.state.selection.main;
774
+ const landedNumber = doc.lineAt(landed.head).number;
775
+ const moved = forward ? landedNumber - startLine.number : startLine.number - landedNumber;
776
+ // 0 is a step within a wrapped line, 1 is the next line: both are right.
777
+ if (moved <= 1) return true;
778
+
779
+ const next = doc.line(forward ? startLine.number + 1 : startLine.number - 1);
780
+
781
+ // A widget standing in for whole lines has nowhere to put a caret, so
782
+ // flying over a table is the correct answer, not a bug to undo.
783
+ const block = view.lineBlockAt(next.from);
784
+ if (block.from !== next.from || block.to !== next.to) return true;
785
+
786
+ // Coming up, the caret belongs on the line's last wrapped row.
787
+ const edge = forward ? next.from : next.to;
788
+ const goal = landed.goalColumn ?? start.goalColumn;
789
+ let pos = null;
790
+ if (goal != null) {
791
+ const coords = view.coordsAtPos(edge);
792
+ if (coords) {
793
+ const left = view.contentDOM.getBoundingClientRect().left;
794
+ pos = view.posAtCoords({ x: left + goal, y: (coords.top + coords.bottom) / 2 }, false);
795
+ }
796
+ }
797
+ if (pos == null || doc.lineAt(pos).number !== next.number) {
798
+ pos = Math.min(next.to, next.from + (start.head - startLine.from));
799
+ }
800
+
801
+ view.dispatch({
802
+ selection: extend
803
+ ? EditorSelection.range(start.anchor, pos, goal ?? undefined)
804
+ : EditorSelection.cursor(pos, undefined, undefined, goal ?? undefined),
805
+ scrollIntoView: true,
806
+ });
807
+ return true;
808
+ };
809
+ }
810
+
811
+ /**
812
+ * Enter carries a list on: a new bullet, the next number, another empty task
813
+ * box, or a second quote line -- and a second Enter on an item with nothing in
814
+ * it ends the list instead of adding to it.
815
+ *
816
+ * Without this, pressing Enter after "- [ ] milk" left a bare line, and typing
817
+ * "[ ] eggs" there looks like a task and is not one: the box only means
818
+ * anything inside a list item.
819
+ */
820
+ const continueList = [{ key: 'Enter', run: insertNewlineContinueMarkup }];
821
+
822
+ const verticalKeymap = [
823
+ { key: 'ArrowUp', run: verticalStep(false, cursorLineUp), preventDefault: true },
824
+ { key: 'ArrowDown', run: verticalStep(true, cursorLineDown), preventDefault: true },
825
+ { key: 'Shift-ArrowUp', run: verticalStep(false, selectLineUp, true), preventDefault: true },
826
+ { key: 'Shift-ArrowDown', run: verticalStep(true, selectLineDown, true), preventDefault: true },
827
+ ];
828
+
589
829
  const shortcuts = [
590
830
  { key: 'Mod-b', run: wrapCommand('b') },
591
831
  { key: 'Mod-i', run: wrapCommand('i') },
@@ -605,6 +845,8 @@ function createLiveEditor({ parent, doc, onChange }) {
605
845
  history(),
606
846
  // Ahead of the defaults, so Mod-i and friends are not swallowed.
607
847
  Prec.high(keymap.of(shortcuts)),
848
+ Prec.high(keymap.of(verticalKeymap)),
849
+ Prec.high(keymap.of(continueList)),
608
850
  // Search ahead of the defaults: Cmd+F must open the panel rather than
609
851
  // fall through to anything else bound to it.
610
852
  Prec.high(keymap.of(searchKeymap)),
@@ -614,7 +856,10 @@ function createLiveEditor({ parent, doc, onChange }) {
614
856
  markdown({ base: markdownLanguage, codeLanguages }),
615
857
  Prec.high(syntaxHighlighting(markdownStyle)),
616
858
  syntaxHighlighting(defaultHighlightStyle), // colours inside code fences
859
+ hoveredFence,
617
860
  livePreview,
861
+ markerReveal,
862
+ fenceHover,
618
863
  ghostCompletion(),
619
864
  EditorView.lineWrapping,
620
865
  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,44 @@ 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
+ /* Full strength, not a hint. A revealed marker is there to be edited -- you
241
+ are counting hashes to see what level a heading is, or putting the caret
242
+ between a pair of stars -- and a washed-out one is hard to read against
243
+ the paper. It is the hiding that keeps the note clean, not the fading. */
244
+ .cm-editor .cm-md-marker-open {
245
+ max-width: 4em;
246
+ opacity: 1;
247
+ }
248
+
199
249
  /* Sits at the end of a block's opening fence line, beside the language name. */
200
250
  .cm-editor .cm-copy {
201
251
  float: right;
@@ -210,14 +260,17 @@ body.editing #tools > * { visibility: visible; }
210
260
  cursor: default;
211
261
  transition: opacity 0.12s ease;
212
262
 
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); }
263
+ /* Hidden until the pointer is anywhere in the block. CodeMirror renders
264
+ each line as its own element with nothing wrapping the block, so there is
265
+ no element to hang a :hover on -- live-editor.js works the hovered block
266
+ out from the pointer's document position and adds .cm-copy-open. Keying
267
+ it to the fence line alone left the button findable only by someone who
268
+ already knew it was there. */
269
+ opacity: 0;
270
+ pointer-events: none;
271
+ }
272
+ .cm-editor .cm-copy-open { opacity: 0.55; pointer-events: auto; }
273
+ .cm-editor .cm-copy-open:hover { opacity: 1; background: rgba(0, 0, 0, 0.16); }
221
274
  .cm-editor .cm-copy.done { opacity: 1; background: rgba(47, 107, 74, 0.22); }
222
275
 
223
276
  .cm-editor .cm-table {