@charcuterie/ui 3.13.0 → 3.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/markdownEditorCodeMirror/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 +243 -26
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +49 -7
- 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 +157 -0
- package/src/markdownEditorCodeMirror/MarkdownView.stories.tsx +314 -0
- package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +288 -0
- package/src/markdownEditorCodeMirror/MarkdownView.tsx +373 -0
- package/src/markdownEditorCodeMirror/index.ts +17 -2
- package/src/markdownEditorCodeMirror/livePreview.ts +329 -33
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +64 -7
- 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.
|
|
465
683
|
*
|
|
466
|
-
* Without
|
|
467
|
-
* counts as "inside" whatever construct starts the
|
|
468
|
-
* task whose description opens with a heading
|
|
469
|
-
* load and no other heading's. It looked like
|
|
470
|
-
* and was really a focus bug: there is no caret
|
|
471
|
-
* until someone puts one there.
|
|
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.
|
|
690
|
+
*
|
|
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
|
|
|
@@ -502,6 +728,10 @@ const toDecorations = (view: EditorView): DecorationSet => {
|
|
|
502
728
|
case "line": {
|
|
503
729
|
decorations.push(
|
|
504
730
|
Decoration.line({
|
|
731
|
+
attributes: toLineAttributes(
|
|
732
|
+
range.lineKind,
|
|
733
|
+
hasDocumentSemantics,
|
|
734
|
+
),
|
|
505
735
|
class: LINE_CLASSES[range.lineKind],
|
|
506
736
|
}).range(range.from),
|
|
507
737
|
)
|
|
@@ -513,9 +743,14 @@ const toDecorations = (view: EditorView): DecorationSet => {
|
|
|
513
743
|
decorations.push(
|
|
514
744
|
Decoration.mark({
|
|
515
745
|
attributes: range.url
|
|
516
|
-
?
|
|
746
|
+
? hasDocumentSemantics
|
|
747
|
+
? toLinkAttributes(range.url)
|
|
748
|
+
: { [URL_ATTRIBUTE]: range.url }
|
|
517
749
|
: undefined,
|
|
518
750
|
class: MARK_CLASSES[range.markKind],
|
|
751
|
+
...(range.url && hasDocumentSemantics
|
|
752
|
+
? { tagName: "a" }
|
|
753
|
+
: {}),
|
|
519
754
|
}).range(range.from, range.to),
|
|
520
755
|
)
|
|
521
756
|
|
|
@@ -543,6 +778,7 @@ const toDecorations = (view: EditorView): DecorationSet => {
|
|
|
543
778
|
widget: new TaskWidget(
|
|
544
779
|
range.isChecked,
|
|
545
780
|
range.label,
|
|
781
|
+
isTaskListInteractive,
|
|
546
782
|
),
|
|
547
783
|
}).range(range.from, range.to),
|
|
548
784
|
)
|
|
@@ -577,15 +813,16 @@ const toTableDecorations = (state: EditorState) => {
|
|
|
577
813
|
false,
|
|
578
814
|
)
|
|
579
815
|
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
816
|
+
const { hasDocumentSemantics } = toOptions(state)
|
|
817
|
+
|
|
818
|
+
const selections =
|
|
819
|
+
state.field(isEditorFocusedField, false) &&
|
|
820
|
+
!hasDocumentSemantics
|
|
821
|
+
? state.selection.ranges.map((range) => ({
|
|
822
|
+
from: range.from,
|
|
823
|
+
to: range.to,
|
|
824
|
+
}))
|
|
825
|
+
: []
|
|
589
826
|
|
|
590
827
|
const text = state.doc.toString()
|
|
591
828
|
|
|
@@ -605,6 +842,7 @@ const toTableDecorations = (state: EditorState) => {
|
|
|
605
842
|
text.slice(range.from, range.to),
|
|
606
843
|
range.rows,
|
|
607
844
|
range.from,
|
|
845
|
+
hasDocumentSemantics,
|
|
608
846
|
),
|
|
609
847
|
}).range(range.from, range.to),
|
|
610
848
|
),
|
|
@@ -624,6 +862,11 @@ const livePreviewTableField =
|
|
|
624
862
|
effect.is(setLivePreviewRawMode) ||
|
|
625
863
|
effect.is(setIsEditorFocused),
|
|
626
864
|
) ||
|
|
865
|
+
// A `Compartment` reconfigure — `MarkdownView` turning its
|
|
866
|
+
// checkboxes on or off — arrives as neither a doc change
|
|
867
|
+
// nor an effect this field would otherwise notice.
|
|
868
|
+
transaction.startState.facet(livePreviewOptions) !==
|
|
869
|
+
transaction.state.facet(livePreviewOptions) ||
|
|
627
870
|
// Parsing is incremental and time-sliced, so a long
|
|
628
871
|
// document's tables arrive over several transactions that
|
|
629
872
|
// change nothing else. Comparing the tree by identity is
|
|
@@ -661,7 +904,12 @@ const livePreviewPlugin = ViewPlugin.fromClass(
|
|
|
661
904
|
transaction.effects.some((effect) =>
|
|
662
905
|
effect.is(setLivePreviewRawMode),
|
|
663
906
|
),
|
|
664
|
-
)
|
|
907
|
+
) ||
|
|
908
|
+
// The same `Compartment` reconfigure the block pass watches
|
|
909
|
+
// for: a checkbox that just became operable is a widget
|
|
910
|
+
// that has to be rebuilt.
|
|
911
|
+
update.startState.facet(livePreviewOptions) !==
|
|
912
|
+
update.state.facet(livePreviewOptions)
|
|
665
913
|
) {
|
|
666
914
|
this.decorations = toDecorations(update.view)
|
|
667
915
|
}
|
|
@@ -872,6 +1120,19 @@ const livePreviewTheme = EditorView.theme({
|
|
|
872
1120
|
cursor: "pointer",
|
|
873
1121
|
textDecoration: "underline",
|
|
874
1122
|
},
|
|
1123
|
+
/**
|
|
1124
|
+
* The read view's links are real anchors, so they are in the tab
|
|
1125
|
+
* order — and a focus ring is the whole reason that is worth
|
|
1126
|
+
* having. CodeMirror injects its own stylesheet and nothing here
|
|
1127
|
+
* is DOM Tailwind scans, so the ring is written from the same
|
|
1128
|
+
* custom properties the `focus-visible` utilities generate.
|
|
1129
|
+
*/
|
|
1130
|
+
".cm-md-link:focus-visible": {
|
|
1131
|
+
borderRadius: "var(--radius-sm)",
|
|
1132
|
+
outline:
|
|
1133
|
+
"var(--focus-ring-width) solid var(--color-focus-ring)",
|
|
1134
|
+
outlineOffset: "var(--focus-ring-offset)",
|
|
1135
|
+
},
|
|
875
1136
|
/**
|
|
876
1137
|
* The dimmed marker.
|
|
877
1138
|
*
|
|
@@ -940,6 +1201,23 @@ const livePreviewTheme = EditorView.theme({
|
|
|
940
1201
|
backgroundColor: "var(--color-surface-sunken)",
|
|
941
1202
|
fontWeight: "var(--font-weight-semibold)",
|
|
942
1203
|
},
|
|
1204
|
+
/**
|
|
1205
|
+
* A read view's table takes no click, so it stops advertising
|
|
1206
|
+
* one. `cursor: text` on a cell is a promise that clicking there
|
|
1207
|
+
* puts a caret in the markdown behind it, and there is no
|
|
1208
|
+
* markdown behind it to reach.
|
|
1209
|
+
*/
|
|
1210
|
+
".cm-md-table-static .cm-md-table-rendered td, .cm-md-table-static .cm-md-table-rendered th":
|
|
1211
|
+
{
|
|
1212
|
+
cursor: "auto",
|
|
1213
|
+
},
|
|
1214
|
+
/** A focusable box says so when it has focus. */
|
|
1215
|
+
".cm-md-table-static:focus-visible": {
|
|
1216
|
+
borderRadius: "var(--radius-sm)",
|
|
1217
|
+
outline:
|
|
1218
|
+
"var(--focus-ring-width) solid var(--color-focus-ring)",
|
|
1219
|
+
outlineOffset: "var(--focus-ring-offset)",
|
|
1220
|
+
},
|
|
943
1221
|
/**
|
|
944
1222
|
* The scroll box, not the table.
|
|
945
1223
|
*
|
|
@@ -981,7 +1259,10 @@ const rawModeClass = EditorView.editorAttributes.of(
|
|
|
981
1259
|
: null,
|
|
982
1260
|
)
|
|
983
1261
|
|
|
984
|
-
export const livePreview = (
|
|
1262
|
+
export const livePreview = (
|
|
1263
|
+
options: LivePreviewOptions = {},
|
|
1264
|
+
): Extension => [
|
|
1265
|
+
livePreviewOptions.of(options),
|
|
985
1266
|
livePreviewRawModeField,
|
|
986
1267
|
isEditorFocusedField,
|
|
987
1268
|
focusTracker,
|
|
@@ -991,7 +1272,22 @@ export const livePreview = (): Extension => [
|
|
|
991
1272
|
livePreviewTableField,
|
|
992
1273
|
livePreviewPlugin,
|
|
993
1274
|
livePreviewTheme,
|
|
994
|
-
|
|
995
|
-
|
|
1275
|
+
/**
|
|
1276
|
+
* The two extensions a **document** does not get, and both are
|
|
1277
|
+
* absences with a reason rather than an oversight.
|
|
1278
|
+
*
|
|
1279
|
+
* `openLinkOnClick` exists because the editor's links are painted
|
|
1280
|
+
* `<span>`s that a `mousedown` has to translate into a
|
|
1281
|
+
* navigation. A document's links are real `<a href>` elements, so
|
|
1282
|
+
* installing this as well would open every link twice — once from
|
|
1283
|
+
* the handler and once from the browser.
|
|
1284
|
+
*
|
|
1285
|
+
* `tableStepKeymap` steps the *caret* into a rendered table so its
|
|
1286
|
+
* markdown can be reached. A document has no caret and nothing
|
|
1287
|
+
* behind the table to reach.
|
|
1288
|
+
*/
|
|
1289
|
+
options.hasDocumentSemantics
|
|
1290
|
+
? []
|
|
1291
|
+
: [openLinkOnClick, tableStepKeymap],
|
|
996
1292
|
rawModeClass,
|
|
997
1293
|
]
|
|
@@ -49,6 +49,11 @@ import type {
|
|
|
49
49
|
Tree,
|
|
50
50
|
} from "@lezer/common"
|
|
51
51
|
|
|
52
|
+
import {
|
|
53
|
+
toSafeImageUrl,
|
|
54
|
+
toSafeLinkUrl,
|
|
55
|
+
} from "./safeUrls.ts"
|
|
56
|
+
|
|
52
57
|
/**
|
|
53
58
|
* A block-level treatment applied to a whole line.
|
|
54
59
|
*
|
|
@@ -928,11 +933,21 @@ const toWalker = ({
|
|
|
928
933
|
|
|
929
934
|
const closeMark = openMark?.nextSibling
|
|
930
935
|
|
|
936
|
+
const safeUrl = urlNode
|
|
937
|
+
? toSafeImageUrl(
|
|
938
|
+
text.slice(urlNode.from, urlNode.to),
|
|
939
|
+
)
|
|
940
|
+
: undefined
|
|
941
|
+
|
|
931
942
|
// An image whose caret is inside it is markup again: you
|
|
932
|
-
// cannot edit a URL you cannot see.
|
|
943
|
+
// cannot edit a URL you cannot see. An image whose URL did
|
|
944
|
+
// not survive `toSafeImageUrl` stays markup **always**,
|
|
945
|
+
// which is the honest answer for a document this surface
|
|
946
|
+
// did not write: the reader is shown the source rather than
|
|
947
|
+
// a broken frame or a silently dropped line.
|
|
933
948
|
if (
|
|
934
949
|
!isRawMode &&
|
|
935
|
-
|
|
950
|
+
safeUrl !== undefined &&
|
|
936
951
|
openMark &&
|
|
937
952
|
closeMark &&
|
|
938
953
|
!isInside(selections, node.from, node.to)
|
|
@@ -942,12 +957,27 @@ const toWalker = ({
|
|
|
942
957
|
from: node.from,
|
|
943
958
|
to: node.to,
|
|
944
959
|
type: "image",
|
|
945
|
-
url:
|
|
960
|
+
url: safeUrl,
|
|
946
961
|
})
|
|
947
962
|
|
|
948
963
|
return false
|
|
949
964
|
}
|
|
950
965
|
|
|
966
|
+
/**
|
|
967
|
+
* An image whose URL was refused shows its **whole**
|
|
968
|
+
* construct, markers and all.
|
|
969
|
+
*
|
|
970
|
+
* Not descending is what does it: the ``
|
|
971
|
+
* would otherwise reach the generic concealment rule below
|
|
972
|
+
* and vanish, leaving the alt text welded to a URL with no
|
|
973
|
+
* punctuation between them. This is the same result the
|
|
974
|
+
* refused-link path gets, by the same reasoning — a reader
|
|
975
|
+
* shown the source can see what the file tried to do.
|
|
976
|
+
*/
|
|
977
|
+
if (safeUrl === undefined && !isRawMode) {
|
|
978
|
+
return false
|
|
979
|
+
}
|
|
980
|
+
|
|
951
981
|
return true
|
|
952
982
|
}
|
|
953
983
|
|
|
@@ -966,13 +996,35 @@ const toWalker = ({
|
|
|
966
996
|
|
|
967
997
|
const closeMark = openMark?.nextSibling
|
|
968
998
|
|
|
969
|
-
|
|
999
|
+
const safeUrl = urlNode
|
|
1000
|
+
? toSafeLinkUrl(
|
|
1001
|
+
text.slice(urlNode.from, urlNode.to),
|
|
1002
|
+
)
|
|
1003
|
+
: undefined
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* A link whose URL did not survive the guard is **not a
|
|
1007
|
+
* link at all**, and not a link-coloured span that quietly
|
|
1008
|
+
* does nothing either.
|
|
1009
|
+
*
|
|
1010
|
+
* Returning here skips the `linkText` mark *and* the two
|
|
1011
|
+
* `pushMarker` calls below, so `[click me](javascript:…)`
|
|
1012
|
+
* keeps its brackets, keeps its parentheses and reads as
|
|
1013
|
+
* exactly the characters the file contains. A reader who
|
|
1014
|
+
* can see the trap is better served than one shown a
|
|
1015
|
+
* confident blue word that silently refuses to work.
|
|
1016
|
+
*/
|
|
1017
|
+
if (safeUrl === undefined) {
|
|
1018
|
+
return true
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (openMark && closeMark) {
|
|
970
1022
|
ranges.push({
|
|
971
1023
|
from: openMark.to,
|
|
972
1024
|
markKind: "linkText",
|
|
973
1025
|
to: closeMark.from,
|
|
974
1026
|
type: "mark",
|
|
975
|
-
url:
|
|
1027
|
+
url: safeUrl,
|
|
976
1028
|
})
|
|
977
1029
|
}
|
|
978
1030
|
|
|
@@ -998,16 +1050,21 @@ const toWalker = ({
|
|
|
998
1050
|
// paste a link, get a link, with no `[](…)` typed at all.
|
|
999
1051
|
const parentName = nodeRef.node.parent?.name
|
|
1000
1052
|
|
|
1053
|
+
const safeUrl = toSafeLinkUrl(
|
|
1054
|
+
text.slice(nodeRef.from, nodeRef.to),
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1001
1057
|
if (
|
|
1002
1058
|
parentName !== "Link" &&
|
|
1003
|
-
parentName !== "Image"
|
|
1059
|
+
parentName !== "Image" &&
|
|
1060
|
+
safeUrl !== undefined
|
|
1004
1061
|
) {
|
|
1005
1062
|
ranges.push({
|
|
1006
1063
|
from: nodeRef.from,
|
|
1007
1064
|
markKind: "autolink",
|
|
1008
1065
|
to: nodeRef.to,
|
|
1009
1066
|
type: "mark",
|
|
1010
|
-
url:
|
|
1067
|
+
url: safeUrl,
|
|
1011
1068
|
})
|
|
1012
1069
|
} else {
|
|
1013
1070
|
ranges.push({
|
|
Binary file
|