@charcuterie/ui 3.13.0 → 3.14.1

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.
Files changed (32) hide show
  1. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts +140 -0
  2. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -0
  3. package/dist/markdownEditorCodeMirror/MarkdownView.js +268 -0
  4. package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -0
  5. package/dist/markdownEditorCodeMirror/index.d.ts +14 -3
  6. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
  7. package/dist/markdownEditorCodeMirror/index.js +12 -3
  8. package/dist/markdownEditorCodeMirror/index.js.map +1 -1
  9. package/dist/markdownEditorCodeMirror/livePreview.d.ts +42 -2
  10. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
  11. package/dist/markdownEditorCodeMirror/livePreview.js +298 -28
  12. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
  13. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
  14. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +141 -10
  15. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  16. package/dist/markdownEditorCodeMirror/safeUrls.d.ts +46 -0
  17. package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +1 -0
  18. package/dist/markdownEditorCodeMirror/safeUrls.js +123 -0
  19. package/dist/markdownEditorCodeMirror/safeUrls.js.map +1 -0
  20. package/package.json +1 -1
  21. package/src/MarkdownEditor/MarkdownEditor.mdx +5 -0
  22. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +11 -0
  23. package/src/markdownEditorCodeMirror/MarkdownView.mdx +173 -0
  24. package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +362 -0
  25. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +349 -0
  26. package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
  27. package/src/markdownEditorCodeMirror/index.ts +17 -2
  28. package/src/markdownEditorCodeMirror/livePreview.ts +391 -35
  29. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +233 -0
  30. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +192 -10
  31. package/src/markdownEditorCodeMirror/safeUrls.test.ts +0 -0
  32. package/src/markdownEditorCodeMirror/safeUrls.ts +142 -0
@@ -32,6 +32,7 @@ import type {
32
32
  Range,
33
33
  } from "@codemirror/state"
