@tulipfarm/sprout 0.1.0 → 0.3.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 +111 -0
- package/dist/components/agent-fanout.d.ts +46 -0
- package/dist/components/agent-fanout.js +228 -0
- package/dist/components/attachment.js +2 -2
- package/dist/components/brand-icon.js +4 -0
- package/dist/components/canvas.js +2 -2
- package/dist/components/chat-input.d.ts +94 -0
- package/dist/components/chat-input.js +279 -0
- package/dist/components/chat-sources.js +11 -2
- package/dist/components/code-block.js +2 -33
- package/dist/components/file-explorer.d.ts +27 -0
- package/dist/components/file-explorer.js +300 -0
- package/dist/components/file-viewer.d.ts +18 -1
- package/dist/components/file-viewer.js +35 -8
- package/dist/components/presence-cursor.d.ts +19 -0
- package/dist/components/presence-cursor.js +14 -0
- package/dist/components/questionnaire.js +2 -1
- package/dist/components/schema-diagram.js +5 -78
- package/dist/components/search-dialog.js +5 -4
- package/dist/components/sql-viewer.d.ts +76 -0
- package/dist/components/sql-viewer.js +409 -0
- package/dist/components/state-panel.js +2 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/internal/brand-glyphs.d.ts +28 -0
- package/dist/internal/brand-glyphs.js +24 -0
- package/dist/internal/code-tokens.d.ts +4 -0
- package/dist/internal/code-tokens.js +34 -0
- package/dist/internal/flow.d.ts +22 -0
- package/dist/internal/flow.js +82 -0
- package/dist/styles/accordion.css +2 -2
- package/dist/styles/agent-fanout.css +529 -0
- package/dist/styles/alert-dialog.css +2 -2
- package/dist/styles/alert.css +2 -2
- package/dist/styles/attachment.css +4 -24
- package/dist/styles/badge.css +2 -2
- package/dist/styles/brand-icon.css +2 -2
- package/dist/styles/breadcrumb.css +2 -2
- package/dist/styles/button.css +2 -2
- package/dist/styles/calendar.css +3 -3
- package/dist/styles/canvas.css +4 -18
- package/dist/styles/carousel.css +2 -2
- package/dist/styles/chart.css +2 -2
- package/dist/styles/chat-confirmation.css +2 -2
- package/dist/styles/chat-input.css +240 -0
- package/dist/styles/chat-sources.css +2 -2
- package/dist/styles/checkbox.css +2 -3
- package/dist/styles/choice-list.css +2 -2
- package/dist/styles/code-block.css +2 -2
- package/dist/styles/collapsible.css +2 -2
- package/dist/styles/combobox.css +6 -6
- package/dist/styles/command.css +2 -2
- package/dist/styles/context-menu.css +3 -3
- package/dist/styles/dialog.css +2 -2
- package/dist/styles/dropdown-menu.css +1 -1
- package/dist/styles/field.css +2 -3
- package/dist/styles/file-explorer.css +517 -0
- package/dist/styles/file-viewer.css +113 -16
- package/dist/styles/hover-card.css +2 -2
- package/dist/styles/index.css +9 -0
- package/dist/styles/input-group.css +6 -11
- package/dist/styles/input-otp.css +2 -2
- package/dist/styles/input.css +3 -4
- package/dist/styles/mention-list.css +1 -1
- package/dist/styles/menubar.css +3 -3
- package/dist/styles/message-response.css +4 -4
- package/dist/styles/message-scroller.css +2 -2
- package/dist/styles/message.css +5 -5
- package/dist/styles/navigation-menu.css +2 -2
- package/dist/styles/page-header.css +4 -4
- package/dist/styles/plan-outline.css +6 -6
- package/dist/styles/presence-cursor.css +68 -0
- package/dist/styles/prompt-list.css +2 -2
- package/dist/styles/questionnaire.css +2 -17
- package/dist/styles/radio-group.css +2 -3
- package/dist/styles/reasoning-effort.css +2 -2
- package/dist/styles/review-card.css +2 -2
- package/dist/styles/rich-text-editor.css +2 -2
- package/dist/styles/schema-diagram.css +4 -4
- package/dist/styles/scroll-area.css +2 -2
- package/dist/styles/search-dialog.css +0 -83
- package/dist/styles/select.css +3 -3
- package/dist/styles/separator.css +2 -2
- package/dist/styles/sheet.css +2 -2
- package/dist/styles/sidebar.css +2 -2
- package/dist/styles/slider.css +1 -1
- package/dist/styles/sql-viewer.css +406 -0
- package/dist/styles/state-panel.css +1 -10
- package/dist/styles/switch.css +2 -2
- package/dist/styles/table.css +10 -10
- package/dist/styles/tabs.css +4 -4
- package/dist/styles/textarea.css +3 -4
- package/dist/styles/themes/tulipfarm.css +89 -99
- package/dist/styles/toast.css +2 -2
- package/dist/styles/toggle-group.css +2 -2
- package/dist/styles/toggle.css +2 -2
- package/dist/styles/tool-activity.css +5 -5
- package/package.json +27 -6
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useDeferredValue, useEffect, useId, useMemo, useRef, useState, } from "react";
|
|
4
|
+
import { ChevronRight } from "reicon-react/icons/ChevronRight";
|
|
5
|
+
import { FileText } from "reicon-react/icons/FileText";
|
|
6
|
+
import { Folder } from "reicon-react/icons/Folder";
|
|
7
|
+
import { FolderOpen } from "reicon-react/icons/FolderOpen";
|
|
8
|
+
import { MinusSquare } from "reicon-react/icons/MinusSquare";
|
|
9
|
+
import { X } from "reicon-react/icons/X";
|
|
10
|
+
import { cn } from "../internal/classes.js";
|
|
11
|
+
import { tokenClass, useCodeTokens } from "../internal/code-tokens.js";
|
|
12
|
+
import { codeLanguageNames } from "../internal/languages.js";
|
|
13
|
+
import { languageForFilename } from "../internal/syntax.js";
|
|
14
|
+
import { SearchField } from "./search-field.js";
|
|
15
|
+
import { StatePanel } from "./state-panel.js";
|
|
16
|
+
/** Search stops after this many matching lines, so one common word cannot flood the list. */
|
|
17
|
+
export const fileExplorerMaxMatches = 500;
|
|
18
|
+
const nameOf = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
19
|
+
const folderOf = (path) => path.slice(0, Math.max(0, path.lastIndexOf("/")));
|
|
20
|
+
const order = (a, b) => a.kind === b.kind
|
|
21
|
+
? a.name.localeCompare(b.name, undefined, { numeric: true })
|
|
22
|
+
: a.kind === "folder"
|
|
23
|
+
? -1
|
|
24
|
+
: 1;
|
|
25
|
+
function ancestors(path) {
|
|
26
|
+
const parts = path.split("/").slice(0, -1);
|
|
27
|
+
return parts.map((_, index) => parts.slice(0, index + 1).join("/"));
|
|
28
|
+
}
|
|
29
|
+
function buildTree(files) {
|
|
30
|
+
const root = [];
|
|
31
|
+
const folders = new Map([["", root]]);
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
for (const folder of ancestors(file.path)) {
|
|
34
|
+
if (folders.has(folder))
|
|
35
|
+
continue;
|
|
36
|
+
const children = [];
|
|
37
|
+
folders.get(folderOf(folder)).push({
|
|
38
|
+
kind: "folder",
|
|
39
|
+
path: folder,
|
|
40
|
+
name: nameOf(folder),
|
|
41
|
+
children,
|
|
42
|
+
});
|
|
43
|
+
folders.set(folder, children);
|
|
44
|
+
}
|
|
45
|
+
folders
|
|
46
|
+
.get(folderOf(file.path))
|
|
47
|
+
.push({ kind: "file", path: file.path, name: nameOf(file.path), file });
|
|
48
|
+
}
|
|
49
|
+
for (const children of folders.values())
|
|
50
|
+
children.sort(order);
|
|
51
|
+
return root;
|
|
52
|
+
}
|
|
53
|
+
function visibleRows(nodes, expanded, depth = 0) {
|
|
54
|
+
return nodes.flatMap((node, index) => [
|
|
55
|
+
{ node, depth, size: nodes.length, position: index + 1 },
|
|
56
|
+
...(node.kind === "folder" && expanded.has(node.path)
|
|
57
|
+
? visibleRows(node.children, expanded, depth + 1)
|
|
58
|
+
: []),
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
function rangesIn(text, needle) {
|
|
62
|
+
const haystack = text.toLowerCase();
|
|
63
|
+
const ranges = [];
|
|
64
|
+
for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length))
|
|
65
|
+
ranges.push([at, at + needle.length]);
|
|
66
|
+
return ranges;
|
|
67
|
+
}
|
|
68
|
+
const excerptLead = 32;
|
|
69
|
+
function searchFiles(files, query) {
|
|
70
|
+
const needle = query.toLowerCase();
|
|
71
|
+
const names = files.filter((file) => file.path.toLowerCase().includes(needle));
|
|
72
|
+
const hits = [];
|
|
73
|
+
let total = 0;
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
if (!("content" in file) || total >= fileExplorerMaxMatches)
|
|
76
|
+
continue;
|
|
77
|
+
const lines = [];
|
|
78
|
+
file.content.split("\n").forEach((raw, index) => {
|
|
79
|
+
if (total >= fileExplorerMaxMatches)
|
|
80
|
+
return;
|
|
81
|
+
const ranges = rangesIn(raw, needle);
|
|
82
|
+
if (!ranges.length)
|
|
83
|
+
return;
|
|
84
|
+
const indent = raw.length - raw.trimStart().length;
|
|
85
|
+
const cut = Math.max(indent, ranges[0][0] - excerptLead);
|
|
86
|
+
const prefix = cut > indent ? "…" : "";
|
|
87
|
+
const shift = cut - prefix.length;
|
|
88
|
+
lines.push({
|
|
89
|
+
line: index + 1,
|
|
90
|
+
text: prefix + raw.slice(cut).trimEnd(),
|
|
91
|
+
ranges: ranges.map(([start, end]) => [start - shift, end - shift]),
|
|
92
|
+
});
|
|
93
|
+
total += 1;
|
|
94
|
+
});
|
|
95
|
+
if (lines.length)
|
|
96
|
+
hits.push({ file, lines });
|
|
97
|
+
}
|
|
98
|
+
return { names, hits, total, capped: total >= fileExplorerMaxMatches };
|
|
99
|
+
}
|
|
100
|
+
/** Splits text pieces at the match ranges and wraps the matches in `mark`. */
|
|
101
|
+
function marked(pieces, ranges) {
|
|
102
|
+
const out = [];
|
|
103
|
+
let offset = 0;
|
|
104
|
+
pieces.forEach((piece, index) => {
|
|
105
|
+
const end = offset + piece.content.length;
|
|
106
|
+
const cuts = new Set([offset, end]);
|
|
107
|
+
for (const [start, stop] of ranges)
|
|
108
|
+
for (const cut of [start, stop])
|
|
109
|
+
if (cut > offset && cut < end)
|
|
110
|
+
cuts.add(cut);
|
|
111
|
+
const sorted = [...cuts].sort((a, b) => a - b);
|
|
112
|
+
for (let at = 0; at < sorted.length - 1; at += 1) {
|
|
113
|
+
const from = sorted[at];
|
|
114
|
+
const text = piece.content.slice(from - offset, sorted[at + 1] - offset);
|
|
115
|
+
const span = (_jsx("span", { className: piece.className, children: text }, `${index}-${from}`));
|
|
116
|
+
out.push(ranges.some(([start, stop]) => from >= start && from < stop) ? (_jsx("mark", { className: "sprout-file-explorer-mark", children: span }, `${index}-${from}`)) : (span));
|
|
117
|
+
}
|
|
118
|
+
offset = end;
|
|
119
|
+
});
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
const glyphs = {
|
|
123
|
+
typescript: ["TS", "info"],
|
|
124
|
+
tsx: ["TSX", "info"],
|
|
125
|
+
javascript: ["JS", "warning"],
|
|
126
|
+
jsx: ["JSX", "warning"],
|
|
127
|
+
json: ["{ }", "warning"],
|
|
128
|
+
jsonc: ["{ }", "warning"],
|
|
129
|
+
json5: ["{ }", "warning"],
|
|
130
|
+
markdown: ["MD", "accent"],
|
|
131
|
+
mdx: ["MDX", "accent"],
|
|
132
|
+
python: ["PY", "info"],
|
|
133
|
+
yaml: ["YML", "danger"],
|
|
134
|
+
toml: ["TML", "danger"],
|
|
135
|
+
css: ["CSS", "accent"],
|
|
136
|
+
scss: ["SCS", "accent"],
|
|
137
|
+
html: ["< >", "danger"],
|
|
138
|
+
xml: ["< >", "warning"],
|
|
139
|
+
shellscript: ["SH", "success"],
|
|
140
|
+
dotenv: ["ENV", "success"],
|
|
141
|
+
sql: ["SQL", "accent"],
|
|
142
|
+
rust: ["RS", "danger"],
|
|
143
|
+
go: ["GO", "info"],
|
|
144
|
+
ruby: ["RB", "danger"],
|
|
145
|
+
};
|
|
146
|
+
/** A 16px tile with a short language label, tinted by language. Decorative. */
|
|
147
|
+
function FileGlyph({ name }) {
|
|
148
|
+
const language = languageForFilename(name);
|
|
149
|
+
const extension = name.includes(".") ? (name.split(".").at(-1) ?? "") : "";
|
|
150
|
+
const [label, tone] = (language && glyphs[language]) ?? [
|
|
151
|
+
extension.slice(0, 3).toUpperCase(),
|
|
152
|
+
"muted",
|
|
153
|
+
];
|
|
154
|
+
if (!label)
|
|
155
|
+
return (_jsx(FileText, { size: 16, "aria-hidden": "true", className: "sprout-file-explorer-glyph" }));
|
|
156
|
+
return (_jsxs("svg", { viewBox: "0 0 16 16", "aria-hidden": "true", focusable: "false", "data-tone": tone, className: "sprout-file-explorer-glyph", children: [_jsx("rect", { x: "0.5", y: "1.5", width: "15", height: "13", rx: "3", className: "sprout-file-explorer-glyph-tile" }), _jsx("text", { x: "8", y: "10.8", textAnchor: "middle", fontSize: label.length > 2 ? 6 : 7.4, fontWeight: "700", className: "sprout-file-explorer-glyph-label", children: label })] }));
|
|
157
|
+
}
|
|
158
|
+
function CodeView({ file, query, line, }) {
|
|
159
|
+
const language = languageForFilename(file.path);
|
|
160
|
+
const tokens = useCodeTokens(file.content, language);
|
|
161
|
+
const needle = query.toLowerCase();
|
|
162
|
+
const current = useRef(null);
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
current.current?.scrollIntoView({ block: "center" });
|
|
165
|
+
}, [line, file.path]);
|
|
166
|
+
const lines = useMemo(() => tokens?.map((row) => row.map((token) => ({
|
|
167
|
+
content: token.content,
|
|
168
|
+
className: tokenClass(token),
|
|
169
|
+
}))) ?? file.content.split("\n").map((content) => [{ content }]), [tokens, file.content]);
|
|
170
|
+
// ponytail: one DOM row per line; virtualize if files past ~20k lines matter.
|
|
171
|
+
return (_jsx("div", { className: "sprout-file-explorer-code", tabIndex: 0, role: "region", "aria-label": file.path, "data-highlighted": tokens ? "" : undefined, children: _jsx("pre", { className: "sprout-file-explorer-pre", children: _jsx("code", { children: lines.map((pieces, index) => {
|
|
172
|
+
const text = pieces.map((piece) => piece.content).join("");
|
|
173
|
+
return (_jsxs("div", { ref: index + 1 === line ? current : undefined, "data-current": index + 1 === line ? "" : undefined, className: "sprout-file-explorer-line", children: [_jsx("span", { "aria-hidden": "true", className: "sprout-file-explorer-line-number", children: index + 1 }), _jsxs("span", { className: "sprout-file-explorer-line-text", children: [needle
|
|
174
|
+
? marked(pieces, rangesIn(text, needle))
|
|
175
|
+
: pieces.map((piece, at) => (_jsx("span", { className: piece.className, children: piece.content }, at))), "\n"] })] }, index));
|
|
176
|
+
}) }) }) }));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A read-only code explorer: a folder tree, open tabs, and a syntax-colored
|
|
180
|
+
* file pane. Search filters file names and lists matching lines across every
|
|
181
|
+
* file; picking a line opens the file there.
|
|
182
|
+
*/
|
|
183
|
+
export function FileExplorer({ files, label = "Files", defaultPath, className, ...props }) {
|
|
184
|
+
const tree = useMemo(() => buildTree(files), [files]);
|
|
185
|
+
const byPath = useMemo(() => new Map(files.map((file) => [file.path, file])), [files]);
|
|
186
|
+
const [expanded, setExpanded] = useState(() => new Set(defaultPath ? ancestors(defaultPath) : []));
|
|
187
|
+
const [tabs, setTabs] = useState(defaultPath && byPath.has(defaultPath) ? [defaultPath] : []);
|
|
188
|
+
const [active, setActive] = useState(tabs[0] ?? null);
|
|
189
|
+
const [focused, setFocused] = useState(active);
|
|
190
|
+
const [line, setLine] = useState(null);
|
|
191
|
+
const [query, setQuery] = useState("");
|
|
192
|
+
const deferred = useDeferredValue(query.trim());
|
|
193
|
+
const results = useMemo(() => (deferred ? searchFiles(files, deferred) : null), [files, deferred]);
|
|
194
|
+
const rows = useMemo(() => visibleRows(tree, expanded), [tree, expanded]);
|
|
195
|
+
const treeRef = useRef(null);
|
|
196
|
+
const id = useId();
|
|
197
|
+
const toggle = (path, open) => setExpanded((current) => {
|
|
198
|
+
const next = new Set(current);
|
|
199
|
+
if (open ?? !next.has(path))
|
|
200
|
+
next.add(path);
|
|
201
|
+
else
|
|
202
|
+
next.delete(path);
|
|
203
|
+
return next;
|
|
204
|
+
});
|
|
205
|
+
const open = (path, at = null) => {
|
|
206
|
+
setTabs((current) => {
|
|
207
|
+
if (current.includes(path))
|
|
208
|
+
return current;
|
|
209
|
+
const next = [...current];
|
|
210
|
+
next.splice(active ? next.indexOf(active) + 1 : next.length, 0, path);
|
|
211
|
+
return next;
|
|
212
|
+
});
|
|
213
|
+
setActive(path);
|
|
214
|
+
setFocused(path);
|
|
215
|
+
setLine(at);
|
|
216
|
+
setExpanded((current) => new Set([...current, ...ancestors(path)]));
|
|
217
|
+
};
|
|
218
|
+
const close = (path) => {
|
|
219
|
+
const index = tabs.indexOf(path);
|
|
220
|
+
const next = tabs.filter((tab) => tab !== path);
|
|
221
|
+
setTabs(next);
|
|
222
|
+
if (active === path) {
|
|
223
|
+
setActive(next[Math.min(index, next.length - 1)] ?? null);
|
|
224
|
+
setLine(null);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const focusRow = (path) => {
|
|
228
|
+
if (!path)
|
|
229
|
+
return;
|
|
230
|
+
setFocused(path);
|
|
231
|
+
treeRef.current
|
|
232
|
+
?.querySelector(`[data-path="${CSS.escape(path)}"]`)
|
|
233
|
+
?.focus();
|
|
234
|
+
};
|
|
235
|
+
const onTreeKeyDown = (event) => {
|
|
236
|
+
const index = rows.findIndex((row) => row.node.path === focused);
|
|
237
|
+
const row = rows[index];
|
|
238
|
+
if (!row)
|
|
239
|
+
return;
|
|
240
|
+
const { node } = row;
|
|
241
|
+
const isOpen = node.kind === "folder" && expanded.has(node.path);
|
|
242
|
+
switch (event.key) {
|
|
243
|
+
case "ArrowDown":
|
|
244
|
+
focusRow(rows[index + 1]?.node.path);
|
|
245
|
+
break;
|
|
246
|
+
case "ArrowUp":
|
|
247
|
+
focusRow(rows[index - 1]?.node.path);
|
|
248
|
+
break;
|
|
249
|
+
case "Home":
|
|
250
|
+
focusRow(rows[0]?.node.path);
|
|
251
|
+
break;
|
|
252
|
+
case "End":
|
|
253
|
+
focusRow(rows.at(-1)?.node.path);
|
|
254
|
+
break;
|
|
255
|
+
case "ArrowRight":
|
|
256
|
+
if (node.kind !== "folder")
|
|
257
|
+
return;
|
|
258
|
+
if (isOpen)
|
|
259
|
+
focusRow(rows[index + 1]?.node.path);
|
|
260
|
+
else
|
|
261
|
+
toggle(node.path, true);
|
|
262
|
+
break;
|
|
263
|
+
case "ArrowLeft":
|
|
264
|
+
if (isOpen)
|
|
265
|
+
toggle(node.path, false);
|
|
266
|
+
else
|
|
267
|
+
focusRow(folderOf(node.path) || undefined);
|
|
268
|
+
break;
|
|
269
|
+
case "Enter":
|
|
270
|
+
case " ":
|
|
271
|
+
if (node.kind === "folder")
|
|
272
|
+
toggle(node.path);
|
|
273
|
+
else
|
|
274
|
+
open(node.path);
|
|
275
|
+
break;
|
|
276
|
+
default:
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
};
|
|
281
|
+
const activeFile = active ? byPath.get(active) : undefined;
|
|
282
|
+
const language = activeFile ? languageForFilename(activeFile.path) : null;
|
|
283
|
+
const rovingPath = focused && rows.some((row) => row.node.path === focused)
|
|
284
|
+
? focused
|
|
285
|
+
: rows[0]?.node.path;
|
|
286
|
+
return (_jsxs("div", { "data-slot": "file-explorer", ...props, className: cn("sprout-file-explorer", className), children: [_jsxs("aside", { className: "sprout-file-explorer-sidebar", "aria-label": label, children: [_jsxs("div", { className: "sprout-file-explorer-sidebar-header", children: [_jsx("span", { className: "sprout-file-explorer-title", children: label }), _jsx("button", { type: "button", className: "sprout-file-explorer-icon-button", "aria-label": "Collapse all folders", title: "Collapse all folders", disabled: expanded.size === 0, onClick: () => setExpanded(new Set()), children: _jsx(MinusSquare, { size: 14, "aria-hidden": "true" }) })] }), _jsx("div", { className: "sprout-file-explorer-search", children: _jsx(SearchField, { label: `Search ${label.toLowerCase()}`, placeholder: "Search names and text", value: query, maxLength: 120, onChange: setQuery, className: "max-w-none" }) }), results ? (_jsxs("div", { className: "sprout-file-explorer-results", "aria-busy": deferred !== query.trim() || undefined, children: [_jsx("p", { role: "status", className: "sprout-file-explorer-summary", children: results.names.length || results.total
|
|
287
|
+
? `${results.names.length} ${results.names.length === 1 ? "file name" : "file names"}, ${results.capped ? `first ${results.total}` : results.total} ${results.total === 1 ? "line" : "lines"}`
|
|
288
|
+
: `No files or lines match “${deferred}”.` }), results.names.length > 0 && (_jsxs("section", { "aria-labelledby": `${id}-names`, children: [_jsx("h3", { id: `${id}-names`, className: "sprout-file-explorer-group", children: "File names" }), _jsx("ul", { className: "sprout-file-explorer-list", children: results.names.map((file) => (_jsx("li", { children: _jsxs("button", { type: "button", className: "sprout-file-explorer-result", "aria-current": file.path === active || undefined, onClick: () => open(file.path), children: [_jsx(FileGlyph, { name: file.path }), _jsx("span", { className: "sprout-file-explorer-name", children: marked([{ content: nameOf(file.path) }], rangesIn(nameOf(file.path), deferred.toLowerCase())) }), _jsx("span", { className: "sprout-file-explorer-dir", children: folderOf(file.path) })] }) }, file.path))) })] })), results.hits.length > 0 && (_jsxs("section", { "aria-labelledby": `${id}-lines`, children: [_jsx("h3", { id: `${id}-lines`, className: "sprout-file-explorer-group", children: "Text in files" }), _jsx("ul", { className: "sprout-file-explorer-list", children: results.hits.map(({ file, lines }) => (_jsxs("li", { children: [_jsxs("button", { type: "button", className: "sprout-file-explorer-result", onClick: () => open(file.path, lines[0].line), children: [_jsx(FileGlyph, { name: file.path }), _jsx("span", { className: "sprout-file-explorer-name", children: nameOf(file.path) }), _jsx("span", { className: "sprout-file-explorer-dir", children: folderOf(file.path) }), _jsx("span", { className: "sprout-file-explorer-count", children: lines.length })] }), _jsx("ul", { className: "sprout-file-explorer-list", children: lines.map((hit) => (_jsx("li", { children: _jsxs("button", { type: "button", className: "sprout-file-explorer-hit", "aria-label": `${file.path}, line ${hit.line}: ${hit.text}`, "aria-current": (file.path === active && hit.line === line) ||
|
|
289
|
+
undefined, onClick: () => open(file.path, hit.line), children: [_jsx("span", { className: "sprout-file-explorer-hit-line", children: hit.line }), _jsx("span", { className: "sprout-file-explorer-hit-text", children: marked([{ content: hit.text }], hit.ranges) })] }) }, hit.line))) })] }, file.path))) })] }))] })) : (_jsxs("div", { ref: treeRef, role: "tree", "aria-label": label, className: "sprout-file-explorer-tree", onKeyDown: onTreeKeyDown, children: [rows.length === 0 && (_jsx("p", { className: "sprout-file-explorer-summary", children: "No files yet." })), rows.map(({ node, depth, size, position }) => {
|
|
290
|
+
const folder = node.kind === "folder";
|
|
291
|
+
const isOpen = folder && expanded.has(node.path);
|
|
292
|
+
return (_jsxs("div", { role: "treeitem", "data-path": node.path, "data-kind": node.kind, "aria-level": depth + 1, "aria-setsize": size, "aria-posinset": position, "aria-expanded": folder ? isOpen : undefined, "aria-selected": folder ? undefined : node.path === active, tabIndex: node.path === rovingPath ? 0 : -1, className: "sprout-file-explorer-row", style: { "--depth": depth }, onFocus: () => setFocused(node.path), onClick: () => (folder ? toggle(node.path) : open(node.path)), children: [Array.from({ length: depth }, (_, at) => (_jsx("span", { "aria-hidden": "true", className: "sprout-file-explorer-guide" }, at))), _jsx("span", { "aria-hidden": "true", className: "sprout-file-explorer-chevron", children: folder && _jsx(ChevronRight, { size: 12 }) }), folder ? (isOpen ? (_jsx(FolderOpen, { size: 16, "aria-hidden": "true", className: "sprout-file-explorer-folder" })) : (_jsx(Folder, { size: 16, "aria-hidden": "true", className: "sprout-file-explorer-folder" }))) : (_jsx(FileGlyph, { name: node.name })), _jsx("span", { className: "sprout-file-explorer-name", title: node.path, children: node.name })] }, node.path));
|
|
293
|
+
})] }))] }), _jsxs("section", { className: "sprout-file-explorer-editor", "aria-label": "Editor", children: [tabs.length > 0 && (_jsx("div", { className: "sprout-file-explorer-tabs", "aria-label": "Open files", children: tabs.map((path) => (_jsxs("div", { className: "sprout-file-explorer-tab", "data-active": path === active ? "" : undefined, children: [_jsxs("button", { type: "button", className: "sprout-file-explorer-tab-open", "aria-current": path === active || undefined, title: path, onClick: () => {
|
|
294
|
+
setActive(path);
|
|
295
|
+
setLine(null);
|
|
296
|
+
}, onAuxClick: (event) => {
|
|
297
|
+
if (event.button === 1)
|
|
298
|
+
close(path);
|
|
299
|
+
}, children: [_jsx(FileGlyph, { name: path }), nameOf(path)] }), _jsx("button", { type: "button", className: "sprout-file-explorer-tab-close", "aria-label": `Close ${nameOf(path)}`, onClick: () => close(path), children: _jsx(X, { size: 12, "aria-hidden": "true" }) })] }, path))) })), activeFile ? (_jsxs(_Fragment, { children: [_jsx("nav", { className: "sprout-file-explorer-crumbs", "aria-label": "File path", children: _jsx("ol", { children: activeFile.path.split("/").map((part, index, parts) => (_jsxs("li", { "aria-current": index === parts.length - 1 ? "page" : undefined, children: [index > 0 && (_jsx(ChevronRight, { size: 10, "aria-hidden": "true", className: "sprout-file-explorer-crumb-separator" })), part] }, index))) }) }), _jsx("div", { className: "sprout-file-explorer-body", children: "content" in activeFile ? (activeFile.content === "" ? (_jsx("p", { className: "sprout-file-explorer-summary", children: "This file is empty." })) : (_jsx(CodeView, { file: activeFile, query: results ? deferred : "", line: line }, activeFile.path))) : (_jsx(StatePanel, { kind: "unavailable", heading: "h3", title: "File cannot be displayed", description: activeFile.unavailable })) }), _jsxs("div", { className: "sprout-file-explorer-status", children: [_jsx("span", { children: language ? codeLanguageNames[language] : "Plain text" }), "content" in activeFile && (_jsxs("span", { children: [activeFile.content.split("\n").length.toLocaleString(), " lines"] })), line !== null && _jsxs("span", { children: ["Line ", line] }), _jsx("span", { className: "sprout-file-explorer-status-end", children: "Read only" })] })] })) : (_jsx(StatePanel, { kind: "empty", heading: "h3", title: "Open a file", description: "Choose a file in the tree, or search names and text.", icon: FileText }))] })] }));
|
|
300
|
+
}
|
|
@@ -65,6 +65,23 @@ interface FileViewerBaseProps {
|
|
|
65
65
|
renderers?: FileRenderers | undefined;
|
|
66
66
|
/** Where focus goes on close. Defaults to the element that had focus when the viewer opened. */
|
|
67
67
|
finalFocus?: DialogContentProps["finalFocus"];
|
|
68
|
+
/** Your header controls before Download, such as who is viewing and a Share button. */
|
|
69
|
+
actions?: ReactNode;
|
|
70
|
+
/** `DropdownMenuItem`s added to the More actions menu after Reload. */
|
|
71
|
+
menu?: ReactNode;
|
|
72
|
+
/**
|
|
73
|
+
* A side panel for comments, activity, sharing, or file details. Adds a
|
|
74
|
+
* header toggle. It stays mounted while hidden and across file steps, so
|
|
75
|
+
* tabs and drafts inside it survive.
|
|
76
|
+
*/
|
|
77
|
+
panel?: ReactNode;
|
|
78
|
+
/** The panel's accessible name and toggle label. Defaults to “Details”. */
|
|
79
|
+
panelLabel?: string;
|
|
80
|
+
/** Controls whether the panel shows. */
|
|
81
|
+
panelOpen?: boolean;
|
|
82
|
+
/** Whether the panel starts open. Defaults to open on screens 1024px and wider. */
|
|
83
|
+
defaultPanelOpen?: boolean;
|
|
84
|
+
onPanelOpenChange?: (open: boolean) => void;
|
|
68
85
|
}
|
|
69
86
|
interface SingleFileProps {
|
|
70
87
|
file: ViewableFile | null;
|
|
@@ -81,7 +98,7 @@ interface FileSetProps {
|
|
|
81
98
|
}
|
|
82
99
|
export type FileViewerProps = FileViewerBaseProps & (SingleFileProps | FileSetProps);
|
|
83
100
|
/** A full-screen dialog that opens a local or linked file, or steps through several. */
|
|
84
|
-
export declare function FileViewer({ file, files, index, onIndexChange, open, onOpenChange, renderers, finalFocus, }: FileViewerProps): import("react").JSX.Element;
|
|
101
|
+
export declare function FileViewer({ file, files, index, onIndexChange, open, onOpenChange, renderers, finalFocus, actions, menu, panel, panelLabel, panelOpen, defaultPanelOpen, onPanelOpenChange, }: FileViewerProps): import("react").JSX.Element;
|
|
85
102
|
export interface ViewableFileContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
86
103
|
file: ViewableFile;
|
|
87
104
|
renderers?: FileRenderers | undefined;
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, Suspense, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
2
|
+
import { createContext, Suspense, useContext, useEffect, useId, useMemo, useRef, useState, } from "react";
|
|
3
3
|
import { ArrowUpRight } from "reicon-react/icons/ArrowUpRight";
|
|
4
4
|
import { ChevronLeft } from "reicon-react/icons/ChevronLeft";
|
|
5
5
|
import { ChevronRight } from "reicon-react/icons/ChevronRight";
|
|
6
6
|
import { Download } from "reicon-react/icons/Download";
|
|
7
|
+
import { MoreH } from "reicon-react/icons/MoreH";
|
|
8
|
+
import { SidebarRight } from "reicon-react/icons/SidebarRight";
|
|
7
9
|
import { X } from "reicon-react/icons/X";
|
|
8
10
|
import { cn } from "../internal/classes.js";
|
|
9
11
|
import { fileKind } from "../internal/file-kind.js";
|
|
10
12
|
import { Button } from "./button.js";
|
|
11
13
|
import { CodeBlock, languageForFilename } from "./code-block.js";
|
|
12
14
|
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, } from "./dialog.js";
|
|
15
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
|
|
13
16
|
import { Spinner } from "./spinner.js";
|
|
14
17
|
import { StatePanel } from "./state-panel.js";
|
|
15
18
|
const RenderersContext = createContext({});
|
|
@@ -94,10 +97,12 @@ export function renderableType(name, mediaType) {
|
|
|
94
97
|
? type
|
|
95
98
|
: "application/octet-stream";
|
|
96
99
|
}
|
|
97
|
-
function useFileContent(file) {
|
|
100
|
+
function useFileContent(file, attempt = 0, enabled = true) {
|
|
98
101
|
const [loaded, setLoaded] = useState({ kind: "loading" });
|
|
99
102
|
const { source, name, mediaType } = file;
|
|
100
103
|
useEffect(() => {
|
|
104
|
+
if (!enabled)
|
|
105
|
+
return;
|
|
101
106
|
let cancelled = false;
|
|
102
107
|
let objectUrl;
|
|
103
108
|
const controller = new AbortController();
|
|
@@ -141,12 +146,21 @@ function useFileContent(file) {
|
|
|
141
146
|
if (objectUrl)
|
|
142
147
|
URL.revokeObjectURL(objectUrl);
|
|
143
148
|
};
|
|
144
|
-
}, [source, name, mediaType]);
|
|
149
|
+
}, [source, name, mediaType, attempt, enabled]);
|
|
145
150
|
return loaded;
|
|
146
151
|
}
|
|
147
152
|
const ownsArrowKeys = 'input, textarea, select, iframe, [contenteditable="true"], [role="region"], [role="slider"]';
|
|
148
153
|
/** A full-screen dialog that opens a local or linked file, or steps through several. */
|
|
149
|
-
export function FileViewer({ file, files, index = 0, onIndexChange, open, onOpenChange, renderers, finalFocus, }) {
|
|
154
|
+
export function FileViewer({ file, files, index = 0, onIndexChange, open, onOpenChange, renderers, finalFocus, actions, menu, panel, panelLabel = "Details", panelOpen, defaultPanelOpen, onPanelOpenChange, }) {
|
|
155
|
+
const panelId = useId();
|
|
156
|
+
const [ownPanelOpen, setOwnPanelOpen] = useState(() => defaultPanelOpen ??
|
|
157
|
+
(typeof window !== "undefined" &&
|
|
158
|
+
!!window.matchMedia?.("(min-width: 1024px)").matches));
|
|
159
|
+
const panelShown = !!panel && (panelOpen ?? ownPanelOpen);
|
|
160
|
+
const togglePanel = () => {
|
|
161
|
+
setOwnPanelOpen(!panelShown);
|
|
162
|
+
onPanelOpenChange?.(!panelShown);
|
|
163
|
+
};
|
|
150
164
|
const current = files ? (files[index] ?? null) : file;
|
|
151
165
|
const count = files?.length ?? 0;
|
|
152
166
|
const refocus = useRef(null);
|
|
@@ -192,7 +206,14 @@ export function FileViewer({ file, files, index = 0, onIndexChange, open, onOpen
|
|
|
192
206
|
event.preventDefault();
|
|
193
207
|
move();
|
|
194
208
|
};
|
|
195
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children:
|
|
209
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { size: "fullscreen", showCloseButton: false, "data-file-viewer": "", "data-panel": panelShown ? "open" : undefined, className: "sprout-file-viewer", finalFocus: finalFocus, onKeyDown: handleKeyDown, children: [current && open && (_jsx(SelectedFile, { file: current, renderers: renderers, step: step, actions: actions, menu: menu, panel: panel
|
|
210
|
+
? {
|
|
211
|
+
id: panelId,
|
|
212
|
+
label: panelLabel,
|
|
213
|
+
open: panelShown,
|
|
214
|
+
toggle: togglePanel,
|
|
215
|
+
}
|
|
216
|
+
: undefined }, step ? index : undefined)), current && open && panel && (_jsx("aside", { id: panelId, "aria-label": panelLabel, hidden: !panelShown, "data-slot": "file-viewer-panel", className: "sprout-file-viewer-panel", children: panel }))] }) }));
|
|
196
217
|
}
|
|
197
218
|
function sourceLabel(file, google) {
|
|
198
219
|
return google
|
|
@@ -201,9 +222,12 @@ function sourceLabel(file, google) {
|
|
|
201
222
|
? "Local file · Not uploaded"
|
|
202
223
|
: "Linked file";
|
|
203
224
|
}
|
|
204
|
-
function SelectedFile({ file, renderers, step, }) {
|
|
225
|
+
function SelectedFile({ file, renderers, step, actions, menu, panel, }) {
|
|
226
|
+
const [attempt, setAttempt] = useState(0);
|
|
227
|
+
const retry = () => setAttempt((value) => value + 1);
|
|
205
228
|
const google = file.source.kind === "remote" ? googleDocument(file.source.url) : null;
|
|
206
|
-
|
|
229
|
+
const loaded = useFileContent(file, attempt, !google);
|
|
230
|
+
return (_jsxs(_Fragment, { children: [_jsxs("header", { "data-slot": "file-viewer-header", className: "sprout-file-viewer-header", children: [_jsxs("div", { className: "sprout-file-viewer-heading", children: [_jsx(DialogTitle, { children: _jsx("span", { className: "sprout-file-viewer-truncate", title: file.name, children: file.name }) }), _jsx(DialogDescription, { children: _jsx("span", { className: "sprout-file-viewer-truncate", children: sourceLabel(file, !!google) }) })] }), _jsxs("div", { "data-slot": "file-viewer-controls", className: "sprout-file-viewer-controls", children: [step && (_jsxs("div", { "data-slot": "file-viewer-steps", className: "sprout-file-viewer-steps", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Previous file", "data-step": "previous", disabled: !step.previous, onClick: step.previous, children: _jsx(ChevronLeft, { size: 16, "aria-hidden": "true" }) }), _jsxs("span", { className: "sprout-file-viewer-position", "aria-hidden": "true", children: [step.position, " of ", step.count] }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Next file", "data-step": "next", disabled: !step.next, onClick: step.next, children: _jsx(ChevronRight, { size: 16, "aria-hidden": "true" }) }), _jsxs("span", { "aria-live": "polite", className: "sprout-file-viewer-sr-only", children: [file.name, ", file ", step.position, " of ", step.count] })] })), actions && (_jsx("div", { "data-slot": "file-viewer-actions", className: "sprout-file-viewer-actions", children: actions })), google ? (_jsxs(Button, { variant: "outline", size: "sm", nativeButton: false, render: _jsx("a", { href: google.url, target: "_blank", rel: "noopener noreferrer" }), children: [_jsx(ArrowUpRight, { size: 16, "aria-hidden": "true" }), "Open in Google"] })) : loaded.kind === "ready" ? (_jsxs(Button, { variant: "outline", size: "sm", "aria-label": "Download", nativeButton: false, render: _jsx("a", { href: loaded.url, download: file.name }), children: [_jsx(Download, { size: 16, "aria-hidden": "true" }), _jsx("span", { className: "sprout-file-viewer-label", children: "Download" })] })) : (_jsxs(Button, { variant: "outline", size: "sm", "aria-label": "Download", disabled: true, children: [_jsx(Download, { size: 16, "aria-hidden": "true" }), _jsx("span", { className: "sprout-file-viewer-label", children: "Download" })] })), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "More actions" }), children: _jsx(MoreH, { size: 16, "aria-hidden": "true" }) }), _jsxs(DropdownMenuContent, { align: "end", size: "menu", children: [_jsx(DropdownMenuItem, { onClick: retry, children: google ? "Reload viewer" : "Reload file" }), menu && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), menu] }))] })] })] }), _jsxs("div", { "data-slot": "file-viewer-window", className: "sprout-file-viewer-window", children: [panel && (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": panel.label, "aria-expanded": panel.open, "aria-controls": panel.id, onClick: panel.toggle, children: _jsx(SidebarRight, { size: 16, "aria-hidden": "true" }) })), _jsx(DialogClose, { render: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Close file viewer" }), children: _jsx(X, { size: 18, "aria-hidden": "true" }) })] })] }), _jsx("div", { "data-slot": "viewable-file-content", className: "sprout-file-viewer-surface", children: google ? (_jsx(EmbeddedFile, { url: google.embedUrl, name: file.name, google: true, onRetry: retry }, `${google.embedUrl}:${attempt}`)) : (_jsx(FileBody, { loaded: loaded, file: file, renderers: renderers, onRetry: retry })) })] }));
|
|
207
231
|
}
|
|
208
232
|
/** Loads a local or linked file and shows it inline, with reload and download. */
|
|
209
233
|
export function ViewableFileContent({ file, renderers, className, ...props }) {
|
|
@@ -214,11 +238,14 @@ export function ViewableFileContent({ file, renderers, className, ...props }) {
|
|
|
214
238
|
}
|
|
215
239
|
function LocalFile({ file, renderers, onRetry, }) {
|
|
216
240
|
const loaded = useFileContent(file);
|
|
241
|
+
return (_jsxs(_Fragment, { children: [loaded.kind === "ready" && (_jsxs("div", { "data-slot": "file-viewer-toolbar", className: "sprout-file-viewer-toolbar", "data-align": "end", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: onRetry, children: "Reload file" }), _jsxs(Button, { variant: "outline", size: "sm", nativeButton: false, render: _jsx("a", { href: loaded.url, download: file.name }), children: [_jsx(Download, { size: 16, "aria-hidden": "true" }), "Download"] })] })), _jsx(FileBody, { loaded: loaded, file: file, renderers: renderers, onRetry: onRetry })] }));
|
|
242
|
+
}
|
|
243
|
+
function FileBody({ loaded, file, renderers, onRetry, }) {
|
|
217
244
|
if (loaded.kind === "loading")
|
|
218
245
|
return _jsx(LoadingFile, {});
|
|
219
246
|
if (loaded.kind === "error")
|
|
220
247
|
return _jsx(FileError, { message: loaded.message, onRetry: onRetry });
|
|
221
|
-
return (
|
|
248
|
+
return (_jsx(FileContent, { name: file.name, mediaType: file.mediaType, bytes: loaded.bytes, url: loaded.url, onRetry: onRetry, renderers: renderers }));
|
|
222
249
|
}
|
|
223
250
|
const textKinds = [
|
|
224
251
|
"text",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
2
|
+
export interface PresenceCursorProps extends Omit<ComponentProps<"div">, "children" | "color"> {
|
|
3
|
+
/** The person's name or email. Shown in the tag, or read out with an avatar. */
|
|
4
|
+
name: string;
|
|
5
|
+
/** Left offset of the arrow tip in px, from the positioned parent. */
|
|
6
|
+
x: number;
|
|
7
|
+
/** Top offset of the arrow tip in px, from the positioned parent. */
|
|
8
|
+
y: number;
|
|
9
|
+
/** Any CSS color. White text sits on it, so keep 4.5:1 contrast. */
|
|
10
|
+
color: string;
|
|
11
|
+
/** Shows this, such as an `Avatar`, next to the arrow instead of the name tag. */
|
|
12
|
+
avatar?: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Another person's pointer on a shared surface, with a name tag or an avatar.
|
|
16
|
+
* Put it in a parent with `position: relative`. It ignores pointer events and
|
|
17
|
+
* glides between positions unless reduced motion is requested.
|
|
18
|
+
*/
|
|
19
|
+
export declare function PresenceCursor({ name, x, y, color, avatar, className, style, ...props }: PresenceCursorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "../internal/classes.js";
|
|
3
|
+
/**
|
|
4
|
+
* Another person's pointer on a shared surface, with a name tag or an avatar.
|
|
5
|
+
* Put it in a parent with `position: relative`. It ignores pointer events and
|
|
6
|
+
* glides between positions unless reduced motion is requested.
|
|
7
|
+
*/
|
|
8
|
+
export function PresenceCursor({ name, x, y, color, avatar, className, style, ...props }) {
|
|
9
|
+
return (_jsxs("div", { "data-slot": "presence-cursor", role: "img", "aria-label": name, className: cn("sprout-presence-cursor", className), style: {
|
|
10
|
+
"--sprout-presence-color": color,
|
|
11
|
+
transform: `translate(${x}px, ${y}px)`,
|
|
12
|
+
...style,
|
|
13
|
+
}, ...props, children: [_jsx("svg", { className: "sprout-presence-cursor-arrow", viewBox: "0 0 16 16", "aria-hidden": "true", children: _jsx("path", { d: "M1.5 1.5 14.5 6.6 8.4 8.4 6.6 14.5Z" }) }), avatar != null ? (_jsx("span", { "data-slot": "presence-cursor-avatar", className: "sprout-presence-cursor-avatar", "aria-hidden": "true", children: avatar })) : (_jsx("span", { "data-slot": "presence-cursor-label", className: "sprout-presence-cursor-label", "aria-hidden": "true", children: name }))] }));
|
|
14
|
+
}
|
|
@@ -6,6 +6,7 @@ import { createContext, useCallback, useContext, useId, useLayoutEffect, useMemo
|
|
|
6
6
|
import { Check } from "reicon-react/icons/Check";
|
|
7
7
|
import { cn } from "../internal/classes.js";
|
|
8
8
|
import { buttonVariants, } from "./button.js";
|
|
9
|
+
import { Kbd } from "./kbd.js";
|
|
9
10
|
export const questionnaireItemStatusNames = [
|
|
10
11
|
"unanswered",
|
|
11
12
|
"answered",
|
|
@@ -800,7 +801,7 @@ export function QuestionnaireChoice({ checked, children, className, defaultCheck
|
|
|
800
801
|
props: mergeProps({
|
|
801
802
|
"data-slot": "questionnaire-choice",
|
|
802
803
|
className: cn("sprout-questionnaire-choice", className),
|
|
803
|
-
children: (_jsxs(_Fragment, { children: [_jsx("input", { "data-slot": "questionnaire-choice-input", className: "sprout-questionnaire-choice-input", ref: setInput, id: id, type: type, name: status === "skipped" ? undefined : name, value: value, checked: isChecked, disabled: disabled, required: required && !multiple && !hasInputAnswer, "aria-invalid": invalid || undefined, "aria-keyshortcuts": keyShortcuts(shortcut, !disabled && isChecked), "data-checked": isChecked ? "" : undefined, "data-unchecked": isChecked ? undefined : "", onChange: handleChange }), _jsxs("span", { "aria-hidden": "true", "data-slot": "questionnaire-choice-indicator", className: "sprout-questionnaire-choice-indicator", children: [_jsx("span", { className: "sprout-questionnaire-choice-dot" }), _jsx(Check, { className: "sprout-questionnaire-choice-check" })] }), _jsx("span", { "data-slot": "questionnaire-choice-label", className: "sprout-questionnaire-choice-label", children: children }), shortcut !== null && (_jsx(
|
|
804
|
+
children: (_jsxs(_Fragment, { children: [_jsx("input", { "data-slot": "questionnaire-choice-input", className: "sprout-questionnaire-choice-input", ref: setInput, id: id, type: type, name: status === "skipped" ? undefined : name, value: value, checked: isChecked, disabled: disabled, required: required && !multiple && !hasInputAnswer, "aria-invalid": invalid || undefined, "aria-keyshortcuts": keyShortcuts(shortcut, !disabled && isChecked), "data-checked": isChecked ? "" : undefined, "data-unchecked": isChecked ? undefined : "", onChange: handleChange }), _jsxs("span", { "aria-hidden": "true", "data-slot": "questionnaire-choice-indicator", className: "sprout-questionnaire-choice-indicator", children: [_jsx("span", { className: "sprout-questionnaire-choice-dot" }), _jsx(Check, { className: "sprout-questionnaire-choice-check" })] }), _jsx("span", { "data-slot": "questionnaire-choice-label", className: "sprout-questionnaire-choice-label", children: children }), shortcut !== null && (_jsx(Kbd, { variant: "outline", "aria-hidden": "true", "data-slot": "questionnaire-choice-shortcut", className: "sprout-questionnaire-choice-shortcut", children: shortcut }))] })),
|
|
804
805
|
}, props),
|
|
805
806
|
state,
|
|
806
807
|
stateAttributesMapping: {
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { Background, BaseEdge, EdgeLabelRenderer, getSmoothStepPath, Handle,
|
|
4
|
-
import {
|
|
5
|
-
import { Add } from "reicon-react/icons/Add";
|
|
3
|
+
import { Background, BaseEdge, EdgeLabelRenderer, getSmoothStepPath, Handle, Position, ReactFlow, ReactFlowProvider, } from "@xyflow/react";
|
|
4
|
+
import { useId, useMemo } from "react";
|
|
6
5
|
import { Key } from "reicon-react/icons/Key";
|
|
7
|
-
import { Minus } from "reicon-react/icons/Minus";
|
|
8
|
-
import { Scan } from "reicon-react/icons/Scan";
|
|
9
6
|
import { cn } from "../internal/classes.js";
|
|
7
|
+
import { FitOnResize, flowMaxZoom as maxZoom, flowMinZoom as minZoom, useKeyboardView, useViewportMobile, ZoomControls, } from "../internal/flow.js";
|
|
10
8
|
import { Badge } from "./badge.js";
|
|
11
|
-
import { Button } from "./button.js";
|
|
12
9
|
export const schemaTableStateNames = [
|
|
13
10
|
"new",
|
|
14
11
|
"changed",
|
|
@@ -24,10 +21,7 @@ export const schemaDiagramLayoutNames = [
|
|
|
24
21
|
const tableWidth = 264;
|
|
25
22
|
const columnGap = 80;
|
|
26
23
|
const rowGap = 48;
|
|
27
|
-
const minZoom = 0.2;
|
|
28
|
-
const maxZoom = 1.5;
|
|
29
24
|
const fitOptions = { padding: 0.15, maxZoom: 1 };
|
|
30
|
-
const panStep = 48;
|
|
31
25
|
const tableHeight = (table) => 44 +
|
|
32
26
|
(table.fields.length === 0
|
|
33
27
|
? 32
|
|
@@ -117,80 +111,13 @@ function LinkLine({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targ
|
|
|
117
111
|
}
|
|
118
112
|
const nodeTypes = { table: TableCard };
|
|
119
113
|
const edgeTypes = { link: LinkLine };
|
|
120
|
-
const mobileQuery = "(max-width: 767px)";
|
|
121
|
-
const motionQuery = "(prefers-reduced-motion: reduce)";
|
|
122
|
-
function subscribeMobile(callback) {
|
|
123
|
-
const media = window.matchMedia(mobileQuery);
|
|
124
|
-
media.addEventListener("change", callback);
|
|
125
|
-
return () => media.removeEventListener("change", callback);
|
|
126
|
-
}
|
|
127
|
-
function useViewportMobile() {
|
|
128
|
-
return useSyncExternalStore(subscribeMobile, () => window.matchMedia(mobileQuery).matches, () => false);
|
|
129
|
-
}
|
|
130
|
-
const motion = (ms) => typeof window !== "undefined" && window.matchMedia(motionQuery).matches
|
|
131
|
-
? 0
|
|
132
|
-
: ms;
|
|
133
|
-
// The canvas panel animates open, so the first fit sees a narrower pane than the final one.
|
|
134
|
-
function FitOnResize({ nodes }) {
|
|
135
|
-
const { fitView } = useReactFlow();
|
|
136
|
-
const width = useStore((state) => state.width);
|
|
137
|
-
const height = useStore((state) => state.height);
|
|
138
|
-
const measured = useNodesInitialized();
|
|
139
|
-
useEffect(() => {
|
|
140
|
-
if (!measured)
|
|
141
|
-
return;
|
|
142
|
-
const frame = requestAnimationFrame(() => void fitView(fitOptions));
|
|
143
|
-
return () => cancelAnimationFrame(frame);
|
|
144
|
-
}, [fitView, width, height, measured, nodes]);
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
function ZoomControls({ position, }) {
|
|
148
|
-
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
|
149
|
-
const zoom = useStore((state) => state.transform[2]);
|
|
150
|
-
return (_jsxs(Panel, { position: position, className: "sprout-schema-controls", "data-slot": "schema-diagram-controls", role: "toolbar", "aria-label": "Diagram view", "aria-orientation": "vertical", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Zoom in", title: "Zoom in", disabled: zoom >= maxZoom, onClick: () => void zoomIn({ duration: motion(160) }), children: _jsx(Add, { size: 16, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Zoom out", title: "Zoom out", disabled: zoom <= minZoom, onClick: () => void zoomOut({ duration: motion(160) }), children: _jsx(Minus, { size: 16, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Fit to view", title: "Fit to view", onClick: () => void fitView({ ...fitOptions, duration: motion(180) }), children: _jsx(Scan, { size: 16, "aria-hidden": "true" }) })] }));
|
|
151
|
-
}
|
|
152
|
-
function useKeyboardView() {
|
|
153
|
-
const { getViewport, setViewport, zoomIn, zoomOut, fitView } = useReactFlow();
|
|
154
|
-
return (event) => {
|
|
155
|
-
const target = event.target;
|
|
156
|
-
if (event.altKey || event.ctrlKey || event.metaKey)
|
|
157
|
-
return;
|
|
158
|
-
if (target !== event.currentTarget &&
|
|
159
|
-
!target.classList.contains("react-flow__node"))
|
|
160
|
-
return;
|
|
161
|
-
const step = panStep * (event.shiftKey ? 4 : 1);
|
|
162
|
-
const pan = {
|
|
163
|
-
ArrowLeft: [step, 0],
|
|
164
|
-
ArrowRight: [-step, 0],
|
|
165
|
-
ArrowUp: [0, step],
|
|
166
|
-
ArrowDown: [0, -step],
|
|
167
|
-
};
|
|
168
|
-
const move = pan[event.key];
|
|
169
|
-
if (move) {
|
|
170
|
-
const { x, y, zoom } = getViewport();
|
|
171
|
-
void setViewport({ x: x + move[0], y: y + move[1], zoom }, { duration: motion(120) });
|
|
172
|
-
}
|
|
173
|
-
else if (event.key === "+" || event.key === "=") {
|
|
174
|
-
void zoomIn({ duration: motion(160) });
|
|
175
|
-
}
|
|
176
|
-
else if (event.key === "-" || event.key === "_") {
|
|
177
|
-
void zoomOut({ duration: motion(160) });
|
|
178
|
-
}
|
|
179
|
-
else if (event.key === "0") {
|
|
180
|
-
void fitView({ ...fitOptions, duration: motion(180) });
|
|
181
|
-
}
|
|
182
|
-
else
|
|
183
|
-
return;
|
|
184
|
-
event.preventDefault();
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
114
|
const keyboardHint = "Arrow keys pan, plus and minus zoom, and 0 fits the diagram. Tab moves between tables.";
|
|
188
115
|
function DiagramFlow({ nodes, edges, direction, label, hintId, }) {
|
|
189
|
-
const onKeyDown = useKeyboardView();
|
|
116
|
+
const onKeyDown = useKeyboardView(fitOptions);
|
|
190
117
|
return (_jsxs(ReactFlow, { className: "sprout-schema-flow", "data-slot": "schema-diagram-viewport", "aria-label": label, "aria-describedby": hintId, tabIndex: 0, onKeyDown: onKeyDown, nodes: nodes, edges: edges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, fitView: true, fitViewOptions: fitOptions, minZoom: minZoom, maxZoom: maxZoom, nodesConnectable: false, elementsSelectable: false, edgesFocusable: false, ariaLabelConfig: {
|
|
191
118
|
"node.a11yDescription.default": keyboardHint,
|
|
192
119
|
"node.a11yDescription.keyboardDisabled": keyboardHint,
|
|
193
|
-
}, proOptions: { hideAttribution: true }, children: [_jsx(Background, { gap: 16, size: 1 }), _jsx(FitOnResize, { nodes: nodes }), _jsx(ZoomControls, { position: direction === "vertical" ? "top-right" : "bottom-right" })] }));
|
|
120
|
+
}, proOptions: { hideAttribution: true }, children: [_jsx(Background, { gap: 16, size: 1 }), _jsx(FitOnResize, { nodes: nodes, fit: fitOptions }), _jsx(ZoomControls, { position: direction === "vertical" ? "top-right" : "bottom-right", fit: fitOptions, className: "sprout-schema-controls", slot: "schema-diagram-controls" })] }));
|
|
194
121
|
}
|
|
195
122
|
/**
|
|
196
123
|
* A pan-and-zoom diagram of tables, their fields, and the links between them,
|