@charcuterie/ui 3.3.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 (45) hide show
  1. package/dist/Field/Field.d.ts.map +1 -1
  2. package/dist/Field/Field.js +19 -1
  3. package/dist/Field/Field.js.map +1 -1
  4. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
  5. package/dist/MarkdownEditor/MarkdownEditor.js +21 -8
  6. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
  7. package/dist/MarkdownEditor/markdownCommands.d.ts +32 -0
  8. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -1
  9. package/dist/MarkdownEditor/markdownCommands.js +47 -0
  10. package/dist/MarkdownEditor/markdownCommands.js.map +1 -1
  11. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
  12. package/dist/MarkdownEditor/markdownSpans.js +30 -0
  13. package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
  14. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts +93 -0
  15. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -0
  16. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +545 -0
  17. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -0
  18. package/dist/markdownEditorCodeMirror/index.d.ts +16 -0
  19. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -0
  20. package/dist/markdownEditorCodeMirror/index.js +14 -0
  21. package/dist/markdownEditorCodeMirror/index.js.map +1 -0
  22. package/dist/markdownEditorCodeMirror/livePreview.d.ts +40 -0
  23. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -0
  24. package/dist/markdownEditorCodeMirror/livePreview.js +428 -0
  25. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -0
  26. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +128 -0
  27. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -0
  28. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +345 -0
  29. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -0
  30. package/package.json +36 -1
  31. package/src/Field/Field.test.tsx +35 -0
  32. package/src/Field/Field.tsx +18 -0
  33. package/src/MarkdownEditor/MarkdownEditor.tsx +33 -7
  34. package/src/MarkdownEditor/markdownCommands.test.ts +55 -0
  35. package/src/MarkdownEditor/markdownCommands.ts +60 -0
  36. package/src/MarkdownEditor/markdownSpans.test.ts +65 -0
  37. package/src/MarkdownEditor/markdownSpans.ts +35 -0
  38. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +144 -0
  39. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +147 -0
  40. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +866 -0
  41. package/src/markdownEditorCodeMirror/index.ts +26 -0
  42. package/src/markdownEditorCodeMirror/livePreview.ts +535 -0
  43. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +431 -0
  44. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +581 -0
  45. package/src/sourceRules.test.ts +20 -0
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Markdown → the ranges a live-preview surface decorates, as pure
3
+ * tree-in / descriptors-out.
4
+ *
5
+ * This is the half of the CodeMirror editor that decides what a
6
+ * document *looks* like, and it is deliberately the half with no
7
+ * `@codemirror/view` import in it. Descriptors are plain
8
+ * `{ from, to, type }` objects; turning them into `Decoration`s is
9
+ * `livePreview.ts`'s job. The split buys two things:
10
+ *
11
+ * - **These tests run in Node.** `packages/ui`'s Vitest project is
12
+ * `.ts`-only and has no DOM, on purpose. A test here parses real
13
+ * markdown with the real GFM parser and asserts real offsets —
14
+ * no jsdom, no mount, no story.
15
+ * - **The runtime stays incremental.** Tests hand in
16
+ * `markdownLanguage.parser.parse(text)`; the view hands in
17
+ * `syntaxTree(state)`, which CodeMirror has already reparsed
18
+ * incrementally. Same function, same offsets, and the expensive
19
+ * path is never the one under test.
20
+ *
21
+ * ### What separates this from `markdownSpans.ts`
22
+ *
23
+ * The sibling `MarkdownEditor` paints *behind* a `<textarea>`, so
24
+ * every span it emits has to be metric-neutral — same glyph
25
+ * advances, or the caret shears away from the letters. That is the
26
+ * constraint this module does **not** have. CodeMirror owns the
27
+ * caret and reflows around decorations, so a heading can genuinely
28
+ * be larger, `**` can genuinely be hidden, and an image can
29
+ * genuinely replace its own markup.
30
+ *
31
+ * That is the entire reason this subpath exists, and it is also why
32
+ * it is a subpath: the capability costs ~176 KB gz of optional peer
33
+ * and only a consumer that opts in should pay it.
34
+ *
35
+ * ### The one invariant
36
+ *
37
+ * **Concealment never touches the document.** Every descriptor here
38
+ * describes a *view* over an unmodified markdown string. Hiding
39
+ * `**` does not delete it; replacing `![alt](url)` with an image
40
+ * does not rewrite it. The stored value stays byte-for-byte what
41
+ * the user typed, which is the same no-HTML-in-the-data guarantee
42
+ * the textarea version gets structurally — kept here by never
43
+ * having a serialiser at all.
44
+ */
45
+
46
+ import type { SyntaxNode, Tree } from "@lezer/common"
47
+
48
+ /**
49
+ * A block-level treatment applied to a whole line.
50
+ *
51
+ * Headings carry their level because size is the point: this is the
52
+ * "block-level type scaling" the textarea version had to give up.
53
+ */
54
+ export type LivePreviewLineKind =
55
+ | "blockquote"
56
+ | "code"
57
+ | "heading1"
58
+ | "heading2"
59
+ | "heading3"
60
+ | "heading4"
61
+ | "heading5"
62
+ | "heading6"
63
+ | "table"
64
+
65
+ /** An inline treatment applied to a character range. */
66
+ export type LivePreviewMarkKind =
67
+ | "autolink"
68
+ | "code"
69
+ | "emphasis"
70
+ | "linkText"
71
+ | "strikethrough"
72
+ | "strong"
73
+ | "url"
74
+
75
+ export type LivePreviewRange = {
76
+ from: number
77
+ to: number
78
+ } & (
79
+ | {
80
+ /** A rendered image, standing in for its own `![alt](url)`. */
81
+ alt: string
82
+ type: "image"
83
+ url: string
84
+ }
85
+ | {
86
+ /** A rendered task checkbox, standing in for `[ ]` / `[x]`. */
87
+ isChecked: boolean
88
+ /**
89
+ * The item's own text, which becomes the checkbox's
90
+ * accessible name — a checkbox inside a `contenteditable` has
91
+ * no `<label>` to inherit one from.
92
+ */
93
+ label: string
94
+ type: "task"
95
+ }
96
+ | {
97
+ /**
98
+ * Syntax the reader does not need: `**`, `#`, `[`, `](…)`.
99
+ *
100
+ * `isConcealed` is the caret's answer, not the parser's. When
101
+ * the selection is inside the construct — or the whole
102
+ * surface is in raw mode — the marker stays in the flow and
103
+ * is merely dimmed, which is what makes the syntax editable
104
+ * at all.
105
+ */
106
+ isConcealed: boolean
107
+ type: "marker"
108
+ }
109
+ | {
110
+ lineKind: LivePreviewLineKind
111
+ type: "line"
112
+ }
113
+ | {
114
+ markKind: LivePreviewMarkKind
115
+ type: "mark"
116
+ /** Present on `linkText` and `autolink`; the click target. */
117
+ url?: string
118
+ }
119
+ )
120
+
121
+ /** A selection range, in the two fields this module needs. */
122
+ export type LivePreviewSelection = {
123
+ from: number
124
+ to: number
125
+ }
126
+
127
+ export type ToLivePreviewRangesOptions = {
128
+ /**
129
+ * Limit the walk to a slice of the document — the view passes its
130
+ * visible ranges, so a long description costs what is on screen
131
+ * rather than what is stored. Omit both and the whole tree is
132
+ * walked, which is what the tests do.
133
+ */
134
+ from?: number
135
+ /**
136
+ * Raw mode — the "edit Markdown" toggle.
137
+ *
138
+ * Nothing is concealed, nothing is replaced by a widget, and no
139
+ * line is rescaled. Syntax colouring stays, because the point of
140
+ * the toggle is to see the markup, not to lose the highlighting.
141
+ */
142
+ isRawMode?: boolean
143
+ selections: readonly LivePreviewSelection[]
144
+ text: string
145
+ to?: number
146
+ tree: Tree
147
+ }
148
+
149
+ const HEADING_LINE_KINDS: Record<
150
+ string,
151
+ LivePreviewLineKind
152
+ > = {
153
+ ATXHeading1: "heading1",
154
+ ATXHeading2: "heading2",
155
+ ATXHeading3: "heading3",
156
+ ATXHeading4: "heading4",
157
+ ATXHeading5: "heading5",
158
+ ATXHeading6: "heading6",
159
+ }
160
+
161
+ const INLINE_MARK_KINDS: Record<
162
+ string,
163
+ LivePreviewMarkKind
164
+ > = {
165
+ Emphasis: "emphasis",
166
+ InlineCode: "code",
167
+ Strikethrough: "strikethrough",
168
+ StrongEmphasis: "strong",
169
+ }
170
+
171
+ /** Marks that vanish with their construct once the caret leaves. */
172
+ const CONCEALABLE_MARK_NAMES = new Set([
173
+ "CodeMark",
174
+ "EmphasisMark",
175
+ "HeaderMark",
176
+ "LinkMark",
177
+ "QuoteMark",
178
+ "StrikethroughMark",
179
+ ])
180
+
181
+ /**
182
+ * A fence is structure, not noise.
183
+ *
184
+ * `CodeMark` covers both `` ` `` and ```` ``` ````, and concealing
185
+ * the fenced kind leaves a blank line at the top and bottom of the
186
+ * block where the fence used to be — which reads as a rendering bug
187
+ * rather than as tidiness. The language tag goes with it, for the
188
+ * same reason Obsidian keeps it: it is the one part of a code block
189
+ * that says what the code *is*.
190
+ */
191
+ const FENCED_CODE_PARENTS = new Set(["FencedCode"])
192
+
193
+ /**
194
+ * Marks that are always visible, only dimmed.
195
+ *
196
+ * Just the list bullet, and the distinction from `QuoteMark` is the
197
+ * interesting part. A blockquote gets a line decoration — an accent
198
+ * bar down its inline start — so the `>` is *redundant* once that
199
+ * bar is drawn, and Obsidian hides it for exactly that reason. A
200
+ * bullet has no such affordance: hide the `-` and an unordered list
201
+ * becomes indented prose. Obsidian substitutes a `•` glyph there,
202
+ * which means choosing a width, and this module has no business
203
+ * choosing one.
204
+ *
205
+ * So the rule is not "block marks persist" — it is **a mark
206
+ * persists unless something else already says what it said.**
207
+ */
208
+ const PERSISTENT_MARK_NAMES = new Set(["ListMark"])
209
+
210
+ const isInside = (
211
+ selections: readonly LivePreviewSelection[],
212
+ from: number,
213
+ to: number,
214
+ ) =>
215
+ selections.some(
216
+ (selection) =>
217
+ selection.from <= to && selection.to >= from,
218
+ )
219
+
220
+ /** The start offset of the line containing `offset`. */
221
+ const toLineStart = (text: string, offset: number) => {
222
+ const previousBreak = text.lastIndexOf("\n", offset - 1)
223
+
224
+ return previousBreak + 1
225
+ }
226
+
227
+ /** The end offset (exclusive of `\n`) of that line. */
228
+ const toLineEnd = (text: string, offset: number) => {
229
+ const nextBreak = text.indexOf("\n", offset)
230
+
231
+ return nextBreak === -1 ? text.length : nextBreak
232
+ }
233
+
234
+ const toChildNamed = (
235
+ node: SyntaxNode,
236
+ name: string,
237
+ ): SyntaxNode | null => {
238
+ for (
239
+ let child = node.firstChild;
240
+ child;
241
+ child = child.nextSibling
242
+ ) {
243
+ if (child.name === name) {
244
+ return child
245
+ }
246
+ }
247
+
248
+ return null
249
+ }
250
+
251
+ /**
252
+ * A heading's `#` run, plus the whitespace after it.
253
+ *
254
+ * Concealing `#` alone would leave the heading text indented by the
255
+ * space that used to separate them — a ragged left edge that reads
256
+ * as a bug. The parser hands back only the hashes, so the trailing
257
+ * run is walked here.
258
+ */
259
+ const toHeaderMarkEnd = (text: string, to: number) => {
260
+ let end = to
261
+
262
+ while (end < text.length) {
263
+ const character = text[end]
264
+
265
+ if (character !== " " && character !== "\t") {
266
+ break
267
+ }
268
+
269
+ end += 1
270
+ }
271
+
272
+ return end
273
+ }
274
+
275
+ /**
276
+ * Every decoration the document wants, in tree order.
277
+ *
278
+ * Order is not sorted here on purpose — `Decoration.set(ranges,
279
+ * true)` sorts, and doing it twice is the sort of thing that looks
280
+ * free until a document is long.
281
+ */
282
+ export const toLivePreviewRanges = ({
283
+ from,
284
+ isRawMode = false,
285
+ selections,
286
+ text,
287
+ to,
288
+ tree,
289
+ }: ToLivePreviewRangesOptions): LivePreviewRange[] => {
290
+ const ranges: LivePreviewRange[] = []
291
+
292
+ const pushMarker = (
293
+ from: number,
294
+ to: number,
295
+ isRevealed: boolean,
296
+ ) => {
297
+ if (from >= to) {
298
+ return
299
+ }
300
+
301
+ ranges.push({
302
+ from,
303
+ isConcealed: !isRawMode && !isRevealed,
304
+ to,
305
+ type: "marker",
306
+ })
307
+ }
308
+
309
+ tree.iterate({
310
+ from,
311
+ to,
312
+ enter: (nodeRef) => {
313
+ const { name } = nodeRef
314
+
315
+ const headingKind = HEADING_LINE_KINDS[name]
316
+
317
+ if (headingKind) {
318
+ if (!isRawMode) {
319
+ const lineStart = toLineStart(text, nodeRef.from)
320
+
321
+ // Zero-length, at the line start. A line decoration is
322
+ // anchored to a position, not a span — give it a real
323
+ // range and CodeMirror throws rather than paints.
324
+ ranges.push({
325
+ from: lineStart,
326
+ lineKind: headingKind,
327
+ to: lineStart,
328
+ type: "line",
329
+ })
330
+ }
331
+
332
+ return true
333
+ }
334
+
335
+ if (name === "Blockquote" || name === "FencedCode") {
336
+ if (!isRawMode) {
337
+ // One line decoration per line: CodeMirror anchors a line
338
+ // decoration at a line start, so a multi-line block needs
339
+ // one per line rather than one spanning range.
340
+ for (
341
+ let offset = toLineStart(text, nodeRef.from);
342
+ offset < nodeRef.to;
343
+ offset = toLineEnd(text, offset) + 1
344
+ ) {
345
+ ranges.push({
346
+ from: offset,
347
+ lineKind:
348
+ name === "Blockquote"
349
+ ? "blockquote"
350
+ : "code",
351
+ to: offset,
352
+ type: "line",
353
+ })
354
+ }
355
+ }
356
+
357
+ return true
358
+ }
359
+
360
+ if (name === "Table") {
361
+ if (!isRawMode) {
362
+ for (
363
+ let offset = toLineStart(text, nodeRef.from);
364
+ offset < nodeRef.to;
365
+ offset = toLineEnd(text, offset) + 1
366
+ ) {
367
+ ranges.push({
368
+ from: offset,
369
+ lineKind: "table",
370
+ to: offset,
371
+ type: "line",
372
+ })
373
+ }
374
+ }
375
+
376
+ return true
377
+ }
378
+
379
+ const inlineKind = INLINE_MARK_KINDS[name]
380
+
381
+ if (inlineKind) {
382
+ ranges.push({
383
+ from: nodeRef.from,
384
+ markKind: inlineKind,
385
+ to: nodeRef.to,
386
+ type: "mark",
387
+ })
388
+
389
+ return true
390
+ }
391
+
392
+ if (name === "Image") {
393
+ const node = nodeRef.node
394
+
395
+ const urlNode = toChildNamed(node, "URL")
396
+
397
+ const openMark = node.firstChild
398
+
399
+ const closeMark = openMark?.nextSibling
400
+
401
+ // An image whose caret is inside it is markup again: you
402
+ // cannot edit a URL you cannot see.
403
+ if (
404
+ !isRawMode &&
405
+ urlNode &&
406
+ openMark &&
407
+ closeMark &&
408
+ !isInside(selections, node.from, node.to)
409
+ ) {
410
+ ranges.push({
411
+ alt: text.slice(openMark.to, closeMark.from),
412
+ from: node.from,
413
+ to: node.to,
414
+ type: "image",
415
+ url: text.slice(urlNode.from, urlNode.to),
416
+ })
417
+
418
+ return false
419
+ }
420
+
421
+ return true
422
+ }
423
+
424
+ if (name === "Link") {
425
+ const node = nodeRef.node
426
+
427
+ const urlNode = toChildNamed(node, "URL")
428
+
429
+ const isRevealed = isInside(
430
+ selections,
431
+ node.from,
432
+ node.to,
433
+ )
434
+
435
+ const openMark = node.firstChild
436
+
437
+ const closeMark = openMark?.nextSibling
438
+
439
+ if (urlNode && openMark && closeMark) {
440
+ ranges.push({
441
+ from: openMark.to,
442
+ markKind: "linkText",
443
+ to: closeMark.from,
444
+ type: "mark",
445
+ url: text.slice(urlNode.from, urlNode.to),
446
+ })
447
+ }
448
+
449
+ // Children are walked so nested emphasis inside link text
450
+ // still paints, but the marks are emitted here where the
451
+ // whole construct's reveal state is known.
452
+ pushMarker(
453
+ node.from,
454
+ openMark?.to ?? node.from,
455
+ isRevealed,
456
+ )
457
+
458
+ if (closeMark) {
459
+ pushMarker(closeMark.from, node.to, isRevealed)
460
+ }
461
+
462
+ return true
463
+ }
464
+
465
+ if (name === "URL") {
466
+ // A bare URL. GFM's autolink emits a `URL` with no `Link`
467
+ // parent, which is the whole of gap 2 in Docket's handoff:
468
+ // paste a link, get a link, with no `[](…)` typed at all.
469
+ const parentName = nodeRef.node.parent?.name
470
+
471
+ if (
472
+ parentName !== "Link" &&
473
+ parentName !== "Image"
474
+ ) {
475
+ ranges.push({
476
+ from: nodeRef.from,
477
+ markKind: "autolink",
478
+ to: nodeRef.to,
479
+ type: "mark",
480
+ url: text.slice(nodeRef.from, nodeRef.to),
481
+ })
482
+ } else {
483
+ ranges.push({
484
+ from: nodeRef.from,
485
+ markKind: "url",
486
+ to: nodeRef.to,
487
+ type: "mark",
488
+ })
489
+ }
490
+
491
+ return true
492
+ }
493
+
494
+ if (name === "TaskMarker") {
495
+ const isRevealed = isInside(
496
+ selections,
497
+ nodeRef.from,
498
+ nodeRef.to,
499
+ )
500
+
501
+ if (!isRawMode && !isRevealed) {
502
+ ranges.push({
503
+ from: nodeRef.from,
504
+ isChecked: text[nodeRef.from + 1] !== " ",
505
+ label: text
506
+ .slice(
507
+ nodeRef.to,
508
+ toLineEnd(text, nodeRef.to),
509
+ )
510
+ .trim(),
511
+ to: nodeRef.to,
512
+ type: "task",
513
+ })
514
+
515
+ return false
516
+ }
517
+
518
+ pushMarker(nodeRef.from, nodeRef.to, isRevealed)
519
+
520
+ return false
521
+ }
522
+
523
+ if (PERSISTENT_MARK_NAMES.has(name)) {
524
+ pushMarker(nodeRef.from, nodeRef.to, true)
525
+
526
+ return false
527
+ }
528
+
529
+ if (name === "TableDelimiter") {
530
+ pushMarker(nodeRef.from, nodeRef.to, true)
531
+
532
+ return false
533
+ }
534
+
535
+ if (name === "CodeInfo") {
536
+ pushMarker(nodeRef.from, nodeRef.to, true)
537
+
538
+ return false
539
+ }
540
+
541
+ if (CONCEALABLE_MARK_NAMES.has(name)) {
542
+ const parent = nodeRef.node.parent
543
+
544
+ if (
545
+ parent &&
546
+ FENCED_CODE_PARENTS.has(parent.name)
547
+ ) {
548
+ pushMarker(nodeRef.from, nodeRef.to, true)
549
+
550
+ return false
551
+ }
552
+
553
+ // A link's marks were already emitted above, with the
554
+ // link's own reveal state spanning both sides of the text.
555
+ // An image's were not — it either replaced itself outright
556
+ // or fell through to here, where the generic parent-range
557
+ // rule is already the correct one.
558
+ if (parent?.name === "Link") {
559
+ return false
560
+ }
561
+
562
+ const isRevealed = parent
563
+ ? isInside(selections, parent.from, parent.to)
564
+ : isInside(selections, nodeRef.from, nodeRef.to)
565
+
566
+ const to =
567
+ name === "HeaderMark"
568
+ ? toHeaderMarkEnd(text, nodeRef.to)
569
+ : nodeRef.to
570
+
571
+ pushMarker(nodeRef.from, to, isRevealed)
572
+
573
+ return false
574
+ }
575
+
576
+ return true
577
+ },
578
+ })
579
+
580
+ return ranges
581
+ }
@@ -561,6 +561,26 @@ const ENTRY_POINT_RUNTIMES: Record<
561
561
  // silently, which is how it shipped broken in three repos.
562
562
  "tailwind-merge",
563
563
  ],
564
+ // The opt-in live-preview editor. Every `@codemirror/*` and
565
+ // `@lezer/*` package appears **only** here, as an optional peer,
566
+ // which is the entire reason it is a subpath: measured at ~176 KB
567
+ // gz, it is 2.5x the total bundle budget of `slatecast`, and a
568
+ // consumer that renders a description field should not pay it to
569
+ // get one. The main entry's `MarkdownEditor` stays dependency-free
570
+ // and stays the default.
571
+ "./markdown-editor-codemirror": [
572
+ "@charcuterie/logic",
573
+ "@charcuterie/tokens",
574
+ "@codemirror/commands",
575
+ "@codemirror/lang-markdown",
576
+ "@codemirror/language",
577
+ "@codemirror/state",
578
+ "@codemirror/view",
579
+ "@floating-ui/react",
580
+ "@lezer/common",
581
+ "react",
582
+ "tailwind-merge",
583
+ ],
564
584
  // The router seam's one shipped adapter, and the reason it is a
565
585
  // subpath rather than part of the barrel: `react-router` appears
566
586
  // **only** here, as an optional peer, so the eleven consumers of