@charcuterie/ui 3.16.0 → 3.18.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 (36) hide show
  1. package/dist/Combobox/Combobox.d.ts.map +1 -1
  2. package/dist/Combobox/Combobox.js +92 -50
  3. package/dist/Combobox/Combobox.js.map +1 -1
  4. package/dist/MarkdownEditor/MarkdownEditor.d.ts +7 -1
  5. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
  6. package/dist/MarkdownEditor/MarkdownEditor.js +1 -1
  7. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
  8. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
  9. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +1 -1
  10. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
  11. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts +24 -1
  12. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -1
  13. package/dist/markdownEditorCodeMirror/MarkdownView.js +14 -2
  14. package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -1
  15. package/dist/markdownEditorCodeMirror/livePreview.d.ts +7 -0
  16. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
  17. package/dist/markdownEditorCodeMirror/livePreview.js +6 -2
  18. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
  19. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +22 -2
  20. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
  21. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +26 -7
  22. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  23. package/package.json +2 -2
  24. package/src/Combobox/Combobox.test.tsx +89 -0
  25. package/src/Combobox/Combobox.tsx +100 -59
  26. package/src/MarkdownEditor/MarkdownEditor.mdx +12 -0
  27. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +17 -0
  28. package/src/MarkdownEditor/MarkdownEditor.tsx +8 -0
  29. package/src/icons.storyHelpers.tsx +108 -0
  30. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +16 -0
  31. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +55 -0
  32. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +1 -0
  33. package/src/markdownEditorCodeMirror/MarkdownView.tsx +40 -1
  34. package/src/markdownEditorCodeMirror/livePreview.ts +18 -3
  35. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +137 -0
  36. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +50 -6
