@hachej/boring-ui-kit 0.4.12 → 0.4.14

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 (57) hide show
  1. package/README.md +12 -2
  2. package/bin/boring-ui-blocks.mjs +8 -1
  3. package/dist/file-tree-view.d.ts +48 -3
  4. package/dist/file-tree-view.js +202 -12
  5. package/dist/file-tree.d.ts +31 -0
  6. package/dist/file-tree.js +122 -1
  7. package/dist/voice-capture.d.ts +10 -0
  8. package/dist/voice-capture.js +235 -39
  9. package/package.json +2 -2
  10. package/r/agent-target.json +4 -4
  11. package/r/app-shell.json +9 -9
  12. package/r/board-viewer.json +12 -6
  13. package/r/button.json +1 -1
  14. package/r/diagram-viewer.json +8 -2
  15. package/r/dock.json +1 -1
  16. package/r/documents.json +14 -12
  17. package/r/feedback-preview.json +1 -1
  18. package/r/feedback.json +6 -6
  19. package/r/file-tree.json +8 -7
  20. package/r/highlights.json +3 -3
  21. package/r/html-viewer.json +3 -3
  22. package/r/looks-viewer.json +9 -3
  23. package/r/maquette-viewer.json +9 -3
  24. package/r/markdown-editor.json +3 -3
  25. package/r/mockup-viewer.json +14 -2
  26. package/r/pi-ambient.json +10 -10
  27. package/r/pi-app.json +31 -20
  28. package/r/pi-artifacts.json +3 -3
  29. package/r/pi-chat-approval.json +1 -1
  30. package/r/pi-chat-attachments.json +1 -1
  31. package/r/pi-chat-markdown.json +2 -2
  32. package/r/pi-chat-mentions.json +1 -1
  33. package/r/pi-chat-pickers.json +1 -1
  34. package/r/pi-chat-plugins.json +11 -11
  35. package/r/pi-chat-question.json +1 -1
  36. package/r/pi-chat-slash.json +1 -1
  37. package/r/pi-chat-subagent.json +1 -1
  38. package/r/pi-chat-voice.json +3 -3
  39. package/r/pi-chat.json +5 -5
  40. package/r/pi-workspace.json +4 -4
  41. package/r/placed-agent.json +4 -4
  42. package/r/provider-setup.json +2 -2
  43. package/r/record-viewer.json +8 -2
  44. package/r/registry.json +398 -204
  45. package/r/requests-board.json +1 -1
  46. package/r/shared-chat.json +6 -6
  47. package/r/task-list-viewer.json +2 -2
  48. package/r/version-history.json +3 -3
  49. package/r/viewer-code.json +36 -0
  50. package/r/viewer-frame.json +447 -0
  51. package/r/viewer-html.json +32 -0
  52. package/r/viewer-image.json +36 -0
  53. package/r/viewer-markdown.json +47 -0
  54. package/r/viewer-pdf.json +45 -0
  55. package/r/viewers.json +8 -491
  56. package/r/windows.json +1 -1
  57. package/styles/file-tree.css +13 -0
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "viewer-image",
4
+ "title": "Image viewer",
5
+ "description": "The image kind: `imageKind` (match on PNG, JPEG, GIF, WebP and SVG, loader) and `ImagePane`, zoom and pan in the viewer frame. The pane loads the first time an image opens.",
6
+ "dependencies": [
7
+ "react@^19.0.0",
8
+ "lucide-react@^1.51.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/viewer-frame.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
13
+ "button"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "registry/viewer-image/image-kind.ts",
18
+ "content": "import type { ComponentType } from 'react';\nimport type { ImagePaneProps } from './image-pane';\nimport { IMAGE_TYPES } from './image-types';\n\n/**\n * The image kind (D-257): PNG, JPEG, GIF, WebP and SVG files, read as bytes and shown by `ImagePane` (zoom and pan) in its own viewer bar.\n * This module is cheap (the match and the loader); the pane loads the first time an image opens. The pi-app `FileViewer` draws it.\n */\nexport interface ImageKind {\n readonly match: (target: { readonly resource: { readonly path: string } }, artifact: { readonly mediaType: string }) => boolean;\n readonly label?: string | undefined;\n /** A default viewer for a media type: a host's `viewers.image` wins over it. */\n readonly fallback: true;\n /** The kind reads the file's bytes (or the host's URL of a large file), not its text. */\n readonly media: 'image';\n readonly load: () => Promise<{ readonly default: ComponentType<ImagePaneProps> }>;\n}\n\nexport const imageKind: ImageKind = {\n match: (_target, artifact) => Object.hasOwn(IMAGE_TYPES, artifact.mediaType),\n fallback: true, media: 'image',\n load: () => import('./image-pane').then(module => ({ default: module.ImagePane })),\n};\n",
19
+ "type": "registry:lib",
20
+ "target": "components/viewer-image/image-kind.ts"
21
+ },
22
+ {
23
+ "path": "registry/viewer-image/image-types.ts",
24
+ "content": "/** The image media types the image viewer shows, with their short names. Its own module: the image kind matches on it without loading the viewer. */\nexport const IMAGE_TYPES: Readonly<Record<string, string>> = { 'image/png': 'PNG', 'image/jpeg': 'JPEG', 'image/gif': 'GIF', 'image/webp': 'WebP', 'image/svg+xml': 'SVG' };\n",
25
+ "type": "registry:lib",
26
+ "target": "components/viewer-image/image-types.ts"
27
+ },
28
+ {
29
+ "path": "registry/viewer-image/image-pane.tsx",
30
+ "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { PointerEvent, ReactNode } from 'react';\nimport { MaximizeIcon, ScanIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { downloadFile } from '../viewer-frame/download';\nimport { formatBytes, useMediaUrl } from '../viewer-frame/media';\nimport type { MediaSource } from '../viewer-frame/media';\nimport { cn } from '../utils/utils';\nimport { useViewerBar, ViewerFrame } from '../viewer-frame/viewer-frame';\nimport type { ViewerShare, ViewerStatus } from '../viewer-frame/viewer-frame';\nimport { useViewerText } from '../viewer-frame/viewer-window';\n\nimport { IMAGE_TYPES } from './image-types';\nexport { IMAGE_TYPES } from './image-types';\nconst STEPS = [0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8];\n\nexport interface ImagePaneProps extends MediaSource {\n readonly name: string;\n readonly mediaType: string;\n readonly subtitle?: ReactNode;\n readonly status?: ViewerStatus;\n readonly target?: unknown;\n readonly revision?: string;\n /** Leading header content, before the title. */\n readonly headerStart?: ReactNode;\n readonly controls?: ReactNode;\n /** Re-reads the file; the host passes the new bytes, blob or URL back in. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n readonly testId?: string;\n /** Overrides the title's `data-testid` (default `<testId>-title`). */\n readonly titleTestId?: string;\n readonly className?: string;\n}\n\n/** Zoom steps and the level; on a narrow bar only the steps stay (the stage also zooms with + - 0). Fit and Actual size are in the \"…\" menu. */\nexport function ZoomControls({ testId, percent, ready, onStep, onFit }: { readonly testId: string; readonly percent: string; readonly ready: boolean; readonly onStep: (direction: 1 | -1) => void; readonly onFit: () => void }) {\n const { tight } = useViewerBar();\n const { labels } = useViewerText();\n return <div role=\"group\" aria-label={labels.zoom} className=\"flex items-center gap-0.5\">\n <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.zoomOut} title={labels.zoomOut} data-testid={`${testId}-zoom-out`} disabled={!ready} onClick={() => onStep(-1)}><ZoomOutIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n {!tight && <Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-zoom-level`} aria-label={labels.zoomLevel(percent)} title={labels.fitToView} disabled={!ready} className=\"min-w-10\" onClick={onFit}>{percent}</Button>}\n <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.zoomIn} title={labels.zoomIn} data-testid={`${testId}-zoom-in`} disabled={!ready} onClick={() => onStep(1)}><ZoomInIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </div>;\n}\n\ntype Zoom = { readonly kind: 'fit' } | { readonly kind: 'scale'; readonly value: number };\n\n/**\n * A read-only image viewer in the standard frame: fit to view by default, zoom, actual size, drag to pan when zoomed, a checkerboard\n * behind transparency. Every type, SVG included, is shown through an `<img>` from an object URL, never as markup.\n */\nexport function ImagePane({ name, mediaType, bytes, blob, url: hostUrl, subtitle, status, target, revision, headerStart, controls, onRefresh, onShare, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, className }: ImagePaneProps) {\n const source = useMemo<MediaSource>(() => ({ ...(bytes ? { bytes } : {}), ...(blob ? { blob } : {}), ...(hostUrl ? { url: hostUrl } : {}) }), [bytes, blob, hostUrl]);\n const { url, size, blob: made } = useMediaUrl(source, mediaType);\n const supported = mediaType in IMAGE_TYPES;\n const { labels } = useViewerText();\n const [zoom, setZoom] = useState<Zoom>({ kind: 'fit' });\n const [natural, setNatural] = useState<{ width: number; height: number } | undefined>(undefined);\n const [failed, setFailed] = useState(false);\n const [fitScale, setFitScale] = useState(1);\n const stage = useRef<HTMLDivElement>(null);\n const image = useRef<HTMLImageElement>(null);\n const drag = useRef<{ x: number; y: number; left: number; top: number } | undefined>(undefined);\n useEffect(() => { setNatural(undefined); setFailed(false); }, [url]);\n const measure = () => {\n const box = stage.current, img = image.current;\n if (box && img && img.naturalWidth) setFitScale(Math.min(1, (box.clientWidth - 32) / img.naturalWidth, (box.clientHeight - 32) / img.naturalHeight));\n };\n useEffect(() => {\n const box = stage.current;\n if (!box || typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(box);\n return () => observer.disconnect();\n }, [url]);\n const current = zoom.kind === 'fit' ? fitScale : zoom.value;\n const step = (direction: 1 | -1) => {\n const now = Number(current.toFixed(3));\n const next = direction > 0 ? STEPS.find(value => value > now + 0.001) : [...STEPS].reverse().find(value => value < now - 0.001);\n if (next) setZoom({ kind: 'scale', value: next });\n };\n const label = IMAGE_TYPES[mediaType] ?? mediaType;\n const facts = [label, natural && `${natural.width} × ${natural.height}`, size !== undefined && formatBytes(size)].filter(Boolean) as string[];\n const panning = zoom.kind === 'scale' && natural !== undefined && (natural.width * zoom.value > (stage.current?.clientWidth ?? 0) || natural.height * zoom.value > (stage.current?.clientHeight ?? 0));\n const begin = (event: PointerEvent<HTMLDivElement>) => {\n const box = stage.current;\n if (!panning || !box || event.button !== 0) return;\n drag.current = { x: event.clientX, y: event.clientY, left: box.scrollLeft, top: box.scrollTop };\n box.setPointerCapture?.(event.pointerId);\n };\n const move = (event: PointerEvent<HTMLDivElement>) => {\n const box = stage.current, start = drag.current;\n if (!box || !start) return;\n box.scrollLeft = start.left - (event.clientX - start.x);\n box.scrollTop = start.top - (event.clientY - start.y);\n };\n const end = () => { drag.current = undefined; };\n const downloadName = name.split('/').pop() || 'image';\n const download = () => { const data = made; if (data) downloadFile(downloadName, data, mediaType); else if (url) { const link = Object.assign(document.createElement('a'), { href: url, download: downloadName }); document.body.append(link); link.click(); link.remove(); } };\n const percent = `${Math.round(current * 100)}%`;\n return <ViewerFrame title={name.split('/').pop() || name} subtitle={<>{subtitle}{subtitle && facts.length > 0 && <span aria-hidden=\"true\">·</span>}{facts.join(' · ')}</>} status={status} target={target} revision={revision} headerStart={headerStart} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh} onShare={onShare} onDownload={url ? download : undefined} onOpenInNewTab={onOpenInNewTab ?? (url ? () => { window.open(url, '_blank', 'noopener,noreferrer'); } : undefined)} onClose={onClose}\n menu={[\n { id: 'zoom-fit', label: labels.fitToView, icon: <ScanIcon className=\"size-4\" />, disabled: !natural, onSelect: () => setZoom({ kind: 'fit' }) },\n { id: 'zoom-actual', label: labels.actualSize, icon: <MaximizeIcon className=\"size-4\" />, disabled: !natural, onSelect: () => setZoom({ kind: 'scale', value: 1 }) }]}\n controls={<>\n <ZoomControls testId={testId} percent={percent} ready={Boolean(natural)} onStep={step} onFit={() => setZoom({ kind: 'fit' })} />\n {controls}\n </>}>\n <div ref={stage} data-testid={`${testId}-stage`} data-zoom={zoom.kind === 'fit' ? 'fit' : String(zoom.value)} data-panning={panning || undefined}\n onPointerDown={begin} onPointerMove={move} onPointerUp={end} onPointerCancel={end}\n className={cn('boring-viewer-stage relative min-h-0 flex-1 overflow-auto', panning && 'cursor-grab active:cursor-grabbing')} tabIndex={0} aria-label={labels.imageStage(name)}\n onKeyDown={event => { if (event.key === '+' || event.key === '=') step(1); else if (event.key === '-') step(-1); else if (event.key === '0') setZoom({ kind: 'fit' }); }}>\n {!supported ? <p role=\"alert\" className=\"m-auto p-6 text-center text-sm text-muted-foreground\">{labels.imageUnsupported(mediaType)}</p>\n : failed ? <p role=\"alert\" className=\"m-auto p-6 text-center text-sm text-muted-foreground\">{labels.imageUnreadable}</p>\n : url ? <div className=\"flex min-h-full min-w-full items-center justify-center p-4\">\n <img ref={image} src={url} alt={name} data-testid={`${testId}-image`} draggable={false} decoding=\"async\" referrerPolicy=\"no-referrer\"\n onLoad={event => { setNatural({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }); measure(); }} onError={() => setFailed(true)}\n style={natural ? { width: natural.width * current, height: natural.height * current, maxWidth: 'none' } : undefined}\n className=\"boring-viewer-checker block shrink-0 select-none outline outline-1 outline-border\" />\n </div> : <p role=\"status\" className=\"m-auto p-6 text-sm text-muted-foreground\">{labels.loading}</p>}\n </div>\n </ViewerFrame>;\n}\n",
31
+ "type": "registry:component",
32
+ "target": "components/viewer-image/image-pane.tsx"
33
+ }
34
+ ],
35
+ "type": "registry:component"
36
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "viewer-markdown",
4
+ "title": "Markdown viewer",
5
+ "description": "The Markdown kind: `markdownKind` (match, loader) and `MarkdownPane`, the tiptap editor with a formatting toolbar and prose styling over the kit's revisioned Markdown controller, in the viewer frame. The pane and tiptap load the first time a Markdown file opens.",
6
+ "dependencies": [
7
+ "@hachej/boring-ui-kit@^0.4.14",
8
+ "react@^19.0.0",
9
+ "lucide-react@^1.51.0",
10
+ "marked@^17.0.6",
11
+ "@tiptap/core@^3.31.4",
12
+ "@tiptap/react@^3.31.4",
13
+ "@tiptap/pm@^3.31.4",
14
+ "@tiptap/starter-kit@^3.31.4",
15
+ "@tiptap/markdown@^3.31.4",
16
+ "@tiptap/extensions@^3.31.4",
17
+ "@tiptap/extension-list@^3.31.4",
18
+ "@tiptap/extension-table@^3.31.4",
19
+ "@tiptap/extension-image@^3.31.4",
20
+ "@tiptap/extension-highlight@^3.31.4",
21
+ "@tiptap/extension-bubble-menu@^3.31.4",
22
+ "@tiptap/extension-floating-menu@^3.31.4"
23
+ ],
24
+ "registryDependencies": [
25
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/viewer-frame.json",
26
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/markdown-editor.json",
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
28
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/button.json",
29
+ "button",
30
+ "dropdown-menu"
31
+ ],
32
+ "files": [
33
+ {
34
+ "path": "registry/viewer-markdown/markdown-kind.ts",
35
+ "content": "import type { ComponentType } from 'react';\nimport type { MarkdownPaneProps } from './markdown-pane';\n\n/**\n * The Markdown kind (D-257): Markdown artifacts and `.md` files, edited by `MarkdownPane` (tiptap) over the panel's revisioned\n * controller. This module is cheap; the editor and tiptap load the first time a Markdown file opens.\n */\nexport interface MarkdownKind {\n readonly match: (target: { readonly resource: { readonly path: string } }, artifact: { readonly type: string }) => boolean;\n readonly label?: string | undefined;\n /** A default viewer for a type: a host's `viewers.markdown` wins over it. */\n readonly fallback: true;\n /** The panel and the file viewer create a Markdown controller over the file and hand it to the pane. */\n readonly editor: 'markdown';\n readonly load: () => Promise<{ readonly default: ComponentType<MarkdownPaneProps> }>;\n}\n\nexport const markdownKind: MarkdownKind = {\n match: (_target, artifact) => artifact.type === 'markdown',\n fallback: true, editor: 'markdown',\n load: () => import('./markdown-pane').then(module => ({ default: module.MarkdownPane })),\n};\n",
36
+ "type": "registry:lib",
37
+ "target": "components/viewer-markdown/markdown-kind.ts"
38
+ },
39
+ {
40
+ "path": "registry/viewer-markdown/markdown-pane.tsx",
41
+ "content": "'use client';\n\nimport { useCallback, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { SaveIcon } from 'lucide-react';\nimport { MarkdownEditor } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { MarkdownEditorProps, MarkdownImageResolver, MarkdownMountedTools, MarkdownRichSafety } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionMenuItemContent } from '../button/actions';\nimport { editorControls } from '../button/editor-controls';\nimport { MarkdownFormattingControl, MarkdownMenuItems } from '../markdown-editor/markdown-editor';\nimport { downloadFile, fileNameFor } from '../viewer-frame/download';\nimport { copyText } from '../utils/utils';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport type { ViewerShare, ViewerStatus } from '../viewer-frame/viewer-frame';\nimport { defaultViewerLabels, useViewerText, useViewerWindow } from '../viewer-frame/viewer-window';\nimport type { ViewerLabels } from '../viewer-frame/viewer-window';\n\nexport interface MarkdownPaneProps {\n /** Borrowed: the host owns the controller and disposes it. */\n readonly controller: MarkdownController;\n readonly title?: string;\n readonly subtitle?: ReactNode;\n readonly target?: unknown;\n readonly revision?: string;\n readonly initialMode?: 'rich' | 'source';\n /** Reports the person's choice, so a host that remounts the pane (for a new revision) can start it in the same mode. */\n readonly onModeChange?: (mode: 'rich' | 'source') => void;\n readonly placeholder?: string;\n /** Defaults to 1200ms idle autosave; false keeps the Save button. */\n readonly autosave?: MarkdownEditorProps['autosave'];\n /** Registers the flush for owner teardown; this is not a navigation guard. */\n readonly onAutosaveChange?: MarkdownEditorProps['onAutosaveChange'];\n /** Registers with a host navigation guard whose onClose handles refused saves and deliberate discard. */\n readonly onBeforeLeaveChange?: MarkdownEditorProps['onAutosaveChange'];\n /** Selection formatting by default. The header icon remembers the person's choice for this viewer session. */\n readonly formattingToolbar?: MarkdownEditorProps['formattingToolbar'];\n readonly onFormattingToolbarChange?: (toolbar: 'selection' | 'pinned') => void;\n /** Leading header content, before the title. */\n readonly headerStart?: ReactNode;\n /** Extra viewer controls, for example a version switcher. */\n readonly controls?: ReactNode;\n /** Defaults to the controller's refresh: a clean document follows the saved one, unsaved edits are kept and shown as changed elsewhere. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Host mapping from an image address in the document to a loadable URL. Images stay inert without it. */\n readonly resolveImage?: MarkdownImageResolver;\n readonly onMountedTools?: (tools: MarkdownMountedTools | null) => void;\n readonly testId?: string;\n /** Overrides the title's `data-testid` (default `<testId>-title`). */\n readonly titleTestId?: string;\n readonly className?: string;\n /** The pane's and its bar's words, over the window's and `defaultViewerLabels`. */\n readonly labels?: Partial<ViewerLabels> | undefined;\n}\n\n/** The status dot for a Markdown controller: Saved (primary), Unsaved or Saving (muted), Save unconfirmed or Changed elsewhere (destructive, with words), Read-only. */\nexport function markdownStatus(state: ReturnType<MarkdownController['getSnapshot']>, labels: ViewerLabels = defaultViewerLabels): ViewerStatus {\n if (state.lifecycle === 'disposed') return { label: labels.closed };\n if (state.save.kind === 'pending') return { label: labels.saving, tone: 'warning' };\n if (state.save.kind === 'settled' && state.save.result.kind === 'unknown') return { label: labels.saveUnconfirmed, tone: 'danger' };\n if (state.save.kind === 'settled' && state.save.result.kind === 'conflict' || state.remote !== null) return { label: labels.changedElsewhere, tone: 'danger' };\n if (state.readOnly) return { label: labels.readOnly, kind: 'read-only' };\n return state.dirty ? { label: labels.unsaved, tone: 'warning' } : { label: labels.saved, tone: 'success', kind: 'saved' };\n}\n\n/** The Markdown editor in the standard viewer frame: Save while there are edits, with view choices and secondary actions under \"…\". */\nexport function MarkdownPane({ controller, title: givenTitle, labels: ownLabels, subtitle, target, revision, initialMode = 'rich', onModeChange, placeholder, formattingToolbar = 'selection', onFormattingToolbarChange, autosave: ownAutosave, onAutosaveChange, onBeforeLeaveChange, headerStart, controls, onRefresh, onShare, onOpenInNewTab, onClose, resolveImage, onMountedTools, testId = 'viewer', titleTestId, className }: MarkdownPaneProps) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useViewerText(ownLabels);\n const window = useViewerWindow();\n const autosave = ownAutosave ?? window?.autosave ?? true;\n const flush = useRef<(() => Promise<boolean>) | null>(null);\n const registerAutosave = useCallback((save: (() => Promise<boolean>) | null) => { flush.current = save; onAutosaveChange?.(save); onBeforeLeaveChange?.(save); }, [onAutosaveChange, onBeforeLeaveChange]);\n const close = onClose ? async () => {\n if (autosave !== false && flush.current && !await flush.current() && !onBeforeLeaveChange) return false;\n return onClose();\n } : undefined;\n const title = givenTitle ?? labels.document;\n const [requested, setRequestedState] = useState<'rich' | 'source'>(initialMode);\n const setRequested = (next: 'rich' | 'source') => { setRequestedState(next); onModeChange?.(next); };\n const [toolbar, setToolbar] = useState(formattingToolbar);\n useLayoutEffect(() => setToolbar(formattingToolbar), [formattingToolbar]);\n const setFormatting = (next: 'selection' | 'pinned') => { setToolbar(next); onFormattingToolbarChange?.(next); };\n const [safety, setSafety] = useState<MarkdownRichSafety>({ safe: true });\n const mode = safety.safe ? requested : 'source';\n const unavailable = state.readOnly || state.lifecycle !== 'active';\n const pending = state.save.kind === 'pending';\n const unconfirmed = state.save.kind === 'settled' && state.save.result.kind === 'unknown';\n const [refused, setRefused] = useState<{ readonly save: typeof state.save; readonly label: string } | null>(null);\n const report: NonNullable<MarkdownEditorProps['onSaveResult']> = result => {\n if (result.kind === 'saved' || result.kind === 'conflict') setRefused(null);\n else if (result.kind === 'denied' || result.kind === 'unavailable' || result.kind === 'file_too_large') {\n const reason = result.reason ?? result.kind;\n setRefused({ save: controller.getSnapshot().save, label: labels.notSaved(reason.length > 48 ? `${reason.slice(0, 47)}…` : reason) });\n }\n };\n const save = () => {\n setRefused(null);\n void controller.flush(controller.actions.selection()).then(report, () => setRefused({ save: controller.getSnapshot().save, label: labels.notSaved(labels.saveFailedReason) }));\n };\n const manualSave = autosave === false && !state.readOnly && state.dirty;\n const saveItem = { id: 'save', label: labels.save, icon: <SaveIcon className=\"size-4\" />, disabled: unavailable || pending || unconfirmed, onSelect: save };\n const filename = useMemo(() => fileNameFor(title, 'md'), [title]);\n return <ViewerFrame showSavedStatus title={title} subtitle={subtitle} headerStart={headerStart} status={refused && refused.save === state.save && state.save.kind !== 'pending' ? { label: refused.label, tone: 'danger' } : markdownStatus(state, labels)} target={target} labels={labels} revision={revision} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh ?? (() => controller.actions.refresh())} onShare={onShare} onCopy={() => copyText(controller.getSnapshot().text)}\n onDownload={() => downloadFile(filename, controller.getSnapshot().text, 'text/markdown;charset=utf-8')} onOpenInNewTab={onOpenInNewTab} onClose={close}\n menuContent={<>\n <MarkdownMenuItems labels={labels} mode={mode} onModeChange={setRequested} richDisabledReason={safety.safe ? undefined : labels.richUnavailable(safety.reasons.join(', '))} testId={testId} />\n {manualSave && <><DropdownMenuSeparator className=\"min-[641px]:hidden\" /><DropdownMenuGroup className=\"min-[641px]:hidden\">\n <DropdownMenuItem aria-label={labels.save} data-testid={`${testId}-save-menu`} disabled={saveItem.disabled} onClick={save} className=\"min-h-8 max-sm:min-h-10 pointer-coarse:min-h-10\"><ActionMenuItemContent item={saveItem} testId={`${testId}-save-menu`} /></DropdownMenuItem>\n </DropdownMenuGroup></>}\n </>}\n mainControl={<>\n {mode === 'rich' && <MarkdownFormattingControl type=\"button\" aria-label={labels.formatting} title={labels.formatting} aria-pressed={toolbar === 'pinned'} data-testid={`${testId}-formatting`} onClick={() => setFormatting(toolbar === 'pinned' ? 'selection' : 'pinned')} />}\n {manualSave && <span className=\"max-[641px]:hidden\"><Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.save} title={labels.save} data-testid={`${testId}-save`} disabled={saveItem.disabled} onClick={save}><SaveIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></span>}\n </>}\n controls={controls}>\n <MarkdownEditor components={editorControls} controller={controller} title={title} header={false} autosave={autosave} onAutosaveChange={registerAutosave} onSaveResult={report} formattingToolbar={toolbar} mode={mode} onModeChange={setRequested} onRichSafety={setSafety}\n {...(placeholder === undefined ? {} : { placeholder })} {...(resolveImage ? { resolveImage } : {})} {...(onMountedTools ? { onMountedTools } : {})} className=\"boring-markdown-editor\" />\n </ViewerFrame>;\n}\n",
42
+ "type": "registry:component",
43
+ "target": "components/viewer-markdown/markdown-pane.tsx"
44
+ }
45
+ ],
46
+ "type": "registry:component"
47
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "viewer-pdf",
4
+ "title": "PDF viewer",
5
+ "description": "The PDF kind: `pdfKind` (match, loader) and `PdfPane` in the viewer frame (the browser's viewer, or pdf.js pages where there is none), and LocalFileViewer for browser-only files. The pane loads the first time a PDF opens.",
6
+ "dependencies": [
7
+ "@hachej/boring-files@^0.4.14",
8
+ "react@^19.0.0",
9
+ "lucide-react@^1.51.0",
10
+ "pdfjs-dist@6.3.289"
11
+ ],
12
+ "registryDependencies": [
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/viewer-frame.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/viewer-image.json",
15
+ "badge",
16
+ "button"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "registry/viewer-pdf/pdf-kind.ts",
21
+ "content": "import type { ComponentType } from 'react';\nimport type { PdfPaneProps } from './pdf-pane';\n\n/**\n * The PDF kind (D-257): `application/pdf` files, read as bytes and shown by `PdfPane` in its own viewer bar. This module is cheap; the\n * pane (and pdf.js, which the pane loads only when the browser has no PDF viewer) loads the first time a PDF opens.\n */\nexport interface PdfKind {\n readonly match: (target: { readonly resource: { readonly path: string } }, artifact: { readonly mediaType: string }) => boolean;\n readonly label?: string | undefined;\n readonly fallback: true;\n readonly media: 'pdf';\n readonly load: () => Promise<{ readonly default: ComponentType<PdfPaneProps> }>;\n}\n\nexport const pdfKind: PdfKind = {\n match: (_target, artifact) => artifact.mediaType === 'application/pdf',\n fallback: true, media: 'pdf',\n load: () => import('./pdf-pane').then(module => ({ default: module.PdfPane })),\n};\n",
22
+ "type": "registry:lib",
23
+ "target": "components/viewer-pdf/pdf-kind.ts"
24
+ },
25
+ {
26
+ "path": "registry/viewer-pdf/pdf-pane.tsx",
27
+ "content": "'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { DownloadIcon, FileTextIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { downloadFile } from '../viewer-frame/download';\nimport { ZoomControls } from '../viewer-image/image-pane';\nimport { formatBytes, useMediaUrl } from '../viewer-frame/media';\nimport { PdfPages } from './pdf-pages';\nimport type { MediaSource } from '../viewer-frame/media';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport type { ViewerShare, ViewerStatus } from '../viewer-frame/viewer-frame';\nimport { useViewerText } from '../viewer-frame/viewer-window';\n\nexport interface PdfPaneProps extends MediaSource {\n readonly name: string;\n readonly subtitle?: ReactNode;\n readonly status?: ViewerStatus;\n readonly target?: unknown;\n readonly revision?: string;\n /** Leading header content, before the title. */\n readonly headerStart?: ReactNode;\n readonly controls?: ReactNode;\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** `false` draws the pages with pdf.js (for a host that knows its browser cannot render PDFs), `true` uses the browser's own viewer. Defaults to the browser's own report. */\n readonly inline?: boolean;\n /** How the browser's viewer opens the page: `width` (default) fits the page to the pane's width, `page` fits the whole page, `none` leaves the browser's own zoom. */\n readonly fit?: 'width' | 'page' | 'none';\n readonly testId?: string;\n /** Overrides the title's `data-testid` (default `<testId>-title`). */\n readonly titleTestId?: string;\n readonly className?: string;\n}\n\n/** The browser reports whether it has a built-in PDF viewer. Unknown (older browsers) is treated as yes: the frame is tried. */\nexport function browserShowsPdf(): boolean {\n const nav = globalThis.navigator as (Navigator & { pdfViewerEnabled?: boolean }) | undefined;\n return nav?.pdfViewerEnabled !== false;\n}\n\nconst STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];\n\n/**\n * A read-only PDF viewer in the standard frame, as in boring-ui v2: the browser's own PDF viewer in a frame over an object URL\n * (so its page navigation, zoom, search and print come with it). Where the browser cannot render PDFs inline (phones), pdf.js, loaded\n * only then, draws the pages with zoom and a page indicator; if that fails too (a damaged or password-protected file, no worker), a clear\n * message with Download and Open in new tab replaces it.\n */\nexport function PdfPane({ name, bytes, blob, url: hostUrl, subtitle, status, target, revision, headerStart, controls, onRefresh, onShare, onOpenInNewTab, onClose, inline, fit = 'width', testId = 'viewer', titleTestId, className }: PdfPaneProps) {\n const source = useMemo<MediaSource>(() => ({ ...(bytes ? { bytes } : {}), ...(blob ? { blob } : {}), ...(hostUrl ? { url: hostUrl } : {}) }), [bytes, blob, hostUrl]);\n const { url, size, blob: made } = useMediaUrl(source, 'application/pdf');\n // The browser's PDF viewer does not run in a sandboxed frame, so the frame is unsandboxed and loads only a blob typed application/pdf (a host `url` is the host's own).\n const isPdf = !made || made.type === 'application/pdf';\n const canShow = isPdf && (inline ?? browserShowsPdf());\n const { labels } = useViewerText();\n const downloadName = name.split('/').pop() || 'document.pdf';\n const download = () => { if (made) downloadFile(downloadName, made, 'application/pdf'); else if (url) { const link = Object.assign(document.createElement('a'), { href: url, download: downloadName }); document.body.append(link); link.click(); link.remove(); } };\n const open = onOpenInNewTab ?? (url ? () => { window.open(url, '_blank', 'noopener,noreferrer'); } : undefined);\n const [zoom, setZoom] = useState(1);\n const [pages, setPages] = useState<{ readonly count: number; readonly current: number }>();\n const [failed, setFailed] = useState(false);\n useEffect(() => { setPages(undefined); setFailed(false); }, [url]);\n const step = (direction: 1 | -1) => setZoom(now => (direction > 0 ? STEPS.find(value => value > now) : [...STEPS].reverse().find(value => value < now)) ?? now);\n const drawn = Boolean(url) && isPdf && !canShow && !failed;\n return <ViewerFrame title={downloadName} subtitle={<>{subtitle}{subtitle && size !== undefined && <span aria-hidden=\"true\">·</span>}{['PDF', size !== undefined && formatBytes(size)].filter(Boolean).join(' · ')}</>} status={status} target={target} revision={revision} headerStart={headerStart} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh} onShare={onShare} onDownload={url ? download : undefined} onOpenInNewTab={open} onClose={onClose}\n controls={drawn && pages ? <><ZoomControls testId={testId} percent={`${Math.round(zoom * 100)}%`} ready onStep={step} onFit={() => setZoom(1)} />{controls}</> : controls}>\n <div data-testid={`${testId}-stage`} data-pdf={url ? (canShow ? 'inline' : drawn ? 'pages' : 'fallback') : 'loading'} className=\"boring-viewer-stage relative flex min-h-0 flex-1\">\n {!url ? <p role=\"status\" className=\"m-auto p-6 text-sm text-muted-foreground\">{labels.loading}</p>\n : canShow ? <iframe src={fit === 'none' ? url : `${url}#view=${fit === 'page' ? 'Fit' : 'FitH'}`} title={`${downloadName} (PDF)`} data-testid={`${testId}-frame-pdf`} className=\"size-full min-h-0 flex-1 bg-background\" />\n : drawn ? <><PdfPages key={url} url={url} name={labels.pdfDocument(downloadName)} zoom={zoom} testId={testId} onLoad={count => setPages({ count, current: 1 })}\n onPage={current => setPages(now => now && now.current !== current ? { ...now, current } : now)} onError={() => setFailed(true)}\n onKey={key => { if (key === '+' || key === '=') step(1); else if (key === '-') step(-1); else if (key === '0') setZoom(1); }} />\n {pages && <Badge variant=\"secondary\" data-testid={`${testId}-pdf-page-indicator`} aria-live=\"polite\" className=\"pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2\">{labels.pdfPage(pages.current, pages.count)}</Badge>}</>\n : <div role=\"alert\" data-testid={`${testId}-pdf-fallback`} className=\"m-auto flex max-w-sm flex-col items-center gap-3 p-6 text-center\">\n <span className=\"grid size-12 place-items-center bg-muted text-muted-foreground\"><FileTextIcon className=\"size-6\" aria-hidden=\"true\" /></span>\n <p className=\"m-0 text-sm font-medium\">{labels.pdfUnsupported}</p>\n <p className=\"m-0 text-xs text-muted-foreground\">{labels.pdfHint(downloadName, Boolean(open))}</p>\n <div className=\"flex flex-wrap justify-center gap-2\">\n <Button variant=\"default\" size=\"sm\" data-testid={`${testId}-pdf-download`} onClick={download}><DownloadIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.download}</Button>\n {open && <Button variant=\"outline\" size=\"sm\" onClick={() => open()}>{labels.openInNewTab}</Button>}\n </div>\n </div>}\n </div>\n </ViewerFrame>;\n}\n",
28
+ "type": "registry:component",
29
+ "target": "components/viewer-pdf/pdf-pane.tsx"
30
+ },
31
+ {
32
+ "path": "registry/viewer-pdf/pdf-pages.tsx",
33
+ "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { PDFDocumentProxy, PDFWorker, RenderTask } from 'pdfjs-dist';\nimport { useViewerText } from '../viewer-frame/viewer-window';\n\n/** Device pixels per CSS pixel are capped (3) and so is one page's canvas (about 8 megapixels) so a zoomed page stays within a phone's memory. */\nconst MAX_RATIO = 3;\nconst MAX_PIXELS = 8 * 1024 * 1024;\n\n/**\n * pdf.js's own worker, from the package's worker entry. `new Worker(new URL(<package path>, import.meta.url))` is the form Vite and\n * webpack (Next) bundle as a worker; a host bundler that leaves the URL as is (esbuild) serves that path itself. Resolves once the\n * worker reports ready and rejects when it cannot load, so a missing worker shows the Download fallback instead of a spinner forever.\n */\nfunction startWorker(signal: AbortSignal): Promise<Worker> {\n return new Promise((resolve, reject) => {\n const worker = new Worker(new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url), { type: 'module' });\n const fail = () => { worker.terminate(); reject(new Error('The PDF worker could not start.')); };\n worker.addEventListener('message', () => { signal.removeEventListener('abort', fail); resolve(worker); }, { once: true });\n worker.addEventListener('error', fail, { once: true });\n signal.addEventListener('abort', fail, { once: true });\n });\n}\n\n/** Opens the document with pdf.js (loaded on demand) in its own worker. `close` destroys the document and stops the worker. */\nasync function openPdf(url: string, signal: AbortSignal): Promise<{ readonly document: PDFDocumentProxy; readonly close: () => void }> {\n const started = startWorker(signal);\n let worker: PDFWorker | undefined, task: { destroy(): Promise<void> } | undefined;\n const close = () => { void task?.destroy(); worker?.destroy(); void started.then(port => port.terminate(), () => {}); };\n try {\n const [pdfjs, port] = await Promise.all([import('pdfjs-dist'), started]);\n if (signal.aborted) throw signal.reason;\n worker = pdfjs.PDFWorker.create({ port });\n const loading = pdfjs.getDocument({ url, worker, verbosity: 0 });\n task = loading;\n const document = await loading.promise;\n if (signal.aborted) throw signal.reason;\n return { document, close };\n } catch (error) { close(); throw error; }\n}\n\nexport interface PdfPagesProps {\n readonly url: string;\n readonly name: string;\n /** 1 fits the page to the pane's width. */\n readonly zoom: number;\n readonly testId: string;\n readonly onLoad: (pages: number) => void;\n readonly onPage: (page: number) => void;\n readonly onError: (error: unknown) => void;\n readonly onKey?: (key: string) => void;\n}\n\n/**\n * The pages of a PDF drawn by pdf.js, for a browser without a PDF viewer of its own (phones): stacked, fit to the pane's width times\n * `zoom`, rendered only near the viewport, crisp at the device's pixel ratio. Unmounting cancels rendering and destroys the document.\n */\nexport function PdfPages({ url, name, zoom, testId, onLoad, onPage, onError, onKey }: PdfPagesProps) {\n const { labels } = useViewerText();\n const scroller = useRef<HTMLDivElement>(null);\n const [document, setDocument] = useState<PDFDocumentProxy>();\n const [ratio, setRatio] = useState(Math.SQRT2);\n const [width, setWidth] = useState(0);\n const latest = useRef({ onLoad, onError });\n latest.current = { onLoad, onError };\n useEffect(() => {\n const abort = new AbortController();\n let close: (() => void) | undefined;\n openPdf(url, abort.signal).then(async opened => {\n close = opened.close;\n if (abort.signal.aborted) { close(); return; }\n const first = (await opened.document.getPage(1)).getViewport({ scale: 1 });\n if (abort.signal.aborted) return;\n setRatio(first.height / first.width);\n setDocument(opened.document);\n latest.current.onLoad(opened.document.numPages);\n }).catch(error => { if (!abort.signal.aborted) latest.current.onError(error); });\n return () => { abort.abort(); close?.(); };\n }, [url]);\n useEffect(() => {\n const box = scroller.current;\n if (!box) return;\n const measure = () => setWidth(Math.max(0, box.clientWidth - 16));\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(box);\n return () => observer.disconnect();\n }, []);\n const scrolled = () => {\n const box = scroller.current;\n if (!box) return;\n const line = box.scrollTop + box.clientHeight / 3;\n let page = 1;\n for (const element of box.querySelectorAll<HTMLElement>('[data-page]')) if (element.offsetTop <= line) page = Number(element.dataset.page);\n onPage(page);\n };\n const pageWidth = Math.floor(width * zoom);\n return <div ref={scroller} data-testid={`${testId}-pdf-pages`} aria-label={name} role=\"document\" tabIndex={0} onScroll={scrolled}\n onKeyDown={event => onKey?.(event.key)} className=\"relative min-h-0 min-w-0 flex-1 overflow-auto p-2\">\n {!document ? <p role=\"status\" className=\"m-0 p-6 text-center text-sm text-muted-foreground\">{labels.loading}</p>\n : <div className=\"flex w-max min-w-full flex-col items-center gap-2\">\n {Array.from({ length: document.numPages }, (_, index) => <PdfPage key={index} document={document} number={index + 1} width={pageWidth} ratio={ratio} root={scroller.current} testId={testId} />)}\n </div>}\n </div>;\n}\n\nfunction PdfPage({ document, number, width, ratio, root, testId }: { readonly document: PDFDocumentProxy; readonly number: number; readonly width: number; readonly ratio: number; readonly root: Element | null; readonly testId: string }) {\n const box = useRef<HTMLDivElement>(null);\n const canvas = useRef<HTMLCanvasElement>(null);\n const [near, setNear] = useState(false);\n const [own, setOwn] = useState<number>();\n const [rendered, setRendered] = useState(false);\n useEffect(() => {\n const element = box.current;\n if (!element || typeof IntersectionObserver === 'undefined') { setNear(true); return; }\n const observer = new IntersectionObserver(entries => { if (entries.some(entry => entry.isIntersecting)) setNear(true); }, { root, rootMargin: '100% 0px' });\n observer.observe(element);\n return () => observer.disconnect();\n }, [root]);\n useEffect(() => {\n const element = canvas.current;\n if (!near || !width || !element) return;\n let cancelled = false, task: RenderTask | undefined;\n void document.getPage(number).then(page => {\n if (cancelled) return;\n const base = page.getViewport({ scale: 1 });\n setOwn(base.height / base.width);\n const viewport = page.getViewport({ scale: width / base.width });\n const scale = Math.min(globalThis.devicePixelRatio || 1, MAX_RATIO, Math.sqrt(MAX_PIXELS / (viewport.width * viewport.height)));\n element.width = Math.floor(viewport.width * scale);\n element.height = Math.floor(viewport.height * scale);\n task = page.render({ canvas: element, viewport, ...(scale === 1 ? {} : { transform: [scale, 0, 0, scale, 0, 0] }) });\n return task.promise.then(() => { if (!cancelled) setRendered(true); });\n }).catch(() => {});\n return () => { cancelled = true; task?.cancel(); };\n }, [document, number, width, near]);\n return <div ref={box} data-page={number} data-testid={`${testId}-pdf-page`} data-rendered={rendered || undefined} className=\"relative shrink-0 bg-background outline outline-1 outline-border\"\n style={{ width, height: Math.round(width * (own ?? ratio)) }}>\n <canvas ref={canvas} data-testid={`${testId}-pdf-canvas`} className=\"absolute inset-0 size-full\" />\n </div>;\n}\n",
34
+ "type": "registry:component",
35
+ "target": "components/viewer-pdf/pdf-pages.tsx"
36
+ },
37
+ {
38
+ "path": "registry/viewer-pdf/local-file-viewer.tsx",
39
+ "content": "'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { mediaTypeOf } from '@hachej/boring-files/file-types';\nimport { downloadFile } from '../viewer-frame/download';\nimport { IMAGE_TYPES, ImagePane } from '../viewer-image/image-pane';\nimport { formatBytes } from '../viewer-frame/media';\nimport { PdfPane } from './pdf-pane';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport type { ViewerFrameProps } from '../viewer-frame/viewer-frame';\nimport { useViewerText } from '../viewer-frame/viewer-window';\n\nexport interface LocalFileViewerProps extends Pick<ViewerFrameProps, 'subtitle' | 'headerStart' | 'controls' | 'onClose' | 'testId' | 'titleTestId' | 'className'> {\n readonly name: string;\n /** Browser-only bytes. Nothing is uploaded or retained on reload. A File is also a Blob. */\n readonly blob: Blob;\n}\n\n/** A read-only browser file, without a resource client or locator. A specific Blob type wins; otherwise the name supplies the type. */\nexport function LocalFileViewer({ name, blob, ...frame }: LocalFileViewerProps) {\n const declared = blob.type.split(';')[0]?.trim().toLowerCase();\n const mediaType = !declared || declared === 'application/octet-stream' ? mediaTypeOf(name) : declared;\n const media = useMemo(() => blob.type === mediaType ? blob : blob.slice(0, blob.size, mediaType), [blob, mediaType]);\n if (Object.hasOwn(IMAGE_TYPES, mediaType)) return <ImagePane key={name} name={name} blob={media} mediaType={mediaType} {...frame} />;\n if (mediaType === 'application/pdf') return <PdfPane key={name} name={name} blob={media} {...frame} />;\n const text = mediaType.startsWith('text/') || mediaType === 'application/json' || mediaType === 'application/xml' || mediaType.endsWith('+json') || mediaType.endsWith('+xml');\n return <LocalText name={name} blob={blob} mediaType={mediaType} text={text} {...frame} />;\n}\n\ntype Preview = { readonly blob: Blob; readonly text: string | undefined };\n\n/** Text stays literal, including HTML. Unknown types and failed UTF-8 reads keep the original download. */\nfunction LocalText({ name, blob, mediaType, text: canRead, subtitle, testId = 'viewer', ...frame }: LocalFileViewerProps & { readonly mediaType: string; readonly text: boolean }) {\n const { labels } = useViewerText();\n const [preview, setPreview] = useState<Preview>();\n useEffect(() => {\n if (!canRead) return;\n let active = true;\n void (async () => {\n let text: string | undefined;\n try {\n const bytes = new Uint8Array(await blob.arrayBuffer());\n if (!bytes.includes(0)) text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);\n } catch { /* A failed read leaves the original file available for download. */ }\n if (active) setPreview({ blob, text });\n })();\n return () => { active = false; };\n }, [blob, canRead]);\n const loading = canRead && preview?.blob !== blob;\n const text = canRead && preview?.blob === blob ? preview.text : undefined;\n const title = name.split('/').pop() || name;\n return <ViewerFrame title={title} testId={testId} {...frame}\n subtitle={<>{subtitle}{subtitle && <span aria-hidden=\"true\">·</span>}{mediaType} · {formatBytes(blob.size)}</>}\n onDownload={() => downloadFile(title, blob, blob.type || mediaType)}>\n {text === undefined\n ? <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{loading ? labels.loading : labels.noPreview}</p>\n : <div className=\"min-h-0 flex-1 overflow-auto p-3\"><pre data-testid={`${testId}-text`} className=\"m-0 font-mono text-sm leading-relaxed break-words whitespace-pre-wrap\">{text}</pre></div>}\n </ViewerFrame>;\n}\n",
40
+ "type": "registry:component",
41
+ "target": "components/viewer-pdf/local-file-viewer.tsx"
42
+ }
43
+ ],
44
+ "type": "registry:component"
45
+ }