@hachej/boring-ui-kit 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +46 -18
  2. package/THIRD_PARTY_NOTICES.md +35 -0
  3. package/dist/canvas-editor-native.d.ts +4 -1
  4. package/dist/canvas-editor-native.js +8 -6
  5. package/dist/canvas-editor.js +1 -1
  6. package/dist/canvas-proposals.d.ts +3 -1
  7. package/dist/canvas-proposals.js +4 -3
  8. package/dist/experience-document-viewer.d.ts +3 -0
  9. package/dist/experience-document-viewer.js +6 -5
  10. package/dist/experience.d.ts +3 -0
  11. package/dist/experience.js +2 -1
  12. package/dist/file-tree-view.d.ts +76 -3
  13. package/dist/file-tree-view.js +172 -39
  14. package/dist/file-tree.d.ts +3 -0
  15. package/dist/file-tree.js +17 -2
  16. package/dist/html-viewer.d.ts +4 -0
  17. package/dist/html-viewer.js +5 -3
  18. package/dist/markdown-autosave.d.ts +13 -0
  19. package/dist/markdown-autosave.js +94 -0
  20. package/dist/markdown-editor.d.ts +16 -0
  21. package/dist/markdown-editor.js +81 -17
  22. package/dist/markdown-extensions.js +9 -1
  23. package/dist/markdown-proposal-diff.d.ts +2 -1
  24. package/dist/markdown-proposal-diff.js +3 -2
  25. package/dist/markdown-safety.js +3 -1
  26. package/dist/native-chat.js +1 -1
  27. package/dist/remote-chat.js +5 -1
  28. package/dist/text-buffer.js +3 -2
  29. package/dist/text-draft-controls.d.ts +3 -1
  30. package/dist/text-draft-controls.js +3 -2
  31. package/dist/viewer-components.d.ts +14 -0
  32. package/dist/viewer-components.js +1 -0
  33. package/package.json +4 -4
  34. package/r/app-shell.json +39 -20
  35. package/r/button.json +19 -11
  36. package/r/dock.json +2 -2
  37. package/r/documents.json +17 -14
  38. package/r/feedback-preview.json +8 -2
  39. package/r/feedback.json +46 -325
  40. package/r/file-tree.json +14 -6
  41. package/r/html-viewer.json +8 -42
  42. package/r/markdown-editor.json +38 -87
  43. package/r/pi-ambient.json +15 -8
  44. package/r/pi-app.json +29 -18
  45. package/r/pi-chat.json +88 -69
  46. package/r/pi-workspace.json +7 -5
  47. package/r/provider-setup.json +9 -4
  48. package/r/registry.json +460 -888
  49. package/r/task-list-viewer.json +11 -21
  50. package/r/theme.json +23 -4
  51. package/r/version-history.json +24 -0
  52. package/r/viewers.json +139 -320
  53. package/styles/file-tree.css +41 -42
  54. package/dist/file-tree-shadcn.d.ts +0 -8
  55. package/dist/file-tree-shadcn.js +0 -14
@@ -1,8 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { Collapsible } from 'radix-ui';
4
- import { ChevronRight, File, Folder } from 'lucide-react';
5
- import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub } from './file-tree-shadcn.js';
3
+ import { ChevronRight, Download, File, FileCode, FileImage, FileSpreadsheet, FileText, Folder, FolderOpen, History, LoaderCircle, MoreHorizontal, Search, Upload, X } from 'lucide-react';
6
4
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
7
5
  import { createFileTreeController } from './file-tree.js';
8
6
  /** Every word of the tree, so a host can translate it. Functions receive what they name. */
