|
|
@@ -1,6 +1,7 @@
|
|
|
-import { useEffect, useState } from 'react'
|
|
|
-import { ImageOff } from 'lucide-react'
|
|
|
+import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
+import { ImageOff, Maximize2 } from 'lucide-react'
|
|
|
import { api } from '../../api/client'
|
|
|
+import { ImageLightbox } from './ImageLightbox'
|
|
|
|
|
|
// A picture an execution produced, referenced rather than embedded. Node outputs
|
|
|
// no longer carry base64 - they carry a file-store id, or the address the image
|
|
|
@@ -9,10 +10,38 @@ interface ImageRef {
|
|
|
key: string
|
|
|
fileId?: string
|
|
|
url?: string
|
|
|
+ // Base64 bytes carried in the output itself. A generated image on its way to
|
|
|
+ // the file store, an attachment, a node that answers with b64_json - none of
|
|
|
+ // these have an id or an address yet, so before this they showed as a wall of
|
|
|
+ // characters in the JSON and nothing else.
|
|
|
+ base64?: string
|
|
|
+ mimeType?: string
|
|
|
}
|
|
|
|
|
|
const IMAGE_URL = /\.(png|jpe?g|gif|webp|avif|bmp)(\?|$)/i
|
|
|
|
|
|
+// A data: URL carries its own type, so it can be shown as it stands.
|
|
|
+const DATA_URL = /^data:image\/[a-z0-9.+-]+;base64,/i
|
|
|
+
|
|
|
+// Fields that hold image bytes. Matched by name rather than by sniffing every
|
|
|
+// long string: a base64 blob and a long token look alike, and rendering a
|
|
|
+// credential as a broken image would be worse than not rendering it.
|
|
|
+const BASE64_FIELDS = [
|
|
|
+ 'imageBase64', 'contentBase64', 'base64Data', 'base64', 'b64_json', 'imageData',
|
|
|
+]
|
|
|
+
|
|
|
+// The first bytes of the common formats, base64-encoded. Used to tell an image
|
|
|
+// from any other base64 - an OCR text attachment is base64 too, and showing it
|
|
|
+// as a broken thumbnail helps nobody.
|
|
|
+function imageTypeOf(base64: string): string | null {
|
|
|
+ if (base64.startsWith('iVBORw0KGgo')) return 'image/png'
|
|
|
+ if (base64.startsWith('/9j/')) return 'image/jpeg'
|
|
|
+ if (base64.startsWith('R0lGOD')) return 'image/gif'
|
|
|
+ if (base64.startsWith('UklGR')) return 'image/webp'
|
|
|
+ if (base64.startsWith('Qk')) return 'image/bmp'
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
function collectImageRefs(value: unknown, depth = 0, found: ImageRef[] = []): ImageRef[] {
|
|
|
if (!value || typeof value !== 'object' || depth > 4 || found.length >= 4) {
|
|
|
return found
|
|
|
@@ -31,10 +60,36 @@ function collectImageRefs(value: unknown, depth = 0, found: ImageRef[] = []): Im
|
|
|
if (typeof obj.fileId === 'string' && obj.fileId.length > 0) {
|
|
|
found.push({ key: obj.fileId, fileId: obj.fileId })
|
|
|
} else {
|
|
|
+ let matched = false
|
|
|
for (const field of ['sourceUrl', 'url']) {
|
|
|
const candidate = obj[field]
|
|
|
- if (typeof candidate === 'string' && IMAGE_URL.test(candidate)) {
|
|
|
+ if (typeof candidate === 'string' && (IMAGE_URL.test(candidate) || DATA_URL.test(candidate))) {
|
|
|
found.push({ key: candidate, url: candidate })
|
|
|
+ matched = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!matched) {
|
|
|
+ for (const field of BASE64_FIELDS) {
|
|
|
+ const candidate = obj[field]
|
|
|
+ if (typeof candidate !== 'string' || candidate.length < 32) continue
|
|
|
+ // A stored execution replaces long strings with "[omitted N bytes]",
|
|
|
+ // so most historical records have no bytes to show - only a live run
|
|
|
+ // or a re-run does. Nothing to render is not an error.
|
|
|
+ if (candidate.startsWith('[omitted')) continue
|
|
|
+
|
|
|
+ const inline = DATA_URL.test(candidate)
|
|
|
+ const type = inline ? null : imageTypeOf(candidate)
|
|
|
+ if (!inline && !type) continue
|
|
|
+
|
|
|
+ found.push({
|
|
|
+ key: field + ':' + candidate.slice(0, 24),
|
|
|
+ base64: inline ? candidate.slice(candidate.indexOf(',') + 1) : candidate,
|
|
|
+ mimeType: inline
|
|
|
+ ? candidate.slice(5, candidate.indexOf(';'))
|
|
|
+ : (typeof obj.mimeType === 'string' ? obj.mimeType : type!),
|
|
|
+ })
|
|
|
break
|
|
|
}
|
|
|
}
|
|
|
@@ -50,10 +105,18 @@ function collectImageRefs(value: unknown, depth = 0, found: ImageRef[] = []): Im
|
|
|
// Stored files sit behind the authenticated API, and an img tag cannot send the
|
|
|
// bearer token, so the bytes are fetched through the API client and handed to
|
|
|
// the tag as an object URL.
|
|
|
-function StoredImage({ fileId }: { fileId: string }) {
|
|
|
+function StoredImage({ fileId, onReady, onOpen }: { fileId: string; onReady: (src: string) => void; onOpen: () => void }) {
|
|
|
const [src, setSrc] = useState<string | null>(null)
|
|
|
const [failed, setFailed] = useState(false)
|
|
|
|
|
|
+ // Held in a ref rather than listed as a dependency. The caller passes a fresh
|
|
|
+ // arrow every render, so depending on it would re-fetch the file on every
|
|
|
+ // render - and listing it while ignoring that is what the lint rule is
|
|
|
+ // warning about. The ref keeps the effect keyed to the fileId alone, which is
|
|
|
+ // the only thing that should cause a re-fetch.
|
|
|
+ const onReadyRef = useRef(onReady)
|
|
|
+ onReadyRef.current = onReady
|
|
|
+
|
|
|
useEffect(() => {
|
|
|
let objectUrl: string | null = null
|
|
|
let cancelled = false
|
|
|
@@ -64,6 +127,7 @@ function StoredImage({ fileId }: { fileId: string }) {
|
|
|
if (cancelled) return
|
|
|
objectUrl = URL.createObjectURL(response.data)
|
|
|
setSrc(objectUrl)
|
|
|
+ onReadyRef.current(objectUrl)
|
|
|
})
|
|
|
.catch(() => {
|
|
|
if (!cancelled) setFailed(true)
|
|
|
@@ -88,41 +152,127 @@ function StoredImage({ fileId }: { fileId: string }) {
|
|
|
return <div className="h-24 w-24 rounded bg-gray-100 dark:bg-slate-700 animate-pulse" />
|
|
|
}
|
|
|
|
|
|
- return <Preview src={src} title={`file ${fileId}`} />
|
|
|
+ return <Preview src={src} title={`file ${fileId}`} onOpen={onOpen} />
|
|
|
}
|
|
|
|
|
|
-function Preview({ src, title }: { src: string; title: string }) {
|
|
|
+// Bytes carried in the output. Turned into a blob URL rather than used as a
|
|
|
+// data: URL directly, because browsers refuse to open a data: URL as a
|
|
|
+// top-level navigation - the thumbnail would render and clicking it would do
|
|
|
+// nothing at all.
|
|
|
+function InlineImage({ base64, mimeType, onReady, onOpen }: { base64: string; mimeType: string; onReady: (src: string) => void; onOpen: () => void }) {
|
|
|
+ const [src, setSrc] = useState<string | null>(null)
|
|
|
+ const [failed, setFailed] = useState(false)
|
|
|
+
|
|
|
+ // See StoredImage: kept in a ref so decoding is keyed to the bytes, not to
|
|
|
+ // the identity of a callback the parent recreates on every render.
|
|
|
+ const onReadyRef = useRef(onReady)
|
|
|
+ onReadyRef.current = onReady
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ let objectUrl: string | null = null
|
|
|
+ try {
|
|
|
+ const binary = atob(base64)
|
|
|
+ const bytes = new Uint8Array(binary.length)
|
|
|
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
|
+ objectUrl = URL.createObjectURL(new Blob([bytes], { type: mimeType }))
|
|
|
+ setSrc(objectUrl)
|
|
|
+ onReadyRef.current(objectUrl)
|
|
|
+ } catch {
|
|
|
+ // Not decodable - a truncated or mislabelled value. Nothing to show.
|
|
|
+ setFailed(true)
|
|
|
+ }
|
|
|
+ return () => {
|
|
|
+ if (objectUrl) URL.revokeObjectURL(objectUrl)
|
|
|
+ }
|
|
|
+ }, [base64, mimeType])
|
|
|
+
|
|
|
+ if (failed || !src) return null
|
|
|
+ return <Preview src={src} title={mimeType} onOpen={onOpen} />
|
|
|
+}
|
|
|
+
|
|
|
+function Preview({ src, title, onOpen }: { src: string; title: string; onOpen: () => void }) {
|
|
|
const [failed, setFailed] = useState(false)
|
|
|
if (failed) return null
|
|
|
|
|
|
return (
|
|
|
- <a href={src} target="_blank" rel="noreferrer" title={title}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onOpen}
|
|
|
+ title={`${title} - click to view`}
|
|
|
+ className="group relative block rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
+ >
|
|
|
<img
|
|
|
src={src}
|
|
|
alt={title}
|
|
|
onError={() => setFailed(true)}
|
|
|
- className="h-24 w-24 object-cover rounded border border-gray-200 dark:border-slate-700 hover:opacity-90"
|
|
|
+ className="h-24 w-24 object-cover rounded border border-gray-200 dark:border-slate-700 transition group-hover:opacity-75"
|
|
|
/>
|
|
|
- </a>
|
|
|
+ <span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded opacity-0 transition group-hover:opacity-100 bg-black/40">
|
|
|
+ <Maximize2 className="w-4 h-4 text-white" />
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
)
|
|
|
}
|
|
|
|
|
|
export function NodeOutputView({ value }: { value: unknown }) {
|
|
|
const refs = collectImageRefs(value)
|
|
|
|
|
|
+ // The src each thumbnail resolved to, so the viewer shows the bytes already
|
|
|
+ // in hand - a stored file is not fetched twice, and base64 is decoded once.
|
|
|
+ const [sources, setSources] = useState<Record<string, string>>({})
|
|
|
+ const [openKey, setOpenKey] = useState<string | null>(null)
|
|
|
+
|
|
|
+ const remember = useCallback((key: string, src: string) => {
|
|
|
+ setSources((prev) => (prev[key] === src ? prev : { ...prev, [key]: src }))
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ // Only images that have actually resolved can be paged through; one still
|
|
|
+ // loading would show an empty frame in the viewer.
|
|
|
+ const viewable = refs
|
|
|
+ .map((ref) => ({ key: ref.key, src: sources[ref.key] ?? ref.url, title: ref.mimeType || ref.url || ref.key }))
|
|
|
+ .filter((item): item is { key: string; src: string; title: string } => Boolean(item.src))
|
|
|
+ const openIndex = viewable.findIndex((item) => item.key === openKey)
|
|
|
+
|
|
|
return (
|
|
|
<div className="space-y-2">
|
|
|
{refs.length > 0 && (
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
{refs.map((ref) =>
|
|
|
ref.fileId ? (
|
|
|
- <StoredImage key={ref.key} fileId={ref.fileId} />
|
|
|
+ <StoredImage
|
|
|
+ key={ref.key}
|
|
|
+ fileId={ref.fileId}
|
|
|
+ onReady={(src) => remember(ref.key, src)}
|
|
|
+ onOpen={() => setOpenKey(ref.key)}
|
|
|
+ />
|
|
|
+ ) : ref.base64 ? (
|
|
|
+ <InlineImage
|
|
|
+ key={ref.key}
|
|
|
+ base64={ref.base64}
|
|
|
+ mimeType={ref.mimeType || 'image/png'}
|
|
|
+ onReady={(src) => remember(ref.key, src)}
|
|
|
+ onOpen={() => setOpenKey(ref.key)}
|
|
|
+ />
|
|
|
) : (
|
|
|
- <Preview key={ref.key} src={ref.url!} title={ref.url!} />
|
|
|
+ <Preview
|
|
|
+ key={ref.key}
|
|
|
+ src={ref.url!}
|
|
|
+ title={ref.url!}
|
|
|
+ onOpen={() => setOpenKey(ref.key)}
|
|
|
+ />
|
|
|
)
|
|
|
)}
|
|
|
</div>
|
|
|
)}
|
|
|
+
|
|
|
+ {openIndex >= 0 && (
|
|
|
+ <ImageLightbox
|
|
|
+ images={viewable}
|
|
|
+ index={openIndex}
|
|
|
+ onClose={() => setOpenKey(null)}
|
|
|
+ onIndexChange={(next) => setOpenKey(viewable[next].key)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
<pre className="p-2 bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded text-xs overflow-auto max-h-48 font-mono text-gray-900 dark:text-gray-100">
|
|
|
{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}
|
|
|
</pre>
|