@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.
@@ -26,8 +26,16 @@
26
26
  */
27
27
 
28
28
  import { syntaxTree } from "@codemirror/language"
29
- import type { Extension, Range } from "@codemirror/state"
30
- import { StateEffect, StateField } from "@codemirror/state"
29
+ import type {
30
+ EditorState,
31
+ Extension,
32
+ Range,
33
+ } from "@codemirror/state"
34
+ import {
35
+ Prec,
36
+ StateEffect,
37
+ StateField,
38
+ } from "@codemirror/state"
31
39
  import type {
32
40
  DecorationSet,
33
41
  ViewUpdate,
@@ -35,6 +43,7 @@ import type {
35
43
  import {
36
44
  Decoration,
37
45
  EditorView,
46
+ keymap,
38
47
  ViewPlugin,
39
48
  WidgetType,
40
49
  } from "@codemirror/view"
@@ -42,12 +51,31 @@ import {
42
51
  import type {
43
52
  LivePreviewLineKind,
44
53
  LivePreviewMarkKind,
54
+ LivePreviewTableRow,
55
+ LivePreviewTableSegment,
56
+ } from "./livePreviewRanges.ts"
57
+ import {
58
+ toLivePreviewRanges,
59
+ toLivePreviewTableRanges,
45
60
  } from "./livePreviewRanges.ts"
46
- import { toLivePreviewRanges } from "./livePreviewRanges.ts"
47
61
 
48
62
  /** Marks a rendered link so the click handler can find its target. */
49
63
  const URL_ATTRIBUTE = "data-charcuterie-url"
50
64
 
65
+ /**
66
+ * A rendered cell's offset into its own table, so a click can put
67
+ * the caret back in the markdown it was drawn from.
68
+ *
69
+ * Relative, not absolute, and for the same reason `TaskWidget` reads
70
+ * its position back from the DOM: a widget outlives the offsets it
71
+ * was built at. Editing a paragraph *above* a table moves every
72
+ * offset in it without changing a character of the table, and the
73
+ * widget is reused — `eq` compares the markdown, which did not
74
+ * change. Relative plus `posAtDOM` at click time is correct in both
75
+ * cases; a captured absolute offset silently is not.
76
+ */
77
+ const CELL_OFFSET_ATTRIBUTE = "data-charcuterie-cell-offset"
78
+
51
79
  /** Toggle the "edit Markdown" mode. */
52
80
  export const setLivePreviewRawMode =
53
81
  StateEffect.define<boolean>()
@@ -75,6 +103,41 @@ export const livePreviewRawModeField =
75
103
  },
76
104
  })
77
105
 
106
+ const setIsEditorFocused = StateEffect.define<boolean>()
107
+
108
+ /**
109
+ * Focus, as editor state.
110
+ *
111
+ * The plugin can read `view.hasFocus` directly; the block pass is a
112
+ * `StateField` and has no view to ask. It still needs the answer,
113
+ * because an unfocused editor reveals nothing — without it the
114
+ * caret's default resting place, offset 0, counts as "inside" a
115
+ * table that opens the document, and a description that starts with
116
+ * a table loads showing pipes.
117
+ */
118
+ const isEditorFocusedField = StateField.define<boolean>({
119
+ create: () => false,
120
+ update: (isFocused, transaction) => {
121
+ let isNextFocused = isFocused
122
+
123
+ for (const effect of transaction.effects) {
124
+ if (effect.is(setIsEditorFocused)) {
125
+ isNextFocused = effect.value
126
+ }
127
+ }
128
+
129
+ return isNextFocused
130
+ },
131
+ })
132
+
133
+ /**
134
+ * The facet CodeMirror provides for exactly this: turn a focus
135
+ * change into an effect, so state can depend on it.
136
+ */
137
+ const focusTracker = EditorView.focusChangeEffect.of(
138
+ (_state, isFocusing) => setIsEditorFocused.of(isFocusing),
139
+ )
140
+
78
141
  const LINE_CLASSES: Record<LivePreviewLineKind, string> = {
79
142
  blockquote: "cm-md-blockquote",
80
143
  code: "cm-md-codeblock",
@@ -212,6 +275,183 @@ class TaskWidget extends WidgetType {
212
275
  }
213
276
  }
