feat(editor): copy selected SQL as rich text with highlighting

This commit is contained in:
邹超 authored and GitHub committed 2026-09-14 13:24:10 +08:00
1 parent 94617a5923
commit 2249aa30c0
17 files changed
+339 -4

No files matched your search

+70 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { clipboardLineEndings, copyToClipboard, type ClipboardEnvironment } from "@/lib/common/clipboard";
import { clipboardLineEndings, copyRichTextToClipboard, copyToClipboard, type ClipboardEnvironment } from "@/lib/common/clipboard";
afterEach(() => {
vi.restoreAllMocks();
@@ -87,3 +87,72 @@ describe("clipboardLineEndings", () => {
expect(clipboardLineEndings("a\nb", "linux")).toBe("a\nb");
});
});
class FakeBlob {
constructor(
readonly parts: readonly string[],
readonly options?: { type?: string },
) {}
}
class FakeClipboardItem {
constructor(readonly items: Record<string, unknown>) {}
}
function richClipboardEnvironment(write = vi.fn().mockResolvedValue(undefined)) {
const env: ClipboardEnvironment = {
navigator: { clipboard: { write } },
ClipboardItem: FakeClipboardItem,
Blob: FakeBlob,
};
return { env, write };
}
describe("copyRichTextToClipboard", () => {
it("writes an html flavor and a plain-text flavor in one item", async () => {
const { env, write } = richClipboardEnvironment();
await copyRichTextToClipboard('<pre style="color:#000">SELECT</pre>', "SELECT", env);
expect(write).toHaveBeenCalledTimes(1);
const [items] = write.mock.calls[0]!;
const [item] = items as FakeClipboardItem[];
const flavors = item!.items;
expect(flavors["text/html"]).toMatchObject({ parts: ['<pre style="color:#000">SELECT</pre>'], options: { type: "text/html" } });
expect(flavors["text/plain"]).toMatchObject({ parts: ["SELECT"], options: { type: "text/plain" } });
});
it("normalizes the plain-text flavor to CRLF on Windows", async () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
const { env, write } = richClipboardEnvironment();
await copyRichTextToClipboard("<pre>SELECT 1\nFROM t</pre>", "SELECT 1\nFROM t", env);
const [items] = write.mock.calls[0]!;
const [item] = items as FakeClipboardItem[];
expect(item!.items["text/html"]).toMatchObject({ parts: ["<pre>SELECT 1\nFROM t</pre>"] });
expect(item!.items["text/plain"]).toMatchObject({ parts: ["SELECT 1\r\nFROM t"] });
});
it("falls back to plain text when the async clipboard rejects the rich write", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const env: ClipboardEnvironment = {
navigator: { clipboard: { write: vi.fn().mockRejectedValue(new Error("denied")), writeText } },
ClipboardItem: FakeClipboardItem,
Blob: FakeBlob,
};
await copyRichTextToClipboard("<pre>SELECT</pre>", "SELECT", env);
expect(writeText).toHaveBeenCalledWith("SELECT");
});
it("falls back to the legacy copy path when no rich clipboard api exists", async () => {
const { env, textarea } = legacyClipboardEnvironment();
await copyRichTextToClipboard("<pre>SELECT 1;</pre>", "SELECT 1;", env);
expect(textarea.value).toBe("SELECT 1;");
expect(env.document?.execCommand).toHaveBeenCalledWith("copy");
});
});
+57
View File
@@ -4,6 +4,7 @@ import { getPlatform, type Platform } from "@/lib/backend/platform";
interface ClipboardApi {
readText?: () => Promise<string> | string;
writeText?: (text: string) => Promise<void> | void;
write?: (items: readonly unknown[]) => Promise<void> | void;
}
interface ClipboardNavigator {
@@ -38,9 +39,17 @@ interface ClipboardDocument {
execCommand?(command: string): boolean;
}
/** Structural stand-ins for the DOM constructors used by the rich-text path. */
type ClipboardItemConstructor = new (items: Record<string, unknown>) => unknown;
type ClipboardBlobConstructor = new (parts: readonly string[], options?: { type?: string }) => unknown;
export interface ClipboardEnvironment {
navigator?: ClipboardNavigator;
document?: ClipboardDocument;
/** Injected for tests; resolves from `globalThis` in the app. */
ClipboardItem?: ClipboardItemConstructor;
/** Injected for tests; resolves from `globalThis` in the app. */
Blob?: ClipboardBlobConstructor;
}
/**
@@ -202,3 +211,51 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment =
container.removeChild(textarea);
}
}
/**
* Write both an HTML flavor and a plain-text flavor to the system clipboard.
*
* Rich-text targets (email clients, Word, chat apps) pick up `text/html` and
* keep the formatting, while plain-text targets fall back to `text` — so the
* same copy works everywhere.
*
* Order of attempts:
* 1. Tauri `clipboard-manager` `writeHtml`, whose `altText` argument is the
* plain-text flavor.
* 2. Web `navigator.clipboard.write` with a two-flavor `ClipboardItem`.
* 3. Plain text via {@link copyToClipboard}. Rich writes need a secure
* context, and a non-secure web deployment should still copy something
* rather than fail.
*/
export async function copyRichTextToClipboard(html: string, text: string, env: ClipboardEnvironment = globalThis as unknown as ClipboardEnvironment): Promise<void> {
text = clipboardLineEndings(text);
if (isTauriRuntime(env as unknown as Record<string, unknown>)) {
try {
const { writeHtml } = await import("@tauri-apps/plugin-clipboard-manager");
await writeHtml(html, text);
recordClipboardWrite();
return;
} catch {
// Fall through to the Web Clipboard rich-text path.
}
}
try {
const ClipboardItem = env.ClipboardItem;
const Blob = env.Blob;
if (env.navigator?.clipboard?.write && typeof ClipboardItem === "function" && typeof Blob === "function") {
await env.navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
"text/plain": new Blob([text], { type: "text/plain" }),
}),
]);
recordClipboardWrite();
return;
}
} catch {
// Fall through to the plain-text path so the copy still succeeds.
}
await copyToClipboard(text, env);
}