@@ -18,9 +16,23 @@ export const defaultFileTreeLabels = {
18
16
  loading: 'Loading files…',
19
17
  retry: 'Retry',
20
18
  noMatches: 'No matching files.',
21
- empty: 'No files here.',
19
+ empty: 'No files yet',
20
+ emptyHint: 'Upload or ask the agent to create one',
21
+ library: 'Library',
22
+ close: 'Close Library',
23
+ open: 'Open',
24
+ download: 'Download',
25
+ downloadingFailed: 'The file could not be downloaded.',
26
+ actionsOf: (path) => `Actions for ${path}`,
27
+ shownItems: (count) => `${count} items shown`,
28
+ folder: 'Folder',
29
+ childCount: (count) => `${count} item${count === 1 ? '' : 's'}`,
30
+ file: 'File',
31
+ image: 'Image',
32
+ document: 'Document',
33
+ dropFiles: 'Drop files to upload',
22
34
  loadMore: 'Load more',
23
- history: 'History',
35
+ history: 'Version history',
24
36
  historyOf: (path) => `History of ${path}`,
25
37
  closeHistory: 'Close history',
26
38
  loadingHistory: 'Loading history…',
@@ -50,7 +62,21 @@ export const frenchFileTreeLabels = {
50
62
  loading: 'Chargement des fichiers…',
51
63
  retry: 'Réessayer',
52
64
  noMatches: 'Aucun fichier ne correspond.',
53
- empty: 'Aucun fichier ici.',
65
+ empty: 'Aucun fichier pour le moment',
66
+ emptyHint: 'Importez un fichier ou demandez à l’agent d’en créer un',
67
+ library: 'Bibliothèque',
68
+ close: 'Fermer la bibliothèque',
69
+ open: 'Ouvrir',
70
+ download: 'Télécharger',
71
+ downloadingFailed: 'Impossible de télécharger le fichier.',
72
+ actionsOf: path => `Actions pour ${path}`,
73
+ shownItems: count => `${count} éléments affichés`,
74
+ folder: 'Dossier',
75
+ childCount: count => `${count} élément${count === 1 ? '' : 's'}`,
76
+ file: 'Fichier',
77
+ image: 'Image',
78
+ document: 'Document',
79
+ dropFiles: 'Déposez les fichiers à importer',
54
80
  loadMore: 'Afficher plus',
55
81
  history: 'Historique',
56
82
  historyOf: path => `Historique de ${path}`,
@@ -70,6 +96,24 @@ export const frenchFileTreeLabels = {
70
96
  uploadFailed: name => `${name} : échec de l’import`,
71
97
  };
72
98
  const LOCALES = { en: defaultFileTreeLabels, fr: frenchFileTreeLabels };
99
+ function fileKind(path, labels) {
100
+ const extension = path.includes('.') ? path.slice(path.lastIndexOf('.') + 1).toLowerCase() : '';
101
+ if (['md', 'markdown'].includes(extension))
102
+ return { icon: FileText, label: 'Markdown' };
103
+ if (extension === 'pdf')
104
+ return { icon: FileText, label: 'PDF' };
105
+ if (['csv', 'xls', 'xlsx', 'tsv'].includes(extension))
106
+ return { icon: FileSpreadsheet, label: extension.toUpperCase() };
107
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(extension))
108
+ return { icon: FileImage, label: labels.image };
109
+ if (['json', 'js', 'mjs', 'ts', 'tsx', 'html', 'css', 'xml', 'yml', 'yaml'].includes(extension))
110
+ return { icon: FileCode, label: extension.toUpperCase() };
111
+ return { icon: extension ? FileText : File, label: extension === 'txt' ? labels.document : extension.toUpperCase() || labels.file };
112
+ }
113
+ function fileSize(bytes, locale) {
114
+ const unit = bytes < 1024 ? 0 : bytes < 1024 ** 2 ? 1 : bytes < 1024 ** 3 ? 2 : 3;
115
+ return `${new Intl.NumberFormat(locale, { maximumFractionDigits: unit ? 1 : 0 }).format(bytes / 1024 ** unit)} ${['B', 'KB', 'MB', 'GB'][unit]}`;
116
+ }
73
117
  /** One borrowed binding powers browsing, search, upload and history. Historical previews never replace an editor's draft. */
74
118
  export function FileTree(props) {
75
119
  return _jsx(BoundFileTree, { ...props }, JSON.stringify([props.revisionProvider.workspace, props.revisionProvider.identity]));
@@ -85,7 +129,9 @@ function BoundFileTree(props) {
85
129
  return owned?.provider === props.revisionProvider ? _jsx(FileTreeView, { ...props, controller: owned.controller }) : _jsx("p", { role: "status", children: props.labels?.loading ?? LOCALES[props.locale ?? 'en'].loading });
86
130
  }
87
131
  /** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
88
- export function FileTreeView({ controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refreshKey, historyKey }) {
132
+ export function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refresh = true, variant = 'list', title, onClose, refreshKey, historyKey }) {
133
+ const { Button, Actions, Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Collapsible: CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, Empty } = components;
134
+ const list = variant === 'list';
89
135
  const labels = { ...LOCALES[locale], ...given };
90
136
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
91
137
  const shownKey = useRef(refreshKey);
@@ -101,9 +147,41 @@ export function FileTreeView({ controller, onOpen, selectedPath, className, uplo
101
147
  void controller.refresh(directory);
102
148
  }, [controller, refreshKey]);
103
149
  const tree = useRef(null);
150
+ const fileInput = useRef(null);
151
+ const dragDepth = useRef(0);
152
+ const [dropping, setDropping] = useState(false);
104
153
  const [focused, setFocused] = useState('');
105
154
  const [uploadError, setUploadError] = useState('');
106
155
  const [historyPath, setHistoryPath] = useState();
156
+ const [localFailures, setLocalFailures] = useState(new Map());
157
+ const uploadFiles = async (files) => {
158
+ for (const file of files) {
159
+ const path = uploadDirectory ? `${uploadDirectory}/${file.name}` : file.name;
160
+ setLocalFailures(previous => { const next = new Map(previous); next.delete(path); return next; });
161
+ try {
162
+ await controller.upload({ path, bytes: new Uint8Array(await file.arrayBuffer()), mediaType: file.type || 'application/octet-stream' });
163
+ }
164
+ catch (error) {
165
+ setLocalFailures(previous => new Map(previous).set(path, { file, reason: error instanceof Error ? error.message : labels.uploadFailed(file.name) }));
166
+ }
167
+ }
168
+ };
169
+ const uploadRows = new Map();
170
+ for (const item of state.uploads) {
171
+ if (item.state.kind === 'pending')
172
+ uploadRows.set(item.path, { kind: 'pending', size: item.size });
173
+ else if (item.state.result.kind === 'committed')
174
+ uploadRows.delete(item.path);
175
+ else
176
+ uploadRows.set(item.path, { kind: 'failed', size: item.size,
177
+ reason: item.state.result.kind === 'partial' ? labels.unconfirmed : item.state.result.reason,
178
+ retryLabel: item.state.result.kind === 'unknown' ? labels.checkUpload : labels.retry,
179
+ retry: () => { void controller.retry(item.operationId).catch(error => setUploadError(error instanceof Error ? error.message : labels.uploadStatusUnavailable)); } });
180
+ }
181
+ for (const [path, failure] of localFailures)
182
+ uploadRows.set(path, { kind: 'failed', size: failure.file.size, reason: failure.reason,
183
+ retryLabel: labels.retry, retry: () => { void uploadFiles([failure.file]); } });
184
+ const uploadButton = _jsxs(Button, { type: "button", variant: "secondary", "data-file-upload": true, onClick: () => fileInput.current?.click(), children: [_jsx(Upload, { "aria-hidden": "true" }), labels.upload] });
107
185
  const rows = [];
108
186
  function flatten(directory, level) {
109
187
  for (const entry of state.directories.get(directory)?.entries ?? []) {
@@ -117,6 +195,12 @@ export function FileTreeView({ controller, onOpen, selectedPath, className, uplo
117
195
  rows.push({ entry, level: 1 });
118
196
  else
119
197
  flatten('', 1);
198
+ const visiblePaths = new Set(rows.map(row => row.entry.path));
199
+ const temporaryEntries = [...uploadRows].filter(([path]) => !visiblePaths.has(path) && path.toLowerCase().includes(state.query.toLowerCase()))
200
+ .map(([path, status]) => ({ path, kind: 'file', size: status.size }));
201
+ const temporaryPaths = new Set(temporaryEntries.map(entry => entry.path));
202
+ for (const entry of temporaryEntries)
203
+ rows.push({ entry, level: 1 });
120
204
  const focusPath = rows.some(row => row.entry.path === focused) ? focused : rows[0]?.entry.path;
121
205
  const focus = (path) => {
122
206
  if (path === undefined)
@@ -164,7 +248,7 @@ export function FileTreeView({ controller, onOpen, selectedPath, className, uplo
164
248
  case ' ':
165
249
  if (entry.kind === 'directory')
166
250
  void controller.toggle(entry.path);
167
- else
251
+ else if (!temporaryPaths.has(entry.path))
168
252
  onOpen?.(entry.path);
169
253
  break;
170
254
  default: return;
@@ -175,44 +259,93 @@ export function FileTreeView({ controller, onOpen, selectedPath, className, uplo
175
259
  if (!listing || listing.kind === 'loading')
176
260
  return _jsx("li", { role: "none", children: _jsx("span", { role: "status", children: labels.loading }) });
177
261
  if (listing.kind === 'error')
178
- return _jsxs("li", { role: "none", children: [_jsx("span", { role: "alert", children: listing.reason }), " ", _jsx("button", { type: "button", onClick: () => { void (search ? controller.search(state.query) : controller.refresh(directory)); }, children: labels.retry })] });
179
- return _jsxs(_Fragment, { children: [listing.entries.length === 0 && _jsx("li", { role: "none", children: _jsx("span", { children: search ? labels.noMatches : labels.empty }) }), listing.cursor !== undefined && _jsx("li", { role: "none", children: _jsx("button", { type: "button", onClick: () => { void (search ? controller.moreSearch() : controller.more(directory)); }, children: labels.loadMore }) })] });
262
+ return _jsxs("li", { role: "none", children: [_jsx("span", { role: "alert", children: listing.reason }), " ", _jsx(Button, { type: "button", variant: "ghost", onClick: () => { void (search ? controller.search(state.query) : controller.refresh(directory)); }, children: labels.retry })] });
263
+ return _jsxs(_Fragment, { children: [listing.entries.length === 0 && (directory || temporaryEntries.length === 0) && _jsx("li", { role: "none", className: list && !directory ? undefined : 'file-tree-status', children: list && !directory
264
+ ? _jsx(Empty, { className: "file-tree-empty", icon: _jsx(FolderOpen, { "aria-hidden": "true" }), title: search ? labels.noMatches : labels.empty, description: search ? undefined : labels.emptyHint, children: !search && upload && uploadButton })
265
+ : _jsx("span", { children: search ? labels.noMatches : labels.empty }) }), listing.cursor !== undefined && _jsx("li", { role: "none", children: _jsx(Button, { type: "button", variant: "ghost", onClick: () => { void (search ? controller.moreSearch() : controller.more(directory)); }, children: labels.loadMore }) })] });
180
266
  };
181
267
  const renderEntries = (directory, level) => _jsxs(_Fragment, { children: [(state.directories.get(directory)?.entries ?? []).map(entry => renderEntry(entry, level)), listingStatus(state.directories.get(directory), directory)] });
182
268
  const renderEntry = (entry, level) => {
183
269
  const folder = entry.kind === 'directory';
184
- // A search result shows its name, with its folder beside it in muted text.
270
+ const temporary = temporaryPaths.has(entry.path);
271
+ const status = uploadRows.get(entry.path);
185
272
  const name = entry.path.split('/').at(-1);
186
- const parent = state.query && entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : '';
187
- const button = _jsxs(SidebarMenuButton, { tabIndex: -1, title: entry.path, isActive: selectedPath === entry.path, className: "min-w-0 flex-1 data-[active=true]:bg-transparent", onClick: () => { focus(entry.path); if (!folder)
188
- onOpen?.(entry.path); }, children: [folder ? _jsxs(_Fragment, { children: [_jsx(ChevronRight, { className: "transition-transform", "aria-hidden": "true" }), _jsx(Folder, { "aria-hidden": "true" })] }) : _jsx(File, { "aria-hidden": "true" }), _jsx("span", { className: "min-w-0 truncate", children: name }), parent && _jsx("span", { className: "min-w-0 shrink truncate text-xs text-muted-foreground", children: parent })] });
189
- const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "aria-level": level, "aria-selected": selectedPath === entry.path, "aria-expanded": folder ? state.expanded.has(entry.path) : undefined, tabIndex: focusPath === entry.path ? 0 : -1, onFocus: event => { if (event.target === event.currentTarget)
273
+ const parent = (state.query || temporary) && entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : '';
274
+ const children = folder ? state.directories.get(entry.path) : undefined;
275
+ const childCount = children?.kind === 'ready' && children.cursor === undefined ? labels.childCount(children.entries.length) : undefined;
276
+ const kind = folder ? { icon: Folder, label: labels.folder } : fileKind(entry.path, labels);
277
+ const Icon = kind.icon;
278
+ const button = _jsxs(Button, { type: "button", variant: selectedPath === entry.path ? 'secondary' : 'ghost', "data-active": selectedPath === entry.path, tabIndex: -1, title: entry.path, disabled: temporary, className: "file-tree-row-button", onClick: () => { focus(entry.path); if (!folder)
279
+ onOpen?.(entry.path); }, children: [folder ? _jsx(ChevronRight, { "aria-hidden": "true" }) : list && _jsx("span", { className: "file-tree-chevron-space", "aria-hidden": "true" }), _jsx(Icon, { "aria-hidden": "true" }), _jsxs("span", { className: "file-tree-copy", children: [_jsx("span", { className: "file-tree-name", children: name }), list ? folder ? childCount !== undefined && _jsx("span", { "data-file-meta": true, children: childCount }) : _jsxs("span", { "data-file-meta": true, children: [kind.label, " \u00B7 ", fileSize(entry.size, locale), parent && ` · ${parent}`] }) : parent && _jsx("span", { className: "file-tree-parent", "data-file-meta": true, children: parent })] })] });
280
+ const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "data-upload-state": status?.kind, "aria-busy": status?.kind === 'pending' || undefined, "aria-level": level, "aria-selected": selectedPath === entry.path, "aria-expanded": folder ? state.expanded.has(entry.path) : undefined, tabIndex: focusPath === entry.path ? 0 : -1, onFocus: event => { if (event.target === event.currentTarget)
190
281
  setFocused(entry.path); }, onKeyDown: event => { if (event.target === event.currentTarget)
191
- keyboard(event, entry); }, className: "group/collapsible rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring [&[data-state=open]>div>button>svg:first-child]:rotate-90", children: [_jsxs("div", { className: "flex items-center gap-1", children: [folder ? _jsx(Collapsible.Trigger, { asChild: true, children: button }) : button, !folder && history && _jsx("button", { type: "button", "aria-label": labels.historyOf(entry.path), className: "px-2 py-1 text-xs", onClick: () => setHistoryPath(entry.path), children: labels.history })] }), folder && !state.query && _jsx(Collapsible.Content, { asChild: true, children: _jsx(SidebarMenuSub, { role: "group", children: renderEntries(entry.path, level + 1) }) })] }, entry.path);
192
- return folder ? _jsx(Collapsible.Root, { asChild: true, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); }, children: item }, entry.path) : item;
282
+ keyboard(event, entry); }, className: "file-tree-item", children: [_jsxs("div", { className: "flex items-center gap-1", children: [folder ? _jsx(CollapsibleTrigger, { render: button }) : button, status?.kind === 'pending' && _jsx(LoaderCircle, { "data-upload-spinner": true, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), !folder && !temporary && !status && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen ? () => onOpen(entry.path) : undefined, onHistory: history ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
283
+ return folder ? _jsx(CollapsibleRoot, { render: item, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); } }, entry.path) : item;
284
+ };
285
+ const visibleListing = state.query ? state.search : state.directories.get('');
286
+ const paginated = visibleListing?.kind === 'ready' && visibleListing.cursor !== undefined;
287
+ return _jsxs("section", { "data-boring": "file-tree", "data-variant": variant, "data-dropping": dropping || undefined, className: className, "aria-label": labels.region, onDragEnter: event => { if (upload && event.dataTransfer.types.includes('Files')) {
288
+ event.preventDefault();
289
+ dragDepth.current++;
290
+ setDropping(true);
291
+ } }, onDragOver: event => { if (upload && event.dataTransfer.types.includes('Files')) {
292
+ event.preventDefault();
293
+ event.dataTransfer.dropEffect = 'copy';
294
+ } }, onDragLeave: event => { if (upload && event.dataTransfer.types.includes('Files')) {
295
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
296
+ if (!dragDepth.current)
297
+ setDropping(false);
298
+ } }, onDrop: event => { if (upload && event.dataTransfer.types.includes('Files')) {
299
+ event.preventDefault();
300
+ dragDepth.current = 0;
301
+ setDropping(false);
302
+ void uploadFiles(Array.from(event.dataTransfer.files));
303
+ } }, children: [list && _jsxs("header", { className: "file-tree-header", children: [_jsx("h2", { children: title ?? labels.library }), _jsxs("span", { className: "file-tree-count", "aria-label": labels.shownItems(rows.length), children: [rows.length, paginated ? '+' : ''] }), upload && uploadButton, onClose && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-file-close": true, "aria-label": labels.close, onClick: onClose, children: _jsx(X, { "aria-hidden": "true" }) })] }), _jsxs("div", { className: "file-tree-toolbar", children: [_jsxs("div", { className: "file-tree-search", children: [list && _jsx(Search, { "aria-hidden": "true" }), _jsx(Input, { type: "search", "aria-label": labels.search, placeholder: labels.searchPlaceholder, value: state.query, onChange: event => { void controller.search(event.currentTarget.value); } })] }), refresh && _jsx(Button, { type: "button", variant: "ghost", "aria-label": labels.refresh, onClick: () => { void (state.query ? controller.search(state.query) : controller.refresh()); }, children: labels.refresh }), !list && upload && uploadButton] }), upload && _jsx(Input, { ref: fileInput, "aria-label": labels.uploadFiles, type: "file", multiple: true, onChange: event => {
304
+ const files = Array.from(event.currentTarget.files ?? []);
305
+ event.currentTarget.value = '';
306
+ void uploadFiles(files);
307
+ } }), dropping && _jsx("div", { className: "file-tree-drop", role: "status", children: _jsx(Empty, { icon: _jsx(Upload, { "aria-hidden": "true" }), title: labels.dropFiles }) }), uploadError && _jsx("p", { role: "alert", children: uploadError }), _jsxs(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "file-tree-menu", children: [state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1), temporaryEntries.map(entry => renderEntry(entry, 1))] }), _jsxs("ul", { "aria-label": labels.uploads, "aria-live": "polite", "aria-relevant": "additions text", className: "file-tree-announcements", children: [state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.kind === 'partial' ? labels.unconfirmed : upload.state.result.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path))] }), history && historyPath !== undefined && _jsx(FileHistory, { components: components, locale: locale, controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
308
+ }
309
+ function FileActions({ Actions, controller, entry, labels, onOpen, onHistory, onError }) {
310
+ const lifetime = useRef(undefined);
311
+ useEffect(() => () => lifetime.current?.abort(), []);
312
+ const download = async () => {
313
+ onError('');
314
+ lifetime.current?.abort();
315
+ const abort = new AbortController();
316
+ lifetime.current = abort;
317
+ try {
318
+ const result = await controller.revisionProvider.read({ target: controller.revisionProvider.locate(entry.path), revision: { kind: 'latest' } }, abort.signal);
319
+ if (abort.signal.aborted)
320
+ return;
321
+ if (result.kind !== 'available') {
322
+ onError(labels.downloadingFailed);
323
+ return;
324
+ }
325
+ const url = URL.createObjectURL(new Blob([new Uint8Array(result.snapshot.bytes)], { type: result.snapshot.mediaType }));
326
+ const link = document.createElement('a');
327
+ link.href = url;
328
+ link.download = entry.path.split('/').at(-1) ?? entry.path;
329
+ document.body.append(link);
330
+ link.click();
331
+ link.remove();
332
+ // Give the browser time to consume the URL before releasing its bytes.
333
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
334
+ }
335
+ catch {
336
+ if (!abort.signal.aborted)
337
+ onError(labels.downloadingFailed);
338
+ }
193
339
  };
194
- return _jsxs("section", { "data-boring": "file-tree", className: className, "aria-label": labels.region, children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 p-2", children: [_jsx("input", { type: "search", "aria-label": labels.search, placeholder: labels.searchPlaceholder, className: "min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-sm", value: state.query, onChange: event => { void controller.search(event.currentTarget.value); } }), _jsx("button", { type: "button", onClick: () => { void (state.query ? controller.search(state.query) : controller.refresh()); }, children: labels.refresh }), upload && _jsxs("label", { className: "cursor-pointer text-sm", children: [labels.upload, _jsx("input", { "aria-label": labels.uploadFiles, type: "file", multiple: true, className: "sr-only", onChange: event => {
195
- const files = Array.from(event.currentTarget.files ?? []);
196
- event.currentTarget.value = '';
197
- setUploadError('');
198
- void (async () => {
199
- // One refused or unreadable file does not stop the others; every refusal is reported.
200
- const errors = [];
201
- for (const file of files) {
202
- const path = uploadDirectory ? `${uploadDirectory}/${file.name}` : file.name;
203
- try {
204
- await controller.upload({ path, bytes: new Uint8Array(await file.arrayBuffer()), mediaType: file.type || 'application/octet-stream' });
205
- }
206
- catch (error) {
207
- errors.push(error instanceof Error ? error.message : labels.uploadFailed(file.name));
208
- }
209
- }
210
- if (errors.length)
211
- setUploadError(errors.join(' '));
212
- })();
213
- } })] })] }), uploadError && _jsx("p", { role: "alert", children: uploadError }), _jsx(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "m-0 list-none p-2", children: state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1) }), state.uploads.length > 0 && _jsx("ul", { "aria-label": labels.uploads, className: "list-none p-2 text-sm", children: state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [_jsxs("span", { children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.kind === 'partial' ? labels.unconfirmed : upload.state.result.reason] }), upload.state.kind === 'settled' && upload.state.result.kind === 'unknown' && _jsx("button", { type: "button", onClick: () => { void controller.reconcile(upload.operationId).catch(error => setUploadError(error instanceof Error ? error.message : labels.uploadStatusUnavailable)); }, children: labels.checkUpload })] }, upload.operationId)) }), history && historyPath !== undefined && _jsx(FileHistory, { controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
340
+ const items = [];
341
+ if (onOpen)
342
+ items.push({ id: 'open', label: labels.open, icon: _jsx(FolderOpen, { "aria-hidden": "true" }), onSelect: onOpen });
343
+ if (onHistory)
344
+ items.push({ id: 'history', label: labels.history, icon: _jsx(History, { "aria-hidden": "true" }), onSelect: onHistory });
345
+ items.push({ id: 'download', label: labels.download, icon: _jsx(Download, { "aria-hidden": "true" }), onSelect: () => { void download(); } });
346
+ return _jsx(Actions, { label: labels.actionsOf(entry.path), icon: _jsx(MoreHorizontal, { "aria-hidden": "true" }), items: items });
214
347
  }
215
- function FileHistory({ controller, path, labels, reload, onClose }) {
348
+ function FileHistory({ components: { Button, Versions, Separator }, locale, controller, path, labels, reload, onClose }) {
216
349
  const [versions, setVersions] = useState();
217
350
  const [failure, setFailure] = useState('');
218
351
  const [selected, setSelected] = useState();
@@ -252,5 +385,5 @@ function FileHistory({ controller, path, labels, reload, onClose }) {
252
385
  }
253
386
  catch { /* Binary history remains a byte-qualified read-only preview. */ }
254
387
  }
