@charcuterie/ui 3.4.1 → 3.5.0
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/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +346 -3
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +99 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +370 -19
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
- package/package.json +1 -1
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +28 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +42 -0
- package/src/markdownEditorCodeMirror/livePreview.ts +465 -3
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +349 -8
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +669 -27
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { markdownLanguage } from "@codemirror/lang-markdown"
|
|
2
2
|
import { describe, expect, test } from "vitest"
|
|
3
3
|
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import type { LivePreviewSelection } from "./livePreviewRanges.ts"
|
|
5
|
+
import {
|
|
6
|
+
toLivePreviewRanges,
|
|
7
|
+
toLivePreviewTableRanges,
|
|
7
8
|
} from "./livePreviewRanges.ts"
|
|
8
|
-
import { toLivePreviewRanges } from "./livePreviewRanges.ts"
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The real GFM parser, not a stub.
|
|
@@ -27,8 +27,27 @@ const toRanges = (
|
|
|
27
27
|
tree: markdownLanguage.parser.parse(text),
|
|
28
28
|
})
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
/** Anything with document offsets — a range, or a table cell. */
|
|
31
|
+
/**
|
|
32
|
+
* The block pass. Separate from `toRanges` because CodeMirror
|
|
33
|
+
* requires the split — see `toLivePreviewTableRanges`.
|
|
34
|
+
*/
|
|
35
|
+
const toTableRanges = (
|
|
36
|
+
text: string,
|
|
37
|
+
selections: readonly LivePreviewSelection[] = [],
|
|
38
|
+
isRawMode = false,
|
|
39
|
+
) =>
|
|
40
|
+
toLivePreviewTableRanges({
|
|
41
|
+
isRawMode,
|
|
42
|
+
selections,
|
|
43
|
+
text,
|
|
44
|
+
tree: markdownLanguage.parser.parse(text),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const toTextOf = (
|
|
48
|
+
text: string,
|
|
49
|
+
range: { from: number; to: number },
|
|
50
|
+
) => text.slice(range.from, range.to)
|
|
32
51
|
|
|
33
52
|
/**
|
|
34
53
|
* Narrows away the `undefined` a `find` returns, failing the test
|
|
@@ -79,6 +98,38 @@ describe("toLivePreviewRanges", () => {
|
|
|
79
98
|
})
|
|
80
99
|
})
|
|
81
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Not table-specific — but `\\|` is the only way to put a pipe in
|
|
103
|
+
* a cell, so this is where its absence showed.
|
|
104
|
+
*/
|
|
105
|
+
describe("escapes", () => {
|
|
106
|
+
test("conceals the backslash and keeps what it escaped", () => {
|
|
107
|
+
const text = "a \\* b"
|
|
108
|
+
|
|
109
|
+
const markers = toRanges(text).filter(
|
|
110
|
+
(range) => range.type === "marker",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
expect(
|
|
114
|
+
markers.map((range) => toTextOf(text, range)),
|
|
115
|
+
).toEqual(["\\"])
|
|
116
|
+
|
|
117
|
+
expect(markers[0]).toMatchObject({
|
|
118
|
+
isConcealed: true,
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test("brings it back for a caret inside it", () => {
|
|
123
|
+
const text = "a \\* b"
|
|
124
|
+
|
|
125
|
+
expect(
|
|
126
|
+
toRanges(text, atCaret(3)).find(
|
|
127
|
+
(range) => range.type === "marker",
|
|
128
|
+
),
|
|
129
|
+
).toMatchObject({ isConcealed: false })
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
82
133
|
describe("headings", () => {
|
|
83
134
|
test("scales the line and conceals the hashes with their space", () => {
|
|
84
135
|
const text = "### Heading three"
|
|
@@ -303,6 +354,291 @@ describe("toLivePreviewRanges", () => {
|
|
|
303
354
|
})
|
|
304
355
|
})
|
|
305
356
|
|
|
357
|
+
/**
|
|
358
|
+
* The one construct whose meaning is geometry.
|
|
359
|
+
*
|
|
360
|
+
* Everything else here is a decoration hung on text CodeMirror is
|
|
361
|
+
* already drawing. A column is not: column two's text is on four
|
|
362
|
+
* different lines, so the markdown stops being drawn and a widget
|
|
363
|
+
* draws instead — which is why these tests assert *content*
|
|
364
|
+
* (rows, cells, segments) where the rest of the file asserts
|
|
365
|
+
* offsets.
|
|
366
|
+
*/
|
|
367
|
+
describe("tables", () => {
|
|
368
|
+
const TABLE = [
|
|
369
|
+
"| Port | **Goes to** | Notes |",
|
|
370
|
+
"| :--- | ---: | :---: |",
|
|
371
|
+
"| 1 | uplink | see [docs](https://e.com) |",
|
|
372
|
+
"| 2 | | `shelf` |",
|
|
373
|
+
].join("\n")
|
|
374
|
+
|
|
375
|
+
const toTable = (
|
|
376
|
+
text: string,
|
|
377
|
+
selections: readonly LivePreviewSelection[] = [],
|
|
378
|
+
) => toFound(toTableRanges(text, selections)[0])
|
|
379
|
+
|
|
380
|
+
test("replaces the whole block, exactly", () => {
|
|
381
|
+
const text = `Before\n\n${TABLE}\n\nAfter\n`
|
|
382
|
+
|
|
383
|
+
const table = toTable(text)
|
|
384
|
+
|
|
385
|
+
// Byte-for-byte the table and nothing either side of it: a
|
|
386
|
+
// block replacement has to land on whole lines, and one
|
|
387
|
+
// character out is a thrown decoration rather than a
|
|
388
|
+
// misdrawn one.
|
|
389
|
+
expect(toTextOf(text, table)).toBe(TABLE)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test("renders no line decoration for a table it replaces", () => {
|
|
393
|
+
expect(
|
|
394
|
+
toRanges(TABLE).filter(
|
|
395
|
+
(range) =>
|
|
396
|
+
range.type === "line" &&
|
|
397
|
+
range.lineKind === "table",
|
|
398
|
+
),
|
|
399
|
+
).toHaveLength(0)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test("reads the header row as a header and the rest as rows", () => {
|
|
403
|
+
expect(
|
|
404
|
+
toTable(TABLE).rows.map((row) => row.isHeader),
|
|
405
|
+
).toEqual([true, false, false])
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The delimiter row is the column spec, not content — it is the
|
|
410
|
+
* one row a rendered table does not show, and it is where the
|
|
411
|
+
* alignment comes from instead.
|
|
412
|
+
*/
|
|
413
|
+
test("takes alignment from the delimiter row", () => {
|
|
414
|
+
expect(
|
|
415
|
+
toTable(TABLE).rows[0]?.cells.map(
|
|
416
|
+
(cell) => cell.alignment,
|
|
417
|
+
),
|
|
418
|
+
).toEqual(["start", "end", "center"])
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
test("renders cell markup rather than the markers", () => {
|
|
422
|
+
expect(
|
|
423
|
+
toTable(TABLE).rows[0]?.cells[1]?.segments,
|
|
424
|
+
).toEqual([
|
|
425
|
+
{
|
|
426
|
+
markKinds: ["strong"],
|
|
427
|
+
text: "Goes to",
|
|
428
|
+
type: "text",
|
|
429
|
+
},
|
|
430
|
+
])
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test("keeps a link in a cell clickable and drops its syntax", () => {
|
|
434
|
+
expect(
|
|
435
|
+
toTable(TABLE).rows[1]?.cells[2]?.segments,
|
|
436
|
+
).toEqual([
|
|
437
|
+
{ markKinds: [], text: "see ", type: "text" },
|
|
438
|
+
{
|
|
439
|
+
markKinds: ["linkText"],
|
|
440
|
+
text: "docs",
|
|
441
|
+
type: "text",
|
|
442
|
+
url: "https://e.com",
|
|
443
|
+
},
|
|
444
|
+
])
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The parser emits no `TableCell` for `| |`, so a cell-node
|
|
449
|
+
* walk would render the row one column short and shift every
|
|
450
|
+
* cell after it left. The `|` positions are the truth.
|
|
451
|
+
*/
|
|
452
|
+
test("keeps an empty cell's column", () => {
|
|
453
|
+
const cells = toFound(toTable(TABLE).rows[2]).cells
|
|
454
|
+
|
|
455
|
+
expect(cells).toHaveLength(3)
|
|
456
|
+
|
|
457
|
+
expect(cells[1]?.segments).toEqual([])
|
|
458
|
+
|
|
459
|
+
expect(cells[2]?.segments).toEqual([
|
|
460
|
+
{
|
|
461
|
+
markKinds: ["code"],
|
|
462
|
+
text: "shelf",
|
|
463
|
+
type: "text",
|
|
464
|
+
},
|
|
465
|
+
])
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test("puts a cell's offsets on the cell's own text", () => {
|
|
469
|
+
const text = `Before\n\n${TABLE}\n`
|
|
470
|
+
|
|
471
|
+
const cell = toFound(
|
|
472
|
+
toFound(toTable(text).rows[1]).cells[1],
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
// The click target for that cell, and the reason it can be
|
|
476
|
+
// one: these are real document offsets, not indexes into a
|
|
477
|
+
// rendered string.
|
|
478
|
+
expect(toTextOf(text, cell)).toBe("uplink")
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* GFM drops cells past the header's width. An editor that did
|
|
483
|
+
* the same would put text the author typed on screen nowhere,
|
|
484
|
+
* so the widest row wins and the short rows gain empty cells.
|
|
485
|
+
*/
|
|
486
|
+
test("pads every row to the widest row", () => {
|
|
487
|
+
const text = [
|
|
488
|
+
"| a | b |",
|
|
489
|
+
"| --- | --- |",
|
|
490
|
+
"| 1 | 2 | 3 |",
|
|
491
|
+
"| only |",
|
|
492
|
+
].join("\n")
|
|
493
|
+
|
|
494
|
+
const table = toTable(text)
|
|
495
|
+
|
|
496
|
+
expect(
|
|
497
|
+
table.rows.map((row) => row.cells.length),
|
|
498
|
+
).toEqual([3, 3, 3])
|
|
499
|
+
|
|
500
|
+
// A padded cell collapses to the end of its own row, so
|
|
501
|
+
// clicking one puts the caret where the missing `| |` would
|
|
502
|
+
// be typed.
|
|
503
|
+
const row = toFound(table.rows[2])
|
|
504
|
+
|
|
505
|
+
expect(toFound(row.cells[2])).toMatchObject({
|
|
506
|
+
from: row.to,
|
|
507
|
+
segments: [],
|
|
508
|
+
to: row.to,
|
|
509
|
+
})
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
test("renders an image in a cell as an image", () => {
|
|
513
|
+
const text = [
|
|
514
|
+
"| icon |",
|
|
515
|
+
"| --- |",
|
|
516
|
+
"|  |",
|
|
517
|
+
].join("\n")
|
|
518
|
+
|
|
519
|
+
expect(
|
|
520
|
+
toTable(text).rows[1]?.cells[0]?.segments,
|
|
521
|
+
).toEqual([
|
|
522
|
+
{
|
|
523
|
+
alt: "up",
|
|
524
|
+
type: "image",
|
|
525
|
+
url: "https://e.com/a.png",
|
|
526
|
+
},
|
|
527
|
+
])
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* A row that ends with a pipe ends there. Counting what follows
|
|
532
|
+
* as a column gave every table trailing whitespace touched an
|
|
533
|
+
* empty extra column that nothing in the markdown asked for —
|
|
534
|
+
* and trailing whitespace is invisible, so it looked like the
|
|
535
|
+
* renderer inventing a column at random.
|
|
536
|
+
*/
|
|
537
|
+
test("ignores whitespace after the last pipe", () => {
|
|
538
|
+
const text =
|
|
539
|
+
"| a | b | \n| --- | --- |\n| 1 | 2 | \n"
|
|
540
|
+
|
|
541
|
+
expect(
|
|
542
|
+
toTable(text).rows.map((row) => row.cells.length),
|
|
543
|
+
).toEqual([2, 2])
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* `\\|` is the only way to put a pipe *in* a cell, so a rendered
|
|
548
|
+
* table is exactly where the backslash must not show.
|
|
549
|
+
*/
|
|
550
|
+
test("renders an escaped pipe as a pipe", () => {
|
|
551
|
+
const text =
|
|
552
|
+
"| a | b |\n| --- | --- |\n| x \\| y | 2 |\n"
|
|
553
|
+
|
|
554
|
+
expect(
|
|
555
|
+
toTable(text).rows[1]?.cells[0]?.segments,
|
|
556
|
+
).toEqual([
|
|
557
|
+
{ markKinds: [], text: "x | y", type: "text" },
|
|
558
|
+
])
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
test("renders a table nested in a blockquote or a list", () => {
|
|
562
|
+
for (const text of [
|
|
563
|
+
"> | a | b |\n> | --- | --- |\n> | 1 | 2 |\n",
|
|
564
|
+
"- item\n\n | a | b |\n | --- | --- |\n | 1 | 2 |\n",
|
|
565
|
+
]) {
|
|
566
|
+
expect(
|
|
567
|
+
toTable(text).rows.map((row) =>
|
|
568
|
+
row.cells.map((cell) => toTextOf(text, cell)),
|
|
569
|
+
),
|
|
570
|
+
).toEqual([
|
|
571
|
+
["a", "b"],
|
|
572
|
+
["1", "2"],
|
|
573
|
+
])
|
|
574
|
+
}
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
test("renders a table with no leading or trailing pipes", () => {
|
|
578
|
+
const text = "a | b\n--- | ---\n1 | 2"
|
|
579
|
+
|
|
580
|
+
expect(
|
|
581
|
+
toTable(text).rows.map((row) =>
|
|
582
|
+
row.cells.map((cell) => toTextOf(text, cell)),
|
|
583
|
+
),
|
|
584
|
+
).toEqual([
|
|
585
|
+
["a", "b"],
|
|
586
|
+
["1", "2"],
|
|
587
|
+
])
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Same rule as a link or an image: the caret is what turns the
|
|
592
|
+
* rendering back into markup, because you cannot edit a column
|
|
593
|
+
* you cannot see.
|
|
594
|
+
*/
|
|
595
|
+
test("stands down for a caret inside it", () => {
|
|
596
|
+
expect(toTableRanges(TABLE, atCaret(3))).toHaveLength(
|
|
597
|
+
0,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
// …and the pipes come back, monospaced, in its place.
|
|
601
|
+
expect(
|
|
602
|
+
toRanges(TABLE, atCaret(3)).filter(
|
|
603
|
+
(range) =>
|
|
604
|
+
range.type === "line" &&
|
|
605
|
+
range.lineKind === "table",
|
|
606
|
+
),
|
|
607
|
+
).toHaveLength(4)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
test("stands down in raw mode", () => {
|
|
611
|
+
expect(toTableRanges(TABLE, [], true)).toHaveLength(0)
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The two passes have to agree: the plugin steps out of the way
|
|
616
|
+
* of exactly the tables the field draws, or the pipes paint
|
|
617
|
+
* underneath a rendered table.
|
|
618
|
+
*/
|
|
619
|
+
test("is the only pass that decorates a rendered table", () => {
|
|
620
|
+
expect(
|
|
621
|
+
toRanges(TABLE).filter(
|
|
622
|
+
(range) => range.from < TABLE.length,
|
|
623
|
+
),
|
|
624
|
+
).toHaveLength(0)
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
test("describes a view, never an edit", () => {
|
|
628
|
+
const text = `${TABLE}\n`
|
|
629
|
+
|
|
630
|
+
for (const row of toTable(text).rows) {
|
|
631
|
+
for (const cell of row.cells) {
|
|
632
|
+
expect(cell.from).toBeGreaterThanOrEqual(0)
|
|
633
|
+
|
|
634
|
+
expect(cell.to).toBeLessThanOrEqual(text.length)
|
|
635
|
+
|
|
636
|
+
expect(cell.from).toBeLessThanOrEqual(cell.to)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
})
|
|
640
|
+
})
|
|
641
|
+
|
|
306
642
|
describe("raw mode", () => {
|
|
307
643
|
const text =
|
|
308
644
|
"# Heading\n\n**bold** and  and [x](https://e.com)\n"
|
|
@@ -398,11 +734,16 @@ describe("toLivePreviewRanges", () => {
|
|
|
398
734
|
).toEqual(["`", "`"])
|
|
399
735
|
})
|
|
400
736
|
|
|
401
|
-
|
|
737
|
+
/**
|
|
738
|
+
* The *editing* view of a table. A caret in it means the
|
|
739
|
+
* rendered widget stood down, and every line goes monospaced so
|
|
740
|
+
* the columns line up as typed.
|
|
741
|
+
*/
|
|
742
|
+
test("decorates every line of a table the caret is in", () => {
|
|
402
743
|
const text = "| a | b |\n| --- | --- |\n| 1 | 2 |"
|
|
403
744
|
|
|
404
745
|
expect(
|
|
405
|
-
toRanges(text).filter(
|
|
746
|
+
toRanges(text, atCaret(2)).filter(
|
|
406
747
|
(range) =>
|
|
407
748
|
range.type === "line" &&
|
|
408
749
|
range.lineKind === "table",
|