@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
|
@@ -43,7 +43,11 @@
|
|
|
43
43
|
* having a serialiser at all.
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
-
import type {
|
|
46
|
+
import type {
|
|
47
|
+
SyntaxNode,
|
|
48
|
+
SyntaxNodeRef,
|
|
49
|
+
Tree,
|
|
50
|
+
} from "@lezer/common"
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
53
|
* A block-level treatment applied to a whole line.
|
|
@@ -72,6 +76,62 @@ export type LivePreviewMarkKind =
|
|
|
72
76
|
| "strong"
|
|
73
77
|
| "url"
|
|
74
78
|
|
|
79
|
+
/**
|
|
80
|
+
* A column's alignment, as the delimiter row spells it.
|
|
81
|
+
*
|
|
82
|
+
* Logical rather than left/right, because everything else in this
|
|
83
|
+
* fleet is: a table in an RTL locale should flip with the text, and
|
|
84
|
+
* `text-align: start` is the property that does it for free.
|
|
85
|
+
*/
|
|
86
|
+
export type LivePreviewTableAlignment =
|
|
87
|
+
| "center"
|
|
88
|
+
| "end"
|
|
89
|
+
| "start"
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A run of cell content that shares one treatment.
|
|
93
|
+
*
|
|
94
|
+
* A rendered table cell is built from these rather than from the
|
|
95
|
+
* raw slice, so a cell keeps everything the rest of the surface
|
|
96
|
+
* gives a line: `**bold**` is bold, a link is a link, `` `code` ``
|
|
97
|
+
* is monospaced, and the markers that said so are gone.
|
|
98
|
+
*/
|
|
99
|
+
export type LivePreviewTableSegment =
|
|
100
|
+
| {
|
|
101
|
+
alt: string
|
|
102
|
+
type: "image"
|
|
103
|
+
url: string
|
|
104
|
+
}
|
|
105
|
+
| {
|
|
106
|
+
markKinds: readonly LivePreviewMarkKind[]
|
|
107
|
+
text: string
|
|
108
|
+
type: "text"
|
|
109
|
+
/** Present when the run is link text; the click target. */
|
|
110
|
+
url?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type LivePreviewTableCell = {
|
|
114
|
+
alignment: LivePreviewTableAlignment
|
|
115
|
+
/**
|
|
116
|
+
* Where a click in this cell puts the caret.
|
|
117
|
+
*
|
|
118
|
+
* The rendered table is a view, so the only way back into the
|
|
119
|
+
* markdown is to move the caret into it — and landing in the cell
|
|
120
|
+
* the reader aimed at is the difference between "editable" and
|
|
121
|
+
* "editable somewhere in there".
|
|
122
|
+
*/
|
|
123
|
+
from: number
|
|
124
|
+
segments: readonly LivePreviewTableSegment[]
|
|
125
|
+
to: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type LivePreviewTableRow = {
|
|
129
|
+
cells: readonly LivePreviewTableCell[]
|
|
130
|
+
from: number
|
|
131
|
+
isHeader: boolean
|
|
132
|
+
to: number
|
|
133
|
+
}
|
|
134
|
+
|
|
75
135
|
export type LivePreviewRange = {
|
|
76
136
|
from: number
|
|
77
137
|
to: number
|
|
@@ -118,6 +178,26 @@ export type LivePreviewRange = {
|
|
|
118
178
|
}
|
|
119
179
|
)
|
|
120
180
|
|
|
181
|
+
/**
|
|
182
|
+
* A whole table, rendered — header row first, every row padded to
|
|
183
|
+
* the same width so the widget has no geometry decisions left.
|
|
184
|
+
*
|
|
185
|
+
* Its own type, and its own pass, because of a hard CodeMirror
|
|
186
|
+
* rule: **a block decoration may not come from a `ViewPlugin`.**
|
|
187
|
+
* Replacing four lines with one element changes the document's
|
|
188
|
+
* block structure, which the view has to know before it can decide
|
|
189
|
+
* what the viewport even contains — so it has to come from a
|
|
190
|
+
* `StateField`, which sees the whole document and no viewport.
|
|
191
|
+
* Everything else here is viewport-limited and stays in the plugin.
|
|
192
|
+
* The split is CodeMirror's, not ours.
|
|
193
|
+
*/
|
|
194
|
+
export type LivePreviewTableRange = {
|
|
195
|
+
from: number
|
|
196
|
+
rows: readonly LivePreviewTableRow[]
|
|
197
|
+
to: number
|
|
198
|
+
type: "table"
|
|
199
|
+
}
|
|
200
|
+
|
|
121
201
|
/** A selection range, in the two fields this module needs. */
|
|
122
202
|
export type LivePreviewSelection = {
|
|
123
203
|
from: number
|
|
@@ -272,44 +352,478 @@ const toHeaderMarkEnd = (text: string, to: number) => {
|
|
|
272
352
|
return end
|
|
273
353
|
}
|
|
274
354
|
|
|
355
|
+
/** A cell region with its surrounding padding removed. */
|
|
356
|
+
const toTrimmedBounds = (
|
|
357
|
+
text: string,
|
|
358
|
+
from: number,
|
|
359
|
+
to: number,
|
|
360
|
+
) => {
|
|
361
|
+
let start = from
|
|
362
|
+
|
|
363
|
+
let end = to
|
|
364
|
+
|
|
365
|
+
while (
|
|
366
|
+
start < end &&
|
|
367
|
+
(text[start] === " " || text[start] === "\t")
|
|
368
|
+
) {
|
|
369
|
+
start += 1
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
while (
|
|
373
|
+
end > start &&
|
|
374
|
+
(text[end - 1] === " " || text[end - 1] === "\t")
|
|
375
|
+
) {
|
|
376
|
+
end -= 1
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return { from: start, to: end }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const toAlignment = (
|
|
383
|
+
spec: string,
|
|
384
|
+
): LivePreviewTableAlignment => {
|
|
385
|
+
if (spec.startsWith(":") && spec.endsWith(":")) {
|
|
386
|
+
return "center"
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (spec.endsWith(":")) {
|
|
390
|
+
return "end"
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return "start"
|
|
394
|
+
}
|
|
395
|
+
|
|
275
396
|
/**
|
|
276
|
-
*
|
|
397
|
+
* The delimiter row, read as alignment.
|
|
277
398
|
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
399
|
+
* It is the one row a rendered table does not show — `| :--- | ---: |`
|
|
400
|
+
* is not content, it is the column spec — so this is where it goes
|
|
401
|
+
* instead of on screen. The parser hands the whole row back as a
|
|
402
|
+
* single `TableDelimiter` child of `Table`, which is what separates
|
|
403
|
+
* it from the `|` between two cells: those are children of the row.
|
|
281
404
|
*/
|
|
282
|
-
|
|
405
|
+
const toColumnAlignments = (
|
|
406
|
+
text: string,
|
|
407
|
+
node: SyntaxNode,
|
|
408
|
+
): LivePreviewTableAlignment[] => {
|
|
409
|
+
for (
|
|
410
|
+
let child = node.firstChild;
|
|
411
|
+
child;
|
|
412
|
+
child = child.nextSibling
|
|
413
|
+
) {
|
|
414
|
+
if (child.name !== "TableDelimiter") {
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return text
|
|
419
|
+
.slice(child.from, child.to)
|
|
420
|
+
.split("|")
|
|
421
|
+
.map((spec) => spec.trim())
|
|
422
|
+
.filter(
|
|
423
|
+
(spec, index, specs) =>
|
|
424
|
+
spec !== "" ||
|
|
425
|
+
(index !== 0 && index !== specs.length - 1),
|
|
426
|
+
)
|
|
427
|
+
.map(toAlignment)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return []
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* A row's columns, as offset ranges — including the empty ones.
|
|
435
|
+
*
|
|
436
|
+
* Splitting on the `TableCell` children alone would lose them: the
|
|
437
|
+
* parser emits no cell node for `| |`, so a table with a blank cell
|
|
438
|
+
* would render with its remaining cells shifted one column left,
|
|
439
|
+
* which is a data-corrupting kind of wrong even though nothing was
|
|
440
|
+
* written. The `|` positions are the truth, so the gaps between
|
|
441
|
+
* them are the columns, cell node or not.
|
|
442
|
+
*/
|
|
443
|
+
const toRowRegions = (row: SyntaxNode, text: string) => {
|
|
444
|
+
const regions: { from: number; to: number }[] = []
|
|
445
|
+
|
|
446
|
+
let start = row.from
|
|
447
|
+
|
|
448
|
+
for (
|
|
449
|
+
let child = row.firstChild;
|
|
450
|
+
child;
|
|
451
|
+
child = child.nextSibling
|
|
452
|
+
) {
|
|
453
|
+
if (child.name !== "TableDelimiter") {
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The leading `|`, which opens no column of its own.
|
|
458
|
+
if (child.from === start) {
|
|
459
|
+
start = child.to
|
|
460
|
+
|
|
461
|
+
continue
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
regions.push({ from: start, to: child.from })
|
|
465
|
+
|
|
466
|
+
start = child.to
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// What is left after the last `|`. Only a column if it holds
|
|
470
|
+
// something: a row that ends with a pipe and then trailing spaces
|
|
471
|
+
// has no final column, and counting one gives every table an
|
|
472
|
+
// empty extra column that nothing in the markdown asked for.
|
|
473
|
+
if (
|
|
474
|
+
start < row.to &&
|
|
475
|
+
text.slice(start, row.to).trim() !== ""
|
|
476
|
+
) {
|
|
477
|
+
regions.push({ from: start, to: row.to })
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return regions
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const toCellNodeIn = (
|
|
484
|
+
row: SyntaxNode,
|
|
485
|
+
from: number,
|
|
486
|
+
to: number,
|
|
487
|
+
): SyntaxNode | null => {
|
|
488
|
+
for (
|
|
489
|
+
let child = row.firstChild;
|
|
490
|
+
child;
|
|
491
|
+
child = child.nextSibling
|
|
492
|
+
) {
|
|
493
|
+
if (
|
|
494
|
+
child.name === "TableCell" &&
|
|
495
|
+
child.from >= from &&
|
|
496
|
+
child.to <= to
|
|
497
|
+
) {
|
|
498
|
+
return child
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return null
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Inline ranges over a slice → the runs a renderer can build DOM
|
|
507
|
+
* from, flattened.
|
|
508
|
+
*
|
|
509
|
+
* Everywhere else in this module a decoration is *applied* to text
|
|
510
|
+
* CodeMirror is already drawing, so overlapping ranges are free —
|
|
511
|
+
* the browser composes them. A widget draws its own text, so they
|
|
512
|
+
* are not: `**a [b](c) d**` is one strong run overlapping one link
|
|
513
|
+
* run, and something has to decide where the spans break. That is
|
|
514
|
+
* this function, and the answer is per character, which is the only
|
|
515
|
+
* version that cannot be defeated by a nesting nobody predicted.
|
|
516
|
+
*
|
|
517
|
+
* Concealed markers are dropped rather than split around, so a run
|
|
518
|
+
* of bold text stays a single segment with its `**` gone from both
|
|
519
|
+
* ends.
|
|
520
|
+
*/
|
|
521
|
+
export const toInlineSegments = ({
|
|
283
522
|
from,
|
|
284
|
-
|
|
285
|
-
selections,
|
|
523
|
+
ranges,
|
|
286
524
|
text,
|
|
287
525
|
to,
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
526
|
+
}: {
|
|
527
|
+
from: number
|
|
528
|
+
ranges: readonly LivePreviewRange[]
|
|
529
|
+
text: string
|
|
530
|
+
to: number
|
|
531
|
+
}): LivePreviewTableSegment[] => {
|
|
532
|
+
const isHidden = new Array<boolean>(
|
|
533
|
+
Math.max(to - from, 0),
|
|
534
|
+
).fill(false)
|
|
535
|
+
|
|
536
|
+
const marks: {
|
|
537
|
+
from: number
|
|
538
|
+
markKind: LivePreviewMarkKind
|
|
539
|
+
to: number
|
|
540
|
+
url?: string
|
|
541
|
+
}[] = []
|
|
542
|
+
|
|
543
|
+
const images = new Map<
|
|
544
|
+
number,
|
|
545
|
+
{ alt: string; to: number; url: string }
|
|
546
|
+
>()
|
|
547
|
+
|
|
548
|
+
const hide = (hiddenFrom: number, hiddenTo: number) => {
|
|
549
|
+
for (
|
|
550
|
+
let offset = Math.max(hiddenFrom, from);
|
|
551
|
+
offset < Math.min(hiddenTo, to);
|
|
552
|
+
offset += 1
|
|
553
|
+
) {
|
|
554
|
+
isHidden[offset - from] = true
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
for (const range of ranges) {
|
|
559
|
+
if (range.type === "marker" && range.isConcealed) {
|
|
560
|
+
hide(range.from, range.to)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (range.type === "mark") {
|
|
564
|
+
marks.push({
|
|
565
|
+
from: range.from,
|
|
566
|
+
markKind: range.markKind,
|
|
567
|
+
to: range.to,
|
|
568
|
+
url: range.url,
|
|
569
|
+
})
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (range.type === "image") {
|
|
573
|
+
images.set(range.from, {
|
|
574
|
+
alt: range.alt,
|
|
575
|
+
to: range.to,
|
|
576
|
+
url: range.url,
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
hide(range.from, range.to)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const segments: LivePreviewTableSegment[] = []
|
|
584
|
+
|
|
585
|
+
let openKey: string | null = null
|
|
586
|
+
|
|
587
|
+
let openSegment: {
|
|
588
|
+
markKinds: LivePreviewMarkKind[]
|
|
589
|
+
text: string
|
|
590
|
+
url?: string
|
|
591
|
+
} | null = null
|
|
592
|
+
|
|
593
|
+
const flush = () => {
|
|
594
|
+
if (openSegment && openSegment.text !== "") {
|
|
595
|
+
segments.push({ ...openSegment, type: "text" })
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
openKey = null
|
|
599
|
+
|
|
600
|
+
openSegment = null
|
|
601
|
+
}
|
|
291
602
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
603
|
+
for (let offset = from; offset < to; offset += 1) {
|
|
604
|
+
const image = images.get(offset)
|
|
605
|
+
|
|
606
|
+
if (image) {
|
|
607
|
+
flush()
|
|
608
|
+
|
|
609
|
+
segments.push({
|
|
610
|
+
alt: image.alt,
|
|
611
|
+
type: "image",
|
|
612
|
+
url: image.url,
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
offset = image.to - 1
|
|
616
|
+
|
|
617
|
+
continue
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (isHidden[offset - from]) {
|
|
621
|
+
continue
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const covering = marks.filter(
|
|
625
|
+
(mark) => mark.from <= offset && mark.to > offset,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
const markKinds = [
|
|
629
|
+
...new Set(covering.map((mark) => mark.markKind)),
|
|
630
|
+
].sort()
|
|
631
|
+
|
|
632
|
+
const url = covering.find((mark) => mark.url)?.url
|
|
633
|
+
|
|
634
|
+
const key = `${markKinds.join(" ")}\u0000${url ?? ""}`
|
|
635
|
+
|
|
636
|
+
if (key !== openKey) {
|
|
637
|
+
flush()
|
|
638
|
+
|
|
639
|
+
openKey = key
|
|
640
|
+
|
|
641
|
+
openSegment = {
|
|
642
|
+
markKinds,
|
|
643
|
+
text: "",
|
|
644
|
+
...(url ? { url } : {}),
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (openSegment) {
|
|
649
|
+
openSegment.text += text[offset]
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
flush()
|
|
654
|
+
|
|
655
|
+
return segments
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* A table, as rows of rendered cells — or `null` when the parser
|
|
660
|
+
* found no rows at all to render.
|
|
661
|
+
*
|
|
662
|
+
* Rows are padded to a rectangle here rather than in the widget:
|
|
663
|
+
* the width is `max(columns in the delimiter row, columns in the
|
|
664
|
+
* widest row)`, which is deliberately **not** what GitHub renders.
|
|
665
|
+
* GFM drops cells past the header's width, and dropping them in an
|
|
666
|
+
* *editor* means text the author typed is on screen nowhere — a
|
|
667
|
+
* worse failure than a table one column wider than it should be,
|
|
668
|
+
* with the raw markdown one toggle away.
|
|
669
|
+
*/
|
|
670
|
+
const toTableRows = ({
|
|
671
|
+
node,
|
|
672
|
+
text,
|
|
673
|
+
toEnter,
|
|
674
|
+
}: {
|
|
675
|
+
node: SyntaxNode
|
|
676
|
+
text: string
|
|
677
|
+
toEnter: (
|
|
678
|
+
ranges: LivePreviewRange[],
|
|
679
|
+
) => (nodeRef: SyntaxNodeRef) => boolean
|
|
680
|
+
}): LivePreviewTableRow[] | null => {
|
|
681
|
+
const alignments = toColumnAlignments(text, node)
|
|
682
|
+
|
|
683
|
+
const rows: LivePreviewTableRow[] = []
|
|
684
|
+
|
|
685
|
+
for (
|
|
686
|
+
let child = node.firstChild;
|
|
687
|
+
child;
|
|
688
|
+
child = child.nextSibling
|
|
689
|
+
) {
|
|
690
|
+
if (
|
|
691
|
+
child.name !== "TableHeader" &&
|
|
692
|
+
child.name !== "TableRow"
|
|
693
|
+
) {
|
|
694
|
+
continue
|
|
299
695
|
}
|
|
300
696
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
697
|
+
const row = child
|
|
698
|
+
|
|
699
|
+
rows.push({
|
|
700
|
+
cells: toRowRegions(row, text).map(
|
|
701
|
+
(region, columnIndex) => {
|
|
702
|
+
const bounds = toTrimmedBounds(
|
|
703
|
+
text,
|
|
704
|
+
region.from,
|
|
705
|
+
region.to,
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
const cellNode = toCellNodeIn(
|
|
709
|
+
row,
|
|
710
|
+
bounds.from,
|
|
711
|
+
bounds.to,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
const cellRanges: LivePreviewRange[] = []
|
|
715
|
+
|
|
716
|
+
if (cellNode) {
|
|
717
|
+
cellNode.cursor().iterate(toEnter(cellRanges))
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return {
|
|
721
|
+
alignment: alignments[columnIndex] ?? "start",
|
|
722
|
+
from: bounds.from,
|
|
723
|
+
segments: toInlineSegments({
|
|
724
|
+
from: bounds.from,
|
|
725
|
+
ranges: cellRanges,
|
|
726
|
+
text,
|
|
727
|
+
to: bounds.to,
|
|
728
|
+
}),
|
|
729
|
+
to: bounds.to,
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
),
|
|
733
|
+
from: row.from,
|
|
734
|
+
isHeader: row.name === "TableHeader",
|
|
735
|
+
to: row.to,
|
|
306
736
|
})
|
|
307
737
|
}
|
|
308
738
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
739
|
+
if (rows.length === 0) {
|
|
740
|
+
return null
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const columnCount = Math.max(
|
|
744
|
+
alignments.length,
|
|
745
|
+
...rows.map((row) => row.cells.length),
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
return rows.map((row) => ({
|
|
749
|
+
...row,
|
|
750
|
+
cells: [
|
|
751
|
+
...row.cells,
|
|
752
|
+
// A short row keeps its shape by gaining empty cells, and
|
|
753
|
+
// they collapse to the end of the row: clicking one puts the
|
|
754
|
+
// caret where the missing `| |` would be typed.
|
|
755
|
+
...Array.from(
|
|
756
|
+
{ length: columnCount - row.cells.length },
|
|
757
|
+
(_unused, index) => ({
|
|
758
|
+
alignment:
|
|
759
|
+
alignments[row.cells.length + index] ?? "start",
|
|
760
|
+
from: row.to,
|
|
761
|
+
segments: [],
|
|
762
|
+
to: row.to,
|
|
763
|
+
}),
|
|
764
|
+
),
|
|
765
|
+
],
|
|
766
|
+
}))
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Is this table drawn, or edited?
|
|
771
|
+
*
|
|
772
|
+
* One predicate, used by both passes, because they have to agree:
|
|
773
|
+
* the block pass draws the table exactly when the inline pass steps
|
|
774
|
+
* out of its way, and a disagreement paints the pipes underneath a
|
|
775
|
+
* rendered table or leaves a gap where neither drew anything.
|
|
776
|
+
*/
|
|
777
|
+
const isTableRendered = ({
|
|
778
|
+
isRawMode,
|
|
779
|
+
node,
|
|
780
|
+
selections,
|
|
781
|
+
}: {
|
|
782
|
+
isRawMode: boolean
|
|
783
|
+
node: SyntaxNode
|
|
784
|
+
selections: readonly LivePreviewSelection[]
|
|
785
|
+
}) =>
|
|
786
|
+
!isRawMode && !isInside(selections, node.from, node.to)
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* The walk, parameterised by where it writes.
|
|
790
|
+
*
|
|
791
|
+
* Everything the document contains is decorated into the array this
|
|
792
|
+
* is handed; a table cell's contents are decorated into a throwaway
|
|
793
|
+
* one instead, because they are not decorations over the document
|
|
794
|
+
* at all — they describe text a widget will draw itself. Same rules
|
|
795
|
+
* for `**` in a cell as for `**` in a paragraph, one
|
|
796
|
+
* implementation, and the recursion is bounded by the cell subtree
|
|
797
|
+
* rather than by a flag.
|
|
798
|
+
*/
|
|
799
|
+
const toWalker = ({
|
|
800
|
+
isRawMode,
|
|
801
|
+
selections,
|
|
802
|
+
text,
|
|
803
|
+
}: {
|
|
804
|
+
isRawMode: boolean
|
|
805
|
+
selections: readonly LivePreviewSelection[]
|
|
806
|
+
text: string
|
|
807
|
+
}) => {
|
|
808
|
+
const toEnter = (ranges: LivePreviewRange[]) => {
|
|
809
|
+
const pushMarker = (
|
|
810
|
+
from: number,
|
|
811
|
+
to: number,
|
|
812
|
+
isRevealed: boolean,
|
|
813
|
+
) => {
|
|
814
|
+
if (from >= to) {
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
ranges.push({
|
|
819
|
+
from,
|
|
820
|
+
isConcealed: !isRawMode && !isRevealed,
|
|
821
|
+
to,
|
|
822
|
+
type: "marker",
|
|
823
|
+
})
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return (nodeRef: SyntaxNodeRef): boolean => {
|
|
313
827
|
const { name } = nodeRef
|
|
314
828
|
|
|
315
829
|
const headingKind = HEADING_LINE_KINDS[name]
|
|
@@ -358,6 +872,22 @@ export const toLivePreviewRanges = ({
|
|
|
358
872
|
}
|
|
359
873
|
|
|
360
874
|
if (name === "Table") {
|
|
875
|
+
// A rendered table belongs to the block pass, and this one
|
|
876
|
+
// has nothing to say about lines that will not be drawn.
|
|
877
|
+
if (
|
|
878
|
+
isTableRendered({
|
|
879
|
+
isRawMode,
|
|
880
|
+
node: nodeRef.node,
|
|
881
|
+
selections,
|
|
882
|
+
})
|
|
883
|
+
) {
|
|
884
|
+
return false
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Otherwise the caret is in it — or raw mode is on — and
|
|
888
|
+
// this is the *editing* view: the pipes, monospaced, on the
|
|
889
|
+
// same rule as an image or a link. You cannot edit a column
|
|
890
|
+
// you cannot see.
|
|
361
891
|
if (!isRawMode) {
|
|
362
892
|
for (
|
|
363
893
|
let offset = toLineStart(text, nodeRef.from);
|
|
@@ -532,6 +1062,19 @@ export const toLivePreviewRanges = ({
|
|
|
532
1062
|
return false
|
|
533
1063
|
}
|
|
534
1064
|
|
|
1065
|
+
if (name === "Escape") {
|
|
1066
|
+
// `\|` is the only way to put a pipe in a table cell, and
|
|
1067
|
+
// the reader wants the pipe, not the backslash. Same rule
|
|
1068
|
+
// as any other marker: the caret brings it back.
|
|
1069
|
+
pushMarker(
|
|
1070
|
+
nodeRef.from,
|
|
1071
|
+
nodeRef.from + 1,
|
|
1072
|
+
isInside(selections, nodeRef.from, nodeRef.to),
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
return false
|
|
1076
|
+
}
|
|
1077
|
+
|
|
535
1078
|
if (name === "CodeInfo") {
|
|
536
1079
|
pushMarker(nodeRef.from, nodeRef.to, true)
|
|
537
1080
|
|
|
@@ -574,6 +1117,105 @@ export const toLivePreviewRanges = ({
|
|
|
574
1117
|
}
|
|
575
1118
|
|
|
576
1119
|
return true
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return toEnter
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Every decoration the document wants that a plugin may provide, in
|
|
1128
|
+
* tree order.
|
|
1129
|
+
*
|
|
1130
|
+
* Order is not sorted here on purpose — `Decoration.set(ranges,
|
|
1131
|
+
* true)` sorts, and doing it twice is the sort of thing that looks
|
|
1132
|
+
* free until a document is long.
|
|
1133
|
+
*/
|
|
1134
|
+
export const toLivePreviewRanges = ({
|
|
1135
|
+
from,
|
|
1136
|
+
isRawMode = false,
|
|
1137
|
+
selections,
|
|
1138
|
+
text,
|
|
1139
|
+
to,
|
|
1140
|
+
tree,
|
|
1141
|
+
}: ToLivePreviewRangesOptions): LivePreviewRange[] => {
|
|
1142
|
+
const ranges: LivePreviewRange[] = []
|
|
1143
|
+
|
|
1144
|
+
tree.iterate({
|
|
1145
|
+
from,
|
|
1146
|
+
to,
|
|
1147
|
+
enter: toWalker({ isRawMode, selections, text })(
|
|
1148
|
+
ranges,
|
|
1149
|
+
),
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1152
|
+
return ranges
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Block nodes a table can be nested inside.
|
|
1157
|
+
*
|
|
1158
|
+
* The block pass is not viewport-limited — it cannot be, since a
|
|
1159
|
+
* block decoration is part of what *decides* the viewport — so it
|
|
1160
|
+
* pays for the whole document on every keystroke. Descending only
|
|
1161
|
+
* into containers keeps that a scan of the block structure rather
|
|
1162
|
+
* than a walk of every inline node in the file.
|
|
1163
|
+
*/
|
|
1164
|
+
const TABLE_CONTAINER_NAMES = new Set([
|
|
1165
|
+
"Blockquote",
|
|
1166
|
+
"BulletList",
|
|
1167
|
+
"Document",
|
|
1168
|
+
"ListItem",
|
|
1169
|
+
"OrderedList",
|
|
1170
|
+
])
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* The tables the document renders, whole-document.
|
|
1174
|
+
*
|
|
1175
|
+
* Separate from `toLivePreviewRanges` because CodeMirror requires
|
|
1176
|
+
* it: block decorations come from a `StateField`, inline ones from
|
|
1177
|
+
* a viewport-limited `ViewPlugin`, and the two passes agree via
|
|
1178
|
+
* `isTableRendered`.
|
|
1179
|
+
*/
|
|
1180
|
+
export const toLivePreviewTableRanges = ({
|
|
1181
|
+
isRawMode = false,
|
|
1182
|
+
selections,
|
|
1183
|
+
text,
|
|
1184
|
+
tree,
|
|
1185
|
+
}: Omit<
|
|
1186
|
+
ToLivePreviewRangesOptions,
|
|
1187
|
+
"from" | "to"
|
|
1188
|
+
>): LivePreviewTableRange[] => {
|
|
1189
|
+
const ranges: LivePreviewTableRange[] = []
|
|
1190
|
+
|
|
1191
|
+
const toEnter = toWalker({ isRawMode, selections, text })
|
|
1192
|
+
|
|
1193
|
+
tree.iterate({
|
|
1194
|
+
enter: (nodeRef) => {
|
|
1195
|
+
if (nodeRef.name !== "Table") {
|
|
1196
|
+
return TABLE_CONTAINER_NAMES.has(nodeRef.name)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const node = nodeRef.node
|
|
1200
|
+
|
|
1201
|
+
const rows = isTableRendered({
|
|
1202
|
+
isRawMode,
|
|
1203
|
+
node,
|
|
1204
|
+
selections,
|
|
1205
|
+
})
|
|
1206
|
+
? toTableRows({ node, text, toEnter })
|
|
1207
|
+
: null
|
|
1208
|
+
|
|
1209
|
+
if (rows) {
|
|
1210
|
+
ranges.push({
|
|
1211
|
+
from: node.from,
|
|
1212
|
+
rows,
|
|
1213
|
+
to: node.to,
|
|
1214
|
+
type: "table",
|
|
1215
|
+
})
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
return false
|
|
577
1219
|
},
|
|
578
1220
|
})
|
|
579
1221
|
|