214
277
 
278
+ /**
279
+ * A cell's content run, as DOM.
280
+ *
281
+ * A plain text node when there is nothing to say about it — the
282
+ * common case, and one fewer element per cell in a table that might
283
+ * have a hundred.
284
+ */
285
+ const toSegmentNode = (
286
+ segment: LivePreviewTableSegment,
287
+ view: EditorView,
288
+ ) => {
289
+ if (segment.type === "image") {
290
+ const image = document.createElement("img")
291
+
292
+ image.setAttribute("src", segment.url)
293
+
294
+ image.setAttribute("alt", segment.alt)
295
+
296
+ image.className = "cm-md-image"
297
+
298
+ image.addEventListener("load", () => {
299
+ view.requestMeasure()
300
+ })
301
+
302
+ return image
303
+ }
304
+
305
+ if (segment.markKinds.length === 0 && !segment.url) {
306
+ return document.createTextNode(segment.text)
307
+ }
308
+
309
+ const span = document.createElement("span")
310
+
311
+ span.className = segment.markKinds
312
+ .map((markKind) => MARK_CLASSES[markKind])
313
+ .join(" ")
314
+
315
+ if (segment.url) {
316
+ span.setAttribute(URL_ATTRIBUTE, segment.url)
317
+ }
318
+
319
+ span.textContent = segment.text
320
+
321
+ return span
322
+ }
323
+
324
+ /**
325
+ * A table, standing in for its own pipes.
326
+ *
327
+ * This is the one widget here that replaces *lines* rather than a
328
+ * span, and the only construct in the language whose meaning is
329
+ * geometry: a column is not a decoration you can hang on the text,
330
+ * because the text of column two is on four different lines. So the
331
+ * markdown stops being drawn and this draws instead — and the way
332
+ * back is the caret, exactly as it is for a link or an image.
333
+ * Clicking a cell dispatches a selection into that cell, the
334
+ * decoration drops on the next update, and the pipes are back with
335
+ * the caret already in the right one.
336
+ *
337
+ * `eq` compares the table's markdown source. Two identical tables
338
+ * never share a widget instance — `eq` is only ever consulted for
339
+ * the same position — so the slice is a complete identity, and it
340
+ * is stable under edits elsewhere in the document, which is what
341
+ * stops every keystroke in a long description from rebuilding every
342
+ * table below it.
343
+ */
344
+ class TableWidget extends WidgetType {
345
+ constructor(
346
+ private readonly source: string,
347
+ private readonly rows: readonly LivePreviewTableRow[],
348
+ private readonly tableFrom: number,
349
+ ) {
350
+ super()
351
+ }
352
+
353
+ eq(other: TableWidget) {
354
+ return other.source === this.source
355
+ }
356
+
357
+ toDOM(view: EditorView) {
358
+ const scroll = document.createElement("div")
359
+
360
+ scroll.className = "cm-md-table-scroll"
361
+
362
+ const table = document.createElement("table")
363
+
364
+ table.className = "cm-md-table-rendered"
365
+
366
+ const head = document.createElement("thead")
367
+
368
+ const body = document.createElement("tbody")
369
+
370
+ for (const row of this.rows) {
371
+ const rowElement = document.createElement("tr")
372
+
373
+ for (const cell of row.cells) {
374
+ const cellElement = document.createElement(
375
+ row.isHeader ? "th" : "td",
376
+ )
377
+
378
+ cellElement.style.textAlign = cell.alignment
379
+
380
+ cellElement.setAttribute(
381
+ CELL_OFFSET_ATTRIBUTE,
382
+ String(cell.from - this.tableFrom),
383
+ )
384
+
385
+ for (const segment of cell.segments) {
386
+ cellElement.append(toSegmentNode(segment, view))
387
+ }
388
+
389
+ rowElement.append(cellElement)
390
+ }
391
+
392
+ ;(row.isHeader ? head : body).append(rowElement)
393
+ }
394
+
395
+ if (head.childElementCount > 0) {
396
+ table.append(head)
397
+ }
398
+
399
+ table.append(body)
400
+
401
+ scroll.append(table)
402
+
403
+ scroll.addEventListener("mousedown", (event) => {
404
+ const target = event.target
405
+
406
+ if (!(target instanceof HTMLElement)) {
407
+ return
408
+ }
409
+
410
+ // A link in a cell is still a link; `openLinkOnClick` gets it
411
+ // on the way up.
412
+ if (target.closest(`[${URL_ATTRIBUTE}]`)) {
413
+ return
414
+ }
415
+
416
+ const cellElement = target.closest(
417
+ `[${CELL_OFFSET_ATTRIBUTE}]`,
418
+ )
419
+
420
+ if (!cellElement) {
421
+ return
422
+ }
423
+
424
+ event.preventDefault()
425
+
426
+ const offset = Number(
427
+ cellElement.getAttribute(CELL_OFFSET_ATTRIBUTE),
428
+ )
429
+
430
+ view.focus()
431
+
432
+ view.dispatch({
433
+ selection: {
434
+ anchor: view.posAtDOM(scroll) + offset,
435
+ },
436
+ })
437
+ })
438
+
439
+ return scroll
440
+ }
441
+
442
+ /**
443
+ * The widget handles its own events.
444
+ *
445
+ * Without this CodeMirror also treats the click as a caret
446
+ * placement and lands wherever the coordinates map to — which for
447
+ * a block widget is its first or last position, never the cell
448
+ * that was clicked.
449
+ */
450
+ ignoreEvent() {
451
+ return true
452
+ }
453
+ }
454
+
215
455
  const toDecorations = (view: EditorView): DecorationSet => {
216
456
  const isRawMode = view.state.field(
217
457
  livePreviewRawModeField,
@@ -319,6 +559,86 @@ const toDecorations = (view: EditorView): DecorationSet => {
319
559
  return Decoration.set(decorations, true)
320
560
  }
321
561
 
562
+ /**
563
+ * The block pass: tables, from a `StateField`.
564
+ *
565
+ * `EditorView.decorations.from` a field rather than a plugin, and
566
+ * that is not a style choice — CodeMirror throws
567
+ * `Block decorations may not be specified via plugins` if you try
568
+ * it the other way, because replacing four lines with one element
569
+ * changes the block structure the viewport is measured against.
570
+ * The whole document is scanned every time, which is what the
571
+ * plugin's `visibleRanges` optimisation buys back for everything
572
+ * else.
573
+ */
574
+ const toTableDecorations = (state: EditorState) => {
575
+ const isRawMode = state.field(
576
+ livePreviewRawModeField,
577
+ false,
578
+ )
579
+
580
+ const selections = state.field(
581
+ isEditorFocusedField,
582
+ false,
583
+ )
584
+ ? state.selection.ranges.map((range) => ({
585
+ from: range.from,
586
+ to: range.to,
587
+ }))
588
+ : []
589
+
590
+ const text = state.doc.toString()
591
+
592
+ return Decoration.set(
593
+ toLivePreviewTableRanges({
594
+ isRawMode: isRawMode ?? false,
595
+ selections,
596
+ text,
597
+ tree: syntaxTree(state),
598
+ }).map((range) =>
599
+ Decoration.replace({
600
+ // Whole lines, which is what `block` requires — and also
601
+ // what makes the table a paragraph in the flow rather than
602
+ // a very tall character.
603
+ block: true,
604
+ widget: new TableWidget(
605
+ text.slice(range.from, range.to),
606
+ range.rows,
607
+ range.from,
608
+ ),
609
+ }).range(range.from, range.to),
610
+ ),
611
+ true,
612
+ )
613
+ }
614
+
615
+ const livePreviewTableField =
616
+ StateField.define<DecorationSet>({
617
+ create: toTableDecorations,
618
+ update: (decorations, transaction) => {
619
+ if (
620
+ transaction.docChanged ||
621
+ transaction.selection !== undefined ||
622
+ transaction.effects.some(
623
+ (effect) =>
624
+ effect.is(setLivePreviewRawMode) ||
625
+ effect.is(setIsEditorFocused),
626
+ ) ||
627
+ // Parsing is incremental and time-sliced, so a long
628
+ // document's tables arrive over several transactions that
629
+ // change nothing else. Comparing the tree by identity is
630
+ // the cheap way to notice.
631
+ syntaxTree(transaction.state) !==
632
+ syntaxTree(transaction.startState)
633
+ ) {
634
+ return toTableDecorations(transaction.state)
635
+ }
636
+
637
+ return decorations
638
+ },
639
+ provide: (field) => EditorView.decorations.from(field),
640
+ })
641
+
322
642
  const livePreviewPlugin = ViewPlugin.fromClass(
323
643
  class {
324
644
  decorations: DecorationSet
@@ -352,6 +672,80 @@ const livePreviewPlugin = ViewPlugin.fromClass(
352
672
  },
353
673
  )
354
674
 
675
+ /**
676
+ * Step *into* a table with the arrow keys.
677
+ *
678
+ * CodeMirror moves the caret over a block widget, not through it:
679
+ * pressing Down on the line above a rendered table lands on the
680
+ * line below it, and the table's markdown is unreachable without a
681
+ * mouse. That is fine for a widget with nothing to edit inside;
682
+ * this one is four lines of the user's own text.
683
+ *
684
+ * So the vertical move is inspected before it happens, and when it
685
+ * would jump a table, the caret is put at that table's edge
686
+ * instead — the near edge, so Down enters at the top and Up enters
687
+ * at the bottom. The table stands down on the next update and the
688
+ * pipes are there with the caret already in them.
689
+ *
690
+ * Only for a collapsed selection: Shift+Down is *selecting* across
691
+ * the table, where jumping it is right — the text is still in the
692
+ * document and still in the selection.
693
+ */
694
+ const toTableStep =
695
+ (isForward: boolean) => (view: EditorView) => {
696
+ const { main } = view.state.selection
697
+
698
+ if (!main.empty) {
699
+ return false
700
+ }
701
+
702
+ const destination = view.moveVertically(
703
+ main,
704
+ isForward,
705
+ ).head
706
+
707
+ const from = Math.min(main.head, destination)
708
+
709
+ const to = Math.max(main.head, destination)
710
+
711
+ if (from === to) {
712
+ return false
713
+ }
714
+
715
+ let target: number | null = null
716
+
717
+ view.state
718
+ .field(livePreviewTableField)
719
+ .between(from, to, (rangeFrom, rangeTo) => {
720
+ target = isForward ? rangeFrom : rangeTo
721
+
722
+ return false
723
+ })
724
+
725
+ if (target === null) {
726
+ return false
727
+ }
728
+
729
+ view.dispatch({
730
+ scrollIntoView: true,
731
+ selection: { anchor: target },
732
+ })
733
+
734
+ return true
735
+ }
736
+
737
+ /**
738
+ * `Prec.high` because the default keymap also binds these, and the
739
+ * first handler that returns `true` wins. Registration order in the
740
+ * extension array is not a promise about precedence.
741
+ */
742
+ const tableStepKeymap = Prec.high(
743
+ keymap.of([
744
+ { key: "ArrowDown", run: toTableStep(true) },
745
+ { key: "ArrowUp", run: toTableStep(false) },
746
+ ]),
747
+ )
748
+
355
749
  /**
356
750
  * Open the link under the pointer.
357
751
  *
@@ -496,9 +890,70 @@ const livePreviewTheme = EditorView.theme({
496
890
  ".cm-md-strong": {
497
891
  fontWeight: "700",
498
892
  },
893
+ /**
894
+ * The *editing* view of a table: the pipes, monospaced so the
895
+ * columns line up as typed. This is what the caret reveals, and
896
+ * what raw mode shows.
897
+ */
499
898
  ".cm-md-table": {
500
899
  fontFamily: "var(--font-mono)",
501
900
  },
901
+ ".cm-md-table-rendered": {
902
+ borderCollapse: "collapse",
903
+ },
904
+ ".cm-md-table-rendered td, .cm-md-table-rendered th": {
905
+ border: "1px solid var(--color-border-subtle)",
906
+ cursor: "text",
907
+ /**
908
+ * Undo the editor's own wrapping rules inside a cell.
909
+ *
910
+ * `.cm-lineWrapping` sets `overflow-wrap: anywhere` so a long
911
+ * unbroken line cannot escape the gutter, and a table cell
912
+ * inherits it — which in the Narrow View broke `Port` across
913
+ * two lines as `Por` / `t`. A cell wraps at spaces like prose,
914
+ * and breaks a word only when the word alone will not fit.
915
+ */
916
+ overflowWrap: "break-word",
917
+ padding: "var(--space-1) var(--space-2)",
918
+ verticalAlign: "top",
919
+ whiteSpace: "normal",
920
+ wordBreak: "normal",
921
+ },
922
+ /**
923
+ * A blank cell still owns a row's worth of height.
924
+ *
925
+ * Without it a row whose cells are all empty collapses to its
926
+ * padding and the table looks like it lost a row — which, in a
927
+ * surface where a blank cell is how you say "nothing here yet",
928
+ * is the wrong answer to a legitimate table.
929
+ */
930
+ ".cm-md-table-rendered td:empty::after, .cm-md-table-rendered th:empty::after":
931
+ {
932
+ content: '"\\200b"',
933
+ },
934
+ /**
935
+ * No `text-align` here: the delimiter row aligns the header cell
936
+ * along with its column, and that is set inline per cell. A rule
937
+ * would be dead code that looks like it is doing the work.
938
+ */
939
+ ".cm-md-table-rendered th": {
940
+ backgroundColor: "var(--color-surface-sunken)",
941
+ fontWeight: "var(--font-weight-semibold)",
942
+ },
943
+ /**
944
+ * The scroll box, not the table.
945
+ *
946
+ * A table wider than the editor has to go somewhere. Letting it
947
+ * overflow puts columns under the gutter or off the edge of a
948
+ * wrapped line; scrolling the block is the same answer every
949
+ * document renderer lands on, and it keeps the *editor* from
950
+ * scrolling sideways as a whole.
951
+ */
952
+ ".cm-md-table-scroll": {
953
+ margin: "var(--space-2) 0",
954
+ maxWidth: "100%",
955
+ overflowX: "auto",
956
+ },
502
957
  ".cm-md-task": {
503
958
  accentColor: "var(--color-intent-accent-solid)",
504
959
  marginInlineEnd: "0.35em",
@@ -528,8 +983,15 @@ const rawModeClass = EditorView.editorAttributes.of(
528
983
 
529
984
  export const livePreview = (): Extension => [
530
985
  livePreviewRawModeField,
986
+ isEditorFocusedField,
987
+ focusTracker,
988
+ // The block pass, before the plugin: a field's decorations are
989
+ // resolved first anyway, but declaring it first keeps the reading
990
+ // order the same as the drawing order.
991
+ livePreviewTableField,
531
992
  livePreviewPlugin,
532
993
  livePreviewTheme,
533
994
  openLinkOnClick,
995
+ tableStepKeymap,
534
996
  rawModeClass,
535
997
  ]