@seed-ship/mcp-ui-solid 6.21.0 → 6.23.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/CHANGELOG.md +173 -0
- package/README.md +102 -0
- package/dist/components/ChartJSRenderer.cjs +244 -68
- package/dist/components/ChartJSRenderer.cjs.map +1 -1
- package/dist/components/ChartJSRenderer.d.ts +9 -0
- package/dist/components/ChartJSRenderer.d.ts.map +1 -1
- package/dist/components/ChartJSRenderer.js +246 -70
- package/dist/components/ChartJSRenderer.js.map +1 -1
- package/dist/components/ExpandableWrapper.cjs +9 -1
- package/dist/components/ExpandableWrapper.cjs.map +1 -1
- package/dist/components/ExpandableWrapper.d.ts.map +1 -1
- package/dist/components/ExpandableWrapper.js +9 -1
- package/dist/components/ExpandableWrapper.js.map +1 -1
- package/dist/components/LightboxOverlay.cjs +139 -16
- package/dist/components/LightboxOverlay.cjs.map +1 -1
- package/dist/components/LightboxOverlay.d.ts +6 -0
- package/dist/components/LightboxOverlay.d.ts.map +1 -1
- package/dist/components/LightboxOverlay.js +141 -18
- package/dist/components/LightboxOverlay.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +15 -5
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +2 -1
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +15 -5
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/context/MCPUIConfigContext.cjs +2 -1
- package/dist/context/MCPUIConfigContext.cjs.map +1 -1
- package/dist/context/MCPUIConfigContext.d.ts +90 -12
- package/dist/context/MCPUIConfigContext.d.ts.map +1 -1
- package/dist/context/MCPUIConfigContext.js +2 -1
- package/dist/context/MCPUIConfigContext.js.map +1 -1
- package/dist/context/MCPUIMathContext.cjs +19 -0
- package/dist/context/MCPUIMathContext.cjs.map +1 -0
- package/dist/context/MCPUIMathContext.d.ts +18 -0
- package/dist/context/MCPUIMathContext.d.ts.map +1 -0
- package/dist/context/MCPUIMathContext.js +19 -0
- package/dist/context/MCPUIMathContext.js.map +1 -0
- package/dist/context/MCPUIStringsContext.cjs +6 -1
- package/dist/context/MCPUIStringsContext.cjs.map +1 -1
- package/dist/context/MCPUIStringsContext.d.ts +22 -0
- package/dist/context/MCPUIStringsContext.d.ts.map +1 -1
- package/dist/context/MCPUIStringsContext.js +6 -1
- package/dist/context/MCPUIStringsContext.js.map +1 -1
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks.cjs +2 -0
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.js +2 -0
- package/dist/hooks.js.map +1 -1
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/katex.cjs +23 -0
- package/dist/plugins/katex.cjs.map +1 -0
- package/dist/plugins/katex.d.cts +7 -0
- package/dist/plugins/katex.d.ts +7 -0
- package/dist/plugins/katex.d.ts.map +1 -0
- package/dist/plugins/katex.js +23 -0
- package/dist/plugins/katex.js.map +1 -0
- package/dist/utils/iframe-coep.cjs.map +1 -1
- package/dist/utils/iframe-coep.d.ts +8 -0
- package/dist/utils/iframe-coep.d.ts.map +1 -1
- package/dist/utils/iframe-coep.js.map +1 -1
- package/dist/utils/math-markdown.cjs +240 -0
- package/dist/utils/math-markdown.cjs.map +1 -0
- package/dist/utils/math-markdown.d.ts +16 -0
- package/dist/utils/math-markdown.d.ts.map +1 -0
- package/dist/utils/math-markdown.js +240 -0
- package/dist/utils/math-markdown.js.map +1 -0
- package/dist/utils/pan-zoom.cjs +312 -0
- package/dist/utils/pan-zoom.cjs.map +1 -0
- package/dist/utils/pan-zoom.d.ts +235 -0
- package/dist/utils/pan-zoom.d.ts.map +1 -0
- package/dist/utils/pan-zoom.js +312 -0
- package/dist/utils/pan-zoom.js.map +1 -0
- package/package.json +26 -3
- package/src/components/ChartJSRenderer.tsx +396 -1
- package/src/components/ExpandableWrapper.tsx +26 -0
- package/src/components/LightboxOverlay.tsx +280 -4
- package/src/components/UIResourceRenderer.tsx +24 -3
- package/src/context/MCPUIConfigContext.tsx +92 -12
- package/src/context/MCPUIMathContext.tsx +32 -0
- package/src/context/MCPUIStringsContext.tsx +30 -0
- package/src/hooks/index.ts +13 -0
- package/src/index.ts +20 -0
- package/src/plugins/katex.ts +25 -0
- package/src/utils/iframe-coep.ts +8 -0
- package/src/utils/math-markdown.ts +222 -0
- package/src/utils/pan-zoom.ts +779 -0
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LightboxOverlay - Fullscreen image viewer
|
|
3
3
|
* Sprint 5: Media Components
|
|
4
|
+
*
|
|
5
|
+
* v6.22.0 — zoom & pan. The overlay is a fullscreen `<Portal>`: it OWNS the
|
|
6
|
+
* viewport, nothing scrolls behind it (the effect below locks `body`), so
|
|
7
|
+
* hijacking the wheel here is legitimate in a way it would not be for a
|
|
8
|
+
* component sitting inline in a chat feed. Zoom is therefore always on in the
|
|
9
|
+
* lightbox and needs no host opt-in.
|
|
4
10
|
*/
|
|
5
11
|
|
|
6
|
-
import { Component, Show, createEffect, onCleanup } from 'solid-js'
|
|
12
|
+
import { Component, Show, createEffect, createMemo, onCleanup } from 'solid-js'
|
|
7
13
|
import { Portal } from 'solid-js/web'
|
|
8
14
|
import type { GalleryImage } from '../types'
|
|
9
15
|
import { safeUrl } from '../utils/safe-url'
|
|
10
|
-
import {
|
|
16
|
+
import { createPanZoom, type PanZoomPoint } from '../utils/pan-zoom'
|
|
17
|
+
import { formatMCPUIString, useMCPUIStrings } from '../context/MCPUIStringsContext'
|
|
18
|
+
|
|
19
|
+
/** Highest magnification of the lightbox — 8× the fitted size. */
|
|
20
|
+
const MAX_ZOOM = 8
|
|
21
|
+
|
|
22
|
+
/** Ratio one press of a zoom button (or of `+` / `-`) applies. */
|
|
23
|
+
const ZOOM_BUTTON_FACTOR = 1.5
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Scale a double-click jumps to. Deliberately modest: a double-click is a
|
|
27
|
+
* coarse gesture, and the wheel is right there for the rest.
|
|
28
|
+
*/
|
|
29
|
+
const DOUBLE_CLICK_ZOOM = 2
|
|
11
30
|
|
|
12
31
|
export interface LightboxOverlayProps {
|
|
13
32
|
/**
|
|
@@ -33,6 +52,17 @@ export interface LightboxOverlayProps {
|
|
|
33
52
|
|
|
34
53
|
export const LightboxOverlay: Component<LightboxOverlayProps> = (props) => {
|
|
35
54
|
const strings = useMCPUIStrings()
|
|
55
|
+
let imageRef: HTMLImageElement | undefined
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The handlers are bound on the BACKDROP, not on the `<img>`: a pan that
|
|
59
|
+
* drags the image out from under the cursor must keep receiving the pointer
|
|
60
|
+
* stream, and a wheel event over the black margin around a portrait image
|
|
61
|
+
* must still zoom. `target` therefore points the maths back at the image,
|
|
62
|
+
* which is the element that actually carries the transform.
|
|
63
|
+
*/
|
|
64
|
+
const panZoom = createPanZoom({ min: 1, max: MAX_ZOOM, target: () => imageRef })
|
|
65
|
+
|
|
36
66
|
const isOpen = () => props.selectedIndex !== null
|
|
37
67
|
const currentImage = () =>
|
|
38
68
|
props.selectedIndex !== null ? props.images[props.selectedIndex] : null
|
|
@@ -52,21 +82,129 @@ export const LightboxOverlay: Component<LightboxOverlayProps> = (props) => {
|
|
|
52
82
|
}
|
|
53
83
|
}
|
|
54
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Identity of the picture on screen, as a VALUE rather than as an object
|
|
87
|
+
* reference — `null` while the overlay is closed.
|
|
88
|
+
*
|
|
89
|
+
* The index alone does not name a picture. A host that replaces `images`
|
|
90
|
+
* under a held `selectedIndex` — a gallery whose list streams in, a refilter,
|
|
91
|
+
* a re-render that rebuilds the array — puts a different photo on screen
|
|
92
|
+
* without the index ever moving. `url` and `srcset` are what the `<img>`
|
|
93
|
+
* below actually resolves, so together with the index they are what "the
|
|
94
|
+
* displayed image changed" has to mean. (`sizes` is left out on purpose: it
|
|
95
|
+
* re-picks among the candidates of the SAME picture, so it changes the
|
|
96
|
+
* resolution fetched, never the subject the zoom is framing.)
|
|
97
|
+
*
|
|
98
|
+
* A memo, and a string, so the comparison is by value: `ImageGalleryRenderer`
|
|
99
|
+
* passes `params()?.images || []`, a fresh array on every parent update, and
|
|
100
|
+
* re-reading that must not by itself count as a new picture — it would throw
|
|
101
|
+
* away a zoom the user is in the middle of. The index stays in the key so
|
|
102
|
+
* that navigating between two entries sharing a URL is still the clean slate
|
|
103
|
+
* the arrows promise.
|
|
104
|
+
*/
|
|
105
|
+
const displayedImage = createMemo(() => {
|
|
106
|
+
if (props.selectedIndex === null) return null
|
|
107
|
+
const image = props.images[props.selectedIndex]
|
|
108
|
+
return JSON.stringify([props.selectedIndex, image?.url ?? null, image?.srcset ?? null])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Every image opens at its fitted size. Without this the transform left over
|
|
113
|
+
* from the previous picture would be applied to the next one — a 6× zoom on
|
|
114
|
+
* a corner of a portrait photo, carried onto a landscape one, shows black.
|
|
115
|
+
* It also settles the "arrows vs. panning" question: navigating away is
|
|
116
|
+
* always a clean slate rather than a frame half-belonging to two images.
|
|
117
|
+
*
|
|
118
|
+
* The dependency is `displayedImage()` and not `selectedIndex`, for the same
|
|
119
|
+
* reason: a swapped-out `images` array reaches the user as exactly that black
|
|
120
|
+
* frame — the old picture's scale and offset applied to a new photo — with no
|
|
121
|
+
* navigation to blame it on.
|
|
122
|
+
*
|
|
123
|
+
* `reset()` reads its state through `untrack`, so this effect subscribes to
|
|
124
|
+
* that one memo and never re-runs on its own write. The scale and offset it
|
|
125
|
+
* writes are absent from the memo for the same reason: panning must not
|
|
126
|
+
* reset the pan.
|
|
127
|
+
*
|
|
128
|
+
* `cancelGesture()` comes first, and matters most on the CLOSE (`null`): the
|
|
129
|
+
* pan handlers live on the backdrop, inside the `<Show>` below, but the
|
|
130
|
+
* controller belongs to this component — which `ImageGalleryRenderer` mounts
|
|
131
|
+
* once and never unmounts. Close the overlay with a button still held
|
|
132
|
+
* (Escape mid-drag) and the backdrop is detached before its `pointerup`,
|
|
133
|
+
* which is then delivered to whatever is underneath and never to these
|
|
134
|
+
* handlers. Nothing else would end that gesture: `reset()` only writes the
|
|
135
|
+
* transform, and `onCleanup` belongs to an owner that is still alive. The
|
|
136
|
+
* overlay would reopen stuck at `cursor: grabbing`, with `isPanning()` true
|
|
137
|
+
* and the arrow keys dead behind their pan guards.
|
|
138
|
+
*/
|
|
139
|
+
createEffect(() => {
|
|
140
|
+
displayedImage()
|
|
141
|
+
panZoom.cancelGesture()
|
|
142
|
+
panZoom.reset()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Is the keystroke going into something the user is typing in?
|
|
147
|
+
*
|
|
148
|
+
* The overlay binds its shortcuts on `document`, so it hears every key in
|
|
149
|
+
* the page — and it does not trap focus, so the host's own composer can keep
|
|
150
|
+
* it while the lightbox is open (`Tab` from the thumbnail that opened the
|
|
151
|
+
* overlay walks on into the host, since the `<Portal>` content is appended
|
|
152
|
+
* after it). The zoom keys are `-`, `+`, `=` and `0`: characters. Calling
|
|
153
|
+
* `preventDefault()` on them without this check silently eats them out of
|
|
154
|
+
* the host's text field — typing a date would zoom the photo instead.
|
|
155
|
+
*
|
|
156
|
+
* `Escape` stays global, as it was before the zoom keys existed: it produces
|
|
157
|
+
* no character, and dismissing the modal is what it is for.
|
|
158
|
+
*/
|
|
159
|
+
const isEditableTarget = (target: EventTarget | null): boolean => {
|
|
160
|
+
if (!(target instanceof HTMLElement)) return false
|
|
161
|
+
if (target.isContentEditable) return true
|
|
162
|
+
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
|
|
163
|
+
}
|
|
164
|
+
|
|
55
165
|
// Keyboard navigation
|
|
56
166
|
createEffect(() => {
|
|
57
167
|
if (!isOpen()) return
|
|
58
168
|
|
|
59
169
|
const handleKeydown = (e: KeyboardEvent) => {
|
|
170
|
+
// Ctrl/Cmd + `+` / `-` / `0` is the browser's own page zoom; the overlay
|
|
171
|
+
// must not swallow it.
|
|
172
|
+
const isZoomChord = e.ctrlKey || e.metaKey
|
|
173
|
+
|
|
174
|
+
// A field the user is typing in owns its keys — all of them but the one
|
|
175
|
+
// that dismisses a modal.
|
|
176
|
+
if (e.key !== 'Escape' && isEditableTarget(e.target)) return
|
|
177
|
+
|
|
60
178
|
switch (e.key) {
|
|
61
179
|
case 'Escape':
|
|
62
180
|
props.onClose()
|
|
63
181
|
break
|
|
64
182
|
case 'ArrowLeft':
|
|
183
|
+
// Mid-drag the arrows would move the picture the user is holding.
|
|
184
|
+
if (panZoom.isPanning()) break
|
|
65
185
|
handlePrev()
|
|
66
186
|
break
|
|
67
187
|
case 'ArrowRight':
|
|
188
|
+
if (panZoom.isPanning()) break
|
|
68
189
|
handleNext()
|
|
69
190
|
break
|
|
191
|
+
case '+':
|
|
192
|
+
case '=':
|
|
193
|
+
// `=` is `+` unshifted — the key people actually press.
|
|
194
|
+
if (isZoomChord) break
|
|
195
|
+
e.preventDefault()
|
|
196
|
+
panZoom.zoomBy(ZOOM_BUTTON_FACTOR)
|
|
197
|
+
break
|
|
198
|
+
case '-':
|
|
199
|
+
if (isZoomChord) break
|
|
200
|
+
e.preventDefault()
|
|
201
|
+
panZoom.zoomBy(1 / ZOOM_BUTTON_FACTOR)
|
|
202
|
+
break
|
|
203
|
+
case '0':
|
|
204
|
+
if (isZoomChord) break
|
|
205
|
+
e.preventDefault()
|
|
206
|
+
panZoom.reset()
|
|
207
|
+
break
|
|
70
208
|
}
|
|
71
209
|
}
|
|
72
210
|
|
|
@@ -79,12 +217,74 @@ export const LightboxOverlay: Component<LightboxOverlayProps> = (props) => {
|
|
|
79
217
|
})
|
|
80
218
|
})
|
|
81
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Closing on a backdrop click is the behaviour users expect — but a pan
|
|
222
|
+
* releases the pointer over the backdrop as often as not, and the browser
|
|
223
|
+
* then fires a `click` on the common ancestor of the press and the release.
|
|
224
|
+
* `wasDragged()` is exactly that distinction: it stays true from the moment
|
|
225
|
+
* the pointer travelled more than a few pixels until the next `pointerdown`,
|
|
226
|
+
* so the click that closes the overlay is only ever a click that did not move.
|
|
227
|
+
*/
|
|
228
|
+
const handleBackdropClick = () => {
|
|
229
|
+
if (panZoom.wasDragged()) return
|
|
230
|
+
props.onClose()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The chrome (close, prev/next, the zoom toolbar) sits on top of the pan
|
|
235
|
+
* surface. Pressing a button must not open a gesture: while zoomed
|
|
236
|
+
* `onPointerDown` calls `preventDefault()`, which costs the button its focus,
|
|
237
|
+
* and a hand that shakes on the way to "next" would pan the image instead.
|
|
238
|
+
*/
|
|
239
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
240
|
+
const target = event.target
|
|
241
|
+
if (target instanceof Element && target.closest('button')) return
|
|
242
|
+
panZoom.onPointerDown(event)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Viewport point of a mouse event, in the space `zoomTo` anchors on: client
|
|
247
|
+
* pixels measured from the image's UNTRANSFORMED top-left corner. That corner
|
|
248
|
+
* is not directly observable — `getBoundingClientRect()` already includes the
|
|
249
|
+
* transform — but with `transform-origin: 0 0` the rendered left edge is
|
|
250
|
+
* exactly `corner + offset`, so subtracting the current offset recovers it
|
|
251
|
+
* whatever the flex centring did.
|
|
252
|
+
*
|
|
253
|
+
* `createPanZoom` computes the same point for the wheel but keeps it private;
|
|
254
|
+
* this is the double-click's copy of it.
|
|
255
|
+
*/
|
|
256
|
+
const anchorOf = (event: MouseEvent): PanZoomPoint | undefined => {
|
|
257
|
+
if (!imageRef) return undefined
|
|
258
|
+
const rect = imageRef.getBoundingClientRect()
|
|
259
|
+
const offset = panZoom.offset()
|
|
260
|
+
return {
|
|
261
|
+
x: event.clientX - (rect.left - offset.x),
|
|
262
|
+
y: event.clientY - (rect.top - offset.y),
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Double-click toggles: in at the spot that was double-clicked, or back out. */
|
|
267
|
+
const handleDoubleClick = (event: MouseEvent) => {
|
|
268
|
+
event.stopPropagation()
|
|
269
|
+
if (panZoom.isZoomed()) {
|
|
270
|
+
panZoom.reset()
|
|
271
|
+
} else {
|
|
272
|
+
panZoom.zoomTo(DOUBLE_CLICK_ZOOM, anchorOf(event))
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
82
276
|
return (
|
|
83
277
|
<Show when={isOpen()}>
|
|
84
278
|
<Portal>
|
|
85
279
|
<div
|
|
86
280
|
class="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
|
|
87
|
-
|
|
281
|
+
style={panZoom.surfaceStyle()}
|
|
282
|
+
onClick={handleBackdropClick}
|
|
283
|
+
onWheel={panZoom.onWheel}
|
|
284
|
+
onPointerDown={handlePointerDown}
|
|
285
|
+
onPointerMove={panZoom.onPointerMove}
|
|
286
|
+
onPointerUp={panZoom.onPointerUp}
|
|
287
|
+
onPointerCancel={panZoom.onPointerCancel}
|
|
88
288
|
role="dialog"
|
|
89
289
|
aria-modal="true"
|
|
90
290
|
aria-label={strings.lightboxLabel}
|
|
@@ -147,14 +347,18 @@ export const LightboxOverlay: Component<LightboxOverlayProps> = (props) => {
|
|
|
147
347
|
</button>
|
|
148
348
|
</Show>
|
|
149
349
|
|
|
150
|
-
{/* Image */}
|
|
350
|
+
{/* Image — `max-w/max-h` fit it, the transform zooms it from there */}
|
|
151
351
|
<img
|
|
352
|
+
ref={imageRef}
|
|
152
353
|
src={safeUrl(currentImage()?.url ?? '', { allowDataImage: true })}
|
|
153
354
|
alt={currentImage()?.alt || ''}
|
|
154
355
|
srcset={currentImage()?.srcset}
|
|
155
356
|
sizes={currentImage()?.sizes}
|
|
156
357
|
class="max-w-[90vw] max-h-[90vh] object-contain"
|
|
358
|
+
style={panZoom.contentStyle()}
|
|
359
|
+
draggable={false}
|
|
157
360
|
onClick={(e) => e.stopPropagation()}
|
|
361
|
+
onDblClick={handleDoubleClick}
|
|
158
362
|
/>
|
|
159
363
|
|
|
160
364
|
{/* Caption */}
|
|
@@ -168,6 +372,78 @@ export const LightboxOverlay: Component<LightboxOverlayProps> = (props) => {
|
|
|
168
372
|
<div class="absolute top-4 left-4 text-white/80 bg-black/40 px-3 py-1 rounded-full text-sm">
|
|
169
373
|
{(props.selectedIndex ?? 0) + 1} / {props.images.length}
|
|
170
374
|
</div>
|
|
375
|
+
|
|
376
|
+
{/* Zoom controls — the pointer-only gestures made reachable by
|
|
377
|
+
keyboard and touch. The wrapper swallows the click so the
|
|
378
|
+
backdrop's close handler never sees it. */}
|
|
379
|
+
<div
|
|
380
|
+
class="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1 bg-black/50 rounded-full px-2 py-1"
|
|
381
|
+
onClick={(e) => e.stopPropagation()}
|
|
382
|
+
>
|
|
383
|
+
<button
|
|
384
|
+
class="text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors disabled:opacity-40 disabled:hover:bg-transparent"
|
|
385
|
+
onClick={() => panZoom.zoomBy(1 / ZOOM_BUTTON_FACTOR)}
|
|
386
|
+
disabled={panZoom.scale() <= panZoom.min}
|
|
387
|
+
aria-label={strings.zoomOut}
|
|
388
|
+
title={strings.zoomOut}
|
|
389
|
+
type="button"
|
|
390
|
+
>
|
|
391
|
+
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
392
|
+
<path
|
|
393
|
+
stroke-linecap="round"
|
|
394
|
+
stroke-linejoin="round"
|
|
395
|
+
stroke-width="2"
|
|
396
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM13.5 10.5h-6"
|
|
397
|
+
/>
|
|
398
|
+
</svg>
|
|
399
|
+
</button>
|
|
400
|
+
|
|
401
|
+
<span
|
|
402
|
+
class="text-white/80 text-xs tabular-nums text-center min-w-[4rem]"
|
|
403
|
+
aria-live="polite"
|
|
404
|
+
>
|
|
405
|
+
{formatMCPUIString(strings.zoomLevel, { percent: panZoom.percent() })}
|
|
406
|
+
</span>
|
|
407
|
+
|
|
408
|
+
<button
|
|
409
|
+
class="text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors disabled:opacity-40 disabled:hover:bg-transparent"
|
|
410
|
+
onClick={() => panZoom.zoomBy(ZOOM_BUTTON_FACTOR)}
|
|
411
|
+
disabled={panZoom.scale() >= panZoom.max}
|
|
412
|
+
aria-label={strings.zoomIn}
|
|
413
|
+
title={strings.zoomIn}
|
|
414
|
+
type="button"
|
|
415
|
+
>
|
|
416
|
+
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
417
|
+
<path
|
|
418
|
+
stroke-linecap="round"
|
|
419
|
+
stroke-linejoin="round"
|
|
420
|
+
stroke-width="2"
|
|
421
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM10.5 7.5v6m3-3h-6"
|
|
422
|
+
/>
|
|
423
|
+
</svg>
|
|
424
|
+
</button>
|
|
425
|
+
|
|
426
|
+
{/* Only while there is something to reset — at 100% it would be a
|
|
427
|
+
button that does nothing. */}
|
|
428
|
+
<Show when={panZoom.isZoomed()}>
|
|
429
|
+
<button
|
|
430
|
+
class="text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors"
|
|
431
|
+
onClick={() => panZoom.reset()}
|
|
432
|
+
aria-label={strings.zoomReset}
|
|
433
|
+
title={strings.zoomReset}
|
|
434
|
+
type="button"
|
|
435
|
+
>
|
|
436
|
+
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
437
|
+
<path
|
|
438
|
+
stroke-linecap="round"
|
|
439
|
+
stroke-linejoin="round"
|
|
440
|
+
stroke-width="2"
|
|
441
|
+
d="M16.023 9.348h4.992V4.356m0 4.992l-3.181-3.183a8.25 8.25 0 00-13.803 3.7M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7"
|
|
442
|
+
/>
|
|
443
|
+
</svg>
|
|
444
|
+
</button>
|
|
445
|
+
</Show>
|
|
446
|
+
</div>
|
|
171
447
|
</div>
|
|
172
448
|
</Portal>
|
|
173
449
|
</Show>
|
|
@@ -67,6 +67,8 @@ import { RenderProvider } from './RenderContext'
|
|
|
67
67
|
import { useAction } from '../hooks/useAction'
|
|
68
68
|
import { createResponsiveGrid } from '../hooks/createResponsiveGrid'
|
|
69
69
|
import { marked } from 'marked'
|
|
70
|
+
import { useMCPUIMath, type MathRenderer } from '../context/MCPUIMathContext'
|
|
71
|
+
import { parseMathMarkdown } from '../utils/math-markdown'
|
|
70
72
|
|
|
71
73
|
/**
|
|
72
74
|
* Copy button component with visual feedback
|
|
@@ -443,6 +445,9 @@ export function highlightQuery(html: string, query: string): string {
|
|
|
443
445
|
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_TEXT)
|
|
444
446
|
const textNodes: Text[] = []
|
|
445
447
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
448
|
+
// A HTML <mark> inside MathML can change or break its layout/semantics.
|
|
449
|
+
// Keep formulas intact; ordinary text around them remains searchable.
|
|
450
|
+
if (node.parentElement?.closest('math')) continue
|
|
446
451
|
textNodes.push(node as Text)
|
|
447
452
|
}
|
|
448
453
|
|
|
@@ -595,7 +600,7 @@ function transformCellCitations(text: string, ctx: CitationCtx): string {
|
|
|
595
600
|
)
|
|
596
601
|
}
|
|
597
602
|
|
|
598
|
-
export function renderCellValue(value: any, citationCtx?: CitationCtx): string {
|
|
603
|
+
export function renderCellValue(value: any, citationCtx?: CitationCtx, mathRenderer?: MathRenderer): string {
|
|
599
604
|
// Handle null/undefined
|
|
600
605
|
if (value === null || value === undefined) {
|
|
601
606
|
return '-'
|
|
@@ -646,6 +651,18 @@ export function renderCellValue(value: any, citationCtx?: CitationCtx): string {
|
|
|
646
651
|
return '-'
|
|
647
652
|
}
|
|
648
653
|
|
|
654
|
+
// Math must be tokenized before Markdown or citation rewrites can consume
|
|
655
|
+
// TeX underscores or bracket arguments (e.g. \sqrt[3]{x}). The opt-in path
|
|
656
|
+
// transforms citations only in ordinary text tokens, never math/code/URLs.
|
|
657
|
+
if (mathRenderer && strValue.includes('$')) {
|
|
658
|
+
return parseMathMarkdown(
|
|
659
|
+
strValue,
|
|
660
|
+
mathRenderer,
|
|
661
|
+
'cellMarkdown',
|
|
662
|
+
citationCtx ? (text) => transformCellCitations(text, citationCtx) : undefined,
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
649
666
|
// Detect and convert markdown links: [text](url) — runs FIRST because
|
|
650
667
|
// the citation transform's negative lookahead `(?!\()` would also skip
|
|
651
668
|
// these, but handling them here keeps the existing return path.
|
|
@@ -706,6 +723,7 @@ function TableRenderer(props: {
|
|
|
706
723
|
const tableParams = props.component.params as any
|
|
707
724
|
let scrollContainerRef: HTMLDivElement | undefined
|
|
708
725
|
const strings = useMCPUIStrings()
|
|
726
|
+
const mathRenderer = useMCPUIMath()
|
|
709
727
|
|
|
710
728
|
// v5.7.0 — opt-in citation chip rendering inside cells. When `citationMap`
|
|
711
729
|
// is present in params, build a CitationCtx once and thread it through
|
|
@@ -1006,7 +1024,7 @@ function TableRenderer(props: {
|
|
|
1006
1024
|
<For each={tableParams.columns}>
|
|
1007
1025
|
{(column: any) => (
|
|
1008
1026
|
<td class="px-6 py-4 text-sm text-gray-700 dark:text-gray-200 whitespace-normal break-words leading-relaxed first:pl-6 last:pr-6">
|
|
1009
|
-
<SafeHtml html={() => highlightQuery(renderCellValue(row[column.key], citationCtx), debouncedQuery())} />
|
|
1027
|
+
<SafeHtml html={() => highlightQuery(renderCellValue(row[column.key], citationCtx, mathRenderer()), debouncedQuery())} />
|
|
1010
1028
|
</td>
|
|
1011
1029
|
)}
|
|
1012
1030
|
</For>
|
|
@@ -1045,7 +1063,7 @@ function TableRenderer(props: {
|
|
|
1045
1063
|
<For each={tableParams.columns}>
|
|
1046
1064
|
{(column: any) => (
|
|
1047
1065
|
<td class="px-6 py-4 text-sm text-gray-700 dark:text-gray-200 whitespace-normal break-words leading-relaxed first:pl-6 last:pr-6">
|
|
1048
|
-
<SafeHtml html={() => highlightQuery(renderCellValue(row[column.key], citationCtx), debouncedQuery())} />
|
|
1066
|
+
<SafeHtml html={() => highlightQuery(renderCellValue(row[column.key], citationCtx, mathRenderer()), debouncedQuery())} />
|
|
1049
1067
|
</td>
|
|
1050
1068
|
)}
|
|
1051
1069
|
</For>
|
|
@@ -1362,6 +1380,7 @@ function extractImageFromMarkdown(
|
|
|
1362
1380
|
*/
|
|
1363
1381
|
function TextRenderer(props: { component: UIComponent }) {
|
|
1364
1382
|
const strings = useMCPUIStrings()
|
|
1383
|
+
const mathRenderer = useMCPUIMath()
|
|
1365
1384
|
const textParams = props.component.params as any
|
|
1366
1385
|
|
|
1367
1386
|
// Check if this is an image markdown that should be rendered as image
|
|
@@ -1399,6 +1418,8 @@ function TextRenderer(props: { component: UIComponent }) {
|
|
|
1399
1418
|
return escapeHtml(String(raw))
|
|
1400
1419
|
}
|
|
1401
1420
|
if (textParams.markdown && !imageData()) {
|
|
1421
|
+
const renderMath = mathRenderer()
|
|
1422
|
+
if (renderMath) return parseMathMarkdown(String(raw), renderMath, 'prose')
|
|
1402
1423
|
return sanitizeHtml(marked.parse(raw, { async: false }) as string, SANITIZE_PROFILES.prose)
|
|
1403
1424
|
}
|
|
1404
1425
|
return sanitizeHtml(String(raw), SANITIZE_PROFILES.prose)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCPUIConfigContext — host-level rendering policy
|
|
2
|
+
* MCPUIConfigContext — host-level rendering policy: the library's iframes
|
|
3
|
+
* (v6.21.0) and, since v6.22.0, where a chart may capture the wheel.
|
|
3
4
|
*
|
|
4
5
|
* @since v6.21.0
|
|
5
6
|
*
|
|
@@ -41,8 +42,35 @@ import type { IframePolicy } from '../types'
|
|
|
41
42
|
/**
|
|
42
43
|
* Host-level rendering policy. Every field has a default — a provider passes
|
|
43
44
|
* a partial override.
|
|
45
|
+
*
|
|
46
|
+
* ## Two names, because there are two jobs
|
|
47
|
+
*
|
|
48
|
+
* This type grows a key every time the library gains a policy switch, and
|
|
49
|
+
* those releases are MINOR — so neither AUTHORING a config nor READING one
|
|
50
|
+
* may break when a key appears. Those two demands pull in opposite
|
|
51
|
+
* directions, and one name cannot serve both:
|
|
52
|
+
*
|
|
53
|
+
* - an author must be allowed to omit the new key, so the fields they write
|
|
54
|
+
* have to be OPTIONAL;
|
|
55
|
+
* - a reader assigning `const p: IframePolicy = config.iframePolicy` must not
|
|
56
|
+
* suddenly face `undefined`, so the fields they read have to be REQUIRED.
|
|
57
|
+
*
|
|
58
|
+
* Hence {@link MCPUIConfigInput} (what a host writes — every field optional)
|
|
59
|
+
* and {@link MCPUIConfig} (what the library hands back — every field
|
|
60
|
+
* resolved). `MCPUIConfig` is derived from the input type, so the two can
|
|
61
|
+
* never drift and a key is declared exactly once.
|
|
62
|
+
*
|
|
63
|
+
* Which one you name depends on the surface:
|
|
64
|
+
*
|
|
65
|
+
* - `<MCPUIConfigProvider config={…}>` takes `MCPUIConfigInput` — name that
|
|
66
|
+
* type for a partial you build, and it merges the rest;
|
|
67
|
+
* - `<MCPUIConfigContext.Provider value={…}>` takes the RESOLVED
|
|
68
|
+
* `MCPUIConfig`, because that context is read as well as written, so a
|
|
69
|
+
* direct provider hands over `{ ...DEFAULT_MCPUI_CONFIG, ...mine }`;
|
|
70
|
+
* - anything you RECEIVE is `MCPUIConfig`, which is what
|
|
71
|
+
* {@link useMCPUIConfig} returns.
|
|
44
72
|
*/
|
|
45
|
-
export interface
|
|
73
|
+
export interface MCPUIConfigInput {
|
|
46
74
|
/**
|
|
47
75
|
* When the library puts the boolean `credentialless` attribute on an
|
|
48
76
|
* `<iframe>` it renders.
|
|
@@ -60,7 +88,7 @@ export interface MCPUIConfig {
|
|
|
60
88
|
* blocked while the host sends COEP); Safari ignores the COEP value itself,
|
|
61
89
|
* so nothing is blocked there in the first place.
|
|
62
90
|
*/
|
|
63
|
-
iframeCredentialless
|
|
91
|
+
iframeCredentialless?: 'auto' | 'always' | 'never'
|
|
64
92
|
|
|
65
93
|
/**
|
|
66
94
|
* Extra hosts treated as trusted, on top of `TRUSTED_IFRAME_DOMAINS`:
|
|
@@ -69,23 +97,23 @@ export interface MCPUIConfig {
|
|
|
69
97
|
*
|
|
70
98
|
* This only RECLASSIFIES a host the allow-list already accepts. To render an
|
|
71
99
|
* iframe whose host is outside `DEFAULT_IFRAME_DOMAINS`, add it to
|
|
72
|
-
* {@link
|
|
100
|
+
* {@link MCPUIConfigInput.customIframeDomains} with `iframePolicy: 'extend'`.
|
|
73
101
|
*/
|
|
74
|
-
customTrustedIframeDomains
|
|
102
|
+
customTrustedIframeDomains?: string[]
|
|
75
103
|
|
|
76
104
|
/**
|
|
77
105
|
* How `UIResourceRenderer` validates an `iframe` component's host:
|
|
78
106
|
* `'strict'` (default) accepts `DEFAULT_IFRAME_DOMAINS` only, `'extend'`
|
|
79
|
-
* also accepts {@link
|
|
107
|
+
* also accepts {@link MCPUIConfigInput.customIframeDomains}.
|
|
80
108
|
*/
|
|
81
|
-
iframePolicy
|
|
109
|
+
iframePolicy?: IframePolicy
|
|
82
110
|
|
|
83
111
|
/**
|
|
84
112
|
* Extra hosts the allow-list accepts when `iframePolicy` is `'extend'`.
|
|
85
113
|
* Without this, a component pointing at an unlisted host is replaced by the
|
|
86
114
|
* validation card before any renderer runs.
|
|
87
115
|
*/
|
|
88
|
-
customIframeDomains
|
|
116
|
+
customIframeDomains?: string[]
|
|
89
117
|
|
|
90
118
|
/**
|
|
91
119
|
* When the library renders an "open in a new tab" link under an embed
|
|
@@ -101,12 +129,47 @@ export interface MCPUIConfig {
|
|
|
101
129
|
* render, so SSR markup and hydration agree.
|
|
102
130
|
* - `'always'` / `'never'` — unconditional.
|
|
103
131
|
*/
|
|
104
|
-
iframeFallbackLink
|
|
132
|
+
iframeFallbackLink?: 'auto' | 'always' | 'never'
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Where `ChartJSRenderer` enables wheel-zoom / drag-pan / pinch on a chart
|
|
136
|
+
* (through the optional `chartjs-plugin-zoom` peer dependency — absent, the
|
|
137
|
+
* chart renders exactly as it does without this setting).
|
|
138
|
+
*
|
|
139
|
+
* Hijacking the mouse wheel on a component sitting inline in a scrolling
|
|
140
|
+
* chat feed is hostile: the user scrolls the conversation and the chart eats
|
|
141
|
+
* the gesture. So zoom belongs to a surface that OWNS its viewport, which
|
|
142
|
+
* here is `ExpandableWrapper`'s fullscreen modal.
|
|
143
|
+
*
|
|
144
|
+
* - `'expanded'` (default) — only inside the fullscreen modal.
|
|
145
|
+
* - `'always'` — inline as well. For a host that lays charts out in a
|
|
146
|
+
* dashboard rather than in a feed, where the wheel has nothing to steal.
|
|
147
|
+
* - `'never'` — off everywhere; the plugin is never registered.
|
|
148
|
+
*
|
|
149
|
+
* @since 6.22.0
|
|
150
|
+
*/
|
|
151
|
+
chartZoom?: 'expanded' | 'always' | 'never'
|
|
105
152
|
}
|
|
106
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The resolved host policy: every key present. This is what the library hands
|
|
156
|
+
* back, so a reader never null-checks a policy.
|
|
157
|
+
*
|
|
158
|
+
* Extends `Required<`{@link MCPUIConfigInput}`>` rather than declaring the
|
|
159
|
+
* fields again, so a key exists in exactly one place and the two views cannot
|
|
160
|
+
* drift. It remains an interface so existing consumers can extend or augment
|
|
161
|
+
* the public contract as they could before 6.22.0.
|
|
162
|
+
*/
|
|
163
|
+
export interface MCPUIConfig extends Required<MCPUIConfigInput> {}
|
|
164
|
+
|
|
107
165
|
/**
|
|
108
166
|
* Defaults. Chosen so a host that sends no COEP header sees no change from
|
|
109
|
-
* 6.20.0 apart from the fallback link, which `'auto'` keeps hidden there
|
|
167
|
+
* 6.20.0 apart from the fallback link, which `'auto'` keeps hidden there —
|
|
168
|
+
* and, since 6.22.0, so no inline component ever captures the wheel
|
|
169
|
+
* (`chartZoom: 'expanded'`).
|
|
170
|
+
*
|
|
171
|
+
* Typed `MCPUIConfig` (i.e. resolved): that annotation is what stops a new
|
|
172
|
+
* key from shipping without a default.
|
|
110
173
|
*/
|
|
111
174
|
export const DEFAULT_MCPUI_CONFIG: MCPUIConfig = {
|
|
112
175
|
iframeCredentialless: 'auto',
|
|
@@ -114,13 +177,30 @@ export const DEFAULT_MCPUI_CONFIG: MCPUIConfig = {
|
|
|
114
177
|
iframePolicy: 'strict',
|
|
115
178
|
customIframeDomains: [],
|
|
116
179
|
iframeFallbackLink: 'auto',
|
|
180
|
+
chartZoom: 'expanded',
|
|
117
181
|
}
|
|
118
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Typed with the RESOLVED view, because this context is read as well as
|
|
185
|
+
* written: it is exported, so a consumer may call
|
|
186
|
+
* `useContext(MCPUIConfigContext)` directly, and typing it as the input view
|
|
187
|
+
* would hand them `IframePolicy | undefined` — exactly the regression this
|
|
188
|
+
* whole split exists to undo.
|
|
189
|
+
*
|
|
190
|
+
* The cost lands on the rarer side: a host feeding this context directly
|
|
191
|
+
* passes a COMPLETE value, `{ ...DEFAULT_MCPUI_CONFIG, ...mine }`. Anyone who
|
|
192
|
+
* wants to pass only the keys they care about uses `MCPUIConfigProvider`,
|
|
193
|
+
* whose `config` prop takes `Partial<`{@link MCPUIConfig}`>` and does that
|
|
194
|
+
* merge. That preserves optional properties added through declaration
|
|
195
|
+
* merging; {@link MCPUIConfigInput} remains the unaugmented built-in input
|
|
196
|
+
* shape a host can name for ordinary authored configs.
|
|
197
|
+
*/
|
|
119
198
|
export const MCPUIConfigContext = createContext<MCPUIConfig>(DEFAULT_MCPUI_CONFIG)
|
|
120
199
|
|
|
121
200
|
/**
|
|
122
|
-
* Reads the active host config
|
|
123
|
-
*
|
|
201
|
+
* Reads the active host config, resolved: every key is present, whether it
|
|
202
|
+
* came from the provider or from `DEFAULT_MCPUI_CONFIG`. Returns the defaults
|
|
203
|
+
* outright when no `<MCPUIConfigProvider>` is mounted above.
|
|
124
204
|
*/
|
|
125
205
|
export function useMCPUIConfig(): MCPUIConfig {
|
|
126
206
|
return mergeProps(DEFAULT_MCPUI_CONFIG, useContext(MCPUIConfigContext))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createContext, useContext, type Accessor, type JSX } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
export interface MathRenderOptions {
|
|
4
|
+
displayMode: boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** A synchronous, host-supplied renderer. Returned markup is always sanitized by MCP-UI. */
|
|
8
|
+
export type MathRenderer = (tex: string, options: MathRenderOptions) => string | null
|
|
9
|
+
|
|
10
|
+
const MCPUIMathContext = createContext<Accessor<MathRenderer | undefined>>(() => undefined)
|
|
11
|
+
|
|
12
|
+
export interface MCPUIMathProviderProps {
|
|
13
|
+
/** Omit or pass null to explicitly disable an inherited renderer. */
|
|
14
|
+
renderMath?: MathRenderer | null
|
|
15
|
+
children: JSX.Element
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function MCPUIMathProvider(props: MCPUIMathProviderProps): JSX.Element {
|
|
19
|
+
return (
|
|
20
|
+
<MCPUIMathContext.Provider value={() => props.renderMath ?? undefined}>
|
|
21
|
+
{props.children}
|
|
22
|
+
</MCPUIMathContext.Provider>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns a reactive accessor for the nearest math renderer. Call the accessor
|
|
28
|
+
* at use time; its value changes when a provider swaps or disables its renderer.
|
|
29
|
+
*/
|
|
30
|
+
export function useMCPUIMath(): Accessor<MathRenderer | undefined> {
|
|
31
|
+
return useContext(MCPUIMathContext)
|
|
32
|
+
}
|