@hachej/boring-ui-kit 0.4.25 → 0.4.27

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 (101) hide show
  1. package/README.md +40 -3
  2. package/dist/chat-artifacts.d.ts +157 -0
  3. package/dist/chat-artifacts.js +274 -0
  4. package/dist/chat-dictation.d.ts +27 -0
  5. package/dist/chat-dictation.js +71 -0
  6. package/dist/chat-react-controller.d.ts +25 -0
  7. package/dist/chat-react-controller.js +72 -0
  8. package/dist/chat-react-conversations.d.ts +40 -0
  9. package/dist/chat-react-conversations.js +68 -0
  10. package/dist/chat-react-draft.d.ts +11 -0
  11. package/dist/chat-react-draft.js +91 -0
  12. package/dist/chat-react-remote.d.ts +33 -0
  13. package/dist/chat-react-remote.js +72 -0
  14. package/dist/chat-react-session.d.ts +170 -0
  15. package/dist/chat-react-session.js +367 -0
  16. package/dist/chat-react-transcript.d.ts +39 -0
  17. package/dist/chat-react-transcript.js +123 -0
  18. package/dist/chat-react.d.ts +62 -0
  19. package/dist/chat-react.js +95 -0
  20. package/dist/chat-text.d.ts +131 -0
  21. package/dist/chat-text.js +220 -0
  22. package/dist/chat-view.d.ts +2 -17
  23. package/dist/chat-view.js +1 -0
  24. package/dist/chat-voice-message.d.ts +65 -0
  25. package/dist/chat-voice-message.js +235 -0
  26. package/dist/chat-voice.d.ts +7 -0
  27. package/dist/chat-voice.js +4 -0
  28. package/dist/experience-catalog.d.ts +2 -2
  29. package/dist/experience-region-tree.d.ts +1 -1
  30. package/dist/markdown-editor-labels.d.ts +83 -0
  31. package/dist/markdown-editor-labels.js +75 -0
  32. package/dist/markdown-editor.d.ts +7 -1
  33. package/dist/markdown-editor.js +28 -18
  34. package/dist/markdown-extensions.d.ts +4 -2
  35. package/dist/markdown-extensions.js +2 -2
  36. package/dist/markdown-preserved.d.ts +5 -1
  37. package/dist/markdown-preserved.js +15 -4
  38. package/dist/markdown-proposal-diff.d.ts +3 -1
  39. package/dist/markdown-proposal-diff.js +6 -5
  40. package/dist/run-notifications.d.ts +42 -0
  41. package/dist/run-notifications.js +67 -0
  42. package/dist/shared-chat/index.d.ts +1 -1
  43. package/dist/shared-chat/index.js +5 -4
  44. package/dist/text-draft-controls.d.ts +5 -1
  45. package/dist/text-draft-controls.js +6 -5
  46. package/dist/text-draft-labels.d.ts +26 -0
  47. package/dist/text-draft-labels.js +47 -0
  48. package/dist/voice-attempt.d.ts +33 -0
  49. package/dist/voice-attempt.js +122 -0
  50. package/dist/voice-capture.d.ts +12 -0
  51. package/dist/voice-capture.js +17 -2
  52. package/package.json +14 -2
  53. package/r/agent-target.json +5 -9
  54. package/r/app-shell.json +19 -28
  55. package/r/board-viewer.json +8 -11
  56. package/r/button.json +2 -9
  57. package/r/diagram-viewer.json +0 -5
  58. package/r/dock.json +2 -2
  59. package/r/documents.json +13 -18
  60. package/r/feedback-preview.json +1 -7
  61. package/r/feedback.json +12 -19
  62. package/r/file-tree.json +4 -14
  63. package/r/highlights.json +4 -9
  64. package/r/html-viewer.json +3 -3
  65. package/r/looks-viewer.json +3 -6
  66. package/r/maquette-viewer.json +2 -20
  67. package/r/markdown-editor.json +4 -7
  68. package/r/mockup-viewer.json +2 -5
  69. package/r/pi-ambient.json +11 -19
  70. package/r/pi-app-kinds.json +10 -10
  71. package/r/pi-app.json +17 -27
  72. package/r/pi-artifacts.json +3 -4
  73. package/r/pi-chat-approval.json +2 -5
  74. package/r/pi-chat-attachments.json +2 -4
  75. package/r/pi-chat-markdown.json +3 -5
  76. package/r/pi-chat-mentions.json +2 -3
  77. package/r/pi-chat-pickers.json +2 -4
  78. package/r/pi-chat-plugins.json +11 -11
  79. package/r/pi-chat-question.json +1 -7
  80. package/r/pi-chat-slash.json +3 -6
  81. package/r/pi-chat-subagent.json +3 -8
  82. package/r/pi-chat-voice.json +7 -10
  83. package/r/pi-chat.json +25 -53
  84. package/r/pi-workspace.json +4 -7
  85. package/r/placed-agent.json +4 -5
  86. package/r/provider-setup.json +2 -8
  87. package/r/record-viewer.json +0 -9
  88. package/r/registry.json +182 -403
  89. package/r/requests-board.json +2 -10
  90. package/r/shared-chat.json +10 -24
  91. package/r/task-list-viewer.json +3 -9
  92. package/r/utils.json +14 -2
  93. package/r/version-history.json +3 -5
  94. package/r/viewer-code.json +2 -3
  95. package/r/viewer-frame.json +9 -17
  96. package/r/viewer-html.json +4 -5
  97. package/r/viewer-image.json +3 -4
  98. package/r/viewer-markdown.json +6 -8
  99. package/r/viewer-pdf.json +5 -7
  100. package/r/viewers.json +6 -6
  101. package/r/windows.json +3 -8
@@ -4,18 +4,16 @@
4
4
  "title": "Version history rows",
5
5
  "description": "Shared view-only version rows and menu metadata with relative time, provenance and Current. No file reads, publication or restoration.",
6
6
  "dependencies": [
7
- "@hachej/boring-files@^0.4.25",
7
+ "@hachej/boring-files@^0.4.27",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/button.json",
13
- "button"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/button.json"
14
12
  ],