255
- return _jsxs("section", { "aria-label": labels.historyOf(path), className: "border-t border-border p-2 text-sm", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("h3", { className: "font-medium", children: labels.historyOf(path) }), _jsx("button", { type: "button", onClick: onClose, children: labels.closeHistory })] }), failure && _jsx("p", { role: "alert", children: failure }), !versions && !failure && _jsx("p", { role: "status", children: labels.loadingHistory }), versions?.length === 0 && _jsx("p", { children: labels.noHistory }), _jsx("ul", { className: "m-0 list-none p-0", children: versions?.map(version => _jsx("li", { children: _jsxs("button", { type: "button", "aria-pressed": selected === version.revision, onClick: () => setSelected(version.revision), children: [version.savedAt ? new Date(version.savedAt).toLocaleString() : labels.earlierVersion, " \u00B7 ", version.revision.slice(0, 8)] }) }, version.revision)) }), preview && _jsxs("div", { "data-testid": "file-history-preview", "data-revision": preview.ref.revision, children: [_jsx("p", { children: labels.readOnlyRevision(preview.bytes.byteLength) }), text === undefined ? _jsx("p", { children: labels.binaryFile(preview.mediaType) }) : _jsx("pre", { className: "max-h-80 overflow-auto whitespace-pre-wrap", children: text })] })] });
388
+ return _jsxs(_Fragment, { children: [_jsx(Separator, {}), _jsxs("section", { "aria-label": labels.historyOf(path), className: "file-tree-history", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("h3", { className: "font-medium", children: labels.historyOf(path) }), _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, children: labels.closeHistory })] }), failure && _jsx("p", { role: "alert", children: failure }), !versions && !failure && _jsx("p", { role: "status", children: labels.loadingHistory }), versions?.length === 0 && _jsx("p", { children: labels.noHistory }), versions && _jsx(Versions, { versions: versions, current: selected ?? versions[0]?.revision ?? '', onSelect: setSelected, locale: locale, earlierVersion: labels.earlierVersion }), preview && _jsxs("div", { "data-testid": "file-history-preview", "data-revision": preview.ref.revision, children: [_jsx("p", { children: labels.readOnlyRevision(preview.bytes.byteLength) }), text === undefined ? _jsx("p", { children: labels.binaryFile(preview.mediaType) }) : _jsx("pre", { className: "max-h-80 overflow-auto whitespace-pre-wrap", children: text })] })] })] });
256
389
  }
