latex-stickies 1.4.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latex-stickies",
3
- "version": "1.4.1",
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": {
@@ -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 {