@hachej/boring-ui-kit 0.4.7 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/markdown-editor.d.ts +6 -4
- package/dist/markdown-editor.js +64 -24
- package/dist/markdown-extensions.d.ts +2 -1
- package/dist/markdown-extensions.js +78 -10
- package/dist/markdown-icons.d.ts +20 -0
- package/dist/markdown-icons.js +2 -0
- package/dist/markdown-parser.js +1 -1
- package/dist/markdown-preserved.d.ts +4 -0
- package/dist/markdown-preserved.js +126 -0
- package/dist/markdown-safety.d.ts +2 -4
- package/dist/markdown-safety.js +16 -33
- package/dist/markdown-source.d.ts +17 -0
- package/dist/markdown-source.js +74 -0
- package/dist/viewer-components.d.ts +22 -0
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +9 -9
- package/r/board-viewer.json +5 -5
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +10 -10
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +5 -5
- package/r/file-tree.json +5 -5
- package/r/highlights.json +4 -4
- package/r/html-viewer.json +3 -3
- package/r/maquette-viewer.json +2 -2
- package/r/markdown-editor.json +9 -5
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +7 -7
- package/r/pi-app.json +15 -15
- package/r/pi-chat.json +5 -5
- package/r/pi-workspace.json +4 -4
- package/r/placed-agent.json +27 -0
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +1 -1
- package/r/registry.json +151 -101
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +3 -3
- package/r/task-list-viewer.json +2 -2
- package/r/utils.json +1 -1
- package/r/version-history.json +3 -3
- package/r/viewers.json +32 -11
- package/r/windows.json +1 -1
package/r/viewers.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Viewers",
|
|
5
5
|
"description": "The standard viewer top bar (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu) with Tailwind panes over it: the Markdown editor with a formatting toolbar and prose styling, the passive HTML preview and source editor, an image viewer with zoom and pan, a PDF viewer, and LocalFileViewer for browser-only files. Owned, editable source over the borrowed controllers; no AI SDK.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.8",
|
|
8
|
+
"@hachej/boring-files@^0.4.8",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
@@ -25,17 +25,18 @@
|
|
|
25
25
|
"pdfjs-dist@6.3.289"
|
|
26
26
|
],
|
|
27
27
|
"registryDependencies": [
|
|
28
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
29
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
30
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
28
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/theme.json",
|
|
29
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/utils.json",
|
|
30
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/button.json",
|
|
31
31
|
"button",
|
|
32
32
|
"toggle",
|
|
33
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
33
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/version-history.json",
|
|
34
34
|
"toggle-group",
|
|
35
35
|
"dialog",
|
|
36
36
|
"textarea",
|
|
37
37
|
"badge",
|
|
38
|
-
"dropdown-menu"
|
|
38
|
+
"dropdown-menu",
|
|
39
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/markdown-editor.json"
|
|
39
40
|
],
|
|
40
41
|
"files": [
|
|
41
42
|
{
|
|
@@ -70,7 +71,7 @@
|
|
|
70
71
|
},
|
|
71
72
|
{
|
|
72
73
|
"path": "registry/viewers/markdown-pane.tsx",
|
|
73
|
-
"content": "'use client';\n\nimport { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { CodeXmlIcon, PencilLineIcon, 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 { editorControls } from '../button/editor-controls';\nimport { downloadFile, fileNameFor } from './download';\nimport { copyText } from '../utils/utils';\nimport { ViewerFrame, ViewerToggle } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { defaultViewerLabels, useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerLabels } from './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 /** Pinned by default on desktop. Phones always use the selection bubble. */\n readonly formattingToolbar?: MarkdownEditorProps['formattingToolbar'];\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: mode toggle, Save while there are edits, Share, and Reload, Copy and Download under the \"…\" menu. */\nexport function MarkdownPane({ controller, title: givenTitle, labels: ownLabels, subtitle, target, revision, initialMode = 'rich', onModeChange, placeholder, formattingToolbar = 'pinned', 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 [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 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 controls={<>\n <ViewerToggle label={labels.editorMode} value={mode} onChange={setRequested} options={[\n { id: 'rich', label: labels.richText, text: labels.rich, icon: <PencilLineIcon data-icon=\"inline-start\" aria-hidden=\"true\" />, disabled: !safety.safe, reason: safety.safe ? undefined : labels.richUnavailable(safety.reasons.join(', ')), testId: `${testId}-mode-rich` },\n { id: 'source', label: labels.markdownSource, text: labels.source, icon: <CodeXmlIcon data-icon=\"inline-start\" aria-hidden=\"true\" />, testId: `${testId}-mode-source` }]} />\n {autosave === false && !state.readOnly && state.dirty && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.save} title={labels.save} data-testid={`${testId}-save`} disabled={unavailable || pending || unconfirmed} onClick={save}><SaveIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {controls}\n </>}>\n <MarkdownEditor components={editorControls} controller={controller} title={title} header={false} autosave={autosave} onAutosaveChange={registerAutosave} onSaveResult={report} formattingToolbar={formattingToolbar} mode={mode} onModeChange={setRequested} onRichSafety={setSafety}\n {...(placeholder === undefined ? {} : { placeholder })} {...(resolveImage ? { resolveImage } : {})} {...(onMountedTools ? { onMountedTools } : {})} className=\"boring-markdown-editor\" />\n </ViewerFrame>;\n}\n",
|
|
74
|
+
"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 './download';\nimport { copyText } from '../utils/utils';\nimport { ViewerFrame } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { defaultViewerLabels, useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerLabels } from './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",
|
|
74
75
|
"type": "registry:component",
|
|
75
76
|
"target": "components/viewers/markdown-pane.tsx"
|
|
76
77
|
},
|
|
@@ -106,19 +107,19 @@
|
|
|
106
107
|
},
|
|
107
108
|
{
|
|
108
109
|
"path": "registry/viewers/viewer-frame.tsx",
|
|
109
|
-
"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 { 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 /**\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, 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) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{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",
|
|
110
|
+
"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",
|
|
110
111
|
"type": "registry:component",
|
|
111
112
|
"target": "components/viewers/viewer-frame.tsx"
|
|
112
113
|
},
|
|
113
114
|
{
|
|
114
115
|
"path": "registry/viewers/viewer-window.ts",
|
|
115
|
-
"content": "/*\n * The window a viewer sits in, and the viewers' words and icons as plain data. A host passes a partial `labels` (and `icons`) object to\n * `ViewerWindowProvider` (every viewer below reads it) or to one `ViewerFrame`; what it leaves out keeps the default below.\n */\nimport { createContext, useContext, useMemo } from 'react';\nimport { EllipsisIcon, Maximize2Icon, Minimize2Icon, Share2Icon, XIcon } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport type { MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\nimport { defaultVersionHistoryLabels } from '../version-history/version-history';\n\nexport const defaultViewerLabels = {\n share: 'Share',\n moreActions: 'More actions',\n reload: 'Reload',\n copy: 'Copy',\n download: 'Download',\n openInNewTab: 'Open in new tab',\n enterFullscreen: 'Enter full screen',\n exitFullscreen: 'Exit full screen',\n close: 'Close',\n readOnly: 'Read-only',\n refreshFailed: 'Refresh failed',\n shareFailed: 'Could not share',\n linkCopied: 'Link copied',\n shared: 'Shared',\n copied: 'Copied',\n copyFailed: 'Could not copy',\n actionFailed: 'The action failed',\n /** Shown when the browser allowed no automatic copy: `link` is true for Share, false for Copy. */\n manualCopy: (link: boolean) => `This browser blocked automatic copying. Copy the ${link ? 'link' : 'text'} below.`,\n done: 'Done',\n ...defaultVersionHistoryLabels,\n versionViewOnly: 'Viewing an older version. Restore is not available in this viewer.',\n latest: 'Latest',\n loading: 'Loading…',\n // Save status\n saved: 'Saved',\n unsaved: 'Unsaved',\n saving: 'Saving…',\n saveUnconfirmed: 'Save unconfirmed',\n changedElsewhere: 'Changed elsewhere',\n closed: 'Closed',\n save: 'Save',\n checkSave: 'Check save outcome',\n abandon: 'Abandon and refresh, keeping my draft',\n discardLocal: 'Discard local edits',\n // Modes\n view: 'View',\n preview: 'Preview',\n source: 'Source',\n htmlPreview: 'HTML preview',\n htmlSource: 'HTML source',\n editorMode: 'Editor mode',\n rich: 'Rich',\n richText: 'Rich text',\n markdownSource: 'Markdown source',\n /** Tooltip of the disabled Text mode: what the rich editor could not keep exactly. */\n richUnavailable: (reasons: string) => `
|
|
116
|
+
"content": "/*\n * The window a viewer sits in, and the viewers' words and icons as plain data. A host passes a partial `labels` (and `icons`) object to\n * `ViewerWindowProvider` (every viewer below reads it) or to one `ViewerFrame`; what it leaves out keeps the default below.\n */\nimport { createContext, useContext, useMemo } from 'react';\nimport { EllipsisIcon, Maximize2Icon, Minimize2Icon, Share2Icon, XIcon } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport type { MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\nimport { defaultVersionHistoryLabels } from '../version-history/version-history';\n\nexport const defaultViewerLabels = {\n share: 'Share',\n moreActions: 'More actions',\n reload: 'Reload',\n copy: 'Copy',\n download: 'Download',\n openInNewTab: 'Open in new tab',\n enterFullscreen: 'Enter full screen',\n exitFullscreen: 'Exit full screen',\n close: 'Close',\n readOnly: 'Read-only',\n refreshFailed: 'Refresh failed',\n shareFailed: 'Could not share',\n linkCopied: 'Link copied',\n shared: 'Shared',\n copied: 'Copied',\n copyFailed: 'Could not copy',\n actionFailed: 'The action failed',\n /** Shown when the browser allowed no automatic copy: `link` is true for Share, false for Copy. */\n manualCopy: (link: boolean) => `This browser blocked automatic copying. Copy the ${link ? 'link' : 'text'} below.`,\n done: 'Done',\n ...defaultVersionHistoryLabels,\n versionViewOnly: 'Viewing an older version. Restore is not available in this viewer.',\n latest: 'Latest',\n loading: 'Loading…',\n // Save status\n saved: 'Saved',\n unsaved: 'Unsaved',\n saving: 'Saving…',\n saveUnconfirmed: 'Save unconfirmed',\n changedElsewhere: 'Changed elsewhere',\n closed: 'Closed',\n save: 'Save',\n checkSave: 'Check save outcome',\n abandon: 'Abandon and refresh, keeping my draft',\n discardLocal: 'Discard local edits',\n // Modes\n view: 'View',\n preview: 'Preview',\n source: 'Source',\n htmlPreview: 'HTML preview',\n htmlSource: 'HTML source',\n editorMode: 'Editor mode',\n rich: 'Rich',\n richText: 'Rich text',\n markdownSource: 'Markdown source',\n formatted: 'Formatted',\n markdown: 'Markdown',\n formatting: 'Formatting',\n showOutline: 'Show outline',\n /** Tooltip of the disabled Text mode: what the rich editor could not keep exactly. */\n richUnavailable: (reasons: string) => `Formatted is off: this document contains ${reasons}, which it could not keep exactly`,\n document: 'Document',\n // Images and PDFs\n zoom: 'Zoom',\n zoomIn: 'Zoom in',\n zoomOut: 'Zoom out',\n zoomLevel: (percent: string) => `Zoom ${percent}, fit to view`,\n fitToView: 'Fit to view',\n actualSize: 'Actual size',\n noPreview: 'No preview available. You can still download this file.',\n imageUnreadable: 'This file could not be read as an image.',\n imageUnsupported: (mediaType: string) => `This image type (${mediaType || 'unknown'}) cannot be shown here. You can still download it.`,\n imageStage: (name: string) => `${name} image`,\n pdfUnsupported: 'This browser cannot show PDFs inline.',\n pdfDocument: (name: string) => `${name} pages`,\n /** The page indicator of the drawn pages, for example `3 / 12`. */\n pdfPage: (current: number, count: number) => `${current} / ${count}`,\n pdfHint: (name: string, canOpen: boolean) => `Download ${name} to read it in a PDF app${canOpen ? ', or open it in a new tab' : ''}.`,\n notSaved: (reason: string) => `Not saved: ${reason}`,\n saveFailedReason: 'the save failed',\n changedElsewhereReason: 'changed elsewhere',\n operationFailed: 'The document operation failed. Your source has been retained.',\n};\nexport type ViewerLabels = typeof defaultViewerLabels;\n\nexport const defaultViewerIcons = {\n share: Share2Icon as BlockIcon,\n more: EllipsisIcon as BlockIcon,\n close: XIcon as BlockIcon,\n enterFullscreen: Maximize2Icon as BlockIcon,\n exitFullscreen: Minimize2Icon as BlockIcon,\n};\nexport type ViewerIcons = typeof defaultViewerIcons;\n\n/**\n * The window a viewer sits in, when the host lays viewers out in a panel (pi-app's `AgentWorkspace`, the ambient window). With\n * `onFullscreenChange`, every `ViewerFrame` below shows an \"Enter full screen\" / \"Exit full screen\" button; `actions` adds the host's\n * actions to every viewer bar (`header` ones as buttons, `menu` ones in its \"…\" menu, test ids `<frame testId>-<id>`); `labels` and\n * `icons` reach every viewer below. Without a provider nothing changes.\n */\nexport interface ViewerWindow {\n /** Markdown autosave policy for every pane below. Defaults to true, with a 1200ms idle delay. */\n readonly autosave?: MarkdownEditorProps['autosave'];\n /** Place the existing bar in a host-owned shared header (for example with createPortal). Default: above the viewer body. */\n readonly renderHeader?: ((header: ReactNode) => ReactNode) | undefined;\n /** `stacked` keeps the title and window actions above a separate content toolbar. Default: `inline`, one row. */\n readonly headerLayout?: 'inline' | 'stacked' | undefined;\n readonly fullscreen?: boolean | undefined;\n readonly onFullscreenChange?: ((next: boolean) => void) | undefined;\n readonly actions?: readonly BlockAction[] | undefined;\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n}\nconst ViewerWindowContext = createContext<ViewerWindow | undefined>(undefined);\nexport const ViewerWindowProvider = ViewerWindowContext.Provider;\nexport const useViewerWindow = (): ViewerWindow | undefined => useContext(ViewerWindowContext);\n\n/** The viewers' labels and icons: the defaults, then the window's, then the frame's own. */\nexport function useViewerText(labels?: Partial<ViewerLabels>, icons?: Partial<ViewerIcons>): { readonly labels: ViewerLabels; readonly icons: ViewerIcons } {\n const host = useViewerWindow();\n return useMemo(() => ({ labels: withDefaults(defaultViewerLabels, host?.labels, labels), icons: withDefaults(defaultViewerIcons, host?.icons, icons) }), [host?.labels, host?.icons, labels, icons]);\n}\n",
|
|
116
117
|
"type": "registry:lib",
|
|
117
118
|
"target": "components/viewers/viewer-window.ts"
|
|
118
119
|
},
|
|
119
120
|
{
|
|
120
121
|
"path": "registry/viewers/viewer-labels-fr.ts",
|
|
121
|
-
"content": "/* The viewers' words in French, the `locale=\"fr\"` base of `AgentWorkspace`'s viewers. Typed as `ViewerLabels`, so a key added in English must be added here too. */\nimport type { ViewerLabels } from './viewer-window';\nimport { frenchVersionHistoryLabels } from '../version-history/version-history';\n\nexport const frenchViewerLabels: ViewerLabels = {\n share: 'Partager',\n moreActions: 'Plus d’actions',\n reload: 'Recharger',\n copy: 'Copier',\n download: 'Télécharger',\n openInNewTab: 'Ouvrir dans un nouvel onglet',\n enterFullscreen: 'Plein écran',\n exitFullscreen: 'Quitter le plein écran',\n close: 'Fermer',\n readOnly: 'Lecture seule',\n refreshFailed: 'Échec de l’actualisation',\n shareFailed: 'Partage impossible',\n linkCopied: 'Lien copié',\n shared: 'Partagé',\n copied: 'Copié',\n copyFailed: 'Copie impossible',\n actionFailed: 'L’action a échoué',\n manualCopy: link => `Ce navigateur a bloqué la copie automatique. Copiez ${link ? 'le lien' : 'le texte'} ci-dessous.`,\n done: 'Terminé',\n ...frenchVersionHistoryLabels,\n versionViewOnly: 'Ancienne version en lecture seule. La restauration n’est pas disponible dans cette vue.',\n latest: 'Dernière version',\n loading: 'Chargement…',\n saved: 'Enregistré',\n unsaved: 'Non enregistré',\n saving: 'Enregistrement',\n saveUnconfirmed: 'Enregistrement non confirmé',\n changedElsewhere: 'Modifié ailleurs',\n closed: 'Fermé',\n save: 'Enregistrer',\n checkSave: 'Vérifier l’enregistrement',\n abandon: 'Abandonner et actualiser, en gardant mon brouillon',\n discardLocal: 'Annuler mes modifications',\n view: 'Affichage',\n preview: 'Aperçu',\n source: 'Source',\n htmlPreview: 'Aperçu HTML',\n htmlSource: 'Source HTML',\n editorMode: 'Mode d’édition',\n rich: 'Mise en forme',\n richText: 'Texte mis en forme',\n markdownSource: 'Source Markdown',\n richUnavailable: reasons => `Texte mis en forme indisponible : ce document contient ${reasons}, qu’il ne pourrait pas conserver à l’identique`,\n document: 'Document',\n zoom: 'Zoom',\n zoomIn: 'Agrandir',\n zoomOut: 'Réduire',\n zoomLevel: percent => `Zoom ${percent}, ajuster à la vue`,\n fitToView: 'Ajuster à la vue',\n actualSize: 'Taille réelle',\n noPreview: 'Aucun aperçu disponible. Vous pouvez toujours télécharger ce fichier.',\n imageUnreadable: 'Ce fichier n’a pas pu être lu comme une image.',\n imageUnsupported: mediaType => `Ce type d’image (${mediaType || 'inconnu'}) ne peut pas être affiché ici. Vous pouvez toujours le télécharger.`,\n imageStage: name => `Image ${name}`,\n pdfUnsupported: 'Ce navigateur ne peut pas afficher les PDF directement.',\n pdfDocument: name => `Pages de ${name}`,\n pdfPage: (current, count) => `${current} / ${count}`,\n pdfHint: (name, canOpen) => `Téléchargez ${name} pour le lire dans une application PDF${canOpen ? ', ou ouvrez-le dans un nouvel onglet' : ''}.`,\n notSaved: reason => `Non enregistré : ${reason}`,\n saveFailedReason: 'l’enregistrement a échoué',\n changedElsewhereReason: 'modifié ailleurs',\n operationFailed: 'L’opération sur le document a échoué. Votre source est conservée.',\n};\n",
|
|
122
|
+
"content": "/* The viewers' words in French, the `locale=\"fr\"` base of `AgentWorkspace`'s viewers. Typed as `ViewerLabels`, so a key added in English must be added here too. */\nimport type { ViewerLabels } from './viewer-window';\nimport { frenchVersionHistoryLabels } from '../version-history/version-history';\n\nexport const frenchViewerLabels: ViewerLabels = {\n share: 'Partager',\n moreActions: 'Plus d’actions',\n reload: 'Recharger',\n copy: 'Copier',\n download: 'Télécharger',\n openInNewTab: 'Ouvrir dans un nouvel onglet',\n enterFullscreen: 'Plein écran',\n exitFullscreen: 'Quitter le plein écran',\n close: 'Fermer',\n readOnly: 'Lecture seule',\n refreshFailed: 'Échec de l’actualisation',\n shareFailed: 'Partage impossible',\n linkCopied: 'Lien copié',\n shared: 'Partagé',\n copied: 'Copié',\n copyFailed: 'Copie impossible',\n actionFailed: 'L’action a échoué',\n manualCopy: link => `Ce navigateur a bloqué la copie automatique. Copiez ${link ? 'le lien' : 'le texte'} ci-dessous.`,\n done: 'Terminé',\n ...frenchVersionHistoryLabels,\n versionViewOnly: 'Ancienne version en lecture seule. La restauration n’est pas disponible dans cette vue.',\n latest: 'Dernière version',\n loading: 'Chargement…',\n saved: 'Enregistré',\n unsaved: 'Non enregistré',\n saving: 'Enregistrement',\n saveUnconfirmed: 'Enregistrement non confirmé',\n changedElsewhere: 'Modifié ailleurs',\n closed: 'Fermé',\n save: 'Enregistrer',\n checkSave: 'Vérifier l’enregistrement',\n abandon: 'Abandonner et actualiser, en gardant mon brouillon',\n discardLocal: 'Annuler mes modifications',\n view: 'Affichage',\n preview: 'Aperçu',\n source: 'Source',\n htmlPreview: 'Aperçu HTML',\n htmlSource: 'Source HTML',\n editorMode: 'Mode d’édition',\n rich: 'Mise en forme',\n richText: 'Texte mis en forme',\n markdownSource: 'Source Markdown',\n formatted: 'Mis en forme',\n markdown: 'Markdown',\n formatting: 'Mise en forme',\n showOutline: 'Afficher le plan',\n richUnavailable: reasons => `Texte mis en forme indisponible : ce document contient ${reasons}, qu’il ne pourrait pas conserver à l’identique`,\n document: 'Document',\n zoom: 'Zoom',\n zoomIn: 'Agrandir',\n zoomOut: 'Réduire',\n zoomLevel: percent => `Zoom ${percent}, ajuster à la vue`,\n fitToView: 'Ajuster à la vue',\n actualSize: 'Taille réelle',\n noPreview: 'Aucun aperçu disponible. Vous pouvez toujours télécharger ce fichier.',\n imageUnreadable: 'Ce fichier n’a pas pu être lu comme une image.',\n imageUnsupported: mediaType => `Ce type d’image (${mediaType || 'inconnu'}) ne peut pas être affiché ici. Vous pouvez toujours le télécharger.`,\n imageStage: name => `Image ${name}`,\n pdfUnsupported: 'Ce navigateur ne peut pas afficher les PDF directement.',\n pdfDocument: name => `Pages de ${name}`,\n pdfPage: (current, count) => `${current} / ${count}`,\n pdfHint: (name, canOpen) => `Téléchargez ${name} pour le lire dans une application PDF${canOpen ? ', ou ouvrez-le dans un nouvel onglet' : ''}.`,\n notSaved: reason => `Non enregistré : ${reason}`,\n saveFailedReason: 'l’enregistrement a échoué',\n changedElsewhereReason: 'modifié ailleurs',\n operationFailed: 'L’opération sur le document a échoué. Votre source est conservée.',\n};\n",
|
|
122
123
|
"type": "registry:lib",
|
|
123
124
|
"target": "components/viewers/viewer-labels-fr.ts"
|
|
124
125
|
}
|
|
@@ -399,6 +400,26 @@
|
|
|
399
400
|
"height": "auto",
|
|
400
401
|
"margin": "1rem 0"
|
|
401
402
|
},
|
|
403
|
+
".boring-viewer .boring-markdown-content .boring-markdown-preserved": {
|
|
404
|
+
"margin": "0.75rem 0",
|
|
405
|
+
"color": "var(--muted-foreground)",
|
|
406
|
+
"font-family": "var(--font-mono)",
|
|
407
|
+
"font-size": "var(--text-sm)"
|
|
408
|
+
},
|
|
409
|
+
".boring-viewer .boring-markdown-content .boring-markdown-preserved small": {
|
|
410
|
+
"font-size": "var(--text-xs)"
|
|
411
|
+
},
|
|
412
|
+
".boring-viewer .boring-markdown-content .boring-markdown-preserved pre": {
|
|
413
|
+
"margin": "0.25rem 0",
|
|
414
|
+
"padding": "0.5rem 0.75rem",
|
|
415
|
+
"white-space": "pre-wrap",
|
|
416
|
+
"overflow-wrap": "anywhere"
|
|
417
|
+
},
|
|
418
|
+
".boring-viewer .boring-markdown-content .boring-markdown-preserved-inline": {
|
|
419
|
+
"color": "var(--muted-foreground)",
|
|
420
|
+
"font-family": "var(--font-mono)",
|
|
421
|
+
"font-size": "var(--text-sm)"
|
|
422
|
+
},
|
|
402
423
|
".boring-viewer .boring-markdown-content .boring-markdown-image": {
|
|
403
424
|
"display": "inline-flex",
|
|
404
425
|
"max-width": "100%",
|
package/r/windows.json
CHANGED