@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.
- package/README.md +46 -18
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/dist/canvas-editor-native.d.ts +4 -1
- package/dist/canvas-editor-native.js +8 -6
- package/dist/canvas-editor.js +1 -1
- package/dist/canvas-proposals.d.ts +3 -1
- package/dist/canvas-proposals.js +4 -3
- package/dist/experience-document-viewer.d.ts +3 -0
- package/dist/experience-document-viewer.js +6 -5
- package/dist/experience.d.ts +3 -0
- package/dist/experience.js +2 -1
- package/dist/file-tree-view.d.ts +76 -3
- package/dist/file-tree-view.js +172 -39
- package/dist/file-tree.d.ts +3 -0
- package/dist/file-tree.js +17 -2
- package/dist/html-viewer.d.ts +4 -0
- package/dist/html-viewer.js +5 -3
- package/dist/markdown-autosave.d.ts +13 -0
- package/dist/markdown-autosave.js +94 -0
- package/dist/markdown-editor.d.ts +16 -0
- package/dist/markdown-editor.js +81 -17
- package/dist/markdown-extensions.js +9 -1
- package/dist/markdown-proposal-diff.d.ts +2 -1
- package/dist/markdown-proposal-diff.js +3 -2
- package/dist/markdown-safety.js +3 -1
- package/dist/native-chat.js +1 -1
- package/dist/remote-chat.js +5 -1
- package/dist/text-buffer.js +3 -2
- package/dist/text-draft-controls.d.ts +3 -1
- package/dist/text-draft-controls.js +3 -2
- package/dist/viewer-components.d.ts +14 -0
- package/dist/viewer-components.js +1 -0
- package/package.json +4 -4
- package/r/app-shell.json +39 -20
- package/r/button.json +19 -11
- package/r/dock.json +2 -2
- package/r/documents.json +17 -14
- package/r/feedback-preview.json +8 -2
- package/r/feedback.json +46 -325
- package/r/file-tree.json +14 -6
- package/r/html-viewer.json +8 -42
- package/r/markdown-editor.json +38 -87
- package/r/pi-ambient.json +15 -8
- package/r/pi-app.json +29 -18
- package/r/pi-chat.json +88 -69
- package/r/pi-workspace.json +7 -5
- package/r/provider-setup.json +9 -4
- package/r/registry.json +460 -888
- package/r/task-list-viewer.json +11 -21
- package/r/theme.json +23 -4
- package/r/version-history.json +24 -0
- package/r/viewers.json +139 -320
- package/styles/file-tree.css +41 -42
- package/dist/file-tree-shadcn.d.ts +0 -8
- package/dist/file-tree-shadcn.js +0 -14
package/dist/file-tree-view.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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: '
|
|
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
|
|
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(
|
|
179
|
-
return _jsxs(_Fragment, { children: [listing.entries.length === 0 &&
|
|
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
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
const
|
|
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: "
|
|
192
|
-
return folder ? _jsx(
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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: "
|
|
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
|
}
|
package/dist/file-tree.d.ts
CHANGED
|
@@ -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 = {
|
|
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())
|
package/dist/html-viewer.d.ts
CHANGED
|
@@ -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;
|
package/dist/html-viewer.js
CHANGED
|
@@ -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(
|
|
60
|
-
? _jsx(
|
|
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;
|