@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.
- package/dist/markdownEditorCodeMirror/MarkdownView.d.ts +140 -0
- package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/MarkdownView.js +268 -0
- package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -0
- package/dist/markdownEditorCodeMirror/index.d.ts +14 -3
- package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/index.js +12 -3
- package/dist/markdownEditorCodeMirror/index.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.d.ts +42 -2
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +298 -28
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +141 -10
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
- package/dist/markdownEditorCodeMirror/safeUrls.d.ts +46 -0
- package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/safeUrls.js +123 -0
- package/dist/markdownEditorCodeMirror/safeUrls.js.map +1 -0
- package/package.json +1 -1
- package/src/MarkdownEditor/MarkdownEditor.mdx +5 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +11 -0
- package/src/markdownEditorCodeMirror/MarkdownView.mdx +173 -0
- package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +362 -0
- package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +349 -0
- package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
- package/src/markdownEditorCodeMirror/index.ts +17 -2
- package/src/markdownEditorCodeMirror/livePreview.ts +391 -35
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +233 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +192 -10
- package/src/markdownEditorCodeMirror/safeUrls.test.ts +0 -0
- 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
|
-
|
|
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
|
-
|
|
482
|
+
element.className = segment.markKinds
|
|
312
483
|
.map((markKind) => MARK_CLASSES[markKind])
|
|
313
484
|
.join(" ")
|
|
314
485
|
|
|
315
486
|
if (segment.url) {
|
|
316
|
-
|
|
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
|
-
|
|
498
|
+
element.textContent = segment.text
|
|
320
499
|
|
|
321
|
-
return
|
|
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(
|
|
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
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
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 =
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
?
|
|
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
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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 =
|
|
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 =
|
|
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 = (
|
|
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
|
-
|
|
995
|
-
|
|
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
|
]
|