@tangle-network/ui 11.33.0 → 11.35.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/CHANGELOG.md +19 -0
- package/dist/editor/document-editor-pane.d.ts +7 -1
- package/dist/editor/document-editor-pane.js +27 -12
- package/dist/editor/markdown-document-editor.js +30 -12
- package/dist/editor/source-markdown-editor.js +256 -0
- package/dist/editor/source-markdown.js +495 -0
- package/dist/files/file-artifact-pane.d.ts +2 -0
- package/dist/files/file-artifact-pane.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/markdown/markdown-table.js +1 -1
- package/dist/primitives/segmented-control.d.ts +6 -3
- package/dist/primitives/segmented-control.js +105 -56
- package/dist/run/inline-thinking-item.d.ts +7 -1
- package/dist/run/inline-thinking-item.js +6 -5
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +9 -1
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +2 -2
- package/package.json +2 -2
- package/src/editor/document-editor-pane.tsx +54 -16
- package/src/editor/markdown-document-editor.tsx +33 -18
- package/src/editor/source-markdown-editor.test.tsx +156 -0
- package/src/editor/source-markdown-editor.ts +296 -0
- package/src/editor/source-markdown.fixtures.ts +90 -0
- package/src/editor/source-markdown.test.ts +235 -0
- package/src/editor/source-markdown.ts +627 -0
- package/src/files/file-artifact-pane.test.tsx +34 -1
- package/src/files/file-artifact-pane.tsx +3 -0
- package/src/markdown/markdown-table.test.tsx +9 -0
- package/src/markdown/markdown-table.tsx +5 -1
- package/src/primitives/segmented-control.stories.tsx +89 -0
- package/src/primitives/segmented-control.test.tsx +20 -7
- package/src/primitives/segmented-control.tsx +90 -29
- package/src/run/inline-thinking-item.test.tsx +29 -0
- package/src/run/inline-thinking-item.tsx +11 -4
- package/src/utils/format.test.ts +16 -1
- package/src/utils/format.ts +17 -0
- package/src/utils/index.ts +1 -1
|
@@ -5,14 +5,39 @@ import { cn } from "../lib/utils.js";
|
|
|
5
5
|
import "../lib/control-presentation.js";
|
|
6
6
|
import * as React from "react";
|
|
7
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
8
9
|
//#region src/primitives/segmented-control.tsx
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
/** Size of the edge chevron, in px. The selected option is kept this far
|
|
11
|
+
* (plus the strip's own padding) from an edge so the chevron never covers it. */
|
|
12
|
+
const SCROLL_CUE_SIZE = 32;
|
|
13
|
+
/**
|
|
14
|
+
* A chevron pinned to one edge of the scrolling strip. It is a sticky flex
|
|
15
|
+
* item with a negative margin equal to its width plus the gap, so it takes no room in the
|
|
16
|
+
* row (showing it never reflows the options) and stays at the visible edge
|
|
17
|
+
* while the strip scrolls. A solid surface keeps it legible on any plane,
|
|
18
|
+
* which an edge fade is not: a fade over the gap between two options, or over
|
|
19
|
+
* a host's inline padding, shows nothing.
|
|
20
|
+
*/
|
|
21
|
+
function ScrollCue({ side, onPage }) {
|
|
22
|
+
const Icon = side === "start" ? ChevronLeft : ChevronRight;
|
|
23
|
+
return /* @__PURE__ */ jsx("button", {
|
|
24
|
+
type: "button",
|
|
25
|
+
tabIndex: -1,
|
|
26
|
+
"aria-hidden": "true",
|
|
27
|
+
"data-scroll-cue": side,
|
|
28
|
+
onClick: onPage,
|
|
29
|
+
className: cn("sticky z-[1] flex shrink-0 items-center justify-center self-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground", side === "start" ? "left-0" : "right-0"),
|
|
30
|
+
style: {
|
|
31
|
+
width: SCROLL_CUE_SIZE,
|
|
32
|
+
height: SCROLL_CUE_SIZE,
|
|
33
|
+
[side === "start" ? "marginRight" : "marginLeft"]: -36
|
|
34
|
+
},
|
|
35
|
+
children: /* @__PURE__ */ jsx(Icon, {
|
|
36
|
+
className: "h-4 w-4",
|
|
37
|
+
"aria-hidden": "true"
|
|
38
|
+
})
|
|
39
|
+
});
|
|
40
|
+
}
|
|
16
41
|
function SegmentedControl({ value, onValueChange, options, variant = "row", className, ...rest }) {
|
|
17
42
|
if (typeof process !== "undefined" && process?.env?.NODE_ENV !== "production" && !rest["aria-label"] && !rest["aria-labelledby"]) console.warn("[SegmentedControl] role=\"radiogroup\" requires either aria-label or aria-labelledby for accessibility.");
|
|
18
43
|
const optionRefs = React.useRef(/* @__PURE__ */ new Map());
|
|
@@ -21,7 +46,7 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
|
|
|
21
46
|
start: false,
|
|
22
47
|
end: false
|
|
23
48
|
});
|
|
24
|
-
const
|
|
49
|
+
const optionKey = options.map((o) => o.value).join("\0");
|
|
25
50
|
const measureOverflow = React.useCallback(() => {
|
|
26
51
|
const strip = stripRef.current;
|
|
27
52
|
if (!strip) return;
|
|
@@ -32,31 +57,45 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
|
|
|
32
57
|
end
|
|
33
58
|
});
|
|
34
59
|
}, []);
|
|
35
|
-
React.
|
|
36
|
-
|
|
60
|
+
const valueRef = React.useRef(value);
|
|
61
|
+
valueRef.current = value;
|
|
62
|
+
const revealSelected = React.useCallback(() => {
|
|
37
63
|
const strip = stripRef.current;
|
|
38
|
-
const option = optionRefs.current.get(
|
|
39
|
-
if (strip && option) {
|
|
64
|
+
const option = optionRefs.current.get(valueRef.current);
|
|
65
|
+
if (strip && option && strip.scrollWidth > strip.clientWidth) {
|
|
66
|
+
const style = getComputedStyle(strip);
|
|
67
|
+
const reserveStart = 36 + (Number.parseFloat(style.paddingLeft) || 0);
|
|
68
|
+
const reserveEnd = 36 + (Number.parseFloat(style.paddingRight) || 0);
|
|
40
69
|
const start = option.offsetLeft;
|
|
41
70
|
const end = start + option.offsetWidth;
|
|
42
|
-
if (start < strip.scrollLeft
|
|
43
|
-
else if (end > strip.scrollLeft + strip.clientWidth
|
|
71
|
+
if (start - reserveStart < strip.scrollLeft) strip.scrollLeft = Math.max(0, start - reserveStart);
|
|
72
|
+
else if (end + reserveEnd > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = end + reserveEnd - strip.clientWidth;
|
|
44
73
|
}
|
|
45
74
|
measureOverflow();
|
|
46
|
-
}, [
|
|
47
|
-
|
|
75
|
+
}, [measureOverflow]);
|
|
76
|
+
React.useLayoutEffect(revealSelected, [
|
|
48
77
|
value,
|
|
49
78
|
options.length,
|
|
50
|
-
|
|
79
|
+
revealSelected
|
|
51
80
|
]);
|
|
52
81
|
React.useEffect(() => {
|
|
53
|
-
if (
|
|
82
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
54
83
|
const strip = stripRef.current;
|
|
55
84
|
if (!strip) return;
|
|
56
|
-
const observer = new ResizeObserver(
|
|
85
|
+
const observer = new ResizeObserver(revealSelected);
|
|
57
86
|
observer.observe(strip);
|
|
87
|
+
for (const option of optionRefs.current.values()) observer.observe(option);
|
|
58
88
|
return () => observer.disconnect();
|
|
59
|
-
}, [
|
|
89
|
+
}, [optionKey, revealSelected]);
|
|
90
|
+
const page = (direction) => {
|
|
91
|
+
const strip = stripRef.current;
|
|
92
|
+
if (!strip) return;
|
|
93
|
+
const reduce = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
94
|
+
strip.scrollBy({
|
|
95
|
+
left: direction * strip.clientWidth * .75,
|
|
96
|
+
behavior: reduce ? "auto" : "smooth"
|
|
97
|
+
});
|
|
98
|
+
};
|
|
60
99
|
const hasMatch = options.some((o) => o.value === value);
|
|
61
100
|
const handleKeyDown = (e) => {
|
|
62
101
|
if (options.length === 0) return;
|
|
@@ -73,7 +112,7 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
|
|
|
73
112
|
optionRefs.current.get(options[next].value)?.focus();
|
|
74
113
|
}
|
|
75
114
|
};
|
|
76
|
-
return /* @__PURE__ */
|
|
115
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
77
116
|
role: "radiogroup",
|
|
78
117
|
id: rest.id,
|
|
79
118
|
"aria-label": rest["aria-label"],
|
|
@@ -83,41 +122,51 @@ function SegmentedControl({ value, onValueChange, options, variant = "row", clas
|
|
|
83
122
|
"data-overflow-end": overflow.end || void 0,
|
|
84
123
|
ref: stripRef,
|
|
85
124
|
onKeyDown: handleKeyDown,
|
|
86
|
-
onScroll:
|
|
87
|
-
className: cn("flex gap-1", variant === "row" && "flex-wrap items-center rounded-lg border border-[var(--field-border)] bg-[var(--well)] p-1", variant === "tabs" &&
|
|
88
|
-
children:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
onScroll: measureOverflow,
|
|
126
|
+
className: cn("relative flex gap-1", variant === "row" && "flex-wrap items-center rounded-lg border border-[var(--field-border)] bg-[var(--well)] p-1", variant === "tabs" && "flex-nowrap items-end border-b border-border pb-0 overflow-x-auto", className),
|
|
127
|
+
children: [
|
|
128
|
+
overflow.start && /* @__PURE__ */ jsx(ScrollCue, {
|
|
129
|
+
side: "start",
|
|
130
|
+
onPage: () => page(-1)
|
|
131
|
+
}),
|
|
132
|
+
options.map((option, i) => {
|
|
133
|
+
const active = option.value === value;
|
|
134
|
+
const focusable = active || !hasMatch && i === 0;
|
|
135
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
136
|
+
ref: (el) => {
|
|
137
|
+
if (el) optionRefs.current.set(option.value, el);
|
|
138
|
+
else optionRefs.current.delete(option.value);
|
|
139
|
+
},
|
|
140
|
+
type: "button",
|
|
141
|
+
role: "radio",
|
|
142
|
+
"aria-checked": active,
|
|
143
|
+
"data-testid": option.testId,
|
|
144
|
+
tabIndex: focusable ? 0 : -1,
|
|
145
|
+
onClick: () => {
|
|
146
|
+
if (!active) onValueChange(option.value);
|
|
147
|
+
},
|
|
148
|
+
className: cn("relative inline-flex items-center gap-2 whitespace-nowrap text-sm font-medium transition-[color,background-color,border-color,box-shadow]", variant === "row" && [
|
|
149
|
+
"rounded-md border border-transparent px-3 py-1.5",
|
|
150
|
+
focusRing,
|
|
151
|
+
"hover:bg-[var(--ix-hover-bg)] active:bg-[var(--ix-sel-bg)]",
|
|
152
|
+
"aria-pressed:bg-[var(--ix-sel-bg)] aria-pressed:text-[var(--accent-text)] aria-pressed:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] aria-checked:bg-[var(--ix-sel-bg)] aria-checked:text-[var(--accent-text)] aria-checked:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] aria-[current=page]:bg-[var(--ix-sel-bg)] aria-[current=page]:text-[var(--accent-text)] aria-[current=page]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] data-[state=on]:bg-[var(--ix-sel-bg)] data-[state=on]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)] data-[state=active]:bg-[var(--ix-sel-bg)] data-[state=active]:text-[var(--accent-text)] data-[state=active]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)]"
|
|
153
|
+
], variant === "tabs" && [
|
|
154
|
+
"shrink-0 rounded-none border-b-2 border-transparent -mb-px px-4 py-2",
|
|
155
|
+
"pointer-coarse:min-h-11",
|
|
156
|
+
focusRingInset,
|
|
157
|
+
"hover:shadow-[inset_0_-2px_0_hsl(var(--hsl-ring)/0.4)] data-[state=active]:bg-[hsl(var(--hsl-ring)/0.07)] data-[state=active]:text-[var(--accent-text)] data-[state=active]:shadow-[inset_0_-2px_0_var(--ix-sel-ring)] aria-checked:bg-[hsl(var(--hsl-ring)/0.07)] aria-checked:text-[var(--accent-text)] aria-checked:shadow-[inset_0_-2px_0_var(--ix-sel-ring)]"
|
|
158
|
+
], active ? "text-[var(--accent-text)]" : "text-muted-foreground hover:text-foreground"),
|
|
159
|
+
children: [/* @__PURE__ */ jsx("span", { children: option.label }), option.adornment && /* @__PURE__ */ jsx("span", {
|
|
160
|
+
className: cn("text-xs", active ? "text-[var(--accent-text)]/80" : "text-muted-foreground"),
|
|
161
|
+
children: option.adornment
|
|
162
|
+
})]
|
|
163
|
+
}, option.value);
|
|
164
|
+
}),
|
|
165
|
+
overflow.end && /* @__PURE__ */ jsx(ScrollCue, {
|
|
166
|
+
side: "end",
|
|
167
|
+
onPage: () => page(1)
|
|
168
|
+
})
|
|
169
|
+
]
|
|
121
170
|
});
|
|
122
171
|
}
|
|
123
172
|
//#endregion
|
|
@@ -4,8 +4,14 @@ export interface InlineThinkingItemProps {
|
|
|
4
4
|
part: ReasoningPart;
|
|
5
5
|
defaultOpen?: boolean;
|
|
6
6
|
autoCollapse?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Open the row while the model is still thinking. A product that keeps
|
|
9
|
+
* deliberation out of the reader's way passes false: the row stays as
|
|
10
|
+
* `defaultOpen` left it, with its one-line preview, until the reader opens it.
|
|
11
|
+
*/
|
|
12
|
+
openWhileActive?: boolean;
|
|
7
13
|
className?: string;
|
|
8
14
|
contentClassName?: string;
|
|
9
15
|
}
|
|
10
|
-
export declare const InlineThinkingItem: import("react").MemoExoticComponent<({ part, defaultOpen, autoCollapse, className, contentClassName }: InlineThinkingItemProps) => import("react").JSX.Element>;
|
|
16
|
+
export declare const InlineThinkingItem: import("react").MemoExoticComponent<({ part, defaultOpen, autoCollapse, openWhileActive, className, contentClassName }: InlineThinkingItemProps) => import("react").JSX.Element>;
|
|
11
17
|
//#endregion
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { TextShimmer } from "../primitives/text-shimmer.js";
|
|
2
|
-
import { truncateText } from "../utils/format.js";
|
|
2
|
+
import { plainTextFromMarkdown, truncateText } from "../utils/format.js";
|
|
3
3
|
import { ExpandableContent } from "./expandable-content.js";
|
|
4
4
|
import { RunRowShell } from "./run-row-shell.js";
|
|
5
5
|
import { Markdown } from "../markdown/markdown.js";
|
|
@@ -7,18 +7,18 @@ import { memo, useEffect, useRef, useState } from "react";
|
|
|
7
7
|
import { jsx } from "react/jsx-runtime";
|
|
8
8
|
import { Brain } from "lucide-react";
|
|
9
9
|
//#region src/run/inline-thinking-item.tsx
|
|
10
|
-
const InlineThinkingItem = memo(({ part, defaultOpen = false, autoCollapse = true, className, contentClassName }) => {
|
|
10
|
+
const InlineThinkingItem = memo(({ part, defaultOpen = false, autoCollapse = true, openWhileActive = true, className, contentClassName }) => {
|
|
11
11
|
const [open, setOpen] = useState(defaultOpen);
|
|
12
12
|
const autoCollapsedRef = useRef(false);
|
|
13
13
|
const startTime = part.time?.start;
|
|
14
14
|
const endTime = part.time?.end;
|
|
15
15
|
const durationMs = startTime != null && endTime != null ? endTime - startTime : void 0;
|
|
16
16
|
const isActive = startTime != null && endTime == null;
|
|
17
|
-
const preview = part.text ? truncateText(part.text, 120) : void 0;
|
|
17
|
+
const preview = part.text ? truncateText(plainTextFromMarkdown(part.text), 120) : void 0;
|
|
18
18
|
useEffect(() => {
|
|
19
19
|
if (isActive) {
|
|
20
20
|
autoCollapsedRef.current = false;
|
|
21
|
-
setOpen(true);
|
|
21
|
+
if (openWhileActive) setOpen(true);
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
24
|
if (autoCollapse && !autoCollapsedRef.current && durationMs != null) {
|
|
@@ -31,7 +31,8 @@ const InlineThinkingItem = memo(({ part, defaultOpen = false, autoCollapse = tru
|
|
|
31
31
|
}, [
|
|
32
32
|
autoCollapse,
|
|
33
33
|
durationMs,
|
|
34
|
-
isActive
|
|
34
|
+
isActive,
|
|
35
|
+
openWhileActive
|
|
35
36
|
]);
|
|
36
37
|
return /* @__PURE__ */ jsx(RunRowShell, {
|
|
37
38
|
icon: /* @__PURE__ */ jsx(Brain, { className: "h-3.5 w-3.5" }),
|
package/dist/utils/format.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
//#region src/utils/format.d.ts
|
|
2
2
|
/** Format a duration in milliseconds to a human-readable string. */
|
|
3
3
|
export declare function formatDuration(ms: number): string;
|
|
4
|
+
/**
|
|
5
|
+
* Markdown read as plain text, for a one-line preview: code fences, emphasis,
|
|
6
|
+
* headings, list and quote markers, and link syntax are removed, so a preview
|
|
7
|
+
* never shows `**` or `#`. Underscores inside words (`snake_case`) are kept.
|
|
8
|
+
*/
|
|
9
|
+
export declare function plainTextFromMarkdown(text: string): string;
|
|
4
10
|
/** Truncate text to `max` characters, appending "..." if truncated. */
|
|
5
11
|
export declare function truncateText(text: string, max: number): string;
|
|
6
12
|
/**
|
package/dist/utils/format.js
CHANGED
|
@@ -8,6 +8,14 @@ function formatDuration(ms) {
|
|
|
8
8
|
const remaining = seconds % 60;
|
|
9
9
|
return remaining > 0 ? `${minutes}m ${remaining}s` : `${minutes}m`;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Markdown read as plain text, for a one-line preview: code fences, emphasis,
|
|
13
|
+
* headings, list and quote markers, and link syntax are removed, so a preview
|
|
14
|
+
* never shows `**` or `#`. Underscores inside words (`snake_case`) are kept.
|
|
15
|
+
*/
|
|
16
|
+
function plainTextFromMarkdown(text) {
|
|
17
|
+
return text.replace(/```[^\n]*\n?([\s\S]*?)```/g, "$1").replace(/`([^`]*)`/g, "$1").replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^[ \t]{0,3}(?:#{1,6}[ \t]+|>[ \t]?|[-*+][ \t]+|\d+[.)][ \t]+)/gm, "").replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2").replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, "$1$2").replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, "$1$2").replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1");
|
|
18
|
+
}
|
|
11
19
|
/** Truncate text to `max` characters, appending "..." if truncated. */
|
|
12
20
|
function truncateText(text, max) {
|
|
13
21
|
const cleaned = text.replace(/\s+/g, " ").trim();
|
|
@@ -51,4 +59,4 @@ function formatBytes(bytes) {
|
|
|
51
59
|
return `${gb < 10 ? gb.toFixed(2) : gb.toFixed(1)} GB`;
|
|
52
60
|
}
|
|
53
61
|
//#endregion
|
|
54
|
-
export { formatBytes, formatDuration, formatUptime, truncateText };
|
|
62
|
+
export { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText };
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { copyText } from "./utils/copy-text.js";
|
|
2
|
-
import { formatBytes, formatDuration, formatUptime, truncateText } from "./utils/format.js";
|
|
2
|
+
import { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText } from "./utils/format.js";
|
|
3
3
|
import { timeAgo } from "./utils/time-ago.js";
|
|
4
4
|
import { TOOL_CATEGORY_ICONS, getToolCategory, getToolDisplayMetadata, getToolErrorText } from "./utils/tool-display.js";
|
|
5
5
|
import { cn } from "./lib/utils.js";
|
|
6
6
|
import { focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset } from "./lib/focus.js";
|
|
7
7
|
import { ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline } from "./lib/interaction.js";
|
|
8
|
-
export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, timeAgo, truncateText };
|
|
8
|
+
export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, plainTextFromMarkdown, timeAgo, truncateText };
|
package/dist/utils.js
CHANGED
|
@@ -2,7 +2,7 @@ import { ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, i
|
|
|
2
2
|
import { focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset } from "./lib/focus.js";
|
|
3
3
|
import { cn } from "./lib/utils.js";
|
|
4
4
|
import { TOOL_CATEGORY_ICONS, getToolCategory, getToolDisplayMetadata, getToolErrorText } from "./utils/tool-display.js";
|
|
5
|
-
import { formatBytes, formatDuration, formatUptime, truncateText } from "./utils/format.js";
|
|
5
|
+
import { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText } from "./utils/format.js";
|
|
6
6
|
import { copyText } from "./utils/copy-text.js";
|
|
7
7
|
import { timeAgo } from "./utils/time-ago.js";
|
|
8
|
-
export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, timeAgo, truncateText };
|
|
8
|
+
export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, plainTextFromMarkdown, timeAgo, truncateText };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.35.0",
|
|
4
4
|
"description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
"peerDependencies": {
|
|
151
151
|
"@hocuspocus/provider": ">=2.15.0 <5.0.0",
|
|
152
152
|
"@nanostores/react": ">=0.8.4 <3.0.0",
|
|
153
|
-
"@tangle-network/brand": "^1.
|
|
153
|
+
"@tangle-network/brand": "^1.24.2",
|
|
154
154
|
"@tiptap/core": ">=3.20.4 <4.0.0",
|
|
155
155
|
"@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
|
|
156
156
|
"@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useState } from "react";
|
|
4
4
|
import type { UrlTransform } from "react-markdown";
|
|
5
5
|
import { PencilLine, Save, Users, Wifi, WifiOff } from "lucide-react";
|
|
6
6
|
import { Markdown } from "../markdown/markdown";
|
|
@@ -39,6 +39,12 @@ export interface DocumentEditorPaneProps
|
|
|
39
39
|
markdown?: string;
|
|
40
40
|
/** Persisted baseline when the host controls the current markdown draft. */
|
|
41
41
|
savedMarkdown?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Whether the draft has unsaved edits, when the host tracks that itself
|
|
44
|
+
* (for example against the file's full bytes, frontmatter included). It
|
|
45
|
+
* takes precedence over comparing the draft with `savedMarkdown`.
|
|
46
|
+
*/
|
|
47
|
+
dirty?: boolean;
|
|
42
48
|
/** Transform Markdown links and images through the shared URL policy. */
|
|
43
49
|
urlTransform?: UrlTransform;
|
|
44
50
|
mode?: DocumentEditorMode;
|
|
@@ -194,6 +200,7 @@ export function DocumentEditorPane({
|
|
|
194
200
|
toolbar,
|
|
195
201
|
markdown = "",
|
|
196
202
|
savedMarkdown,
|
|
203
|
+
dirty,
|
|
197
204
|
urlTransform,
|
|
198
205
|
mode,
|
|
199
206
|
defaultMode = "preview",
|
|
@@ -215,16 +222,32 @@ export function DocumentEditorPane({
|
|
|
215
222
|
useState<DocumentEditorMode>(defaultMode);
|
|
216
223
|
const activeMode = mode ?? uncontrolledMode;
|
|
217
224
|
const isCollaborative = backend === "collaborative" && Boolean(collaboration);
|
|
218
|
-
const isDirty = normalizeMarkdown(draft) !== normalizeMarkdown(savedMarkdown ?? markdown);
|
|
225
|
+
const isDirty = dirty ?? normalizeMarkdown(draft) !== normalizeMarkdown(savedMarkdown ?? markdown);
|
|
226
|
+
const canSave = Boolean(onSave) && !readOnly && isDirty && !saving;
|
|
219
227
|
const saveStateLabel = readOnly
|
|
220
228
|
? "Read only"
|
|
221
229
|
: isCollaborative
|
|
222
230
|
? isDirty
|
|
223
231
|
? "Snapshot pending"
|
|
224
232
|
: "Live document current"
|
|
225
|
-
:
|
|
226
|
-
? "
|
|
227
|
-
:
|
|
233
|
+
: saving
|
|
234
|
+
? "Saving…"
|
|
235
|
+
: isDirty
|
|
236
|
+
? "Unsaved changes"
|
|
237
|
+
: "Saved";
|
|
238
|
+
|
|
239
|
+
const save = () => {
|
|
240
|
+
if (canSave) void onSave?.(draft);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Ctrl+S / Cmd+S saves from anywhere in the pane, the editor included.
|
|
244
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
245
|
+
if (!onSave || readOnly) return;
|
|
246
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
|
|
247
|
+
if (event.key.toLowerCase() !== "s") return;
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
save();
|
|
250
|
+
};
|
|
228
251
|
|
|
229
252
|
useEffect(() => {
|
|
230
253
|
setDraft(markdown);
|
|
@@ -271,26 +294,40 @@ export function DocumentEditorPane({
|
|
|
271
294
|
</TabsTrigger>
|
|
272
295
|
</TabsList>
|
|
273
296
|
|
|
274
|
-
|
|
297
|
+
{/* One save state: the status says whether the draft is saved, and the
|
|
298
|
+
Save button exists only while there is something to save. */}
|
|
299
|
+
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
|
275
300
|
{toolbar}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
301
|
+
{isCollaborative && (
|
|
302
|
+
<span className="rounded-full border border-border bg-card px-2.5 py-1 font-medium">
|
|
303
|
+
Live document
|
|
304
|
+
</span>
|
|
305
|
+
)}
|
|
306
|
+
<span
|
|
307
|
+
role="status"
|
|
308
|
+
aria-live="polite"
|
|
309
|
+
className={cn(
|
|
310
|
+
"rounded-full border px-2.5 py-1",
|
|
311
|
+
isDirty && !readOnly
|
|
312
|
+
? "border-[var(--surface-warning-border)] bg-[var(--surface-warning-bg)] text-[var(--surface-warning-text)]"
|
|
313
|
+
: "border-border bg-background",
|
|
314
|
+
)}
|
|
315
|
+
>
|
|
280
316
|
{saveStateLabel}
|
|
281
317
|
</span>
|
|
282
|
-
{onSave && !readOnly && (
|
|
318
|
+
{onSave && !readOnly && (isDirty || saving) && (
|
|
283
319
|
<button
|
|
284
320
|
type="button"
|
|
285
|
-
onClick={
|
|
286
|
-
disabled={
|
|
321
|
+
onClick={save}
|
|
322
|
+
disabled={!canSave}
|
|
323
|
+
title="Save changes (Ctrl+S)"
|
|
287
324
|
className={cn(
|
|
288
|
-
"inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-
|
|
325
|
+
"inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50",
|
|
289
326
|
focusRing,
|
|
290
327
|
)}
|
|
291
328
|
>
|
|
292
|
-
<Save className="h-
|
|
293
|
-
{
|
|
329
|
+
<Save className="h-4 w-4" />
|
|
330
|
+
{saveLabel}
|
|
294
331
|
</button>
|
|
295
332
|
)}
|
|
296
333
|
</div>
|
|
@@ -341,6 +378,7 @@ export function DocumentEditorPane({
|
|
|
341
378
|
<Tabs
|
|
342
379
|
value={activeMode}
|
|
343
380
|
onValueChange={(nextValue) => setMode(nextValue as DocumentEditorMode)}
|
|
381
|
+
onKeyDown={handleKeyDown}
|
|
344
382
|
className="h-full"
|
|
345
383
|
>
|
|
346
384
|
<ArtifactPane
|
|
@@ -7,11 +7,8 @@ import { retryableLazyEditor } from "./editor-lazy";
|
|
|
7
7
|
import { EditorLoadingPlaceholder } from "./editor-loading";
|
|
8
8
|
import { type DocumentEditorPeers, loadDocumentEditorPeers } from "./editor-peers";
|
|
9
9
|
import { EditorToolbar } from "./editor-toolbar";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
markdownToHtml,
|
|
13
|
-
normalizeMarkdown,
|
|
14
|
-
} from "./markdown-conversion";
|
|
10
|
+
import { normalizeMarkdown } from "./markdown-conversion";
|
|
11
|
+
import { createSourceMarkdownExtensions, SourceMarkdownSession } from "./source-markdown-editor";
|
|
15
12
|
|
|
16
13
|
export interface MarkdownDocumentEditorProps {
|
|
17
14
|
value?: string;
|
|
@@ -28,12 +25,18 @@ export interface MarkdownDocumentEditorProps {
|
|
|
28
25
|
* Builds the local markdown editor against loaded tiptap namespaces. The
|
|
29
26
|
* peers arrive as an argument so that this module reaches them through
|
|
30
27
|
* type-only imports, which a bundler erases.
|
|
28
|
+
*
|
|
29
|
+
* The editor writes back the file's own bytes wherever the reader changed
|
|
30
|
+
* nothing (see `source-markdown.ts`): opening a file and leaving it reports no
|
|
31
|
+
* change, and an edit changes only the edited span. Blocks it cannot represent,
|
|
32
|
+
* such as tables, are shown read-only and kept verbatim.
|
|
31
33
|
*/
|
|
32
34
|
export function createMarkdownDocumentEditor(
|
|
33
35
|
peers: DocumentEditorPeers,
|
|
34
36
|
): ComponentType<MarkdownDocumentEditorProps> {
|
|
35
37
|
const { EditorContent, useEditor } = peers.react;
|
|
36
38
|
const StarterKit = peers.starterKit.default;
|
|
39
|
+
const sourceExtensions = createSourceMarkdownExtensions(peers.react);
|
|
37
40
|
|
|
38
41
|
return function MarkdownDocumentEditor({
|
|
39
42
|
value = "",
|
|
@@ -45,8 +48,13 @@ export function createMarkdownDocumentEditor(
|
|
|
45
48
|
onChange,
|
|
46
49
|
onReady,
|
|
47
50
|
}: MarkdownDocumentEditorProps) {
|
|
48
|
-
const
|
|
49
|
-
|
|
51
|
+
const sessionRef = useRef<SourceMarkdownSession | null>(null);
|
|
52
|
+
if (!sessionRef.current) sessionRef.current = new SourceMarkdownSession(peers.react, value);
|
|
53
|
+
const initialHtml = useMemo(() => sessionRef.current!.html, []);
|
|
54
|
+
// The Markdown last written by this editor or applied from `value`.
|
|
55
|
+
const lastMarkdownRef = useRef(value);
|
|
56
|
+
const onChangeRef = useRef(onChange);
|
|
57
|
+
onChangeRef.current = onChange;
|
|
50
58
|
|
|
51
59
|
const editor = useEditor({
|
|
52
60
|
extensions: [
|
|
@@ -56,7 +64,10 @@ export function createMarkdownDocumentEditor(
|
|
|
56
64
|
class: "hljs",
|
|
57
65
|
},
|
|
58
66
|
},
|
|
67
|
+
// Markdown has no underline, so an underline could not be saved.
|
|
68
|
+
underline: false,
|
|
59
69
|
}),
|
|
70
|
+
...sourceExtensions,
|
|
60
71
|
],
|
|
61
72
|
content: initialHtml,
|
|
62
73
|
editable: !readOnly,
|
|
@@ -73,33 +84,37 @@ export function createMarkdownDocumentEditor(
|
|
|
73
84
|
},
|
|
74
85
|
},
|
|
75
86
|
onUpdate: ({ editor: currentEditor }) => {
|
|
76
|
-
const nextMarkdown =
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
const nextMarkdown = sessionRef.current!.serialize(currentEditor.state.doc);
|
|
88
|
+
// A transaction that changes no Markdown (a selection, a trailing empty
|
|
89
|
+
// paragraph) is not an edit.
|
|
90
|
+
if (nextMarkdown === lastMarkdownRef.current) return;
|
|
91
|
+
lastMarkdownRef.current = nextMarkdown;
|
|
92
|
+
onChangeRef.current?.(nextMarkdown, currentEditor);
|
|
79
93
|
},
|
|
80
94
|
onCreate: ({ editor: currentEditor }) => {
|
|
95
|
+
sessionRef.current!.attach(currentEditor);
|
|
81
96
|
onReady?.(currentEditor);
|
|
82
97
|
},
|
|
83
98
|
});
|
|
84
99
|
|
|
85
100
|
useEffect(() => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
101
|
+
// No update event: toggling read-only is not an edit.
|
|
102
|
+
editor?.setEditable(!readOnly, false);
|
|
89
103
|
}, [editor, readOnly]);
|
|
90
104
|
|
|
91
105
|
useEffect(() => {
|
|
92
106
|
if (!editor) {
|
|
93
107
|
return;
|
|
94
108
|
}
|
|
95
|
-
|
|
96
|
-
const normalizedValue = normalizeMarkdown(value);
|
|
97
|
-
if (normalizedValue === lastAppliedMarkdownRef.current) {
|
|
109
|
+
if (normalizeMarkdown(value) === normalizeMarkdown(lastMarkdownRef.current)) {
|
|
98
110
|
return;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
const session = new SourceMarkdownSession(peers.react, value);
|
|
114
|
+
sessionRef.current = session;
|
|
115
|
+
editor.commands.setContent(session.html, { emitUpdate: false });
|
|
116
|
+
session.attach(editor);
|
|
117
|
+
lastMarkdownRef.current = value;
|
|
103
118
|
}, [editor, value]);
|
|
104
119
|
|
|
105
120
|
return (
|