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 +1 -0
- package/package.json +4 -1
- package/scripts/brand-electron.js +8 -0
- package/src/editor/entry.js +10 -4
- package/src/main.js +40 -3
- package/src/renderer/live-editor.js +267 -22
- package/src/renderer/note.css +65 -12
- package/src/renderer/vendor/codemirror.js +18 -18
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.
|
|
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);
|
package/src/editor/entry.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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,
|
|
18
|
+
EditorState, EditorSelection, StateField, StateEffect, EditorView, Decoration,
|
|
19
|
+
WidgetType, ViewPlugin, keymap, Prec,
|
|
19
20
|
defaultKeymap, history, historyKeymap, indentWithTab,
|
|
20
|
-
|
|
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({
|
|
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.
|
|
448
|
-
// is
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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) => {
|
package/src/renderer/note.css
CHANGED
|
@@ -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
|
-
/*
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
/*
|
|
214
|
-
element with nothing wrapping the block, so there is
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 {
|