15
13
  "files": [
16
14
  {
17
15
  "path": "registry/version-history/version-history.tsx",
18
- "content": "'use client';\n\nimport type { SavedRevision } from '@hachej/boring-files/revision';\nimport { Button } from '@/components/ui/button';\nimport { ActionMenuItemContent } from '../button/actions';\nimport type { MenuItem } from '../button/actions';\n\nexport const defaultVersionHistoryLabels = {\n versionHistory: 'Version history',\n currentVersion: 'Current',\n versionYou: 'You',\n versionAgent: 'Agent',\n versionObserved: 'External change',\n versionUnknownAuthor: 'Saved version',\n versionJustNow: 'Just now',\n versionMinutesAgo: (count: number) => `${count} min ago`,\n versionHoursAgo: (count: number) => `${count} h ago`,\n versionYesterdayAt: (time: string) => `Yesterday ${time}`,\n versionToday: 'Today',\n versionYesterday: 'Yesterday',\n};\nexport type VersionHistoryLabels = typeof defaultVersionHistoryLabels;\nexport const frenchVersionHistoryLabels: VersionHistoryLabels = {\n versionHistory: 'Historique des versions',\n currentVersion: 'Actuelle',\n versionYou: 'Vous',\n versionAgent: 'Agent',\n versionObserved: 'Modification externe',\n versionUnknownAuthor: 'Version enregistrée',\n versionJustNow: 'À l’instant',\n versionMinutesAgo: count => `Il y a ${count} min`,\n versionHoursAgo: count => `Il y a ${count} h`,\n versionYesterdayAt: time => `Hier ${time}`,\n versionToday: 'Aujourd’hui',\n versionYesterday: 'Hier',\n};\n\nexport interface VersionHistoryEntry {\n readonly id: string;\n readonly label: string;\n readonly latest?: boolean;\n readonly savedAt?: number;\n readonly source?: SavedRevision['source'];\n /** Only shown when the host has measured the change. Never inferred from a revision id. */\n readonly changeSummary?: string;\n}\n\nfunction dayLabel(time: number, now: number, labels: VersionHistoryLabels): string {\n const date = new Date(time), today = new Date(now), yesterday = new Date(now);\n yesterday.setDate(yesterday.getDate() - 1);\n return date.toDateString() === today.toDateString() ? labels.versionToday\n : date.toDateString() === yesterday.toDateString() ? labels.versionYesterday\n : date.toLocaleDateString();\n}\n\nfunction relativeTime(time: number, now: number, labels: VersionHistoryLabels): string {\n const minutes = Math.max(0, Math.floor((now - time) / 60000));\n if (minutes < 1) return labels.versionJustNow;\n if (minutes < 60) return labels.versionMinutesAgo(minutes);\n const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);\n if (new Date(time).toDateString() === yesterday.toDateString()) return labels.versionYesterdayAt(new Date(time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }));\n if (minutes < 1440) return labels.versionHoursAgo(Math.floor(minutes / 60));\n return new Date(time).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });\n}\n\n/** Current is the displayed revision, not necessarily the latest one. Shared by menus and inline rows. */\nexport function versionHistoryItems({ versions, current, onSelect, labels, now, testId }: {\n readonly versions: readonly VersionHistoryEntry[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly labels: VersionHistoryLabels;\n readonly now: number;\n readonly testId: string;\n}): readonly MenuItem[] {\n return versions.map(version => {\n const author = version.source === 'publish' ? labels.versionYou : version.source === 'agent' ? labels.versionAgent : version.source === 'observed' ? labels.versionObserved : labels.versionUnknownAuthor;\n return { id: version.id, label: version.savedAt !== undefined ? relativeTime(version.savedAt, now, labels) : version.label,\n description: version.changeSummary ? `${author} · ${version.changeSummary}` : author,\n ...(version.savedAt !== undefined ? { group: dayLabel(version.savedAt, now, labels) } : {}),\n ...(version.id === current ? { hint: labels.currentVersion } : {}), checked: version.id === current,\n testId: `${testId}-item`, onSelect: () => onSelect(version.id) };\n });\n}\n\n/** Inline form of the viewer menu's rows. The consumer's shadcn Button owns each selection control. */\nexport function VersionHistoryRows({ versions, current, onSelect, labels: given, testId = 'version-history' }: {\n readonly versions: readonly VersionHistoryEntry[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly labels?: Partial<VersionHistoryLabels>;\n readonly testId?: string;\n}) {\n const labels = { ...defaultVersionHistoryLabels, ...given };\n const items = versionHistoryItems({ versions, current, onSelect, labels, now: Date.now(), testId });\n return <ul data-boring=\"version-history\" className=\"m-0 list-none p-0\">\n {items.map((item, index) => <li key={item.id}>\n {item.group && item.group !== items[index - 1]?.group && <p className=\"px-2 pt-3 pb-1 text-xs text-muted-foreground\">{item.group}</p>}\n <Button type=\"button\" variant=\"ghost\" aria-pressed={item.checked} data-item={item.id} data-testid={item.testId} onClick={item.onSelect} className=\"h-auto min-h-11 w-full justify-start gap-2 text-left\">\n <ActionMenuItemContent item={item} testId={testId} />\n </Button>\n </li>)}\n </ul>;\n}\n",
16
+ "content": "'use client';\n\nimport type { SavedRevision } from '@hachej/boring-files/revision';\nimport { Button } from '@/components/ui/button';\nimport { ActionMenuItemContent } from '../button/actions';\nimport type { MenuItem } from '../button/actions';\n\nexport const defaultVersionHistoryLabels = {\n versionHistory: 'Version history',\n currentVersion: 'Current',\n versionYou: 'You',\n versionAgent: 'Agent',\n versionObserved: 'External change',\n versionUnknownAuthor: 'Saved version',\n versionJustNow: 'Just now',\n versionMinutesAgo: (count: number) => `${count} min ago`,\n versionHoursAgo: (count: number) => `${count} h ago`,\n versionYesterdayAt: (time: string) => `Yesterday ${time}`,\n versionToday: 'Today',\n versionYesterday: 'Yesterday',\n};\nexport type VersionHistoryLabels = typeof defaultVersionHistoryLabels;\nexport const frenchVersionHistoryLabels: VersionHistoryLabels = {\n versionHistory: 'Historique des versions',\n currentVersion: 'Actuelle',\n versionYou: 'Vous',\n versionAgent: 'Agent',\n versionObserved: 'Modification externe',\n versionUnknownAuthor: 'Version enregistrée',\n versionJustNow: 'À l’instant',\n versionMinutesAgo: count => `Il y a ${count} min`,\n versionHoursAgo: count => `Il y a ${count} h`,\n versionYesterdayAt: time => `Hier ${time}`,\n versionToday: 'Aujourd’hui',\n versionYesterday: 'Hier',\n};\n\nexport interface VersionHistoryEntry {\n readonly id: string;\n readonly label: string;\n readonly latest?: boolean;\n readonly savedAt?: number;\n readonly source?: SavedRevision['source'];\n /** Only shown when the host has measured the change. Never inferred from a revision id. */\n readonly changeSummary?: string;\n}\n\nfunction dayLabel(time: number, now: number, labels: VersionHistoryLabels): string {\n const date = new Date(time), today = new Date(now), yesterday = new Date(now);\n yesterday.setDate(yesterday.getDate() - 1);\n return date.toDateString() === today.toDateString() ? labels.versionToday\n : date.toDateString() === yesterday.toDateString() ? labels.versionYesterday\n : date.toLocaleDateString();\n}\n\nfunction relativeTime(time: number, now: number, labels: VersionHistoryLabels): string {\n const minutes = Math.max(0, Math.floor((now - time) / 60000));\n if (minutes < 1) return labels.versionJustNow;\n if (minutes < 60) return labels.versionMinutesAgo(minutes);\n const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);\n if (new Date(time).toDateString() === yesterday.toDateString()) return labels.versionYesterdayAt(new Date(time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }));\n if (minutes < 1440) return labels.versionHoursAgo(Math.floor(minutes / 60));\n return new Date(time).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });\n}\n\n/** Current is the displayed revision, not necessarily the latest one. Shared by menus and inline rows. */\nexport function versionHistoryItems({ versions, current, onSelect, labels, now, testId }: {\n readonly versions: readonly VersionHistoryEntry[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly labels: VersionHistoryLabels;\n readonly now: number;\n readonly testId: string;\n}): readonly MenuItem[] {\n return versions.map(version => {\n const author = version.source === 'publish' ? labels.versionYou : version.source === 'agent' ? labels.versionAgent : version.source === 'observed' ? labels.versionObserved : labels.versionUnknownAuthor;\n return { id: version.id, label: version.savedAt !== undefined ? relativeTime(version.savedAt, now, labels) : version.label,\n description: version.changeSummary ? `${author} · ${version.changeSummary}` : author,\n ...(version.savedAt !== undefined ? { group: dayLabel(version.savedAt, now, labels) } : {}),\n ...(version.id === current ? { hint: labels.currentVersion } : {}), checked: version.id === current,\n testId: `${testId}-item`, onSelect: () => onSelect(version.id) };\n });\n}\n\n/** Inline form of the viewer menu's rows. The consumer's shadcn Button owns each selection control. */\nexport function VersionHistoryRows({ versions, current, onSelect, labels: given, testId = 'version-history' }: {\n readonly versions: readonly VersionHistoryEntry[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly labels?: Partial<VersionHistoryLabels>;\n readonly testId?: string;\n}) {\n const labels = { ...defaultVersionHistoryLabels, ...given };\n // eslint-disable-next-line react-hooks/purity -- relative times (\"2 min ago\") read the clock each render on purpose\n const items = versionHistoryItems({ versions, current, onSelect, labels, now: Date.now(), testId });\n return <ul data-boring=\"version-history\" className=\"m-0 list-none p-0\">\n {items.map((item, index) => <li key={item.id}>\n {item.group && item.group !== items[index - 1]?.group && <p className=\"px-2 pt-3 pb-1 text-xs text-muted-foreground\">{item.group}</p>}\n <Button type=\"button\" variant=\"ghost\" aria-pressed={item.checked} data-item={item.id} data-testid={item.testId} onClick={item.onSelect} className=\"h-auto min-h-11 w-full justify-start gap-2 text-left\">\n <ActionMenuItemContent item={item} testId={testId} />\n </Button>\n </li>)}\n </ul>;\n}\n",
19
17
  "type": "registry:component",
20
18
  "target": "components/version-history/version-history.tsx"
21
19
  }
@@ -8,9 +8,8 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-frame.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
13
- "table"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-frame.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json"
14
13
  ],