@@ -15,6 +15,7 @@ export type FileListing = {
15
15
  export interface FileUpload {
16
16
  readonly operationId: string;
17
17
  readonly path: string;
18
+ readonly size: number;
18
19
  readonly state: {
19
20
  readonly kind: 'pending';
20
21
  } | {
@@ -47,6 +48,8 @@ export interface FileTreeController {
47
48
  readonly signal?: AbortSignal;
48
49
  }) => Promise<FileUpload>;
49
50
  readonly reconcile: (operationId: string) => Promise<FileUpload>;
51
+ /** Retry a refused upload with its captured bytes. Unconfirmed uploads only reconcile the original operation. */
52
+ readonly retry: (operationId: string) => Promise<FileUpload>;
50
53
  readonly history: (path: string, signal?: AbortSignal) => Promise<readonly SavedRevision[]>;
51
54
  readonly dispose: () => void;
52
55
  }
package/dist/file-tree.js CHANGED
@@ -61,7 +61,7 @@ export function createFileTreeController({ revisionProvider }) {
61
61
  const attempt = attempts.get(operationId);
62
62
  if (!attempt)
63
63
  throw new Error('Unknown upload operation');
64
- const current = { operationId, path: attempt.current.path, state: { kind: 'settled', result } };
64
+ const current = { ...attempt.current, state: { kind: 'settled', result } };
65
65
  attempt.current = current;
66
66
  update({ ...state, uploads: state.uploads.map(item => item.operationId === operationId ? current : item) });
67
67
  if (result.kind === 'committed' && state.lifecycle === 'active') {
@@ -150,7 +150,7 @@ export function createFileTreeController({ revisionProvider }) {
150
150
  const request = publicationSnapshot({ operationId, atomicity: 'all-or-nothing', changes: [input.replace
151
151
  ? { kind: 'replace', target: input.replace, bytes, mediaType: input.mediaType ?? 'application/octet-stream' }
152
152
  : { kind: 'create', target, expected: { kind: 'absent' }, bytes, mediaType: input.mediaType ?? 'application/octet-stream' }] });
153
- const current = { operationId, path: input.path, state: { kind: 'pending' } };
153
+ const current = { operationId, path: input.path, size: bytes.byteLength, state: { kind: 'pending' } };
154
154
  const digest = await publicationDigest(request);
155
155
  active();
156
156
  const concurrent = unresolved();
@@ -195,6 +195,21 @@ export function createFileTreeController({ revisionProvider }) {
195
195
  };
196
196
  return attempt.reconciling = run();
197
197
  },
198
+ retry: operationId => {
199
+ active();
200
+ const attempt = attempts.get(operationId);
201
+ if (!attempt)
202
+ return Promise.reject(new Error('Unknown upload operation'));
203
+ if (attempt.current.state.kind === 'pending' || attempt.current.state.result.kind === 'committed')
204
+ return Promise.resolve(attempt.current);
205
+ if (attempt.current.state.result.kind === 'unknown')
206
+ return controller.reconcile(operationId);
207
+ const change = attempt.request.changes[0];
208
+ if (change.kind === 'delete')
209
+ return Promise.reject(new Error('An upload cannot delete a file'));
210
+ return controller.upload({ path: attempt.current.path, bytes: attempt.bytes, mediaType: change.mediaType,
211
+ ...(change.kind === 'replace' ? { replace: change.target } : {}) });
212
+ },
198
213
  history: (path, signal) => { active(); return revisionProvider.history(path, signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal); },
199
214
  dispose: () => { if (state.lifecycle === 'disposed')
200
215
  return; lifetime.abort(); for (const load of loads.values())
@@ -1,5 +1,9 @@
1
+ import type { ViewerComponents } from './viewer-components.js';
2
+ export type { ViewerComponents } from './viewer-components.js';
1
3
  import type { HtmlController } from './html.js';
2
4
  export interface HtmlViewerProps {
5
+ /** Installed recipes supply the host's shadcn controls. Omit only for unstyled native HTML rendering. */
6
+ readonly components?: ViewerComponents;
3
7
  readonly controller: HtmlController;
4
8
  readonly title?: string;
5
9
  readonly className?: string;
@@ -33,7 +33,9 @@ function HtmlPreview({ text }) {
33
33
  : status === 'failed' ? 'Preview unavailable. Source viewing and saving remain available.'
34
34
  : 'Preview unavailable until this viewer is mounted in a browser.' }), _jsx("div", { ref: container })] });
35
35
  }