@@ -286,32 +286,40 @@ export const Combobox = ({
286
286
 
287
287
  const hasSeededOnOpen = useRef(false)
288
288
 
289
- // The seed sets the index and the in-view effect further down scrolls
290
- // to it, one commit later — the virtualizer measures its scroll
291
- // element in an effect of its own, so a `scrollToIndex` issued any
292
- // earlier has nothing to scroll and is dropped. That effect must not
293
- // scroll the *stale* highlight in the meantime, which would undo the
294
- // centring, so it is told which index to wait for and skips every pass
295
- // short of it.
296
- const pendingSeedIndex = useRef<null | number>(null)
297
-
298
- // Retrying the seed's centring scroll. `useAnchoredOverlay` caps the
299
- // panel's height by writing `style.maxHeight` straight onto the
300
- // floating element inside floating-ui's `size` middleware —
301
- // deliberately outside the `floatingStyles` React manages, so a
302
- // keystroke cannot wipe it. The cost lands here: it arrives a frame
303
- // after the open seed, announces itself with no re-render, and until
304
- // it does the list stands at its full content height with **nothing to
305
- // scroll**. A seed scroll issued then is dropped without a trace,
306
- // which is how the first build of this fix highlighted the chosen row
307
- // and still left it off screen in a real app.
308
- const [seedScrollAttempt, setSeedScrollAttempt] =
309
- useState(0)
289
+ /**
290
+ * The open seed's outstanding scroll — the row to centre, and how many
291
+ * frames it has waited for a panel it can move.
292
+ *
293
+ * **State, and a fresh object on every open.** The obvious build hangs
294
+ * the scroll off `activeIndex` changing, and that is a bug the second
295
+ * time the same picker opens: `activeIndex` still holds the index the
296
+ * *last* open seeded, so setting it again is a no-op, React re-renders
297
+ * nothing, and the effect that would have scrolled never runs. The
298
+ * first open worked and every reopen showed the top of the list.
299
+ *
300
+ * A new object per open cannot be a no-op, so the scroll no longer
301
+ * depends on the highlight having moved.
302
+ *
303
+ * The wait exists because `useAnchoredOverlay` caps the panel's height
304
+ * by writing `style.maxHeight` straight onto the floating element
305
+ * inside floating-ui's `size` middleware — deliberately outside the
306
+ * `floatingStyles` React manages, so a keystroke cannot wipe it. It
307
+ * lands a frame after the seed and re-renders nothing to announce
308
+ * itself, and until it does the list stands at its full content height
309
+ * with **nothing to scroll**, so a scroll issued then is dropped
310
+ * without a trace.
311
+ */
312
+ const [seedScroll, setSeedScroll] = useState<null | {
313
+ attempt: number
314
+ index: number
315
+ value: string
316
+ }>(null)
310
317
 
311
318
  useEffect(() => {
312
319
  if (!isVisible) {
313
320
  hasSeededOnOpen.current = false
314
- pendingSeedIndex.current = null
321
+
322
+ setSeedScroll(null)
315
323
 
316
324
  return
317
325
  }
@@ -337,9 +345,15 @@ export const Combobox = ({
337
345
 
338
346
  hasSeededOnOpen.current = true
339
347
 
340
- pendingSeedIndex.current = openSeedIndex
341
-
342
- setSeedScrollAttempt(0)
348
+ setSeedScroll({
349
+ attempt: 0,
350
+ index: openSeedIndex,
351
+ // Carried rather than looked up in the effect. `filtered` is a new
352
+ // array on every render, and as a dependency it would cancel and
353
+ // reschedule the pending frame each time — a wait that can never
354
+ // end while anything else re-renders.
355
+ value: seedValue,
356
+ })
343
357
 
344
358
  setActiveIndex(openSeedIndex)
345
359
  }, [
@@ -540,38 +554,41 @@ export const Combobox = ({
540
554
  }
541
555
  }
542
556
 
543
- // Keep the active row in view — a real bug in every fleet picker:
544
- // arrowing past the panel edge did not scroll.
557
+ // The open seed's own scroll, centred, and the only one allowed to run
558
+ // until it lands — an arrow move's `block: "nearest"` would drag the
559
+ // list straight back off the chosen row.
545
560
  useEffect(() => {
546
- if (!isVisible) {
561
+ if (!isVisible || seedScroll === null) {
547
562
  return
548
563
  }
549
564
 
550
- const isSeeding = pendingSeedIndex.current !== null
565
+ const list = listElement.current
551
566
 
552
- if (
553
- isSeeding &&
554
- pendingSeedIndex.current !== resolvedActiveIndex
555
- ) {
556
- // The seeded index has not reached this render yet. Scrolling the
557
- // stale highlight now would drag the list off the chosen row.
567
+ if (list === null) {
558
568
  return
559
569
  }
560
570
 
561
- const list = listElement.current
562
-
563
571
  if (
564
- isSeeding &&
565
- seedScrollAttempt < SEED_SCROLL_ATTEMPTS &&
566
- list !== null &&
567
- list.scrollHeight <= list.clientHeight
572
+ seedScroll.attempt < SEED_SCROLL_ATTEMPTS &&
573
+ (seedScroll.attempt === 0 ||
574
+ list.scrollHeight <= list.clientHeight)
568
575
  ) {
569
- // Nothing to scroll yet — the panel is still at its full height.
570
- // Hold the seed and look again next frame rather than scroll a
571
- // container that cannot move. Bounded, so a list that genuinely
572
- // fits its panel stops instead of spinning.
576
+ // Never scroll on the opening commit. Two separate things are
577
+ // still missing then, and each drops the scroll silently: the
578
+ // panel has no height cap yet, so the list stands at full height
579
+ // with nothing to move; and the virtualizer measures its scroll
580
+ // element in an effect of its own, so `scrollToIndex` has nothing
581
+ // to measure against. One frame settles both.
582
+ //
583
+ // After that, keep waiting only while the list cannot move.
584
+ // Bounded, so a list that genuinely fits its panel stops instead
585
+ // of spinning.
573
586
  const frame = requestAnimationFrame(() => {
574
- setSeedScrollAttempt((attempt) => attempt + 1)
587
+ setSeedScroll({
588
+ attempt: seedScroll.attempt + 1,
589
+ index: seedScroll.index,
590
+ value: seedScroll.value,
591
+ })
575
592
  })
576
593
 
577
594
  return () => {
@@ -579,17 +596,43 @@ export const Combobox = ({
579
596
  }
580
597
  }
581
598
 
582
- pendingSeedIndex.current = null
599
+ // Centred, where an arrow move only pulls the next row just into
600
+ // view. "nearest" on open would park the chosen row against the
601
+ // panel's bottom edge with nothing after it, so the neighbours the
602
+ // list was reopened to reach would still be off screen.
603
+ if (isWindowed) {
604
+ rowVirtualizer.scrollToIndex(seedScroll.index, {
605
+ align: "center",
606
+ })
607
+ } else {
608
+ list
609
+ .querySelector(
610
+ `#${CSS.escape(`${listboxId}-opt-${seedScroll.value}`)}`,
611
+ )
612
+ ?.scrollIntoView({ block: "center" })
613
+ }
614
+
615
+ setSeedScroll(null)
616
+ }, [
617
+ seedScroll,
618
+ isVisible,
619
+ isWindowed,
620
+ listboxId,
621
+ rowVirtualizer.scrollToIndex,
622
+ ])
623
+
624
+ // Keep the active row in view — a real bug in every fleet picker:
625
+ // arrowing past the panel edge did not scroll.
626
+ useEffect(() => {
627
+ // While the seed is outstanding the row it wants is not on screen
628
+ // yet, and pulling the highlight into view with `nearest` would
629
+ // settle the list somewhere the seed then has to undo.
630
+ if (!isVisible || seedScroll !== null) {
631
+ return
632
+ }
583
633
 
584
- // The open seed centres its row; an arrow move only pulls the next
585
- // one just into view. "nearest" on open would park the chosen row
586
- // against the panel's bottom edge with nothing after it, so the
587
- // neighbours the list was reopened to reach stay off screen.
588
634
  if (isWindowed) {
589
- rowVirtualizer.scrollToIndex(
590
- resolvedActiveIndex,
591
- isSeeding ? { align: "center" } : undefined,
592
- )
635
+ rowVirtualizer.scrollToIndex(resolvedActiveIndex)
593
636
 
594
637
  return
595
638
  }
@@ -599,9 +642,7 @@ export const Combobox = ({
599
642
  ?.querySelector(
600
643
  `#${CSS.escape(`${listboxId}-opt-${activeValue}`)}`,
601
644
  )
602
- ?.scrollIntoView({
603
- block: isSeeding ? "center" : "nearest",
604
- })
645
+ ?.scrollIntoView({ block: "nearest" })
605
646
  }
606
647
  }, [
607
648
  resolvedActiveIndex,
@@ -610,7 +651,7 @@ export const Combobox = ({
610
651
  isWindowed,
611
652
  listboxId,
612
653
  rowVirtualizer.scrollToIndex,
613
- seedScrollAttempt,
654
+ seedScroll,
614
655
  ])
615
656
 
616
657
  // ─── Attached mode: drive the consumer's input ──────────────────────
@@ -156,6 +156,18 @@ has to keep taking clicks as caret placement. So the editor handles its own drop
156
156
 
157
157
  <Canvas of={MarkdownEditorStories.Responsive} />
158
158
 
159
+ ## The toolbar is words until you bring icons
160
+
161
+ The bar defaults to word buttons because this package ships **no icons**, and words render
162
+ in every font on every target. Pass an `icons` bag and the row fits as glyphs, keeping the
163
+ words as the accessible names and as the labels of whatever still collapses into the menu.
164
+
165
+ `icons.overflow` is the tenth key. It is the **bar's own** trigger rather than a markdown
166
+ command, and it is in the same bag so that an app cannot pass nine glyphs and be left with
167
+ the words "More actions" at the end of the row.
168
+
169
+ <Canvas of={MarkdownEditorStories.IconToolbar} />
170
+
159
171
  ## Keyboard
160
172
 
161
173
  | Key | Does |
@@ -7,6 +7,7 @@ import {
7
7
  StoryGrid,
8
8
  } from "../board.storyHelpers.tsx"
9
9
  import { Field } from "../Field/Field.tsx"
10
+ import { MARKDOWN_ICONS } from "../icons.storyHelpers.tsx"
10
11
  import { MarkdownEditor } from "./MarkdownEditor.tsx"
11
12
 
12
13
  /**
@@ -265,3 +266,19 @@ export const Blank: Story = {
265
266
  placeholder: "Write it in markdown…",
266
267
  },
267
268
  }
269
+
270
+ /**
271
+ * The bar with an app's icon set, and the `overflow` glyph that
272
+ * finishes it.
273
+ *
274
+ * This editor's overflow is a `Menu` rather than a `Popover`, so
275
+ * the collapsed actions keep their words in the menu rows — the
276
+ * icons buy width in the bar without costing the names anywhere.
277
+ */
278
+ export const IconToolbar: Story = {
279
+ args: {
280
+ defaultValue: SAMPLE,
281
+ icons: MARKDOWN_ICONS,
282
+ label: "Description",
283
+ },
284
+ }
@@ -50,6 +50,12 @@ export type MarkdownImageUpload = {
50
50
  * everywhere and collapses into the overflow menu early; an app
51
51
  * that brings lucide gets a compact bar and keeps the words as the
52
52
  * accessible names.
53
+ *
54
+ * `overflow` is the odd one out: it is not a markdown command, it
55
+ * is the **bar's own** trigger. It lives here because an app that
56
+ * passes the nine and not the tenth gets nine icons followed by
57
+ * the words *"More actions"* — which is the one width where an
58
+ * icon bar looks unfinished, and there was no prop to fix it with.
53
59
  */
54
60
  export type MarkdownEditorIcons = Partial<
55
61
  Record<
@@ -60,6 +66,7 @@ export type MarkdownEditorIcons = Partial<
60
66
  | "italic"
61
67
  | "link"
62
68
  | "numberedList"
69
+ | "overflow"
63
70
  | "quote"
64
71
  | "taskList",
65
72
  ReactNode
@@ -746,6 +753,7 @@ export const MarkdownEditor = ({
746
753
  ]}
747
754
  label={toolbarLabel}
748
755
  overflow="menu"
756
+ overflowIcon={icons?.overflow}
749
757
  />
750
758
  </div>
751
759
 
@@ -191,3 +191,111 @@ export const MoreIcon = (): ReactNode => (
191
191
  <circle cx="12" cy="19" r="1.75" />
192
192
  </svg>
193
193
  )
194
+
195
+ /**
196
+ * The markdown toolbar's nine, plus the bar's own overflow above.
197
+ *
198
+ * They are here rather than in the editor's story file because the
199
+ * two editors — the textarea one and the CodeMirror one — take the
200
+ * same `icons` bag and both boards want it. `MARKDOWN_ICONS` below
201
+ * is the whole bag, so a story passes one object instead of nine
202
+ * props and cannot demonstrate a half-iconed bar by accident.
203
+ */
204
+ export const BoldIcon = (): ReactNode => (
205
+ <svg {...iconProps} aria-hidden="true">
206
+ <path d="M7 5h6.5a3.5 3.5 0 0 1 0 7H7z" />
207
+
208
+ <path d="M7 12h7.5a3.5 3.5 0 0 1 0 7H7z" />
209
+ </svg>
210
+ )
211
+
212
+ export const ItalicIcon = (): ReactNode => (
213
+ <svg {...iconProps} aria-hidden="true">
214
+ <path d="M19 5h-8M13 19H5M15 5l-4 14" />
215
+ </svg>
216
+ )
217
+
218
+ export const HeadingIcon = (): ReactNode => (
219
+ <svg {...iconProps} aria-hidden="true">
220
+ <path d="M6 5v14M18 5v14M6 12h12" />
221
+ </svg>
222
+ )
223
+
224
+ export const BulletedListIcon = (): ReactNode => (
225
+ <svg {...iconProps} aria-hidden="true">
226
+ <path d="M9 6h12M9 12h12M9 18h12" />
227
+
228
+ <path d="M4.5 6h.01M4.5 12h.01M4.5 18h.01" />
229
+ </svg>
230
+ )
231
+
232
+ export const TaskListIcon = (): ReactNode => (
233
+ <svg {...iconProps} aria-hidden="true">
234
+ <path d="M3 6.5l1.8 1.8L8 5M3 16.5l1.8 1.8L8 15" />
235
+
236
+ <path d="M12 7h9M12 17h9" />
237
+ </svg>
238
+ )
239
+
240
+ export const NumberedListIcon = (): ReactNode => (
241
+ <svg {...iconProps} aria-hidden="true">
242
+ <path d="M11 6h10M11 12h10M11 18h10" />
243
+
244
+ <path d="M4 5.5l1.5-.5V10M4 10h3" />
245
+
246
+ <path d="M4 15.2a1.6 1.6 0 1 1 2.6 1.3L4 19h3" />
247
+ </svg>
248
+ )
249
+
250
+ /**
251
+ * Two quote marks, not an indented bar. An indent bar is what the
252
+ * *result* looks like; the marks are what the button does.
253
+ *
254
+ * Filled, and closed rather than an open stroke. The stroked
255
+ * version of this shape drew as a pair of `s` characters at
256
+ * `size-[1.15em]` — a curve that reads as a letter is worse than
257
+ * no icon, because the eye stops to read it.
258
+ */
259
+ export const QuoteIcon = (): ReactNode => (
260
+ <svg
261
+ {...iconProps}
262
+ aria-hidden="true"
263
+ fill="currentColor"
264
+ stroke="none"
265
+ >
266
+ <path d="M10 6.2a6.6 6.6 0 0 0-4.7 6.2c0 2.1 1.3 3.6 3.2 3.6 1.7 0 2.9-1.2 2.9-2.8 0-1.6-1.1-2.7-2.6-2.7h-.5c.4-1.4 1.4-2.5 2.8-3.1z" />
267
+
268
+ <path d="M19 6.2a6.6 6.6 0 0 0-4.7 6.2c0 2.1 1.3 3.6 3.2 3.6 1.7 0 2.9-1.2 2.9-2.8 0-1.6-1.1-2.7-2.6-2.7h-.5c.4-1.4 1.4-2.5 2.8-3.1z" />
269
+ </svg>
270
+ )
271
+
272
+ export const CodeIcon = (): ReactNode => (
273
+ <svg {...iconProps} aria-hidden="true">
274
+ <path d="M16 18l6-6-6-6M8 6l-6 6 6 6" />
275
+ </svg>
276
+ )
277
+
278
+ export const LinkIcon = (): ReactNode => (
279
+ <svg {...iconProps} aria-hidden="true">
280
+ <path d="M10.5 13.5a4.5 4.5 0 0 0 6.8.5l2.5-2.5a4.5 4.5 0 0 0-6.4-6.4l-1.4 1.4" />
281
+
282
+ <path d="M13.5 10.5a4.5 4.5 0 0 0-6.8-.5l-2.5 2.5a4.5 4.5 0 0 0 6.4 6.4l1.4-1.4" />
283
+ </svg>
284
+ )
285
+
286
+ /**
287
+ * The whole `MarkdownEditorIcons` bag, ready to spread into either
288
+ * editor's `icons` prop.
289
+ */
290
+ export const MARKDOWN_ICONS = {
291
+ bold: <BoldIcon />,
292
+ bulletedList: <BulletedListIcon />,
293
+ code: <CodeIcon />,
294
+ heading: <HeadingIcon />,
295
+ italic: <ItalicIcon />,
296
+ link: <LinkIcon />,
297
+ numberedList: <NumberedListIcon />,
298
+ overflow: <MoreIcon />,
299
+ quote: <QuoteIcon />,
300
+ taskList: <TaskListIcon />,
301
+ }
@@ -155,6 +155,22 @@ from either edge, or flip the Markdown source toggle, which is exactly what it i
155
155
  a literal paste is worse than not linkifying one.
156
156
  - **Anything else** is left to the browser.
157
157
 
158
+ ## The toolbar is words until you bring icons
159
+
160
+ This package ships **no icons**, so the bar defaults to ten word buttons — which is a bar
161
+ that has already collapsed most of itself into "More actions" at the width a description
162
+ field actually gets. Pass an `icons` bag and the whole row fits, with the words kept as the
163
+ accessible names.
164
+
165
+ <Canvas of={MarkdownEditorCodeMirrorStories.IconsVersusWords} />
166
+
167
+ `icons.overflow` is the tenth key and the odd one out — it is the **bar's own** trigger,
168
+ not a markdown command. It is in the same bag because an app that passes the nine and not
169
+ the tenth gets nine glyphs followed by the words "More actions", and until now there was no
170
+ prop to fix that with.
171
+
172
+ <Canvas of={MarkdownEditorCodeMirrorStories.IconToolbar} />
173
+
158
174
  ## Accessibility, and the honest cost
159
175
 
160
176
  The `<textarea>` sibling gets caret movement, IME composition, spellcheck, the platform undo
@@ -5,6 +5,7 @@ import {
5
5
  StoryGrid,
6
6
  } from "../board.storyHelpers.tsx"
7
7
  import { Field } from "../Field/Field.tsx"
8
+ import { MARKDOWN_ICONS } from "../icons.storyHelpers.tsx"
8
9
  import { MarkdownEditorCodeMirror } from "./MarkdownEditorCodeMirror.tsx"
9
10
 
10
11
  /**
@@ -187,3 +188,57 @@ export const Tables: Story = {
187
188
  label: "Description",
188
189
  },
189
190
  }
191
+
192
+ /**
193
+ * The same bar, with an app's icon set.
194
+ *
195
+ * Words are the default because the library ships no icons
196
+ * ([decision](../../../../docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md)),
197
+ * and nine words is a bar that has already overflowed at the width
198
+ * a description field actually gets. Ten glyphs fit, so the
199
+ * overflow trigger is not drawn at all — and the words survive as
200
+ * the accessible names, which is what the toolbar test asserts.
201
+ *
202
+ * `overflow` is in the bag for the width where the bar *does* still
203
+ * collapse. Without it the row reads as nine icons and then the
204
+ * words "More actions".
205
+ */
206
+ export const IconToolbar: Story = {
207
+ args: {
208
+ defaultValue: SAMPLE,
209
+ icons: MARKDOWN_ICONS,
210
+ label: "Description",
211
+ },
212
+ }
213
+
214
+ /**
215
+ * Icons and words at the width that separates them.
216
+ *
217
+ * Same component, same items, same container — the only difference
218
+ * is the `icons` bag. The word bar has collapsed most of its
219
+ * actions into "More actions"; the icon bar still shows all ten.
220
+ */
221
+ export const IconsVersusWords: Story = {
222
+ args: { label: "Description" },
223
+ render: () => (
224
+ <StoryGrid columns={2}>
225
+ <StoryCell
226
+ align="stretch"
227
+ label="words (no icons passed)"
228
+ >
229
+ <MarkdownEditorCodeMirror
230
+ defaultValue="The default bar."
231
+ label="Words"
232
+ />
233
+ </StoryCell>
234
+
235
+ <StoryCell align="stretch" label="icons">
236
+ <MarkdownEditorCodeMirror
237
+ defaultValue="The same bar, iconed."
238
+ icons={MARKDOWN_ICONS}
239
+ label="Icons"
240
+ />
241
+ </StoryCell>
242
+ </StoryGrid>
243
+ ),
244
+ }
@@ -830,6 +830,7 @@ export const MarkdownEditorCodeMirror = ({
830
830
  ]}
831
831
  label={toolbarLabel}
832
832
  overflow="panel"
833
+ overflowIcon={icons?.overflow}
833
834
  />
834
835
  </div>
835
836
 
@@ -50,6 +50,29 @@ export type MarkdownViewProps = SlotProps & {
50
50
  * back.
51
51
  */
52
52
  onToggleTask?: (nextValue: string) => void
53
+ /**
54
+ * Re-point a link before it becomes an `href`, for a document
55
+ * whose paths mean something only the app knows.
56
+ *
57
+ * The case it exists for is a **relative** path in a fetched or
58
+ * imported file. `[the runbook](../docs/runbook.md)` is resolved
59
+ * by the browser against the page the reader is on — the app —
60
+ * so it lands on nothing. Return the URL it should point at
61
+ * instead, or `undefined` to leave the link exactly as written.
62
+ *
63
+ * The document's own URL has already been through the scheme
64
+ * guard when it arrives, so `javascript:` never reaches this.
65
+ * What comes back is the app's own string and is used as given.
66
+ *
67
+ * **Links only.** An image's `src` is deliberately not offered:
68
+ * an app that maps a document path to a page URL would turn a
69
+ * working image into a broken one.
70
+ *
71
+ * Read once, at mount, through a ref — so a resolver defined
72
+ * inline in a render does not rebuild the view and lose the
73
+ * reader's scroll position.
74
+ */
75
+ resolveUrl?: (url: string) => string | undefined
53
76
  /**
54
77
  * The markdown to render, and **controlled** — unlike either
55
78
  * editor.
@@ -170,6 +193,7 @@ export const MarkdownView = ({
170
193
  className,
171
194
  label,
172
195
  onToggleTask,
196
+ resolveUrl,
173
197
  value,
174
198
  ...receivedSlotProps
175
199
  }: MarkdownViewProps): ReactNode => {
@@ -193,6 +217,16 @@ export const MarkdownView = ({
193
217
 
194
218
  handlerRef.current = onToggleTask
195
219
 
220
+ /**
221
+ * Same treatment, and for the same reason: a resolver written
222
+ * inline in the consumer's render is a new function every time,
223
+ * and rebuilding the view on it would throw away the scroll
224
+ * position of whatever the reader was reading.
225
+ */
226
+ const resolveUrlRef = useRef(resolveUrl)
227
+
228
+ resolveUrlRef.current = resolveUrl
229
+
196
230
  /**
197
231
  * Frozen at mount, and named so rather than read from the render
198
232
  * closure, because the build-once effect below is honestly
@@ -224,7 +258,12 @@ export const MarkdownView = ({
224
258
  // component exists to avoid.
225
259
  markdown({ base: markdownLanguage }),
226
260
  EditorView.lineWrapping,
227
- livePreview({ hasDocumentSemantics: true }),
261
+ livePreview({
262
+ hasDocumentSemantics: true,
263
+ // Stable by construction — the ref is what changes.
264
+ resolveUrl: (url) =>
265
+ resolveUrlRef.current?.(url),
266
+ }),
228
267
  /**
229
268
  * The one answer that can change after mount, and only
230
269
  * that answer.
@@ -104,6 +104,13 @@ export type LivePreviewOptions = {
104
104
  hasDocumentSemantics?: boolean
105
105
  /** Whether a `- [ ]` checkbox can be ticked. Defaults to `true`. */
106
106
  isTaskListInteractive?: boolean
107
+ /**
108
+ * Re-point a link before it becomes an `href` — see
109
+ * `ToLivePreviewRangesOptions.resolveUrl`, which this is handed
110
+ * to. Links only, and the document's URL has already been through
111
+ * the scheme guard when it arrives.
112
+ */
113
+ resolveUrl?: (url: string) => string | undefined
107
114
  }
108
115
 
109
116
  /**
@@ -130,10 +137,12 @@ export const livePreviewOptions = Facet.define<
130
137
  isTaskListInteractive:
131
138
  value.isTaskListInteractive ??
132
139
  merged.isTaskListInteractive,
140
+ resolveUrl: value.resolveUrl ?? merged.resolveUrl,
133
141
  }),
134
142
  {
135
143
  hasDocumentSemantics: false,
136
144
  isTaskListInteractive: true,
145
+ resolveUrl: () => undefined,
137
146
  },
138
147
  ),
139
148
  })
@@ -672,8 +681,11 @@ const toDecorations = (view: EditorView): DecorationSet => {
672
681
  false,
673
682
  )
674
683
 
675
- const { hasDocumentSemantics, isTaskListInteractive } =
676
- toOptions(view.state)
684
+ const {
685
+ hasDocumentSemantics,
686
+ isTaskListInteractive,
687
+ resolveUrl,
688
+ } = toOptions(view.state)
677
689
 
678
690
  const text = view.state.doc.toString()
679
691
 
@@ -709,6 +721,7 @@ const toDecorations = (view: EditorView): DecorationSet => {
709
721
  for (const range of toLivePreviewRanges({
710
722
  from: visible.from,
711
723
  isRawMode: isRawMode ?? false,
724
+ resolveUrl,
712
725
  selections,
713
726
  text,
714
727
  to: visible.to,
@@ -838,7 +851,8 @@ const toTableDecorations = (state: EditorState) => {
838
851
  false,
839
852
  )
840
853
 
841
- const { hasDocumentSemantics } = toOptions(state)
854
+ const { hasDocumentSemantics, resolveUrl } =
855
+ toOptions(state)
842
856
 
843
857
  const selections =
844
858
  state.field(isEditorFocusedField, false) &&
@@ -854,6 +868,7 @@ const toTableDecorations = (state: EditorState) => {
854
868
  return Decoration.set(
855
869
  toLivePreviewTableRanges({
856
870
  isRawMode: isRawMode ?? false,
871
+ resolveUrl,
857
872
  selections,
858
873
  text,
859
874
  tree: syntaxTree(state),