@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.
- package/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +92 -50
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +7 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.js +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownView.d.ts +24 -1
- package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownView.js +14 -2
- package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.d.ts +7 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +6 -2
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +22 -2
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +26 -7
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
- package/package.json +2 -2
- package/src/Combobox/Combobox.test.tsx +89 -0
- package/src/Combobox/Combobox.tsx +100 -59
- package/src/MarkdownEditor/MarkdownEditor.mdx +12 -0
- package/src/MarkdownEditor/MarkdownEditor.stories.tsx +17 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +8 -0
- package/src/icons.storyHelpers.tsx +108 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +16 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +55 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +1 -0
- package/src/markdownEditorCodeMirror/MarkdownView.tsx +40 -1
- package/src/markdownEditorCodeMirror/livePreview.ts +18 -3
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +137 -0
- 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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
//
|
|
544
|
-
//
|
|
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
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
list.scrollHeight <= list.clientHeight
|
|
572
|
+
seedScroll.attempt < SEED_SCROLL_ATTEMPTS &&
|
|
573
|
+
(seedScroll.attempt === 0 ||
|
|
574
|
+
list.scrollHeight <= list.clientHeight)
|
|
568
575
|
) {
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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({
|
|
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 {
|
|
676
|
-
|
|
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 } =
|
|
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),
|