36
- function MountedHtmlViewer({ controller, title = 'HTML document', className, header = true, mode: controlledMode, onModeChange }) {
36
+ function MountedHtmlViewer({ components, controller, title = 'HTML document', className, header = true, mode: controlledMode, onModeChange }) {
37
+ const Control = components?.button ?? 'button';
38
+ const Source = components?.textarea ?? 'textarea';
37
39
  const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
38
40
  const [ownMode, setOwnMode] = useState('preview');
39
41
  const mode = controlledMode ?? ownMode;
@@ -56,8 +58,8 @@ function MountedHtmlViewer({ controller, title = 'HTML document', className, hea
56
58
  const status = disposed ? 'Viewer disposed' : snapshot.readOnly ? 'Read only' : pending ? 'Saving selected source' : unknown ? 'Save unconfirmed. Reconcile before saving again.' : snapshot.save.kind === 'settled' && snapshot.save.result.kind !== 'saved'
57
59
  ? snapshot.save.result.kind === 'partial' ? 'Save unconfirmed' : snapshot.save.result.reason
58
60
  : snapshot.dirty ? 'Unsaved changes' : 'Saved';
59
- return _jsxs("section", { "data-boring": "html-viewer", className: ['boring-html-viewer', className].filter(Boolean).join(' '), "aria-label": title, children: [header && _jsxs("header", { children: [_jsx("strong", { children: title }), _jsx("span", { role: "status", children: status })] }), header && _jsxs("div", { role: "toolbar", "aria-label": "HTML document controls", children: [_jsx("button", { type: "button", "aria-pressed": mode === 'preview', onClick: () => setMode('preview'), children: "HTML preview" }), _jsx("button", { type: "button", "aria-pressed": mode === 'source', onClick: () => setMode('source'), children: "HTML source" }), _jsx("button", { type: "button", disabled: disposed || snapshot.readOnly || !snapshot.dirty || pending || unknown, onClick: () => { const selection = controller.actions.selection(); perform(() => controller.flush(selection)); }, children: "Save" }), _jsx("button", { type: "button", disabled: disposed || pending || unknown, onClick: () => perform(controller.actions.refresh), children: "Refresh" }), _jsx("button", { type: "button", disabled: disposed || !unknown, onClick: () => perform(controller.actions.reconcile), children: "Reconcile save" }), _jsx("button", { type: "button", disabled: disposed || !unknown, onClick: () => perform(controller.actions.abandon), children: "Abandon and refresh, keeping my draft" }), _jsx("button", { type: "button", disabled: disposed || pending || unknown || snapshot.remote === null, onClick: () => perform(controller.actions.discardToRemote), children: "Discard local edits" })] }), _jsx(TextDraftControls, { recovery: snapshot.recovery, actions: controller.actions, readOnly: snapshot.readOnly, blocked: snapshot.lifecycle !== 'active' || snapshot.save.kind === 'pending' || (snapshot.save.kind === 'settled' && snapshot.save.result.kind === 'unknown') }), error && _jsx("p", { role: "alert", children: error }), mode === 'source'
60
- ? _jsx("textarea", { className: "boring-html-viewer__source", "aria-label": "HTML source", readOnly: snapshot.readOnly || disposed, value: snapshot.text, onChange: event => { if (!snapshot.readOnly && !disposed)
61
+ return _jsxs("section", { "data-boring": "html-viewer", className: ['boring-html-viewer', className].filter(Boolean).join(' '), "aria-label": title, children: [header && _jsxs("header", { children: [_jsx("strong", { children: title }), _jsx("span", { role: "status", children: status })] }), header && _jsxs("div", { role: "toolbar", "aria-label": "HTML document controls", children: [_jsx(Control, { type: "button", "aria-pressed": mode === 'preview', onClick: () => setMode('preview'), children: "HTML preview" }), _jsx(Control, { type: "button", "aria-pressed": mode === 'source', onClick: () => setMode('source'), children: "HTML source" }), _jsx(Control, { type: "button", disabled: disposed || snapshot.readOnly || !snapshot.dirty || pending || unknown, onClick: () => { const selection = controller.actions.selection(); perform(() => controller.flush(selection)); }, children: "Save" }), _jsx(Control, { type: "button", disabled: disposed || pending || unknown, onClick: () => perform(controller.actions.refresh), children: "Refresh" }), _jsx(Control, { type: "button", disabled: disposed || !unknown, onClick: () => perform(controller.actions.reconcile), children: "Reconcile save" }), _jsx(Control, { type: "button", disabled: disposed || !unknown, onClick: () => perform(controller.actions.abandon), children: "Abandon and refresh, keeping my draft" }), _jsx(Control, { type: "button", disabled: disposed || pending || unknown || snapshot.remote === null, onClick: () => perform(controller.actions.discardToRemote), children: "Discard local edits" })] }), _jsx(TextDraftControls, { components: components, recovery: snapshot.recovery, actions: controller.actions, readOnly: snapshot.readOnly, blocked: snapshot.lifecycle !== 'active' || snapshot.save.kind === 'pending' || (snapshot.save.kind === 'settled' && snapshot.save.result.kind === 'unknown') }), error && _jsx("p", { role: "alert", children: error }), mode === 'source'
62
+ ? _jsx(Source, { className: "boring-html-viewer__source", "aria-label": "HTML source", readOnly: snapshot.readOnly || disposed, value: snapshot.text, onChange: event => { if (!snapshot.readOnly && !disposed)
61
63
  controller.actions.edit(event.currentTarget.value); } })
62
64
  : _jsx(HtmlPreview, { text: snapshot.text })] });
63
65
  }
@@ -0,0 +1,13 @@
1
+ import type { MarkdownController } from './markdown.js';
2
+ import type { SaveResult } from './resources.js';
3
+ export type MarkdownAutosave = boolean | {
4
+ readonly delayMs?: number;
5
+ };
6
+ export type MarkdownFlush = () => Promise<boolean>;
7
+ /** Browser editing policy only. All writes still use the controller's exact conditional flush. */
8
+ export declare function useMarkdownAutosave(controller: MarkdownController, autosave: MarkdownAutosave, report: (result: SaveResult) => void): {
9
+ enabled: boolean;
10
+ immediate: () => Promise<boolean>;
11
+ manual: () => Promise<boolean>;
12
+ };
13
+ //# sourceMappingURL=markdown-autosave.d.ts.map
@@ -0,0 +1,94 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ /** Browser editing policy only. All writes still use the controller's exact conditional flush. */
3
+ export function useMarkdownAutosave(controller, autosave, report) {
4
+ const enabled = autosave !== false;
5
+ const delayMs = typeof autosave === 'object' ? autosave.delayMs ?? 1200 : 1200;
6
+ const enabledRef = useRef(enabled);
7
+ enabledRef.current = enabled;
8
+ const reporting = useRef(report);
9
+ reporting.current = report;
10
+ const running = useRef(null);
11
+ const paused = useRef(null);
12
+ const timer = useRef(undefined);
13
+ const cancel = useCallback(() => { clearTimeout(timer.current); timer.current = undefined; }, []);
14
+ const flush = useCallback((manual = false) => {
15
+ cancel();
16
+ if (running.current)
17
+ return running.current;
18
+ const drain = async () => {
19
+ for (;;) {
20
+ const state = controller.getSnapshot();
21
+ if (state.lifecycle !== 'active' || state.readOnly)
22
+ return !state.dirty;
23
+ if (state.save.kind === 'pending') {
24
+ await new Promise(resolve => {
25
+ const stop = controller.subscribe(() => { if (controller.getSnapshot().save.kind !== 'pending' || controller.getSnapshot().lifecycle !== 'active') {
26
+ stop();
27
+ resolve();
28
+ } });
29
+ });
30
+ continue;
31
+ }
32
+ if (state.save.kind === 'settled' && state.save.result.kind === 'unknown')
33
+ return false;
34
+ if (!state.dirty || !manual && state.bufferVersion === 0)
35
+ return true;
36
+ if (!manual && (paused.current || state.remote !== null))
37
+ return false;
38
+ const result = await controller.flush(controller.actions.selection());
39
+ reporting.current(result);
40
+ if (result.kind !== 'saved') {
41
+ paused.current = controller.getSnapshot().save;
42
+ return false;
43
+ }
44
+ // A late acknowledgement only cleans its captured text. Select any newer edit after it settles.
45
+ }
46
+ };
47
+ const promise = Promise.resolve().then(drain).catch(() => {
48
+ paused.current = controller.getSnapshot().save;
49
+ reporting.current({ kind: 'unavailable', reason: 'The save could not be completed. Your text is still available.' });
50
+ return false;
51
+ }).finally(() => { if (running.current === promise)
52
+ running.current = null; });
53
+ running.current = promise;
54
+ return promise;
55
+ }, [cancel, controller]);
56
+ useEffect(() => {
57
+ if (!enabled)
58
+ return;
59
+ const changed = () => {
60
+ const state = controller.getSnapshot();
61
+ if (paused.current && state.save !== paused.current && (state.save.kind === 'idle' || state.save.kind === 'settled' && state.save.result.kind === 'saved'))
62
+ paused.current = null;
63
+ cancel();
64
+ if (state.lifecycle !== 'active' || state.readOnly || !state.dirty || state.bufferVersion === 0 || paused.current || state.remote !== null || state.save.kind === 'pending')
65
+ return;
66
+ if (state.save.kind === 'settled' && state.save.result.kind !== 'saved') {
67
+ paused.current = state.save;
68
+ return;
69
+ }
70
+ timer.current = setTimeout(() => { void flush(); }, delayMs);
71
+ };
72
+ const hidden = () => { if (document.visibilityState === 'hidden')
73
+ void flush(); };
74
+ const pagehide = () => { void flush(); };
75
+ const stop = controller.subscribe(changed);
76
+ document.addEventListener('visibilitychange', hidden);
77
+ window.addEventListener('pagehide', pagehide);
78
+ changed();
79
+ return () => {
80
+ stop();
81
+ cancel();
82
+ document.removeEventListener('visibilitychange', hidden);
83
+ window.removeEventListener('pagehide', pagehide);
84
+ };
85
+ }, [cancel, controller, delayMs, enabled, flush]);
86
+ useEffect(() => () => {
87
+ const state = controller.getSnapshot();
88
+ if (enabledRef.current && state.dirty && state.bufferVersion > 0)
89
+ void flush();
90
+ }, [controller, flush]);
91
+ const immediate = useCallback(() => flush(), [flush]);
92
+ const manual = useCallback(() => flush(true), [flush]);
93
+ return { enabled, immediate, manual };
94
+ }
@@ -1,3 +1,8 @@
1
+ import type { ViewerComponents } from './viewer-components.js';
2
+ export type { ViewerComponents } from './viewer-components.js';
3
+ import type { MarkdownAutosave, MarkdownFlush } from './markdown-autosave.js';
4
+ import type { SaveResult } from './resources.js';
5
+ export type { MarkdownAutosave, MarkdownFlush } from './markdown-autosave.js';
1
6
  import type { MarkdownController } from './markdown.js';
2
7
  import { type MarkdownImageResolver } from './markdown-extensions.js';
3
8
  import { type MarkdownRichSafety } from './markdown-safety.js';
@@ -7,6 +12,8 @@ export type { MarkdownRichSafety } from './markdown-safety.js';
7
12
  export type { MarkdownImageResolver } from './markdown-extensions.js';
8
13
  export type { MarkdownMountedTools, MarkdownMountedSubject, MarkdownMountedSelection, MarkdownMountedInspection } from './markdown-mounted.js';
9
14
  export interface MarkdownEditorProps {
15
+ /** Installed recipes supply the host's shadcn controls. Omit only for unstyled native HTML rendering. */
16
+ readonly components?: ViewerComponents;
10
17
  readonly controller: MarkdownController;
11
18
  readonly title?: string;
12
19
  readonly initialMode?: 'rich' | 'source';
@@ -15,6 +22,8 @@ export interface MarkdownEditorProps {
15
22
  readonly onMountedTools?: (tools: MarkdownMountedTools | null) => void;
16
23
  /** `false` omits the built-in header (title, status, source toggle, Save) so a host bar such as the viewer frame supplies them. */
17
24
  readonly header?: boolean;
25
+ /** Show local heading navigation. Defaults to the built-in header's visibility. */
26
+ readonly showOutline?: boolean;
18
27
  /** Controlled mode. The editor still falls back to `source` for a document that rich editing cannot keep exactly. */
19
28
  readonly mode?: 'rich' | 'source';
20
29
  readonly onModeChange?: (mode: 'rich' | 'source') => void;
@@ -22,6 +31,13 @@ export interface MarkdownEditorProps {
22
31
  readonly onRichSafety?: (safety: MarkdownRichSafety) => void;
23
32
  /** Images never load unless this maps their address to a URL (inline `data:` images load without it). */
24
33
  readonly resolveImage?: MarkdownImageResolver;
34
+ /** Autosave defaults to 1200ms idle, blur, hide and removal. false keeps explicit saving. */
35
+ readonly autosave?: MarkdownAutosave;
36
+ /** Registers the async navigation barrier. Null while autosave is disabled or the editor is unmounted. */
37
+ readonly onAutosaveChange?: (flush: MarkdownFlush | null) => void;
38
+ readonly onSaveResult?: (result: SaveResult) => void;
39
+ /** The complete formatting row is pinned by default on desktop; phones show it above the keyboard while editing, plus the selection bubble. */
40
+ readonly formattingToolbar?: 'selection' | 'pinned';
25
41
  }
26
42
  /** Borrows the controller. The host owns document selection, access and disposal. */
27
43
  export declare function MarkdownEditor(props: MarkdownEditorProps): import("react").JSX.Element;