@charcuterie/ui 3.2.0 → 3.4.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/Field/Field.d.ts +1 -1
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +19 -1
- package/dist/Field/Field.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.js +21 -8
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
- package/dist/MarkdownEditor/markdownCommands.d.ts +32 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -1
- package/dist/MarkdownEditor/markdownCommands.js +47 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -1
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
- package/dist/MarkdownEditor/markdownSpans.js +30 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
- package/dist/Select/Select.d.ts +43 -2
- package/dist/Select/Select.d.ts.map +1 -1
- package/dist/Select/Select.js +27 -2
- package/dist/Select/Select.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts +93 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +545 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -0
- package/dist/markdownEditorCodeMirror/index.d.ts +16 -0
- package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/index.js +14 -0
- package/dist/markdownEditorCodeMirror/index.js.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts +40 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreview.js +428 -0
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +128 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +345 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -0
- package/package.json +36 -1
- package/src/Field/Field.mdx +2 -2
- package/src/Field/Field.stories.tsx +9 -5
- package/src/Field/Field.test.tsx +43 -6
- package/src/Field/Field.tsx +19 -1
- package/src/MarkdownEditor/MarkdownEditor.tsx +33 -7
- package/src/MarkdownEditor/markdownCommands.test.ts +55 -0
- package/src/MarkdownEditor/markdownCommands.ts +60 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +65 -0
- package/src/MarkdownEditor/markdownSpans.ts +35 -0
- package/src/Select/Select.mdx +27 -1
- package/src/Select/Select.stories.tsx +6 -1
- package/src/Select/Select.tsx +43 -2
- package/src/index.ts +5 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +144 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +147 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +866 -0
- package/src/markdownEditorCodeMirror/index.ts +26 -0
- package/src/markdownEditorCodeMirror/livePreview.ts +535 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +431 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +581 -0
- package/src/sourceRules.test.ts +20 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@charcuterie/ui/markdown-editor-codemirror` — the opt-in live
|
|
3
|
+
* preview surface.
|
|
4
|
+
*
|
|
5
|
+
* Its own entry point, and every CodeMirror package it needs is an
|
|
6
|
+
* **optional** peer, so a consumer that never imports this subpath
|
|
7
|
+
* installs none of it and bundles none of it. Same packaging as
|
|
8
|
+
* `@charcuterie/ui/react-router`, and the same reason: the capacity
|
|
9
|
+
* is worth having and is not worth charging everybody for.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
livePreview,
|
|
14
|
+
livePreviewRawModeField,
|
|
15
|
+
setLivePreviewRawMode,
|
|
16
|
+
} from "./livePreview.ts"
|
|
17
|
+
export type {
|
|
18
|
+
LivePreviewLineKind,
|
|
19
|
+
LivePreviewMarkKind,
|
|
20
|
+
LivePreviewRange,
|
|
21
|
+
LivePreviewSelection,
|
|
22
|
+
ToLivePreviewRangesOptions,
|
|
23
|
+
} from "./livePreviewRanges.ts"
|
|
24
|
+
export { toLivePreviewRanges } from "./livePreviewRanges.ts"
|
|
25
|
+
export type { MarkdownEditorCodeMirrorProps } from "./MarkdownEditorCodeMirror.tsx"
|
|
26
|
+
export { MarkdownEditorCodeMirror } from "./MarkdownEditorCodeMirror.tsx"
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CodeMirror half of live preview: descriptors → decorations,
|
|
3
|
+
* two widgets, and a theme that reads Charcuterie tokens.
|
|
4
|
+
*
|
|
5
|
+
* `livePreviewRanges.ts` decided *what* to decorate and is pure.
|
|
6
|
+
* This file owns everything that needs `@codemirror/view`, which is
|
|
7
|
+
* also everything that cannot be tested in this package's Node-only
|
|
8
|
+
* Vitest project — so it is kept deliberately thin and mechanical.
|
|
9
|
+
*
|
|
10
|
+
* ### Why the theme uses `var(--color-…)` and not Tailwind classes
|
|
11
|
+
*
|
|
12
|
+
* Every other visual in this package is a Tailwind utility reading
|
|
13
|
+
* a token. CodeMirror is the exception, and for a structural
|
|
14
|
+
* reason: it generates its own scoped class names and injects its
|
|
15
|
+
* own stylesheet, so the DOM these decorations land on is not DOM
|
|
16
|
+
* Tailwind ever scans. Referencing the custom properties directly
|
|
17
|
+
* keeps a scheme flip a repaint — the same behaviour, one layer
|
|
18
|
+
* lower — and avoids a `safelist` that would rot the first time a
|
|
19
|
+
* class here changed.
|
|
20
|
+
*
|
|
21
|
+
* The properties below are the generated token names, not invented
|
|
22
|
+
* ones. That distinction has already cost this fleet a release:
|
|
23
|
+
* Docket shipped invisible priority bars from `--color-danger-9`, a
|
|
24
|
+
* scale that does not exist, and nothing errored. A custom property
|
|
25
|
+
* that resolves to nothing paints nothing, silently.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { syntaxTree } from "@codemirror/language"
|
|
29
|
+
import type { Extension, Range } from "@codemirror/state"
|
|
30
|
+
import { StateEffect, StateField } from "@codemirror/state"
|
|
31
|
+
import type {
|
|
32
|
+
DecorationSet,
|
|
33
|
+
ViewUpdate,
|
|
34
|
+
} from "@codemirror/view"
|
|
35
|
+
import {
|
|
36
|
+
Decoration,
|
|
37
|
+
EditorView,
|
|
38
|
+
ViewPlugin,
|
|
39
|
+
WidgetType,
|
|
40
|
+
} from "@codemirror/view"
|
|
41
|
+
|
|
42
|
+
import type {
|
|
43
|
+
LivePreviewLineKind,
|
|
44
|
+
LivePreviewMarkKind,
|
|
45
|
+
} from "./livePreviewRanges.ts"
|
|
46
|
+
import { toLivePreviewRanges } from "./livePreviewRanges.ts"
|
|
47
|
+
|
|
48
|
+
/** Marks a rendered link so the click handler can find its target. */
|
|
49
|
+
const URL_ATTRIBUTE = "data-charcuterie-url"
|
|
50
|
+
|
|
51
|
+
/** Toggle the "edit Markdown" mode. */
|
|
52
|
+
export const setLivePreviewRawMode =
|
|
53
|
+
StateEffect.define<boolean>()
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Raw mode as editor state rather than a React prop threaded down.
|
|
57
|
+
*
|
|
58
|
+
* It has to live here because the decoration plugin reads it on
|
|
59
|
+
* every update, and because a keybinding should be able to flip it
|
|
60
|
+
* without a round trip through React.
|
|
61
|
+
*/
|
|
62
|
+
export const livePreviewRawModeField =
|
|
63
|
+
StateField.define<boolean>({
|
|
64
|
+
create: () => false,
|
|
65
|
+
update: (isRawMode, transaction) => {
|
|
66
|
+
let isNextRawMode = isRawMode
|
|
67
|
+
|
|
68
|
+
for (const effect of transaction.effects) {
|
|
69
|
+
if (effect.is(setLivePreviewRawMode)) {
|
|
70
|
+
isNextRawMode = effect.value
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return isNextRawMode
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const LINE_CLASSES: Record<LivePreviewLineKind, string> = {
|
|
79
|
+
blockquote: "cm-md-blockquote",
|
|
80
|
+
code: "cm-md-codeblock",
|
|
81
|
+
heading1: "cm-md-heading1",
|
|
82
|
+
heading2: "cm-md-heading2",
|
|
83
|
+
heading3: "cm-md-heading3",
|
|
84
|
+
heading4: "cm-md-heading4",
|
|
85
|
+
heading5: "cm-md-heading5",
|
|
86
|
+
heading6: "cm-md-heading6",
|
|
87
|
+
table: "cm-md-table",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const MARK_CLASSES: Record<LivePreviewMarkKind, string> = {
|
|
91
|
+
autolink: "cm-md-link",
|
|
92
|
+
code: "cm-md-code",
|
|
93
|
+
emphasis: "cm-md-emphasis",
|
|
94
|
+
linkText: "cm-md-link",
|
|
95
|
+
strikethrough: "cm-md-strikethrough",
|
|
96
|
+
strong: "cm-md-strong",
|
|
97
|
+
url: "cm-md-url",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* An image, standing in for its own markup.
|
|
102
|
+
*
|
|
103
|
+
* `eq` compares by URL *and* alt so scrolling past an image reuses
|
|
104
|
+
* the same `<img>` instead of refetching it, while an edit to
|
|
105
|
+
* either still rebuilds. Without it every keystroke elsewhere in
|
|
106
|
+
* the document flashes every image on screen.
|
|
107
|
+
*/
|
|
108
|
+
class ImageWidget extends WidgetType {
|
|
109
|
+
constructor(
|
|
110
|
+
private readonly url: string,
|
|
111
|
+
private readonly alt: string,
|
|
112
|
+
) {
|
|
113
|
+
super()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
eq(other: ImageWidget) {
|
|
117
|
+
return other.url === this.url && other.alt === this.alt
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
toDOM(view: EditorView) {
|
|
121
|
+
const image = document.createElement("img")
|
|
122
|
+
|
|
123
|
+
image.setAttribute("src", this.url)
|
|
124
|
+
|
|
125
|
+
image.setAttribute("alt", this.alt)
|
|
126
|
+
|
|
127
|
+
image.className = "cm-md-image"
|
|
128
|
+
|
|
129
|
+
// The document's height is unknown until the image decodes, and
|
|
130
|
+
// CodeMirror caches line heights. Without a remeasure the lines
|
|
131
|
+
// after a tall image overlap it until something else forces a
|
|
132
|
+
// layout.
|
|
133
|
+
image.addEventListener("load", () => {
|
|
134
|
+
view.requestMeasure()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return image
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A task checkbox, standing in for `[ ]` / `[x]`.
|
|
143
|
+
*
|
|
144
|
+
* The position is read back from the DOM with `posAtDOM` rather
|
|
145
|
+
* than captured in the constructor: a widget outlives the document
|
|
146
|
+
* offsets it was built at, and a stale `from` writes the checkbox
|
|
147
|
+
* into whatever moved into that slot.
|
|
148
|
+
*/
|
|
149
|
+
class TaskWidget extends WidgetType {
|
|
150
|
+
constructor(
|
|
151
|
+
private readonly isChecked: boolean,
|
|
152
|
+
private readonly label: string,
|
|
153
|
+
) {
|
|
154
|
+
super()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
eq(other: TaskWidget) {
|
|
158
|
+
return (
|
|
159
|
+
other.isChecked === this.isChecked &&
|
|
160
|
+
other.label === this.label
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
toDOM(view: EditorView) {
|
|
165
|
+
const checkbox = document.createElement("input")
|
|
166
|
+
|
|
167
|
+
checkbox.type = "checkbox"
|
|
168
|
+
|
|
169
|
+
checkbox.checked = this.isChecked
|
|
170
|
+
|
|
171
|
+
checkbox.className = "cm-md-task"
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The item's own text, as the checkbox's name.
|
|
175
|
+
*
|
|
176
|
+
* A bare `<input type="checkbox">` inside a `contenteditable`
|
|
177
|
+
* has no wrapping `<label>` to inherit from and no `for`
|
|
178
|
+
* pointing at it, so without this it announces as an unlabelled
|
|
179
|
+
* checkbox — and a task list is exactly where "checkbox,
|
|
180
|
+
* checked" with no name is useless. The text is right there on
|
|
181
|
+
* the line; using it means the widget says what the sighted
|
|
182
|
+
* reader sees.
|
|
183
|
+
*/
|
|
184
|
+
checkbox.setAttribute(
|
|
185
|
+
"aria-label",
|
|
186
|
+
this.label === "" ? "Task" : this.label,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
checkbox.addEventListener("mousedown", (event) => {
|
|
190
|
+
// The checkbox owns the click; without this CodeMirror also
|
|
191
|
+
// moves the caret into the widget's slot on the way past.
|
|
192
|
+
event.stopPropagation()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
checkbox.addEventListener("change", () => {
|
|
196
|
+
const from = view.posAtDOM(checkbox)
|
|
197
|
+
|
|
198
|
+
view.dispatch({
|
|
199
|
+
changes: {
|
|
200
|
+
from,
|
|
201
|
+
insert: this.isChecked ? "[ ]" : "[x]",
|
|
202
|
+
to: from + 3,
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
return checkbox
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
ignoreEvent() {
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const toDecorations = (view: EditorView): DecorationSet => {
|
|
216
|
+
const isRawMode = view.state.field(
|
|
217
|
+
livePreviewRawModeField,
|
|
218
|
+
false,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const text = view.state.doc.toString()
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* An unfocused editor reveals nothing.
|
|
225
|
+
*
|
|
226
|
+
* Without this the caret's default resting place — offset 0 —
|
|
227
|
+
* counts as "inside" whatever construct starts the document, so a
|
|
228
|
+
* task whose description opens with a heading rendered its `#` on
|
|
229
|
+
* load and no other heading's. It looked like a concealment bug
|
|
230
|
+
* and was really a focus bug: there is no caret in the document
|
|
231
|
+
* until someone puts one there.
|
|
232
|
+
*/
|
|
233
|
+
const selections = view.hasFocus
|
|
234
|
+
? view.state.selection.ranges.map((range) => ({
|
|
235
|
+
from: range.from,
|
|
236
|
+
to: range.to,
|
|
237
|
+
}))
|
|
238
|
+
: []
|
|
239
|
+
|
|
240
|
+
const decorations: Range<Decoration>[] = []
|
|
241
|
+
|
|
242
|
+
for (const visible of view.visibleRanges) {
|
|
243
|
+
for (const range of toLivePreviewRanges({
|
|
244
|
+
from: visible.from,
|
|
245
|
+
isRawMode: isRawMode ?? false,
|
|
246
|
+
selections,
|
|
247
|
+
text,
|
|
248
|
+
to: visible.to,
|
|
249
|
+
tree: syntaxTree(view.state),
|
|
250
|
+
})) {
|
|
251
|
+
switch (range.type) {
|
|
252
|
+
case "image": {
|
|
253
|
+
decorations.push(
|
|
254
|
+
Decoration.replace({
|
|
255
|
+
widget: new ImageWidget(range.url, range.alt),
|
|
256
|
+
}).range(range.from, range.to),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
break
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
case "line": {
|
|
263
|
+
decorations.push(
|
|
264
|
+
Decoration.line({
|
|
265
|
+
class: LINE_CLASSES[range.lineKind],
|
|
266
|
+
}).range(range.from),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case "mark": {
|
|
273
|
+
decorations.push(
|
|
274
|
+
Decoration.mark({
|
|
275
|
+
attributes: range.url
|
|
276
|
+
? { [URL_ATTRIBUTE]: range.url }
|
|
277
|
+
: undefined,
|
|
278
|
+
class: MARK_CLASSES[range.markKind],
|
|
279
|
+
}).range(range.from, range.to),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
break
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
case "marker": {
|
|
286
|
+
decorations.push(
|
|
287
|
+
range.isConcealed
|
|
288
|
+
? Decoration.replace({}).range(
|
|
289
|
+
range.from,
|
|
290
|
+
range.to,
|
|
291
|
+
)
|
|
292
|
+
: Decoration.mark({
|
|
293
|
+
class: "cm-md-marker",
|
|
294
|
+
}).range(range.from, range.to),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
break
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
case "task": {
|
|
301
|
+
decorations.push(
|
|
302
|
+
Decoration.replace({
|
|
303
|
+
widget: new TaskWidget(
|
|
304
|
+
range.isChecked,
|
|
305
|
+
range.label,
|
|
306
|
+
),
|
|
307
|
+
}).range(range.from, range.to),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
break
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// `true` sorts. The tree walk emits in tree order, which is not
|
|
317
|
+
// the same as decoration order once line decorations and inline
|
|
318
|
+
// replacements interleave, and an unsorted set throws.
|
|
319
|
+
return Decoration.set(decorations, true)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const livePreviewPlugin = ViewPlugin.fromClass(
|
|
323
|
+
class {
|
|
324
|
+
decorations: DecorationSet
|
|
325
|
+
|
|
326
|
+
constructor(view: EditorView) {
|
|
327
|
+
this.decorations = toDecorations(view)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
update(update: ViewUpdate) {
|
|
331
|
+
// Selection is in the list because concealment depends on the
|
|
332
|
+
// caret: moving it with an arrow key changes what is painted
|
|
333
|
+
// even though the document did not change.
|
|
334
|
+
if (
|
|
335
|
+
update.docChanged ||
|
|
336
|
+
update.selectionSet ||
|
|
337
|
+
update.viewportChanged ||
|
|
338
|
+
// Focus is a decoration input now — see `toDecorations`.
|
|
339
|
+
update.focusChanged ||
|
|
340
|
+
update.transactions.some((transaction) =>
|
|
341
|
+
transaction.effects.some((effect) =>
|
|
342
|
+
effect.is(setLivePreviewRawMode),
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
) {
|
|
346
|
+
this.decorations = toDecorations(update.view)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
decorations: (plugin) => plugin.decorations,
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Open the link under the pointer.
|
|
357
|
+
*
|
|
358
|
+
* Plain click, not Ctrl+click, and that is Obsidian's behaviour
|
|
359
|
+
* rather than VS Code's — in a surface where the markup is hidden,
|
|
360
|
+
* a link that looks like a link should act like one. The cost is
|
|
361
|
+
* that clicking link *text* cannot place the caret there; arrow
|
|
362
|
+
* into it from the edge, or use the raw-markdown toggle. That is
|
|
363
|
+
* the same trade Obsidian makes and it is documented in the mdx.
|
|
364
|
+
*
|
|
365
|
+
* `noopener` is not optional: `target="_blank"` without it hands
|
|
366
|
+
* the opened page a live `window.opener` back into this one.
|
|
367
|
+
*/
|
|
368
|
+
const openLinkOnClick = EditorView.domEventHandlers({
|
|
369
|
+
mousedown: (event) => {
|
|
370
|
+
const target = event.target
|
|
371
|
+
|
|
372
|
+
if (!(target instanceof HTMLElement)) {
|
|
373
|
+
return false
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const url = target
|
|
377
|
+
.closest(`[${URL_ATTRIBUTE}]`)
|
|
378
|
+
?.getAttribute(URL_ATTRIBUTE)
|
|
379
|
+
|
|
380
|
+
if (!url) {
|
|
381
|
+
return false
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
event.preventDefault()
|
|
385
|
+
|
|
386
|
+
window.open(url, "_blank", "noopener,noreferrer")
|
|
387
|
+
|
|
388
|
+
return true
|
|
389
|
+
},
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The paint.
|
|
394
|
+
*
|
|
395
|
+
* Headings scale here — which is the one thing the textarea
|
|
396
|
+
* version could never do, because a `<textarea>` and a layer behind
|
|
397
|
+
* it only stay registered while every glyph keeps its advance
|
|
398
|
+
* width. CodeMirror owns the caret, so size is free.
|
|
399
|
+
*/
|
|
400
|
+
const livePreviewTheme = EditorView.theme({
|
|
401
|
+
/**
|
|
402
|
+
* Prose is proportional, and only code is monospaced.
|
|
403
|
+
*
|
|
404
|
+
* The textarea sibling has no choice here — its painted layer only
|
|
405
|
+
* stays registered with the caret while every glyph keeps its
|
|
406
|
+
* advance width, which in practice means one monospaced family for
|
|
407
|
+
* the whole document. This surface has no such constraint, and a
|
|
408
|
+
* description full of prose set in a code font is the single
|
|
409
|
+
* biggest thing that makes an editor *feel* like a code editor
|
|
410
|
+
* rather than like a document.
|
|
411
|
+
*
|
|
412
|
+
* It has to be `.cm-scroller`, not `&`. CodeMirror's own base
|
|
413
|
+
* theme sets `font-family: monospace` there, and a rule on the
|
|
414
|
+
* wrapper loses to it — silently, and it looks like the token
|
|
415
|
+
* failed to resolve rather than like a specificity problem.
|
|
416
|
+
*/
|
|
417
|
+
".cm-scroller": {
|
|
418
|
+
fontFamily: "var(--font-sans)",
|
|
419
|
+
},
|
|
420
|
+
/**
|
|
421
|
+
* …except in raw mode, where monospace is the honest choice:
|
|
422
|
+
* "Markdown source" is a *source* view, and source is code.
|
|
423
|
+
*/
|
|
424
|
+
"&.cm-md-raw .cm-scroller": {
|
|
425
|
+
fontFamily: "var(--font-mono)",
|
|
426
|
+
},
|
|
427
|
+
".cm-md-blockquote": {
|
|
428
|
+
borderInlineStart:
|
|
429
|
+
"3px solid var(--color-intent-accent-border)",
|
|
430
|
+
color: "var(--color-content-secondary)",
|
|
431
|
+
paddingInlineStart: "0.75em",
|
|
432
|
+
},
|
|
433
|
+
".cm-md-code": {
|
|
434
|
+
color: "var(--color-intent-info-content)",
|
|
435
|
+
fontFamily: "var(--font-mono)",
|
|
436
|
+
},
|
|
437
|
+
".cm-md-codeblock": {
|
|
438
|
+
backgroundColor: "var(--color-surface-sunken)",
|
|
439
|
+
fontFamily: "var(--font-mono)",
|
|
440
|
+
},
|
|
441
|
+
".cm-md-emphasis": {
|
|
442
|
+
fontStyle: "italic",
|
|
443
|
+
},
|
|
444
|
+
".cm-md-heading1": {
|
|
445
|
+
fontSize: "1.75em",
|
|
446
|
+
fontWeight: "700",
|
|
447
|
+
lineHeight: "1.25",
|
|
448
|
+
},
|
|
449
|
+
".cm-md-heading2": {
|
|
450
|
+
fontSize: "1.5em",
|
|
451
|
+
fontWeight: "700",
|
|
452
|
+
lineHeight: "1.3",
|
|
453
|
+
},
|
|
454
|
+
".cm-md-heading3": {
|
|
455
|
+
fontSize: "1.25em",
|
|
456
|
+
fontWeight: "600",
|
|
457
|
+
lineHeight: "1.35",
|
|
458
|
+
},
|
|
459
|
+
".cm-md-heading4": {
|
|
460
|
+
fontSize: "1.1em",
|
|
461
|
+
fontWeight: "600",
|
|
462
|
+
},
|
|
463
|
+
".cm-md-heading5": {
|
|
464
|
+
fontWeight: "600",
|
|
465
|
+
},
|
|
466
|
+
".cm-md-heading6": {
|
|
467
|
+
color: "var(--color-content-secondary)",
|
|
468
|
+
fontWeight: "600",
|
|
469
|
+
},
|
|
470
|
+
".cm-md-image": {
|
|
471
|
+
borderRadius: "var(--radius-md)",
|
|
472
|
+
display: "inline-block",
|
|
473
|
+
maxWidth: "100%",
|
|
474
|
+
verticalAlign: "top",
|
|
475
|
+
},
|
|
476
|
+
".cm-md-link": {
|
|
477
|
+
color: "var(--color-intent-accent-content)",
|
|
478
|
+
cursor: "pointer",
|
|
479
|
+
textDecoration: "underline",
|
|
480
|
+
},
|
|
481
|
+
/**
|
|
482
|
+
* The dimmed marker.
|
|
483
|
+
*
|
|
484
|
+
* A token role, never `opacity`. Fading text over a themed
|
|
485
|
+
* background composites to a colour nobody audited, and axe
|
|
486
|
+
* measures the composited value — `content-secondary` at 60% came
|
|
487
|
+
* out at 3.77:1 in the sibling component and failed AA.
|
|
488
|
+
*/
|
|
489
|
+
".cm-md-marker": {
|
|
490
|
+
color: "var(--color-content-muted)",
|
|
491
|
+
},
|
|
492
|
+
".cm-md-strikethrough": {
|
|
493
|
+
color: "var(--color-content-secondary)",
|
|
494
|
+
textDecoration: "line-through",
|
|
495
|
+
},
|
|
496
|
+
".cm-md-strong": {
|
|
497
|
+
fontWeight: "700",
|
|
498
|
+
},
|
|
499
|
+
".cm-md-table": {
|
|
500
|
+
fontFamily: "var(--font-mono)",
|
|
501
|
+
},
|
|
502
|
+
".cm-md-task": {
|
|
503
|
+
accentColor: "var(--color-intent-accent-solid)",
|
|
504
|
+
marginInlineEnd: "0.35em",
|
|
505
|
+
verticalAlign: "middle",
|
|
506
|
+
},
|
|
507
|
+
".cm-md-url": {
|
|
508
|
+
color: "var(--color-content-muted)",
|
|
509
|
+
},
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Everything the live-preview surface needs, as one extension.
|
|
514
|
+
*
|
|
515
|
+
* Order matters only in that the theme must be present for the
|
|
516
|
+
* decoration classes to mean anything; CodeMirror resolves the rest.
|
|
517
|
+
*/
|
|
518
|
+
/**
|
|
519
|
+
* Raw mode, as a class on the editor, so the theme above can react
|
|
520
|
+
* to it in CSS instead of every rule becoming a decoration.
|
|
521
|
+
*/
|
|
522
|
+
const rawModeClass = EditorView.editorAttributes.of(
|
|
523
|
+
(view) =>
|
|
524
|
+
view.state.field(livePreviewRawModeField, false)
|
|
525
|
+
? { class: "cm-md-raw" }
|
|
526
|
+
: null,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
export const livePreview = (): Extension => [
|
|
530
|
+
livePreviewRawModeField,
|
|
531
|
+
livePreviewPlugin,
|
|
532
|
+
livePreviewTheme,
|
|
533
|
+
openLinkOnClick,
|
|
534
|
+
rawModeClass,
|
|
535
|
+
]
|