@lovett/ui 0.0.11 → 0.2.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 (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,341 @@
1
+ /**
2
+ * LinkPreview — one unfurled link card in a comment (ADR-150 D8).
3
+ *
4
+ * The worker does everything dangerous. It scans `body_md`, fetches through the
5
+ * shared SSRF guard, parses a byte-capped head with a regex, and stores plain
6
+ * strings (ADR-150 D1-D3). By the time a card reaches this file it is data:
7
+ * a title, an optional description, an optional image URL, a worker-derived
8
+ * display domain, and the href the person actually typed. Nothing here fetches,
9
+ * parses, or decides anything about a URL.
10
+ *
11
+ * ## The four things this component exists to get right
12
+ *
13
+ * **1. The still frame is DRAWN, never READ BACK.** D4's reduced-motion
14
+ * treatment reuses `attachments.tsx`'s pattern, and that pattern was written
15
+ * for a SAME-ORIGIN attachment. An `og:image` is hotlinked from a third party,
16
+ * so drawing it taints the canvas the instant `drawImage` runs. Drawing and
17
+ * DISPLAYING a tainted canvas is legal and is exactly what this needs;
18
+ * `toDataURL()` or `getImageData()` on one throws `SecurityError`. So the
19
+ * `<canvas>` element is rendered directly and must never be "improved" into
20
+ * producing a data URL.
21
+ *
22
+ * `crossOrigin="anonymous"` is deliberately NOT set. It would untaint the
23
+ * canvas — and make every image whose origin sends no CORS header fail to load
24
+ * at all, trading a working card for a broken one on most of the web.
25
+ *
26
+ * **2. Every `og:image` is treated as potentially animated.** Unlike an
27
+ * attachment, its MIME is unknown without fetching it (D4), so the still-frame
28
+ * path is not gated on `image/gif` the way `AttachmentImage`'s is. Under
29
+ * `prefers-reduced-motion` the live image is hidden behind the captured frame
30
+ * until someone presses play.
31
+ *
32
+ * **3. The media geometry is declared before the bytes arrive and KEPT when
33
+ * they never do.** `imageAspect ?? 16/9` sizes the slot up front, and a load
34
+ * failure swaps the picture for an icon INSIDE the same box. A third party's
35
+ * dead CDN must not be able to move a thread someone is reading.
36
+ *
37
+ * **4. The link and the dismiss button are siblings.** Nesting a `<button>`
38
+ * inside an `<a>` is invalid HTML and breaks keyboard use — and this is exactly
39
+ * the shape where it is tempting, because the card reads as one clickable
40
+ * thing.
41
+ *
42
+ * ## Surface
43
+ *
44
+ * `<Card>`, not a bordered `<div>` with a background token (CLAUDE.md §2). The
45
+ * primitive owns the opaque surface, the border and the shadow; the grey
46
+ * structure — the hairline under the media, the recessed media well — sits
47
+ * INSIDE it, which is the data-display recipe rather than an exception to it.
48
+ */
49
+
50
+ import { useCallback, useEffect, useRef, useState } from 'react'
51
+ import { ExternalLink, ImageOff, Play, X } from 'lucide-react'
52
+ import Card from '../card'
53
+ import { cn } from '../lib/utils'
54
+ import { QUIET_ICON, QUIET_ICON_BOX, usePrefersReducedMotion } from './attachments'
55
+ import type { ThreadLinkPreview } from './types'
56
+
57
+ /**
58
+ * The slot's ratio when the page declared nothing usable.
59
+ *
60
+ * D4 names 16:9 specifically. It is a reservation, not a promise about the
61
+ * picture: `object-fit: cover` crops whatever actually arrives into it.
62
+ */
63
+ export const LINK_PREVIEW_DEFAULT_ASPECT = 16 / 9
64
+
65
+ /**
66
+ * D4 clamps the declared ratio to `0.1..10` in the worker. This re-applies it
67
+ * rather than trusting the row, for one narrow reason: a `0`, a `NaN`, or a
68
+ * 1000:1 ratio arriving from a stale row would produce a collapsed or hairline
69
+ * slot, and the whole point of declaring the geometry is that the layout cannot
70
+ * depend on what a third party sent.
71
+ */
72
+ function mediaAspect(declared: number | null | undefined): number {
73
+ if (declared == null || !Number.isFinite(declared) || declared <= 0) {
74
+ return LINK_PREVIEW_DEFAULT_ASPECT
75
+ }
76
+ return Math.min(10, Math.max(0.1, declared))
77
+ }
78
+
79
+ /** A nullable string field, normalized to "present and non-empty, or absent". */
80
+ function blank(value: string | null | undefined): string | undefined {
81
+ const trimmed = value?.trim()
82
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed
83
+ }
84
+
85
+ export interface LinkPreviewProps {
86
+ readonly preview: ThreadLinkPreview
87
+ /**
88
+ * Hide this card for this comment (D7). Absent — or `canHide: false` — and no
89
+ * dismiss control renders.
90
+ *
91
+ * `canHide` is a RENDERING convenience and never an authorization decision:
92
+ * the server re-checks that the caller authored the comment on every hide,
93
+ * and a client that flips the flag gets a 404, not a hidden card.
94
+ */
95
+ readonly onHide?: ((preview: ThreadLinkPreview) => void) | undefined
96
+ readonly className?: string | undefined
97
+ }
98
+
99
+ export function LinkPreview({ preview, onHide, className }: LinkPreviewProps) {
100
+ const reduced = usePrefersReducedMotion()
101
+ const [broken, setBroken] = useState(false)
102
+ const [playing, setPlaying] = useState(false)
103
+ const [capturedSrc, setCapturedSrc] = useState<string | null>(null)
104
+ const imageRef = useRef<HTMLImageElement>(null)
105
+ const canvasRef = useRef<HTMLCanvasElement>(null)
106
+ const capturedSrcRef = useRef<string | null>(null)
107
+
108
+ const imageUrl = preview.imageUrl ?? undefined
109
+ // Trimmed to `undefined`, not rendered as empty. A whitespace-only
110
+ // description would otherwise reserve a line of nothing under the title, and
111
+ // a whitespace-only site name would render a bare separator dot.
112
+ const description = blank(preview.description)
113
+ const siteName = blank(preview.siteName)
114
+
115
+ /**
116
+ * D6 lets a stale `ok` row keep rendering while a refresh runs, so the same
117
+ * card can be handed a NEW `og:image` without unmounting — the key is the URL
118
+ * hash, not the image. React's documented "adjust state when a prop changes"
119
+ * shape, rather than an effect: an effect would paint the broken placeholder
120
+ * (or the previous still) for a frame before correcting itself.
121
+ */
122
+ const [seenImageUrl, setSeenImageUrl] = useState(imageUrl)
123
+ if (seenImageUrl !== imageUrl) {
124
+ setSeenImageUrl(imageUrl)
125
+ setBroken(false)
126
+ setPlaying(false)
127
+ }
128
+
129
+ /**
130
+ * D5 makes a non-empty title the condition for writing an `ok` row at all, so
131
+ * a blank one should be unreachable. Two lines keep it from producing a card
132
+ * with no accessible name and a dismiss button labelled "Dismiss link
133
+ * preview: ".
134
+ */
135
+ const title = blank(preview.title) ?? preview.domain
136
+
137
+ const captureStill = useCallback(
138
+ (image: HTMLImageElement) => {
139
+ if (imageUrl === undefined || capturedSrcRef.current === imageUrl) return
140
+ const canvas = canvasRef.current
141
+ const width = image.naturalWidth
142
+ const height = image.naturalHeight
143
+ if (canvas === null || width <= 0 || height <= 0) return
144
+ canvas.width = width
145
+ canvas.height = height
146
+ const context = canvas.getContext('2d')
147
+ if (context === null) return
148
+ // DRAW ONLY. This canvas is now tainted by a cross-origin image; reading
149
+ // it back with `toDataURL()` / `getImageData()` throws `SecurityError`.
150
+ // See the header. The element itself is what gets displayed.
151
+ context.drawImage(image, 0, 0, width, height)
152
+ capturedSrcRef.current = imageUrl
153
+ setCapturedSrc(imageUrl)
154
+ },
155
+ [imageUrl],
156
+ )
157
+
158
+ // The preference can flip while the comment is on screen, and an og:image
159
+ // that keeps animating after someone turns reduced motion on has ignored them
160
+ // at the moment they asked. The `complete` check covers an image whose load
161
+ // event already fired before the preference changed.
162
+ useEffect(() => {
163
+ if (!reduced) return
164
+ setPlaying(false)
165
+ const image = imageRef.current
166
+ if (image?.complete === true) captureStill(image)
167
+ }, [captureStill, reduced])
168
+
169
+ const hasMedia = imageUrl !== undefined
170
+ const showStill = hasMedia && reduced && !playing && !broken
171
+ const canDismiss = preview.canHide && onHide !== undefined
172
+
173
+ return (
174
+ <Card
175
+ // `h-auto` because this is a flow item whose height is its content, not a
176
+ // grid cell. `overflow-hidden` (Card's default) is KEPT here so the media
177
+ // clips to the card's corner — nothing that can take focus touches the
178
+ // edge, so no focus ring is eaten by it.
179
+ className={cn('h-auto', className)}
180
+ style={{
181
+ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
182
+ // A preview should not stretch to the full width of a wide comment
183
+ // column; it is a footnote to the text, not a hero.
184
+ maxInlineSize: 'min(100%, calc(var(--space-12) * 8))',
185
+ }}
186
+ >
187
+ {hasMedia ? (
188
+ <div
189
+ className="relative block w-full overflow-hidden border-b"
190
+ style={{
191
+ // Declared BEFORE the bytes, and kept if they never arrive.
192
+ aspectRatio: String(mediaAspect(preview.imageAspect)),
193
+ background: 'rgb(var(--surface-inset))',
194
+ borderColor: 'rgb(var(--border))',
195
+ }}
196
+ >
197
+ {broken ? (
198
+ <span
199
+ className="flex h-full w-full items-center justify-center"
200
+ style={{ color: 'rgb(var(--text-tertiary))' }}
201
+ >
202
+ <ImageOff size={14} strokeWidth={1.5} aria-hidden="true" />
203
+ </span>
204
+ ) : (
205
+ <>
206
+ <img
207
+ ref={imageRef}
208
+ src={imageUrl}
209
+ // D4: the page supplied no trustworthy description of its own
210
+ // image, so it is decorative. The title beside it is the
211
+ // accessible content.
212
+ alt=""
213
+ loading="lazy"
214
+ // Prevents a referrer leak. It does NOT prevent the reader's IP
215
+ // reaching the image origin — that cost is accepted in D4 and
216
+ // is why a signed proxy is a named follow-up.
217
+ referrerPolicy="no-referrer"
218
+ className="h-full w-full object-cover"
219
+ style={{ visibility: showStill ? 'hidden' : 'visible' }}
220
+ onLoad={(event) => {
221
+ // Only under the preference. Capturing every preview image
222
+ // unconditionally would decode and redraw a full-resolution
223
+ // bitmap for every card on the page to produce a still
224
+ // nobody is going to be shown.
225
+ if (reduced) captureStill(event.currentTarget)
226
+ }}
227
+ onError={() => {
228
+ setBroken(true)
229
+ }}
230
+ />
231
+ <canvas
232
+ ref={canvasRef}
233
+ aria-hidden="true"
234
+ hidden={!showStill || capturedSrc !== imageUrl}
235
+ className="absolute inset-0 h-full w-full object-cover"
236
+ />
237
+ {showStill ? (
238
+ <button
239
+ type="button"
240
+ aria-label={`Play preview image from ${preview.domain}`}
241
+ onClick={() => {
242
+ setPlaying(true)
243
+ }}
244
+ className="absolute inset-0 flex items-center justify-center focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]"
245
+ style={{
246
+ // diag:contrast-ok — this scrim sits on a PHOTOGRAPH, which
247
+ // is the one thing check-contrast says it cannot see:
248
+ // composited against the light theme's base surface the
249
+ // pair measures 1.53:1, and against the image it is
250
+ // actually over it is the same media-scrim/on-scrim
251
+ // treatment `attachments.tsx` uses for the identical
252
+ // control. D4 requires reusing that pattern rather than
253
+ // inventing a second one, so this is inherited, eyeballed
254
+ // debt and not a new colour decision.
255
+ background: 'rgb(var(--media-scrim-mid))',
256
+ color: 'rgb(var(--media-on-scrim))',
257
+ }}
258
+ >
259
+ <Play size={20} strokeWidth={1.75} aria-hidden="true" />
260
+ </button>
261
+ ) : null}
262
+ </>
263
+ )}
264
+ </div>
265
+ ) : null}
266
+
267
+ <div
268
+ className="flex flex-col gap-[var(--space-1)]"
269
+ style={{ padding: 'var(--space-3)' }}
270
+ >
271
+ <div className="flex items-start gap-[var(--space-2)]">
272
+ {/* SIBLINGS, never nested. A `<button>` inside an `<a>` is invalid
273
+ and unusable from a keyboard, and this is the exact shape where
274
+ it is tempting. */}
275
+ <a
276
+ href={preview.href}
277
+ target="_blank"
278
+ rel="noopener noreferrer"
279
+ className={cn(
280
+ 'group/link block min-w-0 flex-1 no-underline',
281
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
282
+ )}
283
+ style={{ borderRadius: 'var(--radius-xs)' }}
284
+ >
285
+ <span
286
+ className="flex min-w-0 items-center gap-[var(--space-1)] text-[11.5px]"
287
+ style={{ color: 'rgb(var(--text-tertiary))' }}
288
+ >
289
+ <ExternalLink size={11} strokeWidth={1.5} aria-hidden="true" className="shrink-0" />
290
+ {/* The DOMAIN leads, and that ordering is deliberate. It is
291
+ derived by the worker from the URL the link actually points
292
+ at; `og:site_name` is a string the target page chose. Reading
293
+ "Your Bank - evil.example" is a very different claim from
294
+ reading "evil.example - Your Bank". */}
295
+ <span className="truncate">{preview.domain}</span>
296
+ {siteName === undefined || siteName.toLowerCase() === preview.domain.toLowerCase() ? null : (
297
+ <span className="truncate">&middot; {siteName}</span>
298
+ )}
299
+ </span>
300
+ <span
301
+ className="block text-[13px] font-semibold leading-[1.35] line-clamp-2 group-hover/link:underline"
302
+ style={{
303
+ color: 'rgb(var(--foreground))',
304
+ marginBlockStart: 'var(--space-1)',
305
+ }}
306
+ >
307
+ {title}
308
+ </span>
309
+ </a>
310
+
311
+ {canDismiss ? (
312
+ <button
313
+ type="button"
314
+ // Preview-specific, per D8. The title is what the reader sees, so
315
+ // it is what identifies this card among the (at most three) on
316
+ // one comment — a bare "Dismiss" would be three identical rows in
317
+ // a screen reader's control list.
318
+ aria-label={`Dismiss link preview: ${title}`}
319
+ className={QUIET_ICON}
320
+ style={QUIET_ICON_BOX}
321
+ onClick={() => {
322
+ onHide?.(preview)
323
+ }}
324
+ >
325
+ <X size={12} strokeWidth={1.75} aria-hidden="true" />
326
+ </button>
327
+ ) : null}
328
+ </div>
329
+
330
+ {description === undefined ? null : (
331
+ <p
332
+ className="line-clamp-2 text-[12px] leading-[1.5]"
333
+ style={{ color: 'rgb(var(--text-secondary))' }}
334
+ >
335
+ {description}
336
+ </p>
337
+ )}
338
+ </div>
339
+ </Card>
340
+ )
341
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * markdown-format — what the composer's toolbar actually DOES.
3
+ *
4
+ * ADR-147 D7. The reference this port replaces (`post-modal.tsx`'s
5
+ * `RichComposer`) rendered a toolbar of buttons wired to nothing — fake
6
+ * chrome, and the named anti-pattern of this slice. So the transform is a pure
7
+ * function with its own tests: given the textarea's value and selection, it
8
+ * returns the new value and the new selection. The component's only job is to
9
+ * call it and restore the caret.
10
+ *
11
+ * Toggling is symmetric on purpose. A bold button that can only ever add `**`
12
+ * is a button that punishes a misclick, and "select it again and press it
13
+ * again" is the behaviour every editor has trained people to expect.
14
+ */
15
+
16
+ export type MarkdownFormat = 'bold' | 'italic' | 'code' | 'link' | 'list'
17
+
18
+ export interface MarkdownEdit {
19
+ readonly value: string
20
+ readonly selectionStart: number
21
+ readonly selectionEnd: number
22
+ }
23
+
24
+ const WRAPPERS: Readonly<Record<'bold' | 'italic' | 'code', string>> = {
25
+ bold: '**',
26
+ italic: '_',
27
+ code: '`',
28
+ }
29
+
30
+ const LIST_PREFIX = '- '
31
+ const LINK_PLACEHOLDER = 'url'
32
+
33
+ function clamp(n: number, min: number, max: number): number {
34
+ return Math.min(Math.max(n, min), max)
35
+ }
36
+
37
+ function applyWrapper(
38
+ value: string,
39
+ start: number,
40
+ end: number,
41
+ marker: string,
42
+ ): MarkdownEdit {
43
+ const selected = value.slice(start, end)
44
+ const width = marker.length
45
+
46
+ // Case 1 — the markers are INSIDE the selection: "**bold**" selected whole.
47
+ if (selected.length >= width * 2 && selected.startsWith(marker) && selected.endsWith(marker)) {
48
+ const inner = selected.slice(width, selected.length - width)
49
+ return {
50
+ value: value.slice(0, start) + inner + value.slice(end),
51
+ selectionStart: start,
52
+ selectionEnd: start + inner.length,
53
+ }
54
+ }
55
+
56
+ // Case 2 — the markers are OUTSIDE the selection: only "bold" selected
57
+ // inside "**bold**". Same intent, different selection; unwrap either way.
58
+ if (
59
+ start >= width &&
60
+ value.slice(start - width, start) === marker &&
61
+ value.slice(end, end + width) === marker
62
+ ) {
63
+ return {
64
+ value: value.slice(0, start - width) + selected + value.slice(end + width),
65
+ selectionStart: start - width,
66
+ selectionEnd: start - width + selected.length,
67
+ }
68
+ }
69
+
70
+ // Case 3 — wrap. With an empty selection this leaves the caret between the
71
+ // markers, which is what "press bold then type" has to do.
72
+ return {
73
+ value: value.slice(0, start) + marker + selected + marker + value.slice(end),
74
+ selectionStart: start + width,
75
+ selectionEnd: end + width,
76
+ }
77
+ }
78
+
79
+ function applyLink(value: string, start: number, end: number): MarkdownEdit {
80
+ const selected = value.slice(start, end)
81
+ const inserted = `[${selected}](${LINK_PLACEHOLDER})`
82
+ const next = value.slice(0, start) + inserted + value.slice(end)
83
+
84
+ if (selected === '') {
85
+ // Nothing selected: caret goes between the brackets so the label is typed
86
+ // first. The url placeholder is still there to be tabbed/clicked into.
87
+ return { value: next, selectionStart: start + 1, selectionEnd: start + 1 }
88
+ }
89
+ // Text selected: it becomes the label and the url placeholder is selected,
90
+ // so pasting a link immediately replaces it.
91
+ const urlStart = start + selected.length + 3
92
+ return {
93
+ value: next,
94
+ selectionStart: urlStart,
95
+ selectionEnd: urlStart + LINK_PLACEHOLDER.length,
96
+ }
97
+ }
98
+
99
+ function applyList(value: string, start: number, end: number): MarkdownEdit {
100
+ // Lists are a LINE operation, so the selection is grown to whole lines
101
+ // first — otherwise selecting three words in the middle of a paragraph
102
+ // inserts a bullet mid-sentence.
103
+ const lineStart = value.lastIndexOf('\n', Math.max(start - 1, 0)) + 1
104
+ const nextBreak = value.indexOf('\n', end)
105
+ const lineEnd = nextBreak === -1 ? value.length : nextBreak
106
+
107
+ const block = value.slice(lineStart, lineEnd)
108
+ const lines = block.split('\n')
109
+ const meaningful = lines.filter((line) => line.trim() !== '')
110
+ const allPrefixed =
111
+ meaningful.length > 0 && meaningful.every((line) => line.startsWith(LIST_PREFIX))
112
+
113
+ const next = lines
114
+ .map((line) => {
115
+ if (allPrefixed) return line.startsWith(LIST_PREFIX) ? line.slice(LIST_PREFIX.length) : line
116
+ return line.trim() === '' ? line : LIST_PREFIX + line
117
+ })
118
+ .join('\n')
119
+
120
+ return {
121
+ value: value.slice(0, lineStart) + next + value.slice(lineEnd),
122
+ selectionStart: lineStart,
123
+ selectionEnd: lineStart + next.length,
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Pure. Deterministic. No DOM.
129
+ *
130
+ * Out-of-range selections are clamped rather than trusted — a stale
131
+ * `selectionStart` after a controlled re-render is a real thing, and slicing a
132
+ * string with a negative index fails silently instead of loudly.
133
+ */
134
+ export function applyMarkdownFormat(
135
+ value: string,
136
+ selectionStart: number,
137
+ selectionEnd: number,
138
+ format: MarkdownFormat,
139
+ ): MarkdownEdit {
140
+ const start = clamp(Math.min(selectionStart, selectionEnd), 0, value.length)
141
+ const end = clamp(Math.max(selectionStart, selectionEnd), 0, value.length)
142
+
143
+ switch (format) {
144
+ case 'bold':
145
+ return applyWrapper(value, start, end, WRAPPERS.bold)
146
+ case 'italic':
147
+ return applyWrapper(value, start, end, WRAPPERS.italic)
148
+ case 'code':
149
+ return applyWrapper(value, start, end, WRAPPERS.code)
150
+ case 'link':
151
+ return applyLink(value, start, end)
152
+ case 'list':
153
+ return applyList(value, start, end)
154
+ }
155
+ }