15
14
  "files": [
16
15
  {
@@ -4,23 +4,15 @@
4
4
  "title": "Viewer frame",
5
5
  "description": "The standard viewer top bar every viewer draws in (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu), its window labels, download, share and media helpers and the sandboxed interactive HTML frame. The viewers' styles live here. Each viewer is its own item (viewer-markdown, viewer-html, viewer-image, viewer-pdf, viewer-code) with a cheap kind module and a view loaded on first open.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.25",
7
+ "@hachej/boring-ui-kit@^0.4.27",
8
8
  "react@^19.0.0",
9
9
  "react-dom@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/theme.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/button.json",
16
- "button",
17
- "toggle",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/version-history.json",
19
- "toggle-group",
20
- "dialog",
21
- "textarea",
22
- "badge",
23
- "dropdown-menu"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/version-history.json"
24
16
  ],
25
17
  "files": [
26
18
  {
@@ -37,13 +29,13 @@
37
29
  },
38
30
  {
39
31
  "path": "registry/viewer-frame/media.ts",
40
- "content": "import { useEffect, useMemo, useState } from 'react';\n\n/** What a read-only media viewer shows: bytes or a Blob (the viewer owns the object URL it makes) or a URL the host owns. */\nexport interface MediaSource {\n readonly bytes?: Uint8Array | ArrayBuffer;\n readonly blob?: Blob;\n readonly url?: string;\n}\n\n/** A Blob for the source, or `undefined` when the host gave a URL (or nothing). */\nexport function mediaBlob(source: MediaSource, mediaType: string): Blob | undefined {\n if (source.blob) return source.blob;\n if (source.bytes) return new Blob([source.bytes as BlobPart], { type: mediaType });\n return undefined;\n}\n\n/** An object URL for the source, revoked when the source changes or the viewer unmounts. A host `url` is passed through untouched. */\nexport function useMediaUrl(source: MediaSource, mediaType: string): { url: string | undefined; size: number | undefined; blob: Blob | undefined } {\n const blob = useMemo(() => mediaBlob(source, mediaType), [source.blob, source.bytes, mediaType]); // eslint-disable-line react-hooks/exhaustive-deps\n const [made, setMade] = useState<string>();\n useEffect(() => {\n if (!blob) { setMade(undefined); return; }\n const url = URL.createObjectURL(blob);\n setMade(url);\n return () => { URL.revokeObjectURL(url); setMade(current => current === url ? undefined : current); };\n }, [blob]);\n return { url: blob ? made : source.url, size: blob?.size, blob };\n}\n\nexport function formatBytes(size: number): string {\n if (size < 1024) return `${size} B`;\n if (size < 1024 * 1024) return `${(size / 1024).toFixed(size < 10240 ? 1 : 0)} KB`;\n return `${(size / 1024 / 1024).toFixed(1)} MB`;\n}\n",
32
+ "content": "import { useEffect, useMemo, useState } from 'react';\n\n/** What a read-only media viewer shows: bytes or a Blob (the viewer owns the object URL it makes) or a URL the host owns. */\nexport interface MediaSource {\n readonly bytes?: Uint8Array | ArrayBuffer;\n readonly blob?: Blob;\n readonly url?: string;\n}\n\n/** A Blob for the source, or `undefined` when the host gave a URL (or nothing). */\nexport function mediaBlob(source: MediaSource, mediaType: string): Blob | undefined {\n if (source.blob) return source.blob;\n if (source.bytes) return new Blob([source.bytes as BlobPart], { type: mediaType });\n return undefined;\n}\n\n/** An object URL for the source, revoked when the source changes or the viewer unmounts. A host `url` is passed through untouched. */\nexport function useMediaUrl(source: MediaSource, mediaType: string): { url: string | undefined; size: number | undefined; blob: Blob | undefined } {\n const { blob: given, bytes } = source;\n const blob = useMemo(() => mediaBlob({ blob: given, bytes }, mediaType), [given, bytes, mediaType]);\n // The object URL of the current blob only: a URL made for an earlier blob is never returned, and is revoked with it.\n const [made, setMade] = useState<{ readonly blob: Blob; readonly url: string }>();\n useEffect(() => {\n if (!blob) return undefined;\n const url = URL.createObjectURL(blob);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the object URL is an external resource owned by this effect run; it exists only after commit and is revoked in the cleanup\n setMade({ blob, url });\n return () => { URL.revokeObjectURL(url); setMade(current => current?.url === url ? undefined : current); };\n }, [blob]);\n return { url: blob ? (made?.blob === blob ? made.url : undefined) : source.url, size: blob?.size, blob };\n}\n\nexport function formatBytes(size: number): string {\n if (size < 1024) return `${size} B`;\n if (size < 1024 * 1024) return `${(size / 1024).toFixed(size < 10240 ? 1 : 0)} KB`;\n return `${(size / 1024 / 1024).toFixed(1)} MB`;\n}\n",
41
33
  "type": "registry:lib",
42
34
  "target": "components/viewer-frame/media.ts"
43
35
  },
44
36
  {
45
37
  "path": "registry/viewer-frame/menu.tsx",
46
- "content": "'use client';\n\nimport { useState } from 'react';\nimport { HistoryIcon } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { ActionMenuContent, useMenuPortal } from '../button/actions';\nimport type { MenuItem } from '../button/actions';\nimport { versionHistoryItems } from '../version-history/version-history';\nimport type { VersionHistoryEntry } from '../version-history/version-history';\nimport { useViewerText } from './viewer-window';\n\nexport type ViewerMenuItem = MenuItem;\nexport interface ViewerActionsProps {\n readonly label: string;\n readonly icon: ReactNode;\n readonly items: readonly MenuItem[];\n readonly testId?: string;\n readonly align?: 'start' | 'end';\n readonly className?: string;\n readonly children?: ReactNode;\n readonly onOpenChange?: (open: boolean) => void;\n}\nexport type ViewerVersion = VersionHistoryEntry;\n\n/** The consumer's shadcn Base UI dropdown supplies positioning, dismissal and keyboard navigation. */\nexport function ViewerActions({ label, icon, items, testId = 'viewer-menu', align = 'end', className, children, onOpenChange }: ViewerActionsProps) {\n const portal = useMenuPortal();\n return <DropdownMenu onOpenChange={open => onOpenChange?.(open)}>\n <DropdownMenuTrigger render={<Button ref={portal.ref} variant=\"ghost\" size=\"icon-sm\" aria-label={label} title={label} data-testid={testId} className={className}>{icon}</Button>} />\n <ActionMenuContent label={label} testId={testId} items={items} align={align} container={portal.container}>{children}</ActionMenuContent>\n </DropdownMenu>;\n}\n\n/** Saved versions are view-only choices. Current is the displayed revision, not necessarily the latest one. */\nexport function ViewerVersions({ versions, current, onSelect, testId = 'viewer-versions', label }: {\n readonly versions: readonly ViewerVersion[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly testId?: string;\n readonly label?: string;\n}) {\n const { labels } = useViewerText();\n const [now, setNow] = useState(Date.now);\n return <ViewerActions label={label ?? labels.versionHistory} testId={testId} align=\"end\" icon={<HistoryIcon data-icon=\"inline-start\" aria-hidden=\"true\" />} onOpenChange={open => { if (open) setNow(Date.now()); }}\n items={versionHistoryItems({ versions, current, onSelect, labels, now, testId })} />;\n}\n",
38
+ "content": "'use client';\n\nimport { useState } from 'react';\nimport { HistoryIcon } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { ActionMenuContent, useMenuPortal } from '../button/actions';\nimport type { MenuItem } from '../button/actions';\nimport { versionHistoryItems } from '../version-history/version-history';\nimport type { VersionHistoryEntry } from '../version-history/version-history';\nimport { useViewerText } from './viewer-window';\n\nexport type ViewerMenuItem = MenuItem;\nexport interface ViewerActionsProps {\n readonly label: string;\n readonly icon: ReactNode;\n readonly items: readonly MenuItem[];\n readonly testId?: string;\n readonly align?: 'start' | 'end';\n readonly className?: string;\n readonly children?: ReactNode;\n readonly onOpenChange?: (open: boolean) => void;\n}\nexport type ViewerVersion = VersionHistoryEntry;\n\n/** The consumer's shadcn Base UI dropdown supplies positioning, dismissal and keyboard navigation. */\nexport function ViewerActions({ label, icon, items, testId = 'viewer-menu', align = 'end', className, children, onOpenChange }: ViewerActionsProps) {\n const { ref: portalRef, container: portalContainer } = useMenuPortal();\n return <DropdownMenu onOpenChange={open => onOpenChange?.(open)}>\n <DropdownMenuTrigger render={<Button ref={portalRef} variant=\"ghost\" size=\"icon-sm\" aria-label={label} title={label} data-testid={testId} className={className}>{icon}</Button>} />\n <ActionMenuContent label={label} testId={testId} items={items} align={align} container={portalContainer}>{children}</ActionMenuContent>\n </DropdownMenu>;\n}\n\n/** Saved versions are view-only choices. Current is the displayed revision, not necessarily the latest one. */\nexport function ViewerVersions({ versions, current, onSelect, testId = 'viewer-versions', label }: {\n readonly versions: readonly ViewerVersion[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly testId?: string;\n readonly label?: string;\n}) {\n const { labels } = useViewerText();\n const [now, setNow] = useState(Date.now);\n return <ViewerActions label={label ?? labels.versionHistory} testId={testId} align=\"end\" icon={<HistoryIcon data-icon=\"inline-start\" aria-hidden=\"true\" />} onOpenChange={open => { if (open) setNow(Date.now()); }}\n items={versionHistoryItems({ versions, current, onSelect, labels, now, testId })} />;\n}\n",
47
39
  "type": "registry:component",
48
40
  "target": "components/viewer-frame/menu.tsx"
49
41
  },
@@ -55,7 +47,7 @@
55
47
  },
56
48
  {
57
49
  "path": "registry/viewer-frame/viewer-frame.tsx",
58
- "content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, CheckIcon, CircleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Badge } from '@/components/ui/badge';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { Textarea } from '@/components/ui/textarea';\nimport { DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ViewerActions } from './menu';\nimport type { ViewerMenuItem } from './menu';\nimport { useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerIcons, ViewerLabels } from './viewer-window';\nimport { cn } from '../utils/utils';\nimport type { ManualCopyError } from '../utils/utils';\nimport type { ViewerShare } from './share';\n\nexport type { ViewerShare, ViewerShareRequest, ViewerShareResult } from './share';\nexport { ViewerWindowProvider, defaultViewerIcons, defaultViewerLabels, useViewerText } from './viewer-window';\nexport type { ViewerIcons, ViewerLabels, ViewerWindow } from './viewer-window';\n\nexport type ViewerTone = 'neutral' | 'success' | 'warning' | 'danger';\n/**\n * `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` (unless `showSavedStatus`) and shows\n * `read-only` as a fact. The bar shows a dot in the default shadcn roles: `success` primary, `warning` and `neutral` muted-foreground, `danger` destructive. Only a\n * `danger` status also shows its words, because the person must act on it; the others keep the label in the tooltip and for screen readers.\n */\nexport interface ViewerStatus { readonly label: string; readonly tone?: ViewerTone; readonly kind?: 'saved' | 'read-only' | undefined }\n\nexport interface ViewerFrameProps {\n /** What is shown, for example the file name. */\n readonly title: string;\n /** Type, version, path, size: short facts under the title. */\n readonly subtitle?: ReactNode;\n /** Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\n readonly status?: ViewerStatus | undefined;\n /** Keep a quiet saved acknowledgement visible, for autosaving editors. */\n readonly showSavedStatus?: boolean | undefined;\n /** Opaque identifiers handed to `onShare` so the host can build a link to this exact thing. */\n readonly target?: unknown;\n readonly revision?: string | undefined;\n /** Leading header content, for example Back before the file title. */\n readonly headerStart?: ReactNode;\n /** Viewer-specific controls that stay visible: a mode toggle, Save while there are edits, zoom steps, a version switcher when there are several. */\n readonly controls?: ReactNode;\n /**\n * The one control that matters most here (Comment on a version to try, for example), kept in the bar at every width. With the compact\n * header a phone folds `controls` into the \"…\" menu; this one stays beside it, so the main action is one tap away. Test id `<testId>-main`.\n */\n readonly mainControl?: ReactNode;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[];\n /** Viewer-specific native dropdown groups, before the standard secondary actions. */\n readonly menuContent?: ReactNode;\n /**\n * The host's actions for this bar, after the window's (`ViewerWindow.actions`): `header` ones as buttons beside Share, `menu` ones at the\n * end of the \"…\" menu. Test ids `<testId>-<id>`.\n */\n readonly actions?: readonly BlockAction[] | undefined;\n /** This bar's words and icons, over the window's and the defaults (`defaultViewerLabels`, `defaultViewerIcons`). */\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n /** Each standard action appears only when its handler is supplied. Share is always visible; Reload, Copy, Download and Open in new tab live in the \"…\" menu. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onCopy?: () => unknown;\n readonly onDownload?: () => unknown;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Prefix of the `data-testid` of the bar and its actions (`viewer-share`, `viewer-more`, and `viewer-refresh` for the menu's Reload, ...). */\n readonly testId?: string;\n readonly titleTestId?: string;\n /** Quiet one-row bar with secondary actions in the menu. False retains the former visible Share/fullscreen/close layout. */\n readonly compactHeader?: boolean;\n readonly className?: string;\n readonly children?: ReactNode;\n}\n\n\n/** Narrow panes drop optional controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\nconst touchScreen = () => typeof matchMedia === 'function' && matchMedia('(max-width: 639px), (pointer: coarse)').matches;\n\n/** How much room the bar has, for a pane that wants to drop optional controls (the zoom percentage, for example) on a narrow panel. */\nexport interface ViewerBarSpace { readonly tight: boolean }\nconst ViewerBarContext = createContext<ViewerBarSpace>({ tight: false });\nexport const useViewerBar = (): ViewerBarSpace => useContext(ViewerBarContext);\nconst NOTICE_MS = 2600;\n\n/**\n * One choice of a mode toggle, for example Rich | Source. Icon only: `label` is the accessible name and the tooltip, `text` the short word\n * for hosts that want it (shown when there is no icon). A disabled choice shows `reason` as its tooltip.\n */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly reason?: string | undefined; readonly testId?: string }\n\n/** A segmented control over the consumer's outlined ToggleGroup: the pressed segment is filled with a full-contrast icon, the others muted. */\nexport function ViewerToggle<T extends string>({ label, value, options, onChange }: { readonly label: string; readonly value: T; readonly options: readonly ViewerToggleOption<T>[]; readonly onChange: (id: T) => void }) {\n return <ToggleGroup aria-label={label} variant=\"outline\" size=\"sm\" spacing={1} value={[value]} onValueChange={values => {\n const selected = options.find(option => option.id === values[0] && !option.disabled);\n if (selected && selected.id !== value) onChange(selected.id);\n }}>\n {options.map(option => <ToggleGroupItem key={option.id} value={option.id} aria-label={option.label} title={option.disabled && option.reason ? option.reason : option.label} disabled={option.disabled}\n className=\"disabled:pointer-events-auto disabled:cursor-not-allowed\" data-testid={option.testId} data-mode={option.id}>\n <span className={cn('inline-flex items-center', value !== option.id && 'text-muted-foreground')}>{option.icon ?? option.text}</span></ToggleGroupItem>)}\n </ToggleGroup>;\n}\n\n/** The copy helper throws this when the browser allowed no automatic copy at all; the person copies by hand instead. */\nconst needsManualCopy = (error: unknown): error is ManualCopyError => (error as { name?: string } | null)?.name === 'ManualCopyError' && typeof (error as { text?: unknown }).text === 'string';\n\n/** Last resort for Copy and Share: the text, already selected, so Ctrl/Cmd+C or the long-press menu finishes the job. */\nfunction ManualCopy({ testId, link, text, onClose, labels }: { readonly testId: string; readonly link: boolean; readonly text: string; readonly onClose: () => void; readonly labels: ViewerLabels }) {\n const field = useRef<HTMLTextAreaElement>(null);\n return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>\n <DialogContent showCloseButton={false} className=\"boring-viewer\" data-testid={`${testId}-manual-copy`} initialFocus={field}>\n <DialogHeader><DialogTitle>{labels.manualCopy(link)}</DialogTitle></DialogHeader>\n <Textarea ref={field} readOnly rows={Math.min(6, Math.max(2, Math.ceil(text.length / 48)))} value={text} data-testid={`${testId}-manual-copy-text`} onFocus={event => event.currentTarget.select()} />\n <div className=\"flex justify-end\"><Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-manual-copy-close`} onClick={onClose}>{labels.done}</Button></div>\n </DialogContent>\n </Dialog>;\n}\n\n/** Dot colours: default shadcn roles only. */\nconst DOTS: Record<ViewerTone, string> = {\n neutral: 'fill-muted-foreground',\n success: 'fill-primary',\n warning: 'fill-muted-foreground',\n danger: 'fill-destructive',\n};\n\n/** The save status as a coloured dot; the label is the tooltip and the screen-reader text, and is shown only when the person must act (`danger`). */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n const tone = status.tone ?? 'neutral';\n return <span role=\"status\" aria-label={status.label} title={status.label} data-testid={`${testId}-status`} data-tone={tone} data-kind={status.kind}\n className=\"inline-flex h-6 min-w-6 shrink-0 cursor-default items-center justify-center gap-1.5 text-xs\">\n <CircleIcon aria-hidden=\"true\" strokeWidth={0} className={cn('size-2 shrink-0', DOTS[tone])} />\n <span data-testid={`${testId}-status-text`} className={tone === 'danger' ? 'max-w-48 truncate text-destructive' : 'sr-only'}>{status.label}</span>\n </span>;\n}\n\n/** The one top bar of every viewer, always a single row: title and facts, viewer controls, a status dot when something needs attention, Share, full screen, Close and the \"…\" menu. */\nexport function ViewerFrame({ title, subtitle, status, showSavedStatus = false, target, revision, headerStart, controls, mainControl, menu, menuContent, actions, labels: ownLabels, icons: ownIcons, onRefresh, onShare, onCopy, onDownload, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, compactHeader = true, className, children }: ViewerFrameProps) {\n const bar = useRef<HTMLElement>(null);\n const [space, setSpace] = useState<ViewerBarSpace>({ tight: false });\n const [busy, setBusy] = useState<'refresh' | 'share' | undefined>();\n const [notice, setNotice] = useState<{ readonly text: string; readonly tone: ViewerTone } | undefined>();\n const [copied, setCopied] = useState(false);\n const [manual, setManual] = useState<{ readonly text: string; readonly link: boolean } | undefined>();\n const host = useViewerWindow();\n const { labels, icons } = useViewerText(ownLabels, ownIcons);\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n useLayoutEffect(() => {\n const element = bar.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const measure = () => {\n const width = element.getBoundingClientRect().width;\n const next = { tight: width > 0 && width < (touchScreen() ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer) };\n setSpace(now => now.tight === next.tight ? now : next);\n };\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, [host?.renderHeader]);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\n const guarded = useCallback(async (kind: 'refresh' | 'share', run: () => unknown, failed: string) => {\n setBusy(kind);\n setNotice(undefined);\n try { return await run(); }\n catch (error) { if (mounted.current) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); } return undefined; }\n finally { if (mounted.current) setBusy(undefined); }\n }, []);\n const refresh = () => { if (onRefresh) void guarded('refresh', onRefresh, labels.refreshFailed); };\n const share = () => {\n if (!onShare) return;\n void guarded('share', () => onShare({ title, target, revision }), labels.shareFailed).then(result => {\n if (!mounted.current) return;\n if (result === 'copied') setNotice({ text: labels.linkCopied, tone: 'success' });\n else if (result === 'shared') setNotice({ text: labels.shared, tone: 'success' });\n });\n };\n const copy = () => {\n void Promise.resolve().then(onCopy).then(() => { if (mounted.current) { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => { if (mounted.current) setCopied(false); }, 1500); } })\n .catch(error => { if (!mounted.current) return; if (needsManualCopy(error)) setManual({ text: error.text, link: false }); else setNotice({ text: labels.copyFailed, tone: 'danger' }); });\n };\n const run = (action: (() => unknown) | undefined) => { void Promise.resolve().then(action).catch(() => { if (mounted.current) setNotice({ text: labels.actionFailed, tone: 'danger' }); }); };\n // The host's actions: the window's (every viewer in the panel), then this frame's. Each runs through `run`, so a failure shows as a notice.\n const hostActions = [...host?.actions ?? [], ...actions ?? []].map(action => ({ ...action, onSelect: () => run(action.onSelect) }));\n\n const reading = status?.kind === 'read-only' || status?.label === labels.readOnly;\n const menuItems: ViewerMenuItem[] = ([\n ...(compactHeader && onShare ? [{ id: 'share', label: labels.share, icon: <icons.share className=\"size-4\" />, onSelect: share }] : []),\n ...(compactHeader && host?.onFullscreenChange ? [{ id: 'fullscreen', label: host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen, icon: <icons.enterFullscreen className=\"size-4\" />, onSelect: () => host.onFullscreenChange!(!host.fullscreen) }] : []),\n ...(onRefresh ? [{ id: 'refresh', separatorBefore: true, label: labels.reload, icon: <RefreshCwIcon className=\"size-4\" />, disabled: busy === 'refresh', onSelect: refresh }] : []),\n ...(onCopy ? [{ id: 'copy', label: labels.copy, icon: copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />, onSelect: copy }] : []),\n ...(onDownload ? [{ id: 'download', label: labels.download, icon: <DownloadIcon className=\"size-4\" />, onSelect: () => run(onDownload) }] : []),\n ...(onOpenInNewTab ? [{ id: 'open', label: labels.openInNewTab, icon: <ExternalLinkIcon className=\"size-4\" />, onSelect: () => run(onOpenInNewTab) }] : []),\n ...(menu ?? []).map((item, index) => ({ ...item, separatorBefore: index === 0 || item.separatorBefore })),\n ...menuActions(hostActions).map((action, index) => ({ ...actionMenuItem(action, testId), separatorBefore: index === 0 })),\n ] as ViewerMenuItem[]).map(entry => ({ ...entry, testId: entry.testId ?? `${testId}-${entry.id}` }));\n\n const stacked = !compactHeader && host?.headerLayout === 'stacked';\n const toolbar = controls && <div data-testid={`${testId}-controls`} className={cn('flex shrink-0 items-center gap-1', compactHeader && 'max-[641px]:hidden', stacked && 'flex-wrap px-3 py-2')}>{controls}</div>;\n const titlebar = <>\n {compactHeader && !headerStart && onClose && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><ArrowLeftIcon data-icon=\"inline-start\" className=\"min-[641px]:hidden\" aria-hidden=\"true\" /><icons.close data-icon=\"inline-start\" className=\"hidden min-[641px]:block\" aria-hidden=\"true\" /></Button>}\n {headerStart}\n <div className=\"group/title relative min-w-0 flex-1 basis-0 text-left\">\n <h2 data-testid={titleTestId ?? `${testId}-title`} title={title} className=\"m-0 truncate text-sm leading-5 font-semibold tracking-tight\">{title}</h2>\n {(subtitle || reading) && <p data-testid={`${testId}-subtitle`} className={cn('m-0 flex min-w-0 items-center gap-x-1.5 text-xs leading-4 whitespace-nowrap text-muted-foreground', compactHeader ? 'hidden group-hover/title:flex max-[641px]:hidden' : 'overflow-hidden')}>{subtitle}{subtitle && reading && <span aria-hidden=\"true\">·</span>}{reading && <span data-testid={`${testId}-readonly`}>{labels.readOnly}</span>}</p>}\n </div>\n {!stacked && toolbar}\n {mainControl && <div data-testid={`${testId}-main`} className=\"flex shrink-0 items-center\">{mainControl}</div>}\n {status && !reading && (showSavedStatus || status.kind !== 'saved' && status.label !== labels.saved) && <StatusMark status={status} testId={testId} />}\n <div className=\"flex shrink-0 items-center gap-0.5\">\n {headerActions(hostActions).map(action => <ActionButton key={action.id} action={action} testId={testId} size=\"icon-sm\" />)}\n {!compactHeader && onShare && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.share} title={labels.share} data-testid={`${testId}-share`} disabled={busy === 'share'} onClick={share}><icons.share data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {(menuItems.length > 0 || controls || menuContent) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{menuContent}{menuContent && menuItems.length > 0 && <DropdownMenuSeparator />}{compactHeader && controls && <div className=\"boring-viewer flex flex-wrap items-center gap-2 p-2 min-[641px]:hidden\">{controls}</div>}</ViewerActions>}\n {!compactHeader && host?.onFullscreenChange && <Toggle size=\"sm\" aria-label={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} title={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} data-testid={`${testId}-fullscreen`} pressed={host.fullscreen === true}\n onPressedChange={pressed => host.onFullscreenChange!(pressed)}>{host.fullscreen ? <icons.exitFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.enterFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>}\n {(!compactHeader || headerStart) && onClose && <Button variant=\"ghost\" size=\"icon-sm\" className={compactHeader ? 'max-[641px]:hidden' : undefined} aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><icons.close data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </div>\n </>;\n const header = <header ref={bar} data-testid={`${testId}-bar`} data-layout={stacked ? 'stacked' : undefined}\n className={cn('relative flex shrink-0', stacked ? 'flex-col' : 'h-13 flex-nowrap items-center gap-x-1.5 px-2 sm:px-3')}>\n {stacked ? <><div data-testid={`${testId}-titlebar`} className=\"flex min-h-14 min-w-0 items-center gap-x-1.5 px-3 py-1.5\">{titlebar}</div>{toolbar}</> : titlebar}\n {notice && <Badge role=\"status\" aria-live=\"polite\" data-testid={`${testId}-notice`} data-tone={notice.tone} variant={notice.tone === 'danger' ? 'destructive' : 'secondary'}>{notice.text}</Badge>}\n {manual && <ManualCopy testId={testId} link={manual.link} text={manual.text} labels={labels} onClose={() => setManual(undefined)} />}\n </header>;\n return <ViewerBarContext.Provider value={space}>\n <section data-boring=\"viewer-frame\" data-testid={`${testId}-frame`} data-tight={space.tight || undefined}\n className={cn('boring-viewer isolate flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground', className)}>\n {host?.renderHeader ? host.renderHeader(header) : header}\n <div data-testid={`${testId}-body`} className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{children}</div>\n </section>\n </ViewerBarContext.Provider>;\n}\n",
50
+ "content": "'use client';\n\nimport { useMedia, usePhone } from '../utils/use-media';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, CheckIcon, CircleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Badge } from '@/components/ui/badge';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { Textarea } from '@/components/ui/textarea';\nimport { DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ViewerActions } from './menu';\nimport type { ViewerMenuItem } from './menu';\nimport { useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerIcons, ViewerLabels } from './viewer-window';\nimport { cn } from '../utils/utils';\nimport type { ManualCopyError } from '../utils/utils';\nimport type { ViewerShare } from './share';\n\nexport type { ViewerShare, ViewerShareRequest, ViewerShareResult } from './share';\nexport { ViewerWindowProvider, defaultViewerIcons, defaultViewerLabels, useViewerText } from './viewer-window';\nexport type { ViewerIcons, ViewerLabels, ViewerWindow } from './viewer-window';\n\nexport type ViewerTone = 'neutral' | 'success' | 'warning' | 'danger';\n/**\n * `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` (unless `showSavedStatus`) and shows\n * `read-only` as a fact. The bar shows a dot in the default shadcn roles: `success` primary, `warning` and `neutral` muted-foreground, `danger` destructive. Only a\n * `danger` status also shows its words, because the person must act on it; the others keep the label in the tooltip and for screen readers.\n */\nexport interface ViewerStatus { readonly label: string; readonly tone?: ViewerTone; readonly kind?: 'saved' | 'read-only' | undefined }\n\nexport interface ViewerFrameProps {\n /** What is shown, for example the file name. */\n readonly title: string;\n /** Type, version, path, size: short facts under the title. */\n readonly subtitle?: ReactNode;\n /** Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\n readonly status?: ViewerStatus | undefined;\n /** Keep a quiet saved acknowledgement visible, for autosaving editors. */\n readonly showSavedStatus?: boolean | undefined;\n /** Opaque identifiers handed to `onShare` so the host can build a link to this exact thing. */\n readonly target?: unknown;\n readonly revision?: string | undefined;\n /** Leading header content, for example Back before the file title. */\n readonly headerStart?: ReactNode;\n /** Viewer-specific controls that stay visible: a mode toggle, Save while there are edits, zoom steps, a version switcher when there are several. */\n readonly controls?: ReactNode;\n /**\n * The one control that matters most here (Comment on a version to try, for example), kept in the bar at every width. With the compact\n * header a phone folds `controls` into the \"…\" menu; this one stays beside it, so the main action is one tap away. Test id `<testId>-main`.\n */\n readonly mainControl?: ReactNode;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[];\n /** Viewer-specific native dropdown groups, before the standard secondary actions. */\n readonly menuContent?: ReactNode;\n /**\n * The host's actions for this bar, after the window's (`ViewerWindow.actions`): `header` ones as buttons beside Share, `menu` ones at the\n * end of the \"…\" menu. Test ids `<testId>-<id>`.\n */\n readonly actions?: readonly BlockAction[] | undefined;\n /** This bar's words and icons, over the window's and the defaults (`defaultViewerLabels`, `defaultViewerIcons`). */\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n /** Each standard action appears only when its handler is supplied. Share is always visible; Reload, Copy, Download and Open in new tab live in the \"…\" menu. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onCopy?: () => unknown;\n readonly onDownload?: () => unknown;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Prefix of the `data-testid` of the bar and its actions (`viewer-share`, `viewer-more`, and `viewer-refresh` for the menu's Reload, ...). */\n readonly testId?: string;\n readonly titleTestId?: string;\n /** Quiet one-row bar with secondary actions in the menu. False retains the former visible Share/fullscreen/close layout. */\n readonly compactHeader?: boolean;\n readonly className?: string;\n readonly children?: ReactNode;\n}\n\n\n/** Narrow panes drop optional controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\n\n/** How much room the bar has, for a pane that wants to drop optional controls (the zoom percentage, for example) on a narrow panel. */\nexport interface ViewerBarSpace { readonly tight: boolean }\nconst ViewerBarContext = createContext<ViewerBarSpace>({ tight: false });\nexport const useViewerBar = (): ViewerBarSpace => useContext(ViewerBarContext);\nconst NOTICE_MS = 2600;\n\n/**\n * One choice of a mode toggle, for example Rich | Source. Icon only: `label` is the accessible name and the tooltip, `text` the short word\n * for hosts that want it (shown when there is no icon). A disabled choice shows `reason` as its tooltip.\n */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly reason?: string | undefined; readonly testId?: string }\n\n/** A segmented control over the consumer's outlined ToggleGroup: the pressed segment is filled with a full-contrast icon, the others muted. */\nexport function ViewerToggle<T extends string>({ label, value, options, onChange }: { readonly label: string; readonly value: T; readonly options: readonly ViewerToggleOption<T>[]; readonly onChange: (id: T) => void }) {\n return <ToggleGroup aria-label={label} variant=\"outline\" size=\"sm\" spacing={1} value={[value]} onValueChange={values => {\n const selected = options.find(option => option.id === values[0] && !option.disabled);\n if (selected && selected.id !== value) onChange(selected.id);\n }}>\n {options.map(option => <ToggleGroupItem key={option.id} value={option.id} aria-label={option.label} title={option.disabled && option.reason ? option.reason : option.label} disabled={option.disabled}\n className=\"disabled:pointer-events-auto disabled:cursor-not-allowed\" data-testid={option.testId} data-mode={option.id}>\n <span className={cn('inline-flex items-center', value !== option.id && 'text-muted-foreground')}>{option.icon ?? option.text}</span></ToggleGroupItem>)}\n </ToggleGroup>;\n}\n\n/** The copy helper throws this when the browser allowed no automatic copy at all; the person copies by hand instead. */\nconst needsManualCopy = (error: unknown): error is ManualCopyError => (error as { name?: string } | null)?.name === 'ManualCopyError' && typeof (error as { text?: unknown }).text === 'string';\n\n/** Last resort for Copy and Share: the text, already selected, so Ctrl/Cmd+C or the long-press menu finishes the job. */\nfunction ManualCopy({ testId, link, text, onClose, labels }: { readonly testId: string; readonly link: boolean; readonly text: string; readonly onClose: () => void; readonly labels: ViewerLabels }) {\n const field = useRef<HTMLTextAreaElement>(null);\n return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>\n <DialogContent showCloseButton={false} className=\"boring-viewer\" data-testid={`${testId}-manual-copy`} initialFocus={field}>\n <DialogHeader><DialogTitle>{labels.manualCopy(link)}</DialogTitle></DialogHeader>\n <Textarea ref={field} readOnly rows={Math.min(6, Math.max(2, Math.ceil(text.length / 48)))} value={text} data-testid={`${testId}-manual-copy-text`} onFocus={event => event.currentTarget.select()} />\n <div className=\"flex justify-end\"><Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-manual-copy-close`} onClick={onClose}>{labels.done}</Button></div>\n </DialogContent>\n </Dialog>;\n}\n\n/** Dot colours: default shadcn roles only. */\nconst DOTS: Record<ViewerTone, string> = {\n neutral: 'fill-muted-foreground',\n success: 'fill-primary',\n warning: 'fill-muted-foreground',\n danger: 'fill-destructive',\n};\n\n/** The save status as a coloured dot; the label is the tooltip and the screen-reader text, and is shown only when the person must act (`danger`). */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n const tone = status.tone ?? 'neutral';\n return <span role=\"status\" aria-label={status.label} title={status.label} data-testid={`${testId}-status`} data-tone={tone} data-kind={status.kind}\n className=\"inline-flex h-6 min-w-6 shrink-0 cursor-default items-center justify-center gap-1.5 text-xs\">\n <CircleIcon aria-hidden=\"true\" strokeWidth={0} className={cn('size-2 shrink-0', DOTS[tone])} />\n <span data-testid={`${testId}-status-text`} className={tone === 'danger' ? 'max-w-48 truncate text-destructive' : 'sr-only'}>{status.label}</span>\n </span>;\n}\n\n/** The one top bar of every viewer, always a single row: title and facts, viewer controls, a status dot when something needs attention, Share, full screen, Close and the \"…\" menu. */\nexport function ViewerFrame({ title, subtitle, status, showSavedStatus = false, target, revision, headerStart, controls, mainControl, menu, menuContent, actions, labels: ownLabels, icons: ownIcons, onRefresh, onShare, onCopy, onDownload, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, compactHeader = true, className, children }: ViewerFrameProps) {\n const bar = useRef<HTMLElement>(null);\n const phone = usePhone(), coarse = useMedia('(pointer: coarse)');\n const tightWidth = phone || coarse ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer;\n const [space, setSpace] = useState<ViewerBarSpace>({ tight: false });\n const [busy, setBusy] = useState<'refresh' | 'share' | undefined>();\n const [notice, setNotice] = useState<{ readonly text: string; readonly tone: ViewerTone } | undefined>();\n const [copied, setCopied] = useState(false);\n const [manual, setManual] = useState<{ readonly text: string; readonly link: boolean } | undefined>();\n const host = useViewerWindow();\n const { labels, icons } = useViewerText(ownLabels, ownIcons);\n useLayoutEffect(() => {\n const element = bar.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const measure = () => {\n const width = element.getBoundingClientRect().width;\n const next = { tight: width > 0 && width < tightWidth };\n setSpace(now => now.tight === next.tight ? now : next);\n };\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, [host?.renderHeader, tightWidth]);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\n // A result after unmount updates nothing: React ignores a state update on an unmounted component.\n const guarded = useCallback(async (kind: 'refresh' | 'share', run: () => unknown, failed: string) => {\n setBusy(kind);\n setNotice(undefined);\n try { return await run(); }\n catch (error) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); return undefined; }\n finally { setBusy(undefined); }\n }, []);\n const refresh = () => { if (onRefresh) void guarded('refresh', onRefresh, labels.refreshFailed); };\n const share = () => {\n if (!onShare) return;\n void guarded('share', () => onShare({ title, target, revision }), labels.shareFailed).then(result => {\n if (result === 'copied') setNotice({ text: labels.linkCopied, tone: 'success' });\n else if (result === 'shared') setNotice({ text: labels.shared, tone: 'success' });\n });\n };\n const copy = () => {\n void Promise.resolve().then(onCopy).then(() => { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => setCopied(false), 1500); })\n .catch(error => { if (needsManualCopy(error)) setManual({ text: error.text, link: false }); else setNotice({ text: labels.copyFailed, tone: 'danger' }); });\n };\n const run = (action: (() => unknown) | undefined) => { void Promise.resolve().then(action).catch(() => setNotice({ text: labels.actionFailed, tone: 'danger' })); };\n // The host's actions: the window's (every viewer in the panel), then this frame's. Each runs through `run`, so a failure shows as a notice.\n const hostActions = [...host?.actions ?? [], ...actions ?? []].map(action => ({ ...action, onSelect: () => run(action.onSelect) }));\n\n const reading = status?.kind === 'read-only' || status?.label === labels.readOnly;\n const menuItems: ViewerMenuItem[] = ([\n ...(compactHeader && onShare ? [{ id: 'share', label: labels.share, icon: <icons.share className=\"size-4\" />, onSelect: share }] : []),\n ...(compactHeader && host?.onFullscreenChange ? [{ id: 'fullscreen', label: host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen, icon: <icons.enterFullscreen className=\"size-4\" />, onSelect: () => host.onFullscreenChange!(!host.fullscreen) }] : []),\n ...(onRefresh ? [{ id: 'refresh', separatorBefore: true, label: labels.reload, icon: <RefreshCwIcon className=\"size-4\" />, disabled: busy === 'refresh', onSelect: refresh }] : []),\n ...(onCopy ? [{ id: 'copy', label: labels.copy, icon: copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />, onSelect: copy }] : []),\n ...(onDownload ? [{ id: 'download', label: labels.download, icon: <DownloadIcon className=\"size-4\" />, onSelect: () => run(onDownload) }] : []),\n ...(onOpenInNewTab ? [{ id: 'open', label: labels.openInNewTab, icon: <ExternalLinkIcon className=\"size-4\" />, onSelect: () => run(onOpenInNewTab) }] : []),\n ...(menu ?? []).map((item, index) => ({ ...item, separatorBefore: index === 0 || item.separatorBefore })),\n ...menuActions(hostActions).map((action, index) => ({ ...actionMenuItem(action, testId), separatorBefore: index === 0 })),\n ] as ViewerMenuItem[]).map(entry => ({ ...entry, testId: entry.testId ?? `${testId}-${entry.id}` }));\n\n const stacked = !compactHeader && host?.headerLayout === 'stacked';\n const toolbar = controls && <div data-testid={`${testId}-controls`} className={cn('flex shrink-0 items-center gap-1', compactHeader && 'max-md:hidden', stacked && 'flex-wrap px-3 py-2')}>{controls}</div>;\n const titlebar = <>\n {compactHeader && !headerStart && onClose && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><ArrowLeftIcon data-icon=\"inline-start\" className=\"md:hidden\" aria-hidden=\"true\" /><icons.close data-icon=\"inline-start\" className=\"hidden md:block\" aria-hidden=\"true\" /></Button>}\n {headerStart}\n <div className=\"group/title relative min-w-0 flex-1 basis-0 text-left\">\n <h2 data-testid={titleTestId ?? `${testId}-title`} title={title} className=\"m-0 truncate text-sm leading-5 font-semibold tracking-tight\">{title}</h2>\n {(subtitle || reading) && <p data-testid={`${testId}-subtitle`} className={cn('m-0 flex min-w-0 items-center gap-x-1.5 text-xs leading-4 whitespace-nowrap text-muted-foreground', compactHeader ? 'hidden group-hover/title:flex max-md:hidden' : 'overflow-hidden')}>{subtitle}{subtitle && reading && <span aria-hidden=\"true\">·</span>}{reading && <span data-testid={`${testId}-readonly`}>{labels.readOnly}</span>}</p>}\n </div>\n {!stacked && toolbar}\n {mainControl && <div data-testid={`${testId}-main`} className=\"flex shrink-0 items-center\">{mainControl}</div>}\n {status && !reading && (showSavedStatus || status.kind !== 'saved' && status.label !== labels.saved) && <StatusMark status={status} testId={testId} />}\n <div className=\"flex shrink-0 items-center gap-0.5\">\n {headerActions(hostActions).map(action => <ActionButton key={action.id} action={action} testId={testId} size=\"icon-sm\" />)}\n {!compactHeader && onShare && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.share} title={labels.share} data-testid={`${testId}-share`} disabled={busy === 'share'} onClick={share}><icons.share data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {(menuItems.length > 0 || controls || menuContent) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{menuContent}{menuContent && menuItems.length > 0 && <DropdownMenuSeparator />}{compactHeader && controls && <div className=\"boring-viewer flex flex-wrap items-center gap-2 p-2 md:hidden\">{controls}</div>}</ViewerActions>}\n {!compactHeader && host?.onFullscreenChange && <Toggle size=\"sm\" aria-label={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} title={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} data-testid={`${testId}-fullscreen`} pressed={host.fullscreen === true}\n onPressedChange={pressed => host.onFullscreenChange!(pressed)}>{host.fullscreen ? <icons.exitFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.enterFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>}\n {(!compactHeader || headerStart) && onClose && <Button variant=\"ghost\" size=\"icon-sm\" className={compactHeader ? 'max-md:hidden' : undefined} aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><icons.close data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </div>\n </>;\n const header = <header ref={bar} data-testid={`${testId}-bar`} data-layout={stacked ? 'stacked' : undefined}\n className={cn('relative flex shrink-0', stacked ? 'flex-col' : 'h-13 flex-nowrap items-center gap-x-1.5 px-2 md:px-3')}>\n {stacked ? <><div data-testid={`${testId}-titlebar`} className=\"flex min-h-14 min-w-0 items-center gap-x-1.5 px-3 py-1.5\">{titlebar}</div>{toolbar}</> : titlebar}\n {notice && <Badge role=\"status\" aria-live=\"polite\" data-testid={`${testId}-notice`} data-tone={notice.tone} variant={notice.tone === 'danger' ? 'destructive' : 'secondary'}>{notice.text}</Badge>}\n {manual && <ManualCopy testId={testId} link={manual.link} text={manual.text} labels={labels} onClose={() => setManual(undefined)} />}\n </header>;\n return <ViewerBarContext.Provider value={space}>\n <section data-boring=\"viewer-frame\" data-testid={`${testId}-frame`} data-tight={space.tight || undefined}\n className={cn('boring-viewer isolate flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground', className)}>\n {host?.renderHeader ? host.renderHeader(header) : header}\n <div data-testid={`${testId}-body`} className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{children}</div>\n </section>\n </ViewerBarContext.Provider>;\n}\n",
59
51
  "type": "registry:component",
60
52
  "target": "components/viewer-frame/viewer-frame.tsx"
61
53
  },
@@ -74,7 +66,7 @@
74
66
  ],
