@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.
@@ -43,7 +43,11 @@
43
43
  * having a serialiser at all.
44
44
  */
45
45
 
46
- import type { SyntaxNode, Tree } from "@lezer/common"
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
- * Every decoration the document wants, in tree order.
397
+ * The delimiter row, read as alignment.
277
398
  *
278
- * Order is not sorted here on purpose — `Decoration.set(ranges,
279
- * true)` sorts, and doing it twice is the sort of thing that looks
280
- * free until a document is long.
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
- export const toLivePreviewRanges = ({
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
- isRawMode = false,
285
- selections,
523
+ ranges,
286
524
  text,
287
525
  to,
288
- tree,
289
- }: ToLivePreviewRangesOptions): LivePreviewRange[] => {
290
- const ranges: LivePreviewRange[] = []
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
- const pushMarker = (
293
- from: number,
294
- to: number,
295
- isRevealed: boolean,
296
- ) => {
297
- if (from >= to) {
298
- return
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
- ranges.push({
302
- from,
303
- isConcealed: !isRawMode && !isRevealed,
304
- to,
305
- type: "marker",
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
- tree.iterate({
310
- from,
311
- to,
312
- enter: (nodeRef) => {
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