@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,866 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import {
3
+ defaultKeymap,
4
+ history,
5
+ historyKeymap,
6
+ } from "@codemirror/commands"
7
+ import {
8
+ markdown,
9
+ markdownLanguage,
10
+ } from "@codemirror/lang-markdown"
11
+ import { Compartment, EditorState } from "@codemirror/state"
12
+ import {
13
+ EditorView,
14
+ keymap,
15
+ placeholder as placeholderExtension,
16
+ } from "@codemirror/view"
17
+ import type { ReactNode } from "react"
18
+ import { useEffect, useRef, useState } from "react"
19
+
20
+ import type {
21
+ MarkdownEditorIcons,
22
+ MarkdownImageUpload,
23
+ } from "../MarkdownEditor/MarkdownEditor.tsx"
24
+ import type { MarkdownSelection } from "../MarkdownEditor/markdownCommands.ts"
25
+ import {
26
+ continueList,
27
+ getMinimalEdit,
28
+ indentLines,
29
+ insertLink,
30
+ insertText,
31
+ isLinkPaste,
32
+ MARKDOWN_LINE_PREFIXES,
33
+ outdentLines,
34
+ toggleHeading,
35
+ toggleInlineMarker,
36
+ toggleLinePrefix,
37
+ toMarkdownImage,
38
+ wrapSelectionInLink,
39
+ } from "../MarkdownEditor/markdownCommands.ts"
40
+ import { Switch } from "../Switch/Switch.tsx"
41
+ import type { SlotProps } from "../slotProps.ts"
42
+ import { Toolbar } from "../Toolbar/Toolbar.tsx"
43
+ import { toClassName } from "../toClassName.ts"
44
+ import {
45
+ livePreview,
46
+ setLivePreviewRawMode,
47
+ } from "./livePreview.ts"
48
+
49
+ export type MarkdownEditorCodeMirrorProps = SlotProps & {
50
+ className?: string
51
+ /**
52
+ * **Initial** only, and the same uncontrolled contract as the
53
+ * sibling `MarkdownEditor` — for the same reason, one layer over:
54
+ * reassigning the whole document from outside throws away the
55
+ * undo history that belonged to it. Swap documents with a `key`.
56
+ */
57
+ defaultValue?: string
58
+ /** Start in raw-markdown mode. **Initial** only. */
59
+ isRawModeDefault?: boolean
60
+ icons?: MarkdownEditorIcons
61
+ isDisabled?: boolean
62
+ isReadOnly?: boolean
63
+ /**
64
+ * The accessible name. Omit it inside a `Field`, which supplies a
65
+ * real `<label htmlFor>`.
66
+ */
67
+ label?: string
68
+ onChange?: (value: string) => void
69
+ onUploadError?: (file: File, error: unknown) => void
70
+ /**
71
+ * Paste or drop an image and this runs; the URL you return is
72
+ * inserted at the caret as `![alt](url)` — and, unlike the
73
+ * textarea sibling, is then **rendered in place**.
74
+ */
75
+ onUploadImage?: (
76
+ file: File,
77
+ ) => Promise<MarkdownImageUpload>
78
+ placeholder?: string
79
+ /** Label for the raw-markdown toggle. */
80
+ rawModeLabel?: string
81
+ toolbarLabel?: string
82
+ }
83
+
84
+ const isImageFile = (file: File) =>
85
+ file.type.startsWith("image/")
86
+
87
+ type UploadHandlers = {
88
+ onUploadError?: (file: File, error: unknown) => void
89
+ onUploadImage?: (
90
+ file: File,
91
+ ) => Promise<MarkdownImageUpload>
92
+ }
93
+
94
+ const readSelection = (
95
+ view: EditorView,
96
+ ): MarkdownSelection => {
97
+ const { main } = view.state.selection
98
+
99
+ return {
100
+ selectionEnd: main.to,
101
+ selectionStart: main.from,
102
+ text: view.state.doc.toString(),
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Apply a pure command's result as a **minimal** change.
108
+ *
109
+ * `getMinimalEdit` earns its keep differently here than in the
110
+ * textarea sibling. There it was about not wiping the browser's own
111
+ * undo stack; here CodeMirror's history would happily record a
112
+ * document-sized replacement — and then Ctrl+Z undoes the whole
113
+ * description instead of the bold toggle that caused it.
114
+ *
115
+ * Module scope, not a closure, because it is a pure function of its
116
+ * two arguments. That is not a lint dodge: a helper defined inside
117
+ * the component is a new identity every render, and this one gets
118
+ * captured by extensions that are built exactly once.
119
+ */
120
+ const applyEdit = (
121
+ view: EditorView,
122
+ next: MarkdownSelection,
123
+ ) => {
124
+ const current = view.state.doc.toString()
125
+
126
+ const edit = getMinimalEdit(current, next.text)
127
+
128
+ view.dispatch({
129
+ changes: {
130
+ from: edit.start,
131
+ insert: edit.text,
132
+ to: edit.end,
133
+ },
134
+ selection: {
135
+ anchor: next.selectionStart,
136
+ head: next.selectionEnd,
137
+ },
138
+ })
139
+
140
+ view.focus()
141
+ }
142
+
143
+ /**
144
+ * Upload one file, with a **markdown** placeholder holding the
145
+ * caret's place while the request is in flight.
146
+ *
147
+ * The placeholder is real text in the document rather than an
148
+ * overlay, because the user keeps typing during an upload and the
149
+ * insertion point has to travel with what they type. It carries a
150
+ * counter so two files of the same name do not resolve into each
151
+ * other's slot, and it is markdown like everything else — a failed
152
+ * upload leaves a document the user can read and fix, not a
153
+ * dangling widget.
154
+ */
155
+ const uploadImage = async ({
156
+ count,
157
+ file,
158
+ handlers,
159
+ setMessage,
160
+ view,
161
+ }: {
162
+ count: number
163
+ file: File
164
+ handlers: UploadHandlers
165
+ setMessage: (message: string) => void
166
+ view: EditorView
167
+ }) => {
168
+ const { onUploadError, onUploadImage } = handlers
169
+
170
+ if (!onUploadImage) {
171
+ return
172
+ }
173
+
174
+ const marker = `![Uploading ${file.name}](#uploading-${count})`
175
+
176
+ applyEdit(view, insertText(readSelection(view), marker))
177
+
178
+ setMessage(`Uploading ${file.name}`)
179
+
180
+ const replaceMarker = (insertion: string) => {
181
+ const text = view.state.doc.toString()
182
+
183
+ const start = text.indexOf(marker)
184
+
185
+ if (start === -1) {
186
+ return
187
+ }
188
+
189
+ const caret = start + insertion.length
190
+
191
+ applyEdit(view, {
192
+ selectionEnd: caret,
193
+ selectionStart: caret,
194
+ text:
195
+ text.slice(0, start) +
196
+ insertion +
197
+ text.slice(start + marker.length),
198
+ })
199
+ }
200
+
201
+ try {
202
+ const uploaded = await onUploadImage(file)
203
+
204
+ replaceMarker(
205
+ toMarkdownImage({
206
+ alt: uploaded.alt ?? file.name,
207
+ url: uploaded.url,
208
+ }),
209
+ )
210
+
211
+ setMessage(`Inserted ${file.name}`)
212
+ } catch (error) {
213
+ replaceMarker("")
214
+
215
+ setMessage(`Could not upload ${file.name}`)
216
+
217
+ onUploadError?.(file, error)
218
+ }
219
+ }
220
+
221
+ /**
222
+ * The live-preview markdown editor: Obsidian's model, on CodeMirror.
223
+ *
224
+ * This is the **opt-in** sibling of `MarkdownEditor`, and picking
225
+ * between them is a real decision rather than a preference:
226
+ *
227
+ * | | `MarkdownEditor` | this |
228
+ * | --- | --- | --- |
229
+ * | Surface | `<textarea>` + painted layer | CodeMirror 6 |
230
+ * | New dependency | **none** | ~176 KB gz of optional peer |
231
+ * | Markers | dimmed, never hidden | **concealed** until the caret arrives |
232
+ * | Headings | weight and colour only | **actually larger** |
233
+ * | Images | shown as `![alt](url)` | **rendered in place** |
234
+ * | Undo, IME | the platform's own | CodeMirror's |
235
+ *
236
+ * The default is still the textarea. A component library that makes
237
+ * every consumer install an editor core to render a description
238
+ * field has made the wrong trade, which is why this lives behind
239
+ * `@charcuterie/ui/markdown-editor-codemirror` with every CodeMirror
240
+ * package as an **optional** peer: a consumer that never imports
241
+ * this subpath resolves, installs and audits none of it.
242
+ *
243
+ * ## What is the same, and it is the important part
244
+ *
245
+ * **Markdown is the stored value, byte for byte.** There is no
246
+ * document model here and no serialiser. CodeMirror's state holds
247
+ * the markdown *text*; live preview is a set of decorations
248
+ * *over* that text; concealing `**` hides two characters that are
249
+ * still in the document and still in `onChange`. Every command is
250
+ * the same pure `markdownCommands.ts` the sibling uses, returning
251
+ * markdown in and markdown out.
252
+ *
253
+ * That distinction is the whole reason this is not TipTap or
254
+ * Lexical. Those invert it — their document model is authoritative
255
+ * and markdown becomes a lossy *export* — and the tracker Docket
256
+ * replaces died of exactly that: HTML in a field it later read back
257
+ * as text. An editor core would have been easier to build and is
258
+ * the one thing that could not be allowed.
259
+ *
260
+ * ## What genuinely costs something
261
+ *
262
+ * The `<textarea>` sibling gets caret movement, IME composition,
263
+ * spellcheck, the platform undo stack and `role="textbox"` from the
264
+ * browser, tested by the vendors. A `contenteditable` re-earns each
265
+ * of those, and CodeMirror is the reason that is acceptable rather
266
+ * than reckless: it is the most heavily exercised implementation of
267
+ * that re-earning in the ecosystem. It is still a re-implementation,
268
+ * and that is the honest cost of this subpath.
269
+ *
270
+ * ## Clicking a link opens it
271
+ *
272
+ * Obsidian's behaviour, not VS Code's: where the markup is hidden, a
273
+ * link that looks like a link acts like one. The trade is that you
274
+ * cannot click *into* link text to edit it — arrow in from the edge,
275
+ * or flip the raw-markdown toggle, which is exactly what it is for.
276
+ */
277
+ export const MarkdownEditorCodeMirror = ({
278
+ className,
279
+ defaultValue = "",
280
+ icons,
281
+ isDisabled = false,
282
+ isRawModeDefault = false,
283
+ isReadOnly = false,
284
+ label,
285
+ onChange,
286
+ onUploadError,
287
+ onUploadImage,
288
+ placeholder,
289
+ rawModeLabel = "Markdown source",
290
+ toolbarLabel = "Markdown formatting",
291
+ ...receivedSlotProps
292
+ }: MarkdownEditorCodeMirrorProps): ReactNode => {
293
+ const baseId = useUniqueId()
294
+
295
+ const hintId = `${baseId}-hint`
296
+
297
+ const hostRef = useRef<HTMLDivElement>(null)
298
+
299
+ const viewRef = useRef<EditorView | null>(null)
300
+
301
+ const uploadCount = useRef(0)
302
+
303
+ const editabilityRef = useRef(new Compartment())
304
+
305
+ const [isRawMode, setIsRawMode] = useState(
306
+ isRawModeDefault,
307
+ )
308
+
309
+ const [uploadMessage, setUploadMessage] = useState("")
310
+
311
+ /**
312
+ * Callbacks reach the editor through a ref, never a closure.
313
+ *
314
+ * The `EditorView` is built once and lives for the component's
315
+ * whole life — rebuilding it on every render would discard the
316
+ * undo history and the caret. So its extensions capture *this*
317
+ * object, and the render loop keeps the object current.
318
+ */
319
+ const handlersRef = useRef({
320
+ onChange,
321
+ onUploadError,
322
+ onUploadImage,
323
+ })
324
+
325
+ handlersRef.current = {
326
+ onChange,
327
+ onUploadError,
328
+ onUploadImage,
329
+ }
330
+
331
+ /**
332
+ * The uncontrolled contract, made explicit.
333
+ *
334
+ * These are the props that are **initial only**, frozen at mount
335
+ * in a ref rather than read from the render closure. Writing it
336
+ * this way says the quiet part out loud — changing `placeholder`
337
+ * later genuinely does nothing — and it keeps the build-once
338
+ * effect honestly dependency-free instead of suppressing a lint
339
+ * rule that is, in every other component here, correct.
340
+ */
341
+ const mountRef = useRef({
342
+ ariaDescribedBy: receivedSlotProps["aria-describedby"],
343
+ defaultValue,
344
+ hintId,
345
+ id: receivedSlotProps.id,
346
+ isRawModeDefault,
347
+ label,
348
+ placeholder,
349
+ })
350
+
351
+ const runCommand = (
352
+ command: (
353
+ state: MarkdownSelection,
354
+ ) => MarkdownSelection,
355
+ ) => {
356
+ const view = viewRef.current
357
+
358
+ if (!view || isDisabled || isReadOnly) {
359
+ return
360
+ }
361
+
362
+ applyEdit(view, command(readSelection(view)))
363
+ }
364
+
365
+ /**
366
+ * Built exactly once, and the empty dependency array is the whole
367
+ * point rather than an oversight.
368
+ *
369
+ * An `EditorView` owns the caret, the selection, the scroll
370
+ * position and the undo history. Rebuilding it throws all four
371
+ * away — so a dependency list here would mean that typing a
372
+ * description, then having a parent re-render with a new
373
+ * `placeholder` string, silently drops the user's undo stack
374
+ * mid-sentence. The values read inside are the *initial* half of
375
+ * the uncontrolled contract; everything that can legitimately
376
+ * change afterwards reaches the view through a compartment
377
+ * (editability) or through `handlersRef` (the callbacks).
378
+ *
379
+ * Swapping the document itself — Docket opening a different task —
380
+ * is a `key`, which is honest about the old history belonging to
381
+ * the old document.
382
+ */
383
+ useEffect(() => {
384
+ const host = hostRef.current
385
+
386
+ if (!host) {
387
+ return
388
+ }
389
+
390
+ const view = new EditorView({
391
+ parent: host,
392
+ state: EditorState.create({
393
+ doc: mountRef.current.defaultValue,
394
+ extensions: [
395
+ history(),
396
+ // `Tab` is deliberately absent, at every level: indenting
397
+ // with it is the convention in editors that own the whole
398
+ // page, and inside a form it is a keyboard trap (WCAG
399
+ // 2.1.2). `indentWithTab` is the extension that would add
400
+ // it, and it is not here. Indent is Ctrl+] / Ctrl+[.
401
+ keymap.of([
402
+ {
403
+ key: "Enter",
404
+ run: (target) => {
405
+ const continued = continueList(
406
+ readSelection(target),
407
+ )
408
+
409
+ if (!continued) {
410
+ return false
411
+ }
412
+
413
+ applyEdit(target, continued)
414
+
415
+ return true
416
+ },
417
+ },
418
+ {
419
+ key: "Mod-b",
420
+ run: (target) => {
421
+ applyEdit(
422
+ target,
423
+ toggleInlineMarker(
424
+ readSelection(target),
425
+ "**",
426
+ ),
427
+ )
428
+
429
+ return true
430
+ },
431
+ },
432
+ {
433
+ key: "Mod-i",
434
+ run: (target) => {
435
+ applyEdit(
436
+ target,
437
+ toggleInlineMarker(
438
+ readSelection(target),
439
+ "_",
440
+ ),
441
+ )
442
+
443
+ return true
444
+ },
445
+ },
446
+ {
447
+ key: "Mod-e",
448
+ run: (target) => {
449
+ applyEdit(
450
+ target,
451
+ toggleInlineMarker(
452
+ readSelection(target),
453
+ "`",
454
+ ),
455
+ )
456
+
457
+ return true
458
+ },
459
+ },
460
+ {
461
+ key: "Mod-k",
462
+ run: (target) => {
463
+ applyEdit(
464
+ target,
465
+ insertLink(readSelection(target)),
466
+ )
467
+
468
+ return true
469
+ },
470
+ },
471
+ {
472
+ key: "Mod-]",
473
+ run: (target) => {
474
+ applyEdit(
475
+ target,
476
+ indentLines(readSelection(target)),
477
+ )
478
+
479
+ return true
480
+ },
481
+ },
482
+ {
483
+ key: "Mod-[",
484
+ run: (target) => {
485
+ applyEdit(
486
+ target,
487
+ outdentLines(readSelection(target)),
488
+ )
489
+
490
+ return true
491
+ },
492
+ },
493
+ ...historyKeymap,
494
+ ...defaultKeymap,
495
+ ]),
496
+ // GFM, not strict CommonMark — which is what autolinks a
497
+ // bare URL, renders task lists, and parses tables. A
498
+ // pasted link becoming a link with nothing typed around
499
+ // it falls straight out of this one argument.
500
+ markdown({ base: markdownLanguage }),
501
+ EditorView.lineWrapping,
502
+ livePreview(),
503
+ mountRef.current.placeholder
504
+ ? placeholderExtension(
505
+ mountRef.current.placeholder,
506
+ )
507
+ : [],
508
+ EditorView.contentAttributes.of({
509
+ ...(mountRef.current.label
510
+ ? { "aria-label": mountRef.current.label }
511
+ : {}),
512
+ /**
513
+ * Named by the `Field` above, when there is one and no
514
+ * `label` was passed.
515
+ *
516
+ * The sibling editor needs none of this: a `<textarea>`
517
+ * is a **labelable** element, so `Field`'s
518
+ * `<label htmlFor>` names it and that is the end of it.
519
+ * This surface is a `div[role="textbox"]`, which is not
520
+ * labelable — `htmlFor` pointing at it names nothing,
521
+ * silently, and axe was the only thing that caught it.
522
+ *
523
+ * `Field` gives its `<label>` the id below for exactly
524
+ * this, and `Field.test.tsx` pins the format.
525
+ */
526
+ ...(mountRef.current.id &&
527
+ !mountRef.current.label
528
+ ? {
529
+ "aria-labelledby": `${mountRef.current.id}-label`,
530
+ }
531
+ : {}),
532
+ // Joined, not replaced. `aria-describedby` is the one
533
+ // slot-props key that is a list, and a `Field` above
534
+ // this component has already written its description and
535
+ // its error into it.
536
+ "aria-describedby": [
537
+ mountRef.current.ariaDescribedBy,
538
+ mountRef.current.hintId,
539
+ ]
540
+ .filter(Boolean)
541
+ .join(" "),
542
+ ...(mountRef.current.id
543
+ ? { id: mountRef.current.id }
544
+ : {}),
545
+ }),
546
+ EditorView.updateListener.of((update) => {
547
+ if (update.docChanged) {
548
+ handlersRef.current.onChange?.(
549
+ update.state.doc.toString(),
550
+ )
551
+ }
552
+ }),
553
+ EditorView.domEventHandlers({
554
+ drop: (event, target) => {
555
+ const files = Array.from(
556
+ event.dataTransfer?.files ?? [],
557
+ ).filter(isImageFile)
558
+
559
+ if (
560
+ files.length === 0 ||
561
+ !handlersRef.current.onUploadImage
562
+ ) {
563
+ return false
564
+ }
565
+
566
+ event.preventDefault()
567
+
568
+ for (const file of files) {
569
+ uploadCount.current += 1
570
+
571
+ void uploadImage({
572
+ count: uploadCount.current,
573
+ file,
574
+ handlers: handlersRef.current,
575
+ setMessage: setUploadMessage,
576
+ view: target,
577
+ })
578
+ }
579
+
580
+ return true
581
+ },
582
+ paste: (event, target) => {
583
+ const files = Array.from(
584
+ event.clipboardData?.files ?? [],
585
+ ).filter(isImageFile)
586
+
587
+ if (
588
+ files.length > 0 &&
589
+ handlersRef.current.onUploadImage
590
+ ) {
591
+ event.preventDefault()
592
+
593
+ for (const file of files) {
594
+ uploadCount.current += 1
595
+
596
+ void uploadImage({
597
+ count: uploadCount.current,
598
+ file,
599
+ handlers: handlersRef.current,
600
+ setMessage: setUploadMessage,
601
+ view: target,
602
+ })
603
+ }
604
+
605
+ return true
606
+ }
607
+
608
+ const pasted =
609
+ event.clipboardData?.getData(
610
+ "text/plain",
611
+ ) ?? ""
612
+
613
+ const selection = readSelection(target)
614
+
615
+ if (
616
+ isLinkPaste(pasted) &&
617
+ selection.selectionStart !==
618
+ selection.selectionEnd
619
+ ) {
620
+ event.preventDefault()
621
+
622
+ applyEdit(
623
+ target,
624
+ wrapSelectionInLink(
625
+ selection,
626
+ pasted.trim(),
627
+ ),
628
+ )
629
+
630
+ return true
631
+ }
632
+
633
+ return false
634
+ },
635
+ }),
636
+ editabilityRef.current.of([
637
+ EditorView.editable.of(true),
638
+ EditorState.readOnly.of(false),
639
+ ]),
640
+ ],
641
+ }),
642
+ })
643
+
644
+ viewRef.current = view
645
+
646
+ if (mountRef.current.isRawModeDefault) {
647
+ view.dispatch({
648
+ effects: setLivePreviewRawMode.of(true),
649
+ })
650
+ }
651
+
652
+ return () => {
653
+ view.destroy()
654
+
655
+ viewRef.current = null
656
+ }
657
+ }, [])
658
+
659
+ // Editability flips through a compartment rather than a rebuild,
660
+ // so toggling `isReadOnly` keeps the undo history and the caret.
661
+ useEffect(() => {
662
+ const view = viewRef.current
663
+
664
+ if (!view) {
665
+ return
666
+ }
667
+
668
+ const isEditable = !isDisabled && !isReadOnly
669
+
670
+ view.dispatch({
671
+ effects: editabilityRef.current.reconfigure([
672
+ EditorView.editable.of(isEditable),
673
+ EditorState.readOnly.of(!isEditable),
674
+ ]),
675
+ })
676
+ }, [isDisabled, isReadOnly])
677
+
678
+ useEffect(() => {
679
+ viewRef.current?.dispatch({
680
+ effects: setLivePreviewRawMode.of(isRawMode),
681
+ })
682
+ }, [isRawMode])
683
+
684
+ return (
685
+ <div
686
+ className={toClassName(
687
+ // `@container` here rather than on a wrapper: a container
688
+ // query matches a container's *descendants*, never the
689
+ // container itself.
690
+ "@container flex flex-col gap-2 rounded-md border border-border-default bg-surface-base text-content-primary focus-within:outline-solid focus-within:outline-(length:--focus-ring-width) focus-within:outline-offset-(--focus-ring-offset) focus-within:outline-focus-ring",
691
+ // A token pair, never `opacity-*` — fading the whole box
692
+ // fades the hint under it too, and axe measures the
693
+ // *composited* colour.
694
+ isDisabled &&
695
+ "border-border-subtle bg-surface-sunken",
696
+ className,
697
+ )}
698
+ >
699
+ <div className="border-border-subtle border-b px-2 pt-2 pb-1">
700
+ <Toolbar
701
+ items={[
702
+ {
703
+ icon: icons?.bold,
704
+ isDisabled: isDisabled || isReadOnly,
705
+ key: "bold",
706
+ label: "Bold",
707
+ onSelect: () => {
708
+ runCommand((state) =>
709
+ toggleInlineMarker(state, "**"),
710
+ )
711
+ },
712
+ },
713
+ {
714
+ icon: icons?.italic,
715
+ isDisabled: isDisabled || isReadOnly,
716
+ key: "italic",
717
+ label: "Italic",
718
+ onSelect: () => {
719
+ runCommand((state) =>
720
+ toggleInlineMarker(state, "_"),
721
+ )
722
+ },
723
+ },
724
+ {
725
+ icon: icons?.heading,
726
+ isDisabled: isDisabled || isReadOnly,
727
+ key: "heading",
728
+ label: "Heading",
729
+ onSelect: () => {
730
+ runCommand((state) =>
731
+ toggleHeading(state, 2),
732
+ )
733
+ },
734
+ },
735
+ {
736
+ icon: icons?.bulletedList,
737
+ isDisabled: isDisabled || isReadOnly,
738
+ key: "bulleted-list",
739
+ label: "Bulleted list",
740
+ onSelect: () => {
741
+ runCommand((state) =>
742
+ toggleLinePrefix(
743
+ state,
744
+ MARKDOWN_LINE_PREFIXES.bulletList,
745
+ ),
746
+ )
747
+ },
748
+ },
749
+ {
750
+ icon: icons?.taskList,
751
+ isDisabled: isDisabled || isReadOnly,
752
+ key: "task-list",
753
+ label: "Task list",
754
+ onSelect: () => {
755
+ runCommand((state) =>
756
+ toggleLinePrefix(
757
+ state,
758
+ MARKDOWN_LINE_PREFIXES.taskList,
759
+ ),
760
+ )
761
+ },
762
+ },
763
+ {
764
+ icon: icons?.numberedList,
765
+ isDisabled: isDisabled || isReadOnly,
766
+ key: "numbered-list",
767
+ label: "Numbered list",
768
+ onSelect: () => {
769
+ runCommand((state) =>
770
+ toggleLinePrefix(
771
+ state,
772
+ MARKDOWN_LINE_PREFIXES.orderedList,
773
+ ),
774
+ )
775
+ },
776
+ },
777
+ {
778
+ icon: icons?.quote,
779
+ isDisabled: isDisabled || isReadOnly,
780
+ key: "quote",
781
+ label: "Quote",
782
+ onSelect: () => {
783
+ runCommand((state) =>
784
+ toggleLinePrefix(
785
+ state,
786
+ MARKDOWN_LINE_PREFIXES.blockquote,
787
+ ),
788
+ )
789
+ },
790
+ },
791
+ {
792
+ icon: icons?.code,
793
+ isDisabled: isDisabled || isReadOnly,
794
+ key: "code",
795
+ label: "Code",
796
+ onSelect: () => {
797
+ runCommand((state) =>
798
+ toggleInlineMarker(state, "`"),
799
+ )
800
+ },
801
+ },
802
+ {
803
+ icon: icons?.link,
804
+ isDisabled: isDisabled || isReadOnly,
805
+ key: "link",
806
+ label: "Link",
807
+ onSelect: () => {
808
+ runCommand((state) => insertLink(state))
809
+ },
810
+ },
811
+ {
812
+ /**
813
+ * The "edit Markdown" toggle, as a `Switch` and not a
814
+ * toolbar action — because it is state, not a verb.
815
+ * A `ToolbarAction` renders a `Button`, which
816
+ * announces nothing about being on.
817
+ */
818
+ element: (
819
+ <Switch
820
+ isChecked={isRawMode}
821
+ isDisabled={isDisabled}
822
+ label={rawModeLabel}
823
+ onChange={setIsRawMode}
824
+ size="sm"
825
+ />
826
+ ),
827
+ key: "raw-mode",
828
+ type: "control",
829
+ },
830
+ ]}
831
+ label={toolbarLabel}
832
+ overflow="panel"
833
+ />
834
+ </div>
835
+
836
+ <div
837
+ className="min-h-32 px-1 text-md cq-md:min-h-48"
838
+ ref={hostRef}
839
+ />
840
+
841
+ <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 px-3 pb-2">
842
+ {/*
843
+ `sr-only` rather than `hidden`: `display: none` removes an
844
+ element from the accessibility tree, so an
845
+ `aria-describedby` pointing at a hidden hint silently
846
+ describes nothing.
847
+ */}
848
+ <p
849
+ className="sr-only text-content-secondary text-sm cq-sm:not-sr-only"
850
+ id={hintId}
851
+ >
852
+ Markdown. Ctrl+B bold, Ctrl+I italic, Ctrl+K link,
853
+ Ctrl+E code.
854
+ </p>
855
+
856
+ <span
857
+ aria-label="Image upload"
858
+ className="text-content-secondary text-sm"
859
+ role="status"
860
+ >
861
+ {uploadMessage}
862
+ </span>
863
+ </div>
864
+ </div>
865
+ )
866
+ }