@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- 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">· {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
|
+
}
|