-
Notifications
You must be signed in to change notification settings - Fork 12
/
Copy pathmain.js
758 lines (693 loc) · 26.2 KB
/
main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
//> A renderer for a custom flavor of markdown, that renders
// live, with every keystroke. I wrote the `Marked` component
// to be integrated into my productivity apps (I'm rewriting my
// notes and todo apps soon), but it also works well as a live
// editor by itself.
//> Bootstrap the required globals from Torus, since we're not bundling
for (const exportedName in Torus) {
window[exportedName] = Torus[exportedName];
}
//> Like `jdom.js`, this is a unique object that identifies
// that a reader has reached the last character/line to read. Used
// for parsing strings.
const READER_END = [];
//> These are the regular expressions (`RE`) that match things
// like headers, images, and quotes.
const RE_HEADER = /^(#{1,6})\s*(.*)/;
const RE_IMAGE = /^%\s+(\S*)/;
const RE_QUOTE = /^(>+)\s*(.*)/;
const RE_LIST_ITEM = /^(\s*)(-|\d+\.)\s+(.*)/;
//> Delimiters for text styles. If you want the more
// standard flavor of markdown, you can change these
// these delimiters to get 90% of the way there (minus
// the links).
const ITALIC_DELIMITER = '/';
const BOLD_DELIMITER = '*';
const STRIKE_DELIMITER = '~';
const CODE_DELIMITER = '`';
const LINK_DELIMITER_LEFT = '<';
const LINK_DELIMITER_RIGHT = '>';
const PRE_DELIMITER = '``';
const LITERAL_DELIMITER = '%%';
//> Some text expansions / replacements I find convenient.
const BODY_TEXT_TRANSFORMS = new Map([
// RegExp: replacement
[/--/g, '—'], // em-dash from two dashes
[/(\?!|!\?)/g, '‽'], // interrobang!
[/\$\$/g, '💵'],
[/:\)/g, '🙂'],
[/<3/g, '❤️'],
[/:wave:/g, '👋'],
]);
//> This is the default input that the user sees
// when they first open the app. It demonstrates the basic syntax.
const INPUT_PLACEHOLDER = `# Write some markdown!
## Hash signs mark /headers/.
Here's some text, with /italics/, *bold*, ~strikethrough~, and \`monospace\` styles. We can also *~/combine/~* these things for */\`more emphasis\`/*.
Let's include some links. Here's one to <https://google.com/>.
> Quotes.
>> Nested quotes, like this...
>> ... even across lines.
We can include lists ...
- First
- Second
- Third, which is indented
- Fourth
We can also number lists, and mix both styles.
1. Cal Bears
2. Purdue Boilermakers
3. every other school
- ???
4. Stanford... trees?
We can include code blocks.
\`\`
#include <stdio.h>
int main() {
printf("Two backticks denote a code block");
return 0;
}
\`\`
To include images, prefix the URL with a percent sign:
% https://www.ocf.berkeley.edu/~linuslee/pic.jpg
That's it! Happy markdowning :)
If you're curious about how this app works, you can check out the entire, annotated source code at <https://thesephist.github.io/torus/markdown-parser-demo>, where you'll find annotated JavaScript source files behind this and a few other apps.
This renderer was built with Torus, a UI framework for the web written by Linus, for his personal suite of productivity apps. You can find more information about Torus at <https://github.com/thesephist/torus/>, and you can find Linus at <https://linus.zone/now/>.
`;
//> A generator that yields characters from a string, used for parsing text.
class Reader {
constructor(str) {
this.str = str;
this.idx = 0;
}
next() {
return this.str[this.idx ++] || READER_END;
}
//> Look ahead a character, but don't increment the position.
ahead() {
return this.str[this.idx] || READER_END;
}
//> Reads the string until the first occurrence of a given character.
until(char) {
const sub = this.str.substr(this.idx);
const nextIdx = sub.indexOf(char);
const part = sub.substr(char, nextIdx);
this.idx += nextIdx + 1;
return part;
}
}
//> Like `Reader`, but for lines. It's used for things like parsing nested lists
// and block quotes.
class LineReader {
constructor(lines) {
this.lines = lines;
this.idx = 0;
}
next() {
if (this.idx < this.lines.length) {
return this.lines[this.idx ++];
} else {
this.idx = this.lines.length;
return READER_END;
}
}
//> Decrement the counter, so `next()` will return the same line once again.
backtrack() {
this.idx = this.idx - 1 < 0 ? 0 : this.idx - 1;
}
}
//> Parse "body text", which may include italics, bold text, strikethroughs,
// and inline code blocks. This also takes care of text expansions defined above.
const parseBody = (reader, tag, delimiter = '') => {
const children = [];
let buf = '';
//> Function to "commit" the text read into the buffer as a child
// of body text, so we can add other elements after it.
const commitBuf = () => {
for (const re of BODY_TEXT_TRANSFORMS.keys()) {
buf = buf.replace(re, BODY_TEXT_TRANSFORMS.get(re));
}
children.push(buf);
buf = '';
}
let char;
let last = '';
//> Loop through each character. If there are delimiters, read until
// the end of the delimited chunk of text and parse the contents inside
// as the right tag.
while (last = char, char = reader.next()) {
switch (char) {
//> Backslash is an escape character, so anything that comes
// right after it is just read into the buffer.
case '\\':
buf += reader.next();
break;
//> If we find the delimiter `parseBody` was called with, that means
// we've reached the end of the delimited sequence of text we were
// reading from `reader` and must return control flow to the calling function.
case delimiter:
if (last === ' ') {
buf += char;
} else {
commitBuf();
return {
tag: tag,
children: children,
}
}
break;
//> If we reach the end of the body text, commit everything we've got
// so far and return the whole thing.
case READER_END:
commitBuf();
return {
tag: tag,
children: children,
}
//> Each of these delimiter cases check if the next character
// is a space. If it is, it may just be that the user is trying to type, e.g.
// 3 < 10 or async / await. We don't count those characters as styling delimiters.
// That would be annoying for the user.
case ITALIC_DELIMITER:
if (reader.ahead() === ' ') {
buf += char;
} else {
commitBuf();
children.push(parseBody(reader, 'em', ITALIC_DELIMITER));
}
break;
case BOLD_DELIMITER:
if (reader.ahead() === ' ') {
buf += char;
} else {
commitBuf();
children.push(parseBody(reader, 'strong', BOLD_DELIMITER));
}
break;
case STRIKE_DELIMITER:
if (reader.ahead() === ' ') {
buf += char;
} else {
commitBuf();
children.push(parseBody(reader, 'strike', STRIKE_DELIMITER));
}
break;
case CODE_DELIMITER:
if (reader.ahead() === ' ') {
buf += char;
} else {
commitBuf();
children.push({
tag: 'code',
//> Rather than recursively parsing the text inside a code
// block, we just take it verbatim. Otherwise symbols like * and /
// in code have to be escaped, which would be really annoying.
children: [reader.until(CODE_DELIMITER)],
});
}
break;
//> If we find a link, we read until the end of the link and return
// a JDOM object that's a clickable link tag that opens in another tab.
case LINK_DELIMITER_LEFT:
if (reader.ahead() === ' ') {
buf += char;
} else {
commitBuf();
const url = reader.until(LINK_DELIMITER_RIGHT);
children.push({
tag: 'a',
attrs: {
href: url || '#',
rel: 'noopener',
target: '_blank',
},
children: [url],
});
}
break;
//> If none of the special cases matched, just add the character
// to the buffer we're reading to.
default:
buf += char;
break;
}
}
throw new Error('This should not happen while reading body text!');
}
//> Given a reader of lines, parse (potentially) nested lists recursively.
const parseList = lineReader => {
const children = [];
//> We check out the first line in the sequence to determine
// how far indented we are, and what kind of list (number, bullet)
// it is.
let line = lineReader.next();
const [_, indent, prefix] = RE_LIST_ITEM.exec(line);
const tag = prefix === '-' ? 'ul' : 'ol';
const indentLevel = indent.length;
lineReader.backtrack();
//> Loop through the next few lines from the reader.
while ((line = lineReader.next()) !== READER_END) {
const [_, _indent, prefix] = RE_LIST_ITEM.exec(line) || [];
//> If there's a valid list item prefix, we count it as a list item.
if (prefix) {
//> We compare the indentation level of this line, versus the
// first line in the list.
const thisIndentLevel = line.indexOf(prefix);
//> If it's indented less, we've stumbled upon the end of the
// list section. Backtrack and return control to the parent list
// or block.
if (thisIndentLevel < indentLevel) {
lineReader.backtrack();
return {
tag: tag,
children: children,
}
//> If it's the same indentation, treat it as the next item in the list.
// Parse the list content as body text, and add it to the list of children.
} else if (thisIndentLevel === indentLevel) {
const body = line.match(/\s*(?:\d+\.|-)\s*(.*)/)[1];
children.push(parseBody(new Reader(body), 'li'));
//> If this line is indented farther than the first line,
// that means it's the start of a further-nested list.
// Call `parseList` recursively, and add the returned list
// as a child.
} else { // thisIndentLevel > indentLevel
lineReader.backtrack();
children.push(parseList(lineReader));
}
//> If there's no valid list item prefix, it's the end of the list.
} else {
lineReader.backtrack();
return {
tag: tag,
children: children,
}
}
}
return {
tag: tag,
children: children,
}
}
//> Like `parseList`, but for nested block quotes.
const parseQuote = lineReader => {
const children = [];
//> Look ahead at the first line to determine how far nested we are.
let line = lineReader.next();
const [_, nestCount] = RE_QUOTE.exec(line);
const nestLevel = nestCount.length;
lineReader.backtrack();
//> Loop through each line in the block quote.
while ((line = lineReader.next()) !== READER_END) {
const [_, nestCount, quoteText] = RE_QUOTE.exec(line) || [];
//> If we're able to find a line matching the block quote regex,
// count it as another line in the block.
if (quoteText !== undefined) {
const thisNestLevel = nestCount.length;
//> If this line is nested less than the first line,
// it's the end of this block quote. Return control to the
// parent block quote.
if (thisNestLevel < nestLevel) {
lineReader.backtrack();
return {
tag: 'q',
children: children,
}
//> If this line is indented same as the first line,
// continue reading the quote.
} else if (thisNestLevel === nestLevel) {
children.push(parseBody(new Reader(quoteText), 'p'));
//> If this line is indented further in, it's the start
// of another nested quote block. Call itself recursively.
} else { // thisNestLevel > nestLevel
lineReader.backtrack();
children.push(parseQuote(lineReader));
}
//> If the line didn't match the block quote regex, it's
// the end of the block quote, so return what we have.
} else {
lineReader.backtrack();
return {
tag: 'q',
children: children,
}
}
}
return {
tag: 'q',
children: children,
}
}
//> Main Torus function component for the parser. This component takes
// a string input, parses it into JDOM (HTML elements), and returns it
// in a `<div>`.
const Markus = str => {
//> Make a new line reader that we'll pass to functions to read the input.
const lineReader = new LineReader(str.split('\n'));
//> Various parsing state registers.
let inCodeBlock = false;
let codeBlockResult = '';
let inLiteralBlock = false;
let literalBlockResult = '';
const result = [];
let line;
while ((line = lineReader.next()) !== READER_END) {
//> If we're in a code block, don't do more parsing
// and add the line directly to the code block
if (inCodeBlock) {
if (line === PRE_DELIMITER) {
result.push({
tag: 'pre',
children: [codeBlockResult],
});
inCodeBlock = false;
codeBlockResult = '';
} else {
if (!codeBlockResult) {
codeBlockResult = line.trimStart() + '\n';
} else {
codeBlockResult += line + '\n';
}
}
//> ... likewise for literal HTML blocks.
} else if (inLiteralBlock) {
if (line === LITERAL_DELIMITER) {
const wrapper = document.createElement('div');
wrapper.innerHTML = literalBlockResult;
result.push(wrapper);
inLiteralBlock = false;
literalBlockResult = '';
} else {
literalBlockResult += line;
}
//> If the line starts with a hash sign, it's a header! Parse it as such.
} else if (line.startsWith('#')) {
const [_, hashes, header] = RE_HEADER.exec(line);
//> The HTML tag is `'h'` followed by the number of `#` signs.
result.push(parseBody(new Reader(header), 'h' + hashes.length));
//> If the line matches the image line format, parse the URL
// out of the line and add a link that wraps the image, so it's clickable
// in the final result HTML.
} else if (RE_IMAGE.exec(line)) {
const [_, imageURL] = RE_IMAGE.exec(line);
result.push({
tag: 'a',
attrs: {
href: imageURL || '#',
rel: 'noopener',
target: '_blank',
style: {cursor: 'pointer'},
},
children: [{
tag: 'img',
attrs: {
src: imageURL,
style: {maxWidth: '100%'},
},
}],
});
//> If the line matches a block quote format, backtrack
// and send the control off to the block quote parser, including the
// line we just read.
} else if (RE_QUOTE.exec(line)) {
lineReader.backtrack();
result.push(parseQuote(lineReader));
//> Detect horizontal dividers and handle it.
} else if (line === '- -') {
result.push({tag: 'hr'});
//> Detect start of a code block
} else if (line === PRE_DELIMITER) {
inCodeBlock = true;
//> Detect start of a literal HTML block
} else if (line === LITERAL_DELIMITER) {
inLiteralBlock = true;
//> Detect list formats (numbered, bullet) and
// if they're found, send the control flow off to
// the list parsing function.
} else if (RE_LIST_ITEM.exec(line)) {
lineReader.backtrack();
result.push(parseList(lineReader));
//> If none of the above match, it's a plain old boring
// paragraph. Read the line as a paragraph body.
} else {
result.push(parseBody(new Reader(line), 'p'));
}
}
//> Return the array of children wrapped in a `<div>`, with some padding
// at the bottom so it's freely scrollable during editing.
return jdom`<div class="render" style="padding-bottom:75vh">${result}</div>`;
}
//> Editor view modes
const MODE = {
//> 0 -> two-up, preview and editor; default
BOTH: 0,
//> 1 -> editor only
EDITOR: 1,
//> 2 -> preview only
PREVIEW: 2,
}
//> The app component wraps the entire application and handles state.
class App extends StyledComponent {
init() {
//> We start with the default two-up view
this.mode = MODE.BOTH;
//> Temporary state used to store whether the save button should show
// the saving state indicator ("saved")
this.showSavedIndicator = false;
//> If we've previously saved the user input, pull that back out.
// Otherwise, use the default placeholder.
this.inputValue = window.localStorage.getItem('markusInput') || INPUT_PLACEHOLDER;
//> Bind a few methods we're using to handle input.
this.handleInput = this.handleInput.bind(this);
this.handleKeydown = this.handleKeydown.bind(this);
this.handleToggleMode = this.handleToggleMode.bind(this);
this.handleSave = this.save.bind(this, {showIndicator: true});
//> Before the user leaves the site, we want to save the user input to
// local storage so we can pull it back out later when the user visits the site again.
window.addEventListener('beforeunload',
this.save.bind(this, {showIndicator: false}));
}
//> Callback to save current editor buffer contents to `localStorage`.
// This method takes an option to give feedback to the user, which is
// set to false if saving on the onbeforeunload event.
save({showIndicator} = {}) {
if (showIndicator) {
this.showSavedIndicator = true;
setTimeout(() => {
this.showSavedIndicator = false;
this.render();
}, 1000);
this.render();
}
window.localStorage.setItem('markusInput', this.inputValue);
}
styles() {
return css`
box-sizing: border-box;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
height: 100vh;
width: 100%;
max-width: 1600px;
margin: 0 auto;
overflow: hidden;
header {
padding: 20px 18px 0 18px;
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.buttonGroup {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
button {
margin: 0 6px;
padding: 6px 10px;
font-size: 1em;
border-radius: 4px;
background: #fff;
box-shadow: 0 3px 8px -1px rgba(0, 0, 0, .3);
border: 0;
cursor: pointer;
&:hover {
opacity: .7;
}
}
}
.title {
margin: 0;
font-weight: normal;
color: #888;
.dark {
color: #000;
}
}
.renderContainer {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
height: calc(100% - 60px);
width: 100%;
padding: 16px;
box-sizing: border-box;
}
.half {
width: calc(50% - 8px);
}
.full {
width: calc(100% - 8px);
}
.half, .full {
height: 100%;
box-sizing: border-box;
}
.render, textarea {
box-sizing: border-box;
border: 0;
box-shadow: 0 3px 8px -1px rgba(0, 0, 0, .3);
padding: 12px;
border-radius: 6px;
background: #fff;
height: 100%;
-webkit-overflow-scrolling: touch;
overflow-y: auto;
}
textarea {
font-family: 'Fira Code', 'Menlo', 'Monaco', monospace;
width: 100%;
resize: none;
font-size: 14px;
outline: none;
color: #999;
line-height: 1.5em;
&:focus {
color: #000;
}
}
.result {
height: 100%;
}
.render {
p, pre, code {
line-height: 1.5em;
}
li {
margin-bottom: 6px;
}
pre {
padding: 8px;
overflow-x: auto;
}
code {
padding: 1px 5px;
margin: 0 2px;
}
pre, code {
font-family: 'Menlo', 'Monaco', monospace;
background: #eee;
border-radius: 4px;
}
q {
&::before, &::after {
content: '';
}
display: block;
border-left: 3px solid #777;
padding-left: 6px;
}
}
`;
}
//> When the input changes, set the new local state
// and queue up another render using `requestAnimationFrame`, to be
// efficient with when we render (not necessarily now, just before
// the next frame).
handleInput(evt) {
this.inputValue = evt.target.value;
requestAnimationFrame(() => this.render());
}
//> This is a way to make sure `TAB` keys can be used
// to enter four spaces (yay spaces instead of tabs!) instead
// of tab to the next input on the page. This makes the textarea
// behave like a text editor, allowing you to indent with tab.
handleKeydown(evt) {
if (evt.key === 'Tab') {
evt.preventDefault();
const idx = evt.target.selectionStart;
if (idx !== null) {
const front = this.inputValue.substr(0, idx);
const back = this.inputValue.substr(idx);
this.inputValue = front + ' ' + back;
this.render();
//> Rendering the new input value will
// make us lose focus on the textarea, so we put the
// focus back by selecting the area the user was just editing.
evt.target.setSelectionRange(idx + 4, idx + 4);
}
}
}
handleToggleMode() {
//> Increment mode counter to stay within [0, 2] range.
this.mode = ++ this.mode % 3;
this.render();
}
compose() {
let modeView = null; // unreachable
//> Provide the correct mode view to the app shell depending
// on the chosen view.
switch (this.mode) {
case MODE.EDITOR:
modeView = jdom`<div class="full result">
${Markus(this.inputValue)}
</div>`;
break;
case MODE.PREVIEW:
modeView = jdom`<div class="full markdown">
<textarea autofocus value="${this.inputValue}" oninput="${this.handleInput}"
placeholder="Start writing ..." onkeydown="${this.handleKeydown}" />
</div>`;
break;
default:
modeView = [
jdom`<div class="half result">
${Markus(this.inputValue)}
</div>`,
jdom`<div class="half markdown">
<textarea autofocus value="${this.inputValue}" oninput="${this.handleInput}"
placeholder="Start writing ..." onkeydown="${this.handleKeydown}" />
</div>`,
];
break;
}
return jdom`<main>
<header>
<h1 class="title">
<span class="dark">Markus</span>, a live markdown editor
</h1>
<div class="buttonGroup">
<button class="saveButton" onclick="${this.handleSave}">
${this.showSavedIndicator ? 'saved' : 'save'}
</button>
<button class="showRenderedToggle" onclick="${this.handleToggleMode}">
mode ++
</button>
</div>
</header>
<div class="renderContainer">${modeView}</div>
</main>`;
}
}
//> Create an instance of the app and mount it to the page DOM.
const app = new App();
document.body.appendChild(app.node);
//> Basic grey background and reset of the default margin on `<body>`
document.body.style.backgroundColor = '#f8f8f8';
document.body.style.margin = '0';