* fix(app): preserve formatting when copying assistant selections Browser selection serialized React Native Web layout nodes instead of the assistant message semantics. Recover semantic markup from renderer-owned metadata and reuse the rich clipboard generator used by Copy turn. * fix(app): preserve assistant copy semantics Carry source-only Markdown metadata through the selection copy boundary, unwrap generated code links, and cover ordered starts, hard breaks, and fence languages in Playwright. Refresh the Nix dependency hash for the added clipboard dependency. * fix(app): preserve partial selection context Wrap selected fragments with their shared semantic ancestor chain and enable GFM table and strikethrough conversion. Cover partial nested selections and GFM output through the browser clipboard. * fix(app): preserve copied list and autolink text Adjust partial ordered-list starts for omitted siblings, strip parser-generated anchors before Markdown reconstruction, and refresh the Nix dependency hash computed by CI. * fix(app): normalize partial selection context Normalize cloned ordered lists across block boundaries, degrade incomplete table fragments to selected text, and preserve table alignment provenance through Markdown reconstruction. * fix(app): escape copied table delimiters Rendered table cells no longer retain source escape characters. Escape literal pipes during DOM-to-Markdown conversion so copied tables preserve their column structure. * fix(app): preserve authored link semantics The rendered link boundary conflated parser-generated links with authored autolinks and discarded authored titles. Carry title semantics into the anchor and unwrap only links with explicit generated provenance. * fix(app): preserve nested copy structure Blank-line splitting separated loose nested lists before Markdown rendering, leaving no nesting for clipboard serialization to preserve. Derive protected separators from the Markdown block structure and keep copy metadata independent from monospace surface styling. * fix(app): preserve selected file links Assistant messages and rich clipboard HTML must apply the same link validation policy. A shared parser keeps file links semantic in both MIME representations while retaining markdown-it's unsafe-scheme rejection. * fix(app): preserve partial table content A selected body row wider than its retained header cannot be represented as GFM and is truncated when rich HTML is regenerated. Treat only those over-wide fragments, plus headerless fragments, as incomplete while preserving valid short rows.
246 lines
7.6 KiB
TypeScript
246 lines
7.6 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import { MarkdownTextSpan } from "@/components/markdown-text";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import { Check, Copy } from "lucide-react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { HighlightToken } from "@getpaseo/highlight";
|
|
import { isNative, isWeb } from "@/constants/platform";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
|
|
import {
|
|
markdownCopyCodeBlockDataSet,
|
|
markdownCopyDataSet,
|
|
} from "@/assistant-selection-copy/markup";
|
|
|
|
interface HighlightedCodeBlockProps {
|
|
code: string;
|
|
language: string | null | undefined;
|
|
inheritedStyles: TextStyle;
|
|
textStyle: TextStyle;
|
|
}
|
|
|
|
// Fence info strings ("```ts", "```typescript", "```ts {1,3}") map to the
|
|
// extension-based parser table in @getpaseo/highlight. Aliases here only
|
|
// cover names that don't already match an extension key in parsers.ts.
|
|
const LANGUAGE_ALIASES: Record<string, string> = {
|
|
typescript: "ts",
|
|
javascript: "js",
|
|
python: "py",
|
|
rust: "rs",
|
|
golang: "go",
|
|
"c++": "cpp",
|
|
csharp: "cs",
|
|
"c#": "cs",
|
|
objc: "m",
|
|
"objective-c": "m",
|
|
markdown: "md",
|
|
elixir: "ex",
|
|
};
|
|
|
|
function fenceLanguageToExtension(info: string | null | undefined): string | null {
|
|
if (!info) return null;
|
|
const first = info.trim().split(/\s+/)[0]?.toLowerCase();
|
|
if (!first) return null;
|
|
const normalized = first.replace(/^\./, "");
|
|
return LANGUAGE_ALIASES[normalized] ?? normalized;
|
|
}
|
|
|
|
function stripTerminalFenceNewline(code: string): string {
|
|
return code.endsWith("\n") ? code.slice(0, -1) : code;
|
|
}
|
|
|
|
export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
|
code,
|
|
language,
|
|
inheritedStyles,
|
|
textStyle,
|
|
}: HighlightedCodeBlockProps) {
|
|
// Box styles (bg / padding / border / radius / margin) go on the wrapper View
|
|
// so the absolute copy button positions relative to the visible code area,
|
|
// not to a parent that includes the Text's own marginVertical.
|
|
const { containerStyle, innerTextStyle } = useMemo(
|
|
() => splitFenceStyle(inheritedStyles, textStyle),
|
|
[inheritedStyles, textStyle],
|
|
);
|
|
const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]);
|
|
const copyDataSet = useMemo(
|
|
() => ({ ...CODE_SURFACE_DATASET, ...markdownCopyCodeBlockDataSet(language) }),
|
|
[language],
|
|
);
|
|
|
|
const keyedLines = useMemo<KeyedLine[] | null>(
|
|
() => highlightToKeyedLines(renderedCode, fenceLanguageToExtension(language)),
|
|
[renderedCode, language],
|
|
);
|
|
|
|
const isCompact = useIsCompactFormFactor();
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
|
const controlsVisible = isHovered || isNative || isCompact;
|
|
const getCode = useCallback(() => code, [code]);
|
|
|
|
return (
|
|
<View
|
|
style={containerStyle}
|
|
dataSet={copyDataSet}
|
|
onPointerEnter={handlePointerEnter}
|
|
onPointerLeave={handlePointerLeave}
|
|
>
|
|
{keyedLines ? (
|
|
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
|
|
{renderCodeSegments(keyedLines)}
|
|
</MarkdownTextSpan>
|
|
) : (
|
|
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
|
|
{renderedCode}
|
|
</MarkdownTextSpan>
|
|
)}
|
|
<CopyButton getCode={getCode} visible={controlsVisible} />
|
|
</View>
|
|
);
|
|
});
|
|
|
|
function renderCodeSegments(keyedLines: KeyedLine[]): React.ReactNode[] {
|
|
const segments: React.ReactNode[] = [];
|
|
for (let lineIndex = 0; lineIndex < keyedLines.length; lineIndex += 1) {
|
|
const line = keyedLines[lineIndex];
|
|
if (lineIndex > 0) {
|
|
segments.push(<CodeTextSpan key={`${line.key}-newline`} text={"\n"} />);
|
|
}
|
|
for (const { key, token } of line.tokens) {
|
|
segments.push(<TokenSpan key={`${line.key}-${key}`} token={token} />);
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
interface TokenSpanProps {
|
|
token: HighlightToken;
|
|
}
|
|
|
|
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
|
|
return (
|
|
<MarkdownTextSpan style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
|
{token.text}
|
|
</MarkdownTextSpan>
|
|
);
|
|
});
|
|
|
|
interface CodeTextSpanProps {
|
|
text: string;
|
|
}
|
|
|
|
const CodeTextSpan = React.memo(function CodeTextSpan({ text }: CodeTextSpanProps) {
|
|
return <MarkdownTextSpan>{text}</MarkdownTextSpan>;
|
|
});
|
|
|
|
interface SplitStyles {
|
|
containerStyle: StyleProp<ViewStyle>;
|
|
innerTextStyle: StyleProp<TextStyle>;
|
|
}
|
|
|
|
const CONTAINER_BASE: ViewStyle = { position: "relative" };
|
|
const WEB_SELECTABLE: TextStyle = isWeb ? ({ userSelect: "text" } as TextStyle) : {};
|
|
|
|
function splitFenceStyle(inheritedStyles: TextStyle, textStyle: TextStyle): SplitStyles {
|
|
const { fontFamily, fontSize, color, ...box } = textStyle;
|
|
const textOnly: TextStyle = { ...WEB_SELECTABLE };
|
|
if (fontFamily !== undefined) textOnly.fontFamily = fontFamily;
|
|
if (fontSize !== undefined) textOnly.fontSize = fontSize;
|
|
if (fontSize !== undefined) textOnly.lineHeight = Math.round(fontSize * 1.45);
|
|
if (color !== undefined) textOnly.color = color;
|
|
return {
|
|
containerStyle: [box as ViewStyle, CONTAINER_BASE],
|
|
innerTextStyle: [inheritedStyles, textOnly],
|
|
};
|
|
}
|
|
|
|
interface CopyButtonProps {
|
|
getCode: () => string;
|
|
visible: boolean;
|
|
}
|
|
|
|
const COPIED_RESET_MS = 1500;
|
|
|
|
const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) {
|
|
const { t } = useTranslation();
|
|
const [copied, setCopied] = useState(false);
|
|
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (resetRef.current) clearTimeout(resetRef.current);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handlePress = useCallback(async () => {
|
|
const content = getCode();
|
|
if (!content) return;
|
|
await Clipboard.setStringAsync(content);
|
|
setCopied(true);
|
|
if (resetRef.current) clearTimeout(resetRef.current);
|
|
resetRef.current = setTimeout(() => {
|
|
setCopied(false);
|
|
resetRef.current = null;
|
|
}, COPIED_RESET_MS);
|
|
}, [getCode]);
|
|
|
|
const visibilityStyle = visible
|
|
? copyButtonStyles.containerVisible
|
|
: copyButtonStyles.containerHidden;
|
|
const wrapperStyle = useMemo(
|
|
() => [copyButtonStyles.container, visibilityStyle],
|
|
[visibilityStyle],
|
|
);
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={handlePress}
|
|
style={wrapperStyle}
|
|
pointerEvents={visible ? "auto" : "none"}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={copied ? t("message.actions.copied") : t("message.actions.copyCode")}
|
|
hitSlop={8}
|
|
dataSet={markdownCopyDataSet.ignore}
|
|
>
|
|
{({ hovered }) => {
|
|
const iconColor = hovered
|
|
? copyButtonStyles.iconHoveredColor.color
|
|
: copyButtonStyles.iconColor.color;
|
|
return copied ? (
|
|
<Check size={14} color={iconColor} />
|
|
) : (
|
|
<Copy size={14} color={iconColor} />
|
|
);
|
|
}}
|
|
</Pressable>
|
|
);
|
|
});
|
|
|
|
const copyButtonStyles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
position: "absolute",
|
|
top: theme.spacing[2],
|
|
right: theme.spacing[2],
|
|
padding: theme.spacing[1],
|
|
},
|
|
containerVisible: {
|
|
opacity: 1,
|
|
},
|
|
containerHidden: {
|
|
opacity: 0,
|
|
},
|
|
iconColor: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
iconHoveredColor: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
}));
|