@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.
Files changed (98) hide show
  1. package/README.md +111 -0
  2. package/dist/components/agent-fanout.d.ts +46 -0
  3. package/dist/components/agent-fanout.js +228 -0
  4. package/dist/components/attachment.js +2 -2
  5. package/dist/components/brand-icon.js +4 -0
  6. package/dist/components/canvas.js +2 -2
  7. package/dist/components/chat-input.d.ts +94 -0
  8. package/dist/components/chat-input.js +279 -0
  9. package/dist/components/chat-sources.js +11 -2
  10. package/dist/components/code-block.js +2 -33
  11. package/dist/components/file-explorer.d.ts +27 -0
  12. package/dist/components/file-explorer.js +300 -0
  13. package/dist/components/file-viewer.d.ts +18 -1
  14. package/dist/components/file-viewer.js +35 -8
  15. package/dist/components/presence-cursor.d.ts +19 -0
  16. package/dist/components/presence-cursor.js +14 -0
  17. package/dist/components/questionnaire.js +2 -1
  18. package/dist/components/schema-diagram.js +5 -78
  19. package/dist/components/search-dialog.js +5 -4
  20. package/dist/components/sql-viewer.d.ts +76 -0
  21. package/dist/components/sql-viewer.js +409 -0
  22. package/dist/components/state-panel.js +2 -2
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +5 -0
  25. package/dist/internal/brand-glyphs.d.ts +28 -0
  26. package/dist/internal/brand-glyphs.js +24 -0
  27. package/dist/internal/code-tokens.d.ts +4 -0
  28. package/dist/internal/code-tokens.js +34 -0
  29. package/dist/internal/flow.d.ts +22 -0
  30. package/dist/internal/flow.js +82 -0
  31. package/dist/styles/accordion.css +2 -2
  32. package/dist/styles/agent-fanout.css +529 -0
  33. package/dist/styles/alert-dialog.css +2 -2
  34. package/dist/styles/alert.css +2 -2
  35. package/dist/styles/attachment.css +4 -24
  36. package/dist/styles/badge.css +2 -2
  37. package/dist/styles/brand-icon.css +2 -2
  38. package/dist/styles/breadcrumb.css +2 -2
  39. package/dist/styles/button.css +2 -2
  40. package/dist/styles/calendar.css +3 -3
  41. package/dist/styles/canvas.css +4 -18
  42. package/dist/styles/carousel.css +2 -2
  43. package/dist/styles/chart.css +2 -2
  44. package/dist/styles/chat-confirmation.css +2 -2
  45. package/dist/styles/chat-input.css +240 -0
  46. package/dist/styles/chat-sources.css +2 -2
  47. package/dist/styles/checkbox.css +2 -3
  48. package/dist/styles/choice-list.css +2 -2
  49. package/dist/styles/code-block.css +2 -2
  50. package/dist/styles/collapsible.css +2 -2
  51. package/dist/styles/combobox.css +6 -6
  52. package/dist/styles/command.css +2 -2
  53. package/dist/styles/context-menu.css +3 -3
  54. package/dist/styles/dialog.css +2 -2
  55. package/dist/styles/dropdown-menu.css +1 -1
  56. package/dist/styles/field.css +2 -3
  57. package/dist/styles/file-explorer.css +517 -0
  58. package/dist/styles/file-viewer.css +113 -16
  59. package/dist/styles/hover-card.css +2 -2
  60. package/dist/styles/index.css +9 -0
  61. package/dist/styles/input-group.css +6 -11
  62. package/dist/styles/input-otp.css +2 -2
  63. package/dist/styles/input.css +3 -4
  64. package/dist/styles/mention-list.css +1 -1
  65. package/dist/styles/menubar.css +3 -3
  66. package/dist/styles/message-response.css +4 -4
  67. package/dist/styles/message-scroller.css +2 -2
  68. package/dist/styles/message.css +5 -5
  69. package/dist/styles/navigation-menu.css +2 -2
  70. package/dist/styles/page-header.css +4 -4
  71. package/dist/styles/plan-outline.css +6 -6
  72. package/dist/styles/presence-cursor.css +68 -0
  73. package/dist/styles/prompt-list.css +2 -2
  74. package/dist/styles/questionnaire.css +2 -17
  75. package/dist/styles/radio-group.css +2 -3
  76. package/dist/styles/reasoning-effort.css +2 -2
  77. package/dist/styles/review-card.css +2 -2
  78. package/dist/styles/rich-text-editor.css +2 -2
  79. package/dist/styles/schema-diagram.css +4 -4
  80. package/dist/styles/scroll-area.css +2 -2
  81. package/dist/styles/search-dialog.css +0 -83
  82. package/dist/styles/select.css +3 -3
  83. package/dist/styles/separator.css +2 -2
  84. package/dist/styles/sheet.css +2 -2
  85. package/dist/styles/sidebar.css +2 -2
  86. package/dist/styles/slider.css +1 -1
  87. package/dist/styles/sql-viewer.css +406 -0
  88. package/dist/styles/state-panel.css +1 -10
  89. package/dist/styles/switch.css +2 -2
  90. package/dist/styles/table.css +10 -10
  91. package/dist/styles/tabs.css +4 -4
  92. package/dist/styles/textarea.css +3 -4
  93. package/dist/styles/themes/tulipfarm.css +89 -99
  94. package/dist/styles/toast.css +2 -2
  95. package/dist/styles/toggle-group.css +2 -2
  96. package/dist/styles/toggle.css +2 -2
  97. package/dist/styles/tool-activity.css +5 -5
  98. 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: _jsx(DialogContent, { size: "fullscreen", showCloseButton: false, "data-file-viewer": "", className: "sprout-file-viewer", finalFocus: finalFocus, onKeyDown: handleKeyDown, children: current && open && (_jsx(SelectedFile, { file: current, renderers: renderers, step: step }, step ? index : undefined)) }) }));
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
- 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) }) })] }), 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] })] })), 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"] })), _jsx(DialogClose, { render: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": "Close file viewer" }), children: _jsx(X, { size: 20, "aria-hidden": "true" }) })] }), _jsx(ViewableFileContent, { file: file, renderers: renderers })] }));
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 (_jsxs(_Fragment, { children: [_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(FileContent, { name: file.name, mediaType: file.mediaType, bytes: loaded.bytes, url: loaded.url, onRetry: onRetry, renderers: renderers })] }));
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("span", { "aria-hidden": "true", "data-slot": "questionnaire-choice-shortcut", className: "sprout-questionnaire-choice-shortcut", children: shortcut }))] })),
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, Panel, Position, ReactFlow, ReactFlowProvider, useNodesInitialized, useReactFlow, useStore, } from "@xyflow/react";
4
- import { useEffect, useId, useMemo, useSyncExternalStore, } from "react";
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,