feat: expand intelligent investigation workspace
This commit is contained in:
@@ -1,559 +0,0 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemedToken,
|
||||
} from "shiki";
|
||||
import { createHighlighter } from "shiki";
|
||||
|
||||
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
|
||||
const isUnderline = (fontStyle: number | undefined) =>
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
fontStyle && fontStyle & 4;
|
||||
|
||||
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
||||
interface KeyedToken {
|
||||
token: ThemedToken;
|
||||
key: string;
|
||||
}
|
||||
interface KeyedLine {
|
||||
tokens: KeyedToken[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
|
||||
lines.map((line, lineIdx) => ({
|
||||
key: `line-${lineIdx}`,
|
||||
tokens: line.map((token, tokenIdx) => ({
|
||||
key: `line-${lineIdx}-${tokenIdx}`,
|
||||
token,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Token rendering component
|
||||
const TokenSpan = ({ token }: { token: ThemedToken }) => (
|
||||
<span
|
||||
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
|
||||
style={
|
||||
{
|
||||
backgroundColor: token.bgColor,
|
||||
color: token.color,
|
||||
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
|
||||
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
|
||||
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
|
||||
...token.htmlStyle,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Line number styles using CSS counters
|
||||
const LINE_NUMBER_CLASSES = cn(
|
||||
"block",
|
||||
"before:content-[counter(line)]",
|
||||
"before:inline-block",
|
||||
"before:[counter-increment:line]",
|
||||
"before:w-8",
|
||||
"before:mr-4",
|
||||
"before:text-right",
|
||||
"before:text-muted-foreground/50",
|
||||
"before:font-mono",
|
||||
"before:select-none"
|
||||
);
|
||||
|
||||
// Line rendering component
|
||||
const LineSpan = ({
|
||||
keyedLine,
|
||||
showLineNumbers,
|
||||
}: {
|
||||
keyedLine: KeyedLine;
|
||||
showLineNumbers: boolean;
|
||||
}) => (
|
||||
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
|
||||
{keyedLine.tokens.length === 0
|
||||
? "\n"
|
||||
: keyedLine.tokens.map(({ token, key }) => (
|
||||
<TokenSpan key={key} token={token} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Types
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
interface TokenizedCode {
|
||||
tokens: ThemedToken[][];
|
||||
fg: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
interface CodeBlockContextType {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Context
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
// Highlighter cache (singleton per language)
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>();
|
||||
|
||||
// Token cache
|
||||
const tokensCache = new Map<string, TokenizedCode>();
|
||||
|
||||
// Subscribers for async token updates
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
|
||||
|
||||
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||
const start = code.slice(0, 100);
|
||||
const end = code.length > 100 ? code.slice(-100) : "";
|
||||
return `${language}:${code.length}:${start}:${end}`;
|
||||
};
|
||||
|
||||
const getHighlighter = (
|
||||
language: BundledLanguage
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||
const cached = highlighterCache.get(language);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const highlighterPromise = createHighlighter({
|
||||
langs: [language],
|
||||
themes: ["github-light", "github-dark"],
|
||||
});
|
||||
|
||||
highlighterCache.set(language, highlighterPromise);
|
||||
return highlighterPromise;
|
||||
};
|
||||
|
||||
// Create raw tokens for immediate display while highlighting loads
|
||||
const createRawTokens = (code: string): TokenizedCode => ({
|
||||
bg: "transparent",
|
||||
fg: "inherit",
|
||||
tokens: code.split("\n").map((line) =>
|
||||
line === ""
|
||||
? []
|
||||
: [
|
||||
{
|
||||
color: "inherit",
|
||||
content: line,
|
||||
} as ThemedToken,
|
||||
]
|
||||
),
|
||||
});
|
||||
|
||||
// Synchronous highlight with callback for async results
|
||||
const highlightCode = (
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
callback?: (result: TokenizedCode) => void
|
||||
): TokenizedCode | null => {
|
||||
const tokensCacheKey = getTokensCacheKey(code, language);
|
||||
|
||||
// Return cached result if available
|
||||
const cached = tokensCache.get(tokensCacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Subscribe callback if provided
|
||||
if (callback) {
|
||||
if (!subscribers.has(tokensCacheKey)) {
|
||||
subscribers.set(tokensCacheKey, new Set());
|
||||
}
|
||||
subscribers.get(tokensCacheKey)?.add(callback);
|
||||
}
|
||||
|
||||
// Start highlighting in background - fire-and-forget async pattern
|
||||
getHighlighter(language)
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
||||
.then((highlighter) => {
|
||||
const availableLangs = highlighter.getLoadedLanguages();
|
||||
const langToUse = availableLangs.includes(language) ? language : "text";
|
||||
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: langToUse,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
});
|
||||
|
||||
const tokenized: TokenizedCode = {
|
||||
bg: result.bg ?? "transparent",
|
||||
fg: result.fg ?? "inherit",
|
||||
tokens: result.tokens,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
tokensCache.set(tokensCacheKey, tokenized);
|
||||
|
||||
// Notify all subscribers
|
||||
const subs = subscribers.get(tokensCacheKey);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
sub(tokenized);
|
||||
}
|
||||
subscribers.delete(tokensCacheKey);
|
||||
}
|
||||
})
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
.catch((error) => {
|
||||
console.error("Failed to highlight code:", error);
|
||||
subscribers.delete(tokensCacheKey);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const CodeBlockBody = memo(
|
||||
({
|
||||
tokenized,
|
||||
showLineNumbers,
|
||||
className,
|
||||
}: {
|
||||
tokenized: TokenizedCode;
|
||||
showLineNumbers: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const preStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: tokenized.bg,
|
||||
color: tokenized.fg,
|
||||
}),
|
||||
[tokenized.bg, tokenized.fg]
|
||||
);
|
||||
|
||||
const keyedLines = useMemo(
|
||||
() => addKeysToTokens(tokenized.tokens),
|
||||
[tokenized.tokens]
|
||||
);
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
|
||||
className
|
||||
)}
|
||||
style={preStyle}
|
||||
>
|
||||
<code
|
||||
className={cn(
|
||||
"font-mono text-sm",
|
||||
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
|
||||
)}
|
||||
>
|
||||
{keyedLines.map((keyedLine) => (
|
||||
<LineSpan
|
||||
key={keyedLine.key}
|
||||
keyedLine={keyedLine}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.tokenized === nextProps.tokenized &&
|
||||
prevProps.showLineNumbers === nextProps.showLineNumbers &&
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
|
||||
CodeBlockBody.displayName = "CodeBlockBody";
|
||||
|
||||
export const CodeBlockContainer = ({
|
||||
className,
|
||||
language,
|
||||
style,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
data-language={language}
|
||||
style={{
|
||||
containIntrinsicSize: "auto 200px",
|
||||
contentVisibility: "auto",
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CodeBlockHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockTitle = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockFilename = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn("font-mono", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const CodeBlockActions = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockContent = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
}: {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
}) => {
|
||||
// Memoized raw tokens for immediate display
|
||||
const rawTokens = useMemo(() => createRawTokens(code), [code]);
|
||||
|
||||
// Synchronous cache lookup — avoids setState in effect for cached results
|
||||
const syncTokens = useMemo(
|
||||
() => highlightCode(code, language) ?? rawTokens,
|
||||
[code, language, rawTokens]
|
||||
);
|
||||
|
||||
// Async highlighting result (populated after shiki loads)
|
||||
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
|
||||
const asyncKeyRef = useRef({ code, language });
|
||||
|
||||
// Invalidate stale async tokens synchronously during render
|
||||
if (
|
||||
asyncKeyRef.current.code !== code ||
|
||||
asyncKeyRef.current.language !== language
|
||||
) {
|
||||
asyncKeyRef.current = { code, language };
|
||||
setAsyncTokens(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
highlightCode(code, language, (result) => {
|
||||
if (!cancelled) {
|
||||
setAsyncTokens(result);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, language]);
|
||||
|
||||
const tokenized = asyncTokens ?? syncTokens;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-auto">
|
||||
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const contextValue = useMemo(() => ({ code }), [code]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={contextValue}>
|
||||
<CodeBlockContainer className={className} language={language} {...props}>
|
||||
{children}
|
||||
<CodeBlockContent
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
</CodeBlockContainer>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [code, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
|
||||
|
||||
export const CodeBlockLanguageSelector = (
|
||||
props: CodeBlockLanguageSelectorProps
|
||||
) => <Select {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
|
||||
typeof SelectTrigger
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorTriggerProps) => (
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"form-field-inset h-7 border-none bg-transparent px-2 text-xs shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
|
||||
typeof SelectValue
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorValue = (
|
||||
props: CodeBlockLanguageSelectorValueProps
|
||||
) => <SelectValue {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
|
||||
typeof SelectContent
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorContent = ({
|
||||
align = "end",
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorContentProps) => (
|
||||
<SelectContent align={align} {...props} />
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
|
||||
typeof SelectItem
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorItem = (
|
||||
props: CodeBlockLanguageSelectorItemProps
|
||||
) => <SelectItem {...props} />;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ReadableMarkdownDiagram, ReadableMarkdownTable } from "./markdown-reading";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("readable Markdown tables", () => {
|
||||
const rows = <><thead><tr><th>对象</th><th>压力(MPa)</th></tr></thead><tbody><tr><td>北区入口</td><td id="pressure-value">0.2130</td></tr></tbody></>;
|
||||
it("keeps the original table visible while expanded, avoids duplicate IDs and returns keyboard focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<ReadableMarkdownTable>{rows}</ReadableMarkdownTable>);
|
||||
const original = within(container).getByRole("table");
|
||||
const trigger = screen.getByRole("button", { name: "放大查看表格" });
|
||||
expect(trigger).toHaveTextContent("");
|
||||
await user.click(trigger);
|
||||
const dialog = screen.getByRole("dialog", { name: "表格" });
|
||||
expect(within(dialog).getByRole("table")).toHaveTextContent("0.2130");
|
||||
expect(original).toBeVisible();
|
||||
expect(original).toHaveTextContent("0.2130");
|
||||
expect(document.querySelectorAll("#pressure-value")).toHaveLength(1);
|
||||
await user.click(within(dialog).getByRole("button", { name: "复制表格" }));
|
||||
expect(await navigator.clipboard.readText()).toBe("对象\t压力(MPa)\n北区入口\t0.2130");
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveFocus();
|
||||
expect(original).toBeVisible();
|
||||
});
|
||||
it("downloads the displayed values without converting their precision", async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls = { createObjectURL: URL.createObjectURL, revokeObjectURL: URL.revokeObjectURL };
|
||||
let saved: Blob | undefined;
|
||||
URL.createObjectURL = vi.fn((blob: Blob) => { saved = blob; return "blob:table-test"; });
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
try {
|
||||
render(<ReadableMarkdownTable>{rows}</ReadableMarkdownTable>);
|
||||
await user.click(screen.getByRole("button", { name: "下载表格" }));
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
expect(saved).toBeDefined();
|
||||
// Blob keeps the original textual readout, including trailing zeroes.
|
||||
const reader = new FileReader();
|
||||
const content = await new Promise<string>((resolve) => { reader.onload = () => resolve(String(reader.result)); reader.readAsText(saved!); });
|
||||
expect(content).toContain("北区入口,0.2130");
|
||||
} finally { URL.createObjectURL = urls.createObjectURL; URL.revokeObjectURL = urls.revokeObjectURL; }
|
||||
});
|
||||
});
|
||||
|
||||
it("defers incomplete Mermaid source and provides an accessible icon-only expansion entry", () => {
|
||||
render(<ReadableMarkdownDiagram code="flowchart TD\nA[未完成" isIncomplete language="mermaid" />);
|
||||
expect(screen.getByText("图示仍在生成…")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "放大查看图示" })).toHaveTextContent("");
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { Children, cloneElement, isValidElement, useCallback, useContext, useRef, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { Streamdown, StreamdownContext, extractTableDataFromElement, tableDataToCSV, tableDataToTSV, type CustomRendererProps } from "streamdown";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { Check, Copy, Download, Maximize2, Table2, Workflow, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/shared/ui/dialog";
|
||||
import { createReadingPreview } from "@/shared/ui/reading-preview";
|
||||
import { copySelectedText } from "./paragraph-copy";
|
||||
|
||||
/** Move the existing renderer into a bounded reader. Pan, selection and DOM state survive. */
|
||||
function MarkdownReading({ title, kind, children, expandedChildren, toolbar }: {
|
||||
title: string;
|
||||
kind: "table" | "diagram";
|
||||
children: ReactNode;
|
||||
expandedChildren?: ReactNode;
|
||||
toolbar?: (host: HTMLDivElement) => ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [host] = useState(() => document.createElement("div"));
|
||||
const inline = useRef<HTMLDivElement | null>(null);
|
||||
const snapshot = useRef<HTMLElement | null>(null);
|
||||
const { isAnimating } = useContext(StreamdownContext);
|
||||
const attachInline = useCallback((slot: HTMLDivElement | null) => {
|
||||
inline.current = slot;
|
||||
if (slot && !host.isConnected) slot.append(host);
|
||||
}, [host]);
|
||||
const attachExpanded = useCallback((slot: HTMLDivElement | null) => {
|
||||
if (!inline.current) return;
|
||||
if (slot) {
|
||||
if (kind === "diagram") {
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = createReadingPreview(host);
|
||||
inline.current.append(snapshot.current);
|
||||
}
|
||||
slot.append(host);
|
||||
} else {
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = null;
|
||||
inline.current.append(host);
|
||||
}
|
||||
}, [host, kind]);
|
||||
const Icon = kind === "table" ? Table2 : Workflow;
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<section className="markdown-reading-block" data-streamdown={kind === "table" ? "table-wrapper" : undefined} data-markdown-diagram={kind === "diagram" || undefined}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><Icon size={16} aria-hidden />{title}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{toolbar?.(host)}
|
||||
<DialogTrigger asChild><Button variant="ghost" size="icon-sm" disabled={isAnimating} aria-label={`放大查看${title}`} title={`放大查看${title}`}><Maximize2 size={15} aria-hidden /></Button></DialogTrigger>
|
||||
</div>
|
||||
</header>
|
||||
{kind === "table" ? <div className="min-w-0">{children}</div> : <div ref={attachInline} className="min-w-0" />}
|
||||
</section>
|
||||
{createPortal(expandedChildren ?? children, host)}
|
||||
<DialogContent onCopy={copySelectedText} className="markdown-reading-window motion-reduce:animate-none" data-reading-kind={kind} overlayClassName="markdown-reading-backdrop motion-reduce:animate-none" data-agent-theme="chat" showCloseButton={false}
|
||||
{...(kind === "table" ? { "aria-describedby": undefined } : {})}
|
||||
onEscapeKeyDown={(event) => { event.preventDefault(); event.stopPropagation(); setOpen(false); }}>
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="min-w-0"><DialogTitle className="text-base leading-6 tracking-normal">{title}</DialogTitle>
|
||||
{kind === "diagram" ? <DialogDescription className="mt-0.5 text-xs">可缩放和平移查看图中关系。</DialogDescription> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">{toolbar?.(host)}<DialogClose asChild><Button variant="ghost" size="icon-sm" aria-label="关闭放大查看"><X size={17} aria-hidden /></Button></DialogClose></div>
|
||||
</header>
|
||||
<div className="markdown-reading-scroll min-h-0 flex-1 overflow-auto overscroll-contain p-3 md:p-5" data-markdown-reading-scroll>
|
||||
<div ref={kind === "table" ? (slot) => { if (slot) slot.append(host); } : attachExpanded} className="min-w-0" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
function MarkdownTableActions({ host }: { host: HTMLDivElement }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const getData = () => {
|
||||
const table = host.querySelector("table");
|
||||
return table ? extractTableDataFromElement(table) : null;
|
||||
};
|
||||
return <><Button type="button" variant="ghost" size="icon-sm" aria-label={copied ? "表格已复制" : "复制表格"} onClick={async () => {
|
||||
const data = getData();
|
||||
if (!data) return;
|
||||
try { await navigator.clipboard.writeText(tableDataToTSV(data)); setCopied(true); setError(false); }
|
||||
catch { setError(true); }
|
||||
}}>{copied ? <Check size={15} aria-hidden /> : <Copy size={15} aria-hidden />}</Button>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="下载表格" onClick={() => {
|
||||
const data = getData();
|
||||
if (!data) return;
|
||||
const url = URL.createObjectURL(new Blob(["\uFEFF", tableDataToCSV(data)], { type: "text/csv;charset=utf-8" }));
|
||||
const anchor = document.createElement("a"); anchor.href = url; anchor.download = "table.csv"; anchor.click();
|
||||
const revoke = URL.revokeObjectURL.bind(URL);
|
||||
setTimeout(() => revoke(url), 1000);
|
||||
}}><Download size={15} aria-hidden /></Button>
|
||||
{error ? <span role="status" className="text-xs text-destructive">复制失败,请重试</span> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ReadableMarkdownTable({ children, className, node: _node, ...props }: ComponentProps<"table"> & { node?: unknown }) {
|
||||
const table = <div className="markdown-table-scroll" role="region" aria-label="表格数据" tabIndex={0}>
|
||||
<table {...props} className={className} data-streamdown="table">{children}</table>
|
||||
</div>;
|
||||
return <MarkdownReading title="表格" kind="table" expandedChildren={withoutExplicitIds(table)} toolbar={(host) => <MarkdownTableActions host={host} />}>
|
||||
{table}
|
||||
</MarkdownReading>;
|
||||
}
|
||||
|
||||
function withoutExplicitIds(children: ReactNode): ReactNode {
|
||||
return Children.map(children, (child) => {
|
||||
if (!isValidElement<{ id?: string; children?: ReactNode }>(child)) return child;
|
||||
return cloneElement(child, { id: undefined, ...(child.props.children !== undefined ? { children: withoutExplicitIds(child.props.children) } : {}) });
|
||||
});
|
||||
}
|
||||
|
||||
export function ReadableMarkdownDiagram({ code, isIncomplete }: CustomRendererProps) {
|
||||
return <MarkdownReading title="图示" kind="diagram">
|
||||
{isIncomplete ? <p className="p-4 text-sm text-muted-foreground">图示仍在生成…</p> : <Streamdown
|
||||
className="markdown-diagram-renderer" plugins={{ mermaid }}
|
||||
mermaid={{ config: { securityLevel: "strict", theme: "neutral", fontFamily: '"Noto Sans SC Variable", "PingFang SC", sans-serif', themeVariables: { fontSize: "18px" }, flowchart: { useMaxWidth: false, padding: 20 } } }}
|
||||
controls={{ mermaid: { fullscreen: false, panZoom: true, copy: true, download: true } }}
|
||||
>{`\`\`\`mermaid\n${code}\n\`\`\``}</Streamdown>}
|
||||
</MarkdownReading>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { copySelectedParagraph, copySelectedText } from "./paragraph-copy";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
@@ -16,7 +17,7 @@ import { createMathPlugin } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import type { ClipboardEvent, ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
@@ -326,17 +327,32 @@ const streamdownPlugins = {
|
||||
mermaid,
|
||||
};
|
||||
|
||||
function copyResponseParagraph(event: ClipboardEvent<HTMLElement>) {
|
||||
const selection = event.currentTarget.ownerDocument.defaultView?.getSelection();
|
||||
const anchor = selection?.rangeCount ? selection.getRangeAt(0).startContainer : null;
|
||||
const element = anchor instanceof Element ? anchor : anchor?.parentElement;
|
||||
const paragraph = element?.closest("p");
|
||||
// Keep native serialization for multi-block content, hard breaks, tables,
|
||||
// diagrams and code, whose visible text differs from DOM Range text.
|
||||
if (!paragraph || !event.currentTarget.contains(paragraph) ||
|
||||
paragraph.closest("pre, table, [data-streamdown=mermaid]") ||
|
||||
paragraph.querySelector("br, img, math, svg")) return;
|
||||
copySelectedParagraph(event, paragraph, true);
|
||||
}
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
animated
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
({ className, plugins, ...props }: MessageResponseProps) => (
|
||||
<div className="contents" onCopy={(event) => { copyResponseParagraph(event); copySelectedText(event); }}>
|
||||
<Streamdown
|
||||
animated
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
plugins={{ ...streamdownPlugins, ...plugins }}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/ui/collapsible";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface PlanContextValue {
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
const PlanContext = createContext<PlanContextValue | null>(null);
|
||||
|
||||
const usePlan = () => {
|
||||
const context = useContext(PlanContext);
|
||||
if (!context) {
|
||||
throw new Error("Plan components must be used within Plan");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export const Plan = ({
|
||||
className,
|
||||
isStreaming = false,
|
||||
children,
|
||||
...props
|
||||
}: PlanProps) => {
|
||||
const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
|
||||
|
||||
return (
|
||||
<PlanContext.Provider value={contextValue}>
|
||||
<Collapsible asChild data-slot="plan" {...props}>
|
||||
<Card className={cn("shadow-none", className)}>{children}</Card>
|
||||
</Collapsible>
|
||||
</PlanContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
data-slot="plan-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PlanTitleProps = Omit<
|
||||
ComponentProps<typeof CardTitle>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardTitle data-slot="plan-title" {...props}>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanDescriptionProps = Omit<
|
||||
ComponentProps<typeof CardDescription>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PlanDescriptionProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn("text-balance", className)}
|
||||
data-slot="plan-description"
|
||||
{...props}
|
||||
>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardDescription>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const PlanAction = (props: PlanActionProps) => (
|
||||
<CardAction data-slot="plan-action" {...props} />
|
||||
);
|
||||
|
||||
export type PlanContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const PlanContent = (props: PlanContentProps) => (
|
||||
<CollapsibleContent asChild>
|
||||
<CardContent data-slot="plan-content" {...props} />
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
export type PlanFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const PlanFooter = (props: PlanFooterProps) => (
|
||||
<CardFooter data-slot="plan-footer" {...props} />
|
||||
);
|
||||
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className={cn("size-8", className)}
|
||||
data-slot="plan-trigger"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4" />
|
||||
<span className="sr-only">Toggle plan</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PromptInput, PromptInputBody, PromptInputSubmit, PromptInputTextarea } from "./prompt-input";
|
||||
|
||||
function composer(onSubmit = vi.fn(), disabled = false) {
|
||||
render(<PromptInput onSubmit={onSubmit}>
|
||||
<PromptInputBody><PromptInputTextarea aria-label="输入问题" /></PromptInputBody>
|
||||
<PromptInputSubmit disabled={disabled} />
|
||||
</PromptInput>);
|
||||
return { input: screen.getByRole("textbox", { name: "输入问题" }), onSubmit };
|
||||
}
|
||||
|
||||
describe("text prompt composition", () => {
|
||||
it("waits for IME composition to finish before Enter can submit the complete text", async () => {
|
||||
const { input, onSubmit } = composer();
|
||||
fireEvent.compositionStart(input);
|
||||
fireEvent.change(input, { target: { value: "核查节点压力" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(input).toHaveValue("核查节点压力");
|
||||
fireEvent.compositionEnd(input);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ text: "核查节点压力" }, expect.anything()));
|
||||
});
|
||||
|
||||
it("keeps Shift+Enter and disabled submissions from sending a message", () => {
|
||||
const { input, onSubmit } = composer(vi.fn(), true);
|
||||
fireEvent.change(input, { target: { value: "保留草稿" } });
|
||||
expect(fireEvent.keyDown(input, { key: "Enter", shiftKey: true })).toBe(true);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(input).toHaveValue("保留草稿");
|
||||
});
|
||||
|
||||
it("lets the owning panel intercept keyboard submission", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<PromptInput onSubmit={onSubmit}>
|
||||
<PromptInputTextarea aria-label="输入问题" onKeyDown={(event) => event.preventDefault()} />
|
||||
<PromptInputSubmit />
|
||||
</PromptInput>);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "保留给宿主处理" } });
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops a running generation without submitting a second prompt", () => {
|
||||
const onSubmit = vi.fn(), onStop = vi.fn();
|
||||
render(<PromptInput onSubmit={onSubmit}>
|
||||
<PromptInputTextarea /><PromptInputSubmit status="streaming" onStop={onStop} />
|
||||
</PromptInput>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
|
||||
expect(onStop).toHaveBeenCalledOnce();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,75 +0,0 @@
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { MotionProps } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import type { CSSProperties, ElementType, JSX } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
||||
|
||||
// Cache motion components at module level to avoid creating during render
|
||||
const motionComponentCache = new Map<
|
||||
keyof JSX.IntrinsicElements,
|
||||
React.ComponentType<MotionHTMLProps>
|
||||
>();
|
||||
|
||||
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
|
||||
let component = motionComponentCache.get(element);
|
||||
if (!component) {
|
||||
component = motion.create(element);
|
||||
motionComponentCache.set(element, component);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
export interface TextShimmerProps {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = getMotionComponent(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
duration,
|
||||
ease: "linear",
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
@@ -22,19 +22,6 @@ const browserHttpUrl = z
|
||||
}
|
||||
});
|
||||
|
||||
const optionalBrowserHttpUrl = z
|
||||
.string()
|
||||
.trim()
|
||||
.default("")
|
||||
.superRefine((value, context) => {
|
||||
if (!value) return;
|
||||
const parsed = browserHttpUrl.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: "必须是安全的 HTTP(S) 地址" });
|
||||
}
|
||||
})
|
||||
.transform((value) => value.replace(/\/+$/, ""));
|
||||
|
||||
const optionalBrowserHttpUrlOrSameOriginPath = z
|
||||
.string()
|
||||
.trim()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const speechVoiceIdSchema = z.string().max(120).regex(/^zh-CN-[a-zA-Z0-9]+Neural$/);
|
||||
export const speechVoiceSchema = z.object({
|
||||
id: speechVoiceIdSchema,
|
||||
name: z.string().min(1).max(200),
|
||||
locale: z.literal("zh-CN"),
|
||||
gender: z.enum(["Female", "Male"])
|
||||
});
|
||||
export const speechVoiceCatalogSchema = z.object({
|
||||
voices: z.array(speechVoiceSchema).min(1).max(2_000),
|
||||
defaultVoice: speechVoiceIdSchema
|
||||
});
|
||||
export type SpeechVoice = z.infer<typeof speechVoiceSchema>;
|
||||
export type SpeechVoiceCatalog = z.infer<typeof speechVoiceCatalogSchema>;
|
||||
@@ -13,6 +13,7 @@ export interface DateTimeFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
interface DateTimeParts {
|
||||
@@ -121,7 +122,7 @@ function moveDay(parts: DateTimeParts, amount: number): DateTimeParts {
|
||||
return { ...parts, year, month: date.getMonth() + 1, day: date.getDate() };
|
||||
}
|
||||
|
||||
export function DateTimeField({ label, value, onChange, disabled = false }: DateTimeFieldProps) {
|
||||
export function DateTimeField({ label, value, onChange, disabled = false, contentClassName }: DateTimeFieldProps) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<DateTimeParts>(
|
||||
@@ -214,7 +215,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[80] bg-slate-950/20 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className="fixed left-1/2 top-1/2 z-[85] max-h-[calc(100dvh-1.5rem)] w-[352px] max-w-[calc(100vw-1.5rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border border-[var(--surface-divider)] bg-[var(--surface-reading)] p-4 shadow-[0_20px_60px_rgba(15,23,42,0.22)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
className={cn("fixed left-1/2 top-1/2 z-[85] max-h-[calc(100dvh-1.5rem)] w-[352px] max-w-[calc(100vw-1.5rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border border-[var(--surface-divider)] bg-[var(--surface-reading)] p-4 shadow-[0_20px_60px_rgba(15,23,42,0.22)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", contentClassName)}
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (sourceInvalid) return;
|
||||
event.preventDefault();
|
||||
@@ -224,6 +225,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
|
||||
<DialogTitle className="pr-8 text-base text-slate-900">选择{label}</DialogTitle>
|
||||
<div className="mt-3 flex min-w-0 items-end gap-2">
|
||||
<FieldSelect
|
||||
contentClassName={contentClassName}
|
||||
label="月份"
|
||||
value={String(draft.month)}
|
||||
options={MONTH_OPTIONS}
|
||||
@@ -349,6 +351,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-[var(--surface-divider)] pt-3">
|
||||
<FieldSelect
|
||||
contentClassName={contentClassName}
|
||||
label="小时"
|
||||
value={pad(draft.hour)}
|
||||
options={HOUR_OPTIONS}
|
||||
@@ -358,6 +361,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
|
||||
}}
|
||||
/>
|
||||
<FieldSelect
|
||||
contentClassName={contentClassName}
|
||||
label="分钟"
|
||||
value={pad(draft.minute)}
|
||||
options={MINUTE_OPTIONS}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/shared/ui/cn"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-hover-card-content-transform-origin)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"form-field flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"form-field flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base text-foreground shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { cn } from "./cn";
|
||||
|
||||
export const Popover = PopoverPrimitive.Root;
|
||||
export const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
export const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-80 max-w-[calc(100vw-1.5rem)] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 motion-reduce:animate-none origin-(--radix-popover-content-transform-origin)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = "PopoverContent";
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createReadingPreview } from "./reading-preview";
|
||||
|
||||
describe("reading previews", () => {
|
||||
it("retains complete content without active controls or duplicate SVG IDs", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = '<table id="values"><tr><td>0.2130 MPa</td></tr></table><svg><defs><marker id="arrow" /></defs><path marker-end="url(#arrow)" /><use href="#arrow" /></svg><input value="现场草稿" />';
|
||||
const preview = createReadingPreview(host);
|
||||
expect(preview.textContent).toContain("0.2130 MPa");
|
||||
expect(preview).toHaveAttribute("inert");
|
||||
expect(preview).toHaveAttribute("aria-hidden", "true");
|
||||
const marker = preview.querySelector("marker")!;
|
||||
expect(marker.id).not.toBe("arrow");
|
||||
expect(preview.querySelector("path")).toHaveAttribute("marker-end", `url(#${marker.id})`);
|
||||
expect(preview.querySelector("use")).toHaveAttribute("href", `#${marker.id}`);
|
||||
expect(preview.querySelector("input")).toHaveValue("现场草稿");
|
||||
expect(host.querySelector("marker")).toHaveAttribute("id", "arrow");
|
||||
const second = createReadingPreview(host);
|
||||
expect(second.querySelector("marker")!.id).not.toBe(marker.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
let previewSequence = 0;
|
||||
|
||||
/** A visible, inert snapshot keeps the original reading context in place. */
|
||||
export function createReadingPreview(host: HTMLElement) {
|
||||
const preview = host.cloneNode(true) as HTMLElement;
|
||||
const prefix = `reading-preview-${++previewSequence}-`;
|
||||
const ids = new Map<string, string>();
|
||||
for (const element of [preview, ...preview.querySelectorAll<HTMLElement>("[id]")]) {
|
||||
if (!element.id) continue;
|
||||
const original = element.id;
|
||||
const next = `${prefix}${ids.size}`;
|
||||
ids.set(original, next);
|
||||
element.id = next;
|
||||
}
|
||||
// Preserve SVG markers and clipping while keeping every cloned ID distinct.
|
||||
for (const element of preview.querySelectorAll("*")) {
|
||||
for (const attribute of [...element.attributes]) {
|
||||
let value = attribute.value;
|
||||
for (const [original, next] of ids) {
|
||||
value = value.replaceAll(`url(#${original})`, `url(#${next})`);
|
||||
if (["href", "xlink:href"].includes(attribute.name) && value === `#${original}`) value = `#${next}`;
|
||||
}
|
||||
if (value !== attribute.value) element.setAttribute(attribute.name, value);
|
||||
}
|
||||
if (element.localName === "style") {
|
||||
let css = element.textContent ?? "";
|
||||
for (const [original, next] of [...ids].sort((a, b) => b[0].length - a[0].length)) css = css.replaceAll(`#${original}`, `#${next}`);
|
||||
element.textContent = css;
|
||||
}
|
||||
}
|
||||
preview.setAttribute("aria-hidden", "true");
|
||||
preview.setAttribute("inert", "");
|
||||
preview.setAttribute("data-reading-preview", "");
|
||||
return preview;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type ReadingDocument = { id: string; title: string; host: HTMLDivElement };
|
||||
export type ReadingDocument = { id: string; title: string; host: HTMLDivElement; restoreFocus?: () => void };
|
||||
export type ReadingWorkspace = {
|
||||
active: ReadingDocument | null;
|
||||
view: "focus" | "map_split" | "map_only";
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import { createReadingPreview } from "./reading-preview";
|
||||
|
||||
import { ReadingWorkspaceContext as Context, useReadingWorkspace, type ReadingDocument, type ReadingWorkspace } from "./reading-workspace-context";
|
||||
|
||||
@@ -29,18 +30,23 @@ export function ReadingWorkspaceProvider({ children }: { children: ReactNode })
|
||||
|
||||
/** One live subtree moves between the conversation/report and the workspace. */
|
||||
export function ExpandableReading({
|
||||
title, children, summary, enabled = true
|
||||
title, children, enabled = true
|
||||
}: { title: string; children: ReactNode; summary?: ReactNode; enabled?: boolean }) {
|
||||
const workspace = useReadingWorkspace();
|
||||
const id = useId();
|
||||
const [host] = useState(() => document.createElement("div"));
|
||||
const inline = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const snapshot = useRef<HTMLElement | null>(null);
|
||||
const wasActive = useRef(false);
|
||||
const expanded = workspace?.active?.id === id;
|
||||
const close = workspace?.close;
|
||||
useLayoutEffect(() => {
|
||||
if (!expanded && inline.current && host.parentElement !== inline.current) inline.current.append(host);
|
||||
if (!expanded && inline.current) {
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = null;
|
||||
if (host.parentElement !== inline.current) inline.current.append(host);
|
||||
}
|
||||
if (wasActive.current && !expanded) trigger.current?.focus({ preventScroll: true });
|
||||
wasActive.current = expanded;
|
||||
}, [expanded, host, enabled]);
|
||||
@@ -48,15 +54,18 @@ export function ExpandableReading({
|
||||
if (!workspace || !enabled) return <>{children}</>;
|
||||
return <section className="min-w-0" data-expandable-reading>
|
||||
<div className="mb-2 flex items-center justify-end">
|
||||
<Button ref={trigger} size="sm" variant="ghost" className="h-9 gap-1.5 text-xs text-muted-foreground"
|
||||
onClick={() => workspace.open({ id, title, host })}>
|
||||
<Maximize2 size={14} aria-hidden />{expanded ? "返回展开内容" : "展开查看"}
|
||||
<Button ref={trigger} size="icon-sm" variant="ghost" className="text-muted-foreground"
|
||||
aria-label={expanded ? "返回展开内容" : "展开查看"} title={expanded ? "返回展开内容" : "展开查看"}
|
||||
onClick={() => {
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = createReadingPreview(host);
|
||||
inline.current?.append(snapshot.current);
|
||||
workspace.open({ id, title, host, restoreFocus: () => trigger.current?.focus({ preventScroll: true }) });
|
||||
}}>
|
||||
<Maximize2 size={15} aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
{expanded ? <div className="rounded-lg border bg-muted/30 p-3 text-xs leading-6 text-muted-foreground">
|
||||
{summary ?? `${title}已在工作区展开。`}
|
||||
</div> : null}
|
||||
<div ref={inline} className={expanded ? "hidden" : "min-w-0"} />
|
||||
<div ref={inline} className="min-w-0" />
|
||||
{createPortal(children, host)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"form-field flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"form-field flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base text-foreground shadow-xs transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -27,3 +27,18 @@ export function formatTimeRange(value: string, durationMinutes: number | undefin
|
||||
}
|
||||
return `${startTime} - ${formatTime(timestamp + durationMinutes * 60_000)}`;
|
||||
}
|
||||
|
||||
/** Calendar keys follow the caller's local clock, independently of display time zone. */
|
||||
export function toLocalDateKey(value: Date) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** Preserve the local-clock presentation used by history and peripheral records. */
|
||||
export function formatLocalMonthDayTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user