@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.
Files changed (58) hide show
  1. package/dist/Field/Field.d.ts +1 -1
  2. package/dist/Field/Field.d.ts.map +1 -1
  3. package/dist/Field/Field.js +19 -1
  4. package/dist/Field/Field.js.map +1 -1
  5. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
  6. package/dist/MarkdownEditor/MarkdownEditor.js +21 -8
  7. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
  8. package/dist/MarkdownEditor/markdownCommands.d.ts +32 -0
  9. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -1
  10. package/dist/MarkdownEditor/markdownCommands.js +47 -0
  11. package/dist/MarkdownEditor/markdownCommands.js.map +1 -1
  12. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
  13. package/dist/MarkdownEditor/markdownSpans.js +30 -0
  14. package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
  15. package/dist/Select/Select.d.ts +43 -2
  16. package/dist/Select/Select.d.ts.map +1 -1
  17. package/dist/Select/Select.js +27 -2
  18. package/dist/Select/Select.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts +93 -0
  22. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -0
  23. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +545 -0
  24. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -0
  25. package/dist/markdownEditorCodeMirror/index.d.ts +16 -0
  26. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -0
  27. package/dist/markdownEditorCodeMirror/index.js +14 -0
  28. package/dist/markdownEditorCodeMirror/index.js.map +1 -0
  29. package/dist/markdownEditorCodeMirror/livePreview.d.ts +40 -0
  30. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -0
  31. package/dist/markdownEditorCodeMirror/livePreview.js +428 -0
  32. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -0
  33. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +128 -0
  34. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -0
  35. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +345 -0
  36. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -0
  37. package/package.json +36 -1
  38. package/src/Field/Field.mdx +2 -2
  39. package/src/Field/Field.stories.tsx +9 -5
  40. package/src/Field/Field.test.tsx +43 -6
  41. package/src/Field/Field.tsx +19 -1
  42. package/src/MarkdownEditor/MarkdownEditor.tsx +33 -7
  43. package/src/MarkdownEditor/markdownCommands.test.ts +55 -0
  44. package/src/MarkdownEditor/markdownCommands.ts +60 -0
  45. package/src/MarkdownEditor/markdownSpans.test.ts +65 -0
  46. package/src/MarkdownEditor/markdownSpans.ts +35 -0
  47. package/src/Select/Select.mdx +27 -1
  48. package/src/Select/Select.stories.tsx +6 -1
  49. package/src/Select/Select.tsx +43 -2
  50. package/src/index.ts +5 -0
  51. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +144 -0
  52. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +147 -0
  53. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +866 -0
  54. package/src/markdownEditorCodeMirror/index.ts +26 -0
  55. package/src/markdownEditorCodeMirror/livePreview.ts +535 -0
  56. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +431 -0
  57. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +581 -0
  58. 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
+ ]