75
67
  "css": {
76
68
  "@layer utilities": {
77
- "@media (max-width: 640px), (pointer: coarse)": {
69
+ "@media (width < 768px), (pointer: coarse)": {
78
70
  ".boring-viewer :is([data-slot=\"button\"], [data-slot=\"toggle\"], [data-slot=\"toggle-group-item\"], [class~=\"group/button\"], [class~=\"group/toggle\"])": {
79
71
  "min-height": "2.75rem",
80
72
  "min-width": "2.75rem"
@@ -408,7 +400,7 @@
408
400
  ".boring-viewer .boring-viewer-stage": {
409
401
  "overflow": "auto"
410
402
  },
411
- "@media (max-width: 640px)": {
403
+ "@media (width < 768px)": {
412
404
  ".boring-viewer .boring-markdown-content": {
413
405
  "padding": "1.25rem 1.25rem 3rem"
414
406
  },
@@ -4,15 +4,14 @@
4
4
  "title": "HTML viewer",
5
5
  "description": "The HTML kind: `htmlKind` (match, loader) and `HtmlPane`, the passive HTML preview (sandboxed when the host opts in) and source editor, in the viewer frame. The pane loads the first time an HTML file opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.25",
7
+ "@hachej/boring-ui-kit@^0.4.27",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-frame.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/button.json",
15
- "button"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-frame.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/button.json"
16
15
  ],
17
16
  "files": [
18
17
  {
@@ -8,9 +8,8 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-frame.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/utils.json",
13
- "button"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-frame.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json"
14
13
  ],
15
14
  "files": [
16
15
  {
@@ -27,7 +26,7 @@
27
26
  },
28
27
  {
29
28
  "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",
29
+ "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 // eslint-disable-next-line react-hooks/set-state-in-effect -- another image resets its size and error once, after commit\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 // eslint-disable-next-line react-hooks/refs -- reads the stage's current size while rendering the zoomed image; a DOM read, no state, no render loop\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
30
  "type": "registry:component",
32
31
  "target": "components/viewer-image/image-pane.tsx"
33
32
  }
@@ -4,7 +4,7 @@
4
4
  "title": "Markdown viewer",
5
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
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.25",
7
+ "@hachej/boring-ui-kit@^0.4.27",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0",
10
10
  "marked@^17.0.6",
@@ -22,12 +22,10 @@
22
22
  "@tiptap/extension-floating-menu@^3.31.4"
23
23
  ],
24
24
  "registryDependencies": [
25
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-frame.json",
26
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/markdown-editor.json",
27
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/utils.json",
28
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/button.json",
29
- "button",
30
- "dropdown-menu"
25
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-frame.json",
26
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/markdown-editor.json",
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json",
28
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/button.json"
31
29
  ],
32
30
  "files": [
33
31
  {
@@ -38,7 +36,7 @@
38
36
  },
39
37
  {
40
38
  "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",
39
+ "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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the host's new toolbar choice replaces the local one once, before paint\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
40
  "type": "registry:component",
43
41
  "target": "components/viewer-markdown/markdown-pane.tsx"
44
42
  }
package/r/viewer-pdf.json CHANGED
@@ -4,16 +4,14 @@
4
4
  "title": "PDF viewer",
5
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
6
  "dependencies": [
7
- "@hachej/boring-files@^0.4.25",
7
+ "@hachej/boring-files@^0.4.27",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0",
10
10
  "pdfjs-dist@6.3.289"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-frame.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-image.json",
15
- "badge",
16
- "button"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-frame.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-image.json"
17
15
  ],
18
16
  "files": [
19
17
  {
@@ -24,13 +22,13 @@
24
22
  },
25
23
  {
26
24
  "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",
25
+ "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 // eslint-disable-next-line react-hooks/set-state-in-effect -- another document resets its page count and error once, after commit\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
26
  "type": "registry:component",
29
27
  "target": "components/viewer-pdf/pdf-pane.tsx"
30
28
  },
31
29
  {
32
30
  "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",
31
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, 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, setScroller] = useState<HTMLDivElement | null>(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 useLayoutEffect(() => { 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;\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 }, [scroller]);\n const scrolled = () => {\n const box = scroller;\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={setScroller} 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} 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
32
  "type": "registry:component",
35
33
  "target": "components/viewer-pdf/pdf-pages.tsx"
36
34
  },
package/r/viewers.json CHANGED
@@ -4,12 +4,12 @@
4
4
  "title": "Viewers",
5
5
  "description": "Deprecated aggregate: installs every viewer item (viewer-frame, viewer-markdown, viewer-html, viewer-image, viewer-pdf, viewer-code). Add the items you use instead.",
6
6
  "registryDependencies": [
7
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-frame.json",
8
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-markdown.json",
9
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-html.json",
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-image.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-pdf.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-code.json"
7
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-frame.json",
8
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-markdown.json",
9
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-html.json",
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-image.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-pdf.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-code.json"
13
13
  ],
14
14
  "files": [],
15
15
  "type": "registry:component"