34
34
  import {
35
+ Facet,
35
36
  Prec,
36
37
  StateEffect,
37
38
  StateField,
@@ -76,6 +77,72 @@ const URL_ATTRIBUTE = "data-charcuterie-url"
76
77
  */
77
78
  const CELL_OFFSET_ATTRIBUTE = "data-charcuterie-cell-offset"
78
79
 
80
+ /**
81
+ * What kind of surface this is, and what its checkboxes may do.
82
+ *
83
+ * A facet rather than closure state, for the same reason raw mode is
84
+ * a `StateField`: the decoration builders below are module-level
85
+ * singletons — one `ViewPlugin`, one `StateField` — and rebuilding
86
+ * them per instance would mean a second copy of the whole live
87
+ * preview for every surface on the page. They read the answer out of
88
+ * the state they were already handed.
89
+ *
90
+ * It is also what lets `MarkdownView` put the task flag in a
91
+ * `Compartment` and flip it without discarding the view, exactly as
92
+ * the editors already flip editability.
93
+ */
94
+ export type LivePreviewOptions = {
95
+ /**
96
+ * Draw a **document** rather than an editing surface: real
97
+ * anchors, heading roles, and a table that is a table instead of a
98
+ * way back into the markdown behind it.
99
+ *
100
+ * Off by default, because the editors are the surface this module
101
+ * was written for and every one of these would be wrong inside a
102
+ * `contenteditable`.
103
+ */
104
+ hasDocumentSemantics?: boolean
105
+ /** Whether a `- [ ]` checkbox can be ticked. Defaults to `true`. */
106
+ isTaskListInteractive?: boolean
107
+ }
108
+
109
+ /**
110
+ * The facet itself is exported so a consumer can put **only this**
111
+ * in a `Compartment` and flip one answer without reconfiguring the
112
+ * whole extension — which would tear down and recreate the
113
+ * `StateField`s beside it.
114
+ *
115
+ * A later input wins per key, and a key nobody stated keeps the
116
+ * default. That is a merge rather than "the last one wins outright",
117
+ * so an override that only cares about checkboxes cannot silently
118
+ * turn document semantics back off.
119
+ */
120
+ export const livePreviewOptions = Facet.define<
121
+ LivePreviewOptions,
122
+ Required<LivePreviewOptions>
123
+ >({
124
+ combine: (values) =>
125
+ values.reduce<Required<LivePreviewOptions>>(
126
+ (merged, value) => ({
127
+ hasDocumentSemantics:
128
+ value.hasDocumentSemantics ??
129
+ merged.hasDocumentSemantics,
130
+ isTaskListInteractive:
131
+ value.isTaskListInteractive ??
132
+ merged.isTaskListInteractive,
133
+ }),
134
+ {
135
+ hasDocumentSemantics: false,
136
+ isTaskListInteractive: true,
137
+ },
138
+ ),
139
+ })
140
+
141
+ const toOptions = (
142
+ state: EditorState,
143
+ ): Required<LivePreviewOptions> =>
144
+ state.facet(livePreviewOptions)
145
+
79
146
  /** Toggle the "edit Markdown" mode. */
80
147
  export const setLivePreviewRawMode =
81
148
  StateEffect.define<boolean>()
@@ -138,6 +205,66 @@ const focusTracker = EditorView.focusChangeEffect.of(
138
205
  (_state, isFocusing) => setIsEditorFocused.of(isFocusing),
139
206
  )
140
207
 
208
+ /**
209
+ * How the read view says "heading" to something that is not looking
210
+ * at the font size.
211
+ *
212
+ * The editing surface deliberately has none of this. Its content is
213
+ * a `role="textbox"`, and ARIA flattens a textbox's contents to
214
+ * text — a heading role inside one is not exposed, so adding it
215
+ * there would be a decoration that lies about what it does. The read
216
+ * view is a `role="article"`, where the same markup is a real
217
+ * heading and the reader's H key steps between sections.
218
+ *
219
+ * A `Decoration.line` carries the role, which is exactly right for
220
+ * an ATX heading: the parser only ever emits `ATXHeading1`…`6`, and
221
+ * one of those is one line by definition.
222
+ */
223
+ const HEADING_LEVELS: Partial<
224
+ Record<LivePreviewLineKind, string>
225
+ > = {
226
+ heading1: "1",
227
+ heading2: "2",
228
+ heading3: "3",
229
+ heading4: "4",
230
+ heading5: "5",
231
+ heading6: "6",
232
+ }
233
+
234
+ const toLineAttributes = (
235
+ lineKind: LivePreviewLineKind,
236
+ hasDocumentSemantics: boolean,
237
+ ) => {
238
+ const level = HEADING_LEVELS[lineKind]
239
+
240
+ return hasDocumentSemantics && level
241
+ ? { "aria-level": level, role: "heading" }
242
+ : undefined
243
+ }
244
+
245
+ /**
246
+ * A link's DOM, in a surface where a real anchor is allowed.
247
+ *
248
+ * The editor cannot have one. Its content is `contenteditable`, and
249
+ * an `<a href>` inside an editable region is a focus target the
250
+ * caret has to share the line with — which is why the editor paints
251
+ * a `<span>` and opens the URL from a `mousedown` handler instead,
252
+ * and why clicking link *text* there cannot place a caret.
253
+ *
254
+ * A read view has no caret to protect, so the link is simply a link:
255
+ * tabbable, activated by Enter, copyable from the context menu, and
256
+ * announced as a link rather than as coloured text. That difference
257
+ * is the one place the two surfaces deliberately emit different DOM,
258
+ * and it is a strictly larger set of things a reader can do.
259
+ */
260
+ const toLinkAttributes = (url: string) => ({
261
+ href: url,
262
+ // Not optional with `target="_blank"`: without it the opened page
263
+ // gets a live `window.opener` back into this one.
264
+ rel: "noopener noreferrer",
265
+ target: "_blank",
266
+ })
267
+
141
268
  const LINE_CLASSES: Record<LivePreviewLineKind, string> = {
142
269
  blockquote: "cm-md-blockquote",
143
270
  code: "cm-md-codeblock",
@@ -187,6 +314,15 @@ class ImageWidget extends WidgetType {
187
314
 
188
315
  image.setAttribute("alt", this.alt)
189
316
 
317
+ /**
318
+ * A remote image in a document nobody here wrote is a beacon:
319
+ * fetching it tells its host that this reader opened this file,
320
+ * and the default `Referer` tells it which page they opened it
321
+ * from. The image is still fetched — that is what rendering it
322
+ * means — but it learns nothing about where from.
323
+ */
324
+ image.setAttribute("referrerpolicy", "no-referrer")
325
+
190
326
  image.className = "cm-md-image"
191
327
 
192
328
  // The document's height is unknown until the image decodes, and
@@ -213,6 +349,7 @@ class TaskWidget extends WidgetType {
213
349
  constructor(
214
350
  private readonly isChecked: boolean,
215
351
  private readonly label: string,
352
+ private readonly isInteractive: boolean,
216
353
  ) {
217
354
  super()
218
355
  }
@@ -220,6 +357,7 @@ class TaskWidget extends WidgetType {
220
357
  eq(other: TaskWidget) {
221
358
  return (
222
359
  other.isChecked === this.isChecked &&
360
+ other.isInteractive === this.isInteractive &&
223
361
  other.label === this.label
224
362
  )
225
363
  }
@@ -249,6 +387,30 @@ class TaskWidget extends WidgetType {
249
387
  this.label === "" ? "Task" : this.label,
250
388
  )
251
389
 
390
+ /**
391
+ * A read view's checkbox is **inert by default**, and `disabled`
392
+ * rather than hidden or repainted as a glyph.
393
+ *
394
+ * The state is a fact about the document and belongs on screen;
395
+ * the ability to change it is not, when the document is a file
396
+ * fetched at a commit hash and there is nowhere for a tick to go.
397
+ * A disabled checkbox says both of those at once — it shows
398
+ * checked or unchecked, and it announces "unavailable", so the
399
+ * reader is never left clicking something that swallows the
400
+ * click.
401
+ *
402
+ * A consumer that *does* have somewhere to put the change passes
403
+ * `onToggleTask`, and this comes back to life. That is the same
404
+ * shape as `onUploadImage` on the editors: omit the handler and
405
+ * the capability is simply not offered, which beats offering one
406
+ * that half works.
407
+ */
408
+ if (!this.isInteractive) {
409
+ checkbox.disabled = true
410
+
411
+ return checkbox
412
+ }
413
+
252
414
  checkbox.addEventListener("mousedown", (event) => {
253
415
  // The checkbox owns the click; without this CodeMirror also
254
416
  // moves the caret into the widget's slot on the way past.
@@ -285,6 +447,7 @@ class TaskWidget extends WidgetType {
285
447
  const toSegmentNode = (
286
448
  segment: LivePreviewTableSegment,
287
449
  view: EditorView,
450
+ hasDocumentSemantics: boolean,
288
451
  ) => {
289
452
  if (segment.type === "image") {
290
453
  const image = document.createElement("img")
@@ -293,6 +456,8 @@ const toSegmentNode = (
293
456
 
294
457
  image.setAttribute("alt", segment.alt)
295
458
 
459
+ image.setAttribute("referrerpolicy", "no-referrer")
460
+
296
461
  image.className = "cm-md-image"
297
462
 
298
463
  image.addEventListener("load", () => {
@@ -306,19 +471,33 @@ const toSegmentNode = (
306
471
  return document.createTextNode(segment.text)
307
472
  }
308
473
 
309
- const span = document.createElement("span")
474
+ // A link in a cell follows the same rule as a link in a
475
+ // paragraph: an anchor where an anchor is allowed, a painted span
476
+ // with a click handler where the caret owns the line.
477
+ const element: HTMLElement =
478
+ segment.url && hasDocumentSemantics
479
+ ? document.createElement("a")
480
+ : document.createElement("span")
310
481
 
311
- span.className = segment.markKinds
482
+ element.className = segment.markKinds
312
483
  .map((markKind) => MARK_CLASSES[markKind])
313
484
  .join(" ")
314
485
 
315
486
  if (segment.url) {
316
- span.setAttribute(URL_ATTRIBUTE, segment.url)
487
+ if (hasDocumentSemantics) {
488
+ for (const [name, value] of Object.entries(
489
+ toLinkAttributes(segment.url),
490
+ )) {
491
+ element.setAttribute(name, value)
492
+ }
493
+ } else {
494
+ element.setAttribute(URL_ATTRIBUTE, segment.url)
495
+ }
317
496
  }
318
497
 
319
- span.textContent = segment.text
498
+ element.textContent = segment.text
320
499
 
321
- return span
500
+ return element
322
501
  }
323
502
 
324
503
  /**
@@ -346,6 +525,7 @@ class TableWidget extends WidgetType {
346
525
  private readonly source: string,
347
526
  private readonly rows: readonly LivePreviewTableRow[],
348
527
  private readonly tableFrom: number,
528
+ private readonly hasDocumentSemantics: boolean,
349
529
  ) {
350
530
  super()
351
531
  }
@@ -383,7 +563,13 @@ class TableWidget extends WidgetType {
383
563
  )
384
564
 
385
565
  for (const segment of cell.segments) {
386
- cellElement.append(toSegmentNode(segment, view))
566
+ cellElement.append(
567
+ toSegmentNode(
568
+ segment,
569
+ view,
570
+ this.hasDocumentSemantics,
571
+ ),
572
+ )
387
573
  }
388
574
 
389
575
  rowElement.append(cellElement)
@@ -400,6 +586,34 @@ class TableWidget extends WidgetType {
400
586
 
401
587
  scroll.append(table)
402
588
 
589
+ /**
590
+ * The way back into the markdown, and only where there is one.
591
+ *
592
+ * A read view has no caret to put in a cell, so this handler
593
+ * would be a click that steals the selection and gives nothing
594
+ * back — and `cursor: text` on the cells would be advertising
595
+ * an edit that cannot happen. The table there is simply a table.
596
+ */
597
+ if (this.hasDocumentSemantics) {
598
+ scroll.classList.add("cm-md-table-static")
599
+
600
+ /**
601
+ * A scroll box a keyboard can reach.
602
+ *
603
+ * A table wider than its column scrolls sideways, and in the
604
+ * editor that box is inside a `contenteditable` — already
605
+ * focusable, already scrollable with the arrow keys. Here it
606
+ * is inside content that is deliberately *not* a tab stop, so
607
+ * without this the only way to see column four is a pointer.
608
+ * axe reports it as `scrollable-region-focusable` and is
609
+ * right: it is WCAG 2.1.1, and it is the one thing a read view
610
+ * has to add back that the editor got for free.
611
+ */
612
+ scroll.tabIndex = 0
613
+
614
+ return scroll
615
+ }
616
+
403
617
  scroll.addEventListener("mousedown", (event) => {
404
618
  const target = event.target
405
619
 
@@ -458,24 +672,36 @@ const toDecorations = (view: EditorView): DecorationSet => {
458
672
  false,
459
673
  )
460
674
 
675
+ const { hasDocumentSemantics, isTaskListInteractive } =
676
+ toOptions(view.state)
677
+
461
678
  const text = view.state.doc.toString()
462
679
 
463
680
  /**
464
- * An unfocused editor reveals nothing.
681
+ * An unfocused editor reveals nothing, and a document reveals
682
+ * nothing ever.
683
+ *
684
+ * Without the focus half, the caret's default resting place —
685
+ * offset 0 — counts as "inside" whatever construct starts the
686
+ * document, so a task whose description opens with a heading
687
+ * rendered its `#` on load and no other heading's. It looked like
688
+ * a concealment bug and was really a focus bug: there is no caret
689
+ * in the document until someone puts one there.
465
690
  *
466
- * Without this the caret's default resting place — offset 0 —
467
- * counts as "inside" whatever construct starts the document, so a
468
- * task whose description opens with a heading rendered its `#` on
469
- * load and no other heading's. It looked like a concealment bug
470
- * and was really a focus bug: there is no caret in the document
471
- * until someone puts one there.
691
+ * The document half is stated rather than inherited. A read view's
692
+ * content is not editable, so it cannot take focus and `hasFocus`
693
+ * is already false — but that is a property of how CodeMirror
694
+ * happens to treat a `contenteditable="false"` element, and
695
+ * "markup never comes back in a read view" is a promise the
696
+ * component makes, not a side effect it is relying on.
472
697
  */
473
- const selections = view.hasFocus
474
- ? view.state.selection.ranges.map((range) => ({
475
- from: range.from,
476
- to: range.to,
477
- }))
478
- : []
698
+ const selections =
699
+ view.hasFocus && !hasDocumentSemantics
700
+ ? view.state.selection.ranges.map((range) => ({
701
+ from: range.from,
702
+ to: range.to,
703
+ }))
704
+ : []
479
705
 
480
706
  const decorations: Range<Decoration>[] = []
481
707
 
@@ -488,6 +714,31 @@ const toDecorations = (view: EditorView): DecorationSet => {
488
714
  to: visible.to,
489
715
  tree: syntaxTree(view.state),
490
716
  })) {
717
+ /**
718
+ * One malformed descriptor costs its own construct, never the
719
+ * page.
720
+ *
721
+ * `Decoration.mark` throws `Mark decorations may not be
722
+ * empty` on a zero-length range, and a throw in here does not
723
+ * degrade gracefully: CodeMirror destroys the view plugin, so
724
+ * **every** decoration in the document disappears at once and
725
+ * the reader is left looking at raw markdown. That is what
726
+ * ``[`file.md`](path)`` did to whole documents before
727
+ * `toCloseMark` — one link in one paragraph, and the file
728
+ * stopped rendering.
729
+ *
730
+ * `toLivePreviewRanges` is not supposed to emit one of these
731
+ * any more. This is the second wall, and it is worth having
732
+ * precisely because the first wall already failed once.
733
+ *
734
+ * A `line` decoration is the exception, and legitimately so —
735
+ * it is a point anchored to a line start, so it is always
736
+ * zero-length.
737
+ */
738
+ if (range.type !== "line" && range.from >= range.to) {
739
+ continue
740
+ }
741
+
491
742
  switch (range.type) {
492
743
  case "image": {
493
744
  decorations.push(
@@ -502,6 +753,10 @@ const toDecorations = (view: EditorView): DecorationSet => {
502
753
  case "line": {
503
754
  decorations.push(
504
755
  Decoration.line({
756
+ attributes: toLineAttributes(
757
+ range.lineKind,
758
+ hasDocumentSemantics,
759
+ ),
505
760
  class: LINE_CLASSES[range.lineKind],
506
761
  }).range(range.from),
507
762
  )
@@ -513,9 +768,14 @@ const toDecorations = (view: EditorView): DecorationSet => {
513
768
  decorations.push(
514
769
  Decoration.mark({
515
770
  attributes: range.url
516
- ? { [URL_ATTRIBUTE]: range.url }
771
+ ? hasDocumentSemantics
772
+ ? toLinkAttributes(range.url)
773
+ : { [URL_ATTRIBUTE]: range.url }
517
774
  : undefined,
518
775
  class: MARK_CLASSES[range.markKind],
776
+ ...(range.url && hasDocumentSemantics
777
+ ? { tagName: "a" }
778
+ : {}),
519
779
  }).range(range.from, range.to),
520
780
  )
521
781
 
@@ -543,6 +803,7 @@ const toDecorations = (view: EditorView): DecorationSet => {
543
803
  widget: new TaskWidget(
544
804
  range.isChecked,
545
805
  range.label,
806
+ isTaskListInteractive,
546
807
  ),
547
808
  }).range(range.from, range.to),
548
809
  )
@@ -577,15 +838,16 @@ const toTableDecorations = (state: EditorState) => {
577
838
  false,
578
839
  )
579
840
 
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
- : []
841
+ const { hasDocumentSemantics } = toOptions(state)
842
+
843
+ const selections =
844
+ state.field(isEditorFocusedField, false) &&
845
+ !hasDocumentSemantics
846
+ ? state.selection.ranges.map((range) => ({
847
+ from: range.from,
848
+ to: range.to,
849
+ }))
850
+ : []
589
851
 
590
852
  const text = state.doc.toString()
591
853
 
@@ -605,6 +867,7 @@ const toTableDecorations = (state: EditorState) => {
605
867
  text.slice(range.from, range.to),
606
868
  range.rows,
607
869
  range.from,
870
+ hasDocumentSemantics,
608
871
  ),
609
872
  }).range(range.from, range.to),
610
873
  ),
@@ -624,6 +887,11 @@ const livePreviewTableField =
624
887
  effect.is(setLivePreviewRawMode) ||
625
888
  effect.is(setIsEditorFocused),
626
889
  ) ||
890
+ // A `Compartment` reconfigure — `MarkdownView` turning its
891
+ // checkboxes on or off — arrives as neither a doc change
892
+ // nor an effect this field would otherwise notice.
893
+ transaction.startState.facet(livePreviewOptions) !==
894
+ transaction.state.facet(livePreviewOptions) ||
627
895
  // Parsing is incremental and time-sliced, so a long
628
896
  // document's tables arrive over several transactions that
629
897
  // change nothing else. Comparing the tree by identity is
@@ -639,12 +907,47 @@ const livePreviewTableField =
639
907
  provide: (field) => EditorView.decorations.from(field),
640
908
  })
641
909
 
910
+ /**
911
+ * `toDecorations`, but a throw costs one render rather than the
912
+ * editor.
913
+ *
914
+ * CodeMirror's answer to a view plugin that throws is to destroy
915
+ * it. That is permanent for the life of the editor: the document
916
+ * falls back to raw markdown and no later keystroke brings it back,
917
+ * because there is no longer a plugin to run. The reader sees a
918
+ * surface that broke and stayed broken, and the only trace is one
919
+ * console line nobody is looking at.
920
+ *
921
+ * Returning an empty set instead costs the *current* frame — that
922
+ * frame looks like raw source — and the next update repaints
923
+ * normally. Recovery beats a permanent failure.
924
+ *
925
+ * The stale set is deliberately not reused. Decorations here are
926
+ * not mapped through document changes, so holding the previous
927
+ * frame's offsets over an edit invites a second throw for
928
+ * positions past the end of the document.
929
+ */
930
+ const toSafeDecorations = (
931
+ view: EditorView,
932
+ ): DecorationSet => {
933
+ try {
934
+ return toDecorations(view)
935
+ } catch (error) {
936
+ console.error(
937
+ "[charcuterie] live preview could not build its decorations; this frame renders as raw markdown.",
938
+ error,
939
+ )
940
+
941
+ return Decoration.none
942
+ }
943
+ }
944
+
642
945
  const livePreviewPlugin = ViewPlugin.fromClass(
643
946
  class {
644
947
  decorations: DecorationSet
645
948
 
646
949
  constructor(view: EditorView) {
647
- this.decorations = toDecorations(view)
950
+ this.decorations = toSafeDecorations(view)
648
951
  }
649
952
 
650
953
  update(update: ViewUpdate) {
@@ -661,9 +964,14 @@ const livePreviewPlugin = ViewPlugin.fromClass(
661
964
  transaction.effects.some((effect) =>
662
965
  effect.is(setLivePreviewRawMode),
663
966
  ),
664
- )
967
+ ) ||
968
+ // The same `Compartment` reconfigure the block pass watches
969
+ // for: a checkbox that just became operable is a widget
970
+ // that has to be rebuilt.
971
+ update.startState.facet(livePreviewOptions) !==
972
+ update.state.facet(livePreviewOptions)
665
973
  ) {
666
- this.decorations = toDecorations(update.view)
974
+ this.decorations = toSafeDecorations(update.view)
667
975
  }
668
976
  }
669
977
  },
@@ -872,6 +1180,19 @@ const livePreviewTheme = EditorView.theme({
872
1180
  cursor: "pointer",
873
1181
  textDecoration: "underline",
874
1182
  },
1183
+ /**
1184
+ * The read view's links are real anchors, so they are in the tab
1185
+ * order — and a focus ring is the whole reason that is worth
1186
+ * having. CodeMirror injects its own stylesheet and nothing here
1187
+ * is DOM Tailwind scans, so the ring is written from the same
1188
+ * custom properties the `focus-visible` utilities generate.
1189
+ */
1190
+ ".cm-md-link:focus-visible": {
1191
+ borderRadius: "var(--radius-sm)",
1192
+ outline:
1193
+ "var(--focus-ring-width) solid var(--color-focus-ring)",
1194
+ outlineOffset: "var(--focus-ring-offset)",
1195
+ },
875
1196
  /**
876
1197
  * The dimmed marker.
877
1198
  *
@@ -940,6 +1261,23 @@ const livePreviewTheme = EditorView.theme({
940
1261
  backgroundColor: "var(--color-surface-sunken)",
941
1262
  fontWeight: "var(--font-weight-semibold)",
942
1263
  },
1264
+ /**
1265
+ * A read view's table takes no click, so it stops advertising
1266
+ * one. `cursor: text` on a cell is a promise that clicking there
1267
+ * puts a caret in the markdown behind it, and there is no
1268
+ * markdown behind it to reach.
1269
+ */
1270
+ ".cm-md-table-static .cm-md-table-rendered td, .cm-md-table-static .cm-md-table-rendered th":
1271
+ {
1272
+ cursor: "auto",
1273
+ },
1274
+ /** A focusable box says so when it has focus. */
1275
+ ".cm-md-table-static:focus-visible": {
1276
+ borderRadius: "var(--radius-sm)",
1277
+ outline:
1278
+ "var(--focus-ring-width) solid var(--color-focus-ring)",
1279
+ outlineOffset: "var(--focus-ring-offset)",
1280
+ },
943
1281
  /**
944
1282
  * The scroll box, not the table.
945
1283
  *
@@ -981,7 +1319,10 @@ const rawModeClass = EditorView.editorAttributes.of(
981
1319
  : null,
982
1320
  )
983
1321
 
984
- export const livePreview = (): Extension => [
1322
+ export const livePreview = (
1323
+ options: LivePreviewOptions = {},
1324
+ ): Extension => [
1325
+ livePreviewOptions.of(options),
985
1326
  livePreviewRawModeField,
986
1327
  isEditorFocusedField,
987
1328
  focusTracker,
@@ -991,7 +1332,22 @@ export const livePreview = (): Extension => [
991
1332
  livePreviewTableField,
992
1333
  livePreviewPlugin,
993
1334
  livePreviewTheme,
994
- openLinkOnClick,
995
- tableStepKeymap,
1335
+ /**
1336
+ * The two extensions a **document** does not get, and both are
1337
+ * absences with a reason rather than an oversight.
1338
+ *
1339
+ * `openLinkOnClick` exists because the editor's links are painted
1340
+ * `<span>`s that a `mousedown` has to translate into a
1341
+ * navigation. A document's links are real `<a href>` elements, so
1342
+ * installing this as well would open every link twice — once from
1343
+ * the handler and once from the browser.
1344
+ *
1345
+ * `tableStepKeymap` steps the *caret* into a rendered table so its
1346
+ * markdown can be reached. A document has no caret and nothing
1347
+ * behind the table to reach.
1348
+ */
1349
+ options.hasDocumentSemantics
1350
+ ? []
1351
+ : [openLinkOnClick, tableStepKeymap],
996
1352
  rawModeClass,
997
1353
  ]