mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-10 17:35:57 +00:00
fix(frontend): fallback Streamdown clipboard copy (#3397)
* fix(frontend): fallback streamdown clipboard copy * fix(frontend): address clipboard fallback review * fix(frontend): normalize clipboard fallback rejection * fix(frontend): harden clipboard fallback install * fix(frontend): clarify clipboard fallback errors * fix(frontend): cover clipboard fallback edge cases * fix(frontend): tighten clipboard fallback cleanup * fix(frontend): reduce clipboard fallback copy window * fix(frontend): guard clipboard item fallback install * fix(frontend): clean up clipboard fallback on selection errors * Address clipboard fallback review feedback * fix(frontend): guard clipboard fallback install during SSR
This commit is contained in:
@@ -18,7 +18,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { ClipboardSafeStreamdown } from "./streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
@@ -302,11 +303,13 @@ export const MessageBranchPage = ({
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
export type MessageResponseProps = ComponentProps<
|
||||
typeof ClipboardSafeStreamdown
|
||||
>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
<ClipboardSafeStreamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className,
|
||||
|
||||
@@ -10,9 +10,9 @@ import { cn } from "@/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { reasoningPlugins } from "@/core/streamdown/plugins";
|
||||
import { Shimmer } from "./shimmer";
|
||||
import { ClipboardSafeStreamdown } from "./streamdown";
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean;
|
||||
@@ -178,7 +178,9 @@ export const ReasoningContent = memo(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||
<ClipboardSafeStreamdown {...reasoningPlugins}>
|
||||
{children}
|
||||
</ClipboardSafeStreamdown>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { type ComponentProps } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { installClipboardFallback } from "@/core/clipboard";
|
||||
|
||||
export type ClipboardSafeStreamdownProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
// Only patch browser globals in client context; skip during SSR
|
||||
if (typeof document !== "undefined") {
|
||||
installClipboardFallback();
|
||||
}
|
||||
|
||||
export function ClipboardSafeStreamdown(props: ClipboardSafeStreamdownProps) {
|
||||
return <Streamdown {...props} />;
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import {
|
||||
Artifact,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
ArtifactHeader,
|
||||
ArtifactTitle,
|
||||
} from "@/components/ai-elements/artifact";
|
||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||
import { Select, SelectItem } from "@/components/ui/select";
|
||||
import {
|
||||
SelectContent,
|
||||
@@ -400,13 +400,13 @@ export function ArtifactFilePreview({
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div className="size-full px-4">
|
||||
<Streamdown
|
||||
<ClipboardSafeStreamdown
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{ a: ArtifactLink }}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
</ClipboardSafeStreamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
ChainOfThoughtStep,
|
||||
} from "@/components/ai-elements/chain-of-thought";
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShineBorder } from "@/components/ui/shine-border";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -126,12 +126,12 @@ export function SubtaskCard({
|
||||
{task.prompt && (
|
||||
<ChainOfThoughtStep
|
||||
label={
|
||||
<Streamdown
|
||||
<ClipboardSafeStreamdown
|
||||
{...streamdownPluginsWithWordAnimation}
|
||||
components={{ a: CitationLink }}
|
||||
>
|
||||
{task.prompt}
|
||||
</Streamdown>
|
||||
</ClipboardSafeStreamdown>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Streamdown } from "streamdown";
|
||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||
|
||||
import { aboutMarkdown } from "./about-content";
|
||||
|
||||
export function AboutSettingsPage() {
|
||||
return <Streamdown>{aboutMarkdown}</Streamdown>;
|
||||
return <ClipboardSafeStreamdown>{aboutMarkdown}</ClipboardSafeStreamdown>;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import Link from "next/link";
|
||||
import { useDeferredValue, useId, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -639,12 +639,12 @@ export function MemorySettingsPage() {
|
||||
<div className="text-muted-foreground mb-4 text-sm">
|
||||
{summaryReadOnly}
|
||||
</div>
|
||||
<Streamdown
|
||||
<ClipboardSafeStreamdown
|
||||
className="size-full min-w-0 [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
||||
{...streamdownPlugins}
|
||||
>
|
||||
{summariesToMarkdown(memory, filteredSectionGroups, t)}
|
||||
</Streamdown>
|
||||
</ClipboardSafeStreamdown>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
+246
-19
@@ -1,3 +1,47 @@
|
||||
type ClipboardItemLike = {
|
||||
types?: readonly string[];
|
||||
getType?: (type: string) => Promise<Blob>;
|
||||
items?: Record<string, Blob | string>;
|
||||
};
|
||||
|
||||
function copyTextWithExecCommand(text: string): boolean {
|
||||
const document = globalThis.document;
|
||||
if (
|
||||
typeof document?.createElement !== "function" ||
|
||||
typeof document.body?.appendChild !== "function" ||
|
||||
typeof document.execCommand !== "function"
|
||||
) {
|
||||
throw new Error("Clipboard DOM fallback not available");
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.left = "-9999px";
|
||||
|
||||
let copied = false;
|
||||
let appended = false;
|
||||
try {
|
||||
document.body.appendChild(textarea);
|
||||
appended = true;
|
||||
textarea.select();
|
||||
copied = document.execCommand("copy");
|
||||
} finally {
|
||||
if (appended) {
|
||||
const parentNode = textarea.parentNode;
|
||||
if (typeof textarea.remove === "function") {
|
||||
textarea.remove();
|
||||
} else if (typeof parentNode?.removeChild === "function") {
|
||||
parentNode.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
export async function writeTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
const clipboard = globalThis.navigator?.clipboard;
|
||||
@@ -6,26 +50,209 @@ export async function writeTextToClipboard(text: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
const document = globalThis.document;
|
||||
if (!document?.body?.appendChild || !document.execCommand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} finally {
|
||||
textarea.remove();
|
||||
}
|
||||
return copyTextWithExecCommand(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackWriteText(text: string): Promise<void> {
|
||||
try {
|
||||
if (!copyTextWithExecCommand(text)) {
|
||||
return Promise.reject(new Error("Clipboard copy command failed"));
|
||||
}
|
||||
} catch (error) {
|
||||
return Promise.reject(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function hasUsableClipboardItem(): boolean {
|
||||
return typeof globalThis.ClipboardItem === "function";
|
||||
}
|
||||
|
||||
async function readPlainTextFromClipboardItem(
|
||||
item: ClipboardItemLike,
|
||||
): Promise<string> {
|
||||
const plainText = item.items?.["text/plain"];
|
||||
if (typeof plainText === "string") {
|
||||
return plainText;
|
||||
}
|
||||
if (plainText instanceof Blob) {
|
||||
return await plainText.text();
|
||||
}
|
||||
|
||||
if (item.types && !item.types.includes("text/plain")) {
|
||||
throw new Error("Clipboard item is missing text/plain data");
|
||||
}
|
||||
|
||||
if (typeof item.getType !== "function") {
|
||||
throw new Error("Clipboard item cannot read text/plain data");
|
||||
}
|
||||
|
||||
const blob = await item.getType("text/plain");
|
||||
if (blob instanceof Blob) {
|
||||
return await blob.text();
|
||||
}
|
||||
|
||||
throw new Error("Clipboard item text/plain data is not a Blob");
|
||||
}
|
||||
|
||||
function canDefineNavigatorClipboard(
|
||||
navigator: Navigator,
|
||||
descriptor: PropertyDescriptor | undefined,
|
||||
): boolean {
|
||||
if (descriptor) {
|
||||
return descriptor.configurable === true;
|
||||
}
|
||||
return Object.isExtensible(navigator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs browser clipboard fallbacks for Streamdown copy controls by patching
|
||||
* missing navigator.clipboard methods and ClipboardItem when the host permits it.
|
||||
*/
|
||||
export function installClipboardFallback(): void {
|
||||
const navigator = globalThis.navigator;
|
||||
if (!navigator) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawClipboard = navigator.clipboard;
|
||||
const clipboard =
|
||||
typeof rawClipboard === "object" && rawClipboard !== null
|
||||
? (rawClipboard as Partial<Clipboard>)
|
||||
: undefined;
|
||||
const clipboardDescriptor = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
"clipboard",
|
||||
);
|
||||
const hasWriteText = typeof clipboard?.writeText === "function";
|
||||
const hasWrite = typeof clipboard?.write === "function";
|
||||
const hasClipboardItem = hasUsableClipboardItem();
|
||||
|
||||
if (hasWriteText && hasWrite && hasClipboardItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const writeText = hasWriteText
|
||||
? clipboard.writeText!.bind(clipboard)
|
||||
: fallbackWriteText;
|
||||
const write = hasWrite
|
||||
? clipboard.write!.bind(clipboard)
|
||||
: (items: ClipboardItemLike[]) => {
|
||||
const firstItem = items[0];
|
||||
if (!firstItem) {
|
||||
return Promise.reject(new Error("Clipboard item not available"));
|
||||
}
|
||||
|
||||
return readPlainTextFromClipboardItem(firstItem).then(writeText);
|
||||
};
|
||||
|
||||
const fallbackClipboard = clipboard ?? {};
|
||||
|
||||
try {
|
||||
const missingMethods: PropertyDescriptorMap = {};
|
||||
if (!hasWrite) {
|
||||
missingMethods.write = {
|
||||
configurable: true,
|
||||
value: write,
|
||||
writable: true,
|
||||
};
|
||||
}
|
||||
if (!hasWriteText) {
|
||||
missingMethods.writeText = {
|
||||
configurable: true,
|
||||
value: writeText,
|
||||
writable: true,
|
||||
};
|
||||
}
|
||||
|
||||
Object.defineProperties(fallbackClipboard, missingMethods);
|
||||
|
||||
if (
|
||||
!clipboard &&
|
||||
canDefineNavigatorClipboard(navigator, clipboardDescriptor)
|
||||
) {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: fallbackClipboard,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!canDefineNavigatorClipboard(navigator, clipboardDescriptor)) {
|
||||
// The ClipboardItem fallback below is independent from navigator.clipboard.
|
||||
if (hasClipboardItem) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const replacement = Object.create(clipboard ?? null);
|
||||
for (const methodName of ["read", "readText"] as const) {
|
||||
const method = clipboard?.[methodName];
|
||||
if (typeof method === "function") {
|
||||
Object.defineProperty(replacement, methodName, {
|
||||
configurable: true,
|
||||
value: method.bind(clipboard),
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.defineProperties(replacement, {
|
||||
write: {
|
||||
configurable: true,
|
||||
value: write,
|
||||
writable: true,
|
||||
},
|
||||
writeText: {
|
||||
configurable: true,
|
||||
value: writeText,
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: replacement,
|
||||
});
|
||||
} catch {
|
||||
// The ClipboardItem fallback below is independent from navigator.clipboard.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasClipboardItem) {
|
||||
class ClipboardItemFallback {
|
||||
items: Record<string, Blob | string>;
|
||||
types: string[];
|
||||
|
||||
constructor(items: Record<string, Blob | string>) {
|
||||
this.items = items;
|
||||
this.types = Object.keys(items);
|
||||
}
|
||||
|
||||
getType(type: string): Promise<Blob> {
|
||||
const value = this.items[type];
|
||||
if (value instanceof Blob) {
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return Promise.resolve(new Blob([value], { type }));
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error(`Clipboard item is missing ${type} data`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Object.defineProperty(globalThis, "ClipboardItem", {
|
||||
configurable: true,
|
||||
value: ClipboardItemFallback,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user